Skip to content

Commit 58b68ea

Browse files
committed
Merge dev: pipeline overhaul (v2.0.0)
Cuts GPU time roughly 3x, makes tile skipping exact rather than sampled, and removes the merge OOM by construction rather than by tuning. Performance - per-axis overlap: a scalar halo on a [16,1024,1024] tile read 5.3x the voxels it kept, nearly all wasted z; [4,30,30] brings that to ~1.7x - tiles_per_job batches tiles into one SLURM job, so CUDA init and the Cellpose weight load are paid once per batch, not once per tile - six full passes over the label volume become two: per-tile counts replace the global-uniqueness rewrite, the sequential renumber folds into the merge's LUT, and the merge writes into the label group and relabels in place - pyramid levels stream one source chunk per task instead of rechunking upward through dask, which is what OOM-killed the merge - chunks holding no labels are skipped in the scan and the relabel, so background costs neither I/O nor disk - the boundary scan runs in parallel; multi-config runs run concurrently Correctness - tile skipping used a centred window covering 6.25% of a tile, so objects in the outer ring were never segmented; an exact max-pooled occupancy map replaces it - worker counts and memory budgets read the SLURM/cgroup allocation instead of the node's totals - cupy OOM is retried like torch's; NVML reads the granted GPU, not index 0 - an in-place merge refuses to run twice, which would re-offset global ids - NGFF metadata now matches the zarr version being written (0.5 on v3) - config mistakes fail in prepare, not in the first GPU job BREAKING CHANGE: stage_tile returns the number of labels it wrote rather than the tile index. The workflow's markers move from seg/<tile>.done to seg/<batch>.done and carry per-tile label counts as JSON. Stores are written with NGFF 0.5 metadata on zarr v3; both layouts are still read.
2 parents 942d277 + 6929c60 commit 58b68ea

47 files changed

Lines changed: 3499 additions & 385 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,12 @@ tiles where the dask-image approach stalls.
286286
| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------- |
287287
| In-process Dask client | `FutureCancelledError: lost dependencies` | Detected at startup, raises immediately with fix instructions |
288288
| 3-4× fn recompute during merge | Cellpose runs 3× per tile | Staging writes labels once, merge reads from disk |
289-
| O(n²) sequential relabelling | Graph construction hangs at 1000+ tiles | Linear post-pass O(voxels) via `np.unique` + LUT |
289+
| O(n²) sequential relabelling | Graph construction hangs at 1000+ tiles | Folded into the merge's own LUT — no extra pass over the volume |
290290
| Wrong overlap boundary | Output shape mismatch | Always uses `boundary="none"` |
291291
| Persisting large arrays | Worker OOM | Never persists; keeps dask graph lazy and streams |
292+
| Sizing work to the whole node | Job OOM-killed on a shared cluster node | Reads the SLURM/cgroup allocation, not `os.cpu_count()` |
293+
| Rechunking a pyramid level | Threaded scheduler stockpiles intermediates | Levels stream one source chunk per task, bounded by construction |
294+
| Isotropic halo on flat tiles | 5× the voxels read and segmented, then trimmed | `overlap` takes one width per axis |
292295

293296
---
294297

docs/api/chunks.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,17 @@
33
::: patchworks.auto_tile_shape
44

55
::: patchworks.auto_tile_shape_cellpose
6+
7+
::: patchworks.auto_overlap
8+
9+
::: patchworks.normalize_overlap
10+
11+
## Cluster resource detection
12+
13+
On a shared node the machine's core count and free RAM say nothing about what
14+
this job was granted. These read the allocation instead, and everything that
15+
sizes a worker pool goes through them.
16+
17+
::: patchworks.cpu_allocation
18+
19+
::: patchworks.safe_worker_count

docs/api/io.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,18 @@
22

33
::: patchworks.load_ome_zarr
44

5+
## Deciding which tiles hold signal
6+
7+
`estimate_empty_tiles` is a fast **preview** — it samples a centred window per
8+
tile, so it can miss signal at a tile's edge. `build_occupancy_map` +
9+
`tile_occupancy` are **exact**: a brick maximum exceeds the threshold exactly
10+
when some voxel in that brick does. Use the latter pair when the result is
11+
used as a skip list. See [Skipping empty tiles](../guide/skip_empty.md).
12+
513
::: patchworks.estimate_empty_tiles
14+
15+
::: patchworks.build_occupancy_map
16+
17+
::: patchworks.tile_occupancy
18+
19+
::: patchworks.auto_empty_threshold

docs/api/plugins/ome_zarr.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,15 @@ resolution, matching anisotropic microscopy stacks.
2424
## register_labels
2525

