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//! Reference:
30//! Fang, Z., Shin, H.-G., van Zijl, P., Li, X., Sulam, J. (2023). "WaveSep: A
31//! flexible wavelet-based approach for source separation in susceptibility
32//! imaging." Machine Learning in Clinical Neuroimaging (MLCN), MICCAI 2023,
33//! Springer LNCS. https://doi.org/10.1007/978-3-031-44858-4_6
34//!
35//! Reference implementation: https://github.com/ZhenghanFang/WaveSep
36
37use crate::utils::wavelet::{dwt_max_level, WaveletPlan};
38use crate::Grid;
39
40/// Parameters for [`wavesep`].
41#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
42#[derive(Clone, Debug)]
43pub struct WaveSepParams {
44    /// Paramagnetic static-dephasing relaxivity Dr+ in Hz/ppm (phantom kernel: 137).
45    pub dr_pos: f64,
46    /// Diamagnetic static-dephasing relaxivity Dr− in Hz/ppm (WaveSep assumes = Dr+).
47    pub dr_neg: f64,
48    /// Proximal-gradient step / data-consistency weight (repo default 0.2).
49    pub alpha: f64,
50    /// Wavelet-domain L1 (soft-thresholding) sparsity weight (repo default 0.02).
51    pub lambda: f64,
52    /// Daubechies order for the sparsity transform (repo default 4 = db4).
53    pub wavelet_order: usize,
54    /// Maximum proximal-gradient iterations (repo default 100, early-stops).
55    pub max_iter: usize,
56    /// Relative-change early-stop tolerance (repo default 1e-3).
57    pub tol: f64,
58}
59
60impl Default for WaveSepParams {
61    fn default() -> Self {
62        Self {
63            dr_pos: 137.0,
64            dr_neg: 137.0,
65            alpha: 0.2,
66            lambda: 0.02,
67            wavelet_order: 4,
68            max_iter: 100,
69            tol: 1e-3,
70        }
71    }
72}
73
74/// WaveSep source separation from a QSM and an R2' map.
75///
76/// # Arguments
77/// * `chi_total` — Conventional QSM χ_total in **ppm** (`nx·ny·nz`, column-major).
78/// * `r2prime` — R2' map in **Hz** (`nx·ny·nz`).
79/// * `mask` — Binary brain mask (`nx·ny·nz`, 1 = inside).
80/// * `grid` — Volume dimensions and voxel sizes.
81/// * `params` — See [`WaveSepParams`].
82/// * `progress` — Progress callback `(iteration, max_iterations)`.
83///
84/// # Returns
85/// `(chi_pos, chi_neg, chi_total)` in ppm, restricted to `mask` — matching the
86/// [`chi_sep_ilsqr`](super::chi_sep_ilsqr)/[`chi_sep_medi`](super::chi_sep_medi)
87/// convention: `chi_pos` ≥ 0 (paramagnetic), `chi_neg` ≤ 0 (diamagnetic, signed),
88/// and `chi_total = chi_pos + chi_neg`.
89pub fn wavesep(
90    chi_total: &[f64],
91    r2prime: &[f64],
92    mask: &[u8],
93    grid: &Grid,
94    params: &WaveSepParams,
95    mut progress: impl FnMut(usize, usize),
96) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
97    let (nx, ny, nz) = grid.dims;
98    let n = nx * ny * nz;
99    assert_eq!(chi_total.len(), n, "chi_total length must match grid");
100    assert_eq!(r2prime.len(), n, "r2prime length must match grid");
101    assert_eq!(mask.len(), n, "mask length must match grid");
102
103    // --- padding so each axis is a multiple of 2^L (periodic wavelet round-trip) ---
104    let dec_len = 2 * params.wavelet_order;
105    let (pdims, level) = pad_spec((nx, ny, nz), dec_len);
106    let (pnx, pny, pnz) = pdims;
107    let pn = pnx * pny * pnz;
108
109    // Masked, padded inputs (zeros appended at the high end of each axis).
110    let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
111    let mask_p = pad3d(&maskf, (nx, ny, nz), pdims);
112    let qsm_p = {
113        let mut q = pad3d(chi_total, (nx, ny, nz), pdims);
114        for i in 0..pn {
115            q[i] *= mask_p[i];
116        }
117        q
118    };
119    let r2p_p = {
120        let mut r = pad3d(r2prime, (nx, ny, nz), pdims);
121        for i in 0..pn {
122            r[i] *= mask_p[i];
123        }
124        r
125    };
126
127    let plan = WaveletPlan::new(params.wavelet_order, pdims, level);
128    let th = params.alpha * params.lambda;
129    let dr_ratio = params.dr_neg / params.dr_pos;
130
131    // ISTA state: xp (χ+) and xn (χ−); `prev` holds the last projected iterate
132    // so the early stop matches WaveSep's ‖x − x_old‖ / ‖x‖.
133    let mut xp = vec![0.0_f64; pn];
134    let mut xn = vec![0.0_f64; pn];
135    let mut prev = vec![0.0_f64; 2 * pn];
136
137    for it in 0..params.max_iter {
138        progress(it + 1, params.max_iter);
139        // --- gradient step on the two quadratic fidelities ---
140        // QSM fidelity: gq = χ+ + χ− − χ_total (same gradient for both components).
141        // R2' fidelity: r = χ+ − (Dr−/Dr+)·χ− − R2'/Dr+ ; grads [r, −(Dr−/Dr+)·r].
142        for i in 0..pn {
143            let gq = xp[i] + xn[i] - qsm_p[i];
144            let r = xp[i] - dr_ratio * xn[i] - r2p_p[i] / params.dr_pos;
145            xp[i] -= params.alpha * (gq + r);
146            xn[i] -= params.alpha * (gq - dr_ratio * r);
147        }
148
149        // --- wavelet-L1 proximal step (soft-threshold all coefficients) ---
150        prox_wavelet_l1(&mut xp, &plan, th);
151        prox_wavelet_l1(&mut xn, &plan, th);
152
153        // --- sign projection + mask; accumulate ‖Δx‖ and ‖x‖ for the early stop ---
154        let mut diff = 0.0_f64;
155        let mut norm = 0.0_f64;
156        for i in 0..pn {
157            let m = mask_p[i];
158            xp[i] = if xp[i] < 0.0 { 0.0 } else { xp[i] } * m;
159            xn[i] = if xn[i] > 0.0 { 0.0 } else { xn[i] } * m;
160            let dp = xp[i] - prev[i];
161            let dn = xn[i] - prev[pn + i];
162            diff += dp * dp + dn * dn;
163            norm += xp[i] * xp[i] + xn[i] * xn[i];
164            prev[i] = xp[i];
165            prev[pn + i] = xn[i];
166        }
167        if norm > 0.0 && (diff.sqrt() / norm.sqrt()) < params.tol {
168            break;
169        }
170    }
171
172    // Crop back and emit χ+ (≥ 0), χ− (≤ 0, signed) and χ_total = χ+ + χ−.
173    let xp_c = unpad3d(&xp, pdims, (nx, ny, nz));
174    let xn_c = unpad3d(&xn, pdims, (nx, ny, nz));
175    let mut chi_pos = vec![0.0_f64; n];
176    let mut chi_neg = vec![0.0_f64; n];
177    let mut chi_out = vec![0.0_f64; n];
178    for i in 0..n {
179        if mask[i] != 0 {
180            chi_pos[i] = xp_c[i];
181            chi_neg[i] = xn_c[i];
182            chi_out[i] = xp_c[i] + xn_c[i];
183        }
184    }
185    (chi_pos, chi_neg, chi_out)
186}
187
188/// Wavelet-L1 proximal operator for an orthonormal transform: soft-threshold all
189/// coefficients (approximation + details) by `th`, in place.
190fn prox_wavelet_l1(x: &mut [f64], plan: &WaveletPlan, th: f64) {
191    let mut coef = plan.forward(x);
192    for c in coef.iter_mut() {
193        *c = soft_threshold(*c, th);
194    }
195    let rec = plan.inverse(&coef);
196    x.copy_from_slice(&rec);
197}
198
199#[inline]
200fn soft_threshold(z: f64, th: f64) -> f64 {
201    if z > th {
202        z - th
203    } else if z < -th {
204        z + th
205    } else {
206        0.0
207    }
208}
209
210/// Padded dimensions and decomposition level, matching WaveSep's `pad_spec`:
211/// grow each axis to a common multiple `P` (starting at 16) until every padded
212/// dim is divisible by `2^L`, where `L = dwt_max_level(min_padded, dec_len)`.
213fn pad_spec(dims: (usize, usize, usize), dec_len: usize) -> ((usize, usize, usize), usize) {
214    let ds = [dims.0, dims.1, dims.2];
215    let mut p = 16usize;
216    loop {
217        let padded = [
218            ds[0].div_ceil(p) * p,
219            ds[1].div_ceil(p) * p,
220            ds[2].div_ceil(p) * p,
221        ];
222        let min_padded = *padded.iter().min().unwrap();
223        let level = dwt_max_level(min_padded, dec_len);
224        let m = 1usize << level;
225        if padded.iter().all(|&d| d % m == 0) {
226            return ((padded[0], padded[1], padded[2]), level);
227        }
228        p *= 2;
229    }
230}
231
232/// Zero-pad a column-major 3D array from `from` to `to` (extra voxels appended at
233/// the high end of each axis), matching `numpy.pad(a, [(0, p-d), ...])`.
234fn pad3d(a: &[f64], from: (usize, usize, usize), to: (usize, usize, usize)) -> Vec<f64> {
235    let (fx, fy, fz) = from;
236    let (tx, ty, tz) = to;
237    let mut out = vec![0.0_f64; tx * ty * tz];
238    for k in 0..fz {
239        for j in 0..fy {
240            let src = (k * fy + j) * fx;
241            let dst = (k * ty + j) * tx;
242            out[dst..dst + fx].copy_from_slice(&a[src..src + fx]);
243        }
244    }
245    out
246}
247
248/// Crop a column-major 3D array from `from` back to `to` (inverse of [`pad3d`]).
249fn unpad3d(a: &[f64], from: (usize, usize, usize), to: (usize, usize, usize)) -> Vec<f64> {
250    let (fx, fy, _fz) = from;
251    let (tx, ty, tz) = to;
252    let mut out = vec![0.0_f64; tx * ty * tz];
253    for k in 0..tz {
254        for j in 0..ty {
255            let src = (k * fy + j) * fx;
256            let dst = (k * ty + j) * tx;
257            out[dst..dst + tx].copy_from_slice(&a[src..src + tx]);
258        }
259    }
260    out
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
268        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
269    }
270
271    #[test]
272    fn pad_spec_divisible_and_covers() {
273        for dims in [(64, 64, 40), (100, 100, 60), (48, 56, 72)] {
274            let (pd, level) = pad_spec(dims, 8);
275            let m = 1usize << level;
276            assert!(pd.0 % m == 0 && pd.1 % m == 0 && pd.2 % m == 0, "divisible {dims:?}");
277            assert!(pd.0 >= dims.0 && pd.1 >= dims.1 && pd.2 >= dims.2, "covers {dims:?}");
278        }
279    }
280
281    #[test]
282    fn pad_unpad_roundtrip() {
283        let from = (5, 6, 7);
284        let to = (8, 8, 8);
285        let n = 5 * 6 * 7;
286        let a: Vec<f64> = (0..n).map(|i| i as f64).collect();
287        let p = pad3d(&a, from, to);
288        let back = unpad3d(&p, to, from);
289        assert_eq!(a, back);
290    }
291
292    /// On a phantom obeying the WaveSep model exactly (single kernel Dr, no
293    /// regularisation needed), the solver should recover the sources well.
294    #[test]
295    fn recovers_model_sources() {
296        let (nx, ny, nz) = (16, 16, 16);
297        let g = grid(nx, ny, nz);
298        let n = nx * ny * nz;
299        let dr = 137.0;
300
301        // Smooth, sign-correct source fields inside a central mask.
302        let mut chi_pos = vec![0.0_f64; n];
303        let mut chi_neg = vec![0.0_f64; n]; // ≤ 0
304        let mut mask = vec![0u8; n];
305        for k in 0..nz {
306            for j in 0..ny {
307                for i in 0..nx {
308                    let idx = i + j * nx + k * nx * ny;
309                    let inside = (3..13).contains(&i) && (3..13).contains(&j) && (3..13).contains(&k);
310                    if inside {
311                        mask[idx] = 1;
312                        let fx = i as f64 / nx as f64;
313                        let fy = j as f64 / ny as f64;
314                        chi_pos[idx] = 0.10 * (1.0 + (fx * 6.0).sin()).max(0.0);
315                        chi_neg[idx] = -0.08 * (1.0 + (fy * 5.0).cos()).max(0.0);
316                    }
317                }
318            }
319        }
320        // Forward model: χ_total = χ+ + χ−, R2' = Dr·(χ+ + |χ−|).
321        let chi_total: Vec<f64> = (0..n).map(|i| chi_pos[i] + chi_neg[i]).collect();
322        let r2p: Vec<f64> = (0..n).map(|i| dr * (chi_pos[i] - chi_neg[i])).collect();
323
324        let params = WaveSepParams {
325            dr_pos: dr,
326            dr_neg: dr,
327            alpha: 0.2,
328            lambda: 0.002, // light regularisation for a clean phantom
329            wavelet_order: 4,
330            max_iter: 300,
331            tol: 1e-4,
332        };
333        let (para, neg, total) = wavesep(&chi_total, &r2p, &mask, &g, &params, |_, _| {});
334
335        // Correlate recovered vs. truth inside the mask.
336        let corr = |a: &[f64], b: &[f64]| {
337            let idx: Vec<usize> = (0..n).filter(|&i| mask[i] == 1).collect();
338            let m = idx.len() as f64;
339            let ma = idx.iter().map(|&i| a[i]).sum::<f64>() / m;
340            let mb = idx.iter().map(|&i| b[i]).sum::<f64>() / m;
341            let mut sab = 0.0;
342            let mut saa = 0.0;
343            let mut sbb = 0.0;
344            for &i in &idx {
345                let da = a[i] - ma;
346                let db = b[i] - mb;
347                sab += da * db;
348                saa += da * da;
349                sbb += db * db;
350            }
351            sab / (saa.sqrt() * sbb.sqrt() + 1e-20)
352        };
353        // χ− is returned signed (≤ 0); correlate against the signed truth.
354        let cp = corr(&para, &chi_pos);
355        let cn = corr(&neg, &chi_neg);
356        assert!(cp > 0.9, "χ+ correlation too low: {cp:.3}");
357        assert!(cn > 0.9, "χ− correlation too low: {cn:.3}");
358        // χ_total ≈ χ+ + χ− and χ− ≤ 0 inside the mask.
359        for i in 0..n {
360            if mask[i] == 1 {
361                assert!(neg[i] <= 1e-9, "χ− must be ≤ 0");
362                assert!((total[i] - (para[i] + neg[i])).abs() < 1e-9, "χ_total = χ+ + χ−");
363            }
364        }
365    }
366}