tts-voice-creator-clone-and.../core/character_image_check.py
mARTin-B78 981341de3f feat(characters): check every render, retry with a new seed, and take the sheet's face from the profile
The job checks the portrait crop, then the turnaround, story panel and
same-person against the sheet, re-renders on failure (up to 3 attempts) and
keeps the attempt with the fewest faults. ReActor transfers the profile's face
onto the turnaround and the largest face in the story panel. If the checker is
down, retries stop and the pair is marked unverified. The outcome is saved
with the pair and shown on the character page.

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

210 lines
9.4 KiB
Python

"""Automatic quality check for generated character images.
A vision model is asked only to *report what it sees* (how many figures, which way each one
faces, whether feet are in frame, the apparent gender, ...). The expected values come from the
character sheet and the comparison happens here, in code. Asking a model "is this a woman, as
required?" invites agreement; asking "what gender is this person?" does not.
Pure functions only: building the request, parsing the answer, and judging it. The route owns
the network call, so every piece here is testable without a model.
"""
from __future__ import annotations
import json
import re
PANEL_KINDS = ("portrait", "turnaround", "scene", "same_person")
# Observation fields the model must fill in, per panel kind. Wording is deliberately neutral.
_FIELDS = {
"portrait": {
"person_count": "integer: how many people are visible",
"apparent_gender": '"female", "male" or "unclear"',
"ears": '"pointed", "round" or "hidden" (hidden = covered by hair or headwear)',
"wounds_or_blood": "true if any fresh wound, cut, blood, bruise or bandage is visible",
"background": '"plain" (empty studio backdrop) or "scenery" (landscape, forest, room, sky)',
},
"turnaround": {
"figure_count": "integer: how many full or partial human figures are in the image",
"views_left_to_right": 'list, one entry per figure from left to right, each "front", "side", "back" or "three-quarter"',
"all_feet_visible": "true only if the feet of every figure are completely inside the image",
"all_heads_visible": "true only if the whole head of every figure is inside the image",
"same_person_in_all": "true if every figure is clearly the same person (face, hair, clothing)",
"apparent_gender": '"female", "male" or "unclear" (of the person shown)',
"ears": '"pointed", "round" or "hidden"',
"wounds_or_blood": "true if any fresh wound, cut, blood, bruise or bandage is visible",
"background": '"plain" (empty studio backdrop) or "scenery"',
},
"scene": {
"main_person_gender": '"female", "male" or "unclear": the most prominent person in the foreground',
"ears": '"pointed", "round" or "hidden" (of that person)',
"wounds_or_blood": "true if any fresh wound, cut, blood, bruise or bandage is visible",
},
"same_person": {
"same_person": "true if the two images clearly show the same individual (face shape, features, hair colour and style)",
},
}
_INSTRUCTIONS = (
"You inspect generated images for a production pipeline. Report only what is actually "
"visible; do not guess what was intended. Answer with ONE JSON object and nothing else, "
"with exactly these keys:\n"
)
def expected_from_sheet(sheet: dict, *, is_elf: bool, age_category: str) -> dict:
"""What a correct image must show, derived from the character sheet."""
gender = str((sheet or {}).get("gender") or "").strip().lower()
gender = {"weiblich": "female", "männlich": "male", "maennlich": "male"}.get(gender, gender)
return {
"gender": gender if gender in ("female", "male") else "",
"ears": "pointed" if is_elf else "round",
"age_category": age_category,
}
def build_messages(kind: str, image_uris: list[str]) -> list[dict]:
if kind not in _FIELDS:
raise ValueError(f"unknown panel kind: {kind}")
wanted = 2 if kind == "same_person" else 1
if len(image_uris) != wanted:
raise ValueError(f"{kind} needs {wanted} image(s), got {len(image_uris)}")
keys = "\n".join(f'- "{key}": {desc}' for key, desc in _FIELDS[kind].items())
lead = "Compare the two images." if kind == "same_person" else "Inspect the image."
content: list[dict] = [{"type": "text", "text": f"{lead}\n{_INSTRUCTIONS}{keys}"}]
content += [{"type": "image_url", "image_url": {"url": uri}} for uri in image_uris]
return [{"role": "user", "content": content}]
def parse_observation(raw: str, kind: str) -> dict:
"""Pull the JSON object out of the answer and keep only the expected keys."""
text = re.sub(r"<think>.*?</think>", "", raw or "", flags=re.DOTALL).strip()
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
candidate = fence.group(1) if fence else text[text.find("{"): text.rfind("}") + 1]
try:
data = json.loads(candidate)
except (ValueError, TypeError):
return {}
if not isinstance(data, dict):
return {}
return {key: data[key] for key in _FIELDS.get(kind, {}) if key in data}
def _truthy(value) -> bool | None:
if isinstance(value, bool):
return value
if isinstance(value, str) and value.strip().lower() in ("true", "yes", "ja"):
return True
if isinstance(value, str) and value.strip().lower() in ("false", "no", "nein"):
return False
return None
def judge(kind: str, observed: dict, expected: dict) -> dict:
"""Compare observations with expectations. Returns {passed, failures:[...], observed}.
A missing or unreadable observation counts as a failure: an image we could not verify is
not an image we may call good.
"""
failures: list[str] = []
def need(key: str) -> object:
if key not in observed:
failures.append(f"{key}: not reported")
return None
return observed[key]
def check_gender(key: str) -> None:
value = need(key)
want = expected.get("gender")
if value is None or not want:
return
if str(value).strip().lower() != want:
failures.append(f"gender: shows {value}, sheet says {want}")
def check_ears() -> None:
value = str(need("ears") or "").strip().lower()
want = expected.get("ears")
if value == "pointed" and want == "round":
failures.append("ears: pointed, but the character is not an elf")
elif value == "round" and want == "pointed":
failures.append("ears: round, but the character is an elf")
def check_false(key: str, message: str) -> None:
value = _truthy(need(key))
if value is True:
failures.append(message)
def check_true(key: str, message: str) -> None:
value = _truthy(need(key))
if value is not True and key in observed:
failures.append(message)
if kind == "portrait":
count = need("person_count")
if count is not None and str(count).strip() != "1":
failures.append(f"person_count: {count}, expected exactly one person")
check_gender("apparent_gender")
check_ears()
check_false("wounds_or_blood", "wounds or blood visible")
if str(need("background") or "").strip().lower() == "scenery":
failures.append("background: scenery instead of a plain studio backdrop")
elif kind == "turnaround":
count = need("figure_count")
if count is not None and str(count).strip() != "3":
failures.append(f"figure_count: {count}, expected three")
views = need("views_left_to_right")
if views is not None:
normalised = [str(v).strip().lower() for v in views] if isinstance(views, list) else []
if normalised != ["front", "side", "back"]:
failures.append(f"views: {normalised or views}, expected front, side, back")
check_true("all_feet_visible", "feet cut off")
check_true("all_heads_visible", "head cut off")
check_true("same_person_in_all", "the figures are not the same person")
check_gender("apparent_gender")
check_ears()
check_false("wounds_or_blood", "wounds or blood visible")
if str(need("background") or "").strip().lower() == "scenery":
failures.append("background: scenery instead of a plain studio backdrop")
elif kind == "scene":
check_gender("main_person_gender")
check_ears()
check_false("wounds_or_blood", "wounds or blood visible")
elif kind == "same_person":
check_true("same_person", "the sheet shows a different person than the profile picture")
else:
failures.append(f"unknown panel kind: {kind}")
return {"passed": not failures, "failures": failures, "observed": observed}
CHECKER_UNAVAILABLE = "checker unavailable"
def checker_unavailable(verdict: dict) -> bool:
"""True when the verdict says nothing about the image because the checker could not run.
Re-rendering cannot fix that, so the retry loop must stop instead of re-rolling images.
"""
return any(str(f).startswith(CHECKER_UNAVAILABLE) for f in (verdict or {}).get("failures") or [])
def failure_count(verdicts: dict) -> int:
"""How far an attempt is from passing; lower is better. Missing verdicts count as one each."""
return sum(len((v or {}).get("failures") or []) or (0 if (v or {}).get("passed") else 1)
for v in verdicts.values())
def summarise(verdicts: dict, attempts: int, verified: bool) -> dict:
"""What gets stored with the image pair and shown in the UI."""
failures = [f"{kind}: {f}" for kind, v in verdicts.items() for f in ((v or {}).get("failures") or [])]
return {
"verified": verified,
"passed": verified and not failures,
"attempts": attempts,
"failures": failures,
"checks": {kind: {"passed": bool((v or {}).get("passed")), "failures": (v or {}).get("failures") or [],
"observed": (v or {}).get("observed") or {}}
for kind, v in verdicts.items()},
}