Sensor Fusion: Classical to Modern · Lesson 15 of 22

Calibration & Time Synchronization

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.

Prerequisites: basic vectors & rotations + a little arithmetic. We build the rest.
10
Chapters
6
Simulations
0
Assumed Knowledge

Chapter 0: The Silent Bug That Diverges Every Filter

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:

Unknown 1 — SPATIAL (extrinsics)
Where does sensor B sit relative to sensor A? A rotation R and a translation t — the rigid transform between their frames. "The IMU is 4 cm left of the camera and tilted 2°."
+
Unknown 2 — TEMPORAL (time offset)
When sensor A says "now," what does sensor B's clock read? A latency / offset td in milliseconds. "The image is stamped 8 ms later than the IMU sample it belongs with."
↓ both must be known
THEN fusion is valid
Only once R, t, and td are known can the filter compare a camera measurement to an IMU prediction in the same frame at the same instant.

Let's make the cost visceral — a 2 cm error, by hand

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:

v = ω × r  ⇒  |v| = ω · r

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:

|v| = 1 · 0.02 = 0.02 m/s = 2 cm every second

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.

Now the time-offset cost — 5 ms, by hand

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:

Δθ = ω · td = 1 · 0.005 = 0.005 rad

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:

Δpixels ≈ f · Δθ = 500 · 0.005 = 2.5 pixels

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.

Why this hides so well. Both errors masquerade as sensor noise. The extrinsic error injects a motion-proportional bias; the time offset injects a motion-proportional smear. Neither shows a smoking gun in a log file — they just make your residuals fatter, your covariances grow, your estimate drift. The single most common mistake in fusion is to spend weeks re-tuning the filter when the real fix was ten minutes of calibration. The filter cannot fix a frame error or a clock error; it can only absorb them as noise until it can't.

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.

The fusion-killer: misaligned sensors split a shared feature

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.

Extrinsic err (cm) 0.0 Time offset (ms) 0.0 Rig speed ω (rad/s) 1.0
set both errors to zero to lock the two views together

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.

The one-sentence thesis of this lesson. Fusion math assumes the inputs are in a common frame at a common time. Someone has to earn that assumption. Calibration is the procedure that estimates the spatial transform (R, t) and the temporal offset (td) so the assumption becomes true — and without it, every filter in this series silently diverges.

A rig rotates at 1 rad/s. Your filter diverges, and you suspect noise. You stop the rig and the residuals shrink to almost nothing; you move again and they balloon. What is the most likely culprit?

Chapter 1: Intrinsics vs Extrinsics — Two Very Different Calibrations

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.

Intrinsics: a sensor's private parameters

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:

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

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.

The mental split. Intrinsics are about turning raw output into physical units for one device. Extrinsics are about placing one device's measurements into another's frame. Intrinsics: "what does this camera's pixel mean?" Extrinsics: "where is this camera, relative to the IMU?" You always calibrate intrinsics first, per sensor, because the extrinsic solver assumes each sensor already reports clean physical quantities.

Extrinsics: the sensor-to-sensor pose — the fusion-critical part

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:

pB = R · pA + t

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.

The split in one table.
 IntrinsicsExtrinsics
Aboutone sensor alonetwo sensors’ relative pose
CameraK (fx,fy,cx,cy) + distortionR, t to the other sensor
IMUscale, bias, axis misalignmentR, t to the camera
How foundcheckerboard / static + turntablehand-eye, motion-based
Fusion roleclean 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.

Intrinsics & extrinsics — toggle each layer of calibration

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.

both layers on — frames agree
Your camera's focal length and lens distortion are slightly wrong. Which calibration is to blame, and is this an intrinsic or extrinsic problem?

Chapter 2: Spatial Calibration — The Hand-Eye Problem AX = XB

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.

Deriving AX = XB from rigidity

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":

Path 1
First apply sensor 1's motion A, then convert from frame 1 to frame 2 with X: the combined transform is X · A.
must equal
Path 2
First convert frame 1 to frame 2 with X, then apply sensor 2's motion B: the combined transform is B · X.

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):

A · X = X · B

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.

