patchworks 2.6.7__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.
- {patchworks-2.6.7 → patchworks-2.6.9}/PKG-INFO +1 -1
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/ome_zarr_napari.md +14 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/__init__.py +8 -0
- patchworks-2.6.9/src/patchworks/_volume_filter.py +198 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/plugins/cellpose.py +61 -1
- patchworks-2.6.9/tests/test_cellpose.py +49 -0
- patchworks-2.6.9/tests/test_pw.py +82 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_run_multi.py +26 -0
- patchworks-2.6.9/tests/test_volume_filter.py +135 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/config/config_cilia.yaml +5 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/pixi.toml +14 -1
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/_pw.py +17 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/merge.py +30 -1
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/relate.py +1 -3
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/run_multi.py +3 -1
- patchworks-2.6.9/workflow/scripts/view.py +47 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/.github/workflows/docs.yml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/.github/workflows/lint.yml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/.github/workflows/release.yml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/.gitignore +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/LICENSE +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/README.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/cliff.toml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/chunks.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/cluster.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/io.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/postprocess.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/relabel.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/api/tile_process.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/assets/logo.png +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/custom.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/custom_method.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/dog.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/dog.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/stardist.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/getting_started.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/label_relations.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/measurements.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/merging.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/performance.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/snakemake.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/guide/tiling.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/docs/index.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/mkdocs.yml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/pyproject.toml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_core.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_distributed.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_io.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_merge.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_notify.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_occupancy.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_progress.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/_relations.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/plugins/dog.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/src/patchworks/plugins/ome_zarr.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_allocation.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_core.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_distributed.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_dog.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_gpu.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_napari.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_notify.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_occupancy.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_postprocess.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_progress.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/tests/test_relations.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/README.md +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/Snakefile +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/config/common.yaml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/config/config.yaml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/config/config_cyto.yaml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/config/multi.yaml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/rules/common.smk +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/rules/convert.smk +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/rules/merge.smk +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/rules/segment.smk +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/build_occupancy.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/convert.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/prepare_tiles.py +0 -0
- {patchworks-2.6.7 → patchworks-2.6.9}/workflow/scripts/segment_tile.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.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
|
|
@@ -229,6 +229,20 @@ just one channel instead: `view_in_napari("scan.zarr", channel=0)`.
|
|
|
229
229
|
napari ships in `patchworks[all]`, or install just it with
|
|
230
230
|
`pip install "patchworks[napari]"`.
|
|
231
231
|
|
|
232
|
+
!!! tip "From the cluster workflow"
|
|
233
|
+
`workflow/pixi.toml` has a separate `viewer` environment (napari's Qt/GUI
|
|
234
|
+
dependencies are kept out of the default headless workflow env on
|
|
235
|
+
purpose):
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
pixi install -e viewer
|
|
239
|
+
pixi run -e viewer napari /path/to/work_dir/image.zarr
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
This wraps `view_in_napari` with its `labels=` argument left at the
|
|
243
|
+
default, so every label group in the store loads automatically — needs a
|
|
244
|
+
display (`ssh -X`, or a remote-desktop/VNC session).
|
|
245
|
+
|
|
232
246
|
!!! tip "Measuring all the objects"
|
|
233
247
|
Once labels are loaded, use
|
|
234
248
|
[napari-chunked-regionprops](https://github.com/imcf/napari-chunked-regionprops)'s
|
|
@@ -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
|
|
@@ -75,6 +75,40 @@ def _require_cellpose():
|
|
|
75
75
|
)
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def cellpose_anisotropy(voxel_size: dict[str, float]) -> float | None:
|
|
79
|
+
"""Cellpose's ``anisotropy`` (z voxel size / lateral voxel size) from a calibration.
|
|
80
|
+
|
|
81
|
+
``do_3D`` assumes isotropic voxels unless told otherwise: without this,
|
|
82
|
+
Cellpose builds its 3-D flow-field consensus across z-planes it thinks
|
|
83
|
+
are spaced the same as the xy pixels, which for real data rarely holds
|
|
84
|
+
and fragments or distorts objects across z. Getting this wrong does not
|
|
85
|
+
fail loudly -- it just produces a subtly (or not so subtly) wrong
|
|
86
|
+
segmentation, which is why deriving it beats retyping it.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
voxel_size : dict
|
|
91
|
+
Image calibration, e.g. from
|
|
92
|
+
:func:`patchworks.plugins.ome_zarr.read_pixel_size`. Needs ``z`` and
|
|
93
|
+
``x`` (or ``y``) for the lateral size.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
float or None
|
|
98
|
+
``z / lateral``, or None if the calibration lacks what is needed.
|
|
99
|
+
|
|
100
|
+
Examples
|
|
101
|
+
--------
|
|
102
|
+
>>> cellpose_anisotropy({"z": 0.24, "y": 0.10833, "x": 0.10833})
|
|
103
|
+
2.215452783162559
|
|
104
|
+
"""
|
|
105
|
+
lateral = voxel_size.get("x") or voxel_size.get("y")
|
|
106
|
+
z = voxel_size.get("z")
|
|
107
|
+
if not lateral or not z:
|
|
108
|
+
return None
|
|
109
|
+
return z / lateral
|
|
110
|
+
|
|
111
|
+
|
|
78
112
|
def cellpose_fn(
|
|
79
113
|
model: str = "cyto3",
|
|
80
114
|
*,
|
|
@@ -83,6 +117,7 @@ def cellpose_fn(
|
|
|
83
117
|
do_3D: bool = False,
|
|
84
118
|
channels: list[int] | None = None,
|
|
85
119
|
channel_axis: int | None = None,
|
|
120
|
+
voxel_size: dict[str, float] | None = None,
|
|
86
121
|
**cellpose_kwargs: Any,
|
|
87
122
|
) -> Callable[[np.ndarray], np.ndarray]:
|
|
88
123
|
"""Return a ready-to-use Cellpose function for ``tile_process``.
|
|
@@ -107,6 +142,15 @@ def cellpose_fn(
|
|
|
107
142
|
channel_axis:
|
|
108
143
|
*Cellpose 4 only.* Index of the channel axis in the input array.
|
|
109
144
|
``None`` → greyscale input.
|
|
145
|
+
voxel_size:
|
|
146
|
+
Physical voxel size as ``{"z": .., "y": .., "x": ..}``. When
|
|
147
|
+
``do_3D`` is set and ``anisotropy`` is not already in
|
|
148
|
+
``cellpose_kwargs``, it is derived from this via
|
|
149
|
+
:func:`cellpose_anisotropy` -- an explicit ``anisotropy=`` always
|
|
150
|
+
wins. The Snakemake workflow passes the image's own calibration
|
|
151
|
+
automatically; from the API,
|
|
152
|
+
:func:`patchworks.plugins.ome_zarr.read_pixel_size` reads it from a
|
|
153
|
+
store.
|
|
110
154
|
**cellpose_kwargs:
|
|
111
155
|
Extra kwargs forwarded to ``model.eval()``
|
|
112
156
|
(e.g. ``flow_threshold``, ``cellprob_threshold``, ``anisotropy``).
|
|
@@ -128,15 +172,31 @@ def cellpose_fn(
|
|
|
128
172
|
>>> fn = cellpose_fn("nuclei", diameter=15)
|
|
129
173
|
>>> result = tile_process("image.zarr", fn, channel=1)
|
|
130
174
|
|
|
131
|
-
3-D with anisotropy:
|
|
175
|
+
3-D with an explicit anisotropy:
|
|
132
176
|
|
|
133
177
|
>>> fn = cellpose_fn("cyto3", gpu=True, do_3D=True, anisotropy=3.0, diameter=20)
|
|
134
178
|
>>> from functools import partial
|
|
135
179
|
>>> from patchworks import auto_tile_shape_cellpose, tile_process
|
|
136
180
|
>>> tile_fn = partial(auto_tile_shape_cellpose, do_3D=True, use_gpu=True, diameter=20)
|
|
137
181
|
>>> result = tile_process("image.zarr", fn, tile_shape=tile_fn, overlap=10)
|
|
182
|
+
|
|
183
|
+
3-D with anisotropy derived from the image's own calibration:
|
|
184
|
+
|
|
185
|
+
>>> fn = cellpose_fn("cyto3", gpu=True, do_3D=True, diameter=20,
|
|
186
|
+
... voxel_size={"z": 0.24, "y": 0.10833, "x": 0.10833})
|
|
138
187
|
"""
|
|
139
188
|
_require_cellpose()
|
|
189
|
+
if do_3D and voxel_size and "anisotropy" not in cellpose_kwargs:
|
|
190
|
+
anisotropy = cellpose_anisotropy(voxel_size)
|
|
191
|
+
if anisotropy is not None:
|
|
192
|
+
logger.info(
|
|
193
|
+
"anisotropy derived from image calibration: %.4g "
|
|
194
|
+
"(z=%.4g, lateral=%.4g)",
|
|
195
|
+
anisotropy,
|
|
196
|
+
voxel_size.get("z"),
|
|
197
|
+
voxel_size.get("x") or voxel_size.get("y"),
|
|
198
|
+
)
|
|
199
|
+
cellpose_kwargs = {**cellpose_kwargs, "anisotropy": anisotropy}
|
|
140
200
|
cfg = _make_config(
|
|
141
201
|
model, gpu, channels, channel_axis, diameter, do_3D, **cellpose_kwargs
|
|
142
202
|
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tests for the cellpose plugin's anisotropy handling.
|
|
2
|
+
|
|
3
|
+
cellpose itself is not a test dependency (heavy, GPU-oriented), and
|
|
4
|
+
cellpose_fn() calls _require_cellpose() as its very first line, so it can't
|
|
5
|
+
be exercised end-to-end here. cellpose_anisotropy() is a pure function with
|
|
6
|
+
no cellpose import at all, and is where the actual math lives -- that's
|
|
7
|
+
what's covered directly. The wiring that fills cellpose_fn's voxel_size
|
|
8
|
+
parameter in from the image's calibration (workflow/scripts/_pw.py's
|
|
9
|
+
_with_voxel_size) is covered in tests/test_pw.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_cellpose_anisotropy_from_calibration():
|
|
16
|
+
from patchworks.plugins.cellpose import cellpose_anisotropy
|
|
17
|
+
|
|
18
|
+
calibration = {"z": 0.24, "y": 0.10833, "x": 0.10833}
|
|
19
|
+
assert cellpose_anisotropy(calibration) == 0.24 / 0.10833
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_cellpose_anisotropy_falls_back_to_y_when_x_is_missing():
|
|
23
|
+
from patchworks.plugins.cellpose import cellpose_anisotropy
|
|
24
|
+
|
|
25
|
+
assert cellpose_anisotropy({"z": 0.2, "y": 0.1}) == 2.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_cellpose_anisotropy_missing_calibration_returns_none():
|
|
29
|
+
from patchworks.plugins.cellpose import cellpose_anisotropy
|
|
30
|
+
|
|
31
|
+
assert cellpose_anisotropy({}) is None
|
|
32
|
+
assert cellpose_anisotropy({"z": 0.2}) is None # no lateral size at all
|
|
33
|
+
assert cellpose_anisotropy({"x": 0.1}) is None # no z size
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_cellpose_fn_declares_a_voxel_size_parameter():
|
|
37
|
+
"""workflow/scripts/_pw.py's _with_voxel_size() finds this by signature
|
|
38
|
+
|
|
39
|
+
inspection to decide whether to fill it in -- if this parameter were
|
|
40
|
+
ever renamed, that wiring would silently stop working rather than error.
|
|
41
|
+
"""
|
|
42
|
+
from patchworks.plugins.cellpose import cellpose_fn
|
|
43
|
+
|
|
44
|
+
params = inspect.signature(cellpose_fn).parameters
|
|
45
|
+
assert "voxel_size" in params
|
|
46
|
+
# anisotropy itself goes through **cellpose_kwargs (it's a cellpose
|
|
47
|
+
# model.eval() argument, not a patchworks-specific one) -- only the raw
|
|
48
|
+
# calibration is a named parameter.
|
|
49
|
+
assert "anisotropy" not in params
|
|
@@ -0,0 +1,82 @@
|
|
|
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"})
|
|
@@ -188,6 +188,32 @@ def test_relate_script_has_the_real_bookkeeping():
|
|
|
188
188
|
assert "openpyxl" in src
|
|
189
189
|
|
|
190
190
|
|
|
191
|
+
def test_view_script_loads_every_label_by_default():
|
|
192
|
+
"""view.py must not override labels=, or auto-load stops working.
|
|
193
|
+
|
|
194
|
+
view_in_napari's labels=None default is what auto-loads every label
|
|
195
|
+
group under <image>/labels/<name>/ as its own layer -- passing an
|
|
196
|
+
explicit labels= here would silently drop that and show only one.
|
|
197
|
+
"""
|
|
198
|
+
src = (_workflow_dir() / "scripts" / "view.py").read_text()
|
|
199
|
+
assert "from patchworks.plugins.napari import view_in_napari" in src
|
|
200
|
+
assert "labels=" not in src
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_viewer_is_a_separate_opt_in_pixi_environment():
|
|
204
|
+
"""napari's Qt/GUI deps must stay out of the default headless env.
|
|
205
|
+
|
|
206
|
+
Adding them to the default `[pypi-dependencies]` would pull heavy GUI
|
|
207
|
+
dependencies into every SLURM job's environment for a feature only used
|
|
208
|
+
interactively.
|
|
209
|
+
"""
|
|
210
|
+
src = (_workflow_dir() / "pixi.toml").read_text()
|
|
211
|
+
default_deps = src.split("[pypi-dependencies]")[1].split("[feature")[0]
|
|
212
|
+
assert "napari" not in default_deps
|
|
213
|
+
assert 'viewer = { features = ["viewer"] }' in src
|
|
214
|
+
assert 'extras = ["napari"]' in src
|
|
215
|
+
|
|
216
|
+
|
|
191
217
|
def test_relate_writes_its_own_log():
|
|
192
218
|
"""relate.py runs via srun, not a Snakemake rule -- nothing else wires up
|
|
193
219
|
|
|
@@ -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"
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
# pixi run multi # run several segmentations + relate their labels (local)
|
|
7
7
|
# pixi run multi-slurm # same, on SLURM
|
|
8
8
|
# pixi run multi-dry # dry-run every segmentation in config/multi.yaml
|
|
9
|
+
# pixi install -e viewer && pixi run -e viewer napari <image.zarr>
|
|
10
|
+
# # open a store in napari, every label auto-loaded
|
|
11
|
+
# # (needs a display -- ssh -X or remote-desktop/VNC)
|
|
9
12
|
#
|
|
10
13
|
# On a cluster, put this project on a shared filesystem the compute nodes can
|
|
11
14
|
# read: the SLURM jobs re-launch snakemake from this env's interpreter.
|
|
@@ -31,11 +34,21 @@ cellpose = ">=3.0,<4"
|
|
|
31
34
|
[feature.cp4.pypi-dependencies]
|
|
32
35
|
cellpose = ">=4"
|
|
33
36
|
|
|
34
|
-
#
|
|
37
|
+
# napari pulls in a lot of Qt/GUI dependencies that don't belong in the
|
|
38
|
+
# default headless SLURM environment, so it's a separate opt-in feature --
|
|
39
|
+
# pixi install -e viewer, not part of the default `pixi install`.
|
|
40
|
+
[feature.viewer.pypi-dependencies]
|
|
41
|
+
patchworks = { version = "*", extras = ["napari"] }
|
|
42
|
+
|
|
43
|
+
[feature.viewer.tasks]
|
|
44
|
+
napari = "python scripts/view.py"
|
|
45
|
+
|
|
46
|
+
# Named environments: pixi install -e cellpose3 / -e cellpose4 / -e viewer
|
|
35
47
|
# The base patchworks[cellpose] (>=3.0) is further constrained by each feature.
|
|
36
48
|
[environments]
|
|
37
49
|
cellpose3 = { features = ["cp3"], solve-group = "cp3" }
|
|
38
50
|
cellpose4 = { features = ["cp4"], solve-group = "cp4" }
|
|
51
|
+
viewer = { features = ["viewer"] }
|
|
39
52
|
|
|
40
53
|
[tasks]
|
|
41
54
|
# --rerun-triggers mtime: don't redo finished steps (e.g. the conversion) just
|
|
@@ -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)
|
|
@@ -425,6 +436,12 @@ def _build_method_fn(cfg):
|
|
|
425
436
|
# Cellpose where they are. setdefault so an explicit
|
|
426
437
|
# cellpose.channel_axis in the config still wins.
|
|
427
438
|
extra.setdefault("channel_axis", 0)
|
|
439
|
+
# do_3D without anisotropy assumes isotropic voxels, which fragments
|
|
440
|
+
# objects across z for any real (anisotropic) calibration -- fill it
|
|
441
|
+
# in from the image's own calibration the same way a custom
|
|
442
|
+
# function's voxel_size gets filled in, unless the config already
|
|
443
|
+
# set anisotropy explicitly.
|
|
444
|
+
extra = _with_voxel_size(cellpose_fn, extra, cfg)
|
|
428
445
|
return cellpose_fn(
|
|
429
446
|
cp.get("model", "cyto3"),
|
|
430
447
|
gpu=cp.get("gpu", True),
|
|
@@ -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.
|
|
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,
|
|
@@ -155,9 +155,7 @@ def run_relations(
|
|
|
155
155
|
)
|
|
156
156
|
|
|
157
157
|
ws_b = wb.create_sheet(title=b_name[:31])
|
|
158
|
-
ws_b.append(
|
|
159
|
-
[f"{b_name}_id", f"{a_name}_count", "total_overlap_voxels"]
|
|
160
|
-
)
|
|
158
|
+
ws_b.append([f"{b_name}_id", f"{a_name}_count", "total_overlap_voxels"])
|
|
161
159
|
for b_id in b_ids:
|
|
162
160
|
agg = per_b[b_id]
|
|
163
161
|
ws_b.append([b_id, agg["count"], agg["overlap_voxels"]])
|
|
@@ -356,7 +356,9 @@ def _resolve_shared_tile_shape(
|
|
|
356
356
|
gpu_memory=gpu_bytes,
|
|
357
357
|
n_channels=n_channels,
|
|
358
358
|
)
|
|
359
|
-
candidates.append(
|
|
359
|
+
candidates.append(
|
|
360
|
+
tuple(int(x) for x in sizer(image.shape, image.dtype))
|
|
361
|
+
)
|
|
360
362
|
|
|
361
363
|
tile_shape = min(candidates, key=lambda t: int(np.prod(t)))
|
|
362
364
|
print(
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Open an OME-ZARR store in napari, auto-loading every label group.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
pixi run -e viewer napari /path/to/image.zarr
|
|
5
|
+
pixi run -e viewer napari /path/to/image.zarr --channel 1
|
|
6
|
+
|
|
7
|
+
Needs the `viewer` pixi environment (`pixi install -e viewer`) -- napari's
|
|
8
|
+
Qt/GUI dependencies live there, not in the default headless workflow env --
|
|
9
|
+
and a display: an X11-forwarded SSH session (`ssh -X`) or a remote-desktop/
|
|
10
|
+
VNC session. It cannot open over a plain headless SSH session.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
|
|
17
|
+
from patchworks.plugins.napari import view_in_napari
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"image", help="OME-ZARR store to open, e.g. work_dir/image.zarr"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--channel",
|
|
27
|
+
type=int,
|
|
28
|
+
default=None,
|
|
29
|
+
help="show only this channel (default: every channel)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--no-glasbey",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="use napari's default label colours instead of glasbey",
|
|
35
|
+
)
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
# Leaving the labels argument at its default auto-loads every label
|
|
39
|
+
# group under <image>/labels/<name>/ as its own Labels layer -- exactly
|
|
40
|
+
# what's wanted here, so there is nothing further to wire up.
|
|
41
|
+
view_in_napari(
|
|
42
|
+
args.image, channel=args.channel, glasbey=not args.no_glasbey
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
main()
|
|
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
|