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.
- {patchworks-2.6.10 → patchworks-2.6.12}/PKG-INFO +1 -1
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/dog.md +15 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/snakemake.md +77 -14
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_chunks.py +14 -1
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/cellpose.py +27 -1
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/dog.py +20 -15
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_allocation.py +29 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_cellpose.py +24 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_dog.py +18 -6
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_pw.py +41 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_run_multi.py +165 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/_pw.py +38 -6
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/prepare_tiles.py +12 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/relate.py +39 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/run_multi.py +125 -30
- {patchworks-2.6.10 → patchworks-2.6.12}/.github/workflows/docs.yml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/.github/workflows/lint.yml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/.github/workflows/release.yml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/.gitignore +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/LICENSE +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/README.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/cliff.toml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/chunks.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/cluster.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/io.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/postprocess.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/relabel.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/tile_process.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/api/volume_filter.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/assets/logo.png +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/custom.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/custom_method.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/dog.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/stardist.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/getting_started.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/label_relations.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/measurements.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/merging.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/performance.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/guide/tiling.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/docs/index.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/mkdocs.yml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/pyproject.toml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/__init__.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_core.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_distributed.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_io.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_merge.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_notify.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_occupancy.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_progress.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_relations.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/_volume_filter.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/src/patchworks/plugins/ome_zarr.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_core.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_distributed.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_gpu.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_napari.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_notify.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_occupancy.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_postprocess.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_progress.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_relations.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/tests/test_volume_filter.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/README.md +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/Snakefile +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/common.yaml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/config.yaml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/config_cilia.yaml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/config_cyto.yaml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/config/multi.yaml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/pixi.toml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/rules/common.smk +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/rules/convert.smk +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/rules/merge.smk +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/rules/segment.smk +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/build_occupancy.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/convert.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/merge.py +0 -0
- {patchworks-2.6.10 → patchworks-2.6.12}/workflow/scripts/segment_tile.py +0 -0
- {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.
|
|
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`
|
|
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.
|
|
448
|
-
|
|
449
|
-
the same tee-to-file-and-stdout
|
|
450
|
-
|
|
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
|
|
502
|
-
submitted as its own `srun` job
|
|
503
|
-
in the driver process on the
|
|
504
|
-
the occupancy map. Tune
|
|
505
|
-
`--relate-
|
|
506
|
-
`32G`, `8`, `180` minutes
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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;
|
|
275
|
-
"
|
|
276
|
-
"
|
|
277
|
-
"
|
|
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(
|
|
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
|
-
(
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
#
|
|
125
|
-
assert np.array_equal(out[0:13,
|
|
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
|
|