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,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
+ ]