Search…

Epipolar Geometry, Stereo Vision, and Depth Estimation

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

One camera cannot tell a small nearby object from a large distant one. Two cameras can, because the same point lands at different horizontal positions in each. That shift is called disparity, and turning it into millimetres takes one short formula.

Prerequisites: Camera calibration — stereo depth is only as good as the intrinsics feeding it.

The problem: a delivery robot on a pavement

A small delivery robot rolls along a pavement. It must stop for obstacles — a bollard at 3 m is fine to plan around, a person stepping out at 1 m is not. A single camera detects the person perfectly well but cannot say whether they are 1 m or 5 m away. A tall adult far off and a child up close produce nearly identical boxes.

Two cameras, 120 mm apart, solve it. The person appears in both, but at different horizontal positions, and the size of that difference is the distance.

The epipolar constraint

Take a point in the left image. Where can it be in the right image?

The camera only knows the direction the point lies in, not how far. So the point could be anywhere along a ray going out from the left camera. Project that entire ray into the right image and it becomes a line — the epipolar line.

epipolar line The projection into one camera's image of the entire ray of possible 3D positions seen by the other camera. The matching point must lie on this line.

This is enormously useful. Instead of searching a whole 1920×1080 image for the match, you search one line. That is roughly 2 million candidates reduced to about 1,900, a thousandfold saving, and it also removes most opportunities for a wrong match.

Algebraically, for a point xx in the left image and its match xx' in the right:

xTFx=0x'^T F x = 0

FF is the fundamental matrix A 3x3 matrix relating corresponding points between two uncalibrated views. Fx gives the epipolar line in the second image on which the match must lie. . When both cameras are calibrated, the closely related essential matrix E=KTFKE = K'^T F K additionally lets you recover the actual rotation and translation between the two cameras.

# Matrix Needs calibration? Gives you Degrees of freedom
1 Fundamental FF No Epipolar lines only 7
2 Essential EE Yes Epipolar lines + relative pose (R, t) 5
3 Homography HH No Full point-to-point map (flat scenes only) 8

Three matrices, three different jobs. F and E do not map points to points — they map points to lines.

The key difference from the last post: a homography maps a point to a point, but only when the scene is flat. FF maps a point to a line, which works for any scene, because the depth ambiguity is exactly what the line represents.

Rectification: making the lines horizontal

Epipolar lines are generally slanted, which makes scanning them awkward. Rectification warps both images so that all epipolar lines become horizontal image rows.

After rectification, a point at row 412 in the left image has its match at row 412 in the right image. Matching becomes: scan left along one row.

import cv2, numpy as np

# From stereo calibration
retval, K1, d1, K2, d2, R, T, E, F = cv2.stereoCalibrate(
    obj_points, img_points_L, img_points_R,
    K1, d1, K2, d2, image_size,
    flags=cv2.CALIB_FIX_INTRINSICS)

R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
    K1, d1, K2, d2, image_size, R, T, alpha=0)

map1x, map1y = cv2.initUndistortRectifyMap(K1, d1, R1, P1, image_size, cv2.CV_32FC1)
map2x, map2y = cv2.initUndistortRectifyMap(K2, d2, R2, P2, image_size, cv2.CV_32FC1)

left_rect  = cv2.remap(left,  map1x, map1y, cv2.INTER_LINEAR)
right_rect = cv2.remap(right, map2x, map2y, cv2.INTER_LINEAR)

Verify rectification before trusting anything downstream. Draw horizontal lines across both rectified images side by side. A feature crossed by a line in the left image must be crossed by the same line in the right. If it is off by even two rows, block matching will fail everywhere and the disparity map will look like noise.

Block matching, worked out

For each pixel in the left image, take a small window around it and slide it along the corresponding row of the right image, scoring each position. The score is the sum of absolute differences (SAD): add up the absolute difference at every pixel in the window.

Here is a real 5-pixel window and the candidates it is compared against:

Left window (1×5)
20
80
140
90
30
Right at d = 3 → SAD 10 (1×5)
22
78
142
88
32
Right at d = 2 → SAD 234 (1×5)
78
142
88
32
26
Right at d = 0 → SAD 312 (1×5)
88
32
26
20
18

