800 lines
40 KiB
Python
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),
|
|
)
|