""" Failure analysis: turn the per-metric spatial maps into an interpretable, clinician-facing overlay that answers "*where* is this image bad, and *why*?". Several metrics stash a spatial `_map` (local focus energy, illumination shadow, artifact outliers, vessel signal). Here we: * build a per-axis heatmap the clinician can toggle, * build a single composite "problem map" that highlights the regions the dominant failing axis complains about, * annotate the composite with the driving reason(s). """ from __future__ import annotations import numpy as np import cv2 from .qc_metrics import inner_disc_mask def _norm(x): x = x.astype(np.float32) lo, hi = np.percentile(x, 1), np.percentile(x, 99) if hi <= lo: return np.zeros_like(x) return np.clip((x - lo) / (hi - lo), 0, 1) def _heat_overlay(rgb, heat, mask, color=(230, 60, 60), alpha=0.55): """Blend a heat map (0-1) onto the RGB image within the mask.""" heat = heat.copy() heat[~mask] = 0 heat = cv2.GaussianBlur(heat, (0, 0), 3) heat = _norm(heat) layer = np.zeros_like(rgb, np.float32) for c in range(3): layer[..., c] = color[c] out = rgb.astype(np.float32) * (1 - alpha * heat[..., None]) + layer * (alpha * heat[..., None]) return np.clip(out, 0, 255).astype(np.uint8) def per_axis_heatmap(rgb, fov, metric): """Return an overlay image for a single metric that carries a `_map`.""" mask = inner_disc_mask(rgb.shape, fov, 0.98) m = metric.get("_map") if m is None: return rgb.copy() name = metric["name"] if name == "Focus / Defocus": # invert: low local sharpness == problem loc = cv2.GaussianBlur(m.astype(np.float32), (0, 0), 9) problem = 1 - _norm(loc) return _heat_overlay(rgb, problem, mask, color=(60, 120, 240)) if name == "Illumination Uniformity": return _heat_overlay(rgb, _norm(m), mask, color=(40, 40, 90), alpha=0.6) if name == "Artifact Burden": heat = cv2.dilate(m.astype(np.float32), np.ones((5, 5), np.float32)) return _heat_overlay(rgb, heat, mask, color=(255, 210, 40), alpha=0.8) if name == "Vessel Visibility": return _heat_overlay(rgb, _norm(m), mask, color=(60, 220, 120), alpha=0.7) return _heat_overlay(rgb, _norm(m), mask) def composite_problem_map(rgb, fov, metrics, score_summary): """Highlight the regions responsible for the worst failing axes, with a legend of the driving reasons. Returns (overlay_rgb, caption).""" mask = inner_disc_mask(rgb.shape, fov, 0.98) failing = [m for m in metrics if m["score"] < 0.5 and m.get("_map") is not None] failing = sorted(failing, key=lambda m: m["score"])[:3] if not failing: cap = "No focal quality defect localised - any limitation is global." return rgb.copy(), cap accum = np.zeros(rgb.shape[:2], np.float32) palette = { "Focus / Defocus": (60, 120, 240), "Illumination Uniformity": (40, 40, 90), "Artifact Burden": (255, 210, 40), "Vessel Visibility": (60, 220, 120), } out = rgb.astype(np.float32).copy() legend = [] for metric in failing: m = metric["_map"].astype(np.float32) name = metric["name"] if name == "Focus / Defocus": loc = cv2.GaussianBlur(m, (0, 0), 9) heat = 1 - _norm(loc) else: heat = _norm(m) heat[~mask] = 0 heat = cv2.GaussianBlur(heat, (0, 0), 3) heat = _norm(heat) weight = (1 - metric["score"]) color = palette.get(name, (230, 60, 60)) alpha = 0.55 * weight for c in range(3): out[..., c] = out[..., c] * (1 - alpha * heat) + color[c] * (alpha * heat) accum = np.maximum(accum, heat * weight) legend.append(f"{name} ({metric['status']})") out = np.clip(out, 0, 255).astype(np.uint8) cap = "Highlighted regions drive the verdict - " + "; ".join(legend) return out, cap def fov_overlay(rgb, fov): """Draw the detected retinal field boundary + inner analysis disc.""" out = rgb.copy() cx, cy, r = int(fov["cx"]), int(fov["cy"]), int(fov["radius"]) cv2.circle(out, (cx, cy), r, (0, 200, 255), 2) cv2.circle(out, (cx, cy), int(r * 0.90), (0, 255, 180), 1, cv2.LINE_AA) cv2.drawMarker(out, (cx, cy), (0, 255, 180), cv2.MARKER_CROSS, 14, 2) return out