Skip to main content

qsm_core/separation/
wavesep.rs

1//! WaveSep: wavelet-based susceptibility source separation.
2//!
3//! WaveSep splits net susceptibility (χ_total / QSM) into paramagnetic (χ+, iron)
4//! and diamagnetic (χ−, myelin·calcium) sources using an R2' map to break the
5//! para/dia degeneracy. It solves two voxel-wise data-fidelity terms under a
6//! wavelet-domain L1 (soft-thresholding) sparsity prior by proximal gradient
7//! (ISTA):
8//!
9//! ```text
10//!   χ+ + χ−  ≈ χ_total          (net susceptibility)
11//!   χ+ − χ−  ≈ R2' / Dr         (static-dephasing R2', single relaxivity kernel)
12//! ```
13//!
14//! with the sign convention χ+ ≥ 0, χ− ≤ 0. `Dr` is the static-dephasing
15//! relaxivity (Hz/ppm); the qsm-forward phantom's single kernel is 137. WaveSep's
16//! QSM path uses **no** B0 direction (unlike an STI path), so single-orientation
17//! data needs no reorientation.
18//!
19//! Each ISTA iteration is: a gradient step on the two quadratic fidelities, a
20//! wavelet-L1 proximal step (forward db4 periodic transform → soft-threshold all
21//! coefficients by `alpha·lambda` → inverse), then a sign projection
22//! (χ+ = max(χ+,0), χ− = min(χ−,0)) restricted to the mask. It stops when the
23//! relative change falls below `tol`.
24//!
25//! The volume is zero-padded so every axis is a multiple of `2^L` (L = the
26//! periodic max decomposition level, [`dwt_max_level`]), matching PyWavelets'
27//! `periodization` round-trip, then cropped back.
28//!
29//! The wavelet step is PyWavelets' own transform: `wavedecn`/`waverecn` with `mode="periodization"`
30//! ([`Periodization::PyWavelets`]: the filter centred at `2k + lf/2`, and PyWavelets' db4 taps).
31//! Until 2026-10-30 WaveSep used MATLAB's `'per'` alignment (`2k + 1`, the convention AMP-PE needs)
32//! and MATLAB's db4 taps, which is a differently aligned basis: the proximal step was 3.4 % from
33//! PyWavelets' on a smooth 32³ field, and the separation 10.2 % (χ+) / 10.0 % (χ−) NRMSE from
34//! WaveSep f3a192e on the 24³ case of `tests/wavesep_parity.rs` (0.11 % / 1.08 % on QSMxT/QSM-CI#302's
35//! 164×205×205 case). With PyWavelets' transform the 24³ case agrees to 2.8e-8 / 2.5e-8 NRMSE (the
36//! fixture's float32 rounding), with the same 13 iterations (astewartau/QSM.rs#327).
37//!
38//! # Reference pipeline
39//!
40//! - **Data:** in-vivo 3 T, 1 mm isotropic, six head orientations per subject (Fang et al. 2023,
41//!   in-vivo experiments). The reference χ+/χ− were χ-separation from all six orientations.
42//! - **QSM input:** an LPCNN reconstruction. The repo's example feeds
43//!   `LPCNN_data/test_results/3ori/..._Ori001-Ori002-Ori003.nii.gz`
44//!   (`data/yml/example_qsm.yml:27`), and the paper tests with 1 to 3 orientations. No
45//!   single-orientation, conventional-QSM input appears in the authors' pipeline.
46//! - **R2':** one R2' map per orientation (`R2p_fn_list`, `data/yml/template_qsm.yml:5-8`). The
47//!   R2' gradient is averaged over them (`wavesep/utils/solver_wavesep_qsm.py:202-204`).
48//! - **D_r:** read per dataset from `params.json` (`Dr_pos`, `Dr_neg`;
49//!   `wavesep/utils/run_wavesep_qsm.py:18`). The repo ships no value, and `Dr_pos ≠ Dr_neg` has
50//!   been supported only since the 2025-04-23 update (README), with a warning. The QSM.rs default
51//!   [`DR_SHIN_2021`] is not taken from WaveSep (open question).
52//! - **Defaults:** `alpha = 0.2`, `wavelet = db4`, `level = None` (maximum), `Lambda = 0.02`
53//!   (`wavesep/qsm_sep.py:14-18`), `maxit = 100` (`run_wavesep_qsm.py:32`). These match
54//!   [`WaveSepParams`].
55//! - **Orientation:** "All images should be in the LPS orientation"
56//!   (`data/yml/template_qsm.yml:19`). The QSM path uses no B0 direction, so this only fixes
57//!   the wavelet axes.
58//!
59//! Reference:
60//! Fang, Z., Shin, H.-G., van Zijl, P., Li, X., Sulam, J. (2023). "WaveSep: A
61//! flexible wavelet-based approach for source separation in susceptibility
62//! imaging." Machine Learning in Clinical Neuroimaging (MLCN), MICCAI 2023,
63//! Springer LNCS. https://doi.org/10.1007/978-3-031-44858-4_6
64//!
65//! Reference implementation: https://github.com/ZhenghanFang/WaveSep
66//!
67//! # Agreement with WaveSep
68//!
69//! Against WaveSep f3a192e in the verified QSM-CI `wavesep` image (QSMxT/QSM-CI#302), on the 24³
70//! case of `tests/wavesep_parity.rs`: χ+ 2.8e-8 and χ− 2.5e-8 NRMSE (the fixture's float32), the
71//! same 13 iterations (measured 2026-10-30). The wavelet step's history and the earlier gap
72//! (10.2 % / 10.0 % here; 0.11 % / 1.08 % on a 164×205×205 case) are above, under the padding.
73
74use super::DR_SHIN_2021;
75use crate::utils::wavelet::{dwt_max_level, Periodization, WaveletPlan};
76use crate::error::{check_len, InputError};
77use crate::Grid;
78
79/// Parameters for [`wavesep`].
80#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
81#[derive(Clone, Debug)]
82pub struct WaveSepParams {
83    /// Paramagnetic static-dephasing relaxivity Dr+ in Hz/ppm. Defaults to [`DR_SHIN_2021`].
84    pub dr_pos: f64,
85    /// Diamagnetic static-dephasing relaxivity Dr− in Hz/ppm (WaveSep assumes = Dr+).
86    pub dr_neg: f64,
87    /// Proximal-gradient step / data-consistency weight (repo default 0.2).
88    pub alpha: f64,
89    /// Wavelet-domain L1 (soft-thresholding) sparsity weight (repo default 0.02).
90    pub lambda: f64,
91    /// Daubechies order for the sparsity transform (repo default 4 = db4).
92    pub wavelet_order: usize,
93    /// Maximum proximal-gradient iterations (repo default 100, early-stops).
94    pub max_iter: usize,
95    /// Relative-change early-stop tolerance (repo default 1e-3).
96    pub tol: f64,
97}
98
99impl WaveSepParams {
100    /// Checks `wavelet_order`: it must be a supported Daubechies order
101    /// ([`SUPPORTED_ORDERS`](crate::utils::wavelet::SUPPORTED_ORDERS), 1–4).
102    pub fn validate(&self) -> Result<(), InputError> {
103        crate::utils::wavelet::check_order("WaveSep wavelet_order", self.wavelet_order)
104    }
105}
106
107impl Default for WaveSepParams {
108    fn default() -> Self {
109        Self {
110            dr_pos: DR_SHIN_2021,
111            dr_neg: DR_SHIN_2021,
112            alpha: 0.2,
113            lambda: 0.02,
114            wavelet_order: 4,
115            max_iter: 100,
116            tol: 1e-3,
117        }
118    }
119}
120
121/// WaveSep source separation from a QSM and an R2' map.
122///
123/// # Arguments
124/// * `chi_total` — Conventional QSM χ_total in **ppm** (`nx·ny·nz`, column-major).
125/// * `r2prime` — R2' map in **Hz** (`nx·ny·nz`).
126/// * `mask` — Binary brain mask (`nx·ny·nz`, 1 = inside).
127/// * `grid` — Volume dimensions and voxel sizes.
128/// * `params` — See [`WaveSepParams`].
129/// * `progress` — Progress callback `(iteration, max_iterations)`.
130///
131/// # Returns
132/// `(chi_pos, chi_neg, chi_total)` in ppm, restricted to `mask` — matching the
133/// [`chi_sep_ilsqr`](super::chi_sep_ilsqr)/[`chi_sep_medi`](super::chi_sep_medi)
134/// convention: `chi_pos` ≥ 0 (paramagnetic), `chi_neg` ≤ 0 (diamagnetic, signed),
135/// and `chi_total = chi_pos + chi_neg`.
136pub fn wavesep(
137    chi_total: &[f64],
138    r2prime: &[f64],
139    mask: &[u8],
140    grid: &Grid,
141    params: &WaveSepParams,
142    mut progress: impl FnMut(usize, usize),
143) -> Result<super::ChiSepMaps, InputError> {
144    params.validate()?;
145    let (nx, ny, nz) = grid.dims;
146    let n = nx * ny * nz;
147    check_len("WaveSep chi_total", chi_total.len(), n)?;
148    check_len("WaveSep r2prime", r2prime.len(), n)?;
149    check_len("WaveSep mask", mask.len(), n)?;
150
151    // --- padding so each axis is a multiple of 2^L (periodic wavelet round-trip) ---
152    let dec_len = 2 * params.wavelet_order;
153    let (pdims, level) = pad_spec((nx, ny, nz), dec_len);
154    let (pnx, pny, pnz) = pdims;
155    let pn = pnx * pny * pnz;
156
157    // Masked, padded inputs (zeros appended at the high end of each axis).
158    let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
159    let mask_p = pad3d(&maskf, (nx, ny, nz), pdims);
160    let qsm_p = {
161        let mut q = pad3d(chi_total, (nx, ny, nz), pdims);
162        for i in 0..pn {
163            q[i] *= mask_p[i];
164        }
165        q
166    };
167    let r2p_p = {
168        let mut r = pad3d(r2prime, (nx, ny, nz), pdims);
169        for i in 0..pn {
170            r[i] *= mask_p[i];
171        }
172        r
173    };
174
175    let plan = WaveletPlan::new(params.wavelet_order, pdims, level, Periodization::PyWavelets);
176    let th = params.alpha * params.lambda;
177    let dr_ratio = params.dr_neg / params.dr_pos;
178
179    // ISTA state: xp (χ+) and xn (χ−); `prev` holds the last projected iterate
180    // so the early stop matches WaveSep's ‖x − x_old‖ / ‖x‖.
181    let mut xp = vec![0.0_f64; pn];
182    let mut xn = vec![0.0_f64; pn];
183    let mut prev = vec![0.0_f64; 2 * pn];
184
185    for it in 0..params.max_iter {
186        progress(it + 1, params.max_iter);
187        // --- gradient step on the two quadratic fidelities ---
188        // QSM fidelity: gq = χ+ + χ− − χ_total (same gradient for both components).
189        // R2' fidelity: r = χ+ − (Dr−/Dr+)·χ− − R2'/Dr+ ; grads [r, −(Dr−/Dr+)·r].
190        for i in 0..pn {
191            let gq = xp[i] + xn[i] - qsm_p[i];
192            let r = xp[i] - dr_ratio * xn[i] - r2p_p[i] / params.dr_pos;
193            xp[i] -= params.alpha * (gq + r);
194            xn[i] -= params.alpha * (gq - dr_ratio * r);
195        }
196
197        // --- wavelet-L1 proximal step (soft-threshold all coefficients) ---
198        prox_wavelet_l1(&mut xp, &plan, th);
199        prox_wavelet_l1(&mut xn, &plan, th);
200
201        // --- sign projection + mask; accumulate ‖Δx‖ and ‖x‖ for the early stop ---
202        let mut diff = 0.0_f64;
203        let mut norm = 0.0_f64;
204        for i in 0..pn {
205            let m = mask_p[i];
206            xp[i] = if xp[i] < 0.0 { 0.0 } else { xp[i] } * m;
207            xn[i] = if xn[i] > 0.0 { 0.0 } else { xn[i] } * m;
208            let dp = xp[i] - prev[i];
209            let dn = xn[i] - prev[pn + i];
210            diff += dp * dp + dn * dn;
211            norm += xp[i] * xp[i] + xn[i] * xn[i];
212            prev[i] = xp[i];
213            prev[pn + i] = xn[i];
214        }
215        if norm > 0.0 && (diff.sqrt() / norm.sqrt()) < params.tol {
216            break;
217        }
218    }
219
220    // Crop back and emit χ+ (≥ 0), χ− (≤ 0, signed) and χ_total = χ+ + χ−.
221    let xp_c = unpad3d(&xp, pdims, (nx, ny, nz));
222    let xn_c = unpad3d(&xn, pdims, (nx, ny, nz));
223    let mut chi_pos = vec![0.0_f64; n];
224    let mut chi_neg = vec![0.0_f64; n];
225    let mut chi_out = vec![0.0_f64; n];
226    for i in 0..n {
227        if mask[i] != 0 {
228            chi_pos[i] = xp_c[i];
229            chi_neg[i] = xn_c[i];
230            chi_out[i] = xp_c[i] + xn_c[i];
231        }
232    }
233    Ok((chi_pos, chi_neg, chi_out))
234}
235
236/// Wavelet-L1 proximal operator for an orthonormal transform: soft-threshold all
237/// coefficients (approximation + details) by `th`, in place.
238fn prox_wavelet_l1(x: &mut [f64], plan: &WaveletPlan, th: f64) {
239    let mut coef = plan.forward(x);
240    for c in coef.iter_mut() {
241        *c = soft_threshold(*c, th);
242    }
243    let rec = plan.inverse(&coef);
244    x.copy_from_slice(&rec);
245}
246
247#[inline]
248fn soft_threshold(z: f64, th: f64) -> f64 {
249    if z > th {
250        z - th
251    } else if z < -th {
252        z + th
253    } else {
254        0.0
255    }
256}
257
258/// Padded dimensions and decomposition level, matching WaveSep's `pad_spec`:
259/// grow each axis to a common multiple `P` (starting at 16) until every padded
260/// dim is divisible by `2^L`, where `L = dwt_max_level(min_padded, dec_len)`.
261fn pad_spec(dims: (usize, usize, usize), dec_len: usize) -> ((usize, usize, usize), usize) {
262    let ds = [dims.0, dims.1, dims.2];
263    let mut p = 16usize;
264    loop {
265        let padded = [
266            ds[0].div_ceil(p) * p,
267            ds[1].div_ceil(p) * p,
268            ds[2].div_ceil(p) * p,
269        ];
270        let min_padded = *padded.iter().min().unwrap();
271        let level = dwt_max_level(min_padded, dec_len);
272        let m = 1usize << level;
273        if padded.iter().all(|&d| d % m == 0) {
274            return ((padded[0], padded[1], padded[2]), level);
275        }
276        p *= 2;
277    }
278}
279
280/// Zero-pad a column-major 3D array from `from` to `to` (extra voxels appended at
281/// the high end of each axis), matching `numpy.pad(a, [(0, p-d), ...])`.
282fn pad3d(a: &[f64], from: (usize, usize, usize), to: (usize, usize, usize)) -> Vec<f64> {
283    let (fx, fy, fz) = from;
284    let (tx, ty, tz) = to;
285    let mut out = vec![0.0_f64; tx * ty * tz];
286    for k in 0..fz {
287        for j in 0..fy {
288            let src = (k * fy + j) * fx;
289            let dst = (k * ty + j) * tx;
290            out[dst..dst + fx].copy_from_slice(&a[src..src + fx]);
291        }
292    }
293    out
294}
295
296/// Crop a column-major 3D array from `from` back to `to` (inverse of [`pad3d`]).
297fn unpad3d(a: &[f64], from: (usize, usize, usize), to: (usize, usize, usize)) -> Vec<f64> {
298    let (fx, fy, _fz) = from;
299    let (tx, ty, tz) = to;
300    let mut out = vec![0.0_f64; tx * ty * tz];
301    for k in 0..tz {
302        for j in 0..ty {
303            let src = (k * fy + j) * fx;
304            let dst = (k * ty + j) * tx;
305            out[dst..dst + tx].copy_from_slice(&a[src..src + tx]);
306        }
307    }
308    out
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
316        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
317    }
318
319    #[test]
320    fn pad_spec_divisible_and_covers() {
321        for dims in [(64, 64, 40), (100, 100, 60), (48, 56, 72)] {
322            let (pd, level) = pad_spec(dims, 8);
323            let m = 1usize << level;
324            assert!(pd.0 % m == 0 && pd.1 % m == 0 && pd.2 % m == 0, "divisible {dims:?}");
325            assert!(pd.0 >= dims.0 && pd.1 >= dims.1 && pd.2 >= dims.2, "covers {dims:?}");
326        }
327    }
328
329    #[test]
330    fn pad_unpad_roundtrip() {
331        let from = (5, 6, 7);
332        let to = (8, 8, 8);
333        let n = 5 * 6 * 7;
334        let a: Vec<f64> = (0..n).map(|i| i as f64).collect();
335        let p = pad3d(&a, from, to);
336        let back = unpad3d(&p, to, from);
337        assert_eq!(a, back);
338    }
339
340    /// On a phantom obeying the WaveSep model exactly (single kernel Dr, no
341    /// regularisation needed), the solver should recover the sources well.
342    #[test]
343    fn recovers_model_sources() {
344        let (nx, ny, nz) = (16, 16, 16);
345        let g = grid(nx, ny, nz);
346        let n = nx * ny * nz;
347        let dr = 137.0;
348
349        // Smooth, sign-correct source fields inside a central mask.
350        let mut chi_pos = vec![0.0_f64; n];
351        let mut chi_neg = vec![0.0_f64; n]; // ≤ 0
352        let mut mask = vec![0u8; n];
353        for k in 0..nz {
354            for j in 0..ny {
355                for i in 0..nx {
356                    let idx = i + j * nx + k * nx * ny;
357                    let inside = (3..13).contains(&i) && (3..13).contains(&j) && (3..13).contains(&k);
358                    if inside {
359                        mask[idx] = 1;
360                        let fx = i as f64 / nx as f64;
361                        let fy = j as f64 / ny as f64;
362                        chi_pos[idx] = 0.10 * (1.0 + (fx * 6.0).sin()).max(0.0);
363                        chi_neg[idx] = -0.08 * (1.0 + (fy * 5.0).cos()).max(0.0);
364                    }
365                }
366            }
367        }
368        // Forward model: χ_total = χ+ + χ−, R2' = Dr·(χ+ + |χ−|).
369        let chi_total: Vec<f64> = (0..n).map(|i| chi_pos[i] + chi_neg[i]).collect();
370        let r2p: Vec<f64> = (0..n).map(|i| dr * (chi_pos[i] - chi_neg[i])).collect();
371
372        let params = WaveSepParams {
373            dr_pos: dr,
374            dr_neg: dr,
375            alpha: 0.2,
376            lambda: 0.002, // light regularisation for a clean phantom
377            wavelet_order: 4,
378            max_iter: 300,
379            tol: 1e-4,
380        };
381        let (para, neg, total) = wavesep(&chi_total, &r2p, &mask, &g, &params, |_, _| {}).unwrap();
382
383        // Correlate recovered vs. truth inside the mask.
384        let corr = |a: &[f64], b: &[f64]| {
385            let idx: Vec<usize> = (0..n).filter(|&i| mask[i] == 1).collect();
386            let m = idx.len() as f64;
387            let ma = idx.iter().map(|&i| a[i]).sum::<f64>() / m;
388            let mb = idx.iter().map(|&i| b[i]).sum::<f64>() / m;
389            let mut sab = 0.0;
390            let mut saa = 0.0;
391            let mut sbb = 0.0;
392            for &i in &idx {
393                let da = a[i] - ma;
394                let db = b[i] - mb;
395                sab += da * db;
396                saa += da * da;
397                sbb += db * db;
398            }
399            sab / (saa.sqrt() * sbb.sqrt() + 1e-20)
400        };
401        // χ− is returned signed (≤ 0); correlate against the signed truth.
402        let cp = corr(&para, &chi_pos);
403        let cn = corr(&neg, &chi_neg);
404        assert!(cp > 0.9, "χ+ correlation too low: {cp:.3}");
405        assert!(cn > 0.9, "χ− correlation too low: {cn:.3}");
406        // χ_total ≈ χ+ + χ− and χ− ≤ 0 inside the mask.
407        for i in 0..n {
408            if mask[i] == 1 {
409                assert!(neg[i] <= 1e-9, "χ− must be ≤ 0");
410                assert!((total[i] - (para[i] + neg[i])).abs() < 1e-9, "χ_total = χ+ + χ−");
411            }
412        }
413    }
414
415    /// #199: an unsupported wavelet order panicked in the filter lookup; it is an error now.
416    #[test]
417    fn unsupported_wavelet_order_is_an_error() {
418        let g = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
419        let params = WaveSepParams { wavelet_order: 5, ..Default::default() };
420        let err = wavesep(&[0.0; 64], &[0.0; 64], &[1u8; 64], &g, &params, |_, _| {}).unwrap_err();
421        assert!(err.0.contains("WaveSep wavelet_order = 5"), "{err}");
422    }
423}