Search…

Edge Detection and Thresholding That Actually Work

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

Thresholding and edge detection are the two ways to turn a grayscale image into something you can count and measure. Both look trivial in a tutorial and both fall apart on real photos. This post shows exactly why, and works out the maths that makes them survive.

Prerequisites: Image fundamentals for histograms and filtering.

The problem: a form photographed on a desk

You need to read a printed form. Someone photographs it on a desk with a lamp on the left. The left half of the page is bright white; the right half sits in shadow and is closer to mid-grey.

The text is black in both halves. To a human it reads fine. To a global threshold it is a disaster: any cutoff dark enough to catch the text on the shadowed side turns the entire shadowed background black too.

Here is a row of pixel values sampled straight across that page, crossing four letters:

The red line is a threshold of 128. On the left it separates text (40, 45) from paper (238, 235) perfectly. On the right the paper itself sits at 114–120, below the line. Every background pixel on that side gets classified as ink.

Nothing is wrong with the threshold value. The problem is that one number is being asked to describe two different lighting conditions.

Thresholding, three ways

Fixed threshold

_, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY)

Every pixel above 128 becomes 255, everything else 0. It is the fastest option and the first thing to try when you control the lighting completely. It is also the first thing to break when anything changes.

Otsu: computing the threshold by hand

Otsu's method An automatic threshold that tries every possible cutoff and keeps the one that makes the two resulting groups as different from each other as possible. removes the magic number. Here is the whole algorithm worked out on a small histogram.

Take an image with six grey levels (0 to 5) and these pixel counts:

# Grey level Pixel count target
1 0 6
2 1 10
3 2 2
4 3 2
5 4 10
6 5 6

A bimodal histogram: 36 pixels, two peaks, a valley in the middle

Total pixels N=6+10+2+2+10+6=36N = 6 + 10 + 2 + 2 + 10 + 6 = 36.

For each candidate threshold tt, split the pixels into “at or below tt” (group 0) and “above tt” (group 1). Otsu maximises the between-class variance:

σb2(t)=ω0ω1(μ0μ1)2\sigma_b^2(t) = \omega_0 \, \omega_1 \, (\mu_0 - \mu_1)^2

where ω\omega is each group’s share of the pixels and μ\mu is each group’s mean level.

Try t=1t = 1. Group 0 is levels 0 and 1, which is 6+10=166 + 10 = 16 pixels. Group 1 is levels 2–5, which is 20 pixels.

ω0=1636=0.444,ω1=2036=0.556\omega_0 = \frac{16}{36} = 0.444, \qquad \omega_1 = \frac{20}{36} = 0.556 μ0=(0)(6)+(1)(10)16=1016=0.625\mu_0 = \frac{(0)(6) + (1)(10)}{16} = \frac{10}{16} = 0.625 μ1=(2)(2)+(3)(2)+(4)(10)+(5)(6)20=4+6+40+3020=8020=4.000\mu_1 = \frac{(2)(2) + (3)(2) + (4)(10) + (5)(6)}{20} = \frac{4 + 6 + 40 + 30}{20} = \frac{80}{20} = 4.000 σb2(1)=(0.444)(0.556)(0.6254.000)2=0.247×11.39=2.812\sigma_b^2(1) = (0.444)(0.556)(0.625 - 4.000)^2 = 0.247 \times 11.39 = 2.812

Try t=2t = 2. Group 0 is levels 0–2, which is 18 pixels. Group 1 is levels 3–5, also 18.

ω0=ω1=0.500\omega_0 = \omega_1 = 0.500 μ0=0+10+418=1418=0.778,μ1=6+40+3018=7618=4.222\mu_0 = \frac{0 + 10 + 4}{18} = \frac{14}{18} = 0.778, \qquad \mu_1 = \frac{6 + 40 + 30}{18} = \frac{76}{18} = 4.222 σb2(2)=(0.5)(0.5)(0.7784.222)2=0.25×11.86=2.965\sigma_b^2(2) = (0.5)(0.5)(0.778 - 4.222)^2 = 0.25 \times 11.86 = 2.965

Do the same for every threshold:

# tt ω0\omega_0 μ0\mu_0 μ1\mu_1 σb2\sigma_b^2 target
1 0 0.167 0 3 1.25
2 1 0.444 0.625 4 2.812
3 2 0.5 0.778 4.222 2.965
4 3 0.556 1 4.375 2.812
5 4 0.833 2 5 1.25

Between-class variance at every candidate threshold

The maximum is at t=2t = 2, right in the histogram’s valley. Otsu found it without being told anything about the image.

