Skip to content

Commit c1fb2a9

Browse files
authored
Merge pull request #24 from imcf/feat/snakemake-workflow
feat: Snakemake workflow — per-tile GPU SLURM jobs
2 parents fc6d179 + e93292f commit c1fb2a9

27 files changed

Lines changed: 917 additions & 0 deletions

docs/guide/snakemake.md

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Cluster workflow (Snakemake + SLURM)
2+
3+
`tile_process` runs every tile **serially on one GPU**. For a large 3-D image
4+
that can be days. The bundled Snakemake workflow instead submits **one GPU job
5+
per tile**, so with *N* GPUs the segmentation is ~*N*× faster. This page walks
6+
through running it from scratch.
7+
8+
```text
9+
convert ──▶ prepare (checkpoint) ──▶ segment {tile} ──▶ merge
10+
one GPU SLURM job per tile
11+
```
12+
13+
## 1. Get the workflow
14+
15+
The workflow lives in the `workflow/` directory of the patchworks repository
16+
(it is not shipped inside the pip package — it is a set of Snakemake files you
17+
run):
18+
19+
```bash
20+
git clone https://github.com/imcf/patchworks
21+
cd patchworks/workflow
22+
```
23+
24+
## 2. Install the dependencies
25+
26+
You need patchworks with the workflow + reader + segmentation extras, in the
27+
environment Snakemake will use:
28+
29+
```bash
30+
pip install "patchworks[workflow,cellpose,imaris,bioio]"
31+
```
32+
33+
- `workflow` → Snakemake + the SLURM executor plugin
34+
- `cellpose` → the segmentation model
35+
- `imaris` / `bioio` → read your input format (`.ims`, `.czi`, `.lif`, …)
36+
37+
On a cluster, do this inside a conda/venv that the compute nodes can see, or let
38+
each rule activate a conda env (see *Conda*, below).
39+
40+
## 3. Configure the run
41+
42+
Copy and edit `config/config.yaml`. Every field:
43+
44+
```yaml
45+
# input / output
46+
input: "/data/scan.ims" # .ims/.czi/.lif/.nd2/ome-tiff/.zarr
47+
work_dir: "/scratch/results" # everything is written here
48+
49+
# conversion (input → pyramidal OME-ZARR)
50+
reuse_pyramid: true # .ims: copy its own pyramid (fast)
51+
convert_chunks: null # null → bounded auto chunks; or [c,z,y,x]
52+
shard: false # true → pack chunks into shards (fewer files)
53+
54+
# tiling
55+
channel: 0 # channel to segment (null = keep all)
56+
level: 0 # pyramid level (0 = full resolution)
57+
tile_shape: "auto" # "auto", or e.g. [16, 1024, 1024] (zyx)
58+
overlap: 30 # halo ≈ one object diameter
59+
skip_empty: true # skip background tiles
60+
empty_threshold: null # null → Otsu
61+
62+
# segmentation
63+
method: "cellpose" # "cellpose" (GPU) or "threshold" (no GPU)
64+
label_name: "cellpose" # name under image.zarr/labels/
65+
cellpose:
66+
model: "cyto3"
67+
diameter: 30
68+
do_3D: true
69+
gpu: true
70+
# extra model.eval() kwargs, e.g. flow_threshold: 0.4
71+
72+
# label pyramid
73+
pyramid_levels: 5
74+
pyramid_downscale: 2
75+
sequential_labels: true # renumber labels to a contiguous 1..N
76+
```
77+
78+
!!! tip "Tile size vs runtime"
79+
`tile_shape: "auto"` sizes each tile to your GPU's VRAM. Smaller tiles =
80+
more (faster) jobs; very large 3-D tiles are slow. Keep `do_3D: false` (2-D
81+
per slice) if your objects segment fine per slice — it is much faster.
82+
83+
## 4. Dry-run (always do this first)
84+
85+
Check the plan without running anything:
86+
87+
```bash
88+
python -m snakemake -s Snakefile --configfile config/config.yaml -n -p
89+
```
90+
91+
You should see `convert`, `prepare`, and a note that the **checkpoint** will add
92+
the `segment` jobs after `prepare` runs. (The number of segment jobs is only
93+
known after `prepare` decides which tiles are non-empty.)
94+
95+
## 5a. Run locally (single machine)
96+
97+
```bash
98+
python -m snakemake -s Snakefile --configfile config/config.yaml --cores 8
99+
```
100+
101+
Tiles run on the local machine (one at a time on the GPU). Good for a small
102+
image or a smoke test.
103+
104+
## 5b. Run on SLURM (one GPU job per tile)
105+
106+
Edit `profile/slurm/config.yaml` for **your** cluster — partitions, account,
107+
and the GPU request:
108+
109+
```yaml
110+
executor: slurm
111+
jobs: 64 # max concurrent SLURM jobs ≈ GPUs you can grab
112+
default-resources:
113+
slurm_partition: "cpu" # your CPU partition
114+
# slurm_account: "my_account"
115+
mem_mb: 16000
116+
cpus_per_task: 4
117+
runtime: 60
118+
set-resources:
119+
segment: # the GPU step
120+
slurm_partition: "gpu" # your GPU partition
121+
slurm_extra: "'--gres=gpu:1'"
122+
mem_mb: 32000
123+
runtime: 120
124+
merge:
125+
mem_mb: 128000
126+
runtime: 240
127+
```
128+
129+
Then launch (from a login node — Snakemake submits and watches the jobs):
130+
131+
```bash
132+
python -m snakemake --workflow-profile profile/slurm \
133+
--configfile config/config.yaml
134+
```
135+
136+
Snakemake submits `convert`, then `prepare`, then **one `segment` job per
137+
non-empty tile** (up to `jobs:` at once → that many GPUs in parallel), then
138+
`merge`. Raise `jobs:` to use more GPUs.
139+
140+
!!! note "GPU request flag"
141+
Clusters differ. `--gres=gpu:1` is common; some need `--gpus=1` or a
142+
specific gres name (`--gres=gpu:a100:1`). Put whatever `sbatch` flag your
143+
cluster needs in `slurm_extra`.
144+
145+
## 6. Monitor
146+
147+
- **Snakemake** prints each job as it submits/finishes and a `X of Y steps`
148+
counter.
149+
- **SLURM**: `squeue --me` shows your queued/running jobs (`smk-segment`, …);
150+
logs land where your profile/cluster sends them.
151+
- **patchworks** logs (`processing tile k/N`, ETA) are inside each job's stdout.
152+
153+
## 7. Output
154+
155+
Everything is under `work_dir`:
156+
157+
```text
158+
results/
159+
image.zarr/ # converted, pyramidal OME-ZARR
160+
image.zarr/labels/<name>/ # the segmentation (multi-scale, calibrated)
161+
```
162+
163+
The labels live **inside** the image store. View image + labels together:
164+
165+
```python
166+
from patchworks.plugins.napari import view_in_napari
167+
view_in_napari("/scratch/results/image.zarr") # auto-loads the labels
168+
```
169+
170+
## 8. Re-running and resuming
171+
172+
Snakemake is resumable — if jobs fail or you cancel, just relaunch the same
173+
command and it picks up only the missing tiles. To force a clean rerun, delete
174+
`work_dir` (or the relevant outputs).
175+
176+
## Conda (optional)
177+
178+
To have each rule run in a named conda env instead of the active one, add
179+
`--use-conda` and point the rules at an env; or activate your env in a SLURM
180+
prologue. The simplest path is a single shared env that the compute nodes see.
181+
182+
## Troubleshooting
183+
184+
| Symptom | Fix |
185+
|---------|-----|
186+
| `snakemake: command not found` | use `python -m snakemake` |
187+
| Segment jobs pend forever | wrong `slurm_partition`/`slurm_extra` GPU flag for your cluster |
188+
| `cellpose is not installed` in a job | the job's env lacks `patchworks[cellpose]` |
189+
| Reading the input fails | install the matching reader (`patchworks[imaris]`/`[bioio]` + a `bioio-*`) |
190+
| Out of GPU memory | smaller `tile_shape`, or `do_3D: false` |
191+
| Very slow | confirm GPU is used (`nvidia-smi`); try 2-D or a lower `level` |
192+
193+
## How it works (for the curious)
194+
195+
The rule scripts are thin wrappers over patchworks' public API, so you can build
196+
the same per-tile distribution yourself:
197+
198+
```python
199+
from patchworks import (
200+
load_ome_zarr, spatial_tiles, create_stage, stage_tile, merge_tile_labels
201+
)
202+
from patchworks.plugins.ome_zarr import write_labels
203+
204+
img = load_ome_zarr("image.zarr", channel=0)
205+
tiles = spatial_tiles(img.shape, tile_shape=(16, 1024, 1024))
206+
create_stage("stage.zarr", img.shape, (16, 1024, 1024))
207+
# (distribute these across jobs:)
208+
for i in range(len(tiles)):
209+
stage_tile(img, my_fn, "stage.zarr", i, tile_shape=(16, 1024, 1024), overlap=30)
210+
merged = merge_tile_labels("stage.zarr", input_component="staged",
211+
write_to="merged.zarr", sequential_labels=True)
212+
write_labels("image.zarr", merged, name="cells")
213+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ nav:
3939
- Empty tile skipping: guide/skip_empty.md
4040
- GPU & distributed: guide/gpu_distributed.md
4141
- Performance & memory: guide/performance.md
42+
- Cluster workflow (Snakemake): guide/snakemake.md
4243
- OME-ZARR & napari: guide/ome_zarr_napari.md
4344
- Pitfalls: guide/pitfalls.md
4445
- Examples:

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ bioio = [
5858
imaris = ["imaris-ims-file-reader"]
5959
# napari enables the interactive viewer plugin.
6060
napari = ["napari[all]"]
61+
# workflow runs the Snakemake pipeline (per-tile SLURM jobs across GPUs).
62+
workflow = ["snakemake>=8", "snakemake-executor-plugin-slurm"]
6163
dev = ["pytest", "pytest-cov", "scikit-image", "psutil", "tqdm"]
6264
docs = ["mkdocs-material>=9.0", "mkdocstrings[python]>=0.24"]
6365
all = [

src/patchworks/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ._chunks import auto_overlap, auto_tile_shape, auto_tile_shape_cellpose
3333
from ._cluster import make_local_cluster
3434
from ._core import tile_process
35+
from ._distributed import create_stage, spatial_tiles, stage_tile
3536
from ._io import estimate_empty_tiles, load_ome_zarr
3637
from ._merge import merge_tile_labels
3738
from ._relabel import relabel_sequential_array, relabel_sequential_zarr
@@ -51,4 +52,7 @@
5152
"make_local_cluster",
5253
"relabel_sequential_array",
5354
"relabel_sequential_zarr",
55+
"spatial_tiles",
56+
"create_stage",
57+
"stage_tile",
5458
]

src/patchworks/_distributed.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Per-tile building blocks for distributed processing.
2+
3+
``tile_process`` runs every tile and merges in one process. To spread tiles
4+
across separate jobs (e.g. one SLURM GPU job per tile) you need to process a
5+
*single* tile independently and merge later. These helpers expose exactly that:
6+
:func:`spatial_tiles` enumerates the tiles, :func:`create_stage` makes the
7+
shared output store, and :func:`stage_tile` runs ``fn`` on one tile and writes
8+
it into that store. Stitch the result with
9+
:func:`patchworks.merge_tile_labels` (or ``zarr_native_merge``).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import itertools
15+
from pathlib import Path
16+
from typing import Callable, Union
17+
18+
import numpy as np
19+
import zarr
20+
21+
22+
def spatial_tiles(
23+
shape: tuple[int, ...], tile_shape: tuple[int, ...]
24+
) -> list[tuple[slice, ...]]:
25+
"""Enumerate the tiles covering *shape*, in row-major order.
26+
27+
Parameters
28+
----------
29+
shape : tuple of int
30+
Spatial array shape.
31+
tile_shape : tuple of int
32+
Tile shape.
33+
34+
Returns
35+
-------
36+
list of tuple of slice
37+
One slice tuple per tile (the same order ``estimate_empty_tiles``'s
38+
``occupancy`` grid uses when ravelled).
39+
"""
40+
grids = [range(0, s, t) for s, t in zip(shape, tile_shape)]
41+
return [
42+
tuple(
43+
slice(o, min(o + t, s))
44+
for o, t, s in zip(starts, tile_shape, shape)
45+
)
46+
for starts in itertools.product(*grids)
47+
]
48+
49+
50+
def create_stage(
51+
stage_path: Union[str, Path],
52+
shape: tuple[int, ...],
53+
tile_shape: tuple[int, ...],
54+
*,
55+
component: str = "staged",
56+
dtype=np.int32,
57+
) -> str:
58+
"""Create the empty (zero-filled) shared stage store for tiled writes.
59+
60+
Parameters
61+
----------
62+
stage_path : str or Path
63+
Destination ``.zarr`` store.
64+
shape : tuple of int
65+
Full (spatial) array shape.
66+
tile_shape : tuple of int
67+
Chunk = tile shape (one chunk per tile, so jobs write disjoint files).
68+
component : str, optional
69+
Array name inside the store (default ``"staged"``).
70+
dtype : data-type, optional
71+
Label dtype (default ``int32``).
72+
73+
Returns
74+
-------
75+
str
76+
The stage store path.
77+
"""
78+
root = zarr.open_group(str(stage_path), mode="w")
79+
root.create_array(
80+
name=component, shape=shape, chunks=tile_shape, dtype=dtype
81+
)
82+
return str(stage_path)
83+
84+
85+
def stage_tile(
86+
image,
87+
fn: Callable[[np.ndarray], np.ndarray],
88+
stage_path: Union[str, Path],
89+
index: int,
90+
*,
91+
tile_shape: tuple[int, ...],
92+
overlap: int = 0,
93+
component: str = "staged",
94+
) -> int:
95+
"""Run *fn* on a single tile and write it into the shared stage store.
96+
97+
Reads the tile (expanded by *overlap* on every side for boundary context),
98+
runs *fn*, trims the halo back off, and writes the result to the tile's
99+
disjoint chunk of ``stage_path/component`` — so many of these can run
100+
concurrently (one per job) without conflicts.
101+
102+
Parameters
103+
----------
104+
image : array-like
105+
The full image (dask/zarr/NumPy), indexable by slices.
106+
fn : callable
107+
``(ndarray) -> ndarray`` returning integer labels of the same shape.
108+
stage_path : str or Path
109+
Stage store created by :func:`create_stage`.
110+
index : int
111+
Tile index into :func:`spatial_tiles`.
112+
tile_shape : tuple of int
113+
Tile shape (must match the stage store's chunks).
114+
overlap : int, optional
115+
Halo added on every side before calling *fn*.
116+
component : str, optional
117+
Array name inside the stage store.
118+
119+
Returns
120+
-------
121+
int
122+
The processed tile *index*.
123+
"""
124+
shape = image.shape
125+
sl = spatial_tiles(shape, tile_shape)[index]
126+
expanded, trims = [], []
127+
for s, dim in zip(sl, shape):
128+
lo = max(0, s.start - overlap)
129+
hi = min(dim, s.stop + overlap)
130+
expanded.append(slice(lo, hi))
131+
trims.append((s.start - lo, hi - s.stop))
132+
block = np.asarray(image[tuple(expanded)])
133+
out = np.asarray(fn(block))
134+
sel = tuple(
135+
slice(left, out.shape[i] - right)
136+
for i, (left, right) in enumerate(trims)
137+
)
138+
dst = zarr.open_group(str(stage_path), mode="r+")[component]
139+
dst[sl] = out[sel].astype(dst.dtype)
140+
return index

0 commit comments

Comments
 (0)