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
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)
|