qsm_core/bgremove/sdf.rs
1//! Spatially Dependent Filtering (SDF) for QSMART
2//!
3//! SDF is the background field removal method used in QSMART. It uses variable-radius
4//! Gaussian filtering where the kernel size depends on the proximity to the brain boundary.
5//! This allows for aggressive filtering in the brain interior while preserving details
6//! near the surface.
7//!
8//! The algorithm includes optional curvature-based weighting to further reduce artifacts
9//! at highly curved brain regions.
10//!
11//! ## Reference pipeline
12//!
13//! QSMART runs SDF twice on its echo-fit total field (`QSMART_SDF.m`): stage 1 on the BET mask
14//! × `R_0` with no vessels and `sdf_sigma1` (10) passed as **both** widths (l. 12), so σ =
15//! √(10² + 10²) ≈ 14.1 (`sdf_curvature.m` l. 8); stage 2 on the field × mask × `R_0` with the
16//! vessel mask, σ1 = 8, σ2 = 2 (l. 20). Both use `lowerLim` 0.6, curvature constant 500 and
17//! an indent mask from closing the brain mask with `strel('sphere', 8)` voxels
18//! (`Demo_QSMART.m` l. 51–57). The paper instead lists σ_C = 10, σ_V = 2, σ_I = 10, σ = 10,
19//! C_s = 500 (Yaghmaie et al. 2021, §3.4); [`SdfParams`] follows the toolbox. The mask is not
20//! eroded first (paper §1, §5). Parameters were tuned on 7T human data at 0.6 mm; the Gaussian
21//! widths are in voxels (`imgaussfilt3`), so they are not rescaled for other voxel sizes, and
22//! the cortical proximity map uses `[σ1, 2σ1, 2σ1]` (`sdf_curvature.m` l. 12): half the width
23//! along the first voxel axis, whatever the orientation.
24//!
25//! Reference:
26//! Yaghmaie, N., Syeda, W., et al. (2021).
27//! "QSMART: Quantitative Susceptibility Mapping Artifact Reduction Technique."
28//! NeuroImage, 231:117701. https://doi.org/10.1016/j.neuroimage.2020.117701
29//!
30//! Reference implementation: https://github.com/wtsyeda/QSMART
31//!
32//! # Agreement with the original
33//!
34//! `tests/qsmart_qsmci_parity.rs` (measured 2026-11-02) runs QSMART's stages on the inputs the
35//! verified QSM-CI `qsmart-matlab` v4 container (QSMxT/QSM-CI#296, #355, bit-identical to licensed
36//! MATLAB) gave its own, on a 36×36×28 vessel phantom at 3 T; the fixture is regenerated in Actions
37//! by `tests/data/qsmart_qsmci_small.py`. Given the toolbox's total field and `mask·R_0` (and stage
38//! 2 its vessel mask), [`sdf`] is **3.8e-5 %** from `QSMART_SDF` stage 1 (max |Δ| 5e-9 ppm) and
39//! **1.6e-5 %** from stage 2 (max |Δ| 2.7e-8 ppm): the fixture's float32 storage. The 10.9 % and
40//! 2.1 % measured before were the curvature step's triangulation and arithmetic
41//! (astewartau/QSM.rs#320, fixed by #370; see [`crate::utils::curvature`]). Stage 1's widths are
42//! the toolbox's (σ1 = σ2 = 10, above); with σ2 = 0, as `recon.m`'s `s1.sdf_sigma2`, it is 149 %
43//! off.
44//!
45//! At full size (QSM-CI's simulated head, 164×205×205, and KKI, 256×256×128; measured 2026-11-03,
46//! astewartau/QSM.rs#409) the local fields are 0.075 % / 0.20 % (sim, stage 1 / 2) and
47//! 0.13 % / 0.73 % (KKI) from the original's. An instrumented copy of `sdf_curvature.m` and
48//! `calculate_curvature.m` under licensed MATLAB R2023b reproduces the container to 5e-15 %, and
49//! given MATLAB's curvature map `curvI`, [`sdf`]'s smoothing widths are MATLAB's in every voxel of
50//! both stages and both cases. All of the difference is the curvature weight of flat surface
51//! vertices (see [`crate::utils::curvature`]): 321 of 6,011 (sim) and 590 of 9,003 (KKI) get 1
52//! where MATLAB gives 0 or the reverse, which moves 764 / 2,518 voxels to a neighbouring width in
53//! stage 1. The proximity's rounding to two decimals and the vessel-proximity step add nothing.
54
55use crate::Grid;
56use crate::utils::curvature::calculate_curvature_proximity;
57use crate::utils::portable_math as pm;
58
59/// Parameters for SDF background field removal
60///
61/// There is no indent/closing radius: the reference `QSMART_SDF.m` builds an indent mask from
62/// `sdf_sp_radius` but never passes it to `sdf_curvature.m`, so the radius has no effect.
63#[derive(Clone, Debug)]
64pub struct SdfParams {
65 /// Sigma parameter for initial proximity map (default 10 for stage 1, 8 for stage 2)
66 pub sigma1: f64,
67 /// Sigma parameter for vasculature proximity (default 0 for stage 1, 2 for stage 2)
68 pub sigma2: f64,
69 /// Lower limit for clamping proximity values (default 0.6)
70 pub lower_lim: f64,
71 /// Scaling constant for curvature (default 500)
72 pub curv_constant: f64,
73 /// Whether to use curvature-based edge refinement
74 pub use_curvature: bool,
75 /// Whether to weight the proximity map by the vessel proximity (the Gaussian of `vasc_only`
76 /// with width `sigma2`). `sdf_curvature.m` does this in stage 2 only (`if stage==2`, l. 17);
77 /// stage 1 passes no vessels and skips it, though its `sigma2` is 10. (Multiplying stage 1 by
78 /// the Gaussian of all-ones, as QSM.rs did before, changes the proximity by an ulp and, on
79 /// QSM-CI's full-size cases, no smoothing width.)
80 pub vessel_proximity: bool,
81}
82
83impl Default for SdfParams {
84 fn default() -> Self {
85 Self {
86 sigma1: 10.0,
87 // MATLAB passes sigma1 for both sigma args in stage 1.
88 // sigma2 affects the combined sigma even though vasculature
89 // proximity is skipped when vasc_only is all-ones.
90 sigma2: 10.0,
91 lower_lim: 0.6,
92 curv_constant: 500.0,
93 use_curvature: true,
94 vessel_proximity: false,
95 }
96 }
97}
98
99impl SdfParams {
100 /// Create parameters for QSMART Stage 1
101 /// MATLAB passes sigma1 for both sigma args in stage 1.
102 pub fn stage1() -> Self {
103 Self {
104 sigma1: 10.0,
105 sigma2: 10.0,
106 lower_lim: 0.6,
107 curv_constant: 500.0,
108 use_curvature: true,
109 vessel_proximity: false,
110 }
111 }
112
113 /// Create parameters for QSMART Stage 2
114 pub fn stage2() -> Self {
115 Self {
116 sigma1: 8.0,
117 sigma2: 2.0,
118 lower_lim: 0.6,
119 curv_constant: 500.0,
120 use_curvature: true,
121 vessel_proximity: true,
122 }
123 }
124}
125
126/// SDF background field removal
127///
128/// Removes background field from total field shift using spatially dependent filtering.
129///
130/// # Arguments
131/// * `tfs` - Total field shift (unwrapped phase / ppm)
132/// * `mask` - Binary brain mask (weighted by reliability if R_0 is incorporated)
133/// * `vasc_only` - Vasculature-only mask (1 = tissue, 0 = vessel). Pass all-ones for stage 1.
134/// * `grid` - Volume dimensions and voxel sizes
135/// * `params` - SDF parameters
136/// * `progress` - Progress callback (alpha_index, total_alphas)
137///
138/// # Returns
139/// Local field shift (background removed)
140pub fn sdf(
141 tfs: &[f64],
142 mask: &[f64],
143 vasc_only: &[f64],
144 grid: &Grid,
145 params: &SdfParams,
146 progress: impl Fn(usize, usize),
147) -> Vec<f64> {
148 let (nx, ny, nz) = grid.dims;
149 let n_total = nx * ny * nz;
150
151 // Convert mask to binary for morphological operations
152 let mask_binary: Vec<u8> = mask.iter().map(|&v| if v > 0.0 { 1 } else { 0 }).collect();
153
154 // Combined sigma for n calculation
155 let sigma = (params.sigma1 * params.sigma1 + params.sigma2 * params.sigma2).sqrt();
156 let n = if sigma > 0.0 { -pm::ln(sigma) / pm::ln(0.5) } else { 0.0 };
157
158 // Calculate initial proximity map (prox1)
159 // Gaussian smoothing of mask with anisotropic kernel [sigma1, 2*sigma1, 2*sigma1]
160 let prox1 = if params.sigma1 > 0.0 {
161 gaussian_smooth_3d_masked_f64(mask, mask, nx, ny, nz, &[params.sigma1, 2.0 * params.sigma1, 2.0 * params.sigma1])
162 } else {
163 mask.to_vec()
164 };
165
166 // Calculate curvature-based proximity if enabled
167 let prox = if params.use_curvature {
168 let (prox_curv, _curv_i) = calculate_curvature_proximity(
169 &mask_binary,
170 &prox1,
171 params.lower_lim,
172 params.curv_constant,
173 params.sigma1,
174 grid,
175 );
176 prox_curv
177 } else {
178 // Even without curvature, clamp proximity to lower_lim to prevent
179 // filter sizes from getting too small at the edges
180 // (matching calculate_curvature.m line 45: prox(prox < lowerLim & prox ~= 0) = lowerLim)
181 prox1.iter()
182 .zip(mask.iter())
183 .map(|(&p, &m)| {
184 if m > 0.0 && p > 0.0 && p < params.lower_lim {
185 params.lower_lim
186 } else {
187 p
188 }
189 })
190 .collect()
191 };
192
193 // Calculate vasculature proximity (prox2) for stage 2
194 let prox_final = if params.vessel_proximity && params.sigma2 > 0.0 {
195 let prox2 = gaussian_smooth_3d_masked_f64(vasc_only, mask, nx, ny, nz, &[params.sigma2, params.sigma2, params.sigma2]);
196 // Multiply prox * prox2
197 prox.iter().zip(prox2.iter()).map(|(&p, &p2)| p * p2).collect()
198 } else {
199 prox
200 };
201
202 // alpha = sigma*round(prox.^n,2).*mask (sdf_curvature.m): each voxel's smoothing width
203 let mut alpha: Vec<f64> = prox_final.iter()
204 .zip(mask.iter())
205 .map(|(&p, &m)| if m > 0.0 { smoothing_width(p, n, sigma) } else { 0.0 })
206 .collect();
207
208 // Set alpha=1 for vessel regions within mask
209 // (vasc_only=0 means vessel, matching sdf_curvature.m line 27)
210 for i in 0..n_total {
211 if mask[i] > 0.0 && vasc_only[i] == 0.0 {
212 alpha[i] = 1.0;
213 }
214 }
215
216 // Get unique alpha values and sort
217 let mut unique_alphas: Vec<f64> = alpha.iter()
218 .filter(|&&a| a > 0.0)
219 .copied()
220 .collect();
221 unique_alphas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
222 unique_alphas.dedup();
223
224 let total_alphas = unique_alphas.len();
225
226 // Create index map: for each voxel, which alpha index does it belong to?
227 let mut alpha_index: Vec<usize> = vec![0; n_total];
228 for i in 0..n_total {
229 if alpha[i] > 0.0 {
230 // Find index in unique_alphas
231 let idx = unique_alphas.iter().position(|&a| (a - alpha[i]).abs() < 1e-10).unwrap_or(0);
232 alpha_index[i] = idx + 1; // 1-indexed to distinguish from background
233 }
234 }
235
236 // Apply spatially dependent filtering
237 // For each unique alpha, smooth and assign to corresponding voxels
238 let mut background = vec![0.0f64; n_total];
239
240 // Pre-compute filter size
241 let filter_size = 2 * (2.0 * sigma).ceil() as usize + 1;
242
243 for (alpha_idx, ¤t_alpha) in unique_alphas.iter().enumerate() {
244 progress(alpha_idx, total_alphas);
245
246 // Compute smoothed field for this alpha
247 let smoothed: Vec<f64> = if current_alpha > 0.0 {
248 // Smooth tfs * mask with Gaussian kernel of size current_alpha
249 let weighted_tfs: Vec<f64> = tfs.iter()
250 .zip(mask.iter())
251 .map(|(&t, &m)| t * m)
252 .collect();
253
254 let num = gaussian_smooth_3d_with_filter_size(&weighted_tfs, nx, ny, nz, current_alpha, filter_size);
255 let denom = gaussian_smooth_3d_with_filter_size(mask, nx, ny, nz, current_alpha, filter_size);
256
257 // Divide: num / denom
258 num.iter()
259 .zip(denom.iter())
260 .map(|(&n, &d)| if d.abs() > 1e-10 { n / d } else { 0.0 })
261 .collect()
262 } else {
263 // alpha=0: just use tfs*mask
264 tfs.iter().zip(mask.iter()).map(|(&t, &m)| t * m).collect()
265 };
266
267 // Assign to voxels with this alpha
268 for i in 0..n_total {
269 if alpha_index[i] == alpha_idx + 1 {
270 background[i] = smoothed[i];
271 }
272 }
273 }
274
275 // sdf_curvature.m filters alpha == 0 as `PhiK = tfs.*mask`: where the rounded proximity is
276 // 0, the background is the whole field and the local field is 0.
277 for i in 0..n_total {
278 if mask[i] > 0.0 && alpha[i] == 0.0 {
279 background[i] = tfs[i] * mask[i];
280 }
281 }
282
283 progress(total_alphas, total_alphas);
284
285 // Compute local field: (tfs - background) * mask
286 let local_field: Vec<f64> = tfs.iter()
287 .zip(background.iter())
288 .zip(mask.iter())
289 .map(|((&t, &b), &m)| (t - b) * m)
290 .collect();
291
292 local_field
293}
294
295/// The Gaussian width SDF smooths a voxel with: `sigma*round(prox^n, 2)`, as `sdf_curvature.m`
296/// computes it.
297///
298/// Rounding to two decimals makes the width a discrete choice, which has to come out the same on
299/// every target (or the browser and `qsmxt run` smooth a voxel on a rounding boundary with
300/// different widths, and the inner inversion spreads the difference) and the same as MATLAB's:
301///
302/// - `prox^n` comes from [`portable_math::pow`](crate::utils::portable_math::pow), and the
303/// proximity map it is computed from uses only portable functions too (the Gaussian kernels
304/// here and the curvature's angles). The platform's `powf` differs between targets in the last
305/// bit for about one input in ten, and a one-ulp difference flips the rounding whenever the
306/// value sits within an ulp of a hundredth's midpoint.
307/// - The operations are MATLAB's, in MATLAB's order: `round(x, 2)` is `round(x*100)/100`, with
308/// halves rounded away from zero (as [`f64::round`] does), then multiplied by `sigma`. With
309/// QSMART's own proximity map (`prox.nii` from its 3 T reference run) as input, this gives its
310/// `alpha.nii` bit for bit, for both stages. `sigma*round(x*100)/100` evaluated left to right
311/// (as before) differs in the last bit in a quarter of the brain.
312///
313/// No libm is correctly rounded, MATLAB's included, so a value within an ulp of a midpoint can
314/// still round differently from MATLAB; away from midpoints every `pow` within an ulp of the
315/// exact value gives MATLAB's width.
316fn smoothing_width(prox: f64, n: f64, sigma: f64) -> f64 {
317 sigma * ((pm::pow(prox, n) * 100.0).round() / 100.0)
318}
319
320/// SDF with curvature-based weighting (full QSMART pipeline)
321///
322/// This is the main entry point matching QSMART's sdf_curvature function.
323pub fn sdf_curvature(
324 tfs: &[f64],
325 mask: &[f64],
326 vasc_only: &[f64],
327 grid: &Grid,
328 params: &SdfParams,
329) -> Vec<f64> {
330 // Ensure curvature is enabled
331 let params_with_curv = SdfParams {
332 use_curvature: true,
333 ..params.clone()
334 };
335
336 sdf(tfs, mask, vasc_only, grid, ¶ms_with_curv, |_, _| {})
337}
338
339/// 3D Gaussian smoothing with specified filter size
340fn gaussian_smooth_3d_with_filter_size(
341 data: &[f64],
342 nx: usize, ny: usize, nz: usize,
343 sigma: f64,
344 filter_size: usize,
345) -> Vec<f64> {
346 if sigma <= 0.0 {
347 return data.to_vec();
348 }
349
350 // Create 1D Gaussian kernel
351 let kernel_radius = (filter_size - 1) / 2;
352 let mut kernel = vec![0.0f64; filter_size];
353
354 let mut sum = 0.0;
355 for i in 0..filter_size {
356 let x = i as f64 - kernel_radius as f64;
357 kernel[i] = pm::exp(-x * x / (2.0 * sigma * sigma));
358 sum += kernel[i];
359 }
360
361 // Normalize
362 for k in kernel.iter_mut() {
363 *k /= sum;
364 }
365
366 // Apply separable convolution
367 let smoothed_x = convolve_1d_direction(data, nx, ny, nz, &kernel, 'x');
368 let smoothed_xy = convolve_1d_direction(&smoothed_x, nx, ny, nz, &kernel, 'y');
369 convolve_1d_direction(&smoothed_xy, nx, ny, nz, &kernel, 'z')
370}
371
372/// Gaussian smoothing with anisotropic sigma and mask
373fn gaussian_smooth_3d_masked_f64(
374 data: &[f64],
375 mask: &[f64],
376 nx: usize, ny: usize, nz: usize,
377 sigmas: &[f64; 3],
378) -> Vec<f64> {
379 // Apply separable 1D convolutions
380 let smoothed_x = convolve_1d_direction_sigma(data, nx, ny, nz, sigmas[0], 'x');
381 let smoothed_xy = convolve_1d_direction_sigma(&smoothed_x, nx, ny, nz, sigmas[1], 'y');
382 let smoothed_xyz = convolve_1d_direction_sigma(&smoothed_xy, nx, ny, nz, sigmas[2], 'z');
383
384 // Apply mask
385 smoothed_xyz.iter()
386 .zip(mask.iter())
387 .map(|(&v, &m)| if m > 0.0 { v } else { 0.0 })
388 .collect()
389}
390
391/// 1D convolution along specified axis with replicate padding
392/// Matches MATLAB's imgaussfilt3 default behavior
393fn convolve_1d_direction(
394 data: &[f64],
395 nx: usize, ny: usize, nz: usize,
396 kernel: &[f64],
397 direction: char,
398) -> Vec<f64> {
399 let n_total = nx * ny * nz;
400 let mut result = vec![0.0f64; n_total];
401 let kernel_radius = (kernel.len() - 1) / 2;
402
403 let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
404
405 // Helper to clamp index for replicate padding
406 let clamp_x = |x: isize| -> usize { x.max(0).min(nx as isize - 1) as usize };
407 let clamp_y = |y: isize| -> usize { y.max(0).min(ny as isize - 1) as usize };
408 let clamp_z = |z: isize| -> usize { z.max(0).min(nz as isize - 1) as usize };
409
410 match direction {
411 'x' => {
412 for k in 0..nz {
413 for j in 0..ny {
414 for i in 0..nx {
415 let mut sum = 0.0;
416
417 for ki in 0..kernel.len() {
418 let offset = ki as isize - kernel_radius as isize;
419 let ni = clamp_x(i as isize + offset);
420 sum += data[idx(ni, j, k)] * kernel[ki];
421 }
422
423 result[idx(i, j, k)] = sum;
424 }
425 }
426 }
427 }
428 'y' => {
429 for k in 0..nz {
430 for j in 0..ny {
431 for i in 0..nx {
432 let mut sum = 0.0;
433
434 for ki in 0..kernel.len() {
435 let offset = ki as isize - kernel_radius as isize;
436 let nj = clamp_y(j as isize + offset);
437 sum += data[idx(i, nj, k)] * kernel[ki];
438 }
439
440 result[idx(i, j, k)] = sum;
441 }
442 }
443 }
444 }
445 'z' => {
446 for k in 0..nz {
447 for j in 0..ny {
448 for i in 0..nx {
449 let mut sum = 0.0;
450
451 for ki in 0..kernel.len() {
452 let offset = ki as isize - kernel_radius as isize;
453 let nk = clamp_z(k as isize + offset);
454 sum += data[idx(i, j, nk)] * kernel[ki];
455 }
456
457 result[idx(i, j, k)] = sum;
458 }
459 }
460 }
461 }
462 _ => panic!("Invalid convolution direction"),
463 }
464
465 result
466}
467
468/// 1D convolution with specified sigma
469fn convolve_1d_direction_sigma(
470 data: &[f64],
471 nx: usize, ny: usize, nz: usize,
472 sigma: f64,
473 direction: char,
474) -> Vec<f64> {
475 if sigma <= 0.0 {
476 return data.to_vec();
477 }
478
479 // Create 1D Gaussian kernel
480 // Match MATLAB's imgaussfilt3 default: filterSize = 2*ceil(2*sigma)+1
481 let kernel_radius = (2.0 * sigma).ceil() as usize;
482 let kernel_size = 2 * kernel_radius + 1;
483 let mut kernel = vec![0.0f64; kernel_size];
484
485 let mut sum = 0.0;
486 for i in 0..kernel_size {
487 let x = i as f64 - kernel_radius as f64;
488 kernel[i] = pm::exp(-x * x / (2.0 * sigma * sigma));
489 sum += kernel[i];
490 }
491
492 // Normalize
493 for k in kernel.iter_mut() {
494 *k /= sum;
495 }
496
497 convolve_1d_direction(data, nx, ny, nz, &kernel, direction)
498}
499
500/// Simple SDF without curvature (faster, for testing)
501pub fn sdf_simple(
502 tfs: &[f64],
503 mask: &[f64],
504 grid: &Grid,
505 sigma1: f64,
506) -> Vec<f64> {
507 let vasc_only = vec![1.0f64; mask.len()];
508 let params = SdfParams {
509 sigma1,
510 sigma2: 0.0,
511 use_curvature: false,
512 ..Default::default()
513 };
514
515 sdf(tfs, mask, &vasc_only, grid, ¶ms, |_, _| {})
516}
517
518#[cfg(test)]
519mod tests {
520 #[test]
521 fn zero_alpha_leaves_no_local_field() {
522 // One tissue voxel amid vessel: its vessel proximity (the Gaussian of the tissue mask,
523 // sigma2 = 4) is ~1e-3, so alpha = sigma * round(prox^n, 2) is 0 there, and
524 // sdf_curvature.m takes the whole field as its background: local field 0.
525 let (nx, ny, nz) = (16, 16, 16);
526 let n = nx * ny * nz;
527 let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
528 let mask = vec![1.0; n];
529 let centre = 8 + 8 * nx + 8 * nx * ny;
530 let mut vasc = vec![0.0; n];
531 vasc[centre] = 1.0;
532 let tfs: Vec<f64> = (0..n).map(|i| 1.0 + 0.01 * (i % nx) as f64).collect();
533 let params = SdfParams { sigma1: 1.0, sigma2: 4.0, use_curvature: false, vessel_proximity: true, ..SdfParams::default() };
534 let lfs = sdf(&tfs, &mask, &vasc, &grid, ¶ms, |_, _| {});
535 assert_eq!(lfs[centre], 0.0);
536 }
537
538 use super::*;
539
540 #[test]
541 fn test_sdf_simple() {
542 // Simple test: constant field should give zero local field
543 let n = 10;
544 let n_total = n * n * n;
545
546 let tfs = vec![1.0f64; n_total];
547 let mask = vec![1.0f64; n_total];
548 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
549
550 let lfs = sdf_simple(&tfs, &mask, &grid, 2.0);
551
552 // Local field should be near zero for constant total field
553 let max_lfs = lfs.iter().fold(0.0f64, |a, &b| a.max(b.abs()));
554 assert!(max_lfs < 0.1, "Max LFS was {}", max_lfs);
555 }
556
557 #[test]
558 fn test_gaussian_smooth_constant() {
559 // Smoothing constant field should give same constant
560 let data = vec![5.0f64; 27];
561 let smoothed = gaussian_smooth_3d_with_filter_size(&data, 3, 3, 3, 1.0, 5);
562
563 for &v in &smoothed {
564 assert!((v - 5.0).abs() < 0.1);
565 }
566 }
567}