nabu 2025.1.0.dev13__py3-none-any.whl → 2025.1.0rc1__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.
- nabu/__init__.py +1 -1
- nabu/app/cast_volume.py +12 -1
- nabu/app/cli_configs.py +81 -4
- nabu/app/estimate_motion.py +54 -0
- nabu/app/multicor.py +2 -4
- nabu/app/pcaflats.py +116 -0
- nabu/app/reconstruct.py +1 -7
- nabu/app/reduce_dark_flat.py +5 -2
- nabu/estimation/cor.py +1 -1
- nabu/estimation/motion.py +557 -0
- nabu/estimation/tests/test_motion_estimation.py +471 -0
- nabu/estimation/tilt.py +1 -1
- nabu/estimation/translation.py +47 -1
- nabu/io/cast_volume.py +94 -13
- nabu/io/reader.py +32 -1
- nabu/io/tests/test_remove_volume.py +152 -0
- nabu/pipeline/config_validators.py +42 -43
- nabu/pipeline/estimators.py +255 -0
- nabu/pipeline/fullfield/chunked.py +67 -43
- nabu/pipeline/fullfield/chunked_cuda.py +5 -2
- nabu/pipeline/fullfield/nabu_config.py +17 -11
- nabu/pipeline/fullfield/processconfig.py +8 -2
- nabu/pipeline/fullfield/reconstruction.py +3 -0
- nabu/pipeline/params.py +12 -0
- nabu/pipeline/tests/test_estimators.py +240 -3
- nabu/preproc/ccd.py +53 -3
- nabu/preproc/flatfield.py +306 -1
- nabu/preproc/shift.py +3 -1
- nabu/preproc/tests/test_pcaflats.py +154 -0
- nabu/processing/rotation_cuda.py +3 -1
- nabu/processing/tests/test_rotation.py +4 -2
- nabu/reconstruction/fbp.py +7 -0
- nabu/reconstruction/fbp_base.py +31 -7
- nabu/reconstruction/fbp_opencl.py +8 -0
- nabu/reconstruction/filtering_opencl.py +2 -0
- nabu/reconstruction/mlem.py +51 -14
- nabu/reconstruction/tests/test_filtering.py +13 -2
- nabu/reconstruction/tests/test_mlem.py +91 -62
- nabu/resources/dataset_analyzer.py +144 -20
- nabu/resources/nxflatfield.py +101 -35
- nabu/resources/tests/test_nxflatfield.py +1 -1
- nabu/resources/utils.py +16 -10
- nabu/stitching/alignment.py +7 -7
- nabu/stitching/config.py +22 -20
- nabu/stitching/definitions.py +2 -2
- nabu/stitching/overlap.py +4 -4
- nabu/stitching/sample_normalization.py +5 -5
- nabu/stitching/stitcher/post_processing.py +5 -3
- nabu/stitching/stitcher/pre_processing.py +24 -20
- nabu/stitching/tests/test_config.py +3 -3
- nabu/stitching/tests/test_y_preprocessing_stitching.py +11 -8
- nabu/stitching/tests/test_z_postprocessing_stitching.py +2 -2
- nabu/stitching/tests/test_z_preprocessing_stitching.py +23 -20
- nabu/stitching/utils/utils.py +7 -7
- nabu/testutils.py +1 -4
- nabu/utils.py +13 -0
- {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/METADATA +3 -4
- {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/RECORD +62 -57
- {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/WHEEL +1 -1
- {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/entry_points.txt +2 -1
- nabu/app/correct_rot.py +0 -62
- {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/licenses/LICENSE +0 -0
- {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,557 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
from multiprocessing.pool import ThreadPool
|
3
|
+
import numpy as np
|
4
|
+
from ..utils import get_num_threads
|
5
|
+
|
6
|
+
|
7
|
+
try:
|
8
|
+
from skimage.registration import phase_cross_correlation
|
9
|
+
except ImportError:
|
10
|
+
phase_cross_correlation = None
|
11
|
+
from .translation import DetectorTranslationAlongBeam as RadiosShiftEstimator, estimate_shifts
|
12
|
+
|
13
|
+
try:
|
14
|
+
import matplotlib.pyplot as plt
|
15
|
+
except ImportError:
|
16
|
+
plt = None
|
17
|
+
|
18
|
+
|
19
|
+
class PairType(Enum):
|
20
|
+
OPPOSITE = 1
|
21
|
+
RETURN = 0
|
22
|
+
|
23
|
+
|
24
|
+
class MotionEstimation:
|
25
|
+
"""
|
26
|
+
A class for estimating rigid sample motion during acquisition.
|
27
|
+
The motion is estimated by
|
28
|
+
1. Measuring the translations between radios (either opposite radios for 360-degrees scan ; or "reference radios")
|
29
|
+
2. Fitting these measured translations with a displacement model.
|
30
|
+
|
31
|
+
For (1), there are various available functions to estimate the shift between projections.
|
32
|
+
The workhorse is phase cross-correlation, but this class allows to use other functions.
|
33
|
+
For (2), you can pick several displacement model. The default one is a low-degree polynomial.
|
34
|
+
|
35
|
+
Once the displacement model is computed, you have the displacement in sample reference frame,
|
36
|
+
and you can "project" these displacements in the detector reference frame.
|
37
|
+
Having the displacements converted as detector shifts allows you to
|
38
|
+
(a) assess the fit between displacement model, and measured detector shifts
|
39
|
+
(b) get a list movements to correct during reconstruction, vertical and/or horizontal.
|
40
|
+
In nabu pipeline, this is handled by "translation_movements_file" in [reconstruction] section.
|
41
|
+
"""
|
42
|
+
|
43
|
+
_shifts_estimators_default_kwargs = {
|
44
|
+
"phase_cross_correlation": {"upsample_factor": 10, "overlap_ratio": 0.3},
|
45
|
+
"DetectorTranslationAlongBeam": {"peak_fit_radius": 10, "padding_mode": "edge"},
|
46
|
+
"estimate_shifts": {},
|
47
|
+
}
|
48
|
+
|
49
|
+
def __init__(
|
50
|
+
self,
|
51
|
+
projs_stack1,
|
52
|
+
projs_stack2,
|
53
|
+
angles1_rad,
|
54
|
+
angles2_rad,
|
55
|
+
indices1=None,
|
56
|
+
indices2=None,
|
57
|
+
n_projs_tot=None,
|
58
|
+
n_calc_threads=None,
|
59
|
+
reference="begin",
|
60
|
+
shifts_estimator="phase_cross_correlation",
|
61
|
+
shifts_estimator_kwargs=None,
|
62
|
+
):
|
63
|
+
"""
|
64
|
+
Parameters
|
65
|
+
----------
|
66
|
+
projs_stack1: numpy.ndarray
|
67
|
+
Stack of projections, with shape (n_projs, n_rows, n_cols).
|
68
|
+
It has to be of the same shape as 'projs_stack2'.
|
69
|
+
Projection number 'i' in 'projs_stack1' will be compared with projection number 'i' in 'projs_stack2'
|
70
|
+
projs_stack2: numpy.ndarray
|
71
|
+
Stack of projections, with shape (n_projs, n_rows, n_cols).
|
72
|
+
It has to be of the same shape as 'projs_stack1'.
|
73
|
+
Projection number 'i' in 'projs_stack1' will be compared with projection number 'i' in 'projs_stack2'
|
74
|
+
angles1_rad: numpy.ndarray or list of float
|
75
|
+
Angles (in radians) of each projection, i.e, projection number 'i' in 'projs_stack1' was acquired at angle theta=angles1_rad[i]
|
76
|
+
angles2_rad: numpy.ndarray or list of float
|
77
|
+
Angles (in radians) of each projection, i.e, projection number 'i' in 'projs_stack2' was acquired at angle theta=angles2_rad[i]
|
78
|
+
indices1: numpy.ndarray or list of int, optional
|
79
|
+
Indices corresponding to each projection in 'projs_stack1'.
|
80
|
+
It is used to calculate the curvilinear coordinate for the fit.
|
81
|
+
indices2: numpy.ndarray or list of int, optional
|
82
|
+
Indices corresponding to each projection in 'projs_stack2'.
|
83
|
+
It is used to calculate the curvilinear coordinate for the fit.
|
84
|
+
It is mostly important if projections in 'projs_stack2' are "return projections" (see Notes below for what this means)
|
85
|
+
n_calc_threads: int, optional
|
86
|
+
Number of threads to use for calculating phase cross correlation on pairs of images.
|
87
|
+
Default is to use all available threads.
|
88
|
+
|
89
|
+
Notes
|
90
|
+
-----
|
91
|
+
"Return projections" is the name of extra projections that might be acquired at the end of the scan.
|
92
|
+
For example, for a 180-degrees scan with rotation angles [0, ..., 179.8], extra projections can be saved
|
93
|
+
at angles [180, 90, 0] (the rotation stage rewinds to its original angular position).
|
94
|
+
These extra projection serve to check whether the sample/stage moved during the scan.
|
95
|
+
In this case, angles2_rad = np.radians([180, 90, 0]).
|
96
|
+
|
97
|
+
This class works by fitting the measured displacements (in detector space) with a model.
|
98
|
+
This model uses a "normalized coordinate" built upon projection angles & indices.
|
99
|
+
Each pair of radios (projs_stack1[i], projs_stack2[i]) has angles (angles1_rad[i], angles2_rad[i])
|
100
|
+
and normalized coordinates (a1[i], a2[i])
|
101
|
+
where a1 = normalize_coordinates(angles1_rad) and a2 = normalize_coordinates(angles2_rad)
|
102
|
+
"""
|
103
|
+
self._set_projs_pairs(projs_stack1, projs_stack2, angles1_rad, angles2_rad)
|
104
|
+
self._setup_normalized_coordinates(indices1, indices2, reference, n_projs_tot)
|
105
|
+
self._configure_shifts_estimator(shifts_estimator, shifts_estimator_kwargs)
|
106
|
+
|
107
|
+
self.e_theta = np.array([np.cos(self.angles1), -np.sin(self.angles1)]).T
|
108
|
+
self._n_threads = n_calc_threads or max(1, get_num_threads() // 2)
|
109
|
+
|
110
|
+
# Default value before fit
|
111
|
+
self.shifts_vu = None
|
112
|
+
self._coeffs = None
|
113
|
+
self._coeffs_v = None
|
114
|
+
self._deg_xy = None
|
115
|
+
self._deg_z = None
|
116
|
+
|
117
|
+
def _set_projs_pairs(self, projs_stack1, projs_stack2, angles1, angles2):
|
118
|
+
if projs_stack1.shape != projs_stack2.shape:
|
119
|
+
raise ValueError(
|
120
|
+
f"'projs_stack1' and 'projs_stack2' must have the same shape - have {projs_stack1.shape} and {projs_stack2.shape}"
|
121
|
+
)
|
122
|
+
if len(angles1) != len(angles2):
|
123
|
+
raise ValueError("'angles1' and 'angles2' must have the same size")
|
124
|
+
if len(angles1) != projs_stack1.shape[0] or len(angles2) != projs_stack2.shape[0]:
|
125
|
+
raise ValueError(
|
126
|
+
"There must be as many values in (angles1, angles2) as there are projections in (projs_stack1, projs_stack2"
|
127
|
+
)
|
128
|
+
self.projs_stack1 = projs_stack1
|
129
|
+
self.projs_stack2 = projs_stack2
|
130
|
+
self.angles1 = np.array(angles1)
|
131
|
+
self.angles2 = np.array(angles2)
|
132
|
+
self.n_pairs = self.angles1.size
|
133
|
+
# Now determine if the angular spacing between projs_stack1[i] and projs_stack1[i]
|
134
|
+
self.pair_types = []
|
135
|
+
gaps = []
|
136
|
+
for i, (a1, a2) in enumerate(zip(self.angles1, self.angles2)):
|
137
|
+
gap = np.mod(np.abs(a2 - a1), 2 * np.pi)
|
138
|
+
if np.isclose(gap, 0, atol=1e-1):
|
139
|
+
# same angle: probably "return projection"
|
140
|
+
self.pair_types.append(PairType.RETURN)
|
141
|
+
elif np.isclose(gap, np.pi, atol=1e-1):
|
142
|
+
# opposed by 180 degrees
|
143
|
+
self.pair_types.append(PairType.OPPOSITE)
|
144
|
+
else:
|
145
|
+
raise ValueError(
|
146
|
+
f"Projections pair number {i} are spaced by {np.degrees(gap)} degrees - don't know what to do with them"
|
147
|
+
)
|
148
|
+
gaps.append(gap)
|
149
|
+
if np.std([pt.value for pt in self.pair_types]) > 0:
|
150
|
+
raise NotImplementedError(
|
151
|
+
"Mixing pairs (projection, opposite_projection) and (projection, return_projection) is not supported"
|
152
|
+
)
|
153
|
+
self.is_return = any([pt == PairType.RETURN for pt in self.pair_types])
|
154
|
+
self.gaps = np.array(gaps)
|
155
|
+
self._pair_types = np.array([pt.value for pt in self.pair_types])
|
156
|
+
|
157
|
+
def _setup_normalized_coordinates(self, indices1, indices2, reference, n_projs_tot):
|
158
|
+
self.indices1 = np.array(indices1) if indices1 is not None else None
|
159
|
+
self.indices2 = np.array(indices2) if indices2 is not None else None
|
160
|
+
self.n_projs_tot = n_projs_tot
|
161
|
+
if n_projs_tot is None and indices1 is not None:
|
162
|
+
self.n_projs_tot = np.max(indices1) # best guess
|
163
|
+
self.reference = reference
|
164
|
+
# TODO find a better way
|
165
|
+
distance_to_pi = abs(self.angles2.max() - np.pi)
|
166
|
+
distance_to_twopi = abs(self.angles2.max() - 2 * np.pi)
|
167
|
+
self._angle_max = 2 * np.pi if distance_to_twopi < distance_to_pi else np.pi
|
168
|
+
self.a1 = self.normalize_coordinates(self.angles1, part=1)
|
169
|
+
self.a2 = self.normalize_coordinates(self.angles2, part=2)
|
170
|
+
self.a_all = np.concatenate([self.a1, self.a2])
|
171
|
+
|
172
|
+
def normalize_coordinates(self, angles, part=1):
|
173
|
+
"""
|
174
|
+
Get the "curvilinear coordinates" that are used (instead of projection angles in radians or degrees) for fit.
|
175
|
+
These coordinates depend on:
|
176
|
+
- how we normalize (wrt total number of angles, or wrt angle max)
|
177
|
+
- the reference projection (start or end)
|
178
|
+
|
179
|
+
Parameters
|
180
|
+
----------
|
181
|
+
angles: array
|
182
|
+
Array with projection angles
|
183
|
+
part: int, optional
|
184
|
+
Which part of the scan the provided angles belong to.
|
185
|
+
Using "part=1" (resp. part=2) means that these angles correspond to "angles1_rad" (resp. angles2_rad)
|
186
|
+
|
187
|
+
"""
|
188
|
+
# Currently this will work for
|
189
|
+
# - 360° scan with only pairs of opposite projs
|
190
|
+
# - 180° scan + pairs of return projections only (we normalize only the angles of regular projs)
|
191
|
+
# TODO consider other normalizations
|
192
|
+
if part not in [1, 2]:
|
193
|
+
raise ValueError("Expected 'part' to be either 1 or 2")
|
194
|
+
|
195
|
+
def _normalize_with_reference(a):
|
196
|
+
if self.reference == "end":
|
197
|
+
return 1 - a
|
198
|
+
return a
|
199
|
+
|
200
|
+
# "Outward projections", always normalize with angle max
|
201
|
+
if part == 1:
|
202
|
+
return _normalize_with_reference(angles / self._angle_max)
|
203
|
+
|
204
|
+
# Opposite projections (360° scan), normalize also with angle max
|
205
|
+
elif not (self.is_return):
|
206
|
+
return _normalize_with_reference(angles / self._angle_max)
|
207
|
+
# Now, here, we are in the case "180° scan with return projections"
|
208
|
+
# - The regular projections have coordinate [0, ..., 1] (or almost 1)
|
209
|
+
# - The subsequent return projections have coordinates > 1: [1.01, ...]
|
210
|
+
# We assume that "stack1" is all outward projs, "stack2" is all return projs (see NotImplementedError in _set_projs_pairs)
|
211
|
+
n_outward_projs = self.angles1.size
|
212
|
+
n_return_projs = self.angles2.size
|
213
|
+
if self.indices2 is not None and self.n_projs_tot is not None:
|
214
|
+
return _normalize_with_reference(self.indices2 / self.n_projs_tot)
|
215
|
+
else:
|
216
|
+
# This case is tricky, we should probably throw an error
|
217
|
+
a_all = np.arange(n_outward_projs + n_return_projs) / n_outward_projs
|
218
|
+
return _normalize_with_reference(a_all[-n_outward_projs:])
|
219
|
+
|
220
|
+
def _configure_shifts_estimator(self, shifts_estimator, shifts_estimator_kwargs):
|
221
|
+
self.shifts_estimator = shifts_estimator
|
222
|
+
if shifts_estimator not in self._shifts_estimators_default_kwargs:
|
223
|
+
raise NotImplementedError(
|
224
|
+
f"Unknown estimator shifts '{shifts_estimator}', available are {list(self._shifts_estimators_default_kwargs.keys())}"
|
225
|
+
)
|
226
|
+
self._shifts_estimator_kwargs = self._shifts_estimators_default_kwargs[shifts_estimator].copy()
|
227
|
+
self._shifts_estimator_kwargs.update(shifts_estimator_kwargs or {})
|
228
|
+
|
229
|
+
def _find_shifts(self, img1, img2):
|
230
|
+
if self.shifts_estimator == "estimate_shifts":
|
231
|
+
# estimate_shifts recovers the shifts in scipy convention (scipy.ndimage.shift),
|
232
|
+
# but there is a sign difference wrt scikit-image.
|
233
|
+
return -estimate_shifts(img1, img2, **self._shifts_estimator_kwargs)
|
234
|
+
elif self.shifts_estimator == "phase_cross_correlation" and phase_cross_correlation is not None:
|
235
|
+
return phase_cross_correlation(img1, img2, **self._shifts_estimator_kwargs)[0]
|
236
|
+
else:
|
237
|
+
return RadiosShiftEstimator().find_shift(np.stack([img1, img2]), [0, 1], **self._shifts_estimator_kwargs)
|
238
|
+
|
239
|
+
def compute_detector_shifts(self):
|
240
|
+
"""This function computes the shifts between two images of all pairs."""
|
241
|
+
|
242
|
+
def _comp_shift(i):
|
243
|
+
img2 = self.projs_stack2[i]
|
244
|
+
if self.pair_types[i] == PairType.OPPOSITE:
|
245
|
+
img2 = img2[:, ::-1]
|
246
|
+
return self._find_shifts(self.projs_stack1[i], img2)
|
247
|
+
|
248
|
+
with ThreadPool(self._n_threads) as tp:
|
249
|
+
shifts = tp.map(_comp_shift, range(self.n_pairs))
|
250
|
+
|
251
|
+
self.shifts_vu = np.array(shifts)
|
252
|
+
|
253
|
+
def _compute_detector_shifts_if_needed(self, recalculate_shifts):
|
254
|
+
if recalculate_shifts or (self.shifts_vu is None):
|
255
|
+
self.compute_detector_shifts()
|
256
|
+
|
257
|
+
def get_model_matrix(self, do_cor_estimation=True, degree=1):
|
258
|
+
"""
|
259
|
+
Compute the model matrix for horizontal components (x, y)
|
260
|
+
For degree 1:
|
261
|
+
M = [cos(theta) * (a^+ - a) ; -sin(theta) * (a^+ - a) ; 2 ]
|
262
|
+
This matrix needs three ingredients:
|
263
|
+
- The angles "theta" for which pairs of radios are compared. We take the "angles1_rad" provided at this class instanciation.
|
264
|
+
- The corresponding normalized coordinate "a" : a = normalize_coordinates(angles1_rad)
|
265
|
+
- Normalized coordinate "a_plus" of each second radio in a pair.
|
266
|
+
|
267
|
+
In other words, the pair of radios (projs_stack1[i], projs_stack2[i]) have angles (angles1_rad[i], angles2_rad[i])
|
268
|
+
and normalized coordinates (a[i], a_plus[i])
|
269
|
+
|
270
|
+
The resulting matrix strongly depends on how the angles are ordered/normalized.
|
271
|
+
"""
|
272
|
+
angles = self.angles1
|
273
|
+
cos_theta = np.cos(angles)
|
274
|
+
sin_theta = np.sin(angles)
|
275
|
+
ap = self.a2
|
276
|
+
a = self.a1
|
277
|
+
|
278
|
+
M = np.zeros((self.n_pairs, 2 * degree + int(do_cor_estimation)), dtype=np.float64)
|
279
|
+
i_col = 0
|
280
|
+
for d in range(degree, 0, -1): # eg. [2, 1]. No 0 !
|
281
|
+
columns = np.vstack([(ap**d - a**d) * cos_theta, -(ap**d - a**d) * sin_theta]).T
|
282
|
+
M[:, i_col : i_col + 2] = columns
|
283
|
+
i_col += 2
|
284
|
+
if do_cor_estimation:
|
285
|
+
M[:, -1] = 2
|
286
|
+
return M
|
287
|
+
|
288
|
+
def _get_vdm_matrix(self, a, degree):
|
289
|
+
vdm_mat = np.stack([a**d for d in range(degree, 0, -1)], axis=1)
|
290
|
+
return vdm_mat
|
291
|
+
|
292
|
+
def apply_fit_horiz(self, angles=None, angles_normalized=None):
|
293
|
+
"""
|
294
|
+
Apply the fitted parameters to get the sample displacement in (x, y)
|
295
|
+
|
296
|
+
Parameters
|
297
|
+
-----------
|
298
|
+
angles: array, optional
|
299
|
+
Angles the fit is applied onto.
|
300
|
+
angles_normalized: array, optional
|
301
|
+
Normalized angles the fit is applied onto. If provided, takes precedence over 'angles' (see notes below)
|
302
|
+
|
303
|
+
Returns
|
304
|
+
-------
|
305
|
+
txy: array
|
306
|
+
Array of shape (n_provided_angles, 2) where txy[:, 0] is the motion x-component, and txy[:, 1] is the motion y-component
|
307
|
+
|
308
|
+
Notes
|
309
|
+
------
|
310
|
+
The fit is assumed to have been done beforehand on a series of detector shifts measurements.
|
311
|
+
Once the fit is done, coefficients are extracted and stored by this class instance.
|
312
|
+
The parameter 'angles' provided to this function is normalized before applying the fit.
|
313
|
+
For degree 2, applying the fit is roughly: t_x = alpha_x * a^2 + beta_x * a
|
314
|
+
where 'a' is the normalized angle coordinate, and (alpha_x, beta_x) the coefficients extracted from the fit.
|
315
|
+
"""
|
316
|
+
if self._coeffs is None:
|
317
|
+
raise RuntimeError("Need to do estimate_horizontal_motion() first")
|
318
|
+
if angles is None and angles_normalized is None:
|
319
|
+
raise ValueError("Need to provide either 'angles' or 'angles_normalized'")
|
320
|
+
if angles_normalized is None:
|
321
|
+
angles_normalized = self.normalize_coordinates(angles)
|
322
|
+
|
323
|
+
# See get_model_matrix()
|
324
|
+
end = -1 if self._coeffs.size % 2 == 1 else None
|
325
|
+
coeffs_x = self._coeffs[:end:2]
|
326
|
+
coeffs_y = self._coeffs[1:end:2]
|
327
|
+
|
328
|
+
vdm_mat = self._get_vdm_matrix(angles_normalized, self._deg_xy)
|
329
|
+
return np.stack([vdm_mat.dot(coeffs_x), vdm_mat.dot(coeffs_y)], axis=1)
|
330
|
+
|
331
|
+
def apply_fit_vertic(self, angles=None, angles_normalized=None):
|
332
|
+
if self._coeffs_v is None:
|
333
|
+
raise RuntimeError("Need to do estimate_vertical_motion() first")
|
334
|
+
if angles is None and angles_normalized is None:
|
335
|
+
raise ValueError("Need to provide either 'angles' or 'angles_normalized'")
|
336
|
+
if angles_normalized is None:
|
337
|
+
angles_normalized = self.normalize_coordinates(angles)
|
338
|
+
vdm_mat = self._get_vdm_matrix(angles_normalized, self._deg_z)
|
339
|
+
coeffs_z = self._coeffs_v
|
340
|
+
return vdm_mat.dot(coeffs_z)
|
341
|
+
|
342
|
+
def estimate_horizontal_motion(self, degree=1, cor=None, recalculate_shifts=False):
|
343
|
+
"""
|
344
|
+
Estimation of the horizontal motion component.
|
345
|
+
|
346
|
+
Parameters
|
347
|
+
-----------
|
348
|
+
degree: int (default=1).
|
349
|
+
Degree of the polynomial model of the motion in the horizontal plane,
|
350
|
+
for both x and y components.
|
351
|
+
cor: float, optional
|
352
|
+
Center of rotation, relative to the middle of the image.
|
353
|
+
If None (default), it will be estimated along with the horizontal movement components.
|
354
|
+
If provided (scalar value), use this value and estimate only the horizontal movement components.
|
355
|
+
recalculate_shifts: bool, optional
|
356
|
+
Whether to re-calculate detector shifts (usually with phase cross-correlation) if already calculated
|
357
|
+
"""
|
358
|
+
do_cor_estimation = cor is None
|
359
|
+
|
360
|
+
# Get "Delta u" estimated from pairs of projections
|
361
|
+
self._compute_detector_shifts_if_needed(recalculate_shifts)
|
362
|
+
|
363
|
+
# Build model matrix
|
364
|
+
M = self.get_model_matrix(do_cor_estimation=do_cor_estimation, degree=degree)
|
365
|
+
|
366
|
+
# Build parameters vector
|
367
|
+
b = self.shifts_vu[:, 1] if do_cor_estimation else self.shifts_vu[:, 1] - 2 * cor * self._pair_types
|
368
|
+
|
369
|
+
# Least-square fit, i.e coeffs = pinv(M) * b
|
370
|
+
self._coeffs = np.linalg.lstsq(M, b, rcond=None)[0]
|
371
|
+
self._deg_xy = degree
|
372
|
+
|
373
|
+
# Evaluate coefficients on current angles
|
374
|
+
txy = self.apply_fit_horiz(angles_normalized=self.a_all)
|
375
|
+
c = self._coeffs[-1] if do_cor_estimation else cor
|
376
|
+
|
377
|
+
return txy, c
|
378
|
+
|
379
|
+
estimate_cor_and_horizontal_motion = estimate_horizontal_motion
|
380
|
+
|
381
|
+
def estimate_vertical_motion(self, degree=1, recalculate_shifts=False):
|
382
|
+
"""Estimation of the motion vertical component."""
|
383
|
+
|
384
|
+
# Get "Delta v" estimated from pairs of projections
|
385
|
+
self._compute_detector_shifts_if_needed(recalculate_shifts)
|
386
|
+
|
387
|
+
# Model matrix is simple here: v = t_z (parallel geometry)
|
388
|
+
mat = np.zeros([self.n_pairs, degree])
|
389
|
+
for d in range(degree, 0, -1):
|
390
|
+
mat[:, degree - d] = self.a2**d - self.a1**d
|
391
|
+
self._coeffs_v = np.linalg.lstsq(mat, self.shifts_vu[:, 0], rcond=None)[0]
|
392
|
+
self._deg_z = degree
|
393
|
+
|
394
|
+
tz = self.apply_fit_vertic(angles_normalized=self.a_all)
|
395
|
+
return tz
|
396
|
+
|
397
|
+
def convert_sample_motion_to_detector_shifts(self, t_xy, t_z, angles, cor=0):
|
398
|
+
"""
|
399
|
+
Convert vectors of motion (t_x, t_y, t_z), from the sample domain,
|
400
|
+
to vectors of motion (t_u, t_v) in the detector domain
|
401
|
+
|
402
|
+
Parameters
|
403
|
+
----------
|
404
|
+
t_xy: numpy.ndarray
|
405
|
+
Sample horizontal shifts with shape (n_angles, 2).
|
406
|
+
The first (resp. second) column are the x-shifts (resp. y-shifts)
|
407
|
+
t_z: numpy.ndarray
|
408
|
+
Sample vertical shifts, with the size n_angles
|
409
|
+
angles: numpy.ndarray
|
410
|
+
Rotation angle (in degree) corresponding to each component
|
411
|
+
cor: float, optional
|
412
|
+
Center of rotation
|
413
|
+
"""
|
414
|
+
e_theta = np.array([np.cos(angles), -np.sin(angles)]).T
|
415
|
+
dotp = np.sum(t_xy * e_theta, axis=1)
|
416
|
+
shifts_u = dotp + cor
|
417
|
+
shifts_v = t_z.copy()
|
418
|
+
return np.stack([shifts_v, shifts_u], axis=1)
|
419
|
+
|
420
|
+
def apply_fit_to_get_detector_displacements(self, cor=None):
|
421
|
+
# This should be equal to:
|
422
|
+
# txy2 = self.apply_fit_horiz(angles_normalized=self.a2)
|
423
|
+
# txy1 = self.apply_fit_horiz(angles_normalized=self.a1)
|
424
|
+
# shifts_u = np.sum((txy2 - txy1) * self.e_theta, axis=1) + 2 * cor * (1 - self.is_return)
|
425
|
+
M = self.get_model_matrix(do_cor_estimation=(cor is None), degree=self._deg_xy)
|
426
|
+
shifts_u = M.dot(self._coeffs) + 2 * (cor if cor is not None and not (self.is_return) else 0)
|
427
|
+
|
428
|
+
tz2 = self.apply_fit_vertic(angles_normalized=self.a2)
|
429
|
+
tz1 = self.apply_fit_vertic(angles_normalized=self.a1)
|
430
|
+
shifts_v = tz2 - tz1
|
431
|
+
|
432
|
+
shifts_vu = np.stack([shifts_v, shifts_u], axis=1)
|
433
|
+
return shifts_vu
|
434
|
+
|
435
|
+
def get_max_fit_error(self, cor=None):
|
436
|
+
"""
|
437
|
+
Get the maximum error of the fit of displacement (in pixel units), projected in the detector domain ;
|
438
|
+
i.e compare shifts_u (measured via phase cross correlation) to M.dot(coeffs) (model fit)
|
439
|
+
"""
|
440
|
+
shifts_vu_modelled = self.apply_fit_to_get_detector_displacements(cor=cor)
|
441
|
+
err_max_vu = np.max(np.abs(shifts_vu_modelled - self.shifts_vu), axis=0)
|
442
|
+
return err_max_vu
|
443
|
+
|
444
|
+
def plot_detector_shifts(self, cor=None):
|
445
|
+
"""
|
446
|
+
Plot the detector shifts, i.e difference of u-movements between theta and theta^+.
|
447
|
+
This can be used to compare the fit against measured shifts between pairs of projections.
|
448
|
+
|
449
|
+
The sample movements were inferred from pairs of projections:
|
450
|
+
projs1[i] at angles1[i], and projs2[i] at angles2[i]
|
451
|
+
From these pairs of projections, a model of motion is built and we can generate:
|
452
|
+
- The motion in sample domain (tx(theta), ty(theta), tz(theta)) for arbitrary theta
|
453
|
+
- The motion in detector domain (t_u(theta), t_v(theta)) (for arbitrary theta) which is the parallel projection of the above
|
454
|
+
|
455
|
+
What was used for the fit is t_u(theta^+) - t_u(theta).
|
456
|
+
This function will plot this "difference" between t_u(theta^+) and t_u(theta).
|
457
|
+
"""
|
458
|
+
estimated_shifts_vu = self.apply_fit_to_get_detector_displacements(cor=cor)
|
459
|
+
gt_shifts_u = None
|
460
|
+
gt_shifts_v = None
|
461
|
+
|
462
|
+
fig, ax = plt.subplots(2)
|
463
|
+
t_axis_vals = np.degrees(self.angles1)
|
464
|
+
t_axis_label = "Angle (deg)"
|
465
|
+
|
466
|
+
# Horizontal diff-movements on detector
|
467
|
+
subplot = ax[0]
|
468
|
+
subplot.plot(t_axis_vals, self.shifts_vu[:, 1], ".", label="Measured (%s)" % self.shifts_estimator)
|
469
|
+
subplot.plot(t_axis_vals, estimated_shifts_vu[:, 1], ".-", label="Fit")
|
470
|
+
if gt_shifts_u is not None:
|
471
|
+
subplot.plot(self.angles1, gt_shifts_u, label="Ground Truth")
|
472
|
+
subplot.set_xlabel(t_axis_label)
|
473
|
+
subplot.set_ylabel("shift (pix)")
|
474
|
+
subplot.legend()
|
475
|
+
subplot.set_title("Horizontal shifts on the detector")
|
476
|
+
|
477
|
+
# Vertical diff-movements on detector
|
478
|
+
subplot = ax[1]
|
479
|
+
subplot.plot(t_axis_vals, self.shifts_vu[:, 0], ".", label="Measured (%s)" % self.shifts_estimator)
|
480
|
+
subplot.plot(t_axis_vals, estimated_shifts_vu[:, 0], ".-", label="Fit")
|
481
|
+
if gt_shifts_v is not None:
|
482
|
+
subplot.plot(t_axis_vals, gt_shifts_v, label="Ground truth")
|
483
|
+
subplot.set_xlabel(t_axis_label)
|
484
|
+
subplot.set_ylabel("shift (pix)")
|
485
|
+
subplot.legend()
|
486
|
+
subplot.set_title("Vertical shifts on the detector")
|
487
|
+
|
488
|
+
plt.show()
|
489
|
+
|
490
|
+
def plot_movements(self, cor=None, angles_rad=None, gt_xy=None, gt_z=None):
|
491
|
+
"""
|
492
|
+
Plot the movements in the sample and detector domain, for a given vector of angles.
|
493
|
+
Mind the difference with plot_sample_shifts(): in this case we plot proj(txy(theta))
|
494
|
+
whereas plot_sample_shifts() plots proj(txy(theta^+)) - proj(txy(theta)), which can be used to compare with measured shifts between projections.
|
495
|
+
"""
|
496
|
+
if plt is None:
|
497
|
+
raise ImportError("Need matplotlib")
|
498
|
+
if self._deg_xy is None:
|
499
|
+
raise RuntimeError("Need to estimate shifts first")
|
500
|
+
|
501
|
+
if angles_rad is None:
|
502
|
+
angles_rad = self.angles1
|
503
|
+
angles_deg = np.degrees(angles_rad)
|
504
|
+
|
505
|
+
txy = self.apply_fit_horiz(angles=angles_rad)
|
506
|
+
tz = self.apply_fit_vertic(angles=angles_rad)
|
507
|
+
estimated_shifts_vu = self.convert_sample_motion_to_detector_shifts(txy, tz, angles_rad, cor=cor or 0)
|
508
|
+
|
509
|
+
gt_shifts_vu = None
|
510
|
+
if gt_xy is not None:
|
511
|
+
if gt_z is None or gt_z.shape[0] != gt_xy.shape[0] or gt_xy.shape[0] != len(angles_rad):
|
512
|
+
raise ValueError("gt_xy has to be provided with gt_z, of the same size as 'angles_rad'")
|
513
|
+
gt_shifts_vu = self.convert_sample_motion_to_detector_shifts(gt_xy, gt_z, angles_rad, cor=cor or 0)
|
514
|
+
|
515
|
+
fig, ax = plt.subplots(2, 2)
|
516
|
+
|
517
|
+
# Horizontal movements on detector
|
518
|
+
subplot = ax[0, 0]
|
519
|
+
subplot.plot(angles_deg, estimated_shifts_vu[:, 1], ".-", label="Fit")
|
520
|
+
if gt_shifts_vu is not None:
|
521
|
+
subplot.plot(angles_deg, gt_shifts_vu[:, 1], label="Ground Truth")
|
522
|
+
subplot.set_xlabel("Angle (deg)")
|
523
|
+
subplot.set_ylabel("shift (pix)")
|
524
|
+
subplot.legend()
|
525
|
+
subplot.set_title("Horizontal movements projected onto the detector")
|
526
|
+
|
527
|
+
# Vertical movements on detector
|
528
|
+
subplot = ax[1, 0]
|
529
|
+
subplot.plot(angles_deg, estimated_shifts_vu[:, 0], ".-", label="Fit")
|
530
|
+
if gt_shifts_vu is not None:
|
531
|
+
subplot.plot(angles_deg, gt_shifts_vu[:, 0], label="Ground truth")
|
532
|
+
subplot.set_xlabel("Angle (deg)")
|
533
|
+
subplot.set_ylabel("shift (pix)")
|
534
|
+
subplot.legend()
|
535
|
+
subplot.set_title("Vertical movements projected onto the detector")
|
536
|
+
|
537
|
+
# Horizontal movements in sample domain
|
538
|
+
subplot = ax[0, 1]
|
539
|
+
subplot.scatter(txy[:, 0], txy[:, 1], label="Fit", s=1)
|
540
|
+
if gt_xy is not None:
|
541
|
+
subplot.scatter(gt_xy[:, 0], gt_xy[:, 1], label="Ground Truth", s=1)
|
542
|
+
subplot.set_xlabel("x (pix)")
|
543
|
+
subplot.set_ylabel("y (pix)")
|
544
|
+
subplot.legend()
|
545
|
+
subplot.set_title("Sample movement in horizontal plane")
|
546
|
+
|
547
|
+
# Vertical movements in sample domain
|
548
|
+
subplot = ax[1, 1]
|
549
|
+
subplot.plot(angles_deg, tz, ".-", label="Fit")
|
550
|
+
if gt_z is not None:
|
551
|
+
subplot.plot(angles_deg, gt_z, ".", label="Ground truth")
|
552
|
+
subplot.set_xlabel("Angle (deg)")
|
553
|
+
subplot.set_ylabel("z position (pix)")
|
554
|
+
subplot.legend()
|
555
|
+
subplot.set_title("Sample vertical movement")
|
556
|
+
|
557
|
+
plt.show()
|