Spaces:
Running
Running
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55 | """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) | |