Robotics Engineering · Lesson 5 of 26

Calibration
Intrinsic · Extrinsic · Temporal

The silent multiplier on every metric number your robot reports — and the only fault class that can be catastrophically wrong while every internal health check reads green.

Prerequisites: matrix multiply + the pinhole camera. Everything else is built here.
7
Chapters
8
Simulations
4
Code Labs

Chapter 0: The Typo

A perception lead at a warehouse-robotics company tells this story to every engineer who joins her team. She does not open with a lecture. She opens with a text file.

camera_front.yaml
image_width: 1280
image_height: 960
camera_matrix: [800.14, 0.0, 319.62, 0.0, 800.09, 240.31, 0.0, 0.0, 1.0]
distortion:    [-0.2814, 0.0977, 0.0001, -0.0002, -0.0163]
rms_reproj_px: 0.284
n_images:      22
board:
  rows: 6
  cols: 9
  square_size_m: 0.023        # <-- somebody typed this

"This shipped," she says. "Two hundred and forty robots. Six weeks later the field team reported that our warehouses were all coming back about eight percent too small, our grasp offsets were eight percent short, and every logged trajectory was eight percent shorter than the tape measure. Here is the awkward part. Our calibration monitor never fired. The reprojection error was 0.284 pixels the whole time, exactly what it was on day one. Tell me what happened, and tell me what alarm we should have had."

The squares on that board are 25.0 millimetres. Somebody typed 23.0.

23.0 / 25.0 = 0.920. Every metric quantity the fleet ever produced was multiplied by 0.920, forever, silently. A 1.200 m aisle measured 1.200 × 0.920 = 1.104 m. A mug sitting 0.2609 m from the gripper was reported at 0.2609 × 0.920 = 0.240 m, so the gripper stopped 20.9 mm short and closed on air. A 40.0 m × 25.0 m warehouse was mapped as 36.8 m × 23.0 m. And none of that has a residual.

Why the reprojection error did not move

This is the heart of the story, so let us be exact rather than hand-wavy. We can compute the whole thing.

A calibration from a planar board fits, per image, a homography — the 3×3 matrix H that maps a point on the board plane to its pixel. Do not take that on faith; it falls out of the pinhole model in two lines, and the two lines matter because the whole argument hangs off them.

Line one — the general projection. A 3D point in the board's own frame, written homogeneously, reaches a pixel through

s [ u   v   1 ]T = K [ r1   r2   r3   t ] [ X   Y   Z   1 ]T

Line two — put the board frame on the board. You are free to choose where the board's coordinate origin lives, so put it on the board surface with the z axis along the board normal. Then every printed corner has Z = 0, exactly. The r3 column is multiplied by that zero and vanishes from the product — not approximately, not to first order, identically. What is left is a 3×3 matrix acting on a 3-vector:

s [ u   v   1 ]T = K [ r1   r2   t ] [ X   Y   1 ]T

Where λ comes from. That s on the left is the per-point depth, and you never see it: the pixel you measure is the ratio of the first two components to the third, so s cancels on division. Which means a homography estimated from pixel correspondences is only ever recovered up to an arbitrary global scale — multiply the whole matrix by any non-zero c and every predicted pixel is bit-identical. In practice the DLT returns the smallest right singular vector of the design matrix, which numpy hands back with unit norm, so c is whatever the SVD happened to pick. Writing that unknown as λ gives the standard factorisation:

H = λ · K [ r1   r2   t ]

where K is the intrinsic matrix, r1 and r2 are the first two columns of the board's rotation, t is the board's position in the camera frame, and λ is that unknown scalar. This is the setup in Zhang, "A Flexible New Technique for Camera Calibration," IEEE TPAMI 22(11), 2000 — the paper that made calibration a ten-minute desk procedure instead of a machine-shop one, and the paper whose gauge you are about to break.

Note what λ already tells you: it is not a nuisance you can wave away, because it sits in front of t. Recovering a metric translation means pinning λ down, and the only thing in the problem that can pin it down is the fact that r1 is a unit vector. Hold that thought for four paragraphs.

Now suppose you scale the board model by a factor s — you tell the software the squares are 23 mm when they are really 25 mm, so s = 0.920. The detected corners in the image do not move; they are photons. Only the model coordinates you associate with them shrink: the corner you called (0.100, 0.075) you now call (0.092, 0.069). To map smaller model coordinates onto the same pixels, the first two columns of H must grow by exactly 1/s, and the third column, which multiplies the constant 1, stays put:

H′ [ sX   sY   1 ]T = H [ X   Y   1 ]T   ⇒   H′ = [ h1/s   h2/s   h3 ]

That substitution is exact for every corner simultaneously, so the DLT does not have to compromise anywhere: H′ explains all 54 corners as perfectly as H did. There is no residual to pay for it. Good. But the interesting question is what the intrinsics do, and for that we need the actual constraints rather than a hand-wave.

The two constraints, and the cancellation

Read λ out of the factorisation: the first two columns of H are λK r1 and λK r2, so

r1 = (1/λ) K−1 h1      r2 = (1/λ) K−1 h2

R is a rotation, so its columns are orthonormal. That gives exactly two usable facts — they are usable precisely because they survive the unknown λ:

r1T r2 = 0      r1T r1 = r2T r2 = 1

Substitute, and write B = K−TK−1 (the image of the absolute conic — a symmetric 3×3, so six unknowns, five after the overall scale):

(1/λ2) h1T B h2 = 0   ⇒   h1T B h2 = 0
(1/λ2) h1T B h1 = (1/λ2) h2T B h2   ⇒   h1T B h1 = h2T B h2

The 1/λ2 divided out of the first equation because the right-hand side is zero, and out of the second because it appears on both sides. That is the mechanism that makes Zhang's method work at all — and it is about to become the mechanism that makes it blind.

Now feed it the mis-scaled H′. Substitute h1 → h1/s and h2 → h2/s into both constraints and turn the crank. Every term is bilinear in the two columns, so each picks up exactly one factor of 1/s from each side:

(h1/s)T B (h2/s) = (1/s2) · h1T B h2 = 0   ⇔   h1T B h2 = 0
(1/s2) h1T B h1 = (1/s2) h2T B h2   ⇔   h1T B h1 = h2T B h2

The first is an equation whose right-hand side is zero, and 1/s2 ≠ 0, so you may divide it away. The second has 1/s2 on both sides, so it cancels. Both constraints come back character-for-character identical to the ones you started with. Not "approximately unchanged," not "changed by a second-order term" — identical.

The linear-algebra form of the same statement is one line, and it is decisive. Zhang stacks those two equations per view into rows of a matrix V and solves Vb = 0 for the six entries of B. Every row of V is bilinear in h1 and h2, so the mis-scaled board produces

V′ = (1/s2) · V   ⇒   null(V′) = null(V)   ⇒   B′ = B   ⇒   K′ = K

Scaling a matrix by a non-zero constant does not move its null space. So K does not move at all. Neither does the distortion, which is fitted in normalised coordinates downstream of K. Neither does the reprojection residual: the whole reconstruction is a perfectly self-consistent smaller world, and it reprojects to precisely the same pixels.

So where did the 8% go? Into λ, and from λ straight into t. Recover λ the way everyone does, by imposing ‖r1‖ = 1:

λ = 1 / ‖ K−1 h1 ‖   ⇒   λ′ = 1 / ‖ K−1 (h1/s) ‖ = s · λ
t′ = λ′ K−1 h3 = s · λ K−1 h3 = s · t

Four symbols, and there is the whole field failure: every recovered translation is multiplied by s = 0.920, the intrinsics are untouched, and the residual is zero. The error lands entirely in λ, and therefore entirely in t — the metric distance from the camera to the board.

The compression worth memorising. Zhang's constraints are ratios of homography columns, and the board scale is a common factor of those columns. Ratios cannot see common factors. Everything else in this chapter is a consequence of that sentence.

From scratch, so you can run the claim instead of believing it

The paragraphs above are an argument. Here is the experiment, in numpy, with no cv2 anywhere: synthesise a board, project it through a camera whose intrinsics you know, then solve for those intrinsics twice — once telling the solver the truth and once telling it 23 mm. Everything is from scratch: the object points, the projection, the 108×9 DLT design matrix, the SVD, and Zhang's closed form for K.

python# synth_and_solve.py -- no cv2. Reproduces every number in the callout below.
import numpy as np

K_TRUE = np.array([[800., 0., 320.],
                   [0., 800., 240.],
                   [0.,   0.,   1.]])
SQ_TRUE = 0.025                      # the squares REALLY are 25.0 mm

# 5 board poses: (rx, ry, rz) rad about x,y,z then (tx, ty, tz) metres.
# View 0 is the one the callout quotes: |t| = sqrt(.02^2+.01^2+.60^2).
POSES = [( 0.20, -0.30,  0.05,  0.02, -0.01, 0.60),
         (-0.35,  0.25, -0.10, -0.05,  0.03, 0.55),
         ( 0.40,  0.35,  0.20,  0.01,  0.04, 0.70),
         (-0.15, -0.45, -0.25,  0.03, -0.02, 0.50),
         ( 0.30, -0.10,  0.35, -0.02,  0.05, 0.65)]

def rot(rx, ry, rz):
    Rx = np.array([[1,0,0],[0,np.cos(rx),-np.sin(rx)],[0,np.sin(rx),np.cos(rx)]])
    Ry = np.array([[np.cos(ry),0,np.sin(ry)],[0,1,0],[-np.sin(ry),0,np.cos(ry)]])
    Rz = np.array([[np.cos(rz),-np.sin(rz),0],[np.sin(rz),np.cos(rz),0],[0,0,1]])
    return Rz @ Ry @ Rx

def board(sq):
    """(54,3) float64 object points for a 6x9 board, Z identically 0."""
    gx, gy = np.meshgrid(np.arange(9), np.arange(6))
    return np.stack([gx.ravel()*sq, gy.ravel()*sq,
                     np.zeros(54)], axis=1).astype(np.float64)

def dlt(model_xy, uv):
    """Closed-form homography. (54,2) model + (54,2) pixels -> 3x3."""
    A = np.zeros((108, 9))                # two rows per corner
    for i, ((X, Y), (u, v)) in enumerate(zip(model_xy, uv)):
        A[2*i]   = [-X, -Y, -1,  0,  0,  0, u*X, u*Y, u]
        A[2*i+1] = [ 0,  0,  0, -X, -Y, -1, v*X, v*Y, v]
    _, _, Vt = np.linalg.svd(A)             # smallest right singular vector
    H = Vt[-1].reshape(3, 3)
    return H / H[2, 2]                     # fix the arbitrary lambda for printing

def v_ij(H, i, j):
    """Row of V such that v_ij . b == h_i^T B h_j.  Bilinear in cols i and j.
    THIS is where the 1/s^2 that cancels comes from."""
    return np.array([H[0,i]*H[0,j],
                     H[0,i]*H[1,j] + H[1,i]*H[0,j],
                     H[1,i]*H[1,j],
                     H[2,i]*H[0,j] + H[0,i]*H[2,j],
                     H[2,i]*H[1,j] + H[1,i]*H[2,j],
                     H[2,i]*H[2,j]])

def K_from_b(b):
    """Zhang's closed form: B = K^-T K^-1  ->  K."""
    B11, B12, B22, B13, B23, B33 = b
    v0  = (B12*B13 - B11*B23) / (B11*B22 - B12**2)
    lam = B33 - (B13**2 + v0*(B12*B13 - B11*B23)) / B11
    a   = np.sqrt(lam / B11)
    be  = np.sqrt(lam * B11 / (B11*B22 - B12**2))
    g   = -B12 * a**2 * be / lam
    u0  = g*v0/be - B13*a**2/lam
    return np.array([[a, g, u0], [0., be, v0], [0., 0., 1.]])

def synth_and_solve(square_size_m, n_views=5):
    """Photons come from SQ_TRUE. The MODEL comes from square_size_m.
    Returns (fx, ||t|| of view 0, mean reprojection residual in px)."""
    model = board(square_size_m)[:, :2]              # (54,2) BELIEVED metres
    mh    = np.hstack([model, np.ones((54, 1))])     # (54,3) homogeneous
    Hs, resid = [], []
    for p in POSES[:n_views]:
        P   = board(SQ_TRUE).T                        # (3,54) TRUE geometry
        uvw = K_TRUE @ (rot(*p[:3]) @ P + np.array(p[3:])[:, None])
        uv  = (uvw[:2] / uvw[2]).T                    # (54,2) pixels -- photons
        H   = dlt(model, uv)                          # solve on the LIE
        w   = H @ mh.T
        resid.append(np.linalg.norm((w[:2]/w[2]).T - uv, axis=1))
        Hs.append(H)
    V = np.vstack([np.stack([v_ij(H,0,1), v_ij(H,0,0) - v_ij(H,1,1)]) for H in Hs])
    _, _, Vt = np.linalg.svd(V)                      # 10x6 -> null space
    K  = K_from_b(Vt[-1])
    Ki = np.linalg.inv(K)
    lam = 1.0 / np.linalg.norm(Ki @ Hs[0][:, 0])       # impose ||r1|| = 1
    t   = lam * (Ki @ Hs[0][:, 2])
    return K[0,0], float(np.linalg.norm(t)), float(np.mean(resid))

for sq in (0.025, 0.023):
    fx, d, r = synth_and_solve(sq, 5)
    print("believed %.1f mm -> fx = %.9f   ||t|| = %.9f m   resid = %.2e px"
          % (sq*1000, fx, d, r))

Its output, verbatim:

stdoutbelieved 25.0 mm -> fx = 800.000000000   ||t|| = 0.600416522 m   resid = 1.66e-11 px
believed 23.0 mm -> fx = 800.000000000   ||t|| = 0.552383200 m   resid = 1.88e-11 px
Measured, not asserted. Those two lines are the literal stdout of the block above — paste it into a file and run it. Read them slowly, because each one carries a claim:

fx is 800.000000000 in both runs. Ratio 1.000000000. The 2 mm lie did not perturb the intrinsics in the ninth decimal place.
The board distance moved from 0.600416522 m to 0.552383200 m. Ratio 0.552383200 / 0.600416522 = 0.920000000 — exactly 23.0/25.0, to nine digits.
The residual is 1.9 × 10-11 px, which is double-precision round-off on numbers of order 500. There is no fit to improve. No threshold catches this, ever.

Which pose is 0.600416522 m? View 0, rvec = (0.20, −0.30, 0.05) rad and tvec = (0.02, −0.01, 0.60) m. That is a board tilted about 20°, and its distance is the norm ‖t‖ = √(0.022 + 0.012 + 0.602) = 0.600416522 m, not the 0.600 m depth of the flat, square-on board in Worked example 1 below. Different board pose, same 0.920.

And the production one-liner. Nobody ships the code above. In a real pipeline you write:

pythonrms, K, D, rvecs, tvecs = cv2.calibrateCamera(objp, imgp, size, None, None)
# objp: list of (54,3) float32, IN METRES  <-- the entire metric gauge is here
# imgp: list of (54,2) float32 detected corners
# size: (1280, 960)

Know why the library call earns its place, because it is a two-part answer and most explanations give only one part. First, calibrateCamera follows the closed-form DLT solve with Levenberg–Marquardt refinement of the true geometric reprojection cost; the DLT minimises an algebraic error (the norm of Ah) which is not the quantity you care about and is biased by the conditioning of A. Second, the DLT gives you no distortion model at all — k1, k2, p1, p2, k3 are non-linear in the residual and simply cannot be obtained in closed form, so on a real lens with k1 = −0.28 the from-scratch solve above would be off by tens of pixels at the image corners. Write the from-scratch version to prove to yourself that you understand the estimator; ship the library version because it optimises the right cost and models the lens.

But notice what neither version fixes. LM refinement, the distortion model, a better corner detector, 200 images instead of 22 — every one of those improvements makes the residual smaller, and not one of them touches s. You cannot out-engineer a gauge error from inside the cost function. That is the whole point.

Worked example 1 — one corner, by hand

You do not need the SVD to see this. One corner is enough, and doing it out loud with real numbers is what separates understanding it from having read about it.

Take a board held flat and square-on to the camera, 0.600 m away. Use the camera above: f = 800 px, principal point (320, 240). Look at the corner at grid position (column 4, row 3).

Step 1 — where the photons actually land. The squares really are 25.0 mm, so that corner sits at

X = 4 × 0.025 = 0.100 m     Y = 3 × 0.025 = 0.075 m     Z = 0.600 m

Step 2 — project it. The pinhole model says u = f · X / Z + cx:

u = 800 × 0.100 / 0.600 + 320 = 800 × 0.166667 + 320 = 133.333 + 320 = 453.333 px
v = 800 × 0.075 / 0.600 + 240 = 800 × 0.125 + 240 = 100.000 + 240 = 340.000 px

Your corner detector finds a corner at (453.333, 340.000). That is a fact about photons. Nothing you type in a YAML file can change it.

Step 3 — now lie about the board. The config says 23.0 mm, so the solver believes that same corner is at

X′ = 4 × 0.023 = 0.092 m     Y′ = 3 × 0.023 = 0.069 m

Step 4 — the solver has to explain the observation. It has two knobs: the focal length f and the board distance Z. It needs

f · X′ / Z′ + cx = 453.333  ⇒  f × 0.092 / Z′ = 133.333

One equation, two unknowns — from this corner. But f is shared by all 22 images and all 54 corners in each, while Z′ is free per image. And the shape information that pins down f — the two constraints we wrote out above, h1TBh2 = 0 and h1TBh1 = h2TBh2, both of which we just showed are invariant to a common rescaling of h1 and h2 — does not involve the board's absolute size at all. So the solver keeps f = 800 and moves Z′:

Z′ = 800 × 0.092 / 133.333 = 73.600 / 133.333 = 0.552 m

Step 5 — check the residual. Reproject with the solver's own numbers: 800 × 0.092 / 0.552 + 320 = 800 × 0.166667 + 320 = 453.333. The residual is zero. Exactly, not approximately.

And 0.552 / 0.600 = 0.920 — the same 0.92 that came out of the full five-view SVD solve, from one corner and a pocket calculator.

Worked example 2 — the whole residual vector, all six components

One corner is suggestive, but a fair objection is waiting: fine, that corner cancels — does it cancel everywhere, or did we pick a lucky one? So do three corners and write out the entire residual vector, because "the residual vector is exactly zero" is a claim you should be able to display, not summarise.

Same flat, square-on board as above: true squares 25.0 mm at Z = 0.600 m, f = 800, principal point (320, 240). The solver believes 23.0 mm and has therefore settled on Z′ = 0.600 × 0.920 = 0.552 m. Take the three corners at grid positions (0,0), (4,3) and (8,5) — the origin corner, a middle one, and the far diagonal corner of a 6×9 board.

Corner A, grid (0, 0). True: X = 0, Y = 0, so u = 800 × 0/0.600 + 320 = 320.000000 and v = 800 × 0/0.600 + 240 = 240.000000. Believed: X′ = 0, Y′ = 0, so u′ = 800 × 0/0.552 + 320 = 320.000000, v′ = 240.000000. The origin corner is trivially invariant — zero times anything is zero — which is exactly why you must not stop here.

Corner B, grid (4, 3). True: X = 0.100, Y = 0.075.

u = 800 × 0.100 / 0.600 + 320 = 133.333333 + 320 = 453.333333
v = 800 × 0.075 / 0.600 + 240 = 100.000000 + 240 = 340.000000

Believed: X′ = 4 × 0.023 = 0.092, Y′ = 3 × 0.023 = 0.069, at Z′ = 0.552.

u′ = 800 × 0.092 / 0.552 + 320 = 133.333333 + 320 = 453.333333
v′ = 800 × 0.069 / 0.552 + 240 = 100.000000 + 240 = 340.000000

Corner C, grid (8, 5) — the far diagonal, where an error would be largest if there were one. True: X = 8 × 0.025 = 0.200, Y = 5 × 0.025 = 0.125.

u = 800 × 0.200 / 0.600 + 320 = 266.666667 + 320 = 586.666667
v = 800 × 0.125 / 0.600 + 240 = 166.666667 + 240 = 406.666667

Believed: X′ = 8 × 0.023 = 0.184, Y′ = 5 × 0.023 = 0.115, at Z′ = 0.552.

u′ = 800 × 0.184 / 0.552 + 320 = 266.666667 + 320 = 586.666667
v′ = 800 × 0.115 / 0.552 + 240 = 166.666667 + 240 = 406.666667

Now stack the residual vector — observed minus predicted, u then v, corner by corner:

CornerObserved u, vPredicted u′, v′rurv
A (0,0)320.000000, 240.000000320.000000, 240.0000000.0000000.000000
B (4,3)453.333333, 340.000000453.333333, 340.0000000.0000000.000000
C (8,5)586.666667, 406.666667586.666667, 406.6666670.0000000.000000
r = [ 0,   0,   0,   0,   0,   0 ]T   ⇒   RMS = √(0/6) = 0.000000 px

Six components, six zeros, and it is obvious from the arithmetic why: in every single line the ratio X′/Z′ equals X/Z, because both numerator and denominator were multiplied by the same 0.920 and the fraction is invariant. The projection u = f·X/Z + cx only ever sees that ratio. It is structurally blind to a common scaling of the numerator and the denominator, and a mis-typed square size is exactly a common scaling.

Run the same three corners with a different fault — say f is wrong by 2% instead — and corner A still gives zero (it sits at the principal point, where f is multiplied by zero) but B gives ru = 0.02 × 133.333 = 2.67 px and C gives 0.02 × 266.667 = 5.33 px, growing linearly with distance from the principal point. That contrast is the diagnostic: a residual vector of exact zeros with a wrong answer is a gauge fault; a residual vector with radial structure is a geometry fault. You will build the whole table of those structures in Chapter 4.

Why this argument is the one that settles it. It shows the fault is not a numerical problem the optimiser could have detected. It is a gauge problem: the board's absolute size is the only thing in the entire dataset carrying units of metres, so the optimiser has no way to disagree with it. A quantity that nothing in your cost function constrains cannot produce a residual. This same argument — find the gauge, then look for what fixes it — reappears in Chapters 2 and 3 and is worth practising as a reflex.

Worked example 3 — what 0.920 costs the fleet

A scale factor only becomes real when you convert it into consequences. Three quick ones, all arithmetic:

The map. A warehouse aisle is 40.0 m long. The robot reports 40.0 × 0.920 = 36.8 m. That is a 3.2 m error, which is roughly two pallet positions. The map still closes — loop closure works fine, because the map is internally consistent — it is just a scale model.

The grasp. A mug sits 0.2609 m from the gripper's camera. Perception reports 0.2609 × 0.920 = 0.2400 m. The arm drives to 0.2400 m and stops. The shortfall is 0.2609 − 0.2400 = 0.0209 m, or 20.9 mm. A parallel-jaw gripper with a 30 mm approach tolerance succeeds; one with a 15 mm tolerance closes on air. That is why the failure looked intermittent and got triaged as a grasp-planner bug for three weeks.

The speed. Distance is scaled by 0.920 and time is not, so reported velocity is also scaled by 0.920. A robot actually moving at 1.500 m/s reports 1.500 × 0.920 = 1.380 m/s. It will therefore drive 8.7% faster than its speed limit whenever a controller uses the perception-derived velocity: to report the 1.500 m/s limit it must truly move at 1.500 / 0.920 = 1.630 m/s. A calibration typo became a safety-case violation. That sentence is the one to remember.

The reflex to build: when you find a scale factor, immediately ask which downstream numbers inherit it. Lengths yes. Velocities yes. Accelerations yes. Angles no. Ratios no. Pixel residuals no. That partition is exactly why the fault hides.

So the answer to her question is not "the square size was wrong." That is the symptom, not the diagnosis. The full answer is:

"The square size is the only quantity in that entire dataset carrying units of metres, so it is the gauge. It enters as a pure multiplicative scale on the extrinsic translations and leaves the intrinsics, the distortion and every residual bit-identical — I can show you that on one corner. A residual-based monitor is therefore structurally incapable of catching it, no matter what threshold you pick. The alarm has to compare two independently-scaled measurements of the same distance: wheel odometry against VIO over a sixty-second window, alert if the ratio leaves 0.98 to 1.02. And the square size in the config should be a measured value with a stated uncertainty, checked into the repo next to the photo of the calipers."

The inventory: what actually needs calibrating on one robot

"How many calibration parameters does your robot have?" is a question whose answer reveals whether you have ever owned one. Here is a representative warehouse AMR — six sensors, and every arrow between them is a calibration.

SensorRateMessage shape / bytesCapture → publishedIntrinsic paramsExtrinsic to baseTemporal
Front camera, 1280×960 mono30 Hz(960, 1280) uint8 = 1,228,800 B36.9 MB/s≈ 12 ms4 (fx, fy, cx, cy) + 5 distortion61 offset + 1 line delay
Rear camera, 1280×960 mono30 Hzsame → 36.9 MB/s≈ 12 ms4 + 561 + 1
3D LiDAR, 32 beam10 Hz32 × 1800 pts × 16 B = 921,600 B/sweep → 9.2 MB/s≈ 55 ms32 beam angle + 32 range offset61 offset + 1 spin phase
IMU (6-axis MEMS)200 Hz6 × float32 + 8 B stamp = 32 B6.4 kB/s≈ 1.5 ms3 gyro scale + 3 accel scale + 6 misalignment6reference clock
Wheel encoders (diff-drive)100 Hz2 × int32 + 8 B stamp = 16 B1.6 kB/s≈ 8 ms2 radii + 1 track width1
Depth camera15 Hz(480, 640) uint16 = 614,400 B9.2 MB/s≈ 33 ms4 + 5 + 1 depth scale61
Total≈ 92 MB/s (≈ 740 Mbit/s)spread ≈ 53 ms≈ 107309

Where each latency number comes from, because you should be able to defend every one of them. The front camera's 12 ms is 4 ms sensor readout + 2 ms over USB3 or GMSL + 6 ms of driver copy and serialisation, on top of an exposure window that is itself up to 33 ms wide. The LiDAR's 55 ms is not a delay at all in the usual sense — it is half of a 100 ms rotation, because the first point in a sweep is a full 100 ms older than the last, plus 5 ms of UDP packet reassembly. The IMU's 1.5 ms is an SPI burst read draining a hardware FIFO. The wheel encoders' 8 ms is a CAN frame plus the motor controller's own aggregation window. The depth camera's 33 ms is dominated by the on-module stereo or time-of-flight computation that happens before anything is published at all.

Read the bandwidth column out loud. 92 MB/s is 740 Mbit/s, which does not fit on a shared gigabit link once you add ROS discovery traffic and any logging, and it certainly does not fit in a rosbag written to an SD card. That single number is why real robots publish compressed or downsampled image topics to the network and keep the raw stream on-board — and it is why the calibration bag you record for a board dance is a separate, short, raw-only recording rather than something you scrape out of a production log.

Now the latency column, because that is the one that hides the most bugs. "Capture → published" is not one number; it is a chain, and calibration only cares about which link in the chain your timestamp is attached to. Walk it:

1. Exposure midpoint
The physically correct instant to associate with a global-shutter frame — the mean time at which the photons that formed the image arrived. For a 5 ms exposure starting at t0 this is t0 + 2.5 ms. Almost no sensor reports it.
↓ +4 ms readout
2. End-of-frame / strobe pin
What a hardware-triggered rig latches. If you have a strobe line into a GPIO with an interrupt, this is the best timestamp available and its jitter is microseconds.
↓ +2 ms transport
3. Driver receive
When the kernel hands the buffer to userspace. This is what ros::Time::now() inside a naive driver records. It carries the transport delay and its jitter, which is scheduler-dependent and worst under load.
↓ +6 ms copy / serialise
4. header.stamp on the wire
What every downstream node believes. If the driver stamped at step 3 and not step 1, the whole stack is running on a timestamp that is ~12 ms late and jitters by a few ms.

Which of those hops does td absorb? Precisely the constant part of steps 1 → 4. The time offset you will estimate in Chapter 3 is a single scalar per sensor pair, so it can only ever soak up the mean of that chain — the 12 ms bias. The jitter around that mean is irreducible by calibration: it is noise, it inflates your residual covariance, and the only fixes are hardware triggering (which collapses steps 1–3 into one latched edge) or a per-message timestamp from the sensor's own clock plus a clock-sync protocol. Keep that split in your head: "we calibrate the time offset" is only ever half of the answer.

Two consequences worth having ready. First, the LiDAR's 55 ms is not a bias at all — it is a 100 ms sweep during which the vehicle moved, so a single stamp for the whole cloud is a modelling error that td cannot fix; you need per-point timestamps and motion undistortion. Second, the front camera at 12 ms and the IMU at 1.5 ms differ by 10.5 ms, and at 1 m/s that is 10.5 mm of baked-in translation error in every visual-inertial update — roughly half the grasp shortfall the whole chapter opened with, produced by nothing but a timestamping convention.

And what breaks when a box is deleted. Delete the wheel encoders and you lose the only sensor with an independent metric scale, which means the cross-check alarm at the end of this chapter becomes impossible — you would be comparing VIO against itself. Delete the IMU and the LiDAR–camera extrinsic loses the rotational excitation that makes it observable (Chapter 2), and motion undistortion loses its interpolation source. Delete the depth camera and nothing calibration-relevant breaks, which is exactly why it is the one people delete. The useful habit is to ask, for each box: is this box the sole provider of a gauge, of an excitation, or of a clock? Those are the three deletions that hurt.

About 146 numbers. Six of them are metric gauges — the two wheel radii, the track width, the depth scale, the board square size used to make the camera intrinsics, and the IMU accelerometer scale. Those six carry the units of the entire robot, and only those six can be catastrophically wrong with a green dashboard.

The other 140 do produce residuals when they drift, which is the good news. Chapter 4 is about reading those residuals so you can name which of the 140 moved without touching a single line of code.

A debugging move worth stealing. When a calibration bug lands on your desk, start by partitioning: "Is this a gauge fault or a geometry fault? Gauge faults are residual-free and need an external metric reference. Geometry faults show up in residuals and I can localise them by regressing the residual against radius, range and image velocity." That single sentence usually decides the whole investigation.

The named failure mode, its symptom, and the metric that reveals it

Every chapter in this lesson closes its debugging section the same way, in a table you should be able to reproduce from memory. Here is the one this chapter earned. It has a name, and names matter when you file the incident report: this is the silent gauge fault.

SymptomEvery metric quantity the fleet reports is short by a constant percentage — map dimensions, grasp ranges, logged trajectory lengths, reported speeds — and the percentage is identical across robots, across sites, and across time. Meanwhile every internal health metric is nominal: reprojection RMS 0.284 px, loop closures succeeding, covariances tight, no covariance inflation, no rejected measurements.
WhyThe board square size is the gauge — the only quantity in the calibration dataset with units of metres. Zhang's constraints are ratios of homography columns, so a common factor on those columns cancels exactly (shown above), leaving K, the distortion and every residual bit-identical while every recovered translation is multiplied by s.
The tellA constant multiplicative error with a healthy, unchanged residual. That pair is the whole discriminator. Anything that produces a wrong answer and moves the residual is a geometry fault and is localisable from the residual field. Anything that produces a wrong answer and leaves the residual untouched is a gauge fault, and no amount of residual analysis will ever find it.
The metricmedian60s( wheel_odom_distance / vio_distance ). Two distance estimates whose metres come from physically different sources — a measured wheel radius and a measured board square. Alert outside 0.98–1.02. On the fleet in this story it would have read 1.087 from the first hour of the first robot.
The numberWheel slip on a clean warehouse floor is zero-mean with roughly ±1% per-sample spread; a gauge error is a constant bias with almost no variance. So a per-sample threshold drowns in false positives while a 60 s median separates them cleanly. Use the median, not the mean — a single wheel-lock event is an outlier the mean will chase.
Nearest decoyA wrong wheel radius produces the same ratio deviation, in the opposite direction, and the ratio alone cannot tell you which of the two sensors is lying. Disambiguate with a third, independent length: drive a tape-measured 10.000 m course once. Whichever of the two disagrees with the tape is the faulty gauge. Two measurements detect; three localise.
Second decoyA wrong stereo baseline or a wrong depth-camera depth scale gives a constant multiplicative range error too — but only on that sensor's outputs, not on the whole map. The split is: gauge inside the localisation loop → everything scales; gauge on a leaf sensor → only its consumers scale, and it disagrees with the map. Check whether the error appears in the trajectory length or only in object ranges.

