JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Finding Edges: Gradients, Sobel, and Sharpening

Treat an image as a landscape of light and use gradients to find every edge — then run the same math backwards to sharpen.

An image as a landscape of light

Here is a mental trick that unlocks this whole guide. Stop thinking of a grayscale image as a flat array of numbers and start picturing it as a landscape. Read each pixel's intensity as a height: bright pixels (high numbers) are hills and mountains, dark pixels (low numbers) are valleys. A smooth wall lit evenly is a gentle plateau. A white object on a black background is a flat-topped mesa rising out of a plain.

A patch of pixels: each cell holds an intensity. Imagine each number as the height of the ground at that spot.

A grid of pixels with numeric intensity values shown in each cell.

Now ask: where are the edges in this landscape? An edge in an image is a place where brightness changes suddenly — the boundary between the white object and the black background. In landscape terms, that sudden change is a cliff: the ground that was flat for a while drops away steeply. Inside the object, height barely changes (flat ground). At the boundary, height plunges fast. So 'finding edges' becomes 'finding the steep parts of the terrain'.

To make this crisp before going 2D, take a single horizontal row of the image and graph it as a profile — pixel position along the bottom, intensity going up. A clean object boundary shows up as a sharp step or ramp in that profile: flat-low, then a rapid rise, then flat-high. The steepness of that rise is exactly what we want to measure. And 'rate of change of a quantity as you move along' is precisely what a slope — a derivative — captures. We will turn that steepness into a number called the image gradient in the next section.

The image gradient: slope and direction

On our 1D row, the slope at a pixel is just 'how fast does intensity change as I step right?'. That single number is a derivative. But a real image is 2D: at any pixel you could step right (the x-direction) or step down (the y-direction), and the terrain might rise steeply one way and stay flat the other. So one slope is not enough — we need two, one per direction, bundled into a little arrow. That arrow is the image gradient.

\nabla f = \left( \dfrac{\partial f}{\partial x},\ \dfrac{\partial f}{\partial y} \right)

Read this as: the gradient of the image f, written ∇f (say 'grad f'), is a pair of numbers. The first, ∂f/∂x, is the rate at which intensity changes as you move left-to-right (horizontally). The second, ∂f/∂y, is the rate of change as you move top-to-bottom (vertically). The curly ∂ ('partial') just means 'change in this direction while holding the other fixed'. So ∇f is a 2D vector — an arrow — living at every pixel. If both numbers are near zero, you are on flat ground (no edge). If either is large, intensity is shifting fast there.

An arrow has a length and a direction, and each tells us something useful about the edge:

|\nabla f| = \sqrt{\left(\dfrac{\partial f}{\partial x}\right)^2 + \left(\dfrac{\partial f}{\partial y}\right)^2}, \qquad \theta = \operatorname{atan2}\!\left(\dfrac{\partial f}{\partial y},\ \dfrac{\partial f}{\partial x}\right)

The first quantity, |∇f|, is the magnitude — the length of the arrow, found by the Pythagorean theorem on the two slopes. It is the overall steepness at that pixel: a large magnitude means a strong edge, a small one means smooth terrain. This single number is what we usually display as an 'edge map'. The second quantity, θ (theta), is the direction the arrow points, computed with atan2 (a safe arctangent that knows which quadrant you are in). Here is the hiking intuition: imagine standing on the terrain — the gradient arrow points in the direction of steepest uphill (toward brighter), and its length is how steep that climb is. Because the arrow points across the cliff, the edge itself runs perpendicular to θ.

Let us put numbers on it. Take a 3×3 patch sitting right on a vertical edge — dark on the left, bright on the right: 50 50 200 50 50 200 50 50 200 Using a simple central difference at the center pixel (right neighbour minus left neighbour, divided by the 2 steps between them): ∂f/∂x = (200 − 50) / 2 = 75. Moving down, the column above and below the center are identical, so ∂f/∂y = (50 − 50) / 2 = 0. Then the magnitude is √(75² + 0²) = 75 (a strong edge), and the direction is atan2(0, 75) = 0° — pointing straight right, toward the bright side. Perfect: a vertical edge produces a horizontal gradient arrow, and that arrow points 'uphill' into the bright region, exactly as the analogy promised.