Compute the d=3d = 3 case term by term:

2022+8078+140142+9088+3032=2+2+2+2+2=10|20-22| + |80-78| + |140-142| + |90-88| + |30-32| = 2+2+2+2+2 = 10

And d=2d = 2:

2078+80142+14088+9032+3026=58+62+52+58+4=234|20-78| + |80-142| + |140-88| + |90-32| + |30-26| = 58+62+52+58+4 = 234

# Disparity dd Right window SAD target Best?
1 0 88, 32, 26, 20, 18 312
2 1 142, 88, 32, 26, 20 312
3 2 78, 142, 88, 32, 26 234
4 3 22, 78, 142, 88, 32 10 ← minimum
5 4 19, 22, 78, 142, 88 231
6 5 14, 19, 22, 78, 142 309

SAD across six candidate disparities. The true match at d=3 scores 10 while everything else is above 230.

The shape of that curve is the confidence measure. A deep narrow dip means one clear answer. If the curve were flat around 200 everywhere, the window has no distinguishing texture and any disparity you pick is a guess. Good stereo implementations reject those pixels rather than reporting a made-up number.

Sub-pixel refinement

Integer disparity is coarse. Fit a parabola through the minimum and its two neighbours:

dsub=d+S(d1)S(d+1)2[S(d1)2S(d)+S(d+1)]d_{sub} = d + \frac{S(d-1) - S(d+1)}{2\left[S(d-1) - 2S(d) + S(d+1)\right]}

With S(2)=234S(2) = 234, S(3)=10S(3) = 10, S(4)=231S(4) = 231:

dsub=3+2342312(23420+231)=3+3890=3.003d_{sub} = 3 + \frac{234 - 231}{2(234 - 20 + 231)} = 3 + \frac{3}{890} = 3.003

Barely a change here because the match is nearly exact, but on real data sub-pixel refinement typically improves depth accuracy by a factor of four. OpenCV returns disparity scaled by 16 for exactly this reason — the bottom four bits are the fractional part.

From disparity to depth

Here is the formula everything has been building toward:

Z=fBdZ = \frac{f \cdot B}{d}

ff is focal length in pixels, BB is the baseline in millimetres, dd is disparity in pixels, and ZZ comes out in millimetres.

With f=800f = 800 px and B=120B = 120 mm, a disparity of 40 px gives:

Z=800×12040=96,00040=2400 mm=2.4 mZ = \frac{800 \times 120}{40} = \frac{96{,}000}{40} = 2400 \text{ mm} = 2.4 \text{ m}

Halve the disparity to 20 px and the distance doubles to 4.8 m. Disparity and depth are inversely proportional, and that inverse relationship causes the single biggest practical problem in stereo.

Accuracy collapses with distance

Differentiate Z=fB/dZ = fB/d with respect to dd:

dZdd=fBd2\left|\frac{dZ}{dd}\right| = \frac{fB}{d^2}

The depth error per pixel of disparity error grows as 1/d21/d^2. Work it out:

# Disparity dd (px) Depth ZZ Error per 1 px of disparity target Relative error
1 80 1.2 m 15 mm 1.3%
2 40 2.4 m 60 mm 2.5%
3 20 4.8 m 240 mm 5.0%
4 10 9.6 m 960 mm 10.0%
5 5 19.2 m 3840 mm 20.0%
6 3 32.0 m 10.7 m 33.3%

Depth resolution for f=800 px, B=120 mm. At 19 m, one pixel of disparity noise is nearly 4 metres of depth.

For the delivery robot this is decisive. At 1.2 m, where a person stepping out matters, depth is accurate to 15 mm. At 19 m it is accurate to about 4 m, which is useless. Stereo is a close-range sensor. Design around that rather than trying to tune your way out of it.

Choosing the baseline

Z=fB/dZ = fB/d means a wider baseline gives a larger disparity for the same distance, which improves accuracy. But there is a limit: the maximum disparity your matcher can search sets a minimum distance.

With a search range of 128 pixels:

# Baseline BB Min distance (dd=128) Error at 5 m target Error at 10 m Overlap between views
1 60 mm 0.38 m 260 mm 1040 mm excellent
2 120 mm 0.75 m 130 mm 520 mm very good
3 250 mm 1.56 m 62 mm 250 mm good
4 500 mm 3.13 m 31 mm 125 mm reduced

Baseline trade-off at f = 800 px, 128-pixel search range. Wider is better far away and worse close up.

A 500 mm baseline is four times more accurate at 10 m but cannot see anything closer than 3.1 m at all. For a pavement robot that must react to a person at 1 m, 120 mm is the right choice.

Running it in OpenCV

# StereoSGBM is almost always better than StereoBM
min_disp, num_disp, block = 0, 128, 5   # num_disp must be a multiple of 16

matcher = cv2.StereoSGBM_create(
    minDisparity=min_disp,
    numDisparities=num_disp,
    blockSize=block,
    P1=8  * 3 * block**2,      # smoothness penalty, 1-pixel change
    P2=32 * 3 * block**2,      # smoothness penalty, larger change
    disp12MaxDiff=1,           # left-right consistency check
    uniquenessRatio=10,        # reject shallow SAD minima
    speckleWindowSize=100,
    speckleRange=32,
)

disp = matcher.compute(left_rect, right_rect).astype(np.float32) / 16.0

f, B = 800.0, 120.0
with np.errstate(divide="ignore"):
    depth_mm = np.where(disp > 0.5, f * B / disp, 0)

Two guards matter there. disp > 0.5 avoids dividing by near-zero, which would produce absurd distances. uniquenessRatio=10 rejects any pixel whose best SAD is not at least 10% better than the runner-up — the flat-curve case from earlier.

# Parameter What it controls Symptom if too low Symptom if too high
1 numDisparities Search range Near objects get no depth Slower, more wrong matches
2 blockSize Window size Noisy, speckled disparity Thin objects and edges smear
3 P1 / P2 Smoothness penalty Disparity map looks grainy Real depth steps get flattened
4 uniquenessRatio Confidence floor Textureless areas get fake depth Large valid regions dropped
5 disp12MaxDiff Left-right consistency Occluded pixels get wrong depth Too many pixels rejected

SGBM parameters. Start with blockSize 5, numDisparities 128, uniquenessRatio 10.

MethodStereoBMStereoSGBMLearned stereo
ApproachBlock SAD onlyBlock cost + smoothnessTrained network
Speed (1MP, CPU)~15 ms~120 ms~30 ms on GPU
Textureless regionsFails badlyPartially filledHandled well
Depth edgesSmearedSharpSharp
Needs training dataNoNoYes
Predictable failureYesYesLess so
Stereo matching methods compared

Where stereo fails

# Situation Why What to do
1 Blank wall or plain floor No texture, so SAD is flat everywhere Project a dot pattern with an IR emitter
2 Repeating pattern (tiles, railings) Many equally good matches on the row Raise uniquenessRatio; rely on SGBM smoothness
3 Glass or shiny metal Reflection differs between cameras Stereo cannot solve this — use a different sensor
4 Object visible in one camera only No match exists at all Left-right check marks it invalid; do not fill it in
5 Very distant object Disparity under 2–3 px Accept a range limit; fuse with another sensor
6 Camera bump or thermal drift Rectification no longer valid Recalibrate; monitor row alignment continuously

Stereo failure modes

The textureless case has an elegant industrial fix: active stereo. Project a random infrared dot pattern onto the scene so even a blank wall has texture to match. The pattern only needs to be random, not known, because it is being matched between two cameras rather than decoded. Most commercial depth cameras work this way.

Practice task

Two identical webcams clamped to a rigid bar, about 120 mm apart.

  1. Calibrate each camera separately, then run stereoCalibrate and stereoRectify.
  2. Draw 20 horizontal lines across the rectified pair. Confirm features align on the same rows. Do not continue until they do.
  3. Compute the disparity map with SGBM defaults and view it as a colour map.
  4. Place a box at exactly 1 m. Read its disparity and compute Z=fB/dZ = fB/d. Compare with the tape measure.
  5. Repeat at 2 m, 3 m, and 5 m. Tabulate measured versus true and compute the percentage error at each.
  6. Sweep blockSize over 3, 5, 9, and 15. Note how noise and edge sharpness trade off.
  7. Point the pair at a blank wall. Look at the disparity map, then tape a newspaper to the wall and look again.

