LECTURE 6

Camera Calibration

From pixels to the real world: camera models, lens distortion, calibration & perspective transforms

Pinhole, wide-angle, fisheye, stereo & Raspberry Pi cameras · hands-on with OpenCV · Lab 6

Dr. Muhammad Kazim

Department of Intelligent Systems
University of Lahore

A distorted chessboard and the same image after calibration

Where we are

From lane pixels to real-world measurements

Lecture 5 · you can already

  • Select lane paint by colour and region
  • Find edges with Canny, lines with Hough
  • Draw two straight lane lines on video

…but everything is in pixels, and straight lines only.

Lecture 6 · today

  • How a camera turns 3-D points into pixels (pinhole model, K, [R | t])
  • Why lenses distort and the 5 numbers that fix it
  • Calibrate any camera with a chessboard (Zhang's method)
  • Fisheye, ChArUco, stereo & Raspberry Pi cameras
  • Perspective transform → bird's-eye view → lane curvature in metres
Driver view of a road
Road ahead
Car seen from above

To steer, a car must measure how much its lane curves, which means looking at the road from above. That warp is only correct after we remove lens distortion (Udacity).

Learning outcomes

By the end of this lecture and Lab 6 you should be able to:

  1. Write the pinhole projection s·x = K [R | t] X and explain every intrinsic and extrinsic parameter.
  2. Describe radial and tangential distortion, the OpenCV coefficients (k1, k2, p1, p2, k3), and when you need the fisheye or omnidirectional model instead.
  3. Compare camera types (standard, wide-angle, fisheye, 360°, stereo, RGB-D, rolling vs global shutter) and calibration targets (chessboard, circles, ChArUco).
  4. Calibrate a camera with OpenCV: corners → object/image points → calibrateCamera → reprojection error.
  5. Undistort images and video efficiently, and judge a calibration's quality.
  6. Calibrate a stereo pair and rectify it; calibrate your own Raspberry Pi / USB camera.
  7. Apply a perspective transform to get a bird's-eye view and measure lane curvature.

For the teacher and the student

How this lecture works: 8 levels, basics → advanced

LevelTopicTimeHands-on
0Pictures, pixels, lenses, coordinates10 minpixel explorer, phone camera
1Why calibrate?5 minquiz
2Pinhole model, K and [R | t]15 minpinhole demo, K calculator · Lab 6A
3Lens distortion10 mindistortion explorer · Lab 6A
4The calibration recipe in OpenCV20 mincount corners, coverage demo · 6B, 6C
5LIVE: calibrate the webcam20 minWebcam Calibration Studio · 6H
6Use it: undistort, bird's-eye, measure15 minwarp demo · 6D
7Advanced: camera types, fisheye, ChArUco, stereo, Pi, Zhangpick6E, 6G, 6I–6M

Teacher checklist

  • Print pattern.png at 100 %, glue on card, measure a square
  • Laptop webcam + Chrome/Edge; open the studio once before class
  • Press S for speaker notes (teaching tips on many slides)
  • Backup: offline webcam_calibration_studio.py or the studio's demo images

Students: every level ends with a quiz or "Remember" slide; 🖐 boxes are things to try yourself; ▶ buttons open the matching Colab notebook.

Roadmap: from the very basics to the advanced

0 · Basicspixels, lenses
→
1 · Why?distortion matters
→
2 · PinholeK and [R | t]
→
3 · Distortionk1, k2, p1, p2, k3
→
4 · Calibratethe recipe
→
5 · LIVEyour webcam
→
6 · Use itundistort, warp, measure
→
7 · Advancedfisheye, stereo, Pi…
Real-world scene, distorted image, corrected image

The whole lecture in one picture: real world → (lens) → distorted image → (calibration) → corrected image (Udacity).

The big picture first

Camera calibration in one minute

♟️

1 · Show a known shape

Hold a printed chessboard in front of the camera. We know exactly where every corner is on the paper.

📷

2 · See where it lands

Find the corners in the picture. Because of the lens and the viewpoint they are not where a perfect camera would put them.

🧮

3 · Solve for the numbers

Find the few numbers (focal length, image centre, lens distortion) that explain the difference. Do it once, use it forever.

Analogy: an eye doctor shows you a chart whose letters she knows, sees how you read it, and writes your prescription. The chessboard is the chart, the camera is the eye, and K + dist is the camera's prescription. Afterwards we "put the glasses on": every image is corrected, and pixels can be turned into millimetres.

Key words in plain English

WordMeansEveryday picture
Intrinsics, Knumbers inside the camera: focal length fx, fy and image centre cx, cy (in pixels)the camera's ID card
Extrinsics, [R | t]where the camera is (t) and where it looks (R)your GPS position + which way you face
Distortion, disthow much the lens bends lines near the edges: k1, k2, k3 (radial), p1, p2 (tangential)looking through a peephole or a glass of water
Object pointswhere the board corners really are (mm, Z = 0)the answer sheet
Image pointswhere the corners appear in the photo (pixels)the student's answers
Reprojection errordistance between where the model predicts a corner and where it was foundhow far a dart lands from the bullseye
Undistortre-draw the image as a perfect pinhole camera would have taken itputting the glasses on
Homography / perspective transforma 3 × 3 recipe that re-draws a flat thing as seen from another anglephotographing a poster straight-on

0

The very basics

Level 0 · pictures, pixels, lenses and three kinds of coordinates

Level 0 · step 1

A digital picture is a grid of numbers

Move the mouse over the picture (it is calibration_test.png, 1280 × 720 pixels).

pixel position (u, v) = (–, –)

brightness = – (0 = black, 255 = white)

  • u counts columns from the left, v counts rows from the top.
  • (0, 0) is the top-left corner. v grows downwards (not like maths class!).
  • OpenCV writes the size as (width, height); NumPy as img.shape = (height, width).

Level 0 · step 2

Pixels are not millimetres

phone 1 m away phone 2 m away 150 px tall 75 px tall same 150 mm phone

A camera only gives pixels. How many pixels an object covers depends on

  1. how big it is (mm),
  2. how far it is (distance), and
  3. the camera itself: its zoom (focal length) and lens.

Calibration measures item 3, the camera's own numbers. Then pixels can be turned into millimetres, angles and distances.

🖐 Try it: hold your phone at arm's length, then at half the distance. How much bigger does it look on the webcam? (About 2 ×: size ∝ 1 / distance.)

Level 0 · step 3

What does a lens do?

  • A lens bends light so that all rays from one point meet again at one point on the sensor, like a magnifying glass focusing sunlight to a spot.
  • It lets in much more light than a tiny hole, so pictures are bright and sharp.
  • But real lenses bend the rays near their edges a little too much or too little, so straight lines look curved, most of all near the corners of the picture.
  • Wide lenses (phones' "0.5 ×", GoPro, dash-cams, many webcams) bend the most.

🖐 Try it: open your phone camera, switch to the ultra-wide (0.5 ×) lens and point it at a door frame. Put the frame near the edge of the picture: does it bend?

A chessboard seen through a wide-angle lens: straight lines look curved

A flat chessboard through a wide-angle GoPro lens: the straight rows look curved.

Level 0 · step 4

A point lives in three "addresses"

① World ② Camera ③ Image "the corner is 3 squares right,2 down on the board" (X, Y, Z) in mm we choose where (0,0,0) is "the corner is 40 cm in frontof the lens, 5 cm to the right" (Xc, Yc, Zc) in mm origin = the camera's eye "the corner appears atcolumn 412, row 233" (u, v) in pixels origin = top-left pixel R, twhere the camera is K + distthe camera itself

In plain words: calibration finds the arrows. K + dist (the camera's own numbers) are found once; R, t change every time the camera or the object moves.

Level 0 check: three quick questions

Answer out loud, then click

  1. An image is 640 × 480. What are the (u, v) coordinates of its centre? Of the bottom-right pixel?
  2. A box covers 100 px at 2 m. Roughly how many pixels at 4 m, with the same camera?
  3. Which numbers never change for a camera (if you don't touch the lens or resolution): K and dist, or R and t?
1. Centre (320, 240); bottom-right (639, 479), because counting starts at 0.
2. About 50 px: twice as far → half as big.
3. K and dist (the camera itself). R and t describe where the camera is, so they change whenever it moves.

1

Why calibrate a camera?

Level 1 · Why do cameras need calibrating?

Image distortion: 3-D world → imperfect 2-D image

A car camera turns 3-D objects into a 2-D image

When a camera looks at 3-D objects and turns them into a 2-D image, the transformation is not perfect.

  • Distortion changes the apparent shape and size of objects
  • It is strongest near the edges of the image
  • A distorted lane gives the wrong curvature → the wrong steering angle

So the first step of any measuring vision system is to undo the distortion.

The same road through different lenses

Original and distorted road images
Distorted road images with lane edges bent

The lane edges are bent, rounded or stretched outward. Distortion changes what the shape and size of objects appear to be, and a self-driving car must place itself in the world accurately.

Is it visible on a real dash-cam? new

Raw dash-cam frame, undistorted frame and their difference

A frame from the Udacity Advanced Lane Lines camera before and after undistortion (we calibrate this camera in Lab 6D). The difference image shows the correction is largest near the image border: exactly where the lane lines and nearby cars are.

▶︎ Lab 6D in Colab

Quiz: why is it important to correct for distortion?

Quiz question · select all that apply

  1. Distortion can change the apparent size of an object in an image.
  2. Distortion can change the apparent shape of an object in an image.
  3. Distortion can cause an object's appearance to change depending on where it is in the field of view.
  4. Distortion can make objects appear closer or farther away than they actually are.
All four. Each one breaks measurements: lane width, curvature, object size, distance to the car ahead.

What calibration gives you

Intrinsics K

Focal lengths fx, fy and principal point cx, cy, in pixels. Fixed for a given camera, lens, focus and resolution.

Distortion

dist = [k1, k2, p1, p2, k3] (or the fisheye [k1..k4]). How the lens bends rays away from the ideal pinhole.

Extrinsics [R | t]

Where the camera is relative to the world (or to the board, or to a second camera). One pose per calibration image.

ApplicationNeeds
Lane curvature, bird's-eye view (SDC)K + distortion, then a perspective transform
Measuring object size in mm (inspection, Lab 9)K + distortion + the distance or plane
Stereo depth (Lecture 10), visual SLAM, 3-D reconstructionK, distortion and the relative pose of the cameras
AR overlays, robot pick-and-place, ArUco pose (solvePnP)K + distortion → pose of objects
Multi-sensor fusion (camera + LiDAR / IMU)intrinsics + extrinsic calibration between sensors

2

The pinhole camera model

Level 2 · How 3-D points become pixels

The pinhole camera

Pinhole camera model with object, pinhole and formed image
  • Light reflected from an object passes through a tiny hole and forms an image on the back plane (film / sensor).
  • The image is upside-down and mirrored: rays from the top of the object continue through the pinhole to the bottom of the image.
  • The pinhole camera is the earliest means of projecting images: the camera obscura was described in China around 2,500 years ago.

A pinhole has no lens, so it has no distortion; it just lets in very little light.

The camera and the human eye

Pinhole camera compared with the human eye

The eye works the same way: the pupil is the pinhole and the retina is the image plane, and the image on it is upside-down too. The brain flips it.

Quiz

What is the fundamental difference between images formed with a pinhole camera and those formed using lenses?

  • Pinhole images are always flipped, lens images upright
  • Pinhole images are black and white
  • Pinhole images are free from distortion; lenses tend to introduce distortion
  • They are exactly the same
Free from distortion. Lenses gather more light but bend rays at their edges.

From 3-D to 2-D: the camera matrix

3-D to 2-D projection
Camera matrix C maps P(X,Y,Z) to p(x,y)

A 3-D object point P(X, Y, Z) maps to a 2-D image point p(x, y) through a camera matrix (Udacity calls it C, so p ≈ C P). Calibration is the job of measuring that matrix plus the lens distortion.

Perspective projection by similar triangles

optical axis image plane C (pinhole) X x f Z (depth)

Similar triangles give, for a camera at the origin looking along Z:

\[ x = f\,\frac{X}{Z}, \qquad y = f\,\frac{Y}{Z} \]
  • Objects twice as far appear half as big: the 1/Z is perspective.
  • Depth is lost: every point on the ray through C lands on the same pixel. One camera cannot measure distance on its own.
  • We put a virtual image plane in front of the pinhole so the image is not flipped.

In plain words: a camera is a box with a hole. Far things make small pictures (divide by Z), and a single photo cannot tell how far away something is.

From millimetres to pixels: the intrinsic matrix K

\[ \begin{bmatrix} u \\ v \\ 1 \end{bmatrix} \simeq \underbrace{\begin{bmatrix} f_x & s & c_x \\ 0 & f_y & c_y \\ 0 & 0 & 1 \end{bmatrix}}_{K} \begin{bmatrix} X/Z \\ Y/Z \\ 1 \end{bmatrix} \]
  • fx, fy: focal length in pixels = f [mm] / pixel size [mm]. Equal for square pixels.
  • cx, cy: principal point, where the optical axis hits the sensor (≈ image centre, never exactly).
  • s: skew, 0 for every modern sensor (OpenCV assumes 0).

Worked example: Pi Camera v2

Sony IMX219: f = 3.04 mm, pixel = 1.12 µm, 3280 × 2464 px.

fx = 3.04 / 0.00112 ≈ 2714 px
cx ≈ 3280/2 = 1640, cy ≈ 1232

Horizontal FOV = 2·atan(1640 / 2714) ≈ 62° ✔ (datasheet: 62.2°)

Datasheet values are only a starting guess. Calibration measures the real ones.

In plain words: K turns "how far left/up the ray points" into a pixel position. f says how much the camera zooms; (cx, cy) is the pixel straight in front of the lens.

Try it: K from a datasheet interactive

Sensor data from the Raspberry Pi camera documentation. Lens values of webcams vary: treat the preset as an example.

Where is the camera? Extrinsics [R | t]

World points are usually not given in the camera's frame. A rotation R (3×3) and translation t (3×1) move them there:

\[ \mathbf{X}_{cam} = R\,\mathbf{X}_{world} + \mathbf{t} \]

Putting it all together, the full projection (Lecture 10 notation):

\[ s \begin{bmatrix} u \\ v \\ 1 \end{bmatrix} = K\,[R \mid \mathbf{t}] \begin{bmatrix} X \\ Y \\ Z \\ 1 \end{bmatrix} = P\,\tilde{\mathbf{X}} \]

P = K[R | t] is the 3×4 projection matrix: 4 intrinsic + 6 extrinsic numbers (3 rotation, 3 translation).

In OpenCV

  • R is stored as a 3-vector rvec (axis × angle, Rodrigues): R, _ = cv2.Rodrigues(rvec)
  • calibrateCamera returns one rvec, tvec per calibration image: the pose of the board
  • cv2.projectPoints(X, rvec, tvec, K, dist) applies the whole model, including distortion
  • cv2.solvePnP does the inverse: known K + 3-D/2-D matches → pose

In plain words: first move the world so the camera is at the origin looking along Z (R, t), then let K turn it into pixels.

Live demo: the pinhole camera interactive

A 1 m cube on a floor grid of 1 m squares; the camera looks at the cube. Change f: the image zooms (FOV changes). Move Z: size ∝ 1/Z. Add k1 < 0: straight edges bow outwards.

▶︎ Lab 6A in Colab

Quiz: project a point by hand

Quiz question

A camera has fx = fy = 800 px, cx = 640, cy = 360, no distortion, and R = I, t = 0. Where does the point X = (0.5, −0.25, 4) m land?

u = 800·0.5/4 + 640 = 740, v = 800·(−0.25)/4 + 360 = 310. Pixel (740, 310).

And a point twice as far, (1, −0.5, 8)?

The same pixel (740, 310): it lies on the same ray. Depth cannot be recovered from one image.

import numpy as np, cv2
K = np.array([[800, 0, 640],
              [0, 800, 360],
              [0,   0,   1]], float)
X = np.array([[0.5, -0.25, 4.0],
              [1.0, -0.50, 8.0]])
uv, _ = cv2.projectPoints(X, np.zeros(3), np.zeros(3),
                          K, None)
print(uv.reshape(-1, 2))   # [[740. 310.] [740. 310.]]
▶︎ Lab 6A in Colab

Image formation: the full chain in OpenCV

World point(X, Y, Z) in metres
→
ExtrinsicsXc = R X + t
→
Normalisex' = Xc/Zc, y' = Yc/Zc
→
Distortradial + tangential
(x'', y'')
→
Intrinsicsu = fxx'' + cx
v = fyy'' + cy
Distortion is applied to the normalised coordinates (units of focal length), not to pixels. That is why the same dist vector works at any image size, while K must be scaled when you resize.
Udacity's notes write r as the distance from the distortion centre (≈ image centre). In OpenCV it is r² = x'² + y'² in normalised coordinates.

Watch: Camera Intrinsics and Extrinsics: 5 Minutes with Cyrill (Cyrill Stachniss, Uni Bonn).

Remember

The pinhole model: the 3 things to remember

  1. A camera divides by depth: x = f·X/Z. Far things are small, and depth is lost.
  2. K (fx, fy, cx, cy) = the camera itself. [R | t] = where it stands and looks.
  3. In OpenCV: cv2.projectPoints(X, rvec, tvec, K, dist) does the whole chain.

3

Lens distortion

Level 3 · Real cameras use lenses, and lenses bend light

Why real cameras use lenses

Pinhole versus convex lens
  • A pinhole lets through one ray per scene point: a dark image or a very long exposure.
  • A convex lens focuses many rays at once: bright images and fast shutter speeds.
  • But rays bend a little too much or too little near the edges of a curved lens → distortion.

Radial distortion: the most common type

Radial distortion

Light rays bend more (or less) the further they are from the centre of the lens. Lines and objects appear more or less curved than they are.

  • Barrel (k1 < 0): lines bow outwards: wide-angle, GoPro, dash-cams
  • Pincushion (k1 > 0): lines bow inwards: telephoto, some zooms
  • Mustache: barrel in the centre, pincushion at the edge (k1, k2 opposite signs)

In plain words: the lens squeezes (barrel) or stretches (pincushion) the picture more and more the further you go from the centre.

Tangential distortion: a tilted lens

Tangential distortion

Occurs when the lens is not perfectly parallel to the imaging plane (sensor), for example from glue or assembly tolerances in cheap modules.

The image looks tilted: some objects appear farther away or closer than they actually are.

Usually much smaller than radial distortion, but cheap phone, Pi and webcam modules can show it.

Some lenses distort on purpose

Convex car mirror

A convex car mirror: wider view, objects look farther away

Fisheye photograph

Fisheye photography: 180° field of view, extreme barrel

Fine for style or a wide view, but for measurement (steering, sizing, depth) we need images that accurately reflect the real world.

Five numbers capture the distortion

Distortion coefficients = (k1 k2 p1 p2 k3)

Radial, with r² = x² + y² (normalised coordinates):

\[ \begin{aligned} x_{d} &= x\,(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) \\ y_{d} &= y\,(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) \end{aligned} \]

Tangential:

\[ \begin{aligned} x_{d} &= x + [\,2p_1 xy + p_2(r^2 + 2x^2)\,] \\ y_{d} &= y + [\,p_1(r^2 + 2y^2) + 2p_2 xy\,] \end{aligned} \]
A point in the distorted and corrected image
  • OpenCV order: [k1, k2, p1, p2, k3]. k3 comes last because it is only needed for strong (wide-angle) distortion.
  • Optional extras: rational k4–k6, thin-prism s1–s4, tilted sensor τx, τy, so up to 14 numbers.
  • The equations go ideal → distorted. Undistortion inverts them numerically.
▶︎ Lab 6A in Colab

Distortion with real numbers (you can do this by hand)

A point near the edge

Normalised point (x, y) = (0.8, 0.0), so r² = 0.64. Lens: k1 = −0.2, all others 0 (a mild barrel).

factor = 1 + k1·r² = 1 − 0.2 × 0.64 = 0.872

xd = 0.8 × 0.872 = 0.698: the point moves 13 % towards the centre.

With fx = 800 px that is (0.8 − 0.698) × 800 ≈ 82 pixels!

A point near the centre

(x, y) = (0.1, 0.0), so r² = 0.01.

factor = 1 − 0.2 × 0.01 = 0.998

xd = 0.0998: it moves only 0.0002, about 0.2 pixels.

That is why the centre of a photo looks fine and the edges bend: the effect grows with r².

Take-away: distortion is tiny in the middle and huge at the edges, so calibration photos must have the board near the edges and corners.

Live demo: lens distortion explorer interactive

▶︎ Lab 6A in Colab

Quiz: name that distortion

A wide-angle view of a chessboard with detected corners

Quiz question

This chessboard was shot with a GoPro (the camera we calibrate in Lab 6).

  1. What kind of distortion is it? What sign do you expect for k1?
  2. Where in the image is the distortion strongest?
  3. Would a pure pinhole camera show it?
A: barrel → k1 < 0 (Lab 6 measures k1 ≈ −0.23). B: at the edges and corners (large r). C: No, a pinhole has no lens.

Remember

Lens distortion: the 3 things to remember

  1. Real lenses bend lines, mostly radially: barrel (k1 < 0) or pincushion (k1 > 0).
  2. OpenCV describes it with 5 numbers: [k1, k2, p1, p2, k3].
  3. It is small in the centre and large at the edges (it grows with r²).

4

Calibrating a camera with OpenCV

Level 4 · The recipe: chessboard → corners → K and dist

Measuring distortion with a known shape

A flat chessboard and distorted chessboards
Several chessboard pictures at different angles

We know exactly what a flat chessboard looks like, and its regular high-contrast pattern is easy to detect automatically. Take many pictures of it at different angles and distances; the difference between the apparent and the real square shapes reveals the distortion.

Map distorted points → undistorted points

Distorted and undistorted chessboard
Matching corners between distorted and undistorted chessboards
  1. Detect the corners in each distorted image: the image points (2-D, pixels).
  2. Write down where they really are on the flat board: the object points (3-D, Z = 0).
  3. Calibrate: find K, dist and each pose that best maps object points to image points.
  4. Undistort any new image from that camera.

Quiz: how many corners? interactive

A chessboard to count corners on

Quiz question

Count the inside corners in a row (nx) and in a column (ny) of the right-hand board. Only points where two black and two white squares meet count, not the outside corners.

nx = 8, ny = 6. The board has 9 × 7 squares → 8 × 6 inner corners. This is the patternSize you pass to OpenCV.
▶︎ Lab 6B in Colab

Finding corners: findChessboardCorners

A GoPro chessboard image and the detected corners

gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)              # cv2.imread gives BGR (mpimg.imread gives RGB!)
ret, corners = cv2.findChessboardCorners(gray, (8, 6), None)
img = cv2.drawChessboardCorners(img, (8, 6), corners, ret)  # coloured rows; first corner = (0,0,0)
▶︎ Lab 6B in Colab

Object points: where the corners really are

Object point coordinates from (0,0,0) to (7,5,0)

Top-left corner is (0, 0, 0), bottom-right is (7, 5, 0), not (8, 6, 0): counting starts at zero. Z = 0 for every point because the board is flat.


nx, ny = 8, 6
square = 25.0            # mm, measured on YOUR print

# (48, 3) array: (0,0,0), (1,0,0) ... (7,5,0)
objp = np.zeros((nx * ny, 3), np.float32)
objp[:, :2] = np.mgrid[0:nx, 0:ny].T.reshape(-1, 2)
objp *= square           # now in millimetres

objpoints = []   # 3-D points in the board frame
imgpoints = []   # 2-D points in the image

Units of objp = units of tvec. In squares K and dist are still correct, but distances come out in squares. Multiply by the square size to get mm.

▶︎ Lab 6B in Colab

Refining to sub-pixel accuracy

A chessboard corner at pixel level, whole-pixel versus sub-pixel position

The saddle point between four squares is not on a pixel centre. cornerSubPix moves each corner to where the image gradients around it are orthogonal to the vector to the corner, typically to 0.1 px.


crit = (cv2.TERM_CRITERIA_EPS +
        cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-3)
corners = cv2.cornerSubPix(gray, corners,
                           (11, 11), (-1, -1), crit)

# OpenCV ≥ 4.3: sector-based detector, more
# accurate and robust, sub-pixel built in
ret, corners = cv2.findChessboardCornersSB(
    gray, (8, 6), cv2.CALIB_CB_EXHAUSTIVE +
                  cv2.CALIB_CB_ACCURACY)

Window (11, 11) means a 23 × 23 search area: keep it smaller than one square in the image.

▶︎ Lab 6B in Colab

Calibration, step 1: collect points from every image


import glob, cv2, numpy as np

nx, ny = 8, 6
objp = np.zeros((nx * ny, 3), np.float32)
objp[:, :2] = np.mgrid[0:nx, 0:ny].T.reshape(-1, 2)

objpoints, imgpoints = [], []
images = sorted(glob.glob("calibration_wide/GOPR*.jpg"))

for fname in images:
    img  = cv2.imread(fname)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    ret, corners = cv2.findChessboardCorners(gray, (nx, ny), None)
    if ret:                                   # skip images where the board is not found
        corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), crit)
        objpoints.append(objp)                # same for every image
        imgpoints.append(corners)

print(f"board found in {len(imgpoints)} / {len(images)} images")   # 35 / 36
All detected corners from the 35 calibration images cover the frame

All 1680 corners from the 35 GoPro images: they cover the whole frame, corners included. That is what makes a calibration reliable.

Calibration, step 2: cv2.calibrateCamera

cv2.calibrateCamera returns ret, mtx, dist, rvecs, tvecs

ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
    objpoints, imgpoints, gray.shape[::-1], None, None)

