tts-voice-creator-clone-and.../tests/test_character_image_check.py
mARTin-B78 ac8a6c7a24 feat(characters): automatic vision check for generated character images
The model only reports observations; expectations come from the sheet and the
comparison is in code. Fail-safe: an unreachable checker or unreadable answer
is a failure. Calibrated 14/14 on panels with known defects using the already
loaded LLM; Ling 3.0 Flash VL (free) scored 8/14 with 6 unreadable answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:23:42 +02:00

162 lines
8.5 KiB
Python

"""Automatic character image check: request shape, parsing, judging, and the route."""
from __future__ import annotations
import os
import tempfile
import unittest
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-imgcheck-test-config-"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.character_image_check import build_messages, expected_from_sheet, judge, parse_observation
import routes.conversation as conversation
PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
WOMAN = expected_from_sheet({"gender": "female"}, is_elf=False, age_category="adult")
GOOD_TURNAROUND = {
"figure_count": 3, "views_left_to_right": ["front", "side", "back"],
"all_feet_visible": True, "all_heads_visible": True, "same_person_in_all": True,
"apparent_gender": "female", "ears": "round", "wounds_or_blood": False, "background": "plain",
}
class RequestTests(unittest.TestCase):
def test_the_question_asks_for_observations_not_for_agreement(self):
text = build_messages("turnaround", [PNG])[0]["content"][0]["text"]
self.assertIn("views_left_to_right", text)
self.assertIn("Report only what is actually visible", text)
self.assertNotIn("female", text.split("keys:")[0]) # the expected gender is never revealed
def test_same_person_sends_both_images(self):
parts = build_messages("same_person", [PNG, PNG])[0]["content"]
self.assertEqual(sum(1 for p in parts if p["type"] == "image_url"), 2)
def test_wrong_image_counts_are_rejected(self):
with self.assertRaises(ValueError):
build_messages("same_person", [PNG])
with self.assertRaises(ValueError):
build_messages("portrait", [PNG, PNG])
class ParseTests(unittest.TestCase):
def test_reads_fenced_json_and_drops_unknown_keys(self):
raw = '<think>hmm</think>```json\n{"apparent_gender":"male","extra":1}\n```'
self.assertEqual(parse_observation(raw, "portrait"), {"apparent_gender": "male"})
def test_garbage_gives_an_empty_observation(self):
self.assertEqual(parse_observation("I cannot tell.", "portrait"), {})
class JudgeTests(unittest.TestCase):
def test_a_correct_turnaround_passes(self):
self.assertTrue(judge("turnaround", GOOD_TURNAROUND, WOMAN)["passed"])
def test_three_front_views_fail(self):
out = judge("turnaround", dict(GOOD_TURNAROUND, views_left_to_right=["front", "front", "front"]), WOMAN)
self.assertFalse(out["passed"])
self.assertTrue(any("views" in f for f in out["failures"]))
def test_cut_off_feet_fail(self):
out = judge("turnaround", dict(GOOD_TURNAROUND, all_feet_visible=False), WOMAN)
self.assertIn("feet cut off", out["failures"])
def test_a_man_where_the_sheet_says_woman_fails(self):
out = judge("scene", {"main_person_gender": "male", "ears": "round", "wounds_or_blood": False}, WOMAN)
self.assertEqual(out["failures"], ["gender: shows male, sheet says female"])
def test_elf_ears_on_a_human_fail_but_an_elf_needs_them(self):
self.assertFalse(judge("portrait", {"person_count": 1, "apparent_gender": "female", "ears": "pointed",
"wounds_or_blood": False, "background": "plain"}, WOMAN)["passed"])
elf = expected_from_sheet({"gender": "female"}, is_elf=True, age_category="adult")
self.assertTrue(judge("portrait", {"person_count": 1, "apparent_gender": "female", "ears": "pointed",
"wounds_or_blood": False, "background": "plain"}, elf)["passed"])
def test_hidden_ears_are_not_held_against_the_image(self):
self.assertTrue(judge("portrait", {"person_count": 1, "apparent_gender": "female", "ears": "hidden",
"wounds_or_blood": False, "background": "plain"}, WOMAN)["passed"])
def test_scenery_behind_a_studio_portrait_fails(self):
out = judge("portrait", {"person_count": 1, "apparent_gender": "female", "ears": "round",
"wounds_or_blood": False, "background": "scenery"}, WOMAN)
self.assertFalse(out["passed"])
def test_wounds_fail(self):
out = judge("turnaround", dict(GOOD_TURNAROUND, wounds_or_blood=True), WOMAN)
self.assertIn("wounds or blood visible", out["failures"])
def test_an_observation_the_model_did_not_give_is_a_failure(self):
self.assertFalse(judge("turnaround", {}, WOMAN)["passed"])
def test_no_gender_on_the_sheet_means_gender_is_not_judged(self):
unknown = expected_from_sheet({}, is_elf=False, age_category="adult")
out = judge("scene", {"main_person_gender": "male", "ears": "round", "wounds_or_blood": False}, unknown)
self.assertTrue(out["passed"])
def test_a_different_person_from_the_profile_fails(self):
self.assertFalse(judge("same_person", {"same_person": False}, WOMAN)["passed"])
class RouteTests(unittest.TestCase):
def setUp(self):
app = FastAPI()
app.include_router(conversation.router)
self.client = TestClient(app)
def answer(self, content, status=200):
r = mock.Mock(); r.status_code = status
r.json.return_value = {"choices": [{"message": {"content": content}}]}
r.text = content
return r
def test_judges_the_models_observation_against_the_sheet(self):
with mock.patch.object(conversation, "_post_llm_chat_completion",
return_value=self.answer('{"main_person_gender":"male","ears":"round","wounds_or_blood":false}')):
out = self.client.post("/api/character-image-check",
json={"kind": "scene", "images": [PNG], "sheet": {"gender": "female"}}).json()
self.assertFalse(out["passed"])
self.assertEqual(out["failures"], ["gender: shows male, sheet says female"])
self.assertEqual(out["expected"]["gender"], "female")
def test_a_hedged_species_is_judged_as_human(self):
sheet = {"gender": "female", "race_species": "Mensch (oder Halbelfe, je nach Kontext, hier nicht spezifiziert)"}
with mock.patch.object(conversation, "_post_llm_chat_completion",
return_value=self.answer('{"main_person_gender":"female","ears":"pointed","wounds_or_blood":false}')):
out = self.client.post("/api/character-image-check",
json={"kind": "scene", "images": [PNG], "sheet": sheet}).json()
self.assertEqual(out["expected"]["ears"], "round")
self.assertIn("ears: pointed, but the character is not an elf", out["failures"])
def test_thinking_is_switched_off_and_dropped_if_the_backend_refuses_it(self):
good = self.answer('{"main_person_gender":"female","ears":"round","wounds_or_blood":false}')
with mock.patch.object(conversation, "_post_llm_chat_completion",
side_effect=[self.answer("unsupported", status=400), good]) as llm:
out = self.client.post("/api/character-image-check",
json={"kind": "scene", "images": [PNG], "sheet": {"gender": "female"}}).json()
self.assertTrue(out["passed"])
self.assertEqual(llm.call_args_list[0].args[1]["chat_template_kwargs"], {"enable_thinking": False})
self.assertNotIn("chat_template_kwargs", llm.call_args_list[1].args[1])
def test_an_unreachable_checker_is_a_failure_not_a_pass(self):
with mock.patch.object(conversation, "_post_llm_chat_completion", side_effect=RuntimeError("down")):
out = self.client.post("/api/character-image-check",
json={"kind": "portrait", "images": [PNG], "sheet": {}}).json()
self.assertFalse(out["passed"])
self.assertTrue(out["failures"][0].startswith("checker unavailable"))
def test_bad_requests_are_rejected_before_any_model_call(self):
with mock.patch.object(conversation, "_post_llm_chat_completion") as llm:
for body in ({"kind": "nope", "images": [PNG]},
{"kind": "portrait", "images": ["http://evil/x.png"]},
{"kind": "portrait", "images": []},
{"kind": "same_person", "images": [PNG]}):
with self.subTest(body=str(body)[:50]):
self.assertEqual(self.client.post("/api/character-image-check", json=body).status_code, 400)
llm.assert_not_called()
if __name__ == "__main__":
unittest.main()