patchworks 1.4.3__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {patchworks-1.4.3 → patchworks-2.0.0}/PKG-INFO +5 -2
  2. {patchworks-1.4.3 → patchworks-2.0.0}/README.md +4 -1
  3. patchworks-2.0.0/docs/api/chunks.md +19 -0
  4. patchworks-2.0.0/docs/api/io.md +19 -0
  5. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/plugins/ome_zarr.md +12 -0
  6. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/custom_segmentation.md +15 -0
  7. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/gpu_distributed.md +25 -2
  8. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/merging.md +64 -12
  9. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/ome_zarr_napari.md +32 -3
  10. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/performance.md +12 -4
  11. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/skip_empty.md +47 -6
  12. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/snakemake.md +95 -27
  13. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/tiling.md +29 -0
  14. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/__init__.py +30 -4
  15. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_chunks.py +138 -18
  16. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_cluster.py +3 -2
  17. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_core.py +9 -8
  18. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_distributed.py +68 -13
  19. patchworks-2.0.0/src/patchworks/_gpu.py +158 -0
  20. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_io.py +37 -3
  21. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_merge.py +415 -67
  22. patchworks-2.0.0/src/patchworks/_occupancy.py +400 -0
  23. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_relations.py +3 -2
  24. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/plugins/cellpose.py +86 -6
  25. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/plugins/dog.py +45 -9
  26. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/plugins/napari.py +4 -3
  27. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/plugins/ome_zarr.py +232 -25
  28. patchworks-2.0.0/tests/test_allocation.py +78 -0
  29. {patchworks-1.4.3 → patchworks-2.0.0}/tests/test_core.py +30 -0
  30. patchworks-2.0.0/tests/test_distributed.py +354 -0
  31. patchworks-2.0.0/tests/test_gpu.py +93 -0
  32. patchworks-2.0.0/tests/test_occupancy.py +234 -0
  33. {patchworks-1.4.3 → patchworks-2.0.0}/tests/test_ome_zarr.py +125 -4
  34. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/README.md +22 -7
  35. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/config/config.yaml +17 -4
  36. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/config/config_cilia.yaml +10 -1
  37. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/config/config_cyto.yaml +7 -1
  38. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/config/config_nuclei.yaml +7 -1
  39. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/profile/slurm/config.yaml +29 -11
  40. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/rules/common.smk +9 -4
  41. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/rules/merge.smk +2 -1
  42. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/rules/segment.smk +9 -3
  43. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/scripts/_pw.py +130 -1
  44. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/scripts/convert.py +10 -0
  45. patchworks-2.0.0/workflow/scripts/merge.py +120 -0
  46. patchworks-2.0.0/workflow/scripts/prepare_tiles.py +187 -0
  47. patchworks-2.0.0/workflow/scripts/run_multi.py +350 -0
  48. patchworks-2.0.0/workflow/scripts/segment_tile.py +55 -0
  49. patchworks-1.4.3/docs/api/chunks.md +0 -5
  50. patchworks-1.4.3/docs/api/io.md +0 -5
  51. patchworks-1.4.3/tests/test_distributed.py +0 -75
  52. patchworks-1.4.3/workflow/scripts/merge.py +0 -58
  53. patchworks-1.4.3/workflow/scripts/prepare_tiles.py +0 -64
  54. patchworks-1.4.3/workflow/scripts/run_multi.py +0 -194
  55. patchworks-1.4.3/workflow/scripts/segment_tile.py +0 -30
  56. {patchworks-1.4.3 → patchworks-2.0.0}/.github/workflows/docs.yml +0 -0
  57. {patchworks-1.4.3 → patchworks-2.0.0}/.github/workflows/lint.yml +0 -0
  58. {patchworks-1.4.3 → patchworks-2.0.0}/.github/workflows/release.yml +0 -0
  59. {patchworks-1.4.3 → patchworks-2.0.0}/.gitignore +0 -0
  60. {patchworks-1.4.3 → patchworks-2.0.0}/.markdownlint-cli2.yaml +0 -0
  61. {patchworks-1.4.3 → patchworks-2.0.0}/LICENSE +0 -0
  62. {patchworks-1.4.3 → patchworks-2.0.0}/cliff.toml +0 -0
  63. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/cluster.md +0 -0
  64. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/merge_tile_labels.md +0 -0
  65. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/plugins/cellpose.md +0 -0
  66. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/plugins/dog.md +0 -0
  67. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/plugins/napari.md +0 -0
  68. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/postprocess.md +0 -0
  69. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/relabel.md +0 -0
  70. {patchworks-1.4.3 → patchworks-2.0.0}/docs/api/tile_process.md +0 -0
  71. {patchworks-1.4.3 → patchworks-2.0.0}/docs/assets/logo.png +0 -0
  72. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/cellpose_2d.md +0 -0
  73. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/cellpose_2d.py +0 -0
  74. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/cellpose_3d.md +0 -0
  75. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/cellpose_3d.py +0 -0
  76. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/custom.md +0 -0
  77. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/custom_method.py +0 -0
  78. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/dog.md +0 -0
  79. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/dog.py +0 -0
  80. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/standalone_merge.md +0 -0
  81. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/stardist.md +0 -0
  82. {patchworks-1.4.3 → patchworks-2.0.0}/docs/examples/stardist_2d.py +0 -0
  83. {patchworks-1.4.3 → patchworks-2.0.0}/docs/getting_started.md +0 -0
  84. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/label_relations.md +0 -0
  85. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/measurements.md +0 -0
  86. {patchworks-1.4.3 → patchworks-2.0.0}/docs/guide/pitfalls.md +0 -0
  87. {patchworks-1.4.3 → patchworks-2.0.0}/docs/index.md +0 -0
  88. {patchworks-1.4.3 → patchworks-2.0.0}/mkdocs.yml +0 -0
  89. {patchworks-1.4.3 → patchworks-2.0.0}/pyproject.toml +0 -0
  90. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_postprocess.py +0 -0
  91. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/_relabel.py +0 -0
  92. {patchworks-1.4.3 → patchworks-2.0.0}/src/patchworks/plugins/__init__.py +0 -0
  93. {patchworks-1.4.3 → patchworks-2.0.0}/tests/test_dog.py +0 -0
  94. {patchworks-1.4.3 → patchworks-2.0.0}/tests/test_napari.py +0 -0
  95. {patchworks-1.4.3 → patchworks-2.0.0}/tests/test_postprocess.py +0 -0
  96. {patchworks-1.4.3 → patchworks-2.0.0}/tests/test_relations.py +0 -0
  97. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/Snakefile +0 -0
  98. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/config/multi.yaml +0 -0
  99. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/pixi.toml +0 -0
  100. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/rules/convert.smk +0 -0
  101. {patchworks-1.4.3 → patchworks-2.0.0}/workflow/scripts/fetch_model.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 1.4.3
