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
skop/ops/threshold.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Global thresholding, via scikit-image.
|
|
2
|
+
|
|
3
|
+
Every op here is the same shape: pick one number from the image's histogram,
|
|
4
|
+
keep what lies above it. They differ only in how that number is chosen, and
|
|
5
|
+
which choice is right is a property of the image rather than of the method --
|
|
6
|
+
so they are variations on one call, sharing one module and one environment.
|
|
7
|
+
|
|
8
|
+
A global threshold consumes no particular axis -- it is a histogram over
|
|
9
|
+
whatever it is handed -- so none of these declare slots at all, and each works
|
|
10
|
+
on a line, a plane or a whole volume. Whether one threshold for a stack or one
|
|
11
|
+
per plane is the right answer depends on the experiment, so it is left to the
|
|
12
|
+
caller: by default the stack arrives whole, and a caller wanting a threshold
|
|
13
|
+
per plane iterates instead.
|
|
14
|
+
|
|
15
|
+
Otsu is the default worth reaching for first. Beyond it:
|
|
16
|
+
``li`` minimizes cross-entropy and copes well with a small foreground;
|
|
17
|
+
``triangle`` and ``yen`` suit a skewed histogram with no clear valley;
|
|
18
|
+
``isodata`` and ``mean`` are the cheap classical answers; ``minimum``
|
|
19
|
+
insists on a genuinely bimodal histogram and raises when it cannot find one.
|
|
20
|
+
``multiotsu`` is the odd one out -- it cuts into several classes rather than
|
|
21
|
+
two, so it returns those classes rather than a foreground.
|
|
22
|
+
|
|
23
|
+
Ported from src/imgops/implementations/skimagessegmenter.py.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Annotated
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from opspec.op import Axes, op
|
|
33
|
+
from opspec.types import ImageOf, LabelsOf
|
|
34
|
+
|
|
35
|
+
from ._util import to_gray
|
|
36
|
+
|
|
37
|
+
#: What every op here takes: any number of axes, an RGB(A) axis collapsed.
|
|
38
|
+
_Image = Annotated[ImageOf[np.ndarray], Axes(variadic=True)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mask(gray: np.ndarray, value: float, invert: bool, label_objects: bool):
|
|
42
|
+
"""Turn one threshold into the mask, or labels, an op returns."""
|
|
43
|
+
from skimage import measure
|
|
44
|
+
|
|
45
|
+
mask = gray <= value if invert else gray > value
|
|
46
|
+
if label_objects:
|
|
47
|
+
return measure.label(mask).astype(np.uint16)
|
|
48
|
+
return mask.astype(np.uint16)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@op(env="skimage")
|
|
52
|
+
def otsu(
|
|
53
|
+
image: _Image,
|
|
54
|
+
invert: bool = False,
|
|
55
|
+
label_objects: bool = True,
|
|
56
|
+
) -> LabelsOf[np.ndarray]:
|
|
57
|
+
"""Threshold an image by Otsu's method.
|
|
58
|
+
|
|
59
|
+
Chooses the threshold that minimizes the variance within the two classes
|
|
60
|
+
it creates, which is the same as maximizing the variance between them.
|
|
61
|
+
The standard first thing to try, and what it assumes -- two comparably
|
|
62
|
+
sized modes -- is also what it fails on.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
image: Image to threshold. A trailing RGB(A) axis is collapsed.
|
|
66
|
+
invert: Whether to keep what falls below the threshold instead.
|
|
67
|
+
label_objects: Whether to label connected components, rather than
|
|
68
|
+
returning a plain binary mask.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A label image, or a 0/1 mask when ``label_objects`` is off.
|
|
72
|
+
"""
|
|
73
|
+
from skimage import filters
|
|
74
|
+
|
|
75
|
+
gray = to_gray(image)
|
|
76
|
+
return _mask(gray, filters.threshold_otsu(gray), invert, label_objects)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@op(env="skimage")
|
|
80
|
+
def isodata(
|
|
81
|
+
image: _Image,
|
|
82
|
+
invert: bool = False,
|
|
83
|
+
label_objects: bool = True,
|
|
84
|
+
) -> LabelsOf[np.ndarray]:
|
|
85
|
+
"""Threshold an image by the isodata (Ridler-Calvard) method.
|
|
86
|
+
|
|
87
|
+
Iterates until the threshold sits exactly halfway between the mean of
|
|
88
|
+
what falls below it and the mean of what falls above. Usually lands very
|
|
89
|
+
near Otsu, and is the method ImageJ's "Default" is descended from.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
image: Image to threshold. A trailing RGB(A) axis is collapsed.
|
|
93
|
+
invert: Whether to keep what falls below the threshold instead.
|
|
94
|
+
label_objects: Whether to label connected components, rather than
|
|
95
|
+
returning a plain binary mask.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A label image, or a 0/1 mask when ``label_objects`` is off.
|
|
99
|
+
"""
|
|
100
|
+
from skimage import filters
|
|
101
|
+
|
|
102
|
+
gray = to_gray(image)
|
|
103
|
+
return _mask(gray, filters.threshold_isodata(gray), invert, label_objects)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@op(env="skimage")
|
|
107
|
+
def li(
|
|
108
|
+
image: _Image,
|
|
109
|
+
invert: bool = False,
|
|
110
|
+
label_objects: bool = True,
|
|
111
|
+
) -> LabelsOf[np.ndarray]:
|
|
112
|
+
"""Threshold an image by Li's minimum cross-entropy method.
|
|
113
|
+
|
|
114
|
+
Minimizes the information lost by replacing each class with its mean,
|
|
115
|
+
which does not require the two classes to be of similar size. The one to
|
|
116
|
+
try when Otsu swallows a sparse foreground.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
image: Image to threshold. A trailing RGB(A) axis is collapsed.
|
|
120
|
+
invert: Whether to keep what falls below the threshold instead.
|
|
121
|
+
label_objects: Whether to label connected components, rather than
|
|
122
|
+
returning a plain binary mask.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A label image, or a 0/1 mask when ``label_objects`` is off.
|
|
126
|
+
"""
|
|
127
|
+
from skimage import filters
|
|
128
|
+
|
|
129
|
+
gray = to_gray(image)
|
|
130
|
+
return _mask(gray, filters.threshold_li(gray), invert, label_objects)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@op(env="skimage")
|
|
134
|
+
def mean(
|
|
135
|
+
image: _Image,
|
|
136
|
+
invert: bool = False,
|
|
137
|
+
label_objects: bool = True,
|
|
138
|
+
) -> LabelsOf[np.ndarray]:
|
|
139
|
+
"""Threshold an image at its mean intensity.
|
|
140
|
+
|
|
141
|
+
The simplest global threshold there is, and a reasonable baseline on an
|
|
142
|
+
image whose background is genuinely uniform.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
image: Image to threshold. A trailing RGB(A) axis is collapsed.
|
|
146
|
+
invert: Whether to keep what falls below the threshold instead.
|
|
147
|
+
label_objects: Whether to label connected components, rather than
|
|
148
|
+
returning a plain binary mask.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
A label image, or a 0/1 mask when ``label_objects`` is off.
|
|
152
|
+
"""
|
|
153
|
+
from skimage import filters
|
|
154
|
+
|
|
155
|
+
gray = to_gray(image)
|
|
156
|
+
return _mask(gray, filters.threshold_mean(gray), invert, label_objects)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@op(env="skimage")
|
|
160
|
+
def minimum(
|
|
161
|
+
image: _Image,
|
|
162
|
+
invert: bool = False,
|
|
163
|
+
label_objects: bool = True,
|
|
164
|
+
) -> LabelsOf[np.ndarray]:
|
|
165
|
+
"""Threshold an image at the valley between its two histogram peaks.
|
|
166
|
+
|
|
167
|
+
Smooths the histogram until exactly two maxima remain, then cuts at the
|
|
168
|
+
minimum between them. The most literal reading of "bimodal", and the
|
|
169
|
+
least forgiving: an image whose histogram will not smooth down to two
|
|
170
|
+
peaks raises rather than guessing.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
image: Image to threshold. A trailing RGB(A) axis is collapsed.
|
|
174
|
+
invert: Whether to keep what falls below the threshold instead.
|
|
175
|
+
label_objects: Whether to label connected components, rather than
|
|
176
|
+
returning a plain binary mask.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A label image, or a 0/1 mask when ``label_objects`` is off.
|
|
180
|
+
"""
|
|
181
|
+
from skimage import filters
|
|
182
|
+
|
|
183
|
+
gray = to_gray(image)
|
|
184
|
+
return _mask(gray, filters.threshold_minimum(gray), invert, label_objects)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@op(env="skimage")
|
|
188
|
+
def triangle(
|
|
189
|
+
image: _Image,
|
|
190
|
+
invert: bool = False,
|
|
191
|
+
label_objects: bool = True,
|
|
192
|
+
) -> LabelsOf[np.ndarray]:
|
|
193
|
+
"""Threshold an image by the triangle method.
|
|
194
|
+
|
|
195
|
+
Draws a line from the histogram's peak to its far end and cuts where the
|
|
196
|
+
histogram is furthest from that line. Geometric rather than statistical,
|
|
197
|
+
which is why it holds up on the heavily skewed histogram of a mostly
|
|
198
|
+
empty fluorescence image.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
image: Image to threshold. A trailing RGB(A) axis is collapsed.
|
|
202
|
+
invert: Whether to keep what falls below the threshold instead.
|
|
203
|
+
label_objects: Whether to label connected components, rather than
|
|
204
|
+
returning a plain binary mask.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
A label image, or a 0/1 mask when ``label_objects`` is off.
|
|
208
|
+
"""
|
|
209
|
+
from skimage import filters
|
|
210
|
+
|
|
211
|
+
gray = to_gray(image)
|
|
212
|
+
return _mask(gray, filters.threshold_triangle(gray), invert, label_objects)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@op(env="skimage")
|
|
216
|
+
def yen(
|
|
217
|
+
image: _Image,
|
|
218
|
+
invert: bool = False,
|
|
219
|
+
label_objects: bool = True,
|
|
220
|
+
) -> LabelsOf[np.ndarray]:
|
|
221
|
+
"""Threshold an image by Yen's maximum correlation criterion.
|
|
222
|
+
|
|
223
|
+
An entropy method like Li's, but maximizing a correlation between the two
|
|
224
|
+
classes rather than minimizing a divergence. Tends to cut higher than
|
|
225
|
+
Otsu, keeping less.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
image: Image to threshold. A trailing RGB(A) axis is collapsed.
|
|
229
|
+
invert: Whether to keep what falls below the threshold instead.
|
|
230
|
+
label_objects: Whether to label connected components, rather than
|
|
231
|
+
returning a plain binary mask.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
A label image, or a 0/1 mask when ``label_objects`` is off.
|
|
235
|
+
"""
|
|
236
|
+
from skimage import filters
|
|
237
|
+
|
|
238
|
+
gray = to_gray(image)
|
|
239
|
+
return _mask(gray, filters.threshold_yen(gray), invert, label_objects)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@op(env="skimage")
|
|
243
|
+
def multiotsu(
|
|
244
|
+
image: _Image,
|
|
245
|
+
classes: int = 3,
|
|
246
|
+
label_objects: bool = False,
|
|
247
|
+
) -> LabelsOf[np.ndarray]:
|
|
248
|
+
"""Split an image into several intensity classes, by Otsu's criterion.
|
|
249
|
+
|
|
250
|
+
Otsu generalized: instead of one threshold there are ``classes - 1`` of
|
|
251
|
+
them, chosen together to minimize the variance within the classes they
|
|
252
|
+
make. Where the other ops here answer "foreground or not", this one
|
|
253
|
+
answers "which layer" -- background, dim, bright -- so it returns those
|
|
254
|
+
classes as a label image, numbered 0 upward by intensity.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
image: Image to threshold. A trailing RGB(A) axis is collapsed.
|
|
258
|
+
classes: How many intensity classes to split into, at least 2.
|
|
259
|
+
label_objects: Whether to label the connected components of the
|
|
260
|
+
brightest class instead, discarding the rest. That makes this a
|
|
261
|
+
drop-in for the two-class ops, cutting nearer the bright end.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
A label image: one label per intensity class, or per object in the
|
|
265
|
+
brightest class when ``label_objects`` is on.
|
|
266
|
+
"""
|
|
267
|
+
from skimage import filters, measure
|
|
268
|
+
|
|
269
|
+
if classes < 2:
|
|
270
|
+
raise ValueError(f"classes must be at least 2, got {classes}")
|
|
271
|
+
|
|
272
|
+
gray = to_gray(image)
|
|
273
|
+
thresholds = filters.threshold_multiotsu(gray, classes=classes)
|
|
274
|
+
if label_objects:
|
|
275
|
+
return measure.label(gray > thresholds[-1]).astype(np.uint16)
|
|
276
|
+
# Note: right=True so a pixel *at* a threshold falls below it, matching
|
|
277
|
+
# the strict `gray > value` the two-class ops here use.
|
|
278
|
+
return np.digitize(gray, bins=thresholds, right=True).astype(np.uint16)
|
skop/ops/toy.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Toy ops, used to exercise skop without dragging in a deep learning stack.
|
|
2
|
+
|
|
3
|
+
Note the shape of an op module: imports at the top are limited to what the
|
|
4
|
+
signatures need, so this module loads in a minimal environment. Anything an
|
|
5
|
+
op needs only in order to *run* is imported inside the function body.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Annotated, NamedTuple
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from opspec.op import Axes, Out, op
|
|
16
|
+
from opspec.runtime import cancel_requested, progress
|
|
17
|
+
from opspec.types import ImageOf, LabelsOf, PointsOf
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@op(env="minimal")
|
|
21
|
+
def add(a: int, b: int) -> int:
|
|
22
|
+
"""Add two numbers. The smallest op there is."""
|
|
23
|
+
return a + b
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ScaleResult(NamedTuple):
|
|
27
|
+
scaled: np.ndarray
|
|
28
|
+
total: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@op(env="minimal")
|
|
32
|
+
def scale(
|
|
33
|
+
image: np.ndarray,
|
|
34
|
+
factor: Annotated[
|
|
35
|
+
float,
|
|
36
|
+
{"widget_type": "FloatSlider", "min": 0.0, "max": 10.0, "step": 0.1},
|
|
37
|
+
] = 2.0,
|
|
38
|
+
) -> ScaleResult:
|
|
39
|
+
"""Scale an array, returning the result alongside its sum.
|
|
40
|
+
|
|
41
|
+
Exercises multi-output ops: the NamedTuple field names become the names
|
|
42
|
+
of the task's outputs.
|
|
43
|
+
"""
|
|
44
|
+
scaled = image * factor
|
|
45
|
+
return ScaleResult(scaled=scaled, total=float(scaled.sum()))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@op(env="minimal")
|
|
49
|
+
def scale_into(
|
|
50
|
+
image: ImageOf[np.ndarray],
|
|
51
|
+
result: Out[ImageOf[np.ndarray]],
|
|
52
|
+
factor: float = 2.0,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Computer form: fill a buffer the caller allocated.
|
|
55
|
+
|
|
56
|
+
The caller owns ``result``, so nothing is sent back over the wire. Note
|
|
57
|
+
that ``Out`` and a role compose: nested Annotated flattens, so ``result``
|
|
58
|
+
is both an output buffer and an image.
|
|
59
|
+
"""
|
|
60
|
+
np.multiply(image, factor, out=result, casting="unsafe")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@op(env="minimal")
|
|
64
|
+
def quadrants(
|
|
65
|
+
image: Annotated[ImageOf[np.ndarray], Axes("y", "x")],
|
|
66
|
+
) -> LabelsOf[np.ndarray]:
|
|
67
|
+
"""Label a plane's four quadrants, 1 through 4.
|
|
68
|
+
|
|
69
|
+
A strictly 2-D op, and the standing test case for adapting one: fed a
|
|
70
|
+
stack, every plane numbers its own objects from 1, so a correct
|
|
71
|
+
reassembly has to renumber them.
|
|
72
|
+
"""
|
|
73
|
+
height, width = image.shape
|
|
74
|
+
labels = np.ones(image.shape, dtype=np.uint16)
|
|
75
|
+
labels[: height // 2, width // 2 :] = 2
|
|
76
|
+
labels[height // 2 :, : width // 2] = 3
|
|
77
|
+
labels[height // 2 :, width // 2 :] = 4
|
|
78
|
+
return labels
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@op(env="minimal")
|
|
82
|
+
def slow_sum(image: np.ndarray, steps: int = 5) -> float:
|
|
83
|
+
"""Sum an array slowly, reporting progress and honoring cancellation."""
|
|
84
|
+
total = 0.0
|
|
85
|
+
chunks = np.array_split(image.ravel(), steps)
|
|
86
|
+
for i, chunk in enumerate(chunks):
|
|
87
|
+
if cancel_requested():
|
|
88
|
+
break
|
|
89
|
+
progress(f"Summing chunk {i + 1} of {steps}", i, steps)
|
|
90
|
+
total += float(chunk.sum())
|
|
91
|
+
time.sleep(0.05)
|
|
92
|
+
return total
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@op(env="minimal")
|
|
96
|
+
def probe() -> str:
|
|
97
|
+
"""Report which interpreter is actually executing this op."""
|
|
98
|
+
import sys
|
|
99
|
+
|
|
100
|
+
return f"{sys.executable} | py={sys.version.split()[0]} | numpy={np.__version__}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Findings(NamedTuple):
|
|
104
|
+
labels: LabelsOf[np.ndarray]
|
|
105
|
+
points: PointsOf[np.ndarray]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@op(env="minimal")
|
|
109
|
+
def find_nothing(image: ImageOf[np.ndarray]) -> Findings:
|
|
110
|
+
"""Return an empty result, as a segmentation that finds nothing would."""
|
|
111
|
+
return Findings(
|
|
112
|
+
labels=np.zeros_like(image, dtype=np.uint16),
|
|
113
|
+
points=np.zeros((0, 3), dtype=np.int32),
|
|
114
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Training ops: input/truth pairs in, a model on disk out.
|
|
2
|
+
|
|
3
|
+
Also what you want to know *before* training -- the receptive field of the
|
|
4
|
+
architecture you are about to build. It sits here rather than in a category of
|
|
5
|
+
its own because it takes the same architecture parameters the training op
|
|
6
|
+
does, and a change to one is a change to both.
|
|
7
|
+
|
|
8
|
+
Training ops differ from the rest of skop in two ways, both settled in
|
|
9
|
+
design 0011. They take **paths, not arrays** -- a manifest of image files and
|
|
10
|
+
the label files that pair with them, one list each, matched by position. And
|
|
11
|
+
they **return a path**: a trained TensorFlow or torch model cannot cross the
|
|
12
|
+
worker boundary, so the op writes it and hands back where.
|
|
13
|
+
|
|
14
|
+
The caller resolves its own directory layout into those two lists. Nothing in
|
|
15
|
+
here knows what a patch directory looks like, and nothing in here should.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .cellpose4 import train_cellpose4
|
|
23
|
+
from .cellpose3 import train_cellpose3
|
|
24
|
+
from .stardist2d import receptive_field_stardist2d, train_stardist2d
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"train_cellpose4",
|
|
28
|
+
"train_cellpose3",
|
|
29
|
+
"train_stardist2d",
|
|
30
|
+
"receptive_field_stardist2d",
|
|
31
|
+
"extent",
|
|
32
|
+
"theoretical_receptive_field",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extent(response) -> tuple[int, int]:
|
|
37
|
+
"""The height and width, in pixels, that an impulse response covers.
|
|
38
|
+
|
|
39
|
+
Takes what a ``receptive_field_*`` op returns. Pure numpy, so it runs
|
|
40
|
+
wherever the caller is rather than in the op's environment.
|
|
41
|
+
"""
|
|
42
|
+
reached = np.asarray(response) > 0
|
|
43
|
+
if not reached.any():
|
|
44
|
+
return (0, 0)
|
|
45
|
+
rows, cols = np.where(reached)
|
|
46
|
+
return (int(rows.max() - rows.min() + 1), int(cols.max() - cols.min() + 1))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def theoretical_receptive_field(
|
|
50
|
+
unet_n_depth: int = 3,
|
|
51
|
+
grid_size_xy: int = 1,
|
|
52
|
+
unet_kernel_size: int = 3,
|
|
53
|
+
unet_pool: int = 2,
|
|
54
|
+
unet_n_conv_per_depth: int = 2,
|
|
55
|
+
) -> int:
|
|
56
|
+
"""What the architecture predicts, for comparison with the measurement.
|
|
57
|
+
|
|
58
|
+
Counted the usual way -- each convolution adds ``kernel - 1`` times the
|
|
59
|
+
current stride, each pooling multiplies the stride -- down through the
|
|
60
|
+
encoder, across the bottleneck and back up. ``grid`` multiplies the result
|
|
61
|
+
because StarDist pools the input to grid size before the U-Net, so the
|
|
62
|
+
whole network sees a coarser image.
|
|
63
|
+
|
|
64
|
+
An estimate, and the measurement is the authority: it counts the final
|
|
65
|
+
convolutions after the U-Net, which this does not, so it reads low.
|
|
66
|
+
Compare the two, and when they disagree believe the impulse.
|
|
67
|
+
"""
|
|
68
|
+
k, pool = unet_kernel_size, unet_pool
|
|
69
|
+
r, stride = 1, 1
|
|
70
|
+
for _ in range(unet_n_depth):
|
|
71
|
+
for _ in range(unet_n_conv_per_depth):
|
|
72
|
+
r += (k - 1) * stride
|
|
73
|
+
r += (pool - 1) * stride
|
|
74
|
+
stride *= pool
|
|
75
|
+
for _ in range(unet_n_conv_per_depth):
|
|
76
|
+
r += (k - 1) * stride
|
|
77
|
+
for _ in range(unet_n_depth):
|
|
78
|
+
stride //= pool
|
|
79
|
+
for _ in range(unet_n_conv_per_depth):
|
|
80
|
+
r += (k - 1) * stride
|
|
81
|
+
return r * grid_size_xy
|
skop/ops/train/_util.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Shared by the two Cellpose trainers.
|
|
2
|
+
|
|
3
|
+
Both wrap the same ``cellpose.train.train_seg`` in different versions of the
|
|
4
|
+
package. Pure python, so either environment can import it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from opspec.runtime import progress
|
|
15
|
+
|
|
16
|
+
#: The line train_seg logs at the end of an epoch, in both versions:
|
|
17
|
+
#: ``12, train_loss=0.4213, test_loss=0.5012, LR=0.000010, time 3.21s``
|
|
18
|
+
_EPOCH_LINE = re.compile(r"^(\d+), train_loss=(\S+), test_loss=(\S+),")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class relay_epochs:
|
|
22
|
+
"""Turn Cellpose's epoch log line into ``progress()`` calls.
|
|
23
|
+
|
|
24
|
+
``train_seg`` takes no callback, so its log is the only live signal. A
|
|
25
|
+
handler rather than a stream capture: Appose's protocol is on stdout.
|
|
26
|
+
Removed on exit, or the next run reports this one's epochs too.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, epochs: int):
|
|
30
|
+
self.epochs = epochs
|
|
31
|
+
self.history: list[tuple[int, float, float]] = []
|
|
32
|
+
|
|
33
|
+
def __enter__(self):
|
|
34
|
+
relay = self
|
|
35
|
+
|
|
36
|
+
class Handler(logging.Handler):
|
|
37
|
+
def emit(self, record):
|
|
38
|
+
match = _EPOCH_LINE.match(record.getMessage())
|
|
39
|
+
if match is None:
|
|
40
|
+
return
|
|
41
|
+
epoch = int(match.group(1)) + 1
|
|
42
|
+
loss, val_loss = float(match.group(2)), float(match.group(3))
|
|
43
|
+
relay.history.append((epoch, loss, val_loss))
|
|
44
|
+
progress(
|
|
45
|
+
f"Epoch {epoch}/{relay.epochs} — "
|
|
46
|
+
f"loss {loss:.4f}, val_loss {val_loss:.4f}",
|
|
47
|
+
epoch,
|
|
48
|
+
relay.epochs,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
self._handler = Handler()
|
|
52
|
+
self._log = logging.getLogger("cellpose.train")
|
|
53
|
+
self._level = self._log.level
|
|
54
|
+
self._propagate = self._log.propagate
|
|
55
|
+
# INFO, or the record is dropped before any handler sees it. No
|
|
56
|
+
# propagation, or it reaches a stdout handler and breaks the protocol.
|
|
57
|
+
self._log.setLevel(logging.INFO)
|
|
58
|
+
self._log.propagate = False
|
|
59
|
+
self._log.addHandler(self._handler)
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, *exc):
|
|
63
|
+
self._log.removeHandler(self._handler)
|
|
64
|
+
self._log.setLevel(self._level)
|
|
65
|
+
self._log.propagate = self._propagate
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def continued_from(model_path, initial_model: str) -> bool:
|
|
69
|
+
"""Whether this run carried on the model it is about to write.
|
|
70
|
+
|
|
71
|
+
True only when ``initial_model`` is the same file as ``model_path``.
|
|
72
|
+
Starting from a built-in, or from some *other* model of your own, is a new
|
|
73
|
+
lineage however familiar the name -- the weights do not continue, so
|
|
74
|
+
neither does the curve.
|
|
75
|
+
"""
|
|
76
|
+
if not initial_model:
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
return os.path.samefile(initial_model, model_path)
|
|
80
|
+
except OSError:
|
|
81
|
+
# One of them does not exist yet -- the first run writing this name.
|
|
82
|
+
return os.path.abspath(initial_model) == os.path.abspath(model_path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_history(
|
|
86
|
+
model_path, history, name: str, dataset_id: str, initial_model: str = ""
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Write this run's epochs to ``<model file>_history.csv``. Columns match
|
|
89
|
+
``train_stardist2d``. Rewritten rather than appended: an older file has a
|
|
90
|
+
shorter header, and wider rows read back wrong.
|
|
91
|
+
|
|
92
|
+
Kept epochs continue the old ones only when this run continued the old
|
|
93
|
+
*weights* -- see ``continued_from``. Otherwise the previous history is
|
|
94
|
+
dropped, because Cellpose has just overwritten the model it described:
|
|
95
|
+
keeping a curve for weights that no longer exist draws one training run
|
|
96
|
+
as though it were the tail of another.
|
|
97
|
+
"""
|
|
98
|
+
columns = ["epoch", "loss", "val_loss", "run", "model", "dataset"]
|
|
99
|
+
path = f"{model_path}_history.csv"
|
|
100
|
+
|
|
101
|
+
previous = []
|
|
102
|
+
if continued_from(model_path, initial_model) and os.path.exists(path):
|
|
103
|
+
with open(path) as f:
|
|
104
|
+
previous = list(csv.DictReader(f))
|
|
105
|
+
last_run = int(previous[-1].get("run") or 0) if previous else 0
|
|
106
|
+
# Where the last run got to, not how many rows it wrote: Cellpose logs
|
|
107
|
+
# every tenth epoch, so rows and epochs are not the same count.
|
|
108
|
+
done = int(previous[-1].get("epoch") or 0) if previous else 0
|
|
109
|
+
|
|
110
|
+
with open(path, "w", newline="") as f:
|
|
111
|
+
writer = csv.DictWriter(f, columns, restval="")
|
|
112
|
+
writer.writeheader()
|
|
113
|
+
for row in previous:
|
|
114
|
+
writer.writerow({k: row.get(k, "") for k in columns})
|
|
115
|
+
for epoch, loss, val_loss in history:
|
|
116
|
+
writer.writerow(
|
|
117
|
+
{
|
|
118
|
+
"epoch": done + epoch,
|
|
119
|
+
"loss": loss,
|
|
120
|
+
"val_loss": val_loss,
|
|
121
|
+
"run": last_run + 1,
|
|
122
|
+
"model": name,
|
|
123
|
+
"dataset": dataset_id,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
return path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_pairs(images: list[str], labels: list[str], val_size: int):
|
|
130
|
+
"""Read the patches, validation split off the end. Not random -- a caller
|
|
131
|
+
wanting that shuffles its own lists and records the seed."""
|
|
132
|
+
from tifffile import imread
|
|
133
|
+
|
|
134
|
+
if len(images) != len(labels):
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"{len(images)} images and {len(labels)} labels: a training set "
|
|
137
|
+
f"is pairs, so the two lists must be the same length."
|
|
138
|
+
)
|
|
139
|
+
if len(images) <= val_size:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"{len(images)} pairs is not enough to hold {val_size} back for "
|
|
142
|
+
f"validation and still train on the rest."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
progress(f"Reading {len(images)} pairs", 0, len(images))
|
|
146
|
+
X, Y = [], []
|
|
147
|
+
for i, (image_path, label_path) in enumerate(zip(images, labels)):
|
|
148
|
+
X.append(imread(image_path))
|
|
149
|
+
Y.append(imread(label_path))
|
|
150
|
+
progress(current=i + 1)
|
|
151
|
+
|
|
152
|
+
return X[:-val_size], Y[:-val_size], X[-val_size:], Y[-val_size:]
|