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
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()