Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 20 additions & 2 deletions monai/metrics/hausdorff_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ class HausdorffDistanceMetric(CumulativeIterationMetric):
the metric used to compute surface distance. Defaults to ``"euclidean"``.
percentile: an optional float number between 0 and 100. If specified, the corresponding
percentile of the Hausdorff Distance rather than the maximum result will be achieved.
Defaults to ``None``.
Defaults to ``None``. If one of the two masks is empty the distance is ``inf`` for
every percentile, matching the maximum-distance result; ``nan`` is returned only
when both masks are empty, and is excluded from the reduction.
directed: whether to calculate directed Hausdorff distance. Defaults to ``False``.
reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values,
available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
Expand Down Expand Up @@ -153,7 +155,9 @@ def compute_hausdorff_distance(
the metric used to compute surface distance. Defaults to ``"euclidean"``.
percentile: an optional float number between 0 and 100. If specified, the corresponding
percentile of the Hausdorff Distance rather than the maximum result will be achieved.
Defaults to ``None``.
Defaults to ``None``. If one of the two masks is empty the distance is ``inf`` for
every percentile, matching the maximum-distance result; ``nan`` is returned only
when both masks are empty, and is excluded from the reduction.
directed: whether to calculate directed Hausdorff distance. Defaults to ``False``.
spacing: spacing of pixel (or voxel). This parameter is relevant only if ``distance_metric`` is set to ``"euclidean"``.
If a single number, isotropic spacing with that value is used for all images in the batch. If a sequence of numbers,
Expand Down Expand Up @@ -208,5 +212,19 @@ def _compute_percentile_hausdorff_distance(
return surface_distance.max()

if 0 <= percentile <= 100:
# `get_surface_distance` reports an infinite distance for every voxel when one of
# the two masks is empty, so a prediction that missed the structure entirely
# arrives here as an all-infinite tensor. `torch.quantile` interpolates linearly
# between the two order statistics that straddle the requested rank, and that
# interpolation is NaN when both of them are infinite -- inf + (inf - inf) * frac.
#
# NaN is this metric's "not applicable" sentinel: it is what an empty prediction
# *and* an empty ground truth returns above, and `do_metric_reduction` drops it
# from the average. Returning it for a total miss would quietly remove the worst
# cases from a dataset score instead of counting them. The maximum-distance path
# already answers `inf` for exactly this input, and the quantile of a constant
# sequence is that constant, so answer `inf` here too.
if torch.isinf(surface_distance).all():
return torch.tensor(np.inf, dtype=torch.float, device=surface_distance.device)
return torch.quantile(surface_distance, percentile / 100)
raise ValueError(f"percentile should be a value between 0 and 100, get {percentile}.")
32 changes: 32 additions & 0 deletions tests/metrics/test_hausdorff_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from parameterized import parameterized

from monai.metrics import HausdorffDistanceMetric
from monai.metrics.hausdorff_distance import _compute_percentile_hausdorff_distance

_devices = ["cpu"]
if torch.cuda.is_available():
Expand Down Expand Up @@ -153,6 +154,11 @@ def create_spherical_seg_3d(
],
]

# An empty prediction against a non-empty ground truth: every surface distance is
# infinite, and the reported distance must stay infinite whatever percentile is asked
# for. NaN is reserved for the case where there is no structure on either side.
TEST_CASES_EMPTY_PREDICTION = [[None], [0], [50], [95], [99], [100]]

TEST_CASES_EXPANDED = []
for test_case in TEST_CASES:
test_output: list[float | int]
Expand Down Expand Up @@ -204,6 +210,32 @@ def test_nans(self, input_data):
np.testing.assert_allclose(0, result, rtol=1e-7)
np.testing.assert_allclose(0, not_nans, rtol=1e-7)

@parameterized.expand(TEST_CASES_EMPTY_PREDICTION)
def test_empty_prediction_is_infinite(self, percentile):
"""A prediction that misses the structure entirely scores `inf`, not NaN.

NaN is dropped by `do_metric_reduction`, so returning it here would take the
model's worst cases out of a dataset average rather than scoring them.
"""
seg_gt = torch.tensor(create_spherical_seg_3d(radius=20, centre=(20, 20, 20)))
seg_pred = torch.zeros_like(seg_gt)
hd_metric = HausdorffDistanceMetric(include_background=True, percentile=percentile, get_not_nans=True)
hd_metric(seg_pred.unsqueeze(0).unsqueeze(0), seg_gt.unsqueeze(0).unsqueeze(0))
result, not_nans = hd_metric.aggregate()
self.assertTrue(torch.isinf(result).all(), f"expected inf, got {result}")
np.testing.assert_allclose(1, not_nans, rtol=1e-7)

@parameterized.expand(TEST_CASES_EMPTY_PREDICTION)
def test_all_infinite_surface_distance(self, percentile):
"""The quantile of an all-infinite tensor is infinite, at every percentile.

`torch.quantile` interpolates between order statistics and returns NaN when the
two it interpolates between are both infinite.
"""
surface_distance = torch.full((7,), float("inf"))
result = _compute_percentile_hausdorff_distance(surface_distance, percentile)
self.assertTrue(torch.isinf(result), f"expected inf, got {result}")


if __name__ == "__main__":
unittest.main()