patchworks 2.6.2__tar.gz → 2.6.4__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.2 → patchworks-2.6.4}/PKG-INFO +2 -2
  2. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/snakemake.md +12 -0
  3. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_notify.py +9 -4
  4. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_notify.py +24 -0
  5. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_run_multi.py +26 -0
  6. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/profile/slurm/config.yaml +24 -4
  7. patchworks-2.6.4/workflow/scripts/relate.py +149 -0
  8. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/scripts/run_multi.py +67 -90
  9. {patchworks-2.6.2 → patchworks-2.6.4}/.github/workflows/docs.yml +0 -0
  10. {patchworks-2.6.2 → patchworks-2.6.4}/.github/workflows/lint.yml +0 -0
  11. {patchworks-2.6.2 → patchworks-2.6.4}/.github/workflows/release.yml +0 -0
  12. {patchworks-2.6.2 → patchworks-2.6.4}/.gitignore +0 -0
  13. {patchworks-2.6.2 → patchworks-2.6.4}/.markdownlint-cli2.yaml +0 -0
  14. {patchworks-2.6.2 → patchworks-2.6.4}/LICENSE +0 -0
  15. {patchworks-2.6.2 → patchworks-2.6.4}/README.md +0 -0
  16. {patchworks-2.6.2 → patchworks-2.6.4}/cliff.toml +0 -0
  17. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/chunks.md +0 -0
  18. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/cluster.md +0 -0
  19. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/io.md +0 -0
  20. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/merge_tile_labels.md +0 -0
  21. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/plugins/cellpose.md +0 -0
  22. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/plugins/dog.md +0 -0
  23. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/plugins/napari.md +0 -0
  24. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/plugins/ome_zarr.md +0 -0
  25. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/postprocess.md +0 -0
  26. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/relabel.md +0 -0
  27. {patchworks-2.6.2 → patchworks-2.6.4}/docs/api/tile_process.md +0 -0
  28. {patchworks-2.6.2 → patchworks-2.6.4}/docs/assets/logo.png +0 -0
  29. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/cellpose_2d.md +0 -0
  30. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/cellpose_2d.py +0 -0
  31. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/cellpose_3d.md +0 -0
  32. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/cellpose_3d.py +0 -0
  33. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/custom.md +0 -0
  34. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/custom_method.py +0 -0
  35. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/dog.md +0 -0
  36. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/dog.py +0 -0
  37. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/standalone_merge.md +0 -0
  38. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/stardist.md +0 -0
  39. {patchworks-2.6.2 → patchworks-2.6.4}/docs/examples/stardist_2d.py +0 -0
  40. {patchworks-2.6.2 → patchworks-2.6.4}/docs/getting_started.md +0 -0
  41. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/custom_segmentation.md +0 -0
  42. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/gpu_distributed.md +0 -0
  43. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/label_relations.md +0 -0
  44. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/measurements.md +0 -0
  45. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/merging.md +0 -0
  46. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/ome_zarr_napari.md +0 -0
  47. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/performance.md +0 -0
  48. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/pitfalls.md +0 -0
  49. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/skip_empty.md +0 -0
  50. {patchworks-2.6.2 → patchworks-2.6.4}/docs/guide/tiling.md +0 -0
  51. {patchworks-2.6.2 → patchworks-2.6.4}/docs/index.md +0 -0
  52. {patchworks-2.6.2 → patchworks-2.6.4}/mkdocs.yml +0 -0
  53. {patchworks-2.6.2 → patchworks-2.6.4}/pyproject.toml +0 -0
  54. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/__init__.py +0 -0
  55. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_chunks.py +0 -0
  56. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_cluster.py +0 -0
  57. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_core.py +0 -0
  58. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_distributed.py +0 -0
  59. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_gpu.py +0 -0
  60. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_io.py +0 -0
  61. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_merge.py +0 -0
  62. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_occupancy.py +0 -0
  63. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_postprocess.py +0 -0
  64. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_progress.py +0 -0
  65. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_relabel.py +0 -0
  66. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/_relations.py +0 -0
  67. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/plugins/__init__.py +0 -0
  68. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/plugins/cellpose.py +0 -0
  69. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/plugins/dog.py +0 -0
  70. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/plugins/napari.py +0 -0
  71. {patchworks-2.6.2 → patchworks-2.6.4}/src/patchworks/plugins/ome_zarr.py +0 -0
  72. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_allocation.py +0 -0
  73. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_core.py +0 -0
  74. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_distributed.py +0 -0
  75. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_dog.py +0 -0
  76. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_gpu.py +0 -0
  77. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_napari.py +0 -0
  78. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_occupancy.py +0 -0
  79. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_ome_zarr.py +0 -0
  80. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_postprocess.py +0 -0
  81. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_progress.py +0 -0
  82. {patchworks-2.6.2 → patchworks-2.6.4}/tests/test_relations.py +0 -0
  83. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/README.md +0 -0
  84. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/Snakefile +0 -0
  85. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/config/common.yaml +0 -0
  86. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/config/config.yaml +0 -0
  87. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/config/config_cilia.yaml +0 -0
  88. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/config/config_cyto.yaml +0 -0
  89. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/config/config_nuclei.yaml +0 -0
  90. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/config/multi.yaml +0 -0
  91. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/pixi.toml +0 -0
  92. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/rules/common.smk +0 -0
  93. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/rules/convert.smk +0 -0
  94. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/rules/merge.smk +0 -0
  95. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/rules/segment.smk +0 -0
  96. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/scripts/_pw.py +0 -0
  97. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/scripts/build_occupancy.py +0 -0
  98. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/scripts/convert.py +0 -0
  99. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/scripts/fetch_model.py +0 -0
  100. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/scripts/merge.py +0 -0
  101. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/scripts/prepare_tiles.py +0 -0
  102. {patchworks-2.6.2 → patchworks-2.6.4}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: patchworks
