237 broken edges in a 13-coin photo: an OpenCV edge-to-contour tutorial

How does a computer get from raw pixels to "there are 12 coins"? The obvious answer feels right: find the boundaries, trace them, count. Then you run it, and one photo of 13 coins comes back with 237 broken edges. This tutorial rebuilds the chain from the ground up: what an edge really is, how filters compute it, why the Canny pipeline exists, and how contours turn pixel labels into objects you can count.

1. Why does the same 13-coin photo give a computer 237 "edges"?

Here is the problem that starts the whole story: a photo of 13 coins scattered on a plain background. Your eye finishes counting in about a second. Now imagine a thousand coins, or ten thousand. Suddenly you want the computer to do it.

The challenge photo: 13 international coins, two US quarters, British pounds, and several Japanese coins including one with a square hole

Caption: These are the 13 coins the whole tutorial is about — US quarters, British pound coins, a Spanish doubloon-style piece, and multiple Japanese coins, including one with a square hole in the middle. When the final pipeline reports "Count: 12" in the last chapter, this is the photo it is counting.

So the computer needs to do three things: find each coin's boundary, separate the coins, and count them. Finding boundaries is called edge detection, and connecting boundaries into shapes is contour extraction. That sounds simple enough. The lecture tries the most obvious thing first, using last week's tools: blur the image, subtract the blurred version from the original, and whatever survives is the "sharp detail" — the part where brightness changes fast.

high_freq = coins - GaussianBlur(coins)

This is a classic move. A blur (a low-pass filter) keeps smooth regions and removes detail; subtracting the blur from the original leaves only the high-frequency residual — thin, sharp structures. "High frequency ≈ edges!" It looks convincing. Then you trace contours on that residual, expecting to come back with a dozen or so coin boundaries. The result: 237 contours, most of them garbage.

Why does it fail so badly? The lecture lists three problems, and this "failure triad" is the tension the entire tutorial exists to resolve:

ProblemWhat it looks like
DisconnectedEdges break at many points — nothing runs continuously
Too noisyRandom brightness variations create false "edges" everywhere
Too thickEdges span 5–10 pixels wide, nowhere near precise

The high-frequency image gives you "the feeling of edges" — but a feeling is not a measurement. It cannot tell you which exact pixel is the edge and which is just texture. The residual's premise comes from blurring machinery: a Gaussian blur really does strip detail. We will see in Chapter 2 how that machine works, because the entire fix builds on it. OpenCVFiltering

Before fixing anything, let's pin down what an edge actually is. An edge is a place where brightness changes dramatically — an object boundary, a light-shadow transition, or a change in texture. The lecture's definitional diagram says it in three words: "Rapid change = Edge."

Brightness vs Position plot: a dark-to-white gradient bar with a vertical red line through the transition, and a red arrow labeled "Rapid change = Edge"

Caption: The strip at the top goes from black to white. Plot brightness against position and you get an S-shaped curve; where the curve climbs steeply — the marked transition band — that is an edge. Everything else is flat.

Real edges come in a few flavors, and each one produces a different signature when you look at how fast brightness changes:

Edge typeReal-world exampleFirst-derivative signature
StepObject boundaryA sharp spike — one bright response
RampShadow boundaryA flat plateau — constant change over a stretch
RoofThin line / wirePositive then negative plateau — change, then reverse

Three rows (Step, Ramp, Roof), each showing brightness on the left and its first derivative on the right

Caption: Left column: brightness along a line through the image. Right column: the first derivative, i.e. how fast brightness is changing. A step edge gives one sharp spike; a ramp gives a flat band of constant change; a roof edge gives a rise then a fall. These signatures are what derivative-based edge detectors actually hunt for.

So an edge is a brightness jump, and different jumps leave different fingerprints. That is Chapter 1's whole lesson: "feels like an edge" is not "knows where the edge is." The rest of this tutorial closes that gap, one mechanism at a time, starting with the machine that blur, sharpen, and (later) gradients all ride on: the sliding-window filter.

2. Filters: how a tiny sliding window rewrites every pixel

Every filter worth knowing in this tutorial does one thing: take a small grid of numbers, called the kernel (the kernel is just the filter), and slide it over the image one pixel at a time. At every position, the kernel's weights multiply the pixels underneath and sum into a single new pixel. One kernel, one window, one new pixel per position. That is convolution, and it is the only operation you need to understand for blurring, sharpening, and edge detection alike.