3
+ Version: 2.0.0
4
4
  Summary: Tiled processing of arbitrarily large images with globally consistent labels
5
5
  Project-URL: Homepage, https://github.com/imcf/patchworks
6
6
  Project-URL: Issues, https://github.com/imcf/patchworks/issues
@@ -376,9 +376,12 @@ tiles where the dask-image approach stalls.
376
376
  | ------------------------------ | ----------------------------------------- | ------------------------------------------------------------- |
377
377
  | In-process Dask client | `FutureCancelledError: lost dependencies` | Detected at startup, raises immediately with fix instructions |
378
378
  | 3-4× fn recompute during merge | Cellpose runs 3× per tile | Staging writes labels once, merge reads from disk |
379
- | O(n²) sequential relabelling | Graph construction hangs at 1000+ tiles | Linear post-pass O(voxels) via `np.unique` + LUT |
379
+ | O(n²) sequential relabelling | Graph construction hangs at 1000+ tiles | Folded into the merge's own LUT — no extra pass over the volume |
380
380
  | Wrong overlap boundary | Output shape mismatch | Always uses `boundary="none"` |
381
381
  | Persisting large arrays | Worker OOM | Never persists; keeps dask graph lazy and streams |
382
+ | Sizing work to the whole node | Job OOM-killed on a shared cluster node | Reads the SLURM/cgroup allocation, not `os.cpu_count()` |
383
+ | Rechunking a pyramid level | Threaded scheduler stockpiles intermediates | Levels stream one source chunk per task, bounded by construction |
384
+ | Isotropic halo on flat tiles | 5× the voxels read and segmented, then trimmed | `overlap` takes one width per axis |
382
385
 
