"""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