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

How a Camera Turns the World into Pixels — and Back

A camera flattens a 3D world onto a 2D grid and throws away depth. To use it for robotics we need the exact geometry: project a point forward to a pixel, and reason backward from a pixel to a ray. Then we recover the camera's hidden numbers by calibration.

Prerequisites: basic vectors & matrix multiply + a little Python. That's it.
11
Chapters
6
Simulations
0
Assumed Knowledge

Chapter 0: A Camera Throws Away Depth

Hold up your thumb at arm's length. Now hold it close to your nose. In both cases your thumb covers about the same patch of your visual field, yet one is a few centimeters away and the other is more than half a meter. A photograph cannot tell them apart. A camera records direction, not distance.

That single fact is the heart of this entire lesson. A camera is a device that takes the rich, three-dimensional world in front of it and presses it flat onto a two-dimensional grid of pixels. The pressing is lossy: an entire ray of points — everything along the line from the lens out to infinity — collapses to one pixel. The information "how far away was it?" is simply gone.

For a robot this is both a gift and a problem. The gift: a single cheap camera packs an enormous amount of information about the scene — far more than a LiDAR return or a wheel encoder tick. The problem: to use that information, the robot must know exactly how the pressing works. Given a 3D point, where does it land on the image? And given a pixel, what can the robot say about the 3D world?

Common misconception. "A camera measures where things are in 3D." It does not. A single camera measures the direction to each visible point, never the distance. Two points at very different depths but along the same line of sight produce the identical pixel. Recovering depth needs a second view, motion, or extra assumptions — that is the whole reason the next lesson exists.

Let's see the depth ambiguity directly. Below, a near point and a far point sit along the same line of sight through a pinhole camera. Slide them along that line and watch their projections: no matter how far apart they are in the world, they land on the same spot in the image. Distance information is destroyed at the moment of projection.

Two depths, one pixel

Top: a side view of the camera (the small dot is the pinhole, the bar is the image plane). Two points — a near one and a far one — lie along the same ray. Bottom: the resulting image. Drag the sliders to change each point's distance; both keep projecting to the same pixel.

near Z 3.0 far Z 11.0
Both points project to the same pixel column.

This lesson builds the exact mathematics of that pressing, in both directions. We will:

Why does a robotics course care? Because the camera model is the bridge between the world and the Perception module of the autonomy stack (Lesson 1). Every feature, every detected object, every reconstructed 3D point in structure-from-motion (Lesson 9) flows through the geometry we build here. Get this wrong and everything downstream is wrong.

Where we are on the stack. This is a Perception lesson. The pinhole model is the geometry underneath cameras; the next lesson (features & SfM) and visual SLAM (Lesson 11) both assume you already know how a 3D point becomes a pixel and back. We are laying the foundation they stand on.
A near soda can and a distant billboard happen to lie along the same line of sight from a single camera. What does the camera record about their distances?

Chapter 1: The Pinhole Model — Similar Triangles

Long before lenses, people noticed that light passing through a tiny hole into a dark box forms an image on the far wall — upside down. This is the camera obscura, and it is the simplest possible model of a camera: the pinhole camera. There is no lens, no focus, no aperture. Just a single point that all light rays must pass through.

Set up a coordinate frame fixed to the camera. The origin is the pinhole — call it the camera center OC. The camera looks down its optical axis, which we take to be the Z axis. The X axis points right, the Y axis points down (this is the standard computer-vision convention, and it is why images are indexed from the top-left).

Now place the image plane at distance f in front of the pinhole, where f is the focal length. (Physically the image forms behind the pinhole and is inverted; we use the equivalent virtual image plane in front, which is upright and saves us a pair of minus signs. The geometry is identical.)

The whole model in one picture. A 3D point PC = (XC, YC, ZC), the camera center OC, and the image point p are collinear — they lie on one straight ray. The image point is just where that ray pierces the plane at depth f. Everything else is bookkeeping.

Where does the ray pierce the plane? Use similar triangles. Look at the X–Z plane (a top-down slice). There is a big triangle from the pinhole out to the point at depth ZC with height XC, and a small similar triangle from the pinhole out to the image plane at depth f with height x. Similar triangles have equal ratios:

x / f = XC / ZC   ⇒    x = f · XC / ZC

The Y axis works identically with its own slice:

y = f · YC / ZC

These two lines are the pinhole camera equations. Let us name every symbol so nothing is mysterious: (XC, YC, ZC) are the point's coordinates in the camera frame (metres); ZC is its depth (how far in front of the camera); f is the focal length (the plane's distance from the pinhole); and (x, y) are the resulting image-plane coordinates, in the same physical units as f (we fix this in Chapter 2 by switching to pixels).

Why divide by Z? The signature of perspective

The division by depth ZC is the most important feature of the whole equation. It is what makes far things small and near things large — perspective. Double the depth and the image coordinate halves. This nonlinearity (a ratio of coordinates) is exactly why projection is not a simple matrix multiply yet, and exactly why Chapter 3 introduces homogeneous coordinates to tame it.

Worked example with real numbers

Take a focal length f = 500 (we will see in Chapter 2 these units are pixels). A point sits at PC = (2, 1, 5) — two metres to the right, one metre down, five metres ahead. Then:

