"""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''''''
return svg