Skip to main content

qsm_core/
registration.rs

1//! Rigid-body (6-DOF) volume registration: *estimating* the transform between two volumes.
2//!
3//! [`crate::geometry`] can already **apply** an arbitrary transform — [`crate::geometry::GridMap`]
4//! maps one voxel grid onto another given two affines, and [`crate::geometry::resample_onto`],
5//! [`crate::geometry::resample_mask_onto`] and [`crate::geometry::resample_complex_onto`] move
6//! continuous data, labels and wrapped phase across it. What was missing is *finding* the
7//! transform in the first place, which is what this module does.
8//!
9//! # Why QSM needs it
10//!
11//! Multi-orientation QSM (COSMOS, [`crate::inversion::cosmos`]) and susceptibility tensor
12//! imaging ([`crate::inversion::sti`]) both take N local field maps **on one common grid** plus
13//! **one B0 direction per orientation in that grid's voxel frame**. Registration produces both,
14//! and the second is not a by-product:
15//!
16//! > The object rotates and B0 does not. So the rigid rotation that aligns orientation *t* to
17//! > the reference *is* where B0 pointed during orientation *t*, relative to the common grid.
18//!
19//! That matters most for the dataset shape where the affine is useless: identically prescribed
20//! slabs, where every orientation has the *same* affine and the anatomy moved in voxel space
21//! instead. [`crate::geometry::b0_direction_from_affine`] returns the same direction for all of
22//! them, and N identical directions quietly collapse COSMOS to an unregularized
23//! single-orientation inversion. Registration is the only thing that can recover the real answer
24//! there — see [`RigidTransform::b0_direction_in_fixed`], which is where that convention lives
25//! so no caller has to re-derive it.
26//!
27//! # Register on magnitude, resample phase through the complex domain
28//!
29//! Wrapped phase cannot be interpolated directly (halfway between `+3.0` and `-3.0` rad a linear
30//! interpolator returns `0.0`; see the [`crate::geometry`] module docs), so it must not be used
31//! as the registration input either. Register magnitude against magnitude, then move phase with
32//! [`RigidTransform::resample_complex`], which goes through `mag·e^{iφ}`.
33//!
34//! # Metric: normalised cross-correlation, not mutual information
35//!
36//! Every use case here is mono-modal — GRE magnitude against GRE magnitude of the same subject,
37//! same sequence. NCC is invariant to an affine intensity change (gain and offset), which is
38//! what actually differs between two orientations of one acquisition, and it needs no joint
39//! histogram, no binning choice and no parzen window. Mutual information buys nothing on
40//! same-contrast data and is noisier at the small sample counts a coarse pyramid level uses.
41//! Sum-of-squared-differences would be cheaper still but is not gain-invariant, so a receive-gain
42//! difference between runs would bias it.
43//!
44//! The one thing NCC does *not* absorb is spatially varying shading: the receive field is fixed
45//! to the coil, so when the head rotates the bias field rotates with the coil rather than with the
46//! anatomy. That is a multiplicative *field*, not a scalar gain, and no global metric (MI
47//! included) is invariant to it. Two mitigations, both available here: pass a brain `mask` so the
48//! metric ignores everything outside the head, and/or hand in magnitude that has already been
49//! bias-corrected ([`crate::homogeneity`]).
50//!
51//! # Optimiser, pyramid and cost
52//!
53//! Powell's direction-set method with a bracketed Brent line search, over a coarse-to-fine
54//! Gaussian pyramid. Powell needs no derivatives of the metric — convenient, because the metric's
55//! derivative runs through a trilinear interpolator — and it is what the established intensity
56//! registration tools use for 6-DOF problems. No linear-algebra crate is involved; the whole
57//! module is `f64` arrays, consistent with the rest of qsm-core.
58//!
59//! The pyramid is deep on purpose. It is what sets the **capture range** — how far apart two
60//! volumes can start and still converge — because smoothing and decimating flattens the cost
61//! surface's ripples, and the ripples are what a search walks into instead of the true minimum.
62//! See [`RigidParams::levels`] for the measured numbers; the short version is that three levels
63//! converge to 40° and five to beyond 70°, and 45° of repositioning is ordinary for STI.
64//!
65//! Work per metric evaluation is **bounded independently of volume size** by
66//! [`RigidParams::max_samples`]: each level picks the smallest integer voxel stride whose sample
67//! count fits the cap. Coarse levels therefore see smoothed, decimated data (a smooth cost
68//! landscape, which is the point of the pyramid), while the finest level strides the *unsmoothed*
69//! full-resolution volume (full spatial detail at the same cost, which is what gives the final
70//! precision). Peak extra memory is the pyramid above the finest level — about 1/7 of one input
71//! volume — plus the sample list, so a 32-bit WASM host with a 4 GB heap is not at risk.
72
73use crate::geometry::{
74    b0_direction_from_affine, resample_complex_onto, resample_mask_onto, resample_onto,
75    trilinear_sample, voxel_sizes_from_affine, world_to_voxel,
76};
77
78#[cfg(feature = "parallel")]
79use rayon::prelude::*;
80
81/// Fixed samples per parallel chunk. Fixed size, so the reduction combines in index order and
82/// the metric is bit-identical regardless of thread count.
83const SAMPLE_CHUNK: usize = 8192;
84
85/// Registration parameters.
86#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
87#[derive(Clone, Debug)]
88pub struct RigidParams {
89    /// Pyramid levels, finest included. `5` means the volume is registered at 1/16, then 1/8,
90    /// 1/4, 1/2 and full resolution. Levels that would shrink any axis below 8 voxels are
91    /// dropped, so a small volume quietly gets fewer and nothing has to be tuned for it.
92    ///
93    /// **This is the capture range, and it is the one parameter not to lower.** The search starts
94    /// from the identity, so for identically prescribed slabs it begins with the whole head
95    /// rotation as the error to find — and `1 - NCC` is not a single bowl. A brain is roughly
96    /// symmetric and full of repeating structure, so the cost falls, rises as one gyrus lines up
97    /// with its neighbour, and falls again; past some misalignment the coarsest level has a
98    /// competing minimum of its own and the search converges into it, reporting success.
99    ///
100    /// Measured on the in-vivo-sized test phantom
101    /// (`test_multiorient_register_capture_range`), sweeping an oblique-axis rotation:
102    ///
103    /// | levels | converges up to |
104    /// |---|---|
105    /// | 3 | 40°, then fails hard — 92° out, NCC 0.19 |
106    /// | 5 | the whole swept range to 70°, to 0.01° |
107    ///
108    /// 3 was the original default and is not enough: STI wants orientations spread widely over
109    /// the sphere, so 45° of repositioning is ordinary and would have landed in the failure.
110    /// The two extra levels cost about twice the metric evaluations but very little wall clock,
111    /// because the coarse levels hold a few thousand samples each against the cap below.
112    pub levels: usize,
113    /// Upper bound on fixed voxels sampled per metric evaluation, per level.
114    ///
115    /// This, not the volume size, sets the cost of a registration: a 256³ and a 700³ volume take
116    /// the same time. 6 parameters are vastly over-determined by 10⁵ samples, so lowering this
117    /// buys speed long before it costs accuracy.
118    pub max_samples: usize,
119    /// Maximum Powell cycles (one pass over all six directions) per level.
120    pub max_iterations: usize,
121    /// Total metric evaluations across all levels, as a hard stop. Reached only on a cost
122    /// surface that will not converge; the best parameters so far are returned either way.
123    pub max_evaluations: usize,
124    /// Relative cost improvement per Powell cycle below which a level is considered converged.
125    pub tolerance: f64,
126    /// Initial line-search step for the three rotations, at the finest level, in degrees.
127    /// Coarser levels scale it up with the level's decimation factor.
128    pub rotation_step_deg: f64,
129    /// Initial line-search step for the three translations, at the finest level, in mm.
130    /// Coarser levels scale it up with the level's decimation factor.
131    pub translation_step_mm: f64,
132    /// Smallest fraction of sampled fixed voxels that must still land inside the moving volume.
133    ///
134    /// NCC over a shrinking overlap is gameable — slide the volumes apart until only one
135    /// well-correlated patch is left and the correlation goes up. Candidates below this floor are
136    /// costed worse than any real match, with a gradient that pushes back into the valid region.
137    /// Keep it low enough for a legitimately partial overlap (a thin slab against a whole head).
138    pub min_overlap: f64,
139    /// Starting parameters, in the convention of [`RigidTransform::params`]. `None` starts from
140    /// the identity, i.e. it trusts the two affines to place the volumes in a common world —
141    /// correct both for separately prescribed slabs (where the affines differ and are truthful)
142    /// and for identically prescribed ones (where they agree and only the anatomy moved).
143    ///
144    /// Seed it when a previous answer is a good prior and the pyramid's coarse search is wasted
145    /// work: registering a time series to one reference, for instance, where each volume starts
146    /// from where the last one landed. The parameters are defined about the *fixed* volume's
147    /// world centre, so they carry between pairs that share a fixed volume without conversion.
148    pub initial: Option<[f64; 6]>,
149}
150
151impl Default for RigidParams {
152    fn default() -> Self {
153        Self {
154            levels: 5,
155            max_samples: 200_000,
156            max_iterations: 20,
157            max_evaluations: 5000,
158            tolerance: 1e-5,
159            rotation_step_deg: 3.0,
160            translation_step_mm: 3.0,
161            min_overlap: 0.25,
162            initial: None,
163        }
164    }
165}
166
167/// A recovered rigid transform, together with everything needed to use it.
168///
169/// # Which way it points
170///
171/// [`Self::matrix`] maps **fixed world → moving world**: it answers "where in the moving
172/// acquisition is the anatomy that the fixed volume has here", which is the direction resampling
173/// needs (for each output voxel, find where to read). The inverse direction is one
174/// [`Self::inverse`] away, and it is worth being pedantic about this: applying the rotation the
175/// wrong way round produces a plausible-looking, wrong susceptibility map rather than an error.
176#[derive(Clone, Debug)]
177pub struct RigidTransform {
178    /// Row-major 4×4 rigid transform, fixed world → moving world. Pure rotation plus
179    /// translation; no scaling or shear.
180    pub matrix: [f64; 16],
181    /// The six parameters `matrix` was built from: `[rx, ry, rz, tx, ty, tz]`, rotations in
182    /// **radians** composed as `Rz(rz)·Ry(ry)·Rx(rx)` about the fixed volume's world centre,
183    /// translations in **mm** of world space.
184    pub params: [f64; 6],
185    /// Dimensions and voxel→world affine of the fixed volume — the common grid.
186    pub fixed_dims: (usize, usize, usize),
187    pub fixed_affine: [f64; 16],
188    /// Dimensions and *original* voxel→world affine of the moving volume, as handed in.
189    pub moving_dims: (usize, usize, usize),
190    pub moving_affine: [f64; 16],
191    /// Normalised cross-correlation at the solution, on `[-1, 1]`. Same-subject magnitude
192    /// volumes that genuinely align land above ~0.9; below ~0.5 something is wrong.
193    pub ncc: f64,
194    /// Fraction of sampled fixed voxels that found a moving sample at the solution. Well under
195    /// 1.0 means the volumes only partially overlap, which may be correct (a slab inside a
196    /// head) or may mean the search wandered.
197    pub overlap: f64,
198    /// Metric evaluations used, summed over pyramid levels. Diagnostic.
199    pub evaluations: usize,
200}
201
202impl RigidTransform {
203    /// The identity transform between two grids — the "already registered" answer, for callers
204    /// that skip estimation but still want one uniform type to resample and read directions
205    /// through.
206    pub fn identity(
207        fixed_dims: (usize, usize, usize),
208        fixed_affine: &[f64; 16],
209        moving_dims: (usize, usize, usize),
210        moving_affine: &[f64; 16],
211    ) -> Self {
212        Self {
213            matrix: IDENTITY4,
214            params: [0.0; 6],
215            fixed_dims,
216            fixed_affine: *fixed_affine,
217            moving_dims,
218            moving_affine: *moving_affine,
219            ncc: f64::NAN,
220            overlap: f64::NAN,
221            evaluations: 0,
222        }
223    }
224
225    /// The three rotation angles in degrees, in the order [`Self::params`] stores them.
226    pub fn rotation_degrees(&self) -> [f64; 3] {
227        [
228            self.params[0].to_degrees(),
229            self.params[1].to_degrees(),
230            self.params[2].to_degrees(),
231        ]
232    }
233
234    /// The translation in mm of world space.
235    pub fn translation_mm(&self) -> [f64; 3] {
236        [self.params[3], self.params[4], self.params[5]]
237    }
238
239    /// Total rotation angle in degrees, regardless of axis — the single number to eyeball when
240    /// deciding whether a registration did anything.
241    pub fn rotation_magnitude_deg(&self) -> f64 {
242        let r = rotation_of(&self.matrix);
243        let trace = r[0][0] + r[1][1] + r[2][2];
244        (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos().to_degrees()
245    }
246
247    /// The moving volume's voxel→world affine after alignment: re-tag the moving volume with
248    /// this and its world coordinates agree with the fixed volume's anatomy, with no
249    /// interpolation at all.
250    ///
251    /// **Do not then read B0 off it.** This affine describes the volume in the *reference
252    /// anatomy's* world, where "world +z" is the reference orientation's B0 and not this
253    /// orientation's, so [`b0_direction_from_affine`] applied to it is meaningless. Use
254    /// [`Self::b0_direction_in_fixed`].
255    pub fn aligned_affine(&self) -> [f64; 16] {
256        mat4_mul(&rigid_inverse(&self.matrix), &self.moving_affine)
257    }
258
259    /// The moving acquisition's B0 direction expressed in the **fixed grid's** voxel frame —
260    /// exactly the `bdirs` entry [`crate::inversion::cosmos`] and [`crate::inversion::sti`] want
261    /// for this orientation.
262    ///
263    /// `declared_moving_b0` is a sidecar-declared B0 direction in the *moving* volume's own voxel
264    /// frame, in the convention [`b0_direction_from_affine`] returns (the world B0 resolved onto
265    /// the volume's normalised voxel axes). Pass `None` to read it off the moving affine, which is
266    /// right whenever that affine is the scanner's own.
267    ///
268    /// The chain is: lift the direction into the moving volume's world, rotate it into the fixed
269    /// volume's world with the inverse of [`Self::matrix`] (a free vector carries no translation),
270    /// then resolve it onto the fixed grid's normalised voxel axes. With no rotation it reduces to
271    /// `b0_direction_from_affine(fixed_affine)`, as it must.
272    pub fn b0_direction_in_fixed(
273        &self,
274        declared_moving_b0: Option<(f64, f64, f64)>,
275    ) -> (f64, f64, f64) {
276        let d = declared_moving_b0.unwrap_or_else(|| b0_direction_from_affine(&self.moving_affine));
277        let d = [d.0, d.1, d.2];
278        // Moving voxel frame -> moving world.
279        let in_moving_world = mat3_vec(&direction_cosines(&self.moving_affine), &d);
280        // Moving world -> fixed world. R⁻¹ = Rᵀ for a rotation.
281        let in_fixed_world = mat3_transpose_vec(&rotation_of(&self.matrix), &in_moving_world);
282        // Fixed world -> fixed voxel frame.
283        let v = mat3_transpose_vec(&direction_cosines(&self.fixed_affine), &in_fixed_world);
284        let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
285        if n < 1e-12 {
286            (0.0, 0.0, 1.0)
287        } else {
288            (v[0] / n, v[1] / n, v[2] / n)
289        }
290    }
291
292    /// Resample continuous moving data (magnitude, an unwrapped field map, χ) onto the fixed
293    /// grid. **Not for wrapped phase** — use [`Self::resample_complex`].
294    pub fn resample(&self, moving: &[f64]) -> Option<Vec<f64>> {
295        resample_onto(
296            moving,
297            self.moving_dims,
298            &self.aligned_affine(),
299            self.fixed_dims,
300            &self.fixed_affine,
301        )
302    }
303
304    /// Resample a moving binary mask or label volume onto the fixed grid (nearest neighbour).
305    pub fn resample_mask(&self, mask: &[u8]) -> Option<Vec<u8>> {
306        resample_mask_onto(
307            mask,
308            self.moving_dims,
309            &self.aligned_affine(),
310            self.fixed_dims,
311            &self.fixed_affine,
312        )
313    }
314
315    /// Resample magnitude and wrapped phase onto the fixed grid together, interpolating
316    /// `mag·e^{iφ}` so the wraps survive. Returns `(magnitude, phase)`.
317    pub fn resample_complex(
318        &self,
319        magnitude: &[f64],
320        phase: &[f64],
321    ) -> Option<(Vec<f64>, Vec<f64>)> {
322        resample_complex_onto(
323            magnitude,
324            phase,
325            self.moving_dims,
326            &self.aligned_affine(),
327            self.fixed_dims,
328            &self.fixed_affine,
329        )
330    }
331
332    /// The same alignment with the roles swapped: the transform taking the old moving volume as
333    /// fixed and vice versa. Lets a chain of pairwise registrations be re-referenced without
334    /// re-estimating anything — what intra-run motion correction needs when the volume it
335    /// registered against is not the one it wants to output on.
336    pub fn inverse(&self) -> Self {
337        let matrix = rigid_inverse(&self.matrix);
338        let center = grid_center_world(self.moving_dims, &self.moving_affine);
339        Self {
340            params: params_from_matrix(&matrix, &center),
341            matrix,
342            fixed_dims: self.moving_dims,
343            fixed_affine: self.moving_affine,
344            moving_dims: self.fixed_dims,
345            moving_affine: self.fixed_affine,
346            ncc: self.ncc,
347            overlap: self.overlap,
348            evaluations: self.evaluations,
349        }
350    }
351}
352
353/// Estimate the rigid transform that aligns `moving` to `fixed`.
354///
355/// Both volumes are column-major with their own dimensions and row-major voxel→world affine;
356/// they need not share a grid, voxel size or orientation. `mask` is optional and lives on the
357/// **fixed** grid: it restricts which fixed voxels the metric looks at, and a brain mask is worth
358/// passing — it keeps air, neck and the receive-field falloff out of the correlation.
359///
360/// Returns `None` if either affine is singular, if a volume's data length does not match its
361/// dimensions, or if the metric could never be evaluated (empty mask, no overlap anywhere).
362///
363/// # Example
364///
365/// ```no_run
366/// use qsm_core::registration::{register_rigid, RigidParams};
367/// # fn f(fixed: &[f64], moving: &[f64], dims: (usize, usize, usize),
368/// #      a_fixed: &[f64; 16], a_moving: &[f64; 16], brain: &[u8]) -> Option<()> {
369/// let t = register_rigid(
370///     fixed, dims, a_fixed,
371///     moving, dims, a_moving,
372///     Some(brain), &RigidParams::default(),
373/// )?;
374/// // The moving orientation's field map, on the fixed grid, with the B0 direction to match.
375/// let field_on_common_grid = t.resample(moving)?;
376/// let bdir = t.b0_direction_in_fixed(None);
377/// # let _ = (field_on_common_grid, bdir); Some(()) }
378/// ```
379#[allow(clippy::too_many_arguments)] // Two volumes is six arguments before anything optional.
380pub fn register_rigid(
381    fixed: &[f64],
382    fixed_dims: (usize, usize, usize),
383    fixed_affine: &[f64; 16],
384    moving: &[f64],
385    moving_dims: (usize, usize, usize),
386    moving_affine: &[f64; 16],
387    mask: Option<&[u8]>,
388    params: &RigidParams,
389) -> Option<RigidTransform> {
390    if fixed.len() != fixed_dims.0 * fixed_dims.1 * fixed_dims.2
391        || moving.len() != moving_dims.0 * moving_dims.1 * moving_dims.2
392    {
393        return None;
394    }
395    if let Some(m) = mask {
396        if m.len() != fixed.len() {
397            return None;
398        }
399    }
400    // Both affines must be invertible: the moving one to sample it, the fixed one because the
401    // rotation centre and the B0 transport are defined through it.
402    world_to_voxel(fixed_affine)?;
403    world_to_voxel(moving_affine)?;
404
405    // The rotation centre is the fixed volume's world centre, held fixed across pyramid levels
406    // so one parameter vector means the same thing everywhere. Rotating about the centre rather
407    // than the world origin is also what keeps the rotation and translation parameters from
408    // being strongly coupled, which is the difference between Powell converging in a few cycles
409    // and crawling along a diagonal valley.
410    let center = grid_center_world(fixed_dims, fixed_affine);
411
412    // Coarse-to-fine. Level 0 is the input itself (borrowed, not copied); each further level
413    // halves every axis. Stop before any axis would drop below MIN_AXIS, where there is no
414    // longer enough structure for a 6-parameter fit.
415    const MIN_AXIS: usize = 8;
416    let too_small = |d: (usize, usize, usize)| {
417        d.0 < 2 * MIN_AXIS || d.1 < 2 * MIN_AXIS || d.2 < 2 * MIN_AXIS
418    };
419    let mut fixed_levels = vec![Volume::borrowed(fixed, fixed_dims, *fixed_affine)];
420    let mut moving_levels = vec![Volume::borrowed(moving, moving_dims, *moving_affine)];
421    // Level 0 reads the caller's mask directly; coarser levels own a decimated copy.
422    let mut mask_levels: Vec<Option<Vec<u8>>> = vec![None];
423    for level in 1..params.levels.max(1) {
424        let prev = level - 1;
425        if too_small(fixed_levels[prev].dims) || too_small(moving_levels[prev].dims) {
426            break;
427        }
428        let prev_dims = fixed_levels[prev].dims;
429        let next_mask = match (prev, mask) {
430            (0, Some(m)) => Some(decimate_mask(m, prev_dims)),
431            (0, None) => None,
432            (_, _) => mask_levels[prev].as_ref().map(|m| decimate_mask(m, prev_dims)),
433        };
434        let next_fixed = fixed_levels[prev].halved();
435        let next_moving = moving_levels[prev].halved();
436        fixed_levels.push(next_fixed);
437        moving_levels.push(next_moving);
438        mask_levels.push(next_mask);
439    }
440
441    // The sample set per level does not depend on the parameters, so build it once. Bounded by
442    // `max_samples` per level, which is what keeps the whole search independent of volume size.
443    let samples: Vec<Vec<Sample>> = (0..fixed_levels.len())
444        .map(|level| {
445            let m = if level == 0 { mask } else { mask_levels[level].as_deref() };
446            sample_set(&fixed_levels[level], m, params.max_samples)
447        })
448        .collect();
449
450    let mut p = params.initial.unwrap_or([0.0; 6]);
451    let mut evaluations = 0usize;
452    let mut best: Option<(f64, f64)> = None;
453
454    for level in (0..fixed_levels.len()).rev() {
455        if samples[level].is_empty() {
456            continue;
457        }
458        let factor = (1usize << level) as f64;
459        let mv = &moving_levels[level];
460        let mut cost = CostFn {
461            samples: &samples[level],
462            fixed_affine: fixed_levels[level].affine,
463            moving: mv.data(),
464            moving_dims: mv.dims,
465            inv_moving: to_mat4(&world_to_voxel(&mv.affine)?),
466            center,
467            min_overlap: params.min_overlap,
468            evaluations: 0,
469            budget: params.max_evaluations.saturating_sub(evaluations),
470        };
471        // Coarse levels get proportionally bigger first steps: one voxel there is `factor`
472        // voxels here, and the whole point of starting coarse is to move further per step.
473        let rot = (params.rotation_step_deg * factor).to_radians();
474        let trans = params.translation_step_mm * factor;
475        let scale = [rot, rot, rot, trans, trans, trans];
476        p = powell(&mut cost, p, scale, params.max_iterations, params.tolerance);
477        evaluations += cost.evaluations;
478        best = Some(cost.score(&p));
479        if evaluations >= params.max_evaluations {
480            break;
481        }
482    }
483
484    let (ncc, overlap) = best?;
485    Some(RigidTransform {
486        matrix: matrix_from_params(&p, &center),
487        params: p,
488        fixed_dims,
489        fixed_affine: *fixed_affine,
490        moving_dims,
491        moving_affine: *moving_affine,
492        ncc,
493        overlap,
494        evaluations,
495    })
496}
497
498// ------------------------------------------------------------------ pyramid
499
500/// One pyramid level: either a borrowed view of the caller's data (level 0) or a decimated copy.
501struct Volume<'a> {
502    owned: Option<Vec<f64>>,
503    view: &'a [f64],
504    dims: (usize, usize, usize),
505    affine: [f64; 16],
506}
507
508impl<'a> Volume<'a> {
509    fn borrowed(data: &'a [f64], dims: (usize, usize, usize), affine: [f64; 16]) -> Self {
510        Self { owned: None, view: data, dims, affine }
511    }
512
513    fn data(&self) -> &[f64] {
514        self.owned.as_deref().unwrap_or(self.view)
515    }
516
517    /// Halve every axis: a 5-tap binomial blur per axis, then keep the even samples.
518    ///
519    /// The blur is the anti-aliasing the decimation needs; the binomial `[1 4 6 4 1]/16` kernel is
520    /// the usual pyramid choice and needs no sigma to tune (the crate's box-filter Gaussian
521    /// collapses to a no-op at the σ≈1 this calls for). Keeping the *even* samples means coarse
522    /// voxel `i` sits exactly on fine voxel `2i`, so the level's affine is the fine affine with
523    /// its three direction columns doubled and its translation untouched — exact, no half-voxel
524    /// offset to get wrong.
525    fn halved(&self) -> Volume<'static> {
526        let (nx, ny, nz) = self.dims;
527        let src = self.data();
528        let blurred = binomial_blur3(src, self.dims);
529        let (cx, cy, cz) = (nx.div_ceil(2), ny.div_ceil(2), nz.div_ceil(2));
530        let mut out = Vec::with_capacity(cx * cy * cz);
531        for k in 0..cz {
532            for j in 0..cy {
533                for i in 0..cx {
534                    out.push(blurred[2 * i + 2 * j * nx + 2 * k * nx * ny]);
535                }
536            }
537        }
538        let mut affine = self.affine;
539        for row in 0..3 {
540            for col in 0..3 {
541                affine[4 * row + col] *= 2.0;
542            }
543        }
544        Volume { owned: Some(out), view: &[], dims: (cx, cy, cz), affine }
545    }
546}
547
548/// Separable `[1 4 6 4 1]/16` blur with edge replication.
549fn binomial_blur3(data: &[f64], dims: (usize, usize, usize)) -> Vec<f64> {
550    const W: [f64; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
551    let (nx, ny, nz) = dims;
552    let mut a = data.to_vec();
553    let mut b = vec![0.0f64; a.len()];
554    // One pass per axis. `n` is its length and `stride` the step along it; `base` is the start
555    // of one line, with `o` enumerating the other two axes.
556    for axis in 0..3 {
557        let (n, stride) = match axis {
558            0 => (nx, 1usize),
559            1 => (ny, nx),
560            _ => (nz, nx * ny),
561        };
562        if n < 2 {
563            continue;
564        }
565        let outer = a.len() / n;
566        for o in 0..outer {
567            let base = match axis {
568                0 => o * nx,
569                1 => (o % nx) + (o / nx) * nx * ny,
570                _ => o,
571            };
572            for t in 0..n {
573                let mut acc = 0.0;
574                for (w, dt) in W.iter().zip(-2i64..=2) {
575                    let u = (t as i64 + dt).clamp(0, n as i64 - 1) as usize;
576                    acc += w * a[base + u * stride];
577                }
578                b[base + t * stride] = acc;
579            }
580        }
581        std::mem::swap(&mut a, &mut b);
582    }
583    a
584}
585
586/// Decimate a mask the same way [`Volume::halved`] decimates data: keep the even samples, with
587/// no blur, so the coarse mask stays binary and sits on the coarse sample positions exactly.
588fn decimate_mask(mask: &[u8], dims: (usize, usize, usize)) -> Vec<u8> {
589    let (nx, ny, nz) = dims;
590    let (cx, cy, cz) = (nx.div_ceil(2), ny.div_ceil(2), nz.div_ceil(2));
591    let mut out = Vec::with_capacity(cx * cy * cz);
592    for k in 0..cz {
593        for j in 0..cy {
594            for i in 0..cx {
595                out.push(mask[2 * i + 2 * j * nx + 2 * k * nx * ny]);
596            }
597        }
598    }
599    out
600}
601
602// ------------------------------------------------------------------ metric
603
604/// One fixed-grid sample the metric is evaluated on: its voxel coordinate and its intensity.
605///
606/// `f32` throughout: the coordinates are small exact integers and the intensities only feed a
607/// correlation, so this halves the memory traffic of the inner loop for no accuracy that matters.
608/// The accumulation itself is `f64`.
609#[derive(Clone, Copy)]
610struct Sample {
611    pos: [f32; 3],
612    value: f32,
613}
614
615/// Pick the fixed voxels the metric looks at: the smallest integer stride whose masked sample
616/// count fits `max_samples`.
617///
618/// Striding rather than blurring is deliberate at the finest level — it keeps full spatial detail
619/// at a bounded cost. Coarse levels are already blurred and decimated, which is where the smooth
620/// cost landscape comes from.
621fn sample_set(fixed: &Volume, mask: Option<&[u8]>, max_samples: usize) -> Vec<Sample> {
622    let (nx, ny, nz) = fixed.dims;
623    let data = fixed.data();
624    let eligible = |idx: usize| match mask {
625        Some(m) => m[idx] != 0,
626        None => true,
627    };
628
629    let count_at = |s: usize| {
630        let mut n = 0usize;
631        for k in (0..nz).step_by(s) {
632            for j in (0..ny).step_by(s) {
633                for i in (0..nx).step_by(s) {
634                    if eligible(i + j * nx + k * nx * ny) {
635                        n += 1;
636                    }
637                }
638            }
639        }
640        n
641    };
642
643    let cap = max_samples.max(64);
644    let mut stride = 1usize;
645    while stride < nx.max(ny).max(nz) && count_at(stride) > cap {
646        stride += 1;
647    }
648
649    let mut out = Vec::new();
650    for k in (0..nz).step_by(stride) {
651        for j in (0..ny).step_by(stride) {
652            for i in (0..nx).step_by(stride) {
653                let idx = i + j * nx + k * nx * ny;
654                if eligible(idx) {
655                    out.push(Sample {
656                        pos: [i as f32, j as f32, k as f32],
657                        value: data[idx] as f32,
658                    });
659                }
660            }
661        }
662    }
663    out
664}
665
666/// Running sums for one chunk of samples, so the reduction is order-independent.
667#[derive(Clone, Copy, Default)]
668struct Acc {
669    n: usize,
670    sf: f64,
671    sm: f64,
672    sff: f64,
673    smm: f64,
674    sfm: f64,
675}
676
677/// The cost being minimised: `1 - NCC` over the overlapping, masked samples.
678struct CostFn<'a> {
679    samples: &'a [Sample],
680    fixed_affine: [f64; 16],
681    moving: &'a [f64],
682    moving_dims: (usize, usize, usize),
683    /// World→voxel of the moving volume, as a 4×4 with a `[0,0,0,1]` last row.
684    inv_moving: [f64; 16],
685    center: [f64; 3],
686    min_overlap: f64,
687    evaluations: usize,
688    budget: usize,
689}
690
691/// Costs assigned where NCC is not a meaningful number. Both are above the worst real cost
692/// (`1 - (-1) = 2`), so a degenerate candidate can never win, and the overlap case keeps a
693/// gradient that pushes the search back into the valid region.
694const COST_DEGENERATE: f64 = 4.0;
695const COST_NO_OVERLAP: f64 = 3.0;
696
697impl CostFn<'_> {
698    /// `(ncc, overlap)` at `p`. The sampling matrix folds the whole chain — fixed voxel →
699    /// fixed world → moving world → moving voxel — into one 4×4, so the inner loop is three
700    /// dot products and a trilinear fetch.
701    fn score(&self, p: &[f64; 6]) -> (f64, f64) {
702        let b = mat4_mul(
703            &self.inv_moving,
704            &mat4_mul(&matrix_from_params(p, &self.center), &self.fixed_affine),
705        );
706        let (nx, ny, nz) = self.moving_dims;
707        let moving = self.moving;
708
709        let partials: Vec<Acc> = maybe_par_chunks!(self.samples, SAMPLE_CHUNK)
710            .map(|chunk| {
711                let mut a = Acc::default();
712                for s in chunk {
713                    let (x, y, z) = (s.pos[0] as f64, s.pos[1] as f64, s.pos[2] as f64);
714                    let ox = b[0] * x + b[1] * y + b[2] * z + b[3];
715                    let oy = b[4] * x + b[5] * y + b[6] * z + b[7];
716                    let oz = b[8] * x + b[9] * y + b[10] * z + b[11];
717                    // Same acceptance window as GridMap::source_coord, so the metric and the
718                    // final resampling agree on what "inside" means.
719                    if ox < -0.5
720                        || ox > nx as f64 - 0.5
721                        || oy < -0.5
722                        || oy > ny as f64 - 0.5
723                        || oz < -0.5
724                        || oz > nz as f64 - 0.5
725                    {
726                        continue;
727                    }
728                    let f = s.value as f64;
729                    let m = trilinear_sample(moving, nx, ny, nz, ox, oy, oz);
730                    a.n += 1;
731                    a.sf += f;
732                    a.sm += m;
733                    a.sff += f * f;
734                    a.smm += m * m;
735                    a.sfm += f * m;
736                }
737                a
738            })
739            .collect();
740
741        let mut t = Acc::default();
742        for a in &partials {
743            t.n += a.n;
744            t.sf += a.sf;
745            t.sm += a.sm;
746            t.sff += a.sff;
747            t.smm += a.smm;
748            t.sfm += a.sfm;
749        }
750
751        let overlap = t.n as f64 / self.samples.len().max(1) as f64;
752        if t.n < 2 {
753            return (f64::NAN, overlap);
754        }
755        let n = t.n as f64;
756        // Means over the *overlap*, not over the whole volume: a candidate that shifts which
757        // voxels overlap changes both means, and using global means instead would leave a
758        // spurious bias term in the correlation.
759        let vf = t.sff - t.sf * t.sf / n;
760        let vm = t.smm - t.sm * t.sm / n;
761        if vf <= 0.0 || vm <= 0.0 {
762            return (f64::NAN, overlap);
763        }
764        let cov = t.sfm - t.sf * t.sm / n;
765        (cov / (vf * vm).sqrt(), overlap)
766    }
767
768    fn cost(&mut self, p: &[f64; 6]) -> f64 {
769        if self.evaluations >= self.budget {
770            // Out of budget: report a flat surface so the line searches stop moving and Powell
771            // falls out on its own tolerance test, leaving the best-so-far parameters in place.
772            return COST_DEGENERATE;
773        }
774        self.evaluations += 1;
775        let (ncc, overlap) = self.score(p);
776        if overlap < self.min_overlap {
777            return COST_NO_OVERLAP + (self.min_overlap - overlap);
778        }
779        if !ncc.is_finite() {
780            return COST_DEGENERATE;
781        }
782        1.0 - ncc
783    }
784}
785
786// ------------------------------------------------------------------ optimiser
787
788/// Powell's direction-set method.
789///
790/// Minimises along each of six directions in turn, then — this is the part that distinguishes
791/// Powell from plain coordinate descent — folds the net displacement of the cycle in as a new
792/// direction, so a valley running diagonally through parameter space gets followed instead of
793/// zigzagged along. The initial directions are the coordinate axes scaled by `scale`, which is
794/// how rotations (radians) and translations (mm) are put on comparable footing.
795fn powell(
796    cost: &mut CostFn,
797    start: [f64; 6],
798    scale: [f64; 6],
799    max_cycles: usize,
800    tol: f64,
801) -> [f64; 6] {
802    const N: usize = 6;
803    let mut dirs = [[0.0f64; N]; N];
804    for (i, d) in dirs.iter_mut().enumerate() {
805        d[i] = scale[i];
806    }
807    let mut p = start;
808    let mut fp = cost.cost(&p);
809
810    for _ in 0..max_cycles {
811        let p_start = p;
812        let f_start = fp;
813        let mut biggest_drop = 0.0f64;
814        let mut biggest = 0usize;
815
816        for (i, dir) in dirs.iter().enumerate() {
817            let before = fp;
818            let (np, nf) = line_minimise(cost, &p, dir);
819            p = np;
820            fp = nf;
821            if before - fp > biggest_drop {
822                biggest_drop = before - fp;
823                biggest = i;
824            }
825        }
826
827        if 2.0 * (f_start - fp) <= tol * (f_start.abs() + fp.abs()) + 1e-12 {
828            break;
829        }
830
831        // Try the net direction of this cycle, extrapolated one cycle further.
832        let mut net = [0.0f64; N];
833        let mut extrap = p;
834        for i in 0..N {
835            net[i] = p[i] - p_start[i];
836            extrap[i] = p[i] + net[i];
837        }
838        let f_extrap = cost.cost(&extrap);
839        if f_extrap < f_start {
840            // Numerical Recipes' test: only adopt the net direction when the decrease along it
841            // is not already accounted for by the single best coordinate direction, which keeps
842            // the direction set from collapsing towards linear dependence.
843            let t = 2.0 * (f_start - 2.0 * fp + f_extrap) * (f_start - fp - biggest_drop).powi(2)
844                - biggest_drop * (f_start - f_extrap).powi(2);
845            if t < 0.0 {
846                let (np, nf) = line_minimise(cost, &p, &net);
847                p = np;
848                fp = nf;
849                dirs[biggest] = dirs[N - 1];
850                dirs[N - 1] = net;
851            }
852        }
853    }
854    p
855}
856
857/// Minimise `cost` along `p + x·dir`: bracket the minimum, then Brent.
858fn line_minimise(cost: &mut CostFn, p: &[f64; 6], dir: &[f64; 6]) -> ([f64; 6], f64) {
859    let at = |x: f64| {
860        let mut q = *p;
861        for i in 0..6 {
862            q[i] += x * dir[i];
863        }
864        q
865    };
866    let f = |x: f64, c: &mut CostFn| c.cost(&at(x));
867
868    // Bracket. `dir` carries the step scale, so x = 1 is one nominal step.
869    //
870    // This is the golden ratio, deliberately — it is what makes the bracket expansion and the
871    // section search below golden. clippy 1.99 added `GOLDEN_RATIO` to `approx_constant` and
872    // suggests `f64::consts::GOLDEN_RATIO`, but that constant is newer than the toolchains this
873    // crate is built with elsewhere, so the literal stays and the lint is silenced here rather
874    // than raising the MSRV for a name.
875    #[allow(clippy::approx_constant)]
876    const GOLD: f64 = 1.618_033_988_749_895;
877    const MAX_EXPANSIONS: usize = 24;
878    let (mut ax, mut bx) = (0.0f64, 1.0f64);
879    let f_at_zero = f(ax, cost);
880    let f_at_one = f(bx, cost);
881    // Point the bracket downhill: `b` must be the better of the two endpoints.
882    let mut fb = if f_at_one > f_at_zero {
883        std::mem::swap(&mut ax, &mut bx);
884        f_at_zero
885    } else {
886        f_at_one
887    };
888    let mut cx = bx + GOLD * (bx - ax);
889    let mut fc = f(cx, cost);
890    let mut expansions = 0;
891    while fc < fb && expansions < MAX_EXPANSIONS {
892        ax = bx;
893        bx = cx;
894        fb = fc;
895        cx = bx + GOLD * (bx - ax);
896        fc = f(cx, cost);
897        expansions += 1;
898    }
899
900    // Brent on the bracket [min(ax,cx), max(ax,cx)] with bx inside it.
901    const ITERS: usize = 40;
902    // `x` is measured in nominal steps (the direction vector carries the physical scale), so a
903    // 1e-3-step floor is 0.003 degrees / 0.003 mm at the finest level — far below the precision
904    // the interpolated metric can actually resolve, and it stops Brent grinding near x = 0.
905    const ZEPS: f64 = 1e-3;
906    const TOL: f64 = 1e-3;
907    let (mut a, mut b) = (ax.min(cx), ax.max(cx));
908    let (mut x, mut w, mut v) = (bx, bx, bx);
909    let (mut fx, mut fw, mut fv) = (fb, fb, fb);
910    let mut d = 0.0f64;
911    let mut e = 0.0f64;
912    for _ in 0..ITERS {
913        let xm = 0.5 * (a + b);
914        let tol1 = TOL * x.abs() + ZEPS;
915        if (x - xm).abs() <= 2.0 * tol1 - 0.5 * (b - a) {
916            break;
917        }
918        let mut use_golden = true;
919        if e.abs() > tol1 {
920            // Parabola through (x, fx), (w, fw), (v, fv).
921            let r = (x - w) * (fx - fv);
922            let q = (x - v) * (fx - fw);
923            let mut pnum = (x - v) * q - (x - w) * r;
924            let mut qden = 2.0 * (q - r);
925            if qden > 0.0 {
926                pnum = -pnum;
927            } else {
928                qden = -qden;
929            }
930            let etemp = e;
931            e = d;
932            if qden.abs() > 1e-300
933                && pnum.abs() < (0.5 * qden * etemp).abs()
934                && pnum > qden * (a - x)
935                && pnum < qden * (b - x)
936            {
937                d = pnum / qden;
938                let u = x + d;
939                if (u - a) < 2.0 * tol1 || (b - u) < 2.0 * tol1 {
940                    d = if xm - x >= 0.0 { tol1 } else { -tol1 };
941                }
942                use_golden = false;
943            }
944        }
945        if use_golden {
946            e = if x >= xm { a - x } else { b - x };
947            d = 0.381_966_011_250_105 * e;
948        }
949        let u = if d.abs() >= tol1 {
950            x + d
951        } else {
952            x + if d >= 0.0 { tol1 } else { -tol1 }
953        };
954        let fu = f(u, cost);
955        if fu <= fx {
956            if u >= x {
957                a = x;
958            } else {
959                b = x;
960            }
961            v = w;
962            fv = fw;
963            w = x;
964            fw = fx;
965            x = u;
966            fx = fu;
967        } else {
968            if u < x {
969                a = u;
970            } else {
971                b = u;
972            }
973            if fu <= fw || w == x {
974                v = w;
975                fv = fw;
976                w = u;
977                fw = fu;
978            } else if fu <= fv || v == x || v == w {
979                v = u;
980                fv = fu;
981            }
982        }
983    }
984    (at(x), fx)
985}
986
987// ------------------------------------------------------------------ small matrix helpers
988//
989// Row-major 4×4 with an implicit `[0,0,0,1]` last row, matching the affine layout the rest of
990// the crate uses. Hand-written because the crate carries no linear-algebra dependency (see
991// `utils::denoise`'s Jacobi eigensolver for the same decision).
992
993const IDENTITY4: [f64; 16] = [
994    1.0, 0.0, 0.0, 0.0, //
995    0.0, 1.0, 0.0, 0.0, //
996    0.0, 0.0, 1.0, 0.0, //
997    0.0, 0.0, 0.0, 1.0,
998];
999
1000fn mat4_mul(a: &[f64; 16], b: &[f64; 16]) -> [f64; 16] {
1001    let mut out = [0.0f64; 16];
1002    for i in 0..4 {
1003        for j in 0..4 {
1004            out[4 * i + j] = (0..4).map(|k| a[4 * i + k] * b[4 * k + j]).sum();
1005        }
1006    }
1007    out
1008}
1009
1010fn to_mat4(m: &[[f64; 4]; 3]) -> [f64; 16] {
1011    let mut out = IDENTITY4;
1012    for i in 0..3 {
1013        out[4 * i..4 * i + 4].copy_from_slice(&m[i]);
1014    }
1015    out
1016}
1017
1018/// The 3×3 block of a 4×4.
1019fn rotation_of(m: &[f64; 16]) -> [[f64; 3]; 3] {
1020    [
1021        [m[0], m[1], m[2]],
1022        [m[4], m[5], m[6]],
1023        [m[8], m[9], m[10]],
1024    ]
1025}
1026
1027/// Inverse of a rigid 4×4: transpose the rotation, and carry the translation back through it.
1028fn rigid_inverse(m: &[f64; 16]) -> [f64; 16] {
1029    let r = rotation_of(m);
1030    let t = [m[3], m[7], m[11]];
1031    let mut out = IDENTITY4;
1032    for i in 0..3 {
1033        for j in 0..3 {
1034            out[4 * i + j] = r[j][i];
1035        }
1036        out[4 * i + 3] = -(r[0][i] * t[0] + r[1][i] * t[1] + r[2][i] * t[2]);
1037    }
1038    out
1039}
1040
1041fn mat3_vec(m: &[[f64; 3]; 3], v: &[f64; 3]) -> [f64; 3] {
1042    [
1043        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
1044        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
1045        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
1046    ]
1047}
1048
1049fn mat3_transpose_vec(m: &[[f64; 3]; 3], v: &[f64; 3]) -> [f64; 3] {
1050    [
1051        m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2],
1052        m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2],
1053        m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2],
1054    ]
1055}
1056
1057/// The direction-cosine matrix of an affine: its 3×3 with the voxel sizes divided out, so what
1058/// is left is the pure rotation taking voxel axes to world axes.
1059///
1060/// Factoring the voxel sizes out first is what makes everything downstream correct for
1061/// anisotropic voxels — the same trap [`b0_direction_from_affine`] documents.
1062fn direction_cosines(affine: &[f64; 16]) -> [[f64; 3]; 3] {
1063    let (sx, sy, sz) = voxel_sizes_from_affine(affine);
1064    let s = [sx.max(1e-12), sy.max(1e-12), sz.max(1e-12)];
1065    let mut out = [[0.0f64; 3]; 3];
1066    for i in 0..3 {
1067        for j in 0..3 {
1068            out[i][j] = affine[4 * i + j] / s[j];
1069        }
1070    }
1071    out
1072}
1073
1074/// World coordinate of a grid's centre voxel.
1075fn grid_center_world(dims: (usize, usize, usize), affine: &[f64; 16]) -> [f64; 3] {
1076    let c = [
1077        (dims.0 as f64 - 1.0) * 0.5,
1078        (dims.1 as f64 - 1.0) * 0.5,
1079        (dims.2 as f64 - 1.0) * 0.5,
1080    ];
1081    [
1082        affine[0] * c[0] + affine[1] * c[1] + affine[2] * c[2] + affine[3],
1083        affine[4] * c[0] + affine[5] * c[1] + affine[6] * c[2] + affine[7],
1084        affine[8] * c[0] + affine[9] * c[1] + affine[10] * c[2] + affine[11],
1085    ]
1086}
1087
1088/// `Rz(rz)·Ry(ry)·Rx(rx)`, angles in radians.
1089fn euler_rotation(rx: f64, ry: f64, rz: f64) -> [[f64; 3]; 3] {
1090    let (sa, ca) = rx.sin_cos();
1091    let (sb, cb) = ry.sin_cos();
1092    let (sc, cc) = rz.sin_cos();
1093    [
1094        [cc * cb, cc * sb * sa - sc * ca, cc * sb * ca + sc * sa],
1095        [sc * cb, sc * sb * sa + cc * ca, sc * sb * ca - cc * sa],
1096        [-sb, cb * sa, cb * ca],
1097    ]
1098}
1099
1100/// The rigid 4×4 for a parameter vector: `x_moving = center + R·(x_fixed - center) + t`.
1101fn matrix_from_params(p: &[f64; 6], center: &[f64; 3]) -> [f64; 16] {
1102    let r = euler_rotation(p[0], p[1], p[2]);
1103    let rc = mat3_vec(&r, center);
1104    let mut out = IDENTITY4;
1105    for i in 0..3 {
1106        out[4 * i..4 * i + 3].copy_from_slice(&r[i]);
1107        out[4 * i + 3] = center[i] - rc[i] + p[3 + i];
1108    }
1109    out
1110}
1111
1112/// Recover a parameter vector from a rigid 4×4, about `center`. The inverse of
1113/// [`matrix_from_params`], used by [`RigidTransform::inverse`].
1114fn params_from_matrix(m: &[f64; 16], center: &[f64; 3]) -> [f64; 6] {
1115    let r = rotation_of(m);
1116    // Rz·Ry·Rx has -sin(ry) in position [2][0]; the other two angles come off the entries that
1117    // carry a single sine each.
1118    let ry = (-r[2][0]).clamp(-1.0, 1.0).asin();
1119    let (rx, rz) = if r[2][0].abs() < 1.0 - 1e-9 {
1120        (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0]))
1121    } else {
1122        // Gimbal lock: rx and rz are not separable, so put the whole in-plane rotation in rz.
1123        (0.0, (-r[0][1]).atan2(r[1][1]))
1124    };
1125    let rr = euler_rotation(rx, ry, rz);
1126    let rc = mat3_vec(&rr, center);
1127    [
1128        rx,
1129        ry,
1130        rz,
1131        m[3] - (center[0] - rc[0]),
1132        m[7] - (center[1] - rc[1]),
1133        m[11] - (center[2] - rc[2]),
1134    ]
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140    use crate::geometry::resample_onto;
1141
1142    fn identity_affine(vs: (f64, f64, f64)) -> [f64; 16] {
1143        [
1144            vs.0, 0.0, 0.0, 0.0, //
1145            0.0, vs.1, 0.0, 0.0, //
1146            0.0, 0.0, vs.2, 0.0, //
1147            0.0, 0.0, 0.0, 1.0,
1148        ]
1149    }
1150
1151    /// A rigid 4×4 built from first principles: translate the rotation centre to the origin,
1152    /// apply three *literal* axis-rotation matrices, translate back, then offset.
1153    ///
1154    /// Deliberately not [`matrix_from_params`]. If the tests' ground truth came out of the same
1155    /// function the implementation parameterises with, a transposed rotation in it would cancel
1156    /// on both sides and every recovery assertion below would become unfailable — which is
1157    /// precisely what happened to the first draft of this file. The only shared machinery is
1158    /// [`mat4_mul`], pinned against hand-computed numbers in `mat4_mul_multiplies_row_major`.
1159    fn truth_transform(center: &[f64; 3], deg: [f64; 3], mm: [f64; 3]) -> [f64; 16] {
1160        let shift = |t: [f64; 3]| {
1161            [
1162                1.0, 0.0, 0.0, t[0], //
1163                0.0, 1.0, 0.0, t[1], //
1164                0.0, 0.0, 1.0, t[2], //
1165                0.0, 0.0, 0.0, 1.0,
1166            ]
1167        };
1168        let (sa, ca) = deg[0].to_radians().sin_cos();
1169        let (sb, cb) = deg[1].to_radians().sin_cos();
1170        let (sc, cc) = deg[2].to_radians().sin_cos();
1171        let rx = [
1172            1.0, 0.0, 0.0, 0.0, //
1173            0.0, ca, -sa, 0.0, //
1174            0.0, sa, ca, 0.0, //
1175            0.0, 0.0, 0.0, 1.0,
1176        ];
1177        let ry = [
1178            cb, 0.0, sb, 0.0, //
1179            0.0, 1.0, 0.0, 0.0, //
1180            -sb, 0.0, cb, 0.0, //
1181            0.0, 0.0, 0.0, 1.0,
1182        ];
1183        let rz = [
1184            cc, -sc, 0.0, 0.0, //
1185            sc, cc, 0.0, 0.0, //
1186            0.0, 0.0, 1.0, 0.0, //
1187            0.0, 0.0, 0.0, 1.0,
1188        ];
1189        let c = *center;
1190        let to_origin = shift([-c[0], -c[1], -c[2]]);
1191        let back = shift([c[0] + mm[0], c[1] + mm[1], c[2] + mm[2]]);
1192        mat4_mul(&back, &mat4_mul(&rz, &mat4_mul(&ry, &mat4_mul(&rx, &to_origin))))
1193    }
1194
1195    /// A deterministic lumpy phantom: three ellipsoidal blobs of different brightness plus a
1196    /// shell, which gives the metric real structure in all three axes. A smooth blob alone is
1197    /// rotationally ambiguous and would not pin a rotation down.
1198    fn phantom(dims: (usize, usize, usize)) -> Vec<f64> {
1199        let (nx, ny, nz) = dims;
1200        let mut v = vec![0.0f64; nx * ny * nz];
1201        let blobs = [
1202            // (cx, cy, cz, rx, ry, rz, value) in fractions of the volume
1203            (0.50, 0.50, 0.50, 0.34, 0.26, 0.30, 1.0),
1204            (0.36, 0.44, 0.52, 0.10, 0.09, 0.11, 2.2),
1205            (0.63, 0.57, 0.44, 0.08, 0.13, 0.07, 1.7),
1206            (0.50, 0.62, 0.64, 0.07, 0.06, 0.09, 2.8),
1207        ];
1208        for k in 0..nz {
1209            for j in 0..ny {
1210                for i in 0..nx {
1211                    let (x, y, z) = (
1212                        i as f64 / nx as f64,
1213                        j as f64 / ny as f64,
1214                        k as f64 / nz as f64,
1215                    );
1216                    let mut acc = 0.0;
1217                    for &(cx, cy, cz, rx, ry, rz, val) in &blobs {
1218                        let d = ((x - cx) / rx).powi(2) + ((y - cy) / ry).powi(2) + ((z - cz) / rz).powi(2);
1219                        if d <= 1.0 {
1220                            // Smooth falloff, so trilinear interpolation has a gradient to work
1221                            // with rather than a staircase.
1222                            acc += val * (1.0 - d).sqrt();
1223                        }
1224                    }
1225                    v[i + j * nx + k * nx * ny] = acc;
1226                }
1227            }
1228        }
1229        v
1230    }
1231
1232    /// Build the moving volume that a known world transform `w` (fixed world → moving world)
1233    /// would have produced from `fixed`.
1234    ///
1235    /// Sampling `fixed` through `w·fixed_affine` is exactly the forward model
1236    /// [`RigidTransform::resample`] inverts, so a registration that recovers `w` is the only way
1237    /// this round-trips — which is what makes the test able to catch an inverted or transposed
1238    /// transform rather than just a blurry one.
1239    fn warp(
1240        fixed: &[f64],
1241        fixed_dims: (usize, usize, usize),
1242        fixed_affine: &[f64; 16],
1243        moving_dims: (usize, usize, usize),
1244        moving_affine: &[f64; 16],
1245        w: &[f64; 16],
1246    ) -> Vec<f64> {
1247        resample_onto(
1248            fixed,
1249            fixed_dims,
1250            &mat4_mul(w, fixed_affine),
1251            moving_dims,
1252            moving_affine,
1253        )
1254        .unwrap()
1255    }
1256
1257    /// The angle between two rotations: the rotation angle of `Aᵀ·B`, whose trace is
1258    /// `Σ_ij A[j][i]·B[j][i]`.
1259    fn rotation_error_deg(a: &[f64; 16], b: &[f64; 16]) -> f64 {
1260        let (ra, rb) = (rotation_of(a), rotation_of(b));
1261        let trace: f64 = (0..3)
1262            .map(|i| (0..3).map(|j| ra[j][i] * rb[j][i]).sum::<f64>())
1263            .sum();
1264        (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos().to_degrees()
1265    }
1266
1267    fn translation_error_mm(a: &[f64; 16], b: &[f64; 16]) -> f64 {
1268        [(a[3] - b[3]), (a[7] - b[7]), (a[11] - b[11])]
1269            .iter()
1270            .fold(0.0f64, |m, v| m.max(v.abs()))
1271    }
1272
1273    // ---------------------------------------------------------- algebra
1274
1275    /// Row-major multiplication, against a product worked out by hand.
1276    #[test]
1277    fn mat4_mul_multiplies_row_major() {
1278        let a = [
1279            1.0, 2.0, 0.0, 3.0, //
1280            0.0, 1.0, 4.0, 0.0, //
1281            5.0, 0.0, 1.0, 1.0, //
1282            0.0, 0.0, 0.0, 1.0,
1283        ];
1284        let b = [
1285            0.0, 1.0, 2.0, 1.0, //
1286            3.0, 0.0, 1.0, 0.0, //
1287            1.0, 1.0, 0.0, 2.0, //
1288            0.0, 0.0, 0.0, 1.0,
1289        ];
1290        // Row 0 of A times each column of B: [0+6+0, 1+0+0, 2+2+0, 1+0+0+3]
1291        let want = [
1292            6.0, 1.0, 4.0, 4.0, //
1293            7.0, 4.0, 1.0, 8.0, //
1294            1.0, 6.0, 10.0, 8.0, //
1295            0.0, 0.0, 0.0, 1.0,
1296        ];
1297        let got = mat4_mul(&a, &b);
1298        for i in 0..16 {
1299            assert!((got[i] - want[i]).abs() < 1e-12, "element {i}: {} vs {}", got[i], want[i]);
1300        }
1301    }
1302
1303    /// The implementation's parameterisation and the tests' independent builder must describe the
1304    /// same transform. This is the test that catches a transposed or mis-ordered rotation; the
1305    /// recovery tests below then inherit a ground truth that is not self-fulfilling.
1306    #[test]
1307    fn the_parameterisation_matches_an_independent_construction() {
1308        let center = [12.5, -3.0, 7.25];
1309        for (deg, mm) in [
1310            ([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
1311            ([11.0, -7.0, 23.0], [2.0, -4.0, 6.0]),
1312            ([-30.0, 15.0, -5.0], [-1.0, 0.5, 3.0]),
1313        ] {
1314            let want = truth_transform(&center, deg, mm);
1315            let p = [
1316                deg[0].to_radians(),
1317                deg[1].to_radians(),
1318                deg[2].to_radians(),
1319                mm[0],
1320                mm[1],
1321                mm[2],
1322            ];
1323            let got = matrix_from_params(&p, &center);
1324            for i in 0..16 {
1325                assert!(
1326                    (got[i] - want[i]).abs() < 1e-9,
1327                    "deg {deg:?} mm {mm:?} element {i}: {} vs {}",
1328                    got[i], want[i]
1329                );
1330            }
1331        }
1332    }
1333
1334    #[test]
1335    fn euler_round_trips_through_the_matrix() {
1336        let center = [3.0, -7.0, 11.0];
1337        for p in [
1338            [0.0; 6],
1339            [0.1, -0.2, 0.3, 4.0, -5.0, 6.0],
1340            [-0.4, 0.05, -0.6, -1.0, 2.0, -3.0],
1341        ] {
1342            let m = matrix_from_params(&p, &center);
1343            let back = params_from_matrix(&m, &center);
1344            for i in 0..6 {
1345                assert!((p[i] - back[i]).abs() < 1e-9, "param {i}: {p:?} vs {back:?}");
1346            }
1347        }
1348    }
1349
1350    #[test]
1351    fn rigid_inverse_is_an_inverse() {
1352        let m = matrix_from_params(&[0.3, -0.2, 0.5, 7.0, -2.0, 4.0], &[1.0, 2.0, 3.0]);
1353        let p = mat4_mul(&m, &rigid_inverse(&m));
1354        for (i, (&got, &want)) in p.iter().zip(IDENTITY4.iter()).enumerate() {
1355            assert!((got - want).abs() < 1e-12, "element {i}: {got} vs {want}");
1356        }
1357    }
1358
1359    /// Rotating about the volume centre must leave the centre where it is — the property that
1360    /// decouples the rotation and translation parameters.
1361    #[test]
1362    fn rotation_is_about_the_fixed_volume_centre() {
1363        let affine = identity_affine((1.0, 1.0, 2.0));
1364        let dims = (32, 40, 16);
1365        let c = grid_center_world(dims, &affine);
1366        let m = matrix_from_params(&[0.4, -0.3, 0.2, 0.0, 0.0, 0.0], &c);
1367        let moved = [
1368            m[0] * c[0] + m[1] * c[1] + m[2] * c[2] + m[3],
1369            m[4] * c[0] + m[5] * c[1] + m[6] * c[2] + m[7],
1370            m[8] * c[0] + m[9] * c[1] + m[10] * c[2] + m[11],
1371        ];
1372        for i in 0..3 {
1373            assert!((moved[i] - c[i]).abs() < 1e-9, "axis {i}: {moved:?} vs {c:?}");
1374        }
1375    }
1376
1377    /// The pyramid's affine bookkeeping: coarse voxel `i` must land on the same world point as
1378    /// fine voxel `2i`, or every level would be searching a shifted problem.
1379    #[test]
1380    fn halved_level_keeps_world_coordinates() {
1381        let dims = (24, 24, 24);
1382        let affine = [
1383            0.8, 0.0, 0.0, -11.0, //
1384            0.0, 0.9, 0.0, 5.0, //
1385            0.0, 0.0, 3.0, 2.0, //
1386            0.0, 0.0, 0.0, 1.0,
1387        ];
1388        let data = phantom(dims);
1389        let coarse = Volume::borrowed(&data, dims, affine).halved();
1390        assert_eq!(coarse.dims, (12, 12, 12));
1391        for (i, j, k) in [(0usize, 0usize, 0usize), (3, 5, 7), (11, 11, 11)] {
1392            let fine = [
1393                affine[0] * (2 * i) as f64 + affine[3],
1394                affine[5] * (2 * j) as f64 + affine[7],
1395                affine[10] * (2 * k) as f64 + affine[11],
1396            ];
1397            let a = coarse.affine;
1398            let got = [
1399                a[0] * i as f64 + a[3],
1400                a[5] * j as f64 + a[7],
1401                a[10] * k as f64 + a[11],
1402            ];
1403            for d in 0..3 {
1404                assert!((fine[d] - got[d]).abs() < 1e-12, "{fine:?} vs {got:?}");
1405            }
1406        }
1407    }
1408
1409    // ---------------------------------------------------------- recovery
1410
1411    /// The test that catches an inverted or transposed transform: warp a phantom by a known
1412    /// rigid transform, register, and require the recovered transform back.
1413    #[test]
1414    fn recovers_a_known_rotation_and_translation() {
1415        let dims = (48, 48, 48);
1416        let affine = identity_affine((1.0, 1.0, 1.0));
1417        let fixed = phantom(dims);
1418        let truth = truth_transform(&grid_center_world(dims, &affine), [9.0, -6.0, 12.0], [2.5, -1.5, 3.0]);
1419        let moving = warp(&fixed, dims, &affine, dims, &affine, &truth);
1420
1421        let t = register_rigid(&fixed, dims, &affine, &moving, dims, &affine, None, &RigidParams::default())
1422            .expect("registration should run");
1423
1424        let rot_err = rotation_error_deg(&t.matrix, &truth);
1425        let trans_err = translation_error_mm(&t.matrix, &truth);
1426        println!(
1427            "recovered rot err {rot_err:.3}deg, trans err {trans_err:.3}mm, ncc {:.4}, {} evals",
1428            t.ncc, t.evaluations
1429        );
1430        assert!(rot_err < 1.0, "rotation error {rot_err} deg");
1431        assert!(trans_err < 1.0, "translation error {trans_err} mm");
1432        assert!(t.ncc > 0.99, "ncc {}", t.ncc);
1433    }
1434
1435    /// The assertion above has to be able to fail. Feeding the *unwarped* volume in as `moving`
1436    /// means the true answer is the identity, so measuring it against the same `truth` must blow
1437    /// through both thresholds — if it did not, the thresholds would be vacuous.
1438    #[test]
1439    fn the_recovery_thresholds_can_fail() {
1440        let dims = (48, 48, 48);
1441        let affine = identity_affine((1.0, 1.0, 1.0));
1442        let fixed = phantom(dims);
1443        let truth = truth_transform(&grid_center_world(dims, &affine), [9.0, -6.0, 12.0], [2.5, -1.5, 3.0]);
1444        let t = register_rigid(&fixed, dims, &affine, &fixed, dims, &affine, None, &RigidParams::default())
1445            .expect("registration should run");
1446        assert!(
1447            rotation_error_deg(&t.matrix, &truth) > 1.0,
1448            "a 16-degree rotation must not pass the 1-degree threshold"
1449        );
1450        assert!(
1451            translation_error_mm(&t.matrix, &truth) > 1.0,
1452            "a 3mm offset must not pass the 1mm threshold"
1453        );
1454    }
1455
1456    /// Registering a volume against itself must return (close to) the identity, and must not
1457    /// drift away from it. The cheapest sanity check there is, and it fails loudly if the cost
1458    /// surface or the parameterisation has a sign error.
1459    #[test]
1460    fn self_registration_is_the_identity() {
1461        let dims = (40, 40, 40);
1462        let affine = identity_affine((1.0, 1.0, 1.0));
1463        let fixed = phantom(dims);
1464        let t = register_rigid(&fixed, dims, &affine, &fixed, dims, &affine, None, &RigidParams::default())
1465            .unwrap();
1466        assert!(t.rotation_magnitude_deg() < 0.3, "rotation {}", t.rotation_magnitude_deg());
1467        assert!(
1468            translation_error_mm(&t.matrix, &IDENTITY4) < 0.3,
1469            "translation {:?}",
1470            t.translation_mm()
1471        );
1472        assert!(t.ncc > 0.9999, "ncc {}", t.ncc);
1473    }
1474
1475    /// Anisotropic voxels are where resampling code historically goes wrong (see the
1476    /// `b0_direction_from_affine` note about QSMxT 8.2.2): a rotation in *world* mm is not a
1477    /// rotation in voxel indices when the voxels are not cubic.
1478    #[test]
1479    fn recovers_a_rotation_on_anisotropic_voxels() {
1480        let dims = (48, 48, 24);
1481        let affine = identity_affine((0.8, 0.8, 2.0));
1482        let fixed = phantom(dims);
1483        let truth = truth_transform(&grid_center_world(dims, &affine), [5.0, -4.0, 8.0], [1.6, -2.4, 1.0]);
1484        let moving = warp(&fixed, dims, &affine, dims, &affine, &truth);
1485        let t = register_rigid(&fixed, dims, &affine, &moving, dims, &affine, None, &RigidParams::default())
1486            .unwrap();
1487        let rot_err = rotation_error_deg(&t.matrix, &truth);
1488        let trans_err = translation_error_mm(&t.matrix, &truth);
1489        println!("anisotropic: rot err {rot_err:.3}deg, trans err {trans_err:.3}mm");
1490        assert!(rot_err < 1.5, "rotation error {rot_err} deg");
1491        assert!(trans_err < 1.5, "translation error {trans_err} mm");
1492    }
1493
1494    /// Noise must not move the answer much — NCC is a correlation, so zero-mean noise dilutes
1495    /// its value without moving where its maximum sits.
1496    ///
1497    /// The noise is scaled to the volume's own standard deviation rather than its peak, because
1498    /// the peak is one small bright blob: "30% of the contrast actually present" is the honest
1499    /// way to state the level, and it is applied to *both* volumes independently, which is twice
1500    /// the perturbation of noising only one.
1501    #[test]
1502    fn recovery_survives_noise() {
1503        let dims = (48, 48, 48);
1504        let affine = identity_affine((1.0, 1.0, 1.0));
1505        let clean = phantom(dims);
1506        let truth = truth_transform(&grid_center_world(dims, &affine), [7.0, 5.0, -9.0], [-2.0, 1.0, 2.0]);
1507        let mut moving = warp(&clean, dims, &affine, dims, &affine, &truth);
1508        let mut fixed = clean.clone();
1509
1510        let n = clean.len() as f64;
1511        let mean = clean.iter().sum::<f64>() / n;
1512        let std = (clean.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n).sqrt();
1513        let sigma = 0.3 * std;
1514
1515        // SplitMix64 -> Box-Muller. The crate avoids an RNG dependency and the test only needs
1516        // repeatability.
1517        let mut state = 0xC0FFEEu64;
1518        let mut uniform = || {
1519            state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1520            let mut z = state;
1521            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1522            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1523            ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64
1524        };
1525        for v in fixed.iter_mut().chain(moving.iter_mut()) {
1526            let (u1, u2) = (uniform().max(1e-12), uniform());
1527            *v += sigma * (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
1528        }
1529
1530        let t = register_rigid(&fixed, dims, &affine, &moving, dims, &affine, None, &RigidParams::default())
1531            .unwrap();
1532        let rot_err = rotation_error_deg(&t.matrix, &truth);
1533        let trans_err = translation_error_mm(&t.matrix, &truth);
1534        println!("noisy (sigma {sigma:.3}): rot err {rot_err:.3}deg, trans err {trans_err:.3}mm, ncc {:.3}", t.ncc);
1535        assert!(rot_err < 2.0, "rotation error {rot_err} deg");
1536        assert!(trans_err < 2.0, "translation error {trans_err} mm");
1537    }
1538
1539    /// Different grids, not just different contents: the moving volume is on a coarser,
1540    /// differently-placed grid, which is the separately-prescribed-slab case.
1541    #[test]
1542    fn registers_across_different_grids() {
1543        let fixed_dims = (48, 48, 48);
1544        let fixed_affine = identity_affine((1.0, 1.0, 1.0));
1545        let fixed = phantom(fixed_dims);
1546
1547        let moving_dims = (40, 40, 30);
1548        let moving_affine = [
1549            1.2, 0.0, 0.0, 3.0, //
1550            0.0, 1.2, 0.0, -2.0, //
1551            0.0, 0.0, 1.5, 1.0, //
1552            0.0, 0.0, 0.0, 1.0,
1553        ];
1554        let truth = truth_transform(&grid_center_world(fixed_dims, &fixed_affine), [6.0, -5.0, 7.0], [1.0, -1.0, 2.0]);
1555        let moving = warp(&fixed, fixed_dims, &fixed_affine, moving_dims, &moving_affine, &truth);
1556
1557        let t = register_rigid(
1558            &fixed, fixed_dims, &fixed_affine,
1559            &moving, moving_dims, &moving_affine,
1560            None, &RigidParams::default(),
1561        )
1562        .unwrap();
1563        let rot_err = rotation_error_deg(&t.matrix, &truth);
1564        println!("cross-grid: rot err {rot_err:.3}deg, overlap {:.2}, ncc {:.3}", t.overlap, t.ncc);
1565        assert!(rot_err < 2.0, "rotation error {rot_err} deg");
1566        assert!(translation_error_mm(&t.matrix, &truth) < 2.0);
1567    }
1568
1569    /// Resampling through the recovered transform must put the moving volume back on the fixed
1570    /// grid. This is the half of the result COSMOS consumes directly, and it is a different
1571    /// claim from "the matrix is close to the truth" — it also pins the direction the transform
1572    /// is applied in.
1573    #[test]
1574    fn resampling_through_the_transform_restores_the_fixed_volume() {
1575        let dims = (48, 48, 48);
1576        let affine = identity_affine((1.0, 1.0, 1.0));
1577        let fixed = phantom(dims);
1578        let truth = truth_transform(&grid_center_world(dims, &affine), [10.0, 0.0, -8.0], [2.0, 2.0, -2.0]);
1579        let moving = warp(&fixed, dims, &affine, dims, &affine, &truth);
1580        let t = register_rigid(&fixed, dims, &affine, &moving, dims, &affine, None, &RigidParams::default())
1581            .unwrap();
1582
1583        let back = t.resample(&moving).unwrap();
1584        // Compare where the phantom actually has signal; the rotated corners are empty in one
1585        // volume and not the other, which is a property of the field of view and not of the
1586        // registration.
1587        let (mut sf, mut sb, mut sff, mut sbb, mut sfb, mut n) = (0.0, 0.0, 0.0, 0.0, 0.0, 0usize);
1588        for (&a, &b) in fixed.iter().zip(back.iter()) {
1589            if a <= 0.0 && b <= 0.0 {
1590                continue;
1591            }
1592            sf += a;
1593            sb += b;
1594            sff += a * a;
1595            sbb += b * b;
1596            sfb += a * b;
1597            n += 1;
1598        }
1599        let n = n as f64;
1600        let corr = (sfb - sf * sb / n)
1601            / ((sff - sf * sf / n).sqrt() * (sbb - sb * sb / n).sqrt());
1602        println!("round-trip correlation {corr:.5}");
1603        assert!(corr > 0.98, "round-trip correlation {corr}");
1604
1605        // And the wrong direction really is wrong: resampling with the *inverse* alignment
1606        // leaves the volume doubly rotated, so this must not also pass.
1607        let flipped = RigidTransform { matrix: rigid_inverse(&t.matrix), ..t.clone() };
1608        let bad = flipped.resample(&moving).unwrap();
1609        let (mut sf2, mut sb2, mut sff2, mut sbb2, mut sfb2, mut n2) = (0.0, 0.0, 0.0, 0.0, 0.0, 0usize);
1610        for (&a, &b) in fixed.iter().zip(bad.iter()) {
1611            if a <= 0.0 && b <= 0.0 {
1612                continue;
1613            }
1614            sf2 += a;
1615            sb2 += b;
1616            sff2 += a * a;
1617            sbb2 += b * b;
1618            sfb2 += a * b;
1619            n2 += 1;
1620        }
1621        let n2 = n2 as f64;
1622        let bad_corr = (sfb2 - sf2 * sb2 / n2)
1623            / ((sff2 - sf2 * sf2 / n2).sqrt() * (sbb2 - sb2 * sb2 / n2).sqrt());
1624        println!("wrong-direction correlation {bad_corr:.5}");
1625        assert!(bad_corr < corr - 0.01, "the inverted transform scored {bad_corr}, the correct one {corr}");
1626    }
1627
1628    // ---------------------------------------------------------- B0 transport
1629
1630    /// With no rotation, the B0 direction in the fixed frame is just the fixed affine's own —
1631    /// the degenerate case the general formula has to reduce to.
1632    #[test]
1633    fn b0_without_rotation_matches_the_fixed_affine() {
1634        let dims = (16, 16, 16);
1635        let oblique = [
1636            1.0, 0.0, 0.0, 0.0, //
1637            0.0, 0.9397, -0.3420, 0.0, //
1638            0.0, 0.3420, 0.9397, 0.0, //
1639            0.0, 0.0, 0.0, 1.0,
1640        ];
1641        let t = RigidTransform::identity(dims, &oblique, dims, &oblique);
1642        let got = t.b0_direction_in_fixed(None);
1643        let want = b0_direction_from_affine(&oblique);
1644        assert!((got.0 - want.0).abs() < 1e-12);
1645        assert!((got.1 - want.1).abs() < 1e-12);
1646        assert!((got.2 - want.2).abs() < 1e-12);
1647    }
1648
1649    /// The case the whole feature exists for: identical affines, the head rotated in voxel
1650    /// space. The affine says `(0,0,1)` for both orientations, and the registration is what
1651    /// recovers the real direction.
1652    ///
1653    /// A 20-degree rotation of the object about world x tilts B0, *in the common frame*, by 20
1654    /// degrees the other way. The sign is the whole point: get it backwards and COSMOS gets a
1655    /// kernel rotated 40 degrees from the truth while every diagnostic still looks plausible.
1656    #[test]
1657    fn registration_recovers_b0_where_the_affine_cannot() {
1658        let dims = (48, 48, 48);
1659        let affine = identity_affine((1.0, 1.0, 1.0));
1660        let fixed = phantom(dims);
1661        let angle_deg = 20.0f64;
1662        let angle = angle_deg.to_radians();
1663        let truth = truth_transform(&grid_center_world(dims, &affine), [angle_deg, 0.0, 0.0], [0.0, 0.0, 0.0]);
1664        let moving = warp(&fixed, dims, &affine, dims, &affine, &truth);
1665
1666        // Both volumes carry the same affine, so this is what a pipeline reading headers sees.
1667        assert_eq!(b0_direction_from_affine(&affine), (0.0, 0.0, 1.0));
1668
1669        let t = register_rigid(&fixed, dims, &affine, &moving, dims, &affine, None, &RigidParams::default())
1670            .unwrap();
1671        let b = t.b0_direction_in_fixed(None);
1672
1673        // Rx(angle) maps fixed world to moving world, so B0 (moving world +z) sits at
1674        // Rx(angle)ᵀ·(0,0,1) = (0, sin angle, cos angle) in the common frame.
1675        let want = (0.0, angle.sin(), angle.cos());
1676        println!("b0 recovered {b:?}, expected {want:?}");
1677        for (got, exp) in [(b.0, want.0), (b.1, want.1), (b.2, want.2)] {
1678            assert!((got - exp).abs() < 0.03, "{b:?} vs {want:?}");
1679        }
1680        // And the sign is not a coin flip: the y component must be positive, not negative.
1681        assert!(b.1 > 0.1, "B0 tilted the wrong way: {b:?}");
1682
1683        // The tilt is large enough that a multi-orientation check would accept the pair, which
1684        // is exactly the error this converts into a supported path.
1685        let spread = b.2.abs().clamp(0.0, 1.0).acos().to_degrees();
1686        assert!(spread > 15.0, "spread against the reference is only {spread} deg");
1687    }
1688
1689    /// A declared (sidecar) direction has to be carried through the same rotation. Passing the
1690    /// affine-derived direction explicitly must give the same answer as passing `None`.
1691    #[test]
1692    fn declared_b0_goes_through_the_same_transport() {
1693        let dims = (16, 16, 16);
1694        let fixed_affine = identity_affine((1.0, 1.0, 1.0));
1695        let moving_affine = [
1696            1.0, 0.0, 0.0, 0.0, //
1697            0.0, 0.866, -0.5, 0.0, //
1698            0.0, 0.5, 0.866, 0.0, //
1699            0.0, 0.0, 0.0, 1.0,
1700        ];
1701        let mut t = RigidTransform::identity(dims, &fixed_affine, dims, &moving_affine);
1702        t.matrix = matrix_from_params(&[0.2, -0.1, 0.3, 0.0, 0.0, 0.0], &grid_center_world(dims, &fixed_affine));
1703
1704        let implicit = t.b0_direction_in_fixed(None);
1705        let explicit = t.b0_direction_in_fixed(Some(b0_direction_from_affine(&moving_affine)));
1706        assert!((implicit.0 - explicit.0).abs() < 1e-12);
1707        assert!((implicit.1 - explicit.1).abs() < 1e-12);
1708        assert!((implicit.2 - explicit.2).abs() < 1e-12);
1709
1710        // A different declared direction must give a different answer, or the argument is
1711        // being ignored.
1712        let other = t.b0_direction_in_fixed(Some((1.0, 0.0, 0.0)));
1713        assert!(
1714            (other.0 - implicit.0).abs() + (other.1 - implicit.1).abs() + (other.2 - implicit.2).abs() > 0.1,
1715            "declared direction had no effect"
1716        );
1717    }
1718
1719    /// `aligned_affine` must place the moving volume where resampling says it is: sampling the
1720    /// retagged volume on the fixed grid is the same operation `resample` performs.
1721    #[test]
1722    fn aligned_affine_agrees_with_resampling() {
1723        let dims = (32, 32, 32);
1724        let affine = identity_affine((1.0, 1.0, 1.0));
1725        let moving = phantom(dims);
1726        let mut t = RigidTransform::identity(dims, &affine, dims, &affine);
1727        t.matrix = matrix_from_params(&[0.15, -0.1, 0.25, 1.0, -2.0, 0.5], &grid_center_world(dims, &affine));
1728
1729        let via_method = t.resample(&moving).unwrap();
1730        let via_affine =
1731            resample_onto(&moving, dims, &t.aligned_affine(), dims, &affine).unwrap();
1732        for (a, b) in via_method.iter().zip(via_affine.iter()) {
1733            assert!((a - b).abs() < 1e-12);
1734        }
1735    }
1736
1737    #[test]
1738    fn inverse_swaps_the_roles() {
1739        let fixed_dims = (32, 32, 32);
1740        let moving_dims = (24, 24, 20);
1741        let fixed_affine = identity_affine((1.0, 1.0, 1.0));
1742        let moving_affine = identity_affine((1.5, 1.5, 2.0));
1743        let mut t = RigidTransform::identity(fixed_dims, &fixed_affine, moving_dims, &moving_affine);
1744        t.matrix = matrix_from_params(&[0.2, 0.1, -0.3, 3.0, -1.0, 2.0], &grid_center_world(fixed_dims, &fixed_affine));
1745
1746        let inv = t.inverse();
1747        assert_eq!(inv.fixed_dims, moving_dims);
1748        assert_eq!(inv.moving_dims, fixed_dims);
1749        // Its stored params must regenerate its own matrix.
1750        let rebuilt = matrix_from_params(&inv.params, &grid_center_world(moving_dims, &moving_affine));
1751        for (i, (&got, &want)) in rebuilt.iter().zip(inv.matrix.iter()).enumerate() {
1752            assert!((got - want).abs() < 1e-9, "element {i}: {got} vs {want}");
1753        }
1754        // And inverting twice must come back.
1755        let round = inv.inverse();
1756        for (i, (&got, &want)) in round.matrix.iter().zip(t.matrix.iter()).enumerate() {
1757            assert!((got - want).abs() < 1e-9, "element {i}: {got} vs {want}");
1758        }
1759    }
1760
1761    // ---------------------------------------------------------- guards
1762
1763    #[test]
1764    fn rejects_mismatched_lengths_and_singular_affines() {
1765        let dims = (8, 8, 8);
1766        let affine = identity_affine((1.0, 1.0, 1.0));
1767        let v = vec![0.0f64; 8 * 8 * 8];
1768        let short = vec![0.0f64; 10];
1769        let p = RigidParams::default();
1770        assert!(register_rigid(&short, dims, &affine, &v, dims, &affine, None, &p).is_none());
1771        assert!(register_rigid(&v, dims, &affine, &short, dims, &affine, None, &p).is_none());
1772        assert!(register_rigid(&v, dims, &affine, &v, dims, &affine, Some(&[1u8; 4]), &p).is_none());
1773        let singular = [0.0f64; 16];
1774        assert!(register_rigid(&v, dims, &singular, &v, dims, &affine, None, &p).is_none());
1775        assert!(register_rigid(&v, dims, &affine, &v, dims, &singular, None, &p).is_none());
1776    }
1777
1778    /// A mask must actually restrict the metric.
1779    ///
1780    /// The fixed volume gets bright, structured content *outside* the mask that the moving volume
1781    /// does not have — which is the real situation, since the receive field is fixed to the coil
1782    /// and the scalp and neck differ between orientations. Masked, the registration should land;
1783    /// unmasked, it should do measurably worse. Scaling that content to the phantom's own peak
1784    /// rather than swamping it keeps the comparison about the mask and not about arithmetic.
1785    #[test]
1786    fn the_mask_restricts_the_metric() {
1787        let dims = (48, 48, 48);
1788        let affine = identity_affine((1.0, 1.0, 1.0));
1789        let fixed = phantom(dims);
1790        let truth = truth_transform(&grid_center_world(dims, &affine), [8.0, 0.0, 6.0], [1.0, -1.0, 0.0]);
1791        let moving = warp(&fixed, dims, &affine, dims, &affine, &truth);
1792
1793        let (nx, ny, nz) = dims;
1794        let peak = fixed.iter().cloned().fold(0.0f64, f64::max);
1795        let mut mask = vec![0u8; nx * ny * nz];
1796        let mut spoiled = fixed.clone();
1797        for k in 0..nz {
1798            for j in 0..ny {
1799                for i in 0..nx {
1800                    let idx = i + j * nx + k * nx * ny;
1801                    // An ellipsoid a little larger than the phantom's body: the "brain mask".
1802                    let (x, y, z) = (i as f64 / nx as f64, j as f64 / ny as f64, k as f64 / nz as f64);
1803                    let d = ((x - 0.5) / 0.38).powi(2) + ((y - 0.5) / 0.30).powi(2) + ((z - 0.5) / 0.34).powi(2);
1804                    let inside = d <= 1.0;
1805                    mask[idx] = inside as u8;
1806                    if !inside {
1807                        spoiled[idx] = 0.6 * peak
1808                            * (1.0 + (0.7 * i as f64).sin() * (0.5 * j as f64).cos() * (0.3 * k as f64).sin());
1809                    }
1810                }
1811            }
1812        }
1813        let p = RigidParams::default();
1814        let masked = register_rigid(&spoiled, dims, &affine, &moving, dims, &affine, Some(&mask), &p).unwrap();
1815        let unmasked = register_rigid(&spoiled, dims, &affine, &moving, dims, &affine, None, &p).unwrap();
1816        let masked_err = rotation_error_deg(&masked.matrix, &truth);
1817        let unmasked_err = rotation_error_deg(&unmasked.matrix, &truth);
1818        println!("masked err {masked_err:.3}deg (ncc {:.3}), unmasked err {unmasked_err:.3}deg (ncc {:.3})",
1819                 masked.ncc, unmasked.ncc);
1820        assert!(masked_err < 1.5, "masked rotation error {masked_err} deg");
1821        assert!(
1822            unmasked_err > 2.0 * masked_err,
1823            "the mask made no real difference: masked {masked_err}, unmasked {unmasked_err}"
1824        );
1825    }
1826
1827    /// `max_samples` is the knob that bounds cost. Lowering it must reduce the work done
1828    /// without destroying the answer — the claim the module docs make.
1829    #[test]
1830    fn max_samples_bounds_the_work() {
1831        let dims = (48, 48, 48);
1832        let affine = identity_affine((1.0, 1.0, 1.0));
1833        let fixed = phantom(dims);
1834        let truth = truth_transform(&grid_center_world(dims, &affine), [7.0, 0.0, -5.0], [1.0, 0.0, -1.0]);
1835        let moving = warp(&fixed, dims, &affine, dims, &affine, &truth);
1836
1837        let cheap = RigidParams { max_samples: 3000, ..Default::default() };
1838        let t = register_rigid(&fixed, dims, &affine, &moving, dims, &affine, None, &cheap).unwrap();
1839        let err = rotation_error_deg(&t.matrix, &truth);
1840        println!("3000-sample registration: rot err {err:.3}deg");
1841        assert!(err < 2.0, "rotation error {err} deg with a 3000-sample cap");
1842
1843        // The cap is honoured: a level must never build more samples than asked for.
1844        let v = Volume::borrowed(&fixed, dims, affine);
1845        assert!(sample_set(&v, None, 3000).len() <= 3000);
1846        assert!(sample_set(&v, None, 200_000).len() <= 200_000);
1847    }
1848
1849    #[test]
1850    fn evaluation_budget_is_respected() {
1851        let dims = (32, 32, 32);
1852        let affine = identity_affine((1.0, 1.0, 1.0));
1853        let fixed = phantom(dims);
1854        let moving = warp(
1855            &fixed,
1856            dims,
1857            &affine,
1858            dims,
1859            &affine,
1860            &truth_transform(&grid_center_world(dims, &affine), [6.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
1861        );
1862        let p = RigidParams { max_evaluations: 40, ..Default::default() };
1863        let t = register_rigid(&fixed, dims, &affine, &moving, dims, &affine, None, &p).unwrap();
1864        assert!(t.evaluations <= 60, "budget 40 overrun to {}", t.evaluations);
1865    }
1866}