Skip to content

Commit 911d560

Browse files
authored
Merge pull request #17 from imcf/feat/napari-autolabels-gpu-dashboard
feat: napari auto-loads in-store labels + single-GPU dashboard
2 parents 8a1753f + 0252d07 commit 911d560

5 files changed

Lines changed: 115 additions & 12 deletions

File tree

docs/guide/ome_zarr_napari.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ to_ome_zarr("scan.czi", "scan.zarr", n_levels=5) # via bioio
5050
to_ome_zarr("scan.ims", "scan.zarr") # Imaris, native HDF5
5151
```
5252

53+
!!! note "Imaris pyramids are rebuilt, not reused"
54+
`.ims` files carry their own resolution pyramid, but `to_ome_zarr` reads
55+
only the **full-resolution** level and **builds a fresh NGFF pyramid** from
56+
it. This guarantees a consistent pyramid (XY-only, nearest-neighbour,
57+
calibrated) rather than inheriting Imaris's own downsampling scheme. It
58+
costs some extra compute, but the build is lazy and OOM-safe.
59+
5360
### Pixel calibration
5461

5562
The physical voxel size is read from the input — bioio's `physical_pixel_sizes`,
@@ -96,13 +103,17 @@ write_labels("scan.zarr", my_labels, name="nuclei")
96103
layer in one call. OME-ZARR pyramids are handed to napari as a lazy multi-scale
97104
list, so even huge stores open instantly and only on-screen data is fetched.
98105

106+
Because `tile_process` writes labels **into** the store by default, you usually
107+
need no `labels=` argument at all — `view_in_napari` auto-loads every label
108+
image found under `scan.zarr/labels/`:
109+
99110
```python
100111
from patchworks.plugins.napari import view_in_napari
101112

102-
# one store holding both image and labels/<name>:
103-
view_in_napari("scan.zarr", labels="scan.zarr/labels/labels")
113+
# auto-loads scan.zarr/labels/* as Labels layers:
114+
view_in_napari("scan.zarr")
104115

105-
# or a separate plain label store written with write_to=:
116+
# or point at a separate plain label store written with write_to=:
106117
view_in_napari("scan.zarr", labels="labels.zarr")
107118
```
108119

@@ -120,7 +131,7 @@ from patchworks.plugins.napari import view_in_napari
120131
tile_process("scan.zarr", fn, progress=True)
121132

122133
# 2. inspect image + labels together, straight from the one store
123-
view_in_napari("scan.zarr", labels="scan.zarr/labels/labels")
134+
view_in_napari("scan.zarr") # labels auto-loaded from scan.zarr/labels/
124135
```
125136

126137
Plugging in a different segmentation method is just swapping `fn` — any

docs/guide/performance.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ merge step are sized to the host automatically:
1717
The RAM figure is read live via `psutil`; without it, a conservative default is
1818
used instead of guessing high.
1919

20+
## Live progress dashboard (GPU runs)
21+
22+
A single-GPU run still gets a **Dask dashboard**: patchworks spins up a tiny
23+
1-worker / 1-thread in-process cluster, which keeps GPU evaluations serial (no
24+
VRAM contention) while exposing the dashboard so you can watch tiles stream
25+
through. The URL is logged at the start of staging:
26+
27+
```text
28+
INFO:patchworks._core:Dask dashboard for this run: http://127.0.0.1:8787/status
29+
```
30+
31+
This needs `distributed` (and `bokeh` for the UI) installed; if they are
32+
missing, patchworks logs a warning and falls back to the threaded scheduler
33+
(no dashboard, same result). A cluster you start yourself
34+
(`make_local_cluster`) is used as-is instead.
35+
2036
## Overriding the worker count
2137

