qsm_core/motion.rs
1//! Retrospective motion correction for GRE/QSM: co-register a series of volumes onto one of
2//! them, in the complex domain, and re-derive B0 for each.
3//!
4//! This is the image-domain half of motion correction, and it is the half a library that
5//! consumes reconstructed NIfTI can actually do. The best QSM motion correction in the
6//! literature is *prospective* — spherical or dual-echo spiral navigators driving a
7//! conjugate-phase reconstruction — and it needs raw k-space plus a modified sequence. Nothing
8//! here can reach that; the boundary is recorded so it is not mistaken for a gap.
9//!
10//! What this module does is what the field does in image space. The most recent published
11//! example registers every echo of a UTE acquisition to the first TE as a fixed reference with a
12//! rigid algorithm, and reports that it significantly reduced streaking from substantial
13//! inter-scan motion — with the honest caveat that a residual boundary effect remains from
14//! motion it could not correct. Expect the same shape of result here: a large improvement, not a
15//! clean recovery.
16//!
17//! # The two things that are easy to get wrong
18//!
19//! **Phase must move through the complex domain.** Wrapped phase cannot be interpolated: halfway
20//! between `+3.0` and `−3.0` rad a linear interpolator returns `0.0` when the answer is near
21//! `±π`, so every wrap in the volume becomes a band of wrong values. Registration is therefore
22//! estimated on **magnitude** and applied to the magnitude/phase pair together through
23//! [`crate::geometry::resample_complex_onto`], which interpolates `mag·e^{iφ}`.
24//! [`MotionSeries::apply_complex`] is the only phase-moving entry point here, and
25//! [`MotionSeries::apply`] is documented as refusing to be the one.
26//!
27//! **B0 must be re-derived per volume.** When the head rotates, B0 does not: the field direction
28//! *relative to the head* changes, so the dipole kernel orientation changes with it. A series
29//! that moved is a series with one B0 direction per volume, and reusing a single one produces a
30//! plausible-looking wrong susceptibility map rather than an error. This is the same physics that
31//! makes COSMOS ([`crate::inversion::cosmos`]) work, and the transport lives in
32//! [`crate::registration::RigidTransform::b0_direction_in_fixed`]; [`VolumeMotion::bdir`] is just
33//! that value, carried alongside the volume it belongs to so a caller cannot lose track of which
34//! direction goes with which field map.
35//!
36//! # Two ways to finish
37//!
38//! Having put the series on one grid there are two things to do with it, and they are not
39//! equally safe:
40//!
41//! - **Pass it downstream per volume** ([`correct_motion`]), each field map with its own `bdir`.
42//! Always correct. With enough orientations and enough spread this *is* COSMOS.
43//! - **Average it** ([`MotionSeries::average_complex`]). Correct for magnitude, and the right
44//! thing for repeats that barely moved, but it averages phase accumulated under *different*
45//! dipole-kernel orientations, so it is an approximation that degrades with the rotation it is
46//! averaging over. [`MotionSeries::rotation_spread_deg`] is the number to look at before
47//! trusting it.
48//!
49//! # What is deliberately not here
50//!
51//! Detecting a corrupted volume and *downweighting or dropping* it — a per-echo fit residual as a
52//! corruption detector, feeding a weighted echo combination — is a separate piece of work that
53//! belongs with the weighting machinery in [`crate::utils::multi_echo`], not here.
54//! [`VolumeMotion::suspect`] reports when an *estimate* looks untrustworthy, which is a
55//! registration diagnostic; acting on it is not this module's job.
56//!
57//! # Example
58//!
59//! ```no_run
60//! use qsm_core::motion::{correct_motion, MotionParams, MotionReference};
61//! # fn f(mags: &[Vec<f64>], phases: &[Vec<f64>], dims: (usize, usize, usize),
62//! # affine: &[f64; 16], brain: &[u8]) -> Option<()> {
63//! let params = MotionParams {
64//! reference: MotionReference::First, // the first TE, as the UTE work does
65//! ..Default::default()
66//! };
67//! let corrected = correct_motion(mags, phases, dims, affine, Some(brain), ¶ms)?;
68//! println!(
69//! "max tissue displacement {:.2} mm over {:.2}° of rotation",
70//! corrected.motion.max_displacement_mm(),
71//! corrected.motion.rotation_spread_deg(),
72//! );
73//! // Each volume on the reference grid, each with the B0 direction it was acquired under.
74//! for (phase, bdir) in corrected.phases.iter().zip(corrected.bdirs.iter()) {
75//! let _ = (phase, bdir);
76//! }
77//! # Some(()) }
78//! ```
79
80use crate::geometry::b0_direction_from_affine;
81use crate::registration::{register_rigid, RigidParams, RigidTransform};
82
83/// Which volume of the series the rest are aligned onto.
84///
85/// The reference defines the output grid and is itself left untouched, so it is the one volume
86/// that never passes through an interpolator. Prefer the volume with the best SNR and the least
87/// motion; for a multi-echo series that is conventionally the first echo, which is also what the
88/// published UTE procedure uses.
89#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
91pub enum MotionReference {
92 /// The first volume — the first echo, or the first repeat.
93 #[default]
94 First,
95 /// The last volume.
96 Last,
97 /// A specific index. Out of range makes [`estimate_motion`] return `None`.
98 Index(usize),
99}
100
101impl MotionReference {
102 /// Resolve to an index within a series of `n` volumes, or `None` if it does not name one.
103 pub fn resolve(self, n: usize) -> Option<usize> {
104 match self {
105 MotionReference::First if n > 0 => Some(0),
106 MotionReference::Last if n > 0 => Some(n - 1),
107 MotionReference::Index(i) if i < n => Some(i),
108 _ => None,
109 }
110 }
111}
112
113/// Motion-correction parameters.
114#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
115#[derive(Clone, Debug)]
116pub struct MotionParams {
117 /// Which volume the others are aligned onto.
118 pub reference: MotionReference,
119 /// Parameters for each pairwise rigid registration.
120 ///
121 /// [`RigidParams::initial`] is **overwritten** when [`Self::chain`] is set, which is the
122 /// default; set `chain: false` to have it respected as a fixed seed for every volume.
123 pub rigid: RigidParams,
124 /// Seed each volume's search from the previous volume's solution rather than from the
125 /// identity.
126 ///
127 /// A series is acquired in order, so volume *t* usually sits close to volume *t−1* — the
128 /// strong prior [`RigidParams::initial`] exists for. The walk starts at the reference and
129 /// moves outward in both directions, so the seed is always the nearest already-solved
130 /// neighbour. All volumes register against the same fixed volume, so the parameters are
131 /// defined about one rotation centre and carry between pairs without conversion.
132 ///
133 /// The reference's own two neighbours are excepted: their nearest solved neighbour *is* the
134 /// reference, whose solution is the identity by construction and therefore not a prior at
135 /// all, so they run the full pyramid rather than trade capture range for nothing.
136 ///
137 /// Worth turning off if the series order is not acquisition order, or if one volume is so
138 /// corrupted that its solution would be a bad seed for its neighbour rather than a good one.
139 ///
140 /// On its own a seed buys almost nothing — see [`Self::chain_levels`], which is what makes it
141 /// pay.
142 pub chain: bool,
143 /// Pyramid levels to use for a volume that has a real chained seed, replacing
144 /// [`RigidParams::levels`]. Ignored for the reference's two neighbours, which have no real
145 /// seed — see [`Self::chain`].
146 ///
147 /// A seed alone is nearly free of benefit, which is worth stating because it is not what the
148 /// hint on [`RigidParams::initial`] suggests. Measured on the 164x205x205 phantom, a
149 /// four-volume drifting series registered against one reference:
150 ///
151 /// | | metric evaluations | worst rotation error |
152 /// |---|---|---|
153 /// | cold start, 5 levels | 4372 | 0.012° |
154 /// | chained seed, 5 levels | 4310 | 0.012° |
155 /// | chained seed, 3 levels | 2331 | 0.012° |
156 /// | chained seed, 1 level | 938 | 0.013° |
157 ///
158 /// The coarse levels throw the seed away: they smooth and decimate until the cost surface's
159 /// own minimum is somewhere else, walk to it, and arrive at the finer levels having learned
160 /// nothing from the prior. A seed only pays once the coarse search it is meant to *replace*
161 /// is removed.
162 ///
163 /// Evaluations are the honest unit here, and they are not all priced the same: a level below
164 /// the third costs a few thousand samples against the 200k cap
165 /// ([`RigidParams::max_samples`]), so dropping those levels removes evaluations that were
166 /// nearly free. Expect the wall-clock saving to be smaller than the ratio above — the figures
167 /// are deliberately not quoted in seconds, because the machine they were measured on was not
168 /// quiet enough for that to mean anything.
169 ///
170 /// `3` is the default because the pyramid's job is capture range, and the capture range a
171 /// seeded registration needs is only the motion *between neighbouring volumes* — not the
172 /// whole head rotation. Three levels was measured at 40° in [`RigidParams::levels`], which
173 /// covers any inter-volume motion that is not an outright jump, at half the cost of the full
174 /// pyramid. Drop it to `1` for a series known to drift smoothly; raise it to
175 /// [`RigidParams::levels`] to pay the full price for the full capture range, which is what
176 /// `chain: false` does anyway.
177 pub chain_levels: usize,
178 /// B0 direction in each volume's **own voxel frame**, in the convention
179 /// [`b0_direction_from_affine`] returns, when a sidecar declares it. `None` reads it off each
180 /// volume's affine, which is right whenever that affine is the scanner's own.
181 ///
182 /// One value for the series, not one per volume: a series is one prescription, so every
183 /// volume was acquired with B0 in the same place relative to its own grid. What differs
184 /// between them is where the *head* was, which is what the registration recovers.
185 pub declared_b0: Option<(f64, f64, f64)>,
186 /// NCC below which an estimate is marked [`VolumeMotion::suspect`].
187 ///
188 /// A floor for "something went wrong", not a quality target. Two *repeats* that genuinely
189 /// align land above ~0.99, but echoes do not, and the default is set for echoes because they
190 /// are the common case: on the four-echo 7 T phantom, echoes at TE 12, 20 and 28 ms
191 /// registered to a 4 ms reference scored NCC 0.84, 0.68 and 0.55 — while recovering a known
192 /// 2.5-7.5 degree rotation to 0.04 degrees every time. See [`estimate_motion`] for why.
193 /// `0.3` sits below that and still well clear of the ~0.2 a genuinely failed search returns;
194 /// `0.5` would have flagged that last echo, whose transform was right to four decimal
195 /// places. Raise it to ~0.9 for a series of repeats, where a low value really would mean
196 /// something was wrong.
197 pub min_ncc: f64,
198}
199
200impl Default for MotionParams {
201 fn default() -> Self {
202 Self {
203 reference: MotionReference::First,
204 rigid: RigidParams::default(),
205 chain: true,
206 chain_levels: 3,
207 declared_b0: None,
208 min_ncc: 0.3,
209 }
210 }
211}
212
213/// One volume of a series, borrowed: the magnitude to register on and the grid it lives on.
214///
215/// Volumes of one series normally share a grid — [`uniform_series`] builds that case in one
216/// call — but separately prescribed runs need not, so the grid is carried per volume.
217#[derive(Clone, Copy, Debug)]
218pub struct SeriesVolume<'a> {
219 /// Magnitude. Registration is estimated on this and never on phase.
220 pub magnitude: &'a [f64],
221 /// Volume dimensions, column-major.
222 pub dims: (usize, usize, usize),
223 /// Row-major voxel→world affine.
224 pub affine: [f64; 16],
225}
226
227/// Borrow a series of volumes that all share one grid — the echoes of one acquisition, or
228/// repeats of one prescription.
229pub fn uniform_series<'a, T: AsRef<[f64]>>(
230 magnitudes: &'a [T],
231 dims: (usize, usize, usize),
232 affine: &[f64; 16],
233) -> Vec<SeriesVolume<'a>> {
234 magnitudes
235 .iter()
236 .map(|m| SeriesVolume {
237 magnitude: m.as_ref(),
238 dims,
239 affine: *affine,
240 })
241 .collect()
242}
243
244/// Where one volume of a series was, and which way B0 pointed while it was there.
245#[derive(Clone, Debug)]
246pub struct VolumeMotion {
247 /// Index into the input series.
248 pub index: usize,
249 /// The recovered alignment onto the reference volume. Identity for the reference itself.
250 pub transform: RigidTransform,
251 /// This volume's B0 direction in the **reference grid's** voxel frame — the direction its
252 /// field map was acquired under, and the one a dipole kernel built on the reference grid
253 /// has to use for it.
254 ///
255 /// For the reference volume this is just its own direction. For a volume whose head had
256 /// rotated it is that direction carried through the recovered rotation, which is the whole
257 /// point: the object rotated and B0 did not.
258 pub bdir: (f64, f64, f64),
259 /// Total rotation angle in degrees, regardless of axis.
260 pub rotation_deg: f64,
261 /// Translation magnitude in mm of world space.
262 pub translation_mm: f64,
263 /// Largest distance any tissue inside the evaluation domain actually moved, in mm.
264 ///
265 /// Rotation and translation are not comparable to each other or to a voxel size, and a small
266 /// rotation about a distant centre moves tissue a long way. This is the single number that
267 /// answers "did this matter": compare it against the voxel size. The domain is the mask when
268 /// one was passed to [`estimate_motion`] and the reference grid's corners otherwise; the
269 /// maximum of a rigid displacement field over a convex region is attained at an extreme
270 /// point, so the corner case is exact for the box rather than a sample of it.
271 pub max_displacement_mm: f64,
272 /// Normalised cross-correlation at the solution. `1.0` for the reference volume.
273 pub ncc: f64,
274 /// Fraction of sampled reference voxels that found a sample in this volume. `1.0` for the
275 /// reference volume.
276 pub overlap: f64,
277 /// The **estimate** is not to be trusted: NCC below [`MotionParams::min_ncc`], or overlap
278 /// below [`RigidParams::min_overlap`].
279 ///
280 /// Not a motion flag — a volume can move a long way and be registered perfectly, and that is
281 /// the normal case. This says the registration itself may have failed, so `transform`,
282 /// `bdir` and everything derived from them are questionable. Deciding what to do about a
283 /// volume whose *data* is corrupted — dropping it, downweighting it in an echo
284 /// combination — is a separate problem that belongs with [`crate::utils::multi_echo`]'s
285 /// weighting, and this module deliberately does not do it.
286 pub suspect: bool,
287}
288
289/// Where every volume of a series was, relative to the reference.
290///
291/// The output grid is the reference volume's grid throughout: `apply*` resamples onto it, and
292/// [`VolumeMotion::bdir`] is expressed in it.
293#[derive(Clone, Debug)]
294pub struct MotionSeries {
295 /// Index of the reference volume within the series.
296 pub reference: usize,
297 /// One entry per input volume, in input order. The reference's own entry is present and is
298 /// the identity, so indices line up with the caller's data and nothing has to be skipped.
299 pub volumes: Vec<VolumeMotion>,
300}
301
302/// A series of volumes put onto the reference grid, with the B0 direction each was acquired
303/// under. The "pass downstream" half of Tier A.
304#[derive(Clone, Debug)]
305pub struct CorrectedSeries {
306 /// Per-volume magnitude on the reference grid.
307 pub magnitudes: Vec<Vec<f64>>,
308 /// Per-volume wrapped phase on the reference grid, moved through the complex domain so the
309 /// wraps survived.
310 pub phases: Vec<Vec<f64>>,
311 /// Per-volume B0 direction in the reference grid's voxel frame — [`VolumeMotion::bdir`],
312 /// flattened for handing straight to a multi-orientation inversion.
313 pub bdirs: Vec<(f64, f64, f64)>,
314 /// Reference voxels every volume of the series reaches. Resampling can only fill the part of
315 /// the reference grid a rotated volume still covers, so the rest is not data; masking to
316 /// this intersection is what keeps an edge of zeros out of a field fit.
317 pub coverage: Vec<u8>,
318 /// The motion that was estimated and applied.
319 pub motion: MotionSeries,
320}
321
322/// A complex average of a motion-corrected series.
323///
324/// Averaging phase across volumes acquired at **different head orientations** averages phase
325/// accumulated under different dipole-kernel orientations, so this is an approximation whose
326/// error grows with [`MotionSeries::rotation_spread_deg`]. It is exactly right for repeats that
327/// did not rotate, a good approximation for a degree or two, and the wrong tool once the spread
328/// is large enough to matter — at which point the spread is itself the signal, and
329/// [`CorrectedSeries`] with a per-volume `bdir` is the honest path.
330#[derive(Clone, Debug)]
331pub struct MotionAverage {
332 /// Complex-mean magnitude on the reference grid.
333 pub magnitude: Vec<f64>,
334 /// Complex-mean wrapped phase on the reference grid.
335 pub phase: Vec<f64>,
336 /// How many volumes of the series actually reached each reference voxel. `0` means no data,
337 /// not a zero measurement.
338 pub contributions: Vec<u32>,
339 /// Rotation spread the average was taken over, in degrees — [`MotionSeries::rotation_spread_deg`],
340 /// repeated here because it is the number that decides whether the average is trustworthy.
341 pub rotation_spread_deg: f64,
342}
343
344// ---------------------------------------------------------------------- estimation
345
346/// Estimate where every volume of a series was, relative to a reference volume.
347///
348/// `mask` is optional and lives on the **reference** volume's grid. Passing a brain mask is worth
349/// it twice over: it keeps air, neck and the receive-field falloff out of the correlation, and it
350/// is the domain [`VolumeMotion::max_displacement_mm`] is measured over.
351///
352/// Returns `None` if the series is empty, if [`MotionParams::reference`] does not name a volume,
353/// if any volume's data length disagrees with its dimensions, if the mask length disagrees with
354/// the reference volume, or if any pairwise registration could not be evaluated at all. A
355/// per-volume failure fails the whole call rather than quietly substituting the identity, because
356/// an identity transform for a volume that moved is indistinguishable downstream from a volume
357/// that did not move.
358///
359/// # Echoes are harder than repeats
360///
361/// Repeats and runs are the easy case: same contrast, and NCC absorbs the gain difference
362/// between them. Echoes are not — T2\* decay changes contrast *spatially*, far more in
363/// iron-rich grey matter and vein than in white matter, so a late echo is not a scaled copy of
364/// the first one. NCC still finds the alignment, because the anatomy's edges stay where they
365/// are, but its *value* falls: on the four-echo 7 T phantom, echoes at TE 12, 20 and 28 ms
366/// registered to a 4 ms reference scored 0.84, 0.68 and 0.55 while recovering a known 2.5-7.5
367/// degree rotation to 0.04 degrees. A low NCC on an echo series is therefore the contrast and
368/// not a failure; [`MotionParams::min_ncc`] is defaulted for that case and should be *raised*
369/// for repeats. The same caveat applies, for the same reason, to receive-field shading that
370/// rotates with the coil rather than the head — see [`crate::registration`] on what NCC does
371/// and does not absorb.
372pub fn estimate_motion(
373 series: &[SeriesVolume<'_>],
374 mask: Option<&[u8]>,
375 params: &MotionParams,
376) -> Option<MotionSeries> {
377 let n = series.len();
378 let reference = params.reference.resolve(n)?;
379
380 for v in series {
381 if v.magnitude.len() != v.dims.0 * v.dims.1 * v.dims.2 {
382 return None;
383 }
384 }
385 let fixed = series[reference];
386 if let Some(m) = mask {
387 if m.len() != fixed.magnitude.len() {
388 return None;
389 }
390 }
391
392 // The reference's own entry: the identity between its grid and itself, with the metrics it
393 // trivially achieves rather than the NaNs `RigidTransform::identity` leaves for a caller who
394 // never measured anything. A perfect self-correlation is not a claim about the data, so
395 // nothing downstream can be misled by it; a NaN here would silently poison every `max` and
396 // comparison over the series.
397 let identity = {
398 let mut t = RigidTransform::identity(fixed.dims, &fixed.affine, fixed.dims, &fixed.affine);
399 t.ncc = 1.0;
400 t.overlap = 1.0;
401 t
402 };
403
404 let mut solved: Vec<Option<RigidTransform>> = vec![None; n];
405 solved[reference] = Some(identity);
406
407 // Walk outward from the reference in both directions, so a chained seed is always the
408 // nearest already-solved neighbour rather than whichever volume happened to come first.
409 let order: Vec<usize> = (reference + 1..n).chain((0..reference).rev()).collect();
410 for t in order {
411 // A chained seed replaces the coarse search rather than supplementing it, so it comes
412 // with its own (shallower) pyramid — but only where the seed is worth something. The
413 // reference's own two neighbours are seeded from the reference, whose solution is the
414 // identity by construction and so says nothing about where they are; shortening their
415 // pyramid would throw away capture range in exchange for a prior that does not exist.
416 // They pay full price, which in a series of any length is two registrations.
417 let neighbour = if t > reference { t - 1 } else { t + 1 };
418 let seed = if params.chain && neighbour != reference {
419 solved[neighbour].as_ref().map(|p| p.params)
420 } else {
421 None
422 };
423 let rigid = match seed {
424 Some(initial) => RigidParams {
425 initial: Some(initial),
426 levels: params.chain_levels.max(1),
427 ..params.rigid.clone()
428 },
429 None => params.rigid.clone(),
430 };
431 let moving = series[t];
432 let tf = register_rigid(
433 fixed.magnitude,
434 fixed.dims,
435 &fixed.affine,
436 moving.magnitude,
437 moving.dims,
438 &moving.affine,
439 mask,
440 &rigid,
441 )?;
442 solved[t] = Some(tf);
443 }
444
445 let volumes = solved
446 .into_iter()
447 .enumerate()
448 .map(|(index, tf)| {
449 let transform = tf.expect("every index is either the reference or in the walk");
450 let bdir = transform.b0_direction_in_fixed(params.declared_b0);
451 let [tx, ty, tz] = transform.translation_mm();
452 VolumeMotion {
453 index,
454 rotation_deg: transform.rotation_magnitude_deg(),
455 translation_mm: (tx * tx + ty * ty + tz * tz).sqrt(),
456 max_displacement_mm: max_displacement_mm(&transform, mask),
457 ncc: transform.ncc,
458 overlap: transform.overlap,
459 suspect: transform.ncc < params.min_ncc
460 || transform.overlap < params.rigid.min_overlap,
461 bdir,
462 transform,
463 }
464 })
465 .collect();
466
467 Some(MotionSeries { reference, volumes })
468}
469
470/// Largest `|M·x − x|` over the evaluation domain, in mm.
471///
472/// `M` maps fixed world → moving world, and both are the *same scanner's* world — the scanner
473/// does not move — so for the bit of tissue the reference has at world `x`, `M·x` is where that
474/// same tissue was during the moving acquisition. The difference is the distance it travelled.
475fn max_displacement_mm(t: &RigidTransform, mask: Option<&[u8]>) -> f64 {
476 let a = &t.fixed_affine;
477 let m = &t.matrix;
478 let (nx, ny, nz) = t.fixed_dims;
479 let displacement = |i: f64, j: f64, k: f64| {
480 let w = [
481 a[0] * i + a[1] * j + a[2] * k + a[3],
482 a[4] * i + a[5] * j + a[6] * k + a[7],
483 a[8] * i + a[9] * j + a[10] * k + a[11],
484 ];
485 let d: [f64; 3] = std::array::from_fn(|r| {
486 m[4 * r] * w[0] + m[4 * r + 1] * w[1] + m[4 * r + 2] * w[2] + m[4 * r + 3] - w[r]
487 });
488 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
489 };
490 match mask {
491 Some(mask) => {
492 let mut worst = 0.0f64;
493 for k in 0..nz {
494 for j in 0..ny {
495 for i in 0..nx {
496 if mask[i + j * nx + k * nx * ny] != 0 {
497 worst = worst.max(displacement(i as f64, j as f64, k as f64));
498 }
499 }
500 }
501 }
502 worst
503 }
504 // The maximum of a rigid displacement field over a convex region is attained at an
505 // extreme point, so the eight corners are exact for the grid's bounding box.
506 None => {
507 let mut worst = 0.0f64;
508 for &k in &[0.0, nz as f64 - 1.0] {
509 for &j in &[0.0, ny as f64 - 1.0] {
510 for &i in &[0.0, nx as f64 - 1.0] {
511 worst = worst.max(displacement(i, j, k));
512 }
513 }
514 }
515 worst
516 }
517 }
518}
519
520// ---------------------------------------------------------------------- application
521
522impl MotionSeries {
523 /// Number of volumes in the series.
524 pub fn len(&self) -> usize {
525 self.volumes.len()
526 }
527
528 /// Whether the series is empty. Never true for a [`MotionSeries`] [`estimate_motion`]
529 /// returned, which rejects an empty series.
530 pub fn is_empty(&self) -> bool {
531 self.volumes.is_empty()
532 }
533
534 /// Dimensions of the output grid — the reference volume's.
535 pub fn dims(&self) -> (usize, usize, usize) {
536 self.volumes[self.reference].transform.fixed_dims
537 }
538
539 /// Voxel→world affine of the output grid — the reference volume's.
540 pub fn affine(&self) -> [f64; 16] {
541 self.volumes[self.reference].transform.fixed_affine
542 }
543
544 /// Every volume's B0 direction in the reference grid's voxel frame, in input order — ready to
545 /// hand to a multi-orientation inversion alongside the resampled field maps.
546 pub fn bdirs(&self) -> Vec<(f64, f64, f64)> {
547 self.volumes.iter().map(|v| v.bdir).collect()
548 }
549
550 /// The largest tissue displacement anywhere in the series, in mm — the one number to compare
551 /// against the voxel size when deciding whether the motion mattered. See
552 /// [`VolumeMotion::max_displacement_mm`].
553 pub fn max_displacement_mm(&self) -> f64 {
554 self.volumes
555 .iter()
556 .fold(0.0f64, |m, v| m.max(v.max_displacement_mm))
557 }
558
559 /// The largest angle between any two volumes' recovered B0 directions, in degrees.
560 ///
561 /// This, not the rotation relative to the reference, is what decides whether the series can
562 /// be averaged: it is the spread of dipole-kernel orientations the average would be taken
563 /// over. Zero for a series that only translated, however far it translated.
564 pub fn rotation_spread_deg(&self) -> f64 {
565 let mut worst = 0.0f64;
566 for (i, a) in self.volumes.iter().enumerate() {
567 for b in &self.volumes[i + 1..] {
568 let dot = (a.bdir.0 * b.bdir.0 + a.bdir.1 * b.bdir.1 + a.bdir.2 * b.bdir.2)
569 .clamp(-1.0, 1.0);
570 worst = worst.max(dot.acos().to_degrees());
571 }
572 }
573 worst
574 }
575
576 /// The volumes whose *estimate* looks untrustworthy. See [`VolumeMotion::suspect`].
577 pub fn suspect(&self) -> impl Iterator<Item = &VolumeMotion> {
578 self.volumes.iter().filter(|v| v.suspect)
579 }
580
581 /// Resample volume `t`'s continuous data (magnitude, an unwrapped field map, χ) onto the
582 /// reference grid.
583 ///
584 /// **Not for wrapped phase.** Interpolating wrapped phase directly turns every wrap into a
585 /// band of wrong values; use [`Self::apply_complex`], which is why it exists.
586 pub fn apply(&self, t: usize, data: &[f64]) -> Option<Vec<f64>> {
587 self.volumes.get(t)?.transform.resample(data)
588 }
589
590 /// Resample volume `t`'s magnitude and wrapped phase onto the reference grid together,
591 /// interpolating `mag·e^{iφ}` so the wraps survive. Returns `(magnitude, phase)`.
592 pub fn apply_complex(
593 &self,
594 t: usize,
595 magnitude: &[f64],
596 phase: &[f64],
597 ) -> Option<(Vec<f64>, Vec<f64>)> {
598 self.volumes.get(t)?.transform.resample_complex(magnitude, phase)
599 }
600
601 /// Resample volume `t`'s binary mask or label volume onto the reference grid (nearest
602 /// neighbour).
603 pub fn apply_mask(&self, t: usize, mask: &[u8]) -> Option<Vec<u8>> {
604 self.volumes.get(t)?.transform.resample_mask(mask)
605 }
606
607 /// Which reference voxels volume `t` reaches at all.
608 ///
609 /// A rotated volume cannot fill the whole reference grid, and resampling leaves zeros where
610 /// it does not reach. Those zeros are "no data", not a zero measurement, and this is what
611 /// tells them apart.
612 pub fn coverage(&self, t: usize) -> Option<Vec<u8>> {
613 let v = self.volumes.get(t)?;
614 let (mx, my, mz) = v.transform.moving_dims;
615 v.transform.resample_mask(&vec![1u8; mx * my * mz])
616 }
617
618 /// Reference voxels that **every** volume of the series reaches.
619 pub fn common_coverage(&self) -> Option<Vec<u8>> {
620 let (nx, ny, nz) = self.dims();
621 let mut out = vec![1u8; nx * ny * nz];
622 for t in 0..self.len() {
623 let c = self.coverage(t)?;
624 for (o, &v) in out.iter_mut().zip(c.iter()) {
625 *o &= v;
626 }
627 }
628 Some(out)
629 }
630
631 /// Complex-average the series onto the reference grid: resample every volume's
632 /// magnitude/phase pair through its recovered transform, then take the complex mean over the
633 /// volumes that reached each voxel.
634 ///
635 /// The plain complex mean, not a magnitude-weighted one: for repeats with the same noise
636 /// level that is the maximum-likelihood combination, and weighting by magnitude would bias
637 /// the result toward whichever volume happened to be brightest.
638 ///
639 /// Read [`MotionAverage`] before using this on a series that rotated.
640 ///
641 /// Returns `None` if the number of magnitude or phase volumes disagrees with the series, or
642 /// if any volume's length disagrees with its own grid.
643 pub fn average_complex<T: AsRef<[f64]>>(
644 &self,
645 magnitudes: &[T],
646 phases: &[T],
647 ) -> Option<MotionAverage> {
648 if magnitudes.len() != self.len() || phases.len() != self.len() {
649 return None;
650 }
651 let (nx, ny, nz) = self.dims();
652 let n_out = nx * ny * nz;
653 let mut re = vec![0.0f64; n_out];
654 let mut im = vec![0.0f64; n_out];
655 let mut contributions = vec![0u32; n_out];
656
657 for t in 0..self.len() {
658 let (mag, pha) = (magnitudes[t].as_ref(), phases[t].as_ref());
659 let v = &self.volumes[t];
660 let (mx, my, mz) = v.transform.moving_dims;
661 if mag.len() != mx * my * mz || pha.len() != mx * my * mz {
662 return None;
663 }
664 let (m, p) = self.apply_complex(t, mag, pha)?;
665 let cover = self.coverage(t)?;
666 for i in 0..n_out {
667 if cover[i] != 0 {
668 re[i] += m[i] * p[i].cos();
669 im[i] += m[i] * p[i].sin();
670 contributions[i] += 1;
671 }
672 }
673 }
674
675 let mut magnitude = vec![0.0f64; n_out];
676 let mut phase = vec![0.0f64; n_out];
677 for i in 0..n_out {
678 if contributions[i] > 0 {
679 let inv = 1.0 / contributions[i] as f64;
680 let (r, m) = (re[i] * inv, im[i] * inv);
681 magnitude[i] = r.hypot(m);
682 phase[i] = m.atan2(r);
683 }
684 }
685
686 Some(MotionAverage {
687 magnitude,
688 phase,
689 contributions,
690 rotation_spread_deg: self.rotation_spread_deg(),
691 })
692 }
693}
694
695/// Estimate the motion across a complex series and put every volume onto the reference grid.
696///
697/// The whole of Tier A in one call, for a series that shares a grid: register on magnitude,
698/// resample the magnitude/phase pair through the complex domain, and carry each volume's B0
699/// direction along with it.
700///
701/// `mask` is optional and lives on the reference volume's grid; see [`estimate_motion`] for what
702/// it buys and for the `None` cases.
703pub fn correct_motion<T: AsRef<[f64]>>(
704 magnitudes: &[T],
705 phases: &[T],
706 dims: (usize, usize, usize),
707 affine: &[f64; 16],
708 mask: Option<&[u8]>,
709 params: &MotionParams,
710) -> Option<CorrectedSeries> {
711 if magnitudes.len() != phases.len() {
712 return None;
713 }
714 let series = uniform_series(magnitudes, dims, affine);
715 let motion = estimate_motion(&series, mask, params)?;
716
717 let mut out_mag = Vec::with_capacity(motion.len());
718 let mut out_pha = Vec::with_capacity(motion.len());
719 for t in 0..motion.len() {
720 let (m, p) = motion.apply_complex(t, magnitudes[t].as_ref(), phases[t].as_ref())?;
721 out_mag.push(m);
722 out_pha.push(p);
723 }
724
725 Some(CorrectedSeries {
726 magnitudes: out_mag,
727 phases: out_pha,
728 bdirs: motion.bdirs(),
729 coverage: motion.common_coverage()?,
730 motion,
731 })
732}
733
734/// B0 direction a volume's own affine implies, in its own voxel frame — re-exported shape of
735/// [`b0_direction_from_affine`] so a caller assembling [`MotionParams::declared_b0`] by hand can
736/// see the convention it has to match.
737pub fn implied_b0(affine: &[f64; 16]) -> (f64, f64, f64) {
738 b0_direction_from_affine(affine)
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use crate::geometry::resample_complex_onto;
745
746 fn identity_affine(vs: (f64, f64, f64)) -> [f64; 16] {
747 [
748 vs.0, 0.0, 0.0, 0.0, //
749 0.0, vs.1, 0.0, 0.0, //
750 0.0, 0.0, vs.2, 0.0, //
751 0.0, 0.0, 0.0, 1.0,
752 ]
753 }
754
755 /// Row-major 4x4 product, written out here rather than borrowed from `registration`'s private
756 /// helper so the ground truth below shares no machinery with the code under test.
757 fn mul4(a: &[f64; 16], b: &[f64; 16]) -> [f64; 16] {
758 let mut out = [0.0f64; 16];
759 for i in 0..4 {
760 for j in 0..4 {
761 out[4 * i + j] = (0..4).map(|k| a[4 * i + k] * b[4 * k + j]).sum();
762 }
763 }
764 out
765 }
766
767 /// `mul4` is the one piece of algebra the ground truth shares with nothing, so it is pinned
768 /// against a product worked out by hand before anything is built on it.
769 #[test]
770 fn mul4_multiplies_row_major() {
771 let a = [
772 1.0, 2.0, 0.0, 3.0, //
773 0.0, 1.0, 4.0, 0.0, //
774 5.0, 0.0, 1.0, 1.0, //
775 0.0, 0.0, 0.0, 1.0,
776 ];
777 let b = [
778 0.0, 1.0, 2.0, 1.0, //
779 3.0, 0.0, 1.0, 0.0, //
780 1.0, 1.0, 0.0, 2.0, //
781 0.0, 0.0, 0.0, 1.0,
782 ];
783 let want = [
784 6.0, 1.0, 4.0, 4.0, //
785 7.0, 4.0, 1.0, 8.0, //
786 1.0, 6.0, 10.0, 8.0, //
787 0.0, 0.0, 0.0, 1.0,
788 ];
789 let got = mul4(&a, &b);
790 for i in 0..16 {
791 assert!((got[i] - want[i]).abs() < 1e-12, "element {i}: {} vs {}", got[i], want[i]);
792 }
793 }
794
795 /// A rigid 4x4 rotating `deg` about `axis` through the grid's world centre, plus `shift`.
796 ///
797 /// Rodrigues, written out. The ground truth must not come from the same parameterisation the
798 /// implementation uses, or a transposed rotation would cancel on both sides of every
799 /// comparison and the recovery assertions would be unfailable — which is exactly what
800 /// happened to the first draft of `registration`'s tests.
801 fn truth(
802 axis: [f64; 3],
803 deg: f64,
804 shift: [f64; 3],
805 dims: (usize, usize, usize),
806 affine: &[f64; 16],
807 ) -> [f64; 16] {
808 let n = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
809 let k = [axis[0] / n, axis[1] / n, axis[2] / n];
810 let (s, c) = deg.to_radians().sin_cos();
811 let kx = [
812 [0.0, -k[2], k[1]],
813 [k[2], 0.0, -k[0]],
814 [-k[1], k[0], 0.0],
815 ];
816 let mut r = [[0.0f64; 3]; 3];
817 for i in 0..3 {
818 for j in 0..3 {
819 let kk: f64 = (0..3).map(|m| kx[i][m] * kx[m][j]).sum();
820 r[i][j] = (i == j) as u8 as f64 + s * kx[i][j] + (1.0 - c) * kk;
821 }
822 }
823 let ctr = [
824 (dims.0 as f64 - 1.0) * 0.5 * affine[0],
825 (dims.1 as f64 - 1.0) * 0.5 * affine[5],
826 (dims.2 as f64 - 1.0) * 0.5 * affine[10],
827 ];
828 let mut m = [0.0f64; 16];
829 for i in 0..3 {
830 m[4 * i..4 * i + 3].copy_from_slice(&r[i]);
831 m[4 * i + 3] =
832 ctr[i] - (r[i][0] * ctr[0] + r[i][1] * ctr[1] + r[i][2] * ctr[2]) + shift[i];
833 }
834 m[15] = 1.0;
835 m
836 }
837
838 /// Rotate a free vector by the transpose of the 3x3 of `m`.
839 fn rotate_t(m: &[f64; 16], v: [f64; 3]) -> [f64; 3] {
840 std::array::from_fn(|i| m[i] * v[0] + m[4 + i] * v[1] + m[8 + i] * v[2])
841 }
842
843 fn angle_deg(a: (f64, f64, f64), b: [f64; 3]) -> f64 {
844 let nb = (b[0] * b[0] + b[1] * b[1] + b[2] * b[2]).sqrt();
845 let dot = (a.0 * b[0] + a.1 * b[1] + a.2 * b[2]) / nb;
846 dot.clamp(-1.0, 1.0).acos().to_degrees()
847 }
848
849 /// Rotation angle of `Aᵀ·B`, in degrees.
850 fn rotation_error_deg(a: &[f64; 16], b: &[f64; 16]) -> f64 {
851 let trace: f64 = (0..3)
852 .map(|i| (0..3).map(|j| a[4 * j + i] * b[4 * j + i]).sum::<f64>())
853 .sum();
854 (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos().to_degrees()
855 }
856
857 fn translation_error_mm(a: &[f64; 16], b: &[f64; 16]) -> f64 {
858 [a[3] - b[3], a[7] - b[7], a[11] - b[11]]
859 .iter()
860 .fold(0.0f64, |m, d| m.max(d.abs()))
861 }
862
863 /// A deterministic lumpy phantom: three ellipsoidal blobs of different brightness inside a
864 /// larger one, which gives the metric real structure in all three axes. The same phantom
865 /// `registration`'s own tests use, so the accuracy these tests can expect is the accuracy
866 /// that module measured on it (sub-degree, not the 0.01° it reaches on real data).
867 fn phantom(dims: (usize, usize, usize)) -> Vec<f64> {
868 let (nx, ny, nz) = dims;
869 let mut v = vec![0.0f64; nx * ny * nz];
870 let blobs = [
871 // (cx, cy, cz, rx, ry, rz, value) in fractions of the volume
872 (0.50, 0.50, 0.50, 0.34, 0.26, 0.30, 1.0),
873 (0.36, 0.44, 0.52, 0.10, 0.09, 0.11, 2.2),
874 (0.63, 0.57, 0.44, 0.08, 0.13, 0.07, 1.7),
875 (0.50, 0.62, 0.64, 0.07, 0.06, 0.09, 2.8),
876 ];
877 for k in 0..nz {
878 for j in 0..ny {
879 for i in 0..nx {
880 let (x, y, z) = (
881 i as f64 / nx as f64,
882 j as f64 / ny as f64,
883 k as f64 / nz as f64,
884 );
885 let mut acc = 0.0;
886 for &(cx, cy, cz, rx, ry, rz, val) in &blobs {
887 let d = ((x - cx) / rx).powi(2)
888 + ((y - cy) / ry).powi(2)
889 + ((z - cz) / rz).powi(2);
890 if d <= 1.0 {
891 // Smooth falloff, so trilinear interpolation has a gradient to work
892 // with rather than a staircase.
893 acc += val * (1.0 - d).sqrt();
894 }
895 }
896 v[i + j * nx + k * nx * ny] = acc;
897 }
898 }
899 }
900 v
901 }
902
903 /// An ellipsoid enclosing the phantom's envelope with a margin — a stand-in brain mask, used
904 /// where a *domain* is the thing under test.
905 ///
906 /// Deliberately **not** passed to the registrations that check recovery, which run unmasked.
907 /// This phantom's exterior is exactly zero, so the background is information rather than the
908 /// noise, neck and bias falloff a mask exists to exclude on real data: masking it away leaves
909 /// a centred ellipsoid that is near-symmetric under a half turn, and a shift of five voxels
910 /// or more then converges to an exact 180° flip at NCC 0.78 at every pyramid depth. The same
911 /// transforms recover to 0.003 mm on the 164x205x205 phantom *with* its real brain mask, so
912 /// this is the toy's symmetry and not the masked path — which
913 /// `displacement_is_the_distance_tissue_actually_moved` exercises, on a rotation, where it is
914 /// well inside the toy's capture range.
915 fn phantom_mask(dims: (usize, usize, usize)) -> Vec<u8> {
916 let (nx, ny, nz) = dims;
917 let mut v = vec![0u8; nx * ny * nz];
918 for k in 0..nz {
919 for j in 0..ny {
920 for i in 0..nx {
921 let (x, y, z) = (
922 i as f64 / nx as f64,
923 j as f64 / ny as f64,
924 k as f64 / nz as f64,
925 );
926 let d = ((x - 0.50) / 0.37).powi(2)
927 + ((y - 0.50) / 0.32).powi(2)
928 + ((z - 0.52) / 0.34).powi(2);
929 v[i + j * nx + k * nx * ny] = (d <= 1.0) as u8;
930 }
931 }
932 }
933 v
934 }
935
936 /// A wrapped phase field with real wraps in it: a linear ramp steep enough to wrap several
937 /// times across the volume, wrapped into (-pi, pi].
938 fn wrapped_ramp(dims: (usize, usize, usize)) -> Vec<f64> {
939 let (nx, ny, nz) = dims;
940 let tau = 2.0 * std::f64::consts::PI;
941 let mut v = vec![0.0f64; nx * ny * nz];
942 for k in 0..nz {
943 for j in 0..ny {
944 for i in 0..nx {
945 let raw = 0.9 * i as f64 + 0.35 * j as f64 + 0.2 * k as f64;
946 v[i + j * nx + k * nx * ny] = raw - tau * ((raw / tau) + 0.5).floor();
947 }
948 }
949 }
950 v
951 }
952
953 /// Build the moving volume a known world transform `w` (fixed world → moving world) would
954 /// have produced, for a complex pair.
955 ///
956 /// Sampling through `w·affine` is exactly the forward model `RigidTransform::resample`
957 /// inverts, so a registration that recovers `w` is the only way this round-trips — which is
958 /// what makes these tests able to catch an inverted or transposed transform rather than just
959 /// a blurry one.
960 fn warp_complex(
961 mag: &[f64],
962 pha: &[f64],
963 dims: (usize, usize, usize),
964 affine: &[f64; 16],
965 w: &[f64; 16],
966 ) -> (Vec<f64>, Vec<f64>) {
967 resample_complex_onto(mag, pha, dims, &mul4(w, affine), dims, affine).unwrap()
968 }
969
970 /// Absolute phase differences inside `domain`, each wrapped into (-pi, pi] first so a wrap
971 /// does not count as a 2-pi error.
972 fn phase_errors(a: &[f64], b: &[f64], domain: &[u8]) -> Vec<f64> {
973 let tau = 2.0 * std::f64::consts::PI;
974 (0..a.len())
975 .filter(|&i| domain[i] != 0)
976 .map(|i| {
977 let d = a[i] - b[i];
978 (d - tau * ((d / tau) + 0.5).floor()).abs()
979 })
980 .collect()
981 }
982
983 fn mean_abs_phase_error(a: &[f64], b: &[f64], domain: &[u8]) -> f64 {
984 let e = phase_errors(a, b, domain);
985 if e.is_empty() {
986 f64::INFINITY
987 } else {
988 e.iter().sum::<f64>() / e.len() as f64
989 }
990 }
991
992 /// Fraction of `domain` whose phase is wrong by more than a radian — the shape of damage a
993 /// mishandled wrap does, as opposed to the small smooth error interpolation always costs.
994 fn fraction_badly_wrong(a: &[f64], b: &[f64], domain: &[u8]) -> f64 {
995 let e = phase_errors(a, b, domain);
996 if e.is_empty() {
997 return 1.0;
998 }
999 e.iter().filter(|&&v| v > 1.0).count() as f64 / e.len() as f64
1000 }
1001
1002 // --------------------------------------------------------- guards
1003
1004 #[test]
1005 fn reference_resolves_or_refuses() {
1006 assert_eq!(MotionReference::First.resolve(4), Some(0));
1007 assert_eq!(MotionReference::Last.resolve(4), Some(3));
1008 assert_eq!(MotionReference::Index(2).resolve(4), Some(2));
1009 assert_eq!(MotionReference::Index(4).resolve(4), None);
1010 assert_eq!(MotionReference::First.resolve(0), None);
1011 assert_eq!(MotionReference::Last.resolve(0), None);
1012 assert_eq!(MotionReference::Index(0).resolve(0), None);
1013 }
1014
1015 #[test]
1016 fn empty_and_mismatched_series_are_refused() {
1017 let dims = (24, 24, 24);
1018 let affine = identity_affine((1.0, 1.0, 1.0));
1019 let p = MotionParams::default();
1020 assert!(estimate_motion(&[], None, &p).is_none(), "empty series");
1021
1022 let mag = phantom(dims);
1023 let series = uniform_series(std::slice::from_ref(&mag), dims, &affine);
1024 assert!(
1025 estimate_motion(&series, Some(&[1u8; 10]), &p).is_none(),
1026 "mask length must match the reference volume"
1027 );
1028
1029 let truncated = [SeriesVolume {
1030 magnitude: &mag[..mag.len() - 1],
1031 dims,
1032 affine,
1033 }];
1034 assert!(
1035 estimate_motion(&truncated, None, &p).is_none(),
1036 "a volume whose length disagrees with its own dims"
1037 );
1038
1039 let out_of_range = MotionParams {
1040 reference: MotionReference::Index(3),
1041 ..MotionParams::default()
1042 };
1043 assert!(
1044 estimate_motion(&series, None, &out_of_range).is_none(),
1045 "reference index past the end"
1046 );
1047
1048 // And the single-volume series it rejected those variants of does work.
1049 assert!(estimate_motion(&series, None, &p).is_some());
1050 }
1051
1052 // --------------------------------------------------------- recovery and B0
1053
1054 /// The headline claim: a series where volumes moved by known rigid transforms has those
1055 /// transforms recovered, and every volume's B0 direction comes back as the known rotation of
1056 /// the declared direction.
1057 #[test]
1058 fn a_known_transform_is_recovered_and_b0_follows_it() {
1059 let dims = (48, 48, 40);
1060 let affine = identity_affine((1.0, 1.0, 1.2));
1061 let mag = phantom(dims);
1062 let pha = wrapped_ramp(dims);
1063
1064 // Three volumes: the reference, a mostly-rotated one, a mostly-translated one.
1065 let moves = [
1066 truth([0.2, -0.3, 1.0], 9.0, [0.8, -0.5, 0.4], dims, &affine),
1067 truth([1.0, 0.4, 0.1], 4.0, [-2.2, 1.6, 0.9], dims, &affine),
1068 ];
1069 let mut mags = vec![mag.clone()];
1070 let mut phas = vec![pha.clone()];
1071 for w in &moves {
1072 let (m, p) = warp_complex(&mag, &pha, dims, &affine, w);
1073 mags.push(m);
1074 phas.push(p);
1075 }
1076
1077 let series = uniform_series(&mags, dims, &affine);
1078 let motion =
1079 estimate_motion(&series, None, &MotionParams::default()).expect("estimation runs");
1080
1081 assert_eq!(motion.reference, 0);
1082 assert_eq!(motion.len(), 3);
1083 assert_eq!(motion.dims(), dims);
1084 assert_eq!(motion.volumes[0].rotation_deg, 0.0, "the reference is the identity");
1085 assert_eq!(motion.volumes[0].translation_mm, 0.0);
1086 assert_eq!(motion.volumes[0].max_displacement_mm, 0.0);
1087 assert_eq!(motion.volumes[0].ncc, 1.0);
1088 assert!(!motion.volumes[0].suspect);
1089
1090 // B0 is read off the affine here, which is axial, so the reference's direction is voxel
1091 // +z and each moved volume's is that direction carried back through its own rotation.
1092 let declared = [0.0, 0.0, 1.0];
1093 assert!(
1094 angle_deg(motion.volumes[0].bdir, declared) < 1e-9,
1095 "reference bdir {:?} should be the affine's own direction",
1096 motion.volumes[0].bdir
1097 );
1098
1099 for (t, w) in moves.iter().enumerate() {
1100 let v = &motion.volumes[t + 1];
1101 let rot_err = rotation_error_deg(w, &v.transform.matrix);
1102 let trans_err = translation_error_mm(w, &v.transform.matrix);
1103 println!(
1104 "volume {}: rotation error {rot_err:.4} deg, translation error {trans_err:.4} mm, \
1105 NCC {:.4}, displacement {:.3} mm",
1106 t + 1,
1107 v.ncc,
1108 v.max_displacement_mm
1109 );
1110 // Measured ~0.4 deg / ~0.2 mm on this phantom, which is as well as `registration`
1111 // does on it (its own tests assert 1.0-2.0 deg); on real data the same code reaches
1112 // 0.01 deg. Thresholds set with roughly 2x headroom over the measurement.
1113 assert!(rot_err < 1.0, "volume {}: rotation off by {rot_err} deg", t + 1);
1114 assert!(trans_err < 0.5, "volume {}: translation off by {trans_err} mm", t + 1);
1115 assert!(v.ncc > 0.95, "volume {}: NCC only {}", t + 1, v.ncc);
1116 assert!(!v.suspect, "volume {} should not be suspect", t + 1);
1117
1118 // The object rotated and B0 did not: in the reference frame, this volume's B0 sits
1119 // where the inverse of its rotation puts the declared direction.
1120 let want = rotate_t(w, declared);
1121 let err = angle_deg(v.bdir, want);
1122 println!(
1123 "volume {}: bdir {:?} vs expected [{:.4} {:.4} {:.4}], {err:.4} deg",
1124 t + 1,
1125 v.bdir,
1126 want[0],
1127 want[1],
1128 want[2]
1129 );
1130 assert!(err < 1.0, "volume {}: bdir off by {err} deg", t + 1);
1131 // And it genuinely moved off +z, or the assertion above would pass for free for an
1132 // implementation that returned the reference direction every time.
1133 assert!(
1134 angle_deg(v.bdir, declared) > 1.0,
1135 "volume {}: bdir {:?} did not move away from the declared direction, so the \
1136 agreement above proves nothing",
1137 t + 1,
1138 v.bdir
1139 );
1140 }
1141 }
1142
1143 /// The thresholds above have to be able to fail. Handing the *unmoved* volume in as the
1144 /// moving one makes the true answer the identity, so scoring it against the same `moves`
1145 /// must blow through every one of them.
1146 #[test]
1147 fn the_recovery_thresholds_can_fail() {
1148 let dims = (48, 48, 40);
1149 let affine = identity_affine((1.0, 1.0, 1.2));
1150 let mag = phantom(dims);
1151 let w = truth([0.2, -0.3, 1.0], 9.0, [0.8, -0.5, 0.4], dims, &affine);
1152
1153 let mags = vec![mag.clone(), mag];
1154 let series = uniform_series(&mags, dims, &affine);
1155 let motion = estimate_motion(&series, None, &MotionParams::default()).unwrap();
1156 let v = &motion.volumes[1];
1157 assert!(
1158 rotation_error_deg(&w, &v.transform.matrix) > 1.0,
1159 "a 9-degree rotation must not pass the 1-degree threshold"
1160 );
1161 assert!(
1162 translation_error_mm(&w, &v.transform.matrix) > 0.5,
1163 "a 0.8mm offset must not pass the 0.5mm threshold"
1164 );
1165 assert!(
1166 angle_deg(v.bdir, rotate_t(&w, [0.0, 0.0, 1.0])) > 1.0,
1167 "an untransported bdir must not pass the 1-degree threshold"
1168 );
1169 }
1170
1171 /// B0 transport has to honour a declared direction that is *not* voxel `+z`, or the test
1172 /// above would also pass for an implementation that ignored `declared_b0` and read the affine
1173 /// every time.
1174 #[test]
1175 fn a_declared_b0_is_transported_rather_than_ignored() {
1176 let dims = (40, 40, 36);
1177 let affine = identity_affine((1.0, 1.0, 1.0));
1178 let mag = phantom(dims);
1179 let w = truth([0.1, 1.0, 0.25], 8.0, [1.0, -0.7, 0.5], dims, &affine);
1180 let (moved, _) = warp_complex(&mag, &vec![0.0; mag.len()], dims, &affine, &w);
1181
1182 // An oblique declared direction, normalised.
1183 let d = {
1184 let v = [0.30f64, -0.20, 0.9327];
1185 let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1186 [v[0] / n, v[1] / n, v[2] / n]
1187 };
1188 let params = MotionParams {
1189 declared_b0: Some((d[0], d[1], d[2])),
1190 ..MotionParams::default()
1191 };
1192 let mags = vec![mag, moved];
1193 let series = uniform_series(&mags, dims, &affine);
1194 let motion = estimate_motion(&series, None, ¶ms).unwrap();
1195
1196 assert!(
1197 angle_deg(motion.volumes[0].bdir, d) < 1e-9,
1198 "the reference must report the declared direction unchanged, got {:?}",
1199 motion.volumes[0].bdir
1200 );
1201 let want = rotate_t(&w, d);
1202 let err = angle_deg(motion.volumes[1].bdir, want);
1203 // Reading the affine instead of the declaration would have transported +z, which is a
1204 // different vector; check the two are far enough apart for this test to discriminate.
1205 let affine_answer = rotate_t(&w, [0.0, 0.0, 1.0]);
1206 println!(
1207 "declared-b0 transport error {err:.4} deg; the affine-derived answer would be \
1208 {:.2} deg away",
1209 angle_deg(motion.volumes[1].bdir, affine_answer)
1210 );
1211 assert!(err < 1.0, "transported declared bdir off by {err} deg");
1212 assert!(
1213 angle_deg(motion.volumes[1].bdir, affine_answer) > 5.0,
1214 "the declared and affine-derived answers are too close for this test to discriminate"
1215 );
1216 }
1217
1218 // --------------------------------------------------------- the complex domain
1219
1220 /// Why `apply_complex` exists, measured rather than asserted in a comment.
1221 ///
1222 /// A wrapped phase field is moved through both paths. Through the complex domain the wraps
1223 /// survive; interpolating the wrapped values directly averages across every wrap and turns it
1224 /// into a band of wrong values.
1225 ///
1226 /// The shift is deliberately a *half* voxel in each axis, so every output voxel is a genuine
1227 /// average of its neighbours. A whole-voxel shift makes trilinear interpolation degenerate —
1228 /// weights of exactly 1 and 0, no averaging — and then there is no interpolation for the
1229 /// direct path to get wrong, so it scores only 3x worse and the test stops measuring
1230 /// anything.
1231 ///
1232 /// The headline number is **how much of the volume is badly wrong**, not the mean error.
1233 /// Mean absolute error flatters the direct path badly: this ramp wraps about every seven
1234 /// voxels, so roughly a seventh of the volume is ruined and six sevenths is fine, and the
1235 /// mean lands at only ~5x the complex path's. The complex path's own error is not second
1236 /// order either, because magnitude varies across the interpolation and a magnitude-weighted
1237 /// circular mean is biased first-order by that — reducing the ramp slope scales both errors
1238 /// together and does not separate them. What does separate them is that one path's error is
1239 /// a small smooth bias everywhere and the other's is a radian or more in bands.
1240 #[test]
1241 fn wrapped_phase_survives_the_complex_path_and_not_the_direct_one() {
1242 let dims = (40, 40, 36);
1243 let affine = identity_affine((1.0, 1.0, 1.0));
1244 let mag = phantom(dims);
1245 let pha = wrapped_ramp(dims);
1246 assert!(
1247 pha.iter().any(|&v| v > 2.8) && pha.iter().any(|&v| v < -2.8),
1248 "the test field has to actually wrap, or neither path can fail"
1249 );
1250
1251 let w = truth([0.0, 0.0, 1.0], 0.0, [2.5, 1.5, 0.5], dims, &affine);
1252 let (moved_mag, moved_pha) = warp_complex(&mag, &pha, dims, &affine, &w);
1253
1254 let mags = vec![mag.clone(), moved_mag.clone()];
1255 let series = uniform_series(&mags, dims, &affine);
1256 let motion = estimate_motion(&series, None, &MotionParams::default()).unwrap();
1257 assert!(
1258 translation_error_mm(&w, &motion.volumes[1].transform.matrix) < 0.5,
1259 "the shift has to be recovered before the two paths can be compared fairly"
1260 );
1261
1262 let (_, complex_path) = motion.apply_complex(1, &moved_mag, &moved_pha).unwrap();
1263 let direct_path = motion.apply(1, &moved_pha).unwrap();
1264
1265 // Score only where the moved volume reached and the phantom has signal, so the
1266 // shifted-in edge and the empty background count against neither path.
1267 let cover = motion.coverage(1).unwrap();
1268 let domain: Vec<u8> = mag
1269 .iter()
1270 .zip(cover.iter())
1271 .map(|(&v, &c)| ((v > 0.2) && c != 0) as u8)
1272 .collect();
1273 assert!(
1274 domain.iter().filter(|&&v| v != 0).count() > 1000,
1275 "not enough overlap to score"
1276 );
1277
1278 let e_complex = mean_abs_phase_error(&complex_path, &pha, &domain);
1279 let e_direct = mean_abs_phase_error(&direct_path, &pha, &domain);
1280 let bad_complex = fraction_badly_wrong(&complex_path, &pha, &domain);
1281 let bad_direct = fraction_badly_wrong(&direct_path, &pha, &domain);
1282 println!(
1283 "complex domain: mean {e_complex:.4} rad, {:.2}% of voxels wrong by >1 rad\n\
1284 direct interp.: mean {e_direct:.4} rad, {:.2}% of voxels wrong by >1 rad",
1285 100.0 * bad_complex,
1286 100.0 * bad_direct
1287 );
1288 assert!(
1289 bad_complex < 0.005,
1290 "the complex path should leave essentially nothing badly wrong, got {:.3}%",
1291 100.0 * bad_complex
1292 );
1293 assert!(
1294 bad_direct > 0.05,
1295 "direct interpolation should ruin a measurable share of the volume, got {:.3}%; if it \
1296 does not, this test is not measuring wrap handling",
1297 100.0 * bad_direct
1298 );
1299 assert!(
1300 bad_direct > 20.0 * bad_complex.max(1e-4),
1301 "direct {:.3}% vs complex {:.3}% is not a clear separation",
1302 100.0 * bad_direct,
1303 100.0 * bad_complex
1304 );
1305 assert!(e_complex < 0.15, "complex-domain mean phase error {e_complex} rad");
1306 }
1307
1308 // --------------------------------------------------------- averaging
1309
1310 /// Averaging four repeats, three of which moved, recovers the reference's own complex signal,
1311 /// and reports how many repeats reached each voxel.
1312 #[test]
1313 fn complex_averaging_recovers_the_reference_signal() {
1314 let dims = (40, 40, 36);
1315 let affine = identity_affine((1.0, 1.0, 1.0));
1316 let mag = phantom(dims);
1317 let pha = wrapped_ramp(dims);
1318
1319 let moves = [
1320 truth([0.0, 0.0, 1.0], 0.0, [1.5, 0.0, 0.0], dims, &affine),
1321 truth([0.0, 0.0, 1.0], 0.0, [0.0, -1.5, 1.0], dims, &affine),
1322 truth([0.1, 0.2, 1.0], 2.0, [1.0, 1.0, 0.0], dims, &affine),
1323 ];
1324 let mut mags = vec![mag.clone()];
1325 let mut phas = vec![pha.clone()];
1326 for w in &moves {
1327 let (m, p) = warp_complex(&mag, &pha, dims, &affine, w);
1328 mags.push(m);
1329 phas.push(p);
1330 }
1331
1332 let series = uniform_series(&mags, dims, &affine);
1333 let motion = estimate_motion(&series, None, &MotionParams::default()).unwrap();
1334 let avg = motion.average_complex(&mags, &phas).unwrap();
1335
1336 let full = avg.contributions.iter().filter(|&&c| c == 4).count();
1337 println!(
1338 "contributions: {full} of {} voxels see all 4 repeats",
1339 avg.contributions.len()
1340 );
1341 assert!(full > avg.contributions.len() / 2, "most voxels should see every repeat");
1342
1343 // Score where the phantom has signal and every repeat contributed.
1344 let domain: Vec<u8> = mag
1345 .iter()
1346 .zip(avg.contributions.iter())
1347 .map(|(&v, &c)| ((v > 0.2) && c == 4) as u8)
1348 .collect();
1349 let e_pha = mean_abs_phase_error(&avg.phase, &pha, &domain);
1350 let (mut e_mag, mut n) = (0.0f64, 0usize);
1351 for i in 0..mag.len() {
1352 if domain[i] != 0 {
1353 e_mag += (avg.magnitude[i] - mag[i]).abs();
1354 n += 1;
1355 }
1356 }
1357 e_mag /= n as f64;
1358 println!("average vs reference: mean |phase| {e_pha:.4} rad, mean |magnitude| {e_mag:.4}");
1359 assert!(e_pha < 0.1, "averaged phase error {e_pha} rad");
1360 assert!(e_mag < 0.1, "averaged magnitude error {e_mag}");
1361
1362 // Three of the four repeats only translated and the fourth rotated 2 degrees, so the
1363 // spread of dipole-kernel orientations the average was taken over is small — which is the
1364 // condition under which averaging phase is legitimate at all.
1365 println!("rotation spread {:.3} deg", avg.rotation_spread_deg);
1366 assert!(
1367 avg.rotation_spread_deg < 3.0,
1368 "rotation spread {} deg is larger than this series was built with",
1369 avg.rotation_spread_deg
1370 );
1371
1372 assert!(motion.average_complex(&mags[..2], &phas).is_none(), "length mismatch");
1373 }
1374
1375 /// `rotation_spread_deg` is the angle between the extreme orientations, not the largest
1376 /// rotation from the reference — and a series that only translated has none of it, however
1377 /// far it translated.
1378 #[test]
1379 fn rotation_spread_is_between_the_extremes_not_from_the_reference() {
1380 let dims = (40, 40, 36);
1381 let affine = identity_affine((1.0, 1.0, 1.0));
1382 let mag = phantom(dims);
1383 let zero = vec![0.0f64; mag.len()];
1384
1385 // Opposite rotations about one axis: each is 6 degrees from the reference, so the spread
1386 // across the series is 12.
1387 let moves = [
1388 truth([1.0, 0.0, 0.0], 6.0, [0.0; 3], dims, &affine),
1389 truth([1.0, 0.0, 0.0], -6.0, [0.0; 3], dims, &affine),
1390 ];
1391 let mut mags = vec![mag.clone()];
1392 for w in &moves {
1393 mags.push(warp_complex(&mag, &zero, dims, &affine, w).0);
1394 }
1395 let series = uniform_series(&mags, dims, &affine);
1396 let motion = estimate_motion(&series, None, &MotionParams::default()).unwrap();
1397 let spread = motion.rotation_spread_deg();
1398 println!(
1399 "rotations from the reference: {:.3}, {:.3}; spread {spread:.3}",
1400 motion.volumes[1].rotation_deg, motion.volumes[2].rotation_deg
1401 );
1402 assert!(
1403 (spread - 12.0).abs() < 1.0,
1404 "spread {spread} should be the 12 degrees between the extremes, not the 6 from the \
1405 reference"
1406 );
1407
1408 // Pure translation: the head never rotated, so every bdir is the same and the spread is
1409 // zero no matter how large the displacement.
1410 let t = truth([0.0, 0.0, 1.0], 0.0, [2.0, -2.0, 1.5], dims, &affine);
1411 let pair = vec![mag.clone(), warp_complex(&mag, &zero, dims, &affine, &t).0];
1412 let series = uniform_series(&pair, dims, &affine);
1413 let motion = estimate_motion(&series, None, &MotionParams::default()).unwrap();
1414 println!(
1415 "pure translation: {:.3} mm displacement, spread {:.4} deg",
1416 motion.max_displacement_mm(),
1417 motion.rotation_spread_deg()
1418 );
1419 assert!(motion.max_displacement_mm() > 2.5, "the volume did move");
1420 assert!(
1421 motion.rotation_spread_deg() < 0.3,
1422 "a pure translation must not produce a rotation spread, got {}",
1423 motion.rotation_spread_deg()
1424 );
1425 }
1426
1427 // --------------------------------------------------------- displacement
1428
1429 /// `max_displacement_mm` is a distance in mm, checked against values worked out
1430 /// independently: `|t|` for a pure translation, and the chord `2·r·sin(θ/2)` at the farthest
1431 /// in-domain voxel for a pure rotation about the grid centre.
1432 ///
1433 /// Both domains are exercised: the translation runs with no mask (the eight grid corners) and
1434 /// the rotation with one (every mask voxel).
1435 #[test]
1436 fn displacement_is_the_distance_tissue_actually_moved() {
1437 let dims = (40, 40, 40);
1438 let vs = (1.0, 1.0, 1.0);
1439 let affine = identity_affine(vs);
1440 let mag = phantom(dims);
1441 let zero = vec![0.0f64; mag.len()];
1442
1443 // Pure translation: every voxel moves by the same vector, so the maximum is its length
1444 // whatever the domain.
1445 let shift = [2.0, -2.5, 0.0];
1446 let t = truth([0.0, 0.0, 1.0], 0.0, shift, dims, &affine);
1447 let pair = vec![mag.clone(), warp_complex(&mag, &zero, dims, &affine, &t).0];
1448 let series = uniform_series(&pair, dims, &affine);
1449 let m = estimate_motion(&series, None, &MotionParams::default()).unwrap();
1450 let want = (shift[0] * shift[0] + shift[1] * shift[1]).sqrt();
1451 println!(
1452 "pure translation: displacement {:.4} mm, |t| = {want:.4} mm",
1453 m.volumes[1].max_displacement_mm
1454 );
1455 assert!(
1456 (m.volumes[1].max_displacement_mm - want).abs() < 0.3,
1457 "displacement {} should be |t| = {want}",
1458 m.volumes[1].max_displacement_mm
1459 );
1460
1461 // Pure rotation about +z through the grid's world centre, with a mask as the domain. The
1462 // farthest mask voxel from the axis sets the answer, so find that radius from the mask
1463 // directly and then the chord.
1464 let deg = 8.0;
1465 let mask = phantom_mask(dims);
1466 let rot = truth([0.0, 0.0, 1.0], deg, [0.0; 3], dims, &affine);
1467 let pair = vec![mag.clone(), warp_complex(&mag, &zero, dims, &affine, &rot).0];
1468 let series = uniform_series(&pair, dims, &affine);
1469 let m = estimate_motion(&series, Some(&mask), &MotionParams::default()).unwrap();
1470 assert!(
1471 rotation_error_deg(&rot, &m.volumes[1].transform.matrix) < 1.0,
1472 "the rotation has to be recovered before its displacement means anything"
1473 );
1474
1475 let (nx, ny, nz) = dims;
1476 let ctr = [(nx as f64 - 1.0) * 0.5 * vs.0, (ny as f64 - 1.0) * 0.5 * vs.1];
1477 let mut r_max = 0.0f64;
1478 for k in 0..nz {
1479 for j in 0..ny {
1480 for i in 0..nx {
1481 if mask[i + j * nx + k * nx * ny] != 0 {
1482 let (dx, dy) = (i as f64 * vs.0 - ctr[0], j as f64 * vs.1 - ctr[1]);
1483 r_max = r_max.max((dx * dx + dy * dy).sqrt());
1484 }
1485 }
1486 }
1487 }
1488 let want = 2.0 * r_max * (deg.to_radians() / 2.0).sin();
1489 println!(
1490 "pure rotation: displacement {:.4} mm, chord at r={r_max:.2} mm is {want:.4} mm",
1491 m.volumes[1].max_displacement_mm
1492 );
1493 assert!(
1494 (m.volumes[1].max_displacement_mm - want).abs() < 0.4,
1495 "displacement {} should be the chord {want} at the farthest mask voxel",
1496 m.volumes[1].max_displacement_mm
1497 );
1498 // And it is not the rotation angle, or the translation, in disguise.
1499 assert!(
1500 m.volumes[1].max_displacement_mm > 2.0,
1501 "an 8-degree rotation of this volume moves tissue millimetres, got {}",
1502 m.volumes[1].max_displacement_mm
1503 );
1504 assert!(
1505 m.volumes[1].translation_mm < 1.0,
1506 "this transform has no world translation to speak of, got {}",
1507 m.volumes[1].translation_mm
1508 );
1509 }
1510
1511 // --------------------------------------------------------- chaining and bookkeeping
1512
1513 /// A chained seed with the shallower pyramid it licenses costs fewer metric evaluations than
1514 /// a cold start, without losing accuracy. See `MotionParams::chain_levels` for why the seed
1515 /// alone does not.
1516 #[test]
1517 fn chaining_costs_less_than_starting_cold_each_time() {
1518 let dims = (40, 40, 36);
1519 let affine = identity_affine((1.0, 1.0, 1.0));
1520 let mag = phantom(dims);
1521 let zero = vec![0.0f64; mag.len()];
1522
1523 // A steady drift, which is what chaining is for.
1524 let moves: Vec<[f64; 16]> = (1..5)
1525 .map(|s| {
1526 truth(
1527 [0.2, 0.1, 1.0],
1528 2.0 * s as f64,
1529 [0.4 * s as f64, -0.3 * s as f64, 0.2 * s as f64],
1530 dims,
1531 &affine,
1532 )
1533 })
1534 .collect();
1535 let mut mags = vec![mag.clone()];
1536 for w in &moves {
1537 mags.push(warp_complex(&mag, &zero, dims, &affine, w).0);
1538 }
1539 let series = uniform_series(&mags, dims, &affine);
1540
1541 // `chain_levels: 1` rather than the default 3, because this volume is small enough that
1542 // the pyramid only reaches two levels anyway (`registration` stops before any axis drops
1543 // below 16), so the default would be clamped to the full depth and there would be
1544 // nothing to measure. The real-data numbers for the default are in `chain_levels`' docs.
1545 let chained = estimate_motion(
1546 &series,
1547 None,
1548 &MotionParams {
1549 chain: true,
1550 chain_levels: 1,
1551 ..MotionParams::default()
1552 },
1553 )
1554 .unwrap();
1555 let cold = estimate_motion(
1556 &series,
1557 None,
1558 &MotionParams {
1559 chain: false,
1560 ..MotionParams::default()
1561 },
1562 )
1563 .unwrap();
1564
1565 let evals =
1566 |m: &MotionSeries| -> usize { m.volumes.iter().map(|v| v.transform.evaluations).sum() };
1567 println!("evaluations: chained {}, cold {}", evals(&chained), evals(&cold));
1568 assert!(
1569 evals(&chained) < evals(&cold),
1570 "chaining should cost fewer evaluations: {} vs {}",
1571 evals(&chained),
1572 evals(&cold)
1573 );
1574
1575 // And it is not cheaper by being wrong: both agree with the independent ground truth.
1576 for (t, w) in moves.iter().enumerate() {
1577 for (label, m) in [("chained", &chained), ("cold", &cold)] {
1578 let err = rotation_error_deg(w, &m.volumes[t + 1].transform.matrix);
1579 println!("{label} volume {}: rotation error {err:.4} deg", t + 1);
1580 assert!(err < 1.0, "{label} volume {}: rotation off by {err} deg", t + 1);
1581 }
1582 }
1583 }
1584
1585 /// A mid-series reference: indices still line up with the caller's data, the reference's own
1586 /// entry is the identity, and volumes on both sides of it are solved.
1587 #[test]
1588 fn a_mid_series_reference_solves_both_directions() {
1589 let dims = (40, 40, 36);
1590 let affine = identity_affine((1.0, 1.0, 1.0));
1591 let mag = phantom(dims);
1592 let zero = vec![0.0f64; mag.len()];
1593
1594 let moves = [
1595 truth([0.0, 1.0, 0.2], -5.0, [-1.0, 0.5, 0.0], dims, &affine),
1596 truth([0.0, 1.0, 0.2], 5.0, [1.0, -0.5, 0.0], dims, &affine),
1597 ];
1598 // Reference in the middle: [moved, reference, moved].
1599 let mags = vec![
1600 warp_complex(&mag, &zero, dims, &affine, &moves[0]).0,
1601 mag.clone(),
1602 warp_complex(&mag, &zero, dims, &affine, &moves[1]).0,
1603 ];
1604 let series = uniform_series(&mags, dims, &affine);
1605 let motion = estimate_motion(
1606 &series,
1607 None,
1608 &MotionParams {
1609 reference: MotionReference::Index(1),
1610 ..MotionParams::default()
1611 },
1612 )
1613 .unwrap();
1614
1615 assert_eq!(motion.reference, 1);
1616 assert_eq!(motion.volumes[1].rotation_deg, 0.0, "the reference is the identity");
1617 for (t, w) in [(0usize, &moves[0]), (2usize, &moves[1])] {
1618 let err = rotation_error_deg(w, &motion.volumes[t].transform.matrix);
1619 println!("volume {t}: rotation error {err:.4} deg, NCC {:.4}", motion.volumes[t].ncc);
1620 assert!(err < 1.0, "volume {t}: rotation off by {err} deg");
1621 }
1622 assert!(
1623 motion.volumes[0].rotation_deg > 4.0 && motion.volumes[2].rotation_deg > 4.0,
1624 "both flanking volumes should have been found to have moved"
1625 );
1626 }
1627
1628 /// `correct_motion` is the composition of the pieces, and its coverage is the intersection of
1629 /// what every volume reaches — strictly smaller than the grid once anything has moved off the
1630 /// edge.
1631 #[test]
1632 fn correct_motion_composes_and_reports_coverage() {
1633 let dims = (40, 40, 36);
1634 let affine = identity_affine((1.0, 1.0, 1.0));
1635 let mag = phantom(dims);
1636 let pha = wrapped_ramp(dims);
1637
1638 let w = truth([0.1, 0.0, 1.0], 5.0, [2.0, -1.5, 1.0], dims, &affine);
1639 let (m1, p1) = warp_complex(&mag, &pha, dims, &affine, &w);
1640 let mags = vec![mag.clone(), m1];
1641 let phas = vec![pha.clone(), p1];
1642
1643 let out = correct_motion(&mags, &phas, dims, &affine, None, &MotionParams::default())
1644 .expect("correction runs");
1645 assert_eq!(out.magnitudes.len(), 2);
1646 assert_eq!(out.phases.len(), 2);
1647 assert_eq!(out.bdirs.len(), 2);
1648 assert_eq!(out.coverage.len(), mag.len());
1649 assert_eq!(out.bdirs[0], out.motion.volumes[0].bdir);
1650
1651 // The reference is resampled through the identity, which is exact rather than merely
1652 // close: it is the one volume that never passes through an interpolator.
1653 for (i, (&got, &want)) in out.magnitudes[0].iter().zip(mag.iter()).enumerate() {
1654 assert!(
1655 (got - want).abs() < 1e-9,
1656 "the reference must come back untouched, differs at {i}"
1657 );
1658 }
1659
1660 // Rotating and shifting takes a slab off the edge of the grid, so the intersection
1661 // cannot be the whole volume.
1662 let covered = out.coverage.iter().filter(|&&c| c != 0).count();
1663 println!("coverage {covered} of {} reference voxels", out.coverage.len());
1664 assert!(covered < out.coverage.len(), "a moved volume cannot cover the whole grid");
1665 assert!(covered > out.coverage.len() / 2, "and it should still cover most of it");
1666
1667 // The corrected moved volume agrees with the reference where both have data.
1668 let domain: Vec<u8> = mag
1669 .iter()
1670 .zip(out.coverage.iter())
1671 .map(|(&v, &c)| ((v > 0.2) && c != 0) as u8)
1672 .collect();
1673 let err = mean_abs_phase_error(&out.phases[1], &pha, &domain);
1674 println!("corrected phase error {err:.4} rad");
1675 assert!(err < 0.2, "corrected phase error {err} rad");
1676
1677 assert!(
1678 correct_motion(&mags, &phas[..1], dims, &affine, None, &MotionParams::default())
1679 .is_none(),
1680 "magnitude and phase counts must agree"
1681 );
1682 }
1683
1684 /// `implied_b0` is the convention `declared_b0` has to match, including on the anisotropic
1685 /// voxels where reading it off the affine naively goes wrong.
1686 #[test]
1687 fn implied_b0_matches_the_affine_convention() {
1688 let d = implied_b0(&identity_affine((0.8, 0.8, 3.0)));
1689 assert!(
1690 d.0.abs() < 1e-12 && d.1.abs() < 1e-12 && (d.2 - 1.0).abs() < 1e-12,
1691 "axial acquisition should give voxel +z, got {d:?}"
1692 );
1693 }
1694}