case0 / src /case_zero /visuals /compositor.py
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
raw
history blame
12.7 kB
"""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)