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.
Files changed (77) hide show
  1. {patchworks-0.11.17 → patchworks-0.11.19}/PKG-INFO +3 -3
  2. {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/snakemake.md +166 -0
  3. {patchworks-0.11.17 → patchworks-0.11.19}/pyproject.toml +4 -3
  4. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/__init__.py +2 -0
  5. patchworks-0.11.19/src/patchworks/_relations.py +148 -0
  6. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/plugins/napari.py +43 -12
  7. patchworks-0.11.19/tests/test_relations.py +43 -0
  8. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/README.md +20 -0
  9. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/Snakefile +1 -1
  10. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/config/config.yaml +4 -0
  11. patchworks-0.11.19/workflow/config/config_cyto.yaml +33 -0
  12. patchworks-0.11.19/workflow/config/config_nuclei.yaml +33 -0
  13. patchworks-0.11.19/workflow/config/multi.yaml +21 -0
  14. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/pixi.toml +14 -3
  15. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/rules/common.smk +16 -5
  16. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/rules/merge.smk +1 -1
  17. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/rules/segment.smk +1 -1
  18. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/_pw.py +10 -4
  19. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/merge.py +9 -6
  20. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/prepare_tiles.py +4 -2
  21. patchworks-0.11.19/workflow/scripts/run_multi.py +160 -0
  22. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/segment_tile.py +2 -1
  23. {patchworks-0.11.17 → patchworks-0.11.19}/.github/workflows/docs.yml +0 -0
  24. {patchworks-0.11.17 → patchworks-0.11.19}/.github/workflows/lint.yml +0 -0
  25. {patchworks-0.11.17 → patchworks-0.11.19}/.github/workflows/release.yml +0 -0
  26. {patchworks-0.11.17 → patchworks-0.11.19}/.gitignore +0 -0
  27. {patchworks-0.11.17 → patchworks-0.11.19}/.markdownlint-cli2.yaml +0 -0
  28. {patchworks-0.11.17 → patchworks-0.11.19}/LICENSE +0 -0
  29. {patchworks-0.11.17 → patchworks-0.11.19}/README.md +0 -0
  30. {patchworks-0.11.17 → patchworks-0.11.19}/cliff.toml +0 -0
  31. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/chunks.md +0 -0
  32. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/cluster.md +0 -0
  33. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/io.md +0 -0
  34. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/merge_tile_labels.md +0 -0
  35. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/plugins/cellpose.md +0 -0
  36. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/plugins/napari.md +0 -0
  37. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/plugins/ome_zarr.md +0 -0
  38. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/relabel.md +0 -0
  39. {patchworks-0.11.17 → patchworks-0.11.19}/docs/api/tile_process.md +0 -0
  40. {patchworks-0.11.17 → patchworks-0.11.19}/docs/assets/logo.png +0 -0
  41. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/cellpose_2d.md +0 -0
  42. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/cellpose_2d.py +0 -0
  43. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/cellpose_3d.md +0 -0
  44. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/cellpose_3d.py +0 -0
  45. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/custom.md +0 -0
  46. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/custom_method.py +0 -0
  47. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/standalone_merge.md +0 -0
  48. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/stardist.md +0 -0
  49. {patchworks-0.11.17 → patchworks-0.11.19}/docs/examples/stardist_2d.py +0 -0
  50. {patchworks-0.11.17 → patchworks-0.11.19}/docs/getting_started.md +0 -0
  51. {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/gpu_distributed.md +0 -0
  52. {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/merging.md +0 -0
  53. {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/ome_zarr_napari.md +0 -0
  54. {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/performance.md +0 -0
  55. {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/pitfalls.md +0 -0
  56. {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/skip_empty.md +0 -0
  57. {patchworks-0.11.17 → patchworks-0.11.19}/docs/guide/tiling.md +0 -0
  58. {patchworks-0.11.17 → patchworks-0.11.19}/docs/index.md +0 -0
  59. {patchworks-0.11.17 → patchworks-0.11.19}/mkdocs.yml +0 -0
  60. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_chunks.py +0 -0
  61. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_cluster.py +0 -0
  62. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_core.py +0 -0
  63. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_distributed.py +0 -0
  64. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_io.py +0 -0
  65. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_merge.py +0 -0
  66. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/_relabel.py +0 -0
  67. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/plugins/__init__.py +0 -0
  68. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/plugins/cellpose.py +0 -0
  69. {patchworks-0.11.17 → patchworks-0.11.19}/src/patchworks/plugins/ome_zarr.py +0 -0
  70. {patchworks-0.11.17 → patchworks-0.11.19}/tests/test_core.py +0 -0
  71. {patchworks-0.11.17 → patchworks-0.11.19}/tests/test_distributed.py +0 -0
  72. {patchworks-0.11.17 → patchworks-0.11.19}/tests/test_napari.py +0 -0
  73. {patchworks-0.11.17 → patchworks-0.11.19}/tests/test_ome_zarr.py +0 -0
  74. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/profile/slurm/config.yaml +0 -0
  75. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/rules/convert.smk +0 -0
  76. {patchworks-0.11.17 → patchworks-0.11.19}/workflow/scripts/convert.py +0 -0
  77. {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.17
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.5.5; extra == 'all'
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.5.5; extra == 'napari'
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.5: NumPy 2.0 compatible (older 0.4.x uses np.array(copy=False),
63
- # which raises under NumPy 2 -> ValidationError on Viewer()).
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.5.5",
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 _pyramid_scale(path: Union[str, Path], ndim: int) -> list[float] | None:
146
- """Read the level-0 physical scale (µm/px) from an OME-ZARR's own metadata.
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, so a
149
- volume with anisotropic voxels (e.g. z coarser than x/y) renders with the
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
- list of float or None
165
- Physical size per axis, or ``None`` if the store carries no
166
- calibration (napari then falls back to its uncalibrated default).
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
- return _base_scale(axes, pixel_size) if pixel_size else None
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 = _pyramid_scale(image, img_ndim) if _is_zarr(image) else None
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 = _pyramid_scale(labels, lab_ndim) if _is_zarr(labels) else None
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=_pyramid_scale(store, lab[0].ndim),
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,
@@ -26,4 +26,4 @@ localrules:
26
26
 
27
27
  rule all:
28
28
  input:
29
- f"{WORK}/labels.done",
29
+ f"{RUN}/labels.done",
@@ -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 # dry-run (plan only)
4
- # pixi run go # run locally
5
- # pixi run slurm # submit to SLURM (edit profile/slurm/config.yaml first)
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
- TILES = f"{WORK}/tiles.json"
11
- STAGE = f"{WORK}/stage.zarr"
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"{WORK}/logs"
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
- MODEL_OK = f"{WORK}/model.ready"
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"{WORK}/seg/{i}.done" for i in occupied]
43
+ return [f"{RUN}/seg/{i}.done" for i in occupied]
@@ -4,7 +4,7 @@ rule merge:
4
4
  input:
5
5
  occupied_done,
6
6
  output:
7
- touch(f"{WORK}/labels.done"),
7
+ touch(f"{RUN}/labels.done"),
8
8
  log:
9
9
  STEPLOG,
10
10
  script:
@@ -35,7 +35,7 @@ rule segment:
35
35
  image=IMAGE_OK,
36
36
  model=MODEL_OK,
37
37
  output:
38
- f"{WORK}/seg/{{index}}.done",
38
+ f"{RUN}/seg/{{index}}.done",
39
39
  log:
40
40
  f"{LOGS}/segment/{{index}}.log",
41
41
  script:
@@ -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(os.environ.get("SLURM_CPUS_PER_TASK", os.cpu_count() or 4))
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=cfg.get("label_name", "labels"),
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