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.
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.
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
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:
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:
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:
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.
Read λ out of the factorisation: the first two columns of H are λK r1 and λK r2, so
R is a rotation, so its columns are orthonormal. That gives exactly two usable facts — they are usable precisely because they survive the unknown λ:
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):
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:
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
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:
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 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
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.
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
Step 2 — project it. The pinhole model says u = f · X / Z + cx:
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
Step 4 — the solver has to explain the observation. It has two knobs: the focal length f and the board distance Z. It needs
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′:
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.
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.
Believed: X′ = 4 × 0.023 = 0.092, Y′ = 3 × 0.023 = 0.069, at Z′ = 0.552.
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.
Believed: X′ = 8 × 0.023 = 0.184, Y′ = 5 × 0.023 = 0.115, at Z′ = 0.552.
Now stack the residual vector — observed minus predicted, u then v, corner by corner:
| Corner | Observed u, v | Predicted u′, v′ | ru | rv |
|---|---|---|---|---|
| A (0,0) | 320.000000, 240.000000 | 320.000000, 240.000000 | 0.000000 | 0.000000 |
| B (4,3) | 453.333333, 340.000000 | 453.333333, 340.000000 | 0.000000 | 0.000000 |
| C (8,5) | 586.666667, 406.666667 | 586.666667, 406.666667 | 0.000000 | 0.000000 |
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.
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.
So the answer to her question is not "the square size was wrong." That is the symptom, not the diagnosis. The full answer is:
"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.
| Sensor | Rate | Message shape / bytes | Capture → published | Intrinsic params | Extrinsic to base | Temporal |
|---|---|---|---|---|---|---|
| Front camera, 1280×960 mono | 30 Hz | (960, 1280) uint8 = 1,228,800 B → 36.9 MB/s | ≈ 12 ms | 4 (fx, fy, cx, cy) + 5 distortion | 6 | 1 offset + 1 line delay |
| Rear camera, 1280×960 mono | 30 Hz | same → 36.9 MB/s | ≈ 12 ms | 4 + 5 | 6 | 1 + 1 |
| 3D LiDAR, 32 beam | 10 Hz | 32 × 1800 pts × 16 B = 921,600 B/sweep → 9.2 MB/s | ≈ 55 ms | 32 beam angle + 32 range offset | 6 | 1 offset + 1 spin phase |
| IMU (6-axis MEMS) | 200 Hz | 6 × float32 + 8 B stamp = 32 B → 6.4 kB/s | ≈ 1.5 ms | 3 gyro scale + 3 accel scale + 6 misalignment | 6 | reference clock |
| Wheel encoders (diff-drive) | 100 Hz | 2 × int32 + 8 B stamp = 16 B → 1.6 kB/s | ≈ 8 ms | 2 radii + 1 track width | — | 1 |
| Depth camera | 15 Hz | (480, 640) uint16 = 614,400 B → 9.2 MB/s | ≈ 33 ms | 4 + 5 + 1 depth scale | 6 | 1 |
| Total | ≈ 92 MB/s (≈ 740 Mbit/s) | spread ≈ 53 ms | ≈ 107 | 30 | 9 |
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:
ros::Time::now() inside a naive driver records. It carries the transport delay and its jitter, which is scheduler-dependent and worst under load.header.stamp on the wireWhich 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.
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.
| Symptom | Every 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. |
| Why | The 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 tell | A 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 metric | median60s( 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 number | Wheel 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 decoy | A 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 decoy | A 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.
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.
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.
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.
| Calibration | What it is | Typical size | What it silently multiplies |
|---|---|---|---|
| Intrinsic | How a ray becomes a pixel inside one sensor: fx, fy, cx, cy, skew, distortion | 5 + 4–8 numbers per camera | Ray directions. Errors bend rays, which becomes a boresight bias in the trajectory. |
| Extrinsic | The rigid transform between two sensors: R (3 DOF) and t (3 DOF) | 6 numbers per sensor pair | Where one sensor thinks the other one is. Rotation errors are range-independent; translation errors decay with range. |
| Temporal | The offset td between two clock domains, plus per-row time for rolling shutter | 1 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 input | Board square size, wheel radius, stereo baseline, IMU accelerometer scale | 1 number | All lengths, uniformly, with zero residual signature. This is the one in the story above. |
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:
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.
| Angle | The question it answers | What 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. |
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.
ratio = wheel_odom_distance / vio_distance over the last hour. It reads 1.081. That number alone localises the bug to a metric input and saves three days.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.
cv2.calibrateCamera runs, twenty-six years on, and that longevity is the point: a printed board is cheap, the constraints are linear in B, and the failure modes are understood. Do not let a frontier answer imply Zhang is obsolete. It is the reference every replacement has to beat, and it is the thing whose gauge this chapter just broke.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 unknowns. The intrinsic matrix is upper triangular with a 1 in the corner:
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:
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 λ:
Now the physics. r1 and r2 are two columns of a rotation matrix. That is not a decoration; it is two hard facts:
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:
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
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:
That row costs no images. So the count becomes 2n + 1 ≥ 5, and n ≥ 2.
| Model | Rows available | Rank needed | Minimum images | What you are betting on |
|---|---|---|---|---|
| Skew free | 2n | 5 | n = 3 | Nothing. This is the honest count. |
| Skew fixed at 0 | 2n + 1 | 5 | n = 2 | Sensor rows and columns are perpendicular. True to well under a pixel on anything made this century. |
| Skew fixed and fx = fy | 2n + 2 | 5 | n = 2 | Square 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.
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:
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:
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.
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:
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
and six dot products later you have the whole matrix, with no matrix multiplication at all:
| Entry | Is the dot product | Which equals | Reads as |
|---|---|---|---|
| B11 | m1·m1 | 1/fx2 | horizontal focal length, squared and flipped |
| B12 | m1·m2 | 0 | the two axes are perpendicular — this is the skew |
| B22 | m2·m2 | 1/fy2 | vertical focal length, same deal |
| B13 | m1·m3 | −cx/fx2 | principal point, pre-multiplied by B11 |
| B23 | m2·m3 | −cy/fy2 | ditto, vertically |
| B33 | m3·m3 | cx2/fx2 + cy2/fy2 + 1 | the 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:
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:
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 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
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):
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:
| Quantity | Formula on b̃ | Needs λ? | Why |
|---|---|---|---|
| cx, cy | −B̃13/B̃11, −B̃23/B̃22 | No | A ratio of two entries. μ is in both, top and bottom, and cancels. |
| fx, fy | √(λ/B̃11), √(λ/B̃22) | Yes | An 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:
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.
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
and the same six dot products give
Two things fall out immediately, and both are worth saying at the whiteboard:
Apply that substitution to the four lines from Step 3 and you have literally reproduced Zhang's Appendix B:
| Quantity | Skew-free version (Step 3) | Zhang's general version |
|---|---|---|
| cy | −B23/B22 | v0 = (B12B13 − B11B23) / (B11B22 − B122) |
| λ | B33 − B132/B11 − B232/B22 | B33 − [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/B11 | u0 = γ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.
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:
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:
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):
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:
And the horizontal principal point, with γ = −B12fx2fy/λ = 0 because B12 = 0:
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:
The third differs between the views, because it is the only place the translation lives:
So the two homographies are genuinely different matrices:
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:
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).
— 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.
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:
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:
with c = cosφ, s = sinφ. Do the same for the equal-norm constraint:
Using cos2φ = c2 − s2 and sin2φ = 2cs, the new pair of rows is an exact linear map of the old pair:
And now compute the determinant of that 2×2:
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°.
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 σ5/σ1 — 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 | σ5/σ1 of the noiseless V | Divided by T2 |
|---|---|---|
| 0.02 rad (1.1°) | 6.92×10-7 | 1.73×10-3 |
| 0.10 rad (5.7°) | 1.72×10-5 | 1.72×10-3 |
| 0.20 rad (11.5°) | 6.84×10-5 | 1.71×10-3 |
| 0.35 rad (20.1°) | 2.05×10-4 | 1.67×10-3 |
| 0.50 rad (28.6°) | 4.03×10-4 | 1.61×10-3 |
That last column is flat to 7% over a 25× range of tilt. σ5/σ1 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 trials | Solves that failed outright | Mean recovered fx |
|---|---|---|---|---|
| 1.1° | 3172.8 px | 74.30 px | 30 / 120 | 3701 px (truth 800) |
| 5.7° | 31.4 px | 3.16 px | 0 / 120 | 812.0 px |
| 11.5° | 10.6 px | 1.87 px | 0 / 120 | 800.6 px |
| 20.1° | 5.4 px | 1.49 px | 0 / 120 | 799.9 px |
| 28.6° | 3.9 px | 1.44 px | 0 / 120 | 799.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 = 8 | N = 16 | N = 24 | N = 30 | Measured gain | What 1/√N promises |
|---|---|---|---|---|---|---|
| 1.1° | 3172.8 px | 3233.3 px | 1890.6 px | 2122.7 px | 1.49×, non-monotone | 1.94× |
| 5.7° | 31.4 px | 25.0 px | 18.2 px | 15.9 px | 1.98× | 1.94× |
| 20.1° | 5.4 px | 3.9 px | 3.0 px | 2.8 px | 1.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.
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:
| Lever | Change | σ(fx) at 20.1° tilt | Gain | Why |
|---|---|---|---|---|
| Baseline | 8 images, 0.3 px, closed form | 5.4 px | — | the table above |
| More images | → 22 images | 3.12 px | 1.73× | √(22/8) = 1.66 — the noise-averaging law, and it holds here because the tilt is healthy |
| Better corners | → 0.2 px noise | 2.19 px | 1.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 refine | 1.66 px | 1.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:
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:
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:
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:
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:
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.
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:
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.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 centre | r (normalised) | g(r) | Inward pull | Displacement | vs a 0.2 px noise floor |
|---|---|---|---|---|---|
| 50 px | 0.0625 | 0.998902 | 0.11% | 0.055 px | invisible |
| 100 px | 0.1250 | 0.995627 | 0.44% | 0.437 px | 2× — just detectable |
| 200 px | 0.2500 | 0.982790 | 1.72% | 3.44 px | 17× |
| 320 px (mid edge) | 0.4000 | 0.957410 | 4.26% | 13.6 px | 68× |
| 400 px (corner) | 0.5000 | 0.935502 | 6.45% | 25.8 px | 129× |
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):
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."
"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.
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":
| Model | Free intrinsics | Distortion | Poses | Total | Schur-reduced block |
|---|---|---|---|---|---|
| Everything free (skew estimated) | 5 | 5 | 6×22 = 132 | 142 | 10×10 |
| Skew fixed at 0 — the usual default | 4 | 5 | 132 | 141 | 9×9 |
| Skew fixed and fx = fy | 3 | 5 | 132 | 140 | 8×8 |
| What the production call below actually ships skew, aspect ratio, k3 and tangential all fixed | 3 | 2 (k1, k2) | 132 | 137 | 5×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:
CV_16SC2). Note this is 8× the size of the image it corrects — the LUT, not the frame, is the memory event.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.
objpoints in metres, and that number is the only metric input in the whole pipeline." Ties back to Chapter 0 unprompted.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."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."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.
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.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.
Failure mode A: insufficient tilt (the observability failure).
| Symptom | Recalibrating 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. |
| Why | The 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 metric | stdev_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 σ5/σ6 of the singular values of V: if the sixth is not clearly the smallest by a decade, the null space is not unique. |
| The number | Healthy 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 | σ5/σ1 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 decoy | A 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 σ5/σ1 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).
| Symptom | Someone 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. |
| Why | k3 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 metric | Held-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 number | If 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 decoy | A 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 check | Before 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. |
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.
| Symptom | Every 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. |
| Why | Intrinsics 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 metric | Compare 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 number | Using 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 decoy | A 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. |
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.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:
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.
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.
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:
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:
Matching the two parts gives two separate problems:
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.
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:
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:
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.
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:
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:
ezezT puts a 1 in the bottom-right; exeyT puts a 1 in row 1, column 2. So:
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
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
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.
With RX in hand, the translation equation is linear:
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
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."
"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 motion | 2 sin(θ/2) | Lever-arm error from 1 cm of motion error |
|---|---|---|
| 2° | 0.0349 | 0.287 m — useless |
| 5° | 0.0872 | 0.115 m |
| 10° | 0.1743 | 0.057 m |
| 30° | 0.5176 | 0.019 m |
| 45° | 0.7654 | 0.013 m |
| 90° | 1.4142 | 0.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.
The extrinsic is not a file that sits on disk. It is a live edge in a transform tree that every consumer queries.
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.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.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:
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 Z | 1° rotation error | 2 cm translation error | Which dominates |
|---|---|---|---|
| 1 m | 13.96 px | 16.00 px | translation |
| 2 m | 13.96 px | 8.00 px | rotation |
| 5 m | 13.96 px | 3.20 px | rotation |
| 10 m | 13.96 px | 1.60 px | rotation |
| 20 m | 13.96 px | 0.80 px | rotation |
| 50 m | 13.96 px | 0.32 px | rotation |
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 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."
Failure mode A: rotation-starved extrinsic (the corridor drive).
| Symptom | LiDAR-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. |
| Why | Translation 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 metric | Regress 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 decoy | A 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.
| Symptom | Someone 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. |
| Why | An 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 metric | Correlate 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 number | With 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 decoy | Rolling 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. |
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.
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."
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:
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:
and differentiate:
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.
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̇2/σ2 is enormous, every excitation gate you just wrote is green — and the residual an unmodelled offset creates is
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:
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:
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
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
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.
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:
Step 2 — the residual an unmodelled td creates. With td = 18.4 ms:
Step 3 — compare it to the noise floor. A good tracker has σ = 0.3 px. So the residual is
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 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):
With N = 200 tracked features all moving at u̇ = 240 px/s and σ = 0.3 px:
u̇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:
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 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.
Step 2 — the Jacobian and the measurement noise. Straight from the derivative at the top of the chapter:
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?
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.
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 = 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:
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:
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:
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 × 108 — three 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:
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.
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:
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:
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.
Almost every cheap CMOS sensor exposes rows sequentially. Row k is captured at
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 td | Rolling shutter tline | |
|---|---|---|
| Residual vs image velocity | slope = td, same for every row | slope = td + k·tline, ramps with row index |
| Split residuals into image thirds | identical slope in all three | slope 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 robot | none | none |
| Effect on a vertical pole during a yaw | pole stays vertical, shifts sideways | pole leans — the classic jello skew |
| Fix | one scalar in the state | per-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.
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.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):
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:
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.
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:
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 update | 5.000 ms | 0 |
| bias known, 240 px/s (Worked ex. 3) | 0.088 ms | — |
| bias free, constant ω, 240 px/s | 4.945 ms | −0.9998 |
| bias free, ω reverses, ±240 px/s | 0.088 ms | 0.000 |
| bias free, constant ω, 5 px/s — below gate | 5.000 ms | −0.762 |
| bias free, ω reverses, ±5 px/s — below gate | 3.235 ms | 0.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:
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 = ρ(σtd/σb)δ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 unexcited | what 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 J | unchanged: −1.96 ms — does nothing at all here |
| Freeze the state — zero the td row of K, hold the td row and column of P | 0.000 ms, and bgz still receives its legitimate update — the fix |
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.
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.
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.
Failure mode A: td estimated with no excitation.
| Symptom | The 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. |
| Why | With 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 metric | Publish the implied bound σtd = σpx/(√N · median u̇) alongside td. It is computable per frame from quantities you already have. |
| The number | Gate 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 decoy | A 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.
| Symptom | Calibration 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. |
| Why | Two 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 metric | Fit 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 number | 40 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 decoy | Thermal 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. |
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.
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.
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 parameter | Residual is proportional to | Plot to draw | Signature |
|---|---|---|---|
| k1, k2, k3 (radial distortion) | r3, r5, r7 | residual vs radius | zero at centre, grows steeply outward, points radially |
| p1, p2 (tangential) | r2, with an angular pattern | residual vs radius, coloured by angle | one side of the image worse than the other |
| td (time offset) | image velocity u̇ | residual vs image velocity | line through the origin; slope = td in seconds |
| tline (rolling shutter) | u̇ × row index | residual/u̇ vs row index | ramps top to bottom |
| Extrinsic translation Δt | 1 / range | residual vs 1/Z | line through the origin; big near, vanishes far |
| Extrinsic rotation Δθ | 1 (nothing) | residual vs anything | constant offset everywhere — flat against all four covariates |
| cx, cy (principal point) | 1, to first order | trajectory heading vs ground truth | nearly invisible in residuals; shows as a heading bias |
| Board square size / baseline / wheel radius | nothing | — | no residual at all. Only an external metric reference sees it. |
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
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:
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
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.
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
Multiply by f to convert to pixels, then substitute rn = rpx/f and let the powers of f collect:
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:
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):
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:
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:
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:
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 symbol | First-order Δu | Multiplies | Widget value |
|---|---|---|---|
| δtx in the extrinsic | f·δtx · (1/Z) | 1/Z | 16.0 px·m |
| δθ in the extrinsic | f·δθ · (1 + x2) | 1 | 13.96 px |
| δk1 in the lens model | (δk1/f2) · rpx3 | r3 | 12 px at r = 400 |
| td in the clock | td · u̇ | u̇ | 18.4 ms |
| tline in the shutter | tline · (u̇ × k) | u̇ × k | 20 µs/row |
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
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:
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 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) | 0 | 200 | 400 | 600 | 800 |
|---|---|---|---|---|---|
| mean residual r (px) | 0.05 | 3.75 | 7.30 | 11.10 | 14.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
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:
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:
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:
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:
Step 5 — the t-statistic.
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.
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.
So the reading is two-dimensional, and the two dimensions are independent:
| intercept a ≈ 0 | intercept a large | |
|---|---|---|
| slope b ≈ 0 | nothing geometric is wrong — suspect a gauge | extrinsic rotation error, magnitude a/f radians |
| slope b large | the fault this covariate names | both — fix the rotation first, then re-run |
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) | y | If |t| is large, the slope means |
|---|---|---|---|
| 1 | radius r from principal point | residual (px) | distortion coefficients are wrong; the fitted power tells you which order |
| 2 | image velocity u̇ | residual (px) | time offset, in seconds, directly |
| 3 | inverse range 1/Z | residual (px) | extrinsic translation error: the slope is f·Δt, so Δt = slope/f |
| 4 | row index k | residual divided by u̇ (s) | the line delay tline, in seconds per row |
| — | all four slopes flat, intercept large | extrinsic rotation, Δθ = a/f | |
| — | all four slopes flat, intercept zero | the 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 Z | covariate 1/Z | residual = 800 × 0.02 / Z |
|---|---|---|
| 2 m | 0.50 m−1 | 800 × 0.02 / 2 = 8.0 px |
| 20 m | 0.05 m−1 | 800 × 0.02 / 20 = 0.8 px |
Regress the residual (px) on the covariate (m−1) through those two points:
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:
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.
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.
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) | 100 | 200 | 300 | 400 | 460 |
|---|---|---|---|---|---|
| r3 | 1.000×106 | 8.000×106 | 2.700×107 | 6.400×107 | 9.734×107 |
| true offset (px) | 0.021875 | 0.175000 | 0.590625 | 1.400000 | 2.129225 |
| observed bin mean y (px) | 0.0229 | 0.1738 | 0.5920 | 1.3991 | 2.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.21739 | 0.43478 | 0.65217 | 0.86957 | 1.00000 |
|---|---|---|---|---|---|
| c3 = ρ3 | 0.010274 | 0.082190 | 0.277390 | 0.657516 | 1.000000 |
| c5 = ρ5 | 0.000486 | 0.015537 | 0.117982 | 0.497177 | 1.000000 |
Step 2 — five dot products. That is the entire fit; there is nothing else to compute.
Step 3 — Cramer's rule on the 2×2. The determinant first, because it is also the collinearity warning:
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:
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:
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:
Step 6 — the verdict, which is the whole point:
| Column | coefficient (px at the corner) | SE | t | reading |
|---|---|---|---|---|
| r3 | 2.1289 | 0.0062 | 345.1 | overwhelmingly present |
| r5 | 0.0006 | 0.0068 | 0.087 | indistinguishable 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:
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."
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.
All of this should run on the robot, continuously, and cost nothing. Here is the shape of it with real sizes.
The thresholds, and where each comes from:
| Signal | Warn | Alarm | Where the number comes from |
|---|---|---|---|
| odometry / VIO distance ratio | outside 0.99–1.01 | outside 0.98–1.02 | wheel 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 ms | 5 ms at 1 m/s is 5 mm, at the edge of a grasp budget |
| 1/Z regression slope | f·Δt > 1 px·m | > 4 px·m | 4 px·m at f = 800 is Δt = 5 mm of lever-arm error |
| radius regression | |t| > 5 | outer-quintile RMS > 2× inner | a healthy lens is flat in radius after undistortion |
| per-camera RMS spread | max/min > 1.5 | > 2.0 | identical sensors on the same rig should agree; a 2× spread is one bad camera |
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."
Mode A: healthy residuals, wrong world (the gauge).
| Symptom | Every 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 metric | The ratio of two independently scaled distance measurements over a fixed window — wheel odometry against VIO, or a surveyed landmark pair against the map. |
| The number | Alarm 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 decoy | Wheel 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.
| Symptom | Fleet-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. |
| Why | Aluminium 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 metric | Regress RMS against reported board or die temperature, per robot, over months. Also plot the estimated extrinsic (if you estimate it online) against temperature. |
| The number | A 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 decoy | Genuine 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.
| Symptom | Fleet 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. |
| Why | Five 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 metric | Report residuals split by camera, and by image octant within each camera. Never publish a single number where a group-by is available. |
| The number | Alarm 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 decoy | A 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. |
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.
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.
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.
| Stage | Rate | What crosses the boundary | Size |
|---|---|---|---|
| LiDAR driver | 10 Hz | 32 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 time | 0.92 MB/sweep 9.2 MB/s |
| Camera driver | 30 fps | (960,1280) uint8 mono frame, global shutter, 5 ms exposure, hardware-trigger timestamp at mid-exposure | 1.23 MB/frame 36.9 MB/s |
| Ring buffer | — | 3 s of both: 30 sweeps (27.6 MB) + 90 frames (110.6 MB), overwritten in place, no allocation on the hot path | 138 MB resident |
| Association | 1 Hz | Each 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. | — |
| Projection | 1 Hz | Decimate 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 residual | 1 Hz | One 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 reductions | 1 Hz | Each 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.
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.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:
| Structure | Yaw rate ω | u̇ = fω |
|---|---|---|
| 1 — Z = 1 m, r = 80 px | 0.300 rad/s | 240 px/s |
| 2 — Z = 2 m, r = 160 px | 0.575 rad/s | 460 px/s |
| 3 — Z = 5 m, r = 240 px | 0.275 rad/s | 220 px/s |
| 4 — Z = 10 m, r = 320 px | 0.225 rad/s | 180 px/s |
| 5 — Z = 20 m, r = 400 px | 0.575 rad/s | 460 px/s |
| 6 — Z = 50 m, r = 460 px | 0.300 rad/s | 240 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:
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.
| Signal | Healthy | What it is |
|---|---|---|
| Reprojection RMS | 0.28 px | Root-mean-square distance between where a LiDAR point projects and where the image says it is. The number every dashboard shows. |
| Map scale ratio | 1.000 | Mapped distance divided by surveyed distance. Needs an external metric reference; nothing internal produces it. |
| Colouring @ 2 m | 0.0 px | Projection offset on a near structure. |
| Colouring @ 20 m | 0.0 px | Projection offset on a far structure. The pair of these two is the extrinsic discriminator. |
| Slope vs velocity | 0.0 ms | OLS 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 radius | 0.0 px | OLS 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. |
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
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:
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:
Substitute the two lines above and expand every product:
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):
Read the three terms and the whole chapter is already in your hands:
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.
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.
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.
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.
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.
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):
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.
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.
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.
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.
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:
Now re-run Experiment 3 (extrinsic translation, 2.0 cm) with σ = 1.0 and read the far gauge:
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:
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:
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 | √Sxx | SE at σ = 1.0 | Reading ± 95% | t |
|---|---|---|---|---|
| slope vs velocity (td = 18.4 ms) | 281.4 px/s | 0.50/281.4 = 1.78 ms | 18.4 ± 3.5 ms | 10.4 |
| slope vs radius (Δf = +2%) | 323.0 px | 0.50/323.0 → 0.155 px/100 px | 2.00 ± 0.30 | 12.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.
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:
| Injected | near/far | ttd | tr | tr³ | Returned |
|---|---|---|---|---|---|
| square size 23 mm | — | 0.00 | 0.00 | 0.00 | ('metric gauge', 0.920, False) |
| Δθ = 1.00° | 1.000 | 0.00 | 0.00 | 0.00 | ('rotation OR cx', 0.017453, True) |
| Δt = 2.0 cm | 10.000 | −0.39 | −52.2 | +30.6 | ('translation', 0.0200, False) |
| td = 18.4 ms | 1.000 | 29.6 | −0.34 | +0.32 | ('time offset', 0.01840, False) |
| Δf = +2.0% | 0.400 | −0.16 | 12.3 | 0.00 | ('focal length', 0.02000, False) |
| k1 = +0.02 | 0.064 | −0.01 | 0.00 | 5.08 | ('distortion k1', 0.02000, False) |
| Δcx = +10 px | 1.000 | 0.00 | 0.00 | 0.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 tr³ = +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.
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.
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:
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:
eps*r + dcx + k1*r**3/f**2 + f*tan(dtheta) + f*dt/Z + udot*td — the f*dt/Z is term 2, the lateral lever arm. Term 3, −(fX/Z2)·Δtz = −(r/Z)·Δtz, is simply absent. That matters because it is the only fault in the whole taxonomy that is simultaneously linear in radius and falling as 1/Z: it lights the radius slope like a focal error and drags the near/far ratio like a translation error, at the same time, and the six gauges have no way to name it. Concretely, a 2 cm error along the camera's optical axis gives 0.02 × 400/20 = 0.40 px at the 20 m structure and 0.02 × 160/2 = 1.60 px at the 2 m structure — ratio 4.0, right on the boundary of the ratio > 4 branch, which the code would report as a lateral translation of the wrong magnitude in the wrong axis. If someone hands you a dashboard where the radius slope and the near/far ratio both moved and neither the focal nor the translation magnitude cross-checks, this is the term to reach for. It is also the reason range-direction lever arms are the hardest extrinsic component to calibrate: they hide inside two other signatures.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:
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 observe | What it must be | The confirming question to ask them |
|---|---|---|
| Only map scale moved | a metric gauge | "Was the target's square size measured with calipers or taken from the print spec?" |
| Near and far colouring equal, slopes zero | extrinsic rotation, or cx | "Does the offset appear on both cameras, or only one?" One camera means cx. |
| Near colouring ten times far | extrinsic translation | "How much did the robot rotate during the calibration run?" |
| Everything scales with turn rate | time offset or rolling shutter | "Is the sensor global or rolling shutter?" Then split the residuals by image thirds. |
| Offsets grow toward the image edge | focal length or distortion | "Is the growth linear or cubic in radius?" Linear is f, cubic is k1. |
| RMS is fine, downstream is not | a 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.
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.)
| Signal | Reading ± 95% | Healthy | t |
|---|---|---|---|
| reprojection RMS | 7.46 px | 0.28 | — |
| map scale ratio | 1.000 | 1.000 | 0 |
| colouring @ 2 m | +8.00 ± 0.34 px | 0.0 | 45.7 |
| colouring @ 20 m | +0.80 ± 0.34 px | 0.0 | 4.6 |
| slope vs velocity | −0.24 ± 1.22 ms | 0.0 | 0.4 |
| slope vs radius | −3.81 ± 0.11 px | 0.00 | 70.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.
Step 3 — invert for the magnitude. The model is Δu = f·Δt/Z. Take the 2 m reading:
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.
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.
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
and that zero deletes r3 before it ever multiplies anything, leaving a 3×3 map from board coordinates to pixels:
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
Step 2 — move K to the other side. Reading off the columns, and writing h1, h2, h3 for the columns of H:
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
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:
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
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.
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
Averaging N features whose corner noise is independent shrinks the standard deviation by √N, which gives the form in the cheat sheet:
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.
| Concept | The 30-second explanation | Key equation | Tool | Classic paper | 2024+ 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) |
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:
camera_info, and a 9.8 MB undistortion LUT costing 1.5 ms per frame at runtime.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).
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
| Step | Arithmetic, every intermediate | Result |
|---|---|---|
| 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.
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.
| Symptom | Root cause | The metric that reveals it | The 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. |
| Dimension | Classical (target-based, offline) | Modern (targetless / online / learned) | When to use which |
|---|---|---|---|
| Input | A printed board with a known, measured square size | Ordinary driving, scene edges, or a pretrained network | Board when you need metres; targetless when you need to recalibrate 500 robots without a fixture |
| Metric scale | Comes from the board — and is the single point of failure | Comes 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 |
| Uncertainty | A real covariance from the LM normal equations, gateable | Filter 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 tracking | None — a snapshot at one temperature on one day | Continuous; follows thermal and mechanical drift automatically | Long missions and hot environments favour online; safety cases favour a frozen, audited artifact |
| Failure mode | Fails loudly: the solve does not converge or the covariance is huge | Fails quietly: absorbs a genuine fault into the calibration state | This is the real argument for keeping both — online for tracking, offline as the reference to alarm against |
| Effort | 10 minutes of board dance per sensor, per unit, by a trained operator | Zero marginal effort once it works; substantial effort to make it work | Below ~20 units, do it by hand. Above, the automation pays for itself |
| Best current tools | OpenCV calib3d, Kalibr, MC-Calib | OpenVINS online states, livox_camera_calib, maplab, VGGT for bootstrapping | Kalibr for camera-IMU spatial+temporal; OpenCV for a single camera; MC-Calib for a rig |
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.
Five repositories, and what to look at in each.
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.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.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.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.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.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.
| Question | Answer | How |
|---|---|---|
| 1° extrinsic rotation error, f = 800 px. Pixel offset at 20 m? | 14 px | f·Δθ = 800 × 0.0175. No range term — same at 2 m. |
| 2 cm extrinsic translation error, f = 800 px, at 5 m? | 3 px | f·Δt/Z = 800 × 0.02 / 5 = 3.2 |
| 18 ms time offset at 5 m/s. Position error baked in? | 9 cm | 5 × 0.018 = 0.09 m |
| 960-row sensor, 20 µs per line. Top-to-bottom readout time? | 19 ms | 960 × 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% small | 23/25 = 0.92, and reprojection error does not move at all |
| 40 ppm clock drift over an 8-hour shift? | 1 second | 40×10-6 × 28,800 s = 1.15 s |
| Undistortion LUT for 1280×960, two float32 maps? | 10 MB | 1.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? | 3 | 5 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? | rotation | Translation 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̇).
| # | Step | Arithmetic | Running value |
|---|---|---|---|
| 1 | One feature alone | σpx / u̇ = 0.30 / 240 | 1.25×10-3 s = 1.25 ms |
| 2 | √N | 142 = 196, 14.22 = 201.6, so it is just under 14.15. √200 = 14.142 | 14.14 |
| 3 | √N · u̇ | 14.142 × 240 = 14 × 240 + 0.142 × 240 = 3360 + 34.1 | 3394 px/s |
| 4 | Divide | 0.30 / 3394 = 8.839×10-5 s | 88.4 µs |
| 5 | Round to quote | one 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) |
|---|---|---|---|
| 1 | Half angle | θ/2 = 1° | θ/2 = 45° |
| 2 | Half angle in radians | 1° × π/180 = 0.017453 rad | 0.785398 rad |
| 3 | sin of it | sin 1° = 0.0174524 (small-angle: sin x ≈ x, and it is) | sin 45° = 0.707107 |
| 4 | 2 sin(θ/2) | 0.0349048 | 1.414214 |
| 5 | δ / that | 0.010 / 0.0349048 = 0.28650 m | 0.010 / 1.414214 = 0.0070711 m |
| 6 | In readable units | 28.7 cm of lever-arm error | 7.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.
| # | Step | Arithmetic | Running value |
|---|---|---|---|
| 1 | Pixels per frame | 1280 × 960 = 1280×900 + 1280×60 = 1,152,000 + 76,800 | 1,228,800 px (1.23 M) |
| 2 | Bytes per pixel of map | 2 maps × 4 B (float32) | 8 B/px |
| 3 | Map size | 1,228,800 × 8 = 9,830,400 B | 9.83 MB (9.38 MiB) |
| 4 | Read bandwidth at 30 Hz | 9,830,400 × 30 = 294,912,000 B/s | 295 MB/s, maps alone |
| 5 | Time for one frame's maps | 9.83×106 B / 6.5×109 B/s at a realistic single-core streaming rate | 1.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.
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:
| Ask | What a good answer sounds like | What 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.