3D Vision and Depth

Calibrating a camera with a chessboard

Photographing a printed chessboard from many angles lets you solve for a camera's focal lengths, centre and lens distortion — and one angle is never enough, however good the error looks.

On this page 9
  1. The short answer
  2. The analogy you have already lived
  3. Why a chessboard
  4. What is being solved
  5. The rule everybody breaks
  6. The trap that hides the mistake
  7. Where you have already seen it
  8. Remember this
  9. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

Calibration measures your camera's own numbers by photographing something whose shape you already know.

The analogy you have already lived

Think of checking a new measuring tape against a one-metre tile you already trust. You lay the tape on the tile. If the tape reads 98 cm, you now know your tape is short, and you can correct every future measurement.

Calibration does exactly this. The chessboard is the tile you trust. The camera is the tape being checked.

Why a chessboard

The pattern is chosen for one reason: computers find its corners very precisely.

A corner where four black-and-white squares meet is unambiguous down to a fraction of a pixel. There is no such precise point on a face, a wall or a coffee cup.

And you know the truth about the board. The squares are 25 mm. The corners lie on a perfect flat grid. So for every corner you have a matched pair: where it is in the world, and where it landed in the picture.

What is being solved

You collect hundreds of those pairs. Then you ask: what camera numbers would turn these known world points into these observed pixels?

   known 3D grid  +  observed pixels
              |
      solve for the camera numbers that connect them
              |
   focal lengths, picture centre, lens distortion

The rule everybody breaks

One photo is never enough. You need many, from genuinely different angles.

Photograph the board flat-on from the front, ten times, and the answer comes back badly wrong. Every one of those photos gives the same information. The solver ends up with many equations that all say the same thing.

Tilt the board. Left, right, up, down, near the corners of the frame. Each new angle adds genuinely new information.

The code below shows this with real numbers. From nine varied views the focal length comes back within a fraction of a percent. From one view it is out by a factor of five.

The trap that hides the mistake

The solver reports an error, called reprojection error, in pixels. Small is good. Under one pixel looks excellent.

Here is the catch. A single view gives a lower error than nine views, and is far more wrong. With one view there is less to explain and more freedom to explain it. So it fits beautifully and means nothing.

That is a lesson far bigger than cameras. A model that fits its data perfectly has not proved it is right. It has proved it had enough freedom.

Where you have already seen it

  • A VR headset asking you to look around before tracking works.
  • A robot vacuum's first mapping run.
  • Stitching a panorama, which needs the lens bend removed first.
  • Any measuring app that asks you to move the phone slowly.

Remember this

  • Calibration finds your camera's numbers using a known shape.
  • You need many views at genuinely different angles, not many similar ones.
  • A low reprojection error from few views is a warning, not a success.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install opencv-python==4.10.0.84 numpy==1.26.4

We do not need a printed board or a webcam. We render the board as a real camera with known numbers would see it, run the real corner detector on those rendered images, and then calibrate. Because we set the truth ourselves, we can score the answer instead of admiring it.

Calibration end to end, with the answer checked

calibrate.py
import cv2
import numpy as np

W, H = 640, 480
COLS, ROWS = 9, 6                 # inner corners, not squares
SQ = 0.025                        # 25 mm squares

K_TRUE = np.array([[520.0, 0.0, 318.0],
                   [0.0, 522.0, 241.0],
                   [0.0, 0.0, 1.0]])
DIST_TRUE = np.zeros(5)           # a well-behaved lens, to keep the demo readable

# The board in its own coordinates: z = 0, one point per inner corner.
board = np.zeros((ROWS * COLS, 3), np.float32)
board[:, :2] = np.mgrid[0:COLS, 0:ROWS].T.reshape(-1, 2) * SQ

# A printed chessboard picture, with a white margin so the detector can see the edge.
PIX = 60
tile = np.indices((ROWS + 1, COLS + 1)).sum(axis=0) % 2
pattern = (np.kron(tile, np.ones((PIX, PIX))) * 255).astype(np.uint8)
pattern = cv2.copyMakeBorder(pattern, PIX, PIX, PIX, PIX, cv2.BORDER_CONSTANT, value=255)

POSES = [                                       # rotation vector, translation vector
    ((0.05, 0.02, 0.0), (-0.10, -0.07, 0.32)),
    ((0.35, -0.25, 0.05), (-0.12, -0.06, 0.34)),
    ((-0.30, 0.30, -0.10), (-0.09, -0.08, 0.33)),
    ((0.40, 0.35, 0.15), (-0.11, -0.05, 0.36)),
    ((-0.45, -0.20, 0.20), (-0.08, -0.09, 0.35)),
    ((0.20, 0.45, -0.20), (-0.13, -0.06, 0.33)),
    ((-0.15, -0.40, 0.10), (-0.10, -0.07, 0.38)),
    ((0.50, -0.10, -0.05), (-0.09, -0.08, 0.31)),
    ((-0.35, 0.15, 0.25), (-0.12, -0.07, 0.35)),
]


