patchworks 0.11.17__tar.gz → 0.11.19__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.
- {patchworks-0.11.17 → patchworks-0.11.19}/PKG-INFO +3 -3
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/snakemake.md +166 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/pyproject.toml +4 -3
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/__init__.py +2 -0
- patchworks-0.11.19/src/patchworks/_relations.py +148 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/plugins/napari.py +43 -12
- patchworks-0.11.19/tests/test_relations.py +43 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/README.md +20 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/Snakefile +1 -1
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/config/config.yaml +4 -0
- patchworks-0.11.19/workflow/config/config_cyto.yaml +33 -0
- patchworks-0.11.19/workflow/config/config_nuclei.yaml +33 -0
- patchworks-0.11.19/workflow/config/multi.yaml +21 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/pixi.toml +14 -3
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/rules/common.smk +16 -5
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/rules/merge.smk +1 -1
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/rules/segment.smk +1 -1
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/_pw.py +10 -4
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/merge.py +9 -6
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/prepare_tiles.py +4 -2
- patchworks-0.11.19/workflow/scripts/run_multi.py +160 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/segment_tile.py +2 -1
- {patchworks-0.11.17 → patchworks-0.11.19}/.github/workflows/docs.yml +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/.github/workflows/lint.yml +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/.github/workflows/release.yml +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/.gitignore +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/.markdownlint-cli2.yaml +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/LICENSE +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/README.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/cliff.toml +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/chunks.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/cluster.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/io.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/plugins/napari.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/relabel.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/tile_process.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/assets/logo.png +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/custom.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/custom_method.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/standalone_merge.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/stardist.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/stardist_2d.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/getting_started.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/merging.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/performance.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/pitfalls.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/skip_empty.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/tiling.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/docs/index.md +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/mkdocs.yml +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_chunks.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_cluster.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_core.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_distributed.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_io.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_merge.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_relabel.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/plugins/ome_zarr.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/tests/test_core.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/tests/test_distributed.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/tests/test_napari.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/tests/test_ome_zarr.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/rules/convert.smk +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/convert.py +0 -0
- {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/fetch_model.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchworks
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.19
|
|
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
|
|
@@ -34,7 +34,7 @@ Requires-Dist: glasbey; extra == 'all'
|
|
|
34
34
|
Requires-Dist: imaris-ims-file-reader; extra == 'all'
|
|
35
35
|
Requires-Dist: ipykernel<7; extra == 'all'
|
|
36
36
|
Requires-Dist: lxml-html-clean; extra == 'all'
|
|
37
|
-
Requires-Dist: napari[all]>=0.
|
|
37
|
+
Requires-Dist: napari[all]>=0.7.1; extra == 'all'
|
|
38
38
|
Requires-Dist: numpy<2.5; extra == 'all'
|
|
39
39
|
Requires-Dist: nvidia-ml-py; extra == 'all'
|
|
40
40
|
Requires-Dist: psutil; extra == 'all'
|
|
@@ -74,7 +74,7 @@ Provides-Extra: napari
|
|
|
74
74
|
Requires-Dist: glasbey; extra == 'napari'
|
|
75
75
|
Requires-Dist: ipykernel<7; extra == 'napari'
|
|
76
76
|
Requires-Dist: lxml-html-clean; extra == 'napari'
|
|
77
|
-
Requires-Dist: napari[all]>=0.
|
|
77
|
+
Requires-Dist: napari[all]>=0.7.1; extra == 'napari'
|
|
78
78
|
Requires-Dist: numpy<2.5; extra == 'napari'
|
|
79
79
|
Provides-Extra: workflow
|
|
80
80
|
Requires-Dist: snakemake-executor-plugin-slurm; extra == 'workflow'
|
|
@@ -194,6 +194,172 @@ also re-runs a step when its **code, params or software environment** change —
|
|
|
194
194
|
so upgrading patchworks would re-do the conversion and overwrite an existing
|
|
195
195
|
result. Keep `mtime` and reruns happen only when an output is missing or stale.
|
|
196
196
|
|
|
197
|
+
## Running two segmentations (e.g. nuclei + cytoplasm)
|
|
198
|
+
|
|
199
|
+
Every path the workflow writes — `tiles.json`, `stage.zarr`, per-tile `seg/`,
|
|
200
|
+
the cached model, `labels.done` — lives under `work_dir/<label_name>/`, so
|
|
201
|
+
running the workflow **twice with two configs against the same `work_dir`**
|
|
202
|
+
never collides: each run gets its own private subdirectory, and both reuse
|
|
203
|
+
the *same* already-converted `image.zarr` (conversion never re-runs).
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
# config/config_nuclei.yaml
|
|
207
|
+
input: "/data/scan.ims"
|
|
208
|
+
work_dir: "/scratch/results"
|
|
209
|
+
label_name: "nuclei_labels"
|
|
210
|
+
channel: 1 # nuclear stain channel
|
|
211
|
+
tile_shape: [16, 1024, 1024]
|
|
212
|
+
cellpose:
|
|
213
|
+
model: "nuclei"
|
|
214
|
+
diameter: 15
|
|
215
|
+
do_3D: true
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```yaml
|
|
219
|
+
# config/config_cyto.yaml
|
|
220
|
+
input: "/data/scan.ims"
|
|
221
|
+
work_dir: "/scratch/results" # same work_dir — image.zarr is reused
|
|
222
|
+
label_name: "cyto_labels"
|
|
223
|
+
channel: 0 # cytoplasm/membrane channel
|
|
224
|
+
tile_shape: [16, 1024, 1024] # keep this identical across configs — see below
|
|
225
|
+
cellpose:
|
|
226
|
+
model: "cyto3"
|
|
227
|
+
diameter: 30
|
|
228
|
+
do_3D: true
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Run them one after another (or as two independent SLURM submissions, even
|
|
232
|
+
concurrently — they touch disjoint files):
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
snakemake --workflow-profile profile/slurm --configfile config/config_nuclei.yaml
|
|
236
|
+
snakemake --workflow-profile profile/slurm --configfile config/config_cyto.yaml
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
!!! tip "One command for several segmentations + relations"
|
|
240
|
+
`config/multi.yaml` lists any number of segmentation configs plus which
|
|
241
|
+
pairs to relate afterward; `pixi run multi` (or `multi-slurm`) runs them
|
|
242
|
+
in order and writes a CSV per pair — see *One command: multiple
|
|
243
|
+
segmentations + relations* below for the config format.
|
|
244
|
+
|
|
245
|
+
Both land side by side in the same store:
|
|
246
|
+
|
|
247
|
+
```text
|
|
248
|
+
results/image.zarr/labels/nuclei_labels/
|
|
249
|
+
results/image.zarr/labels/cyto_labels/
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
!!! tip "Keep `tile_shape` (and `level`) identical across configs"
|
|
253
|
+
Different segmentations of the same image can use different `channel` and
|
|
254
|
+
`cellpose:` settings freely, but keep `tile_shape`/`level` the same across
|
|
255
|
+
configs — the label arrays then share the exact same chunk layout, which
|
|
256
|
+
`label_relations()` (below) requires.
|
|
257
|
+
|
|
258
|
+
### Relating labels across segmentations
|
|
259
|
+
|
|
260
|
+
Once you have two segmentations of the same image (e.g. nuclei inside cells),
|
|
261
|
+
`label_relations()` maps each label in one to the label it overlaps most in
|
|
262
|
+
the other — by streaming both arrays chunk by chunk, so it scales to
|
|
263
|
+
hundreds of thousands of objects without loading anything fully into RAM:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
import dask.array as da
|
|
267
|
+
from patchworks import label_relations
|
|
268
|
+
|
|
269
|
+
nuclei = da.from_zarr("results/image.zarr", component="labels/nuclei_labels/0")
|
|
270
|
+
cells = da.from_zarr("results/image.zarr", component="labels/cyto_labels/0")
|
|
271
|
+
|
|
272
|
+
table = label_relations(nuclei, cells)
|
|
273
|
+
table[2]
|
|
274
|
+
# {'match': 3, 'overlap_voxels': 4821, 'overlap_fraction': 0.94}
|
|
275
|
+
# -> nucleus 2 belongs to cell 3, 94% of its voxels fall inside it
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Save it as a table:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
import csv
|
|
282
|
+
|
|
283
|
+
with open("nuclei_to_cell.csv", "w", newline="") as f:
|
|
284
|
+
w = csv.writer(f)
|
|
285
|
+
w.writerow(["nucleus_id", "cell_id", "overlap_voxels", "overlap_fraction"])
|
|
286
|
+
for nucleus_id, m in table.items():
|
|
287
|
+
w.writerow([nucleus_id, m["match"], m["overlap_voxels"], m["overlap_fraction"]])
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### One command: multiple segmentations + relations
|
|
291
|
+
|
|
292
|
+
`scripts/run_multi.py` (wired up as `pixi run multi`) sequences the above
|
|
293
|
+
manually: run every segmentation config listed, then compute + save every
|
|
294
|
+
configured relation — one command instead of juggling several `snakemake`
|
|
295
|
+
calls and a separate Python step.
|
|
296
|
+
|
|
297
|
+
```yaml
|
|
298
|
+
# config/multi.yaml
|
|
299
|
+
segmentations:
|
|
300
|
+
- config/config_nuclei.yaml
|
|
301
|
+
- config/config_cyto.yaml
|
|
302
|
+
|
|
303
|
+
relations:
|
|
304
|
+
- a: nuclei_labels
|
|
305
|
+
b: cyto_labels
|
|
306
|
+
output: nuclei_to_cyto.csv # written into work_dir
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
pixi run multi-dry # dry-run every segmentation config (skips relations)
|
|
311
|
+
pixi run multi # run locally
|
|
312
|
+
pixi run multi-slurm # submit every segmentation to SLURM
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Every listed segmentation config must share the same `work_dir` (so
|
|
316
|
+
`label_relations` has one `image.zarr` to read both label groups from) — the
|
|
317
|
+
script checks this and errors out otherwise. `relations` is optional; omit it
|
|
318
|
+
to just chain segmentations without a relation step.
|
|
319
|
+
|
|
320
|
+
Both lists are ordinary lists, so 3+ segmentations work the same way — add
|
|
321
|
+
more entries to `segmentations`, then list whichever pairs to relate. There's
|
|
322
|
+
no automatic "chain": list every pair explicitly, e.g. for nuclei + cyto +
|
|
323
|
+
membrane you'd add `nuclei_labels -> cyto_labels`, `nuclei_labels ->
|
|
324
|
+
membrane_labels`, and `cyto_labels -> membrane_labels` as three separate
|
|
325
|
+
entries under `relations`.
|
|
326
|
+
|
|
327
|
+
## Measurements (fast, whole-volume regionprops)
|
|
328
|
+
|
|
329
|
+
`skimage.measure.regionprops` needs the full labelled + intensity array in
|
|
330
|
+
RAM — fine for one tile, not for a hundred-thousand-object OME-ZARR. Use
|
|
331
|
+
[`dask-image`](https://image.dask.org)'s `ndmeasure`, which computes directly
|
|
332
|
+
on the dask/zarr-backed arrays, chunk-parallel, without materializing the
|
|
333
|
+
volume:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
pip install dask-image
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
import dask.array as da
|
|
341
|
+
from dask_image.ndmeasure import area, center_of_mass, mean, standard_deviation
|
|
342
|
+
|
|
343
|
+
labels = da.from_zarr("results/image.zarr", component="labels/cyto_labels/0")
|
|
344
|
+
image = da.from_zarr("results/image.zarr", component="0")[0] # channel 0, level 0
|
|
345
|
+
|
|
346
|
+
ids = da.unique(labels[labels > 0]).compute()
|
|
347
|
+
areas = area(image, labels, ids).compute() # voxel counts
|
|
348
|
+
means = mean(image, labels, ids).compute() # mean intensity
|
|
349
|
+
stds = standard_deviation(image, labels, ids).compute()
|
|
350
|
+
centroids = center_of_mass(image, labels, ids).compute() # voxel coords (z, y, x)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Multiply `areas` by the voxel's physical volume and `centroids` by the pixel
|
|
354
|
+
size (both read straight from the OME-ZARR's own `multiscales` metadata) to
|
|
355
|
+
get µm-scale measurements.
|
|
356
|
+
|
|
357
|
+
For interactively inspecting individual cells by clicking in the viewer
|
|
358
|
+
(not all objects at once), the
|
|
359
|
+
[napari-skimage-regionprops](https://github.com/haesleinhuepf/napari-skimage-regionprops)
|
|
360
|
+
plugin's table widget works well — point it at a cropped region rather than
|
|
361
|
+
the full volume, since it loads its input fully into memory.
|
|
362
|
+
|
|
197
363
|
## Custom segmentation function
|
|
198
364
|
|
|
199
365
|
Not using Cellpose? Run **your own** per-tile function — no need to edit the
|
|
@@ -59,15 +59,16 @@ bioio = [
|
|
|
59
59
|
# imaris reads .ims files natively (HDF5, no JVM) for OME-ZARR conversion.
|
|
60
60
|
imaris = ["imaris-ims-file-reader"]
|
|
61
61
|
# napari enables the interactive viewer plugin.
|
|
62
|
-
# - napari >= 0.
|
|
63
|
-
#
|
|
62
|
+
# - napari >= 0.7.1: adds locked_data_level (manual multiscale resolution pin
|
|
63
|
+
# for 2D *and* 3D) — before this, 3D view always showed the coarsest
|
|
64
|
+
# pyramid level unconditionally, with no way to change it.
|
|
64
65
|
# - numpy < 2.5: napari/numba don't support numpy 2.5 yet.
|
|
65
66
|
# - ipykernel < 7: napari-console requires ipykernel < 7.
|
|
66
67
|
# - lxml-html-clean: napari's notebook_display imports lxml.html.clean, split
|
|
67
68
|
# into a separate package in lxml >= 5.2 (else ImportError on Viewer()).
|
|
68
69
|
# - glasbey: distinct high-contrast label LUTs for view_in_napari.
|
|
69
70
|
napari = [
|
|
70
|
-
"napari[all]>=0.
|
|
71
|
+
"napari[all]>=0.7.1",
|
|
71
72
|
"numpy<2.5",
|
|
72
73
|
"ipykernel<7",
|
|
73
74
|
"lxml-html-clean",
|
|
@@ -36,6 +36,7 @@ from ._distributed import create_stage, spatial_tiles, stage_tile
|
|
|
36
36
|
from ._io import estimate_empty_tiles, load_ome_zarr
|
|
37
37
|
from ._merge import merge_tile_labels
|
|
38
38
|
from ._relabel import relabel_sequential_array, relabel_sequential_zarr
|
|
39
|
+
from ._relations import label_relations
|
|
39
40
|
|
|
40
41
|
try:
|
|
41
42
|
__version__ = _pkg_version("patchworks")
|
|
@@ -52,6 +53,7 @@ __all__ = [
|
|
|
52
53
|
"make_local_cluster",
|
|
53
54
|
"relabel_sequential_array",
|
|
54
55
|
"relabel_sequential_zarr",
|
|
56
|
+
"label_relations",
|
|
55
57
|
"spatial_tiles",
|
|
56
58
|
"create_stage",
|
|
57
59
|
"stage_tile",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Relate two label images by voxel overlap (e.g. nucleus -> containing cell)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
import dask.array as da
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _as_dask(
|
|
18
|
+
source: Union["da.Array", str, Path], component: str
|
|
19
|
+
) -> "da.Array":
|
|
20
|
+
if isinstance(source, (str, Path)):
|
|
21
|
+
return da.from_zarr(str(source), component=component)
|
|
22
|
+
return source
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _chunk_pairs(a_block: np.ndarray, b_block: np.ndarray) -> np.ndarray:
|
|
26
|
+
"""Non-background ``(a_id, b_id) -> voxel count`` rows for one chunk pair."""
|
|
27
|
+
mask = (a_block > 0) & (b_block > 0)
|
|
28
|
+
if not mask.any():
|
|
29
|
+
return np.empty((0, 3), dtype=np.int64)
|
|
30
|
+
pairs = np.stack([a_block[mask], b_block[mask]], axis=1).astype(np.int64)
|
|
31
|
+
uniq, counts = np.unique(pairs, axis=0, return_counts=True)
|
|
32
|
+
return np.concatenate([uniq, counts[:, None]], axis=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def label_relations(
|
|
36
|
+
a: Union["da.Array", str, Path],
|
|
37
|
+
b: Union["da.Array", str, Path],
|
|
38
|
+
*,
|
|
39
|
+
a_component: str = "labels",
|
|
40
|
+
b_component: str = "labels",
|
|
41
|
+
n_workers: int | None = None,
|
|
42
|
+
) -> dict[int, dict[str, float]]:
|
|
43
|
+
"""Map each label in *a* to its best-overlapping label in *b*.
|
|
44
|
+
|
|
45
|
+
For every non-background label in *a* (e.g. a nucleus segmentation),
|
|
46
|
+
finds the label in *b* (e.g. a cell/cytoplasm segmentation of the same
|
|
47
|
+
image) it shares the most voxels with. Streams both arrays chunk by
|
|
48
|
+
chunk — memory is bounded by the number of distinct label pairs (one row
|
|
49
|
+
per touching (a, b) pair per chunk), not by volume size.
|
|
50
|
+
|
|
51
|
+
*a* and *b* must be two segmentations of the **same image** (identical
|
|
52
|
+
shape and chunk layout) — e.g. two runs of the Snakemake workflow with
|
|
53
|
+
different ``label_name``/``cellpose:`` config but the same ``tile_shape``.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
a, b : da.Array, str or Path
|
|
58
|
+
Two label arrays of identical shape and chunking. A path is read via
|
|
59
|
+
``dask.array.from_zarr(path, component=...)``.
|
|
60
|
+
a_component, b_component : str, optional
|
|
61
|
+
Zarr array name inside *a*/*b* when they're store paths (default
|
|
62
|
+
``"labels"``).
|
|
63
|
+
n_workers : int or None, optional
|
|
64
|
+
Parallel chunk workers. Default ``min(4, cpu_count)``.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
dict
|
|
69
|
+
``{a_label: {"match": b_label, "overlap_voxels": int,
|
|
70
|
+
"overlap_fraction": float}}`` — one entry per *a* label that touches
|
|
71
|
+
at least one non-background *b* voxel. ``overlap_fraction`` is the
|
|
72
|
+
matched voxel count over *a* label's total voxel count (1.0 = fully
|
|
73
|
+
contained). Labels in *a* with zero overlap are omitted.
|
|
74
|
+
|
|
75
|
+
Examples
|
|
76
|
+
--------
|
|
77
|
+
>>> from patchworks import label_relations
|
|
78
|
+
>>> table = label_relations(
|
|
79
|
+
... "scan.zarr/labels/nuclei", "scan.zarr/labels/cells"
|
|
80
|
+
... ) # doctest: +SKIP
|
|
81
|
+
>>> table[2] # nucleus 2 sits inside cell 3 # doctest: +SKIP
|
|
82
|
+
{'match': 3, 'overlap_voxels': 4821, 'overlap_fraction': 0.94}
|
|
83
|
+
"""
|
|
84
|
+
a = _as_dask(a, a_component)
|
|
85
|
+
b = _as_dask(b, b_component)
|
|
86
|
+
if a.shape != b.shape:
|
|
87
|
+
raise ValueError(f"shape mismatch: a={a.shape} b={b.shape}")
|
|
88
|
+
if a.chunks != b.chunks:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"a and b must share the same chunk layout "
|
|
91
|
+
f"(a={a.chunks} b={b.chunks}); rechunk one to match the other, "
|
|
92
|
+
"e.g. b = b.rechunk(a.chunks)"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
n_blocks = a.numblocks
|
|
96
|
+
total = int(np.prod(n_blocks))
|
|
97
|
+
nw = n_workers if n_workers is not None else min(4, os.cpu_count() or 1)
|
|
98
|
+
|
|
99
|
+
def _one(flat_idx: int) -> np.ndarray:
|
|
100
|
+
idx = np.unravel_index(flat_idx, n_blocks)
|
|
101
|
+
return _chunk_pairs(
|
|
102
|
+
np.asarray(a.blocks[idx]), np.asarray(b.blocks[idx])
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
with ThreadPoolExecutor(max_workers=nw) as ex:
|
|
106
|
+
parts = list(ex.map(_one, range(total)))
|
|
107
|
+
|
|
108
|
+
rows = [p for p in parts if p.size]
|
|
109
|
+
if not rows:
|
|
110
|
+
return {}
|
|
111
|
+
all_pairs = np.concatenate(rows, axis=0)
|
|
112
|
+
|
|
113
|
+
# Merge duplicate (a_id, b_id) rows across chunks (a label can span
|
|
114
|
+
# several chunks) by sorting on a combined key and summing runs.
|
|
115
|
+
key = all_pairs[:, 0] * (int(all_pairs[:, 1].max()) + 1) + all_pairs[:, 1]
|
|
116
|
+
order = np.argsort(key, kind="stable")
|
|
117
|
+
all_pairs, key = all_pairs[order], key[order]
|
|
118
|
+
starts = np.concatenate([[0], np.flatnonzero(np.diff(key)) + 1])
|
|
119
|
+
merged_counts = np.add.reduceat(all_pairs[:, 2], starts)
|
|
120
|
+
merged = np.stack(
|
|
121
|
+
[all_pairs[starts, 0], all_pairs[starts, 1], merged_counts], axis=1
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
a_totals: dict[int, int] = {}
|
|
125
|
+
for a_id, _, count in merged:
|
|
126
|
+
a_id = int(a_id)
|
|
127
|
+
a_totals[a_id] = a_totals.get(a_id, 0) + int(count)
|
|
128
|
+
|
|
129
|
+
best: dict[int, tuple[int, int]] = {}
|
|
130
|
+
for a_id, b_id, count in merged:
|
|
131
|
+
a_id, b_id, count = int(a_id), int(b_id), int(count)
|
|
132
|
+
cur = best.get(a_id)
|
|
133
|
+
if cur is None or count > cur[1]:
|
|
134
|
+
best[a_id] = (b_id, count)
|
|
135
|
+
|
|
136
|
+
logger.info(
|
|
137
|
+
"label_relations: %d a-labels matched across %d chunks",
|
|
138
|
+
len(best),
|
|
139
|
+
total,
|
|
140
|
+
)
|
|
141
|
+
return {
|
|
142
|
+
a_id: {
|
|
143
|
+
"match": b_id,
|
|
144
|
+
"overlap_voxels": count,
|
|
145
|
+
"overlap_fraction": count / a_totals[a_id],
|
|
146
|
+
}
|
|
147
|
+
for a_id, (b_id, count) in best.items()
|
|
148
|
+
}
|
|
@@ -142,12 +142,14 @@ def _resolve_image(
|
|
|
142
142
|
return source
|
|
143
143
|
|
|
144
144
|
|
|
145
|
-
def
|
|
146
|
-
|
|
145
|
+
def _pyramid_calibration(
|
|
146
|
+
path: Union[str, Path], ndim: int
|
|
147
|
+
) -> tuple[list[float], list[str]] | tuple[None, None]:
|
|
148
|
+
"""Read the level-0 physical scale and units from an OME-ZARR's metadata.
|
|
147
149
|
|
|
148
|
-
Without this, napari shows every axis with an implicit scale of 1
|
|
149
|
-
volume with anisotropic voxels (e.g. z coarser than
|
|
150
|
-
wrong aspect ratio and no real-world units.
|
|
150
|
+
Without this, napari shows every axis with an implicit scale of 1 and
|
|
151
|
+
"pixel" units, so a volume with anisotropic voxels (e.g. z coarser than
|
|
152
|
+
x/y) renders with the wrong aspect ratio and no real-world units.
|
|
151
153
|
|
|
152
154
|
Parameters
|
|
153
155
|
----------
|
|
@@ -161,15 +163,20 @@ def _pyramid_scale(path: Union[str, Path], ndim: int) -> list[float] | None:
|
|
|
161
163
|
|
|
162
164
|
Returns
|
|
163
165
|
-------
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
tuple
|
|
167
|
+
``(scale, units)`` — physical size and unit name per axis — or
|
|
168
|
+
``(None, None)`` if the store carries no calibration (napari then
|
|
169
|
+
falls back to its uncalibrated pixel default).
|
|
167
170
|
"""
|
|
168
171
|
from .ome_zarr import _base_scale, _default_axes, _read_zarr_calibration
|
|
169
172
|
|
|
170
173
|
axes = _default_axes(ndim)
|
|
171
174
|
pixel_size = _read_zarr_calibration(str(path), axes)
|
|
172
|
-
|
|
175
|
+
if not pixel_size:
|
|
176
|
+
return None, None
|
|
177
|
+
scale = _base_scale(axes, pixel_size)
|
|
178
|
+
units = ["micrometer"] * len(scale)
|
|
179
|
+
return scale, units
|
|
173
180
|
|
|
174
181
|
|
|
175
182
|
def _inner_label_names(store: Union[str, Path]) -> list[str]:
|
|
@@ -271,6 +278,18 @@ def view_in_napari(
|
|
|
271
278
|
napari.Viewer
|
|
272
279
|
The viewer instance (useful when ``show=False``).
|
|
273
280
|
|
|
281
|
+
Notes
|
|
282
|
+
-----
|
|
283
|
+
In 3-D view (the cube icon, or ``viewer.dims.ndisplay = 3``), napari
|
|
284
|
+
always shows the **coarsest** pyramid level — there is no automatic
|
|
285
|
+
zoom-based switching in 3-D, only in 2-D. To pin a specific resolution
|
|
286
|
+
(needs ``napari>=0.7.1``)::
|
|
287
|
+
|
|
288
|
+
viewer.layers["labels"].locked_data_level = 0 # full resolution
|
|
289
|
+
viewer.layers["labels"].locked_data_level = None # back to coarsest
|
|
290
|
+
|
|
291
|
+
A widget for this is also in the layer controls panel in napari>=0.7.1.
|
|
292
|
+
|
|
274
293
|
Examples
|
|
275
294
|
--------
|
|
276
295
|
>>> view_in_napari("scan.zarr") # auto-loads scan.zarr/labels/* # doctest: +SKIP
|
|
@@ -279,13 +298,18 @@ def view_in_napari(
|
|
|
279
298
|
|
|
280
299
|
img = _resolve_image(image, channel)
|
|
281
300
|
img_ndim = img[0].ndim if isinstance(img, list) else img.ndim
|
|
282
|
-
img_scale
|
|
301
|
+
img_scale, img_units = (
|
|
302
|
+
_pyramid_calibration(image, img_ndim)
|
|
303
|
+
if _is_zarr(image)
|
|
304
|
+
else (None, None)
|
|
305
|
+
)
|
|
283
306
|
viewer = napari.Viewer()
|
|
284
307
|
viewer.add_image(
|
|
285
308
|
img,
|
|
286
309
|
name=image_name,
|
|
287
310
|
multiscale=isinstance(img, list),
|
|
288
311
|
scale=img_scale,
|
|
312
|
+
units=img_units,
|
|
289
313
|
**add_image_kwargs,
|
|
290
314
|
)
|
|
291
315
|
|
|
@@ -322,12 +346,17 @@ def view_in_napari(
|
|
|
322
346
|
if labels is not None:
|
|
323
347
|
lab = _resolve_labels(labels, labels_component)
|
|
324
348
|
lab_ndim = lab[0].ndim if isinstance(lab, list) else lab.ndim
|
|
325
|
-
lab_scale
|
|
349
|
+
lab_scale, lab_units = (
|
|
350
|
+
_pyramid_calibration(labels, lab_ndim)
|
|
351
|
+
if _is_zarr(labels)
|
|
352
|
+
else (None, None)
|
|
353
|
+
)
|
|
326
354
|
viewer.add_labels(
|
|
327
355
|
lab,
|
|
328
356
|
name=labels_name,
|
|
329
357
|
multiscale=isinstance(lab, list),
|
|
330
358
|
scale=lab_scale,
|
|
359
|
+
units=lab_units,
|
|
331
360
|
**label_kwargs,
|
|
332
361
|
)
|
|
333
362
|
elif _is_zarr(image):
|
|
@@ -341,11 +370,13 @@ def view_in_napari(
|
|
|
341
370
|
store = f"{image}/labels/{name}"
|
|
342
371
|
levels = _multiscale_levels(store, None)
|
|
343
372
|
lab = [lvl.astype("int32") for lvl in levels]
|
|
373
|
+
lab_scale, lab_units = _pyramid_calibration(store, lab[0].ndim)
|
|
344
374
|
viewer.add_labels(
|
|
345
375
|
lab,
|
|
346
376
|
name=name,
|
|
347
377
|
multiscale=True,
|
|
348
|
-
scale=
|
|
378
|
+
scale=lab_scale,
|
|
379
|
+
units=lab_units,
|
|
349
380
|
**label_kwargs,
|
|
350
381
|
)
|
|
351
382
|
logger.info("auto-loaded labels/%s from %s", name, image)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import dask.array as da
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from patchworks import label_relations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_label_relations_majority_overlap():
|
|
9
|
+
# nucleus 1 sits fully inside cell 10; nucleus 2 straddles cells 20/21
|
|
10
|
+
# with more voxels in 20 -> should match 20, not 21.
|
|
11
|
+
a = np.zeros((4, 10), dtype=np.int32)
|
|
12
|
+
a[0:2, 0:2] = 1
|
|
13
|
+
a[0:2, 4:8] = 2
|
|
14
|
+
b = np.zeros((4, 10), dtype=np.int32)
|
|
15
|
+
b[0:3, 0:3] = 10
|
|
16
|
+
b[0:2, 4:6] = 20 # 4 voxels overlap with label 2
|
|
17
|
+
b[0:2, 6:8] = 21 # 4 voxels overlap with label 2 -> tie, but let's skew it
|
|
18
|
+
b[0:2, 6:7] = 20 # tip the tie: label 20 now has 6 voxels vs 21's 2
|
|
19
|
+
|
|
20
|
+
table = label_relations(da.from_array(a, chunks=(2, 5)), da.from_array(b, chunks=(2, 5)))
|
|
21
|
+
|
|
22
|
+
assert table[1]["match"] == 10
|
|
23
|
+
assert table[1]["overlap_voxels"] == 4
|
|
24
|
+
assert table[1]["overlap_fraction"] == 1.0
|
|
25
|
+
|
|
26
|
+
assert table[2]["match"] == 20
|
|
27
|
+
assert table[2]["overlap_voxels"] == 6
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_label_relations_no_overlap_omitted():
|
|
31
|
+
a = np.zeros((2, 2), dtype=np.int32)
|
|
32
|
+
a[0, 0] = 1
|
|
33
|
+
b = np.zeros((2, 2), dtype=np.int32) # all background, no overlap anywhere
|
|
34
|
+
|
|
35
|
+
table = label_relations(da.from_array(a, chunks=(2, 2)), da.from_array(b, chunks=(2, 2)))
|
|
36
|
+
assert table == {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_label_relations_chunk_mismatch_raises():
|
|
40
|
+
a = da.zeros((4, 4), chunks=(2, 4), dtype=np.int32)
|
|
41
|
+
b = da.zeros((4, 4), chunks=(4, 4), dtype=np.int32)
|
|
42
|
+
with pytest.raises(ValueError, match="chunk layout"):
|
|
43
|
+
label_relations(a, b)
|
|
@@ -75,6 +75,26 @@ The GPU request lives in `profile/slurm/config.yaml` under
|
|
|
75
75
|
`set-resources: segment:` (`--gres=gpu:1`). Raise `jobs:` to use more GPUs at
|
|
76
76
|
once.
|
|
77
77
|
|
|
78
|
+
## Multiple segmentations (nuclei + cytoplasm, …)
|
|
79
|
+
|
|
80
|
+
Every intermediate path is namespaced under `work_dir/<label_name>/`, so
|
|
81
|
+
running the workflow **twice with two configs** (different `label_name`,
|
|
82
|
+
`channel`, `cellpose:`) against the **same `work_dir`** is safe — both reuse
|
|
83
|
+
the one converted `image.zarr` and land side by side in
|
|
84
|
+
`image.zarr/labels/<label_name>/`. See the
|
|
85
|
+
[full guide](https://imcf.one/patchworks/guide/snakemake/#running-two-segmentations-eg-nuclei--cytoplasm)
|
|
86
|
+
for the two-config recipe and `patchworks.label_relations()` for mapping one
|
|
87
|
+
segmentation onto the other (e.g. nucleus → containing cell).
|
|
88
|
+
|
|
89
|
+
One command instead of juggling several manual runs: list your configs (and
|
|
90
|
+
which label pairs to relate) in `config/multi.yaml`, then:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pixi run multi-dry # dry-run every segmentation config
|
|
94
|
+
pixi run multi # run locally
|
|
95
|
+
pixi run multi-slurm # submit every segmentation to SLURM
|
|
96
|
+
```
|
|
97
|
+
|
|
78
98
|
## Output
|
|
79
99
|
|
|
80
100
|
`<work_dir>/image.zarr` — the image plus `labels/<label_name>/` (multi-scale,
|
|
@@ -30,6 +30,10 @@ empty_threshold: null # null → Otsu; or a number
|
|
|
30
30
|
|
|
31
31
|
# ---- segmentation -----------------------------------------------------------
|
|
32
32
|
method: "cellpose" # "cellpose" (GPU), "threshold" (no GPU; testing), "custom"
|
|
33
|
+
# Also namespaces this run's intermediate files under work_dir/<label_name>/,
|
|
34
|
+
# so a second segmentation (different label_name, e.g. nuclei vs cytoplasm)
|
|
35
|
+
# can safely target the same work_dir — see docs/guide/snakemake.md
|
|
36
|
+
# "Running two segmentations".
|
|
33
37
|
label_name: "cellpose_labels"
|
|
34
38
|
cellpose:
|
|
35
39
|
model: "nuclei"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Example segmentation config: cytoplasm/membrane channel.
|
|
2
|
+
# Paired with config_nuclei.yaml via config/multi.yaml — see
|
|
3
|
+
# docs/guide/snakemake.md "Running two segmentations". Same work_dir as
|
|
4
|
+
# config_nuclei.yaml, and the same tile_shape so patchworks.label_relations()
|
|
5
|
+
# can compare the two label arrays chunk-for-chunk.
|
|
6
|
+
|
|
7
|
+
input: "/path/to/scan.ims"
|
|
8
|
+
work_dir: "/path/to/results"
|
|
9
|
+
|
|
10
|
+
reuse_pyramid: false
|
|
11
|
+
convert_chunks: null
|
|
12
|
+
shard: false
|
|
13
|
+
|
|
14
|
+
channel: 0 # cytoplasm/membrane channel
|
|
15
|
+
level: 0
|
|
16
|
+
tile_shape: [16, 1024, 1024] # keep identical to config_nuclei.yaml
|
|
17
|
+
gpu_memory_gb: null
|
|
18
|
+
overlap: 30
|
|
19
|
+
skip_empty: true
|
|
20
|
+
empty_threshold: null
|
|
21
|
+
|
|
22
|
+
method: "cellpose"
|
|
23
|
+
label_name: "cyto_labels"
|
|
24
|
+
cellpose:
|
|
25
|
+
model: "cyto3"
|
|
26
|
+
diameter: 30
|
|
27
|
+
do_3D: true
|
|
28
|
+
gpu: true
|
|
29
|
+
|
|
30
|
+
pyramid_levels: 5
|
|
31
|
+
pyramid_downscale: 2
|
|
32
|
+
sequential_labels: true
|
|
33
|
+
merge_workers: null
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Example segmentation config: nuclei channel.
|
|
2
|
+
# Paired with config_cyto.yaml via config/multi.yaml — see
|
|
3
|
+
# docs/guide/snakemake.md "Running two segmentations". Both configs share
|
|
4
|
+
# work_dir (and thus image.zarr) with config_cyto.yaml, but keep tile_shape
|
|
5
|
+
# identical across the two so patchworks.label_relations() can compare them.
|
|
6
|
+
|
|
7
|
+
input: "/path/to/scan.ims"
|
|
8
|
+
work_dir: "/path/to/results"
|
|
9
|
+
|
|
10
|
+
reuse_pyramid: false
|
|
11
|
+
convert_chunks: null
|
|
12
|
+
shard: false
|
|
13
|
+
|
|
14
|
+
channel: 1 # nuclear stain channel
|
|
15
|
+
level: 0
|
|
16
|
+
tile_shape: [16, 1024, 1024] # keep identical to config_cyto.yaml
|
|
17
|
+
gpu_memory_gb: null
|
|
18
|
+
overlap: 30
|
|
19
|
+
skip_empty: true
|
|
20
|
+
empty_threshold: null
|
|
21
|
+
|
|
22
|
+
method: "cellpose"
|
|
23
|
+
label_name: "nuclei_labels"
|
|
24
|
+
cellpose:
|
|
25
|
+
model: "nuclei"
|
|
26
|
+
diameter: 15
|
|
27
|
+
do_3D: true
|
|
28
|
+
gpu: true
|
|
29
|
+
|
|
30
|
+
pyramid_levels: 5
|
|
31
|
+
pyramid_downscale: 2
|
|
32
|
+
sequential_labels: true
|
|
33
|
+
merge_workers: null
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Drives `pixi run multi` / `scripts/run_multi.py`: runs several segmentation
|
|
2
|
+
# configs (in order) against the same work_dir, then relates their labels by
|
|
3
|
+
# voxel overlap (patchworks.label_relations) and writes a CSV per pair.
|
|
4
|
+
#
|
|
5
|
+
# All listed segmentation configs must share the same work_dir/image.zarr and
|
|
6
|
+
# the same tile_shape/level, so the resulting label arrays are directly
|
|
7
|
+
# comparable — see docs/guide/snakemake.md "Running two segmentations".
|
|
8
|
+
#
|
|
9
|
+
# Both lists are plain lists: 3+ segmentations work the same way — add more
|
|
10
|
+
# entries to `segmentations`, then list whichever pairs you want related in
|
|
11
|
+
# `relations` (there's no automatic "chain"; list every pair explicitly, e.g.
|
|
12
|
+
# nuclei->cyto, nuclei->membrane, cyto->membrane).
|
|
13
|
+
|
|
14
|
+
segmentations:
|
|
15
|
+
- config/config_nuclei.yaml
|
|
16
|
+
- config/config_cyto.yaml
|
|
17
|
+
|
|
18
|
+
relations:
|
|
19
|
+
- a: nuclei_labels
|
|
20
|
+
b: cyto_labels
|
|
21
|
+
output: nuclei_to_cyto.csv
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# pixi environment for the patchworks Snakemake workflow.
|
|
2
2
|
# pixi install
|
|
3
|
-
# pixi run dry
|
|
4
|
-
# pixi run go
|
|
5
|
-
# pixi run slurm
|
|
3
|
+
# pixi run dry # dry-run (plan only)
|
|
4
|
+
# pixi run go # run locally
|
|
5
|
+
# pixi run slurm # submit to SLURM (edit profile/slurm/config.yaml first)
|
|
6
|
+
# pixi run multi # run several segmentations + relate their labels (local)
|
|
7
|
+
# pixi run multi-slurm # same, on SLURM
|
|
8
|
+
# pixi run multi-dry # dry-run every segmentation in config/multi.yaml
|
|
6
9
|
#
|
|
7
10
|
# On a cluster, put this project on a shared filesystem the compute nodes can
|
|
8
11
|
# read: the SLURM jobs re-launch snakemake from this env's interpreter.
|
|
@@ -38,3 +41,11 @@ cellpose4 = { features = ["cp4"], solve-group = "cp4" }
|
|
|
38
41
|
dry = "snakemake -s Snakefile --configfile config/config.yaml --rerun-triggers mtime -n -p"
|
|
39
42
|
go = "snakemake -s Snakefile --configfile config/config.yaml --rerun-triggers mtime --cores 8"
|
|
40
43
|
slurm = "snakemake --workflow-profile profile/slurm --configfile config/config.yaml"
|
|
44
|
+
|
|
45
|
+
# Run every segmentation config listed in config/multi.yaml (in order), then
|
|
46
|
+
# relate their labels by voxel overlap (patchworks.label_relations) and write
|
|
47
|
+
# a CSV per configured pair — see config/multi.yaml and
|
|
48
|
+
# docs/guide/snakemake.md "Running two segmentations".
|
|
49
|
+
multi-dry = "python scripts/run_multi.py --config config/multi.yaml -n"
|
|
50
|
+
multi = "python scripts/run_multi.py --config config/multi.yaml"
|
|
51
|
+
multi-slurm = "python scripts/run_multi.py --config config/multi.yaml --profile profile/slurm"
|
|
@@ -7,8 +7,17 @@ IMAGE = f"{WORK}/image.zarr"
|
|
|
7
7
|
# directory) lets Snakemake skip conversion when the store already exists and
|
|
8
8
|
# avoids wiping the whole store on a re-run (same trick as imcf/sopa).
|
|
9
9
|
IMAGE_OK = f"{IMAGE}/zarr.json"
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
|
|
11
|
+
# Everything below is per-segmentation, namespaced under WORK/<label_name>/, so
|
|
12
|
+
# running the workflow twice with two configs (different label_name, e.g.
|
|
13
|
+
# "nuclei_labels" and "cell_labels") against the *same* work_dir never
|
|
14
|
+
# collides — each gets its own tiles/stage/seg/model/labels.done, and both
|
|
15
|
+
# read the *same* already-converted image.zarr. See docs/guide/snakemake.md
|
|
16
|
+
# "Running two segmentations" for the two-config recipe.
|
|
17
|
+
LABEL_NAME = config.get("label_name", "labels")
|
|
18
|
+
RUN = f"{WORK}/{LABEL_NAME}"
|
|
19
|
+
TILES = f"{RUN}/tiles.json"
|
|
20
|
+
STAGE = f"{RUN}/stage.zarr"
|
|
12
21
|
# Completion sentinel for the stage store. Tracking a touch()ed marker instead
|
|
13
22
|
# of directory(STAGE) keeps Snakemake from deleting/recreating the store on a
|
|
14
23
|
# re-run and avoids directory-mtime quirks (same touch() discipline as sopa).
|
|
@@ -17,16 +26,18 @@ STAGE_OK = f"{STAGE}.done"
|
|
|
17
26
|
|
|
18
27
|
# Logs: one shared file for the sequential CPU steps (convert/prepare/merge),
|
|
19
28
|
# one file per tile for the GPU segment jobs.
|
|
20
|
-
LOGS = f"{
|
|
29
|
+
LOGS = f"{RUN}/logs"
|
|
21
30
|
STEPLOG = f"{LOGS}/steps.log"
|
|
22
31
|
|
|
23
32
|
# Marker that the segmentation model is cached locally. Produced by a local
|
|
24
33
|
# rule (runs on the networked submit host) so offline GPU nodes never download.
|
|
25
|
-
|
|
34
|
+
# Namespaced per-run too: two configs using different models must each fetch
|
|
35
|
+
# their own, rather than the second silently reusing the first's marker.
|
|
36
|
+
MODEL_OK = f"{RUN}/model.ready"
|
|
26
37
|
|
|
27
38
|
|
|
28
39
|
def occupied_done(wildcards):
|
|
29
40
|
"""Per-tile markers for the occupied tiles (resolved after the checkpoint)."""
|
|
30
41
|
tiles = checkpoints.prepare.get().output.tiles
|
|
31
42
|
occupied = json.loads(Path(tiles).read_text())["occupied"]
|
|
32
|
-
return [f"{
|
|
43
|
+
return [f"{RUN}/seg/{i}.done" for i in occupied]
|
|
@@ -87,20 +87,26 @@ def open_image(work_dir, channel, level):
|
|
|
87
87
|
)
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def stage_path(work_dir):
|
|
91
|
-
"""Path of the staged-labels store.
|
|
90
|
+
def stage_path(work_dir, label_name):
|
|
91
|
+
"""Path of the staged-labels store for one segmentation run.
|
|
92
|
+
|
|
93
|
+
Namespaced under ``<work_dir>/<label_name>/`` so two segmentations
|
|
94
|
+
(different ``label_name``) can target the same ``work_dir`` without
|
|
95
|
+
colliding — see docs/guide/snakemake.md "Running two segmentations".
|
|
92
96
|
|
|
93
97
|
Parameters
|
|
94
98
|
----------
|
|
95
99
|
work_dir : str or Path
|
|
96
100
|
Workflow output directory.
|
|
101
|
+
label_name : str
|
|
102
|
+
This run's ``label_name`` (from config).
|
|
97
103
|
|
|
98
104
|
Returns
|
|
99
105
|
-------
|
|
100
106
|
str
|
|
101
|
-
``<work_dir>/stage.zarr``.
|
|
107
|
+
``<work_dir>/<label_name>/stage.zarr``.
|
|
102
108
|
"""
|
|
103
|
-
return str(Path(work_dir) / "stage.zarr")
|
|
109
|
+
return str(Path(work_dir) / label_name / "stage.zarr")
|
|
104
110
|
|
|
105
111
|
|
|
106
112
|
def load_tiles_json(path):
|
|
@@ -17,16 +17,19 @@ from _pw import stage_path, start_log
|
|
|
17
17
|
start_log(snakemake.log[0]) # noqa: F821
|
|
18
18
|
cfg = snakemake.config # noqa: F821
|
|
19
19
|
work_dir = cfg["work_dir"]
|
|
20
|
+
label_name = cfg.get("label_name", "labels")
|
|
20
21
|
image_store = str(Path(work_dir) / "image.zarr")
|
|
21
|
-
merged_store = str(Path(work_dir) / "_merged.zarr")
|
|
22
|
+
merged_store = str(Path(work_dir) / label_name / "_merged.zarr")
|
|
22
23
|
|
|
23
24
|
# merge_tile_labels defaults to min(4, cpu_count) workers, so it ignores
|
|
24
25
|
# whatever cpus_per_task the "merge" rule was actually allocated in the SLURM
|
|
25
26
|
# profile. Read the real allocation (SLURM_CPUS_PER_TASK) so the job uses all
|
|
26
27
|
# the cores it's paying for; merge_workers: in config.yaml can still override.
|
|
27
|
-
default_workers = int(
|
|
28
|
+
default_workers = int(
|
|
29
|
+
os.environ.get("SLURM_CPUS_PER_TASK", os.cpu_count() or 4)
|
|
30
|
+
)
|
|
28
31
|
merged = merge_tile_labels(
|
|
29
|
-
stage_path(work_dir),
|
|
32
|
+
stage_path(work_dir, label_name),
|
|
30
33
|
write_to=merged_store,
|
|
31
34
|
input_component="staged",
|
|
32
35
|
sequential_labels=cfg.get("sequential_labels", True),
|
|
@@ -36,18 +39,18 @@ merged = merge_tile_labels(
|
|
|
36
39
|
group = write_labels(
|
|
37
40
|
image_store,
|
|
38
41
|
merged,
|
|
39
|
-
name=
|
|
42
|
+
name=label_name,
|
|
40
43
|
n_levels=int(cfg.get("pyramid_levels", 5)),
|
|
41
44
|
downscale=int(cfg.get("pyramid_downscale", 2)),
|
|
42
45
|
overwrite=True,
|
|
43
46
|
)
|
|
44
47
|
|
|
45
48
|
shutil.rmtree(merged_store, ignore_errors=True)
|
|
46
|
-
shutil.rmtree(stage_path(work_dir), ignore_errors=True)
|
|
49
|
+
shutil.rmtree(stage_path(work_dir, label_name), ignore_errors=True)
|
|
47
50
|
# Also drop the checkpoint's completion sentinel (stage.zarr.done): the
|
|
48
51
|
# "prepare" rule's stage=touch(STAGE_OK) output must not outlive the store it
|
|
49
52
|
# claims exists, or a future rerun (e.g. re-segmenting for new labels) skips
|
|
50
53
|
# "prepare" and "segment" tries to open a stage.zarr that's already gone.
|
|
51
|
-
Path(f"{stage_path(work_dir)}.done").unlink(missing_ok=True)
|
|
54
|
+
Path(f"{stage_path(work_dir, label_name)}.done").unlink(missing_ok=True)
|
|
52
55
|
print(f"[patchworks] labels written to {group}")
|
|
53
56
|
open(snakemake.output[0], "w").close() # noqa: F821
|
|
@@ -16,6 +16,8 @@ from _pw import open_image, stage_path, start_log
|
|
|
16
16
|
start_log(snakemake.log[0]) # noqa: F821
|
|
17
17
|
cfg = snakemake.config # noqa: F821
|
|
18
18
|
work_dir = cfg["work_dir"]
|
|
19
|
+
label_name = cfg.get("label_name", "labels")
|
|
20
|
+
Path(work_dir, label_name).mkdir(parents=True, exist_ok=True)
|
|
19
21
|
image = open_image(work_dir, cfg["channel"], cfg["level"])
|
|
20
22
|
|
|
21
23
|
ts = cfg.get("tile_shape", "auto")
|
|
@@ -46,9 +48,9 @@ if cfg.get("skip_empty", True):
|
|
|
46
48
|
occ = info["occupancy"].ravel() # row-major, matches spatial_tiles
|
|
47
49
|
occupied = [i for i in range(len(tiles)) if occ[i]]
|
|
48
50
|
|
|
49
|
-
create_stage(stage_path(work_dir), image.shape, tile_shape)
|
|
51
|
+
create_stage(stage_path(work_dir, label_name), image.shape, tile_shape)
|
|
50
52
|
|
|
51
|
-
Path(work_dir, "tiles.json").write_text(
|
|
53
|
+
Path(work_dir, label_name, "tiles.json").write_text(
|
|
52
54
|
json.dumps(
|
|
53
55
|
{
|
|
54
56
|
"tile_shape": list(tile_shape),
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Run several segmentation configs, then relate their labels by overlap.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python scripts/run_multi.py --config config/multi.yaml
|
|
5
|
+
python scripts/run_multi.py --config config/multi.yaml --profile profile/slurm
|
|
6
|
+
python scripts/run_multi.py --config config/multi.yaml -n # dry-run only
|
|
7
|
+
|
|
8
|
+
See config/multi.yaml and docs/guide/snakemake.md "Running two segmentations"
|
|
9
|
+
for the config format. Each listed segmentation config is run as an ordinary
|
|
10
|
+
`snakemake --configfile ...` invocation (this script is a thin sequencer, not
|
|
11
|
+
a Snakemake rule — the segmentations already namespace their own paths under
|
|
12
|
+
work_dir/<label_name>/, so running them one after another here is exactly
|
|
13
|
+
equivalent to running each snakemake command by hand). Once all segmentations
|
|
14
|
+
finish, each configured relation pair is computed via
|
|
15
|
+
patchworks.label_relations and written as a CSV in work_dir.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import csv
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_yaml(path: Path) -> dict:
|
|
30
|
+
return yaml.safe_load(path.read_text())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run_snakemake(
|
|
34
|
+
configfile: Path,
|
|
35
|
+
*,
|
|
36
|
+
workflow_dir: Path,
|
|
37
|
+
profile: str | None,
|
|
38
|
+
cores: int,
|
|
39
|
+
dry_run: bool,
|
|
40
|
+
) -> None:
|
|
41
|
+
cmd = [
|
|
42
|
+
"snakemake",
|
|
43
|
+
"-s",
|
|
44
|
+
str(workflow_dir / "Snakefile"),
|
|
45
|
+
"--configfile",
|
|
46
|
+
str(configfile),
|
|
47
|
+
]
|
|
48
|
+
if profile:
|
|
49
|
+
cmd += ["--workflow-profile", profile]
|
|
50
|
+
else:
|
|
51
|
+
cmd += ["--cores", str(cores), "--rerun-triggers", "mtime"]
|
|
52
|
+
if dry_run:
|
|
53
|
+
cmd += ["-n", "-p"]
|
|
54
|
+
print(f"[run_multi] $ {' '.join(cmd)}", flush=True)
|
|
55
|
+
subprocess.run(cmd, check=True, cwd=workflow_dir)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve(workflow_dir: Path, path_str: str) -> Path:
|
|
59
|
+
path = Path(path_str)
|
|
60
|
+
return path if path.is_absolute() else workflow_dir / path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> None:
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
description=__doc__,
|
|
66
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--config", required=True, help="multi-segmentation config YAML"
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--profile",
|
|
73
|
+
default=None,
|
|
74
|
+
help="Snakemake --workflow-profile (e.g. profile/slurm); omit to run locally",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--cores",
|
|
78
|
+
type=int,
|
|
79
|
+
default=8,
|
|
80
|
+
help="local run: --cores (ignored with --profile)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"-n",
|
|
84
|
+
"--dry-run",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help="pass -n -p to every Snakemake run; skips relations",
|
|
87
|
+
)
|
|
88
|
+
args = parser.parse_args()
|
|
89
|
+
|
|
90
|
+
workflow_dir = Path(__file__).resolve().parent.parent
|
|
91
|
+
multi_cfg_path = _resolve(workflow_dir, args.config)
|
|
92
|
+
multi_cfg = _load_yaml(multi_cfg_path)
|
|
93
|
+
|
|
94
|
+
seg_config_paths = [
|
|
95
|
+
_resolve(workflow_dir, c) for c in multi_cfg["segmentations"]
|
|
96
|
+
]
|
|
97
|
+
for cfg_path in seg_config_paths:
|
|
98
|
+
_run_snakemake(
|
|
99
|
+
cfg_path,
|
|
100
|
+
workflow_dir=workflow_dir,
|
|
101
|
+
profile=args.profile,
|
|
102
|
+
cores=args.cores,
|
|
103
|
+
dry_run=args.dry_run,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
relations = multi_cfg.get("relations", [])
|
|
107
|
+
if args.dry_run or not relations:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
seg_cfgs = [_load_yaml(p) for p in seg_config_paths]
|
|
111
|
+
work_dirs = {cfg["work_dir"] for cfg in seg_cfgs}
|
|
112
|
+
if len(work_dirs) != 1:
|
|
113
|
+
print(
|
|
114
|
+
f"[run_multi] ERROR: segmentation configs use different work_dir "
|
|
115
|
+
f"({sorted(work_dirs)}); label_relations needs one shared "
|
|
116
|
+
"image.zarr to compare against.",
|
|
117
|
+
file=sys.stderr,
|
|
118
|
+
)
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
work_dir = work_dirs.pop()
|
|
121
|
+
image_store = f"{work_dir}/image.zarr"
|
|
122
|
+
|
|
123
|
+
import dask.array as da
|
|
124
|
+
|
|
125
|
+
from patchworks import label_relations
|
|
126
|
+
|
|
127
|
+
for rel in relations:
|
|
128
|
+
a_name, b_name = rel["a"], rel["b"]
|
|
129
|
+
out_path = Path(work_dir) / rel.get(
|
|
130
|
+
"output", f"{a_name}_to_{b_name}.csv"
|
|
131
|
+
)
|
|
132
|
+
print(f"[run_multi] relating {a_name} -> {b_name} …", flush=True)
|
|
133
|
+
a = da.from_zarr(image_store, component=f"labels/{a_name}/0")
|
|
134
|
+
b = da.from_zarr(image_store, component=f"labels/{b_name}/0")
|
|
135
|
+
table = label_relations(a, b)
|
|
136
|
+
|
|
137
|
+
with open(out_path, "w", newline="") as f:
|
|
138
|
+
writer = csv.writer(f)
|
|
139
|
+
writer.writerow(
|
|
140
|
+
[
|
|
141
|
+
f"{a_name}_id",
|
|
142
|
+
f"{b_name}_id",
|
|
143
|
+
"overlap_voxels",
|
|
144
|
+
"overlap_fraction",
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
for a_id, m in table.items():
|
|
148
|
+
writer.writerow(
|
|
149
|
+
[
|
|
150
|
+
a_id,
|
|
151
|
+
m["match"],
|
|
152
|
+
m["overlap_voxels"],
|
|
153
|
+
m["overlap_fraction"],
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
print(f"[run_multi] wrote {out_path} ({len(table)} rows)", flush=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
main()
|
|
@@ -12,6 +12,7 @@ start_log(snakemake.log[0]) # noqa: F821
|
|
|
12
12
|
cfg = snakemake.config # noqa: F821
|
|
13
13
|
index = int(snakemake.wildcards.index) # noqa: F821
|
|
14
14
|
work_dir = cfg["work_dir"]
|
|
15
|
+
label_name = cfg.get("label_name", "labels")
|
|
15
16
|
|
|
16
17
|
manifest = load_tiles_json(snakemake.input.tiles) # noqa: F821
|
|
17
18
|
image = open_image(work_dir, cfg["channel"], cfg["level"])
|
|
@@ -19,7 +20,7 @@ image = open_image(work_dir, cfg["channel"], cfg["level"])
|
|
|
19
20
|
stage_tile(
|
|
20
21
|
image,
|
|
21
22
|
build_fn(cfg),
|
|
22
|
-
stage_path(work_dir),
|
|
23
|
+
stage_path(work_dir, label_name),
|
|
23
24
|
index,
|
|
24
25
|
tile_shape=tuple(manifest["tile_shape"]),
|
|
25
26
|
overlap=int(manifest["overlap"]),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|