scikit-ops 0.1.0.dev0__py3-none-any.whl

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