383
386
  ---
384
387
 
@@ -286,9 +286,12 @@ tiles where the dask-image approach stalls.
286
286
  | ------------------------------ | ----------------------------------------- | ------------------------------------------------------------- |
287
287
  | In-process Dask client | `FutureCancelledError: lost dependencies` | Detected at startup, raises immediately with fix instructions |
288
288
  | 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 |
290
290
  | Wrong overlap boundary | Output shape mismatch | Always uses `boundary="none"` |
291
291
  | 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 |
292
295
 
293
296
  ---
294
297
 
@@ -0,0 +1,19 @@
1
+ # Tile sizing
2
+
3
+ ::: patchworks.auto_tile_shape
4
+
5
+ ::: 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
@@ -0,0 +1,19 @@
1
+ # I/O helpers
2
+
3
+ ::: patchworks.load_ome_zarr
4
+
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
+
13
+ ::: patchworks.estimate_empty_tiles
14
+
15
+ ::: patchworks.build_occupancy_map
16
+
17
+ ::: patchworks.tile_occupancy
18
+
19
+ ::: patchworks.auto_empty_threshold
@@ -24,3 +24,15 @@ resolution, matching anisotropic microscopy stacks.
24
24
  ## register_labels
25
25
 
26
26
  ::: 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
@@ -118,6 +118,18 @@ def segment(tile: np.ndarray, prob_thresh: float = 0.5) -> np.ndarray:
118
118
 
119
119
  Using a GPU? Just let your framework see it — nothing extra needed.
120
120
 
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
+
121
133
  ## Test it before you submit
122
134
 
123
135
  Run your function on one real tile first — it catches shape/dtype bugs in
@@ -148,6 +160,9 @@ custom:
148
160
  module: "my_seg" # import name
149
161
  function: "segment" # default is "segment"
150
162
  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
151
166
  sigma: 1.5
152
167
  ```
153
168
 
@@ -73,8 +73,16 @@ FutureCancelledError: lost dependencies
73
73
  ## GPU memory sizing
74
74
 
75
75
  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.
78
86
 
79
87
  Without `nvidia-ml-py`, a conservative 8 GiB default is used with a warning.
80
88
  Install it for accurate sizing on large-VRAM cards (A100, H100):
@@ -82,3 +90,18 @@ Install it for accurate sizing on large-VRAM cards (A100, H100):
82
90
  ```bash
83
91
  pip install "patchworks[gpu]"
84
92
  ```
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.
@@ -28,9 +28,9 @@ This is the same approach used by
28
28
 
29
29
  ### Step 1: stage
30
30
 
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.
34
34
 
35
35
  ```text
36
36
  tile_process calls fn once per tile → staged zarr
@@ -38,14 +38,37 @@ tile_process calls fn once per tile → staged zarr
38
38
  merge reads from staged zarr (no fn calls)
39
39
  ```
40
40
 
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
42
61
 
43
62
  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.
45
65
 
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.
47
70
 
48
- ### Step 3: connected components
71
+ ### Step 4: connected components
49
72
 
50
73
  scipy sparse connected components on the touching pairs produces a relabeling
51
74
  lookup table. All labels that transitively touch each other are mapped to the
@@ -53,12 +76,38 @@ same canonical label.
53
76
 
54
77
  Cost: `O(n_touching_pairs)`.
55
78
 
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
57
85
 
58
86
  The LUT is applied to every tile in parallel via `multiprocessing.Pool`. The
59
87
  LUT is shared via process initializer to avoid re-pickling it for every chunk
60
88
  (LUTs can be hundreds of MB for dense label volumes).
61
89
 
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
+
62
111
  ## Using the merge step standalone
63
112
 
64
113
  You can call the merge step directly on any existing label array or zarr:
@@ -86,9 +135,10 @@ merged = merge_tile_labels(
86
135
 
87
136
  ## Sequential label numbering
88
137
 
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.
92
142
 
93
143
  For contiguous 1..N numbering, use `sequential_labels=True`:
94
144
 
@@ -96,7 +146,9 @@ For contiguous 1..N numbering, use `sequential_labels=True`:
96
146
  tile_process("image.zarr", fn, write_to="labels.zarr", sequential_labels=True)
97
147
  ```
