patchworks 2.6.0__tar.gz → 2.6.2__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.0 → patchworks-2.6.2}/PKG-INFO +1 -1
  2. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/gpu_distributed.md +5 -0
  3. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/snakemake.md +33 -3
  4. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/tiling.md +17 -0
  5. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_chunks.py +55 -9
  6. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_allocation.py +42 -1
  7. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_core.py +37 -0
  8. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_run_multi.py +27 -0
  9. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/README.md +5 -0
  10. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/config/config_cyto.yaml +5 -3
  11. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/profile/slurm/config.yaml +9 -1
  12. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/scripts/prepare_tiles.py +9 -1
  13. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/scripts/run_multi.py +18 -0
  14. {patchworks-2.6.0 → patchworks-2.6.2}/.github/workflows/docs.yml +0 -0
  15. {patchworks-2.6.0 → patchworks-2.6.2}/.github/workflows/lint.yml +0 -0
  16. {patchworks-2.6.0 → patchworks-2.6.2}/.github/workflows/release.yml +0 -0
  17. {patchworks-2.6.0 → patchworks-2.6.2}/.gitignore +0 -0
  18. {patchworks-2.6.0 → patchworks-2.6.2}/.markdownlint-cli2.yaml +0 -0
  19. {patchworks-2.6.0 → patchworks-2.6.2}/LICENSE +0 -0
  20. {patchworks-2.6.0 → patchworks-2.6.2}/README.md +0 -0
  21. {patchworks-2.6.0 → patchworks-2.6.2}/cliff.toml +0 -0
  22. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/chunks.md +0 -0
  23. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/cluster.md +0 -0
  24. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/io.md +0 -0
  25. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/merge_tile_labels.md +0 -0
  26. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/plugins/cellpose.md +0 -0
  27. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/plugins/dog.md +0 -0
  28. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/plugins/napari.md +0 -0
  29. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/plugins/ome_zarr.md +0 -0
  30. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/postprocess.md +0 -0
  31. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/relabel.md +0 -0
  32. {patchworks-2.6.0 → patchworks-2.6.2}/docs/api/tile_process.md +0 -0
  33. {patchworks-2.6.0 → patchworks-2.6.2}/docs/assets/logo.png +0 -0
  34. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/cellpose_2d.md +0 -0
  35. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/cellpose_2d.py +0 -0
  36. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/cellpose_3d.md +0 -0
  37. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/cellpose_3d.py +0 -0
  38. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/custom.md +0 -0
  39. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/custom_method.py +0 -0
  40. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/dog.md +0 -0
  41. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/dog.py +0 -0
  42. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/standalone_merge.md +0 -0
  43. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/stardist.md +0 -0
  44. {patchworks-2.6.0 → patchworks-2.6.2}/docs/examples/stardist_2d.py +0 -0
  45. {patchworks-2.6.0 → patchworks-2.6.2}/docs/getting_started.md +0 -0
  46. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/custom_segmentation.md +0 -0
  47. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/label_relations.md +0 -0
  48. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/measurements.md +0 -0
  49. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/merging.md +0 -0
  50. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/ome_zarr_napari.md +0 -0
  51. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/performance.md +0 -0
  52. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/pitfalls.md +0 -0
  53. {patchworks-2.6.0 → patchworks-2.6.2}/docs/guide/skip_empty.md +0 -0
  54. {patchworks-2.6.0 → patchworks-2.6.2}/docs/index.md +0 -0
  55. {patchworks-2.6.0 → patchworks-2.6.2}/mkdocs.yml +0 -0
  56. {patchworks-2.6.0 → patchworks-2.6.2}/pyproject.toml +0 -0
  57. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/__init__.py +0 -0
  58. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_cluster.py +0 -0
  59. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_core.py +0 -0
  60. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_distributed.py +0 -0
  61. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_gpu.py +0 -0
  62. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_io.py +0 -0
  63. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_merge.py +0 -0
  64. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_notify.py +0 -0
  65. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_occupancy.py +0 -0
  66. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_postprocess.py +0 -0
  67. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_progress.py +0 -0
  68. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_relabel.py +0 -0
  69. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/_relations.py +0 -0
  70. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/plugins/__init__.py +0 -0
  71. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/plugins/cellpose.py +0 -0
  72. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/plugins/dog.py +0 -0
  73. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/plugins/napari.py +0 -0
  74. {patchworks-2.6.0 → patchworks-2.6.2}/src/patchworks/plugins/ome_zarr.py +0 -0
  75. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_distributed.py +0 -0
  76. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_dog.py +0 -0
  77. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_gpu.py +0 -0
  78. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_napari.py +0 -0
  79. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_notify.py +0 -0
  80. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_occupancy.py +0 -0
  81. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_ome_zarr.py +0 -0
  82. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_postprocess.py +0 -0
  83. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_progress.py +0 -0
  84. {patchworks-2.6.0 → patchworks-2.6.2}/tests/test_relations.py +0 -0
  85. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/Snakefile +0 -0
  86. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/config/common.yaml +0 -0
  87. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/config/config.yaml +0 -0
  88. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/config/config_cilia.yaml +0 -0
  89. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/config/config_nuclei.yaml +0 -0
  90. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/config/multi.yaml +0 -0
  91. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/pixi.toml +0 -0
  92. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/rules/common.smk +0 -0
  93. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/rules/convert.smk +0 -0
  94. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/rules/merge.smk +0 -0
  95. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/rules/segment.smk +0 -0
  96. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/scripts/_pw.py +0 -0
  97. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/scripts/build_occupancy.py +0 -0
  98. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/scripts/convert.py +0 -0
  99. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/scripts/fetch_model.py +0 -0
  100. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/scripts/merge.py +0 -0
  101. {patchworks-2.6.0 → patchworks-2.6.2}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 2.6.0
