File size: 12,675 Bytes
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
"""Procedural pixel-art renderer (Pillow).

Draws noir character portraits, room scenes, and evidence props on a small grid and
upscales with nearest-neighbour for crisp Terraria-style pixels. Fully deterministic
from a key + descriptor, so the same suspect always looks the same, and zero AI model
runs to draw them - it is the only visual path.
"""

from __future__ import annotations

from PIL import Image

from ..schemas.visual import VisualDescriptor
from .palette import Palette, build_palette, seed_from

CELL = 12
PORTRAIT_W, PORTRAIT_H = 22, 26
SCENE_W, SCENE_H = 40, 24
PROP_W, PROP_H = 16, 16

RGBA = tuple[int, int, int, int]
_LIPSTICK: RGBA = (178, 64, 78, 255)  # female lip colour, distinct from male


def _grid(w: int, h: int, fill: RGBA) -> tuple[Image.Image, object]:
    img = Image.new("RGBA", (w, h), fill)
    return img, img.load()


def _upscale(img: Image.Image, frame: RGBA | None = None) -> Image.Image:
    big = img.resize((img.width * CELL, img.height * CELL), Image.NEAREST)
    if frame is not None:
        px = big.load()
        for x in range(big.width):
            px[x, 0] = frame
            px[x, big.height - 1] = frame
        for y in range(big.height):
            px[0, y] = frame
            px[big.width - 1, y] = frame
    return big


# Frame order in the animation sheet. CSS plays these via background-position steps.
PORTRAIT_FRAMES: tuple[str, ...] = ("neutral", "blink", "talk")


def _draw_portrait_frame(px, ox: int, pal: Palette, seed: int, descriptor: VisualDescriptor,
                         variant: str) -> None:  # type: ignore[no-untyped-def]
    """Draw one portrait frame into a sheet starting at column ``ox``.

    Body/head/hat are identical across frames; only eyes (blink) and mouth (talk)
    differ, so the animation reads as the same person."""
    cx = PORTRAIT_W // 2

    def put(x: int, y: int, c: RGBA) -> None:
        if 0 <= x < PORTRAIT_W and 0 <= y < PORTRAIT_H:
            px[ox + x, y] = c

    def sym(x: int, y: int, c: RGBA) -> None:
        put(x, y, c)
        put(PORTRAIT_W - 1 - x, y, c)

    for row, y in enumerate(range(PORTRAIT_H - 8, PORTRAIT_H)):
        for x in range(cx - 4 - row, cx + 1):
            sym(x, y, pal.cloth)
    for y in range(PORTRAIT_H - 7, PORTRAIT_H - 2):
        sym(cx - 2, y, pal.cloth_dark)
    for y in range(PORTRAIT_H - 7, PORTRAIT_H - 4):
        sym(cx - 1, y, pal.parchment)
    for y in range(PORTRAIT_H - 7, PORTRAIT_H - 3):
        put(cx, y, pal.accent)
        put(cx - 1, y, pal.accent)

    head_top, head_h, hw = 7, 9, 4 + ((seed >> 5) & 1)
    for y in range(head_top + head_h - 1, head_top + head_h + 1):
        sym(cx - 1, y, pal.skin)
    for y in range(head_top, head_top + head_h):
        narrow = 1 if y in (head_top, head_top + head_h - 1) else 0
        for x in range(cx - hw + narrow, cx + 1):
            sym(x, y, pal.skin)

    female = (descriptor.gender or "").lower().startswith("f")
    if female:
        # Full, long hair: a crown over the head, TWO columns flowing down each side past
        # the jaw onto the shoulders, and a soft fringe - an unmistakably feminine look.
        for y in range(head_top - 2, head_top + 1):  # crown
            for x in range(cx - hw - 1, cx + 1):
                sym(x, y, pal.hair)
        for y in range(head_top, head_top + head_h + 4):  # long sides (two strands)
            sym(cx - hw - 1, y, pal.hair)
            sym(cx - hw - 2, y, pal.hair)
        for x in range(cx - hw, cx):  # fringe across the brow
            sym(x, head_top, pal.hair)
    else:
        has_hat = (seed >> 7) % 10 < 7
        if has_hat:
            brim_y = head_top - 1
            for x in range(cx - hw - 2, cx + 1):
                sym(x, brim_y, pal.ink)
            for y in range(head_top - 4, brim_y):
                for x in range(cx - hw + 1, cx + 1):
                    sym(x, y, pal.cloth_dark)
            for x in range(cx - hw + 1, cx + 1):
                sym(x, brim_y - 1, pal.accent)
            for x in range(cx - hw + 1, cx + 1):
                sym(x, head_top, pal.ink)
        else:
            for x in range(cx - hw, cx + 1):
                sym(x, head_top - 1, pal.hair)
                sym(x, head_top, pal.hair)
            sym(cx - hw, head_top + 1, pal.hair)

    # Eyes: open (ink dot) or closed on the blink frame (eyelid line). Female suspects
    # get a longer lash hint.
    eye_y = head_top + 4
    if variant == "blink":
        sym(cx - 3, eye_y, pal.skin)
        sym(cx - 4, eye_y, pal.cloth_dark)
        sym(cx - 2, eye_y, pal.cloth_dark)
    else:
        sym(cx - 3, eye_y, pal.ink)
        if female:
            sym(cx - 4, eye_y, pal.ink)  # lash
        grim = (descriptor.mood or "") in {"imperious", "anxious", "guarded", "nervous"}
        sym(cx - 3, eye_y - 1, pal.ink if grim else pal.skin)

    # Mouth: lipstick (reddish, wider) for female; muted for male. Open on the talk frame.
    mouth_y = head_top + head_h - 2
    lips = _LIPSTICK if female else pal.cloth_dark
    if variant == "talk":
        sym(cx - 1, mouth_y, pal.ink)
        sym(cx - 1, mouth_y + 1, lips)
        if female:
            sym(cx - 2, mouth_y + 1, lips)
    else:
        sym(cx - 1, mouth_y, lips)
        if female:
            sym(cx - 2, mouth_y, lips)


