Spaces:
Sleeping
Sleeping
| """ | |
| Enhanced Image Comparison System | |
| Detects and annotates visual differences between Figma and website screenshots | |
| """ | |
| import os | |
| import numpy as np | |
| from typing import List, Dict, Tuple, Any | |
| from dataclasses import dataclass | |
| from PIL import Image, ImageDraw, ImageFont | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| class DifferenceRegion: | |
| """Represents a region with visual differences.""" | |
| x: int | |
| y: int | |
| width: int | |
| height: int | |
| severity: str # "High", "Medium", "Low" | |
| description: str | |
| confidence: float | |
| class ImageComparator: | |
| """Compares two images and detects visual differences.""" | |
| def compare_images( | |
| image1_path: str, | |
| image2_path: str, | |
| threshold: float = 0.95 | |
| ) -> Tuple[float, List[DifferenceRegion]]: | |
| """ | |
| Compare two images and detect differences. | |
| Args: | |
| image1_path: Path to first image (Figma) | |
| image2_path: Path to second image (Website) | |
| threshold: Similarity threshold (0-1) | |
| Returns: | |
| Tuple of (similarity_score, list of difference regions) | |
| """ | |
| try: | |
| # Load images | |
| img1 = Image.open(image1_path).convert('RGB') | |
| img2 = Image.open(image2_path).convert('RGB') | |
| # Resize to same dimensions for comparison | |
| if img1.size != img2.size: | |
| # Resize img2 to match img1 | |
| img2 = img2.resize(img1.size, Image.Resampling.LANCZOS) | |
| # Convert to numpy arrays | |
| arr1 = np.array(img1, dtype=np.float32) | |
| arr2 = np.array(img2, dtype=np.float32) | |
| # Calculate pixel-wise difference | |
| diff = np.abs(arr1 - arr2) | |
| # Calculate similarity score (0-100) | |
| max_diff = 255.0 * 3 # Max possible difference per pixel (RGB) | |
| mean_diff = np.mean(diff) | |
| similarity_score = 100 * (1 - mean_diff / max_diff) | |
| similarity_score = max(0, min(100, similarity_score)) | |
| # Detect difference regions | |
| difference_regions = ImageComparator._detect_regions( | |
| diff, img1.size, similarity_score | |
| ) | |
| return similarity_score, difference_regions | |
| except Exception as e: | |
| logger.error(f"Error comparing images: {str(e)}") | |
| return 0.0, [] | |
| def _detect_regions( | |
| diff_array: np.ndarray, | |
| image_size: Tuple[int, int], | |
| similarity_score: float | |
| ) -> List[DifferenceRegion]: | |
| """ | |
| Detect regions with significant differences. | |
| Args: | |
| diff_array: Pixel-wise difference array | |
| image_size: Size of original image | |
| similarity_score: Overall similarity score | |
| Returns: | |
| List of difference regions | |
| """ | |
| regions = [] | |
| # Calculate per-channel difference | |
| gray_diff = np.mean(diff_array, axis=2) | |
| # Threshold for significant differences | |
| threshold = 30 # Pixel difference threshold | |
| significant = gray_diff > threshold | |
| # Find connected components | |
| from scipy import ndimage | |
| labeled, num_features = ndimage.label(significant) | |
| # Analyze each region | |
| for region_id in range(1, num_features + 1): | |
| region_mask = labeled == region_id | |
| # Skip very small regions (noise) | |
| if np.sum(region_mask) < 100: | |
| continue | |
| # Get bounding box | |
| rows = np.any(region_mask, axis=1) | |
| cols = np.any(region_mask, axis=0) | |
| if not np.any(rows) or not np.any(cols): | |
| continue | |
| y_min, y_max = np.where(rows)[0][[0, -1]] | |
| x_min, x_max = np.where(cols)[0][[0, -1]] | |
| # Calculate region statistics | |
| region_diff = gray_diff[region_mask] | |
| mean_diff = np.mean(region_diff) | |
| max_diff = np.max(region_diff) | |
| # Determine severity | |
| if max_diff > 100: | |
| severity = "High" | |
| confidence = min(1.0, max_diff / 255) | |
| elif max_diff > 50: | |
| severity = "Medium" | |
| confidence = min(1.0, max_diff / 150) | |
| else: | |
| severity = "Low" | |
| confidence = min(1.0, max_diff / 100) | |
| # Generate description | |
| width = x_max - x_min | |
| height = y_max - y_min | |
| description = f"{severity} difference: {width}x{height}px region" | |
| region = DifferenceRegion( | |
| x=int((x_min + x_max) / 2), | |
| y=int((y_min + y_max) / 2), | |
| width=int(width), | |
| height=int(height), | |
| severity=severity, | |
| description=description, | |
| confidence=float(confidence) | |
| ) | |
| regions.append(region) | |
| # Sort by severity | |
| severity_order = {"High": 0, "Medium": 1, "Low": 2} | |
| regions.sort(key=lambda r: severity_order.get(r.severity, 3)) | |
| return regions | |
| class ScreenshotAnnotator: | |
| """Annotates screenshots with visual difference indicators.""" | |
| def annotate_screenshot( | |
| screenshot_path: str, | |
| differences: List[DifferenceRegion], | |
| output_path: str | |
| ) -> bool: | |
| """ | |
| Annotate screenshot with markers for differences. | |
| Args: | |
| screenshot_path: Path to original screenshot | |
| differences: List of visual differences | |
| output_path: Path to save annotated screenshot | |
| Returns: | |
| True if successful | |
| """ | |
| try: | |
| if not os.path.exists(screenshot_path): | |
| return False | |
| # Load image | |
| img = Image.open(screenshot_path).convert('RGB') | |
| draw = ImageDraw.Draw(img, 'RGBA') | |
| # Draw circles and labels for each difference | |
| circle_radius = 40 | |
| for idx, diff in enumerate(differences): | |
| # Draw circle | |
| circle_color = ScreenshotAnnotator._get_color_by_severity(diff.severity) | |
| x, y = diff.x, diff.y | |
| draw.ellipse( | |
| [(x - circle_radius, y - circle_radius), | |
| (x + circle_radius, y + circle_radius)], | |
| outline=circle_color, | |
| width=4 | |
| ) | |
| # Draw number label | |
| label_number = str(idx + 1) | |
| try: | |
| # Try to use a larger font | |
| font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24) | |
| except: | |
| font = ImageFont.load_default() | |
| # Draw label with background | |
| label_bbox = draw.textbbox((x - 8, y - 8), label_number, font=font) | |
| draw.rectangle(label_bbox, fill=circle_color) | |
| draw.text( | |
| (x - 8, y - 8), | |
| label_number, | |
| fill=(255, 255, 255), | |
| font=font | |
| ) | |
| # Draw bounding box around region | |
| box_x1 = x - diff.width // 2 | |
| box_y1 = y - diff.height // 2 | |
| box_x2 = x + diff.width // 2 | |
| box_y2 = y + diff.height // 2 | |
| draw.rectangle( | |
| [(box_x1, box_y1), (box_x2, box_y2)], | |
| outline=circle_color, | |
| width=2 | |
| ) | |
| # Create output directory | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| # Save annotated image | |
| img.save(output_path) | |
| return True | |
| except Exception as e: | |
| logger.error(f"Error annotating screenshot: {str(e)}") | |
| return False | |
| def _get_color_by_severity(severity: str) -> Tuple[int, int, int, int]: | |
| """Get color based on severity level.""" | |
| if severity == "High": | |
| return (255, 0, 0, 220) # Red | |
| elif severity == "Medium": | |
| return (255, 165, 0, 220) # Orange | |
| else: | |
| return (0, 200, 0, 220) # Green | |
| def create_side_by_side_comparison( | |
| figma_screenshot: str, | |
| website_screenshot: str, | |
| figma_annotated: str, | |
| website_annotated: str, | |
| output_path: str, | |
| title: str = "Figma vs Website" | |
| ) -> bool: | |
| """ | |
| Create side-by-side comparison image with labels. | |
| Args: | |
| figma_screenshot: Original Figma screenshot | |
| website_screenshot: Original website screenshot | |
| figma_annotated: Annotated Figma screenshot | |
| website_annotated: Annotated website screenshot | |
| output_path: Path to save comparison | |
| title: Title for the comparison | |
| Returns: | |
| True if successful | |
| """ | |
| try: | |
| # Load annotated images | |
| figma_img = Image.open(figma_annotated).convert('RGB') | |
| website_img = Image.open(website_annotated).convert('RGB') | |
| # Resize to same height | |
| max_height = max(figma_img.height, website_img.height) | |
| figma_img = figma_img.resize( | |
| (int(figma_img.width * max_height / figma_img.height), max_height), | |
| Image.Resampling.LANCZOS | |
| ) | |
| website_img = website_img.resize( | |
| (int(website_img.width * max_height / website_img.height), max_height), | |
| Image.Resampling.LANCZOS | |
| ) | |
| # Create header space | |
| header_height = 60 | |
| total_width = figma_img.width + website_img.width + 40 | |
| total_height = max_height + header_height + 40 | |
| # Create comparison image | |
| comparison = Image.new('RGB', (total_width, total_height), (255, 255, 255)) | |
| draw = ImageDraw.Draw(comparison) | |
| # Draw title | |
| try: | |
| font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) | |
| except: | |
| font = ImageFont.load_default() | |
| draw.text((20, 15), title, fill=(0, 0, 0), font=font) | |
| # Draw labels | |
| try: | |
| label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16) | |
| except: | |
| label_font = ImageFont.load_default() | |
| draw.text((20, header_height + 10), "Figma Design", fill=(0, 0, 0), font=label_font) | |
| draw.text((figma_img.width + 40, header_height + 10), "Website", fill=(0, 0, 0), font=label_font) | |
| # Paste images | |
| comparison.paste(figma_img, (20, header_height + 30)) | |
| comparison.paste(website_img, (figma_img.width + 40, header_height + 30)) | |
| # Create output directory | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| # Save comparison | |
| comparison.save(output_path) | |
| return True | |
| except Exception as e: | |
| logger.error(f"Error creating comparison image: {str(e)}") | |
| return False | |
| def create_difference_report( | |
| differences: List[DifferenceRegion], | |
| similarity_score: float, | |
| viewport: str | |
| ) -> Dict[str, Any]: | |
| """ | |
| Create a detailed report of detected differences. | |
| Args: | |
| differences: List of detected differences | |
| similarity_score: Overall similarity score | |
| viewport: Viewport name (desktop/mobile) | |
| Returns: | |
| Dictionary with report data | |
| """ | |
| high_severity = len([d for d in differences if d.severity == "High"]) | |
| medium_severity = len([d for d in differences if d.severity == "Medium"]) | |
| low_severity = len([d for d in differences if d.severity == "Low"]) | |
| report = { | |
| "viewport": viewport, | |
| "similarity_score": similarity_score, | |
| "total_differences": len(differences), | |
| "high_severity": high_severity, | |
| "medium_severity": medium_severity, | |
| "low_severity": low_severity, | |
| "differences": [ | |
| { | |
| "id": idx + 1, | |
| "severity": diff.severity, | |
| "location": {"x": diff.x, "y": diff.y}, | |
| "size": {"width": diff.width, "height": diff.height}, | |
| "description": diff.description, | |
| "confidence": diff.confidence | |
| } | |
| for idx, diff in enumerate(differences) | |
| ] | |
| } | |
| return report | |