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(¶ms.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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {});
462
463 for (i, &val) in chi.iter().enumerate() {
464 assert!(val.is_finite(), "Chi should be finite at index {}", i);
465 }
466 }
467}