Skip to main content

qsm_core/bet/
mindgrab.rs

1//! MindGrab deep-learning brain extraction (`onnx` feature).
2//!
3//! MindGrab is a 146 k-parameter MeshNet — 25 dilated 3×3×3 convolutions (15 channels,
4//! dilations 16-8-4-2-1 five times over, each followed by instance normalisation and GELU) and a
5//! 1×1×1 two-class head — trained only on synthetic, modality-agnostic data, so it skull-strips
6//! head images of any contrast. This module ports the pipeline brainchop-cli runs around the
7//! network (`mindgrab.onnx`, see [`crate::models`]) for `brainchop --skull-strip`:
8//!
9//! 1. conform the image as niimath's `-conform` does (a port of FastSurfer's `conform.py`):
10//!    scale intensities to 0–255 over the robust range (0 to the 98th percentile of the
11//!    histogram), then reslice trilinearly to a 256³, 1 mm, LIA grid centred on the image, and
12//!    round to `u8`;
13//! 2. normalise to the unit interval between the 2nd and 98th percentiles, clipping outside;
14//! 3. run the network on the whole 256³ volume (optionally also on its left–right mirror,
15//!    summing the logits, as brainchop's `--tta` does);
16//! 4. take the argmax, keep the largest 26-connected component (niimath `-bwlabel 26`);
17//! 5. reslice the mask back onto the input grid with nearest neighbour (niimath `-reslice_nn`).
18//!
19//! The conform step needs the image's voxel→world affine: the network expects its input in a
20//! fixed anatomical orientation, and that is the only way to find it. Oblique acquisitions are
21//! resliced, not just permuted, as niimath does.
22//!
23//! Instance normalisation runs over the whole volume, so the network cannot be tiled: one pass
24//! holds a few 15-channel 256³ activations (≈1 GB each in `f32`).
25//!
26//! # Instance-norm statistics (why the graph is restated)
27//!
28//! Each block normalises every channel by its mean and variance over all 256³ = 16.8 M voxels
29//! (`ReduceMean` over axes 2–4 in `mindgrab.onnx`). tract sums such a reduction in `f32` along
30//! one long lane (a handful of running accumulators, each over millions of voxels; consecutive
31//! reductions, and reductions split by reshapes, are merged back into that one lane), and once a
32//! running sum is large the small terms lose most of their bits. On the KKI s002001
33//! bridge-run1 head (QSM-CI's MindGrab pair) the first block's per-channel variances came out
34//! up to 2.2 % off and its means 0.11 % off, against `f64`. Over 25 blocks that moved the
35//! logit margin (brain − background) by up to 1.28 (mean 0.035), and 533 conformed voxels
36//! whose `f64` margin was within 0.90 of zero took the other class: the 517 voxels by which the
37//! port's mask differed from brainchop-cli's after the reslice. brainchop-cli (tinygrad) is
38//! within 1e-4 of the `f64` margins everywhere, with no voxel of another class: its result is
39//! the network's, and the port's was tract's rounding. Measured 2026-11-03, the network
40//! evaluated in `f64` (numpy) from the same weights and input.
41//!
42//! [`mindgrab`] therefore loads the graph with every such `ReduceMean` restated as an
43//! `AveragePool` of 16³ windows (stride 16) followed by the `ReduceMean` of the 16³ window means
44//! ([`pool_instance_norm_means`]). The windows tile the 256³ cube exactly, so the mean of the
45//! window means is the same mean; each sum now runs over 4 096 terms, which tract does not
46//! merge with the reduction after it. The variances then agree with `f64` to 6e-6, the margins
47//! to 5e-4, and the mask is brainchop-cli's to the voxel (0 of 16.8 M conformed voxels, 0 of
48//! 8.4 M on the input grid). Same runtime and peak memory. The hosted weights are unchanged,
49//! so the edit is made on the parsed protobuf at load time (`OnnxModel::load_edited`).
50//!
51//! # Normalisation arithmetic
52//!
53//! brainchop's `qnormalize` works in `f32`: numpy's quantiles of the `f32` volume are `f32`,
54//! and `(v − q_low) / (q_high − q_low + 1e-3)` is evaluated in `f32`. [`normalize`] does the
55//! same (quantiles in `f64`, rounded to `f32`). In `f64` throughout, 1.28 M of the 16.8 M inputs
56//! were one `f32` ulp off brainchop's, which changed no voxel of the mask; in `f32` the
57//! network input is brainchop's bit for bit.
58//!
59//! # Reference pipeline
60//!
61//! - **Contrast and resolution.** Any: the image is conformed to 256³ at 1 mm whatever its
62//!   resolution, and the network was trained on synthetic data of random contrast. QSM.rs has
63//!   no Dice against a reference brain mask on GRE recorded.
64//! - **Orientation.** Needs the voxel→world affine (`ScanMetadata::affine` in the pipeline,
65//!   which refuses to run MindGrab without it); a wrong affine gives the network a wrongly
66//!   oriented head.
67//! - **Defaults.** brainchop `--skull-strip` without `--tta`; [`MindGrabParams::default`]
68//!   matches.
69//! - **WASM.** The network always runs on the whole 256³ grid ([`CONFORM_DIM`]) and cannot be
70//!   tiled. Its registry figures (`models::registry`, `mem(…, 363_000_000, 311)` for WASM) give
71//!   `find_model("mindgrab").peak_bytes([256; 3], Heap::Wasm32)` = 363 MB + 311 B × 256³ ≈
72//!   5.58 GB, above the 4 GiB a `wasm32` heap can hold (natively ≈7.3 GB measured at 256³,
73//!   `models::memory_calibration`). MindGrab therefore does not run in a 32-bit WASM host.
74//!
75//! Reference:
76//! Fani, A., Masoud, M., Rorden, C., Plis, S. (2025). "MindGrab for BrainChop: Fast and
77//! Accurate Skull Stripping for Command Line and Browser." arXiv:2506.11860.
78//! https://doi.org/10.48550/arXiv.2506.11860
79//!
80//! Reference implementation: https://github.com/neuroneural/brainchop-cli (MIT), with niimath
81//! (https://github.com/rordenlab/niimath, BSD-2-Clause) for conform, labelling and reslicing.
82//!
83//! # Pipeline rules
84//!
85//! The facts above that a configuration can depart from are published as rules in
86//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `mindgrab.wasm-heap`.
87
88// The pre/post-processing is plain Rust; only inference needs `onnx`.
89#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
90
91#[cfg(feature = "onnx")]
92use crate::grid::Grid;
93#[cfg(feature = "onnx")]
94use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
95#[cfg(feature = "onnx")]
96use crate::utils::connected::{largest_component, Connectivity};
97
98/// Side of the conformed cube, in voxels (1 mm each).
99pub const CONFORM_DIM: usize = 256;
100
101/// Upper fraction of the histogram kept when scaling to `u8` (niimath `-conform`; FreeSurfer's
102/// `mri_convert` uses 0.999).
103const CONFORM_F_HIGH: f64 = 0.98;
104
105/// MindGrab inference parameters.
106#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
107#[derive(Clone, Debug, PartialEq, Default)]
108pub struct MindGrabParams {
109    /// Left–right flip test-time augmentation: also run the mirrored volume and sum the logits
110    /// (brainchop's `--tta`). Doubles the run time. Default off, as brainchop runs it.
111    pub flip_tta: bool,
112}
113
114/// Brain mask via MindGrab.
115///
116/// * `image` — head image of any contrast (for GRE, a magnitude), column-major `(nx, ny, nz)`.
117/// * `affine` — the image's voxel→world affine (row-major 4×4, as in a NIfTI sform/qform).
118/// * `model_onnx` — bytes of the exported `mindgrab.onnx` (see [`crate::models`]).
119/// * `progress(done, total)` — called after each network evaluation.
120///
121/// Returns a binary mask (0/1) on the input grid.
122#[cfg(feature = "onnx")]
123pub fn mindgrab(
124    image: &[f64],
125    grid: &Grid,
126    affine: &[f64; 16],
127    model_onnx: &[u8],
128    params: &MindGrabParams,
129    mut progress: impl FnMut(usize, usize),
130) -> Result<Vec<u8>, OnnxError> {
131    let (nx, ny, nz) = grid.dims;
132    assert_eq!(image.len(), nx * ny * nz, "image length must match grid");
133    let conformed = Conformed::new(image, grid.dims, affine)
134        .ok_or_else(|| OnnxError::Shape("MindGrab needs a non-singular affine".into()))?;
135    let input = normalize(&conformed.data);
136
137    let model = OnnxModel::load_edited(model_onnx, pool_instance_norm_means)?;
138    let n = CONFORM_DIM;
139    let shape = vec![1, 1, n, n, n];
140    let passes = if params.flip_tta { 2 } else { 1 };
141    let mut logits = model.run_single(&Tensor::new(shape.clone(), input.clone()))?.data;
142    progress(1, passes);
143    if params.flip_tta {
144        let flipped = model.run_single(&Tensor::new(shape, flip_axis0(&input, 1)))?.data;
145        logits.iter_mut().zip(flip_axis0(&flipped, 2)).for_each(|(l, f)| *l += f);
146        progress(2, passes);
147    }
148
149    let mask = argmax_mask(&logits);
150    let mask = largest_component(&mask, (n, n, n), Connectivity::Full);
151    Ok(conformed.reslice_back(&mask))
152}
153
154/// The input conformed to the 256³ 1 mm LIA grid, plus what is needed to map a mask back.
155///
156/// The geometry is computed as niimath computes it — `f32` matrices, inverted in `f64` and
157/// rounded back to `f32`, applied in `f32` in the same order — so that voxels whose sample point
158/// falls on a rounding tie (common: a 0.9375 mm grid against a 1 mm one) land where niimath puts
159/// them.
160struct Conformed {
161    /// Conformed `u8` intensities, column-major `(256, 256, 256)`.
162    data: Vec<u8>,
163    /// Input dimensions.
164    dims: (usize, usize, usize),
165    /// Input voxel→world, as `f32`.
166    in_affine: M44,
167    /// Conformed voxel→world, as `f32`.
168    out_affine: M44,
169}
170
171impl Conformed {
172    /// niimath `-conform`: scale to `u8`'s range, reslice to 256³ 1 mm LIA. `None` if the affine
173    /// is singular.
174    fn new(image: &[f64], dims: (usize, usize, usize), affine: &[f64; 16]) -> Option<Self> {
175        let in_affine: M44 = std::array::from_fn(|r| std::array::from_fn(|c| affine[4 * r + c] as f32));
176        let out_affine = conform_affine(dims, &in_affine);
177        // conformed voxel → input voxel
178        let to_input = vox2vox_inverse(&in_affine, &out_affine)?;
179        let scaled = scale_to_u8_range(image);
180        let data = reslice_linear(&scaled, dims, &to_input);
181        Some(Self { data, dims, in_affine, out_affine })
182    }
183
184    /// niimath `-reslice_nn`: nearest-neighbour from the conformed grid back onto the input grid
185    /// (rounding half away from zero; 0 outside the conformed cube).
186    fn reslice_back(&self, mask: &[u8]) -> Vec<u8> {
187        let (nx, ny, nz) = self.dims;
188        let n = CONFORM_DIM as i64;
189        // input voxel → conformed voxel
190        let Some(m) = vox2vox_inverse(&self.out_affine, &self.in_affine) else {
191            return vec![0; nx * ny * nz];
192        };
193        let mut out = vec![0u8; nx * ny * nz];
194        for z in 0..nz {
195            for y in 0..ny {
196                let yz = row_yz(&m, y, z);
197                for x in 0..nx {
198                    let c: [i64; 3] = std::array::from_fn(|r| (x as f32 * m[r][0] + yz[r]).round() as i64);
199                    if c.iter().all(|&v| (0..n).contains(&v)) {
200                        out[x + nx * (y + ny * z)] = mask[(c[0] + n * (c[1] + n * c[2])) as usize];
201                    }
202                }
203            }
204        }
205        out
206    }
207}
208
209/// Row-major 4×4 in `f32`, as niimath's `mat44`.
210type M44 = [[f32; 4]; 4];
211
212/// `nifti_mat44_mul`: `f32` products accumulated in `f32`.
213fn mul44(a: &M44, b: &M44) -> M44 {
214    std::array::from_fn(|i| {
215        std::array::from_fn(|j| {
216            let mut c = 0.0f32;
217            for k in 0..4 {
218                c += a[i][k] * b[k][j];
219            }
220            c
221        })
222    })
223}
224
225/// `nifti_mat44_inverse`: the inverse of the affine, computed in `f64` and rounded to `f32`.
226/// `None` if singular.
227fn inv44(r: &M44) -> Option<M44> {
228    let g = |i: usize, j: usize| r[i][j] as f64;
229    let (r11, r12, r13, r21, r22, r23, r31, r32, r33) =
230        (g(0, 0), g(0, 1), g(0, 2), g(1, 0), g(1, 1), g(1, 2), g(2, 0), g(2, 1), g(2, 2));
231    let (v1, v2, v3) = (g(0, 3), g(1, 3), g(2, 3));
232    let det = r11 * r22 * r33 - r11 * r32 * r23 - r21 * r12 * r33 + r21 * r32 * r13 + r31 * r12 * r23
233        - r31 * r22 * r13;
234    if det == 0.0 {
235        return None;
236    }
237    let d = 1.0 / det;
238    let f = |v: f64| (d * v) as f32;
239    Some([
240        [
241            f(r22 * r33 - r32 * r23),
242            f(-r12 * r33 + r32 * r13),
243            f(r12 * r23 - r22 * r13),
244            f(-r12 * r23 * v3 + r12 * v2 * r33 + r22 * r13 * v3 - r22 * v1 * r33 - r32 * r13 * v2 + r32 * v1 * r23),
245        ],
246        [
247            f(-r21 * r33 + r31 * r23),
248            f(r11 * r33 - r31 * r13),
249            f(-r11 * r23 + r21 * r13),
250            f(r11 * r23 * v3 - r11 * v2 * r33 - r21 * r13 * v3 + r21 * v1 * r33 + r31 * r13 * v2 - r31 * v1 * r23),
251        ],
252        [
253            f(r21 * r32 - r31 * r22),
254            f(-r11 * r32 + r31 * r12),
255            f(r11 * r22 - r21 * r12),
256            f(-r11 * r22 * v3 + r11 * r32 * v2 + r21 * r12 * v3 - r21 * r32 * v1 - r31 * r12 * v2 + r31 * r22 * v1),
257        ],
258        [0.0, 0.0, 0.0, 1.0],
259    ])
260}
261
262/// `nifti_vect44mat44_mul`: `m · (v, 1)` in `f32`.
263fn mul_vec(m: &M44, v: [f32; 3]) -> [f32; 4] {
264    let v = [v[0], v[1], v[2], 1.0];
265    std::array::from_fn(|i| {
266        let mut o = 0.0f32;
267        for j in 0..4 {
268            o += m[i][j] * v[j];
269        }
270        o
271    })
272}
273
274/// niimath's `resliceVox2Vox` inverse: destination voxel → source voxel, i.e.
275/// `inv(inv(dst) · src)`, for resampling a `src`-grid image onto `dst`. `None` if singular.
276fn vox2vox_inverse(src: &M44, dst: &M44) -> Option<M44> {
277    inv44(&mul44(&inv44(dst)?, src))
278}
279
280/// niimath's per-row partial sums `y·m[r][1] + z·m[r][2] + m[r][3]`, in `f32`.
281fn row_yz(m: &M44, y: usize, z: usize) -> [f32; 3] {
282    std::array::from_fn(|r| y as f32 * m[r][1] + z as f32 * m[r][2] + m[r][3])
283}
284
285/// The conformed grid's voxel→world affine (niimath's `conformVox2Vox` with `toRAS = false`):
286/// 1 mm voxels along LIA (+i left, +j inferior, +k anterior), with the cube's centre
287/// `(128, 128, 128)` on the world position of the input's voxel `dims / 2`.
288fn conform_affine(dims: (usize, usize, usize), affine: &M44) -> M44 {
289    let half = [dims.0 as f32 / 2.0, dims.1 as f32 / 2.0, dims.2 as f32 / 2.0];
290    let centre = mul_vec(affine, half);
291    // rotation rows: x = -i, y = k, z = -j (1 mm, so no scaling)
292    let mut out: M44 = [
293        [-1.0, 0.0, 0.0, 0.0],
294        [0.0, 0.0, 1.0, 0.0],
295        [0.0, -1.0, 0.0, 0.0],
296        [0.0, 0.0, 0.0, 1.0],
297    ];
298    let n = CONFORM_DIM as f32;
299    let vol_centre = mul_vec(&out, [n, n, n]).map(|v| v * 0.5);
300    for r in 0..3 {
301        out[r][3] = centre[r] - vol_centre[r];
302    }
303    out
304}
305
306/// niimath's `voxelIntensityScale` (FastSurfer `getscale` + `scalecrop`) to `[0, 255]`, with
307/// `f_low = 0` and `f_high = 0.98`.
308///
309/// The robust maximum is read from a 1000-bin histogram: the first bin whose cumulative count
310/// reaches all voxels but 2 % of the *non-zero* ones. The minimum is the image minimum (or 0, if
311/// fewer than 2 % of voxels are negative). niimath works in `f32`, and so does this.
312fn scale_to_u8_range(image: &[f64]) -> Vec<f32> {
313    let img: Vec<f32> = image.iter().map(|&v| v as f32).collect();
314    let n = img.len();
315    let (mut src_min, src_max) = img.iter().fold((img[0], img[0]), |(lo, hi), &v| (lo.min(v), hi.max(v)));
316    let nz = img.iter().filter(|v| v.abs() >= 1e-15).count();
317    let nltz = img.iter().filter(|&&v| v < 0.0).count();
318    if src_min < 0.0 && 100.0 * (nltz as f64 / n as f64) < 2.0 {
319        src_min = 0.0;
320    }
321
322    const BINS: usize = 1000;
323    let bin_size = (src_max - src_min) / BINS as f32;
324    let mut cs = vec![0usize; BINS];
325    for &v in &img {
326        // C's (int) cast truncates toward zero; then clamp into range
327        let b = ((v - src_min) / bin_size) as i64;
328        cs[b.clamp(0, BINS as i64 - 1) as usize] += 1;
329    }
330    for i in 1..BINS {
331        cs[i] += cs[i - 1];
332    }
333    // f_low = 0: the lower limit is the first bin with a cumulative count >= 0, i.e. bin 0
334    let nth = n - ((1.0 - CONFORM_F_HIGH as f32) * nz as f32) as usize;
335    let mut idx = 0;
336    while idx < BINS - 1 && cs[idx + 1] < nth {
337        idx += 1;
338    }
339    let robust_max = idx as f32 * bin_size + src_min;
340    let scale = if src_min != robust_max { 255.0 / (robust_max - src_min) } else { 1.0 };
341    img.iter().map(|&v| (scale * (v - src_min)).clamp(0.0, 255.0)).collect()
342}
343
344/// niimath's linear `doReslice` onto the 256³ grid, rounded to `u8` (`-odt char`).
345///
346/// A conformed voxel whose 2×2×2 input neighbourhood is not wholly inside the input keeps the
347/// image minimum, as niimath fills it.
348fn reslice_linear(img: &[f32], (nx, ny, nz): (usize, usize, usize), m: &M44) -> Vec<u8> {
349    let n = CONFORM_DIM;
350    let fill = img.iter().copied().fold(f32::INFINITY, f32::min);
351    let mut out = vec![fill; n * n * n];
352    let (sy, sz) = (nx, nx * ny);
353    for z in 0..n {
354        for y in 0..n {
355            let yz = row_yz(m, y, z);
356            for x in 0..n {
357                let [ix, iy, iz]: [f32; 3] = std::array::from_fn(|r| x as f32 * m[r][0] + yz[r]);
358                let (fx, fy, fz) = (ix.floor(), iy.floor(), iz.floor());
359                if fx < 0.0 || fy < 0.0 || fz < 0.0 {
360                    continue;
361                }
362                let (fx, fy, fz) = (fx as usize, fy as usize, fz as usize);
363                if fx + 1 >= nx || fy + 1 >= ny || fz + 1 >= nz {
364                    continue;
365                }
366                let (dx, dy, dz) = (ix - fx as f32, iy - fy as f32, iz - fz as f32);
367                let (wx0, wy0, wz0) = (1.0 - dx, 1.0 - dy, 1.0 - dz);
368                let i000 = fx + fy * sy + fz * sz;
369                // same terms, same order as niimath
370                let mut v = 0.0f32;
371                v += img[i000] * wx0 * wy0 * wz0;
372                v += img[i000 + sz] * wx0 * wy0 * dz;
373                v += img[i000 + sy] * wx0 * dy * wz0;
374                v += img[i000 + sy + sz] * wx0 * dy * dz;
375                v += img[i000 + 1] * dx * wy0 * wz0;
376                v += img[i000 + 1 + sz] * dx * wy0 * dz;
377                v += img[i000 + 1 + sy] * dx * dy * wz0;
378                v += img[i000 + 1 + sy + sz] * dx * dy * dz;
379                out[x + n * (y + n * z)] = v;
380            }
381        }
382    }
383    // C round(): half away from zero; values are already in [0, 255]
384    out.iter().map(|&v| v.round().clamp(0.0, 255.0) as u8).collect()
385}
386
387/// brainchop's `qnormalize`: map the 2nd..98th percentile (numpy's linear interpolation) to
388/// `[0, 1]` and clip. Returns the network input in C order `(x, y, z)` — brainchop feeds the
389/// conformed volume to the network as `(x, y, z)`, the transpose of its column-major layout.
390fn normalize(data: &[u8]) -> Vec<f32> {
391    let mut hist = [0usize; 256];
392    data.iter().for_each(|&v| hist[v as usize] += 1);
393    // value at a sorted position, from the histogram
394    let at = |pos: usize| {
395        let mut cum = 0;
396        for (v, &c) in hist.iter().enumerate() {
397            cum += c;
398            if cum > pos {
399                return v as f64;
400            }
401        }
402        255.0
403    };
404    let quantile = |q: f64| {
405        let p = q * (data.len() - 1) as f64;
406        let (lo, frac) = (p.floor() as usize, p - p.floor());
407        let a = at(lo);
408        if frac == 0.0 { a } else { a + (at(lo + 1) - a) * frac }
409    };
410    // numpy's quantiles of an f32 volume are f32, and qnormalize's arithmetic is f32 (see the
411    // module docs, *Normalisation arithmetic*).
412    let (qlow, qhigh) = (quantile(0.02) as f32, quantile(0.98) as f32);
413    let denom = qhigh - qlow + 1e-3f32;
414    let lut: Vec<f32> = (0..256).map(|v| ((v as f32 - qlow) / denom).clamp(0.0, 1.0)).collect();
415
416    let n = CONFORM_DIM;
417    let mut out = vec![0.0f32; n * n * n];
418    for z in 0..n {
419        for y in 0..n {
420            for x in 0..n {
421                out[(x * n + y) * n + z] = lut[data[x + n * (y + n * z)] as usize];
422            }
423        }
424    }
425    out
426}
427
428/// Side of the `AveragePool` window [`pool_instance_norm_means`] puts in front of each
429/// instance-norm mean: it divides [`CONFORM_DIM`], so the windows tile the cube exactly.
430const NORM_POOL: usize = 16;
431
432/// Restate every `ReduceMean` over the spatial axes `[2, 3, 4]` (instance normalisation's mean and
433/// variance) as an `AveragePool` of [`NORM_POOL`]³ windows, stride [`NORM_POOL`], followed by the
434/// same `ReduceMean` over the window means. Mathematically the same mean (equal windows tiling the
435/// cube); numerically it keeps tract's `f32` sums short (module docs, *Instance-norm
436/// statistics*). Fails if the graph has no such reduction, so a re-exported graph cannot skip it
437/// silently.
438#[cfg(feature = "onnx")]
439fn pool_instance_norm_means(proto: &mut tract_onnx::pb::ModelProto) -> Result<(), OnnxError> {
440    use tract_onnx::pb::{attribute_proto::AttributeType, AttributeProto, NodeProto};
441    let graph = proto.graph.as_mut().ok_or_else(|| OnnxError::Load("MindGrab: ONNX model has no graph".into()))?;
442    let ints = |name: &str| AttributeProto {
443        name: name.into(),
444        r#type: AttributeType::Ints as i32,
445        ints: vec![NORM_POOL as i64; 3],
446        ..Default::default()
447    };
448    let mut rewritten = 0;
449    let mut nodes = Vec::with_capacity(graph.node.len() + 64);
450    for node in std::mem::take(&mut graph.node) {
451        let spatial_mean = node.op_type == "ReduceMean"
452            && node.input.len() == 1
453            && node.attribute.iter().any(|a| a.name == "axes" && a.ints == [2, 3, 4])
454            && node.attribute.iter().all(|a| a.name != "keepdims" || a.i == 1);
455        if !spatial_mean {
456            nodes.push(node);
457            continue;
458        }
459        let pooled = format!("{}/window_means", node.output[0]);
460        nodes.push(NodeProto {
461            input: node.input.clone(),
462            output: vec![pooled.clone()],
463            name: format!("{}/window_means", node.name),
464            op_type: "AveragePool".into(),
465            attribute: vec![ints("kernel_shape"), ints("strides")],
466            ..Default::default()
467        });
468        nodes.push(NodeProto { input: vec![pooled], ..node });
469        rewritten += 1;
470    }
471    graph.node = nodes;
472    if rewritten == 0 {
473        return Err(OnnxError::Load("MindGrab: no instance-norm ReduceMean over axes [2, 3, 4] to restate".into()));
474    }
475    Ok(())
476}
477
478/// Two-class logits `(1, 2, x, y, z)` (C order) → column-major mask: class 1 where its logit
479/// beats class 0 (ties go to class 0, as an argmax does).
480fn argmax_mask(logits: &[f32]) -> Vec<u8> {
481    let n = CONFORM_DIM;
482    let n3 = n * n * n;
483    assert_eq!(logits.len(), 2 * n3, "MindGrab must output two 256³ channels");
484    let (bg, fg) = logits.split_at(n3);
485    let mut mask = vec![0u8; n3];
486    for x in 0..n {
487        for y in 0..n {
488            for z in 0..n {
489                let i = (x * n + y) * n + z;
490                mask[x + n * (y + n * z)] = (fg[i] > bg[i]) as u8;
491            }
492        }
493    }
494    mask
495}
496
497/// Reverse the first spatial axis of `channels` C-order `(x, y, z)` 256³ volumes (the
498/// sagittal flip of brainchop's TTA).
499fn flip_axis0(data: &[f32], channels: usize) -> Vec<f32> {
500    let n = CONFORM_DIM;
501    let plane = n * n;
502    let mut out = vec![0.0f32; data.len()];
503    for c in 0..channels {
504        let base = c * n * plane;
505        for x in 0..n {
506            let (src, dst) = (base + x * plane, base + (n - 1 - x) * plane);
507            out[dst..dst + plane].copy_from_slice(&data[src..src + plane]);
508        }
509    }
510    out
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[rustfmt::skip]
518    const RAS_1MM: [f64; 16] = [
519        1.0, 0.0, 0.0, -10.0,
520        0.0, 1.0, 0.0, -20.0,
521        0.0, 0.0, 1.0, -30.0,
522        0.0, 0.0, 0.0, 1.0,
523    ];
524
525    #[test]
526    fn conform_affine_is_lia_centred_on_the_input() {
527        let aff: M44 = std::array::from_fn(|r| std::array::from_fn(|c| RAS_1MM[4 * r + c] as f32));
528        let a = conform_affine((40, 60, 80), &aff);
529        // input voxel dims/2 = (20, 30, 40) sits at world (10, 10, 10)
530        assert_eq!(mul_vec(&a, [128.0, 128.0, 128.0])[..3], [10.0, 10.0, 10.0]);
531        // +i → left (-x), +j → inferior (-z), +k → anterior (+y)
532        assert_eq!([a[0][0], a[1][0], a[2][0]], [-1.0, 0.0, 0.0]);
533        assert_eq!([a[0][1], a[1][1], a[2][1]], [0.0, 0.0, -1.0]);
534        assert_eq!([a[0][2], a[1][2], a[2][2]], [0.0, 1.0, 0.0]);
535    }
536
537    #[test]
538    fn mask_round_trips_through_the_conformed_grid() {
539        // A 1 mm RAS box resliced into the conformed cube and back lands on the same voxels.
540        let dims = (40, 60, 80);
541        let n = dims.0 * dims.1 * dims.2;
542        let mut img = vec![0.0; n];
543        for z in 20..60 {
544            for y in 15..45 {
545                for x in 10..30 {
546                    img[x + dims.0 * (y + dims.1 * z)] = 100.0;
547                }
548            }
549        }
550        let c = Conformed::new(&img, dims, &RAS_1MM).unwrap();
551        let mask: Vec<u8> = c.data.iter().map(|&v| (v > 127) as u8).collect();
552        let back = c.reslice_back(&mask);
553        let want: Vec<u8> = img.iter().map(|&v| (v > 50.0) as u8).collect();
554        assert_eq!(back, want);
555    }
556
557    #[test]
558    fn intensity_scaling_maps_robust_range_to_u8() {
559        // 98 % of the voxels at 1..=98, a 2 % bright tail: the tail saturates at 255.
560        let img: Vec<f64> = (0..10_000).map(|i| if i < 9_800 { (i % 98 + 1) as f64 } else { 1000.0 }).collect();
561        let s = scale_to_u8_range(&img);
562        assert_eq!(s.iter().cloned().fold(f32::MIN, f32::max), 255.0);
563        assert!(s[..9_800].iter().all(|&v| v < 255.0));
564        assert_eq!(s.iter().cloned().fold(f32::MAX, f32::min), 0.0);
565    }
566
567    #[test]
568    fn normalize_matches_numpy_quantiles() {
569        // 0..=255 repeated evenly: np.quantile at 0.02 / 0.98 by linear interpolation.
570        let n = CONFORM_DIM;
571        let data: Vec<u8> = (0..n * n * n).map(|i| (i % 256) as u8).collect();
572        let out = normalize(&data);
573        // sorted position q*(N-1): value = floor(pos / (N/256)) with linear interpolation
574        let total = (n * n * n) as f64;
575        let per = total / 256.0;
576        let q = |q: f64| {
577            let p = q * (total - 1.0);
578            let lo = (p.floor() / per).floor();
579            let hi = ((p.floor() + 1.0) / per).floor();
580            lo + (hi - lo) * (p - p.floor())
581        };
582        let (ql, qh) = (q(0.02), q(0.98));
583        // in f32, as qnormalize computes it
584        let (ql, qh) = (ql as f32, qh as f32);
585        let want = |v: f64| ((v as f32 - ql) / (qh - ql + 1e-3f32)).clamp(0.0, 1.0);
586        // voxel x=200, y=0, z=0 has value 200, lands at C-order index 200 * n * n
587        assert_eq!(out[200 * n * n], want(200.0));
588        assert_eq!(out[0], 0.0);
589        assert_eq!(out[255 * n * n], 1.0);
590    }
591
592    #[test]
593    fn flip_is_an_involution() {
594        let n = CONFORM_DIM;
595        let v: Vec<f32> = (0..n * n * n).map(|i| (i % 977) as f32).collect();
596        assert_eq!(flip_axis0(&flip_axis0(&v, 1), 1), v);
597        assert_eq!(flip_axis0(&v, 1)[0], v[(n - 1) * n * n]);
598    }
599
600    /// A one-node graph: the instance-norm mean (`ReduceMean` over axes 2–4, keepdims) of a
601    /// `(1, 1, n, n, n)` input.
602    #[cfg(feature = "onnx")]
603    fn spatial_mean_graph() -> tract_onnx::pb::ModelProto {
604        use tract_onnx::pb::*;
605        let attr = |name: &str, ints: Vec<i64>, i: i64, t: attribute_proto::AttributeType| AttributeProto {
606            name: name.into(),
607            r#type: t as i32,
608            ints,
609            i,
610            ..Default::default()
611        };
612        // an f32 tensor of unknown dims (the run binds the shape)
613        let value = |name: &str, rank: usize| ValueInfoProto {
614            name: name.into(),
615            r#type: Some(TypeProto {
616                value: Some(type_proto::Value::TensorType(type_proto::Tensor {
617                    elem_type: tensor_proto::DataType::Float as i32,
618                    shape: Some(TensorShapeProto {
619                        dim: (0..rank)
620                            .map(|d| tensor_shape_proto::Dimension {
621                                value: Some(tensor_shape_proto::dimension::Value::DimParam(format!("d{d}"))),
622                                ..Default::default()
623                            })
624                            .collect(),
625                    }),
626                })),
627                ..Default::default()
628            }),
629            ..Default::default()
630        };
631        ModelProto {
632            ir_version: 8,
633            opset_import: vec![OperatorSetIdProto { domain: String::new(), version: 17 }],
634            graph: Some(GraphProto {
635                name: "spatial_mean".into(),
636                node: vec![NodeProto {
637                    input: vec!["x".into()],
638                    output: vec!["m".into()],
639                    name: "mean".into(),
640                    op_type: "ReduceMean".into(),
641                    attribute: vec![
642                        attr("axes", vec![2, 3, 4], 0, attribute_proto::AttributeType::Ints),
643                        attr("keepdims", vec![], 1, attribute_proto::AttributeType::Int),
644                    ],
645                    ..Default::default()
646                }],
647                input: vec![value("x", 5)],
648                output: vec![value("m", 5)],
649                ..Default::default()
650            }),
651            ..Default::default()
652        }
653    }
654
655    /// The restated instance-norm mean is the `f64` mean to `f32` precision on a 256³ volume,
656    /// where tract's own `ReduceMean` is not (module docs, *Instance-norm statistics*). The
657    /// volume is a head-like layout: a constant background around a varying block, values ~0.1,
658    /// as squared deviations are.
659    #[test]
660    #[cfg(feature = "onnx")]
661    fn restated_instance_norm_mean_is_accurate() {
662        let n = CONFORM_DIM;
663        let x: Vec<f32> = (0..n * n * n)
664            .map(|i| {
665                let (a, b, c) = (i % n, (i / n) % n, i / (n * n));
666                let inside = [a, b, c].iter().all(|&v| (40..216).contains(&v));
667                if inside { 0.1 + 0.05 * ((i * 7919 % 1009) as f32 / 1009.0) } else { 0.003 }
668            })
669            .collect();
670        let exact = x.iter().map(|&v| v as f64).sum::<f64>() / x.len() as f64;
671        let mean_of = |proto: &tract_onnx::pb::ModelProto| -> f64 {
672            let model = OnnxModel::from_proto(proto).unwrap();
673            model.run_single(&Tensor::new(vec![1, 1, n, n, n], x.clone())).unwrap().data[0] as f64
674        };
675        let plain = mean_of(&spatial_mean_graph());
676        let mut proto = spatial_mean_graph();
677        pool_instance_norm_means(&mut proto).unwrap();
678        assert_eq!(proto.graph.as_ref().unwrap().node.len(), 2, "one AveragePool in front of the mean");
679        let pooled = mean_of(&proto);
680        let (e_plain, e_pooled) = ((plain - exact).abs() / exact, (pooled - exact).abs() / exact);
681        eprintln!("relative error of the mean: tract ReduceMean {e_plain:.2e}, restated {e_pooled:.2e}");
682        // Measured: 1.4e-2 for tract's ReduceMean, 2.7e-6 restated.
683        assert!(e_pooled < 1e-5, "restated mean off by {e_pooled:.2e}");
684        // If tract's own reduction became this accurate, the restatement could go.
685        assert!(e_plain > 1e-3, "tract's ReduceMean is now accurate ({e_plain:.2e}): restatement unneeded?");
686    }
687
688    /// A graph without an instance-norm mean is refused, so a re-export cannot skip the restatement.
689    #[test]
690    #[cfg(feature = "onnx")]
691    fn restatement_refuses_a_graph_without_spatial_means() {
692        let mut proto = spatial_mean_graph();
693        proto.graph.as_mut().unwrap().node[0].op_type = "ReduceMax".into();
694        assert!(pool_instance_norm_means(&mut proto).is_err());
695    }
696
697    // ---- parity against brainchop-cli's own pipeline: see tests/models_onnx.rs (mindgrab_*) ----
698
699    /// Minimal little-endian f32 C-order `.npy` reader.
700    fn read_npy_f32(path: &str) -> (Vec<usize>, Vec<f32>) {
701        let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
702        assert_eq!(&b[..6], b"\x93NUMPY");
703        let (hlen, off) = if b[6] == 1 {
704            (u16::from_le_bytes([b[8], b[9]]) as usize, 10)
705        } else {
706            (u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize, 12)
707        };
708        let header = std::str::from_utf8(&b[off..off + hlen]).unwrap();
709        assert!(header.contains("'<f4'") && header.contains("'fortran_order': False"), "{header}");
710        let shape_str = header.split("'shape': (").nth(1).unwrap().split(')').next().unwrap();
711        let shape: Vec<usize> = shape_str.split(',').filter_map(|s| s.trim().parse().ok()).collect();
712        let data = b[off + hlen..].as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect();
713        (shape, data)
714    }
715
716    /// Fixture directory plus its `mag.nii`.
717    type Ref = (String, crate::io::NiftiData);
718
719    /// `MINDGRAB_REF_DIR` holds the fixtures `scripts/onnx-export/ref_mindgrab.py` writes.
720    fn load_ref() -> Option<Ref> {
721        let dir = std::env::var("MINDGRAB_REF_DIR").ok()?;
722        let nii = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/mag.nii"))).unwrap();
723        Some((dir, nii))
724    }
725
726    fn read_conformed(dir: &str, name: &str) -> Vec<u8> {
727        let nii = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/{name}"))).unwrap();
728        assert_eq!(nii.dims, (CONFORM_DIM, CONFORM_DIM, CONFORM_DIM));
729        nii.data.iter().map(|&v| v as u8).collect()
730    }
731
732    /// Conform vs niimath `-conform`.
733    #[test]
734    #[ignore]
735    fn conform_matches_niimath() {
736        let Some((dir, mag)) = load_ref() else { return eprintln!("MINDGRAB_REF_DIR not set; skipping") };
737        let c = Conformed::new(&mag.data, mag.dims, &mag.affine).unwrap();
738        let want = read_conformed(&dir, "conformed.nii");
739        let diff: Vec<i32> = c.data.iter().zip(&want).map(|(&a, &b)| a as i32 - b as i32).collect();
740        let n_diff = diff.iter().filter(|&&d| d != 0).count();
741        let max = diff.iter().map(|d| d.abs()).max().unwrap();
742        eprintln!("{n_diff} conformed voxels differ, max |d| {max}");
743        // the histogram scaling rounds differently at a handful of voxels
744        assert!(max <= 1 && n_diff < 100, "{n_diff} voxels differ, max |d| = {max}");
745    }
746
747    /// Normalisation of niimath's conformed volume vs brainchop's `qnormalize`.
748    #[test]
749    #[ignore]
750    fn normalization_matches_brainchop() {
751        let Some((dir, ..)) = load_ref() else { return eprintln!("MINDGRAB_REF_DIR not set; skipping") };
752        let got = normalize(&read_conformed(&dir, "conformed.nii"));
753        let (shape, want) = read_npy_f32(&format!("{dir}/ref_input.npy"));
754        assert_eq!(shape, vec![1, 1, CONFORM_DIM, CONFORM_DIM, CONFORM_DIM]);
755        let err = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
756        assert!(err < 1e-6, "max |d| = {err}");
757    }
758
759    /// Argmax + largest component + reslice back, applied to the reference logits, reproduces
760    /// brainchop's post-processing (niimath `-bwlabel 26`, `-reslice_nn`) exactly.
761    #[test]
762    #[ignore]
763    #[cfg(feature = "onnx")]
764    fn postprocessing_matches_brainchop() {
765        let Some((dir, mag)) = load_ref() else { return eprintln!("MINDGRAB_REF_DIR not set; skipping") };
766        let (_, logits) = read_npy_f32(&format!("{dir}/ref_logits.npy"));
767        let n = CONFORM_DIM;
768        let mask = largest_component(&argmax_mask(&logits), (n, n, n), Connectivity::Full);
769        let want_c = read_conformed(&dir, "ref_mask_conformed.nii");
770        let diff_c = mask.iter().zip(&want_c).filter(|(&m, &w)| (m != 0) != (w != 0)).count();
771        eprintln!("{diff_c} conformed voxels differ after the largest component");
772
773        // the reslice itself, on brainchop's own conformed mask
774        let c = Conformed::new(&mag.data, mag.dims, &mag.affine).unwrap();
775        let back = c.reslice_back(&want_c.iter().map(|&w| (w != 0) as u8).collect::<Vec<_>>());
776        let want = crate::io::read_nifti_file(std::path::Path::new(&format!("{dir}/ref_mask.nii.gz"))).unwrap();
777        let diff = back.iter().zip(&want.data).filter(|(&m, &w)| (m != 0) != (w > 0.5)).count();
778        eprintln!("{diff} native voxels differ from niimath -reslice_nn");
779        assert_eq!(diff_c, 0, "largest component: {diff_c} differing voxels");
780        assert_eq!(diff, 0, "reslice: {diff} differing voxels");
781    }
782}