3
- Version: 2.6.2
3
+ Version: 2.6.4
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
@@ -451,6 +451,18 @@ the GPU partition stays busy instead of idling through every config's
451
451
  `prepare` and multi-hour `merge` in turn. A config that fails does **not**
452
452
  abort the others; you get a per-config status and a non-zero exit.
453
453
 
454
+ !!! tip "The relate step runs on the cluster too, under `multi-slurm`"
455
+ `label_relations()` streams every chunk of two full-resolution label
456
+ volumes — real CPU/IO work, not orchestration. Under `multi-slurm` it is
457
+ submitted as its own `srun` job (`scripts/relate.py`) instead of running
458
+ in the driver process on the login node, the same fix already applied to
459
+ the occupancy map. Tune its allocation with `--relate-partition`,
460
+ `--relate-mem`, `--relate-cpus` and `--relate-time` (defaults: `scicore`,
461
+ `32G`, `8`, `180` minutes) — these are wide-margin guesses, not measured
462
+ numbers, so raise them for a very large or very object-dense pair. Under
463
+ plain `multi` (no `--profile`), it still runs locally, in-process, as
464
+ before.
465
+
454
466
  !!! tip "After a killed run"
455
467
  Snakemake only releases its lock on a clean exit, so a run that was killed
456
468
  (Ctrl-C, an SSH drop, an OOM) leaves the directory locked. Each phase has
@@ -159,7 +159,7 @@ def slurm_mail_extra(
159
159
 
160
160
 
161
161
  def failing_step(
162
- snakemake_log: Union[str, Path, None],
162
+ snakemake_log: "Union[str, Path, list, tuple, None]",
163
163
  ) -> "tuple[Union[str, None], Union[str, None]]":
164
164
  """Find which rule failed, and its log, from Snakemake's own log file.
165
165
 
@@ -171,9 +171,12 @@ def failing_step(
171
171
 
172
172
  Parameters
173
173
  ----------
174
- snakemake_log : str or Path or None
175
- Path to Snakemake's own log (the ``log`` variable inside an
176
- ``onerror`` handler).
174
+ snakemake_log : str, Path, list, tuple, or None
175
+ Snakemake's own log (the ``log`` variable inside an ``onerror``
176
+ handler). Snakemake 8+ passes this as a list of paths (its
177
+ ``LoggerManager.get_logfile()`` returns ``List[str]``, even though
178
+ there is normally just one) rather than a single string -- take the
179
+ first entry.
177
180
 
178
181
  Returns
179
182
  -------
@@ -181,6 +184,8 @@ def failing_step(
181
184
  ``(rule_name, log_path)``, either of which may be None when the log
182
185
  is unreadable or records no rule error.
183
186
  """
187
+ if isinstance(snakemake_log, (list, tuple)):
188
+ snakemake_log = snakemake_log[0] if snakemake_log else None
184
189
  try:
185
190
  text = Path(snakemake_log).read_text(errors="replace")
186
191
  except (OSError, TypeError, ValueError):
@@ -99,6 +99,30 @@ def test_failing_step_reads_the_rule_from_snakemakes_log(tmp_path):
99
99
  )