The prevention, stated as a policy rather than a fix, because one fixed typo protects one robot while a policy protects the fleet: a metric gauge is not a config value, it is a measurement. It ships with an uncertainty, a date, and the instrument that produced it. The square size line in that YAML should read square_size_m: 0.02503 # +/- 0.00002, calipers, 2026-03-11, see docs/board-A17.jpg. A number with no provenance in a config file is a number nobody can audit, and this chapter is what auditing it is worth.

Watch the dashboard refuse to react

Reading the argument is not the same as watching it happen. Drag the believed square size away from 25.0 mm and keep your eye on the bottom bar — that bar is the number your on-call rotation is paging on.

The silent multiplier

Drag the believed square size. The top strip is the real world; the bottom strip is what the robot maps. The bar underneath is the reprojection RMS your monitoring dashboard shows — watch it refuse to react.

believed square size 23.0 mm
true corridor length 40 m

Three calibrations, three silent multipliers

This lesson is about three things that are usually taught as one, because they fail in three completely different ways — and telling the failures apart is most of the job.

CalibrationWhat it isTypical sizeWhat it silently multiplies
IntrinsicHow a ray becomes a pixel inside one sensor: fx, fy, cx, cy, skew, distortion5 + 4–8 numbers per cameraRay directions. Errors bend rays, which becomes a boresight bias in the trajectory.
ExtrinsicThe rigid transform between two sensors: R (3 DOF) and t (3 DOF)6 numbers per sensor pairWhere one sensor thinks the other one is. Rotation errors are range-independent; translation errors decay with range.
TemporalThe offset td between two clock domains, plus per-row time for rolling shutter1 number per sensor pair (+ 1 line delay)Everything, in proportion to how fast you are moving. It is the only one whose damage scales with speed.
The metric inputBoard square size, wheel radius, stereo baseline, IMU accelerometer scale1 numberAll lengths, uniformly, with zero residual signature. This is the one in the story above.
The one sentence to carry. Calibration is the layer where a number somebody typed becomes a number your robot believes. Every other subsystem — SLAM, planning, grasping, control — is downstream of it, and most calibration faults are invisible to the health metrics those subsystems publish. That asymmetry is the whole subject.

What this lesson does not do

You have four lessons on this site that already teach the underlying material properly, and repeating them here would waste your time. Read them if any of this is new:

The pinhole model built from zero, metric → pixels, homogeneous coordinates, P = K[R|t], the distortion model, and Zhang's method taught as a first encounter. Go here for what the pieces are.
Intrinsics vs extrinsics, hand-eye AX = XB as a first encounter, continuous-time formulations, online self-calibration, and the practical toolbox. Go here for breadth.
Covariance propagation, weighted least squares, MAP, robust costs, NEES and NIS. Every solver in this lesson is one of those. Go here for the estimator underneath.
Why a parameter is observable, and how well it can possibly be estimated. Chapter 3 uses the CRLB to compute how precisely one second of motion pins down a time offset. Go here for the bound.

This lesson spends its words on the craft: deriving Zhang's constraints from a blank page in four minutes, counting the observability of a hand-eye solve out loud, writing the Jacobian of a residual with respect to a time offset, and above all building the symptom → cause table that lets you name a calibration fault from a plot instead of a bisect.

Five angles, every chapter

AngleThe question it answersWhat it actually builds
Concept"Derive it."Can you rebuild it, or only recall it? Pen and paper, no notes, four minutes.
Design"Where does it live in the stack?"The rates, the byte sizes, the latency budget, and what breaks when a box is deleted.
Code"Implement the core."numpy, from scratch, no library call. Then the production one-liner and why you would use it.
Debug"It is broken like this. Go."A failure taxonomy with observable symptoms and the metric that reveals each one.
Frontier"What is changing here?"Staying current, and defending a tradeoff rather than listing a paper.

The shape of a calibration problem

Calibration problems arrive in three recognisable forms. Knowing which one you are in tells you what to do first.

Form 1 — the derivation. You have a checkerboard and a camera. What can you solve for? What matters is the constraint count. The efficient move is to state the unknowns, state the constraints per view, and divide, before writing any algebra. Chapter 1.

Form 2 — the observability trap. You drove the robot down a straight corridor for two minutes to calibrate the LiDAR-to-camera extrinsic. Good data? The answer is no, and the reason is a rank argument you should be able to produce on the spot. Chapter 2.

Form 3 — the field failure. The point cloud colouring is offset by twelve pixels. Which calibration? What you need is a discriminator, not a guess. Chapters 3 and 4.

A useful habit for all three: before you answer, say out loud which quantity is metric and which is dimensionless. Almost every calibration surprise is a metric quantity being multiplied by something, and almost every calibration debugging surprise is that dimensionless quantities show up in residuals and metric ones do not.

A day of this job, concretely

Where this is actually going, and the tradeoff to defend

The chapters ahead each carry their own frontier section, but the arc is one sentence and it is worth having before you start: calibration is migrating from a factory procedure that produces a file into a continuously-running estimator that produces a state with a covariance. Three waypoints, each with a defensible tradeoff rather than a name-drop.

The position to hold. Estimate online because it tracks drift and needs no procedure; keep an offline board reference because a state that is never compared to anything external is a place for a fault to hide. The online estimator will happily absorb a loose bracket into its extrinsic and report a beautiful residual. The alarm is the difference between the two, not either one alone. That is the same structure as the odometry-versus-VIO ratio at 08:40 — two independent measurements of one quantity — and it is the only structure that catches a gauge fault.
Numbers to carry in your head. A 1° extrinsic rotation error at f = 800 px is a 14 px offset at every range. A 2 cm extrinsic translation error is 8 px at 2 m and 0.8 px at 20 m. An 18 ms time offset at 1 m/s bakes in 18 mm of position error and at 60°/s of yaw it is 1.1° of pointing error. A 1280×960 rolling shutter at 20 µs per line is 19.2 ms from the top row to the bottom. Every one of these is derived in the chapters that follow.
Your calibration monitor watches RMS reprojection error and alerts above 0.5 px. What class of calibration fault is it structurally incapable of catching?

Chapter 1: Intrinsics — What the Checkerboard Is Actually Solving For

Forget OpenCV for a chapter. You have a camera and a flat board with a printed pattern, and you can wave the board around in front of the camera as much as you like. What can you solve for, and what is the minimum number of images?

Try it cold, on paper, before reading on. Four minutes.

The move that cracks this problem: do not start writing algebra. Start by counting. Write down the number of unknowns, write down the number of constraints one image gives you, and divide. Then derive the constraints. Reaching "three images" in the first ninety seconds and then justifying it is the efficient route; starting from H = K[r1 r2 t] and grinding forward is the route that bogs down.

Count first, derive second

The unknowns. The intrinsic matrix is upper triangular with a 1 in the corner:

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

