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.
Files changed (66) hide show
  1. scikit_ops-0.1.0.dev0.dist-info/METADATA +372 -0
  2. scikit_ops-0.1.0.dev0.dist-info/RECORD +66 -0
  3. scikit_ops-0.1.0.dev0.dist-info/WHEEL +4 -0
  4. scikit_ops-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  5. skop/__init__.py +14 -0
  6. skop/envs/cellpose3/pixi.toml +46 -0
  7. skop/envs/cupy/pixi.toml +35 -0
  8. skop/envs/minimal/pixi.toml +25 -0
  9. skop/envs/pytorch/pixi.toml +88 -0
  10. skop/envs/sdeconv/pixi.toml +85 -0
  11. skop/envs/segment-everything/pixi.toml +96 -0
  12. skop/envs/skimage/pixi.toml +28 -0
  13. skop/envs/stardist-tf/pixi.toml +91 -0
  14. skop/envs/unseg-cv/pixi.toml +27 -0
  15. skop/models.py +145 -0
  16. skop/ops/__init__.py +19 -0
  17. skop/ops/_util.py +115 -0
  18. skop/ops/deconvolve/__init__.py +18 -0
  19. skop/ops/deconvolve/_edges.py +92 -0
  20. skop/ops/deconvolve/_pad.py +117 -0
  21. skop/ops/deconvolve/richardson_lucy.py +100 -0
  22. skop/ops/deconvolve/richardson_lucy_cupy.py +116 -0
  23. skop/ops/detect/__init__.py +28 -0
  24. skop/ops/detect/_result.py +45 -0
  25. skop/ops/detect/fastsam.py +103 -0
  26. skop/ops/detect/jdll_yolo.py +115 -0
  27. skop/ops/detect/object_aware_yolo.py +112 -0
  28. skop/ops/detect/yolo.py +441 -0
  29. skop/ops/edges.py +382 -0
  30. skop/ops/generate.py +77 -0
  31. skop/ops/kernels/__init__.py +39 -0
  32. skop/ops/kernels/_fluorophore.py +42 -0
  33. skop/ops/kernels/_recenter.py +60 -0
  34. skop/ops/kernels/gibson_lanni.py +147 -0
  35. skop/ops/kernels/paraxial.py +128 -0
  36. skop/ops/kernels/psf.py +59 -0
  37. skop/ops/labels.py +144 -0
  38. skop/ops/mask/__init__.py +29 -0
  39. skop/ops/mask/_result.py +40 -0
  40. skop/ops/mask/microsam.py +114 -0
  41. skop/ops/mask/mobilesam.py +176 -0
  42. skop/ops/morphology.py +214 -0
  43. skop/ops/normalize.py +85 -0
  44. skop/ops/segment/__init__.py +24 -0
  45. skop/ops/segment/cellpose3.py +245 -0
  46. skop/ops/segment/cellpose4.py +127 -0
  47. skop/ops/segment/instanseg.py +162 -0
  48. skop/ops/segment/stardist2d.py +192 -0
  49. skop/ops/segment/starfun3d.py +104 -0
  50. skop/ops/segment/unseg/__init__.py +138 -0
  51. skop/ops/segment/unseg/_algorithm.py +1618 -0
  52. skop/ops/smooth.py +452 -0
  53. skop/ops/threshold.py +278 -0
  54. skop/ops/toy.py +114 -0
  55. skop/ops/train/__init__.py +81 -0
  56. skop/ops/train/_util.py +152 -0
  57. skop/ops/train/cellpose3.py +189 -0
  58. skop/ops/train/cellpose4.py +164 -0
  59. skop/ops/train/stardist2d.py +439 -0
  60. skop/ops/workflows/__init__.py +43 -0
  61. skop/ops/workflows/deconvolve/__init__.py +7 -0
  62. skop/ops/workflows/deconvolve/with_psf.py +103 -0
  63. skop/ops/workflows/mask/__init__.py +7 -0
  64. skop/ops/workflows/mask/detect_then_mask.py +97 -0
  65. skop/ops/workflows/segment/__init__.py +7 -0
  66. 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)