print("RMS reprojection error:", ret)  # 0.82 px
print(mtx)   # [[560.0   0.0  651.1]
             #  [  0.0 561.1  498.9]
             #  [  0.0   0.0    1.0]]
print(dist)  # [[-0.233  0.062  0.000  0.000 -0.008]]

Image size: (width, height)!

gray.shape is (960, 1280) = (rows, cols). OpenCV wants (1280, 960) = (w, h), hence gray.shape[::-1].

For a colour image use img.shape[1::-1]. Passing the full (960, 1280, 3) raises an error.

What comes back

  • ret: RMS reprojection error (px)
  • mtx: K (3 × 3); dist: (1 × 5)
  • rvecs, tvecs: one board pose per image
▶︎ Lab 6C in Colab

What happens inside: Zhang's method (2000)

  1. For each view, the flat board (Z = 0) maps to the image by a homography: \( \mathbf{x} \simeq H\,[X\;Y\;1]^\top \), with \( H = K\,[\mathbf{r}_1\;\mathbf{r}_2\;\mathbf{t}] \).
  2. Columns r1, r2 of a rotation are orthonormal, which gives 2 equations per view on \( B = K^{-\top}K^{-1} \):
    \( \mathbf{h}_1^\top B\,\mathbf{h}_2 = 0, \;\; \mathbf{h}_1^\top B\,\mathbf{h}_1 = \mathbf{h}_2^\top B\,\mathbf{h}_2 \)
  3. B has 5 unknowns (zero skew: 4) → ≥ 3 views (≥ 2 with zero skew) give a closed-form K; then each view's R, t.
  4. Non-linear Levenberg–Marquardt refinement of K, dist and all poses together, minimising the reprojection error.