98
148
 
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.
100
152
 
101
153
  !!! warning "Do not use dask's built-in sequential relabel"
102
154
  `dask_image.ndmeasure.merge_labels_across_chunk_boundaries` has a
@@ -22,9 +22,23 @@ natively. Pass `write_to="other.zarr"` to instead write a separate
22
22
  single-resolution label store, or `output_component="cells"` to name the label
23
23
  image.
24
24
 
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.
28
42
 
29
43
  ## Why a pyramid?
30
44
 
@@ -107,6 +121,21 @@ input — see [Cluster usage](snakemake.md).
107
121
  to_ome_zarr("scan.ims", "scan.zarr", reuse_pyramid=True)
108
122
  ```
109
123
 
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
+
110
139
  ### Pixel calibration
111
140
 
112
141
  The physical voxel size is read from the input — bioio's `physical_pixel_sizes`,
@@ -3,10 +3,10 @@
3
3
  `tile_process` is built so a run **adapts to whatever machine it lands on** and
4
4
  can't run out of RAM/VRAM or freeze the box — without you tuning anything.
5
5
 
6
- ## Automatic, machine-aware concurrency
6
+ ## Automatic, allocation-aware concurrency
7
7
 
8
8
  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:
10
10
 
11
11
  - **GPU** (`use_gpu=True`) → **one tile at a time**, so concurrent evaluations
12
12
  can never exhaust VRAM.
@@ -14,8 +14,16 @@ merge step are sized to the host automatically:
14
14
  from the tile size), and always **leaving one core free** so the machine
15
15
  stays responsive — it never pins every core.
16
16
 
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.
19
27
 
20
28
  ## Live progress dashboard (GPU runs)
21
29
 
@@ -37,7 +37,9 @@ tile_process(
37
37
  )
38
38
  ```
39
39
 
40
- ## How `estimate_empty_tiles` works
40
+ ## Two ways to decide
41
+
42
+ ### `estimate_empty_tiles` — a fast preview
41
43
 
42
44
  For each tile in the grid, only a small centred **sample window** is read
43
45
  (default: 24×256×256 voxels). If the maximum value in that window exceeds
@@ -47,11 +49,50 @@ This is **bounded I/O**: the total data read is `n_tiles × sample_window`,
47
49
  not the full image. For a 2200-tile image with the default window, this reads
48
50
  ≈ 30 MB instead of 250 GB — and it runs in seconds.
49
51
 
50
- !!! warning "Approximate"
51
- `estimate_empty_tiles` inspects only the tile centre. Signal confined to
52
- a tile's edge can be missed. The actual `tile_process` run always inspects
53
- the **full tile** max inline — so no objects are ever dropped in the real
54
- run, only in the preview.
52
+ !!! warning "Approximate — a preview, not a skip list"
53
+ Only the tile centre is inspected. On a `(16, 1024, 1024)` tile the
54
+ default window covers **6.25% of the tile's area**, so an object in the
55
+ outer ring is invisible. `tile_process` re-tests the **full tile** max
56
+ inline, so nothing is dropped in a real run — but anything that uses this
57
+ result *as* the skip list would drop those tiles for good.
58
+
59
+ ### `build_occupancy_map` + `tile_occupancy` — exact
60
+
61
+ Reduces every `block`-sized brick of the image to its **maximum** and stores
62
+ that beside `image.zarr` (about 1/16384 of the image at the default 128 px
63
+ block). `tile_occupancy` then reduces the map over each tile's full footprint.
64
+
65
+ ```python
66
+ from patchworks import (
67
+ auto_empty_threshold, block_for_tile, build_occupancy_map, tile_occupancy
68
+ )
69
+
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))
73
+ info = tile_occupancy(
74
+ "image.zarr", TILE, channel=0, threshold=auto_empty_threshold(img, 0, 0)
75
+ )
76
+ ```
77
+
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
+
83
+ This is **exact, not approximate**: `block_max > threshold` is true exactly
84
+ when some voxel in that block exceeds the threshold, so comparing pooled
85
+ maxima against a threshold derived from raw voxels answers the same question
86
+ as scanning every voxel. Max-pooling cannot lose a bright voxel.
87
+
88
+ The map is built once and shared by every segmentation reading that image, so
89
+ a three-config run pays for one pooling pass instead of three sampling passes.
90
+ This is what the Snakemake workflow uses, and it builds the map on first use
91
+ for stores converted before the map existed.
92
+
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.
55
96
 
