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 faithful port of the algorithm in DIPY's `localpca`/`mppca`
11//! (eigenvalue path), which implements Veraart et al. (2016), "Denoising of
12//! diffusion MRI using random matrix theory", NeuroImage 142:394-406, with the
13//! Manjón (2013) overlapping-patch aggregation replaced by a race-free
14//! centre-voxel assignment (each output voxel is denoised by its own patch).
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#[cfg(feature = "parallel")]
20use rayon::prelude::*;
21
22/// Cyclic Jacobi eigen-decomposition of a symmetric `n×n` matrix `a`
23/// (row-major). Returns `(eigenvalues, eigenvectors)` with eigenvalues in
24/// ASCENDING order and eigenvectors stored column-wise (`v[row*n + col]` is
25/// component `row` of eigenvector `col`).
26fn jacobi_eigh(a_in: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
27    let mut a = a_in.to_vec();
28    // v = identity (eigenvectors accumulate here)
29    let mut v = vec![0.0_f64; n * n];
30    for i in 0..n {
31        v[i * n + i] = 1.0;
32    }
33    // Cyclic Jacobi sweeps. Converges quadratically; ~10 sweeps suffice. Use a
34    // RELATIVE off-diagonal threshold (an absolute one may never trip in f64 and
35    // would waste all sweeps on every voxel).
36    let diag_scale: f64 = (0..n).map(|i| a[i * n + i] * a[i * n + i]).sum::<f64>().max(1e-300);
37    for _sweep in 0..20 {
38        // Off-diagonal magnitude.
39        let mut off = 0.0;
40        for p in 0..n {
41            for q in (p + 1)..n {
42                off += a[p * n + q] * a[p * n + q];
43            }
44        }
45        if off <= 1e-24 * diag_scale {
46            break;
47        }
48        for p in 0..n {
49            for q in (p + 1)..n {
50                let apq = a[p * n + q];
51                if apq.abs() < 1e-300 {
52                    continue;
53                }
54                let app = a[p * n + p];
55                let aqq = a[q * n + q];
56                let theta = (aqq - app) / (2.0 * apq);
57                let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
58                let c = 1.0 / (t * t + 1.0).sqrt();
59                let s = t * c;
60                // Rotate rows/cols p,q of A.
61                for k in 0..n {
62                    let akp = a[k * n + p];
63                    let akq = a[k * n + q];
64                    a[k * n + p] = c * akp - s * akq;
65                    a[k * n + q] = s * akp + c * akq;
66                }
67                for k in 0..n {
68                    let apk = a[p * n + k];
69                    let aqk = a[q * n + k];
70                    a[p * n + k] = c * apk - s * aqk;
71                    a[q * n + k] = s * apk + c * aqk;
72                }
73                // Accumulate rotation into V.
74                for k in 0..n {
75                    let vkp = v[k * n + p];
76                    let vkq = v[k * n + q];
77                    v[k * n + p] = c * vkp - s * vkq;
78                    v[k * n + q] = s * vkp + c * vkq;
79                }
80            }
81        }
82    }
83    // Eigenvalues on the diagonal; sort ascending and reorder eigenvectors.
84    let mut eig: Vec<(f64, usize)> = (0..n).map(|i| (a[i * n + i], i)).collect();
85    eig.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap());
86    let mut d = vec![0.0_f64; n];
87    let mut w = vec![0.0_f64; n * n];
88    for (new_col, &(val, old_col)) in eig.iter().enumerate() {
89        d[new_col] = val;
90        for row in 0..n {
91            w[row * n + new_col] = v[row * n + old_col];
92        }
93    }
94    (d, w)
95}
96
97/// Marchenko–Pastur noise classifier (Veraart 2016; DIPY `_pca_classifier`).
98///
99/// `d` are covariance eigenvalues in ascending order; `n_samples` is the number
100/// of voxels in the patch. Returns the estimated noise variance.
101fn mp_noise_variance(d: &[f64], n_samples: usize) -> f64 {
102    // Correct for the rank lost to mean subtraction.
103    let start = if d.len() > n_samples.saturating_sub(1) {
104        d.len() - (n_samples - 1)
105    } else {
106        0
107    };
108    let l = &d[start..];
109    let m = l.len();
110    if m == 0 {
111        return 0.0;
112    }
113    let mean = |k: usize| -> f64 {
114        if k == 0 {
115            0.0
116        } else {
117            l[..k].iter().sum::<f64>() / k as f64
118        }
119    };
120    let mut var = l.iter().sum::<f64>() / m as f64;
121    let mut c = m - 1;
122    let mut r = l[c] - l[0] - 4.0 * ((c as f64 + 1.0) / n_samples as f64).sqrt() * var;
123    while r > 0.0 && c > 0 {
124        var = mean(c);
125        c -= 1;
126        r = l[c] - l[0] - 4.0 * ((c as f64 + 1.0) / n_samples as f64).sqrt() * var;
127    }
128    var
129}
130
131/// Denoise multi-volume data with MP-PCA.
132///
133/// # Arguments
134/// * `data` - Interleaved `[voxel0_vol0, voxel0_vol1, ..., voxel1_vol0, ...]`
135///   (row-major `(n_voxels, n_vols)`), the same layout as [`r2star_arlo`].
136/// * `dims` - Volume dimensions `(nx, ny, nz)`.
137/// * `n_vols` - Number of volumes (e.g. echoes).
138/// * `patch_radius` - Half-width of the cubic patch (radius 2 → 5×5×5).
139/// * `mask` - Optional binary mask; masked-out voxels are copied unchanged.
140///
141/// # Returns
142/// Denoised data in the same layout. Voxels within `patch_radius` of the volume
143/// border, or outside the mask, are returned unchanged.
144pub fn mppca_denoise(
145    data: &[f64],
146    dims: (usize, usize, usize),
147    n_vols: usize,
148    patch_radius: usize,
149    mask: Option<&[u8]>,
150) -> Vec<f64> {
151    let (nx, ny, nz) = dims;
152    let n_voxels = nx * ny * nz;
153    assert_eq!(data.len(), n_voxels * n_vols, "data length must be n_voxels * n_vols");
154    let n = n_vols;
155    let r = patch_radius;
156    let mut out = data.to_vec();
157
158    // Denoise each voxel from its own patch (centre assignment → race-free).
159    let denoise_center = |cx: usize, cy: usize, cz: usize, slot: &mut [f64]| {
160        let side = 2 * r + 1;
161        let m = side * side * side; // patch voxel count
162        // Gather patch X (m×n), tracking the centre row.
163        let mut x = vec![0.0_f64; m * n];
164        let mut center_row = 0usize;
165        let mut rr = 0usize;
166        for dz in 0..side {
167            let z = cz + dz - r;
168            for dy in 0..side {
169                let y = cy + dy - r;
170                for dx in 0..side {
171                    let xx = cx + dx - r;
172                    if dx == r && dy == r && dz == r {
173                        center_row = rr;
174                    }
175                    let vox = xx + y * nx + z * nx * ny;
176                    x[rr * n..rr * n + n]
177                        .copy_from_slice(&data[vox * n..vox * n + n]);
178                    rr += 1;
179                }
180            }
181        }
182        // Column means; centre X.
183        let mut mean = vec![0.0_f64; n];
184        for row in 0..m {
185            for t in 0..n {
186                mean[t] += x[row * n + t];
187            }
188        }
189        for t in 0..n {
190            mean[t] /= m as f64;
191        }
192        for row in 0..m {
193            for t in 0..n {
194                x[row * n + t] -= mean[t];
195            }
196        }
197        // Covariance C = XᵀX / m  (n×n).
198        let mut cov = vec![0.0_f64; n * n];
199        for row in 0..m {
200            for a in 0..n {
201                let xa = x[row * n + a];
202                if xa == 0.0 {
203                    continue;
204                }
205                for b in a..n {
206                    cov[a * n + b] += xa * x[row * n + b];
207                }
208            }
209        }
210        for a in 0..n {
211            for b in a..n {
212                let v = cov[a * n + b] / m as f64;
213                cov[a * n + b] = v;
214                cov[b * n + a] = v;
215            }
216        }
217        let (d, w) = jacobi_eigh(&cov, n);
218        let var = mp_noise_variance(&d, m);
219        let tau_factor = 1.0 + (n as f64 / m as f64).sqrt();
220        let tau = tau_factor * tau_factor * var;
221        // Keep components with eigenvalue >= tau; ncomps (noise) = count below.
222        // Reconstruct the centre row: mean + Σ_{kept k} (xc·w_k) w_k.
223        let xc = &x[center_row * n..center_row * n + n];
224        for t in 0..n {
225            slot[t] = mean[t];
226        }
227        for k in 0..n {
228            if d[k] < tau {
229                continue; // noise component
230            }
231            let mut proj = 0.0;
232            for a in 0..n {
233                proj += xc[a] * w[a * n + k];
234            }
235            for t in 0..n {
236                slot[t] += proj * w[t * n + k];
237            }
238        }
239        // Non-negativity (magnitude data).
240        for t in 0..n {
241            if slot[t] < 0.0 {
242                slot[t] = 0.0;
243            }
244        }
245    };
246
247    // Parallel over voxels; each writes only its own n-vector slot.
248    let process = |c: usize, slot: &mut [f64]| {
249        let cx = c % nx;
250        let cy = (c / nx) % ny;
251        let cz = c / (nx * ny);
252        if cx < r || cx >= nx - r || cy < r || cy >= ny - r || cz < r || cz >= nz - r {
253            return; // border: keep original
254        }
255        if let Some(mk) = mask {
256            if mk[c] == 0 {
257                return;
258            }
259        }
260        denoise_center(cx, cy, cz, slot);
261    };
262
263    #[cfg(feature = "parallel")]
264    {
265        out.par_chunks_mut(n)
266            .enumerate()
267            .for_each(|(c, slot)| process(c, slot));
268    }
269    #[cfg(not(feature = "parallel"))]
270    {
271        for (c, slot) in out.chunks_mut(n).enumerate() {
272            process(c, slot);
273        }
274    }
275
276    out
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_jacobi_diagonal() {
285        // Diagonal matrix → eigenvalues are the diagonal, ascending.
286        let a = vec![3.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 2.0];
287        let (d, _) = jacobi_eigh(&a, 3);
288        assert!((d[0] - 1.0).abs() < 1e-10);
289        assert!((d[1] - 2.0).abs() < 1e-10);
290        assert!((d[2] - 3.0).abs() < 1e-10);
291    }
292
293    #[test]
294    fn test_jacobi_known_symmetric() {
295        // [[2,1],[1,2]] → eigenvalues 1 and 3.
296        let a = vec![2.0, 1.0, 1.0, 2.0];
297        let (d, w) = jacobi_eigh(&a, 2);
298        assert!((d[0] - 1.0).abs() < 1e-9);
299        assert!((d[1] - 3.0).abs() < 1e-9);
300        // Reconstruct A = W diag(d) Wᵀ.
301        let mut recon = [0.0_f64; 4];
302        for i in 0..2 {
303            for j in 0..2 {
304                for k in 0..2 {
305                    recon[i * 2 + j] += w[i * 2 + k] * d[k] * w[j * 2 + k];
306                }
307            }
308        }
309        for (r, a) in recon.iter().zip(a.iter()) {
310            assert!((r - a).abs() < 1e-9);
311        }
312    }
313
314    #[test]
315    fn test_mppca_reduces_noise_preserves_signal() {
316        // Build a low-rank signal volume (2 spatial regions, smooth decay) plus
317        // noise, and check MP-PCA reduces the error to ground truth.
318        let (nx, ny, nz, n) = (12usize, 12usize, 12usize, 16usize);
319        let nvox = nx * ny * nz;
320        let mut clean = vec![0.0_f64; nvox * n];
321        for z in 0..nz {
322            for y in 0..ny {
323                for x in 0..nx {
324                    let v = x + y * nx + z * nx * ny;
325                    // Two regions with different decay rates.
326                    let r2 = if x < nx / 2 { 20.0 } else { 40.0 };
327                    let s0 = 100.0;
328                    for t in 0..n {
329                        let te = (t + 1) as f64 * 0.005;
330                        clean[v * n + t] = s0 * (-r2 * te).exp();
331                    }
332                }
333            }
334        }
335        // Deterministic pseudo-noise.
336        let mut noisy = clean.clone();
337        let mut seed = 12345u64;
338        let mut rand = || {
339            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
340            // Zero-mean noise in [-1, 1): average two uniforms for a rough Gaussian.
341            let u = |s: u64| (s >> 33) as f64 / (1u64 << 31) as f64; // [0,1)
342            let s2 = seed.wrapping_mul(2862933555777941757).wrapping_add(3037000493);
343            (u(seed) - 0.5) + (u(s2) - 0.5) // mean 0, in (-1,1)
344        };
345        let sigma = 3.0;
346        for v in noisy.iter_mut() {
347            *v += sigma * rand();
348        }
349        let den = mppca_denoise(&noisy, (nx, ny, nz), n, 2, None);
350        // Measure error only over the interior (voxels that are actually denoised;
351        // border voxels within the patch radius are copied through unchanged).
352        let r = 2usize;
353        let interior_err = |a: &[f64]| -> f64 {
354            let mut s = 0.0;
355            for z in r..nz - r {
356                for y in r..ny - r {
357                    for x in r..nx - r {
358                        let v = x + y * nx + z * nx * ny;
359                        for t in 0..n {
360                            let d = a[v * n + t] - clean[v * n + t];
361                            s += d * d;
362                        }
363                    }
364                }
365            }
366            s.sqrt()
367        };
368        let e_noisy = interior_err(&noisy);
369        let e_den = interior_err(&den);
370        assert!(
371            e_den < 0.4 * e_noisy,
372            "MP-PCA should cut interior error substantially: noisy {:.1} -> denoised {:.1}",
373            e_noisy,
374            e_den
375        );
376    }
377}