Skip to content

Commit 75eab99

Browse files
lguerardclaude
andcommitted
feat(core): ✨ persist object count for the napari plugin to skip its scan
merge_tile_labels(sequential_labels=True) already computes the exact object count while renumbering, but discarded it. return_count=True now surfaces it; write_labels/register_labels persist it as n_objects/ sequential_labels zarr attrs; view_in_napari propagates it into each Labels layer's metadata via a new _label_hint() helper. Lets a consumer (napari-dask-ndmeasure) skip a full O(voxels) scan of the label array to find its id set — the id set is range(1, n_objects+1) by construction when labels are sequential. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 8d4590c commit 75eab99

7 files changed

Lines changed: 204 additions & 8 deletions

File tree

src/patchworks/_merge.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,8 @@ def merge_tile_labels(
459459
stage_dir: Union[str, Path, None] = None,
460460
keep_stage: bool = False,
461461
progress: bool = False,
462-
) -> "da.Array":
462+
return_count: bool = False,
463+
) -> Union["da.Array", tuple["da.Array", Union[int, None]]]:
463464
"""Merge per-tile labels into a globally consistent label array.
464465
465466
Standalone merge step — use this when you already have per-tile labels
@@ -503,11 +504,21 @@ def merge_tile_labels(
503504
Keep the temp stage zarr after merging. Default False.
504505
progress:
505506
Show a progress bar during the relabel step.
507+
return_count:
508+
Also return the exact object count. Only meaningful (non-``None``)
509+
when ``sequential_labels=True``, which already computes it for free
510+
while renumbering to ``1..N`` — otherwise no step here knows the
511+
final count without an extra full scan, so the second element is
512+
``None``. Useful to persist alongside the labels (e.g.
513+
``write_labels(..., n_objects=...)``) so a downstream consumer with
514+
the count can skip re-deriving the id set from the array itself.
506515
507516
Returns
508517
-------
509518
da.Array
510-
Merged label array (int32) backed by ``write_to``.
519+
Merged label array (int32) backed by ``write_to``. Or, when
520+
``return_count=True``, a ``(labels, n_objects)`` tuple —
521+
``n_objects`` is ``None`` unless ``sequential_labels=True``.
511522
512523
Examples
513524
--------
@@ -600,9 +611,10 @@ def merge_tile_labels(
600611
show_progress=progress,
601612
)
602613

614+
n_objects = None
603615
if sequential_labels:
604616
logger.info("Relabelling to contiguous ids…")
605-
relabel_sequential_zarr(effective_out, output_component)
617+
n_objects = relabel_sequential_zarr(effective_out, output_component)
606618

607619
# -- Cleanup temp stage (only when we created it) --
608620
if not isinstance(labeled, (str, Path)) and not keep_stage:
@@ -611,4 +623,5 @@ def merge_tile_labels(
611623
shutil.rmtree(stage_path, ignore_errors=True)
612624
logger.info("Removed stage store %s", stage_path)
613625

614-
return da.from_zarr(effective_out, component=output_component)
626+
result = da.from_zarr(effective_out, component=output_component)
627+
return (result, n_objects) if return_count else result

src/patchworks/plugins/napari.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,41 @@ def _pyramid_calibration(
179179
return scale, units
180180

181181

182+
def _label_hint(path: Union[str, Path]) -> dict[str, Any]:
183+
"""Read the known-object-count hint from a label group's zarr attrs.
184+
185+
``write_labels(..., n_objects=...)`` persists this when the labels were
186+
renumbered to a contiguous ``1..N`` range (``sequential_labels=True``
187+
during the merge) — the exact id set is then ``range(1, n_objects +
188+
1)`` by construction, with no scan needed. Passed through as a Labels
189+
layer's ``metadata`` so a downstream consumer (e.g.
190+
napari-dask-ndmeasure) can use it instead of re-deriving the id set
191+
from the array itself.
192+
193+
Parameters
194+
----------
195+
path : str or Path
196+
Label group path (e.g. ``f"{image}/labels/{name}"``).
197+
198+
Returns
199+
-------
200+
dict
201+
``{"n_objects": int, "sequential_labels": True}`` if the group has
202+
the hint, else ``{}`` — safe to splat straight into
203+
``metadata=``/merge into a bigger dict either way.
204+
"""
205+
try:
206+
attrs = zarr.open_group(str(path), mode="r").attrs
207+
except Exception:
208+
return {}
209+
if "n_objects" not in attrs:
210+
return {}
211+
return {
212+
"n_objects": attrs["n_objects"],
213+
"sequential_labels": attrs.get("sequential_labels", False),
214+
}
215+
216+
182217
def _inner_label_names(store: Union[str, Path]) -> list[str]:
183218
"""List label images registered under an OME-ZARR's ``labels/`` group.
184219
@@ -351,12 +386,14 @@ def view_in_napari(
351386
if _is_zarr(labels)
352387
else (None, None)
353388
)
389+
metadata = _label_hint(labels) if _is_zarr(labels) else {}
354390
viewer.add_labels(
355391
lab,
356392
name=labels_name,
357393
multiscale=isinstance(lab, list),
358394
scale=lab_scale,
359395
units=lab_units,
396+
metadata=metadata,
360397
**label_kwargs,
361398
)
362399
elif _is_zarr(image):
@@ -377,6 +414,7 @@ def view_in_napari(
377414
multiscale=True,
378415
scale=lab_scale,
379416
units=lab_units,
417+
metadata=_label_hint(store),
380418
**label_kwargs,
381419
)
382420
logger.info("auto-loaded labels/%s from %s", name, image)

