Before this unit: 2D convolution
Image Gradients: Central Differences, Prewitt and Sobel
Three operators compute the same derivative and behave differently on a noisy photo. Worked by hand on a 3x3 patch: Sobel gives Gx = Gy = 1.80, a magnitude of 2.55 and an orientation of 45 degrees, while Prewitt gives 1.20 and 1.70 on identical pixels. Measured across a noise sweep, Prewitt beats Sobel at every level, and the reason is that its normalised weights have a smaller sum of squares.
The gradient says, at every pixel, how quickly brightness changes and in which direction. Below, each pixel of a match is coloured by that direction and made brighter the stronger the change is.

The direction as well as the size of the change is used in:
- Emboss and relief filters, which are a derivative taken in one chosen direction.
- Focus peaking on mirrorless cameras, which paints the pixels with the strongest gradient so you can see what is sharp.
- Pedestrian detectors built on HOG, which describe a region by a histogram of its gradient directions.
- SIFT, whose descriptor is also built from gradient directions (unit 3.3).
A photograph has edges running every way, so the change has to be measured in two directions at once and combined into a strength and a direction. On one 3x3 patch Sobel returns a magnitude of 2.55 at 45 degrees. Across a noise sweep Prewitt recovers 0.7968 of its clean edge set at sigma = 20 against Sobel’s 0.7814, and the reason is one line of arithmetic.
Where we are
Lesson 1 defined an edge as a peak in the first derivative, on one row of pixels. A photograph is not one row. A column’s sides run vertically, the base runs horizontally, and the top of the temple is a staircase of both. A single row of differences finds the vertical edges and misses the horizontal ones entirely. This is lesson 2 of the Edge Detection unit, the step of its pipeline that measures the change at every pixel, and every operator in it is a 3x3 kernel slid over the image in the sense of 2D convolution.
Two directions, then combine
Measure the derivative twice, once horizontally and once vertically, and treat the pair as a vector. Its length says how strong the change is and its direction says which way the change points:
The gradient points across the edge, uphill toward the bright side, so the edge itself runs perpendicular to . That direction is what lesson 3 needs in order to thin a ridge, and it is carried forward again by every feature detector built on gradients [4], so orientation is not decoration here.
Measuring with the central difference from lesson 1 works, and it uses three pixels. The operators in practice use nine. The extra six are there to average out the rows either side of the one being differenced, and how they are averaged is the entire difference between the named operators [1]. The best known of them was never published as a paper: Sobel and Feldman presented it as a talk in 1968 and it reached print five years later, in Duda and Hart’s textbook [3].
Now you try
First the mechanics: slide the kernel one window at a time and watch the multiply-accumulate that produced the numbers above.
Then the same kernel on a real image. Switch between sobelx and sobely and
notice that each is blind to edges running along its own direction, which is why
you need both and then the magnitude.
How close is this to OpenCV?
Writing Sobel yourself takes two lines of NumPy, and comparing it with cv2.Sobel is
the quickest way to know you have the mechanism right. The numbers elsewhere in this
lesson come from the temple photograph, run in the unit’s notebook. These cells use a
shape whose answer is known before you run anything: a white disc on black, where the
gradient on the rim has to point into the disc.
import numpy as np
import cv2
# a white disc on black
img = np.zeros((160, 160), np.float32)
cv2.circle(img, (80, 80), 50, 1, -1)
def sobel_x(a):
# difference along each row
d = a[:, 2:] - a[:, :-2]
# [1 2 1] down the columns
return d[:-2] + 2*d[1:-1] + d[2:]
def sobel_y(a):
# the same kernel, turned
return sobel_x(a.T).T
gx, gy = sobel_x(img), sobel_y(img)
mag = np.hypot(gx, gy)
show(mag / mag.max(), "our gradient")
print("ours:", gx.shape)ours: (158, 158)
ref_x = cv2.Sobel(img, cv2.CV_32F, 1, 0, ksize=3)
ref_y = cv2.Sobel(img, cv2.CV_32F, 0, 1, ksize=3)
print("OpenCV:", ref_x.shape)
# compare inside the 1-pixel border
ix = ref_x[1:-1, 1:-1]
iy = ref_y[1:-1, 1:-1]
print("max diff Gx:", np.abs(gx - ix).max())
print("max diff Gy:", np.abs(gy - iy).max())
rim = {"right": (80, 130),
"left": (80, 30),
"top": (30, 80),
"bottom": (130, 80)}
for name, (r, c) in rim.items():
x, y = ref_x[r, c], ref_y[r, c]
a = np.degrees(np.arctan2(y, x))
# ours is one pixel smaller per side
mine = gx[r - 1, c - 1]
print(f"{name:6} Gx={x:+.0f} "
f"Gy={y:+.0f} angle={a:+.0f} "
f"ours={mine:+.0f}")OpenCV: (160, 160) max diff Gx: 0.0 max diff Gy: 0.0 right Gx=-4 Gy=+0 angle=+180 ours=-4 left Gx=+4 Gy=+0 angle=+0 ours=+4 top Gx=+0 Gy=+4 angle=+90 ours=+0 bottom Gx=+0 Gy=-4 angle=-90 ours=+0
Inside the image the two agree to the last bit. The shapes do not: ours is 158x158
because a 3x3 window centred on the outermost ring hangs off the image, and OpenCV
returns 160x160 because it fills the missing pixels by reflecting the image across its
border. On the right-hand rim OpenCV reports and an angle of 180°: the
gradient points left, uphill into the bright disc, and the 4 is the sum of the
[1 2 1] weights times a step of 1. That unnormalised scale is the same one that
made Sobel’s magnitude 1.5x Prewitt’s on the patch above.
Which averaging wins, measured
The three operators differ only in the smoothing vector: none, [1 1 1], [1 2 1].
Running all three on the canonical frame with noise added, at a matched budget of
the strongest 5% of pixels, and asking how much of the clean-image edge set survives:
| operator | sigma = 2 | sigma = 5 | sigma = 10 | sigma = 20 |
|---|---|---|---|---|
| central difference | 0.9662 | 0.9158 | 0.8265 | 0.6262 |
| Prewitt | 0.9814 | 0.9509 | 0.8995 | 0.7968 |
| Sobel | 0.9802 | 0.9472 | 0.8948 | 0.7814 |
Averaging is worth 17 points at sigma = 20, which is the expected result. The
unexpected one is that Prewitt leads Sobel at every level. The gap is small against
the run-to-run spread at low noise (1.5 times the larger standard deviation of the
two at sigma = 2: a gap of 0.0012 against standard deviations of 0.0008 and 0.0004)
and grows to 3.4 times at sigma = 20 (0.0154 against 0.0033 and 0.0045). The
standard deviations are under noise_robustness in output/edge_numbers.json. No single level settles it on
its own; what carries the claim is that the ordering is the same at all four.
The mechanism is arithmetic. Noise passed through a weighted average has its variance scaled by the sum of the squared weights. Normalise each smoothing vector so it preserves brightness and compare:
A box average spreads its weight evenly, and for a fixed support that is the
arrangement with the smallest sum of squares. Prewitt therefore passes
, about 11% less noise power, than Sobel. What Sobel’s
[1 2 1] buys instead is a response that varies less as an edge rotates, which
matters when you need the orientation and not only the magnitude.
Scope: one image, one edge budget, synthetic Gaussian noise, five runs per level. The ordering was consistent at every level tested, which makes variance an unlikely explanation and does not make it a law.
In the wild
Gradients have a direction as well as a size, and on a real subject that direction is not uniformly distributed. Buildings are the clearest case: they are built plumb and level, so their edges should pile up at two orientations ninety degrees apart.
Taking the strongest 5% of gradients in the cloister and histogramming their orientation over 0–180°:
| share of strong gradients | |
|---|---|
| within ±10° of the peak (0°) | 53.3% |
| within ±10° of the perpendicular (90°) | 12.2% |
| those two bands together | 65.5% |
| what a uniform distribution would give | 23.3% |

