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>
179 lines
8.1 KiB
Python
179 lines
8.1 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}
|