Skip to content

Commit 0b7dbb4

Browse files
alxndrkalininclaude
andcommitted
feat(dynacell/eval): whole-cell + nucleus instance average-precision
Add instance mAP (cubic average_precision, pred vs GT) to the eval pipeline for two targets, gated by compute_instance_ap: - nucleus (backend=cellpose): each side segments its own nucleus channel into instance labels independently. - whole-cell (backend=cellpose_watershed, membrane): shared GT-nuclei Cellpose seeds -> membrane EDT watershed (segment_whole_cell, a cubic port of the .tmp/wholecell cellgrid prototype) -> cytoplasm-only labels. Both share one AP wrapper (instance_metrics), a uint16 instance-label cache (cache.py write/read_instance_mask + pipeline_cache _fov_instances with manifest auto-invalidation + a whole-cell seed preflight), the merged-column metric output (AP_<th>/mAP/n_gt/n_pred/instance_*@0.50 in mask_metrics), and the _process_one_fov instance branch. 2D (default, single slice at frac/sharpest) and 3D (full-volume do_3D) are supported. A bidirectional guard validates the backend/target/toggle combination, the cached-final gate requires AP columns when compute_instance_ap is set, and the instance toggles are grouped-run invariants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 85e1af4 commit 0b7dbb4

12 files changed

Lines changed: 1413 additions & 31 deletions

applications/dynacell/src/dynacell/evaluation/_configs/eval.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,44 @@ defaults:
1818
benchmark: null
1919

2020
target_name: ???
21+
22+
# Nucleus/membrane organelle-mask segmenter. `supermodel` = segmenter-model-zoo
23+
# SuperModel (default, unchanged behavior). `cellpose` = GPU Cellpose-SAM + fnet
24+
# pre/post (nucleus only). `cellpose_watershed` = whole-cell (membrane only):
25+
# GPU Cellpose-SAM nuclei seeds + membrane EDT watershed. The cellpose /
26+
# cellpose_watershed backends with compute_instance_ap=true add instance mAP
27+
# (cubic average_precision) to mask_metrics; labels cache under
28+
# instance_masks/<target>__<backend>.zarr.
29+
segmentation:
30+
backend: supermodel # supermodel | cellpose (nucleus) | cellpose_watershed (whole-cell)
31+
dimension: 2d # 2d | 3d — instance backends only (3d = full-volume do_3D)
32+
slice_selection: frac # frac | sharpest — picks the 2d plane (dimension=2d)
33+
slice_fraction: 0.30 # fractional Z depth when slice_selection=frac
34+
nuclei_channel_name: null # GT-plate channel for watershed seeds (cellpose_watershed only)
35+
# Nucleus Cellpose-SAM params (the cellpose path + the cellpose_watershed seeds).
36+
cellpose:
37+
target_voxel_um: 0.58 # isotropic grid the nuclei are segmented on (always)
38+
cellprob_threshold: 0.0
39+
flow_threshold: 0.4
40+
min_obj_size: 30 # drop nuclei < N px/vox at target_voxel_um; 2D default — raise (~500) for dimension=3d
41+
# Whole-cell watershed stage (cellpose_watershed only). Physical-um params,
42+
# converted to working-grid pixels per cell_voxel_um (so they are grid-independent).
43+
watershed:
44+
cell_voxel_um: 0.3 # isotropic cell-stage grid (null = native resolution)
45+
close_um: 2.5 # grayscale-closing disk radius
46+
wall_sigma_um: 0.35 # gaussian sigma before wall thresholding
47+
wall_min_um: 1.0 # drop membrane-wall specks (um^ndim)
48+
hole_um: 3.0 # fill cell-mask holes (um^ndim)
49+
min_cell_um: 15.0 # drop cells below this (2D um^2 default; set ~50 um^3 for dimension=3d)
50+
memb_clahe: true # CLAHE the membrane channel
51+
subtract_nuclei: true # carve nuclei out -> cytoplasmic-shape-only metrics
52+
53+
# Whole-cell / nucleus instance average-precision. Requires an instance backend:
54+
# (cellpose_watershed, membrane) or (cellpose, nucleus).
55+
compute_instance_ap: false
56+
instance_metrics:
57+
iou_thresholds: [0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95]
58+
2159
io:
2260
pred_path: ???
2361
gt_path: ???
@@ -110,6 +148,8 @@ force_recompute:
110148
pred_dinov3: false
111149
pred_dynaclr: false
112150
pred_celldino: false
151+
gt_instances: false
152+
pred_instances: false
113153
final_metrics: false
114154