otsu_val, binary = cv2.threshold(gray, 0, 255,
                                 cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print("Otsu chose:", otsu_val)

The 0 you pass as the threshold is ignored; Otsu computes its own. Print otsu_val across a batch. If it swings between 60 and 190 across images, your lighting is unstable and you should be looking at the camera setup, not the algorithm.

Otsu assumes two peaks. Feed it a smooth single-hill histogram and it still returns a number, but that number is arbitrary and will move unpredictably from image to image.

This is what a histogram with no valley looks like next to one Otsu can actually work with:

The red histogram is the dangerous case, because Otsu still returns a confident-looking number for it. Where the green curve is flat between the peaks, thousands of pixels sit right under the red curve’s split point, and every one of them can flip class from one photo to the next.

Adaptive: a different threshold per region

For the shadowed form, compute a local threshold from each pixel’s neighbourhood instead of the whole image.

adaptive = cv2.adaptiveThreshold(
    gray, 255,
    cv2.ADAPTIVE_THRESH_GAUSSIAN_C,   # weighted local mean
    cv2.THRESH_BINARY,
    blockSize=31,                     # neighbourhood size, must be odd
    C=7,                              # subtracted from the local mean
)

Take the shadowed side of the form. The local mean over a 31×31 window of mostly paper is about 117. With C=7C = 7, the local threshold there is:

Tlocal=1177=110T_{local} = 117 - 7 = 110

Ink pixels at 35 fall below 110 and become black. Paper at 116 stays white. Meanwhile on the lit side the local mean is about 234, so:

Tlocal=2347=227T_{local} = 234 - 7 = 227

Ink at 40 is still below. The same code produced two thresholds 117 apart, which is exactly what the image needed.

# Region Local mean C Local threshold target Ink (35-45) Paper
1 Lit side 234 7 227 black ✓ 236 → white ✓
2 Shadowed side 117 7 110 black ✓ 116 → white ✓
3 Global 128 instead 128 black ✓ 116 → black ✗

Adaptive thresholding on the shadowed form, worked out for both halves

Two parameters to tune, and they do different things:

  • blockSize must be larger than the features you want to keep. For 12-pixel-tall text, 31 works. Set it to 5 and the window sits entirely inside a letter stroke, so the letter’s own darkness becomes the local mean and the stroke disappears.
  • C is a bias against noise. On a perfectly uniform patch the local mean equals the pixel value, so without C every tiny fluctuation flips a pixel. Values of 5 to 10 are typical.

Comparison

FixedOtsuAdaptive
Threshold chosen byYouThe image histogramEach local neighbourhood
Handles uneven lightingNoNoYes
Handles drift between imagesNoYesYes
Needs a bimodal histogramNoYesNo
SpeedFastestFastSlower
Parameters to tune1 valueNoneblockSize and C
Fails asEverything black or whiteThreshold jumps between imagesHollow objects if blockSize is wrong
Three thresholding methods and how each one fails

Edge detection

Thresholding classifies pixels by their value. Edge detection looks for places where the value changes. That distinction matters when object and background have similar brightness but a visible boundary between them.

Sobel: gradient size and direction

Sobel applies two kernels, one for horizontal change and one for vertical.

Sobel-X (Gx) (3×3)
-1
0
1
-2
0
2
-1
0
1
Sobel-Y (Gy) (3×3)
-1
-2
-1
0
0
0
1
2
1

Work one out on a patch containing a diagonal edge, dark in the top-left corner and bright in the bottom-right:

Input patch (3×3)
10
10
90
10
90
90
90
90
90

Horizontal gradient. Multiply by Sobel-X and add:

row 0: (1)(10)+(0)(10)+(1)(90)=80\text{row 0: } (-1)(10) + (0)(10) + (1)(90) = 80 row 1: (2)(10)+(0)(90)+(2)(90)=20+180=160\text{row 1: } (-2)(10) + (0)(90) + (2)(90) = -20 + 180 = 160 row 2: (1)(90)+(0)(90)+(1)(90)=0\text{row 2: } (-1)(90) + (0)(90) + (1)(90) = 0 Gx=80+160+0=240G_x = 80 + 160 + 0 = 240

Vertical gradient. Multiply by Sobel-Y:

row 0: (1)(10)+(2)(10)+(1)(90)=120\text{row 0: } (-1)(10) + (-2)(10) + (-1)(90) = -120 row 1: 0\text{row 1: } 0 row 2: (1)(90)+(2)(90)+(1)(90)=360\text{row 2: } (1)(90) + (2)(90) + (1)(90) = 360 Gy=120+0+360=240G_y = -120 + 0 + 360 = 240

Magnitude — how strong the edge is:

G=Gx2+Gy2=2402+2402=2402=339.4|G| = \sqrt{G_x^2 + G_y^2} = \sqrt{240^2 + 240^2} = 240\sqrt{2} = 339.4

Direction — which way brightness increases fastest:

θ=arctan ⁣(GyGx)=arctan ⁣(240240)=45°\theta = \arctan\!\left(\frac{G_y}{G_x}\right) = \arctan\!\left(\frac{240}{240}\right) = 45°

The gradient points at 45°, down and to the right. The edge itself runs perpendicular to that, at 135°, which matches the diagonal boundary in the patch.

# Patch content GxG_x GyG_y G|G| target θ\theta Edge runs
1 Flat region 0 0 0 undefined no edge
2 Vertical edge, dark left 320 0 320 vertically
3 Horizontal edge, dark top 0 320 320 90° horizontally
4 Diagonal (worked above) 240 240 339 45° at 135°

Reading Sobel output: magnitude is edge strength, direction is perpendicular to the edge

import cv2, numpy as np

gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)    # note: CV_64F, not uint8
gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)

