Before this unit: 2D convolution
What Is an Edge? Step, Ramp and Roof, and the Two Derivatives That Find Them
An edge is a peak in the first derivative and a sign change in the second. Worked by hand on nine pixels: the peak lands on column 4 and the second difference is exactly zero there. Add noise of at most 0.03 and the zero-crossing moves by 0.05 px, which is small; the real cost of the second derivative is the sign changes noise makes on its own, with no size attached to threshold, which is why Canny is built on the first derivative.
Wherever one thing ends and another begins in a photograph, the brightness jumps. Read a single row of pixels and that jump is visible in the numbers themselves. Below, a yellow row sweeps down a frame of a match, and underneath is the brightness along it.

You have met the one-row view of an edge before:
- Barcode scanners read one line of light and dark and turn the places where it switches into the bar widths.
- Caliper tools in machine vision measure a part by looking along one line for the two places where brightness jumps.
- Contrast autofocus in many cameras moves the lens until edges like these are as steep as they get.
An edge is a place where brightness changes fast, and “fast” has a derivative in it. On nine pixels worked by hand, the first derivative peaks at column 4 with a value of 0.30, and the second derivative crosses zero at the same pixel. Disturbing the row by no more than 0.03 moves the crossing by a twentieth of a pixel; what hurts the second derivative is the extra sign changes noise creates, which carry no size a threshold could reject.
Where we are
Lesson 1 of Edge Detection, and the criterion behind every step of its pipeline: before the image is smoothed, measured, thinned and linked, we need a definition of an edge a computer can evaluate. The only tool it needs is the sliding window from 2D convolution: every derivative below is a small kernel applied at each pixel.
Take the unit’s photograph and the silhouette of a column against the black backdrop. You can point at where the column ends; a program cannot. What it can do is read one row of pixels across that boundary and do arithmetic on the numbers.
An edge is a change, and a derivative measures change
Walk across that boundary and the intensities go from dark to bright. The rate of that change is the first derivative, and on a discrete row of pixels the cheapest honest estimate is the central difference: look one step either way and halve.
Do it twice and you have the second derivative, which measures how fast the rate itself is changing:
The edge shows up in both, differently. Where the intensity climbs fastest, the first derivative reaches a peak. At that same place the climb stops accelerating and starts decelerating, so the second derivative passes through zero. One number to maximise, one to find a sign change in.
Not every edge is a cliff
A column against the backdrop has a sharp boundary. The border of a shadow on the base does not, and a thin highlight along a column’s edge is a third thing again. Gonzalez and Woods name the three profiles worth knowing [1]: a step, a ramp, and a roof.
The roof is the case that breaks a naive reading. If you look for a peak in the first derivative you will find two, one on each side of the ridge, and miss the ridge itself sitting between them.
Now you try
Pick a model, then widen it. Watch the first derivative of the ramp flatten into a plateau as the edge gets wider, and watch the roof produce two zero-crossings where the step produces one.
Then turn up the noise with smoothing still at zero, and read the crossing counter.
How close is this to OpenCV?
The worked example fits in four lines of NumPy, and there are two library functions that
compute the same central difference: cv2.Sobel with ksize=1, which is the kernel
with no smoothing, and np.gradient. Run all three on the nine pixels.
import numpy as np
import cv2
row = np.array([0.20, 0.20, 0.20, 0.20, 0.50,
0.80, 0.80, 0.80, 0.80])
def central(a):
return (a[2:] - a[:-2]) / 2
d1 = central(row) # columns 1..7
d2 = central(d1) # columns 2..6
print("first: ", np.round(d1, 3))
print("second:", np.round(d2, 3))
print("peak at column", 1 + d1.argmax())first: [0. 0. 0.15 0.3 0.15 0. 0. ] second: [ 0.075 0.15 0. -0.15 -0.075] peak at column 4
noisy = np.array([0.22, 0.18, 0.21, 0.19, 0.50,
0.79, 0.82, 0.78, 0.81])
for name, r in [("clean", row), ("noisy", noisy)]:
# a one-row image; [-1 0 1] halved
img = r[None, :].astype(np.float32)
cv = cv2.Sobel(img, cv2.CV_32F, 1, 0,
ksize=1)[0] / 2
print(name)
print(" ours ", np.round(central(r), 3))
print(" cv2 ", np.round(cv, 3))
print(" numpy", np.round(np.gradient(r), 3))clean ours [0. 0. 0.15 0.3 0.15 0. 0. ] cv2 [0. 0. 0. 0.15 0.3 0.15 0. 0. 0. ] numpy [0. 0. 0. 0.15 0.3 0.15 0. 0. 0. ] noisy ours [-0.005 0.005 0.145 0.3 0.16 -0.005 -0.005] cv2 [ 0. -0.005 0.005 0.145 0.3 0.16 -0.005 -0.005 0. ] numpy [-0.04 -0.005 0.005 0.145 0.3 0.16 -0.005 -0.005 0.03 ]
On the interior columns all three agree to the last digit, on both rows. They disagree
at the two ends, and each disagreement is a choice about pixels that do not exist. Ours
has no answer there, which is why it is two columns shorter. cv2.Sobel mirrors the row
across its end, so column 0 of the noisy row reports 0.000. np.gradient switches to a
one-sided difference and reports −0.04. Neither is a measurement of the scene; the
same thing happens in two dimensions in lesson 2.
In the wild
The profiles above are drawn. Here is what an edge in a photograph measures.
Taking 400 rows at random from a temple cloister, finding the steepest transition in each, and measuring its equivalent width: the total rise divided by the steepest slope across it. For a blurred step that comes out at about 2.5σ, and unlike counting pixels between two thresholds it cannot be capped by the window it was measured in:
| edges measured | 400 |
| median width | 2.30 px |
| 10th–90th percentile | 2.11 – 2.80 px |
| wider than 2 px | 98.8% |
| implied Gaussian σ | 0.92 px |

