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,147 @@
1
+ """The Gibson-Lanni point spread function, via sdeconv.
2
+
3
+ A scalar diffraction model of a real objective looking into a real sample: it
4
+ takes the immersion medium and the sample's refractive index, and the depth
5
+ below the coverslip, so it produces the axially asymmetric PSF that spherical
6
+ aberration actually gives you. That asymmetry is the reason to use this over
7
+ :mod:`~skop.ops.kernels.paraxial`.
8
+
9
+ Ported from tnia-python's ``tnia.deconvolution.psfs``, whose two functions --
10
+ ``gibson_lanni_3D`` and ``gibson_lanni_3D_partial_confocal`` -- collapse into
11
+ one op here, the same way the two Gaussians did. The partial-confocal variant
12
+ was the same computation plus recentring, and its ``confocal`` flag was just
13
+ ``confocal_factor=2``.
14
+
15
+ **psfmodels is deliberately absent.** The original could compute this either
16
+ through sdeconv or through psfmodels, chosen by a ``use_psfm`` flag. psfmodels
17
+ is GPL-3.0 and this project is BSD, so that branch is not defaulted off, it is
18
+ gone -- there is no import path here that could reach it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from typing import Annotated
25
+
26
+ import numpy as np
27
+
28
+ from opspec.op import op
29
+ from opspec.runtime import progress
30
+
31
+ from ._fluorophore import Fluorophore
32
+ from ._recenter import recenter_psf_axial
33
+
34
+ __all__ = ["Fluorophore", "gibson_lanni"]
35
+
36
+
37
+ @op(env="sdeconv")
38
+ def gibson_lanni(
39
+ xy_size: Annotated[int, {"min": 4, "max": 2048}] = 128,
40
+ z_size: Annotated[int, {"min": 1, "max": 1024}] = 64,
41
+ voxel_size_xy: Annotated[float, {"min": 0.001, "step": 0.01}] = 0.1,
42
+ voxel_size_z: Annotated[float, {"min": 0.001, "step": 0.01}] = 0.2,
43
+ numerical_aperture: Annotated[float, {"min": 0.1, "max": 2.0, "step": 0.05}] = 1.4,
44
+ ni: Annotated[float, {"min": 1.0, "max": 2.0, "step": 0.001}] = 1.518,
45
+ ns: Annotated[float, {"min": 1.0, "max": 2.0, "step": 0.001}] = 1.33,
46
+ pz: Annotated[float, {"min": 0.0, "step": 0.1}] = 0.0,
47
+ wavelength: Annotated[float, {"min": 0.1, "max": 2.0, "step": 0.01}] = 0.53,
48
+ confocal_factor: Annotated[float, {"min": 1.0, "max": 2.0, "step": 0.1}] = 1.0,
49
+ recenter: bool = True,
50
+ ) -> np.ndarray:
51
+ """Generate a 3-D Gibson-Lanni point spread function.
52
+
53
+ Args:
54
+ xy_size: Extent of the PSF in XY, in voxels. Square.
55
+ z_size: Number of Z planes to return.
56
+ voxel_size_xy: Lateral voxel size, in microns.
57
+ voxel_size_z: Axial voxel size, in microns.
58
+ numerical_aperture: Numerical aperture of the objective.
59
+ ni: Refractive index of the immersion medium. 1.518 is oil, 1.33
60
+ water, 1.0 air.
61
+ ns: Refractive index of the sample.
62
+ pz: Depth of the point below the coverslip, in microns. Non-zero is
63
+ what makes the PSF axially asymmetric, and is the whole reason to
64
+ model spherical aberration.
65
+ wavelength: Emission wavelength, in microns. ``Fluorophore`` members
66
+ can be passed here by name.
67
+ confocal_factor: 1.0 is widefield. 2.0 is confocal -- the PSF squared,
68
+ since a confocal detects through the same aperture it illuminates
69
+ through. Values in between are an ad-hoc approximation of a
70
+ partially closed pinhole and should be used carefully: it is a
71
+ convenient interpolation, not a model of one.
72
+ recenter: Whether to put the peak on the centre plane. sdeconv does
73
+ not centre its output, so a PSF used as a deconvolution kernel
74
+ wants this on, or the result comes out shifted along Z.
75
+
76
+ Returns:
77
+ A ``(z_size, xy_size, xy_size)`` float32 PSF summing to 1.
78
+ """
79
+ from sdeconv.psfs import SPSFGibsonLanni
80
+
81
+ # NB: only sdeconv 1.x is supported, and the environment pins it. The
82
+ # original sniffed sdeconv.__version__ and branched to `PSFGibsonLanni`
83
+ # with a different constructor for 0.x. 0.x is the only version on
84
+ # conda-forge but is years old and takes wavelength in nanometres with no
85
+ # ni/ns at all, so supporting both would mean two different models behind
86
+ # one signature.
87
+ compute_z = _compute_z(z_size, voxel_size_z, pz) if recenter else z_size
88
+
89
+ progress(f"Computing a {compute_z}x{xy_size}x{xy_size} Gibson-Lanni PSF")
90
+ model = SPSFGibsonLanni(
91
+ (compute_z, xy_size, xy_size),
92
+ NA=numerical_aperture,
93
+ ni=ni,
94
+ ni0=ni,
95
+ ns=ns,
96
+ res_lateral=voxel_size_xy,
97
+ res_axial=voxel_size_z,
98
+ wavelength=wavelength,
99
+ pZ=pz,
100
+ )
101
+
102
+ # sdeconv computes in torch and hands back a tensor, on the CPU here since
103
+ # nothing above asked for a device.
104
+ psf = model().cpu().numpy().astype(np.float32)
105
+
106
+ if recenter:
107
+ progress("Recentring")
108
+ psf = recenter_psf_axial(psf, z_size)
109
+
110
+ # NB: the power comes after the crop and before the normalization, which
111
+ # is the order the partial-confocal original used. It matters: squaring
112
+ # first and cropping second would normalize over planes that get thrown
113
+ # away.
114
+ if confocal_factor != 1.0:
115
+ psf = psf**confocal_factor
116
+
117
+ return psf / psf.sum()
118
+
119
+
120
+ def _compute_z(z_size: int, voxel_size_z: float, pz: float) -> int:
121
+ """How many planes to compute so that ``z_size`` can be cropped out centred.
122
+
123
+ The original used a flat ``z_size + z_size // 2``, and the spec described
124
+ that as what "makes a non-zero ``pz`` usable". Measured against sdeconv
125
+ 1.0.4, it is not: the PSF's peak marches toward plane zero as the point
126
+ goes deeper -- about 6.5 planes per micron of ``pz`` at a 0.2 um axial
127
+ voxel, or 1.3 um of apparent focal shift per micron of depth -- and the
128
+ flat headroom is used up somewhere around ``pz`` = 1 um. Past that the
129
+ crop runs off the front of the volume.
130
+
131
+ Which the original did not notice, because it sliced without checking and
132
+ got a silently short array back. :func:`~skop.ops.kernels.recenter_psf_axial`
133
+ raises instead, so the same PSFs that used to come back quietly wrong
134
+ would now come back as errors -- hence this.
135
+
136
+ The headroom therefore scales with the shift. The factor of 3 is empirical
137
+ and deliberately generous: the measured shift is ~1.3 um per micron of
138
+ depth, and doubling that (the peak has to clear ``z_size // 2``, not just
139
+ stay in the volume) gives ~2.6. At 3 the margin grows rather than shrinks
140
+ with depth -- 7 planes to spare at ``pz`` = 0, 31 at ``pz`` = 16 um, for
141
+ ``z_size`` = 32 -- and the cost is only a taller FFT.
142
+
143
+ The shift depends on the refractive index mismatch, so a strongly
144
+ mismatched ``ni``/``ns`` could still outrun this. That case is what the
145
+ error from ``recenter_psf_axial`` is for; it says to raise ``z_size``.
146
+ """
147
+ return z_size + z_size // 2 + math.ceil(3.0 * pz / voxel_size_z)
@@ -0,0 +1,128 @@
1
+ """Paraxial (scalar, in-focus) optical transfer function and point spread function.
2
+
3
+ The cheapest useful optical model there is: a 2-D diffraction-limited OTF that
4
+ follows from wavelength, numerical aperture and pixel size alone, with no
5
+ immersion medium, no sample refractive index, and no focal depth. Where
6
+ Gibson-Lanni models a real objective looking into a real sample, this models
7
+ the textbook. It costs no environment of its own and takes a millisecond, which
8
+ makes it the right kernel for a quick simulation or a sanity check.
9
+
10
+ Ported from tnia-python's ``tnia.deconvolution.psfs``, which credits
11
+ https://github.com/jdmanton/rl_positivity_sim by James Manton.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Annotated
17
+
18
+ import numpy as np
19
+
20
+ from opspec.op import op
21
+
22
+
23
+ @op(env="skimage")
24
+ def paraxial_otf(
25
+ size: Annotated[int, {"min": 4, "max": 4096}] = 128,
26
+ wavelength: Annotated[float, {"min": 0.1, "max": 2.0, "step": 0.01}] = 0.53,
27
+ numerical_aperture: Annotated[float, {"min": 0.1, "max": 2.0, "step": 0.05}] = 1.4,
28
+ pixel_size: Annotated[float, {"min": 0.001, "step": 0.01}] = 0.1,
29
+ ) -> np.ndarray:
30
+ """Generate a paraxial optical transfer function.
31
+
32
+ Args:
33
+ size: Extent of the OTF, in pixels. Square.
34
+ wavelength: Emission wavelength, in microns. ``Fluorophore`` members
35
+ can be passed here by name.
36
+ numerical_aperture: Numerical aperture of the objective.
37
+ pixel_size: Pixel size in the sample plane, in microns.
38
+
39
+ Returns:
40
+ A ``(size, size)`` OTF, 1 at DC and falling to 0 at the cutoff
41
+ frequency, beyond which it is exactly 0.
42
+ """
43
+ return _otf(size, wavelength, numerical_aperture, pixel_size)
44
+
45
+
46
+ @op(env="skimage")
47
+ def paraxial_psf(
48
+ size: Annotated[int, {"min": 4, "max": 4096}] = 128,
49
+ wavelength: Annotated[float, {"min": 0.1, "max": 2.0, "step": 0.01}] = 0.53,
50
+ numerical_aperture: Annotated[float, {"min": 0.1, "max": 2.0, "step": 0.05}] = 1.4,
51
+ pixel_size: Annotated[float, {"min": 0.001, "step": 0.01}] = 0.1,
52
+ ) -> np.ndarray:
53
+ """Generate a paraxial point spread function.
54
+
55
+ The inverse transform of :func:`paraxial_otf`, which is what a
56
+ deconvolution op wants as a kernel.
57
+
58
+ Args:
59
+ size: Extent of the PSF, in pixels. Square.
60
+ wavelength: Emission wavelength, in microns. ``Fluorophore`` members
61
+ can be passed here by name.
62
+ numerical_aperture: Numerical aperture of the objective.
63
+ pixel_size: Pixel size in the sample plane, in microns.
64
+
65
+ Returns:
66
+ A ``(size, size)`` float32 PSF summing to 1.
67
+
68
+ Note:
69
+ **This PSF has negative side lobes, and Richardson-Lucy will diverge
70
+ on it.** An ideal paraxial PSF is the Airy intensity pattern and is
71
+ non-negative; this one is sampled on a grid and inverted with a DFT,
72
+ which leaves ringing, some of it below zero. No single negative value
73
+ is large -- each is around 1e-4 of the peak -- but together they hold
74
+ about 1% of the PSF's mass. That is invisible for simulation, and
75
+ fatal for deconvolution, which is built on ratios and has no meaning
76
+ for a negative kernel: fifty iterations of ``richardson_lucy`` on an
77
+ unclipped one reaches 1e53.
78
+
79
+ Clip and renormalize before deconvolving::
80
+
81
+ psf = np.clip(psf, 0.0, None)
82
+ psf /= psf.sum()
83
+
84
+ The op does not do this itself. At 1% of the mass the correction is
85
+ too large to apply silently -- it would move every number the
86
+ tnia-python original produced by more than a rounding error -- and the
87
+ simulation callers who are the reason this exists do not need it.
88
+ """
89
+ otf = _otf(size, wavelength, numerical_aperture, pixel_size)
90
+
91
+ # NB: the original cast the complex result of ifftn straight to float32,
92
+ # which discards the imaginary part behind a ComplexWarning. Taking .real
93
+ # is the same number, said out loud. The imaginary part is numerical noise
94
+ # either way -- the OTF is real and symmetric, so its transform is real.
95
+ psf = np.fft.fftshift(np.fft.ifftn(np.fft.ifftshift(otf)).real).astype(np.float32)
96
+ return psf / psf.sum()
97
+
98
+
99
+ def _otf(
100
+ size: int, wavelength: float, numerical_aperture: float, pixel_size: float
101
+ ) -> np.ndarray:
102
+ """The OTF itself, shared by both ops so neither has to call the other.
103
+
104
+ An op calling an op would mean a round trip through the worker for what is
105
+ twenty lines of numpy.
106
+ """
107
+ resolution = 0.5 * wavelength / numerical_aperture
108
+
109
+ # NB: the centre is size/2 + 1, not size/2, and the radial coordinate is
110
+ # normalized by the largest coordinate *after* that offset is subtracted --
111
+ # so the normalization runs to size/2 - 2 rather than size/2. Both are
112
+ # inherited verbatim. They amount to a sub-pixel shift of the centre and a
113
+ # few-percent scaling of the cutoff, which matters to nobody using this as
114
+ # a simulation kernel, and changing either would silently move every result
115
+ # this has ever produced.
116
+ centre = size / 2 + 1
117
+ axis = np.linspace(0, size - 1, size) - centre
118
+ x, y = np.meshgrid(axis, axis)
119
+
120
+ filter_radius = 2 * pixel_size / resolution
121
+ radius = np.sqrt(x * x + y * y) / axis.max()
122
+
123
+ inside = radius <= filter_radius
124
+ # Zeroed outside the passband before the arccos, so the square root never
125
+ # sees a negative and the arccos never sees an argument above 1.
126
+ v = np.where(inside, radius / filter_radius, 0.0)
127
+
128
+ return 2 / np.pi * (np.arccos(v) - v * np.sqrt(1 - v * v)) * inside
@@ -0,0 +1,59 @@
1
+ """Point spread functions.
2
+
3
+ Ported from tnia-python's ``tnia.deconvolution.gaussian_psf``, whose separate
4
+ ``gaussian_2d`` and ``gaussian_3d`` are collapsed into one op here: they differ
5
+ only in whether there is a Z axis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated
11
+
12
+ import numpy as np
13
+
14
+ from opspec.op import op
15
+
16
+
17
+ @op(env="skimage")
18
+ def gaussian_psf(
19
+ xy_dim: Annotated[int, {"min": 1, "max": 4096}] = 64,
20
+ xy_sigma: Annotated[float, {"min": 0.0, "step": 0.1}] = 2.0,
21
+ z_dim: Annotated[int, {"min": 0, "max": 4096}] = 0,
22
+ z_sigma: Annotated[float, {"min": 0.0, "step": 0.1}] = 2.0,
23
+ ) -> np.ndarray:
24
+ """Generate a Gaussian point spread function, in 2D or 3D.
25
+
26
+ Args:
27
+ xy_dim: Extent of the PSF in XY, in pixels.
28
+ xy_sigma: Standard deviation in XY, in pixels.
29
+ z_dim: Extent along Z, in pixels. 0 or 1 gives a 2D PSF.
30
+ z_sigma: Standard deviation along Z, in pixels. Ignored in 2D.
31
+
32
+ Returns:
33
+ A float64 PSF summing to 1, shaped (Z, Y, X) or (Y, X).
34
+ """
35
+ # NB: the original built this with a Python loop per voxel. The Gaussian is
36
+ # separable, so the outer product below is the same array for a 64^3 PSF's
37
+ # 260k iterations of nothing.
38
+ profile_xy = _profile(xy_dim, xy_sigma)
39
+
40
+ if z_dim > 1:
41
+ profile_z = _profile(z_dim, z_sigma)
42
+ psf = (
43
+ profile_z[:, np.newaxis, np.newaxis]
44
+ * profile_xy[np.newaxis, :, np.newaxis]
45
+ * profile_xy[np.newaxis, np.newaxis, :]
46
+ )
47
+ else:
48
+ psf = profile_xy[:, np.newaxis] * profile_xy[np.newaxis, :]
49
+
50
+ # NB: gaussian_3d added 1e-12 before normalizing and gaussian_2d did not.
51
+ # Neither does now -- after normalization the floor is ~1e-12 of the peak,
52
+ # which no caller can act on, and having the two paths agree is worth more.
53
+ return psf / psf.sum()
54
+
55
+
56
+ def _profile(dim: int, sigma: float) -> np.ndarray:
57
+ """One axis of a separable Gaussian, centered on the array."""
58
+ coords = np.linspace(-(dim // 2), dim // 2, dim)
59
+ return np.exp(-(coords**2) / (2.0 * sigma**2))
skop/ops/labels.py ADDED
@@ -0,0 +1,144 @@
1
+ """Ops that operate on a label image, rather than producing one.
2
+
3
+ Where a segmentation op turns intensities into objects, these take the objects
4
+ as given and fix up their numbering. They share the ``skimage`` environment
5
+ because none of them needs more than numpy and scipy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated
11
+
12
+ import numpy as np
13
+
14
+ from opspec.op import Axes, op
15
+ from opspec.runtime import progress
16
+ from opspec.types import LabelsOf
17
+
18
+
19
+ @op(env="skimage")
20
+ def connect(
21
+ labels: Annotated[LabelsOf[np.ndarray], Axes("z", "y", "x")],
22
+ threshold: float = 0.1,
23
+ criterion: Annotated[str, {"choices": ["iou", "iop"]}] = "iou",
24
+ ) -> LabelsOf[np.ndarray]:
25
+ """Give one object one label along an axis, by linking overlaps.
26
+
27
+ Segmenting a volume slice by slice numbers each slice from scratch, so one
28
+ cell becomes a stack of unrelated labels -- rainbow pancakes. This walks
29
+ the stack and relabels each slice so that an object inherits the label of
30
+ the object it overlaps in the slice before, turning the pancakes back into
31
+ solid objects.
32
+
33
+ The link is one-to-one per adjacent pair: each object may claim at most one
34
+ predecessor, chosen by maximizing total overlap across the whole pair
35
+ (:func:`scipy.optimize.linear_sum_assignment`). That is what keeps two
36
+ labels that merely *touch* within a slice from being fused, which is why
37
+ this cannot be done by running connected components over the binary union.
38
+ An object matching nothing starts a new label, so a label that disappears
39
+ for a slice does not reclaim its old number when it comes back.
40
+
41
+ The same algorithm is tracking, with Z where T usually goes; it is
42
+ StarDist's ``group_matching_labels`` and Cellpose's ``stitch3D``, and
43
+ scikit-image has no equivalent.
44
+
45
+ Args:
46
+ labels: Label image to connect, one object numbering per slice along
47
+ the first declared axis. The other axes are the slice, and are
48
+ never mixed into the matching.
49
+ threshold: Minimum overlap for two objects to be considered the same,
50
+ in [0, 1]. StarDist links on any overlap at all, Cellpose defaults
51
+ to 0.25; low is safe here because the one-to-one constraint already
52
+ rules out the pathological links.
53
+ criterion: How overlap is scored. ``"iou"`` is intersection over union,
54
+ the usual choice. ``"iop"`` is intersection over the *smaller* of
55
+ the two areas, which is more forgiving when an object grows or
56
+ shrinks sharply from slice to slice -- as it does at the top and
57
+ bottom of a cell, or in an anisotropic stack.
58
+
59
+ Returns:
60
+ A label image of the same shape, numbered consecutively from 1.
61
+ """
62
+ from scipy.optimize import linear_sum_assignment
63
+
64
+ if criterion not in ("iou", "iop"):
65
+ raise ValueError(f"criterion must be 'iou' or 'iop', got {criterion!r}")
66
+ if not 0.0 <= threshold <= 1.0:
67
+ raise ValueError(f"threshold must be in [0, 1], got {threshold}")
68
+
69
+ out = np.zeros(labels.shape, dtype=np.int32)
70
+ if out.size == 0:
71
+ return out
72
+ # The first slice sets the numbering everything after it inherits, so it
73
+ # is compacted rather than copied: that alone makes the result sequential.
74
+ ids, inverse = np.unique(labels[0].ravel(), return_inverse=True)
75
+ lut = np.arange(len(ids), dtype=np.int32)
76
+ if ids[0] != 0:
77
+ lut += 1 # No background in this slice, so nothing may map to 0.
78
+ out[0] = lut[inverse].reshape(labels[0].shape)
79
+ next_id = int(out[0].max()) + 1
80
+
81
+ for z in range(1, len(labels)):
82
+ progress(f"Connecting slice {z + 1} of {len(labels)}", z, len(labels))
83
+ prev, cur = out[z - 1], labels[z]
84
+ prev_ids, cur_ids, inter, prev_area, cur_area = _overlaps(prev, cur)
85
+
86
+ # Every object gets a fresh label until a match says otherwise.
87
+ lut = np.zeros(len(cur_ids) + 1, dtype=np.int32)
88
+ matches: dict[int, int] = {}
89
+ if len(prev_ids) and len(cur_ids):
90
+ if criterion == "iou":
91
+ score = inter / (prev_area[:, None] + cur_area[None, :] - inter)
92
+ else:
93
+ score = inter / np.minimum(prev_area[:, None], cur_area[None, :])
94
+ rows, cols = linear_sum_assignment(score, maximize=True)
95
+ keep = (score[rows, cols] >= threshold) & (score[rows, cols] > 0)
96
+ matches = {int(j): int(i) for j, i in zip(cols[keep], prev_ids[rows[keep]])}
97
+ for j in range(len(cur_ids)):
98
+ if j in matches:
99
+ lut[j + 1] = matches[j]
100
+ else:
101
+ lut[j + 1] = next_id
102
+ next_id += 1
103
+
104
+ # Index 0 of the lut is background; object j sits at j + 1.
105
+ flat = cur.ravel()
106
+ compact = np.searchsorted(cur_ids, flat) + 1
107
+ compact[flat == 0] = 0
108
+ out[z] = lut[compact].reshape(cur.shape)
109
+
110
+ return out
111
+
112
+
113
+ def _overlaps(
114
+ prev: np.ndarray, cur: np.ndarray
115
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
116
+ """Pixel counts shared by each pair of objects across two slices.
117
+
118
+ Returns the object labels of each slice (background excluded, sorted), the
119
+ dense intersection matrix between them, and each object's area. Dense
120
+ because the matrix is objects-by-objects, not pixels-by-pixels: a slice
121
+ with a thousand cells still only costs a megabyte.
122
+ """
123
+ prev_ids, prev_idx = np.unique(prev.ravel(), return_inverse=True)
124
+ cur_ids, cur_idx = np.unique(cur.ravel(), return_inverse=True)
125
+ prev_area = np.bincount(prev_idx, minlength=len(prev_ids)).astype(np.float64)
126
+ cur_area = np.bincount(cur_idx, minlength=len(cur_ids)).astype(np.float64)
127
+
128
+ both = (prev.ravel() != 0) & (cur.ravel() != 0)
129
+ pairs = prev_idx[both] * len(cur_ids) + cur_idx[both]
130
+ inter = np.bincount(pairs, minlength=len(prev_ids) * len(cur_ids)).reshape(
131
+ len(prev_ids), len(cur_ids)
132
+ )
133
+
134
+ # Drop the background row and column, which are only there because 0 sorts
135
+ # first among the labels np.unique found.
136
+ prev_from = 1 if len(prev_ids) and prev_ids[0] == 0 else 0
137
+ cur_from = 1 if len(cur_ids) and cur_ids[0] == 0 else 0
138
+ return (
139
+ prev_ids[prev_from:],
140
+ cur_ids[cur_from:],
141
+ inter[prev_from:, cur_from:].astype(np.float64),
142
+ prev_area[prev_from:],
143
+ cur_area[cur_from:],
144
+ )
@@ -0,0 +1,29 @@
1
+ """Mask detection: given boxes, draw the object inside each one.
2
+
3
+ Stage two of a detect-then-segment workflow, fed by ``skop.ops.detect``. The
4
+ distinction from ``skop.ops.segment`` is what decides where the objects are:
5
+ Cellpose and StarDist decide for themselves and return a label image, while a
6
+ mask detector is *told*, by a prompt, and returns one mask per prompt. That
7
+ separation is what lets a workflow pair any detector with any mask detector,
8
+ which ``skop.ops.workflows.mask`` does.
9
+
10
+ They return masks rather than labels because SAM-family masks overlap -- a
11
+ cell and the debris on it, a coin behind a coin -- and a label image cannot
12
+ say so. ``ophelper.masks`` holds the projections onto something a viewer shows.
13
+
14
+ Every op here shares a signature and returns ``Masks``, so they are
15
+ substitutable. One op per environment, since ``@op(env=...)`` is fixed per
16
+ function: ``microsam_masks`` in the shared 'pytorch' environment, because
17
+ micro_sam is a conda package that coexists with every other torch library
18
+ there, and ``mobilesam_masks`` in segment-everything's own, alongside the
19
+ object-aware detector it was trained to pair with and inside the vendored
20
+ tree it cannot leave.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from ._result import Masks
26
+ from .microsam import microsam_masks
27
+ from .mobilesam import mobilesam_masks
28
+
29
+ __all__ = ["Masks", "microsam_masks", "mobilesam_masks"]
@@ -0,0 +1,40 @@
1
+ """What every mask detector returns.
2
+
3
+ One class, shared by every mask detector op, so that "these ops are
4
+ substitutable" is a fact rather than a convention -- a workflow choosing
5
+ between them gets the same fields and the same output names either way. The
6
+ same reasoning as ``skop.ops.detect._result``, one stage downstream.
7
+
8
+ Two fields rather than one, because a mask detector does not always answer
9
+ every prompt. SAM returns an empty prediction for a box it cannot make sense
10
+ of, both implementations drop those, and so N out is not N in. Without the
11
+ surviving prompts there is no way back to which detection each mask came
12
+ from.
13
+
14
+ The masks are a stack rather than a label image because SAM masks overlap,
15
+ and rather than the list of annotation dicts the SAM ecosystem passes around
16
+ because that cannot cross the Appose boundary: ``oprunner._codec`` rejects
17
+ ``bool`` outright, and N dicts of arrays would become N shared-memory blocks
18
+ where one stack is one. See docs/design/0008-mask-detector-ops.md.
19
+
20
+ No area, no stability score, no predicted IoU. Area is
21
+ ``masks.sum(axis=(1, 2))`` and does not need carrying; the other two are
22
+ MobileSAM's and have no micro_sam counterpart, so they cannot live in a shared
23
+ return type until docs/spec/per-object-features.md says how per-object
24
+ values travel. The same reason ``Boxes`` carries no scores.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import NamedTuple
30
+
31
+ import numpy as np
32
+
33
+ from opspec.types import BoxesOf, MasksOf
34
+
35
+
36
+ class Masks(NamedTuple):
37
+ #: (N, Y, X) uint8, 0 or 1, one object per plane. These may overlap.
38
+ masks: MasksOf[np.ndarray]
39
+ #: (N, 4) as [min_y, min_x, max_y, max_x] -- the prompt each mask answers.
40
+ boxes: BoxesOf[np.ndarray]
@@ -0,0 +1,114 @@
1
+ """Box-prompted segmentation with micro_sam.
2
+
3
+ micro_sam is Segment Anything finetuned for microscopy, by the Computational
4
+ Cell Analytics group at Göttingen. The finetuning is the point: plain SAM was
5
+ trained on photographs and is unremarkable on a field of nuclei, where
6
+ ``vit_b_lm`` was trained on light microscopy and is not.
7
+
8
+ This op prompts it with boxes -- stage two of the detect-then-segment workflow
9
+ in docs/spec/workflow-ops.md, fed by an op from ``skop.ops.detect``.
10
+
11
+ Ported rather than wrapped, unlike its MobileSAM sibling: micro_sam is a
12
+ normal conda package, and the code between it and here is a loop. The original
13
+ is segment_everything/mask_detectors/microsam.py, which drove micro_sam
14
+ through ``sam_annotator._state.AnnotatorState`` -- a singleton belonging to
15
+ micro_sam's napari plugin. The public path below does the same work without
16
+ reaching into another package's private module.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from enum import Enum
22
+
23
+ import numpy as np
24
+
25
+ import ophelper.boxes
26
+ import ophelper.masks
27
+ from opspec.op import op
28
+ from opspec.runtime import progress
29
+ from opspec.types import BoxesOf, ImageOf
30
+
31
+ from ._result import Masks
32
+
33
+
34
+ class PretrainedModel(Enum):
35
+ """micro_sam's finetuned checkpoints, by the imaging they were trained on.
36
+
37
+ All ViT-B. Larger encoders (``vit_l``, ``vit_h``) exist for some of these
38
+ and are better and slower; they are not offered here until someone wants
39
+ one.
40
+ """
41
+
42
+ light_microscopy = "vit_b_lm"
43
+ electron_microscopy = "vit_b_em_organelles"
44
+ histopathology = "vit_b_histopathology"
45
+ #: Plain Segment Anything, unfinetuned -- the baseline to compare against.
46
+ natural_image = "vit_b"
47
+
48
+
49
+ @op(env="pytorch")
50
+ def microsam_masks(
51
+ image: ImageOf[np.ndarray],
52
+ boxes: BoxesOf[np.ndarray],
53
+ model: PretrainedModel = PretrainedModel.light_microscopy,
54
+ ) -> Masks:
55
+ """Segment one object per bounding box.
56
+
57
+ Args:
58
+ image: Image to segment. 2-D or RGB. micro_sam normalizes it to 8-bit
59
+ itself, by min and max -- so a single hot pixel costs contrast
60
+ everywhere else, and clipping such an image first is worth it.
61
+ boxes: (N, 4) as [min_y, min_x, max_y, max_x], from a detector op, a
62
+ Shapes layer, or ``ophelper.boxes.from_labels``.
63
+ model: Which finetuned checkpoint to prompt.
64
+
65
+ Returns:
66
+ masks: (N, Y, X) uint8, one object per plane. These may overlap, which
67
+ is why they are not a label image; project them with
68
+ ``ophelper.masks``.
69
+ boxes: the prompts that produced them.
70
+
71
+ Masks come back in prompt order, not sorted -- ``masks[i]`` answers
72
+ ``boxes[i]``. Boxes SAM finds nothing in are dropped from both, so this may
73
+ be shorter than what went in.
74
+
75
+ Note: the weights are ~375 MB, downloaded into micro_sam's own cache on
76
+ first use, which MICROSAM_CACHEDIR relocates.
77
+ """
78
+ # Checked before the imports: a detector that found nothing is an ordinary
79
+ # workflow outcome, and there is no reason to load a 375 MB model to
80
+ # answer no prompts.
81
+ prompts = ophelper.boxes.as_boxes(boxes)
82
+ if len(prompts) == 0:
83
+ return Masks(ophelper.masks.empty(image.shape[:2]), ophelper.boxes.EMPTY.copy())
84
+
85
+ from micro_sam.prompt_based_segmentation import segment_from_box
86
+ from micro_sam.util import get_sam_model, precompute_image_embeddings
87
+
88
+ progress(f"Loading {model.value}")
89
+ predictor = get_sam_model(model_type=model.value)
90
+
91
+ # The expensive half, and it depends only on the image -- which is why
92
+ # this op takes every box at once rather than one per call (workflow-ops).
93
+ progress("Computing image embeddings")
94
+ embeddings = precompute_image_embeddings(predictor, image, ndim=2, verbose=False)
95
+
96
+ # micro_sam's own box prompts are [min_y, min_x, max_y, max_x]: its
97
+ # _process_box does the swap to SAM's xyxy internally. That is skop's
98
+ # canonical order, so nothing is converted here.
99
+ found = []
100
+ kept = []
101
+ for i, box in enumerate(prompts):
102
+ progress(f"Segmenting object {i + 1} of {len(prompts)}", i, len(prompts))
103
+ mask = np.squeeze(segment_from_box(predictor, box, image_embeddings=embeddings))
104
+ # SAM answers some prompts with nothing at all. Dropping those is why
105
+ # the surviving boxes are returned alongside the masks.
106
+ if not mask.any():
107
+ continue
108
+ found.append(mask.astype(np.uint8))
109
+ kept.append(i)
110
+
111
+ progress(f"Segmented {len(found)} of {len(prompts)} objects")
112
+ if not found:
113
+ return Masks(ophelper.masks.empty(image.shape[:2]), ophelper.boxes.EMPTY.copy())
114
+ return Masks(np.stack(found), prompts[kept])