diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index 1b83c93e5b..7bf0ef4c8d 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, @@ -162,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: @@ -197,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 @@ -208,5 +238,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 20276a1832..a4320b3ad7 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()