2238
```python

src/patchworks/_core.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,31 @@ def active_fn(block, block_info=None):
332332
import dask as _dask
333333

334334
_tile_nbytes = int(np.prod(labeled.chunksize)) * labeled.dtype.itemsize
335-
if _active is None:
335+
_temp_cluster = None
336+
_temp_client = None
337+
if _active is None and use_gpu:
338+
# Single-GPU runs still get a live Dask dashboard: a 1-worker /
339+
# 1-thread in-process cluster keeps GPU evals serial (no VRAM
340+
# contention) while exposing the dashboard for progress.
341+
try:
342+
from dask.distributed import Client, LocalCluster
343+
344+
_temp_cluster = LocalCluster(
345+
n_workers=1, threads_per_worker=1, processes=False
346+
)
347+
_temp_client = Client(_temp_cluster)
348+
logger.info(
349+
"Dask dashboard for this run: %s",
350+
_temp_client.dashboard_link,
351+
)
352+
except Exception as exc: # no distributed/bokeh → threaded fallback
353+
logger.warning(
354+
"Could not start a dashboard cluster (%s); "
355+
"falling back to the threaded scheduler.",
356+
exc,
357+
)
358+
359+
if _distributed_client() is None:
336360
_workers = (
337361
max_workers
338362
if max_workers is not None
@@ -363,6 +387,9 @@ def active_fn(block, block_info=None):
363387
logger.info("Staging tiles to %s …", stage_path)
364388
with _sched_ctx:
365389
_stage_to_zarr(labeled, stage_path, "staged", progress)
390+
if _temp_client is not None:
391+
_temp_client.close()
392+
_temp_cluster.close()
366393
labeled = da.from_zarr(stage_path, component="staged")
367394

368395
# NB: no post-staging skip-count pass here — counting skipped tiles by

src/patchworks/plugins/napari.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,12 @@
1414
Usage
1515
-----
1616
>>> from patchworks import tile_process
17-
>>> from patchworks.plugins.ome_zarr import to_ome_zarr
1817
>>> from patchworks.plugins.napari import view_in_napari
1918
>>>
20-
>>> tile_process("scan.zarr", fn, write_to="labels.zarr")
21-
>>> to_ome_zarr("scan.zarr", "scan_pyramid.zarr") # optional, for speed
22-
>>>
23-
>>> view_in_napari("scan_pyramid.zarr", labels="labels.zarr")
19+
>>> # labels are written into scan.zarr/labels/ by default …
20+
>>> tile_process("scan.zarr", fn)
21+
>>> # … so the viewer finds and overlays them with no labels= argument:
22+
>>> view_in_napari("scan.zarr")
2423
"""
2524

2625
from __future__ import annotations
@@ -86,6 +85,15 @@ def _resolve_image(
8685
return source
8786

8887

88+
def _inner_label_names(store: Union[str, Path]) -> list[str]:
89+
"""Names registered under an OME-ZARR's NGFF ``labels/`` group, if any."""
90+
try:
91+
grp = zarr.open_group(f"{store}/labels", mode="r")
92+
except Exception:
93+
return []
94+
return list(grp.attrs.get("labels", []))
95+
96+
8997
def _resolve_labels(
9098
source: Union[da.Array, str, Path], component: str
9199
) -> Union[da.Array, list[da.Array]]:
@@ -123,7 +131,10 @@ def view_in_napari(
123131
labels : da.Array, str, Path or None
124132
Label array to overlay. A plain ``.zarr`` store written by
125133
``tile_process`` is read from its ``labels_component``; an OME-ZARR
126-
pyramid is shown multi-scale; ``None`` shows the image only.
134+
pyramid is shown multi-scale. ``None`` (default) **auto-loads** every
135+
label image stored inside the OME-ZARR under ``labels/<name>/`` — the
136+
place ``tile_process`` writes them by default — each as its own Labels
137+
layer. (Falls back to image-only if there are none.)
127138
channel : int or None, optional
128139
Channel to display from the image (``None`` keeps all channels).
129140
labels_component : str, optional
@@ -145,7 +156,7 @@ def view_in_napari(
145156
146157
Examples
147158
--------
148-
>>> view_in_napari("scan.zarr", labels="labels.zarr") # doctest: +SKIP
159+
>>> view_in_napari("scan.zarr") # auto-loads scan.zarr/labels/* # doctest: +SKIP
149160
"""
150161
napari = _require_napari()
151162

@@ -161,6 +172,15 @@ def view_in_napari(
161172
if labels is not None:
162173
lab = _resolve_labels(labels, labels_component)
163174
viewer.add_labels(lab, name=labels_name)
175+
elif _is_zarr(image):
176+
# No labels given → auto-overlay every label image stored inside the
177+
# OME-ZARR under labels/<name>/ (the default place tile_process writes
178+
# them), each as its own multi-scale Labels layer.
179+
for name in _inner_label_names(image):
180+
levels = _multiscale_levels(f"{image}/labels/{name}", None)
181+
lab = [lvl.astype("int32") for lvl in levels]
182+
viewer.add_labels(lab if len(lab) > 1 else lab[0], name=name)
183+
logger.info("auto-loaded labels/%s from %s", name, image)
164184

165185
if show:
166186
napari.run()

tests/test_napari.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,32 @@ def test_require_napari_message(monkeypatch):
3939
nplugin._require_napari()
4040
else:
4141
assert nplugin._require_napari() is napari
42+
43+
44+
def test_inner_label_discovery(tmp_path):
45+
"""Labels written into a store are discoverable for auto-overlay."""
46+
import numpy as np
47+
48+
from patchworks.plugins.ome_zarr import to_ome_zarr, write_labels
49+
50+
store = to_ome_zarr(
51+
np.zeros((8, 8, 8), "uint16"), tmp_path / "scan.zarr", n_levels=2
52+
)
53+
write_labels(store, np.ones((8, 8, 8), "int32"), name="cells", n_levels=2)
54+
55+
assert nplugin._inner_label_names(store) == ["cells"]
56+
levels = nplugin._multiscale_levels(f"{store}/labels/cells", None)
57+
assert len(levels) == 2
58+
assert levels[1].shape == (8, 4, 4) # Z preserved, XY downsampled
59+
60+
61+
def test_inner_label_discovery_none(tmp_path):
62+
"""A store without labels yields an empty list (image-only view)."""
63+
import numpy as np
64+
65+
from patchworks.plugins.ome_zarr import to_ome_zarr
66+
67+
store = to_ome_zarr(
68+
np.zeros((8, 8, 8), "uint16"), tmp_path / "img.zarr", n_levels=1
69+
)
70+
assert nplugin._inner_label_names(store) == []

0 commit comments

Comments
 (0)