115155
save:

applications/dynacell/src/dynacell/evaluation/cache.py

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,28 @@ class CachePaths:
4545
manifest: Path
4646
masks_dir: Path
4747
features_dir: Path
48+
instance_masks_dir: Path
4849

49-
def mask_plate(self, target_name: str) -> Path:
50-
"""Return the HCS OME-Zarr plate for masks of *target_name*."""
51-
return self.masks_dir / f"{target_name}.zarr"
50+
def mask_plate(self, target_name: str, backend: str = "supermodel") -> Path:
51+
"""Return the HCS OME-Zarr plate for masks of *target_name*.
52+
53+
Non-default segmentation backends get a ``__{backend}`` filename infix so
54+
masks from different segmenters never collide in one cache dir; the
55+
default ``supermodel`` keeps the bare ``{target_name}.zarr`` path so
56+
pre-existing caches are unaffected.
57+
"""
58+
stem = target_name if backend == "supermodel" else f"{target_name}__{backend}"
59+
return self.masks_dir / f"{stem}.zarr"
60+
61+
def instance_mask_plate(self, target_name: str, backend: str) -> Path:
62+
"""Return the HCS OME-Zarr plate for instance (uint16) labels.
63+
64+
Instance-label caches always carry a ``__{backend}`` infix
65+
(``nucleus__cellpose.zarr`` / ``membrane__cellpose_watershed.zarr``) and
66+
live under a separate directory from the binary ``organelle_masks`` so
67+
the two never collide.
68+
"""
69+
return self.instance_masks_dir / f"{target_name}__{backend}.zarr"
5270

5371
def cp_features(self) -> Path:
5472
"""Return the zarr group path for CP regionprops features."""
@@ -75,6 +93,7 @@ def cache_paths(cache_dir: Path | str) -> CachePaths:
7593
manifest=root / "manifest.yaml",
7694
masks_dir=root / "organelle_masks",
7795
features_dir=root / "features",
96+
instance_masks_dir=root / "instance_masks",
7897
)
7998

8099

@@ -242,7 +261,7 @@ def built_at_now() -> str:
242261
return datetime.now(timezone.utc).isoformat(timespec="seconds")
243262

244263

