patchworks 2.1.2__tar.gz → 2.3.0__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 (98) hide show
  1. {patchworks-2.1.2 → patchworks-2.3.0}/PKG-INFO +1 -1
  2. {patchworks-2.1.2 → patchworks-2.3.0}/cliff.toml +2 -0
  3. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/label_relations.md +2 -2
  4. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/measurements.md +1 -1
  5. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/snakemake.md +60 -10
  6. patchworks-2.3.0/tests/test_run_multi.py +123 -0
  7. patchworks-2.3.0/workflow/config/common.yaml +42 -0
  8. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/config/config_cilia.yaml +9 -27
  9. patchworks-2.3.0/workflow/config/config_cyto.yaml +22 -0
  10. patchworks-2.3.0/workflow/config/config_nuclei.yaml +22 -0
  11. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/config/multi.yaml +6 -0
  12. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/profile/slurm/config.yaml +8 -0
  13. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/scripts/run_multi.py +84 -2
  14. patchworks-2.1.2/workflow/config/config_cyto.yaml +0 -39
  15. patchworks-2.1.2/workflow/config/config_nuclei.yaml +0 -39
  16. {patchworks-2.1.2 → patchworks-2.3.0}/.github/workflows/docs.yml +0 -0
  17. {patchworks-2.1.2 → patchworks-2.3.0}/.github/workflows/lint.yml +0 -0
  18. {patchworks-2.1.2 → patchworks-2.3.0}/.github/workflows/release.yml +0 -0
  19. {patchworks-2.1.2 → patchworks-2.3.0}/.gitignore +0 -0
  20. {patchworks-2.1.2 → patchworks-2.3.0}/.markdownlint-cli2.yaml +0 -0
  21. {patchworks-2.1.2 → patchworks-2.3.0}/LICENSE +0 -0
  22. {patchworks-2.1.2 → patchworks-2.3.0}/README.md +0 -0
  23. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/chunks.md +0 -0
  24. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/cluster.md +0 -0
  25. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/io.md +0 -0
  26. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/merge_tile_labels.md +0 -0
  27. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/plugins/cellpose.md +0 -0
  28. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/plugins/dog.md +0 -0
  29. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/plugins/napari.md +0 -0
  30. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/plugins/ome_zarr.md +0 -0
  31. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/postprocess.md +0 -0
  32. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/relabel.md +0 -0
  33. {patchworks-2.1.2 → patchworks-2.3.0}/docs/api/tile_process.md +0 -0
  34. {patchworks-2.1.2 → patchworks-2.3.0}/docs/assets/logo.png +0 -0
  35. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/cellpose_2d.md +0 -0
  36. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/cellpose_2d.py +0 -0
  37. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/cellpose_3d.md +0 -0
  38. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/cellpose_3d.py +0 -0
  39. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/custom.md +0 -0
  40. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/custom_method.py +0 -0
  41. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/dog.md +0 -0
  42. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/dog.py +0 -0
  43. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/standalone_merge.md +0 -0
  44. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/stardist.md +0 -0
  45. {patchworks-2.1.2 → patchworks-2.3.0}/docs/examples/stardist_2d.py +0 -0
  46. {patchworks-2.1.2 → patchworks-2.3.0}/docs/getting_started.md +0 -0
  47. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/custom_segmentation.md +0 -0
  48. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/gpu_distributed.md +0 -0
  49. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/merging.md +0 -0
  50. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/ome_zarr_napari.md +0 -0
  51. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/performance.md +0 -0
  52. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/pitfalls.md +0 -0
  53. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/skip_empty.md +0 -0
  54. {patchworks-2.1.2 → patchworks-2.3.0}/docs/guide/tiling.md +0 -0
  55. {patchworks-2.1.2 → patchworks-2.3.0}/docs/index.md +0 -0
  56. {patchworks-2.1.2 → patchworks-2.3.0}/mkdocs.yml +0 -0
  57. {patchworks-2.1.2 → patchworks-2.3.0}/pyproject.toml +0 -0
  58. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/__init__.py +0 -0
  59. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_chunks.py +0 -0
  60. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_cluster.py +0 -0
  61. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_core.py +0 -0
  62. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_distributed.py +0 -0
  63. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_gpu.py +0 -0
  64. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_io.py +0 -0
  65. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_merge.py +0 -0
  66. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_occupancy.py +0 -0
  67. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_postprocess.py +0 -0
  68. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_relabel.py +0 -0
  69. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/_relations.py +0 -0
  70. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/plugins/__init__.py +0 -0
  71. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/plugins/cellpose.py +0 -0
  72. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/plugins/dog.py +0 -0
  73. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/plugins/napari.py +0 -0
  74. {patchworks-2.1.2 → patchworks-2.3.0}/src/patchworks/plugins/ome_zarr.py +0 -0
  75. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_allocation.py +0 -0
  76. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_core.py +0 -0
  77. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_distributed.py +0 -0
  78. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_dog.py +0 -0
  79. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_gpu.py +0 -0
  80. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_napari.py +0 -0
  81. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_occupancy.py +0 -0
  82. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_ome_zarr.py +0 -0
  83. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_postprocess.py +0 -0
  84. {patchworks-2.1.2 → patchworks-2.3.0}/tests/test_relations.py +0 -0
  85. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/README.md +0 -0
  86. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/Snakefile +0 -0
  87. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/config/config.yaml +0 -0
  88. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/pixi.toml +0 -0
  89. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/rules/common.smk +0 -0
  90. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/rules/convert.smk +0 -0
  91. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/rules/merge.smk +0 -0
  92. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/rules/segment.smk +0 -0
  93. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/scripts/_pw.py +0 -0
  94. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/scripts/convert.py +0 -0
  95. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/scripts/fetch_model.py +0 -0
  96. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/scripts/merge.py +0 -0
  97. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/scripts/prepare_tiles.py +0 -0
  98. {patchworks-2.1.2 → patchworks-2.3.0}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 2.1.2