Five numbers: two focal lengths in pixels (fx, fy — they differ if the sensor's pixels are not square), the principal point (cx, cy) where the optical axis pierces the sensor, and the skew γ, which is non-zero only if the sensor rows and columns are not perpendicular. On any sensor manufactured this century, γ is zero to well under a pixel, and most pipelines fix it there. So: 5 unknowns, or 4 with skew fixed.

What one image gives you. This is the whole derivation, and it fits on three lines.

Put the board's own coordinate frame on the board, with Z pointing out of it. Then every board point has Z = 0, so the projection collapses:

s · [u ; v ; 1] = K [ r1   r2   r3   t ] [X ; Y ; 0 ; 1] = K [ r1   r2   t ] [X ; Y ; 1]

The third column of the rotation never gets used, because it is multiplied by zero. What survives is a 3×3 matrix mapping board plane to image plane — a homography, which you fit from the detected corners with a DLT and a least-squares refinement. Call its columns h1, h2, h3. Because homographies are only defined up to scale, there is an unknown λ:

[ h1   h2   h3 ] = λ K [ r1   r2   t ]  ⇒  r1 = (1/λ) K-1 h1,   r2 = (1/λ) K-1 h2

Now the physics. r1 and r2 are two columns of a rotation matrix. That is not a decoration; it is two hard facts:

  1. They are orthogonal: r1T r2 = 0.
  2. They have equal length: r1T r1 = r2T r2 = 1.

Substitute. The unknown λ cancels out of the first because the right-hand side is zero, and cancels out of the second because both sides carry it:

h1T K-TK-1 h2 = 0      h1T K-TK-1 h1 − h2T K-TK-1 h2 = 0

Two equations per image. That is the answer to the counting question — but the next sentence matters just as much, because it is the one that separates counting from reciting.

Count in the right frame. The unknown you actually solve for is not K. It is the 6-vector b holding the distinct entries of B = K-TK-1, and b is homogeneous — every constraint is of the form (row)·b = 0, so if b is a solution then 4b and −17b are solutions too. Six entries, one scale you cannot see: 5 degrees of freedom. To pin b down to a single ray you need the null space of V to be exactly one-dimensional, which means

rank(V) = 5

With skew free, the images are your only source of rows: 2n ≥ 5 gives n ≥ 3. That is the answer to "minimum number of images."

How do you actually fix the skew? Everyone says "assume γ = 0" and almost nobody says how the solver is told. It is one line: γ = 0 forces B12 = 0 (you will see why in the expansion two sections down), and B12 is the second entry of b. So you append a literal extra row to V:

vskew = [ 0,   1,   0,   0,   0,   0 ]  ⇒  vskewTb = B12 = 0

That row costs no images. So the count becomes 2n + 1 ≥ 5, and n ≥ 2.

ModelRows availableRank neededMinimum imagesWhat you are betting on
Skew free2n5n = 3Nothing. This is the honest count.
Skew fixed at 02n + 15n = 2Sensor rows and columns are perpendicular. True to well under a pixel on anything made this century.
Skew fixed and fx = fy2n + 25n = 2Square pixels. The extra row is B11 − B22 = 0, i.e. [1, 0, −1, 0, 0, 0]. Still n = 2 because 2n + 2 ≥ 5 already needed n ≥ 1.5.

Notice the third row buys you nothing in the count. It buys you a great deal in the conditioning, which is a different quantity and is what the debugging section is about. There is a gap here worth dwelling on: two images is enough, but would you ship two? The answer is no, and the reason is that rank 5 is a statement about the noiseless V, while what you ship depends on how far from rank-deficient the noisy V is. In practice you shoot fifteen to twenty-five.

Why λ cancelling is the elegant part. The board's distance from the camera is unknown and different in every image, and it lives entirely inside λ. Both constraints are ratios or differences of the same quantity, so the distance drops out. That is precisely why the intrinsics are immune to the board's absolute size — the fault from Chapter 0. Say this out loud to yourself once; it connects the two halves of the topic.
The two cancellations are different, and naming both is worth a point. The homography's λ cancels because the constraints compare h1 against h2 — that is what makes the board pose drop out. The b-vector's own scale is never determined at all — that is what makes B homogeneous, and it is why the closed form below has to reconstruct that missing scale before it can hand you a focal length. Two different unknown scalars, two different fates. Conflate them and you will get lost in the closed form.

The change of variable that makes it linear

Both constraints contain the same object, K-TK-1. Call it B. It is called the image of the absolute conic, a name from projective geometry that you should recognise but do not need for the derivation. What matters mechanically is: B is symmetric, so it has 6 distinct entries, and the constraints are linear in those 6 entries even though they are horribly nonlinear in K.

Stack them into a vector:

b = [ B11,   B12,   B22,   B13,   B23,   B33 ]T

Now expand hiT B hj and collect the coefficient of each entry of b. The only subtlety — and it is the one everyone gets wrong the first time — is that B is symmetric, so each off-diagonal entry appears twice:

vij = [ h1ih1j,   h1ih2j + h2ih1j,   h2ih2j,   h3ih1j + h1ih3j,   h3ih2j + h2ih3j,   h3ih3j ]

with hki meaning the k-th element of column i. Then the two constraints are simply v12Tb = 0 and (v11 − v22)Tb = 0. Stack 2n such rows into a matrix V, and b is the null vector of V — the last row of VT from an SVD. One SVD, and you are done.

Deriving the closed form — where those six formulas come from

Every textbook drops six extraction formulas on you at this point, and every reader memorises them and forgets them by Thursday. You do not have to. Write out B in terms of the intrinsics and the formulas invert themselves. Do this once and you will never need the reference card again.

Step 1 — invert K, with γ = 0 for now. An upper-triangular matrix with a 1 in the corner has an upper-triangular inverse with reciprocal diagonal:

K-1 = [ 1/fx   0   −cx/fx ;   0   1/fy   −cy/fy ;   0   0   1 ]

Step 2 — read B off as inner products of columns. B = K-TK-1 is a Gram matrix: entry (i, j) is just the dot product of column i with column j of K-1. Name those columns

m1 = (1/fx, 0, 0)    m2 = (0, 1/fy, 0)    m3 = (−cx/fx, −cy/fy, 1)

and six dot products later you have the whole matrix, with no matrix multiplication at all:

EntryIs the dot productWhich equalsReads as
B11m1·m11/fx2horizontal focal length, squared and flipped
B12m1·m20the two axes are perpendicular — this is the skew
B22m2·m21/fy2vertical focal length, same deal
B13m1·m3−cx/fx2principal point, pre-multiplied by B11
B23m2·m3−cy/fy2ditto, vertically
B33m3·m3cx2/fx2 + cy2/fy2 + 1the normalisation, and the source of the λ

Step 3 — now just read the table backwards. Every extraction formula is one line, and every one of them is obvious once B is written this way:

cx = −B13/B11      cy = −B23/B22      fx = √(1/B11)      fy = √(1/B22)

Because B13 = −cx·B11, dividing kills the focal length and leaves the principal point. Because B11 is literally 1/fx2, one square root gives the focal length. That is the entire "closed form."

Step 4 — and the sixth entry is a redundancy check. Substitute cx and cy back into B33:

B33 − B132/B11 − B232/B22 = (cx2/fx2 + cy2/fy2 + 1) − cx2/fx2 − cy2/fy2 = 1

Six entries, five parameters, one identity left over. So B has exactly the 5 degrees of freedom we counted, and that leftover identity is not waste — it is the ruler that measures the missing homogeneous scale. That is the whole reason Zhang's published formulas have a λ in them.

The λ, finally explained

The SVD does not hand you b. It hands you some multiple of b, because a null vector is only defined up to scale — numpy normalises it to unit length, which is an arbitrary choice with no physical meaning. Write what you actually receive as

b̃ = μ b     for some unknown μ ≠ 0

Now run the identity from Step 4 on b̃ instead of b. Every term scales by μ (the ratios B132/B11 carry one factor of μ, not two, because the numerator is quadratic and the denominator is linear):

33 − B̃132/B̃11 − B̃232/B̃22 = μ · 1 = μ

There it is. λ is not a parameter and not a fudge factor — it is the measured value of the unknown SVD scale, recovered from the one algebraic identity the six entries had left over. Once you have it, the formulas that need an absolute magnitude get corrected and the ones that are ratios do not:

QuantityFormula on b̃Needs λ?Why
cx, cy−B̃13/B̃11,   −B̃23/B̃22NoA ratio of two entries. μ is in both, top and bottom, and cancels.
fx, fy√(λ/B̃11),   √(λ/B̃22)YesAn absolute magnitude. B̃11 = μ/fx2, so you must divide the μ back out before the square root.

Watch it work on numbers you can check in your head. The true b for f = 800, c = (320, 240) is [1.5625×10-6, 0, 1.5625×10-6, −5.0×10-4, −3.75×10-4, 1.25] (we derive those six numbers in Worked Example 1 below). Suppose the SVD hands you four times that:

b̃ = [ 6.25×10-6,   0,   6.25×10-6,   −2.0×10-3,   −1.5×10-3,   5.0 ]

Had you skipped λ you would have got √(1/6.25×10-6) = 400 px — exactly half, because μ = 4 and √4 = 2. A calibration that reports half the focal length it should is almost always a missing homogeneous-scale correction, not a bad board. That is a real bug that has shipped, and it is worth recognising by its signature: the principal point is perfect and the focal length is off by a clean multiplicative constant.

And now the published version, with skew

Everything above assumed γ = 0. Zhang's paper does not, which is the only reason his formulas look forbidding. Redo Step 2 with the full upper-triangular inverse

K-1 = [ 1/fx   −γ/(fxfy)   (γcy − cxfy)/(fxfy) ;   0   1/fy   −cy/fy ;   0   0   1 ]

and the same six dot products give

B11 = 1/fx2    B12 = −γ/(fx2fy)    B22 = γ2/(fx2fy2) + 1/fy2

Two things fall out immediately, and both are worth saying at the whiteboard:

  1. B12 = 0 if and only if γ = 0. That is the justification for the [0, 1, 0, 0, 0, 0] row we appended to V above — it is not a heuristic, it is exactly the skew-free condition.
  2. B11B22 − B122 = 1/(fx2fy2) — the γ2 terms cancel exactly. That combination is the 2×2 determinant of the upper-left block of B, and it is the skew-immune stand-in for B22. Every place the γ = 0 formulas used B22, the general ones use this determinant divided by B11. That single substitution converts one set of formulas into the other.

Apply that substitution to the four lines from Step 3 and you have literally reproduced Zhang's Appendix B:

QuantitySkew-free version (Step 3)Zhang's general version
cy−B23/B22v0 = (B12B13 − B11B23) / (B11B22 − B122)
λB33 − B132/B11 − B232/B22B33 − [B132 + v0(B12B13 − B11B23)] / B11
fx√(λ/B11)α = √(λ/B11)  (unchanged — skew never touched B11)
fy√(λ/B22)β = √( λB11 / (B11B22 − B122) )
γ0 by constructionγ = −B12α2β / λ
cx−B13/B11u0 = γv0/β − B13α2

Check the collapse yourself in ten seconds: set B12 = 0 in the right column. v0 becomes −B11B23/(B11B22) = −B23/B22. β becomes √(λB11/(B11B22)) = √(λ/B22). γ becomes 0, so u0 becomes −B13α2/λ = −B13(λ/B11)/λ = −B13/B11. And λ collapses too, because v0(−B11B23) / B11 = −v0B23 = +B232/B22. Every line lands back on the left column.

Where that formula comes from, in thirty seconds. "B is a Gram matrix of the columns of K inverse, so each entry is a dot product I can write down by inspection. Once B is in terms of f and c, the extraction is just reading the table backwards: the principal point is a ratio of two entries so it needs no scale, the focal lengths are square roots so they do, and the leftover identity B33 minus the two normalised squares — which is exactly 1 for the true B — is what measures the SVD's arbitrary scale. Skew only changes which combination stands in for B22." That is a derivation, not a recital.

Worked example 1 — unpack K from B, every step

The derivation above tells you why the formulas have that shape. Now run them on real digits, forwards then backwards, which is how you sanity-check an implementation at 2 a.m. when you have a K you trust and a solver you do not.

Given fx = fy = 800, c = (320, 240), γ = 0. Invert K by inspection — for an upper-triangular K the inverse is upper triangular with reciprocal diagonal:

K-1 = [ 1/800   0   −320/800 ;   0   1/800   −240/800 ;   0   0   1 ] = [ 0.00125   0   −0.4 ;   0   0.00125   −0.3 ;   0   0   1 ]

Form B = K-TK-1. Multiply out entry by entry:

Now run the closed form and watch 800 fall out. First the vertical principal point:

v0 = (B12B13 − B11B23) / (B11B22 − B122)

Numerator: 0 × (−5.0×10-4) − 1.5625×10-6 × (−3.75×10-4) = 0 + 5.859375×10-10.
Denominator: 1.5625×10-6 × 1.5625×10-6 − 0 = 2.44140625×10-12.
Divide: 5.859375×10-10 / 2.44140625×10-12 = 240.000.  ✓

Then the overall scale λ (a different λ from the homography one — Zhang unfortunately reuses the letter):

λ = B33 − [ B132 + v0(B12B13 − B11B23) ] / B11

B132 = (5.0×10-4)2 = 2.5×10-7.
v0 × numerator = 240 × 5.859375×10-10 = 1.40625×10-7.
Sum = 3.90625×10-7. Divide by B11: 3.90625×10-7 / 1.5625×10-6 = 0.250.
So λ = 1.25 − 0.25 = 1.000.

Then the focal lengths:

fx = √(λ / B11) = √(1.000 / 1.5625×10-6) = √640000 = 800.000  ✓
fy = √( λB11 / (B11B22 − B122) ) = √(1.5625×10-6 / 2.44140625×10-12) = √640000 = 800.000  ✓

And the horizontal principal point, with γ = −B12fx2fy/λ = 0 because B12 = 0:

cx = γv0/fy − B13fx2/λ = 0 − (−5.0×10-4)(640000)/1.000 = 320.000  ✓
What you just proved. Every entry of B is a rational function of the intrinsics with no reference to any distance, any board size, or any pose. That is the algebraic version of the argument from Chapter 0: the map from board metres to K simply does not exist. Only the map from board metres to t exists.

Worked example 2 — when three images are not three images

The counting argument says n ≥ 3. But the counting argument is only a necessary condition, and the gap between necessary and sufficient is where real calibrations fail. So do not assert the degeneracy — compute it. Two views, nine numbers each, all of it doable on the back of an envelope.

Set up two fronto-parallel views. Same camera as always: fx = fy = 800, c = (320, 240). Board held square-on, so R = I in both, which means r1 = (1, 0, 0) and r2 = (0, 1, 0). Slide it sideways between shots: tA = (0, 0, 0.60) and tB = (0.05, 0.02, 0.60). Build H = K[r1 r2 t] column by column.

The first two columns are trivial — K times a standard basis vector just picks off a column of K:

h1 = K r1 = (800, 0, 0)      h2 = K r2 = (0, 800, 0)

The third differs between the views, because it is the only place the translation lives:

h3A = K(0, 0, 0.60) = (320×0.60,  240×0.60,  0.60) = (192,  144,  0.60)
h3B = K(0.05, 0.02, 0.60) = (800×0.05 + 192,  800×0.02 + 144,  0.60) = (232,  160,  0.60)

So the two homographies are genuinely different matrices:

HA = [ 800   0   192 ;   0   800   144 ;   0   0   0.60 ]      HB = [ 800   0   232 ;   0   800   160 ;   0   0   0.60 ]

Now form the constraint rows and watch what happens. Recall vij = [h1ih1j,  h1ih2j+h2ih1j,  h2ih2j,  h3ih1j+h1ih3j,  h3ih2j+h2ih3j,  h3ih3j]. For view A, column 1 is (800, 0, 0) and column 2 is (0, 800, 0), so plug in slot by slot:

And here is the whole point: every one of those numbers came from h1 and h2 alone. h3 appears only in slots 4, 5 and 6, always multiplied by h31 or h32 — and both of those are zero for a fronto-parallel board, because r1 and r2 have no z-component. The translation cannot reach the constraint rows. So view B, whose only difference from A is its translation, produces the identical two rows:

V = [ 0   640000   0   0   0   0 ;   640000   0   −640000   0   0   0 ;   0   640000   0   0   0   0 ;   640000   0   −640000   0   0   0 ]

Four rows, two of them literal duplicates. You can even get the singular values by hand, because the two distinct rows are orthogonal: stacking a row twice multiplies the squared singular value by 2, so σ = √2 × (row norm).

σ = ( 1.280×106,   9.051×105,   0,   0,   0,   0 )

— from √2×640000√2 = 1280000 for the equal-norm row (norm 640000√2) and √2×640000 = 905097 for the orthogonality row. Rank 2. A four-dimensional null space. Not "poorly conditioned" — genuinely, exactly undetermined, and adding the other eighteen images changes nothing, because they all produce these same two rows. Twenty images, rank 2, confirmed numerically in the lab below.

Say the mechanism, not the symptom. "The board was not tilted" is the symptom. The mechanism is: h3 only enters the constraints through the third components of h1 and h2, and a fronto-parallel board makes those exactly zero. Tilt is the only thing that puts a z-component into r1 or r2, which is the only thing that lets slots 4, 5 and 6 of V become non-zero — and slots 4, 5, 6 are B13, B23, B33, which is to say the principal point and the scale. No tilt, no principal point. That one sentence explains both this degeneracy and the tilt-diversity failure mode below.

The second degeneracy, proved rather than asserted

Now the subtle one, and it has a genuinely pretty proof. Tilt the board once, then spin it about its own normal. The board face still points the same way; you have only rotated the printed pattern within its own plane. Intuition says you have added information, because the images look completely different. Intuition is wrong, and here is why in three lines.

Spinning about the board normal by φ post-multiplies R by a z-rotation, which replaces the first two columns with rotated combinations of themselves:

r1′ = cosφ · r1 + sinφ · r2      r2′ = −sinφ · r1 + cosφ · r2

K is linear, so h1′ and h2′ are the same combinations of h1 and h2. Write hij as shorthand for hiTBhj and expand the new orthogonality constraint:

h1TBh2′ = (c h1 + s h2)TB(−s h1 + c h2) = (c2 − s2) h12 − cs (h11 − h22)

with c = cosφ, s = sinφ. Do the same for the equal-norm constraint:

h11′ − h22′ = (c2 − s2)(h11 − h22) + 4cs · h12

Using cos2φ = c2 − s2 and sin2φ = 2cs, the new pair of rows is an exact linear map of the old pair:

[ v12′ ;   (v11−v22)′ ] = M [ v12 ;   (v11−v22) ],    M = [ cos2φ   −½sin2φ ;   2sin2φ   cos2φ ]

And now compute the determinant of that 2×2:

det M = cos22φ − (−½sin2φ)(2 sin2φ) = cos22φ + sin22φ = 1

Determinant exactly 1, for every φ. M is invertible always, so the two new rows span precisely the same 2-plane as the two old ones. Spin the board a hundred times at a hundred angles and the row space of V is identical to what one view gave you. This is not near-degeneracy that better numerics could rescue; it is an algebraic identity. Verified numerically to 5×10-10 at φ = 17°, 40° and 90°.

The near-miss worth knowing. Rotating the camera about its optical axis between shots is a different operation — it pre-multiplies R instead of post-multiplying — and it does add rank, because K does not commute with a z-rotation once the principal point is off-centre. If someone tells you "spinning does not help," ask them which spin. The board's own normal: provably useless. The camera's optical axis: helps, but for a reason so fragile (it depends on cx, cy being non-zero) that you should not design a capture procedure around it.

Near-degenerate is the one that ships

Nobody hands you exactly-parallel boards. They hand you boards tilted by three degrees, because tilting is awkward and the corner detector likes fronto-parallel views. Then V is technically full rank and the solve technically succeeds — with a covariance the size of a house.

The structural quantity first. Take the noiseless V from eight views whose tilt direction is spread evenly around a circle at magnitude T, and look at σ51 — the smallest informative singular value, normalised so it is dimensionless. (σ6 is the true null direction and is machine-zero; σ5 is the one that decides whether the null space is really one-dimensional.)

Max tilt Tσ51 of the noiseless VDivided by T2
0.02 rad (1.1°)6.92×10-71.73×10-3
0.10 rad (5.7°)1.72×10-51.72×10-3
0.20 rad (11.5°)6.84×10-51.71×10-3
0.35 rad (20.1°)2.05×10-41.67×10-3
0.50 rad (28.6°)4.03×10-41.61×10-3

That last column is flat to 7% over a 25× range of tilt. σ51 grows as T2. The reason is visible in the hand computation above: tilt puts a z-component of size sin T ≈ T into r1 and r2, the constraints are quadratic forms in those columns, and so the information about the three entries that were previously unreachable enters at second order. Halving the tilt quarters the observability. That is the single most useful sentence in this chapter and it takes ten seconds to say.

Now the consequence, measured. Same eight views, 54 corners each, Gaussian corner noise with σ = 0.3 px, 120 independent Monte Carlo trials per row, board at 0.60 m, seed 20250728. You reproduce every cell of this table in Code Lab 2 below — it is not a quoted result, it is the printed output of code that ships with the chapter:

Max board tiltσ(fx) across trialsσ(cx) across trialsSolves that failed outrightMean recovered fx
1.1°3172.8 px74.30 px30 / 1203701 px (truth 800)
5.7°31.4 px3.16 px0 / 120812.0 px
11.5°10.6 px1.87 px0 / 120800.6 px
20.1°5.4 px1.49 px0 / 120799.9 px
28.6°3.9 px1.44 px0 / 120799.8 px

From 20° of tilt to 1° of tilt, the standard deviation of the recovered focal length degrades by a factor of 3172.8 / 5.4 = 590. Same number of images, same corner detector, same noise. All that changed is the geometry of the excitation.

Read the last column too, because it carries a second lesson. At 1.1° the estimator is not merely noisy — it is biased by a factor of 4.6, reporting a mean focal length of 3701 px for a camera whose true focal length is 800 px. Near-degenerate least squares does not scatter symmetrically about the truth; it runs away along the weak direction. Anyone who tells you "averaging more calibrations will fix it" has not looked at that column.

Does shooting more images rescue a badly tilted set? This is the exact question every team asks, so here is the measurement. Fixing the tilt and sweeping the image count instead:

Tiltσ(fx) at N = 8N = 16N = 24N = 30Measured gainWhat 1/√N promises
1.1°3172.8 px3233.3 px1890.6 px2122.7 px1.49×, non-monotone1.94×
5.7°31.4 px25.0 px18.2 px15.9 px1.98×1.94×
20.1°5.4 px3.9 px3.0 px2.8 px1.93×1.94×

Above about 6° the 1/√N law holds to within 3%, because there the error really is corner noise being averaged down. At 1.1° it fails outright — sixteen images came out worse than eight — because the error is not noise, it is rank deficiency, and near-duplicate rows do not average anything down. You cannot buy observability with sample size. The tilt-bench widget at the end of this chapter enforces exactly this: push tilt below 6° and the images slider stops helping.

Reconciling the table with the 2 px bar

Sharp readers will already have noticed something and it is worth saying before they ask: the best cell in that table is 3.9 px, and the debugging section below calls anything over 2 px unshippable. Is the bar invented? No — the table is deliberately a hard configuration, and the gap between the two is exactly the list of levers you have.

The table is 8 images, 0.3 px corner noise, closed form only. Production is 22 images, sub-pixel-refined corners at 0.2 px, and LM on top. Each of those three is a separate multiplier, and all three are measured:

LeverChangeσ(fx) at 20.1° tiltGainWhy
Baseline8 images, 0.3 px, closed form5.4 pxthe table above
More images→ 22 images3.12 px1.73×√(22/8) = 1.66 — the noise-averaging law, and it holds here because the tilt is healthy
Better corners→ 0.2 px noise2.19 px1.42×σ(fx) is exactly linear in corner noise: measured 20.21 / 13.53 / 6.77 px at 5.7° for 0.3 / 0.2 / 0.1 px, ratios 1.49 and 2.99 against the predicted 1.5 and 3.0
LM refinement→ joint nonlinear refine1.66 px1.32×the closed form is a linear approximation to the wrong cost function; LM optimises actual reprojection error over all 2376 residuals jointly

1.66 px, under the bar, with a mean recovered fx of 800.05 and a fitted per-coordinate RMS of 0.194 px against an input noise of 0.200 px. So the bar is reachable, and now you know exactly what it costs to reach it.

Two of those four multipliers deserve a second look, because they are the ones people get backwards:

The budget sentence. "σ(fx) is linear in corner noise, falls as 1/√N in image count while the geometry is observable, and improves by about a third under LM. If I am over the bar, I check tilt first, because that is the only one of the four that can be infinitely bad."
This is the answer to "why does everyone tell you to tilt the board?" It is not folklore and it is not about the corner detector. Tilt is what puts a z-component into r1 and r2, which is what lets slots 4–6 of V become non-zero, which is what makes the principal point and the scale observable — and it enters quadratically, so the cost of skimping is brutal. The failed solves in the top row are the closed form taking the square root of a negative number because the estimated B was not positive definite — the algebra's own way of reporting "this data does not describe a camera."
The capture rule this buys you. A good board set is not "many images"; it is tilt in at least three distinct directions, at 20–40°, with the board filling the frame in the corners as well as the middle. Three directions because the noiseless V needs rank 5 and each new tilt direction is what adds rows; 20–40° because below 20° you are paying T2 and above ~45° the corner detector's own accuracy collapses under foreshortening; corner coverage because that is where distortion lives. Say those three clauses and the capture-procedure question is over.

Where distortion enters

Everything above assumed a perfect pinhole. Real lenses bend rays. Before writing the model down, derive its shape — because "why are there only even powers of r?" is the single most common follow-up in this whole chapter, and it has a clean two-sentence answer.

Why the radial series has only even powers. A lens ground on a lathe is rotationally symmetric about its optical axis. A symmetric optic cannot push a ray sideways — there is no preferred direction for it to push toward — so all it can do is move the image point along its own radius. That means the distorted point is the undistorted one times a scalar:

(xd, yd) = g(r) · (x, y)     for some scalar function g

Now ask what g is allowed to be. It can only depend on the point through the rotation-invariant combination r2 = x2 + y2, because that is the only function of (x, y) unchanged by rotating the image. And a smooth lens gives a smooth g, so expand it as a Taylor series in that combination:

g = 1 + k1(r2) + k2(r2)2 + k3(r2)3 + … = 1 + k1r2 + k2r4 + k3r6 + …

That is the whole answer. The even powers are not a modelling convention, they are forced: a term in r1 or r3 would be a power series in √(x2+y2), which has a corner at the origin and is not differentiable there — it would predict a kink in the lens at the exact centre of the image, which no ground glass has. The constant term is 1 because zero distortion must be the identity. Three coefficients is a cubic in r2, which is where everyone stops for a normal lens.

And why the tangential terms look the way they do. If the lens element is not exactly parallel to the sensor — a fraction of a degree of decentering from the assembly line — the symmetry argument breaks, because now there is a preferred direction: the axis of the tilt. To first order that adds a displacement field which is not radial. The remarkable thing, and the fact that makes the p-terms memorable, is that the whole field is the gradient of one scalar:

φ(x, y) = r2 (p1y + p2x)   ⇒   (∂φ/∂x,  ∂φ/∂y) = ( 2p1xy + p2(r2+2x2),   p1(r2+2y2) + 2p2xy )

Differentiate it and check: ∂φ/∂x = 2x(p1y + p2x) + r2p2 = 2p1xy + p2(r2 + 2x2). Exactly the published term, with no fitting and no hand-waving. φ is the lowest-order scalar that is radially weighted (the r2) but picks out a direction (the p1y + p2x), so (p1, p2) is literally a vector pointing along the decentering axis and √(p12+p22) is its magnitude. Verified numerically to 1×10-9.

Put the two together and you have Brown–Conrady, applied in normalised coordinates, before K:

xd = x(1 + k1r2 + k2r4 + k3r6) + 2p1xy + p2(r2 + 2x2)
yd = y(1 + k1r2 + k2r4 + k3r6) + p1(r2 + 2y2) + 2p2xy

where x = (u − cx)/fx, y = (v − cy)/fy and r2 = x2 + y2. Note where it is applied: in normalised coordinates, which is why r is dimensionless and why the same k1 describes the lens no matter what resolution you read the sensor out at. Get that wrong — apply the model in pixels — and your k1 is off by f2.

Distortion breaks the linear derivation, because a distorted board is no longer related to the image by a homography. The standard resolution is bootstrap then refine: solve the closed form assuming zero distortion to get a starting K, then hand everything — K, the distortion coefficients, and all n board poses — to Levenberg–Marquardt minimising true reprojection error. The closed form is never the answer; it is the initialisation that stops LM landing in a bad basin.

Pin the sensor down before you quote a single distortion number

Here is where distortion quietly catches people, and where the YAML from Chapter 0 has a trap in it. Every distortion figure is meaningless until you know the image size, because r is normalised by the focal length and the corner's r depends on how many pixels away the corner is.

The rig used in this chapter, and in both of its Code Labs, is 640 × 480 with fx = fy = 800 and c = (320, 240) — which is exactly the centre of a 640 × 480 image, as it should be. Everything follows from those four numbers:

The trap in the Chapter 0 YAML, and the thirty seconds it should cost you. That file declares image_width: 1280 and image_height: 960, and then gives a principal point of (319.62, 240.31). Those two facts cannot both be true. A 1280 × 960 sensor has its optical axis near (640, 480); a principal point at (320, 240) sitting a quarter of the way in from the top-left corner would mean the lens was mounted almost completely off the sensor. The K was fitted on a 640 × 480 stream and somebody pasted the full-resolution header above it. Thirty seconds of sanity-checking the principal point against the declared image size finds a second bug the story never mentioned.

The cost if it ships: applying a 640-fitted K to a 1280 stream. The correct full-resolution intrinsics are exactly double — f = 1600.3, c = (639.2, 480.6) — because doubling the sampling doubles both pixels-per-radian and the pixel coordinate of the axis. Use f = 800 instead and every range estimate comes back at half its true value (Z = fW/w is linear in f), and a ray through the true image centre is reported at atan(√((640−320)2+(480−240)2)/800) = 26.6° off-axis when it is actually dead centre. That is a 2× scale error and a 27° boresight error, from a header line.

Now the magnitudes, for judgment. The Chapter 0 coefficients are k1 = −0.2814, k2 = 0.0977, k3 = −0.0163. Evaluate g(r) = 1 + k1r2 + k2r4 + k3r6 and multiply the pull by the radius to get the displacement in pixels:

Distance from centrer (normalised)g(r)Inward pullDisplacementvs a 0.2 px noise floor
50 px0.06250.9989020.11%0.055 pxinvisible
100 px0.12500.9956270.44%0.437 px2× — just detectable
200 px0.25000.9827901.72%3.44 px17×
320 px (mid edge)0.40000.9574104.26%13.6 px68×
400 px (corner)0.50000.9355026.45%25.8 px129×

Two things to carry out of that table. First, displacement grows as r3, not r — the pull is quadratic and you multiply by the radius. Quadruple the radius from 100 px to 400 px and the displacement goes 0.437 → 25.8 px, a factor of 59, close to 43 = 64 (the k2 and k3 terms bend it back slightly). Second, the k1-only estimate is 28.1 px at the corner and the full three-term answer is 25.8 px, so k2 and k3 together are worth 2.3 px — real, but a 9% correction on a term that is itself only visible in the outer ring. Remember that ratio; it is the whole argument of failure mode B below.

Ignore distortion entirely and your reprojection error at the corners is 129 times the noise floor. Ignore it at the centre and you would never notice. That asymmetry is why a single aggregate RMS is a bad calibration monitor — a board set that never reached the corners will report a beautiful RMS from a model that is badly wrong where it matters.

And the tangential terms, sized honestly. With p1 = 0.0001 and p2 = −0.0002 at the corner (x = 0.400, y = 0.300, r2 = 0.250):

Δx = 2(0.0001)(0.400)(0.300) + (−0.0002)(0.250 + 2×0.160) = 2.40×10-5 − 1.14×10-4 = −9.00×10-5
Δy = (0.0001)(0.250 + 2×0.090) + 2(−0.0002)(0.400)(0.300) = 4.30×10-5 − 4.80×10-5 = −5.00×10-6

Magnitude √((9.00×10-5)2 + (5.00×10-6)2) = 9.01×10-5 in normalised units, which is 800 × 9.01×10-5 = 0.072 px. Seventy-two thousandths of a pixel, at the worst point in the image, against a corner detector whose own noise is 0.2–0.3 px. The tangential terms on this lens are a third of the noise floor. That number — not a preference, not a rule of thumb — is the justification for CALIB_ZERO_TANGENT_DIST in the production call below. Compute it and you get to say "I measured it," which is a different sentence from "I usually turn it off."

The radial signature. Because the radial terms are functions of r alone, any error in k1, k2, k3 produces a residual that is purely a function of distance from the principal point and points radially. Plot residual magnitude against r and you will see a clean curve rather than a cloud. Tangential error, by contrast, has the gradient-field structure derived above — it points the same general way across the whole image rather than in-or-out. So the two are separable by eye on a quiver plot of residuals, which is the first plot to draw in Chapter 4 and is diagnostic on its own.

The calibration service, with numbers

"Where does this live?" is the next question, and it wants rates, sizes and budgets, not boxes. Every number below is for the 640 × 480 mono8 rig pinned down in the previous section — fix the sensor size before you quote a single byte, because every figure in the flow is proportional to it.

Capture
22 frames, 640×480 mono8 = 640×480 = 307200 B = 0.307 MB each → 22 × 0.307 = 6.76 MB of raw board images, held in RAM. Operator waves the board for ~40 s; a coverage widget refuses to accept the set until every image octant and at least three distinct tilt directions are populated — the rank-5 requirement from Worked Example 2, enforced at capture time instead of discovered at solve time.
↓ corner detection, 22 × 54 corners
Sub-pixel corners
(22, 54, 2) float64 = 22×54×2×8 = 19.0 kB — a factor of 356 smaller than the images that produced them, which is why the artifact store keeps the corners forever and the images for a month. Detection ~12 ms/frame on one core → 0.27 s total. Sub-pixel refinement over an 11×11 window is what takes you from σ ≈ 0.8 px to σ ≈ 0.2 px, and by the 1/√N table above a factor of 4 in noise is worth 16× the image count.
↓ per-image DLT homography, 108×9 SVD each
22 homographies
(22, 3, 3) float64 = 22×9×8 = 1.6 kB. Each DLT stacks 2 rows per corner → a 108×9 matrix; 22 SVDs of that size ≈ 22 × 0.2 Mflop = negligible.
↓ stack V (44×6), one SVD, closed form
Initial K
5 numbers, from a 44×6 SVD. Runtime < 1 ms. This is the entire "clever" part of Zhang and it costs nothing — which is the point: you also get σ51 for free here, so gate on it before spending 180 ms on LM.
↓ Levenberg–Marquardt, joint refine
Refined K + distortion + 22 poses
Residuals: 22×54×2 = 2376. Parameter count depends on which flags you set — see the ledger below. The Jacobian is 2376×(parameters) but block-sparse: each image's 108 residuals touch only its own 6 pose parameters, so the Schur complement collapses the normal equations onto the intrinsic block alone. Converges in 6–12 iterations, ~180 ms.
↓ publish
camera_info
A ROS CameraInfo message: K (9 doubles), D (5), R (9), P (12) ≈ 350 B payload. Published at the image rate, 30 Hz → 10.5 kB/s. Also written to a versioned artifact store keyed by camera serial number, with the raw images, the covariance, and the image size that produced it — that last field is not decoration, it is the fix for the Chapter 0 YAML trap.

The parameter ledger, because "how big is the optimisation?" is a follow-up and the honest answer is "depends what you fixed":

ModelFree intrinsicsDistortionPosesTotalSchur-reduced block
Everything free (skew estimated)556×22 = 13214210×10
Skew fixed at 0 — the usual default451321419×9
Skew fixed and fx = fy351321408×8
What the production call below actually ships
skew, aspect ratio, k3 and tangential all fixed
32 (k1, k2)1321375×5

The whole reason a 2376×137 problem solves in 180 ms on one core is that Schur complement: 132 of those 137 parameters live in 22 independent 6×6 blocks that get eliminated analytically, leaving a 5×5 dense system to actually factorise. Marginalising structure out of a bundle is the same trick you will use in Chapter 7's SLAM backend on 105 landmarks; here it is small enough to see whole.

The latency budget that actually matters is not the calibration — it runs once, offline, on a workstation. It is the undistortion that runs forever, per frame, on the robot. You never evaluate the Brown–Conrady polynomial per pixel at runtime; you precompute a remap look-up table once:

The order-of-magnitude drill. Undistortion traffic per frame ≈ 10 bytes per pixel (8 for the two float32 maps, 1 in, 1 out). So MB/s ≈ 10 × pixels × rate × cameras. Check it: 10 × 0.307×106 × 30 × 2 = 184 MB/s. ✓ Carry the "10 bytes per pixel" and you can size any rig's remap budget in your head, at the bench, without a calculator.
The design summary worth memorising: "Calibration is an offline batch job with a 200 ms budget and an artifact store; undistortion is an online per-frame job with a sub-millisecond budget and a LUT eight times the size of the image. Confusing the two is how people end up evaluating a sixth-order polynomial per pixel in a 30 Hz loop. And the artifact must record the image size it was fitted at, or someone will apply it to a different stream."

Build the solve

The whole of Zhang that is worth being able to write is the constraint row. Everything else is an SVD call and a page of algebra you can look up. So that is what the lab makes you write — and then it runs the experiment from Chapter 0 on your own solver.

And the production form, which is what you would actually ship — say this sentence while you type it:

python
# everything above, plus distortion, plus LM refinement, in one call
rms, K, D, rvecs, tvecs, stdev_int, stdev_ext, per_view = cv2.calibrateCameraExtended(
    objpoints,          # list of (54,3) float32 - board model, IN METRES
    imgpoints,          # list of (54,2) float32 - detected corners
    (640, 480),   # the size these corners were detected at - NOT a header you copied
    None, None,
    flags=cv2.CALIB_FIX_K3 | cv2.CALIB_ZERO_TANGENT_DIST | cv2.CALIB_FIX_ASPECT_RATIO)

# stdev_int is the thing nobody reads and everybody needs:
#   [fx, fy, cx, cy, k1, k2, p1, p2, k3, ...] one-sigma, in pixels
print("fx = %.2f +/- %.2f px" % (K[0,0], stdev_int[0]))
assert stdev_int[0] < 2.0, "focal length is not observable from this image set"

What each argument is deciding. Five things, and they are the difference between "used OpenCV" and "owns calibration". Every one of them is a decision with a cost — anyone can name a flag; the cost is the part worth knowing.

  1. "I pass objpoints in metres, and that number is the only metric input in the whole pipeline." Ties back to Chapter 0 unprompted.
  2. "The image size argument is 640 by 480 because that is the resolution the corners were detected at. It is not a header I copied — and if it disagrees with where the principal point lands, one of the two is a lie." That sentence is the fix for the trap in the Chapter 0 YAML, and checking it at the moment you type the argument is much cheaper than discovering it in the field.
  3. "I use the Extended variant because I want stdev_int. An RMS of 0.28 px with σ(fx) of 40 px means the fit is tight and the parameter is not observable — those are different failures and the RMS cannot tell them apart."
  4. "CALIB_FIX_ASPECT_RATIO forces fx = fy. I set it because on a modern sensor the pixels are square to better than 0.1%, so a free fy is a parameter that buys me under a pixel of real signal and correlates strongly with the board's tilt about the horizontal axis — I would be spending an unknown to absorb a pose error. It costs me: free intrinsics drop from 4 to 3, the total from 141 to 140, and the Schur-reduced block from 9×9 to 8×8. If I ever calibrate an anamorphic lens or a sensor with binning applied on one axis only, this is the first flag I remove."
  5. "I fix k3 and the tangential terms unless I have evidence I need them. On this lens the tangential displacement at the worst point in the image is 0.072 px against a 0.2 px detector noise floor — I computed it — so those two parameters can only fit noise." Which is exactly the next section.

Note what those last two flags do together to the ledger: skew fixed by default, aspect ratio fixed by flag 4, and k3 plus both tangential terms fixed by flag 5, leaves 3 intrinsics + 2 distortion = 5 free non-pose parameters, a total of 137, and a 5×5 Schur block. If you quote "141" while typing these flags, someone will notice. Quote the ledger row you are actually on.

The flag nobody sets, and should. CALIB_USE_LU or the newer CALIB_USE_QR change only the linear-algebra backend, and are not the interesting ones. The interesting omission is that none of these flags let you say "and my principal point is within 20 px of the image centre." That is a genuine prior on almost every machine-vision lens, it is exactly the parameter that goes unobservable first when tilt is thin (σ(cx) = 74 px in the 1.1° row), and OpenCV has no way to express it. If you want it you write your own LM with a prior term — and knowing why you would is a much better answer than knowing every flag by name.

Regenerate the tilt table yourself

The table in Worked Example 2 is the empirical spine of this chapter, and a number you cannot regenerate is a number you should not quote. So regenerate it. This lab is the exact script that produced every cell — same seed, same poses, same noise — and it prints the five rows to the digits shown above.

The two TODOs are the two ideas the table is about. Everything else is provided.

What this lab buys you. You can now say "the standard deviation of fx goes from 5.4 px at twenty degrees of tilt to 3173 px at one degree, and a quarter of the solves stop returning a real number at all" — and, when someone raises an eyebrow, "I can show you; it is forty lines and it runs in a second." That is a different conversation from quoting folklore about tilting the board.

Three failure modes and the metric that reveals each

Failure mode A: insufficient tilt (the observability failure).

SymptomRecalibrating the same camera on Tuesday and Thursday gives fx = 812 and fx = 771, while both runs report a healthy RMS around 0.25–0.30 px. Downstream, depth estimates jump by 5% between calibrations and nobody can reproduce it.
WhyThe board never tilted more than a few degrees, so the rows of V are nearly dependent and the null space of V is nearly two-dimensional. The SVD picks a direction inside that near-null space essentially at random, driven by corner noise.
The metricstdev_int[0], the one-sigma on fx from the LM covariance — or, if your tool will not give you one, the standard deviation of fx across 50 bootstrap resamples of the image set. Also the ratio σ56 of the singular values of V: if the sixth is not clearly the smallest by a decade, the null space is not unique.
The numberHealthy is σ(fx) < 2 px for a 20-image set at 800 px focal length. The 1.1°-tilt row of the measured table above gives 3172.8 px; the 20.1° row gives 5.4 px, and 30 images at 20.1° gets you to 2.8 px. There is no ambiguity in the middle — the quantity moves by three orders of magnitude while RMS moves by nothing.
The cheaper metricσ51 of V, available before you spend 180 ms on LM — it is a by-product of the SVD you already ran. Measured: 6.9×10-7 at 1.1°, 2.0×10-4 at 20.1°. Gate the pipeline at 10-4 and reject the board set at capture time, while the operator is still standing there holding the board.
Nearest decoyA bad corner detector also inflates σ(fx) — but it inflates the RMS too. Observability failure has low RMS and high σ; a bad detector has high RMS and high σ. That pair separates them in one glance. And the tell that settles it: an observability failure leaves σ51 tiny, while a noisy detector leaves it healthy — the geometry is fine, the measurements are not.
The trap answer"Shoot more images." Measured above: at 1.1° of tilt, going 8 → 30 images buys 1.49× against the 1.94× the 1/√N law promises, and 16 images came out worse than 8. At 20.1° the same change buys 1.93×, right on the law. Sample size fixes noise; only geometry fixes rank.

Failure mode B: over-parameterised distortion (the overfit).

SymptomSomeone enables k3 and the tangential terms "for accuracy." The reported RMS improves from 0.284 px to 0.261 px, everyone is pleased, and three weeks later the far corners of the image are systematically 1.5 px off in production and nobody connects the two events.
Whyk3 multiplies r6, which is negligible everywhere except the outermost ring of the image — exactly where a hand-waved board set has the fewest corners. With four corners out there and a free sixth-order term, you are fitting corner noise, and the fitted polynomial diverges outside the sampled radius.
The metricHeld-out reprojection error, split by radius. Fit on 18 images, evaluate on the 4 you withheld, and report the residual in the outer radial quintile separately. Fit RMS always improves with more parameters; held-out RMS in the outer ring does not.
The numberIf enabling a coefficient improves fit RMS by less than 10% and worsens held-out outer-ring RMS at all, disable it. The Chapter 0 rig: fit 0.284 → 0.261 px is an 8% improvement, below the bar, while held-out outer-ring error doubled from 0.31 to 0.62 px. Ship without k3.
Nearest decoyA genuinely under-modelled lens (a fisheye fitted with a 2-coefficient radial model) also shows large outer-ring error — but it shows it on the fit data too, and the residuals are a smooth radial function rather than noise. Overfit: fit good, held-out bad. Under-fit: both bad, and structured.
The pre-emptive checkBefore enabling a coefficient, size its contribution against the noise floor. On the Chapter 0 lens: k3 is worth 2.3 px of the 25.8 px corner displacement, so it is real and arguably worth keeping if you have corner coverage out there — but the tangential terms are worth 0.072 px at the worst point in the image, against a detector whose own σ is 0.2–0.3 px. A parameter whose entire effect is a third of the noise it is fitted from can only absorb noise. Both numbers are computed in the distortion section above; that is the difference between a judgment and an opinion.
The transferable rule. RMS on the data you fitted is a measure of your model's flexibility, not its correctness. Every calibration report should carry three numbers, not one: fit RMS, held-out RMS, and the parameter standard deviations. If your tooling gives you only the first, the tooling is the bug.

Failure mode C: the intrinsics are right and the image size is wrong. This is the one hiding in the Chapter 0 YAML, and it is the most common calibration bug in a codebase that has ever changed resolution.

SymptomEvery range the camera reports is off by a clean factor of exactly 2 (or 0.5), and objects near the image edge are localised into a completely wrong bearing while objects near the top-left corner of the frame look roughly fine. Nobody can reproduce it on the bench, because the bench tool reads the stream at the resolution the calibration was fitted at. It only appears after someone enables full-resolution capture, or disables 2× binning, or swaps in a camera driver with a different default.
WhyIntrinsics are in pixels, so they are a property of the (lens + sensor + readout mode), not of the lens. Change the sampling and every entry of K scales: reading out at 2× the resolution doubles fx, fy, cx and cy together. Distortion, being defined in normalised coordinates, does not change — which is exactly why the bug is confusing, because the coefficient block still looks right.
The metricCompare cx, cy against half the declared image size. On any sane lens mount the principal point lands within a few percent of the centre. (319.62, 240.31) against a declared 1280×960 is off by 320 px — a quarter of the frame. Assert it in CI: abs(cx - W/2) < 0.1*W. One line, and it would have caught this before it shipped to 240 robots.
The numberUsing a 640-fitted K on a 1280 stream: ranges come back at 0.50× truth, and a ray through the true image centre is reported 26.6° off-axis. Compare that against the 0.920× from Chapter 0's square-size typo — this one is eight times worse and, unlike the gauge fault, it does produce a huge reprojection residual the moment you try to re-verify against a board. It hides only because nobody re-verifies after a resolution change.
Nearest decoyA wrong square size (Chapter 0) also scales ranges by a constant. The discriminator: the square-size fault leaves RMS bit-identical and the principal point sane; the resolution fault leaves RMS enormous and the principal point visibly off-centre. Check the principal point against the image centre first — it is free, and it splits the two instantly.
The three-line calibration lint everybody should have. assert abs(cx - W/2) < 0.1*W and abs(cy - H/2) < 0.1*H catches failure mode C. assert stdev_int[0] < 2.0 catches failure mode A. assert held_out_outer_rms <= 1.1 * fit_rms catches failure mode B. Three asserts, three of the four ways this chapter's calibration can be wrong. The fourth — the metric gauge from Chapter 0 — cannot be caught inside the calibration at all, which is precisely why it needed its own chapter.

What is changing, briefly

Zhang's method is from Zhang, "A Flexible New Technique for Camera Calibration," TPAMI 2000, and it is still the default in every toolbox twenty-five years later, which tells you something. Three directions are actually moving:

How to answer "will learned methods replace calibration?" Split the question. Learning is already winning at detection (finding the corner). It is not winning at estimation (turning corners into parameters with a covariance), because the classical estimator is optimal given the noise model and reports its own uncertainty. Expect learned front-ends into classical back-ends — the same answer as everywhere else in geometry.

Practice

The tilt bench

Left: the board poses you collected, seen edge-on. Right: the resulting one-sigma cloud of recovered (fx, cx) — the shape you would get from bootstrap-resampling the image set. Every σ the readout prints is interpolated from the five measured rows of Code Lab 2 above — closed form, 0.3 px corner noise — so the widget and the table cannot disagree. The readout also converts to the production figure using the measured 1.42× (sub-pixel corners) × 1.32× (LM) from the reconciliation table, which is what the 2 px bar is defined against.

Two things to try, in this order. (1) Push the tilt down and watch the cloud stretch into a sliver along the fx axis while the reprojection RMS bar refuses to react — that is the whole failure mode in one picture. (2) Now, with tilt at 1°, drag the images slider from 3 to 30 and watch the sliver not shrink. Then set tilt to 20° and drag it again: this time σ falls as 1/√N, exactly as it should. The slider only works when the geometry is already observable — and this is the measured behaviour, not a modelling choice: at 1.1° the real experiment gave 1.49× from 8→30 images (with 16 images worse than 8), against the 1.94× the noise-averaging law promises.

max board tilt 20.0°
images 8
Your calibration reports RMS reprojection error of 0.24 px — better than the 0.28 px you got last month — but the recovered focal length moved from 803 px to 771 px. Which number do you trust, and what do you check next?

Chapter 2: Extrinsics — The Rigid Transform Nobody Measures Directly

A scenario every perception team eventually lives through. A LiDAR is bolted under a camera. The intern drove the robot 60 metres down a straight aisle, logged both sensors, and ran the extrinsic calibration script. It converged. Is that good data?

The answer is no, the rotation is fine and the lever arm is completely unobservable, and you should be able to prove it in ninety seconds. Here is how.

Why extrinsics are the interesting calibration. Intrinsics you can measure with a board on a bench. The 6-DOF transform between two sensors is different: for most sensor pairs there is no shared view of a common target — the LiDAR cannot see a printed checkerboard's colours, the IMU cannot see anything at all. So you cannot measure the transform. You have to infer it from the fact that both sensors are bolted to the same rigid body and therefore experience the same motion, expressed in different frames.

The same motion, seen twice

Let X be the unknown rigid transform from sensor B's frame to sensor A's frame. The robot moves. Sensor A measures its own motion and calls it A. Sensor B measures the same physical motion and calls it B. Both are elements of SE(3).

Follow one physical point through the two routes. Route one: express it in B's old frame, move it with B, then convert to A. Route two: convert to A first, then move it with A. Because the sensors are rigidly attached, the two routes are the same thing:

A X = X B

That is the hand-eye equation, so named because it first appeared for a camera ("eye") bolted to a robot arm ("hand"). It is 4×4, but only 6 of its equations are independent, and the clean way to attack it is to split it.

Split it. Write X = (RX, tX), A = (RA, tA), B = (RB, tB). Multiply out both sides:

A X = ( RARX ,   RAtX + tA )      X B = ( RXRB ,   RXtB + tX )

Matching the two parts gives two separate problems:

(rotation)   RA RX = RX RB
(translation)   ( RA − I ) tX = RX tB − tA

The rotation equation involves only RX. So solve rotation first, then substitute it into a linear system for the translation. That decoupling is the single most useful structural fact about hand-eye calibration and it should be the first thing out of your mouth.

The rotation half is a Procrustes problem

RARX = RXRB rearranges to RA = RX RB RXT — a similarity transform. Take the matrix logarithm of both sides. The log of a rotation is its axis-angle vector: the direction is the axis, the length is the angle in radians. And conjugation by RX just rotates that vector:

log(RA) = RX · log(RB)

Write ai = log(RA,i) and bi = log(RB,i). The problem has collapsed into something you can see: you have two sets of arrows, and one set is the other set rigidly rotated. Find the rotation. That is the classic orthogonal Procrustes problem, and its solution is three lines:

M = ∑i ai biT   →   M = U Σ VT   →   RX = U · diag(1, 1, det(UVT)) · VT

The diag(1, 1, det) guard exists because U VT can come back with determinant −1, which is a reflection. A reflection minimises the same least-squares cost but is not a rotation, and shipping one produces a left-handed coordinate frame and a very confusing week.

This is Park & Martin's method ("Robot sensor calibration: solving AX = XB on the Euclidean group," IEEE T-RA 1994). Tsai & Lenz (1989) is the older, more commonly cited alternative that works on the axis vectors directly, and Daniilidis (IJRR 1999) solves rotation and translation jointly with dual quaternions, which is better when the two are strongly correlated. Naming which one you would use and why is a fast senior signal.

Worked example 1 — a Procrustes solve entirely by hand

People treat the SVD as a black box. It is not, at this size. Do this one on paper and the method stops feeling like magic.

The setup. The true answer — which the solver does not know — is a rotation of +90° about z:

RX = [ 0   −1   0 ;   1   0   0 ;   0   0   1 ]

Motion 1. The LiDAR reports a rotation of 0.40 rad about its own z axis, so b1 = 0.40 ez. The camera sees the same motion as a1 = RXb1. Since RXez = ez (the third column), a1 = 0.40 ez.

Motion 2. The LiDAR reports 0.55 rad about its own y axis, so b2 = 0.55 ey. RXey is the second column of RX, which is (−1, 0, 0) = −ex. So a2 = −0.55 ex.

Build M. Two outer products:

M = a1b1T + a2b2T = (0.40)(0.40) ezezT + (−0.55)(0.55) exeyT = 0.16 ezezT − 0.3025 exeyT

ezezT puts a 1 in the bottom-right; exeyT puts a 1 in row 1, column 2. So:

M = [ 0   −0.3025   0 ;   0   0   0 ;   0   0   0.16 ]

The SVD, read off by inspection. Feed M each basis vector and see what comes out — each column of M is M applied to a basis vector:

Assemble. U = [−ex | ez | s·ey] and V = [ey | ez | ex], with s = ±1 still undetermined. Then

UVT = (−ex)eyT + ezezT + s·eyexT = [ 0   −1   0 ;   s   0   0 ;   0   0   1 ]

Fix the sign with the determinant. Expanding along the first row: det = 0 − (−1)·(s·1 − 0·0) + 0 = s. A rotation needs det = +1, so s = +1, and

X = [ 0   −1   0 ;   1   0   0 ;   0   0   1 ]   =   RX  ✓

Exactly recovered, from two motions, by hand. And notice what the determinant guard just did: σ3 was zero, so the data said nothing about the third singular direction — the guard supplied the missing bit of information from the requirement that the answer be a rotation rather than a reflection. That is not a numerical trick; it is the algorithm using a piece of physics the measurements do not carry.

The observability statement, now provable. Each motion contributes one rank-1 outer product. With one motion, M has rank 1: two of its three singular values are zero, and the determinant guard can only supply one missing bit, not two. With two non-parallel axes, M has rank 2, and one guard is exactly enough. With two parallel axes, both outer products span the same direction and M is still rank 1 — two motions that are not two motions. The requirement is two rotations about non-parallel axes, and "number of motions" is the wrong thing to count.

Worked example 2 — the translation half, and why the corridor drive fails

With RX in hand, the translation equation is linear:

( RA − I ) tX = RX tB − tA

Let us do a planar case where every number is visible. Robot on a floor, so all rotations are about z and we can work in 2D.

Truth (hidden from the solver): RX = 90°, lever arm tX = (0.30, 0.10) m — the camera sits 30 cm forward and 10 cm left of the LiDAR.

The motion. The LiDAR measures its own motion as a 90° turn with translation tB = (0.50, 0.00) m. Generate what the camera must have measured:

RXtB = [0 −1; 1 0](0.50, 0.00) = (0.00, 0.50).
RA = RXRBRXT = 90° as well, since rotations about the same axis commute.
RAtX = [0 −1; 1 0](0.30, 0.10) = (−0.10, 0.30).
From RAtX + tA = RXtB + tX:
tA = (0.00, 0.50) + (0.30, 0.10) − (−0.10, 0.30) = (0.40, 0.30) m.

Now solve it back, pretending we only know RA, tA, RX, tB:

Right-hand side: RXtB − tA = (0.00, 0.50) − (0.40, 0.30) = (−0.40, 0.20).
Left matrix: RA − I = [0 −1; 1 0] − [1 0; 0 1] = [−1 −1; 1 −1].
Its determinant: (−1)(−1) − (−1)(1) = 1 + 1 = 2.
Its inverse: (1/2)·[−1  1; −1 −1].
Multiply: (1/2)·[(−1)(−0.40) + (1)(0.20),   (−1)(−0.40) + (−1)(0.20)] = (1/2)·(0.60, 0.20) = (0.30, 0.10) m  ✓

And now the corridor. The intern drove straight. No turning means RA = I, so

( I − I ) tX = 0 · tX = RXtB − tA

The left side is the zero matrix. Every value of tX satisfies the equation equally well. Sixty metres of beautiful data and the lever arm is exactly as unknown as it was before you started. The solver "converged" because the least-squares system was rank-deficient and whatever damping or pseudo-inverse it used quietly returned the minimum-norm answer — which for a lever arm is zero, i.e. "both sensors are at the same place."

The physical reason, in one sentence you should have ready. Two points on a rigid body translating without rotation move identically — nothing in the data distinguishes them. Only rotation makes the lever arm do something: the outer sensor sweeps a bigger arc. You cannot see a lever arm without rotating about it.

How much rotation? A conditioning number to quote

"Rotate" is advice. "Rotate 30 degrees" is engineering. Here is where the number comes from.

In 2D, the singular values of R(θ) − I are both equal to 2|sin(θ/2)|. Proof in one line: (R−I)T(R−I) = 2I − (R + RT) = 2(1 − cosθ) I, and the square root of 2(1−cosθ) is 2|sin(θ/2)|.

So an error δ in the measured motion translation becomes an error δ / (2 sin(θ/2)) in the recovered lever arm. With a realistic 1 cm error in the per-motion translation:

Rotation per motion2 sin(θ/2)Lever-arm error from 1 cm of motion error
0.03490.287 m — useless
0.08720.115 m
10°0.17430.057 m
30°0.51760.019 m
45°0.76540.013 m
90°1.41420.007 m — good

That table is the whole design of a calibration manoeuvre. It is why the standard IMU-camera dance is a figure-of-eight with aggressive wrist rotation rather than a smooth glide, and why "we collected two hours of driving data" is worth less than ninety seconds of deliberate excitation.

Answer the corridor question like this: "The rotation part is fine — a corridor drive still has small yaw corrections, and rotation only needs two non-parallel axes to be observable. The translation part is not: (RA − I) is singular for pure translation, so the lever arm is in the null space and the solver returned the minimum-norm answer, which is zero. I would check the smallest singular value of the stacked (RA − I) matrix — if it is below about 0.5, meaning under 30 degrees of excitation, I do not trust the translation at all. Then I would redo it with a figure-of-eight."

Where the extrinsic lives, and what an error costs in pixels

The extrinsic is not a file that sits on disk. It is a live edge in a transform tree that every consumer queries.

/tf_static
The 6 sensor-to-base transforms, published once with latching. A TransformStamped is ~100 B; 6 of them is 600 B, sent once. These are the calibrated extrinsics, and they are static because the robot is rigid.
/tf
The dynamic edges: odom → base_link at 100 Hz, plus any joints. Roughly 12 transforms × 100 B × 100 Hz = 120 kB/s, which is small but is the highest-frequency message on many robots and shows up in DDS tuning.
↓ consumers query with a timestamp
LiDAR → camera projection
32-beam LiDAR: 57,600 points/revolution × 10 Hz = 576k points/s. As XYZI float32 that is 16 B/point = 9.2 MB/s. Colouring costs ~20 flop/point = 11.5 Mflop/s, which is free; the real cost is the random-access read into the image, which is cache-hostile.
Coloured cloud / fused detections
This is where an extrinsic error becomes visible, as a colour offset on object boundaries. And its shape tells you which half of the extrinsic is wrong.

The pixel arithmetic that makes you sound like you have done this. Project a point at depth Z through a camera with focal length f. Then:

rotation error:   Δu ≈ f · Δθ      translation error:   Δu ≈ f · Δt / Z

A rotation error does not have a Z in it. It shifts everything by the same number of pixels, near and far. A translation error is divided by depth, so it is enormous up close and vanishes at range. With f = 800 px:

Depth Z1° rotation error2 cm translation errorWhich dominates
1 m13.96 px16.00 pxtranslation
2 m13.96 px8.00 pxrotation
5 m13.96 px3.20 pxrotation
10 m13.96 px1.60 pxrotation
20 m13.96 px0.80 pxrotation
50 m13.96 px0.32 pxrotation

They cross at Z = Δt / Δθ = 0.02 / 0.01745 = 1.15 m. Below about a metre you are looking at a translation error; above it, at a rotation error.

The diagnostic that follows. Plot colouring offset against range. Flat → rotation. Falls as 1/Z → translation. Two minutes with a scatter plot replaces a day of bisecting a calibration file, and it is the single most useful thing in this chapter.

Build the rotation solve

The lab gives you the SO(3) log and exponential and the synthetic motions. You write the two lines that are the method — and then the check makes you prove the observability claims from this chapter on your own solver.

The production form, and the two sentences that go with it:

python
# R_gripper2base / t_gripper2base : the "A" motions (n of them)
# R_target2cam  / t_target2cam    : the "B" motions
R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye(
    R_gripper2base, t_gripper2base,
    R_target2cam,   t_target2cam,
    method=cv2.CALIB_HAND_EYE_PARK)     # TSAI, PARK, HORAUD, ANDREFF, DANIILIDIS

# the check nobody writes and everybody needs: is the translation observable?
S = np.vstack([R - np.eye(3) for R in R_gripper2base])
smin = np.linalg.svd(S, compute_uv=False)[-1]
assert smin > 0.5, f"only {smin:.3f} of rotational excitation - lever arm is unobservable"

Say while typing: "PARK is the closed-form rotation-then-translation solve; DANIILIDIS solves both jointly with dual quaternions and is better when the rotation is poorly excited, because it lets translation information inform the rotation. And the assert is the part I care about — every hand-eye library will happily return a number from degenerate data."

Two failure modes and the metric that reveals each

Failure mode A: rotation-starved extrinsic (the corridor drive).

SymptomLiDAR-camera colouring looks correct on the far wall and on the ceiling, and is visibly offset on anything within two metres — a pallet edge picks up the colour of the floor behind it. Detections fused from both sensors disagree only at close range.
WhyTranslation error contributes f·Δt/Z pixels, which is large near and negligible far. The lever arm was in the null space of the calibration, so it defaulted to something close to zero.
The metricRegress the colouring offset against 1/Z. A significant slope with a near-zero intercept is a translation error. Upstream, the calibration-time metric is the smallest singular value of the stacked (RA − I) matrix.
The numberσmin < 0.5 (under ~30° of excitation) means do not trust the translation. At σmin = 0.035 (2°) a 1 cm motion error becomes a 29 cm lever-arm error.
Nearest decoyA rotation error also produces colouring offsets — but flat in Z, not 1/Z. Fit both models and compare: the intercept-only fit wins for rotation, the 1/Z fit wins for translation. One scatter plot decides it.

Failure mode B: a temporal error masquerading as an extrinsic error.

SymptomSomeone recalibrates the extrinsic, it improves, they recalibrate again a month later, it has "drifted" again. Each recalibration bakes in a slightly different correction and nothing ever converges. Colouring is perfect when the robot is parked.
WhyAn unmodelled time offset td makes the LiDAR points arrive from a pose the robot held td seconds ago. At angular rate ω that is an apparent rotation of ω·td, and the extrinsic solver dutifully absorbs the average of it into RX. Change the driving style, change the average, change the "extrinsic".
The metricCorrelate the residual against angular rate |ω| over the log. A spatial extrinsic error is constant in ω; a temporal error is proportional to it. Report the slope in px per rad/s and its t-statistic.
The numberWith f = 800 px and td = 18 ms, the slope is f·td = 800 × 0.0184 = 14.7 px per rad/s. At a typical 1 rad/s of yaw that is 14.7 px — the same magnitude as a 1° extrinsic rotation error, which is precisely why the two get confused. The slope, not the magnitude, separates them.
Nearest decoyRolling shutter also grows with motion — but it varies down the image (proportional to row index) while a time offset is constant across the frame. Split the residuals into image thirds: constant across thirds is td, ramping is rolling shutter. Chapter 3 does this properly.

Targetless and observability-aware, briefly

Practice

The range signature

A LiDAR scan of a scene with structure at 1, 2, 5, 10, 20 and 50 metres, projected into the camera. Push each error type and watch how the offset behaves with range. The whole diagnostic is the shape of that curve, not its size.

extrinsic rotation error 0.00°
extrinsic translation error 0.0 cm
Your LiDAR-camera colouring is off by about 14 pixels on a wall 20 metres away and by about 14 pixels on a box 2 metres away. Rotation or translation, and how confident are you?

Chapter 3: Temporal — Estimating the Time Offset as a State

Your camera and IMU are on different clocks. The hardware team says the offset is "about 15 milliseconds, maybe." What do you do?

The wrong answer is "ask them to measure it." The right answer is "I do not want a constant from the hardware team. I want td as a state in my estimator, with a Jacobian, a covariance, and an observability gate — because it is not constant, and because I can estimate it better than they can measure it."

Scope, so we do not repeat ourselves. RIP 02 · Time, Clocks & Sensor Alignment owns clock domains, NTP and PTP, hardware triggering, the difference between a capture timestamp and a receive timestamp, and how to interpolate two streams onto a common time base. Read it for all of that. This chapter is about one narrower and harder thing: treating the offset as a parameter you estimate from the same data you use for everything else — its derivative, its Fisher information, when it is unobservable, and how to tell it apart from rolling shutter.

The derivative that makes td estimable

Start from what an offset does. Your camera reports that a frame was captured at time t. It was really captured at t + td. Everything else in the estimator — the IMU integration, the pose prior, the extrinsic — is evaluated at t. So you predict where a feature should be at t, and you measure where it actually was at t + td.

Let u(t) be the image position of a tracked feature as a function of time. Then to first order:

u(t + td) ≈ u(t) + td · u̇(t)

where u̇ is the feature's image velocity in pixels per second — a quantity your tracker already computes for free, because it is the frame-to-frame displacement divided by the frame interval.

So write the residual with td in it explicitly:

r(td) = umeas − [ û(t) + td · u̇(t) ]

and differentiate:

∂r / ∂td = − u̇(t)

That is the entire trick, and it is one line. The Jacobian of the reprojection residual with respect to the time offset is minus the feature's image velocity. Add one column to your Jacobian, one entry to your state vector, and td is estimated jointly with everything else by the same Gauss–Newton you already run — see RIP 03 for why every estimator in this field is that same solve.

The observability statement falls straight out. If u̇ = 0, the Jacobian column is zero, the parameter contributes nothing to the information matrix, and td is completely unobservable. A stationary robot looking at a stationary scene cannot tell you anything about its time offset, no matter how long you record. This is obvious once stated and it catches teams constantly, because "collect a long calibration bag" feels like diligence.

The degeneracy that is not zero motion

u̇ = 0 is the degeneracy everybody quotes, and it is the easy one — a stationary robot is visibly stationary. The degeneracy that actually costs a team a week is the opposite: plenty of motion, all of it the same.

Rotate at a perfectly constant ω. Then u̇ = fω is a large non-zero constant, the information ∑u̇22 is enormous, every excitation gate you just wrote is green — and the residual an unmodelled offset creates is

r = u̇ · td = f · ω · td  —  a constant shift of every feature in the frame

Now ask what else shifts every feature by the same constant amount. A camera–IMU extrinsic whose yaw is wrong by Δθ rotates every predicted bearing by Δθ, which moves every predicted pixel by f·Δθ. Same shape, same sign structure, same everything. Put the two Jacobian columns side by side:

∂r/∂td = −fω      ∂r/∂Δθyaw = −f

Both are constants, and their ratio is ω — the same ratio for every feature and every frame in the window. Two proportional columns are one column. Write the 2×2 information submatrix over (td, Δθyaw) for N features and read its determinant:

I = (N f2 / σ2) · [ [ ω2 , ω ] , [ ω , 1 ] ] ,     det I = (N f22)2 ( ω2·1 − ω·ω ) = 0

Rank 1. Determinant exactly zero, for every N and every ω. Stacking more features or more frames of the same rate multiplies a singular matrix by a bigger number; it does not make it invertible. This is a strictly harder failure than u̇ = 0, because all of your health metrics look excellent while it is happening.

The number, so it is not an abstraction. At ω = 0.30 rad/s a time offset of td = 18.4 ms produces fωtd = 800 × 0.30 × 0.0184 = 4.416 px — Worked example 1's residual. A fixed yaw extrinsic error of

Δθ = ω · td = 0.30 × 0.0184 = 5.52 × 10-3 rad = 5.52 mrad = 0.316°

produces fΔθ = 800 × 0.00552 = 4.416 px. Identical, feature by feature, to every digit you can print. Ask the data which hypothesis it prefers and the data has no opinion. What comes back is whatever mixture the priors and the numerics happen to land on — usually some of each, which is the worst available outcome, because now both parameters are wrong and both report converged.

What breaks the tie is change of rate, not amount of rate. Take two frames, at rates ω1 and ω2. The 2×2 Jacobian over (td, Δθyaw) is

J = − [ [ fω1 , f ] , [ fω2 , f ] ] ,     det J = f2ω1 − f2ω2 = f21 − ω2)