2626
::: patchworks.plugins.ome_zarr.register_labels
27+
28+
## NGFF metadata layout
29+
30+
NGFF 0.4 is defined over zarr v2 and puts its keys at the top level; 0.5 is
31+
the zarr-v3 revision and nests them under `ome`. patchworks writes whichever
32+
matches the store, and reads both.
33+
34+
::: patchworks.plugins.ome_zarr.ngff_version
35+
36+
::: patchworks.plugins.ome_zarr.read_ngff_attr
37+
38+
::: patchworks.plugins.ome_zarr.write_ngff_attrs

docs/guide/custom_segmentation.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ def segment(tile: np.ndarray, prob_thresh: float = 0.5) -> np.ndarray:
118118

119119
Using a GPU? Just let your framework see it — nothing extra needed.
120120

121+
!!! tip "You do not need to modify patchworks"
122+
`method: "custom"` imports any `(tile) -> labels` callable, so a new
123+
method is a module of your own and a config block — nothing in the
124+
package changes. It then inherits everything the pipeline does: empty
125+
tiles are skipped, tiles are batched per job, labels are stitched across
126+
boundaries and renumbered, and the result is written as a calibrated
127+
pyramid.
128+
129+
(The `KNOWN_METHODS` list in `workflow/scripts/_pw.py` is only for
130+
*built-in* shortcuts like `"cellpose"`. Adding to it is for methods that
131+
ship with patchworks, not for your own.)
132+
121133
## Test it before you submit
122134

