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/// Voxel sizes (mm) from a row-major 4×4 affine: the norms of its three columns.
23pub fn voxel_sizes_from_affine(affine: &[f64; 16]) -> (f64, f64, f64) {
24    let col = |j: usize| {
25        (affine[j] * affine[j] + affine[4 + j] * affine[4 + j] + affine[8 + j] * affine[8 + j])
26            .sqrt()
27    };
28    (col(0), col(1), col(2))
29}
30
31/// Direction of the scanner's B0 field (world `+z`) expressed in voxel coordinates, normalised.
32///
33/// The voxel→world matrix is `A = R·S`, with `R` a rotation and `S = diag(voxel sizes)`. Dividing
34/// each column of `A` by its norm recovers `R`, whose inverse is its transpose, so the direction
35/// is simply the third row of the normalised matrix. An axial acquisition returns `(0, 0, 1)`.
36///
37/// Factoring the voxel sizes out first is what makes this correct for anisotropic voxels. Using
38/// `A⁻¹·(0,0,1)` instead — inverting the matrix *with* the voxel scaling still in it — silently
39/// skews the direction: on a 0.8 × 0.8 × 3 mm acquisition tilted 23° it returns a direction 58°
40/// from `z`, and the resulting dipole kernel destroys most of the susceptibility contrast. That
41/// was a real regression in QSMxT 8.2.2; [`tests::b0_direction_anisotropic_oblique`] pins it.
42pub fn b0_direction_from_affine(affine: &[f64; 16]) -> (f64, f64, f64) {
43    let (sx, sy, sz) = voxel_sizes_from_affine(affine);
44    if sx < 1e-10 || sy < 1e-10 || sz < 1e-10 {
45        return (0.0, 0.0, 1.0);
46    }
47    // Third row of R = A · S⁻¹, i.e. world-z expressed in voxel axes.
48    let (bx, by, bz) = (affine[8] / sx, affine[9] / sy, affine[10] / sz);
49    let norm = (bx * bx + by * by + bz * bz).sqrt();
50    if norm < 1e-10 {
51        return (0.0, 0.0, 1.0);
52    }
53    (bx / norm, by / norm, bz / norm)
54}
55
56/// Angle (degrees) between B0 and the voxel `+z` axis — the tilt that matters for the dipole
57/// kernel. Zero for an axial acquisition; equals the scanner's slice tilt for a simple oblique.
58pub fn b0_angle_from_affine(affine: &[f64; 16]) -> f64 {
59    let (_, _, bz) = b0_direction_from_affine(affine);
60    bz.clamp(-1.0, 1.0).abs().acos().to_degrees()
61}
62
63/// Per-axis obliquity (degrees), matching `nibabel.affines.obliquity`: for each voxel axis, the
64/// angle between it and the closest world axis. `[0, 0, 0]` for a cardinal-aligned acquisition.
65pub fn obliquity_axes_from_affine(affine: &[f64; 16]) -> [f64; 3] {
66    let sizes = voxel_sizes_from_affine(affine);
67    let sizes = [sizes.0, sizes.1, sizes.2];
68    let mut out = [0.0f64; 3];
69    for (i, o) in out.iter_mut().enumerate() {
70        // Row i of A · S⁻¹; its largest component is the cosine to the nearest world axis.
71        let best = (0..3)
72            .map(|j| {
73                if sizes[j] < 1e-10 {
74                    0.0
75                } else {
76                    (affine[4 * i + j] / sizes[j]).abs()
77                }
78            })
79            .fold(0.0f64, f64::max);
80        *o = best.clamp(0.0, 1.0).acos().to_degrees();
81    }
82    out
83}
84
85/// Scalar obliquity (degrees): the Euclidean norm of [`obliquity_axes_from_affine`].
86///
87/// This is the quantity QSMxT 8.x thresholded on (`nibabel` obliquity → degrees → `np.linalg.norm`),
88/// so thresholds carry over unchanged. Note it is a combined measure, not a tilt: a single 23°
89/// oblique acquisition scores ≈32° here because two voxel axes each move. For the physical tilt of
90/// B0 relative to the slice stack, use [`b0_angle_from_affine`].
91pub fn obliquity_from_affine(affine: &[f64; 16]) -> f64 {
92    let a = obliquity_axes_from_affine(affine);
93    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
94}
95
96/// Mapping from one voxel grid to another, each described by its own affine.
97///
98/// Used for the return trip: a run reconstructed on a resampled grid still has to write its
99/// outputs where the caller's other data lives. That matters more than it sounds — a FLIRT
100/// matrix, for instance, is defined in a coordinate space derived from the image's dimensions
101/// and voxel sizes, so handing it a volume on a different grid produces a wrong registration
102/// rather than an error.
103pub struct GridMap {
104    pub dst_dims: (usize, usize, usize),
105    src_dims: (usize, usize, usize),
106    /// Voxel→world of the destination.
107    dst_affine: [f64; 16],
108    /// World→voxel of the source.
109    inv_src: [[f64; 4]; 3],
110}
111
112/// Invert the 3×3 of an affine and fold in its translation, giving world→voxel as a 3×4.
113fn world_to_voxel(affine: &[f64; 16]) -> Option<[[f64; 4]; 3]> {
114    let r = [
115        [affine[0], affine[1], affine[2]],
116        [affine[4], affine[5], affine[6]],
117        [affine[8], affine[9], affine[10]],
118    ];
119    let t = [affine[3], affine[7], affine[11]];
120    let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1])
121        - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
122        + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
123    if det.abs() < 1e-12 {
124        return None;
125    }
126    let id = 1.0 / det;
127    let inv = [
128        [
129            (r[1][1] * r[2][2] - r[1][2] * r[2][1]) * id,
130            (r[0][2] * r[2][1] - r[0][1] * r[2][2]) * id,
131            (r[0][1] * r[1][2] - r[0][2] * r[1][1]) * id,
132        ],
133        [
134            (r[1][2] * r[2][0] - r[1][0] * r[2][2]) * id,
135            (r[0][0] * r[2][2] - r[0][2] * r[2][0]) * id,
136            (r[0][2] * r[1][0] - r[0][0] * r[1][2]) * id,
137        ],
138        [
139            (r[1][0] * r[2][1] - r[1][1] * r[2][0]) * id,
140            (r[0][1] * r[2][0] - r[0][0] * r[2][1]) * id,
141            (r[0][0] * r[1][1] - r[0][1] * r[1][0]) * id,
142        ],
143    ];
144    let mut out = [[0.0f64; 4]; 3];
145    for (i, row) in out.iter_mut().enumerate() {
146        row[..3].copy_from_slice(&inv[i]);
147        row[3] = -(inv[i][0] * t[0] + inv[i][1] * t[1] + inv[i][2] * t[2]);
148    }
149    Some(out)
150}
151
152impl GridMap {
153    /// Map from `src` onto `dst`. Both affines are row-major voxel→world.
154    pub fn new(
155        src_dims: (usize, usize, usize),
156        src_affine: &[f64; 16],
157        dst_dims: (usize, usize, usize),
158        dst_affine: &[f64; 16],
159    ) -> Option<Self> {
160        Some(Self {
161            dst_dims,
162            src_dims,
163            dst_affine: *dst_affine,
164            inv_src: world_to_voxel(src_affine)?,
165        })
166    }
167
168    /// Source-voxel coordinate for a destination voxel, or `None` if it falls outside the source.
169    fn source_coord(&self, i: usize, j: usize, k: usize) -> Option<(f64, f64, f64)> {
170        let (i, j, k) = (i as f64, j as f64, k as f64);
171        let a = &self.dst_affine;
172        let w = [
173            a[0] * i + a[1] * j + a[2] * k + a[3],
174            a[4] * i + a[5] * j + a[6] * k + a[7],
175            a[8] * i + a[9] * j + a[10] * k + a[11],
176        ];
177        let m = &self.inv_src;
178        let o = (
179            m[0][0] * w[0] + m[0][1] * w[1] + m[0][2] * w[2] + m[0][3],
180            m[1][0] * w[0] + m[1][1] * w[1] + m[1][2] * w[2] + m[1][3],
181            m[2][0] * w[0] + m[2][1] * w[1] + m[2][2] * w[2] + m[2][3],
182        );
183        let (nx, ny, nz) = self.src_dims;
184        let inside = o.0 >= -0.5 && o.0 <= nx as f64 - 0.5
185            && o.1 >= -0.5 && o.1 <= ny as f64 - 0.5
186            && o.2 >= -0.5 && o.2 <= nz as f64 - 0.5;
187        if inside { Some(o) } else { None }
188    }
189
190    /// Trilinearly resample continuous data onto the destination grid; 0 outside the source.
191    pub fn sample(&self, data: &[f64]) -> Vec<f64> {
192        let (nx, ny, nz) = self.src_dims;
193        let (dx, dy, dz) = self.dst_dims;
194        let mut out = vec![0.0f64; dx * dy * dz];
195        for k in 0..dz {
196            for j in 0..dy {
197                for i in 0..dx {
198                    if let Some((ox, oy, oz)) = self.source_coord(i, j, k) {
199                        out[i + j * dx + k * dx * dy] = trilinear_sample(data, nx, ny, nz, ox, oy, oz);
200                    }
201                }
202            }
203        }
204        out
205    }
206
207    /// Nearest-neighbour, for labels and binary masks.
208    pub fn sample_nearest(&self, data: &[u8]) -> Vec<u8> {
209        let (nx, ny, nz) = self.src_dims;
210        let (dx, dy, dz) = self.dst_dims;
211        let mut out = vec![0u8; dx * dy * dz];
212        for k in 0..dz {
213            for j in 0..dy {
214                for i in 0..dx {
215                    if let Some((ox, oy, oz)) = self.source_coord(i, j, k) {
216                        let xi = (ox.round() as isize).clamp(0, nx as isize - 1) as usize;
217                        let yi = (oy.round() as isize).clamp(0, ny as isize - 1) as usize;
218                        let zi = (oz.round() as isize).clamp(0, nz as isize - 1) as usize;
219                        out[i + j * dx + k * dx * dy] = data[xi + yi * nx + zi * nx * ny];
220                    }
221                }
222            }
223        }
224        out
225    }
226}
227
228/// Resample continuous data (magnitude, an unwrapped field, χ) from one grid onto another.
229/// Returns `None` if the source affine is singular.
230pub fn resample_onto(
231    data: &[f64],
232    src_dims: (usize, usize, usize),
233    src_affine: &[f64; 16],
234    dst_dims: (usize, usize, usize),
235    dst_affine: &[f64; 16],
236) -> Option<Vec<f64>> {
237    Some(GridMap::new(src_dims, src_affine, dst_dims, dst_affine)?.sample(data))
238}
239
240/// Resample a binary mask from one grid onto another (nearest neighbour).
241pub fn resample_mask_onto(
242    mask: &[u8],
243    src_dims: (usize, usize, usize),
244    src_affine: &[f64; 16],
245    dst_dims: (usize, usize, usize),
246    dst_affine: &[f64; 16],
247) -> Option<Vec<u8>> {
248    Some(GridMap::new(src_dims, src_affine, dst_dims, dst_affine)?.sample_nearest(mask))
249}
250
251/// Resample magnitude and wrapped phase from one grid onto another, through the complex domain
252/// so the wraps survive. Returns `(magnitude, phase)`.
253pub fn resample_complex_onto(
254    magnitude: &[f64],
255    phase: &[f64],
256    src_dims: (usize, usize, usize),
257    src_affine: &[f64; 16],
258    dst_dims: (usize, usize, usize),
259    dst_affine: &[f64; 16],
260) -> Option<(Vec<f64>, Vec<f64>)> {
261    let n = src_dims.0 * src_dims.1 * src_dims.2;
262    assert_eq!(magnitude.len(), n, "magnitude length does not match source dimensions");
263    assert_eq!(phase.len(), n, "phase length does not match source dimensions");
264    let map = GridMap::new(src_dims, src_affine, dst_dims, dst_affine)?;
265    let real: Vec<f64> = (0..n).map(|i| magnitude[i] * phase[i].cos()).collect();
266    let imag: Vec<f64> = (0..n).map(|i| magnitude[i] * phase[i].sin()).collect();
267    let (re, im) = (map.sample(&real), map.sample(&imag));
268    Some((
269        (0..re.len()).map(|i| re[i].hypot(im[i])).collect(),
270        (0..re.len()).map(|i| im[i].atan2(re[i])).collect(),
271    ))
272}
273
274/// The cardinal-aligned grid an oblique volume resamples onto, plus the mapping back to the
275/// original voxel space. Build once with [`axial_grid_for`] and reuse for every volume that
276/// shares the geometry (magnitude, phase, mask), so they land on identical grids.
277pub struct AxialGrid {
278    pub dims: (usize, usize, usize),
279    pub voxel_size: (f64, f64, f64),
280    /// Diagonal voxel→world affine of the new grid (row-major 4×4).
281    pub affine: [f64; 16],
282    /// Source dimensions this grid was built for.
283    src_dims: (usize, usize, usize),
284    world_min: [f64; 3],
285    /// Inverse of the source 3×3, for world→source-voxel.
286    inv_r: [[f64; 3]; 3],
287    t: [f64; 3],
288}
289
290/// Build the axial grid covering the same world-space extent as an oblique volume, keeping its
291/// voxel sizes. The new affine is diagonal, so [`b0_direction_from_affine`] on it returns
292/// `(0, 0, 1)`.
293pub fn axial_grid_for(
294    nx: usize,
295    ny: usize,
296    nz: usize,
297    affine: &[f64; 16],
298) -> AxialGrid {
299    let r = [
300        [affine[0], affine[1], affine[2]],
301        [affine[4], affine[5], affine[6]],
302        [affine[8], affine[9], affine[10]],
303    ];
304    let t = [affine[3], affine[7], affine[11]];
305    let (vsx, vsy, vsz) = voxel_sizes_from_affine(affine);
306
307    // World bounding box of the source volume's eight corners.
308    let mut world_min = [f64::INFINITY; 3];
309    let mut world_max = [f64::NEG_INFINITY; 3];
310    for &(vi, vj, vk) in &[
311        (0.0, 0.0, 0.0),
312        (nx as f64 - 1.0, 0.0, 0.0),
313        (0.0, ny as f64 - 1.0, 0.0),
314        (0.0, 0.0, nz as f64 - 1.0),
315        (nx as f64 - 1.0, ny as f64 - 1.0, 0.0),
316        (nx as f64 - 1.0, 0.0, nz as f64 - 1.0),
317        (0.0, ny as f64 - 1.0, nz as f64 - 1.0),
318        (nx as f64 - 1.0, ny as f64 - 1.0, nz as f64 - 1.0),
319    ] {
320        for d in 0..3 {
321            let w = r[d][0] * vi + r[d][1] * vj + r[d][2] * vk + t[d];
322            world_min[d] = world_min[d].min(w);
323            world_max[d] = world_max[d].max(w);
324        }
325    }
326
327    let dims = (
328        ((world_max[0] - world_min[0]) / vsx).ceil() as usize + 1,
329        ((world_max[1] - world_min[1]) / vsy).ceil() as usize + 1,
330        ((world_max[2] - world_min[2]) / vsz).ceil() as usize + 1,
331    );
332
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    let inv_det = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
337    let inv_r = [
338        [
339            (r[1][1] * r[2][2] - r[1][2] * r[2][1]) * inv_det,
340            (r[0][2] * r[2][1] - r[0][1] * r[2][2]) * inv_det,
341            (r[0][1] * r[1][2] - r[0][2] * r[1][1]) * inv_det,
342        ],
343        [
344            (r[1][2] * r[2][0] - r[1][0] * r[2][2]) * inv_det,
345            (r[0][0] * r[2][2] - r[0][2] * r[2][0]) * inv_det,
346            (r[0][2] * r[1][0] - r[0][0] * r[1][2]) * inv_det,
347        ],
348        [
349            (r[1][0] * r[2][1] - r[1][1] * r[2][0]) * inv_det,
350            (r[0][1] * r[2][0] - r[0][0] * r[2][1]) * inv_det,
351            (r[0][0] * r[1][1] - r[0][1] * r[1][0]) * inv_det,
352        ],
353    ];
354
355    AxialGrid {
356        dims,
357        voxel_size: (vsx, vsy, vsz),
358        affine: [
359            vsx, 0.0, 0.0, world_min[0],
360            0.0, vsy, 0.0, world_min[1],
361            0.0, 0.0, vsz, world_min[2],
362            0.0, 0.0, 0.0, 1.0,
363        ],
364        src_dims: (nx, ny, nz),
365        world_min,
366        inv_r,
367        t,
368    }
369}
370
371impl AxialGrid {
372    /// Source-voxel coordinate of a target voxel, or `None` if it falls outside the source.
373    fn source_coord(&self, ni: usize, nj: usize, nk: usize) -> Option<(f64, f64, f64)> {
374        let (nx, ny, nz) = self.src_dims;
375        let dx = self.world_min[0] + ni as f64 * self.voxel_size.0 - self.t[0];
376        let dy = self.world_min[1] + nj as f64 * self.voxel_size.1 - self.t[1];
377        let dz = self.world_min[2] + nk as f64 * self.voxel_size.2 - self.t[2];
378        let ox = self.inv_r[0][0] * dx + self.inv_r[0][1] * dy + self.inv_r[0][2] * dz;
379        let oy = self.inv_r[1][0] * dx + self.inv_r[1][1] * dy + self.inv_r[1][2] * dz;
380        let oz = self.inv_r[2][0] * dx + self.inv_r[2][1] * dy + self.inv_r[2][2] * dz;
381        let inside = ox >= -0.5
382            && ox <= nx as f64 - 0.5
383            && oy >= -0.5
384            && oy <= ny as f64 - 0.5
385            && oz >= -0.5
386            && oz <= nz as f64 - 0.5;
387        if inside { Some((ox, oy, oz)) } else { None }
388    }
389
390    /// Trilinearly resample one continuous volume onto this grid. Voxels outside the source are 0.
391    pub fn sample(&self, data: &[f64]) -> Vec<f64> {
392        let (nx, ny, nz) = self.src_dims;
393        let (dx, dy, dz) = self.dims;
394        let mut out = vec![0.0f64; dx * dy * dz];
395        for nk in 0..dz {
396            for nj in 0..dy {
397                for ni in 0..dx {
398                    if let Some((ox, oy, oz)) = self.source_coord(ni, nj, nk) {
399                        out[ni + nj * dx + nk * dx * dy] =
400                            trilinear_sample(data, nx, ny, nz, ox, oy, oz);
401                    }
402                }
403            }
404        }
405        out
406    }
407
408    /// Nearest-neighbour resample, for labels and binary masks.
409    pub fn sample_nearest(&self, data: &[u8]) -> Vec<u8> {
410        let (nx, ny, nz) = self.src_dims;
411        let (dx, dy, dz) = self.dims;
412        let mut out = vec![0u8; dx * dy * dz];
413        for nk in 0..dz {
414            for nj in 0..dy {
415                for ni in 0..dx {
416                    if let Some((ox, oy, oz)) = self.source_coord(ni, nj, nk) {
417                        let xi = (ox.round() as isize).clamp(0, nx as isize - 1) as usize;
418                        let yi = (oy.round() as isize).clamp(0, ny as isize - 1) as usize;
419                        let zi = (oz.round() as isize).clamp(0, nz as isize - 1) as usize;
420                        out[ni + nj * dx + nk * dx * dy] = data[xi + yi * nx + zi * nx * ny];
421                    }
422                }
423            }
424        }
425        out
426    }
427}
428
429/// Trilinear interpolation at a fractional voxel coordinate, clamped at the edges.
430fn trilinear_sample(data: &[f64], nx: usize, ny: usize, nz: usize, x: f64, y: f64, z: f64) -> f64 {
431    let x0 = (x.floor() as isize).clamp(0, nx as isize - 1) as usize;
432    let y0 = (y.floor() as isize).clamp(0, ny as isize - 1) as usize;
433    let z0 = (z.floor() as isize).clamp(0, nz as isize - 1) as usize;
434    let x1 = (x0 + 1).min(nx - 1);
435    let y1 = (y0 + 1).min(ny - 1);
436    let z1 = (z0 + 1).min(nz - 1);
437    let (fx, fy, fz) = (x - x0 as f64, y - y0 as f64, z - z0 as f64);
438    let idx = |a: usize, b: usize, c: usize| a + b * nx + c * nx * ny;
439
440    data[idx(x0, y0, z0)] * (1.0 - fx) * (1.0 - fy) * (1.0 - fz)
441        + data[idx(x1, y0, z0)] * fx * (1.0 - fy) * (1.0 - fz)
442        + data[idx(x0, y1, z0)] * (1.0 - fx) * fy * (1.0 - fz)
443        + data[idx(x1, y1, z0)] * fx * fy * (1.0 - fz)
444        + data[idx(x0, y0, z1)] * (1.0 - fx) * (1.0 - fy) * fz
445        + data[idx(x1, y0, z1)] * fx * (1.0 - fy) * fz
446        + data[idx(x0, y1, z1)] * (1.0 - fx) * fy * fz
447        + data[idx(x1, y1, z1)] * fx * fy * fz
448}
449
450/// A volume resampled onto a cardinal-aligned grid.
451pub struct ResampledVolume {
452    pub data: Vec<f64>,
453    pub dims: (usize, usize, usize),
454    pub voxel_size: (f64, f64, f64),
455    pub affine: [f64; 16],
456}
457
458/// Magnitude and phase resampled together onto a cardinal-aligned grid.
459pub struct ResampledComplex {
460    pub magnitude: Vec<f64>,
461    /// Wrapped phase in radians, on `(-π, π]`.
462    pub phase: Vec<f64>,
463    pub dims: (usize, usize, usize),
464    pub voxel_size: (f64, f64, f64),
465    pub affine: [f64; 16],
466}
467
468/// Options for [`resample_complex_to_axial`].
469#[derive(Debug, Clone, Copy)]
470pub struct AxialResampleParams {
471    /// Replace exactly-zero phase with uniform noise on `(-π, π]` when at least this fraction of
472    /// the output is zero. Resampling an oblique volume leaves empty corners, and a large block of
473    /// identical zeros is not something a region-growing unwrapper handles gracefully. `None`
474    /// disables the fill. Default `Some(0.1)`, matching QSMxT 8.x.
475    pub noise_fill_fraction: Option<f64>,
476}
477
478impl Default for AxialResampleParams {
479    fn default() -> Self {
480        Self { noise_fill_fraction: Some(0.1) }
481    }
482}
483
484/// Resample a continuous volume (magnitude, unwrapped field, χ) to a cardinal-aligned grid.
485///
486/// **Not for wrapped phase** — see the module docs and [`resample_complex_to_axial`].
487pub fn resample_to_axial(
488    data: &[f64],
489    nx: usize,
490    ny: usize,
491    nz: usize,
492    affine: &[f64; 16],
493) -> ResampledVolume {
494    let grid = axial_grid_for(nx, ny, nz, affine);
495    ResampledVolume {
496        data: grid.sample(data),
497        dims: grid.dims,
498        voxel_size: grid.voxel_size,
499        affine: grid.affine,
500    }
501}
502
503/// Resample a binary mask to a cardinal-aligned grid with nearest-neighbour interpolation.
504pub fn resample_mask_to_axial(
505    mask: &[u8],
506    nx: usize,
507    ny: usize,
508    nz: usize,
509    affine: &[f64; 16],
510) -> Vec<u8> {
511    axial_grid_for(nx, ny, nz, affine).sample_nearest(mask)
512}
513
514/// Resample magnitude and wrapped phase to a cardinal-aligned grid, interpolating in the complex
515/// domain so wraps survive.
516///
517/// `mag·cos φ` and `mag·sin φ` are interpolated separately and recombined, which is continuous
518/// across the `±π` boundary. The returned affine is diagonal, so the dipole kernel can be built
519/// with B0 = `(0, 0, 1)`.
520///
521/// # Panics
522/// If `magnitude` and `phase` differ in length or do not match `nx · ny · nz`.
523pub fn resample_complex_to_axial(
524    magnitude: &[f64],
525    phase: &[f64],
526    nx: usize,
527    ny: usize,
528    nz: usize,
529    affine: &[f64; 16],
530    params: &AxialResampleParams,
531) -> ResampledComplex {
532    let n = nx * ny * nz;
533    assert_eq!(magnitude.len(), n, "magnitude length does not match dimensions");
534    assert_eq!(phase.len(), n, "phase length does not match dimensions");
535
536    let real: Vec<f64> = (0..n).map(|i| magnitude[i] * phase[i].cos()).collect();
537    let imag: Vec<f64> = (0..n).map(|i| magnitude[i] * phase[i].sin()).collect();
538
539    let grid = axial_grid_for(nx, ny, nz, affine);
540    let real_r = grid.sample(&real);
541    let imag_r = grid.sample(&imag);
542
543    let out_n = real_r.len();
544    let mut mag_out = Vec::with_capacity(out_n);
545    let mut pha_out = Vec::with_capacity(out_n);
546    for i in 0..out_n {
547        mag_out.push(real_r[i].hypot(imag_r[i]));
548        pha_out.push(imag_r[i].atan2(real_r[i]));
549    }
550
551    if let Some(frac) = params.noise_fill_fraction {
552        let zeros = pha_out.iter().filter(|p| **p == 0.0).count();
553        if out_n > 0 && (zeros as f64 / out_n as f64) >= frac {
554            // Seeded from the data so a rerun on the same input gives the same volume.
555            let mut rng = SplitMix64::new(seed_from_slice(&pha_out));
556            for p in pha_out.iter_mut() {
557                if *p == 0.0 {
558                    *p = rng.uniform_signed_pi();
559                }
560            }
561        }
562    }
563
564    ResampledComplex {
565        magnitude: mag_out,
566        phase: pha_out,
567        dims: grid.dims,
568        voxel_size: grid.voxel_size,
569        affine: grid.affine,
570    }
571}
572
573/// Deterministic seed from volume contents (FNV-1a over the bit patterns).
574fn seed_from_slice(data: &[f64]) -> u64 {
575    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
576    for v in data {
577        for b in v.to_bits().to_le_bytes() {
578            h ^= b as u64;
579            h = h.wrapping_mul(0x0000_0100_0000_01b3);
580        }
581    }
582    h | 1
583}
584
585/// SplitMix64 — small, dependency-free, and adequate for filling empty corners with noise.
586struct SplitMix64(u64);
587
588impl SplitMix64 {
589    fn new(seed: u64) -> Self {
590        Self(seed)
591    }
592    fn next_u64(&mut self) -> u64 {
593        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
594        let mut z = self.0;
595        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
596        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
597        z ^ (z >> 31)
598    }
599    /// Uniform on `(-π, π]`.
600    fn uniform_signed_pi(&mut self) -> f64 {
601        let u = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64; // [0, 1)
602        (u - 0.5) * 2.0 * std::f64::consts::PI
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use std::f64::consts::PI;
610
611    /// Row-major affine from a 3×3 and a translation.
612    fn affine_from(r: [[f64; 3]; 3], t: [f64; 3]) -> [f64; 16] {
613        [
614            r[0][0], r[0][1], r[0][2], t[0],
615            r[1][0], r[1][1], r[1][2], t[1],
616            r[2][0], r[2][1], r[2][2], t[2],
617            0.0, 0.0, 0.0, 1.0,
618        ]
619    }
620
621    fn identity_affine(vs: (f64, f64, f64)) -> [f64; 16] {
622        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])
623    }
624
625    /// A real UK Biobank SWI affine: 0.8 × 0.8 × 3 mm, header "Tra>Cor(-22.9)>Sag(-2.1)".
626    fn ukb_swi_affine() -> [f64; 16] {
627        affine_from(
628            [
629                [0.7976, -0.0273, -0.1075],
630                [0.0141, 0.7358, -1.1647],
631                [0.0370, 0.3092, 2.7626],
632            ],
633            [-101.6, -87.4, -60.2],
634        )
635    }
636
637    #[test]
638    fn voxel_sizes_are_column_norms() {
639        let (sx, sy, sz) = voxel_sizes_from_affine(&ukb_swi_affine());
640        assert!((sx - 0.799).abs() < 0.002, "{sx}");
641        assert!((sy - 0.799).abs() < 0.002, "{sy}");
642        assert!((sz - 3.0).abs() < 0.002, "{sz}");
643    }
644
645    #[test]
646    fn b0_direction_axial_is_z() {
647        for vs in [(1.0, 1.0, 1.0), (0.8, 0.8, 3.0), (2.0, 0.5, 1.0)] {
648            let (bx, by, bz) = b0_direction_from_affine(&identity_affine(vs));
649            assert!(bx.abs() < 1e-9 && by.abs() < 1e-9, "{vs:?} -> {bx} {by}");
650            assert!((bz - 1.0).abs() < 1e-9, "{vs:?} -> {bz}");
651            assert!(b0_angle_from_affine(&identity_affine(vs)) < 1e-6);
652        }
653    }
654
655    /// The regression that matters: with anisotropic voxels, factoring the voxel scaling out of
656    /// the affine is not optional. Inverting the scaled matrix instead (`A⁻¹·(0,0,1)`) is the
657    /// QSMxT 8.2.2 bug, and on this acquisition it is 35° away from the right answer.
658    #[test]
659    fn b0_direction_anisotropic_oblique() {
660        let a = ukb_swi_affine();
661        let (bx, by, bz) = b0_direction_from_affine(&a);
662        // Reference value from nibabel: pure_rotation.T @ [0, 0, 1].
663        assert!((bx - 0.0463).abs() < 1e-3, "bx={bx}");
664        assert!((by - 0.3871).abs() < 1e-3, "by={by}");
665        assert!((bz - 0.9209).abs() < 1e-3, "bz={bz}");
666
667        // The tilt matches the acquisition's stated 22.9° obliquity.
668        let angle = b0_angle_from_affine(&a);
669        assert!((angle - 22.9).abs() < 0.5, "angle={angle}");
670
671        // And is nowhere near what inverting the scaled matrix would give (≈58° off axis).
672        let naive = {
673            let r = [[a[0], a[1], a[2]], [a[4], a[5], a[6]], [a[8], a[9], a[10]]];
674            let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1])
675                - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
676                + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
677            let v = [
678                (r[0][1] * r[1][2] - r[0][2] * r[1][1]) / det,
679                (r[0][2] * r[1][0] - r[0][0] * r[1][2]) / det,
680                (r[0][0] * r[1][1] - r[0][1] * r[1][0]) / det,
681            ];
682            let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
683            [v[0] / n, v[1] / n, v[2] / n]
684        };
685        let dot = (bx * naive[0] + by * naive[1] + bz * naive[2]).abs().clamp(0.0, 1.0);
686        let between = dot.acos().to_degrees();
687        assert!(between > 30.0, "the two differ by {between}°, expected >30");
688    }
689
690    /// With isotropic voxels the scaling cancels, so the bug above is invisible — which is why it
691    /// survived: every isotropic test passes either way.
692    #[test]
693    fn b0_direction_isotropic_oblique_agrees_with_naive() {
694        let (c, s) = (30.0f64.to_radians().cos(), 30.0f64.to_radians().sin());
695        let a = affine_from([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], [0.0; 3]);
696        let (bx, by, bz) = b0_direction_from_affine(&a);
697        assert!(bx.abs() < 1e-9, "{bx}");
698        assert!((by - s).abs() < 1e-9, "{by} vs {s}");
699        assert!((bz - c).abs() < 1e-9, "{bz} vs {c}");
700        assert!((b0_angle_from_affine(&a) - 30.0).abs() < 1e-6);
701    }
702
703    #[test]
704    fn obliquity_matches_nibabel() {
705        // nibabel.affines.obliquity on the same affine, in degrees.
706        let axes = obliquity_axes_from_affine(&ukb_swi_affine());
707        for (got, want) in axes.iter().zip([2.838, 22.869, 22.947]) {
708            assert!((got - want).abs() < 0.05, "{axes:?}");
709        }
710        let norm = obliquity_from_affine(&ukb_swi_affine());
711        assert!((norm - 32.521).abs() < 0.05, "{norm}");
712        // Cardinal acquisitions score zero whatever the voxel sizes.
713        assert!(obliquity_from_affine(&identity_affine((0.8, 0.8, 3.0))) < 1e-9);
714    }
715
716    #[test]
717    fn axial_grid_of_an_axial_volume_is_the_same_grid() {
718        let a = identity_affine((1.0, 1.0, 2.0));
719        let g = axial_grid_for(4, 5, 6, &a);
720        assert_eq!(g.dims, (4, 5, 6));
721        let (vx, vy, vz) = g.voxel_size;
722        assert!((vx - 1.0).abs() < 1e-9 && (vy - 1.0).abs() < 1e-9 && (vz - 2.0).abs() < 1e-9);
723        // Resampling is then an identity.
724        let data: Vec<f64> = (0..4 * 5 * 6).map(|i| i as f64).collect();
725        let out = resample_to_axial(&data, 4, 5, 6, &a);
726        for (i, (got, want)) in out.data.iter().zip(data.iter()).enumerate() {
727            assert!((got - want).abs() < 1e-6, "voxel {i}: {got} vs {want}");
728        }
729    }
730
731    #[test]
732    fn resampled_affine_is_cardinal_and_b0_is_z() {
733        let g = axial_grid_for(32, 32, 12, &ukb_swi_affine());
734        for (i, v) in g.affine.iter().enumerate() {
735            let diagonal = matches!(i, 0 | 5 | 10 | 15);
736            let translation = matches!(i, 3 | 7 | 11);
737            if !diagonal && !translation {
738                assert!(v.abs() < 1e-12, "affine[{i}] = {v} should be 0");
739            }
740        }
741        assert!(b0_angle_from_affine(&g.affine) < 1e-9);
742        assert!(obliquity_from_affine(&g.affine) < 1e-9);
743        // The oblique volume needs a larger cardinal box to fit inside.
744        assert!(g.dims.0 >= 32 && g.dims.1 >= 32 && g.dims.2 >= 12, "{:?}", g.dims);
745    }
746
747    /// Interpolating wrapped phase directly is wrong at every wrap; going through the complex
748    /// representation is not. Build a ramp that wraps many times and compare both.
749    #[test]
750    fn complex_resampling_survives_wraps_where_scalar_does_not() {
751        let (nx, ny, nz) = (24, 24, 8);
752        let n = nx * ny * nz;
753        // Oblique enough to force real interpolation, isotropic so the true phase is easy to state.
754        let (c, s) = (20.0f64.to_radians().cos(), 20.0f64.to_radians().sin());
755        let a = affine_from([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], [0.0; 3]);
756
757        // Smooth field, wrapping ~6 times across x.
758        let true_phase = |x: f64, y: f64, _z: f64| 0.8 * x + 0.3 * y;
759        let mut mag = vec![0.0; n];
760        let mut pha = vec![0.0; n];
761        for k in 0..nz {
762            for j in 0..ny {
763                for i in 0..nx {
764                    let idx = i + j * nx + k * nx * ny;
765                    mag[idx] = 100.0;
766                    pha[idx] = wrap(true_phase(i as f64, j as f64, k as f64));
767                }
768            }
769        }
770        // Count real wrap discontinuities along x (adjacent voxels jumping by more than π).
771        let wraps = (0..nz)
772            .flat_map(|k| (0..ny).flat_map(move |j| (1..nx).map(move |i| (i, j, k))))
773            .filter(|&(i, j, k)| {
774                let idx = i + j * nx + k * nx * ny;
775                (pha[idx] - pha[idx - 1]).abs() > PI
776            })
777            .count();
778        assert!(wraps > 500, "test data should contain plenty of wraps, found {wraps}");
779
780        let cpx = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &AxialResampleParams { noise_fill_fraction: None });
781        let scalar = resample_to_axial(&pha, nx, ny, nz, &a);
782        let grid = axial_grid_for(nx, ny, nz, &a);
783
784        // Compare both against the analytic phase at each interior sample point.
785        let (mut cpx_err, mut scalar_err, mut count) = (0.0f64, 0.0f64, 0usize);
786        let (dx, dy, dz) = grid.dims;
787        for k in 0..dz {
788            for j in 0..dy {
789                for i in 0..dx {
790                    let Some((ox, oy, oz)) = grid.source_coord(i, j, k) else { continue };
791                    // Interior only: edge voxels mix in the zero background.
792                    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 {
793                        continue;
794                    }
795                    let idx = i + j * dx + k * dx * dy;
796                    let want = wrap(true_phase(ox, oy, oz));
797                    cpx_err += wrap(cpx.phase[idx] - want).abs();
798                    scalar_err += wrap(scalar.data[idx] - want).abs();
799                    count += 1;
800                }
801            }
802        }
803        assert!(count > 500, "too few interior samples: {count}");
804        let (cpx_err, scalar_err) = (cpx_err / count as f64, scalar_err / count as f64);
805        assert!(cpx_err < 0.05, "complex resampling error {cpx_err} rad is too high");
806        assert!(
807            scalar_err > 10.0 * cpx_err,
808            "scalar {scalar_err} should be far worse than complex {cpx_err}"
809        );
810        // Magnitude comes through nearly intact. It dips slightly rather than exactly matching,
811        // because averaging two phasors that differ by Δφ scales the result by cos(Δφ/2) — here
812        // the ramp is 0.8 rad/voxel, so a midpoint sample can lose up to 1 − cos(0.4) ≈ 8%. That
813        // is inherent to complex interpolation, not a defect, and it is the price of keeping the
814        // wraps; an unwrapped field would not pay it.
815        for (idx, m) in cpx.magnitude.iter().enumerate() {
816            if grid_interior(&grid, idx) {
817                assert!(*m > 90.0 && *m < 100.5, "magnitude {m} at {idx} outside the expected dip");
818            }
819        }
820    }
821
822    fn grid_interior(grid: &AxialGrid, idx: usize) -> bool {
823        let (dx, dy, _) = grid.dims;
824        let i = idx % dx;
825        let j = (idx / dx) % dy;
826        let k = idx / (dx * dy);
827        grid.source_coord(i, j, k)
828            .map(|(ox, oy, oz)| {
829                let (nx, ny, nz) = grid.src_dims;
830                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
831            })
832            .unwrap_or(false)
833    }
834
835    fn wrap(p: f64) -> f64 {
836        let mut v = (p + PI) % (2.0 * PI);
837        if v < 0.0 {
838            v += 2.0 * PI;
839        }
840        v - PI
841    }
842
843    // --- GridMap: the return trip ---
844
845    #[test]
846    fn round_trip_to_axial_and_back_recovers_the_original() {
847        let (nx, ny, nz) = (20, 22, 10);
848        let n = nx * ny * nz;
849        let a = ukb_swi_affine();
850        // A smooth field, so interpolation error is the only thing being measured.
851        let data: Vec<f64> = (0..n)
852            .map(|i| {
853                let (x, y, z) = (i % nx, (i / nx) % ny, i / (nx * ny));
854                (x as f64 * 0.1).sin() + (y as f64 * 0.07).cos() + z as f64 * 0.02
855            })
856            .collect();
857
858        let grid = axial_grid_for(nx, ny, nz, &a);
859        let there = resample_to_axial(&data, nx, ny, nz, &a);
860        let back = resample_onto(&there.data, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
861
862        // Interior voxels come back close; edges lose data to the empty corners, as expected of
863        // two interpolations.
864        let (mut err, mut count) = (0.0f64, 0usize);
865        for z in 2..nz - 2 {
866            for y in 2..ny - 2 {
867                for x in 2..nx - 2 {
868                    let i = x + y * nx + z * nx * ny;
869                    err += (back[i] - data[i]).abs();
870                    count += 1;
871                }
872            }
873        }
874        let mean = err / count as f64;
875        assert!(mean < 0.05, "round trip lost {mean} per voxel on a smooth field");
876    }
877
878    #[test]
879    fn round_trip_puts_the_data_back_on_the_original_grid() {
880        let (nx, ny, nz) = (16, 16, 8);
881        let a = ukb_swi_affine();
882        let grid = axial_grid_for(nx, ny, nz, &a);
883        assert_ne!(grid.dims, (nx, ny, nz), "the axial grid should differ, else this proves nothing");
884        let there = vec![1.0f64; grid.dims.0 * grid.dims.1 * grid.dims.2];
885        let back = resample_onto(&there, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
886        assert_eq!(back.len(), nx * ny * nz, "output must be on the acquired grid");
887        // The acquired volume sits inside the axial box, so everything is covered.
888        assert!(back.iter().filter(|v| **v > 0.5).count() > (nx * ny * nz) * 9 / 10);
889    }
890
891    #[test]
892    fn identity_map_is_a_no_op() {
893        let (nx, ny, nz) = (6, 5, 4);
894        let a = identity_affine((1.0, 2.0, 3.0));
895        let data: Vec<f64> = (0..nx * ny * nz).map(|i| i as f64).collect();
896        let out = resample_onto(&data, (nx, ny, nz), &a, (nx, ny, nz), &a).unwrap();
897        for (got, want) in out.iter().zip(data.iter()) {
898            assert!((got - want).abs() < 1e-9, "{got} vs {want}");
899        }
900    }
901
902    #[test]
903    fn singular_affine_is_reported_not_panicked() {
904        let zero = [0.0f64; 16];
905        let a = identity_affine((1.0, 1.0, 1.0));
906        assert!(resample_onto(&[0.0; 8], (2, 2, 2), &zero, (2, 2, 2), &a).is_none());
907        assert!(GridMap::new((2, 2, 2), &zero, (2, 2, 2), &a).is_none());
908    }
909
910    #[test]
911    fn mask_round_trip_stays_binary() {
912        let (nx, ny, nz) = (16, 16, 8);
913        let a = ukb_swi_affine();
914        let mut mask = vec![0u8; nx * ny * nz];
915        for z in 2..nz - 2 {
916            for y in 4..ny - 4 {
917                for x in 4..nx - 4 {
918                    mask[x + y * nx + z * nx * ny] = 1;
919                }
920            }
921        }
922        let grid = axial_grid_for(nx, ny, nz, &a);
923        let there = resample_mask_to_axial(&mask, nx, ny, nz, &a);
924        let back = resample_mask_onto(&there, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
925        assert!(back.iter().all(|v| *v == 0 || *v == 1));
926        assert!(back.iter().filter(|v| **v == 1).count() > 100, "mask should survive the trip");
927    }
928
929    /// Phase has to come back through the complex domain too, for the same reason it went out
930    /// that way.
931    #[test]
932    fn complex_round_trip_preserves_wraps() {
933        let (nx, ny, nz) = (20, 20, 8);
934        let n = nx * ny * nz;
935        let a = ukb_swi_affine();
936        let mag = vec![100.0f64; n];
937        let pha: Vec<f64> = (0..n)
938            .map(|i| {
939                let (x, y) = (i % nx, (i / nx) % ny);
940                wrap(0.6 * x as f64 + 0.2 * y as f64)
941            })
942            .collect();
943
944        let grid = axial_grid_for(nx, ny, nz, &a);
945        let out = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &AxialResampleParams { noise_fill_fraction: None });
946        let (_, back) = resample_complex_onto(&out.magnitude, &out.phase, grid.dims, &grid.affine, (nx, ny, nz), &a).unwrap();
947
948        let (mut err, mut count) = (0.0f64, 0usize);
949        for z in 2..nz - 2 {
950            for y in 3..ny - 3 {
951                for x in 3..nx - 3 {
952                    let i = x + y * nx + z * nx * ny;
953                    err += wrap(back[i] - pha[i]).abs();
954                    count += 1;
955                }
956            }
957        }
958        let mean = err / count as f64;
959        assert!(mean < 0.25, "complex round trip drifted {mean} rad per voxel");
960    }
961
962    #[test]
963    fn noise_fill_is_deterministic_and_bounded() {
964        let (nx, ny, nz) = (16, 16, 6);
965        let n = nx * ny * nz;
966        let (c, s) = (25.0f64.to_radians().cos(), 25.0f64.to_radians().sin());
967        let a = affine_from([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], [0.0; 3]);
968        let mag = vec![50.0; n];
969        let pha: Vec<f64> = (0..n).map(|i| wrap(0.4 * (i % nx) as f64)).collect();
970
971        let p = AxialResampleParams::default();
972        let a1 = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &p);
973        let a2 = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &p);
974        assert_eq!(a1.phase, a2.phase, "same input must give the same volume");
975        assert!(a1.phase.iter().all(|v| v.is_finite() && v.abs() <= PI + 1e-9));
976
977        // Without the fill, the empty corners stay at exactly zero.
978        let off = resample_complex_to_axial(&mag, &pha, nx, ny, nz, &a, &AxialResampleParams { noise_fill_fraction: None });
979        assert!(off.phase.iter().any(|v| *v == 0.0));
980        assert!(off.phase.iter().filter(|v| **v == 0.0).count() > a1.phase.iter().filter(|v| **v == 0.0).count());
981    }
982
983    #[test]
984    fn mask_resampling_stays_binary() {
985        let (nx, ny, nz) = (12, 12, 6);
986        let (c, s) = (15.0f64.to_radians().cos(), 15.0f64.to_radians().sin());
987        let a = affine_from([[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], [0.0; 3]);
988        let mut mask = vec![0u8; nx * ny * nz];
989        for k in 1..nz - 1 {
990            for j in 3..ny - 3 {
991                for i in 3..nx - 3 {
992                    mask[i + j * nx + k * nx * ny] = 1;
993                }
994            }
995        }
996        let out = resample_mask_to_axial(&mask, nx, ny, nz, &a);
997        assert!(out.iter().all(|v| *v == 0 || *v == 1), "mask must stay binary");
998        assert!(out.iter().filter(|v| **v == 1).count() > 100, "mask should survive");
999    }
1000}