patchworks 2.6.8__tar.gz → 2.6.10__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.8 → patchworks-2.6.10}/PKG-INFO +1 -1
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/plugins/cellpose.md +2 -0
- patchworks-2.6.10/docs/api/volume_filter.md +9 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/cellpose_3d.md +11 -2
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/cellpose_3d.py +2 -2
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/merging.md +53 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/snakemake.md +27 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/mkdocs.yml +1 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/__init__.py +10 -0
- patchworks-2.6.10/src/patchworks/_volume_filter.py +249 -0
- patchworks-2.6.10/tests/test_pw.py +123 -0
- patchworks-2.6.10/tests/test_volume_filter.py +193 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/config_cilia.yaml +7 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/config_cyto.yaml +7 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/_pw.py +34 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/merge.py +40 -1
- patchworks-2.6.8/tests/test_pw.py +0 -57
- {patchworks-2.6.8 → patchworks-2.6.10}/.github/workflows/docs.yml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/.github/workflows/lint.yml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/.github/workflows/release.yml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/.gitignore +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/LICENSE +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/README.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/cliff.toml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/chunks.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/cluster.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/io.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/postprocess.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/relabel.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/tile_process.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/assets/logo.png +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/custom.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/custom_method.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/dog.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/dog.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/stardist.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/getting_started.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/label_relations.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/measurements.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/performance.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/tiling.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/docs/index.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/pyproject.toml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_core.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_distributed.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_io.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_merge.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_notify.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_occupancy.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_progress.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_relations.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/dog.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/ome_zarr.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_allocation.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_cellpose.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_core.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_distributed.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_dog.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_gpu.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_napari.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_notify.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_occupancy.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_postprocess.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_progress.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_relations.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_run_multi.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/README.md +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/Snakefile +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/common.yaml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/config.yaml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/multi.yaml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/pixi.toml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/rules/common.smk +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/rules/convert.smk +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/rules/merge.smk +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/rules/segment.smk +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/build_occupancy.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/convert.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/prepare_tiles.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/relate.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/run_multi.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/segment_tile.py +0 -0
- {patchworks-2.6.8 → patchworks-2.6.10}/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.10
|
|
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
|
|
@@ -14,19 +14,19 @@ plane orientations and takes a 3-D consensus.
|
|
|
14
14
|
from functools import partial
|
|
15
15
|
from patchworks import auto_tile_shape_cellpose, make_local_cluster, tile_process
|
|
16
16
|
from patchworks.plugins.cellpose import cellpose_fn
|
|
17
|
+
from patchworks.plugins.ome_zarr import read_pixel_size
|
|
17
18
|
|
|
18
19
|
IMAGE = "image.zarr"
|
|
19
20
|
OUTPUT = "labels_3d.zarr"
|
|
20
21
|
CHANNEL = 0
|
|
21
22
|
DIAMETER = 20 # pixels
|
|
22
|
-
ANISOTROPY = 3.0 # z_spacing / xy_spacing
|
|
23
23
|
|
|
24
24
|
fn = cellpose_fn(
|
|
25
25
|
"cyto3",
|
|
26
26
|
gpu=True,
|
|
27
27
|
do_3D=True,
|
|
28
28
|
diameter=DIAMETER,
|
|
29
|
-
anisotropy=
|
|
29
|
+
voxel_size=read_pixel_size(IMAGE), # -> anisotropy = z / lateral
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
# Tile shape: full z, xy tiled for memory
|
|
@@ -59,6 +59,15 @@ finally:
|
|
|
59
59
|
cluster.close()
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
!!! tip "Anisotropy is derived from the calibration, not retyped"
|
|
63
|
+
`do_3D` without `anisotropy` assumes isotropic voxels, which fragments
|
|
64
|
+
objects across z for any real (anisotropic) dataset. Passing
|
|
65
|
+
`voxel_size` derives it as `z / lateral` via
|
|
66
|
+
[`cellpose_anisotropy`](../api/plugins/cellpose.md) instead of keeping a
|
|
67
|
+
second, driftable copy of the calibration in code. An explicit
|
|
68
|
+
`anisotropy=` still wins if you pass one. The Snakemake workflow does
|
|
69
|
+
this automatically — see [Configure the run](../guide/snakemake.md#3-configure-the-run).
|
|
70
|
+
|
|
62
71
|
## Memory notes
|
|
63
72
|
|
|
64
73
|
In `do_3D=True` mode, each tile has shape `(z_full, y_tile, x_tile)`.
|
|
@@ -12,19 +12,19 @@ from patchworks import (
|
|
|
12
12
|
tile_process,
|
|
13
13
|
)
|
|
14
14
|
from patchworks.plugins.cellpose import cellpose_fn
|
|
15
|
+
from patchworks.plugins.ome_zarr import read_pixel_size
|
|
15
16
|
|
|
16
17
|
IMAGE = "image.zarr"
|
|
17
18
|
OUTPUT = "labels_3d.zarr"
|
|
18
19
|
CHANNEL = 0
|
|
19
20
|
DIAMETER = 20 # pixels
|
|
20
|
-
ANISOTROPY = 3.0 # z-spacing / xy-spacing
|
|
21
21
|
|
|
22
22
|
fn = cellpose_fn(
|
|
23
23
|
"cyto3",
|
|
24
24
|
gpu=True,
|
|
25
25
|
do_3D=True,
|
|
26
26
|
diameter=DIAMETER,
|
|
27
|
-
anisotropy=
|
|
27
|
+
voxel_size=read_pixel_size(IMAGE), # -> anisotropy = z / lateral
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
tile_fn = partial(
|
|
@@ -133,6 +133,59 @@ merged = merge_tile_labels(
|
|
|
133
133
|
)
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
+
## Filtering by size after merge
|
|
137
|
+
|
|
138
|
+
Once labels are globally consistent, [`filter_labels_by_size`](../api/volume_filter.md)
|
|
139
|
+
can drop objects outside a voxel-count range, in place — too small, too large,
|
|
140
|
+
or both:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from patchworks import filter_labels_by_size, merge_tile_labels
|
|
144
|
+
|
|
145
|
+
merged = merge_tile_labels("stage.zarr", write_to="labels.zarr", sequential_labels=True)
|
|
146
|
+
# drop anything under 500 voxels, over 50000, or both -- give either bound alone
|
|
147
|
+
n_kept, n_removed = filter_labels_by_size(
|
|
148
|
+
"labels.zarr", "labels", min_voxels=500, max_voxels=50000
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
This has to run **after** the merge, not per tile: a tile only sees whatever
|
|
153
|
+
fragment of an object landed inside its own bounds, so a per-tile filter would
|
|
154
|
+
judge (and possibly drop) an object crossing a tile boundary as if it were
|
|
155
|
+
only that fragment's size — including judging it too *large*, for a
|
|
156
|
+
`max_voxels` filter, when several separate objects in one tile would in fact
|
|
157
|
+
merge back into one across the boundary.
|
|
158
|
+
|
|
159
|
+
Like the merge itself, it is a two-pass streaming zarr scan — the array never
|
|
160
|
+
has to fit in RAM. `relabel=True` (the default) folds the size filter into
|
|
161
|
+
the same lookup table that renumbers survivors to a contiguous `1..N` range,
|
|
162
|
+
so dropping out-of-range objects costs no extra pass over the volume beyond
|
|
163
|
+
the scan that already counts them.
|
|
164
|
+
|
|
165
|
+
Physical thresholds (µm³) convert to a voxel count via
|
|
166
|
+
[`min_voxels_for_volume`](../api/volume_filter.md)/[`max_voxels_for_volume`](../api/volume_filter.md),
|
|
167
|
+
using the same `{"z": .., "y": .., "x": ..}` calibration deconvolution and
|
|
168
|
+
Cellpose's `anisotropy` are derived from. The two round in opposite
|
|
169
|
+
directions — `min_voxels_for_volume` rounds up (an object must *reach* the
|
|
170
|
+
threshold), `max_voxels_for_volume` rounds down (an object must not *exceed*
|
|
171
|
+
it):
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from patchworks import max_voxels_for_volume, min_voxels_for_volume
|
|
175
|
+
from patchworks.plugins.ome_zarr import read_pixel_size
|
|
176
|
+
|
|
177
|
+
voxel_size = read_pixel_size("image.zarr")
|
|
178
|
+
min_voxels = min_voxels_for_volume(5.0, voxel_size)
|
|
179
|
+
max_voxels = max_voxels_for_volume(500.0, voxel_size)
|
|
180
|
+
n_kept, n_removed = filter_labels_by_size(
|
|
181
|
+
"labels.zarr", "labels", min_voxels, max_voxels
|
|
182
|
+
)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
On the cluster, set `min_volume: 5.0`/`max_volume: 500.0` in the config
|
|
186
|
+
instead — see [Configure the run](snakemake.md#3-configure-the-run). Either
|
|
187
|
+
or both run automatically between `merge` and the pyramid build.
|
|
188
|
+
|
|
136
189
|
## Sequential label numbering
|
|
137
190
|
|
|
138
191
|
By default, merged labels are globally unique but may be **gappy** — boundary
|
|
@@ -68,12 +68,16 @@ method: "cellpose" # "cellpose" (GPU), "threshold" (no GPU), "custom
|
|
|
68
68
|
label_name: "cellpose" # name under image.zarr/labels/
|
|
69
69
|
dilate: 0 # optional: pixels to grow labels by, any method
|
|
70
70
|
dilate_gpu: false # dilate via cupy instead of scipy (needs a GPU)
|
|
71
|
+
min_volume: null # optional: drop objects smaller than this many µm³
|
|
72
|
+
max_volume: null # optional: drop objects larger than this many µm³
|
|
71
73
|
cellpose:
|
|
72
74
|
model: "cyto3"
|
|
73
75
|
diameter: 30
|
|
74
76
|
do_3D: true
|
|
75
77
|
gpu: true
|
|
76
78
|
# extra model.eval() kwargs, e.g. flow_threshold: 0.4
|
|
79
|
+
# anisotropy: 2.2 # optional: overrides the value derived automatically
|
|
80
|
+
# # from image.zarr's calibration for do_3D (see tip below)
|
|
77
81
|
|
|
78
82
|
# label pyramid
|
|
79
83
|
pyramid_levels: 5
|
|
@@ -94,6 +98,29 @@ sequential_labels: true # renumber labels to a contiguous 1..N
|
|
|
94
98
|
labels afterwards](custom_segmentation.md#growing-labels-afterwards-dilation)
|
|
95
99
|
for how it works and the equivalent direct-API call.
|
|
96
100
|
|
|
101
|
+
!!! tip "Dropping objects by size with `min_volume`/`max_volume`"
|
|
102
|
+
`min_volume: N` drops any object smaller than `N` µm³; `max_volume: N`
|
|
103
|
+
drops any object larger than `N` µm³ (e.g. several objects merged into
|
|
104
|
+
one blob). Set either, both, or neither (`null`, the default, disables
|
|
105
|
+
each). Both run once on the **fully merged** image — not per tile, where
|
|
106
|
+
an object crossing a tile boundary would look smaller or larger than it
|
|
107
|
+
really is. Runs after `merge` and before the pyramid is built, so every
|
|
108
|
+
pyramid level reflects the filtered result, and needs `image.zarr` to
|
|
109
|
+
carry a pixel size (the same calibration deconvolution's voxel sizes and
|
|
110
|
+
Cellpose's `anisotropy` are derived from — see the tip below); an
|
|
111
|
+
uncalibrated store raises rather than silently skipping the filter. See
|
|
112
|
+
[Filtering by size after merge](merging.md#filtering-by-size-after-merge)
|
|
113
|
+
for the equivalent direct-API call.
|
|
114
|
+
|
|
115
|
+
!!! tip "3-D anisotropy is derived automatically"
|
|
116
|
+
Cellpose's `do_3D` assumes isotropic voxels unless told otherwise —
|
|
117
|
+
without an `anisotropy`, a real (anisotropic) dataset gets objects
|
|
118
|
+
fragmented or distorted across z. `segment` now derives it from
|
|
119
|
+
`image.zarr`'s own calibration (`z` voxel size ÷ lateral voxel size)
|
|
120
|
+
whenever `do_3D: true` and `cellpose.anisotropy` isn't set explicitly, so
|
|
121
|
+
there's usually nothing to configure. Set `anisotropy:` yourself in the
|
|
122
|
+
`cellpose:` block to override it.
|
|
123
|
+
|
|
97
124
|
!!! tip "Tile size vs runtime"
|
|
98
125
|
`tile_shape: "auto"` sizes each tile to your GPU's VRAM. Smaller tiles =
|
|
99
126
|
more (faster) jobs; very large 3-D tiles are slow. Keep `do_3D: false` (2-D
|
|
@@ -58,6 +58,7 @@ nav:
|
|
|
58
58
|
- tile_process: api/tile_process.md
|
|
59
59
|
- merge_tile_labels: api/merge_tile_labels.md
|
|
60
60
|
- dilate_labels: api/postprocess.md
|
|
61
|
+
- Volume filtering: api/volume_filter.md
|
|
61
62
|
- Tile sizing: api/chunks.md
|
|
62
63
|
- I/O helpers: api/io.md
|
|
63
64
|
- Relabelling: api/relabel.md
|
|
@@ -55,6 +55,12 @@ from ._occupancy import (
|
|
|
55
55
|
from ._postprocess import dilate_labels
|
|
56
56
|
from ._relabel import relabel_sequential_array, relabel_sequential_zarr
|
|
57
57
|
from ._relations import label_relations
|
|
58
|
+
from ._volume_filter import (
|
|
59
|
+
filter_labels_by_size,
|
|
60
|
+
max_voxels_for_volume,
|
|
61
|
+
min_voxels_for_volume,
|
|
62
|
+
voxel_volume,
|
|
63
|
+
)
|
|
58
64
|
|
|
59
65
|
try:
|
|
60
66
|
__version__ = _pkg_version("patchworks")
|
|
@@ -85,4 +91,8 @@ __all__ = [
|
|
|
85
91
|
"create_stage",
|
|
86
92
|
"stage_tile",
|
|
87
93
|
"dilate_labels",
|
|
94
|
+
"filter_labels_by_size",
|
|
95
|
+
"min_voxels_for_volume",
|
|
96
|
+
"max_voxels_for_volume",
|
|
97
|
+
"voxel_volume",
|
|
88
98
|
]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Drop label objects outside a size range, in place, after merge.
|
|
2
|
+
|
|
3
|
+
Meant to run once, globally, on the fully merged label array -- not per
|
|
4
|
+
tile, where an object's true size isn't known yet (a tile only sees
|
|
5
|
+
whatever fragment of it landed inside that tile's bounds, so a per-tile
|
|
6
|
+
filter would clip or drop objects that are only small, or only large,
|
|
7
|
+
*within one tile*).
|
|
8
|
+
|
|
9
|
+
Two-pass streaming algorithm, mirroring :func:`patchworks.relabel_sequential_zarr`
|
|
10
|
+
-- safe for arrays far larger than RAM. Pass 1 does a chunk-wise
|
|
11
|
+
unique+count to get every label's voxel count (bounded memory: a Python
|
|
12
|
+
dict keyed by label id, not the voxels themselves). Pass 2 builds a LUT
|
|
13
|
+
that zeroes labels outside ``[min_voxels, max_voxels]`` -- optionally
|
|
14
|
+
renumbering the survivors to a contiguous range in the same pass -- and
|
|
15
|
+
applies it chunk by chunk, writing back into the same store.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import math
|
|
22
|
+
from itertools import product as _iproduct
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import zarr
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_LUT_WARN_THRESHOLD = 100_000_000 # warn when max_label > 100 M (LUT > 800 MB)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def voxel_volume(voxel_size: "dict[str, float]") -> float:
|
|
33
|
+
"""Physical volume of one voxel, from a per-axis calibration.
|
|
34
|
+
|
|
35
|
+
Axes missing from *voxel_size* are treated as 1.0 -- e.g. a 2-D
|
|
36
|
+
calibration with no ``z`` gives an area, not a bogus volume shrunk by a
|
|
37
|
+
fake axis. Units follow whatever *voxel_size* is in (micrometers for
|
|
38
|
+
:func:`patchworks.plugins.ome_zarr.read_pixel_size`).
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
voxel_size : dict
|
|
43
|
+
Per-axis physical size, e.g. ``{"z": .., "y": .., "x": ..}``.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
float
|
|
48
|
+
Product of the given axis sizes.
|
|
49
|
+
|
|
50
|
+
Examples
|
|
51
|
+
--------
|
|
52
|
+
>>> voxel_volume({"z": 0.24, "y": 0.10833, "x": 0.10833})
|
|
53
|
+
0.0028164933359999997
|
|
54
|
+
"""
|
|
55
|
+
vol = 1.0
|
|
56
|
+
for size in voxel_size.values():
|
|
57
|
+
vol *= size
|
|
58
|
+
return vol
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def min_voxels_for_volume(
|
|
62
|
+
min_volume: float, voxel_size: "dict[str, float]"
|
|
63
|
+
) -> int:
|
|
64
|
+
"""Convert a physical volume threshold to a voxel count.
|
|
65
|
+
|
|
66
|
+
Rounds up: an object must reach *min_volume* to survive, so a partial
|
|
67
|
+
voxel's worth of extra volume should not tip it over the line.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
min_volume : float
|
|
72
|
+
Minimum object volume to keep, in the same physical units as
|
|
73
|
+
*voxel_size* (micrometers³ for an NGFF calibration).
|
|
74
|
+
voxel_size : dict
|
|
75
|
+
Per-axis physical size -- see :func:`voxel_volume`.
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
int
|
|
80
|
+
Minimum voxel count for an object to survive filtering.
|
|
81
|
+
|
|
82
|
+
Examples
|
|
83
|
+
--------
|
|
84
|
+
>>> min_voxels_for_volume(5.0, {"z": 0.24, "y": 0.10833, "x": 0.10833})
|
|
85
|
+
1776
|
|
86
|
+
"""
|
|
87
|
+
return math.ceil(min_volume / voxel_volume(voxel_size))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def max_voxels_for_volume(
|
|
91
|
+
max_volume: float, voxel_size: "dict[str, float]"
|
|
92
|
+
) -> int:
|
|
93
|
+
"""Convert a physical volume threshold to a voxel count.
|
|
94
|
+
|
|
95
|
+
Rounds down, the mirror image of :func:`min_voxels_for_volume`: an
|
|
96
|
+
object must not *exceed* *max_volume*, so a voxel count whose volume
|
|
97
|
+
would tip past it must not survive.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
max_volume : float
|
|
102
|
+
Maximum object volume to keep, in the same physical units as
|
|
103
|
+
*voxel_size* (micrometers³ for an NGFF calibration).
|
|
104
|
+
voxel_size : dict
|
|
105
|
+
Per-axis physical size -- see :func:`voxel_volume`.
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
int
|
|
110
|
+
Maximum voxel count for an object to survive filtering.
|
|
111
|
+
|
|
112
|
+
Examples
|
|
113
|
+
--------
|
|
114
|
+
>>> max_voxels_for_volume(5.0, {"z": 0.24, "y": 0.10833, "x": 0.10833})
|
|
115
|
+
1775
|
|
116
|
+
"""
|
|
117
|
+
return math.floor(max_volume / voxel_volume(voxel_size))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _chunk_slices(shape, chunks):
|
|
121
|
+
"""Every zarr chunk's index expression, in all dimensions.
|
|
122
|
+
|
|
123
|
+
Iterating actual chunk boundaries (rather than z-slabs) keeps each read
|
|
124
|
+
bounded to one chunk's worth of memory, whatever the array's shape.
|
|
125
|
+
"""
|
|
126
|
+
n_per_dim = [(s + c - 1) // c for s, c in zip(shape, chunks)]
|
|
127
|
+
return [
|
|
128
|
+
tuple(
|
|
129
|
+
slice(i * c, min((i + 1) * c, s))
|
|
130
|
+
for i, c, s in zip(idx, chunks, shape)
|
|
131
|
+
)
|
|
132
|
+
for idx in _iproduct(*[range(n) for n in n_per_dim])
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def filter_labels_by_size(
|
|
137
|
+
store_path: str,
|
|
138
|
+
component: str,
|
|
139
|
+
min_voxels: "int | None" = None,
|
|
140
|
+
max_voxels: "int | None" = None,
|
|
141
|
+
*,
|
|
142
|
+
relabel: bool = True,
|
|
143
|
+
) -> "tuple[int, int]":
|
|
144
|
+
"""Drop label objects outside ``[min_voxels, max_voxels]``, in place.
|
|
145
|
+
|
|
146
|
+
Two-pass streaming scan (see module docstring) -- the array never has
|
|
147
|
+
to fit in RAM.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
store_path : str
|
|
152
|
+
Path to the zarr store containing the label array.
|
|
153
|
+
component : str
|
|
154
|
+
Array name inside the store to filter in place.
|
|
155
|
+
min_voxels : int, optional
|
|
156
|
+
Objects with fewer voxels than this are zeroed (dropped). ``None``
|
|
157
|
+
(default) sets no lower bound. Use :func:`min_voxels_for_volume` to
|
|
158
|
+
derive this from a physical volume and calibration.
|
|
159
|
+
max_voxels : int, optional
|
|
160
|
+
Objects with more voxels than this are zeroed (dropped) -- e.g. a
|
|
161
|
+
segmentation artifact where several objects merged into one giant
|
|
162
|
+
blob. ``None`` (default) sets no upper bound. Use
|
|
163
|
+
:func:`max_voxels_for_volume` to derive this from a physical volume
|
|
164
|
+
and calibration.
|
|
165
|
+
relabel : bool, optional
|
|
166
|
+
Renumber the surviving objects to a contiguous ``1..N`` range in
|
|
167
|
+
the same LUT that drops the out-of-range ones (default ``True``)
|
|
168
|
+
-- otherwise the removed ids leave permanent gaps and survivors
|
|
169
|
+
keep their original ids.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
tuple of int
|
|
174
|
+
``(n_kept, n_removed)``.
|
|
175
|
+
|
|
176
|
+
Examples
|
|
177
|
+
--------
|
|
178
|
+
>>> import zarr
|
|
179
|
+
>>> root = zarr.open_group("labels.zarr", mode="w") # doctest: +SKIP
|
|
180
|
+
>>> root.create_array(
|
|
181
|
+
... "labels", shape=(4, 4), chunks=(4, 4), dtype="int32"
|
|
182
|
+
... )[:] = [
|
|
183
|
+
... [0, 1, 1, 0],
|
|
184
|
+
... [0, 1, 1, 0],
|
|
185
|
+
... [0, 0, 0, 2],
|
|
186
|
+
... [0, 0, 0, 0],
|
|
187
|
+
... ] # doctest: +SKIP
|
|
188
|
+
>>> filter_labels_by_size("labels.zarr", "labels", min_voxels=2) # doctest: +SKIP
|
|
189
|
+
(1, 1)
|
|
190
|
+
"""
|
|
191
|
+
if min_voxels is None and max_voxels is None:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
"filter_labels_by_size needs min_voxels, max_voxels, or both"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
root = zarr.open_group(store_path, mode="r+")
|
|
197
|
+
z = root[component]
|
|
198
|
+
slices = _chunk_slices(z.shape, z.chunks)
|
|
199
|
+
|
|
200
|
+
counts: "dict[int, int]" = {}
|
|
201
|
+
for sl in slices:
|
|
202
|
+
ids, n = np.unique(np.asarray(z[sl]), return_counts=True)
|
|
203
|
+
for label_id, count in zip(ids.tolist(), n.tolist()):
|
|
204
|
+
if label_id == 0:
|
|
205
|
+
continue
|
|
206
|
+
counts[label_id] = counts.get(label_id, 0) + count
|
|
207
|
+
|
|
208
|
+
kept = sorted(
|
|
209
|
+
i
|
|
210
|
+
for i, c in counts.items()
|
|
211
|
+
if (min_voxels is None or c >= min_voxels)
|
|
212
|
+
and (max_voxels is None or c <= max_voxels)
|
|
213
|
+
)
|
|
214
|
+
n_kept = len(kept)
|
|
215
|
+
n_removed = len(counts) - n_kept
|
|
216
|
+
|
|
217
|
+
# Sized to the largest id *seen*, not just the largest surviving one --
|
|
218
|
+
# a removed object's id can still exceed every kept id and must stay
|
|
219
|
+
# in bounds so the LUT gather below maps it to 0 rather than indexing
|
|
220
|
+
# past the end.
|
|
221
|
+
max_label = max(counts) if counts else 0
|
|
222
|
+
if max_label > _LUT_WARN_THRESHOLD:
|
|
223
|
+
logger.warning(
|
|
224
|
+
"filter_labels_by_size: max_label=%d -> LUT size ~%.0f MB.",
|
|
225
|
+
max_label,
|
|
226
|
+
max_label * 8 / 1024**2,
|
|
227
|
+
)
|
|
228
|
+
lut = np.zeros(max_label + 1, dtype=np.int64)
|
|
229
|
+
if kept:
|
|
230
|
+
lut[kept] = np.arange(1, n_kept + 1) if relabel else np.asarray(kept)
|
|
231
|
+
|
|
232
|
+
max_out = n_kept if relabel else max_label
|
|
233
|
+
out_dtype = np.uint16 if max_out < np.iinfo(np.uint16).max else np.uint32
|
|
234
|
+
for sl in slices:
|
|
235
|
+
block = np.asarray(z[sl])
|
|
236
|
+
z[sl] = lut[block].astype(out_dtype)
|
|
237
|
+
|
|
238
|
+
bounds = "-".join(
|
|
239
|
+
str(v) if v is not None else "" for v in (min_voxels, max_voxels)
|
|
240
|
+
)
|
|
241
|
+
logger.info(
|
|
242
|
+
"filter_labels_by_size: dropped %d/%d object(s) outside [%s] voxels, "
|
|
243
|
+
"%d remain",
|
|
244
|
+
n_removed,
|
|
245
|
+
len(counts),
|
|
246
|
+
bounds,
|
|
247
|
+
n_kept,
|
|
248
|
+
)
|
|
249
|
+
return n_kept, n_removed
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Tests for workflow/scripts/_pw.py's config-to-segmentation-function wiring."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.path.insert(
|
|
7
|
+
0, str(Path(__file__).resolve().parents[1] / "workflow" / "scripts")
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
import numpy as np # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_with_voxel_size_fills_in_cellposes_calibration(tmp_path):
|
|
14
|
+
"""The same auto-fill a custom function's voxel_size gets (see
|
|
15
|
+
|
|
16
|
+
_with_voxel_size's docstring) must also reach cellpose_fn, or do_3D
|
|
17
|
+
silently assumes isotropic voxels -- fragmenting objects across z for
|
|
18
|
+
any real (anisotropic) calibration. This checks the actual calibration
|
|
19
|
+
read + injection against a real store, not just that the parameter
|
|
20
|
+
exists (see test_cellpose.py for that).
|
|
21
|
+
"""
|
|
22
|
+
from _pw import _with_voxel_size
|
|
23
|
+
from patchworks.plugins.cellpose import cellpose_fn
|
|
24
|
+
from patchworks.plugins.ome_zarr import to_ome_zarr
|
|
25
|
+
|
|
26
|
+
arr = np.zeros((4, 8, 8), dtype="uint16")
|
|
27
|
+
to_ome_zarr(
|
|
28
|
+
arr,
|
|
29
|
+
str(tmp_path / "image.zarr"),
|
|
30
|
+
axes="zyx",
|
|
31
|
+
pixel_size={"z": 0.24, "y": 0.10833, "x": 0.10833},
|
|
32
|
+
n_levels=1,
|
|
33
|
+
progress=False,
|
|
34
|
+
)
|
|
35
|
+
cfg = {"work_dir": str(tmp_path)}
|
|
36
|
+
|
|
37
|
+
kwargs = _with_voxel_size(cellpose_fn, {}, cfg)
|
|
38
|
+
|
|
39
|
+
assert kwargs["voxel_size"]["z"] == 0.24
|
|
40
|
+
assert kwargs["voxel_size"]["x"] == 0.10833
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_with_voxel_size_never_overrides_an_explicit_value(tmp_path):
|
|
44
|
+
"""An explicit voxel_size must win, and win *cheaply*: no image.zarr
|
|
45
|
+
|
|
46
|
+
exists in work_dir at all here, so if this read past the early return it
|
|
47
|
+
would raise, not just return the wrong value.
|
|
48
|
+
"""
|
|
49
|
+
from _pw import _with_voxel_size
|
|
50
|
+
from patchworks.plugins.cellpose import cellpose_fn
|
|
51
|
+
|
|
52
|
+
cfg = {"work_dir": str(tmp_path)}
|
|
53
|
+
explicit = {"z": 1.0, "y": 1.0, "x": 1.0}
|
|
54
|
+
|
|
55
|
+
kwargs = _with_voxel_size(cellpose_fn, {"voxel_size": explicit}, cfg)
|
|
56
|
+
|
|
57
|
+
assert kwargs["voxel_size"] == explicit
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_validate_config_accepts_a_positive_min_volume():
|
|
61
|
+
from _pw import validate_config
|
|
62
|
+
|
|
63
|
+
validate_config({"method": "threshold", "min_volume": 5.0})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_validate_config_accepts_no_min_volume():
|
|
67
|
+
from _pw import validate_config
|
|
68
|
+
|
|
69
|
+
validate_config({"method": "threshold"})
|
|
70
|
+
validate_config({"method": "threshold", "min_volume": None})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_validate_config_rejects_a_non_positive_min_volume():
|
|
74
|
+
import pytest
|
|
75
|
+
from _pw import validate_config
|
|
76
|
+
|
|
77
|
+
with pytest.raises(ValueError, match="min_volume"):
|
|
78
|
+
validate_config({"method": "threshold", "min_volume": 0})
|
|
79
|
+
with pytest.raises(ValueError, match="min_volume"):
|
|
80
|
+
validate_config({"method": "threshold", "min_volume": -1.0})
|
|
81
|
+
with pytest.raises(ValueError, match="min_volume"):
|
|
82
|
+
validate_config({"method": "threshold", "min_volume": "5"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_validate_config_accepts_a_positive_max_volume():
|
|
86
|
+
from _pw import validate_config
|
|
87
|
+
|
|
88
|
+
validate_config({"method": "threshold", "max_volume": 500.0})
|
|
89
|
+
validate_config({"method": "threshold", "max_volume": None})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_validate_config_rejects_a_non_positive_max_volume():
|
|
93
|
+
import pytest
|
|
94
|
+
from _pw import validate_config
|
|
95
|
+
|
|
96
|
+
with pytest.raises(ValueError, match="max_volume"):
|
|
97
|
+
validate_config({"method": "threshold", "max_volume": 0})
|
|
98
|
+
with pytest.raises(ValueError, match="max_volume"):
|
|
99
|
+
validate_config({"method": "threshold", "max_volume": -1.0})
|
|
100
|
+
with pytest.raises(ValueError, match="max_volume"):
|
|
101
|
+
validate_config({"method": "threshold", "max_volume": "5"})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_validate_config_accepts_max_volume_above_min_volume():
|
|
105
|
+
from _pw import validate_config
|
|
106
|
+
|
|
107
|
+
validate_config(
|
|
108
|
+
{"method": "threshold", "min_volume": 5.0, "max_volume": 500.0}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_validate_config_rejects_max_volume_at_or_below_min_volume():
|
|
113
|
+
import pytest
|
|
114
|
+
from _pw import validate_config
|
|
115
|
+
|
|
116
|
+
with pytest.raises(ValueError, match="max_volume"):
|
|
117
|
+
validate_config(
|
|
118
|
+
{"method": "threshold", "min_volume": 5.0, "max_volume": 5.0}
|
|
119
|
+
)
|
|
120
|
+
with pytest.raises(ValueError, match="max_volume"):
|
|
121
|
+
validate_config(
|
|
122
|
+
{"method": "threshold", "min_volume": 500.0, "max_volume": 5.0}
|
|
123
|
+
)
|