Files
gig-line/Tools/review/judge_screenshot.py
zwitschi 4d27109668 refactor(tools): group the sixty scripts by what they talk to
One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:

  Tools/unreal/    commandlets and the two map builders, with ue_common.py and ue_placement.py as the
                   shared imports (they need the engine)
  Tools/blender/   builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
                   (they need the Blender Flatpak)
  Tools/review/    the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
                   build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
                   visual_targets.json
  Tools/gen/       image and texture generation: the ComfyUI driver, deterministic texture writers,
                   reference fetching
  Tools/input/     OS-level input injection
  root             _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/

Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.

51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.

Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
2026-11-10 12:40:30 +01:00

238 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Judge a screenshot against an expected-scene description using OpenRouter
multimodal image understanding.
Reads OPENROUTER_API_KEY from the project .env (ignored, never committed) and
sends the screenshot as a base64 data-URI image alongside a description of what
the shot is expected to show. Returns a verdict (yes / no / uncertain), a
confidence, and the model's reasoning, so a screenshot can be pre-checked before
a human reviews it.
API: OpenRouter chat/completions, OpenAI-compatible image_url content part:
{"type": "image_url", "image_url": {"url": "data:image/png;base64,<...>"}}
Docs: https://openrouter.ai/docs/guides/overview/multimodal/image-understanding
Usage:
judge_screenshot.py --image Screenshots/review-overview.png \
--expect "a character and two 1 m cubes on a large grey floor"
judge_screenshot.py --image Screenshots/review-character.png \
--expect "the idle upright human figure with warm skin, feet on the plane" \
--model openai/gpt-4o-mini --verbose
judge_screenshot.py --shots # evaluate every review-NN shot in README
.env format:
OPENROUTER_API_KEY=sk-or-v1-...
"""
import argparse
import base64
import json
import os
import re
import sys
import requests
API_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "openai/gpt-4o-mini"
# Two models by default. A single vision model is advisory and has been wrong in both directions on this
# project, so every image is judged twice by different families and the pair is reconciled below.
DEFAULT_MODELS = (
"openai/gpt-4o-mini",
"qwen/qwen3.7-flash",
)
def combine(judgements):
"""Reconcile two or more model judgements into one verdict.
Agreement carries the verdict and the mean confidence. Disagreement is reported as `uncertain` with
both reasons kept, because picking the more confident model would hide exactly the case worth looking
at - the models seeing different things.
"""
usable = [j for j in judgements if j.get("verdict") in ("yes", "no", "uncertain")]
if not usable:
return {"verdict": "unknown", "confidence": 0.0,
"reason": "no usable verdicts", "models": {}}
verdicts = {j["verdict"] for j in usable}
by_model = {j.get("model", "?"): {"verdict": j.get("verdict"),
"confidence": j.get("confidence", 0.0),
"reason": j.get("reason", "")} for j in usable}
if len(verdicts) == 1:
verdict = verdicts.pop()
confidence = sum(j.get("confidence", 0.0) for j in usable) / len(usable)
reason = usable[0].get("reason", "")
else:
verdict = "uncertain"
confidence = min(j.get("confidence", 0.0) for j in usable)
reason = "models disagree: " + " | ".join(
f"{j.get('model')}={j.get('verdict')}: {j.get('reason', '')[:120]}" for j in usable)
return {"verdict": verdict, "confidence": confidence, "reason": reason, "models": by_model}
# Project root = parent of the Tools/ directory the script lives in.
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ENV_PATH = os.path.join(PROJECT_ROOT, ".env")
def load_api_key():
"""Read OPENROUTER_API_KEY from the project .env."""
if not os.path.exists(ENV_PATH):
sys.exit(f"error: no {ENV_PATH} — create it with OPENROUTER_API_KEY=sk-or-v1-...")
with open(ENV_PATH, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
key, _, val = line.partition("=")
if key.strip() == "OPENROUTER_API_KEY":
return val.strip()
sys.exit("error: OPENROUTER_API_KEY not found in .env")
def image_data_uri(path):
"""Return a base64 data: URI for a local image file."""
if not os.path.isfile(path):
sys.exit(f"error: image not found: {path}")
ext = os.path.splitext(path)[1].lower().lstrip(".") or "png"
mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "gif": "image/gif"}.get(ext, "image/png")
with open(path, "rb") as fh:
b64 = base64.b64encode(fh.read()).decode("ascii")
return f"data:{mime};base64,{b64}"
def build_messages(image_path, expect):
user = (
f"You are an objective QA reviewer for a 3D game scene. Look at the "
f"rendered screenshot and judge whether it shows what is expected.\n\n"
f"Expected scene:\n{expect}\n\n"
f"Answer STRICTLY in this JSON shape, with no other text:\n"
f'{{"verdict": "yes"|"no"|"uncertain", "confidence": 0.0-1.0, '
f'"reason": "<short reason, up to 3 sentences>", '
f'"missing": ["<brief gap, if any>"]}}'
)
return [{
"role": "user",
"content": [
{"type": "text", "text": user},
{"type": "image_url", "image_url": {"url": image_data_uri(image_path)}},
],
}]
def judge(api_key, image_path, expect, model, timeout=60):
"""One model's judgement. `combine()` reconciles several of these."""
payload = {
"model": model,
"messages": build_messages(image_path, expect),
"temperature": 0,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
# Identifies the caller to OpenRouter; HTTP-Referer/X-Title are optional.
"HTTP-Referer": "https://github.com/zwitschi/gig-line",
"X-Title": "gig-line screenshot judge",
}
resp = requests.post(API_URL, headers=headers, json=payload, timeout=timeout)
if resp.status_code != 200:
body = resp.text[:500]
sys.exit(f"error: OpenRouter returned {resp.status_code}: {body}")
data = resp.json()
content = data["choices"][0]["message"]["content"]
# Trim any markdown fences and take the first JSON object we can parse.
m = re.search(r"\{.*\}", content, re.S)
if not m:
sys.exit(f"error: model returned non-JSON:\n{content[:500]}")
verdict = json.loads(m.group(0))
verdict["model"] = model
return verdict
def parse_shots_from_targets():
"""Read the review shots and their expectations from Tools/review/visual_targets.json.
Parsing the README's tables broke when the README was restructured into per-shot
sections with the comparison table appended: the old parser looked for a table whose
first cell held a backticked `review-` name. The targets file is the same source the
render gate uses, so it is both stable and the thing the expectations are written to.
"""
targets_path = os.path.join(PROJECT_ROOT, "Tools", "review", "visual_targets.json")
if not os.path.isfile(targets_path):
sys.exit(f"error: no {targets_path}")
import json
with open(targets_path) as handle:
data = json.load(handle)
rows = []
for target in data.get("targets", []):
shot = target.get("shot") or ""
expect = target.get("expect") or ""
if not shot or not expect:
continue
rows.append((shot, expect))
return rows
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--image", help="path to a screenshot PNG to judge")
g.add_argument("--shots", action="store_true",
help="evaluate every review shot listed in Screenshots/README.md")
ap.add_argument("--expect", help="what the shot is expected to show (required with --image)")
ap.add_argument("--models", default=",".join(DEFAULT_MODELS),
help=f"comma-separated OpenRouter models; every image is judged by all of them "
f"and the results reconciled (default {','.join(DEFAULT_MODELS)})")
ap.add_argument("--model", default=None,
help="single model, overriding --models (one judgement, no reconciliation)")
ap.add_argument("--verbose", action="store_true")
ap.add_argument("--timeout", type=int, default=60)
args = ap.parse_args()
api_key = load_api_key()
models = [args.model] if args.model else [m.strip() for m in args.models.split(",") if m.strip()]
if args.shots:
shots = parse_shots_from_targets()
if not shots:
sys.exit("error: no shots found in Tools/review/visual_targets.json")
n = len(shots)
print(f"Judging {n} screenshots with {' + '.join(models)} ...")
ok = 0
for i, (fname, expect) in enumerate(shots, 1):
path = os.path.join(PROJECT_ROOT, fname)
if not os.path.isfile(path):
print(f"[{i}/{n}] {fname}: MISSING (skip)")
continue
try:
judged = [judge(api_key, path, expect, model, args.timeout) for model in models]
except SystemExit as e:
print(f"[{i}/{n}] {fname}: ERROR {e}")
continue
res = combine(judged) if len(judged) > 1 else judged[0]
verdict = res.get("verdict", "unknown")
conf = res.get("confidence", 0.0)
reason = res.get("reason", "")
mark = {"yes": "OK", "no": "FAIL", "uncertain": "?"}.get(verdict, "?")
print(f"[{i}/{n}] {fname}: {mark} ({conf:.2f}) {reason}")
if verdict == "yes":
ok += 1
print(f"\nSummary: {ok}/{n} judged yes")
return
if not args.expect:
ap.error("--expect is required with --image")
judged = [judge(api_key, args.image, args.expect, model, args.timeout) for model in models]
res = combine(judged) if len(judged) > 1 else judged[0]
if args.verbose:
print(json.dumps(res, indent=2))
else:
print(f"verdict={res.get('verdict')} confidence={res.get('confidence')} "
f"reason={res.get('reason', '')}")
for model, detail in res.get("models", {}).items():
print(f" {model}: {detail['verdict']} ({detail['confidence']:.2f})")
if __name__ == "__main__":
main()