x = 500 · 2 / 5 = 500 · 0.4 = 200
y = 500 · 1 / 5 = 500 · 0.2 = 100

So the point lands at image coordinates (200, 100) measured from the optical axis. Now push the same point twice as far away, to (4, 2, 10) — same direction, double the distance:

x = 500 · 4 / 10 = 200,    y = 500 · 2 / 10 = 100

Identical pixel! That is the depth ambiguity of Chapter 0, now in arithmetic: the projection depends only on the ratios X/Z and Y/Z, so any two points on the same ray collapse together.

From scratch in Python

python
def project_pinhole(P_c, f):
    """Project a 3D camera-frame point P_c=(Xc,Yc,Zc) to image coords (x,y)."""
    Xc, Yc, Zc = P_c
    if Zc <= 0:
        return None            # behind the camera: not imaged
    x = f * Xc / Zc            # similar triangles, X-Z slice
    y = f * Yc / Zc            # similar triangles, Y-Z slice
    return (x, y)

print(project_pinhole((2, 1, 5), f=500))   # (200.0, 100.0)
print(project_pinhole((4, 2, 10), f=500))  # (200.0, 100.0) -- same ray!

The Zc <= 0 guard matters: a point behind the camera has negative depth and the equations would happily produce a phantom pixel. Real cameras can't see behind themselves, so we reject it.

Now play with the model. Drag the 3D point in the side view; watch the ray swing through the pinhole and the projection slide along the image plane. Move the point deeper (increase Z) and watch its projection shrink toward the center — perspective, live.

Pinhole projection (side view)

Side view in the Y–Z plane. The dot at left is the pinhole OC; the vertical bar at distance f is the image plane. Drag the orange point to move it in (Y, Z). The dashed ray through the pinhole shows where it projects. Slide f to "zoom".

f 200 y = f·Y/Z
Common misconception. "Focal length is how 'zoomed in' the lens looks, so bigger f means I can see more." Backwards. A larger f spreads the same scene over more pixels — a narrower field of view, more zoomed-in, less of the world visible. Telephoto lenses have long focal lengths; wide-angle lenses have short ones. In the equation, f is just the multiplier that converts a direction ratio into an image coordinate.
A point at (X, Y, Z) = (3, 0, 6) is imaged by a pinhole camera with f = 400. What is its image x-coordinate?

Chapter 2: From Metric to Pixels — The Intrinsic Matrix K

The pinhole equations give image coordinates (x, y) measured in physical units (millimetres, say) from the optical axis. But a camera does not report millimetres — it reports pixel coordinates (u, v), counted from the top-left corner of the image. Two adjustments bridge the gap.

Adjustment 1: shift the origin (principal point)

The optical axis pierces the image plane at a special pixel called the principal point (cx, cy) — often written (u0, v0). It is roughly, but not exactly, the center of the image. Because pixels are counted from the corner, we shift the metric coordinate by the principal point:

u-ish = (metric x) + cx,    v-ish = (metric y) + cy

Adjustment 2: scale to pixels (focal length in pixels)

The image sensor has some number of pixels per millimetre, and it can differ between the horizontal and vertical directions (non-square pixels). Call those densities kx and ky (pixels per unit length). Folding the pixel density into the focal length gives the focal lengths in pixels:

fx = kx · f,    fy = ky · f

(Stanford writes these as α = kxf and β = kyf; same thing.) Now we have the full pixel map directly from the camera-frame point:

u = fx · XC / ZC + cx
v = fy · YC / ZC + cy

Adjustment 3 (rarely needed): skew

If the sensor's pixel axes are not perfectly perpendicular, a tiny skew term γ couples u to Y. For almost all real cameras γ ≈ 0 and you can ignore it — but it is part of the general model, so we carry it along once.

Packaging it: the intrinsic matrix K

All five numbers — fx, fy, cx, cy, γ — live in one 3×3 upper-triangular matrix, the intrinsic matrix (or camera matrix) K:

K =  [  fx   γ   cx  ]
      [  0    fy   cy  ]
      [  0    0    1   ]

These are called intrinsic parameters because they describe the camera itself — its lens and sensor — independent of where the camera is or what it is looking at. A camera carries its K everywhere; that is exactly why we can measure K once (calibration, Chapter 7) and reuse it.

Reading K like a sentence. Top row: "scale X by fx, then shift right by cx." Middle row: "scale Y by fy, then shift down by cy." Bottom row: a structural "1" that will carry the depth in homogeneous coordinates (next chapter). Every entry has a job; none is decoration.

Worked example: project (2, 1, 5) all the way to a pixel

Reuse the point PC = (2, 1, 5). Take a realistic VGA-ish camera: fx = fy = 500, principal point (cx, cy) = (320, 240) (the middle of a 640×480 image), and skew γ = 0. Step by step:

StepComputationResult
X/Z ratio2 / 50.40
Y/Z ratio1 / 50.20
scale u500 × 0.40200
scale v500 × 0.20100
shift u200 + 320u = 520
shift v100 + 240v = 340

