#!/usr/bin/env python3 """Check the paired L109/L110 capture set and 4 m shell close-ups.""" from __future__ import annotations import hashlib import json from collections import Counter from pathlib import Path import struct ROOT = Path(__file__).resolve().parents[4] CAPTURE_ROOT = Path(__file__).resolve().parent / "captures" RESEARCH_ROOT = CAPTURE_ROOT.parent INDEX = CAPTURE_ROOT / "capture-index.json" OUTPUT = CAPTURE_ROOT / "capture-verification.json" def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def png_size(path: Path) -> tuple[int, int]: with path.open("rb") as stream: header = stream.read(24) if len(header) != 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": raise RuntimeError(f"invalid PNG: {path}") return struct.unpack(">II", header[16:24]) def main() -> int: captures = json.loads(INDEX.read_text(encoding="utf-8"))["captures"] counts = {variant: Counter(row["kind"] for row in captures if row["variant"] == variant) for variant in ("before", "after")} by_variant = {variant: {row["name"]: row for row in captures if row["variant"] == variant} for variant in ("before", "after")} errors: list[str] = [] for variant in ("before", "after"): rows = [row for row in captures if row["variant"] == variant] if len(rows) != 53: errors.append(f"{variant}: expected 53 captures, got {len(rows)}") for row in rows: image = RESEARCH_ROOT / row["capture"] if not row.get("verified") or row.get("render_tile_skirts") != "off": errors.append(f"{variant}/{row['name']}: render verification flags failed") if row.get("drawn_triangles", 0) <= 0 or not image.is_file(): errors.append(f"{variant}/{row['name']}: missing geometry or image") elif png_size(image) != (1920, 1080): errors.append(f"{variant}/{row['name']}: unexpected image size {png_size(image)}") closeups = [row for row in rows if row["kind"] == "open-air-closeup"] if len(closeups) != 11: errors.append(f"{variant}: expected 11 close-ups, got {len(closeups)}") for row in closeups: distance = row.get("shell_standoff_actual_m") if distance is None or not 3.0 <= distance <= 5.0: errors.append(f"{variant}/{row['name']}: close-up standoff {distance}") names_before, names_after = set(by_variant["before"]), set(by_variant["after"]) if names_before != names_after: errors.append("before/after capture names do not match") pair_counts = Counter() for name in sorted(names_before & names_after): left, right = by_variant["before"][name], by_variant["after"][name] same = sha256(RESEARCH_ROOT / left["capture"]) == sha256( RESEARCH_ROOT / right["capture"]) pair_counts[f"{left['kind']}_{'identical' if same else 'changed'}"] += 1 summary = { "status": "PASS" if not errors else "FAIL", "capture_count": len(captures), "open_air_sections": len(json.loads(INDEX.read_text(encoding="utf-8"))[ "open_air_sections"]), "per_variant_kind_counts": {variant: dict(sorted(counts[variant].items())) for variant in counts}, "paired_image_bytes": dict(sorted(pair_counts.items())), "errors": errors, } OUTPUT.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") print(json.dumps(summary, sort_keys=True)) return 0 if not errors else 1 if __name__ == "__main__": raise SystemExit(main())