The "hand-eye" name. The problem was born in 1989 (Tsai & Lenz) for a camera ("eye") mounted on a robot gripper ("hand"). The arm's encoders report the hand's motion A; the camera reports its own motion B by watching a fixed target; X is the unknown camera-to-gripper transform. The exact same equation calibrates camera-to-IMU, LiDAR-to-camera, or any two rigidly-coupled sensors — the "hand" and "eye" are just whichever two sensors you bolted together.

Split the problem: solve rotation first, then translation

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:

Rotation:   RA · RX = RX · RB
Translation:   RA · tX + tA = RX · tB + tX

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.

The rotation solve via the axis-angle (log) trick

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).

Worked hand-eye rotation solve, with actual numbers

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:

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

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.

Hand-eye AX=XB — paired motions converge on the extrinsic

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.

0 motions — extrinsic unknown (error 90°)

Target-based vs targetless calibration

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 signature of spatial calibration. It exploits rigidity: two sensors bolted together see the same motion in different frames, giving AX = XB. Solve rotation first (decoupled, via axis-angle), then translation (linear). It needs varied motion — rotations about non-parallel axes — or it is degenerate. Target-based (Kalibr/AprilTag) is precise and controlled; targetless is flexible and online.

Hand-eye rotation solve in numpy (verbose)

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.

In the hand-eye equation AX = XB, what do A, B, and X represent, and why must the equation hold?

Chapter 3: Temporal Calibration — Finding the Clock Offset td

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.

Why a few milliseconds is catastrophic under motion

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:

Δpixels = vpx · td = f · ω · td

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.

The time-offset cost, worked by hand

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 τ:

C(τ) = Σk [ observedcam(tk) − predictedimu(tk + τ) ]²

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 ms10 ms = 0.010 s500·2·0.010 = 10.0 px100.0
5 ms5 ms = 0.005 s500·2·0.005 = 5.0 px25.0
10 ms0 ms500·2·0 = 0.0 px0.0 ← minimum
15 ms5 ms500·2·0.005 = 5.0 px25.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.)

The temporal-spatial duality. Spatial calibration aligns the sensors in space using the fact that they see the same motion in different frames. Temporal calibration aligns them in time using the fact that they see the same motion at different instants. Both lean on motion; both are sharpened by excitation; both go blind at rest. They are the same idea on two axes — which is why the modern tools (next chapter) solve them jointly.

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.

Time-offset cost — the residual is a V, sharpened by motion

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.

Trial offset τ (ms) 0.0 Motion speed ω 2.0
slide τ to find the offset that minimizes the residual

The time-offset cost in numpy (verbose + compact)

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.

Why does the cost bowl for td get sharper (easier to find the minimum) as the rig moves faster?

Chapter 4: Continuous-Time Calibration — One Smooth Trajectory, Any Timestamp

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.

The problem discrete representations can't solve

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.

Why a B-spline

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:

T(t) = Σi Bi(t) · ci   (blend of control points ci by basis functions Bi)

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.

Why continuous-time is the right tool for calibration. The whole difficulty is that sensors fire at different, unsynchronized, td-uncertain times. A continuous trajectory T(t) dissolves that difficulty: any measurement, at any timestamp, off any clock, relates to the same smooth curve, and td is just where along the curve you evaluate. The B-spline gives you that curve cheaply (few control points), smoothly (analytic derivatives for the IMU), and sparsely (local support).

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.

B-spline trajectory — query any timestamp on one smooth curve

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.

Query time 0.50 td shift (ms) 0
one smooth curve serves every sensor at every timestamp
Why does Furgale's continuous-time (B-spline) representation make it natural to solve for the time offset td?

Chapter 5: Online Self-Calibration — Making R, t, td Filter States

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.

The idea: calibration parameters are just more state

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:

x = [ position, velocity, orientation, gyro bias, accel bias, Rextrinsic, textrinsic, td ]

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.

Why making td a state works — the linearized handle

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:

feature_position(td) ≈ feature_position(0) + velocityimage · td

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).

Offline vs online — when to use which. Offline batch (Kalibr): do it once, with a target, when you need the most accurate calibration and the rig is stable — factory calibration, a research setup, the initial bring-up of a new sensor stack. Online self-cal (VINS-Mono-style): run it continuously when the calibration drifts — consumer devices, long missions, anything thermal or mechanical. The pragmatic recipe is both: a tight offline calibration as the initial guess, then online refinement to track drift around it. Online needs a good seed; batch needs a controlled session. They are partners, not rivals.

