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.
Files changed (63) hide show
  1. nabu/__init__.py +1 -1
  2. nabu/app/cast_volume.py +12 -1
  3. nabu/app/cli_configs.py +81 -4
  4. nabu/app/estimate_motion.py +54 -0
  5. nabu/app/multicor.py +2 -4
  6. nabu/app/pcaflats.py +116 -0
  7. nabu/app/reconstruct.py +1 -7
  8. nabu/app/reduce_dark_flat.py +5 -2
  9. nabu/estimation/cor.py +1 -1
  10. nabu/estimation/motion.py +557 -0
  11. nabu/estimation/tests/test_motion_estimation.py +471 -0
  12. nabu/estimation/tilt.py +1 -1
  13. nabu/estimation/translation.py +47 -1
  14. nabu/io/cast_volume.py +94 -13
  15. nabu/io/reader.py +32 -1
  16. nabu/io/tests/test_remove_volume.py +152 -0
  17. nabu/pipeline/config_validators.py +42 -43
  18. nabu/pipeline/estimators.py +255 -0
  19. nabu/pipeline/fullfield/chunked.py +67 -43
  20. nabu/pipeline/fullfield/chunked_cuda.py +5 -2
  21. nabu/pipeline/fullfield/nabu_config.py +17 -11
  22. nabu/pipeline/fullfield/processconfig.py +8 -2
  23. nabu/pipeline/fullfield/reconstruction.py +3 -0
  24. nabu/pipeline/params.py +12 -0
  25. nabu/pipeline/tests/test_estimators.py +240 -3
  26. nabu/preproc/ccd.py +53 -3
  27. nabu/preproc/flatfield.py +306 -1
  28. nabu/preproc/shift.py +3 -1
  29. nabu/preproc/tests/test_pcaflats.py +154 -0
  30. nabu/processing/rotation_cuda.py +3 -1
  31. nabu/processing/tests/test_rotation.py +4 -2
  32. nabu/reconstruction/fbp.py +7 -0
  33. nabu/reconstruction/fbp_base.py +31 -7
  34. nabu/reconstruction/fbp_opencl.py +8 -0
  35. nabu/reconstruction/filtering_opencl.py +2 -0
  36. nabu/reconstruction/mlem.py +51 -14
  37. nabu/reconstruction/tests/test_filtering.py +13 -2
  38. nabu/reconstruction/tests/test_mlem.py +91 -62
  39. nabu/resources/dataset_analyzer.py +144 -20
  40. nabu/resources/nxflatfield.py +101 -35
  41. nabu/resources/tests/test_nxflatfield.py +1 -1
  42. nabu/resources/utils.py +16 -10
  43. nabu/stitching/alignment.py +7 -7
  44. nabu/stitching/config.py +22 -20
  45. nabu/stitching/definitions.py +2 -2
  46. nabu/stitching/overlap.py +4 -4
  47. nabu/stitching/sample_normalization.py +5 -5
  48. nabu/stitching/stitcher/post_processing.py +5 -3
  49. nabu/stitching/stitcher/pre_processing.py +24 -20
  50. nabu/stitching/tests/test_config.py +3 -3
  51. nabu/stitching/tests/test_y_preprocessing_stitching.py +11 -8
  52. nabu/stitching/tests/test_z_postprocessing_stitching.py +2 -2
  53. nabu/stitching/tests/test_z_preprocessing_stitching.py +23 -20
  54. nabu/stitching/utils/utils.py +7 -7
  55. nabu/testutils.py +1 -4
  56. nabu/utils.py +13 -0
  57. {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/METADATA +3 -4
  58. {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/RECORD +62 -57
  59. {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/WHEEL +1 -1
  60. {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/entry_points.txt +2 -1
  61. nabu/app/correct_rot.py +0 -62
  62. {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.dist-info}/licenses/LICENSE +0 -0
  63. {nabu-2025.1.0.dev13.dist-info → nabu-2025.1.0rc1.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)
nabu/io/cast_volume.py CHANGED
@@ -1,10 +1,12 @@
1
1
  import os
2
-
3
- from tomoscan.esrf.volume.singleframebase import VolumeSingleFrameBase
4
- from nabu.misc.utils import rescale_data
5
- from nabu.pipeline.params import files_formats
2
+ import logging
3
+ from glob import glob
4
+ from shutil import rmtree
5
+ import numpy
6
+ from silx.io.utils import get_data
7
+ from silx.io.url import DataUrl
6
8
  from tomoscan.volumebase import VolumeBase
7
- from tomoscan.scanbase import TomoScanBase
9
+ from tomoscan.esrf.volume.singleframebase import VolumeSingleFrameBase
8
10
  from tomoscan.esrf.volume import (
9
11
  EDFVolume,
10
12
  HDF5Volume,
@@ -13,11 +15,10 @@ from tomoscan.esrf.volume import (
13
15
  TIFFVolume,
14
16
  )
15
17
  from tomoscan.io import HDF5File
16
- from silx.io.utils import get_data
17
- import numpy
18
- from silx.io.url import DataUrl
19
- from typing import Optional
20
- import logging
18
+ from ..utils import first_generator_item
19
+ from ..misc.utils import rescale_data
20
+ from ..pipeline.params import files_formats
21
+ from .reader import get_hdf5_file_all_virtual_sources, list_hdf5_entries
21
22
 
22
23
  _logger = logging.getLogger(__name__)
23
24
 
@@ -133,7 +134,7 @@ def cast_volume(
133
134
  output_data_type: numpy.dtype,
134
135
  data_min=None,
135
136
  data_max=None,
136
- scan: Optional[TomoScanBase] = None,
137
+ scan=None,
137
138
  rescale_min_percentile=RESCALE_MIN_PERCENTILE,
138
139
  rescale_max_percentile=RESCALE_MAX_PERCENTILE,
139
140
  save=True,
@@ -283,7 +284,7 @@ def clamp_and_rescale_data(
283
284
  return rescaled_data
284
285
 
285
286
 
286
- def find_histogram(volume: VolumeBase, scan: Optional[TomoScanBase] = None) -> Optional[DataUrl]:
287
+ def find_histogram(volume: VolumeBase, scan=None):
287
288
  """
288
289
  Look for histogram of the provided url. If found one return the DataUrl of the nabu histogram
289
290
  """
@@ -330,7 +331,7 @@ def find_histogram(volume: VolumeBase, scan: Optional[TomoScanBase] = None) -> O
330
331
  data_path = getattr(scan, "entry/histogram/results/data", "entry/histogram/results/data")
331
332
  else:
332
333
 
333
- def get_file_entries(file_path: str) -> Optional[tuple]:
334
+ def get_file_entries(file_path: str):
334
335
  if os.path.exists(file_path):
335
336
  with HDF5File(file_path, mode="r") as h5s:
336
337
  return tuple(h5s.keys())
@@ -408,3 +409,83 @@ def _min_max_from_histo(url: DataUrl, rescale_min_percentile: int, rescale_max_p
408
409
  return _get_hst_saturations(
409
410
  hist, bins, numpy.float32(rescale_min_percentile), numpy.float32(rescale_max_percentile)
410
411
  )
412
+
413
+
414
+ def _remove_volume_singleframe(volume, check=True):
415
+ volume_directory = volume.data_url.file_path()
416
+ if check:
417
+ volume_files = set(volume.browse_data_files())
418
+ files_names_pattern = os.path.join(volume_directory, "*." + volume.data_extension)
419
+ files_on_disk = set(glob(files_names_pattern))
420
+ # Don't check strict equality here, as some files on disk might be already removed.
421
+ # i.e, there should be no more files on disk than expected files in the volume
422
+ if not (files_on_disk.issubset(volume_files)):
423
+ raise RuntimeError(f"Unexpected files present in {volume_directory}: {files_on_disk - volume_files}")
424
+ # TODO also check for metadata file(s) ?
425
+ rmtree(volume_directory)
426
+
427
+
428
+ def _remove_volume_multiframe(volume, check=True):
429
+ file_path = volume.data_url.file_path()
430
+ if check:
431
+ if not (os.path.isfile(file_path)):
432
+ raise RuntimeError(f"Expected a file: {file_path}")
433
+ os.remove(file_path)
434
+
435
+
436
+ def _remove_volume_hdf5(volume, check=True):
437
+ file_path = volume.data_url.file_path()
438
+ entry = volume.data_url.data_path().lstrip("/").split("/")[0]
439
+
440
+ # Nabu HDF5 reconstructions have a folder alongside the HDF5 file, with the same prefix
441
+ # For example the HDF5 file "/path/to/rec.hdf5" has an associated directory "/path/to/rec"
442
+ associated_dir, _ = os.path.splitext(os.path.basename(file_path))
443
+ associated_dir_abs = os.path.join(os.path.dirname(file_path), associated_dir)
444
+
445
+ with HDF5File(file_path, "r") as f:
446
+ fdesc = f[entry]
447
+ virtual_sources = get_hdf5_file_all_virtual_sources(fdesc, return_only_filenames=True)
448
+
449
+ # TODO check if this is legitimate. Nabu reconstruction will only do one VS (for entry/reconstruction/results/data).
450
+ # Bliss/Lima do have multiple VS (flats/darks/projs), but we generally don't want to remove raw data ?
451
+ if len(virtual_sources) > 1:
452
+ raise ValueError("Found more than one virtual source - this looks weird. Interrupting.")
453
+ #
454
+ if len(virtual_sources) > 0:
455
+ h5path, virtual_source_files_paths = first_generator_item(virtual_sources[0].items())
456
+ if len(virtual_source_files_paths) == 1:
457
+ target_dir = os.path.dirname(virtual_source_files_paths[0])
458
+ else:
459
+ target_dir = os.path.commonpath(virtual_source_files_paths)
460
+ target_dir_abs = os.path.join(os.path.dirname(file_path), target_dir)
461
+ if check and (target_dir_abs != associated_dir_abs):
462
+ raise ValueError(
463
+ f"The virtual sources in {file_path}:{h5path} reference the directory {target_dir}, but expected was {associated_dir}"
464
+ )
465
+ if os.path.isdir(target_dir_abs):
466
+ rmtree(associated_dir_abs)
467
+ os.remove(file_path)
468
+
469
+
470
+ def remove_volume(volume, check=True):
471
+ """
472
+ Remove files belonging to a volume, claim disk space.
473
+
474
+ Parameters
475
+ ----------
476
+ volume: tomoscan.esrf.volume
477
+ Volume object
478
+ check: bool, optional
479
+ Whether to check if the files that would be removed do not have extra other files ; interrupt the operation if so.
480
+
481
+ """
482
+ if isinstance(volume, (EDFVolume, JP2KVolume, TIFFVolume)):
483
+ _remove_volume_singleframe(volume, check=check)
484
+ elif isinstance(volume, MultiTIFFVolume):
485
+ _remove_volume_multiframe(volume, check=check)
486
+ elif isinstance(volume, HDF5Volume):
487
+ if len(list_hdf5_entries(volume.file_path)) > 1:
488
+ raise NotImplementedError("Removing a HDF5 volume with more than one entry is not supported")
489
+ _remove_volume_hdf5(volume, check=check)
490
+ else:
491
+ raise TypeError("Unknown type of volume")