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}