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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {})
248 });
249
250 // Parallel (default threads)
251 let chi_par = tv_admm(&field, &mask, &grid, (0.0, 0.0, 1.0), ¶ms, |_, _| {});
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}