What does a blurring kernel do to a real photo? The canonical demo is the OpenCV logo: feed it through a Gaussian blur kernel and the sharp edges visibly soften.

Original vs Blurred: the OpenCV logo side by side, noticeably softened on the right

Caption: The same logo before and after cv2.GaussianBlur. Each pixel is replaced by a weighted average of its neighborhood, so high-frequency detail — sharp edges, crisp text — gets smeared out. This is the blur that the Chapter 1 residual subtracted away. OpenCVFiltering

The same machinery can do much more than soften. Change the kernel's weights and the filter keeps the image identical (the identity kernel: a single 1 at the center), shifts it by a pixel (the 1 moved off-center), or sharpens it. The sharpening kernel puts a heavy weight on the center and negative weights on the neighbors; the lecture's sharpening slide sums that up as "accentuates differences with the local average" — in words, the sharpened image is essentially the original minus a blurred copy. The kernel content changes, the sliding-window operation doesn't. That one fact is why the entire Week-3 toolbox is just Week-2's filter machinery wearing different weights, and it is also exactly why the Chapter 1 residual (original − blur) is a legitimate filter product. OpenCVFiltering

Now the deck asks a pointed question: what advantage does the median filter have over Gaussian blurring? The median filter is the odd one out — it is not a weighted sum at all. It sorts the window's values and replaces the center pixel with the middle value. That makes it non-linear: you cannot write it as a kernel multiplication. But that is precisely its superpower.

Three rows of the same signal: INPUT, then filtered with MEDIAN, then with MEAN

Caption: The same input signal under two filters of width 5. The MEDIAN row kills the isolated spike and otherwise keeps the signal's shape — it is robust to outliers. The MEAN row rounds everything off into smooth curves, smearing transitions that the median preserves.

The one-dimensional worked example makes the contrast concrete. Take the signal (2, 3, 80, 6, 2, 3) — one corrupted spike of 80 sitting in otherwise calm data. A 3-point median filter returns (2, 3, 6, 6, 3, 2): the 80 is gone completely. A moving-average of the same width produces (1.7, 28.3, 29.7, 29.3, 3.7, 1.7): the single bad value "infects" its two neighbors, dragging them from 3 and 6 up to 28 and 30. MedianFilter

The canonical target for the median filter is salt-and-pepper noise — random white and black specks scattered across an image. Averaging filters smear those specks into gray smudges; the median removes them cleanly, because a single extreme value cannot win an election where it is only one of nine votes.

Pepper image: left corrupted by salt-and-pepper noise, right restored by the median filter, with row-intensity plots below

Caption: The left panel is the classic pepper photo buried in salt-and-pepper noise; the right panel shows the same image after median filtering — the specks are gone while edges stay crisp. The line plots below show one row's grayscale intensity: wildly spiking before, smooth and structured after. MedianFilter

This non-linear aside plants a seed for Chapter 4: when noise is of the impulse type, averaging smears edges — so an edge detector's smoothing step has to be chosen carefully for the noise it actually faces. Blur and sharpen are nice in themselves, but the real question of this tutorial is staring at us now: how do we turn the "edge feeling" into a number a computer can compute?

3. How do we turn an edge "feeling" into a number a computer can compute?

The key move is to treat brightness as a landscape and ask how steep it is. In one dimension, that's the derivative — "how fast a function is changing," the slope intuition from school. Large derivative means rapid change, which is our definition of an edge; zero derivative means a flat region, which is not. And crucially, images are discrete — pixels sit at integer positions — so the derivative is approximated by something even simpler than calculus: subtract neighboring pixels. No limit laws, just subtraction. ImageGradient

An image is two-dimensional, so we need the rate of change in both directions: $\partial I/\partial x$ (how brightness changes as we move right) and $\partial I/\partial y$ (how it changes as we move down). Together they form the gradient, a vector at every pixel with two pieces of information:

  • Magnitude $\sqrt{G_x^2 + G_y^2}$ — how strong the change is, i.e. edge strength;
  • Direction $\theta = \text{atan2}(G_y, G_x)$ — which way brightness increases most steeply.

