"""Metrics for evaluating segmentation quality.""" from __future__ import annotations import math from pathlib import Path from typing import TYPE_CHECKING, Any import nibabel as nib import numpy as np if TYPE_CHECKING: from numpy.typing import NDArray def load_nifti_as_array(path: Path) -> tuple[NDArray[np.floating[Any]], tuple[float, float, float]]: """ Load NIfTI file and return data array with voxel dimensions. Args: path: Path to NIfTI file Returns: Tuple of (data_array, voxel_sizes_mm) """ img = nib.load(path) # type: ignore[attr-defined] # Use float32 for memory efficiency (sufficient for medical images) data = img.get_fdata(dtype=np.float32) # type: ignore[attr-defined] zooms = img.header.get_zooms() # type: ignore[attr-defined] # zooms can be 3D or 4D, we want spatial dims. DeepISLES output is 3D. # Extract exactly 3 spatial dimensions. spatial_zooms = zooms[:3] voxel_sizes: tuple[float, float, float] = ( float(spatial_zooms[0]), float(spatial_zooms[1]), float(spatial_zooms[2]), ) return data, voxel_sizes def compute_dice( prediction: Path | NDArray[np.floating[Any]], ground_truth: Path | NDArray[np.floating[Any]], *, threshold: float = 0.5, ) -> float: """ Compute Dice similarity coefficient between prediction and ground truth. Dice = 2 * |P ∩ G| / (|P| + |G|) Args: prediction: Path to NIfTI file or numpy array ground_truth: Path to NIfTI file or numpy array threshold: Threshold for binarization (if needed) Returns: Dice coefficient in [0, 1] Raises: ValueError: If shapes don't match """ if isinstance(prediction, Path): p_data, _ = load_nifti_as_array(prediction) else: p_data = prediction if isinstance(ground_truth, Path): g_data, _ = load_nifti_as_array(ground_truth) else: g_data = ground_truth if p_data.shape != g_data.shape: raise ValueError( f"Shape mismatch: prediction {p_data.shape} vs ground truth {g_data.shape}" ) # Binarize p_bin = (p_data > threshold).astype(bool) g_bin = (g_data > threshold).astype(bool) intersection = np.sum(p_bin & g_bin) total = np.sum(p_bin) + np.sum(g_bin) if total == 0: return 1.0 # Both empty return float(2.0 * intersection / total) def compute_volume_ml( mask: Path | NDArray[np.floating[Any]], voxel_size_mm: tuple[float, float, float] | None = None, *, threshold: float = 0.5, ) -> float: """ Compute lesion volume in milliliters. Args: mask: Path to NIfTI file or numpy array voxel_size_mm: Voxel dimensions in mm (read from NIfTI if None) threshold: Threshold for binarization (default 0.5 for consistency with compute_dice) Returns: Volume in milliliters (mL) Note: Uses the same default threshold (0.5) as compute_dice for consistency. This ensures the volume measurement matches the clinical segmentation decision boundary. """ if isinstance(mask, Path): data, loaded_zooms = load_nifti_as_array(mask) voxel_dims = voxel_size_mm if voxel_size_mm is not None else loaded_zooms else: data = mask # Default to 1mm isotropic if not provided for array voxel_dims = voxel_size_mm if voxel_size_mm is not None else (1.0, 1.0, 1.0) # Binarize at threshold for consistent measurement with compute_dice volume_voxels = np.sum(data > threshold) voxel_vol_mm3 = math.prod(voxel_dims) return float(volume_voxels * voxel_vol_mm3 / 1000.0) # mm3 -> mL