123135
Run your function on one real tile first — it catches shape/dtype bugs in
@@ -148,6 +160,9 @@ custom:
148160
module: "my_seg" # import name
149161
function: "segment" # default is "segment"
150162
kwargs: # optional — forwarded as segment(tile, **kwargs)
163+
# checked against your function's signature during
164+
# `prepare`, so a typo fails on a cheap CPU job
165+
# rather than in the first GPU job hours later
151166
sigma: 1.5
152167
```
153168

docs/guide/gpu_distributed.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,35 @@ FutureCancelledError: lost dependencies
7373
## GPU memory sizing
7474

7575
When `use_gpu=True`, patchworks queries free GPU VRAM via `nvidia-ml-py`
76-
(install: `pip install "patchworks[gpu]"`). Tile size is set so each tile
77-
uses at most half the available VRAM.
76+
(install: `pip install "patchworks[gpu]"`) and keeps 20% of it as headroom,
77+
because `info.free` is a point-in-time reading of a device you usually do not
78+
own outright. `auto_tile_shape` then sizes each tile to at most half of that
79+
budget; `auto_tile_shape_cellpose` uses Cellpose's own memory model instead
80+
(roughly 20× the raw tile bytes, plus ~2 GiB for the model).
81+
82+
The device is resolved from `CUDA_VISIBLE_DEVICES`. This matters on
83+
multi-GPU nodes: NVML enumerates **every** GPU regardless of `--gres=gpu:1`,
84+
so querying index 0 unconditionally would read a different card's free memory
85+
than the one your job was granted.
7886

7987
Without `nvidia-ml-py`, a conservative 8 GiB default is used with a warning.
8088
Install it for accurate sizing on large-VRAM cards (A100, H100):
8189

8290
```bash
8391
pip install "patchworks[gpu]"
8492
```
93+
94+
!!! tip "Sizing for a GPU you can't see"
95+
The Snakemake workflow plans tiles in `prepare`, which runs on a CPU
96+
node, so no GPU can be queried there. Set `gpu_memory_gb` in the config
97+
to the segment GPU's VRAM (e.g. `24` for an RTX 4090) instead of relying
98+
on the fallback.
99+
100+
## Surviving a shared GPU
101+
102+
An out-of-memory error on a shared device is often transient — a co-tenant
103+
job grew, not a tile that doesn't fit. patchworks retries on the GPU with a
104+
backoff rather than falling back to the CPU, where a single tile can take
105+
over an hour. Before each backoff it releases its own device memory
106+
(including any cached Cellpose model), since holding that is exactly what
107+
starves the other job. Both torch and cupy OOM errors are recognised.

docs/guide/merging.md

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,37 +28,86 @@ This is the same approach used by
2828

2929
### Step 1: stage
3030

31-
Each tile's labels are written to a temporary zarr once. This is critical:
32-
without staging, any downstream operation that reads the label array re-runs
33-
your segmentation function. The merge internally reads labels multiple times.
31+
Each tile's labels are written to zarr once. This is critical: without
32+
staging, any downstream operation that reads the label array re-runs your
33+
segmentation function. The merge internally reads labels multiple times.
3434

3535
```text
3636
tile_process calls fn once per tile → staged zarr
3737
3838
merge reads from staged zarr (no fn calls)
3939
```
4040

41-
### Step 2: boundary scan
41+
The Snakemake workflow goes further and stages **directly into**
42+
`image.zarr/labels/<name>/0`, then has the merge rewrite that array in place
43+
— saving a whole extra write of the volume plus the scratch store's disk. It
44+
falls back to a separate store when the tile is larger than the label chunk
45+
cap, since in place the chunking cannot be changed and level 0 has to stay
46+
pageable for a viewer.
47+
48+
### Step 2: make the ids globally unique
49+
50+
Tiles write local `1..n`, which collide, so the boundary scan could not
51+
otherwise tell two different objects apart. If each tile's label **count** is
52+
known, this is just an exclusive cumulative sum — global id is
53+
`offset[tile] + local`, computed in `O(n_tiles)` with no read of the volume
54+
at all. `stage_tile` returns that count for exactly this purpose; pass the
55+
counts as `label_counts=`.
56+
57+
Without counts, the merge falls back to streaming every chunk and renumbering
58+
it in place — correct, but a full read **and write** of the volume.
59+
60+
### Step 3: boundary scan
4261

4362
Only the two voxels on either side of each tile boundary are read. For any
44-
pair of touching non-zero labels `(a, b)`, they must be the same object.
63+
pair of touching non-zero labels `(a, b)`, they must be the same object. The
64+
per-tile offsets are applied here, on the fly.
4565

46-
I/O cost: `O(n_boundaries × face_area)`, not `O(full_volume)`.
66+
I/O cost: `O(n_boundaries × face_area)`, not `O(full_volume)`. The columns
67+
are read in parallel, and a boundary next to a chunk that holds no labels is
68+
skipped outright — a pair needs a non-zero label on *both* sides, so it could
69+
never produce one.
4770

48-
### Step 3: connected components
71+
### Step 4: connected components
4972

5073
scipy sparse connected components on the touching pairs produces a relabeling
5174
lookup table. All labels that transitively touch each other are mapped to the
5275
same canonical label.
5376

5477
Cost: `O(n_touching_pairs)`.
5578

56-
### Step 4: parallel relabel
79+
With `sequential_labels=True` the contiguous renumbering is folded into this
80+
same LUT. Because the id domain is dense by construction, the surviving ids
81+
are exactly the distinct LUT values — a `np.unique` over an array the length
82+
of the object count, with no scan of the volume.
83+
84+
### Step 5: parallel relabel
5785

5886
The LUT is applied to every tile in parallel via `multiprocessing.Pool`. The
5987
LUT is shared via process initializer to avoid re-pickling it for every chunk
6088
(LUTs can be hundreds of MB for dense label volumes).
6189

90+
Chunks whose tile wrote no labels are skipped entirely — not read, and not
91+
written. Zarr never materialises an unwritten chunk and reads it back as the
92+
fill value, so background regions cost neither I/O nor disk. On a sparse
93+
image that is most of the volume.
94+
95+
When the merge's output *is* its input, this pass rewrites the array in
96+
place. That is safe because the boundary scan (step 3) has already finished,
97+
so nothing still needs the original ids.
98+
99+
Because an in-place merge destroys its own input, it records how far it got
100+
on the array itself, and refuses to guess on a re-run:
101+
102+
| State found | What happens |
103+
| --- | --- |
104+
| nothing recorded | fresh tile-local ids — merge normally |
105+
| `running` | a previous attempt died mid-relabel, so the array is part local and part global. **Refuses**: re-segment to rebuild it. |
106+
| `done` | already merged — a no-op, so a failure *after* the relabel (the pyramid, say) can simply be retried |
107+
108+
Without that, a second pass would add each tile's offset to ids that are
109+
already global, which can land two unrelated objects on the same id.
110+
62111
## Using the merge step standalone
63112

64113
You can call the merge step directly on any existing label array or zarr:
@@ -86,17 +135,20 @@ merged = merge_tile_labels(
86135

87136
## Sequential label numbering
88137

89-
By default, merged labels are globally unique but may be **gappy**
90-
(block-encoded IDs like 1, 2, 500001, 500002, …). This is fine for
91-
counting, `regionprops`, and measurement — the IDs just aren't consecutive.
138+
By default, merged labels are globally unique but may be **gappy** — boundary
139+
merging fuses ids, leaving holes where the absorbed ones were. This is fine
140+
for counting, `regionprops`, and measurement — the IDs just aren't
141+
consecutive.
92142

93143
For contiguous 1..N numbering, use `sequential_labels=True`:
94144

95145
```python
96146
tile_process("image.zarr", fn, write_to="labels.zarr", sequential_labels=True)
97147
```
98148

99-
This runs a cheap linear post-pass: `np.unique` + lookup-table remap, O(voxels).
149+
This is free: it composes into the relabel LUT the merge already applies, so
150+
it costs a `np.unique` over the object count rather than another pass over
151+
the volume.
100152

101153
!!! warning "Do not use dask's built-in sequential relabel"
102154
`dask_image.ndmeasure.merge_labels_across_chunk_boundaries` has a

docs/guide/ome_zarr_napari.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,23 @@ natively. Pass `write_to="other.zarr"` to instead write a separate
2222
single-resolution label store, or `output_component="cells"` to name the label
2323
image.
2424

25-
The label pyramid is built lazily (`da.to_zarr`, streamed chunk by chunk), so
26-
it stays OOM-safe even for terabyte volumes. Control it with `pyramid_levels`
27-
and `pyramid_downscale`.
25+
The label pyramid is streamed chunk by chunk, so it stays OOM-safe even for
26+
terabyte volumes. Control it with `pyramid_levels` and `pyramid_downscale`.
27+
28+
!!! note "NGFF version"
29+
The metadata layout follows the zarr format being written: **NGFF 0.5**
30+
(keys nested under an `ome` attribute) on zarr v3, **0.4** (keys at the
31+
top level) on v2. Writing v3 data with 0.4's top-level layout — which
32+
earlier versions did — matches neither revision, and a strict 0.5 reader
33+
finds nothing there.
34+
35+
Reading accepts **both** layouts, so stores written by any patchworks
36+
version still load. Use `read_ngff_attr()` if you need to inspect the
37+
metadata yourself rather than indexing `attrs["multiscales"]` directly.
38+
39+
patchworks' own hints (`n_objects`, `sequential_labels`) are not NGFF
40+
keys, so they stay at the top level where a consumer finds them without
41+
knowing the layout.
2842

2943
## Why a pyramid?
3044

@@ -107,6 +121,21 @@ input — see [Cluster usage](snakemake.md).
107121
to_ome_zarr("scan.ims", "scan.zarr", reuse_pyramid=True)
108122
```
109123

