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
Where we are
…but everything is in pixels, and straight lines only.



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).
By the end of this lecture and Lab 6 you should be able to:
(k1, k2, p1, p2, k3), and when you need the fisheye or omnidirectional model instead.calibrateCamera → reprojection error.For the teacher and the student
| Level | Topic | Time | Hands-on |
|---|---|---|---|
| 0 | Pictures, pixels, lenses, coordinates | 10 min | pixel explorer, phone camera |
| 1 | Why calibrate? | 5 min | quiz |
| 2 | Pinhole model, K and [R | t] | 15 min | pinhole demo, K calculator · Lab 6A |
| 3 | Lens distortion | 10 min | distortion explorer · Lab 6A |
| 4 | The calibration recipe in OpenCV | 20 min | count corners, coverage demo · 6B, 6C |
| 5 | LIVE: calibrate the webcam | 20 min | Webcam Calibration Studio · 6H |
| 6 | Use it: undistort, bird's-eye, measure | 15 min | warp demo · 6D |
| 7 | Advanced: camera types, fisheye, ChArUco, stereo, Pi, Zhang | pick | 6E, 6G, 6I–6M |
webcam_calibration_studio.py or the studio's demo imagesStudents: every level ends with a quiz or "Remember" slide; 🖐 boxes are things to try yourself; ▶ buttons open the matching Colab notebook.
The whole lecture in one picture: real world → (lens) → distorted image → (calibration) → corrected image (Udacity).
The big picture first
♟️
Hold a printed chessboard in front of the camera. We know exactly where every corner is on the paper.
📷
Find the corners in the picture. Because of the lens and the viewpoint they are not where a perfect camera would put them.
🧮
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.
| Word | Means | Everyday picture |
|---|---|---|
| Intrinsics, K | numbers 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, dist | how 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 points | where the board corners really are (mm, Z = 0) | the answer sheet |
| Image points | where the corners appear in the photo (pixels) | the student's answers |
| Reprojection error | distance between where the model predicts a corner and where it was found | how far a dart lands from the bullseye |
| Undistort | re-draw the image as a perfect pinhole camera would have taken it | putting the glasses on |
| Homography / perspective transform | a 3 × 3 recipe that re-draws a flat thing as seen from another angle | photographing a poster straight-on |
0
The very basics
Level 0 · pictures, pixels, lenses and three kinds of coordinates
Level 0 · step 1
Move the mouse over the picture (it is calibration_test.png, 1280 × 720 pixels).
pixel position (u, v) = (–, –)
brightness = – (0 = black, 255 = white)
img.shape = (height, width).Level 0 · step 2
A camera only gives pixels. How many pixels an object covers depends on
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
🖐 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 flat chessboard through a wide-angle GoPro lens: the straight rows look curved.
Level 0 · step 4
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.
Answer out loud, then click
1
Why calibrate a camera?
Level 1 · Why do cameras need calibrating?

