Skip to main content

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), &params)?;
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, &params).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}