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.

Luis Condados · · Updated October 3, 2026 · 9 min read Intermediate
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.

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.

A card lying at an angle on a wooden bench; the photo turns into thin white edges, a green outline is drawn along the card's four sides, red dots mark the corners, and the card is warped into an upright rectangle
The four steps of a scanner app, run on a real photograph. The edge map shown is smoothed more than the one the sides were fitted to, so the wood grain does not bury the card. “Stationery on a bench (Unsplash).jpg” by Brigitte Tohm (Wikimedia Commons), CC0.
1 · Cannythin, connected edges2 · four linesone fitted per side3 · four cornerswhere sides meet4 · homographycorners → rectangle
What the scanner above runs, one box per step. Only the first is this lesson. Finding lines in edges is unit 3.2, intersecting two lines is one cross product in homogeneous coordinates, and the last step is image alignment.
  1. Canny finds the edges. The wood grain mostly drops out; the card’s border is a thin, unbroken outline.
  2. 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.
  3. The corners are where neighbouring sides meet, so they land more precisely than a tap could put them.
  4. 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.

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. 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 to exist at all.

What it buys, measured

On the canonical frame, smoothed with a Gaussian of σ=1.4\sigma = 1.4 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”:

(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. Both pipelines get the same σ=1.4\sigma = 1.4 Gaussian, Tlow=50T_{low} = 50, Thigh=150T_{high} = 150 and the Euclidean gradient norm. Reported per stage, because the stages do not agree equally:

stagethis implementationOpenCV
gradient magnitude scale——median 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.

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 Tlow=50T_{low} = 50 and Thigh=150T_{high} = 150. Hysteresis should keep the weak side because it touches strong ones; a single threshold of 150 should drop it.

cv2.Canny on a square with one weak side
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)")
Output
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
Canny(50, 150)
Canny(50, 150)

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 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: 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 σ\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.

Exercises

  1. Write hysteresis for one chain. keep(chain, low, high) returns how many pixels of a 1-D chain survive: a pixel at or above high seeds, and a pixel at or above low survives 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.
Your turn
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))
  1. Predict, then run. In the square cell, raise TlowT_{low} from 50 to 110 and keep ThighT_{high} at 150. How many of the weak side’s 80 rows survive?
  2. Blur first, as the lesson’s pipeline does. Smooth the square with cv2.GaussianBlur(img, (0, 0), 1.0) before cv2.Canny(..., 50, 150). The weak side’s gradient is still 64, above TlowT_{low}. Does it survive? Then run cv2.Canny(blurred, 50, 50) and print edges[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.

  1. 6, 0 and 2.
  2. One row: a gradient of 100 is below 110, so the weak side cannot join a chain at all.
  3. 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.

  1. Non-maximum suppression keeps a pixel when it is at least as large as which neighbours?

  2. 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?

  3. Why can no single threshold reproduce what hysteresis does?

  4. Why does Canny fail where a column meets the base?

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