From 08db939efc547dcdec0a1bfed8a2e8d76cfb99c2 Mon Sep 17 00:00:00 2001 From: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:47:45 -0400 Subject: [PATCH 1/2] Fix MeanIoU ignore_index to exclude voxels, not just a channel compute_dice() masks out every voxel belonging to the ignored class via create_ignore_mask(), so those voxels are excluded from all class scores. compute_iou() instead zeroed the ignored channel, which leaves voxels of the ignored class counting as false positives against the other classes. Both docstrings promise the same thing, so the two metrics disagreed on identical input. Use create_ignore_mask() in compute_iou() as well. Signed-off-by: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com> --- monai/metrics/meaniou.py | 14 ++++---------- tests/metrics/test_ignore_index_metrics.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/monai/metrics/meaniou.py b/monai/metrics/meaniou.py index 0ff4f131f87..f9be38b38ae 100644 --- a/monai/metrics/meaniou.py +++ b/monai/metrics/meaniou.py @@ -152,16 +152,10 @@ def compute_iou( if y.shape != y_pred.shape: raise ValueError(f"y_pred and y should have same shapes, got {y_pred.shape} and {y.shape}.") - if ignore_index is not None and 0 <= ignore_index < (y_pred.shape[1] + (0 if include_background else 1)): - ignore_channel = ignore_index if include_background else ignore_index - 1 - if 0 <= ignore_channel < y_pred.shape[1]: - y_pred = y_pred.clone() - y = y.clone() - y_pred[:, ignore_channel] = 0 - y[:, ignore_channel] = 0 - mask = None - else: - mask = create_ignore_mask(original_y if ignore_index is not None else y, ignore_index) + # Use the same spatial masking as DiceHelper so both metrics exclude the + # same voxels: zeroing the ignored channel alone would leave voxels of the + # ignored class counting as false positives for the other classes + mask = create_ignore_mask(original_y, ignore_index) if mask is not None: if mask.shape != y_pred.shape: mask = mask.expand_as(y_pred) diff --git a/tests/metrics/test_ignore_index_metrics.py b/tests/metrics/test_ignore_index_metrics.py index af5ffccee78..ca9fd06fd14 100644 --- a/tests/metrics/test_ignore_index_metrics.py +++ b/tests/metrics/test_ignore_index_metrics.py @@ -24,6 +24,8 @@ MeanIoU, SurfaceDiceMetric, SurfaceDistanceMetric, + compute_dice, + compute_iou, ) from monai.utils import optional_import @@ -142,6 +144,20 @@ def test_metric_ignore_class_index_without_background(self, metric_class, kwargs torch.testing.assert_close(res1, res2, msg=f"Failed for {metric_class.__name__}") + def test_ignored_voxels_excluded_from_other_classes(self): + """Ignored voxels must be dropped from every class score, not just their own.""" + # 4 voxels, 3 one-hot classes; voxel 1 belongs to the ignored class 1 + y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) + # a perfect prediction except the ignored voxel is called class 0 + y_pred = torch.tensor([[[1.0, 1.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) + + iou = compute_iou(y_pred, y, include_background=True, ignore_index=1) + dice = compute_dice(y_pred, y, include_background=True, ignore_index=1) + + # the mislabelled voxel is ignored, so class 0 is scored as perfect + self.assertEqual(iou[0, 0].item(), 1.0) + torch.testing.assert_close(iou, dice, equal_nan=True) + @unittest.skipUnless(has_scipy, "Scipy required for surface metrics") class TestIgnoreIndexSurfaceMetrics(unittest.TestCase): From be204542683e8da895c012893a043a49c71bf12e Mon Sep 17 00:00:00 2001 From: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:31:33 -0400 Subject: [PATCH 2/2] Add ignore_index test with include_background=False Pins the interaction kesonglab flagged in review on #9134: the ignore_index mask is built from the original (pre-background-strip) one-hot array, so it must stay correctly aligned with ignore_background's channel removal. Reproduces the pre-fix bug (iou 0.5 instead of 1.0 for the surviving class) if the alignment regresses. Signed-off-by: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com> --- tests/metrics/test_ignore_index_metrics.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/metrics/test_ignore_index_metrics.py b/tests/metrics/test_ignore_index_metrics.py index ca9fd06fd14..5353ab6618a 100644 --- a/tests/metrics/test_ignore_index_metrics.py +++ b/tests/metrics/test_ignore_index_metrics.py @@ -158,6 +158,28 @@ def test_ignored_voxels_excluded_from_other_classes(self): self.assertEqual(iou[0, 0].item(), 1.0) torch.testing.assert_close(iou, dice, equal_nan=True) + def test_ignored_voxels_excluded_with_include_background_false(self): + """The ignore_index mask must line up with the ignore_background channel strip.""" + # 4 one-hot classes: 0=background, 1, 2=ignored, 3 + y = torch.zeros(1, 4, 4) + y[0, 0, 0] = 1 # voxel 0 -> background + y[0, 2, 1] = 1 # voxel 1 -> ignored class + y[0, 1, 2] = 1 # voxel 2 -> class 1 + y[0, 3, 3] = 1 # voxel 3 -> class 3 + + y_pred = y.clone() + # mislabel the ignored voxel as class 1 instead of leaving it unpredicted + y_pred[0, 2, 1] = 0 + y_pred[0, 1, 1] = 1 + + iou = compute_iou(y_pred, y, include_background=False, ignore_index=2) + dice = compute_dice(y_pred, y, include_background=False, ignore_index=2) + + # class 1's false positive at the ignored voxel must be dropped, not just + # its own (now background-stripped) channel + self.assertEqual(iou[0, 0].item(), 1.0) + torch.testing.assert_close(iou, dice, equal_nan=True) + @unittest.skipUnless(has_scipy, "Scipy required for surface metrics") class TestIgnoreIndexSurfaceMetrics(unittest.TestCase):