Skip to content

Commit bc4b7bc

Browse files
lguerardclaude
andcommitted
Merge branch dev: shared config for multi-segmentation runs
Settings every segmentation agrees on -- the input, the work_dir, the tiling, everything convert reads -- move into one config/common.yaml that Snakemake merges under each per-config file. Each config drops from 20 keys to 5, and run_multi refuses to start when a key convert reads disagrees across configs, which until now was silently ignored rather than reported. Also skips merge commits in generated changelogs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 parents 07362e5 + e65b2fc commit bc4b7bc

9 files changed

Lines changed: 251 additions & 88 deletions

File tree

cliff.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ commit_preprocessors = [
3131
{ pattern = "(\\w+(?:\\([^)]*\\))?!?:\\s+)[^\\w\\s]+\\s*", replace = "$1" },
3232
]
3333
commit_parsers = [
34+
# Merge commits are noise in a changelog — the merged commits are already listed.
35+
{ message = "^Merge ", skip = true },
3436
{ message = "^feat", group = "✨ Features" },
3537
{ message = "^fix", group = "🐛 Bug Fixes" },
3638
{ message = "^perf", group = "⚡ Performance" },

docs/guide/snakemake.md

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -269,26 +269,38 @@ running the workflow **twice with two configs against the same `work_dir`**
269269
never collides: each run gets its own private subdirectory, and both reuse
270270
the *same* already-converted `image.zarr` (conversion never re-runs).
271271

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+
272277
```yaml
273-
# config/config_nuclei.yaml
278+
# config/common.yaml — shared by every segmentation
274279
input: "/data/scan.ims"
275280
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
276288
label_name: "nuclei_labels"
277289
channel: 1 # nuclear stain channel
278-
tile_shape: [16, 1024, 1024]
290+
overlap: [4, 30, 30]
291+
method: "cellpose"
279292
cellpose:
280293
model: "nuclei"
281294
diameter: 15
282295
do_3D: true
283296
```
284297

285298
```yaml
286-
# config/config_cyto.yaml
287-
input: "/data/scan.ims"
288-
work_dir: "/scratch/results" # same work_dir — image.zarr is reused
299+
# config/config_cyto.yaml — only the differences
289300
label_name: "cyto_labels"
290301
channel: 0 # cytoplasm/membrane channel
291-
tile_shape: [16, 1024, 1024] # keep this identical across configs — see below
302+
overlap: [4, 30, 30]
303+
method: "cellpose"
292304
cellpose:
293305
model: "cyto3"
294306
diameter: 30
@@ -300,12 +312,23 @@ they can run concurrently. Give each its own `--directory`, because
300312
Snakemake's lock lives in the working directory, not in the config:
301313

302314
```bash
303-
snakemake --workflow-profile profile/slurm --configfile config/config_nuclei.yaml \
304-
--directory /scratch/results/nuclei_labels/.snakemake
305-
snakemake --workflow-profile profile/slurm --configfile config/config_cyto.yaml \
306-
--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
307316
```
308317

318+
```bash
319+
snakemake --workflow-profile profile/slurm --configfile config/common.yaml config/config_cyto.yaml --directory /scratch/results/cyto_labels/.snakemake
320+
```
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+
309332
!!! tip "One command for several segmentations + relations"
310333
`config/multi.yaml` lists any number of segmentation configs plus which
311334
pairs to relate afterward; `pixi run multi` (or `multi-slurm`) converts
@@ -339,6 +362,8 @@ and saves every configured relation — one command instead of juggling several
339362

340363
```yaml
341364
# config/multi.yaml
365+
common: config/common.yaml # shared settings, merged under each config below
366+
342367
segmentations:
343368
- config/config_nuclei.yaml
344369
- config/config_cyto.yaml
@@ -349,6 +374,10 @@ relations:
349374
output: nuclei_to_cyto.xlsx # written into work_dir
350375
```
351376

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+
352381
```bash
353382
pixi run multi-dry # dry-run every segmentation config (skips relations)
354383
pixi run multi # run locally

tests/test_run_multi.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88
0, str(Path(__file__).resolve().parents[1] / "workflow" / "scripts")
99
)
1010

11-
from run_multi import slurm_jobname_prefix # noqa: E402
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+
)
1220

1321
# The SLURM executor's own rule (snakemake_executor_plugin_slurm): it raises a
1422
# WorkflowError and aborts the whole run if the prefix does not match.
@@ -36,3 +44,80 @@ def test_jobname_prefix_keeps_the_label_readable():
3644
"""The label must lead, since that is what a queue listing truncates to."""
3745
assert slurm_jobname_prefix("nuclei_labels") == "pw-nuclei_labels"
3846
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

workflow/config/common.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

workflow/config/config_cilia.yaml

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,24 @@
11
# Example segmentation config: cilia channel, via method: "custom" ->
22
# patchworks.plugins.dog.segment (deconvolution + difference-of-Gaussians).
33
# 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.
89
#
910
# pycudadecon is CUDA-only, so this segment job needs a GPU too — same
1011
# profile/slurm as the Cellpose configs already covers it.
1112

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-
1913
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+
2315
# Per-axis halo [z, y, x], covering the PSF support (decon) + the DoG's
2416
# high_sigma. Lateral 30 px at dxdata 0.1 = 3 um; at dzdata 0.2 that is 15
2517
# 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.
2921
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
3522

3623
method: "custom"
3724
# dilate: 2 # optional: pixels to grow labels by after segmentation
@@ -58,8 +45,3 @@ custom:
5845
wavelength: 525
5946
na: 1.4
6047
nimm: 1.515
61-
62-
pyramid_levels: 5
63-
pyramid_downscale: 2
64-
sequential_labels: true
65-
merge_workers: null

workflow/config/config_cyto.yaml

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,17 @@
11
# 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
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
138

149
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
10+
1811
# Per-axis halo [z, y, x]. A scalar 30 would expand a [16, 1024, 1024] tile to
1912
# 76 x 1084 x 1084 = 5.3x the voxels it keeps, nearly all of it wasted z. The
2013
# z-halo only needs to cover one cell in z, not one cell in x/y.
2114
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
2715

2816
method: "cellpose"
2917
label_name: "cyto_labels"
@@ -32,8 +20,3 @@ cellpose:
3220
diameter: 30
3321
do_3D: true
3422
gpu: true
35-
36-
pyramid_levels: 5
37-
pyramid_downscale: 2
38-
sequential_labels: true
39-
merge_workers: null

workflow/config/config_nuclei.yaml

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,17 @@
11
# 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
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
138

149
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
10+
1811
# Per-axis halo [z, y, x]. A scalar 30 would expand a [16, 1024, 1024] tile to
1912
# 76 x 1084 x 1084 = 5.3x the voxels it keeps, nearly all of it wasted z. The
2013
# z-halo only needs to cover one nucleus in z, not one nucleus in x/y.
2114
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
2715

2816
method: "cellpose"
2917
label_name: "nuclei_labels"
@@ -32,8 +20,3 @@ cellpose:
3220
diameter: 15
3321
do_3D: true
3422
gpu: true
35-
36-
pyramid_levels: 5
37-
pyramid_downscale: 2
38-
sequential_labels: true
39-
merge_workers: null

0 commit comments

Comments
 (0)