Part III · Edges, shapes and colour · Lesson 07

The Canny edge detector

Build the most widely used edge detector step by step — smoothing, gradient, non-maximum suppression, double threshold and hysteresis — and check it against cv2.Canny.

Based on: UFRN lecture 09 and activity 04

Run this lessonpython 07_canny.pypython 07_canny.py --save out
Download 07_canny.py

What makes a good edge detector#

John Canny (1986) wrote down three criteria: detect real edges and nothing else, localise them where they really are, and give one response per edge. Sobel + threshold fails the last two: its edges are several pixels thick and broken. Canny's algorithm fixes this in five steps.

1–2. Smooth, then take the gradient#

blurred = cv2.GaussianBlur(img.astype(np.float32), (5, 5), sigmaX=1.4)
gx = cv2.Sobel(blurred, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(blurred, cv2.CV_32F, 0, 1, ksize=3)
magnitude = np.hypot(gx, gy)
angle = np.rad2deg(np.arctan2(gy, gx)) % 180     # direction, folded into [0, 180)

The blur (\(\sigma \approx 1.4\)) controls the scale: more blur, fewer and smoother edges. We keep the direction folded into \([0°, 180°)\), because an edge and its opposite are the same line.

3. Non-maximum suppression#

A thick ridge of gradient magnitude should become a 1-pixel line at its crest. For each pixel, look at its two neighbours along the gradient direction (across the edge); if it is not at least as large as both, it is not the crest — set it to zero. The direction is quantised to the 4 neighbour axes:

Gradient angle Edge runs Compare with
≈ 0° vertically left and right
≈ 45° diagonally top-left and bottom-right
≈ 90° horizontally above and below
≈ 135° diagonally top-right and bottom-left
def non_max_suppression(mag, ang):
    """Keep a pixel only if it is a local maximum along the gradient direction."""
    h, w = mag.shape
    out = np.zeros_like(mag)
    for i in range(1, h - 1):
        for j in range(1, w - 1):
            a = ang[i, j]
            if a < 22.5 or a >= 157.5:     # gradient ~horizontal -> compare left/right
                n1, n2 = mag[i, j - 1], mag[i, j + 1]
            elif a < 67.5:                 # ~45°: image y grows downwards, so this diagonal
                n1, n2 = mag[i - 1, j - 1], mag[i + 1, j + 1]
            elif a < 112.5:                # ~vertical -> compare up/down
                n1, n2 = mag[i - 1, j], mag[i + 1, j]
            else:                          # ~135°
                n1, n2 = mag[i - 1, j + 1], mag[i + 1, j - 1]
            if mag[i, j] >= n1 and mag[i, j] >= n2:
                out[i, j] = mag[i, j]
    return out

Two bugs I made the first time

I wrote np.arctan2(gx, gy) instead of np.arctan2(gy, gx), which mirrors every angle around 45° and compares the wrong neighbours. And in image coordinates y grows downwards: a gradient at +45° points to the bottom-right, so its neighbours are \((i-1, j-1)\) and \((i+1, j+1)\). Test on a circle (below): every direction appears once.

On a circle every gradient direction is present. NMS turns the thick Sobel ring into a 1-pixel contour — the best check that the four cases are right.
On a circle every gradient direction is present. NMS turns the thick Sobel ring into a 1-pixel contour — the best check that the four cases are right.

4. Double threshold#

One threshold cannot be both strict enough to reject noise and permissive enough to keep the faint part of a real edge. Use two: pixels above high are strong edges, pixels between low and high are weak (maybe), the rest are dropped.

STRONG, WEAK = 255, 128


def double_threshold(nms, low, high):
    out = np.zeros(nms.shape, np.uint8)
    out[nms >= high] = STRONG
    out[(nms >= low) & (nms < high)] = WEAK
    return out

5. Hysteresis#

A weak pixel is kept only if it is connected to a strong one through a chain of weak pixels. This is a flood fill: start from every strong pixel and grow into weak neighbours.

def hysteresis(labels):
    """Weak pixels survive only if they are connected (8-neighbourhood) to a strong one."""
    out = np.where(labels == STRONG, 255, 0).astype(np.uint8)
    queue = deque(zip(*np.nonzero(labels == STRONG)))
    h, w = labels.shape
    while queue:                                   # flood fill starting from every strong pixel
        i, j = queue.popleft()
        for di in (-1, 0, 1):
            for dj in (-1, 0, 1):
                y, x = i + di, j + dj
                if 0 <= y < h and 0 <= x < w and labels[y, x] == WEAK and out[y, x] == 0:
                    out[y, x] = 255
                    queue.append((y, x))
    return out

Checking only the 8 immediate neighbours of each weak pixel once is not enough — a weak segment ten pixels long would only keep its first pixel. On this image the single-pass version keeps 3021 edge pixels, the flood fill 3464.

The five steps. Thick gradient ridges become thin crests (3), which are split into strong and weak (4); hysteresis keeps weak pixels that continue a strong edge (5).
The five steps. Thick gradient ridges become thin crests (3), which are split into strong and weak (4); hysteresis keeps weak pixels that continue a strong edge (5).

Putting it together, and OpenCV#

def canny(img, low, high, sigma=1.4):
    f = cv2.GaussianBlur(img.astype(np.float32), (5, 5), sigma)
    gx = cv2.Sobel(f, cv2.CV_32F, 1, 0)
    gy = cv2.Sobel(f, cv2.CV_32F, 0, 1)
    ang = np.rad2deg(np.arctan2(gy, gx)) % 180
    return hysteresis(double_threshold(non_max_suppression(np.hypot(gx, gy), ang), low, high))


# OpenCV: blur yourself, then give the two thresholds (L2gradient=True uses the true magnitude)
edges_cv = cv2.Canny(cv2.GaussianBlur(img, (5, 5), 1.4), 40, 100, L2gradient=True)

cv2.Canny does steps 2–5 (do the blur yourself), and expects low and high on the same scale as the Sobel magnitude. With L2gradient=True it uses the true magnitude like we do. Our result and OpenCV's agree on 99.8 % of the pixels.

Lower thresholds keep texture (grass); higher ones keep only the main outlines. A common rule of thumb: `high` ≈ 2–3 × `low`.
Lower thresholds keep texture (grass); higher ones keep only the main outlines. A common rule of thumb: `high` ≈ 2–3 × `low`.

Exercises#

  1. Make the thresholds automatic: high = 0.33 * percentile(magnitude, 90), low = high / 2.5. Does it work on rue_rouen.jpg?
  2. Vectorise non-maximum suppression with four boolean masks (one per direction) and np.roll.
  3. Implement hysteresis with cv2.connectedComponents on the weak-or-strong mask: keep every component that contains a strong pixel. Compare with the flood fill.