Only a printed board, no expensive 3-D rig, so it is used everywhere: OpenCV, MATLAB, ROS.

Board poses recovered by calibration, in the camera frame

The 35 recovered board poses (rvecs, tvecs) seen from the camera (orange).

Watch: Camera Calibration using Zhang's Method and Intrinsic Camera Calibration (Cyrill Stachniss).

▶︎ Lab 6M in Colab

In plain words: every tilted photo of the flat board gives two clues about K. Three photos give enough clues to solve K; then a computer polishes all numbers together.

Is the calibration any good? Reprojection error

Per-view reprojection error bar chart and residual scatter
\[ E_{RMS} = \sqrt{\tfrac{1}{N}\sum_i \lVert \mathbf{x}_i - \hat{\mathbf{x}}_i(K, \text{dist}, R_j, \mathbf{t}_j)\rVert^2} \]

Project the object points with the estimated model and compare with the detected corners.

  • < 0.5 px: good; 0.5–1 px: acceptable; > 1 px: investigate
  • One view at 3.5 px (orange): blurred or mis-detected. Drop it and recalibrate (Lab 6C).
  • Low error ≠ correct: 3 nearly identical views can also fit well. Check coverage, too.
▶︎ Lab 6C in Colab

In plain words: put the board back into the picture using the model and measure how far the predicted corners are from the real ones. Small = the model explains the photos well.

