Skip to main content

qsm_core/inversion/
l1qsm.rs

1//! L1-QSM: nonlinear L1 data-fidelity dipole inversion with TV regularization
2//!
3//! Solves the nonlinear QSM inverse problem with an L1 data-fidelity term
4//! (robust to phase outliers / noise) and a total-variation prior:
5//!
6//! min_x  || W .* ( exp(i * D x) - exp(i * phi) ) ||_1  +  alpha1 * ||grad(x)||_1
7//!
8//! where `D` is the dipole operator and `phi` the local field phase in radians.
9//! The nonlinear fidelity is linearized around the current auxiliary phase and
10//! solved with an inner complex-argument Newton iteration, all embedded in an
11//! ADMM splitting.
12//!
13//! Reference:
14//! Milovic C, Lambert M, Langkammer C, Bredies K, Irarrazaval P, Tejos C.
15//! "Streaking artifact suppression of quantitative susceptibility mapping
16//! reconstructions via L1-norm data fidelity optimization (L1-QSM)."
17//! Magnetic Resonance in Medicine, 2022;87:457-473.
18//! https://doi.org/10.1002/mrm.28957
19//!
20//! Ported from the FANSI toolbox `nlL1TV.m`
21//! (https://gitlab.com/cmilovic/FANSI-toolbox).
22//!
23//! # Units and weights
24//! Like the other inversions, L1-QSM takes the local field in ppm and returns ppm.
25//! nlL1TV.m works on the phase in radians, so the field is converted with
26//! `2*pi*gamma*b0*te` first and the result converted back at the end. The
27//! scale matters here: the L1 proximal step thresholds `|exp(i*Dx) - exp(i*phi)|`
28//! at `W/mu3`, so in ppm every residual falls below it and the data are fitted
29//! exactly. `b0` and `echo_times` are scan parameters
30//! that the pipeline fills from metadata, and [`L1QsmParams::te`] picks the echo time the
31//! conversion uses (by default the reference's choice; see there).
32//!
33//! `W = lambda * min(2*mask*mag/max(mag), 1)`: the magnitude in [0, 1] as in
34//! FANSI's in vivo example, scaled by lambda (nlL1TV.m: "lambda*magnitude_data
35//! or lambda*mask"). The magnitude is required: with a flat (mask) weight the
36//! nonlinear data term can diverge where the phase nears +-pi.
37//!
38//! The finite differences are FANSI's, on unit spacing whatever the voxel size.
39//!
40//! # Reference pipeline
41//! FANSI `script_DICOM_Philips.m` (commit 93044a3) runs `nlL1TV` on the same local field and
42//! TE scaling as nlTV (see [`super::fansi`](mod@super::fansi)), with weight `lambda*m0.*mask./cnoise / max`,
43//! `lambda = 0.8` (L443-450), and says the regularisation weight must be re-tuned for L1-QSM,
44//! not reused from FANSI or WH-QSM (L442-443). `nlL1TV.m` defaults: mu1 = 100*alpha1, mu2 = mu3
45//! = 1, 50 iterations, tolUpdate 1, weight ones (L48-112).
46//!
47//! # Parity
48//! Against the verified original (QSM-CI pair `l1qsm`; FANSI-toolbox 93044a3, identical to
49//! licensed MATLAB R2023b), final-map χ NRMSE at both sides' defaults on QSM-CI's simulated head
50//! and the KKI travelling head (QSM-CI parity run 41403629314, QSM.rs#399–#403): sim 0.0022 %, KKI 0.0026 %, from
51//! 14.4 % / 15.5 % (sim / KKI) before. The kernel was the whole gap (a numpy `nlL1TV.m` with FANSI's kernel is 0.0004 % from the original on sim; the `isPrecond` start and the 0.05 floor already matched). What remains is
52//! single (MATLAB) vs double (here) precision and FANSI's γ, 42.58 against the crate's 42.576
53//! (the wrappers keep theirs). Fixture: `tests/fansi_qsmci_parity.rs` (relative L2 below 1e-5
54//! on an oblique, anisotropic phantom).
55//!
56//! # Pipeline rules
57//!
58//! The facts above that a configuration can depart from are published as rules in
59//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `fansi.no-laplacian-multi-echo`, `l1qsm.te-first`.
60
61use crate::inversion::admm::{fansi_magnitude_weight, fansi_ppm_to_rad, fansi_unit_ee2, prepare_fansi_kernel, FansiKernel};
62use crate::utils::gradient::{bdiv_inplace, fgrad_inplace};
63use crate::utils::{apply_mask_zero, shrink};
64use crate::{EchoTime, Grid};
65use num_complex::Complex64;
66
67/// L1-QSM algorithm parameters (FANSI `nlL1TV.m` defaults where it has them).
68#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
69#[derive(Clone, Debug)]
70pub struct L1QsmParams {
71    /// Gradient (TV) L1 penalty weight. nlL1TV.m gives no default, and FANSI's example says
72    /// L1-QSM needs its own tuning. The default 5e-3 was chosen from an alpha1 sweep on the
73    /// EMTP travelling-head data: 2e-4 streaks, 1e-2 starts to oversmooth.
74    pub alpha1: f64,
75    /// Gradient-consistency ADMM weight; `None` is nlL1TV.m's `100 * alpha1`.
76    pub mu1: Option<f64>,
77    /// Fidelity-consistency ADMM weight.
78    pub mu2: f64,
79    /// L1 proximal ADMM weight.
80    pub mu3: f64,
81    /// Phase-rejection strength: the data weight is `lambda` times the normalised
82    /// magnitude. Smaller values reject more inconsistent voxels. Default 0.8, the value of
83    /// FANSI's in-vivo demo (`script_DICOM_Philips.m` l. 445-447: `lambda = 0.8;
84    /// params.weight = lambda*params.weight/max(params.weight(:))`); `nlL1TV.m` itself has no
85    /// default ("recommended = lambda*magnitude_data", l. 18-19). QSM.rs previously used 1.0.
86    /// Measured against 1.0 (ROMEO field, default V-SHARP): on the qsm-forward phantom χ XSIM
87    /// 0.338 → 0.339, NRMSE 78.2 → 78.1 % (on its true local field 0.808 → 0.809, NRMSE
88    /// 38.8 → 39.2 %); on an in-vivo 3T Philips scan, agreement with the UK Biobank pipeline's χ
89    /// XSIM 0.446 → 0.461, NRMSE 103.0 → 97.7 %.
90    pub lambda: f64,
91    /// Number of outer ADMM iterations.
92    pub max_iter: usize,
93    /// Percent-update convergence stopping tolerance.
94    pub tol_update: f64,
95    /// Inner Newton convergence tolerance.
96    pub tol_delta: f64,
97    /// Field strength in Tesla (scan parameter, for the radian conversion)
98    pub b0: f64,
99    /// Echo times in seconds, in acquisition order (scan parameter, for the radian
100    /// conversion).
101    pub echo_times: Vec<f64>,
102    /// Which echo time the radian conversion uses. Default [`EchoTime::First`]. FANSI's
103    /// multi-echo example, `script_DICOM_Philips.m`, scales the field by `deltaTE = TE(echo2) -
104    /// TE(echo1)` (FANSI-toolbox 93044a3, line 364) before `nlL1TV` (line 450), and its comment
105    /// on that line says "You may use also any individual echo time". On the qsm-forward phantom
106    /// (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
107    /// echo does best (0.776 / 38.9% / 0.923), against 0.764 / 46.9% / 0.889 at the echo
108    /// spacing. (With the continuous kernel, #276: 0.782 / 37.4% / 0.928 against 0.776 / 40.2% /
109    /// 0.916.)
110    ///
111    /// The magnitude weight is required, although `nlL1TV.m` weights by ones when given none
112    /// (lines 109-113): with a flat weight L1-QSM is worse on that phantom (0.767 / 42.0% / 0.912
113    /// at the first echo, 0.763 / 43.1% / 0.906 at the spacing). With the continuous kernel the
114    /// flat weight's NRMSE exceeded 100% at either echo time (0.738 / 108% / 0.636 at the first
115    /// echo, #276).
116    pub te: EchoTime,
117    /// The dipole kernel. Default [`FansiKernel::Discrete`], FANSI's
118    /// `dipole_kernel_angulated` as the original is run with; [`FansiKernel::Continuous`] is
119    /// the crate's continuous kernel (see [`FansiKernel`] for the decision and the measurements).
120    pub kernel: FansiKernel,
121}
122
123impl Default for L1QsmParams {
124    fn default() -> Self {
125        Self {
126            alpha1: 5e-3,
127            mu1: None,
128            mu2: 1.0,
129            mu3: 1.0,
130            lambda: 0.8,
131            max_iter: 50,
132            tol_update: 1.0,
133            tol_delta: 1e-6,
134            b0: 3.0,
135            echo_times: vec![0.02],
136            te: EchoTime::First,
137            kernel: FansiKernel::Discrete,
138        }
139    }
140}
141
142/// L2 norm of a real slice.
143fn norm2(v: &[f64]) -> f64 {
144    v.iter().map(|&a| a * a).sum::<f64>().sqrt()
145}
146
147/// L1-QSM nonlinear dipole inversion.
148///
149/// # Arguments
150/// * `local_field` - Local field in ppm (nx * ny * nz).
151/// * `mask` - Binary mask (nx * ny * nz), non-zero = inside ROI.
152/// * `magnitude` - Magnitude image, the data weight (see module docs).
153/// * `grid` - Volume grid (dimensions and voxel sizes).
154/// * `bdir` - B0 field direction.
155/// * `params` - L1-QSM parameters.
156/// * `progress` - Progress callback `(iteration, max_iter)`.
157///
158/// # Returns
159/// Susceptibility map in ppm, masked to the ROI.
160pub fn l1qsm(
161    local_field: &[f64],
162    mask: &[u8],
163    magnitude: &[f64],
164    grid: &Grid,
165    bdir: (f64, f64, f64),
166    params: &L1QsmParams,
167    mut progress: impl FnMut(usize, usize),
168) -> Vec<f64> {
169    let n = grid.n_total();
170
171    let (mut fft_ws, k) = prepare_fansi_kernel(grid, bdir, params.kernel);
172    // FANSI's E1..E3 are unit-spacing differences.
173    let ee2 = fansi_unit_ee2(grid.dims);
174    let ugrid = Grid::new(grid.nx(), grid.ny(), grid.nz(), 1.0, 1.0, 1.0);
175    let grid = &ugrid;
176
177    // ppm -> radians, as nlL1TV.m expects; W = lambda * normalised magnitude (or mask).
178    let ppm_to_rad = fansi_ppm_to_rad(params.b0, params.te.seconds(&params.echo_times, "L1-QSM"));
179    let phase: Vec<f64> = local_field.iter().map(|&f| f * ppm_to_rad).collect();
180    let w: Vec<f64> = fansi_magnitude_weight(mask, magnitude)
181        .into_iter()
182        .map(|v| params.lambda * v)
183        .collect();
184
185    // IS = exp(1i * phase)
186    let is: Vec<Complex64> = phase
187        .iter()
188        .map(|&p| Complex64::new(p.cos(), p.sin()))
189        .collect();
190
191    let mu1 = params.mu1.unwrap_or(100.0 * params.alpha1);
192    let mu2 = params.mu2;
193    let mu3 = params.mu3;
194    let alpha_over_mu = params.alpha1 / mu1;
195
196    // ADMM variables.
197    let mut x = vec![0.0f64; n];
198    let mut x_prev = vec![0.0f64; n];
199
200    // Gradient-consistency split (real).
201    let mut z_dx = vec![0.0f64; n];
202    let mut z_dy = vec![0.0f64; n];
203    let mut z_dz = vec![0.0f64; n];
204    let mut s_dx = vec![0.0f64; n];
205    let mut s_dy = vec![0.0f64; n];
206    let mut s_dz = vec![0.0f64; n];
207
208    // Fidelity-consistency split (real phase auxiliary) and its multiplier.
209    // isPrecond=true: z2 = W .* phase / max(W)
210    let w_max = w.iter().cloned().fold(0.0f64, f64::max);
211    let mut z2 = vec![0.0f64; n];
212    if w_max > 0.0 {
213        for i in 0..n {
214            z2[i] = w[i] * phase[i] / w_max;
215        }
216    }
217    let mut s2 = vec![0.0f64; n];
218
219    // L1 proximal split (complex) and its multiplier.
220    let mut z3 = vec![Complex64::new(0.0, 0.0); n];
221    let mut s3 = vec![Complex64::new(0.0, 0.0); n];
222
223    // Reusable buffers.
224    let mut fdiv = vec![Complex64::new(0.0, 0.0); n];
225    let mut fd2 = vec![Complex64::new(0.0, 0.0); n];
226    let mut xhat = vec![Complex64::new(0.0, 0.0); n];
227    let mut fx = vec![Complex64::new(0.0, 0.0); n];
228
229    let mut gxc = vec![0.0f64; n];
230    let mut gyc = vec![0.0f64; n];
231    let mut gzc = vec![0.0f64; n];
232    let mut x_dx = vec![0.0f64; n];
233    let mut x_dy = vec![0.0f64; n];
234    let mut x_dz = vec![0.0f64; n];
235    let mut div = vec![0.0f64; n];
236    let mut dx = vec![0.0f64; n]; // Dx = real(ifft(k .* fft(x)))
237    let mut rhs_z2 = vec![0.0f64; n];
238    let mut diff = vec![0.0f64; n];
239
240    for t in 0..params.max_iter {
241        progress(t + 1, params.max_iter);
242
243        // ---- x-subproblem (gradient consistency) --------------------------
244        // gradient side: mu1 * sum(E_t .* fft(z_d - s_d)) == mu1 * fft(bdiv(z_d - s_d))
245        for i in 0..n {
246            gxc[i] = z_dx[i] - s_dx[i];
247            gyc[i] = z_dy[i] - s_dy[i];
248            gzc[i] = z_dz[i] - s_dz[i];
249        }
250        bdiv_inplace(&mut div, &gxc, &gyc, &gzc, grid);
251        for i in 0..n {
252            fdiv[i] = Complex64::new(div[i], 0.0);
253        }
254        fft_ws.fft3d(&mut fdiv);
255
256        // fidelity side: mu2 * conj(K) .* fft(z2 - s2); K real so conj(K) = K,
257        // applied after the FFT below.
258        for i in 0..n {
259            fd2[i] = Complex64::new(z2[i] - s2[i], 0.0);
260        }
261        fft_ws.fft3d(&mut fd2);
262
263        // Combine in k-space.
264        for i in 0..n {
265            // Minus on the gradient term: adjoint of crate `fgrad` is `-bdiv` (matches
266            // QSM.rs TV-ADMM). `+bdiv` doubles the effective regularization. See fansi.rs.
267            let num = -mu1 * fdiv[i] + mu2 * k[i] * fd2[i];
268            // Guard the dipole null-space (DC/singular bins): both dipole kernel
269            // and Laplacian vanish there. Zero it instead of dividing FFT
270            // round-off by ~0, which would otherwise create a huge DC pedestal.
271            let den = mu2 * k[i] * k[i] + mu1 * ee2[i];
272            xhat[i] = if den > 1e-20 { num / den } else { Complex64::new(0.0, 0.0) };
273        }
274        fft_ws.ifft3d(&mut xhat);
275        x_prev.copy_from_slice(&x);
276        for i in 0..n {
277            x[i] = xhat[i].re;
278        }
279
280        // ---- convergence check (percent update) ---------------------------
281        // As nlL1TV.m, a zero solution gives NaN and stops.
282        for i in 0..n {
283            diff[i] = x[i] - x_prev[i];
284        }
285        let x_update = 100.0 * norm2(&diff) / norm2(&x);
286        if x_update < params.tol_update || x_update.is_nan() {
287            progress(t + 1, t + 1);
288            break;
289        }
290
291        if t + 1 >= params.max_iter {
292            break;
293        }
294
295        // ---- gradient split update (TV shrink) ----------------------------
296        // Fx = fft(x)  (reused for the dipole rhs below)
297        for i in 0..n {
298            fx[i] = Complex64::new(x[i], 0.0);
299        }
300        fft_ws.fft3d(&mut fx);
301
302        // x_d{x,y,z} = fgrad(x)
303        fgrad_inplace(&mut x_dx, &mut x_dy, &mut x_dz, &x, grid);
304        for i in 0..n {
305            z_dx[i] = shrink(x_dx[i] + s_dx[i], alpha_over_mu);
306            z_dy[i] = shrink(x_dy[i] + s_dy[i], alpha_over_mu);
307            z_dz[i] = shrink(x_dz[i] + s_dz[i], alpha_over_mu);
308            s_dx[i] += x_dx[i] - z_dx[i];
309            s_dy[i] += x_dy[i] - z_dy[i];
310            s_dz[i] += x_dz[i] - z_dz[i];
311        }
312
313        // ---- L1 fidelity proximal (z3) ------------------------------------
314        // Y3 = exp(1i*z2) - IS + s3 ;  z3 = soft_threshold_complex(Y3, W/mu3)
315        for i in 0..n {
316            let ez2 = Complex64::new(z2[i].cos(), z2[i].sin());
317            let y3 = ez2 - is[i] + s3[i];
318            let mag = y3.norm();
319            let thr = w[i] / (mu3 + f64::EPSILON);
320            let shr = (mag - thr).max(0.0);
321            z3[i] = if mag > 0.0 {
322                y3 * (shr / mag)
323            } else {
324                Complex64::new(0.0, 0.0)
325            };
326        }
327
328        // ---- fidelity auxiliary (z2) via complex-arg Newton ---------------
329        // rhs_z2 = mu2 * (Dx + s2), with Dx = real(ifft(K .* Fx))
330        for i in 0..n {
331            xhat[i] = fx[i] * k[i];
332        }
333        fft_ws.ifft3d(&mut xhat);
334        for i in 0..n {
335            dx[i] = xhat[i].re;
336            rhs_z2[i] = mu2 * (dx[i] + s2[i]);
337            // init z2 = rhs_z2 / mu2 (== Dx + s2)
338            z2[i] = rhs_z2[i] / mu2;
339        }
340
341        // Newton per-voxel: minimize mu3*|exp(iz2)-(IS+z3-s3)| + mu2/2*(z2-...)^2
342        // Uses complex-argument trigonometric linearization (see module notes).
343        // yphase = angle(IS+z3-s3), ym = abs(IS+z3-s3), b = ln(ym).
344        let mut yphase = vec![0.0f64; n];
345        let mut cosh_b = vec![0.0f64; n];
346        let mut sinh_b = vec![0.0f64; n];
347        for i in 0..n {
348            let yc = is[i] + z3[i] - s3[i];
349            yphase[i] = yc.arg();
350            let m = yc.norm();
351            if m > 0.0 {
352                cosh_b[i] = 0.5 * (m + 1.0 / m); // cosh(ln m)
353                sinh_b[i] = 0.5 * (m - 1.0 / m); // sinh(ln m)
354            } else {
355                cosh_b[i] = 1.0;
356                sinh_b[i] = 0.0;
357            }
358        }
359
360        let mut delta = f64::INFINITY;
361        let mut inn = 0usize;
362        let mut update = vec![0.0f64; n];
363        while delta > params.tol_delta && inn < 4 {
364            inn += 1;
365            let norm_old = norm2(&z2);
366
367            for i in 0..n {
368                let a = z2[i] - yphase[i];
369                let (sa, ca) = a.sin_cos();
370                // sin(a - i b) = sin(a)cosh(b) - i cos(a)sinh(b)
371                let sin_arg = Complex64::new(sa * cosh_b[i], -ca * sinh_b[i]);
372                // cos(a - i b) = cos(a)cosh(b) + i sin(a)sinh(b)
373                let cos_arg = Complex64::new(ca * cosh_b[i], sa * sinh_b[i]);
374
375                let temp = mu3 * cos_arg + Complex64::new(mu2 + f64::EPSILON, 0.0);
376                let numer = mu3 * sin_arg + Complex64::new(mu2 * z2[i] - rhs_z2[i], 0.0);
377
378                // max(abs(temp), 0.05) .* sign(temp): floor magnitude at 0.05.
379                let tm = temp.norm();
380                let denom = if tm < 0.05 {
381                    if tm > 0.0 {
382                        temp * (0.05 / tm)
383                    } else {
384                        Complex64::new(0.05, 0.0)
385                    }
386                } else {
387                    temp
388                };
389
390                update[i] = (numer / denom).re;
391                z2[i] -= update[i];
392            }
393
394            let delta_new = if norm_old > 0.0 {
395                norm2(&update) / norm_old
396            } else {
397                0.0
398            };
399            if delta_new > delta {
400                break;
401            }
402            delta = delta_new;
403        }
404
405        // ---- multiplier updates -------------------------------------------
406        // s2 = s2 + Dx - z2
407        for i in 0..n {
408            s2[i] += dx[i] - z2[i];
409        }
410        // s3 = exp(1i*z2) - IS + s3 - z3
411        for i in 0..n {
412            let ez2 = Complex64::new(z2[i].cos(), z2[i].sin());
413            s3[i] = ez2 - is[i] + s3[i] - z3[i];
414        }
415    }
416
417    // Back to ppm.
418    for v in &mut x {
419        *v /= ppm_to_rad;
420    }
421
422    apply_mask_zero(&mut x, mask);
423    x
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_l1qsm_zero_field() {
432        // Zero field should give (approximately) zero susceptibility.
433        let n = 8;
434        let field = vec![0.0; n * n * n];
435        let mask = vec![1u8; n * n * n];
436        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
437        let params = L1QsmParams {
438            max_iter: 10,
439            ..L1QsmParams::default()
440        };
441
442        let chi = l1qsm(&field, &mask, &mask.iter().map(|_| 1.0).collect::<Vec<f64>>(), &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
443
444        for &val in chi.iter() {
445            assert!(val.abs() < 1e-6, "Zero field should give ~zero chi, got {}", val);
446        }
447    }
448
449    #[test]
450    fn test_l1qsm_finite() {
451        // A small ramp field should produce all-finite output.
452        let n = 8;
453        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
454        let mask = vec![1u8; n * n * n];
455        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
456        let params = L1QsmParams {
457            max_iter: 10,
458            ..L1QsmParams::default()
459        };
460
461        let chi = l1qsm(&field, &mask, &mask.iter().map(|_| 1.0).collect::<Vec<f64>>(), &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
462
463        for (i, &val) in chi.iter().enumerate() {
464            assert!(val.is_finite(), "Chi should be finite at index {}", i);
465        }
466    }
467}