When a camera looks at 3-D objects and turns them into a 2-D image, the transformation is not perfect.
So the first step of any measuring vision system is to undo the distortion.


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.
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 ColabQuiz question · select all that apply
Focal lengths fx, fy and principal point cx, cy, in pixels. Fixed for a given camera, lens, focus and resolution.
dist = [k1, k2, p1, p2, k3] (or the fisheye [k1..k4]). How the lens bends rays away from the ideal pinhole.
Where the camera is relative to the world (or to the board, or to a second camera). One pose per calibration image.
| Application | Needs |
|---|---|
| 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 reconstruction | K, 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

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

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?


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.
Similar triangles give, for a camera at the origin looking along Z:
\[ x = f\,\frac{X}{Z}, \qquad y = f\,\frac{Y}{Z} \]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.
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.
Sensor data from the Raspberry Pi camera documentation. Lens values of webcams vary: treat the preset as an example.
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).
rvec (axis × angle, Rodrigues): R, _ = cv2.Rodrigues(rvec)calibrateCamera returns one rvec, tvec per calibration image: the pose of the boardcv2.projectPoints(X, rvec, tvec, K, dist) applies the whole model, including distortioncv2.solvePnP does the inverse: known K + 3-D/2-D matches → poseIn 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.
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.
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?
And a point twice as far, (1, −0.5, 8)?
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.]]
dist vector works at any image size, while K must be scaled when you resize.Watch: Camera Intrinsics and Extrinsics: 5 Minutes with Cyrill (Cyrill Stachniss, Uni Bonn).
Remember
cv2.projectPoints(X, rvec, tvec, K, dist) does the whole chain.3
Lens distortion
Level 3 · Real cameras use lenses, and lenses bend light


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.
In plain words: the lens squeezes (barrel) or stretches (pincushion) the picture more and more the further you go from the centre.

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.

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

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.
Generated with cv2.projectPoints on a flat grid: same K, different dist vectors.
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} \]
[k1, k2, p1, p2, k3]. k3 comes last because it is only needed for strong (wide-angle) distortion.k4–k6, thin-prism s1–s4, tilted sensor τx, τy, so up to 14 numbers.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!
(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.

Quiz question
This chessboard was shot with a GoPro (the camera we calibrate in Lab 6).
Remember
[k1, k2, p1, p2, k3].4
Calibrating a camera with OpenCV
Level 4 · The recipe: chessboard → corners → K and dist


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.



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.
patternSize you pass to OpenCV.findChessboardCorners
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
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.

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.
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 1680 corners from the 35 GoPro images: they cover the whole frame, corners included. That is what makes a calibration reliable.
cv2.calibrateCamera
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]]
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.
ret: RMS reprojection error (px)mtx: K (3 × 3); dist: (1 × 5)rvecs, tvecs: one board pose per imageOnly a printed board, no expensive 3-D rig, so it is used everywhere: OpenCV, MATLAB, ROS.
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).
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.
Project the object points with the estimated model and compare with the detected corners.
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.
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)
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.
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.
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
camera_calibration.calibrator (MonoCalibrator, StereoCalibrator)camera_info YAML (K, D, R, P), loaded by camera_info_manager and used by image_proc to publish image_rectpinhole (plumb-bob) and fisheye (equidistant)
mtx keeps the same focal lengthStraight lines on the board are straight again: the calibration worked.
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
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.
getOptimalNewCameraMatrix
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
CALIB_RATIONAL_MODEL or fisheye for wide lenses (Lab 6C).
# 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)
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.
| Symptom | Likely cause |
|---|---|
ret is False for many images | wrong (nx, ny): squares instead of inner corners; board cut off by the frame; glare |
calibrateCamera error about sizes | passed img.shape with 3 channels, or images of different sizes (two Udacity images are 1281 × 721!) |
| RMS > 1 px | blur, a bent board, a mis-detected view: check per-view errors |
| Edges still curved / folded after undistort | no corners near the image edges; lens too wide for the model → k3, rational, or fisheye |
| Good RMS, wrong distances | wrong square_size; calibrated at another resolution or focus |
| Image upside-down / mirrored axes | symmetric board (8 × 8): use nx ≠ ny |
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.
Remember
findChessboardCorners + cornerSubPix, count the inner corners.calibrateCamera(objpoints, imgpoints, (w, h)) → K, dist and one pose per photo.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

It has 10 × 7 squares but 9 × 6 inner corners (red = corner number 1). We always count the inner corners.
Hold it still for each shot. Cover the edges and corners, where the lens bends most.
Level 5 · the plan
| # | Teacher does | Students see | The OpenCV behind it |
|---|---|---|---|
| 1 | Open the studio, click Start webcam, allow the camera | the live picture | VideoCapture / getUserMedia |
| 2 | Type the board: 9 × 6 corners, measured square size | – | objp = np.mgrid[...] * square |
| 3 | Hold the board up | coloured corners: found! | cv2.findChessboardCorners |
| 4 | Turn Auto on, do the calibration dance | views appear; X/Y/Size/Skew bars fill | cv2.cornerSubPix, collect points |
| 5 | Click Calibrate | K, dist, RMS, the error of each view | cv2.calibrateCamera |
| 6 | Tab ③ Undistort, split view + grid | raw vs corrected, live | initUndistortRectifyMap + remap |
| 7 | Tab ④ 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.
Level 5 · explain
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
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]
| You see | Means | Do |
|---|---|---|
| RMS < 0.5 px | excellent | save camera.yaml |
| RMS 0.5–1 px | usable | delete the red (worst) views, recalibrate |
| RMS > 1 px | bad views | blur? bent board? re-shoot, hold still |
| fx and fy differ by > 2 % | too few tilted views | add tilted and near views |
| cx, cy far from the centre | edges not covered | put the board in the corners |
| distance wrong by a fixed % | wrong square size | measure 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
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.
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.
Click Try the test image: calibration_test.png from a GoPro, straightened with that GoPro's calibration (Lab 6C).
Level 5 · troubleshooting
| Problem | Fix |
|---|---|
| No camera prompt / "NotAllowedError" | click the camera icon in the address bar → Allow; or Open full screen (own tab) |
| "NotReadableError" / black picture | close Zoom, Teams, the Windows Camera app: only one program can use the webcam |
| "board not found" all the time | show the whole board with a white margin; check 9 × 6; more light; avoid reflections on glossy paper or a phone |
| Corners found but flicker | hold still; webcams auto-expose; move closer to a window or lamp |
| Auto never captures | the 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 high | delete the red views (click their bars), recalibrate |
Remember
findChessboardCorners → calibrateCamera → check RMS and delete bad views.6
Perspective transform & lane curvature
Level 6 · Use your calibration: straighten, warp and measure the road