3
+ Version: 2.6.2
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×
@@ -321,14 +330,35 @@ occupancy map and the staged labels are byte-for-byte what a single-channel
321
330
  run produces, and `merge` and `label_relations` need no changes. Two things
322
331
  follow from that:
323
332
 
324
- - A tile holds twice the bytes, so a hand-set `tile_shape` sized to fill a GPU
325
- may need halving. `tile_shape: "auto"` sizes from the single-channel array
326
- and does not yet know about the pair.
333
+ - A tile holds twice the bytes. `tile_shape: "auto"` accounts for this it is
334
+ told the tile carries two channels and shrinks each spatial side by ~1/√2,
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.
327
338
  - The translation is version-specific. Cellpose 3 gets `channels: [1, 2]`
328
339
  (1-based into the channel axis, `0` = grayscale); Cellpose 4 (cpsam) dropped
329
340
  `channels` entirely and simply reads both. Either is overridable by setting
330
341
  `channels:` or `channel_axis:` in the `cellpose:` block.
331
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
+
332
362
  `nuclei_channel` applies to the SLURM/Snakemake path. The single-process
333
363
  `tile_process` API still takes one `channel`.
334
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
@@ -264,6 +264,7 @@ def auto_tile_shape(
264
264
  gpu_memory: int | None = None,
265
265
  available_memory: int | None = None,
266
266
  n_workers: int | None = None,
267
+ n_channels: int = 1,
267
268
  verbose: bool = False,
268
269
  ) -> tuple[int, ...]:
269
270
  """Balanced tile shape for general-purpose 3-D processing.
@@ -280,13 +281,21 @@ def auto_tile_shape(
280
281
  target_bytes:
281
282
  Memory ceiling per tile. Default 64 MiB.
282
283
  use_gpu:
283
- 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``.
284
286
  gpu_memory:
285
287
  Available GPU VRAM in bytes; auto-queried when None.
286
288
  available_memory:
287
- 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.
288
293
  n_workers:
289
294
  Number of parallel workers (divides the RAM budget).
295
+ n_channels:
296
+ Channels each tile carries (default 1). Above 1 the per-voxel cost
297
+ scales with it, so the tile shrinks accordingly -- e.g. the workflow's
298
+ ``nuclei_channel`` hands Cellpose a cyto+nuclei pair.
290
299
  verbose:
291
300
  Log the chosen shape and estimated tile size.
292
301
 
@@ -302,11 +311,22 @@ def auto_tile_shape(
302
311
  (128, 512, 512)
303
312
  """
304
313
  n_workers = n_workers or cpu_allocation()
