patchworks 2.6.8__tar.gz → 2.6.9__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 (107) hide show
  1. {patchworks-2.6.8 → patchworks-2.6.9}/PKG-INFO +1 -1
  2. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/__init__.py +8 -0
  3. patchworks-2.6.9/src/patchworks/_volume_filter.py +198 -0
  4. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_pw.py +25 -0
  5. patchworks-2.6.9/tests/test_volume_filter.py +135 -0
  6. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/config/config_cilia.yaml +5 -0
  7. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/_pw.py +11 -0
  8. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/merge.py +30 -1
  9. {patchworks-2.6.8 → patchworks-2.6.9}/.github/workflows/docs.yml +0 -0
  10. {patchworks-2.6.8 → patchworks-2.6.9}/.github/workflows/lint.yml +0 -0
  11. {patchworks-2.6.8 → patchworks-2.6.9}/.github/workflows/release.yml +0 -0
  12. {patchworks-2.6.8 → patchworks-2.6.9}/.gitignore +0 -0
  13. {patchworks-2.6.8 → patchworks-2.6.9}/.markdownlint-cli2.yaml +0 -0
  14. {patchworks-2.6.8 → patchworks-2.6.9}/LICENSE +0 -0
  15. {patchworks-2.6.8 → patchworks-2.6.9}/README.md +0 -0
  16. {patchworks-2.6.8 → patchworks-2.6.9}/cliff.toml +0 -0
  17. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/chunks.md +0 -0
  18. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/cluster.md +0 -0
  19. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/io.md +0 -0
  20. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/merge_tile_labels.md +0 -0
  21. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/plugins/cellpose.md +0 -0
  22. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/plugins/dog.md +0 -0
  23. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/plugins/napari.md +0 -0
  24. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/plugins/ome_zarr.md +0 -0
  25. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/postprocess.md +0 -0
  26. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/relabel.md +0 -0
  27. {patchworks-2.6.8 → patchworks-2.6.9}/docs/api/tile_process.md +0 -0
  28. {patchworks-2.6.8 → patchworks-2.6.9}/docs/assets/logo.png +0 -0
  29. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/cellpose_2d.md +0 -0
  30. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/cellpose_2d.py +0 -0
  31. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/cellpose_3d.md +0 -0
  32. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/cellpose_3d.py +0 -0
  33. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/custom.md +0 -0
  34. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/custom_method.py +0 -0
  35. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/dog.md +0 -0
  36. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/dog.py +0 -0
  37. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/standalone_merge.md +0 -0
  38. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/stardist.md +0 -0
  39. {patchworks-2.6.8 → patchworks-2.6.9}/docs/examples/stardist_2d.py +0 -0
  40. {patchworks-2.6.8 → patchworks-2.6.9}/docs/getting_started.md +0 -0
  41. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/custom_segmentation.md +0 -0
  42. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/gpu_distributed.md +0 -0
  43. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/label_relations.md +0 -0
  44. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/measurements.md +0 -0
  45. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/merging.md +0 -0
  46. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/ome_zarr_napari.md +0 -0
  47. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/performance.md +0 -0
  48. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/pitfalls.md +0 -0
  49. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/skip_empty.md +0 -0
  50. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/snakemake.md +0 -0
  51. {patchworks-2.6.8 → patchworks-2.6.9}/docs/guide/tiling.md +0 -0
  52. {patchworks-2.6.8 → patchworks-2.6.9}/docs/index.md +0 -0
  53. {patchworks-2.6.8 → patchworks-2.6.9}/mkdocs.yml +0 -0
  54. {patchworks-2.6.8 → patchworks-2.6.9}/pyproject.toml +0 -0
  55. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_chunks.py +0 -0
  56. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_cluster.py +0 -0
  57. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_core.py +0 -0
  58. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_distributed.py +0 -0
  59. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_gpu.py +0 -0
  60. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_io.py +0 -0
  61. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_merge.py +0 -0
  62. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_notify.py +0 -0
  63. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_occupancy.py +0 -0
  64. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_postprocess.py +0 -0
  65. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_progress.py +0 -0
  66. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_relabel.py +0 -0
  67. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/_relations.py +0 -0
  68. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/plugins/__init__.py +0 -0
  69. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/plugins/cellpose.py +0 -0
  70. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/plugins/dog.py +0 -0
  71. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/plugins/napari.py +0 -0
  72. {patchworks-2.6.8 → patchworks-2.6.9}/src/patchworks/plugins/ome_zarr.py +0 -0
  73. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_allocation.py +0 -0
  74. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_cellpose.py +0 -0
  75. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_core.py +0 -0
  76. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_distributed.py +0 -0
  77. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_dog.py +0 -0
  78. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_gpu.py +0 -0
  79. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_napari.py +0 -0
  80. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_notify.py +0 -0
  81. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_occupancy.py +0 -0
  82. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_ome_zarr.py +0 -0
  83. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_postprocess.py +0 -0
  84. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_progress.py +0 -0
  85. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_relations.py +0 -0
  86. {patchworks-2.6.8 → patchworks-2.6.9}/tests/test_run_multi.py +0 -0
  87. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/README.md +0 -0
  88. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/Snakefile +0 -0
  89. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/config/common.yaml +0 -0
  90. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/config/config.yaml +0 -0
  91. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/config/config_cyto.yaml +0 -0
  92. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/config/config_nuclei.yaml +0 -0
  93. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/config/multi.yaml +0 -0
  94. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/pixi.toml +0 -0
  95. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/profile/slurm/config.yaml +0 -0
  96. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/rules/common.smk +0 -0
  97. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/rules/convert.smk +0 -0
  98. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/rules/merge.smk +0 -0
  99. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/rules/segment.smk +0 -0
  100. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/build_occupancy.py +0 -0
  101. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/convert.py +0 -0
  102. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/fetch_model.py +0 -0
  103. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/prepare_tiles.py +0 -0
  104. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/relate.py +0 -0
  105. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/run_multi.py +0 -0
  106. {patchworks-2.6.8 → patchworks-2.6.9}/workflow/scripts/segment_tile.py +0 -0
  107. {patchworks-2.6.8 → patchworks-2.6.9}/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.9
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
@@ -55,6 +55,11 @@ 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
+ min_voxels_for_volume,
61
+ voxel_volume,
62
+ )
58
63
 
