Skip to main content

qsm_core/inversion/
ndi.rs

1//! Nonlinear Dipole Inversion (NDI) for QSM
2//!
3//! Solves the dipole inversion problem with a nonlinear data-consistency term,
4//! modelling the wrapped phase directly via `sin(Dx - phase)` and minimizing it
5//! with a simple L2-regularized gradient descent:
6//!
7//! min_x || W .* (exp(i*Dx) - exp(i*phase)) ||^2 + alpha ||x||^2
8//!
9//! which yields the update
10//!
11//! x <- x - tau * D^H(W^2 .* sin(Dx - phase)) - tau*alpha*x
12//!
13//! `W` is the data weight: the magnitude, normalised as in FANSI's in vivo
14//! example (`min(2*mask*mag/max(mag), 1)`). The magnitude is required: with a
15//! uniform weight the update can diverge where the phase nears ±π.
16//!
17//! Because the dipole kernel `D` is real, `conj(D) = D` and the adjoint is just
18//! another forward dipole application.
19//!
20//! Reference:
21//! Polak, D., Chatnuntawech, I., Yoon, J., Iyer, S.S., Milovic, C., Lee, J.,
22//! Bachert, P., Adalsteinsson, E., Setsompop, K., Bilgic, B. (2020).
23//! "Nonlinear dipole inversion (NDI) enables robust quantitative susceptibility
24//! mapping (QSM)." NMR in Biomedicine, 33(12):e4271.
25//! https://doi.org/10.1002/nbm.4271
26//!
27//! Ported from FANSI's `ndi.m`, and `ndi_auto.m` for the automatic stopping
28//! criterion (Milovic et al., ISMRM 2021, p. 3982)
29//! (https://gitlab.com/cmilovic/FANSI-toolbox).
30//!
31//! # Units
32//! Like the other inversions, NDI takes the local field in ppm and returns ppm. FANSI's solver
33//! works on the phase in radians, so the field is converted with `2*pi*gamma*b0*te` first and the
34//! result converted back at the end; left in ppm, `sin(x) ~ x` and the data term would not be
35//! nonlinear. `b0` and `echo_times` are scan parameters that the pipeline fills from metadata, and
36//! [`NdiParams::te`] picks the echo time the conversion uses (by default the reference's choice;
37//! see there).
38//!
39//! # Reference pipeline
40//! - Paper (Polak et al. 2020, Methods): 3T data at 1 mm isotropic, single TE 25 ms, and 7T
41//!   Wave-CAIPI at 0.5 mm isotropic, TE 19 ms; BET brain masking, Laplacian unwrapping and SMV
42//!   background filtering; a small Tikhonov term ("lambda = 0.1%") to stop over-fitting.
43//! - FANSI `script_DICOM_Philips.m` (commit 93044a3) runs `ndi` at the end of the same chain as
44//!   nlTV (see [`super::fansi`](mod@super::fansi) for its masking, SEGUE unwrapping, multi-echo fit and MSMV
45//!   background removal): input `local*deltaTE` and weight `m0.*mask./cnoise`, normalised
46//!   (L457-461), 300 iterations (L463). `ndi.m` itself defaults the weight to ones (L51-56).
47//! - The defaults here are `ndi.m`'s (tau 2, alpha 1e-5, 100 iterations).
48//!
49//! # Parity
50//! Against the verified original (QSM-CI pair `ndi`; FANSI-toolbox 93044a3, identical to
51//! licensed MATLAB R2023b), final-map χ NRMSE at both sides' defaults on QSM-CI's simulated head
52//! and the KKI travelling head (QSM-CI parity run 41403629314, QSM.rs#399–#403): sim 0.0011 %, KKI 0.00009 %, from
53//! 10.6 % / 9.5 % (sim / KKI) before. The kernel was the whole gap. What remains is
54//! single (MATLAB) vs double (here) precision and FANSI's γ, 42.58 against the crate's 42.576
55//! (the wrappers keep theirs). Fixture: `tests/fansi_qsmci_parity.rs` (relative L2 below 1e-5
56//! on an oblique, anisotropic phantom).
57//!
58//! # Pipeline rules
59//!
60//! The facts above that a configuration can depart from are published as rules in
61//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `fansi.no-laplacian-multi-echo`, `ndi.te-first`.
62
63use crate::fft::Fft3dWorkspace;
64use crate::utils::apply_mask_zero;
65use crate::{EchoTime, Grid};
66use super::admm::{prepare_fansi_kernel, FansiKernel};
67use num_complex::Complex64;
68
69/// NDI algorithm parameters
70#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
71#[derive(Clone, Debug)]
72pub struct NdiParams {
73    /// Gradient-descent step size
74    pub tau: f64,
75    /// L2 regularization weight
76    pub alpha: f64,
77    /// Number of iterations (the maximum, with `auto_stop`)
78    pub max_iter: usize,
79    /// Stop once the high-frequency energy of the estimate along the dipole cone exceeds that
80    /// just off it (FANSI `ndi_auto.m`)
81    pub auto_stop: bool,
82    /// Field strength in Tesla (scan parameter, for the radian conversion)
83    pub b0: f64,
84    /// Echo times in seconds, in acquisition order (scan parameter, for the radian
85    /// conversion).
86    pub echo_times: Vec<f64>,
87    /// Which echo time the radian conversion uses. Default [`EchoTime::First`]. FANSI's
88    /// multi-echo example, `script_DICOM_Philips.m`, scales the field by `deltaTE = TE(echo2) -
89    /// TE(echo1)` (FANSI-toolbox 93044a3, line 364) before `ndi` (lines 459, 465), and its comment
90    /// on that line says "You may use also any individual echo time". On the qsm-forward phantom
91    /// (echoes 4/12/20/28 ms; FANSI's discrete kernel, measured for QSM.rs#418 with the integration tests' settings; XSIM / NRMSE / r), the magnitude weight at the first
92    /// echo gives the best NRMSE and r (0.635 / 52.0% / 0.854) against 0.636 / 54.0% / 0.842 at
93    /// the echo spacing. (With the continuous kernel, #276: 0.636 / 51.4% / 0.858 against
94    /// 0.637 / 53.3% / 0.846.)
95    ///
96    /// The magnitude weight is required, although `ndi.m` weights by ones when given none
97    /// (lines 51-56): with a flat weight NDI is worse on that phantom (0.625 / 58.8% / 0.818 at
98    /// the first echo, 0.630 / 57.3% / 0.825 at the spacing; continuous kernel 0.628 / 57.5% /
99    /// 0.822 at the first echo).
100    pub te: EchoTime,
101    /// The dipole kernel. Default [`FansiKernel::Discrete`], FANSI's
102    /// `dipole_kernel_angulated` as the original is run with; [`FansiKernel::Continuous`] is
103    /// the crate's continuous kernel (see [`FansiKernel`] for the decision and the measurements).
104    pub kernel: FansiKernel,
105}
106
107impl Default for NdiParams {
108    /// FANSI `ndi.m` defaults.
109    fn default() -> Self {
110        Self {
111            tau: 2.0,
112            alpha: 1e-5,
113            max_iter: 100,
114            auto_stop: false,
115            b0: 3.0,
116            echo_times: vec![0.02],
117            te: EchoTime::First,
118            kernel: FansiKernel::Discrete,
119        }
120    }
121}
122
123impl NdiParams {
124    /// FANSI `ndi_auto.m` defaults: automatic stopping, at most 500 iterations.
125    pub fn auto() -> Self {
126        Self {
127            tau: 1.0,
128            alpha: 1e-6,
129            max_iter: 500,
130            auto_stop: true,
131            ..Self::default()
132        }
133    }
134}
135
136/// The two k-space bands `ndi_auto.m` compares (FANSI `create_freqmasks_auto.m`): a radial
137/// shell of high frequencies, split by |D| into a band close to the cone (0.15 < |D| <= 0.2)
138/// and one further off it (0.225 < |D| <= 0.275). `k` is the kernel in FFT order.
139fn auto_stop_masks(grid: &Grid, k: &[f64]) -> (Vec<bool>, Vec<bool>) {
140    let (nx, ny, nz) = grid.dims;
141    let res = [grid.vsx(), grid.vsy(), grid.vsz()];
142    let rmax = res.iter().cloned().fold(f64::MIN, f64::max);
143    let (rin, rout) = (rmax * 0.65 / 2.0, rmax * 0.95 / 2.0);
144    // MATLAB builds the shell on a centred grid ((1:N) - (1 + floor(N/2))) * res/N and then
145    // fftshifts it; index i in FFT order is centred index (i + floor(N/2)) mod N.
146    let coord = |i: usize, n: usize, r: f64| {
147        let c = (i + n / 2) % n;
148        (c as f64 - (n / 2) as f64) * r / n as f64
149    };
150    let mut m1 = vec![false; k.len()];
151    let mut m2 = vec![false; k.len()];
152    for z in 0..nz {
153        let kz = coord(z, nz, res[2]);
154        for y in 0..ny {
155            let ky = coord(y, ny, res[1]);
156            for x in 0..nx {
157                let kx = coord(x, nx, res[0]);
158                let k2 = kx * kx + ky * ky + kz * kz;
159                if k2 > rout * rout || k2 < rin * rin {
160                    continue;
161                }
162                let i = x + nx * (y + ny * z);
163                let a = k[i].abs();
164                m1[i] = a > 0.15 && a <= 0.2;
165                m2[i] = a > 0.225 && a <= 0.275;
166            }
167        }
168    }
169    (m1, m2)
170}
171
172/// Apply the forward dipole operator `D * x` in-place buffers.
173///
174/// Equivalent to MATLAB `susc2field(kernel, x) = real(ifftn(D .* fftn(x)))`.
175/// Since the dipole kernel `k` is real, `conj(k) = k`, so the same routine
176/// computes the adjoint `D^H`.
177fn apply_dipole(
178    fft_ws: &mut Fft3dWorkspace,
179    k: &[f64],
180    x: &[f64],
181    buf: &mut [Complex64],
182    out: &mut [f64],
183) {
184    for i in 0..x.len() {
185        buf[i] = Complex64::new(x[i], 0.0);
186    }
187    fft_ws.fft3d(buf);
188    for i in 0..x.len() {
189        buf[i] *= k[i];
190    }
191    fft_ws.ifft3d(buf);
192    for i in 0..x.len() {
193        out[i] = buf[i].re;
194    }
195}
196
197/// Nonlinear Dipole Inversion (NDI)
198///
199/// # Arguments
200/// * `local_field` - Local field in ppm (nx * ny * nz)
201/// * `mask` - Binary mask (nx * ny * nz), non-zero = inside ROI
202/// * `magnitude` - Magnitude image, the data weight (see module docs)
203/// * `grid` - Volume grid (dimensions and voxel sizes)
204/// * `bdir` - B0 field direction
205/// * `params` - NDI parameters
206/// * `progress` - Progress callback `(iteration, max_iter)`
207///
208/// # Returns
209/// Estimated susceptibility map (ppm-scale), masked to the ROI.
210pub fn ndi(
211    local_field: &[f64],
212    mask: &[u8],
213    magnitude: &[f64],
214    grid: &Grid,
215    bdir: (f64, f64, f64),
216    params: &NdiParams,
217    mut progress: impl FnMut(usize, usize),
218) -> Vec<f64> {
219    let n = grid.n_total();
220
221    let (mut fft_ws, k_kernel) = prepare_fansi_kernel(grid, bdir, params.kernel);
222
223    // ppm -> radians, as FANSI expects.
224    // FANSI's scripts use `gyro = 2*pi*42.58`; QSM.rs uses GAMMA_BAR_MHZ_PER_T, which is closer
225    // to the CODATA shielded-proton value (see `crate::constants`).
226    let ppm_to_rad = 2.0 * std::f64::consts::PI * crate::constants::GAMMA_BAR_MHZ_PER_T * params.b0 * params.te.seconds(&params.echo_times, "NDI");
227    let phase: Vec<f64> = local_field.iter().map(|&f| f * ppm_to_rad).collect();
228
229    // W = min(2*mask*mag/max(mag), 1) (FANSI script_invivo.m), squared as in ndi.m.
230    let mag_max = magnitude.iter().cloned().fold(0.0f64, f64::max);
231    let w: Vec<f64> = (0..n)
232        .map(|i| {
233            if mask[i] == 0 || mag_max <= 0.0 {
234                return 0.0;
235            }
236            let v = (2.0 * magnitude[i] / mag_max).min(1.0);
237            v * v
238        })
239        .collect();
240    let stop_masks = params.auto_stop.then(|| auto_stop_masks(grid, &k_kernel));
241
242    let mut x = vec![0.0f64; n];
243
244    // Reusable buffers.
245    let mut buf = vec![Complex64::new(0.0, 0.0); n];
246    let mut phix = vec![0.0f64; n];
247    let mut resid = vec![0.0f64; n];
248    let mut grad = vec![0.0f64; n];
249
250    for t in 0..params.max_iter {
251        progress(t + 1, params.max_iter);
252
253        // phix = D * x
254        apply_dipole(&mut fft_ws, &k_kernel, &x, &mut buf, &mut phix);
255
256        // resid = W .* sin(phix - phase)
257        for i in 0..n {
258            resid[i] = w[i] * (phix[i] - phase[i]).sin();
259        }
260
261        // grad = D^H * resid  (conj(kernel) = kernel, real)
262        apply_dipole(&mut fft_ws, &k_kernel, &resid, &mut buf, &mut grad);
263
264        // x <- x - tau*grad - tau*alpha*x
265        for i in 0..n {
266            x[i] -= params.tau * grad[i] + params.tau * params.alpha * x[i];
267        }
268
269        // ndi_auto.m: stop once the mean |FFT(x)| in the band near the cone exceeds the band
270        // off it (MATLAB's `t > 3` is 1-based, i.e. from the fourth iteration on).
271        if let Some((m1, m2)) = &stop_masks {
272            for i in 0..n {
273                buf[i] = Complex64::new(x[i], 0.0);
274            }
275            fft_ws.fft3d(&mut buf);
276            let (mut s1, mut n1, mut s2, mut n2) = (0.0, 0usize, 0.0, 0usize);
277            for i in 0..n {
278                if m1[i] {
279                    s1 += buf[i].norm();
280                    n1 += 1;
281                } else if m2[i] {
282                    s2 += buf[i].norm();
283                    n2 += 1;
284                }
285            }
286            if t > 2 && n1 > 0 && n2 > 0 && s1 / n1 as f64 > s2 / n2 as f64 {
287                break;
288            }
289        }
290    }
291
292    // Back to ppm.
293    for v in &mut x {
294        *v /= ppm_to_rad;
295    }
296
297    apply_mask_zero(&mut x, mask);
298    x
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_ndi_zero_field() {
307        // Zero field should give zero susceptibility.
308        let n = 8;
309        let field = vec![0.0; n * n * n];
310        let mask = vec![1u8; n * n * n];
311        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
312        let params = NdiParams { max_iter: 20, ..Default::default() };
313
314        let chi = ndi(&field, &mask, &vec![1.0; n * n * n], &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
315
316        for &val in chi.iter() {
317            assert!(val.abs() < 1e-6, "Zero field should give zero chi, got {}", val);
318        }
319    }
320
321    #[test]
322    fn test_ndi_finite() {
323        // Result should be finite for a small ramp field.
324        let n = 8;
325        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
326        let mask = vec![1u8; n * n * n];
327        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
328        let params = NdiParams { max_iter: 20, ..Default::default() };
329
330        let chi = ndi(&field, &mask, &vec![1.0; n * n * n], &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
331
332        for (i, &val) in chi.iter().enumerate() {
333            assert!(val.is_finite(), "Chi should be finite at index {}", i);
334        }
335    }
336}