Skip to content

Commit 2c54111

Browse files
SID-6921pre-commit-ci[bot]ericspod
authored
Fix per_component Dice silently ignoring ignore_index (#9171)
### Description `DiceHelper`/`DiceMetric`'s `per_component=True` path never received the spatial mask `create_ignore_mask` builds from `ignore_index` — `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. The regular per-channel path already applies this mask correctly; only the per-component path was missing it. Fix 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 any component's score — consistent with how the non-per_component path already handles masking. Reachable via the public API: `DiceMetric(per_component=True, ignore_index=N)(y_pred, y)`. No existing test exercised this combination (checked `tests/metrics/test_compute_meandice.py` — separate `per_component` and `ignore_index` cases, never together). ### Types of changes - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] New tests added to cover the changes - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage` - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests` (ran the targeted test file directly via pytest instead: `pytest tests/metrics/test_compute_meandice.py`, 22/22 passed; did not run the full repo test runner) - [ ] In-line docstrings updated - [ ] Documentation updated --------- Signed-off-by: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com> Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
1 parent e0cee14 commit 2c54111

2 files changed

Lines changed: 30 additions & 3 deletions

File tree

‎monai/metrics/meandice.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,23 +331,34 @@ def __init__(
331331
self.per_component = per_component
332332
self.ignore_index = ignore_index
333333

334-
def compute_cc_dice(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
334+
def compute_cc_dice(self, y_pred: torch.Tensor, y: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
335335
"""
336336
Compute per-component Dice for a single batch item.
337337
338338
Args:
339339
y_pred (torch.Tensor): Predictions with shape (1, 2, D, H, W) or (1, 2, H, W).
340340
y (torch.Tensor): Ground truth with shape (1, 2, D, H, W) or (1, 2, H, W).
341+
mask (torch.Tensor, optional): spatial mask (1, D, H, W) or (1, H, W) from
342+
``create_ignore_mask``, 1=valid, 0=ignore. Ignored voxels are forced to
343+
agree as background in both `y` and `y_pred`, so they never contribute to
344+
a component's TP/FP/FN, matching how `ignore_index` is handled elsewhere.
341345
342346
Returns:
343347
torch.Tensor: Mean Dice over connected components.
348+
Raises:
349+
OptionalImportError: If the ground truth contains foreground voxels
350+
after masking, is on the CPU, and SciPy is unavailable.
344351
"""
345352
if y_pred.ndim == y.ndim:
346353
y_pred_idx = torch.argmax(y_pred, dim=1)
347354
y_idx = torch.argmax(y, dim=1)
348355
else:
349356
y_pred_idx = y_pred
350357
y_idx = y
358+
if mask is not None:
359+
ignore = mask == 0
360+
y_idx = y_idx.masked_fill(ignore, 0)
361+
y_pred_idx = y_pred_idx.masked_fill(ignore, 0)
351362
if y_idx[0].sum() == 0:
352363
if self.ignore_empty:
353364
data = torch.tensor(float("nan"), device=y_idx.device)
@@ -445,7 +456,10 @@ def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor | tupl
445456
data = []
446457
for b in range(y_pred.shape[0]):
447458
if self.per_component:
448-
data.append(self.compute_cc_dice(y_pred=y_pred[b].unsqueeze(0), y=y[b].unsqueeze(0)).reshape(-1))
459+
b_mask = mask[b, 0] if mask is not None else None
460+
data.append(
461+
self.compute_cc_dice(y_pred=y_pred[b].unsqueeze(0), y=y[b].unsqueeze(0), mask=b_mask).reshape(-1)
462+
)
449463
continue
450464
c_list = []
451465
for c in range(first_ch, n_pred_ch) if n_pred_ch > 1 else [1]:

‎tests/metrics/test_compute_meandice.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,19 @@
290290
[[0.6400], [1.0], [1.0], [1.0], [1.0]],
291291
]
292292

293+
# Testcase for per_component DiceMetric with ignore_index: a disagreement confined
294+
# entirely to the ignored (sentinel, all-zero one-hot) region must not affect the score.
295+
y = torch.zeros((1, 2, 8, 8))
296+
y_hat = torch.zeros((1, 2, 8, 8))
297+
y[0, 1, :, :4] = 1
298+
y_hat[0, 1, :, :4] = 1
299+
y_hat[0, 1, :, 4:] = 1 # disagreement, but this region is ignored below
300+
y[0, 0] = 1 - y[0, 1]
301+
y[0, 0, :, 4:] = 0 # sentinel: both channels 0 -> ignored by ignore_index=2
302+
y_hat[0, 0] = 1 - y_hat[0, 1]
303+
304+
TEST_CASE_18 = [{"per_component": True, "ignore_empty": False, "ignore_index": 2}, {"y": y, "y_pred": y_hat}, [[1.0]]]
305+
293306

294307
class TestComputeMeanDice(unittest.TestCase):
295308

@@ -342,7 +355,7 @@ def test_nans_class(self, params, input_data, expected_value):
342355
np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4)
343356

344357
# CC DiceMetric tests
345-
@parameterized.expand([TEST_CASE_16, TEST_CASE_17])
358+
@parameterized.expand([TEST_CASE_16, TEST_CASE_17, TEST_CASE_18])
346359
@unittest.skipUnless(has_ndimage, "Requires scipy.ndimage.")
347360
def test_cc_dice_value_nogpu(self, params, input_data, expected_value):
348361
dice_metric = DiceMetric(**params)

0 commit comments

Comments
 (0)