There are multiple ways to compute FFT in Python:
numpy.fftscipy.fftpack -- Considered legacy. SciPy recommends using scipy.fftscipy.fftpyfftwcupy.fft -- NVIDIA's library that evaluates FFTs on GPUstorch.FFTtorch.fft as the fastest optionscipy.fft is slightly faster than numpy.fft, so using that in this notebookimport sys
print('Environment: {}'.format(sys.exec_prefix))
print('Executable: {}'.format(sys.executable))
print('Version: {}'.format(sys.version))
import numpy as np
import scipy.misc
from scipy import ndimage, signal
from scipy.fft import fft, ifft, fftn, ifftn, fftshift, ifftshift, fftfreq, rfft, irfft, rfftfreq
import skimage
from skimage.util import img_as_float
from skimage.data import camera, gravel, checkerboard
from skimage.filters import difference_of_gaussians, window
import matplotlib
from matplotlib import cm
import matplotlib.pyplot as plt
#plt.style.use('seaborn-poster')
%matplotlib inline
print('numpy: {}'.format(np.__version__))
print('scipy: {}'.format(scipy.__version__))
print('skimage: {}'.format(skimage.__version__))
print('matplotlib: {}'.format(matplotlib.__version__))
General comments:
fft takes longer if npnts is not a multiple of 2 and even longer if it is a prime
The frequency axis has been defined in different ways in fft_powerspectrum_plot below:
Method 1 (Frequency mirroring above Nyquist):\
Manually define the frequency axis in this range, [0, Sampling Rate]. The FFT Amplitude of the signal now has frequency peaks that are mirrored in the [Nyquist, Sampling Rate] too. Ignore the mirrored peaks and only consider this range, [0 to Nyquist frequency]. Recall that Nyquist frequency is half of the Sampling Rate.
Method 2 (Frequency mirroring about zero):\
Define the frequency axis using fftfreq which centers the output of fft(). The frequency axis now extends from [-Nyquist, Nyquist] and the peaks are mirrored in the negative axis. Again, ignore the negative frequency and only consider this range, [0 to Nyquist frequency]
Method 3 (No mirroring-- only positive frequencies):\
The frequency spectrum that fft() outputted was reflected about the y-axis, which is caused by inputting real numbers instead of complex numbers to fft(). This symmetry can be taken advantage of to make Fourier transform faster by computing only the positive frequencies, which is what rfft() does.
def fft_powerspectrum_plot(x, t, dt, npnts, freq_show, method=2, denoise=False, cutoff=0):
'''
- plot original signal
- compute FFT and power spectrum (power per frequency)
- plot power spectrum
- plot recovered signal after fft-> ifft
x: 1D signal
t: time vector
dt: sampling interval
npnts: number of time points
freq_show: upper range of frequency to plot
method: 1/2/3
denoise: filter out noise
cutoff: denoise below the cutoff amplitude
Call: plot_signal_fftamplitude(x, t, dt, npnts, 200, method=1, denoise=True, cutoff=3)
'''
if method == 1:
X = fft(x)
n = np.arange(npnts)
T = npnts*dt
freq = n/T
title = 'Frequency mirroring above Nyquist'
elif method == 2:
X = fft(x)
freq = fftfreq(npnts, dt)
title = 'Frequency mirroring about zero'
elif method == 3:
X = rfft(x)
freq = rfftfreq(npnts, dt)
title = 'No mirroring: only positive frequencies'
powerspect = 2*np.abs(X)/npnts
# Note: Normalized as 2*np.abs(X)/npnts instead of simply np.abs(X)
# returns the actual amplitude values of the sine and cosine functions
# instead of some arbitrarily-scaled values
if denoise:
powerspect = powerspect * (powerspect > cutoff) # Zero all frequencies with small power
X = X * (powerspect > cutoff) # Zero small Fourier coefficients
#To further zero out a peak at zero frequency -- occurs if noise is 'rand' instead of 'randn'
#powerspect = powerspect * (powerspect < 10)
#X = X * (powerspect < 10)
# PLOT
plt.figure(figsize = (9, 7))
plt.subplot(311)
plt.plot(t, x, 'k',label='original')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.legend()
plt.subplot(312)
plt.title(title)
#plt.stem(freq, np.abs(X),'c', markerfmt=" ", basefmt="-b")
plt.stem(freq, powerspect,'c', markerfmt=" ", basefmt="-b")
plt.xlabel('Freq (Hz)')
plt.ylabel('FFT Amplitude')
if method == 2:
plt.xlim(-freq_show,freq_show)
else:
plt.xlim(0,freq_show)
plt.subplot(313)
if method == 3:
plt.plot(t, irfft(X), 'k--',label='recovered')
else:
plt.plot(t, ifft(X), 'k--',label='recovered')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.legend()
plt.tight_layout()
plt.show()
print('Number of time points:', npnts, 'points')
print('Number of points in frequency range:', len(freq), 'points')
print('Frequency range:', min(freq), max(freq), 'Hz')
Let's generate a 1D signal and add some noise to it:
srate = 1000 # Sampling rate in Hz
dt = 1/srate
duration = 1 # in seconds
t = np.arange(0,duration,dt) # Time Vector in seconds
npnts = len(t)
# 1D signal
freq1 = 10.0
x = 2*np.sin(2*np.pi*freq1*t)
freq2 = 70.0
x += 4*np.cos(2*np.pi*freq2*t)
freq3 = 170.0
x += 6*np.cos(2*np.pi*freq3*t+np.pi/4)
noise = 6.0*np.random.randn(npnts)
x += noise
Let's plot to see what the signal looks like and what its power spectrum looks like (no denoising yet):
fft_powerspectrum_plot(x, t, dt, npnts, 200, 1)
Now, let's denoise frequencies with power spectrum below 1:
fft_powerspectrum_plot(x, t, dt, npnts, 200, 1, True, 1)
If we plot the frequency axis to show mirrored values in the negative axis too, this is what it looks like:
fft_powerspectrum_plot(x, t, dt, npnts, 200, 2, True, 1)
Or, using method 3, which uses rfft instead of fft:
fft_powerspectrum_plot(x, t, dt, npnts, 200, 3, True, 1)
Let's change the frequencies of the signal to non-integer values. When we do so, we see that the FFT amplitude shows up with spurious frequency peaks
srate = 1000 # Sampling rate in Hz
dt = 1/srate
duration = 1 # in seconds
t = np.arange(0,duration,dt) # Time Vector in seconds
npnts = len(t)
# 1D signal
freq1 = 10.1
x = 2*np.sin(2*np.pi*freq1*t)
freq2 = 70.5
x += 4*np.cos(2*np.pi*freq2*t)
freq3 = 170.7
x += 6* np.cos(2*np.pi*freq3*t+np.pi/4)
noise = 6.0*np.random.randn(npnts)
x += noise
fft_powerspectrum_plot(x, t, dt, npnts, 200)
One way to mitigate these additional frequency components is to use a longer signal, as shown below:
srate = 1000 # Sampling rate in Hz
dt = 1/srate
duration = 10 # in seconds
t = np.arange(0,duration,dt) # Time Vector in seconds
npnts = len(t)
# 1D signal
freq1 = 10.1
x = 2*np.sin(2*np.pi*freq1*t)
freq2 = 70.5
x += 4*np.cos(2*np.pi*freq2*t)
freq3 = 170.7
x += 6*np.cos(2*np.pi*freq3*t+np.pi/4)
noise = 6.0*np.random.randn(npnts)
x += noise
fft_powerspectrum_plot(x, t, dt, npnts, 200)
Another option is to denoise lower frequencies like we did earlier
Adapted from the following numpy example
tmp = scipy.misc.face(gray=True) #Racoon face from SciPy
image = tmp[128:640, 256:768]
image.shape
plt.figure(figsize = (12, 6))
plt.subplot(121)
plt.title('Original image')
plt.axis('off')
plt.imshow(image,cmap='gray');
# Take the 2-dimensional FFT and center the frequencies
ftimage = fftn(image)
ftimage = fftshift(ftimage)
plt.subplot(122)
#plt.imshow(np.abs(ftimage))
plt.imshow(np.log(np.abs(ftimage)), cmap='gray')
plt.title('FT(image)')
plt.axis('off')
plt.show()
# Build and apply a Gaussian filter
sigmax, sigmay = 7, 13
size = image.shape[0]
cy, cx = size/2, size/2
x = np.linspace(0, size, size)
y = np.linspace(0, size, size)
X, Y = np.meshgrid(x, y)
gmask = np.exp(-(((X-cx)/sigmax)**2 + ((Y-cy)/sigmay)**2))
#Note: mask is in Fourier space; same size as FT(image)
plt.figure(figsize = (12, 6))
plt.subplot(131)
plt.imshow(gmask, cmap='gray')
plt.title('Gaussian mask')
ftimagep = ftimage * gmask
plt.subplot(132)
plt.imshow(np.log(np.abs(ftimagep)),cmap='gray')
plt.title('FT(image)*mask')
plt.axis('off')
# Finally, take the inverse transform and show the blurred image
imagep = np.fft.ifftn(ftimagep)
plt.subplot(133)
plt.imshow(np.abs(imagep), cmap='gray')
plt.title('Blurred image')
plt.axis('off')
plt.show()
fftconvolve¶#From https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.fftconvolve.html
window_x = signal.windows.gaussian(101, std=7)
window_y = signal.windows.gaussian(101, std=13)
plt.plot(window_x, label= "$\sigma_x$=7")
plt.plot(window_y, label= "$\sigma_y$=13")
plt.legend(loc="upper right")
plt.title("Gaussian window")
plt.ylabel("Amplitude")
plt.xlabel("Sample")
kernel = np.outer(window_x, window_y)
#Note: Kernel is in the same coordinate as the image; Its size is smaller compared to image
plt.figure(figsize = (12, 6))
plt.subplot(121)
plt.imshow(kernel, cmap='gray')
plt.title('Gaussian Kernel')
blurred = signal.fftconvolve(image, kernel, mode='same')
plt.subplot(122)
plt.imshow(blurred, cmap='gray')
plt.title('Blurred image')
plt.axis('off')
plt.show()
Notice the dark borders around the image, due to zero-padding beyond its boundaries.
scipy.ndimage.gaussian_filter gets rid of this artifact.
# Image Denoising: Gaussian filter, Median filter
noisy = image + 0.4*image.std()*np.random.random(image.shape)
gauss_denoised = ndimage.gaussian_filter(noisy, sigma = 11)
med_denoised = ndimage.median_filter(noisy, size = 9)
plt.figure(figsize=(12,4))
plt.subplot(131)
plt.imshow(noisy, cmap=plt.cm.gray, vmin=40, vmax=220)
plt.axis('off')
plt.title('noisy', fontsize=20)
plt.subplot(132)
plt.imshow(gauss_denoised, cmap=plt.cm.gray, vmin=40, vmax=220)
plt.axis('off')
plt.title('Gaussian filter', fontsize=20)
plt.subplot(133)
plt.imshow(med_denoised, cmap=plt.cm.gray, vmin=40, vmax=220)
plt.axis('off')
plt.title('Median filter', fontsize=20)
plt.subplots_adjust(wspace=0.02, hspace=0.02, top=0.9, bottom=0, left=0, right=1)
plt.show()
Note that a Gaussian filter smooths out the noise and the edges. Median filter averages the noise and preserves the edges better
Adapted from the following SciKit-Image documentation
Band-pass filters attenuate signal frequencies outside of a range (band) of interest. In image analysis, they can be used to denoise images while at the same time reducing low-frequency artifacts such a uneven illumination. Band-pass filters can be used to find image features such as blobs and edges.
One method for applying band-pass filters to images is to subtract an image blurred with a Gaussian kernel from a less-blurred image. This example shows two applications of the Difference of Gaussians approach for band-pass filtering.
More on DoG here
image = gravel()
wimage = image * window('hann', image.shape) # window image to improve FFT
filtered_image = difference_of_gaussians(image, 1, 12)
filtered_wimage = filtered_image * window('hann', image.shape)
im_f_mag = fftshift(np.abs(fftn(wimage)))
fim_f_mag = fftshift(np.abs(fftn(filtered_wimage)))
fig, ax = plt.subplots(nrows=2, ncols=2, figsize=(11, 12))
ax[0, 0].imshow(image, cmap='gray')
ax[0, 0].set_title('Original Image')
ax[0, 0].axis('off')
ax[0, 1].imshow(np.log(im_f_mag), cmap='magma')
ax[0, 1].set_title('FFT Magnitude (log)')
ax[0, 1].axis('off')
ax[1, 0].imshow(filtered_image, cmap='gray')
ax[1, 0].set_title('Filtered Image')
ax[1, 0].axis('off')
ax[1, 1].imshow(np.log(fim_f_mag), cmap='magma')
ax[1, 1].set_title('FFT Magnitude (log)')
ax[1, 1].axis('off')
plt.show()
image = camera()
#image = noisy
wimage = image * window('hann', image.shape) # window image to improve FFT
filtered_image = difference_of_gaussians(image, 1.5)
filtered_wimage = filtered_image * window('hann', image.shape)
im_f_mag = fftshift(np.abs(fftn(wimage)))
fim_f_mag = fftshift(np.abs(fftn(filtered_wimage)))
fig, ax = plt.subplots(nrows=2, ncols=2, figsize=(11, 12))
ax[0, 0].imshow(image, cmap='gray')
ax[0, 0].set_title('Original Image')
ax[0, 0].axis('off')
ax[0, 1].imshow(np.log(im_f_mag), cmap='magma')
ax[0, 1].set_title('Original FFT Magnitude (log)')
ax[0, 1].axis('off')
ax[1, 0].imshow(filtered_image, cmap='gray')
ax[1, 0].set_title('Filtered Image')
ax[1, 0].axis('off')
ax[1, 1].imshow(np.log(fim_f_mag), cmap='magma')
ax[1, 1].set_title('Filtered FFT Magnitude (log)')
ax[1, 1].axis('off')
plt.show()
Relationship between imaging properties:
cPSF(u,v) ⟸ FT ⟹ CTF(Fu,Fv)
| |
Mag. squared Autocorrelation
↓ 🡓
iPSF(u,v) ⟸ FT ⟹ OTF(Fu,Fv)
where
(u,v)= image plane co-ordinates
cPSF = coherent point spread function or coherent impulse response function, h(u,v)
iPSF = intensity point spread function or incoherent impulse response function, PSF(u,v)
CTF = coherent transfer function or amplitude transfer function, H(Fu,Fv)
OTF = optical transfer function, OTF(Fu,Fv)
Note that modulation transfer function, MTF = |OTF|
from google.colab import drive
drive.mount('/content/drive')
cd /content/drive/MyDrive/Colab Notebooks/
#Coherent Image Simulation
img = plt.imread('./USAF_Resolution_Chart.jpeg') # RGB image, 8-bit
img = img[:,:,0] # grayscale image, 8-bit
M, N = img.shape
L = 0.9e-3 # image length in m
du = L/M # sample interval (m)
print(f'Object is {L:.0e} m ({M} pixels), sampled every {du:.2e} m')
Nyquistf = 1/(2*du)
print(f'Thus, the highest frequency avaiable in an ideal image (Nyquist freqeuncy) is {Nyquistf:.2e} cycles/m')
# Image space co-ordinates
u = np.arange(-L/2, L/2, du)
v = np.arange(-L/2, L/2, du)
# binary Image, type: float
Ig = np.where(img > 128, 1.0, 0.0) #img>128, replace by 1.0, otherwise replace by 0.0
ug = np.sqrt(Ig) # an ideal geometric-optics predicted image field
plt.figure(figsize=(7,7))
plt.title('Ideal image')
plt.imshow(ug, extent=[-L/2, L/2, -L/2, L/2], cmap='gray')
plt.ticklabel_format(style="sci", scilimits=(0,0))
plt.xlabel('u (m)')
plt.ylabel('v (m)')
plt.show()
print('Note that u and v are image plane co-ordinates')
lamda = 0.5e-6 # wavelength in m
wxp = 6.25e-3 # exit pupil radius in m
zxp = 125e-3 # exit pupil distance in m
fnumber = zxp/(2*wxp)
print(f'f# of the lens is {fnumber}')
f0 = wxp/(lamda*zxp) # coherent cutoff frequency
print(f'Coherent cutoff frequency with this lens is {f0:.2e} cycles/m')
if Nyquistf >= 2*f0:
print('Imaging is in the diffraction-limited regime')
else:
print('Not in the diffraction-limited regime: object is undersampled')
#frequency coordinates
fu = np.arange(-1/(2*du), 1/(2*du), 1/L)
fv = np.arange(-1/(2*du), 1/(2*du), 1/L)
Fu,Fv = np.meshgrid(fu,fv)
H = np.sqrt(Fu**2 + Fv**2)/f0 < 1 # Coherent Transfer Function (Amplitude Transfer Function)
fig = plt.figure(figsize=(9,9))
fig.suptitle('Coherent Transfer Function')
ax = fig.add_subplot(projection='3d')
# Plot the surface
ax.plot_surface(Fu, Fv, H, cmap=cm.gray)
plt.ticklabel_format(style="sci", scilimits=(0,0))
fig.tight_layout()
ax.set_xlabel('fu (cycle/m)')
ax.set_ylabel('fv (cycle/m)')
plt.show()
Note that the ideal image field is blurred by the impulse response function of the imaging system, h(u,v)= FFT[H(Fu,Fv)]
i.e., Image(u,v) = Ideal_Image(u,v) $*$ h(u,v)
This is implemented in frequency-domain as follows:
Gi(Fu,Fv)= H(Fu,Fv).FFT(Ideal_Image)
So, actual image field, ui(u,v) = iFFT(Gi)
H = fftshift(H) #Coherent transfer function
Gg = fftn(fftshift(ug)) #FFT of an ideal geometric-optics predicted image field
Gi = np.multiply(Gg, H)
ui = ifftshift(ifftn(Gi))
Ii = (np.abs(ui))**2
#Simulated diffraction-limited coherent image
plt.figure(figsize=(7,7))
plt.title('Simulated image')
plt.imshow(Ii**0.5, extent=[-L/2, L/2, -L/2, L/2], cmap='gray')
plt.ticklabel_format(style="sci", scilimits=(0,0))
plt.xlabel('u (m)')
plt.ylabel('v (m)')
plt.show()
Next, let's account for surface roughness of the object by adding a random complex exponential phase term to the field:
ug1 = np.sqrt(Ig)*np.exp(1j*2*np.pi*np.random.randn(M))
H1 = H
Gg1 = fftn(fftshift(ug1))
Gi1 = np.multiply(Gg1, H1)
ui1 = ifftshift(ifftn(Gi1))
Ii1 = (np.abs(ui1))**2
#Simulated diffraction-limited coherent image
plt.figure(figsize=(7,7))
plt.title('Simulated image of an object w/ a rough surface')
plt.imshow(Ii1**0.5, extent=[-L/2, L/2, -L/2, L/2], cmap='gray')
plt.ticklabel_format(style="sci", scilimits=(0,0))
plt.xlabel('u (m)')
plt.ylabel('v (m)')
plt.show()
The irradiance image that we get is thus impeded further by the prescence of speckles (more apparent with imshow Ii**0.5 so plotting that above)
PSF = np.abs(fftn(fftshift(H)))**2
OTF = ifftn(PSF) #left in the shifted arrangement
OTF = OTF/OTF[0,0] #normalize wrt zero-frequency value
MTF = ifftshift(np.abs(OTF))
fig = plt.figure(figsize=(9,9))
fig.suptitle('Incoherent Transfer Function')
ax = fig.add_subplot(projection='3d')
# Plot the surface
ax.plot_surface(Fu, Fv, MTF, cmap=cm.gray)
plt.ticklabel_format(style="sci", scilimits=(0,0))
fig.tight_layout()
ax.set_xlabel('fu (cycle/m)')
ax.set_ylabel('fv (cycle/m)')
plt.show()
Gg2 = fftn(fftshift(Ig)) #FFT of an ideal geometric-optics predicted image intensity
Gi2 = np.multiply(Gg2, OTF)
Ii2 = ifftshift(ifftn(Gi2))
Ii2 = np.real(Ii2) #remove residual imaginary parts
Ii2 = np.where(Ii2 <= 0, 0, Ii2) #wherever Ii2 is <= 0, replace by 0, otherwise leave as is
#Simulated diffraction-limited coherent image
plt.figure(figsize=(7,7))
plt.title('Simulated image')
plt.imshow(Ii2, extent=[-L/2, L/2, -L/2, L/2], cmap='gray')
plt.ticklabel_format(style="sci", scilimits=(0,0))
plt.xlabel('u (m)')
plt.ylabel('v (m)')
plt.show()
Notice that the image quality in case of incoherent imaging is better than than with coherent imaging. Also, there are no ringing artifacts or speckle features in the image.
!pwd