What it means: two bands covering 23% of the possible orientations hold 65.5% of the strong-gradient pixels, nearly three times what chance would give. That is the building, recovered from nothing but first derivatives. It is also the practical reason the orientation half of a gradient is worth keeping: a magnitude tells you that something changed, and it is the angle that says the change belongs to a column rather than to a shadow. Every method later in the track that bins orientations, from Hough to SIFT’s descriptor, is living on this histogram.
The asymmetry between the two peaks (53% against 12%) is not the operator preferring one axis: Sobel’s and are the same kernel rotated. I did not measure what causes it. Two explanations fit and I cannot separate them from this artifact: the frame may hold more vertical structure than horizontal, and in a one-point perspective shot the horizontal lines that run down the corridor converge toward the vanishing point, so they leave the 90° band and spread across other orientations.
Where this breaks
We have a magnitude per pixel. Turning that into edges means picking a threshold, and no value of it works. Sweeping the threshold on the canonical frame:
| threshold | magnitude px | thinned px | ridge thickness | coverage |
|---|---|---|---|---|
| 10 | 76,266 | 14,946 | 5.10 px | 1.000 |
| 20 | 60,202 | 12,651 | 4.76 px | 0.846 |
| 40 | 40,378 | 9,596 | 4.21 px | 0.642 |
| 80 | 20,388 | 5,729 | 3.56 px | 0.383 |
| 160 | 6,623 | 2,295 | 2.89 px | 0.154 |
Ridge thickness is the magnitude pixels divided by the thinned pixels at the same threshold, so it is the average width of an edge you get back. Coverage is how much of the lowest threshold’s edge structure survives.
At threshold 10 an edge comes back 5.10 pixels wide, which is the ramp’s plateau from lesson 1 showing up in two dimensions. Raise the threshold to 160 and the ridge narrows to 2.89 pixels, but you are down to 15.4% of the edge structure. Thin and complete are not available from the same number.
One correction to the usual telling, since the sweep contradicts it: raising the threshold does not fragment the edges. The fragment count falls from 2,269 to 213 across this sweep, because weak structure disappears outright rather than breaking apart. The cost of a high threshold is coverage, not fragmentation.
Two separate problems, then. Raising the threshold does narrow the ridge, but only by discarding coverage, and it never gets close to one pixel: at 160 the ridge is still 2.89 pixels wide with 85% of the structure gone. Thinness cannot be bought with the threshold, so it needs a mechanism of its own, and coverage needs another. Lesson 3 supplies both, which is why Canny is two ideas.
The border is a smaller version of the same lesson: a number OpenCV returns is not
always a measurement. On a plain left-to-right ramp, whose true gradient is the same
everywhere, cv2.Sobel returns 8 in the interior and 0 on the first and last
columns. Reflecting a ramp across its edge makes a peak that is not in the scene, so
the outermost pixels of any gradient image report the border rule and not the
picture. Crop them, or pick the border mode on purpose.
Exercises
- Match OpenCV everywhere, on a ramp. Make
gx_full, the same size as the image, so that it equalscv2.Sobelon every pixel including the border.np.padadds the missing ring; one of its modes copies OpenCV’s default border.
# brightness rises 1 per column
ramp = np.tile(np.arange(64.0), (64, 1))
ramp = ramp.astype(np.float32)
# 62x62 so far: pad ramp first
gx_full = sobel_x(ramp)
print(gx_full.shape)- Predict, then run. Change the 2 in
sobel_xto a 1, which makes it Prewitt. Before running both cells again, write down whatourson the right-hand rim becomes, and what happens to the largest difference. - Test a claim this lesson makes. “Which averaging wins” says Sobel’s
[1 2 1]buys a response that varies less as an edge rotates. Draw a straight edge at 0°, 15°, 30° and 45° (a ramp along a rotated axis works), normalise both kernels to sum to 1, and compare the largest magnitude each operator returns at each angle.
What you should see
These numbers come from section 2c of the unit notebook, edge_detection.ipynb, and are
the same on OpenCV 4.11 (this page) and 5.0.
- Only
mode="reflect"passes. NumPy’s"reflect"mirrors without repeating the edge pixel, which is OpenCV’sBORDER_REFLECT_101;"edge"and"symmetric"are both off by 4 on the border columns. ours=-3while OpenCV still says , and the largest difference becomes 1.0 in both and (sobel_yreusessobel_x): the weights now sum to 3.- On a ramp two pixels wide, the peak falls from 1 at 0° to 0.854 at 45° for Sobel and to 0.805 for Prewitt, a drop of 14.6% against 19.5%. The claim holds here, on a drawn edge. The noise result runs the other way: on Gaussian noise with a standard deviation of 0.1, Sobel’s has a standard deviation of 0.0866 and Prewitt’s 0.0816, a ratio of 0.94, which is from the arithmetic above.
Check yourself
Four questions on this lesson. Pick an answer to see why it is right or wrong.
On the same 3x3 patch, Sobel gives a gradient magnitude of 2.55 and Prewitt gives 1.70. What does that tell you?
Same pixels and same 45° direction; only the weights differ ([1 2 1] against [1 1 1]). Compare operators at a matched budget of pixels, never at a shared threshold.
A white disc sits on a black background. At a pixel on the right-hand rim of the disc, which way does the gradient point?
The gradient points uphill, toward the bright side. On the right rim that is left, which is why OpenCV returns Gx = −4, Gy = 0 and an angle of 180° there.
Why does Prewitt let less noise through than Sobel?
Both read nine pixels and both are separable. Noise variance scales with the sum of squared weights, and an even box spreads weight in the way that keeps that sum smallest: about 11% less noise power.
You raise the threshold on the gradient magnitude from 10 to 160. What happens to the edges?
Ridges narrow from 5.10 to 2.89 pixels while coverage falls to 15.4%, and the fragment count drops from 2,269 to 213. Thinness needs its own mechanism, which is non-maximum suppression in the Canny lesson.
Next
The gradient is measured; the ridge it gives is five pixels wide and no threshold makes it one. Lesson 3, Canny, adds the two steps that fix it: thin the ridge to its peak, then keep weak pixels only where they connect to strong ones [5]. The other way to measure the change, the second derivative Marr and Hildreth built on [2], comes after it in lesson 4.
References
[1] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.), section 10.2 “Point, Line, and Edge Detection”, Gradient Operators, p. 779. Pearson. Covers the Prewitt and Sobel masks and the magnitude/orientation definitions used here.
[2] 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
[3] Sobel, I., & Feldman, G. (1968). A 3x3 Isotropic Gradient Operator for Image Processing. Presented at the Stanford Artificial Intelligence Laboratory; never formally published. It first reached print in Duda, R. O., & Hart, P. E. (1973). Pattern Classification and Scene Analysis, pp. 271-272. Wiley. Sobel later published his own account as An Isotropic 3x3 Image Gradient Operator (2014). Citations of a 1968 Duda and Hart book are a conflation of the talk’s year with the book’s; the book is 1973.
[4] Szeliski, R. (2022). Computer Vision: Algorithms and Applications (2nd ed.), ch. 7 “Feature Detection and Matching”. Springer. Free PDF
[5] 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