stroke-viewer-frontend / tests /test_metrics.py
VibecoderMcSwaggins's picture
feat(phase-3): End-to-end pipeline with metrics and CLI
3f8bf9c unverified
raw
history blame
4.03 kB
"""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)