The second quantity carries a beautiful geometric fact, and it is worth pausing on because the whole of Canny's non-maximum suppression hinges on it. The gradient points straight up the brightness "hill." The edge itself runs along the boundary, where brightness stays roughly constant. So the two are perpendicular — the gradient and the edge meet at a right angle, and the gradient always points toward the brighter side.

Gradient ⊥ Edge diagram: a square split diagonally dark/light, a blue arrow along the boundary and a red arrow pointing into the dark region at a right angle, labeled "Gradient points toward brighter region"

Caption: A square split into a darker and a lighter triangle. The blue arrow runs along the edge direction (the boundary); the red gradient arrow meets it at a right angle (⊥). The rule the figure is built on: the gradient points toward the brighter region, and it is perpendicular to the edge — which is exactly why Canny's NMS scans along the gradient direction.

Now we need a filter that computes this. Enter the Sobel operator, a 3×3 kernel designed to compute a derivative by convolution. It actually does two jobs at once:

$$G_x = \begin{bmatrix} -1 & 0 & +1 \ -2 & 0 & +2 \ -1 & 0 & +1 \end{bmatrix}, \qquad G_y = \begin{bmatrix} -1 & -2 & -1 \ 0 & 0 & 0 \ +1 & +2 & +1 \end{bmatrix}$$

Look at $G_x$'s structure: the column $[-1, 0, +1]$ repeated — that is differencing left-to-right — but each row is weighted by $[1, 2, 1]$, a smoothing tap. So Sobel is differentiation and smoothing fused into one kernel: it differentiates in one direction while gently averaging in the other, which is why it is far less noisy than the naive two-pixel difference. The two kernels split the work: $G_x$ computes $\partial I/\partial x$, $G_y$ computes $\partial I/\partial y$. SobelOperator

Here is the classical confusion, and the deck confronts it head-on: why does Sobel X detect vertical edges? The X in Sobel X names the direction of change, not the orientation of the edge. Sobel X computes the horizontal derivative; if brightness changes left-to-right, the boundary separating the two sides must run vertically. The matched Einstein demo makes it visible: same portrait, one operation apart.

Einstein portrait with the Sobel X kernel drawn between: original photo, 3x3 kernel matrix, and the result labeled "Vertical Edge (absolute value)"

Caption: Middle: the $G_x$ kernel, with its zero column down the center. Right: the result — structures that run vertically (face outline, eyebrows, collar folds, curtain pleats) light up. Sobel X responds to horizontal brightness change, which is what a vertical boundary is.

Einstein portrait with the Sobel Y kernel drawn between: original photo, 3x3 kernel matrix, and the result labeled "Horizontal Edge (absolute value)"

Caption: The $G_y$ kernel has a zero row through the middle. Its output lights up horizontal structures instead — the same face, but the hairline and lip seams dominate. Side by side with the previous figure, the "X names the direction of change" rule lands visually.

Two practical details matter when you call this in OpenCV. First, use cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=3): gradients can be negative (brightness can go down), and an 8-bit output type clips negatives to zero — you would silently lose half the edges. Second, the magnitude combines both directions with $\sqrt{G_x^2 + G_y^2}$; pure $G_x$ or $G_y$ alone shows only one orientation. SobelOperator

The Sobel operator is not the only option in its family. Prewitt is its simpler cousin: identical structure, but the smoothing tap is uniform $[1,1,1]$ instead of center-weighted $[1,2,1]$ — slightly noisier, slightly cheaper. And then there is a different strategy altogether: the Laplacian, a second-derivative operator:

$$\Delta f = \frac{\partial^2 f}{\partial x^2} + \frac{\partial^2 f}{\partial y^2}, \qquad \Delta f(x,y) \approx f(x{+}1,y) + f(x{-}1,y) + f(x,y{+}1) + f(x,y{-}1) - 4f(x,y)$$

Where the first derivative has its peak at an edge, the second derivative passes through zero — it crosses from positive to negative exactly at the transition. That "zero-crossing" is a wonderfully precise edge locator, and the Laplacian is isotropic, meaning it responds to edges of every orientation equally. The catch, and it is a serious one: second derivatives amplify high-frequency noise brutally. Applying a bare Laplacian to a noisy image gives you noise, not edges. The classic fix, proposed by Marr and Hildreth in their 1980 edge-detection theory, is to smooth with a Gaussian first and then take the Laplacian — the Laplacian of Gaussian (LoG), whose kernel is the famous "Mexican hat" shape. MarrHildreth

