Principles of Robot Autonomy I · Lesson 9 of 15 · Stanford AA274A

From Pixels to 3D: Features, RANSAC & Structure from Motion

A single photo gives you rays, not depth. To recover the world's shape you must find the same point in two views, match it through clutter, and triangulate — the engine behind visual SLAM, stereo cameras, and every panorama on your phone.

Prerequisites: Lesson 8 (camera models) + basic vectors & a 2×2 matrix. That's it.
12
Chapters
9
Simulations
0
Assumed Knowledge

Chapter 0: One Photo Is Not Enough

Hold up your phone and snap a picture of a coffee mug on a table. The image is a flat grid of pixels. Now ask the hardest question in computer vision: how far away is the mug?

You cannot answer it. Not because the camera is cheap, but because of geometry. In Lesson 8 we learned that a camera collapses the whole 3D world onto a 2D plane: every point along a single ray from the lens lands on the same pixel. A toy mug 10 cm away and a giant mug 10 m away can produce the identical bright dot. The pixel tells you the direction to the object. It says nothing about the distance.

Stanford's Lecture 18 states it bluntly: given a calibrated camera and an observed point p, can we recover the 3D location of P? No — one can only say that P lies somewhere along the line joining the pixel and the camera center. A single image is a bundle of rays, and a ray is an infinite line, not a point.

The whole problem in one sentence. A pixel pins down a direction but loses the depth. To get depth back you need a second viewpoint: the same world point seen along a different ray. Where the two rays cross is the 3D point. Everything in this lesson is machinery for finding and intersecting those rays.

Here is the catch. To intersect two rays, you must first know that the bright dot in photo A and some bright dot in photo B are images of the same physical point. That is the correspondence problem, and it is brutally hard: the two photos were taken from different angles, under different lighting, with millions of pixels each. Which pixel in B matches this pixel in A?

Play with the demo. Two cameras look at one scene. Drag the slider to widen the gap between them (the baseline) and watch how the same 3D point projects to different pixels in each view. The dashed rays cross exactly at the point — but only if you matched the right pixels.

Two Views, One Point — and the Matching Problem

Two cameras (left & right) view a single 3D point. Drag the baseline slider to move the second camera. Each camera sends a ray through its image plane; the rays cross at the true 3D point. Toggle show wrong match to see what happens when you pick the wrong pixel in the second view — the rays cross at a phantom location.

baseline 0.28 rays cross at the true point

Notice two things. First, with a wider baseline the same point shifts further between the two images — that shift, called disparity, is what carries depth information (Chapter 8). Second, a wrong match sends a ray off in the wrong direction, and the intersection lands nowhere near reality. Bad correspondences are poison.

So the recipe for recovering 3D structure from images, which is the spine of this whole lesson, is:

1. Detect
find repeatable, distinctive points (corners)
2. Describe
summarize each point's neighborhood
3. Match
link the same point across views
4. Geometry
epipolar + RANSAC + triangulate → 3D

Every box here is one chapter. We start at the bottom of the perception stack — the raw image itself — and climb to a 3D reconstruction. By the end you will be able to take two photos and recover both the camera motion and the scene shape, the trick that lets a drone with one camera know where it is.

Why can't a single calibrated camera measure the 3D position of a point it sees?

Chapter 1: An Image Is a Function

Before we can find corners or match points, we need the right mental model of an image. To a robot, a grayscale image is not a picture — it is a function. Stanford's Lecture 9 writes it precisely:

I : [a, b] × [c, d] → [0, L]

Read that aloud: I takes a position (u, v) — a column and a row in the image — and returns a single number, the intensity (brightness) at that spot, between 0 (black) and L (white, usually 255). A color image is just three such functions stacked, one each for red, green, blue. From here on we work in grayscale; everything generalizes.

Because the function lives on a discrete grid of pixels, we store it as a matrix: row index v, column index u, and the entry I(u, v) is the brightness.

Why "function" and not "picture"? Because once an image is a function, calculus applies. We can ask how fast brightness changes as we step sideways — the derivative. And it turns out that almost everything interesting in an image (edges, corners, texture) lives in the derivatives, not the raw intensities. A flat white wall and a flat black wall are both boring; what your eye locks onto is where brightness changes.

The gradient: how brightness changes

The image has no formula, so we cannot differentiate it the calculus-class way. Instead we approximate the derivative with a finite difference — literally subtracting neighboring pixels. The centered difference from Lecture 9 is:

Iu = ∂I/∂u ≈ [ I(u+1, v) − I(u−1, v) ] / 2
Iv = ∂I/∂v ≈ [ I(u, v+1) − I(u, v−1) ] / 2

Here Iu is the horizontal gradient (how fast brightness rises as you walk right) and Iv is the vertical gradient. Stack them and you get the gradient vector at each pixel:

∇I = [ Iu, Iv ]

This little vector points uphill in brightness, in the direction of fastest increase, and its length tells you how steep the change is. A bright edge has a long gradient vector pointing across the edge; a flat region has a near-zero gradient.

Worked example: gradient of a 3×3 patch by hand

Let's compute one gradient with real numbers. Suppose a tiny patch of brightness looks like this (a vertical dark-to-light edge), and we want the gradient at the center pixel (the 90):

u−1u (center)u+1
v−1306090
v (center)4090140
v+135120150

Apply the centered differences at the center pixel:

Iu = (140 − 40) / 2 = 50
Iv = (120 − 60) / 2 = 30

So ∇I = (50, 30). The gradient magnitude — the steepness, used for edge detection — is its length:

|∇I| = √(50² + 30²) = √(2500 + 900) = √3400 ≈ 58.3

And the gradient direction (which way is brightness climbing fastest) is:

θ = atan2(Iv, Iu) = atan2(30, 50) ≈ 31°

The brightness climbs mostly to the right and a bit downward. An edge runs perpendicular to the gradient, so this edge is roughly vertical — exactly what the dark-left / light-right pattern shows. We just detected an edge with three subtractions.

Smooth first, then differentiate. Raw differences amplify noise: a single bad pixel produces a huge fake gradient. So real detectors first blur the image with a Gaussian (a weighted local average) to kill noise, then take the gradient. By the associativity of convolution you can merge the two steps into one derivative-of-Gaussian filter — blur and differentiate in a single pass. This is the heart of the Canny edge detector.

