#!/usr/bin/env python3 """ visual-diff.py — Pixel-level visual diff for R8 self-check pipeline (L3 enforcement). Compares two PNG images and outputs: - Pixel diff count + percentage (objective, vs AI subjective "look") - Highlighted diff PNG (red overlay on differing regions) — AI can Read this to see exactly where差异 is - Bounding box of all diffs - Exit code 0 if diff <= threshold, 1 otherwise (for hook / CI integration) Usage: python3 scripts/visual-diff.py [threshold-pct] Defaults: threshold-pct = 5.0 (% of pixels that may differ before failing) channel tolerance = 10 (per-channel value diff > 10 counted as different pixel — handles anti-aliasing) Why this script exists: R8 v1 required AI to "对比关键视觉点" — subjective and漏细节-prone. R8 v2 (L3 enforcement) requires AI to call this script for objective measurement. See docs/internal/code-conventions.md R8 + docs/meta-rules.md §4 enforcement层级. """ import sys from PIL import Image, ImageChops def main() -> int: if len(sys.argv) < 4: print(__doc__, file=sys.stderr) return 2 my_path, truth_path, diff_path = sys.argv[1:4] threshold_pct = float(sys.argv[4]) if len(sys.argv) > 4 else 5.0 channel_tolerance = 10 my_img = Image.open(my_path).convert("RGBA") truth_img = Image.open(truth_path).convert("RGBA") # Auto-resize my_img to truth dimensions if mismatch (so any viewport size can compare to Figma frame) if my_img.size != truth_img.size: print( f"⚠️ Size mismatch: my={my_img.size} truth={truth_img.size} — resizing my → truth", file=sys.stderr, ) my_img = my_img.resize(truth_img.size, Image.LANCZOS) # Compute per-pixel difference diff = ImageChops.difference(my_img, truth_img) bbox = diff.getbbox() # Convert to grayscale for histogram-based count (PIL native, vectorized — fast) diff_l = diff.convert("L") histogram = diff_l.histogram() total_pixels = my_img.size[0] * my_img.size[1] diff_pixels = sum(histogram[channel_tolerance + 1 :]) pct = diff_pixels / total_pixels * 100 # Build highlighted diff: red overlay where diff > tolerance mask = diff_l.point(lambda p: 200 if p > channel_tolerance else 0, mode="L") red_overlay = Image.new("RGBA", my_img.size, (255, 0, 0, 0)) red_overlay.putalpha(mask) result = Image.alpha_composite(my_img, red_overlay) result.save(diff_path) # Output objective measurement print(f"📊 Diff pixels: {diff_pixels:,} / {total_pixels:,} ({pct:.2f}%)") print(f"📊 Threshold: {threshold_pct:.1f}%") if bbox: print(f"📊 Diff bbox (x0,y0,x1,y1): {bbox}") print(f"💾 Highlighted diff: {diff_path}") if pct > threshold_pct: print(f"❌ FAIL: diff {pct:.2f}% exceeds threshold {threshold_pct:.1f}%") return 1 print(f"✅ PASS: diff within threshold") return 0 if __name__ == "__main__": sys.exit(main())