src/patchworks/plugins/ome_zarr.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,7 @@ def register_labels(
10451045
chunks: Union[tuple[int, ...], None] = None,
10461046
shard: ShardSpec = False,
10471047
progress: bool = True,
1048+
n_objects: Union[int, None] = None,
10481049
) -> str:
10491050
"""Pyramidalise and register an existing ``labels/<name>/0`` base level.
10501051
@@ -1076,6 +1077,14 @@ def register_labels(
10761077
Sharding request (see :func:`to_ome_zarr`'s *shard*).
10771078
progress : bool, optional
10781079
Show a per-level dask progress bar (default ``True``).
1080+
n_objects : int or None, optional
1081+
Exact non-background object count, if known (e.g. from
1082+
:func:`patchworks.merge_tile_labels`'s ``return_count=True`` after
1083+
``sequential_labels=True``, which means ``ids == range(1, n_objects
1084+
+ 1)`` by construction). When given, written into the label group's
1085+
attrs as ``n_objects``/``sequential_labels`` so a downstream reader
1086+
(e.g. napari-dask-ndmeasure) can use the known id set instead of
1087+
re-deriving it with a full-volume scan of its own.
10791088
10801089
Returns
10811090
-------
@@ -1106,6 +1115,9 @@ def register_labels(
11061115
)
11071116
grp = zarr.open_group(group, mode="a")
11081117
grp.attrs["image-label"] = {"version": _NGFF_VERSION}
1118+
if n_objects is not None:
1119+
grp.attrs["n_objects"] = int(n_objects)
1120+
grp.attrs["sequential_labels"] = True
11091121

11101122
labels_grp = zarr.open_group(f"{store}/labels", mode="a")
11111123
registered = list(labels_grp.attrs.get("labels", []))
@@ -1128,6 +1140,7 @@ def write_labels(
11281140
shard: ShardSpec = False,
11291141
progress: bool = True,
11301142
overwrite: bool = False,
1143+
n_objects: Union[int, None] = None,
11311144
) -> str:
11321145
"""Store *labels* inside *image_store* under the NGFF ``labels/`` group.
11331146
@@ -1166,6 +1179,9 @@ def write_labels(
11661179
overwrite : bool, optional
11671180
Replace an existing label image of the same *name* (default
11681181
``False``).
1182+
n_objects : int or None, optional
1183+
Exact non-background object count, if known — forwarded to
1184+
:func:`register_labels`; see its docstring for what this enables.
11691185
11701186
Returns
11711187
-------
@@ -1175,10 +1191,16 @@ def write_labels(
11751191
Examples
11761192
--------
11771193
>>> from patchworks import merge_tile_labels
1178-
>>> merged = merge_tile_labels(
1179-
... "stage.zarr", input_component="staged", write_to="merged.zarr"
1194+
>>> merged, n = merge_tile_labels(
1195+
... "stage.zarr",
1196+
... input_component="staged",
1197+
... write_to="merged.zarr",
1198+
... sequential_labels=True,
1199+
... return_count=True,
1200+
... ) # doctest: +SKIP
1201+
>>> write_labels(
1202+
... "scan.zarr", merged, name="cells", n_objects=n
11801203
... ) # doctest: +SKIP
1181-
>>> write_labels("scan.zarr", merged, name="cells") # doctest: +SKIP
11821204
'scan.zarr/labels/cells'
11831205
"""
11841206
arr = labels if isinstance(labels, da.Array) else da.asarray(labels)
@@ -1209,4 +1231,5 @@ def write_labels(
12091231
chunks=chunks,
12101232
shard=shard,
12111233
progress=progress,
1234+
n_objects=n_objects,
12121235
)

tests/test_core.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,62 @@ def fn(tile):
148148
assert ids.size == 1, f"object split into {ids.size} labels, expected 1"
149149

150150

151+
def test_merge_tile_labels_return_count(tmp_path):
152+
# sequential_labels=True already computes the exact object count while
153+
# renumbering to 1..N — return_count=True surfaces it instead of
154+
# discarding it, so a caller can persist it (e.g. write_labels'
155+
# n_objects=) for a downstream consumer to skip re-deriving the id set.
156+
import dask.array as da
157+
158+
from patchworks import merge_tile_labels
159+
160+
data = np.zeros((1, 16, 32), dtype="uint16")
161+
data[0, 2:6, 2:6] = 1
162+
data[0, 2:6, 10:14] = 1 # same tile-local label, different object
163+
image = da.from_array(data, chunks=(1, 16, 16))
164+
165+
def fn(tile):
166+
from skimage.measure import label
167+
168+
return label(tile > 0).astype("int32")
169+
170+
labeled = image.map_blocks(
171+
fn, dtype="int32", meta=np.empty((0,) * image.ndim, dtype="int32")
172+
)
173+
out = str(tmp_path / "merged.zarr")
174+
merged, n_objects = merge_tile_labels(
175+
labeled, write_to=out, sequential_labels=True, return_count=True
176+
)
177+
arr = merged.compute()
178+
ids = np.unique(arr[arr > 0])
179+
assert n_objects == ids.size
180+
assert n_objects == 2
181+
182+
183+
def test_merge_tile_labels_return_count_none_without_sequential(tmp_path):
184+
import dask.array as da
185+
186+
from patchworks import merge_tile_labels
187+
188+
data = np.zeros((1, 16, 32), dtype="uint16")
189+
data[0, 4:12, 8:24] = 1
190+
image = da.from_array(data, chunks=(1, 16, 16))
191+
192+
def fn(tile):
193+
from skimage.measure import label
194+
195+
return label(tile > 0).astype("int32")
196+
197+
labeled = image.map_blocks(
198+
fn, dtype="int32", meta=np.empty((0,) * image.ndim, dtype="int32")
199+
)
200+
out = str(tmp_path / "merged.zarr")
201+
merged, n_objects = merge_tile_labels(
202+
labeled, write_to=out, sequential_labels=False, return_count=True
203+
)
204+
assert n_objects is None
205+
206+
151207
def test_merge_transitive_three_tiles(tmp_path):
152208
# A cell that spans 3 tiles (A→B→C) must be merged into one label even
153209
# though A and C never directly touch. Transitivity via connected_components.

tests/test_napari.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,39 @@ def test_inner_label_discovery_none(tmp_path):
6868
np.zeros((8, 8, 8), "uint16"), tmp_path / "img.zarr", n_levels=1
6969
)
7070
assert nplugin._inner_label_names(store) == []
71+
72+
73+
def test_label_hint_present_when_n_objects_written(tmp_path):
74+
"""write_labels(..., n_objects=...) is readable back via _label_hint."""
75+
from patchworks.plugins.ome_zarr import to_ome_zarr, write_labels
76+
77+
store = to_ome_zarr(
78+
np.zeros((8, 8, 8), "uint16"), tmp_path / "scan.zarr", n_levels=1
79+
)
80+
write_labels(
81+
store,
82+
np.ones((8, 8, 8), "int32"),
83+
name="cells",
84+
n_levels=1,
85+
n_objects=17,
86+
)
87+
88+
hint = nplugin._label_hint(f"{store}/labels/cells")
89+
assert hint == {"n_objects": 17, "sequential_labels": True}
90+
91+
92+
def test_label_hint_empty_without_n_objects(tmp_path):
93+
"""No n_objects= at write time -> no hint, not a misleading default."""
94+
from patchworks.plugins.ome_zarr import to_ome_zarr, write_labels
95+
96+
store = to_ome_zarr(
97+
np.zeros((8, 8, 8), "uint16"), tmp_path / "scan.zarr", n_levels=1
98+
)
99+
write_labels(store, np.ones((8, 8, 8), "int32"), name="cells", n_levels=1)
100+
101+
assert nplugin._label_hint(f"{store}/labels/cells") == {}
102+
103+
104+
def test_label_hint_missing_store_returns_empty():
105+
"""A path that doesn't exist (or isn't a label group) just yields {}."""
106+
assert nplugin._label_hint("/no/such/store.zarr") == {}

tests/test_ome_zarr.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,34 @@ def test_write_labels_into_store(tmp_path):
130130
assert lg.attrs["image-label"]["version"]
131131

132132

133+
def test_write_labels_n_objects_persisted(tmp_path):
134+
"""n_objects lands in the label group's attrs for a downstream reader."""
135+
store = to_ome_zarr(
136+
np.zeros((8, 8, 8), "uint16"), tmp_path / "img.zarr", n_levels=2
137+
)
138+
labels = np.ones((8, 8, 8), dtype="int32")
139+
140+
group = write_labels(store, labels, name="cells", n_levels=2, n_objects=42)
141+
142+
lg = zarr.open_group(group, mode="r")
143+
assert lg.attrs["n_objects"] == 42
144+
assert lg.attrs["sequential_labels"] is True
145+
146+
147+
def test_write_labels_no_n_objects_by_default(tmp_path):
148+
"""Without n_objects=, no misleading count is written."""
149+
store = to_ome_zarr(
150+
np.zeros((8, 8, 8), "uint16"), tmp_path / "img.zarr", n_levels=2
151+
)
152+
labels = np.ones((8, 8, 8), dtype="int32")
153+
154+
group = write_labels(store, labels, name="cells", n_levels=2)
155+
156+
lg = zarr.open_group(group, mode="r")
157+
assert "n_objects" not in lg.attrs
158+
assert "sequential_labels" not in lg.attrs
159+
160+
133161
def test_reuse_pyramid_ignored_for_arrays(tmp_path):
134162
"""reuse_pyramid only affects .ims inputs; arrays still rebuild."""
135163
out = to_ome_zarr(

workflow/scripts/merge.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,14 @@
2828
default_workers = int(
2929
os.environ.get("SLURM_CPUS_PER_TASK", os.cpu_count() or 4)
3030
)
31-
merged = merge_tile_labels(
31+
merged, n_objects = merge_tile_labels(
3232
stage_path(work_dir, label_name),
3333
write_to=merged_store,
3434
input_component="staged",
3535
sequential_labels=cfg.get("sequential_labels", True),
3636
n_workers=cfg.get("merge_workers", default_workers),
3737
progress=False,
38+
return_count=True,
3839
)
3940
group = write_labels(
4041
image_store,
@@ -43,6 +44,7 @@
4344
n_levels=int(cfg.get("pyramid_levels", 5)),
4445
downscale=int(cfg.get("pyramid_downscale", 2)),
4546
overwrite=True,
47+
n_objects=n_objects,
4648
)
4749

4850
shutil.rmtree(merged_store, ignore_errors=True)

0 commit comments

Comments
 (0)