Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions monai/metrics/surface_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
"""

Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions tests/metrics/test_surface_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down
Loading