Recap: what we have built so far
In the earlier parts of this path you learned how a camera turns light into pixels, how colour spaces like RGB, HSV and greyscale describe those pixels, how to read, show and save images with OpenCV, and how filters and blurs clean up noise before you do anything more demanding with an image. That cleanup step matters more than it might have seemed at the time, because almost everything in this article depends on it. Edge detection is sensitive to noise, so a blurred image, from part 5, is usually the starting point rather than the raw photo straight off the sensor.
This part is about three closely related ideas that sit at the core of classical computer vision: edges, which mark where brightness changes sharply; contours, which join those changes into continuous outlines; and shapes, which you can recognise once you have a clean outline to measure. By the end you will be able to take a photograph of, say, a few coins or cut-out paper shapes, and have OpenCV tell you how many objects are in it, roughly what shape each one is, and where its centre sits.
What exactly is an edge?
Picture a photograph of a white coffee mug sitting on a dark wooden table. If you look at any single pixel inside the mug, its neighbours are all a similar shade of white. The same is true for a pixel deep in the wood grain of the table; its neighbours are all a similar shade of brown. But at the boundary between mug and table, something different happens: as you move one pixel to the side, the brightness jumps from pale to dark almost instantly. That jump is an edge. Informally, an edge is any place in an image where the pixel intensity changes quickly over a short distance, usually because two different objects, surfaces or lighting conditions meet there.
It helps to think about this in one dimension first. Imagine a single row of pixel brightness values, on a 0 to 255 scale, read from left to right: 12, 11, 13, 12, 200, 198, 201, 199. The first four values barely change, they hover around 12. The last four values also barely change, they hover around 200. But between the fourth and fifth value the brightness jumps by nearly 190 in a single step. That single large jump is exactly what an edge detector is built to find: not the absolute brightness, but the rate at which brightness changes from one pixel to the next.
Gradients: turning brightness change into numbers
In calculus, the rate of change of a function is its derivative. An image is not a smooth function, it is a grid of discrete numbers, but the same idea applies: you can approximate the derivative at a pixel by comparing it to its neighbours. Do this in the horizontal direction and you get a measure of how quickly brightness changes as you move left to right. Do it in the vertical direction and you get a measure of how quickly it changes top to bottom. Together these two numbers form a gradient, and the strength of the gradient at a pixel tells you how likely that pixel is to sit on an edge.
The most common tool for computing this in OpenCV is the Sobel operator. Rather than comparing just two adjacent pixels, Sobel uses a small 3 by 3 window and gives more weight to the pixels directly above, below, left and right of the centre than to the diagonal ones, which makes the result a bit less sensitive to noise. There are two Sobel kernels, one tuned to detect horizontal change and one tuned to detect vertical change.
Sobel kernel for horizontal gradient (Gx):
-1 0 1
-2 0 2
-1 0 1
Sobel kernel for vertical gradient (Gy):
-1 -2 -1
0 0 0
1 2 1
To see how this works with real numbers, take a tiny 3 by 3 patch of an image that contains a vertical edge, dark on the left and bright on the right: the three rows are [10, 10, 200], [10, 10, 200] and [10, 10, 200]. Apply the Gx kernel by multiplying each kernel value by the pixel underneath it and adding everything up: the first row contributes (-1 x 10) + (0 x 10) + (1 x 200) = 190, the second row contributes (-2 x 10) + (0 x 10) + (2 x 200) = 380, and the third row contributes another 190. Adding these gives a horizontal gradient of 760, a large value, correctly signalling a strong vertical edge. Now apply the Gy kernel to the same patch: the top row contributes (-1 x 10) + (-2 x 10) + (-1 x 200) = -230, the middle row contributes 0, and the bottom row contributes (1 x 10) + (2 x 10) + (1 x 200) = 230. These cancel out to a vertical gradient of 0, which makes sense, because nothing changes as you move up and down in this patch, only as you move left to right.
Combining the two gradients with Pythagoras, magnitude equals the square root of Gx squared plus Gy squared, gives the overall strength of the edge at that point, here sqrt(760^2 + 0^2) = 760. In a flat, uniform region both Gx and Gy would be close to zero, giving a small magnitude, which is exactly what you want: no edge where nothing changes.
In code, OpenCV computes these gradients for you with cv2.Sobel. One detail matters a great deal here: gradients can be negative, for example a bright-to-dark transition gives a negative Gx. If you store the result in an 8-bit image, which can only hold values from 0 to 255, every negative gradient gets clipped to 0 and you silently lose half of your edges. The fix is to compute the Sobel result in a floating point type, typically cv2.CV_64F, and only convert back to a displayable 8-bit image at the very end.
import cv2
import numpy as np
# Create a simple synthetic image: a dark square on a bright background.
# This avoids needing a sample photo; you can run this exactly as shown.
img = np.full((200, 200), 220, dtype=np.uint8)
img[60:140, 60:140] = 30
# Compute gradients in floating point to avoid clipping negative values
sobelx = cv2.Sobel(img, cv2.CV_64F, dx=1, dy=0, ksize=3)
sobely = cv2.Sobel(img, cv2.CV_64F, dx=0, dy=1, ksize=3)
# Combine into a single magnitude image, then scale back to 0-255 for saving
magnitude = cv2.magnitude(sobelx, sobely)
magnitude = cv2.convertScaleAbs(magnitude)
cv2.imwrite("sobel_magnitude.png", magnitude)
print("Max gradient value:", magnitude.max())
print("Image shape:", magnitude.shape)
This code assumes opencv-python version 4.x and NumPy; both cv2.Sobel and cv2.magnitude have been stable across OpenCV 4 releases. Running it produces a 200 by 200 image saved as sobel_magnitude.png. Open it and you will see a thin bright square outline, roughly where the dark square meets the bright background, sitting on an almost completely black background, because the interior of the square and the interior of the background are both flat and contribute almost no gradient.
Canny edge detection: the industry-standard recipe
Sobel gives you a gradient magnitude at every pixel, but on its own that is a fairly rough tool: edges come out several pixels wide, and you still need to decide which gradient values count as a real edge rather than noise. The Canny edge detector, included in OpenCV as cv2.Canny, wraps several extra steps around the basic gradient idea to produce clean, thin, one-pixel-wide edges. It was designed in the 1980s and remains one of the most widely used edge detectors precisely because it is simple to use and gives reliable results.
- Smooth the image first, usually with a Gaussian blur, to reduce the effect of sensor noise on the gradient calculation, exactly the kind of blur covered in part 5.
- Compute the gradient magnitude and direction at every pixel, internally using something very close to the Sobel operator you just saw.
- Thin the edges with non-maximum suppression: at each pixel, check whether its gradient magnitude is the largest among its neighbours in the direction of the gradient, and if not, suppress it to zero. This turns thick bands of high gradient into single-pixel-wide lines.
- Apply hysteresis thresholding using two thresholds, a lower one and an upper one: pixels with gradient above the upper threshold are kept as definite edges, pixels below the lower threshold are discarded, and pixels in between are kept only if they are connected to a definite edge pixel.
That last step, hysteresis, is what makes Canny more reliable than a simple single threshold. A single threshold forces you to pick one number: too high and you lose faint but genuine edges, too low and you pick up noise everywhere. Hysteresis lets weak-but-plausible edge pixels survive if they are attached to a strong edge, which mimics how a human eye tends to trace a faint continuation of an obvious line, while still discarding isolated weak pixels that are probably noise.
import cv2
import numpy as np
img = np.full((200, 200), 220, dtype=np.uint8)
img[60:140, 60:140] = 30
# Blur first, as Canny expects a reasonably clean greyscale image
blurred = cv2.GaussianBlur(img, (5, 5), 0)
# Two different threshold pairs on the same image
edges_tight = cv2.Canny(blurred, 100, 200)
edges_loose = cv2.Canny(blurred, 10, 50)
cv2.imwrite("edges_tight.png", edges_tight)
cv2.imwrite("edges_loose.png", edges_loose)
print("Tight edge pixel count:", cv2.countNonZero(edges_tight))
print("Loose edge pixel count:", cv2.countNonZero(edges_loose))
Both calls take a single-channel 8-bit image and the two threshold values, in that order: the lower threshold first, then the upper one. On this clean synthetic image both threshold pairs will likely trace the same crisp square outline, because the contrast is large and there is no real noise to confuse things. On a real photograph the difference is more visible: a tight pair of thresholds, such as 100 and 200, only keeps the strongest, most obvious edges, while a loose pair, such as 10 and 50, keeps far more detail, including faint texture that you might not want. There is no universal correct pair of numbers; tuning Canny thresholds by trial and error on your own images, often by looking at the output side by side at a couple of settings, is completely normal practice.
From scattered edges to connected contours
Edge detection, even Canny, gives you a map of individual pixels that look like edges. Nothing in that map tells you which edge pixels belong to the same object, whether an outline is closed, or how big the shape enclosed by an outline is. A contour solves that problem: it is a curve joining all the continuous points along a boundary that share a similar colour or intensity, stored as an ordered list of coordinates rather than a scattered set of pixels. Once you have a contour, you can ask much more useful questions of it: how long is this outline, how much area does it enclose, is it roughly circular, how many straight sides does it have.
What cv2.findContours needs
OpenCV's contour finder, cv2.findContours, expects a binary image: pixels are either 0 (background) or non-zero, usually 255 (foreground). You can get such an image in two ways relevant to this path: by running Canny and feeding it the resulting edge map, or by thresholding the image into black and white, which is exactly the subject of the next part of this path. Both are valid starting points and you will often try both on a given picture to see which gives cleaner contours. For the rest of this article the examples build simple binary images directly with NumPy and OpenCV's own drawing functions, so you can run every example without needing any external photo.
Retrieval modes and approximation methods
cv2.findContours takes two extra arguments beyond the image: a retrieval mode and an approximation method. The retrieval mode controls which contours you get back when shapes are nested inside each other, for instance a white ring with a black hole in the middle contains an outer contour and an inner contour. cv2.RETR_EXTERNAL gives you only the outermost contour of each separate object, ignoring anything nested inside it. cv2.RETR_LIST gives you every contour found, with no information about which ones are nested inside which. cv2.RETR_TREE gives you every contour along with a full hierarchy describing parent and child relationships, returned as an array where each contour has four numbers: the index of the next contour at the same level, the previous one at the same level, its first child, and its parent, with -1 used where there is none.
The approximation method controls how densely each contour is stored. cv2.CHAIN_APPROX_NONE keeps every single point along the boundary, which can be a lot of points for a large, smooth curve. cv2.CHAIN_APPROX_SIMPLE compresses straight horizontal, vertical and diagonal segments down to just their two end points, which is almost always what you want, since it gives the same shape with far less data to store and process.
One version detail is worth stating plainly, because it has changed across OpenCV releases and causes confusion if you follow an older tutorial: in OpenCV 4.x, which this path assumes throughout (the opencv-python package, version 4 or later), cv2.findContours returns two values, contours and hierarchy. In OpenCV 3.x it returned three values, the image itself as well as contours and hierarchy. If you ever see sample code that does something like image, contours, hierarchy = cv2.findContours(...) and it raises an error about too many values to unpack, that code was written for OpenCV 3.x.
import cv2
import numpy as np
# Build a small binary image with two separate shapes
img = np.zeros((300, 300), dtype=np.uint8)
cv2.rectangle(img, (30, 30), (120, 120), 255, -1)
cv2.circle(img, (220, 80), 50, 255, -1)
# OpenCV 4.x returns two values: contours and hierarchy
contours, hierarchy = cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
print("Number of contours found:", len(contours))
# Draw the contours in red on a colour copy so we can see them
color_img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
cv2.drawContours(color_img, contours, -1, (0, 0, 255), 2)
cv2.imwrite("contours.png", color_img)
This prints Number of contours found: 2, one for the square, one for the circle, because RETR_EXTERNAL only looks at outer boundaries and both shapes are solid with nothing nested inside them. cv2.drawContours takes the target image to draw on, the list of contours, an index telling it which contour to draw (-1 means draw all of them), a colour, and a line thickness. Opening contours.png shows the original white shapes now outlined in red, which is a handy way to sanity check that findContours is seeing what you expect before you do anything more with the result.
Measuring what you found
A contour is just a list of (x, y) coordinates, but OpenCV provides several ready-made functions that turn that list into useful numbers. cv2.contourArea(contour) returns the enclosed area in square pixels. cv2.arcLength(contour, True) returns the perimeter, the True argument telling it the contour is closed, meaning the last point connects back to the first. cv2.boundingRect(contour) returns the smallest upright rectangle, as (x, y, width, height), that fully contains the contour, useful for cropping a region or for comparing width to height. cv2.minEnclosingCircle(contour) returns the centre and radius of the smallest circle that contains the whole contour, useful when you expect roughly round objects such as coins. Finally, cv2.moments(contour) computes a set of statistical moments of the shape, and from the zeroth and first moments you can compute the centroid, the shape's centre of mass, as cx = m10 / m00 and cy = m01 / m00.
import cv2
import numpy as np
img = np.zeros((300, 300), dtype=np.uint8)
cv2.rectangle(img, (30, 30), (120, 120), 255, -1)
cv2.circle(img, (220, 80), 50, 255, -1)
contours, _ = cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
area = cv2.contourArea(c)
perimeter = cv2.arcLength(c, True)
x, y, w, h = cv2.boundingRect(c)
M = cv2.moments(c)
cx = int(M["m10"] / M["m00"])
cy = int(M["m01"] / M["m00"])
print(f"area={area:.0f} perimeter={perimeter:.1f} "
f"box=({x},{y},{w},{h}) centre=({cx},{cy})")
Running this prints one line per shape. For the square drawn from (30, 30) to (120, 120), which is 90 pixels wide and 90 pixels tall, you should see an area close to 8100 (90 times 90) and a perimeter close to 360 (four sides of 90), with small differences of a few pixels being normal because cv2.contourArea and cv2.arcLength measure along the actual traced boundary rather than the mathematical rectangle you intended. For the circle with radius 50, you should see an area close to the mathematical value of pi times 50 squared, around 7854, and a perimeter close to 2 times pi times 50, around 314, again with a small amount of pixel-level rounding. The centroid for the square should land close to its geometric centre at roughly (75, 75), and for the circle close to its drawn centre at (220, 80).
Naming shapes: polygon approximation
Area and perimeter tell you about size, but not about shape. To tell a triangle from a square from a circle, OpenCV offers cv2.approxPolyDP, which implements the Douglas-Peucker algorithm. The intuition is straightforward: given a detailed curve with many points, the algorithm tries to replace it with the smallest number of straight line segments that still stay close to the original curve, within a tolerance you choose. A perfect square, traced pixel by pixel around its slightly bumpy edges, gets simplified down to its four true corners. A circle, which has no straight sides at all, gets simplified down to a polygon with many short sides, because no small number of straight lines can approximate a curve well.
The tolerance argument to approxPolyDP, usually called epsilon, is the maximum distance allowed between the original curve and the simplified one. Rather than picking an arbitrary pixel distance, a common and robust approach is to set epsilon as a small fraction, commonly around 1 to 4 percent, of the contour's own perimeter, so that the tolerance automatically scales with the size of the shape in the image. Once you have the approximated polygon, counting its vertices with len(approx) gives you a strong hint about the shape: three points suggests a triangle, four points suggests a square or rectangle, and a larger number of points, say eight or more, is a reasonable sign that OpenCV could not find a good small-polygon fit, which usually means the original shape was round.
import cv2
import numpy as np
# Build a synthetic image with a triangle, a square, a rectangle and a circle
canvas = np.zeros((300, 500), dtype=np.uint8)
cv2.rectangle(canvas, (30, 30), (120, 120), 255, -1) # square
cv2.rectangle(canvas, (160, 40), (300, 110), 255, -1) # rectangle
cv2.circle(canvas, (390, 80), 50, 255, -1) # circle
triangle_pts = np.array([[60, 280], [20, 200], [100, 200]])
cv2.fillPoly(canvas, [triangle_pts], 255)
contours, _ = cv2.findContours(canvas, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
output = cv2.cvtColor(canvas, cv2.COLOR_GRAY2BGR)
for c in contours:
perimeter = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, 0.02 * perimeter, True)
x, y, w, h = cv2.boundingRect(c)
sides = len(approx)
if sides == 3:
label = "triangle"
elif sides == 4:
aspect_ratio = w / float(h)
label = "square" if 0.95 <= aspect_ratio <= 1.05 else "rectangle"
else:
label = "circle"
cv2.drawContours(output, [c], -1, (0, 255, 0), 2)
cv2.putText(output, label, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX,
0.6, (0, 0, 255), 2)
cv2.imwrite("shapes_labelled.png", output)
print("Shapes found and labelled:", len(contours))
This script needs only opencv-python 4.x and NumPy, and it draws its own test image, so there is nothing to download. Running it prints Shapes found and labelled: 4, and saves shapes_labelled.png with each shape outlined in green and a red text label sitting just above it. The square should be labelled square because its approximated polygon has four vertices and its width and height are equal, the wider rectangle should be labelled rectangle because it also has four vertices but an aspect ratio away from 1, the circle should be labelled circle because its approximation needs more than four vertices to follow the curve, and the triangle should be labelled triangle because its approximation lands on exactly three vertices. Notice the square-versus-rectangle distinction: both have four sides, so vertex count alone cannot tell them apart, which is why the code also checks the ratio of width to height, treating anything close to a 1 to 1 ratio as a square.
Common mistakes
- Running findContours straight on a noisy greyscale photo instead of a clean binary image. The result is usually hundreds of tiny, meaningless contours from texture and noise rather than the handful of outlines you actually want. Blur first, then threshold or run Canny, and only then find contours.
- Treating edges and contours as the same thing. An edge map from Canny is a set of pixels that may contain gaps, especially where contrast is low; a contour expects a connected boundary. If your edges are broken, findContours may trace a tangle of small fragments rather than one clean outline for each object; closing small gaps with a brief morphological dilation, as covered in part 5, often helps before contour detection.
- Doing Sobel or similar gradient math in an 8-bit image type. Negative gradients silently clip to zero, which throws away roughly half of the genuine edge information. Compute in a floating point type such as cv2.CV_64F and convert to 8-bit only for display or saving.
- Choosing Canny thresholds once and assuming they will work on every image. Lighting, contrast and camera noise vary between photos, so thresholds that give a clean outline on one image can give almost nothing, or an overwhelming mess, on another. Expect to adjust them per image or per camera setup.
- Forgetting to filter contours by size. A real photo often contains small stray contours from dust, compression artefacts or shadows; checking cv2.contourArea against a minimum size before processing a contour further avoids treating noise as if it were an object.
- Picking an epsilon for approxPolyDP that is far too small or far too large relative to the shape's own perimeter. Too small and even a clean square can come out with extra spurious vertices from tiny pixel-level bumps; too large and a genuine pentagon can be flattened down to a triangle. Scaling epsilon as a percentage of cv2.arcLength, rather than a fixed pixel count, keeps it sensible across shapes of different sizes.
Summary and what's next
An edge is a place where brightness changes sharply, and you can measure that change precisely using gradients, most commonly with the Sobel operator, which gives both a magnitude and a direction at every pixel. The Canny edge detector builds on this idea with smoothing, thinning and a two-threshold hysteresis step to produce clean, mostly one-pixel-wide edge maps. A contour takes that further by joining boundary points into an ordered curve, which cv2.findContours extracts from a binary image, with retrieval modes controlling how nested shapes are handled and approximation methods controlling how densely each curve is stored. Once you have contours, functions such as cv2.contourArea, cv2.arcLength, cv2.boundingRect, cv2.minEnclosingCircle and cv2.moments turn them into size and position measurements, and cv2.approxPolyDP lets you simplify a contour down to its essential vertices, which is often enough to tell a triangle from a square from a circle just by counting corners.
The next part in this path, Thresholding and Masks, looks closely at the step that usually comes just before contour detection: turning a greyscale or colour photo into the clean black-and-white image that findContours works best on. After that, part 8 covers applying all of this to live video from a webcam rather than a single still image. The three hands-on projects later in the path, a document scanner, a coin counter and a webcam motion detector, all lean directly on the edge, contour and shape tools introduced here, so the code patterns in this article are worth keeping close by.
Comments (0)
No comments yet. Be the first to share your thoughts.