Before this unit: 2D convolution
Canny: Non-Maximum Suppression and Hysteresis, Taken Apart
Canny is two independent mechanisms. NMS collapses a five-pixel ridge to one pixel by comparing along the gradient; hysteresis keeps a weak chain because it touches a strong seed. Worked on nine numbers: a single threshold gives either 1 pixel or 9, and hysteresis gives the 6 that belong to a real contour. Measured on a photo, that is 204 contours averaging 18.68 px against 393 averaging 9.69 px at the same pixel budget.
Canny’s method turns a wide, noisy edge response into thin, connected outlines, and it has been the textbook edge detector since 1986. Below, a document scanner uses it to find a card photographed at an angle and then flattens it.

- Canny finds the edges. The wood grain mostly drops out; the card’s border is a thin, unbroken outline.
- Straight lines are fitted to the four sides. The app asks for rough corners (or guesses them) and fits each side to the edge pixels near it.
- The corners are where neighbouring sides meet, so they land more precisely than a tap could put them.
- One homography moves the four corners to a rectangle and the card comes out flat (image alignment, unit 3.5).
You have met Canny in:
- Document-scanning apps, in the first step above.
- OpenCV’s
cv2.Canny, one function call with the two thresholds this lesson explains. - Line-finding pipelines, where a Hough transform votes for straight lines among Canny’s edge pixels.
A gradient ridge comes back too thick, and no single threshold makes it both thin and complete; Canny fixes the two problems with two separate steps. Non-maximum suppression collapses a five-pixel ridge to one pixel without consulting any threshold, and hysteresis keeps a weak pixel only if it connects to a strong one: on nine hand-worked numbers a single threshold gives either 1 pixel or 9, hysteresis the 6 that belong to a real contour.
Where we are
Lesson 3 of the Edge Detection unit, and the two steps of its pipeline after the measurement: thin, then link. Lesson 2 left two problems: the gradient has strength and direction, but its ridge comes back about five pixels wide, and no single threshold gives both thin edges and full coverage. The ridge is thick for the reason lesson 1 gave: a real edge is a ramp, and its first derivative is a plateau.
Canny’s contribution was to state what a good detector must do: find every real edge, put it in the right place, and report it once. He then derived an operator from those requirements rather than tuning one [1]. Two of the three fall out as separate mechanisms.
Thin it: non-maximum suppression
A gradient ridge is thick because pixels either side of the true edge also see the brightness change. They see it slightly less strongly, though, and the direction in which the response falls off is the gradient direction, which lesson 2 already computed.
So: keep a pixel only if it is at least as large as its two neighbours along the gradient. Nothing is compared against a threshold and nothing is compared along the edge, so a long contour survives intact while its width collapses.
Link it: hysteresis
Thinning does nothing about coverage. That is the second mechanism, and it changes the question being asked. Instead of “is this pixel strong enough”, hysteresis asks “is this pixel connected to one that is”. That is a property of a chain rather than of a pixel, and it is why no single number can express it. Two thresholds: anything above is an edge outright and seeds a chain, anything above joins a chain it touches, and anything else is dropped. Gonzalez and Woods file it under edge linking for that reason [2].
Now you try
The pipeline on the canonical frame, with all three parameters live. Two things to do deliberately:
Set . That turns hysteresis off, and the edge count drops to whatever the single threshold keeps, which the third readout shows directly. Then pull back down and watch contours grow back from their seeds.
Then raise alone. Whole contours vanish at once rather than thinning, because you are removing the seeds a chain needs to exist at all.
What it buys, measured
On the canonical frame, smoothed with a Gaussian of before the gradient, and comparing hysteresis against a single threshold tuned to keep the same number of edge pixels, so the comparison is not just “one keeps more”:
| edge px | hysteresis: contours / mean length | single threshold: contours / mean length | |
|---|---|---|---|
| (50, 150) | 3,810 | 204 / 18.68 px | 393 / 9.69 px |
| (75, 200) | 2,426 | 109 / 22.26 px | 220 / 11.03 px |
| (100, 250) | 1,509 | 67 / 22.52 px | 134 / 11.26 px |
At an identical pixel budget, hysteresis returns about half as many contours, each about twice as long. The pixels it spends are spent on continuing contours that already have evidence, instead of on isolated fragments scattered across the frame.
How close is this to OpenCV?
The implementation here is written to be read, so it is worth asking what it agrees with. Both pipelines get the same Gaussian, , and the Euclidean gradient norm. Reported per stage, because the stages do not agree equally:
| stage | this implementation | OpenCV | |
|---|---|---|---|
| gradient magnitude scale | — | — | median ratio 1.0001 |
| pixels surviving NMS | 32,593 | 32,819 | 0.7% apart |
| weak set at | 8,403 | 9,810 | 17% apart |
| final edge pixels | 3,810 | 5,564 | IoU 0.573 |
The gradient and the thinning agree. What happens after the threshold does not, and I have not explained why. Ruled out so far: the gradient norm (OpenCV defaults to and is forced to the Euclidean one here), the missing blur (OpenCV applies none of its own, so the caller must), 8-bit quantization of the input, and the magnitude scale. Feeding this pipeline the quantized image moves the final count from 3,810 to 3,715, which is not the gap.
Treat the code here as a teaching implementation, not a drop-in replacement for
cv2.Canny [3]. Comparing the first two rows is what tells you the
mechanism is right; the last row is an open question, and reporting it as
“IoU 0.573, close enough” would be the dishonest reading.
What cv2.Canny does with the hysteresis rule can be checked on a shape built to need
it. A grey square sits on black, with a lighter grey band to its right, so three of its
sides are strong edges and the right-hand side is a weak one: a step of 25 grey levels,
which Sobel turns into a gradient of 100, between and .
Hysteresis should keep the weak side because it touches strong ones; a single threshold
of 150 should drop it.
import numpy as np
import cv2
img = np.zeros((160, 160), np.uint8)
img[:, 100:] = 75 # the band
img[40:120, 40:100] = 100 # the square
gx = cv2.Sobel(img, cv2.CV_32F, 1, 0)
print("weak side gradient:",
np.abs(gx[60:100, 97:103]).max())
for low, high in [(50, 150), (150, 150)]:
edges = cv2.Canny(img, low, high)
side = edges[40:120, 95:105]
rows = side.any(axis=1).sum()
print(f"Canny({low}, {high}): weak side "
f"in {rows} of 80 rows")
show(cv2.Canny(img, 50, 150), "Canny(50, 150)")weak side gradient: 100.0 Canny(50, 150): weak side in 79 of 80 rows Canny(150, 150): weak side in 1 of 80 rows
Hysteresis keeps the weak side on 79 of its 80 rows, and the single threshold keeps one
row, the pixel where it meets the strong top edge. On a clean drawn shape cv2.Canny
does what the worked example says. The gap in the table above is somewhere in how it
treats a photograph, and the third exercise shows one place to look.
In the wild
Hysteresis is the part of Canny that is hardest to argue for in the abstract, and easiest to show on a photograph. Same image, same low threshold; the only change is whether a high threshold seeds the contours.
| edge pixels | contours | longest | over 100 px | |
|---|---|---|---|---|
| threshold 60 alone | 427,799 | 73,025 | 3,363 | 700 |
| threshold 180 alone | 77,780 | 7,258 | 2,173 | 173 |
| 60 and 180 together | 170,420 | 4,340 | 3,363 | 654 |