100
100
 
101
101
 
102
+ def test_failing_step_accepts_snakemakes_list_log(tmp_path):
103
+ """Snakemake 8+ passes `log` as a list, not a bare path.
104
+
105
+ ``LoggerManager.get_logfile()`` returns ``List[str]``, and that's what
106
+ the `log` variable inside `onerror:` actually is. `Path(a_list)` raises
107
+ `TypeError`, which the old code caught and turned into `(None, None)` --
108
+ so this path was *always* silently falling back to the mtime guess it
109
+ was written to replace, on every real failure.
110
+ """
111
+ from patchworks._notify import failing_step
112
+
113
+ log = tmp_path / "sm.log"
114
+ log.write_text(
115
+ "Error in rule segment:\n"
116
+ " jobid: 69\n"
117
+ " output: seg/61.done\n"
118
+ " log: /w/nuclei_labels/logs/segment/61.log (check log file(s))\n"
119
+ )
120
+ expected = ("segment", "/w/nuclei_labels/logs/segment/61.log")
121
+ assert failing_step([str(log)]) == expected
122
+ assert failing_step((str(log),)) == expected
123
+ assert failing_step([]) == (None, None)
124
+
125
+
102
126
  def test_failing_step_degrades_quietly(tmp_path):
103
127
  """It runs inside an error handler, so it must never raise itself."""
104
128
  from patchworks._notify import failing_step
@@ -160,6 +160,32 @@ def test_occupancy_is_not_rebuilt_by_the_driver():
160
160
  assert "occupancy.zarr" in src
161
161
 
162
162
 
163
+ def test_relate_is_submitted_via_slurm_under_profile():
164
+ """The relate step must never run in-process on the submit host.
165
+
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
+
163
189
  def test_auto_tile_shape_with_a_lone_nuclei_channel_is_refused():
164
190
  """Matching `tile_shape` *values* are not enough when one config is 2-ch.
165
191
 
@@ -74,7 +74,7 @@ set-resources:
74
74
  # equal to segment's mem_mb below: lower and tiles come out needlessly
75
75
  # small; higher and the sizer budgets against more host RAM than segment
76
76
  # will actually get, undoing the point of the host-RAM check.
77
- mem_mb: "attempt * 32000"
77
+ mem_mb: "attempt * 128000"
78
78
  runtime: 120
79
79
  segment:
80
80
  # one GPU per job — this is what spreads Cellpose across GPUs.
@@ -83,15 +83,35 @@ set-resources:
83
83
  # leaves the job without a device.)
84
84
  slurm_partition: "rtx4090"
85
85
  gres: "gpu:1"
86
- qos: "rtx4090-6hours" # scicore: <partition>-<duration> QOS
86
+ # rtx4090-6hours wasn't enough: a do_3D whole-z tile (126 planes, full
87
+ # footprint) can run past 6h even though most same-sized tiles finish in
88
+ # 2.5-3.5h -- retrying it bought nothing since `runtime` didn't scale with
89
+ # `attempt` the way `mem_mb` does below, so it hit the identical wall on
90
+ # every attempt and burned all `retries` for a job that was never going to
91
+ # finish in the window. rtx4090-1day is scicore's next tier up (see
92
+ # `sacctmgr show qos format=name,maxwall | grep rtx4090`); 24h is wide
93
+ # margin over anything observed so far. If a tile still times out at 24h,
94
+ # that's not a slow tile anymore -- it's stuck, and worth profiling on its
95
+ # own rather than reaching for rtx4090-1week.
96
+ qos: "rtx4090-1day" # scicore: <partition>-<duration> QOS
87
97
  # A job now processes `tiles_per_job` tiles sequentially, so both memory
88
98
  # and runtime scale with that setting — raise it there and re-check here.
89
99
  # The old "a tile used ~1G" note predates tile_shape: "auto", which sizes
