scikit-ops 0.1.0.dev0__py3-none-any.whl
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.
- scikit_ops-0.1.0.dev0.dist-info/METADATA +372 -0
- scikit_ops-0.1.0.dev0.dist-info/RECORD +66 -0
- scikit_ops-0.1.0.dev0.dist-info/WHEEL +4 -0
- scikit_ops-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- skop/__init__.py +14 -0
- skop/envs/cellpose3/pixi.toml +46 -0
- skop/envs/cupy/pixi.toml +35 -0
- skop/envs/minimal/pixi.toml +25 -0
- skop/envs/pytorch/pixi.toml +88 -0
- skop/envs/sdeconv/pixi.toml +85 -0
- skop/envs/segment-everything/pixi.toml +96 -0
- skop/envs/skimage/pixi.toml +28 -0
- skop/envs/stardist-tf/pixi.toml +91 -0
- skop/envs/unseg-cv/pixi.toml +27 -0
- skop/models.py +145 -0
- skop/ops/__init__.py +19 -0
- skop/ops/_util.py +115 -0
- skop/ops/deconvolve/__init__.py +18 -0
- skop/ops/deconvolve/_edges.py +92 -0
- skop/ops/deconvolve/_pad.py +117 -0
- skop/ops/deconvolve/richardson_lucy.py +100 -0
- skop/ops/deconvolve/richardson_lucy_cupy.py +116 -0
- skop/ops/detect/__init__.py +28 -0
- skop/ops/detect/_result.py +45 -0
- skop/ops/detect/fastsam.py +103 -0
- skop/ops/detect/jdll_yolo.py +115 -0
- skop/ops/detect/object_aware_yolo.py +112 -0
- skop/ops/detect/yolo.py +441 -0
- skop/ops/edges.py +382 -0
- skop/ops/generate.py +77 -0
- skop/ops/kernels/__init__.py +39 -0
- skop/ops/kernels/_fluorophore.py +42 -0
- skop/ops/kernels/_recenter.py +60 -0
- skop/ops/kernels/gibson_lanni.py +147 -0
- skop/ops/kernels/paraxial.py +128 -0
- skop/ops/kernels/psf.py +59 -0
- skop/ops/labels.py +144 -0
- skop/ops/mask/__init__.py +29 -0
- skop/ops/mask/_result.py +40 -0
- skop/ops/mask/microsam.py +114 -0
- skop/ops/mask/mobilesam.py +176 -0
- skop/ops/morphology.py +214 -0
- skop/ops/normalize.py +85 -0
- skop/ops/segment/__init__.py +24 -0
- skop/ops/segment/cellpose3.py +245 -0
- skop/ops/segment/cellpose4.py +127 -0
- skop/ops/segment/instanseg.py +162 -0
- skop/ops/segment/stardist2d.py +192 -0
- skop/ops/segment/starfun3d.py +104 -0
- skop/ops/segment/unseg/__init__.py +138 -0
- skop/ops/segment/unseg/_algorithm.py +1618 -0
- skop/ops/smooth.py +452 -0
- skop/ops/threshold.py +278 -0
- skop/ops/toy.py +114 -0
- skop/ops/train/__init__.py +81 -0
- skop/ops/train/_util.py +152 -0
- skop/ops/train/cellpose3.py +189 -0
- skop/ops/train/cellpose4.py +164 -0
- skop/ops/train/stardist2d.py +439 -0
- skop/ops/workflows/__init__.py +43 -0
- skop/ops/workflows/deconvolve/__init__.py +7 -0
- skop/ops/workflows/deconvolve/with_psf.py +103 -0
- skop/ops/workflows/mask/__init__.py +7 -0
- skop/ops/workflows/mask/detect_then_mask.py +97 -0
- skop/ops/workflows/segment/__init__.py +7 -0
- skop/ops/workflows/segment/connect_2d_in_3d.py +145 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Cellpose 3, the model zoo that came before CellposeSAM.
|
|
2
|
+
|
|
3
|
+
Cellpose 4 replaced a shelf of specialised models -- cyto3, nuclei, and the
|
|
4
|
+
ones trained on bacteria, yeast and tissue -- with one CellposeSAM model that
|
|
5
|
+
is better on most things and not on everything. ``skop.ops.segment.cellpose``
|
|
6
|
+
is that one; this is the shelf, kept because a model trained on your organism
|
|
7
|
+
still beats a general one that never saw it, and because published results
|
|
8
|
+
were produced with these.
|
|
9
|
+
|
|
10
|
+
A separate op in a separate environment rather than a version parameter on
|
|
11
|
+
one op. The two are not API-compatible: v3's ``models.Cellpose`` became v4's
|
|
12
|
+
``models.CellposeModel``, and ``eval`` lost a return value on the way. The
|
|
13
|
+
old ``CellposeSegmenter`` in napari-ai-lab carried both, branching on
|
|
14
|
+
``cellpose.version`` at every call site -- which is exactly the runtime
|
|
15
|
+
version sniffing an environment per op is meant to replace. Pinning the
|
|
16
|
+
environment to ``cellpose <4`` means the code below has one path.
|
|
17
|
+
|
|
18
|
+
``models.Cellpose``, not ``models.CellposeModel``, is the important detail
|
|
19
|
+
for the built-in zoo. In v3 they are different classes: ``Cellpose`` bundles
|
|
20
|
+
a SizeModel, so ``diameter=None`` makes it estimate the diameter before
|
|
21
|
+
segmenting. ``CellposeModel`` has no such thing and would silently use its
|
|
22
|
+
default. That is also why v3's ``eval`` returns four values where v4 returns
|
|
23
|
+
three -- the fourth is the diameter it estimated.
|
|
24
|
+
|
|
25
|
+
A model you trained yourself has to use ``CellposeModel`` anyway, since a
|
|
26
|
+
SizeModel is trained separately and a finetuned checkpoint does not carry
|
|
27
|
+
one. So the op has both classes, picked by whether ``pretrained_model`` is
|
|
28
|
+
set, and what changes with it is what "estimate the diameter" can mean --
|
|
29
|
+
see ``diameter`` below.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from enum import Enum
|
|
35
|
+
from functools import partial
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Annotated
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
|
|
41
|
+
from opspec.op import Axes, op
|
|
42
|
+
from opspec.runtime import progress
|
|
43
|
+
from opspec.types import ImageOf, LabelsOf
|
|
44
|
+
|
|
45
|
+
from .._util import to_gray
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PretrainedModel(Enum):
|
|
49
|
+
"""Cellpose 3's built-in models, by what they were trained on.
|
|
50
|
+
|
|
51
|
+
The generalist pair first. The rest are the specialists, each finetuned
|
|
52
|
+
on one imaging domain and worth reaching for when the generalist misses
|
|
53
|
+
-- which is the reason this op exists at all.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
cyto3 = "cyto3"
|
|
57
|
+
nuclei = "nuclei"
|
|
58
|
+
bacteria_phase = "bact_phase_cp3"
|
|
59
|
+
bacteria_fluorescence = "bact_fluor_cp3"
|
|
60
|
+
yeast_phase = "yeast_PhC_cp3"
|
|
61
|
+
yeast_brightfield = "yeast_BF_cp3"
|
|
62
|
+
tissue = "tissuenet_cp3"
|
|
63
|
+
live_cell = "livecell_cp3"
|
|
64
|
+
deepbacs = "deepbacs_cp3"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CytoplasmChannel(Enum):
|
|
68
|
+
"""Which colour the cells to segment are in.
|
|
69
|
+
|
|
70
|
+
Cellpose 3 states this as a number -- 0 for grey, then 1, 2, 3 for red,
|
|
71
|
+
green and blue -- which is one-based, is not an axis index, and is easy
|
|
72
|
+
to transpose by accident. Named instead.
|
|
73
|
+
|
|
74
|
+
``grayscale`` is the default and collapses the colours into one plane.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
grayscale = "grayscale"
|
|
78
|
+
red = "red"
|
|
79
|
+
green = "green"
|
|
80
|
+
blue = "blue"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class NucleusChannel(Enum):
|
|
84
|
+
"""Which colour the nuclei are in, if a second channel has them.
|
|
85
|
+
|
|
86
|
+
The optional half of Cellpose 3's channel pair: a nuclear stain helps it
|
|
87
|
+
separate cells that touch. ``none`` is the default and means there isn't
|
|
88
|
+
one.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
none = "none"
|
|
92
|
+
red = "red"
|
|
93
|
+
green = "green"
|
|
94
|
+
blue = "blue"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: Cellpose's own numbering for the two channel parameters. One-based over
|
|
98
|
+
#: red/green/blue, with 0 meaning grey in the first slot and absent in the
|
|
99
|
+
#: second.
|
|
100
|
+
_CHANNEL_NUMBER = {"grayscale": 0, "none": 0, "red": 1, "green": 2, "blue": 3}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@op(env="cellpose3")
|
|
104
|
+
def cellpose3(
|
|
105
|
+
image: Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")],
|
|
106
|
+
model: PretrainedModel = PretrainedModel.cyto3,
|
|
107
|
+
pretrained_model: Path | None = None,
|
|
108
|
+
cytoplasm_channel: CytoplasmChannel = CytoplasmChannel.grayscale,
|
|
109
|
+
nucleus_channel: NucleusChannel = NucleusChannel.none,
|
|
110
|
+
diameter: Annotated[
|
|
111
|
+
float,
|
|
112
|
+
{"widget_type": "FloatSpinBox", "min": 0.0, "max": 1000.0, "step": 1.0},
|
|
113
|
+
] = 30.0,
|
|
114
|
+
flow_threshold: Annotated[
|
|
115
|
+
float,
|
|
116
|
+
{"widget_type": "FloatSlider", "min": 0.0, "max": 3.0, "step": 0.05},
|
|
117
|
+
] = 0.4,
|
|
118
|
+
cellprob_threshold: Annotated[
|
|
119
|
+
float,
|
|
120
|
+
{"widget_type": "FloatSlider", "min": -6.0, "max": 6.0, "step": 0.1},
|
|
121
|
+
] = 0.0,
|
|
122
|
+
niter: int = 200,
|
|
123
|
+
min_size: int = 15,
|
|
124
|
+
normalize: bool = True,
|
|
125
|
+
use_gpu: bool = True,
|
|
126
|
+
) -> LabelsOf[np.ndarray]:
|
|
127
|
+
"""Segment cells with one of Cellpose 3's pretrained models.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
image: Plane to segment. A trailing RGB(A) axis is collapsed. A
|
|
131
|
+
caller naming its axes may hand this a stack instead, and it is
|
|
132
|
+
segmented plane by plane.
|
|
133
|
+
model: Which pretrained model to run. cyto3 is the generalist; the
|
|
134
|
+
specialists are worth trying when it misses on your organism.
|
|
135
|
+
Ignored when ``pretrained_model`` is set.
|
|
136
|
+
pretrained_model: A model you trained yourself, which takes
|
|
137
|
+
precedence over ``model``. It must be a Cellpose 1-3 model, not
|
|
138
|
+
a CPSAM one -- those load in ``cellpose``, and
|
|
139
|
+
``skop.models.cellpose_flavor`` tells the two apart from the
|
|
140
|
+
file.
|
|
141
|
+
cytoplasm_channel: Which colour the cells are in. Left at
|
|
142
|
+
``grayscale``, the colours are collapsed into one plane before
|
|
143
|
+
Cellpose sees them -- which is right for a single-channel image
|
|
144
|
+
and wrong for a stain that only shows in one colour.
|
|
145
|
+
nucleus_channel: Which colour the nuclei are in, when a nuclear
|
|
146
|
+
stain is present to help separate touching cells. Ignored unless
|
|
147
|
+
``cytoplasm_channel`` names a colour too: Cellpose reads the
|
|
148
|
+
pair, and a grey first channel makes it drop the second.
|
|
149
|
+
diameter: Expected cell diameter in pixels, where 0 means "work it
|
|
150
|
+
out". What that costs depends on the model. For the built-in
|
|
151
|
+
zoo it runs a size model over the image first, which is the
|
|
152
|
+
right choice when object size varies between images. A model of
|
|
153
|
+
your own has no size model, so 0 falls back to the diameter it
|
|
154
|
+
was trained at -- right when your images resemble your training
|
|
155
|
+
set, and worth overriding when they do not.
|
|
156
|
+
flow_threshold: Maximum allowed flow error per mask. Higher accepts
|
|
157
|
+
more irregular shapes; lower discards them.
|
|
158
|
+
cellprob_threshold: Cell probability cutoff. Lower finds more, and
|
|
159
|
+
grows the masks it finds.
|
|
160
|
+
niter: Dynamics iterations. More is slower and helps long or
|
|
161
|
+
branched cells, whose pixels need further to travel.
|
|
162
|
+
min_size: Discard masks smaller than this many pixels. -1 keeps
|
|
163
|
+
everything.
|
|
164
|
+
normalize: Whether to percentile-normalize the plane first. Cellpose
|
|
165
|
+
does this per call, so a caller running it slice by slice over a
|
|
166
|
+
stack gets each plane stretched to its own range -- which turns a
|
|
167
|
+
faint plane at the top of a volume into a bright one full of
|
|
168
|
+
detections. Turn it off and normalize the volume beforehand when
|
|
169
|
+
that matters; see ``skop.ops.workflows.segment``.
|
|
170
|
+
use_gpu: Whether to use the GPU when one is available. Falls back to
|
|
171
|
+
CPU rather than failing if the GPU model cannot be built.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A label image, one integer per detected cell.
|
|
175
|
+
|
|
176
|
+
Note: weights are downloaded into Cellpose's own cache on first use.
|
|
177
|
+
"""
|
|
178
|
+
from cellpose import models
|
|
179
|
+
|
|
180
|
+
# Naming a colour means Cellpose picks the channels itself, so it has to
|
|
181
|
+
# be given them: collapsing to grey first leaves nothing to pick from,
|
|
182
|
+
# and Cellpose does not complain -- its reshape short-circuits on a
|
|
183
|
+
# single-channel image and never reads `channels` at all.
|
|
184
|
+
select = {}
|
|
185
|
+
if cytoplasm_channel is not CytoplasmChannel.grayscale:
|
|
186
|
+
select["channels"] = [
|
|
187
|
+
_CHANNEL_NUMBER[cytoplasm_channel.value],
|
|
188
|
+
_CHANNEL_NUMBER[nucleus_channel.value],
|
|
189
|
+
]
|
|
190
|
+
plane = np.asarray(image)
|
|
191
|
+
progress(
|
|
192
|
+
f"Segmenting the {cytoplasm_channel.value} channel"
|
|
193
|
+
+ (
|
|
194
|
+
f", nuclei in {nucleus_channel.value}"
|
|
195
|
+
if nucleus_channel is not NucleusChannel.none
|
|
196
|
+
else ""
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
plane = to_gray(image)
|
|
201
|
+
|
|
202
|
+
# int32 reaches eval from label-image arithmetic and upstream ops, and
|
|
203
|
+
# Cellpose's normalization does not handle it. Carried over from
|
|
204
|
+
# napari-ai-lab's CellposeSegmenter, which hit it.
|
|
205
|
+
if plane.dtype == np.int32:
|
|
206
|
+
plane = plane.astype(np.float32)
|
|
207
|
+
|
|
208
|
+
if pretrained_model is not None:
|
|
209
|
+
# CellposeModel, since a finetuned checkpoint carries no SizeModel.
|
|
210
|
+
name = Path(pretrained_model).name
|
|
211
|
+
build = partial(models.CellposeModel, pretrained_model=str(pretrained_model))
|
|
212
|
+
else:
|
|
213
|
+
name = model.value
|
|
214
|
+
build = partial(models.Cellpose, model_type=model.value)
|
|
215
|
+
|
|
216
|
+
progress(f"Loading Cellpose 3 model {name}")
|
|
217
|
+
try:
|
|
218
|
+
net = build(gpu=use_gpu)
|
|
219
|
+
except (AttributeError, ValueError, TypeError) as exc:
|
|
220
|
+
# A GPU that torch can see but Cellpose cannot use fails here rather
|
|
221
|
+
# than at eval. Segmenting slowly beats not segmenting.
|
|
222
|
+
progress(f"GPU model unavailable ({exc}); falling back to CPU")
|
|
223
|
+
net = build(gpu=False)
|
|
224
|
+
|
|
225
|
+
if diameter <= 0 and pretrained_model is not None:
|
|
226
|
+
# No size model to ask, so the diameter the model was trained at is
|
|
227
|
+
# the best available answer. Cellpose would otherwise quietly use
|
|
228
|
+
# CellposeModel's default of 30.
|
|
229
|
+
diameter = float(getattr(net, "diam_labels", 0.0) or 0.0)
|
|
230
|
+
progress(f"Using the model's trained diameter, {diameter:.1f} px")
|
|
231
|
+
|
|
232
|
+
progress("Running Cellpose 3")
|
|
233
|
+
# Four returns in v3 -- masks, flows, styles, diams -- where v4 gives
|
|
234
|
+
# three. Indexed rather than unpacked so the difference cannot bite.
|
|
235
|
+
result = net.eval(
|
|
236
|
+
plane,
|
|
237
|
+
diameter=diameter if diameter > 0 else None,
|
|
238
|
+
flow_threshold=flow_threshold,
|
|
239
|
+
cellprob_threshold=cellprob_threshold,
|
|
240
|
+
niter=niter,
|
|
241
|
+
min_size=min_size,
|
|
242
|
+
normalize=normalize,
|
|
243
|
+
**select,
|
|
244
|
+
)
|
|
245
|
+
return np.asarray(result[0]).astype(np.uint16)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Cellpose 4 segmentation, on its transformer models.
|
|
2
|
+
|
|
3
|
+
Four models, two backbones: CPSAM and the DINOv3 one added in June 2026.
|
|
4
|
+
Same call and same finetuning for all four, so one op with a model choice.
|
|
5
|
+
|
|
6
|
+
Shares the 'pytorch' environment (0002). Cellpose 3 cannot: version 4
|
|
7
|
+
replaced the zoo and moved the API, so it has an environment of its own.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from opspec.op import Axes, op
|
|
19
|
+
from opspec.runtime import progress
|
|
20
|
+
from opspec.types import ImageOf, LabelsOf
|
|
21
|
+
|
|
22
|
+
from .._util import channel_axis, to_gray
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PretrainedModel(Enum):
|
|
26
|
+
"""Cellpose 4's built-ins, newest of each backbone first. cpdino_vitb is
|
|
27
|
+
ViT-B, about a third the size of the others and cheaper to finetune."""
|
|
28
|
+
|
|
29
|
+
cpsam_v2 = "cpsam_v2"
|
|
30
|
+
cpsam = "cpsam"
|
|
31
|
+
cpdino = "cpdino"
|
|
32
|
+
cpdino_vitb = "cpdino-vitb"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@op(env="pytorch")
|
|
36
|
+
def cellpose4(
|
|
37
|
+
image: Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")],
|
|
38
|
+
model: PretrainedModel = PretrainedModel.cpsam_v2,
|
|
39
|
+
pretrained_model: Path | None = None,
|
|
40
|
+
diameter: Annotated[
|
|
41
|
+
float,
|
|
42
|
+
{"widget_type": "FloatSpinBox", "min": 0.0, "max": 1000.0, "step": 1.0},
|
|
43
|
+
] = 0.0,
|
|
44
|
+
flow_threshold: Annotated[
|
|
45
|
+
float,
|
|
46
|
+
{"widget_type": "FloatSlider", "min": 0.0, "max": 3.0, "step": 0.05},
|
|
47
|
+
] = 0.4,
|
|
48
|
+
cellprob_threshold: Annotated[
|
|
49
|
+
float,
|
|
50
|
+
{"widget_type": "FloatSlider", "min": -6.0, "max": 6.0, "step": 0.1},
|
|
51
|
+
] = 0.0,
|
|
52
|
+
niter: int = 0,
|
|
53
|
+
min_size: int = 15,
|
|
54
|
+
normalize: bool = True,
|
|
55
|
+
collapse_channels: bool = False,
|
|
56
|
+
use_gpu: bool = True,
|
|
57
|
+
) -> LabelsOf[np.ndarray]:
|
|
58
|
+
"""Segment cells with Cellpose.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
image: Plane to segment. A trailing RGB(A) axis is passed through
|
|
62
|
+
to CPSAM, which reads up to three channels itself. A caller
|
|
63
|
+
naming its axes may hand this a stack instead.
|
|
64
|
+
model: Which built-in to run. Ignored when ``pretrained_model``
|
|
65
|
+
is set.
|
|
66
|
+
pretrained_model: A Cellpose 4 model finetuned on your own data.
|
|
67
|
+
Empty runs the built-in named by ``model``. It must be a
|
|
68
|
+
Cellpose 4 model, not one from Cellpose 3 -- those load in
|
|
69
|
+
``cellpose3`` instead -- and it carries its own backbone, so
|
|
70
|
+
``model`` does not choose one for it.
|
|
71
|
+
diameter: Expected cell diameter in pixels; 0 lets Cellpose estimate.
|
|
72
|
+
flow_threshold: Maximum allowed flow error per mask.
|
|
73
|
+
cellprob_threshold: Cell probability cutoff; lower finds more cells.
|
|
74
|
+
niter: Dynamics iterations; 0 lets Cellpose scale it to the diameter.
|
|
75
|
+
More is slower and helps long or branched cells, whose pixels
|
|
76
|
+
need further to travel.
|
|
77
|
+
min_size: Discard masks smaller than this many pixels. -1 keeps
|
|
78
|
+
everything, which is what you want when the objects are small
|
|
79
|
+
enough that the default is doing the discarding for you.
|
|
80
|
+
normalize: Whether to percentile-normalize the plane first. Cellpose
|
|
81
|
+
does this per call, so a caller running it slice by slice over a
|
|
82
|
+
stack gets each plane stretched to its own range -- which turns a
|
|
83
|
+
faint plane at the top of a volume into a bright one full of
|
|
84
|
+
detections. Turn it off and normalize the volume beforehand when
|
|
85
|
+
that matters; see ``skop.ops.workflows.segment``.
|
|
86
|
+
collapse_channels: Average a trailing RGB(A) axis to grey before
|
|
87
|
+
segmenting. Off, because CPSAM takes 1-3 channels in any order
|
|
88
|
+
and using them beats throwing them away. On is for when one
|
|
89
|
+
colour is noise the average dilutes -- and for matching
|
|
90
|
+
``cellpose3``, which needs the channels named or collapsed.
|
|
91
|
+
use_gpu: Whether to use the GPU, when one is available.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A label image, one integer per detected cell.
|
|
95
|
+
"""
|
|
96
|
+
from cellpose import models
|
|
97
|
+
|
|
98
|
+
if collapse_channels:
|
|
99
|
+
plane, axis = to_gray(image), None
|
|
100
|
+
else:
|
|
101
|
+
plane, axis = np.asarray(image), channel_axis(image)
|
|
102
|
+
|
|
103
|
+
# Passed only when set, rather than relying on what the version in this
|
|
104
|
+
# environment treats as "no model" -- it has been None and False.
|
|
105
|
+
# Cellpose 4 reads `pretrained_model` as a name or a path.
|
|
106
|
+
if pretrained_model is not None:
|
|
107
|
+
source = str(pretrained_model)
|
|
108
|
+
progress(f"Loading Cellpose model {Path(pretrained_model).name}")
|
|
109
|
+
else:
|
|
110
|
+
source = model.value
|
|
111
|
+
progress(f"Loading Cellpose model {source}")
|
|
112
|
+
net = models.CellposeModel(gpu=use_gpu, pretrained_model=source)
|
|
113
|
+
|
|
114
|
+
progress("Running Cellpose")
|
|
115
|
+
result = net.eval(
|
|
116
|
+
plane,
|
|
117
|
+
channel_axis=axis,
|
|
118
|
+
diameter=diameter if diameter > 0 else None,
|
|
119
|
+
flow_threshold=flow_threshold,
|
|
120
|
+
cellprob_threshold=cellprob_threshold,
|
|
121
|
+
niter=niter if niter > 0 else None,
|
|
122
|
+
min_size=min_size,
|
|
123
|
+
normalize=normalize,
|
|
124
|
+
)
|
|
125
|
+
# NB: eval returns (masks, flows, styles) or (masks, flows, styles, diams),
|
|
126
|
+
# depending on the Cellpose version.
|
|
127
|
+
return np.asarray(result[0]).astype(np.uint16)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""InstanSeg segmentation: nuclei, and cells around them.
|
|
2
|
+
|
|
3
|
+
InstanSeg (instanseg-torch) is plain torch, so it shares the 'pytorch'
|
|
4
|
+
environment (0002). Its fluorescence model takes any number of channels, in
|
|
5
|
+
any order, and labels nuclei and whole cells in one pass.
|
|
6
|
+
|
|
7
|
+
The scripts InstanSeg ships with also read files -- Bio-Formats through a
|
|
8
|
+
JVM, whole-slide readers -- and none of that is here. The op is handed an
|
|
9
|
+
array; reading the file is the caller's job.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Annotated, NamedTuple
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from opspec.op import Axes, op
|
|
20
|
+
from opspec.runtime import progress
|
|
21
|
+
from opspec.types import ImageOf, LabelsOf
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PretrainedModel(Enum):
|
|
25
|
+
"""InstanSeg's published models."""
|
|
26
|
+
|
|
27
|
+
fluorescence_nuclei_and_cells = "fluorescence_nuclei_and_cells"
|
|
28
|
+
brightfield_nuclei = "brightfield_nuclei"
|
|
29
|
+
single_channel_nuclei = "single_channel_nuclei"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Target(Enum):
|
|
33
|
+
"""What to label, for a model that finds both."""
|
|
34
|
+
|
|
35
|
+
nuclei_and_cells = "all_outputs"
|
|
36
|
+
nuclei = "nuclei"
|
|
37
|
+
cells = "cells"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NucleiAndCells(NamedTuple):
|
|
41
|
+
#: One label per nucleus.
|
|
42
|
+
nuclei: LabelsOf[np.ndarray] | None
|
|
43
|
+
#: One label per cell, numbered to match its nucleus. None for a model
|
|
44
|
+
#: that only finds nuclei, or when only nuclei were asked for.
|
|
45
|
+
cells: LabelsOf[np.ndarray] | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: Bigger than this, in pixels along either axis, and the image is run in
|
|
49
|
+
#: tiles rather than whole.
|
|
50
|
+
_WHOLE = 2048
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@op(env="pytorch")
|
|
54
|
+
def instanseg(
|
|
55
|
+
image: Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")],
|
|
56
|
+
cells: Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")] | None = None,
|
|
57
|
+
model: PretrainedModel = PretrainedModel.fluorescence_nuclei_and_cells,
|
|
58
|
+
target: Target = Target.nuclei_and_cells,
|
|
59
|
+
nuclei_channel: Annotated[int, {"min": 0, "max": 64}] = 0,
|
|
60
|
+
cells_channel: Annotated[int, {"min": 0, "max": 64}] = 0,
|
|
61
|
+
pixel_size: Annotated[
|
|
62
|
+
float,
|
|
63
|
+
{"widget_type": "FloatSpinBox", "min": 0.0, "max": 10.0, "step": 0.05},
|
|
64
|
+
] = 0.0,
|
|
65
|
+
use_gpu: bool = True,
|
|
66
|
+
) -> NucleiAndCells:
|
|
67
|
+
"""Label nuclei and cells with InstanSeg.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
image: Plane to segment: the nuclei, or every channel when *cells*
|
|
71
|
+
is not given. A trailing channel axis is passed to the model,
|
|
72
|
+
which takes any number of fluorescence channels; the brightfield
|
|
73
|
+
model wants RGB.
|
|
74
|
+
cells: The cells -- membrane or cytoplasm -- when they are a layer
|
|
75
|
+
of their own. Goes to the model after the nuclei.
|
|
76
|
+
model: Which published model to run. Downloaded on first use.
|
|
77
|
+
target: Nuclei, cells or both, for the model that finds both.
|
|
78
|
+
nuclei_channel: The nuclei's channel in *image*, counting from 1;
|
|
79
|
+
0 for all of it.
|
|
80
|
+
cells_channel: The cells' channel, counting from 1: in *cells* when
|
|
81
|
+
it is given, else in *image*. 0 for all of *cells*, or with no
|
|
82
|
+
*cells* and no nuclei channel either, all of *image*. So: two
|
|
83
|
+
layers, 0 and 0; one layer of channels, given twice or once,
|
|
84
|
+
the two channel numbers.
|
|
85
|
+
pixel_size: The image's pixel size in microns. InstanSeg was trained
|
|
86
|
+
at about 0.5, and rescales the image to that; 0 runs it at the
|
|
87
|
+
size it is, which is right only if the pixels are near 0.5 um.
|
|
88
|
+
use_gpu: Whether to use the GPU, when one is available.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
nuclei: Labels, one per nucleus.
|
|
92
|
+
cells: Labels, one per cell, or None when there are none to give.
|
|
93
|
+
|
|
94
|
+
Note: the weights are downloaded into skop's asset cache on first use.
|
|
95
|
+
"""
|
|
96
|
+
import os
|
|
97
|
+
|
|
98
|
+
import torch
|
|
99
|
+
|
|
100
|
+
from ophelper.assets import cache_dir
|
|
101
|
+
|
|
102
|
+
# InstanSeg downloads into its own package folder unless told otherwise.
|
|
103
|
+
os.environ["INSTANSEG_BIOIMAGEIO_PATH"] = str(cache_dir() / "instanseg")
|
|
104
|
+
from instanseg import InstanSeg
|
|
105
|
+
|
|
106
|
+
device = "cuda" if use_gpu and torch.cuda.is_available() else "cpu"
|
|
107
|
+
progress(f"Loading {model.value} on {device}")
|
|
108
|
+
net = InstanSeg(model.value, device=device, verbosity=0)
|
|
109
|
+
|
|
110
|
+
def channels_first(array: np.ndarray) -> np.ndarray:
|
|
111
|
+
# InstanSeg wants channels first; a plane with none gets one.
|
|
112
|
+
return array[np.newaxis] if array.ndim == 2 else np.moveaxis(array, -1, 0)
|
|
113
|
+
|
|
114
|
+
def pick(planes: np.ndarray, channel: int, name: str) -> np.ndarray:
|
|
115
|
+
if not channel:
|
|
116
|
+
return planes
|
|
117
|
+
if channel > len(planes):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"{name} channel {channel} asked for, but it has {len(planes)}; "
|
|
120
|
+
"channels count from 1"
|
|
121
|
+
)
|
|
122
|
+
return planes[channel - 1 : channel]
|
|
123
|
+
|
|
124
|
+
if cells is None:
|
|
125
|
+
# One input: the channel numbers pick within it, nuclei first.
|
|
126
|
+
planes = channels_first(image)
|
|
127
|
+
chosen = list(dict.fromkeys(c for c in (nuclei_channel, cells_channel) if c))
|
|
128
|
+
if chosen:
|
|
129
|
+
planes = np.concatenate([pick(planes, c, "image") for c in chosen])
|
|
130
|
+
else:
|
|
131
|
+
if cells.shape[:2] != image.shape[:2]:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"cells is {cells.shape[0]} x {cells.shape[1]}, but image is "
|
|
134
|
+
f"{image.shape[0]} x {image.shape[1]}"
|
|
135
|
+
)
|
|
136
|
+
planes = np.concatenate(
|
|
137
|
+
[
|
|
138
|
+
pick(channels_first(image), nuclei_channel, "image"),
|
|
139
|
+
pick(channels_first(cells), cells_channel, "cells"),
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
planes = np.ascontiguousarray(planes, dtype=np.float32)
|
|
143
|
+
kwargs = {
|
|
144
|
+
"pixel_size": pixel_size or None,
|
|
145
|
+
"target": target.value,
|
|
146
|
+
"return_image_tensor": False,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
progress(f"Segmenting {image.shape[0]} x {image.shape[1]}")
|
|
150
|
+
if max(image.shape[:2]) <= _WHOLE:
|
|
151
|
+
labels = net.eval_small_image(planes, **kwargs)
|
|
152
|
+
else:
|
|
153
|
+
labels = net.eval_medium_image(planes, tile_size=512, **kwargs)
|
|
154
|
+
labels = labels.squeeze(0).cpu().numpy().astype(np.int32)
|
|
155
|
+
|
|
156
|
+
# One plane per output: nuclei then cells for the model that finds both
|
|
157
|
+
# and was asked for both, else just the one asked for.
|
|
158
|
+
if len(labels) == 2:
|
|
159
|
+
return NucleiAndCells(nuclei=labels[0], cells=labels[1])
|
|
160
|
+
if target is Target.cells:
|
|
161
|
+
return NucleiAndCells(nuclei=None, cells=labels[0])
|
|
162
|
+
return NucleiAndCells(nuclei=labels[0], cells=None)
|