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:
12//! Yaghmaie, N., Syeda, W., et al. (2021).
13//! "QSMART: Quantitative Susceptibility Mapping Artifact Reduction Technique."
14//! NeuroImage, 231:117701. https://doi.org/10.1016/j.neuroimage.2020.117701
15//!
16//! Reference implementation: https://github.com/wtsyeda/QSMART
17
18use crate::Grid;
19use crate::utils::curvature::calculate_curvature_proximity;
20
21/// Parameters for SDF background field removal
22#[derive(Clone, Debug)]
23pub struct SdfParams {
24    /// Sigma parameter for initial proximity map (default 10 for stage 1, 8 for stage 2)
25    pub sigma1: f64,
26    /// Sigma parameter for vasculature proximity (default 0 for stage 1, 2 for stage 2)
27    pub sigma2: f64,
28    /// Spatial radius for morphological closing of indents (default 8 voxels)
29    pub spatial_radius: i32,
30    /// Lower limit for clamping proximity values (default 0.6)
31    pub lower_lim: f64,
32    /// Scaling constant for curvature (default 500)
33    pub curv_constant: f64,
34    /// Whether to use curvature-based edge refinement
35    pub use_curvature: bool,
36}
37
38impl Default for SdfParams {
39    fn default() -> Self {
40        Self {
41            sigma1: 10.0,
42            // MATLAB passes sigma1 for both sigma args in stage 1.
43            // sigma2 affects the combined sigma even though vasculature
44            // proximity is skipped when vasc_only is all-ones.
45            sigma2: 10.0,
46            spatial_radius: 8,
47            lower_lim: 0.6,
48            curv_constant: 500.0,
49            use_curvature: true,
50        }
51    }
52}
53
54impl SdfParams {
55    /// Create parameters for QSMART Stage 1
56    /// MATLAB passes sigma1 for both sigma args in stage 1.
57    pub fn stage1() -> Self {
58        Self {
59            sigma1: 10.0,
60            sigma2: 10.0,
61            spatial_radius: 8,
62            lower_lim: 0.6,
63            curv_constant: 500.0,
64            use_curvature: true,
65        }
66    }
67
68    /// Create parameters for QSMART Stage 2
69    pub fn stage2() -> Self {
70        Self {
71            sigma1: 8.0,
72            sigma2: 2.0,
73            spatial_radius: 8,
74            lower_lim: 0.6,
75            curv_constant: 500.0,
76            use_curvature: true,
77        }
78    }
79}
80
81/// SDF background field removal
82///
83/// Removes background field from total field shift using spatially dependent filtering.
84///
85/// # Arguments
86/// * `tfs` - Total field shift (unwrapped phase / ppm)
87/// * `mask` - Binary brain mask (weighted by reliability if R_0 is incorporated)
88/// * `vasc_only` - Vasculature-only mask (1 = tissue, 0 = vessel). Pass all-ones for stage 1.
89/// * `grid` - Volume dimensions and voxel sizes
90/// * `params` - SDF parameters
91/// * `progress` - Progress callback (alpha_index, total_alphas)
92///
93/// # Returns
94/// Local field shift (background removed)
95pub fn sdf(
96    tfs: &[f64],
97    mask: &[f64],
98    vasc_only: &[f64],
99    grid: &Grid,
100    params: &SdfParams,
101    progress: impl Fn(usize, usize),
102) -> Vec<f64> {
103    let (nx, ny, nz) = grid.dims;
104    let n_total = nx * ny * nz;
105
106    // Convert mask to binary for morphological operations
107    let mask_binary: Vec<u8> = mask.iter().map(|&v| if v > 0.0 { 1 } else { 0 }).collect();
108
109    // Combined sigma for n calculation
110    let sigma = (params.sigma1 * params.sigma1 + params.sigma2 * params.sigma2).sqrt();
111    let n = if sigma > 0.0 { -sigma.ln() / 0.5_f64.ln() } else { 0.0 };
112
113    // Calculate initial proximity map (prox1)
114    // Gaussian smoothing of mask with anisotropic kernel [sigma1, 2*sigma1, 2*sigma1]
115    let prox1 = if params.sigma1 > 0.0 {
116        gaussian_smooth_3d_masked_f64(mask, mask, nx, ny, nz, &[params.sigma1, 2.0 * params.sigma1, 2.0 * params.sigma1])
117    } else {
118        mask.to_vec()
119    };
120
121    // Calculate curvature-based proximity if enabled
122    let prox = if params.use_curvature {
123        let (prox_curv, _curv_i) = calculate_curvature_proximity(
124            &mask_binary,
125            &prox1,
126            params.lower_lim,
127            params.curv_constant,
128            params.sigma1,
129            grid,
130        );
131        prox_curv
132    } else {
133        // Even without curvature, clamp proximity to lower_lim to prevent
134        // filter sizes from getting too small at the edges
135        // (matching calculate_curvature.m line 45: prox(prox < lowerLim & prox ~= 0) = lowerLim)
136        prox1.iter()
137            .zip(mask.iter())
138            .map(|(&p, &m)| {
139                if m > 0.0 && p > 0.0 && p < params.lower_lim {
140                    params.lower_lim
141                } else {
142                    p
143                }
144            })
145            .collect()
146    };
147
148    // Calculate vasculature proximity (prox2) for stage 2
149    let prox_final = if params.sigma2 > 0.0 {
150        let prox2 = gaussian_smooth_3d_masked_f64(vasc_only, mask, nx, ny, nz, &[params.sigma2, params.sigma2, params.sigma2]);
151        // Multiply prox * prox2
152        prox.iter().zip(prox2.iter()).map(|(&p, &p2)| p * p2).collect()
153    } else {
154        prox
155    };
156
157    // Calculate alpha = sigma * round(prox^n, 2)
158    // Alpha determines the local smoothing kernel size
159    let mut alpha: Vec<f64> = prox_final.iter()
160        .zip(mask.iter())
161        .map(|(&p, &m)| {
162            if m > 0.0 {
163                sigma * (p.powf(n) * 100.0).round() / 100.0
164            } else {
165                0.0
166            }
167        })
168        .collect();
169
170    // Set alpha=1 for vessel regions within mask
171    // (vasc_only=0 means vessel, matching sdf_curvature.m line 27)
172    for i in 0..n_total {
173        if mask[i] > 0.0 && vasc_only[i] == 0.0 {
174            alpha[i] = 1.0;
175        }
176    }
177
178    // Get unique alpha values and sort
179    let mut unique_alphas: Vec<f64> = alpha.iter()
180        .filter(|&&a| a > 0.0)
181        .copied()
182        .collect();
183    unique_alphas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
184    unique_alphas.dedup();
185
186    let total_alphas = unique_alphas.len();
187
188    // Create index map: for each voxel, which alpha index does it belong to?
189    let mut alpha_index: Vec<usize> = vec![0; n_total];
190    for i in 0..n_total {
191        if alpha[i] > 0.0 {
192            // Find index in unique_alphas
193            let idx = unique_alphas.iter().position(|&a| (a - alpha[i]).abs() < 1e-10).unwrap_or(0);
194            alpha_index[i] = idx + 1; // 1-indexed to distinguish from background
195        }
196    }
197
198    // Apply spatially dependent filtering
199    // For each unique alpha, smooth and assign to corresponding voxels
200    let mut background = vec![0.0f64; n_total];
201
202    // Pre-compute filter size
203    let filter_size = 2 * (2.0 * sigma).ceil() as usize + 1;
204
205    for (alpha_idx, &current_alpha) in unique_alphas.iter().enumerate() {
206        progress(alpha_idx, total_alphas);
207
208        // Compute smoothed field for this alpha
209        let smoothed: Vec<f64> = if current_alpha > 0.0 {
210            // Smooth tfs * mask with Gaussian kernel of size current_alpha
211            let weighted_tfs: Vec<f64> = tfs.iter()
212                .zip(mask.iter())
213                .map(|(&t, &m)| t * m)
214                .collect();
215
216            let num = gaussian_smooth_3d_with_filter_size(&weighted_tfs, nx, ny, nz, current_alpha, filter_size);
217            let denom = gaussian_smooth_3d_with_filter_size(&mask.to_vec(), nx, ny, nz, current_alpha, filter_size);
218
219            // Divide: num / denom
220            num.iter()
221                .zip(denom.iter())
222                .map(|(&n, &d)| if d.abs() > 1e-10 { n / d } else { 0.0 })
223                .collect()
224        } else {
225            // alpha=0: just use tfs*mask
226            tfs.iter().zip(mask.iter()).map(|(&t, &m)| t * m).collect()
227        };
228
229        // Assign to voxels with this alpha
230        for i in 0..n_total {
231            if alpha_index[i] == alpha_idx + 1 {
232                background[i] = smoothed[i];
233            }
234        }
235    }
236
237    progress(total_alphas, total_alphas);
238
239    // Compute local field: (tfs - background) * mask
240    let local_field: Vec<f64> = tfs.iter()
241        .zip(background.iter())
242        .zip(mask.iter())
243        .map(|((&t, &b), &m)| (t - b) * m)
244        .collect();
245
246    local_field
247}
248
249/// SDF with curvature-based weighting (full QSMART pipeline)
250///
251/// This is the main entry point matching QSMART's sdf_curvature function.
252pub fn sdf_curvature(
253    tfs: &[f64],
254    mask: &[f64],
255    vasc_only: &[f64],
256    grid: &Grid,
257    params: &SdfParams,
258) -> Vec<f64> {
259    // Ensure curvature is enabled
260    let params_with_curv = SdfParams {
261        use_curvature: true,
262        ..params.clone()
263    };
264
265    sdf(tfs, mask, vasc_only, grid, &params_with_curv, |_, _| {})
266}
267
268/// 3D Gaussian smoothing with specified filter size
269fn gaussian_smooth_3d_with_filter_size(
270    data: &[f64],
271    nx: usize, ny: usize, nz: usize,
272    sigma: f64,
273    filter_size: usize,
274) -> Vec<f64> {
275    if sigma <= 0.0 {
276        return data.to_vec();
277    }
278
279    // Create 1D Gaussian kernel
280    let kernel_radius = (filter_size - 1) / 2;
281    let mut kernel = vec![0.0f64; filter_size];
282
283    let mut sum = 0.0;
284    for i in 0..filter_size {
285        let x = i as f64 - kernel_radius as f64;
286        kernel[i] = (-x * x / (2.0 * sigma * sigma)).exp();
287        sum += kernel[i];
288    }
289
290    // Normalize
291    for k in kernel.iter_mut() {
292        *k /= sum;
293    }
294
295    // Apply separable convolution
296    let smoothed_x = convolve_1d_direction(data, nx, ny, nz, &kernel, 'x');
297    let smoothed_xy = convolve_1d_direction(&smoothed_x, nx, ny, nz, &kernel, 'y');
298    convolve_1d_direction(&smoothed_xy, nx, ny, nz, &kernel, 'z')
299}
300
301/// Gaussian smoothing with anisotropic sigma and mask
302fn gaussian_smooth_3d_masked_f64(
303    data: &[f64],
304    mask: &[f64],
305    nx: usize, ny: usize, nz: usize,
306    sigmas: &[f64; 3],
307) -> Vec<f64> {
308    // Apply separable 1D convolutions
309    let smoothed_x = convolve_1d_direction_sigma(data, nx, ny, nz, sigmas[0], 'x');
310    let smoothed_xy = convolve_1d_direction_sigma(&smoothed_x, nx, ny, nz, sigmas[1], 'y');
311    let smoothed_xyz = convolve_1d_direction_sigma(&smoothed_xy, nx, ny, nz, sigmas[2], 'z');
312
313    // Apply mask
314    smoothed_xyz.iter()
315        .zip(mask.iter())
316        .map(|(&v, &m)| if m > 0.0 { v } else { 0.0 })
317        .collect()
318}
319
320/// 1D convolution along specified axis with replicate padding
321/// Matches MATLAB's imgaussfilt3 default behavior
322fn convolve_1d_direction(
323    data: &[f64],
324    nx: usize, ny: usize, nz: usize,
325    kernel: &[f64],
326    direction: char,
327) -> Vec<f64> {
328    let n_total = nx * ny * nz;
329    let mut result = vec![0.0f64; n_total];
330    let kernel_radius = (kernel.len() - 1) / 2;
331
332    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
333
334    // Helper to clamp index for replicate padding
335    let clamp_x = |x: isize| -> usize { x.max(0).min(nx as isize - 1) as usize };
336    let clamp_y = |y: isize| -> usize { y.max(0).min(ny as isize - 1) as usize };
337    let clamp_z = |z: isize| -> usize { z.max(0).min(nz as isize - 1) as usize };
338
339    match direction {
340        'x' => {
341            for k in 0..nz {
342                for j in 0..ny {
343                    for i in 0..nx {
344                        let mut sum = 0.0;
345
346                        for ki in 0..kernel.len() {
347                            let offset = ki as isize - kernel_radius as isize;
348                            let ni = clamp_x(i as isize + offset);
349                            sum += data[idx(ni, j, k)] * kernel[ki];
350                        }
351
352                        result[idx(i, j, k)] = sum;
353                    }
354                }
355            }
356        }
357        'y' => {
358            for k in 0..nz {
359                for j in 0..ny {
360                    for i in 0..nx {
361                        let mut sum = 0.0;
362
363                        for ki in 0..kernel.len() {
364                            let offset = ki as isize - kernel_radius as isize;
365                            let nj = clamp_y(j as isize + offset);
366                            sum += data[idx(i, nj, k)] * kernel[ki];
367                        }
368
369                        result[idx(i, j, k)] = sum;
370                    }
371                }
372            }
373        }
374        'z' => {
375            for k in 0..nz {
376                for j in 0..ny {
377                    for i in 0..nx {
378                        let mut sum = 0.0;
379
380                        for ki in 0..kernel.len() {
381                            let offset = ki as isize - kernel_radius as isize;
382                            let nk = clamp_z(k as isize + offset);
383                            sum += data[idx(i, j, nk)] * kernel[ki];
384                        }
385
386                        result[idx(i, j, k)] = sum;
387                    }
388                }
389            }
390        }
391        _ => panic!("Invalid convolution direction"),
392    }
393
394    result
395}
396
397/// 1D convolution with specified sigma
398fn convolve_1d_direction_sigma(
399    data: &[f64],
400    nx: usize, ny: usize, nz: usize,
401    sigma: f64,
402    direction: char,
403) -> Vec<f64> {
404    if sigma <= 0.0 {
405        return data.to_vec();
406    }
407
408    // Create 1D Gaussian kernel
409    // Match MATLAB's imgaussfilt3 default: filterSize = 2*ceil(2*sigma)+1
410    let kernel_radius = (2.0 * sigma).ceil() as usize;
411    let kernel_size = 2 * kernel_radius + 1;
412    let mut kernel = vec![0.0f64; kernel_size];
413
414    let mut sum = 0.0;
415    for i in 0..kernel_size {
416        let x = i as f64 - kernel_radius as f64;
417        kernel[i] = (-x * x / (2.0 * sigma * sigma)).exp();
418        sum += kernel[i];
419    }
420
421    // Normalize
422    for k in kernel.iter_mut() {
423        *k /= sum;
424    }
425
426    convolve_1d_direction(data, nx, ny, nz, &kernel, direction)
427}
428
429/// Simple SDF without curvature (faster, for testing)
430pub fn sdf_simple(
431    tfs: &[f64],
432    mask: &[f64],
433    grid: &Grid,
434    sigma1: f64,
435) -> Vec<f64> {
436    let vasc_only = vec![1.0f64; mask.len()];
437    let params = SdfParams {
438        sigma1,
439        sigma2: 0.0,
440        use_curvature: false,
441        ..Default::default()
442    };
443
444    sdf(tfs, mask, &vasc_only, grid, &params, |_, _| {})
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn test_sdf_simple() {
453        // Simple test: constant field should give zero local field
454        let n = 10;
455        let n_total = n * n * n;
456
457        let tfs = vec![1.0f64; n_total];
458        let mask = vec![1.0f64; n_total];
459        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
460
461        let lfs = sdf_simple(&tfs, &mask, &grid, 2.0);
462
463        // Local field should be near zero for constant total field
464        let max_lfs = lfs.iter().fold(0.0f64, |a, &b| a.max(b.abs()));
465        assert!(max_lfs < 0.1, "Max LFS was {}", max_lfs);
466    }
467
468    #[test]
469    fn test_gaussian_smooth_constant() {
470        // Smoothing constant field should give same constant
471        let data = vec![5.0f64; 27];
472        let smoothed = gaussian_smooth_3d_with_filter_size(&data, 3, 3, 3, 1.0, 5);
473
474        for &v in &smoothed {
475            assert!((v - 5.0).abs() < 0.1);
476        }
477    }
478}