Add a vision-model screenshot judge to the QA tooling
Full build / Import + headless tests (push) Successful in 32s
Full build / Full desktop build + optional release (push) Successful in 3m30s

`tools/screen_designs.py judge` sends each capture together with its concept
mockup to a vision model and asks whether the capture realises the same screen and
layout intent. This covers what pixel/perceptual metrics cannot judge.

- Prompt explicitly ignores art style, fidelity, fonts, and placeholder text, since
  the references are stylised mockups.
- Verdicts are classed match / review (mismatch below --fail-confidence, default
  0.6) / diff (confident mismatch, exits non-zero) / unknown; --json, --only,
  --dry-run, --model supported.
- Reads the repo .env for OPENROUTER_API_KEY without overriding real env vars.
- Default judge model google/gemini-2.5-flash-lite; openai/gpt-4o-mini verified too.

Tests/docs
- 7 new offline tests in tools/test_screen_designs.py (request payload embeds both
  images, JSON/fenced/parts/non-JSON parsing, threshold classification, .env
  loading); 30 Python tests pass.
- Live run: 3 captures match, 3 review, 0 confident diffs.
- README, TODO section moved to DONE, MEMORY. Version 0.2.15.
This commit is contained in:
zwitschi committed 2026-10-31 22:11:45 +01:00
1 parent 74f14014b2
commit 72b9378e69
7 files changed
+347 -21

No files matched your search

