""" Visualization utilities for video quality metrics. Includes: - Radar chart generation - Metric normalization helpers """ import torch from typing import Dict, List, Tuple, Optional import math def normalize_metrics(metrics: Dict[str, float], metric_ranges: Optional[Dict[str, Tuple[float, float]]] = None) -> Dict[str, float]: """ Normalize metrics to 0-1 scale for radar chart visualization. Args: metrics: Dictionary of metric_name -> value metric_ranges: Optional custom ranges per metric Returns: Dictionary of normalized metrics """ # Default ranges based on typical values default_ranges = { 'psnr': (20.0, 50.0), # Higher is better 'ssim': (0.0, 1.0), # Already 0-1, higher is better 'ciede2000': (0.0, 50.0), # Lower is better (will be inverted) 'warping_error': (0.0, 0.5), # Lower is better 'flickering_score': (0.0, 1.0), # Lower is better 'smoothness_score': (0.0, 1.0), # Higher is better 'fvd': (0.0, 500.0), # Lower is better 'fid': (0.0, 300.0), # Lower is better } ranges = {**default_ranges, **(metric_ranges or {})} # Metrics where lower is better (need to invert) lower_is_better = {'ciede2000', 'warping_error', 'flickering_score', 'fvd', 'fid'} normalized = {} for name, value in metrics.items(): if name in ranges: min_val, max_val = ranges[name] norm = (value - min_val) / (max_val - min_val) norm = max(0.0, min(1.0, norm)) # Clamp to [0, 1] if name in lower_is_better: norm = 1.0 - norm normalized[name] = norm else: # Unknown metric, assume 0-1 range normalized[name] = max(0.0, min(1.0, value)) return normalized def generate_radar_chart_svg(metrics: Dict[str, float], title: str = "Video Quality Metrics", size: int = 400) -> str: """ Generate SVG radar chart for metric visualization. Args: metrics: Dictionary of metric_name -> normalized_value (0-1) title: Chart title size: SVG size in pixels Returns: SVG string """ if not metrics: return "" labels = list(metrics.keys()) values = [metrics[k] for k in labels] n = len(labels) center = size // 2 radius = size // 2 - 60 # Calculate polygon points def polar_to_cart(angle: float, r: float) -> Tuple[float, float]: x = center + r * math.cos(angle - math.pi / 2) y = center + r * math.sin(angle - math.pi / 2) return x, y angles = [2 * math.pi * i / n for i in range(n)] # Generate grid lines grid_lines = [] for level in [0.25, 0.5, 0.75, 1.0]: points = [polar_to_cart(a, radius * level) for a in angles] path = "M " + " L ".join(f"{x:.1f},{y:.1f}" for x, y in points) + " Z" grid_lines.append(f'') # Generate axis lines axis_lines = [] for i, angle in enumerate(angles): x, y = polar_to_cart(angle, radius) axis_lines.append(f'') # Generate data polygon data_points = [polar_to_cart(angles[i], radius * values[i]) for i in range(n)] data_path = "M " + " L ".join(f"{x:.1f},{y:.1f}" for x, y in data_points) + " Z" # Generate labels label_elems = [] for i, (label, angle) in enumerate(zip(labels, angles)): lx, ly = polar_to_cart(angle, radius + 25) anchor = "middle" if lx < center - 10: anchor = "end" elif lx > center + 10: anchor = "start" display_label = label.replace('_', ' ').title() value_text = f"{values[i]:.2f}" label_elems.append( f'{display_label}' ) # Assemble SVG svg = f''' {title} {"".join(grid_lines)} {"".join(axis_lines)} {"".join(label_elems)} ''' return svg def generate_radar_chart_tensor(metrics: Dict[str, float], size: int = 256) -> torch.Tensor: """ Generate radar chart as a tensor image for ComfyUI. Args: metrics: Dictionary of metric_name -> normalized_value (0-1) size: Image size Returns: Tensor [1, H, W, 3] suitable for ComfyUI IMAGE output """ # Simple rasterization using PyTorch n = len(metrics) if n == 0: return torch.ones(1, size, size, 3) labels = list(metrics.keys()) values = [metrics[k] for k in labels] # Create image img = torch.ones(size, size, 3) center = size // 2 radius = size // 2 - 30 # Draw using simple circle and line approximations angles = [2 * math.pi * i / n - math.pi / 2 for i in range(n)] # Draw grid circles for r_frac in [0.25, 0.5, 0.75, 1.0]: r = int(radius * r_frac) for theta in range(360): rad = theta * math.pi / 180 x = int(center + r * math.cos(rad)) y = int(center + r * math.sin(rad)) if 0 <= x < size and 0 <= y < size: img[y, x] = torch.tensor([0.9, 0.9, 0.9]) # Draw data polygon points = [] for i in range(n): r = radius * values[i] x = int(center + r * math.cos(angles[i])) y = int(center + r * math.sin(angles[i])) points.append((x, y)) # Draw lines between points for i in range(n): x1, y1 = points[i] x2, y2 = points[(i + 1) % n] steps = max(abs(x2 - x1), abs(y2 - y1), 1) for s in range(steps + 1): x = int(x1 + (x2 - x1) * s / steps) y = int(y1 + (y2 - y1) * s / steps) if 0 <= x < size and 0 <= y < size: img[y, x] = torch.tensor([0.26, 0.52, 0.96]) # Draw center dot for dy in range(-2, 3): for dx in range(-2, 3): y, x = center + dy, center + dx if 0 <= x < size and 0 <= y < size: img[y, x] = torch.tensor([0.2, 0.2, 0.2]) return img.unsqueeze(0) # [1, H, W, 3]