Files
aetherbound-guild/tools/verify_visual_fidelity.py
T

134 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Compare an approved design master with a normal-render Runtime capture.
This is a geometry and composition gate, not a replacement for human review.
It deliberately reports dynamic regions separately from static artwork so a
page cannot hide a changed composition behind a passing behavior test.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from PIL import Image, ImageChops, ImageDraw
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--approved", type=Path, required=True)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--contract", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--allow-fail", action="store_true")
return parser.parse_args()
def rect(bounds: list[float], size: tuple[int, int]) -> tuple[int, int, int, int]:
width, height = size
x, y, w, h = bounds
return (
round(x * width),
round(y * height),
round((x + w) * width),
round((y + h) * height),
)
def mean_error(left: Image.Image, right: Image.Image) -> float:
diff = ImageChops.difference(left.convert("RGB"), right.convert("RGB"))
histogram = diff.histogram()
total = sum(value * (index % 256) for index, value in enumerate(histogram))
pixels = left.width * left.height * 3
return total / max(1, pixels * 255)
def main() -> int:
args = parse_args()
approved = Image.open(args.approved).convert("RGB")
runtime_original = Image.open(args.runtime).convert("RGB")
contract: dict[str, Any] = json.loads(args.contract.read_text(encoding="utf-8"))
approved_meta = contract["approved_master"]
runtime_meta = contract["runtime_canvas"]
defaults = contract.get("defaults", {})
aspect_tolerance = float(defaults.get("aspect_ratio_delta", 0.01))
static_error_tolerance = float(defaults.get("static_region_mean_error", 0.12))
runtime_aspect = runtime_original.width / runtime_original.height
approved_aspect = float(approved_meta["aspect"])
aspect_delta = abs(runtime_aspect - approved_aspect) / approved_aspect
runtime = runtime_original.resize(approved.size, Image.Resampling.LANCZOS)
args.output.mkdir(parents=True, exist_ok=True)
diff = ImageChops.difference(approved, runtime)
diff = diff.point(lambda value: min(255, value * 3))
side = Image.new("RGB", (approved.width * 2, approved.height), "#17252c")
side.paste(approved, (0, 0))
side.paste(runtime, (approved.width, 0))
side.save(args.output / "side-by-side.png")
diff.save(args.output / "pixel-diff.png")
overlay = Image.blend(approved, runtime, 0.5)
overlay.save(args.output / "alpha-overlay.png")
annotated = side.copy()
draw = ImageDraw.Draw(annotated)
for region in contract.get("regions", []):
approved_box = rect(region["approved_bounds"], approved.size)
runtime_box = rect(region.get("runtime_bounds", region["approved_bounds"]), approved.size)
draw.rectangle(approved_box, outline="#f3d991", width=4)
draw.rectangle(tuple(value + approved.width for value in runtime_box), outline="#d97964", width=4)
annotated.save(args.output / "annotated-bounds.png")
checks: list[dict[str, Any]] = []
checks.append({
"id": "aspect_ratio",
"expected": approved_aspect,
"actual": runtime_aspect,
"relative_delta": aspect_delta,
"threshold": aspect_tolerance,
"pass": aspect_delta <= aspect_tolerance,
})
for region in contract.get("regions", []):
approved_box = rect(region["approved_bounds"], approved.size)
runtime_box = rect(region.get("runtime_bounds", region["approved_bounds"]), approved.size)
approved_crop = approved.crop(approved_box)
runtime_crop = runtime.crop(runtime_box).resize(approved_crop.size, Image.Resampling.LANCZOS)
anchor_delta = max(
abs(region["approved_bounds"][0] - region.get("runtime_bounds", region["approved_bounds"])[0]),
abs(region["approved_bounds"][1] - region.get("runtime_bounds", region["approved_bounds"])[1]),
)
result: dict[str, Any] = {
"id": region["id"],
"mode": region["mode"],
"approved_bounds": region["approved_bounds"],
"runtime_bounds": region.get("runtime_bounds", region["approved_bounds"]),
"anchor_delta": anchor_delta,
}
if region["mode"] == "static_visual":
error = mean_error(approved_crop, runtime_crop)
result.update({"mean_error": error, "threshold": static_error_tolerance, "pass": error <= static_error_tolerance})
else:
tolerance = float(region.get("anchor_tolerance", region.get("focal_scale_tolerance", 0.08)))
result.update({"threshold": tolerance, "pass": anchor_delta <= tolerance})
checks.append(result)
passed = all(bool(check["pass"]) for check in checks)
report = {
"schema_version": 1,
"approved": str(args.approved),
"runtime": str(args.runtime),
"approved_size": list(approved.size),
"runtime_size": list(runtime_original.size),
"checks": checks,
"result": "PASS" if passed else "FAIL_VISUAL_FIDELITY",
"artifacts": ["side-by-side.png", "pixel-diff.png", "alpha-overlay.png", "annotated-bounds.png"],
}
(args.output / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
return 0 if passed or args.allow_fail else 1
if __name__ == "__main__":
raise SystemExit(main())