+210
View File
@@ -17,6 +17,12 @@ Two commands:
--draft deterministic wireframe sketches drawn with Pillow
(no network or API key; good for CI and layout checks).
judge send each capture together with its reference design to a vision
model and ask whether they depict the same screen. Pixel and
perceptual metrics cannot judge composition; this closes that gap.
Requires OPENROUTER_API_KEY (falls back to the repo .env).
--dry-run build and print the requests without calling the API.
compare compare docs/screenshots/*.png against the reference designs and
flag gross drift. Semantic composition is judged with coarse,
Pillow-only signals (aspect ratio, frame size, blank-frame
@@ -33,6 +39,7 @@ Run from the repo root:
"""
import argparse
import json
import os
import sys
from pathlib import Path
@@ -54,6 +61,10 @@ ASPECT = 16 / 9
# the model asset_pipeline.py already trusts for non-alpha roles).
DEFAULT_MODEL = "openai/gpt-5-image-mini"
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
# Shared art direction, appended to every prompt so the generated frames read
# as one coherent game. "scene" entries are first-person 3D world views;
# "ui" entries are in-game panels overlaid on a softened scene.
@@ -582,6 +593,193 @@ def compare(args):
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Vision judge — semantic "same screen?" check against the reference designs.
# ---------------------------------------------------------------------------
JUDGE_INSTRUCTIONS = (
"You are reviewing screenshots of a low-poly 3D security-guard game. "
"IMAGE 1 is a CONCEPT MOCKUP of the intended screen (it may be painted in a "
"different art style and show different actors); IMAGE 2 is the ACTUAL "
"capture from the game. Different art style, fidelity, fonts, and character "
"models are EXPECTED and must be ignored.\n\n"
"Judge only whether IMAGE 2 realises the same SCREEN and LAYOUT INTENT as "
"IMAGE 1: is it the same kind of screen (world view, HUD overlay, modal "
"panel), are the main interface regions present in roughly the same places "
"(target line, inspection card, report form, on-screen buttons), and is the "
"subject of the shot comparable (entrance, street, person)?\n\n"
"Answer with JSON only: {\"match\": true|false, \"confidence\": 0..1, "
"\"differences\": [\"short point\", ...]}. Set match=true when the capture "
"realises the mockup's screen even if it looks much simpler."
)
def _load_env_file(path=None):
"""Populate os.environ from a .env file without overriding real variables."""
path = Path(path) if path else ROOT / ".env"
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
def _data_url(path):
import base64
payload = Path(path).read_bytes()
return "data:image/png;base64," + base64.b64encode(payload).decode("ascii")
def build_judge_request(model, screen, capture_path, design_path):
"""Build the OpenRouter chat payload for one capture/design pair."""
prompt = (f"{JUDGE_INSTRUCTIONS}\n\nScreen: {screen['title']} "
f"({screen['source']}). Design notes: {screen['description']}")
return {
"model": model,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": _data_url(design_path)}},
{"type": "image_url", "image_url": {"url": _data_url(capture_path)}},
],
}],
"temperature": 0,
"response_format": {"type": "json_object"},
}
def parse_judge_response(payload):
"""Extract a verdict dict from an OpenRouter chat response."""
try:
text = payload["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError):
return {"match": None, "confidence": 0.0,
"differences": ["unreadable model response"]}
if isinstance(text, list): # some providers return content parts
text = "".join(part.get("text", "") for part in text
if isinstance(part, dict))
text = str(text).strip()
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
text = text[start:end + 1]
import json
try:
data = json.loads(text)
except ValueError:
return {"match": None, "confidence": 0.0,
"differences": [f"non-JSON response: {text[:120]}"]}
differences = data.get("differences") or []
if not isinstance(differences, list):
differences = [str(differences)]
return {
"match": data.get("match") if isinstance(data.get("match"), bool) else None,
"confidence": float(data.get("confidence", 0.0) or 0.0),
"differences": [str(item) for item in differences],
}
def classify(verdict, threshold=0.6):
"""Turn a model verdict into a QA class.
The reference images are concept mockups, so a low-confidence disagreement is
a review note, not a failure. Only a confident mismatch fails the check.
"""
if verdict.get("match") is True:
return "match"
if verdict.get("match") is False:
return "bad" if float(verdict.get("confidence", 0.0)) >= threshold else "review"
return "unknown"
def _call_openrouter(body, timeout=180):
import urllib.error
import urllib.request
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
raise ValueError("OPENROUTER_API_KEY is not set (repo .env is read "
"automatically); use --dry-run to inspect requests")
req = urllib.request.Request(
OPENROUTER_CHAT_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
"X-Title": "Step Line",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise ValueError(f"OpenRouter HTTP {exc.code}: {detail[:300]}")
def judge(args):
_load_env_file()
if not SCREENSHOTS_DIR.is_dir():
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}")
refs = {screen["name"]: screen["ref"] for screen in SCREENS if screen["ref"]}
by_name = {screen["name"]: screen for screen in SCREENS}
results = []
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
if args.only and args.only not in capture.name:
continue
design_name = next((n for n, ref in refs.items()
if ref == capture.name), None)
if design_name is None:
results.append({"capture": capture.name, "screen": None,
"match": None, "confidence": 0.0,
"differences": ["no design reference for this capture"]})
continue
reference = _design_path(design_name)
if not reference.is_file():
results.append({"capture": capture.name, "screen": design_name,
"match": None, "confidence": 0.0,
"differences": ["missing reference design; run generate"]})
continue
body = build_judge_request(args.model, by_name[design_name],
capture, reference)
if args.dry_run:
results.append({
"capture": capture.name, "screen": design_name, "match": None,
"confidence": 0.0, "model": body["model"],
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
})
continue
verdict = parse_judge_response(_call_openrouter(body))
verdict.update({"capture": capture.name, "screen": design_name,
"class": classify(verdict, args.fail_confidence)})
results.append(verdict)
if args.json:
print(json.dumps(results, indent=2))
else:
labels = {"match": "MATCH", "review": "REVIEW", "bad": "DIFF",
"unknown": "UNKNOWN"}
for result in results:
print(f"{labels[result['class']]:7} {result['capture']} "
f"(design: {result['screen']}) "
f"confidence={result['confidence']:.2f}")
for difference in result["differences"]:
print(f" - {difference}")
counts = {key: sum(1 for r in results if r["class"] == key)
for key in labels}
print(f"Summary: {counts['match']}/{len(results)} match, "
f"{counts['review']} review, {counts['bad']} diff "
f"(fail threshold confidence >= {args.fail_confidence:.2f})")
if args.dry_run:
return 0
return 1 if any(r["class"] == "bad" for r in results) else 0
def build_parser():
parser = argparse.ArgumentParser(
description="Generate and compare Step Line screen-design references.")
@@ -599,6 +797,18 @@ def build_parser():
default=generate_textures.GEN_SIZE)
gen.set_defaults(handler=generate, draft=False)
judge_cmd = commands.add_parser(
"judge", help="ask a vision model whether captures match the designs")
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
judge_cmd.add_argument("--json", action="store_true")
judge_cmd.add_argument("--dry-run", action="store_true",
help="print requests instead of calling the API")
judge_cmd.add_argument("--only", default="",
help="only judge captures whose name contains this text")
judge_cmd.add_argument("--fail-confidence", type=float, default=0.6,
help="a mismatch at or above this confidence fails")
judge_cmd.set_defaults(handler=judge)
cmp = commands.add_parser(
"compare", help="compare docs/screenshots/*.png against the designs")
cmp.add_argument("--json", action="store_true")