tts-voice-creator-clone-and.../core/face_profile.py
mARTin-B78 7eabdf0e43 feat(characters): distinct deterministic face profiles per character
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 14:25:56 +02:00

800 lines
40 KiB
Python

"""Deterministic, explainable face profiles for character image prompts.
Why: the image model (Qwen-Image Lightning, 4 steps, cfg 1.0) falls back on one default face
whenever a prompt does not name facial features, and most sheets name none ("Weiblich, Traenen
in den Augen" ...). Every character then looks like a relative of every other one.
What: for each character this module produces one value per facial *slot* (face shape, jaw,
nose, eyes, brows, mouth, cheekbones, skin, hair colour and style, facial hair, age signs, a
distinguishing mark).
1. Whatever the sheet already says is extracted and kept (``source == "sheet"``).
2. A free-text owner override ``visual_face`` replaces the generated clause (still parsed, so the
character keeps occupying its place in the distinctness assignment).
3. Remaining slots are filled from option pools filtered by species, age stage, gender and (for
skin) the culture named in the sheet.
4. Characters of one book are processed in a fixed order (creation order). Each free slot takes
the value that collides least with the earlier characters, weighting collisions with
characters that already look alike more (greedy max-distance). A character therefore depends
only on the characters before it: adding characters never changes existing profiles.
Ties are broken by a hash of (book, name, slot, option), never by chance.
5. Relatives named in the ``relationships`` field (brother, father, ...) inherit a few family
slots from the relative instead of avoiding it.
Pure functions, no I/O, no randomness. ``clause()`` is the compact text for the image prompt.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from typing import Iterable
SLOTS = (
"hair_colour", "hair_style", "face_shape", "skin", "nose", "facial_hair", "eye_colour", "jaw",
"eyes", "brows", "mouth", "cheeks", "age_signs", "mark",
)
WEIGHTS = {
"hair_colour": 1.5, "hair_style": 1.5, "face_shape": 1.2, "skin": 1.2, "nose": 1.2,
"facial_hair": 1.2, "eye_colour": 1.0, "jaw": 1.0, "eyes": 1.0, "brows": 0.8, "mouth": 0.8,
"cheeks": 0.8, "age_signs": 0.8, "mark": 0.8,
}
# Slots a relative may pass on (the rest still avoid the relative, so siblings differ too).
FAMILY_SLOTS = ("eye_colour", "hair_colour", "nose", "jaw")
DEFAULT = "default" # what an unspecified slot looks like: the image model's own default face
_AGE_STAGES = "ctymo" # child, teen, young adult, middle-aged, old
@dataclass(frozen=True)
class Option:
id: str
phrase: str
species: frozenset # codes h e d o v ; empty = every species
no_vampire: bool
gender: str # "m", "f" or "" (both)
ages: str # subset of _AGE_STAGES; "" = all
culture: str # option is NOT offered when the character's culture is this ("N" north, "S" south)
def _o(oid: str, phrase: str, spec: str = "", gender: str = "", ages: str = "", culture: str = "") -> Option:
tokens = spec.split()
return Option(oid, phrase, frozenset(t for t in tokens if t in "hedov" and not t.startswith("-")),
"-v" in tokens, gender, ages, culture)
_ADULT = "ymo"
_POOLS: dict[str, tuple[Option, ...]] = {
"face_shape": (
_o("oval", "oval face"), _o("round", "round face"), _o("square", "square face", "h d o"),
_o("long", "long narrow face", "h e o"), _o("heart", "heart-shaped face", "h e"),
_o("broad", "broad wide face", "h d o"), _o("diamond", "diamond-shaped face with a narrow forehead", "h e"),
_o("gaunt", "lean angular face", "h e v o", ages="ymo"), _o("full", "full soft face", "h d", ages="tymo"),
),
"jaw": (
_o("square_jaw", "strong square jaw", "h d o", "m", "tymo"),
_o("heavy_jaw", "heavy blunt jaw", "d o h", "m", "ymo"),
_o("tusk_jaw", "wide protruding lower jaw", "o"),
_o("soft_jaw", "soft rounded jaw", "h e d v"),
_o("pointed_chin", "narrow pointed chin", "h e v"),
_o("cleft_chin", "cleft chin", "h d", "", "tymo"),
_o("delicate_chin", "delicate small chin", "h e", "f"),
_o("defined_jaw", "defined oval jaw", "h e v"),
_o("receding_chin", "slightly receding chin", "h", "", "tymo"),
),
"nose": (
_o("straight", "straight nose"), _o("aquiline", "aquiline nose with a high bridge", "h e v d"),
_o("snub", "short upturned nose", "h e d"), _o("broad", "broad nose with wide nostrils", "h d o"),
_o("narrow", "long narrow nose", "h e v d o"), _o("button", "small button nose", "h e", "", "ct"),
_o("crooked", "slightly crooked nose", "h d o", "", "ymo"),
_o("hooked", "prominent hooked nose", "h d v o", "", "ymo"),
_o("flat", "flat wide-bridged nose", "h o"), _o("bulbous", "round bulbous nose tip", "h d", "m", "ymo"),
),
"eyes": (
_o("almond", "almond-shaped"), _o("round", "large round", "h e d"),
_o("hooded", "heavy-lidded hooded", "h d v o"), _o("deep_set", "deep-set", "h d o v"),
_o("wide_set", "wide-set", "h e d"), _o("upturned", "slightly upturned", "h e"),
_o("downturned", "gently downturned", "h d v"), _o("narrow", "narrow", "h d o v"),
_o("close_set", "close-set", "h o d"),
),
"eye_colour": (
_o("brown", "brown", "h d o v"), _o("dark_brown", "dark brown", "h d o v"),
_o("hazel", "hazel", "h d e"), _o("green", "green", "h e d"),
_o("blue", "blue", "h e d v"), _o("grey", "grey", "h e d v o"),
_o("amber", "amber", "h e o v"), _o("light_blue", "pale ice-blue", "h e v"),
_o("black", "near-black", "h o d v"), _o("gold", "golden-brown", "e h"),
),
"brows": (
_o("thick", "thick straight brows"), _o("thin_arched", "thin arched brows", "h e v"),
_o("heavy", "heavy dark brows", "h d o v"), _o("pale", "pale faint brows", "h e d"),
_o("bushy", "bushy brows", "h d o", "", "ymo"), _o("slanted", "sharply slanted brows", "h e v o"),
_o("flat", "flat even brows", "h e d"),
),
"mouth": (
_o("full", "full lips"), _o("thin", "thin lips", "h e d v o"), _o("wide", "wide mouth", "h d o"),
_o("small", "small mouth", "h e v"), _o("cupid", "bowed upper lip", "h e", "f"),
_o("firm", "firm straight mouth", "h d o v"),
_o("uneven", "slightly uneven lips", "h d o"),
),
"cheeks": (
_o("high", "high prominent cheekbones", "h e v o"), _o("soft", "soft full cheeks", "h d e", "", "ctym"),
_o("hollow", "hollow cheeks", "h v o", "", "ymo"), _o("broad", "broad flat cheekbones", "h d o"),
_o("rosy", "rosy cheeks", "h d e", "", "ctymo", "S"),
_o("sharp", "sharp angular cheekbones", "h e v"),
),
"skin": (
_o("pale", "pale skin", "h e v", "", "", "S"), _o("fair", "fair skin", "h e d v"),
_o("fair_freckled", "fair skin", "h e d -v", "", "", "S"),
_o("ruddy", "ruddy weathered complexion", "h d -v", "", "ymo", "S"),
_o("light_tan", "light tan skin", "h e d -v"), _o("olive", "olive skin", "h e d o v"),
_o("golden", "golden-tan skin", "h e -v"),
_o("warm_brown", "warm brown skin", "h o -v", "", "", "N"),
_o("deep_brown", "deep dark-brown skin", "h -v", "", "", "N"),
_o("dark_olive", "dark olive-grey skin", "o -v"), _o("ash_brown", "ashen brown skin", "o -v"),
_o("sallow", "sallow skin", "h d v"),
),
"hair_colour": (
_o("black", "black hair", "h d o v e", "", "ctym"), _o("dark_brown", "dark brown hair", "h d o v e", "", "ctym"),
_o("brown", "mid-brown hair", "h d e", "", "ctym"), _o("chestnut", "chestnut hair", "h e d", "", "ctym"),
_o("auburn", "auburn hair", "h e d", "", "ctym"), _o("red", "copper-red hair", "h e d", "", "ctym"),
_o("dark_blonde", "dark blonde hair", "h e d", "", "ctym"), _o("blonde", "golden blonde hair", "h e d", "", "ctym"),
_o("silver_blonde", "pale silver-blonde hair", "e", "", "ctymo"),
_o("grey_streaked", "dark hair streaked with grey", "h d o v", "", "mo"),
_o("grey", "iron-grey hair", "h d o v", "", "mo"), _o("white", "white hair", "h d o v", "", "o"),
),
}
_HAIR_STYLES_F = (
_o("f_long_loose_wavy", "long loose wavy hair", "h e d o v", "f", "ctymo"),
_o("f_long_braid", "one long thick braid over the shoulder", "h e d o v", "f", "ctymo"),
_o("f_long_straight", "long straight hair parted in the middle", "h e v o", "f", "tymo"),
_o("f_curly", "shoulder-length curly hair", "h e d", "f", "ctymo"),
_o("f_bun", "hair pinned in a low bun with loose strands", "h e d v", "f", "ymo"),
_o("f_two_braids", "two side braids pinned back", "h e d o", "f", "ctym"),
_o("f_bob", "chin-length hand-cut bob with a fringe", "h e d v", "f", "tym"),
_o("f_crown_braid", "braids wound around the head like a crown", "h e d", "f", "tymo"),
_o("f_ponytail", "long hair tied back in a high ponytail", "h e d o", "f", "tym"),
_o("f_curly_long", "long thick curls", "h d o v", "f", "tym"),
_o("f_straight_short", "straight shoulder-length hair tucked behind the ears", "h e d o", "f", "tymo"),
)
_HAIR_STYLES_M = (
_o("m_short_cropped", "short hand-cropped hair", "h d o v", "m", "tymo"),
_o("m_collar_wavy", "collar-length wavy hair", "h e d v", "m", "tymo"),
_o("m_shoulder_straight", "shoulder-length straight hair", "h e d o v", "m", "tymo"),
_o("m_curly_short", "short tight curls", "h d o", "m", "ctymo"),
_o("m_tied_back", "hair tied back at the nape", "h e d o v", "m", "tymo"),
_o("m_long_loose", "long loose hair", "h e o v", "m", "ymo"),
_o("m_receding", "high receding hairline with hair kept short at the sides", "h d v", "m", "mo"),
_o("m_braided", "hair in a thick back braid", "h e d o", "m", "ymo"),
_o("m_unruly", "thick unruly hair", "h d o v", "m", "ctymo"),
_o("m_swept_back", "hair swept straight back from the forehead", "h e v", "m", "tymo"),
_o("m_fringe", "straight fringe over the forehead with hair cut at the ears", "h e d", "m", "ctym"),
_o("m_side_braids", "two thin braids at the temples, the rest tied back", "d o h", "m", "ymo"),
)
_HAIR_STYLES_U = ( # gender not stated: only styles that read either way
_o("u_shoulder", "shoulder-length hair", "h e d o v", "u", "ctymo"),
_o("u_short", "short hand-cut hair", "h d o v", "u", "ctymo"),
_o("u_tied", "hair tied back", "h e d o v", "u", "tymo"),
_o("u_wavy", "wavy collar-length hair", "h e d v", "u", "ctymo"),
)
_FACIAL_HAIR_M = (
_o("clean", "clean-shaven", "h e v o", "m", "ymo"),
_o("stubble", "short stubble", "h o d v", "m", "ymo"),
_o("moustache", "a full moustache and a bare chin", "h d o", "m", "ymo"),
_o("goatee", "a trimmed goatee", "h v", "m", "ymo"),
_o("short_beard", "a short close-trimmed beard", "h v", "m", "ymo"),
_o("full_beard", "a full broad beard", "h d", "m", "ymo"),
_o("mutton", "long sideburns joined to a moustache", "h", "m", "ymo"),
_o("long_beard", "a long beard reaching the chest", "h d", "m", "mo"),
_o("braided_beard", "a long braided beard bound with metal rings", "d", "m", "ymo"),
_o("forked_beard", "a forked beard", "d h", "m", "ymo"),
_o("square_beard", "a thick square-cut beard", "d", "m", "ymo"),
_o("sparse_beard", "a sparse thin chin beard", "o h", "m", "ymo"),
)
_AGE_SIGNS = (
_o("child_smooth", "smooth round child's skin", "h e d o v", "", "c"),
_o("teen_smooth", "smooth youthful skin", "h e d o v", "", "t"),
_o("young_smooth", "smooth youthful skin", "h e d o v", "", "y"),
_o("young_clear", "clear unlined skin", "h e d o v", "", "y"),
_o("young_dimples", "dimples when the face relaxes", "h e d", "", "ty"),
_o("mid_lines", "faint laugh lines at the eyes", "h d o v", "", "m"),
_o("mid_creases", "creased forehead and crow's feet", "h d o v", "", "m"),
_o("mid_deep", "deep lines from nose to mouth corners", "h d o v", "", "m"),
_o("old_wrinkled", "deeply wrinkled weathered skin", "h d o v", "", "o"),
_o("old_sagging", "soft sagging skin with age spots", "h d v", "", "o"),
_o("old_lines", "deep furrowed forehead and many fine lines", "h d o v", "", "o"),
_o("elf_ageless", "ageless smooth flawless skin", "e", "", "ymo"),
_o("elf_fine", "fine delicate skin with no sign of age", "e", "", "ymo"),
)
_MARKS = (
_o("none", "", "h e d o v"), _o("freckles", "freckles across the nose and cheeks", "h e d -v", "", "ctymo"),
_o("mole_lip", "a small mole above the lip", "h e d v", "", "tymo"),
_o("mole_cheek", "a beauty mark on the cheek", "h e v", "", "tymo"),
_o("birthmark", "a faint birthmark near the temple", "h d", "", "ctymo"),
_o("gap_teeth", "a small gap between the front teeth", "h d", "", "ctymo"),
_o("long_lashes", "very long dark lashes", "h e v", "", "ctymo"),
_o("dark_circles", "faintly shadowed under-eyes", "h v d", "", "ymo"),
_o("broken_ear_lobe", "a pierced ear with a small metal ring", "h d o", "", "tymo"),
_o("brow_split", "a small notch in one eyebrow", "h d o", "", "tymo"),
_o("high_forehead", "a high broad forehead", "h e d v", "", "ymo"),
_o("low_forehead", "a low heavy brow ridge", "h o d", "", "ymo"),
)
_POOLS["hair_style"] = _HAIR_STYLES_F + _HAIR_STYLES_M + _HAIR_STYLES_U
_POOLS["facial_hair"] = _FACIAL_HAIR_M
_POOLS["age_signs"] = _AGE_SIGNS
_POOLS["mark"] = _MARKS
_OPTION_BY_ID = {slot: {opt.id: opt for opt in opts} for slot, opts in _POOLS.items()}
# ---------------------------------------------------------------------------------------------
# Reading a sheet
# ---------------------------------------------------------------------------------------------
_HUMAN_NAME_SKIP = {"narrator", "erzähler", "erzaehler", "unknown", "unbekannt", "sprecher"}
_NON_HUMANOID_RE = re.compile(
r"\b(?:rabe|krähe|vogel|pferd|ross|hund|wolf|katze|bär|drache|golem|dämon|daemon|tier|ratte|schlange|"
r"spinne|fisch|esel|falke|adler|eule|greif|kobold|goblin|troll|oger|schwarm|raven|crow|bird|horse|dog|cat|"
r"bear|dragon|demon|animal|rat|snake|spider|fish|donkey|falcon|eagle|owl|griffin|ogre|ghost)(?:e|en|s|n)?\b",
re.IGNORECASE,
)
_SPECIES_RES = (
("d", re.compile(r"zwerg\w*|dwarf|dwarv\w*", re.IGNORECASE)),
("e", re.compile(r"\b(?:au|firn|wald|halb|hoch|nacht)?elf(?:e|en|in|isch\w*)?\b|\belben?\b|\belven\b|half-elf\b", re.IGNORECASE)),
("o", re.compile(r"\b(?:halb)?ork(?:s|en|in|isch\w*)?\b|\borcs?\b|\borcish\b", re.IGNORECASE)),
("v", re.compile(r"vampir\w*|vampire", re.IGNORECASE)),
("h", re.compile(r"mensch\w*|human\w*", re.IGNORECASE)),
)
_NAME_HINT_RES = ( # compounds such as "Orkschamane"; "Elfriede" and "Orkan" must not match
("d", re.compile(r"zwerg\w*", re.IGNORECASE)),
("e", re.compile(r"\b(?:elfen|elfe\b|halbelf|auelf|waldelf|firnelf)\w*", re.IGNORECASE)),
("o", re.compile(r"\bork(?!an\b)\w*", re.IGNORECASE)),
)
_ALTERNATIVE_RE = re.compile(r"\b(?:oder|or)\b|/", re.IGNORECASE)
_NORTH_RE = re.compile(r"thorwal|nordland|norbard|nostri|andergast|firun|svellt|nordmark|fjord|nordic|norse", re.IGNORECASE)
_SOUTH_RE = re.compile(r"tulamid|novad|mhanadi|al.?anfa|aranien|zahori|ferkina|sand|desert|southern|wüste|khom", re.IGNORECASE)
_MALE = {"male", "männlich", "maennlich", "m", "man", "mann", "boy", "junge"}
_FEMALE = {"female", "weiblich", "w", "f", "woman", "frau", "girl", "mädchen", "maedchen"}
def _text(value) -> str:
if isinstance(value, list):
return ", ".join(_text(v) for v in value if _text(v))
if isinstance(value, dict):
return ", ".join(_text(v) for v in value.values() if _text(v))
return str(value or "").strip()
@dataclass(frozen=True)
class FaceInput:
"""What the profile needs to know about one character (no I/O, no sheet access)."""
name: str
species: str # one of h e d o v, "hE" half-elf = "he", or "" for no humanoid face
stage: str # one of c t y m o
gender: str # "m", "f" or ""
culture: str # "", "N", "S"
text: str # appearance text the traits are read from (physical / visual_appearance / override)
override: str = ""
kin: tuple = () # names of relatives mentioned in the sheet
def species_code(species_text: str, name: str = "", extra: str = "") -> str:
"""Species of the character's OWN assertion ('' = not a humanoid face, e.g. a raven).
Only the leading clause counts: "Mensch (oder Zwerg, je nach Kontext ...)" is a human.
An empty species falls back to what the name/title say ("Zwerg", "Orkschamane"), else human.
"""
head = re.split(r"[(\[]", species_text or "", maxsplit=1)[0].strip()
if head:
if _NON_HUMANOID_RE.search(head) and not any(r.search(head) for _, r in _SPECIES_RES):
return ""
found = [code for code, rx in _SPECIES_RES if rx.search(head)]
if _ALTERNATIVE_RE.search(head) and "h" in found:
return "h"
if "e" in found and re.search(r"halb|half", head, re.IGNORECASE):
return "he"
return found[0] if found else "h"
hint = f"{name} {extra}"
if _NON_HUMANOID_RE.search(name) and not any(r.search(name) for _, r in _SPECIES_RES):
return ""
for code, rx in _NAME_HINT_RES:
if rx.search(hint):
return code
return "h"
_OLD_RE = re.compile(
r"\balt(?:e|en|em|es)?\b|\balter\s+(?:mann|frau|herr|dame|krieger|priester|zwerg|elf|kerl)\b|greis\w*|hochbetagt|"
r"\bold\b|elderly|\baged\b|\bälter\w*|uralt|ancient",
re.IGNORECASE,
)
_MIDDLE_RE = re.compile(r"mittel\w*\s*alter|middle|mittleres|erfahren|reif\w*|\b[3-5]\d\b", re.IGNORECASE)
_YOUNG_RE = re.compile(r"jung|young|youth|twenties|zwanzig", re.IGNORECASE)
_NUMBER_AGE_RE = re.compile(r"\b(\d{1,3})\s*(?:jahre|jahr|years?|sommer|winter)", re.IGNORECASE)
def age_stage(age_text: str, category: str = "adult", physical: str = "") -> str:
"""c/t/y/m/o from the age category (child/teen/adult) plus the wording of the sheet."""
if category == "child":
return "c"
if category == "teen":
return "t"
text = f"{age_text} {physical[:200]}"
number = _NUMBER_AGE_RE.search(age_text or "")
if number:
years = int(number.group(1))
return "y" if years < 35 else "m" if years < 60 else "o"
if _OLD_RE.search(text):
return "o"
if _YOUNG_RE.search(age_text or ""): # "jung bis mittelalterlich": the younger end
return "y"
if _MIDDLE_RE.search(age_text or ""):
return "m"
return "y"
def gender_code(value: str) -> str:
v = (value or "").strip().lower()
return "m" if v in _MALE else "f" if v in _FEMALE else ""
# Trait readers: (regex, option id). Option ids are the pool ids, so the sheet's wording lands
# on the same canonical value another character may be given (and counts as a collision).
def _compile(items: Iterable[tuple[str, str]]):
return tuple((re.compile(rx, re.IGNORECASE), oid) for rx, oid in items)
_HAIR_COLOUR_READ = _compile((
(r"dunkelblond\w*|dark[- ]blond\w*|dirty blond\w*", "dark_blonde"),
(r"silber\w*|silver\w*", "silver_blonde"),
(r"blond\w*|golden\w*|goldfarben\w*|gold\b|flaxen|flachs\w*", "blonde"),
(r"dunkelbraun\w*|dark[- ]brown\w*|dunkel\w*|dunkl\w*|dark\w*", "dark_brown"),
(r"kastanien\w*|chestnut\w*", "chestnut"),
(r"auburn|rotbraun\w*|kupfer\w*|copper\w*", "auburn"),
(r"rot(?!br)\w*|red\w*|ingwer\w*|ginger\w*|feuer\w*", "red"),
(r"schwarz\w*|black\w*|raven\w*|rabenschwarz\w*", "black"),
(r"braun\w*|brown\w*|brünett\w*|brunett\w*", "brown"),
(r"eisgrau\w*|stahlgrau\w*|grau\w*|ergraut\w*|grey\w*|gray\w*", "grey"),
(r"wei(?:ß|ss)\w*|white\w*|schneewei\w*", "white"),
))
_COMPOUND_HAIR = re.compile(r"\b(schwarz|blond|dunkelblond|rot|grau|wei(?:ß|ss)|braun|dunkel|silber|brünett|ergraut)haarig\w*", re.IGNORECASE)
_COMPOUND_MAP = {"schwarz": "black", "blond": "blonde", "dunkelblond": "dark_blonde", "rot": "red", "grau": "grey",
"weiß": "white", "weiss": "white", "braun": "brown", "dunkel": "dark_brown", "silber": "silver_blonde",
"brünett": "brown", "ergraut": "grey"}
_EYE_COLOUR_READ = _compile((
(r"hasel\w*|hazel\w*", "hazel"), (r"bernstein\w*|amber\w*|gelb\w*|yellow\w*|golden\w*|gold\w*", "amber"),
(r"eisblau\w*|hellblau\w*|ice[- ]blue|pale blue", "light_blue"),
(r"blau\w*|blue\w*", "blue"), (r"gr(?:ü|u)n\w*|green\w*|smaragd\w*", "green"),
(r"schwarz\w*|black\w*", "black"), (r"dunkelbraun\w*|dark[- ]brown\w*", "dark_brown"),
(r"eisgrau\w*|stahl\w*|grau\w*|grey\w*|gray\w*|silber\w*", "grey"), (r"braun\w*|brown\w*", "brown"),
(r"dunkel\w*|dark\w*", "dark_brown"),
))
_SIMPLE_READS = {
"facial_hair": _compile((
(r"stoppel\w*|stubble|unrasiert\w*|dreitage\w*", "stubble"),
(r"bartlos\w*|glattrasiert\w*|clean[- ]shaven|beardless|bartfrei\w*|(?<!un)rasiert\w*", "clean"),
(r"spitzbart\w*|ziegenbart\w*|goatee", "goatee"),
(r"schnurrbart\w*|schnauzbart\w*|schnauz\w*|moustache\w*|mustache\w*|zwirbel\w*", "moustache"),
(r"langer?\s+(?:\w+\s+)?bart|long\s+(?:\w+\s+)?beard|bart\s+bis\b", "long_beard"),
(r"vollbart\w*|full\s+beard", "full_beard"),
(r"(?<![a-zäöüß])bart(?![a-zäöüß])|\bbeard\w*|knebelbart|backenbart|kinnbart|zwergenbart", "sheet_beard"),
)),
"skin": _compile((
(r"blass\w*|bleich\w*|pale\b|ashen|wächsern\w*|porcelain", "pale"),
(r"dunkelh(?:ä|a)utig\w*|dark[- ]skinned|dunkle[rn]?\s+haut|dark\s+skin", "deep_brown"),
(r"gebräunt\w*|sonnengebräunt\w*|tanned|sun[- ]browned", "light_tan"),
(r"gerötet\w*\s+(?:haut|gesicht|wangen)|r(?:ö|o)tlich\w*\s+(?:haut|gesicht|teint)|ruddy|red\s+face|weathered", "ruddy"),
(r"olive\w*", "olive"), (r"helle[rn]?\s+haut|fair\s+skin|light\s+skin|heller\s+teint", "fair"),
(r"fahl\w*|sallow", "sallow"),
)),
"nose": _compile((
(r"hakennase|hooked|adlernase|aquiline", "aquiline"),
(r"krumm\w*\s+nase|crooked\s+nose|schiefe\s+nase", "crooked"),
(r"stupsnase|snub|upturned\s+nose|button\s+nose", "snub"),
(r"breite[rn]?\s+nase|broad\s+nose|wide\s+nose|knollennase|bulbous", "broad"),
(r"spitze[rn]?\s+nase|schmale[rn]?\s+nase|narrow\s+nose|thin\s+nose|pointed\s+nose|lange[rn]?\s+nase|long\s+nose", "narrow"),
(r"gerade[rn]?\s+nase|straight\s+nose", "straight"),
)),
"jaw": _compile((
(r"kantig\w*\s+kinn|eckig\w*\s+kinn|square\s+jaw|strong\s+(?:jaw|chin)|kr(?:ä|a)ftig\w*\s+(?:kinn|kiefer)|markant\w*\s+kinn", "square_jaw"),
(r"spitz\w*\s+kinn|pointed\s+chin", "pointed_chin"),
(r"rund\w*\s+kinn|weiche[sn]?\s+kinn|soft\s+(?:chin|jaw)", "soft_jaw"),
(r"kinngr(?:ü|u)bchen|cleft\s+chin|chin\s+dimple", "cleft_chin"),
(r"fliehend\w*\s+kinn|receding\s+chin", "receding_chin"),
)),
"face_shape": _compile((
(r"rund\w*\s+gesicht|round\s+face|mondgesicht", "round"),
(r"oval\w*\s+gesicht|oval\s+face", "oval"),
(r"herzf(?:ö|o)rmig\w*|heart[- ]shaped", "heart"),
(r"hager\w*|gaunt|knochig\w*|sunken|angular\s+face|schmal\w*\s+gesicht|narrow\s+face|lean\s+face|hollow", "gaunt"),
(r"breite[sn]?\s+gesicht|broad\s+face|wide\s+face", "broad"),
(r"kantig\w*\s+gesicht|eckig\w*\s+gesicht|square\s+face", "square"),
(r"langes?\s+gesicht|long\s+face", "long"),
)),
"eyes": _compile((
(r"mandelf(?:ö|o)rmig\w*|almond", "almond"), (r"tiefliegend\w*|deep[- ]set", "deep_set"),
(r"gro(?:ß|ss)e\s+augen|runde\s+augen|round\s+eyes|large\s+eyes|big\s+eyes", "round"),
(r"schmale\s+augen|narrow\s+eyes|schlitz\w*", "narrow"), (r"schr(?:ä|a)g\w*\s+augen|slanted|upturned\s+eyes", "upturned"),
)),
"brows": _compile((
(r"buschig\w*\s+(?:augen)?brauen?|bushy\s+brows?|dichte\s+(?:augen)?brauen|thick\s+brows?|heavy\s+brows?", "bushy"),
(r"d(?:ü|u)nne\s+(?:augen)?brauen|thin\s+brows?|schmale\s+(?:augen)?brauen|gezupft", "thin_arched"),
)),
"mouth": _compile((
(r"voll\w*\s+lippen|full\s+lips", "full"), (r"schmal\w*\s+lippen|thin\s+lips|d(?:ü|u)nn\w*\s+lippen", "thin"),
(r"breite[rn]?\s+mund|wide\s+mouth", "wide"),
)),
"cheeks": _compile((
(r"hohe\s+wangenknochen|high\s+cheek\s*bones?|prominent\w*\s+cheek", "high"),
(r"eingefallen\w*\s+wangen|hollow\s+cheeks?", "hollow"), (r"pausb(?:ä|a)ck\w*|full\s+cheeks|rundliche\s+wangen", "soft"),
(r"rosig\w*\s+wangen|rosy\s+cheeks", "rosy"),
)),
"age_signs": _compile((
(r"faltig\w*|falten|runzel\w*|wrinkl\w*|furrow\w*|lined\s+face|crow'?s", "old_wrinkled"),
)),
"mark": _compile((
(r"sommersprossen|freckle\w*", "freckles"),
(r"muttermal|leberfleck|schönheitsfleck|\bmole\b|birthmark|geburtsmal", "mole_lip"),
(r"narbe\w*|\bscars?\b|tätowier\w*|tattoo\w*", "sheet_mark"),
)),
}
_HAIR_STYLE_WORDS = _compile((
(r"kahl\w*|glatze|bald\b|geschoren\w*|shaved\s+head|shaven\s+head", "bald"),
(r"zopf|z(?:ö|o)pfe|braid\w*|plait\w*|geflochten\w*", "braided"),
(r"lockig\w*|gelockt\w*|locken|curly|curls|kraus\w*", "curly"),
(r"wellig\w*|gewellt\w*|wavy", "wavy"),
(r"glatt\w*\s+haar|straight\s+hair", "straight"),
(r"schulterlang\w*|shoulder[- ]length", "shoulder-length"),
(r"(?<![a-zäöü])lang\w*[,\s]+(?:\w+\s+)?haar\w*|long\s+(?:\w+\s+)?hair|waist-length", "long"),
(r"(?<![a-zäöü])kurz\w*[,\s]+(?:\w+\s+)?haar\w*|short\s+(?:\w+\s+)?hair|kurzgeschnitten\w*|cropped", "short"),
(r"struppig\w*|zerzaust\w*|unruly|tousled|sträubend\w*|wild\w*\s+haar", "unruly"),
(r"dicht\w*\s+haar|thick\s+hair", "thick"),
))
_BEARD_COLOUR_RE = re.compile(
r"\b(wei(?:ß|ss)|grau|silber|schwarz|rot|blond|braun|white|grey|gray|silver|black|red|brown|blond)\w*\s+(?:\w+\s+)?(?:bart|beard)",
re.IGNORECASE)
_BEARD_COLOURS = {"weiß": "white", "weiss": "white", "white": "white", "grau": "grey", "grey": "grey", "gray": "grey",
"silber": "grey", "silver": "grey", "schwarz": "black", "black": "black", "rot": "red", "red": "red",
"blond": "blond", "braun": "brown", "brown": "brown"}
_HAIR_ANCHOR = re.compile(r"\b(?:haar\w*|hair\w*|locken|locks|mähne|mane|schopf)\b", re.IGNORECASE)
_EYE_ANCHOR = re.compile(r"\b(?:augen|auge|eyes?)\b", re.IGNORECASE)
_WORD = r"[\wäöüßÄÖÜ'-]+"
def _classify(word: str, rules) -> str:
for rx, oid in rules:
if re.fullmatch(rx.pattern, word, re.IGNORECASE):
return oid
return ""
def _nearest_colour(text: str, anchor: re.Pattern, rules) -> str:
"""The colour word next to a hair/eye noun: the adjectives right before it, then a predicate
right after it ('hair: black', 'eyes are green'), then an adjective across a comma."""
for sentence in re.split(r"[.;!?\n]", text or ""):
for hit in anchor.finditer(sentence):
before, after = sentence[:hit.start()], sentence[hit.end():]
direct = re.search(rf"((?:{_WORD}\s+){{0,2}})$", before)
words = re.findall(_WORD, direct.group(1)) if direct else []
for word in reversed(words):
oid = _classify(word, rules)
if oid:
return oid
pred = re.match(rf"[\s:—-]*(?:(?:ist|sind|is|are|wirkt|looks?)\s+)?({_WORD})", after)
if pred:
oid = _classify(pred.group(1), rules)
if oid:
return oid
across = re.search(rf"({_WORD}),\s*(?:{_WORD}\s+){{0,1}}$", before)
if across:
oid = _classify(across.group(1), rules)
if oid:
return oid
return ""
def _read_hair_colour(text: str) -> str:
m = _COMPOUND_HAIR.search(text)
if m:
return _COMPOUND_MAP.get(m.group(1).lower(), "")
return _nearest_colour(text, _HAIR_ANCHOR, _HAIR_COLOUR_READ)
def _read_eye_colour(text: str) -> str:
m = re.search(r"\b(blau|gr(?:ü|u)n|braun|grau|schwarz|dunkel|hell)(?:äugig|augig)", text or "", re.IGNORECASE)
if m:
return {"blau": "blue", "grün": "green", "grun": "green", "braun": "brown", "grau": "grey",
"schwarz": "black", "dunkel": "dark_brown", "hell": "light_blue"}[m.group(1).lower()]
return _nearest_colour(text, _EYE_ANCHOR, _EYE_COLOUR_READ)
def read_sheet_traits(text: str) -> dict[str, str]:
"""slot -> option id (or 's:<words>' for free hair wording) for everything the text states."""
text = text or ""
found: dict[str, str] = {}
colour = _read_hair_colour(text)
if colour:
found["hair_colour"] = colour
eyes = _read_eye_colour(text)
if eyes:
found["eye_colour"] = eyes
for slot, rules in _SIMPLE_READS.items():
for rx, oid in rules:
if rx.search(text):
found[slot] = oid
break
if found.get("facial_hair") == "sheet_beard":
colour = _BEARD_COLOUR_RE.search(text)
if colour:
found["facial_hair"] = "sheet_beard:" + _BEARD_COLOURS.get(colour.group(1).lower(), "")
if found["facial_hair"].endswith(":"):
found["facial_hair"] = "sheet_beard"
words = [oid for rx, oid in _HAIR_STYLE_WORDS if rx.search(text)]
if words:
found["hair_style"] = "bald" if "bald" in words else "s:" + "|".join(words)
return found
def _sheet_phrase(slot: str, oid: str) -> str:
if oid == "bald":
return "a bald head"
if oid.startswith("s:"):
return ", ".join(oid[2:].split("|")) + " hair"
if oid.startswith("sheet_beard"):
return f"a {oid.split(':', 1)[1]} beard" if ":" in oid else "a beard"
if oid == "sheet_mark":
return "" # scars/tattoos stay in the appearance text; prompt hygiene strips scars on purpose
opt = _OPTION_BY_ID.get(slot, {}).get(oid)
return opt.phrase if opt else ""
# ---------------------------------------------------------------------------------------------
# Profiles
# ---------------------------------------------------------------------------------------------
@dataclass
class FaceProfile:
name: str
values: dict = field(default_factory=dict) # slot -> option id
sources: dict = field(default_factory=dict) # slot -> "sheet" | "kin" | "pool" | "override"
species: str = "h"
stage: str = "y"
gender: str = ""
override: str = ""
def phrase(self, slot: str) -> str:
oid = self.values.get(slot)
if oid is None:
return ""
if oid in _OPTION_BY_ID.get(slot, {}):
return _OPTION_BY_ID[slot][oid].phrase
return _sheet_phrase(slot, oid)
def clause(self) -> str:
"""Compact English 'face:' clause for the image prompt."""
if self.override:
return "face: " + self.override.strip().rstrip(".")
eye_colour = self.phrase("eye_colour")
eye_shape = self.phrase("eyes")
eyes = " ".join(p for p in (eye_shape, eye_colour) if p)
eyes = f"{eyes} eyes" if eyes else ""
parts = [self.phrase("face_shape"), self.phrase("jaw"), self.phrase("nose"), eyes, self.phrase("brows"),
self.phrase("mouth"), self.phrase("cheeks"), self.phrase("skin"), self.phrase("age_signs"),
self.phrase("hair_colour"), self.phrase("hair_style"), self.phrase("facial_hair"), self.phrase("mark")]
parts = [p for p in parts if p]
return ("face: " + ", ".join(parts)) if parts else ""
def to_dict(self) -> dict:
return {"name": self.name, "species": self.species, "stage": self.stage, "gender": self.gender,
"clause": self.clause(), "override": bool(self.override),
"slots": {s: {"id": self.values[s], "text": self.phrase(s), "source": self.sources.get(s, "pool")}
for s in SLOTS if s in self.values}}
def _hash(*parts) -> int:
return int.from_bytes(hashlib.sha256("|".join(str(p) for p in parts).encode("utf-8")).digest()[:8], "big")
def _allowed(opt: Option, inp: FaceInput) -> bool:
if opt.species and not (opt.species & set(inp.species)):
return False
if opt.no_vampire and inp.species == "v":
return False
if opt.gender == "u": # styles that read either way: only when the sheet states no gender
if inp.gender:
return False
elif opt.gender and opt.gender != inp.gender:
return False
stage = "y" if inp.species == "e" and inp.stage in ("m", "o") else inp.stage # elves do not age visibly
if opt.ages and stage not in opt.ages:
return False
if opt.culture == "N" and inp.culture == "N":
return False
if opt.culture == "S" and inp.culture == "S":
return False
return True
def _slot_applicable(slot: str, inp: FaceInput) -> bool:
if slot == "facial_hair":
return inp.gender == "m" and inp.stage in ("y", "m", "o") and "e" not in inp.species.replace("he", "")
return True
def candidates(slot: str, inp: FaceInput, hair_old_beard: bool = False) -> list[Option]:
opts = [o for o in _POOLS[slot] if _allowed(o, inp)]
if slot == "hair_colour" and hair_old_beard:
opts = [o for o in opts if o.id in ("grey", "white", "grey_streaked")] or opts
return opts
_KIN_RE = re.compile(
r"\b(bruder|schwester|geschwister|vater|mutter|sohn|tochter|onkel|tante|cousin\w*|neffe|nichte|zwilling\w*|"
r"brother|sister|sibling|father|mother|son|daughter|uncle|aunt|nephew|niece|twin|vater von|sohn von)\b",
re.IGNORECASE,
)
def find_kin(name: str, relationships: str, roster_names: Iterable[str]) -> tuple:
"""Roster names that the relationships text names inside a clause with a kinship word."""
names = [n for n in roster_names if n and n != name]
found = []
for clause in re.split(r"[;\n.]", relationships or ""):
kin_words = [m.span() for m in _KIN_RE.finditer(clause)]
if not kin_words:
continue
lowered = clause.lower()
for other in names:
first = other.split()[0].lower()
if len(first) < 3 or other in found:
continue
for hit in re.finditer(rf"(?<![a-zäöüß]){re.escape(first)}(?![a-zäöüß])", lowered):
# the kinship word must stand right next to the name ("Vater: Darrag", "Vater von Marrad")
if any(abs(hit.start() - end) <= 12 or abs(start - hit.end()) <= 12 for start, end in kin_words):
found.append(other)
break
return tuple(found)
def _sim(a: dict, b: dict, decided: Iterable[str]) -> float:
return sum(WEIGHTS[s] for s in decided if a.get(s) is not None and a.get(s) == b.get(s))
def build_profile(inp: FaceInput, book_id: str, peers: list[FaceProfile]) -> FaceProfile:
"""One character's profile given the profiles of the characters before it in the roster."""
prof = FaceProfile(inp.name, species=inp.species, stage=inp.stage, gender=inp.gender, override=inp.override)
source_text = inp.override or inp.text
traits = read_sheet_traits(source_text)
for slot, oid in traits.items():
if slot == "facial_hair" and not inp.gender:
pass # unknown gender: keep what the sheet says
prof.values[slot] = oid
prof.sources[slot] = "override" if inp.override else "sheet"
if prof.values.get("hair_style") == "bald":
prof.values.pop("hair_colour", None)
prof.sources.pop("hair_colour", None)
beard_colour = prof.values.get("facial_hair", "") in ("sheet_beard:white", "sheet_beard:grey") and inp.stage in ("m", "o")
kin_profiles = [p for p in peers if p.name in inp.kin]
others = [p for p in peers if p.name not in inp.kin]
decided = [s for s in SLOTS if s in prof.values]
for slot in sorted((s for s in SLOTS if s not in prof.values), key=lambda s: -WEIGHTS[s]):
if not _slot_applicable(slot, inp):
continue
if slot == "hair_colour" and prof.values.get("hair_style") == "bald":
continue
opts = candidates(slot, inp, hair_old_beard=(slot == "hair_colour" and beard_colour))
if not opts:
continue
chosen = None
if slot in FAMILY_SLOTS:
for rel in kin_profiles:
if _hash(book_id, inp.name, rel.name, slot) % 2 == 0 and rel.values.get(slot) in {o.id for o in opts}:
chosen = rel.values[slot]
prof.sources[slot] = "kin"
break
if chosen is None:
def cost(opt: Option):
total = 0.0
for p in others:
if p.values.get(slot) == opt.id:
total += WEIGHTS[slot] * (1.0 + _sim(prof.values, p.values, decided))
return (round(total, 6), _hash(book_id, inp.name, slot, opt.id))
chosen = min(opts, key=cost).id
prof.sources[slot] = "pool"
prof.values[slot] = chosen
decided.append(slot)
return prof
def assign_profiles(book_id: str, inputs: list[FaceInput]) -> dict[str, FaceProfile]:
"""Profiles for a whole roster, in the given order (use creation order: stable when it grows)."""
result: dict[str, FaceProfile] = {}
ordered: list[FaceProfile] = []
names = [i.name for i in inputs if i.species]
related: dict[str, set] = {n: set() for n in names}
for inp in inputs:
for other in inp.kin:
if other in related and inp.name in related:
related[inp.name].add(other)
related[other].add(inp.name)
for inp in inputs:
if not inp.species or inp.name.strip().lower() in _HUMAN_NAME_SKIP or inp.name in result:
continue
prof = build_profile(FaceInput(**{**inp.__dict__, "kin": tuple(sorted(related.get(inp.name, ())))}), book_id, ordered)
result[inp.name] = prof
ordered.append(prof)
return result
def distance(a: dict, b: dict) -> float:
"""Weighted share of facial slots that differ. Unspecified slots both count as the model default."""
total = diff = 0.0
for slot in SLOTS:
va, vb = a.get(slot), b.get(slot)
if va is None and vb is None:
continue
total += WEIGHTS[slot]
if va != vb:
diff += WEIGHTS[slot]
return diff / total if total else 0.0
def baseline_values(inp: FaceInput) -> dict[str, str]:
"""What the old prompt carried: the sheet's own traits; everything else is the model default."""
values = read_sheet_traits(inp.override or inp.text)
return {slot: values.get(slot, DEFAULT) for slot in SLOTS}
def mean_pairwise(rows: list[dict]) -> float:
n = len(rows)
if n < 2:
return 0.0
total = sum(distance(rows[i], rows[j]) for i in range(n) for j in range(i + 1, n))
return total / (n * (n - 1) / 2)
def similarity_report(inputs: list[FaceInput], profiles: dict[str, FaceProfile]) -> dict:
"""Before/after distinctness for a roster, and the most similar pairs. Read-only."""
names = [i.name for i in inputs if i.name in profiles]
by_name = {i.name: i for i in inputs}
before = {n: baseline_values(by_name[n]) for n in names}
after = {n: dict(profiles[n].values) for n in names}
def pairs(rows):
out = [(round(1 - distance(rows[a], rows[b]), 3), a, b) for i, a in enumerate(names) for b in names[i + 1:]]
return sorted(out, reverse=True)
specified = [sum(1 for s in SLOTS if before[n][s] != DEFAULT) for n in names]
return {
"characters": len(names),
"mean_distance_before": round(mean_pairwise([before[n] for n in names]), 4),
"mean_distance_after": round(mean_pairwise([after[n] for n in names]), 4),
"mean_sheet_slots_specified": round(sum(specified) / len(specified), 2) if specified else 0.0,
"characters_without_any_face_trait": sum(1 for x in specified if x == 0),
"most_similar_before": [{"similarity": s, "a": a, "b": b} for s, a, b in pairs(before)[:5]],
"most_similar_after": [{"similarity": s, "a": a, "b": b} for s, a, b in pairs(after)[:5]],
}
def face_input_from_sheet(name: str, sheet: dict, age_category: str = "adult", relationships_names: Iterable[str] = (),
species_text: str = "") -> FaceInput:
"""Build a FaceInput from a character sheet (German or English text)."""
sheet = sheet or {}
species_text = species_text or _text(sheet.get("visual_species")) or _text(sheet.get("race_species"))
extra = " ".join(_text(sheet.get(k)) for k in ("title", "profession", "aliases", "archetype"))
code = species_code(species_text, name, extra)
physical = _text(sheet.get("visual_appearance")) or _text(sheet.get("physical"))
nationality = _text(sheet.get("nationality_background"))
culture = "N" if _NORTH_RE.search(nationality) else "S" if _SOUTH_RE.search(nationality) else ""
return FaceInput(
name=name, species=code, stage=age_stage(_text(sheet.get("age_estimate")) or _text(sheet.get("age")), age_category, physical),
gender=gender_code(_text(sheet.get("gender"))), culture=culture, text=physical,
override=_text(sheet.get("visual_face")),
kin=find_kin(name, _text(sheet.get("relationships")), relationships_names),
)