def render_portrait(descriptor: VisualDescriptor | None, key: str) -> Image.Image:
    descriptor = descriptor or VisualDescriptor(subject_type="suspect")  # type: ignore[arg-type]
    seed = seed_from(key, descriptor.mood or "", descriptor.accent_color or "")
    pal = build_palette(seed, descriptor.accent_color)
    img, px = _grid(PORTRAIT_W, PORTRAIT_H, pal.bg)
    _draw_portrait_frame(px, 0, pal, seed, descriptor, "neutral")
    return _upscale(img, frame=pal.accent)


def render_portrait_sheet(descriptor: VisualDescriptor | None, key: str) -> Image.Image:
    """A horizontal sheet of the animation frames (neutral, blink, talk) for CSS
    sprite-sheet playback. Transparent background so it layers over the room scene."""
    descriptor = descriptor or VisualDescriptor(subject_type="suspect")  # type: ignore[arg-type]
    seed = seed_from(key, descriptor.mood or "", descriptor.accent_color or "")
    pal = build_palette(seed, descriptor.accent_color)
    n = len(PORTRAIT_FRAMES)
    img, px = _grid(PORTRAIT_W * n, PORTRAIT_H, (0, 0, 0, 0))
    for i, variant in enumerate(PORTRAIT_FRAMES):
        _draw_portrait_frame(px, i * PORTRAIT_W, pal, seed, descriptor, variant)
    return img.resize((img.width * CELL, img.height * CELL), Image.NEAREST)


# Distinct room moods: (wall, floor, extra-prop accent). Picked by seed so each
# room reads differently - a green baize lounge, a wood office, a tiled kitchen, etc.
_ROOM_TINTS: tuple[tuple[RGBA, RGBA], ...] = (
    ((38, 32, 27, 255), (28, 22, 17, 255)),    # warm wood
    ((28, 40, 36, 255), (18, 28, 24, 255)),    # green baize
    ((30, 34, 46, 255), (20, 23, 33, 255)),    # cool slate
    ((46, 30, 30, 255), (30, 18, 18, 255)),    # red velvet
    ((42, 40, 30, 255), (28, 26, 18, 255)),    # brass / sepia
    ((34, 34, 38, 255), (22, 22, 26, 255)),    # grey stone
)


def _shade(color: RGBA, factor: float) -> RGBA:
    return (int(color[0] * factor), int(color[1] * factor), int(color[2] * factor), 255)


def _draw_window(put, x: int, pal: Palette) -> None:  # type: ignore[no-untyped-def]
    for y in range(4, 13):
        for x2 in range(x, x + 8):
            lit = y == 8 or x2 == x + 4
            put(x2, y, (12, 14, 22, 255) if lit else (60, 74, 102, 255))


def _draw_desk(put, x: int, floor_y: int, pal: Palette) -> None:  # type: ignore[no-untyped-def]
    top = floor_y - 5
    for xx in range(x, x + 11):
        put(xx, top, (96, 66, 38, 255))
        put(xx, top + 1, (70, 48, 28, 255))
    for leg in (x + 1, x + 9):
        for yy in range(top + 2, floor_y):
            put(leg, yy, (54, 36, 22, 255))
    put(x + 8, top - 1, pal.accent)  # a lamp/object on the desk


def _draw_bar(put, x: int, floor_y: int, pal: Palette) -> None:  # type: ignore[no-untyped-def]
    top = floor_y - 6
    for yy in range(top, floor_y):
        for xx in range(x, x + 13):
            put(xx, yy, (60, 42, 30, 255))
    for xx in range(x, x + 13):
        put(xx, top, (110, 80, 50, 255))
    for i, bottle in enumerate(range(x + 1, x + 12, 2)):
        put(bottle, top - 1, ((90, 150, 120, 255), (140, 90, 70, 255), pal.accent)[i % 3])
        put(bottle, top - 2, (40, 40, 50, 255))


