#!/usr/bin/env python3 """Validate / normalize IdleSpectator dossier crops for agent visual checks. Prefer Unity-written ``*-dossier.png`` files (exact UI rect via ReadPixels). For player pastes or legacy full frames, produce a readable 2x nearest upscale. Usage: ./scripts/crop-dossier-hud.py --strict IdleSpectator/.harness/hud-chronicle_smoke-dossier.png ./scripts/crop-dossier-hud.py IdleSpectator/.harness/hud-chronicle_smoke.png # warns: use -dossier.png """ from __future__ import annotations import argparse import json import sys from pathlib import Path import numpy as np from PIL import Image def analyze_nametag(im: Image.Image) -> tuple[bool, int]: """Heuristic: top ~28% of a dossier crop should contain bright nametag pixels.""" arr = np.array(im.convert("RGB")) h, w, _ = arr.shape band = arr[: max(1, h // 3), :] white = (band[:, :, 0] > 165) & (band[:, :, 1] > 150) & (band[:, :, 2] > 110) green = (band[:, :, 1] > 185) & (band[:, :, 0] < 130) orange = (band[:, :, 0] > 190) & (band[:, :, 1] > 90) & (band[:, :, 1] < 180) & (band[:, :, 2] < 100) bright = int(white.sum() + green.sum() + orange.sum()) # Tiny crops still need a handful of lit pixels; larger crops need more. need = max(25, (w * h) // 400) return bright >= need, bright def normalize(path: Path, scale: int = 2) -> dict: im = Image.open(path).convert("RGB") w, h = im.size is_fullframe = w >= 1600 or h >= 1000 stem = path.stem if stem.endswith("-dossier"): out = path method = "unity_or_existing_crop" elif is_fullframe: # Do not pretend pixel heuristics can find the card reliably on 2880p. out = path.with_name(path.stem + "-dossier.png") # Top-left fallback only so agents have *something*; mark nametag unknown. panel = im.crop((0, int(h * 0.08), min(w, int(w * 0.22)), int(h * 0.08) + min(280, h // 4))) panel.resize((panel.width * scale, panel.height * scale), Image.NEAREST).save(out) method = "fullframe_fallback_topleft" else: out = path.with_name(path.stem + "-dossier.png") im.resize((w * scale, h * scale), Image.NEAREST).save(out) method = "player_paste_upscale" check_im = Image.open(out) ok, bright = analyze_nametag(check_im) if method == "fullframe_fallback_topleft": ok = False # never trust full-frame fallback for PASS report = { "source": str(path), "dossier": str(out), "method": method, "size": [check_im.width, check_im.height], "nametag_bright_pixels": bright, "nametag_ok": ok, "is_fullframe_source": is_fullframe, } report_path = Path(str(out).replace(".png", ".json")) if report_path.suffix != ".json": report_path = out.with_suffix(".json") # Prefer sibling *-dossier.json next to crop. if out.name.endswith("-dossier.png"): report_path = out.with_name(out.stem + ".json") report_path.write_text(json.dumps(report, indent=2) + "\n") return report def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("images", nargs="+", type=Path) ap.add_argument("--strict", action="store_true", help="Exit 1 if nametag band looks empty") args = ap.parse_args() worst = 0 for path in args.images: if not path.is_file(): print(f"MISSING {path}", file=sys.stderr) worst = 1 continue report = normalize(path) status = "OK" if report["nametag_ok"] else "WARN_NO_NAMETAG" if report["method"] == "fullframe_fallback_topleft": status = "WARN_FULLFRAME" print( f"{status} {report['dossier']} - prefer Unity '*-dossier.png' from harness screenshot action", file=sys.stderr, ) worst = max(worst, 1 if args.strict else 0) else: print( f"{status} {report['dossier']} method={report['method']} " f"bright={report['nametag_bright_pixels']} size={report['size']}" ) if args.strict and not report["nametag_ok"]: worst = 1 return worst if __name__ == "__main__": raise SystemExit(main())