Skip to main content
Contents2 / 8 ▾
02
Phase Two · Where Am I, Two Hundred Times a Second

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.

Close your eyes and walk across a room you know, counting steps. Ten steps in, you are confident. Fifty steps and a few turns in, you are betting. Nothing was wrong with any single step — the error accumulated, and turning made it worse, because a small heading error converts every subsequent metre of walking into sideways position error. That heading-error-times-distance term is the dominant villain of all odometry.

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:

SensorMeasuresRateDrifts becauseLies when
Wheel encoderswheel rotation → body twist100 Hz+integration of slip & kinematic errorwheels slip, skid, or leave the ground
IMU (gyro + accel)angular velocity, specific force200–1000 Hzdouble integration of bias + noisealways, within seconds, if used alone
Camerabearing to visual texture15–60 Hzper-frame matching error accumulatestexture-poor, dark, fast motion, mono scale
LiDARrange to structure10–100 Hzper-scan registration error accumulatesgeometry-poor: long corridors, open fields
DRIFT, THE HONEST ENEMY truth dead-reckoned odometry covariance grows without bound — and must say so the drift
Fig 2.1: odometry tracks the truth locally and leaves it globally. A good odometry system drifts slowly and reports an uncertainty that actually contains the truth; a bad one drifts fast, or worse, drifts fast while claiming confidence.
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.

Wheel odometry’s failure mode is not gradual — it is catastrophic and silent. A wheel spinning on a wet patch reports beautiful, confident, entirely fictional motion; a robot pushed by a human reports none. Encoders cannot tell the difference from inside. This is why no serious stack ever trusts wheels alone: the IMU (which feels the true rotation and the shove) is the standard witness, and disagreement between the two is your slip detector — an idea Phase 7 scales up to detect stuck robots in mud.

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.
The IMU is the robot’s inner ear, and it inherits the inner ear’s famous bug: with eyes closed you cannot distinguish steady acceleration from tilt — both press the same way. An accelerometer reading of 0.5 m/s² sideways is either real acceleration or a 3° tilt misestimating gravity, and the mathematics genuinely cannot separate them without a second opinion. That second opinion — camera, lidar, wheels — is why the rest of this chapter says "inertial" only ever as a suffix.

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.

Nominal state on the manifold, error state in the tangent space, corrections applied with ⊞ — the ESKF is Phase 1’s keypoint wearing a filter. Once you see it, every fusion system in this book (2.7’s LIO, 2.8’s VIO, Phase 7’s GNSS stacks) reads as the same design with different measurement models plugged into H.

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.

REPROJECTION ERROR, THE RESIDUAL OF VISUAL ODOMETRY landmark p camera at pose T image plane π(T·p) u observed observed ray misses p r
Fig 2.2: project the landmark through the current pose estimate and it lands at π(T·p); the detector found it at u. The gap r is the reprojection error. Minimize Σ‖r‖² over T and you have PnP; over poses and landmarks jointly and you have bundle adjustment (Phase 3).
Feature-based (ORB-SLAM lineage)Direct (DSO lineage)
Comparesdescriptors of a few hundred cornersraw pixel intensities along gradients
Robust toexposure/lighting change, larger view changetexture-poor scenes (needs only gradients)
Fragile totexture-poor scenes, repetitive patternsexposure change, rolling shutter, bad photometric calibration
Residualgeometric (pixels of reprojection)photometric (units of brightness)
RepresentativeORB-SLAM3, VINS front-endsDSO, LSD-SLAM; SVO 2.0 = semi-direct hybrid
Monocular VO’s scale isn’t just unknown — it drifts: each initialization error compounds, and a trajectory can be internally consistent while being 7% too small in a way no image can reveal. Auto-exposure silently sabotages direct methods (the "constant brightness" assumption dies every time the camera gain steps). And all VO dies together in the same three places: featureless walls, motion blur from fast rotation, and darkness. Know the failure modes before the demo, because the demo will find them.

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.

VariantResidualNeedsCharacter
Point-to-point ICP‖T·p − q‖nothing extrasimple, slow convergence, narrow basin
Point-to-plane ICPnᵀ(T·p − q)target normalsthe default; flat regions slide, converges in few iterations
GICP (plane-to-plane)Mahalanobis, per-point Σlocal covariances both sidesmost accurate classical; heavier per iteration
NDT−Σ exp(Gaussian score)voxelized target Gaussianswide basin, smooth cost, resolution-sensitive
ICP is the Phase 1 loop with one twist: the correspondences themselves are re-decided every iteration. That makes the cost surface piecewise and multi-basin, which is why ICP’s single tunable that matters is the initial guess — and why every LIO system feeds it an IMU prediction rather than starting from identity.

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

THE TIGHTLY-COUPLED LIO LOOP (FAST-LIO2 SHAPE) IMU 200–1000 Hz LiDAR scan 10–100 Hz propagate (predict) iterated error-state KF deskew scan per-point IMU poses iterated update point-to-plane residuals vs local map ikd-tree map incremental insert/prune per-point poses insert refined pts corrected state + biases
Fig 2.3: the FAST-LIO2-shaped loop. The IMU predicts, the prediction deskews the scan and seeds registration, raw point-to-plane residuals correct the same filter, and refined points feed an incremental kd-tree map. Tight coupling means one estimator sees everything.

