Files
aetherbound-guild/tools/verify_visual_fidelity.py
T

471 lines
19 KiB
Python

#!/usr/bin/env python3
"""Compare an approved design master with a normal-render Runtime capture.
Static artwork is compared with dynamic search regions masked out. Dynamic
geometry is measured from rendered pixels by subtracting the same clean static
master from both the approved design and Runtime capture. Declared Runtime
bounds are never accepted as evidence of rendered geometry.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from collections import deque
from pathlib import Path
from typing import Any
from PIL import Image, ImageChops, ImageDraw, ImageFilter
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 (
max(0, min(width, round(x * width))),
max(0, min(height, round(y * height))),
max(0, min(width, round((x + w) * width))),
max(0, min(height, round((y + h) * height))),
)
def normalized_bounds(
box: tuple[int, int, int, int], size: tuple[int, int]
) -> list[float]:
left, top, right, bottom = box
width, height = size
return [
left / width,
top / height,
(right - left) / width,
(bottom - top) / height,
]
def mean_error(left: Image.Image, right: Image.Image, mask: Image.Image | None = None) -> float:
diff = ImageChops.difference(left.convert("RGB"), right.convert("RGB"))
if mask is None:
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)
mask = mask.convert("L")
included = sum(1 for value in mask.getdata() if value)
if included == 0:
return 0.0
total = 0
for channel in diff.split():
histogram = channel.histogram(mask=mask)
total += sum(value * count for value, count in enumerate(histogram))
return total / (included * 3 * 255)
def contained_canvas(image: Image.Image, aspect: float, policy: Any) -> Image.Image:
if isinstance(policy, list):
return image.crop(rect(policy, image.size))
width, height = image.size
current = width / height
if current >= aspect:
canvas_width = round(height * aspect)
left = (width - canvas_width) // 2
return image.crop((left, 0, left + canvas_width, height))
canvas_height = round(width / aspect)
top = (height - canvas_height) // 2
return image.crop((0, top, width, top + canvas_height))
def resolve_contract_path(contract_path: Path, value: str) -> Path:
path = Path(value)
if not path.is_absolute():
path = contract_path.parent / path
return path.resolve()
def raw_difference_mask(left: Image.Image, right: Image.Image, threshold: float) -> Image.Image:
diff = ImageChops.difference(left.convert("RGB"), right.convert("RGB"))
red, green, blue = diff.split()
maximum = ImageChops.lighter(ImageChops.lighter(red, green), blue)
cutoff = round(threshold * 255) if threshold <= 1.0 else round(threshold)
return maximum.point(lambda value: 255 if value >= cutoff else 0, mode="L")
def remove_small_components(mask: Image.Image, minimum_area: int) -> Image.Image:
if minimum_area <= 1:
return mask
width, height = mask.size
pixels = mask.load()
visited = bytearray(width * height)
kept = Image.new("L", mask.size, 0)
kept_pixels = kept.load()
for y in range(height):
for x in range(width):
offset = y * width + x
if visited[offset] or not pixels[x, y]:
continue
visited[offset] = 1
pending: deque[tuple[int, int]] = deque([(x, y)])
component: list[tuple[int, int]] = []
while pending:
current_x, current_y = pending.popleft()
component.append((current_x, current_y))
for next_x, next_y in (
(current_x - 1, current_y),
(current_x + 1, current_y),
(current_x, current_y - 1),
(current_x, current_y + 1),
):
if next_x < 0 or next_y < 0 or next_x >= width or next_y >= height:
continue
next_offset = next_y * width + next_x
if visited[next_offset] or not pixels[next_x, next_y]:
continue
visited[next_offset] = 1
pending.append((next_x, next_y))
if len(component) >= minimum_area:
for kept_x, kept_y in component:
kept_pixels[kept_x, kept_y] = 255
return kept
def prepare_detection_mask(
image: Image.Image,
static_master: Image.Image,
search_bounds: list[float],
detection: dict[str, Any],
) -> Image.Image:
threshold = float(detection.get("difference_threshold", 0.10))
mask = raw_difference_mask(image, static_master, threshold)
outside = Image.new("L", mask.size, 0)
search_box = rect(search_bounds, mask.size)
outside.paste(mask.crop(search_box), search_box[:2])
mask = outside
draw = ImageDraw.Draw(mask)
for excluded in detection.get("exclude_bounds", []):
draw.rectangle(rect(excluded, mask.size), fill=0)
median_size = int(detection.get("median_filter_px", 0))
if median_size > 1:
if median_size % 2 == 0:
median_size += 1
mask = mask.filter(ImageFilter.MedianFilter(median_size))
opening_size = int(detection.get("morphology_open_px", 0))
if opening_size > 1:
if opening_size % 2 == 0:
opening_size += 1
mask = mask.filter(ImageFilter.MinFilter(opening_size))
mask = mask.filter(ImageFilter.MaxFilter(opening_size))
return remove_small_components(mask, int(detection.get("minimum_component_area_px", 1)))
def measure_mask(mask: Image.Image, search_bounds: list[float]) -> dict[str, Any]:
box = mask.getbbox()
foreground_pixels = sum(1 for value in mask.getdata() if value)
search_box = rect(search_bounds, mask.size)
search_area = max(1, (search_box[2] - search_box[0]) * (search_box[3] - search_box[1]))
if box is None:
return {
"bounds": None,
"foreground_pixels": 0,
"search_occupancy": 0.0,
"bounds_occupancy": 0.0,
}
bounds_area = max(1, (box[2] - box[0]) * (box[3] - box[1]))
return {
"bounds": normalized_bounds(box, mask.size),
"foreground_pixels": foreground_pixels,
"search_occupancy": foreground_pixels / search_area,
"bounds_occupancy": foreground_pixels / bounds_area,
}
def maximum_delta(left: list[float], right: list[float], indexes: tuple[int, ...]) -> float:
return max(abs(left[index] - right[index]) for index in indexes)
def offset_box_x(box: tuple[int, int, int, int], offset: int) -> tuple[int, int, int, int]:
return (box[0] + offset, box[1], box[2] + offset, box[3])
def dynamic_check(
region: dict[str, Any],
approved: Image.Image,
runtime: Image.Image,
static_master: Image.Image,
defaults: dict[str, Any],
) -> tuple[dict[str, Any], Image.Image, Image.Image]:
detection = dict(defaults.get("dynamic_detection", {}))
detection.update(region.get("detection", {}))
search_bounds = region.get("search_bounds")
if not isinstance(search_bounds, list) or len(search_bounds) != 4:
raise ValueError(f"dynamic region {region['id']} requires search_bounds")
approved_mask = prepare_detection_mask(approved, static_master, search_bounds, detection)
runtime_mask = prepare_detection_mask(runtime, static_master, search_bounds, detection)
approved_measurement = measure_mask(approved_mask, search_bounds)
runtime_measurement = measure_mask(runtime_mask, search_bounds)
result: dict[str, Any] = {
"id": region["id"],
"mode": region["mode"],
"measurement_source": "rendered_pixel_delta_from_clean_static_master",
"search_bounds": search_bounds,
"approved_measurement": approved_measurement,
"runtime_measurement": runtime_measurement,
}
if "runtime_bounds" in region:
result["declared_runtime_bounds_ignored"] = region["runtime_bounds"]
approved_bounds = approved_measurement["bounds"]
runtime_bounds = runtime_measurement["bounds"]
minimum_pixels = int(detection.get("minimum_foreground_pixels", 1))
minimum_occupancy = float(detection.get("minimum_search_occupancy", 0.0))
presence_pass = (
runtime_bounds is not None
and runtime_measurement["foreground_pixels"] >= minimum_pixels
and runtime_measurement["search_occupancy"] >= minimum_occupancy
)
result["presence"] = {
"minimum_foreground_pixels": minimum_pixels,
"minimum_search_occupancy": minimum_occupancy,
"pass": presence_pass,
}
comparison = region.get("comparison", "approved_delta")
if comparison == "runtime_presence":
anchor_delta = 0.0
size_delta = 0.0
occupancy_delta = 0.0
elif comparison == "runtime_target":
target_bounds = region.get("target_bounds")
if not isinstance(target_bounds, list) or len(target_bounds) != 4:
raise ValueError(f"runtime-target region {region['id']} requires target_bounds")
anchor_delta = maximum_delta(runtime_bounds, target_bounds, (0, 1)) if runtime_bounds else 1.0
size_delta = maximum_delta(runtime_bounds, target_bounds, (2, 3)) if runtime_bounds else 1.0
occupancy_delta = 0.0
result["target_bounds"] = target_bounds
else:
if approved_bounds is None or runtime_bounds is None:
anchor_delta = 1.0
size_delta = 1.0
occupancy_delta = 1.0
else:
anchor_delta = maximum_delta(approved_bounds, runtime_bounds, (0, 1))
size_delta = maximum_delta(approved_bounds, runtime_bounds, (2, 3))
occupancy_delta = abs(
approved_measurement["bounds_occupancy"]
- runtime_measurement["bounds_occupancy"]
)
anchor_tolerance = float(region.get("anchor_tolerance", defaults.get("anchor_delta", 0.03)))
size_tolerance = float(region.get("size_tolerance", defaults.get("focal_scale_delta", 0.08)))
occupancy_tolerance = float(
region.get("occupancy_tolerance", defaults.get("occupancy_delta", 0.20))
)
geometry_pass = anchor_delta <= anchor_tolerance and size_delta <= size_tolerance
occupancy_pass = comparison in ("runtime_presence", "runtime_target") or occupancy_delta <= occupancy_tolerance
result.update(
{
"comparison": comparison,
"anchor_delta": anchor_delta,
"anchor_tolerance": anchor_tolerance,
"size_delta": size_delta,
"size_tolerance": size_tolerance,
"occupancy_delta": occupancy_delta,
"occupancy_tolerance": occupancy_tolerance,
"pass": presence_pass and geometry_pass and occupancy_pass,
}
)
return result, approved_mask, runtime_mask
def dynamic_exclusion_mask(regions: list[dict[str, Any]], size: tuple[int, int]) -> Image.Image:
mask = Image.new("L", size, 255)
draw = ImageDraw.Draw(mask)
for region in regions:
if region["mode"] == "static_visual":
continue
bounds = region.get("static_exclusion_bounds", region.get("search_bounds"))
if isinstance(bounds, list) and len(bounds) == 4:
draw.rectangle(rect(bounds, size), fill=0)
return mask
def run_verification(
approved: Image.Image,
runtime_original: Image.Image,
static_master: Image.Image,
contract: dict[str, Any],
output: Path,
approved_path: Path,
runtime_path: Path,
static_master_path: Path,
) -> dict[str, Any]:
approved_meta = contract["approved_master"]
runtime_meta = contract["runtime_canvas"]
defaults = contract.get("defaults", {})
approved_aspect = float(approved_meta["aspect"])
aspect_tolerance = float(defaults.get("aspect_ratio_delta", 0.01))
static_error_tolerance = float(defaults.get("static_region_mean_error", 0.12))
analysis_width = int(defaults.get("analysis_width", 768))
if approved.size != static_master.size:
raise ValueError("approved design and clean static master must have identical dimensions")
runtime_canvas = contained_canvas(
runtime_original, approved_aspect, runtime_meta.get("bounds", "auto_centered")
)
runtime_aspect = runtime_canvas.width / runtime_canvas.height
aspect_delta = abs(runtime_aspect - approved_aspect) / approved_aspect
analysis_size = (analysis_width, round(analysis_width / approved_aspect))
approved_analysis = approved.resize(analysis_size, Image.Resampling.LANCZOS)
runtime_analysis = runtime_canvas.resize(analysis_size, Image.Resampling.LANCZOS)
static_analysis = static_master.resize(analysis_size, Image.Resampling.LANCZOS)
output.mkdir(parents=True, exist_ok=True)
side = Image.new("RGB", (analysis_size[0] * 2, analysis_size[1]), "#17252c")
side.paste(approved_analysis, (0, 0))
side.paste(runtime_analysis, (analysis_size[0], 0))
side.save(output / "side-by-side.png")
diff = ImageChops.difference(approved_analysis, runtime_analysis)
diff.point(lambda value: min(255, value * 3)).save(output / "pixel-diff.png")
Image.blend(approved_analysis, runtime_analysis, 0.5).save(output / "alpha-overlay.png")
regions: list[dict[str, Any]] = contract.get("regions", [])
checks: list[dict[str, Any]] = [
{
"id": "aspect_ratio",
"expected": approved_aspect,
"actual": runtime_aspect,
"relative_delta": aspect_delta,
"threshold": aspect_tolerance,
"pass": aspect_delta <= aspect_tolerance,
}
]
measured_bounds: dict[str, tuple[list[float] | None, list[float] | None]] = {}
dynamic_masks: list[Image.Image] = []
static_mask = dynamic_exclusion_mask(regions, analysis_size)
for region in regions:
if region["mode"] == "static_visual":
region_box = rect(region["approved_bounds"], analysis_size)
approved_crop = static_analysis.crop(region_box)
runtime_crop = runtime_analysis.crop(region_box)
included = static_mask.crop(region_box)
error = mean_error(approved_crop, runtime_crop, included)
checks.append(
{
"id": region["id"],
"mode": region["mode"],
"comparison_source": "clean_static_master_with_dynamic_search_regions_masked",
"approved_bounds": region["approved_bounds"],
"mean_error": error,
"threshold": float(region.get("mean_error_tolerance", static_error_tolerance)),
"pass": error
<= float(region.get("mean_error_tolerance", static_error_tolerance)),
}
)
continue
result, approved_mask, runtime_mask = dynamic_check(
region, approved_analysis, runtime_analysis, static_analysis, defaults
)
checks.append(result)
measured_bounds[region["id"]] = (
result["approved_measurement"]["bounds"],
result["runtime_measurement"]["bounds"],
)
dynamic_masks.append(runtime_mask)
combined_dynamic_mask = Image.new("L", analysis_size, 0)
for mask in dynamic_masks:
combined_dynamic_mask = ImageChops.lighter(combined_dynamic_mask, mask)
combined_dynamic_mask.save(output / "runtime-dynamic-mask.png")
static_mask.save(output / "static-comparison-mask.png")
annotated = side.copy()
draw = ImageDraw.Draw(annotated)
for region in regions:
if region["mode"] == "static_visual":
approved_box = rect(region["approved_bounds"], analysis_size)
draw.rectangle(approved_box, outline="#f3d991", width=3)
draw.rectangle(offset_box_x(approved_box, analysis_size[0]), outline="#d97964", width=3)
continue
approved_bounds, runtime_bounds = measured_bounds[region["id"]]
if approved_bounds is not None:
draw.rectangle(rect(approved_bounds, analysis_size), outline="#f3d991", width=3)
if runtime_bounds is not None:
runtime_box = rect(runtime_bounds, analysis_size)
draw.rectangle(offset_box_x(runtime_box, analysis_size[0]), outline="#d97964", width=3)
annotated.save(output / "annotated-bounds.png")
passed = all(bool(check["pass"]) for check in checks)
return {
"schema_version": 2,
"approved": str(approved_path),
"runtime": str(runtime_path),
"clean_static_master": str(static_master_path),
"approved_size": list(approved.size),
"runtime_size": list(runtime_original.size),
"runtime_canvas_size": list(runtime_canvas.size),
"analysis_size": list(analysis_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",
"runtime-dynamic-mask.png",
"static-comparison-mask.png",
],
}
def main() -> int:
args = parse_args()
contract: dict[str, Any] = json.loads(args.contract.read_text(encoding="utf-8"))
static_meta = contract.get("clean_static_master")
if not isinstance(static_meta, dict) or not static_meta.get("path"):
raise SystemExit("visual-fidelity contract requires clean_static_master.path")
static_master_path = resolve_contract_path(args.contract, str(static_meta["path"]))
static_master_sha256 = hashlib.sha256(static_master_path.read_bytes()).hexdigest()
expected_static_sha256 = str(static_meta.get("sha256", ""))
report = run_verification(
Image.open(args.approved).convert("RGB"),
Image.open(args.runtime).convert("RGB"),
Image.open(static_master_path).convert("RGB"),
contract,
args.output,
args.approved,
args.runtime,
static_master_path,
)
report["clean_static_master"] = str(static_meta["path"])
static_hash_pass = bool(expected_static_sha256) and static_master_sha256 == expected_static_sha256
report["clean_static_master_sha256"] = static_master_sha256
report["checks"].insert(
0,
{
"id": "clean_static_master_hash",
"expected": expected_static_sha256,
"actual": static_master_sha256,
"pass": static_hash_pass,
},
)
if not static_hash_pass:
report["result"] = "FAIL_VISUAL_FIDELITY"
(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 report["result"] == "PASS" or args.allow_fail else 1
if __name__ == "__main__":
raise SystemExit(main())