Histograms and contrast enhancement
Histogram calculation, equalisation, CLAHE, back-projection, and automatic contrast stretching that does not clip highlights.
Reading a histogram
import cv2
import numpy as np
image = cv2.imread("photo.jpg")
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# one channel, 256 bins, full range
hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).ravel()
print("pixels", int(hist.sum()), "darkest bin", int(np.argmax(hist)))
# masked: only count the region you care about
mask = np.zeros(gray.shape, dtype="uint8")
mask[100:400, 150:500] = 255
masked_hist = cv2.calcHist([gray], [0], mask, [256], [0, 256]).ravel()
# all three channels at once
colour_hist = cv2.calcHist([image], [0, 1, 2], None,
[8, 8, 8], [0, 256, 0, 256, 0, 256])
print(colour_hist.shape) # (8, 8, 8)
# where the mass sits tells you what the image needs
total = hist.sum()
dark = hist[:60].sum() / total
bright = hist[200:].sum() / total
print(f"dark fraction {dark:.3f} bright fraction {bright:.3f}")
# normalise a histogram so two images can be compared
normalised = cv2.normalize(hist, hist, 0, 1, cv2.NORM_MINMAX)- A histogram is a per-intensity count, not a spatial map. Two completely different images can share a histogram.
- Mass piled at 0 means underexposure and clipped shadows; mass piled at 255 means blown highlights that no amount of processing recovers.
- Use a mask to restrict the histogram to a region of interest. A histogram of the whole frame is dominated by background and tells you nothing about the subject.
- The bin count is a choice: 256 bins for 8-bit data is standard, but coarser bins (32 or 64) show the overall shape better.
Equalisation and CLAHE
# global equalisation: spreads the intensity distribution across the full range
equalised = cv2.equalizeHist(gray)
# CLAHE: equalise in tiles with a contrast limit, so flat regions do not amplify noise
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))
limited = clahe.apply(gray)
# apply to a colour image by working on the luminance channel only
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
l_eq = clahe.apply(l)
enhanced = cv2.cvtColor(cv2.merge([l_eq, a, b]), cv2.COLOR_LAB2BGR)
# compare local contrast rather than global mean
def local_contrast(gray_image, block=32):
h, w = gray_image.shape
values = []
for y in range(0, h - block, block):
for x in range(0, w - block, block):
values.append(gray_image[y:y + block, x:x + block].std())
return float(np.mean(values))
print("original", round(local_contrast(gray), 2))
print("equalised", round(local_contrast(equalised), 2))
print("clahe", round(local_contrast(limited), 2))| Method | Behaviour | Best for | Risk |
|---|---|---|---|
equalizeHist | One global mapping | Uniformly lit, low-contrast images | Amplifies noise, washes out colour |
| CLAHE | Tile-based with a clip limit | Uneven lighting, medical, industrial | Visible tile seams if over-clipped |
| Gamma correction | Power-law intensity mapping | Fixing an over- or under-exposed capture | Requires choosing a gamma |
| Linear stretch | Maps min-max to 0-255 | Known intensity range with outliers present | Outliers compress the useful range |
| Percentile stretch | Maps the 2nd-98th percentile | Robust automatic stretching | Clips a little of both tails |
- Never equalise each BGR channel independently: the relative channel values change and the colour balance shifts. Convert to LAB or HSV and enhance the luminance channel alone.
- CLAHE's
clipLimitis the noise control. A value around 2 to 3 is a good starting point; higher values increase local contrast and noise together. tileGridSizedetermines the scale of the local adjustment. Small tiles brighten fine detail; large tiles behave more like global equalisation.- Equalisation is not always an improvement. It makes a low-contrast image easier to see and can make a correctly exposed image look unnatural, so apply it where the measurement shows it helps.
Automatic contrast stretching
def percentile_stretch(gray_image, low=2.0, high=98.0):
"""Robust linear stretch that ignores a small fraction of outliers."""
lo, hi = np.percentile(gray_image, [low, high])
if hi <= lo:
return gray_image.copy()
scaled = (gray_image.astype("float32") - lo) * (255.0 / (hi - lo))
return np.clip(scaled, 0, 255).astype("uint8")
def gamma_correct(gray_image, gamma=1.0):
inv = 1.0 / max(gamma, 1e-6)
table = ((np.arange(256) / 255.0) ** inv * 255).astype("uint8")
return cv2.LUT(gray_image, table)
stretched = percentile_stretch(gray)
brightened = gamma_correct(stretched, gamma=0.7) # gamma < 1 brightens shadows
darkened = gamma_correct(stretched, gamma=1.5)
# a lookup table is the fast path for any per-pixel intensity mapping
table = np.clip((np.arange(256) * 1.3), 0, 255).astype("uint8")
boosted = cv2.LUT(gray, table)
# back-projection: find regions whose colour distribution matches a target
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
roi_hist = cv2.calcHist([hsv], [0, 1], None, [180, 256], [0, 180, 0, 256])
cv2.normalize(roi_hist, roi_hist, 0, 255, cv2.NORM_MINMAX)
backproj = cv2.calcBackProject([hsv], [0, 1], roi_hist, [0, 180, 0, 256], 1)
print("back-projection peak", int(backproj.max()))⚠️
Stretching clips everything outside the chosen percentile range to pure black or pure white. On an image where the extremes carry information — a faint defect, an overexposed indicator light — record which values you clipped, and keep the original alongside the processed result.
FAQ
Why does equalisation make colour images look wrong?
You equalised the B, G and R channels separately, so their relative levels changed and the hue shifted. Work on the luminance or value channel only and keep the colour channels untouched.
When should I use CLAHE instead of equalisation?
Whenever the lighting is uneven across the frame, which is most real captures. Global equalisation brightens the already-bright region and leaves the dark one dark; CLAHE adjusts each tile separately.
Related
Filters and edge detection Colour spaces and channel operations
Last refreshed 2026-09-18.