Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

DRAFT, not finalized. This chapter is new and still under review.

stough 202-

Preview: Gradient Maps and Edge Detection
  1. Gradient magnitude and direction

  2. Noise, and why we smooth first

  3. Canny edge detection, step by step

    1. Non-maximum suppression

    2. Double threshold and hysteresis

    3. Comparing with scikit-image

  4. Interactive Canny

  5. 🔨 Your Turn: Tune Canny on a Noisy Image

  6. 🧠 Further Efforts

In Spatial Operations we computed the gradient magnitude of an image with the Sobel kernels: a map of how steeply the intensity changes at every pixel, bright at edges and dark in flat regions. That’s a picture of edges, but it isn’t yet an answer to “where are the edges?” Its edges are several pixels thick, it responds weakly to faint edges and strongly to noise, and it says nothing about which pixels belong to the same edge.

An edge detector turns the gradient into a yes/no decision per pixel: a thin, connected outline. In this notebook we’ll build the most famous one, Canny’s (1986), from pieces we mostly already have, and then compare it with scikit-image’s implementation.

 

Gradient Magnitude and Direction

We’ll use the classic camera test image that ships with scikit-image, as floats in [0,1][0,1]. The Sobel kernels estimate the derivatives in xx (along the columns) and yy (down the rows). scipy.ndimage.sobel applies them for us: axis=1 differentiates along the columns, axis=0 along the rows.

The two derivatives together form the gradient vector ∇I=⟨Gx,Gy⟩\nabla I = \langle G_x, G_y \rangle at every pixel. It has a length and a direction:

∣∇I∣=Gx2+Gy2,θ=atan2⁡(Gy,Gx).|\nabla I| = \sqrt{G_x^2 + G_y^2}, \qquad \theta = \operatorname{atan2}(G_y, G_x).

The gradient points in the direction of steepest increase in intensity, so it is perpendicular to the edge: across it, from dark to bright.

((512, 512), dtype('float64'), np.float64(0.0), np.float64(3.6474762569457115))

To see the direction, we can borrow from the Color chapter: hue is an angle too. Below, each pixel’s hue is its gradient direction and its value (brightness) is its gradient magnitude, so flat regions are black and edges are colored by which way they face.

<Figure size 1000x350 with 3 Axes>

In the magnitude image (middle), the outline of the photographer, the camera and tripod, and the buildings in the background are bright; the sky and the smooth parts of his coat are dark. Notice, though, that the grass is covered in a fine speckle: lots of small changes, each a little bit of “edge”.

In the direction image (right), the two sides of a thin object, like a tripod leg, have opposite colors. The gradient points toward the brighter side, so on either side of a thin bright leg it points in opposite directions, toward the leg. Long straight edges keep a single color along their length, because their direction doesn’t change.

 

Noise, and Why We Smooth First

Derivatives amplify noise. A derivative measures the difference between neighbors, and noise is exactly where neighbors differ for no good reason. Let’s add a little Gaussian noise to the image, of a standard deviation of only 5% of the intensity range, and look at the gradient magnitude again.

<Figure size 800x400 with 2 Axes>

The noise is barely noticeable in the image, but it’s all over the gradient magnitude. In the sky (the top-left corner), the average magnitude jumps tenfold, from about 0.02 to 0.23, and its brightest noise pixels are as strong as many of the real edges. Any threshold low enough to keep the faint edges would also keep thousands of noise pixels.

The cure is to smooth before differentiating, with a Gaussian low-pass filter. The noise averages out, while edges, which extend over many pixels, survive. And because convolution is associative, smoothing and then differentiating is the same as convolving once with the derivative of a Gaussian:

∂∂x(gσ∗I)=(∂gσ∂x)∗I.\frac{\partial}{\partial x}\left(g_\sigma * I\right) = \left(\frac{\partial g_\sigma}{\partial x}\right) * I.

That is the kernel we looked at as a 3D height map in filters_explore3D. The Gaussian’s σ\sigma sets the scale of the edges we’re looking for: larger σ\sigma ignores more noise and fine texture, and keeps only the broad outlines.

<Figure size 1000x350 with 3 Axes>

At σ=1\sigma = 1 the sky is already much quieter, and by σ=2\sigma = 2 its average magnitude is nearly back to that of the clean image. By σ=4\sigma = 4 the noise is gone and the grass texture has faded to faint blobs, but the edges have become wide and soft, and the tripod’s two thin legs are blurring together. There’s no single right σ\sigma: it depends on how noisy the image is and how fine the edges you care about are. This trade-off between noise suppression and localization is fundamental, and Canny’s method has a σ\sigma parameter for exactly this reason.

 

Canny Edge Detection, Step by Step

Canny’s detector has four steps:

  1. Smooth and differentiate: Gaussian smoothing, then Sobel, giving the gradient magnitude and direction. (Done.)

  2. Non-maximum suppression: thin each edge to a one-pixel-wide ridge.

  3. Double threshold: classify the survivors as strong or weak edges, and discard the rest.

  4. Hysteresis: keep the weak edges only where they connect to strong ones.

We’ll work with the noisy image at σ=2\sigma = 2.

((512, 512), dtype('float64'), np.float64(5.8702306506753004e-05), np.float64(1.2763935310629921))

Non-maximum suppression

Across an edge, the gradient magnitude rises and falls like a hill. The edge itself is the top of the hill, the one pixel where the magnitude is largest. Non-maximum suppression keeps a pixel only if its magnitude is at least as large as that of its two neighbors along the gradient direction, that is, across the edge. Pixels on the slopes of the hill are set to zero, leaving a thin ridge.

