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/edges.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Derivative filters: edges, ridges, and the sharpening they enable.
|
|
2
|
+
|
|
3
|
+
Where :mod:`skop.ops.smooth` averages a neighborhood, everything here
|
|
4
|
+
*differences* one. The first group -- :func:`sobel` and its relatives -- are
|
|
5
|
+
first-derivative operators, and answer "how fast is intensity changing here",
|
|
6
|
+
which is large at a boundary and near zero inside an object. They differ only
|
|
7
|
+
in the small kernel they use to estimate that derivative, and the differences
|
|
8
|
+
between them matter mainly on noisy or anisotropic data.
|
|
9
|
+
|
|
10
|
+
The second group works on the second derivative, which is what separates a
|
|
11
|
+
*ridge* -- a line, a filament, a vessel -- from a step edge. :func:`frangi`
|
|
12
|
+
and friends examine the Hessian's eigenvalues over a range of scales, so they
|
|
13
|
+
respond to structures of a given thickness rather than to contrast alone.
|
|
14
|
+
scikit-image's fourth ridge filter, ``hessian``, is deliberately not wrapped:
|
|
15
|
+
it is ``frangi`` with every non-positive response replaced by 1, so the
|
|
16
|
+
background comes back reading as a maximal detection, and nothing sensible
|
|
17
|
+
happens when a front end hands that to a threshold.
|
|
18
|
+
|
|
19
|
+
:func:`unsharp_mask` is the third thing derivatives are good for: adding an
|
|
20
|
+
image's own detail back to it.
|
|
21
|
+
|
|
22
|
+
Every op takes an image and returns a response map of the same shape, as
|
|
23
|
+
float32 -- these are filters, and their output is meant to be thresholded or
|
|
24
|
+
fed onward, not read as intensities. A trailing RGB(A) axis is filtered
|
|
25
|
+
channel by channel.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Annotated
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from opspec.op import Axes, op
|
|
35
|
+
from opspec.types import ImageOf
|
|
36
|
+
|
|
37
|
+
from ._util import channel_axis, per_channel, to_gray
|
|
38
|
+
|
|
39
|
+
#: An op happy with any number of axes, RGB(A) handled channel by channel.
|
|
40
|
+
_Image = Annotated[ImageOf[np.ndarray], Axes(variadic=True)]
|
|
41
|
+
|
|
42
|
+
#: An op that only knows about planes, with or without colour.
|
|
43
|
+
_Plane = Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")]
|
|
44
|
+
|
|
45
|
+
#: An op that handles a plane or a volume, and nothing else.
|
|
46
|
+
_Volume = Annotated[ImageOf[np.ndarray], Axes("z?", "y", "x")]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sigmas(sigma_min: float, sigma_max: float, sigma_step: float) -> np.ndarray:
|
|
50
|
+
"""The scales a ridge filter sweeps, from three numbers a GUI can offer."""
|
|
51
|
+
if sigma_step <= 0:
|
|
52
|
+
raise ValueError(f"sigma_step must be positive, got {sigma_step}")
|
|
53
|
+
if sigma_max < sigma_min:
|
|
54
|
+
raise ValueError(f"sigma_max {sigma_max} is below sigma_min {sigma_min}")
|
|
55
|
+
return np.arange(sigma_min, sigma_max + sigma_step / 2, sigma_step)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _apply(image: np.ndarray, fn) -> np.ndarray:
|
|
59
|
+
return per_channel(np.asarray(image, dtype=np.float32), fn).astype(np.float32)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@op(env="skimage")
|
|
63
|
+
def sobel(image: _Image) -> ImageOf[np.ndarray]:
|
|
64
|
+
"""Find edges with the Sobel operator.
|
|
65
|
+
|
|
66
|
+
A central difference along each axis, smoothed across the others, with
|
|
67
|
+
the per-axis responses combined into one magnitude. The default edge
|
|
68
|
+
filter, and the one to reach for absent a reason not to: it is cheap,
|
|
69
|
+
and the smoothing built into its kernel makes it far steadier on noisy
|
|
70
|
+
data than a bare difference.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
image: Image to filter. Any number of axes; a trailing RGB(A) axis is
|
|
74
|
+
filtered channel by channel.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Edge magnitude, as float32.
|
|
78
|
+
"""
|
|
79
|
+
from skimage import filters
|
|
80
|
+
|
|
81
|
+
return _apply(image, filters.sobel)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@op(env="skimage")
|
|
85
|
+
def scharr(image: _Image) -> ImageOf[np.ndarray]:
|
|
86
|
+
"""Find edges with the Scharr operator.
|
|
87
|
+
|
|
88
|
+
Sobel's kernel, with the weights rebalanced so that the operator's
|
|
89
|
+
response is as close to rotationally symmetric as a 3x3 kernel can be. A
|
|
90
|
+
diagonal edge therefore reads at the same strength as an axis-aligned
|
|
91
|
+
one, which matters when the magnitude is going to be compared against a
|
|
92
|
+
threshold or an orientation read off it.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
image: Image to filter. Any number of axes; a trailing RGB(A) axis is
|
|
96
|
+
filtered channel by channel.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Edge magnitude, as float32.
|
|
100
|
+
"""
|
|
101
|
+
from skimage import filters
|
|
102
|
+
|
|
103
|
+
return _apply(image, filters.scharr)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@op(env="skimage")
|
|
107
|
+
def prewitt(image: _Image) -> ImageOf[np.ndarray]:
|
|
108
|
+
"""Find edges with the Prewitt operator.
|
|
109
|
+
|
|
110
|
+
Sobel without the extra weight on the centre row: the smoothing across
|
|
111
|
+
the perpendicular axis is a plain box mean rather than a triangular one.
|
|
112
|
+
Marginally cheaper, marginally noisier, and included because a good deal
|
|
113
|
+
of older literature specifies it.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
image: Image to filter. Any number of axes; a trailing RGB(A) axis is
|
|
117
|
+
filtered channel by channel.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Edge magnitude, as float32.
|
|
121
|
+
"""
|
|
122
|
+
from skimage import filters
|
|
123
|
+
|
|
124
|
+
return _apply(image, filters.prewitt)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@op(env="skimage")
|
|
128
|
+
def farid(image: _Plane) -> ImageOf[np.ndarray]:
|
|
129
|
+
"""Find edges with the Farid-Simoncelli operator.
|
|
130
|
+
|
|
131
|
+
A larger kernel, fitted so that the derivative it computes is as accurate
|
|
132
|
+
as possible rather than merely symmetric. Worth its extra cost when the
|
|
133
|
+
gradient's *value* is being used -- for orientation, or optical flow --
|
|
134
|
+
rather than just thresholded.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
image: Image to filter, as a plane with an optional RGB(A) axis.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Edge magnitude, as float32.
|
|
141
|
+
"""
|
|
142
|
+
from skimage import filters
|
|
143
|
+
|
|
144
|
+
return _apply(image, filters.farid)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@op(env="skimage")
|
|
148
|
+
def roberts(image: _Plane) -> ImageOf[np.ndarray]:
|
|
149
|
+
"""Find edges with the Roberts cross operator.
|
|
150
|
+
|
|
151
|
+
The smallest edge filter there is: two 2x2 diagonal differences. It
|
|
152
|
+
localizes an edge to within a pixel, which no larger kernel does, and it
|
|
153
|
+
has no noise rejection whatsoever -- so it belongs on clean, high
|
|
154
|
+
contrast images and nowhere else.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
image: Image to filter, as a plane. A trailing RGB(A) axis is
|
|
158
|
+
filtered channel by channel.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Edge magnitude, as float32.
|
|
162
|
+
"""
|
|
163
|
+
from skimage import filters
|
|
164
|
+
|
|
165
|
+
return _apply(image, filters.roberts)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@op(env="skimage")
|
|
169
|
+
def laplace(image: _Image, ksize: int = 3) -> ImageOf[np.ndarray]:
|
|
170
|
+
"""Find edges with the Laplacian, the sum of the second derivatives.
|
|
171
|
+
|
|
172
|
+
Unlike the gradient filters, this is not directional: it responds to
|
|
173
|
+
curvature, so it peaks on *either* side of an edge and crosses zero at
|
|
174
|
+
the edge itself. Those zero crossings, not the peaks, are the edge --
|
|
175
|
+
which is why the Laplacian is usually a step toward something else
|
|
176
|
+
rather than an answer. It amplifies noise sharply; smooth first.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
image: Image to filter. Any number of axes; a trailing RGB(A) axis is
|
|
180
|
+
filtered channel by channel.
|
|
181
|
+
ksize: Width of the discrete Laplacian kernel, in pixels.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The Laplacian, as float32. Signed, unlike the gradient filters.
|
|
185
|
+
"""
|
|
186
|
+
from skimage import filters
|
|
187
|
+
|
|
188
|
+
return _apply(image, lambda plane: filters.laplace(plane, ksize=ksize))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@op(env="skimage")
|
|
192
|
+
def difference_of_gaussians(
|
|
193
|
+
image: _Image,
|
|
194
|
+
low_sigma: float = 1.0,
|
|
195
|
+
high_sigma: float = 4.0,
|
|
196
|
+
) -> ImageOf[np.ndarray]:
|
|
197
|
+
"""Band-pass an image, by subtracting one Gaussian blur from another.
|
|
198
|
+
|
|
199
|
+
Blurring at two scales and taking the difference keeps exactly the
|
|
200
|
+
structure that falls between them: finer detail is in both blurs and
|
|
201
|
+
cancels, coarser shading is in neither. That makes this the standard way
|
|
202
|
+
to find blobs of a known size, and equally the standard way to flatten
|
|
203
|
+
uneven illumination while keeping the objects.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
image: Image to filter. Any number of axes; a trailing RGB(A) axis is
|
|
207
|
+
filtered channel by channel.
|
|
208
|
+
low_sigma: Standard deviation of the finer blur, in pixels. Sets the
|
|
209
|
+
smallest detail kept.
|
|
210
|
+
high_sigma: Standard deviation of the coarser blur, in pixels. Sets
|
|
211
|
+
the largest. Must exceed ``low_sigma``.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The band-passed image, as float32. Signed.
|
|
215
|
+
"""
|
|
216
|
+
from skimage import filters
|
|
217
|
+
|
|
218
|
+
if high_sigma <= low_sigma:
|
|
219
|
+
raise ValueError(f"high_sigma {high_sigma} must exceed low_sigma {low_sigma}")
|
|
220
|
+
filtered = filters.difference_of_gaussians(
|
|
221
|
+
np.asarray(image, dtype=np.float32),
|
|
222
|
+
low_sigma=low_sigma,
|
|
223
|
+
high_sigma=high_sigma,
|
|
224
|
+
channel_axis=channel_axis(image),
|
|
225
|
+
)
|
|
226
|
+
return filtered.astype(np.float32)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@op(env="skimage")
|
|
230
|
+
def unsharp_mask(
|
|
231
|
+
image: _Image,
|
|
232
|
+
radius: float = 1.0,
|
|
233
|
+
amount: float = 1.0,
|
|
234
|
+
) -> ImageOf[np.ndarray]:
|
|
235
|
+
"""Sharpen an image by adding back what blurring it removes.
|
|
236
|
+
|
|
237
|
+
Blur the image, subtract that from the original to get the detail, then
|
|
238
|
+
add a multiple of the detail back. Older than digital imaging -- the name
|
|
239
|
+
is from the darkroom -- and still the sharpening every tool implements.
|
|
240
|
+
It creates no information, so overdoing it produces halos around edges
|
|
241
|
+
rather than more detail.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
image: Image to sharpen. Any number of axes; a trailing RGB(A) axis
|
|
245
|
+
is sharpened channel by channel.
|
|
246
|
+
radius: Standard deviation of the blur that defines "detail", in
|
|
247
|
+
pixels. Larger radii sharpen coarser structure.
|
|
248
|
+
amount: How much of the detail to add back. 0 is a no-op, 1 is the
|
|
249
|
+
usual starting point, and much above 2 halos.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The sharpened image, as float32.
|
|
253
|
+
"""
|
|
254
|
+
from skimage import filters
|
|
255
|
+
|
|
256
|
+
sharpened = filters.unsharp_mask(
|
|
257
|
+
np.asarray(image, dtype=np.float32),
|
|
258
|
+
radius=radius,
|
|
259
|
+
amount=amount,
|
|
260
|
+
preserve_range=True,
|
|
261
|
+
channel_axis=channel_axis(image),
|
|
262
|
+
)
|
|
263
|
+
return sharpened.astype(np.float32)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@op(env="skimage")
|
|
267
|
+
def frangi(
|
|
268
|
+
image: _Volume,
|
|
269
|
+
sigma_min: float = 1.0,
|
|
270
|
+
sigma_max: float = 10.0,
|
|
271
|
+
sigma_step: float = 2.0,
|
|
272
|
+
black_ridges: bool = True,
|
|
273
|
+
alpha: float = 0.5,
|
|
274
|
+
beta: float = 0.5,
|
|
275
|
+
) -> ImageOf[np.ndarray]:
|
|
276
|
+
"""Enhance tubular structures, by Frangi's vesselness measure.
|
|
277
|
+
|
|
278
|
+
Written for angiograms and used ever since for anything filamentous --
|
|
279
|
+
vessels, neurites, cytoskeleton. At each scale it takes the Hessian's
|
|
280
|
+
eigenvalues and asks whether they look like a tube: one small eigenvalue
|
|
281
|
+
along the structure, large ones across it. The strongest response over
|
|
282
|
+
all the scales is kept, so a range of ``sigmas`` covering the thicknesses
|
|
283
|
+
present is the parameter that matters.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
image: Image to filter, as a plane or a volume. RGB(A) is collapsed
|
|
287
|
+
to grayscale, since a vesselness has no per-channel meaning.
|
|
288
|
+
sigma_min: Smallest structure thickness to look for, as a Gaussian
|
|
289
|
+
sigma in pixels.
|
|
290
|
+
sigma_max: Largest, inclusive.
|
|
291
|
+
sigma_step: Spacing between the scales swept.
|
|
292
|
+
black_ridges: Whether to find dark structures on a light background,
|
|
293
|
+
as in a brightfield image, rather than the reverse.
|
|
294
|
+
alpha: Sensitivity to plate-like rather than tube-like structure.
|
|
295
|
+
beta: Sensitivity to blob-like rather than tube-like structure.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Vesselness in [0, 1], as float32.
|
|
299
|
+
"""
|
|
300
|
+
from skimage import filters
|
|
301
|
+
|
|
302
|
+
return filters.frangi(
|
|
303
|
+
to_gray(np.asarray(image, dtype=np.float32)),
|
|
304
|
+
sigmas=_sigmas(sigma_min, sigma_max, sigma_step),
|
|
305
|
+
alpha=alpha,
|
|
306
|
+
beta=beta,
|
|
307
|
+
black_ridges=black_ridges,
|
|
308
|
+
).astype(np.float32)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@op(env="skimage")
|
|
312
|
+
def sato(
|
|
313
|
+
image: _Volume,
|
|
314
|
+
sigma_min: float = 1.0,
|
|
315
|
+
sigma_max: float = 10.0,
|
|
316
|
+
sigma_step: float = 2.0,
|
|
317
|
+
black_ridges: bool = True,
|
|
318
|
+
) -> ImageOf[np.ndarray]:
|
|
319
|
+
"""Enhance tubular structures, by Sato's tubeness measure.
|
|
320
|
+
|
|
321
|
+
The same Hessian sweep as :func:`frangi` with a simpler combination of
|
|
322
|
+
the eigenvalues, and no free sensitivity parameters. That makes it the
|
|
323
|
+
easier of the two to use, and the less selective: it responds more
|
|
324
|
+
readily at junctions and to structures that are not quite tubes.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
image: Image to filter, as a plane or a volume. RGB(A) is collapsed
|
|
328
|
+
to grayscale.
|
|
329
|
+
sigma_min: Smallest structure thickness to look for, as a Gaussian
|
|
330
|
+
sigma in pixels.
|
|
331
|
+
sigma_max: Largest, inclusive.
|
|
332
|
+
sigma_step: Spacing between the scales swept.
|
|
333
|
+
black_ridges: Whether to find dark structures on a light background
|
|
334
|
+
rather than the reverse.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Tubeness, as float32.
|
|
338
|
+
"""
|
|
339
|
+
from skimage import filters
|
|
340
|
+
|
|
341
|
+
return filters.sato(
|
|
342
|
+
to_gray(np.asarray(image, dtype=np.float32)),
|
|
343
|
+
sigmas=_sigmas(sigma_min, sigma_max, sigma_step),
|
|
344
|
+
black_ridges=black_ridges,
|
|
345
|
+
).astype(np.float32)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@op(env="skimage")
|
|
349
|
+
def meijering(
|
|
350
|
+
image: _Volume,
|
|
351
|
+
sigma_min: float = 1.0,
|
|
352
|
+
sigma_max: float = 10.0,
|
|
353
|
+
sigma_step: float = 2.0,
|
|
354
|
+
black_ridges: bool = True,
|
|
355
|
+
) -> ImageOf[np.ndarray]:
|
|
356
|
+
"""Enhance neurite-like structures, by Meijering's neuriteness measure.
|
|
357
|
+
|
|
358
|
+
A Hessian filter tuned for thin, low-contrast, branching structures --
|
|
359
|
+
it was written for tracing neurites in noisy microscopy. It holds up
|
|
360
|
+
better than :func:`frangi` where the structure is barely above the
|
|
361
|
+
background, and is correspondingly noisier where it is not.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
image: Image to filter, as a plane or a volume. RGB(A) is collapsed
|
|
365
|
+
to grayscale.
|
|
366
|
+
sigma_min: Smallest structure thickness to look for, as a Gaussian
|
|
367
|
+
sigma in pixels.
|
|
368
|
+
sigma_max: Largest, inclusive.
|
|
369
|
+
sigma_step: Spacing between the scales swept.
|
|
370
|
+
black_ridges: Whether to find dark structures on a light background
|
|
371
|
+
rather than the reverse.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Neuriteness, as float32.
|
|
375
|
+
"""
|
|
376
|
+
from skimage import filters
|
|
377
|
+
|
|
378
|
+
return filters.meijering(
|
|
379
|
+
to_gray(np.asarray(image, dtype=np.float32)),
|
|
380
|
+
sigmas=_sigmas(sigma_min, sigma_max, sigma_step),
|
|
381
|
+
black_ridges=black_ridges,
|
|
382
|
+
).astype(np.float32)
|
skop/ops/generate.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Synthetic data, for exercising ops that have nothing to segment yet.
|
|
2
|
+
|
|
3
|
+
Kept out of ``skop.ops.segment`` because generating a volume is not
|
|
4
|
+
segmenting one, and because these ops run in a light environment rather than
|
|
5
|
+
the deep learning stacks the segmenters need.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from opspec.op import op
|
|
13
|
+
from opspec.runtime import progress
|
|
14
|
+
from opspec.types import ImageOf
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@op(env="skimage")
|
|
18
|
+
def synthetic_nuclei(
|
|
19
|
+
size_z: int = 64,
|
|
20
|
+
size_y: int = 256,
|
|
21
|
+
size_x: int = 256,
|
|
22
|
+
n_nuclei: int = 8,
|
|
23
|
+
radius_z_min: float = 2.5,
|
|
24
|
+
radius_z_max: float = 4.5,
|
|
25
|
+
radius_xy_min: float = 16.0,
|
|
26
|
+
radius_xy_max: float = 22.0,
|
|
27
|
+
noise_level: int = 6553,
|
|
28
|
+
seed: int | None = None,
|
|
29
|
+
) -> ImageOf[np.ndarray]:
|
|
30
|
+
"""Generate a synthetic 3D volume of anisotropic Gaussian blob nuclei.
|
|
31
|
+
|
|
32
|
+
The default radii are centered on the confocal model's training data, so
|
|
33
|
+
the result is something ``skop.ops.segment.segment_nuclei`` should be
|
|
34
|
+
able to find.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
size_z: Volume extent along Z.
|
|
38
|
+
size_y: Volume extent along Y.
|
|
39
|
+
size_x: Volume extent along X.
|
|
40
|
+
n_nuclei: How many nuclei to place.
|
|
41
|
+
radius_z_min: Smallest nucleus radius along Z, as a Gaussian sigma.
|
|
42
|
+
radius_z_max: Largest nucleus radius along Z.
|
|
43
|
+
radius_xy_min: Smallest nucleus radius in XY.
|
|
44
|
+
radius_xy_max: Largest nucleus radius in XY.
|
|
45
|
+
noise_level: Amplitude of the uniform background noise.
|
|
46
|
+
seed: Seed for the placement of nuclei. None places them at random,
|
|
47
|
+
which means the number that can actually be told apart varies.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A uint16 volume in (Z, Y, X) order.
|
|
51
|
+
"""
|
|
52
|
+
from scipy.ndimage import gaussian_filter
|
|
53
|
+
|
|
54
|
+
shape = (size_z, size_y, size_x)
|
|
55
|
+
rng = np.random.default_rng(seed)
|
|
56
|
+
volume = (rng.random(shape) * noise_level).astype(np.uint16)
|
|
57
|
+
|
|
58
|
+
zz, yy, xx = np.mgrid[0 : shape[0], 0 : shape[1], 0 : shape[2]]
|
|
59
|
+
|
|
60
|
+
for i in range(n_nuclei):
|
|
61
|
+
progress(f"Placing nucleus {i + 1} of {n_nuclei}", i, n_nuclei)
|
|
62
|
+
z = rng.integers(0, shape[0])
|
|
63
|
+
y = rng.integers(0, shape[1])
|
|
64
|
+
x = rng.integers(0, shape[2])
|
|
65
|
+
radius_z = rng.uniform(radius_z_min, radius_z_max)
|
|
66
|
+
radius_xy = rng.uniform(radius_xy_min, radius_xy_max)
|
|
67
|
+
intensity = rng.uniform(32768, 65535)
|
|
68
|
+
|
|
69
|
+
dist_sq = (
|
|
70
|
+
(zz - z) ** 2 / (2 * radius_z**2)
|
|
71
|
+
+ (yy - y) ** 2 / (2 * radius_xy**2)
|
|
72
|
+
+ (xx - x) ** 2 / (2 * radius_xy**2)
|
|
73
|
+
)
|
|
74
|
+
blob = intensity * np.exp(-dist_sq)
|
|
75
|
+
volume = np.clip(np.maximum(volume, blob), 0, 65535).astype(np.uint16)
|
|
76
|
+
|
|
77
|
+
return gaussian_filter(volume.astype(np.float32), sigma=0.5).astype(np.uint16)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Kernels: generate something to convolve an image with.
|
|
2
|
+
|
|
3
|
+
A PSF is as much a forward-simulation tool as a deconvolution one, so it lives
|
|
4
|
+
here rather than under ``skop.ops.deconvolve``.
|
|
5
|
+
|
|
6
|
+
Three models, in increasing order of what they know about the microscope:
|
|
7
|
+
|
|
8
|
+
``gaussian_psf`` no optics at all, just a width you pick
|
|
9
|
+
``paraxial_psf`` wavelength, NA and pixel size -- the textbook, in focus
|
|
10
|
+
``gibson_lanni`` the above plus immersion medium, sample index and depth,
|
|
11
|
+
so it can be axially asymmetric
|
|
12
|
+
|
|
13
|
+
Everything here is a *theoretical* PSF, computed from optical parameters with
|
|
14
|
+
no measurement involved. Extracting a PSF from a bead image is deliberately
|
|
15
|
+
elsewhere: it composes a deconvolution op whose backend depends on the
|
|
16
|
+
machine, which makes it a workflow op rather than a plain one. See
|
|
17
|
+
docs/spec/workflow-ops.md.
|
|
18
|
+
|
|
19
|
+
This namespace spans two environments -- ``gibson_lanni`` needs sdeconv, the
|
|
20
|
+
rest are content with ``skimage`` -- which is the README's rule for a
|
|
21
|
+
namespace being a package rather than a module.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from ._fluorophore import Fluorophore
|
|
27
|
+
from ._recenter import recenter_psf_axial
|
|
28
|
+
from .gibson_lanni import gibson_lanni
|
|
29
|
+
from .paraxial import paraxial_otf, paraxial_psf
|
|
30
|
+
from .psf import gaussian_psf
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Fluorophore",
|
|
34
|
+
"gaussian_psf",
|
|
35
|
+
"gibson_lanni",
|
|
36
|
+
"paraxial_otf",
|
|
37
|
+
"paraxial_psf",
|
|
38
|
+
"recenter_psf_axial",
|
|
39
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Emission wavelengths for common fluorophores.
|
|
2
|
+
|
|
3
|
+
Every PSF model here takes a wavelength in microns, and in practice nobody
|
|
4
|
+
knows one off the top of their head -- they know they imaged DAPI. This is
|
|
5
|
+
that lookup, ported from tnia-python's ``wave_dictionary``.
|
|
6
|
+
|
|
7
|
+
A ``float`` mixin rather than a plain ``Enum``, so a member *is* its
|
|
8
|
+
wavelength: ``gibson_lanni(wavelength=Fluorophore.DAPI)`` works with no
|
|
9
|
+
conversion, and the parameter stays a plain ``float`` that also accepts the
|
|
10
|
+
647.1 nm line of whatever laser is actually on the bench.
|
|
11
|
+
|
|
12
|
+
Stdlib only, so any environment holding a PSF op can import it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
__all__ = ["Fluorophore"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Fluorophore(float, Enum):
|
|
23
|
+
"""Peak emission wavelength, in microns.
|
|
24
|
+
|
|
25
|
+
The source dictionary paired an excitation with each emission. Only
|
|
26
|
+
emission is kept: it is what forms the image, and so what every PSF model
|
|
27
|
+
here asks for. Excitation is noted in the comments rather than carried,
|
|
28
|
+
since nothing computes with it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
DAPI = 0.461 # excitation 358 nm, blue
|
|
32
|
+
Cy2 = 0.506 # excitation 489 nm, green
|
|
33
|
+
FITC = 0.519 # excitation 495 nm, green
|
|
34
|
+
TexasRed = 0.610 # excitation 561 nm, orange-red
|
|
35
|
+
AF594 = 0.617 # excitation 590 nm, red
|
|
36
|
+
Cy5 = 0.670 # excitation 649 nm, far-red / NIR
|
|
37
|
+
|
|
38
|
+
# NB: the original spelled Texas Red "Texa", which reads as a typo rather
|
|
39
|
+
# than an abbreviation -- these were dictionary keys, so nothing depended
|
|
40
|
+
# on the spelling. Ordered by emission here rather than alphabetically,
|
|
41
|
+
# because a wavelength list that runs blue to red is the one a microscopist
|
|
42
|
+
# can scan.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Putting an off-centre PSF back on centre.
|
|
2
|
+
|
|
3
|
+
A theoretical PSF computed at a non-zero focal depth is not centred on the
|
|
4
|
+
middle plane -- that asymmetry is the spherical aberration being modelled, and
|
|
5
|
+
it is the point. But a deconvolution kernel has to be centred, or the result
|
|
6
|
+
comes out shifted along Z by however far the peak sat from the middle.
|
|
7
|
+
|
|
8
|
+
sdeconv in particular does not centre its output at all, which is the reason
|
|
9
|
+
this exists. Compute taller than needed, find the peak, crop back around it.
|
|
10
|
+
|
|
11
|
+
numpy and the standard library only, so any environment holding a PSF op can
|
|
12
|
+
import it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
__all__ = ["recenter_psf_axial"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def recenter_psf_axial(psf: np.ndarray, new_z: int) -> np.ndarray:
|
|
23
|
+
"""Crop a 3-D PSF to ``new_z`` planes, centred on its brightest plane.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
psf: A ``(Z, Y, X)`` PSF, generally taller than ``new_z``.
|
|
27
|
+
new_z: Number of planes to return.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A ``(new_z, Y, X)`` view-derived copy whose peak sits on the centre
|
|
31
|
+
plane, at index ``new_z // 2``.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If the crop would run off either end of ``psf`` -- which
|
|
35
|
+
means the PSF was not computed with enough headroom for this
|
|
36
|
+
``pz``, and the honest answer is to compute a taller one.
|
|
37
|
+
"""
|
|
38
|
+
if psf.ndim != 3:
|
|
39
|
+
raise ValueError(f"expected a 3-D PSF, got {psf.ndim}-D")
|
|
40
|
+
if new_z > psf.shape[0]:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"cannot crop {psf.shape[0]} planes down to {new_z}: nothing to crop"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
peak_z = int(np.unravel_index(int(psf.argmax()), psf.shape)[0])
|
|
46
|
+
start = peak_z - new_z // 2
|
|
47
|
+
|
|
48
|
+
# NB: the original sliced without checking, and Python's slicing is
|
|
49
|
+
# forgiving in exactly the wrong way here -- a negative start counts from
|
|
50
|
+
# the far end and an overlong stop silently truncates, so a PSF whose peak
|
|
51
|
+
# sits near an edge came back with the wrong number of planes and no
|
|
52
|
+
# complaint. The caller cannot recover from that; it can from an error.
|
|
53
|
+
if start < 0 or start + new_z > psf.shape[0]:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"peak at plane {peak_z} is too close to the edge to crop "
|
|
56
|
+
f"{new_z} planes around it, from {psf.shape[0]}: compute the PSF "
|
|
57
|
+
f"with more Z headroom, or reduce pz"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return psf[start : start + new_z].copy()
|