Camera Calibration and Perspective Correction
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- Responsible CV: Privacy, Fairness, Security, Governance
Every measurement you take from a photo is wrong until you calibrate. The lens bends straight lines, and a pixel has no fixed size in the real world. Calibration fixes both, and it takes about twenty photos of a printed checkerboard.
Prerequisites: Feature matching and homography, and matrix multiplication.
The problem: measuring gaskets with a phone
A small workshop cuts rubber gaskets and needs to check their diameter to within a millimetre. Buying a measuring microscope is expensive. A phone on a stand, pointing down at a lit surface, costs nothing.
Photograph a gasket you know is exactly 80 mm across. Measure it in the image and you get 340 pixels — but move the gasket to the corner of the frame and the same gasket measures 328 pixels. It has not changed size. The lens has bent it.
| # | Gasket position in frame | Measured width (px) | Implied diameter at 0.235 mm/px | Error target |
|---|---|---|---|---|
| 1 | Centre | 340 | 79.9 mm | −0.1 mm |
| 2 | Halfway to the edge | 336 | 79.0 mm | −1.0 mm |
| 3 | Near the corner | 328 | 77.1 mm | −2.9 mm |
The same 80 mm gasket measured at three positions in an uncalibrated frame
A 2.9 mm error on an 80 mm part is a 3.6% error, well outside tolerance. That error is not random noise you can average away — it is systematic, it depends on position, and calibration removes it exactly.
The pinhole model
Strip a camera down to its simplest form: a box with a tiny hole. Light from a point in the world travels in a straight line through the hole and lands on the sensor behind it. Similar triangles give the whole model:
are the point’s position in the world, is the distance from the hole to the sensor, and is where it lands.
Try it. A part 100 mm wide sits 2,000 mm from a camera whose focal length is 800 pixels:
Move the part to 1,000 mm and it doubles to 80 pixels. Move it to 4,000 mm and it halves to 20 pixels.
That curve carries an important warning. Image size depends on the ratio , so a 100 mm part at 2,000 mm and a 200 mm part at 4,000 mm both measure 40 pixels. One camera cannot separate size from distance. The workshop gets away with it only because the camera is on a fixed stand, so is known and constant.
focal length in pixels The focal length expressed in sensor pixels rather than millimetres. It combines the physical lens focal length with the pixel pitch, and it is what calibration actually recovers.The intrinsic matrix
The pinhole equations assume the image origin sits at the optical axis. In a real image, is the top-left corner. Two more terms fix that, and the whole thing packs into a 3×3 matrix:
| # | Parameter | Meaning | Typical value on a 1920×1080 phone |
|---|---|---|---|
| 1 | Focal length in horizontal pixels | 1240 | |
| 2 | Focal length in vertical pixels | 1238 | |
| 3 | Principal point, horizontal | 960 (near image centre) | |
| 4 | Principal point, vertical | 540 (near image centre) |
The four intrinsic parameters
and differ slightly because sensor pixels are not perfectly square. land near the image centre but rarely exactly on it, because the sensor is never mounted perfectly concentric with the lens.
Project a real point. The part’s corner sits at mm, mm, mm:
So that corner appears at pixel .
The intrinsic matrix also tells you the field of view:
A useful sanity check. If calibration returns an FOV of 140° for a normal phone lens, the calibration is wrong.
graph LR A["World point<br/>(X, Y, Z)"] -->|"extrinsics R, t"| B["Camera coordinates"] B -->|"divide by Z"| C["Normalised<br/>(x, y)"] C -->|"distortion k1..k3, p1, p2"| D["Distorted<br/>(x_d, y_d)"] D -->|"intrinsics K"| E["Pixel (u, v)"]
Extrinsics ( and ) say where the camera is in the world; they change every time the camera moves. Intrinsics () and distortion describe the camera itself; they stay fixed as long as you do not change the lens or zoom. Calibration recovers all of them, but only the intrinsics and distortion are worth saving.
Distortion, worked out radius by radius
A real lens is not a pinhole. It bends light more at the edges, so straight lines bow. The standard model corrects a point in normalised coordinates by a radial factor:
Take a typical phone lens with and , and a point at normalised :
In pixels with and , the ideal position is , but the actual position is . The point is pulled 29 pixels toward the centre.
Now compute the same displacement across the whole radius:
| # | Radius | Radial shift (px) target | Where in a 1920×1080 frame | |
|---|---|---|---|---|
| 1 | 0.1 | −0.00249 | −0.31 | very near centre |
| 2 | 0.2 | −0.00987 | −2.45 | inner quarter |
| 3 | 0.3 | −0.02185 | −8.13 | mid frame |
| 4 | 0.4 | −0.03795 | −18.8 | outer third |
| 5 | 0.5 | −0.05750 | −35.7 | near the short edge |
| 6 | 0.6 | −0.07963 | −59.2 | outer edge |
| 7 | 0.7 | −0.10329 | −89.7 | corners |
Radial displacement for k1=-0.25, k2=0.08, fx=1240. Distortion is invisible at the centre and enormous at the corners.
The shape of that curve is the practical lesson. At the error is a third of a pixel and you would never notice. At it is 90 pixels. If you only ever measure things in the middle 20% of the frame, you can skip calibration. For anything else, you cannot.
| # | Coefficient | Name | Sign and effect |
|---|---|---|---|
| 1 | First radial | Negative = barrel (bulges out). Positive = pincushion. | |
| 2 | Second radial | Corrects the residual at large radius | |
| 3 | Third radial | Only needed for fisheye and very wide lenses | |
| 4 | Tangential | Corrects a sensor not perfectly parallel to the lens; usually tiny |
The five standard distortion coefficients
Look at the corners. In the ideal grid they are filled; under barrel distortion they are empty, because the corner content has been pulled toward the centre. That is exactly the 90-pixel shift from the table above.
Calibrating with a checkerboard
A checkerboard works because its corners are detectable to sub-pixel precision and their real-world spacing is known exactly. Every corner gives two equations, so a 9×6 board provides 54 corners and 108 equations per photo.
import cv2
import numpy as np
import glob
PATTERN = (9, 6) # inner corners, not squares
SQUARE_MM = 25.0 # measure your printed board, do not assume
# Real-world corner positions, z = 0 because the board is flat
objp = np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2)
objp *= SQUARE_MM
obj_points, img_points = [], []
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
for path in glob.glob("calib/*.jpg"):
gray = cv2.cvtColor(cv2.imread(path), cv2.COLOR_BGR2GRAY)
found, corners = cv2.findChessboardCorners(gray, PATTERN, None)
if not found:
print(f"no board in {path}")
continue
corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
obj_points.append(objp)
img_points.append(corners)
rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(
obj_points, img_points, gray.shape[::-1], None, None)
print(f"RMS reprojection error: {rms:.3f} px")
print(f"fx={K[0,0]:.1f} fy={K[1,1]:.1f} cx={K[0,2]:.1f} cy={K[1,2]:.1f}")
print(f"distortion: {dist.ravel()}")
np.savez("calibration.npz", K=K, dist=dist)
Note SQUARE_MM = 25.0. Measure the printed board with a ruler. Printers scale to fit page margins, and a board you believe is 25 mm but is actually 24.2 mm makes every distance you compute 3% wrong, with no other symptom.
How to shoot the calibration photos
| # | Rule | Why it matters |
|---|---|---|
| 1 | Take 15–25 photos | Fewer than 10 leaves the solution underdetermined |
| 2 | Tilt the board 20°–45° in different directions | Front-on views cannot separate focal length from distance |
| 3 | Cover all four corners of the frame | Distortion is only observable where it is large |
| 4 | Fill 30–60% of the frame with the board | Too small gives imprecise corners; too large misses the edges |
| 5 | Keep the board perfectly flat | Tape it to glass or a clipboard — a bent board corrupts everything |
| 6 | Lock focus and zoom | Both change fx, so a changing lens is a changing camera |
| 7 | Light it evenly, no glare | Specular highlights break corner detection |
Calibration photo checklist. Most bad calibrations come from breaking rows 2 or 3.
The tilt requirement is the one people get wrong. Photograph the board flat-on twenty times and the solver cannot tell a long lens far away from a short lens up close — both produce the same image. Tilting breaks that ambiguity, because perspective foreshortening depends on the focal length in a way scale alone does not.
Reading the reprojection error
calibrateCamera returns an RMS error in pixels: on average, how far the model’s predicted corner positions are from the detected ones.
| # | RMS error | Verdict target | Likely cause |
|---|---|---|---|
| 1 | < 0.3 px | Excellent | — |
| 2 | 0.3–0.5 px | Good, usable for measurement | — |
| 3 | 0.5–1.0 px | Acceptable for alignment, not for metrology | Too few angles, or blur |
| 4 | 1.0–2.0 px | Poor | Bent board, wrong square size, or autofocus moved |
| 5 | > 2.0 px | Failed | Wrong pattern size, or a mis-detected board slipped in |
Interpreting the RMS reprojection error
# Find which individual photos are dragging the error up
for i in range(len(obj_points)):
proj, _ = cv2.projectPoints(obj_points[i], rvecs[i], tvecs[i], K, dist)
err = cv2.norm(img_points[i], proj, cv2.NORM_L2) / len(proj)
print(f"image {i}: {err:.3f} px")
If one photo shows 2.5 px while the rest sit at 0.3 px, drop it and recalibrate. It is almost always a blurred frame or a board that flexed.
Undistorting and measuring
data = np.load("calibration.npz")
K, dist = data["K"], data["dist"]
img = cv2.imread("gasket.jpg")
h, w = img.shape[:2]
newK, roi = cv2.getOptimalNewCameraMatrix(K, dist, (w, h), alpha=0)
undistorted = cv2.undistort(img, K, dist, None, newK)
x, y, rw, rh = roi
undistorted = undistorted[y:y+rh, x:x+rw]
alpha controls what happens to the edges. At alpha=0 the output is cropped so every pixel is valid — use this for measurement. At alpha=1 all original pixels are kept and the borders curve, leaving black wedges — use this when you must not lose any field of view.
From pixels to millimetres
Undistortion removes the position-dependent error. Converting to real units needs one known reference in the scene, at the same distance as what you are measuring.
Place a 100 mm calibration bar next to the gasket. It spans 412 pixels:
The gasket now measures 340 pixels in the undistorted image:
| # | Position in frame | Measured (px) | Before calibration | After calibration target | True |
|---|---|---|---|---|---|
| 1 | Centre | 340 | 79.9 mm | 82.5 mm | 82.5 mm |
| 2 | Halfway out | 336 | 79.0 mm | 82.4 mm | 82.5 mm |
| 3 | Near corner | 328 | 77.1 mm | 82.3 mm | 82.5 mm |
The same gasket, three positions. After calibration the reading is consistent to 0.2 mm.
The spread collapsed from 2.8 mm to 0.2 mm. That is the entire value of calibration in one row.
Perspective correction without calibration
There is a shortcut for one common case. If you are photographing a flat object and you know the real-world coordinates of four points on it, you can rectify to a top-down view with a homography — no calibration needed.
# Four corners of a document as they appear in the photo
src = np.float32([[214, 88], [1102, 141], [1183, 856], [147, 782]])
# Where they should be: an A4 page at 2 px per mm
W, H = 420, 594
dst = np.float32([[0, 0], [W, 0], [W, H], [0, H]])
M = cv2.getPerspectiveTransform(src, dst)
flat = cv2.warpPerspective(img, M, (W, H))
Four point pairs give exactly eight equations for the homography’s eight degrees of freedom, so getPerspectiveTransform solves it directly with no RANSAC needed.
| Question | Full calibration | Four-point homography |
|---|---|---|
| Setup needed | ~20 checkerboard photos | Four clicked corners |
| Removes lens distortion | Yes | No |
| Works on non-flat scenes | Yes | No — flat objects only |
| Gives real-world units | Yes, with known Z | Yes, if the object size is known |
| Valid after the camera moves | Yes | No, must be redone |
| Needed for stereo or 3D | Yes | No |
| Time to set up | 20 minutes | 1 minute |
- You need measurements accurate to better than 1%
- Objects appear anywhere in the frame, not just the centre
- You are doing stereo depth, 3D reconstruction, or pose estimation
- The lens is wide-angle, action-camera, or fisheye
- You will place virtual content in the scene (AR)
- You only classify or detect — a network does not care about distortion
- Everything you measure sits in the middle 20% of the frame
- The object is flat and four known corners are enough
- The camera is a long lens with negligible distortion
Common calibration failures
| # | Symptom | Cause | Fix |
|---|---|---|---|
| 1 | findChessboardCorners returns False on most photos | Pattern size counts squares, not inner corners | A board with 10×7 squares has 9×6 inner corners |
| 2 | RMS error above 2 px | A blurred or bent board slipped into the set | Print per-image errors and drop the worst |
| 3 | Undistorted image looks more curved, not less | Board never reached the frame corners | Reshoot with the board at all four corners |
| 4 | Distances all off by the same percentage | SQUARE_MM does not match the printed board | Measure the print with a ruler and rerun |
| 5 | Calibration drifts between sessions | Autofocus or zoom changed | Lock focus and zoom, recalibrate after any change |
| 6 | fx and fy differ by more than 5% | Bad solution, usually from too little tilt | Add tilted views and reshoot |
Calibration failures by symptom
Practice task
Print a 9×6 checkerboard and tape it flat to a clipboard.
- Measure one square with a ruler. Write down the real number, not the intended one.
- Take 20 photos: varied tilt, varied distance, board reaching all four frame corners.
- Run the calibration. Record the RMS error, , , , , and .
- Compute the field of view from and check it looks sensible for your lens.
- Print the per-image error. Drop anything above 1.0 px and recalibrate. How much did RMS improve?
- Now recalibrate using only 5 photos, all flat-on. Compare to the good calibration.
- Photograph a ruler at the frame centre and again at the corner. Measure a 100 mm span in pixels, before and after undistortion.
Step 6 is the eye-opener. The flat-on calibration reports a small reprojection error and looks fine, but its can be off by 20% or more. A low reprojection error does not mean a correct calibration — it only means the model fits the data you gave it, and flat-on data cannot constrain focal length.
Summary
The pinhole model reduces a camera to . A 100 mm part at 2,000 mm with px lands 40 pixels wide, and because only the ratio matters, one camera can never separate size from distance.
The intrinsic matrix holds . You projected a point at mm to pixel and derived a 75.5° field of view from .
Radial distortion follows . You computed a factor of 0.9425 at , a 29-pixel inward shift, and tabulated the displacement rising from 0.31 px at to 89.7 px at . That curve is why the gasket shrank as it moved toward the corner, and why undistortion collapsed the measurement spread from 2.8 mm to 0.2 mm.
Calibrate from about 20 tilted checkerboard photos covering the whole frame. Check the RMS error, but remember it only tells you the model fits your photos — flat-on views produce a small error and a badly wrong focal length.
- The pinhole model is x = fX/Z, so image size depends only on the ratio of size to distance.
- One camera cannot separate a small near object from a large far one.
- K holds fx, fy, cx, cy. Field of view is 2·arctan(w / 2fx) — use it as a sanity check.
- Distortion is negligible in the middle of the frame and huge at the corners: 0.3 px at r=0.1, 90 px at r=0.7.
- Negative k1 is barrel distortion, which pulls corners inward. Positive is pincushion.
- Measure your printed checkerboard square with a ruler; printers rescale silently.
- Pattern size means inner corners, not squares. A 10×7-square board is 9×6.
- Tilt the board 20–45° across the photo set, or focal length stays unidentifiable.
- RMS under 0.5 px is good, but a low error does not prove the calibration is correct.
- For a flat object with four known corners, a homography rectifies it without any calibration.
What comes next
Calibration gives you one camera’s geometry, which removes distortion and enables measurement at a known distance — but still not depth. Epipolar geometry and stereo vision adds a second camera and turns the disparity between two views into an actual distance in millimetres. Calibration is a hard prerequisite there: stereo depth is only as accurate as the intrinsics feeding it.