134 lines
5.7 KiB
Python
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())
|