Three stacked line plots comparing the Difference of Gaussians curve with the Mexican-hat function, then overlaid

Caption: This is the shape of a LoG kernel in cross-section: a tall positive peak surrounded by a ring of negative values, decaying to zero — a sombrero. The Difference of Gaussians (blue) is nearly identical to the Mexican-hat function (red), which is why DoG was used as a cheap LoG stand-in. MarrHildreth

So after this chapter we have two families of edge locators: first-derivative peaks (Sobel, Prewitt) and second-derivative zero-crossings (Laplacian, LoG). But look honestly at what they output. A Sobel magnitude map is still thick — the edge band is several pixels wide — and it is still noisy, and it still has not made a yes/no decision about any single pixel. All we have is a strength score at every pixel. What is missing to turn that into a clean, one-pixel-thick, decided edge map?

4. Canny: how five decisions turn a noisy gradient map into one-pixel edges

In 1986, John Canny, then an MIT graduate student, published the answer in a paper that would become one of the most cited in computer vision, and forty years later cv2.Canny is still OpenCV's default edge detector. Canny's move was to stop eyeballing operators and instead define what an optimal edge detector should do, then engineer an algorithm that does it. His three design goals:

GoalMeaning
Low error rateDetect all real edges, produce no false ones
Precise localizationThe detected edge sits exactly on the true boundary
Single responseOne edge per real boundary, one pixel wide

Sobel fails all three: noisy, thick, and capable of firing multiple times along one edge. Canny1986

The solution is a five-step pipeline, and it is worth reading as "each step fixes one specific flaw from Chapter 3": Gaussian smoothing → gradient (Sobel) → non-maximum suppression → dual threshold → hysteresis.

Step 1, Gaussian smoothing, attacks the noise. Random pixel fluctuations produce fake gradients, so we blur first — this is Week 2's machinery earning its keep. (Fine print: cv2.Canny already applies its own 5×5 Gaussian internally; for noisy images the deck recommends adding your own cv2.GaussianBlur pass anyway.)

Step 2, the Sobel gradient, produces the magnitude and direction we already know. And here is where we see exactly what is still wrong. Look at the Sobel output on a tiger image — the pipeline step-2 result that the LearnOpenCV walkthrough shows:

Tiger photo side by side with its Sobel filter output: doubled edges and fur texture still visible on black

Caption: After Step 2 the tiger's outline comes back doubled — a bright and a dim line from the two sides of each transition — and the fur texture inside the body is still showing as edge-like noise. This is precisely the "thick, noisy, undecided" gradient map Chapter 3 ended on. Edges are visible; decisions have not been made. LearnOpenCV

Step 3, non-maximum suppression (NMS), is Canny's core innovation and it fixes the thickness. A gradient band is several pixels wide, but along any cross-section of the band, one pixel is the ridge — the local maximum. NMS keeps the ridge pixel and deletes its neighbors along the gradient direction. Because the gradient is perpendicular to the edge (Chapter 3's geometry), scanning along the gradient direction means scanning across the edge, and only the strongest response survives. To keep this cheap, the continuous gradient angle is snapped to one of four directions:

Angle rangeQuantized toCompare the pixel with
−22.5° to 22.5°0°Left & right neighbors
22.5° to 67.5°45°Diagonal neighbors (NE & SW)
67.5° to 112.5°90°Top & bottom neighbors
112.5° to 157.5°135°Diagonal neighbors (NW & SE)

Four 3x3 grids showing how the gradient direction is quantized: center pixel P with arrows to opposite neighbors at 0°, 45°, 90°, 135°

Caption: Each grid shows one quantized direction: from the center pixel P, draw a line in that direction and look at the two pixels it hits. Keep P only if its gradient magnitude beats both neighbors along that line; otherwise suppress it. Four directions is a deliberate approximation — in practice the continuous angle is rounded to the nearest 45° before the comparison.

