"""Parametric police-composite-sketch face renderer (pure SVG). Every face is a FaceSpec of closed-vocabulary attributes. The renderer is deterministic: same spec -> same sketch. This is the "author the ground truth" core — the game always knows exactly what the culprit looks like. """ from __future__ import annotations import random from dataclasses import dataclass, asdict, fields # ---------------------------------------------------------------- vocabulary VOCAB: dict[str, list[str]] = { "sex": ["male", "female"], "age": ["young", "adult", "old"], "face_shape": ["oval", "round", "square", "long"], "skin": ["light", "medium", "dark"], "hair_style": ["bald", "buzz", "short_messy", "slick_back", "curly", "long", "ponytail", "mohawk"], "hair_color": ["black", "brown", "blond", "red", "gray"], "brows": ["thin", "thick", "bushy", "unibrow"], "eyes": ["normal", "narrow", "big", "droopy"], "glasses": ["none", "round", "square", "sunglasses"], "nose": ["small", "big", "hooked", "wide"], "mouth": ["neutral", "smirk", "frown", "open"], "facial_hair": ["none", "stubble", "mustache", "goatee", "full_beard"], "hat": ["none", "beanie", "cap", "fedora"], "extra": ["none", "scar_cheek", "earring", "neck_tattoo", "mole"], } # attributes a witness is MOST likely to notice — used to weight scoring/lineups SALIENCE = { "sex": 3.0, "age": 2.0, "hat": 3.0, "hair_style": 2.5, "glasses": 2.5, "facial_hair": 2.5, "hair_color": 2.0, "extra": 2.0, "face_shape": 1.5, "mouth": 1.0, "brows": 1.0, "nose": 1.0, "eyes": 1.0, "skin": 1.5, } HAIR_HEX = {"black": "#23211e", "brown": "#5b4226", "blond": "#b89a4e", "red": "#8c4a2a", "gray": "#8d8d89"} SKIN_HEX = {"light": "#efe6d8", "medium": "#dcc4a2", "dark": "#a9805c"} INK = "#2b2a28" PAPER = "#f4efe4" @dataclass(frozen=True) class FaceSpec: sex: str age: str face_shape: str skin: str hair_style: str hair_color: str brows: str eyes: str glasses: str nose: str mouth: str facial_hair: str hat: str extra: str def to_dict(self) -> dict[str, str]: return asdict(self) @staticmethod def random(rng: random.Random | None = None) -> "FaceSpec": rng = rng or random return FaceSpec(**{k: rng.choice(v) for k, v in VOCAB.items()}) def with_changes(self, **kw: str) -> "FaceSpec": d = self.to_dict() d.update(kw) return FaceSpec(**d) def diff(self, other: "FaceSpec") -> list[str]: return [f.name for f in fields(self) if getattr(self, f.name) != getattr(other, f.name)] # ---------------------------------------------------------------- renderer def _head_path(shape: str) -> str: """Head outline path for each face shape (300x360 canvas, center x=150).""" return { "oval": "M150,68 C108,68 84,104 84,160 C84,216 112,262 150,262 C188,262 216,216 216,160 C216,104 192,68 150,68 Z", "round": "M150,72 C100,72 78,116 78,168 C78,222 108,260 150,260 C192,260 222,222 222,168 C222,116 200,72 150,72 Z", "square": "M150,70 C112,70 92,92 90,128 L88,210 C88,242 116,262 150,262 C184,262 212,242 212,210 L210,128 C208,92 188,70 150,70 Z", "long": "M150,62 C114,62 92,96 92,150 C92,216 116,272 150,272 C184,272 208,216 208,150 C208,96 186,62 150,62 Z", }[shape] def _hair(spec: FaceSpec) -> tuple[str, str]: """(behind_head_svg, front_svg) for hair. Front layer draws over forehead.""" c = HAIR_HEX[spec.hair_color] s = spec.hair_style behind, front = "", "" if s == "bald": return "", "" if s == "buzz": front = f'' elif s == "short_messy": front = (f'') elif s == "slick_back": front = f'' elif s == "curly": circles = "".join( f'' for x, y, r in [(105,110,18),(125,92,19),(150,84,20),(175,92,19),(195,110,18),(98,134,14),(202,134,14)] ) front = circles elif s == "long": behind = f'' front = f'' elif s == "ponytail": behind = f'' front = f'' elif s == "mohawk": front = f'' return behind, front def _brows(spec: FaceSpec) -> str: y = 142 if spec.brows == "thin": return (f'' f'') if spec.brows == "thick": return (f'' f'') if spec.brows == "bushy": return (f'' f'') # unibrow return f'' def _eyes(spec: FaceSpec) -> str: y = 158 lx, rx = 127, 173 if spec.eyes == "narrow": return (f'' f'') if spec.eyes == "big": return (f'' f'' f'') if spec.eyes == "droopy": return (f'' f'' f'' f'') # normal return (f'' f'' f'') def _glasses(spec: FaceSpec) -> str: if spec.glasses == "none": return "" y = 158 if spec.glasses == "round": return (f'' f'' f'') if spec.glasses == "square": return (f'' f'' f'') # sunglasses return (f'' f'' f'') def _nose(spec: FaceSpec) -> str: if spec.nose == "small": return f'' if spec.nose == "big": return f'' if spec.nose == "hooked": return f'' # wide return (f'' f'' f'') def _mouth(spec: FaceSpec) -> str: y = 218 if spec.mouth == "smirk": return f'' if spec.mouth == "frown": return f'' if spec.mouth == "open": return f'' return f'' def _facial_hair(spec: FaceSpec) -> str: c = HAIR_HEX[spec.hair_color] fh = spec.facial_hair if fh == "none": return "" if fh == "stubble": dots = "".join( f'' for i in range(46) ) return dots if fh == "mustache": return f'' if fh == "goatee": return (f'' f'') # full beard return (f'' f'') def _hat(spec: FaceSpec) -> str: if spec.hat == "none": return "" if spec.hat == "beanie": return (f'' f'') if spec.hat == "cap": return (f'' f'') # fedora return (f'' f'' f'') def _extra(spec: FaceSpec) -> str: if spec.extra == "scar_cheek": return (f'' + "".join(f'' for i in range(4))) if spec.extra == "earring": return f'' if spec.extra == "neck_tattoo": return f'' if spec.extra == "mole": return f'' return "" def _sex_age(spec: FaceSpec) -> str: """Comic-legible cues: lashes/lips=female, wrinkles=old, freckles=young.""" parts = [] if spec.sex == "female": y = 158 for ex, flip in ((127, -1), (173, 1)): x0 = ex + flip * 11 parts.append( f'') parts.append('') if spec.age == "old": parts.append(f'') parts.append(f'') parts.append(f'') elif spec.age == "young": parts.append("".join(f'' for x, y in ((116, 186), (122, 192), (110, 192), (184, 186), (178, 192), (190, 192)))) return "".join(parts) def render_face_svg(spec: FaceSpec, width: int = 300, paper: bool = True, seed_jitter: int = 0) -> str: """Render a FaceSpec to a self-contained SVG string (sketch style).""" skin = SKIN_HEX[spec.skin] hair_behind, hair_front = _hair(spec) ear_y = 178 bg = ( f'' f'' if paper else "" ) svg = f''' {bg} {hair_behind} {_facial_hair(spec)} {_brows(spec)} {_eyes(spec)} {_nose(spec)} {_mouth(spec)} {hair_front} {_hat(spec)} {_glasses(spec)} {_extra(spec)} {_sex_age(spec)} ''' return svg