What it means: almost no edge in this photograph is a step. The median transition takes 2.3 pixels and 98.8% take more than two, which is the ramp model from earlier in this lesson, and it is not a defect. Lenses have a point-spread function, sensors integrate over a photosite’s area, demosaicing interpolates and the anti-alias filter blurs on purpose. Every one of those widens a boundary before you see it. A detector built for perfect steps has no work to do in the real world, which is why the operators in the rest of this unit all begin by choosing a scale.
Where this breaks
Take the same nine pixels and disturb each by no more than 0.03, less than one part in thirty and invisible to the eye:
| column | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 0.22 | 0.18 | 0.21 | 0.19 | 0.50 | 0.79 | 0.82 | 0.78 | 0.81 | |
| -0.005 | 0.005 | 0.145 | 0.300 | 0.160 | -0.005 | -0.005 | |||
| 0.075 | 0.1475 | 0.0075 | -0.1525 | -0.0825 |
The first derivative’s peak is still 0.300 at column 4. The second derivative was 0.0000 at column 4 and is now 0.0075, so the sign change no longer happens at a pixel. It happens between columns 4 and 5, and interpolating in a straight line between the two values places it at
What it means: noise you cannot see moved the zero-crossing by 0.05 pixels. That is a small error, and on its own it would be no reason to prefer one derivative over the other.
The real problem shows up on a longer row. On nine pixels the edge dominates the arithmetic; on a real row of several hundred, noise in the flat regions produces sign changes of its own, and a profile containing one edge reports dozens of crossings. The lab above shows it: set noise to 0.05 with smoothing at zero and read the counter. Every one of those crossings looks the same as the real one, because a sign change carries no size. A peak in the first derivative does carry one, and a threshold on it can throw the small ones away.
Two consequences run through the rest of the unit. Even in the clean case, the ramp’s plateau means a real gradient map is thick, so something has to decide which pixel of the ridge is the edge, which is the job of Canny’s non-maximum suppression, lesson 3. And the second derivative needs its noise handled some other way, which is why Canny is built on the first [2] and why the zero-crossing method has smoothing built into it rather than bolted on [3]; lesson 4 measures what that smoothing costs.
Exercises
- Measure the edge’s width. The “In the wild” table uses the equivalent width: the
total rise divided by the steepest slope. Compute it for the nine clean pixels and
store it in
width.
width = 0.0 # total rise / steepest slope
print(width)- Predict, then run. Spread the same rise over four pixels instead of two:
0.20 0.20 0.20 0.35 0.50 0.65 0.80 0.80 0.80. Before running, write down the first derivative’s peak value, how many columns reach it, and the width. - Find the roof’s ridge. Put a single bright pixel in a dark row,
0.2 0.2 0.2 0.2 0.8 0.2 0.2 0.2 0.2, and look for the ridge with the first derivative’s peak. Where does the peak land, and what does the first derivative say at column 4 itself?
What you should see
These come from the “What is an edge?” cells of the unit notebook, edge_detection.ipynb,
and are the same on OpenCV 4.11 (this page) and 5.0.
width = 0.6 / 0.3 = 2.0pixels: the two-pixel transition the row was built with.- A peak of 0.15, held over three columns (3, 4 and 5), and a width of 4.0. Half the slope, twice the width, and a plateau where there was a spike: the ramp in the figure above.
- Two peaks, +0.3 at column 3 and −0.3 at column 5, and 0 at column 4, where the ridge is. A peak search finds the flanks and misses the line, which is why a roof is detected by a zero-crossing of the first derivative.
Check yourself
Four questions on this lesson. Pick an answer to see why it is right or wrong.
On the nine clean pixels the first derivative peaks at column 4 with 0.30. What does the second derivative do at that column?
Where the climb is steepest it stops accelerating and starts decelerating, so the second derivative is 0.0000 at column 4 with opposite signs either side. Both tests point at the same pixel.
On a long noisy row, why is a zero-crossing of the second derivative harder to clean up than a peak of the first?
Noise moved the crossing by only 0.05 px on nine pixels. The trouble is that noise in flat regions makes crossings of its own, and each looks the same as the real one.
A thin bright line in a dark row is a roof. Where does a peak search on the first derivative find it?
The first derivative rises on one flank and falls on the other, and crosses zero on the ridge. A roof is found by that crossing, not by a peak.
In the cloister photograph the median edge is 2.30 px wide and 98.8% of edges are wider than 2 px. What does that say?
A step is an idealisation no camera produces. Every stage between the scene and the file blurs a boundary a little, which is why the operators in this unit begin by choosing a scale.
Next
We have a definition and no way to apply it to an image. A row of pixels is not a photograph, and the operator has to work in two directions at once. Lesson 2 builds it, and answers why three operators that compute the same derivative behave differently on a noisy photo.
References
[1] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.), section 10.2 “Point, Line, and Edge Detection”, Edge Models, p. 772. Pearson.
[2] 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
[3] Marr, D., & Hildreth, E. (1980). Theory of edge detection. Proceedings of the Royal Society of London B, 207(1167), 187-217. doi:10.1098/rspb.1980.0020