Contours and Shape Analysis
Contour finding is where classical vision pipelines converge: binarize, then findContours returns the boundaries of every white region as point lists. Everything about a detected object - its area, perimeter, bounding box, corner count, centroid, and orientation - is a function of that contour. This chapter is the workhorse of document detection, blob counting, and shape classification, and the pattern (threshold -> clean -> find -> filter -> draw) repeats in dozens of applications.
The Pipeline and findContours
import cv2
img = cv2.imread("doc_photo.jpg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
# binarize so contours have white objects
_, binary = cv2.threshold(blur, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# contours, hierarchy, params
contours, hierarchy = cv2.findContours(binary,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
print("found:", len(contours))
# draw them all in green on a copy
overlay = img.copy()
cv2.drawContours(overlay, contours, -1, (0, 255, 0), 2)
cv2.imwrite("contours.jpg", overlay)
findContours works on a single-channel 8-bit image (the binary mask), never on color. The retrieval mode controls which contours survive: RETR_EXTERNAL (only the outermost - the usual choice), RETR_LIST (all, flat), or RETR_TREE (all, with parent-child hierarchy). The approximation mode trades points for size - CHAIN_APPROX_SIMPLE keeps only the corner points, the normal default. Note the function modifies the input in older builds, so pass a copy if you still need the mask. The return is (contours, hierarchy) in modern OpenCV - three values in the legacy 3.x signature.
Reading a Contour: Area, Box, Moments
import cv2
img = cv2.imread("doc_photo.jpg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
area = cv2.contourArea(c)
if area < 500:
continue # filter specks
peri = cv2.arcLength(c, True)
x, y, w, h = cv2.boundingRect(c) # axis-aligned box
rx, ry, rw, rh, angle = cv2.minAreaRect(c) # rotated box
(cx, cy), radius = cv2.minEnclosingCircle(c)
M = cv2.moments(c)
if M["m00"] > 0:
cx_m = int(M["m10"] / M["m00"]) # centroid
cy_m = int(M["m01"] / M["m00"])
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.putText(img, f"{int(area)}", (x, y - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
cv2.imwrite("annotated.jpg", img)
The per-contour toolkit: contourArea (the shape's area, not the box area), arcLength (perimeter), boundingRect (axis-aligned x/y/w/h), minAreaRect (the tightest rotated rectangle plus its angle), minEnclosingCircle, and moments - the statistical description from which the centroid and orientation follow. Area filtering is always the first line: without a minimum area, dust and noise become "objects." These same values drive every downstream decision - size gates, aspect ratios, sorting by position.
Approximation: Counting Corners
import cv2
img = cv2.imread("shapes.jpg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
peri = cv2.arcLength(c, True)
# epsilon as a fraction of the perimeter
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
n = len(approx)
if n == 3:
label = "triangle"
elif n == 4:
label = "quad"
elif n == 5:
label = "pentagon"
else:
label = f"{n}-gon"
cv2.drawContours(img, [approx], -1, (0, 0, 255), 2)
cv2.putText(img, label, approx[0][0],
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
cv2.imwrite("classified.jpg", img)
approxPolyDP simplifies a contour to the minimum points that stay within epsilon of the original - the corner counter. The epsilon is a fraction of the perimeter (0.01-0.05 is the working range): too small and every wobble becomes a corner, too large and a quadrilateral collapses to a triangle. This is the classic shape classifier, and the same trick is how a document detector finds its four corners: find the largest contour, approximate it, and if it yields four points, you have the quadrilateral to feed getPerspectiveTransform.
Hierarchy and the Document Pattern
import cv2
gray = cv2.cvtColor(cv2.imread("doc_photo.jpg"), cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
contours, hierarchy = cv2.findContours(binary, cv2.RETR_TREE,
cv2.CHAIN_APPROX_SIMPLE)
# hierarchy: [next, previous, first_child, parent] per contour
# RETR_TREE lets you walk containment - text lines inside a block,
# blocks inside the page
h = hierarchy[0]
for i, c in enumerate(contours):
parent = h[i][3]
if cv2.contourArea(c) > 5000 and parent != -1:
print(f"contour {i}: area={int(cv2.contourArea(c))} parent={parent}")
With RETR_TREE, each contour carries a [next, previous, first_child, parent] record, making containment explicit - letters inside words inside lines, holes inside rings. Most detection work uses RETR_EXTERNAL and ignores it, but the hierarchy is the tool when you need nesting. The full document pattern that ties the chapter together: detect the largest quadrilateral contour, perspective-flatten it, then re-run the pipeline on the flat image for the text - two passes of the same findContours logic at two scales.
Summary
Run the standard pipeline - grayscale, blur, threshold (usually Otsu), findContours with RETR_EXTERNAL and CHAIN_APPROX_SIMPLE - and filter every result by minimum area before trusting it. Read each contour with contourArea, arcLength, boundingRect, minAreaRect, and moments-based statistics; classify shapes by the corner count from approxPolyDP with epsilon as a perimeter fraction; and use RETR_TREE plus the hierarchy only when nesting matters. This is the most reused sequence in classical OpenCV - document detection, blob counting, and shape logic all live in these six lines.