patchworks 2.6.10__tar.gz → 2.6.12__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 (108) hide show
  1. {patchworks-2.6.10 → patchworks-2.6.12}/PKG-INFO +1 -1
  2. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/dog.md +15 -0
  3. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/snakemake.md +77 -14
  4. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_chunks.py +14 -1
  5. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/cellpose.py +27 -1
  6. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/dog.py +20 -15
  7. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_allocation.py +29 -0
  8. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_cellpose.py +24 -0
  9. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_dog.py +18 -6
  10. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_pw.py +41 -0
  11. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_run_multi.py +165 -0
  12. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/_pw.py +38 -6
  13. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/prepare_tiles.py +12 -0
  14. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/relate.py +39 -0
  15. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/run_multi.py +125 -30
  16. {patchworks-2.6.10 → patchworks-2.6.12}/.github/workflows/docs.yml +0 -0
  17. {patchworks-2.6.10 → patchworks-2.6.12}/.github/workflows/lint.yml +0 -0
  18. {patchworks-2.6.10 → patchworks-2.6.12}/.github/workflows/release.yml +0 -0
  19. {patchworks-2.6.10 → patchworks-2.6.12}/.gitignore +0 -0
  20. {patchworks-2.6.10 → patchworks-2.6.12}/.markdownlint-cli2.yaml +0 -0
  21. {patchworks-2.6.10 → patchworks-2.6.12}/LICENSE +0 -0
  22. {patchworks-2.6.10 → patchworks-2.6.12}/README.md +0 -0
  23. {patchworks-2.6.10 → patchworks-2.6.12}/cliff.toml +0 -0
  24. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/chunks.md +0 -0
  25. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/cluster.md +0 -0
  26. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/io.md +0 -0
  27. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/merge_tile_labels.md +0 -0
  28. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/plugins/cellpose.md +0 -0
  29. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/plugins/dog.md +0 -0
  30. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/plugins/napari.md +0 -0
  31. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/plugins/ome_zarr.md +0 -0
  32. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/postprocess.md +0 -0
  33. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/relabel.md +0 -0
  34. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/tile_process.md +0 -0
  35. {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/volume_filter.md +0 -0
  36. {patchworks-2.6.10 → patchworks-2.6.12}/docs/assets/logo.png +0 -0
  37. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/cellpose_2d.md +0 -0
  38. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/cellpose_2d.py +0 -0
  39. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/cellpose_3d.md +0 -0
  40. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/cellpose_3d.py +0 -0
  41. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/custom.md +0 -0
  42. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/custom_method.py +0 -0
  43. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/dog.py +0 -0
  44. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/standalone_merge.md +0 -0
  45. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/stardist.md +0 -0
  46. {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/stardist_2d.py +0 -0
  47. {patchworks-2.6.10 → patchworks-2.6.12}/docs/getting_started.md +0 -0
  48. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/custom_segmentation.md +0 -0
  49. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/gpu_distributed.md +0 -0
  50. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/label_relations.md +0 -0
  51. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/measurements.md +0 -0
  52. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/merging.md +0 -0
  53. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/ome_zarr_napari.md +0 -0
  54. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/performance.md +0 -0
  55. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/pitfalls.md +0 -0
  56. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/skip_empty.md +0 -0
  57. {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/tiling.md +0 -0
  58. {patchworks-2.6.10 → patchworks-2.6.12}/docs/index.md +0 -0
  59. {patchworks-2.6.10 → patchworks-2.6.12}/mkdocs.yml +0 -0
  60. {patchworks-2.6.10 → patchworks-2.6.12}/pyproject.toml +0 -0
  61. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/__init__.py +0 -0
  62. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_cluster.py +0 -0
  63. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_core.py +0 -0
  64. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_distributed.py +0 -0
  65. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_gpu.py +0 -0
  66. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_io.py +0 -0
  67. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_merge.py +0 -0
  68. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_notify.py +0 -0
  69. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_occupancy.py +0 -0
  70. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_postprocess.py +0 -0
  71. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_progress.py +0 -0
  72. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_relabel.py +0 -0
  73. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_relations.py +0 -0
  74. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_volume_filter.py +0 -0
  75. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/__init__.py +0 -0
  76. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/napari.py +0 -0
  77. {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/ome_zarr.py +0 -0
  78. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_core.py +0 -0
  79. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_distributed.py +0 -0
  80. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_gpu.py +0 -0
  81. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_napari.py +0 -0
  82. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_notify.py +0 -0
  83. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_occupancy.py +0 -0
  84. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_ome_zarr.py +0 -0
  85. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_postprocess.py +0 -0
  86. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_progress.py +0 -0
  87. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_relations.py +0 -0
  88. {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_volume_filter.py +0 -0
  89. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/README.md +0 -0
  90. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/Snakefile +0 -0
  91. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/common.yaml +0 -0
  92. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/config.yaml +0 -0
  93. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/config_cilia.yaml +0 -0
  94. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/config_cyto.yaml +0 -0
  95. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/config_nuclei.yaml +0 -0
  96. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/multi.yaml +0 -0
  97. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/pixi.toml +0 -0
  98. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/profile/slurm/config.yaml +0 -0
  99. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/rules/common.smk +0 -0
  100. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/rules/convert.smk +0 -0
  101. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/rules/merge.smk +0 -0
  102. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/rules/segment.smk +0 -0
  103. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/build_occupancy.py +0 -0
  104. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/convert.py +0 -0
  105. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/fetch_model.py +0 -0
  106. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/merge.py +0 -0
  107. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/segment_tile.py +0 -0
  108. {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: patchworks
3
- Version: 2.6.10
3
+ Version: 2.6.12
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
@@ -102,6 +102,21 @@ decon_kwargs=dict(psf=psf, dxpsf=0.05, dzpsf=0.1, wavelength=525, ...)
102
102
  so edge tiles keep enough context (a plain intensity/threshold halo is
103
103
  too thin).
104
104
 
105
+ !!! note "cudaDecon can return a smaller volume than it was given"
106
+ It rounds each axis down to an FFT-efficient length — e.g. a
107
+ `(32, 1084, 1084)` tile comes back `(32, 1080, 1080)`, because
108
+ `1080 = 2³·3³·5` while `1084 = 4·271` — and trims the excess off the
109
+ **high end**, leaving voxel `(0, 0, 0)` where it was. patchworks restores
110
+ the input shape before the DoG step (one label per input voxel is
111
+ required) anchored at that origin, and logs a WARNING with both shapes.
112
+
113
+ Anchoring matters: restoring it *centred* instead moves every voxel by
114
+ `excess // 2` — a 2 px y/x shift for the tile above, identical on every
115
+ tile. That is invisible on a cell tens of voxels wide and obvious on a
116
+ cilium a few voxels wide, which is how it was eventually caught. If the
117
+ logged difference is more than a few voxels, the PSF or the voxel sizes
118
+ are wrong.
119
+
105
120
  ## Growing the labels afterwards
106
121
 
107
122
  DoG spots/threads are often thin — grow each label by a few pixels with
@@ -112,15 +112,60 @@ sequential_labels: true # renumber labels to a contiguous 1..N
112
112
  [Filtering by size after merge](merging.md#filtering-by-size-after-merge)
113
113
  for the equivalent direct-API call.
114
114
 
115
+ !!! warning "`model:` names are version-specific — check which Cellpose you have"
116
+ Cellpose 3 and 4 have **disjoint** model names: v3 has `cyto3`,
117
+ `nuclei`, …; v4 replaced them all with the `cpsam` family (`cpsam`,
118
+ `cpsam_v2`, `cpdino`, …). Neither version *raises* on a name it doesn't
119
+ know — v4 logs a warning and quietly loads its default (`cpsam_v2`)
120
+ instead. A `cyto3` left in a config against a v4 install therefore
121
+ segments every tile with a model you did not choose, and the only
122
+ evidence is one line in the job log.
123
+
124
+ patchworks now rejects an unavailable name in `prepare`, on the cheap
125
+ CPU job, rather than letting it through. Check what you have with
126
+ `python -c "import cellpose; print(cellpose.version)"`, then either pick
127
+ a name that install offers, point `model:` at a custom-trained model's
128
+ path, or install the version you want — the workflow ships `cellpose3`
129
+ and `cellpose4` pixi environments (`pixi install -e cellpose3`) for
130
+ exactly this.
131
+
115
132
  !!! tip "3-D anisotropy is derived automatically"
116
133
  Cellpose's `do_3D` assumes isotropic voxels unless told otherwise —
117
134
  without an `anisotropy`, a real (anisotropic) dataset gets objects
118
- fragmented or distorted across z. `segment` now derives it from
135
+ fragmented or distorted across z. `segment` derives it from
119
136
  `image.zarr`'s own calibration (`z` voxel size ÷ lateral voxel size)
120
137
  whenever `do_3D: true` and `cellpose.anisotropy` isn't set explicitly, so
121
138
  there's usually nothing to configure. Set `anisotropy:` yourself in the
122
139
  `cellpose:` block to override it.
123
140
 
141
+ Note it is not just a hint to the model: Cellpose *resizes* the tile to
142
+ `z × anisotropy` planes before the net runs. `tile_shape: "auto"`
143
+ budgets for that resized tile, so a 2.2× anisotropy buys a
144
+ correspondingly smaller tile rather than an out-of-memory job.
145
+
146
+ !!! warning "The physically-correct anisotropy is not always the best one"
147
+ Upsampling z by the true ratio can produce **ring artifacts** and costs
148
+ runtime proportional to the ratio. A contributor on
149
+ [cellpose#1408](https://github.com/MouseLand/cellpose/pull/1408) reports
150
+ that `anisotropy: 1` avoids the rings entirely and is much faster, at
151
+ the cost of boundaries being off by a few pixels in the top/bottom
152
+ z-planes, where a cell's cross-section changes fastest — and recommends
153
+ pairing it with light z-only flow smoothing:
154
+
155
+ ```yaml
156
+ cellpose:
157
+ do_3D: true
158
+ anisotropy: 1 # overrides the derived value
159
+ flow3D_smooth: [1, 0, 0] # z, y, x -- smooth z only
160
+ ```
161
+
162
+ This is a genuine trade-off, not a strictly better setting, and it is
163
+ worth testing both on your own data rather than taking either on faith.
164
+ Reach for it especially if 3-D results look ringed or fragmented along
165
+ z: that is the symptom this addresses. `flow3D_smooth` accepts a scalar
166
+ on older Cellpose and a `[z, y, x]` list from the version that merged
167
+ that PR onward.
168
+
124
169
  !!! tip "Tile size vs runtime"
125
170
  `tile_shape: "auto"` sizes each tile to your GPU's VRAM. Smaller tiles =
126
171
  more (faster) jobs; very large 3-D tiles are slow. Keep `do_3D: false` (2-D
@@ -444,10 +489,14 @@ results/image.zarr/labels/cyto_labels/
444
489
 
445
490
  !!! tip "The relate step's log"
446
491
  Unlike `prepare`/`segment`/`merge`, the relate step isn't a Snakemake
447
- rule, so it doesn't get a `log:` directive for free. It writes its own
448
- log to `<work_dir>/logs/relate.log` (override with `relate.py --log`),
449
- the same tee-to-file-and-stdout behaviour as the other steps — check
450
- there instead of scrolling back through `srun`'s live output.
492
+ rule, so it doesn't get a `log:` directive for free. Standalone (or
493
+ under plain `multi`), it writes to `<work_dir>/logs/relate.log`
494
+ (override with `relate.py --log`), the same tee-to-file-and-stdout
495
+ behaviour as the other steps. Under `multi-slurm`, where each pair is
496
+ its own concurrent job, `run_multi.py` points each one at its own file
497
+ instead — `<work_dir>/logs/relate/<a>_to_<b>.log` — so concurrent pairs
498
+ don't interleave into one log; check there instead of scrolling back
499
+ through `srun`'s live output.
451
500
 
452
501
  See [Relating labels across segmentations](label_relations.md) for what
453
502
  `label_relations()` returns and how to save it yourself — the cluster
@@ -498,15 +547,29 @@ abort the others; you get a per-config status and a non-zero exit.
498
547
 
499
548
  !!! tip "The relate step runs on the cluster too, under `multi-slurm`"
500
549
  `label_relations()` streams every chunk of two full-resolution label
501
- volumes — real CPU/IO work, not orchestration. Under `multi-slurm` it is
502
- submitted as its own `srun` job (`scripts/relate.py`) instead of running
503
- in the driver process on the login node, the same fix already applied to
504
- the occupancy map. Tune its allocation with `--relate-partition`,
505
- `--relate-mem`, `--relate-cpus` and `--relate-time` (defaults: `scicore`,
506
- `32G`, `8`, `180` minutes) — these are wide-margin guesses, not measured
507
- numbers, so raise them for a very large or very object-dense pair. Under
508
- plain `multi` (no `--profile`), it still runs locally, in-process, as
509
- before.
550
+ volumes — real CPU/IO work, not orchestration. Under `multi-slurm`,
551
+ **each pair in `relations:` is submitted as its own `srun` job**
552
+ (`scripts/relate.py`) instead of running in the driver process on the
553
+ login node, the same fix already applied to the occupancy map. Tune the
554
+ allocation with `--relate-partition`, `--relate-mem`, `--relate-cpus`
555
+ and `--relate-time` (defaults: `scicore`, `32G`, `8`, `180` minutes,
556
+ **per relation**) — these are wide-margin guesses, not measured numbers,
557
+ so raise them for a very large or very object-dense pair; a pair needing
558
+ a chunk-layout rechunk first (see below) is the usual reason one runs
559
+ long. Set `--relate-qos` if your account's default QOS for the partition
560
+ caps the wall time below `--relate-time` — `srun` fails immediately with
561
+ `QOSMaxWallDurationPerJobLimit` when that happens; `sacctmgr -p show
562
+ assoc user=$USER` and `sacctmgr -p show qos` list what's available and
563
+ each one's `MaxWall`. Under plain `multi` (no `--profile`), relations
564
+ still run locally, in-process, one after another, as before.
565
+
566
+ Because every pair gets its own job, one running long no longer starves
567
+ the others out of a shared time budget, and a pair that gets killed no
568
+ longer takes an already-finished sibling's workbook down with it.
569
+ `relate.py` also skips a pair whose `.xlsx` is already newer than both
570
+ labels' merge marker, so **re-running the exact same `multi-slurm`
571
+ command only recomputes what's still missing or stale** — delete a
572
+ specific `.xlsx` yourself to force just that one to recompute.
510
573
 
511
574
  !!! tip "After a killed run"
512
575
  Snakemake only releases its lock on a clean exit, so a run that was killed
@@ -380,6 +380,7 @@ def auto_tile_shape_cellpose(
380
380
  model_memory_bytes: int = 2 * 1024**3,
381
381
  cellpose_memory_factor: int = 20,
382
382
  n_channels: int = 1,
383
+ anisotropy: float | None = None,
383
384
  verbose: bool = False,
384
385
  ) -> tuple[int, ...]:
385
386
  """Cellpose-optimised tile shape.
@@ -426,6 +427,13 @@ def auto_tile_shape_cellpose(
426
427
  Channels each tile carries (default 1). Above 1 the per-voxel cost
427
428
  scales with it, so the tile shrinks accordingly -- e.g. the workflow's
428
429
  ``nuclei_channel`` hands Cellpose a cyto+nuclei pair.
430
+ anisotropy:
431
+ The ``anisotropy`` Cellpose will be given (``do_3D`` only). It is not
432
+ merely a hint to the model: Cellpose resizes the tile to
433
+ ``z * anisotropy`` planes before the net runs, so a 2.2x anisotropy
434
+ costs 2.2x the z it was handed. Budgeting against the unscaled ``z``
435
+ under-counts by exactly that factor. ``None`` (the default) assumes
436
+ isotropic, i.e. no resize.
429
437
  verbose:
430
438
  Log the chosen shape and memory estimates.
431
439
 
@@ -477,7 +485,12 @@ def auto_tile_shape_cellpose(
477
485
  chunk_spatial = [1, min(y, tile_side), min(x, tile_side)]
478
486
  else:
479
487
  z, y, x = shape[-3], shape[-2], shape[-1]
480
- max_pixels_per_slice = max(1, (max_raw_bytes // 3) // (z * itemsize))
488
+ # Cellpose resizes z by `anisotropy` before the net runs, so the tile
489
+ # it actually holds is that much deeper than the one handed to it.
490
+ effective_z = z * max(1.0, anisotropy or 1.0)
491
+ max_pixels_per_slice = max(
492
+ 1, int((max_raw_bytes // 3) // (effective_z * itemsize))
493
+ )
481
494
  tile_side = max(min_tile, int(max_pixels_per_slice**0.5))
482
495
  chunk_spatial = [z, min(y, tile_side), min(x, tile_side)]
483
496
 
@@ -253,6 +253,26 @@ def _make_config(
253
253
  }
254
254
 
255
255
 
256
+ def available_models() -> list[str]:
257
+ """Pretrained model names the installed Cellpose actually accepts.
258
+
259
+ The name set changed completely between major versions -- v3 ships
260
+ ``cyto3``/``nuclei``/… , v4 replaced them with ``cpsam``-family names --
261
+ and neither version raises on an unknown one. v4 falls back to its
262
+ default model with only a log line, so a v3 name in a v4 environment
263
+ segments everything with the wrong model and nothing fails. Checking the
264
+ name against this list turns that into a config error instead.
265
+
266
+ Returns
267
+ -------
268
+ list of str
269
+ Model names, or an empty list when Cellpose isn't installed or
270
+ doesn't publish them (in which case no name can be rejected).
271
+ """
272
+ names = getattr(_cellpose_models, "MODEL_NAMES", None)
273
+ return list(names) if names else []
274
+
275
+
256
276
  def _get_model(cellpose_dict: dict[str, Any]) -> Any:
257
277
  """Return a worker-local cached Cellpose model.
258
278
 
@@ -272,8 +292,14 @@ def _get_model(cellpose_dict: dict[str, Any]) -> Any:
272
292
  gpu = cellpose_dict.get("gpu", False)
273
293
  model_type = cellpose_dict["model"]
274
294
  if _CELLPOSE_V4:
295
+ # v4 renamed this: `model_type=` is accepted but explicitly
296
+ # ignored ("not used in v4.0.1+"), leaving pretrained_model at
297
+ # its "cpsam_v2" default -- so passing the configured name there
298
+ # silently segmented *every* config with the same default model,
299
+ # whatever `model:` said, with only a logger warning to show for
300
+ # it. `available_models()` rejects an unusable name up front.
275
301
  _model_cache[key] = _cellpose_models.CellposeModel(
276
- model_type=model_type, gpu=gpu
302
+ pretrained_model=model_type, gpu=gpu
277
303
  )
278
304
  else:
279
305
  _model_cache[key] = _cellpose_models.Cellpose(
@@ -239,18 +239,23 @@ def _run(block: np.ndarray, dog_dict: dict[str, Any]) -> np.ndarray:
239
239
 
240
240
 
241
241
  def _restore_shape(arr: np.ndarray, shape: tuple[int, ...]) -> np.ndarray:
242
- """Centre *arr* back into an array of *shape*, cropping or edge-padding.
242
+ """Restore *arr* to *shape*, anchored at the origin, cropping or padding.
243
243
 
244
244
  Deconvolution must not change the field of view: patchworks writes the
245
245
  result into a destination slice derived from the tile's geometry, so one
246
246
  label per input voxel is required.
247
247
 
248
- Centring is the right correction for a symmetric crop, which is what
249
- apodisation produces. The discrepancies observed are small (a voxel in z,
250
- a few in x/y) and land inside the halo, which is discarded anyway -- so
251
- the labels that survive the trim are unaffected. It is logged at WARNING
252
- with the exact shapes so a larger, non-symmetric crop cannot pass
253
- silently.
248
+ The alignment is **origin-anchored**, not centred: cudaDecon rounds each
249
+ axis down to an FFT-efficient length (e.g. 1084 -> 1080, since
250
+ 1080 = 2**3 * 3**3 * 5 while 1084 = 4 * 271) and trims the excess off the
251
+ high end, leaving voxel (0, 0, 0) where it was. Re-centring content that
252
+ was never centred shifts every voxel by ``excess // 2`` -- measured at 2
253
+ px in y and x on a real (32, 1084, 1084) tile, in the same direction on
254
+ every tile. That is invisible on a cell tens of voxels across and glaring
255
+ on a cilium a few voxels across, which is exactly how it was found.
256
+
257
+ A mismatch is still logged at WARNING with the exact shapes, since a
258
+ large one means the PSF or voxel sizes are wrong.
254
259
 
255
260
  Parameters
256
261
  ----------
@@ -271,22 +276,22 @@ def _restore_shape(arr: np.ndarray, shape: tuple[int, ...]) -> np.ndarray:
271
276
  (14, 1024)
272
277
  """
273
278
  logger.warning(
274
- "deconvolution returned %s for a %s input; re-centring to the input "
275
- "shape. patchworks needs one label per input voxel. A large or "
276
- "asymmetric difference here would shift labels -- check the PSF and "
277
- "voxel sizes if this is more than a few voxels.",
279
+ "deconvolution returned %s for a %s input; restoring the input shape "
280
+ "from the origin. patchworks needs one label per input voxel. A large "
281
+ "difference here means the PSF or voxel sizes are wrong -- check them "
282
+ "if this is more than a few voxels.",
278
283
  arr.shape,
279
284
  shape,
280
285
  )
281
286
  # Crop first, so an axis that grew is handled before padding the rest.
287
+ # Both keep voxel 0 where it is: cudaDecon trims off the high end (see the
288
+ # docstring), so the low corner is the one landmark known to be unmoved.
282
289
  crop = tuple(
283
- slice((a - s) // 2, (a - s) // 2 + s) if a > s else slice(None)
284
- for a, s in zip(arr.shape, shape)
290
+ slice(0, s) if a > s else slice(None) for a, s in zip(arr.shape, shape)
285
291
  )
286
292
  arr = arr[crop]
287
293
  pad = tuple(
288
- ((s - a) // 2, s - a - (s - a) // 2) if a < s else (0, 0)
289
- for a, s in zip(arr.shape, shape)
294
+ (0, s - a) if a < s else (0, 0) for a, s in zip(arr.shape, shape)
290
295
  )
291
296
  if any(lo or hi for lo, hi in pad):
292
297
  arr = np.pad(arr, pad, mode="edge")
@@ -117,3 +117,32 @@ def test_gpu_tile_sizing_is_bounded_by_the_host_allocation(monkeypatch):
117
117
  "the 1 GiB host grant must shrink the tile below what the same "
118
118
  "24 GiB GPU would otherwise allow"
119
119
  )
120
+
121
+
122
+ def test_auto_tile_shape_cellpose_budgets_for_the_anisotropy_resize():
123
+ """Cellpose resizes a do_3D tile to z * anisotropy planes before the net
124
+
125
+ runs, so budgeting against the unscaled z hands the GPU a tile that is
126
+ `anisotropy` times bigger than the estimate. Auto-deriving anisotropy
127
+ turned that from dormant into live, so the sizer has to know about it.
128
+ """
129
+ from patchworks import auto_tile_shape_cellpose
130
+
131
+ kwargs = dict(
132
+ shape=(64, 4096, 4096),
133
+ dtype="uint16",
134
+ do_3D=True,
135
+ use_gpu=True,
136
+ gpu_memory=8 * 1024**3,
137
+ available_memory=64 * 1024**3,
138
+ )
139
+ isotropic = auto_tile_shape_cellpose(**kwargs)
140
+ anisotropic = auto_tile_shape_cellpose(**kwargs, anisotropy=2.215)
141
+
142
+ # z is pinned to the full extent either way; the cost is paid in y/x.
143
+ assert anisotropic[0] == isotropic[0]
144
+ assert anisotropic[1] < isotropic[1]
145
+ assert anisotropic[2] < isotropic[2]
146
+ # An anisotropy at or below 1 cannot *grow* the budget.
147
+ assert auto_tile_shape_cellpose(**kwargs, anisotropy=1.0) == isotropic
148
+ assert auto_tile_shape_cellpose(**kwargs, anisotropy=0.5) == isotropic
@@ -47,3 +47,27 @@ def test_cellpose_fn_declares_a_voxel_size_parameter():
47
47
  # model.eval() argument, not a patchworks-specific one) -- only the raw
48
48
  # calibration is a named parameter.
49
49
  assert "anisotropy" not in params
50
+
51
+
52
+ def test_available_models_is_a_list():
53
+ """Empty when Cellpose isn't installed -- then no name can be rejected."""
54
+ from patchworks.plugins.cellpose import available_models
55
+
56
+ assert isinstance(available_models(), list)
57
+
58
+
59
+ def test_v4_gets_the_model_name_as_pretrained_model():
60
+ """v4 accepts `model_type=` and then ignores it ("not used in v4.0.1+"),
61
+
62
+ leaving pretrained_model at its default -- so passing the configured name
63
+ there segmented every config with the same default model whatever
64
+ `model:` said. The name has to reach `pretrained_model=` on v4.
65
+ """
66
+ import inspect
67
+
68
+ from patchworks.plugins import cellpose as cp
69
+
70
+ src = inspect.getsource(cp._get_model)
71
+ v4_branch = src.split("if _CELLPOSE_V4:")[1].split("else:")[0]
72
+ assert "pretrained_model=model_type" in v4_branch
73
+ assert "model_type=model_type" not in v4_branch
@@ -109,20 +109,32 @@ def test_explicit_decon_kwargs_win_over_the_calibration(monkeypatch):
109
109
  assert captured["dzpsf"] == 0.2
110
110
 
111
111
 
112
- def test_restore_shape_recentres_a_cropped_decon():
112
+ def test_restore_shape_anchors_a_cropped_decon_at_the_origin():
113
113
  """cudaDecon can hand back a smaller volume than it was given.
114
114
 
115
- Observed on a real edge tile: (14, 1024, 1024) in, (13, 1020, 1020) out.
116
- patchworks needs one label per input voxel, so the field of view has to be
117
- restored before the DoG step.
115
+ Observed on a real tile: (32, 1084, 1084) in, (32, 1080, 1080) out --
116
+ each axis rounded down to an FFT-efficient length, with the excess taken
117
+ off the high end. Restoring it *centred* (what this used to do) moved
118
+ every voxel by excess // 2, measured as a 2 px y/x shift on real data:
119
+ invisible on a cell, glaring on a cilium a few voxels across.
118
120
  """
119
121
  from patchworks.plugins.dog import _restore_shape
120
122
 
121
123
  arr = np.arange(13 * 1020 * 1020, dtype="float32").reshape(13, 1020, 1020)
122
124
  out = _restore_shape(arr, (14, 1024, 1024))
123
125
  assert out.shape == (14, 1024, 1024)
124
- # The original content is preserved, centred, not resampled.
125
- assert np.array_equal(out[0:13, 2:1022, 2:1022], arr)
126
+ # Content keeps its original indices -- voxel 0 stays voxel 0.
127
+ assert np.array_equal(out[0:13, 0:1020, 0:1020], arr)
128
+
129
+
130
+ def test_restore_shape_crops_from_the_high_end():
131
+ """The mirror case: an axis that came back too long keeps its low corner."""
132
+ from patchworks.plugins.dog import _restore_shape
133
+
134
+ arr = np.arange(6 * 12, dtype="float32").reshape(6, 12)
135
+ out = _restore_shape(arr, (4, 8))
136
+ assert out.shape == (4, 8)
137
+ assert np.array_equal(out, arr[0:4, 0:8])
126
138
 
127
139
 
128
140
  def test_restore_shape_handles_growth_and_exact_fit():
@@ -121,3 +121,44 @@ def test_validate_config_rejects_max_volume_at_or_below_min_volume():
121
121
  validate_config(
122
122
  {"method": "threshold", "min_volume": 500.0, "max_volume": 5.0}
123
123
  )
124
+
125
+
126
+ def test_validate_config_rejects_a_model_the_install_does_not_have(
127
+ monkeypatch,
128
+ ):
129
+ """Cellpose does not raise on an unknown model name -- it logs and loads
130
+
131
+ its default. A v3 name against a v4 install therefore segments every
132
+ tile with a model nobody chose, silently. That has to fail in prepare.
133
+ """
134
+ import pytest
135
+ from _pw import validate_config
136
+
137
+ import patchworks.plugins.cellpose as cp
138
+
139
+ monkeypatch.setattr(cp, "available_models", lambda: ["cpsam", "cpsam_v2"])
140
+ with pytest.raises(ValueError, match="cyto3"):
141
+ validate_config({"method": "cellpose", "cellpose": {"model": "cyto3"}})
142
+
143
+
144
+ def test_validate_config_accepts_a_model_the_install_has(monkeypatch):
145
+ from _pw import validate_config
146
+
147
+ import patchworks.plugins.cellpose as cp
148
+
149
+ monkeypatch.setattr(cp, "available_models", lambda: ["cpsam", "cpsam_v2"])
150
+ validate_config({"method": "cellpose", "cellpose": {"model": "cpsam"}})
151
+
152
+
153
+ def test_validate_config_leaves_a_custom_model_path_alone(
154
+ monkeypatch, tmp_path
155
+ ):
156
+ """A path is a custom-trained model; no name list can vouch for it."""
157
+ from _pw import validate_config
158
+
159
+ import patchworks.plugins.cellpose as cp
160
+
161
+ monkeypatch.setattr(cp, "available_models", lambda: ["cpsam"])
162
+ custom = tmp_path / "my_model.pth"
163
+ custom.write_text("")
164
+ validate_config({"method": "cellpose", "cellpose": {"model": str(custom)}})
@@ -1,7 +1,10 @@
1
1
  """Tests for the multi-config driver's SLURM-facing behaviour."""
2
2
 
3
+ import json
4
+ import os
3
5
  import re
4
6
  import sys
7
+ import time
5
8
  from pathlib import Path
6
9
 
7
10
  sys.path.insert(
@@ -15,6 +18,7 @@ import yaml # noqa: E402
15
18
 
16
19
  from run_multi import ( # noqa: E402
17
20
  _CONVERT_KEYS,
21
+ _relate_cmd,
18
22
  _snakemake_cmd,
19
23
  _validate_configs,
20
24
  slurm_jobname_prefix,
@@ -176,6 +180,85 @@ def test_relate_is_submitted_via_slurm_under_profile():
176
180
  assert "label_relations(" not in src
177
181
 
178
182
 
183
+ def _relate_kwargs(**overrides):
184
+ kwargs = dict(
185
+ work_dir="/w",
186
+ image_store="/w/image.zarr",
187
+ workflow_dir=Path("/workflow"),
188
+ relate_partition="scicore",
189
+ relate_mem="32G",
190
+ relate_cpus=8,
191
+ relate_time=180,
192
+ relate_qos=None,
193
+ )
194
+ kwargs.update(overrides)
195
+ return kwargs
196
+
197
+
198
+ def test_relate_cmd_is_one_job_per_relation_pair():
199
+ """A killed shared job used to lose every relation still queued behind
200
+
201
+ the one that was running -- one srun per pair means a slow or failing
202
+ pair can no longer starve, or take down, its siblings' time budget.
203
+ """
204
+ relations = [
205
+ {"a": "nuclei_labels", "b": "cyto_labels", "output": "n2c.xlsx"},
206
+ {"a": "cilia_labels", "b": "cyto_labels", "output": "c2c.xlsx"},
207
+ ]
208
+ cmds = [_relate_cmd(rel, **_relate_kwargs()) for rel in relations]
209
+
210
+ assert len(cmds) == 2
211
+ for cmd, rel in zip(cmds, relations):
212
+ assert cmd[0] == "srun"
213
+ assert "--relations" in cmd
214
+ # Each job's payload is *only* its own pair, not the whole list.
215
+ payload = json.loads(cmd[cmd.index("--relations") + 1])
216
+ assert payload == [rel]
217
+
218
+
219
+ def test_relate_cmd_job_name_identifies_the_pair():
220
+ cmd = _relate_cmd(
221
+ {"a": "cilia_labels", "b": "cyto_labels"}, **_relate_kwargs()
222
+ )
223
+ name = cmd[cmd.index("--job-name") + 1]
224
+ assert _EXECUTOR_RULE.match(name)
225
+ assert "cilia_labels" in name
226
+ assert "cyto_labels" in name
227
+
228
+
229
+ def test_relate_cmd_gives_each_pair_its_own_log():
230
+ """Concurrent per-pair jobs sharing one relate.log would interleave --
231
+
232
+ each pair's --log must be a distinct file, or the whole point of
233
+ splitting the log the way segment/<batch>.log already does is lost.
234
+ """
235
+ cmds = [
236
+ _relate_cmd(rel, **_relate_kwargs())
237
+ for rel in (
238
+ {"a": "nuclei_labels", "b": "cyto_labels"},
239
+ {"a": "cilia_labels", "b": "cyto_labels"},
240
+ )
241
+ ]
242
+ logs = [cmd[cmd.index("--log") + 1] for cmd in cmds]
243
+ assert len(set(logs)) == 2
244
+ assert all(log.startswith("/w/logs/relate/") for log in logs)
245
+
246
+
247
+ def test_relate_cmd_omits_qos_by_default():
248
+ cmd = _relate_cmd({"a": "a", "b": "b"}, **_relate_kwargs())
249
+ assert "--qos" not in cmd
250
+
251
+
252
+ def test_relate_cmd_passes_qos_when_set():
253
+ """A default QOS whose MaxWall is shorter than --relate-time is exactly
254
+
255
+ what killed a real run (QOSMaxWallDurationPerJobLimit) -- --relate-qos
256
+ lets a longer one be requested explicitly instead of guessed at.
257
+ """
258
+ cmd = _relate_cmd({"a": "a", "b": "b"}, **_relate_kwargs(relate_qos="1day"))
259
+ assert cmd[cmd.index("--qos") + 1] == "1day"
260
+
261
+
179
262
  def test_relate_script_has_the_real_bookkeeping():
180
263
  """relate.py must be the actual implementation, not a stub.
181
264
 
@@ -285,6 +368,88 @@ def test_relate_rechunks_mismatched_label_arrays(tmp_path):
285
368
  assert rows[2] == (None, 0, 0) # label 2 touches nothing in b
286
369
 
287
370
 
371
+ def test_relation_up_to_date_missing_output_is_false(tmp_path):
372
+ from relate import _relation_up_to_date
373
+
374
+ assert not _relation_up_to_date(
375
+ str(tmp_path), "a", "b", tmp_path / "nope.xlsx"
376
+ )
377
+
378
+
379
+ def test_relation_up_to_date_missing_marker_is_false(tmp_path):
380
+ """No labels.done for a label means its state can't be judged -- treat
381
+
382
+ that as "recompute", not as "trust the existing workbook".
383
+ """
384
+ from relate import _relation_up_to_date
385
+
386
+ out = tmp_path / "rel.xlsx"
387
+ out.write_text("x")
388
+ assert not _relation_up_to_date(str(tmp_path), "a", "b", out)
389
+
390
+
391
+ def test_relation_up_to_date_true_only_when_newer_than_both_markers(
392
+ tmp_path,
393
+ ):
394
+ from relate import _relation_up_to_date
395
+
396
+ (tmp_path / "a").mkdir()
397
+ (tmp_path / "b").mkdir()
398
+ (tmp_path / "a" / "labels.done").touch()
399
+ (tmp_path / "b" / "labels.done").touch()
400
+ out = tmp_path / "rel.xlsx"
401
+ out.write_text("x")
402
+
403
+ now = time.time()
404
+ os.utime(tmp_path / "a" / "labels.done", (now, now))
405
+ os.utime(tmp_path / "b" / "labels.done", (now, now))
406
+
407
+ # older than both markers -> stale
408
+ os.utime(out, (now - 10, now - 10))
409
+ assert not _relation_up_to_date(str(tmp_path), "a", "b", out)
410
+
411
+ # newer than both markers -> up to date
412
+ os.utime(out, (now + 10, now + 10))
413
+ assert _relation_up_to_date(str(tmp_path), "a", "b", out)
414
+
415
+ # b re-merged after the workbook was written -> stale again
416
+ os.utime(tmp_path / "b" / "labels.done", (now + 20, now + 20))
417
+ assert not _relation_up_to_date(str(tmp_path), "a", "b", out)
418
+
419
+
420
+ def test_relate_skips_a_relation_whose_workbook_is_up_to_date(tmp_path):
421
+ """A retry must not recompute what already finished -- only what a
422
+
423
+ shared, now-split-per-pair job left missing after a partial failure.
424
+ Proven by planting sentinel content no real relation would produce: if
425
+ run_relations() recomputed anyway, the sentinel would be gone.
426
+ """
427
+ from relate import run_relations
428
+
429
+ work_dir = tmp_path / "work"
430
+ work_dir.mkdir()
431
+ for name in ("a_labels", "b_labels"):
432
+ (work_dir / name).mkdir()
433
+ (work_dir / name / "labels.done").touch()
434
+
435
+ out_path = work_dir / "rel.xlsx"
436
+ sentinel = openpyxl.Workbook()
437
+ sentinel.active.append(["sentinel"])
438
+ sentinel.save(out_path)
439
+ os.utime(out_path, (time.time() + 60, time.time() + 60))
440
+
441
+ # No image_store/labels at all -- if this weren't skipped, run_relations
442
+ # would raise trying to open them, not just produce the wrong content.
443
+ run_relations(
444
+ str(work_dir),
445
+ str(tmp_path / "image.zarr"),
446
+ [{"a": "a_labels", "b": "b_labels", "output": "rel.xlsx"}],
447
+ )
448
+
449
+ wb = openpyxl.load_workbook(out_path)
450
+ assert wb.active["A1"].value == "sentinel"
451
+
452
+
288
453
  def test_mixed_nuclei_channel_auto_passes_validation():
289
454
  """A channel-count mismatch under `tile_shape: "auto"` is no longer
290
455