scikit-ops 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scikit_ops-0.1.0.dev0.dist-info/METADATA +372 -0
- scikit_ops-0.1.0.dev0.dist-info/RECORD +66 -0
- scikit_ops-0.1.0.dev0.dist-info/WHEEL +4 -0
- scikit_ops-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- skop/__init__.py +14 -0
- skop/envs/cellpose3/pixi.toml +46 -0
- skop/envs/cupy/pixi.toml +35 -0
- skop/envs/minimal/pixi.toml +25 -0
- skop/envs/pytorch/pixi.toml +88 -0
- skop/envs/sdeconv/pixi.toml +85 -0
- skop/envs/segment-everything/pixi.toml +96 -0
- skop/envs/skimage/pixi.toml +28 -0
- skop/envs/stardist-tf/pixi.toml +91 -0
- skop/envs/unseg-cv/pixi.toml +27 -0
- skop/models.py +145 -0
- skop/ops/__init__.py +19 -0
- skop/ops/_util.py +115 -0
- skop/ops/deconvolve/__init__.py +18 -0
- skop/ops/deconvolve/_edges.py +92 -0
- skop/ops/deconvolve/_pad.py +117 -0
- skop/ops/deconvolve/richardson_lucy.py +100 -0
- skop/ops/deconvolve/richardson_lucy_cupy.py +116 -0
- skop/ops/detect/__init__.py +28 -0
- skop/ops/detect/_result.py +45 -0
- skop/ops/detect/fastsam.py +103 -0
- skop/ops/detect/jdll_yolo.py +115 -0
- skop/ops/detect/object_aware_yolo.py +112 -0
- skop/ops/detect/yolo.py +441 -0
- skop/ops/edges.py +382 -0
- skop/ops/generate.py +77 -0
- skop/ops/kernels/__init__.py +39 -0
- skop/ops/kernels/_fluorophore.py +42 -0
- skop/ops/kernels/_recenter.py +60 -0
- skop/ops/kernels/gibson_lanni.py +147 -0
- skop/ops/kernels/paraxial.py +128 -0
- skop/ops/kernels/psf.py +59 -0
- skop/ops/labels.py +144 -0
- skop/ops/mask/__init__.py +29 -0
- skop/ops/mask/_result.py +40 -0
- skop/ops/mask/microsam.py +114 -0
- skop/ops/mask/mobilesam.py +176 -0
- skop/ops/morphology.py +214 -0
- skop/ops/normalize.py +85 -0
- skop/ops/segment/__init__.py +24 -0
- skop/ops/segment/cellpose3.py +245 -0
- skop/ops/segment/cellpose4.py +127 -0
- skop/ops/segment/instanseg.py +162 -0
- skop/ops/segment/stardist2d.py +192 -0
- skop/ops/segment/starfun3d.py +104 -0
- skop/ops/segment/unseg/__init__.py +138 -0
- skop/ops/segment/unseg/_algorithm.py +1618 -0
- skop/ops/smooth.py +452 -0
- skop/ops/threshold.py +278 -0
- skop/ops/toy.py +114 -0
- skop/ops/train/__init__.py +81 -0
- skop/ops/train/_util.py +152 -0
- skop/ops/train/cellpose3.py +189 -0
- skop/ops/train/cellpose4.py +164 -0
- skop/ops/train/stardist2d.py +439 -0
- skop/ops/workflows/__init__.py +43 -0
- skop/ops/workflows/deconvolve/__init__.py +7 -0
- skop/ops/workflows/deconvolve/with_psf.py +103 -0
- skop/ops/workflows/mask/__init__.py +7 -0
- skop/ops/workflows/mask/detect_then_mask.py +97 -0
- skop/ops/workflows/segment/__init__.py +7 -0
- skop/ops/workflows/segment/connect_2d_in_3d.py +145 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Edge handling: deciding where the image has data, and extending it.
|
|
2
|
+
|
|
3
|
+
Padding and bad-pixel masking look like two chores but are one idea. An edge
|
|
4
|
+
is a place where no data was acquired; so is a saturated pixel. Both are
|
|
5
|
+
handled the same way -- held out of the HTones array, which is what tells the
|
|
6
|
+
iteration how much signal each location should have received.
|
|
7
|
+
|
|
8
|
+
This is identical whichever backend runs the iteration, and happens on the CPU
|
|
9
|
+
either way, so both share it. The two originals ported here had drifted apart
|
|
10
|
+
in exactly these steps; sharing them is what keeps that from happening again.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import NamedTuple
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from ._pad import get_next_smooth, pad, pad_psf, pad_to_largest, unpad
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Padded(NamedTuple):
|
|
23
|
+
"""Arrays at their working size, and what it takes to get back."""
|
|
24
|
+
|
|
25
|
+
image: np.ndarray
|
|
26
|
+
psf: np.ndarray
|
|
27
|
+
htones: np.ndarray
|
|
28
|
+
original_size: tuple[int, ...]
|
|
29
|
+
mask: np.ndarray | None
|
|
30
|
+
mask_values: np.ndarray | None
|
|
31
|
+
|
|
32
|
+
def crop_and_restore(self, estimate: np.ndarray) -> np.ndarray:
|
|
33
|
+
"""Undo ``pad_and_mask``: crop back, and put masked pixels back.
|
|
34
|
+
|
|
35
|
+
A method rather than a free function because the padding is what
|
|
36
|
+
knows how to reverse itself -- there is no way to pair the wrong
|
|
37
|
+
result with the wrong padding.
|
|
38
|
+
"""
|
|
39
|
+
if estimate.shape != self.original_size:
|
|
40
|
+
estimate = unpad(estimate, self.original_size)
|
|
41
|
+
if self.mask is not None:
|
|
42
|
+
estimate = estimate * self.mask + self.mask_values
|
|
43
|
+
return estimate
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pad_and_mask(
|
|
47
|
+
image: np.ndarray,
|
|
48
|
+
psf: np.ndarray,
|
|
49
|
+
noncirc: bool,
|
|
50
|
+
mask: np.ndarray | None,
|
|
51
|
+
dtype=np.float64,
|
|
52
|
+
) -> Padded:
|
|
53
|
+
"""Hold out masked pixels, and pad the arrays to their working size.
|
|
54
|
+
|
|
55
|
+
Note:
|
|
56
|
+
When ``noncirc`` is off and the image and psf already agree, there is
|
|
57
|
+
nothing to pad and only the masking applies.
|
|
58
|
+
"""
|
|
59
|
+
image = np.asarray(image, dtype=dtype)
|
|
60
|
+
psf = np.asarray(psf, dtype=dtype)
|
|
61
|
+
original_size = image.shape
|
|
62
|
+
|
|
63
|
+
mask_values = None
|
|
64
|
+
if mask is None:
|
|
65
|
+
htones = np.ones_like(image)
|
|
66
|
+
else:
|
|
67
|
+
mask = np.asarray(mask, dtype=dtype)
|
|
68
|
+
if mask.shape != original_size:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"mask shape {mask.shape} does not match image shape {original_size}"
|
|
71
|
+
)
|
|
72
|
+
# Masked pixels are set aside and restored afterwards. Folding the mask
|
|
73
|
+
# into HTones is what makes them behave like edges.
|
|
74
|
+
mask_values = image * (1.0 - mask)
|
|
75
|
+
image = image * mask
|
|
76
|
+
htones = mask.copy()
|
|
77
|
+
|
|
78
|
+
if noncirc:
|
|
79
|
+
extended = get_next_smooth(
|
|
80
|
+
[image.shape[i] + 2 * (psf.shape[i] // 2) for i in range(image.ndim)]
|
|
81
|
+
)
|
|
82
|
+
image, _ = pad(image, extended, "constant")
|
|
83
|
+
htones, _ = pad(htones, extended, "constant")
|
|
84
|
+
psf = pad_psf(psf, extended)
|
|
85
|
+
elif image.shape != psf.shape:
|
|
86
|
+
# NB: the numpy original padded the psf up to the image, which
|
|
87
|
+
# truncates a psf wider than the image. Both backends now pad each to
|
|
88
|
+
# the larger of the two, as the cupy version did.
|
|
89
|
+
image, psf = pad_to_largest(image, psf, "constant")
|
|
90
|
+
htones, _ = pad(htones, image.shape, "constant")
|
|
91
|
+
|
|
92
|
+
return Padded(image, psf, htones, original_size, mask, mask_values)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Padding helpers for FFT-based deconvolution.
|
|
2
|
+
|
|
3
|
+
Vendored from ``tnia.deconvolution.pad`` (tnia-python), reformatted but
|
|
4
|
+
semantically unchanged. Only what the ops here need came across.
|
|
5
|
+
|
|
6
|
+
Private, so discovery never imports it as an op module. Pure numpy and
|
|
7
|
+
``math``, which is what lets both backends share it: ``unpad`` only slices, so
|
|
8
|
+
it works on a cupy array as readily as a numpy one.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _handle_prime(p: int, x: int, a: np.ndarray) -> None:
|
|
19
|
+
"""Accumulate log(p) into every index reachable as a multiple of p."""
|
|
20
|
+
log = math.log(p)
|
|
21
|
+
power = p
|
|
22
|
+
|
|
23
|
+
while power <= x + a.shape[0]:
|
|
24
|
+
j = x % power
|
|
25
|
+
if j > 0:
|
|
26
|
+
j = power - j
|
|
27
|
+
|
|
28
|
+
while j < a.shape[0]:
|
|
29
|
+
a[j] += log
|
|
30
|
+
j += power
|
|
31
|
+
|
|
32
|
+
power *= p
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def next_smooth(x: int) -> int:
|
|
36
|
+
"""Return the next number >= x divisible only by primes up to 7.
|
|
37
|
+
|
|
38
|
+
FFTs are dramatically faster on smooth sizes, so a padded extent is
|
|
39
|
+
rounded up to one. Based on A. Granville, *Finding smooth numbers
|
|
40
|
+
computationally*.
|
|
41
|
+
|
|
42
|
+
Authors: Johannes Schindelin, Brian Northan.
|
|
43
|
+
"""
|
|
44
|
+
if x == 1:
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
z = int(16 * math.log2(x))
|
|
48
|
+
delta = 0.000001
|
|
49
|
+
|
|
50
|
+
a = np.zeros(z)
|
|
51
|
+
|
|
52
|
+
_handle_prime(2, x, a)
|
|
53
|
+
_handle_prime(3, x, a)
|
|
54
|
+
_handle_prime(5, x, a)
|
|
55
|
+
_handle_prime(7, x, a)
|
|
56
|
+
|
|
57
|
+
log = math.log(x)
|
|
58
|
+
for i in range(a.shape[0]):
|
|
59
|
+
if a[i] >= log - delta:
|
|
60
|
+
return x + i
|
|
61
|
+
|
|
62
|
+
return -1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_next_smooth(size) -> tuple[int, ...]:
|
|
66
|
+
"""Apply ``next_smooth`` to every element of an n-dimensional size."""
|
|
67
|
+
return tuple(next_smooth(i) for i in size)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def pad(img: np.ndarray, paddedsize, mode: str):
|
|
71
|
+
"""Pad an image up to ``paddedsize``, centered.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The padded array, and the per-axis padding that was applied.
|
|
75
|
+
"""
|
|
76
|
+
padding = tuple(
|
|
77
|
+
(math.ceil((i - j) / 2), math.floor((i - j) / 2))
|
|
78
|
+
for i, j in zip(paddedsize, img.shape)
|
|
79
|
+
)
|
|
80
|
+
return np.pad(img, padding, mode), padding
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pad_psf(psf: np.ndarray, paddedsize) -> np.ndarray:
|
|
84
|
+
"""Pad a PSF with zeros up to ``paddedsize``, its centre where FFTs want it.
|
|
85
|
+
|
|
86
|
+
The centre, index ``n // 2`` of the PSF, goes to index ``N // 2`` of the
|
|
87
|
+
padded array, which ``ifftshift`` then moves to 0. ``pad`` centres the
|
|
88
|
+
array instead, and for a PSF of even size that puts the centre half a
|
|
89
|
+
pixel off one way or the other depending on whether the padded size is
|
|
90
|
+
odd: the result shifts by a pixel. Untiled, every run shifts the same way;
|
|
91
|
+
tiled, tiles of different sizes disagree, by 20% or more on sharp points.
|
|
92
|
+
"""
|
|
93
|
+
padding = tuple(
|
|
94
|
+
(big // 2 - small // 2, big - small - (big // 2 - small // 2))
|
|
95
|
+
for big, small in zip(paddedsize, psf.shape)
|
|
96
|
+
)
|
|
97
|
+
return np.pad(psf, padding, "constant")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def pad_to_largest(img: np.ndarray, psf: np.ndarray, mode: str):
|
|
101
|
+
"""Pad both arrays so each axis matches the larger of the two."""
|
|
102
|
+
largest = tuple(max(i, p) for i, p in zip(img.shape, psf.shape))
|
|
103
|
+
img, _ = pad(img, largest, mode)
|
|
104
|
+
return img, pad_psf(psf, largest)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def unpad(padded, imgsize):
|
|
108
|
+
"""Crop a padded array back to ``imgsize``, undoing ``pad``.
|
|
109
|
+
|
|
110
|
+
Slicing only, so this accepts a cupy array as well as a numpy one.
|
|
111
|
+
"""
|
|
112
|
+
padding = tuple(
|
|
113
|
+
(math.ceil((i - j) / 2), math.floor((i - j) / 2))
|
|
114
|
+
for i, j in zip(padded.shape, imgsize)
|
|
115
|
+
)
|
|
116
|
+
slices = tuple(slice(p[0], p[0] + s) for p, s in zip(padding, imgsize))
|
|
117
|
+
return padded[slices]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Richardson-Lucy deconvolution on the CPU, with numpy.
|
|
2
|
+
|
|
3
|
+
Ported from tnia-python's ``tnia.deconvolution.richardson_lucy_np``. Where it
|
|
4
|
+
differed from the cupy version, the cupy behaviour won -- see the NB comments
|
|
5
|
+
below and ``richardson_lucy_cupy``, which this op is kept numerically
|
|
6
|
+
equivalent to.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from opspec.op import Axes, Overlap, PeakMemory, op
|
|
16
|
+
from opspec.runtime import cancel_requested, progress
|
|
17
|
+
|
|
18
|
+
from ._edges import pad_and_mask
|
|
19
|
+
|
|
20
|
+
# Floor for the divisions and the estimate: an iteration must not divide by
|
|
21
|
+
# zero, and intensities are non-negative by construction.
|
|
22
|
+
DELTA = 1e-6
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: Non-circulant: half the PSF, per axis, how far it reaches at most; safe,
|
|
26
|
+
#: if often more than the light needs. Circulant: 10 px, as clij2-fft and
|
|
27
|
+
#: tnia-python use. A Tiler can set either.
|
|
28
|
+
_OVERLAP = Overlap(param="psf", of="shape", scale=0.5, only_if="noncirc", otherwise=10)
|
|
29
|
+
#: Non-circulant pads by half the PSF on each side; circulant doesn't pad.
|
|
30
|
+
_PAD = Overlap(param="psf", of="shape", scale=0.5, only_if="noncirc")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@op(
|
|
34
|
+
env="skimage",
|
|
35
|
+
# As richardson_lucy_cupy: the mask tiled the same as the image, z never
|
|
36
|
+
# cut. In RAM and in float64, numpy's FFT precision; 10x is a guess, not
|
|
37
|
+
# yet measured, counted on the padded size.
|
|
38
|
+
tile=("image", "mask"),
|
|
39
|
+
split=("y", "x"),
|
|
40
|
+
overlap=_OVERLAP,
|
|
41
|
+
peak_memory=PeakMemory(10, "float64", pad=_PAD),
|
|
42
|
+
merge="blend",
|
|
43
|
+
)
|
|
44
|
+
def richardson_lucy(
|
|
45
|
+
image: Annotated[np.ndarray, Axes("z?", "y", "x")],
|
|
46
|
+
psf: np.ndarray,
|
|
47
|
+
num_iters: Annotated[int, {"min": 1, "max": 1000}] = 10,
|
|
48
|
+
noncirc: bool = False,
|
|
49
|
+
mask: np.ndarray | None = None,
|
|
50
|
+
) -> np.ndarray:
|
|
51
|
+
"""Deconvolve an image by the Richardson-Lucy algorithm.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
image: The image to deconvolve.
|
|
55
|
+
psf: The point spread function. Need not match the image's shape.
|
|
56
|
+
num_iters: How many iterations to run.
|
|
57
|
+
noncirc: Whether to use non-circulant edge handling, which pads the
|
|
58
|
+
image out rather than treating it as wrapping around.
|
|
59
|
+
mask: Optional array the shape of the image, zero at pixels that
|
|
60
|
+
should not be considered -- saturated ones, say. They are held out
|
|
61
|
+
of the deconvolution and their original values restored at the end.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The deconvolved image, as float64.
|
|
65
|
+
|
|
66
|
+
Note:
|
|
67
|
+
numpy's FFT promotes float32 to float64, so passing 32-bit input to
|
|
68
|
+
save memory does not work here; the result is float64 regardless.
|
|
69
|
+
"""
|
|
70
|
+
fftn, ifftn, ifftshift = np.fft.fftn, np.fft.ifftn, np.fft.ifftshift
|
|
71
|
+
|
|
72
|
+
padded = pad_and_mask(image, psf, noncirc, mask)
|
|
73
|
+
image, psf, htones = padded.image, padded.psf, padded.htones
|
|
74
|
+
|
|
75
|
+
otf = fftn(ifftshift(psf))
|
|
76
|
+
otf_conj = np.conjugate(otf)
|
|
77
|
+
|
|
78
|
+
htones = np.real(ifftn(fftn(htones) * otf_conj))
|
|
79
|
+
htones[htones < DELTA] = 1.0
|
|
80
|
+
|
|
81
|
+
# NB: the numpy original seeded the circulant case with the image itself.
|
|
82
|
+
# Both backends now start from a flat sheet at the image's mean, which is
|
|
83
|
+
# what the cupy version did and what the non-circulant case always needed.
|
|
84
|
+
estimate = np.full_like(image, image.mean())
|
|
85
|
+
|
|
86
|
+
for i in range(num_iters):
|
|
87
|
+
if cancel_requested():
|
|
88
|
+
break
|
|
89
|
+
progress(f"Iteration {i + 1} of {num_iters}", i, num_iters)
|
|
90
|
+
|
|
91
|
+
reblurred = np.real(ifftn(fftn(estimate) * otf))
|
|
92
|
+
reblurred[reblurred < DELTA] = DELTA
|
|
93
|
+
ratio = image / reblurred
|
|
94
|
+
correction = np.real(ifftn(fftn(ratio) * otf_conj))
|
|
95
|
+
correction[correction < 0] = DELTA
|
|
96
|
+
estimate = estimate * correction / htones
|
|
97
|
+
estimate[estimate < 0] = DELTA
|
|
98
|
+
|
|
99
|
+
progress("Deconvolution complete", num_iters, num_iters)
|
|
100
|
+
return padded.crop_and_restore(estimate)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Richardson-Lucy deconvolution on an NVIDIA GPU, with cupy.
|
|
2
|
+
|
|
3
|
+
Ported from tnia-python's ``tnia.deconvolution.richardson_lucy``. The
|
|
4
|
+
iteration is the same one ``richardson_lucy`` runs, and the two are held
|
|
5
|
+
numerically equivalent by a test; what differs is where the arrays live.
|
|
6
|
+
|
|
7
|
+
Dropped in the port: the RMSE-against-truth tracking (a benchmarking concern,
|
|
8
|
+
not an op's), ``do_unpad`` (an op returns something the caller can use), and
|
|
9
|
+
the memory-pool diagnostics.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from opspec.op import Axes, Overlap, PeakMemory, op
|
|
19
|
+
from opspec.runtime import cancel_requested, progress
|
|
20
|
+
|
|
21
|
+
from ._edges import pad_and_mask
|
|
22
|
+
from .richardson_lucy import DELTA
|
|
23
|
+
|
|
24
|
+
#: Non-circulant: half the PSF, per axis, how far it reaches at most; safe,
|
|
25
|
+
#: if often more than the light needs. Circulant: 10 px, as clij2-fft and
|
|
26
|
+
#: tnia-python use. A Tiler can set either.
|
|
27
|
+
_OVERLAP = Overlap(param="psf", of="shape", scale=0.5, only_if="noncirc", otherwise=10)
|
|
28
|
+
#: Non-circulant pads by half the PSF on each side; circulant doesn't pad.
|
|
29
|
+
_PAD = Overlap(param="psf", of="shape", scale=0.5, only_if="noncirc")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@op(
|
|
33
|
+
env="cupy",
|
|
34
|
+
# The mask is tiled the same as the image; the PSF is given whole to every
|
|
35
|
+
# tile. z is never cut: a PSF is usually long in z, and cutting there
|
|
36
|
+
# costs the most overlap for the least gain.
|
|
37
|
+
tile=("image", "mask"),
|
|
38
|
+
split=("y", "x"),
|
|
39
|
+
overlap=_OVERLAP,
|
|
40
|
+
# On the GPU, in float32, counted on the padded size. About 13 float32
|
|
41
|
+
# buffers are live at once by the code -- image, PSF, weights, estimate,
|
|
42
|
+
# two complex OTFs, and an iteration's complex FFTs -- plus cuFFT's
|
|
43
|
+
# workspace. Not yet measured.
|
|
44
|
+
peak_memory=PeakMemory(16, "float32", pad=_PAD, device="gpu"),
|
|
45
|
+
merge="blend",
|
|
46
|
+
)
|
|
47
|
+
def richardson_lucy_cupy(
|
|
48
|
+
image: Annotated[np.ndarray, Axes("z?", "y", "x")],
|
|
49
|
+
psf: np.ndarray,
|
|
50
|
+
num_iters: Annotated[int, {"min": 1, "max": 1000}] = 10,
|
|
51
|
+
noncirc: bool = False,
|
|
52
|
+
mask: np.ndarray | None = None,
|
|
53
|
+
) -> np.ndarray:
|
|
54
|
+
"""Deconvolve an image by the Richardson-Lucy algorithm, on the GPU.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
image: The image to deconvolve.
|
|
58
|
+
psf: The point spread function. Need not match the image's shape.
|
|
59
|
+
num_iters: How many iterations to run.
|
|
60
|
+
noncirc: Whether to use non-circulant edge handling, which pads the
|
|
61
|
+
image out rather than treating it as wrapping around.
|
|
62
|
+
mask: Optional array the shape of the image, zero at pixels that
|
|
63
|
+
should not be considered -- saturated ones, say. They are held out
|
|
64
|
+
of the deconvolution and their original values restored at the end.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The deconvolved image, as float32, back in host memory.
|
|
68
|
+
|
|
69
|
+
Note:
|
|
70
|
+
cupy's FFT works in single precision, so this runs in float32 where
|
|
71
|
+
``richardson_lucy`` runs in float64. Expect the two to agree to
|
|
72
|
+
single-precision tolerance, not exactly.
|
|
73
|
+
"""
|
|
74
|
+
import cupy as cp
|
|
75
|
+
|
|
76
|
+
# Masking and padding happen on the host, so only what the iteration needs
|
|
77
|
+
# is uploaded -- and in float32, which is all cupy's FFT will give back.
|
|
78
|
+
padded = pad_and_mask(image, psf, noncirc, mask, dtype=np.float32)
|
|
79
|
+
image = cp.asarray(padded.image)
|
|
80
|
+
psf = cp.asarray(padded.psf)
|
|
81
|
+
htones = cp.asarray(padded.htones)
|
|
82
|
+
|
|
83
|
+
otf = cp.fft.fftn(cp.fft.ifftshift(psf))
|
|
84
|
+
otf_conj = cp.conjugate(otf)
|
|
85
|
+
|
|
86
|
+
htones = cp.real(cp.fft.ifftn(cp.fft.fftn(htones) * otf_conj))
|
|
87
|
+
htones[htones < DELTA] = 1.0
|
|
88
|
+
|
|
89
|
+
estimate = cp.full_like(image, float(image.mean()))
|
|
90
|
+
|
|
91
|
+
for i in range(num_iters):
|
|
92
|
+
if cancel_requested():
|
|
93
|
+
break
|
|
94
|
+
progress(f"Iteration {i + 1} of {num_iters}", i, num_iters)
|
|
95
|
+
|
|
96
|
+
reblurred = cp.real(cp.fft.ifftn(cp.fft.fftn(estimate) * otf))
|
|
97
|
+
reblurred[reblurred < DELTA] = DELTA
|
|
98
|
+
ratio = image / reblurred
|
|
99
|
+
correction = cp.real(cp.fft.ifftn(cp.fft.fftn(ratio) * otf_conj))
|
|
100
|
+
correction[correction < 0] = DELTA
|
|
101
|
+
estimate = estimate * correction / htones
|
|
102
|
+
estimate[estimate < 0] = DELTA
|
|
103
|
+
|
|
104
|
+
# Wait for the iteration actually to finish. Every cupy call above
|
|
105
|
+
# queues work and returns at once, so without this the loop issues all
|
|
106
|
+
# num_iters iterations in a few milliseconds -- reporting each -- and
|
|
107
|
+
# then blocks on the transfer at the end. The bar would sweep to 100%
|
|
108
|
+
# and sit there while the GPU did the work, and Cancel would arrive
|
|
109
|
+
# long after every iteration had been queued and so do nothing.
|
|
110
|
+
#
|
|
111
|
+
# The cost is that the queue can no longer run ahead. For FFTs this
|
|
112
|
+
# size that is small, and a progress bar that lies is worse.
|
|
113
|
+
cp.cuda.runtime.deviceSynchronize()
|
|
114
|
+
|
|
115
|
+
progress("Deconvolution complete", num_iters, num_iters)
|
|
116
|
+
return padded.crop_and_restore(cp.asnumpy(estimate))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Object detection, class-agnostic or with a user's trained YOLO model.
|
|
2
|
+
|
|
3
|
+
FastSAM and object-aware YOLO return the same thing, so a
|
|
4
|
+
caller can swap one for the other -- the first stage of a detect-then-segment
|
|
5
|
+
workflow, feeding boxes to a mask detector.
|
|
6
|
+
|
|
7
|
+
Those two are class-agnostic on purpose. A COCO-pretrained detector has no
|
|
8
|
+
category for a cell or a coin and reports nothing; these have a single class,
|
|
9
|
+
called object, and report everything.
|
|
10
|
+
|
|
11
|
+
One op per environment, since ``@op(env=...)`` is fixed per function:
|
|
12
|
+
``fastsam`` in the shared 'pytorch' environment, ``object_aware_yolo`` in one
|
|
13
|
+
of its own, because segment-everything vendors an ultralytics fork that must
|
|
14
|
+
not meet the real one.
|
|
15
|
+
|
|
16
|
+
``yolo`` runs a user's detection checkpoint on overlapping tiles, returning
|
|
17
|
+
boxes, confidences and class IDs. It shares the 'pytorch' environment.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from ._result import Boxes, Detections
|
|
23
|
+
from .fastsam import fastsam
|
|
24
|
+
from .jdll_yolo import jdll_yolo
|
|
25
|
+
from .object_aware_yolo import object_aware_yolo
|
|
26
|
+
from .yolo import yolo
|
|
27
|
+
|
|
28
|
+
__all__ = ["Boxes", "Detections", "fastsam", "jdll_yolo", "object_aware_yolo", "yolo"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Return types for box detectors.
|
|
2
|
+
|
|
3
|
+
``Boxes`` is shared by the class-agnostic ops, so "these ops are substitutable"
|
|
4
|
+
is a fact rather than a convention -- a workflow choosing between them gets
|
|
5
|
+
the same fields and the same output names either way.
|
|
6
|
+
|
|
7
|
+
A one-field NamedTuple rather than a bare return annotation, because the field
|
|
8
|
+
name becomes the output name (0001): a front end labels the layer "boxes"
|
|
9
|
+
rather than "result", and adding a second output later is additive.
|
|
10
|
+
|
|
11
|
+
No classes: these detectors are class-agnostic by design, and a column of
|
|
12
|
+
zeros pretending to be class IDs would be worse than its absence.
|
|
13
|
+
|
|
14
|
+
No scores either, for now, though every detector here computes them. A
|
|
15
|
+
confidence per box is not a layer -- it is a *feature* of one, and skop has no
|
|
16
|
+
way to say that yet. Returning it as a bare array made a front end guess, and
|
|
17
|
+
napari guessed "image", which a 1-D array cannot be. Rather than encode a
|
|
18
|
+
guess, the value is dropped until the question is answered. See
|
|
19
|
+
docs/spec/per-object-features.md.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import NamedTuple
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from opspec.types import BoxesOf
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Boxes(NamedTuple):
|
|
32
|
+
#: (N, 4) as [min_y, min_x, max_y, max_x], in image coordinates.
|
|
33
|
+
boxes: BoxesOf[np.ndarray]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Detections(NamedTuple):
|
|
37
|
+
"""Classified boxes, with one confidence and class ID per row.
|
|
38
|
+
|
|
39
|
+
Lists keep per-object values out of image layers until skop has a
|
|
40
|
+
features role (docs/spec/per-object-features.md).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
boxes: BoxesOf[np.ndarray]
|
|
44
|
+
confidences: list[float]
|
|
45
|
+
classes: list[int]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Class-agnostic object detection with FastSAM.
|
|
2
|
+
|
|
3
|
+
FastSAM is a YOLOv8 segmentation model trained on 2% of SA-1B. Ultralytics
|
|
4
|
+
describes it as recognising and segmenting "all objects as the same class" --
|
|
5
|
+
it has one class, called object, so its confidence score means "is this a
|
|
6
|
+
thing" rather than "is this one of 80 COCO categories".
|
|
7
|
+
|
|
8
|
+
That is the distinction that matters here. A COCO-pretrained detector
|
|
9
|
+
localises coins or cells perfectly well -- box regression is class-agnostic --
|
|
10
|
+
and then scores every class near zero and throws them all away below the
|
|
11
|
+
confidence threshold. A single-class model keeps them.
|
|
12
|
+
|
|
13
|
+
Only the boxes are returned. FastSAM also produces masks, from a prototype
|
|
14
|
+
branch cropped by these same boxes; a mask detector op built on that is
|
|
15
|
+
possible later (see docs/design/0007) but is not this op's job.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Annotated
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
import ophelper.boxes
|
|
26
|
+
from ophelper.assets import file_from_url
|
|
27
|
+
from opspec.op import Axes, op
|
|
28
|
+
from opspec.runtime import progress
|
|
29
|
+
from opspec.types import ImageOf
|
|
30
|
+
|
|
31
|
+
from .._util import to_rgb
|
|
32
|
+
from ._result import Boxes
|
|
33
|
+
|
|
34
|
+
_WEIGHTS = "https://github.com/ultralytics/assets/releases/download/v8.3.0/{name}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PretrainedModel(Enum):
|
|
38
|
+
"""FastSAM's two published checkpoints."""
|
|
39
|
+
|
|
40
|
+
small = "FastSAM-s.pt"
|
|
41
|
+
large = "FastSAM-x.pt"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@op(env="pytorch")
|
|
45
|
+
def fastsam(
|
|
46
|
+
image: Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")],
|
|
47
|
+
model: PretrainedModel = PretrainedModel.small,
|
|
48
|
+
conf: Annotated[
|
|
49
|
+
float,
|
|
50
|
+
{"widget_type": "FloatSlider", "min": 0.0, "max": 1.0, "step": 0.05},
|
|
51
|
+
] = 0.4,
|
|
52
|
+
iou: Annotated[
|
|
53
|
+
float,
|
|
54
|
+
{"widget_type": "FloatSlider", "min": 0.0, "max": 1.0, "step": 0.05},
|
|
55
|
+
] = 0.9,
|
|
56
|
+
max_det: int = 300,
|
|
57
|
+
imgsz: int = 1024,
|
|
58
|
+
) -> Boxes:
|
|
59
|
+
"""Find every object in an image, without regard to what it is.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
image: Image to detect in. 2-D or RGB; anything else is stretched to
|
|
63
|
+
8-bit RGB first, which is what the model was trained on.
|
|
64
|
+
model: Which checkpoint to use. The small one is 24 MB, the large
|
|
65
|
+
145 MB.
|
|
66
|
+
conf: Confidence threshold. Lower finds more, and more spuriously.
|
|
67
|
+
iou: NMS threshold. Two detections overlapping by more than this are
|
|
68
|
+
treated as one object, so dense or touching objects need it high.
|
|
69
|
+
max_det: Cap on how many objects to report.
|
|
70
|
+
imgsz: Size the image is resized to for inference.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
boxes: (N, 4) as [min_y, min_x, max_y, max_x], in image coordinates.
|
|
74
|
+
|
|
75
|
+
Note: the weights are downloaded into skop's asset cache on first use.
|
|
76
|
+
"""
|
|
77
|
+
from ultralytics import FastSAM
|
|
78
|
+
|
|
79
|
+
weights = file_from_url(_WEIGHTS.format(name=model.value), f"fastsam/{model.value}")
|
|
80
|
+
|
|
81
|
+
progress(f"Loading {model.value}")
|
|
82
|
+
net = FastSAM(str(weights))
|
|
83
|
+
|
|
84
|
+
progress("Detecting objects")
|
|
85
|
+
results = net.predict(
|
|
86
|
+
to_rgb(image),
|
|
87
|
+
conf=conf,
|
|
88
|
+
iou=iou,
|
|
89
|
+
max_det=max_det,
|
|
90
|
+
imgsz=imgsz,
|
|
91
|
+
retina_masks=False,
|
|
92
|
+
verbose=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# An image with nothing in it gives back an empty result list, not a
|
|
96
|
+
# result holding zero boxes.
|
|
97
|
+
if not results or results[0].boxes is None:
|
|
98
|
+
return Boxes(ophelper.boxes.EMPTY.copy())
|
|
99
|
+
|
|
100
|
+
found = results[0].boxes
|
|
101
|
+
progress(f"Found {len(found)} objects")
|
|
102
|
+
# found.conf holds a confidence per box. Not returned -- see _result.py.
|
|
103
|
+
return Boxes(ophelper.boxes.from_xyxy(found.xyxy.cpu().numpy()))
|