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,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
|
skop/ops/kernels/psf.py
ADDED
|
@@ -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"]
|
skop/ops/mask/_result.py
ADDED
|
@@ -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])
|