essimaging 25.11.0__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.
@@ -0,0 +1,19 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
+ # ruff: noqa: E402, F401, I
4
+
5
+ import importlib.metadata
6
+
7
+ try:
8
+ __version__ = importlib.metadata.version("essimaging")
9
+ except importlib.metadata.PackageNotFoundError:
10
+ __version__ = "0.0.0"
11
+
12
+ from . import tools
13
+
14
+ del importlib
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "tools",
19
+ ]
@@ -0,0 +1,62 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
+ """
4
+ Contains the providers to compute neutron time-of-flight and wavelength.
5
+ """
6
+
7
+ import scippneutron as scn
8
+ import scippnexus as snx
9
+
10
+ from .types import (
11
+ CoordTransformGraph,
12
+ GravityVector,
13
+ Position,
14
+ RunType,
15
+ TofDetector,
16
+ WavelengthDetector,
17
+ )
18
+
19
+
20
+ def make_coordinate_transform_graph(
21
+ sample_position: Position[snx.NXsample, RunType],
22
+ source_position: Position[snx.NXsource, RunType],
23
+ gravity: GravityVector,
24
+ ) -> CoordTransformGraph[RunType]:
25
+ """
26
+ Create a graph of coordinate transformations to compute the wavelength from the
27
+ time-of-flight.
28
+ """
29
+ graph = {
30
+ **scn.conversion.graph.beamline.beamline(scatter=False),
31
+ **scn.conversion.graph.tof.elastic("tof"),
32
+ 'sample_position': lambda: sample_position,
33
+ 'source_position': lambda: source_position,
34
+ 'gravity': lambda: gravity,
35
+ }
36
+ return CoordTransformGraph(graph)
37
+
38
+
39
+ def compute_detector_wavelength(
40
+ tof_data: TofDetector[RunType],
41
+ graph: CoordTransformGraph[RunType],
42
+ ) -> WavelengthDetector[RunType]:
43
+ """
44
+ Compute the wavelength of neutrons detected by the detector.
45
+
46
+ Parameters
47
+ ----------
48
+ tof_data:
49
+ Data with a time-of-flight coordinate.
50
+ graph:
51
+ Graph of coordinate transformations.
52
+ """
53
+ return WavelengthDetector[RunType](
54
+ tof_data.transform_coords("wavelength", graph=graph)
55
+ )
56
+
57
+
58
+ providers = (
59
+ make_coordinate_transform_graph,
60
+ compute_detector_wavelength,
61
+ )
62
+ """Providers to compute neutron time-of-flight and wavelength."""
ess/imaging/data.py ADDED
@@ -0,0 +1,49 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ import pathlib
4
+
5
+ from ess.reduce.data import make_registry
6
+
7
+ _registry = make_registry(
8
+ 'ess/imaging',
9
+ version="1",
10
+ files={
11
+ 'siemens_star.tiff': 'md5:0ba27c2daf745338959f5156a3b0a2c0',
12
+ 'resolving_power_test_target.tiff': 'md5:a5d414603797f4cc02fe7b2ae4d7aa48',
13
+ # Measurements that Søren Schmidt (imaging IDS 2025) made at J-PARC.
14
+ "siemens-star-measured.h5": "md5:8e333d36c7c102f474b2b66cb785f5e8",
15
+ "siemens-star-openbeam.h5": "md5:ee429b2c247aeaafb0ef3ca4171f2e6a",
16
+ },
17
+ )
18
+
19
+
20
+ def siemens_star_path() -> pathlib.Path:
21
+ """
22
+ Return the path to the Siemens star test image.
23
+ """
24
+
25
+ return _registry.get_path('siemens_star.tiff')
26
+
27
+
28
+ def resolving_power_test_target_path() -> pathlib.Path:
29
+ """
30
+ Return the path to the resolving power test target image.
31
+ """
32
+
33
+ return _registry.get_path('resolving_power_test_target.tiff')
34
+
35
+
36
+ def jparc_siemens_star_measured_path() -> pathlib.Path:
37
+ """
38
+ Return the path to the Siemens star test image measured at J-PARC.
39
+ """
40
+
41
+ return _registry.get_path('siemens-star-measured.h5')
42
+
43
+
44
+ def jparc_siemens_star_openbeam_path() -> pathlib.Path:
45
+ """
46
+ Return the path to the Siemens star open beam image measured at J-PARC.
47
+ """
48
+
49
+ return _registry.get_path('siemens-star-openbeam.h5')
ess/imaging/py.typed ADDED
File without changes
@@ -0,0 +1,25 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
+
4
+
5
+ from .analysis import blockify, laplace_2d, resample, resize, sharpness
6
+ from .resolution import (
7
+ estimate_cut_off_frequency,
8
+ maximum_resolution_achievable,
9
+ modulation_transfer_function,
10
+ mtf_less_than,
11
+ )
12
+ from .saturation import saturation_indicator
13
+
14
+ __all__ = [
15
+ "blockify",
16
+ "estimate_cut_off_frequency",
17
+ "laplace_2d",
18
+ "maximum_resolution_achievable",
19
+ "modulation_transfer_function",
20
+ "mtf_less_than",
21
+ "resample",
22
+ "resize",
23
+ "saturation_indicator",
24
+ "sharpness",
25
+ ]
@@ -0,0 +1,224 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
+ """
4
+ Tools for image analysis and manipulation.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from itertools import combinations
9
+
10
+ import numpy as np
11
+ import scipp as sc
12
+
13
+
14
+ def blockify(
15
+ image: sc.Variable | sc.DataArray, sizes: dict[str, int]
16
+ ) -> sc.Variable | sc.DataArray:
17
+ """
18
+ Blockify an image by folding it into blocks of specified sizes.
19
+ The sizes should be provided as keyword arguments, where the keys are
20
+ dimension names and the values are the sizes of the blocks.
21
+ The shape of the input image must be divisible by the block sizes.
22
+
23
+ Parameters
24
+ ----------
25
+ image:
26
+ The image to blockify.
27
+ sizes:
28
+ The block sizes for each dimension.
29
+ For example, `{'x': 4, 'y': 4}` will create blocks of size 4x4.
30
+ """
31
+ out = image
32
+ for dim, size in sizes.items():
33
+ i = 0
34
+ while f'newdim{i}' in out.dims:
35
+ i += 1
36
+ out = out.fold(dim=dim, sizes={dim: -1, f'newdim{i}': size})
37
+ return out
38
+
39
+
40
+ def resample(
41
+ image: sc.Variable | sc.DataArray,
42
+ sizes: dict[str, int],
43
+ method: str | Callable = 'sum',
44
+ ) -> sc.Variable | sc.DataArray:
45
+ """
46
+ Resample an image by folding it into blocks of specified sizes and applying a
47
+ reduction method.
48
+ The sizes should be provided as a dictionary where the keys are dimension names
49
+ and the values are the sizes of the blocks. The shape of the input image must be
50
+ divisible by the block sizes.
51
+
52
+ Parameters
53
+ ----------
54
+ image:
55
+ The image to resample.
56
+ sizes:
57
+ A dictionary specifying the block sizes for each dimension.
58
+ For example, ``{'x': 4, 'y': 4}`` will create blocks of size 4x4.
59
+ method:
60
+ The reduction method to apply to the blocks. This can be a string referring to
61
+ any valid Scipp reduction method, such as 'sum', 'mean', 'max', etc.
62
+ Alternatively, a custom reduction function can be provided. The function
63
+ signature should accept a ``scipp.Variable`` or ``scipp.DataArray`` as first
64
+ argument and a set of dimensions to reduce over as second argument. The
65
+ function should return a ``scipp.Variable`` or ``scipp.DataArray``.
66
+ """
67
+ blocked = blockify(image, sizes=sizes)
68
+ _method = getattr(sc, method) if isinstance(method, str) else method
69
+ out = _method(blocked, set(blocked.dims) - set(image.dims))
70
+ if 'position' in blocked.coords:
71
+ out.coords['position'] = blocked.coords['position'].mean(
72
+ set(blocked.dims) - set(image.dims)
73
+ )
74
+ return out
75
+
76
+
77
+ def resize(
78
+ image: sc.Variable | sc.DataArray,
79
+ sizes: dict[str, int],
80
+ method: str | Callable = 'sum',
81
+ ) -> sc.Variable | sc.DataArray:
82
+ """
83
+ Resize an image by folding it into blocks of specified sizes and applying a
84
+ reduction method.
85
+ The sizes should be provided as a dictionary where the keys are dimension names
86
+ and the values are the sizes of the blocks. The shape of the input image must be
87
+ divisible by the block sizes.
88
+
89
+ Parameters
90
+ ----------
91
+ image:
92
+ The image to resample.
93
+ sizes:
94
+ A dictionary specifying the desired size of the output image for each dimension.
95
+ The original sizes should be divisible by the specified sizes.
96
+ For example, ``{'x': 128, 'y': 128}`` will create an output image of size
97
+ 128x128.
98
+ method:
99
+ The reduction method to apply to the blocks. This can be a string referring to
100
+ any valid Scipp reduction method, such as 'sum', 'mean', 'max', etc.
101
+ Alternatively, a custom reduction function can be provided. The function
102
+ signature should accept a ``scipp.Variable`` or ``scipp.DataArray`` as first
103
+ argument and a set of dimensions to reduce over as second argument. The
104
+ function should return a ``scipp.Variable`` or ``scipp.DataArray``.
105
+ """
106
+ block_sizes = {}
107
+ for dim, size in sizes.items():
108
+ if image.sizes[dim] % size != 0:
109
+ raise ValueError(
110
+ f"Size of dimension '{dim}' ({image.sizes[dim]}) is not divisible by"
111
+ f" the requested size ({size})."
112
+ )
113
+ block_sizes[dim] = image.sizes[dim] // size
114
+ return resample(image, sizes=block_sizes, method=method)
115
+
116
+
117
+ def laplace_2d(
118
+ image: sc.Variable | sc.DataArray, dims: tuple[str, str] | list[str]
119
+ ) -> sc.Variable | sc.DataArray:
120
+ """
121
+ Compute the Laplace operator of a 2d image using a kernel that approximates
122
+ the second derivative in two dimensions. The kernel is designed to
123
+ highlight areas of rapid intensity change, which are indicative of edges
124
+ in the image.
125
+ The kernel is applied to the image by convolving it with the Laplace operator,
126
+ which is a discrete approximation of the second derivative. The result is
127
+ a new image where each pixel value represents the sum of the second
128
+ derivatives in the x and y directions, effectively highlighting areas of
129
+ high curvature or rapid intensity change.
130
+
131
+ Parameters
132
+ ----------
133
+ image:
134
+ The input image to compute the Laplace operator on.
135
+ dims:
136
+ The dimensions of the image over which to compute the Laplace operator.
137
+ Other dimensions will be preserved in the output.
138
+ """
139
+ kernel = [8] + ([-1] * 8)
140
+ ii = np.repeat([0, -1, 1], 3)
141
+ jj = np.tile([0, -1, 1], 3)
142
+
143
+ lp2d = sc.reduce(
144
+ (
145
+ image[dims[0], (1 + j) : (image.sizes[dims[0]] - 1 + j)][
146
+ dims[1], (1 + i) : (image.sizes[dims[1]] - 1 + i)
147
+ ]
148
+ * k
149
+ for i, j, k in zip(ii, jj, kernel, strict=True)
150
+ )
151
+ ).sum()
152
+
153
+ lp2d.unit = "" # Laplacian is dimensionless
154
+ out = (
155
+ sc.DataArray(data=sc.zeros(sizes=image.sizes, dtype=lp2d.dtype))
156
+ .assign_coords(image.coords)
157
+ .assign_masks(image.masks)
158
+ )
159
+ out[dims[0], 1:-1][dims[1], 1:-1] = lp2d
160
+ return out
161
+
162
+
163
+ def _prime_factors(n):
164
+ i = 2
165
+ factors = []
166
+ while i * i <= n:
167
+ if n % i == 0:
168
+ factors.append(i)
169
+ n //= i
170
+ else:
171
+ i += 1
172
+ if n > 1:
173
+ factors.append(n)
174
+ return factors
175
+
176
+
177
+ def _best_subset_product(factors, target):
178
+ best_product = 1
179
+ for r in range(1, len(factors) + 1):
180
+ for combo in combinations(factors, r):
181
+ prod = np.prod(combo)
182
+ if abs(prod - target) < abs(best_product - target):
183
+ best_product = prod
184
+ return best_product
185
+
186
+
187
+ def sharpness(
188
+ image: sc.Variable | sc.DataArray,
189
+ dims: tuple[str, str] | list[str],
190
+ max_size: int | None = 512,
191
+ ) -> sc.Variable | sc.DataArray:
192
+ """
193
+ Calculate the sharpness of an image by computing the Laplace operator
194
+ and summing the absolute values of the results over specified dimensions.
195
+ The sharpness is a measure of the amount of detail in the image, with
196
+ higher values indicating sharper images. The Laplace operator is used to
197
+ detect edges in the image, and the variance of the Laplacian highlights areas of
198
+ rapid intensity change, which are indicative of sharp features.
199
+
200
+ Parameters
201
+ ----------
202
+ image:
203
+ The input image to compute the sharpness on.
204
+ dims:
205
+ The dimensions of the image over which to compute the sharpness.
206
+ Other dimensions will be preserved in the output.
207
+ max_size:
208
+ The maximum size of the image to compute the sharpness on. If the
209
+ image is larger than this size, it will be downsampled to fit within
210
+ the specified maximum size. This is useful for large images where
211
+ computing the Laplace operator directly would be computationally
212
+ expensive.
213
+ """
214
+ if max_size is not None:
215
+ sizes = {}
216
+ for dim in dims:
217
+ if image.sizes[dim] > max_size:
218
+ # Decompose size into prime numbers to find the best subset product
219
+ # closest to the maximum size
220
+ factors = _prime_factors(image.sizes[dim])
221
+ sizes[dim] = _best_subset_product(factors, max_size)
222
+ image = resize(image, sizes=sizes)
223
+
224
+ return laplace_2d(image, dims=dims).var(dim=dims, ddof=1)
@@ -0,0 +1,321 @@
1
+ import numpy as np
2
+ import scipp as sc
3
+ from numpy.typing import NDArray
4
+
5
+
6
+ def maximum_resolution_achievable(
7
+ events: sc.DataArray,
8
+ coarse_x_bin_edges: sc.Variable,
9
+ coarse_y_bin_edges: sc.Variable,
10
+ time_bin_edges: sc.Variable,
11
+ max_tries: int = 10,
12
+ max_pixels_x: int = 2048,
13
+ max_pixels_y: int = 2048,
14
+ raise_if_not_maximum: bool = False,
15
+ ):
16
+ """
17
+ Estimates the maximum resolution achievable
18
+ given a desired binning in time.
19
+ The maximum achievable resolution is defined
20
+ as the resolution in ``xy`` such that
21
+ there is at least one event in every ``xyt`` pixel.
22
+
23
+ Parameters
24
+ -------------
25
+ events:
26
+ 1D DataArray containing events with associated x, y, and t coordinates.
27
+ The names of the coordinates must not be `x`, `y` and `t`,
28
+ the names of the coordinates are taken from the provided ``bin_edges``
29
+ for each respective dimension.
30
+ coarse_x_bin_edges:
31
+ Minimum acceptable resolution in ``x``.
32
+ coarse_y_bin_edges:
33
+ Minimum acceptable resolution in ``y``.
34
+ time_bin_edges:
35
+ Desired resolution in ``t``.
36
+ max_tries:
37
+ The maximum number of iterations before giving up.
38
+ max_pixels_x:
39
+ The maximum number of pixels in ``x``.
40
+ max_pixels_y:
41
+ The maximum number of pixels in ``y``.
42
+ raise_if_not_maximum:
43
+ Often it is not important to find the exact maximum resolution.
44
+ Therefore this parameter is ``False`` by default, and the function
45
+ returns an estimate of the maximum resolution.
46
+ If you want the returned resolution to be exactly the maximum resolution,
47
+ set the value of this parameter to ``True``.
48
+
49
+ Returns
50
+ -------------
51
+ The bin edges in x respectively y that define the
52
+ maximum achievable resolution.
53
+ """
54
+
55
+ lower_nx = coarse_x_bin_edges.size
56
+ lower_ny = coarse_y_bin_edges.size
57
+ upper_nx = max_pixels_x
58
+ upper_ny = max_pixels_y
59
+
60
+ nx = int(2**0.5 * lower_nx) + 1
61
+ ny = int(2**0.5 * lower_ny) + 1
62
+ events = events.bin({time_bin_edges.dim: time_bin_edges})
63
+
64
+ for _ in range(max_tries):
65
+ xbins = sc.linspace(
66
+ coarse_x_bin_edges.dim, coarse_x_bin_edges[0], coarse_x_bin_edges[-1], nx
67
+ )
68
+ ybins = sc.linspace(
69
+ coarse_y_bin_edges.dim, coarse_y_bin_edges[0], coarse_y_bin_edges[-1], ny
70
+ )
71
+ min_counts_per_pixel = (
72
+ events.bin(
73
+ {
74
+ coarse_x_bin_edges.dim: xbins,
75
+ coarse_y_bin_edges.dim: ybins,
76
+ }
77
+ )
78
+ .bins.size()
79
+ .min()
80
+ )
81
+
82
+ if min_counts_per_pixel.value > 0:
83
+ lower_nx = nx
84
+ lower_ny = ny
85
+ nx = max(min(round((upper_nx * nx) ** 0.5), nx * 2), lower_nx + 1)
86
+ ny = max(min(round((upper_ny * ny) ** 0.5), ny * 2), lower_ny + 1)
87
+ else:
88
+ upper_nx = nx
89
+ upper_ny = ny
90
+ nx = min(round((lower_nx * nx) ** 0.5), upper_nx - 1)
91
+ ny = min(round((lower_ny * ny) ** 0.5), upper_nx - 1)
92
+
93
+ if upper_nx - lower_nx < 2 and upper_ny - lower_ny < 2:
94
+ break
95
+
96
+ if raise_if_not_maximum and upper_nx - lower_nx >= 2 and upper_ny - lower_ny >= 2:
97
+ raise RuntimeError(
98
+ 'Maximal resolution was not found. Increase `max_tries` to search longer. '
99
+ 'Or set `raise_if_not_maximum=False` if it is not necessary to locate the '
100
+ 'maximum exactly.'
101
+ )
102
+
103
+ return (
104
+ sc.linspace(
105
+ coarse_x_bin_edges.dim,
106
+ coarse_x_bin_edges[0],
107
+ coarse_x_bin_edges[-1],
108
+ lower_nx,
109
+ ),
110
+ sc.linspace(
111
+ coarse_y_bin_edges.dim,
112
+ coarse_y_bin_edges[0],
113
+ coarse_y_bin_edges[-1],
114
+ lower_ny,
115
+ ),
116
+ )
117
+
118
+
119
+ def _radial_profile(data: NDArray) -> NDArray:
120
+ '''Integrate ellipses around center of image.'''
121
+ y, x = np.indices(data.shape)
122
+ cy, cx = np.array(data.shape) / 2.0
123
+ r = np.hypot((cx * cy) ** 0.5 * (x - cx) / cx, (cx * cy) ** 0.5 * (y - cy) / cy)
124
+ r = r.astype(np.int32)
125
+ tbin = np.bincount(r.ravel(), data.ravel())
126
+ nr = np.bincount(r.ravel())
127
+ return tbin / (nr + 1e-15)
128
+
129
+
130
+ def modulation_transfer_function(
131
+ measured_image: sc.DataArray,
132
+ open_beam_image: sc.DataArray,
133
+ target: sc.DataArray,
134
+ ) -> sc.DataArray:
135
+ '''
136
+ Computes the modulation transfer function (MTF) of
137
+ the camera given a measured image and the
138
+ ideal image that would have been captured if
139
+ the instrument had infinite resolution.
140
+
141
+ Parameters
142
+ ------------
143
+ measured_image:
144
+ The image of the sample captured by the camera.
145
+ open_beam_image:
146
+ The image without the sample captured by the camera.
147
+ target:
148
+ A perfect image of the sample
149
+ on the same grid as `measured_image`.
150
+
151
+ Returns
152
+ ------------
153
+ :
154
+ The modulation transfer function as a function
155
+ of "frequency" representing "line pairs" per pixel.
156
+
157
+ Notes
158
+ -----------
159
+
160
+ Computing modulation transfer function (MTF)
161
+ ============================================
162
+
163
+ The definition of the MTF is
164
+
165
+ .. math::
166
+
167
+ \\mathrm{MTF}(f) = |\\mathcal{F}(P)|
168
+
169
+ where :math:`\\mathcal{F}(P)` is the Fourier transform of the point spread function :math:`P`.
170
+
171
+ The Fourier transform of the point spread function is really a function of two variables, but it is assumed that the MTF does not vary depending on the direction of change, so here it's denoted as a function of the frequency independent of direction:
172
+
173
+ .. math::
174
+
175
+ \\mathrm{MTF}(\\|(f_x, f_y)\\|) = |\\mathcal{F}(P)|(f_x, f_y)
176
+
177
+ Model for images in detector
178
+ ----------------------------
179
+
180
+ The intensity distribution in the detector (the "image") :math:`I` is modeled as
181
+
182
+ .. math::
183
+
184
+ I = I_0 S \\star P
185
+
186
+ where :math:`I_0` is the intensity distribution at the sample, :math:`S` is the transmission function of the sample, and :math:`P` is the point-spread function.
187
+
188
+ For the open beam we don't have any sample and the intensity distribution in the detector is modeled as
189
+
190
+ .. math::
191
+
192
+ I_{ob} = I_0 \\star P
193
+
194
+ Approximation
195
+ -------------
196
+
197
+ Assuming :math:`I_0` is more or less uniform, and :math:`P` is relatively localized, we can approximate
198
+
199
+ .. math::
200
+
201
+ I_0 \\star P \\approx I_0
202
+
203
+ Making this assumption we can substitute :math:`I_0` for :math:`I_{ob}` in the model for the image:
204
+
205
+ .. math::
206
+
207
+ I = I_{ob} S \\star P
208
+
209
+ Applying the Fourier transform on both sides we have
210
+
211
+ .. math::
212
+
213
+ \\mathcal{F}(I) = \\mathcal{F}(I_{ob} S)\\, \\mathcal{F}(P)
214
+
215
+ which implies
216
+
217
+ .. math::
218
+
219
+ |\\mathcal{F}(P)| = \\left| \\frac{\\mathcal{F}(I)}{\\mathcal{F}(I_{ob} S)} \\right|
220
+
221
+ and therefore
222
+
223
+ .. math::
224
+
225
+ \\mathrm{MTF}(\\|(f_x, f_y)\\|) =
226
+ \\frac{|\\mathcal{F}(I)|(f_x, f_y)}{|\\mathcal{F}(I_{ob} S)|(f_x, f_y)}
227
+
228
+ Finally, integrating over constant frequency magnitude:
229
+
230
+ .. math::
231
+
232
+ \\mathrm{MTF}(f) =
233
+ \\frac{\\int_{\\|(f_x, f_y)\\| = f} |\\mathcal{F}(I)|(f_x, f_y)\\, df_x\\, df_y}
234
+ {\\int_{\\|(f_x, f_y)\\| = f} |\\mathcal{F}(I_{ob} S)|(f_x, f_y)\\, df_x\\, df_y}
235
+
236
+ Conclusion
237
+ ----------
238
+
239
+ The modulation transfer function at frequency :math:`f` can be estimated as the ratio of the Fourier transform of the image (integrated over constant frequency magnitude) to the Fourier transform of the open beam image multiplied by the sample mask (also integrated over constant frequency magnitude).
240
+ ''' # noqa: E501
241
+ _measured = measured_image.values
242
+ # Can't do inplace because dtype of sum might be different from dtype of input
243
+ _measured = _measured / _measured.sum()
244
+ _reference = (open_beam_image * target).to(unit=measured_image.unit).values
245
+ _reference = _reference / _reference.sum()
246
+ f_ideal = np.abs(np.fft.fftshift(np.fft.fft2(_reference)))
247
+ f_measured = np.abs(np.fft.fftshift(np.fft.fft2(_measured)))
248
+ _mtf = _radial_profile(f_measured) / _radial_profile(f_ideal)
249
+ return sc.DataArray(
250
+ sc.array(dims=['frequency'], values=_mtf),
251
+ # Unit of frequency is line_pairs / pixel but since both of those are
252
+ # a kind of counts I think in our unit system that is best
253
+ # represented as 'dimensionless'.
254
+ # The largest frequency magnitude in 2d fft is sqrt(1/2).
255
+ coords={'frequency': sc.linspace('frequency', 0, (1 / 2) ** 0.5, len(_mtf))},
256
+ # We're only interested in frequencies below 0.5 oscillations per pixel
257
+ # because those above are unphysical.
258
+ )['frequency', : sc.scalar(0.5)]
259
+
260
+
261
+ def estimate_cut_off_frequency(mtf: sc.DataArray) -> sc.Variable:
262
+ '''Estimates the cut off frequency of
263
+ the modulation transfer function (mtf).
264
+
265
+ Parameters
266
+ -------------
267
+ mtf:
268
+ A (potentially noisy) modulation transfer function curve
269
+ having a coordinate named "frequency".
270
+
271
+ Returns
272
+ -------------
273
+ :
274
+ An estimate of the frequency where the modulation
275
+ transfer function goes to zero, the "cut off frequency".
276
+ '''
277
+ _freq = np.concat([[0.0], mtf.coords['frequency'].values])
278
+ _mtf = np.concat([[1.0], mtf.values])
279
+ # The line should go through (0, 1), so give it a big weight.
280
+ # 10 x total_weight was determined good enough by trial and error.
281
+ w = np.concat([[10 * len(mtf)], np.ones(len(mtf))])
282
+ m = np.ones(len(_freq), dtype='bool')
283
+ fc = np.nan
284
+ maxiters = 100
285
+ for _ in range(maxiters):
286
+ p = np.polyfit(_freq[m], _mtf[m], 1, w=w[m])
287
+ # 1e-4 is used as a threshold because the method is not
288
+ # accurate to less than 1e-4 anyway so we can just as well stop there.
289
+ if abs(-p[1] / p[0] - fc) < 1e-4:
290
+ break
291
+ fc = -p[1] / p[0]
292
+ m = np.polyval(p, _freq) >= 0
293
+ # Correction factor 9/8 is the ratio between where a linear approximation
294
+ # of the MTF of a circular apparture crosses 0 and where the actual cutoff frequency
295
+ # of the same circular apparture is.
296
+ # For reference:
297
+ # import sympy as sp
298
+ # x, f, a = sp.symbols('x, f, a', positive=True)
299
+ # sp.solve(sp.integrate(sp.diff((1 - a * x - 2 / sp.pi * (sp.acos(x/f) - x/f * sp.sqrt(1 - x**2/f**2)))**2, a), (x, 0, f)), f) # noqa: E501
300
+ return 9 / 8 * sc.scalar(-p[1] / p[0], unit=mtf.coords['frequency'].unit)
301
+
302
+
303
+ def mtf_less_than(mtf: sc.DataArray, limit: sc.Variable) -> sc.Variable:
304
+ '''Computes the frequency where the
305
+ modulation transfer function goes below ``limit``.
306
+
307
+ Parameters
308
+ --------------
309
+ mtf:
310
+ A (potentially noisy) modulation transfer function curve
311
+ having a coordinate named "frequency".
312
+
313
+ limit:
314
+ The modulation transfer function value at the returned frequency.
315
+
316
+ Returns
317
+ -----------
318
+ :
319
+ The frequency where the modulation transfer function goes below "limit".
320
+ '''
321
+ return mtf.coords['frequency'][mtf.data <= limit].min()