What it means: the contour count is the number to read. The low threshold alone produces 73,025 contours to get 700 useful ones; the rest is speckle. The high threshold alone is clean but has broken the structure: 173 contours over 100 px, a quarter of what is there, because a line that dips below 180 anywhere is cut in two.
Hysteresis returns 654 long contours out of 4,340 total. It keeps 93% of what the low threshold found worth keeping while producing one seventeenth as many fragments, and its longest contour is as long as the low threshold’s, the full run of the ceiling. That is the trade the second threshold buys, and no single threshold reaches it from either direction.
Where this breaks
Non-maximum suppression assumes a pixel sits on a ridge with a direction. At a junction it does not. Where a column meets the base, two edges cross and the gradient direction is whatever the stronger one happens to be, so the comparison is made along the wrong axis and one of the two arms gets suppressed. Every corner on the temple is the same problem: a corner is the one place where “the gradient direction” is not a well-defined thing.
No value of , or recovers a junction, because the mechanism asks a question the junction has no answer to.
There is a second limit, structural. Everything so far returns curves, and a curve tells you where a boundary runs without telling you where you are along it. Two photographs of the same temple give two sets of contours with no way to say which point on one corresponds to which point on the other.
Exercises
- Write hysteresis for one chain.
keep(chain, low, high)returns how many pixels of a 1-D chain survive: a pixel at or abovehighseeds, and a pixel at or abovelowsurvives if an unbroken run of such pixels connects it to a seed. Contour A from the worked example must give 6, contour B 0, and[60, 40, 170, 88]2, since the 40 cuts the 60 off.
def keep(chain, low, high):
kept = [False] * len(chain)
# mark what connects to a seed
return sum(kept)
print(keep([60, 95, 170, 88, 62, 55], 50, 150),
keep([70, 65, 58], 50, 150),
keep([60, 40, 170, 88], 50, 150))- Predict, then run. In the square cell, raise from 50 to 110 and keep at 150. How many of the weak side’s 80 rows survive?
- Blur first, as the lesson’s pipeline does. Smooth the square with
cv2.GaussianBlur(img, (0, 0), 1.0)beforecv2.Canny(..., 50, 150). The weak side’s gradient is still 64, above . Does it survive? Then runcv2.Canny(blurred, 50, 50)and printedges[36:46, 94:106] // 255, the rows where the weak side meets the top of the square.
What you should see
These come from the Canny cells of the unit notebook, edge_detection.ipynb, and are
the same on OpenCV 4.11 (this page) and 5.0.
- 6, 0 and 2.
- One row: a gradient of 100 is below 110, so the weak side cannot join a chain at all.
- Hysteresis now keeps the weak side on one row of 80, while the single threshold of 50 keeps it on 79. The printout shows why: the weak side’s pixels stop one row short of the top edge, so it is a separate component with no seed in it. That gap sits at a point where three regions meet, which is the junction failure described under “Where this breaks”, on a shape drawn to have one.
Check yourself
Four questions on this lesson. Pick an answer to see why it is right or wrong.
Non-maximum suppression keeps a pixel when it is at least as large as which neighbours?
The response falls off across the edge, in the gradient direction, so that is where the comparison is made. Nothing is compared along the edge, which is why a long contour survives while its width collapses to one pixel.
Contour A is 60 95 170 88 62 55 and contour B is 70 65 58, with Tlow = 50 and Thigh = 150. What does hysteresis keep?
The 170 seeds A and every other pixel in A connects to it above 50. B has no seed, so none of it survives, even though each of its pixels clears the low threshold.
Why can no single threshold reproduce what hysteresis does?
At 150 you keep 1 pixel, at 50 you keep 9, and hysteresis keeps 6. The rule is about chains, so no value of a per-pixel threshold can express it.
Why does Canny fail where a column meets the base?
Two edges cross and the direction is whichever is stronger, so one arm gets suppressed. No setting of σ or the thresholds fixes it, because the mechanism asks a question a junction cannot answer.
Next
Both limits have the same fix. Instead of asking whether brightness changes across one direction, ask whether it changes across both. That is false along an edge, true at a junction, and it gives back a point rather than a curve; it is the branch the unit’s last lesson takes off the gradient step. Before it, lesson 4 tries the other derivative, which crosses zero at an edge instead of peaking, and measures what that costs. Lesson 5 builds the corner detector [4].
References
[1] Canny, J. F. (1986). A Computational Approach to Edge Detection. IEEE Transactions on Pattern Analysis and Machine Intelligence, 8(6), 679-698. doi:10.1109/TPAMI.1986.4767851
[2] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.), section 10.2 “Point, Line, and Edge Detection”, The Canny Edge Detector, p. 791, and Linking Edge Points, subsection Local Processing, p. 797. Pearson.
[3] OpenCV Documentation (5.0). Image Processing: Canny Edge Detector. docs.opencv.org/5.0
[4] Harris, C. G., & Stephens, M. (1988). A Combined Corner and Edge Detector. Proceedings of the Alvey Vision Conference 1988, pp. 1-6. doi:10.5244/C.2.23