59
64
  try:
60
65
  __version__ = _pkg_version("patchworks")
@@ -85,4 +90,7 @@ __all__ = [
85
90
  "create_stage",
86
91
  "stage_tile",
87
92
  "dilate_labels",
93
+ "filter_labels_by_size",
94
+ "min_voxels_for_volume",
95
+ "voxel_volume",
88
96
  ]
@@ -0,0 +1,198 @@
1
+ """Drop label objects below a volume threshold, 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 *within one tile*).
7
+
8
+ Two-pass streaming algorithm, mirroring :func:`patchworks.relabel_sequential_zarr`
9
+ -- safe for arrays far larger than RAM. Pass 1 does a chunk-wise
10
+ unique+count to get every label's voxel count (bounded memory: a Python
11
+ 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.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import math
21
+ from itertools import product as _iproduct
22
+
23
+ import numpy as np
24
+ import zarr
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _LUT_WARN_THRESHOLD = 100_000_000 # warn when max_label > 100 M (LUT > 800 MB)
29
+
30
+
31
+ def voxel_volume(voxel_size: "dict[str, float]") -> float:
32
+ """Physical volume of one voxel, from a per-axis calibration.
33
+
34
+ Axes missing from *voxel_size* are treated as 1.0 -- e.g. a 2-D
35
+ calibration with no ``z`` gives an area, not a bogus volume shrunk by a
36
+ fake axis. Units follow whatever *voxel_size* is in (micrometers for
37
+ :func:`patchworks.plugins.ome_zarr.read_pixel_size`).
38
+
39
+ Parameters
40
+ ----------
41
+ voxel_size : dict
42
+ Per-axis physical size, e.g. ``{"z": .., "y": .., "x": ..}``.
43
+
44
+ Returns
45
+ -------
46
+ float
47
+ Product of the given axis sizes.
48
+
49
+ Examples
50
+ --------
51
+ >>> voxel_volume({"z": 0.24, "y": 0.10833, "x": 0.10833})
52
+ 0.0028164933359999997
53
+ """
54
+ vol = 1.0
55
+ for size in voxel_size.values():
56
+ vol *= size
57
+ return vol
58
+
59
+
60
+ def min_voxels_for_volume(
61
+ min_volume: float, voxel_size: "dict[str, float]"
62
+ ) -> int:
63
+ """Convert a physical volume threshold to a voxel count.
64
+
65
+ Rounds up: an object must reach *min_volume* to survive, so a partial
66
+ voxel's worth of extra volume should not tip it over the line.
67
+
68
+ Parameters
69
+ ----------
70
+ min_volume : float
71
+ Minimum object volume to keep, in the same physical units as
72
+ *voxel_size* (micrometers³ for an NGFF calibration).
73
+ voxel_size : dict
74
+ Per-axis physical size -- see :func:`voxel_volume`.
75
+
76
+ Returns
77
+ -------
78
+ int
79
+ Minimum voxel count for an object to survive filtering.
80
+
81
+ Examples
82
+ --------
83
+ >>> min_voxels_for_volume(5.0, {"z": 0.24, "y": 0.10833, "x": 0.10833})
84
+ 1776
85
+ """
86
+ return math.ceil(min_volume / voxel_volume(voxel_size))
87
+
88
+
89
+ def _chunk_slices(shape, chunks):
90
+ """Every zarr chunk's index expression, in all dimensions.
91
+
92
+ Iterating actual chunk boundaries (rather than z-slabs) keeps each read
93
+ bounded to one chunk's worth of memory, whatever the array's shape.
94
+ """
95
+ n_per_dim = [(s + c - 1) // c for s, c in zip(shape, chunks)]
96
+ return [
97
+ tuple(
98
+ slice(i * c, min((i + 1) * c, s))
99
+ for i, c, s in zip(idx, chunks, shape)
100
+ )
101
+ for idx in _iproduct(*[range(n) for n in n_per_dim])
102
+ ]
103
+
104
+
105
+ def filter_labels_by_size(
106
+ store_path: str,
107
+ component: str,
108
+ min_voxels: int,
109
+ *,
110
+ relabel: bool = True,
111
+ ) -> "tuple[int, int]":
112
+ """Drop label objects smaller than *min_voxels*, in place.
113
+
114
+ Two-pass streaming scan (see module docstring) -- the array never has
115
+ to fit in RAM.
116
+
117
+ Parameters
118
+ ----------
119
+ store_path : str
120
+ Path to the zarr store containing the label array.
121
+ component : str
122
+ 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.
127
+ relabel : bool, optional
128
+ 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.
132
+
133
+ Returns
134
+ -------
135
+ tuple of int
136
+ ``(n_kept, n_removed)``.
137
+
138
+ Examples
139
+ --------
140
+ >>> import zarr
141
+ >>> root = zarr.open_group("labels.zarr", mode="w") # doctest: +SKIP
142
+ >>> root.create_array(
143
+ ... "labels", shape=(4, 4), chunks=(4, 4), dtype="int32"
144
+ ... )[:] = [
145
+ ... [0, 1, 1, 0],
146
+ ... [0, 1, 1, 0],
147
+ ... [0, 0, 0, 2],
148
+ ... [0, 0, 0, 0],
149
+ ... ] # doctest: +SKIP
150
+ >>> filter_labels_by_size("labels.zarr", "labels", min_voxels=2) # doctest: +SKIP
151
+ (1, 1)
152
+ """
153
+ root = zarr.open_group(store_path, mode="r+")
154
+ z = root[component]
155
+ slices = _chunk_slices(z.shape, z.chunks)
156
+
157
+ counts: "dict[int, int]" = {}
158
+ for sl in slices:
159
+ ids, n = np.unique(np.asarray(z[sl]), return_counts=True)
160
+ for label_id, count in zip(ids.tolist(), n.tolist()):
161
+ if label_id == 0:
162
+ continue
163
+ counts[label_id] = counts.get(label_id, 0) + count
164
+
165
+ kept = sorted(i for i, c in counts.items() if c >= min_voxels)
166
+ n_kept = len(kept)
167
+ n_removed = len(counts) - n_kept
168
+
169
+ # Sized to the largest id *seen*, not just the largest surviving one --
170
+ # a removed object's id can still exceed every kept id and must stay
171
+ # in bounds so the LUT gather below maps it to 0 rather than indexing
172
+ # past the end.
173
+ max_label = max(counts) if counts else 0
174
+ if max_label > _LUT_WARN_THRESHOLD:
175
+ logger.warning(
176
+ "filter_labels_by_size: max_label=%d -> LUT size ~%.0f MB.",
177
+ max_label,
178
+ max_label * 8 / 1024**2,
179
+ )
180
+ lut = np.zeros(max_label + 1, dtype=np.int64)
181
+ if kept:
182
+ lut[kept] = np.arange(1, n_kept + 1) if relabel else np.asarray(kept)
183
+
184
+ max_out = n_kept if relabel else max_label
185
+ out_dtype = np.uint16 if max_out < np.iinfo(np.uint16).max else np.uint32
186
+ for sl in slices:
187
+ block = np.asarray(z[sl])
188
+ z[sl] = lut[block].astype(out_dtype)
189
+
190
+ logger.info(
191
+ "filter_labels_by_size: dropped %d/%d object(s) under %d voxels, "
192
+ "%d remain",
193
+ n_removed,
194
+ len(counts),
195
+ min_voxels,
196
+ n_kept,
197
+ )
198
+ return n_kept, n_removed
@@ -55,3 +55,28 @@ def test_with_voxel_size_never_overrides_an_explicit_value(tmp_path):
55
55
  kwargs = _with_voxel_size(cellpose_fn, {"voxel_size": explicit}, cfg)
56
56
 
57
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"})
@@ -0,0 +1,135 @@
1
+ """Tests for the global, post-merge label volume filter."""
2
+
3
+ import numpy as np
4
+ import zarr
5
+
6
+
7
+ def test_voxel_volume_multiplies_given_axes():
8
+ from patchworks import voxel_volume
9
+
10
+ assert voxel_volume({"z": 0.24, "y": 0.10833, "x": 0.10833}) == (
11
+ 0.24 * 0.10833 * 0.10833
12
+ )
13
+
14
+
15
+ def test_voxel_volume_missing_axis_treated_as_one():
16
+ from patchworks import voxel_volume
17
+
18
+ assert voxel_volume({"y": 0.5, "x": 0.5}) == 0.25
19
+
20
+
21
+ def test_min_voxels_for_volume_rounds_up():
22
+ from patchworks import min_voxels_for_volume
23
+
24
+ calibration = {"z": 0.24, "y": 0.10833, "x": 0.10833}
25
+ assert min_voxels_for_volume(5.0, calibration) == 1776
26
+
27
+
28
+ def _write_labels(path, array, chunks):
29
+ root = zarr.open_group(path, mode="w")
30
+ arr = root.create_array(
31
+ "labels", shape=array.shape, chunks=chunks, dtype="int32"
32
+ )
33
+ arr[:] = array
34
+ return root
35
+
36
+
37
+ def test_filter_labels_by_size_drops_small_objects(tmp_path):
38
+ from patchworks import filter_labels_by_size
39
+
40
+ array = np.array(
41
+ [
42
+ [0, 1, 1, 0],
43
+ [0, 1, 1, 0],
44
+ [0, 0, 0, 2],
45
+ [0, 0, 0, 0],
46
+ ]
47
+ )
48
+ path = str(tmp_path / "labels.zarr")
49
+ root = _write_labels(path, array, chunks=(4, 4))
50
+
51
+ n_kept, n_removed = filter_labels_by_size(path, "labels", min_voxels=2)
52
+
53
+ assert (n_kept, n_removed) == (1, 1)
54
+ assert np.array_equal(
55
+ np.asarray(root["labels"]),
56
+ [
57
+ [0, 1, 1, 0],
58
+ [0, 1, 1, 0],
59
+ [0, 0, 0, 0],
60
+ [0, 0, 0, 0],
61
+ ],
62
+ )
63
+
64
+
65
+ def test_filter_labels_by_size_relabels_surviving_ids_sequentially(tmp_path):
66
+ """Dropping id 1 must not leave id 2 with a gap where 1 used to be."""
67
+ from patchworks import filter_labels_by_size
68
+
69
+ array = np.array(
70
+ [
71
+ [1, 0, 2, 2],
72
+ [0, 0, 2, 2],
73
+ ]
74
+ )
75
+ path = str(tmp_path / "labels.zarr")
76
+ root = _write_labels(path, array, chunks=(2, 4))
77
+
78
+ filter_labels_by_size(path, "labels", min_voxels=2)
79
+
80
+ assert np.array_equal(
81
+ np.asarray(root["labels"]),
82
+ [
83
+ [0, 0, 1, 1],
84
+ [0, 0, 1, 1],
85
+ ],
86
+ )
87
+
88
+
89
+ def test_filter_labels_by_size_relabel_false_keeps_original_ids(tmp_path):
90
+ from patchworks import filter_labels_by_size
91
+
92
+ array = np.array(
93
+ [
94
+ [1, 0, 5, 5],
95
+ [0, 0, 5, 5],
96
+ ]
97
+ )
98
+ path = str(tmp_path / "labels.zarr")
99
+ root = _write_labels(path, array, chunks=(2, 4))
100
+
101
+ filter_labels_by_size(path, "labels", min_voxels=2, relabel=False)
102
+
103
+ assert np.array_equal(
104
+ np.asarray(root["labels"]),
105
+ [
106
+ [0, 0, 5, 5],
107
+ [0, 0, 5, 5],
108
+ ],
109
+ )
110
+
111
+
112
+ def test_filter_labels_by_size_works_across_multiple_chunks(tmp_path):
113
+ """A removed object's id can exceed every surviving id and still must
114
+
115
+ stay in bounds of the LUT built from the largest id *seen*, not just
116
+ the largest surviving one -- this is the case a single-chunk array
117
+ can't exercise, since chunking is what makes the scan streaming at all.
118
+ """
119
+ from patchworks import filter_labels_by_size
120
+
121
+ array = np.zeros((4, 4), dtype="int32")
122
+ array[0:2, 0:2] = 1 # 4 voxels, kept
123
+ array[2:4, 2:4] = 2 # 4 voxels, kept
124
+ array[0, 3] = (
125
+ 3 # 1 voxel, dropped -- id 3 exceeds nothing survives above it
126
+ )
127
+ path = str(tmp_path / "labels.zarr")
128
+ root = _write_labels(path, array, chunks=(2, 2))
129
+
130
+ n_kept, n_removed = filter_labels_by_size(path, "labels", min_voxels=2)
131
+
132
+ assert (n_kept, n_removed) == (2, 1)
133
+ out = np.asarray(root["labels"])
134
+ assert set(np.unique(out).tolist()) == {0, 1, 2}
135
+ assert out[0, 3] == 0
@@ -23,6 +23,11 @@ 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
31
  label_name: "cilia_labels"
27
32
  custom:
28
33
  module: "patchworks.plugins.dog"
@@ -268,6 +268,17 @@ def validate_config(cfg) -> None:
268
268
  "which segments a single channel"
269
269
  )
270
270
 
271
+ min_volume = cfg.get("min_volume")
272
+ if min_volume is not None and (
273
+ isinstance(min_volume, bool)
274
+ or not isinstance(min_volume, (int, float))
275
+ or min_volume <= 0
276
+ ):
277
+ problems.append(
278
+ "min_volume must be null or a positive number of micrometers³ "
279
+ f"(e.g. 5.0); got {min_volume!r}"
280
+ )
281
+
271
282
  method = cfg.get("method", "cellpose")
272
283
  if method not in KNOWN_METHODS:
273
284
  listed = ", ".join(f'"{m}"' for m in KNOWN_METHODS)
@@ -18,7 +18,11 @@ from patchworks import (
18
18
  safe_worker_count,
19
19
  )
20
20
  from patchworks._chunks import _get_available_memory
21
- from patchworks.plugins.ome_zarr import register_labels
21
+ from patchworks._volume_filter import (
22
+ filter_labels_by_size,
23
+ min_voxels_for_volume,
24
+ )
25
+ from patchworks.plugins.ome_zarr import read_pixel_size, register_labels
22
26
 
23
27
  from _pw import load_tiles_json, stage_path, start_log
24
28
 
@@ -96,6 +100,31 @@ _, n_objects = merge_tile_labels(
96
100
  return_count=True,
97
101
  label_counts=label_counts,
98
102
  )
103
+
104
+ # Global, exact volume filter -- runs once on the fully merged array so an
105
+ # object's size is never judged from just the fragment one tile happened to
106
+ # see. Runs before the pyramid so every level reflects the filtered result.
107
+ min_volume = cfg.get("min_volume")
108
+ if min_volume:
109
+ voxel_size = read_pixel_size(image_store)
110
+ if not voxel_size:
111
+ 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"
115
+ )
116
+ min_voxels = min_voxels_for_volume(min_volume, voxel_size)
117
+ n_objects, n_removed = filter_labels_by_size(
118
+ label_group,
119
+ "0",
120
+ min_voxels,
121
+ relabel=cfg.get("sequential_labels", True),
122
+ )
123
+ print(
124
+ f"[patchworks] volume filter: dropped {n_removed} object(s) under "
125
+ f"{min_volume} µm³ ({min_voxels} voxels), {n_objects} remain"
126
+ )
127
+
99
128
  group = register_labels(
100
129
  image_store,
101
130
  label_name,
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