Three ways to remove noise. One smears edges, one ignores outliers, one refuses to cross a boundary.
Overview
The same job, three different assumptions
All three filters answer the same question. What should this pixel be, given its neighbours? — and they differ in what they assume about how the pixel got corrupted. That assumption is the whole story, and it's why one filter can be great on one kind of noise and useless on another.
The image in the visualisation carries salt-and-pepper noise: isolated pixels flipped to pure black or pure white. Switch between the three filters and the difference isn't subtle.
Blur: Gaussian, Median and Bilateral
This module needs JavaScript: the
images are computed in the page rather than downloaded.
Worth knowing
Gaussian blur is a weighted average. An outlier is included in the average, so it is spread out rather than removed.
The median filter sorts the neighbourhood and takes the middle value. An outlier is never the middle value, so it disappears completely.
Bilateral filtering weights neighbours by both distance and similarity, so it averages within a region but not across a boundary.
The source image here has salt-and-pepper noise, which is the case that separates the three most clearly.
Blur: Gaussian, Median and Bilateral
Three smoothing filters, one noisy image. A clear demo of why the choice matters.
Gaussian: a weighted average
A Gaussian blur replaces each pixel with a weighted mean of its zone, where the weights fall off with distance per to a Gaussian curve. The centre counts most, the corners least.
It's the right filter when the noise is additive and roughly even — each pixel nudged up or down by a small random amount. Blending a few such pixels cancels the nudges, because they're as often plus as minus. Sensor noise in reasonable light behaves like this, and a Gaussian handles it well.
On salt-and-pepper it fails, and the reason is structural: a mean includes each value it's given. A single pixel at 255 sitting among neighbours at 40 drags the mean up. The filter doesn't drop the outlier — it *spreads* it over the zone, converting one bright dot into a larger, dimmer smudge. Grow the radius and the smudges grow rather than vanishing.
The other cost is edges. An edge is a place where neighbouring pixels are truly different, and blending across it mixes the two sides together. Any filter based on a plain mean must blur edges. It has no way to know that an edge is there.
Median: sorting instead of averaging
The median filter takes the zone, sorts the values, and outputs the middle one.
This one change fixes salt-and-pepper fully. To be selected as the median, a value has to sit in the middle of the sorted list — and an extreme value, by meaning, sorts to one end. It's never chosen. It isn't reduced or spread. It's simply not selected, and the output takes a value that some genuine neighbouring pixel really had.
Set the filter to Median with radius 2 above. The speckles don't fade. They are gone, and the shapes underneath keep their edges. That edge preservation is the second gain: at a boundary, most of the zone belongs to one side or the other, so the median comes from the majority side rather than from a blend of both.
The median is a non-linear filter. This Means it can't be expressed as a convolution kernel and can't be broken down into separate flat and vertical passes. It's correspondingly slower, since a sort is needed at each pixel.
Bilateral: averaging only within a region
The bilateral filter keeps the weighted mean but times each weight by a second factor based on how similar the neighbour's *value* is to the centre pixel's.
A neighbour that is nearby and similar gets a large weight. A neighbour that is nearby but very different — because it's on the other side of an edge — gets a weight close to zero and is in effect excluded.
The result smooths within a region and refuses to smooth across a boundary. It is what makes the slightly plastic look of smartphone portrait modes: skin is smoothed heavily while the outline of the face stays sharp.
The edge sigma control sets how different a neighbour has to be before it's skipped. Turn it low and the filter barely does any of it, because almost all counts as an edge. Turn it high and the range term stops telling apart, and you've an plain Gaussian blur back.
Choosing
Noise
Use
Because
Small random variation
Gaussian
averaging cancels symmetric errors
Isolated extreme pixels
Median
outliers never sort to the middle
Any, with edges to protect
Bilateral
dissimilar neighbours are excluded
Impulse noise before thresholding
Median
extremes survive every threshold otherwise
A hands-on note: these compose. Median first to drop impulses, then a gentle Gaussian to handle what remains, is a common and effective pipeline.
Mean against median, on one neighbourhood
The three filters differ in what they assume about the noise, and a single salt-and-pepper zone shows why the choice isn't cosmetic. Take a 3×3 window of pixels around value 40, with two corrupted to the extremes — a 255 (salt) and a 0 (pepper):
[40, 42, 255, 41, 39, 38, 43, 40, 0]
filter
output
mean (Gaussian-like)
59.8 — dragged up, the 255 spread over the region
median
40 — the extremes sort to the ends and are never chosen
A mean *includes each value it's given*. The 255 doesn't get removed, it gets smeared into a dimmer smudge, and the 0 pulls the other way. The output, 59.8, resembles neither the true signal nor the noise. The median sorts the nine values and takes the middle one, and an extreme by meaning sorts to an end, so it's simply not selected — the output is a value some real neighbour really had. That is why median filtering drops salt-and-pepper fully while a Gaussian can't, and why it also preserves edges: at a boundary most of the window belongs to one side, so the majority side supplies the median. The cost is that it's non-linear, so it can't be separated into two 1D passes and needs a sort per pixel. The bilateral filter keeps the mean but zeroes the weight of neighbours too different in value, which is how it smooths within a region and refuses to smooth across an edge.
Where it goes wrong with Blur
Blurring before edge spotting without thinking. Some blur helps, since edge finders amplify noise. Too much drops the edges you were looking for. The Canny finder builds a Gaussian in for just this reason, with a param to control it.
Using a large median radius on fine detail. Thin lines narrower than half the window are a minority in each zone they appear in, so the median drops them along with the noise.
Reaching for bilateral by default. It's far more costly than the other two and has two params instead of one. If the edges don't need protecting, it's a slow Gaussian.
Questions people ask about Blur
Which blur should I use to remove salt-and-pepper noise? The median filter. It discards extreme outliers by sorting the neighbourhood, whereas a Gaussian smears each speck into its neighbours.
Why does a Gaussian blur soften edges? It averages across an edge, mixing dark and bright pixels together. It has no way to tell an edge from a gradual change.
How does a bilateral filter keep edges sharp? It weights neighbours by both distance and similarity of colour. Pixels across an edge look very different, so they get almost no weight and the edge survives.
How do I pick the kernel size or sigma? Sigma sets how much smoothing happens, and the kernel only needs to be about six sigma wide to hold the bell shape. Raise sigma until noise is gone, then stop, since every extra step loses detail.
Why is the bilateral filter so slow? Its weights depend on pixel values, so they must be recomputed at every position, and it cannot be split into two separable passes. Large diameters get costly quickly.
Should I blur before edge detection? Usually yes. Gradient kernels amplify noise, so a light Gaussian blur first gives cleaner edges, which is why Canny begins with one.
Three blurs, and the one that keeps edges
Gaussian, median and bilateral filtering on the same noisy image. Each drops a different kind of noise and damages a thing different. The numbers show just which.
example_01.pyNumPy
import numpy as np
rng = np.random.default_rng(0)
H, W = 11, 22
clean = np.full((H, W), 60.0)
clean[:, 11:] = 200.0 # a hard vertical edge
noisy = clean + rng.normal(0, 12, (H, W)) # gaussian sensor noise
sp = noisy.copy() # plus salt-and-pepper
for _ in range(14):
r, c = rng.integers(0, H), rng.integers(0, W)
sp[r, c] = 255.0 if rng.random() < 0.5 else 0.0
SHADE = " .:-=+*#%@"
def show(a, label):
print(" %s" % label)
for row in a:
print(" " + "".join(SHADE[int(np.clip(v / 255.0, 0, 1) * 9)] for v in row))
show(clean, "the clean image: dark left, bright right")
show(sp, "with gaussian noise AND salt-and-pepper specks")
print()
def gaussian_blur(a, k=5, sigma=1.5):
ax = np.arange(k) - k // 2
g = np.exp(-(ax ** 2) / (2 * sigma ** 2))
g = np.outer(g, g); g /= g.sum()
p = np.pad(a, k // 2, mode="edge")
out = np.zeros_like(a)
for i in range(a.shape[0]):
for j in range(a.shape[1]):
out[i, j] = (p[i:i + k, j:j + k] * g).sum()
return out
show(gaussian_blur(sp), "gaussian 5x5")
print(" the speckles are dimmed but NOT gone -- gaussian averaging")
print(" spreads each outlier over the window rather than removing it.")
Output
The median filter sorts each neighbourhood and picks the middle value, which drops outliers entirely.
example_02.pyNumPy
import numpy as np
rng = np.random.default_rng(0)
H, W = 11, 22
clean = np.full((H, W), 60.0)
clean[:, 11:] = 200.0
noisy = clean + rng.normal(0, 12, (H, W))
sp = noisy.copy()
for _ in range(14):
r, c = rng.integers(0, H), rng.integers(0, W)
sp[r, c] = 255.0 if rng.random() < 0.5 else 0.0
SHADE = " .:-=+*#%@"
def show(a, label):
print(" %s" % label)
for row in a:
print(" " + "".join(SHADE[int(np.clip(v / 255.0, 0, 1) * 9)] for v in row))
def neighbourhood(a, i, j, k):
p = k // 2
r0, r1 = max(0, i - p), min(a.shape[0], i + p + 1)
c0, c1 = max(0, j - p), min(a.shape[1], j + p + 1)
return a[r0:r1, c0:c1]
def median_blur(a, k=5):
out = np.zeros_like(a)
for i in range(a.shape[0]):
for j in range(a.shape[1]):
out[i, j] = np.median(neighbourhood(a, i, j, k))
return out
show(median_blur(sp), "median 5x5")
print()
win = np.array([60., 62., 255., 58., 61., 59., 60., 63., 57.])
print(" why it works -- a salt speck of 255 among neighbours of 60:")
print(" window %s" % win.astype(int))
print(" mean %.1f <- the speck pulled it up by %.1f" % (win.mean(), win.mean() - 60))
print(" median %.1f <- the speck is simply not the middle value" % np.median(win))
print(" a median is a RANK statistic. an outlier cannot drag the answer")
print(" toward itself the way a mean can. that is why median filtering is")
print(" the standard tool for impulse noise.")
Output
Bilateral filtering weights neighbours by both distance and similarity, so it smooths within a region but refuses to cross a boundary.
example_03.pyNumPy
import numpy as np
rng = np.random.default_rng(0)
H, W = 11, 22
clean = np.full((H, W), 60.0)
clean[:, 11:] = 200.0
noisy = clean + rng.normal(0, 12, (H, W))
sp = noisy.copy()
for _ in range(14):
r, c = rng.integers(0, H), rng.integers(0, W)
sp[r, c] = 255.0 if rng.random() < 0.5 else 0.0
SHADE = " .:-=+*#%@"
def show(a, label):
print(" %s" % label)
for row in a:
print(" " + "".join(SHADE[int(np.clip(v / 255.0, 0, 1) * 9)] for v in row))
def bilateral(a, k=5, sigma_s=1.5, sigma_r=25.0):
ax = np.arange(k) - k // 2
gs = np.exp(-(ax ** 2) / (2 * sigma_s ** 2))
gs = np.outer(gs, gs)
p = np.pad(a, k // 2, mode="edge")
out = np.zeros_like(a)
for i in range(a.shape[0]):
for j in range(a.shape[1]):
win = p[i:i + k, j:j + k]
gr = np.exp(-((win - a[i, j]) ** 2) / (2 * sigma_r ** 2))
w = gs * gr
out[i, j] = (win * w).sum() / w.sum()
return out
show(bilateral(sp), "bilateral 5x5")
print()
i, j = 5, 11
p = np.pad(sp, 2, mode="edge")
win = p[i:i + 5, j:j + 5]
gr = np.exp(-((win - sp[i, j]) ** 2) / (2 * 25.0 ** 2))
print(" at a pixel ON the edge, the bilateral RANGE weights are:")
for r in np.round(gr, 3):
print(" %s" % r)
print(" similar neighbours get weight near 1; those on the other side")
print(" of the edge get near 0. the average stays on one surface.")
Output
Running all three filters on both noise types shows the quantitative separation between them.
example_04.pyNumPy
import numpy as np
rng = np.random.default_rng(0)
H, W = 11, 22
clean = np.full((H, W), 60.0)
clean[:, 11:] = 200.0
noisy = clean + rng.normal(0, 12, (H, W))
sp = noisy.copy()
for _ in range(14):
r, c = rng.integers(0, H), rng.integers(0, W)
sp[r, c] = 255.0 if rng.random() < 0.5 else 0.0
def gauss(a, k=5, s=1.5):
ax = np.arange(k)-k//2; g = np.outer(np.exp(-ax**2/(2*s**2)), np.exp(-ax**2/(2*s**2))); g /= g.sum(); p = np.pad(a, k//2, mode="edge")
return np.array([[float((p[i:i+k,j:j+k]*g).sum()) for j in range(a.shape[1])] for i in range(a.shape[0])])
def med(a, k=5):
h = k//2
return np.array([[float(np.median(a[max(0,i-h):min(a.shape[0],i+h+1), max(0,j-h):min(a.shape[1],j+h+1)])) for j in range(a.shape[1])] for i in range(a.shape[0])])
def bilat(a, k=5, ss=1.5, sr=25.0):
ax = np.arange(k)-k//2; gs = np.outer(np.exp(-ax**2/(2*ss**2)), np.exp(-ax**2/(2*ss**2)))
p = np.pad(a, k//2, mode="edge"); out = np.zeros_like(a)
for i in range(a.shape[0]):
for j in range(a.shape[1]):
w = gs*np.exp(-((p[i:i+k,j:j+k]-a[i,j])**2)/(2*sr**2)); out[i,j] = (p[i:i+k,j:j+k]*w).sum()/w.sum()
return out
def edge_sharpness(a):
return abs(a[:, 12].mean() - a[:, 9].mean())
def flat_noise(a):
return (a[:, 1:9].std() + a[:, 14:21].std()) / 2
print("MEASURED, on TWO different noises:")
print()
for src, label in ((noisy, "gaussian noise only"), (sp, "gaussian + specks")):
print(" %s" % label)
print("%22s %16s %16s %18s" % ("", "noise in flat", "worst speck", "edge sharpness"))
print("%22s %16.3f %16.1f %18.1f" % ("input", flat_noise(src), abs(src[:, 1:9] - 60).max(), edge_sharpness(src)))
for name, fn in (("gaussian 5x5", gauss), ("median 5x5", med), ("bilateral 5x5", bilat)):
o = fn(src)
print("%22s %16.3f %16.1f %18.1f" % (name, flat_noise(o), abs(o[:, 1:9] - 60).max(), edge_sharpness(o)))
print("%22s %16.3f %16.1f %18.1f" % ("(clean original)", flat_noise(clean), 0.0, edge_sharpness(clean)))
print()
print(" gaussian blurs specks into the area. median removes them cleanly.")
print(" bilateral keeps edges but keeps specks too. median first, then bilateral.")
Output
Check yourself
0 of 3
Answer without scrolling back up.
Why does a Gaussian blur fail to remove salt-and-pepper noise?
Averaging incorporates every value it is given. A pixel at 255 among neighbours at 40 pulls the mean up, turning one bright dot into a larger dim smudge.
Why does the median filter remove an outlier completely?
The output is whichever value sits in the middle of the sorted neighbourhood. An extreme value is at an end by definition, so it is simply never selected.
What happens to a bilateral filter as its range sigma grows very large?
A large range sigma means the similarity term stops discriminating between neighbours, so only the spatial term is left - which is exactly a Gaussian blur.
Cheat sheet
Blur: Gaussian, Median and Bilateral
Apply Gaussian, median and bilateral blur to one noisy image side by side and see which filter removes noise while preserving edges.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.