90
100
  # tiles against the real GPU and makes them far bigger. If you raise
91
101
  # this, raise prepare's mem_mb above to match (see its comment).
92
- mem_mb: "attempt * 32000"
102
+ #
103
+ # 128 GB base (256/384 GB on retry) is a wide safety margin, not a tight
104
+ # estimate: a do_3D tile that `auto_tile_shape_cellpose`'s 20x
105
+ # cellpose_memory_factor judged safe within a 24 GiB GPU budget still hit
106
+ # a real 32 GB host OOM, so that heuristic underestimates do_3D's actual
107
+ # host-RAM use by more than expected. rtx4090 nodes have ~1 TB RAM
108
+ # (~800 GB usable) and the QOS caps at 4 TB account-wide, so this has
109
+ # plenty of room -- it buys time until do_3D gets a properly measured
110
+ # memory factor (e.g. from `seff` on a job that completes) instead of a
111
+ # guess. ``ponytail:`` tighten this once real peak-RSS numbers exist.
112
+ mem_mb: "attempt * 128000"
93
113
  cpus_per_task: 4
94
- runtime: 360 # 6 hours — must match the QOS, NOT 120 (=2h → killed early)
114
+ runtime: 1400 # ~23.3h — must stay under the rtx4090-1day QOS cap (24h)
95
115
  merge:
96
116
  # merge.py sizes its worker pool from the cgroup/SLURM budget rather than
97
117
  # the node's core count, and the pyramid is written zarr-natively (one
@@ -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
@@ -337,6 +341,28 @@ def main() -> None:
337
341
  "otherwise look identical: no email either way."
338
342
  ),
339
343
  )
344
+ parser.add_argument(
345
+ "--relate-partition",
346
+ default="scicore",
347
+ help="SLURM partition for the relate step under --profile (default: scicore)",
348
+ )
349
+ parser.add_argument(
350
+ "--relate-mem",
351
+ default="32G",
352
+ help="srun --mem for the relate step under --profile (default: 32G)",
353
+ )
354
+ parser.add_argument(
355
+ "--relate-cpus",
356
+ type=int,
357
+ default=8,
358
+ help="srun --cpus-per-task for the relate step under --profile (default: 8)",
359
+ )
360
+ parser.add_argument(
361
+ "--relate-time",
362
+ type=int,
363
+ default=180,
364
+ help="srun --time in minutes for the relate step under --profile (default: 180)",
365
+ )
340
366
  args = parser.parse_args()
341
367
 
342
368
  workflow_dir = Path(__file__).resolve().parent.parent
@@ -467,96 +493,47 @@ def main() -> None:
467
493
  if args.dry_run or not relations:
468
494
  return
469
495
 
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())
496
+ if args.profile:
497
+ # Real CPU/IO work -- tens of thousands of zarr chunk reads for a
498
+ # full-resolution label volume -- not orchestration, so (like the
499
+ # occupancy map) it does not belong in this driver process on the
500
+ # login node. Submit it as its own job instead.
501
+ cmd = [
502
+ "srun",
503
+ "--partition",
504
+ args.relate_partition,
505
+ "--mem",
506
+ args.relate_mem,
507
+ "--cpus-per-task",
508
+ str(args.relate_cpus),
509
+ "--time",
510
+ str(args.relate_time),
511
+ "--job-name",
512
+ "pw-relate",
513
+ sys.executable,
514
+ str(workflow_dir / "scripts" / "relate.py"),
515
+ "--work-dir",
516
+ work_dir,
517
+ "--image-store",
518
+ image_store,
519
+ "--relations",
520
+ json.dumps(relations),
521
+ ]
522
+ rc = _run(cmd, workflow_dir)
523
+ if rc != 0:
524
+ print(
525
+ f"[run_multi] ERROR: relate step failed (exit {rc}). "
526
+ "Segmentations already succeeded -- only the relation "
527
+ "workbook(s) are missing. Re-run with the same --config to "
528
+ "retry just this step.",
529
+ file=sys.stderr,
530
+ )
531
+ sys.exit(rc)
532
+ return
498
533
 
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
- )
534
+ from relate import run_relations
535
+
536
+ run_relations(work_dir, image_store, relations)
560
537
 
561
538
 
562
539
  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