56
97
  ## Threshold selection
57
98
 
@@ -57,9 +57,10 @@ channel: 0 # channel to segment (null = keep all)
57
57
  level: 0 # pyramid level (0 = full resolution)
58
58
  tile_shape: "auto" # "auto", or e.g. [16, 1024, 1024] (zyx)
59
59
  gpu_memory_gb: null # for "auto" on SLURM: your segment GPU's VRAM
60
- overlap: 30 # halo ≈ one object diameter
60
+ overlap: [4, 30, 30] # halo ≈ one object diameter; scalar or [z,y,x]
61
61
  skip_empty: true # skip background tiles
62
62
  empty_threshold: null # null → Otsu
63
+ tiles_per_job: 4 # tiles per SLURM job (sequential, shared model)
63
64
 
64
65
  # segmentation
65
66
  method: "cellpose" # "cellpose" (GPU), "threshold" (no GPU), "custom"
@@ -102,6 +103,29 @@ sequential_labels: true # renumber labels to a contiguous 1..N
102
103
  8 GiB. Harmless, but to size for the real GPU set `gpu_memory_gb:` to its
103
104
  VRAM (e.g. `24`, `40`, `80`) — or just set `tile_shape` explicitly.
104
105
 
106
+ With `do_3D: true`, `"auto"` pins z to the **full** extent: no z tiling,
107
+ no z-boundary stitching, and Cellpose sees each object's whole depth. An
108
+ explicit z (like `[16, 1024, 1024]`) tiles in z instead. `prepare` logs
109
+ which regime it picked.
110
+
111
+ !!! tip "Use a per-axis `overlap`"
112
+ A scalar halo is applied to every axis. On a `[16, 1024, 1024]` tile,
113
+ `overlap: 30` reads `76 × 1084 × 1084` to keep `16 × 1024 × 1024` — 5.3×
114
+ the voxels it uses, nearly all of it wasted z. `[4, 30, 30]` brings that
115
+ to ~1.7×, i.e. roughly **3× less GPU time for identical results**.
116
+ `prepare` logs the amplification and refuses a halo wider than the tile.
117
+
118
+ !!! tip "Batch tiles per job with `tiles_per_job`"
119
+ Each job pays CUDA init plus a Cellpose weight load — tens of seconds —
120
+ before its first voxel. `tiles_per_job: N` segments N tiles sequentially
121
+ in one process, sharing one loaded model, which is most of the win on
122
+ fast tiles.
123
+
124
+ Tiles in a batch run **sequentially**, so job wall time is roughly
125
+ `N × per-tile time` and must stay inside the profile's `runtime`/QOS
126
+ ceiling. A retry re-runs the whole batch. Measure one tile with
127
+ `seff <jobid>` and size N from that; `1` restores one job per tile.
128
+
105
129
  ## 4. Dry-run (always do this first)
106
130
 
107
131
  Check the plan without running anything:
@@ -126,7 +150,7 @@ image or a smoke test. `--rerun-triggers mtime` re-runs only steps whose output
126
150
  is missing/stale — so upgrading patchworks won't redo the conversion (the SLURM
127
151
  profile sets this for you).
128
152
 
