patchworks 2.6.1__tar.gz → 2.6.3__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 (101) hide show
  1. {patchworks-2.6.1 → patchworks-2.6.3}/PKG-INFO +2 -2
  2. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/gpu_distributed.md +5 -0
  3. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/snakemake.md +31 -2
  4. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/tiling.md +17 -0
  5. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_chunks.py +33 -7
  6. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_notify.py +9 -4
  7. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_allocation.py +42 -1
  8. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_core.py +9 -1
  9. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_notify.py +24 -0
  10. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_run_multi.py +27 -0
  11. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/README.md +5 -0
  12. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/profile/slurm/config.yaml +33 -5
  13. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/scripts/run_multi.py +18 -0
  14. {patchworks-2.6.1 → patchworks-2.6.3}/.github/workflows/docs.yml +0 -0
  15. {patchworks-2.6.1 → patchworks-2.6.3}/.github/workflows/lint.yml +0 -0
  16. {patchworks-2.6.1 → patchworks-2.6.3}/.github/workflows/release.yml +0 -0
  17. {patchworks-2.6.1 → patchworks-2.6.3}/.gitignore +0 -0
  18. {patchworks-2.6.1 → patchworks-2.6.3}/.markdownlint-cli2.yaml +0 -0
  19. {patchworks-2.6.1 → patchworks-2.6.3}/LICENSE +0 -0
  20. {patchworks-2.6.1 → patchworks-2.6.3}/README.md +0 -0
  21. {patchworks-2.6.1 → patchworks-2.6.3}/cliff.toml +0 -0
  22. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/chunks.md +0 -0
  23. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/cluster.md +0 -0
  24. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/io.md +0 -0
  25. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/merge_tile_labels.md +0 -0
  26. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/plugins/cellpose.md +0 -0
  27. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/plugins/dog.md +0 -0
  28. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/plugins/napari.md +0 -0
  29. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/plugins/ome_zarr.md +0 -0
  30. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/postprocess.md +0 -0
  31. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/relabel.md +0 -0
  32. {patchworks-2.6.1 → patchworks-2.6.3}/docs/api/tile_process.md +0 -0
  33. {patchworks-2.6.1 → patchworks-2.6.3}/docs/assets/logo.png +0 -0
  34. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/cellpose_2d.md +0 -0
  35. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/cellpose_2d.py +0 -0
  36. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/cellpose_3d.md +0 -0
  37. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/cellpose_3d.py +0 -0
  38. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/custom.md +0 -0
  39. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/custom_method.py +0 -0
  40. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/dog.md +0 -0
  41. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/dog.py +0 -0
  42. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/standalone_merge.md +0 -0
  43. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/stardist.md +0 -0
  44. {patchworks-2.6.1 → patchworks-2.6.3}/docs/examples/stardist_2d.py +0 -0
  45. {patchworks-2.6.1 → patchworks-2.6.3}/docs/getting_started.md +0 -0
  46. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/custom_segmentation.md +0 -0
  47. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/label_relations.md +0 -0
  48. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/measurements.md +0 -0
  49. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/merging.md +0 -0
  50. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/ome_zarr_napari.md +0 -0
  51. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/performance.md +0 -0
  52. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/pitfalls.md +0 -0
  53. {patchworks-2.6.1 → patchworks-2.6.3}/docs/guide/skip_empty.md +0 -0
  54. {patchworks-2.6.1 → patchworks-2.6.3}/docs/index.md +0 -0
  55. {patchworks-2.6.1 → patchworks-2.6.3}/mkdocs.yml +0 -0
  56. {patchworks-2.6.1 → patchworks-2.6.3}/pyproject.toml +0 -0
  57. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/__init__.py +0 -0
  58. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_cluster.py +0 -0
  59. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_core.py +0 -0
  60. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_distributed.py +0 -0
  61. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_gpu.py +0 -0
  62. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_io.py +0 -0
  63. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_merge.py +0 -0
  64. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_occupancy.py +0 -0
  65. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_postprocess.py +0 -0
  66. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_progress.py +0 -0
  67. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_relabel.py +0 -0
  68. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/_relations.py +0 -0
  69. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/plugins/__init__.py +0 -0
  70. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/plugins/cellpose.py +0 -0
  71. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/plugins/dog.py +0 -0
  72. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/plugins/napari.py +0 -0
  73. {patchworks-2.6.1 → patchworks-2.6.3}/src/patchworks/plugins/ome_zarr.py +0 -0
  74. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_distributed.py +0 -0
  75. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_dog.py +0 -0
  76. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_gpu.py +0 -0
  77. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_napari.py +0 -0
  78. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_occupancy.py +0 -0
  79. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_ome_zarr.py +0 -0
  80. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_postprocess.py +0 -0
  81. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_progress.py +0 -0
  82. {patchworks-2.6.1 → patchworks-2.6.3}/tests/test_relations.py +0 -0
  83. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/Snakefile +0 -0
  84. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/config/common.yaml +0 -0
  85. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/config/config.yaml +0 -0
  86. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/config/config_cilia.yaml +0 -0
  87. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/config/config_cyto.yaml +0 -0
  88. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/config/config_nuclei.yaml +0 -0
  89. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/config/multi.yaml +0 -0
  90. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/pixi.toml +0 -0
  91. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/rules/common.smk +0 -0
  92. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/rules/convert.smk +0 -0
  93. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/rules/merge.smk +0 -0
  94. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/rules/segment.smk +0 -0
  95. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/scripts/_pw.py +0 -0
  96. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/scripts/build_occupancy.py +0 -0
  97. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/scripts/convert.py +0 -0
  98. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/scripts/fetch_model.py +0 -0
  99. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/scripts/merge.py +0 -0
  100. {patchworks-2.6.1 → patchworks-2.6.3}/workflow/scripts/prepare_tiles.py +0 -0
  101. {patchworks-2.6.1 → patchworks-2.6.3}/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.1
