Histograms and contrast

OpenCV · Practical · 10 min

Histograms reveal exposure problems and drive thresholds; CLAHE is a standard fix for low-contrast images.

A histogram counts how many pixels have each intensity. cv2.calcHist([img], [channel], mask, [bins], [range]) takes lists because it can combine several images and channels; for a gray image use cv2.calcHist([gray], [0], None, [256], [0, 256]), which returns float32 counts of shape (256, 1) in OpenCV 4 (flat (256,) in OpenCV 5). The upper range bound is exclusive, so 256 covers 0 to 255. np.bincount(gray.ravel(), minlength=256) gives the same counts.

Histograms tell you a lot at a glance: everything bunched at the left means underexposed, two clear peaks mean a threshold (or Otsu) will work well. 2-D hue-saturation histograms are used for colour-based object matching (cv2.compareHist) and back-projection.

Histogram equalisation, cv2.equalizeHist(gray), remaps intensities so the cumulative histogram becomes roughly linear, spreading a narrow range across 0 to 255. It works on the whole image, so it can over-amplify noise and wash out regions.

CLAHE (Contrast Limited Adaptive Histogram Equalisation) equalises small tiles separately and clips each histogram to limit noise amplification:

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
out = clahe.apply(gray)

For colour images, never equalise B, G and R separately (that shifts colours). Convert to LAB, apply CLAHE to the L channel, and convert back.

Example

import cv2
import numpy as np

rng = np.random.default_rng(0)
dull = rng.integers(100, 140, (64, 64), dtype=np.uint8)   # low contrast

hist = cv2.calcHist([dull], [0], None, [256], [0, 256])
print(hist.shape, int(hist[:100].sum()))      # (256, 1) 0  -- (256,) in OpenCV 5
print(dull.min(), dull.max())                 # 100 139

eq = cv2.equalizeHist(dull)
print(eq.min(), eq.max())                     # spread to roughly 0..255

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4))
print(clahe.apply(dull).std() > dull.std())   # True: more contrast

bgr = cv2.merge([dull, dull, rng.integers(0, 255, (64, 64), dtype=np.uint8)])
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
lab = cv2.merge([clahe.apply(l), a, b])       # only lightness changed
out = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
print(out.shape)                              # (64, 64, 3)

Remember

  • calcHist takes lists: images, channels, bins, ranges
  • equalizeHist spreads intensities globally
  • CLAHE equalises tiles with a clip limit and amplifies less noise
  • For colour, equalise the L channel of LAB, not B, G, R

Watch out for

  • Equalising each BGR channel independently, which distorts the colours.

Check yourself

  1. Why is CLAHE usually preferred to equalizeHist on photos with mixed lighting?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.opencv.org/4.x/d5/daf/tutorial_py_histogram_equalization.html