Skip to main content

qsm_core/inversion/
tv.rs

1//! Total Variation (TV) regularized dipole inversion using ADMM
2//!
3//! Solves the L1-regularized inverse problem:
4//! min_x ||Dx - f||_2^2 + lambda||grad(x)||_1
5//!
6//! using Alternating Direction Method of Multipliers (ADMM).
7//!
8//! Reference:
9//! Bilgic, B., Fan, A.P., Polimeni, J.R., et al. (2014).
10//! "Fast quantitative susceptibility mapping with L1-regularization and automatic
11//! parameter selection." Magnetic Resonance in Medicine, 72(5):1444-1459.
12//! https://doi.org/10.1002/mrm.25029
13//!
14//! Reference implementation: https://github.com/kamesy/QSM.jl (`tv`), whose defaults these
15//! are: `lambda = 1e-3`, `rho = 100*lambda`, `tol = 1e-3`, 250 iterations. Like QSM.jl, the
16//! field is in ppm and the first x-update starts from the scaled split variable set to the
17//! gradient of the field. QSM.jl's optional data and TV weights (`W`, `Wtv`) are not ported;
18//! their default (`nothing`) is what this function computes.
19//!
20//! QSM.jl is this method's reference: QSM.jl, Kames's maintained successor to QSM.m, is the implementation
21//! designated for this family (QSM.rs's reference rule: the authors' code where it
22//! exists, otherwise one designated implementation per family, the others' values recorded).
23//!
24//! QSM.jl pads the field to its fast FFT size first (`pad = (0, 0, 0)`, `padfastfft`), and so
25//! does this port by default ([`TvParams::pad`]).
26//!
27//! # Half spectrum (tilted B0)
28//!
29//! QSM.jl applies its k-space multipliers on FFTW's real-to-complex half spectrum and inverts
30//! with `irfft`. With B0 tilted from the grid axes the dipole kernel is not even on the Nyquist
31//! planes of an even axis (`D(k) ≠ D(−k)` there, since the index of −½ maps to itself), and
32//! `irfft` resolves that differently from a full complex FFT and its real part. This port uses
33//! the multiplier `irfft` implies ([`crate::fft::c2r_kernel`]/[`crate::fft::c2r_spectrum`],
34//! astewartau/QSM.rs#329), which reproduces QSM.jl there too. Before (2026-10-29) the full-FFT
35//! port differed from QSM.jl on the oblique KKI travelling head (Philips Achieva, B0 tilted) by
36//! 0.5–1.2 % NRMSE for TKD, TSVD, Tikhonov and RTS (QSMxT/QSM-CI parity workflow);
37//! `tests/inversion_qsmjl_parity.rs` now has even-sized oblique cases.
38//!
39//! # Agreement with QSM.jl
40//!
41//! On the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, V-SHARP
42//! local field and brain mask), against QSM.jl in the QSM-CI `julia-qsm` image (`sha256:b3c048ac…`,
43//! QSM.jl fde4b92), both outputs saved as float32:
44//! NRMSE 3.4e-14 % at QSM.jl's defaults. Before this port padded, its defaults differed from
45//! QSM.jl's by NRMSE 6.9 %. `tests/inversion_qsmjl_parity.rs` checks it against QSM.jl's output on
46//! a small phantom (relative L2 ~1e-15). As in [`super::rts`](mod@super::rts), the DC term of the
47//! Laplacian is exactly 0 here; on anisotropic voxels QSM.jl inverts its rounding, which shifts
48//! χ by an arbitrary constant.
49//!
50//! # Reference pipeline
51//! QSM.jl's README pipeline (commit fde4b92, L35-52: BET on the last echo, 5-voxel erosion,
52//! Laplacian unwrapping, V-SHARP), as for [`super::rts`](mod@super::rts).
53
54use crate::utils::{shrink, apply_mask_zero};
55use crate::utils::gradient::fgrad_inplace;
56use crate::Grid;
57use super::admm::{AdmmBuffers, admm_step, prepare_admm_spectral};
58
59/// TV-ADMM algorithm parameters
60#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
61#[derive(Clone, Debug)]
62pub struct TvParams {
63    /// Regularization parameter
64    pub lambda: f64,
65    /// ADMM penalty parameter; `None` is QSM.jl's `100*lambda`
66    pub rho: Option<f64>,
67    /// Convergence tolerance
68    pub tol: f64,
69    /// Maximum iterations
70    pub max_iter: usize,
71    /// Zero padding per axis, QSM.jl's `pad` (default `[0, 0, 0]`, QSM.jl's): `< 0` does not
72    /// pad that axis, `≥ 0` pads it by at least that many voxels, rounded up to QSM.jl's fast
73    /// FFT size (see [`with_qsmjl_padding`](crate::utils::padding::with_qsmjl_padding)).
74    pub pad: [i64; 3],
75}
76
77impl Default for TvParams {
78    fn default() -> Self {
79        Self {
80            lambda: 1e-3,
81            rho: None,
82            tol: 1e-3,
83            max_iter: 250,
84            pad: crate::utils::padding::QSMJL_PAD,
85        }
86    }
87}
88
89/// TV-ADMM dipole inversion
90///
91/// Optimized implementation with:
92/// - Pre-allocated buffers (zero allocations per iteration)
93/// - In-place gradient/divergence operations
94/// - Buffer swapping instead of cloning
95/// - Fused z-subproblem and u-update
96///
97/// # Arguments
98/// * `local_field` - Local field values (nx * ny * nz)
99/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
100/// * `grid` - Volume grid (dimensions and voxel sizes)
101/// * `bdir` - B0 field direction
102/// * `params` - TV-ADMM parameters
103/// * `progress` - Progress callback `(iteration, max_iter)`
104///
105/// # Returns
106/// Susceptibility map
107pub fn tv_admm(
108    local_field: &[f64],
109    mask: &[u8],
110    grid: &Grid,
111    bdir: (f64, f64, f64),
112    params: &TvParams,
113    progress: impl FnMut(usize, usize),
114) -> Vec<f64> {
115    crate::utils::padding::with_qsmjl_padding(local_field, mask, grid, params.pad, |f, m, g| {
116        tv_admm_unpadded(f, m, g, bdir, params, progress)
117    })
118}
119
120/// [`tv_admm`] on the already padded grid.
121fn tv_admm_unpadded(
122    local_field: &[f64],
123    mask: &[u8],
124    grid: &Grid,
125    bdir: (f64, f64, f64),
126    params: &TvParams,
127    mut progress: impl FnMut(usize, usize),
128) -> Vec<f64> {
129    let n_total = grid.n_total();
130
131    let rho = params.rho.unwrap_or(100.0 * params.lambda);
132
133    // Pre-compute spectral operators
134    let (mut fft_ws, inv_a, f_hat) = prepare_admm_spectral(local_field, grid, bdir, rho);
135
136    // Pre-allocate working buffers and run ADMM iterations
137    let mut buf = AdmmBuffers::new(n_total);
138    let lambda_over_rho = params.lambda / rho;
139
140    // QSM.jl starts the split variable at the gradient of the field (`d = ∇f`, with `u = 0`),
141    // so the first x-update already carries the TV term.
142    fgrad_inplace(&mut buf.gx, &mut buf.gy, &mut buf.gz, local_field, grid);
143
144    for iter in 0..params.max_iter {
145        progress(iter + 1, params.max_iter);
146
147        let converged = admm_step(
148            &mut buf, &mut fft_ws, &f_hat, &inv_a, rho, grid, params.tol,
149            |vx, vy, vz, _| (shrink(vx, lambda_over_rho), shrink(vy, lambda_over_rho), shrink(vz, lambda_over_rho)),
150        );
151
152        if converged {
153            progress(iter + 1, iter + 1);
154            break;
155        }
156    }
157
158    apply_mask_zero(&mut buf.x, mask);
159
160    buf.x
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::utils::gradient::fgrad;
167
168    #[test]
169    fn test_shrink() {
170        assert!((shrink(1.0, 0.5) - 0.5).abs() < 1e-10);
171        assert!((shrink(-1.0, 0.5) - (-0.5)).abs() < 1e-10);
172        assert!((shrink(0.3, 0.5) - 0.0).abs() < 1e-10);
173        assert!((shrink(-0.3, 0.5) - 0.0).abs() < 1e-10);
174    }
175
176    #[test]
177    fn test_tv_admm_zero_field() {
178        // Zero field should give zero susceptibility
179        let n = 8;
180        let field = vec![0.0; n * n * n];
181        let mask = vec![1u8; n * n * n];
182        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
183        let params = TvParams { lambda: 1e-3, rho: Some(0.1), tol: 1e-2, max_iter: 10, ..Default::default() };
184
185        let chi = tv_admm(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
186
187        for &val in chi.iter() {
188            assert!(val.abs() < 1e-8, "Zero field should give zero chi, got {}", val);
189        }
190    }
191
192    #[test]
193    fn test_tv_admm_finite() {
194        // Result should be finite
195        let n = 8;
196        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.001).collect();
197        let mask = vec![1u8; n * n * n];
198        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
199        let params = TvParams { lambda: 1e-3, rho: Some(0.1), tol: 1e-2, max_iter: 10, ..Default::default() };
200
201        let chi = tv_admm(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
202
203        for (i, &val) in chi.iter().enumerate() {
204            assert!(val.is_finite(), "Chi should be finite at index {}", i);
205        }
206    }
207
208    #[test]
209    fn test_tv_admm_smoother_than_tkd() {
210        // TV should produce smoother results than TKD
211        let n = 8;
212        // Create noisy field
213        let mut field = vec![0.0; n * n * n];
214        for i in 0..n*n*n {
215            field[i] = if i % 2 == 0 { 0.01 } else { -0.01 };  // Alternating
216        }
217        let mask = vec![1u8; n * n * n];
218        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
219        let params = TvParams { lambda: 1e-2, rho: Some(1.0), tol: 1e-2, max_iter: 50, ..Default::default() };
220
221        let chi_tv = tv_admm(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
222
223        // Compute total variation (L1 norm of gradient)
224        let (gx, gy, gz) = fgrad(&chi_tv, &grid);
225        let tv: f64 = gx.iter().chain(gy.iter()).chain(gz.iter())
226            .map(|&g| g.abs())
227            .sum();
228
229        // TV result should have small total variation
230        // (exact value depends on parameters, but should be bounded)
231        assert!(tv.is_finite(), "TV should be finite");
232    }
233
234    /// Verify parallel and sequential TV-ADMM produce identical results.
235    #[cfg(feature = "parallel")]
236    #[test]
237    fn test_tv_parallel_matches_sequential() {
238        let n = 16;
239        let field: Vec<f64> = (0..n*n*n).map(|i| ((i as f64) * 0.7).sin() * 0.01).collect();
240        let mask = vec![1u8; n * n * n];
241        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
242        let params = TvParams { lambda: 1e-3, rho: Some(0.1), tol: 1e-3, max_iter: 50, ..Default::default() };
243
244        // Sequential (1 thread)
245        let pool_1 = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
246        let chi_seq = pool_1.install(|| {
247            tv_admm(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {})
248        });
249
250        // Parallel (default threads)
251        let chi_par = tv_admm(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
252
253        // Compare
254        for (i, (s, p)) in chi_seq.iter().zip(chi_par.iter()).enumerate() {
255            assert!(
256                (s - p).abs() < 1e-10,
257                "TV mismatch at voxel {}: seq={} par={} diff={}",
258                i, s, p, (s - p).abs()
259            );
260        }
261    }
262}