From calculus to kernels: the Sobel operator

Derivatives are defined for smooth, continuous functions, but an image is a discrete grid — there is no infinitely small step you can take, only whole pixels. The fix is the finite difference: approximate a derivative by subtracting neighbouring pixels. The change in intensity from the pixel on your left to the pixel on your right is a perfectly good stand-in for ∂f/∂x. That is the entire bridge from calculus to code: a derivative is just a weighted difference of nearby pixels.

But Guide 3 taught us a hard lesson: real images carry noise, and noise is full of tiny, fast, random intensity wiggles. A raw difference would mistake every noisy speckle for a steep slope, flooding the edge map with false edges. The Sobel operator is the classic, beautifully practical answer: a 3×3 kernel that differentiates in one direction while smoothing in the other, so it reads genuine edges and shrugs off noise. Recall from Guide 2 that we apply a kernel by convolution — sliding it over the image and taking a weighted sum at each spot. Sobel is exactly such a spatial filtering kernel, and it directly estimates the image gradient.

G_x = \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{bmatrix}, \qquad G_y = G_x^{\top} = \begin{bmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \end{bmatrix}

Look at Gx, the horizontal-edge detector. Each row has the pattern (−1, 0, +1): it subtracts the left pixel from the right pixel — a left-versus-right difference, i.e. the derivative ∂f/∂x. Now look down the columns: the weights are 1, 2, 1. That is a tiny Gaussian-like blur applied vertically, perpendicular to the direction we are differentiating. So Sobel's recipe is: *differentiate across the edge, average along it.* The center row gets the doubled weight (−2, 0, +2) because it is closest to the pixel we are computing, so it should count most. Gy is just Gx turned 90° (its transpose, written Gxᵀ): it differences top-versus-bottom while smoothing horizontally, detecting horizontal edges.

Convolution in action: the 3×3 Sobel kernel slides over the image; at each position the overlapping pixels are multiplied by the kernel weights and summed into one output value.

A small kernel sliding over an image grid, with a weighted-sum computation producing one output pixel.

M = \sqrt{G_x^2 + G_y^2}

After convolving the image with Gx and Gy you have two response images: one strong at vertical edges, one strong at horizontal edges. To get a single edge-strength image M that fires on edges of any orientation, combine them the same way we combined the gradient components — Pythagoras, per pixel. Here Gx and Gy mean 'the Sobel responses at this pixel', and M is the Sobel edge magnitude. Let us work the same patch as before: 50 50 200 50 50 200 50 50 200 Gx response = (−1·50 + 0·50 + 1·200) + (−2·50 + 0·50 + 2·200) + (−1·50 + 0·50 + 1·200) = 150 + 300 + 150 = 600. Gy response: the top row contributes −(50+2·50+200) = −350 and the bottom row +(50+2·50+200) = +350, summing to 0. So M = √(600² + 0²) = 600 — a big number, correctly screaming 'strong vertical edge here', while a flat patch (all 50s) would give Gx = Gy = 0 and M = 0.

import numpy as np
from scipy.ndimage import convolve

# img: a 2D grayscale image as float (so differences can go negative)
img = img.astype(np.float64)

# Sobel kernels: differentiate in one axis, smooth (1,2,1) in the other
Gx = np.array([[-1, 0, 1],
               [-2, 0, 2],
               [-1, 0, 1]], dtype=np.float64)
Gy = Gx.T  # transpose -> detects horizontal edges

gx = convolve(img, Gx)   # horizontal gradient (strong on vertical edges)
gy = convolve(img, Gy)   # vertical gradient   (strong on horizontal edges)

magnitude = np.sqrt(gx**2 + gy**2)   # edge strength, any orientation
direction = np.arctan2(gy, gx)       # edge orientation in radians

# Pitfall: keep the image as float. On uint8, the -1/-2 taps would wrap
# negative values around to ~255 and corrupt every edge.
Sobel edge detection: convolve with Gx and Gy, then combine into magnitude and direction.

The Laplacian: curvature and zero-crossings

Sobel measures the first derivative — the slope. There is a second, complementary way to find edges that asks a subtler question: not 'how steep is it?' but 'how fast is the steepness changing?'. That is the second derivative. On our 1D profile, the second derivative measures curvature — whether the terrain is bending upward (a valley) or downward (a ridge). Its 2D sum over both directions is the Laplacian, and it locates edges in a clever way: not as peaks, but as zero-crossings — the exact spot where the response flips from positive to negative.

\nabla^2 f = \dfrac{\partial^2 f}{\partial x^2} + \dfrac{\partial^2 f}{\partial y^2}

Read ∇²f ('del-squared f', or 'the Laplacian of f') as: add up the second derivative in the x-direction and the second derivative in the y-direction. The term ∂²f/∂x² is the rate of change of the horizontal slope — is the left-to-right steepness itself increasing or decreasing? Likewise ∂²f/∂y² for the vertical slope. Unlike the gradient, the result is a single number (a scalar), not an arrow — it throws away direction and keeps only 'how much is the slope bending here, total'. Flat regions and even steady ramps (constant slope) give roughly zero; the response only spikes where the slope is itself changing — at intensity 'corners' around an edge.

\nabla^2 \approx \begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix}

