diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index 3b6d017bac7..683b9b1bf45 100644 --- a/monai/metrics/hausdorff_distance.py +++ b/monai/metrics/hausdorff_distance.py @@ -50,7 +50,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"``, @@ -172,7 +174,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, @@ -184,6 +188,15 @@ def compute_hausdorff_distance( 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. + + 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]``. """ class_offset = 0 @@ -235,7 +248,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 @@ -246,5 +276,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()