Three systems define the state of the art, all from the same research lineage and all standalone C++:

SystemCore ideaDistinguishing trickReach for it when
FAST-LIO2Iterated ESKF, direct raw-point registration (no feature extraction), incremental ikd-tree mapA Kalman-gain reformulation that scales with state dim, not measurement count — thousands of residuals per update, cheapThe default. General-purpose, mature, fast on modest CPUs
Point-LIOUpdates the filter per point rather than per scan; models IMU as output, not inputEffectively continuous-time updates → survives violent motion and IMU saturation (spinning, thrown sensors)Aggressive dynamics: legged robots, drones hitting things, high angular rates
DLIOContinuous-time motion correction with an analytic (closed-form) integrator, geometric observer instead of a KFDeskew via exact continuous-time integration — accurate warp at low cost; dense keyframe mapsYou 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:

SystemFront-endBack-endSensorsCharacter
ORB-SLAM3ORB features, descriptor matchingFactor-graph MAP over keyframes (g2o), multi-map Atlasmono / stereo / RGB-D, ± IMUThe accuracy benchmark; a full SLAM system whose VIO mode is the front half (Phase 3 covers the rest)
VINS-FusionKLT optical flow (no descriptors)Sliding-window optimization (Ceres) + marginalizationmono/stereo + IMU, + GNSSThe pragmatic workhorse: robust initialization, online extrinsic & time-offset estimation
OKVIS2Multi-scale features, stereo-firstKeyframe sliding window with careful marginalization; realtime loop closurestereo + IMUThe rigorous engineer’s choice; excellent calibration story, strong on stereo rigs
SVO 2.0Semi-direct: photometric tracking of sparse patches, depth filtersLightweight local BA (+ optional loop closure)mono/stereo/fisheye + IMUSpeed 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 / environmentReach first forBecause
Indoor wheeled robot, budget buildWheel + gyro EKF (Setup A), camera assistFloors are flat, slips are rare, encoders are free
Indoor/outdoor, structure-rich, money for one good sensorLiDAR-inertial: FAST-LIO2 / DLIOGeometry is lighting-proof; LIO is the most drift-free odometry per dollar of compute
Aggressive motion (legged, aerial contact)Point-LIOPer-point updates + IMU-as-output survive saturation
Weight/power-starved aerialVIO: SVO 2.0 or VINS-FusionCamera + IMU weigh grams; scale and gravity come from the IMU
Benchmark accuracy, later loop closureORB-SLAM3 / OKVIS2Feature machinery and keyframe BA transfer straight into Phase 3
Degeneracy is the failure mode that unifies this chapter. A long featureless corridor leaves lidar registration unconstrained along the corridor: the Phase 1 Hessian JᵀJ goes near-singular in exactly one direction, and the estimate slides freely there while looking healthy everywhere else. A blank wall does it to VO; a straight-line constant-velocity run does it to mono-VIO scale; ice does it to wheels. The professional move is to detect it — monitor the smallest eigenvalue of the registration Hessian, and when it collapses, inflate covariance along that eigenvector and lean on the complementary sensor. Systems that skip this check don’t fail less; they fail confidently.
Fuse sensors whose failure modes are disjoint, detect degeneracy through the eigenvalues of the same JᵀJ Phase 1 taught you to build, and never ship an odometry covariance that doesn’t contain the truth. Drift you have honestly quantified is a solved problem — Phase 3 erases it with loop closures; drift you lied about poisons everything downstream.

2.10Reading List

Visual Odometry: Part I & Part II (Tutorial)Scaramuzza & Fraundorfer · IEEE Robotics & Automation Magazine 2011/2012The 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 FilterSolà · arXiv:1711.02508Section 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 OdometryForster, Carlone, Dellaert & Scaramuzza · IEEE TRO 2017The 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 ShapesBesl & McKay · IEEE TPAMI 1992The ICP original. Thirty-plus years old and Setup B is still recognizably this paper. The Normal Distributions Transform: A New Approach to Laser Scan MatchingBiber & Straßer · IROS 2003Registration without correspondences: voxels as Gaussians, matching as smooth optimization. The other half of section 2.6. FAST-LIO2: Fast Direct LiDAR-Inertial OdometryXu, Cai, He, Lin & Zhang · IEEE TRO 2022The 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 OdometryHe, Xu, Zhou, Cai & Zhang · Advanced Intelligent Systems 2023Per-point filtering and the stochastic-process IMU model — LIO that survives 75 rad/s and saturated accelerometers. Direct LiDAR-Inertial Odometry (DLIO)Chen, Nemiroff & Lopez · ICRA 2023Continuous-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 SLAMCampos, Elvira, Rodríguez, Montiel & Tardós · IEEE TRO 2021The 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 EstimatorQin, Li & Shen · IEEE TRO 2018Sliding-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 ClosureLeutenegger · arXiv:2202.09199Keyframe 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.