Per-view error and saving the result


errs = []
for objp_i, imgp_i, r, t in zip(objpoints, imgpoints,
                                rvecs, tvecs):
    proj, _ = cv2.projectPoints(objp_i, r, t, mtx, dist)
    d = imgp_i.reshape(-1, 2) - proj.reshape(-1, 2)
    errs.append(np.sqrt((d ** 2).sum(1).mean()))

worst = np.argsort(errs)[::-1][:3]
print("worst views:", [images[i] for i in worst])

# Uncertainty of each intrinsic (std. dev.)
ret, mtx, dist, rv, tv, std_in, std_ex, per_view = \
    cv2.calibrateCameraExtended(objpoints, imgpoints,
                                size, None, None)
print("fx = %.1f ± %.1f px" % (mtx[0, 0], std_in[0, 0]))

# Save: portable YAML that C++, ROS and Python can read
fs = cv2.FileStorage("camera.yaml", cv2.FILE_STORAGE_WRITE)
fs.write("image_width", size[0]); fs.write("image_height", size[1])
fs.write("camera_matrix", mtx)
fs.write("distortion_coefficients", dist)
fs.write("rms", ret)
fs.release()

# Load it back
fs = cv2.FileStorage("camera.yaml", cv2.FILE_STORAGE_READ)
K = fs.getNode("camera_matrix").mat()
D = fs.getNode("distortion_coefficients").mat()

# or simply
np.savez("camera.npz", K=mtx, dist=dist)
# (Udacity used pickle: wide_dist_pickle.p)
▶︎ Lab 6C in Colab

How to take good calibration images

Coverage & variety

  • 15–30 images (Udacity: ≥ 20)
  • Board in every part of the frame, especially the corners, where distortion is largest
  • Tilt the board ±30–45° in both axes. Frontal-only views cannot separate f from distance
  • Vary the distance: near (board fills the frame) and far

Quality

  • Sharp: still board, good light, short exposure
  • Same resolution, focus and zoom you will use later: K changes with all three
  • Do not crop or resize the images before calibration
  • Check the per-view error and coverage, re-shoot the gaps

The ROS calibrator turns this advice into four progress bars, X, Y, Size and Skew, and only enables CALIBRATE once your views cover enough of each. Try it on the next slide.

Live demo: collect a good calibration set interactive

Bars follow ROS camera_calibration: the spread of the board's centre (X, Y), apparent size and skew over the accepted samples. Samples too similar to an earlier one are rejected.

▶︎ Lab 6I in Colab

The same idea in ROS 2: camera_calibration


# install (ROS 2 Humble / Jazzy / Rolling)
sudo apt install ros-$ROS_DISTRO-camera-calibration

# monocular: 8x6 inner corners, 25 mm squares
ros2 run camera_calibration cameracalibrator \
  --size 8x6 --square 0.025 \
  --ros-args -r image:=/camera/image_raw \
             -p camera:=/camera

# stereo pair
ros2 run camera_calibration cameracalibrator \
  --size 8x6 --square 0.025 --approximate 0.1 \
  --ros-args -r left:=/left/image_raw -r right:=/right/image_raw

# fisheye lens:   --camera-model fisheye
# ChArUco board:  --pattern charuco --charuco_marker_size 0.019 --aruco_dict 5x5_100

How it works

  • Uses OpenCV under the hood: the Python module camera_calibration.calibrator (MonoCalibrator, StereoCalibrator)
  • Move the board until the X / Y / Size / Skew bars go green → CALIBRATE → SAVE / COMMIT
  • Writes a camera_info YAML (K, D, R, P), loaded by camera_info_manager and used by image_proc to publish image_rect
  • Models: pinhole (plumb-bob) and fisheye (equidistant)

docs.ros.org: camera_calibration

Calibration, step 3: undistort

Original distorted test image and undistorted result
dst = cv2.undistort(img, mtx, dist, None, mtx)
  • Takes a distorted image, K and dist; returns the corrected destination image
  • The last argument is the new camera matrix; passing mtx keeps the same focal length
  • For every output pixel OpenCV computes where it came from (the distortion model) and interpolates

Straight lines on the board are straight again: the calibration worked.

▶︎ Lab 6C in Colab

Quiz: write cal_undistort()


import pickle, cv2, numpy as np

# Saved objpoints and imgpoints (Udacity wide_dist_pickle.p)
dist_pickle = pickle.load(open("wide_dist_pickle.p", "rb"))
objpoints = dist_pickle["objpoints"]
imgpoints = dist_pickle["imgpoints"]
img = cv2.imread("test_image.png")

