This commit is contained in:
2026-02-19 00:33:08 -08:00
parent e37f3dd7b1
commit 70dd0779f2
143 changed files with 31888 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
Visual Diff Tool for iOS Simulator Screenshots
Compares two screenshots pixel-by-pixel to detect visual changes.
Optimized for minimal token output.
Usage: python scripts/visual_diff.py baseline.png current.png [options]
"""
import argparse
import json
import os
import sys
from pathlib import Path
try:
from PIL import Image, ImageChops, ImageDraw
except ImportError:
print("Error: Pillow not installed. Run: pip3 install pillow")
sys.exit(1)
class VisualDiffer:
"""Performs visual comparison between screenshots."""
def __init__(self, threshold: float = 0.01):
"""
Initialize differ with threshold.
Args:
threshold: Maximum acceptable difference ratio (0.01 = 1%)
"""
self.threshold = threshold
def compare(self, baseline_path: str, current_path: str) -> dict:
"""
Compare two images and return difference metrics.
Args:
baseline_path: Path to baseline image
current_path: Path to current image
Returns:
Dictionary with comparison results
"""
# Load images
try:
baseline = Image.open(baseline_path)
current = Image.open(current_path)
except FileNotFoundError as e:
print(f"Error: Image not found - {e}")
sys.exit(1)
except Exception as e:
print(f"Error: Failed to load image - {e}")
sys.exit(1)
# Verify dimensions match
if baseline.size != current.size:
return {
"error": "Image dimensions do not match",
"baseline_size": baseline.size,
"current_size": current.size,
}
# Convert to RGB if needed
if baseline.mode != "RGB":
baseline = baseline.convert("RGB")
if current.mode != "RGB":
current = current.convert("RGB")
# Calculate difference
diff = ImageChops.difference(baseline, current)
# Calculate metrics
total_pixels = baseline.size[0] * baseline.size[1]
diff_pixels = self._count_different_pixels(diff)
diff_percentage = (diff_pixels / total_pixels) * 100
# Determine pass/fail
passed = diff_percentage <= (self.threshold * 100)
return {
"dimensions": baseline.size,
"total_pixels": total_pixels,
"different_pixels": diff_pixels,
"difference_percentage": round(diff_percentage, 2),
"threshold_percentage": self.threshold * 100,
"passed": passed,
"verdict": "PASS" if passed else "FAIL",
}
def _count_different_pixels(self, diff_image: Image.Image) -> int:
"""Count number of pixels that are different."""
# Convert to grayscale for easier processing
diff_gray = diff_image.convert("L")
# Count non-zero pixels (different)
pixels = diff_gray.getdata()
return sum(1 for pixel in pixels if pixel > 10) # Threshold for noise
def generate_diff_image(self, baseline_path: str, current_path: str, output_path: str) -> None:
"""Generate highlighted difference image."""
baseline = Image.open(baseline_path).convert("RGB")
current = Image.open(current_path).convert("RGB")
# Create difference image
diff = ImageChops.difference(baseline, current)
# Enhance differences with red overlay
diff_enhanced = Image.new("RGB", baseline.size)
for x in range(baseline.size[0]):
for y in range(baseline.size[1]):
diff_pixel = diff.getpixel((x, y))
if sum(diff_pixel) > 30: # Threshold for visibility
# Highlight in red
diff_enhanced.putpixel((x, y), (255, 0, 0))
else:
# Keep original
diff_enhanced.putpixel((x, y), current.getpixel((x, y)))
diff_enhanced.save(output_path)
def generate_side_by_side(
self, baseline_path: str, current_path: str, output_path: str
) -> None:
"""Generate side-by-side comparison image."""
baseline = Image.open(baseline_path)
current = Image.open(current_path)
# Create combined image
width = baseline.size[0] * 2 + 10 # 10px separator
height = max(baseline.size[1], current.size[1])
combined = Image.new("RGB", (width, height), color=(128, 128, 128))
# Paste images
combined.paste(baseline, (0, 0))
combined.paste(current, (baseline.size[0] + 10, 0))
combined.save(output_path)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Compare screenshots for visual differences")
parser.add_argument("baseline", help="Path to baseline screenshot")
parser.add_argument("current", help="Path to current screenshot")
parser.add_argument(
"--output",
default=".",
help="Output directory for diff artifacts (default: current directory)",
)
parser.add_argument(
"--threshold",
type=float,
default=0.01,
help="Acceptable difference threshold (0.01 = 1%%, default: 0.01)",
)
parser.add_argument(
"--details", action="store_true", help="Show detailed output (increases tokens)"
)
args = parser.parse_args()
# Create output directory if needed
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
# Initialize differ
differ = VisualDiffer(threshold=args.threshold)
# Perform comparison
result = differ.compare(args.baseline, args.current)
# Handle dimension mismatch
if "error" in result:
print(f"Error: {result['error']}")
print(f"Baseline: {result['baseline_size']}")
print(f"Current: {result['current_size']}")
sys.exit(1)
# Generate artifacts
diff_image_path = output_dir / "diff.png"
comparison_image_path = output_dir / "side-by-side.png"
try:
differ.generate_diff_image(args.baseline, args.current, str(diff_image_path))
differ.generate_side_by_side(args.baseline, args.current, str(comparison_image_path))
except Exception as e:
print(f"Warning: Could not generate images - {e}")
# Output results (token-optimized)
if args.details:
# Detailed output
report = {
"summary": {
"baseline": args.baseline,
"current": args.current,
"threshold": args.threshold,
"passed": result["passed"],
},
"results": result,
"artifacts": {
"diff_image": str(diff_image_path),
"comparison_image": str(comparison_image_path),
},
}
print(json.dumps(report, indent=2))
else:
# Minimal output (default)
print(f"Difference: {result['difference_percentage']}% ({result['verdict']})")
if result["different_pixels"] > 0:
print(f"Changed pixels: {result['different_pixels']:,}")
print(f"Artifacts saved to: {output_dir}/")
# Save JSON report
report_path = output_dir / "diff-report.json"
with open(report_path, "w") as f:
json.dump(
{
"baseline": os.path.basename(args.baseline),
"current": os.path.basename(args.current),
"results": result,
"artifacts": {"diff": "diff.png", "comparison": "side-by-side.png"},
},
f,
indent=2,
)
# Exit with error if test failed
sys.exit(0 if result["passed"] else 1)
if __name__ == "__main__":
main()