Skip to main content

qsm_core/
swi.rs

1//! Susceptibility Weighted Imaging (CLEAR-SWI) and Susceptibility Map-Weighted
2//! Imaging (SMWI)
3//!
4//! [`calculate_swi`] implements CLEAR-SWI (Eckstein et al. 2021), as CLEARSWI.jl does with its
5//! default options. All echoes contribute:
6//!
7//! 1. **Magnitude.** The echoes are combined ([`MagCombine`]; root-sum-of-squares by default),
8//!    divided by a receive-sensitivity estimate from the first echo ([`Sensitivity`]; the
9//!    CLEAR bias-field estimate of [`get_sensitivity`]) and scaled with a softplus curve
10//!    ([`softplus_scaling`]) whose offset is half the 80th intensity percentile.
11//! 2. **Phase.** Each echo is unwrapped ([`SwiUnwrap`]; Laplacian by default), high-pass
12//!    filtered in-plane ([`highpass_filter`]) and the echoes are combined into a frequency map
13//!    by weighted averaging of `φ/TE` ([`B0WeightType`]; `mag·TE`, the phase-SNR weighting, by
14//!    default). With [`SwiUnwrap::Romeo`] the echoes are unwrapped together and the combined
15//!    frequency map is filtered instead.
16//! 3. **Phase mask.** The filtered phase is mapped to [0, 1] ([`create_phase_mask`]; a tanh
17//!    sigmoid by default), and SWI = magnitude × phase mask.
18//!
19//! A single echo is the special case without combination: the filtered phase itself drives the
20//! phase mask, and the magnitude is that echo's magnitude.
21//!
22//! [`create_mip`] gives the minimum-intensity projection (7 slices by default).
23//!
24//! SMWI ([`calculate_smwi`]) builds the same kind of [0, 1] mask from a
25//! susceptibility map instead of from filtered phase, which avoids the
26//! blooming and residual-wrap artifacts a phase mask inherits from the
27//! non-local dipole field.
28//!
29//! References:
30//! Eckstein, K., Bachrata, B., Hangel, G., Widhalm, G., Enzinger, C., Barth, M., Trattnig, S.,
31//! Robinson, S.D. (2021). "Improved susceptibility weighted imaging at ultra-high field using
32//! bipolar multi-echo acquisition and optimized image processing: CLEAR-SWI." NeuroImage 237,
33//! 118175. https://doi.org/10.1016/j.neuroimage.2021.118175
34//!
35//! Gho, S.-M., et al. (2014). "Susceptibility map-weighted imaging (SMWI)
36//! for neuroimaging." Magnetic Resonance in Medicine, 72:337-346.
37//! https://doi.org/10.1002/mrm.24920
38//!
39//! Nam, Y., et al. (2017). "Imaging of nigrosome 1 in substantia nigra at 3T
40//! using multiecho susceptibility map-weighted imaging (SMWI)." Journal of
41//! Magnetic Resonance Imaging, 46:528-536. https://doi.org/10.1002/jmri.25553
42//!
43//! Reference implementations: https://github.com/korbinian90/CLEARSWI.jl
44//! (SWI; `calculateSWI` with default `Options`), https://github.com/kschan0214/sepia
45//! (SMWI, `misc/swi_smwi/smwi`)
46//!
47//! # Differences from CLEARSWI.jl
48//!
49//! - Laplacian unwrapping uses [`laplacian_unwrap`] with the DCT solver, whose discrete
50//!   Laplacian accounts for the voxel size and the matrix size of each axis. MriResearchTools'
51//!   `laplacianunwrap` uses eigenvalues `p² + q² + t²` (`p = 1..N`) in index units, which
52//!   weights the axes by matrix size rather than by voxel spacing. The difference is a smooth
53//!   field that the high-pass filter largely removes.
54//! - Quantiles for the softplus offset and the rescaling phase masks use the same block
55//!   sampling as MriResearchTools' `estimatequantile`; the brain-agnostic mask comes from
56//!   [`robust_mask`].
57//! - The rescaling phase masks ([`PhaseScaling::Positive`] and friends) clamp the rescaled
58//!   value to [0, 1] before raising it to `strength`, so a value beyond the 1st/99th-percentile
59//!   extrapolation cannot exceed 1 (or go negative under a non-integer power).
60//!
61//! # Agreement with CLEARSWI.jl
62//!
63//! `tests/clearswi_parity.rs` (measured 2026-11-02) runs a 20×20×14 three-echo phantom through
64//! CLEARSWI.jl 0949bc5 in the verified QSM-CI Julia image (QSMxT/QSM-CI#299; the fixture is
65//! regenerated in Actions by `tests/data/clearswi_small.jl`):
66//!
67//! - **Matched to rounding:** the phase mask's mask ([`robust_mask`]) is identical; the SWI
68//!   magnitude is 2e-14 % from CLEARSWI.jl's without sensitivity correction (RSS and softplus) and
69//!   2e-14 % with the CLEAR sensitivity estimate ([`get_sensitivity`]); after unwrapping (given the
70//!   true phase, CLEARSWI.jl's own steps: [`highpass_filter`]'s masked smoothing, the echo
71//!   combination and the phase mask) the combined filtered phase is 2e-14 % and the phase mask
72//!   4e-15 % from CLEARSWI.jl's. The 4.4 % (sensitivity) and 1.9 % (masked smoothing) measured
73//!   before were the port errors in the NaN box filter and `box_segment` that
74//!   astewartau/QSM.rs#355 fixed for `makehomogeneous` (#319), which shares both.
75//! - **The deliberate unwrapping difference** (above) cannot be avoided by the input:
76//!   MriResearchTools' `laplacianunwrap` does not return even a phase without wraps unchanged (it
77//!   adds a smooth field of 0.09–0.20 rad standard deviation per echo there), where the DCT solver
78//!   returns it up to a constant. End to end, this alone puts the phase mask 2.1 % from
79//!   CLEARSWI.jl's without wraps and 3.8 % with wraps across the brain (the filtered phase 17 % and
80//!   43 %): the high-pass filter does not remove all of it.
81
82use crate::error::InputError;
83use crate::unwrap::{laplacian_unwrap, unwrap_romeo_multi_echo, LaplacianSolver, RomeoParams};
84use crate::utils::{calculate_b0_weighted, gaussian_smooth_3d, get_sensitivity, robust_mask, B0WeightType};
85use crate::Grid;
86
87/// CLEAR-SWI parameters. The defaults are CLEARSWI.jl's `Options()` defaults.
88#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
89#[derive(Clone, Debug)]
90pub struct SwiParams {
91    /// How the echoes' magnitudes are combined (default: [`MagCombine::Rss`], CLEARSWI.jl's `:SNR`)
92    pub mag_combine: MagCombine,
93    /// Receive-sensitivity (bias-field) correction of the combined magnitude
94    /// (default: [`Sensitivity::Estimate`] with CLEARSWI.jl's default sigma)
95    pub sensitivity: Sensitivity,
96    /// Scale the corrected magnitude with [`softplus_scaling`], offset at half the 80th
97    /// percentile (default: true)
98    pub softplus: bool,
99    /// Phase unwrapping (default: [`SwiUnwrap::Laplacian`])
100    pub unwrap: SwiUnwrap,
101    /// Echo weighting when combining the filtered phases into a frequency map
102    /// (default: [`B0WeightType::PhaseSNR`], `mag·TE`)
103    pub echo_weighting: B0WeightType,
104    /// High-pass filter sigma in voxels [x, y, z] (default: [4, 4, 0])
105    pub hp_sigma: [f64; 3],
106    /// Phase mask scaling (default: [`PhaseScaling::Tanh`])
107    pub scaling: PhaseScaling,
108    /// Phase mask strength; for the rescaling masks, the power (default: 4)
109    pub strength: f64,
110    /// Minimum-intensity projection window in slices (default: 7)
111    pub mip_window: usize,
112}
113
114impl Default for SwiParams {
115    fn default() -> Self {
116        Self {
117            mag_combine: MagCombine::Rss,
118            sensitivity: Sensitivity::Estimate { sigma_mm: None },
119            softplus: true,
120            unwrap: SwiUnwrap::Laplacian,
121            echo_weighting: B0WeightType::PhaseSNR,
122            hp_sigma: [4.0, 4.0, 0.0],
123            scaling: PhaseScaling::Tanh,
124            strength: 4.0,
125            mip_window: 7,
126        }
127    }
128}
129
130/// Magnitude echo combination for SWI
131#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum MagCombine {
134    /// Root sum of squares, `sqrt(Σ mag²)` (CLEARSWI.jl `:SNR`)
135    Rss,
136    /// Sum over echoes (CLEARSWI.jl `:average`, which sums; the scale cancels in the softplus)
137    Average,
138    /// The last echo only
139    Last,
140    /// One echo, 0-indexed
141    Echo(usize),
142}
143
144/// Receive-sensitivity correction of the combined SWI magnitude
145#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
146#[derive(Debug, Clone, Copy, PartialEq)]
147pub enum Sensitivity {
148    /// No correction
149    Off,
150    /// Divide by the CLEAR bias-field estimate of the first echo ([`get_sensitivity`]).
151    /// `sigma_mm: None` uses CLEARSWI.jl's default: 7 mm, or the median field of view if
152    /// that is smaller.
153    Estimate {
154        /// Bias-field smoothing sigma in mm
155        sigma_mm: Option<f64>,
156    },
157}
158
159/// Phase unwrapping for SWI
160#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum SwiUnwrap {
163    /// Each echo unwrapped with [`laplacian_unwrap`] (DCT solver) and high-pass filtered,
164    /// then the echoes combined (CLEARSWI.jl `:laplacian`)
165    Laplacian,
166    /// As [`Self::Laplacian`], slice by slice in 2D (CLEARSWI.jl `:laplacianslice`); for 2D
167    /// multi-slice acquisitions
168    LaplacianSlice,
169    /// All echoes unwrapped together with ROMEO (template echo 1), combined, then the combined
170    /// map high-pass filtered in-plane (CLEARSWI.jl `:romeo`)
171    Romeo,
172}
173
174/// Phase mask scaling type
175#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
176#[derive(Debug, Clone, Copy, PartialEq)]
177pub enum PhaseScaling {
178    /// Sigmoid weighting: `(1 + tanh(1 - x/m)) / 2`
179    /// where `m = median(positive phase in mask) * 10 / strength`
180    Tanh,
181    /// Negate phase first, then apply Tanh
182    NegativeTanh,
183    /// Traditional SWI: positive phase suppressed, negative → 1
184    Positive,
185    /// Traditional SWI: negative phase suppressed, positive → 1
186    Negative,
187    /// Both positive and negative phase suppressed
188    Triangular,
189}
190
191/// CLEAR-SWI and the images it was made from.
192#[derive(Debug, Clone)]
193pub struct SwiResult {
194    /// SWI: `magnitude × phase_mask`
195    pub swi: Vec<f64>,
196    /// The SWI magnitude: combined, sensitivity-corrected and softplus-scaled
197    pub magnitude: Vec<f64>,
198    /// The phase mask, in [0, 1]
199    pub phase_mask: Vec<f64>,
200    /// The high-pass filtered (combined) phase the mask was made from: a frequency map in Hz
201    /// when the echo times are in seconds and there are several echoes, radians for one echo
202    pub filtered_phase: Vec<f64>,
203    /// The mask the phase was filtered and scaled within
204    pub mask: Vec<u8>,
205}
206
207/// High-pass filter by subtracting a masked Gaussian-smoothed version
208///
209/// The smoothing is MriResearchTools' masked `gaussiansmooth3d` (4 box passes, NaN
210/// extrapolation into the masked-out region). An axis with sigma 0 is not smoothed.
211///
212/// # Arguments
213/// * `data` - Input data (e.g. unwrapped phase)
214/// * `mask` - Binary mask (1 = inside, 0 = outside)
215/// * `grid` - Volume grid (dimensions and voxel sizes)
216/// * `sigma` - Gaussian sigma for each dimension in voxels (e.g. [4, 4, 0])
217///
218/// # Returns
219/// High-pass filtered data, zero outside `mask`
220pub fn highpass_filter(
221    data: &[f64],
222    mask: &[u8],
223    grid: &Grid,
224    sigma: [f64; 3],
225) -> Vec<f64> {
226    let nbox = 4; // masked smoothing uses nbox=4 in MriResearchTools
227    let smoothed = gaussian_smooth_3d(data, sigma, Some(mask), None, nbox, grid);
228    let n_total = grid.n_total();
229    let mut result = vec![0.0; n_total];
230    for i in 0..n_total {
231        if mask[i] != 0 {
232            result[i] = data[i] - smoothed[i];
233        }
234    }
235    result
236}
237
238/// Create the SWI phase mask from filtered phase
239///
240/// As CLEARSWI.jl's `createphasemask!`: the phase is set to zero outside `mask`, then the
241/// scaling is applied to every voxel, so outside the mask the weight is the scaling's value at
242/// zero phase (≈ 0.88 for [`PhaseScaling::Tanh`], 1 for [`PhaseScaling::Positive`]) rather
243/// than zero. The SWI therefore keeps the whole head visible, as CLEAR-SWI does.
244///
245/// # Arguments
246/// * `phase` - High-pass filtered phase (any unit; every scaling is scale-invariant)
247/// * `mask` - Binary mask
248/// * `scaling` - Phase scaling type
249/// * `strength` - Scaling strength (higher = stronger phase contrast)
250///
251/// # Returns
252/// Phase mask with values in [0, 1]
253pub fn create_phase_mask(
254    phase: &[f64],
255    mask: &[u8],
256    scaling: PhaseScaling,
257    strength: f64,
258) -> Vec<f64> {
259    let mut result: Vec<f64> = phase
260        .iter()
261        .zip(mask)
262        .map(|(&p, &m)| if m != 0 { p } else { 0.0 })
263        .collect();
264    // `pos` is taken before any negation, as in CLEARSWI.jl.
265    let pos: Vec<bool> = result.iter().map(|&v| v > 0.0).collect();
266
267    let scaling = if scaling == PhaseScaling::NegativeTanh {
268        for v in result.iter_mut() {
269            *v = -*v;
270        }
271        PhaseScaling::Tanh
272    } else {
273        scaling
274    };
275
276    match scaling {
277        PhaseScaling::Tanh => {
278            let mut positives: Vec<f64> = result
279                .iter()
280                .zip(mask)
281                .filter(|&(&v, &m)| m != 0 && v > 0.0)
282                .map(|(&v, _)| v)
283                .collect();
284            let m = match median(&mut positives) {
285                Some(med) if med > 0.0 => med * 10.0 / strength,
286                _ => 1.0,
287            };
288            for v in result.iter_mut() {
289                *v = (1.0 + (1.0 - *v / m).tanh()) / 2.0;
290            }
291        }
292        PhaseScaling::Positive => {
293            rescale_selected(&mut result, &pos, true, 1.0, 0.0, strength);
294            fill_selected(&mut result, &pos, false, 1.0);
295        }
296        PhaseScaling::Negative => {
297            rescale_selected(&mut result, &pos, false, 0.0, 1.0, strength);
298            fill_selected(&mut result, &pos, true, 1.0);
299        }
300        PhaseScaling::Triangular => {
301            rescale_selected(&mut result, &pos, true, 1.0, 0.0, strength);
302            rescale_selected(&mut result, &pos, false, 0.0, 1.0, strength);
303        }
304        PhaseScaling::NegativeTanh => unreachable!(),
305    }
306
307    for v in &mut result {
308        if *v < 0.0 || v.is_nan() {
309            *v = 0.0;
310        }
311    }
312    result
313}
314
315/// Combine the magnitude echoes for SWI.
316///
317/// `mags` holds one volume per echo.
318///
319/// # Errors
320/// If there are no echoes, or [`MagCombine::Echo`] names an echo that does not exist.
321pub fn combine_magnitude<M: AsRef<[f64]>>(
322    mags: &[M],
323    combine: MagCombine,
324) -> Result<Vec<f64>, InputError> {
325    let n_echoes = mags.len();
326    if n_echoes == 0 {
327        return Err(InputError::new("SWI needs at least one magnitude echo"));
328    }
329    if n_echoes == 1 {
330        return Ok(mags[0].as_ref().to_vec());
331    }
332    let n = mags[0].as_ref().len();
333    Ok(match combine {
334        MagCombine::Rss => (0..n)
335            .map(|i| mags.iter().map(|m| m.as_ref()[i].powi(2)).sum::<f64>().sqrt())
336            .collect(),
337        MagCombine::Average => (0..n).map(|i| mags.iter().map(|m| m.as_ref()[i]).sum()).collect(),
338        MagCombine::Last => mags[n_echoes - 1].as_ref().to_vec(),
339        MagCombine::Echo(e) => mags
340            .get(e)
341            .ok_or_else(|| {
342                InputError::new(format!("SWI magnitude echo {} requested, but there are {n_echoes}", e + 1))
343            })?
344            .as_ref()
345            .to_vec(),
346    })
347}
348
349/// The SWI magnitude: combined echoes, sensitivity-corrected, softplus-scaled.
350///
351/// As CLEARSWI.jl's `getswimag`. The sensitivity is estimated from the first echo.
352pub fn swi_magnitude<M: AsRef<[f64]>>(
353    mags: &[M],
354    grid: &Grid,
355    params: &SwiParams,
356) -> Result<Vec<f64>, InputError> {
357    let mut combined = combine_magnitude(mags, params.mag_combine)?;
358    if let Sensitivity::Estimate { sigma_mm } = params.sensitivity {
359        let sigma_mm = sigma_mm.unwrap_or_else(|| default_sensitivity_sigma_mm(grid));
360        let sensitivity = get_sensitivity(mags[0].as_ref(), grid, sigma_mm, 15)?;
361        for (c, &s) in combined.iter_mut().zip(&sensitivity) {
362            if s.is_finite() && s > 0.0 {
363                *c /= s;
364            }
365        }
366    }
367    if params.softplus {
368        let q = estimate_quantile(&combined, 0.8);
369        combined = softplus_scaling(&combined, q / 2.0, 2.0);
370    }
371    Ok(combined)
372}
373
374/// CLEARSWI.jl's default sensitivity sigma: 7 mm, or the median field of view if smaller.
375fn default_sensitivity_sigma_mm(grid: &Grid) -> f64 {
376    let (nx, ny, nz) = grid.dims;
377    let (vx, vy, vz) = grid.voxel_size;
378    let mut fov = [nx as f64 * vx, ny as f64 * vy, nz as f64 * vz];
379    fov.sort_by(f64::total_cmp);
380    fov[1].min(7.0)
381}
382
383/// The high-pass filtered, echo-combined phase that drives the SWI phase mask.
384///
385/// As CLEARSWI.jl's `getcombinedphase` for its three unwrapping options. With several echoes
386/// the result is a frequency map (`φ / (2π·TE)`, Hz for TEs in seconds); with one echo it is
387/// the filtered phase in radians. Zero outside `mask`.
388pub fn swi_phase<P: AsRef<[f64]> + Sync, M: AsRef<[f64]> + Sync>(
389    phases: &[P],
390    mags: &[M],
391    tes: &[f64],
392    mask: &[u8],
393    grid: &Grid,
394    params: &SwiParams,
395) -> Vec<f64> {
396    let n_total = grid.n_total();
397    let everywhere = vec![1u8; n_total];
398    let combine = |filtered: &[Vec<f64>]| -> Vec<f64> {
399        if filtered.len() == 1 {
400            filtered[0].clone()
401        } else {
402            calculate_b0_weighted(filtered, mags, tes, mask, params.echo_weighting, grid)
403        }
404    };
405
406    match params.unwrap {
407        SwiUnwrap::Laplacian => {
408            let filtered: Vec<Vec<f64>> = phases
409                .iter()
410                .map(|p| {
411                    let unwrapped = laplacian_unwrap(p.as_ref(), &everywhere, grid, LaplacianSolver::Dct);
412                    highpass_filter(&unwrapped, mask, grid, params.hp_sigma)
413                })
414                .collect();
415            combine(&filtered)
416        }
417        SwiUnwrap::LaplacianSlice => {
418            let (nx, ny, nz) = grid.dims;
419            let (vx, vy, vz) = grid.voxel_size;
420            let slice_grid = Grid::new(nx, ny, 1, vx, vy, vz);
421            let nxy = nx * ny;
422            let ones = vec![1u8; nxy];
423            let sigma = [params.hp_sigma[0], params.hp_sigma[1], 0.0];
424            let filtered: Vec<Vec<f64>> = phases
425                .iter()
426                .map(|p| {
427                    let mut out = vec![0.0; n_total];
428                    for k in 0..nz {
429                        let range = k * nxy..(k + 1) * nxy;
430                        let unwrapped =
431                            laplacian_unwrap(&p.as_ref()[range.clone()], &ones, &slice_grid, LaplacianSolver::Dct);
432                        let hp = highpass_filter(&unwrapped, &mask[range.clone()], &slice_grid, sigma);
433                        out[range].copy_from_slice(&hp);
434                    }
435                    out
436                })
437                .collect();
438            combine(&filtered)
439        }
440        SwiUnwrap::Romeo => {
441            // CLEARSWI.jl calls `romeo(phase; mag, TEs)` without a mask.
442            let unwrapped = unwrap_romeo_multi_echo(phases, mags, tes, &everywhere, &RomeoParams::default(), grid);
443            let combined = combine(&unwrapped);
444            // CLEARSWI.jl filters the combined map in-plane only (`dims=1:2`).
445            highpass_filter(&combined, mask, grid, [params.hp_sigma[0], params.hp_sigma[1], 0.0])
446        }
447    }
448}
449
450/// CLEAR-SWI from multi-echo (or single-echo) phase and magnitude.
451///
452/// As CLEARSWI.jl's `calculateSWI(Data(mag, phase, header, TEs), options)`. See the
453/// [module docs](self) for the steps.
454///
455/// # Arguments
456/// * `phases` - Wrapped phase, one volume per echo, in radians
457/// * `mags` - Magnitude, one volume per echo
458/// * `tes` - Echo times, one per echo (seconds; only their ratios and the weighting use them)
459/// * `mask` - Mask the phase is filtered and scaled within. `None` uses CLEAR-SWI's own:
460///   [`robust_mask`] of the first echo's magnitude, which covers the head
461/// * `grid` - Volume grid (dimensions and voxel sizes)
462/// * `params` - CLEAR-SWI parameters
463///
464/// # Errors
465/// If there are no echoes, the phase, magnitude and echo-time counts differ, an echo time is
466/// not positive and finite (several echoes only), or a volume does not match `grid`.
467pub fn calculate_swi<P: AsRef<[f64]> + Sync, M: AsRef<[f64]> + Sync>(
468    phases: &[P],
469    mags: &[M],
470    tes: &[f64],
471    mask: Option<&[u8]>,
472    grid: &Grid,
473    params: &SwiParams,
474) -> Result<SwiResult, InputError> {
475    let n_total = grid.n_total();
476    let n_echoes = phases.len();
477    if n_echoes == 0 {
478        return Err(InputError::new("SWI needs at least one echo"));
479    }
480    if mags.len() != n_echoes || tes.len() != n_echoes {
481        return Err(InputError::new(format!(
482            "SWI got {} phase echoes, {} magnitude echoes and {} echo times; they must match",
483            n_echoes, mags.len(), tes.len(),
484        )));
485    }
486    if n_echoes > 1 && !tes.iter().all(|&t| t.is_finite() && t > 0.0) {
487        return Err(InputError::new(format!("SWI echo times must be positive, got {tes:?}")));
488    }
489    for (e, (p, m)) in phases.iter().zip(mags).enumerate() {
490        crate::error::check_len(&format!("SWI phase echo {}", e + 1), p.as_ref().len(), n_total)?;
491        crate::error::check_len(&format!("SWI magnitude echo {}", e + 1), m.as_ref().len(), n_total)?;
492    }
493    if let Some(m) = mask {
494        crate::error::check_len("SWI mask", m.len(), n_total)?;
495    }
496
497    let mask: Vec<u8> = match mask {
498        Some(m) => m.iter().map(|&v| u8::from(v != 0)).collect(),
499        None => robust_mask(mags[0].as_ref(), grid),
500    };
501    let magnitude = swi_magnitude(mags, grid, params)?;
502    let filtered_phase = swi_phase(phases, mags, tes, &mask, grid, params);
503    let phase_mask = create_phase_mask(&filtered_phase, &mask, params.scaling, params.strength);
504    let swi = magnitude.iter().zip(&phase_mask).map(|(&m, &p)| m * p).collect();
505    Ok(SwiResult { swi, magnitude, phase_mask, filtered_phase, mask })
506}
507
508
509/// A minimum-intensity projection, and the geometry it actually lives on.
510///
511/// A projection is not the volume it came from: it is shorter along the slice axis, and each of
512/// its slices stands for a slab rather than a plane. Both facts travel with the data here so a
513/// caller cannot write it out under the source volume's geometry — which produces a file whose
514/// header promises more voxels than it holds, and whose slices are misplaced in world space.
515#[derive(Debug, Clone)]
516pub struct Mip {
517    /// Projection values, Fortran order, `grid.n_total()` of them.
518    pub data: Vec<f64>,
519    /// Dimensions and voxel sizes of the projection.
520    pub grid: Grid,
521    /// Row-major 4×4 voxel→world affine, with the origin at the centre of the first slab.
522    pub affine: [f64; 16],
523}
524
525/// Geometry of a sliding projection over `window` slices, without computing one.
526///
527/// Returns the projection's grid and affine. Use it to size or place a projection ahead of
528/// time — to validate a window before doing the work, or to allocate an output file.
529///
530/// The slice axis loses `window - 1` slices, since a sliding window of `window` slices has
531/// `nz - window + 1` positions. Each output slice represents the **centre** of its slab, so the
532/// origin moves `(window - 1) / 2` slices along the slice direction. That shift follows the
533/// affine's third column rather than world z, which keeps it correct for an oblique
534/// acquisition; an even window lands the origin on a half-slice offset, as the convention
535/// implies.
536///
537/// # Errors
538/// If `window` is zero, or deeper than the volume.
539pub fn mip_geometry(
540    grid: &Grid,
541    affine: &[f64; 16],
542    window: usize,
543) -> Result<(Grid, [f64; 16]), String> {
544    let (nx, ny, nz) = grid.dims;
545    if window == 0 {
546        return Err("MIP window must be at least 1 slice".to_string());
547    }
548    if window > nz {
549        return Err(format!(
550            "MIP window of {} slices is deeper than the {}-slice volume",
551            window, nz,
552        ));
553    }
554
555    let (vsx, vsy, vsz) = grid.voxel_size;
556    let mip_grid = Grid::new(nx, ny, nz - window + 1, vsx, vsy, vsz);
557
558    let mut mip_affine = *affine;
559    let slabs = (window - 1) as f64 / 2.0;
560    for row in 0..3 {
561        // Row-major 4×4: column 2 is the slice direction, column 3 the origin.
562        mip_affine[row * 4 + 3] = affine[row * 4 + 3] + affine[row * 4 + 2] * slabs;
563    }
564
565    Ok((mip_grid, mip_affine))
566}
567
568/// Minimum intensity projection along the z-axis.
569///
570/// For each (x, y) position, takes the minimum value over a sliding window of `window` slices
571/// along z. The result is `window - 1` slices shorter than `data` and sits half a slab further
572/// along the slice direction, so it comes back as a [`Mip`] carrying its own grid and affine —
573/// see [`mip_geometry`] for the convention.
574///
575/// # Arguments
576/// * `data` - 3D volume (Fortran order)
577/// * `grid` - Volume grid (dimensions and voxel sizes)
578/// * `affine` - Row-major 4×4 voxel→world affine of `data`. Pass an identity affine if the
579///   caller has no world geometry; the projection's affine is then identity-with-origin-shift.
580/// * `window` - Number of slices in the projection window
581///
582/// # Errors
583/// If `window` is zero or deeper than the volume, or if `data` does not match `grid`.
584pub fn create_mip(
585    data: &[f64],
586    grid: &Grid,
587    affine: &[f64; 16],
588    window: usize,
589) -> Result<Mip, String> {
590    let (nx, ny, nz) = grid.dims;
591    if data.len() != grid.n_total() {
592        return Err(format!(
593            "MIP input is {} values but its {}x{}x{} grid has {} voxels",
594            data.len(), nx, ny, nz, grid.n_total(),
595        ));
596    }
597    let (mip_grid, mip_affine) = mip_geometry(grid, affine, window)?;
598
599    let nz_out = mip_grid.nz();
600    let nxy = nx * ny;
601    let mut mip = vec![0.0; nxy * nz_out];
602
603    for k_out in 0..nz_out {
604        for j in 0..ny {
605            for i in 0..nx {
606                let idx_xy = i + j * nx;
607                let mut min_val = data[idx_xy + k_out * nxy];
608                for kw in 1..window {
609                    let val = data[idx_xy + (k_out + kw) * nxy];
610                    if val < min_val {
611                        min_val = val;
612                    }
613                }
614                mip[idx_xy + k_out * nxy] = min_val;
615            }
616        }
617    }
618
619    debug_assert_eq!(mip.len(), mip_grid.n_total());
620    Ok(Mip { data: mip, grid: mip_grid, affine: mip_affine })
621}
622
623/// Softplus magnitude scaling for enhanced contrast
624///
625/// Applies a shifted softplus function: `softplus(x) - softplus(0)` where
626/// `softplus(x) = (log(1 + exp(-|f*(x-offset)|)) + max(0, f*(x-offset))) / f`
627/// and `f = factor / offset`.
628///
629/// # Arguments
630/// * `magnitude` - Input magnitude data
631/// * `offset` - Softplus offset (controls transition point)
632/// * `factor` - Steepness factor (default 2.0)
633///
634/// # Returns
635/// Scaled magnitude
636pub fn softplus_scaling(
637    magnitude: &[f64],
638    offset: f64,
639    factor: f64,
640) -> Vec<f64> {
641    if offset.abs() < 1e-20 {
642        return magnitude.to_vec();
643    }
644
645    let f = factor / offset;
646
647    // softplus(0) for baseline subtraction
648    let arg0 = f * (0.0 - offset);
649    let sp0 = ((1.0 + (-arg0.abs()).exp()).ln() + arg0.max(0.0)) / f;
650
651    magnitude.iter().map(|&val| {
652        let arg = f * (val - offset);
653        let sp = ((1.0 + (-arg.abs()).exp()).ln() + arg.max(0.0)) / f;
654        sp - sp0
655    }).collect()
656}
657
658// ---- Susceptibility Map-Weighted Imaging (SMWI) ----
659
660/// SMWI algorithm parameters
661#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
662#[derive(Clone, Debug)]
663pub struct SmwiParams {
664    /// Susceptibility threshold in ppm: the |χ| at which the weighting mask
665    /// reaches zero. Must be positive; non-positive values fall back to 1 ppm.
666    pub threshold_ppm: f64,
667    /// Power the mask is raised to (contrast strength)
668    pub power: f64,
669    /// mIP window size in slices
670    pub mip_window: usize,
671}
672
673impl Default for SmwiParams {
674    fn default() -> Self {
675        Self {
676            threshold_ppm: 1.0,
677            power: 4.0,
678            mip_window: 4,
679        }
680    }
681}
682
683/// Which susceptibility sources the weighting mask suppresses
684#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub enum SmwiContrast {
687    /// Suppress paramagnetic sources (χ > 0): veins, iron, microbleeds
688    Paramagnetic,
689    /// Suppress diamagnetic sources (χ < 0): calcification, myelin
690    Diamagnetic,
691}
692
693/// Build the SMWI weighting mask from a susceptibility map
694///
695/// ```text
696/// paramagnetic: w(χ) = clamp(1 - χ/t, 0, 1)
697/// diamagnetic:  w(χ) = clamp(1 + χ/t, 0, 1)
698/// ```
699///
700/// Non-finite susceptibilities map to 0 so they cannot propagate into the
701/// weighted image or a later mIP.
702///
703/// # Arguments
704/// * `chi_ppm` - Susceptibility map in ppm
705/// * `threshold_ppm` - Susceptibility threshold in ppm (non-positive → 1 ppm)
706/// * `contrast` - Which sources to suppress
707///
708/// # Returns
709/// Weighting mask with values in [0, 1]
710pub fn susceptibility_mask(
711    chi_ppm: &[f64],
712    threshold_ppm: f64,
713    contrast: SmwiContrast,
714) -> Vec<f64> {
715    let t = if threshold_ppm > 0.0 { threshold_ppm } else { 1.0 };
716    let sign = match contrast {
717        SmwiContrast::Paramagnetic => -1.0,
718        SmwiContrast::Diamagnetic => 1.0,
719    };
720
721    chi_ppm
722        .iter()
723        .map(|&chi| {
724            if chi.is_finite() {
725                (1.0 + sign * chi / t).clamp(0.0, 1.0)
726            } else {
727                0.0
728            }
729        })
730        .collect()
731}
732
733/// Calculate SMWI from magnitude and a susceptibility map
734///
735/// Pipeline: susceptibility map → weighting mask → magnitude × mask^power.
736/// Unlike SWI, the weighting comes from χ rather than high-pass filtered
737/// phase, so it is free of the blooming and residual-wrap artifacts that the
738/// non-local dipole field introduces into a phase mask.
739///
740/// `magnitude` may hold several echoes stacked along the slowest axis
741/// (length `n_echoes × n_total`, as in `[x, y, z, echo]` Fortran order); the
742/// single χ-derived mask is then broadcast across echoes — the multi-echo
743/// SMWI of Nam et al. Use [`average_echoes`] to combine the result.
744///
745/// # Arguments
746/// * `magnitude` - Magnitude image(s), `n_total` or `n_echoes × n_total` values
747/// * `chi_ppm` - Susceptibility map in ppm (`n_total` values)
748/// * `mask` - Optional binary brain mask (1 = inside); outside is zeroed
749/// * `grid` - Volume grid (dimensions and voxel sizes)
750/// * `params` - SMWI algorithm parameters
751///
752/// # Returns
753/// `(paramagnetic, diamagnetic)` weighted images, each shaped like `magnitude`
754///
755/// # Panics
756/// If `chi_ppm` is not `n_total` long, or `magnitude` is not a non-zero
757/// multiple of `n_total`.
758pub fn calculate_smwi(
759    magnitude: &[f64],
760    chi_ppm: &[f64],
761    mask: Option<&[u8]>,
762    grid: &Grid,
763    params: &SmwiParams,
764) -> (Vec<f64>, Vec<f64>) {
765    let n_total = grid.n_total();
766    assert_eq!(
767        chi_ppm.len(),
768        n_total,
769        "SMWI: susceptibility map has {} voxels, grid has {}",
770        chi_ppm.len(),
771        n_total
772    );
773    assert!(
774        !magnitude.is_empty() && magnitude.len().is_multiple_of(n_total),
775        "SMWI: magnitude length {} is not a non-zero multiple of the {} grid voxels",
776        magnitude.len(),
777        n_total
778    );
779
780    let p_mask = susceptibility_mask(chi_ppm, params.threshold_ppm, SmwiContrast::Paramagnetic);
781    let d_mask = susceptibility_mask(chi_ppm, params.threshold_ppm, SmwiContrast::Diamagnetic);
782
783    // Weight once per voxel, then broadcast over echoes
784    let p_weight: Vec<f64> = p_mask.iter().map(|&w| w.powf(params.power)).collect();
785    let d_weight: Vec<f64> = d_mask.iter().map(|&w| w.powf(params.power)).collect();
786
787    let mut p_smwi = vec![0.0; magnitude.len()];
788    let mut d_smwi = vec![0.0; magnitude.len()];
789
790    let inside = |v: usize| mask.is_none_or(|m| m[v] != 0);
791
792    for (i, &m) in magnitude.iter().enumerate() {
793        let v = i % n_total;
794        if !inside(v) {
795            continue;
796        }
797        p_smwi[i] = m * p_weight[v];
798        d_smwi[i] = m * d_weight[v];
799    }
800
801    (p_smwi, d_smwi)
802}
803
804/// Average a multi-echo volume over its echoes
805///
806/// Input is `n_echoes × n_total` values stacked along the slowest axis;
807/// output is a single `n_total` volume. A single-echo input is returned
808/// unchanged.
809///
810/// # Panics
811/// If `data` is not a non-zero multiple of `grid.n_total()`.
812pub fn average_echoes(data: &[f64], grid: &Grid) -> Vec<f64> {
813    let n_total = grid.n_total();
814    assert!(
815        !data.is_empty() && data.len().is_multiple_of(n_total),
816        "average_echoes: length {} is not a non-zero multiple of the {} grid voxels",
817        data.len(),
818        n_total
819    );
820
821    let n_echoes = data.len() / n_total;
822    let mut out = vec![0.0; n_total];
823    for (i, &v) in data.iter().enumerate() {
824        out[i % n_total] += v;
825    }
826    for v in &mut out {
827        *v /= n_echoes as f64;
828    }
829    out
830}
831
832// ---- Helpers ----
833
834/// Median of `v` (reordered in place), or `None` if empty.
835fn median(v: &mut [f64]) -> Option<f64> {
836    if v.is_empty() {
837        return None;
838    }
839    v.sort_unstable_by(f64::total_cmp);
840    let mid = v.len() / 2;
841    Some(if v.len().is_multiple_of(2) { (v[mid - 1] + v[mid]) / 2.0 } else { v[mid] })
842}
843
844/// MriResearchTools' `sample`: up to 1e5 finite values taken as `√n` evenly spaced blocks of
845/// `√n` consecutive values, so a quantile of a large volume is cheap and deterministic.
846fn sample(data: &[f64]) -> Vec<f64> {
847    let n = data.len().min(100_000);
848    if n == 0 {
849        return Vec::new();
850    }
851    let len = (n as f64).sqrt().ceil() as usize;
852    let last_start = (data.len() - len) as f64;
853    let mut out = Vec::with_capacity(len * len);
854    for b in 0..len {
855        // Julia's `round(Int, range(0, N - len; length = len))`: ties to even.
856        let start = if len == 1 { 0.0 } else { last_start * b as f64 / (len - 1) as f64 };
857        let start = start.round_ties_even() as usize;
858        out.extend(data[start..start + len].iter().copied().filter(|v| v.is_finite()));
859    }
860    if out.is_empty() {
861        out = data.iter().copied().filter(|v| v.is_finite()).collect();
862    }
863    out
864}
865
866/// MriResearchTools' `estimatequantile`: quantiles `ps` of a block sample of `data`, with
867/// Julia's default (type 7, linear) interpolation. NaN if `data` holds no finite value.
868fn estimate_quantiles(data: &[f64], ps: &[f64]) -> Vec<f64> {
869    let mut s = sample(data);
870    if s.is_empty() {
871        return vec![f64::NAN; ps.len()];
872    }
873    s.sort_unstable_by(f64::total_cmp);
874    ps.iter()
875        .map(|&p| {
876            let h = (s.len() - 1) as f64 * p;
877            let lo = h.floor() as usize;
878            let hi = (lo + 1).min(s.len() - 1);
879            s[lo] + (h - lo as f64) * (s[hi] - s[lo])
880        })
881        .collect()
882}
883
884fn estimate_quantile(data: &[f64], p: f64) -> f64 {
885    estimate_quantiles(data, &[p])[0]
886}
887
888/// CLEARSWI.jl's `robustrescale(x[sel], new_min, new_max) .^ strength` on the voxels whose
889/// `pos` equals `positive`: the 1st and 99th percentiles, extrapolated to 0 % and 100 %, map
890/// to `new_min` and `new_max`. Clamped to [0, 1] before the power.
891fn rescale_selected(
892    data: &mut [f64],
893    pos: &[bool],
894    positive: bool,
895    new_min: f64,
896    new_max: f64,
897    strength: f64,
898) {
899    let selected: Vec<f64> = data.iter().zip(pos).filter(|&(_, &p)| p == positive).map(|(&v, _)| v).collect();
900    if selected.is_empty() {
901        return;
902    }
903    let q = estimate_quantiles(&selected, &[0.01, 0.99]);
904    let old_range = (q[1] - q[0]) / 0.98;
905    let old_min = q[0] - 0.01 * old_range;
906    for (v, &p) in data.iter_mut().zip(pos) {
907        if p != positive {
908            continue;
909        }
910        let r = if old_range.abs() > 1e-20 && old_range.is_finite() {
911            (*v - old_min) * ((new_max - new_min) / old_range) + new_min
912        } else {
913            (new_min + new_max) / 2.0
914        };
915        *v = r.clamp(0.0, 1.0).powf(strength);
916    }
917}
918
919/// Set the voxels whose `pos` equals `positive` to `value`.
920fn fill_selected(data: &mut [f64], pos: &[bool], positive: bool, value: f64) {
921    for (v, &p) in data.iter_mut().zip(pos) {
922        if p == positive {
923            *v = value;
924        }
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    const F0: f64 = 0.880_797_077_977_882_3; // (1 + tanh(1)) / 2
933
934    /// A ball of radius `r` voxels at the centre of an `n`³ grid.
935    fn ball(n: usize, r: f64) -> Vec<bool> {
936        let c = (n as f64 - 1.0) / 2.0;
937        (0..n * n * n)
938            .map(|i| {
939                let (x, y, z) = ((i % n) as f64, ((i / n) % n) as f64, (i / (n * n)) as f64);
940                (x - c).powi(2) + (y - c).powi(2) + (z - c).powi(2) <= r * r
941            })
942            .collect()
943    }
944
945    /// Echo phases `ω·TE` (wrapped) of a frequency map `ω` in rad/s.
946    fn echoes(omega: &[f64], tes: &[f64]) -> Vec<Vec<f64>> {
947        let wrap = |p: f64| (p + std::f64::consts::PI).rem_euclid(2.0 * std::f64::consts::PI) - std::f64::consts::PI;
948        tes.iter().map(|&te| omega.iter().map(|&w| wrap(w * te)).collect()).collect()
949    }
950
951    /// A smooth background frequency plus a small paramagnetic bump, in rad/s.
952    fn frequency(n: usize, background: f64, bump: f64) -> Vec<f64> {
953        let c = (n as f64 - 1.0) / 2.0;
954        (0..n * n * n)
955            .map(|i| {
956                let (x, y, z) = ((i % n) as f64, ((i / n) % n) as f64, (i / (n * n)) as f64);
957                let d2 = (x - c - 3.0).powi(2) + (y - c).powi(2) + (z - c).powi(2);
958                background * (x - c) / n as f64 + bump * (-d2 / 4.0).exp()
959            })
960            .collect()
961    }
962
963    #[test]
964    fn test_calculate_swi_zero_phase() {
965        let n = 8;
966        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
967        let phase = vec![vec![0.0; n * n * n]];
968        let mag = vec![vec![1.0; n * n * n]];
969        let mask = vec![1u8; n * n * n];
970        let r = calculate_swi(&phase, &mag, &[0.01], Some(&mask), &grid, &SwiParams::default()).unwrap();
971        // Zero phase: no positive phase, so m = 1 and every voxel gets tanh's value at zero.
972        for (&p, &s) in r.phase_mask.iter().zip(&r.swi) {
973            assert!((p - F0).abs() < 1e-12, "{p}");
974            assert!(s.is_finite() && s >= 0.0);
975        }
976    }
977
978    #[test]
979    fn test_phase_mask_outside_mask_is_the_zero_phase_weight() {
980        // CLEARSWI.jl zeroes the phase outside the mask, then scales every voxel: the head
981        // outside the mask keeps its magnitude (× f(0)) rather than going black.
982        let phase = vec![0.3, -0.2, 0.5, 0.7];
983        let mask = vec![1u8, 1, 0, 0];
984        let tanh = create_phase_mask(&phase, &mask, PhaseScaling::Tanh, 4.0);
985        assert!((tanh[2] - F0).abs() < 1e-12 && (tanh[3] - F0).abs() < 1e-12, "{tanh:?}");
986        let positive = create_phase_mask(&phase, &mask, PhaseScaling::Positive, 4.0);
987        assert_eq!(&positive[2..], &[1.0, 1.0]);
988        // Negative phase is untouched by the positive mask.
989        assert_eq!(positive[1], 1.0);
990    }
991
992    #[test]
993    fn test_tanh_mask_formula() {
994        // m = median(positive phase in mask) · 10 / strength
995        let phase = vec![0.1, 0.2, 0.3, -0.4];
996        let mask = vec![1u8; 4];
997        let pm = create_phase_mask(&phase, &mask, PhaseScaling::Tanh, 4.0);
998        let m = 0.2 * 10.0 / 4.0;
999        for (i, &p) in phase.iter().enumerate() {
1000            let want = (1.0 + (1.0 - p / m).tanh()) / 2.0;
1001            assert!((pm[i] - want).abs() < 1e-12, "{i}: {} vs {want}", pm[i]);
1002        }
1003        // NegativeTanh is Tanh of the negated phase.
1004        let neg: Vec<f64> = phase.iter().map(|p| -p).collect();
1005        assert_eq!(
1006            create_phase_mask(&phase, &mask, PhaseScaling::NegativeTanh, 4.0),
1007            create_phase_mask(&neg, &mask, PhaseScaling::Tanh, 4.0),
1008        );
1009    }
1010
1011    #[test]
1012    fn test_estimate_quantile_matches_mriresearchtools() {
1013        // Reference values from MriResearchTools 2.2 `estimatequantile` (Julia 1.9).
1014        let x: Vec<f64> = (1..=10).map(|i| (i * i) as f64).collect();
1015        assert_eq!(estimate_quantile(&x, 0.8), 64.0);
1016        let y: Vec<f64> = (1..=1000).map(|i| (0.37 * i as f64).sin() * i as f64).collect();
1017        let q = estimate_quantiles(&y, &[0.01, 0.99]);
1018        assert!((q[0] - -893.956_867_457_096_1).abs() < 1e-9, "{q:?}");
1019        assert!((q[1] - 898.654_032_022_898_7).abs() < 1e-9, "{q:?}");
1020        let z: Vec<f64> = (1..=300_000).map(|i| (0.011 * i as f64).cos() + 0.001 * i as f64).collect();
1021        assert!((estimate_quantile(&z, 0.8) - 240.122_157_209_175_4).abs() < 1e-9);
1022    }
1023
1024    #[test]
1025    fn test_combine_magnitude() {
1026        let mags = vec![vec![3.0, 1.0], vec![4.0, 2.0]];
1027        assert_eq!(combine_magnitude(&mags, MagCombine::Rss).unwrap(), vec![5.0, 5f64.sqrt()]);
1028        assert_eq!(combine_magnitude(&mags, MagCombine::Average).unwrap(), vec![7.0, 3.0]);
1029        assert_eq!(combine_magnitude(&mags, MagCombine::Last).unwrap(), vec![4.0, 2.0]);
1030        assert_eq!(combine_magnitude(&mags, MagCombine::Echo(0)).unwrap(), vec![3.0, 1.0]);
1031        assert!(combine_magnitude(&mags, MagCombine::Echo(2)).is_err());
1032        // One echo is that echo, whatever the combination.
1033        assert_eq!(combine_magnitude(&mags[..1], MagCombine::Rss).unwrap(), vec![3.0, 1.0]);
1034    }
1035
1036    #[test]
1037    fn test_calculate_swi_rejects_mismatched_echoes() {
1038        let grid = Grid::new(2, 2, 2, 1.0, 1.0, 1.0);
1039        let v = vec![vec![0.0; 8]; 2];
1040        let err = calculate_swi(&v, &v[..1], &[0.01, 0.02], None, &grid, &SwiParams::default()).unwrap_err();
1041        assert!(err.0.contains("must match"), "{err}");
1042        let err = calculate_swi(&v, &v, &[0.01, 0.0], None, &grid, &SwiParams::default()).unwrap_err();
1043        assert!(err.0.contains("positive"), "{err}");
1044        let short = vec![vec![0.0; 7]];
1045        assert!(calculate_swi(&short, &short, &[0.01], None, &grid, &SwiParams::default()).is_err());
1046        let none: Vec<Vec<f64>> = Vec::new();
1047        assert!(calculate_swi(&none, &none, &[], None, &grid, &SwiParams::default()).is_err());
1048    }
1049
1050    #[test]
1051    fn test_multi_echo_phase_matches_single_echo_without_wraps() {
1052        // Noise-free phase linear in TE, small enough not to wrap: every echo carries the same
1053        // frequency map, so the echo-combined filtered phase is the single-echo filtered phase
1054        // over 2π·TE, and the (scale-invariant) phase mask is the same.
1055        let n = 24;
1056        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1057        let omega = frequency(n, 20.0, 60.0);
1058        let tes = [0.004, 0.008, 0.012];
1059        let phases = echoes(&omega, &tes);
1060        let mags = vec![vec![100.0; n * n * n], vec![80.0; n * n * n], vec![60.0; n * n * n]];
1061        let mask: Vec<u8> = ball(n, 10.0).iter().map(|&b| u8::from(b)).collect();
1062        let params = SwiParams::default();
1063
1064        let multi = calculate_swi(&phases, &mags, &tes, Some(&mask), &grid, &params).unwrap();
1065        let single = calculate_swi(&phases[2..], &mags[2..], &tes[2..], Some(&mask), &grid, &params).unwrap();
1066        for i in 0..n * n * n {
1067            if mask[i] != 0 {
1068                let want = single.filtered_phase[i] / (2.0 * std::f64::consts::PI * tes[2]);
1069                assert!((multi.filtered_phase[i] - want).abs() < 1e-6, "{i}: {} vs {want}", multi.filtered_phase[i]);
1070            }
1071            assert!((multi.phase_mask[i] - single.phase_mask[i]).abs() < 1e-9, "{i}");
1072        }
1073        // The bump is paramagnetic: the phase mask darkens it.
1074        let centre = (n / 2 + 3) + (n / 2) * n + (n / 2) * n * n;
1075        assert!(multi.phase_mask[centre] < 0.5, "{}", multi.phase_mask[centre]);
1076    }
1077
1078    #[test]
1079    fn test_romeo_and_laplacian_agree_on_wrapped_phase() {
1080        // A background gradient that wraps the later echoes several times: both unwrappers
1081        // must recover the bump.
1082        let n = 24;
1083        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1084        let omega = frequency(n, 1200.0, 300.0);
1085        let tes = [0.005, 0.010, 0.015];
1086        let phases = echoes(&omega, &tes);
1087        let peak = omega.iter().fold(0.0f64, |a, w| a.max(w.abs())) * tes[2];
1088        assert!(peak > 2.0 * std::f64::consts::PI, "test phase should wrap, peak {peak}");
1089        let mags = vec![vec![100.0; n * n * n]; 3];
1090        let mask: Vec<u8> = ball(n, 10.0).iter().map(|&b| u8::from(b)).collect();
1091        let centre = (n / 2 + 3) + (n / 2) * n + (n / 2) * n * n;
1092        for unwrap in [SwiUnwrap::Laplacian, SwiUnwrap::LaplacianSlice, SwiUnwrap::Romeo] {
1093            let params = SwiParams { unwrap, ..SwiParams::default() };
1094            let r = calculate_swi(&phases, &mags, &tes, Some(&mask), &grid, &params).unwrap();
1095            assert!(r.phase_mask[centre] < 0.5, "{unwrap:?}: bump weight {}", r.phase_mask[centre]);
1096            assert!(r.swi.iter().all(|v| v.is_finite()), "{unwrap:?}");
1097        }
1098    }
1099
1100    #[test]
1101    fn test_default_mask_is_robust_mask_of_first_echo() {
1102        let n = 16;
1103        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1104        let inside = ball(n, 6.0);
1105        let mag: Vec<f64> = inside.iter().map(|&b| if b { 100.0 } else { 1.0 }).collect();
1106        let phase = vec![0.0; n * n * n];
1107        let r = calculate_swi(&[phase], std::slice::from_ref(&mag), &[0.02], None, &grid, &SwiParams::default()).unwrap();
1108        assert_eq!(r.mask, robust_mask(&mag, &grid));
1109    }
1110
1111    #[test]
1112    fn test_phase_mask_range() {
1113        let n = 10;
1114        let nn = n * n * n;
1115        let phase: Vec<f64> = (0..nn).map(|i| (i as f64 * 0.01) - 5.0).collect();
1116        let mask = vec![1u8; nn];
1117
1118        for scaling in &[
1119            PhaseScaling::Tanh,
1120            PhaseScaling::NegativeTanh,
1121            PhaseScaling::Positive,
1122            PhaseScaling::Negative,
1123            PhaseScaling::Triangular,
1124        ] {
1125            let pm = create_phase_mask(&phase, &mask, *scaling, 4.0);
1126            for (i, &v) in pm.iter().enumerate() {
1127                assert!(v >= 0.0, "{:?}: value at {} = {} < 0", scaling, i, v);
1128                assert!(v <= 1.0 + 1e-10, "{:?}: value at {} = {} > 1", scaling, i, v);
1129            }
1130        }
1131    }
1132
1133    #[test]
1134    fn test_highpass_filter_constant() {
1135        // Constant input should give zero output (constant is its own smooth)
1136        let n = 16;
1137        let nn = n * n * n;
1138        let data = vec![5.0; nn];
1139        let mask = vec![1u8; nn];
1140        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1141
1142        let result = highpass_filter(&data, &mask, &grid, [2.0, 2.0, 0.0]);
1143
1144        for &v in &result {
1145            assert!(v.abs() < 1.0, "High-pass of constant should be near zero, got {}", v);
1146        }
1147    }
1148
1149    const IDENTITY: [f64; 16] = [
1150        1.0, 0.0, 0.0, 0.0,
1151        0.0, 1.0, 0.0, 0.0,
1152        0.0, 0.0, 1.0, 0.0,
1153        0.0, 0.0, 0.0, 1.0,
1154    ];
1155
1156    #[test]
1157    fn test_mip_basic() {
1158        // 3x3x5 volume, mIP with window=3 → 3x3x3 output
1159        let (nx, ny, nz) = (3, 3, 5);
1160        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1161        let mut data = vec![10.0; nx * ny * nz];
1162        // Place a low value at slice 2
1163        let idx = 1 + nx + 2 * nx * ny; // (x, y, z) = (1, 1, 2)
1164        data[idx] = 1.0;
1165
1166        let mip = create_mip(&data, &grid, &IDENTITY, 3).unwrap();
1167        assert_eq!(mip.data.len(), nx * ny * 3);
1168
1169        // The minimum at (1,1) should appear in every window that includes z=2: the windows
1170        // starting at z = 0, 1 and 2 cover slices 0-2, 1-3 and 2-4 respectively.
1171        // (A closure rather than three `1 + 1 * nx + k * nx * ny` expressions, which kept the
1172        // three lines visually parallel at the cost of a `0 *` that clippy denies.)
1173        let mip_idx = |window: usize| 1 + nx + window * nx * ny;
1174        for window in 0..3 {
1175            assert_eq!(
1176                mip.data[mip_idx(window)], 1.0,
1177                "window starting at z={window} covers z=2 and must see the minimum"
1178            );
1179        }
1180    }
1181
1182    /// The projection must describe itself: a caller that trusts the source grid writes a file
1183    /// whose header promises more voxels than it holds (QSMxT#211).
1184    #[test]
1185    fn test_mip_reports_its_own_dimensions() {
1186        let grid = Grid::new(32, 32, 32, 1.0, 1.0, 1.0);
1187        let mip = create_mip(&vec![1.0; grid.n_total()], &grid, &IDENTITY, 7).unwrap();
1188        assert_eq!(mip.grid.dims, (32, 32, 26));
1189        assert_eq!(mip.data.len(), mip.grid.n_total());
1190        // Voxel sizes are unchanged by a projection.
1191        assert_eq!(mip.grid.voxel_size, grid.voxel_size);
1192    }
1193
1194    #[test]
1195    fn test_mip_origin_moves_to_the_slab_centre() {
1196        let grid = Grid::new(4, 4, 16, 1.0, 1.0, 2.0);
1197        let mut affine = IDENTITY;
1198        affine[10] = 2.0; // 2 mm slices
1199        affine[11] = 10.0;
1200        let (mip_grid, mip_affine) = mip_geometry(&grid, &affine, 7).unwrap();
1201        assert_eq!(mip_grid.dims, (4, 4, 10));
1202        // Three slices of 2 mm past the first slab's first slice.
1203        assert_eq!(mip_affine[11], 16.0);
1204        // Nothing but the origin moves.
1205        assert_eq!(mip_affine[..11], affine[..11]);
1206    }
1207
1208    #[test]
1209    fn test_mip_origin_follows_an_oblique_slice_direction() {
1210        // Slice direction tilted 45° in the y/z plane, 2 mm slices.
1211        let s = 2.0 / 2f64.sqrt();
1212        let affine = [
1213            1.0, 0.0, 0.0, 0.0,
1214            0.0, 1.0, s, 0.0,
1215            0.0, 0.0, s, 0.0,
1216            0.0, 0.0, 0.0, 1.0,
1217        ];
1218        let grid = Grid::new(8, 8, 20, 1.0, 1.0, 2.0);
1219        let (mip_grid, mip_affine) = mip_geometry(&grid, &affine, 7).unwrap();
1220        assert_eq!(mip_grid.dims, (8, 8, 14));
1221        // Both y and z move: three slice steps along the tilted direction, not along world z.
1222        assert!((mip_affine[7] - 3.0 * s).abs() < 1e-12, "{:?}", mip_affine);
1223        assert!((mip_affine[11] - 3.0 * s).abs() < 1e-12, "{:?}", mip_affine);
1224        assert_eq!(mip_affine[3], 0.0);
1225    }
1226
1227    #[test]
1228    fn test_mip_even_window_lands_on_a_half_slice() {
1229        let grid = Grid::new(4, 4, 16, 1.0, 1.0, 1.0);
1230        let (_, mip_affine) = mip_geometry(&grid, &IDENTITY, 4).unwrap();
1231        assert_eq!(mip_affine[11], 1.5);
1232    }
1233
1234    #[test]
1235    fn test_mip_window_of_one_is_the_volume_itself() {
1236        let grid = Grid::new(3, 3, 5, 1.0, 1.0, 1.0);
1237        let data: Vec<f64> = (0..grid.n_total()).map(|i| i as f64).collect();
1238        let mip = create_mip(&data, &grid, &IDENTITY, 1).unwrap();
1239        assert_eq!(mip.grid.dims, grid.dims);
1240        assert_eq!(mip.data, data);
1241        assert_eq!(mip.affine, IDENTITY);
1242    }
1243
1244    #[test]
1245    fn test_mip_window_of_full_depth_leaves_one_slice() {
1246        let grid = Grid::new(3, 3, 5, 1.0, 1.0, 1.0);
1247        let mip = create_mip(&vec![1.0; grid.n_total()], &grid, &IDENTITY, 5).unwrap();
1248        assert_eq!(mip.grid.dims, (3, 3, 1));
1249        assert_eq!(mip.affine[11], 2.0);
1250    }
1251
1252    #[test]
1253    fn test_mip_window_too_large() {
1254        let grid = Grid::new(3, 3, 3, 1.0, 1.0, 1.0);
1255        let err = create_mip(&[1.0; 27], &grid, &IDENTITY, 10).unwrap_err();
1256        assert!(err.contains("deeper than the 3-slice volume"), "{}", err);
1257    }
1258
1259    #[test]
1260    fn test_mip_zero_window() {
1261        let grid = Grid::new(3, 3, 3, 1.0, 1.0, 1.0);
1262        let err = create_mip(&[1.0; 27], &grid, &IDENTITY, 0).unwrap_err();
1263        assert!(err.contains("at least 1 slice"), "{}", err);
1264    }
1265
1266    #[test]
1267    fn test_mip_rejects_data_that_does_not_match_the_grid() {
1268        let grid = Grid::new(3, 3, 3, 1.0, 1.0, 1.0);
1269        let err = create_mip(&[1.0; 26], &grid, &IDENTITY, 3).unwrap_err();
1270        assert!(err.contains("26 values"), "{}", err);
1271        assert!(err.contains("27 voxels"), "{}", err);
1272    }
1273
1274    #[test]
1275    fn test_softplus_scaling() {
1276        let mag = vec![0.0, 0.5, 1.0, 2.0];
1277        let result = softplus_scaling(&mag, 1.0, 2.0);
1278
1279        // softplus(0, offset=1, factor=2) should be 0 (baseline subtracted)
1280        assert!(result[0].abs() < 1e-10, "softplus(0) should be ~0, got {}", result[0]);
1281        // Values should increase monotonically
1282        for i in 1..result.len() {
1283            assert!(result[i] >= result[i - 1], "softplus should be monotonically increasing");
1284        }
1285    }
1286
1287    // ---- SMWI ----
1288
1289    #[test]
1290    fn test_susceptibility_mask_paramagnetic() {
1291        // t = 0.5 ppm: 1 below zero, linear ramp to 0 at the threshold
1292        let chi = vec![-1.0, -0.001, 0.0, 0.125, 0.25, 0.5, 0.9];
1293        let w = susceptibility_mask(&chi, 0.5, SmwiContrast::Paramagnetic);
1294        let expected = [1.0, 1.0, 1.0, 0.75, 0.5, 0.0, 0.0];
1295        for (i, (&got, &want)) in w.iter().zip(expected.iter()).enumerate() {
1296            assert!((got - want).abs() < 1e-12, "index {}: got {}, want {}", i, got, want);
1297        }
1298    }
1299
1300    #[test]
1301    fn test_susceptibility_mask_diamagnetic() {
1302        // Mirror image of the paramagnetic mask about χ = 0
1303        let chi = vec![-0.9, -0.5, -0.25, 0.0, 0.25, 1.0];
1304        let w = susceptibility_mask(&chi, 0.5, SmwiContrast::Diamagnetic);
1305        let expected = [0.0, 0.0, 0.5, 1.0, 1.0, 1.0];
1306        for (i, (&got, &want)) in w.iter().zip(expected.iter()).enumerate() {
1307            assert!((got - want).abs() < 1e-12, "index {}: got {}, want {}", i, got, want);
1308        }
1309    }
1310
1311    #[test]
1312    fn test_susceptibility_mask_edge_cases() {
1313        // Non-finite χ is suppressed rather than propagated
1314        let chi = vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
1315        for contrast in &[SmwiContrast::Paramagnetic, SmwiContrast::Diamagnetic] {
1316            let w = susceptibility_mask(&chi, 1.0, *contrast);
1317            assert!(w.iter().all(|&v| v == 0.0), "{:?}: non-finite χ should give 0", contrast);
1318        }
1319
1320        // Non-positive threshold falls back to 1 ppm
1321        let chi = vec![0.5];
1322        let fallback = susceptibility_mask(&chi, -1.0, SmwiContrast::Paramagnetic);
1323        let one_ppm = susceptibility_mask(&chi, 1.0, SmwiContrast::Paramagnetic);
1324        assert_eq!(fallback, one_ppm);
1325    }
1326
1327    #[test]
1328    fn test_smwi_reference_formula() {
1329        // magn .* max(0, 1 - χ/t).^m, hand-computed at t = 0.2 ppm, m = 4
1330        let grid = Grid::new(2, 1, 1, 1.0, 1.0, 1.0);
1331        let chi = vec![0.05, -0.05];
1332        let magnitude = vec![100.0, 100.0];
1333        let params = SmwiParams { threshold_ppm: 0.2, power: 4.0, mip_window: 4 };
1334
1335        let (para, dia) = calculate_smwi(&magnitude, &chi, None, &grid, &params);
1336
1337        // χ = +0.05: paramagnetic weight (1 - 0.25)^4 = 0.31640625, diamagnetic 1
1338        assert!((para[0] - 31.640625).abs() < 1e-9, "got {}", para[0]);
1339        assert!((dia[0] - 100.0).abs() < 1e-9, "got {}", dia[0]);
1340        // χ = -0.05: mirror image
1341        assert!((para[1] - 100.0).abs() < 1e-9, "got {}", para[1]);
1342        assert!((dia[1] - 31.640625).abs() < 1e-9, "got {}", dia[1]);
1343    }
1344
1345    #[test]
1346    fn test_smwi_multi_echo_broadcast() {
1347        // One χ mask applied to every echo of a 4D magnitude
1348        let grid = Grid::new(2, 2, 2, 1.0, 1.0, 1.0);
1349        let n = grid.n_total();
1350        let chi: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
1351        let magnitude: Vec<f64> = (0..3 * n).map(|i| (i + 1) as f64).collect();
1352        let params = SmwiParams::default();
1353
1354        let (para, _dia) = calculate_smwi(&magnitude, &chi, None, &grid, &params);
1355        assert_eq!(para.len(), magnitude.len());
1356
1357        let weight = susceptibility_mask(&chi, params.threshold_ppm, SmwiContrast::Paramagnetic);
1358        for i in 0..magnitude.len() {
1359            let want = magnitude[i] * weight[i % n].powf(params.power);
1360            assert!((para[i] - want).abs() < 1e-12, "index {}: got {}, want {}", i, para[i], want);
1361        }
1362
1363        // Single-echo call on the first echo must agree with the broadcast one
1364        let (para1, _) = calculate_smwi(&magnitude[..n], &chi, None, &grid, &params);
1365        assert_eq!(&para[..n], &para1[..]);
1366    }
1367
1368    #[test]
1369    fn test_smwi_brain_mask() {
1370        let grid = Grid::new(2, 2, 2, 1.0, 1.0, 1.0);
1371        let n = grid.n_total();
1372        let chi = vec![0.0; n];
1373        let magnitude = vec![5.0; 2 * n];
1374        let mut mask = vec![1u8; n];
1375        mask[0] = 0;
1376
1377        let (para, dia) = calculate_smwi(&magnitude, &chi, Some(&mask), &grid, &SmwiParams::default());
1378
1379        // Masked-out voxel is zero in every echo, χ = 0 leaves the rest untouched
1380        assert_eq!(para[0], 0.0);
1381        assert_eq!(para[n], 0.0);
1382        assert_eq!(dia[0], 0.0);
1383        assert!((para[1] - 5.0).abs() < 1e-12);
1384        assert!((para[n + 1] - 5.0).abs() < 1e-12);
1385    }
1386
1387    #[test]
1388    fn test_average_echoes() {
1389        let grid = Grid::new(2, 1, 1, 1.0, 1.0, 1.0);
1390        let data = vec![1.0, 2.0, 3.0, 4.0, 8.0, 12.0];
1391        let avg = average_echoes(&data, &grid);
1392        assert_eq!(avg, vec![4.0, 6.0]);
1393
1394        // Single echo passes through
1395        let single = vec![7.0, 9.0];
1396        assert_eq!(average_echoes(&single, &grid), single);
1397    }
1398
1399    #[test]
1400    #[should_panic(expected = "not a non-zero multiple")]
1401    fn test_smwi_rejects_ragged_magnitude() {
1402        let grid = Grid::new(2, 2, 2, 1.0, 1.0, 1.0);
1403        let chi = vec![0.0; grid.n_total()];
1404        let magnitude = vec![1.0; grid.n_total() + 1];
1405        let _ = calculate_smwi(&magnitude, &chi, None, &grid, &SmwiParams::default());
1406    }
1407}