The point lands at pixel (520, 340). Notice it is right-and-down of center (320, 240), which matches the point being to the right (X>0) and below (Y>0) the optical axis. Sanity-checking the direction of the shift catches sign-convention bugs instantly.

From scratch, then the matrix form

python
import numpy as np

def project_pixels_scalar(P_c, fx, fy, cx, cy, gamma=0.0):
    Xc, Yc, Zc = P_c
    u = fx * Xc / Zc + gamma * Yc / Zc + cx
    v = fy * Yc / Zc + cy
    return (u, v)

print(project_pixels_scalar((2, 1, 5), 500, 500, 320, 240))  # (520.0, 340.0)

# Same thing with the matrix K (homogeneous; see Ch.3 for why this works)
K = np.array([[500, 0, 320],
              [0, 500, 240],
              [0, 0,   1]])
P_c = np.array([2, 1, 5])
uvw = K @ P_c              # [520*?, ...]; this is (Zc*u, Zc*v, Zc)
u, v = uvw[0]/uvw[2], uvw[1]/uvw[2]   # divide by the last entry
print(u, v)              # 520.0 340.0

The matrix form does the same arithmetic but hides the divide-by-Z inside "divide by the last entry of K @ P_c." That trick — treat the depth as a scale factor and divide it out at the end — is the homogeneous-coordinate magic we unpack next.

Play with K below. Drag the principal point and watch the whole projected image shift; change fx and fy independently to stretch the image. The little grid of world points re-projects live.

The intrinsic matrix K, live

A fixed 3×3 grid of world points is projected into a 640×480 image. Adjust fx, fy, and drag the principal point (the crosshair). Watch how each knob warps the pixel layout. The yellow dot is our worked point (2,1,5).

fx 500 fy 500
drag the crosshair to move (cx, cy)
Common misconception. "The principal point is always the exact center of the image." Usually close, but not exactly — manufacturing tolerances offset the sensor from the lens axis by a few pixels. Calibration recovers the true (cx, cy); assuming dead-center introduces a small but real systematic error in every back-projected ray.
A camera has K with fx=fy=400 and principal point (cx, cy)=(300, 200). A point projects to metric image coords (X/Z, Y/Z) = (0.5, −0.25). What pixel (u, v)?

Chapter 3: Homogeneous Coordinates — Making Projection Linear

Here is a problem. The projection u = fxX/Z + cx has a division in it. Matrix multiplication only adds and multiplies — it cannot divide one coordinate by another. So projection, as written, is not a linear map, and we cannot fold a whole projection-plus-pose pipeline into a single matrix. That is a real obstacle, because we want to chain transformations cleanly. The fix is a change of coordinates so old it predates cameras: homogeneous coordinates.

The trick: add a coordinate, allow free scaling

To make a 2D point homogeneous, append a 1: (x, y) → (x, y, 1). A 3D point gains a 1 too: (X, Y, Z) → (X, Y, Z, 1). The defining rule: a homogeneous vector represents the same point after multiplying by any nonzero scalar α:

(x, y, 1) ≡ (αx, αy, α)    for any α ≠ 0

To read a homogeneous vector back to ordinary (inhomogeneous) coordinates, divide by the last entry:

(a, b, w) → (a/w, b/w)

And there is the division we needed — pushed to the very last step. The idea: do all the linear work with matrices, carry a scale factor along in the extra coordinate, and divide it out only at the end.

Projection becomes one clean matrix multiply

Watch how the pinhole-plus-K map becomes purely linear. Multiply the camera matrix by the homogeneous 3D point (with the bottom-right "1" of K supplying the depth):

[ fx  0   cx ] [XC]   [ fxXC + cxZC ]
[ 0   fy  cy ] [YC] = [ fyYC + cyZC ]
[ 0   0   1  ] [ZC]   [ ZC ]

Call the result (a, b, w). Divide by the last entry w = ZC:

u = a / w = (fxXC + cxZC) / ZC = fxXC/ZC + cx
v = b / w = fyYC/ZC + cy

Exactly the pixel equations from Chapter 2. The matrix multiply did the linear part; the divide-by-last-entry did the perspective part. The depth ZC appears as the homogeneous scale factor and divides itself out — which is precisely why the camera can't recover depth: that scale factor is exactly what homogeneous coordinates throw away.

The deep reason a pixel is a ray, not a point. Because (a, b, w) ≡ (αa, αb, αw), scaling the 3D point by α (moving it along its ray, deeper or nearer) multiplies the homogeneous image vector by α too — and the divide cancels it. Every point on the ray gives the same pixel. A pixel does not name a 3D point; it names a direction. Recovering the lost scale α is the depth problem.

See the equivalence directly below: one pixel, the whole ray of 3D points behind it, all stamped from the same homogeneous direction.

One pixel ≡ one ray of homogeneous points

Click anywhere in the image (right) to pick a pixel. The side view (left) draws the entire ray of 3D points that project to it. Drag the slider to slide a sample point along the ray — its homogeneous vector scales, the pixel never moves.

depth along ray 6.0 click the image to pick a pixel

A subtle but crucial benefit: in homogeneous coordinates, rigid motions (rotation + translation) are also just matrix multiplies (Chapter 4). So the full chain — move the world into the camera frame, then project — collapses into one matrix product. That is the payoff we cash in Chapter 5.

