Skip to main content

qsm_core/inversion/
tkd.rs

1//! Truncated k-space division (TKD) / TSVD for QSM
2//!
3//! TKD is the simplest dipole inversion method. It directly divides the
4//! field in k-space by the dipole kernel, with truncation to avoid
5//! division by small values near the magic angle.
6//!
7//! Reference:
8//! Shmueli, K., de Zwart, J.A., van Gelderen, P., Li, T.Q., Dodd, S.J., Duyn, J.H. (2009).
9//! "Magnetic susceptibility mapping of brain tissue in vivo using MRI phase data."
10//! Magnetic Resonance in Medicine, 62(6):1510-1522. https://doi.org/10.1002/mrm.22135
11//!
12//! Reference implementation: https://github.com/kamesy/QSM.jl
13//!
14//! QSM.jl is this method's reference: its authors released no code, and QSM.jl, Kames's maintained successor to
15//! QSM.m, is the implementation designated for this family (QSM.rs's reference rule: the authors' code where it
16//! exists, otherwise one designated implementation per family, the others' values recorded).
17//!
18//! QSM.jl pads the field to its fast FFT size first (`pad = (0, 0, 0)`, `padfastfft`,
19//! `src/inversion/direct.jl:182-184`), and so does this port by default ([`TkdParams::pad`]).
20//!
21//! # Agreement with QSM.jl
22//!
23//! On the QSM-CI in-vivo travelling-head scan (Siemens Prisma 2.89 T, 1 mm, 184×256×128, V-SHARP
24//! local field and brain mask), against QSM.jl in the QSM-CI `julia-qsm` image (`sha256:b3c048ac…`,
25//! QSM.jl fde4b92), both outputs saved as float32:
26//! identical (NRMSE 0) for `tkd` and `tsvd` at QSM.jl's defaults and with padding off on both.
27//! Before this port padded, its defaults differed from QSM.jl's by NRMSE 7.5 % (TKD) and 15.6 %
28//! (TSVD). `tests/inversion_qsmjl_parity.rs` checks both against QSM.jl's output on small
29//! phantoms, including tilted B0 on even sizes.
30//!
31//! # Half spectrum (tilted B0)
32//!
33//! QSM.jl applies its k-space multipliers on FFTW's real-to-complex half spectrum and inverts
34//! with `irfft`. With B0 tilted from the grid axes the dipole kernel is not even on the Nyquist
35//! planes of an even axis (`D(k) ≠ D(−k)` there, since the index of −½ maps to itself), and
36//! `irfft` resolves that differently from a full complex FFT and its real part. This port uses
37//! the multiplier `irfft` implies ([`crate::fft::c2r_kernel`]/[`crate::fft::c2r_spectrum`],
38//! astewartau/QSM.rs#329), which reproduces QSM.jl there too. Before (2026-10-29) the full-FFT
39//! port differed from QSM.jl on the oblique KKI travelling head (Philips Achieva, B0 tilted) by
40//! 0.5–1.2 % NRMSE for TKD, TSVD, Tikhonov and RTS (QSMxT/QSM-CI parity workflow);
41//! `tests/inversion_qsmjl_parity.rs` now has even-sized oblique cases.
42//!
43//! # Reference pipeline
44//! QSM.jl (commit fde4b92) `tkd`/`tsvd` (`src/inversion/direct.jl` L1-96), threshold 0.15 for
45//! both: TKD replaces `1/D` by `sign(D)/thr` where `|D| <= thr`, TSVD by 0. QSM.jl's README
46//! pipeline (L35-52: BET on the last echo, 5-voxel erosion, Laplacian unwrapping, V-SHARP) is the
47//! one its inversions are shown with; see [`super::rts`](mod@super::rts).
48
49use num_complex::Complex64;
50use crate::fft::{fft3d, ifft3d};
51use crate::kernels::dipole::dipole_kernel;
52use crate::utils::apply_mask_zero;
53use crate::Grid;
54
55/// TKD algorithm parameters
56#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
57#[derive(Clone, Debug)]
58pub struct TkdParams {
59    /// Truncation threshold (typically 0.1-0.2)
60    pub threshold: f64,
61    /// Zero padding per axis, QSM.jl's `pad` (default `[0, 0, 0]`, QSM.jl's): `< 0` does not
62    /// pad that axis, `≥ 0` pads it by at least that many voxels, rounded up to QSM.jl's fast
63    /// FFT size (see [`with_qsmjl_padding`](crate::utils::padding::with_qsmjl_padding)).
64    pub pad: [i64; 3],
65}
66
67impl Default for TkdParams {
68    fn default() -> Self {
69        Self { threshold: 0.15, pad: crate::utils::padding::QSMJL_PAD }
70    }
71}
72
73/// Truncated k-space division (TKD) for dipole inversion
74///
75/// Computes susceptibility map from local field using direct k-space division
76/// with threshold-based truncation.
77///
78/// # Arguments
79/// * `local_field` - Local field values (unwrapped phase / TE / gamma / B0)
80/// * `mask` - Binary mask (1 = inside ROI, 0 = outside)
81/// * `grid` - Volume grid (dimensions and voxel sizes)
82/// * `bdir` - B0 field direction (bx, by, bz)
83/// * `params` - TKD parameters (truncation threshold)
84///
85/// # Returns
86/// Susceptibility map (same size as input)
87pub fn tkd(
88    local_field: &[f64],
89    mask: &[u8],
90    grid: &Grid,
91    bdir: (f64, f64, f64),
92    params: &TkdParams,
93) -> Vec<f64> {
94    crate::utils::padding::with_qsmjl_padding(local_field, mask, grid, params.pad, |f, m, g| {
95        tkd_unpadded(f, m, g, bdir, params)
96    })
97}
98
99/// [`tkd`] on the already padded grid.
100fn tkd_unpadded(
101    local_field: &[f64],
102    mask: &[u8],
103    grid: &Grid,
104    bdir: (f64, f64, f64),
105    params: &TkdParams,
106) -> Vec<f64> {
107    let (nx, ny, nz) = grid.dims;
108    let n_total = grid.n_total();
109    let threshold = params.threshold;
110
111    // Generate dipole kernel
112    let d = dipole_kernel(grid, bdir);
113
114    // Compute inverse dipole kernel with truncation
115    // TKD: if |D| <= threshold, use sign(D)/threshold; else use 1/D
116    let inv_threshold = 1.0 / threshold;
117    let inv_d: Vec<f64> = d.iter().map(|&dval| {
118        if dval.abs() <= threshold {
119            // Truncate: use sign(D)/threshold
120            if dval >= 0.0 { inv_threshold } else { -inv_threshold }
121        } else {
122            // Normal inverse
123            1.0 / dval
124        }
125    }).collect();
126
127    // QSM.jl applies this multiplier on its real-to-complex half spectrum; the kernel `irfft`
128    // implies, so the full FFT reproduces it with tilted B0 (astewartau/QSM.rs#329).
129    let inv_d = crate::fft::c2r_kernel(&inv_d, nx, ny, nz);
130
131    // Convert local field to complex
132    let mut field_complex: Vec<Complex64> = local_field.iter()
133        .map(|&x| Complex64::new(x, 0.0))
134        .collect();
135
136    // FFT of local field
137    fft3d(&mut field_complex, nx, ny, nz);
138
139    // Multiply by inverse dipole kernel
140    for i in 0..n_total {
141        field_complex[i] *= inv_d[i];
142    }
143
144    // IFFT to get susceptibility
145    ifft3d(&mut field_complex, nx, ny, nz);
146
147    // Extract real part and apply mask
148    let mut chi: Vec<f64> = field_complex.iter()
149        .map(|c| c.re)
150        .collect();
151
152    apply_mask_zero(&mut chi, mask);
153
154    chi
155}
156
157/// Truncated singular value decomposition (TSVD) variant
158///
159/// Similar to TKD but zeros out values below threshold instead of truncating.
160/// This produces smoother results but may have more artifacts at the magic angle.
161pub fn tsvd(
162    local_field: &[f64],
163    mask: &[u8],
164    grid: &Grid,
165    bdir: (f64, f64, f64),
166    params: &TkdParams,
167) -> Vec<f64> {
168    crate::utils::padding::with_qsmjl_padding(local_field, mask, grid, params.pad, |f, m, g| {
169        tsvd_unpadded(f, m, g, bdir, params)
170    })
171}
172
173/// [`tsvd`] on the already padded grid.
174fn tsvd_unpadded(
175    local_field: &[f64],
176    mask: &[u8],
177    grid: &Grid,
178    bdir: (f64, f64, f64),
179    params: &TkdParams,
180) -> Vec<f64> {
181    let (nx, ny, nz) = grid.dims;
182    let n_total = grid.n_total();
183    let threshold = params.threshold;
184
185    // Generate dipole kernel
186    let d = dipole_kernel(grid, bdir);
187
188    // Compute inverse dipole kernel with TSVD truncation
189    // TSVD: if |D| <= threshold, use 0; else use 1/D
190    let inv_d: Vec<f64> = d.iter().map(|&dval| {
191        if dval.abs() <= threshold {
192            0.0  // Zero out small values
193        } else {
194            1.0 / dval
195        }
196    }).collect();
197
198    // QSM.jl applies this multiplier on its real-to-complex half spectrum; the kernel `irfft`
199    // implies, so the full FFT reproduces it with tilted B0 (astewartau/QSM.rs#329).
200    let inv_d = crate::fft::c2r_kernel(&inv_d, nx, ny, nz);
201
202    // Convert local field to complex
203    let mut field_complex: Vec<Complex64> = local_field.iter()
204        .map(|&x| Complex64::new(x, 0.0))
205        .collect();
206
207    // FFT of local field
208    fft3d(&mut field_complex, nx, ny, nz);
209
210    // Multiply by inverse dipole kernel
211    for i in 0..n_total {
212        field_complex[i] *= inv_d[i];
213    }
214
215    // IFFT to get susceptibility
216    ifft3d(&mut field_complex, nx, ny, nz);
217
218    // Extract real part and apply mask
219    let mut chi: Vec<f64> = field_complex.iter()
220        .map(|c| c.re)
221        .collect();
222
223    apply_mask_zero(&mut chi, mask);
224
225    chi
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_tkd_zero_field() {
234        // Zero field should give zero susceptibility
235        let n = 8;
236        let field = vec![0.0; n * n * n];
237        let mask = vec![1u8; n * n * n];
238        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
239
240        let chi = tkd(&field, &mask, &grid, (0.0, 0.0, 1.0), &TkdParams { threshold: 0.15, ..Default::default() });
241
242        for val in chi.iter() {
243            assert!(val.abs() < 1e-10, "Zero field should give zero chi");
244        }
245    }
246
247    #[test]
248    fn test_tkd_mask() {
249        // Values outside mask should be zero
250        let n = 8;
251        let field = vec![1.0; n * n * n];
252        let mut mask = vec![1u8; n * n * n];
253        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
254
255        // Set some voxels outside mask
256        mask[0] = 0;
257        mask[1] = 0;
258
259        let chi = tkd(&field, &mask, &grid, (0.0, 0.0, 1.0), &TkdParams { threshold: 0.15, ..Default::default() });
260
261        assert_eq!(chi[0], 0.0, "Outside mask should be 0");
262        assert_eq!(chi[1], 0.0, "Outside mask should be 0");
263    }
264
265    #[test]
266    fn test_tkd_finite() {
267        // Result should be finite (no NaN or Inf)
268        let n = 8;
269        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.01).collect();
270        let mask = vec![1u8; n * n * n];
271        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
272
273        let chi = tkd(&field, &mask, &grid, (0.0, 0.0, 1.0), &TkdParams { threshold: 0.15, ..Default::default() });
274
275        for (i, val) in chi.iter().enumerate() {
276            assert!(val.is_finite(), "Chi should be finite at index {}", i);
277        }
278    }
279
280    #[test]
281    fn test_tsvd_vs_tkd() {
282        // TSVD and TKD should give different results near magic angle
283        let n = 16;
284        let field: Vec<f64> = (0..n*n*n).map(|i| ((i as f64) * 0.1).sin()).collect();
285        let mask = vec![1u8; n * n * n];
286        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
287
288        let chi_tkd = tkd(&field, &mask, &grid, (0.0, 0.0, 1.0), &TkdParams { threshold: 0.15, ..Default::default() });
289        let chi_tsvd = tsvd(&field, &mask, &grid, (0.0, 0.0, 1.0), &TkdParams { threshold: 0.15, ..Default::default() });
290
291        // They should be different
292        let diff: f64 = chi_tkd.iter().zip(chi_tsvd.iter())
293            .map(|(a, b)| (a - b).abs())
294            .sum();
295
296        assert!(diff > 1e-10, "TKD and TSVD should give different results");
297    }
298}