State Estimation & Odometry
Everything above this layer — maps, planners, controllers — consumes one product: a fast, smooth, honest estimate of where the robot is and how it is moving. This phase builds that estimate four ways and fuses them with the Phase 1 toolbox.
2.1The Odometry Contract
Odometry is dead reckoning: integrate what the sensors say about motion, step by step, starting from wherever you started. No map, no absolute reference — just accumulation. That definition contains its own doom: every step adds a little error, errors compound through the pose chain, and the estimate drifts without bound. Nothing in this phase eliminates drift. The entire craft is making drift slow, smooth, and honestly quantified — and the entire point of Phase 3 (SLAM) and Phase 4 (global localization) is to cancel what remains against a map.
The contract every consumer relies on: odometry must be high-rate (a controller at 50 Hz cannot wait for a 10 Hz camera), smooth (no jumps — jumps belong to loop closures, Phase 3), locally accurate, and shipped with a covariance that admits the drift. Four sensor families supply the raw material, with beautifully complementary failure modes:
| Sensor | Measures | Rate | Drifts because | Lies when |
|---|---|---|---|---|
| Wheel encoders | wheel rotation → body twist | 100 Hz+ | integration of slip & kinematic error | wheels slip, skid, or leave the ground |
| IMU (gyro + accel) | angular velocity, specific force | 200–1000 Hz | double integration of bias + noise | always, within seconds, if used alone |
| Camera | bearing to visual texture | 15–60 Hz | per-frame matching error accumulates | texture-poor, dark, fast motion, mono scale |
| LiDAR | range to structure | 10–100 Hz | per-scan registration error accumulates | geometry-poor: long corridors, open fields |
- Odometry / dead reckoning
- Estimating pose by integrating motion measurements from a known start. Relative, drifting, high-rate.
- Localization
- Estimating pose against a prior map. Absolute, non-drifting, needs the map (Phase 4).
- Twist
- The velocity object of Phase 1: linear plus angular velocity, an element of se(2) or se(3). Odometry integrates twists into poses with ⊞.
- Proprioceptive vs exteroceptive
- Sensors that measure the robot itself (encoders, IMU) versus the world (camera, lidar). Only exteroceptive sensing can ever cancel drift.
2.2Wheel Odometry: Rolling, Integrated
For a differential-drive robot — two independently driven wheels, the dominant indoor platform — the encoder-to-motion story is two lines of algebra. Wheel angular rates times radius give rim speeds; their mean moves the robot, their difference turns it:
v = (v_R + v_L) / 2 forward speed
ω = (v_R − v_L) / b yaw rate, b = wheel separation
per step, the body twist is u = (v·Δt, 0, ω·Δt) ∈ se(2), and the pose update
is Phase 1's boxplus — exactly the arc the robot physically drove:
X ← X ⊞ u = X · exp(u^)
(driving with constant v and ω traces a circular arc of radius v/ω;
the exp of an se(2) twist IS that arc — the V matrix from Phase 1.3)
Wheel odometry errors come in two species that are fixed in two different places. Systematic errors — wrong wheel radius, wrong wheel separation, unequal tire pressure — bias every step the same way and are calibratable: drive a known square or circle, measure the closure error, correct the parameters (the classic UMBmark procedure; Phase 6 revisits this). Stochastic errors — slip, skid, carpet fibers, debris — are unpredictable per step and can only be modeled, as the process noise Q that widens the covariance each update. Getting Q roughly right matters more than getting it precisely right: too small and the fused estimate ignores better sensors; too large and it jitters after every correction.
2.3The IMU: Gravity, Biases, and t² Drift
An IMU is two instruments in one package. The gyroscope measures angular velocity — genuinely, directly, at hundreds of hertz. The accelerometer does not measure acceleration. It measures specific force: the sum of true acceleration and the force resisting gravity. Sitting still, it reads +9.81 m/s² upward; in free fall it reads zero. Every IMU pipeline begins by writing that fact down correctly, and both sensors add a slowly wandering bias plus white noise:
gyro: ω̃ = ω + b_g + n_g b_g, b_a: biases, random walk
accel: ã = Rᵀ(a_w − g) + b_a + n_a ḃ = n_b (they wander — track them)
naive integration ("strapdown"):
R ← R · exp((ω̃ − b_g)·Δt) attitude: Phase 1 exp again
v ← v + (R·(ã − b_a) + g)·Δt velocity: rotate, restore gravity
p ← p + v·Δt + ½·(R·(ã − b_a) + g)·Δt² position: integrate once more
error growth: gyro bias → heading error ~ t → position error ~ t², and an
attitude error tilts g (≈9.81 m/s²) into the velocity integral. Minutes? No.
Seconds.
So why tolerate a sensor that diverges in seconds? Because between any two camera frames or lidar scans, the IMU is the only sensor fast enough to know what happened — it bridges the gaps, predicts where features will reappear, de-blurs fast motion, and makes gravity (hence roll and pitch) observable. Fusion is not optional garnish; it is the design. The two integration styles are the fork in the road for every system in 2.7 and 2.8: filter-style (propagate an EKF between corrections — next section) and preintegration (summarize the IMU stream between keyframes as a single relative-motion factor, computed once and cheaply re-linearized as estimates improve — the Forster et al. trick that made optimization-based VIO real-time, and Phase 3’s factor graphs eat these factors directly).
- Specific force
- What accelerometers measure: true acceleration minus gravity, expressed in the sensor frame. Static reading: +1 g up.
- Bias / random walk
- A slowly wandering offset added to each measurement. Estimated on-line as part of the state; its noise density comes from an Allan-variance calibration (Phase 6).
- Strapdown integration
- Direct numerical integration of IMU readings into pose — the equations above. Exact in form, divergent in practice.
- Preintegration
- Compressing hundreds of IMU samples between keyframes into one relative constraint (ΔR, Δv, Δp) with covariance and bias-Jacobians, on the manifold.
2.4Fusion: The EKF and the Error-State Trick
Phase 1 left the Bayes filter abstract. Make it concrete: restrict the belief to a Gaussian, linearize the models at the current estimate with Jacobians, and you get the extended Kalman filter — the workhorse that fuses the last three sections into one estimate:
predict (each odometry/IMU step):
x̂⁻ = f(x̂, u) propagate the estimate
P⁻ = F P Fᵀ + G Q Gᵀ F = ∂f/∂δx, G = ∂f/∂u
correct (when a slower/absolute measurement arrives):
K = P⁻ Hᵀ (H P⁻ Hᵀ + R)⁻¹ H = ∂h/∂δx
x̂ = x̂⁻ ⊞ K·(z − h(x̂⁻)) innovation, weighted by trust
P = (I − K H) P⁻
The subtlety is the δx. Robot states contain rotations, and Phase 1 spent three sections proving you cannot put a Gaussian on a rotation matrix. The error-state EKF (ESKF) is the manifold contract executed inside a filter: keep the nominal state on the manifold (quaternion, SE(3)), define the filter’s Gaussian over the small error δx in the tangent space, propagate the nominal with full nonlinear integration, and fold the estimated error back in with ⊞ after each correction. The error stays near zero — the one place linearization is guaranteed honest — which is why every IMU filter that works, from consumer drones to FAST-LIO2, is an ESKF. Solà’s tutorial (reading list) derives every Jacobian; manif hands them to you as code.
The UKF, in One Honest Paragraph
The unscented Kalman filter replaces Jacobians with deterministic sampling: pick 2n+1 sigma points spread along the covariance axes, push each through the true nonlinear model, refit a Gaussian to the results. Second-order accuracy instead of first, no derivatives to derive, similar cost. Choose it when models are violently nonlinear or Jacobians are error-prone; skip it when an ESKF is already consistent — on mildly nonlinear problems the two agree to within noise, and the ESKF’s structure is easier to introspect. Both remain unimodal Gaussians; when the belief genuinely splits (kidnapped robot, symmetric corridors), Phase 4’s particle filter takes over.
Setup A: an SE(2) EKF with manif — one evening. Goal: simulate a differential-drive robot driving a circle, dead-reckon it with noisy, gyro-biased odometry, then fuse 1 Hz position fixes with an on-manifold EKF and watch metres of drift collapse to centimetres. The Jacobians come from manif’s operators — you derive nothing by hand.
Prereqs: Eigen (Phase 1 Setup A), plus manif:
git clone https://github.com/artivis/manif.git
cmake -S manif -B manif/build && sudo cmake --install manif/build
Save as se2_ekf.cc, compile with g++ -O2 -std=c++17 -I/usr/include/eigen3 se2_ekf.cc -o se2_ekf:
// se2_ekf.cc — dead reckoning vs on-manifold EKF with sparse position fixes.
#include <manif/manif.h>
#include <Eigen/Dense>
#include <iostream>
#include <random>
int main() {
std::mt19937 rng(7);
auto gauss = [&](double s) {
return std::normal_distribution<double>(0.0, s)(rng); };
const double dt = 0.02; // 50 Hz odometry
const int N = 3000; // 60 seconds
const double gyro_bias = 0.005; // rad/s — unmodeled, systematic
manif::SE2d X_true = manif::SE2d::Identity(); // truth
manif::SE2d X_dr = X_true; // dead reckoning only
manif::SE2d X = X_true; // EKF estimate
Eigen::Matrix3d P = Eigen::Matrix3d::Zero();
const Eigen::Matrix3d Q = // per-step twist noise
Eigen::Vector3d(4e-6, 4e-6, 1e-7).asDiagonal();
const Eigen::Matrix2d R_fix = Eigen::Matrix2d::Identity() * 0.05 * 0.05;
manif::SE2d::Jacobian F_x, F_u; // 3×3, filled by manif
for (int k = 1; k <= N; ++k) {
// truth drives a circle: v = 1 m/s, ω = 0.3 rad/s
manif::SE2Tangentd u_true; u_true << 1.0 * dt, 0.0, 0.3 * dt;
X_true = X_true + u_true;
// measured twist = truth + noise + gyro bias
manif::SE2Tangentd u_meas;
u_meas << u_true.x() + gauss(2e-3), gauss(2e-3),
u_true.angle() + gyro_bias * dt + gauss(3e-4);
X_dr = X_dr + u_meas; // dead reckoning
X = X.plus(u_meas, F_x, F_u); // EKF predict, Jacobians free
P = F_x * P * F_x.transpose() + F_u * Q * F_u.transpose();
if (k % 50 == 0) { // 1 Hz position fix (GNSS/UWB)
Eigen::Vector2d z = X_true.translation()
+ Eigen::Vector2d(gauss(0.05), gauss(0.05));
Eigen::Matrix<double, 2, 3> H; // t(X⊞δ) ≈ t + R·δρ ⇒ H=[R 0]
H << X.rotation(), Eigen::Vector2d::Zero();
const Eigen::Vector2d r = z - X.translation();
const Eigen::Matrix2d S = H * P * H.transpose() + R_fix;
const Eigen::Matrix<double, 3, 2> K = P * H.transpose() * S.inverse();
X = X + manif::SE2Tangentd(K * r); // correct with ⊞
P = (Eigen::Matrix3d::Identity() - K * H) * P;
}
}
const auto err = [&](const manif::SE2d& A) {
return (X_true.inverse() * A).log().coeffs().head<2>().norm(); };
std::cout << "dead-reckoning error: " << err(X_dr) << " m\n"
<< "EKF error : " << err(X) << " m\n";
}
Expected: dead reckoning ends metres off — the unmodeled gyro bias bends the whole circle — while the EKF stays within roughly the fix noise, a few centimetres. Note what the filter never sees: heading. Position fixes plus the F-matrix correlation between heading and future position let the EKF infer and cancel the heading error indirectly. That correlation bookkeeping is the entire value of carrying P.
Sanity checks: (1) set gyro_bias = 0 and shrink the fix rate to k % 500 — the EKF should still beat dead reckoning, now by less; drift is slower without the systematic term. (2) Multiply Q by 1000: the estimate starts chasing every fix, jittering — over-humble models jitter, over-confident ones ignore. (3) Add the bias to the state (state dim 4: SE(2) ⊕ b_g) and watch the filter estimate it to ~0.005 — the standard upgrade, and the reason real IMU filters carry 15+ states.
2.5Visual Odometry: Features, Directness, and Scale
A camera measures bearings to texture — no range, no motion, just a dense grid of brightness. Visual odometry (VO) recovers camera motion by watching that texture flow between frames, and every VO system is one of two philosophies about what to compare.
Feature-based (indirect) methods distill each image into a few hundred distinctive points — corners with descriptors (ORB, or Phase 8’s learned SuperPoint) — match them across frames, and do geometry on the matches: the essential matrix from 2D–2D matches for initialization, then PnP (perspective-n-point) against already-triangulated 3D points for tracking. The residual is reprojection error, and minimizing it is — say it with me — the Phase 1 loop:
reprojection residual (feature-based):
rᵢ = uᵢ − π(T_cw · pᵢ) π = camera projection (Phase 6 calibrates it)
epipolar constraint: u₂ᵀ E u₁ = 0, E = t^ R (the 5-point algorithm's target)
photometric residual (direct):
rᵢ = I₂( π(T · π⁻¹(uᵢ, dᵢ)) ) − I₁(uᵢ) compare brightness, not landmarks
monocular scale: if (R, t) explains the images, so does (R, s·t) for ANY s —
scale is unobservable. Fixes: stereo baseline, RGB-D, or an IMU (section 2.8).
Direct methods skip features entirely: warp one image toward another through the current motion-and-depth estimate and minimize the brightness difference itself. They exploit the whole image (edges, gradients — not just corners), shine where texture is weak but gradients exist, and run fast; the price is a brutal sensitivity to exposure changes, rolling shutter, and un-modeled photometrics. Semi-direct hybrids (SVO) track sparse points directly and optimize structure like a feature method — the pragmatic middle.
| Feature-based (ORB-SLAM lineage) | Direct (DSO lineage) | |
|---|---|---|
| Compares | descriptors of a few hundred corners | raw pixel intensities along gradients |
| Robust to | exposure/lighting change, larger view change | texture-poor scenes (needs only gradients) |
| Fragile to | texture-poor scenes, repetitive patterns | exposure change, rolling shutter, bad photometric calibration |
| Residual | geometric (pixels of reprojection) | photometric (units of brightness) |
| Representative | ORB-SLAM3, VINS front-ends | DSO, LSD-SLAM; SVO 2.0 = semi-direct hybrid |
2.6Point Cloud Registration: ICP and NDT
A lidar hands you geometry directly: thousands of 3D points per scan. Two consecutive scans overlap heavily, so the transform aligning scan to scan (or scan to a local map) is the robot’s motion. The classical tool is ICP — iterative closest point — and it earns the "iterative": correspondences are unknown, so guess them from proximity, solve, and repeat with better proximity:
repeat:
1. associate: for each source point pᵢ (moved by current T),
find the nearest target point qᵢ ← the guessed matches
2. solve: T = argmin Σᵢ ‖ T·pᵢ − qᵢ ‖² ← closed form! (below)
3. loop until the alignment stops improving
closed form for fixed pairs (Kabsch/Umeyama): with centroids p̄, q̄ and
cross-covariance W = Σᵢ (qᵢ − q̄)(pᵢ − p̄)ᵀ = U S Vᵀ (SVD):
R = U · diag(1, 1, det(UVᵀ)) · Vᵀ t = q̄ − R·p̄
point-to-plane variant (what production systems use):
rᵢ = nᵢᵀ (T·pᵢ − qᵢ) distance along the target's normal — lets
flat surfaces slide into place, converges far faster
NDT (normal distributions transform) trades points for statistics: voxelize the target cloud, fit a little Gaussian (μ, Σ) to the points in each voxel, then optimize the pose so source points fall on high-probability ground — a smooth, differentiable score with a wider convergence basin and no per-point correspondence search. GICP generalizes both, modeling each point as a local Gaussian (plane-to-plane). The engineering tiers: brute-force ICP for learning, kd-tree accelerated point-to-plane for products, NDT when maps are big and initial guesses rough, GICP when accuracy is worth the cost.
| Variant | Residual | Needs | Character |
|---|---|---|---|
| Point-to-point ICP | ‖T·p − q‖ | nothing extra | simple, slow convergence, narrow basin |
| Point-to-plane ICP | nᵀ(T·p − q) | target normals | the default; flat regions slide, converges in few iterations |
| GICP (plane-to-plane) | Mahalanobis, per-point Σ | local covariances both sides | most accurate classical; heavier per iteration |
| NDT | −Σ exp(Gaussian score) | voxelized target Gaussians | wide basin, smooth cost, resolution-sensitive |
Setup B: ICP from scratch — one evening. Goal: sixty lines of Eigen that recover a known SE(3) transform between two clouds, watch RMS fall per iteration, then find the convergence basin’s edge by cranking the initial rotation until ICP confidently returns the wrong answer.
Prereqs: Phase 1 Setup A (Eigen + Sophus). Save as icp.cc, compile with g++ -O2 -std=c++17 -I/usr/include/eigen3 icp.cc -o icp:
// icp.cc — point-to-point ICP: associate by proximity, solve in closed form.
#include <Eigen/Dense>
#include <sophus/se3.hpp>
#include <iostream>
#include <random>
#include <vector>
using V3 = Eigen::Vector3d;
// Kabsch/Umeyama: best rigid transform for KNOWN correspondences P[i] ↔ Q[i]
Sophus::SE3d solve_pairs(const std::vector<V3>& P, const std::vector<V3>& Q) {
V3 cp = V3::Zero(), cq = V3::Zero();
for (size_t i = 0; i < P.size(); ++i) { cp += P[i]; cq += Q[i]; }
cp /= P.size(); cq /= Q.size();
Eigen::Matrix3d W = Eigen::Matrix3d::Zero();
for (size_t i = 0; i < P.size(); ++i)
W += (Q[i] - cq) * (P[i] - cp).transpose();
Eigen::JacobiSVD<Eigen::Matrix3d> svd(W, Eigen::ComputeFullU | Eigen::ComputeFullV);
Eigen::Matrix3d R = svd.matrixU() * svd.matrixV().transpose();
if (R.determinant() < 0) { // guard against reflections
Eigen::Matrix3d D = Eigen::Matrix3d::Identity(); D(2, 2) = -1.0;
R = svd.matrixU() * D * svd.matrixV().transpose();
}
return Sophus::SE3d(Sophus::SO3d(R), cq - R * cp);
}
int main() {
std::mt19937 rng(1);
std::uniform_real_distribution<double> box(-2.0, 2.0);
std::normal_distribution<double> noise(0.0, 0.01);
const double yaw_deg = 25.0; // experiment: try 60, 90, 120
const Sophus::SE3d T_true(Sophus::SO3d::rotZ(yaw_deg * M_PI / 180.0),
V3(0.6, -0.4, 0.3));
std::vector<V3> model, scan;
for (int i = 0; i < 500; ++i) {
V3 p(box(rng), box(rng), box(rng));
model.push_back(p);
scan.push_back(T_true * p + V3(noise(rng), noise(rng), noise(rng)));
}
Sophus::SE3d T; // initial guess: identity
for (int it = 0; it < 30; ++it) {
std::vector<V3> src, dst; // associate: brute-force NN
double rms = 0.0;
for (const V3& p : model) {
const V3 y = T * p;
double best = 1e18; V3 bq;
for (const V3& q : scan) {
const double d = (y - q).squaredNorm();
if (d < best) { best = d; bq = q; }
}
src.push_back(p); dst.push_back(bq); rms += best;
}
rms = std::sqrt(rms / model.size());
T = solve_pairs(src, dst); // solve on guessed matches
std::cout << "iter " << it << " rms " << rms << "\n";
if (rms < 0.02) break;
}
const auto err = T_true.inverse() * T;
std::cout << "rotation error : "
<< err.so3().log().norm() * 180.0 / M_PI << " deg\n"
<< "translation error: " << err.translation().norm() << " m\n";
}
Expected at 25°: RMS collapses over a handful of iterations to the noise floor (~0.017, i.e. √3·0.01), final errors a fraction of a degree and millimetres. Now the real lesson: rerun with yaw_deg = 60, 90, 120. Somewhere along that dial ICP stops converging to the truth and settles into a confident wrong alignment — the nearest-neighbor guesses become self-consistently wrong, and step 2 happily optimizes fiction. Nothing in the RMS trace screams "wrong basin"; it just plateaus higher. That silence is why registration always wants an initial guess (IMU, constant-velocity, or Phase 4’s place recognition after a kidnap).
Sanity checks: (1) raise noise to 0.05 — convergence holds, the floor rises; ICP degrades gracefully against noise, brutally against bad initialization. (2) Delete the reflection guard and rerun many seeds; the rare det = −1 case produces a mirrored "alignment" — a real bug class in naive implementations. (3) The O(N²) association is the toy part: production code uses a kd-tree (nanoflann; FAST-LIO2’s incremental ikd-tree) and voxel downsampling — swap them in and this same file scales to real scans.
2.7LiDAR-Inertial Odometry: The 2026 Default
Put sections 2.3, 2.4, and 2.6 in one tight loop and you get the architecture that now dominates mobile-robot odometry. The IMU propagates an error-state filter at hundreds of hertz; each arriving lidar scan is first deskewed (a spinning lidar collects points over ~100 ms of motion — each point gets un-warped using the IMU trajectory), then registered point-to-plane against a local map; the registration residuals update the same filter the IMU propagates, biases and all. That "same filter" is the definition of tightly coupled — raw measurements meet raw measurements inside one estimator, instead of two finished pose streams being averaged after the fact (loosely coupled, simpler and strictly weaker in degenerate scenes).
Three systems define the state of the art, all from the same research lineage and all standalone C++:
| System | Core idea | Distinguishing trick | Reach for it when |
|---|---|---|---|
| FAST-LIO2 | Iterated ESKF, direct raw-point registration (no feature extraction), incremental ikd-tree map | A Kalman-gain reformulation that scales with state dim, not measurement count — thousands of residuals per update, cheap | The default. General-purpose, mature, fast on modest CPUs |
| Point-LIO | Updates the filter per point rather than per scan; models IMU as output, not input | Effectively continuous-time updates → survives violent motion and IMU saturation (spinning, thrown sensors) | Aggressive dynamics: legged robots, drones hitting things, high angular rates |
| DLIO | Continuous-time motion correction with an analytic (closed-form) integrator, geometric observer instead of a KF | Deskew via exact continuous-time integration — accurate warp at low cost; dense keyframe maps | You want dense, pretty maps and a simple, robust estimator |
Why this family displaced the LOAM lineage that preceded it: LOAM extracted sparse edge/plane features from each scan, which threw information away and broke on featureless-but-geometric scenes; FAST-LIO2 registers raw points against the map and lets the filter weigh them. The enabler is the ikd-tree — a kd-tree that supports incremental insertion and deletion, so "the map" is a living structure updated per scan rather than rebuilt. GLIM and the full SLAM systems of Phase 3 pick up exactly here, adding loop closure on top.
- Deskew / motion compensation
- Undoing the motion the robot made during one lidar sweep, using the IMU trajectory, so the scan looks like a snapshot.
- Tightly / loosely coupled
- Raw measurements fused in one estimator vs finished pose estimates averaged afterwards. Tight wins in degenerate scenes because information (not conclusions) gets weighed.
- ikd-tree
- Incremental kd-tree: nearest-neighbor structure supporting insert/delete/re-balance on the fly. FAST-LIO2’s map container.
- Iterated update
- Re-linearizing the measurement model a few times within one correction step — an EKF borrowing one Gauss-Newton trick from Phase 1.
2.8Visual-Inertial Odometry: Four Systems Worth Knowing
Cameras and IMUs are the great complementary pair: the IMU is blind but fast and metric; the camera is slow and scale-blind but anchors drift to the world. Fusing them makes monocular scale observable (accelerometer units are metres per second squared), makes gravity observable (so roll/pitch never drift), and bridges motion blur. The fusion currency on the optimization side is preintegration: compress the hundreds of IMU samples between two keyframes into a single relative factor (ΔR, Δv, Δp) with covariance and bias Jacobians, computed once on the manifold and cheaply corrected when bias estimates move. Four mature open systems cover the design space:
| System | Front-end | Back-end | Sensors | Character |
|---|---|---|---|---|
| ORB-SLAM3 | ORB features, descriptor matching | Factor-graph MAP over keyframes (g2o), multi-map Atlas | mono / stereo / RGB-D, ± IMU | The accuracy benchmark; a full SLAM system whose VIO mode is the front half (Phase 3 covers the rest) |
| VINS-Fusion | KLT optical flow (no descriptors) | Sliding-window optimization (Ceres) + marginalization | mono/stereo + IMU, + GNSS | The pragmatic workhorse: robust initialization, online extrinsic & time-offset estimation |
| OKVIS2 | Multi-scale features, stereo-first | Keyframe sliding window with careful marginalization; realtime loop closure | stereo + IMU | The rigorous engineer’s choice; excellent calibration story, strong on stereo rigs |
| SVO 2.0 | Semi-direct: photometric tracking of sparse patches, depth filters | Lightweight local BA (+ optional loop closure) | mono/stereo/fisheye + IMU | Speed champion — hundreds of Hz on embedded CPUs; the drone-racing lineage |
Reading the table as a decision: ORB-SLAM3 when accuracy on benchmarks matters and compute is available; VINS-Fusion when the rig is imperfect and you want something that initializes itself and forgives miscalibration; OKVIS2 for a serious stereo product; SVO for embedded speed. All four assume Phase 6’s homework — intrinsics, extrinsics, and time offset between camera and IMU — and the difference between "works in the demo" and "works" is usually that homework, not the system choice.
2.9Choosing a Stack, and Watching It Fail
Odometry selection is failure-mode engineering: pick the primary sensor whose degenerate case you will meet least, and a partner that covers it. A decision table compresses this chapter:
| Platform / environment | Reach first for | Because |
|---|---|---|
| Indoor wheeled robot, budget build | Wheel + gyro EKF (Setup A), camera assist | Floors are flat, slips are rare, encoders are free |
| Indoor/outdoor, structure-rich, money for one good sensor | LiDAR-inertial: FAST-LIO2 / DLIO | Geometry is lighting-proof; LIO is the most drift-free odometry per dollar of compute |
| Aggressive motion (legged, aerial contact) | Point-LIO | Per-point updates + IMU-as-output survive saturation |
| Weight/power-starved aerial | VIO: SVO 2.0 or VINS-Fusion | Camera + IMU weigh grams; scale and gravity come from the IMU |
| Benchmark accuracy, later loop closure | ORB-SLAM3 / OKVIS2 | Feature machinery and keyframe BA transfer straight into Phase 3 |
2.10Reading List
Visual Odometry: Part I & Part II (Tutorial)The standard on-ramp for section 2.5: the full feature-based pipeline, drift analysis, and the vocabulary interviews assume. Quaternion Kinematics for the Error-State Kalman FilterSection 2.4 with every Jacobian derived and every convention stated. The reference that stays open while you write an IMU filter. On-Manifold Preintegration for Real-Time Visual-Inertial OdometryThe preintegration paper: how hundreds of IMU samples become one factor, and why that made optimization-based VIO real-time. A Method for Registration of 3-D ShapesThe ICP original. Thirty-plus years old and Setup B is still recognizably this paper. The Normal Distributions Transform: A New Approach to Laser Scan MatchingRegistration without correspondences: voxels as Gaussians, matching as smooth optimization. The other half of section 2.6. FAST-LIO2: Fast Direct LiDAR-Inertial OdometryThe paper behind Fig 2.3: raw-point registration, the ikd-tree, and the Kalman gain trick that makes thousands of residuals cheap. Point-LIO: Robust High-Bandwidth LiDAR-Inertial OdometryPer-point filtering and the stochastic-process IMU model — LIO that survives 75 rad/s and saturated accelerometers. Direct LiDAR-Inertial Odometry (DLIO)Continuous-time deskew with closed-form integration and a geometric observer — the "simple and dense" corner of the LIO design space. ORB-SLAM3: An Accurate Open-Source Library for Visual, Visual-Inertial and Multi-Map SLAMThe reference feature-based system for 2.5 and 2.8; returns as a lead character in Phase 3. VINS-Mono: A Robust and Versatile Monocular Visual-Inertial State EstimatorSliding-window VIO with marginalization, online calibration, and the initialization procedure everyone borrowed. VINS-Fusion is its multi-sensor successor. OKVIS2: Realtime Scalable Visual-Inertial SLAM with Loop ClosureKeyframe sliding-window VIO done with unusual rigor; the marginalization discussion doubles as a Phase 3 preview.2.11Interview Questions
What does an accelerometer actually measure, and why does the answer matter?
Specific force: true acceleration minus gravity, in the sensor frame — not acceleration. At rest it reads +9.81 m/s² upward (the table pushing back); in free fall it reads zero. It matters because every integration pipeline must add gravity back using the current attitude estimate, so any attitude error tilts a 9.81 m/s² vector into the velocity integral — which is why attitude accuracy dominates inertial navigation error, and why roll/pitch are observable in the first place (gravity is a free reference direction).
Why is scale unobservable in monocular VO, and what are the standard fixes?
A camera measures bearings only. Scale every distance and every translation by the same factor s and all image projections are identical — (R, t) and (R, s·t) explain the data equally well, so no image evidence can pick s. Fixes add a metric ruler: a stereo baseline (known distance between cameras), an RGB-D sensor (direct depth), an IMU (accelerometer units are m/s², making scale observable given sufficient acceleration excitation), or known object sizes. Note the IMU fix fails under constant velocity — zero acceleration excites nothing — which is the classic mono-VIO degeneracy.
What is an error-state Kalman filter and why is it the standard for IMU fusion?
The ESKF splits the state into a nominal part, kept on the manifold and propagated with full nonlinear integration, and an error part δx in the tangent space, which is what the Gaussian and covariance actually describe. Corrections estimate δx and fold it into the nominal with ⊞, resetting the error to zero. It is standard because the error is always small — so linearization is maximally honest, rotations never leave their manifold, there is no gimbal lock or quaternion-normalization hack, and the covariance is a clean 3×3 or 15×15 over tangent coordinates.
Point-to-point vs point-to-plane ICP: why does point-to-plane converge so much faster?
Point-to-point penalizes the full 3D distance between matched points, but on a flat surface the correspondence is essentially arbitrary along the plane — the true constraint is only perpendicular to it. Point-to-plane encodes exactly that: the residual nᵀ(T·p − q) measures only the distance along the target normal, so tangential sliding is free and the optimizer can slide surfaces into place instead of fighting fictitious in-plane constraints. Fewer iterations, better resistance to imperfect correspondences; the price is needing target normals, computed once from local neighborhoods.
What problem does IMU preintegration solve in optimization-based VIO?
A factor graph over keyframes must relate consecutive keyframe states through the hundreds of IMU samples between them. Naively, changing the linearization point (which iterative optimization does constantly) would force re-integrating all raw samples every iteration. Preintegration integrates the samples once, in a frame relative to the first keyframe, producing ΔR, Δv, Δp with covariance — independent of the global states — plus Jacobians with respect to bias so the factor can be corrected first-order when bias estimates move. The result: one cheap factor per keyframe pair, re-linearizable for free, on the manifold.
Tightly coupled vs loosely coupled fusion — what is the actual difference and when does it matter?
Loosely coupled runs each sensor pipeline to a finished pose estimate and fuses the poses; tightly coupled feeds raw measurements (image features, lidar residuals, IMU samples) into a single estimator. The difference shows in degenerate scenes: a corridor-degenerate lidar pipeline outputs a confident wrong pose that a loose fusion can only average in, while a tight fusion sees the raw residuals constrain five directions and not the sixth, and lets the IMU own that direction automatically through the shared covariance. Loose is simpler and easier to certify; tight is strictly more informative. FAST-LIO2 and VINS are tight; most quick GNSS+odometry integrations are loose.
Your lidar odometry works everywhere except a long straight corridor. What is happening and what do you do?
The corridor constrains motion perpendicular to its walls and floor but says nothing about translation along it — every point-to-plane residual has its normal roughly perpendicular to the corridor axis. The registration Hessian JᵀJ becomes near-singular along that axis: its smallest eigenvalue collapses and the corresponding eigenvector points down the corridor. Detection: monitor that eigenvalue (or the condition number) per scan. Mitigation: inflate covariance along the weak eigenvector and let the fused IMU/wheel odometry carry that direction; refuse registration updates along it (solve in the reduced subspace); and longer-term, add texture the sensor can see or a sensor whose failure mode differs. What you must not do is let the registration "succeed" — it will, at an arbitrary position along the corridor.
Wheel odometry: what are the two classes of error, and how is each handled?
Systematic errors — wrong effective wheel radius, wrong wheel base, unequal wheels — bias every step identically and are handled by calibration: drive closed paths (the UMBmark square, or circles both directions), measure closure error, and solve for corrected parameters; they are parameters, not noise. Stochastic errors — slip, skid, surface irregularity — are unpredictable and handled by modeling: they become the process noise Q that grows the covariance per step, tuned so the reported uncertainty actually covers observed drift. Mixing the two up is the classic tuning failure: no amount of Q inflation fixes a 2% wheel-radius bias, and no calibration removes carpet slip.