Skip to main content

qsm_core/utils/
denoise.rs

1//! Marchenko–Pastur PCA (MP-PCA) denoising for multi-volume data.
2//!
3//! Denoises a stack of co-registered volumes (e.g. multi-echo magnitude) by
4//! exploiting redundancy across the volume dimension: within a small spatial
5//! patch the noise-free signal is low-rank, so PCA components whose eigenvalues
6//! fall inside the Marchenko–Pastur (random-matrix) noise bulk are discarded.
7//! Unlike Gaussian smoothing this preserves spatial edges — it removes noise
8//! along the volume dimension, not across space.
9//!
10//! This is a port of DIPY's `mppca` (`localpca.genpca`, eigenvalue path), which implements
11//! Veraart et al. (2016), "Denoising of diffusion MRI using random matrix theory", NeuroImage
12//! 142:394-406, with Manjón et al. (2013)'s overlapping-patch aggregation: every patch's estimate
13//! is given to all its voxels, weighted by `1 / (1 + n − ncomps)`, and each voxel is the weighted
14//! mean of the estimates covering it. Voxels outside the mask are 0, as in DIPY.
15//!
16//! Applying this to multi-echo magnitude before R2*/R2 fitting reduces the
17//! variance of the fitted relaxation rate without blurring structure.
18//!
19//! **Agreement with DIPY 1.12.1** (verified QSM-CI `py-originals` image, QSMxT/QSM-CI#302;
20//! patch radius 2; measured 2026-10-31, astewartau/QSM.rs#327): NRMSE 2.6e-6 % on a 64×64×32
21//! crop of a travelling head's five echoes (`prisma-bridge-run1`; DIPY changes the input by
22//! 2.4 %), and 2.6e-6 % (the fixture's float32 rounding) on the six-echo 14×14×10 phantom of
23//! `tests/mppca_dipy_parity.rs`. What is left is summation order and the eigensolver (cyclic
24//! Jacobi here, LAPACK `syevr` in SciPy). Before this, each voxel took only its own patch's
25//! estimate (a race-free shortcut), which was 0.97 % from DIPY on the full travelling head and
26//! 1.3 % on the phantom.
27//!
28//! **Which implementation.** DIPY is the reference QSM-CI runs. Veraart's own MATLAB,
29//! `MPdenoising.m` (NYU-DiffusionMRI/mppca_denoise), is not the same algorithm in its details:
30//! - it does not centre the patch by default;
31//! - its noise criterion compares two σ² estimates per rank, with a `(max(M, N) − r)/N` scaling;
32//! - it assigns the centre voxel (`'full'` sampling) or whole blocks (`'fast'`, its default for
33//!   denoising) rather than averaging overlapping patches;
34//! - its licence is non-commercial.
35//!
36//! Whether to follow it instead is an open question (astewartau/QSM.rs#327).
37
38#[cfg(feature = "parallel")]
39use rayon::prelude::*;
40
41/// Cyclic Jacobi eigen-decomposition of a symmetric `n×n` matrix `a`
42/// (row-major). Returns `(eigenvalues, eigenvectors)` with eigenvalues in
43/// ASCENDING order and eigenvectors stored column-wise (`v[row*n + col]` is
44/// component `row` of eigenvector `col`).
45pub(crate) fn jacobi_eigh(a_in: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
46    let mut a = a_in.to_vec();
47    // v = identity (eigenvectors accumulate here)
48    let mut v = vec![0.0_f64; n * n];
49    for i in 0..n {
50        v[i * n + i] = 1.0;
51    }
52    // Cyclic Jacobi sweeps. Converges quadratically; ~10 sweeps suffice. Use a
53    // RELATIVE off-diagonal threshold (an absolute one may never trip in f64 and
54    // would waste all sweeps on every voxel).
55    let diag_scale: f64 = (0..n).map(|i| a[i * n + i] * a[i * n + i]).sum::<f64>().max(1e-300);
56    for _sweep in 0..20 {
57        // Off-diagonal magnitude.
58        let mut off = 0.0;
59        for p in 0..n {
60            for q in (p + 1)..n {
61                off += a[p * n + q] * a[p * n + q];
62            }
63        }
64        if off <= 1e-24 * diag_scale {
65            break;
66        }
67        for p in 0..n {
68            for q in (p + 1)..n {
69                let apq = a[p * n + q];
70                if apq.abs() < 1e-300 {
71                    continue;
72                }
73                let app = a[p * n + p];
74                let aqq = a[q * n + q];
75                let theta = (aqq - app) / (2.0 * apq);
76                let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
77                let c = 1.0 / (t * t + 1.0).sqrt();
78                let s = t * c;
79                // Rotate rows/cols p,q of A.
80                for k in 0..n {
81                    let akp = a[k * n + p];
82                    let akq = a[k * n + q];
83                    a[k * n + p] = c * akp - s * akq;
84                    a[k * n + q] = s * akp + c * akq;
85                }
86                for k in 0..n {
87                    let apk = a[p * n + k];
88                    let aqk = a[q * n + k];
89                    a[p * n + k] = c * apk - s * aqk;
90                    a[q * n + k] = s * apk + c * aqk;
91                }
92                // Accumulate rotation into V.
93                for k in 0..n {
94                    let vkp = v[k * n + p];
95                    let vkq = v[k * n + q];
96                    v[k * n + p] = c * vkp - s * vkq;
97                    v[k * n + q] = s * vkp + c * vkq;
98                }
99            }
100        }
101    }
102    // Eigenvalues on the diagonal; sort ascending and reorder eigenvectors.
103    let mut eig: Vec<(f64, usize)> = (0..n).map(|i| (a[i * n + i], i)).collect();
104    eig.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap());
105    let mut d = vec![0.0_f64; n];
106    let mut w = vec![0.0_f64; n * n];
107    for (new_col, &(val, old_col)) in eig.iter().enumerate() {
108        d[new_col] = val;
109        for row in 0..n {
110            w[row * n + new_col] = v[row * n + old_col];
111        }
112    }
113    (d, w)
114}
115
116/// Marchenko–Pastur noise classifier (Veraart 2016; DIPY `_pca_classifier`).
117///
118/// `d` are covariance eigenvalues in ascending order; `n_samples` is the number
119/// of voxels in the patch. Returns the estimated noise variance.
120fn mp_noise_variance(d: &[f64], n_samples: usize) -> f64 {
121    // Correct for the rank lost to mean subtraction.
122    let start = if d.len() > n_samples.saturating_sub(1) {
123        d.len() - (n_samples - 1)
124    } else {
125        0
126    };
127    let l = &d[start..];
128    let m = l.len();
129    if m == 0 {
130        return 0.0;
131    }
132    let mean = |k: usize| -> f64 {
133        if k == 0 {
134            0.0
135        } else {
136            l[..k].iter().sum::<f64>() / k as f64
137        }
138    };
139    let mut var = l.iter().sum::<f64>() / m as f64;
140    let mut c = m - 1;
141    let mut r = l[c] - l[0] - 4.0 * ((c as f64 + 1.0) / n_samples as f64).sqrt() * var;
142    while r > 0.0 && c > 0 {
143        var = mean(c);
144        c -= 1;
145        r = l[c] - l[0] - 4.0 * ((c as f64 + 1.0) / n_samples as f64).sqrt() * var;
146    }
147    var
148}
149
150/// Denoise multi-volume data with MP-PCA, as DIPY's `mppca` (`genpca` with `sigma=None`,
151/// `pca_method='eig'`).
152///
153/// For every voxel at least `patch_radius` from the volume border and inside the mask, the
154/// `(2r+1)³` patch around it is centred on its per-volume mean, its covariance eigen-decomposed,
155/// the noise variance estimated from the Marchenko–Pastur bulk (DIPY's
156/// `_pca_classifier`), and the components with eigenvalue below `τ = (1 + √(n/m))² σ²` dropped.
157/// Every voxel of the patch gets that patch's estimate, weighted by `1 / (1 + n − ncomps)` with
158/// `ncomps` the dropped count (Manjón et al. 2013, eq. 3), and the output is the weighted mean of
159/// the estimates of all patches covering a voxel.
160///
161/// # Arguments
162/// * `data` - Interleaved `[voxel0_vol0, voxel0_vol1, ..., voxel1_vol0, ...]`
163///   (row-major `(n_voxels, n_vols)`).
164/// * `dims` - Volume dimensions `(nx, ny, nz)`.
165/// * `n_vols` - Number of volumes (e.g. echoes).
166/// * `patch_radius` - Half-width of the cubic patch (radius 2 → 5×5×5).
167/// * `mask` - Optional binary mask: patches are centred only on voxels inside it, and voxels
168///   outside it are 0 in the output, as in DIPY. `None` is the whole volume.
169///
170/// # Returns
171/// Denoised data in the same layout, with negative values set to 0 as DIPY's `genpca` does
172/// (`clip(min=0)`): the input is magnitude, where a negative value has no physical meaning.
173/// Voxels inside the mask that no patch covers (possible only at the volume border, next to the
174/// mask's edge) keep their input values, where DIPY's `0/0` leaves NaN.
175pub fn mppca_denoise(
176    data: &[f64],
177    dims: (usize, usize, usize),
178    n_vols: usize,
179    patch_radius: usize,
180    mask: Option<&[u8]>,
181) -> Vec<f64> {
182    let (nx, ny, nz) = dims;
183    let n_voxels = nx * ny * nz;
184    assert_eq!(data.len(), n_voxels * n_vols, "data length must be n_voxels * n_vols");
185    let n = n_vols;
186    let r = patch_radius;
187    let side = 2 * r + 1;
188    let m = side * side * side; // patch voxel count
189    let plane = nx * ny;
190    let in_mask = |v: usize| mask.is_none_or(|mk| mk[v] != 0);
191    let tau_factor = 1.0 + (n as f64 / m as f64).sqrt();
192
193    // Σ θ and Σ θ·X̂ over the patches covering each voxel (DIPY's `theta`, `thetax`).
194    let mut theta = vec![0.0_f64; n_voxels];
195    let mut thetax = vec![0.0_f64; n_voxels * n];
196
197    // The estimate of the patch centred at (cx, cy, cz), added into a slab of `theta`/`thetax`
198    // that starts at plane `z0`.
199    let add_patch = |cx: usize, cy: usize, cz: usize, z0: usize, th: &mut [f64], thx: &mut [f64]| {
200        // Gather the patch X (m×n) and its voxel indices.
201        let mut x = vec![0.0_f64; m * n];
202        let mut vox_of = vec![0usize; m];
203        let mut rr = 0usize;
204        for dz in 0..side {
205            let z = cz + dz - r;
206            for dy in 0..side {
207                let y = cy + dy - r;
208                for dx in 0..side {
209                    let xx = cx + dx - r;
210                    let vox = xx + y * nx + z * plane;
211                    x[rr * n..rr * n + n].copy_from_slice(&data[vox * n..vox * n + n]);
212                    vox_of[rr] = vox;
213                    rr += 1;
214                }
215            }
216        }
217        // Column means; centre X.
218        let mut mean = vec![0.0_f64; n];
219        for row in 0..m {
220            for t in 0..n {
221                mean[t] += x[row * n + t];
222            }
223        }
224        for t in 0..n {
225            mean[t] /= m as f64;
226        }
227        for row in 0..m {
228            for t in 0..n {
229                x[row * n + t] -= mean[t];
230            }
231        }
232        // Covariance C = XᵀX / m  (n×n).
233        let mut cov = vec![0.0_f64; n * n];
234        for row in 0..m {
235            for a in 0..n {
236                let xa = x[row * n + a];
237                if xa == 0.0 {
238                    continue;
239                }
240                for b in a..n {
241                    cov[a * n + b] += xa * x[row * n + b];
242                }
243            }
244        }
245        for a in 0..n {
246            for b in a..n {
247                let v = cov[a * n + b] / m as f64;
248                cov[a * n + b] = v;
249                cov[b * n + a] = v;
250            }
251        }
252        let (d, w) = jacobi_eigh(&cov, n);
253        let var = mp_noise_variance(&d, m);
254        let tau = tau_factor * tau_factor * var;
255        // Dropped (noise) components: eigenvalue below tau.
256        let kept: Vec<usize> = (0..n).filter(|&k| d[k] >= tau).collect();
257        let ncomps = n - kept.len();
258        let this_theta = 1.0 / (1.0 + n as f64 - ncomps as f64);
259        // X̂ = X W Wᵀ + M over the kept components, for every row of the patch.
260        let mut est = vec![0.0_f64; n];
261        for row in 0..m {
262            est.copy_from_slice(&mean);
263            let xr = &x[row * n..row * n + n];
264            for &k in &kept {
265                let mut proj = 0.0;
266                for a in 0..n {
267                    proj += xr[a] * w[a * n + k];
268                }
269                for t in 0..n {
270                    est[t] += proj * w[t * n + k];
271                }
272            }
273            let local = vox_of[row] - z0 * plane;
274            th[local] += this_theta;
275            for t in 0..n {
276                thx[local * n + t] += est[t] * this_theta;
277            }
278        }
279    };
280
281    if nx >= side && ny >= side && nz >= side {
282        // Patches centred on planes cz ≡ g (mod 2r+1) cover disjoint slabs of 2r+1 planes, so
283        // each group's planes run in parallel; within a plane, centres go y then x, as DIPY's loop.
284        for g in 0..side {
285            let z_first = g; // slab of the centre plane cz = r + g starts at plane g
286            if r + g >= nz - r {
287                continue;
288            }
289            let slab = side * plane;
290            let th_slabs = theta[z_first * plane..].chunks_exact_mut(slab);
291            let thx_slabs = thetax[z_first * plane * n..].chunks_exact_mut(slab * n);
292            let work = |(t, (th, thx)): (usize, (&mut [f64], &mut [f64]))| {
293                let z0 = z_first + t * side;
294                let cz = z0 + r;
295                if cz >= nz - r {
296                    return;
297                }
298                for cy in r..ny - r {
299                    for cx in r..nx - r {
300                        if in_mask(cx + cy * nx + cz * plane) {
301                            add_patch(cx, cy, cz, z0, th, thx);
302                        }
303                    }
304                }
305            };
306            #[cfg(feature = "parallel")]
307            {
308                let slabs: Vec<_> = th_slabs.zip(thx_slabs).enumerate().collect();
309                slabs.into_par_iter().for_each(work);
310            }
311            #[cfg(not(feature = "parallel"))]
312            {
313                th_slabs.zip(thx_slabs).enumerate().for_each(work);
314            }
315        }
316    }
317
318    let mut out = vec![0.0_f64; n_voxels * n];
319    for v in 0..n_voxels {
320        if !in_mask(v) {
321            continue; // 0, as DIPY's `denoised_arr[mask == 0] = 0`
322        }
323        if theta[v] == 0.0 {
324            out[v * n..v * n + n].copy_from_slice(&data[v * n..v * n + n]);
325            continue;
326        }
327        for t in 0..n {
328            // Non-negativity (magnitude data), as DIPY's `clip(min=0)`.
329            out[v * n + t] = (thetax[v * n + t] / theta[v]).max(0.0);
330        }
331    }
332    out
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_jacobi_diagonal() {
341        // Diagonal matrix → eigenvalues are the diagonal, ascending.
342        let a = vec![3.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 2.0];
343        let (d, _) = jacobi_eigh(&a, 3);
344        assert!((d[0] - 1.0).abs() < 1e-10);
345        assert!((d[1] - 2.0).abs() < 1e-10);
346        assert!((d[2] - 3.0).abs() < 1e-10);
347    }
348
349    #[test]
350    fn test_jacobi_known_symmetric() {
351        // [[2,1],[1,2]] → eigenvalues 1 and 3.
352        let a = vec![2.0, 1.0, 1.0, 2.0];
353        let (d, w) = jacobi_eigh(&a, 2);
354        assert!((d[0] - 1.0).abs() < 1e-9);
355        assert!((d[1] - 3.0).abs() < 1e-9);
356        // Reconstruct A = W diag(d) Wᵀ.
357        let mut recon = [0.0_f64; 4];
358        for i in 0..2 {
359            for j in 0..2 {
360                for k in 0..2 {
361                    recon[i * 2 + j] += w[i * 2 + k] * d[k] * w[j * 2 + k];
362                }
363            }
364        }
365        for (r, a) in recon.iter().zip(a.iter()) {
366            assert!((r - a).abs() < 1e-9);
367        }
368    }
369
370    /// Pure zero-mean noise denoises to values around 0; none of them may be negative.
371    #[test]
372    fn test_mppca_output_is_never_negative() {
373        let (nx, ny, nz, n) = (9usize, 9usize, 9usize, 6usize);
374        let mut seed = 7u64;
375        let data: Vec<f64> = (0..nx * ny * nz * n)
376            .map(|_| {
377                seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
378                (seed >> 33) as f64 / (1u64 << 31) as f64 - 0.5
379            })
380            .collect();
381        let den = mppca_denoise(&data, (nx, ny, nz), n, 2, None);
382        let r = 2usize;
383        let mut zeros = 0;
384        for z in r..nz - r {
385            for y in r..ny - r {
386                for x in r..nx - r {
387                    let v = x + y * nx + z * nx * ny;
388                    for t in 0..n {
389                        assert!(den[v * n + t] >= 0.0, "negative denoised magnitude");
390                        zeros += (den[v * n + t] == 0.0) as usize;
391                    }
392                }
393            }
394        }
395        assert!(zeros > 0, "zero-mean noise should have been clamped somewhere");
396    }
397
398    #[test]
399    fn test_mppca_reduces_noise_preserves_signal() {
400        // Build a low-rank signal volume (2 spatial regions, smooth decay) plus
401        // noise, and check MP-PCA reduces the error to ground truth.
402        let (nx, ny, nz, n) = (12usize, 12usize, 12usize, 16usize);
403        let nvox = nx * ny * nz;
404        let mut clean = vec![0.0_f64; nvox * n];
405        for z in 0..nz {
406            for y in 0..ny {
407                for x in 0..nx {
408                    let v = x + y * nx + z * nx * ny;
409                    // Two regions with different decay rates.
410                    let r2 = if x < nx / 2 { 20.0 } else { 40.0 };
411                    let s0 = 100.0;
412                    for t in 0..n {
413                        let te = (t + 1) as f64 * 0.005;
414                        clean[v * n + t] = s0 * (-r2 * te).exp();
415                    }
416                }
417            }
418        }
419        // Deterministic pseudo-noise.
420        let mut noisy = clean.clone();
421        let mut seed = 12345u64;
422        let mut rand = || {
423            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
424            // Zero-mean noise in [-1, 1): average two uniforms for a rough Gaussian.
425            let u = |s: u64| (s >> 33) as f64 / (1u64 << 31) as f64; // [0,1)
426            let s2 = seed.wrapping_mul(2862933555777941757).wrapping_add(3037000493);
427            (u(seed) - 0.5) + (u(s2) - 0.5) // mean 0, in (-1,1)
428        };
429        let sigma = 3.0;
430        for v in noisy.iter_mut() {
431            *v += sigma * rand();
432        }
433        let den = mppca_denoise(&noisy, (nx, ny, nz), n, 2, None);
434        // Measure error only over the interior (voxels that are actually denoised;
435        // border voxels within the patch radius are copied through unchanged).
436        let r = 2usize;
437        let interior_err = |a: &[f64]| -> f64 {
438            let mut s = 0.0;
439            for z in r..nz - r {
440                for y in r..ny - r {
441                    for x in r..nx - r {
442                        let v = x + y * nx + z * nx * ny;
443                        for t in 0..n {
444                            let d = a[v * n + t] - clean[v * n + t];
445                            s += d * d;
446                        }
447                    }
448                }
449            }
450            s.sqrt()
451        };
452        let e_noisy = interior_err(&noisy);
453        let e_den = interior_err(&den);
454        assert!(
455            e_den < 0.4 * e_noisy,
456            "MP-PCA should cut interior error substantially: noisy {:.1} -> denoised {:.1}",
457            e_noisy,
458            e_den
459        );
460    }
461}