# TODO: take an image, object points and image points,
# calibrate, undistort and return the undistorted image
def cal_undistort(img, objpoints, imgpoints):
    undist = np.copy(img)        # delete this line
    return undist
Original and undistorted test image

One solution


def cal_undistort(img, objpoints, imgpoints):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
        objpoints, imgpoints, gray.shape[::-1], None, None)
    return cv2.undistort(img, mtx, dist, None, mtx)

In practice calibrate once, save mtx and dist, and only call undistort per frame. Lab 6C has an auto-check.

▶︎ Lab 6C in Colab

Keep or crop the borders? getOptimalNewCameraMatrix

Undistortion with the original K, alpha 0 and alpha 1

h, w = img.shape[:2]
newK, roi = cv2.getOptimalNewCameraMatrix(
    mtx, dist, (w, h), alpha=0)   # 0 … 1
und = cv2.undistort(img, mtx, dist, None, newK)
x, y, rw, rh = roi                 # green box
und = und[y:y + rh, x:x + rw]      # crop black edges
  • alpha = 0: zoom in until every output pixel is valid (loses a little FOV)
  • alpha = 1: keep all source pixels (black curved borders)
  • Here alpha = 1 folds: the 5-coefficient polynomial is only valid where there were corners. Try CALIB_RATIONAL_MODEL or fisheye for wide lenses (Lab 6C).
▶︎ Lab 6C in Colab

Undistorting video fast: compute the map once


# once, at start-up
newK, roi = cv2.getOptimalNewCameraMatrix(K, dist, (w, h), 0)
map1, map2 = cv2.initUndistortRectifyMap(
    K, dist, None, newK, (w, h), cv2.CV_16SC2)

cap = cv2.VideoCapture(0)          # or Picamera2 frames
while True:
    ok, frame = cap.read()
    if not ok: break
    und = cv2.remap(frame, map1, map2, cv2.INTER_LINEAR)
    cv2.imshow("undistorted", und)
    if cv2.waitKey(1) == 27: break

# undistort only some points (e.g. detected corners, lane pixels)
pts_ud = cv2.undistortPoints(pts, K, dist, P=newK)

Why it is faster

cv2.undistort recomputes the distortion model for every pixel on every call.

initUndistortRectifyMap stores the source coordinate of each output pixel once; remap is then a pure lookup: several times faster, which matters at 30 fps on a Raspberry Pi.

If you only need a few points, undistortPoints avoids warping the image at all.

▶︎ Lab 6C in Colab

Common calibration pitfalls

SymptomLikely cause
ret is False for many imageswrong (nx, ny): squares instead of inner corners; board cut off by the frame; glare
calibrateCamera error about sizespassed img.shape with 3 channels, or images of different sizes (two Udacity images are 1281 × 721!)
RMS > 1 pxblur, a bent board, a mis-detected view: check per-view errors
Edges still curved / folded after undistortno corners near the image edges; lens too wide for the model → k3, rational, or fisheye
Good RMS, wrong distanceswrong square_size; calibrated at another resolution or focus
Image upside-down / mirrored axessymmetric board (8 × 8): use nx ≠ ny
Three calibration images where the board was not found

3 of the 20 road-camera images fail: part of the board is outside the frame. Fine for a chessboard: they are skipped. A ChArUco board would still use them.

Lab 6F is a bug hunt with these pitfalls planted in the code.

▶︎ Lab 6F in Colab

Remember

Calibrating with OpenCV: the 3 things to remember

  1. Corners: findChessboardCorners + cornerSubPix, count the inner corners.
  2. calibrateCamera(objpoints, imgpoints, (w, h)) → K, dist and one pose per photo.
  3. Judge it with the RMS reprojection error (< 0.5 px good, > 1 px re-shoot), then undistort / remap.

5

Hands-on: calibrate the webcam, live

Level 5 · real OpenCV running in the browser, a printed chessboard, 10 minutes

Level 5 · get ready

What you need (and how to hold the board)

The course pattern with its 9 × 6 inner corners marked
  1. Print pattern.png on A4 at 100 % (no "fit to page") or show it full-screen on a phone
  2. Glue it to stiff card. It must stay flat
  3. Measure one square with a ruler (e.g. 24.5 mm)
  4. A laptop webcam, Chrome or Edge, good light

It has 10 × 7 squares but 9 × 6 inner corners (red = corner number 1). We always count the inner corners.

The "calibration dance": 15–20 views

cornercornercorner centretilt lefttilt right tilt backnearfar

Hold it still for each shot. Cover the edges and corners, where the lens bends most.

Level 5 · the plan

The live demo in 7 steps

#Teacher doesStudents seeThe OpenCV behind it
1Open the studio, click Start webcam, allow the camerathe live pictureVideoCapture / getUserMedia
2Type the board: 9 × 6 corners, measured square size–objp = np.mgrid[...] * square
3Hold the board upcoloured corners: found!cv2.findChessboardCorners
4Turn Auto on, do the calibration danceviews appear; X/Y/Size/Skew bars fillcv2.cornerSubPix, collect points
5Click CalibrateK, dist, RMS, the error of each viewcv2.calibrateCamera
6Tab ③ Undistort, split view + gridraw vs corrected, liveinitUndistortRectifyMap + remap
7Tab ④ Measure; check with a tape measure"43.2 cm away"cv2.solvePnP

Tip: let two or three students take turns holding the board. Everyone watches the bars fill and learns why varied views matter.

LIVE: calibrate this webcam now live

⛶ Open full screen

Level 5 · explain

What just happened? The code you ran


import cv2, numpy as np
nx, ny, square = 9, 6, 24.5                        # step 2: the board
objp = np.zeros((nx * ny, 3), np.float32)
objp[:, :2] = np.mgrid[0:nx, 0:ny].T.reshape(-1, 2) * square

objpoints, imgpoints = [], []
cap = cv2.VideoCapture(0)                           # step 1: the webcam
while len(imgpoints) < 15:
    ok, frame = cap.read()
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    found, corners = cv2.findChessboardCorners(gray, (nx, ny))   # step 3
    if found and board_is_still_and_new(corners):                # step 4 (auto)
        corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), crit)
        objpoints.append(objp); imgpoints.append(corners)

rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(      # step 5
    objpoints, imgpoints, gray.shape[::-1], None, None)

mapx, mapy = cv2.initUndistortRectifyMap(K, dist, None, K, (w, h), cv2.CV_32FC1)
fixed = cv2.remap(frame, mapx, mapy, cv2.INTER_LINEAR)   # step 6

ok, rvec, tvec = cv2.solvePnP(objp, corners, K, dist)    # step 7
print("distance:", np.linalg.norm(tvec) / 10, "cm")

The studio runs exactly these OpenCV-Python calls: real cv2 4.10, compiled to WebAssembly and running inside your browser. The video never leaves your laptop.

The same program as a desktop app, which works offline:


pip install opencv-python numpy
python webcam_calibration_studio.py --board 9x6 --mm 24.5
# SPACE capture · A auto · C calibrate · U undistort · M measure · S save

webcam_calibration_studio.py in LAB/Lab_6

Level 5 · understand

Reading your result: is it good?

A typical laptop webcam at 640 × 480

RMS = 0.31 px
K = [[612.4,     0, 318.9],
     [    0, 611.8, 243.6],
     [    0,     0,     1]]
dist = [0.04, -0.16, 0.001, -0.002, 0.09]
  • RMS 0.31 px: on average the model is off by a third of a pixel. Excellent.
  • fx ≈ fy ≈ 612: square pixels ✓. Field of view = 2·atan(320 / 612) ≈ 55°.
  • (cx, cy) ≈ (319, 244): close to the centre (320, 240) ✓.
  • k1 small: webcams have little distortion. Wide cameras have k1 ≈ −0.2 to −0.4.
You seeMeansDo
RMS < 0.5 pxexcellentsave camera.yaml
RMS 0.5–1 pxusabledelete the red (worst) views, recalibrate
RMS > 1 pxbad viewsblur? bent board? re-shoot, hold still
fx and fy differ by > 2 %too few tilted viewsadd tilted and near views
cx, cy far from the centreedges not coveredput the board in the corners
distance wrong by a fixed %wrong square sizemeasure the square again

Remember: a small RMS only says the model fits your photos. The ruler test (next slide) says whether it fits the world.

Level 5 · check

Three ways to test a calibration

① Straight-line test

Undistort tab, split view + grid. Hold a ruler or door frame near the image edge: in the corrected half it must follow the grid lines.