With only 8 neighbors to choose from, we round each gradient direction to the nearest of four: horizontal (0°0°), the two diagonals (45°45° and 135°135°), and vertical (90°90°). A direction and its opposite share the same pair of neighbors, so angles only matter modulo 180°180°. Remember that yy points down, so a 45°45° gradient points down and to the right.

<Figure size 800x400 with 2 Axes>

Rather than loop over pixels, non_max_suppression handles one direction at a time. Slicing the zero-padded magnitude array with an offset gives, for every pixel at once, the neighbor one step ahead (or behind) in that direction. keep then marks the pixels whose direction falls in this bin and that are at least as large as both neighbors.

Zoom in on the right-hand image: the wide, soft edges are now thin lines, one pixel wide. But every little noise bump in the sky is also the top of its own (tiny) hill, so the sky is full of short, faint ridges. That’s what thresholding is for.

Double threshold and hysteresis

A single threshold forces a bad choice: set it high and the faint parts of real edges break up; set it low and the noise ridges get in. Canny uses two thresholds instead:

  • pixels above the high threshold are strong edges, kept for sure;

  • pixels between the low and high thresholds are weak edges, kept only if they are connected to a strong edge, possibly through other weak pixels.

This last step is called hysteresis. A real edge usually has a strong part somewhere along it, and the weak parts of the same edge are connected to it. Noise ridges are short, isolated, and usually all weak. To find connections, we label the connected groups of weak-or-strong pixels with scipy.ndimage.label, counting diagonal neighbors as connected, and keep every group that contains at least one strong pixel.

11744 strong pixels, 22174 weak, 20078 in the final edges
<Figure size 800x400 with 2 Axes>

On the left, the strong pixels trace the main outlines, with gaps wherever the edge is faint, while weak pixels fill those gaps and also scatter across the sky and grass. On the right, hysteresis has kept the weak pixels that continue a strong edge and dropped the isolated ones: the outlines of the photographer, his camera and tripod are long and mostly unbroken, and the sky is empty.

The grass is another story. Its texture produces genuinely strong gradients at this scale, so Canny keeps much of it. These are real edges, just not ones we care about. A larger σ\sigma or higher thresholds would remove them, at the cost of some detail elsewhere; you’ll get to try that below.

The thresholds here are in the units of the gradient magnitude (the Sobel kernels’ weights make it about 8 times the actual derivative). I chose them by looking at the result. Choosing thresholds automatically is a hard problem in general; one common rule of thumb sets them as percentiles of the gradient magnitude.

Comparing with scikit-image

skimage.feature.canny implements the same four steps. It smooths and differentiates the same way, so we can give it the same σ\sigma and thresholds.

ours: 20078 pixels, skimage: 19084, in both: 17293
<Figure size 800x400 with 2 Axes>

The two results trace the same outlines, and most of their edge pixels coincide. Along the main outlines they agree almost everywhere. Most of the disagreements are in the grass, where short fragments are kept by one and not the other, plus a few places where a line is shifted by a pixel. Two implementation details account for that. scikit-image doesn’t round the gradient direction to one of four bins: it interpolates the magnitude between the two nearest neighbors on each side, which places diagonal edges more precisely. It also handles the image border differently. Small differences in non-maximum suppression then change which weak pixels connect to strong ones.

Our couple of dozen lines of code reproduce the essentials of a classic algorithm. In practice, use the library version: it’s faster, better tested, and can choose thresholds from quantiles for you (use_quantiles=True).

 

Interactive Canny

Here’s canny with sliders for σ\sigma and the high threshold (the low threshold is kept at half the high one). Try it on the clean and noisy images, by changing J below.

<Figure size 800x400 with 2 Axes>

 

🔨 Your Turn: Tune Canny on a Noisy Image

Task: Choose an image with clear objects and add noise to it: Gaussian noise with a standard deviation of 0.1 (twice what we used), or a photo of your own taken in dim light. Find the Canny settings (σ\sigma, low and high thresholds) that best recover the outlines of the main objects while ignoring the noise. Show your best result, and one result each where σ\sigma is clearly too small and clearly too large. In a few sentences, describe the trade-off you ran into.

Guidance:

  • Use the interactive figure above (set J to your noisy image) to explore, then record your settings and show the final results in ordinary figures.

  • Decide what a “good” result is before tuning: which outlines matter? Running canny on the clean image gives you a reference to compare against.

  • Change one parameter at a time. Which one mostly controls the noise speckle, and which mostly controls the gaps in real edges?

  • Check whether the ratio between the low and high thresholds matters, or only their overall level.

 

🧠 Further Efforts

  1. Which way do edges face? Make a histogram of the gradient directions θ\theta, weighted by magnitude so that strong edges count more, for a photo of a city street and for a photo of a forest or field. How do the two histograms differ, and why?
    Guidance: np.histogram takes a weights argument. Directions that differ by 180°180° describe the same edge orientation, so you may want to fold θ\theta into [0°,180°)[0°, 180°).

  2. Interpolated non-maximum suppression. Our non_max_suppression rounds the direction to one of four bins. Instead, estimate the magnitude at the exact point one pixel along the gradient by interpolating between the two nearest neighbors, as scikit-image does. Does it bring our result closer to canny’s?
    Guidance: scipy.ndimage.map_coordinates with order=1 does bilinear interpolation at arbitrary positions, as in coord_ops. Compare the two by counting agreeing pixels, as above.

  3. Edges in color. Two regions can have the same brightness but different hues, and then a grayscale edge detector misses the boundary between them. Design an edge detector for color images, and find (or make) an image where it finds an edge that grayscale Canny misses.
    Guidance: One approach computes the gradient magnitude of each channel and combines them at every pixel. Which colorspace you work in matters; see the Color chapter.