Step 4, dual threshold, attacks the noise — but this time by classifying confidence, and it exists because a single threshold is an impossible trade-off. Set it too high and noise disappears but real edges break into gaps; set it too low and edges stay connected but false edges flood in. Canny's answer: use two thresholds and sort every pixel into three classes:

ClassRule
Strongmagnitude ≥ T_high — keep unconditionally, these are anchors
WeakT_low ≤ magnitude < T_high — keep only if connected to strong
Non-edgemagnitude < T_low — discard

Pixel grid of the dual-threshold and hysteresis logic: strong pixels in red, weak pixels kept in green when connected, isolated weak pixels crossed out in orange, with the T_high/T_low color bar

Caption: The magnitude bar on the right is cut by two dashed lines, T_high and T_low. Red pixels are strong — always kept, they anchor the edge. Green pixels are weak-but-connected — kept. The orange pixels crossed out are weak-but-isolated — noise in disguise, discarded. Strength alone does not decide; connectivity does.

Step 5, hysteresis, is the payoff of the three-class scheme: strong edges are seeds; weak edges near seeds grow; isolated weak edges die. The algorithm starts from every strong pixel, walks its 8-connected neighbors in search of weak pixels, promotes every weak pixel it can reach, and discards the leftover weak pixels that touch nothing strong. A faint section of a real edge that a single high threshold would have carved out is saved because it is physically attached to a bright, certain part of the same edge. Canny1986

And the whole five-part machine collapses into one line: edges = cv2.Canny(img, 50, 150). The two arguments are T_low and T_high, and a 2:1–3:1 ratio is the recommended starting point. The canonical OpenCV demo shows the input/output contract:

Grayscale footballer side by side with the Canny output: thin white edges on a black background

Caption: Left: the input grayscale image. Right: cv2.Canny(img, 100, 200) output — clean single-pixel edges on black. This is what the full five-step pipeline delivers in one call. OpenCVAPI

Each step exists because a specific Chapter-3 flaw needed fixing, and skipping any of them reproduces the flaw — skip the Gaussian and noise spawns false edges; skip NMS and edges stay thick; use one threshold and you either lose edges or drown in them; skip hysteresis and real-but-faint edges get discarded. Canny1986

So now we have clean, one-pixel-thin edges on the 13-coin photo — a stack of labels saying "edge" or "not edge" for every pixel. That is a huge improvement over 237 noisy fragments. But here is the uncomfortable question we cannot avoid: a pixel label is not an object. How do we get from "these pixels are edges" to "this is one coin, that is another, count them"?

5. From pixel labels to closed curves: the jump that makes counting possible

An edge image and a contour are different kinds of things, and confusing them is the conceptual wall every beginner hits. An edge image is a 2D binary array — each pixel wears a label (edge / not edge), and nothing says which labels belong together. The deck's analogy: an edge image is scattered ink drops on paper. A contour is the opposite: an ordered list of (x, y) points that form a continuous, usually closed curve — a line drawn with a single stroke. The ink drops know nothing about the drawing; the single stroke knows it is one shape.

That is why you cannot count objects from an edge image. Counting needs contours: one closed boundary per object.

How does a computer go from pixels to strokes? cv2.findContours consumes a binary image (white objects on a black background — Canny output works perfectly) and traces boundaries. The deck's intuition is "a blind person touching a wall": you walk along the boundary pixel by pixel, and only when you return to where you started do you know you have found one complete contour.

Raster scan contour tracing diagram: an L-shaped white foreground on dark gray, teal traced boundary with numbered walk steps and a start marker, ending in "Complete contour!"

Caption: A raster scan sweeps the image in rows to find the first white pixel; then the tracer walks the boundary, numbering each step (the red circle marks the start). Step 10 arrives back at the start pixel, and only then does a contour exist. Discovery of the start, walk the wall, close the loop: that is the whole algorithm.

Two design choices in findContours matter a lot in practice. First, the retrieval mode decides which contours come back. The extremes that matter here:

  • RETR_EXTERNAL — only the outermost contours, children ignored. Perfect for simple counting (coins, cells).
  • RETR_LIST — all contours, no parent-child structure.
  • RETR_TREE — all contours plus the full family tree, needed when objects contain holes.