245-
def read_mask(paths: CachePaths, target_name: str, pos_name: str) -> np.ndarray | None:
264+
def read_mask(paths: CachePaths, target_name: str, pos_name: str, backend: str = "supermodel") -> np.ndarray | None:
246265
"""Read cached organelle masks for a single position.
247266
248267
Returns
@@ -251,7 +270,7 @@ def read_mask(paths: CachePaths, target_name: str, pos_name: str) -> np.ndarray
251270
Bool array of shape ``(T, D, H, W)``, or ``None`` if the plate or
252271
position is absent.
253272
"""
254-
plate_path = paths.mask_plate(target_name)
273+
plate_path = paths.mask_plate(target_name, backend)
255274
if not plate_path.exists():
256275
return None
257276
with open_ome_zarr(plate_path, mode="r") as plate:
@@ -297,6 +316,7 @@ def write_mask(
297316
masks: np.ndarray,
298317
*,
299318
channel_name: str = _MASK_CHANNEL,
319+
backend: str = "supermodel",
300320
) -> None:
301321
"""Append masks for a single position to the ``{target_name}.zarr`` plate.
302322
@@ -315,7 +335,7 @@ def write_mask(
315335
"""
316336
if masks.ndim != 4:
317337
raise ValueError(f"masks must be 4-D (T, D, H, W); got shape {masks.shape}")
318-
plate_path = paths.mask_plate(target_name)
338+
plate_path = paths.mask_plate(target_name, backend)
319339
plate_path.parent.mkdir(parents=True, exist_ok=True)
320340
data = masks.astype(bool)[:, None] # (T, 1, D, H, W)
321341
if plate_path.exists() and _is_position_malformed(plate_path, pos_name):
@@ -341,6 +361,86 @@ def write_mask(
341361
position.create_image("0", data)
342362

343363

364+
_INSTANCE_MASK_CHANNEL = "instance_seg"
365+
366+
367+
def read_instance_mask(paths: CachePaths, target_name: str, pos_name: str, backend: str) -> np.ndarray | None:
368+
"""Read cached instance labels for a single position.
369+
370+
Returns
371+
-------
372+
numpy.ndarray | None
373+
uint16 array of shape ``(T, D, H, W)`` (2-D runs are stored with
374+
``D=1``), or ``None`` if the plate or position is absent.
375+
"""
376+
plate_path = paths.instance_mask_plate(target_name, backend)
377+
if not plate_path.exists():
378+
return None
379+
with open_ome_zarr(plate_path, mode="r") as plate:
380+
try:
381+
position = plate[pos_name]
382+
except KeyError:
383+
return None
384+
data = np.asarray(position.data[:, 0]).astype(np.uint16)
385+
return data
386+
387+
388+
def write_instance_mask(
389+
paths: CachePaths,
390+
target_name: str,
391+
pos_name: str,
392+
labels: np.ndarray,
393+
*,
394+
channel_name: str = _INSTANCE_MASK_CHANNEL,
395+
backend: str,
396+
) -> None:
397+
"""Append uint16 instance labels for a single position to the instance plate.
398+
399+
Mirrors :func:`write_mask` but preserves integer labels (no bool coercion).
400+
401+
Parameters
402+
----------
403+
paths
404+
Cache paths.
405+
target_name
406+
Organelle name (mask plate filename stem, with the ``__{backend}`` infix).
407+
pos_name
408+
HCS position name in ``row/col/fov`` form.
409+
labels
410+
uint16 array of shape ``(T, D, H, W)`` (2-D runs use ``D=1``).
411+
channel_name
412+
OME-Zarr channel label to write for this instance plate.
413+
backend
414+
Segmentation backend (selects the plate filename infix).
415+
"""
416+
if labels.ndim != 4:
417+
raise ValueError(f"labels must be 4-D (T, D, H, W); got shape {labels.shape}")
418+
plate_path = paths.instance_mask_plate(target_name, backend)
419+
plate_path.parent.mkdir(parents=True, exist_ok=True)
420+
data = labels.astype(np.uint16)[:, None] # (T, 1, D, H, W)
421+
if plate_path.exists() and _is_position_malformed(plate_path, pos_name):
422+
_rewrite_inner_array(plate_path / pos_name, data)
423+
return
424+
mode = "r+" if plate_path.exists() else "w"
425+
with open_ome_zarr(
426+
plate_path,
427+
mode=mode,
428+
layout="hcs",
429+
channel_names=[channel_name],
430+
version="0.5",
431+
) as plate:
432+
row, col, fov = pos_name.split("/")
433+
try:
434+
position = plate[pos_name]
435+
except KeyError:
436+
position = plate.create_position(row, col, fov)
437+
try:
438+
del position["0"]
439+
except KeyError:
440+
pass
441+
position.create_image("0", data)
442+
443+
344444
def _features_group_path(
345445
paths: CachePaths,
346446
kind: FeatureKind,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Instance-segmentation average-precision metrics (Cellpose-style).
2+
3+
Thin numpy wrapper over :func:`cubic.metrics.average_precision` (TP / (TP + FP +
4+
FN) per IoU threshold, the Cellpose definition). Used for both the nucleus and
5+
whole-cell instance-AP eval paths. The returned dict merges directly into the
6+
per-(FOV, t) mask-metric rows, so every per-threshold AP becomes its own
7+
``mask_metrics.csv`` column / ``.npy`` key.
8+
"""
9+
10+
import numpy as np
11+
from cubic.metrics import average_precision
12+
13+
DEFAULT_IOU_THRESHOLDS = (0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95)
14+
"""IoU thresholds for the AP sweep (Cellpose / StarDist standard 0.50..0.95)."""
15+
16+
_PRIMARY_THRESHOLD = 0.50
17+
"""IoU threshold whose TP/FP/FN counts are persisted (the standard 0.50 operating
18+
point); stored under ``instance_{TP,FP,FN}@0.50`` so the threshold is explicit."""
19+
20+
21+
def _relabel_sequential(labels: np.ndarray) -> np.ndarray:
22+
"""Relabel an integer label image to a dense ``0, 1..K`` (background stays 0).
23+
24+
``cubic.average_precision`` derives object counts from ``labels.max()``, so
25+
labels must be gap-free for FP/FN to be correct. Uses ``np.unique`` with
26+
``return_inverse`` (not connected-component relabeling — disjoint pieces that
27+
share an id stay one object).
28+
"""
29+
labels = np.asarray(labels)
30+
uniq, inv = np.unique(labels, return_inverse=True)
31+
inv = inv.reshape(labels.shape)
32+
# uniq is sorted ascending; if a 0 background exists it maps to 0, real ids to
33+
# 1..K. If no 0 is present (no background), shift so ids become 1..K.
34+
return inv if uniq[0] == 0 else inv + 1
35+
36+
37+
def instance_average_precision(
38+
labels_pred: np.ndarray,
39+
labels_gt: np.ndarray,
40+
iou_thresholds=DEFAULT_IOU_THRESHOLDS,
41+
) -> dict:
42+
"""Average precision of predicted vs ground-truth instance labels.
43+
44+
Parameters
45+
----------
46+
labels_pred, labels_gt : numpy.ndarray
47+
Predicted and ground-truth integer instance-label images (same shape).
48+
iou_thresholds : sequence of float
49+
IoU thresholds for the AP sweep.
50+
51+
Returns
52+
-------
53+
dict
54+
``AP_<th>`` (one per threshold), ``mAP`` (their mean), ``n_gt``,
55+
``n_pred``, and ``instance_{TP,FP,FN}@0.50``. Both sides empty → all-NaN
56+
AP/mAP; exactly one side empty → AP/mAP 0.0.
57+
"""
58+
thresholds = [float(t) for t in iou_thresholds]
59+
pred = _relabel_sequential(labels_pred)
60+
gt = _relabel_sequential(labels_gt)
61+
n_pred = int(pred.max())
62+
n_gt = int(gt.max())
63+
64+
# cubic.average_precision returns 0 (not NaN) on empty inputs, so pre-check the
65+
# degenerate cases here and call it only when both sides have objects.
66+
if n_gt == 0 and n_pred == 0:
67+
ap_vals = [float("nan")] * len(thresholds)
68+
tp = fp = fn = float("nan")
69+
elif n_gt == 0 or n_pred == 0:
70+
ap_vals = [0.0] * len(thresholds)
71+
tp, fp, fn = 0.0, float(n_pred), float(n_gt)
72+
else:
73+
ap, tp_arr, fp_arr, fn_arr = average_precision(gt, pred, thresholds)
74+
ap_vals = [float(a) for a in np.atleast_1d(ap)]
75+
idx = thresholds.index(_PRIMARY_THRESHOLD) if _PRIMARY_THRESHOLD in thresholds else 0
76+
tp = float(np.atleast_1d(tp_arr)[idx])
77+
fp = float(np.atleast_1d(fp_arr)[idx])
78+
fn = float(np.atleast_1d(fn_arr)[idx])
79+
80+
result = {"n_gt": n_gt, "n_pred": n_pred}
81+
for th, a in zip(thresholds, ap_vals):
82+
result[f"AP_{th:.2f}"] = a
83+
result["mAP"] = float(np.mean(ap_vals))
84+
result[f"instance_TP@{_PRIMARY_THRESHOLD:.2f}"] = tp
85+
result[f"instance_FP@{_PRIMARY_THRESHOLD:.2f}"] = fp
86+
result[f"instance_FN@{_PRIMARY_THRESHOLD:.2f}"] = fn
87+
return result
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Tests for the instance average-precision wrapper (real cubic, CPU)."""
2+
3+
import numpy as np
4+
5+
from dynacell.evaluation.instance_metrics import (
6+
DEFAULT_IOU_THRESHOLDS,
7+
_relabel_sequential,
8+
instance_average_precision,
9+
)
10+
11+
12+
def _two_squares(shape=(16, 16)) -> np.ndarray:
13+
"""A label image with two well-separated square instances (ids 1, 2)."""
14+
lab = np.zeros(shape, dtype=np.uint16)
15+
lab[2:6, 2:6] = 1
16+
lab[10:14, 10:14] = 2
17+
return lab
18+
19+
20+
def test_identical_labels_map_one():
21+
"""Identical label images score mAP 1.0 and AP 1.0 at every threshold."""
22+
gt = _two_squares()
23+
result = instance_average_precision(gt.copy(), gt)
24+
assert result["mAP"] == 1.0
25+
assert result["n_gt"] == 2 and result["n_pred"] == 2
26+
for th in DEFAULT_IOU_THRESHOLDS:
27+
assert result[f"AP_{th:.2f}"] == 1.0
28+
assert result["instance_TP@0.50"] == 2.0
29+
assert result["instance_FP@0.50"] == 0.0
30+
assert result["instance_FN@0.50"] == 0.0
31+
32+
33+
def test_disjoint_labels_map_zero():
34+
"""Predicting objects nowhere near the GT scores mAP 0.0."""
35+
gt = _two_squares()
36+
pred = np.zeros_like(gt)
37+
pred[2:6, 10:14] = 1 # overlaps neither GT square
38+
result = instance_average_precision(pred, gt)
39+
assert result["mAP"] == 0.0
40+
41+
42+
def test_one_side_empty_is_zero():
43+
"""One empty side → AP 0.0 (not NaN); FP/FN reflect the non-empty side."""
44+
gt = _two_squares()
45+
empty = np.zeros_like(gt)
46+
miss = instance_average_precision(empty, gt)
47+
assert miss["mAP"] == 0.0
48+
assert miss["instance_FN@0.50"] == 2.0
49+
assert miss["instance_FP@0.50"] == 0.0
50+
extra = instance_average_precision(gt.copy(), empty)
51+
assert extra["mAP"] == 0.0
52+
assert extra["instance_FP@0.50"] == 2.0
53+
assert extra["instance_FN@0.50"] == 0.0
54+
55+
56+
def test_both_empty_is_nan():
57+
"""Both sides empty → AP undefined (NaN), not a spurious 0.0."""
58+
empty = np.zeros((16, 16), dtype=np.uint16)
59+
result = instance_average_precision(empty, empty)
60+
assert np.isnan(result["mAP"])
61+
assert result["n_gt"] == 0 and result["n_pred"] == 0
62+
for th in DEFAULT_IOU_THRESHOLDS:
63+
assert np.isnan(result[f"AP_{th:.2f}"])
64+
65+
66+
def test_arg_swap_moves_fp_fn_not_map():
67+
"""Swapping pred/gt leaves mAP unchanged but swaps FP and FN."""
68+
gt = _two_squares()
69+
pred = gt.copy()
70+
pred[10:14, 10:14] = 0 # pred has only 1 of the 2 objects
71+
a = instance_average_precision(pred, gt) # 1 pred, 2 gt -> 1 FN
72+
b = instance_average_precision(gt, pred) # 2 pred, 1 gt -> 1 FP
73+
assert a["mAP"] == b["mAP"]
74+
assert a["instance_FN@0.50"] == b["instance_FP@0.50"] == 1.0
75+
assert a["instance_FP@0.50"] == b["instance_FN@0.50"] == 0.0
76+
77+
78+
def test_gapped_relabel_no_merge():
79+
"""``_relabel_sequential`` densifies ids without merging disjoint objects."""
80+
lab = np.zeros((8, 8), dtype=np.uint16)
81+
lab[0:2, 0:2] = 5
82+
lab[5:7, 5:7] = 9 # gap in ids (5, 9) -> should become (1, 2)
83+
out = _relabel_sequential(lab)
84+
assert sorted(np.unique(out).tolist()) == [0, 1, 2]
85+
# the two original objects stay distinct (no merge)
86+
assert out[0, 0] != out[5, 5]
87+
88+
89+
def test_relabel_preserves_object_count_for_ap():
90+
"""AP object counts come from labels.max(); relabel keeps n correct."""
91+
lab = np.zeros((8, 8), dtype=np.uint16)
92+
lab[0:2, 0:2] = 7 # single object with a non-1 id
93+
result = instance_average_precision(lab.copy(), lab)
94+
assert result["n_gt"] == 1 and result["n_pred"] == 1
95+
assert result["mAP"] == 1.0

0 commit comments

Comments
 (0)