Skip to content

Commit a8da817

Browse files
authored
Merge branch 'dev' into fix/pydicom-affine-single-slice-zero-div
2 parents 789e533 + eb74488 commit a8da817

4 files changed

Lines changed: 349 additions & 0 deletions

File tree

docs/source/metrics.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ Metrics
116116
.. autoclass:: SurfaceDiceMetric
117117
:members:
118118

119+
`Absolute volume difference`
120+
----------------------------
121+
.. autofunction:: compute_absolute_volume_difference
122+
123+
.. autoclass:: AbsoluteVolumeDifferenceMetric
124+
:members:
125+
119126
`PanopticQualityMetric`
120127
-----------------------
121128
.. autofunction:: compute_panoptic_quality

monai/metrics/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from __future__ import annotations
1313

14+
from .absolute_volume_difference import AbsoluteVolumeDifferenceMetric, compute_absolute_volume_difference
1415
from .active_learning_metrics import LabelQualityScore, VarianceMetric, compute_variance, label_quality_score
1516
from .average_precision import AveragePrecisionMetric, compute_average_precision
1617
from .calibration import CalibrationErrorMetric, CalibrationReduction, calibration_binning
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Copyright (c) MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
from __future__ import annotations
13+
14+
import torch
15+
16+
from monai.metrics.utils import do_metric_reduction, ignore_background
17+
from monai.utils import MetricReduction
18+
19+
from .metric import CumulativeIterationMetric
20+
21+
__all__ = ["AbsoluteVolumeDifferenceMetric", "compute_absolute_volume_difference"]
22+
23+
24+
class AbsoluteVolumeDifferenceMetric(CumulativeIterationMetric):
25+
"""
26+
Compute the Absolute Volume Difference (AVD) between predicted and ground-truth
27+
segmentation masks.
28+
29+
AVD measures the absolute difference in the number of foreground voxels between
30+
prediction and ground truth, per class. It is particularly useful for small-object
31+
segmentation (e.g. retinal fluid in OCT volumes) where Dice score is known to be
32+
overly sensitive to volume size and does not directly reflect volume discrepancies.
33+
34+
.. note::
35+
For 2D inputs this computes the difference in foreground **areas** rather than
36+
volumes. In all cases the returned values are raw voxel/pixel counts and are
37+
**not** scaled by the voxel/pixel spacing, so they are not expressed in the
38+
physical units of the original image.
39+
40+
Reference:
41+
Bogunovic et al. (2019). RETOUCH: The Retinal OCT Fluid Detection and
42+
Segmentation Benchmark and Challenge.
43+
IEEE Transactions on Medical Imaging, 38(8), 1858-1874.
44+
https://ieeexplore.ieee.org/document/8653407
45+
46+
The inputs ``y_pred`` and ``y`` are expected to be binarized one-hot tensors with
47+
shape BCHW[D]. If they contain continuous values (e.g. sigmoid outputs), binarize
48+
them first with a suitable threshold transform.
49+
50+
The typical execution steps of this metric class follow
51+
:py:class:`monai.metrics.metric.Cumulative`.
52+
53+
Example:
54+
55+
.. code-block:: python
56+
57+
import torch
58+
from monai.metrics import AbsoluteVolumeDifferenceMetric
59+
60+
batch_size, n_classes = 4, 3
61+
y_pred = torch.randint(0, 2, (batch_size, n_classes, 64, 64, 32)).float()
62+
y = torch.randint(0, 2, (batch_size, n_classes, 64, 64, 32)).float()
63+
64+
metric = AbsoluteVolumeDifferenceMetric(include_background=False)
65+
metric(y_pred, y) # accumulate
66+
result = metric.aggregate() # shape: (n_classes - 1,) after mean reduction
67+
metric.reset()
68+
69+
Args:
70+
include_background: whether to include AVD computation on the first channel
71+
(index 0), which is by convention assumed to be background. Defaults to
72+
``True``. Set to ``False`` when the background class dominates and you only
73+
care about foreground classes (e.g. fluid sub-types in OCT).
74+
reduction: defines how to aggregate per-batch-per-class results. Available
75+
modes are enumerated in :py:class:`monai.utils.enums.MetricReduction`.
76+
Defaults to ``"mean"``.
77+
get_not_nans: if ``True``, :meth:`aggregate` returns ``(metric, not_nans)``
78+
where ``not_nans`` counts the number of valid (non-NaN) values.
79+
Defaults to ``False``.
80+
ignore_empty: if ``True``, cases where the ground-truth channel is entirely
81+
empty (zero voxels) are excluded from aggregation by setting their value
82+
to ``NaN``. If ``False``, the raw absolute difference (equal to the
83+
predicted volume for that class) is returned. Defaults to ``True``.
84+
"""
85+
86+
def __init__(
87+
self,
88+
include_background: bool = True,
89+
reduction: MetricReduction | str = MetricReduction.MEAN,
90+
get_not_nans: bool = False,
91+
ignore_empty: bool = True,
92+
) -> None:
93+
super().__init__()
94+
self.include_background = include_background
95+
self.reduction = reduction
96+
self.get_not_nans = get_not_nans
97+
self.ignore_empty = ignore_empty
98+
99+
def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor: # type: ignore[override]
100+
"""
101+
Args:
102+
y_pred: binarized prediction tensor, shape BCHW[D].
103+
y: binarized ground-truth tensor, shape BCHW[D].
104+
105+
Raises:
106+
ValueError: when ``y_pred`` has fewer than three dimensions.
107+
"""
108+
if y_pred.ndimension() < 3:
109+
raise ValueError(
110+
f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {y_pred.ndimension()}."
111+
)
112+
return compute_absolute_volume_difference(
113+
y_pred=y_pred, y=y, include_background=self.include_background, ignore_empty=self.ignore_empty
114+
)
115+
116+
def aggregate(
117+
self, reduction: MetricReduction | str | None = None
118+
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
119+
"""
120+
Execute reduction logic for the accumulated AVD values.
121+
122+
Args:
123+
reduction: optional override for the reduction mode set at construction.
124+
"""
125+
data = self.get_buffer()
126+
if not isinstance(data, torch.Tensor):
127+
raise ValueError("the data to aggregate must be a PyTorch Tensor.")
128+
129+
f, not_nans = do_metric_reduction(data, reduction or self.reduction)
130+
return (f, not_nans) if self.get_not_nans else f
131+
132+
133+
def compute_absolute_volume_difference(
134+
y_pred: torch.Tensor, y: torch.Tensor, include_background: bool = True, ignore_empty: bool = True
135+
) -> torch.Tensor:
136+
"""
137+
Compute the Absolute Volume Difference (AVD) for a batch of segmentation predictions.
138+
139+
AVD is defined per class as::
140+
141+
AVD_c = | sum_{spatial}(y_pred_c) - sum_{spatial}(y_c) |
142+
143+
where the sum counts the number of foreground voxels in each channel.
144+
145+
Args:
146+
y_pred: binarized prediction tensor with shape BCHW[D].
147+
y: binarized ground-truth tensor with shape BCHW[D].
148+
include_background: whether to include the first channel (background).
149+
Defaults to ``True``.
150+
ignore_empty: if ``True``, entries where the ground-truth channel contains no
151+
foreground voxels are set to ``NaN`` so they are excluded during reduction.
152+
Defaults to ``True``.
153+
154+
Returns:
155+
AVD per batch item and per class, shape ``[batch_size, num_classes]``.
156+
157+
Raises:
158+
ValueError: when ``y_pred`` and ``y`` have different shapes.
159+
"""
160+
if y_pred.ndim < 3:
161+
raise ValueError(f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {y_pred.ndim}.")
162+
163+
if not include_background:
164+
y_pred, y = ignore_background(y_pred=y_pred, y=y)
165+
166+
if y_pred.shape != y.shape:
167+
raise ValueError(f"y_pred and y should have the same shape, got {y_pred.shape} and {y.shape}.")
168+
169+
# sum over all spatial dimensions; keep batch (dim 0) and channel (dim 1)
170+
reduce_axis = list(range(2, y_pred.ndim))
171+
vol_pred = torch.sum(y_pred, dim=reduce_axis) # [B, C]
172+
vol_true = torch.sum(y, dim=reduce_axis) # [B, C]
173+
174+
avd = torch.abs(vol_pred - vol_true) # [B, C]
175+
176+
if ignore_empty:
177+
# mark cases with no ground-truth foreground as NaN
178+
avd = torch.where(vol_true > 0, avd, torch.tensor(float("nan"), device=avd.device))
179+
180+
return avd
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Copyright (c) MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
from __future__ import annotations
13+
14+
import unittest
15+
16+
import torch
17+
18+
from monai.metrics import AbsoluteVolumeDifferenceMetric, compute_absolute_volume_difference
19+
20+
21+
class TestComputeAbsoluteVolumeDifference(unittest.TestCase):
22+
"""Tests for the standalone compute_absolute_volume_difference function."""
23+
24+
def test_perfect_prediction_returns_zero(self):
25+
"""Identical prediction and ground truth should yield AVD of zero for all classes."""
26+
# identical masks → AVD = 0 for every class
27+
y = torch.zeros(2, 3, 4, 4)
28+
y[:, 1, :2, :2] = 1.0
29+
y[:, 2, 2:, 2:] = 1.0
30+
result = compute_absolute_volume_difference(y_pred=y, y=y, ignore_empty=False)
31+
self.assertEqual(result.shape, torch.Size([2, 3]))
32+
self.assertTrue(torch.all(result == 0.0))
33+
34+
def test_known_volume_difference(self):
35+
"""AVD should equal the absolute difference in foreground voxel counts between prediction and GT."""
36+
# batch=1, 2 classes (background + foreground), 1D spatial of length 10
37+
y_pred = torch.zeros(1, 2, 10)
38+
y_true = torch.zeros(1, 2, 10)
39+
y_pred[0, 1, :7] = 1.0 # 7 foreground voxels predicted
40+
y_true[0, 1, :4] = 1.0 # 4 foreground voxels in GT
41+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, ignore_empty=False)
42+
# channel 0: both all-zeros → AVD = 0
43+
# channel 1: |7 - 4| = 3
44+
self.assertAlmostEqual(result[0, 0].item(), 0.0)
45+
self.assertAlmostEqual(result[0, 1].item(), 3.0)
46+
47+
def test_ignore_background(self):
48+
"""Setting include_background=False should strip the first channel and reduce output shape accordingly."""
49+
y_pred = torch.zeros(2, 3, 8, 8)
50+
y_true = torch.zeros(2, 3, 8, 8)
51+
y_pred[:, 1, :3, :3] = 1.0
52+
y_true[:, 1, :4, :4] = 1.0
53+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, include_background=False)
54+
# background channel stripped → shape [2, 2]
55+
self.assertEqual(result.shape, torch.Size([2, 2]))
56+
57+
def test_ignore_empty_sets_nan(self):
58+
"""Channels with no ground-truth foreground voxels should be NaN when ignore_empty=True."""
59+
# channel 1 has no GT voxels → should be NaN when ignore_empty=True
60+
y_pred = torch.zeros(1, 2, 6)
61+
y_true = torch.zeros(1, 2, 6)
62+
y_pred[0, 0, :3] = 1.0
63+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, ignore_empty=True)
64+
# channel 0: GT is empty → NaN
65+
self.assertTrue(torch.isnan(result[0, 0]))
66+
# channel 1: GT is empty → NaN
67+
self.assertTrue(torch.isnan(result[0, 1]))
68+
69+
def test_ignore_empty_false_returns_pred_volume(self):
70+
"""With ignore_empty=False and empty GT, AVD should equal the predicted volume."""
71+
# when GT is all zero and ignore_empty=False, AVD = |V_pred - 0| = V_pred
72+
y_pred = torch.zeros(1, 2, 6)
73+
y_true = torch.zeros(1, 2, 6)
74+
y_pred[0, 1, :5] = 1.0
75+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, ignore_empty=False)
76+
self.assertAlmostEqual(result[0, 1].item(), 5.0)
77+
78+
def test_shape_mismatch_raises(self):
79+
"""Mismatched y_pred and y shapes should raise a ValueError."""
80+
with self.assertRaises(ValueError):
81+
compute_absolute_volume_difference(y_pred=torch.zeros(2, 3, 8, 8), y=torch.zeros(2, 3, 4, 4))
82+
83+
def test_too_few_dims_raises(self):
84+
"""Input tensors with fewer than 3 dimensions should raise a ValueError."""
85+
with self.assertRaises(ValueError):
86+
compute_absolute_volume_difference(y_pred=torch.zeros(2, 3), y=torch.zeros(2, 3))
87+
88+
def test_3d_volumes(self):
89+
"""AVD should correctly count voxel differences in 3-D spatial inputs."""
90+
# 3-D spatial (D, H, W)
91+
y_pred = torch.zeros(1, 2, 8, 8, 8)
92+
y_true = torch.zeros(1, 2, 8, 8, 8)
93+
y_pred[0, 1, :4, :4, :4] = 1.0 # 64 voxels
94+
y_true[0, 1, :3, :3, :3] = 1.0 # 27 voxels
95+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, ignore_empty=False)
96+
self.assertAlmostEqual(result[0, 1].item(), 37.0)
97+
98+
def test_output_shape_multi_class(self):
99+
"""Output shape should be [batch_size, num_classes] for multi-class inputs."""
100+
y = torch.randint(0, 2, (4, 5, 16, 16)).float()
101+
result = compute_absolute_volume_difference(y_pred=y, y=y, ignore_empty=False)
102+
self.assertEqual(result.shape, torch.Size([4, 5]))
103+
104+
105+
class TestAbsoluteVolumeDifferenceMetric(unittest.TestCase):
106+
"""Tests for the AbsoluteVolumeDifferenceMetric class (cumulative interface)."""
107+
108+
def test_aggregate_mean(self):
109+
"""Mean reduction over accumulated batches should return the correct per-class AVD."""
110+
y_pred = torch.zeros(2, 2, 8, 8)
111+
y_true = torch.zeros(2, 2, 8, 8)
112+
y_pred[:, 1, :6, :6] = 1.0 # 36 voxels per batch item
113+
y_true[:, 1, :4, :4] = 1.0 # 16 voxels per batch item
114+
metric = AbsoluteVolumeDifferenceMetric(include_background=False, reduction="mean", ignore_empty=False)
115+
metric(y_pred, y_true)
116+
agg = metric.aggregate()
117+
# single foreground channel, AVD = 20 for both batch items → mean = 20
118+
self.assertAlmostEqual(agg.item(), 20.0)
119+
metric.reset()
120+
121+
def test_aggregate_returns_not_nans_when_requested(self):
122+
"""When get_not_nans=True, aggregate should return a (metric, not_nans) tuple."""
123+
y_pred = torch.zeros(2, 2, 4, 4)
124+
y_true = torch.zeros(2, 2, 4, 4)
125+
y_pred[:, 1, :2, :2] = 1.0
126+
y_true[:, 1, :2, :2] = 1.0
127+
metric = AbsoluteVolumeDifferenceMetric(include_background=False, get_not_nans=True)
128+
metric(y_pred, y_true)
129+
result, not_nans = metric.aggregate()
130+
self.assertIsInstance(result, torch.Tensor)
131+
self.assertIsInstance(not_nans, torch.Tensor)
132+
metric.reset()
133+
134+
def test_cumulative_accumulation(self):
135+
"""Multiple forward calls before aggregate should use all accumulated data correctly."""
136+
# calling the metric twice and aggregating should use all accumulated data
137+
metric = AbsoluteVolumeDifferenceMetric(include_background=False, reduction="mean", ignore_empty=False)
138+
for _ in range(3):
139+
y_pred = torch.zeros(1, 2, 8)
140+
y_true = torch.zeros(1, 2, 8)
141+
y_pred[0, 1, :6] = 1.0
142+
y_true[0, 1, :4] = 1.0
143+
metric(y_pred, y_true)
144+
agg = metric.aggregate()
145+
self.assertAlmostEqual(agg.item(), 2.0)
146+
metric.reset()
147+
148+
def test_reset_clears_buffer(self):
149+
"""Calling reset() should clear the buffer so a subsequent aggregate() raises."""
150+
metric = AbsoluteVolumeDifferenceMetric(ignore_empty=False)
151+
y = torch.zeros(1, 2, 4)
152+
y[0, 1, :2] = 1.0
153+
metric(y, y)
154+
metric.reset()
155+
# after reset the buffer should be empty; calling aggregate raises
156+
with self.assertRaises(ValueError):
157+
metric.aggregate()
158+
159+
160+
if __name__ == "__main__":
161+
unittest.main()

0 commit comments

Comments
 (0)