A self-driving car needs a steering angle, which comes from the car's speed and dynamics and how much the lane curves:
A → curvature · B → heading (direction) · C → position (offset from the left of the image)
\[ R_{curve} = \frac{(1 + (2Ay + B)^2)^{3/2}}{|2A|} \]

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.

4 points on a plane in the image …

… 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.


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

getPerspectiveTransform(src, dst) → MwarpPerspective(img, M, size) → the warped imageA traffic sign is a good test because text shows clearly whether the transform is right.
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
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.
Drag the 4 red handles onto the lane lines (undistorted frame, Lab 6D camera).
Warped: good points make the lane lines vertical and parallel.
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).
Given mtx and dist, write corners_unwarp(img, nx, ny, mtx, dist):
cv2.undistortgetPerspectiveTransform → 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
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.
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.Matching challenge
Processes: ① Camera calibration · ② Distortion correction · ③ Perspective transform
Purposes:
D is not a purpose of any of the three on its own.
Remember
getPerspectiveTransform → warpPerspective.7
Advanced: more cameras, better targets
Level 7 · advanced · camera types · ChArUco · fisheye · stereo · Raspberry Pi · tools
| Camera | Typical FOV | Model to calibrate with | OpenCV | Examples |
|---|---|---|---|---|
| Standard / narrow lens | 40–70° | pinhole + radial/tangential (k1, k2, p1, p2) | cv2.calibrateCamera | Pi Cam v2/3, webcams, phone main camera |
| Wide-angle | 90–120° | pinhole + k3, or CALIB_RATIONAL_MODEL (k4–k6) | cv2.calibrateCamera | Pi Cam 3 Wide, GoPro (linear mode), dash-cams |
| Fisheye | 150–200° | Kannala–Brandt equidistant θ(1 + k1θ² + …) | cv2.fisheye.calibrate | car 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 pair | any | two intrinsics + relative pose (R, T) | cv2.stereoCalibrate | ZED, OAK-D, two Pi cameras, Kaggle stereo set |
| RGB-D / ToF | 60–90° | factory-calibrated colour + depth intrinsics & extrinsics | vendor SDK, calibrateCamera on IR | RealSense, Kinect, Orbbec |
| Thermal | 25–60° | pinhole: needs a heated or emissive target | calibrateCamera | FLIR Lepton |
| Event camera | ~ 60–90° | pinhole; board shown as a blinking screen | reconstruct frames → OpenCV | Prophesee, 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.