def render(rvec, tvec):
    """Draw what the camera would see: warp the printed board by its pose."""
    R, _ = cv2.Rodrigues(np.array(rvec, np.float64))
    t = np.array(tvec, np.float64).reshape(3, 1)
    # A planar board means a homography: board-plane metres -> pixels.
    Hp = K_TRUE @ np.hstack([R[:, :1], R[:, 1:2], t])
    # The printed picture is in its own pixels, offset by the white margin.
    to_metres = np.array([[SQ / PIX, 0, -SQ], [0, SQ / PIX, -SQ], [0, 0, 1]])
    return cv2.warpPerspective(pattern, Hp @ to_metres, (W, H), borderValue=255)


object_points, image_points, found = [], [], 0
for rvec, tvec in POSES:
    view = render(rvec, tvec)
    ok, corners = cv2.findChessboardCorners(view, (COLS, ROWS))
    if ok:
        found += 1
        corners = cv2.cornerSubPix(
            view, corners, (5, 5), (-1, -1),
            (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-4))
        object_points.append(board)
        image_points.append(corners)

print(f"rendered {len(POSES)} views, detector found the board in {found}")
print(f"each view gives {COLS} x {ROWS} = {COLS * ROWS} corners")
print(f"total corner observations: {found * COLS * ROWS}")
print(f"unknowns: 4 intrinsics + 5 distortion + 6 per view = {9 + 6 * found}\n")

print("one rendered view, coarsely sampled so it fits on screen:")
view = render(*POSES[1])
for y in range(0, H, 20):
    print("   " + "".join("#" if view[y, x] < 128 else "." for x in range(0, W, 8)))

rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(
    object_points, image_points, (W, H), None, None)

print(f"\nreprojection error reported by calibrateCamera: {rms:.4f} pixels")
print("\n            fx        fy        cx        cy")
print(f"true    {K_TRUE[0, 0]:9.2f} {K_TRUE[1, 1]:9.2f} {K_TRUE[0, 2]:9.2f} {K_TRUE[1, 2]:9.2f}")
print(f"found   {K[0, 0]:9.2f} {K[1, 1]:9.2f} {K[0, 2]:9.2f} {K[1, 2]:9.2f}")
print(f"error   {K[0, 0] - K_TRUE[0, 0]:9.2f} {K[1, 1] - K_TRUE[1, 1]:9.2f} "
      f"{K[0, 2] - K_TRUE[0, 2]:9.2f} {K[1, 2] - K_TRUE[1, 2]:9.2f}")
print("\ndistortion found (true value is all zeros):")
print("   ", np.round(dist.ravel(), 4))

print("\nwhat happens with only ONE view:")
rms1, K1, _, _, _ = cv2.calibrateCamera(
    object_points[:1], image_points[:1], (W, H), None, None)
print(f"   reprojection error {rms1:.4f} px  (lower than with nine views)")
print(f"   fx {K1[0, 0]:8.2f}  fy {K1[1, 1]:8.2f}  cx {K1[0, 2]:8.2f}  cy {K1[1, 2]:8.2f}")
print("   a low reprojection error on one view means overfitting, not accuracy")
Output
rendered 9 views, detector found the board in 8
each view gives 9 x 6 = 54 corners
total corner observations: 432
unknowns: 4 intrinsics + 5 distortion + 6 per view = 57

one rendered view, coarsely sampled so it fits on screen:
   ................................................................................
   ................................................................................
   ................................................................................
   ................................................................................
   ................................................................................
   ................................................................................
   ................................................................................
   ................................................................................
   .................#####.....#####....####.....##.................................
   .................#####.....####.....####....####....####........................
   ......................#####....#####....####....####...####.....................
   ......................#####....#####....####....###....####.....................
   ..................####.....####....####....####....####.........................
   ......................#####....####....####....####...####......................
   ......................#####....####....####....###....###.......................
   ..................####.....####....####....###....###...........................
   ......................#####....####....###....###....###........................
   ......................####.....###....####.###...###............................
   ..................####....####....####....###....###............................
   ................................................................................
   ................................................................................
   ................................................................................
   ................................................................................
   ................................................................................

reprojection error reported by calibrateCamera: 0.1798 pixels

            fx        fy        cx        cy
true       520.00    522.00    318.00    241.00
found      519.89    521.84    317.53    240.69
error       -0.11     -0.16     -0.47     -0.31

distortion found (true value is all zeros):
    [ 0.0074 -0.058  -0.0002 -0.0003  0.1075]

