← All posts

Canny: Non-Maximum Suppression and Hysteresis, Taken Apart

Lesson 4 of the Edge Detection unit. Canny is two independent mechanisms, not one tuned number. 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.

Luis Condados ·
The pipeline, one panel per stage: the photograph, its gradient magnitude, the ridge after non-maximum suppression, and what survives hysteresis.
The pipeline, one panel per stage: the photograph, its gradient magnitude, the ridge after non-maximum suppression, and what survives hysteresis.

Lesson 2 left two problems that looked like one: gradient ridges come back about five pixels wide, and no single threshold gives both thin edges and full coverage. They are independent, and Canny fixed them independently. Non-maximum suppression collapses a five-pixel ridge to one pixel without consulting any threshold. Hysteresis then recovers weak pixels a threshold would have dropped, by asking whether they connect to a strong one. On nine hand-worked numbers a single threshold gives either 1 pixel or 9; hysteresis gives the 6 that belong to a real contour.

Where we are

Lesson 3 showed the second derivative can locate an edge to half a pixel but cannot rank one, so it cannot tell a shadow from a shadow. That rules it out as the backbone. We are back with the gradient from lesson 2, which has strength and orientation and is too thick.

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.

compare along the gradient120180110180 ≥ both, so it survivesthe edge runs vertically, unexaminedthe ridge, before and after40 · 120 · 180 · 110 · 30one pixel left, no threshold involved
Non-maximum suppression is a local comparison, not a decision about whether an edge is real. It answers “which pixel of this ridge is the ridge” and leaves “is this ridge worth keeping” entirely alone. That separation is why the two mechanisms can be tuned independently.

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 ThighT_{high} is an edge outright and seeds a chain, anything above TlowT_{low} 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 Tlow=ThighT_{low} = T_{high}. That turns hysteresis off, and the edge count drops to whatever the single threshold keeps, which the third readout shows directly. Then pull TlowT_{low} back down and watch contours grow back from their seeds.

Then raise ThighT_{high} alone. Whole contours vanish at once rather than thinning, because you are removing the seeds a chain needs, not the chain.

What it buys, measured

On the canonical frame, comparing hysteresis against a single threshold tuned to keep the same number of edge pixels, so the comparison is not just “one keeps more”:

(Tlow,Thigh)(T_{low}, T_{high})edge pxhysteresis: contours / mean lengthsingle threshold: contours / mean length
(50, 150)3,810204 / 18.68 px393 / 9.69 px
(75, 200)2,426109 / 22.26 px220 / 11.03 px
(100, 250)1,50967 / 22.52 px134 / 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. Reported per stage, because the stages do not agree equally:

stagethis implementationOpenCV
gradient magnitude scalemedian ratio 1.0001
pixels surviving NMS32,59332,8190.7% apart
weak set at Tlow=50T_{low} = 508,4039,81017% apart
final edge pixels3,8105,564IoU 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 Gx+Gy|G_x| + |G_y| 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.

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 pixelscontourslongestover 100 px
threshold 60 alone427,79973,0253,363700
threshold 180 alone77,7807,2582,173173
60 and 180 together170,4204,3403,363654
Three edge maps of the same cloister: a low threshold full of speckle, a high threshold with broken contours, and hysteresis with clean continuous lines
One low threshold, one high, and both together. The middle panel has lost the far end of the corridor; the right one keeps it without the speckle on the left. Source: “The Cloister Mandapam, in One point perspective” by Sindugab (Wikimedia Commons), CC0.

What it means, and 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 exactly 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.

That is not a tuning failure. No value of σ\sigma, TlowT_{low} or ThighT_{high} 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.

Next

Both limits have the same fix, and it is the last lesson of the unit. 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. Lesson 5 builds it [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 — 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