The determinant contains the difference of the rates and nothing else at all. A careful smooth sweep from 0.29 to 0.30 rad/s gives det = 640,000 × 0.01 = 6,400. A reversal between +0.30 and −0.30 rad/s gives 640,000 × 0.60 = 384,000 — sixty times better conditioned from the same amount of shaking. Rate magnitude buys information about td in isolation (that was Worked example 2). Rate variation is the only thing that buys the ability to tell td apart from everything else that also scales with rate.

Why a Kalibr bag looks like someone having a small argument with a checkerboard. The instruction is never "move a lot." It is: excite all three rotation axes, with reversals, plus translation, at frequencies well inside the sensor bandwidth. You can now say exactly why. td, the extrinsic rotation, and the gyro bias all enter the residual through columns that become proportional the moment the rate stops changing. Only a rate that varies makes those columns linearly independent, and only linearly independent columns produce a unique solve. A slow smooth sweep feels careful and is nearly worthless; a jerky reversal feels sloppy and is precisely what the estimator is starving for.
The trap this sets in the design section below. The estimator-state box invites you to co-estimate the six camera–IMU extrinsic parameters alongside td for one extra state's worth of memory. Do that on a bag of smooth constant-rate motion and the solve is rank-deficient in exactly the (td, Δθyaw) plane. Ceres or GTSAM will still return an answer, the cost will still drop monotonically, and the covariance will be enormous along one direction that nobody plots. Either co-estimate them only on a bag with genuine rate reversals, or hold the extrinsic fixed and estimate td alone. What you may not do is co-estimate both and trust the number.

Worked example 1 — how loud is an 18 ms offset?

Numbers, so the abstraction becomes a plot you can picture.

Step 1 — convert body rotation into image velocity. A camera rotating at ω rad/s sweeps the image at f·ω pixels per second, because a small rotation Δθ moves a feature by f·Δθ pixels. Take a gentle yaw of ω = 0.30 rad/s (about 17°/s, a slow turn) and f = 800 px:

u̇ = f · ω = 800 × 0.30 = 240 px/s

Step 2 — the residual an unmodelled td creates. With td = 18.4 ms:

r = u̇ · td = 240 × 0.0184 = 4.416 px

Step 3 — compare it to the noise floor. A good tracker has σ = 0.3 px. So the residual is

4.416 / 0.3 = 14.7 σ

Fourteen sigma. This is not a subtle effect — during a gentle turn an unmodelled 18 ms offset is screaming. And yet it hides, because during the 80% of a log when the robot is going straight, u̇ is small and the residual is small, so the average residual looks acceptable. That is the trap: td faults are invisible in aggregate statistics and obvious in conditional ones.

The plot that finds it in one minute: scatter every residual against that feature's image velocity, and fit a line. The slope is td, in seconds, directly — because r = u̇·td is exactly a line through the origin with slope td. A slope of 0.0184 s means 18.4 ms of offset. You have both diagnosed and measured the fault from one scatter plot, without touching the estimator.

Worked example 2 — how well can td possibly be known?

The natural follow-up: how precisely can you estimate it? That is a Cramér–Rao question, and it has a closed form here that is worth memorising.

The Fisher information a set of measurements carries about a scalar parameter is the sum over measurements of the squared derivative divided by the noise variance (see Fisher Information & the CRLB for where that comes from):

I(td) = ∑i (∂ri/∂td)2 / σ2 = ∑ii2 / σ2

With N = 200 tracked features all moving at u̇ = 240 px/s and σ = 0.3 px:

2 = 2402 = 57,600.
σ2 = 0.32 = 0.09.
Per feature: 57,600 / 0.09 = 640,000 s-2.
Times 200 features: I = 1.28 × 108 s-2.
The bound is σtd ≥ 1/√I = 1/11,313.7 = 8.84 × 10-5 s = 88 µs.

Cancel the algebra and the result is memorable:

σtd = σpx / ( √N · u̇ )

One frame of a gentle turn pins the time offset to under a hundred microseconds. That is roughly two hundred times better than the hardware team's "about 15 milliseconds, maybe," from data you were collecting anyway. It is also why online estimation wins: the information is free and abundant.

And read the formula backwards for the failure. As u̇ → 0, σtd → ∞. There is no amount of N that fixes a stationary robot, because N multiplies under a square root while u̇ multiplies directly. Ten times more features buys you a factor of 3.2; ten times more motion buys you a factor of 10.

The design rule that follows. Gate the td update on information, not on time. Accumulate ∑u̇2 over the window and only update when the implied σtd is below your threshold. A reasonable production gate: require the median feature speed to exceed 20 px/s, which at f = 800 is 0.025 rad/s of rotation — a barely perceptible turn, and enough.

Worked example 3 — one update, by hand

The Cramér–Rao bound says what is achievable. The next thing to demand of yourself is one actual update, worked in full. So here is a single scalar Kalman update on td with every intermediate written down. No matrix in it is bigger than 1×1, which is the whole reason to do it this way first.

The setup. The hardware team said "about 15 milliseconds, maybe." You take them at their word — as a prior, not as a fact: t̂d = 15.0 ms with σ = 5 ms. The truth, which you do not know, is 18.4 ms. One feature is tracked at u̇ = 240 px/s (the gentle yaw of Worked example 1) and the tracker's noise is σpx = 0.3 px.

Step 1 — the prior variance, in SI. Always SI. Milliseconds inside a covariance are how you earn a factor-of-106 bug that survives three code reviews.

P = (5 × 10-3)2 = 2.5 × 10-5 s2

Step 2 — the Jacobian and the measurement noise. Straight from the derivative at the top of the chapter:

H = ∂r/∂td = −u̇ = −240 px/s      R = σpx2 = 0.32 = 0.09 px2

Keep the units in view, because they are the sanity check: H is pixels per second-of-offset, so H2P comes out in px2 and is directly comparable with R. That comparison is the update; everything below is bookkeeping around it.

Step 3 — the innovation covariance. How large a residual should we expect, given what we do not yet know?

S = H2P + R = 57,600 × 2.5 × 10-5 + 0.09 = 1.44 + 0.09 = 1.53 px2

Read those two terms rather than summing them mechanically. The timing uncertainty is worth 1.44 px2 of expected residual — 1.2 px of scatter. The tracker contributes 0.09 px2 — 0.3 px. The thing we are trying to learn is sixteen times louder in variance than the noise we have to see through, which is why the update about to happen is decisive rather than incremental.

Step 4 — the gain.

K = PH / S = 2.5 × 10-5 × (−240) / 1.53 = −6 × 10-3 / 1.53 = −3.9216 × 10-3 s/px

Units check: seconds of clock offset per pixel of unexplained image motion. One pixel of residual is worth 3.92 ms of clock. That number is worth carrying in your head — it is the exchange rate between the two quantities in this problem, and it makes the "4.4 px = 18 ms" claim from Worked example 1 fall out again from the other direction.

Step 5 — the posterior variance.

KH = (−3.9216 × 10-3) × (−240) = 0.94118
P+ = (1 − KH) P = 0.05882 × 2.5 × 10-5 = 1.4706 × 10-6 s2
σ+ = √(1.4706 × 10-6) = 1.2127 × 10-3 s = 1.21 ms

KH = 0.94118 is not a new quantity: it is exactly 1.44/1.53, the share of the expected residual that the prior uncertainty owns. The measurement gets 94.1% of the vote, the prior keeps 5.9% — from one feature, in one frame, off a 5 ms prior.

Step 6 — the state. Take the residual noise-free so the arithmetic stays visible. With the truth at 18.4 ms and the prediction built at 15.0 ms:

r = u̇ (tdtrue − t̂d) = 240 × (0.0184 − 0.0150) = 240 × 0.0034 = 0.816 px

We are carrying H as the derivative of the residual — which is what the chapter derived and what a Gauss–Newton code path actually holds in memory — so the step is t̂+ = t̂ − K r. That is identical to the textbook x + Kν with ν = −r; only the sign convention differs, and mixing the two is the single most common way this update ships backwards:

d+ = 0.0150 − (−3.9216 × 10-3)(0.816) = 0.0150 + 0.00320 = 0.01820 s = 18.20 ms

The prior was 3.40 ms wrong. After one feature it is 0.20 ms wrong — and 0.20/3.40 = 0.0588 = 1 − KH, exactly. The gain is not a tuning knob; it is the fraction of your error that this measurement deletes. If you can say that sentence and then show the two numbers that prove it, you are done with this question.

Step 7 — all 200 features at once, in information form. Repeating step 4 two hundred times is arithmetic, not insight. The information form does the whole frame in one line, because independent measurements add information:

1/P+ = 1/P + N H2/R = 4 × 104 + 200 × 57,600/0.09 = 4 × 104 + 1.28 × 108 = 1.2804 × 108 s-2
σ+ = 1/√(1.2804 × 108) = 8.837 × 10-5 s = 88 µs

That is Worked example 2's Cramér–Rao bound, to the digit. It is not a coincidence, and reproducing it is the reason to do both calculations: for a linear-Gaussian problem the Kalman posterior attains the CRLB, so the bound you quote at the whiteboard is the standard deviation you will actually measure in the log. If your logged spread is much wider than the bound, you have a bug or an unmodelled correlation — not bad luck.

Notice the other number hiding in that line. The prior contributed 4 × 104 out of 1.2804 × 108three parts in ten thousand. After a single frame of gentle yaw, the hardware team's estimate is arithmetically irrelevant. That is the quantitative version of "I can estimate it better than they can measure it," and it is a much better answer than the assertion.

Step 8 — the same update below the observability gate. Set u̇ = 5 px/s, under the 20 px/s gate, and run the identical arithmetic with nothing else changed:

S = 25 × 2.5 × 10-5 + 0.09 = 0.000625 + 0.09 = 0.090625 px2
K = 2.5 × 10-5 × (−5) / 0.090625 = −1.3793 × 10-3 s/px ,    KH = 0.0069
σ+ = √(0.9931 × 2.5 × 10-5) = 4.983 ms

One feature moved σ from 5.000 ms to 4.983 ms. Seventeen microseconds of learning. All two hundred together give 1/P+ = 4 × 104 + 55,556 = 95,556 s-2, i.e. σ+ = 3.23 ms — after a full frame the answer is still dominated by the hardware team's guess, and 42% of the information in it came from a prior somebody typed into a YAML file. The gate is not conservatism. It is arithmetic: below it, the update is theatre.

The other route: batch correlation

Adding a state to a live estimator is the right long-term answer. But on day one, before the estimator exists, you want a number from a bag file. That is the correlation route, and it is the one you will most likely be asked to implement.

The idea: find two scalar signals that measure the same physical quantity through the two clocks. For camera and IMU the natural pair is angular rate — the gyroscope measures it directly, and the camera gives it as the frame-to-frame rotation divided by the frame interval. Then slide one against the other and find the alignment that maximises the normalised cross-correlation:

ρ(L) = ⟨ a − ā ,   bshift L − b̄ ⟩ / ( ‖a − ā‖ · ‖bshift L − b̄‖ )

Why normalised and not a raw dot product. The two signals are in different units with different gains — one is rad/s from a gyro with a scale-factor error, the other is rad/s derived from pixels through a focal length. A raw dot product's peak location is affected by both signals' amplitude envelopes; the normalised version is invariant to any affine change (gain and bias) of either signal, so a gyro scale error cannot move your answer. That invariance is exactly what the lab's first assert tests.

Then refine below the sample grid. The correlation is only evaluated at integer sample lags, so the raw peak is quantised to one sample — 5 ms at 200 Hz, which is worse than the offset you are chasing. Fit a parabola through the peak and its two neighbours and take its vertex. For three points one unit apart with values y0, y1, y2:

δ = ( y0 − y2 ) / ( 2 ( y0 − 2y1 + y2 ) )

Derive it, do not memorise it. Put the middle sample at d = 0 and fit y = Ad2 + Bd + C. Then y0 = A − B + C, y1 = C, y2 = A + B + C. Subtracting: y0 − y2 = −2B, so B = (y2 − y0)/2. Adding: y0 + y2 − 2y1 = 2A, so A = (y0 − 2y1 + y2)/2. The vertex of a parabola is at d = −B/(2A), which substitutes to the formula above. Ninety seconds at a whiteboard.

What sub-sample refinement is worth, measured. True offset 18.4 ms, both streams resampled to 200 Hz (5.0 ms grid), 3 seconds of data, 1% noise. The integer peak lands at lag −4, i.e. 20.0 ms — 1.60 ms of error, quantisation-limited. The parabolic vertex sits at δ = +0.323 samples from that peak, i.e. at lag −3.677 — the refinement pulls the answer toward zero lag, not away from it. Chain it out exactly the way the lab's last line does, t̂d = −(lagpeak + δ)·ΔT:
−(−4 + 0.323) × 5.0 ms = 3.677 × 5.0 ms = 18.384 ms — 0.016 ms of error. That is 98× better than the grid, and about 1/300th of a sample. You build this in the lab below.
Get the sign backwards and you get −4.323, i.e. 21.6 ms — further from the truth than the integer peak you started from. That is why the lab asserts |δ| < 0.5: a vertex outside the three points you fitted to is not a refinement, it is a reflection.

Rolling shutter: a time offset that varies down the image

Almost every cheap CMOS sensor exposes rows sequentially. Row k is captured at

tk = t0 + k · tline

where tline is the line delay, typically 10–30 µs. For a 960-row sensor at 20 µs per line, the bottom row is captured 960 × 20 µs = 19.2 ms after the top row. That is the same order as the whole camera–IMU offset, which is exactly why the two get conflated.

The distinction is sharp once you look at the right axis:

Time offset tdRolling shutter tline
Residual vs image velocityslope = td, same for every rowslope = td + k·tline, ramps with row index
Split residuals into image thirdsidentical slope in all threeslope increases top → bottom by tline·2H/3 (the third-centroids sit at H/6 and 5H/6, so they are 2H/3 = 640 rows apart, not H/3)
Effect on a stationary robotnonenone
Effect on a vertical pole during a yawpole stays vertical, shifts sidewayspole leans — the classic jello skew
Fixone scalar in the stateper-row time in the projection model, or buy a global-shutter sensor

The number for the thirds test. With H = 960 and tline = 20 µs, the mean row of the top third is 160 and of the bottom third is 800, a difference of 640 rows = 12.8 ms. So the fitted slope should differ by 12.8 ms between top and bottom thirds — larger than most td values, and unmistakable if you look.

The senior answer to "should we buy global shutter?" Global shutter costs more and is less sensitive, so it is a real tradeoff, not a free win. Frame it as: a rolling shutter is a known, modellable per-row time offset that costs one extra parameter and a per-row interpolation of the pose — roughly 15% more compute in the projection. It becomes unacceptable when the exposure-time uncertainty exceeds your budget, which for a 5 m/s vehicle with a 19.2 ms readout is 5 × 0.0192 = 9.6 cm of spatial smear across the frame. If your localisation budget is 5 cm, you cannot model your way out of it; buy the sensor.

Where the offset lives, with numbers

IMU, 200 Hz
A ROS sensor_msgs/Imu is ~320 B → 64 kB/s. Its timestamp is the reference clock for the whole stack, because it is the fastest sensor and is usually the one with a hardware timestamp closest to the physical event.
↓ both feed the sliding window
Camera, 30 Hz
1280×960 mono8 = 1.229 MB/frame → 36.9 MB/s. Its header stamp should be mid-exposure, not exposure start and definitely not receive time. A 2–8 ms auto-exposure means start-vs-mid alone is a 1–4 ms wobble that varies with lighting, which looks like a drifting td and is really an exposure convention bug.
↓ state augmentation
Estimator state
position 3 + velocity 3 + attitude error 3 + accel bias 3 + gyro bias 3 = 15, plus td = 1, plus optionally the 6 camera–IMU extrinsic parameters → 16 or 22. The covariance goes from 15×15 (225 doubles, 1.8 kB) to 16×16 (256, 2.0 kB). One extra state costs 14% more covariance memory and about 20% more work in the update, since the cost is roughly quadratic in state size at these sizes.

The 2×2 that decides whether the extra state helps you or hurts you

Byte counts are the easy half of "what does one more state cost." The half that decides whether the system works is the new row and column of cross-covariance, because a td that cannot be observed does not sit politely still — it parks its error in whatever state it is correlated with, and that state then lies to everyone downstream. So take the 2×2 corner of P over (td, bgz) — the time offset and the z gyro bias — and do the update by hand, the way you did the scalar one.

Why those two share a Jacobian row. Over a sliding window of length Δt, an error δb in the z gyro bias integrates into an attitude error δθ = δb·Δt, which moves every predicted pixel by f·δb·Δt. So with Δt = 1.0 s (a typical keyframe window — for a 0.1 s window divide every coupling number below by ten, and the correlation falls with it):

H = [ ∂r/∂td ,   ∂r/∂bgz ] = [ −u̇ ,   −fΔt ] = [ −240 ,   −800 ]

The priors, and the trick of quoting them in pixels. Start from an uncorrelated P = diag(2.5 × 10-5 s2, (0.01 rad/s)2) — 5 ms of timing doubt and 10 mrad/s of bias doubt. Push each through its own column to see what it is worth as image motion, which is the only currency the measurement understands:

u̇ σtd = 240 × 0.005 = 1.20 px      fΔt σb = 800 × 0.01 = 8.00 px

The bias is a 6.7× cheaper place to put a residual than the offset is. Hold on to that ratio; it decides everything that follows.

One measurement, both states free.

S = H P HT + R = 1.44 + 64.0 + 0.09 = 65.53 px2
K = PHT/S = [ −6 × 10-3 ,   −0.08 ] / 65.53 = [ −9.156 × 10-5 ,   −1.2208 × 10-3 ]

Feed it the same 0.816 px residual as Worked example 3 — a residual caused entirely and only by the 3.4 ms timing error:

δtd = −K0r = +0.0747 ms      δbgz = −K1r = +0.996 mrad/s

Account for the 0.816 px. The bias absorbed 800 × 9.96 × 10-4 = 0.797 px, 97.8% of it; td took 240 × 7.47 × 10-5 = 0.018 px, 2.2%. The identical measurement that moved td by 3.20 ms when the bias was known moves it by 0.075 ms when the bias is free — a factor of 43 — and in exchange it has invented a full milliradian per second of gyro bias that the gyro does not have. Your timing fault has become a bias fault, and the bias state has no way of knowing it is lying.

Two hundred features do not fix it. Changing the rate does. Stack a whole frame, add the information, invert the 2×2. The only thing that varies between the rows below is whether ω keeps one sign across the window or reverses inside it:

the window, 200 featuresσtd afterρ
prior, before any update5.000 ms0
bias known, 240 px/s (Worked ex. 3)0.088 ms
bias free, constant ω, 240 px/s4.945 ms−0.9998
bias free, ω reverses, ±240 px/s0.088 ms0.000
bias free, constant ω, 5 px/s — below gate5.000 ms−0.762
bias free, ω reverses, ±5 px/s — below gate3.235 ms0.000

(ρ is the correlation coefficient of the (td, bgz) block. The bias standard deviations, in the same order, are 10.00, —, 1.484, 0.027, 0.041 and 0.027 mrad/s: note that the bias is estimated beautifully in every single row, including the ones where td learns nothing at all. A dashboard that watches bias convergence as a proxy for "calibration is healthy" will be green throughout this entire table.)

