Histograms & Equalisation
By the end of this lesson you will be able to draw an image's histogram, read it like a photographer to spot dark, bright or flat photos, and write histogram equalisation yourself. You will also learn CLAHE, the version used for real photos and medical scans.
What is a histogram?
A histogram counts how many pixels have each value. For an 8-bit grey image it has 256 bars, one for each value from 0 to 255. It throws away where the pixels are and keeps only how many of each brightness there are.
Think of a class teacher in Jaipur making a marks table: how many students scored 0 to 10, 11 to 20, and so on. The table does not say who sat where, but it tells you at once whether the test was too hard. An image histogram tells you at once whether a photo is too dark.
NumPy's np.bincount counts how often each whole number appears, which is exactly a histogram.
import numpy as np
img = np.array([[0, 0, 1, 3],
[3, 3, 2, 0],
[1, 3, 3, 3]], dtype=np.uint8)
counts = np.bincount(img.ravel(), minlength=4)
for value, n in enumerate(counts):
print(f"value {value}: {'#' * n} ({n})")
print("total pixels:", counts.sum(), "=", img.size)value 0: ### (3) value 1: ## (2) value 2: # (1) value 3: ###### (6) total pixels: 12 = 12
img.ravel() flattens the 2D image into one long row, and minlength=256 (here 4) makes sure every possible value gets a bar, even with a count of zero.
Reading a histogram
Let us make three fake photos and look at their histograms side by side: a dark room, a sunny beach, and a hazy, low-contrast view.
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(0)
photos = {
"dark room": np.clip(rng.normal(50, 20, (100, 100)), 0, 255).astype(np.uint8),
"sunny beach": np.clip(rng.normal(200, 25, (100, 100)), 0, 255).astype(np.uint8),
"hazy view": np.clip(rng.normal(128, 10, (100, 100)), 0, 255).astype(np.uint8),
}
fig, axes = plt.subplots(2, 3, figsize=(11, 5))
for i, (name, img) in enumerate(photos.items()):
axes[0, i].imshow(img, cmap="gray", vmin=0, vmax=255)
axes[0, i].set_title(name); axes[0, i].axis("off")
axes[1, i].bar(range(256), np.bincount(img.ravel(), minlength=256), width=1)
axes[1, i].set_xlim(0, 255)
print(f"{name:12} mean {img.mean():6.1f} std {img.std():5.1f}")
plt.tight_layout()
plt.show()dark room mean 49.7 std 19.8 sunny beach mean 199.5 std 24.6 hazy view mean 127.5 std 10.0
Here is how to read them:
| Histogram shape | What it means |
|---|---|
| Bunched on the left | Under-exposed, dark photo |
| Bunched on the right | Over-exposed, bright photo |
| Narrow hump in the middle | Low contrast, flat or hazy |
| Spread across the whole range | Good use of the available tones |
| Two separate humps | Two main regions, such as dark text on white paper |
That last row is important. A page of text has a big hump of paper pixels and a smaller hump of ink pixels. The valley between them is the perfect place to separate ink from paper, which is exactly what Thresholding does next.
Histogram equalisation from scratch
Histogram equalisation spreads the values out so that every brightness level is used about equally. It boosts contrast automatically, with no settings to choose.
The key tool is the cumulative distribution function (CDF). For each value v, the CDF says what fraction of pixels are v or darker. It always rises from near 0 to exactly 1. Equalisation uses the CDF as a lookup table: a pixel darker than 30% of the image becomes 30% of 255.
It is like ranking students by marks and then giving grades by rank: the top 10% get the top grade whatever their raw marks were.
Step 1: count. Step 2: build the CDF. Step 3: turn the CDF into a 256-entry lookup table. Step 4: apply it.
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(1)
hazy = np.clip(rng.normal(128, 12, (120, 160)), 0, 255).astype(np.uint8)
# Step 1: the histogram
hist = np.bincount(hazy.ravel(), minlength=256)
# Step 2: the CDF, as a fraction of all pixels
cdf = hist.cumsum() / hazy.size
# Step 3: stretch the CDF so the darkest used value maps to 0
cdf_min = cdf[cdf > 0].min()
lut = np.round((cdf - cdf_min) / (1 - cdf_min) * 255).clip(0, 255).astype(np.uint8)
# Step 4: apply it to every pixel
equal = lut[hazy]
for name, im in [("before", hazy), ("after", equal)]:
print(f"{name:6} min {im.min():3} max {im.max():3} std {im.std():5.1f}")
fig, axes = plt.subplots(1, 3, figsize=(12, 3.2))
axes[0].imshow(hazy, cmap="gray", vmin=0, vmax=255); axes[0].set_title("before")
axes[1].imshow(equal, cmap="gray", vmin=0, vmax=255); axes[1].set_title("after")
axes[2].plot(cdf * 255, label="CDF x 255 (the LUT)")
axes[2].legend()
plt.show()before min 81 max 175 std 11.9 after min 0 max 255 std 73.6
The before image used only a narrow band of greys. After equalisation it uses the full range from 0 to 255, and the spread (std) grows several times. Subtracting cdf_min makes sure the darkest pixel becomes exactly 0.
OpenCV: equalizeHist and CLAHE
Global equalisation uses one lookup table for the whole image. That fails when one part is dark and another bright, for example a classroom photo with a bright window. It also amplifies noise in flat areas.
CLAHE (Contrast Limited Adaptive Histogram Equalisation) fixes both problems:
- Adaptive: it splits the image into tiles (8 by 8 by default) and equalises each tile separately, then blends the tile edges smoothly.
- Contrast limited: it caps each histogram bar at a clip limit before building the CDF, so flat areas and noise are not boosted too much.
CLAHE is widely used for X-ray and other medical images, for underwater photos, and as a pre-processing step before OCR.
import cv2
import numpy as np
rng = np.random.default_rng(5)
grey = rng.integers(90, 151, size=(240, 320)).astype(np.uint8) # a flat, low-contrast photo
hist = cv2.calcHist([grey], [0], None, [256], [0, 256]) # images, channel, mask, bins, range
print("hist shape:", hist.shape, " pixels counted:", int(hist.sum()))
eq = cv2.equalizeHist(grey)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
local = clahe.apply(grey)
for name, im in [("original", grey), ("equalizeHist", eq), ("CLAHE", local)]:
print(f"{name:12} min {im.min():3} max {im.max():3} std {im.std():5.1f}")hist shape: (256,) pixels counted: 76800 original min 90 max 150 std 17.7 equalizeHist min 0 max 255 std 74.9 CLAHE min 60 max 210 std 44.6
CLAHE stretches less than global equalisation because of the clip limit. That is the point: it improves local detail without turning noise into speckles. Raise clipLimit for a stronger effect. (This output is from OpenCV 5.0. OpenCV 4 returns the histogram with shape (256, 1), so older code often calls .ravel() on it.)
Comparing images by histogram
Two photos of the same scene usually have similar histograms, even if the camera moved a little. A simple score is histogram intersection: for each value, take the smaller of the two counts, and add them up. Identical histograms score 1.0 when each is normalised to sum to 1.
import numpy as np
rng = np.random.default_rng(2)
def norm_hist(img):
h = np.bincount(img.ravel(), minlength=256).astype(float)
return h / h.sum()
sunset_1 = np.clip(rng.normal(170, 30, (60, 80)), 0, 255).astype(np.uint8)
sunset_2 = np.clip(rng.normal(165, 32, (60, 80)), 0, 255).astype(np.uint8)
night = np.clip(rng.normal(40, 15, (60, 80)), 0, 255).astype(np.uint8)
h1, h2, h3 = norm_hist(sunset_1), norm_hist(sunset_2), norm_hist(night)
print("sunset_1 vs sunset_2:", round(np.minimum(h1, h2).sum(), 3))
print("sunset_1 vs night :", round(np.minimum(h1, h3).sum(), 3))sunset_1 vs sunset_2: 0.888 sunset_1 vs night : 0.002
This is cheap and works surprisingly well for finding near-duplicate frames in a video. But it ignores where things are: a photo and its upside-down copy have the same histogram. Deep learning features, which you meet later in the course, do much better at "is this the same object?".
Common mistakes
- Equalising colour channels separately. Equalise only the brightness channel.
- Equalising noisy images globally. Noise in flat areas gets amplified; use CLAHE with a modest clip limit.
- Forgetting
minlength=256. Without it,np.bincountstops at the largest value present. - Treating histograms as location data. Histograms say how many, never where.
Key points
- A histogram counts how many pixels have each value;
np.bincount(img.ravel(), minlength=256)builds it. - Its shape reveals dark, bright, flat or two-region (bimodal) images.
- Equalisation uses the cumulative histogram (CDF) as a lookup table to spread values over 0 to 255.
- CLAHE equalises small tiles with a clip limit; it is the safer choice for real photos.
- Histogram intersection gives a quick similarity score between images.
Exercise
Build a "document" image: 100 by 100, mostly paper pixels around 200, with a block of ink pixels around 40 (for example rows 40 to 59, columns 10 to 89). Print how many pixels fall below 128 and how many above. Then find the value with the lowest count between the two humps (search values 60 to 180) as a candidate threshold.
Show solution
import numpy as np
rng = np.random.default_rng(4)
doc = np.clip(rng.normal(200, 10, (100, 100)), 0, 255)
doc[40:60, 10:90] = np.clip(rng.normal(40, 10, (20, 80)), 0, 255)
doc = doc.astype(np.uint8)
hist = np.bincount(doc.ravel(), minlength=256)
print("below 128:", hist[:128].sum(), " above:", hist[128:].sum()) # 1600 and 8400
valley = 60 + np.argmin(hist[60:181])
print("candidate threshold:", valley)The ink block has 20 × 80 = 1,600 pixels, so the counts below and above 128 are 1,600 and 8,400. Many values between the humps have a count of zero, so argmin returns the first of them; any value in that empty valley separates ink from paper.
Quick Quiz
1. What does an image histogram NOT tell you?
A histogram only counts values; all location information is thrown away.
2. A histogram is bunched in a narrow hump around 128. What is the problem?
A narrow hump means only a few grey levels are used, so the image looks flat.
3. What does histogram equalisation use as its lookup table?
Each value is mapped to the fraction of pixels at or below it, times 255.
4. Why is CLAHE preferred over global equalisation for many real photos?
CLAHE equalises tiles separately and clips the histogram, so local detail improves without boosting noise.
Learn Computer Vision with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