From scratch in Python

python
import numpy as np

def gradients(I):
    # I: float grayscale image, shape (H, W)
    Iu = np.zeros_like(I)
    Iv = np.zeros_like(I)
    # centered difference; skip the 1-pixel border
    Iu[:, 1:-1] = (I[:, 2:] - I[:, :-2]) / 2.0   # horizontal
    Iv[1:-1, :] = (I[2:, :] - I[:-2, :]) / 2.0   # vertical
    mag = np.sqrt(Iu**2 + Iv**2)              # edge strength
    ang = np.arctan2(Iv, Iu)                  # edge orientation
    return Iu, Iv, mag, ang

And the idiomatic, fast version uses a Sobel filter (a smoothed derivative) from OpenCV:

python
import cv2
Iu = cv2.Sobel(I, cv2.CV_64F, 1, 0, ksize=3)   # d/du, blurred
Iv = cv2.Sobel(I, cv2.CV_64F, 0, 1, ksize=3)   # d/dv, blurred
mag = cv2.magnitude(Iu, Iv)

Run the gradient field below. Move the slider to change the edge angle in a synthetic image; the little arrows show ∇I at each cell — long across the edge, short along it.

Gradient Field of a Synthetic Edge

A grayscale ramp forms an edge at the chosen angle. Arrows show the local gradient ∇I; their length is brightness change. Notice the arrows are long and perpendicular to the edge, and near zero in flat regions. Toggle smoothing to see how blurring tames a noisy image.

edge angle 60° noise 0
At a pixel on a sharp vertical edge (dark on the left, bright on the right), what does the gradient vector ∇I look like?

Chapter 2: Finding Corners — The Harris Detector

Edges are useful, but for matching across views they have a fatal flaw. Slide a window along an edge and the picture barely changes — one stretch of edge looks like any other stretch. You cannot tell where on the edge you are. That ambiguity is the aperture problem, and it makes edges poor landmarks.

What you want is a corner: a point where two edges meet, so that brightness changes in every direction. Slide a window even slightly off a corner and the picture changes a lot, in a unique way. That is what makes corners repeatable (findable in both views) and distinctive (matchable) — the two properties Lecture 9 demands of a good feature.

The common misconception. "A corner is just where the gradient is large." No — an edge also has a large gradient. The distinguishing test is whether brightness changes in all directions, not just one. A long bright edge has a huge gradient but is a terrible feature. We need a detector that fires on two-directional change, and rejects pure edges. That detector is Harris.

The intuition: shift a window and watch the change

Stanford's geometric picture: take a small window, shift it by a tiny amount (Δu, Δv), and measure how much the patch's intensity changes (sum of squared differences). Three cases:

The change for a small shift turns out (after a Taylor expansion) to depend on a single 2×2 matrix built from the gradients in the window — the structure tensor (also called the second-moment matrix), M:

M = Σwindow [ Iu²    IuIv ;    IuIv    Iv² ]

Each entry is a sum over all pixels in the window. Define the three sums:

A = Σ Iu²,    B = Σ Iv²,    C = Σ IuIv,    M = [ A   C ;   C   B ]

This matrix is a compact summary of which directions have brightness change inside the window. Its two eigenvalues λ1, λ2 measure the change along the two principal directions:

The cornerness score — no eigenvalues needed

Computing eigenvalues per pixel is expensive. Harris's trick: a score that is large only when both eigenvalues are large, built from cheap quantities — the determinant and trace of M:

R = det(M) − k · tr(M)²

where det(M) = λ1λ2 = AB − C², tr(M) = λ1 + λ2 = A + B, and k is a small constant, typically 0.04–0.06. Why does this work? The determinant is the product of eigenvalues — it is only large when both are large. The trace penalty subtracts off cases where one eigenvalue dominates (an edge), where the trace squared blows up but the product does not. So:

Worked example: cornerness of a tiny patch by hand

Take a window with three pixels whose gradients we computed (mix of horizontal and vertical change — a corner-like patch). Gradients (Iu, Iv) at the three pixels are (4, 0), (0, 3), (2, 2). Build the three sums:

pixelIuIvIu²Iv²IuIv
1401600
203090
322444
ΣA=20B=13C=4
M = [ 20   4 ;   4   13 ]
det(M) = (20)(13) − (4)(4) = 260 − 16 = 244
tr(M) = 20 + 13 = 33
R = 244 − 0.04 × 33² = 244 − 0.04 × 1089 = 244 − 43.6 = 200.4

R = 200.4 > 0 and large → this is a corner. Now contrast with a pure-edge patch where all gradients are horizontal: (4, 0), (5, 0), (3, 0). Then A = 16+25+9 = 50, B = 0, C = 0:

det = (50)(0) − 0 = 0,   tr = 50,   R = 0 − 0.04 × 2500 = −100

Negative → correctly rejected as an edge. The determinant collapsed to zero because all change was in one direction. Harris just told an edge from a corner with grade-school arithmetic.

