patchworks 0.11.17__tar.gz → 0.11.18__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 (73) hide show
  1. {patchworks-0.11.17 → patchworks-0.11.18}/PKG-INFO +3 -3
  2. {patchworks-0.11.17 → patchworks-0.11.18}/docs/guide/snakemake.md +123 -0
  3. {patchworks-0.11.17 → patchworks-0.11.18}/pyproject.toml +4 -3
  4. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/__init__.py +2 -0
  5. patchworks-0.11.18/src/patchworks/_relations.py +142 -0
  6. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/plugins/napari.py +41 -12
  7. patchworks-0.11.18/tests/test_relations.py +43 -0
  8. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/README.md +11 -0
  9. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/Snakefile +1 -1
  10. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/config/config.yaml +4 -0
  11. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/rules/common.smk +16 -5
  12. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/rules/merge.smk +1 -1
  13. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/rules/segment.smk +1 -1
  14. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/scripts/_pw.py +10 -4
  15. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/scripts/merge.py +6 -5
  16. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/scripts/prepare_tiles.py +4 -2
  17. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/scripts/segment_tile.py +2 -1
  18. {patchworks-0.11.17 → patchworks-0.11.18}/.github/workflows/docs.yml +0 -0
  19. {patchworks-0.11.17 → patchworks-0.11.18}/.github/workflows/lint.yml +0 -0
  20. {patchworks-0.11.17 → patchworks-0.11.18}/.github/workflows/release.yml +0 -0
  21. {patchworks-0.11.17 → patchworks-0.11.18}/.gitignore +0 -0
  22. {patchworks-0.11.17 → patchworks-0.11.18}/.markdownlint-cli2.yaml +0 -0
  23. {patchworks-0.11.17 → patchworks-0.11.18}/LICENSE +0 -0
  24. {patchworks-0.11.17 → patchworks-0.11.18}/README.md +0 -0
  25. {patchworks-0.11.17 → patchworks-0.11.18}/cliff.toml +0 -0
  26. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/chunks.md +0 -0
  27. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/cluster.md +0 -0
  28. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/io.md +0 -0
  29. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/merge_tile_labels.md +0 -0
  30. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/plugins/cellpose.md +0 -0
  31. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/plugins/napari.md +0 -0
  32. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/plugins/ome_zarr.md +0 -0
  33. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/relabel.md +0 -0
  34. {patchworks-0.11.17 → patchworks-0.11.18}/docs/api/tile_process.md +0 -0
  35. {patchworks-0.11.17 → patchworks-0.11.18}/docs/assets/logo.png +0 -0
  36. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/cellpose_2d.md +0 -0
  37. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/cellpose_2d.py +0 -0
  38. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/cellpose_3d.md +0 -0
  39. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/cellpose_3d.py +0 -0
  40. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/custom.md +0 -0
  41. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/custom_method.py +0 -0
  42. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/standalone_merge.md +0 -0
  43. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/stardist.md +0 -0
  44. {patchworks-0.11.17 → patchworks-0.11.18}/docs/examples/stardist_2d.py +0 -0
  45. {patchworks-0.11.17 → patchworks-0.11.18}/docs/getting_started.md +0 -0
  46. {patchworks-0.11.17 → patchworks-0.11.18}/docs/guide/gpu_distributed.md +0 -0
  47. {patchworks-0.11.17 → patchworks-0.11.18}/docs/guide/merging.md +0 -0
  48. {patchworks-0.11.17 → patchworks-0.11.18}/docs/guide/ome_zarr_napari.md +0 -0
  49. {patchworks-0.11.17 → patchworks-0.11.18}/docs/guide/performance.md +0 -0
  50. {patchworks-0.11.17 → patchworks-0.11.18}/docs/guide/pitfalls.md +0 -0
  51. {patchworks-0.11.17 → patchworks-0.11.18}/docs/guide/skip_empty.md +0 -0
  52. {patchworks-0.11.17 → patchworks-0.11.18}/docs/guide/tiling.md +0 -0
  53. {patchworks-0.11.17 → patchworks-0.11.18}/docs/index.md +0 -0
  54. {patchworks-0.11.17 → patchworks-0.11.18}/mkdocs.yml +0 -0
  55. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/_chunks.py +0 -0
  56. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/_cluster.py +0 -0
  57. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/_core.py +0 -0
  58. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/_distributed.py +0 -0
  59. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/_io.py +0 -0
  60. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/_merge.py +0 -0
  61. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/_relabel.py +0 -0
  62. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/plugins/__init__.py +0 -0
  63. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/plugins/cellpose.py +0 -0
  64. {patchworks-0.11.17 → patchworks-0.11.18}/src/patchworks/plugins/ome_zarr.py +0 -0
  65. {patchworks-0.11.17 → patchworks-0.11.18}/tests/test_core.py +0 -0
  66. {patchworks-0.11.17 → patchworks-0.11.18}/tests/test_distributed.py +0 -0
  67. {patchworks-0.11.17 → patchworks-0.11.18}/tests/test_napari.py +0 -0
  68. {patchworks-0.11.17 → patchworks-0.11.18}/tests/test_ome_zarr.py +0 -0
  69. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/pixi.toml +0 -0
  70. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/profile/slurm/config.yaml +0 -0
  71. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/rules/convert.smk +0 -0
  72. {patchworks-0.11.17 → patchworks-0.11.18}/workflow/scripts/convert.py +0 -0
  73. {patchworks-0.11.17 → patchworks-0.11.18}/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.18
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,129 @@ 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
+ Both land side by side in the same store:
240
+
241
+ ```text
242
+ results/image.zarr/labels/nuclei_labels/
243
+ results/image.zarr/labels/cyto_labels/
244
+ ```
245
+
246
+ !!! tip "Keep `tile_shape` (and `level`) identical across configs"
247
+ Different segmentations of the same image can use different `channel` and
248
+ `cellpose:` settings freely, but keep `tile_shape`/`level` the same across
249
+ configs — the label arrays then share the exact same chunk layout, which
250
+ `label_relations()` (below) requires.
251
+
252
+ ### Relating labels across segmentations
253
+
254
+ Once you have two segmentations of the same image (e.g. nuclei inside cells),
255
+ `label_relations()` maps each label in one to the label it overlaps most in
256
+ the other — by streaming both arrays chunk by chunk, so it scales to
257
+ hundreds of thousands of objects without loading anything fully into RAM:
258
+
259
+ ```python
260
+ import dask.array as da
261
+ from patchworks import label_relations
262
+
263
+ nuclei = da.from_zarr("results/image.zarr", component="labels/nuclei_labels/0")
264
+ cells = da.from_zarr("results/image.zarr", component="labels/cyto_labels/0")
265
+
266
+ table = label_relations(nuclei, cells)
267
+ table[2]
268
+ # {'match': 3, 'overlap_voxels': 4821, 'overlap_fraction': 0.94}
269
+ # -> nucleus 2 belongs to cell 3, 94% of its voxels fall inside it
270
+ ```
271
+
272
+ Save it as a table:
273
+
274
+ ```python
275
+ import csv
276
+
277
+ with open("nuclei_to_cell.csv", "w", newline="") as f:
278
+ w = csv.writer(f)
279
+ w.writerow(["nucleus_id", "cell_id", "overlap_voxels", "overlap_fraction"])
280
+ for nucleus_id, m in table.items():
281
+ w.writerow([nucleus_id, m["match"], m["overlap_voxels"], m["overlap_fraction"]])
282
+ ```
283
+
284
+ ## Measurements (fast, whole-volume regionprops)
285
+
286
+ `skimage.measure.regionprops` needs the full labelled + intensity array in
287
+ RAM — fine for one tile, not for a hundred-thousand-object OME-ZARR. Use
288
+ [`dask-image`](https://image.dask.org)'s `ndmeasure`, which computes directly
289
+ on the dask/zarr-backed arrays, chunk-parallel, without materializing the
290
+ volume:
291
+
292
+ ```bash
293
+ pip install dask-image
294
+ ```
295
+
296
+ ```python
297
+ import dask.array as da
298
+ from dask_image.ndmeasure import area, center_of_mass, mean, standard_deviation
299
+
300
+ labels = da.from_zarr("results/image.zarr", component="labels/cyto_labels/0")
301
+ image = da.from_zarr("results/image.zarr", component="0")[0] # channel 0, level 0
302
+
303
+ ids = da.unique(labels[labels > 0]).compute()
304
+ areas = area(image, labels, ids).compute() # voxel counts
305
+ means = mean(image, labels, ids).compute() # mean intensity
306
+ stds = standard_deviation(image, labels, ids).compute()
307
+ centroids = center_of_mass(image, labels, ids).compute() # voxel coords (z, y, x)
308
+ ```
309
+
310
+ Multiply `areas` by the voxel's physical volume and `centroids` by the pixel
311
+ size (both read straight from the OME-ZARR's own `multiscales` metadata) to
312
+ get µm-scale measurements.
313
+
314
+ For interactively inspecting individual cells by clicking in the viewer
315
+ (not all objects at once), the
316
+ [napari-skimage-regionprops](https://github.com/haesleinhuepf/napari-skimage-regionprops)
317
+ plugin's table widget works well — point it at a cropped region rather than
318
+ the full volume, since it loads its input fully into memory.
319
+
197
320
  ## Custom segmentation function
198
321
 
199
322
  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,142 @@
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(source: Union["da.Array", str, Path], component: str) -> "da.Array":
18
+ if isinstance(source, (str, Path)):
19
+ return da.from_zarr(str(source), component=component)
20
+ return source
21
+
22
+
23
+ def _chunk_pairs(a_block: np.ndarray, b_block: np.ndarray) -> np.ndarray:
24
+ """Non-background ``(a_id, b_id) -> voxel count`` rows for one chunk pair."""
25
+ mask = (a_block > 0) & (b_block > 0)
26
+ if not mask.any():
27
+ return np.empty((0, 3), dtype=np.int64)
28
+ pairs = np.stack([a_block[mask], b_block[mask]], axis=1).astype(np.int64)
29
+ uniq, counts = np.unique(pairs, axis=0, return_counts=True)
30
+ return np.concatenate([uniq, counts[:, None]], axis=1)
31
+
32
+
33
+ def label_relations(
34
+ a: Union["da.Array", str, Path],
35
+ b: Union["da.Array", str, Path],
36
+ *,
37
+ a_component: str = "labels",
38
+ b_component: str = "labels",
39
+ n_workers: int | None = None,
40
+ ) -> dict[int, dict[str, float]]:
41
+ """Map each label in *a* to its best-overlapping label in *b*.
42
+
43
+ For every non-background label in *a* (e.g. a nucleus segmentation),
44
+ finds the label in *b* (e.g. a cell/cytoplasm segmentation of the same
45
+ image) it shares the most voxels with. Streams both arrays chunk by
46
+ chunk — memory is bounded by the number of distinct label pairs (one row
47
+ per touching (a, b) pair per chunk), not by volume size.
48
+
49
+ *a* and *b* must be two segmentations of the **same image** (identical
50
+ shape and chunk layout) — e.g. two runs of the Snakemake workflow with
51
+ different ``label_name``/``cellpose:`` config but the same ``tile_shape``.
52
+
53
+ Parameters
54
+ ----------
55
+ a, b : da.Array, str or Path
56
+ Two label arrays of identical shape and chunking. A path is read via
57
+ ``dask.array.from_zarr(path, component=...)``.
58
+ a_component, b_component : str, optional
59
+ Zarr array name inside *a*/*b* when they're store paths (default
60
+ ``"labels"``).
61
+ n_workers : int or None, optional
62
+ Parallel chunk workers. Default ``min(4, cpu_count)``.
63
+
64
+ Returns
65
+ -------
66
+ dict
67
+ ``{a_label: {"match": b_label, "overlap_voxels": int,
68
+ "overlap_fraction": float}}`` — one entry per *a* label that touches
69
+ at least one non-background *b* voxel. ``overlap_fraction`` is the
70
+ matched voxel count over *a* label's total voxel count (1.0 = fully
71
+ contained). Labels in *a* with zero overlap are omitted.
72
+
73
+ Examples
74
+ --------
75
+ >>> from patchworks import label_relations
76
+ >>> table = label_relations(
77
+ ... "scan.zarr/labels/nuclei", "scan.zarr/labels/cells"
78
+ ... ) # doctest: +SKIP
79
+ >>> table[2] # nucleus 2 sits inside cell 3 # doctest: +SKIP
80
+ {'match': 3, 'overlap_voxels': 4821, 'overlap_fraction': 0.94}
81
+ """
82
+ a = _as_dask(a, a_component)
83
+ b = _as_dask(b, b_component)
84
+ if a.shape != b.shape:
85
+ raise ValueError(f"shape mismatch: a={a.shape} b={b.shape}")
86
+ if a.chunks != b.chunks:
87
+ raise ValueError(
88
+ "a and b must share the same chunk layout "
89
+ f"(a={a.chunks} b={b.chunks}); rechunk one to match the other, "
90
+ "e.g. b = b.rechunk(a.chunks)"
91
+ )
92
+
93
+ n_blocks = a.numblocks
94
+ total = int(np.prod(n_blocks))
95
+ nw = n_workers if n_workers is not None else min(4, os.cpu_count() or 1)
96
+
97
+ def _one(flat_idx: int) -> np.ndarray:
98
+ idx = np.unravel_index(flat_idx, n_blocks)
99
+ return _chunk_pairs(np.asarray(a.blocks[idx]), np.asarray(b.blocks[idx]))
100
+
101
+ with ThreadPoolExecutor(max_workers=nw) as ex:
102
+ parts = list(ex.map(_one, range(total)))
103
+
104
+ rows = [p for p in parts if p.size]
105
+ if not rows:
106
+ return {}
107
+ all_pairs = np.concatenate(rows, axis=0)
108
+
109
+ # Merge duplicate (a_id, b_id) rows across chunks (a label can span
110
+ # several chunks) by sorting on a combined key and summing runs.
111
+ key = all_pairs[:, 0] * (int(all_pairs[:, 1].max()) + 1) + all_pairs[:, 1]
112
+ order = np.argsort(key, kind="stable")
113
+ all_pairs, key = all_pairs[order], key[order]
114
+ starts = np.concatenate([[0], np.flatnonzero(np.diff(key)) + 1])
115
+ merged_counts = np.add.reduceat(all_pairs[:, 2], starts)
116
+ merged = np.stack(
117
+ [all_pairs[starts, 0], all_pairs[starts, 1], merged_counts], axis=1
118
+ )
119
+
120
+ a_totals: dict[int, int] = {}
121
+ for a_id, _, count in merged:
122
+ a_id = int(a_id)
123
+ a_totals[a_id] = a_totals.get(a_id, 0) + int(count)
124
+
125
+ best: dict[int, tuple[int, int]] = {}
126
+ for a_id, b_id, count in merged:
127
+ a_id, b_id, count = int(a_id), int(b_id), int(count)
128
+ cur = best.get(a_id)
129
+ if cur is None or count > cur[1]:
130
+ best[a_id] = (b_id, count)
131
+
132
+ logger.info(
133
+ "label_relations: %d a-labels matched across %d chunks", len(best), total
134
+ )
135
+ return {
136
+ a_id: {
137
+ "match": b_id,
138
+ "overlap_voxels": count,
139
+ "overlap_fraction": count / a_totals[a_id],
140
+ }
141
+ for a_id, (b_id, count) in best.items()
142
+ }
@@ -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,16 @@ 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) if _is_zarr(image) else (None, None)
303
+ )
283
304
  viewer = napari.Viewer()
284
305
  viewer.add_image(
285
306
  img,
286
307
  name=image_name,
287
308
  multiscale=isinstance(img, list),
288
309
  scale=img_scale,
310
+ units=img_units,
289
311
  **add_image_kwargs,
290
312
  )
291
313
 
@@ -322,12 +344,17 @@ def view_in_napari(
322
344
  if labels is not None:
323
345
  lab = _resolve_labels(labels, labels_component)
324
346
  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
347
+ lab_scale, lab_units = (
348
+ _pyramid_calibration(labels, lab_ndim)
349
+ if _is_zarr(labels)
350
+ else (None, None)
351
+ )
326
352
  viewer.add_labels(
327
353
  lab,
328
354
  name=labels_name,
329
355
  multiscale=isinstance(lab, list),
330
356
  scale=lab_scale,
357
+ units=lab_units,
331
358
  **label_kwargs,
332
359
  )
333
360
  elif _is_zarr(image):
@@ -341,11 +368,13 @@ def view_in_napari(
341
368
  store = f"{image}/labels/{name}"
342
369
  levels = _multiscale_levels(store, None)
343
370
  lab = [lvl.astype("int32") for lvl in levels]
371
+ lab_scale, lab_units = _pyramid_calibration(store, lab[0].ndim)
344
372
  viewer.add_labels(
345
373
  lab,
346
374
  name=name,
347
375
  multiscale=True,
348
- scale=_pyramid_scale(store, lab[0].ndim),
376
+ scale=lab_scale,
377
+ units=lab_units,
349
378
  **label_kwargs,
350
379
  )
351
380
  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,17 @@ 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
+
78
89
  ## Output
79
90
 
80
91
  `<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"
@@ -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,8 +17,9 @@ 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
@@ -26,7 +27,7 @@ merged_store = str(Path(work_dir) / "_merged.zarr")
26
27
  # the cores it's paying for; merge_workers: in config.yaml can still override.
27
28
  default_workers = int(os.environ.get("SLURM_CPUS_PER_TASK", os.cpu_count() or 4))
28
29
  merged = merge_tile_labels(
29
- stage_path(work_dir),
30
+ stage_path(work_dir, label_name),
30
31
  write_to=merged_store,
31
32
  input_component="staged",
32
33
  sequential_labels=cfg.get("sequential_labels", True),
@@ -36,18 +37,18 @@ merged = merge_tile_labels(
36
37
  group = write_labels(
37
38
  image_store,
38
39
  merged,
39
- name=cfg.get("label_name", "labels"),
40
+ name=label_name,
40
41
  n_levels=int(cfg.get("pyramid_levels", 5)),
41
42
  downscale=int(cfg.get("pyramid_downscale", 2)),
42
43
  overwrite=True,
43
44
  )
44
45
 
45
46
  shutil.rmtree(merged_store, ignore_errors=True)
46
- shutil.rmtree(stage_path(work_dir), ignore_errors=True)
47
+ shutil.rmtree(stage_path(work_dir, label_name), ignore_errors=True)
47
48
  # Also drop the checkpoint's completion sentinel (stage.zarr.done): the
48
49
  # "prepare" rule's stage=touch(STAGE_OK) output must not outlive the store it
49
50
  # claims exists, or a future rerun (e.g. re-segmenting for new labels) skips
50
51
  # "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)
52
+ Path(f"{stage_path(work_dir, label_name)}.done").unlink(missing_ok=True)
52
53
  print(f"[patchworks] labels written to {group}")
53
54
  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),
@@ -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