From b96dcc1c86e0a32b10dda4dfdf2ca71034871eaf Mon Sep 17 00:00:00 2001 From: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:16:28 -0400 Subject: [PATCH 1/2] Fix per_component Dice silently ignoring ignore_index DiceHelper/DiceMetric's per_component=True path (per-connected- component Dice via compute_cc_dice) never received the spatial mask that create_ignore_mask builds from ignore_index, while the regular per-channel path already applies it. compute_cc_dice has no mask parameter at all, so combining per_component=True with ignore_index silently computed TP/FP/FN over the "ignored" voxels as if nothing were ignored, with no validation or warning. Threads the mask into compute_cc_dice: ignored voxels are forced to agree as background in both y and y_pred before the connected- component assignment and histogram, so they never contribute to a component's score, consistent with how masking works in the non-per_component path. Reachable via the public API: DiceMetric(per_component=True, ignore_index=N)(y_pred, y). No existing test exercised this combination. Signed-off-by: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com> --- monai/metrics/meandice.py | 19 +++++++++++++++++-- tests/metrics/test_compute_meandice.py | 19 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index 5320f0c1b4e..b6e9474213a 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -331,13 +331,19 @@ def __init__( self.per_component = per_component self.ignore_index = ignore_index - def compute_cc_dice(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + def compute_cc_dice( + self, y_pred: torch.Tensor, y: torch.Tensor, mask: torch.Tensor | None = None + ) -> torch.Tensor: """ Compute per-component Dice for a single batch item. Args: y_pred (torch.Tensor): Predictions with shape (1, 2, D, H, W) or (1, 2, H, W). y (torch.Tensor): Ground truth with shape (1, 2, D, H, W) or (1, 2, H, W). + mask (torch.Tensor, optional): spatial mask (1, D, H, W) or (1, H, W) from + ``create_ignore_mask``, 1=valid, 0=ignore. Ignored voxels are forced to + agree as background in both `y` and `y_pred`, so they never contribute to + a component's TP/FP/FN, matching how `ignore_index` is handled elsewhere. Returns: torch.Tensor: Mean Dice over connected components. @@ -348,6 +354,10 @@ def compute_cc_dice(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor else: y_pred_idx = y_pred y_idx = y + if mask is not None: + ignore = mask == 0 + y_idx = y_idx.masked_fill(ignore, 0) + y_pred_idx = y_pred_idx.masked_fill(ignore, 0) if y_idx[0].sum() == 0: if self.ignore_empty: data = torch.tensor(float("nan"), device=y_idx.device) @@ -445,7 +455,12 @@ def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor | tupl data = [] for b in range(y_pred.shape[0]): if self.per_component: - data.append(self.compute_cc_dice(y_pred=y_pred[b].unsqueeze(0), y=y[b].unsqueeze(0)).reshape(-1)) + b_mask = mask[b, 0] if mask is not None else None + data.append( + self.compute_cc_dice( + y_pred=y_pred[b].unsqueeze(0), y=y[b].unsqueeze(0), mask=b_mask + ).reshape(-1) + ) continue c_list = [] for c in range(first_ch, n_pred_ch) if n_pred_ch > 1 else [1]: diff --git a/tests/metrics/test_compute_meandice.py b/tests/metrics/test_compute_meandice.py index 1a000b29b67..795ba0ab761 100644 --- a/tests/metrics/test_compute_meandice.py +++ b/tests/metrics/test_compute_meandice.py @@ -290,6 +290,23 @@ [[0.6400], [1.0], [1.0], [1.0], [1.0]], ] +# Testcase for per_component DiceMetric with ignore_index: a disagreement confined +# entirely to the ignored (sentinel, all-zero one-hot) region must not affect the score. +y = torch.zeros((1, 2, 8, 8)) +y_hat = torch.zeros((1, 2, 8, 8)) +y[0, 1, :, :4] = 1 +y_hat[0, 1, :, :4] = 1 +y_hat[0, 1, :, 4:] = 1 # disagreement, but this region is ignored below +y[0, 0] = 1 - y[0, 1] +y[0, 0, :, 4:] = 0 # sentinel: both channels 0 -> ignored by ignore_index=2 +y_hat[0, 0] = 1 - y_hat[0, 1] + +TEST_CASE_18 = [ + {"per_component": True, "ignore_empty": False, "ignore_index": 2}, + {"y": y, "y_pred": y_hat}, + [[1.0]], +] + class TestComputeMeanDice(unittest.TestCase): @@ -342,7 +359,7 @@ def test_nans_class(self, params, input_data, expected_value): np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) # CC DiceMetric tests - @parameterized.expand([TEST_CASE_16, TEST_CASE_17]) + @parameterized.expand([TEST_CASE_16, TEST_CASE_17, TEST_CASE_18]) @unittest.skipUnless(has_ndimage, "Requires scipy.ndimage.") def test_cc_dice_value_nogpu(self, params, input_data, expected_value): dice_metric = DiceMetric(**params) From bcb96a9e23818eaff7e40f73a3fd8f132a7d8b23 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:17:37 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/metrics/meandice.py | 8 ++------ tests/metrics/test_compute_meandice.py | 6 +----- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index b6e9474213a..089e2924664 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -331,9 +331,7 @@ def __init__( self.per_component = per_component self.ignore_index = ignore_index - def compute_cc_dice( - self, y_pred: torch.Tensor, y: torch.Tensor, mask: torch.Tensor | None = None - ) -> torch.Tensor: + def compute_cc_dice(self, y_pred: torch.Tensor, y: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor: """ Compute per-component Dice for a single batch item. @@ -457,9 +455,7 @@ def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor | tupl if self.per_component: b_mask = mask[b, 0] if mask is not None else None data.append( - self.compute_cc_dice( - y_pred=y_pred[b].unsqueeze(0), y=y[b].unsqueeze(0), mask=b_mask - ).reshape(-1) + self.compute_cc_dice(y_pred=y_pred[b].unsqueeze(0), y=y[b].unsqueeze(0), mask=b_mask).reshape(-1) ) continue c_list = [] diff --git a/tests/metrics/test_compute_meandice.py b/tests/metrics/test_compute_meandice.py index 795ba0ab761..b13d3f2b4b4 100644 --- a/tests/metrics/test_compute_meandice.py +++ b/tests/metrics/test_compute_meandice.py @@ -301,11 +301,7 @@ y[0, 0, :, 4:] = 0 # sentinel: both channels 0 -> ignored by ignore_index=2 y_hat[0, 0] = 1 - y_hat[0, 1] -TEST_CASE_18 = [ - {"per_component": True, "ignore_empty": False, "ignore_index": 2}, - {"y": y, "y_pred": y_hat}, - [[1.0]], -] +TEST_CASE_18 = [{"per_component": True, "ignore_empty": False, "ignore_index": 2}, {"y": y, "y_pred": y_hat}, [[1.0]]] class TestComputeMeanDice(unittest.TestCase):