From 4219980252dcd8be24d0704b0c0f35ab95fbb6e0 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:25:41 -0400 Subject: [PATCH 01/17] test: reference Hausdorff helper returns inf for unmatched instance instead of max_distance --- tests/test_evaluate_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index ee529d8..842f204 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -74,7 +74,7 @@ def _hausdorff_full_reference_labels( if a_n == 0 and b_n == 0: return 0.0 if a_n == 0 or b_n == 0: - return float(max_distance) + return float("inf") ndim = truth_label.ndim vs = np.asarray(voxel_size, dtype=np.float64) From 100d87da30bf62656695e5cdc418659c17e61981 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:26:11 -0400 Subject: [PATCH 02/17] test: expect inf (not max_distance) when a truth instance has no matched prediction --- tests/test_evaluate_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index 842f204..35a1669 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -259,7 +259,7 @@ def test_roi_hausdorff_empty_sets_and_missing_instance(): voxel_size=voxel_size, max_distance=max_distance, ) - assert np.isclose(d1, max_distance) + assert np.isinf(d1) def test_roi_hausdorff_clips_to_max_distance_matches_reference(): From 20af5dbd0f9275a7c13a59a1f6d039f8b265678a Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:26:19 -0400 Subject: [PATCH 03/17] test: expect inf (not max_distance) when roi_slices_for_pair returns None --- tests/test_evaluate_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index 35a1669..779b3e4 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -373,7 +373,7 @@ def _fake_roi(*args, **kwargs): voxel_size=(1.0, 1.0), max_distance=max_distance, ) - assert np.isclose(d, max_distance) + assert np.isinf(d) def test_optimized_hausdorff_distances_per_instance(): From eaae1dc8326b0bf0e9e4ab108ba7fec71b58d70d Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:26:26 -0400 Subject: [PATCH 04/17] test: unmatched predictions get an infinite Hausdorff penalty, not max_distance --- tests/test_evaluate_refactored.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_evaluate_refactored.py b/tests/test_evaluate_refactored.py index 217d04b..70bdf11 100644 --- a/tests/test_evaluate_refactored.py +++ b/tests/test_evaluate_refactored.py @@ -278,7 +278,7 @@ def test_compute_hausdorff_scores_empty(self): assert len(distances) == 0 def test_compute_hausdorff_scores_unmatched_predictions(self): - """Hallucinations (unmatched predictions) each get the max penalty.""" + """Hallucinations (unmatched predictions) each get an infinite penalty.""" distances = _compute_hausdorff_scores( {}, np.zeros((10, 10)), From 39f377e44834c91cd43bffa4bd7653c7ba14da32 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:26:44 -0400 Subject: [PATCH 05/17] test: assert isinf on unmatched-prediction distances (remainder of prior commit) --- tests/test_evaluate_refactored.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_evaluate_refactored.py b/tests/test_evaluate_refactored.py index 70bdf11..04a7982 100644 --- a/tests/test_evaluate_refactored.py +++ b/tests/test_evaluate_refactored.py @@ -289,7 +289,7 @@ def test_compute_hausdorff_scores_unmatched_predictions(self): ) assert len(distances) == 5 - assert np.allclose(distances, 100.0) + assert np.all(np.isinf(distances)) # ============================================================================ From b0b1d0de0b1e2bcb34a6b36b8840339c126569dd Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:27:02 -0400 Subject: [PATCH 06/17] test: add normalize_distance(inf) == 0 invariant test --- tests/test_evaluate_metrics.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index 779b3e4..d3ef8e6 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -394,6 +394,17 @@ def test_optimized_hausdorff_distances_per_instance(): assert np.allclose(dists, 0.0) +def test_normalize_distance_inf_is_zero_regardless_of_voxel_size(): + from cellmap_segmentation_challenge import evaluate as ev + + assert ev.normalize_distance(np.inf, (1.0, 1.0)) == 0.0 + assert ev.normalize_distance(np.inf, (32.0, 32.0, 32.0)) == 0.0 + + out = ev.normalize_distance(np.array([0.0, np.inf]), (2.0, 2.0)) + assert np.isclose(out[0], 1.0) + assert out[1] == 0.0 + + # ------------------------ # score_instance tests # ------------------------ From ba35f9eab0e2499f09ac5cab023d3aaa749db0a8 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:27:08 -0400 Subject: [PATCH 07/17] test: assert hallucinated FPs with no GT contribute 0 to hausdorff_norm_sum --- tests/test_evaluate_metrics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index d3ef8e6..4c807f7 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -456,8 +456,9 @@ def test_score_instance_f1_all_fp(): assert scores["tp"] == 0 assert scores["fn"] == 0 assert scores["fp"] == 2 - # 2 hallucinated predictions, each a max-distance penalty + # 2 hallucinated predictions, no counterpart to measure -> 0 contribution assert scores["n_hausdorff"] == 2 + assert scores["hausdorff_norm_sum"] == 0.0 def test_score_instance_f1_all_fn(): From 3f03731f6ead5410ab6cdb7b8c9374186af8d380 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:27:15 -0400 Subject: [PATCH 08/17] test: assert missed GT instances (FN) contribute 0 to hausdorff_norm_sum --- tests/test_evaluate_metrics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index 4c807f7..7cda972 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -474,8 +474,9 @@ def test_score_instance_f1_all_fn(): assert scores["tp"] == 0 assert scores["fp"] == 0 assert scores["fn"] == 2 - # 2 missed truth instances, each a max-distance penalty + # 2 missed truth instances, no counterpart to measure -> 0 contribution assert scores["n_hausdorff"] == 2 + assert scores["hausdorff_norm_sum"] == 0.0 def test_score_instance_empty_vs_empty_contributes_nothing(): From 1a45b1b84a9c4efe0421a250eb743769ce303452 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:29:02 -0400 Subject: [PATCH 09/17] fix: return inf from compute_hausdorff_distance_roi for unmatched instances --- src/cellmap_segmentation_challenge/utils/eval_utils/distance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cellmap_segmentation_challenge/utils/eval_utils/distance.py b/src/cellmap_segmentation_challenge/utils/eval_utils/distance.py index 2ac7b4a..a1758b7 100644 --- a/src/cellmap_segmentation_challenge/utils/eval_utils/distance.py +++ b/src/cellmap_segmentation_challenge/utils/eval_utils/distance.py @@ -235,7 +235,7 @@ def compute_hausdorff_distance_roi( if a_n == 0 and b_n == 0: return 0.0 elif a_n == 0 or b_n == 0: - return max_distance + return float("inf") vs = np.asarray(voxel_size, dtype=np.float64) if vs.size != ndim: From 0c2aa8f365b1c91fb3d42a0b97fad803aad129c9 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 16:29:08 -0400 Subject: [PATCH 10/17] fix: penalize unmatched predictions with inf instead of max_distance --- .../utils/eval_utils/scoring.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py b/src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py index 448e5fa..5f5a67b 100644 --- a/src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py +++ b/src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py @@ -84,13 +84,13 @@ def _compute_hausdorff_scores( truth_label, pred_label, voxel_size, hausdorff_distance_max, truth_ids=truth_ids ) - # Max-distance penalty per unmatched prediction (hallucination). + # Infinite-distance penalty per unmatched prediction (hallucination). n_unmatched_pred = n_pred - len(set(mapping.keys()) - {0}) if n_unmatched_pred > 0: hausdorff_distances = np.concatenate( [ hausdorff_distances, - np.full(n_unmatched_pred, hausdorff_distance_max, dtype=np.float32), + np.full(n_unmatched_pred, np.inf, dtype=np.float32), ] ) From 99c0714778f4943ee86f2f6eccaabd6aa3d6013b Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 17:12:55 -0400 Subject: [PATCH 11/17] test: normalize_distance inf check passes explicit max_distance --- tests/test_evaluate_metrics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index 7cda972..b0296cd 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -397,10 +397,10 @@ def test_optimized_hausdorff_distances_per_instance(): def test_normalize_distance_inf_is_zero_regardless_of_voxel_size(): from cellmap_segmentation_challenge import evaluate as ev - assert ev.normalize_distance(np.inf, (1.0, 1.0)) == 0.0 - assert ev.normalize_distance(np.inf, (32.0, 32.0, 32.0)) == 0.0 + assert ev.normalize_distance(np.inf, (1.0, 1.0), max_distance=10.0) == 0.0 + assert ev.normalize_distance(np.inf, (32.0, 32.0, 32.0), max_distance=1000.0) == 0.0 - out = ev.normalize_distance(np.array([0.0, np.inf]), (2.0, 2.0)) + out = ev.normalize_distance(np.array([0.0, np.inf]), (2.0, 2.0), max_distance=10.0) assert np.isclose(out[0], 1.0) assert out[1] == 0.0 From b0c28c15790529921ada79f3cda8f439b7367b37 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 17:14:26 -0400 Subject: [PATCH 12/17] test: add normalize_distance worst-real-case-hits-floor test --- tests/test_evaluate_metrics.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index b0296cd..45a6686 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -405,6 +405,15 @@ def test_normalize_distance_inf_is_zero_regardless_of_voxel_size(): assert out[1] == 0.0 +def test_normalize_distance_worst_real_case_hits_floor(): + from cellmap_segmentation_challenge import evaluate as ev + + for voxel_size, max_distance in [((1.0, 1.0), 10.0), ((32.0, 32.0, 32.0), 1500.0)]: + assert np.isclose( + ev.normalize_distance(max_distance, voxel_size, max_distance), 0.1 + ) + + # ------------------------ # score_instance tests # ------------------------ From 6c29a782504a629073afb1029b5303122d305614 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 17:14:45 -0400 Subject: [PATCH 13/17] test: add normalize_distance floor-consistent-across-crops test --- tests/test_evaluate_metrics.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index 45a6686..5186d05 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -414,6 +414,15 @@ def test_normalize_distance_worst_real_case_hits_floor(): ) +def test_normalize_distance_floor_is_consistent_across_crops(): + from cellmap_segmentation_challenge import evaluate as ev + + small_crop = ev.normalize_distance(1120.0, (16.0, 16.0, 16.0), max_distance=1120.0) + big_crop = ev.normalize_distance(16000.0, (32.0, 32.0, 32.0), max_distance=16000.0) + assert np.isclose(small_crop, big_crop) + assert np.isclose(small_crop, 0.1) + + # ------------------------ # score_instance tests # ------------------------ From 49a63563244ea03540b291b585388b2dcd8523c0 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 17:15:04 -0400 Subject: [PATCH 14/17] test: add normalize_distance custom-floor test --- tests/test_evaluate_metrics.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index 5186d05..fda2558 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -423,6 +423,14 @@ def test_normalize_distance_floor_is_consistent_across_crops(): assert np.isclose(small_crop, 0.1) +def test_normalize_distance_custom_floor(): + from cellmap_segmentation_challenge import evaluate as ev + + assert np.isclose( + ev.normalize_distance(50.0, (1.0, 1.0), max_distance=50.0, floor=0.2), 0.2 + ) + + # ------------------------ # score_instance tests # ------------------------ From 3b3d34767f94b554fbcf5d1876c4f72e5fa43fce Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 17:15:13 -0400 Subject: [PATCH 15/17] test: redesign simple_shift test so max_distance cap doesn't bind --- tests/test_evaluate_metrics.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_evaluate_metrics.py b/tests/test_evaluate_metrics.py index fda2558..c838507 100644 --- a/tests/test_evaluate_metrics.py +++ b/tests/test_evaluate_metrics.py @@ -453,19 +453,22 @@ def test_score_instance_perfect_match(): def test_score_instance_simple_shift(): from cellmap_segmentation_challenge import evaluate as ev - # GT has one instance [0,0] and [0,1] - truth = np.array([[1, 1, 0], [0, 0, 0]], dtype=np.int32) - # Prediction shifted one voxel to the right: [0,1],[0,2] (and needs renumbering) - pred = np.array([[0, 2, 2], [0, 0, 0]], dtype=np.int32) + # GT has a 2-voxel instance, prediction shifted one voxel to the right + # (so they still overlap by one voxel and can match). Crop is large + # relative to the shift so the max_distance cap doesn't bind. + truth = np.zeros((10, 10), dtype=np.int32) + truth[5, 4:6] = 1 + pred = np.zeros((10, 10), dtype=np.int32) + pred[5, 5:7] = 1 voxel_size = (1.0, 1.0) scores = ev.score_instance(pred, truth, voxel_size) assert scores["tp"] == 1 assert scores["fp"] == 0 assert scores["fn"] == 0 - # one matched instance, Hausdorff distance 1 -> normalized 1.01**(-1/||vs||) assert scores["n_hausdorff"] == 1 - expected = 1.01 ** (-1.0 / np.linalg.norm(voxel_size)) + max_distance = ev.compute_max_distance(voxel_size, truth.shape) + expected = ev.normalize_distance(1.0, voxel_size, max_distance) assert np.isclose(scores["hausdorff_norm_sum"], expected) From 01c0fdc11a115f3a4fc4fd4a7dff694a44adf1db Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 17:15:22 -0400 Subject: [PATCH 16/17] fix: normalize_distance rescales worst real match to a fixed floor --- .../utils/eval_utils/distance.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/cellmap_segmentation_challenge/utils/eval_utils/distance.py b/src/cellmap_segmentation_challenge/utils/eval_utils/distance.py index a1758b7..5bde91f 100644 --- a/src/cellmap_segmentation_challenge/utils/eval_utils/distance.py +++ b/src/cellmap_segmentation_challenge/utils/eval_utils/distance.py @@ -21,22 +21,27 @@ def compute_max_distance(voxel_size, shape) -> float: return float(min([(v * s) / 2 for v, s in zip(voxel_size, shape)])) -def normalize_distance(distance, voxel_size): - """Normalize a distance to ``(0, 1]`` via ``1.01 ** (-distance / ||voxel_size||)``. - - A distance of 0 maps to 1 (perfect) and inf maps to 0. Accepts a scalar or - an array, returning a float for scalar input and an array otherwise. +def normalize_distance(distance, voxel_size, max_distance, floor=0.1): + """Normalize a distance to ``(0, 1]``, rescaled so the worst real (capped) + distance maps to ``floor`` and 0 maps to 1. Infinite distance always maps + to 0. Accepts a scalar or an array, returning a float for scalar input and + an array otherwise. Args: distance: Distance value(s), scalar or array. voxel_size: Physical voxel size. + max_distance: Crop-specific cap used to bound a real matched distance. + floor: Score assigned to a real distance at the max_distance cap. Returns: - Normalized score(s) in ``(0, 1]``. + Normalized score(s) in ``(0, 1]``, or 0 for infinite distance. """ - # TODO: Normalize by max possible distance in the volume d = np.asarray(distance, dtype=np.float64) - out = np.where(np.isinf(d), 0.0, 1.01 ** (-d / np.linalg.norm(voxel_size))) + norm = np.linalg.norm(voxel_size) + raw = 1.01 ** (-d / norm) + worst_real = 1.01 ** (-max_distance / norm) + rescaled = floor + (raw - worst_real) / (1.0 - worst_real) * (1.0 - floor) + out = np.where(np.isinf(d), 0.0, rescaled) return float(out) if out.ndim == 0 else out From c286eadc8ec1f6c8b7b060ad59922254d1b876a6 Mon Sep 17 00:00:00 2001 From: yuriyzubov Date: Thu, 2 Jul 2026 17:15:27 -0400 Subject: [PATCH 17/17] fix: score_instance passes max_distance to normalize_distance --- src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py b/src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py index 5f5a67b..d872360 100644 --- a/src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py +++ b/src/cellmap_segmentation_challenge/utils/eval_utils/scoring.py @@ -177,7 +177,7 @@ def score_instance( fn = gt_count - tp # Normalize per-instance distances; emit sum/count for per-class pooling. - norm = normalize_distance(hausdorff_distances, voxel_size) + norm = normalize_distance(hausdorff_distances, voxel_size, hausdorff_distance_max) hausdorff_norm_sum = float(np.sum(norm)) n_hausdorff = int(np.size(norm)) logging.debug(f"TP={tp}, FP={fp}, FN={fn}, n_hausdorff={n_hausdorff}")