patchworks 2.6.9__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.9 → patchworks-2.6.10}/PKG-INFO +1 -1
- {patchworks-2.6.9 → 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.9 → patchworks-2.6.10}/docs/examples/cellpose_3d.md +11 -2
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/cellpose_3d.py +2 -2
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/merging.md +53 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/snakemake.md +27 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/mkdocs.yml +1 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/__init__.py +2 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_volume_filter.py +68 -17
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_pw.py +41 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_volume_filter.py +58 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/config_cilia.yaml +7 -5
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/config_cyto.yaml +7 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/_pw.py +23 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/merge.py +17 -7
- {patchworks-2.6.9 → patchworks-2.6.10}/.github/workflows/docs.yml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/.github/workflows/lint.yml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/.github/workflows/release.yml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/.gitignore +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/LICENSE +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/README.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/cliff.toml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/chunks.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/cluster.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/io.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/postprocess.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/relabel.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/tile_process.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/assets/logo.png +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/custom.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/custom_method.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/dog.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/dog.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/stardist.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/getting_started.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/label_relations.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/measurements.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/performance.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/tiling.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/docs/index.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/pyproject.toml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_core.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_distributed.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_io.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_merge.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_notify.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_occupancy.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_progress.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_relations.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/dog.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/ome_zarr.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_allocation.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_cellpose.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_core.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_distributed.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_dog.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_gpu.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_napari.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_notify.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_occupancy.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_postprocess.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_progress.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_relations.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_run_multi.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/README.md +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/Snakefile +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/common.yaml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/config.yaml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/multi.yaml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/pixi.toml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/rules/common.smk +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/rules/convert.smk +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/rules/merge.smk +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/rules/segment.smk +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/build_occupancy.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/convert.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/prepare_tiles.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/relate.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/run_multi.py +0 -0
- {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/segment_tile.py +0 -0
- {patchworks-2.6.9 → 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
|
|
@@ -57,6 +57,7 @@ from ._relabel import relabel_sequential_array, relabel_sequential_zarr
|
|
|
57
57
|
from ._relations import label_relations
|
|
58
58
|
from ._volume_filter import (
|
|
59
59
|
filter_labels_by_size,
|
|
60
|
+
max_voxels_for_volume,
|
|
60
61
|
min_voxels_for_volume,
|
|
61
62
|
voxel_volume,
|
|
62
63
|
)
|
|
@@ -92,5 +93,6 @@ __all__ = [
|
|
|
92
93
|
"dilate_labels",
|
|
93
94
|
"filter_labels_by_size",
|
|
94
95
|
"min_voxels_for_volume",
|
|
96
|
+
"max_voxels_for_volume",
|
|
95
97
|
"voxel_volume",
|
|
96
98
|
]
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
"""Drop label objects
|
|
1
|
+
"""Drop label objects outside a size range, in place, after merge.
|
|
2
2
|
|
|
3
3
|
Meant to run once, globally, on the fully merged label array -- not per
|
|
4
4
|
tile, where an object's true size isn't known yet (a tile only sees
|
|
5
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
|
|
6
|
+
filter would clip or drop objects that are only small, or only large,
|
|
7
|
+
*within one tile*).
|
|
7
8
|
|
|
8
9
|
Two-pass streaming algorithm, mirroring :func:`patchworks.relabel_sequential_zarr`
|
|
9
10
|
-- safe for arrays far larger than RAM. Pass 1 does a chunk-wise
|
|
10
11
|
unique+count to get every label's voxel count (bounded memory: a Python
|
|
11
12
|
dict keyed by label id, not the voxels themselves). Pass 2 builds a LUT
|
|
12
|
-
that zeroes labels
|
|
13
|
-
survivors to a contiguous range in the same pass -- and
|
|
14
|
-
chunk, writing back into the same store.
|
|
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.
|
|
15
16
|
"""
|
|
16
17
|
|
|
17
18
|
from __future__ import annotations
|
|
@@ -86,6 +87,36 @@ def min_voxels_for_volume(
|
|
|
86
87
|
return math.ceil(min_volume / voxel_volume(voxel_size))
|
|
87
88
|
|
|
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
|
+
|
|
89
120
|
def _chunk_slices(shape, chunks):
|
|
90
121
|
"""Every zarr chunk's index expression, in all dimensions.
|
|
91
122
|
|
|
@@ -105,11 +136,12 @@ def _chunk_slices(shape, chunks):
|
|
|
105
136
|
def filter_labels_by_size(
|
|
106
137
|
store_path: str,
|
|
107
138
|
component: str,
|
|
108
|
-
min_voxels: int,
|
|
139
|
+
min_voxels: "int | None" = None,
|
|
140
|
+
max_voxels: "int | None" = None,
|
|
109
141
|
*,
|
|
110
142
|
relabel: bool = True,
|
|
111
143
|
) -> "tuple[int, int]":
|
|
112
|
-
"""Drop label objects
|
|
144
|
+
"""Drop label objects outside ``[min_voxels, max_voxels]``, in place.
|
|
113
145
|
|
|
114
146
|
Two-pass streaming scan (see module docstring) -- the array never has
|
|
115
147
|
to fit in RAM.
|
|
@@ -120,15 +152,21 @@ def filter_labels_by_size(
|
|
|
120
152
|
Path to the zarr store containing the label array.
|
|
121
153
|
component : str
|
|
122
154
|
Array name inside the store to filter in place.
|
|
123
|
-
min_voxels : int
|
|
124
|
-
Objects with fewer voxels than this are zeroed (dropped).
|
|
125
|
-
:func:`min_voxels_for_volume` to
|
|
126
|
-
volume and calibration.
|
|
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.
|
|
127
165
|
relabel : bool, optional
|
|
128
166
|
Renumber the surviving objects to a contiguous ``1..N`` range in
|
|
129
|
-
the same LUT that drops the
|
|
130
|
-
otherwise the removed ids leave permanent gaps and survivors
|
|
131
|
-
their original ids.
|
|
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.
|
|
132
170
|
|
|
133
171
|
Returns
|
|
134
172
|
-------
|
|
@@ -150,6 +188,11 @@ def filter_labels_by_size(
|
|
|
150
188
|
>>> filter_labels_by_size("labels.zarr", "labels", min_voxels=2) # doctest: +SKIP
|
|
151
189
|
(1, 1)
|
|
152
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
|
+
|
|
153
196
|
root = zarr.open_group(store_path, mode="r+")
|
|
154
197
|
z = root[component]
|
|
155
198
|
slices = _chunk_slices(z.shape, z.chunks)
|
|
@@ -162,7 +205,12 @@ def filter_labels_by_size(
|
|
|
162
205
|
continue
|
|
163
206
|
counts[label_id] = counts.get(label_id, 0) + count
|
|
164
207
|
|
|
165
|
-
kept = sorted(
|
|
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
|
+
)
|
|
166
214
|
n_kept = len(kept)
|
|
167
215
|
n_removed = len(counts) - n_kept
|
|
168
216
|
|
|
@@ -187,12 +235,15 @@ def filter_labels_by_size(
|
|
|
187
235
|
block = np.asarray(z[sl])
|
|
188
236
|
z[sl] = lut[block].astype(out_dtype)
|
|
189
237
|
|
|
238
|
+
bounds = "-".join(
|
|
239
|
+
str(v) if v is not None else "" for v in (min_voxels, max_voxels)
|
|
240
|
+
)
|
|
190
241
|
logger.info(
|
|
191
|
-
"filter_labels_by_size: dropped %d/%d object(s)
|
|
242
|
+
"filter_labels_by_size: dropped %d/%d object(s) outside [%s] voxels, "
|
|
192
243
|
"%d remain",
|
|
193
244
|
n_removed,
|
|
194
245
|
len(counts),
|
|
195
|
-
|
|
246
|
+
bounds,
|
|
196
247
|
n_kept,
|
|
197
248
|
)
|
|
198
249
|
return n_kept, n_removed
|
|
@@ -80,3 +80,44 @@ def test_validate_config_rejects_a_non_positive_min_volume():
|
|
|
80
80
|
validate_config({"method": "threshold", "min_volume": -1.0})
|
|
81
81
|
with pytest.raises(ValueError, match="min_volume"):
|
|
82
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
|
+
)
|
|
@@ -25,6 +25,13 @@ def test_min_voxels_for_volume_rounds_up():
|
|
|
25
25
|
assert min_voxels_for_volume(5.0, calibration) == 1776
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def test_max_voxels_for_volume_rounds_down():
|
|
29
|
+
from patchworks import max_voxels_for_volume
|
|
30
|
+
|
|
31
|
+
calibration = {"z": 0.24, "y": 0.10833, "x": 0.10833}
|
|
32
|
+
assert max_voxels_for_volume(5.0, calibration) == 1775
|
|
33
|
+
|
|
34
|
+
|
|
28
35
|
def _write_labels(path, array, chunks):
|
|
29
36
|
root = zarr.open_group(path, mode="w")
|
|
30
37
|
arr = root.create_array(
|
|
@@ -133,3 +140,54 @@ def test_filter_labels_by_size_works_across_multiple_chunks(tmp_path):
|
|
|
133
140
|
out = np.asarray(root["labels"])
|
|
134
141
|
assert set(np.unique(out).tolist()) == {0, 1, 2}
|
|
135
142
|
assert out[0, 3] == 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_filter_labels_by_size_drops_large_objects(tmp_path):
|
|
146
|
+
from patchworks import filter_labels_by_size
|
|
147
|
+
|
|
148
|
+
array = np.array(
|
|
149
|
+
[
|
|
150
|
+
[1, 0, 2, 2],
|
|
151
|
+
[0, 0, 2, 2],
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
path = str(tmp_path / "labels.zarr")
|
|
155
|
+
root = _write_labels(path, array, chunks=(2, 4))
|
|
156
|
+
|
|
157
|
+
n_kept, n_removed = filter_labels_by_size(path, "labels", max_voxels=2)
|
|
158
|
+
|
|
159
|
+
assert (n_kept, n_removed) == (1, 1)
|
|
160
|
+
assert np.array_equal(
|
|
161
|
+
np.asarray(root["labels"]),
|
|
162
|
+
[
|
|
163
|
+
[1, 0, 0, 0],
|
|
164
|
+
[0, 0, 0, 0],
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_filter_labels_by_size_min_and_max_together_keeps_the_middle(tmp_path):
|
|
170
|
+
from patchworks import filter_labels_by_size
|
|
171
|
+
|
|
172
|
+
array = np.array([1, 0, 2, 2, 0, 3, 3, 3]) # sizes: 1, 2, 3
|
|
173
|
+
path = str(tmp_path / "labels.zarr")
|
|
174
|
+
root = _write_labels(path, array, chunks=(8,))
|
|
175
|
+
|
|
176
|
+
n_kept, n_removed = filter_labels_by_size(
|
|
177
|
+
path, "labels", min_voxels=2, max_voxels=2, relabel=False
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
assert (n_kept, n_removed) == (1, 2)
|
|
181
|
+
assert np.array_equal(np.asarray(root["labels"]), [0, 0, 2, 2, 0, 0, 0, 0])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_filter_labels_by_size_requires_at_least_one_bound(tmp_path):
|
|
185
|
+
import pytest
|
|
186
|
+
from patchworks import filter_labels_by_size
|
|
187
|
+
|
|
188
|
+
array = np.array([1, 1, 0, 2])
|
|
189
|
+
path = str(tmp_path / "labels.zarr")
|
|
190
|
+
_write_labels(path, array, chunks=(4,))
|
|
191
|
+
|
|
192
|
+
with pytest.raises(ValueError, match="min_voxels.*max_voxels"):
|
|
193
|
+
filter_labels_by_size(path, "labels")
|
|
@@ -23,11 +23,13 @@ overlap: [8, 30, 30]
|
|
|
23
23
|
method: "custom"
|
|
24
24
|
# dilate: 2 # optional: pixels to grow labels by after segmentation
|
|
25
25
|
# dilate_gpu: true # optional: dilate via cupy instead of scipy, needs a GPU
|
|
26
|
-
# min_volume: 5.0 # optional: drop objects smaller than this many µm
|
|
27
|
-
#
|
|
28
|
-
# #
|
|
29
|
-
# #
|
|
30
|
-
# #
|
|
26
|
+
# min_volume: 5.0 # optional: drop objects smaller than this many µm³
|
|
27
|
+
# max_volume: 500.0 # optional: drop objects larger than this many µm³
|
|
28
|
+
# # (e.g. several cilia merged into one blob). Either or both
|
|
29
|
+
# # -- runs once on the whole merged image (not per tile,
|
|
30
|
+
# # where an object crossing a tile boundary would look
|
|
31
|
+
# # smaller/larger than it really is) and needs image.zarr
|
|
32
|
+
# # to carry a pixel size -- see common.yaml/convert.
|
|
31
33
|
label_name: "cilia_labels"
|
|
32
34
|
custom:
|
|
33
35
|
module: "patchworks.plugins.dog"
|
|
@@ -32,6 +32,13 @@ overlap: [4, 30, 30]
|
|
|
32
32
|
|
|
33
33
|
method: "cellpose"
|
|
34
34
|
label_name: "cyto_labels"
|
|
35
|
+
# min_volume: 100.0 # optional: drop objects smaller than this many µm³
|
|
36
|
+
# max_volume: 5000.0 # optional: drop objects larger than this many µm³
|
|
37
|
+
# # (e.g. several cells merged into one blob). Either or both
|
|
38
|
+
# # -- runs once on the whole merged image (not per tile,
|
|
39
|
+
# # where an object crossing a tile boundary would look
|
|
40
|
+
# # smaller/larger than it really is) and needs image.zarr
|
|
41
|
+
# # to carry a pixel size -- see common.yaml/convert.
|
|
35
42
|
cellpose:
|
|
36
43
|
model: "cyto3"
|
|
37
44
|
diameter: 30
|
|
@@ -279,6 +279,29 @@ def validate_config(cfg) -> None:
|
|
|
279
279
|
f"(e.g. 5.0); got {min_volume!r}"
|
|
280
280
|
)
|
|
281
281
|
|
|
282
|
+
max_volume = cfg.get("max_volume")
|
|
283
|
+
if max_volume is not None and (
|
|
284
|
+
isinstance(max_volume, bool)
|
|
285
|
+
or not isinstance(max_volume, (int, float))
|
|
286
|
+
or max_volume <= 0
|
|
287
|
+
):
|
|
288
|
+
problems.append(
|
|
289
|
+
"max_volume must be null or a positive number of micrometers³ "
|
|
290
|
+
f"(e.g. 500.0); got {max_volume!r}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if (
|
|
294
|
+
isinstance(min_volume, (int, float))
|
|
295
|
+
and not isinstance(min_volume, bool)
|
|
296
|
+
and isinstance(max_volume, (int, float))
|
|
297
|
+
and not isinstance(max_volume, bool)
|
|
298
|
+
and max_volume <= min_volume
|
|
299
|
+
):
|
|
300
|
+
problems.append(
|
|
301
|
+
f"max_volume ({max_volume}) must be greater than min_volume "
|
|
302
|
+
f"({min_volume}), or nothing would ever survive the filter"
|
|
303
|
+
)
|
|
304
|
+
|
|
282
305
|
method = cfg.get("method", "cellpose")
|
|
283
306
|
if method not in KNOWN_METHODS:
|
|
284
307
|
listed = ", ".join(f'"{m}"' for m in KNOWN_METHODS)
|
|
@@ -20,6 +20,7 @@ from patchworks import (
|
|
|
20
20
|
from patchworks._chunks import _get_available_memory
|
|
21
21
|
from patchworks._volume_filter import (
|
|
22
22
|
filter_labels_by_size,
|
|
23
|
+
max_voxels_for_volume,
|
|
23
24
|
min_voxels_for_volume,
|
|
24
25
|
)
|
|
25
26
|
from patchworks.plugins.ome_zarr import read_pixel_size, register_labels
|
|
@@ -105,24 +106,33 @@ _, n_objects = merge_tile_labels(
|
|
|
105
106
|
# object's size is never judged from just the fragment one tile happened to
|
|
106
107
|
# see. Runs before the pyramid so every level reflects the filtered result.
|
|
107
108
|
min_volume = cfg.get("min_volume")
|
|
108
|
-
|
|
109
|
+
max_volume = cfg.get("max_volume")
|
|
110
|
+
if min_volume or max_volume:
|
|
109
111
|
voxel_size = read_pixel_size(image_store)
|
|
110
112
|
if not voxel_size:
|
|
111
113
|
raise RuntimeError(
|
|
112
|
-
f"min_volume filtering needs calibration in
|
|
113
|
-
"which has none -- set
|
|
114
|
-
"source carries a pixel size at conversion time"
|
|
114
|
+
f"min_volume/max_volume filtering needs calibration in "
|
|
115
|
+
f"{image_store}, which has none -- set both to null, or make "
|
|
116
|
+
"sure the source carries a pixel size at conversion time"
|
|
115
117
|
)
|
|
116
|
-
min_voxels =
|
|
118
|
+
min_voxels = (
|
|
119
|
+
min_voxels_for_volume(min_volume, voxel_size) if min_volume else None
|
|
120
|
+
)
|
|
121
|
+
max_voxels = (
|
|
122
|
+
max_voxels_for_volume(max_volume, voxel_size) if max_volume else None
|
|
123
|
+
)
|
|
117
124
|
n_objects, n_removed = filter_labels_by_size(
|
|
118
125
|
label_group,
|
|
119
126
|
"0",
|
|
120
127
|
min_voxels,
|
|
128
|
+
max_voxels,
|
|
121
129
|
relabel=cfg.get("sequential_labels", True),
|
|
122
130
|
)
|
|
123
131
|
print(
|
|
124
|
-
f"[patchworks] volume filter: dropped {n_removed} object(s)
|
|
125
|
-
f"{min_volume
|
|
132
|
+
f"[patchworks] volume filter: dropped {n_removed} object(s) outside "
|
|
133
|
+
f"[{min_volume or 0}, {max_volume or 'inf'}] µm³ "
|
|
134
|
+
f"([{min_voxels or 0}, {max_voxels or 'inf'}] voxels), "
|
|
135
|
+
f"{n_objects} remain"
|
|
126
136
|
)
|
|
127
137
|
|
|
128
138
|
group = register_labels(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|