124+
### Axis names
125+
126+
For a file, axes come from the reader's own dimension metadata (bioio's
127+
`dims.order`, Imaris, or the `sequence_pattern` groups) — not guessed. Only
128+
non-spatial singleton axes are dropped, so a genuine `z=1` survives.
129+
130+
For a **bare array** there is nothing to read, so axes are inferred from the
131+
number of dimensions (`yx`, `zyx`, `czyx`, `tczyx`). That is unambiguous up to
132+
3-D; from 4-D the leading axis could be channel or time, and patchworks logs a
133+
warning saying what it guessed. Pass `axes=` to settle it:
134+
135+
```python
136+
to_ome_zarr(arr, "out.zarr", axes="tzyx") # not the czyx it would assume
137+
```
138+
110139
### Pixel calibration
111140

112141
The physical voxel size is read from the input — bioio's `physical_pixel_sizes`,

docs/guide/performance.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,27 @@
33
`tile_process` is built so a run **adapts to whatever machine it lands on** and
44
can't run out of RAM/VRAM or freeze the box — without you tuning anything.
55

6-
## Automatic, machine-aware concurrency
6+
## Automatic, allocation-aware concurrency
77

88
The staging step (running your `fn` once per tile to a temp store) and the
9-
merge step are sized to the host automatically:
9+
merge step are sized automatically:
1010

1111
- **GPU** (`use_gpu=True`) → **one tile at a time**, so concurrent evaluations
1212
can never exhaust VRAM.
1313
- **CPU** → as many tiles in flight as fit **80 % of available RAM** (estimated
1414
from the tile size), and always **leaving one core free** so the machine
1515
stays responsive — it never pins every core.
1616

17-
The RAM figure is read live via `psutil`; without it, a conservative default is
18-
used instead of guessing high.
17+
"Available" means available **to this process**, not to the machine. On a
18+
shared cluster node those differ wildly — a 32-core, 128 GB job on a 128-core,
19+
512 GB node would otherwise size itself for the whole box and get OOM-killed
20+
while its own accounting said it had room. patchworks takes the smallest of
21+
`SLURM_MEM_PER_NODE`, `SLURM_MEM_PER_CPU × cpus`, the cgroup limit (the one
22+
that actually triggers the kill) and `psutil`'s free RAM, and reads the core
23+
count from `SLURM_CPUS_PER_TASK` or the process' CPU affinity mask.
24+
25+
Without any of those signals, a conservative default is used instead of
26+
guessing high.
1927

2028
## Live progress dashboard (GPU runs)
2129

0 commit comments

Comments
 (0)