| """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" |
| 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") |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| if speed_lines: |
| j2, _ = project(Pf, cam, W, H) |
| cx, cy = j2.mean(axis=0) |
| rng = np.random.default_rng(7) |
| 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) |
| |
| 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]))) |
|
|
| |
| 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) |
| 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) |
| |
| 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)) |
| |
| 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") |
|
|
|
|
| |
|
|
| 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" |
| 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) |
| |
| 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 |
|
|