nabu 2025.1.0.dev14__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 +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 +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 +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.0rc1.dist-info}/METADATA +3 -4
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc1.dist-info}/RECORD +62 -57
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc1.dist-info}/WHEEL +1 -1
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc1.dist-info}/entry_points.txt +2 -1
- nabu/app/correct_rot.py +0 -62
- {nabu-2025.1.0.dev14.dist-info → nabu-2025.1.0rc1.dist-info}/licenses/LICENSE +0 -0
- {nabu-2025.1.0.dev14.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)
|
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)
|
nabu/io/cast_volume.py
CHANGED
@@ -1,10 +1,12 @@
|
|
1
1
|
import os
|
2
|
-
|
3
|
-
from
|
4
|
-
from
|
5
|
-
|
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.
|
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
|
17
|
-
import
|
18
|
-
from
|
19
|
-
from
|
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
|
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
|
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)
|
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")
|