""" Universal Contrast Analyzer for detecting low contrast between ALL adjacent objects. Optimized for Alzheimer's/dementia care environments. """ import numpy as np import cv2 from typing import Dict, List, Tuple, Optional import logging from scipy.spatial import distance from skimage.segmentation import find_boundaries from sklearn.cluster import DBSCAN import colorsys logger = logging.getLogger(__name__) class UniversalContrastAnalyzer: """ Analyzes contrast between ALL adjacent objects in a room. Ensures proper visibility for elderly individuals with Alzheimer's or dementia. """ def __init__(self, wcag_threshold: float = 4.5): self.wcag_threshold = wcag_threshold # Comprehensive ADE20K semantic class mappings self.semantic_classes = { # Floors and ground surfaces 'floor': [3, 4, 13, 28, 78], # floor, wood floor, rug, carpet, mat # Walls and vertical surfaces 'wall': [0, 1, 9, 21], # wall, building, brick, house # Ceiling 'ceiling': [5, 16], # ceiling, sky (for rooms with skylights) # Furniture - expanded list 'furniture': [ 10, 19, 15, 7, 18, 23, 30, 33, 34, 36, 44, 45, 57, 63, 64, 65, 75, # sofa, chair, table, bed, armchair, cabinet, desk, counter, stool, # bench, nightstand, coffee table, ottoman, wardrobe, dresser, shelf, # chest of drawers ], # Doors and openings 'door': [25, 14, 79], # door, windowpane, screen door # Windows 'window': [8, 14], # window, windowpane # Stairs and steps 'stairs': [53, 59], # stairs, step # Small objects that might be on floors/furniture 'objects': [ 17, 20, 24, 37, 38, 39, 42, 62, 68, 71, 73, 80, 82, 84, 89, 90, 92, 93, # curtain, book, picture, towel, clothes, pillow, box, bag, lamp, fan, # cushion, basket, bottle, plate, clock, vase, tray, bowl ], # Kitchen/bathroom fixtures 'fixtures': [ 32, 46, 49, 50, 54, 66, 69, 70, 77, 94, 97, 98, 99, 117, 118, 119, 120, # sink, toilet, bathtub, shower, dishwasher, oven, microwave, # refrigerator, stove, washer, dryer, range hood, kitchen island ], # Decorative elements 'decorative': [ 6, 12, 56, 60, 61, 72, 83, 91, 96, 100, 102, 104, 106, 110, 112, # painting, mirror, sculpture, chandelier, sconce, poster, tapestry ] } # Create reverse mapping for quick lookup self.class_to_category = {} for category, class_ids in self.semantic_classes.items(): for class_id in class_ids: self.class_to_category[class_id] = category def calculate_wcag_contrast(self, color1: np.ndarray, color2: np.ndarray) -> float: """Calculate WCAG 2.0 contrast ratio between two colors""" def relative_luminance(rgb): # Normalize to 0-1 rgb_norm = np.array(rgb) / 255.0 # Apply gamma correction (linearize) rgb_linear = np.where( rgb_norm <= 0.03928, rgb_norm / 12.92, np.power((rgb_norm + 0.055) / 1.055, 2.4) ) # Calculate luminance using ITU-R BT.709 coefficients # L = 0.2126 * R + 0.7152 * G + 0.0722 * B return 0.2126 * rgb_linear[0] + 0.7152 * rgb_linear[1] + 0.0722 * rgb_linear[2] lum1 = relative_luminance(color1) lum2 = relative_luminance(color2) # Ensure lighter color is in numerator lighter = max(lum1, lum2) darker = min(lum1, lum2) # Calculate contrast ratio contrast_ratio = (lighter + 0.05) / (darker + 0.05) return contrast_ratio def calculate_hue_difference(self, color1: np.ndarray, color2: np.ndarray) -> float: """Calculate hue difference in degrees (0-180)""" # Convert RGB to HSV using colorsys for accuracy rgb1 = color1 / 255.0 rgb2 = color2 / 255.0 hsv1 = colorsys.rgb_to_hsv(rgb1[0], rgb1[1], rgb1[2]) hsv2 = colorsys.rgb_to_hsv(rgb2[0], rgb2[1], rgb2[2]) # Calculate circular hue difference (0-1 range converted to 0-180) hue_diff = abs(hsv1[0] - hsv2[0]) * 180 if hue_diff > 90: hue_diff = 180 - hue_diff return hue_diff def calculate_saturation_difference(self, color1: np.ndarray, color2: np.ndarray) -> float: """Calculate saturation difference (0-100)""" rgb1 = color1 / 255.0 rgb2 = color2 / 255.0 hsv1 = colorsys.rgb_to_hsv(rgb1[0], rgb1[1], rgb1[2]) hsv2 = colorsys.rgb_to_hsv(rgb2[0], rgb2[1], rgb2[2]) # Return saturation difference as percentage return abs(hsv1[1] - hsv2[1]) * 100 def extract_dominant_color(self, image: np.ndarray, mask: np.ndarray, sample_size: int = 1000) -> np.ndarray: """Extract dominant color from masked region using robust statistics""" if not np.any(mask): return np.array([128, 128, 128]) # Default gray # Get masked pixels masked_pixels = image[mask] if len(masked_pixels) == 0: return np.array([128, 128, 128]) # Sample if too many pixels (for efficiency) if len(masked_pixels) > sample_size: indices = np.random.choice(len(masked_pixels), sample_size, replace=False) masked_pixels = masked_pixels[indices] # Use DBSCAN clustering to find dominant color cluster if len(masked_pixels) > 50: try: clustering = DBSCAN(eps=30, min_samples=10).fit(masked_pixels) labels = clustering.labels_ # Get the largest cluster unique_labels, counts = np.unique(labels[labels >= 0], return_counts=True) if len(unique_labels) > 0: dominant_label = unique_labels[np.argmax(counts)] dominant_colors = masked_pixels[labels == dominant_label] return np.median(dominant_colors, axis=0).astype(int) except: pass # Fallback to median return np.median(masked_pixels, axis=0).astype(int) def find_adjacent_segments(self, segmentation: np.ndarray) -> Dict[Tuple[int, int], np.ndarray]: """ Find all pairs of adjacent segments and their boundaries. Returns dict mapping (seg1_id, seg2_id) to boundary mask. """ adjacencies = {} # Find boundaries using 4-connectivity boundaries = find_boundaries(segmentation, mode='inner') # For each boundary pixel, check its neighbors h, w = segmentation.shape for y in range(1, h-1): for x in range(1, w-1): if boundaries[y, x]: center_id = segmentation[y, x] # Check 8-connected neighbors for more complete boundaries neighbors = [ segmentation[y-1, x], # top segmentation[y+1, x], # bottom segmentation[y, x-1], # left segmentation[y, x+1], # right segmentation[y-1, x-1], # top-left segmentation[y-1, x+1], # top-right segmentation[y+1, x-1], # bottom-left segmentation[y+1, x+1] # bottom-right ] for neighbor_id in neighbors: if neighbor_id != center_id and neighbor_id != 0: # Different segment, not background # Create ordered pair (smaller id first) pair = tuple(sorted([center_id, neighbor_id])) # Add this boundary pixel to the adjacency map if pair not in adjacencies: adjacencies[pair] = np.zeros((h, w), dtype=bool) adjacencies[pair][y, x] = True # Filter out small boundaries (noise) min_boundary_pixels = 20 # Reduced threshold for better detection filtered_adjacencies = {} for pair, boundary in adjacencies.items(): if np.sum(boundary) >= min_boundary_pixels: filtered_adjacencies[pair] = boundary return filtered_adjacencies def is_contrast_sufficient(self, color1: np.ndarray, color2: np.ndarray, category1: str, category2: str) -> Tuple[bool, str]: """ Determine if contrast is sufficient based on WCAG and perceptual guidelines. Returns (is_sufficient, severity_if_not) """ wcag_ratio = self.calculate_wcag_contrast(color1, color2) hue_diff = self.calculate_hue_difference(color1, color2) sat_diff = self.calculate_saturation_difference(color1, color2) # Critical relationships requiring highest contrast critical_pairs = [ ('floor', 'stairs'), ('floor', 'door'), ('stairs', 'wall') ] # High priority relationships high_priority_pairs = [ ('floor', 'furniture'), ('wall', 'door'), ('wall', 'furniture'), ('floor', 'objects') ] # Check relationship type relationship = tuple(sorted([category1, category2])) # Determine thresholds based on relationship if relationship in critical_pairs: # Critical: require 7:1 contrast ratio if wcag_ratio < 7.0: return False, 'critical' # Also check perceptual differences if wcag_ratio < 10.0 and hue_diff < 30 and sat_diff < 50: return False, 'critical' elif relationship in high_priority_pairs: # High priority: require 4.5:1 contrast ratio if wcag_ratio < 4.5: return False, 'high' # Also check perceptual differences if wcag_ratio < 7.0 and hue_diff < 20 and sat_diff < 40: return False, 'high' else: # Standard: require 3:1 contrast ratio minimum if wcag_ratio < 3.0: return False, 'medium' # Also check perceptual differences if wcag_ratio < 4.5 and hue_diff < 15 and sat_diff < 30: return False, 'medium' return True, None def analyze_contrast(self, image: np.ndarray, segmentation: np.ndarray) -> Dict: """ Perform comprehensive contrast analysis between ALL adjacent objects. Args: image: RGB image segmentation: Segmentation mask with class IDs Returns: Dictionary containing analysis results and visualizations """ h, w = segmentation.shape results = { 'issues': [], 'visualization': image.copy(), 'statistics': { 'total_segments': 0, 'analyzed_pairs': 0, 'low_contrast_pairs': 0, 'critical_issues': 0, 'high_priority_issues': 0, 'medium_priority_issues': 0, 'floor_object_issues': 0 } } # Get unique segments unique_segments = np.unique(segmentation) unique_segments = unique_segments[unique_segments != 0] # Remove background results['statistics']['total_segments'] = len(unique_segments) # Build segment information segment_info = {} logger.info(f"Building segment information for {len(unique_segments)} segments...") for seg_id in unique_segments: mask = segmentation == seg_id area = np.sum(mask) if area < 50: # Skip very small segments continue category = self.class_to_category.get(seg_id, 'unknown') color = self.extract_dominant_color(image, mask) segment_info[seg_id] = { 'category': category, 'mask': mask, 'color': color, 'area': area, 'class_id': seg_id } # Find all adjacent segment pairs logger.info("Finding adjacent segments...") adjacencies = self.find_adjacent_segments(segmentation) logger.info(f"Found {len(adjacencies)} adjacent segment pairs") # Analyze each adjacent pair for (seg1_id, seg2_id), boundary in adjacencies.items(): if seg1_id not in segment_info or seg2_id not in segment_info: continue info1 = segment_info[seg1_id] info2 = segment_info[seg2_id] # Skip if both are unknown categories if info1['category'] == 'unknown' and info2['category'] == 'unknown': continue results['statistics']['analyzed_pairs'] += 1 # Check contrast sufficiency is_sufficient, severity = self.is_contrast_sufficient( info1['color'], info2['color'], info1['category'], info2['category'] ) if not is_sufficient: results['statistics']['low_contrast_pairs'] += 1 # Calculate detailed metrics wcag_ratio = self.calculate_wcag_contrast(info1['color'], info2['color']) hue_diff = self.calculate_hue_difference(info1['color'], info2['color']) sat_diff = self.calculate_saturation_difference(info1['color'], info2['color']) # Check if it's a floor-object issue is_floor_object = ( (info1['category'] == 'floor' and info2['category'] in ['furniture', 'objects']) or (info2['category'] == 'floor' and info1['category'] in ['furniture', 'objects']) ) if is_floor_object: results['statistics']['floor_object_issues'] += 1 # Count by severity if severity == 'critical': results['statistics']['critical_issues'] += 1 elif severity == 'high': results['statistics']['high_priority_issues'] += 1 elif severity == 'medium': results['statistics']['medium_priority_issues'] += 1 # Record the issue issue = { 'segment_ids': (seg1_id, seg2_id), 'categories': (info1['category'], info2['category']), 'colors': (info1['color'].tolist(), info2['color'].tolist()), 'wcag_ratio': float(wcag_ratio), 'hue_difference': float(hue_diff), 'saturation_difference': float(sat_diff), 'boundary_pixels': int(np.sum(boundary)), 'severity': severity, 'is_floor_object': is_floor_object, 'boundary_mask': boundary } results['issues'].append(issue) # Visualize on the output image self._visualize_issue(results['visualization'], boundary, severity) # Sort issues by severity severity_order = {'critical': 0, 'high': 1, 'medium': 2} results['issues'].sort(key=lambda x: severity_order.get(x['severity'], 3)) logger.info(f"Contrast analysis complete: {results['statistics']['low_contrast_pairs']} issues found") return results def _visualize_issue(self, image: np.ndarray, boundary: np.ndarray, severity: str): """Add visual indicators for contrast issues""" # Color coding by severity colors = { 'critical': (255, 0, 0), # Red 'high': (255, 128, 0), # Orange 'medium': (255, 255, 0), # Yellow } color = colors.get(severity, (255, 255, 255)) # Dilate boundary for better visibility kernel = np.ones((3, 3), np.uint8) dilated = cv2.dilate(boundary.astype(np.uint8), kernel, iterations=2) # Apply color overlay with transparency overlay = image.copy() overlay[dilated > 0] = color cv2.addWeighted(overlay, 0.5, image, 0.5, 0, image) return image def generate_report(self, results: Dict) -> str: """Generate a detailed text report of contrast analysis""" stats = results['statistics'] issues = results['issues'] report = [] report.append("=== Universal Contrast Analysis Report ===\n") # Summary statistics report.append(f"Total segments analyzed: {stats['total_segments']}") report.append(f"Adjacent pairs analyzed: {stats['analyzed_pairs']}") report.append(f"Low contrast pairs found: {stats['low_contrast_pairs']}") report.append(f"- Critical issues: {stats['critical_issues']}") report.append(f"- High priority issues: {stats['high_priority_issues']}") report.append(f"- Medium priority issues: {stats['medium_priority_issues']}") report.append(f"Floor-object contrast issues: {stats['floor_object_issues']}\n") # Detailed issues if issues: report.append("=== Contrast Issues (sorted by severity) ===\n") for i, issue in enumerate(issues[:10], 1): # Show top 10 issues cat1, cat2 = issue['categories'] wcag = issue['wcag_ratio'] hue_diff = issue['hue_difference'] sat_diff = issue['saturation_difference'] severity = issue['severity'].upper() report.append(f"{i}. [{severity}] {cat1} ↔ {cat2}") report.append(f" - WCAG Contrast Ratio: {wcag:.2f}:1") # Add recommended values based on severity if severity == 'CRITICAL': report.append(f" - Required: 7:1 minimum") elif severity == 'HIGH': report.append(f" - Required: 4.5:1 minimum") else: report.append(f" - Required: 3:1 minimum") report.append(f" - Hue Difference: {hue_diff:.1f}° (recommended: >30°)") report.append(f" - Saturation Difference: {sat_diff:.1f}% (recommended: >50%)") if issue['is_floor_object']: report.append(" - ⚠️ Floor-object boundary - high visibility required!") report.append(f" - Boundary size: {issue['boundary_pixels']} pixels") report.append("") else: report.append("✅ No contrast issues detected!") return "\n".join(report)