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