This is the discrete Laplacian kernel — the finite-difference version, applied (like Sobel) by convolution. Read it as a comparison: the center pixel is weighted −4, and its four direct neighbours (up, down, left, right) are each weighted +1. So at every pixel it computes (sum of the 4 neighbours) − 4×(center) — literally 'how much does this pixel differ from the average of its neighbours?'. On flat ground the neighbours equal the center, the weights cancel, and you get 0. On a bright spike surrounded by dark, the center is much bigger than its neighbours and you get a large negative number; in a dark dip, a large positive number. The single negative center balancing four positive arms is why this is one combined second-derivative operator, not two separate ones.

Watch the zero-crossing appear on a 1D step edge. Take a row crossing from dark to bright: 10, 10, 10, 90, 90, 90 (the jump is between positions 3 and 4). The 1D second derivative at position i is f[i−1] − 2·f[i] + f[i+1]. Position 3 (the last dark pixel): 10 − 2·10 + 90 = +80. Position 4 (the first bright pixel): 10 − 2·90 + 90 = −80. So the response goes ... 0, +80, −80, 0 ... — strongly positive just before the edge, strongly negative just after, and it must pass through zero right at the edge's center. That zero-crossing pinpoints the edge to a hair, which is the Laplacian's great strength.

Unsharp masking: sharpening by subtracting blur

Here is the payoff and the master move of this guide. Everything so far used the edge math to detect edges. Now we run it in reverse to enhance them — to sharpen. The trick that makes this possible is wonderfully simple: an edge is exactly the part of an image that a blur destroys. So if you can isolate 'what the blur removed', that isolated layer is the edges and fine texture — and you can add it back, stronger, to make edges crisper. This is called unsharp masking.

g = f + k\,\bigl(f - \operatorname{blur}(f)\bigr)

Unpack every symbol. f is the original image. blur(f) is a Gaussian blur of it — a copy with the fine detail smeared away, keeping only the slow, broad variations (the 'low frequencies'). The difference f − blur(f) is the detail layer or mask: subtracting the smooth version from the original cancels everything that survived blurring and leaves only what the blur erased — edges, texture, fine structure (the 'high frequencies'). k is the amount — a positive knob for how strongly to push. And g is the sharpened result: take the original f and add back k copies of its own detail. The intuition: f − blur(f) is large and positive on the bright side of an edge and large and negative on the dark side, so adding it brightens the bright lip and darkens the dark lip — it exaggerates contrast exactly at edges, which the eye reads as 'sharper'.

