diff --git a/monai/metrics/surface_distance.py b/monai/metrics/surface_distance.py index 6b7fc9609d..7899d1e702 100644 --- a/monai/metrics/surface_distance.py +++ b/monai/metrics/surface_distance.py @@ -69,7 +69,8 @@ class SurfaceDistanceMetric(CumulativeIterationMetric): ignore_index: single integer class index (or sentinel value) to ignore from the metric computation. Voxels with this label are excluded from the score, which is useful for padding, unlabeled regions, or boundary artifacts. For federated or aggregated settings, ensure all clients use the same - ignore_index to keep score values comparable. + ignore_index to keep score values comparable. For multi-channel (one-hot) input, when + ``ignore_index`` names an existing channel that channel's per-image result is ``nan``. per_component: whether to compute the Surface Distance on a per-connected component basis. Defaults to ``False``. """ @@ -203,10 +204,20 @@ def compute_average_surface_distance( for all images in batch. Defaults to ``None``. ignore_index: optional class index used to suppress empty-mask warnings when that class is ignored. For federated or aggregated settings, ensure all clients use the same ignore_index to keep score - values comparable. + values comparable. For multi-channel (one-hot) input, when ``ignore_index`` names an existing + channel that channel's per-image result is ``nan``. per_component: whether to compute the Surface Distance on a per-connected component basis. Defaults to ``False``. """ + # ponytail: capture the channel count before any background-stripping. n_class below + # is recomputed post-strip and, for include_background=False, undercounts how many + # channels the caller actually passed in (e.g. a true 2-channel one-hot input becomes + # n_class==1 after dropping the background channel) -- using the post-strip count to + # decide whether ignore_index names a real one-hot channel wrongly treated a genuinely + # multi-channel ignore_index as the single-channel/label-encoded case, letting the + # ignored channel's (masked-to-empty) ground truth leak `inf` into the aggregate again. + orig_n_channels = y_pred.shape[1] + if not include_background: y_pred, y = ignore_background(y_pred=y_pred, y=y) @@ -225,6 +236,18 @@ def compute_average_surface_distance( class_offset = 0 if include_background else 1 for b, c in np.ndindex(batch_size, n_class): + absolute_c = c + class_offset + if ignore_index is not None and absolute_c == ignore_index and orig_n_channels > 1: + # ponytail: mirror compute_hausdorff_distance's early-exit for the ignored + # channel instead of letting get_edge_surface_distance run on a ground truth + # that create_ignore_mask has zeroed out (which produced `inf`, not `nan`, + # and was silently included by do_metric_reduction). Only for multi-channel + # (one-hot) input does `ignore_index` name a specific channel to drop entirely; + # for single-channel (label-encoded) input it is a value-based spatial mask + # already applied via create_ignore_mask, and the lone channel is still the + # score being requested, so it must not be forced to nan. + asd[b, c] = torch.tensor(float("nan"), device=asd.device) + continue yp = y_pred[b, c] yt = y[b, c] if per_component: @@ -267,7 +290,6 @@ def compute_average_surface_distance( ) asd[b, c] = torch.nanmean(torch.stack(component_scores)) if component_scores else 0.0 else: - absolute_c = c + class_offset warn_empty = ignore_index is None or absolute_c != ignore_index _, distances, _ = get_edge_surface_distance( yp, diff --git a/tests/metrics/test_surface_distance.py b/tests/metrics/test_surface_distance.py index ff6edb2577..b5a7e3ace7 100644 --- a/tests/metrics/test_surface_distance.py +++ b/tests/metrics/test_surface_distance.py @@ -235,6 +235,71 @@ def test_cc_metrics(self, input_data, expected_value): result = sd_metric.aggregate(reduction="none") np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) + def test_ignore_index_excludes_channel_like_hausdorff(self): + # Regression test: SurfaceDistanceMetric used to leave the ignored channel as `inf` + # (included by do_metric_reduction, poisoning the aggregate) instead of `nan` like + # HausdorffDistanceMetric, because compute_average_surface_distance was missing the + # early-exit that compute_hausdorff_distance has for `absolute_c == ignore_index`. + y = torch.zeros((2, 3, 16, 16)) + y_pred = torch.zeros((2, 3, 16, 16)) + y[:, 1, 4:8, 4:8] = 1 + y[:, 2, 8:12, 8:12] = 1 + y[:, 0] = 1 - y[:, 1:].sum(dim=1) + y_pred[:, 1, 5:9, 5:9] = 1 + y_pred[:, 2, 8:12, 8:12] = 1 + y_pred[:, 0] = 1 - y_pred[:, 1:].sum(dim=1) + + sd_metric = SurfaceDistanceMetric(include_background=True, ignore_index=1, reduction="mean") + sd_metric(y_pred, y) + + sd_buffer = sd_metric.get_buffer() + self.assertTrue(torch.isnan(sd_buffer[:, 1]).all(), f"ignored channel should be nan, got {sd_buffer[:, 1]}") + sd_result = sd_metric.aggregate() + self.assertTrue(torch.isfinite(sd_result).all(), f"aggregate should not be inf/nan, got {sd_result}") + + def test_ignore_index_single_channel_does_not_blank_the_only_score(self): + # Regression test: for single-channel (label-encoded) binary input, `ignore_index` + # is a value-based spatial mask (handled by create_ignore_mask before this loop + # runs), not a request to drop an entire one-hot channel. With include_background= + # True and ignore_index=0, `absolute_c == ignore_index` was true for the only + # channel (c=0), so the unconditional early-exit forced that channel to nan for + # every image, which do_metric_reduction's all-nan convention then silently + # reduces to 0 -- reporting a perfect score regardless of the actual prediction. + y = torch.zeros((2, 1, 16, 16)) + y_pred = torch.zeros((2, 1, 16, 16)) + y[:, 0, 4:8, 4:8] = 1 + y_pred[:, 0, 5:9, 5:9] = 1 # off by one voxel, partially overlapping the ground truth + + sd_metric = SurfaceDistanceMetric(include_background=True, ignore_index=0, reduction="mean") + sd_metric(y_pred, y) + result = sd_metric.aggregate() + self.assertFalse(torch.isnan(result).any(), f"expected a real distance, got {result}") + self.assertFalse(torch.isinf(result).any(), f"expected a finite distance, got {result}") + self.assertGreater(result.item(), 0.0, "an imperfect prediction must not score as perfect (0)") + + def test_ignore_index_two_channel_exclude_background_still_excludes_channel(self): + # Regression test: n_class is computed AFTER ignore_background strips the + # background channel (for include_background=False), so a genuinely two-channel + # one-hot input (background + foreground) collapses to n_class == 1 post-strip. + # Guarding the early-exit on that post-strip count wrongly treated this as the + # single-channel/label-encoded case and skipped it, letting the ignored + # (masked-to-empty) foreground channel's ground truth leak `inf` into the + # aggregate again whenever the prediction had a false positive outside the true + # foreground region. The guard must use the channel count from BEFORE stripping. + y = torch.zeros((2, 2, 16, 16)) + y_pred = torch.zeros((2, 2, 16, 16)) + y[:, 1, 4:8, 4:8] = 1 + y[:, 0] = 1 - y[:, 1] + y_pred[:, 1, 10:14, 10:14] = 1 # false positive entirely outside the true foreground + y_pred[:, 0] = 1 - y_pred[:, 1] + + sd_metric = SurfaceDistanceMetric(include_background=False, ignore_index=1, reduction="mean") + sd_metric(y_pred, y) + sd_buffer = sd_metric.get_buffer() + self.assertTrue(torch.isnan(sd_buffer).all(), f"ignored channel should be nan, got {sd_buffer}") + result = sd_metric.aggregate() + self.assertTrue(torch.isfinite(result).all(), f"aggregate should not be inf, got {result}") + def test_channel_dimensions(self): with self.assertRaises(ValueError): SurfaceDistanceMetric(per_component=True)(torch.ones([3, 3, 144, 144]), torch.ones([3, 3, 144, 144]))