what happens with only ONE view:
   reprojection error 0.1644 px  (lower than with nine views)
   fx   106.50  fy   106.97  cx   313.52  cy   224.39
   a low reprojection error on one view means overfitting, not accuracy

Reading the output carefully

Nine views were rendered and eight were detected. One pose tilted the board far enough that the detector lost it. This happens constantly with real boards, which is why calibration code must check the return flag rather than assume success. Silently appending an empty corner list is a common bug that produces confident nonsense.

432 observations against 57 unknowns. That ratio is the point of the whole exercise. Each corner gives two equations, so there are 864 equations for 57 unknowns — a fifteen-fold overdetermination. That redundancy is what averages the noise out.

Focal lengths recovered to -0.11 and -0.16 pixels. Roughly 0.03% error, from sub-pixel corner detection on rendered images. On real photographs, with a real printed board that is not perfectly flat and a lens that has real distortion, expect errors an order of magnitude larger. This is the best case, not the typical one.

The principal point is worse than the focal length: -0.47 and -0.31 pixels. That ordering is normal and is a real property of the problem. The principal point is weakly constrained, because moving it is nearly compensated by a small rotation of every view. Calibration reports on real cameras almost always show cx, cy as the least stable parameters.

The distortion coefficients are not zero, and the true answer is. k1 = 0.0074, k2 = -0.058, k3 = 0.1075, on a lens with no distortion at all. The three radial terms are highly correlated — a small positive k1 can be cancelled by a negative k2 and a positive k3 over the range of radii the board covers. The individual numbers are meaningless; only their combined effect over the observed radii is constrained. This is why cv2.CALIB_FIX_K3 is standard advice for ordinary lenses.

The one-view result is the headline. fx = 106.50 against a true 520.00, a factor of five out. And its reprojection error, 0.1644, is lower than the nine-view error of 0.1798.

Sit with that for a moment. The badly wrong answer scores better on the metric. With one view there are 108 equations and 15 unknowns, and — more importantly — a single planar view leaves the focal length and the board's distance almost perfectly traded against each other. A small board close up and a large board far away produce the same picture, exactly as the camera model predicted. Multiple orientations are what break the tie.

Doing it with a real board

python
ok, corners = cv2.findChessboardCornersSB(gray, (COLS, ROWS),
                                          flags=cv2.CALIB_CB_EXHAUSTIVE)

findChessboardCornersSB is newer, more robust to blur and uneven lighting, and does its own sub-pixel refinement. Prefer it over findChessboardCorners plus cornerSubPix for new code.

Practical checklist for a real session:

  • Print the board and stick it to something rigid. A board held in the hand bows, and the bow is absorbed into the distortion coefficients.
  • Take 15 to 25 views. More than 30 adds little.
  • Cover the frame. The corners of the image constrain distortion, and a board that only ever appears in the centre leaves distortion unmeasured where it is largest.
  • Tilt to roughly 30 to 45 degrees in both axes. Beyond that, detection starts failing.
  • Keep the board filling a good fraction of the frame, and vary the distance.
  • Measure your printed square size with a ruler. A "25 mm" square printed at 96% scale makes every distance you later compute wrong by 4%.

Common mistakes

Passing the wrong grid size. (COLS, ROWS) counts inner corners. A board with 10 by 7 squares has 9 by 6 inner corners. Swapping the two silently produces a valid-looking calibration with a transposed board.

Using a symmetric board. A board with an equal number of rows and columns has a 90-degree rotational ambiguity, so different views can be matched to the object points inconsistently. Always use an odd-by-even grid, as here.

Only front-on views. The failure mode demonstrated above.

Judging quality by RMS alone. Also inspect per-view errors: cv2.projectPoints with the recovered parameters, compared against the detected corners, per image. One bad view dominating the residual is common and easy to drop.

Calibrating at one resolution and using at another. Intrinsics scale with the image. If you calibrate at 1280x960 and run at 640x480, halve all four numbers.

Ignoring focus and zoom. Changing either changes the intrinsics. A calibration is valid only for the lens configuration it was made with, which is why autofocus cameras are difficult to calibrate well.

Try it yourself

Delete all but the first three poses, keeping them nearly front-on by shrinking the rotation vectors to a tenth. Rerun. The reprojection error will stay small and fx will drift wildly. That is the whole lesson, reproducible in one edit.

What to learn next

Researcher — Mathematics and papers.

Zhang's method

Zhang (2000), A flexible new technique for camera calibration, TPAMI 22(11), is what cv2.calibrateCamera implements. The insight is that a planar target makes the problem tractable in closed form before any nonlinear refinement.

Set the board's plane at $Z = 0$. Then

