Skip to content

Commit 0ae7f05

Browse files
lguerardclaude
andcommitted
docs: 📝 document the in-place merge, empty-chunk skipping and block sizing
Covers the second review round: the occupancy map now lives beside image.zarr and needs a tile-derived block (a block as coarse as the tile makes every tile test occupied); chunks holding no labels are skipped in both the boundary scan and the relabel, so background reaches neither disk nor memory; and the workflow stages into the label group and relabels it in place, taking three passes over the label volume down to two. The restart trade-off is stated rather than glossed: in place, a job killed mid-relabel leaves a half-relabelled array, where a separate output store left the input intact to redo from. The label group is unregistered until the merge finishes, so NGFF readers do not list a partial result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7a8c84d commit 0ae7f05

4 files changed

Lines changed: 62 additions & 15 deletions

File tree

‎docs/guide/merging.md‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,23 @@ 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+
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+
4148
### Step 2: make the ids globally unique
4249

4350
Tiles write local `1..n`, which collide, so the boundary scan could not
@@ -56,7 +63,10 @@ Only the two voxels on either side of each tile boundary are read. For any
5663
pair of touching non-zero labels `(a, b)`, they must be the same object. The
5764
per-tile offsets are applied here, on the fly.
5865

59-
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.
6070

6171
### Step 4: connected components
6272

@@ -77,6 +87,17 @@ The LUT is applied to every tile in parallel via `multiprocessing.Pool`. The
7787
LUT is shared via process initializer to avoid re-pickling it for every chunk
7888
(LUTs can be hundreds of MB for dense label volumes).
7989

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. The trade-off is restartability: a
98+
job killed part-way through leaves a half-relabelled array, where a separate
99+
output store would have left the input intact to redo from.
100+
80101
## Using the merge step standalone
81102

82103
You can call the merge step directly on any existing label array or zarr:

‎docs/guide/skip_empty.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,23 @@ that beside `image.zarr` (about 1/16384 of the image at the default 128 px
6363
block). `tile_occupancy` then reduces the map over each tile's full footprint.
6464

6565
```python
66-
from patchworks import auto_empty_threshold, build_occupancy_map, tile_occupancy
66+
from patchworks import (
67+
auto_empty_threshold, block_for_tile, build_occupancy_map, tile_occupancy
68+
)
6769

68-
build_occupancy_map("image.zarr") # once per image, all channels
70+
# Size the block from the tile, or every tile over-covers the same block and
71+
# tests occupied — correct, but useless as a skip list.
72+
build_occupancy_map("image.zarr", block=block_for_tile(TILE))
6973
info = tile_occupancy(
7074
"image.zarr", TILE, channel=0, threshold=auto_empty_threshold(img, 0, 0)
7175
)
7276
```
7377

78+
The map is stored **beside** the image (`image.occupancy.zarr`), not inside
79+
it: it is not an NGFF array, and a zarr hierarchy containing one cannot be
80+
walked. It is rebuilt automatically if an existing map was built at a
81+
different block.
82+
7483
This is **exact, not approximate**: `block_max > threshold` is true exactly
7584
when some voxel in that block exceeds the threshold, so comparing pooled
7685
maxima against a threshold derived from raw voxels answers the same question
@@ -81,6 +90,10 @@ a three-config run pays for one pooling pass instead of three sampling passes.
8190
This is what the Snakemake workflow uses, and it builds the map on first use
8291
for stores converted before the map existed.
8392

93+
Skipping a tile pays off twice over: it is never segmented, **and** the merge
94+
skips its chunk too — neither reading it nor writing zeros over it, so the
95+
background never reaches disk at all.
96+
8497
## Threshold selection
8598

8699
```python

‎docs/guide/snakemake.md‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,8 @@ result. Keep `mtime` and reruns happen only when an output is missing or stale.
242242

243243
## Running two segmentations (e.g. nuclei + cytoplasm)
244244

245-
Every path the workflow writes — `tiles.json`, `stage.zarr`, per-tile `seg/`,
246-
the cached model, `labels.done` — lives under `work_dir/<label_name>/`, so
245+
Every path the workflow writes — `tiles.json`, per-batch `seg/` markers, the
246+
cached model, `labels.done` — lives under `work_dir/<label_name>/`, so
247247
running the workflow **twice with two configs against the same `work_dir`**
248248
never collides: each run gets its own private subdirectory, and both reuse
249249
the *same* already-converted `image.zarr` (conversion never re-runs).
@@ -489,7 +489,14 @@ merged = merge_tile_labels("stage.zarr", input_component="staged",
489489
write_labels("image.zarr", merged, name="cells")
490490
```
491491

492-
The workflow goes one step further and points the merge straight at
493-
`image.zarr/labels/cells/0` (via `write_to=` + `output_component="0"`), then
494-
calls `register_labels` to add the pyramid — skipping the scratch store and
495-
the full-volume copy that `write_labels` would otherwise do.
492+
The workflow goes two steps further. It stages **into**
493+
`image.zarr/labels/cells/0` in the first place, then has the merge rewrite
494+
that array **in place** (`write_to=` and `input_component=`/
495+
`output_component=` all pointing at it) and calls `register_labels` to add
496+
the pyramid. That removes both the scratch store and the full-volume copy
497+
`write_labels` would otherwise do — three passes over the label volume become
498+
two.
499+
500+
It falls back to the separate store above when the tile is larger than the
501+
label chunk cap, because in place the chunking cannot be changed and level 0
502+
has to stay pageable for a viewer.

‎workflow/README.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,15 @@ calibrated, multi-scale `labels/` group.
2929
image), create the empty `stage.zarr`, and group the tiles into batches.
3030
3. **segment {batch}** — one GPU job per `tiles_per_job` tiles, processed
3131
sequentially so they share a single CUDA init and model load: read tile +
32-
halo, run Cellpose, trim, write to the stage. Scattered across the cluster.
33-
4. **merge** — zarr-native boundary stitch + renumber, written straight into
34-
`image.zarr/labels/<name>/0`, then pyramided.
32+
halo, run Cellpose, trim, write into `image.zarr/labels/<name>/0` (or a
33+
scratch store for oversized tiles). Scattered across the cluster.
34+
4. **merge** — zarr-native boundary stitch + renumber, applied **in place**
35+
to that array, then pyramided. Chunks holding no labels are skipped
36+
entirely, so background costs neither I/O nor disk.
37+
38+
The label group is only registered in `labels/.zattrs` once the merge
39+
finishes, so a run that dies midway leaves a group NGFF readers won't list;
40+
re-running recreates it.
3541

3642
## Install
3743

0 commit comments

Comments
 (0)