Skip to main content

qsm_core/inversion/
nextqsm.rs

1//! NeXtQSM single-step deep-learning reconstruction (`onnx` feature).
2//!
3//! NeXtQSM (Cognolato 2023) is a *hybrid* method: a background-removal U-Net
4//! followed by a **6-step variational** dipole inversion. Each variational step
5//! is a gradient-descent update
6//! `x ← x − (λ_k·∇E_D + ∇E_R)·mask`, where `E_D` is an RMSE data-consistency
7//! term through the FFT dipole forward and `E_R = mean(|VarNet(x)|)` is a learned
8//! regularizer whose gradient is a backprop through the VarNet U-Net.
9//!
10//! The two U-Net pieces run as ONNX (`nextqsm-bf.onnx` = BFR forward;
11//! `nextqsm-vjp.onnx` = the regularizer gradient `∇ₓ mean(|VarNet(x)|)`, hand-coded
12//! as a forward graph). The FFT data-consistency gradient and the unroll live here
13//! in Rust (`rustfft`), since tract can't do in-graph FFT.
14//!
15//! Weights are not bundled; the caller passes both ONNX byte buffers.
16//!
17//! # Reference pipeline
18//!
19//! From Cognolato et al., *Medical Image Analysis* 84:102700 (2023; arXiv:2107.07752) and the
20//! authors' package (`QSMxT/NeXtQSM`):
21//!
22//! - **Training data:** hybrid 1 mm isotropic brains from 3T MP2RAGE segmentations with simulated
23//!   susceptibilities (paper §3.1); tested on QSM challenge 2.0 data and 0.75 mm 7T data, with an
24//!   FSL BET mask (paper §3.2); the paper reports robustness at 0.64 and 0.95 mm (§4.2).
25//! - **Input:** "an unwrapped frequency map (unitless and scaled to ppm) and brain mask"
26//!   (README) — a total field in ppm; the package masks it and zero-pads to a multiple of 64
27//!   (`processing/data_loader.py`). The unwrapping method is not specified.
28//! - **Resampling:** none; the data-consistency dipole kernel uses the NIfTI voxel size
29//!   (`predict_all.py:60-61`).
30//! - **Padding:** an odd axis gets one zero voxel in front, then each axis is padded equally on
31//!   both sides to a multiple of 64 (`tf_utils/misc.py` `add_padding`/`remove_padding`).
32//!
33//! # Parity
34//!
35//! Measured against the `nextqsm` 1.0.6 package (QSM-CI's `nextqsm` entry, `predict_all.py`, run
36//! on the same CPU) on in-vivo boxes of `prisma-bridge-run1`, all at 1 mm (QSM.rs#330):
37//!
38//! - Per step, given the same inputs, the BFR output, the data-consistency gradient and the
39//!   regulariser gradient each match TensorFlow to ~1e-6 relative.
40//! - End to end: 0.13 % NRMSE (r 0.9999992, max |Δ| 0.045 ppm) on 36×32×28, and 0.038 % (r
41//!   0.9999999, max |Δ| 0.022 ppm) on 35×31×27 (odd axes). Before the padding fix below, the odd
42//!   box was 70.6 % (r 0.73).
43//! - That residual is the method's own sensitivity to float32 rounding, not a port difference:
44//!   the regulariser's gradient goes through `sign(VarNet(x))`, so voxels whose output is near
45//!   zero flip sign between implementations. The package itself moves by 0.10 % NRMSE (max |Δ|
46//!   0.058 ppm) on the 36×32×28 box between an AVX-512 Intel and an AVX2 AMD CPU, and the same
47//!   glue on ONNX Runtime instead of tract lands 0.0057 % from it.
48//!
49//! # Agreement with the original
50//!
51//! `tests/models_onnx/field.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across the
52//! brain's edge) against its original's verified QSM-CI entry, nextqsm 1.0.6 (`nextqsm`)
53//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
54//! `tests/data/dl_field_qsmci_small.py`): **NRMSE 0.12 %**, r 0.9999993, max |Δ| 0.058 ppm
55//! (measured 2026-10-30; 0.97 % on QSM-CI's 128×160×96 case, 3.1 % on a 64³ box). Not rounding, but
56//! the size of the package's own CPU-to-CPU spread (0.10 %, max |Δ| 0.058 ppm on this box, under
57//! Parity above): the method's sensitivity to float32 rounding, not a port difference
58//! (astewartau/QSM.rs#330). The test holds it just above that floor.
59//!
60//! # Pipeline rules
61//!
62//! The facts above that a configuration can depart from are published as rules in
63//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `nextqsm.mask-bet`.
64
65use num_complex::Complex64;
66
67use crate::fft::{fft3d_real, ifft3d_real};
68use crate::grid::Grid;
69use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
70
71/// Trained data-consistency weights `λ_k` (one per variational step) shipped with
72/// the NeXtQSM checkpoint. Scalars, like a normalization constant.
73pub const NEXTQSM_LAMBDAS: [f64; 6] =
74    [15.141516, 15.005543, 16.733437, 14.574243, 7.072418, 0.7588475];
75
76/// Run NeXtQSM on a total field map.
77///
78/// * `total_field` — total field (ppm), column-major `(nx,ny,nz)`.
79/// * `mask` — binary brain mask (same layout).
80/// * `bdir` — B0 direction; `bf_onnx`/`vjp_onnx` — the two exported graphs.
81///
82/// The volume is zero-padded to a multiple of 64 (six pooling levels) as the package pads it,
83/// reconstructed, and cropped back. Returns susceptibility (ppm), masked.
84pub fn nextqsm(
85    total_field: &[f64],
86    mask: &[u8],
87    grid: &Grid,
88    bdir: (f64, f64, f64),
89    bf_onnx: &[u8],
90    vjp_onnx: &[u8],
91) -> Result<Vec<f64>, OnnxError> {
92    let (nx, ny, nz) = grid.dims;
93    let n = nx * ny * nz;
94    assert_eq!(total_field.len(), n);
95    assert_eq!(mask.len(), n);
96
97    let (bx, px) = pad64(nx);
98    let (by, py) = pad64(ny);
99    let (bz, pz) = pad64(nz);
100    let pgrid = Grid { dims: (px, py, pz), voxel_size: grid.voxel_size };
101    let np = px * py * pz;
102
103    let mut field_p = vec![0.0f64; np];
104    let mut mask_p = vec![0u8; np];
105    for z in 0..nz {
106        for y in 0..ny {
107            for x in 0..nx {
108                let s = x + nx * (y + ny * z);
109                let d = (x + bx) + px * ((y + by) + py * (z + bz));
110                field_p[d] = total_field[s];
111                mask_p[d] = mask[s];
112            }
113        }
114    }
115
116    let chi_p = nextqsm_padded(&field_p, &mask_p, &pgrid, bdir, bf_onnx, vjp_onnx, &NEXTQSM_LAMBDAS)?;
117
118    // Crop back.
119    let mut chi = vec![0.0f64; n];
120    for z in 0..nz {
121        for y in 0..ny {
122            for x in 0..nx {
123                chi[x + nx * (y + ny * z)] = chi_p[(x + bx) + px * ((y + by) + py * (z + bz))];
124            }
125        }
126    }
127    Ok(chi)
128}
129
130/// Where an axis of `s` voxels starts in its padded copy, and the padded length (the next
131/// multiple of 64), as the package pads it (`tf_utils/misc.py` `add_padding`): an odd axis first
132/// gets one zero voxel in front, then each axis is padded equally on both sides. So the volume
133/// starts at ⌈total/2⌉, not ⌊total/2⌋, on odd axes. The networks pool six times, so a one-voxel
134/// shift moves every pooling window: on a 35×31×27 in-vivo box, ⌊total/2⌋ gave 70.6 % NRMSE
135/// against the package and ⌈total/2⌉ gives 0.038 % (QSM.rs#330).
136fn pad64(s: usize) -> (usize, usize) {
137    let total = (64 - s % 64) % 64;
138    (total.div_ceil(2), s + total)
139}
140
141/// NeXtQSM on a grid already sized to a multiple of 64 (no internal padding).
142/// Exposed for validation against the reference trajectory.
143#[allow(clippy::too_many_arguments)]
144pub fn nextqsm_padded(
145    field: &[f64],
146    mask: &[u8],
147    grid: &Grid,
148    bdir: (f64, f64, f64),
149    bf_onnx: &[u8],
150    vjp_onnx: &[u8],
151    lambdas: &[f64],
152) -> Result<Vec<f64>, OnnxError> {
153    let (nx, ny, nz) = grid.dims;
154    let n = nx * ny * nz;
155    let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
156
157    // fftshift(dipole kernel) in the crate's column-major layout.
158    let kernel = dipole_kernel_shifted(grid, bdir);
159
160    // bf_logits = BFRnet(field·mask)·mask
161    let bf = OnnxModel::load(bf_onnx)?;
162    let masked_field: Vec<f64> = field.iter().zip(&maskf).map(|(&f, &m)| f * m).collect();
163    let bf_out = run_unet(&bf, &masked_field, grid)?;
164    let bf_logits: Vec<f64> = bf_out.iter().zip(&maskf).map(|(&v, &m)| v * m).collect();
165    let norm_bf = l2(&bf_logits);
166
167    let vjp = OnnxModel::load(vjp_onnx)?;
168    let mut x = bf_logits.clone();
169    for &lam in lambdas.iter() {
170        let dx = dipole_forward(&x, &kernel, grid);
171        // u = bf_logits − D(x);  ∇E_D = 100/(‖bf‖·‖u‖) · D(D(x) − bf)
172        let u: Vec<f64> = bf_logits.iter().zip(&dx).map(|(&b, &d)| b - d).collect();
173        let norm_u = l2(&u);
174        let resid: Vec<f64> = dx.iter().zip(&bf_logits).map(|(&d, &b)| d - b).collect();
175        let d_resid = dipole_forward(&resid, &kernel, grid);
176        let scale = if norm_bf > 0.0 && norm_u > 0.0 { 100.0 / (norm_bf * norm_u) } else { 0.0 };
177
178        // The VJP graph returns the unnormalized gradient ∇ₓ Σ|VarNet(x)|; divide
179        // by N to get the mean-gradient ∇ₓ mean(|VarNet(x)|) the regularizer uses.
180        let grad_r = run_unet(&vjp, &x, grid)?;
181        let inv_n = 1.0 / (n as f64);
182
183        for i in 0..n {
184            let de = lam * (scale * d_resid[i]) + grad_r[i] * inv_n;
185            x[i] = (x[i] - de) * maskf[i];
186        }
187    }
188    Ok(x)
189}
190
191/// Dipole forward `D(y) = real(ifft3(fft3(y) · kernel_shifted))`.
192pub(crate) fn dipole_forward(y: &[f64], kernel_shifted: &[f64], grid: &Grid) -> Vec<f64> {
193    let (nx, ny, nz) = grid.dims;
194    let mut spec = fft3d_real(y, nx, ny, nz);
195    for (s, &k) in spec.iter_mut().zip(kernel_shifted) {
196        *s *= Complex64::new(k, 0.0);
197    }
198    ifft3d_real(&spec, nx, ny, nz)
199}
200
201/// `fftshift(get_dipole_kernel_fourier(...))` in column-major layout.
202pub(crate) fn dipole_kernel_shifted(grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
203    let (nx, ny, nz) = grid.dims;
204    let (vx, vy, vz) = grid.voxel_size;
205    let eps = f32::EPSILON as f64;
206    let (bx, by, bz) = bdir;
207    let mut k = vec![0.0f64; nx * ny * nz];
208    // Centered grid, then fftshift => index shift by half in each dim.
209    let sh = |i: usize, nsz: usize| (i + nsz / 2) % nsz; // fftshift index
210    for z in 0..nz {
211        for y in 0..ny {
212            for x in 0..nx {
213                // centered coordinate = (idx − n/2); normalized by (n/2) and (2·vox)
214                let rx = ((x as f64) - (nx as f64) / 2.0) / ((nx as f64) / 2.0) / (2.0 * vx);
215                let ry = ((y as f64) - (ny as f64) / 2.0) / ((ny as f64) / 2.0) / (2.0 * vy);
216                let rz = ((z as f64) - (nz as f64) / 2.0) / ((nz as f64) / 2.0) / (2.0 * vz);
217                let r2 = rx * rx + ry * ry + rz * rz;
218                let dot = rx * bx + ry * by + rz * bz;
219                let val = 1.0 / 3.0 - (dot * dot) / (r2 + eps);
220                // write to fftshifted position
221                let (sx, sy, sz) = (sh(x, nx), sh(y, ny), sh(z, nz));
222                k[sx + nx * (sy + ny * sz)] = val;
223            }
224        }
225    }
226    k
227}
228
229/// Run a single-input/single-output 3D U-Net ONNX on a column-major volume.
230fn run_unet(model: &OnnxModel, vol: &[f64], grid: &Grid) -> Result<Vec<f64>, OnnxError> {
231    let (nx, ny, nz) = grid.dims;
232    let mut input = vec![0.0f32; nx * ny * nz];
233    for z in 0..nz {
234        for y in 0..ny {
235            for x in 0..nx {
236                input[(x * ny + y) * nz + z] = vol[x + nx * (y + ny * z)] as f32;
237            }
238        }
239    }
240    let out = model.run_single(&Tensor::new(vec![1, 1, nx, ny, nz], input))?;
241    let mut v = vec![0.0f64; nx * ny * nz];
242    for z in 0..nz {
243        for y in 0..ny {
244            for x in 0..nx {
245                v[x + nx * (y + ny * z)] = out.data[(x * ny + y) * nz + z] as f64;
246            }
247        }
248    }
249    Ok(v)
250}
251
252fn l2(v: &[f64]) -> f64 {
253    v.iter().map(|&a| a * a).sum::<f64>().sqrt()
254}
255
256/// Memory-bounded NeXtQSM via whole-algorithm overlap-tiling — the full VarNet gradient-descent
257/// loop runs on each patch sub-volume (each internally padded to /64). NeXtQSM's forward model is
258/// global, so tiling is **strongly off-design** and approximate; prefer a whole-volume run (e.g.
259/// QSMxT). See [`crate::inversion::tiled::tiled_volume_algorithm`].
260#[allow(clippy::too_many_arguments)]
261pub fn nextqsm_tiled(
262    total_field: &[f64],
263    mask: &[u8],
264    grid: &Grid,
265    bdir: (f64, f64, f64),
266    bf_onnx: &[u8],
267    vjp_onnx: &[u8],
268    cfg: &super::tiled::TileConfig,
269    progress: impl FnMut(usize, usize),
270) -> Result<Vec<f64>, OnnxError> {
271    super::tiled::tiled_volume_algorithm(
272        total_field, mask, grid, &super::tiled::tile_cost_of("nextqsm"), cfg,
273        |f, m, g| nextqsm(f, m, g, bdir, bf_onnx, vjp_onnx),
274        progress,
275    )
276}
277
278#[cfg(test)]
279mod tests {
280    use super::pad64;
281
282    #[test]
283    fn pads_like_the_package() {
284        // Even axes: equal padding on both sides.
285        assert_eq!(pad64(64), (0, 64));
286        assert_eq!(pad64(36), (14, 64));
287        assert_eq!(pad64(160), (16, 192));
288        // Odd axes: one voxel in front first, then equal padding (misc.add_padding).
289        assert_eq!(pad64(35), (15, 64));
290        assert_eq!(pad64(27), (19, 64));
291        assert_eq!(pad64(63), (1, 64));
292        assert_eq!(pad64(65), (32, 128));
293    }
294}