Thresholding and Otsu

OpenCV · Essentials · 10 min

Binary masks are the input to contours, morphology and measurement, and the threshold choice decides their quality.

Thresholding splits pixels into foreground and background. cv2.threshold(gray, thresh, maxval, type) returns a tuple (used_threshold, result):

  • cv2.THRESH_BINARY: pixels above thresh become maxval, others 0
  • cv2.THRESH_BINARY_INV: the opposite, handy for dark objects on a light background
  • THRESH_TRUNC, THRESH_TOZERO: clip or zero instead of binarising

Picking thresh by hand is fragile. Add cv2.THRESH_OTSU to the type (and pass 0 as thresh) and Otsu's method chooses the value that best separates a two-peaked histogram. The first return value tells you which threshold it chose.

A single global value fails when lighting varies across the image, as with a photographed document that is darker in one corner. cv2.adaptiveThreshold(gray, 255, method, type, blockSize, C) computes a separate threshold for each pixel from its neighbourhood (ADAPTIVE_THRESH_MEAN_C or ADAPTIVE_THRESH_GAUSSIAN_C), minus a constant C. blockSize must be odd.

Thresholds expect a single-channel image, so convert with cvtColor first. Blurring slightly before thresholding (cv2.GaussianBlur) removes speckle noise that would otherwise become tiny false blobs. For colour-based masks, use cv2.inRange on HSV instead.

Example

import cv2
import numpy as np

rng = np.random.default_rng(1)
gray = np.full((100, 100), 60, np.uint8)        # dark background
gray[30:70, 30:70] = 190                        # bright square
gray = cv2.add(gray, rng.integers(0, 30, gray.shape, dtype=np.uint8))

t, fixed = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
print(t, fixed[50, 50], fixed[5, 5])            # 127.0 255 0

t, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print("otsu chose", t)                          # somewhere between the two peaks
print((otsu == 255).sum())                      # 1600  (the 40x40 square)

ramp = np.tile(np.linspace(0, 200, 100, dtype=np.uint8), (100, 1))
ramp[45:55, ::10] += 40                         # faint marks on uneven light
ad = cv2.adaptiveThreshold(ramp, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
                           cv2.THRESH_BINARY, 11, -10)
print(ad.dtype, np.unique(ad))                  # uint8 [  0 255]

Remember

  • cv2.threshold returns (threshold, mask)
  • Add THRESH_OTSU to let OpenCV pick the threshold
  • Use adaptiveThreshold when lighting varies across the image
  • Threshold a single-channel image, ideally lightly blurred

Watch out for

  • Forgetting that cv2.threshold returns a tuple and treating the whole tuple as the image.

Check yourself

  1. What is the first value returned by `cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)`?
  2. A scanned page is bright on the left and shadowed on the right. Which approach binarises the text best?

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

Official docs: docs.opencv.org/4.x/d7/d4d/tutorial_py_thresholding.html