The hierarchy is reported per contour as the tuple [Next, Previous, First_Child, Parent] — four indices into the contour list, with −1 meaning "none." Think of a donut: an outer circle and a hole inside it are two contours, one the parent of the other. With RETR_EXTERNAL you only get the outer circle; with RETR_TREE you get both and the relationship between them. OpenCVAPI

Nested white rectangles on black, each labeled with a red contour number and a green hierarchy order

Caption: The canonical RETR_TREE demo: nested rectangles, each labeled with its contour index (red) and its hierarchy position (green). The outer box is the parent of the box inside it, which is the parent of the next — reading the chain of nested relationships is what the [Next, Previous, First_Child, Parent] tuple encodes. OpenCVAPI

Second, the approximation method decides how greedily the point list is stored. CHAIN_APPROX_NONE records every boundary pixel; CHAIN_APPROX_SIMPLE compresses straight segments to their endpoints. The difference is dramatic: a rectangle stored with NONE needs 734 points, with SIMPLE only 4 — its four corners. OpenCVContours

Two rectangles on black background: a thick blue outline on the left (CHAIN_APPROX_NONE) and a fainter one with dots only at the four corners on the right (CHAIN_APPROX_SIMPLE)

Caption: The same rectangle, stored two ways. Right: CHAIN_APPROX_SIMPLE keeps just the four corner dots — you can reconstruct the whole shape from them. Left: CHAIN_APPROX_NONE stores all 734 boundary pixels, which at this resolution just reads as a thick border. Same shape, very different memory bills. OpenCVContours

contours, hierarchy = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

For coin counting, RETR_EXTERNAL + CHAIN_APPROX_SIMPLE is the standard pairing: we want only the outer boundaries, and we want them cheap. The data-structure jump from pixel labels to closed curves is the watershed between finding boundaries and counting objects. Now we have a list of closed curves. But the Canny edge map of the coin photo still handed us roughly 23 contours where we want 12 — because the coins sit on a mottled background and their engraving produces internal edges. Which of these closed curves are actually coins?

6. Which closed curve is a coin? Measuring shape with area, circularity, and centroid

Once a shape exists as a contour, OpenCV can measure it directly. Three measurements cover most filtering jobs:

  • Area — cv2.contourArea(c).
  • Perimeter — cv2.arcLength(c, True) (the True says the contour is closed).
  • Centroid — via image moments: M = cv2.moments(c), then cx = M['m10']/M['m00'], cy = M['m01']/M['m00']. The zeroth moment M['m00'] is exactly the area, and the two ratios give the center of mass. OpenCVContours
M = cv2.moments(contour)
cx = int(M["m10"] / M["m00"])   # x of the center
cy = int(M["m01"] / M["m00"])   # y of the center

But the hero descriptor of this chapter is circularity — a single number that answers "how round is this shape?" It compares area to the square of perimeter:

$$C = \frac{4\pi A}{P^2}$$

A perfect circle scores exactly 1.0, and anything less round scores lower:

ShapeCircularity
Perfect circle1.0
Square≈ 0.785
Rectangle (2:1)≈ 0.70
Star≈ 0.3
Irregular< 0.5

The intuition: for a fixed amount of boundary, a circle packs the most area. A star spends its perimeter on spikes, so it can't. That single formula separates coins (round, ≈ 0.9+) from the debris of the background — no deep learning needed, just arithmetic on the contour. OpenCVContours

Two secondary descriptors round out the toolkit. Aspect ratio is the width over height of the bounding rectangle — ≈ 1.0 for round or square shapes, far from 1.0 for elongated debris. Extent is contour area divided by bounding-rectangle area — a circle fills about 78% of its box (π/4), a square fills 100%, a concave star much less. The bounding-rectangle geometry is worth seeing once:

A star-like shape on black with a green axis-aligned bounding rectangle and a red rotated rectangle, the red one visibly tighter

Caption: Two ways to box a contour. The green rectangle is what cv2.boundingRect returns — axis-aligned, cheap, but loose. The red rectangle is the rotated minimum-area box from cv2.minAreaRect — it hugs the shape but costs more to compute. A low extent (area ÷ box area) flags irregular, concave silhouettes. OpenCVContours