Row three is the one to stare at. Two hundred features, a healthy 240 px/s, every excitation gate green — and td improved from 5.000 ms to 4.945 ms. About one percent. The information matrix is rank 1: this is the degeneracy from the top of this chapter again, with the gyro bias now playing the part the extrinsic played there, and rank is not something N can buy. Row four is the same 200 features at the same speed, differing only in that ω changed sign halfway through the window: σtd = 88 µs, a 56× improvement, and ρ collapses to zero.

Now read the correlation column carefully, because the obvious reading of it is wrong. |ρ| is largest in the well-excited-but-unvarying row (−0.9998) and smaller in the starved below-gate row (−0.762). Correlation on its own is therefore not the alarm. The alarm is σtd refusing to shrink while |ρ| is large — that pair says "these measurements are informative about some combination of the two states, and it is not the one you asked for." Publish both, not either.

And here is the bill for that correlation. Take the posterior from row three — P = [[2.445 × 10-5, −7.335 × 10-6], [−7.335 × 10-6, 2.201 × 10-6]], ρ = −0.9998 — and now the robot stops. u̇ = 0 exactly, so the td column of the Jacobian is exactly zero: H = [0, −800]. A completely ordinary 0.5 px residual (1.7σ of tracker noise, nothing to see) arrives:

S = 640,000 × 2.201 × 10-6 + 0.09 = 1.4087 + 0.09 = 1.4987 px2
Ktd = P01 · (−800) / S = (−7.335 × 10-6)(−800) / 1.4987 = 3.915 × 10-3 s/px
δtd = −Ktd · r = −3.915 × 10-3 × 0.5 = −1.96 ms

A state whose Jacobian column is exactly zero just moved by two milliseconds, on noise, because the cross-covariance dragged it along behind the bias. Equivalently δtd = ρ(σtdb)δb = −0.9998 × 3.333 × 0.5875 mrad/s. At 30 Hz, with the noise sign flipping frame to frame, that is precisely the random walk of tens of milliseconds that failure mode A describes below — now derived, with a number, instead of asserted.

So: freeze, or inflate R? Both are defensible in a design review, so run both through the same arithmetic instead of arguing about them.

response when the window is unexcitedwhat td does on a 0.5 px noise residual
Nothing — let the filter run−1.96 ms per frame, sign flipping — the failure itself
Inflate R by 100× (0.09 → 9 px2)S = 10.409, Ktd = 5.64 × 10-4, δtd = −0.28 ms — 7× better, still fiction
Zero the td column of Junchanged: −1.96 ms — does nothing at all here
Freeze the state — zero the td row of K, hold the td row and column of P0.000 ms, and bgz still receives its legitimate update — the fix
The engineering decision, stated as teaching. We freeze the td state rather than inflating R because inflating R still lets the correlated gyro-bias state absorb the residual, and the cross-covariance then drags td along with it. R only scales how much gets absorbed; it never changes which state absorbs it. And we freeze the row of the gain rather than the column of the Jacobian, because the leak enters through P[td, bgz], which a zeroed Jacobian column does not touch — as row three of that table shows, zeroing the column buys exactly nothing once the correlation exists. The gate in the CODE section below zeroes the Jacobian column: that is the right first move, it is what the papers print, and it stops being sufficient the moment td becomes correlated with anything. Ship both.
The time-offset / gyro-bias corner of P

Dashed = the prior: 5 ms of timing doubt, 10 mrad/s of bias doubt, uncorrelated. Filled = the posterior after 200 features. Raise the image speed and it shrinks — but along one direction only, into a knife edge, until you give the window some rate reversal. Watch σtd and ρ disagree about whether things are going well.

image speed u̇ 240 px/s
rate reversal 0%
↓ per feature, per frame
Jacobian column
200 features × 2 rows × 1 column = 400 extra entries per frame, each of which is a value your tracker already has. At 30 Hz that is 12,000 float writes per second. The marginal cost of estimating td online is essentially zero — say this when someone proposes hard-coding it.
Published diagnostics
td and its σ at 1 Hz, plus the accumulated ∑u̇2 gate. Fit a line to td(t) over an hour: a non-zero slope is clock drift, not offset, and needs a different fix.

Offset versus drift, because they are different faults. An offset is a constant; drift is an offset that grows because two crystals run at different rates. A cheap oscillator is specified to ±40 parts per million, which is 40 µs per second, 2.4 ms per minute, 144 ms per hour. So a system that estimates a single constant td at boot and never revisits it is fine for a two-minute mission and catastrophically wrong for an eight-hour shift.

The one-line design answer: "Model it as a slowly-varying state, not a constant — a random walk with process noise around 40 ppm times the update interval. Then the filter tracks drift automatically and the covariance tells me when I have lost observability."

Build the offline estimator

And the online form, which is the answer you give when they ask what you would actually ship. It is four lines added to a Gauss–Newton you already have:

python
# state x = [ ..., td ]  with td the LAST element
for i, f in enumerate(tracked_features):
    u_pred = project(T_wc, f.point_w, K)          # (2,)
    u_dot  = (f.uv - f.uv_prev) / dt_frame          # (2,) px/s - already computed

    r[2*i:2*i+2] = f.uv - (u_pred + td * u_dot)   # residual WITH td in it
    J[2*i:2*i+2, :15] = jac_pose_and_bias(...)  # unchanged
    J[2*i:2*i+2, -1]  = -u_dot                    # THE new column: dr/dtd = -u_dot

# observability gate: do not let an unexcited window move td
info_td = np.sum(u_dots**2) / sigma_px**2
if 1.0 / np.sqrt(info_td) > 2e-3:                 # worse than 2 ms - refuse
    J[:, -1] = 0.0

This is the mechanism behind Qin & Shen's online temporal calibration in VINS-Mono (IROS 2018) and, in filter form, Li & Mourikis (IJRR 2014). Being able to say "it is one Jacobian column, minus the image velocity" is the difference between having read about it and understanding it.

Two failure modes and the metric that reveals each

Failure mode A: td estimated with no excitation.

SymptomThe estimated td random-walks over a range of tens of milliseconds while the robot is docked or waiting, then the robot starts moving and the trajectory has a violent transient for the first two seconds before settling.
WhyWith u̇ ≈ 0 the Jacobian column is zero, so the measurement update leaves td untouched while process noise keeps inflating its covariance. The state wanders, and any tiny spurious correlation gets amplified by a covariance that has grown without bound.
The metricPublish the implied bound σtd = σpx/(√N · median u̇) alongside td. It is computable per frame from quantities you already have.
The numberGate at median u̇ > 20 px/s. At f = 800 that is 0.025 rad/s of rotation. Below it, freeze the state; the well-excited case (240 px/s) gives σtd = 88 µs, and the gate value gives 88 × 240/20 = 1.06 ms — which is the worst you should ever accept.
Nearest decoyA genuinely drifting clock also makes td move — but it moves monotonically at a fixed rate, whereas an unobservable state moves as a symmetric random walk with growing variance. Fit a line: significant slope means drift, zero slope with growing σ means unobservable.

Failure mode B: the offset is really a drift.

SymptomCalibration is perfect after boot. Ninety minutes into a shift the residuals during turns have grown, and the operators have learned to "just reboot it." Everyone believes the sensors are warming up.
WhyTwo free-running crystals at ±40 ppm. The offset grows at up to 80 µs per second in the worst case, though 40 ppm relative is the number to quote.
The metricFit a straight line to td(t) over the whole session and report the slope in ppm, with a confidence interval. This is a one-line regression on data you are already logging.
The number40 ppm = 40 µs/s = 2.4 ms/min = 144 ms/hour. If the fitted slope is above ~5 ppm you have drift, not offset, and a constant td in a config file will never be right for more than a few minutes.
Nearest decoyThermal expansion of the mount changes the extrinsic, and also gets worse over the shift. Separate them: a thermal extrinsic drift shows up when the robot is stationary too (the colouring offset moves); a clock drift shows up only during motion, because it is multiplied by velocity.

The frontier, briefly

The tradeoff to defend. Online estimation tracks drift and needs no procedure — but it can silently absorb a real fault (a loose mount, a driver regression) into the calibration state and hide it from you. The mature answer is both: estimate online for tracking, log the estimate, and alarm when it leaves a band established by an offline reference. Calibration state that is never compared to anything is a place for bugs to live.

Practice

The offset bench

Top: two yaw-rate traces, gyro in teal and camera in orange, with your assumed offset applied. Bottom: the residual plotted against image velocity. Slide the assumed offset until the traces lock and the residual line goes flat — the slope of that line is the remaining offset in seconds.

assumed td 0.0 ms
true td 18.4 ms
You want to estimate the camera–IMU time offset online, with the extrinsic held fixed. What is the Jacobian of the reprojection residual with respect to it, and when does your estimate become worthless?

Chapter 4: Reading the Residuals — Symptoms of Bad Calibration in a Running System

Picture a scatter plot with no axis labels and about two thousand points: the reprojection residual from a robot in the field. One calibration parameter is wrong. You get to pick what goes on the x axis. What do you pick, and in what order?

This is the best puzzle in the whole topic, because it separates people who have read about calibration from people who have debugged it. There is a right answer and it is short.

The answer: "Four covariates, in this order. Radius from the principal point — catches distortion. Image velocity — catches the time offset. Inverse range — catches an extrinsic translation. Row index — catches rolling shutter. If all four come back flat, the residuals are telling me the geometry is self-consistent, which means the fault is a metric gauge and no residual will ever find it — so I stop looking at residuals and go compare two independent distance measurements."

Every fault has a covariate

A residual is a number. A residual plotted against the right thing is a diagnosis. The reason this works is that each calibration parameter enters the projection multiplied by a different physical quantity, so an error in it produces a residual proportional to that quantity — and to nothing else.

Here is the whole table. It is how the knowledge gets used — but do not commit it to memory yet. Every row is one derivative of the projection map, and the section straight after it takes all five in front of you. A slope you have differentiated is a slope you can defend when someone asks "why r3 and not r2?"; a slope you have memorised is one you will misquote under pressure.

Wrong parameterResidual is proportional toPlot to drawSignature
k1, k2, k3 (radial distortion)r3, r5, r7residual vs radiuszero at centre, grows steeply outward, points radially
p1, p2 (tangential)r2, with an angular patternresidual vs radius, coloured by angleone side of the image worse than the other
td (time offset)image velocity u̇residual vs image velocityline through the origin; slope = td in seconds
tline (rolling shutter)u̇ × row indexresidual/u̇ vs row indexramps top to bottom
Extrinsic translation Δt1 / rangeresidual vs 1/Zline through the origin; big near, vanishes far
Extrinsic rotation Δθ1 (nothing)residual vs anythingconstant offset everywhere — flat against all four covariates
cx, cy (principal point)1, to first ordertrajectory heading vs ground truthnearly invisible in residuals; shows as a heading bias
Board square size / baseline / wheel radiusnothingno residual at all. Only an external metric reference sees it.
Read the bottom two rows twice. They are the ones that cost teams months. A principal-point error is almost a rotation of the camera, and a rotation of a single camera is unobservable to that camera — so it hides in your residuals and shows up as a drift in your trajectory. A gauge error hides completely. Everything above them is findable in an afternoon with a scatter plot; those two need a different instrument.

Where the table comes from — five derivatives

Nobody should carry that table on trust, least of all in a room where the next question is "why?". Every row is one partial derivative of the projection map, each takes about three lines, and the payoff is direct: the derivative you are about to write is the slope that the regression later in this chapter reports. Differentiate once and the triage procedure stops being a ritual and becomes a consequence.

Everything starts from the two lines Chapter 1 built. A point sitting at (X, Y, Z) in the camera frame lands at

u = f · X/Z + cx      v = f · Y/Z + cy      rpx = √((u − cx)2 + (v − cy)2)

Each fault perturbs exactly one symbol in that map. Perturb it, keep first order, and read off what multiplies the error. That multiplier is the covariate. There is no more to the theory than this sentence; the rest is five substitutions.

(a) Extrinsic translation δt → residual ∝ 1/Z. A lever-arm error means the camera physically sits δt away from where the extrinsic file says it does, so every point's camera-frame X is wrong by δtx. Nothing else in the map moves — not f, not cx, not the row, not the clock. So differentiate u with respect to X alone:

∂u/∂X = f/Z   →   Δu = f · δtx / Z = (f · δtx) · (1/Z)

Read the right-hand grouping literally: the residual is a constant times 1/Z. Plot Δu against 1/Z and you get a straight line through the origin whose slope is f·δtx — one number carrying both the focal length and the millimetres you are hunting. With f = 800 px and δtx = 2 cm the slope is 800 × 0.02 = 16.0, in units of pixels per inverse metre, which is where the strange "px·m" in the threshold table comes from. Two sections down we cash that out arithmetically.

Notice what is absent: no dependence on where in the image the feature sits, and none on how fast it is moving. A lever-arm error is nearly invisible on a far-field-only log (0.8 px at 20 m) and screams on a near-field one (16 px at 1 m), which is why this test is only honest when the log carries range diversity. Chapter 2 tabulated exactly this fall-off; here it is the derivative that produced the table.

(b) Extrinsic rotation δθ → residual ∝ 1, a pure constant. Now rotate the camera by δθ about its Y axis instead of translating it. A rotation acts on the ray direction, and the normalised coordinate x = X/Z is precisely the tangent of the bearing angle θ. So x = tanθ becomes tan(θ + δθ), and

Δu = f[tan(θ + δθ) − tanθ] = f · δθ · sec2θ + O(δθ2) = f · δθ · (1 + x2)

At the principal point x = 0 and the whole expression collapses to Δu = f·δθ. There is no Z anywhere in it: a rotation moves the point at 1 m and the point at 50 m by the same number of pixels. With f = 800 px and δθ = 1.0° = 0.017453 rad, Δu = 800 × 0.017453 = 13.96 px — the constant the practice widget at the bottom of this chapter injects for its "extrinsic Δθ" fault, and the same 13.96 px that runs down every row of the Chapter 2 comparison table.

This derivative is why the intercept gets promoted to its own channel. A fault whose derivative is a constant has no covariate to correlate with. In a linear model the only place a constant can live is the constant term. Force the fit through the origin and it has nowhere to go, so it redistributes itself into whichever slopes will absorb it — which is the mechanism behind the four simultaneous false positives described a few sections below. The intercept is not a nuisance parameter; it is row 6 of the table.

The (1 + x2) factor deserves one more sentence, because it is a trap waiting in real data. At the image corner of a 1280×960 sensor with f = 800, x ≈ 0.5, so sec2θ = 1.25 and the same 1° error produces 800 × 0.017453 × 1.25 = 17.45 px rather than 13.96 px. So a large rotation error leaves a weak, genuinely radial residual — about 3.5 px of spread across the field here — sitting on top of its constant, and it can be misread as a mild distortion error. The discriminator is the value at r = 0: distortion is exactly zero at the principal point, a rotation is 13.96 px there.

(c) Radial distortion δk1 → residual ∝ r3 (and δk2 ∝ r5, δk3 ∝ r7). This is the row you cannot reach from Chapter 1's formula without a change of units, and it is where most people fumble, because Brown–Conrady is written in normalised coordinates while the residual you plot is in pixels. Write rn = rpx/f. The radial model pushes a point along its own radius from rn to rn(1 + k1rn2 + k2rn4 + k3rn6), so the displacement is

δn = k1rn3 + k2rn5 + k3rn7   (normalised units)

Multiply by f to convert to pixels, then substitute rn = rpx/f and let the powers of f collect:

δpx = f·δn = k1 rpx3/f2  +  k2 rpx5/f4  +  k3 rpx7/f6

Look hard at the denominators, because they are the whole reason the pixel-space powers are usable at all. Each extra order in r costs two more powers of f, and f is 800. Without the f2 you cannot get from the normalised formula in Chapter 1 to the r3 row of the table, and the numbers come out absurd — k1r3 alone at r = 400 would be eighteen million pixels.

Check it against a number you already have. Chapter 1 quoted k1 = −0.281 producing a 28 px inward pull at r = 400 px on an f = 800 lens, derived there in normalised coordinates. The pixel form must agree:

δpx = −0.281 × 4003 / 8002 = −0.281 × 64,000,000 / 640,000 = −0.281 × 100 = −28.1 px

It does, to the digit. That 4003/8002 = 100 is worth remembering as a sanity anchor: on an 800 px lens, a distortion coefficient converts to pixels at r = 400 by multiplying by 100.

Now the diagnostic version, which is different in an important way. You are never plotting k1; you are plotting the residual left by the error δk1 that survived calibration. Take a 5.0% error on that lens, δk1 = 0.0140, and evaluate at r = 460 px (roughly the corner radius of a 1280×960 frame measured from centre, and the largest radius the practice widget samples):

δpx(460) = 0.0140 × 97,336,000 / 640,000 = 0.0140 × 152.1 = 2.13 px

Two pixels at the corner from a 5% coefficient error — seven times a 0.30 px noise floor, and zero at the centre. That contrast is the entire diagnostic. The practice widget injects a deliberately gross version: 12 px at r = 400, which by the same formula is δk1 = 12 × 640,000/64,000,000 = 0.120, a 43% error, chosen so the cubic shape is legible on a phone screen rather than realistic.

And the tangential row falls out of the same substitution. The p-terms in Chapter 1's model are 2p1xy and p2(rn2 + 2x2): quadratic in normalised coordinates, so in pixels they scale as p·rpx2/f — one power of f in the denominator, not two. That is the r2 row. But they carry x and y separately rather than only through r, so the offset is not radially symmetric: it is largest along one diagonal and near zero along the other. That is the "coloured by angle" instruction in the table — a plain residual-vs-radius plot smears a tangential error into a fat band instead of a curve, and only colouring by azimuth separates it.

(d) Time offset td → residual ∝ image velocity u̇. The camera reports an image taken at true time t but stamped t − td, so the estimator predicts the feature using the pose from the wrong instant. Over that interval the feature has swept across the sensor at its image velocity u̇, so it lands td seconds' worth of motion away from the prediction:

Δu = u̇ · td     [px/s] × [s] = [px]  ✓

The unit check is not decoration — it is the fastest way to remember which way the fraction goes, and it is why the slope of a residual-vs-velocity regression comes out directly in seconds with no focal length in sight. On the Chapter 5 rig (f = 800 px, yaw 0.30 rad/s, so u̇ = f·ω = 240 px/s) an 18.4 ms offset produces 240 × 0.0184 = 4.42 px. Stand still and it produces exactly nothing, which is the observability collapse Chapter 3 spent its length on.

(e) Line delay tline → residual ∝ u̇ × row. A rolling shutter exposes image row k a delay of k·tline after row 0. That is the same derivation as (d) with td replaced by k·tline:

Δu = u̇ · (k · tline) = tline · (u̇ × k)

Two covariates multiplied together, which is why this row is the only one in the table whose "plot to draw" column has a division in it. And here is the number that makes the confusion concrete. Take the widget's tline = 20 µs/row. At the bottom of a 960-row sensor, k = 900:

k · tline = 900 × 20×10-6 = 0.0180 s = 18.0 ms

The widget's separate time-offset fault is 18.4 ms. So at the bottom of the image the two faults are numerically indistinguishable — 18.0 ms of effective lag versus 18.4 ms — while at the top row rolling shutter contributes zero and the time offset still contributes 18.4 ms. Nothing about them differs except how the lag varies with row. That is the derivation-level reason the fourth regression divides the residual by u̇ before looking at row index, and it is a far better answer than "because rolling shutter also depends on velocity."

Five derivatives, six rows of the table. The remaining two rows are already derived elsewhere in this lesson: the principal-point row is Worked Example 1 immediately below, and the gauge row is Chapter 0's argument that a uniform scaling of the world model leaves every projection exactly where it was. Here is the whole thing compressed to what each derivative multiplies:

Perturbed symbolFirst-order ΔuMultipliesWidget value
δtx in the extrinsicf·δtx · (1/Z)1/Z16.0 px·m
δθ in the extrinsicf·δθ · (1 + x2)113.96 px
δk1 in the lens model(δk1/f2) · rpx3r312 px at r = 400
td in the clocktd · u̇18.4 ms
tline in the shuttertline · (u̇ × k)u̇ × k20 µs/row

Worked example 1 — a 10-pixel principal-point error, priced out

Why is cx nearly invisible? Because it changes where you think the optical axis is, and that is almost the same as saying the camera is pointed slightly differently.

Step 1 — the bearing bias. A pixel at u maps to the ray direction (u − cx)/f. If cx is 10 px too large, every ray direction is shifted by

Δθ ≈ Δcx / f = 10 / 800 = 0.012500 rad = 0.7162°

and crucially every ray shifts by the same amount, in the same direction. That is exactly what a small rotation of the camera does. Since a single camera cannot observe its own orientation, nothing in the reprojection residual complains — the estimator simply reports a slightly rotated camera pose, and all the residuals stay small.

Step 2 — where it goes. Visual odometry estimates the direction of translation from the image. A 0.7162° boresight bias rotates that direction by 0.7162°. Over 100 m of straight driving:

lateral error = 100 × tan(0.7162°) = 100 × 0.012500 = 1.25 m

Step 3 — what it looks like in the field. The robot believes it drove straight down the aisle; it actually curved 1.25 m to one side over 100 m. Loop closure will fix the map, so the map looks fine. Live localisation between loop closures will be off by up to a metre, and the failure will present as "the robot clips the shelf on the long straight, but only on the eastbound run." The direction-dependence is the tell: a boresight bias is fixed in the camera frame, so it produces an error that flips sign when you drive the other way.

The metric that reveals it. Not a residual. Drive a straight, surveyed line in both directions and compare the estimated heading against the survey. A boresight bias produces equal and opposite lateral error on the two runs; a genuine yaw-rate bias in the gyro produces error in the same direction both ways. One experiment, two runs, unambiguous.

Worked example 2 — fitting the covariate, by hand, with a t-statistic

"The residual grows with image velocity" is an impression. "The slope is 18.5 ms with a t-statistic of 339" is a diagnosis. Here is the whole computation on five points, which is all you need to do at a whiteboard.

You have binned your residuals by image velocity and computed the mean residual in each bin:

image velocity u̇ (px/s)0200400600800
mean residual r (px)0.053.757.3011.1014.80

Step 1 — fit through the origin. A time-offset error has no intercept (zero motion, zero residual), so fit r = b·u̇ with no constant term. The least-squares slope is

b = ∑ u̇i ri / ∑ u̇i2

Numerator: 0×0.05 + 200×3.75 + 400×7.30 + 600×11.10 + 800×14.80
= 0 + 750 + 2920 + 6660 + 11840 = 22,170.
Denominator: 0 + 40,000 + 160,000 + 360,000 + 640,000 = 1,200,000.
b = 22,170 / 1,200,000 = 0.0184750 s = 18.475 ms.

Step 2 — the fit residuals. Predicted values are 0.018475 × u̇:
0,  3.695,  7.390,  11.085,  14.780.
Errors: 0.050,  0.055,  −0.090,  0.015,  0.020.
Sum of squares: 0.0025 + 0.003025 + 0.0081 + 0.000225 + 0.0004 = 0.014250.

Step 3 — the residual standard deviation. One fitted parameter, so n − 1 = 4 degrees of freedom:

s2 = 0.014250 / 4 = 0.0035625  →  s = 0.05969 px

Step 4 — the standard error of the slope. This is the one formula worth pushing on, because it is what turns a slope into a diagnosis, so derive it rather than quote it. It takes three lines.

4a. The estimator is linear in the data. Rewrite the slope with the denominator pulled inside the sum:

b = ∑ii ri / ∑jj2 = ∑i wi ri   with   wi = u̇i / ∑jj2

The weights wi are built only from the covariates, which we treat as known. So b is a fixed weighted sum of the observations — and the variance of a weighted sum of independent numbers is the sum of the squared weights times their variance.

4b. Propagate the variance. If the fit residuals are independent with common variance σ2:

Var(b) = σ2i wi2 = σ2 · ∑ii2 / (∑jj2)2 = σ2 / ∑ii2

One factor of the sum cancels against the square, and what is left is the whole formula: the more your covariate varied, the better you know the slope. That is the same statement as Chapter 3's observability argument, arrived at from the algebra instead of from the information matrix.

4c. Substitute s for σ and put the numbers in. We already have s2 = 0.0035625 from Step 3 and ∑u̇2 = 1,200,000 from Step 1:

Var(b) = 0.0035625 / 1,200,000 = 2.96875 × 10-9   s2
SE(b) = √(2.96875 × 10-9) = s / √(∑ u̇i2) = 0.05969 / 1095.4 = 5.449 × 10-5
The honest caveat, and say it before they ask. Step 4b assumed the residuals are independent. In a shipped monitor they are not: all 200 features in one keyframe are reprojected through the same pose estimate, so a pose error moves all of them together. Their residuals are positively correlated within a keyframe, and a positive intra-cluster correlation ρ inflates the true variance by the design effect 1 + (m − 1)ρ for m features per cluster. With m = 200 and a modest ρ = 0.05 that is 1 + 199×0.05 = 10.95, so the honest standard error is √10.95 = 3.31× the naive one and the reported t drops from 339 to about 102. Still decisive here — but on a marginal fault this is the difference between "|t| = 6, alarm" and "|t| = 1.8, say nothing." The fix is one line: aggregate to one number per keyframe first, then regress across keyframes. That is exactly why this worked example was handed to you as five binned means rather than ten thousand raw residuals.

Step 5 — the t-statistic.

t = b / SE(b) = 0.0184750 / 5.449×10-5 = 339.1

A t of 339 on 4 degrees of freedom is not a hint; it is a certainty. And the estimate itself, 18.475 ms, is your first correction — you have diagnosed and measured the fault from binned residuals you were already logging.

Why the t-statistic and not the R2. R2 tells you how much variance the covariate explains, which is dominated by how much your covariate happened to vary in that log. The t-statistic asks the question you actually care about: is this slope distinguishable from zero given my noise? A quiet log with little motion gives a small t and correctly refuses to conclude anything. Report t, and report the slope with its units, and you will never over-claim.

The intercept is not a nuisance — it is a fifth channel

I fitted that example through the origin, which is legitimate here because the intercept in this data is 0.05 px, i.e. nothing. In production, always fit the intercept and always read it. The reason is structural.

Look back at the table: every fault except one produces a residual proportional to some covariate. The exception is an extrinsic rotation error, which produces a constant. If you fit through the origin, that constant has nowhere to go, so it leaks into the slope of every regression you run — and you get four confident false positives at once. Fit an intercept and the constant lands where it belongs.

residual = a + b · covariate   →   a is the rotation channel, b is the covariate channel

So the reading is two-dimensional, and the two dimensions are independent:

intercept a ≈ 0intercept a large
slope b ≈ 0nothing geometric is wrong — suspect a gaugeextrinsic rotation error, magnitude a/f radians
slope b largethe fault this covariate namesboth — fix the rotation first, then re-run

The four regressions, as a triage procedure

Run all four every time. Each is one ordinary least-squares fit over the same residual buffer, so the whole thing is a handful of dot products.

#Covariate (x)yIf |t| is large, the slope means
1radius r from principal pointresidual (px)distortion coefficients are wrong; the fitted power tells you which order
2image velocity u̇residual (px)time offset, in seconds, directly
3inverse range 1/Zresidual (px)extrinsic translation error: the slope is f·Δt, so Δt = slope/f
4row index kresidual divided by u̇ (s)the line delay tline, in seconds per row
all four slopes flat, intercept largeextrinsic rotation, Δθ = a/f
all four slopes flat, intercept zerothe geometry is self-consistent; suspect a gauge

Where "px·m" comes from, in three lines of arithmetic. Regression 3 is the one whose units people cannot reproduce under pressure, and the threshold table below quotes alarms in px·m, so do it once by hand on the chapter's own numbers — f = 800 px, a Δt = 2 cm lever-arm error, which are exactly the constants the practice widget injects. From derivative (a), the residual is f·Δt/Z:

Range Zcovariate 1/Zresidual = 800 × 0.02 / Z
2 m0.50 m−1800 × 0.02 / 2 = 8.0 px
20 m0.05 m−1800 × 0.02 / 20 = 0.8 px

Regress the residual (px) on the covariate (m−1) through those two points:

slope = (8.0 − 0.8) / (0.50 − 0.05) = 7.2 / 0.45 = 16.0    [px] / [m−1] = px·m

A pixel divided by an inverse metre is a pixel-metre. That is the whole mystery. And now divide by the focal length, watching the units cancel:

Δt = slope / f = 16.0 px·m / 800 px = 0.020 m = 2 cm    [px·m]/[px] = [m]  ✓

which is the fault we injected, recovered from two binned residuals. So the thresholds in the health-monitor table are not conventions — they are lengths in disguise. The alarm at 4 px·m is 4/800 = 0.005 m = 5 mm of lever-arm error; the warn at 1 px·m is 1/800 = 1.25 mm. An equivalent phrasing that sticks better: 1 px·m means one pixel of residual on a feature one metre away, because at Z = 1 the covariate is 1 and the slope is the residual.

Why regression 4 divides by u̇. Rolling shutter's residual is u̇ × k × tline — it carries both covariates. Left alone it shows a strong slope against image velocity and looks exactly like a time offset. Divide the residual by u̇ first and the velocity dependence cancels, leaving td + k·tline: a genuine time offset becomes a flat line at height td, and rolling shutter becomes a ramp with slope tline. Same data, one division, two faults separated.

Separating r3 from r5 — the only row of the triage table that needs a second column

Regression 1's payoff line says "the fitted power tells you which order," and that sentence hides a problem: regress() above takes one covariate. It cannot fit a power. Neither should it try — fitting an exponent means a nonlinear solve, a starting guess, and a number like "2.87" that you then have to argue is really 3. There is a better move, and it is the one to build into the monitor.

Do not fit the power. Fit both columns and read both t-statistics. The candidate orders are known in advance — they are 3, 5 and 7, because that is what Brown–Conrady offers. So build a design matrix with r3 and r5 as two separate covariates, run one ordinary least squares, and let each coefficient defend itself. Linear, closed-form, and it returns a magnitude for each order rather than a disputed exponent.

Concretely: stack X = [r3, r5], solve the 2×2 normal equations XTXβ = XTy by Cramer's rule, and get a t for each. Two practical points before the arithmetic. First, no intercept in this one: radial distortion is exactly zero at r = 0, so an intercept here would only soak up an extrinsic-rotation constant that belongs to the intercept channel — run the rotation check first, subtract its constant, then fit this. Second, normalise the covariates. Raw r5 at r = 460 is 2×1013; in float32 the normal equations would be numerically dead. Divide by rmax so the columns are ρ3 and ρ5 with ρ = r/rmax ∈ (0, 1], and the coefficients come out in pixels of offset at the corner, which is the unit you want to report anyway.

The five-point example. Synthetic k1-only data on the lesson's lens: f = 800 px, a 5.0% coefficient error δk1 = 0.0140, sampled at r = 100, 200, 300, 400, 460 px. From derivative (c), the true offsets are δk1·r3/f2 = r3/45,714,286, so:

r (px)100200300400460
r31.000×1068.000×1062.700×1076.400×1079.734×107
true offset (px)0.0218750.1750000.5906251.4000002.129225
observed bin mean y (px)0.02290.17380.59201.39912.1298

The observed row is the true row plus about ±0.0013 px of noise — realistic for a bin mean, because averaging ~3,000 features at a 0.17 px per-feature σ gives 0.17/√3000 = 0.003 px of standard error on the mean.

Step 1 — the two normalised columns, ρ = r/460, c3 = ρ3, c5 = ρ5:

ρ0.217390.434780.652170.869571.00000
c3 = ρ30.0102740.0821900.2773900.6575161.000000
c5 = ρ50.0004860.0155370.1179820.4971771.000000

Step 2 — five dot products. That is the entire fit; there is nothing else to compute.

S33 = 1.516133    S35 = 1.360911    S55 = 1.261346    S3y = 3.228465    S5y = 2.897957

Step 3 — Cramer's rule on the 2×2. The determinant first, because it is also the collinearity warning:

det = S33S55 − S352 = 1.912369 − 1.852078 = 0.060291

Note how nearly the two products cancel — that is 3.2% of S33S55 surviving, and it is the first sign of what is coming in the trap below. Now the coefficients:

b3 = (S55S3y − S35S5y)/det = (4.072213 − 3.943861)/0.060291 = 0.128352/0.060291 = 2.128880 px
b5 = (S33S5y − S35S3y)/det = (4.393689 − 4.393654)/0.060291 = 3.545×10-5/0.060291 = 0.000588 px

Step 4 — residuals and the noise estimate. Fitted values b3c3 + b5c5 are 0.021872, 0.174981, 0.590599, 1.400065, 2.129468, so the errors are +0.001028, −0.001181, +0.001401, −0.000965, +0.000332. Five points, two fitted parameters, three degrees of freedom:

∑e2 = 5.4577×10-6  →  s2 = 1.8192×10-6  →  s = 0.001349 px

Step 5 — the two standard errors. For a two-column fit the covariance is s2(XTX)-1, and inverting a 2×2 swaps the diagonal:

Var(b3) = s2·S55/det = 1.8192×10-6 × 20.9211  →  SE(b3) = 0.006169
Var(b5) = s2·S33/det = 1.8192×10-6 × 25.1471  →  SE(b5) = 0.006764

Step 6 — the verdict, which is the whole point:

Columncoefficient (px at the corner)SEtreading
r32.12890.0062345.1overwhelmingly present
r50.00060.00680.087indistinguishable from zero

Two numbers, one sentence: "the cubic term is 2.13 px at the corner with t = 345, the quintic is zero to within ±0.02 px, so this is k1 and not k2." And invert the cubic coefficient to get the parameter itself, using derivative (c) backwards:

δk1 = b3 · f2/rmax3 = 2.128880 × 640,000 / 97,336,000 = 0.013998

which recovers the 0.0140 we injected to four figures. That is the difference between "distortion looks wrong" and "k1 is high by 0.0140, which is 5.0% — re-run the calibration, do not touch k2."

The trap, and you should raise it before the data springs it. Over a narrow radius band r3 and r5 are almost the same function, because their ratio is c5/c3 = ρ2, and if ρ only ranges over 0.9–1.0 that ratio barely moves. In the fit above the two columns already correlate at 0.9841 even though the log spans ρ from 0.217 to 1.0 — a variance-inflation factor of 32. Restrict the same five points to r = 440–460 px and the correlation goes to 0.9995, VIF 1025: the standard errors blow up from 0.062 to 0.205 at the same noise, and the 95% interval on the quintic coefficient widens to ±0.68 px, i.e. the r5 term is only bounded to ±32% of the cubic. The fit is decisive only when the log reaches inward, to about 0.2·rmax or better, so that ρ2 spans a factor of ~20.

So what do you do when it does not? Not fit the power anyway, and not report the larger coefficient as the answer. You do three things, in this order. (1) Report the pair jointly: "the radial residual is 2.1 px at the corner; over this log's radius coverage r3 and r5 are 99.95% collinear, so I can state the magnitude but not attribute the order." That sentence is a stronger report than a confident wrong exponent. (2) Publish the condition number or the column correlation next to the coefficients, so the dashboard cannot silently degrade into over-claiming when a robot spends a week working close to a wall. (3) Fix it with data, not with statistics — the radius coverage is a collection problem, exactly like the tilt-diversity problem in Chapter 1 and the motion-diversity problem in Chapter 3. Gate the regression on observed radius span the same way you gate the time-offset regression on median image speed, and refuse to publish an order when the span is short.

Same shape, third time. Chapter 1: without tilt diversity, B is unobservable. Chapter 3: without image motion, td leaves the information matrix. Here: without radius diversity, r3 and r5 are the same column. Every one is a rank-deficiency created by the data collection, not the estimator, and every one is fixed by moving the sensor rather than by improving the solver. If you can name that pattern out loud, you have said the thing this whole lesson exists to teach.
The most important row is the last one. "All four flat, intercept zero, and the system is still wrong" is not a dead end — it is a positive diagnosis. It rules out every geometric fault at once and points you straight at the six metric inputs from Chapter 0. Treating a clean residual plot as "no information" misses the strongest signal in the whole procedure.

The calibration health monitor

All of this should run on the robot, continuously, and cost nothing. Here is the shape of it with real sizes.

Per-keyframe residual record
For each inlier feature: residual (2 × float16), radius, image speed, inverse range, row index (4 × float16) = 12 B. 200 features × 12 B = 2.4 kB per keyframe.
↓ ring buffer
500-keyframe ring
500 × 2.4 kB = 1.2 MB resident. At 3 keyframes/s that is ~2.8 minutes of history — long enough to average out a corner, short enough to notice a change.
↓ every 10 s
Four through-origin regressions
100,000 residual records × 4 covariates × 4 flops (two multiply-accumulates each) = 1.6 Mflop per report. At 0.1 Hz that is 160 kflop/s — roughly 0.001% of one core. There is no excuse for not running it.
↓ every 60 s
Metric gauge watchdog
Integrate wheel-odometry distance and VIO distance over a 60 s window and publish the ratio. Two float accumulators. This is the only check that can see a gauge fault, and it is four lines of code.
/diagnostics at 0.1 Hz
Four slopes with t-statistics, the odometry ratio, per-camera RMS split, and the current td with its σ. About 200 B/report = 20 B/s. Cheaper than one image row.

The thresholds, and where each comes from:

SignalWarnAlarmWhere the number comes from
odometry / VIO distance ratiooutside 0.99–1.01outside 0.98–1.02wheel slip on a clean warehouse floor is well under 1%; 2% is bigger than any legitimate mechanism
td regression slope|t| > 5 and |b| > 1 ms|b| > 5 ms5 ms at 1 m/s is 5 mm, at the edge of a grasp budget
1/Z regression slopef·Δt > 1 px·m> 4 px·m4 px·m at f = 800 is Δt = 5 mm of lever-arm error
radius regression|t| > 5outer-quintile RMS > 2× innera healthy lens is flat in radius after undistortion
per-camera RMS spreadmax/min > 1.5> 2.0identical sensors on the same rig should agree; a 2× spread is one bad camera

The regressor

Four lines of numpy that will save you more field time than any other four lines in this lesson:

python
import numpy as np

def regress(cov, res):
    """Fit res = a + b*cov. Returns (a, b, t_of_b, n)."""
    x = np.asarray(cov, float); y = np.asarray(res, float)
    xc = x - x.mean(); yc = y - y.mean()
    Sxx = float(xc @ xc)
    if Sxx < 1e-18:                          # covariate never varied -> no information
        return float(y.mean()), 0.0, 0.0, y.size
    b = float(xc @ yc) / Sxx
    a = float(y.mean() - b * x.mean())
    e = y - a - b * x
    s = np.sqrt(float(e @ e) / max(y.size - 2, 1))
    return a, b, b / (s / np.sqrt(Sxx) + 1e-30), y.size

# the four channels. note regression 4 divides the residual by image speed,
# which is what separates rolling shutter from a plain time offset.
CHANNELS = [("radius",    RAD,      RES),
            ("img_speed", UDOT,     RES),
            ("inv_range", 1.0/Z,   RES),
            ("row",       ROW,      RES / np.maximum(UDOT, 20.0))]
for name, cov, y in CHANNELS:
    a, b, t, n = regress(cov, y)
    print("%-10s intercept %+8.3f  slope %+.6g  t %+8.1f  n %d" % (name, a, b, t, n))

Say while typing: "Two things I would defend here. First, the intercept — every one of these faults except an extrinsic rotation is proportional to a covariate, so if I force the fit through the origin the rotation's constant leaks into all four slopes and I get four false positives. The intercept is its own diagnostic channel. Second, the Sxx guard: if the robot never moved, the covariate is constant, and I want the function to report 'no information' rather than divide by nearly zero and publish a confident nonsense slope to a dashboard someone will act on."

The library form is scipy.stats.linregress or numpy.polyfit, and I would use them for exploration — but the shipped monitor is these four lines, because it has to run on the robot with no scipy and a fixed memory budget.

And the one extension it needs, because regression 1 promised to name the distortion order and a single-covariate fit cannot do that. This is the Cramer's-rule arithmetic from the r3-versus-r5 section, transcribed, plus the collinearity guard that section argued for:

python
def regress2(c1, c2, y):
    """Through-origin fit y = b1*c1 + b2*c2. Returns (b1, b2, t1, t2, corr)."""
    S11 = c1 @ c1; S12 = c1 @ c2; S22 = c2 @ c2
    S1y = c1 @ y;  S2y = c2 @ y
    det = S11 * S22 - S12 * S12
    corr = S12 / np.sqrt(S11 * S22)          # publish this NEXT TO the coefficients
    if det <= 1e-12 * S11 * S22:               # columns collinear -> refuse to attribute
        return None, None, 0.0, 0.0, corr
    b1 = (S22 * S1y - S12 * S2y) / det
    b2 = (S11 * S2y - S12 * S1y) / det
    e  = y - b1 * c1 - b2 * c2
    s2 = (e @ e) / max(y.size - 2, 1)
    return b1, b2, b1 / np.sqrt(s2 * S22 / det), b2 / np.sqrt(s2 * S11 / det), corr

# normalise so the columns are O(1) and the coefficients are px AT THE CORNER.
rho = RAD / RAD.max()
b3, b5, t3, t5, corr = regress2(rho**3, rho**5, RES)
# -> 2.1289  0.0006  345.1  0.087  0.984   (k1 is wrong; k2 is not)
# dk1 = b3 * f**2 / RAD.max()**3   -> 0.0140

The reasoning behind those two lines: "I am deliberately not fitting the exponent. The candidate orders are 3, 5 and 7 because that is what the lens model offers, so I put them in as separate columns and let each one carry its own t — that stays linear and closed-form, and it hands me a magnitude per order instead of an exponent I would then have to argue about. The corr return is not diagnostics decoration: over a narrow radius band these two columns run to 0.999 correlated, and I want the monitor to say 'magnitude yes, order no' rather than pick whichever coefficient the noise favoured."

Three named modes

Mode A: healthy residuals, wrong world (the gauge).

SymptomEvery residual regression is flat. RMS is 0.28 px. Loop closures snap shut. And the surveyed 40.0 m aisle comes back as 36.8 m.
The metricThe ratio of two independently scaled distance measurements over a fixed window — wheel odometry against VIO, or a surveyed landmark pair against the map.
The numberAlarm outside 0.98–1.02. In the Chapter 0 story the ratio was 1.081, an 8.1% discrepancy, which is four times the alarm band and would have fired on day one.
Nearest decoyWheel slip also breaks the ratio — but slip is noisy and one-directional (odometry over-reports on a slipping wheel) and varies with surface, while a gauge error is a constant, repeatable factor on every surface and both directions. Log the ratio's standard deviation, not just its mean: slip has a big one, a gauge error has almost none.

Mode B: the slow thermal walk.

SymptomFleet-wide RMS creeps from 0.28 px in January to 0.41 px in July. No single robot looks broken; no deploy correlates. Everyone blames the new feature detector.
WhyAluminium expands about 23 parts per million per kelvin. A 200 mm camera-to-LiDAR bracket over a 30 K swing changes length by 200 × 23×10-6 × 30 = 0.138 mm, and differential expansion tilts it by a comparable angular amount. Plus lens elements move.
The metricRegress RMS against reported board or die temperature, per robot, over months. Also plot the estimated extrinsic (if you estimate it online) against temperature.
The numberA slope above ~0.005 px/K is a real thermal effect. Below that, look elsewhere. The confirmation is that the effect is reversible: the same robot in a cold aisle at night reads 0.28 px again.
Nearest decoyGenuine mechanical loosening also grows over months — but it is monotonic and irreversible, and it does not come back at night. Temperature-correlated and reversible means thermal; monotonic and one-way means a screw.

Mode C: one bad sensor hidden by the aggregate.

SymptomFleet RMS is 0.34 px, which is above the 0.30 px target but not alarming. It has been like that for two months. Nobody can find the cause because every robot looks the same.
WhyFive of six cameras are at 0.28 px and one is at 0.64 px. The mean of {0.28×5, 0.64} is 0.34 — comfortably mediocre and completely uninformative. Aggregation destroyed the signal.
The metricReport residuals split by camera, and by image octant within each camera. Never publish a single number where a group-by is available.
The numberAlarm when max/min across cameras exceeds 2.0. Here it is 0.64/0.28 = 2.3, which fires immediately once the split exists.
Nearest decoyA camera pointed at a genuinely harder scene (glare, low texture) also has higher residuals — but that varies with location and time of day, while a bad calibration is constant across the route. Group by camera and by map region: constant across regions is calibration, varying is the scene.
The pattern in all three. Every one is a case where an aggregate hid a conditional. Mean residual hid a gauge error, mean-over-months hid a temperature dependence, mean-over-cameras hid one bad camera. The transferable habit: whenever a health metric is a single scalar, ask what you would see if you grouped it by the covariate most likely to matter. That one question routinely uncovers faults that months of staring at the aggregate never will.

The frontier, briefly

Practice

The residual reader

Pick a fault, then pick a covariate for the x axis. Three things to go and find: rolling shutter lights up against image velocity and against row index, which is why it gets mistaken for a time offset; extrinsic Δθ lights up against nothing but pushes the whole cloud off the zero line, which is the intercept channel; and square size stays flat and centred on all four, because a gauge fault has no residual at all.

A fourth thing, and it is the one that earns the two-column fit above: select distortion against radius and read the intercept. It comes back around −5.8 px, and at 3× magnitude around −17 px — a large constant on a fault that has no constant term at all. That is a straight line trying to fit a cubic and pushing its intercept negative to average the error; take it at face value and you will report a phantom extrinsic rotation. Regression 1 is the one row of the triage table whose slope is only a detector. Once it fires, you switch to regress2 with r3 and r5 and no intercept, which is what actually names the order.

Then do the thing the design table above only asserted: drag the magnitude down and watch where the alarm dies. The widget prints its own detection floor — the smallest slope this many samples at this noise level can resolve at |t| = 5 — so you can walk the fault from 3× nominal to zero and find the exact size at which a real fault becomes unreportable. The thresholds in the design table should be numbers you have measured by the time you leave this widget, not numbers you read.

Fault magnitude 1.00 × nominal
FAULT 
X AXIS 
You ran all four residual regressions on a misbehaving robot. Every slope came back flat with |t| under 1, and every intercept is within noise of zero. What have you learned?

Chapter 5: The Fault Injector — Build the Table by Breaking Things

Everything so far has been derivations and tables. Tables are how the knowledge is stored; they are a terrible way to learn it, because a table read is forgotten by Thursday and a table you built from experiments is not.

So here is the rig, and here are seven ways to break it. Turn one knob at a time and watch which of the six downstream signals moves. By the time you have been through all seven you will have the symptom-to-cause table in your head, derived rather than memorised — and then the blind drill at the bottom will tell you whether that is true.

The rig — optics and geometry. A 32-beam LiDAR and a 1280×960 camera (f = 800 px, principal point at the image centre, global shutter, 5 ms exposure) rigidly mounted on a warehouse robot. The LiDAR provides metric range, which is what makes this a real test bench: when you project a LiDAR point into the image you have an absolute reference, so an intrinsic error produces a residual instead of being absorbed. The nominal yaw rate is 0.30 rad/s, so a feature sweeps the image at f·ω = 800 × 0.30 = 240 px/s. Six structures sit at 1, 2, 5, 10, 20 and 50 m, at image radii from 80 to 460 px.

This is a running monitor, not a geometry exercise

Before any of the physics, be able to say what this thing is in a system. "We project the LiDAR into the image and look at the residual" immediately raises four questions — at what rate, on what buffer, in what array, and inside what budget — and those four answers are the difference between a monitor and a Jupyter notebook.

The monitor is an online gate: it runs at 1 Hz on a 3-second ring buffer and has 40 ms of one core to produce six numbers. Here is every boundary it crosses, with the shape and the byte count on each one.

StageRateWhat crosses the boundarySize
LiDAR driver10 Hz32 beams × 1800 azimuth bins = 57,600 points per sweep as an (N,4) float32 array of x/y/z/intensity, plus a per-point (N,) float64 capture time0.92 MB/sweep
9.2 MB/s
Camera driver30 fps(960,1280) uint8 mono frame, global shutter, 5 ms exposure, hardware-trigger timestamp at mid-exposure1.23 MB/frame
36.9 MB/s
Ring buffer3 s of both: 30 sweeps (27.6 MB) + 90 frames (110.6 MB), overwritten in place, no allocation on the hot path138 MB resident
Association1 HzEach sweep is matched to the frame whose timestamp is nearest, then the camera pose is linearly interpolated between the two bracketing frames. Nearest-frame alone would carry up to half a frame interval — 16.7 ms, which at 240 px/s is 4.0 px, larger than every fault we want to see. Interpolation drops that to the curvature term over 33 ms and leaves a ~2 ms floor on any td this monitor can honestly report.
Projection1 HzDecimate 4:1 → 14,400 pts/sweep, transform by CTL, project through K → (N,2) float32 pixel array. 30 sweeps × 14,400 = 432,000 points per monitor tick.3.5 MB
Edge residual1 HzOne bilinear fetch per point into a precomputed distance transform of the image edge map → (N,) float32 signed residual and (N,) int32 structure id. Points with no edge within 8 px or invalid depth are dropped; ~11% survive, so ~48,000 residuals reach the gauges.0.4 MB
Six reductions1 HzEach gauge is one named reduction over that (N,) vector (below). Then two 6-point OLS fits over the per-structure means.6 float64

The 40 ms, spent. Transform and projection over 432,000 points at ~20 flops each is 8.6 Mflop, about 4 ms. The distance-transform lookup is one cache-hostile bilinear fetch per point at ~50 ns, which is 22 ms and is the hot spot. The six reductions over 48,000 survivors are ~1 ms. The two OLS fits are over six aggregated numbers and are free. Typical tick 27 ms, p99 40 ms — 4% of one core at 1 Hz, which is what makes it affordable to leave running on every robot forever.

Where the gauges come from, exactly. Let res be the (N,) residual vector and Z the matching (N,) range vector. Then RMS = √(mean(res2)) over all N. Colouring @ 2 m = mean(res[(Z > 1.5) & (Z < 2.5)]), and @ 20 m is the same reduction on the 15–25 m shell. Map scale is the only gauge that does not come from res at all — it is surveyed distance divided by mapped distance, supplied from outside. The two slopes are OLS over the six per-structure means against two (6,) regressor vectors: image radius r, and image velocity u̇. Naming the reduction is half the answer; saying "reprojection error" without saying which reduction over which subset is the tell of someone who has not built one.

The scene, and why each structure has its own image velocity

Chapter 3 established that a time offset is only observable where u̇ ≠ 0, and that you estimate it by regressing residual on image velocity. A regression needs spread in its regressor. If every structure in the scene sweeps at the same 240 px/s, the velocity column has zero variance, Sxx = 0, and the slope is undefined — you can no longer measure td, only assume it.

So the monitor's 3-second window is taken during a slalom, where the yaw rate is deliberately not constant. Each structure's residual sample is tagged with the image velocity it actually had:

StructureYaw rate ωu̇ = fω
1 — Z = 1 m, r = 80 px0.300 rad/s240 px/s
2 — Z = 2 m, r = 160 px0.575 rad/s460 px/s
3 — Z = 5 m, r = 240 px0.275 rad/s220 px/s
4 — Z = 10 m, r = 320 px0.225 rad/s180 px/s
5 — Z = 20 m, r = 400 px0.575 rad/s460 px/s
6 — Z = 50 m, r = 460 px0.300 rad/s240 px/s

The lateral offset of each structure in metres follows from X = rZ/f, so the six are at 0.10, 0.40, 1.50, 4.00, 10.00 and 28.75 m off the optical axis — worth having, because the depth-squared term in the derivation above is fX/Z2, and fX/Z2 = r/Z.

Structures 2 and 5 — the two the colouring gauges read — were both sampled at 460 px/s. That is deliberate too: it means a pure time offset produces equal colouring at 2 m and 20 m, so it perfectly imitates a boresight error on the ratio test and can only be unmasked by the velocity column. The bench would be easier and less honest without it.

Those six velocities are not arbitrary. They were chosen so that the velocity column is nearly orthogonal to the other three regressors the bench cares about — r, r3 and 1/Z. Orthogonal means the mean-centred dot product is zero, so a fault that lives in one regressor leaks nothing into the others. Check the worst of the three by hand, u̇ against r:

ū = (240 + 460 + 220 + 180 + 460 + 240) / 6 = 1800 / 6 = 300 px/s
r̄ = (80 + 160 + 240 + 320 + 400 + 460) / 6 = 1660 / 6 = 276.667 px
Sur = ∑(u̇i − 300)(ri − 276.667)
= (−60)(−196.667) + (160)(−116.667) + (−80)(−36.667) + (−120)(43.333) + (160)(123.333) + (−60)(183.333)
= 11,800 − 18,667 + 2,933 − 5,200 + 19,733 − 11,000 = −400

Compare that to the scale of the two columns: Suu = 602+1602+802+1202+1602+602 = 79,200 and Srr = 104,333. The correlation is −400 / √(79,200 × 104,333) = −400 / 90,893 = −0.0044. Four parts in a thousand. That is why, in the experiments below, a pure time offset leaves the radius slope at −0.007 px/100 px — visually zero — and a pure focal error leaves the velocity slope at −0.10 ms.

This is a design decision, and it is the answer to a real design question. "How would you set up a calibration monitor so its diagnostics are not confounded?" — you choose where and when you sample so the regressor columns decorrelate, exactly as you would design a factorial experiment. The counter-example is right here in the same scene: r and 1/Z are strongly negatively correlated (the far structures are also the peripheral ones), which is why an extrinsic translation fault will spill a large fake radius slope into the dashboard. That confound is real, it survives to the worked example at the bottom of this chapter, and the cure is a joint fit rather than six marginal slopes.

The six signals on the dashboard

SignalHealthyWhat it is
Reprojection RMS0.28 pxRoot-mean-square distance between where a LiDAR point projects and where the image says it is. The number every dashboard shows.
Map scale ratio1.000Mapped distance divided by surveyed distance. Needs an external metric reference; nothing internal produces it.
Colouring @ 2 m0.0 pxProjection offset on a near structure.
Colouring @ 20 m0.0 pxProjection offset on a far structure. The pair of these two is the extrinsic discriminator.
Slope vs velocity0.0 msOLS slope of the six per-structure residual means against the six image velocities in the table above, Su̇e/Su̇u̇. Its units are px / (px/s) = seconds, so it reads out directly in milliseconds of time offset.
Slope vs radius0.0 pxOLS slope of the same six means against image radius, Sre/Srr, scaled to px per 100 px of radius. Catches focal length and distortion — and, because r is correlated with 1/Z in this scene, it also picks up extrinsic translation. That confound is real; see the worked example at the end.

Derive the model the bench is built from

The bench is not evidence. It is a simulator, and a simulator cannot prove the equations it was coded from — if you slide the extrinsic-rotation knob and see a depth-independent offset, all you have proved is that somebody typed a depth-independent expression. So derive the expressions first, and then use the bench for what a bench is actually good at: turning three symbolic terms into an experience you will still have in six months.

Take one LiDAR point pL. The correct extrinsic puts it in the camera frame as p = (X, Y, Z) = R pL + t. Now suppose the calibration you shipped is wrong by a small rotation δθ and a small translation δt. A small rotation is I + [δθ]× to first order, so the point you actually compute is

p′ = (I + [δθ]×) R pL + t + δt = p + δθ × p + δt   (to first order)

So the 3-D perturbation is δp = δθ × p + δt. Write out the two components that matter for the horizontal pixel coordinate, using the cross-product definition:

δX = δθyZ − δθzY + δtx
δZ = δθxY − δθyX + δtz

Now push it through the projection u = f·X/Z + cx. This is where the depth structure appears, and it appears because of the quotient rule and nothing else:

δu = (∂u/∂X)δX + (∂u/∂Z)δZ = (f/Z)·δX − (fX/Z2)·δZ

Substitute the two lines above and expand every product:

δu = fδθy − fδθz(Y/Z) + fδtx/Z − (fXY/Z2)δθx + (fX2/Z2)δθy − (fX/Z2)δtz

Six terms. Group them by what they do to the depth axis, keeping only the paraxial ones (X, Y small next to Z, which is the regime the bench models):

δu ≈ f·δθy  +  f·δtx/Z  −  (fX/Z2)·δtz

Read the three terms and the whole chapter is already in your hands:

What the paraxial approximation threw away, in numbers. The exact yaw term is f·δθy(1 + X2/Z2) = f·δθy(1 + (r/f)2). At the outermost structure, r = 460 and f = 800, so (460/800)2 = 0.5752 = 0.331 — the true offset there is 33% larger than the constant the bench draws. That is not a rounding error; it is a genuine radial signature hiding inside what everyone calls "the constant one". On a wide-angle lens (f = 300, r = 460) the factor is 1 + 1.5332 = 3.35, and the "constant offset means rotation" rule you are about to learn stops being true. Know the regime your rule of thumb lives in.

The intrinsic faults are not in that expansion because they act after projection, on the pixel itself. Chapter 1 derived them: a focal-scale error ε = Δf/f moves a pixel by ε·r (linear in radius), a principal-point error moves it by Δcx (constant), and a radial distortion coefficient moves it by k1r3/f2 (cubic in radius, because the normalised radius is r/f and the Brown term is k1·x·(r/f)2). Chapter 3 gave the temporal one: u̇·td. And Chapter 0 gave the gauge: zero residual, scale s/25.

That is the complete model behind every gauge below. Nothing in the widget is unexplained.

Calibration fault injector

Move one slider at a time and watch the six gauges. Bars turn amber past the warning line and red past the alarm line. The last slider is not a fault — it is the measurement noise, and it survives “reset all” because it is a property of the world, not of the robot. Above σ = 0 every estimated gauge grows a ±1.96 SE whisker and prints its interval, and the verdict line starts reporting the near/far ratio with a confidence interval instead of a bare number. Then press Mystery fault, read only the gauges, and name the cause.

focal length Δf0.0 %
principal point Δcx0.0 px
distortion Δk10.000
extrinsic Δθ0.00°
extrinsic Δt0.0 cm
time offset td0.0 ms
board square size25.0 mm
measurement noise σ0.00 px

The seven experiments, and what each one proves

Do these in order. Reset between each. Each one takes fifteen seconds and settles a question that costs teams weeks.

Experiment 1 — board square size, 25.0 → 23.0 mm. Watch every gauge except map scale ratio stay exactly where it was. RMS: 0.28. Colouring near: 0.0. Colouring far: 0.0. Both slopes: 0.0. Map scale: 0.920.

Proves: a metric gauge has no residual signature. The five internal signals are blind by construction, because the reconstruction is perfectly self-consistent in the wrong units. Only the sixth signal, which requires an external reference, sees it. This is the Chapter 0 story, reproduced as an experiment.

Experiment 2 — extrinsic rotation, 0 → 1.00°. Colouring at 2 m goes to 13.96 px. Colouring at 20 m goes to 13.96 px. Identical.

1.00° = 1.00 × π/180 = 0.0174533 rad
Δu = f·tan(Δθ) = 800 × tan(0.0174533) = 800 × 0.0174551 = 13.964 px
at Z = 2 m: 13.964 px  ·  at Z = 20 m: 13.964 px  ·  ratio = 13.964 / 13.964 = 1.000

Proves: f·Δθ has no depth in it. The two readings are identical because Z does not appear anywhere in the expression — not because the two structures happen to be similar. Two structures a decade apart in range, the same offset. Both slope gauges stay at exactly zero, because a constant is not a slope — it is an intercept, and OLS puts constants in the intercept. Note also that tan(0.0174533) = 0.0174551 differs from the angle itself only in the sixth decimal, which is why everyone writes f·Δθ and nobody notices; at 10° the small-angle version would be off by 1%.

Experiment 3 — extrinsic translation, 0 → 2.0 cm. Colouring at 2 m goes to 8.0 px. Colouring at 20 m goes to 0.8 px. A factor of exactly ten, for a factor of exactly ten in range.

Δu = f·Δt/Z
at Z = 2 m: 800 × 0.020 / 2 = 16.0 / 2 = 8.00 px
at Z = 20 m: 800 × 0.020 / 20 = 16.0 / 20 = 0.800 px
ratio = 8.00 / 0.800 = 10.0 = 20 / 2 = the range ratio

Proves: f·Δt/Z. The numerator f·Δt = 16.0 px·m is the same for both structures; only the division by Z differs, so the ratio of the two readings is the inverse ratio of the two ranges, always, with no dependence on f or on Δt. Put experiments 2 and 3 side by side and you have the entire LiDAR-camera extrinsic diagnostic: the ratio of near offset to far offset is the answer. Ratio 1 means rotation; ratio 10 means translation. And because the ratio is independent of the fault magnitude, it works on a fault too small to alarm the RMS gauge. Now look at the radius slope, which also moved, to −3.81 px per 100 px. Nothing radial is broken. That reading is a confound — in this scene the far structures are also the peripheral ones, so a 1/Z residual is automatically a decreasing-with-r residual too. It is the single most common way a competent engineer misreads this dashboard, and the worked example at the end of the chapter takes it apart properly.

Experiment 4 — time offset, 0 → 18.4 ms. RMS jumps to 5.92 px. The velocity slope reads 18.4 ms. Both colouring gauges move by the same amount — not because velocity is uniform (it is not), but because structures 2 and 5 were both sampled at 460 px/s.

Δui = u̇i·td, with td = 0.0184 s
static structures (u̇ = 240): 240 × 0.0184 = 4.416 px   [structures 1 and 6]
fast structures (u̇ = 460): 460 × 0.0184 = 8.464 px   [structures 2 and 5 — the two colouring gauges]
the other two: 220 × 0.0184 = 4.048 px,   180 × 0.0184 = 3.312 px

Now do the regression by hand, because this is the gauge whose whole claim is that it measures rather than restates. Deviations of u̇ from ū = 300 are (−60, +160, −80, −120, +160, −60); deviations of the offsets from their mean 5.520 are (−1.104, +2.944, −1.472, −2.208, +2.944, −1.104):

Su̇e = (−60)(−1.104) + (160)(2.944) + (−80)(−1.472) + (−120)(−2.208) + (160)(2.944) + (−60)(−1.104)
= 66.24 + 471.04 + 117.76 + 264.96 + 471.04 + 66.24 = 1457.28
Su̇u̇ = 602 + 1602 + 802 + 1202 + 1602 + 602 = 79,200
slope = Su̇e / Su̇u̇ = 1457.28 / 79,200 = 0.01840 s = 18.40 ms

And the RMS, so you can check the top gauge too: mean of the six squared offsets is (4.4162 + 8.4642 + 4.0482 + 3.3122 + 8.4642 + 4.4162)/6 = 210.03/6 = 35.005, and RMS = √(0.282 + 35.005) = √35.083 = 5.92 px.

Proves: the velocity slope reads out the fault in its own units. You do not infer a time offset from this plot; you measure it, and the number that comes out of Sxy/Sxx is the number you put in, to five digits. Note that colouring near and far both moved equally (8.464 and 8.464), so it masquerades as a rotation error on the ratio test alone — and the thing that unmasks it is the velocity column, which is why the scene was built to have one. Check the cross-talk while you are here: the radius slope reads −0.007 px/100 px, dead flat, because u̇ was designed orthogonal to r.

Experiment 5 — focal length, 0 → +2.0%. RMS climbs to 6.14 px. The radius slope goes to 2.0 px per 100 px of radius. Colouring at 2 m and 20 m both move, but by amounts set by where in the image the structure is, not by its range.

ε = Δf/f = 0.02,   Δu = ε·r
at r = 160 (the 2 m structure): 0.02 × 160 = 3.20 px
at r = 400 (the 20 m structure): 0.02 × 400 = 8.00 px
ratio = 3.20 / 8.00 = 0.400 — neither 1 nor 10, so the extrinsic ratio test correctly refuses to fire
OLS slope = Sre/Srr = ε·Srr/Srr = ε = 0.02 px/px = 2.00 px per 100 px

That middle line deserves a beat: because the offset is exactly ε times the regressor, the OLS slope is ε identically — Sre = ∑(ri−r̄)(εri−εr̄) = ε·Srr, and the Srr cancels. The gauge is not correlated with the fault; it is the fault, in units of fractional focal error.

