File size: 4,032 Bytes
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 126 |
"""Tests for metrics module."""
from __future__ import annotations
from typing import TYPE_CHECKING
import nibabel as nib
import numpy as np
import pytest
from stroke_deepisles_demo.metrics import (
compute_dice,
compute_volume_ml,
load_nifti_as_array,
)
if TYPE_CHECKING:
from pathlib import Path
class TestComputeDice:
"""Tests for compute_dice."""
def test_identical_masks_return_one(self) -> None:
"""Dice of identical masks is 1.0."""
mask = np.array([[[1, 1, 0], [0, 1, 0], [0, 0, 1]]])
dice = compute_dice(mask, mask)
assert dice == 1.0
def test_no_overlap_returns_zero(self) -> None:
"""Dice of non-overlapping masks is 0.0."""
pred = np.array([[[1, 1, 0], [0, 0, 0], [0, 0, 0]]])
gt = np.array([[[0, 0, 0], [0, 0, 0], [0, 0, 1]]])
dice = compute_dice(pred, gt)
assert dice == 0.0
def test_partial_overlap(self) -> None:
"""Dice with partial overlap is between 0 and 1."""
pred = np.array([[[1, 1, 0], [0, 0, 0], [0, 0, 0]]])
gt = np.array([[[1, 0, 0], [0, 0, 0], [0, 0, 0]]])
dice = compute_dice(pred, gt)
# Overlap: 1, Pred: 2, GT: 1 -> Dice = 2*1 / (2+1) = 0.667
assert 0.6 < dice < 0.7
def test_empty_masks_return_one(self) -> None:
"""Dice of two empty masks is 1.0 (both agree on nothing)."""
empty = np.zeros((10, 10, 10))
dice = compute_dice(empty, empty)
assert dice == 1.0
def test_accepts_file_paths(self, temp_dir: Path) -> None:
"""Can compute Dice from NIfTI file paths."""
mask = np.array([[[1, 1, 0], [0, 1, 0], [0, 0, 1]]]).astype(np.float32)
img = nib.Nifti1Image(mask, np.eye(4)) # type: ignore[attr-defined, no-untyped-call]
pred_path = temp_dir / "pred.nii.gz"
gt_path = temp_dir / "gt.nii.gz"
nib.save(img, pred_path) # type: ignore[attr-defined]
nib.save(img, gt_path) # type: ignore[attr-defined]
dice = compute_dice(pred_path, gt_path)
assert dice == 1.0
def test_shape_mismatch_raises(self) -> None:
"""Raises ValueError if shapes don't match."""
pred = np.zeros((10, 10, 10))
gt = np.zeros((10, 10, 5))
with pytest.raises(ValueError, match="Shape mismatch"):
compute_dice(pred, gt)
class TestComputeVolumeMl:
"""Tests for compute_volume_ml."""
def test_computes_volume_from_voxel_size(self) -> None:
"""Volume computed correctly from voxel dimensions."""
# 10x10x10 = 1000 voxels of size 1mm^3 each = 1000mm^3 = 1mL
mask = np.ones((10, 10, 10))
volume = compute_volume_ml(mask, voxel_size_mm=(1.0, 1.0, 1.0))
assert volume == pytest.approx(1.0, rel=0.01)
def test_reads_voxel_size_from_nifti(self, temp_dir: Path) -> None:
"""Reads voxel size from NIfTI header."""
mask = np.ones((10, 10, 10)).astype(np.float32)
# Affine with 2mm voxels
affine = np.diag([2.0, 2.0, 2.0, 1.0])
img = nib.Nifti1Image(mask, affine) # type: ignore[attr-defined, no-untyped-call]
path = temp_dir / "mask.nii.gz"
nib.save(img, path) # type: ignore[attr-defined]
# 1000 voxels * 8mm^3 = 8000mm^3 = 8mL
volume = compute_volume_ml(path)
assert volume == pytest.approx(8.0, rel=0.01)
class TestLoadNiftiAsArray:
"""Tests for load_nifti_as_array."""
def test_returns_array_and_voxel_sizes(self, temp_dir: Path) -> None:
"""Returns data array and voxel dimensions."""
data = np.random.rand(10, 10, 10).astype(np.float32)
affine = np.diag([1.5, 1.5, 2.0, 1.0])
img = nib.Nifti1Image(data, affine) # type: ignore[attr-defined, no-untyped-call]
path = temp_dir / "test.nii.gz"
nib.save(img, path) # type: ignore[attr-defined]
arr, voxels = load_nifti_as_array(path)
assert arr.shape == (10, 10, 10)
assert voxels == pytest.approx((1.5, 1.5, 2.0), rel=0.01)
|