Skip to main content

qsm_core/
geometry.rs

1//! Scan geometry derived from the NIfTI affine, and resampling to an axial grid.
2//!
3//! Two things QSM needs from the affine and nothing else provides:
4//!
5//! - **Which way B0 points in voxel space.** The dipole kernel is built in the voxel grid, so an
6//!   oblique acquisition must either be told the true B0 direction ([`b0_direction_from_affine`])
7//!   or be resampled so that the grid is cardinal-aligned and B0 is `(0, 0, 1)` by construction
8//!   ([`resample_complex_to_axial`]). Getting this wrong rotates the kernel and suppresses
9//!   susceptibility contrast in exactly the iron-rich structures QSM is usually measuring.
10//! - **How oblique the acquisition is** ([`obliquity_from_affine`]), so a pipeline can decide
11//!   whether resampling is worth the interpolation.
12//!
13//! ## Phase must be resampled in the complex domain
14//!
15//! Wrapped phase cannot be interpolated directly: halfway between `+3.0` and `-3.0` rad, a linear
16//! interpolator returns `0.0`, when the correct answer is near `±π`. Every wrap in the volume
17//! becomes a band of wrong values. [`resample_complex_to_axial`] takes magnitude and phase
18//! together, interpolates `mag·e^{iφ}` as real and imaginary parts, and recovers magnitude and
19//! phase afterwards, which is well defined across wraps. Use [`resample_to_axial`] only for
20//! quantities that are already continuous (magnitude, an unwrapped field map, χ).
21//!
22//! ## The same result on every target
23//!
24//! The complex resamplers give the same bits natively and in WebAssembly. Their sines, cosines,
25//! `atan2` and `hypot` come from [`crate::utils::portable_math`] rather than the platform's maths
26//! library, which differs between targets in the last bit; the rest is IEEE arithmetic in a
27//! fixed order. That matters beyond the last bit: the empty-corner fill in
28//! [`resample_complex_to_axial`] is seeded from a hash of the whole phase volume, and phase-based
29//! masking thresholds what comes out, so one differing ulp anywhere used to give the browser a
30//! different mask from `qsmxt run`. Where the interpolated value is exactly zero (outside the
31//! source, or where the source magnitude is zero) there is no phase to recover, and the phase is
32//! defined as 0 rather than whatever `atan2` makes of the signs of the zeros.
33
34// ------------------------------------------------------------------ axis orientation
35
36/// The anatomical direction an array axis runs *toward*.
37///
38/// The three letters of a NIfTI-style orientation code, e.g. `RAS` = axis 0 runs toward the
39/// patient's right, axis 1 anterior, axis 2 superior.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41#[allow(clippy::upper_case_acronyms)]
42pub enum AxisCode {
43    /// Toward the patient's right.
44    R,
45    /// Toward the patient's left.
46    L,
47    /// Anterior.
48    A,
49    /// Posterior.
50    P,
51    /// Superior.
52    S,
53    /// Inferior.
54    I,
55}
56
57impl AxisCode {
58    /// Which world axis (0 = L-R, 1 = P-A, 2 = I-S) this code lies on.
59    const fn world_axis(self) -> usize {
60        match self {
61            AxisCode::R | AxisCode::L => 0,
62            AxisCode::A | AxisCode::P => 1,
63            AxisCode::S | AxisCode::I => 2,
64        }
65    }
66
67    /// Whether this code points along the *negative* direction of its world axis, i.e. away
68    /// from R, A or S.
69    const fn is_negative(self) -> bool {
70        matches!(self, AxisCode::L | AxisCode::P | AxisCode::I)
71    }
72}
73
74/// Which volume axis each of R, A, S runs along — lab2im's `get_ras_axes`.
75///
76/// Returns `[axis_of_R, axis_of_A, axis_of_S]`. A degenerate affine that maps two world axes
77/// onto the same volume axis is repaired by reassigning the last duplicate to whichever volume
78/// axis is otherwise unused, so the result is always a permutation.
79pub fn ras_axes(affine: &[f64; 16]) -> [usize; 3] {
80    let Some(inv) = invert_3x3([
81        [affine[0], affine[1], affine[2]],
82        [affine[4], affine[5], affine[6]],
83        [affine[8], affine[9], affine[10]],
84    ]) else {
85        return [0, 1, 2];
86    };
87    // `voxel = inv · world`, so *column* j of the inverse is world axis j expressed in volume
88    // axes; the row with the largest magnitude is the volume axis it runs along. (This is
89    // lab2im's `np.argmax(|inv|, axis=0)` — down the columns. Taking the argmax along the rows
90    // instead yields the inverse permutation, which agrees only for a diagonal affine.)
91    //
92    // Scale each row to unit length first, which lab2im does not. The voxel→world matrix is
93    // `A = R·S` with `S = diag(voxel sizes)`, so `A⁻¹ = S⁻¹·Rᵀ` and row `i` of the inverse carries
94    // a factor `1/sᵢ`. Comparing those magnitudes directly therefore compares direction times
95    // inverse voxel size, and a thick axis loses to a thin axis's side lobe as soon as the tilt
96    // exceeds roughly the reciprocal of the anisotropy ratio — about 15° at 4:1. A tilted sagittal
97    // stack then came back with its slice axis identified as an in-plane one, which is how the
98    // spacings end up on the wrong world axes all over again. Row `i` of the inverse has norm
99    // `1/sᵢ`, so normalising recovers `Rᵀ` exactly and the comparison is between directions.
100    let mut axes = [0usize; 3];
101    let norms: [f64; 3] = std::array::from_fn(|i| {
102        let n = (inv[i][0] * inv[i][0] + inv[i][1] * inv[i][1] + inv[i][2] * inv[i][2]).sqrt();
103        if n > 0.0 { n } else { 1.0 }
104    });
105    for (j, a) in axes.iter_mut().enumerate() {
106        *a = (0..3)
107            .max_by(|&x, &y| {
108                (inv[x][j].abs() / norms[x]).total_cmp(&(inv[y][j].abs() / norms[y]))
109            })
110            .unwrap();
111    }
112    // A near-degenerate affine can hand the same volume axis to two world axes; make it a
113    // permutation again by reassigning the last duplicate to whichever axis is missing.
114    for i in 0..3 {
115        if !axes.contains(&i) {
116            let mut counts = [0usize; 3];
117            for &a in &axes {
118                counts[a] += 1;
119            }
120            let dup = (0..3).max_by_key(|&v| counts[v]).unwrap();
121            if let Some(pos) = axes.iter().rposition(|&a| a == dup) {
122                axes[pos] = i;
123            }
124        }
125    }
126    axes
127}
128
129fn invert_3x3(m: [[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
130    let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
131        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
132        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
133    if det.abs() < 1e-12 {
134        return None;
135    }
136    let mut inv = [[0.0f64; 3]; 3];
137    for i in 0..3 {
138        for j in 0..3 {
139            let (a, b) = ((i + 1) % 3, (i + 2) % 3);
140            let (c, d) = ((j + 1) % 3, (j + 2) % 3);
141            // Transposed cofactor = adjugate.
142            inv[j][i] = (m[a][c] * m[b][d] - m[a][d] * m[b][c]) / det;
143        }
144    }
145    Some(inv)
146}
147
148/// The axis permutation and per-axis flips that take a volume into `target` orientation.
149///
150/// Returns `(perm, flip)` where target axis `i` is the source volume's axis `perm[i]`, reversed
151/// when `flip[i]`. Feed the pair to [`reorient`]; [`invert_alignment`] gives the pair that
152/// undoes it.
153///
154/// This resolves only the 48 cardinal flips and permutations, which is all a learned model
155/// needs: it says nothing about obliquity. An oblique acquisition still has to be resampled
156/// ([`resample_to_axial`]) — this picks the nearest cardinal relabelling of its axes.
157pub fn alignment_to(affine: &[f64; 16], target: [AxisCode; 3]) -> ([usize; 3], [bool; 3]) {
158    let ras = ras_axes(affine);
159    let m = [
160        [affine[0], affine[1], affine[2]],
161        [affine[4], affine[5], affine[6]],
162        [affine[8], affine[9], affine[10]],
163    ];
164    let mut perm = [0usize; 3];
165    let mut flip = [false; 3];
166    for i in 0..3 {
167        let w = target[i].world_axis();
168        perm[i] = ras[w];
169        // Row w of the direction matrix is world axis w in terms of the volume axes; its sign
170        // along the chosen volume axis says whether that axis runs with or against it.
171        flip[i] = (m[w][ras[w]] < 0.0) != target[i].is_negative();
172    }
173    (perm, flip)
174}
175
176/// The `(perm, flip)` that undoes [`alignment_to`] — pass it to [`reorient`] to get back.
177pub fn invert_alignment(perm: [usize; 3], flip: [bool; 3]) -> ([usize; 3], [bool; 3]) {
178    let mut inv_perm = [0usize; 3];
179    for i in 0..3 {
180        inv_perm[perm[i]] = i;
181    }
182    let mut inv_flip = [false; 3];
183    for j in 0..3 {
184        inv_flip[j] = flip[inv_perm[j]];
185    }
186    (inv_perm, inv_flip)
187}
188
189/// Whether a `(perm, flip)` pair leaves a volume untouched.
190pub fn alignment_is_identity(perm: [usize; 3], flip: [bool; 3]) -> bool {
191    perm == [0, 1, 2] && flip == [false; 3]
192}
193
194/// Permute and flip a column-major volume. Returns the volume and its new dimensions.
195///
196/// Output axis `i` is the input's axis `perm[i]`, reversed when `flip[i]` — the convention
197/// [`alignment_to`] returns. Pure gather, so it is exact: no interpolation, no resampling.
198pub fn reorient<T: Copy>(
199    data: &[T],
200    dims: (usize, usize, usize),
201    perm: [usize; 3],
202    flip: [bool; 3],
203) -> (Vec<T>, (usize, usize, usize)) {
204    let src = [dims.0, dims.1, dims.2];
205    assert_eq!(data.len(), src[0] * src[1] * src[2], "data length must match dims");
206    let out_dims = [src[perm[0]], src[perm[1]], src[perm[2]]];
207    let strides = [1isize, src[0] as isize, (src[0] * src[1]) as isize];
208
209    // Source offset contributed by each output axis, and its step along that axis.
210    let mut base = 0isize;
211    let mut step = [0isize; 3];
212    for i in 0..3 {
213        let s = strides[perm[i]];
214        if flip[i] {
215            base += (out_dims[i] as isize - 1) * s;
216            step[i] = -s;
217        } else {
218            step[i] = s;
219        }
220    }
221
222    let mut out = Vec::with_capacity(out_dims[0] * out_dims[1] * out_dims[2]);
223    for o2 in 0..out_dims[2] {
224        let p2 = base + step[2] * o2 as isize;
225        for o1 in 0..out_dims[1] {
226            let p1 = p2 + step[1] * o1 as isize;
227            for o0 in 0..out_dims[0] {
228                out.push(data[(p1 + step[0] * o0 as isize) as usize]);
229            }
230        }
231    }
232    (out, (out_dims[0], out_dims[1], out_dims[2]))
233}
234
235/// Voxel sizes (mm) from a row-major 4×4 affine: the norms of its three columns.
236pub fn voxel_sizes_from_affine(affine: &[f64; 16]) -> (f64, f64, f64) {
237    let col = |j: usize| {
238        (affine[j] * affine[j] + affine[4 + j] * affine[4 + j] + affine[8 + j] * affine[8 + j])
239            .sqrt()
240    };
241    (col(0), col(1), col(2))
242}
243
244/// Direction of the scanner's B0 field (world `+z`) expressed in voxel coordinates, normalised.
245///
246/// The voxel→world matrix is `A = R·S`, with `R` a rotation and `S = diag(voxel sizes)`. Dividing
247/// each column of `A` by its norm recovers `R`, whose inverse is its transpose, so the direction
248/// is simply the third row of the normalised matrix. An axial acquisition returns `(0, 0, 1)`.
249///
250/// Factoring the voxel sizes out first is what makes this correct for anisotropic voxels. Using
251/// `A⁻¹·(0,0,1)` instead — inverting the matrix *with* the voxel scaling still in it — silently
252/// skews the direction: on a 0.8 × 0.8 × 3 mm acquisition tilted 23° it returns a direction 58°
253/// from `z`, and the resulting dipole kernel destroys most of the susceptibility contrast. That
254/// was a real regression in QSMxT 8.2.2; [`tests::b0_direction_anisotropic_oblique`] pins it.
255pub fn b0_direction_from_affine(affine: &[f64; 16]) -> (f64, f64, f64) {
256    let (sx, sy, sz) = voxel_sizes_from_affine(affine);
257    if sx < 1e-10 || sy < 1e-10 || sz < 1e-10 {
258        return (0.0, 0.0, 1.0);
259    }
260    // Third row of R = A · S⁻¹, i.e. world-z expressed in voxel axes.
261    let (bx, by, bz) = (affine[8] / sx, affine[9] / sy, affine[10] / sz);
262    let norm = (bx * bx + by * by + bz * bz).sqrt();
263    if norm < 1e-10 {
264        return (0.0, 0.0, 1.0);
265    }
266    (bx / norm, by / norm, bz / norm)
267}
268
269/// Angle (degrees) between B0 and the voxel `+z` axis — the tilt that matters for the dipole
270/// kernel. Zero for an axial acquisition; equals the scanner's slice tilt for a simple oblique.
271pub fn b0_angle_from_affine(affine: &[f64; 16]) -> f64 {
272    let (_, _, bz) = b0_direction_from_affine(affine);
273    bz.clamp(-1.0, 1.0).abs().acos().to_degrees()
274}
275
276/// Per-axis obliquity (degrees), matching `nibabel.affines.obliquity`: for each voxel axis, the
277/// angle between it and the closest world axis. `[0, 0, 0]` for a cardinal-aligned acquisition.
278pub fn obliquity_axes_from_affine(affine: &[f64; 16]) -> [f64; 3] {
279    let sizes = voxel_sizes_from_affine(affine);
280    let sizes = [sizes.0, sizes.1, sizes.2];
281    let mut out = [0.0f64; 3];
282    for (i, o) in out.iter_mut().enumerate() {
283        // Row i of A · S⁻¹; its largest component is the cosine to the nearest world axis.
284        let best = (0..3)
285            .map(|j| {
286                if sizes[j] < 1e-10 {
287                    0.0
288                } else {
289                    (affine[4 * i + j] / sizes[j]).abs()
290                }
291            })
292            .fold(0.0f64, f64::max);
293        *o = best.clamp(0.0, 1.0).acos().to_degrees();
294    }
295    out
296}
297
298/// Scalar obliquity (degrees): the Euclidean norm of [`obliquity_axes_from_affine`].
299///
300/// This is the quantity QSMxT 8.x thresholded on (`nibabel` obliquity → degrees → `np.linalg.norm`),
301/// so thresholds carry over unchanged. Note it is a combined measure, not a tilt: a single 23°
302/// oblique acquisition scores ≈32° here because two voxel axes each move. For the physical tilt of
303/// B0 relative to the slice stack, use [`b0_angle_from_affine`].
304pub fn obliquity_from_affine(affine: &[f64; 16]) -> f64 {
305    let a = obliquity_axes_from_affine(affine);
306    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
307}
308
309/// Mapping from one voxel grid to another, each described by its own affine.
310///
311/// Used for the return trip: a run reconstructed on a resampled grid still has to write its
312/// outputs where the caller's other data lives. That matters more than it sounds — a FLIRT
313/// matrix, for instance, is defined in a coordinate space derived from the image's dimensions
314/// and voxel sizes, so handing it a volume on a different grid produces a wrong registration
315/// rather than an error.
316pub struct GridMap {
317    pub dst_dims: (usize, usize, usize),
318    src_dims: (usize, usize, usize),
319    /// Voxel→world of the destination.
320    dst_affine: [f64; 16],
321    /// World→voxel of the source.
322    inv_src: [[f64; 4]; 3],
323}
324
325/// Invert the 3×3 of an affine and fold in its translation, giving world→voxel as a 3×4.
326pub(crate) fn world_to_voxel(affine: &[f64; 16]) -> Option<[[f64; 4]; 3]> {
327    let r = [
328        [affine[0], affine[1], affine[2]],
329        [affine[4], affine[5], affine[6]],
330        [affine[8], affine[9], affine[10]],
331    ];
332    let t = [affine[3], affine[7], affine[11]];
333    let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1])
334        - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
335        + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
336    if det.abs() < 1e-12 {
337        return None;
338    }
339    let id = 1.0 / det;
340    let inv = [
341        [
342            (r[1][1] * r[2][2] - r[1][2] * r[2][1]) * id,
343            (r[0][2] * r[2][1] - r[0][1] * r[2][2]) * id,
344            (r[0][1] * r[1][2] - r[0][2] * r[1][1]) * id,
345        ],
346        [
347            (r[1][2] * r[2][0] - r[1][0] * r[2][2]) * id,
348            (r[0][0] * r[2][2] - r[0][2] * r[2][0]) * id,
349            (r[0][2] * r[1][0] - r[0][0] * r[1][2]) * id,
350        ],
351        [
352            (r[1][0] * r[2][1] - r[1][1] * r[2][0]) * id,
353            (r[0][1] * r[2][0] - r[0][0] * r[2][1]) * id,
354            (r[0][0] * r[1][1] - r[0][1] * r[1][0]) * id,
355        ],
356    ];
357    let mut out = [[0.0f64; 4]; 3];
358    for (i, row) in out.iter_mut().enumerate() {
359        row[..3].copy_from_slice(&inv[i]);
360        row[3] = -(inv[i][0] * t[0] + inv[i][1] * t[1] + inv[i][2] * t[2]);
361    }
362    Some(out)
363}
364
365impl GridMap {
366    /// Map from `src` onto `dst`. Both affines are row-major voxel→world.
367    pub fn new(
368        src_dims: (usize, usize, usize),
369        src_affine: &[f64; 16],
370        dst_dims: (usize, usize, usize),
371        dst_affine: &[f64; 16],
372    ) -> Option<Self> {
373        Some(Self {
374            dst_dims,
375            src_dims,
376            dst_affine: *dst_affine,
377            inv_src: world_to_voxel(src_affine)?,
378        })
379    }
380
381    /// Source-voxel coordinate for a destination voxel, or `None` if it falls outside the source.
382    fn source_coord(&self, i: usize, j: usize, k: usize) -> Option<(f64, f64, f64)> {
383        let (i, j, k) = (i as f64, j as f64, k as f64);
384        let a = &self.dst_affine;
385        let w = [
386            a[0] * i + a[1] * j + a[2] * k + a[3],
387            a[4] * i + a[5] * j + a[6] * k + a[7],
388            a[8] * i + a[9] * j + a[10] * k + a[11],
389        ];
390        let m = &self.inv_src;
391        let o = (
392            m[0][0] * w[0] + m[0][1] * w[1] + m[0][2] * w[2] + m[0][3],
393            m[1][0] * w[0] + m[1][1] * w[1] + m[1][2] * w[2] + m[1][3],
394            m[2][0] * w[0] + m[2][1] * w[1] + m[2][2] * w[2] + m[2][3],
395        );
396        let (nx, ny, nz) = self.src_dims;
397        let inside = o.0 >= -0.5 && o.0 <= nx as f64 - 0.5
398            && o.1 >= -0.5 && o.1 <= ny as f64 - 0.5
399            && o.2 >= -0.5 && o.2 <= nz as f64 - 0.5;
400        if inside { Some(o) } else { None }
401    }
402
403    /// Trilinearly resample continuous data onto the destination grid; 0 outside the source.
404    pub fn sample(&self, data: &[f64]) -> Vec<f64> {
405        let (nx, ny, nz) = self.src_dims;
406        let (dx, dy, dz) = self.dst_dims;
407        let mut out = vec![0.0f64; dx * dy * dz];
408        for k in 0..dz {
409            for j in 0..dy {
410                for i in 0..dx {
411                    if let Some((ox, oy, oz)) = self.source_coord(i, j, k) {
412                        out[i + j * dx + k * dx * dy] = trilinear_sample(data, nx, ny, nz, ox, oy, oz);
413                    }
414                }
415            }
416        }
417        out
418    }
419
420    /// Nearest-neighbour, for labels and binary masks.
421    pub fn sample_nearest(&self, data: &[u8]) -> Vec<u8> {
422        let (nx, ny, nz) = self.src_dims;
423        let (dx, dy, dz) = self.dst_dims;
424        let mut out = vec![0u8; dx * dy * dz];
425        for k in 0..dz {
426            for j in 0..dy {
427                for i in 0..dx {
428                    if let Some((ox, oy, oz)) = self.source_coord(i, j, k) {
429                        let xi = (ox.round() as isize).clamp(0, nx as isize - 1) as usize;
430                        let yi = (oy.round() as isize).clamp(0, ny as isize - 1) as usize;
431                        let zi = (oz.round() as isize).clamp(0, nz as isize - 1) as usize;
432                        out[i + j * dx + k * dx * dy] = data[xi + yi * nx + zi * nx * ny];
433                    }
434                }
435            }
436        }
437        out
438    }
439
440    /// Label image resampled by trilinear-weighted majority vote; `T::default()` outside the
441    /// source. See [`resample_labels_onto`]. `data` must be the source grid's size.
442    fn sample_labels<T: Copy + PartialEq + Default>(&self, data: &[T]) -> Vec<T> {
443        let (dx, dy, dz) = self.dst_dims;
444        let mut out = vec![T::default(); dx * dy * dz];
445        for k in 0..dz {
446            for j in 0..dy {
447                for i in 0..dx {
448                    if let Some((ox, oy, oz)) = self.source_coord(i, j, k) {
449                        out[i + j * dx + k * dx * dy] = majority_label(data, self.src_dims, ox, oy, oz);
450                    }
451                }
452            }
453        }
454        out
455    }
456}
457
458/// The label with the largest total trilinear weight among the (up to) eight source voxels
459/// around a fractional coordinate: the argmax of each label's indicator, linearly
460/// interpolated. A tie goes to the nearest voxel's label, then to the first found.
461fn majority_label<T: Copy + PartialEq>(
462    data: &[T],
463    (nx, ny, nz): (usize, usize, usize),
464    x: f64,
465    y: f64,
466    z: f64,
467) -> T {
468    // corner indices and in-cell fractions, clamped at the edges as `trilinear_sample` does,
469    // but with the fraction clamped too so every weight is non-negative
470    let axis = |c: f64, n: usize| {
471        let c0 = (c.floor() as isize).clamp(0, n as isize - 1) as usize;
472        let c1 = (c0 + 1).min(n - 1);
473        (c0, c1, (c - c0 as f64).clamp(0.0, 1.0))
474    };
475    let (x0, x1, fx) = axis(x, nx);
476    let (y0, y1, fy) = axis(y, ny);
477    let (z0, z1, fz) = axis(z, nz);
478    let idx = |a: usize, b: usize, c: usize| a + b * nx + c * nx * ny;
479    let nearest = data[idx(
480        if fx < 0.5 { x0 } else { x1 },
481        if fy < 0.5 { y0 } else { y1 },
482        if fz < 0.5 { z0 } else { z1 },
483    )];
484
485    let mut labels: [Option<(T, f64)>; 8] = [None; 8];
486    for (cx, wx) in [(x0, 1.0 - fx), (x1, fx)] {
487        for (cy, wy) in [(y0, 1.0 - fy), (y1, fy)] {
488            for (cz, wz) in [(z0, 1.0 - fz), (z1, fz)] {
489                let w = wx * wy * wz;
490                let label = data[idx(cx, cy, cz)];
491                for slot in labels.iter_mut() {
492                    match slot {
493                        Some((l, total)) if *l == label => {
494                            *total += w;
495                            break;
496                        }
497                        None => {
498                            *slot = Some((label, w));
499                            break;
500                        }
501                        Some(_) => {}
502                    }
503                }
504            }
505        }
506    }
507
508    const TIE: f64 = 1e-12;
509    let best = labels.iter().flatten().map(|&(_, w)| w).fold(f64::NEG_INFINITY, f64::max);
510    if labels.iter().flatten().any(|&(l, w)| l == nearest && w >= best - TIE) {
511        return nearest;
512    }
513    labels
514        .iter()
515        .flatten()
516        .find(|&&(_, w)| w >= best - TIE)
517        .map_or(nearest, |&(l, _)| l)
518}
519
520/// Resample continuous data (magnitude, an unwrapped field, χ) from one grid onto another.
521/// Returns `None` if the source affine is singular.
522pub fn resample_onto(
523    data: &[f64],
524    src_dims: (usize, usize, usize),
525    src_affine: &[f64; 16],
526    dst_dims: (usize, usize, usize),
527    dst_affine: &[f64; 16],
528) -> Option<Vec<f64>> {
529    Some(GridMap::new(src_dims, src_affine, dst_dims, dst_affine)?.sample(data))
530}
531
532/// Resample a binary mask from one grid onto another (nearest neighbour). For a label image,
533/// use [`resample_labels_onto`].
534pub fn resample_mask_onto(
535    mask: &[u8],
536    src_dims: (usize, usize, usize),
537    src_affine: &[f64; 16],
538    dst_dims: (usize, usize, usize),
539    dst_affine: &[f64; 16],
540) -> Option<Vec<u8>> {
541    Some(GridMap::new(src_dims, src_affine, dst_dims, dst_affine)?.sample_nearest(mask))
542}
543
544/// Resample a label image (a segmentation, a parcellation) from one grid onto another.
545///
546/// Each destination voxel takes the label with the largest total trilinear weight among the
547/// eight source voxels around it: equivalently, each label's indicator is interpolated
548/// linearly and the largest wins. Ties go to the nearest voxel's label. Outside the source the
549/// output is `T::default()` (0, background).
550///
551/// The output only ever holds labels that are in the input. Trilinear interpolation
552/// ([`resample_onto`]) would invent values between label ids at every boundary; plain nearest
553/// neighbour does not, but on a rotated grid it takes whichever single voxel the rounding
554/// lands on, so boundaries come out staircased and a structure one or two voxels thick can be
555/// dropped or doubled depending on where the grids happen to line up. The vote moves each
556/// boundary to where the interpolated label fraction crosses one half, as a linearly
557/// interpolated mask thresholded at 0.5 would, and costs the same eight lookups as trilinear
558/// interpolation. On the identity mapping it returns the input unchanged.
559///
560/// Works for any label type: `u8`/`u16`/`u32` ids, or integer-valued `f64` as read from a
561/// NIfTI.
562///
563/// # Errors
564/// If `labels` is not `src_dims` in size, or the source affine is singular.
565pub fn resample_labels_onto<T: Copy + PartialEq + Default>(
566    labels: &[T],
567    src_dims: (usize, usize, usize),
568    src_affine: &[f64; 16],
569    dst_dims: (usize, usize, usize),
570    dst_affine: &[f64; 16],
571) -> Result<Vec<T>, crate::error::InputError> {
572    crate::error::check_len("labels", labels.len(), src_dims.0 * src_dims.1 * src_dims.2)?;
573    let map = GridMap::new(src_dims, src_affine, dst_dims, dst_affine).ok_or_else(|| {
574        crate::error::InputError("the source affine is singular".into())
575    })?;
576    Ok(map.sample_labels(labels))
577}
578
579/// Resample magnitude and wrapped phase from one grid onto another, through the complex domain
580/// so the wraps survive. Returns `(magnitude, phase)`.
581pub fn resample_complex_onto(
582    magnitude: &[f64],
583    phase: &[f64],
584    src_dims: (usize, usize, usize),
585    src_affine: &[f64; 16],
586    dst_dims: (usize, usize, usize),
587    dst_affine: &[f64; 16],
588) -> Option<(Vec<f64>, Vec<f64>)> {
589    let n = src_dims.0 * src_dims.1 * src_dims.2;
590    assert_eq!(magnitude.len(), n, "magnitude length does not match source dimensions");
591    assert_eq!(phase.len(), n, "phase length does not match source dimensions");
592    let map = GridMap::new(src_dims, src_affine, dst_dims, dst_affine)?;
593    let (real, imag) = to_complex(magnitude, phase);
594    Some(from_complex(&map.sample(&real), &map.sample(&imag)))
595}
596
597/// `mag·cos φ` and `mag·sin φ`, the same on every target (see the module docs).
598fn to_complex(magnitude: &[f64], phase: &[f64]) -> (Vec<f64>, Vec<f64>) {
599    use crate::utils::portable_math::{cos, sin};
600    magnitude.iter().zip(phase).map(|(&m, &p)| (m * cos(p), m * sin(p))).unzip()
601}
602
603/// Magnitude and phase of interpolated real and imaginary parts, the same on every target (see
604/// the module docs). Phase is 0 where both parts are zero: there is no signal there, and `atan2`
605/// would otherwise return 0 or ±π depending on the signs of the zeros.
606fn from_complex(real: &[f64], imag: &[f64]) -> (Vec<f64>, Vec<f64>) {
607    use crate::utils::portable_math::{atan2, hypot};
608    real.iter()
609        .zip(imag)
610        .map(|(&re, &im)| {
611            if re == 0.0 && im == 0.0 {
612                (0.0, 0.0)
613            } else {
614                (hypot(re, im), atan2(im, re))
615            }
616        })
617        .unzip()
618}
619
620/// The cardinal-aligned grid an oblique volume resamples onto, plus the mapping back to the
621/// original voxel space. Build once with [`axial_grid_for`] and reuse for every volume that
622/// shares the geometry (magnitude, phase, mask), so they land on identical grids.
623pub struct AxialGrid {
624    pub dims: (usize, usize, usize),
625    pub voxel_size: (f64, f64, f64),
626    /// Diagonal voxel→world affine of the new grid (row-major 4×4).
627    pub affine: [f64; 16],
628    /// Source dimensions this grid was built for.
629    src_dims: (usize, usize, usize),
630    world_min: [f64; 3],
631    /// Inverse of the source 3×3, for world→source-voxel.
632    inv_r: [[f64; 3]; 3],
633    t: [f64; 3],
634}
635
636/// Build the axial grid covering the same world-space extent as an oblique volume, keeping its
637/// voxel sizes. The new affine is diagonal, so [`b0_direction_from_affine`] on it returns
638/// `(0, 0, 1)`.
639pub fn axial_grid_for(
640    nx: usize,
641    ny: usize,
642    nz: usize,
643    affine: &[f64; 16],
644) -> AxialGrid {
645    let r = [
646        [affine[0], affine[1], affine[2]],
647        [affine[4], affine[5], affine[6]],
648        [affine[8], affine[9], affine[10]],
649    ];
650    let t = [affine[3], affine[7], affine[11]];
651    // Voxel sizes come back per *voxel* axis, but this grid is laid out on the world axes, so
652    // each spacing has to move to the world axis its voxel axis actually samples. They only
653    // coincide when the acquisition is already stored R-A-S; on a sagittal or coronal stack they
654    // are a permutation apart, and pairing them off by position would put the slice pitch on the
655    // wrong axis.
656    let vs = voxel_sizes_from_affine(affine);
657    let vs = [vs.0, vs.1, vs.2];
658    let ras = ras_axes(affine);
659    let (vsx, vsy, vsz) = (vs[ras[0]], vs[ras[1]], vs[ras[2]]);
660
661    // World bounding box of the source volume's eight corners.
662    let mut world_min = [f64::INFINITY; 3];
663    let mut world_max = [f64::NEG_INFINITY; 3];
664    for &(vi, vj, vk) in &[
665        (0.0, 0.0, 0.0),
666        (nx as f64 - 1.0, 0.0, 0.0),
667        (0.0, ny as f64 - 1.0, 0.0),
668        (0.0, 0.0, nz as f64 - 1.0),
669        (nx as f64 - 1.0, ny as f64 - 1.0, 0.0),
670        (nx as f64 - 1.0, 0.0, nz as f64 - 1.0),
671        (0.0, ny as f64 - 1.0, nz as f64 - 1.0),
672        (nx as f64 - 1.0, ny as f64 - 1.0, nz as f64 - 1.0),
673    ] {
674        for d in 0..3 {
675            let w = r[d][0] * vi + r[d][1] * vj + r[d][2] * vk + t[d];
676            world_min[d] = world_min[d].min(w);
677            world_max[d] = world_max[d].max(w);
678        }
679    }
680
681    let dims = (
682        ((world_max[0] - world_min[0]) / vsx).ceil() as usize + 1,
683        ((world_max[1] - world_min[1]) / vsy).ceil() as usize + 1,
684        ((world_max[2] - world_min[2]) / vsz).ceil() as usize + 1,
685    );
686
687    let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1])
688        - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
689        + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
690    let inv_det = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
691    let inv_r = [
692        [
693            (r[1][1] * r[2][2] - r[1][2] * r[2][1]) * inv_det,
694            (r[0][2] * r[2][1] - r[0][1] * r[2][2]) * inv_det,
695            (r[0][1] * r[1][2] - r[0][2] * r[1][1]) * inv_det,
696        ],
697        [
698            (r[1][2] * r[2][0] - r[1][0] * r[2][2]) * inv_det,
699            (r[0][0] * r[2][2] - r[0][2] * r[2][0]) * inv_det,
700            (r[0][2] * r[1][0] - r[0][0] * r[1][2]) * inv_det,
701        ],
702        [
703            (r[1][0] * r[2][1] - r[1][1] * r[2][0]) * inv_det,
704            (r[0][1] * r[2][0] - r[0][0] * r[2][1]) * inv_det,
705            (r[0][0] * r[1][1] - r[0][1] * r[1][0]) * inv_det,
706        ],
707    ];
708
709    AxialGrid {
710        dims,
711        voxel_size: (vsx, vsy, vsz),
712        affine: [
713            vsx, 0.0, 0.0, world_min[0],
714            0.0, vsy, 0.0, world_min[1],
715            0.0, 0.0, vsz, world_min[2],
716            0.0, 0.0, 0.0, 1.0,
717        ],
718        src_dims: (nx, ny, nz),
719        world_min,
720        inv_r,
721        t,
722    }
723}
724
725impl AxialGrid {
726    /// Source-voxel coordinate of a target voxel, or `None` if it falls outside the source.
727    fn source_coord(&self, ni: usize, nj: usize, nk: usize) -> Option<(f64, f64, f64)> {
728        let (nx, ny, nz) = self.src_dims;
729        let dx = self.world_min[0] + ni as f64 * self.voxel_size.0 - self.t[0];
730        let dy = self.world_min[1] + nj as f64 * self.voxel_size.1 - self.t[1];
731        let dz = self.world_min[2] + nk as f64 * self.voxel_size.2 - self.t[2];
732        let ox = self.inv_r[0][0] * dx + self.inv_r[0][1] * dy + self.inv_r[0][2] * dz;
733        let oy = self.inv_r[1][0] * dx + self.inv_r[1][1] * dy + self.inv_r[1][2] * dz;
734        let oz = self.inv_r[2][0] * dx + self.inv_r[2][1] * dy + self.inv_r[2][2] * dz;
735        let inside = ox >= -0.5
736            && ox <= nx as f64 - 0.5
737            && oy >= -0.5
738            && oy <= ny as f64 - 0.5
739            && oz >= -0.5
740            && oz <= nz as f64 - 0.5;
741        if inside { Some((ox, oy, oz)) } else { None }
742    }
743
744    /// Trilinearly resample one continuous volume onto this grid. Voxels outside the source are 0.
745    pub fn sample(&self, data: &[f64]) -> Vec<f64> {
746        let (nx, ny, nz) = self.src_dims;
747        let (dx, dy, dz) = self.dims;
748        let mut out = vec![0.0f64; dx * dy * dz];
749        for nk in 0..dz {
750            for nj in 0..dy {
751                for ni in 0..dx {
752                    if let Some((ox, oy, oz)) = self.source_coord(ni, nj, nk) {
753                        out[ni + nj * dx + nk * dx * dy] =
754                            trilinear_sample(data, nx, ny, nz, ox, oy, oz);
755                    }
756                }
757            }
758        }
759        out
760    }
761
762    /// Nearest-neighbour resample, for labels and binary masks.
763    pub fn sample_nearest(&self, data: &[u8]) -> Vec<u8> {
764        let (nx, ny, nz) = self.src_dims;
765        let (dx, dy, dz) = self.dims;
766        let mut out = vec![0u8; dx * dy * dz];
767        for nk in 0..dz {
768            for nj in 0..dy {
769                for ni in 0..dx {
770                    if let Some((ox, oy, oz)) = self.source_coord(ni, nj, nk) {
771                        let xi = (ox.round() as isize).clamp(0, nx as isize - 1) as usize;
772                        let yi = (oy.round() as isize).clamp(0, ny as isize - 1) as usize;
773                        let zi = (oz.round() as isize).clamp(0, nz as isize - 1) as usize;
774                        out[ni + nj * dx + nk * dx * dy] = data[xi + yi * nx + zi * nx * ny];
775                    }
776                }
777            }
778        }
779        out
780    }
781}
782
783/// Trilinear interpolation at a fractional voxel coordinate, clamped at the edges.
784pub(crate) fn trilinear_sample(data: &[f64], nx: usize, ny: usize, nz: usize, x: f64, y: f64, z: f64) -> f64 {
785    let x0 = (x.floor() as isize).clamp(0, nx as isize - 1) as usize;
786    let y0 = (y.floor() as isize).clamp(0, ny as isize - 1) as usize;
787    let z0 = (z.floor() as isize).clamp(0, nz as isize - 1) as usize;
788    let x1 = (x0 + 1).min(nx - 1);
789    let y1 = (y0 + 1).min(ny - 1);
790    let z1 = (z0 + 1).min(nz - 1);
791    let (fx, fy, fz) = (x - x0 as f64, y - y0 as f64, z - z0 as f64);
792    let idx = |a: usize, b: usize, c: usize| a + b * nx + c * nx * ny;
793
794    data[idx(x0, y0, z0)] * (1.0 - fx) * (1.0 - fy) * (1.0 - fz)
795        + data[idx(x1, y0, z0)] * fx * (1.0 - fy) * (1.0 - fz)
796        + data[idx(x0, y1, z0)] * (1.0 - fx) * fy * (1.0 - fz)
797        + data[idx(x1, y1, z0)] * fx * fy * (1.0 - fz)
798        + data[idx(x0, y0, z1)] * (1.0 - fx) * (1.0 - fy) * fz
799        + data[idx(x1, y0, z1)] * fx * (1.0 - fy) * fz
800        + data[idx(x0, y1, z1)] * (1.0 - fx) * fy * fz
801        + data[idx(x1, y1, z1)] * fx * fy * fz
802}
803
804/// A volume resampled onto a cardinal-aligned grid.
805pub struct ResampledVolume {
806    pub data: Vec<f64>,
807    pub dims: (usize, usize, usize),
808    pub voxel_size: (f64, f64, f64),
809    pub affine: [f64; 16],
810}
811
812/// Magnitude and phase resampled together onto a cardinal-aligned grid.
813pub struct ResampledComplex {
814    pub magnitude: Vec<f64>,
815    /// Wrapped phase in radians, on `(-π, π]`.
816    pub phase: Vec<f64>,
817    pub dims: (usize, usize, usize),
818    pub voxel_size: (f64, f64, f64),
819    pub affine: [f64; 16],
820}
821
822/// The zero fraction above which QSMxT 8.x filled resampled phase with noise, for a caller that
823/// opts in to that behaviour through [`AxialResampleParams::noise_fill_fraction`].
824pub const QSMXT8_NOISE_FILL_FRACTION: f64 = 0.1;
825
826/// Options for [`resample_complex_to_axial`].
827#[derive(Debug, Clone, Copy, Default)]
828pub struct AxialResampleParams {
829    /// Replace exactly-zero phase with uniform noise on `(-π, π]` when at least this fraction of
830    /// the output is zero, as QSMxT 8.x did ([`QSMXT8_NOISE_FILL_FRACTION`]). Default `None`: the
831    /// empty corners of an oblique volume stay at zero.
832    ///
833    /// The fill was meant to keep a region-growing unwrapper away from a large block of identical
834    /// zeros, but the corners have zero magnitude, so ROMEO's magnitude coherence already makes
835    /// their edges invalid. With the object inside the field of view the fill changes nothing;
836    /// where the mask reaches the edge of the resampled field of view it shifts χ by a few percent
837    /// with the noise seed, which adds variance between scans of the same subject.
838    pub noise_fill_fraction: Option<f64>,
839}
840
841/// Resample a continuous volume (magnitude, unwrapped field, χ) to a cardinal-aligned grid.
842///
843/// **Not for wrapped phase** — see the module docs and [`resample_complex_to_axial`].
844pub fn resample_to_axial(
845    data: &[f64],
846    nx: usize,
847    ny: usize,
848    nz: usize,
849    affine: &[f64; 16],
850) -> ResampledVolume {
851    let grid = axial_grid_for(nx, ny, nz, affine);
852    ResampledVolume {
853        data: grid.sample(data),
854        dims: grid.dims,
855        voxel_size: grid.voxel_size,
856        affine: grid.affine,
857    }
858}
859
860/// Resample a binary mask to a cardinal-aligned grid with nearest-neighbour interpolation.
861pub fn resample_mask_to_axial(
862    mask: &[u8],
863    nx: usize,
864    ny: usize,
865    nz: usize,
866    affine: &[f64; 16],
867) -> Vec<u8> {
868    axial_grid_for(nx, ny, nz, affine).sample_nearest(mask)
869}
870
871/// Resample magnitude and wrapped phase to a cardinal-aligned grid, interpolating in the complex
872/// domain so wraps survive.
873///
874/// `mag·cos φ` and `mag·sin φ` are interpolated separately and recombined, which is continuous
875/// across the `±π` boundary. The returned affine is diagonal, so the dipole kernel can be built
876/// with B0 = `(0, 0, 1)`.
877///
878/// # Panics
879/// If `magnitude` and `phase` differ in length or do not match `nx · ny · nz`.
880pub fn resample_complex_to_axial(
881    magnitude: &[f64],
882    phase: &[f64],
883    nx: usize,
884    ny: usize,
885    nz: usize,
886    affine: &[f64; 16],
887    params: &AxialResampleParams,
888) -> ResampledComplex {
889    let n = nx * ny * nz;
890    assert_eq!(magnitude.len(), n, "magnitude length does not match dimensions");
891    assert_eq!(phase.len(), n, "phase length does not match dimensions");
892
893    let (real, imag) = to_complex(magnitude, phase);
894    let grid = axial_grid_for(nx, ny, nz, affine);
895    let (mag_out, mut pha_out) = from_complex(&grid.sample(&real), &grid.sample(&imag));
896    let out_n = pha_out.len();
897
898    if let Some(frac) = params.noise_fill_fraction {
899        let zeros = pha_out.iter().filter(|p| **p == 0.0).count();
900        if out_n > 0 && (zeros as f64 / out_n as f64) >= frac {
901            // Seeded from the data so a rerun on the same input gives the same volume.
902            let mut rng = SplitMix64::new(seed_from_slice(&pha_out));
903            for p in pha_out.iter_mut() {
904                if *p == 0.0 {
905                    *p = rng.uniform_signed_pi();
906                }
907            }
908        }
909    }
910
911    ResampledComplex {
912        magnitude: mag_out,
913        phase: pha_out,
914        dims: grid.dims,
915        voxel_size: grid.voxel_size,
916        affine: grid.affine,
917    }
918}
919
920/// Deterministic seed from volume contents (FNV-1a over the bit patterns).
921fn seed_from_slice(data: &[f64]) -> u64 {
922    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
923    for v in data {
924        for b in v.to_bits().to_le_bytes() {
925            h ^= b as u64;
926            h = h.wrapping_mul(0x0000_0100_0000_01b3);
927        }
928    }
929    h | 1
930}
931
932/// SplitMix64 — small, dependency-free, and adequate for filling empty corners with noise.
933struct SplitMix64(u64);
934
935impl SplitMix64 {
936    fn new(seed: u64) -> Self {
937        Self(seed)
938    }
939    fn next_u64(&mut self) -> u64 {
940        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
941        let mut z = self.0;
942        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
943        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
944        z ^ (z >> 31)
945    }
946    /// Uniform on `(-π, π]`.
947    fn uniform_signed_pi(&mut self) -> f64 {
948        let u = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64; // [0, 1)
949        (u - 0.5) * 2.0 * std::f64::consts::PI
950    }
951}
952
953#[cfg(test)]
954mod tests {
955
956    use super::{alignment_is_identity, alignment_to, invert_alignment, reorient, AxisCode::*};
957
958    /// A cardinal affine whose axes carry the given codes, 1 mm isotropic.
959    fn affine_for(codes: [super::AxisCode; 3]) -> [f64; 16] {
960        let mut a = [0.0f64; 16];
961        a[15] = 1.0;
962        for (axis, c) in codes.iter().enumerate() {
963            let row = c.world_axis();
964            a[4 * row + axis] = if c.is_negative() { -1.0 } else { 1.0 };
965        }
966        a
967    }
968
969    /// A sagittal 0.8 x 0.8 x 3 mm acquisition: voxel axis 0 runs A-P, axis 1 S-I, axis 2 L-R.
970    /// The axial grid must carry each spacing on the world axis that voxel axis actually samples,
971    /// so world L-R gets the 3 mm slice pitch and S-I keeps 0.8 mm. Pairing them off by position
972    /// instead hands L-R 0.8 mm (inventing resolution across the slice direction) and S-I 3 mm
973    /// (throwing away most of what was acquired), and the wrong voxel sizes then propagate into
974    /// the dipole kernel.
975    #[test]
976    fn axial_grid_pairs_each_spacing_with_the_axis_it_samples() {
977        let affine = [
978            0.0, 0.0, 3.0, 0.0, // world x (R) <- voxel axis 2, 3 mm
979            0.8, 0.0, 0.0, 0.0, // world y (A) <- voxel axis 0, 0.8 mm
980            0.0, 0.8, 0.0, 0.0, // world z (S) <- voxel axis 1, 0.8 mm
981            0.0, 0.0, 0.0, 1.0,
982        ];
983        let g = super::axial_grid_for(64, 64, 20, &affine);
984        assert_eq!(g.voxel_size, (3.0, 0.8, 0.8), "spacings followed position, not geometry");
985        // ...and the dims follow from those spacings. L-R is the telling one: 20 slices at the
986        // 3 mm pitch, where pairing by position would have asked for 57 mm at 0.8 mm = 72.
987        // The in-plane axes allow a one-voxel overshoot, which is the `ceil` in `dims` meeting
988        // 50.4 / 0.8 landing just above 63 in binary floating point. Pre-existing and harmless
989        // (one zero-filled row); tightening it would move every oblique grid by a voxel.
990        assert_eq!(g.dims.0, 20, "L-R spans 20 slices at 3 mm");
991        assert!((64..=65).contains(&g.dims.1), "A-P was {}", g.dims.1);
992        assert!((64..=65).contains(&g.dims.2), "S-I was {}", g.dims.2);
993    }
994
995    /// Rotate a voxel→world affine about a world axis — a left-multiply, so the voxel sizes are
996    /// untouched and only the orientation tilts.
997    fn tilted(affine: &[f64; 16], world_axis: usize, degrees: f64) -> [f64; 16] {
998        let (s, c) = degrees.to_radians().sin_cos();
999        let rot = match world_axis {
1000            0 => [[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]],
1001            1 => [[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]],
1002            _ => [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]],
1003        };
1004        let mut out = *affine;
1005        for row in 0..3 {
1006            for col in 0..4 {
1007                out[4 * row + col] = (0..3).map(|k| rot[row][k] * affine[4 * k + col]).sum();
1008            }
1009        }
1010        out
1011    }
1012
1013    /// A sagittal `slice_mm` stack: voxel axis 0 runs A-P at 1 mm, axis 1 S-I at 1 mm, axis 2 L-R
1014    /// at the slice pitch. `ras_axes` is `[2, 0, 1]` for it, tilted or not.
1015    fn sagittal_affine(slice_mm: f64) -> [f64; 16] {
1016        affine_from(
1017            [[0.0, 0.0, slice_mm], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1018            [0.0, 0.0, 0.0],
1019        )
1020    }
1021
1022    /// `ras_axes` compares magnitudes down the columns of the inverse affine, and those carry a
1023    /// factor `1/voxel size` — so the comparison was between direction *times inverse spacing*,
1024    /// not direction. A thick axis therefore lost to a thin axis's side lobe once the tilt passed
1025    /// roughly the reciprocal of the anisotropy ratio, around 15° at 4:1, and a tilted sagittal
1026    /// stack came back with its slice axis identified as an in-plane one. `axial_grid_for` then
1027    /// put the slice pitch on the wrong world axis — the same wreckage #132 fixed, arriving
1028    /// through `ras_axes` instead of through the pairing.
1029    ///
1030    /// Checked against `nibabel.io_orientation` over 3000 randomised scanner-shaped affines
1031    /// (cardinal permutation, flips, up to two tilts of ≤30°, anisotropy to 10:1): 591 disagreed
1032    /// before normalising the rows and none after. Not one of the 591 was isotropic, which is why
1033    /// nothing caught it.
1034    #[test]
1035    fn ras_axes_is_not_fooled_by_anisotropy() {
1036        for slice_mm in [1.0, 2.0, 4.0, 8.0] {
1037            for world_axis in 0..3 {
1038                for degrees in [0.0, 10.0, 18.0, 30.0, -25.0] {
1039                    let a = tilted(&sagittal_affine(slice_mm), world_axis, degrees);
1040                    assert_eq!(
1041                        ras_axes(&a), [2, 0, 1],
1042                        "{slice_mm} mm slices tilted {degrees}° about world axis {world_axis}",
1043                    );
1044                }
1045            }
1046        }
1047    }
1048
1049    /// The consequence, end to end: the grid an anisotropic *double-oblique* sagittal stack
1050    /// resamples onto. A tilt about the slice normal was already right; a tilt about either
1051    /// in-plane axis is what [`tests::ras_axes_is_not_fooled_by_anisotropy`] covers, and this
1052    /// pins that the spacings and dimensions that follow from it are right too.
1053    #[test]
1054    fn axial_grid_of_a_double_oblique_sagittal_stack_keeps_its_slice_pitch() {
1055        // 18° about S-I, then 12° about A-P: both in-plane for a sagittal stack.
1056        let a = tilted(&tilted(&sagittal_affine(4.0), 2, 18.0), 1, 12.0);
1057        let g = super::axial_grid_for(64, 64, 20, &a);
1058        let (vx, vy, vz) = g.voxel_size;
1059        assert!((vx - 4.0).abs() < 1e-9, "L-R should carry the 4 mm slice pitch, got {vx}");
1060        assert!((vy - 1.0).abs() < 1e-9, "A-P should stay 1 mm, got {vy}");
1061        assert!((vz - 1.0).abs() < 1e-9, "S-I should stay 1 mm, got {vz}");
1062        // And the grid is sized for those spacings: 20 slices at 4 mm spans 76 mm, which the tilt
1063        // widens but cannot triple. Getting the spacing wrong put ~90 voxels here.
1064        assert!((20..=40).contains(&g.dims.0), "L-R dim was {}", g.dims.0);
1065    }
1066
1067    #[test]
1068    fn alignment_to_same_orientation_is_identity() {
1069        for codes in [[R, A, S], [L, P, I], [P, R, S], [A, S, L]] {
1070            let (perm, flip) = alignment_to(&affine_for(codes), codes);
1071            assert!(alignment_is_identity(perm, flip), "{codes:?} -> {perm:?} {flip:?}");
1072        }
1073    }
1074
1075    /// The χ-sepnet family wants (P, R, S); a plain RAS volume is a 90° in-plane rotation away.
1076    #[test]
1077    fn ras_to_prs_is_an_in_plane_quarter_turn() {
1078        let (perm, flip) = alignment_to(&affine_for([R, A, S]), [P, R, S]);
1079        assert_eq!(perm, [1, 0, 2]);
1080        assert_eq!(flip, [true, false, false]);
1081    }
1082
1083    #[test]
1084    fn alignment_to_handles_a_flipped_axis() {
1085        // LAS (a common radiological storage order) differs from RAS only in axis 0.
1086        let (perm, flip) = alignment_to(&affine_for([L, A, S]), [R, A, S]);
1087        assert_eq!(perm, [0, 1, 2]);
1088        assert_eq!(flip, [true, false, false]);
1089    }
1090
1091    #[test]
1092    fn reorient_moves_the_voxel_it_says_it_does() {
1093        let dims = (2, 3, 4);
1094        let data: Vec<f64> = (0..24).map(|v| v as f64).collect();
1095        let (out, out_dims) = reorient(&data, dims, [1, 0, 2], [true, false, false]);
1096        assert_eq!(out_dims, (3, 2, 4));
1097        // Output (o0,o1,o2) is input (o1, dims1-1-o0, o2).
1098        for o2 in 0..4 {
1099            for o1 in 0..2 {
1100                for o0 in 0..3 {
1101                    let got = out[o0 + 3 * (o1 + 2 * o2)];
1102                    let want = data[o1 + 2 * ((3 - 1 - o0) + 3 * o2)];
1103                    assert_eq!(got, want, "at {o0},{o1},{o2}");
1104                }
1105            }
1106        }
1107    }
1108
1109    /// Every one of the 48 cardinal relabellings must round-trip exactly.
1110    #[test]
1111    fn reorient_then_invert_restores_the_volume() {
1112        let dims = (3, 4, 5);
1113        let data: Vec<f64> = (0..60).map(|v| v as f64).collect();
1114        let axes = [[R, L], [A, P], [S, I]];
1115        for p in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
1116            for bits in 0..8u8 {
1117                let target =
1118                    [axes[p[0]][(bits & 1) as usize], axes[p[1]][((bits >> 1) & 1) as usize],
1119                     axes[p[2]][((bits >> 2) & 1) as usize]];
1120                let (perm, flip) = alignment_to(&affine_for([R, A, S]), target);
1121                let (moved, moved_dims) = reorient(&data, dims, perm, flip);
1122                let (back_perm, back_flip) = invert_alignment(perm, flip);
1123                let (back, back_dims) = reorient(&moved, moved_dims, back_perm, back_flip);
1124                assert_eq!(back_dims, dims, "{target:?}");
1125                assert_eq!(back, data, "{target:?}");
1126            }
1127        }
1128    }
1129
1130    /// Reorienting into a target and asking for the alignment of the result must be a no-op:
1131    /// the moved volume really is in the target orientation.
1132    #[test]
1133    fn reorienting_actually_reaches_the_target() {
1134        for src in [[R, A, S], [L, A, S], [P, R, S], [I, P, L]] {
1135            for target in [[R, A, S], [P, R, S], [A, L, I]] {
1136                let a = affine_for(src);
1137                let (perm, flip) = alignment_to(&a, target);
1138                // Apply the same relabelling to the affine's direction matrix and re-ask.
1139                let mut moved = [0.0f64; 16];
1140                moved[15] = 1.0;
1141                for i in 0..3 {
1142                    let sign = if flip[i] { -1.0 } else { 1.0 };
1143                    for row in 0..3 {
1144                        moved[4 * row + i] = sign * a[4 * row + perm[i]];
1145                    }
1146                }
1147                let (p2, f2) = alignment_to(&moved, target);
1148                assert!(alignment_is_identity(p2, f2), "{src:?} -> {target:?}: {p2:?} {f2:?}");
1149            }
1150        }
1151    }
1152    use super::*;
1153    use std::f64::consts::PI;
1154
1155    /// Row-major affine from a 3×3 and a translation.
1156    fn affine_from(r: [[f64; 3]; 3], t: [f64; 3]) -> [f64; 16] {
1157        [
1158            r[0][0], r[0][1], r[0][2], t[0],
1159            r[1][0], r[1][1], r[1][2], t[1],
1160            r[2][0], r[2][1], r[2][2], t[2],
1161            0.0, 0.0, 0.0, 1.0,
1162        ]
1163    }
1164
1165    fn identity_affine(vs: (f64, f64, f64)) -> [f64; 16] {
1166        affine_from([[vs.0, 0.0, 0.0], [0.0, vs.1, 0.0], [0.0, 0.0, vs.2]], [0.0, 0.0, 0.0])
1167    }
1168
1169    /// A real UK Biobank SWI affine: 0.8 × 0.8 × 3 mm, header "Tra>Cor(-22.9)>Sag(-2.1)".
1170    fn ukb_swi_affine() -> [f64; 16] {
1171        affine_from(
1172            [
1173                [0.7976, -0.0273, -0.1075],
1174                [0.0141, 0.7358, -1.1647],
1175                [0.0370, 0.3092, 2.7626],
1176            ],
1177            [-101.6, -87.4, -60.2],
1178        )
1179    }
1180
1181    #[test]
1182    fn voxel_sizes_are_column_norms() {
1183        let (sx, sy, sz) = voxel_sizes_from_affine(&ukb_swi_affine());
1184        assert!((sx - 0.799).abs() < 0.002, "{sx}");
1185        assert!((sy - 0.799).abs() < 0.002, "{sy}");
1186        assert!((sz - 3.0).abs() < 0.002, "{sz}");
1187    }
1188
1189    #[test]
1190    fn b0_direction_axial_is_z() {
1191        for vs in [(1.0, 1.0, 1.0), (0.8, 0.8, 3.0), (2.0, 0.5, 1.0)] {
1192            let (bx, by, bz) = b0_direction_from_affine(&identity_affine(vs));
1193            assert!(bx.abs() < 1e-9 && by.abs() < 1e-9, "{vs:?} -> {bx} {by}");
1194            assert!((bz - 1.0).abs() < 1e-9, "{vs:?} -> {bz}");
1195            assert!(b0_angle_from_affine(&identity_affine(vs)) < 1e-6);
1196        }
1197    }
1198
1199    /// A singular affine (a zero column, or no geometry at all) has no direction to give: B0 is
1200    /// taken along z rather than as NaN.
1201    #[test]
1202    fn b0_direction_of_a_singular_affine_is_z() {
1203        let mut flat = identity_affine((1.0, 1.0, 1.0));
1204        flat[10] = 0.0;
1205        for a in [[0.0; 16], flat] {
1206            assert_eq!(b0_direction_from_affine(&a), (0.0, 0.0, 1.0), "{a:?}");
1207        }
1208    }
1209
1210    /// The regression that matters: with anisotropic voxels, factoring the voxel scaling out of
1211    /// the affine is not optional. Inverting the scaled matrix instead (`A⁻¹·(0,0,1)`) is the
1212    /// QSMxT 8.2.2 bug, and on this acquisition it is 35° away from the right answer.
1213    #[test]
1214    fn b0_direction_anisotropic_oblique() {
1215        let a = ukb_swi_affine();
1216        let (bx, by, bz) = b0_direction_from_affine(&a);
1217        // Reference value from nibabel: pure_rotation.T @ [0, 0, 1].
1218        assert!((bx - 0.0463).abs() < 1e-3, "bx={bx}");
1219        assert!((by - 0.3871).abs() < 1e-3, "by={by}");
1220        assert!((bz - 0.9209).abs() < 1e-3, "bz={bz}");
1221
1222        // The tilt matches the acquisition's stated 22.9° obliquity.
1223        let angle = b0_angle_from_affine(&a);
1224        assert!((angle - 22.9).abs() < 0.5, "angle={angle}");
1225
1226        // And is nowhere near what inverting the scaled matrix would give (≈58° off axis).
1227        let naive = {
1228            let r = [[a[0], a[1], a[2]], [a[4], a[5], a[6]], [a[8], a[9], a[10]]];
1229            let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1])
1230                - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
1231                + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
1232            let v = [
1233                (r[0][1] * r[1][2] - r[0][2] * r[1][1]) / det,
1234                (r[0][2] * r[1][0] - r[0][0] * r[1][2]) / det,
1235                (r[0][0] * r[1][1] - r[0][1] * r[1][0]) / det,
1236            ];
1237            let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1238            [v[0] / n, v[1] / n, v[2] / n]
1239        };
1240        let dot = (bx * naive[0] + by * naive[1] + bz * naive[2]).abs().clamp(0.0, 1.0);
1241        let between = dot.acos().to_degrees();
1242        assert!(between > 30.0, "the two differ by {between}°, expected >30");
1243    }
1244
1245    /// With isotropic voxels the scaling cancels, so the bug above is invisible — which is why it
1246    /// survived: every isotropic test passes either way.
1247    #[test]
1248    fn b0_direction_isotropic_oblique_agrees_with_naive() {
1249        let (c, s) = (30.0f64.to_radians().cos(), 30.0f64.to_radians().sin());
1250        let a = affine_from([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], [0.0; 3]);
1251        let (bx, by, bz) = b0_direction_from_affine(&a);
1252        assert!(bx.abs() < 1e-9, "{bx}");
1253        assert!((by - s).abs() < 1e-9, "{by} vs {s}");
1254        assert!((bz - c).abs() < 1e-9, "{bz} vs {c}");
1255        assert!((b0_angle_from_affine(&a) - 30.0).abs() < 1e-6);
1256    }
1257
1258    #[test]
1259    fn obliquity_matches_nibabel() {
1260        // nibabel.affines.obliquity on the same affine, in degrees.
1261        let axes = obliquity_axes_from_affine(&ukb_swi_affine());
1262        for (got, want) in axes.iter().zip([2.838, 22.869, 22.947]) {
1263            assert!((got - want).abs() < 0.05, "{axes:?}");
1264        }
1265        let norm = obliquity_from_affine(&ukb_swi_affine());
1266        assert!((norm - 32.521).abs() < 0.05, "{norm}");
1267        // Cardinal acquisitions score zero whatever the voxel sizes.
1268        assert!(obliquity_from_affine(&identity_affine((0.8, 0.8, 3.0))) < 1e-9);
1269    }
1270
1271    #[test]
1272    fn axial_grid_of_an_axial_volume_is_the_same_grid() {
1273        let a = identity_affine((1.0, 1.0, 2.0));
1274        let g = axial_grid_for(4, 5, 6, &a);
1275        assert_eq!(g.dims, (4, 5, 6));
1276        let (vx, vy, vz) = g.voxel_size;
1277        assert!((vx - 1.0).abs() < 1e-9 && (vy - 1.0).abs() < 1e-9 && (vz - 2.0).abs() < 1e-9);
1278        // Resampling is then an identity.
1279        let data: Vec<f64> = (0..4 * 5 * 6).map(|i| i as f64).collect();
1280        let out = resample_to_axial(&data, 4, 5, 6, &a);
1281        for (i, (got, want)) in out.data.iter().zip(data.iter()).enumerate() {
1282            assert!((got - want).abs() < 1e-6, "voxel {i}: {got} vs {want}");
1283        }
1284    }
1285
1286    #[test]
1287    fn resampled_affine_is_cardinal_and_b0_is_z() {
1288        let g = axial_grid_for(32, 32, 12, &ukb_swi_affine());
1289        for (i, v) in g.affine.iter().enumerate() {
1290            let diagonal = matches!(i, 0 | 5 | 10 | 15);
1291            let translation = matches!(i, 3 | 7 | 11);
1292            if !diagonal && !translation {
1293                assert!(v.abs() < 1e-12, "affine[{i}] = {v} should be 0");
1294            }
1295        }
1296        assert!(b0_angle_from_affine(&g.affine) < 1e-9);
1297        assert!(obliquity_from_affine(&g.affine) < 1e-9);
1298        // The oblique volume needs a larger cardinal box to fit inside.
1299        assert!(g.dims.0 >= 32 && g.dims.1 >= 32 && g.dims.2 >= 12, "{:?}", g.dims);
1300    }
1301
1302    /// Interpolating wrapped phase directly is wrong at every wrap; going through the complex
1303    /// representation is not. Build a ramp that wraps many times and compare both.
1304    #[test]
1305    fn complex_resampling_survives_wraps_where_scalar_does_not() {
1306        let (nx, ny, nz) = (24, 24, 8);
1307        let n = nx * ny * nz;
1308        // Oblique enough to force real interpolation, isotropic so the true phase is easy to state.
1309        let (c, s) = (20.0f64.to_radians().cos(), 20.0f64.to_radians().sin());
1310        let a = affine_from([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], [0.0; 3]);
1311
1312        // Smooth field, wrapping ~6 times across x.
1313        let true_phase = |x: f64, y: f64, _z: f64| 0.8 * x + 0.3 * y;
1314        let mut mag = vec![0.0; n];
1315        let mut pha = vec![0.0; n];
1316        for k in 0..nz {
1317            for j in 0..ny {
1318                for i in 0..nx {
1319                    let idx = i + j * nx + k * nx * ny;
1320                    mag[idx] = 100.0;
1321                    pha[idx] = wrap(true_phase(i as f64, j as f64, k as f64));
1322                }
1323            }
1324        }
1325        // Count real wrap discontinuities along x (adjacent voxels jumping by more than π).
1326        let wraps = (0..nz)
1327            .flat_map(|k| (0..ny).flat_map(move |j| (1..nx).map(move |i| (i, j, k))))
1328            .filter(|&(i, j, k)| {
1329                let idx = i + j * nx + k * nx * ny;
1330                (pha[idx] - pha[idx - 1]).abs() > PI
1331            })
1332            .count();
1333        assert!(wraps > 500, "test data should contain plenty of wraps, found {wraps}");
1334
1335        let cpx = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &AxialResampleParams { noise_fill_fraction: None });
1336        let scalar = resample_to_axial(&pha, nx, ny, nz, &a);
1337        let grid = axial_grid_for(nx, ny, nz, &a);
1338
1339        // Compare both against the analytic phase at each interior sample point.
1340        let (mut cpx_err, mut scalar_err, mut count) = (0.0f64, 0.0f64, 0usize);
1341        let (dx, dy, dz) = grid.dims;
1342        for k in 0..dz {
1343            for j in 0..dy {
1344                for i in 0..dx {
1345                    let Some((ox, oy, oz)) = grid.source_coord(i, j, k) else { continue };
1346                    // Interior only: edge voxels mix in the zero background.
1347                    if ox < 1.0 || ox > nx as f64 - 2.0 || oy < 1.0 || oy > ny as f64 - 2.0 || oz < 1.0 || oz > nz as f64 - 2.0 {
1348                        continue;
1349                    }
1350                    let idx = i + j * dx + k * dx * dy;
1351                    let want = wrap(true_phase(ox, oy, oz));
1352                    cpx_err += wrap(cpx.phase[idx] - want).abs();
1353                    scalar_err += wrap(scalar.data[idx] - want).abs();
1354                    count += 1;
1355                }
1356            }
1357        }
1358        assert!(count > 500, "too few interior samples: {count}");
1359        let (cpx_err, scalar_err) = (cpx_err / count as f64, scalar_err / count as f64);
1360        assert!(cpx_err < 0.05, "complex resampling error {cpx_err} rad is too high");
1361        assert!(
1362            scalar_err > 10.0 * cpx_err,
1363            "scalar {scalar_err} should be far worse than complex {cpx_err}"
1364        );
1365        // Magnitude comes through nearly intact. It dips slightly rather than exactly matching,
1366        // because averaging two phasors that differ by Δφ scales the result by cos(Δφ/2) — here
1367        // the ramp is 0.8 rad/voxel, so a midpoint sample can lose up to 1 − cos(0.4) ≈ 8%. That
1368        // is inherent to complex interpolation, not a defect, and it is the price of keeping the
1369        // wraps; an unwrapped field would not pay it.
1370        for (idx, m) in cpx.magnitude.iter().enumerate() {
1371            if grid_interior(&grid, idx) {
1372                assert!(*m > 90.0 && *m < 100.5, "magnitude {m} at {idx} outside the expected dip");
1373            }
1374        }
1375    }
1376
1377    fn grid_interior(grid: &AxialGrid, idx: usize) -> bool {
1378        let (dx, dy, _) = grid.dims;
1379        let i = idx % dx;
1380        let j = (idx / dx) % dy;
1381        let k = idx / (dx * dy);
1382        grid.source_coord(i, j, k)
1383            .map(|(ox, oy, oz)| {
1384                let (nx, ny, nz) = grid.src_dims;
1385                ox > 1.0 && ox < nx as f64 - 2.0 && oy > 1.0 && oy < ny as f64 - 2.0 && oz > 1.0 && oz < nz as f64 - 2.0
1386            })
1387            .unwrap_or(false)
1388    }
1389
1390    fn wrap(p: f64) -> f64 {
1391        let mut v = (p + PI) % (2.0 * PI);
1392        if v < 0.0 {
1393            v += 2.0 * PI;
1394        }
1395        v - PI
1396    }
1397
1398    // --- GridMap: the return trip ---
1399
1400    #[test]
1401    fn round_trip_to_axial_and_back_recovers_the_original() {
1402        let (nx, ny, nz) = (20, 22, 10);
1403        let n = nx * ny * nz;
1404        let a = ukb_swi_affine();
1405        // A smooth field, so interpolation error is the only thing being measured.
1406        let data: Vec<f64> = (0..n)
1407            .map(|i| {
1408                let (x, y, z) = (i % nx, (i / nx) % ny, i / (nx * ny));
1409                (x as f64 * 0.1).sin() + (y as f64 * 0.07).cos() + z as f64 * 0.02
1410            })
1411            .collect();
1412
1413        let grid = axial_grid_for(nx, ny, nz, &a);
1414        let there = resample_to_axial(&data, nx, ny, nz, &a);
1415        let back = resample_onto(&there.data, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
1416
1417        // Interior voxels come back close; edges lose data to the empty corners, as expected of
1418        // two interpolations.
1419        let (mut err, mut count) = (0.0f64, 0usize);
1420        for z in 2..nz - 2 {
1421            for y in 2..ny - 2 {
1422                for x in 2..nx - 2 {
1423                    let i = x + y * nx + z * nx * ny;
1424                    err += (back[i] - data[i]).abs();
1425                    count += 1;
1426                }
1427            }
1428        }
1429        let mean = err / count as f64;
1430        assert!(mean < 0.05, "round trip lost {mean} per voxel on a smooth field");
1431    }
1432
1433    #[test]
1434    fn round_trip_puts_the_data_back_on_the_original_grid() {
1435        let (nx, ny, nz) = (16, 16, 8);
1436        let a = ukb_swi_affine();
1437        let grid = axial_grid_for(nx, ny, nz, &a);
1438        assert_ne!(grid.dims, (nx, ny, nz), "the axial grid should differ, else this proves nothing");
1439        let there = vec![1.0f64; grid.dims.0 * grid.dims.1 * grid.dims.2];
1440        let back = resample_onto(&there, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
1441        assert_eq!(back.len(), nx * ny * nz, "output must be on the acquired grid");
1442        // The acquired volume sits inside the axial box, so everything is covered.
1443        assert!(back.iter().filter(|v| **v > 0.5).count() > (nx * ny * nz) * 9 / 10);
1444    }
1445
1446    #[test]
1447    fn identity_map_is_a_no_op() {
1448        let (nx, ny, nz) = (6, 5, 4);
1449        let a = identity_affine((1.0, 2.0, 3.0));
1450        let data: Vec<f64> = (0..nx * ny * nz).map(|i| i as f64).collect();
1451        let out = resample_onto(&data, (nx, ny, nz), &a, (nx, ny, nz), &a).unwrap();
1452        for (got, want) in out.iter().zip(data.iter()) {
1453            assert!((got - want).abs() < 1e-9, "{got} vs {want}");
1454        }
1455    }
1456
1457    #[test]
1458    fn singular_affine_is_reported_not_panicked() {
1459        let zero = [0.0f64; 16];
1460        let a = identity_affine((1.0, 1.0, 1.0));
1461        assert!(resample_onto(&[0.0; 8], (2, 2, 2), &zero, (2, 2, 2), &a).is_none());
1462        assert!(GridMap::new((2, 2, 2), &zero, (2, 2, 2), &a).is_none());
1463    }
1464
1465    #[test]
1466    fn mask_round_trip_stays_binary() {
1467        let (nx, ny, nz) = (16, 16, 8);
1468        let a = ukb_swi_affine();
1469        let mut mask = vec![0u8; nx * ny * nz];
1470        for z in 2..nz - 2 {
1471            for y in 4..ny - 4 {
1472                for x in 4..nx - 4 {
1473                    mask[x + y * nx + z * nx * ny] = 1;
1474                }
1475            }
1476        }
1477        let grid = axial_grid_for(nx, ny, nz, &a);
1478        let there = resample_mask_to_axial(&mask, nx, ny, nz, &a);
1479        let back = resample_mask_onto(&there, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
1480        assert!(back.iter().all(|v| *v == 0 || *v == 1));
1481        assert!(back.iter().filter(|v| **v == 1).count() > 100, "mask should survive the trip");
1482    }
1483
1484    /// Phase has to come back through the complex domain too, for the same reason it went out
1485    /// that way.
1486    #[test]
1487    fn complex_round_trip_preserves_wraps() {
1488        let (nx, ny, nz) = (20, 20, 8);
1489        let n = nx * ny * nz;
1490        let a = ukb_swi_affine();
1491        let mag = vec![100.0f64; n];
1492        let pha: Vec<f64> = (0..n)
1493            .map(|i| {
1494                let (x, y) = (i % nx, (i / nx) % ny);
1495                wrap(0.6 * x as f64 + 0.2 * y as f64)
1496            })
1497            .collect();
1498
1499        let grid = axial_grid_for(nx, ny, nz, &a);
1500        let out = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &AxialResampleParams { noise_fill_fraction: None });
1501        let (_, back) = resample_complex_onto(&out.magnitude, &out.phase, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
1502
1503        let (mut err, mut count) = (0.0f64, 0usize);
1504        for z in 2..nz - 2 {
1505            for y in 3..ny - 3 {
1506                for x in 3..nx - 3 {
1507                    let i = x + y * nx + z * nx * ny;
1508                    err += wrap(back[i] - pha[i]).abs();
1509                    count += 1;
1510                }
1511            }
1512        }
1513        let mean = err / count as f64;
1514        assert!(mean < 0.25, "complex round trip drifted {mean} rad per voxel");
1515    }
1516
1517    #[test]
1518    fn noise_fill_is_deterministic_and_bounded() {
1519        let (nx, ny, nz) = (16, 16, 6);
1520        let n = nx * ny * nz;
1521        let (c, s) = (25.0f64.to_radians().cos(), 25.0f64.to_radians().sin());
1522        let a = affine_from([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], [0.0; 3]);
1523        let mag = vec![50.0; n];
1524        let pha: Vec<f64> = (0..n).map(|i| wrap(0.4 * (i % nx) as f64)).collect();
1525
1526        let p = AxialResampleParams { noise_fill_fraction: Some(QSMXT8_NOISE_FILL_FRACTION) };
1527        let a1 = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &p);
1528        let a2 = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &p);
1529        assert_eq!(a1.phase, a2.phase, "same input must give the same volume");
1530        assert!(a1.phase.iter().all(|v| v.is_finite() && v.abs() <= PI + 1e-9));
1531
1532        // By default there is no fill, so the empty corners stay at exactly zero.
1533        let off = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &AxialResampleParams::default());
1534        assert!(off.phase.contains(&0.0));
1535        assert!(off.phase.iter().filter(|v| **v == 0.0).count() > a1.phase.iter().filter(|v| **v == 0.0).count());
1536    }
1537
1538    /// On an axial grid, resampling a mask to axial changes nothing.
1539    #[test]
1540    fn mask_resampling_on_an_axial_grid_is_a_no_op() {
1541        let mask = vec![0u8, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 0];
1542        for vs in [(1.0, 1.0, 1.0), (0.8, 0.8, 2.0)] {
1543            assert_eq!(resample_mask_to_axial(&mask, 3, 2, 2, &identity_affine(vs)), mask, "{vs:?}");
1544        }
1545    }
1546
1547    #[test]
1548    fn mask_resampling_stays_binary() {
1549        let (nx, ny, nz) = (12, 12, 6);
1550        let (c, s) = (15.0f64.to_radians().cos(), 15.0f64.to_radians().sin());
1551        let a = affine_from([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], [0.0; 3]);
1552        let mut mask = vec![0u8; nx * ny * nz];
1553        for k in 1..nz - 1 {
1554            for j in 3..ny - 3 {
1555                for i in 3..nx - 3 {
1556                    mask[i + j * nx + k * nx * ny] = 1;
1557                }
1558            }
1559        }
1560        let out = resample_mask_to_axial(&mask, nx, ny, nz, &a);
1561        assert!(out.iter().all(|v| *v == 0 || *v == 1), "mask must stay binary");
1562        assert!(out.iter().filter(|v| **v == 1).count() > 100, "mask should survive");
1563    }
1564
1565    /// QSMxT #267: a label image sent to an oblique grid and back holds only labels it started
1566    /// with, unlike trilinear interpolation, which invents values at every boundary.
1567    #[test]
1568    fn label_resampling_only_produces_input_labels() {
1569        let (nx, ny, nz) = (16, 16, 8);
1570        let a = ukb_swi_affine();
1571        let ids = [0.0f64, 2.0, 3.0, 41.0, 42.0, 2035.0];
1572        let labels: Vec<f64> = (0..nx * ny * nz)
1573            .map(|i| {
1574                let (x, y, z) = (i % nx, (i / nx) % ny, i / (nx * ny));
1575                if !(2..nx - 2).contains(&x) || !(2..ny - 2).contains(&y) || !(1..nz - 1).contains(&z) {
1576                    0.0
1577                } else {
1578                    ids[1 + (x / 3 + y / 5 + z) % (ids.len() - 1)]
1579                }
1580            })
1581            .collect();
1582        let grid = axial_grid_for(nx, ny, nz, &a);
1583        let there = resample_labels_onto(&labels, (nx, ny, nz), &a, grid.dims, &grid.affine).unwrap();
1584        let back = resample_labels_onto(&there, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
1585        for v in there.iter().chain(&back) {
1586            assert!(ids.contains(v), "{v} is not one of the input labels");
1587        }
1588        // every structure survives the round trip
1589        for id in &ids[1..] {
1590            assert!(back.contains(id), "label {id} was lost");
1591        }
1592        // most voxels come back with their own label
1593        let same = back.iter().zip(&labels).filter(|(a, b)| a == b).count();
1594        assert!(same > nx * ny * nz * 3 / 4, "only {same} of {} voxels kept their label", nx * ny * nz);
1595
1596        // trilinear would not have: it produces values between the ids
1597        let smeared = resample_onto(&labels, (nx, ny, nz), &a, grid.dims, &grid.affine).unwrap();
1598        assert!(smeared.iter().any(|v| !ids.contains(v)), "test volume too easy");
1599
1600        // integer label types work the same way
1601        let labels_u16: Vec<u16> = labels.iter().map(|&v| v as u16).collect();
1602        let there_u16 = resample_labels_onto(&labels_u16, (nx, ny, nz), &a, grid.dims, &grid.affine).unwrap();
1603        assert!(there_u16.iter().zip(&there).all(|(&a, &b)| a as f64 == b));
1604    }
1605
1606    #[test]
1607    fn label_resampling_on_the_identity_is_a_no_op() {
1608        let (nx, ny, nz) = (6, 5, 4);
1609        let a = identity_affine((1.0, 2.0, 3.0));
1610        let labels: Vec<u32> = (0..nx * ny * nz).map(|i| (i % 7) as u32 * 100).collect();
1611        assert_eq!(resample_labels_onto(&labels, (nx, ny, nz), &a, (nx, ny, nz), &a).unwrap(), labels);
1612    }
1613
1614    /// Between eight voxels, six labelled 1 and two labelled 2, the vote gives 1 even where the
1615    /// nearest voxel (by rounding) is a 2.
1616    #[test]
1617    fn label_resampling_takes_the_majority_not_the_nearest() {
1618        let src = identity_affine((1.0, 1.0, 1.0));
1619        // corner (1,1,1) is where rounding (0.5,0.5,0.5) lands
1620        let mut labels = vec![1u8; 8];
1621        labels[7] = 2; // (1,1,1)
1622        labels[6] = 2; // (0,1,1)
1623        let dst = affine_from([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], [0.5, 0.5, 0.5]);
1624        let nearest = resample_mask_onto(&labels, (2, 2, 2), &src, (1, 1, 1), &dst).unwrap();
1625        assert_eq!(nearest, vec![2]);
1626        assert_eq!(resample_labels_onto(&labels, (2, 2, 2), &src, (1, 1, 1), &dst).unwrap(), vec![1]);
1627        // a 4-4 tie goes to the nearest voxel's label
1628        let mut tie = vec![1u8; 8];
1629        for i in [4, 5, 6, 7] {
1630            tie[i] = 2;
1631        }
1632        assert_eq!(resample_labels_onto(&tie, (2, 2, 2), &src, (1, 1, 1), &dst).unwrap(), vec![2]);
1633    }
1634
1635    #[test]
1636    fn label_resampling_reports_bad_input() {
1637        let a = identity_affine((1.0, 1.0, 1.0));
1638        assert!(resample_labels_onto(&[0u8; 7], (2, 2, 2), &a, (2, 2, 2), &a).is_err());
1639        assert!(resample_labels_onto(&[0u8; 8], (2, 2, 2), &[0.0; 16], (2, 2, 2), &a).is_err());
1640    }
1641}