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
17use num_complex::Complex64;
18
19use crate::fft::{fft3d_real, ifft3d_real};
20use crate::grid::Grid;
21use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
22
23/// Trained data-consistency weights `λ_k` (one per variational step) shipped with
24/// the NeXtQSM checkpoint. Scalars, like a normalization constant.
25pub const NEXTQSM_LAMBDAS: [f64; 6] =
26    [15.141516, 15.005543, 16.733437, 14.574243, 7.072418, 0.7588475];
27
28/// Run NeXtQSM on a total field map.
29///
30/// * `total_field` — total field (ppm), column-major `(nx,ny,nz)`.
31/// * `mask` — binary brain mask (same layout).
32/// * `bdir` — B0 direction; `bf_onnx`/`vjp_onnx` — the two exported graphs.
33///
34/// The volume is zero-padded (centered) to a multiple of 64 (six pooling levels),
35/// reconstructed, and cropped back. Returns susceptibility (ppm), masked.
36pub fn nextqsm(
37    total_field: &[f64],
38    mask: &[u8],
39    grid: &Grid,
40    bdir: (f64, f64, f64),
41    bf_onnx: &[u8],
42    vjp_onnx: &[u8],
43) -> Result<Vec<f64>, OnnxError> {
44    let (nx, ny, nz) = grid.dims;
45    let n = nx * ny * nz;
46    assert_eq!(total_field.len(), n);
47    assert_eq!(mask.len(), n);
48
49    // Centered pad to a multiple of 64.
50    let pad = |s: usize| -> (usize, usize) {
51        let total = (64 - s % 64) % 64;
52        (total / 2, s + total)
53    };
54    let (bx, px) = pad(nx);
55    let (by, py) = pad(ny);
56    let (bz, pz) = pad(nz);
57    let pgrid = Grid { dims: (px, py, pz), voxel_size: grid.voxel_size };
58    let np = px * py * pz;
59
60    let mut field_p = vec![0.0f64; np];
61    let mut mask_p = vec![0u8; np];
62    for z in 0..nz {
63        for y in 0..ny {
64            for x in 0..nx {
65                let s = x + nx * (y + ny * z);
66                let d = (x + bx) + px * ((y + by) + py * (z + bz));
67                field_p[d] = total_field[s];
68                mask_p[d] = mask[s];
69            }
70        }
71    }
72
73    let chi_p = nextqsm_padded(&field_p, &mask_p, &pgrid, bdir, bf_onnx, vjp_onnx, &NEXTQSM_LAMBDAS)?;
74
75    // Crop back.
76    let mut chi = vec![0.0f64; n];
77    for z in 0..nz {
78        for y in 0..ny {
79            for x in 0..nx {
80                chi[x + nx * (y + ny * z)] = chi_p[(x + bx) + px * ((y + by) + py * (z + bz))];
81            }
82        }
83    }
84    Ok(chi)
85}
86
87/// NeXtQSM on a grid already sized to a multiple of 64 (no internal padding).
88/// Exposed for validation against the reference trajectory.
89#[allow(clippy::too_many_arguments)]
90pub fn nextqsm_padded(
91    field: &[f64],
92    mask: &[u8],
93    grid: &Grid,
94    bdir: (f64, f64, f64),
95    bf_onnx: &[u8],
96    vjp_onnx: &[u8],
97    lambdas: &[f64],
98) -> Result<Vec<f64>, OnnxError> {
99    let (nx, ny, nz) = grid.dims;
100    let n = nx * ny * nz;
101    let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
102
103    // fftshift(dipole kernel) in the crate's column-major layout.
104    let kernel = dipole_kernel_shifted(grid, bdir);
105
106    // bf_logits = BFRnet(field·mask)·mask
107    let bf = OnnxModel::load(bf_onnx)?;
108    let masked_field: Vec<f64> = field.iter().zip(&maskf).map(|(&f, &m)| f * m).collect();
109    let bf_out = run_unet(&bf, &masked_field, grid)?;
110    let bf_logits: Vec<f64> = bf_out.iter().zip(&maskf).map(|(&v, &m)| v * m).collect();
111    let norm_bf = l2(&bf_logits);
112
113    let vjp = OnnxModel::load(vjp_onnx)?;
114    let mut x = bf_logits.clone();
115    for &lam in lambdas.iter() {
116        let dx = dipole_forward(&x, &kernel, grid);
117        // u = bf_logits − D(x);  ∇E_D = 100/(‖bf‖·‖u‖) · D(D(x) − bf)
118        let u: Vec<f64> = bf_logits.iter().zip(&dx).map(|(&b, &d)| b - d).collect();
119        let norm_u = l2(&u);
120        let resid: Vec<f64> = dx.iter().zip(&bf_logits).map(|(&d, &b)| d - b).collect();
121        let d_resid = dipole_forward(&resid, &kernel, grid);
122        let scale = if norm_bf > 0.0 && norm_u > 0.0 { 100.0 / (norm_bf * norm_u) } else { 0.0 };
123
124        // The VJP graph returns the unnormalized gradient ∇ₓ Σ|VarNet(x)|; divide
125        // by N to get the mean-gradient ∇ₓ mean(|VarNet(x)|) the regularizer uses.
126        let grad_r = run_unet(&vjp, &x, grid)?;
127        let inv_n = 1.0 / (n as f64);
128
129        for i in 0..n {
130            let de = lam * (scale * d_resid[i]) + grad_r[i] * inv_n;
131            x[i] = (x[i] - de) * maskf[i];
132        }
133    }
134    Ok(x)
135}
136
137/// Dipole forward `D(y) = real(ifft3(fft3(y) · kernel_shifted))`.
138pub(crate) fn dipole_forward(y: &[f64], kernel_shifted: &[f64], grid: &Grid) -> Vec<f64> {
139    let (nx, ny, nz) = grid.dims;
140    let mut spec = fft3d_real(y, nx, ny, nz);
141    for (s, &k) in spec.iter_mut().zip(kernel_shifted) {
142        *s *= Complex64::new(k, 0.0);
143    }
144    ifft3d_real(&spec, nx, ny, nz)
145}
146
147/// `fftshift(get_dipole_kernel_fourier(...))` in column-major layout.
148pub(crate) fn dipole_kernel_shifted(grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
149    let (nx, ny, nz) = grid.dims;
150    let (vx, vy, vz) = grid.voxel_size;
151    let eps = f32::EPSILON as f64;
152    let (bx, by, bz) = bdir;
153    let mut k = vec![0.0f64; nx * ny * nz];
154    // Centered grid, then fftshift => index shift by half in each dim.
155    let sh = |i: usize, nsz: usize| (i + nsz / 2) % nsz; // fftshift index
156    for z in 0..nz {
157        for y in 0..ny {
158            for x in 0..nx {
159                // centered coordinate = (idx − n/2); normalized by (n/2) and (2·vox)
160                let rx = ((x as f64) - (nx as f64) / 2.0) / ((nx as f64) / 2.0) / (2.0 * vx);
161                let ry = ((y as f64) - (ny as f64) / 2.0) / ((ny as f64) / 2.0) / (2.0 * vy);
162                let rz = ((z as f64) - (nz as f64) / 2.0) / ((nz as f64) / 2.0) / (2.0 * vz);
163                let r2 = rx * rx + ry * ry + rz * rz;
164                let dot = rx * bx + ry * by + rz * bz;
165                let val = 1.0 / 3.0 - (dot * dot) / (r2 + eps);
166                // write to fftshifted position
167                let (sx, sy, sz) = (sh(x, nx), sh(y, ny), sh(z, nz));
168                k[sx + nx * (sy + ny * sz)] = val;
169            }
170        }
171    }
172    k
173}
174
175/// Run a single-input/single-output 3D U-Net ONNX on a column-major volume.
176fn run_unet(model: &OnnxModel, vol: &[f64], grid: &Grid) -> Result<Vec<f64>, OnnxError> {
177    let (nx, ny, nz) = grid.dims;
178    let mut input = vec![0.0f32; nx * ny * nz];
179    for z in 0..nz {
180        for y in 0..ny {
181            for x in 0..nx {
182                input[(x * ny + y) * nz + z] = vol[x + nx * (y + ny * z)] as f32;
183            }
184        }
185    }
186    let out = model.run_single(&Tensor::new(vec![1, 1, nx, ny, nz], input))?;
187    let mut v = vec![0.0f64; nx * ny * nz];
188    for z in 0..nz {
189        for y in 0..ny {
190            for x in 0..nx {
191                v[x + nx * (y + ny * z)] = out.data[(x * ny + y) * nz + z] as f64;
192            }
193        }
194    }
195    Ok(v)
196}
197
198fn l2(v: &[f64]) -> f64 {
199    v.iter().map(|&a| a * a).sum::<f64>().sqrt()
200}
201
202/// Memory-bounded NeXtQSM via whole-algorithm overlap-tiling — the full VarNet gradient-descent
203/// loop runs on each patch sub-volume (each internally padded to /64). NeXtQSM's forward model is
204/// global, so tiling is **strongly off-design** and approximate; prefer a whole-volume run (e.g.
205/// QSMxT). See [`crate::inversion::tiled::tiled_volume_algorithm`].
206#[allow(clippy::too_many_arguments)]
207pub fn nextqsm_tiled(
208    total_field: &[f64],
209    mask: &[u8],
210    grid: &Grid,
211    bdir: (f64, f64, f64),
212    bf_onnx: &[u8],
213    vjp_onnx: &[u8],
214    cfg: &super::tiled::TileConfig,
215    progress: impl FnMut(usize, usize),
216) -> Result<Vec<f64>, OnnxError> {
217    super::tiled::tiled_volume_algorithm(
218        total_field, mask, grid, 64, cfg,
219        |f, m, g| nextqsm(f, m, g, bdir, bf_onnx, vjp_onnx),
220        progress,
221    )
222}