Skip to content

Commit 24e1ca3

Browse files
committed
Merge branch dev: optional nuclei channel for Cellpose
Adds a per-config nuclei_channel, guards napari's label auto-load, and notes the codebase's provenance in the README.
2 parents 844720f + d10b830 commit 24e1ca3

10 files changed

Lines changed: 316 additions & 46 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ site/
99
*.log
1010
.snakemake/
1111
.pixi/
12+
13+
# graft's local graph cache — regenerable, not committed (run `graft build`).
14+
graft/

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ patchworks splits a large image into tiles, runs **any callable** on each
2525
tile in parallel, and merges the results into a globally consistent label array.
2626
It handles terabyte-scale images without loading them into memory.
2727

28+
> [!NOTE]
29+
> **On how this was written.** Large parts of patchworks were vibe coded —
30+
> written with heavy LLM assistance rather than line by line. It is covered by
31+
> a test suite and has been run on real data, so it is not untested, but the
32+
> usual caveats apply: read the code before you trust it with anything
33+
> irreplaceable, and please open an issue if something looks off.
34+
2835
---
2936

3037
## Installation

docs/guide/snakemake.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ convert_chunks: null # null → bounded auto chunks; or [c,z,y,x]
5353
shard: false # true → pack chunks into shards (fewer files)
5454