Common misconception. "The extra '1' in homogeneous coordinates is meaningless padding." It is the opposite of meaningless — it is the slot that holds the perspective scale factor. After a projection the last entry is the depth ZC; dividing by it is the perspective divide. Drop that coordinate and you lose the only thing that makes projection work.
A projection produces the homogeneous image vector (1040, 680, 2). What pixel (u, v) does it represent?

Chapter 4: Extrinsics — Where the Camera Stands [R | t]

So far the point was given in the camera frame — coordinates relative to the camera itself. But the world doesn't revolve around the camera. A robot's map, its waypoints, the checkerboard on the wall — all of these live in a fixed world frame. Before we can project a world point, we must first express it in the camera frame. That requires knowing where the camera is: its pose.

Recall from the kinematics lessons that a rigid-body pose in 3D — an element of SE(3) — is exactly a rotation plus a translation. For the camera we need the transform that takes a point's world coordinates PW and returns its camera coordinates PC.

Deriving the world→camera transform

Look at the geometry. The camera-frame coordinate of the point is the vector from the camera center to the point, written in the camera's axes. By chasing the triangle (Stanford's Figure 8.3) you get:

PC = R · PW + t

Define every symbol: R is a 3×3 rotation matrix that re-expresses world directions in camera axes (its rows are the world axes seen from the camera, its columns the camera axes seen from the world). t is a 3-vector, the position of the world origin expressed in camera coordinates — it slides the rotated point so the arithmetic comes out in the camera frame. Together [R | t] are the camera's extrinsic parameters: where it is and which way it points.

Intrinsic vs. extrinsic, the clean split. Intrinsic (K) = the camera's internals: lens and sensor. Travel with the camera; never change as it moves. Extrinsic ([R|t]) = the camera's pose in the world: change every time the camera moves. Same lens on a tripod that you spin around a table: K fixed, [R|t] changing each shot. Calibration recovers both, but only K is reusable across scenes.

Homogeneous form: one 4×4 matrix

Just like before, the affine map RPW + t — a multiply plus an add — becomes a single matrix multiply in homogeneous coordinates. Append a 1 to the world point and stack R and t:

[PC]   [ R    t ] [PW]
[ 1 ] = [ 0  0  0  1 ] [ 1 ]

The bottom row [0 0 0 1] just copies the 1 through, keeping the point homogeneous. For projection we only need the top three rows, the 3×4 block [R | t].

Worked example: move a world point into the camera frame

