Volumetric Wavelet Processing¶
Extend multiscale image processing to a three-dimensional volume. Examine slices and isosurfaces, then study how coefficient thresholding changes both the interior signal and the geometry of its level sets.
Run this tour¶
Run the cells in order with a Python 3 kernel. The first cell locates the companion data and toolbox and installs missing dependencies when needed. All worked examples include their implementation directly in this notebook. Random seeds make comparisons reproducible; you can change them to explore other samples.
# Locate the companion toolbox locally, or fetch it for a standalone/Colab copy.
from pathlib import Path
import importlib.util
import os
import subprocess
import sys
working = Path.cwd()
candidates = [working, working / "python", working.parent / "python"]
python_dir = next((p for p in candidates if (p / "nt_toolbox").is_dir()), None)
if python_dir is None:
checkout = working / "numerical-tours-support"
if not checkout.exists():
subprocess.run(
[
"git",
"clone",
"--depth",
"1",
"--branch",
"master",
"https://github.com/gpeyre/numerical-tours.git",
str(checkout),
],
check=True,
)
python_dir = checkout / "python"
os.chdir(python_dir)
if str(python_dir) not in sys.path:
sys.path.insert(0, str(python_dir))
requirements = python_dir / "requirements.txt"
if any(
importlib.util.find_spec(name) is None
for name in [
"numpy",
"scipy",
"matplotlib",
"skimage",
"sklearn",
"pywt",
"ipywidgets",
"cvxpy",
"skfmm",
"autograd",
"progressbar",
"celer",
]
):
subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", str(requirements)], check=True
)
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(0)
plt.rcParams.update(
{
"figure.figsize": (8, 4),
"figure.dpi": 100,
"axes.spines.top": False,
"axes.spines.right": False,
"font.size": 11,
"image.cmap": "gray",
}
)
%matplotlib inline
$\newcommand{\dotp}[2]{\langle #1, #2 \rangle}$ $\newcommand{\enscond}[2]{\lbrace #1, #2 \rbrace}$ $\newcommand{\pd}[2]{ \frac{ \partial #1}{\partial #2} }$ $\newcommand{\umin}[1]{\underset{#1}{\min}\;}$ $\newcommand{\umax}[1]{\underset{#1}{\max}\;}$ $\newcommand{\uargmin}[1]{\underset{#1}{argmin}\;}$ $\newcommand{\norm}[1]{\|#1\|}$ $\newcommand{\abs}[1]{\left|#1\right|}$ $\newcommand{\choice}[1]{ \left\{ \begin{array}{l} #1 \end{array} \right. }$ $\newcommand{\pa}[1]{\left(#1\right)}$ $\newcommand{\diag}[1]{{diag}\left( #1 \right)}$ $\newcommand{\qandq}{\quad\text{and}\quad}$ $\newcommand{\qwhereq}{\quad\text{where}\quad}$ $\newcommand{\qifq}{ \quad \text{if} \quad }$ $\newcommand{\qarrq}{ \quad \Longrightarrow \quad }$ $\newcommand{\ZZ}{\mathbb{Z}}$ $\newcommand{\CC}{\mathbb{C}}$ $\newcommand{\RR}{\mathbb{R}}$ $\newcommand{\EE}{\mathbb{E}}$ $\newcommand{\Zz}{\mathcal{Z}}$ $\newcommand{\Ww}{\mathcal{W}}$ $\newcommand{\Vv}{\mathcal{V}}$ $\newcommand{\Nn}{\mathcal{N}}$ $\newcommand{\NN}{\mathcal{N}}$ $\newcommand{\Hh}{\mathcal{H}}$ $\newcommand{\Bb}{\mathcal{B}}$ $\newcommand{\Ee}{\mathcal{E}}$ $\newcommand{\Cc}{\mathcal{C}}$ $\newcommand{\Gg}{\mathcal{G}}$ $\newcommand{\Ss}{\mathcal{S}}$ $\newcommand{\Pp}{\mathcal{P}}$ $\newcommand{\Ff}{\mathcal{F}}$ $\newcommand{\Xx}{\mathcal{X}}$ $\newcommand{\Mm}{\mathcal{M}}$ $\newcommand{\Ii}{\mathcal{I}}$ $\newcommand{\Dd}{\mathcal{D}}$ $\newcommand{\Ll}{\mathcal{L}}$ $\newcommand{\Tt}{\mathcal{T}}$ $\newcommand{\si}{\sigma}$ $\newcommand{\al}{\alpha}$ $\newcommand{\la}{\lambda}$ $\newcommand{\ga}{\gamma}$ $\newcommand{\Ga}{\Gamma}$ $\newcommand{\La}{\Lambda}$ $\newcommand{\Si}{\Sigma}$ $\newcommand{\be}{\beta}$ $\newcommand{\de}{\delta}$ $\newcommand{\De}{\Delta}$ $\newcommand{\phi}{\varphi}$ $\newcommand{\th}{\theta}$ $\newcommand{\om}{\omega}$ $\newcommand{\Om}{\Omega}$
This numerical tour explores volumetric (3D) data processing.
import numpy as np
import scipy as scp
import pylab as pyl
import matplotlib.pyplot as plt
from nt_toolbox.general import circshift, clamp, np, plt, pylab, rescale
from nt_toolbox.signal import imageplot, np, plt, pylab, snr
import warnings
%matplotlib inline
3D Volumetric Datasets¶
We load a volumetric data.
from nt_toolbox.read_bin import np, read_bin
M = read_bin("nt_toolbox/data/vessels.bin", ndims=3)
M = rescale(M)
Size of the image (here it is a cube).
n = np.shape(M)[1]
We can display some horizontal slices.
slices = np.round(np.linspace(10, n - 10, 4))
plt.figure(figsize=(10, 10))
for i in range(len(slices)):
s = int(slices[i])
imageplot(M[:, :, s], "Z = %i" % s, [2, 2, i + 1])
We can display an isosurface of the dataset (here we sub-sample to speed up the computation).
from nt_toolbox.isosurface import isosurface, np, plt
isosurface(M, 0.5, 3)
3D Haar Transform¶
An isotropic 3D Haar transform recursively extracts details wavelet coefficients by performing local averages/differences along the X/Y/Z axis.
We apply a step of Haar transform in the X/Y/Z direction
Initialize the transform
MW = np.copy(M)
Average/difference along X
MW = np.concatenate(
(
(MW[0:n:2, :, :] + MW[1:n:2, :, :]) / np.sqrt(2),
(MW[0:n:2, :, :] - MW[1:n:2, :, :]) / np.sqrt(2),
),
0,
)
Average/difference along Y
MW = np.concatenate(
(
(MW[:, 0:n:2, :] + MW[:, 1:n:2, :]) / np.sqrt(2),
(MW[:, 0:n:2, :] - MW[:, 1:n:2, :]) / np.sqrt(2),
),
1,
)
Average/difference along Z
MW = np.concatenate(
(
(MW[:, :, 0:n:2] + MW[:, :, 1:n:2]) / np.sqrt(2),
(MW[:, :, 0:n:2] - MW[:, :, 1:n:2]) / np.sqrt(2),
),
2,
)
Display a horizontal and vertical slice to see the structure of the coefficients.
plt.figure(figsize=(10, 5))
imageplot(MW[:, :, 30], "Horizontal slice", [1, 2, 1])
imageplot((MW[:, 30, :]), "Vertical slice", [1, 2, 2])
Worked example 1
Implement the forward wavelet transform by iteratively applying these transform steps to the low pass residual.
MW = np.copy(M)
for j in range(1, int(np.log2(n)) + 1):
p = int(n / 2 ** (j - 1))
sel = np.arange(0, p)
even = np.arange(0, p, 2)
odd = np.arange(1, p, 2)
# average/ difference along X
MW[np.ix_(sel, sel, sel)] = np.concatenate(
(
(MW[np.ix_(even, sel, sel)] + MW[np.ix_(odd, sel, sel)]) / np.sqrt(2),
(MW[np.ix_(even, sel, sel)] - MW[np.ix_(odd, sel, sel)]) / np.sqrt(2),
),
0,
)
# average/ difference along Y
MW[np.ix_(sel, sel, sel)] = np.concatenate(
(
(MW[np.ix_(sel, even, sel)] + MW[np.ix_(sel, odd, sel)]) / np.sqrt(2),
(MW[np.ix_(sel, even, sel)] - MW[np.ix_(sel, odd, sel)]) / np.sqrt(2),
),
1,
)
# average/ difference along Z
MW[np.ix_(sel, sel, sel)] = np.concatenate(
(
(MW[np.ix_(sel, sel, even)] + MW[np.ix_(sel, sel, odd)]) / np.sqrt(2),
(MW[np.ix_(sel, sel, even)] - MW[np.ix_(sel, sel, odd)]) / np.sqrt(2),
),
2,
)
Volumetric Data Haar Approximation¶
An approximation is obtained by keeping only the largest coefficients.
We threshold the coefficients to perform $m$-term approximation.
number of kept coefficients
from nt_toolbox.perform_thresholding import np, perform_thresholding
m = round(0.01 * n**3)
MWT = perform_thresholding(MW, m, type="largest")
Worked example 2
Implement the backward transform to compute an approximation $M_1$ from the coefficients MWT.
M1 = np.copy(MWT)
for j in range(int(np.log2(n)), 0, -1):
p = int(n / 2**j)
sel = np.arange(0, p)
sel1 = np.arange(0, 2 * p)
selw = np.arange(p, 2 * p)
even = np.arange(0, 2 * p, 2)
odd = np.arange(1, 2 * p, 2)
# average/ difference along X
A = M1[np.ix_(sel, sel1, sel1)]
D = M1[np.ix_(selw, sel1, sel1)]
M1[np.ix_(even, sel1, sel1)] = (A + D) / np.sqrt(2)
M1[np.ix_(odd, sel1, sel1)] = (A - D) / np.sqrt(2)
# average/ difference along Y
A = M1[np.ix_(sel1, sel, sel1)]
D = M1[np.ix_(sel1, selw, sel1)]
M1[np.ix_(sel1, even, sel1)] = (A + D) / np.sqrt(2)
M1[np.ix_(sel1, odd, sel1)] = (A - D) / np.sqrt(2)
# average/ difference along Z
A = M1[np.ix_(sel1, sel1, sel)]
D = M1[np.ix_(sel1, sel1, selw)]
M1[np.ix_(sel1, sel1, even)] = (A + D) / np.sqrt(2)
M1[np.ix_(sel1, sel1, odd)] = (A - D) / np.sqrt(2)
Display the approximation as slices.
s = 30
plt.figure(figsize=(10, 5))
imageplot(M[:, :, s], "Original", [1, 2, 1])
imageplot(clamp(M1[:, :, s]), "Approximation", [1, 2, 2])
Display the approximated isosurface.
isosurface(M1, 0.5, 2)
Linear Volumetric Denoising¶
Linear denoising is obtained by low pass filtering.
We add a Gaussian noise to the image.
from numpy import random
sigma = 0.06
Mnoisy = M + sigma * random.randn(n, n, n)
Display slices of the noisy data.
plt.figure(figsize=(10, 5))
imageplot(Mnoisy[:, :, n // 2], "X slice", [1, 2, 1])
imageplot(Mnoisy[:, n // 2, :], "Y slice", [1, 2, 2])
A simple denoising method performs a linear filtering of the data.
We build a Gaussian filter of width $\sigma$.
Construct a 3D grid
x = np.arange(-n // 2, n // 2)
[X, Y, Z] = np.meshgrid(x, x, x)
Gaussian filter
s = 2 # width
h = np.exp(-(X**2 + Y**2 + Z**2) / (2 * s**2))
h = h / np.sum(h)
The filtering is computed over the Fourier domain.
Mh = np.real(
pyl.ifft2(
pyl.fft2(Mnoisy, axes=(0, 1, 2))
* pyl.fft2(pyl.fftshift(h, axes=(0, 1, 2)), axes=(0, 1, 2)),
axes=(0, 1, 2),
)
)
Display denoised slices.
i = 40
plt.figure(figsize=(10, 5))
imageplot(Mnoisy[:, :, i], "Noisy", [1, 2, 1])
imageplot(Mh[:, :, i], "Denoised", [1, 2, 2])
Display denoised iso-surface.
isosurface(M, 0.5, 3)
Worked example 3
Select the optimal blurring width $s$ to reach the smallest possible SNR. Keep the optimal denoising Mblur.
ntests = 20
slist = np.linspace(0.01, 1.5, ntests)
err = []
for i in range(ntests):
h = np.exp(-(X**2 + Y**2 + Z**2) / (2 * slist[i] ** 2))
h = h / np.sum(h)
Mh = np.real(
pyl.ifft2(
pyl.fft2(Mnoisy, axes=(0, 1, 2))
* pyl.fft2(pyl.fftshift(h, axes=(0, 1, 2)), axes=(0, 1, 2)),
axes=(0, 1, 2),
)
)
err = err + [snr(M, Mh)]
if i > 1 and err[i] > np.max(err[:i]):
Mblur = Mh
plt.figure(figsize=(7, 5))
plt.plot(slist, err, ".-")
plt.xlabel("s")
plt.ylabel("SNR")
plt.show()
Display optimally denoised iso-surface.
isosurface(Mblur, 0.5, 2, "Filtering, SNR = %.1f dB" % snr(M, Mblur))
Non-Linear Wavelet Volumetric Denoising¶
Denoising is obtained by removing small amplitude coefficients that corresponds to noise.
Worked example 4
Perforn Wavelet denoising by thresholding the wavelet coefficients of Mnoisy. Test both hard thresholding and soft thresholding to determine the optimal threshold and the corresponding SNR. Record the optimal result Mwav.
from nt_toolbox.perform_haar_transf import np, perform_haar_transf
from nt_toolbox.perform_thresholding import np, perform_thresholding
MW = perform_haar_transf(Mnoisy, 1, +1)
Tlist = np.linspace(1, 4, 20) * sigma
err_hard = []
err_soft = []
for i in range(len(Tlist)):
MWT = perform_thresholding(MW, Tlist[i], "hard")
M1 = perform_haar_transf(MWT, 1, -1)
err_hard = err_hard + [snr(M, M1)]
MWT = perform_thresholding(MW, Tlist[i], "soft")
M1 = perform_haar_transf(MWT, 1, -1)
err_soft = err_soft + [snr(M, M1)]
if i > 1 and err_soft[i] > np.max(err_soft[:i]):
Mwav = M1
plt.figure(figsize=(7, 5))
plt.plot(Tlist / sigma, err_hard, ".-", label="hard", color="b")
plt.plot(Tlist / sigma, err_soft, ".-", label="soft", color="r")
plt.xlabel(r"$T/\sigma$")
plt.ylabel("SNR")
plt.ylim(np.min(err_hard), np.max(err_soft))
plt.legend(loc="upper right")
plt.show()
Display denoised iso-surface with optimal soft thresholding.
isosurface(Mblur, 0.5, 2, "Soft thresholding, SNR = %.1f dB" % snr(M, Mwav))
Orthogonal wavelet thresholdings suffers from blocking artifacts. This can be aleviated by performing a cycle spinning denoising, which averages the denosing result of translated version of the signal.
A typical cycle spinning process is like this.
Maximum translation.
w = 4
List of translations.
[dZ, dX, dY] = np.meshgrid(np.arange(0, w), np.arange(0, w), np.arange(0, w))
dX = np.ravel(dX)
dY = np.ravel(dY)
dZ = np.ravel(dZ)
Initialize spinning process.
Mspin = np.zeros([n, n, n])
Spin.
def circshift(x, v):
x = np.roll(x, v[0], axis=0)
x = np.roll(x, v[1], axis=1)
x = np.roll(x, v[2], axis=2)
return x
for i in range(w**3):
# shift the image
MnoisyC = circshift(Mnoisy, [dX[i], dY[i], dZ[i]])
# denoise the image to get a result M1
M1 = MnoisyC # replace this line by some denoising
# shift inverse
M1 = circshift(M1, [-dX[i], -dY[i], -dZ[i]])
# average the result
Mspin = Mspin * (i) / (i + 1) + M1 / (i + 1)
Worked example 5
Implement cycle spinning hard thresholding with $T=3\sigma$.
T = 3 * sigma
w = 4
Mspin = np.zeros([n, n, n])
for i in range(w**3):
# shift the image
MnoisyC = circshift(Mnoisy, [dX[i], dY[i], dZ[i]])
# denoise
MW = perform_haar_transf(MnoisyC, 1, +1)
MWT = perform_thresholding(MW, T, "hard")
M1 = perform_haar_transf(MWT, 1, -1)
# back
M1 = circshift(M1, [-dX[i], -dY[i], -dZ[i]])
# average the result
Mspin = Mspin * (i) / (i + 1) + M1 / (i + 1)
Display denoised iso-surface.
isosurface(Mspin, 0.5, 2, "Cycle spinning, SNR = %.1f dB" % snr(M, Mspin))
References and further reading¶
Stéphane Mallat. A Wavelet Tour of Signal Processing: The Sparse Way. 2009, 3rd ed., Academic Press. Multiresolution analysis, sparse approximation, and wavelet algorithms.
Ingrid Daubechies. Ten Lectures on Wavelets. 1992, SIAM. Compactly supported orthogonal wavelets and their regularity.
Gabriel Peyré. Advanced Signal, Image and Surface Processing. 2010, course notes. A mathematical companion to the Numerical Tours.
David L. Donoho. De-noising by Soft-Thresholding. 1995, IEEE Transactions on Information Theory 41(3), 613–627. Why shrinkage of wavelet coefficients suppresses noise.
Pauli Virtanen et al.. SciPy 1.0: Fundamental Algorithms for Scientific Computing in Python. 2020, Nature Methods 17, 261–272. The numerical routines used for transforms, interpolation, and optimization.