Before you can fuse two sensors you must know exactly where one sits relative to the other, and exactly when each one spoke. Get the geometry or the clock wrong by a hair and the optimal filter you so carefully built fuses misaligned data and quietly diverges. This is how you find those hidden unknowns.
You have spent two weeks building a Kalman filter. The math is right. The noise models are tuned. On paper it is the optimal way to fuse your camera and your IMU. You run it on the robot and the estimate slowly, smoothly, confidently walks off into nonsense. You re-check the filter a hundred times. The filter is fine. The bug is somewhere you never looked: the filter was fusing two sensors as if they sat in the same spot and ticked on the same clock — and they don't.
This is the lesson nobody warns you about. Every previous chapter of this series assumed the measurements arriving at the fuser were already expressed in a common frame and stamped with a common clock. That assumption is a lie that someone, somewhere, had to make true. Two physical sensors bolted to a rig are separated by a few centimetres and a few degrees of rotation, and their internal clocks drift apart by a few milliseconds. Those two tiny unknowns — the spatial transform between the sensors and the temporal offset between their clocks — are the silent prerequisite for all multi-sensor fusion. Get either wrong and the most beautiful filter in the world fuses garbage.
There are exactly two unknowns you must pin down before fusion can work, and this whole lesson is about finding them:
Numbers turn "be careful" into "oh no." Suppose your rig is rotating at a modest ω = 1 rad/s (about 57° per second — a gentle hand-held pan). You think the camera and IMU are at the same point, but really there is a 2 cm = 0.02 m translation error between them that you forgot to calibrate. When a rigid body rotates, every point on it moves; a point at distance r from the rotation axis sweeps with a velocity:
Plug in ω = 1 rad/s and r = 0.02 m. The two sensors, which you assumed move together, are actually moving past each other at:
That is a phantom velocity of 2 cm/s that your filter cannot explain. It did not come from the world; it came from your wrong belief about where the sensors are. The filter, being optimal, will faithfully try to account for it — by inventing a bias, a drift, an acceleration that isn't there. The error looks exactly like sensor noise, so you will tune your noise covariances bigger and bigger, chasing a ghost that calibration would have killed in one shot.
The temporal error is even more insidious because it is invisible at rest and only bites under motion. Suppose the same gentle rotation ω = 1 rad/s, and the camera's timestamps are off by just td = 5 ms = 0.005 s relative to the IMU. During those 5 ms the rig rotated by:
That is 0.005 radians ≈ 0.29° of unaccounted rotation. Tiny — until you ask what it does to a pixel. A feature sitting at the edge of a typical camera image is about f = 500 pixels from the optical centre. A rotation of Δθ smears that feature across the image by roughly:
Two and a half pixels of error on every single feature, injected by a 5-millisecond clock skew, growing linearly with how fast you move. Faster motion, bigger error. Spin at 3 rad/s and it is 7.5 pixels — enough to wreck a visual-inertial estimator. And here is the cruel part: stop moving and the error vanishes, so a static test rig passes calibration and the bug only appears in the field, under motion, when it matters most.
Below, watch it happen. Two sensors are bolted to one rotating rig. A feature they both observe should land in the same place when you fuse their views. Drag the extrinsic error and the time offset away from zero and watch the two views split apart — then drive them back to zero and watch them snap together. Calibration is the act of finding the slider values that make them lock.
A rig rotates at the speed you set. The first sensor and second sensor both observe one feature. With perfect calibration the two projected points coincide (the fused point, green). Introduce an extrinsic error (wrong R/t) or a time offset and they split — the gap is the fused error. Press Run for live motion; drive both errors to zero to lock them.
Notice the time-offset slider has no effect when the rig speed is zero, and a huge effect when it is high. That is the signature you will learn to read in Chapter 9: a time-sync error is proportional to motion speed and disappears at rest. An extrinsic error behaves differently — it also grows with motion, but it leaves a residual even at low speed because the geometry itself is wrong. Telling these two apart from their symptoms is half of debugging a fusion system.
Before we hunt the fusion-critical unknowns, we have to name what kind of calibration we are even talking about, because the word "calibration" covers two completely different things and conflating them is a classic confusion. Every sensor has intrinsics — parameters about the sensor itself, in isolation — and a multi-sensor rig additionally has extrinsics — parameters about how the sensors sit relative to each other. Fusion lives and dies on the extrinsics, but you cannot get the extrinsics right until the intrinsics are right, so we recap both.
Intrinsic calibration answers: "given this sensor's raw output, what does it mean in physical units?" It is a property of the single device and never mentions any other sensor. The two intrinsics you will meet most are the camera's and the IMU's.
Camera intrinsics. A camera turns 3D rays into 2D pixels. The map is captured by the camera matrix K, a little 3×3 table:
Here fx, fy are the focal lengths in pixels (how strongly the lens magnifies, horizontally and vertically), and cx, cy are the principal point — the pixel where the optical axis pierces the image, usually near the centre but never exactly at it. On top of K sits lens distortion: real lenses bend straight lines into curves (barrel/pincushion), modelled by a few radial coefficients k1, k2, k3 and tangential p1, p2. Intrinsic camera calibration estimates all of these — classically by photographing a checkerboard from many angles (Zhang's method).
IMU intrinsics. An IMU's raw accelerometer and gyroscope outputs are corrupted by three private flaws: a scale factor (a "1 m/s²" true acceleration might read 1.02), a bias (a constant offset even at rest — the very thing that drove the t² drift in Lesson 1), and axis misalignment (the three sensitive axes aren't perfectly orthogonal). IMU intrinsic calibration estimates the scale, bias, and a small misalignment matrix so that raw counts become trustworthy m/s² and rad/s.
Now the part this lesson is really about. Extrinsic calibration finds the rigid transform X that takes a point expressed in sensor A's frame and re-expresses it in sensor B's frame. A rigid transform is just a rotation followed by a translation:
where R is a 3×3 rotation matrix (the orientation of A's axes seen from B) and t is a 3-vector (the position of A's origin seen from B). Together (R, t) is the extrinsic, six numbers in all: three for rotation, three for translation. This is exactly the (R, t) you dragged in Chapter 0 — the thing that, when wrong by 2 cm, injected a phantom 2 cm/s.
Why does fusion need this so badly? Because a filter compares a prediction to a measurement, and those two must live in the same frame. The IMU predicts how the rig moved; the camera measures where a feature went. To check the camera against the IMU prediction, you must transport the IMU's prediction into the camera frame — which requires X. A wrong X transports it to the wrong place, and the residual the filter computes is contaminated by the calibration error, not by real sensor disagreement.
| Intrinsics | Extrinsics | |
|---|---|---|
| About | one sensor alone | two sensors’ relative pose |
| Camera | K (fx,fy,cx,cy) + distortion | R, t to the other sensor |
| IMU | scale, bias, axis misalignment | R, t to the camera |
| How found | checkerboard / static + turntable | hand-eye, motion-based |
| Fusion role | clean physical units (prereq) | the prerequisite — same-frame comparison |
The toggle below splits a single feature observation into its frames. Turn intrinsics off and the pixel is mislocated by lens distortion; turn extrinsics off and the camera and IMU frames don't agree on where the feature is. Both must be on for the fused estimate to be trustworthy.
A grid of features seen by a camera. Intrinsics ON undistorts the lens so straight lines are straight; OFF leaves barrel distortion. Extrinsics ON aligns the IMU frame to the camera frame; OFF leaves them rotated/translated apart. Only with both ON do the frames agree.
We now know the extrinsic X = (R, t) is the prize. How do we find it without a tape measure and a protractor (which would be hopeless at the precision fusion demands)? The classic answer is one of the most elegant results in robotics, and it falls out of a single observation: if two sensors are rigidly bolted together, then whenever the rig moves, the motion each sensor sees is the same motion, just expressed in its own frame. That constraint, written down, is the famous hand-eye equation.
Let's build it carefully, because the derivation is the understanding. Call the two sensors 1 and 2 (historically "hand" and "eye," from a camera on a robot arm). The rig moves from pose 0 to pose 1. Look at the same physical motion from each sensor's point of view:
Now trace a single point through the motion two different ways, which must agree because it is one rigid body. Go from "sensor 2 at time 1" back to "sensor 1 at time 0":
Both paths describe the same rigid displacement of the same object, so they must be equal. Setting them equal and renaming gives the celebrated equation (using the convention where A is sensor-1 motion, B is sensor-2 motion, X the extrinsic):
Read it in English: "transforming by sensor-1's motion and then crossing to sensor-2's frame equals crossing to sensor-2's frame and then transforming by sensor-2's motion." It must hold because both sides move one rigid object the same way. The unknown X is squeezed in the middle. One pair of motions (A, B) gives one equation; collect several motions and you can solve for X. Crucially you never measured X directly — you inferred it purely from how the two sensors' self-reported motions relate.
AX = XB looks like one equation, but it secretly splits into two, and the split is what makes it solvable. A rigid transform has a rotation part and a translation part. Writing A = (RA, tA), B = (RB, tB), X = (RX, tX) and multiplying out the homogeneous transforms, the equation separates into:
The top equation involves only the unknown rotation RX. So we solve it first, completely ignoring translation. Once RX is known, the bottom equation becomes linear in the remaining unknown tX — rearrange to (RA − I) tX = RX tB − tA, a plain linear system you stack over many motions and solve by least squares. Decoupling rotation from translation is the master trick of every hand-eye method; it turns one nasty coupled problem into one rotation problem followed by one easy linear problem.
How do we solve RA RX = RX RB for the rotation RX? Tsai & Lenz's insight: every rotation has a rotation axis (the direction that doesn't move when you rotate). Write each rotation as an axis-angle, i.e. take its "logarithm" to get a 3-vector pointing along the axis with length equal to the angle. Call these α = log(RA) and β = log(RB). The matrix equation collapses to a tidy vector statement: RX must rotate sensor-2's axis onto sensor-1's axis, i.e. α = RX β, for every motion. Stack a few such pairs and RX is over-determined and solvable by least squares (the classic form gives a skew-symmetric linear system in a parameter from which RX is recovered).
Let's do a clean, fully-numeric case so the abstraction lands. Suppose the unknown extrinsic rotation RX is a 90° rotation about the z-axis — we will pretend we don't know it and recover it. Take two rig motions:
We seek the RX satisfying αi = RX βi for both. From motion 1: RX(0,0,1) = (0,0,1), so RX fixes the z-axis — it is a rotation about z. From motion 2: RX(0,−1,0) = (1,0,0), i.e. RX sends −y to +x. A rotation about z that maps −y→+x is precisely a +90° rotation about z:
Check it: RX·(0,−1,0) = (0·0 + (−1)(−1) + 0, 1·0 + 0 + 0, 0+0+0) = (1, 0, 0). ✓. And RX·(0,0,1) = (0,0,1). ✓. We recovered the 90°-about-z extrinsic from two motions, never touching translation, never measuring anything physically — only by comparing how each sensor reported the same rotations. That is hand-eye calibration. (Two motions about non-parallel axes is the bare minimum; in practice you use dozens for robustness, and that requirement — "non-parallel axes" — is the seed of the observability story in Chapter 6.)
Below, feed the solver paired motions one at a time and watch the estimated RX tighten onto the true extrinsic as the motions accumulate — and watch it stall if you only give it rotations about one axis.
Each Add motion gives the solver one (A, B) pair (the same rig motion seen by both sensors). The bar shows the angular error between the estimated RX and the true extrinsic. Add motions about varied axes and it converges fast; add motions about the same axis and it barely improves — the geometry is degenerate.
Where do the motions A and B come from? Two families:
Target-based. Put a known calibration object in view — a checkerboard or an AprilTag grid — whose geometry you know exactly. Each sensor estimates its pose relative to the target, and from those poses you extract clean motions A and B. This is what Kalibr, the standard camera-IMU calibration toolbox, does. The target makes the per-sensor motion estimates precise, so the extrinsic comes out tight. Cost: you need the target and a controlled session.
Targetless (motion/structure-based). No special object. You drive the rig through the world and recover each sensor's motion from its own odometry — the camera does visual odometry, the LiDAR does scan-matching, the IMU integrates. Then AX=XB again. This is how on-the-fly and field recalibration work (and how online self-calibration in Chapter 5 operates). Cost: the per-sensor motions are noisier, so you need more motion and good excitation.
The rotation solve, written out with the log-of-rotation and a least-squares stack, exactly as derived:
python import numpy as np def log_rot(R): # axis-angle vector of a rotation matrix: direction = axis, length = angle angle = np.arccos(np.clip((np.trace(R) - 1) / 2, -1, 1)) if abs(angle) < 1e-9: return np.zeros(3) # no rotation -> useless for hand-eye! axis = np.array([R[2,1]-R[1,2], R[0,2]-R[2,0], R[1,0]-R[0,1]]) / (2*np.sin(angle)) return axis * angle # the "log" of R def solve_handeye_rotation(A_rots, B_rots): # Tsai-Lenz: stack alpha = Rx . beta as a skew-symmetric linear system. M = [] for Ra, Rb in zip(A_rots, B_rots): a = log_rot(Ra) # axis of sensor-1 motion b = log_rot(Rb) # axis of sensor-2 motion # the Tsai constraint: skew(a + b) . x = (a - b) s = a + b skew = np.array([[0, -s[2], s[1]], [s[2], 0, -s[0]], [-s[1], s[0], 0]]) M.append((skew, a - b)) H = np.vstack([m[0] for m in M]) # stack all motions (3 rows each) d = np.hstack([m[1] for m in M]) x, *_ = np.linalg.lstsq(H, d, rcond=None) # least squares over ALL motions # recover Rx from the Cayley parameter x (modified Rodrigues) theta = 2*np.arctan(np.linalg.norm(x)) n = x / (np.linalg.norm(x) + 1e-12) K = np.array([[0,-n[2],n[1]],[n[2],0,-n[0]],[-n[1],n[0],0]]) Rx = np.eye(3) + np.sin(theta)*K + (1-np.cos(theta))*K@K # Rodrigues return Rx
Notice the comment on log_rot: a motion with no rotation returns the zero vector and contributes nothing. That single line is why pure-translation motion is useless for the rotation solve — the exact degeneracy Chapter 6 is about. And the compact one-liner heart of it: axis = log(R), stack skew(a+b)·x = (a−b), lstsq, recover RX.
Space is half the battle. Even with a perfect R and t, your fusion can still diverge if the two sensors disagree about when. Every sensor stamps its data with a timestamp, but those timestamps come from different clocks, through different driver paths, with different latencies. The result is a constant time offset td: when you read "image at t = 1.000 s" and "IMU sample at t = 1.000 s," they may actually correspond to instants 8 ms apart. Temporal calibration estimates td so you can shift one stream onto the other.
We saw the headline number in Chapter 0 (5 ms → 2.5 px). Let's understand why the offset turns into error, because the mechanism tells you how to measure td. The key fact: the IMU's angular rate and the image's feature velocity are two views of the same motion, and a time offset slides one against the other.
Concretely, when the camera rotates at angular rate ω, a feature at distance f (in pixels) from the centre sweeps across the image at a pixel velocity of roughly vpx = f · ω. The IMU's gyroscope directly measures ω. So at every instant, "feature is moving this fast in the image" and "gyro says we're rotating this fast" are perfectly correlated — if the two streams are aligned in time. If the image stream is delayed by td, the feature you see "now" actually corresponds to the gyro reading from td ago. The mismatch produces a position error of:
Stare at that product. The error is the time offset times the feature velocity. At rest (ω = 0) it is zero — the offset is invisible. Under fast motion (large ω) it is large. This is the entire signature of a time-sync error: proportional to motion speed, zero at rest. And it is also the lever for measuring td: slide the streams against each other and find the shift that minimizes the disagreement between the gyro-predicted feature velocity and the observed feature velocity.
Here is how you actually find td. Define a cost that measures how well the IMU-predicted motion agrees with the camera-observed motion after shifting the image stream by a trial offset τ:
Sweep τ over a range of candidate offsets; the τ that minimizes C is your estimated td. Let's compute it with toy numbers. Say the true offset is td = 10 ms, the rig rotates at ω = 2 rad/s, and f = 500 px, so the true per-sample feature displacement the IMU predicts and the camera sees are aligned only when τ = 10 ms. Evaluate the cost at three trial offsets, where each ms of misalignment leaves a residual of f·ω·|τ − td| pixels:
| Trial τ | misalignment |τ − 10 ms| | residual = f·ω·|Δ| = 500·2·|Δ| | cost (residual²) |
|---|---|---|---|
| 0 ms | 10 ms = 0.010 s | 500·2·0.010 = 10.0 px | 100.0 |
| 5 ms | 5 ms = 0.005 s | 500·2·0.005 = 5.0 px | 25.0 |
| 10 ms | 0 ms | 500·2·0 = 0.0 px | 0.0 ← minimum |
| 15 ms | 5 ms | 500·2·0.005 = 5.0 px | 25.0 |
The cost is a clean V (a parabola) bottoming out exactly at τ = 10 ms — the true offset. You did not need to know td in advance; you found it as the bottom of the bowl. And notice the bowl's steepness: it scales with f·ω. Faster motion makes the V sharper, so the offset is estimated more precisely. Slow motion makes the bowl flat and the offset hard to pin down. Motion is the signal that lets you measure time offset — the more excitation, the sharper the estimate. (At ω = 0 the bowl is perfectly flat: every τ gives zero cost, and td is completely unobservable. Another preview of Chapter 6.)
Play with it. Below, two streams — the gyro's rate and the image feature's velocity — slide past each other as you change td; the residual is a V that bottoms at the true offset, and the V gets sharper as you increase motion speed.
The gyro rate and feature velocity are the same motion seen by two sensors. Slide the trial offset to align them; the residual cost (bottom bar) is minimized at the true offset (10 ms). Crank up motion speed and watch the cost bowl get steeper — motion is what makes td measurable.
Finding td is a 1-D search over the cost we just tabulated:
python import numpy as np def time_offset_cost(tau, t_cam, feat_vel, t_imu, gyro_rate, f=500.0): # Shift the camera stream by tau, then compare what the IMU predicts # the feature velocity should be (f * gyro) against what the camera saw. imu_pred = np.interp(t_cam + tau, t_imu, f * gyro_rate) # predicted px/s residual = feat_vel - imu_pred # disagreement return np.sum(residual**2) # total squared cost def estimate_td(t_cam, feat_vel, t_imu, gyro_rate, f=500.0): taus = np.linspace(-0.05, 0.05, 201) # search +/- 50 ms costs = [time_offset_cost(t, t_cam, feat_vel, t_imu, gyro_rate, f) for t in taus] return taus[np.argmin(costs)] # bottom of the V = t_d # compact: td = argmin over tau of sum( feat_vel - interp(t_cam+tau, t_imu, f*gyro) )^2
The whole estimator is "shift, interpolate, compare, take the argmin." The np.interp is doing the slide; the squared sum is the V; argmin reads off the bottom. Online estimators (next two chapters) do this incrementally instead of by brute-force sweep.
We have a spatial solver (AX=XB) and a temporal solver (the V-cost). The modern batch-calibration tools — above all Kalibr — solve both at once, and they do it with a beautiful idea from Furgale, Barfoot & Sibley: don't think of the trajectory as a list of poses at discrete timestamps; think of it as one smooth continuous function of time, and represent that function with a B-spline.
Here is the trap continuous-time solves. The IMU samples at, say, 200 Hz; the camera at 20 Hz; and — this is the whole point — their samples don't line up in time, and once you allow an unknown time offset td, you literally don't know which instant any measurement belongs to until you've solved for td. A discrete estimator that stores "pose at frame 1, pose at frame 2, ..." has no pose to hand you at the exact, off-grid, td-shifted instant a measurement actually occurred. You'd have to interpolate ad hoc, and the interpolation interacts with the unknown you're solving for.
The fix is to make the trajectory a function you can evaluate anywhere. Write the rig's pose as a continuous curve T(t), so that for any time — an IMU instant, a camera instant, a camera instant shifted by a trial td — you can just ask "where was the rig at time t?" and get an answer. With a continuous T(t), a measurement at any unsynchronized timestamp relates cleanly to one shared trajectory, and td becomes just another parameter you optimize, because shifting a measurement in time simply queries T(t) at a slightly different argument.
How do you store a continuous trajectory with finitely many numbers? A B-spline: a smooth curve built from a handful of control points (a.k.a. knots). The curve's value at any time t is a weighted blend of the few nearby control points, with weights given by smooth basis functions. Three properties make it perfect for calibration:
The control points ci become the unknowns of one big optimization, together with the extrinsics (R, t) and the time offset td. Every measurement — each gyro sample, each accelerometer sample, each detected checkerboard corner — becomes a residual against this single smooth trajectory. Minimize the sum of squared residuals and out pop a smooth trajectory and the calibration, jointly, optimally. This is the continuous-time batch estimation backbone, and it is what makes Kalibr's camera-IMU calibration the gold standard.
Below, see the spline in action: scattered, off-grid sensor samples (different rates) all attach to one smooth curve you can query at any time — including at a shifted time to test a td.
Sparse control points define a smooth B-spline trajectory. The fast IMU samples and slow camera samples land at different, unsynchronized times — yet all relate to the one curve. Move the query time to read the pose at any instant; shift it by td to see how a time offset just slides the query point.
Batch calibration (Chapter 4) is wonderful but assumes you can stop, wave a target around, and run an offline solver. Real systems drift: a phone warms up and its lens shifts microns; a car hits a pothole and the LiDAR mount flexes; a drone's frame bends in flight. The extrinsics and td you measured in the lab are stale by the time it matters. The modern answer is online self-calibration: fold the calibration parameters into the estimator's state and let the running filter (or factor graph) refine them continuously, for free, while it does its normal job.
Recall the state vector of a visual-inertial estimator: position, velocity, orientation, IMU biases. The trick is to append the calibration unknowns to that vector:
The estimator already knows how to estimate state from measurements; now the extrinsics and time offset are part of the state, so the same machinery estimates them too. As the system moves, every measurement that depends on (R, t, td) — which is every camera-IMU residual — provides a little information about them, and the filter slowly tightens its estimate. This is exactly what VINS-Mono (Qin & Shen) does: it makes td a state variable and estimates it online, so the system stays calibrated even as the offset drifts.
For the filter to estimate td, it needs to know how a measurement changes when td changes — the derivative (Jacobian) of the residual with respect to td. We already derived it: a feature's image position moves by f·ω per unit time, so its sensitivity to td is precisely its velocity in the image. Qin & Shen's insight is to model a feature's position as a linear function of the time offset, using its measured image velocity as the slope:
Now the residual depends linearly on td, with a slope (the image velocity) the system already measures by tracking the feature. That makes td an ordinary, observable state the filter can correct each step — provided there's motion, because if the features aren't moving, the slope is zero and the filter gets no information (Chapter 6 again).
Where you fold the calibration in depends on your estimator architecture. In a filter (EKF/UKF, Lessons 5–8), you literally extend the state vector and covariance matrix, and the calibration parameters get their own rows/columns — their covariance shrinks as motion gives information, grows (slowly) if you let them drift. In a factor graph (the modern VIO/SLAM backbone), the calibration parameters become nodes shared across many factors, and the optimizer refines them jointly with the trajectory. Either way the principle is identical: a parameter you're uncertain about becomes a variable you estimate.
Watch online calibration converge below: start with a deliberately wrong extrinsic and td, run the system, and the estimates walk toward truth — fast when the motion is rich, stalling when the motion is poor.
The estimator carries the extrinsic and time offset as states, starting wrong. Press Run and each step folds in a measurement, shrinking the error — if there's excitation. Toggle Rich motion off (poor excitation) and watch convergence stall: the parameters stop improving because the measurements carry no information about them.
This chapter is the one that turns a junior calibration engineer into a senior one, because it explains a failure that has no error message. You run the calibration, it converges, it reports a confident number — and the number is garbage, silently. The cause is observability: a parameter is only recoverable if the motion you performed actually excited it. Move the wrong way and the calibration solves for a parameter it has no information about, filling it with noise while reporting success.
A calibration parameter is observable under a given motion if changing that parameter would change the measurements you collected. If you could change the parameter and no measurement would notice, then no measurement can tell you its value — it is unobservable, and the solver is free to put anything there. We have already met this three times:
Certain motions leave certain parameters unobservable. Memorize these traps:
| Motion you performed | What stays unobservable | Why |
|---|---|---|
| Pure translation (no rotation) | the rotation extrinsic; much of td | hand-eye rotation needs rotation (log(R)=0 for pure translation — recall the numpy comment) |
| Rotation about one axis only | the extrinsic components about the other axes | you only ever compared the sensors around one axis |
| Constant velocity (no acceleration) | accel scale/bias; translation extrinsic is weak | the accelerometer only "feels" changes in velocity |
| No motion at all (static) | almost everything | nothing changes, so nothing is informative |
The cure is the opposite of all of these: full excitation — rotate about all three axes and translate along all three axes, with varying speed (so there's acceleration too). This is exactly why a Kalibr calibration session asks you to wave the rig through a vigorous, varied 3D dance in front of the target rather than slide it smoothly along a rail. The dance isn't superstition; each degree of freedom of motion makes a different calibration parameter observable.
Below, run two motions side by side: a degenerate one (rotation about a single axis) and a full-excitation one. Watch the parameter's uncertainty ellipse: it stays fat under degenerate motion (unobservable) and collapses under full excitation (observable).
The uncertainty ellipse shows how confidently the calibration parameter is known. Choose degenerate motion (one axis) and the ellipse stays huge in the unexcited direction — the parameter is unobservable. Choose full excitation (all axes) and the ellipse collapses to a tight dot — observable. Press Run to accumulate motion.
This recurring chapter grounds the theory in the actual tools and systems that depend on it. Calibration is invisible when it works and catastrophic when it doesn't, so the products below all treat it as a first-class engineering problem, not an afterthought.
Kalibr (from ETH Zurich's ASL, the lab of Furgale, Rehder, Siegwart) is the de-facto open-source toolbox for exactly this lesson. It performs camera intrinsic calibration, camera-IMU extrinsic calibration (R, t), and temporal calibration (td) — all jointly, using the continuous-time B-spline batch estimation of Chapter 4. You wave an AprilTag grid in front of the rig with full excitation; Kalibr fits one smooth trajectory and spits out K, distortion, R, t, td, and the residuals to judge the fit. It also calibrates multi-camera rigs (camera-to-camera extrinsics) and IMU intrinsics. If you have ever set up a VIO system, you have almost certainly run Kalibr first.
A self-driving car carries cameras, LiDARs, and radars, and fusing them demands every pairwise extrinsic: camera-LiDAR (so a LiDAR point lands on the right pixel for semantic labelling), camera-camera (surround-view stitching), LiDAR-LiDAR (merging multiple spinning units into one cloud), and radar-camera (associating a radar return with a visual object). Target-based methods use checkerboards visible to both modalities; targetless methods exploit edges and planes shared between a LiDAR cloud and an image. Time sync is mandated by hardware triggering — a PTP (Precision Time Protocol) or GPS-PPS master clock so the cameras shutter at known instants relative to the LiDAR spin — precisely to drive td toward zero rather than estimate it.
A headset (Quest, Vision Pro, HoloLens) is a camera-IMU rig strapped to a moving head, and a wrong extrinsic or td makes virtual objects swim against the world — instant nausea. These devices are factory-calibrated (a precise per-unit offline calibration on the production line) and run online self-calibration (Chapter 5) because the plastic frame flexes with temperature and the user's grip. The combination — a tight factory seed refined online — is exactly the offline-plus-online recipe from Chapter 5. The whole comfort of AR rests on td staying sub-millisecond under fast head rotation.
The original use case, still ubiquitous: a robot arm with a camera (on the wrist, "eye-in-hand," or fixed overlooking the workspace, "eye-to-hand") must know the camera-to-gripper transform to convert "I see the part here in the image" into "move the gripper there." This is the literal hand-eye problem AX = XB (Chapter 2), solved by Tsai & Lenz's 1989 method and its descendants. Every pick-and-place cell, every visual-servoing robot, every bin-picking system runs hand-eye calibration on setup — and re-runs it whenever the camera mount is bumped.
This recurring chapter is the field manual: how you, hands on keyboard, get a good calibration and know it's good. We'll walk a Kalibr-style camera-IMU session end to end, because if you can do that you can do the rest.
Calibrate each sensor's intrinsics before touching extrinsics. For the camera, photograph an AprilTag grid (or checkerboard) from many angles, distances, and orientations — fill the frame, hit the corners, tilt steeply — and fit K + distortion. For the IMU, characterize its noise (the Allan-variance plot gives you the white-noise and bias-instability parameters Kalibr needs). Garbage intrinsics poison the extrinsics, so this is non-negotiable.
This is where most calibrations are won or lost. Hold the rig and move it through a rich 3D trajectory in front of the target: rotate about all three axes (pitch, roll, yaw — nod, tilt, turn) and translate along all three axes (up-down, left-right, in-out), with varying speed so there's acceleration. Keep the target fully in view the whole time. The motion should look almost silly — a fast, varied wobble, not a smooth glide. Each axis of motion makes a different parameter observable (Chapter 6); skip an axis and you ship an unobservable garbage value. A typical session is 30–90 seconds of this dance.
After the solver runs, the verdict is in the reprojection residuals — for each detected corner, the gap between where the calibration predicts it and where it was actually seen, in pixels. What good looks like vs bad:
| Signal | Good calibration | Bad calibration |
|---|---|---|
| Reprojection RMS | sub-pixel (< ~0.3 px) | several pixels, or growing |
| Residual pattern | random scatter, zero-mean, no structure | systematic — swirls, radial bias, a constant offset |
| Residual vs motion | flat — same at all speeds | grows with speed (time-sync error) or rotation-rate (extrinsic error) |
| Parameter covariance | tight on every parameter | huge on some parameter (unobservable) |
The pattern matters more than the magnitude. Random sub-pixel scatter means the model fits and the leftover is just noise. Structured residuals — a swirl, a constant bias, anything speed-correlated — mean a systematic error the model didn't capture: a wrong distortion model, a wrong extrinsic, an unestimated td. A residual plot that grows with motion speed is the unmistakable fingerprint of a leftover time offset (Chapter 3).
Decide your regime. Calibrate offline once when the rig is mechanically stable and you can run a target session — a fixed robot cell, a research platform, factory bring-up. Estimate online when the calibration drifts — consumer devices, thermal environments, long missions, anything that flexes. In practice: do a tight offline calibration to get the seed, then enable online self-cal to track drift around it (Chapter 5). Don't run online with no seed — it can wander off if the initial guess is too far.
Never trust a calibration you haven't validated independently. Three cheap checks:
This recurring chapter is a field guide to diagnosis. Calibration errors are sneaky precisely because they all look like "the filter is a bit off." The skill is reading the pattern of the error to name its cause. Each failure below has a distinct fingerprint.
A wrong R or t injects an error with a geometric structure. The two giveaways:
How to confirm: overlay the two modalities (LiDAR-into-image, or one camera's features into the other's frame). A bad extrinsic shows a consistent spatial misregistration you can literally see. Re-run hand-eye with better excitation; if the extrinsic shifts a lot, the old one was wrong.
A wrong td has the most diagnostic fingerprint of all, because of its motion dependence:
How to confirm: run the motion-speed sweep (Chapter 8). A monotonic error-vs-speed line that hits zero at zero speed is td, full stop. Then estimate td (sweep the V-cost or enable online estimation) and watch the speed-dependence vanish.
The calibration was right, then slowly went wrong. The rig heats up (electronics, sun, friction) and the mount expands; vibration loosens a screw; a payload bends the frame. The fingerprint: a calibration that degrades over time or with temperature, fine after a cold start and bad after an hour. How to confirm: log residuals over a long run and correlate with temperature; if error tracks temperature, it's thermal drift. Fix: online self-calibration (Chapter 5) so the estimate tracks the drift, plus better mechanical/thermal design of the mount.
The most dangerous, because there's no obvious symptom at calibration time. The solver converged, reported a number, you shipped it — but a parameter was unobservable and got filled with noise. The fingerprint appears only later: the system works in some conditions and mysteriously fails in others (the conditions that exercise the unobservable parameter). How to confirm: inspect the covariance of every calibration parameter, not just the point estimate. A huge variance on a parameter means it was never observed — the point estimate is meaningless. Fix: recalibrate with full excitation (Chapter 6, Chapter 8).
This deserves its own heading because it wastes more engineer-weeks than any other. Both extrinsic and time-sync errors masquerade as fat sensor noise, so the reflex is to inflate the noise covariances Q and R and move on. This "works" — the filter stops complaining — while hiding a structural bug. The tell that it's calibration, not noise: real sensor noise is random and motion-independent; calibration error is structured and motion-proportional. If your "noise" gets worse when you move faster, or shows a consistent direction, it is not noise — it is calibration, and no covariance tuning will truly fix it.
You now hold the silent prerequisite for every other lesson in this series. The filters you built in Lessons 5–10 assumed a common frame and a common clock; this lesson is how that assumption gets earned. Everything spatial that comes after — visual-inertial odometry, LiDAR-inertial odometry, multi-sensor SLAM — begins with the calibration you just learned to find.
This is Lesson 15 — the bridge from the classical filters into the modern spatial-fusion systems. The recurring structure (silent bug → concepts → Use Cases, Practical, Debugging, Connections) is the same one you've walked since Lesson 1.
| Direction | Lesson | The connection |
|---|---|---|
| Builds on | IMU & Inertial Navigation (sf-13) | the IMU intrinsics (scale, bias, misalignment) recapped here come from there; calibration removes the systematic part the t² drift fed on |
| Builds on | INS / GNSS Coupling (sf-14) | tight coupling assumes the GNSS antenna lever-arm (an extrinsic!) and time tags are calibrated — this lesson is why it can |
| Builds on | Extended Kalman Filter (sf-07) | online self-cal extends the EKF state with R, t, td; the Jacobians you learned to linearize now include the calibration parameters |
| Enables | Visual-Inertial Odometry (sf-16) | VIO is the camera+IMU cooperative pair — it cannot run without the camera-IMU extrinsic and td this lesson finds; VINS-Mono's online td is Chapter 5 |
| Enables | LiDAR-Inertial Odometry (sf-17) | LIO needs the LiDAR-IMU extrinsic and time sync; same hand-eye and temporal calibration, different sensor pair |
| Concept | The one thing to remember |
|---|---|
| Two unknowns | Fusion needs the spatial transform (R, t) and the temporal offset td. Get either wrong → the filter fuses misaligned data and diverges. |
| Cost of error | 2 cm extrinsic @ 1 rad/s → 2 cm/s phantom velocity. 5 ms td @ 1 rad/s → ~2.5 px feature error. Both motion-proportional. |
| Intrinsic vs extrinsic | Intrinsic = one sensor's private params (K, distortion; IMU scale/bias). Extrinsic = sensor-to-sensor pose (R, t) — the fusion-critical one. |
| Hand-eye | A·X = X·B from rigidity. Solve rotation first (decoupled, via log/axis-angle), then translation (linear). Needs non-parallel rotation axes. |
| Time offset | Slide streams; td = argmin of the residual V. Slope ∝ f·ω — sharper (more measurable) under faster motion. |
| Continuous-time | Represent the trajectory as a B-spline (Furgale): evaluable at any timestamp, so td is just an optimization parameter. The Kalibr backbone. |
| Online self-cal | Fold R, t, td into the filter/graph state and refine continuously (VINS-Mono). Tracks drift — needs a seed and excitation. |
| Observability | Only excited parameters are observable. Pure translation kills rotation extrinsic; rest kills td. Full excitation = rotate + translate, all axes, varying speed. |
| Debugging | Vanishes at rest, grows with speed → td. Constant/low-speed offset → extrinsic. Over time/temperature → drift. Huge param covariance → unobservable. Random & motion-independent → real noise. |