Suppose the camera is rotated 90° about the world Y axis (it has turned to look along the world's +X direction) and sits 4 units back along its own view. A clean 90°-about-Y rotation matrix is:

R =  [  0  0  1 ]
      [  0  1  0 ]
      [ −1  0  0 ]

and take t = (0, 0, 4). Take the world point PW = (3, 1, 0). Compute RPW row by row:

Row of RDot with PW=(3,1,0)Value
[0, 0, 1]0·3 + 0·1 + 1·00
[0, 1, 0]0·3 + 1·1 + 0·01
[−1, 0, 0]−1·3 + 0·1 + 0·0−3

So RPW = (0, 1, −3). Add t = (0, 0, 4):

PC = (0, 1, −3) + (0, 0, 4) = (0, 1, 1)

The point now sits at depth ZC = 1 in front of the camera, dead center horizontally (XC=0), one unit down (YC=1). Positive depth means it is visible. The rotation turned the world's +X direction into the camera's view direction, exactly as intended.

python
import numpy as np

R = np.array([[ 0, 0, 1],
              [ 0, 1, 0],
              [-1, 0, 0]])
t = np.array([0, 0, 4])
P_w = np.array([3, 1, 0])

P_c = R @ P_w + t          # world -> camera frame
print(P_c)                 # [0 1 1]  -> depth Zc=1, in front of camera

# homogeneous 4x4 form does the same in one multiply
T = np.eye(4); T[:3, :3] = R; T[:3, 3] = t
P_w_h = np.array([3, 1, 0, 1])
print((T @ P_w_h)[:3])     # [0 1 1]
Common misconception. "t is the camera's position in the world." Almost — but not quite. t is the world origin expressed in camera coordinates, which is not the same as the camera's location in world coordinates. The camera center in world coordinates is C = −Rt. Mixing these up flips signs and is one of the most common camera-pose bugs in practice.
The extrinsic parameters [R | t] of a camera describe what?

Chapter 5: The Full Projection — P = K[R | t]

We now have both halves. Chapter 4 maps a world point into the camera frame; Chapters 2–3 map a camera-frame point to a pixel. In homogeneous coordinates each half is a matrix, so the whole pipeline is one matrix product. Chain them:

ph = K [R | t] PWh

where PWh = (XW, YW, ZW, 1) is the world point in homogeneous coordinates and ph = (a, b, w) is the homogeneous pixel, recovered as (u, v) = (a/w, b/w). The product

P = K [R | t]   (a 3×4 matrix)

is the camera projection matrix (also called the homography in the calibration literature, written M). It maps any homogeneous world point straight to a homogeneous pixel. Read left to right: K (intrinsics) times [R|t] (extrinsics). Intrinsics on the left, extrinsics on the right — memorize the order; swapping them is meaningless.

Degrees of freedom. K has 5 (fx, fy, cx, cy, γ), R has 3 (a 3D rotation), and t has 3. Total: 11 (or 10 if you assume γ=0). That number matters in Chapter 7 — it sets the minimum amount of data calibration needs to pin the camera down.

End-to-end worked example: world point → pixel

Use the camera from Chapter 4: R = 90° about Y, t = (0, 0, 4), and intrinsics fx=fy=500, (cx, cy)=(320, 240). Project the world point PW = (6, 1, 0).

Step 1 — world to camera. Compute RPW: rows of R dotted with (6,1,0) give (0·6+0·1+1·0, 0·6+1·1+0·0, −1·6+0+0) = (0, 1, −6). Add t: PC = (0, 1, −6) + (0, 0, 4) = (0, 1, −2).

Negative depth — the point is behind the camera! It would not be imaged. This is a real, useful failure: the camera is looking along world +X, and a point at world X=6 in front of a camera backed off to X-view by only 4 units ends up behind it. Let's pick a visible point instead.

Take PW = (−1, 1, 0). Then RPW = (0, 1, 1), add t: PC = (0, 1, 5). Depth ZC = 5 > 0, visible.

Step 2 — camera to pixel. This is exactly the Chapter 2 worked example with PC=(0,1,5):

u = 500 · 0/5 + 320 = 320
v = 500 · 1/5 + 240 = 100 + 240 = 340

The world point (−1, 1, 0) images at pixel (320, 340) — horizontally centered, below middle. The full P = K[R|t] did this in one multiply; we just unrolled it to see every step.

python
import numpy as np

K  = np.array([[500, 0, 320], [0, 500, 240], [0, 0, 1]])
R  = np.array([[0, 0, 1], [0, 1, 0], [-1, 0, 0]])
t  = np.array([[0], [0], [4]])
Rt = np.hstack([R, t])                  # 3x4 extrinsic block [R | t]
P  = K @ Rt                             # 3x4 projection matrix

Pw_h = np.array([-1, 1, 0, 1])         # homogeneous world point
ph   = P @ Pw_h                         # homogeneous pixel (a, b, w)
u, v = ph[0]/ph[2], ph[1]/ph[2]
print(round(u,1), round(v,1))      # 320.0 340.0

Now drive the whole thing. Below, a small 3D scene (a wireframe cube) is projected to the image. Orbit the camera around it, slide the focal length, and watch the picture re-render — the camera P updating live, every cube vertex pushed through P = K[R|t].

3D scene → image, full pipeline

Left: top-down map of the world with the cube and the camera (drag the camera to orbit it around the cube). Right: the image the camera sees, rendered by projecting every vertex through P=K[R|t]. Slide f to zoom. Vertices behind the camera are clipped.

f 500 cam dist 7.0
Common misconception. "P = [R|t]K — the order doesn't matter for matrices that big." Order always matters for matrix products, and here it is physically forced: you must move the point into the camera frame ([R|t]) before you image it (K). K[R|t] is right; [R|t]K is dimensionally nonsense (you cannot multiply a 3×4 by a 3×3 on that side).
How many degrees of freedom does the full projection matrix P = K[R|t] have (counting skew in K)?

Chapter 6: Lens Distortion — When Straight Lines Bend

The pinhole model assumes light travels in perfectly straight lines through a single point. Real cameras use lenses, and lenses bend light — especially near the edges. The result is radial distortion: straight lines in the world image as curves, more strongly the farther they are from the image center.

Two flavors. In barrel distortion, the image magnification decreases with distance from center, so a square bulges outward like the side of a barrel — classic in wide-angle and fisheye lenses. In pincushion distortion, magnification increases with distance, so the square's edges pinch inward — common in telephoto lenses. Both are radial: they push pixels toward or away from the distortion center along the radius.

Why it matters for a robot. Distortion can move a pixel by tens of pixels near the corners of a wide lens. If you back-project a corner pixel to a ray (Chapter 8) without undistorting first, the ray points in the wrong direction — and any 3D estimate built on it inherits the error. Undistortion is not optional polish; it is correctness.

The distortion model

Model distortion as a radial scaling about the distortion center (ucd, vcd), which is usually the principal point. Let r² = (u − ucd)² + (v − vcd be the squared distance of an ideal pixel from that center. Stanford's one-parameter model maps the ideal pixel (u, v) to the distorted pixel (ud, vd) the camera actually records:

ud = (1 + k·r²)(u − ucd) + ucd
vd = (1 + k·r²)(v − vcd) + vcd

Here k is the radial distortion factor (sometimes split into k1, k2 for more accuracy with higher even powers of r). Read it: take the offset from center (u − ucd), scale it by (1 + k r²), add the center back. When k > 0 the scale grows with radius → pixels pushed outward → pincushion. When k < 0 → pixels pulled inward → barrel. At the center r=0 nothing moves; distortion is purely an edge effect.

Worked example with numbers

Distortion center (320, 240), factor k = 1.0×10−6 per pixel² (a small barrel-ish positive value for illustration). An ideal pixel sits near a corner at (620, 440). Offsets from center: Δu = 300, Δv = 200. Then:

r² = 300² + 200² = 90000 + 40000 = 130000
scale = 1 + (1.0×10−6)(130000) = 1 + 0.13 = 1.13
ud = 1.13 · 300 + 320 = 339 + 320 = 659
vd = 1.13 · 200 + 240 = 226 + 240 = 466

The corner pixel moves from (620, 440) to (659, 466) — pushed outward by about 47 pixels. Now check the center: a pixel at (330, 250) has offsets (10, 10), r²=200, scale 1+0.0002 — it barely moves (by ~0.002 px). Distortion is dramatic at the edges, invisible at the center. That radial signature is exactly what calibration detects.

Undistortion

Undistortion is the inverse: given a measured (distorted) pixel, recover the ideal pinhole pixel so the clean model applies. Because the forward map is a polynomial in r, the inverse has no simple closed form — it is solved numerically (iterate, or use a precomputed lookup). Once you know k (from calibration), every incoming frame can be undistorted to a clean pinhole image, and all the linear machinery of Chapters 1–5 holds exactly.

python
import numpy as np

def distort(u, v, k, ucd=320, vcd=240):
    """Map an ideal pinhole pixel to the distorted pixel the lens records."""
    du, dv = u - ucd, v - vcd
    r2 = du*du + dv*dv
    s  = 1.0 + k * r2          # radial scale; grows with distance from center
    return s*du + ucd, s*dv + vcd

print(distort(620, 440, k=1e-6))   # (659.0, 466.0)  -- corner pushed out
print(distort(330, 250, k=1e-6))   # (330.002, 250.002) -- center barely moves

See it on a grid. A regular grid of straight lines (a checkerboard) is the world; slide k and watch it bow into a barrel or pinch into a pincushion. This is exactly the pattern calibration software fits to recover k.

Radial distortion of a grid

A straight grid distorted radially about the center. Drag k negative for barrel (lines bulge out), positive for pincushion (lines pinch in). Notice the center stays put and the corners move most — distortion is a radial, edge-dominated effect.

k −0.60
barrel distortion
Common misconception. "Distortion shifts every pixel by the same amount, so it's like re-centering the image." No — the shift scales with r², so center pixels barely move while corner pixels move a lot. A constant shift would just re-define the principal point; radial distortion genuinely bends straight lines into curves, which a linear K can never undo. That is why it needs its own parameter.
Under the radial model ud = (1 + k·r²)(u − ucd) + ucd with k < 0, what happens to a pixel far from the center?

Chapter 7: Camera Calibration — Recovering K, R, t

Every projection so far assumed we knew K, R, t. But a fresh camera comes with no reliable numbers (manufacturer specs are approximate, and they say nothing about the principal point or distortion). Camera calibration measures these parameters from data: a set of known 3D world points whose pixel positions we can observe. The classic tool is a checkerboard — its corners have known world coordinates (the squares are 20.5 mm in Stanford's HW3) and OpenCV finds their pixels automatically.

The strategy: known correspondences → solve for P

Given n correspondences pi ↔ PW,i (pixel ↔ world point), each obeys pih = M PW,ih with the unknown 3×4 projection matrix M = K[R|t]. Write M as three rows m1, m2, m3. The pixel equations are:

ui = (m1 · PW,ih) / (m3 · PW,ih),    vi = (m2 · PW,ih) / (m3 · PW,ih)

Clear the denominators to make them linear in the unknown entries of M (this is the Direct Linear Transformation, DLT):

ui (m3 · PW,ih) − (m1 · PW,ih) = 0
vi (m3 · PW,ih) − (m2 · PW,ih) = 0

Each correspondence gives 2 equations; M has 12 entries but only 11 independent ones (overall scale is free, just like a homogeneous vector). So we need at least 2n ≥ 11, i.e. n ≥ 6 correspondences. Stack all the equations into one big matrix (size 2n×12) acting on the stacked entries m:

P̃ m = 0

The catch, and the fix: constrained least squares

With noise, no exact solution exists, and the trivial answer m = 0 always satisfies P̃m=0 but is useless. So we constrain the scale and minimize the residual — a constrained least-squares problem:

minimize ‖P̃ m‖²   subject to   ‖m‖ = 1

The solution is a gem of linear algebra: m is the eigenvector of P̃P̃ with the smallest eigenvalue — equivalently, the last column of V in the SVD P̃ = UΣV (the right-singular vector for the smallest singular value). The smallest-singular-vector direction is the one P̃ stretches least, so it comes closest to landing on zero.

Worked mini least-squares (with numbers)

The full DLT needs a computer, but the heart — "smallest-singular-vector solves min ‖Ax‖ s.t. ‖x‖=1" — fits on one screen. Take a tiny A:

A =  [ 3  0 ]
      [ 0  1 ]
      [ 0  1 ]

We want the unit vector x that makes ‖Ax‖ as small as possible. Form AA:

AA =  [ 9  0 ]
        [ 0  2 ]

This is diagonal, so its eigenvalues are simply 9 and 2, with eigenvectors (1,0) and (0,1). The smallest eigenvalue is 2, eigenvector x = (0, 1). Check it: Ax = (0, 1, 1), so ‖Ax‖² = 0+1+1 = 2. Try the other unit vector x=(1,0): Ax=(3,0,0), ‖Ax‖²=9. Indeed the smallest-eigenvalue eigenvector gives the smaller residual (2 vs 9). That is the exact mechanism the DLT uses on the giant 2n×12 matrix — same idea, bigger matrix.

python
import numpy as np

A = np.array([[3., 0.], [0., 1.], [0., 1.]])
# constrained least squares: min ||A x|| s.t. ||x||=1  ->  smallest right-singular vector
U, S, Vt = np.linalg.svd(A)
x = Vt[-1]                         # last row of V^T = smallest-singular-value direction
print(np.round(x, 3))            # [0. 1.]  (sign may flip)
print(round(np.linalg.norm(A @ x)**2, 3))   # 2.0  (the minimum residual)

Step 2: factor M back into K, R, t

Once m (hence M) is found, recover the parameters. The left 3×3 block of M equals KR, the product of an upper-triangular K and an orthonormal R — exactly an RQ factorization, which splits any matrix into (upper-triangular)×(orthogonal). That hands you K and R directly; then t = K−1c4 from M's fourth column.

Zhang's flexible method (the practical one). Pointing a camera at a flat checkerboard from several angles is far easier than building a precise 3D rig. Zhang (2000) exploits the planar trick: put the board on ZW=0, so each image gives a planar homography, and the orthonormality of R's columns yields linear constraints on B = K−⊤K−1. Because K is the same across all images while [R|t] differs per image, stacking ≥3 views pins K down. This is exactly Problem 1 of Stanford's HW3 — and exactly what OpenCV's calibrateCamera does under the hood.

A sanity check Stanford asks you to verify: the recovered skew γ should be tiny (|γ| « α) and the principal point (u0, v0) should land near the image center. If they don't, you have a correspondence-ordering bug — the single most common calibration mistake.

Below, run a toy calibration. The "true" camera projects a checkerboard's corners to pixels (with a little noise); your DLT-style solver recovers a projection matrix from those correspondences and re-projects. Watch the recovered crosses converge onto the measured dots as you add correspondences.

Toy calibration: correspondences → projection

Dots = measured checkerboard corner pixels (with noise). Crosses = where the recovered camera re-projects those corners. Add correspondences with the slider; with too few, the fit is loose; with enough, the crosses snap onto the dots. The reprojection-error readout is the calibration quality metric.

correspondences 20 noise 1.0
reprojection error: –
Common misconception. "More correspondences just slows things down; 6 is enough." Six is the bare minimum for a unique solution with perfect data. Real corner detections are noisy, so you want an overdetermined system — dozens of corners across many board poses — and let least squares average out the noise. Calibrating from the minimum number of points is fragile and inaccurate.
In the DLT, why is the camera matrix recovered as the smallest-singular-value vector of P̃, subject to ‖m‖=1?

Chapter 8: Back-Projection — From Pixel to Ray

Calibration done, we know K, R, t exactly. Surely now we can take a pixel and recover the 3D point that made it? No — and understanding precisely why is the whole point of this chapter. The projection matrix M is 3×4: it maps a 4D homogeneous world point to a 3D homogeneous pixel. It is not square, so it has no inverse. The information lost in the perspective divide (Chapter 3) cannot be conjured back from a single pixel.

What we can recover is the ray: the line through the camera center along which the 3D point must lie. Start from a pixel (u, v), lift it to homogeneous (u, v, 1), and undo the intrinsics with K−1:

dC = K−1 (u, v, 1)

dC is a direction in the camera frame — the ray's heading. Every visible 3D camera-frame point on that ray has the form PC = λ dC for some unknown positive depth scale λ > 0. The pixel fixes the direction; λ — the depth — is exactly the homogeneous scale that projection threw away.

Worked example: back-project a pixel

Use K with fx=fy=500, (cx, cy)=(320, 240). Its inverse for a no-skew K is clean:

K−1 =  [ 1/500   0      −320/500 ]
         [ 0      1/500  −240/500 ]
         [ 0      0      1 ]

Back-project the pixel (520, 340) — the very pixel our Chapter 2 point landed on. Apply K−1(520, 340, 1) row by row:

ComponentComputationValue
dx520/500 − 320/500 = (520−320)/5000.40
dy340/500 − 240/500 = (340−240)/5000.20
dz11.00

So the ray direction is dC = (0.40, 0.20, 1). Now sweep the depth: at λ=5, PC = (2, 1, 5)exactly the original point. At λ=10, (4, 2, 10); at λ=2.5, (1, 0.5, 2.5). All on the same ray, all project back to (520, 340). The pixel told us the direction perfectly and the depth not at all.

python
import numpy as np

K = np.array([[500, 0, 320], [0, 500, 240], [0, 0, 1]])
Kinv = np.linalg.inv(K)

px = np.array([520, 340, 1])
d  = Kinv @ px                 # ray direction in camera frame
print(np.round(d, 3))        # [0.4 0.2 1. ]

for lam in [2.5, 5.0, 10.0]:    # sweep unknown depth lambda
    print(lam, np.round(lam * d, 2))   # all project back to (520,340)

How do you get the depth back? A second look.

Since one camera fundamentally can't, you add information. Stanford lists the options:

Triangulation, in one breath. Two pixels (one per camera) give two rays. In a noiseless world they cross at the 3D point. With noise they nearly cross, so you find the point Q minimizing the total reprojection error — the squared pixel distance between where Q projects and where the corners were actually seen. Same least-squares spirit as calibration, now solving for the 3D point instead of the camera.

Below, see depth ambiguity resolved by a second camera. Move the world point; with one camera you only know its ray; turn on the second camera and the two rays intersect to pin the point in 3D.

One ray vs. two: depth by triangulation

Top-down view. Drag the world point. Camera A's ray (blue) alone leaves the depth unknown — the point could be anywhere along it. Toggle Camera B (teal); the second ray intersects the first, and the depth is recovered. This is the bridge to Lesson 9.

one camera: depth unknown (ray only)
Common misconception. "If the camera is calibrated well enough, I can invert the projection and get 3D from one image." Calibration accuracy is irrelevant here — the obstruction is structural: M is 3×4 and non-invertible, and the perspective divide discards the depth scale λ. No amount of calibration recovers a number that was never recorded. You need a second view, motion, or a prior.
With a perfectly calibrated single camera, back-projecting a pixel gives you…

Chapter 9: Showcase — The Interactive Pinhole Camera

Everything we built — pinhole projection, K, extrinsics, the full P=K[R|t], and distortion — lives in one sim. A 3D scene (a wireframe cube on a ground plane, plus a tracked point) sits in the world. A movable camera images it. Orbit the camera, slide the focal length, drag the principal point, and toggle lens distortion; the rendered image updates live, and a readout shows the full (X, Y, Z) → (u, v) computation for the highlighted point.

This is the whole lesson in one frame. Every vertex you see in the image went through: world → (apply [R|t]) → camera frame → (apply K, divide by Z) → ideal pixel → (apply distortion k) → the pixel drawn. The panel traces those exact steps for the yellow point so you can read the geometry, not just see it.
Interactive pinhole camera — full pipeline

Left: top-down world map (cube + ground grid + camera). Drag the camera to orbit it around the scene. Right: the live image. Sliders set focal length f and orbit distance; drag the crosshair on the image to move the principal point; toggle distortion. The readout traces (X,Y,Z)→(u,v) for the yellow tracked vertex.

f 520 orbit 35° dist 8.0
tracked point readout

Things to try. Increase f: the scene zooms in and fewer vertices fit — a narrower field of view, exactly as Chapter 1 promised. Drag the principal point: the whole image slides, with no change to the 3D scene — intrinsics are independent of the world. Orbit the camera behind the cube: vertices cross to negative depth and get clipped (a point behind the camera is not imaged). Turn on distortion: the straight cube edges bow, more at the image corners.

Read the panel as you go. For the tracked yellow vertex it prints its world coordinates, its camera-frame coordinates after [R|t], the metric ratios X/Z and Y/Z, and the final pixel after K (and distortion). When a vertex's camera-frame Z goes negative, the panel says "behind camera — clipped." That is the entire forward model, narrated.

You just operated a camera from first principles. No black box: you set the pose, you set the lens, and you watched a 3D world become pixels through P=K[R|t] plus a distortion you can dial in or out. This is the exact computation a robot does for every feature it tracks — and the inverse of it (pixel→ray) is what Lesson 9 builds on to recover structure.

Chapter 10: Connections & Cheat-Sheet

You now own the geometry that turns a 3D world into pixels and reasons back to rays. Here it is, compressed.

Cheat-sheet

Pinhole: x = f·XC/ZC, y = f·YC/ZC. Divide-by-Z is perspective; it destroys depth.

Pixels: u = fxXC/ZC + cx, v = fyYC/ZC + cy. Packaged in K (3×3, upper-triangular, 5 DOF).

Homogeneous: append a 1; read back by dividing by the last entry. Makes projection a single matrix multiply; a pixel is a ray, not a point.

Extrinsics: PC = R·PW + t. [R|t] = camera pose (6 DOF). Camera center in world = −Rt.

Full projection: ph = K[R|t]PWh. Matrix P = K[R|t], 3×4, 11 DOF.

Distortion: ud = (1+k·r²)(u−ucd) + ucd. k<0 barrel, k>0 pincushion; radial, edge-dominated.

Calibration (DLT): ≥6 correspondences → P̃m=0 → m = smallest-singular-vector (SVD) → RQ-factor M into K, R, t. Zhang: planar checkerboard, ≥3 views, shared K.

Back-projection: dC = K−1(u,v,1); PC = λdC with depth λ unknown. Recover λ via stereo / SfM / focus / priors.

Where this sits in the series

The camera model is the geometry beneath the Perception module. It feeds directly into feature detection and structure-from-motion next door:

Related Engineermaxxing lessons

"What I cannot create, I do not understand." — Richard Feynman.
You can now build the forward camera model in a dozen lines and explain exactly why a single image cannot recover depth. Next, Lesson 9: matching features and using these rays to triangulate the 3D world.
A robotics engineer needs 3D positions of objects but has only one calibrated camera. Per this lesson, what is the correct conclusion?