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))
MethodBehaviourBest forRisk
equalizeHistOne global mappingUniformly lit, low-contrast imagesAmplifies noise, washes out colour
CLAHETile-based with a clip limitUneven lighting, medical, industrialVisible tile seams if over-clipped
Gamma correctionPower-law intensity mappingFixing an over- or under-exposed captureRequires choosing a gamma
Linear stretchMaps min-max to 0-255Known intensity range with outliers presentOutliers compress the useful range
Percentile stretchMaps the 2nd-98th percentileRobust automatic stretchingClips 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 clipLimit is the noise control. A value around 2 to 3 is a good starting point; higher values increase local contrast and noise together.
  • tileGridSize determines 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.

Filters and edge detection Colour spaces and channel operations

Last refreshed 2026-09-18.