What Harris is invariant to (and not). Lecture 9: Harris is invariant to rotation (the eigenvalues of M don't care which way the corner is turned) and to linear intensity changes (brighter/dimmer lighting). It is not invariant to scale — zoom in and a corner becomes a smooth curve. That gap motivates scale-invariant detectors like the Difference-of-Gaussians used inside SIFT.

From scratch in Python

python
import numpy as np
from scipy.ndimage import uniform_filter

def harris(I, k=0.04, win=3):
    Iu = np.zeros_like(I); Iv = np.zeros_like(I)
    Iu[:, 1:-1] = (I[:, 2:] - I[:, :-2]) / 2.0
    Iv[1:-1, :] = (I[2:, :] - I[:-2, :]) / 2.0
    # sum the products over a window == the structure tensor entries A,B,C
    A = uniform_filter(Iu*Iu, win)
    B = uniform_filter(Iv*Iv, win)
    C = uniform_filter(Iu*Iv, win)
    detM = A*B - C*C
    trM  = A + B
    R = detM - k*trM**2                # cornerness per pixel
    return R

# corners = local maxima of R above a threshold (non-max suppression)

And the one-liner most engineers actually call:

python
import cv2
R = cv2.cornerHarris(I.astype('float32'), blockSize=2, ksize=3, k=0.04)
corners = np.argwhere(R > 0.01*R.max())   # threshold + (you still need NMS)

Below, the cornerness heatmap is computed live on a synthetic scene with a square. Bright spots are high R. Watch the four corners of the square light up red while the edges stay dark — exactly what the determinant predicts. Drag k to see how it trades corners against edges.

Harris Cornerness Heatmap

A synthetic square. The right panel is the cornerness score R: warm = corner (R large positive), blue = edge (R negative), dark = flat. Move k and the threshold; detected corners are marked with circles on the left image.

k 0.04 threshold 0.25 corners: 0
In the Harris score R = det(M) − k·tr(M)², why does using det(M) reject edges?

Chapter 3: Describing & Matching Features

Harris hands us a list of corner locations: "interesting point at pixel (412, 88)." But a location alone cannot be matched across two photos. To say "this corner in image A is that corner in image B," each corner needs a fingerprint — a vector of numbers that summarizes the patch around it. That fingerprint is a descriptor.

Lecture 9 lays out the design requirements. A good descriptor should be:

The naive descriptor and why it fails

The simplest descriptor: grab the n×m window of raw pixel intensities around the corner and flatten it into a vector. Normalize it (subtract the mean, divide by the standard deviation) and it becomes invariant to linear brightness changes. Stanford lists its three fatal drawbacks:

  1. Sensitive to pose. Rotate the camera a few degrees and every pixel shifts; the raw window changes completely.
  2. Sensitive to scale. Move closer and the patch covers different physical area.
  3. Poorly distinctive. Two different smooth patches look alike pixel-for-pixel.
The misconception. "Just compare the raw pixel patches — if they look the same, they match." Raw patches are wrecked by the smallest rotation or zoom, the very things that happen between two photos taken from different places. The fix is to describe not the pixels themselves but the distribution of gradients around the point, which is far more stable under those transformations.

The SIFT idea: a histogram of gradient orientations

The SIFT descriptor (Scale-Invariant Feature Transform) is the workhorse. Its key idea, in one breath: describe a keypoint by the pattern of gradient orientations in its neighborhood, not the raw intensities. Orientations survive lighting changes (a brighter image has the same gradient directions, just longer arrows), and binning them into a histogram tolerates small shifts.

The construction, simplified:

  1. Take a patch (say 16×16) around the keypoint and compute the gradient (magnitude, orientation) at every pixel.
  2. Assign a dominant orientation to the whole patch (the peak of its orientation histogram) and rotate the patch to that angle. Now the descriptor is rotation-invariant: a corner described the same way no matter how the camera was tilted.
  3. Split the patch into a 4×4 grid of cells. In each cell, build an 8-bin histogram of gradient orientations, weighted by magnitude.
  4. Concatenate: 4×4 cells × 8 bins = 128 numbers. Normalize to unit length for lighting invariance. That 128-dimensional vector is the SIFT descriptor.

The genius is in step 3: a histogram throws away exact pixel positions within each cell, keeping only "how much gradient points which way." That tolerance is precisely what makes it robust to the small geometric wobble between two views.

Why orientation histograms beat raw pixels. Raw pixels encode absolute brightness at exact positions — fragile. A gradient-orientation histogram encodes relative structure ("there's a strong edge pointing up-left in this region") — robust. The same physical corner, seen brighter, smaller, or slightly rotated, produces almost the same histogram. That is invariance bought by deliberately discarding the fragile information.

Matching: nearest descriptor + the Lowe ratio test

Now we have a set of 128-D descriptors in image A and another set in image B. To match a descriptor dA, find the nearest descriptor in B by Euclidean distance:

d(x, y) = √( Σi (xi − yi)² )

But "nearest" is not enough. Smooth regions and repetitive textures produce many near-identical descriptors, and the closest one is often a false match. David Lowe's fix — the single most important line in feature matching — is the ratio test: find the two nearest neighbors in B (distances d1 ≤ d2) and keep the match only if the best is clearly better than the runner-up:

accept match  ⇔  d1 / d2 < τ   (typically τ = 0.7 or 0.8)

Worked example: the ratio test by hand

Descriptor dA's two nearest neighbors in image B are at distances d1 = 0.18 and d2 = 0.21. The ratio is:

0.18 / 0.21 = 0.857  >  0.8  →  REJECT

The best match is barely better than the second-best — ambiguous, probably a repetitive texture, so we throw it away. Now another descriptor with d1 = 0.12 and d2 = 0.40:

0.12 / 0.40 = 0.30  <  0.8  →  ACCEPT

The best match is far better than any alternative — a confident, unique correspondence. The ratio test is a cheap, brutally effective outlier filter that discards most bad matches before geometry ever sees them. But it does not catch all of them — which is why we still need RANSAC (Chapter 6).

From scratch in Python

python
import numpy as np

def match_ratio(descA, descB, tau=0.8):
    matches = []
    for i, da in enumerate(descA):
        # Euclidean distance from da to every descriptor in B
        dists = np.linalg.norm(descB - da, axis=1)
        order = np.argsort(dists)            # nearest first
        d1, d2 = dists[order[0]], dists[order[1]]
        if d1 < tau * d2:                     # Lowe ratio test
            matches.append((i, order[0]))    # (index in A, index in B)
    return matches

And the library version with OpenCV's SIFT + brute-force matcher:

python
import cv2
sift = cv2.SIFT_create()
kpA, descA = sift.detectAndCompute(imgA, None)
kpB, descB = sift.detectAndCompute(imgB, None)
bf = cv2.BFMatcher()
pairs = bf.knnMatch(descA, descB, k=2)
good = [m for m, n in pairs if m.distance < 0.8*n.distance]   # ratio test

Try the matcher below. Two views of a scene with several corners. Slide the ratio threshold τ: low τ keeps only the most confident matches (few lines, all correct); high τ keeps more, but lets a few wrong (red) lines slip through.

Descriptor Matching with the Ratio Test

Left and right are two views of the same points. Green lines = accepted matches; red dashed = an ambiguous match that the ratio test is about to reject as you lower τ. Slide τ and watch the trade between more matches and cleaner matches.

ratio τ 0.80 accepted: 0 · rejected: 0
The Lowe ratio test accepts a match only if d₁/d₂ < τ. What does a small ratio (e.g. 0.3) tell you?

Chapter 4: Epipolar Geometry — The Geometry of Two Views

Matching by descriptors is a 2D search over millions of pixels — slow and error-prone. But two views are not independent: they are rigidly tied by geometry. Once you know how the two cameras are placed relative to each other, the match for a point in image A is forced to lie on a single line in image B. That constraint, the crown jewel of two-view vision, is epipolar geometry.

The setup

Two cameras with centers O and O′ both look at a 3D point P. It projects to pixel p in image 1 and p′ in image 2. Now stare at the five points P, O, O′, p, p′. They are coplanar — they all lie on one plane, the epipolar plane. Why? Because p is on the ray Op, p′ is on the ray O′p′, and both rays pass through P — so the two rays plus the line joining the camera centers (the baseline OO′) all live in the plane they define.

Some vocabulary you will see everywhere:

The epipolar constraint, in words. The match p′ for a point p must lie on the epipolar line l′ in the second image — not anywhere in the 2D image, just along one line. The 2D correspondence search collapses to a 1D search. That is a massive speedup and a powerful sanity check: any match that lands off its epipolar line is geometrically impossible.

The fundamental matrix F

This coplanarity condition can be written as one beautifully compact equation. With p and p′ written in homogeneous coordinates (a pixel (u, v) becomes the 3-vector (u, v, 1)), the epipolar constraint is:

p′ F p = 0

Here F is the fundamental matrix, a 3×3 matrix that encodes everything about the geometric relationship between the two views — the relative rotation, the relative translation, and both cameras' internal calibration, all baked into nine numbers. Let's unpack the equation:

F is a machine that turns points into lines. Give it a point p in image 1 and it spits out the epipolar line l′ = Fp in image 2 where the match must lie — with no other information needed. Transpose it and l = Fp′ goes the other way. Two key facts: F is singular (det F = 0, since all epipolar lines pass through the epipole), and it has only 7 degrees of freedom (9 entries − 1 for overall scale − 1 for the rank constraint).

Fundamental F vs. essential E

There are two cousins, and the difference is whether you have calibrated cameras:

Fundamental FEssential E
Works onraw pixel coordinatescalibrated (normalized) coordinates
Needs intrinsics K?NoYes (uses K from Lesson 8)
Encodesgeometry + both calibrationspure relative pose (R, t)
DoF75

The exact relationship, from Lecture 18 (with K the camera intrinsic matrix, R the relative rotation, and [t]× the skew-symmetric matrix encoding the translation as a cross product):

E = [t]× R,     F = K−⊤ E K−1

The essential matrix E is the prize: once you have it (and you have K), you can decompose it to recover the relative rotation R and translation t between the two cameras — the camera motion. That decomposition is the engine of visual odometry (Chapter 9).

Click a point in the left image below. The constraint draws the matching epipolar line in the right image — the only place its match can live. Drag the second camera (baseline slider) to watch the epipoles and the family of lines pivot.

Click a Point → See Its Epipolar Line

Click anywhere in the left image. The right image draws the epipolar line l′ = Fp where the match must lie. Notice every line passes through the same point — the epipole. Move the baseline to change the camera geometry.

baseline 0.18 click the left image
What does the fundamental matrix F do when you compute Fp for a point p in image 1?

Chapter 5: The 8-Point Algorithm — Estimating F

The fundamental matrix is so useful that the obvious question is: how do we find it from a pair of images? We don't know the camera motion in advance. But we do have something better — a pile of corresponding points (pi, p′i) from Chapter 3. Each correspondence gives us one equation about F, and enough of them pin it down. This is the 8-point algorithm.

Turning the constraint into a linear equation

Write the constraint p′ F p = 0 for one correspondence, with p = (u, v, 1) and p′ = (u′, v′, 1) and the unknown matrix written out:

F = [ f11 f12 f13 ; f21 f22 f23 ; f31 f32 f33 ]

Multiplying it all out, the single scalar equation p′Fp = 0 becomes linear in the nine unknowns:

u′u·f11 + u′v·f12 + u′·f13 + v′u·f21 + v′v·f22 + v′·f23 + u·f31 + v·f32 + f33 = 0

Stack the nine unknowns into a vector f = (f11, …, f33). Then each correspondence contributes one row ai of known coefficients:

ai = ( u′u,   u′v,   u′,   v′u,   v′v,   v′,   u,   v,   1 )

With n correspondences we stack the rows into an n×9 matrix A and demand:

A f = 0

Why "8 points"?

F has nine entries, but it is only defined up to scale (multiplying every entry by 2 leaves the constraint p′Fp = 0 unchanged). So there are really only 8 free parameters, and you need 8 correspondences to fix them. With exactly 8 points you solve the system directly; with more, you solve a least-squares problem (overdetermined, more robust).

Solving Af = 0 by least squares (the SVD)

We cannot just solve Af = 0 the usual way, because f = 0 is a trivial useless answer. We want the nonzero f (of unit length) that makes Af as close to zero as possible:

minimize   ||A f||²   subject to   ||f|| = 1

The answer is a classic linear-algebra fact: it is the singular vector of A with the smallest singular value — the last column of V in the SVD A = UΣV. Reshape that 9-vector back into a 3×3 matrix and you have an estimate .

Two crucial fix-ups

The raw estimate needs two corrections, both straight from Lecture 18:

  1. Normalization (Hartley). Pixel coordinates have wildly different scales (e.g. u′u can be hundreds of thousands while the last entry is 1). This makes A badly conditioned and the answer noise-sensitive. The fix: before building A, translate and scale each image's points so they are centered at the origin with average distance √2. Solve, then undo the transform. This is not optional — it is the difference between garbage and a usable F.
  2. Rank-2 enforcement. A true fundamental matrix is singular (det F = 0, rank 2), but noisy data gives a full-rank estimate. Force it: take the SVD of F̂ = UΣV, set the smallest singular value in Σ to zero, and rebuild. This is the closest rank-2 matrix (in Frobenius norm) to your estimate.
The misconception that wrecks reconstructions. "I'll just solve the 8 equations and call it done." Skip Hartley normalization and your F is dominated by numerical error; skip rank-2 enforcement and your epipolar lines won't even pass through a common epipole. Both fix-ups are mandatory. They are why production code uses cv2.findFundamentalMat, which does them for you.

Worked setup: building one row of A

Suppose one correspondence is p = (100, 50, 1) in image 1 and p′ = (110, 48, 1) in image 2. Its row of A is:

a = ( u′u, u′v, u′, v′u, v′v, v′, u, v, 1 )
a = ( 110×100, 110×50, 110, 48×100, 48×50, 48, 100, 50, 1 )
a = ( 11000,   5500,   110,   4800,   2400,   48,   100,   50,   1 )

Notice the disastrous spread: 11000 down to 1. That is exactly why Hartley normalization — rescaling the points first — is essential. Eight such rows stack into A, and the smallest-singular-vector gives f.

From scratch in Python

python
import numpy as np

def eight_point(pts1, pts2):
    # pts1, pts2: arrays of shape (n, 2), n >= 8 correspondences
    n = pts1.shape[0]
    A = np.zeros((n, 9))
    for i in range(n):
        u, v = pts1[i]; up, vp = pts2[i]
        A[i] = [up*u, up*v, up, vp*u, vp*v, vp, u, v, 1]
    # smallest singular vector of A -> f
    _, _, Vt = np.linalg.svd(A)
    F = Vt[-1].reshape(3, 3)
    # enforce rank 2: zero the smallest singular value
    U, S, Vt2 = np.linalg.svd(F)
    S[2] = 0
    F = U @ np.diag(S) @ Vt2
    return F / F[2, 2]   # normalize scale

(A real implementation wraps this in Hartley normalization.) The library version:

python
import cv2
F, mask = cv2.findFundamentalMat(pts1, pts2, cv2.FM_RANSAC)   # normalization + rank-2 + RANSAC

Notice that flag: FM_RANSAC. Real point lists are full of bad matches that no ratio test caught, and a single outlier destroys a least-squares fit. The cure is the algorithm in the next chapter.

Why does the 8-point algorithm solve Af = 0 using the smallest-singular-value vector of A, rather than ordinary linear solving?

Chapter 6: RANSAC — Fitting Through a Storm of Outliers

Here is the crisis. Your matcher returns 200 correspondences. Maybe 140 are correct (inliers); the other 60 are nonsense (outliers) — repetitive textures, glare, mismatched windows that survived the ratio test. Now fit a fundamental matrix to all 200 with least squares. The result is ruined. Least squares minimizes squared error, so a few wild outliers, squared, dominate the fit. One bad match can drag the answer off a cliff.

We need an estimator that ignores the bad data instead of averaging it in. That estimator is RANSAC — RANdom SAmple Consensus. It is one of the most important algorithms in all of robotics and vision, and the idea is shockingly simple.

The RANSAC bet. Instead of fitting all the data, repeatedly guess using a tiny random sample, then ask the rest of the data to vote. The insight: a model fit to a few good points will be supported by many points (a big consensus), while a model fit to even one bad point will be supported by few. So fit lots of tiny random models, keep the one with the most votes, and the outliers never get a say.

The algorithm, step by step

To fit a model needing a minimal sample of s points (e.g. a line needs s = 2, a fundamental matrix needs s = 8):

1. Sample
pick s points at random
2. Fit
fit the model to just those s
3. Score
count inliers (points within ε)
4. Keep best
remember the model with the most inliers
↻ N times

Then, as a final polish, refit the best model using all its inliers by least squares — now safe, because the outliers have been removed from the pool. A point is an inlier if its error from the model is below a threshold ε (for an epipolar fit, the distance from the matched point to its predicted epipolar line).

How many iterations? The N formula

How many random samples N do we need to be confident? We want at least one sample that is all inliers. Let w be the fraction of inliers in the data. Then:

We want that failure probability to be small, equal to 1 − p where p is our desired success confidence (e.g. 0.99). Set (1 − ws)N = 1 − p and take logs:

N = log(1 − p) / log(1 − ws)

Worked example: N for line fitting with 50% outliers

Fit a line (minimal sample s = 2), inlier fraction w = 0.5 (half the data is junk), desired confidence p = 0.99. Plug in:

ws = 0.5² = 0.25  →  1 − ws = 0.75
N = log(1 − 0.99) / log(0.75) = log(0.01) / log(0.75)
N = (−4.605) / (−0.2877) = 16.0  →  N ≈ 16 iterations

Stunning. Even with half the data being garbage, just 16 random two-point samples give 99% confidence of hitting one clean sample. That is why RANSAC is everywhere — it is cheap and almost magically robust to outliers.

The N formula's two lessons. (1) N grows exponentially with the sample size s: a line (s=2) at 50% inliers needs 16, but the 8-point fundamental matrix (s=8) at the same 50% needs N = log(0.01)/log(1−0.5⁸) ≈ 1177 — which is why people use the 5-point or 7-point essential-matrix solvers when they can. (2) N depends on nothing about the total data size — only the inlier ratio. A million points or a hundred, the iteration count is the same.

The fundamental matrix, RANSAC-ified

Combining Chapters 5 and 6 gives the production recipe used in every visual-SLAM front end:

  1. Randomly pick 8 correspondences.
  2. Fit F with the 8-point algorithm.
  3. Count inliers: correspondences where p′ lies within ε pixels of its predicted epipolar line Fp.
  4. Repeat N times; keep the F with the most inliers.
  5. Refit F using all inliers; discard the outliers forever.

From scratch in Python (line fit)

python
import numpy as np

def ransac_line(pts, eps=1.0, p=0.99, w=0.5):
    s = 2
    N = int(np.ceil(np.log(1-p) / np.log(1 - w**s)))   # iteration budget
    best_inliers, best_model = [], None
    for _ in range(N):
        i, j = np.random.choice(len(pts), 2, replace=False)   # 1. sample
        (x1,y1),(x2,y2) = pts[i], pts[j]
        a, b, c = y2-y1, x1-x2, x2*y1 - x1*y2            # 2. fit line ax+by+c=0
        norm = np.hypot(a, b) + 1e-9
        dist = np.abs(a*pts[:,0] + b*pts[:,1] + c) / norm   # 3. point-to-line dist
        inliers = np.where(dist < eps)[0]
        if len(inliers) > len(best_inliers):              # 4. keep best
            best_inliers, best_model = inliers, (a,b,c)
    # 5. refit on all inliers (least squares) -- omitted for brevity
    return best_model, best_inliers

Watch it run. Points are mostly on a line plus a cloud of outliers. RANSAC repeatedly samples two points (the thin candidate line), counts inliers, and locks onto the consensus line in red — while plain least squares (gray) gets yanked toward the outliers. Crank the outlier slider and watch least squares fail while RANSAC holds.

RANSAC Line Fit vs. Least Squares

Press Run RANSAC to step through random 2-point samples (cyan candidate, current consensus highlighted). The red line is the best RANSAC fit; the gray line is plain least squares over all points. Increase the outlier fraction and see least squares collapse while RANSAC survives. The readout shows the computed iteration budget N.

outliers 35% N budget: — · best inliers: —
Fitting a line (s = 2) with inlier fraction w = 0.5 and confidence p = 0.99 gives N ≈ 16. If you instead needed an 8-point sample (s = 8) at the same w and p, what happens to N?

Chapter 7: Triangulation — From Two Rays to a 3D Point

We have come a long way: detected corners, matched them, estimated the geometry with RANSAC, and (by decomposing the essential matrix) recovered the relative pose (R, t) between the two cameras. Now we cash in. Given a matched pair (p, p′) and the camera poses, triangulation recovers the 3D point P.

The clean idea: intersect two rays

Each pixel back-projects to a ray. Pixel p defines the ray Op from camera 1's center; pixel p′ defines the ray O′p′ from camera 2's center. The 3D point lives where the two rays cross. In a perfect world they intersect exactly — that is the geometry of Chapter 0 made precise.

Why we know the rays can be intersected now. Earlier we said one camera gives only a ray. With two cameras whose relative pose is known, we have two rays expressed in a common frame. Two lines in 3D generically meet at a point — and that point is the reconstruction. The whole pipeline existed to put both rays into the same coordinate frame.

The catch: noisy rays don't quite meet

In reality, pixels are measured with noise, so the two rays are skew — they pass close but never exactly touch. Lecture 18's fix: instead of demanding an exact intersection, find the 3D point Q whose projections q, q′ into the two images are as close as possible to the measured p, p′. We minimize the re-projection error:

Q = argminQ   ||q(Q) − p||² + ||q′(Q) − p′||²

where q(Q) is where the candidate 3D point Q would land in image 1. Re-projection error — "how far off is my reconstruction when I project it back into the photos" — is the universal currency of multi-view geometry. We will see it again in bundle adjustment.

The linear (DLT) method

A point projects via its camera matrix M (a 3×4 matrix combining intrinsics and pose) as p ∼ M Q, where means "equal up to scale" and Q = (X, Y, Z, 1) is the homogeneous 3D point. The "up to scale" means the cross product p × MQ = 0. Each image gives two independent linear equations in the unknown Q; two images give four equations, and — just like the 8-point algorithm — we solve the resulting homogeneous system with an SVD (smallest singular vector). This is the Direct Linear Transform (DLT).

Worked example: triangulating a point with rectified cameras

To see real numbers without 3×4 matrix grind, take the simplest case — two parallel cameras (the stereo setup of Chapter 8), focal length f = 500 px, baseline B = 0.10 m, with the left camera at the origin. A point projects to uL = 320 in the left image and uR = 300 in the right. The disparity is:

d = uL − uR = 320 − 300 = 20 px

Depth (derived in full next chapter) is Z = fB/d:

Z = (500 × 0.10) / 20 = 50 / 20 = 2.5 m

Now recover X and Y by back-projecting the left pixel (with principal point at, say, cu = 320, cv = 240, and vL = 240):

X = (uL − cu) · Z / f = (320 − 320) × 2.5 / 500 = 0 m
Y = (vL − cv) · Z / f = (240 − 240) × 2.5 / 500 = 0 m

So P = (0, 0, 2.5) m — straight ahead, 2.5 meters out. We just turned two pixel measurements into a 3D point with arithmetic.

The scale ambiguity

One profound subtlety from Lecture 18: with a single camera moving (structure from motion), you can recover the scene shape and the camera motion only up to an unknown overall scale. The reason: the essential matrix gives the translation direction t but not its length. Double the whole scene and double the camera baseline, and the images are pixel-for-pixel identical. You cannot tell a dollhouse photographed up close from a real house photographed far away.

The misconception. "Two photos give me a metric 3D model." Only if you know the baseline length (stereo with a measured rig, Chapter 8) or have another scale cue (a known object size, IMU, wheel odometry). Monocular SfM gives shape, not size. That is why monocular visual odometry suffers scale drift and why robots fuse a second sensor to nail the scale.

From scratch (linear triangulation)

python
import numpy as np

def triangulate(p1, p2, M1, M2):
    # p1, p2: pixel (u, v) in each image; M1, M2: 3x4 camera matrices
    u1, v1 = p1; u2, v2 = p2
    A = np.array([
        u1*M1[2] - M1[0],
        v1*M1[2] - M1[1],
        u2*M2[2] - M2[0],
        v2*M2[2] - M2[1],
    ])                                  # 4x4 from p x MQ = 0
    _, _, Vt = np.linalg.svd(A)
    Q = Vt[-1]                          # smallest singular vector
    return Q[:3] / Q[3]                  # de-homogenize -> (X, Y, Z)

And the library one-liner:

python
import cv2
pts4d = cv2.triangulatePoints(M1, M2, pts1.T, pts2.T)   # 4xN homogeneous
pts3d = (pts4d[:3] / pts4d[3]).T                       # Nx3

Drag the matched point in the demo. Two camera rays redraw and intersect; the 3D depth readout updates. Add measurement noise to see the rays go skew and the reconstruction wobble — the re-projection-error minimizer picks the midpoint.

Triangulating a 3D Point (top-down view)

Top-down view of two cameras and one point. Drag the 3D point; its two rays redraw and the depth Z updates. Add pixel noise to make the rays skew — the estimate (open circle) is the least-reprojection-error point. Note how depth error grows as the point moves far from the cameras.

pixel noise 0 drag the point · Z = —
A robot uses one moving camera (monocular SfM) to reconstruct a hallway. What can it NOT determine?

Chapter 8: Stereo Vision — Depth From a Rigid Pair

Triangulation is easiest when the two cameras are arranged in the friendliest possible way: pointing the same direction, offset horizontally, like your two eyes. This is a stereo rig, and it is so common (every depth camera, every self-driving stereo pair) that it earns its own clean formula.

Rectification: make the epipolar lines horizontal

If the two image planes are exactly parallel and the cameras differ only by a horizontal shift, something wonderful happens: every epipolar line becomes a horizontal row, and corresponding points have the same v-coordinate (same row) in both images. The 1D epipolar search of Chapter 4 becomes a search along a single image row — trivial.

Real rigs are never perfectly aligned, so we rectify: apply a projective warp to both images that simulates a perfectly parallel pair. Lecture 18: "from now on, we assume rectified image pairs." After rectification, matching point p′ for p sits on the same row, shifted left by some amount.

Disparity and the depth formula

That horizontal shift is the disparity:

d = uL − uR

where uL and uR are the column of the same point in the left and right images. Now the payoff. By similar triangles (the triangle from the 3D point to the two camera centers is similar to the triangle from the point to the two image projections), the depth is:

Z = f · B / d

Three letters, each earning its place:

Read the formula like a sentence. Depth is inversely proportional to disparity. Nearby objects shift a lot between the two views (big d, small Z); faraway objects barely shift (tiny d, huge Z). Hold a finger near your nose and blink each eye — it jumps wildly. A distant building barely moves. That jump is disparity, and your brain runs Z = fB/d without telling you.

Worked example: depth from disparity, with numbers

A stereo camera has focal length f = 700 px and baseline B = 0.12 m. A point appears at column uL = 410 in the left image and uR = 380 in the right. Step by step:

d = 410 − 380 = 30 px
Z = (700 × 0.12) / 30 = 84 / 30 = 2.8 m

The object is 2.8 meters away. Now move it closer so the disparity doubles to d = 60:

Z = 84 / 60 = 1.4 m

Double the disparity, halve the depth — the inverse relationship in action. And a far object with d = 3 px:

Z = 84 / 3 = 28 m

Notice the danger: at large depth, a tiny disparity error (3 vs 4 px) swings Z from 28 m to 21 m — a 7-meter jump from one pixel. Stereo is precise up close and increasingly uncertain far away.

The baseline trade-off

Baseline BDisparity for same ZProCon
Largelarge (precise depth)small depth errorpoint may be visible to only one camera (occlusion)
Smallsmallboth cameras see most pointslarge depth error (tiny d, sensitive)

This is the same tension as Chapter 0's baseline slider: wider baseline = more depth information but harder correspondence. Stereo rig designers tune B to the working range.

The misconception. "More disparity is always better, so make the baseline huge." Too large a baseline and a point seen by the left camera falls outside the right camera's view (occlusion), so you get no match at all. And the appearance change between very different viewpoints breaks the correspondence search. Stereo lives in a sweet spot: wide enough for depth precision, narrow enough to keep correspondences findable.

From scratch in Python

python
import numpy as np

def depth_from_disparity(disparity, f, B):
    # disparity: HxW pixel-shift map; f: focal px; B: baseline m
    Z = np.where(disparity > 0, f * B / disparity, np.inf)
    return Z          # per-pixel depth in meters

And computing the disparity map with OpenCV's block matcher:

python
import cv2
stereo = cv2.StereoBM_create(numDisparities=64, blockSize=15)
disp = stereo.compute(left_gray, right_gray).astype(np.float32) / 16.0
Z = f * B / disp           # meters

Drag the object in the rectified stereo demo. Both views update, the disparity bar and depth Z = fB/d recompute live. Watch the disparity shrink as you push the object away.

Stereo Depth: Z = f·B/d

A rectified left/right pair (top) viewing one object, plus a top-down depth view (bottom). Drag the object left/right and near/far. The disparity d and depth Z update live. Adjust the baseline B to see precision change.

baseline B 0.12 m d = — px · Z = — m
In stereo, Z = f·B/d. An object far away has what kind of disparity, and what does that mean for depth accuracy?

Chapter 9: Structure from Motion — The Full Pipeline

Stereo had it easy: a fixed rig with a known baseline. Structure from Motion (SfM) is the harder, more general problem from Lecture 18: given m images of n fixed 3D points, taken from unknown positions (maybe even by different cameras at different times), recover both:

This is the algorithm behind a single moving camera reconstructing its surroundings — the core of monocular visual odometry and the front-end of visual SLAM. Every chapter you have read is a stage in it.

The two-view SfM pipeline, assembled

Lecture 18's algebraic two-view recipe, now that you own every piece:

Detect
Harris/SIFT corners in both images (Ch 2)
Describe + Match
descriptors + ratio test (Ch 3)
Estimate F/E
8-point + RANSAC (Ch 5, 6)
Recover pose
decompose E → relative (R, t) (Ch 4)
Triangulate
matched points + pose → 3D cloud (Ch 7)
Refine
bundle adjustment

Walk through it once in words. Detect corners in image A and B. Describe and match them, ratio-testing out the obvious junk. Feed the surviving matches to RANSAC + 8-point to get a clean fundamental matrix and an inlier set. Convert F to the essential matrix using the calibration K, and decompose it into the relative rotation and translation. Now both cameras live in one frame, so triangulate every inlier match into a 3D point. You have structure and motion — up to the scale ambiguity of Chapter 7.

Bundle adjustment: the final polish

The two-view estimate is good but not optimal — errors accumulate, and with more than two views the camera poses and 3D points must all agree simultaneously. Bundle adjustment is the global refinement: jointly nudge all camera poses and all 3D points to minimize the total re-projection error across every image:

min{cameras}, {points}   Σi,j ||qij − project(camerai, pointj)||²

Read it: for every point j seen in every image i, project the current 3D estimate into that image and compare to the actually-observed pixel qij. Sum all those squared errors and minimize over everything at once. It is a large nonlinear least-squares problem, solved with Levenberg-Marquardt. The name evokes "bundles" of light rays converging on each camera, adjusted until they best agree.

Re-projection error, again. Notice the same quantity that drove triangulation (Ch 7) drives bundle adjustment (Ch 9). It is the master objective of geometric vision: a reconstruction is good exactly when projecting it back into all the photos reproduces the measured pixels. If you understand re-projection error, you understand what every SfM and SLAM optimizer is actually minimizing.

Scale drift — the monocular curse

String many two-view steps together (frame 1→2, 2→3, 3→4, …) to track a camera over a long trajectory, and each step's unknown scale (Ch 7) is estimated slightly differently. The errors compound: the reconstructed trajectory slowly grows or shrinks relative to reality. This is scale drift, and it is the defining weakness of monocular visual odometry.

The misconception. "Bundle adjustment fixes scale drift." It reduces local error beautifully, but it cannot invent the absolute scale that monocular geometry never had. The real fixes: a stereo rig (known baseline → metric scale every frame), an IMU (accelerometer integrates to metric motion), wheel odometry, or loop closure (recognizing a previously-visited place and snapping the trajectory back — the heart of full SLAM in Lesson 11).

SfM as visual odometry

Lecture 18 closes on the robotics payoff: visual odometry — estimating the robot's motion from its camera stream. A monocular camera gives motion up to scale; a stereo camera gives motion in absolute scale directly (every frame is metric). This is how a robot with cameras knows it moved 1.2 m forward and turned 15° — by running this exact detect→match→estimate→triangulate loop on consecutive frames. Features and SfM feed straight into Perception, and their pose estimates feed Localization & SLAM (Lesson 11).

python
# Two-view SfM, end to end (OpenCV)
import cv2, numpy as np
# 1-2. detect + describe + match (ratio-tested)  --> pts1, pts2  (Ch 2-3)
# 3.   robust essential matrix (RANSAC built in)
E, mask = cv2.findEssentialMat(pts1, pts2, K, method=cv2.RANSAC, prob=0.999)
# 4.   recover relative pose (R, t) by decomposing E
_, R, t, mask = cv2.recoverPose(E, pts1, pts2, K)
# 5.   build camera matrices and triangulate the inliers
M1 = K @ np.hstack([np.eye(3), np.zeros((3,1))])
M2 = K @ np.hstack([R, t])
pts4d = cv2.triangulatePoints(M1, M2, pts1.T, pts2.T)
cloud = (pts4d[:3] / pts4d[3]).T          # 3D point cloud (up to scale)
# 6.   bundle adjustment over many frames -> refined poses + points
What does bundle adjustment minimize, and what can it NOT recover for a monocular sequence?

Chapter 10: Showcase — RANSAC Fights the Outliers, Live

This is the payoff. One interactive sandbox that ties together the two ideas at the heart of robust geometry: least squares collapses under outliers, and RANSAC does not. You control the outlier fraction; you watch both estimators react in real time; and the iteration budget N = log(1−p)/log(1−ws) recomputes as you drag.

The scene: a cloud of 2D points that mostly lie on a hidden true line (the inliers), plus a tunable fraction of pure-noise points scattered anywhere (the outliers). Two estimators race to recover the line:

Press Run to animate RANSAC sampling: each frame draws the current random 2-point candidate (cyan) and highlights its inliers. The best-so-far consensus line firms up in red. The status panel reports the live inlier count, the consensus count, the running best, and the theoretical iteration budget.

What to look for. At low outlier fraction the two lines agree — outliers don't matter much, so why bother with RANSAC? Now drag the outlier fraction up past ~30%. The gray least-squares line tilts and slides toward the junk, while the red RANSAC line stays glued to the true inliers. That divergence — the moment robustness starts to matter — is the entire lesson of this chapter, and the reason RANSAC is in every SLAM front end.
RANSAC vs. Least Squares — Robust Fitting Sandbox

Drag the outlier fraction and the inlier threshold ε. Press Run to animate RANSAC sampling (cyan = current candidate, green dots = its inliers). Red = best RANSAC fit; gray = least squares. The readout shows the iteration budget N and the divergence between the two fits. Press Step to advance one sample at a time.

outliers 30% ε 16
drag a slider or press Run

This sandbox is, in miniature, what a robot does when it estimates its motion from a camera: its matched features are full of outliers, and RANSAC is what lets it find the true geometry anyway. Swap "line" for "fundamental matrix" and "point-to-line distance" for "distance to epipolar line," and you have the actual SfM front end.

Chapter 11: Connections & Cheat-Sheet

You started with a flat grid of pixels and ended with a 3D reconstruction and a recovered camera motion. Let's consolidate, place this lesson in the autonomy stack, and point to where it leads.

Where this sits on the stack

Features and SfM are squarely in Perception — they turn raw pixels (Lesson 8's camera images) into geometric meaning: 3D points and relative poses. Their outputs flow upward into Localization & SLAM (Lesson 11), where the camera poses become the robot's trajectory and the 3D points become a feature map. The feature/landmark map from Lesson 1's map zoo? This is the lesson that produces it.

Cheat-sheet

Image as a function: I(u,v) → intensity. Gradient ∇I = (Iu, Iv); edge magnitude |∇I| = √(Iu²+Iv²). Smooth before differentiating.

Harris corner: M = Σ[Iu² IuIv; IuIv Iv²]; R = det(M) − k·tr(M)². R>0 large = corner, R<0 = edge.

Matching: nearest descriptor + Lowe ratio test d₁/d₂ < τ (≈0.8).

Epipolar: p′Fp = 0; Fp = epipolar line in image 2. E = [t]×R (calibrated), F = K−⊤EK−1.

8-point: stack rows ai, solve Af=0 via smallest singular vector; Hartley-normalize + enforce rank 2.

RANSAC: sample s → fit → count inliers → keep best → refit. N = log(1−p)/log(1−ws). (w=0.5, s=2, p=0.99 → N≈16.)

Stereo depth: Z = f·B/d, where d = uL−uR is disparity. Depth is inverse to disparity.

SfM: detect → match → estimate F/E → recover (R,t) → triangulate → bundle-adjust. Monocular = up to scale (scale drift).

Related lessons on Engineermaxxing

These go deeper on what we touched, and on what comes next:

"What I cannot create, I do not understand." — Richard Feynman.
You can now create a 3D reconstruction from two photos — detect, match, robustly fit, and triangulate. Next, Lesson 10: learned perception, where neural networks take over feature extraction and semantic understanding.
Putting the pipeline in order: which sequence correctly recovers 3D structure from two images?