Spaces:
Sleeping
Sleeping
| """ | |
| Image Differ Utility | |
| Compares Figma and Website screenshots, generates visual diff overlays. | |
| Key Features: | |
| - Normalizes image sizes (handles Figma 2x export) | |
| - Creates side-by-side comparison | |
| - Highlights differences in red | |
| - Calculates similarity score | |
| """ | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| from pathlib import Path | |
| from typing import Dict, List, Tuple, Any | |
| import cv2 | |
| class ImageDiffer: | |
| """ | |
| Compares two images and generates visual diff output. | |
| """ | |
| def __init__(self, output_dir: str = "data/comparisons"): | |
| self.output_dir = output_dir | |
| Path(output_dir).mkdir(parents=True, exist_ok=True) | |
| def normalize_images( | |
| self, | |
| figma_path: str, | |
| website_path: str, | |
| figma_dims: Dict[str, int], | |
| website_dims: Dict[str, int] | |
| ) -> Tuple[np.ndarray, np.ndarray, float]: | |
| """ | |
| Normalize images to the same size for comparison. | |
| Handles Figma 2x export by detecting and adjusting. | |
| Returns: | |
| Tuple of (figma_array, website_array, scale_factor) | |
| """ | |
| figma_img = Image.open(figma_path).convert("RGB") | |
| website_img = Image.open(website_path).convert("RGB") | |
| figma_w, figma_h = figma_img.size | |
| website_w, website_h = website_img.size | |
| # Detect if Figma is at 2x (common for retina exports) | |
| # If Figma is roughly 2x the website width, scale it down | |
| width_ratio = figma_w / website_w | |
| scale_factor = 1.0 | |
| if 1.8 <= width_ratio <= 2.2: | |
| # Figma is at 2x, scale down to match website | |
| scale_factor = 0.5 | |
| new_figma_w = int(figma_w * scale_factor) | |
| new_figma_h = int(figma_h * scale_factor) | |
| figma_img = figma_img.resize((new_figma_w, new_figma_h), Image.Resampling.LANCZOS) | |
| print(f" 📐 Detected Figma 2x export, scaled to {new_figma_w}x{new_figma_h}") | |
| # Now resize both to match (use the smaller dimensions) | |
| target_w = min(figma_img.size[0], website_img.size[0]) | |
| target_h = min(figma_img.size[1], website_img.size[1]) | |
| figma_img = figma_img.resize((target_w, target_h), Image.Resampling.LANCZOS) | |
| website_img = website_img.resize((target_w, target_h), Image.Resampling.LANCZOS) | |
| return np.array(figma_img), np.array(website_img), scale_factor | |
| def calculate_similarity( | |
| self, | |
| img1: np.ndarray, | |
| img2: np.ndarray | |
| ) -> Tuple[float, np.ndarray]: | |
| """ | |
| Calculate similarity score between two images. | |
| Uses structural similarity (SSIM) for perceptual comparison. | |
| Returns: | |
| Tuple of (similarity_score_0_to_100, diff_mask) | |
| """ | |
| from skimage.metrics import structural_similarity as ssim | |
| # Convert to grayscale for SSIM | |
| gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY) | |
| gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY) | |
| # Calculate SSIM | |
| score, diff = ssim(gray1, gray2, full=True) | |
| # Convert to 0-100 scale | |
| similarity = score * 100 | |
| # Create diff mask (areas with low similarity) | |
| diff_mask = ((1 - diff) * 255).astype(np.uint8) | |
| return similarity, diff_mask | |
| def create_diff_overlay( | |
| self, | |
| figma_img: np.ndarray, | |
| website_img: np.ndarray, | |
| diff_mask: np.ndarray, | |
| threshold: int = 30 | |
| ) -> np.ndarray: | |
| """ | |
| Create an overlay image highlighting differences. | |
| Args: | |
| figma_img: Figma screenshot as numpy array | |
| website_img: Website screenshot as numpy array | |
| diff_mask: Difference mask from SSIM | |
| threshold: Minimum difference to highlight (0-255) | |
| Returns: | |
| Overlay image with differences highlighted in red | |
| """ | |
| # Create output image (copy of website) | |
| overlay = website_img.copy() | |
| # Find areas with significant differences | |
| significant_diff = diff_mask > threshold | |
| # Highlight differences in semi-transparent red | |
| red_overlay = overlay.copy() | |
| red_overlay[significant_diff] = [255, 0, 0] # Red | |
| # Blend with original (50% opacity for red areas) | |
| alpha = 0.5 | |
| overlay[significant_diff] = ( | |
| alpha * red_overlay[significant_diff] + | |
| (1 - alpha) * overlay[significant_diff] | |
| ).astype(np.uint8) | |
| return overlay | |
| def create_comparison_image( | |
| self, | |
| figma_path: str, | |
| website_path: str, | |
| output_path: str, | |
| figma_dims: Dict[str, int], | |
| website_dims: Dict[str, int], | |
| viewport: str | |
| ) -> Dict[str, Any]: | |
| """ | |
| Create a comprehensive comparison image. | |
| Generates a side-by-side view: | |
| [Figma Design] | [Website] | [Diff Overlay] | |
| Returns: | |
| Dict with comparison results | |
| """ | |
| print(f"\n 🔍 Comparing {viewport} screenshots...") | |
| # Normalize images | |
| figma_arr, website_arr, scale = self.normalize_images( | |
| figma_path, website_path, figma_dims, website_dims | |
| ) | |
| # Calculate similarity | |
| similarity, diff_mask = self.calculate_similarity(figma_arr, website_arr) | |
| print(f" 📊 Similarity Score: {similarity:.1f}%") | |
| # Create diff overlay | |
| overlay = self.create_diff_overlay(figma_arr, website_arr, diff_mask) | |
| # Count different pixels | |
| significant_diff = diff_mask > 30 | |
| diff_percentage = (np.sum(significant_diff) / significant_diff.size) * 100 | |
| print(f" 📍 Pixels with differences: {diff_percentage:.1f}%") | |
| # Create side-by-side comparison | |
| h, w = figma_arr.shape[:2] | |
| padding = 20 | |
| label_height = 40 | |
| # Create canvas | |
| canvas_w = (w * 3) + (padding * 4) | |
| canvas_h = h + label_height + (padding * 2) | |
| canvas = np.ones((canvas_h, canvas_w, 3), dtype=np.uint8) * 240 # Light gray bg | |
| # Place images | |
| y_offset = label_height + padding | |
| # Figma (left) | |
| x1 = padding | |
| canvas[y_offset:y_offset+h, x1:x1+w] = figma_arr | |
| # Website (center) | |
| x2 = padding * 2 + w | |
| canvas[y_offset:y_offset+h, x2:x2+w] = website_arr | |
| # Diff overlay (right) | |
| x3 = padding * 3 + w * 2 | |
| canvas[y_offset:y_offset+h, x3:x3+w] = overlay | |
| # Convert to PIL for text | |
| canvas_pil = Image.fromarray(canvas) | |
| draw = ImageDraw.Draw(canvas_pil) | |
| # Try to use a font, fall back to default | |
| try: | |
| font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) | |
| small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14) | |
| except: | |
| font = ImageFont.load_default() | |
| small_font = font | |
| # Add labels | |
| draw.text((x1 + w//2 - 60, 10), "FIGMA DESIGN", fill=(0, 0, 0), font=font) | |
| draw.text((x2 + w//2 - 40, 10), "WEBSITE", fill=(0, 0, 0), font=font) | |
| draw.text((x3 + w//2 - 80, 10), "DIFFERENCES", fill=(255, 0, 0), font=font) | |
| # Add similarity score | |
| score_text = f"Similarity: {similarity:.1f}%" | |
| draw.text((canvas_w - 150, canvas_h - 30), score_text, fill=(0, 100, 0), font=small_font) | |
| # Save | |
| Path(output_path).parent.mkdir(parents=True, exist_ok=True) | |
| canvas_pil.save(output_path) | |
| print(f" ✓ Saved comparison: {output_path}") | |
| # Detect specific differences | |
| differences = self._detect_differences(figma_arr, website_arr, diff_mask, viewport) | |
| return { | |
| "viewport": viewport, | |
| "similarity_score": similarity, | |
| "diff_percentage": diff_percentage, | |
| "comparison_image": output_path, | |
| "differences": differences, | |
| "scale_applied": scale | |
| } | |
| def _detect_differences( | |
| self, | |
| figma_arr: np.ndarray, | |
| website_arr: np.ndarray, | |
| diff_mask: np.ndarray, | |
| viewport: str | |
| ) -> List[Dict[str, Any]]: | |
| """ | |
| Detect and categorize specific differences. | |
| Returns: | |
| List of detected differences with details | |
| """ | |
| differences = [] | |
| # 1. Check overall color difference | |
| figma_mean = np.mean(figma_arr, axis=(0, 1)) | |
| website_mean = np.mean(website_arr, axis=(0, 1)) | |
| color_diff = np.linalg.norm(figma_mean - website_mean) | |
| if color_diff > 10: | |
| differences.append({ | |
| "category": "colors", | |
| "severity": "Medium" if color_diff < 30 else "High", | |
| "title": "Color scheme differs", | |
| "description": f"Average color difference detected (delta: {color_diff:.1f})", | |
| "viewport": viewport | |
| }) | |
| # 2. Check for significant regions of difference | |
| # Find contours in diff mask | |
| _, binary = cv2.threshold(diff_mask, 50, 255, cv2.THRESH_BINARY) | |
| contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| # Filter significant contours (larger than 1% of image) | |
| min_area = (figma_arr.shape[0] * figma_arr.shape[1]) * 0.01 | |
| significant_regions = [c for c in contours if cv2.contourArea(c) > min_area] | |
| if len(significant_regions) > 0: | |
| differences.append({ | |
| "category": "layout", | |
| "severity": "High" if len(significant_regions) > 5 else "Medium", | |
| "title": f"Layout differences in {len(significant_regions)} regions", | |
| "description": f"Found {len(significant_regions)} areas with significant visual differences", | |
| "viewport": viewport, | |
| "regions_count": len(significant_regions) | |
| }) | |
| # 3. Check edges/borders | |
| figma_edges = cv2.Canny(cv2.cvtColor(figma_arr, cv2.COLOR_RGB2GRAY), 50, 150) | |
| website_edges = cv2.Canny(cv2.cvtColor(website_arr, cv2.COLOR_RGB2GRAY), 50, 150) | |
| edge_diff = np.abs(figma_edges.astype(float) - website_edges.astype(float)) | |
| edge_diff_percentage = np.mean(edge_diff) / 255 * 100 | |
| if edge_diff_percentage > 5: | |
| differences.append({ | |
| "category": "structure", | |
| "severity": "Medium", | |
| "title": "Element borders/edges differ", | |
| "description": f"Edge structure differs by {edge_diff_percentage:.1f}%", | |
| "viewport": viewport | |
| }) | |
| return differences | |
| def compare_all_viewports( | |
| self, | |
| figma_screenshots: Dict[str, str], | |
| website_screenshots: Dict[str, str], | |
| figma_dims: Dict[str, Dict[str, int]], | |
| website_dims: Dict[str, Dict[str, int]], | |
| execution_id: str | |
| ) -> Dict[str, Any]: | |
| """ | |
| Compare all viewports and generate comprehensive results. | |
| Returns: | |
| Complete comparison results | |
| """ | |
| results = { | |
| "comparisons": {}, | |
| "all_differences": [], | |
| "viewport_scores": {}, | |
| "overall_score": 0.0 | |
| } | |
| viewports = set(figma_screenshots.keys()) & set(website_screenshots.keys()) | |
| for viewport in viewports: | |
| output_path = f"{self.output_dir}/comparison_{viewport}_{execution_id}.png" | |
| comparison = self.create_comparison_image( | |
| figma_screenshots[viewport], | |
| website_screenshots[viewport], | |
| output_path, | |
| figma_dims.get(viewport, {}), | |
| website_dims.get(viewport, {}), | |
| viewport | |
| ) | |
| results["comparisons"][viewport] = comparison | |
| results["all_differences"].extend(comparison["differences"]) | |
| results["viewport_scores"][viewport] = comparison["similarity_score"] | |
| # Calculate overall score (average of viewports) | |
| if results["viewport_scores"]: | |
| results["overall_score"] = sum(results["viewport_scores"].values()) / len(results["viewport_scores"]) | |
| return results | |