NeuroNest / universal_contrast_analyzer.py
lolout1's picture
finishing up defect logic
3311f6c
"""
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)