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,97 @@
|
|
|
1
|
+
"""Find the objects, then draw each one: a detector paired with a masker.
|
|
2
|
+
|
|
3
|
+
The two-stage segmenter from docs/spec/workflow-ops.md, and the reason the
|
|
4
|
+
detectors and mask detectors were built to share signatures in the first
|
|
5
|
+
place. Any of the two detectors pairs with either of the two mask detectors,
|
|
6
|
+
which is four segmenters from four ops -- and the point of the pairing is that
|
|
7
|
+
they are genuinely different: the object-aware YOLO finds objects a
|
|
8
|
+
photograph-trained SAM misses, and micro_sam draws cells that MobileSAM
|
|
9
|
+
smears.
|
|
10
|
+
|
|
11
|
+
Both stages take the image. Only one asks for it: ``binds`` on each stage's
|
|
12
|
+
arguments names what this workflow supplies, so a front end renders the rest
|
|
13
|
+
and nothing gets typed twice. The masker's ``boxes`` are bound for the same
|
|
14
|
+
reason, though they come from the detector rather than from the panel.
|
|
15
|
+
|
|
16
|
+
Note that a pairing may cross environments -- ``fastsam`` in 'pytorch' with
|
|
17
|
+
``mobilesam_masks`` in 'segment-everything' means two workers, and the image
|
|
18
|
+
crosses shared memory twice. That is the cost of composing on the host, and
|
|
19
|
+
it is why this is a workflow rather than an op.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import Annotated, NamedTuple
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from opspec.op import Choices, ParamsFor, op
|
|
30
|
+
from opspec.runtime import progress, run
|
|
31
|
+
from opspec.types import BoxesOf, ImageOf, MasksOf
|
|
32
|
+
from skop.ops.detect.fastsam import fastsam
|
|
33
|
+
from skop.ops.detect.object_aware_yolo import object_aware_yolo
|
|
34
|
+
from skop.ops.mask.microsam import microsam_masks
|
|
35
|
+
from skop.ops.mask.mobilesam import mobilesam_masks
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Detected(NamedTuple):
|
|
39
|
+
"""What each stage produced, in the order the stages ran.
|
|
40
|
+
|
|
41
|
+
Stage order rather than importance: a front end adds these to a viewer in
|
|
42
|
+
declaration order, so the pipeline reads down the layer list, and the last
|
|
43
|
+
stage -- the answer -- ends up on top where a new layer belongs. This is
|
|
44
|
+
why the fields sit the other way round from ``skop.ops.mask.Masks``, which
|
|
45
|
+
every mask detector returns. There, the masks are the point and the boxes
|
|
46
|
+
record which prompts survived; here they are stage one and stage two.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
#: The prompts that produced the masks: the detector's boxes, minus any
|
|
50
|
+
#: the mask detector answered with nothing.
|
|
51
|
+
boxes: BoxesOf[np.ndarray]
|
|
52
|
+
#: (N, Y, X) uint8, one object per plane, possibly overlapping.
|
|
53
|
+
masks: MasksOf[np.ndarray]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@op()
|
|
57
|
+
def detect_then_mask(
|
|
58
|
+
image: ImageOf[np.ndarray],
|
|
59
|
+
detector: Annotated[
|
|
60
|
+
Callable,
|
|
61
|
+
Choices(object_aware_yolo=object_aware_yolo, fastsam=fastsam),
|
|
62
|
+
] = object_aware_yolo,
|
|
63
|
+
detector_args: Annotated[dict | None, ParamsFor("detector", binds="image")] = None,
|
|
64
|
+
masker: Annotated[
|
|
65
|
+
Callable,
|
|
66
|
+
Choices(mobilesam=mobilesam_masks, microsam=microsam_masks),
|
|
67
|
+
] = mobilesam_masks,
|
|
68
|
+
masker_args: Annotated[
|
|
69
|
+
dict | None, ParamsFor("masker", binds=("image", "boxes"))
|
|
70
|
+
] = None,
|
|
71
|
+
) -> Detected:
|
|
72
|
+
"""Detect objects and segment each one.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
image: The image to segment.
|
|
76
|
+
detector: Which op finds the bounding boxes. ``object_aware_yolo`` was
|
|
77
|
+
trained on microscopy; ``fastsam`` is a general detector.
|
|
78
|
+
detector_args: Settings for the chosen detector, minus the image.
|
|
79
|
+
masker: Which op draws a mask inside each box. ``microsam`` is
|
|
80
|
+
finetuned for microscopy and slower; ``mobilesam`` is the one the
|
|
81
|
+
object-aware detector was trained to pair with.
|
|
82
|
+
masker_args: Settings for the chosen mask detector, minus the image
|
|
83
|
+
and the boxes, which this workflow supplies.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
boxes: the box each mask answers.
|
|
87
|
+
masks: (N, Y, X) uint8, one object per plane, possibly overlapping.
|
|
88
|
+
|
|
89
|
+
A detector that finds nothing is an ordinary outcome, not a failure: the
|
|
90
|
+
mask detector is skipped and the result is empty.
|
|
91
|
+
"""
|
|
92
|
+
progress("Detecting objects")
|
|
93
|
+
boxes = run(detector, image=image, **(detector_args or {})).boxes
|
|
94
|
+
|
|
95
|
+
progress(f"Segmenting {len(boxes)} objects")
|
|
96
|
+
found = run(masker, image=image, boxes=boxes, **(masker_args or {}))
|
|
97
|
+
return Detected(found.boxes, found.masks)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Segment a volume plane by plane, then join the planes back into objects.
|
|
2
|
+
|
|
3
|
+
The workflow ``skop.ops.normalize`` was written for. A 2-D segmenter handed a
|
|
4
|
+
volume is looped over its planes, and every one of these segmenters normalizes
|
|
5
|
+
whatever array it is given -- so each plane gets stretched to its own range,
|
|
6
|
+
and a top or bottom plane holding nothing but out-of-focus blur is stretched
|
|
7
|
+
until that blur fills the range and reads as nuclei. The segmenter then finds
|
|
8
|
+
objects there that are not there.
|
|
9
|
+
|
|
10
|
+
Three ops in a row fix it, and the order is the whole point:
|
|
11
|
+
|
|
12
|
+
1. normalize over the **whole volume**, so an empty plane stays dim relative
|
|
13
|
+
to the planes with signal in them.
|
|
14
|
+
2. the segmenter, **plane by plane and with its own normalization off**, so
|
|
15
|
+
nothing puts the stretch back.
|
|
16
|
+
3. connect, which links objects that overlap between adjacent planes, so one
|
|
17
|
+
cell stops being a stack of unrelated labels.
|
|
18
|
+
|
|
19
|
+
Step 2 is why ``normalize`` is bound rather than offered. Leaving a checkbox
|
|
20
|
+
there would let someone switch back on precisely the behaviour this exists to
|
|
21
|
+
avoid, and get a result that looks like a failure of the idea rather than of
|
|
22
|
+
the setting. Running a segmenter with its own normalization on is still a
|
|
23
|
+
perfectly good thing to do -- from the Ops panel, where that is what you asked
|
|
24
|
+
for.
|
|
25
|
+
|
|
26
|
+
**All three stages are choosers, including the two with one choice each.**
|
|
27
|
+
There is only one normalizer and one connector today and there will be more,
|
|
28
|
+
and a stage that starts as a chooser gains its second option by having a name
|
|
29
|
+
added to a list rather than by being rewritten. It also keeps the shape
|
|
30
|
+
uniform: every stage's settings come from the chosen op's own signature, so
|
|
31
|
+
nothing here restates a parameter that lives somewhere else and can drift from
|
|
32
|
+
it.
|
|
33
|
+
|
|
34
|
+
**Three dimensions, and the dimensions are held fixed.** The connector needs
|
|
35
|
+
an axis to walk along, so there is no 2-D version of this. The workflow
|
|
36
|
+
declares ``Axes("z", "y", "x")`` on its own input, which is what lets a front
|
|
37
|
+
end ask which of *your* axes is z -- but it does not pass that question down.
|
|
38
|
+
Each stage's axis handling is pinned here, because getting it wrong is the bug
|
|
39
|
+
the workflow is for.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from typing import Annotated, NamedTuple
|
|
46
|
+
|
|
47
|
+
import numpy as np
|
|
48
|
+
|
|
49
|
+
from opspec.op import Axes, Choices, ParamsFor, op
|
|
50
|
+
from opspec.runtime import progress, run
|
|
51
|
+
from opspec.types import ImageOf, LabelsOf
|
|
52
|
+
from skop.ops.labels import connect
|
|
53
|
+
from skop.ops.normalize import percentile
|
|
54
|
+
from skop.ops.segment.cellpose3 import cellpose3
|
|
55
|
+
from skop.ops.segment.cellpose4 import cellpose4
|
|
56
|
+
from skop.ops.segment.stardist2d import stardist2d_fluo
|
|
57
|
+
|
|
58
|
+
#: What every stage is handed, and what the workflow refuses to make adjustable.
|
|
59
|
+
_ZYX = list("zyx")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Connected(NamedTuple):
|
|
63
|
+
"""What each stage produced, in the order the stages ran.
|
|
64
|
+
|
|
65
|
+
Stage order rather than importance: a front end adds these to a viewer in
|
|
66
|
+
declaration order, so the pipeline reads down the layer list, and the last
|
|
67
|
+
stage -- the answer -- ends up on top where a new layer belongs.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
#: The normalized volume the segmenter actually saw. Returned because it
|
|
71
|
+
#: is the evidence: if the top plane is still dim here, the normalization
|
|
72
|
+
#: did its job, and a spurious object there is the segmenter's doing.
|
|
73
|
+
normalized: ImageOf[np.ndarray]
|
|
74
|
+
#: One label per object, consistent through the stack.
|
|
75
|
+
labels: LabelsOf[np.ndarray]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@op()
|
|
79
|
+
def connect_2d_in_3d(
|
|
80
|
+
image: Annotated[ImageOf[np.ndarray], Axes("z", "y", "x")],
|
|
81
|
+
normalizer: Annotated[Callable, Choices(percentile=percentile)] = percentile,
|
|
82
|
+
normalizer_args: Annotated[
|
|
83
|
+
dict | None, ParamsFor("normalizer", binds="image")
|
|
84
|
+
] = None,
|
|
85
|
+
segmenter: Annotated[
|
|
86
|
+
Callable,
|
|
87
|
+
Choices(stardist=stardist2d_fluo, cellpose4=cellpose4, cellpose3=cellpose3),
|
|
88
|
+
] = stardist2d_fluo,
|
|
89
|
+
segmenter_args: Annotated[
|
|
90
|
+
dict | None, ParamsFor("segmenter", binds=("image", "normalize"))
|
|
91
|
+
] = None,
|
|
92
|
+
connector: Annotated[Callable, Choices(connect=connect)] = connect,
|
|
93
|
+
connector_args: Annotated[
|
|
94
|
+
dict | None, ParamsFor("connector", binds="labels")
|
|
95
|
+
] = None,
|
|
96
|
+
) -> Connected:
|
|
97
|
+
"""Segment a volume plane by plane and join the planes into objects.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
image: The volume, as z, y, x. Normalized once, whole, before any
|
|
101
|
+
plane is segmented.
|
|
102
|
+
normalizer: Which op scales the volume. Whichever it is, it is run on
|
|
103
|
+
the whole volume rather than plane by plane -- that is the step
|
|
104
|
+
this workflow exists to get right.
|
|
105
|
+
normalizer_args: Settings for the chosen normalizer, minus the volume.
|
|
106
|
+
segmenter: Which 2-D segmenter to run on each plane. Each brings its
|
|
107
|
+
own environment, and picking one for the first time builds it.
|
|
108
|
+
segmenter_args: Settings for the chosen segmenter, minus the plane and
|
|
109
|
+
its normalization, both of which this workflow supplies.
|
|
110
|
+
connector: Which op links objects between adjacent planes.
|
|
111
|
+
connector_args: Settings for the chosen connector, minus the labels.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
normalized: The volume as the segmenter saw it.
|
|
115
|
+
labels: The connected label volume, numbered from 1.
|
|
116
|
+
|
|
117
|
+
Note: the three stages live in three environments, so a run crosses shared
|
|
118
|
+
memory three times. That is the cost of composing on the host, and it is
|
|
119
|
+
paid per call rather than per plane -- skop loops the segmenter over z
|
|
120
|
+
inside its own worker.
|
|
121
|
+
"""
|
|
122
|
+
progress("Normalizing the whole volume")
|
|
123
|
+
# Naming the axes hands a variadic op the volume entire rather than a
|
|
124
|
+
# plane at a time. This is the step the whole workflow exists for.
|
|
125
|
+
normalized = run(
|
|
126
|
+
normalizer, image=image, axes={"image": _ZYX}, **(normalizer_args or {})
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
progress("Segmenting plane by plane")
|
|
130
|
+
# The segmenter declares two axes and is given three, so skop loops it over
|
|
131
|
+
# z. normalize=False is what keeps it from undoing the step above.
|
|
132
|
+
labels = run(
|
|
133
|
+
segmenter,
|
|
134
|
+
image=normalized,
|
|
135
|
+
normalize=False,
|
|
136
|
+
axes={"image": _ZYX},
|
|
137
|
+
**(segmenter_args or {}),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
progress("Connecting objects between planes")
|
|
141
|
+
joined = run(
|
|
142
|
+
connector, labels=labels, axes={"labels": _ZYX}, **(connector_args or {})
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return Connected(normalized, joined)
|