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}