Epipolar Geometry, Stereo Vision, and Depth Estimation
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- 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.
graph TD A["Left camera"] --> C["Rectify both<br/>using calibration"] B["Right camera"] --> C C --> D["Search along matching rows<br/>for each pixel"] D --> E["Disparity map d(x,y)"] E --> F["Z = f·B / d"] F --> G["Depth in millimetres"]
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 in the left image and its match in the right:
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 additionally lets you recover the actual rotation and translation between the two cameras.
| # | Matrix | Needs calibration? | Gives you | Degrees of freedom |
|---|---|---|---|---|
| 1 | Fundamental | No | Epipolar lines only | 7 |
| 2 | Essential | Yes | Epipolar lines + relative pose (R, t) | 5 |
| 3 | Homography | 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. 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:
Compute the case term by term:
And :
| # | Disparity | 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:
With , , :
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:
is focal length in pixels, is the baseline in millimetres, is disparity in pixels, and comes out in millimetres.
With px and mm, a disparity of 40 px gives:
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 with respect to :
The depth error per pixel of disparity error grows as . Work it out:
| # | Disparity (px) | Depth | 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
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 | Min distance (=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.
| Method | StereoBM | StereoSGBM | Learned stereo |
|---|---|---|---|
| Approach | Block SAD only | Block cost + smoothness | Trained network |
| Speed (1MP, CPU) | ~15 ms | ~120 ms | ~30 ms on GPU |
| Textureless regions | Fails badly | Partially filled | Handled well |
| Depth edges | Smeared | Sharp | Sharp |
| Needs training data | No | No | Yes |
| Predictable failure | Yes | Yes | Less so |
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.
- You need depth from 0.3 m to about 8 m
- The scene has texture, or you can project a pattern onto it
- You need real metric distances, not relative ordering
- Passive operation matters — no emitted signal, low power
- You need to explain and bound the error, as the formula lets you
- You need depth beyond 20 m — use lidar or radar
- Surfaces are glass, chrome, or water
- You cannot rigidly mount two cameras and keep them aligned
- Relative depth ordering is enough — monocular depth networks are simpler
Practice task
Two identical webcams clamped to a rigid bar, about 120 mm apart.
- Calibrate each camera separately, then run
stereoCalibrateandstereoRectify. - Draw 20 horizontal lines across the rectified pair. Confirm features align on the same rows. Do not continue until they do.
- Compute the disparity map with SGBM defaults and view it as a colour map.
- Place a box at exactly 1 m. Read its disparity and compute . Compare with the tape measure.
- Repeat at 2 m, 3 m, and 5 m. Tabulate measured versus true and compute the percentage error at each.
- Sweep
blockSizeover 3, 5, 9, and 15. Note how noise and edge sharpness trade off. - 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 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 . 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 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 . With px and mm, a disparity of 40 px is 2.4 m. Error grows as : 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.
- The epipolar constraint reduces matching from a full 2D image search to a line search.
- The fundamental matrix maps a point to a line; a homography maps a point to a point but only for flat scenes.
- Rectification makes epipolar lines horizontal. Verify it with drawn lines before anything else.
- SAD block matching: sum absolute differences over a window, take the minimum along the row.
- A deep narrow SAD minimum means confidence; a flat curve means no texture — reject that pixel.
- Parabola fitting through the SAD minimum gives sub-pixel disparity and roughly 4x better depth.
- Z = fB/d. With f=800 px and B=120 mm, 40 px of disparity is 2.4 m.
- Depth error grows as fB/d², so it is 15 mm at 1.2 m but 3.8 m at 19 m.
- A wider baseline helps far away and raises the minimum distance proportionally.
- Never interpolate across holes in a disparity map — an honest unknown beats an invented surface.
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.