patchworks 0.11.16__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.16 → patchworks-0.11.18}/PKG-INFO +3 -3
  2. {patchworks-0.11.16 → patchworks-0.11.18}/docs/guide/snakemake.md +123 -0
  3. {patchworks-0.11.16 → patchworks-0.11.18}/pyproject.toml +4 -3
  4. {patchworks-0.11.16 → 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.16 → patchworks-0.11.18}/src/patchworks/plugins/napari.py +82 -4
  7. patchworks-0.11.18/tests/test_relations.py +43 -0
  8. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/README.md +11 -0
  9. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/Snakefile +1 -1
  10. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/config/config.yaml +4 -0
  11. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/rules/common.smk +16 -5
  12. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/rules/merge.smk +1 -1
  13. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/rules/segment.smk +1 -1
  14. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/scripts/_pw.py +10 -4
  15. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/scripts/merge.py +6 -5
  16. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/scripts/prepare_tiles.py +4 -2
  17. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/scripts/segment_tile.py +2 -1
  18. {patchworks-0.11.16 → patchworks-0.11.18}/.github/workflows/docs.yml +0 -0
  19. {patchworks-0.11.16 → patchworks-0.11.18}/.github/workflows/lint.yml +0 -0
  20. {patchworks-0.11.16 → patchworks-0.11.18}/.github/workflows/release.yml +0 -0
  21. {patchworks-0.11.16 → patchworks-0.11.18}/.gitignore +0 -0
  22. {patchworks-0.11.16 → patchworks-0.11.18}/.markdownlint-cli2.yaml +0 -0
  23. {patchworks-0.11.16 → patchworks-0.11.18}/LICENSE +0 -0
  24. {patchworks-0.11.16 → patchworks-0.11.18}/README.md +0 -0
  25. {patchworks-0.11.16 → patchworks-0.11.18}/cliff.toml +0 -0
  26. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/chunks.md +0 -0
  27. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/cluster.md +0 -0
  28. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/io.md +0 -0
  29. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/merge_tile_labels.md +0 -0
  30. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/plugins/cellpose.md +0 -0
  31. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/plugins/napari.md +0 -0
  32. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/plugins/ome_zarr.md +0 -0
  33. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/relabel.md +0 -0
  34. {patchworks-0.11.16 → patchworks-0.11.18}/docs/api/tile_process.md +0 -0
  35. {patchworks-0.11.16 → patchworks-0.11.18}/docs/assets/logo.png +0 -0
  36. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/cellpose_2d.md +0 -0
  37. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/cellpose_2d.py +0 -0
  38. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/cellpose_3d.md +0 -0
  39. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/cellpose_3d.py +0 -0
  40. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/custom.md +0 -0
  41. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/custom_method.py +0 -0
  42. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/standalone_merge.md +0 -0
  43. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/stardist.md +0 -0
  44. {patchworks-0.11.16 → patchworks-0.11.18}/docs/examples/stardist_2d.py +0 -0
  45. {patchworks-0.11.16 → patchworks-0.11.18}/docs/getting_started.md +0 -0
  46. {patchworks-0.11.16 → patchworks-0.11.18}/docs/guide/gpu_distributed.md +0 -0
  47. {patchworks-0.11.16 → patchworks-0.11.18}/docs/guide/merging.md +0 -0
  48. {patchworks-0.11.16 → patchworks-0.11.18}/docs/guide/ome_zarr_napari.md +0 -0
  49. {patchworks-0.11.16 → patchworks-0.11.18}/docs/guide/performance.md +0 -0
  50. {patchworks-0.11.16 → patchworks-0.11.18}/docs/guide/pitfalls.md +0 -0
  51. {patchworks-0.11.16 → patchworks-0.11.18}/docs/guide/skip_empty.md +0 -0
  52. {patchworks-0.11.16 → patchworks-0.11.18}/docs/guide/tiling.md +0 -0
  53. {patchworks-0.11.16 → patchworks-0.11.18}/docs/index.md +0 -0
  54. {patchworks-0.11.16 → patchworks-0.11.18}/mkdocs.yml +0 -0
  55. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/_chunks.py +0 -0
  56. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/_cluster.py +0 -0
  57. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/_core.py +0 -0
  58. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/_distributed.py +0 -0
  59. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/_io.py +0 -0
  60. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/_merge.py +0 -0
  61. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/_relabel.py +0 -0
  62. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/plugins/__init__.py +0 -0
  63. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/plugins/cellpose.py +0 -0
  64. {patchworks-0.11.16 → patchworks-0.11.18}/src/patchworks/plugins/ome_zarr.py +0 -0
  65. {patchworks-0.11.16 → patchworks-0.11.18}/tests/test_core.py +0 -0
  66. {patchworks-0.11.16 → patchworks-0.11.18}/tests/test_distributed.py +0 -0
  67. {patchworks-0.11.16 → patchworks-0.11.18}/tests/test_napari.py +0 -0
  68. {patchworks-0.11.16 → patchworks-0.11.18}/tests/test_ome_zarr.py +0 -0
  69. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/pixi.toml +0 -0
  70. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/profile/slurm/config.yaml +0 -0
  71. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/rules/convert.smk +0 -0
  72. {patchworks-0.11.16 → patchworks-0.11.18}/workflow/scripts/convert.py +0 -0
  73. {patchworks-0.11.16 → 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.16
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,6 +142,43 @@ def _resolve_image(
142
142
  return source
143
143
 
144
144
 
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.
149
+
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.
153
+
154
+ Parameters
155
+ ----------
156
+ path : str or Path
157
+ OME-ZARR store path (image or label group) — each carries its own
158
+ ``multiscales`` metadata, so image and label stores are read
159
+ independently.
160
+ ndim : int
161
+ Number of spatial dimensions of the loaded array (2 -> "yx", 3 ->
162
+ "zyx"), used to align the calibration to the right axes.
163
+
164
+ Returns
165
+ -------
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).
170
+ """
171
+ from .ome_zarr import _base_scale, _default_axes, _read_zarr_calibration
172
+
173
+ axes = _default_axes(ndim)
174
+ pixel_size = _read_zarr_calibration(str(path), axes)
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
180
+
181
+
145
182
  def _inner_label_names(store: Union[str, Path]) -> list[str]:
146
183
  """List label images registered under an OME-ZARR's ``labels/`` group.
147
184
 
@@ -241,6 +278,18 @@ def view_in_napari(
241
278
  napari.Viewer
242
279
  The viewer instance (useful when ``show=False``).
243
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
+
244
293
  Examples
245
294
  --------
246
295
  >>> view_in_napari("scan.zarr") # auto-loads scan.zarr/labels/* # doctest: +SKIP
@@ -248,11 +297,17 @@ def view_in_napari(
248
297
  napari = _require_napari()
249
298
 
250
299
  img = _resolve_image(image, channel)
300
+ img_ndim = img[0].ndim if isinstance(img, list) else img.ndim
301
+ img_scale, img_units = (
302
+ _pyramid_calibration(image, img_ndim) if _is_zarr(image) else (None, None)
303
+ )
251
304
  viewer = napari.Viewer()
252
305
  viewer.add_image(
253
306
  img,
254
307
  name=image_name,
255
308
  multiscale=isinstance(img, list),
309
+ scale=img_scale,
310
+ units=img_units,
256
311
  **add_image_kwargs,
257
312
  )
258
313
 
@@ -288,16 +343,39 @@ def view_in_napari(
288
343
 
289
344
  if labels is not None:
290
345
  lab = _resolve_labels(labels, labels_component)
291
- viewer.add_labels(lab, name=labels_name, **label_kwargs)
346
+ lab_ndim = lab[0].ndim if isinstance(lab, list) else lab.ndim
347
+ lab_scale, lab_units = (
348
+ _pyramid_calibration(labels, lab_ndim)
349
+ if _is_zarr(labels)
350
+ else (None, None)
351
+ )
352
+ viewer.add_labels(
353
+ lab,
354
+ name=labels_name,
355
+ multiscale=isinstance(lab, list),
356
+ scale=lab_scale,
357
+ units=lab_units,
358
+ **label_kwargs,
359
+ )
292
360
  elif _is_zarr(image):
293
361
  # No labels given → auto-overlay every label image stored inside the
294
362
  # OME-ZARR under labels/<name>/ (the default place tile_process writes
295
- # them), each as its own multi-scale Labels layer.
363
+ # them), each as its own multi-scale Labels layer. Kept as a list (not
364
+ # unwrapped to a single array) even for one level, so napari always
365
+ # treats it as multiscale — required for 3D resolution switching, see
366
+ # https://napari.org/stable/gallery/add_multiscale_volume.html
296
367
  for name in _inner_label_names(image):
297
- levels = _multiscale_levels(f"{image}/labels/{name}", None)
368
+ store = f"{image}/labels/{name}"
369
+ levels = _multiscale_levels(store, None)
298
370
  lab = [lvl.astype("int32") for lvl in levels]
371
+ lab_scale, lab_units = _pyramid_calibration(store, lab[0].ndim)
299
372
  viewer.add_labels(
300
- lab if len(lab) > 1 else lab[0], name=name, **label_kwargs
373
+ lab,
374
+ name=name,
375
+ multiscale=True,
376
+ scale=lab_scale,
377
+ units=lab_units,
378
+ **label_kwargs,
301
379
  )
302
380
  logger.info("auto-loaded labels/%s from %s", name, image)
303
381
 
@@ -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