129
- ## 5b. Run on SLURM (one GPU job per tile)
153
+ ## 5b. Run on SLURM (one GPU job per batch of tiles)
130
154
 
131
155
  Edit `profile/slurm/config.yaml` for **your** cluster — partitions, account,
132
156
  and the GPU request:
@@ -140,14 +164,15 @@ default-resources:
140
164
  mem_mb: 16000
141
165
  cpus_per_task: 4
142
166
  runtime: 60
167
+ retries: 2 # resubmit a failed job, asking for more memory
143
168
  set-resources:
144
169
  segment: # the GPU step
145
170
  slurm_partition: "gpu" # your GPU partition
146
171
  slurm_extra: "'--gres=gpu:1'"
147
- mem_mb: 32000
172
+ mem_mb: "attempt * 32000" # grows on each retry
148
173
  runtime: 120
149
174
  merge:
150
- mem_mb: 128000
175
+ mem_mb: "attempt * 64000"
151
176
  runtime: 240
152
177
  ```
153
178
 
@@ -159,8 +184,14 @@ python -m snakemake --workflow-profile profile/slurm \
159
184
  ```
160
185
 
161
186
  Snakemake submits `convert`, then `prepare`, then **one `segment` job per
162
- non-empty tile** (up to `jobs:` at once → that many GPUs in parallel), then
163
- `merge`. Raise `jobs:` to use more GPUs.
187
+ batch of `tiles_per_job` non-empty tiles** (up to `jobs:` at once → that many
188
+ GPUs in parallel), then `merge`. Raise `jobs:` to use more GPUs.
189
+
190
+ !!! tip "Sizing memory"
191
+ Every step now sizes its own worker counts from what SLURM actually
192
+ granted (`SLURM_CPUS_PER_TASK`, `SLURM_MEM_PER_*`, the cgroup limit)
193
+ rather than the node's totals, and `merge` logs the budget it detected.
194
+ Compare that line against `seff <jobid>` when tuning `mem_mb`.
164
195
 
165
196
  !!! note "GPU request flag"
166
197
  Clusters differ. `--gres=gpu:1` is common; some need `--gpus=1` or a
@@ -211,8 +242,8 @@ result. Keep `mtime` and reruns happen only when an output is missing or stale.
211
242
 
212
243
  ## Running two segmentations (e.g. nuclei + cytoplasm)
213
244
 
214
- Every path the workflow writes — `tiles.json`, `stage.zarr`, per-tile `seg/`,
215
- 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
216
247
  running the workflow **twice with two configs against the same `work_dir`**
217
248
  never collides: each run gets its own private subdirectory, and both reuse
218
249
  the *same* already-converted `image.zarr` (conversion never re-runs).
@@ -243,18 +274,22 @@ cellpose:
243
274
  do_3D: true
244
275
  ```
245
276
 
246
- Run them one after another (or as two independent SLURM submissions, even
247
- concurrently they touch disjoint files):
277
+ Run them as two independent SLURM submissions — they touch disjoint files, so
278
+ they can run concurrently. Give each its own `--directory`, because
279
+ Snakemake's lock lives in the working directory, not in the config:
248
280
 
249
281
  ```bash
250
- snakemake --workflow-profile profile/slurm --configfile config/config_nuclei.yaml
251
- snakemake --workflow-profile profile/slurm --configfile config/config_cyto.yaml
282
+ snakemake --workflow-profile profile/slurm --configfile config/config_nuclei.yaml \
283
+ --directory /scratch/results/nuclei_labels/.snakemake
284
+ snakemake --workflow-profile profile/slurm --configfile config/config_cyto.yaml \
285
+ --directory /scratch/results/cyto_labels/.snakemake
252
286
  ```
253
287
 
254
288
  !!! tip "One command for several segmentations + relations"
255
289
  `config/multi.yaml` lists any number of segmentation configs plus which
256
- pairs to relate afterward; `pixi run multi` (or `multi-slurm`) runs them
257
- in order and writes a CSV per pair see *One command: multiple
290
+ pairs to relate afterward; `pixi run multi` (or `multi-slurm`) converts
291
+ once, then runs every config **concurrently** with its own state
292
+ directory, and writes a workbook per pair — see *One command: multiple
258
293
  segmentations + relations* below for the config format.
259
294
 
260
295
  Both land side by side in the same store:
@@ -276,10 +311,10 @@ workflow's own automation is below.
276
311
 
277
312
  ### One command: multiple segmentations + relations
278
313
 
279
- `scripts/run_multi.py` (wired up as `pixi run multi`) sequences the above
280
- manually: run every segmentation config listed, then compute + save every
281
- configured relation — one command instead of juggling several `snakemake`
282
- calls and a separate Python step.
314
+ `scripts/run_multi.py` (wired up as `pixi run multi`) drives the above: it
315
+ converts once, runs every segmentation config **concurrently**, then computes
316
+ and saves every configured relation — one command instead of juggling several
317
+ `snakemake` calls and a separate Python step.
283
318
 
284
319
  ```yaml
