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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {});
401
402 for (i, &val) in chi.iter().enumerate() {
403 assert!(val.is_finite(), "Chi should be finite at index {}", i);
404 }
405 }
406}