3
+ Version: 2.3.0
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
@@ -31,6 +31,8 @@ commit_preprocessors = [
31
31
  { pattern = "(\\w+(?:\\([^)]*\\))?!?:\\s+)[^\\w\\s]+\\s*", replace = "$1" },
32
32
  ]
33
33
  commit_parsers = [
34
+ # Merge commits are noise in a changelog — the merged commits are already listed.
35
+ { message = "^Merge ", skip = true },
34
36
  { message = "^feat", group = "✨ Features" },
35
37
  { message = "^fix", group = "🐛 Bug Fixes" },
36
38
  { message = "^perf", group = "⚡ Performance" },
@@ -24,7 +24,7 @@ table[2]
24
24
  `table` only contains matched `a` labels (nuclei with at least one
25
25
  overlapping voxel in `cells`) — unmatched labels and full per-`b` coverage
26
26
  need a bit more bookkeeping (the [cluster workflow's `run_multi.py`
27
- script](snakemake.md#one-command-multiple-segmentations--relations) does
27
+ script](snakemake.md#one-command-multiple-segmentations-relations) does
28
28
  this for you and writes it as a two-sheet workbook).
29
29
 
30
30
  Save it as a table yourself:
@@ -41,4 +41,4 @@ with open("nuclei_to_cell.csv", "w", newline="") as f:
41
41
 
42
42
  On the cluster, producing the two label stores in the first place is a
43
43
  matter of running the workflow twice against the same `work_dir` — see
44
- [Running two segmentations](snakemake.md#running-two-segmentations-eg-nuclei--cytoplasm).
44
+ [Running two segmentations](snakemake.md#running-two-segmentations-eg-nuclei-cytoplasm).
@@ -10,7 +10,7 @@ is built for this — its "Measure" dock widget computes area/centroid/intensity
10
10
  stats directly off a Labels layer's dask/zarr-backed array, out-of-core, and
11
11
  scales with chunk count rather than object count. It's the best fit for
12
12
  measuring *every* object in a store this size, not just a cropped region —
13
- see [View image + labels in napari](ome_zarr_napari.md#view-image--labels-in-napari).
13
+ see [View image + labels in napari](ome_zarr_napari.md#view-image-labels-in-napari).
14
14
  Bundled in `patchworks[napari]`.
15
15
 
16
16
  For interactively inspecting individual cells by clicking in the viewer (not
@@ -187,6 +187,27 @@ Snakemake submits `convert`, then `prepare`, then **one `segment` job per
187
187
  batch of `tiles_per_job` non-empty tiles** (up to `jobs:` at once → that many
188
188
  GPUs in parallel), then `merge`. Raise `jobs:` to use more GPUs.
189
189
 
190
+ !!! tip "Recognisable job names in `squeue`"
191
+ The SLURM executor names every job after its run UUID and **rejects** a
192
+ `--job-name` in `slurm_extra`, so by default `squeue` shows nothing you
193
+ can identify. A prefix is the supported lever, and it goes first in the
194
+ name (`<prefix>_<uuid>`) — the part a queue listing truncates to:
195
+
196
+ ```yaml
197
+ slurm-jobname-prefix: patchworks # already in the shipped profile
198
+ ```
199
+
200
+ `run_multi` overrides it per config, so a three-way run shows
201
+ `pw-convert`, then `pw-nuclei_labels` / `pw-cyto_labels` /
202
+ `pw-cilia_labels` — telling the concurrent runs apart at a glance:
203
+
204
+ ```bash
205
+ squeue -u $USER -o '%.18i %.24j %.8T %.10M'
206
+ ```
207
+
208
+ Alphanumerics, underscores and hyphens only, 50 characters max; an
209
+ invalid prefix fails the run, so `label_name` is sanitised before use.
210
+
190
211
  !!! tip "Sizing memory"
191
212
  Every step now sizes its own worker counts from what SLURM actually
192
213
  granted (`SLURM_CPUS_PER_TASK`, `SLURM_MEM_PER_*`, the cgroup limit)
@@ -248,13 +269,26 @@ running the workflow **twice with two configs against the same `work_dir`**
248
269
  never collides: each run gets its own private subdirectory, and both reuse
249
270
  the *same* already-converted `image.zarr` (conversion never re-runs).
250
271
 
272
+ Most of what those configs contain is identical — the input, the `work_dir`,
273
+ the tiling, everything `convert` reads. Put it in **one** shared file and let
274
+ each config carry only what actually differs. Snakemake merges several
275
+ `--configfile` values in order, with the later one winning:
276
+
251
277
  ```yaml
252
- # config/config_nuclei.yaml
278
+ # config/common.yaml — shared by every segmentation
253
279
  input: "/data/scan.ims"
254
280
  work_dir: "/scratch/results"
281
+ tile_shape: [16, 1024, 1024]
282
+ shard: false # true → far fewer files, same chunks
283
+ tiles_per_job: 4
284
+ ```
285
+
286
+ ```yaml
287
+ # config/config_nuclei.yaml — only the differences
255
288
  label_name: "nuclei_labels"
256
289
  channel: 1 # nuclear stain channel
257
- tile_shape: [16, 1024, 1024]
290
+ overlap: [4, 30, 30]
291
+ method: "cellpose"
258
292
  cellpose:
259
293
  model: "nuclei"
260
294
  diameter: 15
@@ -262,12 +296,11 @@ cellpose:
262
296
  ```
263
297
 
264
298
  ```yaml
265
- # config/config_cyto.yaml
266
- input: "/data/scan.ims"
267
- work_dir: "/scratch/results" # same work_dir — image.zarr is reused
299
+ # config/config_cyto.yaml — only the differences
268
300
  label_name: "cyto_labels"
269
301
  channel: 0 # cytoplasm/membrane channel
270
- tile_shape: [16, 1024, 1024] # keep this identical across configs — see below
302
+ overlap: [4, 30, 30]
303
+ method: "cellpose"
271
304
  cellpose:
272
305
  model: "cyto3"
273
306
  diameter: 30
@@ -279,12 +312,23 @@ they can run concurrently. Give each its own `--directory`, because
279
312
  Snakemake's lock lives in the working directory, not in the config:
280
313
 
281
314
  ```bash
282
- snakemake --workflow-profile profile/slurm --configfile config/config_nuclei.yaml \
283
- --directory /scratch/results/nuclei_labels/.snakemake
284
- snakemake --workflow-profile profile/slurm --configfile config/config_cyto.yaml \
285
- --directory /scratch/results/cyto_labels/.snakemake
315
+ snakemake --workflow-profile profile/slurm --configfile config/common.yaml config/config_nuclei.yaml --directory /scratch/results/nuclei_labels/.snakemake
316
+ ```
317
+
318
+ ```bash
319
+ snakemake --workflow-profile profile/slurm --configfile config/common.yaml config/config_cyto.yaml --directory /scratch/results/cyto_labels/.snakemake
286
320
  ```
287
321
 
322
+ !!! warning "Conversion settings belong in the shared file"
323
+ `convert` runs **once**, from the first config only. A `shard`, `input` or
324
+ `pyramid_levels` set on the second config is therefore never read, and
325
+ nothing logs that it was dropped. `run_multi` refuses to start when those
326
+ keys disagree across configs and tells you which one — but if you drive
327
+ the configs by hand, keep them in `common.yaml`.
328
+
329
+ Splitting the configs is optional: a self-contained config still works,
330
+ and `common:` can simply be left out of `multi.yaml`.
331
+
288
332
  !!! tip "One command for several segmentations + relations"
289
333
  `config/multi.yaml` lists any number of segmentation configs plus which
290
334
  pairs to relate afterward; `pixi run multi` (or `multi-slurm`) converts
@@ -318,6 +362,8 @@ and saves every configured relation — one command instead of juggling several
318
362
 
319
363
  ```yaml
320
364
  # config/multi.yaml
365
+ common: config/common.yaml # shared settings, merged under each config below
366
+
321
367
  segmentations:
322
368
  - config/config_nuclei.yaml
323
369
  - config/config_cyto.yaml
@@ -328,6 +374,10 @@ relations:
328
374
  output: nuclei_to_cyto.xlsx # written into work_dir
329
375
  ```
330
376
 
377
+ `common:` is optional — leave it out and each config must be self-contained,
378
+ as before. With it, changing the input path or turning on `shard` is a
379
+ one-line edit in one file instead of the same edit repeated per config.
380
+
331
381
  ```bash
332
382
  pixi run multi-dry # dry-run every segmentation config (skips relations)
333
383
  pixi run multi # run locally
@@ -0,0 +1,123 @@
1
+ """Tests for the multi-config driver's SLURM-facing behaviour."""
2
+
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ sys.path.insert(
8
+ 0, str(Path(__file__).resolve().parents[1] / "workflow" / "scripts")
9
+ )
10
+
11
+ import pytest # noqa: E402
12
+ import yaml # noqa: E402
13
+
14
+ from run_multi import ( # noqa: E402
15
+ _CONVERT_KEYS,
16
+ _snakemake_cmd,
17
+ _validate_configs,
18
+ slurm_jobname_prefix,
19
+ )
20
+
21
+ # The SLURM executor's own rule (snakemake_executor_plugin_slurm): it raises a
22
+ # WorkflowError and aborts the whole run if the prefix does not match.
23
+ _EXECUTOR_RULE = re.compile(r"^[A-Za-z0-9_-]{1,50}$")
24
+
25
+
26
+ def test_jobname_prefix_satisfies_the_executor():
27
+ """Whatever a label_name contains, the prefix must stay submittable.
28
+
29
+ The executor names jobs after a UUID and refuses a --job-name override, so
30
+ this prefix is the only thing that makes squeue readable -- and an invalid
31
+ one fails the run rather than degrading.
32
+ """
33
+ for label in ("nuclei_labels", "cyto_labels", "convert", "a"):
34
+ assert _EXECUTOR_RULE.match(slurm_jobname_prefix(label))
35
+
36
+ # Characters a label might plausibly pick up are sanitised, not passed on.
37
+ assert _EXECUTOR_RULE.match(slurm_jobname_prefix("cilia/v2 (test)"))
38
+ assert _EXECUTOR_RULE.match(slurm_jobname_prefix("run 1: nuclei"))
39
+ # And an over-long label is truncated to the executor's 50-char limit.
40
+ assert _EXECUTOR_RULE.match(slurm_jobname_prefix("x" * 200))
41
+
42
+
43
+ def test_jobname_prefix_keeps_the_label_readable():
44
+ """The label must lead, since that is what a queue listing truncates to."""
45
+ assert slurm_jobname_prefix("nuclei_labels") == "pw-nuclei_labels"
46
+ assert slurm_jobname_prefix("convert") == "pw-convert"
47
+
48
+
49
+ def test_common_configfile_is_merged_under_the_per_config_one():
50
+ """Snakemake merges --configfile values in order, later winning.
51
+
52
+ That ordering is the whole mechanism: shared settings come from common.yaml
53
+ and the per-config file overrides only what differs. Swap the two and every
54
+ config would silently get the shared defaults instead of its own channel.
55
+ """
56
+ cmd = _snakemake_cmd(
57
+ Path("config/config_nuclei.yaml"),
58
+ workflow_dir=Path("workflow"),
59
+ profile=None,
60
+ cores=8,
61
+ dry_run=False,
62
+ common=Path("config/common.yaml"),
63
+ )
64
+ i = cmd.index("--configfile")
65
+ assert cmd[i + 1].endswith("common.yaml")
66
+ assert cmd[i + 2].endswith("config_nuclei.yaml")
67
+
68
+ # Without a common file the invocation is unchanged: one configfile, so
69
+ # a self-contained config keeps working exactly as before.
70
+ plain = _snakemake_cmd(
71
+ Path("config/config_nuclei.yaml"),
72
+ workflow_dir=Path("workflow"),
73
+ profile=None,
74
+ cores=8,
75
+ dry_run=False,
76
+ )
77
+ j = plain.index("--configfile")
78
+ assert plain[j + 1].endswith("config_nuclei.yaml")
79
+ assert not plain[j + 2].endswith(".yaml")
80
+
81
+
82
+ def test_convert_keys_must_agree_across_configs():
83
+ """`convert` runs once from the first config, so a later one is ignored.
84
+
85
+ Setting shard on the second config and watching a million files appear
86
+ anyway is invisible without this check -- there is no log line saying the
87
+ value was dropped, because nothing ever read it.
88
+ """
89
+ paths = [Path("a.yaml"), Path("b.yaml")]
90
+ base = {"work_dir": "/w", "tile_shape": [16, 512, 512], "level": 0}
91
+ good = [
92
+ {**base, "label_name": "a", "shard": True},
93
+ {**base, "label_name": "b", "shard": True},
94
+ ]
95
+ assert _validate_configs(paths, good) == "/w"
96
+
97
+ bad = [
98
+ {**base, "label_name": "a", "shard": True},
99
+ {**base, "label_name": "b", "shard": False},
100
+ ]
101
+ # It reports every problem and exits, rather than raising, so that a
102
+ # mistake costs one readable message instead of a traceback.
103
+ with pytest.raises(SystemExit):
104
+ _validate_configs(paths, bad)
105
+
106
+
107
+ def test_shipped_multi_configs_are_consistent():
108
+ """The shipped example must satisfy its own validator.
109
+
110
+ It is the thing users copy, so a config set that run_multi would refuse to
111
+ start is worse than no example at all.
112
+ """
113
+ cfg_dir = Path(__file__).resolve().parents[1] / "workflow" / "config"
114
+ multi = yaml.safe_load((cfg_dir / "multi.yaml").read_text())
115
+ common = yaml.safe_load((cfg_dir.parent / multi["common"]).read_text())
116
+ paths = [cfg_dir.parent / p for p in multi["segmentations"]]
117
+ cfgs = [{**common, **yaml.safe_load(p.read_text())} for p in paths]
118
+
119
+ assert _validate_configs(paths, cfgs) == common["work_dir"]
120
+ # Every key convert reads comes from the shared file, not a per-config one.
121
+ for path in paths:
122
+ own = yaml.safe_load(path.read_text())
123
+ assert not set(own) & set(_CONVERT_KEYS), path.name
@@ -0,0 +1,42 @@
1
+ # Settings shared by every segmentation in config/multi.yaml.
2
+ #
3
+ # `common:` in multi.yaml points here, and run_multi passes it as the first of
4
+ # two --configfile values. Snakemake merges them in order with the later
5
+ # winning, so a per-config file only carries what actually differs from this
6
+ # one — the channel it reads, the method it uses, its label_name.
7
+ #
8
+ # Everything `convert` reads MUST live here rather than in a per-config file:
9
+ # the conversion runs once, up front, from the first config only, so a `shard`
10
+ # or `input` set on the second config would be silently ignored. run_multi
11
+ # refuses to start if those keys disagree across configs.
12
+
13
+ input: "/path/to/scan.ims"
14
+ work_dir: "/path/to/results"
15
+
16
+ # --- conversion (read once, in phase A, from the first config only) ---
17
+ reuse_pyramid: false
18
+ convert_chunks: null
19
+ # true → pack chunks into shards: same chunking and same memory, far fewer
20
+ # files. Worth turning on for anything large; convert warns when a store is
21
+ # heading past ~200,000 chunks, which a shared filesystem will not enjoy.
22
+ shard: false
23
+
24
+ # --- label pyramid (read by merge, i.e. once per config) ---
25
+ # Shared here for convenience, but a per-config file may override these: each
26
+ # segmentation builds its own label pyramid.
27
+ pyramid_levels: 5
28
+ pyramid_downscale: 2
29
+
30
+ # --- tiling (must match across configs so label arrays stay comparable) ---
31
+ level: 0
32
+ tile_shape: [16, 1024, 1024]
33
+ gpu_memory_gb: null
34
+ skip_empty: true
35
+ empty_threshold: null
36
+ # Tiles per SLURM job; they run sequentially and share one model load. Raise
37
+ # once you know a tile's runtime -- job wall time is ~N x per-tile time.
38
+ tiles_per_job: 4
39
+
40
+ # --- merge ---
41
+ sequential_labels: true
42
+ merge_workers: null
@@ -1,37 +1,24 @@
1
1
  # Example segmentation config: cilia channel, via method: "custom" ->
2
2
  # patchworks.plugins.dog.segment (deconvolution + difference-of-Gaussians).
3
3
  # Cilia are thin/small structures a cell-body model like Cellpose isn't
4
- # shaped for. Paired with config_cyto.yaml / config_nuclei.yaml via
5
- # config/multi.yaml to relate cilia -> containing cell / nucleus. Same
6
- # work_dir and tile_shape as those two so patchworks.label_relations() can
7
- # compare the label arrays chunk-for-chunk.
4
+ # shaped for. Related to cyto/nuclei via config/multi.yaml to map each
5
+ # cilium to its containing cell / nucleus.
6
+ #
7
+ # Only what differs from config/common.yaml — the input, work_dir, tiling and
8
+ # conversion settings all live there and are merged in ahead of this file.
8
9
  #
9
10
  # pycudadecon is CUDA-only, so this segment job needs a GPU too — same
10
11
  # profile/slurm as the Cellpose configs already covers it.
11
12
 
12
- input: "/path/to/scan.ims"
13
- work_dir: "/path/to/results"
14
-
15
- reuse_pyramid: false
16
- convert_chunks: null
17
- shard: false
18
-
19
13
  channel: 2 # cilia marker channel
20
- level: 0
21
- tile_shape: [16, 1024, 1024] # keep identical to config_cyto.yaml/config_nuclei.yaml
22
- gpu_memory_gb: null
14
+
23
15
  # Per-axis halo [z, y, x], covering the PSF support (decon) + the DoG's
24
16
  # high_sigma. Lateral 30 px at dxdata 0.1 = 3 um; at dzdata 0.2 that is 15
25
17
  # z-planes -- almost the whole 16-plane tile. Deconvolution genuinely wants a
26
- # deeper z tile than this: if cilia quality matters, raise tile_shape's z for
27
- # all three configs together (label_relations needs them identical) rather
28
- # than pushing the halo up against the tile depth.
18
+ # deeper z tile than this: if cilia quality matters, raise tile_shape's z in
19
+ # common.yaml (label_relations needs it identical across configs) rather than
20
+ # pushing the halo up against the tile depth.
29
21
  overlap: [8, 30, 30]
30
- skip_empty: true
31
- # Tiles per SLURM job; they run sequentially and share one decon/GPU context.
32
- # Raise once you know a tile's runtime -- job wall time is ~N x per-tile time.
33
- tiles_per_job: 4
34
- empty_threshold: null
35
22
 
36
23
  method: "custom"
37
24
  # dilate: 2 # optional: pixels to grow labels by after segmentation
@@ -58,8 +45,3 @@ custom:
58
45
  wavelength: 525
59
46
  na: 1.4
60
47
  nimm: 1.515
61
-
62
- pyramid_levels: 5
63
- pyramid_downscale: 2
64
- sequential_labels: true
65
- merge_workers: null
@@ -0,0 +1,22 @@
1
+ # Example segmentation config: cytoplasm/membrane channel.
2
+ #
3
+ # Only what differs from config/common.yaml — the input, work_dir, tiling and
4
+ # conversion settings all live there and are merged in ahead of this file.
5
+ # Run it via config/multi.yaml, or on its own with both files:
6
+ #
7
+ # snakemake -s Snakefile --configfile config/common.yaml config/config_cyto.yaml
8
+
9
+ channel: 0 # cytoplasm/membrane channel
10
+
11
+ # Per-axis halo [z, y, x]. A scalar 30 would expand a [16, 1024, 1024] tile to
12
+ # 76 x 1084 x 1084 = 5.3x the voxels it keeps, nearly all of it wasted z. The
13
+ # z-halo only needs to cover one cell in z, not one cell in x/y.
14
+ overlap: [4, 30, 30]
15
+
16
+ method: "cellpose"
17
+ label_name: "cyto_labels"
18
+ cellpose:
19
+ model: "cyto3"
20
+ diameter: 30
21
+ do_3D: true
22
+ gpu: true
@@ -0,0 +1,22 @@
1
+ # Example segmentation config: nuclei channel.
2
+ #
3
+ # Only what differs from config/common.yaml — the input, work_dir, tiling and
4
+ # conversion settings all live there and are merged in ahead of this file.
5
+ # Run it via config/multi.yaml, or on its own with both files:
6
+ #
7
+ # snakemake -s Snakefile --configfile config/common.yaml config/config_nuclei.yaml
8
+
9
+ channel: 1 # nuclear stain channel
10
+
11
+ # Per-axis halo [z, y, x]. A scalar 30 would expand a [16, 1024, 1024] tile to
12
+ # 76 x 1084 x 1084 = 5.3x the voxels it keeps, nearly all of it wasted z. The
13
+ # z-halo only needs to cover one nucleus in z, not one nucleus in x/y.
14
+ overlap: [4, 30, 30]
15
+
16
+ method: "cellpose"
17
+ label_name: "nuclei_labels"
18
+ cellpose:
19
+ model: "nuclei"
20
+ diameter: 15
21
+ do_3D: true
22
+ gpu: true
@@ -13,6 +13,12 @@
13
13
  # `relations` (there's no automatic "chain"; list every pair explicitly, e.g.
14
14
  # nuclei->cyto, nuclei->membrane, cyto->membrane).
15
15
 
16
+ # Settings every segmentation shares — the input, the work_dir, the tiling,
17
+ # everything `convert` reads. Merged in ahead of each config below, so a
18
+ # per-config file carries only what differs. Optional: drop this line and each
19
+ # config can be self-contained again.
20
+ common: config/common.yaml
21
+
16
22
  segmentations:
17
23
  - config/config_nuclei.yaml
18
24
  - config/config_cyto.yaml
@@ -5,6 +5,14 @@
5
5
  # Edit the partitions / account / GPU request for your cluster.
6
6
 
7
7
  executor: slurm
8
+ # The SLURM executor names every job after its run UUID and rejects a
9
+ # --job-name in slurm_extra, so `squeue` shows nothing recognisable without
10
+ # this. The prefix goes first in the name (<prefix>_<uuid>), which is the part
11
+ # that survives truncation in a queue listing.
12
+ # Alphanumerics, underscores and hyphens only; 50 characters max.
13
+ # run_multi overrides this per config, so a three-way run shows pw-convert,
14
+ # then pw-nuclei_labels / pw-cyto_labels / pw-cilia_labels.
15
+ slurm-jobname-prefix: patchworks
8
16
  # Max concurrent SLURM jobs Snakemake will have in flight at once. This is a
9
17
  # submission-side cap, not a cluster limit — raising it just lets more
10
18
  # "segment" tiles queue/run together; scicore's scheduler still enforces your
@@ -26,6 +26,7 @@ overlap, including b-objects with zero matches).
26
26
  from __future__ import annotations
27
27
 
28
28
  import argparse
29
+ import re
29
30
  import subprocess
30
31
  import sys
31
32
  from pathlib import Path
@@ -47,25 +48,39 @@ def _snakemake_cmd(
47
48
  state_dir: Path | None = None,
48
49
  targets: list[str] | None = None,
49
50
  extra: list[str] | None = None,
51
+ jobname_prefix: str | None = None,
52
+ common: Path | None = None,
50
53
  ) -> list[str]:
51
54
  """Build one snakemake invocation.
52
55
 
53
56
  Every path is absolutised because ``--directory`` moves the working
54
57
  directory: each config needs its own ``.snakemake`` state directory, or
55
58
  concurrent runs would contend for the same ``.snakemake/locks/``.
59
+
60
+ *common*, when given, is passed as the first of two ``--configfile``
61
+ values. Snakemake merges them in order with the later winning, so the
62
+ settings every config shares -- the input, the work_dir, everything
63
+ ``convert`` reads -- live in one file and the per-config file carries only
64
+ what actually differs.
56
65
  """
66
+ configfiles = [str(configfile.resolve())]
67
+ if common is not None:
68
+ configfiles.insert(0, str(common.resolve()))
57
69
  cmd = [
58
70
  "snakemake",
59
71
  "-s",
60
72
  str(workflow_dir / "Snakefile"),
61
73
  "--configfile",
62
- str(configfile.resolve()),
74
+ *configfiles,
63
75
  ]
64
76
  if state_dir is not None:
65
77
  state_dir.mkdir(parents=True, exist_ok=True)
66
78
  cmd += ["--directory", str(state_dir.resolve())]
67
79
  if profile:
68
80
  cmd += ["--workflow-profile", str((workflow_dir / profile).resolve())]
81
+ if jobname_prefix:
82
+ # A SLURM-executor setting, so only valid alongside the profile.
83
+ cmd += ["--slurm-jobname-prefix", jobname_prefix]
69
84
  else:
70
85
  cmd += ["--cores", str(cores), "--rerun-triggers", "mtime"]
71
86
  if dry_run:
@@ -79,11 +94,50 @@ def _snakemake_cmd(
79
94
  return cmd
80
95
 
81
96
 
97
+ def slurm_jobname_prefix(label: str) -> str:
98
+ """Sanitise *label* into a SLURM job-name prefix the executor accepts.
99
+
100
+ The SLURM executor names every job after its run UUID and refuses a
101
+ ``--job-name`` in ``slurm_extra``, so a prefix is the only way to get
102
+ something recognisable into ``squeue``. It becomes ``<prefix>_<uuid>``,
103
+ which puts the readable part first -- the part that survives truncation
104
+ in a queue listing.
105
+
106
+ The executor requires alphanumerics, underscores and hyphens only, at
107
+ most 50 characters, and rejects the whole run otherwise.
108
+
109
+ Examples
110
+ --------
111
+ >>> slurm_jobname_prefix("nuclei_labels")
112
+ 'pw-nuclei_labels'
113
+ >>> slurm_jobname_prefix("cilia/v2 (test)")
114
+ 'pw-cilia-v2--test-'
115
+ """
116
+ safe = re.sub(r"[^A-Za-z0-9_-]", "-", label)
117
+ return f"pw-{safe}"[:50]
118
+
119
+
82
120
  def _run(cmd: list[str], workflow_dir: Path) -> int:
83
121
  print(f"[run_multi] $ {' '.join(cmd)}", flush=True)
84
122
  return subprocess.run(cmd, cwd=workflow_dir).returncode
85
123
 
86
124
 
125
+ # Exactly the config keys scripts/convert.py reads -- keep the two in step.
126
+ # Conversion runs once, in phase A, from the first config, so these have to
127
+ # agree across all of them or the disagreement is invisible.
128
+ #
129
+ # Deliberately NOT here: pyramid_levels / pyramid_downscale. Those are read by
130
+ # merge.py, which runs once per config and builds that config's own label
131
+ # pyramid, so they may legitimately differ.
132
+ _CONVERT_KEYS = (
133
+ "input",
134
+ "sequence_pattern",
135
+ "convert_chunks",
136
+ "shard",
137
+ "reuse_pyramid",
138
+ )
139
+
140
+
87
141
  def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
88
142
  """Check the cross-config invariants before anything is submitted.
89
143
 
@@ -117,6 +171,20 @@ def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
117
171
  f"share a chunk layout; got {_spread(key)}"
118
172
  )
119
173
 
174
+ # Phase A converts once, from the first config. Anything `convert` reads
175
+ # out of a later config is therefore silently ignored -- someone setting
176
+ # `shard: true` on the second config and watching a million files appear
177
+ # anyway has no way to see why. Refuse instead, and point at common.yaml.
178
+ for key in _CONVERT_KEYS:
179
+ values = {repr(cfg.get(key)) for cfg in cfgs}
180
+ if len(values) != 1:
181
+ problems.append(
182
+ f"{key} affects `convert`, which runs once from the first "
183
+ f"config, so the other values would be silently ignored; got "
184
+ f"{_spread(key)}. Put the settings every config shares in one "
185
+ f"file and point `common:` in multi.yaml at it."
186
+ )
187
+
120
188
  for path, cfg in zip(paths, cfgs):
121
189
  source = str(cfg.get("input", ""))
122
190
  if any(ch in source for ch in "*?[") and not cfg.get(
@@ -193,7 +261,15 @@ def main() -> None:
193
261
  seg_config_paths = [
194
262
  _resolve(workflow_dir, c) for c in multi_cfg["segmentations"]
195
263
  ]
196
- seg_cfgs = [_load_yaml(p) for p in seg_config_paths]
264
+ # Optional shared config: Snakemake merges --configfile values in order,
265
+ # so `common` holds what every segmentation agrees on and each per-config
266
+ # file overrides only what differs. Validation has to see the same merged
267
+ # view Snakemake will, or it would report a missing work_dir that is
268
+ # simply defined one file over.
269
+ common_path = multi_cfg.get("common")
270
+ common_path = _resolve(workflow_dir, common_path) if common_path else None
271
+ common_cfg = _load_yaml(common_path) if common_path else {}
272
+ seg_cfgs = [{**common_cfg, **_load_yaml(p)} for p in seg_config_paths]
197
273
  work_dir = _validate_configs(seg_config_paths, seg_cfgs)
198
274
  image_store = f"{work_dir}/image.zarr"
199
275
 
@@ -217,6 +293,7 @@ def main() -> None:
217
293
  dry_run=False,
218
294
  state_dir=state_dir,
219
295
  extra=["--unlock"],
296
+ common=common_path,
220
297
  ),
221
298
  workflow_dir,
222
299
  )
@@ -235,6 +312,8 @@ def main() -> None:
235
312
  dry_run=args.dry_run,
236
313
  state_dir=Path(work_dir) / ".snakemake_convert",
237
314
  targets=[f"{image_store}/zarr.json"],
315
+ jobname_prefix=slurm_jobname_prefix("convert"),
316
+ common=common_path,
238
317
  ),
239
318
  workflow_dir,
240
319
  )
@@ -287,6 +366,9 @@ def main() -> None:
287
366
  cores=args.cores,
288
367
  dry_run=args.dry_run,
289
368
  state_dir=Path(cfg["work_dir"]) / cfg["label_name"] / ".snakemake",
369
+ # Names the config in squeue, so concurrent runs are tellable apart.
370
+ jobname_prefix=slurm_jobname_prefix(cfg["label_name"]),
371
+ common=common_path,
290
372
  )
291
373
  print(f"[run_multi] $ {' '.join(cmd)}", flush=True)
292
374
  procs.append((cfg_path.name, subprocess.Popen(cmd, cwd=workflow_dir)))
@@ -1,39 +0,0 @@
1
- # Example segmentation config: cytoplasm/membrane channel.
2
- # Paired with config_nuclei.yaml via config/multi.yaml — see
3
- # docs/guide/snakemake.md "Running two segmentations". Same work_dir as
4
- # config_nuclei.yaml, and the same tile_shape so patchworks.label_relations()
5
- # can compare the two label arrays chunk-for-chunk.
6
-
7
- input: "/path/to/scan.ims"
8
- work_dir: "/path/to/results"
9
-
10
- reuse_pyramid: false
11
- convert_chunks: null
12
- shard: false
13
-
14
- channel: 0 # cytoplasm/membrane channel
15
- level: 0
16
- tile_shape: [16, 1024, 1024] # keep identical to config_nuclei.yaml
17
- gpu_memory_gb: null
18
- # Per-axis halo [z, y, x]. A scalar 30 would expand a [16, 1024, 1024] tile to
19
- # 76 x 1084 x 1084 = 5.3x the voxels it keeps, nearly all of it wasted z. The
20
- # z-halo only needs to cover one cell in z, not one cell in x/y.
21
- overlap: [4, 30, 30]
22
- skip_empty: true
23
- # Tiles per SLURM job; they run sequentially and share one model load. Raise
24
- # once you know a tile's runtime -- job wall time is ~N x per-tile time.
25
- tiles_per_job: 4
26
- empty_threshold: null
27
-
28
- method: "cellpose"
29
- label_name: "cyto_labels"
30
- cellpose:
31
- model: "cyto3"
32
- diameter: 30
33
- do_3D: true
34
- gpu: true
35
-
36
- pyramid_levels: 5
37
- pyramid_downscale: 2
38
- sequential_labels: true
39
- merge_workers: null
@@ -1,39 +0,0 @@
1
- # Example segmentation config: nuclei channel.
2
- # Paired with config_cyto.yaml via config/multi.yaml — see
3
- # docs/guide/snakemake.md "Running two segmentations". Both configs share
4
- # work_dir (and thus image.zarr) with config_cyto.yaml, but keep tile_shape
5
- # identical across the two so patchworks.label_relations() can compare them.
6
-
7
- input: "/path/to/scan.ims"
8
- work_dir: "/path/to/results"
9
-
10
- reuse_pyramid: false
11
- convert_chunks: null
12
- shard: false
13
-
14
- channel: 1 # nuclear stain channel
15
- level: 0
16
- tile_shape: [16, 1024, 1024] # keep identical to config_cyto.yaml
17
- gpu_memory_gb: null
18
- # Per-axis halo [z, y, x]. A scalar 30 would expand a [16, 1024, 1024] tile to
19
- # 76 x 1084 x 1084 = 5.3x the voxels it keeps, nearly all of it wasted z. The
20
- # z-halo only needs to cover one nucleus in z, not one nucleus in x/y.
21
- overlap: [4, 30, 30]
22
- skip_empty: true
23
- # Tiles per SLURM job; they run sequentially and share one model load. Raise
24
- # once you know a tile's runtime -- job wall time is ~N x per-tile time.
25
- tiles_per_job: 4
26
- empty_threshold: null
27
-
28
- method: "cellpose"
29
- label_name: "nuclei_labels"
30
- cellpose:
31
- model: "nuclei"
32
- diameter: 15
33
- do_3D: true
34
- gpu: true
35
-
36
- pyramid_levels: 5
37
- pyramid_downscale: 2
38
- sequential_labels: true
39
- merge_workers: null
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