patchworks 2.6.3__tar.gz → 2.6.5__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 (102) hide show
  1. {patchworks-2.6.3 → patchworks-2.6.5}/PKG-INFO +1 -1
  2. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/snakemake.md +24 -3
  3. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_run_multi.py +97 -11
  4. patchworks-2.6.5/workflow/scripts/relate.py +149 -0
  5. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/run_multi.py +186 -105
  6. {patchworks-2.6.3 → patchworks-2.6.5}/.github/workflows/docs.yml +0 -0
  7. {patchworks-2.6.3 → patchworks-2.6.5}/.github/workflows/lint.yml +0 -0
  8. {patchworks-2.6.3 → patchworks-2.6.5}/.github/workflows/release.yml +0 -0
  9. {patchworks-2.6.3 → patchworks-2.6.5}/.gitignore +0 -0
  10. {patchworks-2.6.3 → patchworks-2.6.5}/.markdownlint-cli2.yaml +0 -0
  11. {patchworks-2.6.3 → patchworks-2.6.5}/LICENSE +0 -0
  12. {patchworks-2.6.3 → patchworks-2.6.5}/README.md +0 -0
  13. {patchworks-2.6.3 → patchworks-2.6.5}/cliff.toml +0 -0
  14. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/chunks.md +0 -0
  15. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/cluster.md +0 -0
  16. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/io.md +0 -0
  17. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/merge_tile_labels.md +0 -0
  18. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/plugins/cellpose.md +0 -0
  19. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/plugins/dog.md +0 -0
  20. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/plugins/napari.md +0 -0
  21. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/plugins/ome_zarr.md +0 -0
  22. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/postprocess.md +0 -0
  23. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/relabel.md +0 -0
  24. {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/tile_process.md +0 -0
  25. {patchworks-2.6.3 → patchworks-2.6.5}/docs/assets/logo.png +0 -0
  26. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/cellpose_2d.md +0 -0
  27. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/cellpose_2d.py +0 -0
  28. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/cellpose_3d.md +0 -0
  29. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/cellpose_3d.py +0 -0
  30. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/custom.md +0 -0
  31. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/custom_method.py +0 -0
  32. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/dog.md +0 -0
  33. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/dog.py +0 -0
  34. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/standalone_merge.md +0 -0
  35. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/stardist.md +0 -0
  36. {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/stardist_2d.py +0 -0
  37. {patchworks-2.6.3 → patchworks-2.6.5}/docs/getting_started.md +0 -0
  38. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/custom_segmentation.md +0 -0
  39. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/gpu_distributed.md +0 -0
  40. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/label_relations.md +0 -0
  41. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/measurements.md +0 -0
  42. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/merging.md +0 -0
  43. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/ome_zarr_napari.md +0 -0
  44. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/performance.md +0 -0
  45. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/pitfalls.md +0 -0
  46. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/skip_empty.md +0 -0
  47. {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/tiling.md +0 -0
  48. {patchworks-2.6.3 → patchworks-2.6.5}/docs/index.md +0 -0
  49. {patchworks-2.6.3 → patchworks-2.6.5}/mkdocs.yml +0 -0
  50. {patchworks-2.6.3 → patchworks-2.6.5}/pyproject.toml +0 -0
  51. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/__init__.py +0 -0
  52. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_chunks.py +0 -0
  53. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_cluster.py +0 -0
  54. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_core.py +0 -0
  55. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_distributed.py +0 -0
  56. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_gpu.py +0 -0
  57. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_io.py +0 -0
  58. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_merge.py +0 -0
  59. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_notify.py +0 -0
  60. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_occupancy.py +0 -0
  61. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_postprocess.py +0 -0
  62. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_progress.py +0 -0
  63. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_relabel.py +0 -0
  64. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_relations.py +0 -0
  65. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/__init__.py +0 -0
  66. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/cellpose.py +0 -0
  67. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/dog.py +0 -0
  68. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/napari.py +0 -0
  69. {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/ome_zarr.py +0 -0
  70. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_allocation.py +0 -0
  71. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_core.py +0 -0
  72. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_distributed.py +0 -0
  73. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_dog.py +0 -0
  74. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_gpu.py +0 -0
  75. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_napari.py +0 -0
  76. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_notify.py +0 -0
  77. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_occupancy.py +0 -0
  78. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_ome_zarr.py +0 -0
  79. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_postprocess.py +0 -0
  80. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_progress.py +0 -0
  81. {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_relations.py +0 -0
  82. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/README.md +0 -0
  83. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/Snakefile +0 -0
  84. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/common.yaml +0 -0
  85. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/config.yaml +0 -0
  86. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/config_cilia.yaml +0 -0
  87. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/config_cyto.yaml +0 -0
  88. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/config_nuclei.yaml +0 -0
  89. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/multi.yaml +0 -0
  90. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/pixi.toml +0 -0
  91. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/profile/slurm/config.yaml +0 -0
  92. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/rules/common.smk +0 -0
  93. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/rules/convert.smk +0 -0
  94. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/rules/merge.smk +0 -0
  95. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/rules/segment.smk +0 -0
  96. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/_pw.py +0 -0
  97. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/build_occupancy.py +0 -0
  98. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/convert.py +0 -0
  99. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/fetch_model.py +0 -0
  100. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/merge.py +0 -0
  101. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/prepare_tiles.py +0 -0
  102. {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: patchworks
3
- Version: 2.6.3
3
+ Version: 2.6.5
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
@@ -398,12 +398,21 @@ results/image.zarr/labels/nuclei_labels/
398
398
  results/image.zarr/labels/cyto_labels/
399
399
  ```
400
400
 
401
- !!! tip "Keep `tile_shape` (and `level`) identical across configs"
402
- Different segmentations of the same image can use different `channel` and
403
- `cellpose:` settings freely, but keep `tile_shape`/`level` the same across
401
+ !!! tip "Keep `level` identical across configs; `tile_shape` usually takes care of itself"
402
+ Different segmentations of the same image can use different `channel`
403
+ and `cellpose:` settings freely, but keep `level` the same across
404
404
  configs — the label arrays then share the exact same chunk layout, which
405
405
  [`label_relations()`](label_relations.md) requires.
406
406
 
407
+ `tile_shape` is a little more forgiving under `run_multi.py`: leaving it
408
+ on `"auto"` (the default) would normally size a `nuclei_channel` config's
409
+ tile smaller than a single-channel one (the sizer charges per channel),
410
+ breaking that same requirement — `run_multi.py` detects this and pins
411
+ every config to the smallest computed tile automatically, so mixed
412
+ single-/two-channel configs under `"auto"` just work. Setting an
413
+ explicit `tile_shape` yourself still requires it to be identical across
414
+ configs, same as before.
415
+
407
416
  See [Relating labels across segmentations](label_relations.md) for what
408
417
  `label_relations()` returns and how to save it yourself — the cluster
409
418
  workflow's own automation is below.
@@ -451,6 +460,18 @@ the GPU partition stays busy instead of idling through every config's
451
460
  `prepare` and multi-hour `merge` in turn. A config that fails does **not**
452
461
  abort the others; you get a per-config status and a non-zero exit.
453
462
 
463
+ !!! tip "The relate step runs on the cluster too, under `multi-slurm`"
464
+ `label_relations()` streams every chunk of two full-resolution label
465
+ volumes — real CPU/IO work, not orchestration. Under `multi-slurm` it is
466
+ submitted as its own `srun` job (`scripts/relate.py`) instead of running
467
+ in the driver process on the login node, the same fix already applied to
468
+ the occupancy map. Tune its allocation with `--relate-partition`,
469
+ `--relate-mem`, `--relate-cpus` and `--relate-time` (defaults: `scicore`,
470
+ `32G`, `8`, `180` minutes) — these are wide-margin guesses, not measured
471
+ numbers, so raise them for a very large or very object-dense pair. Under
472
+ plain `multi` (no `--profile`), it still runs locally, in-process, as
473
+ before.
474
+
454
475
  !!! tip "After a killed run"
455
476
  Snakemake only releases its lock on a clean exit, so a run that was killed
456
477
  (Ctrl-C, an SSH drop, an OOM) leaves the directory locked. Each phase has
@@ -160,28 +160,114 @@ def test_occupancy_is_not_rebuilt_by_the_driver():
160
160
  assert "occupancy.zarr" in src
161
161
 
162
162
 
163
- def test_auto_tile_shape_with_a_lone_nuclei_channel_is_refused():
164
- """Matching `tile_shape` *values* are not enough when one config is 2-ch.
163
+ def test_relate_is_submitted_via_slurm_under_profile():
164
+ """The relate step must never run in-process on the submit host.
165
165
 
166
- "auto" == "auto" passes the plain equality check, but the sizer charges
167
- per channel, so the nuclei_channel config gets a smaller tile. The label
168
- groups then disagree on chunk layout and label_relations raises -- after
169
- every segmentation has already run, which is the expensive way to find out.
166
+ Same failure mode as the occupancy map: label_relations() streams every
167
+ chunk of two full-resolution label volumes. A prior fix moved the map
168
+ build off the login node; the relate step made the identical mistake and
169
+ hung there for the same reason until this fix.
170
+ """
171
+ src = (_workflow_dir() / "scripts" / "run_multi.py").read_text()
172
+ assert "from relate import run_relations" in src
173
+ assert '"srun"' in src
174
+ assert "label_relations(" not in src
175
+
176
+
177
+ def test_relate_script_has_the_real_bookkeeping():
178
+ """relate.py must be the actual implementation, not a stub.
179
+
180
+ Submitting the wrong (or a trimmed-down) script would silently produce a
181
+ workbook missing the unmatched-label rows the docstring promises.
182
+ """
183
+ src = (_workflow_dir() / "scripts" / "relate.py").read_text()
184
+ assert "def run_relations(" in src
185
+ assert "label_relations" in src
186
+ assert "openpyxl" in src
187
+
188
+
189
+ def test_mixed_nuclei_channel_auto_passes_validation():
190
+ """A channel-count mismatch under `tile_shape: "auto"` is no longer
191
+
192
+ refused at validation time -- it's resolved automatically instead (see
193
+ `_resolve_shared_tile_shape`), which needs the converted image's real
194
+ shape/dtype and so can only run after phase A, not from
195
+ `_validate_configs()`. This used to `sys.exit` here; asserting that
196
+ would now be testing the wrong layer.
170
197
  """
171
198
  paths = [Path("a.yaml"), Path("b.yaml")]
172
199
  base = {"work_dir": "/w", "tile_shape": "auto", "level": 0}
173
200
 
174
- bad = [
201
+ mixed = [
175
202
  {**base, "label_name": "a", "channel": 0, "nuclei_channel": 1},
176
203
  {**base, "label_name": "b", "channel": 2},
177
204
  ]
178
- with pytest.raises(SystemExit):
179
- _validate_configs(paths, bad)
205
+ assert _validate_configs(paths, mixed) == "/w"
180
206
 
181
207
  # Same pair with one explicit shape is fine: both get that tile.
182
- pinned = [{**c, "tile_shape": [16, 512, 512]} for c in bad]
208
+ pinned = [{**c, "tile_shape": [16, 512, 512]} for c in mixed]
183
209
  assert _validate_configs(paths, pinned) == "/w"
184
210
 
185
211
  # And "auto" is fine when every config carries the same channel count.
186
- both = [{**bad[0]}, {**bad[1], "nuclei_channel": 3}]
212
+ both = [{**mixed[0]}, {**mixed[1], "nuclei_channel": 3}]
187
213
  assert _validate_configs(paths, both) == "/w"
214
+
215
+
216
+ def test_resolve_shared_tile_shape_pins_the_smallest_candidate(
217
+ tmp_path, monkeypatch
218
+ ):
219
+ """The shared tile must be the tightest of every config's own budget.
220
+
221
+ A larger tile than some config's own sizer output would ask that config
222
+ for more memory than its settings were judged to need -- only the
223
+ smallest candidate is safe for every config at once.
224
+ """
225
+ import run_multi
226
+
227
+ class _FakeImage:
228
+ shape = (10, 100, 100)
229
+ dtype = "uint16"
230
+
231
+ calls = []
232
+
233
+ def _fake_load_ome_zarr(store, *, channel, level):
234
+ calls.append((store, channel, level))
235
+ return _FakeImage()
236
+
237
+ # One tile per config, matched up by call order (channel 0 then 1).
238
+ fake_tiles = [(8, 64, 64), (4, 32, 32)]
239
+
240
+ def _fake_sizer_cellpose(shape, dtype, **kwargs):
241
+ return fake_tiles[len(calls) - 1]
242
+
243
+ monkeypatch.setattr(
244
+ "patchworks.load_ome_zarr", _fake_load_ome_zarr, raising=False
245
+ )
246
+ monkeypatch.setattr(
247
+ "patchworks.auto_tile_shape_cellpose",
248
+ _fake_sizer_cellpose,
249
+ raising=False,
250
+ )
251
+
252
+ seg_cfgs = [
253
+ {
254
+ "channel": 0,
255
+ "level": 0,
256
+ "method": "cellpose",
257
+ "cellpose": {"do_3D": True, "gpu": True},
258
+ },
259
+ {
260
+ "channel": 1,
261
+ "level": 0,
262
+ "nuclei_channel": 2,
263
+ "method": "cellpose",
264
+ "cellpose": {"do_3D": True, "gpu": True},
265
+ },
266
+ ]
267
+ out = run_multi._resolve_shared_tile_shape(
268
+ seg_cfgs, "/w/image.zarr", str(tmp_path)
269
+ )
270
+ assert out == tmp_path / ".multi_tile_shape.generated.yaml"
271
+ written = yaml.safe_load(out.read_text())
272
+ assert written == {"tile_shape": [4, 32, 32]} # the smaller candidate
273
+ assert len(calls) == 2
@@ -0,0 +1,149 @@
1
+ """Compute label_relations for configured pairs and write .xlsx.
2
+
3
+ Split out of run_multi.py so this step can be submitted as its own SLURM job
4
+ instead of running in-process on the login node. It streams every chunk of
5
+ two full-resolution label volumes -- real CPU/IO work, not orchestration --
6
+ same reasoning as the occupancy-map fix (see run_multi.py's phase A comment).
7
+
8
+ Usage (called by run_multi.py under --profile, but also runnable standalone,
9
+ e.g. under srun):
10
+ python scripts/relate.py --work-dir /path/to/work_dir \
11
+ --image-store /path/to/work_dir/image.zarr \
12
+ --relations '[{"a": "nuclei_labels", "b": "cyto_labels", "output": "nuclei_to_cyto.xlsx"}]'
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ from pathlib import Path
20
+
21
+
22
+ def _label_ids(image_store: str, name: str) -> list[int]:
23
+ """Ids present in a label image, without scanning the volume.
24
+
25
+ The merge writes n_objects/sequential_labels into the label group's
26
+ attrs precisely so consumers don't have to re-derive the id set; the ids
27
+ are 1..n_objects by construction. Fall back to a full scan only for a
28
+ label group written before those attrs existed.
29
+ """
30
+ import dask.array as da
31
+ import zarr
32
+
33
+ attrs = dict(zarr.open_group(f"{image_store}/labels/{name}").attrs)
34
+ if attrs.get("sequential_labels") and attrs.get("n_objects") is not None:
35
+ return list(range(1, int(attrs["n_objects"]) + 1))
36
+ print(
37
+ f"[relate] {name}: no n_objects attr, falling back to a full scan "
38
+ "for its id set",
39
+ flush=True,
40
+ )
41
+ arr = da.from_zarr(image_store, component=f"labels/{name}/0")
42
+ return sorted(int(x) for x in da.unique(arr[arr > 0]).compute())
43
+
44
+
45
+ def run_relations(
46
+ work_dir: str, image_store: str, relations: list[dict]
47
+ ) -> None:
48
+ """Compute and write every configured relation pair as an .xlsx workbook.
49
+
50
+ Parameters
51
+ ----------
52
+ work_dir : str
53
+ Directory relation workbooks are written into (a relation's
54
+ ``output``, when relative, resolves against this).
55
+ image_store : str
56
+ The shared ``image.zarr`` holding every config's ``labels/<name>``.
57
+ relations : list of dict
58
+ Each ``{"a": ..., "b": ..., "output": ...}`` (``output`` optional,
59
+ defaults to ``<a>_to_<b>.xlsx``), matching ``multi.yaml``'s
60
+ ``relations:`` list.
61
+ """
62
+ import dask.array as da
63
+ import openpyxl
64
+
65
+ from patchworks import label_relations
66
+
67
+ for rel in relations:
68
+ a_name, b_name = rel["a"], rel["b"]
69
+ out_path = Path(work_dir) / rel.get(
70
+ "output", f"{a_name}_to_{b_name}.xlsx"
71
+ )
72
+ print(f"[relate] relating {a_name} -> {b_name} …", flush=True)
73
+ a = da.from_zarr(image_store, component=f"labels/{a_name}/0")
74
+ b = da.from_zarr(image_store, component=f"labels/{b_name}/0")
75
+ table = label_relations(a, b)
76
+
77
+ # label_relations() only returns a-objects that touch a b-object.
78
+ # Pull the full id sets so unmatched a-objects (zero overlap) and
79
+ # b-objects with no matches at all still get a row -- otherwise
80
+ # they'd silently vanish instead of counting as zero.
81
+ a_ids = _label_ids(image_store, a_name)
82
+ b_ids = _label_ids(image_store, b_name)
83
+
84
+ per_b = {b_id: {"count": 0, "overlap_voxels": 0} for b_id in b_ids}
85
+ for m in table.values():
86
+ agg = per_b.get(m["match"])
87
+ if agg is not None:
88
+ agg["count"] += 1
89
+ agg["overlap_voxels"] += m["overlap_voxels"]
90
+
91
+ wb = openpyxl.Workbook()
92
+ ws_a = wb.active
93
+ ws_a.title = a_name[:31] # Excel sheet-name length limit
94
+ ws_a.append(
95
+ [
96
+ f"{a_name}_id",
97
+ f"{b_name}_id",
98
+ "overlap_voxels",
99
+ "overlap_fraction",
100
+ ]
101
+ )
102
+ for a_id in a_ids:
103
+ m = table.get(a_id)
104
+ if m is None:
105
+ ws_a.append([a_id, None, 0, 0]) # no overlap -- still counted
106
+ else:
107
+ ws_a.append(
108
+ [
109
+ a_id,
110
+ m["match"],
111
+ m["overlap_voxels"],
112
+ m["overlap_fraction"],
113
+ ]
114
+ )
115
+
116
+ ws_b = wb.create_sheet(title=b_name[:31])
117
+ ws_b.append(
118
+ [f"{b_name}_id", f"{a_name}_count", "total_overlap_voxels"]
119
+ )
120
+ for b_id in b_ids:
121
+ agg = per_b[b_id]
122
+ ws_b.append([b_id, agg["count"], agg["overlap_voxels"]])
123
+
124
+ wb.save(out_path)
125
+ print(
126
+ f"[relate] wrote {out_path} "
127
+ f"({len(a_ids)} {a_name}, {len(b_ids)} {b_name})",
128
+ flush=True,
129
+ )
130
+
131
+
132
+ def main() -> None:
133
+ parser = argparse.ArgumentParser(description=__doc__)
134
+ parser.add_argument("--work-dir", required=True)
135
+ parser.add_argument("--image-store", required=True)
136
+ parser.add_argument(
137
+ "--relations",
138
+ required=True,
139
+ help=(
140
+ "JSON list of {a, b, output} dicts, matching multi.yaml's "
141
+ "relations:"
142
+ ),
143
+ )
144
+ args = parser.parse_args()
145
+ run_relations(args.work_dir, args.image_store, json.loads(args.relations))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -20,12 +20,16 @@ Once all segmentations succeed, each configured relation pair is computed via
20
20
  patchworks.label_relations and written as an Excel workbook in work_dir,
21
21
  with two sheets: one row per a-object (unmatched ones included, with an
22
22
  empty b-id and zeros) and one row per b-object (a-object count + total
23
- overlap, including b-objects with zero matches).
23
+ overlap, including b-objects with zero matches). Under --profile, this runs
24
+ as a submitted SLURM job (see scripts/relate.py) rather than in-process here
25
+ -- same reasoning as the occupancy-map fix: it streams entire label volumes,
26
+ which is real work, not orchestration, and does not belong on the login node.
24
27
  """
25
28
 
26
29
  from __future__ import annotations
27
30
 
28
31
  import argparse
32
+ import json
29
33
  import re
30
34
  import subprocess
31
35
  import sys
@@ -50,6 +54,7 @@ def _snakemake_cmd(
50
54
  extra: list[str] | None = None,
51
55
  jobname_prefix: str | None = None,
52
56
  common: Path | None = None,
57
+ extra_configfiles: list[Path] | None = None,
53
58
  ) -> list[str]:
54
59
  """Build one snakemake invocation.
55
60
 
@@ -62,10 +67,17 @@ def _snakemake_cmd(
62
67
  settings every config shares -- the input, the work_dir, everything
63
68
  ``convert`` reads -- live in one file and the per-config file carries only
64
69
  what actually differs.
70
+
71
+ *extra_configfiles*, when given, are appended after *configfile* and so
72
+ win over both it and *common* -- used to pin a driver-computed value
73
+ (e.g. a resolved ``tile_shape``) across every config without editing any
74
+ config file on disk.
65
75
  """
66
76
  configfiles = [str(configfile.resolve())]
67
77
  if common is not None:
68
78
  configfiles.insert(0, str(common.resolve()))
79
+ if extra_configfiles:
80
+ configfiles += [str(p.resolve()) for p in extra_configfiles]
69
81
  cmd = [
70
82
  "snakemake",
71
83
  "-s",
@@ -230,21 +242,12 @@ def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
230
242
 
231
243
  # `tile_shape: "auto"` is identical as a *value* across configs while
232
244
  # producing different tiles: the sizer charges per channel, so a config
233
- # with nuclei_channel gets a smaller one. The label groups then disagree on
234
- # chunk layout and label_relations raises -- after every segmentation has
235
- # run. Matching values are not enough here, so check the inputs that feed
236
- # the sizer instead.
237
- if {repr(cfg.get("tile_shape", "auto")) for cfg in cfgs} == {repr("auto")}:
238
- if len({cfg.get("nuclei_channel") is not None for cfg in cfgs}) != 1:
239
- problems.append(
240
- 'tile_shape: "auto" sizes a nuclei_channel config smaller '
241
- "(a tile carries two channels), so the label groups would "
242
- "not share a chunk layout and label_relations would fail "
243
- f"after every segmentation had run; got "
244
- f"{_spread('nuclei_channel')}. Set one explicit tile_shape in "
245
- "the file `common:` points at, sized for the two-channel "
246
- "config."
247
- )
245
+ # with nuclei_channel gets a smaller one. The label groups would then
246
+ # disagree on chunk layout and label_relations would raise -- after every
247
+ # segmentation had run. That used to be a hard error asking for a manual
248
+ # explicit tile_shape; main() now resolves and pins one automatically
249
+ # (see _resolve_shared_tile_shape), once the converted image exists to
250
+ # size against, so there is nothing to check here anymore.
248
251
 
249
252
  # Phase A converts once, from the first config. Anything `convert` reads
250
253
  # out of a later config is therefore silently ignored -- someone setting
@@ -288,6 +291,92 @@ def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
288
291
  return work_dirs.pop()
289
292
 
290
293
 
294
+ def _resolve_shared_tile_shape(
295
+ seg_cfgs: list[dict], image_store: str, work_dir: str
296
+ ) -> Path:
297
+ """Auto-size ``tile_shape`` once, shared across every config.
298
+
299
+ ``tile_shape: "auto"`` resolves differently per config when
300
+ ``nuclei_channel`` differs -- the sizer charges per channel, so a
301
+ two-channel config gets a smaller tile. Left alone, the label groups
302
+ would end up with different chunk layouts and ``label_relations`` would
303
+ raise, after every segmentation had already run.
304
+
305
+ Computes what each config's own settings (channel count, ``do_3D``,
306
+ ``diameter``, GPU budget) would actually produce, then pins every config
307
+ to the *smallest* of them by voxel count -- the most memory-constrained
308
+ case, and safe for every config since a smaller tile only ever asks for
309
+ less memory than that config's own budget allows, never more. Configs
310
+ can therefore use different ``do_3D``/``diameter``/channel-count
311
+ settings and still end up with one shared, valid ``tile_shape``.
312
+
313
+ Only called once the converted image exists (the sizer needs its real
314
+ shape/dtype), so this runs from ``main()`` after phase A, not from
315
+ ``_validate_configs()``.
316
+
317
+ Returns
318
+ -------
319
+ Path
320
+ A generated one-key YAML file (``tile_shape: [...]``), meant to be
321
+ passed as an ``extra_configfiles`` entry to ``_snakemake_cmd`` so it
322
+ overrides every config's own (or common's) ``tile_shape`` value.
323
+ """
324
+ from functools import partial
325
+
326
+ import numpy as np
327
+ from patchworks import (
328
+ auto_tile_shape,
329
+ auto_tile_shape_cellpose,
330
+ load_ome_zarr,
331
+ )
332
+
333
+ candidates = []
334
+ for cfg in seg_cfgs:
335
+ image = load_ome_zarr(
336
+ image_store, channel=cfg["channel"], level=int(cfg.get("level", 0))
337
+ )
338
+ gpu_gb = cfg.get("gpu_memory_gb")
339
+ gpu_bytes = int(gpu_gb * 1024**3) if gpu_gb else None
340
+ n_channels = 2 if cfg.get("nuclei_channel") is not None else 1
341
+ method = cfg.get("method", "cellpose")
342
+ if method == "cellpose":
343
+ cp = cfg["cellpose"]
344
+ sizer = partial(
345
+ auto_tile_shape_cellpose,
346
+ do_3D=cp.get("do_3D", False),
347
+ use_gpu=cp.get("gpu", True),
348
+ diameter=cp.get("diameter"),
349
+ gpu_memory=gpu_bytes,
350
+ n_channels=n_channels,
351
+ )
352
+ else:
353
+ sizer = partial(
354
+ auto_tile_shape,
355
+ use_gpu=gpu_bytes is not None,
356
+ gpu_memory=gpu_bytes,
357
+ n_channels=n_channels,
358
+ )
359
+ candidates.append(tuple(int(x) for x in sizer(image.shape, image.dtype)))
360
+
361
+ tile_shape = min(candidates, key=lambda t: int(np.prod(t)))
362
+ print(
363
+ f'[run_multi] tile_shape: "auto" resolves differently across '
364
+ f"configs (nuclei_channel differs); pinning every config to the "
365
+ f"smallest computed tile {list(tile_shape)} so the label arrays "
366
+ f"share a chunk layout. Candidates were {[list(c) for c in candidates]}.",
367
+ flush=True,
368
+ )
369
+
370
+ override_path = Path(work_dir) / ".multi_tile_shape.generated.yaml"
371
+ override_path.write_text(
372
+ "# Generated by run_multi.py -- pins tile_shape across configs so\n"
373
+ "# label_relations sees matching chunk layouts. Safe to delete; it\n"
374
+ "# is regenerated on the next multi-config run.\n"
375
+ f"tile_shape: {list(tile_shape)}\n"
376
+ )
377
+ return override_path
378
+
379
+
291
380
  def _resolve(workflow_dir: Path, path_str: str) -> Path:
292
381
  path = Path(path_str)
293
382
  return path if path.is_absolute() else workflow_dir / path
@@ -337,6 +426,28 @@ def main() -> None:
337
426
  "otherwise look identical: no email either way."
338
427
  ),
339
428
  )
429
+ parser.add_argument(
430
+ "--relate-partition",
431
+ default="scicore",
432
+ help="SLURM partition for the relate step under --profile (default: scicore)",
433
+ )
434
+ parser.add_argument(
435
+ "--relate-mem",
436
+ default="32G",
437
+ help="srun --mem for the relate step under --profile (default: 32G)",
438
+ )
439
+ parser.add_argument(
440
+ "--relate-cpus",
441
+ type=int,
442
+ default=8,
443
+ help="srun --cpus-per-task for the relate step under --profile (default: 8)",
444
+ )
445
+ parser.add_argument(
446
+ "--relate-time",
447
+ type=int,
448
+ default=180,
449
+ help="srun --time in minutes for the relate step under --profile (default: 180)",
450
+ )
340
451
  args = parser.parse_args()
341
452
 
342
453
  workflow_dir = Path(__file__).resolve().parent.parent
@@ -427,6 +538,24 @@ def main() -> None:
427
538
  )
428
539
  sys.exit(rc)
429
540
 
541
+ # tile_shape: "auto" resolves differently per config when nuclei_channel
542
+ # differs (the sizer charges per channel) -- pin every config to one
543
+ # shared, computed value so the label arrays end up with matching chunk
544
+ # layouts. Needs the just-converted image, so this can only happen here,
545
+ # not in _validate_configs(). Dry runs never reach a real image.zarr.
546
+ tile_override = None
547
+ if not args.dry_run and {
548
+ cfg.get("tile_shape", "auto") for cfg in seg_cfgs
549
+ } == {"auto"}:
550
+ channel_counts = {
551
+ 2 if cfg.get("nuclei_channel") is not None else 1
552
+ for cfg in seg_cfgs
553
+ }
554
+ if len(channel_counts) > 1:
555
+ tile_override = _resolve_shared_tile_shape(
556
+ seg_cfgs, image_store, work_dir
557
+ )
558
+
430
559
  # Phase B: the segmentations touch disjoint files under
431
560
  # work_dir/<label_name>/, so run them together and let the GPU partition
432
561
  # stay busy instead of idling through each config's prepare and merge.
@@ -444,6 +573,7 @@ def main() -> None:
444
573
  # Names the config in squeue, so concurrent runs are tellable apart.
445
574
  jobname_prefix=slurm_jobname_prefix(cfg["label_name"]),
446
575
  common=common_path,
576
+ extra_configfiles=[tile_override] if tile_override else None,
447
577
  )
448
578
  print(f"[run_multi] $ {' '.join(cmd)}", flush=True)
449
579
  procs.append((cfg_path.name, subprocess.Popen(cmd, cwd=workflow_dir)))
@@ -467,96 +597,47 @@ def main() -> None:
467
597
  if args.dry_run or not relations:
468
598
  return
469
599
 
470
- import dask.array as da
471
- import openpyxl
472
- import zarr
473
-
474
- from patchworks import label_relations
475
-
476
- def _label_ids(name: str) -> list[int]:
477
- """Ids present in a label image, without scanning the volume.
478
-
479
- The merge writes n_objects/sequential_labels into the label group's
480
- attrs precisely so consumers don't have to re-derive the id set; the
481
- ids are 1..n_objects by construction. Fall back to the full scan only
482
- for a label group written before those attrs existed -- that scan runs
483
- here on the login node, so it is worth avoiding.
484
- """
485
- attrs = dict(zarr.open_group(f"{image_store}/labels/{name}").attrs)
486
- if (
487
- attrs.get("sequential_labels")
488
- and attrs.get("n_objects") is not None
489
- ):
490
- return list(range(1, int(attrs["n_objects"]) + 1))
491
- print(
492
- f"[run_multi] {name}: no n_objects attr, falling back to a full "
493
- "scan for its id set",
494
- flush=True,
495
- )
496
- arr = da.from_zarr(image_store, component=f"labels/{name}/0")
497
- return sorted(int(x) for x in da.unique(arr[arr > 0]).compute())
600
+ if args.profile:
601
+ # Real CPU/IO work -- tens of thousands of zarr chunk reads for a
602
+ # full-resolution label volume -- not orchestration, so (like the
603
+ # occupancy map) it does not belong in this driver process on the
604
+ # login node. Submit it as its own job instead.
605
+ cmd = [
606
+ "srun",
607
+ "--partition",
608
+ args.relate_partition,
609
+ "--mem",
610
+ args.relate_mem,
611
+ "--cpus-per-task",
612
+ str(args.relate_cpus),
613
+ "--time",
614
+ str(args.relate_time),
615
+ "--job-name",
616
+ "pw-relate",
617
+ sys.executable,
618
+ str(workflow_dir / "scripts" / "relate.py"),
619
+ "--work-dir",
620
+ work_dir,
621
+ "--image-store",
622
+ image_store,
623
+ "--relations",
624
+ json.dumps(relations),
625
+ ]
626
+ rc = _run(cmd, workflow_dir)
627
+ if rc != 0:
628
+ print(
629
+ f"[run_multi] ERROR: relate step failed (exit {rc}). "
630
+ "Segmentations already succeeded -- only the relation "
631
+ "workbook(s) are missing. Re-run with the same --config to "
632
+ "retry just this step.",
633
+ file=sys.stderr,
634
+ )
635
+ sys.exit(rc)
636
+ return
498
637
 
499
- for rel in relations:
500
- a_name, b_name = rel["a"], rel["b"]
501
- out_path = Path(work_dir) / rel.get(
502
- "output", f"{a_name}_to_{b_name}.xlsx"
503
- )
504
- print(f"[run_multi] relating {a_name} -> {b_name} …", flush=True)
505
- a = da.from_zarr(image_store, component=f"labels/{a_name}/0")
506
- b = da.from_zarr(image_store, component=f"labels/{b_name}/0")
507
- table = label_relations(a, b)
508
-
509
- # label_relations() only returns a-objects that touch a b-object.
510
- # Pull the full id sets so unmatched a-objects (zero overlap) and
511
- # b-objects with no matches at all still get a row -- otherwise
512
- # they'd silently vanish instead of counting as zero.
513
- a_ids = _label_ids(a_name)
514
- b_ids = _label_ids(b_name)
515
-
516
- per_b = {b_id: {"count": 0, "overlap_voxels": 0} for b_id in b_ids}
517
- for m in table.values():
518
- agg = per_b.get(m["match"])
519
- if agg is not None:
520
- agg["count"] += 1
521
- agg["overlap_voxels"] += m["overlap_voxels"]
522
-
523
- wb = openpyxl.Workbook()
524
- ws_a = wb.active
525
- ws_a.title = a_name[:31] # Excel sheet-name length limit
526
- ws_a.append(
527
- [
528
- f"{a_name}_id",
529
- f"{b_name}_id",
530
- "overlap_voxels",
531
- "overlap_fraction",
532
- ]
533
- )
534
- for a_id in a_ids:
535
- m = table.get(a_id)
536
- if m is None:
537
- ws_a.append([a_id, None, 0, 0]) # no overlap -- still counted
538
- else:
539
- ws_a.append(
540
- [
541
- a_id,
542
- m["match"],
543
- m["overlap_voxels"],
544
- m["overlap_fraction"],
545
- ]
546
- )
547
-
548
- ws_b = wb.create_sheet(title=b_name[:31])
549
- ws_b.append([f"{b_name}_id", f"{a_name}_count", "total_overlap_voxels"])
550
- for b_id in b_ids:
551
- agg = per_b[b_id]
552
- ws_b.append([b_id, agg["count"], agg["overlap_voxels"]])
553
-
554
- wb.save(out_path)
555
- print(
556
- f"[run_multi] wrote {out_path} "
557
- f"({len(a_ids)} {a_name}, {len(b_ids)} {b_name})",
558
- flush=True,
559
- )
638
+ from relate import run_relations
639
+
640
+ run_relations(work_dir, image_store, relations)
560
641
 
561
642
 
562
643
  if __name__ == "__main__":
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes