diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index 683b9b1bf4..200740101f 100644 --- a/monai/metrics/hausdorff_distance.py +++ b/monai/metrics/hausdorff_distance.py @@ -50,9 +50,10 @@ 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``. 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. + A value of 0 returns the minimum distance. 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"``, @@ -174,9 +175,10 @@ 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``. 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. + A value of 0 returns the minimum distance. 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, @@ -256,7 +258,7 @@ def _compute_percentile_hausdorff_distance( 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``. + A value of 0 returns the minimum distance. Defaults to ``None``. Returns: A scalar ``float`` tensor. ``nan`` when ``surface_distance`` is empty, meaning @@ -272,10 +274,13 @@ def _compute_percentile_hausdorff_distance( if surface_distance.shape == (0,): return torch.tensor(np.nan, dtype=torch.float, device=surface_distance.device) - if not percentile: + if percentile is None: return surface_distance.max() - if 0 <= percentile <= 100: + if percentile == 0: + return surface_distance.min() + + 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 diff --git a/tests/metrics/test_hausdorff_distance.py b/tests/metrics/test_hausdorff_distance.py index a4320b3ad7..79de503d9f 100644 --- a/tests/metrics/test_hausdorff_distance.py +++ b/tests/metrics/test_hausdorff_distance.py @@ -117,6 +117,25 @@ def create_spherical_seg_3d( ], [19.924858845171276, 20.09975124224178, 14, 18, 22, 33], ], + [ + [ + # percentile=0 is the 0th-percentile (minimum) surface distance, not the max + create_spherical_seg_3d(radius=20, centre=(20, 20, 20)), + create_spherical_seg_3d(radius=20, centre=(19, 19, 19)), + None, + 0, + ], + [0, 0, 0, 0, 0, 0], + ], + [ + [ + create_spherical_seg_3d(radius=15, centre=(20, 33, 22), im_spacing=test_spacing), + create_spherical_seg_3d(radius=30, centre=(20, 33, 22), im_spacing=test_spacing), + test_spacing, + 0, + ], + [5.099999904632568, 5.099999904632568, 6, 6, 6, 6], + ], [ [ create_spherical_seg_3d(radius=20, centre=(20, 20, 20), im_spacing=test_spacing), @@ -173,6 +192,15 @@ def _describe_test_case(test_func, test_number, params): return f"device: {_device} metric: {metric} directed:{directed} expected: {test_output}" +TEST_CASES_PERCENTILE = [ + [[0.0, 3.0], None, 3.0], + [[0.0, 3.0], 0, 0.0], + [[np.inf, np.inf, np.inf], 0, np.inf], + [[1.0, 2.0, 3.0], 50, 2.0], + [[], 0, np.nan], +] + + class TestHausdorffDistance(unittest.TestCase): @parameterized.expand(TEST_CASES_EXPANDED, doc_func=_describe_test_case) @@ -236,6 +264,18 @@ def test_all_infinite_surface_distance(self, percentile): result = _compute_percentile_hausdorff_distance(surface_distance, percentile) self.assertTrue(torch.isinf(result), f"expected inf, got {result}") + @parameterized.expand(TEST_CASES_PERCENTILE) + def test_percentile(self, surface_distances, percentile, expected_value): + surface_distance = torch.tensor(surface_distances, dtype=torch.float) + result = _compute_percentile_hausdorff_distance(surface_distance, percentile) + np.testing.assert_allclose(expected_value, result, rtol=1e-7) + + def test_percentile_out_of_range(self): + for percentile in [-1, 101]: + with self.subTest(percentile=percentile): + with self.assertRaises(ValueError): + _compute_percentile_hausdorff_distance(torch.tensor([1.0, 2.0, 3.0]), percentile) + if __name__ == "__main__": unittest.main()