② Ruler test

Measure tab. Put the board 40 cm from the webcam (tape measure). The studio should say 40 ± 1 cm. Move to 60 cm, then 80 cm.

③ Test image

Click Try the test image: calibration_test.png from a GoPro, straightened with that GoPro's calibration (Lab 6C).

calibration_test.png before and after undistortion with a straight-line grid

Level 5 · troubleshooting

If the live demo goes wrong

ProblemFix
No camera prompt / "NotAllowedError"click the camera icon in the address bar → Allow; or Open full screen (own tab)
"NotReadableError" / black pictureclose Zoom, Teams, the Windows Camera app: only one program can use the webcam
"board not found" all the timeshow the whole board with a white margin; check 9 × 6; more light; avoid reflections on glossy paper or a phone
Corners found but flickerhold still; webcams auto-expose; move closer to a window or lamp
Auto never capturesthe board must be still and in a new place: move to a new corner or tilt
OpenCV does not load (no internet)run python webcam_calibration_studio.py (offline), or use the 20 demo images
RMS highdelete the red views (click their bars), recalibrate

Remember

Live calibration: the 3 things to remember

  1. A printed, flat 9 × 6 board + a measured square size + 15–20 varied, still views.
  2. findChessboardCorners → calibrateCamera → check RMS and delete bad views.
  3. Test it in the world: straight lines stay straight, the ruler distance comes out right.

6

Perspective transform & lane curvature

Level 6 · Use your calibration: straighten, warp and measure the road

Why we need all this: lane curvature

Original image
Thresholded image with region
Perspective transform
Detect lines and determine curvature

A self-driving car needs a steering angle, which comes from the car's speed and dynamics and how much the lane curves:

  1. threshold the lane pixels (Lecture 5)
  2. perspective transform → bird's-eye view
  3. fit a polynomial to each line
  4. compute the curvature

For a near-vertical lane line

\[ f(y) = Ay^2 + By + C \]

A → curvature · B → heading (direction) · C → position (offset from the left of the image)

\[ R_{curve} = \frac{(1 + (2Ay + B)^2)^{3/2}}{|2A|} \]
▶︎ Lab 6D in Colab

Perspective: far things look small, parallel lines meet

Van Gogh's Bedroom in Arles and a road photo with converging lines
Road with background large z and foreground small z

The greater an object's Z (distance from the camera), the smaller it appears: the 1/Z of the pinhole model. Artists use the same rule. Lane lines that are parallel on the road converge to a vanishing point in the image.

A perspective transform changes the viewpoint

Text seen at an angle with four corner points

4 points on a plane in the image …

Text seen from the front

… mapped to 4 points of a rectangle

A perspective transform maps the points of a given image to different, desired image points, as if seen from a new viewpoint. It effectively drags points towards or away from the camera. Four point pairs are enough to define it (a 3 × 3 homography with 8 degrees of freedom).

In plain words: four corners of a flat thing are enough to tell OpenCV how to "walk around" it and photograph it from the front or from above.

The bird's-eye view of a road

Normal view versus bird's-eye view
Maps and bird's-eye examples
  • From the car's camera, the left line seems to lean and curve to the right, the right line to lean left without much curve.
  • From above, both lines are parallel, a consistent width apart, and curve by the same amount.
  • A top-down view also lines up directly with a map.

Quiz: why a bird's-eye view?

Quiz question

For images from a front-facing car camera, why do we want to perform a perspective transform?

  1. Because it is more straightforward to take a gradient on a top-down view.
  2. Because ultimately we want to measure the curvature of the lines, and to do that we need to transform to a top-down view.
  3. Because transforming the perspective removes the effect of curvature from our measurement.
  4. Because it removes the effect of shadows and colour differences.
B. Curvature is a property of the road seen from above. In the camera view perspective bends and squeezes it.

Example: transform a stop sign

Stop sign with four source points mapped to a frontal warped image
  1. Pick 4 source points that define a rectangle on a plane in the image (the face of the sign)
  2. Pick 4 destination points: where they should appear in the warped image
  3. getPerspectiveTransform(src, dst) → M
  4. warpPerspective(img, M, size) → the warped image

A traffic sign is a good test because text shows clearly whether the transform is right.

Perspective transform: the code


def warp(img):
    img_size = (img.shape[1], img.shape[0])        # (w, h)
    # four source points on the sign (read off by hovering
    # the mouse over the image), clockwise from top-right
    src = np.float32([[850, 320], [865, 450],
                      [533, 350], [535, 210]])
    # where we want them: a front-facing rectangle
    dst = np.float32([[870, 240], [870, 370],
                      [520, 370], [520, 240]])
    M    = cv2.getPerspectiveTransform(src, dst)
    Minv = cv2.getPerspectiveTransform(dst, src)   # to un-warp
    warped = cv2.warpPerspective(img, M, img_size,
                                 flags=cv2.INTER_LINEAR)
    return warped
Stop sign before and after warping

Choosing points by hand is fine for a fixed car camera (do it once). Many systems pick them automatically from corners, edges or detected objects, as you will with chessboard corners in the next quiz.

Live demo: drag four points → bird's-eye view interactive

Drag the 4 red handles onto the lane lines (undistorted frame, Lab 6D camera).

Warped: good points make the lane lines vertical and parallel.

▶︎ Lab 6D in Colab

Undistort → warp → fit: on the real road camera

Undistorted road with source trapezoid and the bird's-eye view Curved road, bird's-eye view and polynomial fit

Top: src = [[577,463],[707,463],[1110,720],[205,720]] → dst = [[300,0],[980,0],[980,720],[300,720]]. Bottom: lane pixels in the bird's-eye view, second-order fits and the radius in metres (assuming 30 m per 720 px vertically and 3.7 m per 700 px horizontally; Lab 6D).

▶︎ Lab 6D in Colab

Quiz: undistort and transform a chessboard

Original image and undistorted warped image

Given mtx and dist, write corners_unwarp(img, nx, ny, mtx, dist):

  1. Undistort with cv2.undistort
  2. Convert to grayscale and find the chessboard corners; draw them
  3. src = the outer 4 detected corners
  4. dst = 4 points of a rectangle, in the same order as src
  5. getPerspectiveTransform → M; warpPerspective → top-down view

def corners_unwarp(img, nx, ny, mtx, dist):
    undist = cv2.undistort(img, mtx, dist, None, mtx)
    gray = cv2.cvtColor(undist, cv2.COLOR_BGR2GRAY)
    ret, corners = cv2.findChessboardCorners(gray, (nx, ny), None)
    M, warped = None, undist
    if ret:
        corners = corners.reshape(-1, 2)   # (N,1,2) in 4.x, (N,2) in 5.0
        cv2.drawChessboardCorners(undist, (nx, ny), corners, ret)
        offset = 100
        w, h = gray.shape[1], gray.shape[0]
        src = np.float32([corners[0], corners[nx - 1],
                          corners[-1], corners[-nx]])
        dst = np.float32([[offset, offset], [w - offset, offset],
                          [w - offset, h - offset], [offset, h - offset]])
        M = cv2.getPerspectiveTransform(src, dst)
        warped = cv2.warpPerspective(undist, M, (w, h))
    return warped, M
▶︎ Lab 6D in Colab

Undistort & transform: the result

Original, undistorted with corners, and warped top-down chessboard

Our solution on a GoPro image from Lab 6. Note the order: undistort first. A homography maps straight lines to straight lines, so it cannot remove lens curvature.

OpenCV 5 note: findChessboardCorners returns corners of shape (N, 2); OpenCV 4.x returned (N, 1, 2). Code like corners[0][0] breaks. Use corners.reshape(-1, 2) to work with both.

Quiz: match each process to its purpose

Matching challenge

Processes: ① Camera calibration · ② Distortion correction · ③ Perspective transform

Purposes:

  1. To transform an image so that we are effectively viewing objects from a different angle or direction.
  2. To ensure that the geometrical shape of objects is represented consistently, no matter where they appear in an image.
  3. To compute the transformation between 3-D object points in the world and 2-D image points.
  4. To measure the orientation angles of objects in an image. (distractor)
① Camera calibration → C: computes the mapping between 3-D world points and 2-D image points.
② Distortion correction → B: shapes look the same anywhere in the frame.
③ Perspective transform → A: view the scene from a different angle, e.g. from above.

D is not a purpose of any of the three on its own.

Remember