$$ s\,\mathbf{x} = K \begin{bmatrix} \mathbf{r}_1 & \mathbf{r}_2 & \mathbf{r}_3 & \mathbf{t} \end{bmatrix} \begin{bmatrix} X \ Y \ 0 \ 1 \end{bmatrix} = \underbrace{K \begin{bmatrix} \mathbf{r}_1 & \mathbf{r}2 & \mathbf{t} \end{bmatrix}}{\textstyle H} \begin{bmatrix} X \ Y \ 1 \end{bmatrix} $$

So each view yields a homography $H = [\mathbf{h}_1\ \mathbf{h}_2\ \mathbf{h}_3] = \lambda K [\mathbf{r}_1\ \mathbf{r}_2\ \mathbf{t}]$, estimable by DLT from four or more correspondences.

Because $\mathbf{r}_1$ and $\mathbf{r}_2$ are orthonormal columns of a rotation matrix, writing $B = K^{-\top}K^{-1}$ gives two linear constraints per view:

$$ \mathbf{h}_1^\top B \, \mathbf{h}_2 = 0, \qquad \mathbf{h}_1^\top B \, \mathbf{h}_1 = \mathbf{h}_2^\top B \, \mathbf{h}_2 $$

$B$ is symmetric with 6 unknowns, defined up to scale, so 5 degrees of freedom. Three views give six constraints, enough to solve for $B$ linearly, after which $K$ follows by Cholesky decomposition of $B^{-1}$.

This is why one view fails and it is not a numerical accident. One view supplies two constraints on a 5-parameter object. With zero skew and known aspect ratio you can scrape by with two views; three is the true minimum, and the recommendation of 15 to 25 is about noise, not rank.

$B$ is the image of the absolute conic, $\omega$, introduced in camera models and intrinsics. The two constraints above say that $\mathbf{h}_1$ and $\mathbf{h}_2$ are the vanishing points of two orthogonal board directions.

The refinement stage

The linear solution is not the answer, because it minimises an algebraic quantity with no geometric meaning. Zhang follows it with maximum-likelihood refinement over all parameters jointly:

$$ \min_{K, \mathbf{k}, {R_i, \mathbf{t}i}} \sum{i=1}^{n}\sum_{j=1}^{m} \bigl\lVert \mathbf{x}_{ij} - \hat{\mathbf{x}}(K, \mathbf{k}, R_i, \mathbf{t}_i, \mathbf{X}_j) \bigr\rVert^2 $$

Where $n$ is the number of views, $m$ the corners per view, and $\mathbf{k}$ the distortion coefficients. This is a sparse nonlinear least-squares problem solved by Levenberg-Marquardt, and it is a small instance of bundle adjustment. Under isotropic Gaussian corner noise the objective is the negative log-likelihood, which is why reprojection error is the correct cost rather than a convenient one.

The reported RMS is

$$ \mathrm{RMS} = \sqrt{\frac{1}{2nm}\sum_{i,j} \lVert \mathbf{x}{ij} - \hat{\mathbf{x}}{ij} \rVert^2} $$

It is a training error over the same points that determined the parameters. It has no cross-validation and is therefore not an estimate of accuracy on new data. This is the precise sense in which the one-view result above is overfitting: 108 residuals fitted with 15 free parameters plus a near-degenerate direction in parameter space.

A better diagnostic is a held-out view: calibrate on $n-1$ views, then compute the reprojection error of the remaining one with its pose solved by solvePnP under the fixed intrinsics. That number behaves like a test error and tracks true accuracy far better.

Degeneracies and conditioning

Three configurations that produce a good RMS and wrong parameters:

  • All views parallel. The plane normal is constant, so all homographies impose the same constraint on $B$. Rank-deficient.
  • All views at the same distance and orientation, differing only by translation in the image plane. Adds nothing beyond the first view.
  • Board too small in frame. The radii covered are a small subset of the image, so distortion is extrapolated everywhere else. This is the reason calibration guidance insists on covering the image corners.

The parameter covariance is available: cv2.calibrateCameraExtended returns per-parameter standard deviations. Reporting them alongside the values is good practice and instantly reveals a degenerate session.

Alternative targets

ChArUco boards combine a chessboard with ArUco markers in the white squares. Each marker is individually identifiable, so partial occlusion and boards running off the edge of the frame are still usable — a large practical gain over plain chessboards. cv2.aruco.CharucoDetector handles them, and it is the recommended target for new work.

Circle grids (cv2.findCirclesGrid, symmetric or asymmetric) locate centroids rather than corners. Centroid estimation is more robust to blur; the drawback is that a circle's projected centroid is not the projection of the circle's centre under perspective, introducing a small systematic bias that matters at high precision.

Self-calibration from natural scenes avoids a target entirely, using the Kruppa equations or a stratified projective-to-metric upgrade. It is far less accurate and is the right choice only when you cannot get a target in front of the camera — archive footage, for instance.

What to learn next