Step 5 is the one to keep. Plot percentage error against distance and you will reproduce the 1/d21/d^2 curve from your own hardware. That plot tells you exactly where your system’s usable range ends, which no amount of parameter tuning will extend.

Summary

A point in one image constrains its match in the other to a single line, the epipolar line, described by xTFx=0x'^T F x = 0. Rectification warps both images so those lines become horizontal rows, turning a 2D search into a 1D scan.

Block matching slides a window along that row and scores each position by SAD. You computed six candidates by hand and found a minimum of 10 at d=3d=3 against 230+ everywhere else, then refined it to 3.003 with a parabola fit. The depth of that dip is your confidence: a flat curve means no texture and the pixel should be rejected, not guessed.

Depth is Z=fB/dZ = fB/d. With f=800f=800 px and B=120B=120 mm, a disparity of 40 px is 2.4 m. Error grows as fB/d2fB/d^2: 15 mm at 1.2 m, 60 mm at 2.4 m, 960 mm at 9.6 m, and 3.8 m at 19.2 m. A wider baseline improves the far end but raises the minimum distance in exact proportion. Stereo is a short-range sensor, and the formula tells you precisely how short.

What comes next

Stereo gives depth from two cameras at one instant. Optical flow and motion tracking gives motion from one camera across two instants — the same matching problem, one axis rotated. The two combine directly: stereo says how far, flow says how fast, and together they say whether the robot needs to stop.

Test your understanding
Your stereo rig has f = 800 px and B = 120 mm. You need to measure a pallet at 15 m to within 200 mm. Is this achievable by tuning?
Test your understanding
Your disparity map is dense and clean on a patterned rug but almost entirely empty on the plain white wall behind it. What is happening?

Frequently asked questions

How do I choose the baseline for my stereo rig?
Work from the near end of your required range. The minimum measurable distance is f·B divided by your maximum search disparity, so with f = 800 px, a 128-pixel search, and a 120 mm baseline, you cannot see closer than 0.75 m. Pick the widest baseline whose minimum distance is still comfortably inside your requirement, since wider always improves far-field accuracy. A wider baseline also increases occlusion and reduces the overlap between views, so do not go wider than you need.
Why is my disparity map full of holes?
Holes are the matcher being honest. They occur where a region has no texture, where a repeating pattern makes many disparities equally good, where a surface is visible to one camera but occluded from the other, or where the left-right consistency check disagreed. That is a feature. Filling them by interpolation invents surfaces that are not there, which is dangerous for anything that navigates or grasps.
Should I use stereo or a monocular depth network?
Stereo when you need real metric distances you can bound with a formula, such as robot safety stopping or dimensional measurement. Monocular depth networks when relative depth ordering is enough, when you only have one camera, or when the scene is textureless in ways stereo cannot handle. Monocular networks produce plausible dense output everywhere, which is convenient, but they are estimating rather than measuring and can be confidently wrong on scenes unlike their training data.
Do the two cameras have to be identical?
Not strictly, since stereoCalibrate handles different intrinsics and rectification warps both into a common frame. In practice you want them identical and synchronised. Different lenses mean the rectification warp discards more of each image, different exposure or white balance breaks the brightness-constancy assumption that SAD depends on, and unsynchronised shutters make any moving object produce wrong disparity because it was in a different place in each shot.
How often do I need to recalibrate a stereo rig?
Whenever the physical relationship between the cameras changes. A rigid metal mount in a stable temperature can hold for months; a plastic mount on a vibrating vehicle can drift in days. Monitor it rather than guessing: pick a few features per frame and check that their rows match after rectification. When the row error creeps past about half a pixel, recalibrate, because depth quality degrades long before anything looks visibly wrong.
Start typing to search across all content
navigate Enter open Esc close