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