karate-wiener / comic_compose.py
polats's picture
Deploy Karate Wiener (kimodo kata maker)
2026366 verified
Raw
History Blame
8.96 kB
"""4-koma compositor for the stance comics. render_panel draws one skeleton frame
through a named shot (comic_shots) as an ink figure on dark paper; compose_koma
stacks 4 panels into a phone-proportioned vertical strip with speech bubbles,
SFX, a reserved Weiner-sticker slot per panel, and a footer stamp. Captions are
caller-supplied (templates for now, Weiner's LLM later)."""
import io
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
from PIL import Image, ImageDraw, ImageFont
from comic_shots import PARENTS, LEFTJ, RIGHTJ, HEAD, make_camera, project, ground_grid
INK = "#f3ead8" # warm ink on dark paper (matches the app's dark viewer)
PAPER_TOP = (44, 42, 48)
PAPER_BOT = (24, 23, 27)
LEFT_TINT = "#9fc2ff"
RIGHT_TINT = "#ffab9b"
GRID_COL = "#8a8276"
BORDER = "#0c0b0d"
PANEL_W, PANEL_H = 660, 470
def _blend(hex_a, hex_b, t):
a = np.array([int(hex_a[i:i + 2], 16) for i in (1, 3, 5)], float)
b = np.array([int(hex_b[i:i + 2], 16) for i in (1, 3, 5)], float)
c = (a * (1 - t) + b * t).astype(int)
return "#%02x%02x%02x" % tuple(c)
def render_panel(Pf, shot, W=PANEL_W, H=PANEL_H, speed_lines=False):
"""One skeleton frame [22,3] -> PIL Image, framed by the named shot."""
Pf = np.asarray(Pf, np.float32)
cam = make_camera(shot, Pf)
dpi = 100
fig = plt.figure(figsize=(W / dpi, H / dpi), dpi=dpi)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_xlim(0, W); ax.set_ylim(H, 0); ax.axis("off")
# paper: vertical gradient
grad = np.linspace(0, 1, 64).reshape(-1, 1)
grad = np.dstack([(PAPER_TOP[i] + (PAPER_BOT[i] - PAPER_TOP[i]) * grad) / 255.0 for i in range(3)])
ax.imshow(grad, extent=[0, W, H, 0], aspect="auto", zorder=0)
# floor grid, faded by depth
center = (Pf.min(axis=0) + Pf.max(axis=0)) / 2.0
for a, b in ground_grid(center):
(p2, z) = project(np.array([a, b]), cam, W, H)
alpha = float(np.clip(0.55 - 0.05 * z.mean(), 0.12, 0.55))
ax.plot(p2[:, 0], p2[:, 1], color=GRID_COL, lw=1.2, alpha=alpha, zorder=1)
# speed lines (gag/action): radial strokes from the panel edge toward the figure
if speed_lines:
j2, _ = project(Pf, cam, W, H)
cx, cy = j2.mean(axis=0)
rng = np.random.default_rng(7) # fixed seed: deterministic art
for ang in np.linspace(0, 2 * np.pi, 30, endpoint=False):
r0 = max(W, H) * 0.80
r1 = r0 * rng.uniform(0.50, 0.66)
x0, y0 = cx + r0 * np.cos(ang), cy + r0 * np.sin(ang)
x1, y1 = cx + r1 * np.cos(ang), cy + r1 * np.sin(ang)
ax.plot([x0, x1], [y0, y1], color=INK, lw=2.0, alpha=0.40, zorder=2)
j2, jz = project(Pf, cam, W, H)
# px-per-meter near the head, for sizing the head circle and end caps
head3 = Pf[HEAD]
probe, _ = project(np.array([head3, head3 + np.array([0, 0.12, 0])]), cam, W, H)
head_r = max(6.0, float(np.linalg.norm(probe[1] - probe[0])))
# bones far-to-near so nearer limbs overdraw
order = sorted([(j, p) for j, p in enumerate(PARENTS) if p >= 0],
key=lambda jp: -(jz[jp[0]] + jz[jp[1]]) / 2.0)
zmin, zmax = jz.min(), jz.max()
for j, p in order:
t = 0.0 if zmax - zmin < 1e-6 else float((jz[j] + jz[p]) / 2.0 - zmin) / (zmax - zmin)
col = INK
if j in LEFTJ:
col = _blend(INK, LEFT_TINT, 0.45)
elif j in RIGHTJ:
col = _blend(INK, RIGHT_TINT, 0.45)
col = _blend(col, "#3a3a40", 0.35 * t) # recede with depth
lw = 7.0 - 2.2 * t
ax.plot([j2[p, 0], j2[j, 0]], [j2[p, 1], j2[j, 1]], "-", color=BORDER,
lw=lw + 3.0, solid_capstyle="round", zorder=3)
ax.plot([j2[p, 0], j2[j, 0]], [j2[p, 1], j2[j, 1]], "-", color=col,
lw=lw, solid_capstyle="round", zorder=4)
# head
ax.add_patch(Circle((j2[HEAD, 0], j2[HEAD, 1]), head_r * 1.05, facecolor=BORDER, zorder=5))
ax.add_patch(Circle((j2[HEAD, 0], j2[HEAD, 1]), head_r * 0.85, facecolor=INK, zorder=6))
# hands / feet nubs
for j in (20, 21, 10, 11):
col = _blend(INK, LEFT_TINT if j in LEFTJ else RIGHT_TINT, 0.45)
ax.add_patch(Circle((j2[j, 0], j2[j, 1]), head_r * 0.32, facecolor=col,
edgecolor=BORDER, lw=1.5, zorder=6))
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi)
plt.close(fig)
buf.seek(0)
return Image.open(buf).convert("RGB")
# ---------------------------------------------------------------- strip assembly
def _font(size, bold=False):
for name in (("comicbd.ttf",) if bold else ("comic.ttf",)) + ("arialbd.ttf" if bold else "arial.ttf",):
try:
return ImageFont.truetype("C:/Windows/Fonts/" + name, size)
except OSError:
continue
return ImageFont.load_default()
def _wrap(draw, text, font, max_w):
words, lines, cur = text.split(), [], ""
for w in words:
trial = (cur + " " + w).strip()
if draw.textlength(trial, font=font) <= max_w:
cur = trial
else:
if cur:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
return lines
def _bubble(img, draw, text, anchor_xy, panel_box, speaker):
"""Speech bubble in the panel's upper strip; tail points down toward the figure."""
x0, y0, x1, y1 = panel_box
font = _font(21)
pad, max_w = 12, int((x1 - x0) * 0.62)
lines = _wrap(draw, text, font, max_w - 2 * pad)
line_h = font.size + 5
bw = max(draw.textlength(l, font=font) for l in lines) + 2 * pad
bh = len(lines) * line_h + 2 * pad
on_left = speaker != "student" # weiner speaks from the left, student from the right
bx = x0 + 14 if on_left else x1 - 14 - bw
by = y0 + 12
fill = "#f6f1e4" if speaker != "student" else "#e8f0fb"
draw.rounded_rectangle([bx, by, bx + bw, by + bh], radius=13, fill=fill,
outline=BORDER, width=3)
tx = bx + bw * (0.28 if on_left else 0.72)
draw.polygon([(tx - 9, by + bh - 2), (tx + 9, by + bh - 2), (tx + (0 if on_left else 4), by + bh + 16)],
fill=fill, outline=BORDER)
for i, l in enumerate(lines):
draw.text((bx + pad, by + pad + i * line_h), l, font=font, fill="#17151a")
def _sfx(img, draw, text, panel_box):
x0, y0, x1, y1 = panel_box
font = _font(54, bold=True)
tw = draw.textlength(text, font=font)
# rotated SFX on its own layer so it can tilt like a stamp
lay = Image.new("RGBA", (int(tw) + 40, 90), (0, 0, 0, 0))
ld = ImageDraw.Draw(lay)
for dx in (-3, 3):
for dy in (-3, 3):
ld.text((20 + dx, 12 + dy), text, font=font, fill=BORDER)
ld.text((20, 12), text, font=font, fill="#ffd24a")
lay = lay.rotate(9, expand=True, resample=Image.BICUBIC)
img.paste(lay, (int(x1 - lay.width - 8), int(y1 - lay.height - 6)), lay)
def _sticker_slot(draw, panel_box, expression):
"""Reserved corner box for the Karate Weiner sticker (art pending)."""
x0, y0, x1, y1 = panel_box
s = 108
bx, by = x0 + 12, y1 - 12 - s
draw.rounded_rectangle([bx, by, bx + s, by + s], radius=12, outline="#bba76f", width=2)
try:
emoji = ImageFont.truetype("C:/Windows/Fonts/seguiemj.ttf", 44)
draw.text((bx + s / 2, by + s / 2 - 12), "🌭", font=emoji, anchor="mm", embedded_color=True)
except OSError:
draw.text((bx + s / 2, by + s / 2 - 12), "KW", font=_font(34, bold=True), fill="#bba76f", anchor="mm")
draw.text((bx + s / 2, by + s - 16), expression, font=_font(15), fill="#bba76f", anchor="mm")
def compose_koma(panels, title, footer, out_w=720):
"""panels: 4 dicts {img, caption, speaker, sfx?, weiner} -> one vertical strip."""
gutter, border = 14, 4
header_h, footer_h = 86, 56
pw = out_w - 2 * gutter
ph = int(pw * PANEL_H / PANEL_W)
H = header_h + 4 * (ph + gutter) + footer_h
img = Image.new("RGB", (out_w, H), "#141318")
draw = ImageDraw.Draw(img)
draw.text((out_w / 2, 30), "KARATE WEINER", font=_font(34, bold=True), fill=INK, anchor="mm")
draw.text((out_w / 2, 64), title, font=_font(19), fill="#bba76f", anchor="mm")
y = header_h
for p in panels:
pim = p["img"].resize((pw, ph), Image.LANCZOS)
img.paste(pim, (gutter, y))
box = (gutter, y, gutter + pw, y + ph)
draw.rectangle(box, outline=BORDER, width=border)
draw.rectangle([box[0] - 1, box[1] - 1, box[2] + 1, box[3] + 1], outline="#5a5345", width=1)
_sticker_slot(draw, box, p.get("weiner", ""))
if p.get("caption"):
_bubble(img, draw, p["caption"], None, box, p.get("speaker", "weiner"))
if p.get("sfx"):
_sfx(img, draw, p["sfx"], box)
y += ph + gutter
draw.text((out_w / 2, H - footer_h / 2 - 4), footer, font=_font(17), fill="#8d8676", anchor="mm")
return img