Skip to main content

qsm_core/
swi.rs

1//! Susceptibility Weighted Imaging (SWI)
2//!
3//! SWI enhances susceptibility contrast by combining magnitude and phase
4//! information. Phase is high-pass filtered, converted to a [0, 1] mask,
5//! and multiplied with magnitude.
6//!
7//! Reference:
8//! Eckstein, K., et al. (2021). "Computationally efficient combination of
9//! multi-channel phase data from multi-echo acquisitions (ASPIRE)."
10//! Magnetic Resonance in Medicine, 79:2996-3006.
11//! https://doi.org/10.1002/mrm.26963
12//!
13//! Reference implementation: https://github.com/korbinian90/CLEARSWI.jl
14
15use crate::Grid;
16use crate::utils::{gaussian_smooth_3d, apply_mask_zero};
17
18/// SWI algorithm parameters
19#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
20#[derive(Clone, Debug)]
21pub struct SwiParams {
22    /// High-pass filter sigma in voxels [x, y, z]
23    pub hp_sigma: [f64; 3],
24    /// Phase scaling type
25    pub scaling: PhaseScaling,
26    /// Phase scaling strength
27    pub strength: f64,
28    /// MIP window size in slices
29    pub mip_window: usize,
30}
31
32impl Default for SwiParams {
33    fn default() -> Self {
34        Self {
35            hp_sigma: [4.0, 4.0, 0.0],
36            scaling: PhaseScaling::Tanh,
37            strength: 4.0,
38            mip_window: 7,
39        }
40    }
41}
42
43/// Phase mask scaling type
44#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub enum PhaseScaling {
47    /// Sigmoid weighting: `(1 + tanh(1 - x/m)) / 2`
48    /// where `m = median(positive_phase) * 10 / strength`
49    Tanh,
50    /// Negate phase first, then apply Tanh
51    NegativeTanh,
52    /// Traditional SWI: positive phase suppressed, negative → 1
53    Positive,
54    /// Traditional SWI: negative phase suppressed, positive → 1
55    Negative,
56    /// Both positive and negative phase suppressed
57    Triangular,
58}
59
60/// High-pass filter by subtracting Gaussian-smoothed version
61///
62/// # Arguments
63/// * `data` - Input data (e.g. unwrapped phase)
64/// * `mask` - Binary mask (1 = inside, 0 = outside)
65/// * `grid` - Volume grid (dimensions and voxel sizes)
66/// * `sigma` - Gaussian sigma for each dimension in voxels (e.g. [4, 4, 0])
67///
68/// # Returns
69/// High-pass filtered data
70pub fn highpass_filter(
71    data: &[f64],
72    mask: &[u8],
73    grid: &Grid,
74    sigma: [f64; 3],
75) -> Vec<f64> {
76    let nbox = 4; // masked smoothing uses nbox=4 in MriResearchTools
77    let smoothed = gaussian_smooth_3d(data, sigma, Some(mask), None, nbox, grid);
78    let n_total = grid.n_total();
79    let mut result = vec![0.0; n_total];
80    for i in 0..n_total {
81        if mask[i] == 1 {
82            result[i] = data[i] - smoothed[i];
83        }
84    }
85    result
86}
87
88/// Create phase mask from filtered phase values
89///
90/// Converts phase to a [0, 1] weighting mask using the specified scaling.
91///
92/// # Arguments
93/// * `phase` - High-pass filtered phase
94/// * `mask` - Binary mask
95/// * `scaling` - Phase scaling type
96/// * `strength` - Scaling strength (higher = stronger phase contrast)
97///
98/// # Returns
99/// Phase mask with values in [0, 1]
100pub fn create_phase_mask(
101    phase: &[f64],
102    mask: &[u8],
103    scaling: PhaseScaling,
104    strength: f64,
105) -> Vec<f64> {
106    let n = phase.len();
107    let mut result = vec![0.0; n];
108
109    // Copy phase into result, zeroing outside mask
110    for i in 0..n {
111        if mask[i] == 1 {
112            result[i] = phase[i];
113        }
114    }
115
116    // Handle NegativeTanh by negating first
117    let effective_scaling = if scaling == PhaseScaling::NegativeTanh {
118        for v in result.iter_mut() {
119            *v = -*v;
120        }
121        PhaseScaling::Tanh
122    } else {
123        scaling
124    };
125
126    match effective_scaling {
127        PhaseScaling::Tanh => {
128            // m = median(positive phase in mask) * 10 / strength
129            let mut positives: Vec<f64> = (0..n)
130                .filter(|&i| mask[i] == 1 && result[i] > 0.0)
131                .map(|i| result[i])
132                .collect();
133
134            let m = if positives.is_empty() {
135                1.0
136            } else {
137                positives.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
138                let mid = positives.len() / 2;
139                let median = if positives.len().is_multiple_of(2) {
140                    (positives[mid - 1] + positives[mid]) / 2.0
141                } else {
142                    positives[mid]
143                };
144                median * 10.0 / strength
145            };
146
147            for v in result.iter_mut() {
148                *v = (1.0 + (1.0 - *v / m).tanh()) / 2.0;
149            }
150        }
151        PhaseScaling::Positive => {
152            // Positive phase: rescale to [1,0] then ^strength; negative → 1
153            let (min_pos, max_pos) = positive_range(&result, mask);
154            for i in 0..n {
155                if result[i] > 0.0 && mask[i] == 1 {
156                    result[i] = rescale(result[i], min_pos, max_pos, 1.0, 0.0).powf(strength);
157                } else {
158                    result[i] = 1.0;
159                }
160            }
161        }
162        PhaseScaling::Negative => {
163            // Negative phase: rescale to [0,1] then ^strength; positive → 1
164            let (min_neg, max_neg) = negative_range(&result, mask);
165            for i in 0..n {
166                if result[i] <= 0.0 && mask[i] == 1 {
167                    result[i] = rescale(result[i], min_neg, max_neg, 0.0, 1.0).powf(strength);
168                } else {
169                    result[i] = 1.0;
170                }
171            }
172        }
173        PhaseScaling::Triangular => {
174            // Both directions suppressed
175            let (min_pos, max_pos) = positive_range(&result, mask);
176            let (min_neg, max_neg) = negative_range(&result, mask);
177            for i in 0..n {
178                if mask[i] == 0 {
179                    result[i] = 0.0;
180                } else if result[i] > 0.0 {
181                    result[i] = rescale(result[i], min_pos, max_pos, 1.0, 0.0).powf(strength);
182                } else {
183                    result[i] = rescale(result[i], min_neg, max_neg, 0.0, 1.0).powf(strength);
184                }
185            }
186        }
187        PhaseScaling::NegativeTanh => unreachable!(),
188    }
189
190    // Clamp to [0, 1]
191    for v in &mut result {
192        if *v < 0.0 {
193            *v = 0.0;
194        }
195    }
196
197    apply_mask_zero(&mut result, mask);
198
199    result
200}
201
202/// Calculate SWI from unwrapped phase and magnitude
203///
204/// Pipeline: high-pass filter phase → create phase mask → multiply with magnitude.
205///
206/// # Arguments
207/// * `phase` - Unwrapped phase (single echo or combined)
208/// * `magnitude` - Magnitude image (single echo or combined)
209/// * `mask` - Binary brain mask
210/// * `grid` - Volume grid (dimensions and voxel sizes)
211/// * `params` - SWI algorithm parameters
212///
213/// # Returns
214/// SWI image (magnitude × phase mask)
215pub fn calculate_swi(
216    phase: &[f64],
217    magnitude: &[f64],
218    mask: &[u8],
219    grid: &Grid,
220    params: &SwiParams,
221) -> Vec<f64> {
222    let n_total = grid.n_total();
223
224    // High-pass filter phase
225    let filtered = highpass_filter(phase, mask, grid, params.hp_sigma);
226
227    // Create phase mask
228    let phase_mask = create_phase_mask(&filtered, mask, params.scaling, params.strength);
229
230    // SWI = magnitude × phase_mask
231    let mut swi = vec![0.0; n_total];
232    for i in 0..n_total {
233        swi[i] = magnitude[i] * phase_mask[i];
234    }
235
236    swi
237}
238
239/// Minimum intensity projection along the z-axis
240///
241/// For each (x, y) position, takes the minimum value over a sliding window
242/// of `window` slices along z.
243///
244/// # Arguments
245/// * `data` - 3D volume (Fortran order)
246/// * `grid` - Volume grid (dimensions and voxel sizes)
247/// * `window` - Number of slices in the projection window
248///
249/// # Returns
250/// MIP volume with dimensions `nx × ny × (nz - window + 1)`.
251/// Returns empty vec if `window > nz`.
252pub fn create_mip(
253    data: &[f64],
254    grid: &Grid,
255    window: usize,
256) -> Vec<f64> {
257    let (nx, ny, nz) = grid.dims;
258
259    if window > nz || window == 0 {
260        return vec![];
261    }
262
263    let nz_out = nz - window + 1;
264    let nxy = nx * ny;
265    let mut mip = vec![0.0; nxy * nz_out];
266
267    for k_out in 0..nz_out {
268        for j in 0..ny {
269            for i in 0..nx {
270                let idx_xy = i + j * nx;
271                let mut min_val = data[idx_xy + k_out * nxy];
272                for kw in 1..window {
273                    let val = data[idx_xy + (k_out + kw) * nxy];
274                    if val < min_val {
275                        min_val = val;
276                    }
277                }
278                mip[idx_xy + k_out * nxy] = min_val;
279            }
280        }
281    }
282
283    mip
284}
285
286/// Softplus magnitude scaling for enhanced contrast
287///
288/// Applies a shifted softplus function: `softplus(x) - softplus(0)` where
289/// `softplus(x) = (log(1 + exp(-|f*(x-offset)|)) + max(0, f*(x-offset))) / f`
290/// and `f = factor / offset`.
291///
292/// # Arguments
293/// * `magnitude` - Input magnitude data
294/// * `offset` - Softplus offset (controls transition point)
295/// * `factor` - Steepness factor (default 2.0)
296///
297/// # Returns
298/// Scaled magnitude
299pub fn softplus_scaling(
300    magnitude: &[f64],
301    offset: f64,
302    factor: f64,
303) -> Vec<f64> {
304    if offset.abs() < 1e-20 {
305        return magnitude.to_vec();
306    }
307
308    let f = factor / offset;
309
310    // softplus(0) for baseline subtraction
311    let arg0 = f * (0.0 - offset);
312    let sp0 = ((1.0 + (-arg0.abs()).exp()).ln() + arg0.max(0.0)) / f;
313
314    magnitude.iter().map(|&val| {
315        let arg = f * (val - offset);
316        let sp = ((1.0 + (-arg.abs()).exp()).ln() + arg.max(0.0)) / f;
317        sp - sp0
318    }).collect()
319}
320
321// ---- Helpers ----
322
323/// Get min/max of positive values within mask
324fn positive_range(data: &[f64], mask: &[u8]) -> (f64, f64) {
325    let mut min_val = f64::MAX;
326    let mut max_val = f64::MIN;
327    for i in 0..data.len() {
328        if mask[i] == 1 && data[i] > 0.0 {
329            if data[i] < min_val { min_val = data[i]; }
330            if data[i] > max_val { max_val = data[i]; }
331        }
332    }
333    if min_val > max_val {
334        (0.0, 1.0) // fallback
335    } else {
336        (min_val, max_val)
337    }
338}
339
340/// Get min/max of non-positive values within mask
341fn negative_range(data: &[f64], mask: &[u8]) -> (f64, f64) {
342    let mut min_val = f64::MAX;
343    let mut max_val = f64::MIN;
344    for i in 0..data.len() {
345        if mask[i] == 1 && data[i] <= 0.0 {
346            if data[i] < min_val { min_val = data[i]; }
347            if data[i] > max_val { max_val = data[i]; }
348        }
349    }
350    if min_val > max_val {
351        (-1.0, 0.0) // fallback
352    } else {
353        (min_val, max_val)
354    }
355}
356
357/// Linear rescale from [old_min, old_max] to [new_min, new_max]
358#[inline]
359fn rescale(val: f64, old_min: f64, old_max: f64, new_min: f64, new_max: f64) -> f64 {
360    let range = old_max - old_min;
361    if range.abs() < 1e-20 {
362        return (new_min + new_max) / 2.0;
363    }
364    let t = (val - old_min) / range;
365    // Clamp t to [0, 1] for robustness
366    let t = t.clamp(0.0, 1.0);
367    new_min + t * (new_max - new_min)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_calculate_swi_zero_phase() {
376        let n = 8;
377        let nn = n * n * n;
378        let phase = vec![0.0; nn];
379        let magnitude = vec![1.0; nn];
380        let mask = vec![1u8; nn];
381        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
382
383        let swi = calculate_swi(&phase, &magnitude, &mask, &grid, &SwiParams::default());
384
385        // With zero phase, tanh mask gives (1 + tanh(1)) / 2 ≈ 0.88
386        for &v in &swi {
387            assert!(v.is_finite(), "SWI values should be finite");
388            assert!(v >= 0.0, "SWI values should be non-negative");
389        }
390    }
391
392    #[test]
393    fn test_calculate_swi_mask() {
394        let n = 8;
395        let nn = n * n * n;
396        let phase = vec![0.1; nn];
397        let magnitude = vec![1.0; nn];
398        let mut mask = vec![1u8; nn];
399        mask[0] = 0;
400        mask[1] = 0;
401        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
402
403        let swi = calculate_swi(&phase, &magnitude, &mask, &grid, &SwiParams::default());
404
405        assert_eq!(swi[0], 0.0, "Outside mask should be 0");
406        assert_eq!(swi[1], 0.0, "Outside mask should be 0");
407    }
408
409    #[test]
410    fn test_phase_mask_range() {
411        let n = 10;
412        let nn = n * n * n;
413        let phase: Vec<f64> = (0..nn).map(|i| (i as f64 * 0.01) - 5.0).collect();
414        let mask = vec![1u8; nn];
415
416        for scaling in &[
417            PhaseScaling::Tanh,
418            PhaseScaling::NegativeTanh,
419            PhaseScaling::Positive,
420            PhaseScaling::Negative,
421            PhaseScaling::Triangular,
422        ] {
423            let pm = create_phase_mask(&phase, &mask, *scaling, 4.0);
424            for (i, &v) in pm.iter().enumerate() {
425                assert!(v >= 0.0, "{:?}: value at {} = {} < 0", scaling, i, v);
426                assert!(v <= 1.0 + 1e-10, "{:?}: value at {} = {} > 1", scaling, i, v);
427            }
428        }
429    }
430
431    #[test]
432    fn test_highpass_filter_constant() {
433        // Constant input should give zero output (constant is its own smooth)
434        let n = 16;
435        let nn = n * n * n;
436        let data = vec![5.0; nn];
437        let mask = vec![1u8; nn];
438        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
439
440        let result = highpass_filter(&data, &mask, &grid, [2.0, 2.0, 0.0]);
441
442        for &v in &result {
443            assert!(v.abs() < 1.0, "High-pass of constant should be near zero, got {}", v);
444        }
445    }
446
447    #[test]
448    fn test_mip_basic() {
449        // 3x3x5 volume, mIP with window=3 → 3x3x3 output
450        let (nx, ny, nz) = (3, 3, 5);
451        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
452        let mut data = vec![10.0; nx * ny * nz];
453        // Place a low value at slice 2
454        let idx = 1 + 1 * nx + 2 * nx * ny; // (1,1,2)
455        data[idx] = 1.0;
456
457        let mip = create_mip(&data, &grid, 3);
458        assert_eq!(mip.len(), nx * ny * 3);
459
460        // The minimum at (1,1) should appear in slices that include z=2
461        // Window starting at z=0: slices 0,1,2 → includes the 1.0
462        let mip_idx_0 = 1 + 1 * nx + 0 * nx * ny;
463        assert_eq!(mip[mip_idx_0], 1.0);
464        // Window starting at z=1: slices 1,2,3 → includes the 1.0
465        let mip_idx_1 = 1 + 1 * nx + 1 * nx * ny;
466        assert_eq!(mip[mip_idx_1], 1.0);
467        // Window starting at z=2: slices 2,3,4 → includes the 1.0
468        let mip_idx_2 = 1 + 1 * nx + 2 * nx * ny;
469        assert_eq!(mip[mip_idx_2], 1.0);
470    }
471
472    #[test]
473    fn test_mip_window_too_large() {
474        let grid = Grid::new(3, 3, 3, 1.0, 1.0, 1.0);
475        let mip = create_mip(&[1.0; 27], &grid, 10);
476        assert!(mip.is_empty());
477    }
478
479    #[test]
480    fn test_softplus_scaling() {
481        let mag = vec![0.0, 0.5, 1.0, 2.0];
482        let result = softplus_scaling(&mag, 1.0, 2.0);
483
484        // softplus(0, offset=1, factor=2) should be 0 (baseline subtracted)
485        assert!(result[0].abs() < 1e-10, "softplus(0) should be ~0, got {}", result[0]);
486        // Values should increase monotonically
487        for i in 1..result.len() {
488            assert!(result[i] >= result[i - 1], "softplus should be monotonically increasing");
489        }
490    }
491
492    #[test]
493    fn test_rescale() {
494        assert!((rescale(0.0, 0.0, 10.0, 0.0, 1.0) - 0.0).abs() < 1e-10);
495        assert!((rescale(5.0, 0.0, 10.0, 0.0, 1.0) - 0.5).abs() < 1e-10);
496        assert!((rescale(10.0, 0.0, 10.0, 0.0, 1.0) - 1.0).abs() < 1e-10);
497        // Inverted rescale
498        assert!((rescale(0.0, 0.0, 10.0, 1.0, 0.0) - 1.0).abs() < 1e-10);
499        assert!((rescale(10.0, 0.0, 10.0, 1.0, 0.0) - 0.0).abs() < 1e-10);
500    }
501}