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}