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.
Files changed (108) hide show
  1. {patchworks-2.6.9 → patchworks-2.6.10}/PKG-INFO +1 -1
  2. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/plugins/cellpose.md +2 -0
  3. patchworks-2.6.10/docs/api/volume_filter.md +9 -0
  4. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/cellpose_3d.md +11 -2
  5. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/cellpose_3d.py +2 -2
  6. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/merging.md +53 -0
  7. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/snakemake.md +27 -0
  8. {patchworks-2.6.9 → patchworks-2.6.10}/mkdocs.yml +1 -0
  9. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/__init__.py +2 -0
  10. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_volume_filter.py +68 -17
  11. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_pw.py +41 -0
  12. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_volume_filter.py +58 -0
  13. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/config_cilia.yaml +7 -5
  14. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/config_cyto.yaml +7 -0
  15. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/_pw.py +23 -0
  16. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/merge.py +17 -7
  17. {patchworks-2.6.9 → patchworks-2.6.10}/.github/workflows/docs.yml +0 -0
  18. {patchworks-2.6.9 → patchworks-2.6.10}/.github/workflows/lint.yml +0 -0
  19. {patchworks-2.6.9 → patchworks-2.6.10}/.github/workflows/release.yml +0 -0
  20. {patchworks-2.6.9 → patchworks-2.6.10}/.gitignore +0 -0
  21. {patchworks-2.6.9 → patchworks-2.6.10}/.markdownlint-cli2.yaml +0 -0
  22. {patchworks-2.6.9 → patchworks-2.6.10}/LICENSE +0 -0
  23. {patchworks-2.6.9 → patchworks-2.6.10}/README.md +0 -0
  24. {patchworks-2.6.9 → patchworks-2.6.10}/cliff.toml +0 -0
  25. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/chunks.md +0 -0
  26. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/cluster.md +0 -0
  27. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/io.md +0 -0
  28. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/merge_tile_labels.md +0 -0
  29. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/plugins/dog.md +0 -0
  30. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/plugins/napari.md +0 -0
  31. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/plugins/ome_zarr.md +0 -0
  32. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/postprocess.md +0 -0
  33. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/relabel.md +0 -0
  34. {patchworks-2.6.9 → patchworks-2.6.10}/docs/api/tile_process.md +0 -0
  35. {patchworks-2.6.9 → patchworks-2.6.10}/docs/assets/logo.png +0 -0
  36. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/cellpose_2d.md +0 -0
  37. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/cellpose_2d.py +0 -0
  38. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/custom.md +0 -0
  39. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/custom_method.py +0 -0
  40. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/dog.md +0 -0
  41. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/dog.py +0 -0
  42. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/standalone_merge.md +0 -0
  43. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/stardist.md +0 -0
  44. {patchworks-2.6.9 → patchworks-2.6.10}/docs/examples/stardist_2d.py +0 -0
  45. {patchworks-2.6.9 → patchworks-2.6.10}/docs/getting_started.md +0 -0
  46. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/custom_segmentation.md +0 -0
  47. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/gpu_distributed.md +0 -0
  48. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/label_relations.md +0 -0
  49. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/measurements.md +0 -0
  50. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/ome_zarr_napari.md +0 -0
  51. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/performance.md +0 -0
  52. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/pitfalls.md +0 -0
  53. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/skip_empty.md +0 -0
  54. {patchworks-2.6.9 → patchworks-2.6.10}/docs/guide/tiling.md +0 -0
  55. {patchworks-2.6.9 → patchworks-2.6.10}/docs/index.md +0 -0
  56. {patchworks-2.6.9 → patchworks-2.6.10}/pyproject.toml +0 -0
  57. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_chunks.py +0 -0
  58. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_cluster.py +0 -0
  59. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_core.py +0 -0
  60. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_distributed.py +0 -0
  61. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_gpu.py +0 -0
  62. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_io.py +0 -0
  63. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_merge.py +0 -0
  64. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_notify.py +0 -0
  65. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_occupancy.py +0 -0
  66. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_postprocess.py +0 -0
  67. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_progress.py +0 -0
  68. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_relabel.py +0 -0
  69. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/_relations.py +0 -0
  70. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/__init__.py +0 -0
  71. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/cellpose.py +0 -0
  72. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/dog.py +0 -0
  73. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/napari.py +0 -0
  74. {patchworks-2.6.9 → patchworks-2.6.10}/src/patchworks/plugins/ome_zarr.py +0 -0
  75. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_allocation.py +0 -0
  76. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_cellpose.py +0 -0
  77. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_core.py +0 -0
  78. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_distributed.py +0 -0
  79. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_dog.py +0 -0
  80. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_gpu.py +0 -0
  81. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_napari.py +0 -0
  82. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_notify.py +0 -0
  83. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_occupancy.py +0 -0
  84. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_ome_zarr.py +0 -0
  85. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_postprocess.py +0 -0
  86. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_progress.py +0 -0
  87. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_relations.py +0 -0
  88. {patchworks-2.6.9 → patchworks-2.6.10}/tests/test_run_multi.py +0 -0
  89. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/README.md +0 -0
  90. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/Snakefile +0 -0
  91. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/common.yaml +0 -0
  92. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/config.yaml +0 -0
  93. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/config_nuclei.yaml +0 -0
  94. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/config/multi.yaml +0 -0
  95. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/pixi.toml +0 -0
  96. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/profile/slurm/config.yaml +0 -0
  97. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/rules/common.smk +0 -0
  98. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/rules/convert.smk +0 -0
  99. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/rules/merge.smk +0 -0
  100. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/rules/segment.smk +0 -0
  101. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/build_occupancy.py +0 -0
  102. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/convert.py +0 -0
  103. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/fetch_model.py +0 -0
  104. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/prepare_tiles.py +0 -0
  105. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/relate.py +0 -0
  106. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/run_multi.py +0 -0
  107. {patchworks-2.6.9 → patchworks-2.6.10}/workflow/scripts/segment_tile.py +0 -0
  108. {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.9
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
@@ -1,3 +1,5 @@
1
1
  # Cellpose plugin
2
2
 
3
3
  ::: patchworks.plugins.cellpose.cellpose_fn
4
+
5
+ ::: patchworks.plugins.cellpose.cellpose_anisotropy
@@ -0,0 +1,9 @@
1
+ # Volume filtering
2
+
3
+ ::: patchworks.filter_labels_by_size
4
+
5
+ ::: patchworks.voxel_volume
6
+
7
+ ::: patchworks.min_voxels_for_volume
8
+
9
+ ::: patchworks.max_voxels_for_volume
@@ -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=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=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 below a volume threshold, in place, after merge.
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 *within one tile*).
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 under the threshold -- optionally renumbering the
13
- survivors to a contiguous range in the same pass -- and applies it chunk by
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 smaller than *min_voxels*, in place.
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). Use
125
- :func:`min_voxels_for_volume` to derive this from a physical
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 small ones (default ``True``) --
130
- otherwise the removed ids leave permanent gaps and survivors keep
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(i for i, c in counts.items() if c >= min_voxels)
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) under %d voxels, "
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
- min_voxels,
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
- # # Runs once on the whole merged image (not per tile, where
28
- # # an object crossing a tile boundary would look smaller
29
- # # than it really is) and needs image.zarr to carry a pixel
30
- # # size -- see common.yaml/convert.
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
- if min_volume:
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 {image_store}, "
113
- "which has none -- set min_volume: null, or make sure the "
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 = min_voxels_for_volume(min_volume, voxel_size)
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) under "
125
- f"{min_volume} µm³ ({min_voxels} voxels), {n_objects} remain"
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