Skip to main content

qsm_core/inversion/
rts.rs

1//! Rapid Two-Step (RTS) dipole inversion
2//!
3//! Two steps:
4//! 1. **Well-conditioned k-space:** a few LSMR iterations on `D χ̂ = F f` (all of k-space,
5//!    from zero), then the result is masked.
6//! 2. **Ill-conditioned k-space:** TV-regularised ADMM that holds the well-conditioned
7//!    coefficients (`|D| > delta`) of step 1 with weight `mu`, starting from the gradient of the
8//!    step-1 map, and raising the penalty `rho` by `gamma` whenever the primal residual falls by
9//!    less than a factor `tau`.
10//!
11//! Reference:
12//! Kames, C., Wiggermann, V., Rauscher, A. (2018).
13//! "Rapid two-step dipole inversion for susceptibility mapping with sparsity priors."
14//! NeuroImage, 167:276-283. https://doi.org/10.1016/j.neuroimage.2017.11.018
15//!
16//! Reference implementation: QSM.jl's `rts` (https://github.com/kamesy/QSM.jl, by the
17//! method's first author), ported line for line with its defaults. The field is in ppm, as in
18//! QSM.jl. QSM.jl works on real-to-complex FFTs; here the full complex FFT is used, and the
19//! LSMR inner products are taken over the half spectrum QSM.jl stores (`kx` index
20//! `0..=nx/2`) so its scalars are the same.
21//!
22//! QSM.jl is this method's reference: RTS is Kames's method and QSM.jl is his code, so this is the authors' code (QSM.rs's reference rule: the authors' code where it
23//! exists, otherwise one designated implementation per family, the others' values recorded).
24//!
25//! QSM.jl pads the field to its fast FFT size first (`pad = (0, 0, 0)`, `padfastfft`,
26//! `src/inversion/rts.jl:109-111`), and so does this port by default ([`RtsParams::pad`]); the
27//! padding changes the result, since the filters act on the periodic extension of the array.
28//!
29//! # Agreement with QSM.jl
30//!
31//! On the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, V-SHARP
32//! local field and brain mask), against QSM.jl in the QSM-CI `julia-qsm` image (`sha256:b3c048ac…`,
33//! QSM.jl fde4b92), both outputs saved as float32:
34//! NRMSE 1.0e-8 % at QSM.jl's defaults, 1.3e-9 % with padding off on both. Before this port
35//! padded, its defaults differed from QSM.jl's by NRMSE 0.45 %. `tests/inversion_qsmjl_parity.rs`
36//! checks it against QSM.jl's output on small phantoms (relative L2 ~1e-15), axial and oblique.
37//!
38//! One deviation, where QSM.jl's result is not well defined:
39//! - **DC of the Laplacian.** On anisotropic voxels the FFT of the Laplacian stencil is not
40//!   exactly 0 at DC (4.4e-16 at 0.9 × 1.0 × 1.3 mm), and QSM.jl inverts that rounding, which
41//!   puts an arbitrary constant into χ (it moved χ by a uniform 1e-3 ppm on a small phantom, and
42//!   changed the iteration count). Here the DC term is exactly 0, as it is on isotropic voxels.
43//!
44//! Tilted B0 on even sizes is reproduced too: before each inverse FFT the spectrum is replaced by
45//! the Hermitian spectrum QSM.jl's `irfft` reads from its stored half
46//! ([`crate::fft::c2r_spectrum`]; see *Half spectrum* below).
47//!
48//! # Half spectrum (tilted B0)
49//!
50//! QSM.jl applies its k-space multipliers on FFTW's real-to-complex half spectrum and inverts
51//! with `irfft`. With B0 tilted from the grid axes the dipole kernel is not even on the Nyquist
52//! planes of an even axis (`D(k) ≠ D(−k)` there, since the index of −½ maps to itself), and
53//! `irfft` resolves that differently from a full complex FFT and its real part. This port uses
54//! the multiplier `irfft` implies ([`crate::fft::c2r_kernel`]/[`crate::fft::c2r_spectrum`],
55//! astewartau/QSM.rs#329), which reproduces QSM.jl there too. Before (2026-10-29) the full-FFT
56//! port differed from QSM.jl on the oblique KKI travelling head (Philips Achieva, B0 tilted) by
57//! 0.5–1.2 % NRMSE for TKD, TSVD, Tikhonov and RTS (QSMxT/QSM-CI parity workflow);
58//! `tests/inversion_qsmjl_parity.rs` now has even-sized oblique cases.
59//!
60//! # Reference pipeline
61//! QSM.jl's README example (commit fde4b92, L35-52) ends in `rts`: FSL BET on the last echo's
62//! magnitude (`-f 0.5`), eroded by 5 voxels; Laplacian unwrapping of each echo, each scaled to
63//! ppm by its own TE; V-SHARP with QSM.jl's defaults (radii `18*min(vsz)` down to `2*max(vsz)`
64//! mm in steps of `2*max(vsz)`, threshold 0.05; `src/bgremove/sharp.jl` L136-137); then `rts` on
65//! the (per-echo) local field, with `pad = (0, 0, 0)`, padding to QSM.jl's fast FFT size only.
66//!
67//! # Pipeline rules
68//!
69//! The facts above that a configuration can depart from are published as rules in
70//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `rts.bg-vsharp`.
71
72use num_complex::Complex64;
73use crate::fft::Fft3dWorkspace;
74use crate::kernels::dipole::dipole_kernel;
75use crate::kernels::laplacian::laplacian_kernel;
76use crate::utils::apply_mask_zero;
77use crate::utils::gradient::{bdiv_inplace, fgrad_inplace};
78use crate::Grid;
79
80/// RTS algorithm parameters (QSM.jl `rts` keywords and defaults)
81#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
82#[derive(Clone, Debug)]
83pub struct RtsParams {
84    /// Threshold on |D| for the ill-conditioned k-space region (`delta`)
85    pub delta: f64,
86    /// Weight of the well-conditioned k-space data in step 2 (`mu`)
87    pub mu: f64,
88    /// Initial ADMM penalty parameter (`rho`)
89    pub rho: f64,
90    /// Stopping tolerance on the relative change of χ (`tol`)
91    pub tol: f64,
92    /// Maximum ADMM iterations (`maxit`)
93    pub max_iter: usize,
94    /// LSMR iterations in step 1 (`lstol`)
95    pub lsmr_iter: usize,
96    /// Raise `rho` when the primal residual falls by less than this factor (`tau`)
97    pub tau: f64,
98    /// Factor `rho` is raised by (`gamma`)
99    pub gamma: f64,
100    /// Zero padding per axis, QSM.jl's `pad` (default `[0, 0, 0]`, QSM.jl's): `< 0` does not
101    /// pad that axis, `≥ 0` pads it by at least that many voxels, rounded up to QSM.jl's fast
102    /// FFT size (see [`with_qsmjl_padding`](crate::utils::padding::with_qsmjl_padding)).
103    pub pad: [i64; 3],
104}
105
106impl Default for RtsParams {
107    fn default() -> Self {
108        Self {
109            delta: 0.15,
110            mu: 1e5,
111            rho: 10.0,
112            tol: 1e-2,
113            max_iter: 20,
114            lsmr_iter: 4,
115            tau: 0.7,
116            gamma: 5.0,
117            pad: crate::utils::padding::QSMJL_PAD,
118        }
119    }
120}
121
122/// Norm of a Hermitian-symmetric spectrum as QSM.jl sees it: over the `kx = 0..=nx/2` half
123/// that its real-to-complex FFT stores.
124fn half_norm(v: &[Complex64], nx: usize) -> f64 {
125    let half = nx / 2;
126    v.iter()
127        .enumerate()
128        .filter(|(i, _)| i % nx <= half)
129        .map(|(_, c)| c.norm_sqr())
130        .sum::<f64>()
131        .sqrt()
132}
133
134/// LSMR for the diagonal operator `diag(d)` on a spectrum, from x = 0, with `lambda = 0`,
135/// `atol = btol = 0` and `conlim = 1e8`, as QSM.jl's `rts` calls its copy of
136/// IterativeSolvers.jl's `lsmr!` (v0.9.2).
137fn lsmr_diag(d: &[f64], b: &[Complex64], nx: usize, maxit: usize) -> Vec<Complex64> {
138    let n = b.len();
139    let zero = Complex64::new(0.0, 0.0);
140    let mut x = vec![zero; n];
141
142    let mut u = b.to_vec();
143    let mut beta = half_norm(&u, nx);
144    if beta > 0.0 {
145        u.iter_mut().for_each(|c| *c /= beta);
146    }
147    let mut v: Vec<Complex64> = (0..n).map(|i| u[i] * d[i]).collect();
148    let mut alpha = half_norm(&v, nx);
149    if alpha * beta == 0.0 {
150        return x;
151    }
152    if alpha > 0.0 {
153        v.iter_mut().for_each(|c| *c /= alpha);
154    }
155
156    let mut zetabar = alpha * beta;
157    let mut alphabar = alpha;
158    let mut rho = 1.0f64;
159    let mut rhobar = 1.0f64;
160    let mut cbar = 1.0f64;
161    let mut sbar = 0.0f64;
162
163    let mut hbar = vec![zero; n];
164    let mut h = v.clone();
165
166    let mut betadd = beta;
167    let mut betad = 0.0f64;
168    let mut rhodold = 1.0f64;
169    let mut tautildeold = 0.0f64;
170    let mut thetatilde = 0.0f64;
171    let mut zeta = 0.0f64;
172    let mut dsum = 0.0f64;
173
174    let mut norm_a2 = alpha * alpha;
175    let mut maxrbar = 0.0f64;
176    let mut minrbar = f64::MAX;
177    let normb = beta;
178    let ctol = 1e-8; // 1/conlim
179
180    let mut i = 0;
181    while i < maxit {
182        i += 1;
183
184        // u = A v - alpha u
185        for k in 0..n {
186            u[k] = v[k] * d[k] - u[k] * alpha;
187        }
188        beta = half_norm(&u, nx);
189        if beta > 0.0 {
190            u.iter_mut().for_each(|c| *c /= beta);
191            for k in 0..n {
192                v[k] = u[k] * d[k] - v[k] * beta;
193            }
194            alpha = half_norm(&v, nx);
195            if alpha > 0.0 {
196                v.iter_mut().for_each(|c| *c /= alpha);
197            }
198        }
199
200        // lambda = 0: chat = 1, shat = 0
201        let alphahat = alphabar.hypot(0.0);
202        let chat = alphabar / alphahat;
203        let shat = 0.0 / alphahat;
204
205        let rhoold = rho;
206        rho = alphahat.hypot(beta);
207        let c = alphahat / rho;
208        let s = beta / rho;
209        let thetanew = s * alpha;
210        alphabar = c * alpha;
211
212        let rhobarold = rhobar;
213        let zetaold = zeta;
214        let thetabar = sbar * rho;
215        let rhotmp = cbar * rho;
216        rhobar = rhotmp.hypot(thetanew);
217        cbar = rhotmp / rhobar;
218        sbar = thetanew / rhobar;
219        zeta = cbar * zetabar;
220        zetabar *= -sbar;
221
222        let delta = -thetabar * rho / (rhoold * rhobarold);
223        let sigma = zeta / (rho * rhobar);
224        let nu = -thetanew / rho;
225        let mut normx2 = 0.0;
226        for k in 0..n {
227            hbar[k] = hbar[k] * delta + h[k];
228            x[k] += hbar[k] * sigma;
229            h[k] = h[k] * nu + v[k];
230            if k % nx <= nx / 2 {
231                normx2 += x[k].norm_sqr();
232            }
233        }
234        let normx = normx2.sqrt();
235
236        let betaacute = chat * betadd;
237        let betacheck = -shat * betadd;
238        let betahat = c * betaacute;
239        betadd = -s * betaacute;
240
241        let thetatildeold = thetatilde;
242        let rhotildeold = rhodold.hypot(thetabar);
243        let ctildeold = rhodold / rhotildeold;
244        let stildeold = thetabar / rhotildeold;
245        thetatilde = stildeold * rhobar;
246        rhodold = ctildeold * rhobar;
247        betad = -stildeold * betad + ctildeold * betahat;
248
249        tautildeold = (zetaold - thetatildeold * tautildeold) / rhotildeold;
250        let taud = (zeta - thetatilde * tautildeold) / rhodold;
251
252        dsum += betacheck * betacheck;
253        let normr = (dsum + (betad - taud).powi(2) + betadd * betadd).sqrt();
254
255        norm_a2 += beta * beta;
256        let norm_a = norm_a2.sqrt();
257        norm_a2 += alpha * alpha;
258
259        maxrbar = maxrbar.max(rhobarold);
260        if i > 1 {
261            minrbar = minrbar.min(rhobarold);
262        }
263        let cond_a = maxrbar.max(rhotmp) / minrbar.min(rhotmp);
264
265        let norm_ar = zetabar.abs();
266        let test1 = normr / normb;
267        let test2 = norm_ar / (norm_a * normr);
268        let test3 = 1.0 / cond_a;
269        let t1 = test1 / (1.0 + norm_a * normx / normb);
270        let eps_r = 0.0; // btol + atol*... with atol = btol = 0
271
272        if test1 <= eps_r
273            || test2 <= 0.0
274            || test3 <= ctol
275            || 1.0 + t1 <= 1.0
276            || 1.0 + test2 <= 1.0
277            || 1.0 + test3 <= 1.0
278            || i >= maxit
279        {
280            break;
281        }
282    }
283    x
284}
285
286/// RTS dipole inversion (QSM.jl `rts`)
287///
288/// # Arguments
289/// * `local_field` - Local field in ppm (nx * ny * nz)
290/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
291/// * `grid` - Volume grid (dimensions and voxel sizes)
292/// * `bdir` - B0 field direction
293/// * `params` - RTS parameters
294/// * `progress` - Progress callback `(iteration, max_iter)`
295///
296/// # Returns
297/// Susceptibility map in ppm, masked
298pub fn rts(
299    local_field: &[f64],
300    mask: &[u8],
301    grid: &Grid,
302    bdir: (f64, f64, f64),
303    params: &RtsParams,
304    progress: impl FnMut(usize, usize),
305) -> Vec<f64> {
306    crate::utils::padding::with_qsmjl_padding(local_field, mask, grid, params.pad, |f, m, g| {
307        rts_unpadded(f, m, g, bdir, params, progress)
308    })
309}
310
311/// [`rts`] on the already padded grid.
312fn rts_unpadded(
313    local_field: &[f64],
314    mask: &[u8],
315    grid: &Grid,
316    bdir: (f64, f64, f64),
317    params: &RtsParams,
318    mut progress: impl FnMut(usize, usize),
319) -> Vec<f64> {
320    let (nx, ny, nz) = grid.dims;
321    let n = grid.n_total();
322    let zero = Complex64::new(0.0, 0.0);
323
324    let mut fft_ws = Fft3dWorkspace::new(nx, ny, nz);
325    let d = dipole_kernel(grid, bdir);
326
327    // Negative Laplacian kernel in k-space (L)
328    let mut work: Vec<Complex64> = laplacian_kernel(grid, true)
329        .iter()
330        .map(|&v| Complex64::new(v, 0.0))
331        .collect();
332    fft_ws.fft3d(&mut work);
333    let mut l: Vec<f64> = work.iter().map(|c| c.re).collect();
334    // Exactly 0 at DC (the stencil sums to zero; its FFT does only to rounding on anisotropic
335    // voxels, and inverting that rounding puts an arbitrary constant into χ). See the module docs.
336    l[0] = 0.0;
337
338    // M = mu on the well-conditioned frequencies
339    let m: Vec<f64> = d.iter().map(|&dv| if dv.abs() > params.delta { params.mu } else { 0.0 }).collect();
340
341    // ---- Step 1: LSMR on D x = F f, then mask ----
342    for i in 0..n {
343        work[i] = Complex64::new(local_field[i], 0.0);
344    }
345    fft_ws.fft3d(&mut work);
346    let mut xhat = lsmr_diag(&d, &work, nx, params.lsmr_iter);
347    // QSM.jl's irfft of its half spectrum (astewartau/QSM.rs#329; see the module docs)
348    crate::fft::c2r_spectrum(&mut xhat, nx, ny, nz);
349    fft_ws.ifft3d(&mut xhat);
350    let mut x: Vec<f64> = (0..n).map(|i| if mask[i] != 0 { xhat[i].re } else { 0.0 }).collect();
351
352    // ---- Step 2: TV for the ill-conditioned frequencies ----
353    let mut rho = params.rho;
354    for i in 0..n {
355        work[i] = Complex64::new(x[i], 0.0);
356    }
357    fft_ws.fft3d(&mut work);
358    let mut f_hat = vec![zero; n];
359    let mut i_a = vec![0.0f64; n];
360    for i in 0..n {
361        let a = rho * l[i] + m[i];
362        if a != 0.0 {
363            let ia = 1.0 / a;
364            f_hat[i] = work[i] * (ia * m[i]);
365            i_a[i] = rho * ia;
366        }
367    }
368
369    let mut px = vec![0.0f64; n];
370    let mut py = vec![0.0f64; n];
371    let mut pz = vec![0.0f64; n];
372    // v = y - p, starting from v = ∇x
373    let mut vx = vec![0.0f64; n];
374    let mut vy = vec![0.0f64; n];
375    let mut vz = vec![0.0f64; n];
376    fgrad_inplace(&mut vx, &mut vy, &mut vz, &x, grid);
377    let mut dx = vec![0.0f64; n];
378    let mut dy = vec![0.0f64; n];
379    let mut dz = vec![0.0f64; n];
380    let mut div = vec![0.0f64; n];
381    let mut x0 = vec![0.0f64; n];
382    let mut nr = f64::MAX;
383
384    for it in 1..=params.max_iter {
385        progress(it, params.max_iter);
386        std::mem::swap(&mut x, &mut x0);
387
388        // x-subproblem: x = F⁻¹(iA · F(∇ᵀ v) + F̂), with ∇ᵀ = -div
389        bdiv_inplace(&mut div, &vx, &vy, &vz, grid);
390        for i in 0..n {
391            work[i] = Complex64::new(-div[i], 0.0);
392        }
393        fft_ws.fft3d(&mut work);
394        for i in 0..n {
395            work[i] = work[i] * i_a[i] + f_hat[i];
396        }
397        crate::fft::c2r_spectrum(&mut work, nx, ny, nz);
398        fft_ws.ifft3d(&mut work);
399        for i in 0..n {
400            x[i] = work[i].re;
401        }
402
403        // convergence check
404        let (mut ndx2, mut nx2) = (0.0, 0.0);
405        for i in 0..n {
406            let (a, b) = (x[i], x0[i]);
407            ndx2 += (a - b) * (a - b);
408            nx2 += a * a;
409        }
410        if ndx2.sqrt() < params.tol * nx2.sqrt() || it == params.max_iter {
411            progress(it, it);
412            break;
413        }
414
415        // y-subproblem and multiplier update
416        fgrad_inplace(&mut dx, &mut dy, &mut dz, &x, grid);
417        let thr = 1.0 / rho;
418        let update = |p: &mut [f64], v: &mut [f64], dd: &mut [f64]| {
419            for i in 0..n {
420                let di = dd[i];
421                let pi = p[i] + di;
422                let y = if pi.abs() <= thr { 0.0 } else { (pi.abs() - thr).copysign(pi) };
423                p[i] = pi - y;
424                v[i] = y - pi + y;
425                dd[i] = di - y;
426            }
427        };
428        update(&mut px, &mut vx, &mut dx);
429        update(&mut py, &mut vy, &mut dy);
430        update(&mut pz, &mut vz, &mut dz);
431
432        // penalty update on the primal residual Σ|∇x - y|
433        let nr0 = nr;
434        nr = (0..n).map(|i| (dx[i] * dx[i] + dy[i] * dy[i] + dz[i] * dz[i]).sqrt()).sum();
435        if nr > params.tau * nr0 {
436            let rho0 = rho;
437            rho *= params.gamma;
438            for i in 0..n {
439                let iaprev = i_a[i];
440                if iaprev != 0.0 {
441                    let ia = 1.0 / (rho * l[i] + m[i]);
442                    f_hat[i] *= ia * rho0 / iaprev;
443                    i_a[i] = rho * ia;
444                }
445            }
446            let ig = 1.0 / params.gamma;
447            px.iter_mut().for_each(|v| *v *= ig);
448            py.iter_mut().for_each(|v| *v *= ig);
449            pz.iter_mut().for_each(|v| *v *= ig);
450        }
451    }
452
453    apply_mask_zero(&mut x, mask);
454    x
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn test_rts_zero_field() {
463        let n = 8;
464        let field = vec![0.0; n * n * n];
465        let mask = vec![1u8; n * n * n];
466        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
467        let params = RtsParams { max_iter: 5, lsmr_iter: 2, ..Default::default() };
468
469        let chi = rts(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
470
471        for &val in chi.iter() {
472            assert!(val.abs() < 1e-6, "Zero field should give near-zero chi");
473        }
474    }
475
476    #[test]
477    fn test_rts_finite() {
478        let n = 8;
479        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.001).collect();
480        let mask = vec![1u8; n * n * n];
481        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
482        let params = RtsParams { max_iter: 5, lsmr_iter: 2, ..Default::default() };
483
484        let chi = rts(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
485
486        for (i, &val) in chi.iter().enumerate() {
487            assert!(val.is_finite(), "Chi should be finite at index {}", i);
488        }
489    }
490
491    #[test]
492    fn test_rts_mask() {
493        let n = 8;
494        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.001).collect();
495        let mut mask = vec![1u8; n * n * n];
496        // Zero out some mask values
497        mask[0] = 0;
498        mask[10] = 0;
499        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
500        let params = RtsParams { max_iter: 5, lsmr_iter: 2, ..Default::default() };
501
502        let chi = rts(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
503
504        assert_eq!(chi[0], 0.0, "Masked voxel should be zero");
505        assert_eq!(chi[10], 0.0, "Masked voxel should be zero");
506    }
507
508    /// Verify parallel and sequential RTS produce identical results.
509    #[cfg(feature = "parallel")]
510    #[test]
511    fn test_rts_parallel_matches_sequential() {
512        let n = 16;
513        let field: Vec<f64> = (0..n*n*n).map(|i| ((i as f64) * 0.7).sin() * 0.01).collect();
514        let mask = vec![1u8; n * n * n];
515        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
516        let params = RtsParams { tol: 1e-4, ..Default::default() };
517
518        // Sequential (1 thread)
519        let pool_1 = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
520        let chi_seq = pool_1.install(|| {
521            rts(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {})
522        });
523
524        // Parallel (default threads)
525        let chi_par = rts(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
526
527        // Compare
528        for (i, (s, p)) in chi_seq.iter().zip(chi_par.iter()).enumerate() {
529            assert!(
530                (s - p).abs() < 1e-10,
531                "RTS mismatch at voxel {}: seq={} par={} diff={}",
532                i, s, p, (s - p).abs()
533            );
534        }
535    }
536}