Skip to main content

qsm_core/inversion/
whqsm.rs

1//! Weak-Harmonic QSM (WH-QSM) dipole inversion
2//!
3//! Jointly estimates the susceptibility map `x` AND a residual harmonic
4//! background field `phi_h`, so that any harmonic (Laplacian-null) field
5//! remaining after background-field removal is absorbed into `phi_h` instead
6//! of corrupting the susceptibility estimate. This makes the reconstruction
7//! robust to imperfect background-field removal.
8//!
9//! The optimization solves a nonlinear total-variation problem via ADMM,
10//! with an additional weak-harmonic regularization term coupling `x` and the
11//! harmonic field. The data-fidelity term is nonlinear (sine of the phase),
12//! and is solved with an inner Newton iteration.
13//!
14//! Reference:
15//! Milovic, C., Bilgic, B., Zhao, B., Acosta-Cabronero, J., Tejos, C. (2019).
16//! "Weak-harmonic regularization for quantitative susceptibility mapping."
17//! Magnetic Resonance in Medicine, 81(2):1399-1411.
18//! <https://doi.org/10.1002/mrm.27483>
19//!
20//! Ported from FANSI's `WH_nlTV.m`.
21//!
22//! # Units / `phase_scale`
23//! The nonlinear data term operates on a wrapped phase. The input
24//! `local_field` is multiplied by `params.phase_scale` to form the internal
25//! `phase`, and the final susceptibility map is divided by `phase_scale`. For
26//! field data already in ppm-consistent units (as the rest of QSM.rs assumes),
27//! use `phase_scale = 1.0`. When the input is a raw radians phase and a
28//! ppm-scaled output is desired, set `phase_scale` to the radians→ppm factor.
29
30use crate::inversion::admm::prepare_fansi_spectral;
31use crate::utils::gradient::{bdiv_inplace, fgrad_inplace};
32use crate::utils::{apply_mask_zero, shrink};
33use crate::Grid;
34use num_complex::Complex64;
35
36/// WH-QSM parameters.
37#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
38#[derive(Clone, Debug)]
39pub struct WhQsmParams {
40    /// TV regularization weight.
41    pub alpha1: f64,
42    /// ADMM penalty for the TV splitting (mu).
43    pub mu1: f64,
44    /// ADMM penalty for the data-fidelity splitting.
45    pub mu2: f64,
46    /// Weak-harmonic ROI penalty (constrains the harmonic field inside the mask).
47    pub beta: f64,
48    /// ADMM penalty for the harmonic-field splitting.
49    pub muh: f64,
50    /// Maximum outer iterations.
51    pub max_iter: usize,
52    /// Percent-update stopping tolerance on `x`.
53    pub tol_update: f64,
54    /// Inner Newton stopping tolerance.
55    pub tol_delta: f64,
56    /// Phase scaling factor (see module docs).
57    pub phase_scale: f64,
58}
59
60impl Default for WhQsmParams {
61    fn default() -> Self {
62        Self {
63            alpha1: 2e-4,
64            mu1: 2e-2,
65            mu2: 1.0,
66            beta: 150.0,
67            muh: 3.0,
68            max_iter: 300,
69            tol_update: 0.1,
70            tol_delta: 1e-6,
71            phase_scale: 1.0,
72        }
73    }
74}
75
76/// L2 norm of a real vector.
77#[inline]
78fn norm(v: &[f64]) -> f64 {
79    v.iter().map(|&a| a * a).sum::<f64>().sqrt()
80}
81
82/// Weak-Harmonic QSM dipole inversion.
83///
84/// Jointly estimates the susceptibility map and a residual harmonic
85/// background field, returning the susceptibility map (`out.x`).
86///
87/// # Arguments
88/// * `local_field` - Local field values (nx * ny * nz).
89/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI.
90/// * `grid` - Volume grid (dimensions and voxel sizes).
91/// * `bdir` - B0 field direction.
92/// * `params` - WH-QSM parameters.
93/// * `progress` - Progress callback `(iteration, max_iter)`.
94///
95/// # Returns
96/// Susceptibility map.
97pub fn whqsm(
98    local_field: &[f64],
99    mask: &[u8],
100    grid: &Grid,
101    bdir: (f64, f64, f64),
102    params: &WhQsmParams,
103    mut progress: impl FnMut(usize, usize),
104) -> Vec<f64> {
105    let n = grid.n_total();
106
107    let alpha = params.alpha1;
108    let mu = params.mu1;
109    let mu2 = params.mu2;
110    let beta = params.beta;
111    let muh = params.muh;
112    let alpha_over_mu = alpha / mu;
113
114    // Spectral operators: k = real dipole kernel D(k) (DC=0), ee2 = real Laplacian |E|^2.
115    let (mut fft_ws, k, ee2) = prepare_fansi_spectral(grid, bdir);
116
117    // Weight W = mask (weight = mask, W = weight^2 = mask since 0/1).
118    // mask_f: floating mask.
119    let mask_f: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
120    let w: Vec<f64> = mask_f.clone();
121
122    // phase = local_field * phase_scale
123    let phase: Vec<f64> = local_field.iter().map(|&f| f * params.phase_scale).collect();
124
125    // Denominator for x-subproblem: 1e-30 + mu2*k^2 + mu*ee2
126    let x_denom: Vec<f64> = (0..n)
127        .map(|i| 1e-30 + mu2 * k[i] * k[i] + mu * ee2[i])
128        .collect();
129    // Denominator for phi_h subproblem: eps + mu2 + muh*ee2^2
130    let phi_denom: Vec<f64> = (0..n)
131        .map(|i| 1e-30 + mu2 + muh * ee2[i] * ee2[i])
132        .collect();
133
134    // State variables.
135    let mut x = vec![0.0f64; n];
136    let mut z_dx = vec![0.0f64; n];
137    let mut z_dy = vec![0.0f64; n];
138    let mut z_dz = vec![0.0f64; n];
139    let mut s_dx = vec![0.0f64; n];
140    let mut s_dy = vec![0.0f64; n];
141    let mut s_dz = vec![0.0f64; n];
142
143    let mut phi_h = vec![0.0f64; n];
144    let mut z_h = vec![0.0f64; n];
145    let mut s_h = vec![0.0f64; n];
146
147    // z2 = phase .* W ; s2 = 0
148    let mut z2: Vec<f64> = (0..n).map(|i| phase[i] * w[i]).collect();
149    let mut s2 = vec![0.0f64; n];
150
151    // Scratch buffers.
152    let mut cbuf = vec![Complex64::new(0.0, 0.0); n]; // general complex scratch
153    let mut cbuf2 = vec![Complex64::new(0.0, 0.0); n]; // second complex scratch
154    let mut fx = vec![Complex64::new(0.0, 0.0); n]; // Fx = fft(x)
155    let mut real_scratch = vec![0.0f64; n];
156    let mut gx = vec![0.0f64; n];
157    let mut gy = vec![0.0f64; n];
158    let mut gz = vec![0.0f64; n];
159    let mut dg = vec![0.0f64; n]; // divergence buffer
160    let mut dx_real = vec![0.0f64; n]; // Dx = real(ifft(k .* Fx))
161    let mut ee2_phi = vec![0.0f64; n]; // real(ifft(EE2 .* Fphi_h))
162
163    for t in 0..params.max_iter {
164        progress(t + 1, params.max_iter);
165
166        // --- x-subproblem (masked) ---
167        // numerator k-space = mu * fft(bdiv(z_d - s_d)) + K .* fft(z2 - s2 - phi_h)
168        // x = mask .* real(ifft( numerator ./ x_denom ))
169
170        // bdiv(z_d - s_d) in real space, then fft.
171        for i in 0..n {
172            gx[i] = z_dx[i] - s_dx[i];
173            gy[i] = z_dy[i] - s_dy[i];
174            gz[i] = z_dz[i] - s_dz[i];
175        }
176        bdiv_inplace(&mut dg, &gx, &gy, &gz, grid);
177        for i in 0..n {
178            cbuf[i] = Complex64::new(dg[i], 0.0);
179        }
180        fft_ws.fft3d(&mut cbuf); // cbuf = fft(bdiv(z_d - s_d))
181
182        // Dt_kspace source in real space = z2 - s2 - phi_h ; fft it.
183        for i in 0..n {
184            fx[i] = Complex64::new(z2[i] - s2[i] - phi_h[i], 0.0);
185        }
186        fft_ws.fft3d(&mut fx); // fx = fft(z2 - s2 - phi_h)
187
188        // numerator ./ x_denom  (k real -> K.* = k[i] * fx[i]; conj(K)=K since real)
189        for i in 0..n {
190            // Minus on the gradient term: adjoint of crate `fgrad` is `-bdiv` (matches
191            // QSM.rs TV-ADMM). `+bdiv` doubles the effective regularization. See fansi.rs.
192            let num = -mu * cbuf[i] + k[i] * fx[i];
193            // Guard the dipole null-space (DC/singular bins): x_denom -> ~0 there
194            // (both dipole kernel and Laplacian vanish). Zero it instead of
195            // dividing FFT round-off by ~1e-30, which blows up and (via the
196            // harmonic-field coupling) diverges the whole solve.
197            cbuf[i] = if x_denom[i] > 1e-20 { num / x_denom[i] } else { Complex64::new(0.0, 0.0) };
198        }
199        fft_ws.ifft3d(&mut cbuf);
200
201        // Compute x_update = 100 * norm(x - x_prev) / norm(x) after updating.
202        // Keep old x in real_scratch for the diff.
203        real_scratch.copy_from_slice(&x);
204        for i in 0..n {
205            x[i] = mask_f[i] * cbuf[i].re;
206        }
207        // x_update.
208        let mut diff_norm = 0.0;
209        for i in 0..n {
210            let d = x[i] - real_scratch[i];
211            diff_norm += d * d;
212        }
213        let diff_norm = diff_norm.sqrt();
214        let x_norm = norm(&x);
215        let x_update = if x_norm > 0.0 {
216            100.0 * diff_norm / x_norm
217        } else {
218            // If ||x|| == 0, treat as no meaningful update (avoid div by zero).
219            0.0
220        };
221        if x_update < params.tol_update {
222            progress(t + 1, t + 1);
223            break;
224        }
225
226        if t + 1 < params.max_iter {
227            // Fx = fft(x)
228            for i in 0..n {
229                fx[i] = Complex64::new(x[i], 0.0);
230            }
231            fft_ws.fft3d(&mut fx);
232
233            // Forward gradient of x (== [real(ifft(E1.*Fx)), ...]).
234            fgrad_inplace(&mut gx, &mut gy, &mut gz, &x, grid);
235
236            // z_d = shrink(grad + s_d, alpha_over_mu) ; s_d += grad - z_d.
237            for i in 0..n {
238                let ax = gx[i] + s_dx[i];
239                let ay = gy[i] + s_dy[i];
240                let az = gz[i] + s_dz[i];
241                z_dx[i] = shrink(ax, alpha_over_mu);
242                z_dy[i] = shrink(ay, alpha_over_mu);
243                z_dz[i] = shrink(az, alpha_over_mu);
244                s_dx[i] += gx[i] - z_dx[i];
245                s_dy[i] += gy[i] - z_dy[i];
246                s_dz[i] += gz[i] - z_dz[i];
247            }
248
249            // Dx = real(ifft(K .* Fx))
250            for i in 0..n {
251                cbuf[i] = k[i] * fx[i];
252            }
253            fft_ws.ifft3d(&mut cbuf);
254            for i in 0..n {
255                dx_real[i] = cbuf[i].re;
256            }
257
258            // rhs_z2 = mu2 * (Dx + s2 + phi_h) ; z2 = rhs_z2 / mu2 = Dx + s2 + phi_h.
259            // Store rhs_z2 in real_scratch.
260            for i in 0..n {
261                real_scratch[i] = mu2 * (dx_real[i] + s2[i] + phi_h[i]);
262                z2[i] = real_scratch[i] / mu2;
263            }
264
265            // Newton iteration on z2 (nonlinear data term).
266            let mut delta = f64::INFINITY;
267            let mut inn = 0;
268            while delta > params.tol_delta && inn < 50 {
269                inn += 1;
270                let norm_old = norm(&z2);
271                // update = (W.*sin(z2-phase) + mu2*z2 - rhs_z2) ./ (W.*cos(z2-phase) + mu2)
272                let mut upd_norm2 = 0.0;
273                for i in 0..n {
274                    let dphi = z2[i] - phase[i];
275                    let numr = w[i] * dphi.sin() + mu2 * z2[i] - real_scratch[i];
276                    let denr = w[i] * dphi.cos() + mu2;
277                    let u = numr / denr;
278                    z2[i] -= u;
279                    upd_norm2 += u * u;
280                }
281                let upd_norm = upd_norm2.sqrt();
282                delta = if norm_old > 0.0 {
283                    upd_norm / norm_old
284                } else {
285                    // guard: if z2 became zero, stop.
286                    0.0
287                };
288            }
289
290            // --- Harmonic field update (phi_h) ---
291            // Fphi_h = ( muh*EE2.*fft(z_h - s_h) + mu2*fft(z2 - s2) - mu2*K.*Fx )
292            //          ./ (eps + mu2 + muh*EE2.^2)
293            // fft(z_h - s_h)
294            for i in 0..n {
295                cbuf[i] = Complex64::new(z_h[i] - s_h[i], 0.0);
296            }
297            fft_ws.fft3d(&mut cbuf); // cbuf = fft(z_h - s_h)
298
299            // fft(z2 - s2)  -> reuse a fresh complex buffer via dx_real? Need another buffer.
300            // Use `phi_h`-sized complex scratch: allocate once via a persistent buffer.
301            // We reuse `cbuf` after combining; but we need fft(z2 - s2) simultaneously.
302            // Compute fft(z2 - s2) into a second complex buffer.
303            // (fx currently holds Fx; we still need Fx for the -mu2*K.*Fx term.)
304            for i in 0..n {
305                cbuf2[i] = Complex64::new(z2[i] - s2[i], 0.0);
306            }
307            fft_ws.fft3d(&mut cbuf2); // cbuf2 = fft(z2 - s2)
308
309            // Fphi_h (store into cbuf).
310            for i in 0..n {
311                let numer =
312                    muh * ee2[i] * cbuf[i] + mu2 * cbuf2[i] - mu2 * (k[i] * fx[i]);
313                cbuf[i] = numer / phi_denom[i];
314            }
315            // cbuf now = Fphi_h.
316
317            // EE2_phi = real(ifft(EE2 .* Fphi_h)) : first compute with EE2 multiply.
318            for i in 0..n {
319                cbuf2[i] = ee2[i] * cbuf[i];
320            }
321            fft_ws.ifft3d(&mut cbuf2);
322            for i in 0..n {
323                ee2_phi[i] = cbuf2[i].re;
324            }
325
326            // phi_h = real(ifft(Fphi_h))
327            fft_ws.ifft3d(&mut cbuf);
328            for i in 0..n {
329                phi_h[i] = cbuf[i].re;
330            }
331
332            // z_h = muh*(EE2_phi + s_h) ./ (muh + beta*mask)   (mask 0/1)
333            for i in 0..n {
334                z_h[i] = muh * (ee2_phi[i] + s_h[i]) / (muh + beta * mask_f[i]);
335            }
336
337            // --- dual updates ---
338            // s2 = s2 + real(ifft(K.*Fx)) - z2 + phi_h  (= s2 + Dx - z2 + phi_h)
339            for i in 0..n {
340                s2[i] = s2[i] + dx_real[i] - z2[i] + phi_h[i];
341            }
342            // s_h = s_h + EE2_phi - z_h
343            for i in 0..n {
344                s_h[i] = s_h[i] + ee2_phi[i] - z_h[i];
345            }
346        }
347    }
348
349    // Divide out phase_scale and apply mask.
350    if params.phase_scale != 1.0 {
351        let inv = 1.0 / params.phase_scale;
352        for v in x.iter_mut() {
353            *v *= inv;
354        }
355    }
356    apply_mask_zero(&mut x, mask);
357
358    x
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_whqsm_zero_field() {
367        // Zero field should give (near) zero susceptibility.
368        let n = 8;
369        let field = vec![0.0; n * n * n];
370        let mask = vec![1u8; n * n * n];
371        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
372        let params = WhQsmParams {
373            max_iter: 15,
374            ..Default::default()
375        };
376
377        let chi = whqsm(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
378
379        for &val in chi.iter() {
380            assert!(
381                val.abs() < 1e-6,
382                "Zero field should give ~zero chi, got {}",
383                val
384            );
385        }
386    }
387
388    #[test]
389    fn test_whqsm_finite() {
390        // Small ramp field -> all outputs finite.
391        let n = 8;
392        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
393        let mask = vec![1u8; n * n * n];
394        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
395        let params = WhQsmParams {
396            max_iter: 15,
397            ..Default::default()
398        };
399
400        let chi = whqsm(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
401
402        for (i, &val) in chi.iter().enumerate() {
403            assert!(val.is_finite(), "Chi should be finite at index {}", i);
404        }
405    }
406}