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, ¶ms, |_, _| {}).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(¶, &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, ¶ms, |_, _| {}).unwrap_err();
421 assert!(err.0.contains("WaveSep wavelet_order = 5"), "{err}");
422 }
423}