Skip to main content

qsm_core/inversion/
tikhonov.rs

1//! Tikhonov regularization for QSM
2//!
3//! Tikhonov regularization adds an L2 penalty term to stabilize the inversion:
4//!
5//! chi = argmin_x ||Dx - f||_2^2 + lambda||Gamma*x||_2^2
6//!
7//! This has a closed-form solution in k-space:
8//! chi_hat = D* * f_hat / (|D|^2 + lambda|Gamma|^2)
9//!
10//! Reference:
11//! Bilgic, B., et al. (2014). "Fast image reconstruction with L2-regularization."
12//! Journal of Magnetic Resonance Imaging, 40(1):181-191. https://doi.org/10.1002/jmri.24365
13//!
14//! Reference implementation: https://github.com/kamesy/QSM.jl
15//!
16//! # Reference pipeline
17//! QSM.jl (commit fde4b92) `tikh` (`src/inversion/direct.jl` L100-155): lambda 1e-2 and
18//! `reg = :identity` in the code (L149-150); its docstring's signature line says `:gradient`
19//! (L109) but its keyword list says `:identity` (L131). Same README pipeline as
20//! [`super::rts`](mod@super::rts).
21//!
22//! QSM.jl is this method's reference: its authors released no code, and QSM.jl, Kames's maintained successor to
23//! QSM.m, is the implementation designated for this family (QSM.rs's reference rule: the authors' code where it
24//! exists, otherwise one designated implementation per family, the others' values recorded).
25//!
26//! QSM.jl pads the field to its fast FFT size first (`pad = (0, 0, 0)`, `padfastfft`,
27//! `src/inversion/direct.jl:182-184`), and so does this port by default ([`TikhonovParams::pad`]).
28//!
29//! # Half spectrum (tilted B0)
30//!
31//! QSM.jl applies its k-space multipliers on FFTW's real-to-complex half spectrum and inverts
32//! with `irfft`. With B0 tilted from the grid axes the dipole kernel is not even on the Nyquist
33//! planes of an even axis (`D(k) ≠ D(−k)` there, since the index of −½ maps to itself), and
34//! `irfft` resolves that differently from a full complex FFT and its real part. This port uses
35//! the multiplier `irfft` implies ([`crate::fft::c2r_kernel`]/[`crate::fft::c2r_spectrum`],
36//! astewartau/QSM.rs#329), which reproduces QSM.jl there too. Before (2026-10-29) the full-FFT
37//! port differed from QSM.jl on the oblique KKI travelling head (Philips Achieva, B0 tilted) by
38//! 0.5–1.2 % NRMSE for TKD, TSVD, Tikhonov and RTS (QSMxT/QSM-CI parity workflow);
39//! `tests/inversion_qsmjl_parity.rs` now has even-sized oblique cases.
40//!
41//! # Agreement with QSM.jl
42//!
43//! On the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, V-SHARP
44//! local field and brain mask), against QSM.jl in the QSM-CI `julia-qsm` image (`sha256:b3c048ac…`,
45//! QSM.jl fde4b92), both outputs saved as float32:
46//! identical (NRMSE 0) at QSM.jl's defaults and with padding off on both. Before this port
47//! padded, its defaults differed from QSM.jl's by NRMSE 1.9 %. `tests/inversion_qsmjl_parity.rs`
48//! checks the identity and gradient regularisers against QSM.jl's output on small phantoms.
49
50use num_complex::Complex64;
51use crate::fft::{fft3d, ifft3d};
52use crate::kernels::dipole::dipole_kernel;
53use crate::kernels::laplacian::laplacian_kernel;
54use crate::utils::apply_mask_zero;
55use crate::Grid;
56
57/// Regularization type for Tikhonov
58#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
59#[derive(Clone, Copy, Debug)]
60pub enum Regularization {
61    /// Identity: lambda||x||_2^2
62    Identity,
63    /// Gradient: lambda||grad(x)||_2^2 (uses negative Laplacian)
64    Gradient,
65    /// Laplacian: lambda||lap(x)||_2^2
66    Laplacian,
67}
68
69/// Tikhonov algorithm parameters
70#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
71#[derive(Clone, Debug)]
72pub struct TikhonovParams {
73    /// Regularization weight
74    pub lambda: f64,
75    /// Regularization type
76    pub reg: Regularization,
77    /// Zero padding per axis, QSM.jl's `pad` (default `[0, 0, 0]`, QSM.jl's): `< 0` does not
78    /// pad that axis, `≥ 0` pads it by at least that many voxels, rounded up to QSM.jl's fast
79    /// FFT size (see [`with_qsmjl_padding`](crate::utils::padding::with_qsmjl_padding)).
80    pub pad: [i64; 3],
81}
82
83impl Default for TikhonovParams {
84    fn default() -> Self {
85        Self { lambda: 0.01, reg: Regularization::Identity, pad: crate::utils::padding::QSMJL_PAD }
86    }
87}
88
89/// Tikhonov regularization for dipole inversion
90///
91/// # Arguments
92/// * `local_field` - Local field values
93/// * `mask` - Binary mask (1 = inside ROI, 0 = outside)
94/// * `grid` - Volume grid (dimensions and voxel sizes)
95/// * `bdir` - B0 field direction
96/// * `params` - Tikhonov parameters (lambda, regularization type)
97///
98/// # Returns
99/// Susceptibility map
100pub fn tikhonov(
101    local_field: &[f64],
102    mask: &[u8],
103    grid: &Grid,
104    bdir: (f64, f64, f64),
105    params: &TikhonovParams,
106) -> Vec<f64> {
107    crate::utils::padding::with_qsmjl_padding(local_field, mask, grid, params.pad, |f, m, g| {
108        tikhonov_unpadded(f, m, g, bdir, params)
109    })
110}
111
112/// [`tikhonov`] on the already padded grid.
113fn tikhonov_unpadded(
114    local_field: &[f64],
115    mask: &[u8],
116    grid: &Grid,
117    bdir: (f64, f64, f64),
118    params: &TikhonovParams,
119) -> Vec<f64> {
120    let (nx, ny, nz) = grid.dims;
121    let n_total = grid.n_total();
122    let lambda = params.lambda;
123
124    // Generate dipole kernel
125    let d = dipole_kernel(grid, bdir);
126
127    // Generate regularization kernel and FFT it
128    let gamma: Vec<f64> = match params.reg {
129        Regularization::Identity => {
130            vec![1.0; n_total]
131        }
132        Regularization::Gradient => {
133            // Negative Laplacian for gradient regularization
134            let l = laplacian_kernel(grid, true);
135            // FFT to get frequency response
136            let mut l_complex: Vec<Complex64> = l.iter()
137                .map(|&x| Complex64::new(x, 0.0))
138                .collect();
139            fft3d(&mut l_complex, nx, ny, nz);
140            // Take real part (Laplacian FFT is real)
141            l_complex.iter().map(|c| c.re).collect()
142        }
143        Regularization::Laplacian => {
144            // Laplacian squared
145            let l = laplacian_kernel(grid, false);
146            let mut l_complex: Vec<Complex64> = l.iter()
147                .map(|&x| Complex64::new(x, 0.0))
148                .collect();
149            fft3d(&mut l_complex, nx, ny, nz);
150            // |Gamma|^2
151            l_complex.iter().map(|c| c.re * c.re).collect()
152        }
153    };
154
155    // Compute Tikhonov inverse: D / (D^2 + lambda*Gamma)
156    let inv_d: Vec<f64> = d.iter().zip(gamma.iter()).map(|(&dval, &gval)| {
157        let denom = dval * dval + lambda * gval;
158        if denom.abs() > 1e-20 {
159            dval / denom
160        } else {
161            0.0
162        }
163    }).collect();
164
165    // QSM.jl applies this multiplier on its real-to-complex half spectrum; the kernel `irfft`
166    // implies, so the full FFT reproduces it with tilted B0 (astewartau/QSM.rs#329).
167    let inv_d = crate::fft::c2r_kernel(&inv_d, nx, ny, nz);
168
169    // Convert local field to complex
170    let mut field_complex: Vec<Complex64> = local_field.iter()
171        .map(|&x| Complex64::new(x, 0.0))
172        .collect();
173
174    // FFT of local field
175    fft3d(&mut field_complex, nx, ny, nz);
176
177    // Multiply by Tikhonov inverse
178    for i in 0..n_total {
179        field_complex[i] *= inv_d[i];
180    }
181
182    // IFFT to get susceptibility
183    ifft3d(&mut field_complex, nx, ny, nz);
184
185    // Extract real part and apply mask
186    let mut chi: Vec<f64> = field_complex.iter()
187        .map(|c| c.re)
188        .collect();
189
190    apply_mask_zero(&mut chi, mask);
191
192    chi
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_tikhonov_zero_field() {
201        let n = 8;
202        let field = vec![0.0; n * n * n];
203        let mask = vec![1u8; n * n * n];
204        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
205
206        let chi = tikhonov(&field, &mask, &grid, (0.0, 0.0, 1.0), &TikhonovParams::default());
207
208        for val in chi.iter() {
209            assert!(val.abs() < 1e-10, "Zero field should give zero chi");
210        }
211    }
212
213    #[test]
214    fn test_tikhonov_finite() {
215        let n = 8;
216        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.01).collect();
217        let mask = vec![1u8; n * n * n];
218        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
219
220        let chi = tikhonov(&field, &mask, &grid, (0.0, 0.0, 1.0), &TikhonovParams::default());
221
222        for (i, val) in chi.iter().enumerate() {
223            assert!(val.is_finite(), "Chi should be finite at index {}", i);
224        }
225    }
226
227    #[test]
228    fn test_tikhonov_regularization_types() {
229        let n = 8;
230        let field: Vec<f64> = (0..n*n*n).map(|i| ((i as f64) * 0.1).sin()).collect();
231        let mask = vec![1u8; n * n * n];
232        let bdir = (0.0, 0.0, 1.0);
233        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
234
235        let chi_id = tikhonov(&field, &mask, &grid, bdir,
236                             &TikhonovParams { lambda: 0.01, reg: Regularization::Identity, ..Default::default() });
237        let chi_grad = tikhonov(&field, &mask, &grid, bdir,
238                               &TikhonovParams { lambda: 0.01, reg: Regularization::Gradient, ..Default::default() });
239        let chi_lap = tikhonov(&field, &mask, &grid, bdir,
240                              &TikhonovParams { lambda: 0.01, reg: Regularization::Laplacian, ..Default::default() });
241
242        // All should be different
243        let diff_ig: f64 = chi_id.iter().zip(chi_grad.iter())
244            .map(|(a, b)| (a - b).abs()).sum();
245        let diff_gl: f64 = chi_grad.iter().zip(chi_lap.iter())
246            .map(|(a, b)| (a - b).abs()).sum();
247
248        assert!(diff_ig > 1e-10, "Identity and Gradient should differ");
249        assert!(diff_gl > 1e-10, "Gradient and Laplacian should differ");
250    }
251}