Skip to main content

qsm_core/inversion/
nltv.rs

1//! Nonlinear total variation (NLTV) dipole inversion
2//!
3//! TV-regularised dipole inversion with a nonlinear data term on the wrapped phase:
4//!
5//! min_x || W (exp(i D x) - exp(i f)) ||_2^2 + lambda || grad x ||_1
6//!
7//! solved with ADMM. The data term gets its own auxiliary variable `z = D x`, whose
8//! subproblem `W^2 sin(z - f) + mu (z - (D x + u)) = 0` is solved per voxel with Newton's
9//! method.
10//!
11//! Reference:
12//! Milovic, C., Bilgic, B., Zhao, B., Acosta-Cabronero, J., Tejos, C. (2018).
13//! "Fast nonlinear susceptibility inversion with variational regularization."
14//! Magnetic Resonance in Medicine, 80(2):814-821. https://doi.org/10.1002/mrm.27073
15//!
16//! Ported line for line from QSM.jl's `nltv` (`src/inversion/nltv.jl`,
17//! https://github.com/kamesy/QSM.jl), including its defaults, its zero padding to a fast FFT
18//! size, and its starting point (`z - u = grad f` for the TV term).
19//!
20//! Two places where this port has to choose, because QSM.jl's code is not well defined:
21//! - QSM.jl's `W` is optional, but its unweighted path (`W = nothing`) never refreshes the FFT
22//!   of `x` before forming `D x` for the Newton step, and with `W = 1` and `mu = 1` the Newton
23//!   step's derivative `W^2/mu*cos(z - f) + 1` reaches zero: QSM.jl itself diverges on fields of
24//!   more than a few radians (the qsm-forward 7T phantom). This port therefore requires the
25//!   magnitude.
26//! - QSM.jl takes the data weight `W` from the caller and gives no rule for making it. Here
27//!   `W = min(2*mask*mag/max(mag), 1)`, the normalisation of FANSI's in vivo example by the same
28//!   authors as the paper (and of [`super::ndi`]); QSM.jl squares it.
29//! - QSM.jl allocates the data-term multiplier without initialising it; here it starts at zero.
30//!
31//! QSM.jl uses real FFTs, this port complex FFTs. When B0 is tilted from the grid axes the
32//! dipole kernel is not symmetric on the Nyquist planes, and the two would treat those bins
33//! differently (about 1e-4 ppm RMS on a 3T scan tilted by 6 degrees); before each inverse FFT
34//! this port therefore replaces the spectrum by the Hermitian one QSM.jl's `irfft` reads from
35//! its stored half ([`crate::fft::c2r_spectrum`], astewartau/QSM.rs#329, from 2026-10-29). Not
36//! fixture-tested (QSM.jl's `nltv` is not reproducible on small arrays; see below).
37//!
38//! QSM.jl is this method's reference: QSM.jl, Kames's maintained successor to QSM.m, is the implementation
39//! designated for this family (QSM.rs's reference rule: the authors' code where it
40//! exists, otherwise one designated implementation per family, the others' values recorded).
41//!
42//! # Agreement with QSM.jl
43//!
44//! On the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, V-SHARP
45//! local field and brain mask), against QSM.jl in the QSM-CI `julia-qsm` image (`sha256:b3c048ac…`,
46//! QSM.jl fde4b92), both outputs saved as float32:
47//! with QSM.jl given this port's data weight `W` and the field in radians at the first echo
48//! time, NRMSE 1.8e-10 %, max |Δ| 1.2e-10 ppm (195 iterations here). QSM.jl's `nltv` cannot
49//! be run on small arrays for a fixture test: its data-term multiplier `ue` is allocated with
50//! `similar` and never initialised, and on small arrays (which reuse freed memory) its output is
51//! NaN and differs between runs. The fresh, zeroed allocations of the in-vivo arrays start it at
52//! zero, as this port does.
53//!
54//! # Units
55//! Like the other inversions, NLTV takes the local field in ppm and returns ppm. The nonlinear
56//! data term needs the phase in radians, so the field is converted with `2*pi*gamma*b0*te`
57//! first and the result converted back at the end. `b0` and `echo_times` are scan parameters
58//! that the pipeline fills from metadata, and [`NltvParams::te`] picks the echo time the
59//! conversion uses (by default the reference's choice; see there).
60//!
61//! # Reference pipeline
62//! QSM.jl's README example (commit fde4b92, L35-52), the pipeline its author ships for all its
63//! inversions: brain mask by FSL BET on the last echo's magnitude (`-f 0.5`), eroded by 5
64//! voxels; Laplacian unwrapping of each echo (which also removes harmonic background), each echo
65//! scaled to ppm by its own TE; V-SHARP with QSM.jl's defaults (radii `18*min(vsz)` down to
66//! `2*max(vsz)` mm in steps of `2*max(vsz)`, threshold 0.05; `src/bgremove/sharp.jl` L136-137).
67//! QSM.jl's inversions take 3D or 4D (per-echo) fields; the pipeline here inverts one combined
68//! field.
69
70use crate::utils::gradient::{bdiv_inplace, fgrad_inplace};
71use crate::utils::padding::{pad3d, unpad3d};
72use crate::utils::apply_mask_zero;
73use crate::{EchoTime, Grid};
74use super::admm::prepare_fansi_spectral;
75use num_complex::Complex64;
76
77/// NLTV algorithm parameters (QSM.jl `nltv` defaults)
78#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
79#[derive(Clone, Debug)]
80pub struct NltvParams {
81    /// TV regularisation parameter
82    pub lambda: f64,
83    /// ADMM penalty for the TV term; `None` uses QSM.jl's default, `100 * lambda`
84    pub rho: Option<f64>,
85    /// ADMM penalty for the data term
86    pub mu: f64,
87    /// Stopping tolerance on ||x - x_prev|| / ||x||
88    pub tol: f64,
89    /// Maximum ADMM iterations
90    pub max_iter: usize,
91    /// Stopping tolerance for the Newton iterations of the data subproblem
92    pub tol_newton: f64,
93    /// Maximum Newton iterations per ADMM iteration
94    pub max_iter_newton: usize,
95    /// Field strength in Tesla (scan parameter, for the radian conversion)
96    pub b0: f64,
97    /// Echo times in seconds, in acquisition order (scan parameter, for the radian
98    /// conversion).
99    pub echo_times: Vec<f64>,
100    /// Which echo time the radian conversion uses. Default [`EchoTime::First`]: QSM.jl's `nltv`
101    /// (`src/inversion/nltv.jl`, fde4b92) takes the field or phase of each echo separately and
102    /// gives no rule for a field combined over echoes, so the first echo time, the default where
103    /// the reference does not say.
104    pub te: EchoTime,
105    /// Zero padding per axis, QSM.jl's `pad` (default `[0, 0, 0]`, QSM.jl's): `< 0` does not
106    /// pad that axis, `≥ 0` pads it by at least that many voxels, rounded up to QSM.jl's fast
107    /// FFT size (see [`with_qsmjl_padding`](crate::utils::padding::with_qsmjl_padding)).
108    pub pad: [i64; 3],
109}
110
111impl Default for NltvParams {
112    fn default() -> Self {
113        Self {
114            lambda: 1e-3,
115            rho: None,
116            mu: 1.0,
117            tol: 1e-3,
118            max_iter: 250,
119            tol_newton: 1e-6,
120            max_iter_newton: 10,
121            b0: 3.0,
122            echo_times: vec![0.02],
123            te: EchoTime::First,
124            pad: crate::utils::padding::QSMJL_PAD,
125        }
126    }
127}
128
129#[inline]
130fn shrink(v: f64, t: f64) -> f64 {
131    if v.abs() <= t { 0.0 } else { (v.abs() - t).copysign(v) }
132}
133
134/// NLTV dipole inversion (QSM.jl `nltv`)
135///
136/// # Arguments
137/// * `local_field` - Local field in ppm (nx * ny * nz)
138/// * `mask` - Binary mask (nx * ny * nz); the result is zeroed outside it
139/// * `magnitude` - Magnitude image (required), made into the data weight `W` (see module docs;
140///   zero in the padding)
141/// * `grid` - Volume grid (dimensions and voxel sizes)
142/// * `bdir` - B0 field direction
143/// * `params` - NLTV parameters
144/// * `progress` - Progress callback `(iteration, max_iter)`
145///
146/// # Returns
147/// Susceptibility map in ppm, masked to the ROI.
148pub fn nltv(
149    local_field: &[f64],
150    mask: &[u8],
151    magnitude: &[f64],
152    grid: &Grid,
153    bdir: (f64, f64, f64),
154    params: &NltvParams,
155    mut progress: impl FnMut(usize, usize),
156) -> Vec<f64> {
157    let rho = params.rho.unwrap_or(100.0 * params.lambda);
158    let mu = params.mu;
159    let lambda_over_rho = params.lambda / rho;
160    let tol_newton2 = params.tol_newton * params.tol_newton;
161
162    // ppm -> radians, as the nonlinear data term needs.
163    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, "NLTV");
164
165    // Zero padding to a fast FFT size (QSM.jl `pad`, `(0, 0, 0)` by default). Every step is
166    // shift-equivariant, so placing the data in the corner is the same as centring it.
167    let dims = grid.dims;
168    let [px, py, pz] = crate::utils::padding::qsmjl_fast_fft_size([dims.0, dims.1, dims.2], params.pad);
169    let pdims = (px, py, pz);
170    let pgrid = Grid::new(pdims.0, pdims.1, pdims.2, grid.vsx(), grid.vsy(), grid.vsz());
171    let n = pgrid.n_total();
172
173    let phase: Vec<f64> = local_field.iter().map(|&f| f * ppm_to_rad).collect();
174    let fp = pad3d(&phase, dims, pdims);
175    // W^2 / mu
176    let mag_max = magnitude.iter().cloned().fold(0.0f64, f64::max);
177    let w: Vec<f64> = (0..magnitude.len())
178        .map(|i| {
179            if mask[i] == 0 || mag_max <= 0.0 { 0.0 } else { (2.0 * magnitude[i] / mag_max).min(1.0) }
180        })
181        .collect();
182    let w2: Vec<f64> = pad3d(&w, dims, pdims).into_iter().map(|v| v * v / mu).collect();
183
184    let (mut fft_ws, d, l) = prepare_fansi_spectral(&pgrid, bdir);
185
186    // iA = (mu D^H D - rho Laplacian)^-1
187    let ia: Vec<f64> = (0..n)
188        .map(|i| {
189            let a = mu * d[i] * d[i] + rho * l[i];
190            if a == 0.0 { 0.0 } else { 1.0 / a }
191        })
192        .collect();
193
194    let mut f_hat = vec![Complex64::new(0.0, 0.0); n];
195    let mut x_hat = vec![Complex64::new(0.0, 0.0); n];
196    let mut work = vec![Complex64::new(0.0, 0.0); n];
197
198    // previous iterate; only the first convergence check sees this starting value
199    let mut x = vec![0.0; n];
200    let mut x0 = vec![0.0; n];
201    let (mut ux, mut uy, mut uz) = (vec![0.0; n], vec![0.0; n], vec![0.0; n]);
202    let (mut dx, mut dy, mut dz) = (vec![0.0; n], vec![0.0; n], vec![0.0; n]);
203    let mut ue = vec![0.0; n];
204    let mut ze = vec![0.0; n];
205    let mut div = vec![0.0; n];
206
207    // d = z - u = grad f to start
208    fgrad_inplace(&mut dx, &mut dy, &mut dz, &fp, &pgrid);
209
210    for it in 1..=params.max_iter {
211        progress(it, params.max_iter);
212        std::mem::swap(&mut x, &mut x0);
213
214        // x-subproblem: x = iA (rho grad^T (z - u) + mu D^H (z_e - u_e)), grad^T = -bdiv
215        bdiv_inplace(&mut div, &dx, &dy, &dz, &pgrid);
216        for i in 0..n {
217            x_hat[i] = Complex64::new(-div[i], 0.0);
218        }
219        fft_ws.fft3d(&mut x_hat);
220        for i in 0..n {
221            x_hat[i] = rho * ia[i] * x_hat[i] + f_hat[i];
222            work[i] = x_hat[i];
223        }
224        // QSM.jl's irfft of its half spectrum (astewartau/QSM.rs#329; see the module docs)
225        crate::fft::c2r_spectrum(&mut work, pdims.0, pdims.1, pdims.2);
226        fft_ws.ifft3d(&mut work);
227        for i in 0..n {
228            x[i] = work[i].re;
229        }
230
231        // convergence check
232        let (mut ndx, mut nx) = (0.0, 0.0);
233        for i in 0..n {
234            let (a, b) = (x[i], x0[i]);
235            ndx += (a - b) * (a - b);
236            nx += a * a;
237        }
238        if ndx.sqrt() < params.tol * nx.sqrt() || it == params.max_iter {
239            break;
240        }
241
242        // TV term: z = shrink(grad x + u, lambda/rho), u = u + grad x - z, d = z - u
243        fgrad_inplace(&mut dx, &mut dy, &mut dz, &x, &pgrid);
244        for (u, g) in [(&mut ux, &mut dx), (&mut uy, &mut dy), (&mut uz, &mut dz)] {
245            for i in 0..n {
246                let v = u[i] + g[i];
247                let z = shrink(v, lambda_over_rho);
248                u[i] = v - z;
249                g[i] = z - v + z;
250            }
251        }
252
253        // Data term: v = D x + u_e; Newton on W^2/mu sin(z - f) + z - v = 0, from z = v
254        for i in 0..n {
255            work[i] = x_hat[i] * d[i];
256        }
257        crate::fft::c2r_spectrum(&mut work, pdims.0, pdims.1, pdims.2);
258        fft_ws.ifft3d(&mut work);
259        for i in 0..n {
260            let v = work[i].re + ue[i];
261            ze[i] = v;
262            ue[i] = v;
263        }
264        for _ in 0..params.max_iter_newton {
265            let (mut nd, mut nz) = (0.0, 0.0);
266            for i in 0..n {
267                let (v, w, z) = (ue[i], w2[i], ze[i]);
268                let (s, c) = (z - fp[i]).sin_cos();
269                let delta = (w * s + z - v) / (w * c + 1.0);
270                ze[i] = z - delta;
271                nd += delta * delta;
272                nz += z * z;
273            }
274            if nd < nz * tol_newton2 {
275                break;
276            }
277        }
278        // u_e = v - z; data part of the next right-hand side: mu D^H (z - u_e) iA
279        for i in 0..n {
280            let (v, z) = (ue[i], ze[i]);
281            ue[i] = v - z;
282            f_hat[i] = Complex64::new(z - v + z, 0.0);
283        }
284        fft_ws.fft3d(&mut f_hat);
285        for i in 0..n {
286            f_hat[i] *= mu * d[i] * ia[i];
287        }
288    }
289
290    let mut chi = unpad3d(&x, pdims, dims);
291    for v in chi.iter_mut() {
292        *v /= ppm_to_rad;
293    }
294    apply_mask_zero(&mut chi, mask);
295    chi
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn small_params() -> NltvParams {
303        NltvParams { max_iter: 20, ..NltvParams::default() }
304    }
305
306    #[test]
307    fn test_nltv_zero_field() {
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 mag = vec![1.0; field.len()];
313        let chi = nltv(&field, &mask, &mag, &grid, (0.0, 0.0, 1.0), &small_params(), |_, _| {});
314        assert!(chi.iter().all(|v| v.abs() < 1e-12), "zero field should give zero chi");
315    }
316
317    #[test]
318    fn test_nltv_magnitude_weight() {
319        // a dimmer region gets a smaller weight and changes the result
320        let (nx, ny, nz) = (10, 9, 8);
321        let nt = nx * ny * nz;
322        let field: Vec<f64> = (0..nt).map(|i| ((i * 37 % 101) as f64 - 50.0) * 1e-3).collect();
323        let mask = vec![1u8; nt];
324        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
325        let p = small_params();
326        let mag = vec![5.0; nt];
327        let a = nltv(&field, &mask, &mag, &grid, (0.1, 0.0, 1.0), &p, |_, _| {});
328        let mut mag2 = mag.clone();
329        mag2[..nt / 2].iter_mut().for_each(|v| *v = 1.0);
330        let c = nltv(&field, &mask, &mag2, &grid, (0.1, 0.0, 1.0), &p, |_, _| {});
331        assert!(a.iter().all(|v| v.is_finite()));
332        assert!(a.iter().zip(&c).any(|(x, y)| (x - y).abs() > 1e-9));
333    }
334
335    #[test]
336    fn test_nltv_masks_output() {
337        let n = 11; // padded to 12
338        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 1e-4).collect();
339        let mut mask = vec![1u8; n * n * n];
340        mask[0] = 0;
341        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
342        let mag = vec![1.0; field.len()];
343        let chi = nltv(&field, &mask, &mag, &grid, (0.0, 0.0, 1.0), &small_params(), |_, _| {});
344        assert_eq!(chi.len(), n * n * n);
345        assert_eq!(chi[0], 0.0);
346        assert!(chi.iter().all(|v| v.is_finite()));
347    }
348}