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
skop/ops/smooth.py ADDED
@@ -0,0 +1,452 @@
1
+ """Smoothing and denoising, via scikit-image.
2
+
3
+ Two families live here, and the difference between them is what they do to an
4
+ edge. A linear filter -- :func:`gaussian`, :func:`mean` -- averages a
5
+ neighborhood without asking what is in it, so it blurs the boundaries along
6
+ with the noise. The rest are edge-preserving: they each decide, per pixel,
7
+ which neighbors are worth averaging with. :func:`median` throws out the
8
+ outliers, :func:`kuwahara` averages only the most uniform corner of its
9
+ window, :func:`bilateral` weights by intensity as well as distance, and
10
+ :func:`nl_means` looks for similar *patches* anywhere nearby rather than
11
+ similar pixels.
12
+
13
+ Every op takes an image and returns one of the same shape, as float32 --
14
+ these are filters, not segmenters, and their output is meant to be fed to
15
+ something else. A trailing RGB(A) axis is filtered channel by channel rather
16
+ than across, since a neighborhood is a spatial notion.
17
+
18
+ The denoisers -- :func:`bilateral`, :func:`tv_chambolle`, :func:`wavelet`,
19
+ :func:`nl_means` -- each carry a strength parameter measured in intensity
20
+ units, which would otherwise mean something different for a uint8 image than
21
+ for a uint16 one. They run on a copy scaled into [0, 1] and scale the result
22
+ back, so their parameters are fractions of the image's own range and a
23
+ setting that worked on one image transfers to the next.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import itertools
29
+ from typing import Annotated
30
+
31
+ import numpy as np
32
+
33
+ from opspec.op import Axes, Overlap, PeakMemory, op
34
+ from opspec.types import ImageOf
35
+
36
+ from ._util import Footprint, channel_axis, footprint, per_channel
37
+
38
+ #: An op happy with any number of axes, RGB(A) handled channel by channel.
39
+ _Image = Annotated[ImageOf[np.ndarray], Axes(variadic=True)]
40
+
41
+ #: An op that only knows about planes, with or without colour.
42
+ _Plane = Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")]
43
+
44
+
45
+ def _unit(image: np.ndarray) -> tuple[np.ndarray, float, float]:
46
+ """An image scaled into [0, 1], with what it takes to put it back."""
47
+ x = np.asarray(image, dtype=np.float32)
48
+ low = float(x.min())
49
+ span = float(x.max()) - low
50
+ if span <= 0:
51
+ # A blank image: any scaling is the identity, and 0 would divide.
52
+ return np.zeros_like(x), low, 1.0
53
+ return (x - low) / span, low, span
54
+
55
+
56
+ @op(
57
+ env="skimage",
58
+ # A tile reaches as far as the kernel does: scikit-image cuts it off at
59
+ # 4 sigma. Two float32 copies are live at once, the input as float32 and
60
+ # the result, so peak memory is 2x the tile as float32.
61
+ tile="image",
62
+ overlap=Overlap(param="sigma", scale=4),
63
+ peak_memory=PeakMemory(scale=2, dtype="float32"),
64
+ merge="crop",
65
+ )
66
+ def gaussian(image: _Image, sigma: float = 1.0) -> ImageOf[np.ndarray]:
67
+ """Blur an image with a Gaussian kernel.
68
+
69
+ The linear smoother, and the one everything else is compared against: it
70
+ is the only kernel that neither invents structure at coarser scales nor
71
+ rings, which is what makes it the right thing to put in front of a scale
72
+ space, a gradient or a threshold. It is also indiscriminate -- an edge is
73
+ a high frequency like any other, so it goes too.
74
+
75
+ Args:
76
+ image: Image to blur. Any number of axes; a trailing RGB(A) axis is
77
+ blurred channel by channel.
78
+ sigma: Standard deviation of the kernel, in pixels, along every axis.
79
+ An anisotropic volume wants this small enough for its thinnest
80
+ axis, or a smoothing pass of its own per axis.
81
+
82
+ Returns:
83
+ The blurred image, as float32.
84
+ """
85
+ from skimage import filters
86
+
87
+ smoothed = filters.gaussian(
88
+ np.asarray(image, dtype=np.float32),
89
+ sigma=sigma,
90
+ preserve_range=True,
91
+ channel_axis=channel_axis(image),
92
+ )
93
+ return smoothed.astype(np.float32)
94
+
95
+
96
+ @op(env="skimage")
97
+ def median(
98
+ image: _Image,
99
+ radius: int = 1,
100
+ shape: Footprint = Footprint.ball,
101
+ ) -> ImageOf[np.ndarray]:
102
+ """Replace each pixel with the median of its neighborhood.
103
+
104
+ The classic answer to salt-and-pepper noise, and the reason to prefer it
105
+ over a mean: a median is decided by the middle of its neighborhood rather
106
+ than pulled by the extremes, so a hot pixel is discarded outright instead
107
+ of being smeared across everything near it. Edges survive because a
108
+ neighborhood straddling one still has a majority on one side.
109
+
110
+ Cost grows with the neighborhood, so large radii are slow.
111
+
112
+ Args:
113
+ image: Image to filter. Any number of axes; a trailing RGB(A) axis is
114
+ filtered channel by channel.
115
+ radius: Radius of the neighborhood, in pixels.
116
+ shape: Shape of that neighborhood.
117
+
118
+ Returns:
119
+ The filtered image, as float32.
120
+ """
121
+ from skimage import filters
122
+
123
+ fp = footprint(
124
+ image.ndim - (0 if channel_axis(image) is None else 1), radius, shape
125
+ )
126
+ return per_channel(
127
+ np.asarray(image, dtype=np.float32),
128
+ lambda plane: filters.median(plane, footprint=fp),
129
+ ).astype(np.float32)
130
+
131
+
132
+ @op(env="skimage")
133
+ def mean(image: _Image, radius: int = 1) -> ImageOf[np.ndarray]:
134
+ """Replace each pixel with the mean of a box around it.
135
+
136
+ The cheapest smoother there is -- the cost of a box mean does not depend
137
+ on the size of the box -- and the bluntest. Its kernel has hard edges, so
138
+ it rings where a Gaussian does not; reach for it when speed is the point,
139
+ and for :func:`gaussian` otherwise.
140
+
141
+ Args:
142
+ image: Image to filter. Any number of axes; a trailing RGB(A) axis is
143
+ filtered channel by channel.
144
+ radius: Half-width of the box, in pixels, along every axis. The box
145
+ is ``2 * radius + 1`` across.
146
+
147
+ Returns:
148
+ The filtered image, as float32.
149
+ """
150
+ from scipy.ndimage import uniform_filter
151
+
152
+ return per_channel(
153
+ np.asarray(image, dtype=np.float32),
154
+ lambda plane: uniform_filter(plane, size=2 * radius + 1, mode="reflect"),
155
+ ).astype(np.float32)
156
+
157
+
158
+ @op(env="skimage")
159
+ def kuwahara(image: _Image, radius: int = 2) -> ImageOf[np.ndarray]:
160
+ """Smooth an image while keeping its edges sharp, by Kuwahara's method.
161
+
162
+ Each pixel's window is split into overlapping corners -- four quadrants in
163
+ 2-D, eight octants in 3-D -- and the pixel takes the mean of whichever
164
+ corner has the lowest variance. On a pixel inside a region every corner
165
+ looks alike and the result is an ordinary local mean. On a pixel astride
166
+ an edge, the corner lying wholly on one side wins, so the average never
167
+ crosses the boundary and the edge stays where it was.
168
+
169
+ The trade is that edges are not merely preserved but sharpened, and
170
+ gentle gradients turn painterly -- flattened into broad patches with
171
+ abrupt steps between them, since a pixel's value comes from whichever
172
+ corner won rather than from its neighborhood as a whole. That is a help
173
+ before a threshold and a hindrance before a measurement, and it is why
174
+ this is also a favourite for stylizing photographs.
175
+
176
+ scikit-image has no Kuwahara filter, so this is implemented here, over
177
+ ``scipy.ndimage``.
178
+
179
+ Args:
180
+ image: Image to filter. Any number of axes; a trailing RGB(A) axis is
181
+ filtered channel by channel.
182
+ radius: Radius of the window, in pixels. Each corner is
183
+ ``radius + 1`` across, and the whole window ``2 * radius + 1``.
184
+
185
+ Returns:
186
+ The filtered image, as float32.
187
+ """
188
+ if radius < 0:
189
+ raise ValueError(f"radius must not be negative, got {radius}")
190
+ return per_channel(np.asarray(image, dtype=np.float32), _kuwahara(radius)).astype(
191
+ np.float32
192
+ )
193
+
194
+
195
+ def _kuwahara(radius: int):
196
+ """Kuwahara over one channel, for any number of axes."""
197
+ from scipy.ndimage import uniform_filter
198
+
199
+ def filter_one(x: np.ndarray) -> np.ndarray:
200
+ size = radius + 1
201
+ pad = size - 1
202
+ # Padded so every corner of every pixel's window is in bounds, and
203
+ # offset so the filter at index j covers [j - radius, j] -- the
204
+ # "lowest" corner. Every other corner is then that same array read at
205
+ # a shifted index, which is why only one pass is needed.
206
+ padded = np.pad(x.astype(np.float64), pad, mode="reflect")
207
+ origin = (size - 1) // 2
208
+ means = uniform_filter(padded, size=size, origin=origin, mode="nearest")
209
+ squares = uniform_filter(padded**2, size=size, origin=origin, mode="nearest")
210
+ variances = np.clip(squares - means**2, 0.0, None)
211
+
212
+ best_mean = best_var = None
213
+ for offsets in itertools.product((0, size - 1), repeat=x.ndim):
214
+ window = tuple(
215
+ slice(pad + off, pad + off + extent)
216
+ for off, extent in zip(offsets, x.shape)
217
+ )
218
+ corner_mean, corner_var = means[window], variances[window]
219
+ if best_var is None:
220
+ best_mean, best_var = corner_mean.copy(), corner_var.copy()
221
+ continue
222
+ better = corner_var < best_var
223
+ best_mean[better] = corner_mean[better]
224
+ best_var[better] = corner_var[better]
225
+ return best_mean
226
+
227
+ return filter_one
228
+
229
+
230
+ @op(env="skimage")
231
+ def bilateral(
232
+ image: _Plane,
233
+ sigma_color: float = 0.1,
234
+ sigma_spatial: float = 1.0,
235
+ ) -> ImageOf[np.ndarray]:
236
+ """Smooth an image, weighting neighbors by intensity as well as distance.
237
+
238
+ A Gaussian blur whose kernel is multiplied by a second Gaussian over the
239
+ *difference in value* between the two pixels, so a neighbor across an
240
+ edge contributes almost nothing however close it is. The result is a
241
+ smoother that respects boundaries without the blockiness
242
+ :func:`kuwahara` introduces.
243
+
244
+ Planes only, which is scikit-image's limit rather than the algorithm's.
245
+
246
+ Args:
247
+ image: Image to filter, as a plane with an optional RGB(A) axis.
248
+ sigma_color: How different in value a neighbor may be and still
249
+ count, as a fraction of the image's range. Large values converge
250
+ on a plain Gaussian blur.
251
+ sigma_spatial: Standard deviation of the spatial term, in pixels.
252
+
253
+ Returns:
254
+ The filtered image, as float32.
255
+ """
256
+ from skimage import restoration
257
+
258
+ unit, low, span = _unit(image)
259
+ smoothed = restoration.denoise_bilateral(
260
+ unit,
261
+ sigma_color=sigma_color,
262
+ sigma_spatial=sigma_spatial,
263
+ channel_axis=channel_axis(image),
264
+ )
265
+ return (smoothed * span + low).astype(np.float32)
266
+
267
+
268
+ @op(env="skimage")
269
+ def tv_chambolle(
270
+ image: _Image,
271
+ weight: float = 0.1,
272
+ max_num_iter: int = 200,
273
+ ) -> ImageOf[np.ndarray]:
274
+ """Denoise an image by total-variation minimization.
275
+
276
+ Finds the image closest to this one whose total variation -- the summed
277
+ magnitude of its gradient -- is smallest. Because a step edge costs the
278
+ same total variation whether it is sharp or spread out, there is no
279
+ incentive to blur it, and edges come through intact while noise, which is
280
+ expensive in gradient per unit of signal, does not. The characteristic
281
+ artifact is the flip side: flat cartoon-like patches, "staircasing".
282
+
283
+ Args:
284
+ image: Image to denoise. Any number of axes; a trailing RGB(A) axis
285
+ is denoised channel by channel.
286
+ weight: How hard to smooth, as a fraction of the image's range.
287
+ Larger means more denoising and more staircasing.
288
+ max_num_iter: Cap on the number of optimization steps.
289
+
290
+ Returns:
291
+ The denoised image, as float32.
292
+ """
293
+ from skimage import restoration
294
+
295
+ unit, low, span = _unit(image)
296
+ denoised = restoration.denoise_tv_chambolle(
297
+ unit,
298
+ weight=weight,
299
+ max_num_iter=max_num_iter,
300
+ channel_axis=channel_axis(image),
301
+ )
302
+ return (denoised * span + low).astype(np.float32)
303
+
304
+
305
+ @op(env="skimage")
306
+ def wavelet(
307
+ image: _Image,
308
+ method: Annotated[str, {"choices": ["BayesShrink", "VisuShrink"]}] = "BayesShrink",
309
+ mode: Annotated[str, {"choices": ["soft", "hard"]}] = "soft",
310
+ wavelet_name: Annotated[str, {"choices": ["db1", "db2", "haar", "sym2"]}] = "db1",
311
+ sigma: float | None = None,
312
+ ) -> ImageOf[np.ndarray]:
313
+ """Denoise an image by thresholding its wavelet coefficients.
314
+
315
+ Noise is spread evenly across a wavelet decomposition while structure
316
+ concentrates into a few large coefficients, so zeroing the small ones
317
+ removes much of the former and little of the latter. Fast, and good at
318
+ fine texture; it can leave faint ripples around sharp edges, which is
319
+ what the wavelet choice trades against.
320
+
321
+ Args:
322
+ image: Image to denoise. Any number of axes; a trailing RGB(A) axis
323
+ is denoised channel by channel.
324
+ method: How the threshold is chosen. ``BayesShrink`` adapts it per
325
+ subband and is usually the better of the two; ``VisuShrink``
326
+ applies one threshold everywhere, and errs toward oversmoothing.
327
+ mode: ``soft`` shrinks every coefficient toward zero, ``hard`` zeroes
328
+ those below the threshold and leaves the rest. Soft is smoother,
329
+ hard keeps more contrast.
330
+ wavelet_name: Which wavelet to decompose with. ``db1`` -- the Haar
331
+ wavelet -- is the usual default.
332
+ sigma: Noise standard deviation, as a fraction of the image's range.
333
+ None estimates it from the image.
334
+
335
+ Returns:
336
+ The denoised image, as float32.
337
+ """
338
+ from skimage import restoration
339
+
340
+ unit, low, span = _unit(image)
341
+ denoised = restoration.denoise_wavelet(
342
+ unit,
343
+ sigma=sigma,
344
+ wavelet=wavelet_name,
345
+ mode=mode,
346
+ method=method,
347
+ channel_axis=channel_axis(image),
348
+ )
349
+ return (denoised * span + low).astype(np.float32)
350
+
351
+
352
+ @op(env="skimage")
353
+ def nl_means(
354
+ image: _Image,
355
+ patch_size: int = 7,
356
+ patch_distance: int = 11,
357
+ h: float = 0.1,
358
+ sigma: float | None = None,
359
+ fast_mode: bool = True,
360
+ ) -> ImageOf[np.ndarray]:
361
+ """Denoise an image by averaging over similar patches nearby.
362
+
363
+ Every other filter here decides what to average by where a pixel is; this
364
+ one decides by what its surroundings look like. Each pixel is replaced by
365
+ a weighted mean of the pixels whose surrounding *patch* resembles its
366
+ own, anywhere within a search window. Repeated structure -- texture, a
367
+ row of similar objects, an edge running through the field -- therefore
368
+ reinforces itself, and this preserves fine detail better than anything
369
+ else here.
370
+
371
+ It is also by far the slowest, growing with both the patch and the search
372
+ window.
373
+
374
+ Args:
375
+ image: Image to denoise. Any number of axes; a trailing RGB(A) axis
376
+ is denoised channel by channel.
377
+ patch_size: Width of the patches being compared, in pixels.
378
+ patch_distance: How far from a pixel to search for similar patches,
379
+ in pixels. The dominant cost.
380
+ h: How much a patch may differ and still be averaged in, as a
381
+ fraction of the image's range. Larger means smoother.
382
+ sigma: Noise standard deviation, as a fraction of the image's range,
383
+ subtracted from the patch distances so that noise is not mistaken
384
+ for dissimilarity. None estimates it from the image.
385
+ fast_mode: Use the faster approximation, which weights patches
386
+ slightly differently. Rarely worth turning off.
387
+
388
+ Returns:
389
+ The denoised image, as float32.
390
+ """
391
+ from skimage import restoration
392
+
393
+ unit, low, span = _unit(image)
394
+ axis = channel_axis(image)
395
+ if sigma is None:
396
+ sigma = float(
397
+ restoration.estimate_sigma(unit, channel_axis=axis, average_sigmas=True)
398
+ )
399
+ denoised = restoration.denoise_nl_means(
400
+ unit,
401
+ patch_size=patch_size,
402
+ patch_distance=patch_distance,
403
+ h=h,
404
+ sigma=sigma,
405
+ fast_mode=fast_mode,
406
+ preserve_range=True,
407
+ channel_axis=axis,
408
+ )
409
+ return (denoised * span + low).astype(np.float32)
410
+
411
+
412
+ @op(env="skimage")
413
+ def butterworth(
414
+ image: _Image,
415
+ cutoff_frequency_ratio: float = 0.05,
416
+ order: float = 2.0,
417
+ high_pass: bool = False,
418
+ ) -> ImageOf[np.ndarray]:
419
+ """Smooth an image by attenuating its high frequencies.
420
+
421
+ The only filter here that works in the frequency domain: it multiplies
422
+ the image's spectrum by a Butterworth window, whose defining property is
423
+ that it is as flat as possible in the band it keeps. That flatness is why
424
+ it rings far less than an abrupt cutoff, and the order is the knob
425
+ between the two -- a low order rolls off gently, a high one approaches a
426
+ brick wall and the ringing that comes with it.
427
+
428
+ Inverted, the same filter is the standard way to flatten uneven
429
+ illumination, which is what ``high_pass`` is for.
430
+
431
+ Args:
432
+ image: Image to filter. Any number of axes; a trailing RGB(A) axis is
433
+ filtered channel by channel.
434
+ cutoff_frequency_ratio: Where the cutoff sits, as a fraction of the
435
+ sampling frequency, in [0, 0.5]. Smaller means more smoothing.
436
+ order: Steepness of the roll-off.
437
+ high_pass: Keep the high frequencies instead, subtracting the smooth
438
+ background rather than the detail.
439
+
440
+ Returns:
441
+ The filtered image, as float32.
442
+ """
443
+ from skimage import filters
444
+
445
+ filtered = filters.butterworth(
446
+ np.asarray(image, dtype=np.float32),
447
+ cutoff_frequency_ratio=cutoff_frequency_ratio,
448
+ high_pass=high_pass,
449
+ order=order,
450
+ channel_axis=channel_axis(image),
451
+ )
452
+ return filtered.astype(np.float32)