Perspective transform: the 3 things to remember

  1. Undistort first, then warp: a homography keeps straight lines straight, so it cannot fix lens curves.
  2. 4 point pairs → getPerspectiveTransform → warpPerspective.
  3. Bird's-eye view → fit x = Ay² + By + C → lane curvature in metres.

7

Advanced: more cameras, better targets

Level 7 · advanced · camera types · ChArUco · fisheye · stereo · Raspberry Pi · tools

Camera types you will meet

CameraTypical FOVModel to calibrate withOpenCVExamples
Standard / narrow lens40–70°pinhole + radial/tangential (k1, k2, p1, p2)cv2.calibrateCameraPi Cam v2/3, webcams, phone main camera
Wide-angle90–120°pinhole + k3, or CALIB_RATIONAL_MODEL (k4–k6)cv2.calibrateCameraPi Cam 3 Wide, GoPro (linear mode), dash-cams
Fisheye150–200°Kannala–Brandt equidistant θ(1 + k1θ² + …)cv2.fisheye.calibratecar surround-view, drones, robot vacuum, T265
Omnidirectional / catadioptric≥ 180–360°unified (Mei) model with mirror parameter ξcv2.omnidir.calibrate (contrib)mirror + lens rigs, 360° cameras (dual fisheye)
Stereo pairanytwo intrinsics + relative pose (R, T)cv2.stereoCalibrateZED, OAK-D, two Pi cameras, Kaggle stereo set
RGB-D / ToF60–90°factory-calibrated colour + depth intrinsics & extrinsicsvendor SDK, calibrateCamera on IRRealSense, Kinect, Orbbec
Thermal25–60°pinhole: needs a heated or emissive targetcalibrateCameraFLIR Lepton
Event camera~ 60–90°pinhole; board shown as a blinking screenreconstruct frames → OpenCVProphesee, DAVIS

The more the field of view grows, the more the simple pinhole + polynomial model breaks down: at 90° off-axis, tan θ → ∞.

In plain words: the wider the lens sees, the more it bends lines, and the more special the maths. Normal lens → standard model; very wide → fisheye model.

Why fisheye needs a different model

Image radius versus ray angle for different lens projections
  • A pinhole maps a ray at angle θ to radius r = f tan θ, which goes to infinity at 90°. No finite sensor can hold a 180° view.
  • Fisheye lenses are designed so r grows slowly: equidistant r = fθ, equisolid, stereographic…
  • OpenCV's fisheye module models the real lens as
    θd = θ (1 + k1θ² + k2θ⁴ + k3θ⁶ + k4θ⁸), so D has 4 coefficients.
  • Rule of thumb: FOV above ~120°, or the standard model leaves the edges curved → switch to cv2.fisheye.

The cameras on our lab bench: Raspberry Pi

ModuleSensorResolutionPixelFocal lengthH-FOVShutterCalibrate as
Camera Module v2IMX2193280 × 24641.12 µm3.04 mm62°rollingstandard
Camera Module 3IMX7084608 × 25921.4 µm4.74 mm66°rollingstandard (fix the focus!)
Camera Module 3 WideIMX7084608 × 25921.4 µm2.75 mm102°rollingwide: k3 or rational
HQ CameraIMX4774056 × 30401.55 µmlens (6 mm, 16 mm…)lensrollingdepends on lens
Global Shutter CameraIMX2961456 × 10883.45 µmlenslensglobaldepends on lens
M12 fisheye lens on HQ/GS–––~1.5–2 mm> 180°–fisheye
Camera Module 3 has autofocus. Intrinsics change with focus, so lock the lens (picam2.set_controls({"AfMode": 0, "LensPosition": …})) before you calibrate and keep it there.
Rolling shutter rows are exposed one after another, so a moving board or camera skews the image. Hold the board still for every shot, or use the Global Shutter camera.

Calibration targets

Chessboard, circle grid, asymmetric circles, ChArUco and ArUco grid board
TargetDetect with+−
ChessboardfindChessboardCorners, findChessboardCornersSBeasy to print, very accurate saddle pointsmust be fully visible; 180° ambiguity if rows = cols
(Asymmetric) circle gridfindCirclesGridrobust to blur & noisecircle centres biased under strong perspective/distortion
ChArUcocv2.aruco.CharucoDetectorpartial views OK, every corner has an ID: ideal for fisheye & edgesneeds the right dictionary & a sharp print
ArUco grid / AprilGridcv2.aruco.ArucoDetectorpose of each marker, multi-camera rigs (Kalibr)marker corners less precise than saddles

Making a good target (it matters more than the code)

Do

  • Print at 100 % scale, then measure a square with a ruler or caliper: that is square_size
  • Glue it to something rigid and flat: glass, aluminium, foam board
  • Matte paper: no glare spots on the corners
  • Keep a white border around the pattern (the detector needs it)
  • Use different row/column counts (9 × 6, not 8 × 8) so the orientation is unambiguous

Don't

  • Use a board that bends or ripples: the model assumes Z = 0 for every corner
  • Show it on a laptop screen at an angle (moiré, refresh, reflections). OK only as a last resort
  • Count squares instead of inner corners
  • Let auto-exposure / motion blur smear the corners

Pattern generators: calib.io, OpenCV gen_pattern.py, or board.generateImage() in Lab 6E.

ChArUco: a chessboard with an ID on every square

A rendered ChArUco board partly covered, and the detected corners with IDs
  • ArUco markers inside the white squares tell the detector which corner it is seeing
  • So partial, occluded or out-of-frame boards still give corners: perfect for covering image edges and fisheye lenses
  • Corners are chessboard saddle points → as accurate as a chessboard

Watch: How to do Camera Calibration with ChArUco Boards (Nicolai Nielsen) · OpenCV tutorial: calibration with ArUco and ChArUco

▶︎ Lab 6E in Colab

ChArUco calibration: the code (OpenCV ≥ 4.7 / 5)


import cv2, glob

aruco_dict = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_5X5_100)
board = cv2.aruco.CharucoBoard((7, 5),          # squares x, y
                               0.040, 0.030,    # square, marker (m)
                               aruco_dict)
cv2.imwrite("board.png", board.generateImage((2100, 1500), marginSize=60))

detector = cv2.aruco.CharucoDetector(board)
all_obj, all_img = [], []
for f in glob.glob("charuco/*.jpg"):
    gray = cv2.imread(f, cv2.IMREAD_GRAYSCALE)
    ch_corners, ch_ids, m_corners, m_ids = detector.detectBoard(gray)
    if ch_ids is not None and len(ch_ids) >= 6:     # partial view is fine
        obj, img = board.matchImagePoints(ch_corners, ch_ids)
        all_obj.append(obj); all_img.append(img)

rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(
    all_obj, all_img, gray.shape[::-1], None, None)

Notes

  • The old cv2.aruco.calibrateCameraCharuco / interpolateCornersCharuco were removed in 4.7+. Use CharucoDetector + matchImagePoints.
  • Measure both the square and marker size on the print.
  • Pick a dictionary with few markers (e.g. 5×5_100) for better robustness.
  • Lab 6E renders synthetic ChArUco views with a known camera, so you can check your calibration against the ground truth.
▶︎ Lab 6E in Colab

Fisheye lenses: cv2.fisheye

A fisheye view of a road scene and two undistorted versions

# per view: objpoints (1,N,3) float64, imgpoints (1,N,2) float64
# OpenCV 5: cv2.CALIB_*   (OpenCV 4: cv2.fisheye.CALIB_*)
flags = cv2.CALIB_RECOMPUTE_EXTRINSIC | cv2.CALIB_FIX_SKEW
rms, K, D, rvecs, tvecs = cv2.fisheye.calibrate(
    objpoints, imgpoints, (w, h), None, None, flags=flags)   # D = [k1..k4]

newK = cv2.fisheye.estimateNewCameraMatrixForUndistortRectify(
    K, D, (w, h), np.eye(3), balance=0.0)                    # 0 crop … 1 keep all
m1, m2 = cv2.fisheye.initUndistortRectifyMap(K, D, np.eye(3), newK, (w, h), cv2.CV_16SC2)
und = cv2.remap(img, m1, m2, cv2.INTER_LINEAR)
  • The whole 180° view cannot fit into one pinhole image: choose a narrower newK (middle) or accept stretched edges (right)
  • Points must be float64, shape (1, N, 3): OpenCV 5 rejects the old (N, 1, 3). The #1 fisheye error
  • Very wide (> 200°) or mirror cameras: cv2.omnidir (contrib)
