nabu 2025.1.0.dev14__py3-none-any.whl → 2025.1.0rc2__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.
- doc/doc_config.py +32 -0
- nabu/__init__.py +1 -1
- nabu/app/cast_volume.py +9 -1
- nabu/app/cli_configs.py +80 -3
- 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 +100 -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 +20 -14
- nabu/pipeline/fullfield/processconfig.py +17 -3
- nabu/pipeline/fullfield/reconstruction.py +4 -1
- 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/astra.py +245 -0
- 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 +47 -13
- 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.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/METADATA +3 -4
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/RECORD +64 -57
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/WHEEL +1 -1
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/entry_points.txt +2 -1
- nabu/app/correct_rot.py +0 -62
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/licenses/LICENSE +0 -0
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,471 @@
|
|
1
|
+
from math import cos, sin
|
2
|
+
import numpy as np
|
3
|
+
from nabu.utils import search_sorted
|
4
|
+
from nabu.estimation.motion import MotionEstimation
|
5
|
+
from nabu.estimation.translation import estimate_shifts
|
6
|
+
from nabu.testutils import get_data, __do_long_tests__
|
7
|
+
from scipy.ndimage import shift
|
8
|
+
import pytest
|
9
|
+
|
10
|
+
try:
|
11
|
+
import astra
|
12
|
+
except (ImportError, RuntimeError):
|
13
|
+
astra = None
|
14
|
+
|
15
|
+
|
16
|
+
def test_estimate_shifts():
|
17
|
+
image = get_data("chelsea.npz")["data"]
|
18
|
+
n_tests = 10
|
19
|
+
shift_min = -50
|
20
|
+
shift_max = 50
|
21
|
+
max_tol = 0.2 # in pixel unit
|
22
|
+
shifts = np.random.rand(n_tests, 2) * (shift_max - shift_min) - shift_min
|
23
|
+
for s in shifts:
|
24
|
+
image_shifted = shift(image, s)
|
25
|
+
estimated_shifts = estimate_shifts(image, image_shifted)
|
26
|
+
abs_diff = np.abs(np.array(estimated_shifts) - np.array(s))
|
27
|
+
assert np.all(abs_diff < max_tol), "Wrong estimation for shifts=%s : got %s" % (s, estimated_shifts)
|
28
|
+
|
29
|
+
|
30
|
+
def _bootstrap_test_motion_combined(cls):
|
31
|
+
cls._fdesc = get_data("motion/test_correct_motion_combined.npz")
|
32
|
+
cls.projs = cls._fdesc["projs"]
|
33
|
+
cls.angles = cls._fdesc["angles_rad"]
|
34
|
+
cls.shifts = cls._fdesc["shifts"]
|
35
|
+
cls.cor = -2.81 # center of rotation in pixel units, (relative to the middle of the detector)
|
36
|
+
if __do_long_tests__:
|
37
|
+
cls._fdesc = get_data("motion/test_correct_motion_horizontal.npz")
|
38
|
+
cls.projs_horiz = cls._fdesc["projs"]
|
39
|
+
cls.angles_horiz = cls._fdesc["angles_rad"]
|
40
|
+
cls.shifts_horiz = cls._fdesc["shifts"]
|
41
|
+
cls._fdesc = get_data("motion/test_correct_motion_vertical.npz")
|
42
|
+
cls.projs_vertic = cls._fdesc["projs"]
|
43
|
+
cls.angles_vertic = cls._fdesc["angles_rad"]
|
44
|
+
cls.shifts_vertic = cls._fdesc["shifts"]
|
45
|
+
|
46
|
+
|
47
|
+
@pytest.fixture(scope="class")
|
48
|
+
def bootstrap_test_motion_combined(request):
|
49
|
+
cls = request.cls
|
50
|
+
_bootstrap_test_motion_combined(cls)
|
51
|
+
|
52
|
+
|
53
|
+
@pytest.mark.usefixtures("bootstrap_test_motion_combined")
|
54
|
+
class TestMotionEstimation:
|
55
|
+
|
56
|
+
def _test_estimate_motion_360(
|
57
|
+
self,
|
58
|
+
projs,
|
59
|
+
angles,
|
60
|
+
reference_shifts,
|
61
|
+
deg=2,
|
62
|
+
deg_z=2,
|
63
|
+
tol_x=1e-5,
|
64
|
+
tol_y=1e-5,
|
65
|
+
tol_z=1e-5,
|
66
|
+
estimate_cor=False,
|
67
|
+
verbose=True,
|
68
|
+
):
|
69
|
+
"""
|
70
|
+
Test MotionEstimation using pairs of projs
|
71
|
+
"""
|
72
|
+
n_a = projs.shape[0]
|
73
|
+
projs1 = projs[: n_a // 2]
|
74
|
+
projs2 = projs[n_a // 2 :]
|
75
|
+
angles1 = angles[: n_a // 2]
|
76
|
+
angles2 = angles[n_a // 2 :]
|
77
|
+
estimator = MotionEstimation(projs1, projs2, angles1, angles2, shifts_estimator="DetectorTranslationAlongBeam")
|
78
|
+
estimated_shifts_v = estimator.estimate_vertical_motion(degree=deg_z)
|
79
|
+
cor = None if estimate_cor else self.cor
|
80
|
+
estimated_shifts_h, cor_est = estimator.estimate_horizontal_motion(degree=deg, cor=cor)
|
81
|
+
|
82
|
+
shifts = reference_shifts
|
83
|
+
shifts_xy = -shifts[:, :2]
|
84
|
+
shifts_z = -shifts[:, 2]
|
85
|
+
|
86
|
+
# mind the signs, nabu vs RTK geometry
|
87
|
+
abs_diff_x = np.max(np.abs(estimated_shifts_h[:, 0] - shifts_xy[:360, 0]))
|
88
|
+
abs_diff_y = np.max(np.abs(estimated_shifts_h[:, 1] - shifts_xy[:360, 1]))
|
89
|
+
abs_diff_z = np.max(np.abs(estimated_shifts_v - shifts_z[:360]))
|
90
|
+
|
91
|
+
if verbose:
|
92
|
+
estimator.plot_detector_shifts(cor=cor)
|
93
|
+
estimator.plot_movements(cor=cor, angles_rad=angles, gt_xy=shifts_xy, gt_z=shifts_z)
|
94
|
+
|
95
|
+
assert abs_diff_x < tol_x, "Wrong x-movement estimation (estimate_cor=%s)" % estimate_cor
|
96
|
+
assert abs_diff_y < tol_y, "Wrong y-movement estimation (estimate_cor=%s)" % estimate_cor
|
97
|
+
assert abs_diff_z < tol_z, "Wrong z-movement estimation (estimate_cor=%s)" % estimate_cor
|
98
|
+
|
99
|
+
def test_estimate_motion_360(self, verbose=False):
|
100
|
+
"""
|
101
|
+
Test with a synthetic dataset that underwent horizontal AND vertical translations
|
102
|
+
"""
|
103
|
+
params_dict = {
|
104
|
+
"estimate_cor": [False, True],
|
105
|
+
"tol_x": [0.05, 0.1],
|
106
|
+
"tol_y": [0.05, 0.2],
|
107
|
+
"tol_z": [0.005, 0.01],
|
108
|
+
}
|
109
|
+
params_names = params_dict.keys()
|
110
|
+
params_values = np.array(list(params_dict.values()), dtype="O")
|
111
|
+
for i in range(params_values.shape[1]):
|
112
|
+
params = {k: v for k, v in zip(params_names, params_values[:, i].tolist())}
|
113
|
+
self._test_estimate_motion_360(
|
114
|
+
self.projs[:360],
|
115
|
+
self.angles[:360],
|
116
|
+
self.shifts[:360],
|
117
|
+
verbose=verbose,
|
118
|
+
**params,
|
119
|
+
)
|
120
|
+
|
121
|
+
def test_estimate_motion_360_return(self, verbose=False):
|
122
|
+
return_angles = np.deg2rad([360, 270, 180, 90, 0])
|
123
|
+
outward_angles_indices = np.array([search_sorted(self.angles[:360], ra) for ra in return_angles])
|
124
|
+
outward_angles = self.angles[outward_angles_indices]
|
125
|
+
projs1 = self.projs[outward_angles_indices]
|
126
|
+
projs2 = self.projs[-5:]
|
127
|
+
|
128
|
+
estimator = MotionEstimation(
|
129
|
+
projs1,
|
130
|
+
projs2,
|
131
|
+
outward_angles,
|
132
|
+
return_angles,
|
133
|
+
indices1=outward_angles_indices,
|
134
|
+
indices2=np.arange(self.projs.shape[0])[-5:],
|
135
|
+
n_projs_tot=self.projs.shape[0],
|
136
|
+
)
|
137
|
+
estimated_shifts_z = estimator.estimate_vertical_motion(degree=2)
|
138
|
+
estimated_shifts_xy, c = estimator.estimate_horizontal_motion(degree=2, cor=self.cor)
|
139
|
+
|
140
|
+
shifts = self.shifts
|
141
|
+
gt_xy = -shifts[:, :2][:-5]
|
142
|
+
gt_z = -shifts[:, 2][:-5]
|
143
|
+
|
144
|
+
if verbose:
|
145
|
+
estimator.plot_detector_shifts(cor=self.cor)
|
146
|
+
estimator.plot_movements(cor=self.cor, angles_rad=self.angles[:-5], gt_xy=gt_xy, gt_z=gt_z)
|
147
|
+
|
148
|
+
estimated_shifts_xy = estimator.apply_fit_horiz(angles=self.angles[:-5])
|
149
|
+
estimated_shifts_z = estimator.apply_fit_vertic(angles=self.angles[:-5])
|
150
|
+
|
151
|
+
abs_diff_x = np.max(np.abs(estimated_shifts_xy[:360, 0] - gt_xy[:360, 0]))
|
152
|
+
abs_diff_y = np.max(np.abs(estimated_shifts_xy[:360, 1] - gt_xy[:360, 1]))
|
153
|
+
abs_diff_z = np.max(np.abs(estimated_shifts_z[:360] - gt_z[:360]))
|
154
|
+
|
155
|
+
max_fit_err_vu = estimator.get_max_fit_error(cor=self.cor)
|
156
|
+
assert (
|
157
|
+
max_fit_err_vu[0] < 0.7
|
158
|
+
), "Max difference between detector_v_shifts and fit(detector_v_shifts) is too high" # can't do better for z estimation ?!
|
159
|
+
assert (
|
160
|
+
max_fit_err_vu[1] < 0.2
|
161
|
+
), "Max difference between detector_u_shifts and fit(detector_u_shifts) is too high"
|
162
|
+
|
163
|
+
assert np.max(abs_diff_x) < 0.3, "Wrong x-movement estimation"
|
164
|
+
assert np.max(abs_diff_y) < 0.5, "Wrong y-movement estimation"
|
165
|
+
assert np.max(abs_diff_z) < 0.5, "Wrong z-movement estimation"
|
166
|
+
|
167
|
+
|
168
|
+
@pytest.fixture(scope="class")
|
169
|
+
def bootstrap_test_motion_estimation2(request):
|
170
|
+
cls = request.cls
|
171
|
+
cls.volume = get_data("motion/mri_volume_subsampled.npy")
|
172
|
+
|
173
|
+
|
174
|
+
def _create_translations_vector(a, alpha, beta):
|
175
|
+
return alpha * a**2 + beta * a
|
176
|
+
|
177
|
+
|
178
|
+
def project_volume(vol, angles, tx, ty, tz, cor=0, orig_det_dist=0):
|
179
|
+
"""
|
180
|
+
Forward-project a volume with translations (tx, ty, tz) of the sample
|
181
|
+
"""
|
182
|
+
n_y, n_x, n_z = vol.shape
|
183
|
+
vol_geom = astra.create_vol_geom(n_y, n_x, n_z)
|
184
|
+
|
185
|
+
det_row_count = n_z
|
186
|
+
det_col_count = max(n_y, n_x)
|
187
|
+
|
188
|
+
vectors = np.zeros((len(angles), 12))
|
189
|
+
for i in range(len(angles)):
|
190
|
+
theta = angles[i]
|
191
|
+
# ray direction
|
192
|
+
vectors[i, 0] = sin(theta)
|
193
|
+
vectors[i, 1] = -cos(theta)
|
194
|
+
vectors[i, 2] = 0
|
195
|
+
|
196
|
+
# center of detector
|
197
|
+
vectors[i, 3] = (
|
198
|
+
(cos(theta) ** 2) * tx[i] + cos(theta) * sin(theta) * ty[i] - orig_det_dist * sin(theta) + cos(theta) * cor
|
199
|
+
)
|
200
|
+
vectors[i, 4] = (
|
201
|
+
sin(theta) * cos(theta) * tx[i] + (sin(theta) ** 2) * ty[i] + orig_det_dist * cos(theta) + sin(theta) * cor
|
202
|
+
)
|
203
|
+
vectors[i, 5] = tz[i]
|
204
|
+
|
205
|
+
# vector from detector pixel (0,0) to (0,1)
|
206
|
+
vectors[i, 6] = cos(theta) # uX
|
207
|
+
vectors[i, 7] = sin(theta) # uY
|
208
|
+
vectors[i, 8] = 0 # uZ
|
209
|
+
|
210
|
+
# vector from detector pixel (0,0) to (1,0)
|
211
|
+
vectors[i, 9] = 0
|
212
|
+
vectors[i, 10] = 0
|
213
|
+
vectors[i, 11] = 1
|
214
|
+
|
215
|
+
proj_geom = astra.create_proj_geom("parallel3d_vec", det_row_count, det_col_count, vectors)
|
216
|
+
|
217
|
+
sinogram_id, sinogram = astra.create_sino3d_gpu(vol, proj_geom, vol_geom)
|
218
|
+
return sinogram
|
219
|
+
|
220
|
+
|
221
|
+
def check_motion_estimation(
|
222
|
+
motion_estimator,
|
223
|
+
angles,
|
224
|
+
cor,
|
225
|
+
gt_xy,
|
226
|
+
gt_z,
|
227
|
+
fit_error_shifts_tol_vu=(1e-5, 1e-5),
|
228
|
+
fit_error_det_tol_vu=(1e-5, 1e-5),
|
229
|
+
fit_error_tol_xyz=(1e-5, 1e-5, 1e-5),
|
230
|
+
fit_error_det_all_angles_tol_vu=(1e-5, 1e-5),
|
231
|
+
):
|
232
|
+
"""
|
233
|
+
Assess the fit quality of a MotionEstimation object, given known ground truth sample movements.
|
234
|
+
|
235
|
+
Parameters
|
236
|
+
----------
|
237
|
+
motion_estimator: MotionEstimation
|
238
|
+
MotionEstimation object
|
239
|
+
angles: numpy.ndarray
|
240
|
+
Array containing rotation angles in radians. For 180° scan it must not contain the "return projections".
|
241
|
+
cor: float
|
242
|
+
Center of rotation
|
243
|
+
gt_xy: numpy.ndarray
|
244
|
+
Ground-truth sample movements in (x, y). Shape (n_angles, 2)
|
245
|
+
gt_z: numpy.ndarray
|
246
|
+
Ground-truth sample movements in z. Shape (n_angles,)
|
247
|
+
fit_error_shifts_tol_vu: tuple of float
|
248
|
+
Maximum error tolerance when assessing the fit of radios movements,
|
249
|
+
i.e see how well the cross-correlation shifts between pairs of radios are fitted
|
250
|
+
fit_error_det_tol_vu: tuple of float
|
251
|
+
Maximum error tolerance when assessing the sample movements fit on used angles, projected on the detector
|
252
|
+
fit_error_tol_xyz: tuple of float
|
253
|
+
Maximum error tolerance when assessing the fit of sample movements, in the sample domain
|
254
|
+
fit_error_det_all_angles_tol_vu: tuple of float
|
255
|
+
Maximum error tolerance when assessing the sample movements fit on ALL angles, projected on the detector
|
256
|
+
"""
|
257
|
+
|
258
|
+
is_return = motion_estimator.is_return
|
259
|
+
n_angles = angles.size
|
260
|
+
outward_angles = motion_estimator.angles1
|
261
|
+
return_angles = motion_estimator.angles1
|
262
|
+
ma = lambda x: np.max(np.abs(x))
|
263
|
+
outward_angles_indices = motion_estimator.indices1
|
264
|
+
|
265
|
+
# (1) Check the fit in the detector domain, wrt measured shifts between pair of projs
|
266
|
+
# ------------------------------------------------------------------------------------
|
267
|
+
for fit_err, fit_max_error_tol, coord_name in zip(
|
268
|
+
motion_estimator.get_max_fit_error(cor), fit_error_shifts_tol_vu, ["v", "u"]
|
269
|
+
):
|
270
|
+
assert (
|
271
|
+
fit_err < fit_max_error_tol
|
272
|
+
), f"Max error between (measured detector {coord_name}-shifts) and (estimated fit) is too high: {fit_err} > {fit_max_error_tol}"
|
273
|
+
|
274
|
+
# (2) Check the fit wrt the ground truth motion projected onto detector
|
275
|
+
# ----------------------------------------------------------------------
|
276
|
+
shifts_vu = motion_estimator.apply_fit_to_get_detector_displacements(cor)
|
277
|
+
if is_return:
|
278
|
+
gt_shifts_vu_1 = motion_estimator.convert_sample_motion_to_detector_shifts(
|
279
|
+
gt_xy[outward_angles_indices], gt_z[outward_angles_indices], outward_angles, cor=cor
|
280
|
+
)
|
281
|
+
gt_shifts_vu_2 = motion_estimator.convert_sample_motion_to_detector_shifts(
|
282
|
+
gt_xy[n_angles:], gt_z[n_angles:], return_angles, cor=cor
|
283
|
+
)
|
284
|
+
else:
|
285
|
+
gt_shifts_vu_1 = motion_estimator.convert_sample_motion_to_detector_shifts(
|
286
|
+
gt_xy[: n_angles // 2], gt_z[: n_angles // 2], outward_angles, cor=cor
|
287
|
+
)
|
288
|
+
gt_shifts_vu_2 = motion_estimator.convert_sample_motion_to_detector_shifts(
|
289
|
+
gt_xy[n_angles // 2 :], gt_z[n_angles // 2 :], outward_angles, cor=cor
|
290
|
+
)
|
291
|
+
gt_shifts_vu = gt_shifts_vu_2 - gt_shifts_vu_1
|
292
|
+
if not (is_return):
|
293
|
+
gt_shifts_vu[:, 1] += (
|
294
|
+
2 * cor
|
295
|
+
) # when comparing pairs of opposits projs, delta_u = u(theta+) - u(theta) + 2*cor !
|
296
|
+
|
297
|
+
err_max_vu = np.max(np.abs(gt_shifts_vu - shifts_vu), axis=0)
|
298
|
+
assert (
|
299
|
+
err_max_vu[0] < fit_error_det_tol_vu[0]
|
300
|
+
), f"Max difference of fit(used_angles) on 'v' coordinate is too high: {err_max_vu[0]} > {fit_error_det_tol_vu[0]}"
|
301
|
+
assert (
|
302
|
+
err_max_vu[1] < fit_error_det_tol_vu[1]
|
303
|
+
), f"Max difference of fit(used_angles) on 'u'' coordinate is too high: {err_max_vu[1]} > {fit_error_det_tol_vu[1]}"
|
304
|
+
|
305
|
+
# (3) Check the fit wrt ground truth motion of the sample
|
306
|
+
# This is less precise when using only return projections,
|
307
|
+
# because the movements were estimated from the detector shifts between a few pairs of projections
|
308
|
+
# Here apply the shift on all angles (above was only on the angles used to calculate the fit)
|
309
|
+
# --------------------------------------------------------------------------------------------
|
310
|
+
txy_est_all_angles = motion_estimator.apply_fit_horiz(angles=angles)
|
311
|
+
tz_est_all_angles = motion_estimator.apply_fit_vertic(angles=angles)
|
312
|
+
err_max_x = ma(txy_est_all_angles[:, 0] - gt_xy[:n_angles, 0])
|
313
|
+
err_max_y = ma(txy_est_all_angles[:, 1] - gt_xy[:n_angles, 1])
|
314
|
+
err_max_z = ma(tz_est_all_angles - gt_z[:n_angles])
|
315
|
+
assert (
|
316
|
+
err_max_x < fit_error_tol_xyz[0]
|
317
|
+
), f"Max error for x coordinate is too high: {err_max_x} > {fit_error_tol_xyz[0]}"
|
318
|
+
assert (
|
319
|
+
err_max_y < fit_error_tol_xyz[1]
|
320
|
+
), f"Max error for y coordinate is too high: {err_max_y} > {fit_error_tol_xyz[1]}"
|
321
|
+
assert (
|
322
|
+
err_max_z < fit_error_tol_xyz[2]
|
323
|
+
), f"Max error for z coordinate is too high: {err_max_z} > {fit_error_tol_xyz[2]}"
|
324
|
+
|
325
|
+
# (4) Check the fit wrt ground truth motion, projected onto the detector, for all angles
|
326
|
+
# ---------------------------------------------------------------------------------------
|
327
|
+
estimated_shifts_vu_all_angles = motion_estimator.convert_sample_motion_to_detector_shifts(
|
328
|
+
txy_est_all_angles, tz_est_all_angles, angles, cor=cor
|
329
|
+
)
|
330
|
+
gt_shifts_vu_all_angles = motion_estimator.convert_sample_motion_to_detector_shifts(
|
331
|
+
gt_xy[:n_angles], gt_z[:n_angles], angles, cor=cor
|
332
|
+
)
|
333
|
+
err_max_vu_all_angles = np.max(np.abs(estimated_shifts_vu_all_angles - gt_shifts_vu_all_angles), axis=0)
|
334
|
+
assert (
|
335
|
+
err_max_vu_all_angles[0] < fit_error_det_all_angles_tol_vu[0]
|
336
|
+
), f"Max error of fit(all_angles) on 'v' coordinate is too high: {err_max_vu_all_angles[0]} > {fit_error_det_all_angles_tol_vu[0]}"
|
337
|
+
assert (
|
338
|
+
err_max_vu_all_angles[1] < fit_error_det_all_angles_tol_vu[1]
|
339
|
+
), f"Max error of fit(all_angles) on 'u' coordinate is too high: {err_max_vu_all_angles[1]} > {fit_error_det_all_angles_tol_vu[1]}"
|
340
|
+
|
341
|
+
|
342
|
+
@pytest.mark.skipif(not (__do_long_tests__), reason="Need NABU_LONG_TESTS=1 for this test")
|
343
|
+
@pytest.mark.skipif(astra is None, reason="Need astra for this test")
|
344
|
+
@pytest.mark.usefixtures("bootstrap_test_motion_estimation2")
|
345
|
+
class TestMotionEstimationFromProjectedVolume:
|
346
|
+
|
347
|
+
def test_180_with_return_projections(self, verbose=False):
|
348
|
+
n_angles = 250
|
349
|
+
cor = -10
|
350
|
+
|
351
|
+
alpha_x = 4
|
352
|
+
beta_x = 3
|
353
|
+
alpha_y = -5
|
354
|
+
beta_y = 10
|
355
|
+
beta_z = 0
|
356
|
+
orig_det_dist = 0
|
357
|
+
|
358
|
+
angles0 = np.linspace(0, np.pi, n_angles, False)
|
359
|
+
return_angles = np.deg2rad([180.0, 135.0, 90.0, 45.0, 0.0])
|
360
|
+
angles = np.hstack([angles0, return_angles]).ravel()
|
361
|
+
a = np.arange(angles0.size + return_angles.size) / angles0.size
|
362
|
+
|
363
|
+
tx = _create_translations_vector(a, alpha_x, beta_x)
|
364
|
+
ty = _create_translations_vector(a, alpha_y, beta_y)
|
365
|
+
tz = _create_translations_vector(a, 0, beta_z)
|
366
|
+
|
367
|
+
if not (hasattr(self, "volume")):
|
368
|
+
self.volume = get_data("motion/mri_volume_subsampled.npy")
|
369
|
+
sinos = project_volume(self.volume, angles, -tx, -ty, -tz, cor=-cor, orig_det_dist=orig_det_dist)
|
370
|
+
radios = np.moveaxis(sinos, 1, 0)
|
371
|
+
|
372
|
+
n_return_angles = return_angles.size
|
373
|
+
projs_stack2 = radios[-n_return_angles:]
|
374
|
+
outward_angles_indices = np.array([search_sorted(angles0, ra) for ra in return_angles])
|
375
|
+
outward_angles = angles0[outward_angles_indices]
|
376
|
+
projs_stack1 = radios[:-n_return_angles][outward_angles_indices]
|
377
|
+
|
378
|
+
motion_estimator = MotionEstimation(
|
379
|
+
projs_stack1,
|
380
|
+
projs_stack2,
|
381
|
+
outward_angles,
|
382
|
+
return_angles,
|
383
|
+
indices1=outward_angles_indices,
|
384
|
+
indices2=np.arange(n_angles, n_angles + n_return_angles),
|
385
|
+
shifts_estimator="DetectorTranslationAlongBeam",
|
386
|
+
)
|
387
|
+
motion_estimator.estimate_horizontal_motion(degree=2, cor=cor)
|
388
|
+
motion_estimator.estimate_vertical_motion()
|
389
|
+
|
390
|
+
gt_h = np.stack([-tx, ty], axis=1)
|
391
|
+
gt_v = -tz
|
392
|
+
if verbose:
|
393
|
+
motion_estimator.plot_detector_shifts(cor=cor)
|
394
|
+
motion_estimator.plot_movements(cor, angles_rad=angles0, gt_xy=gt_h[:n_angles], gt_z=gt_v[:n_angles])
|
395
|
+
|
396
|
+
check_motion_estimation(
|
397
|
+
motion_estimator,
|
398
|
+
angles0,
|
399
|
+
cor,
|
400
|
+
gt_h,
|
401
|
+
gt_v,
|
402
|
+
fit_error_shifts_tol_vu=(0.01, 0.05),
|
403
|
+
fit_error_det_tol_vu=(0.02, 0.5),
|
404
|
+
fit_error_tol_xyz=(0.5, 1, 0.05),
|
405
|
+
fit_error_det_all_angles_tol_vu=(0.02, 1),
|
406
|
+
)
|
407
|
+
|
408
|
+
def test_360(self, verbose=False):
|
409
|
+
n_angles = 250
|
410
|
+
cor = -5.5
|
411
|
+
|
412
|
+
alpha_x = -2
|
413
|
+
beta_x = 7.0
|
414
|
+
alpha_y = -2
|
415
|
+
beta_y = 3
|
416
|
+
beta_z = 100
|
417
|
+
orig_det_dist = 0
|
418
|
+
|
419
|
+
angles = np.linspace(0, 2 * np.pi, n_angles, False)
|
420
|
+
a = np.linspace(0, 1, angles.size, endpoint=False) # theta/theta_max
|
421
|
+
|
422
|
+
tx = _create_translations_vector(a, alpha_x, beta_x)
|
423
|
+
ty = _create_translations_vector(a, alpha_y, beta_y)
|
424
|
+
tz = _create_translations_vector(a, 0, beta_z)
|
425
|
+
|
426
|
+
if not (hasattr(self, "volume")):
|
427
|
+
self.volume = get_data("motion/mri_volume_subsampled.npy")
|
428
|
+
|
429
|
+
sinos = project_volume(self.volume, angles, -tx, -ty, -tz, cor=-cor, orig_det_dist=orig_det_dist)
|
430
|
+
radios = np.moveaxis(sinos, 1, 0)
|
431
|
+
|
432
|
+
projs_stack1 = radios[: n_angles // 2]
|
433
|
+
projs_stack2 = radios[n_angles // 2 :]
|
434
|
+
angles1 = angles[: n_angles // 2]
|
435
|
+
angles2 = angles[n_angles // 2 :]
|
436
|
+
|
437
|
+
motion_estimator = MotionEstimation(
|
438
|
+
projs_stack1, projs_stack2, angles1, angles2, shifts_estimator="phase_cross_correlation"
|
439
|
+
)
|
440
|
+
motion_estimator.estimate_horizontal_motion(degree=2, cor=cor)
|
441
|
+
motion_estimator.estimate_vertical_motion()
|
442
|
+
|
443
|
+
gt_xy = np.stack([-tx, ty], axis=1)
|
444
|
+
gt_z = -tz
|
445
|
+
|
446
|
+
if verbose:
|
447
|
+
motion_estimator.plot_detector_shifts(cor=cor)
|
448
|
+
motion_estimator.plot_movements(cor=cor, angles_rad=angles, gt_xy=gt_xy, gt_z=gt_z)
|
449
|
+
|
450
|
+
check_motion_estimation(
|
451
|
+
motion_estimator,
|
452
|
+
angles,
|
453
|
+
cor,
|
454
|
+
gt_xy,
|
455
|
+
gt_z,
|
456
|
+
fit_error_shifts_tol_vu=(1e-5, 0.2),
|
457
|
+
fit_error_det_tol_vu=(1e-5, 0.05),
|
458
|
+
fit_error_tol_xyz=(0.1, 0.1, 1e-5),
|
459
|
+
fit_error_det_all_angles_tol_vu=(1e-5, 0.1),
|
460
|
+
)
|
461
|
+
|
462
|
+
|
463
|
+
if __name__ == "__main__":
|
464
|
+
test1 = TestMotionEstimationFromProjectedVolume()
|
465
|
+
test1.test_180_with_return_projections(verbose=True)
|
466
|
+
test1.test_360(verbose=True)
|
467
|
+
|
468
|
+
test2 = TestMotionEstimation()
|
469
|
+
_bootstrap_test_motion_combined(test2)
|
470
|
+
test2.test_estimate_motion_360(verbose=True)
|
471
|
+
test2.test_estimate_motion_360_return(verbose=True)
|
nabu/estimation/tilt.py
CHANGED
@@ -131,7 +131,7 @@ class CameraTilt(CenterOfRotation):
|
|
131
131
|
img_1, img_2, padding_mode, axes=(-1,), high_pass=high_pass, low_pass=low_pass
|
132
132
|
)
|
133
133
|
|
134
|
-
img_shape = img_2.shape
|
134
|
+
img_shape = cc.shape # cc.shape can differ from img_2.shape, e.g. in case of odd nb of cols
|
135
135
|
cc_h_coords = np.fft.fftfreq(img_shape[-1], 1 / img_shape[-1])
|
136
136
|
|
137
137
|
(f_vals, fh) = self.extract_peak_regions_1d(cc, peak_radius=peak_fit_radius, cc_coords=cc_h_coords)
|
nabu/estimation/translation.py
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
import numpy as np
|
2
2
|
from numpy.polynomial.polynomial import Polynomial, polyval
|
3
|
+
from scipy import fft
|
4
|
+
|
5
|
+
from ..utils import calc_padding_lengths
|
3
6
|
from .alignment import AlignmentBase, plt
|
4
7
|
|
5
8
|
|
@@ -151,7 +154,7 @@ class DetectorTranslationAlongBeam(AlignmentBase):
|
|
151
154
|
for ii in range(1, num_imgs)
|
152
155
|
]
|
153
156
|
|
154
|
-
img_shape =
|
157
|
+
img_shape = ccs[0].shape # cc.shape can differ from img.shape, e.g. in case of odd number of cols.
|
155
158
|
cc_vs = np.fft.fftfreq(img_shape[-2], 1 / img_shape[-2])
|
156
159
|
cc_hs = np.fft.fftfreq(img_shape[-1], 1 / img_shape[-1])
|
157
160
|
|
@@ -191,3 +194,46 @@ class DetectorTranslationAlongBeam(AlignmentBase):
|
|
191
194
|
return coeffs_v[1], coeffs_h[1], shifts_vh
|
192
195
|
else:
|
193
196
|
return coeffs_v[1], coeffs_h[1]
|
197
|
+
|
198
|
+
|
199
|
+
def _fft_pad(i, axes=None, padding_mode="constant"):
|
200
|
+
pad_len = calc_padding_lengths(i.shape, np.array(i.shape) * 2)
|
201
|
+
i_p = np.pad(i, pad_len, mode=padding_mode)
|
202
|
+
return fft.fftn(i_p)
|
203
|
+
|
204
|
+
|
205
|
+
def estimate_shifts(im1, im2):
|
206
|
+
"""
|
207
|
+
Simple implementation of shift estimation between two images, based on phase cross correlation.
|
208
|
+
"""
|
209
|
+
pr = _fft_pad(im1) * _fft_pad(im2).conjugate()
|
210
|
+
pr_n = pr / np.maximum(1e-7, np.abs(pr))
|
211
|
+
corr = np.fft.fftshift(fft.ifftn(pr_n).real)
|
212
|
+
argmax = np.array(np.unravel_index(np.argmax(corr), pr.shape))
|
213
|
+
shp = np.array(pr.shape)
|
214
|
+
argmax_refined = refine_parabola_2D(corr, argmax)
|
215
|
+
argmax = argmax + argmax_refined
|
216
|
+
return shp // 2 - np.array(argmax)
|
217
|
+
|
218
|
+
|
219
|
+
def refine_parabola_2D(im_vals, argmax):
|
220
|
+
argmax = tuple(argmax)
|
221
|
+
maxval = im_vals[argmax]
|
222
|
+
ny, nx = im_vals.shape
|
223
|
+
|
224
|
+
iy, ix = np.array(argmax, dtype=np.intp)
|
225
|
+
ixm, ixp = (ix - 1) % nx, (ix + 1) % nx
|
226
|
+
iym, iyp = (iy - 1) % ny, (iy + 1) % ny
|
227
|
+
|
228
|
+
F = maxval
|
229
|
+
A = (im_vals[iy, ixp] + im_vals[iy, ixm]) / 2 - F
|
230
|
+
D = (im_vals[iy, ixp] - im_vals[iy, ixm]) / 2
|
231
|
+
B = (im_vals[iyp, ix] + im_vals[iym, ix]) / 2 - F
|
232
|
+
E = (im_vals[iyp, ix] - im_vals[iym, ix]) / 2
|
233
|
+
C = (im_vals[iyp, ixp] - im_vals[iym, ixp] - im_vals[iyp, ixm] + im_vals[iym, ixm]) / 4
|
234
|
+
det = C**2 - 4 * A * B
|
235
|
+
dx = (2 * B * D - C * E) / det
|
236
|
+
dy = (2 * A * E - C * D) / det
|
237
|
+
dx = np.clip(dx, -0.5, 0.5)
|
238
|
+
dy = np.clip(dy, -0.5, 0.5)
|
239
|
+
return (dy, dx)
|