In OpenCV, when does cv2.adaptiveThreshold beat a global threshold?
answer
- lighting varies across the frame
- a threshold per pixel
- neighbourhood mean minus a constant
- blockSize must be odd
- flat regions come back as speckle
basics
~20 sWhen illumination varies across the frame. cv2.adaptiveThreshold computes a separate threshold for every pixel from the mean of its blockSize x blockSize neighbourhood, minus a constant C, so a shadow or lighting gradient cancels out instead of blacking out half the image.
solid answer
~40 s`cv2.adaptiveThreshold(src, maxValue, adaptiveMethod, thresholdType, blockSize, C)` sets each pixel by comparing it to a threshold derived from its own local neighbourhood rather than one number for the whole image. `adaptiveMethod` is `cv2.ADAPTIVE_THRESH_MEAN_C` (plain neighbourhood mean) or `cv2.ADAPTIVE_THRESH_GAUSSIAN_C` (weighted mean, usually cleaner). `blockSize` is the neighbourhood side and **must be odd and greater than 1**; it should comfortably exceed the stroke width of the features you want to keep. `C` is subtracted from the local mean, so raising it makes the result more conservative and suppresses noise. Constraints worth knowing: the input must be 8-bit single-channel, `thresholdType` may only be `cv2.THRESH_BINARY` or `cv2.THRESH_BINARY_INV`, and it cannot be combined with `cv2.THRESH_OTSU`. Because it thresholds locally, flat regions turn into speckle - follow it with a morphological opening.
code
python · 14 linesimport cv2
import numpy as np
# a left-to-right illumination ramp with two dark blobs on it
gray = np.tile(np.linspace(40, 220, 200, dtype=np.uint8), (200, 1))
cv2.circle(gray, (50, 100), 20, 0, -1)
cv2.circle(gray, (150, 100), 20, 0, -1)
_, global_mask = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
adaptive = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 5
)
print(cv2.countNonZero(global_mask), cv2.countNonZero(adaptive))go deeper
Know that it exists for unevenly lit images and that you pass blockSize and C. Being able to say it thresholds each pixel against its neighbours rather than against one fixed number covers the screening version.
Explain the two adaptiveMethod options, why blockSize must be odd, what C does to the noise floor, and the restriction to THRESH_BINARY and THRESH_BINARY_INV.
Show the tuning reasoning tied to physical scale - blockSize relative to stroke width, C as a contrast margin - and pair it with a morphological opening rather than shipping speckle into the contour stage.
Own the strategy call between fixing illumination in hardware, flattening it computationally and then using a global threshold, or going fully adaptive - and the cost of per-site parameter tuning that nobody can later justify.
## The failure it is built for A global threshold assigns one intensity cut to the entire frame. That is correct only when illumination is uniform. In practice it rarely is: a desk lamp on one side of a page, a curved book spine, vignetting from a wide lens, a shadow from the operator's hand. A single cut is then simultaneously too high in the dim corner (real ink lost) and too low in the bright corner (paper texture promoted to foreground). The signature symptom is a scan where one half is entirely black and the other is speckled. The insight behind adaptive thresholding is that illumination varies **slowly** across the image while the features you care about - strokes, edges, small parts - vary **quickly**. If you threshold a pixel against its own local surroundings, the slow component cancels and only the local contrast survives. ## The signature ``` dst = cv2.adaptiveThreshold(src, maxValue, adaptiveMethod, thresholdType, blockSize, C) ``` Unlike `cv2.threshold` it returns only the image - there is no threshold value to report back, because there are as many thresholds as pixels. **src** must be 8-bit single-channel. Convert to grayscale first. **maxValue** is the value written to pixels that pass, normally 255. **adaptiveMethod** is either `cv2.ADAPTIVE_THRESH_MEAN_C`, where the local threshold is the arithmetic mean of the `blockSize x blockSize` neighbourhood, or `cv2.ADAPTIVE_THRESH_GAUSSIAN_C`, where it is a Gaussian-weighted mean centred on the pixel. The Gaussian variant weights nearby pixels more heavily and generally produces smoother, less blocky output; the mean variant is marginally cheaper. **thresholdType** may only be `cv2.THRESH_BINARY` or `cv2.THRESH_BINARY_INV`. The truncation modes are not supported here, and neither is the Otsu flag - Otsu is a global histogram method and the two are mutually exclusive approaches. **blockSize** is the neighbourhood side length. It must be **odd** and greater than 1; an even value raises an error rather than being rounded, which is the most common first-run mistake with this function. **C** is a constant subtracted from the computed local mean. Positive C raises the bar for becoming foreground. ## Tuning blockSize and C These two are the whole job, and both have physical meaning. `blockSize` sets the scale at which illumination is considered constant. Too small and the neighbourhood sits entirely inside a thick stroke, so the stroke's own interior becomes its own background and hollows out - you get outlines instead of solid characters. Too large and you drift back toward a global threshold, reintroducing the shadow problem. The working rule is that blockSize should be several times the stroke or feature width but well under the scale over which lighting changes. For scanned text at typical resolutions, values in the tens are normal. `C` is the noise margin. With C = 0, a pixel just barely below its local mean flips to foreground, so any flat region - blank paper, empty conveyor - is thresholded on sensor noise alone and comes out as dense salt-and-pepper speckle. This is not a bug in the method; local thresholding has no concept of "there is nothing here." Raising C by a few grey levels demands real local contrast and clears most of it. Increase C until blank areas are clean, then stop - push further and thin strokes start dropping out. ## The clean-up that always follows Even well-tuned, adaptive thresholding produces more isolated speckle than a global threshold does. The standard remedy is a morphological opening with a small elliptical element, which deletes anything thinner than the kernel while leaving real strokes at their original width. If strokes come out broken, follow with a closing. Skipping this step and going straight to `cv2.findContours` is why naive document pipelines report thousands of tiny contours. ## Choosing between the three thresholding strategies A **fixed** value belongs only in a sealed enclosure with locked exposure, where it is the fastest and most predictable option. **Otsu** suits scenes where overall brightness drifts frame to frame but is uniform within each frame, and it hands you back the value it chose, which is a useful health signal. **Adaptive** is for illumination that varies *within* the frame - documents, curved surfaces, uncontrolled ambient light - and is the default for OCR preprocessing and hand-held capture. A hybrid is often best in industrial settings: estimate the illumination field once (a morphological tophat, or a heavy blur used as a background estimate), divide or subtract it out, then apply Otsu to the flattened image. That keeps a single reportable threshold value while removing the gradient, and avoids adaptive thresholding's speckle in genuinely empty regions. ## What it cannot fix Adaptive thresholding needs local contrast to exist. If foreground and background have the same intensity in a region - a grey part on a grey belt, ink that has faded to the paper's tone - no local statistic recovers it. At that point the fix is upstream: lighting geometry, a colour channel that separates the two, or a different feature altogether.
- cv2.adaptiveThreshold with blockSize=30 raises an error. Why?blockSize must be odd and greater than 1, because the neighbourhood has to be centred on the pixel being thresholded - an even side length has no centre pixel. OpenCV rejects the value rather than silently rounding it. Use 29 or 31.
- Blank areas of the page come out as dense salt-and-pepper noise. What is the fix?Two things. Raise C so a pixel needs real contrast against its neighbourhood, not just sensor noise, to become foreground. Then apply a morphological opening with a small elliptical element to delete the isolated pixels that remain. If speckle persists, the region has no signal and a local method cannot invent one.
- Can you combine cv2.THRESH_OTSU with adaptiveThreshold to get the best of both?No. adaptiveThreshold accepts only THRESH_BINARY or THRESH_BINARY_INV as its thresholdType; Otsu is a global histogram method and belongs to cv2.threshold. The practical hybrid is to flatten the illumination first - a morphological tophat or a background-estimate subtraction - and then run Otsu on the flattened image.
- Thick strokes come out hollow, with only their outlines marked. What went wrong?blockSize is too small relative to the stroke width. When the neighbourhood fits entirely inside a stroke, the stroke's own pixels dominate the local mean, so its interior is no longer darker than its surroundings and fails the test. Increase blockSize until the neighbourhood comfortably spans stroke plus surrounding background.
saying these in an interview costs you the question
- Thinks it computes one smarter global threshold
- Passes an even blockSize
- Tries to combine it with THRESH_OTSU
- Leaves C at 0 and calls the speckle a bug
- Skips the morphological clean-up before findContours