Note
Go to the end to download the full example code.
Denoising a DWI with MP-PCA#
Thermal noise lowers the precision of everything fitted from a diffusion image, most of all at high b-values where the signal is low. MP-PCA (Veraart et al. 2016) removes much of it: in a small patch around each voxel, the signals of all volumes form a matrix whose principal components split into a few that carry the signal and many that carry only noise. The eigenvalues of pure noise follow the Marchenko-Pastur distribution, which tells where the split is and gives the noise level sigma as a by-product.
This example builds a noisy DWI image from known tensors, denoises it with Larmor, and checks the estimated sigma and the error before and after.
import numpy as np
from larmor.dwi import denoise_mppca
from larmor.types import Volume
A synthetic acquisition#
Two b0 images and 60 directions at b = 1000 s/mm^2, on a 12x12x12 grid. Each voxel has a prolate tensor (eigenvalues 1.7e-3 and 0.3e-3 mm^2/s) whose direction turns slowly along x, so that neighbouring voxels have similar signals, as in tissue.
rng = np.random.default_rng(0)
directions = rng.normal(size=(60, 3))
directions /= np.linalg.norm(directions, axis=1, keepdims=True)
bvals = np.concatenate([[0.0, 0.0], np.full(60, 1000.0)])
bvecs = np.vstack([[[0.0, 0.0, 0.0]] * 2, directions])
shape = (12, 12, 12)
angle = np.linspace(0, np.pi / 2, shape[0])[:, None, None] * np.ones(shape)
fibre = np.stack([np.cos(angle), np.sin(angle), np.zeros(shape)], axis=-1)
along = (bvecs @ fibre[..., :, None])[..., 0] ** 2 # cos^2 to the fibre, per volume
diffusivity = 0.3e-3 + (1.7e-3 - 0.3e-3) * along
s0 = 1000.0
truth = s0 * np.exp(-bvals * diffusivity)
Gaussian noise of sigma = 20, a signal-to-noise ratio of 50 in the b0 images.
sigma = 20.0
noisy = truth + rng.normal(scale=sigma, size=truth.shape)
volume = Volume(noisy.astype(np.float32), np.eye(4))
Denoising#
The patch defaults to the smallest odd cube with at least as many voxels as
volumes: 5x5x5 for these 62 volumes. Real data comes from a file,
load_volume("dwi.nii.gz"), and a brain mask limits the work to the brain.
result = denoise_mppca(volume)
print(f"estimated sigma: {np.median(result.noise):.2f} (true {sigma})")
print(f"signal components per patch: {np.median(result.num_signal):.0f} of 62")
error_before = np.sqrt(np.mean((noisy - truth) ** 2))
error_after = np.sqrt(np.mean((result.volume.data - truth) ** 2))
print(f"RMS error: {error_before:.2f} before, {error_after:.2f} after")
assert abs(np.median(result.noise) - sigma) < 0.05 * sigma
assert error_after < error_before / 2
estimated sigma: 19.78 (true 20.0)
signal components per patch: 3 of 62
RMS error: 20.00 before, 5.56 after