The histogram tells you what an image is made of. Equalisation rewrites it to use the whole range.
Overview
Counting, not looking
An image histogram is a count: for each of the 256 possible brightness values, how many pixels hold it. Nothing else. It discards spot fully — an image and the same image shuffled into random order have the same histograms.
That sounds like a weakness and is mostly a strength. Exposure, contrast and the presence of distinct regions are all properties of the *spread* of values, not of where they sit. So The histogram is just the right summary for judging them. Photographers read one on the back of a camera for this reason.
Watch the two histograms under the visualisation above. The input image is on purpose low in contrast. So Its histogram is a narrow spike: almost each pixel sits between roughly 100 and 160, and the ranges below and above are empty. Two hundred of the 256 available values are being wasted.
Histograms and Equalisation
This module needs JavaScript: the
images are computed in the page rather than downloaded.
Worth knowing
A histogram counts how many pixels hold each brightness value. It says nothing about where they are.
A narrow histogram means low contrast: the image is using a fraction of the range available to it.
Equalisation applies the cumulative distribution as a lookup table, stretching crowded regions apart and squeezing empty ones together.
It is a global operation. CLAHE does the same thing per tile, which avoids amplifying noise in already-flat areas.
Histograms and Equalisation
What an image is made of. How to make it use the range it has.
What a shape means
A spike in the middle is low contrast. The image is grey and flat, and detail exists but the differences are too small to see.
A wide, flat spread is high contrast, using the full range.
Two separated humps — a bimodal histogram — means the image has two distinct groups of pixel, often an object and a background. This is the shape that makes thresholding work, and the valley between the humps is where the threshold belongs.
A pile against the right edge is clipping: pixels that were brighter than 255 have all been recorded as 255. That detail is gone and no amount of processing brings it back. This Is why photographers expose to avoid it.
How equalisation works
Histogram equalisation asks a exact question: what mapping from old values to new ones would make the histogram as flat as possible?
The answer is the cumulative spread function. For each value *v*, find the fraction of pixels whose value is at most *v*. That fraction is between 0 and 1. Times by 255 and you've the new value for *v*.
Why this works is worth a moment. If a value is very common, the CDF climbs steeply across it, so values on either side get mapped far apart — crowded regions are spread out. If a range of values is rare or absent, the CDF is nearly flat there, so that whole range merges to almost a single output value. The change gives range to where the pixels really are, and takes it away from where they aren't.
Toggle the control above and watch both the image and the output histogram. The narrow spike is pulled apart across the full width, and detail that was present but invisible — the difference between the triangle and the background — turns plainly visible.
What it costs
Equalisation isn't free, and the costs follow right from the mechanism.
It amplifies noise. In a region that is truly flat, the small random variations between neighbouring pixels are still variations, and stretching the range stretches them too. A clear sky turns a mottled one.
It's global. One lookup table is found from the whole image and applied all over. An image that is right exposed on the left and dark on the right gets a compromise that suits neither.
The result isn't natural. Faces in given look wrong after equalisation, because skin tones occupy a narrow band and equalisation on purpose spreads narrow bands apart.
CLAHE
The standard fix for the second and third problems is CLAHE, Contrast Limited Adaptive Histogram Equalisation. It cuts the image into tiles, equalises each tile against its own histogram, and interpolates between tiles so the boundaries don't show. The dark region gets its own aggressive stretch. The well-exposed region is left roughly alone.
The "contrast limited" half handles the noise. Before finding the CDF, any histogram bin taller than a set limit is clipped and the excess redistributed across the other bins. This caps how steeply the CDF can climb, which caps how far apart nearly-the same values can be pushed — and since noise amplification *is* pushing nearly-the same values apart, capping it right limits the damage.
Equalisation is the CDF, worked
Histogram equalisation has one moving part: it remaps each brightness through the image's own cumulative spread, scaled to the full range. On a 16-pixel image whose values cluster low, new[v] = round(cdf[v] × 7) over an 8-level range:
old value
cumulative fraction
new value
2
4/16
2
3
10/16
4
5
13/16
6
6
15/16
7
7
16/16
7
The crowded values (3 appears six times) get pulled far apart, because the CDF climbs steeply across them. The rare high values collapse together, because the CDF is nearly flat there. The change gives range to where the pixels really are and takes it from where they aren't. This Is what flattens the histogram and makes buried contrast visible. The costs follow from the same mechanism: in a truly flat region the small random differences between pixels are stretched too, so noise is amplified — a clear sky turns mottled — and because one lookup table is found from the whole image, a frame that is well-exposed on one side and dark on the other gets a compromise suiting neither. CLAHE fixes both by equalising per-tile with a contrast cap that limits how steeply the CDF may climb.
Where it goes wrong with Histograms and Equalisation
Equalising an already well-exposed image. There's nothing to gain and noise to lose. Look at the histogram first. If it already spans the range, leave it alone.
Equalising each RGB channel apart. The three channels get three different mappings, so the colours shift. Convert to a space with a separate brightness channel, equalise that one channel, and convert back.
Checking histograms across images to judge likeness. Two fully different photos can share a histogram. It shows the palette, not the picture.
Questions people ask about Histograms and Equalisation
What does an image histogram show? How many pixels have each intensity value. It shows overall brightness and contrast but nothing about where pixels are.
How does histogram equalisation work? It maps each intensity through the image's cumulative distribution, spreading the values to use the full range. Common intensities get stretched apart.
Why can equalisation make an image look worse? It treats the whole image the same way, so noise in flat regions is amplified and bright areas can wash out. It also changes colours if applied per channel.
What is CLAHE? Contrast Limited Adaptive Histogram Equalisation. It equalises small tiles separately and clips the histogram so noise is not blown up.
How should I equalise a colour image? Convert to a space that separates brightness, such as YCrCb or HSV, and equalise only the luminance or value channel. Doing R, G and B separately shifts the hues.
Does equalisation add information? No. It redistributes existing values, so detail that was already lost to clipping or quantisation does not come back.
The histogram, and the CDF that flattens it
A histogram throws away each bit of spatial info and keeps only how often each brightness occurs. That sounds like a loss. It's -- but it's just what you need to fix exposure. Equalisation turns out to be nothing more than applying the image's own cumulative spread as a lookup table.
example_01.pyNumPy
import numpy as np
rng = np.random.default_rng(11)
H, W = 40, 60
yy, xx = np.mgrid[0:H, 0:W]
scene = 90 + 40*np.sin(xx/7.0)*np.cos(yy/9.0) + 25*((xx//12 + yy//10) % 2)
scene += rng.normal(0, 4, (H, W))
img = np.clip(60 + (scene - scene.mean()) * 0.28, 0, 255).astype(int)
def hist(a, bins=16):
h, _ = np.histogram(a, bins=bins, range=(0, 256))
return h
def draw(h, label, width=44):
print(" %s" % label)
top = h.max()
for b in range(len(h)):
lo = b * 256 // len(h)
n = int(round(width * h[b] / top)) if top else 0
print(" %3d-%3d |%-44s %5d" % (lo, lo+255//len(h), "#"*n, h[b]))
print("THE IMAGE is under-exposed: %d..%d out of 0..255." % (img.min(), img.max()))
print(" mean %.1f, std %.1f" % (img.mean(), img.std()))
draw(hist(img), "histogram, 16 bins")
used = int((hist(img, 256) > 0).sum())
print(" only %d of 256 levels are used." % used)
print()
print("CONTRAST STRETCHING -- map darkest to 0 and brightest to 255:")
lo, hi = img.min(), img.max()
stretched = np.round((img - lo) * 255.0 / (hi - lo)).astype(int)
draw(hist(stretched), "after stretching")
print(" range %d..%d, std %.1f (was %.1f)."
% (stretched.min(), stretched.max(), stretched.std(), img.std()))
print(" distinct levels used: %d of 256."
% int((hist(stretched, 256) > 0).sum()))
Output
The next block builds the equalisation lookup table from the cumulative histogram and compares it against stretching.
example_02.pyNumPy
import numpy as np
rng = np.random.default_rng(11)
H, W = 40, 60
yy, xx = np.mgrid[0:H, 0:W]
scene = 90 + 40*np.sin(xx/7.0)*np.cos(yy/9.0) + 25*((xx//12 + yy//10) % 2)
img = np.clip(60 + ((scene + rng.normal(0, 4, (H, W))) - scene.mean()) * 0.28, 0, 255).astype(int)
def hist(a, bins=16):
h, _ = np.histogram(a, bins=bins, range=(0, 256))
return h
def draw(h, label, width=44):
print(" %s" % label)
top = h.max()
for b in range(len(h)):
lo = b * 256 // len(h)
print(" %3d-%3d |%-44s %5d" % (lo, lo+255//len(h),
"#"*int(round(width*h[b]/top)) if top else "", h[b]))
lo, hi = img.min(), img.max()
stretched = np.round((img - lo) * 255.0 / (hi - lo)).astype(int)
counts = hist(img, 256)
cdf = np.cumsum(counts).astype(float)
cdf_min = cdf[cdf > 0][0]
lut = np.clip(np.round((cdf - cdf_min) / (img.size - cdf_min) * 255), 0, 255).astype(int)
eq = lut[img]
print("EQUALISATION -- the lookup table IS the cumulative histogram:")
print("%14s %14s %14s" % ("input level", "cumulative count", "output level"))
for v in range(img.min(), img.max()+1, max(1, (img.max()-img.min())//6)):
print("%14d %14d %14d" % (v, int(cdf[v]), lut[v]))
draw(hist(eq), "after equalisation")
print(" range %d..%d, std %.1f." % (eq.min(), eq.max(), eq.std()))
print()
print("HOW FLAT IS FLAT?")
print("%-24s %14s %14s" % ("", "bin std", "levels used"))
for name, a in (("original", img), ("stretched", stretched), ("equalised", eq)):
h = hist(a).astype(float)
print("%-24s %14.1f %14d" % (name, h.std(), int((hist(a, 256) > 0).sum())))
print(" equalisation flattens the histogram that stretching only widened.")
Output
The next block shows equalisation's real failure mode: a large dark region elsewhere in the frame drags the global table and crushes the subject's contrast.
example_03.pyNumPy
import numpy as np
rng = np.random.default_rng(11)
H, W = 40, 60
yy, xx = np.mgrid[0:H, 0:W]
scene = 90 + 40*np.sin(xx/7.0)*np.cos(yy/9.0) + 25*((xx//12 + yy//10) % 2)
scene += rng.normal(0, 4, (H, W))
img = np.clip(60 + (scene - scene.mean()) * 0.28, 0, 255).astype(int)
def hist(a, bins=16):
h, _ = np.histogram(a, bins=bins, range=(0, 256))
return h
counts = hist(img, 256)
cdf = np.cumsum(counts).astype(float)
cdf_min = cdf[cdf > 0][0]
lut = np.clip(np.round((cdf - cdf_min) / (img.size - cdf_min) * 255), 0, 255).astype(int)
eq = lut[img]
print("THE REAL FAILURE MODE. equalisation is global -- ONE table for")
print("the whole frame. paste a dark strip and re-equalise:")
bordered = img.copy()
bordered[:, :20] = np.clip(rng.normal(12, 5, (H, 20)), 0, 255).astype(int)
c2 = hist(bordered, 256)
cd2 = np.cumsum(c2).astype(float)
m2 = cd2[cd2 > 0][0]
lut2 = np.clip(np.round((cd2-m2) / (bordered.size-m2) * 255), 0, 255).astype(int)
eq2 = lut2[bordered]
region = (slice(None), slice(20, None))
print("%-30s %12s %12s" % ("", "std, no border", "std, border"))
print("%-30s %12.1f %12.1f"
% ("the subject region only", eq[region].std(), eq2[region].std()))
print("%-30s %12d %12d"
% ("output range it occupies",
int(eq[region].max()-eq[region].min()), int(eq2[region].max()-eq2[region].min())))
print(" the same pixels, the same operation, %.0f%% less contrast."
% (100 * (1 - eq2[region].std() / eq[region].std())))
print(" the fix is CLAHE: equalise small tiles independently, clip the")
print(" histogram before accumulating so no single level can dominate,")
print(" and interpolate between neighbouring tiles' tables.")
Output
Check yourself
0 of 3
Answer without scrolling back up.
What does a histogram tell you nothing about?
A histogram is a count per value. An image and a randomly shuffled copy of it have identical histograms, because position is discarded entirely.
Which function does histogram equalisation use as its lookup table?
The fraction of pixels at or below each value, scaled to 0-255. Common values make the CDF climb steeply and so get spread apart; rare values collapse together.
Why does CLAHE clip the histogram before computing the CDF?
A tall bin means a steep CDF, which pushes nearly-identical neighbouring values far apart. That is exactly what noise amplification is, so limiting bin height limits it.
Cheat sheet
Histograms and Equalisation
Plot an image histogram, apply equalisation, and watch a low-contrast photograph spread across the full intensity range in one pass.
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.