Skip to content

Commit 74ee065

Browse files
committed
Add AbsoluteVolumeDifferenceMetric to monai.metrics
Implements the Absolute Volume Difference (AVD) metric as requested in #4009. AVD measures the absolute difference in foreground voxel counts between prediction and ground truth per class, and is particularly suited for small-object segmentation tasks (e.g. retinal fluid in OCT volumes) where Dice score is overly sensitive to volume size. Changes: - monai/metrics/absolute_volume_difference.py: new AbsoluteVolumeDifferenceMetric class (CumulativeIterationMetric) and compute_absolute_volume_difference() standalone function. Supports include_background, reduction, get_not_nans, and ignore_empty. References the RETOUCH benchmark paper. - monai/metrics/__init__.py: export both new symbols. - tests/metrics/test_absolute_volume_difference.py: 14 unit tests covering perfect prediction, known volume difference, ignore_empty behaviour, include_background stripping, 3D volumes, cumulative accumulation, and error cases. This PR was authored with the assistance of an AI coding assistant. Signed-off-by: MDSALMANSHAMS <salmanshams67@gmail.com>
1 parent 03338b2 commit 74ee065

3 files changed

Lines changed: 343 additions & 0 deletions

File tree

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: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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+
Reference:
35+
Bogunovic et al. (2019). RETOUCH: The Retinal OCT Fluid Detection and
36+
Segmentation Benchmark and Challenge.
37+
IEEE Transactions on Medical Imaging, 38(8), 1858-1874.
38+
https://ieeexplore.ieee.org/document/8653407
39+
40+
The inputs ``y_pred`` and ``y`` are expected to be binarized one-hot tensors with
41+
shape BCHW[D]. If they contain continuous values (e.g. sigmoid outputs), binarize
42+
them first with a suitable threshold transform.
43+
44+
The typical execution steps of this metric class follow
45+
:py:class:`monai.metrics.metric.Cumulative`.
46+
47+
Example:
48+
49+
.. code-block:: python
50+
51+
import torch
52+
from monai.metrics import AbsoluteVolumeDifferenceMetric
53+
54+
batch_size, n_classes = 4, 3
55+
y_pred = torch.randint(0, 2, (batch_size, n_classes, 64, 64, 32)).float()
56+
y = torch.randint(0, 2, (batch_size, n_classes, 64, 64, 32)).float()
57+
58+
metric = AbsoluteVolumeDifferenceMetric(include_background=False)
59+
metric(y_pred, y) # accumulate
60+
result = metric.aggregate() # shape: (n_classes - 1,) after mean reduction
61+
metric.reset()
62+
63+
Args:
64+
include_background: whether to include AVD computation on the first channel
65+
(index 0), which is by convention assumed to be background. Defaults to
66+
``True``. Set to ``False`` when the background class dominates and you only
67+
care about foreground classes (e.g. fluid sub-types in OCT).
68+
reduction: defines how to aggregate per-batch-per-class results. Available
69+
modes are enumerated in :py:class:`monai.utils.enums.MetricReduction`.
70+
Defaults to ``"mean"``.
71+
get_not_nans: if ``True``, :meth:`aggregate` returns ``(metric, not_nans)``
72+
where ``not_nans`` counts the number of valid (non-NaN) values.
73+
Defaults to ``False``.
74+
ignore_empty: if ``True``, cases where the ground-truth channel is entirely
75+
empty (zero voxels) are excluded from aggregation by setting their value
76+
to ``NaN``. If ``False``, the raw absolute difference (equal to the
77+
predicted volume for that class) is returned. Defaults to ``True``.
78+
"""
79+
80+
def __init__(
81+
self,
82+
include_background: bool = True,
83+
reduction: MetricReduction | str = MetricReduction.MEAN,
84+
get_not_nans: bool = False,
85+
ignore_empty: bool = True,
86+
) -> None:
87+
super().__init__()
88+
self.include_background = include_background
89+
self.reduction = reduction
90+
self.get_not_nans = get_not_nans
91+
self.ignore_empty = ignore_empty
92+
93+
def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor: # type: ignore[override]
94+
"""
95+
Args:
96+
y_pred: binarized prediction tensor, shape BCHW[D].
97+
y: binarized ground-truth tensor, shape BCHW[D].
98+
99+
Raises:
100+
ValueError: when ``y_pred`` has fewer than three dimensions.
101+
"""
102+
if y_pred.ndimension() < 3:
103+
raise ValueError(
104+
f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {y_pred.ndimension()}."
105+
)
106+
return compute_absolute_volume_difference(
107+
y_pred=y_pred,
108+
y=y,
109+
include_background=self.include_background,
110+
ignore_empty=self.ignore_empty,
111+
)
112+
113+
def aggregate(
114+
self, reduction: MetricReduction | str | None = None
115+
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
116+
"""
117+
Execute reduction logic for the accumulated AVD values.
118+
119+
Args:
120+
reduction: optional override for the reduction mode set at construction.
121+
"""
122+
data = self.get_buffer()
123+
if not isinstance(data, torch.Tensor):
124+
raise ValueError("the data to aggregate must be a PyTorch Tensor.")
125+
126+
f, not_nans = do_metric_reduction(data, reduction or self.reduction)
127+
return (f, not_nans) if self.get_not_nans else f
128+
129+
130+
def compute_absolute_volume_difference(
131+
y_pred: torch.Tensor,
132+
y: torch.Tensor,
133+
include_background: bool = True,
134+
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(
162+
f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {y_pred.ndim}."
163+
)
164+
165+
if not include_background:
166+
y_pred, y = ignore_background(y_pred=y_pred, y=y)
167+
168+
if y_pred.shape != y.shape:
169+
raise ValueError(f"y_pred and y should have the same shape, got {y_pred.shape} and {y.shape}.")
170+
171+
# sum over all spatial dimensions; keep batch (dim 0) and channel (dim 1)
172+
reduce_axis = list(range(2, y_pred.ndim))
173+
vol_pred = torch.sum(y_pred, dim=reduce_axis) # [B, C]
174+
vol_true = torch.sum(y, dim=reduce_axis) # [B, C]
175+
176+
avd = torch.abs(vol_pred - vol_true) # [B, C]
177+
178+
if ignore_empty:
179+
# mark cases with no ground-truth foreground as NaN
180+
avd = torch.where(vol_true > 0, avd, torch.tensor(float("nan"), device=avd.device))
181+
182+
return avd
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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 masks → AVD = 0 for every class
26+
y = torch.zeros(2, 3, 4, 4)
27+
y[:, 1, :2, :2] = 1.0
28+
y[:, 2, 2:, 2:] = 1.0
29+
result = compute_absolute_volume_difference(y_pred=y, y=y, ignore_empty=False)
30+
self.assertEqual(result.shape, torch.Size([2, 3]))
31+
self.assertTrue(torch.all(result == 0.0))
32+
33+
def test_known_volume_difference(self):
34+
# batch=1, 2 classes (background + foreground), 1D spatial of length 10
35+
y_pred = torch.zeros(1, 2, 10)
36+
y_true = torch.zeros(1, 2, 10)
37+
y_pred[0, 1, :7] = 1.0 # 7 foreground voxels predicted
38+
y_true[0, 1, :4] = 1.0 # 4 foreground voxels in GT
39+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, ignore_empty=False)
40+
# channel 0: both all-zeros → AVD = 0
41+
# channel 1: |7 - 4| = 3
42+
self.assertAlmostEqual(result[0, 0].item(), 0.0)
43+
self.assertAlmostEqual(result[0, 1].item(), 3.0)
44+
45+
def test_ignore_background(self):
46+
y_pred = torch.zeros(2, 3, 8, 8)
47+
y_true = torch.zeros(2, 3, 8, 8)
48+
y_pred[:, 1, :3, :3] = 1.0
49+
y_true[:, 1, :4, :4] = 1.0
50+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, include_background=False)
51+
# background channel stripped → shape [2, 2]
52+
self.assertEqual(result.shape, torch.Size([2, 2]))
53+
54+
def test_ignore_empty_sets_nan(self):
55+
# channel 1 has no GT voxels → should be NaN when ignore_empty=True
56+
y_pred = torch.zeros(1, 2, 6)
57+
y_true = torch.zeros(1, 2, 6)
58+
y_pred[0, 0, :3] = 1.0
59+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, ignore_empty=True)
60+
# channel 0: GT is empty → NaN
61+
self.assertTrue(torch.isnan(result[0, 0]))
62+
# channel 1: GT is empty → NaN
63+
self.assertTrue(torch.isnan(result[0, 1]))
64+
65+
def test_ignore_empty_false_returns_pred_volume(self):
66+
# when GT is all zero and ignore_empty=False, AVD = |V_pred - 0| = V_pred
67+
y_pred = torch.zeros(1, 2, 6)
68+
y_true = torch.zeros(1, 2, 6)
69+
y_pred[0, 1, :5] = 1.0
70+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, ignore_empty=False)
71+
self.assertAlmostEqual(result[0, 1].item(), 5.0)
72+
73+
def test_shape_mismatch_raises(self):
74+
with self.assertRaises(ValueError):
75+
compute_absolute_volume_difference(
76+
y_pred=torch.zeros(2, 3, 8, 8),
77+
y=torch.zeros(2, 3, 4, 4),
78+
)
79+
80+
def test_too_few_dims_raises(self):
81+
with self.assertRaises(ValueError):
82+
compute_absolute_volume_difference(
83+
y_pred=torch.zeros(2, 3),
84+
y=torch.zeros(2, 3),
85+
)
86+
87+
def test_3d_volumes(self):
88+
# 3-D spatial (D, H, W)
89+
y_pred = torch.zeros(1, 2, 8, 8, 8)
90+
y_true = torch.zeros(1, 2, 8, 8, 8)
91+
y_pred[0, 1, :4, :4, :4] = 1.0 # 64 voxels
92+
y_true[0, 1, :3, :3, :3] = 1.0 # 27 voxels
93+
result = compute_absolute_volume_difference(y_pred=y_pred, y=y_true, ignore_empty=False)
94+
self.assertAlmostEqual(result[0, 1].item(), 37.0)
95+
96+
def test_output_shape_multi_class(self):
97+
y = torch.randint(0, 2, (4, 5, 16, 16)).float()
98+
result = compute_absolute_volume_difference(y_pred=y, y=y, ignore_empty=False)
99+
self.assertEqual(result.shape, torch.Size([4, 5]))
100+
101+
102+
class TestAbsoluteVolumeDifferenceMetric(unittest.TestCase):
103+
"""Tests for the AbsoluteVolumeDifferenceMetric class (cumulative interface)."""
104+
105+
def test_aggregate_mean(self):
106+
y_pred = torch.zeros(2, 2, 8, 8)
107+
y_true = torch.zeros(2, 2, 8, 8)
108+
y_pred[:, 1, :6, :6] = 1.0 # 36 voxels per batch item
109+
y_true[:, 1, :4, :4] = 1.0 # 16 voxels per batch item
110+
metric = AbsoluteVolumeDifferenceMetric(include_background=False, reduction="mean", ignore_empty=False)
111+
metric(y_pred, y_true)
112+
agg = metric.aggregate()
113+
# single foreground channel, AVD = 20 for both batch items → mean = 20
114+
self.assertAlmostEqual(agg.item(), 20.0)
115+
metric.reset()
116+
117+
def test_aggregate_returns_not_nans_when_requested(self):
118+
y_pred = torch.zeros(2, 2, 4, 4)
119+
y_true = torch.zeros(2, 2, 4, 4)
120+
y_pred[:, 1, :2, :2] = 1.0
121+
y_true[:, 1, :2, :2] = 1.0
122+
metric = AbsoluteVolumeDifferenceMetric(include_background=False, get_not_nans=True)
123+
metric(y_pred, y_true)
124+
result, not_nans = metric.aggregate()
125+
self.assertIsInstance(result, torch.Tensor)
126+
self.assertIsInstance(not_nans, torch.Tensor)
127+
metric.reset()
128+
129+
def test_cumulative_accumulation(self):
130+
# calling the metric twice and aggregating should use all accumulated data
131+
metric = AbsoluteVolumeDifferenceMetric(include_background=False, reduction="mean", ignore_empty=False)
132+
for _ in range(3):
133+
y_pred = torch.zeros(1, 2, 8)
134+
y_true = torch.zeros(1, 2, 8)
135+
y_pred[0, 1, :6] = 1.0
136+
y_true[0, 1, :4] = 1.0
137+
metric(y_pred, y_true)
138+
agg = metric.aggregate()
139+
self.assertAlmostEqual(agg.item(), 2.0)
140+
metric.reset()
141+
142+
def test_reset_clears_buffer(self):
143+
metric = AbsoluteVolumeDifferenceMetric(ignore_empty=False)
144+
y = torch.zeros(1, 2, 4)
145+
y[0, 1, :2] = 1.0
146+
metric(y, y)
147+
metric.reset()
148+
# after reset the buffer should be empty; calling aggregate raises
149+
with self.assertRaises(Exception):
150+
metric.aggregate()
151+
152+
def test_imported_from_top_level(self):
153+
# ensure the class is accessible from monai.metrics top-level
154+
from monai.metrics import AbsoluteVolumeDifferenceMetric as _AVD
155+
156+
self.assertIs(_AVD, AbsoluteVolumeDifferenceMetric)
157+
158+
159+
if __name__ == "__main__":
160+
unittest.main()

0 commit comments

Comments
 (0)