Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4219980
test: reference Hausdorff helper returns inf for unmatched instance i…
yuriyzubov Jul 2, 2026
100d87d
test: expect inf (not max_distance) when a truth instance has no matc…
yuriyzubov Jul 2, 2026
20af5db
test: expect inf (not max_distance) when roi_slices_for_pair returns …
yuriyzubov Jul 2, 2026
eaae1dc
test: unmatched predictions get an infinite Hausdorff penalty, not ma…
yuriyzubov Jul 2, 2026
39f377e
test: assert isinf on unmatched-prediction distances (remainder of pr…
yuriyzubov Jul 2, 2026
b0b1d0d
test: add normalize_distance(inf) == 0 invariant test
yuriyzubov Jul 2, 2026
ba35f9e
test: assert hallucinated FPs with no GT contribute 0 to hausdorff_no…
yuriyzubov Jul 2, 2026
3f03731
test: assert missed GT instances (FN) contribute 0 to hausdorff_norm_sum
yuriyzubov Jul 2, 2026
1a45b1b
fix: return inf from compute_hausdorff_distance_roi for unmatched ins…
yuriyzubov Jul 2, 2026
0c2aa8f
fix: penalize unmatched predictions with inf instead of max_distance
yuriyzubov Jul 2, 2026
99c0714
test: normalize_distance inf check passes explicit max_distance
yuriyzubov Jul 2, 2026
b0c28c1
test: add normalize_distance worst-real-case-hits-floor test
yuriyzubov Jul 2, 2026
6c29a78
test: add normalize_distance floor-consistent-across-crops test
yuriyzubov Jul 2, 2026
49a6356
test: add normalize_distance custom-floor test
yuriyzubov Jul 2, 2026
3b3d347
test: redesign simple_shift test so max_distance cap doesn't bind
yuriyzubov Jul 2, 2026
01c0fdc
fix: normalize_distance rescales worst real match to a fixed floor
yuriyzubov Jul 2, 2026
c286ead
fix: score_instance passes max_distance to normalize_distance
yuriyzubov Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions src/cellmap_segmentation_challenge/utils/eval_utils/distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -235,7 +240,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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
)

Expand Down Expand Up @@ -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}")
Expand Down
64 changes: 53 additions & 11 deletions tests/test_evaluate_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand All @@ -394,6 +394,43 @@ 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), 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), max_distance=10.0)
assert np.isclose(out[0], 1.0)
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
)


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)


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
# ------------------------
Expand All @@ -416,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)


Expand All @@ -445,8 +485,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():
Expand All @@ -462,8 +503,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():
Expand Down
4 changes: 2 additions & 2 deletions tests/test_evaluate_refactored.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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))


# ============================================================================
Expand Down
Loading