3
+ Version: 2.6.3
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
@@ -79,6 +79,11 @@ own outright. `auto_tile_shape` then sizes each tile to at most half of that
79
79
  budget; `auto_tile_shape_cellpose` uses Cellpose's own memory model instead
80
80
  (roughly 20× the raw tile bytes, plus ~2 GiB for the model).
81
81
 
82
+ "Raw tile bytes" counts every channel a tile carries, so pass `n_channels=2`
83
+ when feeding Cellpose a cyto+nuclei pair — see
84
+ [Multi-channel tiles](tiling.md#multi-channel-tiles). The Snakemake workflow
85
+ does this for you whenever `nuclei_channel` is set.
86
+
82
87
  The device is resolved from `CUDA_VISIBLE_DEVICES`. This matters on
83
88
  multi-GPU nodes: NVML enumerates **every** GPU regardless of `--gres=gpu:1`,
84
89
  so querying index 0 unconditionally would read a different card's free memory
@@ -109,6 +109,15 @@ sequential_labels: true # renumber labels to a contiguous 1..N
109
109
  explicit z (like `[16, 1024, 1024]`) tiles in z instead. `prepare` logs
110
110
  which regime it picked.
111
111
 
112
+ `"auto"` also caps the tile to the **host** RAM available to the job, not
113
+ just VRAM — a `do_3D` tile that comfortably fits a big GPU can still be
114
+ too large for the SLURM/cgroup memory the job was actually granted, and
115
+ that shows up as a plain `SIGKILL`, not a catchable CUDA-OOM error.
116
+ Because `prepare` runs on a CPU node, it checks *its own* grant as a
117
+ stand-in for `segment`'s — keep `prepare`'s and `segment`'s `mem_mb` in
118
+ `profile/slurm/config.yaml` equal, or the estimate is sized against the
119
+ wrong job's budget.
120
+
112
121
  !!! tip "Use a per-axis `overlap`"
113
122
  A scalar halo is applied to every axis. On a `[16, 1024, 1024]` tile,
114
123
  `overlap: 30` reads `76 × 1084 × 1084` to keep `16 × 1024 × 1024` — 5.3×
@@ -323,13 +332,33 @@ follow from that:
323
332
 
324
333
  - A tile holds twice the bytes. `tile_shape: "auto"` accounts for this — it is
325
334
  told the tile carries two channels and shrinks each spatial side by ~1/√2,
326
- so the tile still fits the same VRAM budget. A **hand-set** `tile_shape`
327
- sized to fill a GPU has no such protection and needs halving yourself.
335
+ so the tile still fits the same VRAM *and* host-RAM budget (see the "Tile
336
+ size vs runtime" tip above). A **hand-set** `tile_shape` sized to fill a
337
+ GPU has no such protection and needs halving yourself.
328
338
  - The translation is version-specific. Cellpose 3 gets `channels: [1, 2]`
329
339
  (1-based into the channel axis, `0` = grayscale); Cellpose 4 (cpsam) dropped
330
340
  `channels` entirely and simply reads both. Either is overridable by setting
331
341
  `channels:` or `channel_axis:` in the `cellpose:` block.
332
342
 
343
+ !!! warning "In a `multi.yaml` run, pin `tile_shape` explicitly"
344
+
345
+ `label_relations` requires its two label arrays to share a chunk layout,
346
+ and that layout comes from `tile_shape`. Giving *one* config a
347
+ `nuclei_channel` while the group uses `tile_shape: "auto"` produces a
348
+ **smaller** tile for that config only — so the label groups end up chunked
349
+ differently and the relations step fails, after every segmentation has
350
+ already run.
351
+
352
+ `run_multi`'s cross-config check compares the configured values, and
353
+ `"auto" == "auto"`, so it flags this case specifically. Fix it by setting
354
+ one explicit `tile_shape` in `common.yaml`, sized for the two-channel
355
+ config (roughly each spatial side ÷ √2 versus what you would use for a
356
+ single channel), so every config shares it.
357
+
358
+ If you do not need that segmentation related to the others, the alternative is
359
+ to run it on its own against the same `work_dir` and leave it out of
360
+ `multi.yaml`.
361
+
333
362
  `nuclei_channel` applies to the SLURM/Snakemake path. The single-process
334
363
  `tile_process` API still takes one `channel`.
335
364
 
@@ -57,6 +57,23 @@ tile_process("image.zarr", fn, tile_shape=tile_fn)
57
57
  The callable is called with `(shape, dtype)` at runtime, after the image is
58
58
  loaded — useful when you don't know the image shape in advance.
59
59
 
60
+ ### Multi-channel tiles
61
+
62
+ Both sizers take `n_channels` (default 1) and charge it per voxel. Use it when
63
+ a tile carries more than one channel while its *geometry* stays spatial — the
64
+ case the Snakemake workflow's
65
+ [`nuclei_channel`](snakemake.md#giving-cellpose-a-nuclei-channel) creates, where
66
+ Cellpose is handed a cyto+nuclei pair but still returns one label per voxel:
67
+
68
+ ```python
69
+ # Same VRAM budget, so each spatial side shrinks by ~1/√2
70
+ auto_tile_shape_cellpose(shape, dtype, diameter=30, use_gpu=True, n_channels=2)
71
+ ```
72
+
73
+ Leaving it at 1 for a 2-channel tile budgets for half the bytes the tile
74
+ actually holds, which surfaces as an out-of-memory error in the first tile
75
+ rather than as anything about channels.
76
+
60
77
  ## Overlap
61
78
 
62
79
  Methods that need spatial context (Cellpose, StarDist, U-Net) produce wrong
@@ -281,11 +281,15 @@ def auto_tile_shape(
281
281
  target_bytes:
282
282
  Memory ceiling per tile. Default 64 MiB.
283
283
  use_gpu:
284
- Size tiles against GPU VRAM rather than host RAM.
284
+ Size tiles against GPU VRAM rather than host RAM. Still capped by
285
+ host RAM too -- see ``available_memory``.
285
286
  gpu_memory:
286
287
  Available GPU VRAM in bytes; auto-queried when None.
287
288
  available_memory:
288
- Available host RAM in bytes; auto-queried when None.
289
+ Available host RAM in bytes; auto-queried when None. Consulted even
290
+ when ``use_gpu`` is set: the tile has to be loaded into host memory
291
+ before (and often after) it reaches the GPU, so it must fit both
292
+ budgets, not just VRAM.
289
293
  n_workers:
290
294
  Number of parallel workers (divides the RAM budget).
291
295
  n_channels:
@@ -316,7 +320,13 @@ def auto_tile_shape(
316
320
  n_spatial = min(3, len(shape))
317
321
 
318
322
  if use_gpu:
319
- mem = gpu_memory if gpu_memory is not None else _get_gpu_memory()
323
+ # A tile that fits VRAM still has to be decoded into (and often
324
+ # copied back out of) host RAM first, so a GPU with more memory
325
+ # than the job's own host allocation must not produce a tile the
326
+ # job itself can't hold -- take whichever budget is tighter.
327
+ gpu_mem = gpu_memory if gpu_memory is not None else _get_gpu_memory()
328
+ host_mem = available_memory or _get_available_memory()
329
+ mem = min(gpu_mem, host_mem)
320
330
  budget = min(target_bytes * 2, mem // 2)
321
331
  else:
322
332
  mem = available_memory or _get_available_memory()
@@ -395,13 +405,23 @@ def auto_tile_shape_cellpose(
395
405
  do_3D:
396
406
  Whether Cellpose will run in 3-D mode.
397
407
  use_gpu:
398
- Size tiles for GPU VRAM.
408
+ Size tiles for GPU VRAM. Still capped by host RAM too -- see
409
+ ``available_memory``.
399
410
  gpu_memory, available_memory, n_workers:
400
- Memory parameters (auto-queried when None).
411
+ Memory parameters (auto-queried when None). ``available_memory`` is
412
+ consulted even when ``use_gpu`` is set: Cellpose's ``do_3D``
413
+ preprocessing (orthogonal-view flows, mask assembly) copies the tile
414
+ through host memory, so VRAM headroom alone doesn't guarantee the
415
+ job's own host allocation can hold it.
401
416
  model_memory_bytes:
402
417
  Memory consumed by the Cellpose model weights (default 2 GiB).
403
418
  cellpose_memory_factor:
404
- Cellpose allocates roughly this multiple of raw input bytes (default 20×).
419
+ Cellpose allocates roughly this multiple of raw input bytes (default
420
+ 20×). Applied against whichever of VRAM/host RAM is tighter --
421
+ ``ponytail:`` this reuses one factor for both budgets as an
422
+ approximation; if it proves off in practice, calibrate a separate
423
+ host-side factor from a SLURM job's peak RSS (``seff``/
424
+ ``/usr/bin/time -v``) and pass it alongside this one.
405
425
  n_channels:
406
426
  Channels each tile carries (default 1). Above 1 the per-voxel cost
407
427
  scales with it, so the tile shrinks accordingly -- e.g. the workflow's
@@ -429,7 +449,13 @@ def auto_tile_shape_cellpose(
429
449
  itemsize = np.dtype(dtype).itemsize * n_channels
430
450
 
431
451
  if use_gpu:
432
- total_mem = gpu_memory if gpu_memory is not None else _get_gpu_memory()
452
+ # A tile that fits VRAM still has to be decoded into (and often
453
+ # copied back out of) host RAM first, so a GPU with more memory
454
+ # than the job's own host allocation must not produce a tile the
455
+ # job itself can't hold -- take whichever budget is tighter.
456
+ gpu_mem = gpu_memory if gpu_memory is not None else _get_gpu_memory()
457
+ host_mem = available_memory or _get_available_memory()
458
+ total_mem = min(gpu_mem, host_mem)
433
459
  else:
434
460
  total_mem = (available_memory or _get_available_memory()) // n_workers
435
461
 
@@ -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):
@@ -8,7 +8,11 @@ checks that would have caught it.
8
8
 
9
9
  import numpy as np
10
10
 
11
- from patchworks import cpu_allocation, safe_worker_count
11
+ from patchworks import (
12
+ auto_tile_shape_cellpose,
13
+ cpu_allocation,
14
+ safe_worker_count,
15
+ )
12
16
  from patchworks._chunks import _get_available_memory
13
17
 
14
18
  GIB = 1024**3
@@ -76,3 +80,40 @@ def test_worker_count_is_bounded_by_the_allocation(monkeypatch):
76
80
  n = safe_worker_count(chunk_nbytes * 40, fn_overhead=3)
77
81
  assert n < 32, "must not size itself to the core count when RAM is tighter"
78
82
  assert n >= 1
83
+
84
+
85
+ def test_gpu_tile_sizing_is_bounded_by_the_host_allocation(monkeypatch):
86
+ """A big GPU must not excuse a tile the job's own host RAM can't hold.
87
+
88
+ This is the concrete failure a `do_3D` nuclei segmentation hit: an ample
89
+ GPU (24 GiB) sized the tile against VRAM alone, and the job -- granted
90
+ only 1 GiB of host RAM here -- was SIGKILLed loading it, unrelated to
91
+ ``nuclei_channel``. The sizer must take whichever budget is tighter.
92
+ """
93
+ monkeypatch.delenv("SLURM_MEM_PER_CPU", raising=False)
94
+ monkeypatch.setenv("SLURM_MEM_PER_NODE", str(1024)) # 1 GiB
95
+ monkeypatch.setattr(
96
+ "patchworks._chunks._cgroup_memory_limit", lambda: 512 * GIB
97
+ )
98
+
99
+ tile = auto_tile_shape_cellpose(
100
+ (128, 2048, 2048),
101
+ "uint16",
102
+ diameter=30,
103
+ do_3D=True,
104
+ use_gpu=True,
105
+ gpu_memory=24 * GIB,
106
+ )
107
+ generous = auto_tile_shape_cellpose(
108
+ (128, 2048, 2048),
109
+ "uint16",
110
+ diameter=30,
111
+ do_3D=True,
112
+ use_gpu=True,
113
+ gpu_memory=24 * GIB,
114
+ available_memory=64 * GIB,
115
+ )
116
+ assert np.prod(tile) < np.prod(generous), (
117
+ "the 1 GiB host grant must shrink the tile below what the same "
118
+ "24 GiB GPU would otherwise allow"
119
+ )
@@ -410,7 +410,15 @@ def test_auto_tile_shape_charges_for_extra_channels():
410
410
  assert np.prod(two) * 2 <= np.prod(one)
411
411
  assert np.prod(two) * 2 >= np.prod(one) * 0.9
412
412
 
413
- kw = dict(diameter=30, do_3D=True, use_gpu=True, gpu_memory=24 * 1024**3)
413
+ kw = dict(
414
+ diameter=30,
415
+ do_3D=True,
416
+ use_gpu=True,
417
+ gpu_memory=24 * 1024**3,
418
+ # Generous on purpose: this test is about GPU-vs-channel scaling,
419
+ # not the host-RAM ceiling, so host RAM must stay non-binding here.
420
+ available_memory=64 * 1024**3,
421
+ )
414
422
  cp_one = auto_tile_shape_cellpose(shape, dtype, **kw)
415
423
  cp_two = auto_tile_shape_cellpose(shape, dtype, n_channels=2, **kw)
416
424
  assert np.prod(cp_two) * 2 <= np.prod(cp_one)
@@ -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
@@ -158,3 +158,30 @@ def test_occupancy_is_not_rebuilt_by_the_driver():
158
158
  src = (_workflow_dir() / "scripts" / "run_multi.py").read_text()
159
159
  assert "build_occupancy_map(" not in src
160
160
  assert "occupancy.zarr" in src
161
+
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.
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.
170
+ """
171
+ paths = [Path("a.yaml"), Path("b.yaml")]
172
+ base = {"work_dir": "/w", "tile_shape": "auto", "level": 0}
173
+
174
+ bad = [
175
+ {**base, "label_name": "a", "channel": 0, "nuclei_channel": 1},
176
+ {**base, "label_name": "b", "channel": 2},
177
+ ]
178
+ with pytest.raises(SystemExit):
179
+ _validate_configs(paths, bad)
180
+
181
+ # Same pair with one explicit shape is fine: both get that tile.
182
+ pinned = [{**c, "tile_shape": [16, 512, 512]} for c in bad]
183
+ assert _validate_configs(paths, pinned) == "/w"
184
+
185
+ # And "auto" is fine when every config carries the same channel count.
186
+ both = [{**bad[0]}, {**bad[1], "nuclei_channel": 3}]
187
+ assert _validate_configs(paths, both) == "/w"
@@ -68,6 +68,11 @@ Edit `config/config.yaml` (input, output dir, channel, tile shape, Cellpose
68
68
  model/diameter/`do_3D`, …) and `profile/slurm/config.yaml` (partitions,
69
69
  account, GPU request).
70
70
 
71
+ Channel indices are **0-based**. Set `nuclei_channel` alongside `channel` to
72
+ hand Cellpose the nuclear stain as a second input, which usually improves
73
+ cytoplasm segmentation — see
74
+ [Giving Cellpose a nuclei channel](https://imcf.one/patchworks/guide/snakemake/#giving-cellpose-a-nuclei-channel).
75
+
71
76
  ## Run
72
77
 
73
78
  ```bash
@@ -67,7 +67,14 @@ set-resources:
67
67
  prepare:
68
68
  # Cheap now: it reduces the already-built occupancy map over each tile's
69
69
  # footprint instead of touching the image.
70
- mem_mb: "attempt * 32000"
70
+ #
71
+ # tile_shape: "auto" now also sizes against a host-RAM budget, and it
72
+ # reads *this job's* grant as a stand-in for segment's -- prepare runs on
73
+ # a CPU node and can't query segment's GPU-node grant directly. Keep this
74
+ # equal to segment's mem_mb below: lower and tiles come out needlessly
75
+ # small; higher and the sizer budgets against more host RAM than segment
76
+ # will actually get, undoing the point of the host-RAM check.
77
+ mem_mb: "attempt * 128000"
71
78
  runtime: 120
72
79
  segment:
73
80
  # one GPU per job — this is what spreads Cellpose across GPUs.
@@ -76,14 +83,35 @@ set-resources:
76
83
  # leaves the job without a device.)
77
84
  slurm_partition: "rtx4090"
78
85
  gres: "gpu:1"
79
- 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
80
97
  # A job now processes `tiles_per_job` tiles sequentially, so both memory
81
98
  # and runtime scale with that setting — raise it there and re-check here.
82
99
  # The old "a tile used ~1G" note predates tile_shape: "auto", which sizes
83
- # tiles against the real GPU and makes them far bigger.
84
- mem_mb: "attempt * 32000"
100
+ # tiles against the real GPU and makes them far bigger. If you raise
101
+ # this, raise prepare's mem_mb above to match (see its comment).
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"
85
113
  cpus_per_task: 4
86
- 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)
87
115
  merge:
88
116
  # merge.py sizes its worker pool from the cgroup/SLURM budget rather than
89
117
  # the node's core count, and the pyramid is written zarr-natively (one
@@ -228,6 +228,24 @@ def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
228
228
  f"share a chunk layout; got {_spread(key)}"
229
229
  )
230
230
 
231
+ # `tile_shape: "auto"` is identical as a *value* across configs while
232
+ # 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
+ )
248
+
231
249
  # Phase A converts once, from the first config. Anything `convert` reads
232
250
  # out of a later config is therefore silently ignored -- someone setting
233
251
  # `shard: true` on the second config and watching a million files appear
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