Filter vs factor graph — two homes for the same parameters

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.

Online self-calibration — the estimate converges as the system runs

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.

extrinsic err 5.0 cm · t_d err 12.0 ms — press Run
What does "online self-calibration" do with the extrinsics R, t and the time offset td?

Chapter 6: Observability — You Cannot Calibrate What You Don't Excite

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.

What "observable" means, concretely

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:

The degenerate motions, named

Certain motions leave certain parameters unobservable. Memorize these traps:

Motion you performedWhat stays unobservableWhy
Pure translation (no rotation)the rotation extrinsic; much of tdhand-eye rotation needs rotation (log(R)=0 for pure translation — recall the numpy comment)
Rotation about one axis onlythe extrinsic components about the other axesyou only ever compared the sensors around one axis
Constant velocity (no acceleration)accel scale/bias; translation extrinsic is weakthe accelerometer only "feels" changes in velocity
No motion at all (static)almost everythingnothing 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.

The silent killer. Insufficient excitation does not announce itself. The solver still returns a number; the optimizer still "converges." But an unobservable parameter is pinned by noise or by your initial guess, not by data — and you ship it. The only defense is to watch the uncertainty: a well-excited parameter's variance shrinks to a tight value; an unobservable one's variance stays huge (or, in a naive solver, gets quietly clamped to a meaningless point). Always inspect the covariance of every calibration parameter, not just the point estimate. A confident point estimate with enormous variance is the calibration equivalent of a confident liar.

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).

Observability — uncertainty collapses only under full excitation

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.

choose a motion and press Run
You calibrate a camera-IMU rig by sliding it smoothly along a straight rail (pure translation, constant speed). The solver converges and reports an extrinsic rotation. Should you trust it?

Chapter 7: Use Cases & Real Products — Where Calibration Earns Its Keep

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 — the standard camera-IMU / multi-cam toolbox

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.

Autonomous-vehicle multi-sensor rigs

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.

AR/VR headsets — factory plus online calibration

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.

Robot hand-eye calibration for manipulation

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.

The common thread. Every one of these products treats (R, t, td) as unknowns to be found, not assumed. Kalibr finds them offline with a target; AV rigs drive td to zero with hardware time sync and find extrinsics with targets/structure; headsets seed offline and refine online; robot arms run hand-eye on setup. The names change; the two unknowns from Chapter 0 never do.
An AR headset is factory-calibrated, yet still runs online self-calibration during use. Why both?

Chapter 8: Practical Application — Actually Running a Calibration

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.

Step 1 — intrinsics first, always

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.

Step 2 — the motion pattern: a vigorous, full-excitation dance

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.

The litmus for excitation. If you can describe your calibration motion in one word — "I slid it along the rail," "I spun it on the turntable" — it is almost certainly degenerate. Good calibration motion resists a one-word description because it excites every axis. When in doubt, move more, in more directions, at more speeds.

Step 3 — read the residuals: good vs bad

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:

SignalGood calibrationBad calibration
Reprojection RMSsub-pixel (< ~0.3 px)several pixels, or growing
Residual patternrandom scatter, zero-mean, no structuresystematic — swirls, radial bias, a constant offset
Residual vs motionflat — same at all speedsgrows with speed (time-sync error) or rotation-rate (extrinsic error)
Parameter covariancetight on every parameterhuge 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).

Step 4 — offline once vs online continuously

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.

Step 5 — validate the calibration

Never trust a calibration you haven't validated independently. Three cheap checks:

The classic mistake. Calibrating with gentle motion and validating with gentle motion — then deploying into fast motion. Both the extrinsic and the time-sync error are motion-proportional, so a calibration validated only at low speed can hide a large error that only appears in the field. Always validate at the highest motion speed your system will actually see.
Your calibration solver reports a sub-pixel average reprojection error, but the residual plot shows a clear radial swirl pattern. Is the calibration good?

Chapter 9: Debugging & Failure Modes — Reading the Symptoms

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.

Symptom: bad extrinsics

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.

Symptom: bad time-sync

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.

Symptom: thermal / mechanical drift of extrinsics

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.

