Skillversitas

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.