worldbox-observer-mod/scripts/crop-dossier-hud.py

112 lines
4.2 KiB
Python
Executable file

#!/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())