File size: 3,319 Bytes
3f8bf9c
 
 
 
 
 
26f14be
3f8bf9c
 
 
 
 
 
 
 
26f14be
3f8bf9c
 
 
 
 
 
 
 
 
 
26f14be
 
3f8bf9c
 
 
 
 
 
 
 
 
 
 
 
 
26f14be
 
3f8bf9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26f14be
3f8bf9c
 
 
 
 
 
 
 
 
 
 
 
 
 
a544a50
3f8bf9c
 
a544a50
 
3f8bf9c
 
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
"""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,
) -> 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)

    Returns:
        Volume in milliliters (mL)
    """
    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)

    volume_voxels = np.sum(data > 0)
    voxel_vol_mm3 = math.prod(voxel_dims)

    return float(volume_voxels * voxel_vol_mm3 / 1000.0)  # mm3 -> mL