The full descriptor table from the deck makes the pattern clear: circle ≈ (1.0, 1.0, 0.78), square ≈ (0.78, 1.0, 1.0), star ≈ (0.3, 1.0, 0.4) across circularity, aspect ratio, and extent. Each shape has a signature, and thresholds pick the population you want.

The filter recipe is then two gates: an area range that kills both dust (too small) and background blobs (too large), and a circularity floor that kills everything not round:

valid_contours = []
for c in contours:
    area = cv2.contourArea(c)
    if 100 < area < 10000:
        perimeter = cv2.arcLength(c, True)
        circularity = 4 * np.pi * area / (perimeter ** 2)
        if circularity > 0.7:
            valid_contours.append(c)

Shape descriptors translate "looks round" into numbers a computer can compare — and that is the weapon that will cut the ~23 noisy contours from Chapter 5 down to 12 coin-shaped ones. Everything is now in place: the filters, the gradient, the Canny pipeline, the contour tracer, and the shape filter. Time to assemble the whole chain and see what the real world does to it.

7. Assembling the pipeline: it counts 12 of 13 coins — and the 13th teaches the real lesson

The complete pipeline is the fusion of every chapter so far, in six lines of intent:

Coins → grayscale → Gaussian blur → Canny → findContours → area + circularity filter → drawContours → count

# Step 1: preprocessing
gray = cv2.cvtColor(coins, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 1.5)

# Step 2: edge detection
edges = cv2.Canny(blurred, 50, 150)

# Step 3: contour extraction
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

# Step 4: shape filtering
valid_coins = []
for c in contours:
    area = cv2.contourArea(c)
    if 500 < area < 50000:
        perim = cv2.arcLength(c, True)
        circ = 4 * np.pi * area / (perim ** 2)
        if circ > 0.7:
            valid_coins.append(c)

# Steps 5-6: draw and count
cv2.drawContours(coins, valid_coins, -1, (0, 255, 0), 2)
print(f"Detected {len(valid_coins)} coins")   # -> 12

Every line is a chapter of this tutorial: the blur is Chapter 2's machinery, Canny is Chapter 4's five decisions, findContours is Chapter 5's blind person touching the wall, the filter is Chapter 6's shape descriptors. To see the shape of the whole chain on a real image, here is the identical recipe running on a different 7-coin photo, stage by stage: Coincount

Four stacked macOS windows showing the same coins at successive pipeline stages: grey scale, blurred, "The edges" as white lines on black, and "Result" with green contours drawn

Caption: Four windows, one pipeline: the grayscale input, the blurred version, the Canny edge map, and the final result with green contours drawn around the detected coins. This is a different 7-coin photograph, not the 13-coin challenge image — use it for the shape of the pipeline, not for any count. Coincount

Run the three methods side by side on the 13-coin photo and the arc of the whole tutorial shows up as a single table:

MethodEdge sourceFilteringCountVerdict
1. High-freq + contoursoriginal − blurnone237Fail: noisy, disconnected
2. Canny + contourscv2.Cannynone~23Close but wrong: noise contours remain
3. Canny + smart filtercv2.Cannyarea + circularity12Success

Method 1 is Chapter 1's naïveté — 237 fragments. Method 2 is Chapter 4's clean one-pixel edges but no filtering — better, but still ~23 because the coin engraving and background texture produce closed contours of their own. Method 3 is everything together. The deck's own four-node summary is the architecture we just built, in one picture:

The deck's four-step summary: Edge Detection (find boundaries), Contour Extraction (connect into shapes), Shape Filtering (keep only coins), and the final "Count: 12"

Caption: Edge Detection → Contour Extraction → Shape Filtering → Count: 12. This is the whole tutorial in four boxes, and the number at the bottom is the payoff of Chapters 2 through 6 working as one chain.

And here is the result on the actual 13-coin photo: twelve coins, each outlined in green and numbered at its centroid, with "Count: 12" in the corner.

The 13-coin photo with 12 coins numbered 1 through 12, green outlines, and "Count: 12" in the top-left

Caption: The capstone's output: 12 coins detected, each with a green outline and a number at its centroid. It looks like a complete success. Count the coins in the photo again — there are 13.

Now the deck twists the knife. Recount the photo: there are 13 coins, not 12. Top-center sits a small Roman silver coin that the pipeline completely missed. Why?