305
- itemsize = np.dtype(dtype).itemsize
314
+ # A tile holds n_channels planes per voxel (e.g. Cellpose's
315
+ # cyto+nuclei pair), so the per-voxel cost -- and every budget
316
+ # derived from it below -- scales with them.
317
+ if n_channels < 1:
318
+ raise ValueError(f"n_channels must be >= 1; got {n_channels!r}")
319
+ itemsize = np.dtype(dtype).itemsize * n_channels
306
320
  n_spatial = min(3, len(shape))
307
321
 
308
322
  if use_gpu:
309
- 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)
310
330
  budget = min(target_bytes * 2, mem // 2)
311
331
  else:
312
332
  mem = available_memory or _get_available_memory()
@@ -359,6 +379,7 @@ def auto_tile_shape_cellpose(
359
379
  n_workers: int | None = None,
360
380
  model_memory_bytes: int = 2 * 1024**3,
361
381
  cellpose_memory_factor: int = 20,
382
+ n_channels: int = 1,
362
383
  verbose: bool = False,
363
384
  ) -> tuple[int, ...]:
364
385
  """Cellpose-optimised tile shape.
@@ -384,13 +405,27 @@ def auto_tile_shape_cellpose(
384
405
  do_3D:
385
406
  Whether Cellpose will run in 3-D mode.
386
407
  use_gpu:
387
- Size tiles for GPU VRAM.
408
+ Size tiles for GPU VRAM. Still capped by host RAM too -- see
409
+ ``available_memory``.
388
410
  gpu_memory, available_memory, n_workers:
389
- 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.
390
416
  model_memory_bytes:
391
417
  Memory consumed by the Cellpose model weights (default 2 GiB).
392
418
  cellpose_memory_factor:
393
- 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.
425
+ n_channels:
426
+ Channels each tile carries (default 1). Above 1 the per-voxel cost
427
+ scales with it, so the tile shrinks accordingly -- e.g. the workflow's
428
+ ``nuclei_channel`` hands Cellpose a cyto+nuclei pair.
394
429
  verbose:
395
430
  Log the chosen shape and memory estimates.
396
431
 
@@ -406,10 +441,21 @@ def auto_tile_shape_cellpose(
406
441
  (1, 2048, 2048)
407
442
  """
408
443
  n_workers = n_workers or cpu_allocation()
409
- itemsize = np.dtype(dtype).itemsize
444
+ # A tile holds n_channels planes per voxel (e.g. Cellpose's
445
+ # cyto+nuclei pair), so the per-voxel cost -- and every budget
446
+ # derived from it below -- scales with them.
447
+ if n_channels < 1:
448
+ raise ValueError(f"n_channels must be >= 1; got {n_channels!r}")
449
+ itemsize = np.dtype(dtype).itemsize * n_channels
410
450
 
411
451
  if use_gpu:
412
- 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)
413
459
  else:
414
460
  total_mem = (available_memory or _get_available_memory()) // n_workers
415
461
 
@@ -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
+ )
@@ -389,3 +389,40 @@ def test_channel_selection_respects_the_stores_axes(tmp_path):
389
389
  store4 = str(tmp_path / "czyx.zarr")
390
390
  to_ome_zarr(vol4, store4, axes="czyx", n_levels=1, progress=False)
391
391
  assert load_ome_zarr(store4, channel=1, level=0).shape == (8, 64, 64)
392
+
393
+
394
+ def test_auto_tile_shape_charges_for_extra_channels():
395
+ """A 2-channel tile must fit the same byte budget, not twice it.
396
+
397
+ `nuclei_channel` doubles what a tile holds while the tile geometry stays
398
+ single-channel, so a sizer blind to it hands the GPU a tile needing twice
399
+ the VRAM it budgeted for.
400
+ """
401
+ import pytest
402
+
403
+ from patchworks import auto_tile_shape, auto_tile_shape_cellpose
404
+
405
+ shape, dtype = (128, 2048, 2048), "uint16"
406
+
407
+ one = auto_tile_shape(shape, dtype)
408
+ two = auto_tile_shape(shape, dtype, n_channels=2)
409
+ # Same bytes overall: 2 channels of roughly half the area each.
410
+ assert np.prod(two) * 2 <= np.prod(one)
411
+ assert np.prod(two) * 2 >= np.prod(one) * 0.9
412
+
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
+ )
422
+ cp_one = auto_tile_shape_cellpose(shape, dtype, **kw)
423
+ cp_two = auto_tile_shape_cellpose(shape, dtype, n_channels=2, **kw)
424
+ assert np.prod(cp_two) * 2 <= np.prod(cp_one)
425
+ assert cp_two[0] == cp_one[0] # do_3D still pins z to the full extent
426
+
427
+ with pytest.raises(ValueError, match="n_channels"):
428
+ auto_tile_shape(shape, dtype, n_channels=0)
@@ -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
@@ -14,9 +14,11 @@ channel: 0 # cytoplasm/membrane channel
14
14
  #
