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.
Files changed (65) hide show
  1. doc/doc_config.py +32 -0
  2. nabu/__init__.py +1 -1
  3. nabu/app/cast_volume.py +9 -1
  4. nabu/app/cli_configs.py +80 -3
  5. nabu/app/estimate_motion.py +54 -0
  6. nabu/app/multicor.py +2 -4
  7. nabu/app/pcaflats.py +116 -0
  8. nabu/app/reconstruct.py +1 -7
  9. nabu/app/reduce_dark_flat.py +5 -2
  10. nabu/estimation/cor.py +1 -1
  11. nabu/estimation/motion.py +557 -0
  12. nabu/estimation/tests/test_motion_estimation.py +471 -0
  13. nabu/estimation/tilt.py +1 -1
  14. nabu/estimation/translation.py +47 -1
  15. nabu/io/cast_volume.py +100 -13
  16. nabu/io/reader.py +32 -1
  17. nabu/io/tests/test_remove_volume.py +152 -0
  18. nabu/pipeline/config_validators.py +42 -43
  19. nabu/pipeline/estimators.py +255 -0
  20. nabu/pipeline/fullfield/chunked.py +67 -43
  21. nabu/pipeline/fullfield/chunked_cuda.py +5 -2
  22. nabu/pipeline/fullfield/nabu_config.py +20 -14
  23. nabu/pipeline/fullfield/processconfig.py +17 -3
  24. nabu/pipeline/fullfield/reconstruction.py +4 -1
  25. nabu/pipeline/params.py +12 -0
  26. nabu/pipeline/tests/test_estimators.py +240 -3
  27. nabu/preproc/ccd.py +53 -3
  28. nabu/preproc/flatfield.py +306 -1
  29. nabu/preproc/shift.py +3 -1
  30. nabu/preproc/tests/test_pcaflats.py +154 -0
  31. nabu/processing/rotation_cuda.py +3 -1
  32. nabu/processing/tests/test_rotation.py +4 -2
  33. nabu/reconstruction/astra.py +245 -0
  34. nabu/reconstruction/fbp.py +7 -0
  35. nabu/reconstruction/fbp_base.py +31 -7
  36. nabu/reconstruction/fbp_opencl.py +8 -0
  37. nabu/reconstruction/filtering_opencl.py +2 -0
  38. nabu/reconstruction/mlem.py +47 -13
  39. nabu/reconstruction/tests/test_filtering.py +13 -2
  40. nabu/reconstruction/tests/test_mlem.py +91 -62
  41. nabu/resources/dataset_analyzer.py +144 -20
  42. nabu/resources/nxflatfield.py +101 -35
  43. nabu/resources/tests/test_nxflatfield.py +1 -1
  44. nabu/resources/utils.py +16 -10
  45. nabu/stitching/alignment.py +7 -7
  46. nabu/stitching/config.py +22 -20
  47. nabu/stitching/definitions.py +2 -2
  48. nabu/stitching/overlap.py +4 -4
  49. nabu/stitching/sample_normalization.py +5 -5
  50. nabu/stitching/stitcher/post_processing.py +5 -3
  51. nabu/stitching/stitcher/pre_processing.py +24 -20
  52. nabu/stitching/tests/test_config.py +3 -3
  53. nabu/stitching/tests/test_y_preprocessing_stitching.py +11 -8
  54. nabu/stitching/tests/test_z_postprocessing_stitching.py +2 -2
  55. nabu/stitching/tests/test_z_preprocessing_stitching.py +23 -20
  56. nabu/stitching/utils/utils.py +7 -7
  57. nabu/testutils.py +1 -4
  58. nabu/utils.py +13 -0
  59. {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/METADATA +3 -4
  60. {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/RECORD +64 -57
  61. {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/WHEEL +1 -1
  62. {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/entry_points.txt +2 -1
  63. nabu/app/correct_rot.py +0 -62
  64. {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc2.dist-info}/licenses/LICENSE +0 -0
  65. {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)
@@ -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 = img_stack.shape[-2:]
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)