EthicsComputerCourses
Join training +91 90532 10052

Histograms & Equalisation

Computer Vision · Lesson 7 of 28 Intermediate 7 min read Updated

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.

PythonRun it yourself
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)
Output
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.

PythonRun it yourself
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()
Output
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 shapeWhat it means
Bunched on the leftUnder-exposed, dark photo
Bunched on the rightOver-exposed, bright photo
Narrow hump in the middleLow contrast, flat or hazy
Spread across the whole rangeGood use of the available tones
Two separate humpsTwo 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.

PythonRun it yourself
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()
Output
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.

Python
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}")
Output
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.

PythonRun it yourself
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))
Output
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.bincount stops 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
Python
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?

2. A histogram is bunched in a narrow hump around 128. What is the problem?

3. What does histogram equalisation use as its lookup table?

4. Why is CLAHE preferred over global equalisation for many real photos?

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