feat(characters): distinct deterministic face profiles per character
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
488e57e609
commit
7eabdf0e43
@ -7,6 +7,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- **Distinct, deterministic face profile per character** (`core/face_profile.py`). The cast of one book looked like one family because most sheets name no facial feature and the image model then draws its default face. Each character now gets a compact `face:` clause (face shape, jaw, nose, eyes, brows, mouth, cheekbones, skin, hair colour and style, facial hair, age signs, a mark) in the Subject of every character prompt: traits the sheet states (German or English) are kept, the rest comes from species/age/gender/culture-filtered option pools, chosen so that the characters of a book are spread apart (greedy max-distance in creation order; adding characters never changes an existing profile; relatives named in `relationships` share a few traits). Owner override: a `visual_face` text on the sheet. On the real books 02/03 the mean pairwise face-descriptor distance goes from 0.09/0.04 to 0.91 (44/68 characters had no face trait at all). See `architecture/character-visual-identity.md`.
|
||||||
|
- **Dry run for faces:** `POST /api/character-face-preview {book}` lists every character's face profile (what came from the sheet, what was chosen) with a similarity matrix and before/after score; `POST /api/character-prompt-preview` also returns `face_clause`. Nothing is generated or saved.
|
||||||
- **Character info is translated to English before image prompts are built** (`core/prompt_translation.py`, `_english_prompt_inputs`). Free-text sheet fields (species, appearance, clothing, age, props, story-moment quote) and the book setting are translated in one batched, low-temperature call through the app's existing LLM, validated per field (non-empty, no error echo, length, names preserved, not still German), and cached in the new `prompt_translation_cache` table keyed by source hash, language, model and prompt version. Records and the displayed German text never change; species/age/dwarf/period checks now run on English. If the LLM is down or output is rejected the original text is used, a warning is logged, `prompt_translation.used_untranslated_text` is reported and generation continues. `PROMPT_TRANSLATION=off` disables it. New read-only dry run `POST /api/character-prompt-preview`. See architecture/character-visual-identity.md.
|
- **Character info is translated to English before image prompts are built** (`core/prompt_translation.py`, `_english_prompt_inputs`). Free-text sheet fields (species, appearance, clothing, age, props, story-moment quote) and the book setting are translated in one batched, low-temperature call through the app's existing LLM, validated per field (non-empty, no error echo, length, names preserved, not still German), and cached in the new `prompt_translation_cache` table keyed by source hash, language, model and prompt version. Records and the displayed German text never change; species/age/dwarf/period checks now run on English. If the LLM is down or output is rejected the original text is used, a warning is logged, `prompt_translation.used_untranslated_text` is reported and generation continues. `PROMPT_TRANSLATION=off` disables it. New read-only dry run `POST /api/character-prompt-preview`. See architecture/character-visual-identity.md.
|
||||||
- **Record/replay cache for vision OCR responses** (`core/vision_cache.py`, `MAGAZINE_VISION_CACHE=live|record|replay`). Freezes the raw model response per (model, prompt, image, parameters) in a git-ignored cache directory so OCR/layout/post-processing changes can be compared on identical vision output; replay fails loudly on a miss. `bench_ocr.py --vision-cache` and `compare_magazines.py` (`--baseline-vision-run`, `--new-vision-run`) report mode and models and say whether a comparison is frozen. See architecture/magazine-ingestion.md.
|
- **Record/replay cache for vision OCR responses** (`core/vision_cache.py`, `MAGAZINE_VISION_CACHE=live|record|replay`). Freezes the raw model response per (model, prompt, image, parameters) in a git-ignored cache directory so OCR/layout/post-processing changes can be compared on identical vision output; replay fails loudly on a miss. `bench_ocr.py --vision-cache` and `compare_magazines.py` (`--baseline-vision-run`, `--new-vision-run`) report mode and models and say whether a comparison is frozen. See architecture/magazine-ingestion.md.
|
||||||
|
|
||||||
@ -14,6 +16,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
- **Driving mode plays on automatically.** When an article ends it is marked finished (saved like the playlist does) and the next article that has audio starts (rows without audio are skipped, speed carries over, title, picture, text, index highlight and lock-screen metadata follow, "Now playing: ..." is announced once). At the last article it stops with an *End of magazine* state, no wrap-around. New switch *Play next article automatically* (default on, remembered in the driving preferences); off stops after the article as before. A next article whose audio cannot be loaded is skipped with a brief message. Manual Next, seeks and duplicate `ended` events never advance twice.
|
- **Driving mode plays on automatically.** When an article ends it is marked finished (saved like the playlist does) and the next article that has audio starts (rows without audio are skipped, speed carries over, title, picture, text, index highlight and lock-screen metadata follow, "Now playing: ..." is announced once). At the last article it stops with an *End of magazine* state, no wrap-around. New switch *Play next article automatically* (default on, remembered in the driving preferences); off stops after the article as before. A next article whose audio cannot be loaded is skipped with a brief message. Manual Next, seeks and duplicate `ended` events never advance twice.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Prompt wording that pulled every face and head toward one look was removed: "consistent facial construction" (concept style) became "individually distinct faces"; the hair locks no longer force "reaching at least to the ears or collar" / "past the shoulders" when the face clause states the hair; the dwarf body lock no longer says "a thick, full beard" (the beard now comes from the face clause, men only, so dwarf women get none); the Subject limit in the compact render prompts is 900 instead of 700 characters to make room for the clause.
|
||||||
- `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before.
|
- `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before.
|
||||||
### Fixed
|
### Fixed
|
||||||
- **"Colors: Dark (night)" looked disabled in the light theme.** The menu item carried `data-theme="dark"`, which is also the hook of the app-wide dark palette (`[data-theme="dark"] {...}`), so the button itself got the dark palette's light text colour on the light menu. The theme buttons now use `data-theme-choice`, so no app theme selector can match them.
|
- **"Colors: Dark (night)" looked disabled in the light theme.** The menu item carried `data-theme="dark"`, which is also the hook of the app-wide dark palette (`[data-theme="dark"] {...}`), so the button itself got the dark palette's light text colour on the light menu. The theme buttons now use `data-theme-choice`, so no app theme selector can match them.
|
||||||
|
|||||||
33
Manual.md
33
Manual.md
@ -139,6 +139,39 @@ continues. Set `PROMPT_TRANSLATION=off` to disable translation entirely.
|
|||||||
|
|
||||||
**Privacy:** character text goes only to the LLM you configured; cached translations stay in the local database.
|
**Privacy:** character text goes only to the LLM you configured; cached translations stay in the local database.
|
||||||
|
|
||||||
|
### Distinct faces for each character
|
||||||
|
|
||||||
|
**Purpose:** characters of one book used to look like members of one family, because most sheets describe no face
|
||||||
|
and the image model then draws its default one. Every character now gets its own face description, so the cast is
|
||||||
|
easy to tell apart. What the book says about a character always wins.
|
||||||
|
|
||||||
|
**Prerequisites:** none. It is part of generating character prompts and images.
|
||||||
|
|
||||||
|
**Usage:** generate profile/reference images as before. The prompt now contains a `face:` clause such as
|
||||||
|
`face: oval face, narrow pointed chin, straight nose, wide-set grey eyes, ..., chestnut hair, shoulder-length wavy hair`.
|
||||||
|
Parts the sheet states (for example "schwarzhaarig, blaue Augen") are kept; the other parts are chosen by the app,
|
||||||
|
fitting species, age and gender (no beards for women or children, no elf traits on humans, grey hair for the old),
|
||||||
|
and spread across the characters of the book. The choice is stable: the same character always gets the same face,
|
||||||
|
and adding new characters does not change existing ones. Relatives named in the relationships field (brother,
|
||||||
|
father ...) may share eye colour, hair colour, nose or jaw.
|
||||||
|
|
||||||
|
**Override:** to fix a face, write it in the sheet field `visual_face` (free text, English works best), for example
|
||||||
|
`narrow face, hooked nose, one pale grey eye, black hair tied back`. It replaces the generated face. Describing
|
||||||
|
features in the physical appearance also works; those words are kept.
|
||||||
|
|
||||||
|
**Preview without generating:** `POST /api/character-face-preview` with `{"book": "<book title>"}` returns every
|
||||||
|
character's face (and where each part came from) plus a similarity matrix and a before/after score;
|
||||||
|
`POST /api/character-prompt-preview` shows the finished prompt including `face_clause`. Nothing is generated or saved.
|
||||||
|
|
||||||
|
**Expected result:** clearly different faces within a book. Existing images stay as they are until you regenerate them.
|
||||||
|
|
||||||
|
**Errors:** if the cast cannot be read, the character gets a face from its own sheet alone. Non-human sheets (a raven,
|
||||||
|
the narrator) get no face clause.
|
||||||
|
|
||||||
|
**Mobile, accessibility, offline:** no interface change; works offline (no LLM needed for the face itself).
|
||||||
|
|
||||||
|
**Privacy:** nothing leaves the machine; profiles are derived from the stored sheets and not saved.
|
||||||
|
|
||||||
### Keeping characters apart (profiles and the character library)
|
### Keeping characters apart (profiles and the character library)
|
||||||
|
|
||||||
**Purpose:** each character's profile holds only that character's facts.
|
**Purpose:** each character's profile holds only that character's facts.
|
||||||
|
|||||||
@ -212,3 +212,66 @@ confused species checks). Every image-prompt builder therefore reads an **Englis
|
|||||||
writes no record (only the translation cache).
|
writes no record (only the translation cache).
|
||||||
* **Side fixes:** `_character_age_category` now reads `age_estimate` (sheets store that key; a boy used to render
|
* **Side fixes:** `_character_age_category` now reads `age_estimate` (sheets store that key; a boy used to render
|
||||||
adult); `_period_wording` no longer turns German "hat" (has) into a hood.
|
adult); `_period_wording` no longer turns German "hat" (has) into a hood.
|
||||||
|
|
||||||
|
|
||||||
|
## Distinct faces per character (`core/face_profile.py`, 2026-10-03)
|
||||||
|
|
||||||
|
**Problem (owner report):** the faces of different characters of one book look like one family. Measured on the
|
||||||
|
real library (read-only snapshot, 4 books): 28-68 of 49-83 sheets name **no** facial feature at all, so the
|
||||||
|
prompt carried none either, and the image model (Qwen-Image 2512 + 4-step Lightning LoRA, cfg 1.0 in every bundled
|
||||||
|
graph) answers with its own default face. Seeds are already per prompt/character (`voice-creator-image-v5|<prompt>`,
|
||||||
|
`voice-creator-cast-v6|<book>|<style>|<name>|<uuid>`), so seeds are not the cause. Generic wording made it worse:
|
||||||
|
"one fixed studio artist, with consistent facial construction" (concept style), "a full, thick head of hair
|
||||||
|
reaching at least to the ears or collar, simply combed, tied back, or left loose", "Long hair reaching past the
|
||||||
|
shoulders" (armoured women) and "a thick, full beard" in the dwarf body lock (also for dwarf women).
|
||||||
|
|
||||||
|
**Design.** A deterministic, explainable *face profile* gives every humanoid character one value per slot:
|
||||||
|
face shape, jaw/chin, nose, eye shape, eye colour, brows, mouth, cheekbones, skin, hair colour, hair style,
|
||||||
|
facial hair, age signs, one distinguishing mark. Pure functions, no I/O, no randomness; the clause is the
|
||||||
|
compact text `face: oval face, narrow pointed chin, ...` that goes into the Subject right after species (high
|
||||||
|
priority; a hand-edited Subject without it gets it prepended).
|
||||||
|
|
||||||
|
1. **Sheet first.** Traits the sheet states (German or English: `physical` / `visual_appearance`) are read by
|
||||||
|
keyword rules and kept (`source: sheet`). Stated hair length/texture is kept as the sheet's own words.
|
||||||
|
Scars and tattoos occupy the mark slot but are never written into the prompt (existing hygiene: the model draws
|
||||||
|
a scar as a wound). Unknown wording is simply not read: nothing is invented that contradicts what was read.
|
||||||
|
2. **Owner override.** `visual_face` (free text on the sheet) replaces the generated clause verbatim; it is still
|
||||||
|
parsed so the character keeps its place in the distinctness assignment.
|
||||||
|
3. **Pools, filtered.** Each option carries species (human/elf/dwarf/orc/vampire, half-elf = human+elf), age stage
|
||||||
|
(child, teen, young, middle, old), gender and a culture exclusion. Facial hair exists only for men from
|
||||||
|
young adulthood, never for elves; old people get grey/white hair and old-age skin; elves show no age; vampires
|
||||||
|
stay pale; a culture named in `nationality_background` (north/south) removes implausible skin options only.
|
||||||
|
Unknown gender gets only styles that read either way. Species is the character's own leading assertion
|
||||||
|
(`Mensch (oder Zwerg ...)` is human); a raven, golem, narrator etc. gets no profile. Ears stay with
|
||||||
|
`_character_ear_shape`; the clause never mentions ears.
|
||||||
|
4. **Spread over the cast.** `char_face_roster(book)` reads the cast in creation order (a light SQL extract, no
|
||||||
|
images). Each character takes, per free slot, the value that collides least with the earlier characters,
|
||||||
|
weighted by how similar the colliding character already is (greedy max-distance); ties by
|
||||||
|
`sha256(book|name|slot|option)`. A character depends only on those before it, so **adding characters never
|
||||||
|
changes an existing profile** (a new, unsaved character goes last). Deleting an earlier character, or editing
|
||||||
|
a sheet's traits, can shift later ones; pin a face with `visual_face` where that matters.
|
||||||
|
5. **Relatives** named in `relationships` next to a kinship word (brother, Vater: ...) pass on family slots
|
||||||
|
(eye colour, hair colour, nose, jaw, each by a hash coin flip) and are ignored in the collision cost; all other
|
||||||
|
slots still avoid each other.
|
||||||
|
6. **Hair locks follow the profile.** When the clause states hair, the "reaching to the ears or collar" and
|
||||||
|
"past the shoulders" wording is replaced by "the hair described in the face clause". The dwarf body lock no
|
||||||
|
longer carries a beard; the beard (men only) comes from the clause.
|
||||||
|
|
||||||
|
**Not added:** a "not the same face as other characters" negative. The bundled graphs run cfg 1.0, where the
|
||||||
|
negative conditioning is skipped, and the positive encoder draws any noun it reads.
|
||||||
|
|
||||||
|
**Distinctness measure.** `distance(a, b)` = weighted share of slots that differ (hair 1.5, face shape/skin/nose/beard
|
||||||
|
1.2, others 0.8-1.0). *Before* = what the old prompt carried: only slots the sheet states; every other slot is the
|
||||||
|
"model default" and counts as equal. *After* = the profile. This is a descriptor-space proxy, not a measurement of
|
||||||
|
rendered images.
|
||||||
|
|
||||||
|
**Dry run (generates nothing, writes nothing):** `POST /api/character-face-preview {book}` returns each
|
||||||
|
character's profile (slot, text, source) and the before/after mean pairwise distance, the most similar pairs and
|
||||||
|
the similarity matrix; `POST /api/character-prompt-preview` now also returns `face_clause`. Prompts get the clause
|
||||||
|
through `_attach_face_profile` in `character_generate_prompts`, `character_generate_consistent_images` and the
|
||||||
|
preview; without cast context `_character_visual_facts` falls back to the character's own solo profile.
|
||||||
|
|
||||||
|
**Limits.** Existing images are unchanged until regenerated, and the identity fingerprint is unchanged (the
|
||||||
|
profile is derived, not stored), so pairs are not flagged stale. The model may still blur details at cfg 1.0 with
|
||||||
|
4 steps; if regenerated faces stay too similar the options are a higher-cfg/more-steps pass for the portrait
|
||||||
|
(slower), or a per-character face LoRA/IP-adapter reference. Visual verification needs real regenerations.
|
||||||
|
|||||||
@ -475,6 +475,32 @@ def char_get_all() -> list[dict]:
|
|||||||
return [_row_to_char(r) for r in rows]
|
return [_row_to_char(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def char_face_roster(book: str) -> list[dict]:
|
||||||
|
"""The cast of one book in creation order, with only the sheet fields the face profile reads.
|
||||||
|
|
||||||
|
Images make a full sheet several MB, so the fields are cut out in SQL (one JSON parse per
|
||||||
|
row) instead of loading whole records. Read-only.
|
||||||
|
"""
|
||||||
|
keys = ("race_species", "visual_species", "gender", "age_estimate", "age", "physical", "visual_appearance",
|
||||||
|
"relationships", "nationality_background", "title", "profession", "aliases", "archetype",
|
||||||
|
"visual_face", "sources")
|
||||||
|
paths = ", ".join(f"'$.{k}'" for k in keys)
|
||||||
|
with _open() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT id, name, created, json_extract(sheet, {paths}) AS picked FROM characters "
|
||||||
|
"WHERE book=? ORDER BY created, id", (book,),
|
||||||
|
).fetchall()
|
||||||
|
roster = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
values = json.loads(row["picked"] or "[]")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
values = []
|
||||||
|
roster.append({"id": row["id"], "name": row["name"], "created": row["created"],
|
||||||
|
"sheet": {k: v for k, v in zip(keys, values) if v not in (None, "")}})
|
||||||
|
return roster
|
||||||
|
|
||||||
|
|
||||||
def char_get(char_id: str) -> dict | None:
|
def char_get(char_id: str) -> dict | None:
|
||||||
with _open() as conn:
|
with _open() as conn:
|
||||||
row = conn.execute("SELECT * FROM characters WHERE id=?", (char_id,)).fetchone()
|
row = conn.execute("SELECT * FROM characters WHERE id=?", (char_id,)).fetchone()
|
||||||
|
|||||||
799
core/face_profile.py
Normal file
799
core/face_profile.py
Normal file
@ -0,0 +1,799 @@
|
|||||||
|
"""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),
|
||||||
|
)
|
||||||
@ -38,6 +38,7 @@ from core.database import background_job_create, background_job_get, background_
|
|||||||
from core.retry import request_with_retries
|
from core.retry import request_with_retries
|
||||||
from core import prompt_translation as _pt
|
from core import prompt_translation as _pt
|
||||||
from core import character_records as _cr
|
from core import character_records as _cr
|
||||||
|
from core import face_profile as _fp
|
||||||
from core.character_image_check import checker_unavailable, failure_count, summarise
|
from core.character_image_check import checker_unavailable, failure_count, summarise
|
||||||
from core.attribution_spans import (
|
from core.attribution_spans import (
|
||||||
SpanRequestError, build_messages as _span_build_messages, max_tokens_for as _span_max_tokens,
|
SpanRequestError, build_messages as _span_build_messages, max_tokens_for as _span_max_tokens,
|
||||||
@ -1992,6 +1993,84 @@ def _character_species_text(sheet: dict, limit: int = 100) -> str:
|
|||||||
return _established_visual_value({"race_species": raw}, "visual_species", "race_species", limit)
|
return _established_visual_value({"race_species": raw}, "visual_species", "race_species", limit)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Distinct faces (core/face_profile.py) ────────────────────────────────────────────────
|
||||||
|
# The image model draws one default face for every prompt that names no facial features, and most
|
||||||
|
# sheets name none. Each character therefore gets a face clause (face shape, jaw, nose, eyes,
|
||||||
|
# skin, hair, facial hair ...) taken from its sheet where the sheet speaks and otherwise chosen
|
||||||
|
# deterministically so that the cast of one book is spread over the option space.
|
||||||
|
|
||||||
|
def _face_input_for(name: str, sheet: dict, roster_names=()) -> "_fp.FaceInput":
|
||||||
|
sheet = _character_form_sheet(sheet)
|
||||||
|
return _fp.face_input_from_sheet(name, sheet, _character_age_category(sheet), roster_names,
|
||||||
|
_character_species_text(sheet, 100))
|
||||||
|
|
||||||
|
|
||||||
|
def _book_face_profiles(book: str, name: str, sheet: dict) -> tuple[dict, list]:
|
||||||
|
"""Profiles of the whole cast of ``book`` (creation order), with this character's live sheet.
|
||||||
|
|
||||||
|
A character that is not stored yet goes last, so nobody else's profile changes.
|
||||||
|
Read-only; falls back to this character alone when the cast cannot be read.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from core.database import char_face_roster
|
||||||
|
roster = char_face_roster(book) if book else []
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning("face profile: cast of %r unreadable", book, exc_info=True)
|
||||||
|
roster = []
|
||||||
|
names = [r["name"] for r in roster]
|
||||||
|
inputs, seen = [], False
|
||||||
|
for row in roster:
|
||||||
|
if row["name"] == name:
|
||||||
|
inputs.append(_face_input_for(name, sheet, names))
|
||||||
|
seen = True
|
||||||
|
else:
|
||||||
|
inputs.append(_face_input_for(row["name"], row["sheet"], names))
|
||||||
|
if not seen:
|
||||||
|
inputs.append(_face_input_for(name, sheet, names + [name]))
|
||||||
|
return _fp.assign_profiles(book, inputs), inputs
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_face_profile(name: str, book: str, native_sheet: dict, prompt_sheet: dict) -> dict:
|
||||||
|
"""Copy of the (English) prompt sheet carrying this character's face clause.
|
||||||
|
|
||||||
|
The profile is read from the stored (native-language) sheet so that it is the same one the
|
||||||
|
other characters of the book were spread against; the English copy only carries the result.
|
||||||
|
"""
|
||||||
|
prompt_sheet = dict(prompt_sheet or {})
|
||||||
|
try:
|
||||||
|
profiles, _ = _book_face_profiles(book, name, native_sheet)
|
||||||
|
profile = profiles.get(name)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning("face profile for %r failed", name, exc_info=True)
|
||||||
|
profile = None
|
||||||
|
if profile is not None:
|
||||||
|
_set_face_fields(prompt_sheet, profile)
|
||||||
|
return prompt_sheet
|
||||||
|
|
||||||
|
|
||||||
|
def _set_face_fields(sheet: dict, profile: "_fp.FaceProfile") -> None:
|
||||||
|
sheet["_face_clause"] = profile.clause()
|
||||||
|
hair = profile.values.get("hair_style")
|
||||||
|
sheet["_face_hair"] = bool(hair) and hair != "bald"
|
||||||
|
sheet["_face_beard"] = profile.values.get("facial_hair") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _character_face_clause(name: str, sheet: dict) -> str:
|
||||||
|
"""The 'face: ...' clause: the attached one, else this character's own (no cast context)."""
|
||||||
|
clause = _visual_prompt_value(sheet.get("_face_clause"))
|
||||||
|
if clause or "_face_clause" in sheet:
|
||||||
|
return clause
|
||||||
|
profile = _fp.assign_profiles("", [_face_input_for(name, sheet)]).get(name)
|
||||||
|
return profile.clause() if profile else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _face_hair_is_described(sheet: dict) -> bool:
|
||||||
|
if "_face_hair" in sheet:
|
||||||
|
return bool(sheet.get("_face_hair"))
|
||||||
|
profile = _fp.assign_profiles("", [_face_input_for(str(sheet.get("name") or "x"), sheet)]).get(str(sheet.get("name") or "x"))
|
||||||
|
return bool(profile and profile.values.get("hair_style") not in (None, "bald"))
|
||||||
|
|
||||||
|
|
||||||
def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) -> str:
|
def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) -> str:
|
||||||
"""Build the replaceable Subject block used by every character image."""
|
"""Build the replaceable Subject block used by every character image."""
|
||||||
sheet = _character_form_sheet(sheet)
|
sheet = _character_form_sheet(sheet)
|
||||||
@ -2009,6 +2088,7 @@ def _character_visual_facts(name: str, sheet: dict, include_items: bool = True)
|
|||||||
("gender", clipped("gender", 40)),
|
("gender", clipped("gender", 40)),
|
||||||
("age", clipped("age_estimate", 100)),
|
("age", clipped("age_estimate", 100)),
|
||||||
("species", species),
|
("species", species),
|
||||||
|
("face", _character_face_clause(name, sheet).removeprefix("face:").strip()),
|
||||||
("title", clipped("title", 100)),
|
("title", clipped("title", 100)),
|
||||||
("role", clipped("profession", 180) if include_items else _primary_visual_role(sheet)),
|
("role", clipped("profession", 180) if include_items else _primary_visual_role(sheet)),
|
||||||
("archetype", clipped("archetype", 100)),
|
("archetype", clipped("archetype", 100)),
|
||||||
@ -2361,7 +2441,7 @@ _DWARF_BODY_LOCK = (
|
|||||||
"EXACTLY ONE ADULT DWARF.",
|
"EXACTLY ONE ADULT DWARF.",
|
||||||
"adult dwarf",
|
"adult dwarf",
|
||||||
"Natural adult dwarf body proportions: roughly five and a half head-heights tall, a broad, dense, "
|
"Natural adult dwarf body proportions: roughly five and a half head-heights tall, a broad, dense, "
|
||||||
"muscular build, a wide chest, short strong legs, and a thick, full beard. ",
|
"muscular build, a wide chest, and short strong legs. ",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -2397,6 +2477,10 @@ def _period_lock(book: str, book_profile: dict, sheet: dict) -> str:
|
|||||||
headwear = (
|
headwear = (
|
||||||
"Use only the explicitly established period headwear and reproduce it unchanged in every view."
|
"Use only the explicitly established period headwear and reproduce it unchanged in every view."
|
||||||
if _character_has_established_headwear(sheet) else
|
if _character_has_established_headwear(sheet) else
|
||||||
|
("The scalp, crown, hairline, ears, and the full head of hair in its described style "
|
||||||
|
"are visibly exposed and unobstructed, grown out and trimmed by hand with a knife or shears rather than a "
|
||||||
|
"modern barbershop cut or a bald/receding head.")
|
||||||
|
if _face_hair_is_described(sheet) else
|
||||||
"The scalp, crown, hairline, ears, and a full, thick head of hair reaching at least to the ears or collar "
|
"The scalp, crown, hairline, ears, and a full, thick head of hair reaching at least to the ears or collar "
|
||||||
"are visibly exposed and unobstructed, grown out and trimmed by hand with a knife or shears rather than a "
|
"are visibly exposed and unobstructed, grown out and trimmed by hand with a knife or shears rather than a "
|
||||||
"modern barbershop cut or a bald/receding head."
|
"modern barbershop cut or a bald/receding head."
|
||||||
@ -2600,7 +2684,7 @@ def _build_character_portrait_prompt(name: str, book: str, sheet: dict, book_pro
|
|||||||
if visual_style == "concept":
|
if visual_style == "concept":
|
||||||
style = (
|
style = (
|
||||||
"detailed semi-realistic hand-painted fantasy production portrait from the same fixed studio artist as the cast sheets, "
|
"detailed semi-realistic hand-painted fantasy production portrait from the same fixed studio artist as the cast sheets, "
|
||||||
"with consistent facial construction, restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, "
|
"with individually distinct faces, restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, "
|
||||||
"and believable weathered materials; no photorealism, anime, cartoon, glossy 3D render, or mixed media"
|
"and believable weathered materials; no photorealism, anime, cartoon, glossy 3D render, or mixed media"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -2632,7 +2716,7 @@ def _build_character_sheet_prompt(name: str, book: str, sheet: dict, book_profil
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
style = (
|
style = (
|
||||||
"detailed semi-realistic hand-painted fantasy production concept art from one fixed studio artist, with consistent facial construction, "
|
"detailed semi-realistic hand-painted fantasy production concept art from one fixed studio artist, with individually distinct faces, "
|
||||||
"restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, and believable weathered wool, linen, leather, wood, "
|
"restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, and believable weathered wool, linen, leather, wood, "
|
||||||
"and forged metal; no anime, cartoon, glossy 3D render, photo collage, or mixed media"
|
"and forged metal; no anime, cartoon, glossy 3D render, photo collage, or mixed media"
|
||||||
)
|
)
|
||||||
@ -2796,6 +2880,13 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
|||||||
# Saved prompts were written before the wording/injury clean-up existed.
|
# Saved prompts were written before the wording/injury clean-up existed.
|
||||||
portrait_subject = _positive_conditioning_text(_period_wording(_strip_injury_clauses(portrait_subject)))
|
portrait_subject = _positive_conditioning_text(_period_wording(_strip_injury_clauses(portrait_subject)))
|
||||||
sheet_subject = _positive_conditioning_text(_period_wording(_strip_injury_clauses(sheet_subject)))
|
sheet_subject = _positive_conditioning_text(_period_wording(_strip_injury_clauses(sheet_subject)))
|
||||||
|
# A hand-edited Subject has no face clause; put it first so the truncation below never cuts it.
|
||||||
|
face_clause = _character_face_clause(name, sheet)
|
||||||
|
if face_clause:
|
||||||
|
if "face:" not in portrait_subject.lower():
|
||||||
|
portrait_subject = f"{face_clause}; {portrait_subject}"
|
||||||
|
if "face:" not in sheet_subject.lower():
|
||||||
|
sheet_subject = f"{face_clause}; {sheet_subject}"
|
||||||
ear_shape = _character_ear_shape(sheet)
|
ear_shape = _character_ear_shape(sheet)
|
||||||
no_headwear = (
|
no_headwear = (
|
||||||
"Use only the explicitly established period headwear."
|
"Use only the explicitly established period headwear."
|
||||||
@ -2919,7 +3010,14 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
|||||||
# head. Positive-only per the reasoning above: state the full coverage
|
# head. Positive-only per the reasoning above: state the full coverage
|
||||||
# and hand-cut texture actually wanted rather than naming the modern or
|
# and hand-cut texture actually wanted rather than naming the modern or
|
||||||
# bald look to avoid.
|
# bald look to avoid.
|
||||||
|
# When the face clause states the hair (colour, length, style), the length and styling words are
|
||||||
|
# left to it: "reaching at least to the ears or collar, simply combed, tied back, or left loose"
|
||||||
|
# gave every character the same hair.
|
||||||
|
hair_described = _face_hair_is_described(sheet)
|
||||||
hair_texture_lock = (
|
hair_texture_lock = (
|
||||||
|
("The hair described in the face clause covers the entire scalp, grown out and trimmed by hand with a knife "
|
||||||
|
"or shears, natural uneven ends and texture, with a lived-in, handmade, slightly imperfect look. ")
|
||||||
|
if hair_described else
|
||||||
"A full, thick head of hair covering the entire scalp, reaching at least to the ears or collar, grown out "
|
"A full, thick head of hair covering the entire scalp, reaching at least to the ears or collar, grown out "
|
||||||
"and trimmed by hand with a knife or shears, natural uneven ends and texture, simply combed, tied back, or "
|
"and trimmed by hand with a knife or shears, natural uneven ends and texture, simply combed, tied back, or "
|
||||||
"left loose, with a lived-in, handmade, slightly imperfect look. "
|
"left loose, with a lived-in, handmade, slightly imperfect look. "
|
||||||
@ -2931,6 +3029,9 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
|||||||
# hair and feminine features as a fact of this figure, same reasoning as
|
# hair and feminine features as a fact of this figure, same reasoning as
|
||||||
# every other lock above.
|
# every other lock above.
|
||||||
female_presentation_lock = (
|
female_presentation_lock = (
|
||||||
|
("Her hair as described in the face clause, and clearly feminine facial "
|
||||||
|
"features and build, exactly as a woman looks wearing this armor. ")
|
||||||
|
if hair_described else
|
||||||
"Long hair reaching past the shoulders, worn loose, braided, or tied back, and clearly feminine facial "
|
"Long hair reaching past the shoulders, worn loose, braided, or tied back, and clearly feminine facial "
|
||||||
"features and build, exactly as a woman looks wearing this armor. "
|
"features and build, exactly as a woman looks wearing this armor. "
|
||||||
) if bareheaded and _visual_prompt_value(sheet.get("gender")).strip().lower() in {"female", "weiblich"} and _character_has_established_combat_gear(sheet) else ""
|
) if bareheaded and _visual_prompt_value(sheet.get("gender")).strip().lower() in {"female", "weiblich"} and _character_has_established_combat_gear(sheet) else ""
|
||||||
@ -2974,7 +3075,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
|||||||
# the long prompt ("visible floor") did nothing. Only the rules a sheet actually needs are added,
|
# the long prompt ("visible floor") did nothing. Only the rules a sheet actually needs are added,
|
||||||
# one short sentence each.
|
# one short sentence each.
|
||||||
def short_subject(text: str) -> str:
|
def short_subject(text: str) -> str:
|
||||||
text = text if len(text) <= 700 else text[:700].rsplit(" ", 1)[0]
|
text = text if len(text) <= 900 else text[:900].rsplit(" ", 1)[0]
|
||||||
return text.rstrip(" .;,")
|
return text.rstrip(" .;,")
|
||||||
|
|
||||||
portrait_subject_short = short_subject(portrait_subject)
|
portrait_subject_short = short_subject(portrait_subject)
|
||||||
@ -3447,6 +3548,7 @@ async def character_generate_prompts(request: Request):
|
|||||||
# Image prompts read ENGLISH versions of the free text; the record stays German.
|
# Image prompts read ENGLISH versions of the free text; the record stays German.
|
||||||
prompt_sheet, prompt_book_profile, _, translation_info = await asyncio.to_thread(
|
prompt_sheet, prompt_book_profile, _, translation_info = await asyncio.to_thread(
|
||||||
_english_prompt_inputs, name, sheet, book_profile, None, _settings)
|
_english_prompt_inputs, name, sheet, book_profile, None, _settings)
|
||||||
|
prompt_sheet = await asyncio.to_thread(_attach_face_profile, name, book, sheet, prompt_sheet)
|
||||||
if "image_prompt" in wanted:
|
if "image_prompt" in wanted:
|
||||||
deterministic["image_prompt"] = _build_character_portrait_prompt(name, book, prompt_sheet, prompt_book_profile, visual_style)
|
deterministic["image_prompt"] = _build_character_portrait_prompt(name, book, prompt_sheet, prompt_book_profile, visual_style)
|
||||||
if "concept_art_prompt" in wanted:
|
if "concept_art_prompt" in wanted:
|
||||||
@ -3547,6 +3649,11 @@ async def character_prompt_preview(request: Request):
|
|||||||
visual_style = "concept" if str(data.get("visual_style") or "").strip().lower() == "concept" else "cinematic"
|
visual_style = "concept" if str(data.get("visual_style") or "").strip().lower() == "concept" else "cinematic"
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
sheet_en, book_en, _, info = await asyncio.to_thread(_english_prompt_inputs, name, sheet, book_profile, None, settings)
|
sheet_en, book_en, _, info = await asyncio.to_thread(_english_prompt_inputs, name, sheet, book_profile, None, settings)
|
||||||
|
changed = {k: {"original": sheet.get(k), "english": sheet_en.get(k)}
|
||||||
|
for k in sorted(set(sheet) | set(sheet_en)) if sheet.get(k) != sheet_en.get(k)}
|
||||||
|
face_sheet = await asyncio.to_thread(_attach_face_profile, name, book, sheet, {})
|
||||||
|
face = {k: v for k, v in face_sheet.items() if k.startswith("_face")}
|
||||||
|
sheet, sheet_en = {**sheet, **face}, {**sheet_en, **face}
|
||||||
|
|
||||||
def render(sh: dict, bp: dict) -> dict:
|
def render(sh: dict, bp: dict) -> dict:
|
||||||
portrait = _build_character_portrait_prompt(name, book, sh, bp, visual_style)
|
portrait = _build_character_portrait_prompt(name, book, sh, bp, visual_style)
|
||||||
@ -3556,12 +3663,47 @@ async def character_prompt_preview(request: Request):
|
|||||||
return {"profile": profile, "front": front, "story_moment": scene, "props": props,
|
return {"profile": profile, "front": front, "story_moment": scene, "props": props,
|
||||||
"dwarf": _character_is_dwarf(sh), "age_category": _character_age_category(sh)}
|
"dwarf": _character_is_dwarf(sh), "age_category": _character_age_category(sh)}
|
||||||
|
|
||||||
changed = {k: {"original": sheet.get(k), "english": sheet_en.get(k)}
|
|
||||||
for k in sorted(set(sheet) | set(sheet_en)) if sheet.get(k) != sheet_en.get(k)}
|
|
||||||
return {"name": name, "prompt_translation": info, "translated_sheet_fields": changed,
|
return {"name": name, "prompt_translation": info, "translated_sheet_fields": changed,
|
||||||
|
"face_clause": face.get("_face_clause", ""),
|
||||||
"original_prompts": render(sheet, book_profile), "english_prompts": render(sheet_en, book_en)}
|
"original_prompts": render(sheet, book_profile), "english_prompts": render(sheet_en, book_en)}
|
||||||
|
|
||||||
|
|
||||||
|
def _face_preview_for_roster(book: str, roster: list[dict]) -> dict:
|
||||||
|
"""Face profiles and before/after distinctness for a roster [{name, sheet}] in creation order."""
|
||||||
|
names = [r["name"] for r in roster]
|
||||||
|
inputs = [_face_input_for(r["name"], r.get("sheet") or {}, names) for r in roster]
|
||||||
|
profiles = _fp.assign_profiles(book, inputs)
|
||||||
|
report = _fp.similarity_report(inputs, profiles)
|
||||||
|
by_name = {i.name: i for i in inputs}
|
||||||
|
order = [n for n in names if n in profiles]
|
||||||
|
matrix = [[round(1 - _fp.distance(profiles[a].values, profiles[b].values), 2) for b in order] for a in order]
|
||||||
|
return {
|
||||||
|
"book": book, "report": report, "order": order, "similarity_matrix_after": matrix,
|
||||||
|
"characters": [{**profiles[n].to_dict(), "sheet_traits": _fp.read_sheet_traits(by_name[n].override or by_name[n].text)}
|
||||||
|
for n in order],
|
||||||
|
"skipped_without_humanoid_face": [n for n in names if n not in profiles],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/character-face-preview")
|
||||||
|
async def character_face_preview(request: Request):
|
||||||
|
"""Read-only dry run of the face profiles of one book: each character's profile (what came from
|
||||||
|
the sheet, what was chosen), and the average pairwise face-descriptor distance before and after.
|
||||||
|
Generates no image and changes no record.
|
||||||
|
|
||||||
|
Body: {book}.
|
||||||
|
"""
|
||||||
|
data = await request.json()
|
||||||
|
book = str(data.get("book") or "").strip()
|
||||||
|
if not book:
|
||||||
|
raise HTTPException(400, "book required")
|
||||||
|
from core.database import char_face_roster
|
||||||
|
roster = await asyncio.to_thread(char_face_roster, book)
|
||||||
|
if not roster:
|
||||||
|
raise HTTPException(404, "No characters for this book")
|
||||||
|
return await asyncio.to_thread(_face_preview_for_roster, book, roster)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/audiobook-consistency-check")
|
@router.post("/api/audiobook-consistency-check")
|
||||||
async def audiobook_consistency_check(request: Request):
|
async def audiobook_consistency_check(request: Request):
|
||||||
"""Check whether every line already attributed to ONE character across the
|
"""Check whether every line already attributed to ONE character across the
|
||||||
@ -4328,6 +4470,7 @@ async def character_generate_consistent_images(request: Request):
|
|||||||
settings)
|
settings)
|
||||||
if translation_info.get("used_untranslated_text"):
|
if translation_info.get("used_untranslated_text"):
|
||||||
logging.getLogger(__name__).warning("character images for %r use untranslated text", name)
|
logging.getLogger(__name__).warning("character images for %r use untranslated text", name)
|
||||||
|
prompt_sheet = await asyncio.to_thread(_attach_face_profile, name, book, sheet, prompt_sheet)
|
||||||
saved_profile_prompt_en = prompt_extras.get("profile_direction") or saved_profile_prompt
|
saved_profile_prompt_en = prompt_extras.get("profile_direction") or saved_profile_prompt
|
||||||
saved_reference_prompt_en = prompt_extras.get("reference_direction") or saved_reference_prompt
|
saved_reference_prompt_en = prompt_extras.get("reference_direction") or saved_reference_prompt
|
||||||
portrait_master = _build_character_portrait_prompt(name, book, prompt_sheet, prompt_book_profile, visual_style)
|
portrait_master = _build_character_portrait_prompt(name, book, prompt_sheet, prompt_book_profile, visual_style)
|
||||||
|
|||||||
@ -41,7 +41,8 @@ DWARF = {
|
|||||||
"physical": "Breit gebaut, mit langem rotem Bart.",
|
"physical": "Breit gebaut, mit langem rotem Bart.",
|
||||||
"clothing": "Ein Kettenhemd über einer Wolltunika.",
|
"clothing": "Ein Kettenhemd über einer Wolltunika.",
|
||||||
}
|
}
|
||||||
DWARF_TERMS = ("dwarf", "zwerg", "beard")
|
# A human may have a beard of its own (face profile); what must not leak is the dwarf lock.
|
||||||
|
DWARF_TERMS = ("dwarf", "zwerg", "thick, full beard", "short strong legs")
|
||||||
|
|
||||||
|
|
||||||
def _render(name: str, sheet: dict) -> list[str]:
|
def _render(name: str, sheet: dict) -> list[str]:
|
||||||
@ -92,7 +93,7 @@ class ConsecutiveCharacterPromptTests(unittest.TestCase):
|
|||||||
self.assertNotIn(term, text, f"{term!r} leaked into {name}")
|
self.assertNotIn(term, text, f"{term!r} leaked into {name}")
|
||||||
dwarf_text = " ".join(prompts["Garbaz"]).lower()
|
dwarf_text = " ".join(prompts["Garbaz"]).lower()
|
||||||
self.assertIn("adult dwarf", dwarf_text)
|
self.assertIn("adult dwarf", dwarf_text)
|
||||||
self.assertIn("beard", dwarf_text)
|
self.assertIn("beard", dwarf_text) # the dwarf's own beard now comes from its face clause
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
398
tests/test_face_profile.py
Normal file
398
tests/test_face_profile.py
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
"""Distinct, deterministic face profiles (core/face_profile.py) and their place in the prompts.
|
||||||
|
|
||||||
|
The fixture roster is shaped like the real book-02 sheets: German text, most characters with no
|
||||||
|
facial trait at all, a few with hair or eye colour, hedged species notes, a raven and a narrator.
|
||||||
|
No image is generated and no LLM is called.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-face-test-config-"))
|
||||||
|
|
||||||
|
import routes.conversation as conv
|
||||||
|
from core import face_profile as fp
|
||||||
|
|
||||||
|
BOOK = "Testbuch 02"
|
||||||
|
|
||||||
|
|
||||||
|
def _s(gender="", age="", species="Mensch", physical="", rel="", **extra):
|
||||||
|
return {"gender": gender, "age_estimate": age, "race_species": species, "physical": physical,
|
||||||
|
"relationships": rel, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
ROSTER = [
|
||||||
|
("Cindira", _s("female", "Junge Erwachsene", physical="Weiblich, Tränen in den Augen, zerrüttet.")),
|
||||||
|
("Marcian", _s("male", "Jung", physical="Jung, schlank genug, um sich auf einem Schemel unbehaglich zu fühlen.")),
|
||||||
|
("Marrad", _s("male", "Jung (Fast acht Sommer)", "Mensch (oder Zwerg, je nach Kontext)", "Klein, dünn, schwach.",
|
||||||
|
"Vater: Darrag (Schmied)")),
|
||||||
|
("Lysandra", _s("female", "jung bis mittelalterlich", "Mensch (Amazone)", "Schwarzhaarig.")),
|
||||||
|
("Garbaz", _s("male", "mittleres Alter", "Zwerg (Amboßzwerg)", "Breit gebaut.")),
|
||||||
|
("Tschubax", _s("male", "Mittleres Alter", "Erzzwerg", "Trägt einen Bart, wirkt wie ein Gelehrter.")),
|
||||||
|
("Himgi", _s("male", "mittleres Alter", "Mensch", "Zwergengröße, sträubende Haare bei Anspannung.")),
|
||||||
|
("Nyrilla", _s("female", "unbestimmt", "Elfe", "Schlank.")),
|
||||||
|
("Eolan", _s("male", "alt", "Auelfe (Mensch-Elfen-Mischling)", "")),
|
||||||
|
("Kolon", _s("male", "Mittleres Alter", "Mensch (oder Ork-Verbündeter)", "Kleiner Mann mit einem weißen Bart.")),
|
||||||
|
("Darrag", _s("male", "Erwachsener Mann", "Mensch", "Groß, stark; gerötete Augen, unrasiertes Gesicht.",
|
||||||
|
"Vater von Marrad (tot)")),
|
||||||
|
("Prinz", _s("male", "junges Erwachsenenalter", "Mensch",
|
||||||
|
"Jung, schulterlanges dunkelblondes Haar, braune Augen, trägt ein Amulett.")),
|
||||||
|
("Ludara", _s("female", "junges Erwachsenenalter", "Mensch", "Blonde Haare, blaue Augen.")),
|
||||||
|
("Gernot", _s("male", "junges Erwachsenenalter", "Mensch", "Junges Aussehen, schulterlanges dunkelblondes Haar, braune Augen.")),
|
||||||
|
("Marina", _s("female", "Jungfrau (junges Mädchen)", "Mensch (wird Vampir)", "Schön. Langes, blondes Haar.")),
|
||||||
|
("Zerwas", _s("male", "alter Mann (im Aussehen)", "Vampir", "Groß und muskulös.")),
|
||||||
|
("Promos", _s("male", "Alter Mann (beugt sich, grauhaarig)", "Mensch", "Alter Mann, taumelt benommen.")),
|
||||||
|
("Graf", _s("male", "alt", "Mensch", "Alt, boshafte Gesichtszüge.")),
|
||||||
|
("Ordbert", _s("male", "mittleres Alter", "Mensch", "Hat gezwirbelte Schnauzbartspitzen.")),
|
||||||
|
("Leonardo", _s("male", "Mittleres Alter", "Mensch", "Bart, unsicherer Blick.")),
|
||||||
|
("Rialla", _s("female", "jung bis mittelalterlich", "Mensch", "groß, blonde Haare, Kriegerin.")),
|
||||||
|
("Olda", _s("female", "", "Mensch", "Schwarzhaarige")),
|
||||||
|
("Sartassa", _s("female", "jung", "Mensch", "")),
|
||||||
|
("junges Mädchen", _s("female", "Junges Mädchen", "Mensch", "Schlank, junges Mädchen.")),
|
||||||
|
("Orkschamane", _s("", "", "", "")),
|
||||||
|
("Orgrim", _s("male", "mittleres Alter", "Ork", "")),
|
||||||
|
("Bote", _s("male", "jung", "Mensch", "")),
|
||||||
|
("Wächter", _s("", "", "Mensch", "")),
|
||||||
|
("Kroah", _s("male", "unbekannt", "Rabe (magisch)", "Rabe, fliegt enge Kreise.")),
|
||||||
|
("Narrator", _s("", "", "", "")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs(roster=ROSTER, cast=None):
|
||||||
|
names = [n for n, _ in roster]
|
||||||
|
return [fp.face_input_from_sheet(n, sh, conv._character_age_category(sh), names,
|
||||||
|
conv._character_species_text(sh, 100)) for n, sh in roster]
|
||||||
|
|
||||||
|
|
||||||
|
def _profiles(roster=ROSTER):
|
||||||
|
return fp.assign_profiles(BOOK, _inputs(roster))
|
||||||
|
|
||||||
|
|
||||||
|
class DeterminismTests(unittest.TestCase):
|
||||||
|
def test_same_roster_gives_same_profiles(self):
|
||||||
|
a, b = _profiles(), _profiles()
|
||||||
|
self.assertEqual({n: p.values for n, p in a.items()}, {n: p.values for n, p in b.items()})
|
||||||
|
|
||||||
|
def test_other_book_gives_other_choices(self):
|
||||||
|
other = fp.assign_profiles("Anderes Buch", _inputs())
|
||||||
|
mine = _profiles()
|
||||||
|
self.assertNotEqual({n: p.values for n, p in mine.items()}, {n: p.values for n, p in other.items()})
|
||||||
|
|
||||||
|
def test_existing_characters_keep_their_profile_when_the_cast_grows(self):
|
||||||
|
before = _profiles()
|
||||||
|
extra = [("Neu Eins", _s("female", "jung", "Mensch", "")), ("Neu Zwei", _s("male", "alt", "Zwerg", ""))]
|
||||||
|
after = _profiles(ROSTER + extra)
|
||||||
|
for name, profile in before.items():
|
||||||
|
self.assertEqual(profile.values, after[name].values, name)
|
||||||
|
self.assertIn("Neu Eins", after)
|
||||||
|
|
||||||
|
def test_non_humanoids_and_the_narrator_get_no_profile(self):
|
||||||
|
profiles = _profiles()
|
||||||
|
self.assertNotIn("Kroah", profiles)
|
||||||
|
self.assertNotIn("Narrator", profiles)
|
||||||
|
self.assertIn("Orkschamane", profiles) # species taken from the name when the sheet has none
|
||||||
|
self.assertEqual(profiles["Orkschamane"].species, "o")
|
||||||
|
|
||||||
|
|
||||||
|
class DistinctnessTests(unittest.TestCase):
|
||||||
|
def test_distinctness_beats_the_old_baseline(self):
|
||||||
|
inputs = _inputs()
|
||||||
|
profiles = fp.assign_profiles(BOOK, inputs)
|
||||||
|
report = fp.similarity_report(inputs, profiles)
|
||||||
|
self.assertLess(report["mean_distance_before"], 0.25) # the model default is shared
|
||||||
|
self.assertGreater(report["mean_distance_after"], 0.8)
|
||||||
|
self.assertGreater(report["mean_distance_after"], report["mean_distance_before"] + 0.55)
|
||||||
|
self.assertGreater(report["characters_without_any_face_trait"], 10)
|
||||||
|
|
||||||
|
def test_no_two_unrelated_characters_look_alike(self):
|
||||||
|
profiles = _profiles()
|
||||||
|
names = list(profiles)
|
||||||
|
kin = {frozenset(("Marrad", "Darrag")), frozenset(("Himgi", "Darrag"))}
|
||||||
|
worst = 0.0
|
||||||
|
for i, a in enumerate(names):
|
||||||
|
for b in names[i + 1:]:
|
||||||
|
if frozenset((a, b)) in kin:
|
||||||
|
continue
|
||||||
|
worst = max(worst, 1 - fp.distance(profiles[a].values, profiles[b].values))
|
||||||
|
self.assertLess(worst, 0.55, f"most similar unrelated pair has similarity {worst:.2f}")
|
||||||
|
|
||||||
|
def test_greedy_beats_picking_by_hash_alone(self):
|
||||||
|
inputs = _inputs()
|
||||||
|
greedy = fp.mean_pairwise([p.values for p in fp.assign_profiles(BOOK, inputs).values()])
|
||||||
|
solo = fp.mean_pairwise([fp.assign_profiles(BOOK, [i]).get(i.name).values
|
||||||
|
for i in inputs if fp.assign_profiles(BOOK, [i])])
|
||||||
|
self.assertGreater(greedy, solo)
|
||||||
|
|
||||||
|
|
||||||
|
class ConstraintTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.profiles = _profiles()
|
||||||
|
|
||||||
|
def test_no_facial_hair_for_women_children_or_unknown_gender(self):
|
||||||
|
for name in ("Cindira", "Lysandra", "Nyrilla", "Ludara", "Marina", "Rialla", "Olda", "Sartassa",
|
||||||
|
"junges Mädchen", "Marrad", "Orkschamane", "Wächter"):
|
||||||
|
self.assertNotIn("facial_hair", self.profiles[name].values, name)
|
||||||
|
self.assertNotIn("beard", self.profiles[name].clause(), name)
|
||||||
|
|
||||||
|
def test_no_beard_for_a_young_woman_even_when_a_dwarf(self):
|
||||||
|
sheet = _s("female", "jung", "Zwerg", "")
|
||||||
|
prof = fp.assign_profiles(BOOK, [fp.face_input_from_sheet("Dwarfa", sheet, "adult", [], "Zwerg")])["Dwarfa"]
|
||||||
|
self.assertNotIn("facial_hair", prof.values)
|
||||||
|
|
||||||
|
def test_elves_have_no_beard_and_no_wrinkles_and_no_orc_jaw(self):
|
||||||
|
for name in ("Nyrilla", "Eolan"):
|
||||||
|
prof = self.profiles[name]
|
||||||
|
self.assertNotIn("facial_hair", prof.values)
|
||||||
|
self.assertNotIn(prof.values.get("age_signs", "elf_ageless")[:3], ("old", "mid"))
|
||||||
|
self.assertNotIn("tusk_jaw", prof.values.values())
|
||||||
|
|
||||||
|
def test_human_clauses_never_mention_elf_or_orc_traits(self):
|
||||||
|
for name, profile in self.profiles.items():
|
||||||
|
if profile.species == "h":
|
||||||
|
text = profile.clause().lower()
|
||||||
|
for word in ("pointed ear", "elf", "tusk", "protruding lower jaw", "ageless"):
|
||||||
|
self.assertNotIn(word, text, f"{word!r} in {name}")
|
||||||
|
|
||||||
|
def test_age_appropriate_hair_and_skin(self):
|
||||||
|
for name in ("Promos", "Graf", "Zerwas"):
|
||||||
|
self.assertIn(self.profiles[name].values["hair_colour"], ("grey", "white", "grey_streaked"), name)
|
||||||
|
self.assertIn(self.profiles[name].values["age_signs"], ("old_wrinkled", "old_sagging", "old_lines"), name)
|
||||||
|
for name in ("Marrad", "junges Mädchen"):
|
||||||
|
self.assertTrue(self.profiles[name].values["age_signs"].startswith(("child", "teen")), name)
|
||||||
|
self.assertNotIn(self.profiles[name].values["hair_colour"], ("grey", "white", "grey_streaked"))
|
||||||
|
for name in ("Ludara", "Prinz", "Gernot"):
|
||||||
|
self.assertNotIn(self.profiles[name].values["hair_colour"], ("grey", "white", "grey_streaked"))
|
||||||
|
|
||||||
|
def test_white_beard_in_the_sheet_means_light_hair(self):
|
||||||
|
# Kolon: "weißen Bart" (middle-aged): hair follows the beard, the beard itself is kept
|
||||||
|
self.assertEqual(self.profiles["Kolon"].values["facial_hair"], "sheet_beard:white")
|
||||||
|
self.assertIn(self.profiles["Kolon"].values["hair_colour"], ("grey", "white", "grey_streaked"))
|
||||||
|
self.assertIn("a white beard", self.profiles["Kolon"].clause())
|
||||||
|
|
||||||
|
def test_vampire_skin_stays_pale(self):
|
||||||
|
self.assertIn(self.profiles["Zerwas"].values["skin"], ("pale", "fair", "sallow", "olive"))
|
||||||
|
self.assertNotIn("freckles", self.profiles["Zerwas"].values.get("mark", ""))
|
||||||
|
|
||||||
|
def test_orc_and_dwarf_pools(self):
|
||||||
|
self.assertIn(self.profiles["Orgrim"].values["skin"], ("olive", "dark_olive", "ash_brown", "warm_brown"))
|
||||||
|
self.assertIn(self.profiles["Garbaz"].values["facial_hair"], ("stubble", "moustache", "full_beard", "long_beard",
|
||||||
|
"braided_beard", "forked_beard", "square_beard"))
|
||||||
|
|
||||||
|
def test_culture_named_in_the_sheet_limits_skin(self):
|
||||||
|
north = _s("male", "jung", "Mensch", "", nationality_background="Thorwaler")
|
||||||
|
inp = fp.face_input_from_sheet("Thor", north, "adult", [])
|
||||||
|
self.assertEqual(inp.culture, "N")
|
||||||
|
self.assertTrue(all(o.id not in ("warm_brown", "deep_brown") for o in fp.candidates("skin", inp)))
|
||||||
|
south = _s("male", "jung", "Mensch", "", nationality_background="Tulamide aus Mhanadistan")
|
||||||
|
inp = fp.face_input_from_sheet("Tula", south, "adult", [])
|
||||||
|
self.assertTrue(all(o.id not in ("pale", "fair_freckled", "ruddy") for o in fp.candidates("skin", inp)))
|
||||||
|
|
||||||
|
def test_alternatives_in_the_species_note_do_not_change_the_species(self):
|
||||||
|
self.assertEqual(fp.species_code("Mensch (oder Zwerg, je nach Kontext)"), "h")
|
||||||
|
self.assertEqual(fp.species_code("Mensch oder Ork"), "h")
|
||||||
|
self.assertEqual(fp.species_code("Halbelf"), "he")
|
||||||
|
self.assertEqual(fp.species_code("Rabe (magisch)"), "")
|
||||||
|
self.assertEqual(fp.species_code("", "Elfriede"), "h")
|
||||||
|
|
||||||
|
def test_every_species_age_gender_combination_gets_every_applicable_slot(self):
|
||||||
|
for species in ("h", "e", "d", "o", "v", "he"):
|
||||||
|
for stage in "ctymo":
|
||||||
|
for gender in ("m", "f", ""):
|
||||||
|
inp = fp.FaceInput("X", species, stage, gender, "", "")
|
||||||
|
prof = fp.build_profile(inp, "b", [])
|
||||||
|
missing = [s for s in fp.SLOTS if s not in prof.values and fp._slot_applicable(s, inp)]
|
||||||
|
self.assertEqual(missing, [], (species, stage, gender))
|
||||||
|
if species != "e" or stage in "ct":
|
||||||
|
continue
|
||||||
|
self.assertNotIn(prof.values.get("age_signs", ""), ("old_wrinkled", "mid_lines"))
|
||||||
|
|
||||||
|
def test_alter_the_noun_is_not_old(self):
|
||||||
|
self.assertEqual(fp.age_stage("Alter unklar, erscheint als junger Mann"), "y")
|
||||||
|
self.assertEqual(fp.age_stage("Alter Mann (beugt sich)"), "o")
|
||||||
|
|
||||||
|
|
||||||
|
class SheetTraitTests(unittest.TestCase):
|
||||||
|
def _one(self, physical, **kw):
|
||||||
|
sheet = _s(kw.pop("gender", "male"), kw.pop("age", "jung"), kw.pop("species", "Mensch"), physical)
|
||||||
|
return fp.assign_profiles(BOOK, [fp.face_input_from_sheet("X", sheet, "adult", [])])["X"]
|
||||||
|
|
||||||
|
def test_sheet_traits_are_kept_and_marked(self):
|
||||||
|
prof = self._one("Schwarzhaarig, blaue Augen, Sommersprossen, hohe Wangenknochen, Hakennase.")
|
||||||
|
self.assertEqual((prof.values["hair_colour"], prof.values["eye_colour"], prof.values["mark"],
|
||||||
|
prof.values["cheeks"], prof.values["nose"]), ("black", "blue", "freckles", "high", "aquiline"))
|
||||||
|
for slot in ("hair_colour", "eye_colour", "mark", "cheeks", "nose"):
|
||||||
|
self.assertEqual(prof.sources[slot], "sheet")
|
||||||
|
self.assertIn("black hair", prof.clause())
|
||||||
|
|
||||||
|
def test_english_sheets_work_too(self):
|
||||||
|
prof = self._one("Short red hair, green eyes, a broad nose and a square jaw.")
|
||||||
|
self.assertEqual((prof.values["hair_colour"], prof.values["eye_colour"], prof.values["nose"], prof.values["jaw"]),
|
||||||
|
("red", "green", "broad", "square_jaw"))
|
||||||
|
self.assertEqual(prof.values["hair_style"], "s:short")
|
||||||
|
|
||||||
|
def test_sheet_never_contradicted(self):
|
||||||
|
cases = [("Kurzes dunkles Haar, grüne Augen", {"hair_colour": "dark_brown", "eye_colour": "green"}),
|
||||||
|
("Bartlos, rundes Gesicht", {"facial_hair": "clean", "face_shape": "round"}),
|
||||||
|
("Dunkelhäutig, volle Lippen", {"skin": "deep_brown", "mouth": "full"}),
|
||||||
|
("Hair is white, eyes are grey", {"hair_colour": "white", "eye_colour": "grey"}),
|
||||||
|
("dark eyes and brown hair", {"hair_colour": "brown", "eye_colour": "dark_brown"})]
|
||||||
|
for text, expected in cases:
|
||||||
|
prof = self._one(text)
|
||||||
|
for slot, oid in expected.items():
|
||||||
|
self.assertEqual(prof.values[slot], oid, f"{text!r}: {slot}")
|
||||||
|
|
||||||
|
def test_bald_sheet_gets_no_hair_colour_or_style(self):
|
||||||
|
prof = self._one("Kahlköpfig.")
|
||||||
|
self.assertEqual(prof.values["hair_style"], "bald")
|
||||||
|
self.assertNotIn("hair_colour", prof.values)
|
||||||
|
self.assertIn("a bald head", prof.clause())
|
||||||
|
|
||||||
|
def test_scars_are_not_added_to_the_prompt_text(self):
|
||||||
|
prof = self._one("Eine lange Narbe über der Wange.")
|
||||||
|
self.assertEqual(prof.values["mark"], "sheet_mark")
|
||||||
|
self.assertNotIn("scar", prof.clause().lower())
|
||||||
|
|
||||||
|
def test_unspecified_unrasiert_is_stubble_not_clean(self):
|
||||||
|
self.assertEqual(self._one("Unrasiert.").values["facial_hair"], "stubble")
|
||||||
|
|
||||||
|
def test_owner_override_replaces_the_clause_and_still_counts(self):
|
||||||
|
sheet = _s("male", "jung", "Mensch", "Mager.", visual_face="narrow face, hooked nose, one blind white eye, black hair")
|
||||||
|
inp = fp.face_input_from_sheet("X", sheet, "adult", [])
|
||||||
|
prof = fp.assign_profiles(BOOK, [inp])["X"]
|
||||||
|
self.assertEqual(prof.clause(), "face: narrow face, hooked nose, one blind white eye, black hair")
|
||||||
|
self.assertEqual(prof.values["hair_colour"], "black") # parsed, so later characters avoid it
|
||||||
|
self.assertEqual(prof.sources["hair_colour"], "override")
|
||||||
|
|
||||||
|
|
||||||
|
class KinTests(unittest.TestCase):
|
||||||
|
def test_kin_detection(self):
|
||||||
|
names = ["Marrad", "Darrag", "Himgi"]
|
||||||
|
self.assertEqual(fp.find_kin("Marrad", "Vater: Darrag (Schmied, trinkt viel); Himgi (Freund)", names), ("Darrag",))
|
||||||
|
self.assertEqual(fp.find_kin("Darrag", "Vater von Marrad (tot)", names), ("Marrad",))
|
||||||
|
self.assertEqual(fp.find_kin("Himgi", "Arbeitskollege von Darrag", names), ())
|
||||||
|
|
||||||
|
def test_relatives_share_family_traits_strangers_do_not(self):
|
||||||
|
shared_kin = shared_other = 0
|
||||||
|
total = 12
|
||||||
|
for i in range(total):
|
||||||
|
roster = [(f"Anna{i}", _s("female", "jung", "Mensch", "")), (f"Bert{i}", _s("male", "jung", "Mensch", "",
|
||||||
|
rel=f"Schwester: Anna{i}")), (f"Cord{i}", _s("male", "jung", "Mensch", ""))]
|
||||||
|
profiles = fp.assign_profiles(BOOK, _inputs(roster))
|
||||||
|
fam = lambda a, b: sum(profiles[a].values.get(s) == profiles[b].values.get(s) for s in fp.FAMILY_SLOTS)
|
||||||
|
shared_kin += fam(f"Anna{i}", f"Bert{i}")
|
||||||
|
shared_other += fam(f"Anna{i}", f"Cord{i}")
|
||||||
|
self.assertGreater(shared_kin, shared_other)
|
||||||
|
self.assertGreaterEqual(shared_kin / total, 1.0) # at least one inherited slot on average
|
||||||
|
|
||||||
|
def test_relatives_still_differ_in_the_other_slots(self):
|
||||||
|
roster = [("Anna", _s("female", "jung", "Mensch", "")), ("Bert", _s("male", "jung", "Mensch", "", rel="Bruder: Anna"))]
|
||||||
|
profiles = fp.assign_profiles(BOOK, _inputs(roster))
|
||||||
|
self.assertGreater(fp.distance(profiles["Anna"].values, profiles["Bert"].values), 0.3)
|
||||||
|
|
||||||
|
|
||||||
|
class PromptTests(unittest.TestCase):
|
||||||
|
SHEET = _s("female", "Junge Erwachsene", "Mensch", "Blonde Haare, blaue Augen.",
|
||||||
|
clothing="Ein einfaches Leinenkleid.")
|
||||||
|
|
||||||
|
def _attached(self, name="Ludara", sheet=None):
|
||||||
|
sheet = sheet or self.SHEET
|
||||||
|
roster = [{"name": n, "sheet": sh} for n, sh in ROSTER]
|
||||||
|
with mock.patch("core.database.char_face_roster", return_value=roster):
|
||||||
|
return conv._attach_face_profile(name, BOOK, sheet, dict(sheet))
|
||||||
|
|
||||||
|
def test_face_clause_is_in_every_prompt_and_survives_cleaning(self):
|
||||||
|
sheet = self._attached()
|
||||||
|
clause = sheet["_face_clause"]
|
||||||
|
self.assertTrue(clause.startswith("face: "))
|
||||||
|
self.assertIn("blonde hair", clause)
|
||||||
|
portrait = conv._build_character_portrait_prompt("Ludara", BOOK, sheet, {}, "cinematic")
|
||||||
|
turnaround = conv._build_character_sheet_prompt("Ludara", BOOK, sheet, {}, "cinematic")
|
||||||
|
self.assertIn(clause.removeprefix("face: "), portrait)
|
||||||
|
self.assertIn(clause.removeprefix("face: "), turnaround)
|
||||||
|
profile, front, _, _, scene, _ = conv._build_compact_character_render_prompts(
|
||||||
|
"Ludara", BOOK, sheet, {}, "cinematic", portrait, turnaround)
|
||||||
|
for text in (profile, front, scene):
|
||||||
|
self.assertIn(clause, text) # the injury/negation filters leave it untouched
|
||||||
|
|
||||||
|
def test_hand_edited_subject_still_gets_the_face(self):
|
||||||
|
sheet = self._attached()
|
||||||
|
edited = "Task: x. Subject: a weathered shepherd girl in a brown dress. Context: y."
|
||||||
|
profile, front, *_ = conv._build_compact_character_render_prompts(
|
||||||
|
"Ludara", BOOK, sheet, {}, "cinematic", edited, edited)
|
||||||
|
self.assertIn(sheet["_face_clause"], profile)
|
||||||
|
self.assertIn(sheet["_face_clause"], front)
|
||||||
|
|
||||||
|
def test_homogenising_boilerplate_is_gone(self):
|
||||||
|
sheet = self._attached()
|
||||||
|
text = " ".join((
|
||||||
|
conv._build_character_portrait_prompt("Ludara", BOOK, sheet, {}, "concept"),
|
||||||
|
conv._build_character_sheet_prompt("Ludara", BOOK, sheet, {}, "concept"),
|
||||||
|
*conv._build_compact_character_render_prompts("Ludara", BOOK, sheet, {}, "concept", "", "")[:2],
|
||||||
|
)).lower()
|
||||||
|
self.assertNotIn("consistent facial construction", text)
|
||||||
|
self.assertNotIn("reaching at least to the ears or collar", text)
|
||||||
|
self.assertNotIn("past the shoulders", text)
|
||||||
|
|
||||||
|
def test_dwarf_beard_comes_from_the_face_clause_only(self):
|
||||||
|
dwarf = _s("male", "mittleres Alter", "Zwerg", "Breit gebaut.", clothing="Kettenhemd")
|
||||||
|
sheet = self._attached("Garbaz", dwarf)
|
||||||
|
self.assertIn("beard", sheet["_face_clause"]) if sheet["_face_beard"] else None
|
||||||
|
lock = conv._age_body_lock(dwarf)[2]
|
||||||
|
self.assertNotIn("beard", lock)
|
||||||
|
woman = self._attached("Dwarfa", _s("female", "jung", "Zwerg", "", clothing="Kleid"))
|
||||||
|
text = " ".join(conv._build_compact_character_render_prompts("Dwarfa", BOOK, woman, {}, "cinematic", "", "")[:2]).lower()
|
||||||
|
self.assertNotIn("beard", text)
|
||||||
|
|
||||||
|
def test_german_to_english_path_still_feeds_the_profile(self):
|
||||||
|
sheet = {**self.SHEET, "physical": "Schwarzhaarig, blaue Augen."}
|
||||||
|
|
||||||
|
def chat(messages, max_tokens):
|
||||||
|
import json
|
||||||
|
payload = json.loads(messages[-1]["content"]) if messages[-1]["content"].lstrip().startswith("{") else {}
|
||||||
|
return json.dumps({k: str(v).replace("Schwarzhaarig", "Black-haired") for k, v in payload.items()})
|
||||||
|
|
||||||
|
with mock.patch.object(conv, "_load_settings", return_value={}):
|
||||||
|
en, _, _, info = conv._english_prompt_inputs("Ludara", sheet, {}, None, {}, chat=chat, cache={})
|
||||||
|
roster = [{"name": n, "sheet": sh} for n, sh in ROSTER]
|
||||||
|
with mock.patch("core.database.char_face_roster", return_value=roster):
|
||||||
|
prompt_sheet = conv._attach_face_profile("Ludara", BOOK, sheet, en)
|
||||||
|
self.assertIn("black hair", prompt_sheet["_face_clause"])
|
||||||
|
self.assertIn("blue eyes", prompt_sheet["_face_clause"])
|
||||||
|
self.assertTrue(conv._character_visual_facts("Ludara", prompt_sheet).count("face: ") == 1)
|
||||||
|
|
||||||
|
def test_cast_unreadable_falls_back_to_the_character_alone(self):
|
||||||
|
with mock.patch("core.database.char_face_roster", side_effect=RuntimeError("db")):
|
||||||
|
sheet = conv._attach_face_profile("Ludara", BOOK, self.SHEET, dict(self.SHEET))
|
||||||
|
self.assertTrue(sheet["_face_clause"].startswith("face: "))
|
||||||
|
|
||||||
|
def test_facts_without_cast_context_are_still_deterministic(self):
|
||||||
|
a = conv._character_visual_facts("Ludara", self.SHEET)
|
||||||
|
b = conv._character_visual_facts("Ludara", dict(self.SHEET))
|
||||||
|
self.assertEqual(a, b)
|
||||||
|
self.assertIn("face: ", a)
|
||||||
|
|
||||||
|
def test_seeds_stay_per_prompt(self):
|
||||||
|
# the image seed is derived from the prompt text / generation key, never shared by the cast
|
||||||
|
import inspect
|
||||||
|
source = inspect.getsource(conv._comfyui_run)
|
||||||
|
self.assertIn("seed_key or prompt", source)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewTests(unittest.TestCase):
|
||||||
|
def test_preview_lists_profiles_and_a_before_after_score(self):
|
||||||
|
roster = [{"name": n, "sheet": sh} for n, sh in ROSTER]
|
||||||
|
result = conv._face_preview_for_roster(BOOK, roster)
|
||||||
|
self.assertEqual(result["report"]["characters"], len(result["characters"]))
|
||||||
|
self.assertGreater(result["report"]["mean_distance_after"], result["report"]["mean_distance_before"])
|
||||||
|
self.assertEqual(len(result["similarity_matrix_after"]), len(result["order"]))
|
||||||
|
self.assertIn("Kroah", result["skipped_without_humanoid_face"])
|
||||||
|
first = result["characters"][0]
|
||||||
|
self.assertIn("slots", first)
|
||||||
|
self.assertIn("source", next(iter(first["slots"].values())))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user