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(¶ms.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}