def _draw_shelf(put, x: int, floor_y: int, pal: Palette) -> None:  # type: ignore[no-untyped-def]
    for shelf in range(floor_y - 10, floor_y, 3):
        for xx in range(x, x + 9):
            put(xx, shelf, (70, 50, 32, 255))
        for i, bx in enumerate(range(x, x + 9)):
            put(bx, shelf - 1, ((150, 80, 60, 255), (80, 110, 140, 255), (110, 140, 90, 255),
                                pal.accent)[i % 4])
            put(bx, shelf - 2, _shade(((150, 80, 60, 255), (80, 110, 140, 255),
                                       (110, 140, 90, 255), pal.accent)[i % 4], 0.7))


def _draw_piano(put, x: int, floor_y: int, pal: Palette) -> None:  # type: ignore[no-untyped-def]
    top = floor_y - 7
    for yy in range(top, floor_y):
        for xx in range(x, x + 12):
            put(xx, yy, (24, 22, 26, 255))
    for xx in range(x + 1, x + 11):
        put(xx, top + 2, (220, 215, 205, 255) if xx % 2 else (30, 28, 32, 255))


def _draw_plant(put, x: int, floor_y: int, pal: Palette) -> None:  # type: ignore[no-untyped-def]
    for yy in range(floor_y - 3, floor_y):
        for xx in range(x, x + 3):
            put(xx, yy, (90, 60, 40, 255))
    for dx, dy in ((1, -4), (0, -5), (2, -5), (1, -6), (0, -7), (2, -7)):
        put(x + dx, floor_y - 3 + dy, (70, 120, 70, 255))


_FURNITURE = (_draw_desk, _draw_bar, _draw_shelf, _draw_piano, _draw_plant)


def render_scene(name: str, key: str, accent_hex: str | None = None) -> Image.Image:
    seed = seed_from("scene", key, name)
    pal = build_palette(seed, accent_hex)
    wall, floor = _ROOM_TINTS[seed % len(_ROOM_TINTS)]
    img, px = _grid(SCENE_W, SCENE_H, wall)
    floor_y = SCENE_H - 4

    def put(x: int, y: int, c: RGBA) -> None:
        if 0 <= x < SCENE_W and 0 <= y < SCENE_H:
            px[x, y] = c

    # Wall with gentle top-down shading.
    for y in range(floor_y):
        s = max(0.55, 1.0 - y / (SCENE_H * 2.2))
        for x in range(SCENE_W):
            put(x, y, _shade(wall, s))
    # 1-2 windows at seed-chosen positions.
    _draw_window(put, 4 + (seed % 5), pal)
    if (seed >> 4) % 2:
        _draw_window(put, 26 + ((seed >> 6) % 6), pal)
    # A framed painting sometimes.
    if (seed >> 8) % 2:
        fx = 18 + (seed % 4)
        for yy in range(5, 10):
            for xx in range(fx, fx + 5):
                put(xx, yy, pal.accent if (yy in (5, 9) or xx in (fx, fx + 4)) else (52, 60, 80, 255))
    # Floor.
    for y in range(floor_y, SCENE_H):
        for x in range(SCENE_W):
            put(x, y, floor if (x + y) % 2 or y > floor_y else _shade(floor, 1.25))
    for x in range(SCENE_W):
        put(x, floor_y - 1, pal.accent)  # baseboard trim
    # Two distinct furniture motifs.
    pick_a = _FURNITURE[seed % len(_FURNITURE)]
    pick_b = _FURNITURE[(seed >> 3) % len(_FURNITURE)]
    pick_a(put, 3, floor_y, pal)
    if pick_b is not pick_a:
        pick_b(put, 25, floor_y, pal)
    return _upscale(img)


def render_prop(name: str, key: str, accent_hex: str | None = None) -> Image.Image:
    seed = seed_from("prop", key, name)
    pal = build_palette(seed, accent_hex)
    img, px = _grid(PROP_W, PROP_H, (0, 0, 0, 0))

    def put(x: int, y: int, c: RGBA) -> None:
        if 0 <= x < PROP_W and 0 <= y < PROP_H:
            px[x, y] = c

    # A folded document card.
    for y in range(2, PROP_H - 2):
        for x in range(3, PROP_W - 3):
            put(x, y, pal.parchment)
    for x in range(3, PROP_W - 3):
        put(x, 2, pal.ink)
        put(x, PROP_H - 3, pal.ink)
    for y in range(2, PROP_H - 2):
        put(3, y, pal.ink)
        put(PROP_W - 4, y, pal.ink)
    # Folded corner + accent wax seal.
    put(PROP_W - 4, 2, pal.cloth_dark)
    put(PROP_W - 5, 3, pal.cloth_dark)
    for y in range(5, 9):
        for x in range(5, PROP_W - 5):
            if (x + y) % 2 == 0:
                put(x, y, (90, 80, 60, 255))
    put(PROP_W - 6, PROP_H - 5, pal.accent)
    put(PROP_W - 7, PROP_H - 5, pal.accent)
    return _upscale(img)


def palette_for(descriptor: VisualDescriptor | None, key: str) -> Palette:
    seed = seed_from(key, (descriptor.mood if descriptor else "") or "")
    return build_palette(seed, descriptor.accent_color if descriptor else None)