285
320
  # config/multi.yaml
@@ -299,10 +334,22 @@ pixi run multi # run locally
299
334
  pixi run multi-slurm # submit every segmentation to SLURM
300
335
  ```
301
336
 
302
- Every listed segmentation config must share the same `work_dir` (so
303
- `label_relations` has one `image.zarr` to read both label groups from) — the
304
- script checks this and errors out otherwise. `relations` is optional; omit it
305
- to just chain segmentations without a relation step.
337
+ Before anything is submitted, the script checks that every listed config
338
+ shares one `work_dir` (so `label_relations` has a single `image.zarr` to read
339
+ both label groups from), that `tile_shape` and `level` are identical (so the
340
+ label arrays share a chunk layout), and that `label_name` is unique — two
341
+ configs sharing one would silently overwrite each other's outputs. `relations`
342
+ is optional; omit it to just run segmentations without a relation step.
343
+
344
+ The configs then run at the same time, each with its own state directory, so
345
+ the GPU partition stays busy instead of idling through every config's
346
+ `prepare` and multi-hour `merge` in turn. A config that fails does **not**
347
+ abort the others; you get a per-config status and a non-zero exit.
348
+
349
+ !!! warning "`jobs:` is per config"
350
+ The profile's `jobs:` caps one Snakemake process. Running three configs
351
+ concurrently can therefore have 3× that many jobs in flight — lower it if
352
+ that would exceed your cluster quota.
306
353
 
307
354
  Each `output:` is an Excel workbook (`openpyxl`, part of the `workflow`
308
355
  extra) with two sheets:
@@ -422,13 +469,34 @@ from patchworks import (
422
469
  )
423
470
  from patchworks.plugins.ome_zarr import write_labels
424
471
 
472
+ TILE = (16, 1024, 1024)
425
473
  img = load_ome_zarr("image.zarr", channel=0)
426
- tiles = spatial_tiles(img.shape, tile_shape=(16, 1024, 1024))
427
- create_stage("stage.zarr", img.shape, (16, 1024, 1024))
428
- # (distribute these across jobs:)
474
+ tiles = spatial_tiles(img.shape, tile_shape=TILE)
475
+ create_stage("stage.zarr", img.shape, TILE)
476
+
477
+ # (distribute these across jobs:) each returns how many labels it wrote
478
+ counts = {}
429
479
  for i in range(len(tiles)):
430
- stage_tile(img, my_fn, "stage.zarr", i, tile_shape=(16, 1024, 1024), overlap=30)
480
+ counts[i] = stage_tile(
481
+ img, my_fn, "stage.zarr", i, tile_shape=TILE, overlap=[4, 30, 30]
482
+ )
483
+
484
+ # Handing those counts to the merge lets it derive each tile's global id range
485
+ # by a cumulative sum, instead of streaming the whole store to renumber it.
431
486
  merged = merge_tile_labels("stage.zarr", input_component="staged",
432
- write_to="merged.zarr", sequential_labels=True)
487
+ write_to="merged.zarr", sequential_labels=True,
488
+ label_counts=counts)
433
489
  write_labels("image.zarr", merged, name="cells")
434
490
  ```
491
+
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.