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.
- ess/imaging/__init__.py +19 -0
- ess/imaging/conversion.py +62 -0
- ess/imaging/data.py +49 -0
- ess/imaging/py.typed +0 -0
- ess/imaging/tools/__init__.py +25 -0
- ess/imaging/tools/analysis.py +224 -0
- ess/imaging/tools/resolution.py +321 -0
- ess/imaging/tools/saturation.py +49 -0
- ess/imaging/types.py +77 -0
- ess/odin/__init__.py +21 -0
- ess/odin/beamline.py +120 -0
- ess/odin/data.py +71 -0
- ess/odin/masking.py +30 -0
- ess/odin/workflows.py +63 -0
- ess/tbl/__init__.py +19 -0
- ess/tbl/data.py +46 -0
- ess/tbl/py.typed +0 -0
- ess/tbl/workflow.py +41 -0
- ess/ymir/data.py +21 -0
- ess/ymir/io.py +421 -0
- ess/ymir/normalize.py +268 -0
- ess/ymir/types.py +26 -0
- ess/ymir/workflow.py +126 -0
- essimaging-25.11.0.dist-info/METADATA +55 -0
- essimaging-25.11.0.dist-info/RECORD +28 -0
- essimaging-25.11.0.dist-info/WHEEL +5 -0
- essimaging-25.11.0.dist-info/licenses/LICENSE +29 -0
- essimaging-25.11.0.dist-info/top_level.txt +1 -0
ess/imaging/__init__.py
ADDED
|
@@ -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()
|