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

399 lines
22 KiB
Python

"""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()