PropertyMost coinsThe missing coin
SurfaceSmooth, clear reliefComplex, detailed texture
Edge qualityClean, continuousBroken, fragmented
ContourClosed loop → detectedOpen fragments → lost

The Canny edge map of that coin found plenty of edges — on the coin's surface texture. The problem is the coin's outer boundary broke into fragments, probably under uneven lighting. RETR_EXTERNAL only returns closed outer contours, and a broken ring is not closed; the open fragments were never eligible for the coin filter, so the coin silently vanished.

That one missing coin is worth more than the twelve found ones. It teaches the closing lesson of the entire tutorial: real-world objects don't always play by the rules. Our five-step pipeline reaches 12 out of 13 coins — 92% accuracy — which is impressive and still not perfect. The deck turns this into a genuine challenge rather than an answer: figure out why the 13th coin was missed and modify the code so all 13 are detected. Two hints point the way: switch from RETR_EXTERNAL to RETR_LIST or RETR_TREE (so open-border fragments stop being discarded wholesale), or apply a morphology closing operation before findContours to weld the fragments back into a closed ring. Coincount

And before you mourn this as a failure, notice what the pipeline is really for. The same chain — Canny → findContours → area filter → count — is what runs under microscopes: the deck's example marks 47 cells in a blood smear, with the four steps written under the result. Counting coins today, counting cells in research. OpenCVContours

Microscope blood-smear image on the left labeled "Microscope Input", and the same image on the right with every cell outlined in green and a display reading "Detected: 47 cells"

Caption: Left: the raw microscope image — pink-red blood cells scattered on a light background. Right: the identical pipeline outlined every cell in green and reported 47. The caption under the pair spells out the recipe: Canny → findContours → Area filter → Count. Same architecture, completely different domain.

The same recipe shows up elsewhere, too. Lane detection in cars uses Canny as Step 1 of a lane-departure warning pipeline (ROI mask → Canny → Hough transform → line fitting), and phone document scanners use Canny plus a quadrilateral contour plus perspective correction to un-warp a photographed page. One pipeline — blur, edges, contours, filter, count — runs from coins to cells to lanes to documents. It lands 12 of 13, and the one miss is the reason the last rule of the deck matters more than any of the math: the real world is messy, even a correct pipeline will miss edge cases, so always verify your results.

That is the answer to the opening question. The computer gets from raw pixels to "there are 12 coins" by giving "edge" a precise computable meaning, thinning and connecting those edges into closed curves, measuring the curves, and counting what survives the shape filter — and it earns the right to that answer only because it also tells you when it is wrong.

References

The core sources referenced in this tutorial, recommended for deeper reading:

  1. Image gradient (Wikipedia) — https://en.wikipedia.org/wiki/Image_gradient
  2. OpenCV-Python Tutorial — Smoothing Images (Image Filtering / Convolution) — https://docs.opencv.org/4.x/d4/d13/tutorial_py_filtering.html
  3. Median filter (Wikipedia + scipy.ndimage.median_filter) — https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.median_filter.html
  4. Sobel operator (Wikipedia) + OpenCV cv2.Sobel — https://en.wikipedia.org/wiki/Sobel_operator
  5. D. Marr & E. Hildreth, "Theory of Edge Detection," Proc. R. Soc. Lond. B 207:187–217 (1980) — https://doi.org/10.1098/rspb.1980.0020
  6. J. Canny, "A Computational Approach to Edge Detection," IEEE TPAMI PAMI-8(6):679–698 (1986) — https://doi.org/10.1109/TPAMI.1986.4767851
  7. LearnOpenCV — Edge Detection Using OpenCV — https://learnopencv.com/edge-detection-using-opencv/
  8. OpenCV 4.x docs — cv2.Canny / cv2.Sobel / cv2.findContours — https://docs.opencv.org/4.x/da/d22/tutorial_py_canny.html
  9. OpenCV-Python Tutorials — Contour Features and Shape Descriptors — https://docs.opencv.org/4.x/dd/d49/tutorial_py_contour_features.html
  10. OpenCV coin-counting worked examples (kineticR / Shrishail Gajbhar / techskillguru) — https://github.com/kineticR/Image-coin-counter
237 broken edges in a 13-coin photo: an OpenCV edge-to-contour tutorial