magnitude = cv2.magnitude(gx, gy)
direction = np.arctan2(gy, gx) * 180 / np.pi        # degrees, -180 to 180

vis = cv2.convertScaleAbs(magnitude)                # rescale to 0-255 for viewing

The cv2.CV_64F matters. Gradients are signed and often exceed 255, as the 339 above shows. Asking for uint8 output clips or wraps the values, and edges in one direction disappear entirely.

Try filters interactively

Position (0, 0)
Watch a 3×3 filter slide across a 5×5 input. At each position, the filter overlaps a patch, multiplies element-wise, and sums to produce one value in the output feature map.

Select “Sobel X” and step the filter across. Watch where the response is large and where it is zero. The filter never sees the whole object; it only ever reports local change, which is why edge output is a set of lines rather than a filled region.

Canny: turning thick gradients into thin lines

Raw Sobel magnitude is blurry: a one-pixel edge produces a two- or three-pixel-wide bright band. Canny adds three steps to fix that.

Step 3, non-maximum suppression. For each pixel, look along its gradient direction and compare it to the two neighbours on either side. Keep it only if it is the largest of the three.

Say three pixels along a gradient direction have magnitudes 210, 339, 180:

Along the gradient (1×3)
210
339
180
After suppression (1×3)
0
339
0

339 is the local peak, so it survives. Its neighbours were part of the same blurry band and get zeroed. A three-pixel-wide edge becomes one pixel wide.

Step 4, hysteresis. Two thresholds instead of one. A pixel above the high threshold is definitely an edge. A pixel above the low threshold is an edge only if it connects to a definite one. Anything below the low threshold is discarded.

With low = 50 and high = 150, walk along a chain of edge pixels:

# Pixel Magnitude Category Connected to a strong pixel? Kept? target
1 A 160 strong (> 150) yes
2 B 90 weak yes, via A yes
3 C 70 weak yes, via B yes
4 D 30 below low no
5 E 95 weak no, isolated no

Hysteresis: weak pixels survive only when they connect to a strong one

This is what stops a single edge from breaking into dashes wherever it dips slightly in contrast. Pixels B and C would have been lost by any single threshold above 90, taking a chunk out of the middle of a real edge. Pixel E, a noise spike with no neighbours, is correctly dropped even though it is stronger than B.

blurred = cv2.GaussianBlur(gray, (0, 0), 1.4)
edges = cv2.Canny(blurred, threshold1=50, threshold2=150)

A good starting rule is high = 3 × low, or derive both from the image:

def auto_canny(img, sigma=0.33):
    v = np.median(img)
    low = int(max(0, (1.0 - sigma) * v))
    high = int(min(255, (1.0 + sigma) * v))
    return cv2.Canny(img, low, high)
# Symptom Cause Fix
1 Thousands of tiny fragments Noise treated as edges Blur more, or raise both thresholds
2 Edges break into dashes Low threshold too high Lower threshold1
3 Real edges missing entirely High threshold too high Lower threshold2
4 Edges thick and blobby Not using Canny, just Sobel Canny includes the thinning step
5 Edges shifted from the true boundary Blur sigma too large Reduce sigma
6 Nothing detected at all Image is uint8 but very low contrast Check the histogram; try CLAHE first

Debugging Canny by symptom

Thresholding or edges?

A pipeline that holds up

import cv2
import numpy as np

