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,176 @@
|
|
|
1
|
+
"""Box-prompted segmentation with MobileSAMv2's prompt-guided decoder.
|
|
2
|
+
|
|
3
|
+
The other half of the model ``skop.ops.detect.object_aware_yolo`` wraps, and
|
|
4
|
+
the reason both live in the same environment: the object-aware YOLO was
|
|
5
|
+
trained to produce box prompts for exactly this decoder, and both come out of
|
|
6
|
+
the vendored MobileSAMv2 tree that segment-everything carries.
|
|
7
|
+
|
|
8
|
+
The model is assembled from three pieces, which is what
|
|
9
|
+
``weights_helper.create_mobile_sam_model`` does:
|
|
10
|
+
|
|
11
|
+
- an empty ``vit_h`` Sam, for the postprocessing and the thresholds,
|
|
12
|
+
- EfficientViT-L2 as its image encoder (``l2.pt``, 234 MB),
|
|
13
|
+
- MobileSAMv2's prompt encoder and mask decoder, which are one 15 MB file.
|
|
14
|
+
|
|
15
|
+
Assembled here rather than by calling ``create_mobile_sam_model``, for the
|
|
16
|
+
reason ``object_aware_yolo`` gives: ``segment_everything.weights_helper``
|
|
17
|
+
imports toolz and ``napari.utils.progress`` at module scope, and
|
|
18
|
+
segment-everything declares neither. Importing it means installing napari into
|
|
19
|
+
a worker to download a file. Both weight files come from the HuggingFace
|
|
20
|
+
mirror already listed in its own ``WEIGHTS_URLS``, rather than through gdown
|
|
21
|
+
from Google Drive.
|
|
22
|
+
|
|
23
|
+
Its sibling ``microsam_masks`` is a port; this is a wrapper. That asymmetry is
|
|
24
|
+
the whole argument of docs/design/0007: micro_sam is a conda package, and this
|
|
25
|
+
decoder only exists inside a 28,000-line vendored tree.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
import ophelper.boxes
|
|
33
|
+
import ophelper.masks
|
|
34
|
+
from ophelper.assets import file_from_url
|
|
35
|
+
from opspec.op import op
|
|
36
|
+
from opspec.runtime import progress
|
|
37
|
+
from opspec.types import BoxesOf, ImageOf
|
|
38
|
+
|
|
39
|
+
from .._util import to_rgb
|
|
40
|
+
from ._result import Masks
|
|
41
|
+
|
|
42
|
+
# EfficientViT-L2, the image encoder. The same file weights_helper fetches
|
|
43
|
+
# through gdown as "efficientvit_l2"; this is its HuggingFace mirror.
|
|
44
|
+
_ENCODER = "https://huggingface.co/RogerQi/MobileSAMV2/resolve/main/l2.pt"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@op(env="segment-everything")
|
|
48
|
+
def mobilesam_masks(
|
|
49
|
+
image: ImageOf[np.ndarray],
|
|
50
|
+
boxes: BoxesOf[np.ndarray],
|
|
51
|
+
batch_size: int = 100,
|
|
52
|
+
) -> Masks:
|
|
53
|
+
"""Segment one object per bounding box.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
image: Image to segment. 2-D or RGB; anything else is stretched to
|
|
57
|
+
8-bit RGB first, which is what the model was trained on.
|
|
58
|
+
boxes: (N, 4) as [min_y, min_x, max_y, max_x], from a detector op, a
|
|
59
|
+
Shapes layer, or ``ophelper.boxes.from_labels``.
|
|
60
|
+
batch_size: How many boxes to decode at once. The image is encoded
|
|
61
|
+
once regardless; this only trades GPU memory against the number
|
|
62
|
+
of decoder calls.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
masks: (N, Y, X) uint8, one object per plane. These may overlap, which
|
|
66
|
+
is why they are not a label image; project them with
|
|
67
|
+
``ophelper.masks``.
|
|
68
|
+
boxes: the prompts that produced them.
|
|
69
|
+
|
|
70
|
+
Masks come back in prompt order, not sorted -- ``masks[i]`` answers
|
|
71
|
+
``boxes[i]``. Boxes the decoder finds nothing in are dropped from both, so
|
|
72
|
+
this may be shorter than what went in.
|
|
73
|
+
|
|
74
|
+
Note: the image encoder is 234 MB, downloaded into skop's asset cache on
|
|
75
|
+
first use. The decoder ships inside segment-everything itself.
|
|
76
|
+
"""
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
import segment_everything
|
|
80
|
+
import torch
|
|
81
|
+
from segment_everything.vendored.mobilesamv2 import (
|
|
82
|
+
SamPredictor,
|
|
83
|
+
sam_model_registry,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
prompts = ophelper.boxes.as_boxes(boxes)
|
|
87
|
+
if len(prompts) == 0:
|
|
88
|
+
return Masks(ophelper.masks.empty(image.shape[:2]), ophelper.boxes.EMPTY.copy())
|
|
89
|
+
|
|
90
|
+
if torch.cuda.is_available():
|
|
91
|
+
device = "cuda"
|
|
92
|
+
elif torch.backends.mps.is_available():
|
|
93
|
+
device = "mps"
|
|
94
|
+
else:
|
|
95
|
+
device = "cpu"
|
|
96
|
+
|
|
97
|
+
progress("Loading MobileSAMv2")
|
|
98
|
+
encoder_weights = file_from_url(_ENCODER, "mobilesam/l2.pt")
|
|
99
|
+
# Shipped inside the installed package rather than downloaded -- this is
|
|
100
|
+
# the one entry in WEIGHTS_URLS marked "local:". Same file as the
|
|
101
|
+
# Prompt_guided_Mask_Decoder.pt on the mirror above, if it ever moves.
|
|
102
|
+
decoder_weights = (
|
|
103
|
+
Path(segment_everything.__file__).parent
|
|
104
|
+
/ "vendored/PromptGuidedDecoder/Prompt_guided_Mask_Decoder.pt"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
prompt_guided = sam_model_registry["PromptGuidedDecoder"](str(decoder_weights))
|
|
108
|
+
model = sam_model_registry["vit_h"]()
|
|
109
|
+
model.prompt_encoder = prompt_guided["PromtEncoder"] # sic, upstream spelling
|
|
110
|
+
model.mask_decoder = prompt_guided["MaskDecoder"]
|
|
111
|
+
model.image_encoder = sam_model_registry["efficientvit_l2"](str(encoder_weights))
|
|
112
|
+
model.to(device)
|
|
113
|
+
|
|
114
|
+
predictor = SamPredictor(model)
|
|
115
|
+
|
|
116
|
+
# The expensive half, and it depends only on the image -- which is why
|
|
117
|
+
# this op takes every box at once rather than one per call (workflow-ops).
|
|
118
|
+
progress("Encoding the image")
|
|
119
|
+
predictor.set_image(to_rgb(image))
|
|
120
|
+
|
|
121
|
+
# SamPredictor's transform resizes prompts alongside the image it just
|
|
122
|
+
# encoded, and it reads them as xyxy -- the one conversion this op needs.
|
|
123
|
+
scaled = predictor.transform.apply_boxes(
|
|
124
|
+
ophelper.boxes.to_xyxy(prompts), predictor.original_size
|
|
125
|
+
)
|
|
126
|
+
scaled = torch.as_tensor(scaled, dtype=torch.float32, device=device)
|
|
127
|
+
|
|
128
|
+
# One encoding, broadcast across each batch of prompts. Sliced from the
|
|
129
|
+
# full repeat rather than reassigned in place: the original narrowed these
|
|
130
|
+
# tensors on every iteration, which only survived because the short batch
|
|
131
|
+
# is always the last one.
|
|
132
|
+
image_embedding = torch.repeat_interleave(predictor.features, batch_size, dim=0)
|
|
133
|
+
prompt_embedding = torch.repeat_interleave(
|
|
134
|
+
model.prompt_encoder.get_dense_pe(), batch_size, dim=0
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
decoded = []
|
|
138
|
+
for start in range(0, len(scaled), batch_size):
|
|
139
|
+
batch = scaled[start : start + batch_size]
|
|
140
|
+
progress(
|
|
141
|
+
f"Segmenting objects {start + 1}-{start + len(batch)} of {len(scaled)}",
|
|
142
|
+
start,
|
|
143
|
+
len(scaled),
|
|
144
|
+
)
|
|
145
|
+
with torch.no_grad():
|
|
146
|
+
sparse, dense = model.prompt_encoder(points=None, boxes=batch, masks=None)
|
|
147
|
+
low_res, _predicted_iou = model.mask_decoder(
|
|
148
|
+
image_embeddings=image_embedding[: len(batch)],
|
|
149
|
+
image_pe=prompt_embedding[: len(batch)],
|
|
150
|
+
sparse_prompt_embeddings=sparse,
|
|
151
|
+
dense_prompt_embeddings=dense,
|
|
152
|
+
multimask_output=False,
|
|
153
|
+
simple_type=True,
|
|
154
|
+
)
|
|
155
|
+
# _predicted_iou is a confidence per mask, dropped for the same
|
|
156
|
+
# reason Boxes drops its scores -- see _result.py and the
|
|
157
|
+
# per-object-features spec. The
|
|
158
|
+
# original also computed a stability score here, which cost a
|
|
159
|
+
# second thresholding pass for a value nothing consumed.
|
|
160
|
+
full = predictor.model.postprocess_masks(
|
|
161
|
+
low_res, predictor.input_size, predictor.original_size
|
|
162
|
+
)
|
|
163
|
+
decoded.append((full > model.mask_threshold).squeeze(1).to(torch.uint8))
|
|
164
|
+
|
|
165
|
+
stacked = torch.cat(decoded).cpu().numpy()
|
|
166
|
+
del decoded
|
|
167
|
+
if device == "cuda":
|
|
168
|
+
torch.cuda.empty_cache()
|
|
169
|
+
|
|
170
|
+
# The decoder answers some prompts with nothing at all. Dropping those is
|
|
171
|
+
# why the surviving boxes are returned alongside the masks.
|
|
172
|
+
kept = stacked.any(axis=(1, 2))
|
|
173
|
+
progress(f"Segmented {int(kept.sum())} of {len(prompts)} objects")
|
|
174
|
+
if not kept.any():
|
|
175
|
+
return Masks(ophelper.masks.empty(image.shape[:2]), ophelper.boxes.EMPTY.copy())
|
|
176
|
+
return Masks(np.ascontiguousarray(stacked[kept]), prompts[kept])
|
skop/ops/morphology.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Grayscale morphology, via scikit-image.
|
|
2
|
+
|
|
3
|
+
Morphology asks a different question from the filters in
|
|
4
|
+
:mod:`skop.ops.smooth`: not "what is the average around here" but "does a
|
|
5
|
+
shape of this size fit here". Every op below slides one structuring element
|
|
6
|
+
-- a footprint of a given radius -- over the image and takes an extremum
|
|
7
|
+
under it, and the whole family follows from the two that do:
|
|
8
|
+
:func:`erosion` takes the minimum, :func:`dilation` the maximum, and the
|
|
9
|
+
rest are those two composed.
|
|
10
|
+
|
|
11
|
+
Because they choose an existing value rather than computing a new one, they
|
|
12
|
+
introduce no intermediate intensities and preserve the input's dtype -- which
|
|
13
|
+
is why they are the safe thing to run on a label image or a mask, and why
|
|
14
|
+
:func:`white_tophat` is the classical background subtraction.
|
|
15
|
+
|
|
16
|
+
The radius is the whole parameter: it says what counts as small. Anything
|
|
17
|
+
smaller than the footprint does not survive an opening, and that is the
|
|
18
|
+
knob to turn. Its shape is the second, and :class:`Footprint` -- re-exported
|
|
19
|
+
here, since this is the namespace it belongs to -- is what names the three
|
|
20
|
+
choices. :func:`skop.ops.smooth.median` takes the same enum.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Annotated
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from opspec.op import Axes, op
|
|
30
|
+
from opspec.types import ImageOf
|
|
31
|
+
|
|
32
|
+
from ._util import Footprint, channel_axis, footprint, per_channel
|
|
33
|
+
|
|
34
|
+
#: Every op here works on any number of axes, RGB(A) channel by channel.
|
|
35
|
+
_Image = Annotated[ImageOf[np.ndarray], Axes(variadic=True)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _apply(image: np.ndarray, radius: int, shape: Footprint, fn) -> np.ndarray:
|
|
39
|
+
"""Run a morphological op with a footprint matching the image's rank."""
|
|
40
|
+
if radius < 1:
|
|
41
|
+
raise ValueError(f"radius must be at least 1, got {radius}")
|
|
42
|
+
spatial = image.ndim - (0 if channel_axis(image) is None else 1)
|
|
43
|
+
fp = footprint(spatial, radius, shape)
|
|
44
|
+
return per_channel(image, lambda plane: fn(plane, footprint=fp))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@op(env="skimage")
|
|
48
|
+
def erosion(
|
|
49
|
+
image: _Image,
|
|
50
|
+
radius: int = 1,
|
|
51
|
+
shape: Footprint = Footprint.ball,
|
|
52
|
+
) -> ImageOf[np.ndarray]:
|
|
53
|
+
"""Shrink bright regions, by taking the minimum under the footprint.
|
|
54
|
+
|
|
55
|
+
Bright objects lose a border of ``radius`` and dark ones grow by the
|
|
56
|
+
same; anything bright and thinner than the footprint disappears. On a
|
|
57
|
+
binary mask this is how touching objects are pulled apart before a
|
|
58
|
+
connected-components pass.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
image: Image to erode. Any number of axes; a trailing RGB(A) axis is
|
|
62
|
+
eroded channel by channel.
|
|
63
|
+
radius: Radius of the footprint, in pixels.
|
|
64
|
+
shape: Shape of the footprint.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The eroded image, in the input's dtype.
|
|
68
|
+
"""
|
|
69
|
+
from skimage import morphology
|
|
70
|
+
|
|
71
|
+
return _apply(image, radius, shape, morphology.erosion)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@op(env="skimage")
|
|
75
|
+
def dilation(
|
|
76
|
+
image: _Image,
|
|
77
|
+
radius: int = 1,
|
|
78
|
+
shape: Footprint = Footprint.ball,
|
|
79
|
+
) -> ImageOf[np.ndarray]:
|
|
80
|
+
"""Grow bright regions, by taking the maximum under the footprint.
|
|
81
|
+
|
|
82
|
+
The dual of :func:`erosion`: bright objects gain a border of ``radius``,
|
|
83
|
+
and gaps narrower than the footprint close up. The usual use is joining a
|
|
84
|
+
structure that noise or thresholding has broken into pieces.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
image: Image to dilate. Any number of axes; a trailing RGB(A) axis is
|
|
88
|
+
dilated channel by channel.
|
|
89
|
+
radius: Radius of the footprint, in pixels.
|
|
90
|
+
shape: Shape of the footprint.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The dilated image, in the input's dtype.
|
|
94
|
+
"""
|
|
95
|
+
from skimage import morphology
|
|
96
|
+
|
|
97
|
+
return _apply(image, radius, shape, morphology.dilation)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@op(env="skimage")
|
|
101
|
+
def opening(
|
|
102
|
+
image: _Image,
|
|
103
|
+
radius: int = 1,
|
|
104
|
+
shape: Footprint = Footprint.ball,
|
|
105
|
+
) -> ImageOf[np.ndarray]:
|
|
106
|
+
"""Remove bright detail smaller than the footprint.
|
|
107
|
+
|
|
108
|
+
An erosion followed by a dilation. The erosion deletes whatever the
|
|
109
|
+
footprint does not fit inside, and the dilation restores what survived to
|
|
110
|
+
roughly its original size -- so unlike a plain erosion this removes small
|
|
111
|
+
bright specks without shrinking everything else. Sizes and shapes larger
|
|
112
|
+
than the footprint come through essentially untouched.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
image: Image to open. Any number of axes; a trailing RGB(A) axis is
|
|
116
|
+
opened channel by channel.
|
|
117
|
+
radius: Radius of the footprint, in pixels. Bright structure thinner
|
|
118
|
+
than this is what gets removed.
|
|
119
|
+
shape: Shape of the footprint.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The opened image, in the input's dtype.
|
|
123
|
+
"""
|
|
124
|
+
from skimage import morphology
|
|
125
|
+
|
|
126
|
+
return _apply(image, radius, shape, morphology.opening)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@op(env="skimage")
|
|
130
|
+
def closing(
|
|
131
|
+
image: _Image,
|
|
132
|
+
radius: int = 1,
|
|
133
|
+
shape: Footprint = Footprint.ball,
|
|
134
|
+
) -> ImageOf[np.ndarray]:
|
|
135
|
+
"""Remove dark detail smaller than the footprint.
|
|
136
|
+
|
|
137
|
+
A dilation followed by an erosion: the mirror of :func:`opening`, filling
|
|
138
|
+
small dark holes and thin dark gaps without growing the bright regions
|
|
139
|
+
around them. Between the two, most mask cleanup is a closing to mend
|
|
140
|
+
objects and then an opening to drop debris.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
image: Image to close. Any number of axes; a trailing RGB(A) axis is
|
|
144
|
+
closed channel by channel.
|
|
145
|
+
radius: Radius of the footprint, in pixels. Dark structure thinner
|
|
146
|
+
than this is what gets filled.
|
|
147
|
+
shape: Shape of the footprint.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The closed image, in the input's dtype.
|
|
151
|
+
"""
|
|
152
|
+
from skimage import morphology
|
|
153
|
+
|
|
154
|
+
return _apply(image, radius, shape, morphology.closing)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@op(env="skimage")
|
|
158
|
+
def white_tophat(
|
|
159
|
+
image: _Image,
|
|
160
|
+
radius: int = 1,
|
|
161
|
+
shape: Footprint = Footprint.ball,
|
|
162
|
+
) -> ImageOf[np.ndarray]:
|
|
163
|
+
"""Keep bright detail smaller than the footprint, dropping the background.
|
|
164
|
+
|
|
165
|
+
The image minus its :func:`opening` -- that is, exactly what the opening
|
|
166
|
+
removed. Since an opening keeps whatever is larger than the footprint,
|
|
167
|
+
the difference is the small bright structure sitting on top of it, with
|
|
168
|
+
slow background variation subtracted away.
|
|
169
|
+
|
|
170
|
+
This is background subtraction, done morphologically: a radius comfortably
|
|
171
|
+
larger than the objects and smaller than the illumination gradient
|
|
172
|
+
flattens the field without touching the objects.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
image: Image to filter. Any number of axes; a trailing RGB(A) axis is
|
|
176
|
+
filtered channel by channel.
|
|
177
|
+
radius: Radius of the footprint, in pixels. Bright structure smaller
|
|
178
|
+
than this is what is kept.
|
|
179
|
+
shape: Shape of the footprint.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The bright detail, in the input's dtype.
|
|
183
|
+
"""
|
|
184
|
+
from skimage import morphology
|
|
185
|
+
|
|
186
|
+
return _apply(image, radius, shape, morphology.white_tophat)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@op(env="skimage")
|
|
190
|
+
def black_tophat(
|
|
191
|
+
image: _Image,
|
|
192
|
+
radius: int = 1,
|
|
193
|
+
shape: Footprint = Footprint.ball,
|
|
194
|
+
) -> ImageOf[np.ndarray]:
|
|
195
|
+
"""Keep dark detail smaller than the footprint, dropping the background.
|
|
196
|
+
|
|
197
|
+
The :func:`closing` minus the image, and the dual of
|
|
198
|
+
:func:`white_tophat`: it isolates small dark structure on a bright,
|
|
199
|
+
slowly varying background. What to reach for on a brightfield image,
|
|
200
|
+
where the objects are the dark things.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
image: Image to filter. Any number of axes; a trailing RGB(A) axis is
|
|
204
|
+
filtered channel by channel.
|
|
205
|
+
radius: Radius of the footprint, in pixels. Dark structure smaller
|
|
206
|
+
than this is what is kept.
|
|
207
|
+
shape: Shape of the footprint.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
The dark detail, in the input's dtype.
|
|
211
|
+
"""
|
|
212
|
+
from skimage import morphology
|
|
213
|
+
|
|
214
|
+
return _apply(image, radius, shape, morphology.black_tophat)
|
skop/ops/normalize.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Intensity normalization, separated from the models that assume it.
|
|
2
|
+
|
|
3
|
+
Every deep learning segmenter wants its input scaled the way its training
|
|
4
|
+
data was, and most of them do it silently on whatever array they are handed.
|
|
5
|
+
That is fine until the array is a slice of something bigger.
|
|
6
|
+
|
|
7
|
+
StarDist is the case this exists for. ``stardist2d_fluo`` declares
|
|
8
|
+
``Axes("y", "x", "c?")``, so a volume handed to it is looped over plane by
|
|
9
|
+
plane -- and csbdeep normalizes *each plane* against its own percentiles. A
|
|
10
|
+
top or bottom plane holding nothing but out-of-focus blur gets stretched until
|
|
11
|
+
that blur fills the range and reads as nuclei, so the segmenter finds objects
|
|
12
|
+
there that are not there.
|
|
13
|
+
|
|
14
|
+
Normalizing first, over the whole volume, fixes it: the empty planes stay
|
|
15
|
+
dim relative to the ones with signal in them, because they are measured
|
|
16
|
+
against the same range. Hence ``Axes(variadic=True)`` here, the same
|
|
17
|
+
declaration ``otsu`` carries -- percentiles do not care how many axes they are
|
|
18
|
+
given, so the volume arrives whole and is normalized in one call, and only
|
|
19
|
+
then does the 2-D segmenter run slice by slice.
|
|
20
|
+
|
|
21
|
+
Pure numpy, so it lives in the 'minimal' environment and composes with a model
|
|
22
|
+
in any other. An op's environment is where it runs, not where its caller runs:
|
|
23
|
+
this normalizing in 'minimal' and feeding StarDist in 'stardist-tf' is two
|
|
24
|
+
worker processes and one shared-memory handoff.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Annotated
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from opspec.op import Axes, op
|
|
34
|
+
from opspec.types import ImageOf
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@op(env="minimal")
|
|
38
|
+
def percentile(
|
|
39
|
+
image: Annotated[ImageOf[np.ndarray], Axes(variadic=True)],
|
|
40
|
+
low: Annotated[
|
|
41
|
+
float,
|
|
42
|
+
{"widget_type": "FloatSpinBox", "min": 0.0, "max": 100.0, "step": 0.1},
|
|
43
|
+
] = 1.0,
|
|
44
|
+
high: Annotated[
|
|
45
|
+
float,
|
|
46
|
+
{"widget_type": "FloatSpinBox", "min": 0.0, "max": 100.0, "step": 0.1},
|
|
47
|
+
] = 99.8,
|
|
48
|
+
clip: bool = False,
|
|
49
|
+
) -> ImageOf[np.ndarray]:
|
|
50
|
+
"""Rescale intensities so that two percentiles land on 0 and 1.
|
|
51
|
+
|
|
52
|
+
The normalization csbdeep applies inside StarDist and CARE, as an op that
|
|
53
|
+
can be called on its own -- which matters whenever the thing consuming it
|
|
54
|
+
works on fewer axes than you have. See this module's docstring.
|
|
55
|
+
|
|
56
|
+
Percentiles rather than min and max, because one hot pixel or one dead
|
|
57
|
+
one would otherwise set the whole range.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
image: Image to normalize. Any number of axes; all of them are
|
|
61
|
+
measured together, so a volume gets one range rather than one per
|
|
62
|
+
plane. A trailing RGB axis is included in that, which is right for
|
|
63
|
+
a photograph and wrong for unrelated fluorescence channels --
|
|
64
|
+
normalize those separately for now.
|
|
65
|
+
low: Percentile mapped to 0. StarDist's own default is 1.
|
|
66
|
+
high: Percentile mapped to 1. StarDist's own default is 99.8.
|
|
67
|
+
clip: Whether to cut everything outside [0, 1]. Off by default,
|
|
68
|
+
matching csbdeep: values beyond the percentiles are real signal,
|
|
69
|
+
and a model trained this way saw them.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The image as float32, with ``low`` at 0 and ``high`` at 1.
|
|
73
|
+
"""
|
|
74
|
+
if not 0.0 <= low < high <= 100.0:
|
|
75
|
+
raise ValueError(f"need 0 <= low < high <= 100, got low={low}, high={high}")
|
|
76
|
+
|
|
77
|
+
array = np.asarray(image, dtype=np.float32)
|
|
78
|
+
lo, hi = np.percentile(array, [low, high])
|
|
79
|
+
|
|
80
|
+
# The epsilon is csbdeep's, and it is what keeps a blank image -- every
|
|
81
|
+
# pixel identical, so lo == hi -- from dividing by zero and returning nan.
|
|
82
|
+
out = (array - lo) / (hi - lo + 1e-20)
|
|
83
|
+
if clip:
|
|
84
|
+
out = np.clip(out, 0.0, 1.0)
|
|
85
|
+
return out.astype(np.float32)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Segmentation ops: label the objects in an image.
|
|
2
|
+
|
|
3
|
+
Each op here gets a module of its own, because each brings its own
|
|
4
|
+
environment, its own pretrained models and its own supporting types. The
|
|
5
|
+
re-exports below are the namespace's public surface -- written out rather
|
|
6
|
+
than generated, so that an IDE can offer them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .cellpose4 import cellpose4
|
|
12
|
+
from .cellpose3 import cellpose3
|
|
13
|
+
from .stardist2d import stardist2d_fluo, stardist2d_he
|
|
14
|
+
from .starfun3d import segment_nuclei
|
|
15
|
+
from .unseg import unseg
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"cellpose4",
|
|
19
|
+
"cellpose3",
|
|
20
|
+
"segment_nuclei",
|
|
21
|
+
"stardist2d_fluo",
|
|
22
|
+
"stardist2d_he",
|
|
23
|
+
"unseg",
|
|
24
|
+
]
|