Why the strange name 'unsharp'? It comes from the film darkroom. Photographers made a blurred (literally unsharp) copy of a negative on a separate film, sandwiched it with the original, and printed through both. The blurry copy acted as a mask that held back the broad tones while letting edge contrast print through harder — sharpening the print using an unsharp plate. We now do the identical arithmetic in software, but the historical name stuck.

This connects straight back to the previous section. Subtracting a blur is, mathematically, very close to subtracting the Laplacian: the detail layer f − blur(f) approximates −∇²f, so unsharp masking is essentially g ≈ f − k·∇²f. Adding edge curvature back is what sharpens. And it foreshadows Guide 5's frequency view: blur(f) keeps the low frequencies, so f − blur(f) is a high-pass signal, and boosting it by k is a high-frequency boost. 'Sharpening' and 'high-pass filtering' will turn out to be two names for the same operation.

import numpy as np
from scipy.ndimage import gaussian_filter

def unsharp_mask(f, sigma=1.5, k=1.0):
    f = f.astype(np.float64)
    low  = gaussian_filter(f, sigma=sigma)  # blur(f): the low-frequency copy
    detail = f - low                        # high-frequency edge/texture mask
    g = f + k * detail                      # add scaled detail back
    return np.clip(g, 0, 255).astype(np.uint8)  # clip to avoid wrap-around

# k = 0  -> unchanged   k = 1 -> moderate   k = 3+ -> likely halos/overshoot
Unsharp masking: blur, subtract to get the detail mask, then add k times that detail back.

A quick numeric walk-through. Consider two pixels straddling an edge: a bright one f = 100 and a dark one f = 60, so the original contrast across the edge is 100 − 60 = 40. After blurring, each pixel is pulled toward its neighbours: say blur gives 80 to both. Then the detail is +20 for the bright pixel (100 − 80) and −20 for the dark pixel (60 − 80). With k = 1: the bright pixel becomes 100 + 20 = 120 and the dark becomes 60 − 20 = 40. The new contrast is 120 − 40 = 80 — doubled. The edge is visibly crisper, and we never touched the flat regions (where detail ≈ 0).

From edges to vision

Step back and see what you now hold. By reading an image as a landscape of light, you learned to measure steepness with the image gradient, to compute it robustly with the Sobel kernel, to pinpoint edges with the Laplacian's zero-crossings, and to run the whole idea backwards to sharpen. Gradients and edges are not an end in themselves — they are the raw material of nearly all higher-level computer vision. Almost every classical vision algorithm starts by asking 'where does the brightness change?'.

The classic capstone built entirely from this guide's pieces is the Canny edge detector. It does not invent anything new — it just stages the tools you already have into a careful pipeline that turns a noisy gradient map into clean, thin, connected edge lines:

  1. Smooth: blur with a Gaussian first, so noise does not masquerade as edges (Guide 3's lesson, and the reason Sobel and LoG smooth too).
  2. Gradient: run Sobel to get edge magnitude and direction at every pixel.
  3. Non-maximum suppression: along each gradient direction, keep only the single strongest pixel and zero the rest, thinning fat edges down to one-pixel-wide ridges.
  4. Hysteresis thresholding: use a high threshold to start confident edges and a low threshold to extend them, linking weak-but-connected pixels while discarding isolated weak ones.

From those clean edges, vision builds upward. Corner and feature detectors (Harris corners, SIFT, ORB) look for points where edges meet or where the gradient is strong in two directions at once — distinctive landmarks for matching one photo to another, stitching panoramas, or tracking motion. Lines and shapes can be voted for from edge pixels (the Hough transform). The gradient you computed in Section 2 is the seed of all of it.

One thread is still loose. We kept saying 'low frequencies' for blur and 'high frequencies' for detail, and we noticed sharpening is a high-pass boost. Guide 5 makes that language exact: it views an image not as a landscape but as a sum of waves, and shows how scale, frequency, and shape give you a single master toolbox that contains blurring, edge-finding, and sharpening all at once.