Removing Noise
Which filter keeps the edges?.
In low light, a camera turns up its gain, and speckle appears all over the photo. Filtering removes it. But filtering also softens edges, and edges are exactly what you need. So which filter should you pick? Instead of guessing, let's measure.

- Low light means speckle
- Filtering softens edges
- Let's measure
Comparing filters
We add strong noise to a clean image. Then we try three filters, and score each one with PSNR, which measures how close the result is to the clean original, in decibels. Higher is better. Gaussian averages neighbouring pixels. Median takes the middle value. And bilateral averages only neighbours of similar colour, to protect edges. Run it. All three are a big improvement on the noisy twenty decibels. Median narrowly wins here, at almost thirty two. Bilateral scores lowest here, because this noise is so strong that it can't tell noise from real edges. There's no universal best filter. So measure on your own images.
import cv2
import numpy as np
from belt import belt
clean, _, _ = belt()
rng = np.random.default_rng(0)
noisy = np.clip(clean + rng.normal(0, 25, clean.shape), 0, 255).astype(np.uint8)
def psnr(a, b):
return 10 * np.log10(255 ** 2 / ((a.astype(float) - b) ** 2).mean())
print(f"Noisy: {psnr(clean, noisy):.1f} dB")
print(f"Gaussian: {psnr(clean, cv2.GaussianBlur(noisy, (5, 5), 0)):.1f} dB")
print(f"Median: {psnr(clean, cv2.medianBlur(noisy, 5)):.1f} dB")
print(f"Bilateral: {psnr(clean, cv2.bilateralFilter(noisy, 9, 60, 7)):.1f} dB")Noisy: 20.2 dB Gaussian: 31.3 dB Median: 31.9 dB Bilateral: 29.1 dB
Also uses belt.py
import numpy as np
import cv2
def belt(n=7, seed=1, size=(480, 640), bruises=2, light=1.0):
"""Draw a simulated conveyor belt of apples. Returns the image and the true boxes."""
rng = np.random.default_rng(seed)
h, w = size
img = np.full((h, w, 3), (70, 75, 80), np.uint8)
img = cv2.add(img, rng.normal(0, 6, img.shape).clip(0, 30).astype(np.uint8))
boxes, spots, taken = [], [], []
while len(boxes) < n:
r = int(rng.integers(32, 48))
x, y = int(rng.integers(r + 5, w - r - 5)), int(rng.integers(r + 5, h - r - 5))
if any((x - a) ** 2 + (y - b) ** 2 < (r + c + 8) ** 2 for a, b, c in taken):
continue
taken.append((x, y, r))
red = int(rng.integers(150, 225))
cv2.circle(img, (x, y), r, (35, 40, red), -1, cv2.LINE_AA)
cv2.circle(img, (x - r // 3, y - r // 3), r // 4, (90, 90, min(255, red + 30)), -1, cv2.LINE_AA)
boxes.append((x - r, y - r, x + r, y + r))
for x, y, r in taken[:bruises]:
cv2.ellipse(img, (x + r // 3, y + r // 4), (r // 3, r // 4), 20, 0, 360, (25, 45, 70), -1, cv2.LINE_AA)
spots.append((x, y, r))
img = cv2.GaussianBlur(img, (3, 3), 0)
return np.clip(img * light, 0, 255).astype(np.uint8), boxes, spotsWhich filter?
Here's a quick guide. Gaussian suits gentle, even noise, and it's very fast. Median is superb on salt and pepper noise, which means random white and black dots. Bilateral protects edges when noise is moderate. Non local means gives the best classic quality, but it's slow. And trained denoising networks do better still, if you've got the compute.
| Filter | Good for |
|---|---|
| Gaussian | Gentle noise, speed |
| Median | Salt and pepper noise |
| Bilateral | Keeping edges |
| Non-local means | Best classic quality |
| Neural denoiser | Top quality, more compute |
Summary
Measure filters on your own images. Next, a completely different way of seeing an image, as waves.
Key takeaways- PSNR scores denoising
- Median beat the rest here
- There's no universal best