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.
Files changed (109) hide show
  1. {patchworks-2.6.8 → patchworks-2.6.10}/PKG-INFO +1 -1
  2. {patchworks-2.6.8 → 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.8 → patchworks-2.6.10}/docs/examples/cellpose_3d.md +11 -2
  5. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/cellpose_3d.py +2 -2
  6. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/merging.md +53 -0
  7. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/snakemake.md +27 -0
  8. {patchworks-2.6.8 → patchworks-2.6.10}/mkdocs.yml +1 -0
  9. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/__init__.py +10 -0
  10. patchworks-2.6.10/src/patchworks/_volume_filter.py +249 -0
  11. patchworks-2.6.10/tests/test_pw.py +123 -0
  12. patchworks-2.6.10/tests/test_volume_filter.py +193 -0
  13. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/config_cilia.yaml +7 -0
  14. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/config_cyto.yaml +7 -0
  15. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/_pw.py +34 -0
  16. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/merge.py +40 -1
  17. patchworks-2.6.8/tests/test_pw.py +0 -57
  18. {patchworks-2.6.8 → patchworks-2.6.10}/.github/workflows/docs.yml +0 -0
  19. {patchworks-2.6.8 → patchworks-2.6.10}/.github/workflows/lint.yml +0 -0
  20. {patchworks-2.6.8 → patchworks-2.6.10}/.github/workflows/release.yml +0 -0
  21. {patchworks-2.6.8 → patchworks-2.6.10}/.gitignore +0 -0
  22. {patchworks-2.6.8 → patchworks-2.6.10}/.markdownlint-cli2.yaml +0 -0
  23. {patchworks-2.6.8 → patchworks-2.6.10}/LICENSE +0 -0
  24. {patchworks-2.6.8 → patchworks-2.6.10}/README.md +0 -0
  25. {patchworks-2.6.8 → patchworks-2.6.10}/cliff.toml +0 -0
  26. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/chunks.md +0 -0
  27. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/cluster.md +0 -0
  28. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/io.md +0 -0
  29. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/merge_tile_labels.md +0 -0
  30. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/plugins/dog.md +0 -0
  31. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/plugins/napari.md +0 -0
  32. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/plugins/ome_zarr.md +0 -0
  33. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/postprocess.md +0 -0
  34. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/relabel.md +0 -0
  35. {patchworks-2.6.8 → patchworks-2.6.10}/docs/api/tile_process.md +0 -0
  36. {patchworks-2.6.8 → patchworks-2.6.10}/docs/assets/logo.png +0 -0
  37. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/cellpose_2d.md +0 -0
  38. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/cellpose_2d.py +0 -0
  39. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/custom.md +0 -0
  40. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/custom_method.py +0 -0
  41. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/dog.md +0 -0
  42. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/dog.py +0 -0
  43. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/standalone_merge.md +0 -0
  44. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/stardist.md +0 -0
  45. {patchworks-2.6.8 → patchworks-2.6.10}/docs/examples/stardist_2d.py +0 -0
  46. {patchworks-2.6.8 → patchworks-2.6.10}/docs/getting_started.md +0 -0
  47. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/custom_segmentation.md +0 -0
  48. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/gpu_distributed.md +0 -0
  49. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/label_relations.md +0 -0
  50. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/measurements.md +0 -0
  51. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/ome_zarr_napari.md +0 -0
  52. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/performance.md +0 -0
  53. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/pitfalls.md +0 -0
  54. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/skip_empty.md +0 -0
  55. {patchworks-2.6.8 → patchworks-2.6.10}/docs/guide/tiling.md +0 -0
  56. {patchworks-2.6.8 → patchworks-2.6.10}/docs/index.md +0 -0
  57. {patchworks-2.6.8 → patchworks-2.6.10}/pyproject.toml +0 -0
  58. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_chunks.py +0 -0
  59. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_cluster.py +0 -0
  60. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_core.py +0 -0
  61. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_distributed.py +0 -0
  62. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_gpu.py +0 -0
  63. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_io.py +0 -0
  64. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_merge.py +0 -0
  65. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_notify.py +0 -0
  66. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_occupancy.py +0 -0
  67. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_postprocess.py +0 -0
  68. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_progress.py +0 -0
  69. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_relabel.py +0 -0
  70. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/_relations.py +0 -0
  71. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/__init__.py +0 -0
  72. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/cellpose.py +0 -0
  73. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/dog.py +0 -0
  74. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/napari.py +0 -0
  75. {patchworks-2.6.8 → patchworks-2.6.10}/src/patchworks/plugins/ome_zarr.py +0 -0
  76. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_allocation.py +0 -0
  77. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_cellpose.py +0 -0
  78. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_core.py +0 -0
  79. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_distributed.py +0 -0
  80. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_dog.py +0 -0
  81. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_gpu.py +0 -0
  82. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_napari.py +0 -0
  83. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_notify.py +0 -0
  84. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_occupancy.py +0 -0
  85. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_ome_zarr.py +0 -0
  86. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_postprocess.py +0 -0
  87. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_progress.py +0 -0
  88. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_relations.py +0 -0
  89. {patchworks-2.6.8 → patchworks-2.6.10}/tests/test_run_multi.py +0 -0
  90. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/README.md +0 -0
  91. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/Snakefile +0 -0
  92. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/common.yaml +0 -0
  93. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/config.yaml +0 -0
  94. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/config_nuclei.yaml +0 -0
  95. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/config/multi.yaml +0 -0
  96. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/pixi.toml +0 -0
  97. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/profile/slurm/config.yaml +0 -0
  98. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/rules/common.smk +0 -0
  99. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/rules/convert.smk +0 -0
  100. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/rules/merge.smk +0 -0
  101. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/rules/segment.smk +0 -0
  102. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/build_occupancy.py +0 -0
  103. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/convert.py +0 -0
  104. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/fetch_model.py +0 -0
  105. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/prepare_tiles.py +0 -0
  106. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/relate.py +0 -0
  107. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/run_multi.py +0 -0
  108. {patchworks-2.6.8 → patchworks-2.6.10}/workflow/scripts/segment_tile.py +0 -0
  109. {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.8
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
@@ -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
+ )