File size: 3,716 Bytes
3f8bf9c
 
 
 
 
 
26f14be
3f8bf9c
 
 
 
 
 
 
 
26f14be
3f8bf9c
 
 
 
 
 
 
 
 
 
26f14be
 
3f8bf9c
 
 
 
 
 
 
 
 
 
 
 
 
26f14be
 
3f8bf9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26f14be
3f8bf9c
987c4be
 
3f8bf9c
 
 
 
 
 
 
987c4be
3f8bf9c
 
 
987c4be
 
 
 
3f8bf9c
 
 
a544a50
3f8bf9c
 
a544a50
 
3f8bf9c
987c4be
 
a544a50
3f8bf9c
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""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