15
15
  # Only the segment step reads it: the tile geometry, the occupancy map and the
16
16
  # staged labels all stay exactly as they are for a single channel -- the pair
17
- # is stacked on a leading axis that is carried into each tile, not tiled. It
18
- # does double the bytes a tile holds, so a hand-set `tile_shape` sized to the
19
- # GPU may need halving.
17
+ # is stacked on a leading axis that is carried into each tile, not tiled.
18
+ #
19
+ # It does double the bytes a tile holds. `tile_shape: "auto"` is told about
20
+ # that and shrinks each spatial side by ~1/sqrt(2) to fit the same VRAM
21
+ # budget; the hand-set tile_shape in common.yaml is not, so halve it yourself.
20
22
  #
21
23
  # Cellpose 3 turns this into channels: [1, 2]; Cellpose 4 (cpsam) dropped
22
24
  # `channels` and just reads both. Override either by setting `channels:` or
@@ -67,6 +67,13 @@ 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
+ #
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.
70
77
  mem_mb: "attempt * 32000"
71
78
  runtime: 120
72
79
  segment:
@@ -80,7 +87,8 @@ set-resources:
80
87
  # A job now processes `tiles_per_job` tiles sequentially, so both memory
81
88
  # and runtime scale with that setting — raise it there and re-check here.
82
89
  # The old "a tile used ~1G" note predates tile_shape: "auto", which sizes
83
- # tiles against the real GPU and makes them far bigger.
90
+ # tiles against the real GPU and makes them far bigger. If you raise
91
+ # this, raise prepare's mem_mb above to match (see its comment).
84
92
  mem_mb: "attempt * 32000"
85
93
  cpus_per_task: 4
86
94
  runtime: 360 # 6 hours — must match the QOS, NOT 120 (=2h → killed early)
@@ -44,6 +44,10 @@ if ts == "auto":
44
44
  # "GPU memory query failed" fallback). None => the built-in 8 GiB default.
45
45
  gpu_gb = cfg.get("gpu_memory_gb")
46
46
  gpu_bytes = int(gpu_gb * 1024**3) if gpu_gb else None
47
+ # `image` is single-channel here (the geometry is spatial), but segment
48
+ # reads nuclei_channel alongside it, so a tile costs twice the bytes.
49
+ # Without this the sizer would hand the GPU a tile it cannot hold.
50
+ n_channels = 2 if cfg.get("nuclei_channel") is not None else 1
47
51
  if method == "cellpose":
48
52
  cp = cfg["cellpose"]
49
53
  sizer = partial(
@@ -52,6 +56,7 @@ if ts == "auto":
52
56
  use_gpu=cp.get("gpu", True),
53
57
  diameter=cp.get("diameter"),
54
58
  gpu_memory=gpu_bytes,
59
+ n_channels=n_channels,
55
60
  )
56
61
  else:
57
62
  # cfg["cellpose"] used to be read unconditionally here, so a DoG or
@@ -59,7 +64,10 @@ if ts == "auto":
59
64
  # 'cellpose'. The Cellpose estimator's memory model wouldn't apply to
60
65
  # them anyway.
61
66
  sizer = partial(
62
- auto_tile_shape, use_gpu=gpu_bytes is not None, gpu_memory=gpu_bytes
67
+ auto_tile_shape,
68
+ use_gpu=gpu_bytes is not None,
69
+ gpu_memory=gpu_bytes,
70
+ n_channels=n_channels,
63
71
  )
64
72
  tile_shape = tuple(sizer(image.shape, image.dtype))
65
73
  else:
@@ -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