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}