def binarize(bgr, mode="auto"):
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)

    # 1. how uneven is the lighting? compare corner means
    h, w = gray.shape
    k = min(h, w) // 6
    corners = [gray[:k, :k].mean(), gray[:k, -k:].mean(),
               gray[-k:, :k].mean(), gray[-k:, -k:].mean()]
    spread = max(corners) - min(corners)

    # 2. clean noise before any decision
    gray = cv2.medianBlur(gray, 3)

    if mode == "adaptive" or (mode == "auto" and spread > 40):
        return cv2.adaptiveThreshold(gray, 255,
                                     cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                     cv2.THRESH_BINARY, 31, 7), "adaptive"

    thr, mask = cv2.threshold(gray, 0, 255,
                              cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    return mask, f"otsu({thr:.0f})"

That corner-spread check is worth keeping. It is four means and a subtraction, and it tells you whether lighting is even before you commit to a method. Log the returned mode across a batch: if it flips between otsu and adaptive on similar images, your capture setup is the thing to fix.

Practice task

Take one photo of a page of text on a desk with a lamp to one side, so half is bright and half is shadowed.

  1. Plot the grayscale histogram. Is it bimodal? Where would you put a threshold by eye?
  2. Apply a fixed threshold at 128, then at 100, then at 180. Note what breaks in each.
  3. Run Otsu and print the chosen value. Compare the mask against your fixed attempts.
  4. Run adaptive thresholding with blockSize at 5, 15, 31, and 71. Describe what changes.
  5. Compute the corner-mean spread from the pipeline above. Would the auto rule have picked correctly?
  6. Run Canny at (50, 150), then (100, 200), then (20, 60). Count the connected components in each.

Step 4 teaches the most. At blockSize=5 the letters come out hollow, which shows you the failure signature described earlier, and once you have seen it you will recognise it instantly in future.

Summary

Thresholding and edge detection answer different questions, and each fails in a characteristic way.

A fixed threshold works only under fixed lighting. Otsu removes the magic number by searching every cutoff for the one that maximises between-class variance, which you worked out by hand to land at t=2t = 2, exactly in the histogram’s valley. Otsu needs two peaks; without them it returns an arbitrary and unstable value. Adaptive thresholding computes a local cutoff per region and was the only method that handled the shadowed form, producing thresholds of 227 and 110 in the two halves from the same line of code.

Sobel gives gradient magnitude and direction, computed here as 339 at 45° on a diagonal edge, and must be stored in a signed float type. Canny adds noise suppression, thinning to one pixel via non-maximum suppression, and hysteresis with two thresholds so that weak-but-connected pixels survive while isolated strong noise does not.

What comes next

You have a binary mask, and it is not clean. Morphology, contours, and shape analysis covers removing specks, closing gaps, separating touching objects, and turning the result into measurements you can trust. After that, feature matching and stitching moves from measuring one image to relating two.

Test your understanding
You run Canny and get thousands of tiny disconnected fragments across the whole image instead of clean object outlines. What is the first thing to change?
Test your understanding
Adaptive thresholding on 20-pixel-tall text with blockSize=7 produces hollow letters — only the outlines survive. Why?

Frequently asked questions

What is the difference between Sobel and Canny?
Sobel computes the raw gradient and stops there, so its output is a thick, blurry band of response around every edge. Canny uses Sobel internally and then adds three steps: a Gaussian blur to suppress noise, non-maximum suppression to thin the response to a single pixel, and hysteresis thresholding to keep connected weak edges while dropping isolated ones. Use Sobel when you want gradient values, Canny when you want clean binary edge lines.
How do I choose Canny's two thresholds?
Start with a ratio of 1:3 or 1:2 between low and high. A common automatic approach is to compute the median pixel value and set low to 0.67 times it and high to 1.33 times it. Then adjust by symptom: broken dashed edges mean the low threshold is too high, while missing real edges mean the high threshold is too high.
Why does Otsu sometimes give a terrible threshold?
Otsu assumes the histogram has two distinct peaks representing object and background. On a single smooth hill, or when one class occupies only a few percent of the pixels, there is no valley to find, so it splits somewhere arbitrary and that split shifts with tiny lighting changes. Check the histogram shape first; if it is not bimodal, use adaptive thresholding or a colour-based rule instead.
Should I threshold before or after blurring?
Blur first, always. Removing noise while the image still has its full range of values is what filters are designed for. Blurring a binary mask produces grey values that need re-thresholding, gains nothing, and softens the boundary. To clean up a mask after thresholding, use morphological opening and closing.
Can I use thresholding on colour images?
Yes, via cv2.inRange on an HSV image, which thresholds all three channels at once. That is the right approach when colour is what distinguishes your object. For brightness-based separation, convert to grayscale first, because thresholding each BGR channel independently and combining the results rarely gives what you expect.
Start typing to search across all content
navigate Enter open Esc close