cv2.fisheye.| Module | Sensor | Resolution | Pixel | Focal length | H-FOV | Shutter | Calibrate as |
|---|---|---|---|---|---|---|---|
| Camera Module v2 | IMX219 | 3280 × 2464 | 1.12 µm | 3.04 mm | 62° | rolling | standard |
| Camera Module 3 | IMX708 | 4608 × 2592 | 1.4 µm | 4.74 mm | 66° | rolling | standard (fix the focus!) |
| Camera Module 3 Wide | IMX708 | 4608 × 2592 | 1.4 µm | 2.75 mm | 102° | rolling | wide: k3 or rational |
| HQ Camera | IMX477 | 4056 × 3040 | 1.55 µm | lens (6 mm, 16 mm…) | lens | rolling | depends on lens |
| Global Shutter Camera | IMX296 | 1456 × 1088 | 3.45 µm | lens | lens | global | depends on lens |
| M12 fisheye lens on HQ/GS | – | – | – | ~1.5–2 mm | > 180° | – | fisheye |
picam2.set_controls({"AfMode": 0, "LensPosition": …})) before you calibrate and keep it there.
| Target | Detect with | + | − |
|---|---|---|---|
| Chessboard | findChessboardCorners, findChessboardCornersSB | easy to print, very accurate saddle points | must be fully visible; 180° ambiguity if rows = cols |
| (Asymmetric) circle grid | findCirclesGrid | robust to blur & noise | circle centres biased under strong perspective/distortion |
| ChArUco | cv2.aruco.CharucoDetector | partial views OK, every corner has an ID: ideal for fisheye & edges | needs the right dictionary & a sharp print |
| ArUco grid / AprilGrid | cv2.aruco.ArucoDetector | pose of each marker, multi-camera rigs (Kalibr) | marker corners less precise than saddles |
square_sizePattern generators: calib.io, OpenCV gen_pattern.py, or board.generateImage() in Lab 6E.
Watch: How to do Camera Calibration with ChArUco Boards (Nicolai Nielsen) · OpenCV tutorial: calibration with ArUco and ChArUco
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)
cv2.aruco.calibrateCameraCharuco / interpolateCornersCharuco were removed in 4.7+. Use CharucoDetector + matchImagePoints.cv2.fisheye
# 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)
cv2.omnidir (contrib)
stereoCalibrate → R, T between the cameras (+ E, F)stereoRectify → rotations that make epipolar lines horizontalinitUndistortRectifyMap + remap per frame → disparity → depthOpenCV'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.
# 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.
# 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")
calibrate.py --board 9x6 --square 24.5 → camera.yamlcamera.yaml next to your project codeBased on Petros626/Camera-Calibration-with-PiCamera2-OpenCV, niconielsen32/camera-calibration, nuwandda/Checkerboard-Calibration, ibois-epfl/rgb-camera-calibration.
| Tool | Best for | Notes |
|---|---|---|
OpenCV calib3d (cv2.calibrateCamera, fisheye, stereoCalibrate, aruco) | everything in this course | calib3d reference: models, flags, functions |
OpenCV interactive-calibration app | guided live calibration | suggests the next pose, shows per-parameter uncertainty |
ROS 2 camera_calibration | robots, mono/stereo, fisheye, ChArUco | X/Y/Size/Skew bars → camera_info YAML |
| MATLAB Camera Calibrator / Stereo Camera Calibrator | teaching, quick visual checks | error plots and 3-D pose views |
| Kalibr (ETH Zürich) | multi-camera + IMU, rolling shutter | AprilGrid targets; the standard for VIO / SLAM rigs |
| calib.io camera calibrator, mrcal (NASA JPL) | high-accuracy industrial / metrology | richer 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.
Intrinsics & extrinsics
Intrinsic camera calibration
Zhang's method
Camera calibration explained, step by step
OpenCV Python calibration (intrinsic, extrinsic, distortion)
Camera calibration in < 5 minutes
Calibration with a Python script
ChArUco board calibration
Calibrate an OpenCV camera in 15 minutesHands-on · Video lab 6I
# 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
cameraMatrix.txt, cameraDistortion.txt, camera.yamlAfter 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).
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?
findChessboardCorners (+ cornerSubPix) → calibrateCamera → RMS < 0.5–1 pxundistort or initUndistortRectifyMap + remap for videogetPerspectiveTransform + warpPerspective → bird's-eye → curvatureNext: Lecture 10 builds on this with epipolar geometry, disparity and depth from stereo, and the Lab 9 measuring system needs your calibrated camera.
📷 Webcam Calibration Studio (browser) · webcam_calibration_studio.py (offline) · Scripts: pi_calibrate_20.py · aruco_pose_test.py · capture_calib.py · calibrate.py · get_images.py · all on GitHub · video tutorials
Reading: OpenCV calib3d · ArUco/ChArUco calibration · ROS camera_calibration · Zhang, TPAMI 2000 · Szeliski 2e §2.1, §11.1 · Medium: checkerboard calibration · udacity/CarND-Camera-Calibration
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.
Intelligent Vision Systems; University of Lahore | Dr. Muhammad Kazim