Calibration & ROS-Free System Infrastructure
No algorithm from Phases 1–5 survives a rig whose sensors disagree about where they are, what they measure, and when they measured it — or a process architecture that drops frames under load. This phase is the homework.
6.1The Calibration Ladder
Every fusion equation in this book silently assumed three things were known exactly: each sensor’s intrinsics (its internal model — focal length, distortion, bias behavior), the extrinsics between sensors (the fixed T_imu_cam, T_imu_lidar of Phase 1’s frame chains), and a shared clock. Get any rung wrong and the errors don’t add — they masquerade: a 5 ms time offset looks like an IMU bias, a 1° extrinsic error looks like odometry drift, a bad focal length looks like scale drift. Whole weeks have been lost tuning filters to compensate for calibration that was never done. The ladder must be climbed in order — intrinsics first, then extrinsics, then time — because each stage’s estimator assumes the previous stage is solved.
6.2Camera Intrinsics: The Projection Contract
The pinhole model maps a 3D point in the camera frame to a pixel, and calibration is — one more time — the Phase 1 loop, with the model’s parameters as the state and checkerboard (or AprilTag) corners as the measurements:
projection: [u] [f_x 0 c_x] [X/Z]
[v] = [ 0 f_y c_y]·[Y/Z] then distortion warps (u,v):
[1] [ 0 0 1 ]
radial: x_d = x(1 + k₁r² + k₂r⁴ + k₃r⁶) + tangential p₁, p₂ terms
calibrate: argmin_{K, dist, poses} Σᵢⱼ ‖ u_ij − π(K, dist, T_j, P_i) ‖²
→ LM over intrinsics AND board poses jointly (Phase 1.8)
target RMS reprojection: < 0.5 px. Above 1 px, redo the capture, not the math.
Capture craft matters more than solver choice: fill the whole image with board views (distortion lives at the edges), tilt the board (foreshortening constrains focal length), lock exposure and focus, and prefer AprilTag grids over checkerboards — each tag’s identity is unambiguous, so partial views and motion blur stop silently corrupting corners. Rolling-shutter cameras need the rolling-shutter model or slow motion during capture; fisheye lenses need their own projection models (equidistant, double-sphere), not more radial terms bolted onto pinhole.
6.3IMU Intrinsics: Allan Variance
Phase 2’s filters need two numbers per gyro/accel axis that datasheets state optimistically and temperature ruins: the white noise density (how noisy each sample is) and the bias random walk (how fast the bias wanders). The standard instrument is the Allan variance: record the stationary sensor for hours, average the signal over windows of growing length τ, and study how the averages disagree as a function of τ:
σ²(τ) = ½ ⟨ ( ȳ_{k+1}(τ) − ȳ_k(τ) )² ⟩ ȳ_k: mean over the k-th window
log-log plot of σ(τ) vs τ reads like a spectrogram of error sources:
slope −½ → white noise: σ(τ) = N / √τ → read N at τ = 1 s
slope 0 → bias instability (the flat floor)
slope +½ → rate random walk: σ(τ) = K·√(τ/3) → read K on the rise
averaging longer first helps (noise averages out), then hurts (bias wanders)
Setup A: Allan variance from scratch — one evening, zero dependencies. Goal: simulate two hours of gyro data with known noise parameters, compute the Allan deviation, and recover the parameters you injected — the round trip that makes IMU datasheets stop being folklore. Save as allan.cc, compile with g++ -O2 -std=c++17 allan.cc -o allan:
// allan.cc — simulate a stationary gyro, recover N and K from Allan deviation.
#include <cmath>
#include <iostream>
#include <random>
#include <vector>
int main() {
const double fs = 100.0, T = 7200.0; // 100 Hz, two hours
const double N_true = 0.005; // white noise [rad/s/√Hz]
const double K_true = 0.0001; // rate random walk [rad/s·√Hz]
const size_t n = size_t(fs * T);
std::mt19937 rng(11);
std::normal_distribution<double> gauss(0.0, 1.0);
std::vector<double> w(n);
double bias = 0.0;
for (size_t i = 0; i < n; ++i) { // rate = wandering bias + noise
bias += K_true * std::sqrt(1.0 / fs) * gauss(rng);
w[i] = bias + N_true * std::sqrt(fs) * gauss(rng);
}
std::cout << "tau(s) sigma(rad/s)\n";
for (double tau = 0.01; tau <= 1000.0; tau *= 2.0) {
const size_t m = size_t(tau * fs); // samples per window
if (m < 1 || 2 * m >= n) continue;
std::vector<double> avg; // window means
for (size_t k = 0; k + m <= n; k += m) {
double s = 0.0;
for (size_t i = k; i < k + m; ++i) s += w[i];
avg.push_back(s / m);
}
double acc = 0.0; // Allan variance
for (size_t k = 0; k + 1 < avg.size(); ++k)
acc += (avg[k + 1] - avg[k]) * (avg[k + 1] - avg[k]);
const double sigma = std::sqrt(0.5 * acc / (avg.size() - 1));
std::cout << tau << "\t" << sigma << "\n";
if (std::abs(tau - 1.28) < 0.1) // ≈ read-off points
std::cout << " → N ≈ σ(1s)·√1s = " << sigma
<< " (true " << N_true << ")\n";
if (std::abs(tau - 655.36) < 1.0)
std::cout << " → K ≈ σ(τ)·√(3/τ) = " << sigma * std::sqrt(3.0 / tau)
<< " (true " << K_true << ")\n";
}
}
Expected: the printed σ(τ) column falls with slope −½ (halving per 4× of τ), flattens, then rises — Fig 6.2 in numbers. The τ ≈ 1 s read-off recovers N within a few percent; the long-τ read-off recovers K within a factor of ~1.5 (long-τ estimates are statistically starved even with two hours of data — note how few windows exist at τ = 655 s, and why real IMU characterization runs overnight). These two numbers, per axis, are precisely what Phase 2’s filters want as Q entries, and what Kalibr’s IMU yaml demands.
Sanity checks: (1) set K_true = 0 — the curve keeps falling with slope −½ forever; the rising arm is the bias wandering. (2) Halve the record length twice and watch the long-τ tail get noisy first — the practical reason short logs mis-estimate random walk. (3) Add a temperature-style slow sinusoidal drift and watch a hump appear — real Allan plots have personality, and reading them is the skill.
6.4Extrinsics: Kalibr, Hand-Eye, and LiDAR Rigs
Extrinsic calibration answers: what rigid transform relates two sensors’ frames — and it needs shared observations of motion or structure. The camera–IMU case is the canon, and Kalibr is its standard tool: wave the rig in front of an AprilTag grid with rich rotation on all axes, and solve a continuous-time batch problem — the trajectory modeled as B-splines so that camera poses (from the target) and IMU samples (rates and accelerations, i.e. spline derivatives) constrain one smooth curve — estimating T_imu_cam, gravity, biases, and, crucially, the camera–IMU time offset in the same optimization. The observability fine print: without rotational excitation about all axes, the extrinsic translation is barely observable — a gentle hand-held figure-eight beats any tripod.
hand-eye (the classical form of every extrinsic problem):
A_i · X = X · B_i A_i: sensor-1 motion, B_i: sensor-2 motion,
X = the unknown extrinsic between them
→ rotations first (quaternion linear system), then translation (linear LS)
→ needs ≥ 2 motions with non-parallel rotation axes — excitation, again
LiDAR–IMU rigs use the same principles with geometry instead of tags: LiDAR-Align optimizes the extrinsic (and time offset) by minimizing the crispness of the aggregated point cloud — with the wrong transform, walls smear when the rig moves; with the right one, they focus. Newer target-free tools (the BIFROST line and friends) push this further with continuous-time trajectories, but the acceptance test never changes: aggregate a rotation-rich log through the estimated calibration and look at the walls. Blur is truth.
6.5Time: The Third Dimension of Calibration
Two sensors with perfect intrinsics and extrinsics still disagree if their clocks do. Time errors are vicious because they scale with motion: at a yaw rate of 1 rad/s, a 5 ms offset is a 0.29° attitude error — larger than a good extrinsic calibration’s whole budget — and it vanishes on the bench where the rig sits still. The engineering hierarchy, best to worst: hardware triggering (one signal line exposes the camera and timestamps the lidar sweep — the offset becomes zero by construction); PTP / IEEE 1588 on the network for microsecond-level sync between devices that can’t share a wire; software timestamping at the driver with an estimated constant offset (what Kalibr and VINS-Fusion estimate online); and — the one that fails silently — timestamping on message arrival, where USB buffering injects tens of jittering milliseconds. One more subtlety worth an interview answer: a camera’s honest timestamp is mid-exposure, and a spinning lidar’s points span the whole sweep — which is exactly the deskew problem Phase 2.7 solved, now revealed as a time-calibration statement.
6.6ROS-Free IPC: Iceoryx, ZeroMQ, Fast-DDS
A navigation stack is a set of processes — driver, odometry, SLAM, planner, controller — passing sensor-sized messages at sensor rates. Without ROS, you choose the transport yourself, and the choice is really about copies. A 2 MB point cloud at 20 Hz through a socket API is serialized, copied into the kernel, copied out, deserialized — four traversals of memory per hop, per subscriber. Iceoryx deletes all of them: publishers loan a chunk of shared memory, write the cloud in place, and publish the pointer; subscribers read the same physical bytes. Latency becomes single-digit microseconds and — the deeper win — independent of message size.
| Iceoryx | ZeroMQ | eProsima Fast-DDS | |
|---|---|---|---|
| Model | zero-copy shared-memory pub/sub | message sockets (pub/sub, req/rep, push/pull) | full DDS: discovery, QoS, typed topics |
| Copies (same host) | 0 — pointer handoff | 2+ per hop | 1–2 (shared-mem transport) or socket path |
| Cross-machine | no (pair with a gateway) | yes, natively | yes, UDP with discovery |
| QoS / reliability | minimal by design | DIY on top of patterns | rich: deadlines, liveliness, history, durability |
| Reach for it when | point clouds/images between processes on one box | simple glue, tools, cross-language plumbing | multi-machine systems needing contracts and QoS |
The pragmatic architecture stacks them: iceoryx for the fat intra-host pipes (lidar → odometry → mapping), Fast-DDS or ZeroMQ for the thin inter-host ones (telemetry, commands, fleet traffic) — which is, not coincidentally, roughly what ROS 2 itself assembles from these very libraries. Using them directly is not heresy; it is the same parts without the framework contract.
6.7Threads, Queues, and the Real-Time Contract
Inside each process, the same discipline in miniature. The sensor pipeline pattern is one thread per blocking source, handing off through bounded single-producer single-consumer queues, with an explicit drop policy — a localization consumer wants the latest scan, not a growing backlog of stale ones (queue depth 1, overwrite), while a logging consumer wants every scan (deep queue, backpressure). Declaring which consumer is which, per edge, is the design act; middleware QoS is just this decision given names. The hot path — the loop between sensor arrival and actuator command — additionally obeys the real-time contract: no heap allocation, no unbounded locks, no printf; preallocate everything, pin the control thread to a core with elevated priority, and measure the tail latency, not the mean, because the 99.9th percentile is the one that misses the control deadline into a wall.
Setup B: measure your IPC — one evening. Goal: put numbers on Fig 6.3 with ZeroMQ — round-trip latency for small messages and throughput for sensor-sized ones — and see exactly the size-dependence that zero-copy designs delete. Prereq: sudo apt install libzmq3-dev. Save as ipcbench.cc, compile with g++ -O2 -std=c++17 ipcbench.cc -o ipcbench -lzmq:
// ipcbench.cc — ZeroMQ over ipc://: REQ/REP latency + PUSH/PULL throughput.
#include <zmq.h>
#include <chrono>
#include <cstring>
#include <iostream>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
using clk = std::chrono::steady_clock;
double ms_since(clk::time_point t0) {
return std::chrono::duration<double, std::milli>(clk::now() - t0).count();
}
int main() {
const int PINGS = 2000;
const size_t MSG = 2 * 1024 * 1024; // a 2 MB "point cloud"
const int CLOUDS = 500;
if (fork() == 0) { // ---- child: echo + sink ----
void* ctx = zmq_ctx_new();
void* rep = zmq_socket(ctx, ZMQ_REP);
zmq_bind(rep, "ipc:///tmp/lat.ipc");
char buf[64];
for (int i = 0; i < PINGS; ++i) { // echo pings back
zmq_recv(rep, buf, sizeof buf, 0);
zmq_send(rep, buf, 8, 0);
}
void* pull = zmq_socket(ctx, ZMQ_PULL);
zmq_bind(pull, "ipc:///tmp/thr.ipc");
std::vector<char> big(MSG);
for (int i = 0; i < CLOUDS; ++i) // swallow the clouds
zmq_recv(pull, big.data(), MSG, 0);
zmq_close(rep); zmq_close(pull); zmq_ctx_term(ctx);
_exit(0);
}
void* ctx = zmq_ctx_new(); // ---- parent: measure ----
void* req = zmq_socket(ctx, ZMQ_REQ);
usleep(200000); // let the child bind
zmq_connect(req, "ipc:///tmp/lat.ipc");
char buf[64] = "ping";
auto t0 = clk::now();
for (int i = 0; i < PINGS; ++i) {
zmq_send(req, buf, 8, 0);
zmq_recv(req, buf, sizeof buf, 0);
}
std::cout << "REQ/REP round trip: "
<< ms_since(t0) * 1000.0 / PINGS << " µs avg\n";
void* push = zmq_socket(ctx, ZMQ_PUSH);
zmq_connect(push, "ipc:///tmp/thr.ipc");
std::vector<char> cloud(MSG, 42);
t0 = clk::now();
for (int i = 0; i < CLOUDS; ++i)
zmq_send(push, cloud.data(), MSG, 0);
const double s = ms_since(t0) / 1000.0;
std::cout << "PUSH/PULL 2 MB × " << CLOUDS << ": "
<< (MSG / 1048576.0) * CLOUDS / s << " MB/s ("
<< CLOUDS / s << " clouds/s)\n";
zmq_close(req); zmq_close(push); zmq_ctx_term(ctx);
wait(nullptr);
}
Expected on a modern Linux box: round trips in the tens of microseconds (good!), and throughput of a few GB/s — sounding generous until you divide: a 2 MB cloud costs on the order of a millisecond per hop per subscriber, so three subscribers and two pipeline stages of a 20 Hz lidar are already burning double-digit percent of a core just moving bytes that never change. That line of arithmetic is the entire case for iceoryx’s pointer handoff, whose cost for the same cloud is the same handful of microseconds as for an 8-byte ping.
Sanity checks: (1) rerun the throughput leg with MSG = 64 — clouds/s explodes, MB/s collapses: you were never bandwidth-limited, you were per-message-overhead limited; both regimes matter. (2) Switch the transport strings to tcp://127.0.0.1:5555-style and watch latency multiply — loopback TCP is not ipc. (3) Add a second PULL subscriber and watch producer-side cost double — the per-subscriber scaling in Fig 6.3’s caption, measured.
6.8Reading List
Unified Temporal and Spatial Calibration for Multi-Sensor SystemsThe Kalibr paper: continuous-time B-spline batch estimation that recovers extrinsics and time offsets in one problem. Extending Kalibr: Calibrating the Extrinsics of Multiple IMUs and of Individual AxesIMU intrinsics (scale, misalignment, g-sensitivity) join the same framework — the paper behind Kalibr’s IMU model zoo. Analysis and Modeling of Inertial Sensors Using Allan VarianceSection 6.3 with the full noise taxonomy and the slope-reading recipes Setup A implements. A Flexible New Technique for Camera CalibrationThe planar-target calibration everyone uses: why a handful of tilted board views pin down K and distortion. A New Technique for Fully Autonomous and Efficient 3D Robotics Hand/Eye CalibrationAX = XB, solved — the closed form under every extrinsic calibration that pairs two motion streams. AprilTag: A Robust and Flexible Visual Fiducial SystemWhy tag grids displaced checkerboards: unambiguous identity per tag, robust decoding, partial-view tolerance. lidar_align: LiDAR–Odometry Extrinsic CalibrationThe crispness principle in tool form: the right extrinsic is the one that makes aggregated walls sharp. Eclipse iceoryx: True Zero-Copy Inter-Process CommunicationLoaned chunks, pointer publishing, and why latency stops depending on payload size. The ZeroMQ Guide (zguide)The rare middleware manual that is also a systems-design education. Patterns, failure modes, and honest trade-offs. eProsima Fast DDS DocumentationThe full DDS machine — discovery, QoS vocabulary, shared-memory transport — usable entirely without ROS.6.9Interview Questions
A 5 ms camera–IMU time offset: walk through what it does to a VIO system.
Every camera measurement is matched against the wrong slice of IMU history. The error scales with motion: at 1 rad/s yaw, 5 ms is ~0.29° of attitude disagreement injected per frame — bigger than the whole extrinsic error budget — while at rest it is invisible, which is why bench tests pass and field tests wobble. Symptoms: soft, lagging fusion; innovations correlated with angular velocity (the fingerprint); scale and bias estimates absorbing the residual and drifting. Fixes in order of rigor: hardware trigger (offset zero by construction), estimate the offset in calibration (Kalibr) or online (VINS-Fusion), and always timestamp at exposure — mid-exposure — in the driver, never at message arrival.
How do you read an IMU’s two key noise parameters off an Allan deviation plot?
Record the stationary sensor for hours, plot Allan deviation σ(τ) log-log. The descending slope −½ arm is white noise: read N directly at τ = 1 s (σ = N/√τ). The flat floor is bias instability — the best the bias will ever hold. The rising slope +½ arm is rate random walk: read K via σ(τ)·√(3/τ) on that arm. N and K per axis become the process-noise entries of any Phase 2 filter. Practical gotchas: the long-τ arm is statistically starved (few windows), so short logs mis-estimate K badly, and temperature drift adds humps that a naive slope-fit will misread.
Why does camera–IMU extrinsic calibration require "excitation", and what happens without it?
The extrinsic translation only enters the measurements through rotation: the lever-arm effect makes the camera translate p = ω × r when the rig rotates, so with no angular velocity the translation is multiplied by zero — unobservable. A calibration log without rich rotation about all three axes leaves that direction of the parameter space unconstrained; the solver still returns numbers (optimizers always do), with a beautiful reprojection error and a translation that is fiction along the unexcited axes. The same principle generalizes: hand-eye needs two non-parallel rotation axes, mono-VIO scale needs acceleration excitation. Always check the estimator’s reported covariance, not just its residual.
What is the hand-eye calibration problem, and where does it appear outside arms?
Given paired motion streams from two rigidly connected sensors — Ai from one, Bi from the other — find the fixed transform X with AiX = XBi. Classically it is a camera on a robot arm (arm kinematics vs camera-to-target motion), but it is equally lidar-odometry vs wheel-odometry, camera vs IMU integration, any pair that can each estimate its own motion. Standard solution: decouple — solve the rotation part first (a linear system in quaternion or rotation-vector form over motion pairs), then translation by linear least squares given the rotation. It needs at least two motions with non-parallel rotation axes, and it is the mental model behind every "target-free" extrinsic tool.
How does iceoryx achieve zero-copy, and what does the design give up?
All payloads live in a shared-memory pool managed by a daemon. A publisher loans a chunk, constructs the message in place, and "publishing" pushes only the chunk pointer onto each subscriber’s lock-free queue; subscribers read the same physical bytes and release the chunk, with reference counting reclaiming it. No serialization, no kernel traversal, no per-byte cost — latency is microseconds regardless of payload. The trade-offs: same-host only (network needs a gateway); messages should be fixed-size-friendly and trivially relocatable (no internal pointers — hence flat types); subscribers can hold chunks hostage (pool exhaustion is a real failure mode); and minimal QoS by design. It is a scalpel for fat intra-host pipes, not a general middleware.
Design the message-passing architecture for lidar → odometry → SLAM → planner without ROS.
Fat, latency-critical, same-host edges get iceoryx: driver publishes point clouds into loaned chunks; odometry and SLAM subscribe zero-copy; map and ESDF layers likewise. Thin or cross-host edges get Fast-DDS (typed topics, discovery, QoS: e.g. RELIABLE + short history for commands, BEST_EFFORT + depth-1 for telemetry) or ZeroMQ if the system is small and you prefer explicit plumbing. Per-edge drop policy declared up front: depth-1 overwrite for anything a consumer only wants fresh (scans into the planner), bounded FIFO with backpressure for the logger. Hot path (odometry → controller) additionally in-process: one SPSC queue, preallocated messages, pinned threads. And one rule that outlives any middleware: every message carries the sensor timestamp from the driver, never the arrival time.
Your fusion residuals are fine on the bench but degrade in the field over weeks. What’s your differential diagnosis?
Calibration is a failure mode with a schedule, so hunt the drifting rung. Innovation-vs-motion correlations: with angular rate → time sync (a cable now routed near a switching regulator, PTP degraded); with orientation → extrinsics (mount flexed, robot took a hit — re-run lidar-align style crispness checks); with temperature → IMU intrinsics (biases outside the calibrated envelope, re-run Allan/thermal characterization) or camera focus creep. Also the boring candidates: a firmware update changed driver timestamping; a new sensor batch with different latency. Process fix, not just point fix: scheduled recalibration, continuous innovation monitoring with alerts, and calibration values under version control with dates — "when was this number measured" as an operational metric.