▶︎ Lab 6E in Colab

Stereo calibration: two cameras, one rig

Raw and rectified stereo pairs with horizontal guide lines
  1. Capture the board with both cameras at the same moment (hardware sync or a still board)
  2. Calibrate each camera → K1, D1, K2, D2
  3. stereoCalibrate → R, T between the cameras (+ E, F)
  4. stereoRectify → rotations that make epipolar lines horizontal
  5. initUndistortRectifyMap + remap per frame → disparity → depth

OpenCV's left/right sample pair: RMS 0.45 px, baseline 3.3 squares along −x.

Depth from disparity, Z = fB/d, is Lecture 10. Datasets: Kaggle stereo chessboard pictures.

▶︎ Lab 6G in Colab

Stereo calibration: the code


# objpoints, imgL, imgR: corners found in BOTH images of each pair
_, K1, D1, _, _ = cv2.calibrateCamera(objpoints, imgL, size, None, None)
_, K2, D2, _, _ = cv2.calibrateCamera(objpoints, imgR, size, None, None)

rms, K1, D1, K2, D2, R, T, E, F = cv2.stereoCalibrate(
    objpoints, imgL, imgR, K1, D1, K2, D2, size,
    flags=cv2.CALIB_FIX_INTRINSIC)           # keep the mono results, solve only R, T
print("stereo RMS", rms, "baseline", np.linalg.norm(T) * square_mm, "mm")

R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(K1, D1, K2, D2, size, R, T, alpha=0)
mapL = cv2.initUndistortRectifyMap(K1, D1, R1, P1, size, cv2.CV_16SC2)
mapR = cv2.initUndistortRectifyMap(K2, D2, R2, P2, size, cv2.CV_16SC2)
rectL = cv2.remap(left,  *mapL, cv2.INTER_LINEAR)
rectR = cv2.remap(right, *mapR, cv2.INTER_LINEAR)       # now matching points share a row

Two Raspberry Pi cameras on a bracket (or the Pi 5's two CSI ports) make a cheap stereo rig. Keep them rigidly mounted: any movement invalidates R, T. See Raspberry Pi stereo camera calibration.

Check the rectified pair: draw horizontal lines. If the same corner is on different rows, the calibration or the synchronisation is off.

▶︎ Lab 6G in Colab

Challenge: calibrate your own camera


# capture_calib.py  (Lab 6H): Pi camera or USB webcam
from picamera2 import Picamera2           # USB: cv2.VideoCapture(0)
import cv2, time, os

picam2 = Picamera2()
picam2.configure(picam2.create_still_configuration(
    main={"size": (1920, 1080), "format": "RGB888"}))
picam2.start()
picam2.set_controls({"AfMode": 0, "LensPosition": 0.0})  # Module 3: lock focus

os.makedirs("calib_images", exist_ok=True)
for i in range(25):
    time.sleep(3)                           # move the board, hold still
    frame = picam2.capture_array()
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    ok, _ = cv2.findChessboardCorners(gray, (9, 6),
                cv2.CALIB_CB_FAST_CHECK)
    cv2.imwrite(f"calib_images/img_{i:02d}.jpg", frame)
    print(i, "board found" if ok else "NO board: move it")

Recipe

  1. Print a 9 × 6 board (pattern.png), stick it to a flat board, measure a square
  2. Take 20–30 pictures over the whole field of view, especially near the edges
  3. Run calibrate.py --board 9x6 --square 24.5 → camera.yaml
  4. Check RMS < 0.5–1 px and that straight lines are straight
  5. Commit camera.yaml next to your project code

Based on Petros626/Camera-Calibration-with-PiCamera2-OpenCV, niconielsen32/camera-calibration, nuwandda/Checkerboard-Calibration, ibois-epfl/rgb-camera-calibration.

▶︎ Lab 6H in Colab

Calibration tools in the wild

ToolBest forNotes
OpenCV calib3d (cv2.calibrateCamera, fisheye, stereoCalibrate, aruco)everything in this coursecalib3d reference: models, flags, functions
OpenCV interactive-calibration appguided live calibrationsuggests the next pose, shows per-parameter uncertainty
ROS 2 camera_calibrationrobots, mono/stereo, fisheye, ChArUcoX/Y/Size/Skew bars → camera_info YAML
MATLAB Camera Calibrator / Stereo Camera Calibratorteaching, quick visual checkserror plots and 3-D pose views
Kalibr (ETH Zürich)multi-camera + IMU, rolling shutterAprilGrid targets; the standard for VIO / SLAM rigs
calib.io camera calibrator, mrcal (NASA JPL)high-accuracy industrial / metrologyricher lens models, uncertainty maps

Whatever the tool, the inputs and outputs are the same: known 3-D target points + detected 2-D corners → K, distortion, poses, reprojection error.

Watch & learn: recommended videos

Hands-on · Video lab 6I

Calibrate your Raspberry Pi camera in 15 minutes


# on the Pi (SSH + VNC), Raspberry Pi OS Bookworm
mkdir -p ~/calib && cd ~/calib
for f in pi_calibrate_20.py aruco_pose_test.py; do
  wget https://raw.githubusercontent.com/Kazimbalti/\
intelligent-vision-systems/main/LAB/Lab_6/$f; done
wget https://raw.githubusercontent.com/opencv/opencv/4.x/doc/pattern.png
sudo apt install -y python3-opencv python3-picamera2

# 1. calibrate: 26 mm squares, 640 x 480 (as in the video)
python3 pi_calibrate_20.py --mm 26 --width 640 --height 480

# 2. print a marker, measure it, test with a ruler
python3 aruco_pose_test.py --make-marker --id 72
python3 aruco_pose_test.py --size 200 --id 72 \
        --width 640 --height 480
  1. Print pattern.png on A4, glue it flat, measure a square
  2. The script keeps 20 good, different frames by itself: show the edges first, then near, far, tilted
  3. It saves cameraMatrix.txt, cameraDistortion.txt, camera.yaml
  4. Ruler test: centre an ArUco marker (x ≈ 0), slide it 6 cm: the program must say ≈ 60 mm
  5. Same resolution in every script!

After The Drone Dojo: Calibrate OpenCV Camera In Under 15 Minutes, updated for Bookworm + OpenCV 5 (Picamera2, apt instead of sudo pip, solvePnP instead of estimatePoseSingleMarkers).

Questions students always ask

Do I calibrate every time I switch the camera on?
No. Once per camera + lens + focus + resolution. Save camera.yaml and reuse it.

I changed the resolution. Recalibrate?
Best yes. Same aspect ratio: just scale fx, fy, cx, cy by the resize factor (dist stays the same).

Two cameras of the same model: same K?
Close, but not equal. Every unit is assembled slightly differently, so calibrate each one.

Why a chessboard and not any picture?
We must know the true shape exactly, and chessboard corners can be found to 0.1 px.

How many photos?
3 is the mathematical minimum; use 15–30 sharp, tilted views covering the whole frame.

My RMS is 0.3 px. Am I done?
Almost: also check the board covered the edges, and do a ruler test (Lab 6I): does a known distance come out right?

Summary

Model

  • s·x = K [R | t] X: K (fx, fy, cx, cy) is the camera, [R | t] is where it is
  • Lenses add radial (k1, k2, k3) and tangential (p1, p2) distortion, applied in normalised coordinates
  • Wide and fisheye lenses need k3 / rational or the fisheye model; stereo needs R, T between cameras

Practice

  • findChessboardCorners (+ cornerSubPix) → calibrateCamera → RMS < 0.5–1 px
  • 20+ sharp, tilted views covering the whole frame; same resolution & focus as deployment
  • undistort or initUndistortRectifyMap + remap for video
  • Undistort first, then getPerspectiveTransform + warpPerspective → bird's-eye → curvature

Next: Lecture 10 builds on this with epipolar geometry, disparity and depth from stereo, and the Lab 9 measuring system needs your calibrated camera.

Lab 6: all notebooks (click to open in Colab)

Credits. Adapted and extended from the Camera Calibration lesson of the Udacity Self-Driving Car Engineer Nanodegree. Lesson illustrations and calibration images © Udacity, Inc. (MIT-licensed repositories); stereo sample images from OpenCV (Apache 2.0). Videos © their authors. Used here for teaching.

Questions?

Next: Lab 6. Calibrate the GoPro, the road camera, a stereo pair, and then your own camera.