From 503e979d7e249dee210a23579bd0c4a3abb3ae48 Mon Sep 17 00:00:00 2001 From: asifuddin01 Date: Fri, 4 Sep 2026 16:23:01 +0600 Subject: [PATCH 1/2] fix(metrics): score a missed prediction in Hausdorff percentile, don't drop it `HausdorffDistanceMetric(percentile=...)` returns `nan` when one of the two masks is empty, where `percentile=None` returns `inf` for the same input. `get_surface_distance` reports an infinite distance for every boundary voxel when a mask is empty, so an all-infinite tensor reaches `_compute_percentile_hausdorff_distance`. `torch.quantile` interpolates linearly between the order statistics straddling the requested rank, and that interpolation is `inf + (inf - inf) * frac`, which is `nan`. The maximum and minimum paths escape it because they do not interpolate. The quantile of a constant sequence is that constant, so the `nan` is an artefact rather than a property of the distances. It also collides with the meaning this metric already gives `nan`: both-masks-empty returns it to say "not applicable", and `do_metric_reduction` excludes it from the average. A prediction that missed the structure entirely is therefore removed from a dataset score rather than counted as the worst case, and the reported HD95 improves as the model finds fewer structures. `get_not_nans=True` reveals the shrinking denominator but is off by default. Return the infinity directly when every distance is infinite, so the percentile path agrees with the maximum path. The guard is exact: `get_surface_distance` returns either all-finite or all-infinite distances, never a mixture, and each direction of the symmetric distance is reduced separately. Adds regression tests over both entry points at `percentile` None, 0, 50, 95, 99 and 100. Without the source change eight fail and four pass, the four being the non-interpolating None and 0 paths. Co-Authored-By: Claude Opus 5 Signed-off-by: asifuddin01 --- monai/metrics/hausdorff_distance.py | 22 ++++++++++++++-- tests/metrics/test_hausdorff_distance.py | 32 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index 1b83c93e5b0..c644c95e7cf 100644 --- a/monai/metrics/hausdorff_distance.py +++ b/monai/metrics/hausdorff_distance.py @@ -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"``, @@ -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, @@ -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}.") diff --git a/tests/metrics/test_hausdorff_distance.py b/tests/metrics/test_hausdorff_distance.py index 20276a18323..a4320b3ad75 100644 --- a/tests/metrics/test_hausdorff_distance.py +++ b/tests/metrics/test_hausdorff_distance.py @@ -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(): @@ -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] @@ -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() From 9b168f4eff7fdf37a8c3814fb8d33501c6248d99 Mon Sep 17 00:00:00 2001 From: asifuddin01 Date: Fri, 4 Sep 2026 16:57:06 +0600 Subject: [PATCH 2/2] docs(metrics): document the Hausdorff nan/inf contract Adds the Google-style `Returns` and `Raises` sections the repository's path instructions ask for, on `compute_hausdorff_distance` and on `_compute_percentile_hausdorff_distance`, which had a one-line docstring and no `Args`. The sections state the part this change turns on: `nan` when neither mask has a foreground, so there is nothing to measure and the reduction excludes it, and `inf` when exactly one mask is empty, at the maximum and at every percentile alike. That distinction was previously only inferable from the code. Co-Authored-By: Claude Opus 5 Signed-off-by: asifuddin01 --- monai/metrics/hausdorff_distance.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index c644c95e7cf..7bf0ef4c8d1 100644 --- a/monai/metrics/hausdorff_distance.py +++ b/monai/metrics/hausdorff_distance.py @@ -166,6 +166,15 @@ def compute_hausdorff_distance( If inner sequence has length 1, isotropic spacing with that value is used for all images in the batch, else the inner sequence length must be equal to the image dimensions. If ``None``, spacing of unity is used for all images in batch. Defaults to ``None``. + + Returns: + A ``float`` tensor of shape ``[batch_size, n_class]`` holding one distance per + image and class. An entry is ``inf`` where exactly one of the two masks is empty, + and ``nan`` where both are, which ``do_metric_reduction`` then excludes. + + Raises: + ValueError: if ``y_pred`` and ``y`` have different shapes, or if ``percentile`` + is outside ``[0, 100]``. """ if not include_background: @@ -201,7 +210,24 @@ def _compute_percentile_hausdorff_distance( surface_distance: torch.Tensor, percentile: float | None = None ) -> torch.Tensor: """ - This function is used to compute the Hausdorff distance. + Reduce a tensor of surface distances to a single Hausdorff distance. + + Args: + surface_distance: the surface distances for one image and class, as returned by + ``get_surface_distance``. Empty when neither mask has a foreground, and + entirely infinite when exactly one of them does. + percentile: an optional float between 0 and 100. If given, the corresponding + percentile of ``surface_distance`` is returned rather than its maximum. + Defaults to ``None``. + + Returns: + A scalar ``float`` tensor. ``nan`` when ``surface_distance`` is empty, meaning + there was no structure on either side to measure; ``inf`` when every distance is + infinite, meaning one mask was empty, at the maximum and at every percentile + alike. + + Raises: + ValueError: if ``percentile`` is outside ``[0, 100]``. """ # for both pred and gt do not have foreground