File size: 8,959 Bytes
76d7f3c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2026366
 
76d7f3c
 
 
 
 
 
2026366
 
 
76d7f3c
 
2026366
76d7f3c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2026366
 
 
 
 
76d7f3c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""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