"""QC visualization for spheroid segmentation and DAB quantification.""" from pathlib import Path import cv2 import numpy as np DEFAULT_QC_MAX_DIMENSION = 1200 DEFAULT_DAB_VMAX = 0.3 def _resize( image: np.ndarray, max_dimension: int, *, nearest: bool = False, ) -> np.ndarray: height, width = image.shape[:2] scale = min(1.0, max_dimension / max(height, width)) if scale == 1: return image.copy() size = (round(width * scale), round(height * scale)) interpolation = cv2.INTER_NEAREST if nearest else cv2.INTER_AREA return cv2.resize(image, size, interpolation=interpolation) def _draw_spheroids( image: np.ndarray, labels: np.ndarray, boundary_spheroid_ids: set[int], ) -> np.ndarray: output = image.copy() for spheroid_id in np.unique(labels): if spheroid_id == 0: continue spheroid = labels == spheroid_id contours, _ = cv2.findContours( spheroid.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE, ) outline_color = ( (255, 165, 0) if spheroid_id in boundary_spheroid_ids else (0, 255, 255) ) cv2.drawContours(output, contours, -1, outline_color, 3) y, x = np.nonzero(spheroid) center = (round(x.mean()), round(y.mean())) text = str(spheroid_id) cv2.circle(output, center, 18, (0, 0, 0), cv2.FILLED) cv2.putText( output, text, (center[0] - 7 * len(text), center[1] + 7), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 255, 255), 2, cv2.LINE_AA, ) return output def _draw_debris_outlines( image: np.ndarray, debris_mask: np.ndarray, ) -> np.ndarray: output = image.copy() contours, _ = cv2.findContours( debris_mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE, ) cv2.drawContours(output, contours, -1, (255, 0, 255), 1) return output def _add_title(image: np.ndarray, title: str) -> np.ndarray: titled = cv2.copyMakeBorder( image, 54, 0, 0, 0, cv2.BORDER_CONSTANT, value=(28, 28, 28), ) cv2.putText( titled, title, (18, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2, cv2.LINE_AA, ) return titled def create_qc_image( preview: np.ndarray, labels: np.ndarray, debris_mask: np.ndarray, dab: np.ndarray, *, boundary_spheroid_ids: set[int] | None = None, positive_threshold: float | None = None, dab_vmax: float = DEFAULT_DAB_VMAX, max_dimension: int = DEFAULT_QC_MAX_DIMENSION, ) -> np.ndarray: """Return an RGB QC image with segmentation, debris, and DAB panels.""" if preview.shape[:2] != labels.shape: raise ValueError("preview and labels must have the same height and width.") if labels.shape != debris_mask.shape or labels.shape != dab.shape: raise ValueError("labels, debris_mask, and dab must have the same shape.") if max_dimension <= 0: raise ValueError("max_dimension must be positive.") if positive_threshold is not None and not np.isfinite(positive_threshold): raise ValueError("positive_threshold must be finite.") if not np.isfinite(dab_vmax) or dab_vmax <= 0: raise ValueError("dab_vmax must be finite and positive.") preview_small = _resize(preview, max_dimension) labels_small = _resize(labels, max_dimension, nearest=True) debris_small = _resize( (debris_mask > 0).astype(np.uint8), max_dimension, nearest=True, ).astype(bool) dab_small = _resize(dab, max_dimension) if boundary_spheroid_ids is None: boundary_spheroid_ids = { int(spheroid_id) for spheroid_id in np.unique( np.concatenate( ( labels[0, :], labels[-1, :], labels[:, 0], labels[:, -1], ) ) ) if spheroid_id != 0 } debris_outline = _draw_debris_outlines(preview_small, debris_small) debris_outline = _draw_spheroids( debris_outline, labels_small, boundary_spheroid_ids, ) debris_outline = _add_title( debris_outline, "cyan: within edge tolerance | orange: exceeds tolerance | magenta: excluded", ) valid = (labels_small > 0) & ~debris_small heatmap = np.full((*dab_small.shape, 3), 235, dtype=np.uint8) if valid.any(): scaled_dab = np.clip(dab_small / dab_vmax, 0, 1) colors = cv2.applyColorMap( (scaled_dab * 255).astype(np.uint8), cv2.COLORMAP_INFERNO, ) colors = cv2.cvtColor(colors, cv2.COLOR_BGR2RGB) heatmap[valid] = colors[valid] heatmap[debris_small & (labels_small > 0)] = (255, 0, 255) heatmap = _draw_spheroids( heatmap, labels_small, boundary_spheroid_ids, ) heatmap = _add_title( heatmap, ( f"DAB signal (fixed 0-{dab_vmax:g}) | " "orange: exceeds edge tolerance" ), ) panels = [debris_outline, heatmap] if positive_threshold is not None: positive = ( (labels > 0) & (debris_mask == 0) & (dab >= positive_threshold) ) positive_small = _resize( positive.astype(np.uint8), max_dimension, nearest=True, ).astype(bool) binary_positive = np.zeros((*labels_small.shape, 3), dtype=np.uint8) binary_positive[positive_small] = (255, 255, 255) binary_positive = _add_title( binary_positive, f"white: DAB-positive | threshold >= {positive_threshold:.4g}", ) panels.append(binary_positive) return np.concatenate(panels, axis=1) def save_qc_image( output_path: str | Path, preview: np.ndarray, labels: np.ndarray, debris_mask: np.ndarray, dab: np.ndarray, *, boundary_spheroid_ids: set[int] | None = None, positive_threshold: float | None = None, dab_vmax: float = DEFAULT_DAB_VMAX, max_dimension: int = DEFAULT_QC_MAX_DIMENSION, ) -> Path: """Create and save the RGB QC image, returning its output path.""" output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) qc_image = create_qc_image( preview, labels, debris_mask, dab, boundary_spheroid_ids=boundary_spheroid_ids, positive_threshold=positive_threshold, dab_vmax=dab_vmax, max_dimension=max_dimension, ) saved = cv2.imwrite( str(output_path), cv2.cvtColor(qc_image, cv2.COLOR_RGB2BGR), ) if not saved: raise OSError(f"Could not write QC image: {output_path}") return output_path