Spaces:
Running on Zero
Running on Zero
| """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 | |