From 7eabdf0e43c6d264092058cd34b015a3ad60f8c6 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 3 Oct 2026 14:25:56 +0200 Subject: [PATCH] feat(characters): distinct deterministic face profiles per character Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 + Manual.md | 33 + architecture/character-visual-identity.md | 63 ++ core/database.py | 26 + core/face_profile.py | 799 ++++++++++++++++++++++ routes/conversation.py | 155 ++++- tests/test_character_species_isolation.py | 5 +- tests/test_face_profile.py | 398 +++++++++++ 8 files changed, 1474 insertions(+), 8 deletions(-) create mode 100644 core/face_profile.py create mode 100644 tests/test_face_profile.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 46596a7..7b24dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### 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. - **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. ### 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. ### 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. diff --git a/Manual.md b/Manual.md index 406a61c..44a7f9a 100644 --- a/Manual.md +++ b/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. +### 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": ""}` 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) **Purpose:** each character's profile holds only that character's facts. diff --git a/architecture/character-visual-identity.md b/architecture/character-visual-identity.md index bd075b9..bb2244c 100644 --- a/architecture/character-visual-identity.md +++ b/architecture/character-visual-identity.md @@ -212,3 +212,66 @@ confused species checks). Every image-prompt builder therefore reads an **Englis 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 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|`, +`voice-creator-cast-v6||