|
|
"""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) |
|
|
|
|
|
data = img.get_fdata(dtype=np.float32) |
|
|
zooms = img.header.get_zooms() |
|
|
|
|
|
|
|
|
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}" |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
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 |
|
|
|
|
|
voxel_dims = voxel_size_mm if voxel_size_mm is not None else (1.0, 1.0, 1.0) |
|
|
|
|
|
|
|
|
volume_voxels = np.sum(data > threshold) |
|
|
voxel_vol_mm3 = math.prod(voxel_dims) |
|
|
|
|
|
return float(volume_voxels * voxel_vol_mm3 / 1000.0) |
|
|
|