waveorder 0.2.2rc0__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.
Potentially problematic release.
This version of waveorder might be problematic. Click here for more details.
- waveorder/__init__.py +0 -0
- waveorder/_version.py +16 -0
- waveorder/background_estimator.py +319 -0
- waveorder/correction.py +107 -0
- waveorder/focus.py +198 -0
- waveorder/models/inplane_oriented_thick_pol3d.py +159 -0
- waveorder/models/isotropic_fluorescent_thick_3d.py +192 -0
- waveorder/models/isotropic_thin_3d.py +281 -0
- waveorder/models/phase_thick_3d.py +219 -0
- waveorder/optics.py +1196 -0
- waveorder/stokes.py +458 -0
- waveorder/util.py +2241 -0
- waveorder/visual.py +1931 -0
- waveorder/waveorder_reconstructor.py +4031 -0
- waveorder/waveorder_simulator.py +1217 -0
- waveorder-0.2.2rc0.dist-info/LICENSE +28 -0
- waveorder-0.2.2rc0.dist-info/METADATA +147 -0
- waveorder-0.2.2rc0.dist-info/RECORD +20 -0
- waveorder-0.2.2rc0.dist-info/WHEEL +5 -0
- waveorder-0.2.2rc0.dist-info/top_level.txt +1 -0
waveorder/__init__.py
ADDED
|
File without changes
|
waveorder/_version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
+
else:
|
|
8
|
+
VERSION_TUPLE = object
|
|
9
|
+
|
|
10
|
+
version: str
|
|
11
|
+
__version__: str
|
|
12
|
+
__version_tuple__: VERSION_TUPLE
|
|
13
|
+
version_tuple: VERSION_TUPLE
|
|
14
|
+
|
|
15
|
+
__version__ = version = '0.2.2rc0'
|
|
16
|
+
__version_tuple__ = version_tuple = (0, 2, 2)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Estimate flat field images"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import itertools
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
This script is adopted from
|
|
10
|
+
|
|
11
|
+
https://github.com/mehta-lab/reconstruct-order
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BackgroundEstimator2D:
|
|
18
|
+
"""Estimates flat field image"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, block_size=32):
|
|
21
|
+
"""
|
|
22
|
+
Background images are estimated once per channel for 2D data
|
|
23
|
+
:param int block_size: Size of blocks image will be divided into
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
if block_size is None:
|
|
27
|
+
block_size = 32
|
|
28
|
+
self.block_size = block_size
|
|
29
|
+
|
|
30
|
+
def sample_block_medians(self, im):
|
|
31
|
+
"""Subdivide a 2D image in smaller blocks of size block_size and
|
|
32
|
+
compute the median intensity value for each block. Any incomplete
|
|
33
|
+
blocks (remainders of modulo operation) will be ignored.
|
|
34
|
+
|
|
35
|
+
:param np.array im: 2D image
|
|
36
|
+
:return np.array(float) sample_coords: Image coordinates for block
|
|
37
|
+
centers
|
|
38
|
+
:return np.array(float) sample_values: Median intensity values for
|
|
39
|
+
blocks
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
im_shape = im.shape
|
|
43
|
+
assert (
|
|
44
|
+
self.block_size < im_shape[0]
|
|
45
|
+
), "Block size larger than image height"
|
|
46
|
+
assert (
|
|
47
|
+
self.block_size < im_shape[1]
|
|
48
|
+
), "Block size larger than image width"
|
|
49
|
+
|
|
50
|
+
nbr_blocks_x = im_shape[0] // self.block_size
|
|
51
|
+
nbr_blocks_y = im_shape[1] // self.block_size
|
|
52
|
+
sample_coords = np.zeros(
|
|
53
|
+
(nbr_blocks_x * nbr_blocks_y, 2), dtype=np.float64
|
|
54
|
+
)
|
|
55
|
+
sample_values = np.zeros(
|
|
56
|
+
(nbr_blocks_x * nbr_blocks_y,), dtype=np.float64
|
|
57
|
+
)
|
|
58
|
+
for x in range(nbr_blocks_x):
|
|
59
|
+
for y in range(nbr_blocks_y):
|
|
60
|
+
idx = y * nbr_blocks_x + x
|
|
61
|
+
sample_coords[idx, :] = [
|
|
62
|
+
x * self.block_size + (self.block_size - 1) / 2,
|
|
63
|
+
y * self.block_size + (self.block_size - 1) / 2,
|
|
64
|
+
]
|
|
65
|
+
sample_values[idx] = np.median(
|
|
66
|
+
im[
|
|
67
|
+
x * self.block_size : (x + 1) * self.block_size,
|
|
68
|
+
y * self.block_size : (y + 1) * self.block_size,
|
|
69
|
+
]
|
|
70
|
+
)
|
|
71
|
+
return sample_coords, sample_values
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def fit_polynomial_surface_2D(
|
|
75
|
+
sample_coords, sample_values, im_shape, order=2, normalize=True
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Given coordinates and corresponding values, this function will fit a
|
|
79
|
+
2D polynomial of given order, then create a surface of given shape.
|
|
80
|
+
|
|
81
|
+
:param np.array sample_coords: 2D sample coords (nbr of points, 2)
|
|
82
|
+
:param np.array sample_values: Corresponding intensity values (nbr points,)
|
|
83
|
+
:param tuple im_shape: Shape of desired output surface (height, width)
|
|
84
|
+
:param int order: Order of polynomial (default 2)
|
|
85
|
+
:param bool normalize: Normalize surface by dividing by its mean
|
|
86
|
+
for background correction (default True)
|
|
87
|
+
|
|
88
|
+
:return np.array poly_surface: 2D surface of shape im_shape
|
|
89
|
+
"""
|
|
90
|
+
assert (order + 1) * (order + 2) / 2 <= len(
|
|
91
|
+
sample_values
|
|
92
|
+
), "Can't fit a higher degree polynomial than there are sampled values"
|
|
93
|
+
# Number of coefficients is determined by (order + 1)*(order + 2)/2
|
|
94
|
+
orders = np.arange(order + 1)
|
|
95
|
+
variable_matrix = np.zeros(
|
|
96
|
+
(sample_coords.shape[0], int((order + 1) * (order + 2) / 2))
|
|
97
|
+
)
|
|
98
|
+
order_pairs = list(itertools.product(orders, orders))
|
|
99
|
+
# sum of orders of x,y <= order of the polynomial
|
|
100
|
+
variable_iterator = itertools.filterfalse(
|
|
101
|
+
lambda x: sum(x) > order, order_pairs
|
|
102
|
+
)
|
|
103
|
+
for idx, (m, n) in enumerate(variable_iterator):
|
|
104
|
+
variable_matrix[:, idx] = (
|
|
105
|
+
sample_coords[:, 0] ** n * sample_coords[:, 1] ** m
|
|
106
|
+
)
|
|
107
|
+
# Least squares fit of the points to the polynomial
|
|
108
|
+
coeffs, _, _, _ = np.linalg.lstsq(
|
|
109
|
+
variable_matrix, sample_values, rcond=-1
|
|
110
|
+
)
|
|
111
|
+
# Create a grid of image (x, y) coordinates
|
|
112
|
+
x_mesh, y_mesh = np.meshgrid(
|
|
113
|
+
np.linspace(0, im_shape[1] - 1, im_shape[1]),
|
|
114
|
+
np.linspace(0, im_shape[0] - 1, im_shape[0]),
|
|
115
|
+
)
|
|
116
|
+
# Reconstruct the surface from the coefficients
|
|
117
|
+
poly_surface = np.zeros(im_shape, np.float64)
|
|
118
|
+
order_pairs = list(itertools.product(orders, orders))
|
|
119
|
+
# sum of orders of x,y <= order of the polynomial
|
|
120
|
+
variable_iterator = itertools.filterfalse(
|
|
121
|
+
lambda x: sum(x) > order, order_pairs
|
|
122
|
+
)
|
|
123
|
+
for coeff, (m, n) in zip(coeffs, variable_iterator):
|
|
124
|
+
poly_surface += coeff * x_mesh**m * y_mesh**n
|
|
125
|
+
|
|
126
|
+
if normalize:
|
|
127
|
+
poly_surface /= np.mean(poly_surface)
|
|
128
|
+
return poly_surface
|
|
129
|
+
|
|
130
|
+
def get_background(self, im, order=2, normalize=True):
|
|
131
|
+
"""
|
|
132
|
+
Combine sampling and polynomial surface fit for background estimation.
|
|
133
|
+
To background correct an image, divide it by background.
|
|
134
|
+
|
|
135
|
+
:param np.array im: 2D image
|
|
136
|
+
:param int order: Order of polynomial (default 2)
|
|
137
|
+
:param bool normalize: Normalize surface by dividing by its mean
|
|
138
|
+
for background correction (default True)
|
|
139
|
+
|
|
140
|
+
:return np.array background: Background image
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
coords, values = self.sample_block_medians(im=im)
|
|
144
|
+
background = self.fit_polynomial_surface_2D(
|
|
145
|
+
sample_coords=coords,
|
|
146
|
+
sample_values=values,
|
|
147
|
+
im_shape=im.shape,
|
|
148
|
+
order=order,
|
|
149
|
+
normalize=normalize,
|
|
150
|
+
)
|
|
151
|
+
# Backgrounds can't contain zeros or negative values
|
|
152
|
+
# if background.min() <= 0:
|
|
153
|
+
# raise ValueError(
|
|
154
|
+
# "The generated background was not strictly positive {}.".format(
|
|
155
|
+
# background.min()),
|
|
156
|
+
# )
|
|
157
|
+
return background
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class BackgroundEstimator2D_GPU:
|
|
161
|
+
"""Estimates flat field image"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, block_size=32, gpu_id=0):
|
|
164
|
+
"""
|
|
165
|
+
Background images are estimated once per channel for 2D data
|
|
166
|
+
:param int block_size: Size of blocks image will be divided into
|
|
167
|
+
"""
|
|
168
|
+
globals()["cp"] = __import__("cupy")
|
|
169
|
+
self.gpu_id = gpu_id
|
|
170
|
+
cp.cuda.Device(self.gpu_id).use()
|
|
171
|
+
|
|
172
|
+
if block_size is None:
|
|
173
|
+
block_size = 32
|
|
174
|
+
self.block_size = block_size
|
|
175
|
+
|
|
176
|
+
def sample_block_medians(self, im):
|
|
177
|
+
"""Subdivide a 2D image in smaller blocks of size block_size and
|
|
178
|
+
compute the median intensity value for each block. Any incomplete
|
|
179
|
+
blocks (remainders of modulo operation) will be ignored.
|
|
180
|
+
|
|
181
|
+
:param np.array im: 2D image
|
|
182
|
+
:return np.array(float) sample_coords: Image coordinates for block
|
|
183
|
+
centers
|
|
184
|
+
:return np.array(float) sample_values: Median intensity values for
|
|
185
|
+
blocks
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
im_shape = im.shape
|
|
189
|
+
assert (
|
|
190
|
+
self.block_size < im_shape[0]
|
|
191
|
+
), "Block size larger than image height"
|
|
192
|
+
assert (
|
|
193
|
+
self.block_size < im_shape[1]
|
|
194
|
+
), "Block size larger than image width"
|
|
195
|
+
|
|
196
|
+
nbr_blocks_x = im_shape[0] // self.block_size
|
|
197
|
+
nbr_blocks_y = im_shape[1] // self.block_size
|
|
198
|
+
sample_coords = np.zeros(
|
|
199
|
+
(nbr_blocks_x * nbr_blocks_y, 2), dtype=cp.float64
|
|
200
|
+
)
|
|
201
|
+
sample_values = np.zeros(
|
|
202
|
+
(nbr_blocks_x * nbr_blocks_y,), dtype=cp.float64
|
|
203
|
+
)
|
|
204
|
+
for x in range(nbr_blocks_x):
|
|
205
|
+
for y in range(nbr_blocks_y):
|
|
206
|
+
idx = y * nbr_blocks_x + x
|
|
207
|
+
sample_coords[idx, :] = [
|
|
208
|
+
x * self.block_size + (self.block_size - 1) / 2,
|
|
209
|
+
y * self.block_size + (self.block_size - 1) / 2,
|
|
210
|
+
]
|
|
211
|
+
sample_values[idx] = np.median(
|
|
212
|
+
im[
|
|
213
|
+
x * self.block_size : (x + 1) * self.block_size,
|
|
214
|
+
y * self.block_size : (y + 1) * self.block_size,
|
|
215
|
+
]
|
|
216
|
+
)
|
|
217
|
+
return sample_coords, sample_values
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def median_cp(x):
|
|
221
|
+
x = x.flatten()
|
|
222
|
+
n = x.shape[0]
|
|
223
|
+
s = cp.sort(x)
|
|
224
|
+
m_odd = cp.take(s, n // 2)
|
|
225
|
+
if n % 2 == 1:
|
|
226
|
+
return m_odd
|
|
227
|
+
else:
|
|
228
|
+
m_even = cp.take(s, n // 2 - 1)
|
|
229
|
+
return (m_odd + m_even) / 2
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def fit_polynomial_surface_2D(
|
|
233
|
+
sample_coords, sample_values, im_shape, order=2, normalize=True
|
|
234
|
+
):
|
|
235
|
+
"""
|
|
236
|
+
Given coordinates and corresponding values, this function will fit a
|
|
237
|
+
2D polynomial of given order, then create a surface of given shape.
|
|
238
|
+
|
|
239
|
+
:param np.array sample_coords: 2D sample coords (nbr of points, 2)
|
|
240
|
+
:param np.array sample_values: Corresponding intensity values (nbr points,)
|
|
241
|
+
:param tuple im_shape: Shape of desired output surface (height, width)
|
|
242
|
+
:param int order: Order of polynomial (default 2)
|
|
243
|
+
:param bool normalize: Normalize surface by dividing by its mean
|
|
244
|
+
for background correction (default True)
|
|
245
|
+
|
|
246
|
+
:return np.array poly_surface: 2D surface of shape im_shape
|
|
247
|
+
"""
|
|
248
|
+
assert (order + 1) * (order + 2) / 2 <= len(
|
|
249
|
+
sample_values
|
|
250
|
+
), "Can't fit a higher degree polynomial than there are sampled values"
|
|
251
|
+
# Number of coefficients is determined by (order + 1)*(order + 2)/2
|
|
252
|
+
orders = np.arange(order + 1)
|
|
253
|
+
variable_matrix = np.zeros(
|
|
254
|
+
(sample_coords.shape[0], int((order + 1) * (order + 2) / 2))
|
|
255
|
+
)
|
|
256
|
+
order_pairs = list(itertools.product(orders, orders))
|
|
257
|
+
# sum of orders of x,y <= order of the polynomial
|
|
258
|
+
variable_iterator = itertools.filterfalse(
|
|
259
|
+
lambda x: sum(x) > order, order_pairs
|
|
260
|
+
)
|
|
261
|
+
for idx, (m, n) in enumerate(variable_iterator):
|
|
262
|
+
variable_matrix[:, idx] = (
|
|
263
|
+
sample_coords[:, 0] ** n * sample_coords[:, 1] ** m
|
|
264
|
+
)
|
|
265
|
+
# Least squares fit of the points to the polynomial
|
|
266
|
+
coeffs, _, _, _ = np.linalg.lstsq(
|
|
267
|
+
variable_matrix, sample_values, rcond=-1
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Create a grid of image (x, y) coordinates
|
|
271
|
+
x_mesh, y_mesh = cp.meshgrid(
|
|
272
|
+
cp.linspace(0, im_shape[1] - 1, im_shape[1]),
|
|
273
|
+
cp.linspace(0, im_shape[0] - 1, im_shape[0]),
|
|
274
|
+
)
|
|
275
|
+
# Reconstruct the surface from the coefficients
|
|
276
|
+
poly_surface = cp.zeros(im_shape, cp.float)
|
|
277
|
+
coeffs = cp.array(coeffs)
|
|
278
|
+
order_pairs = list(itertools.product(orders, orders))
|
|
279
|
+
# sum of orders of x,y <= order of the polynomial
|
|
280
|
+
variable_iterator = itertools.filterfalse(
|
|
281
|
+
lambda x: sum(x) > order, order_pairs
|
|
282
|
+
)
|
|
283
|
+
for coeff, (m, n) in zip(coeffs, variable_iterator):
|
|
284
|
+
poly_surface += coeff * x_mesh**m * y_mesh**n
|
|
285
|
+
|
|
286
|
+
if normalize:
|
|
287
|
+
poly_surface /= cp.mean(poly_surface)
|
|
288
|
+
return poly_surface
|
|
289
|
+
|
|
290
|
+
def get_background(self, im, order=2, normalize=True):
|
|
291
|
+
"""
|
|
292
|
+
Combine sampling and polynomial surface fit for background estimation.
|
|
293
|
+
To background correct an image, divide it by background.
|
|
294
|
+
|
|
295
|
+
:param np.array im: 2D image
|
|
296
|
+
:param int order: Order of polynomial (default 2)
|
|
297
|
+
:param bool normalize: Normalize surface by dividing by its mean
|
|
298
|
+
for background correction (default True)
|
|
299
|
+
|
|
300
|
+
:return np.array background: Background image
|
|
301
|
+
"""
|
|
302
|
+
cp.cuda.Device(self.gpu_id).use()
|
|
303
|
+
im = cp.asnumpy(im)
|
|
304
|
+
|
|
305
|
+
coords, values = self.sample_block_medians(im=im)
|
|
306
|
+
background = self.fit_polynomial_surface_2D(
|
|
307
|
+
sample_coords=coords,
|
|
308
|
+
sample_values=values,
|
|
309
|
+
im_shape=im.shape,
|
|
310
|
+
order=order,
|
|
311
|
+
normalize=normalize,
|
|
312
|
+
)
|
|
313
|
+
# Backgrounds can't contain zeros or negative values
|
|
314
|
+
# if background.min() <= 0:
|
|
315
|
+
# raise ValueError(
|
|
316
|
+
# "The generated background was not strictly positive {}.".format(
|
|
317
|
+
# background.min()),
|
|
318
|
+
# )
|
|
319
|
+
return background
|
waveorder/correction.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Background correction methods"""
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn.functional as F
|
|
5
|
+
from torch import Tensor, Size
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _sample_block_medians(image: Tensor, block_size) -> Tensor:
|
|
9
|
+
"""
|
|
10
|
+
Sample densely tiled square blocks from a 2D image and return their medians.
|
|
11
|
+
Incomplete blocks (overhangs) will be ignored.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
image : Tensor
|
|
16
|
+
2D image
|
|
17
|
+
block_size : int, optional
|
|
18
|
+
Width and height of the blocks
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
Tensor
|
|
23
|
+
Median intensity values for each block, flattened
|
|
24
|
+
"""
|
|
25
|
+
if not image.dtype.is_floating_point:
|
|
26
|
+
image.to(torch.float)
|
|
27
|
+
blocks = F.unfold(image[None, None], block_size, stride=block_size)[0]
|
|
28
|
+
return blocks.median(0)[0]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _grid_coordinates(image: Tensor, block_size: int) -> Tensor:
|
|
32
|
+
"""Build image coordinates from the center points of square blocks"""
|
|
33
|
+
coords = torch.meshgrid(
|
|
34
|
+
[
|
|
35
|
+
torch.arange(
|
|
36
|
+
0 + block_size / 2,
|
|
37
|
+
boundary - block_size / 2 + 1,
|
|
38
|
+
block_size,
|
|
39
|
+
device=image.device,
|
|
40
|
+
)
|
|
41
|
+
for boundary in image.shape
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
return torch.stack(coords, dim=-1).reshape(-1, 2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fit_2d_polynomial_surface(
|
|
48
|
+
coords: Tensor, values: Tensor, order: int, surface_shape: Size
|
|
49
|
+
) -> Tensor:
|
|
50
|
+
"""Fit a 2D polynomial to a set of coordinates and their values,
|
|
51
|
+
and return the surface evaluated at every point."""
|
|
52
|
+
n_coeffs = int((order + 1) * (order + 2) / 2)
|
|
53
|
+
if n_coeffs >= len(values):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Cannot fit a {order} degree 2D polynomial "
|
|
56
|
+
f"with {len(values)} sampled values"
|
|
57
|
+
)
|
|
58
|
+
orders = torch.arange(order + 1, device=coords.device)
|
|
59
|
+
order_pairs = torch.stack(torch.meshgrid(orders, orders), -1)
|
|
60
|
+
order_pairs = order_pairs[order_pairs.sum(-1) <= order].reshape(-1, 2)
|
|
61
|
+
terms = torch.stack(
|
|
62
|
+
[coords[:, 0] ** i * coords[:, 1] ** j for i, j in order_pairs], -1
|
|
63
|
+
)
|
|
64
|
+
# use "gels" driver for precision and GPU consistency
|
|
65
|
+
coeffs = torch.linalg.lstsq(terms, values, driver="gels").solution
|
|
66
|
+
dense_coords = torch.meshgrid(
|
|
67
|
+
[
|
|
68
|
+
torch.arange(s, dtype=values.dtype, device=values.device)
|
|
69
|
+
for s in surface_shape
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
dense_terms = torch.stack(
|
|
73
|
+
[dense_coords[0] ** i * dense_coords[1] ** j for i, j in order_pairs],
|
|
74
|
+
-1,
|
|
75
|
+
)
|
|
76
|
+
return torch.matmul(dense_terms, coeffs)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def estimate_background(image: Tensor, order: int = 2, block_size: int = 32):
|
|
80
|
+
"""
|
|
81
|
+
Combine sampling and polynomial surface fit for background estimation.
|
|
82
|
+
To background correct an image, divide it by the background.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
image : Tensor
|
|
87
|
+
2D image
|
|
88
|
+
order : int, optional
|
|
89
|
+
Order of polynomial, by default 2
|
|
90
|
+
block_size : int, optional
|
|
91
|
+
Width and height of the blocks, by default 32
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
Tensor
|
|
96
|
+
Background image
|
|
97
|
+
"""
|
|
98
|
+
if image.ndim != 2:
|
|
99
|
+
raise ValueError(f"Image must be 2D, got shape {image.shape}")
|
|
100
|
+
height, width = image.shape
|
|
101
|
+
if block_size > width:
|
|
102
|
+
raise ValueError("Block size larger than image height")
|
|
103
|
+
if block_size > height:
|
|
104
|
+
raise ValueError("Block size larger than image width")
|
|
105
|
+
medians = _sample_block_medians(image, block_size)
|
|
106
|
+
coords = _grid_coordinates(image, block_size)
|
|
107
|
+
return _fit_2d_polynomial_surface(coords, medians, order, image.shape)
|
waveorder/focus.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from scipy.signal import peak_widths
|
|
2
|
+
from typing import Literal, Optional
|
|
3
|
+
from waveorder import util
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import numpy as np
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def focus_from_transverse_band(
|
|
10
|
+
zyx_array,
|
|
11
|
+
NA_det,
|
|
12
|
+
lambda_ill,
|
|
13
|
+
pixel_size,
|
|
14
|
+
midband_fractions=(0.125, 0.25),
|
|
15
|
+
mode: Literal["min", "max"] = "max",
|
|
16
|
+
plot_path: Optional[str] = None,
|
|
17
|
+
threshold_FWHM: float = 0,
|
|
18
|
+
):
|
|
19
|
+
"""Estimates the in-focus slice from a 3D stack by optimizing a transverse spatial frequency band.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
zyx_array: np.array
|
|
24
|
+
Data stack in (Z, Y, X) order.
|
|
25
|
+
Requires len(zyx_array.shape) == 3.
|
|
26
|
+
NA_det: float
|
|
27
|
+
Detection NA.
|
|
28
|
+
lambda_ill: float
|
|
29
|
+
Illumination wavelength
|
|
30
|
+
Units are arbitrary, but must match [pixel_size]
|
|
31
|
+
pixel_size: float
|
|
32
|
+
Object-space pixel size = camera pixel size / magnification.
|
|
33
|
+
Units are arbitrary, but must match [lambda_ill]
|
|
34
|
+
midband_fractions: Tuple[float, float], optional
|
|
35
|
+
The minimum and maximum fraction of the cutoff frequency that define the midband.
|
|
36
|
+
Requires: 0 <= midband_fractions[0] < midband_fractions[1] <= 1.
|
|
37
|
+
mode: {'max', 'min'}, optional
|
|
38
|
+
Option to choose the in-focus slice by minimizing or maximizing the midband frequency.
|
|
39
|
+
plot_path: str or None, optional
|
|
40
|
+
File name for a diagnostic plot (supports matplotlib filetypes .png, .pdf, .svg, etc.).
|
|
41
|
+
Use None to skip.
|
|
42
|
+
threshold_FWHM: float, optional
|
|
43
|
+
Threshold full-width half max for a peak to be considered in focus.
|
|
44
|
+
The default value, 0, applies no threshold, and the maximum midband power is always considered in focus.
|
|
45
|
+
For values > 0, the peak's FWHM must be greater than the threshold for the slice to be considered in focus.
|
|
46
|
+
If the peak does not meet this threshold, the function returns None.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
------
|
|
50
|
+
slice : int or None
|
|
51
|
+
If peak's FWHM > peak_width_threshold:
|
|
52
|
+
return the index of the in-focus slice
|
|
53
|
+
else:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
Example
|
|
57
|
+
------
|
|
58
|
+
>>> zyx_array.shape
|
|
59
|
+
(11, 2048, 2048)
|
|
60
|
+
>>> from waveorder.focus import focus_from_transverse_band
|
|
61
|
+
>>> slice = focus_from_transverse_band(zyx_array, NA_det=0.55, lambda_ill=0.532, pixel_size=6.5/20)
|
|
62
|
+
>>> in_focus_data = data[slice,:,:]
|
|
63
|
+
"""
|
|
64
|
+
minmaxfunc = _mode_to_minmaxfunc(mode)
|
|
65
|
+
|
|
66
|
+
_check_focus_inputs(
|
|
67
|
+
zyx_array, NA_det, lambda_ill, pixel_size, midband_fractions
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Check for single slice
|
|
71
|
+
if zyx_array.shape[0] == 1:
|
|
72
|
+
warnings.warn(
|
|
73
|
+
"The dataset only contained a single slice. Returning trivial slice index = 0."
|
|
74
|
+
)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
# Calculate coordinates
|
|
78
|
+
_, Y, X = zyx_array.shape
|
|
79
|
+
_, _, fxx, fyy = util.gen_coordinate((Y, X), pixel_size)
|
|
80
|
+
frr = np.sqrt(fxx**2 + fyy**2)
|
|
81
|
+
|
|
82
|
+
# Calculate fft
|
|
83
|
+
xy_abs_fft = np.abs(np.fft.fftn(zyx_array, axes=(1, 2)))
|
|
84
|
+
|
|
85
|
+
# Calculate midband mask
|
|
86
|
+
cutoff = 2 * NA_det / lambda_ill
|
|
87
|
+
midband_mask = np.logical_and(
|
|
88
|
+
frr > cutoff * midband_fractions[0],
|
|
89
|
+
frr < cutoff * midband_fractions[1],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Find slice index with min/max power in midband
|
|
93
|
+
midband_sum = np.sum(xy_abs_fft[:, midband_mask], axis=1)
|
|
94
|
+
peak_index = minmaxfunc(midband_sum)
|
|
95
|
+
|
|
96
|
+
peak_results = peak_widths(midband_sum, [peak_index])
|
|
97
|
+
peak_FWHM = peak_results[0][0]
|
|
98
|
+
|
|
99
|
+
if peak_FWHM >= threshold_FWHM:
|
|
100
|
+
in_focus_index = peak_index
|
|
101
|
+
else:
|
|
102
|
+
in_focus_index = None
|
|
103
|
+
|
|
104
|
+
# Plot
|
|
105
|
+
if plot_path is not None:
|
|
106
|
+
_plot_focus_metric(
|
|
107
|
+
plot_path,
|
|
108
|
+
midband_sum,
|
|
109
|
+
peak_index,
|
|
110
|
+
in_focus_index,
|
|
111
|
+
peak_results,
|
|
112
|
+
threshold_FWHM,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return in_focus_index
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _mode_to_minmaxfunc(mode):
|
|
119
|
+
if mode == "min":
|
|
120
|
+
minmaxfunc = np.argmin
|
|
121
|
+
elif mode == "max":
|
|
122
|
+
minmaxfunc = np.argmax
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError("mode must be either `min` or `max`")
|
|
125
|
+
return minmaxfunc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_focus_inputs(
|
|
129
|
+
zyx_array, NA_det, lambda_ill, pixel_size, midband_fractions
|
|
130
|
+
):
|
|
131
|
+
N = len(zyx_array.shape)
|
|
132
|
+
if N != 3:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"{N}D array supplied. `focus_from_transverse_band` only accepts 3D arrays."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if NA_det < 0:
|
|
138
|
+
raise ValueError("NA must be > 0")
|
|
139
|
+
if lambda_ill < 0:
|
|
140
|
+
raise ValueError("lambda_ill must be > 0")
|
|
141
|
+
if pixel_size < 0:
|
|
142
|
+
raise ValueError("pixel_size must be > 0")
|
|
143
|
+
if not 0.4 < lambda_ill / pixel_size < 10:
|
|
144
|
+
warnings.warn(
|
|
145
|
+
f"WARNING: lambda_ill/pixel_size = {lambda_ill/pixel_size}."
|
|
146
|
+
f"Did you use the same units?"
|
|
147
|
+
f"Did you enter the pixel size in (demagnified) object-space units?"
|
|
148
|
+
)
|
|
149
|
+
if not midband_fractions[0] < midband_fractions[1]:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"midband_fractions[0] must be less than midband_fractions[1]"
|
|
152
|
+
)
|
|
153
|
+
if not (0 <= midband_fractions[0] <= 1):
|
|
154
|
+
raise ValueError("midband_fractions[0] must be between 0 and 1")
|
|
155
|
+
if not (0 <= midband_fractions[1] <= 1):
|
|
156
|
+
raise ValueError("midband_fractions[1] must be between 0 and 1")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _plot_focus_metric(
|
|
160
|
+
plot_path,
|
|
161
|
+
midband_sum,
|
|
162
|
+
peak_index,
|
|
163
|
+
in_focus_index,
|
|
164
|
+
peak_results,
|
|
165
|
+
threshold_FWHM,
|
|
166
|
+
):
|
|
167
|
+
_, ax = plt.subplots(1, 1, figsize=(4, 4))
|
|
168
|
+
ax.plot(midband_sum, "-k")
|
|
169
|
+
ax.plot(
|
|
170
|
+
peak_index,
|
|
171
|
+
midband_sum[peak_index],
|
|
172
|
+
"go" if in_focus_index is not None else "ro",
|
|
173
|
+
)
|
|
174
|
+
ax.hlines(*peak_results[1:], color="k", linestyles="dashed")
|
|
175
|
+
|
|
176
|
+
ax.set_xlabel("Slice index")
|
|
177
|
+
ax.set_ylabel("Midband power")
|
|
178
|
+
|
|
179
|
+
ax.annotate(
|
|
180
|
+
f"In-focus slice = {in_focus_index}\n Peak width = {peak_results[0][0]:.2f}\n Peak width threshold = {threshold_FWHM}",
|
|
181
|
+
xy=(1, 1),
|
|
182
|
+
xytext=(1.0, 1.1),
|
|
183
|
+
textcoords="axes fraction",
|
|
184
|
+
xycoords="axes fraction",
|
|
185
|
+
ha="right",
|
|
186
|
+
va="center",
|
|
187
|
+
annotation_clip=False,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
ax.spines["right"].set_visible(False)
|
|
191
|
+
ax.spines["top"].set_visible(False)
|
|
192
|
+
ax.spines["left"].set_position(("outward", 10))
|
|
193
|
+
ax.spines["bottom"].set_position(("outward", 10))
|
|
194
|
+
ax.ticklabel_format(style="sci", scilimits=(-2, 2))
|
|
195
|
+
|
|
196
|
+
print(f"Saving plot to {plot_path}")
|
|
197
|
+
plt.savefig(plot_path, bbox_inches="tight", dpi=300)
|
|
198
|
+
plt.close()
|