Skip to main content

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, &current_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, &params_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, &params, |_, _| {})
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, &params, |_, _| {});
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}