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(¶ms.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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {});
331
332 for (i, &val) in chi.iter().enumerate() {
333 assert!(val.is_finite(), "Chi should be finite at index {}", i);
334 }
335 }
336}