Thresholding and Binarization
Thresholding turns a grayscale image into a binary image - every pixel becomes pure white (255) or pure black (0) based on a comparison. It is the bridge between "a picture" and "a set of shapes," because contour finding, connected-component analysis, and most classic object pipelines all want binary input. The global functions (threshold and its Otsu variant) assume even lighting; adaptive thresholding handles the uneven illumination that real documents and scenes have.
Global Thresholds
import cv2
gray = cv2.cvtColor(cv2.imread("photo.jpg"), cv2.COLOR_BGR2GRAY)
# threshold(src, maxval, thresh, type)
# pixels above thresh -> maxval (255), below -> the type's other value
_, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
_, inverted = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)
# Otsu: pick the threshold automatically from the histogram
otsu_val, binary_otsu = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print("otsu picked:", otsu_val)
# truncate: above -> thresh, below -> unchanged (used for contrast limiting)
_, truncated = cv2.threshold(gray, 127, 0, cv2.THRESH_TRUNC)
cv2.imwrite("binary.jpg", binary)
cv2.imwrite("binary_otsu.jpg", binary_otsu)
The type flags define the two outcomes: THRESH_BINARY (above -> 255, below -> 0), THRESH_BINARY_INV (the inverse - the one you want when objects are dark on a light background, since contours want white objects), and THRESH_TRUNC. Otsu mode (+ cv2.THRESH_OTSU) scans all 256 thresholds and picks the one that minimizes the within-class variance - the standard "no tuning" choice for bimodal images like text on paper. For inverted documents (dark ink), use THRESH_BINARY_INV + THRESH_OTSU so the ink becomes the white foreground.
Adaptive Thresholding: Uneven Lighting
import cv2
gray = cv2.cvtColor(cv2.imread("scan.jpg"), cv2.COLOR_BGR2GRAY)
# per-pixel threshold from a local neighborhood
adaptive = cv2.adaptiveThreshold(gray, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, 51, 15)
# mean variant: average of the neighborhood minus C
adaptive_mean = cv2.adaptiveThreshold(gray, 255,
cv2.ADAPTIVE_THRESH_MEAN_C,
cv2.THRESH_BINARY_INV, 51, 10)
cv2.imwrite("adaptive.jpg", adaptive)
Instead of one global value, adaptiveThreshold computes a threshold for every pixel from its neighborhood - a Gaussian-weighted average (or plain mean) minus the constant C. A pixel is bright if it is brighter than its surroundings by that margin, so a scan with a bright spot on the left and a shadow on the right still binarizes correctly. The neighborhood block size (51) must be odd and larger than any character, and C is the bias - larger means fewer false positives. Adaptive is the right tool for scanned documents, PCB photos, and anything where illumination is not uniform.
Gamma Correction: Before, Not Instead
A common confusion: gamma correction is not thresholding. convertScaleAbs or a gamma LUT stretches the gray levels so a dark image becomes visible - it preserves the gradient and is a preprocessing step. Thresholding discards all levels into two. The effective pipeline for a dark, uneven scan is: gamma to even out the dynamic range, Gaussian blur to kill grain, then adaptive threshold. Each step has a distinct job; skipping one makes the next one fight a problem it was not designed for.
Choosing the Mode
| Scenario | Use |
|---|---|
| Even lighting, bimodal (text, documents) | threshold + Otsu |
| Uneven lighting (scans, photos of paper) | adaptiveThreshold |
| Dark source image first | gamma / convertScaleAbs, then threshold |
| Color source, one specific color | HSV inRange mask (see color spaces), then morphology |
Summary
Binarize with cv2.threshold - the type flag picks the polarity, and THRESH_OTSU removes the need to tune the value on even-lighting images. When illumination varies across the frame, switch to adaptiveThreshold (Gaussian variant, odd block size, bias constant) so each pixel is compared against its own neighborhood. Remember the polarity convention: contours want white objects, so dark-on-light sources usually need BINARY_INV. And treat gamma correction as preprocessing that makes the threshold's job possible, not a replacement for it.