Symptom: insufficient excitation — the silent garbage

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).

The cardinal error: mistaking calibration error for sensor noise

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.

The diagnostic flowchart. Error vanishes at rest, grows linearly with speed → time-sync (td). Error is a constant directional offset, or grows with rotation rate, visible at low speed → extrinsic (R, t). Error appears over time / with temperature → thermal-mechanical drift → go online. A parameter with huge covariance after calibration → insufficient excitation → recalibrate with full motion. Error is random and motion-independent → genuine sensor noise — the only case where tuning Q/R is the right answer.
Two rigs show fusion errors. Rig A's error is a constant directional offset visible even when nearly still; Rig B's error is near-zero at rest and grows linearly with motion speed. What's wrong with each?

Chapter 10: Connections, References & Cheat-Sheet

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.

Where this sits in the series

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.

DirectionLessonThe connection
Builds onIMU & 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 onINS / 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 onExtended 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
EnablesVisual-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
EnablesLiDAR-Inertial Odometry (sf-17)LIO needs the LiDAR-IMU extrinsic and time sync; same hand-eye and temporal calibration, different sensor pair

Related lessons on Engineermaxxing

Cheat-sheet — the whole lesson on one card

ConceptThe one thing to remember
Two unknownsFusion needs the spatial transform (R, t) and the temporal offset td. Get either wrong → the filter fuses misaligned data and diverges.
Cost of error2 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 extrinsicIntrinsic = one sensor's private params (K, distortion; IMU scale/bias). Extrinsic = sensor-to-sensor pose (R, t) — the fusion-critical one.
Hand-eyeA·X = X·B from rigidity. Solve rotation first (decoupled, via log/axis-angle), then translation (linear). Needs non-parallel rotation axes.
Time offsetSlide streams; td = argmin of the residual V. Slope ∝ f·ω — sharper (more measurable) under faster motion.
Continuous-timeRepresent the trajectory as a B-spline (Furgale): evaluable at any timestamp, so td is just an optimization parameter. The Kalibr backbone.
Online self-calFold R, t, td into the filter/graph state and refine continuously (VINS-Mono). Tracks drift — needs a seed and excitation.
ObservabilityOnly excited parameters are observable. Pure translation kills rotation extrinsic; rest kills td. Full excitation = rotate + translate, all axes, varying speed.
DebuggingVanishes at rest, grows with speed → td. Constant/low-speed offset → extrinsic. Over time/temperature → drift. Huge param covariance → unobservable. Random & motion-independent → real noise.
The motto. "What I cannot create, I cannot understand." You cannot create a working fusion system until you can create its calibration — find the (R, t, td) that makes two flawed sensors speak in one frame at one time. The filter is the easy part. The calibration is the part that decides whether the filter is fusing truth or fusing a lie.

References

  1. Furgale, P., Rehder, J., and Siegwart, R. "Unified Temporal and Spatial Calibration for Multi-Sensor Systems." IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS), pp. 1280–1286, 2013. The paper behind Kalibr: jointly estimates extrinsics and the time offset using a continuous-time formulation. doi:10.1109/IROS.2013.6696514
  2. Furgale, P., Barfoot, T. D., and Sibley, G. "Continuous-Time Batch Estimation Using Temporal Basis Functions." IEEE International Conference on Robotics and Automation (ICRA), pp. 2088–2095, 2012. Introduces the B-spline trajectory representation that lets you evaluate sensor poses at arbitrary, unsynchronized times. doi:10.1109/ICRA.2012.6225005
  3. Qin, T. and Shen, S. "Online Temporal Calibration for Monocular Visual-Inertial Systems." IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS), pp. 3662–3669, 2018. Makes the time offset td a state variable estimated online, as in VINS-Mono. doi:10.1109/IROS.2018.8593603
  4. Tsai, R. Y. and Lenz, R. K. "A New Technique for Fully Autonomous and Efficient 3D Robotics Hand/Eye Calibration." IEEE Transactions on Robotics and Automation, vol. 5, no. 3, pp. 345–358, 1989. The foundational hand-eye (AX = XB) solution, decoupling rotation from translation. doi:10.1109/70.34770
A teammate says "our VIO filter is perfect, we just need to deploy it on the new rig." What is the precise correction this lesson demands?