Proves: an intrinsic scale error is radial and linear: offset = ε·r. This is why the LiDAR matters — without a metric reference the camera would happily absorb a wrong focal length into a rescaled reconstruction and show you nothing. Cross-talk check: the velocity slope reads −0.10 ms, inside the 1 ms warn band, so the temporal gauge stays quiet.

Experiment 6 — distortion k1, 0 → +0.02. The radius slope moves to 0.78 px per 100 px, but look at the shape in the dashboard's scene strip: the offset is nearly nothing at 160 px of radius and large at 460 px.

Δu = k1·r3/f2,   f2 = 8002 = 640,000
at r = 460: 4603 = 97,336,000 → 0.02 × 97,336,000 / 640,000 = 1,946,720 / 640,000 = 3.042 px
at r = 160: 1603 = 4,096,000 → 0.02 × 4,096,000 / 640,000 = 81,920 / 640,000 = 0.128 px
cubic ratio = 0.128 / 3.042 = 0.0421 = 1 / 23.8
linear ratio for comparison = 160 / 460 = 0.348 = 1 / 2.875

Proves: distortion is cubic in radius where focal length is linear. Both light up the same gauge; the power separates them, and the separation is enormous — a factor of 23.8 against a factor of 2.9 between the same two structures. That is the whole discriminator, and it is why you fit the residual against a design matrix with both an r column and an r3 column and compare the two t-statistics, rather than fitting "radius" and squinting. The code block below does exactly that, because a rule you cannot implement is a rule you will misapply.

Experiment 7 — principal point, 0 → +10 px. Colouring moves by 10 px everywhere. Both slopes stay at zero. RMS climbs to 10.00 px.

Δu = Δcx = 10 px on every structure, at every range, at every radius
the extrinsic rotation that produces the identical dashboard:
Δθequiv = arctan(Δcx/f) = arctan(10/800) = arctan(0.0125) = 0.0124993 rad = 0.7162°
RMS = √(0.282 + 102) = √100.078 = 10.004 px

Proves: to first order a principal-point error is a constant image shift — indistinguishable, from these six signals alone, from an extrinsic rotation of 0.7162°. Set the cx slider to +10 and note the six readings; reset, set the Δθ slider to 0.72°, and the six readings are the same to two decimals. Not similar. The same. That degeneracy is real, it is why the two are strongly correlated in any joint calibration, and it has a literature (see Frontier below). To separate them you need a signal these gauges do not have: a second camera (the principal-point error is per-camera, the extrinsic is per-pair), the (1 + (r/f)2) off-axis term from the derivation above if your lens is wide enough to make it measurable, or the trajectory heading against a survey, as in Chapter 4.

Experiment 8 — turn the noise on, and watch the diagnosis rot

Experiments 1–7 were run at σ = 0, which is a lie of omission every bench tells. In the field each gauge is an estimate, and an estimate without an interval is a rumour. The seventh slider adds Gaussian edge-localisation noise of standard deviation σ to each structure's residual sample, and the widget starts printing ±1.96 SE next to every reading and a whisker on every bar.

The first thing to be honest about is the sample size, because this is where most monitoring dashboards lie to themselves. Roughly 48,000 residuals reach the gauges every tick, so the naive standard error of a per-structure mean is σ/√8000 = σ/89.4 — at σ = 1 px that is 0.011 px, and you would conclude that every fault above a hundredth of a pixel is detectable. It is not, because those 48,000 numbers are nowhere near independent. Edge localisation on one wall segment shares an extraction bias across every point that lands on it; the correlated part does not average away. The honest effective sample size is a handful of independent error draws per structure per window:

neff ≈ 4 per structure per 3-second window
SE(colouring) = σ/√neff = σ/2
at σ = 1.0 px: SE = 0.50 px,   1.96·SE = 0.98 px
the naive √48,000 answer would have said 0.011 px — 45× too confident

Now re-run Experiment 3 (extrinsic translation, 2.0 cm) with σ = 1.0 and read the far gauge:

colouring @ 20 m = 0.80 ± 0.98 px → 95% CI [−0.18, +1.78]
the interval contains zero → the far reading is not distinguishable from no fault at all

And that is the whole failure, in one line. The near/far ratio test needs a far reading, and there is no longer one. Propagate the two intervals through the delta method and watch the ratio come apart:

SE(ratio) = |ratio| · √((SE/near)2 + (SE/far)2)
= 10.0 × √((0.5/8.0)2 + (0.5/0.8)2) = 10.0 × √(0.0039 + 0.3906) = 10.0 × 0.628 = 6.28
ratio = 10.0, 95% CI [−2.3, +22.3]contains 1

An interval that contains 1 means the data cannot tell an extrinsic translation from an extrinsic rotation. The bench refuses to print a ratio at all in this state and says why — "near/far ratio undefined: the far gauge is consistent with zero" — because a dashboard that prints "10.2" here is lying by omission. An engineer reading only the point estimates from such a dashboard, on the next realisation of the same noise, diagnoses an extrinsic rotation on a robot whose lever arm is 2 cm out. Wrong fault, wrong fix, and a confident-sounding write-up.

Try it: set extrinsic Δt to 2.0 cm, then walk σ up from 0 and watch the verdict line. The far reading holds at 0.80 px the whole way; what changes is the whisker, 1.96·σ/2 = 0.98σ, and the moment that exceeds 0.80 — at σ = 0.85, one slider notch past 0.80 — the diagnosis stops being available. Nothing about the robot changed. Nothing about the fault changed. The instrument stopped being able to see it, and the only thing on the dashboard that told you so was the width of a whisker.

Now dial σ back to 0.35 px and the same bench recovers:

SE = 0.35/2 = 0.175 px,   1.96·SE = 0.343 px
colouring @ 20 m = 0.80 ± 0.34 → CI [0.46, 1.14], excludes zero
SE(ratio) = 10.0 × √((0.175/8)2 + (0.175/0.8)2) = 10.0 × 0.2198 = 2.198
ratio = 10.0, 95% CI [5.7, 14.3]inconsistent with 1, so: translation
The metric that reveals the failure is the width of the interval, not the value. Both runs show a far reading "near zero". Only the interval tells you whether that is a measurement or an absence of one. This is why the honest form of an answer in a review is never "the ratio is 10" — it is "the ratio is 10, 95% CI 5.7 to 14.3, inconsistent with 1", and when the interval straddles 1 the correct sentence is "this window cannot separate the two extrinsics; give me a longer buffer or a nearer structure" rather than a guess.

What actually buys precision here. Not more points — we just saw that 48,000 of them bought 4 independent draws. Three things move the interval: a longer buffer (neff grows with independent windows, so 30 s instead of 3 s is a factor of √10 = 3.2), a nearer far-field structure is useless but a farther near-field one helps because it widens the 1/Z lever, and better edge localisation lowers σ directly. "I would average more points" is the instinct that fails here; "I would extend the window, because the noise is spatially correlated and only independent windows count" is the one that works.

The slope gauges degrade too, and their standard errors are the classic OLS form SE(slope) = SE(point)/√Sxx. Both regressors were designed with real spread, so both survive:

Gauge√SxxSE at σ = 1.0Reading ± 95%t
slope vs velocity (td = 18.4 ms)281.4 px/s0.50/281.4 = 1.78 ms18.4 ± 3.5 ms10.4
slope vs radius (Δf = +2%)323.0 px0.50/323.0 → 0.155 px/100 px2.00 ± 0.3012.9

Now imagine the scene had been sampled at a nearly constant yaw rate — velocities (240, 260, 240, 260, 240, 260) instead of the designed spread. Then Sxx = 6×102 = 600, √Sxx = 24.5, and SE(slope) = 0.50/24.5 = 20.4 ms. The reading would be 18.4 ± 40 ms — a t-statistic of 0.9, indistinguishable from no time offset at all. Same noise, same fault, same estimator; the scene design is what decided whether the fault was visible. That is Chapter 3's observability gate reappearing as a property of where you point the robot.

The table you just built. Ratio of near to far colouring separates the two extrinsics. The velocity slope isolates time. The radius slope isolates the intrinsics, and its power separates focal length from distortion. Nothing internal touches the gauge, and nothing here separates cx from Δθ. Five clean diagnoses, one honest degeneracy. That is what a real failure taxonomy looks like — it names its own blind spots.

The diagnostic, from scratch

Everything above is a procedure a human runs by eye. The version that ships is forty lines, it runs once a second on every robot, and writing it is the fastest way to find out whether you actually understood the ratio test — because code will not let you wave at "and then you look at the slope."

Three pieces. An OLS slope by hand, as Sxy/Sxx, so nothing is hidden inside polyfit. A two-column radial fit against r and r3 simultaneously, which is the instruction Experiment 6 gave and which nobody ever implements. And a return type that can say "I do not know" — the degeneracy flag, without which the function will confidently label a principal-point error as a boresight error forever.

python
import numpy as np

F, N_EFF = 800.0, 4          # focal px; independent error draws per structure

def ols_slope(x, y):
    """Slope of y on x, by hand: Sxy / Sxx. No polyfit, no lstsq."""
    n  = len(x)
    mx = sum(x) / n                                   # x-bar
    my = sum(y) / n                                   # y-bar
    Sxy = sum((x[i] - mx) * (y[i] - my) for i in range(n))
    Sxx = sum((x[i] - mx) ** 2          for i in range(n))
    return Sxy / Sxx, Sxx                          # slope, and the lever we paid for it

def radial_powers(r, e, se):
    """Fit e = c + a*r + b*r**3 in ONE regression and return both t-stats.
       Linear-in-r is a focal-length error; cubic-in-r is distortion k1.
       Fitting them separately is the classic mistake: r and r**3 are
       correlated 0.94 over this radius range, so a univariate r-fit on a
       pure distortion fault comes back significant and you call it focal."""
    A    = np.column_stack([np.ones_like(r), r, r ** 3])   # (6,3) design matrix
    AtA  = A.T @ A                                        # (3,3) normal matrix
    beta = np.linalg.solve(AtA, A.T @ e)                 # (3,) = [c, a, b]
    cov  = np.linalg.inv(AtA) * se ** 2                  # Cov(e) = se**2 * I
    t    = beta[1:] / np.sqrt(np.diag(cov)[1:])          # (t_r, t_r3)
    return beta, t

def diagnose(offsets, ranges, radii, velocities, map_scale, sigma=0.35):
    """Six gauges in -> (fault_label, magnitude, degeneracy_flag) out.

    offsets    (6,) mean residual per structure          [px]
    ranges     (6,) Z per structure                      [m]
    radii      (6,) image radius per structure           [px]
    velocities (6,) image velocity per structure         [px/s]
    map_scale  scalar: surveyed distance / mapped distance
    """
    e = np.asarray(offsets,    float)
    r = np.asarray(radii,      float)
    Z = np.asarray(ranges,     float)
    u = np.asarray(velocities, float)
    se = sigma / np.sqrt(N_EFF)     # NOT sigma/sqrt(48000): the points are correlated

    # -- 0. the gauge channel: metric error, and NO residual signature -------
    if abs(map_scale - 1.0) > 0.002 and np.max(np.abs(e)) < 1.96 * se:
        return ('metric gauge (square size / wheel radius / baseline)',
                map_scale, False)

    # -- 1. temporal FIRST: it fakes a ratio of 1 and would be read as -------
    #       a boresight error if the ratio test ran before it.
    td, Suu = ols_slope(u, e)                  # units px / (px/s) = SECONDS
    t_td    = td / (se / np.sqrt(Suu))          # SE(slope) = SE(point)/sqrt(Sxx)
    if abs(t_td) > 3.0:
        return ('time offset', td, False)

    # -- 2. extrinsic: the near/far ratio, guarded by the far reading's CI ---
    near, far = e[1], e[4]                  # the 2 m and 20 m structures
    if abs(far) < 1.96 * se:                 # far gauge consistent with zero:
        return ('extrinsic, undetermined - far gauge is not measuring',
                None, True)                     # the ratio is unbounded. say so.
    ratio = near / far
    if abs(ratio - 1.0) < 0.12:               # constant in range
        dtheta = np.arctan(near / F)          # rad, IF it is boresight at all
        return ('extrinsic rotation OR principal point',
                dtheta, True)                     # <-- DEGENERATE. never collapse this.
    if ratio > 4.0:                            # falls as 1/Z
        return ('extrinsic translation', near * Z[1] / F, False)   # metres

    # -- 3. radial: which POWER of r carries the residual? -------------------
    beta, (t_lin, t_cub) = radial_powers(r, e, se)
    if max(abs(t_lin), abs(t_cub)) > 3.0:
        if abs(t_cub) > abs(t_lin):
            return ('distortion k1', beta[2] * F ** 2, False)   # k1 = b * f**2
        return ('focal length', beta[1], False)              # eps = a = df/f

    return ('no fault above noise', 0.0, False)

Run it on the seven experiments and this is what comes back, at σ = 0.35 px so SE = 0.175 px:

Injectednear/farttdtrtReturned
square size 23 mm0.000.000.00('metric gauge', 0.920, False)
Δθ = 1.00°1.0000.000.000.00('rotation OR cx', 0.017453, True)
Δt = 2.0 cm10.000−0.39−52.2+30.6('translation', 0.0200, False)
td = 18.4 ms1.00029.6−0.34+0.32('time offset', 0.01840, False)
Δf = +2.0%0.400−0.1612.30.00('focal length', 0.02000, False)
k1 = +0.020.064−0.010.005.08('distortion k1', 0.02000, False)
Δcx = +10 px1.0000.000.000.00('rotation OR cx', 0.012499, True)

Every magnitude comes back to five digits, and the two degenerate cases come back flagged rather than guessed. But look at the translation row, because it is the one that teaches something: tr = −52.2 and t = +30.6. A pure lever-arm error produces a screaming radial signature, far larger than the real focal-length fault's 12.3, purely because in this scene the far structures are also the peripheral ones and so r is correlated with 1/Z. If the radial branch ran before the ratio branch, this function would confidently report a focal-length error on every loose bolt in the fleet.

The ordering is load-bearing, and that is a smell. A diagnostic whose correctness depends on the order of its if statements is a diagnostic that has not been written properly — it works because the author knew which confound bites first. The principled version does not branch at all: put every hypothesis in one design matrix, solve once, and read the coefficients.

The library one-liner, which is what you would actually ship once you have earned the right to use it:

python
# One joint fit instead of four ordered tests. Columns, left to right:
#   1     -> constant shift   : dcx  OR  f*dtheta   (structurally degenerate)
#   r     -> focal scale      : eps = df/f
#   r**3  -> distortion       : k1 = coef * f**2
#   1/Z   -> lever arm        : dt = coef / f     [px*m -> m]
#   udot  -> time offset      : td = coef         [seconds, directly]
A = np.column_stack([np.ones(6), r, r**3, 1.0/Z, u])          # (6,5)
beta, *_ = np.linalg.lstsq(A, e, rcond=None)                   # (5,)
t = beta[1:] / np.sqrt(np.diag(np.linalg.inv(A.T @ A))[1:]) / se

On the same seven faults this recovers beta = [0, 0.02, 0, 0, 0] for the focal error, [0, 0, 3.125e-8, 0, 0] for distortion (times f2 = 0.0200 = k1), [0, 0, 0, 16.0, 0] for the lever arm (16.0/800 = 0.0200 m), and [0, 0, 0, 0, 0.0184] for the time offset — each fault in exactly one column, no ordering required. And the boresight and principal-point faults both land entirely in the intercept, 13.964 and 10.000, which is the cleanest possible statement of the degeneracy: they are not two hypotheses that are hard to tell apart; they are the same column of the design matrix. No estimator, however clever, separates them from this data.

Two costs to name up front. First, six structures and five columns leaves one residual degree of freedom — you can fit, but you can barely test goodness-of-fit, so in production you want twenty structures, not six. Second, the joint fit's t-statistics are smaller than the marginal ones (12.3 → 2.8 for the focal error) because the columns are correlated and the joint fit correctly charges you for that ambiguity. The marginal test was not more powerful; it was more confident, which is a different thing and a worse one.

The blind drill

Press Mystery fault. One knob moves by a random amount; the readouts turn to question marks. You get the six gauges and nothing else. Name the cause.

Two rules that make the drill worth doing:

  1. Read the ratio before you read the magnitude. Beginners look at which bar is biggest. The near/far ratio and the slope signs carry the diagnosis; magnitudes only tell you how bad it is.
  2. Say your reasoning out loud before you click. "Colouring near equals colouring far, both slopes zero, map scale one — so it is constant in range, constant in velocity, constant in radius. That is either an extrinsic rotation or a principal-point error, and I cannot separate them here, so I would say rotation and name the ambiguity." That sentence is worth more than a lucky correct guess.
When the drill gives you cx and you said Δθ (or the reverse), you were not wrong — you hit the degeneracy from experiment 7. The drill counts it as a miss so that you feel it, because in real debugging the credit goes to the engineer who names the ambiguity rather than the one who guesses inside it.

What this bench does not model, and why that matters

A showcase that pretends to be complete teaches overconfidence. Four things this bench leaves out, each of which you should be able to name unprompted:

Fault injection and calibration monitoring as a research area

Everything on this bench is a threshold on a residual, which is the 2015 answer. Three papers to name if the conversation goes past the bench, all specific to monitoring rather than to calibration itself:

The tradeoff to defend. Information-based gating is strictly better statistics and strictly worse operations: a monitor that only alarms when it is confident will stay silent through a slow degradation that a dumb RMS threshold would have caught by accident. The mature system runs both — the residual threshold as a coarse always-on tripwire, the information gate as the thing that decides whether a diagnosis is allowed to be published to the fleet dashboard. Never let a monitor issue a diagnosis it cannot attach an interval to.

Turning the bench into a diagnosis procedure

When someone hands you a symptom, the bench in your head should run backwards. Here is the reverse lookup, which is the one you actually use:

What you observeWhat it must beThe confirming question to ask them
Only map scale moveda metric gauge"Was the target's square size measured with calipers or taken from the print spec?"
Near and far colouring equal, slopes zeroextrinsic rotation, or cx"Does the offset appear on both cameras, or only one?" One camera means cx.
Near colouring ten times farextrinsic translation"How much did the robot rotate during the calibration run?"
Everything scales with turn ratetime offset or rolling shutter"Is the sensor global or rolling shutter?" Then split the residuals by image thirds.
Offsets grow toward the image edgefocal length or distortion"Is the growth linear or cubic in radius?" Linear is f, cubic is k1.
RMS is fine, downstream is nota gauge, or cx absorbed into pose"What is your wheel-odometry to VIO distance ratio?"

Notice that every row's confirming question is cheap — it costs one plot or one sentence, not a recalibration campaign. Producing the cheap discriminating question, rather than the expensive experiment, is the habit that separates a diagnostician from a guesser.

One worked reverse lookup, out loud

Here is what the answer sounds like when you do it properly. Set the noise slider to σ = 0.35 px so the intervals are real, and suppose the bench hands you this. (The point estimates below are written noise-free so the arithmetic stays checkable; the intervals are exactly the ones σ = 0.35 produces, and a live realisation will jitter the last digit of each reading.)

SignalReading ± 95%Healthyt
reprojection RMS7.46 px0.28
map scale ratio1.0001.0000
colouring @ 2 m+8.00 ± 0.34 px0.045.7
colouring @ 20 m+0.80 ± 0.34 px0.04.6
slope vs velocity−0.24 ± 1.22 ms0.00.4
slope vs radius−3.81 ± 0.11 px0.0070.2

Step 1 — ratio before magnitude. Near over far is 8.00 / 0.80 = 10.0. The two structures are at 2 m and 20 m, a factor of ten. So the offset is falling as exactly 1/Z. Nothing else in the fault set does that. And say the interval out loud, because it is what makes this a measurement: SE(ratio) = 10.0 × √((0.175/8)2 + (0.175/0.8)2) = 2.20, so the ratio is 10.0 with a 95% interval of 5.7 to 14.3 — inconsistent with 1. Had the interval straddled 1, the correct next sentence would have been "this window cannot separate rotation from translation," not a guess.

Step 2 — rule out the other channels, and do not flinch at the radius slope. The velocity slope is −0.24 ± 1.22 ms, a t of 0.4, so it is not temporal. The map scale is 1.000, so it is not a gauge. The offset is not constant with range, so it is not an extrinsic rotation or a principal-point error. That leaves the radius slope, which is −3.81 px per 100 px at t = 70 — five times more significant than the real focal-length fault in Experiment 5 — and which anyone who has only memorised the table will now use to announce a focal-length error.

Step 2b — why that slope is a ghost, said in one sentence. In this scene the far structures are also the peripheral ones: r and 1/Z have a correlation of −0.91. So a residual that falls as 1/Z is automatically also a residual that falls with r, and the marginal regression on radius happily reports it. The tells are all there once you look: the sign is negative (a focal error grows outward, this shrinks outward), and the cubic coefficient came back significant too (t = +30.6), which no pure focal error ever does. The cure is the joint fit from the code block — put r, r3, 1/Z and u̇ in one design matrix and the entire residual lands in the 1/Z column at 16.0 px·m with the other three collapsing to zero.

This is the single most transferable sentence in the chapter. "That slope is significant but confounded — my regressors are correlated at 0.91, so I would fit them jointly before naming a cause." A significant marginal slope is evidence that something varies with that axis; it is not evidence that the axis is the cause. Knowing that difference is most of what calibration debugging actually tests.

Step 3 — invert for the magnitude. The model is Δu = f·Δt/Z. Take the 2 m reading:

Δt = Δu · Z / f = 8.00 × 2 / 800 = 0.0200 m

Check it against the other reading: 0.80 × 20 / 800 = 0.0200 m. Both agree, which is itself confirmation that the 1/Z model is the right one — if the two disagreed, the fault would be a mixture. Attach the interval here too: ±0.34 px at 2 m propagates to ±0.34 × 2 / 800 = ±0.00085 m, so the honest statement is Δt = 20.0 ± 0.9 mm. A number without a millimetre is a number nobody can act on.

Step 4 — name the likely upstream cause and the confirming question. A 2 cm lever-arm error, almost always from a rotation-starved calibration run. "How much did the robot rotate during that calibration? I want the smallest singular value of the stacked (RA − I) matrix — under 0.5 and I do not trust the translation at all, and at 2° of excitation a 1 cm motion error becomes 29 cm of lever arm."

Step 5 — state what would change your mind. The one thing this dashboard cannot rule out is the depth-direction lever arm from the omissions list, −(r/Z)·Δtz, which also falls as 1/Z and grows with r. Its near/far ratio would be 4.0, not 10.0, so the measured 10.0 ± [5.7, 14.3] does not exclude it. "I would confirm by driving the same aisle at two different standoff distances: a lateral lever arm keeps the near/far ratio at the range ratio, a range-direction one does not." Naming the alternative you did not choose, and the cheap experiment that would separate them, is the difference between a diagnosis and an opinion.

Five steps, about a minute. Ratio with its interval, rule out (including the confounded channel), invert for the magnitude with its interval, name the upstream cause with the confirming question, then name what would change your mind. That is the whole procedure, and it is the same five steps for every row of the reverse-lookup table above. Practise it on the bench until you do not have to think about the order.
On the bench you just used, one fault moved the reprojection RMS to 10 px, moved colouring by 10 px at both 2 m and 20 m, and left both regression slopes at zero. Name it — and say what you would need to be sure.

Chapter 6: The Field Guide

One last dashboard, and then the reference material. A fleet dashboard from a warehouse-robot company: the maps come back 8 % short against the surveyed floor plan, the reprojection RMS is flat at 0.28 px and has not moved in three months, and every loop closure in the last week accepted on the first try. What do you check first?

On a live fleet, you have about ninety seconds of credibility before "let me get back to you" becomes the answer. This chapter is the set of instruments for exactly those ninety seconds — the one derivation you can rebuild from a blank page, the numbers you can do without a calculator, the discriminator that separates two hypotheses that fit the same plot, and the sentence that closes the investigation. It is a reference; do not read it instead of the chapters.

0 · The one derivation to keep in muscle memory

There is one derivation at the centre of camera calibration, and it is always the same one: why three views of a plane are enough to recover K. It rewards practice because it is short enough to finish inside five minutes and structured enough that you cannot bluff it — one substitution, two facts about rotation matrices, and a counting argument. The tables further down this chapter are only useful to somebody who can also do this, so do it here first, out loud, until it is muscle.

Step 1 — put the frame on the board, and the third rotation column dies. Attach the world frame to the target so every corner has Zboard = 0. The full pinhole projection of a corner is

s · [u v 1]T = K [ r1   r2   r3  |  t ] [X Y 0 1]T

and that zero deletes r3 before it ever multiplies anything, leaving a 3×3 map from board coordinates to pixels:

s · [u v 1]T = K [ r1   r2   t ] [X Y 1]T

That 3×3 map is a homography, and four or more corner correspondences fit it directly from one image with no knowledge of K at all. But the fit only ever determines it up to an overall scale, because projection divides by the third coordinate and a common factor cancels. So the honest statement of what you have is

H = λ K [ r1   r2   t ],    λ unknown and different for every view

Step 2 — move K to the other side. Reading off the columns, and writing h1, h2, h3 for the columns of H:

r1 = (1/λ) K-1h1    r2 = (1/λ) K-1h2    t = (1/λ) K-1h3

Nothing has been assumed yet. Now use the only thing you know for free: r1 and r2 are two columns of a rotation matrix, so they are orthogonal and both have unit length. That is two scalar facts, and they are the entire method.

Step 3 — impose orthogonality. r1Tr2 = 0 becomes

(1/λ2) h1T K-TK-1 h2 = 0  ⇒  h1T B h2 = 0,   B ≡ K-TK-1

The unknown λ multiplies both sides of an equation whose right-hand side is zero, so it cancels. This is the load-bearing move of the whole derivation and it is worth saying out loud: the constraint is homogeneous, which is exactly why you never needed to know the scale of H, and also — the same fact wearing a different hat — why the absolute size of the board never enters the intrinsic solve. Zhang gives you fx in pixels whether the squares are 25 mm or 25 km. That is the gauge problem of Chapter 0, visible right here in the algebra.

Step 4 — impose equal length, not unit length. The naive move is to write r1Tr1 = 1. You cannot: that equation contains λ, which you do not know. What survives is the difference of the two norm conditions, where λ cancels again:

h1TBh1 = h2TBh2

So one view of the plane yields exactly two linear equations in the entries of B. Not three, not six. If a derivation claims a view gives more, ask which of the extra equations still contains λ.

Step 5 — count. B = K-TK-1 is symmetric 3×3, so it has 6 distinct entries: b11, b12, b22, b13, b23, b33. Both constraints are homogeneous in B (one is "= 0", the other is a difference), so scaling B changes nothing and one degree of freedom is unphysical: 5 free parameters. Each view contributes 2 rows to the stacked system Vb = 0. Therefore

2n ≥ 5  ⇒  n ≥ 2.5  ⇒  n = 3 views

and if you additionally fix the skew at zero — true for every digital sensor made this century — then b12 = 0, B has 4 free parameters, and 2n ≥ 4 gives n = 2. Keep both numbers. The three-view answer with the skew caveat is the complete answer; "three" alone is the memorised one.

Step 6 — get K back. B is positive definite, so B-1 = K KT and a Cholesky factorisation of B-1 hands you K directly (up to a sign convention on the diagonal). No iteration, no initial guess. The LM refinement that follows exists only to add distortion and to reweight by the real corner noise.

Do it with numbers once and it stops being abstract. Take the lesson's rig: fx = fy = 800 px, cx = 640, cy = 480, zero skew. Then K-1 = [[0.00125, 0, −0.8], [0, 0.00125, −0.6], [0, 0, 1]], because 1/800 = 0.00125 and 640/800 = 0.8, 480/800 = 0.6. Multiplying out B = K-TK-1: b11 = b22 = (0.00125)2 = 1.5625×10-6; b13 = −0.8 × 0.00125 = −1.000×10-3; b23 = −0.6 × 0.00125 = −7.50×10-4; b33 = 0.82 + 0.62 + 1 = 0.64 + 0.36 + 1 = 2.000; b12 = 0. Now run it backwards, which is what the solver does: f = 1/√b11 = 1/0.00125 = 800; cx = −b13/b11 = 0.001/1.5625×10-6 = 640; cy = −b23/b11 = 7.5×10-4/1.5625×10-6 = 480. The loop closes exactly, by hand, in about forty seconds. Doing this once is the difference between reciting "B is the image of the absolute conic" and knowing what B is.

Step 7 — and then say why the count is necessary but not sufficient. This is the sentence that separates a good understanding from an excellent one. Three views satisfy 2n ≥ 5, but if all three board planes are parallel, every view produces the same two rows of V up to scale: V has rank 2 no matter how many images you take, and twenty views solve nothing. The count is about the number of rows; observability is about their independence. That is why Chapter 1's tilt bench exists, and why σ(fx) — not the view count and not the RMS — is the thing to gate on.

The second derivation, and it takes ten seconds. σtd appears twice in the tables below, so do not let it arrive uninvited. A time offset td displaces a tracked feature by exactly the distance it travels in that time, so the residual it induces on one feature is r = u̇·td, where u̇ is that feature's image velocity in px/s. Invert it: from one feature, t̂d = r/u̇, so a corner-localisation noise of σpx becomes a timing noise of

σtd (one feature) = σpx / u̇

Averaging N features whose corner noise is independent shrinks the standard deviation by √N, which gives the form in the cheat sheet:

σtd = σpx / (√N · u̇)

and note what the u̇ in the denominator is telling you before you ever plug numbers in: as the image slows down, the uncertainty diverges. A parked robot cannot calibrate its own time offset at any N. Same fact as ∂r/∂td = −u̇ being a column of zeros — unobservability and infinite variance are two descriptions of one thing. The numbers drill in section 7 works this to a figure.

1 · The cheat sheet

ConceptThe 30-second explanationKey equationToolClassic paper2024+ paper
Zhang's method A board is planar, so each view gives a homography. Its first two columns are rotation columns, which are orthogonal and equal-length — two constraints per view, linear in B = K-TK-1. Five unknowns, so three views. h1TBh2 = 0
h1TBh1 = h2TBh2
cv2.calibrateCameraExtended Zhang, TPAMI 2000 VGGT, CVPR 2025 (predicts intrinsics directly)
Distortion Applied in normalised coordinates before K. Radial terms are functions of radius alone; tangential terms come from a tilted lens element. Fit jointly with K in LM, never in closed form. xd = x(1 + k1r2 + k2r4 + k3r6) initUndistortRectifyMap + remap Brown, Photogrammetric Eng. 1971 Deep ChArUco, CVPR 2019 (learned corners)
Hand-eye rotation Both sensors see the same motion in different frames. Log both, and one arrow set is the other rigidly rotated — orthogonal Procrustes. Needs two motions about non-parallel axes. M = ∑aibiT
R = U·diag(1,1,det)·VT
cv2.calibrateHandEye Park & Martin, T-RA 1994 MC-Calib, CVIU 2022 (whole rigs at once)
Hand-eye translation Linear once rotation is known. Singular under pure translation: two points on a rigid body translating together move identically, so the lever arm is invisible. (RA − I)tX = RXtB − tA Kalibr; calibrateHandEye Tsai & Lenz, T-RA 1989 Lv et al., T-RO 2022 (observability-aware)
Temporal offset Shift the predicted feature by td times its image velocity. One extra state, one extra Jacobian column, and the column is minus the image velocity. ∂r/∂td = −u̇
σtd = σpx/(√N·u̇)
Kalibr; OpenVINS calib_camimu_dt Li & Mourikis, IJRR 2014 OpenVINS, ICRA 2020 (shipped online)
Rolling shutter A per-row time offset. Looks exactly like td until you divide the residual by image velocity and plot against row index. tk = t0 + k·tline Kalibr rolling-shutter model Furgale et al., IROS 2013 OpenVINS, ICRA 2020 (RS in the filter)
Metric gauge The one quantity carrying metres: board square size, wheel radius, stereo baseline, accel scale. Scales every length uniformly and produces no residual. Z = f·B/d, so δB/B = δZ/Z a tape measure and an odometry ratio Hartley & Zisserman, 2004, ch. 10 maplab, RA-L 2018 (map as reference)
Residual regression Every fault is proportional to a different covariate. Fit residual against radius, image velocity, inverse range and row index; the intercept is the extrinsic-rotation channel. r = a + b·x,   t = b/SE(b) 4 dot products, 1.6 Mflop Bar-Shalom et al., 2001 (consistency) Lv et al., T-RO 2022 (information-based)