5555
# tiling
56-
channel: 0 # channel to segment (null = keep all)
56+
channel: 0 # channel to segment, 0-based (null = keep all)
57+
nuclei_channel: null # optional 2nd channel for Cellpose (see below)
5758
level: 0 # pyramid level (0 = full resolution)
5859
tile_shape: "auto" # "auto", or e.g. [16, 1024, 1024] (zyx)
5960
gpu_memory_gb: null # for "auto" on SLURM: your segment GPU's VRAM
@@ -299,6 +300,7 @@ cellpose:
299300
# config/config_cyto.yaml — only the differences
300301
label_name: "cyto_labels"
301302
channel: 0 # cytoplasm/membrane channel
303+
nuclei_channel: 1 # optional: nuclear stain, as Cellpose's 2nd input
302304
overlap: [4, 30, 30]
303305
method: "cellpose"
304306
cellpose:
@@ -307,6 +309,29 @@ cellpose:
307309
do_3D: true
308310
```
309311

312+
### Giving Cellpose a nuclei channel
313+
314+
`nuclei_channel` hands Cellpose a second channel — the nuclear stain — which
315+
usually improves cytoplasm segmentation. Both indices are 0-based, like
316+
`channel`.
317+
318+
Only the `segment` step reads it. The pair is stacked on a leading axis that
319+
is *carried* into each tile rather than tiled, so the tile geometry, the
320+
occupancy map and the staged labels are byte-for-byte what a single-channel
321+
run produces, and `merge` and `label_relations` need no changes. Two things
322+
follow from that:
323+
324+
- A tile holds twice the bytes, so a hand-set `tile_shape` sized to fill a GPU
325+
may need halving. `tile_shape: "auto"` sizes from the single-channel array
326+
and does not yet know about the pair.
327+
- The translation is version-specific. Cellpose 3 gets `channels: [1, 2]`
328+
(1-based into the channel axis, `0` = grayscale); Cellpose 4 (cpsam) dropped
329+
`channels` entirely and simply reads both. Either is overridable by setting
330+
`channels:` or `channel_axis:` in the `cellpose:` block.
331+
332+
`nuclei_channel` applies to the SLURM/Snakemake path. The single-process
333+
`tile_process` API still takes one `channel`.
334+
310335
Run them as two independent SLURM submissions — they touch disjoint files, so
311336
they can run concurrently. Give each its own `--directory`, because
312337
Snakemake's lock lives in the working directory, not in the config:

src/patchworks/_distributed.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ def stage_tile(
166166
tile_shape: tuple[int, ...],
167167
overlap: Overlap = 0,
168168
component: str = "staged",
169+
channel_axis: int | None = None,
169170
) -> int:
170171
"""Run *fn* on a single tile and write it into the shared stage store.
171172
@@ -192,6 +193,13 @@ def stage_tile(
192193
:func:`normalize_overlap`).
193194
component : str, optional
194195
Array name inside the stage store.
196+
channel_axis : int or None, optional
197+
Axis of *image* holding channels, which is **not** tiled: it is read
198+
whole and handed to *fn* alongside the tile's voxels. ``tile_shape``,
199+
``overlap`` and the stage store stay purely spatial, so *fn* still
200+
returns one label per voxel with no channel axis (e.g. Cellpose fed a
201+
cytoplasm + nuclei pair returns a single label volume). ``None`` (the
202+
default) means *image* is already single-channel.
195203
196204
Returns
197205
-------
@@ -202,18 +210,30 @@ def stage_tile(
202210
by a cumulative sum, instead of rewriting the whole store to make the
203211
ids unique.
204212
"""
205-
shape = image.shape
206-
sl = spatial_tiles(shape, tile_shape)[index]
213+
shape = tuple(image.shape)
214+
# The channel axis is carried, not tiled: geometry (tiles, halo, the stage
215+
# store) stays spatial, so nothing downstream of fn learns about channels.
216+
if channel_axis is None:
217+
spatial_shape = shape
218+
else:
219+
channel_axis %= len(shape)
220+
spatial_shape = shape[:channel_axis] + shape[channel_axis + 1 :]
221+
sl = spatial_tiles(spatial_shape, tile_shape)[index]
207222
halo = normalize_overlap(overlap, len(sl), tile_shape=tile_shape)
208223
expanded, trims = [], []
209-
for s, dim, ov in zip(sl, shape, halo):
224+
for s, dim, ov in zip(sl, spatial_shape, halo):
210225
lo = max(0, s.start - ov)
211226
hi = min(dim, s.stop + ov)
212227
expanded.append(slice(lo, hi))
213228
trims.append((s.start - lo, hi - s.stop))
214-
block = np.asarray(image[tuple(expanded)])
229+
read = list(expanded)
230+
if channel_axis is not None:
231+
read.insert(channel_axis, slice(None))
232+
block = np.asarray(image[tuple(read)])
233+
# What fn owes us back: one label per voxel, channel axis consumed.
234+
block_spatial = tuple(e.stop - e.start for e in expanded)
215235
out = np.asarray(fn(block))
216-
if out.shape != block.shape:
236+
if out.shape != block_spatial:
217237
# Caught here rather than 6 frames deep in zarr's codec pipeline as
218238
# "could not broadcast input array from shape (13,1020,1020) into
219239
# shape (14,1024,1024)", which says nothing about which function is
@@ -224,7 +244,7 @@ def stage_tile(
224244
name = getattr(fn, "__name__", type(fn).__name__)
225245
raise ValueError(
226246
f"segmentation function {name!r} returned shape {out.shape} for "
227-
f"a tile of shape {block.shape} (tile {index}). It must return "
247+
f"a tile of shape {block_spatial} (tile {index}). It must return "
228248
"one label per input voxel. Some deconvolution backends crop "
229249
"their output -- pad or centre it back to the input shape before "
230250
"returning."

src/patchworks/plugins/cellpose.py

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,13 @@ def _make_config(
161161
gpu : bool
162162
Run on the GPU.
163163
channels : list of int or None
164-
Cellpose-3 ``[cyto, nucleus]`` channels; defaults to ``[0, 0]``.
164+
*Cellpose 3 only.* ``[cyto, nucleus]``, 1-based into the channel axis
165+
(0 = grayscale). ``None`` resolves per tile: ``[1, 2]`` when the tile
166+
carries two channels, else ``[0, 0]``. Cellpose 4 dropped this
167+
argument, so it is ignored there.
165168
channel_axis : int or None
166-
Cellpose-4 channel axis.
169+
Axis of the tile holding channels, forwarded to ``eval`` for both
170+
Cellpose 3 and 4. ``None`` means single-channel tiles.
167171
diameter : float or None
168172
Expected cell diameter in pixels.
169173
do_3D : bool
@@ -179,7 +183,9 @@ def _make_config(
179183
return {
180184
"model": model,
181185
"gpu": gpu,
182-
"channels": channels if channels is not None else [0, 0],
186+
# Left as None ("auto") rather than [0, 0]: _run only knows how many
187+
# channels a tile actually carries once it has one in hand.
188+
"channels": channels,
183189
"channel_axis": channel_axis,
184190
"diameter": diameter,
185191
"do_3D": do_3D,
@@ -284,37 +290,50 @@ def _run(block: np.ndarray, cellpose_dict: dict[str, Any]) -> np.ndarray:
284290
Integer (``int32``) label array of the same spatial shape.
285291
"""
286292
do_3D = cellpose_dict["do_3D"]
293+
channel_axis = cellpose_dict.get("channel_axis")
294+
n_channels = block.shape[channel_axis] if channel_axis is not None else 1
295+
296+
kwargs: dict[str, Any] = dict(
297+
channel_axis=channel_axis,
298+
diameter=cellpose_dict["diameter"],
299+
do_3D=do_3D,
300+
**cellpose_dict.get("cellpose_kwargs", {}),
301+
)
302+
if not _CELLPOSE_V4:
303+
# Cellpose 4 (cpsam) dropped `channels` and reads whatever channels
304+
# the array carries; Cellpose 3 needs the cyto/nucleus pairing named.
305+
channels = cellpose_dict.get("channels")
306+
if channels is None:
307+
channels = [1, 2] if n_channels >= 2 else [0, 0]
308+
kwargs["channels"] = channels
287309

288-
if _CELLPOSE_V4:
289-
kwargs: dict[str, Any] = dict(
290-
channel_axis=cellpose_dict.get("channel_axis"),
291-
diameter=cellpose_dict["diameter"],
292-
do_3D=do_3D,
293-
**cellpose_dict.get("cellpose_kwargs", {}),
294-
)
295-
else:
296-
kwargs = dict(
297-
channels=cellpose_dict["channels"],
298-
diameter=cellpose_dict["diameter"],
299-
do_3D=do_3D,
300-
**cellpose_dict.get("cellpose_kwargs", {}),
301-
)
310+
# Where z sits once the channel axis is accounted for.
311+
z_axis = 1 if channel_axis == 0 else 0
302312

303313
if do_3D:
304-
kwargs["z_axis"] = 0
314+
kwargs["z_axis"] = z_axis
305315
masks = _eval_with_oom_fallback(block, kwargs, cellpose_dict)
306316
return masks.astype("int32")
307317
else:
308318
# Squeeze singleton z so Cellpose gets a clean 2-D image
309-
squeeze = block.ndim == 3 and block.shape[0] == 1
310-
if block.ndim == 3 and not squeeze:
319+
spatial = list(block.shape)
320+
if channel_axis is not None:
321+
spatial.pop(channel_axis)
322+
squeeze = len(spatial) == 3 and spatial[0] == 1
323+
if len(spatial) == 3 and not squeeze:
311324
raise ValueError(
312-
f"do_3D is False but this tile has {block.shape[0]} z-planes. "
325+
f"do_3D is False but this tile has {spatial[0]} z-planes. "
313326
"Cellpose would receive the stack with no z_axis and treat "
314327
"the leading axis as channels. Set do_3D: true, or tile with "
315328
"z=1 to segment plane by plane."
316329
)
317-
img = block[0] if squeeze else block
330+
if squeeze:
331+
img = block[(slice(None),) * z_axis + (0,)]
332+
# Dropping z shifts any channel axis that sat behind it.
333+
if channel_axis is not None and channel_axis > z_axis:
334+
kwargs["channel_axis"] = channel_axis - 1
335+
else:
336+
img = block
318337
masks = _eval_with_oom_fallback(img, kwargs, cellpose_dict)
319338
masks = masks.astype("int32")
320339
return masks[np.newaxis] if squeeze else masks

src/patchworks/plugins/napari.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -406,21 +406,57 @@ def view_in_napari(
406406
# unwrapped to a single array) even for one level, so napari always
407407
# treats it as multiscale — required for 3D resolution switching, see
408408
# https://napari.org/stable/gallery/add_multiscale_volume.html
409-
for name in _inner_label_names(image):
410-
store = f"{image}/labels/{name}"
411-
levels = _multiscale_levels(store, None)
412-
lab = [lvl.astype("int32") for lvl in levels]
413-
lab_scale, lab_units = _pyramid_calibration(store, lab[0].ndim)
414-
viewer.add_labels(
415-
lab,
416-
name=name,
417-
multiscale=True,
418-
scale=lab_scale,
419-
units=lab_units,
420-
metadata=_label_hint(store),
421-
**label_kwargs,
409+
names = _inner_label_names(image)
410+
if not names:
411+
logger.warning(
412+
"%s has no label images under labels/, so nothing was "
413+
"overlaid. Pass labels=<path> explicitly if they live "
414+
"somewhere else.",
415+
image,
416+
)
417+
else:
418+
logger.info(
419+
"auto-loading %d label image(s) from %s/labels: %s",
420+
len(names),
421+
image,
422+
", ".join(names),
422423
)
424+
loaded = 0
425+
for name in names:
426+
store = f"{image}/labels/{name}"
427+
# Guarded per label: without this, one bad label group raised
428+
# *after* the image had been added, so the viewer opened showing
429+
# the image alone and every remaining label was skipped -- which
430+
# looks exactly like "there were no labels".
431+
try:
432+
levels = _multiscale_levels(store, None)
433+
lab = [lvl.astype("int32") for lvl in levels]
434+
lab_scale, lab_units = _pyramid_calibration(store, lab[0].ndim)
435+
viewer.add_labels(
436+
lab,
437+
name=name,
438+
multiscale=True,
439+
scale=lab_scale,
440+
units=lab_units,
441+
metadata=_label_hint(store),
442+
**label_kwargs,
443+
)
444+
except Exception:
445+
logger.exception(
446+
"could not add labels/%s as a layer; skipping it and "
447+
"continuing with the rest.",
448+
name,
449+
)
450+
continue
451+
loaded += 1
423452
logger.info("auto-loaded labels/%s from %s", name, image)
453+
if names and not loaded:
454+
logger.error(
455+
"found %d label image(s) in %s/labels but none could be "
456+
"added -- see the errors above.",
457+
len(names),
458+
image,
459+
)
424460

425461
if show:
426462
napari.run()

0 commit comments

Comments
 (0)