Every filter so far has worked directly on pixel neighborhoods. The Fourier transform offers a completely different view: any image can be rewritten as a sum of sinusoidal gratings at different frequencies and orientations. Filtering can then be done by directly boosting or suppressing specific frequencies — and, thanks to the convolution theorem, this turns out to be mathematically the same operation as spatial convolution, just seen from a different angle.
import numpy as np
import cv2
import matplotlib.pyplot as plt
Any signal can be approximated by adding up sine and cosine waves of different frequencies. For example, a square wave is the sum of sine waves at increasing frequencies (odd harmonics, decreasing amplitude). The Fourier transform is the tool that goes the other way: given a signal, it tells you exactly which frequencies (and how much of each) are present.
t = np.linspace(0, 1, 500, endpoint=False)
square_wave = np.sign(np.sin(2 * np.pi * 5 * t))
approx = np.zeros_like(t)
fig, axes = plt.subplots(1, 2, figsize=(9, 3.5))
for n_harmonics in [1, 9]:
approx = sum((4 / (np.pi * k)) * np.sin(2 * np.pi * 5 * k * t) for k in range(1, n_harmonics + 1, 2))
axes[0 if n_harmonics == 1 else 1].plot(t, square_wave, '--', color='gray', label='true square wave')
axes[0 if n_harmonics == 1 else 1].plot(t, approx, label=f'{n_harmonics} harmonic(s)')
axes[0 if n_harmonics == 1 else 1].legend(fontsize=8)
axes[0 if n_harmonics == 1 else 1].set_title(f'{n_harmonics} harmonic(s)', fontsize=9)
plt.tight_layout()
plt.show()
This animation builds the square wave up one pair of odd harmonics at a time, showing the partial sum converge toward the true square wave while a fixed overshoot ("ringing") near each jump never fully disappears — the Gibbs phenomenon. The reason: reproducing an instantaneous jump exactly would require infinitely many frequencies, so truncating to a finite number of harmonics is really a hard cutoff in frequency — some frequencies kept completely, the rest discarded completely. That hard cutoff is what produces the ripple, a pattern that will reappear later in this lesson whenever frequencies are cut off sharply instead of faded out smoothly.
The square wave above only used the 1D Fourier transform informally, as a sum of sines. More precisely, the Fourier transform turns any signal into a function of continuous frequency (its spectrum), and the two are linked as a Fourier pair: compact and sharp in one domain always corresponds to spread out in the other. The clearest example: a box pulse is sharp-edged and finite in space, but its transform is a sinc function that extends — and oscillates — forever (box ↔ sinc). An even simpler case is a pure sine wave, spread out forever in space, whose Fourier transform is two sharp spikes in frequency (sine ↔ spikes) — we'll see this next.
x = np.linspace(-4, 4, 1000)
box = ((x > -0.5) & (x < 0.5)).astype(float)
f = np.linspace(-8, 8, 1000)
sinc_transform = np.sinc(f) # np.sinc(x) = sin(pi*x)/(pi*x), exactly the FT of a unit-width box
fig, axes = plt.subplots(1, 2, figsize=(9, 3.5))
axes[0].plot(x, box)
axes[0].set_title('Box pulse (sharp, compact in space)')
axes[0].set_xlabel('x')
axes[1].plot(f, sinc_transform)
axes[1].set_title('Its Fourier transform: a sinc (spread out, oscillating)')
axes[1].set_xlabel('frequency')
plt.tight_layout()
plt.show()
The 2D discrete Fourier transform (DFT) of an image decomposes it into 2D sinusoidal gratings at every combination of horizontal and vertical frequency. The algorithm for computing the DFT is the fast Fourier transform (FFT) (np.fft.fft2), which results in a complex-valued (real + imaginary) 2D array. Similar to the image gradient, we usually view the Fourier transform's magnitude (how much of each frequency is present) and orientation. Two common tricks for visualization: show the magnitude on a log scale, and shift the zero-frequency (average brightness) term from the top-left corner to the center.
def show_spectrum(image, ax, title):
F = np.fft.fftshift(np.fft.fft2(image)) # compute discrete Fourier transform (DFT), and shift DC value to center
magnitude = np.log1p(np.abs(F)) # display logarithm of magnitude
ax.imshow(magnitude, cmap='gray')
ax.set_title(title, fontsize=9)
ax.axis('off')
return F
img = np.zeros((200, 200), dtype=np.float64)
cv2.rectangle(img, (60, 60), (140, 140), 200, -1)
fig, axes = plt.subplots(1, 2, figsize=(7, 3.5))
axes[0].imshow(img, cmap='gray')
axes[0].set_title('Image')
axes[0].axis('off')
show_spectrum(img, axes[1], 'Log-magnitude spectrum')
plt.tight_layout()
plt.show()
The bright cross through the center comes from the square's sharp horizontal and vertical edges — a hard step edge contains energy at every frequency along the direction perpendicular to it, which is why a sharp-edged shape has such a spread-out spectrum (the Fourier-pairs principle from before: sharp in one domain, spread out in the other).
As previewed above (sine ↔ spikes), a pure sinusoidal grating is the simplest possible test: its spectrum should be (almost) a single pair of bright dots, at a distance from the center equal to its frequency, in the direction perpendicular to its stripes.
Why a pair, not one dot? A real-valued sine wave is, by Euler's formula, a mix of two complex exponentials in equal amounts: $\sin(2\pi f x) = \frac{1}{2i}\left(e^{i2\pi fx} - e^{-i2\pi fx}\right)$. The Fourier transform of the first exponential is a spike at frequency $f$, and for the other it is at $-f$. Therefore, a real sinusoid produces two spikes at $+f$ and $-f$, symmetric about the zero-frequency (DC) center. In fact, for any real-valued image, its Fourier magnitude is symmetric: $|F(-f)|=|F(f)|$.
size = 100
x = np.arange(size)
cycles_across_image = 12
grating = np.tile(np.sin(2 * np.pi * cycles_across_image * x / size), (size, 1))
fig, axes = plt.subplots(1, 2, figsize=(7, 3.5))
axes[0].imshow(grating, cmap='gray')
axes[0].set_title(f'Grating, {cycles_across_image} cycles across')
axes[0].axis('off')
F_grating = show_spectrum(grating, axes[1], 'Spectrum')
plt.tight_layout()
plt.show()
peak = np.unravel_index(np.argmax(np.abs(F_grating)), F_grating.shape)
print(f'brightest spectrum point at offset {peak[1] - size // 2} from center '
f'(grating frequency was {cycles_across_image})')
This animation rotates the grating through orientations and shows its spectrum updating live — the bright peak pair always sits in the direction perpendicular to the stripes, rotating in lockstep with them.
The below animation instead sweeps the grating's frequency (cycles across the image) from low to high and back (while holding the orientation fixed). The spectrum peak pair moves straight outward from the center as the frequency rises, and back inward (toward the origin) as it falls — with its direction unchanged.
An ideal low-pass filter keeps only frequencies inside a circle around the center (blurs); an ideal high-pass filter keeps only frequencies outside it (edge-enhances).
def circular_mask(shape, radius, keep_inside=True):
h, w = shape
yy, xx = np.mgrid[:h, :w]
dist = np.sqrt((yy - h / 2)**2 + (xx - w / 2)**2)
inside = dist <= radius
return inside if keep_inside else ~inside
photo = np.zeros((200, 200), dtype=np.float64)
cv2.rectangle(photo, (40, 40), (120, 120), 200, -1)
cv2.circle(photo, (150, 150), 30, 120, -1)
low_pass_mask = circular_mask(photo.shape, radius=15, keep_inside=True)
high_pass_mask = circular_mask(photo.shape, radius=15, keep_inside=False)
fig, axes = plt.subplots(1, 2, figsize=(9, 3.5))
titles = ['Ideal low-pass mask' + chr(10) + '(frequency domain)',
'Ideal high-pass mask' + chr(10) + '(frequency domain)']
for ax, im, title in zip(axes, [low_pass_mask, high_pass_mask], titles):
ax.imshow(im, cmap='gray')
ax.set_title(title, fontsize=9)
ax.axis('off')
# thin border so the mask's full extent is visible even when it's mostly
# white (the high-pass mask) and would otherwise blend into the page;
# clip_on=False so the line isn't half-clipped by the axes edge
h, w = im.shape
ax.add_patch(plt.Rectangle((-0.5, -0.5), w, h, fill=False, edgecolor='black',
linewidth=0.8, clip_on=False))
plt.tight_layout()
plt.show()
Once an image is transformed to the frequency domain, we can filter it by masking frequencies directly, then transforming back with the inverse transform. Since the mask is the same size as the image, we can simply elementwise-multiply in the frequency domain. Watch for one side effect in the result below: cutting frequencies off sharply introduces faint ringing near sharp edges — the same hard-cutoff ringing seen in the square-wave demo above, now reappearing as ripples radiating from the photo's sharp edges. The reason is the same box↔sinc Fourier pair from before, just mirrored: since the ideal filter's mask is a hard box in the frequency domain, its spatial kernel is a sinc (which, as shown above, has ripples).
def apply_frequency_filter(image, mask):
F = np.fft.fftshift(np.fft.fft2(image)) # forward transform
filtered = np.fft.ifft2(np.fft.ifftshift(F * mask)) # filter, then inverse transform
return np.real(filtered)
low_pass = apply_frequency_filter(photo, low_pass_mask)
high_pass = apply_frequency_filter(photo, high_pass_mask)
fig, axes = plt.subplots(1, 3, figsize=(9, 3.5))
for ax, im, title in zip(axes, [photo, low_pass, high_pass],
['Original', 'Ideal low-pass\n(blurred)', 'Ideal high-pass\n(edges only)']):
ax.imshow(im, cmap='gray')
ax.set_title(title, fontsize=9)
ax.axis('off')
plt.tight_layout()
plt.show()
This animation shows the effect of changing the ideal low-pass cutoff radius, with the filtered image updating live. With a large enough radius all frequencies are passed, and the image is unchanged. As the radius shrinks, more and more frequencies get discarded outright, and the ringing noted above becomes increasingly visible.
As explained, the faint ripples radiating from the shape edges — the Gibbs phenomenon — are caused by the ideal filter's hard on/off cutoff in frequency. A Gaussian mask, which falls off smoothly instead of cutting off sharply, avoids this entirely.
def gaussian_mask(shape, sigma):
h, w = shape
yy, xx = np.mgrid[:h, :w]
dist2 = (yy - h / 2)**2 + (xx - w / 2)**2
return np.exp(-dist2 / (2 * sigma**2))
smooth_low_pass = apply_frequency_filter(photo, gaussian_mask(photo.shape, sigma=15))
fig, axes = plt.subplots(1, 2, figsize=(7, 3.5))
axes[0].imshow(low_pass, cmap='gray')
axes[0].set_title('Ideal (sharp cutoff)\nvisible ringing near edges')
axes[1].imshow(smooth_low_pass, cmap='gray')
axes[1].set_title('Gaussian mask\nsmooth falloff, no ringing')
for ax in axes:
ax.axis('off')
plt.tight_layout()
plt.show()
Why does frequency-domain filtering work? The convolution theorem
$$\mathcal{F}\{f * g\} = \mathcal{F}\{f\} \cdot \mathcal{F}\{g\}$$
says that convolving two signals in the spatial domain is exactly the same as multiplying their Fourier transforms elementwise, then transforming back — convolution becomes multiplication, and vice versa.
The DFT implicitly treats the image as if it were infinitely repeating (imagine seamless tiles extending forever in both directions). There's no "outside the image", only "the next copy of the same image." So multiplying two DFTs and transforming back doesn't give the ordinary ("linear") convolution from Lesson 10, where the kernel eventually slides off the edge into some explicit border (zero, replicate, reflect); rather, it gives circular convolution, where the kernel that slides off the right edge wraps around and picks up pixels from the left edge instead. The two only agree if the border handling on the spatial side is also wraparound (cv2.BORDER_WRAP).
rng = np.random.default_rng(0)
test_img = rng.integers(0, 255, (64, 64)).astype(np.float64)
sigma, ksize = 3, 15
ax = np.arange(ksize) - ksize // 2
gx, gy = np.meshgrid(ax, ax)
kernel = np.exp(-(gx**2 + gy**2) / (2 * sigma**2))
kernel /= kernel.sum()
# Spatial convolution, wrap-around border to match the FFT's implicit circular convolution
spatial_result = cv2.filter2D(test_img, cv2.CV_64F, kernel, borderType=cv2.BORDER_WRAP)
# Frequency-domain: pad the kernel to image size, center it at (0,0) via np.roll, then multiply spectra
kernel_padded = np.zeros_like(test_img)
kernel_padded[:ksize, :ksize] = kernel
kernel_padded = np.roll(kernel_padded, -(ksize // 2), axis=0)
kernel_padded = np.roll(kernel_padded, -(ksize // 2), axis=1)
freq_result = np.real(np.fft.ifft2(np.fft.fft2(test_img) * np.fft.fft2(kernel_padded)))
print(f'max abs difference between spatial and frequency-domain convolution: '
f'{np.abs(spatial_result - freq_result).max():.2e}')
In practice, though, frequency-domain filtering is rarely used in everyday computer vision. Most kernels — box blur, Gaussian, Sobel, sharpen — are small (3x3 or 5x5), and direct spatial convolution is already fast at those sizes, so the fixed overhead of two forward FFTs and one inverse FFT usually isn't worth paying. The DFT earns its keep mainly once kernels get large.
x*cos(theta) + y*sin(theta). Predict, then verify, where the spectrum peak moves to.photo. What kind of image content survives?apply_frequency_filter with a very small Gaussian mask sigma (e.g. 3) versus a large one (e.g. 60) on a photo-like test image, and describe the trend as sigma increases toward the image size.test_img and kernel to at least (image_size + kernel_size - 1) along each axis before transforming, multiply the spectra, inverse-transform, then crop back down. Compare the result to cv2.filter2D(..., borderType=cv2.BORDER_CONSTANT) (zero-padding border) instead of BORDER_WRAP.%timeit, measure direct convolution (cv2.filter2D) against FFT-based convolution (as in freq_result above) for Gaussian kernel sizes 5, 15, 31, 63, and 127 on a 256x256 image. At roughly what kernel size does the FFT approach start winning?