2 · System-design patterns

Prompt A — "Design the calibration pipeline for a fleet of 500 robots, each with 2 cameras, a LiDAR and an IMU."

Framework, in this order:

  1. Split factory from field. Intrinsics and rigid extrinsics are factory jobs with a controlled target and a fixture that guarantees tilt coverage. Temporal offsets and slow extrinsic drift are field jobs, estimated online.
  2. Every artifact is versioned and immutable, keyed by sensor serial number, carrying the raw images, the covariance, the operator, and the target's measured square size with its uncertainty. Never a bare YAML.
  3. Gate on covariance, not RMS. The factory line rejects a calibration whose σ(fx) exceeds 2 px, regardless of how pretty the RMS is. State the number.
  4. Budgets: 22 images × 1.23 MB = 27 MB captured, 180 ms to solve 141 parameters against 2376 residuals, ~350 B published per camera_info, and a 9.8 MB undistortion LUT costing 1.5 ms per frame at runtime.
  5. Monitoring is the deliverable. Four residual regressions at 0.1 Hz (1.6 Mflop) plus one odometry-ratio watchdog at 1/60 Hz. Without the watchdog the fleet is blind to gauge faults.
  6. Rollout: a calibration change is a deploy. Canary it, keep the previous artifact, and be able to roll back in one command.

Prompt B — "We are adding a fourth camera. What changes?"

The trap is answering "run the calibration again." The real answer is about topology: with four cameras you have six possible pairwise extrinsics but only three independent ones, so pairwise calibration will produce an inconsistent loop — go around the ring and you will not get back to identity. Calibrate the rig jointly against a shared board set (MC-Calib does exactly this), report the loop-closure residual as a health metric, and store the extrinsics as a tree with one designated root rather than as a set of pairs. Then: does the new camera overlap any existing one? If not, you need a board large enough to be seen by two cameras at once, or a mirror, or a motion-based method.

Prompt C — "The hardware team wants to save $40 per robot by dropping the hardware trigger and timestamping in software."

Do not say no; price it. Software timestamping on a USB3 driver adds 3–12 ms of receive-side latency with several milliseconds of jitter, and jitter is the part that hurts — a constant offset is estimable, a random one is noise you cannot remove. At 1 m/s, 4 ms of jitter is 4 mm of position noise per frame, and at 60°/s it is 0.24° of pointing noise. Then state the condition: "If our localisation budget is 5 cm and our pointing budget is 1°, I can absorb this by estimating td online and modelling the jitter as extra measurement noise — at the cost of roughly a factor of two in effective feature noise. If the budget is 1 cm, the trigger is cheaper than the engineering." Then ask what the 500-unit volume makes the real number.

Prompt D — "How do you know your calibration is still good, six months in?"

Three independent instruments, and say why three. (1) The four residual regressions catch every geometric fault and localise it to a parameter. (2) The odometry-versus-VIO distance ratio catches gauge faults, which regression 1 structurally cannot. (3) Localisation against a long-lived map catches slow drift that both of the others average away, because the map is a reference that does not drift with the robot. Then the trends: RMS against temperature (thermal, reversible), td against time (clock drift, in ppm), and per-camera RMS spread (one bad sensor hiding in an aggregate).

3 · Coding drills

Drill 1 — "Recover the board pose from a homography." The most common calibration coding question, because it is short and it has a trap.

python
def pose_from_homography(K, H):
    """K : (3,3) float64, PIXEL units   -- fx, fy, cx, cy, skew
       H : (3,3) float64, board -> image, DEFINED ONLY UP TO SCALE
       returns R : (3,3) float64, det(R) = +1
               t : (3,)  float64, METRES, board origin in the camera frame
       t inherits its unit from the board's square size. Feed findHomography
       a board grid in mm and t comes back in mm. This is the gauge."""
    Ki = np.linalg.inv(K)
    h1, h2, h3 = H[:, 0], H[:, 1], H[:, 2]
    lam = 1.0 / np.linalg.norm(Ki @ h1)   # r1 is UNIT length - that pins lambda
    r1, r2 = lam * (Ki @ h1), lam * (Ki @ h2)
    t      = lam * (Ki @ h3)
    r3     = np.cross(r1, r2)                # not free: fixed by r1 and r2
    U, _, Vt = np.linalg.svd(np.column_stack([r1, r2, r3]))
    R = U @ np.diag([1, 1, np.linalg.det(U @ Vt)]) @ Vt   # back onto SO(3)
    return R, t

Say while writing: "H is only defined up to scale, and the thing that fixes the scale is physics, not algebra — r1 is a column of a rotation matrix, so it has unit length. If I skip λ, R is still roughly right and t is wrong by a pure scale factor, which is the same signature as a wrong square size and just as hard to notice. And the final SVD is not decoration: with real corner noise, r1 and r2 are not exactly orthogonal, and every downstream consumer assumes RTR = I exactly."

Now run one concrete round trip through it, because "t is wrong by a scale factor" is a claim, and a claim you can produce numbers for is an answer. Take the lesson's camera, fx = fy = 800, cx = 640, cy = 480, and a board rotated 20° about the camera's y axis at t = [0.12, −0.05, 0.83] m. The homography your corner fit actually returns — already normalised by the library to some arbitrary scale — is

H = [[530.887,   0.000,   624.879], [−163.563,   797.040,   357.074], [−0.34075,   0.000,   0.82693]]
StepArithmetic, every intermediateResult
K-1 1/800 = 0.00125; 640/800 = 0.8; 480/800 = 0.6 → K-1 = [[0.00125, 0, −0.8], [0, 0.00125, −0.6], [0, 0, 1]]
K-1h1 row 1: 0.00125×530.887 − 0.8×(−0.34075) = 0.663609 + 0.272600 = 0.936209
row 2: 0.00125×(−163.563) − 0.6×(−0.34075) = −0.204454 + 0.204450 = −0.000004
row 3: −0.34075
[0.93621, −0.00000, −0.34075]
‖K-1h1 0.9362092 = 0.876487; 0.340752 = 0.116111; sum = 0.992598; √0.992598 = 0.996292 0.9963
λ 1 / 0.996292 = 1.003722 1.0037
r1 = λK-1h1 1.003722 × [0.93621, 0, −0.34075] [0.93969, 0, −0.34202] — and cos 20° = 0.93969, sin 20° = 0.34202. Exact.
‖K-1h2 K-1h2 = [0, 0.00125×797.040, 0] = [0, 0.99630, 0]; norm = 0.99630 0.9963 — equal to column 1. That equality is Zhang's second constraint, verified numerically. If K were wrong the two norms would differ, and their difference is precisely what the solve drives to zero.
K-1h3 row 1: 0.00125×624.879 − 0.8×0.82693 = 0.781099 − 0.661544 = 0.119555
row 2: 0.00125×357.074 − 0.6×0.82693 = 0.446343 − 0.496158 = −0.049815
row 3: 0.82693
[0.11956, −0.04982, 0.82693]
t = λK-1h3 1.003722 × [0.119555, −0.049815, 0.826930] [0.1200, −0.0500, 0.8300] m — the true pose, recovered.
t with λ skipped the same K-1h3, unscaled [0.1196, −0.0498, 0.8269] m

So dropping one line costs you ‖t‖ = 0.83702 m instead of 0.84012 m — 3.1 mm short on an 0.84 m baseline, every component low by the same 0.371 %. Note what did not change: R comes out of an SVD, and svd(cM) = U·(cS)·VT shares its U and VT with svd(M), so the rotation is bit-identical either way. You get a perfect rotation, a uniformly shrunken translation, and a reprojection error that never moves — because the board corners still land where they landed. That is the exact signature of a wrong square size from Chapter 0, arriving this time from a missing line of code. That is the sentence that shows you know what the trap costs, not merely that there is one.

Drill 2 — "Solve AX = XB for the rotation."

python
def handeye_rotation(A_list, B_list):
    """A_list, B_list : lists of (4,4) float64 SE(3) RELATIVE motions, same
       length, index-aligned. A[i] is sensor A's motion from pose i to i+1
       in A's own frame; B[i] is the SAME physical motion seen by B. Do NOT
       pass absolute poses -- AX = XB is a statement about motions.
       so3_log(T) : (4,4) -> (3,) axis-angle vector, RADIANS, norm = angle.
       returns R_AB : (3,3) float64, det = +1, rotates B-frame into A-frame.
       Two motions minimum, and their axes must NOT be parallel."""
    M = sum(np.outer(so3_log(A), so3_log(B)) for A, B in zip(A_list, B_list))
    U, S, Vt = np.linalg.svd(M)
    if S[1] < 1e-6 * S[0]:
        raise ValueError("rank 1: all rotation axes are parallel")
    return U @ np.diag([1, 1, np.linalg.det(U @ Vt)]) @ Vt

Say while writing: "The determinant guard is the difference between a rotation and a reflection — both minimise the same cost. And I raise on rank deficiency rather than returning a number, because every library I have used will happily hand you an answer from one motion. The requirement is two motions about non-parallel axes; counting motions is the wrong check."

Drill 3 — "Estimate a time offset between two rate signals."

python
def time_offset(a, b, dt, max_lag=20):
    """a, b : 1-D float64, EQUAL length, uniformly sampled at dt seconds.
       Rate signals, not poses -- e.g. gyro |omega| and image-derived
       rotation rate. Different units and gains are fine (ncc is scale-free).
       dt : float, SECONDS per sample. max_lag : int, SAMPLES either way.
       returns float, SECONDS. POSITIVE means a LAGS b: a's event at
       t + offset corresponds to b's event at t, so ADD it to a's stamps."""
    def ncc(u, v):
        u = u - u.mean(); v = v - v.mean()
        return (u @ v) / np.sqrt((u @ u) * (v @ v))   # gain-invariant
    lags = np.arange(-max_lag, max_lag + 1)
    c = np.array([ncc(a[L:], b[:len(b)-L]) if L > 0 else
                  ncc(a[:len(a)+L], b[-L:]) if L < 0 else ncc(a, b) for L in lags])
    k = int(np.argmax(c)); k = min(max(k, 1), len(lags) - 2)
    y0, y1, y2 = c[k-1], c[k], c[k+1]
    d = 0.5 * (y0 - y2) / (y0 - 2*y1 + y2)          # parabola vertex
    return -(lags[k] + d) * dt

Say while writing: "Normalised, because the two sensors have different gains and I do not want a gyro scale error to move my peak. And the parabola, because the integer grid is 5 ms and the offset I am chasing is 18 — without sub-sample refinement my answer is quantised to worse than the thing I am measuring. On clean data the refinement is about a hundred times better than the grid."

Drill 4 — "Given a residual buffer, tell me which calibration is wrong." This is the drill that separates reading about calibration from owning it, so it does not get to be a pointer at another chapter. Write the whole thing. It is one helper, four calls and a ladder, and the ladder is the part that carries the diagnosis.

python
def triage(res, rad, vel, invZ, row):
    """All five are 1-D float64 arrays of the SAME length N (one entry per
       tracked feature in the buffer; N ~ 200 is plenty).
         res  px      signed reprojection residual along the flow direction
         rad  px      radius from the principal point       (0 .. ~500)
         vel  px/s    image-plane feature velocity          (0 .. ~900)
         invZ 1/m     inverse range from the depth sensor   (0.04 .. 0.8)
         row  index   sensor row the feature was read out on (0 .. H-1)
       returns (name: str, value: float, t: float). The VALUE's unit differs
       per channel and is printed below -- that is the point of the ladder."""
    def ols(x, y):                       # slope + its t-statistic
        x = x - x.mean(); y = y - y.mean()
        Sxx = x @ x
        b   = (x @ y) / Sxx
        e   = y - b * x
        se  = np.sqrt((e @ e) / (len(x) - 2) / Sxx)   # SE(b)
        return b, b / se

    b_r, t_r = ols(rad,  res)        # px per px    -> df/f  = b_r
    b_v, t_v = ols(vel,  res)        # seconds      -> t_d   = b_v
    b_z, t_z = ols(invZ, res)        # px*m         -> dt    = b_z / f
    b_w, t_w = ols(row,  res / vel)  # s per row    -> t_line = b_w
    a  = res.mean()                                     # px  the constant channel
    ta = a / (res.std(ddof=1) / np.sqrt(len(res)))   # its own t

    # ORDER MATTERS. Rolling shutter also lights the velocity channel,
    # so it must be tested BEFORE t_d or it will be misread as one.
    if   abs(t_r) > 4: return "intrinsics: focal or k1", b_r, t_r
    elif abs(t_w) > 4: return "rolling shutter",          b_w, t_w
    elif abs(t_v) > 4: return "time offset t_d",         b_v, t_v
    elif abs(t_z) > 4: return "extrinsic translation",   b_z, t_z
    elif abs(ta)  > 4: return "extrinsic rotation (or c_x)", a, ta
    else:               return "no residual signature: check the gauge", 0.0, 0.0

Say while writing: "Five channels, five faults, and the ladder is ordered by confusability, not by how common the fault is. Radius first because an intrinsic error contaminates every other channel downstream and has to be cleared before anything else means what it says. Rolling shutter before td, because rolling shutter is a per-row td and therefore lights the velocity channel too — dividing the residual by u̇ before regressing against row is exactly what separates them. And I gate on the t-statistic, not the slope, because a slope of 0.4 ms is decisive with 200 features and meaningless with six. The last branch is the one that matters: it does not say 'clean', it says 'stop looking at residuals'. Five flat channels is a positive result — it has ruled out every fault that has a residual signature, which leaves the class that does not."

Two follow-ups they will ask, so have both. "Why 4 and not 2?" Because you run this on five channels at 0.1 Hz on a fleet: at |t| > 2 you are firing on roughly one channel in twenty every ten seconds and the on-call engineer stops reading the alerts within a day. |t| > 4 is about 6×10-5 per channel per test, which on 500 robots × 5 channels × 8640 tests a day is still a handful of false pages — so in production the alert also requires the channel to stay hot for three consecutive windows. "What if two faults are present?" The ladder returns the first one, which is correct behaviour: fix it, re-run, and the second one is now the top of the ladder. Reporting both at once from one pass is how you get a confident wrong answer, because a large radius slope biases every other channel's estimate.

Whiteboard triage — run Drill 4 on live data

The four regressions of the ladder above, all on screen at once, on the same 170-feature buffer. Pick a fault, then wind its magnitude up from zero and watch which panel goes hot. Three things to go and find: rolling shutter is the only fault that lights two panels (velocity and row) — that is why the ladder tests it first; metric gauge leaves all four dead flat at any magnitude, which is a diagnosis, not a null result; and around 0.01–0.02× every fault vanishes into the corner noise, so the slope alone tells you nothing about whether anything is wrong — only t = b/SE(b) does. The slope stays exactly proportional to the magnitude the whole way down; it is t that crosses 4 and decides.

FAULT 
fault magnitude1.00×

4 · Debugging scenarios

SymptomRoot causeThe metric that reveals itThe value that decides
Maps and trajectories consistently ~8% short. Reprojection RMS flat at 0.28 px. Loop closures fine. Metric gauge wrong — board square size, wheel radius or stereo baseline. Ratio of two independently scaled distances (wheel odometry / VIO) over a 60 s window. Ratio outside 0.98–1.02. Wheel slip has a large standard deviation; a gauge error has almost none.
Recalibrating the same camera twice a week apart gives fx = 812 then 771, RMS healthy both times. Insufficient board tilt — the rows of V are near-dependent, so fx sits in a near-null space. stdev_int[0] from the LM covariance, or the spread of fx over 50 bootstrap resamples. σ(fx) > 2 px for 20 images. At 1° of tilt it is 3156 px; at 20° it is 5.1 px.
LiDAR-camera colouring is right on the far wall, wrong on a nearby pallet. Extrinsic translation error — almost always a rotation-starved calibration run. Regress colouring offset against 1/Z; also σmin of the stacked (RA − I). Near/far offset ratio ≈ 10 for a decade of range. σmin < 0.5 means under 30° of excitation.
Colouring offset is 14 px at 2 m and 14 px at 20 m. Extrinsic rotation error (or, degenerately, a principal-point error). Same plot: flat against range. Confirm with the regression intercept. Δθ = offset/f = 14/800 = 0.0175 radians; × 180/π = 0.0175 × 57.30 = 1.00°. (The division gives radians because a pixel offset over a focal length in pixels is a tangent, and tan θ ≈ θ here. Forgetting the 57.3 is the single most common slip in this conversation.) To separate it from cx you need a second camera or a surveyed heading.
Everything is fine when parked; residuals blow up during turns and scale with turn rate. Unmodelled time offset (or rolling shutter). Regress residual against image velocity: slope = td in seconds. Slope > 1 ms is real. Then divide by u̇ and regress against row index: flat = td, ramping = rolling shutter.
Fleet RMS crept from 0.28 px in January to 0.41 px in July. Thermal drift of the mount and lens. Regress RMS against reported board temperature, per robot, over months. Slope > 0.005 px/K, and the effect is reversible overnight. Irreversible means a loose screw.
Fleet RMS stuck at 0.34 px against a 0.30 target; no robot looks broken. One camera in six is at 0.64 px and the mean is hiding it. Report residuals grouped by camera and by image octant. Never publish a bare aggregate. max/min across cameras > 2.0. Here 0.64/0.28 = 2.3.
Calibration is perfect after boot, degrades over a shift, "fixed" by a reboot. Clock drift, not offset — two free-running crystals. Fit a line to td(t) over the session; report the slope in ppm. > 5 ppm is drift. 40 ppm = 2.4 ms/min = 144 ms/hour.

5 · Classical versus modern, and when to use which

DimensionClassical (target-based, offline)Modern (targetless / online / learned)When to use which
InputA printed board with a known, measured square sizeOrdinary driving, scene edges, or a pretrained networkBoard when you need metres; targetless when you need to recalibrate 500 robots without a fixture
Metric scaleComes from the board — and is the single point of failureComes from the IMU or wheels, or not at all (DUSt3R, VGGT are scale-free)Anything that grasps or measures needs a metric source; a viewer does not
UncertaintyA real covariance from the LM normal equations, gateableFilter covariance (OpenVINS) — or nothing at all (learned)If you must gate on quality, you need the covariance. This is still the biggest gap for learned methods
Drift trackingNone — a snapshot at one temperature on one dayContinuous; follows thermal and mechanical drift automaticallyLong missions and hot environments favour online; safety cases favour a frozen, audited artifact
Failure modeFails loudly: the solve does not converge or the covariance is hugeFails quietly: absorbs a genuine fault into the calibration stateThis is the real argument for keeping both — online for tracking, offline as the reference to alarm against
Effort10 minutes of board dance per sensor, per unit, by a trained operatorZero marginal effort once it works; substantial effort to make it workBelow ~20 units, do it by hand. Above, the automation pays for itself
Best current toolsOpenCV calib3d, Kalibr, MC-CalibOpenVINS online states, livox_camera_calib, maplab, VGGT for bootstrappingKalibr for camera-IMU spatial+temporal; OpenCV for a single camera; MC-Calib for a rig
The sentence that ends this question well: "I would not frame it as classical versus learned. I would frame it as: which component produces a number I can gate on? Learned methods are already better at detection and initialisation, and classical estimation is still the only thing that gives me a covariance and a metre. So the frontier for me is a learned front-end feeding a classical back-end, with an offline artifact kept as the reference that the online estimate is alarmed against."

6 · Recommended reading

The one book. Hartley & Zisserman, Multiple View Geometry in Computer Vision (2nd edition, 2004). Chapter 8 on the absolute conic is where Zhang's B matrix actually comes from, and chapter 10's treatment of projective, affine and metric reconstruction is the rigorous version of this lesson's "the gauge" argument. If you read one chapter, read chapter 8. The runner-up, for the estimator underneath everything here, is Timothy Barfoot, State Estimation for Robotics (2nd edition, 2024).

Five papers, and why each.

  1. Zhang, "A Flexible New Technique for Camera Calibration," TPAMI 2000. Because it is still the default in every toolbox and you will be asked to derive it. Read it for the two constraints, not the closed form.
  2. Park & Martin, "Robot sensor calibration: solving AX = XB on the Euclidean group," IEEE T-RA 1994. Because it makes the rotation/translation decoupling explicit and treats the observability properly. Tsai & Lenz (1989) is the more-cited ancestor; Daniilidis (IJRR 1999) is the dual-quaternion joint solve worth naming as an alternative.
  3. Furgale, Rehder & Siegwart, "Unified Temporal and Spatial Calibration for Multi-Sensor Systems," IROS 2013. The Kalibr paper. Read it for the continuous-time B-spline trajectory, which is the right way to handle sensors at different rates, and for its honesty about what is observable.
  4. Qin & Shen, "Online Temporal Calibration for Monocular Visual-Inertial Systems," IROS 2018. Six pages, one idea, and it is the idea from Chapter 3: shift the feature by td times its image velocity and let the optimiser have the derivative. Pair with Li & Mourikis (IJRR 2014) for the filter version.
  5. Lv et al., "Observability-Aware Intrinsic and Extrinsic Calibration of LiDAR-IMU Systems," T-RO 2022. The modern answer to "how much data do I need": not more, but better conditioned. It formalises the rank arguments this lesson makes by hand.

Five repositories, and what to look at in each.

  1. ethz-asl/kalibr — the camera-IMU spatial and temporal calibrator. Look at the report it prints: it gives you parameter covariances and a warning when a direction is poorly excited. Reading that report is a skill in itself.
  2. opencv/opencv, module calib3d — read calibrateCameraExtended for how the LM parameterisation is set up and which flags fix which parameters, and calibrateHandEye for all five hand-eye methods side by side in one file. It is the fastest way to see how they differ.
  3. rpng/open_vins — look at how the state class carries optional camera intrinsics, camera-IMU extrinsics and calib_camimu_dt, and how each gets its Jacobian block. This is the reference implementation of Chapter 3's one extra column.
  4. hku-mars/livox_camera_calib — targetless LiDAR-camera calibration by aligning depth discontinuities to image edges. Look at the edge extraction and the cost function; it is the clearest small example of an appearance-based extrinsic solve.
  5. rameau-fr/MC-Calib — multi-camera rig calibration against multiple ChArUco boards, including non-overlapping cameras. Look at how it builds and closes the camera graph; that is the answer to Prompt B above.

7 · The numbers drill

One significant figure, out loud, no calculator. If you cannot do these in about three seconds each, you cannot hold a design conversation at pace.

QuestionAnswerHow
1° extrinsic rotation error, f = 800 px. Pixel offset at 20 m?14 pxf·Δθ = 800 × 0.0175. No range term — same at 2 m.
2 cm extrinsic translation error, f = 800 px, at 5 m?3 pxf·Δt/Z = 800 × 0.02 / 5 = 3.2
18 ms time offset at 5 m/s. Position error baked in?9 cm5 × 0.018 = 0.09 m
960-row sensor, 20 µs per line. Top-to-bottom readout time?19 ms960 × 20 µs = 19.2 ms — the same order as a camera-IMU offset
Board squares are really 25 mm, config says 23. Map error?8% small23/25 = 0.92, and reprojection error does not move at all
40 ppm clock drift over an 8-hour shift?1 second40×10-6 × 28,800 s = 1.15 s
Undistortion LUT for 1280×960, two float32 maps?10 MB1.23 M × 2 × 4 B = 9.83 MB. At 30 Hz it is memory-bound, ~1.5 ms/frame.
σ on td from 200 features at 240 px/s with 0.3 px noise?90 µsσpx/(√N·u̇) = 0.3/(14.1×240) = 88 µs
Minimum board views for Zhang with skew free?35 unknowns in B up to scale, 2 constraints per view, 2n ≥ 5
cx off by 10 px, f = 800. Lateral error over 100 m of straight driving?1.3 mΔcx/f = 0.0125 rad boresight bias, × 100 m = 1.25 m
Lever-arm error from 1 cm of motion error, calibrating with only 2° of rotation?30 cmδ/(2 sin(θ/2)) = 0.01/0.0349 = 0.287 m. At 90° it would be 7 mm.
Colouring offset 14 px near AND 14 px far. Which extrinsic?rotationTranslation must fall as 1/Z; equal offsets a decade apart can only be rotation

A one-line "how" column is a reminder for someone who has already done the arithmetic once. If you have not, the reminder is useless, so here are three of those rows worked with every intermediate on the page. Do them by hand before you trust the one-liners.

Worked A — σ on td from 200 features. Inputs: N = 200 tracked features, image velocity u̇ = 240 px/s, corner-localisation noise σpx = 0.30 px. Formula from section 0: σtd = σpx / (√N · u̇).

#StepArithmeticRunning value
1One feature aloneσpx / u̇ = 0.30 / 2401.25×10-3 s = 1.25 ms
2√N142 = 196, 14.22 = 201.6, so it is just under 14.15. √200 = 14.14214.14
3√N · u̇14.142 × 240 = 14 × 240 + 0.142 × 240 = 3360 + 34.13394 px/s
4Divide0.30 / 3394 = 8.839×10-5 s88.4 µs
5Round to quoteone significant figure, and never quote a σ more precisely than the model deserves≈ 90 µs

Read step 1 against step 5 before you move on: 200 features bought you a factor of 14, from 1.25 ms to 88 µs. That is the whole economics of the estimate. It also tells you the shape of every follow-up — wanting 10 µs from the same motion needs N ≈ 15,000, which you will not have, so you get it by increasing u̇ instead. Doubling the yaw rate is worth as much as quadrupling the feature count, and it is free.

Worked B — the lever arm you cannot see. Inputs: the hand-eye translation solve has 1 cm of motion error, δ = 0.010 m, and the calibration dance only rotated the rig by θ = 2°. The conditioning factor is ‖(R − I)t‖ = 2 sin(θ/2)·‖t‖, so the error in the recovered lever arm is δtX = δ / (2 sin(θ/2)).

#Stepθ = 2° (starved)θ = 90° (healthy)
1Half angleθ/2 = 1°θ/2 = 45°
2Half angle in radians1° × π/180 = 0.017453 rad0.785398 rad
3sin of itsin 1° = 0.0174524 (small-angle: sin x ≈ x, and it is)sin 45° = 0.707107
42 sin(θ/2)0.03490481.414214
5δ / that0.010 / 0.0349048 = 0.28650 m0.010 / 1.414214 = 0.0070711 m
6In readable units28.7 cm of lever-arm error7.07 mm

The ratio is the punchline: 0.28650 / 0.0070711 = 40.5× — call it a factor of forty. The same 1 cm of motion error produces 7 mm of lever-arm uncertainty after a proper 90° excitation and 29 cm after a timid 2° wrist-flick. Nothing about the data volume changed; only the conditioning did. And notice that at small θ the factor is 2 sin(θ/2) ≈ θ, so the error grows as 1/θ and diverges at θ = 0 — which is the singularity of (RA − I) from Chapter 2, arriving as a number instead of a rank argument. This is why "we collected 400 images" is not an answer to "is your extrinsic observable".

Worked C — the undistortion LUT, and why it costs 1.5 ms. Inputs: a 1280×960 sensor, undistortion precomputed by initUndistortRectifyMap into two float32 maps (one for the source x, one for the source y), consumed by remap at 30 Hz.

#StepArithmeticRunning value
1Pixels per frame1280 × 960 = 1280×900 + 1280×60 = 1,152,000 + 76,8001,228,800 px (1.23 M)
2Bytes per pixel of map2 maps × 4 B (float32)8 B/px
3Map size1,228,800 × 8 = 9,830,400 B9.83 MB (9.38 MiB)
4Read bandwidth at 30 Hz9,830,400 × 30 = 294,912,000 B/s295 MB/s, maps alone
5Time for one frame's maps9.83×106 B / 6.5×109 B/s at a realistic single-core streaming rate1.51 ms

Step 5 is the claim in the system-design section, and now it has a derivation instead of a vibe: 1.5 ms per frame is spent before a single source pixel has been touched, because the map is bigger than any last-level cache and has to come from DRAM every frame. The source-image gather that follows is worse per byte, because it is random access rather than streaming. Two consequences worth having ready: the fixed-point form (CV_16SC2 coordinates plus a CV_16UC1 interpolation-weight table) is 4 + 2 = 6 B/px = 7.37 MB, about 25 % less traffic for a sub-pixel accuracy loss you will not measure; and on four cameras this is 1.18 GB/s of pure map traffic, at which point undistorting on the GPU — or not undistorting at all and pushing the distortion model into the feature tracker — stops being a micro-optimisation and becomes an architecture decision.

8 · The five sentences to have ready

  1. The gauge sentence. "The board square size is the only quantity in the dataset carrying units of metres, so it is the gauge — it scales every extrinsic translation, leaves the intrinsics and the residual bit-identical, and can only be caught by an external metric reference."
  2. The tilt sentence. "Tilt is not folklore. It is what makes the two homography columns explore different directions, which is what makes the constraint rows independent. Under about five degrees, σ(fx) goes from a few pixels to a few thousand while the RMS never moves."
  3. The lever-arm sentence. "You cannot see a lever arm without rotating about it. Two points on a rigid body translating together move identically, so (RA − I) is singular and the translation is in its null space."
  4. The time-offset sentence. "It is one extra state and one extra Jacobian column, and the column is minus the feature's image velocity. Which also tells me exactly when it is unobservable: whenever nothing is moving in the image."
  5. The triage sentence. "Regress the residual against radius, image velocity, inverse range and row index, and read the intercept too. Five channels, five faults. If all five are clean and the system is still wrong, it is a gauge, and I go find a second measurement of a distance."
What all of this is actually building. Not the ability to recite Zhang. The ability, handed a broken robot and a plot, to produce a discriminator rather than a hypothesis. Every table in this lesson is built to be usable in that direction: symptom in, cause out, with the number that settles it. Hypotheses are cheap; the engineer who names the measurement that would distinguish two hypotheses is the one who fixes the fleet.

9 · The questions that X-ray a stack

Calibration questions are unusually revealing to ask of any robotics organisation — a team you are joining, a vendor you are evaluating, or your own — because the answers tell you a great deal about how it actually operates. Four worth having ready:

AskWhat a good answer sounds likeWhat a bad answer tells you
"Where does the metric scale in your stack come from, and who owns that number?" A named source (measured board, surveyed baseline, characterised wheel radius) with an owner and an uncertainty. Bonus if they say "and here is the watchdog on it." A pause, then "the calibration file, I think." You have just learned that the failure from Chapter 0 is live on their fleet and nobody would see it.
"What does your calibration report contain besides the RMS?" Parameter covariances, held-out error, per-camera and per-region splits, and a coverage summary of the board poses. "Just the RMS." Which means observability failures ship, and re-calibrations disagree with each other for reasons nobody can explain.
"Do you estimate the time offset, and is it a constant or a state?" A state, with a covariance, gated on excitation. Or a hardware trigger and a good reason for it. "It's in a config file." At 40 ppm that number is stale within minutes, and their residuals during turns are carrying it.
"When a robot comes back from the field misbehaving, what is the first plot someone opens?" A named dashboard with the residual channels on it, and a person who owns it. "We usually bisect the recent commits." Calibration faults are not in the commits, and this team will burn weeks on each one.

Asking these also does something for you that no dashboard can: each one points, in the form of a question, at a place where this subject's failures live. When you can predict what a good answer sounds like before you hear it, the lesson has done its job.

And when you want to pressure-test yourself under a clock, the Studio button on this page runs a timed practice session on exactly this material.

"The first principle is that you must not fool yourself — and you are the easiest person to fool." — Richard Feynman. A calibration file is the most efficient way ever devised for a robot to fool an entire engineering team, because it lies in a way that every internal consistency check agrees with.
Closing question: if you could add only one automated check to a robotics stack that has none, which one do you add?