""" Visualization utilities for XAI heatmap overlays. Provides: - Heatmap colorization with multiple colormaps - Alpha blending of heatmap over original image - Side-by-side comparison figures - Plotly-based interactive figures """ import numpy as np import cv2 from PIL import Image import matplotlib import matplotlib.pyplot as plt import matplotlib.colors as mcolors from matplotlib.gridspec import GridSpec import plotly.graph_objects as go import plotly.express as px from plotly.subplots import make_subplots from typing import List, Optional, Tuple, Dict import io # ─── Colormaps ──────────────────────────────────────────────────────────────── COLORMAPS = { "jet": cv2.COLORMAP_JET, "hot": cv2.COLORMAP_HOT, "plasma": cv2.COLORMAP_PLASMA, "inferno": cv2.COLORMAP_INFERNO, "viridis": cv2.COLORMAP_VIRIDIS, "turbo": cv2.COLORMAP_TURBO, "cool": cv2.COLORMAP_COOL, "spring": cv2.COLORMAP_SPRING, } def normalize_map(saliency_map: np.ndarray, percentile_clip: float = 99.0) -> np.ndarray: """ Normalize a saliency map to [0, 1], optionally clipping outliers. Args: saliency_map: 2D numpy array percentile_clip: Values above this percentile are clipped (reduces noise spikes) """ if percentile_clip < 100.0: clip_val = np.percentile(saliency_map, percentile_clip) saliency_map = np.clip(saliency_map, None, clip_val) min_val, max_val = saliency_map.min(), saliency_map.max() if max_val - min_val < 1e-8: return np.zeros_like(saliency_map, dtype=np.float32) return ((saliency_map - min_val) / (max_val - min_val)).astype(np.float32) def apply_colormap( saliency_map: np.ndarray, colormap: str = "jet", ) -> np.ndarray: """ Apply a colormap to a normalized [0, 1] saliency map. Returns: RGB image array (H, W, 3) uint8 """ saliency_uint8 = (saliency_map * 255).astype(np.uint8) cmap_code = COLORMAPS.get(colormap, cv2.COLORMAP_JET) colored = cv2.applyColorMap(saliency_uint8, cmap_code) return cv2.cvtColor(colored, cv2.COLOR_BGR2RGB) def overlay_heatmap( original_image: np.ndarray, saliency_map: np.ndarray, alpha: float = 0.5, colormap: str = "jet", percentile_clip: float = 99.0, ) -> np.ndarray: """ Blend a heatmap overlay onto the original image. Args: original_image: (H, W, 3) uint8 numpy array saliency_map: (H, W) float saliency map alpha: Heatmap opacity [0, 1] colormap: Colormap name from COLORMAPS percentile_clip: Clip saliency values above this percentile Returns: Blended (H, W, 3) uint8 numpy array """ H, W = original_image.shape[:2] # Resize map to match image if saliency_map.shape != (H, W): saliency_map = cv2.resize(saliency_map, (W, H), interpolation=cv2.INTER_LINEAR) norm_map = normalize_map(saliency_map, percentile_clip) colored = apply_colormap(norm_map, colormap) original_float = original_image.astype(np.float32) colored_float = colored.astype(np.float32) blended = (1 - alpha) * original_float + alpha * colored_float return np.clip(blended, 0, 255).astype(np.uint8) def make_comparison_figure( original_image: np.ndarray, results: Dict[str, np.ndarray], colormap: str = "jet", alpha: float = 0.5, figsize_per_col: Tuple[float, float] = (4.0, 4.5), ) -> plt.Figure: """ Create a matplotlib figure comparing multiple XAI methods side by side. Args: original_image: (H, W, 3) uint8 image results: {method_name: saliency_map (H, W)} colormap: Colormap for all heatmaps alpha: Overlay opacity Returns: matplotlib Figure """ n_methods = len(results) n_cols = n_methods + 1 # +1 for original fig_w = figsize_per_col[0] * n_cols fig_h = figsize_per_col[1] fig, axes = plt.subplots(1, n_cols, figsize=(fig_w, fig_h)) fig.patch.set_facecolor("#0e1117") titles = ["Original"] + list(results.keys()) images = [original_image] + [ overlay_heatmap(original_image, m, alpha=alpha, colormap=colormap) for m in results.values() ] for ax, title, img in zip(axes, titles, images): ax.imshow(img) ax.set_title(title, color="white", fontsize=11, fontweight="bold", pad=6) ax.axis("off") for spine in ax.spines.values(): spine.set_visible(False) plt.tight_layout(pad=0.5) return fig def fig_to_pil(fig: plt.Figure) -> Image.Image: """Convert a matplotlib Figure to a PIL Image.""" buf = io.BytesIO() fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor()) buf.seek(0) return Image.open(buf).copy() def make_plotly_heatmap( original_image: np.ndarray, saliency_map: np.ndarray, method_name: str, colormap: str = "Hot", ) -> go.Figure: """ Create an interactive Plotly figure with zoomable heatmap overlay. """ H, W = original_image.shape[:2] if saliency_map.shape != (H, W): saliency_map = cv2.resize(saliency_map, (W, H), interpolation=cv2.INTER_LINEAR) norm_map = normalize_map(saliency_map) fig = make_subplots( rows=1, cols=2, subplot_titles=("Original", f"{method_name} Overlay"), horizontal_spacing=0.05, ) fig.add_trace( go.Image(z=original_image, name="Original"), row=1, col=1, ) overlay = overlay_heatmap(original_image, saliency_map, alpha=0.55, colormap="jet") fig.add_trace( go.Image(z=overlay, name=method_name), row=1, col=2, ) fig.update_layout( paper_bgcolor="#0e1117", plot_bgcolor="#0e1117", font=dict(color="white"), margin=dict(l=10, r=10, t=40, b=10), height=380, ) for ann in fig.layout.annotations: ann.font.color = "white" ann.font.size = 13 return fig def plot_top_predictions( probs: np.ndarray, labels: List[str], top_k: int = 5, ) -> go.Figure: """ Create a horizontal bar chart for top-k class predictions. """ top_indices = np.argsort(probs)[::-1][:top_k] top_probs = probs[top_indices] top_labels = [labels[i] for i in top_indices] # Truncate long labels top_labels = [lbl[:35] + "…" if len(lbl) > 35 else lbl for lbl in top_labels] colors = [ "#ff4b4b" if i == 0 else "#4b8bff" for i in range(len(top_probs)) ] fig = go.Figure(go.Bar( x=top_probs[::-1], y=top_labels[::-1], orientation="h", marker_color=colors[::-1], text=[f"{p*100:.1f}%" for p in top_probs[::-1]], textposition="outside", textfont=dict(color="white", size=12), )) fig.update_layout( paper_bgcolor="#0e1117", plot_bgcolor="#161c27", font=dict(color="#c8d0e0", size=12), xaxis=dict( range=[0, min(1.0, top_probs.max() * 1.3)], title="Confidence", gridcolor="#2a2f3e", tickformat=".0%", ), yaxis=dict(gridcolor="#2a2f3e"), margin=dict(l=10, r=60, t=10, b=30), height=220, ) return fig def compute_attribution_stats(saliency_map: np.ndarray) -> Dict[str, float]: """Compute summary statistics for a saliency map.""" flat = saliency_map.flatten() return { "mean": float(flat.mean()), "std": float(flat.std()), "max": float(flat.max()), "sparsity": float((flat < 0.05).mean()), # fraction of near-zero pixels "top10_mean": float(np.partition(flat, -max(1, len(flat)//10))[-len(flat)//10:].mean()), }