Skip to main content
Contents3 / 8 ▾
03
Phase Three · Remembering the World Well Enough to Correct It

Mapping & SLAM Systems

Phase 2 ended with drift honestly quantified but still growing. This phase builds the memory — grids, octrees, distance fields — and the correction machinery — loop closure, pose graphs — that cap it. Then it tours the systems that package the whole thing.

3.1From Odometry to SLAM

Odometry answers "how did I move"; a map answers "what is here"; SLAM — simultaneous localization and mapping — is the admission that on unknown ground each requires the other. You cannot place sensor readings into a map without knowing the pose that took them; you cannot correct the pose against the map until the map exists. Every SLAM system breaks that circle the same way: trust odometry locally (it is excellent over ten metres), build the map incrementally from it, and when the robot recognizes a previously mapped place, use the reunion to repair everything at once.

Architecturally the field converged on a two-part design. The front-end is perception: Phase 2’s odometry, plus the machinery that decides "this scan matches that old place" (a Phase 4 topic previewed here as loop closure detection). The back-end is inference: an optimization over the whole trajectory that reconciles what the front-end reported. The split matters because the two halves fail differently — front-ends lie occasionally (a wrong match), back-ends amplify whatever they are fed — and because the back-end is, to the letter, the Phase 1 loop.

THE ANATOMY OF A SLAM SYSTEM sensors 10–1000 Hz odometry front-end Phase 2: LIO / VIO / wheels submap / keyframe builder the growing map loop detection place recognition (Phase 4) pose-graph back-end sparse LM (Phase 1, verbatim) poses new + old submaps constraints corrected poses
Fig 3.1: the standard SLAM split. The front-end (odometry + loop detection) produces poses and constraints; the back-end optimizes the whole trajectory; corrected poses re-anchor the map. Front-ends lie occasionally, back-ends believe everything — section 3.6 is about managing that marriage.
Full vs online SLAM
Estimating the entire trajectory plus map (full, what graph back-ends do) versus only the current pose plus map (online, what filters did). Modern systems are full-SLAM with real-time incremental solvers.
Submap / keyframe
The unit of map building: a small locally-consistent chunk (Cartographer’s submaps) or a selected sensor frame with pose (ORB-SLAM’s keyframes). Local consistency comes from odometry; global consistency from the graph.
Loop closure
Recognizing a previously visited place and computing the relative pose to it — the only measurement that ties "now" to "long ago", and therefore the only cure for drift.
Front-end / back-end
Perception (make constraints) versus inference (optimize over them). The dividing line of every architecture diagram in this field.

3.2Occupancy Grids: The Workhorse Memory

The oldest map representation is still the one your planner probably eats: carve space into cells, store per cell the probability it is occupied. Each lidar ray is evidence — the cells it traverses are probably free, the cell it ends in is probably occupied — and per-cell Bayes updates have a famous closed form once you work in log-odds, where fusing evidence becomes addition:

odds = p/(1−p),   l = log(odds)                l₀ = prior (usually 0)

per ray, per cell:   l ← l + l_sensor − l₀     additive! clamp to [l_min, l_max]

  cells along the ray  →  l_free = log(0.3/0.7) ≈ −0.85     "probably empty"
  cell at the endpoint →  l_occ  = log(0.7/0.3) ≈ +0.85     "probably a hit"

recover probability:  p = 1 − 1/(1 + eˡ)
cells never touched stay at p = 0.5 — unknown, and "unknown" is real
information a planner must respect (Phase 5's frontier exploration lives on it)

Why log-odds instead of raw probabilities? Multiplying probabilities underflows and needs renormalizing; adding bounded integers is fast, stable, and trivially undoable. The clamp matters too: without bounds, a cell seen free ten thousand times takes ten thousand hits of evidence to flip when a pallet is parked there — clamping keeps the map adaptable. Rays are traced with Bresenham stepping; resolution is the knob that trades fidelity against memory and update cost, and in 2D a few centimetres is cheap. In 3D it is not — which is the next two sections.

3.3Octrees and OctoMap: 3D Without the Memory Bill

A dense 3D grid of a warehouse at 5 cm dies fast: 100 m × 50 m × 10 m is 400 million cells, nearly all of them air, most never observed. The octree fixes this with recursive subdivision: a cube splits into eight children only where the world has detail; huge uniform regions — free space, unknown space — stay as single fat nodes. OctoMap is the standard C++ implementation: probabilistic (each leaf carries the log-odds machinery of 3.2), lossless at the chosen max depth, queryable at any coarser resolution for free (ancestors summarize descendants), and serialized compactly enough to ship over a network. Setup B below builds one from simulated rays and measures the savings.

An octree stores space the way you describe your apartment: "the bedroom is empty" is one sentence, not a cubic-centimetre inventory of empty positions. Only the bookshelf gets described shelf by shelf. Resolution where the world earns it, brevity everywhere else.

3.4TSDF and ESDF: Maps Made of Distances (Voxblox, NVBlox)

Occupancy answers "is this cell full?" — a question about cells. Planners and surface reconstruction keep asking better questions: "how far is the nearest obstacle?" and "where exactly is the surface?". Signed distance fields answer both natively. Each voxel stores its distance to the nearest surface, signed: positive in free space, negative inside objects; the surface is the zero crossing, recoverable at sub-voxel precision. The truncated SDF (TSDF) only maintains this within a band ±τ around surfaces (distance far from walls is expensive and useless for reconstruction), and fuses observations with a weighted running average that averages away sensor noise instead of just voting like occupancy does:

per observed ray/pixel, for voxels near the endpoint:
  d  = depth_measured − depth_of_voxel_along_ray       signed: + in front of wall
  d  ← clamp(d, −τ, +τ)                                the truncation band

fuse (weighted running average — this is why TSDF surfaces come out smooth):
  D(v) ← ( W(v)·D(v) + w·d ) / ( W(v) + w )
  W(v) ← W(v) + w

surface  = zero crossing of D  →  marching cubes  →  mesh
ESDF     = untruncated distance field, propagated outward from the TSDF —
           d(x) and ∇d(x) are exactly the collision cost and gradient
           Phase 5's trajectory optimizers want
A TSDF ALONG ONE RAY +τ −τ 0 sensor measured ray wall (true surface) D = +τ (free, far from surface) unobserved: behind the wall D = 0 → surface
Fig 3.2: the TSDF stores clamped signed distance in a band around the surface. Averaged over many noisy rays, the zero crossing localizes the wall at sub-voxel precision — the reason TSDF meshes look smooth where occupancy grids look like sugar cubes.

Voxblox (ETH Zurich) made this practical on CPUs for planning: projective TSDF integration, then a wavefront that propagates the TSDF band outward into a full ESDF, incrementally, as the map grows — built explicitly so that Phase 5’s trajectory optimizers could query distance-and-gradient in microseconds. NVBlox is the same idea rebuilt for GPUs — TSDF, color, ESDF layers integrated in CUDA, order-of-magnitude faster, with a decay mechanism for dynamic scenes — usable as a plain C++/CUDA library. The representation menu, summarized:

RepresentationCell storesNative queryShines atLibrary
Occupancy grid (2D)P(occupied), log-oddsis (x,y) blocked?2D planning, cheap updatesevery 2D SLAM stack
Octree (3D)log-odds, hierarchicaloccupancy at any resolutionbig sparse 3D worlds, memoryOctoMap
TSDFclamped signed distance + weightwhere is the surface?smooth reconstruction, RGB-D fusionVoxblox, NVBlox
ESDFfull distance (+ gradient)how far is the nearest obstacle?trajectory optimization (Phase 5)Voxblox, NVBlox
Point cloud + kd-treeraw pointsnearest neighborsregistration (Phase 2), LIO mapsikd-tree, nanoflann
Pick the map by the query, not the sensor. Registration wants nearest points, planners want distances and gradients, reconstruction wants zero crossings, coverage logic wants explicit unknown. Production stacks run two representations side by side and treat that as normal, not as waste.

3.5Loop Closure: The Drift Eraser

Everything so far still inherits Phase 2’s drift — a map built on drifting poses is a drifting map. The repair comes from one event: the robot re-enters known territory, recognizes it, and measures its pose relative to a place it saw long ago. That single relative measurement — pose 840 to pose 12 — carries information no amount of odometry can: it bounds the error of everything between.

A loop closure is manufactured in three steps, each owned by a different phase of this book. Propose: place recognition (Phase 4’s descriptors — DBoW, Scan Context) nominates "current scan resembles old submap k". Verify & measure: Phase 2’s registration (ICP/NDT, or PnP for cameras) aligns the two, producing the relative transform and — critically — a fitness score. Gate: accept only if the alignment is geometrically strong and statistically consistent with current drift estimates (a chi-squared test on the innovation). The gate exists because the downstream optimizer treats every accepted constraint as truth, and one false loop closure folds a building in half.

The failure signature of an accepted false loop: the map was fine, then two parallel corridors became one corridor with doubled walls, like a misregistered photocopy. Recovery is ugly because optimization moved every pose. Defense in depth, in order: make the proposer conservative (Phase 4), demand high registration fitness, gate on chi-squared innovation, wrap loop edges (never odometry) in robust kernels (3.6), and log every accepted closure so you can replay without the guilty one.

3.6Pose Graph Optimization: The Back-End

Now assemble the estimation problem. Nodes: the trajectory’s poses Xi (one per submap or keyframe — not every sensor frame). Edges: relative-pose measurements — consecutive ones from odometry, long-range ones from loop closures — each with its information matrix. The residual of an edge asks one question: does the trajectory disagree with what was measured between i and j?

edge (i, j):  measured Z_ij, information Ω_ij = Σ_ij⁻¹

residual:     r_ij(X) = log( Z_ij⁻¹ · X_i⁻¹ · X_j )ᵛ        a tangent vector!
                        (Phase 1's ⊟: "the step separating prediction from
                         measurement", so ‖r‖ = 0 means perfect agreement)

solve:        X* = argmin Σ_(i,j)  r_ijᵀ Ω_ij r_ij           + pin X₀ (gauge!)

— sparse damped Gauss-Newton on the manifold: Phase 1.8, line for line.
robust variant: wrap loop-closure terms in Huber/Cauchy ρ(·) so a false
edge saturates instead of bending the whole trajectory to appease it.

Sparsity does the heavy lifting: each edge touches two poses, so the Hessian is nearly empty, and 10,000-pose graphs solve in tens of milliseconds — which is why systems can afford to re-optimize on every accepted loop. The optimization redistributes the accumulated drift around the loop according to each edge’s stiffness Ω: confident odometry bends little, sloppy stretches absorb more. Before and after, in one picture:

ONE LOOP CLOSURE, BEFORE AND AFTER start end (drifted) before: raw odometry loop closure: "end = start" optimize (LM, sparse, ms) after: error redistributed around the loop
Fig 3.3: odometry drove a square but drifted; the loop closure asserts end = start; optimization spreads the disagreement across all edges in proportion to their uncertainty. The map jumps — section 3.8 deals with who has to survive that jump. Setup A builds exactly this.

Setup A: a pose graph with Ceres + Sophus, plus one lying loop closure — one evening. Goal: build the five-pose square of Fig 3.3 with drifting odometry, snap it shut with a loop closure, then inject a false loop closure and watch a Huber kernel save the map that a quadratic cost destroys.

Prereqs: Phase 1’s Setups (Eigen, Sophus, Ceres). Save as posegraph2d.cc, reuse Phase 1 Setup B’s CMakeLists with the target renamed:

// posegraph2d.cc — SE(2) pose graph: drifting square + loop closure(s).
#include <ceres/ceres.h>
#include <sophus/se2.hpp>
#include <sophus/ceres_manifold.hpp>
#include <iostream>
#include <vector>

struct EdgeResidual {          // r = log( Z⁻¹ · Xa⁻¹ · Xb )
  explicit EdgeResidual(const Sophus::SE2d& Z) : Z_inv_(Z.inverse()) {}
  template <typename T>
  bool operator()(const T* const a, const T* const b, T* residual) const {
    const Eigen::Map<const Sophus::SE2<T>> Xa(a), Xb(b);
    Eigen::Map<Eigen::Matrix<T, 3, 1>> r(residual);
    r = (Z_inv_.cast<T>() * (Xa.inverse() * Xb)).log();
    return true;
  }
  const Sophus::SE2d Z_inv_;
};

ceres::CostFunction* make_edge(const Sophus::SE2d& Z) {
  return new ceres::AutoDiffCostFunction<EdgeResidual, 3,
      Sophus::SE2d::num_parameters, Sophus::SE2d::num_parameters>(
      new EdgeResidual(Z));
}

int main() {
  const bool inject_false_loop = false;     // experiment 1: flip me
  const bool use_robust        = false;     // experiment 2: then flip me

  // truth: a 2 m square. odometry measures 2.1 m legs and 84.8° turns.
  const Sophus::SE2d odom_meas(1.48, Eigen::Vector2d(2.1, 0.0));

  std::vector<Sophus::SE2d> X(5);           // initial guess: integrate odometry
  for (int i = 1; i < 5; ++i) X[i] = X[i - 1] * odom_meas;

  ceres::Problem problem;
  for (auto& x : X)
    problem.AddParameterBlock(x.data(), Sophus::SE2d::num_parameters,
                              new Sophus::Manifold<Sophus::SE2>());
  problem.SetParameterBlockConstant(X[0].data());    // pin the gauge!

  for (int i = 0; i < 4; ++i)                        // odometry edges
    problem.AddResidualBlock(make_edge(odom_meas), nullptr,
                             X[i].data(), X[i + 1].data());

  // the true loop closure: pose 4 re-observes pose 0 at identity
  ceres::LossFunction* loss =
      use_robust ? new ceres::HuberLoss(0.5) : nullptr;
  problem.AddResidualBlock(make_edge(Sophus::SE2d()), loss,
                           X[4].data(), X[0].data());

  if (inject_false_loop)                    // a place-recognition mistake:
    problem.AddResidualBlock(make_edge(Sophus::SE2d()), loss,
                             X[2].data(), X[0].data());   // "pose2 = pose0" — lie

  ceres::Solver::Options opts;
  ceres::Solver::Summary summary;
  ceres::Solve(opts, &problem, &summary);

  for (int i = 0; i < 5; ++i)
    std::cout << "x" << i << "  (" << X[i].translation().x() << ", "
              << X[i].translation().y() << ", "
              << X[i].so2().log() * 180.0 / M_PI << "°)\n";
  std::cout << summary.BriefReport() << "\n";
}

Run it three ways. Baseline (both flags false): before optimization the integrated odometry leaves pose 4 about 0.6 m and 21° from home; after, the printed poses form a near-perfect 2 m square — the drift got spread around the loop, no edge left more than slightly stretched. Experiment 1 (inject_false_loop = true): the quadratic cost appeases the lie, and the "square" folds — pose 2 is dragged toward pose 0 and every other pose distorts to compensate. This is the doubled-corridor disaster at toy scale. Experiment 2 (both true): Huber saturates the false edge’s pull; the square survives visibly intact, and the false edge is left carrying a huge residual — which is exactly how you find it in the logs.

Sanity checks: (1) comment out SetParameterBlockConstant — Ceres reports a rank-deficient Jacobian, or converges to a translated/rotated square: gauge freedom, live (Phase 1.7’s warfield). (2) Robustify the odometry edges too and note the fit barely changes here but real systems never do this — odometry is dense and honest, and a kernel that can mute it can mute the truth. (3) Scale the loop edge’s residual by 10 (information weighting) and watch it win every argument — covariances are the knobs, exactly as in Phase 1.

3.7The Systems: Cartographer, GLIM, ORB-SLAM3

Three mature, ROS-free-usable systems cover the classical SLAM design space — one lidar-first with submaps, one LIO-first with a factor graph and a GPU, one camera-first with keyframes:

SystemSensorsLocal unitLoop closureBack-endCharacter
Cartographer2D/3D lidar + IMU (+ odom)Submaps: small probability grids, scan-to-submap matchingBranch-and-bound correlative search over all finished submaps — fast, deterministic, no learned descriptorsSparse pose graph (Ceres)The standalone C++ core of a decade of service robots; superbly engineered 2D, capable 3D
GLIM3D lidar + IMU (+ camera)Keyframed LIO odometry (GPU voxelized GICP)Global matching-cost factors — registration error minimized globally, not just at detected loopsFactor graph (GTSAM), GPU-acceleratedThe modern research-grade LIO-SLAM: extensible via callbacks, exceptional accuracy, wants a GPU
ORB-SLAM3mono/stereo/RGB-D ± IMUKeyframes + covisibility graph of ORB featuresDBoW2 place recognition → geometric verification → Sim(3)/SE(3) correctiong2o: local BA + pose graph + full BAThe visual reference: three threads (tracking, local mapping, loop closing) + Atlas multi-map for tracking loss

Two architectural contrasts worth internalizing. Submaps vs keyframes: Cartographer trusts odometry for the few seconds a submap takes to build, freezes the submap, and does all global reasoning submap-to-submap; ORB-SLAM keeps individual keyframes live forever and lets bundle adjustment re-shape everything. Submaps bound compute and suit dense grids; keyframes maximize accuracy and suit sparse features. Detected loops vs global matching: Cartographer and ORB-SLAM3 close loops when a detector fires; GLIM instead keeps registration-error factors between all overlapping submap pairs and lets the optimizer continuously grind global consistency — more compute, fewer dramatic jumps, no single make-or-break detection.

And the ecosystem note that keeps this book honest: the Phase 2 LIO systems (FAST-LIO2, DLIO) are odometry — superb, drifting. Add a loop-closure layer and a pose graph over their output and you have built a SLAM system; GLIM is what that looks like done properly, and Phase 4’s place recognition is the proposer it needs.

3.8Living With the Jump

When a loop closes, thousands of poses move at once — and the map, rebuilt from corrected poses, moves with them. Something on the robot is mid-motion when that happens. The survival pattern is a two-frame contract: keep a continuous odometry frame (Phase 2’s output, smooth, drifting, what the controller consumes) and a map frame (globally correct, jumping), and express SLAM’s output as the slowly-updated correction transform between them. Controllers and local planners live entirely in the odometry frame and never feel a jump; global planners re-plan in the map frame after each correction. The same contract handles map re-rendering: Cartographer re-anchors frozen submaps (cheap — the grid content never changes, only its pose); ORB-SLAM re-projects landmarks; TSDF systems either re-integrate or accept local warping.

SLAM’s output is not "the pose" — it is a correction between a smooth frame and a correct one. Controllers consume the smooth frame, planners the correct one, and the jump lives in the transform where nothing dynamic can trip over it. Getting this contract wrong is how demos drive into walls at the exact moment the map improves.

Setup B: a 3D octree map from simulated scans with OctoMap — one evening. Goal: build a probabilistic 3D map of a synthetic room with a pillar, query it, serialize it, and measure the octree’s memory against the dense grid it replaces.

Prereqs: sudo apt install liboctomap-dev octovis. Save as room_octree.cc, compile with g++ -O2 -std=c++17 room_octree.cc -o room_octree -loctomap -loctomath:

// room_octree.cc — insert simulated lidar rays into an OctoMap octree.
#include <octomap/OcTree.h>
#include <cmath>
#include <iostream>

// room interior: |x|<3, |y|<2, 0<z<2.5. pillar: box 1.2<x<1.8, 0.5<y<1.1.
double exit_t(double o, double d, double lo, double hi) {   // slab exit
  if (std::abs(d) < 1e-9) return 1e18;
  return (d > 0 ? hi - o : lo - o) / d;
}
bool pillar_entry(const octomap::point3d& o, const octomap::point3d& d,
                  double& t_hit) {                          // slab entry test
  double t0 = 0.0, t1 = 1e18;
  const double lo[3] = {1.2, 0.5, 0.0}, hi[3] = {1.8, 1.1, 2.5};
  const double oo[3] = {o.x(), o.y(), o.z()}, dd[3] = {d.x(), d.y(), d.z()};
  for (int a = 0; a < 3; ++a) {
    if (std::abs(dd[a]) < 1e-9) { if (oo[a] < lo[a] || oo[a] > hi[a]) return false; continue; }
    double ta = (lo[a] - oo[a]) / dd[a], tb = (hi[a] - oo[a]) / dd[a];
    if (ta > tb) std::swap(ta, tb);
    t0 = std::max(t0, ta); t1 = std::min(t1, tb);
  }
  t_hit = t0;  return t0 < t1 && t0 > 0.0;
}

int main() {
  octomap::OcTree tree(0.05);                               // 5 cm leaves
  const octomap::point3d origin(0.0f, 0.0f, 1.2f);          // sensor pose

  for (int yaw_deg = 0; yaw_deg < 360; ++yaw_deg)           // a full sweep
    for (int pitch_deg = -30; pitch_deg <= 30; pitch_deg += 2) {
      const double yaw = yaw_deg * M_PI / 180.0, pit = pitch_deg * M_PI / 180.0;
      const octomap::point3d dir(std::cos(pit) * std::cos(yaw),
                                 std::cos(pit) * std::sin(yaw), std::sin(pit));
      double t = std::min({exit_t(origin.x(), dir.x(), -3.0, 3.0),
                           exit_t(origin.y(), dir.y(), -2.0, 2.0),
                           exit_t(origin.z(), dir.z(),  0.0, 2.5)});
      double tp;                                             // pillar occludes?
      if (pillar_entry(origin, dir, tp) && tp < t) t = tp;
      tree.insertRay(origin, origin + dir * (float)t);       // free + endpoint
    }

  tree.updateInnerOccupancy();
  const auto* hit  = tree.search(octomap::point3d(3.0f, 0.0f, 1.2f));  // wall
  const auto* mid  = tree.search(octomap::point3d(0.5f, 0.0f, 1.2f));  // air
  const auto* shad = tree.search(octomap::point3d(2.4f, 0.8f, 1.2f));  // shadow
  std::cout << "wall   P(occ) = " << (hit  ? hit->getOccupancy() : -1) << "\n"
            << "air    P(occ) = " << (mid  ? mid->getOccupancy() : -1) << "\n"
            << "shadow          " << (shad ? "observed?!" : "unknown — correct!") << "\n";
  std::cout << "leaf nodes : " << tree.getNumLeafNodes() << "\n"
            << "memory     : " << tree.memoryUsage() / 1024.0 << " kB  (dense 5 cm grid of this room: "
            << 120L * 80 * 50 * 8 / 1024.0 << " kB)\n";
  tree.writeBinary("room.bt");                               // octovis room.bt
}

Expected: the wall voxel reports P(occ) near 0.97 (many agreeing endpoint hits), the mid-room voxel near 0.03 (many traversals), and the point behind the pillar returns null — never observed, honestly unknown, exactly the tri-state a planner needs. Leaf count lands in the low tens of thousands against 480,000 dense cells, with memory to match — and the gap widens brutally with volume, which is the entire octree argument. Open room.bt in octovis and you can see the pillar’s occlusion shadow as a cone of unknown space.

Sanity checks: (1) drop resolution to 0.01 and watch nodes and time explode cubically — resolution is never free. (2) Insert the same sweep from a second origin at (−2, −1, 1.2) and the shadow fills in: mapping coverage is a viewpoint problem, which is why Phase 5’s exploration planners exist. (3) Re-run the sweep 10× and note P(occ) saturates at the clamp — the adaptability bound from 3.2 in action.

3.9Reading List

Past, Present, and Future of Simultaneous Localization and MappingCadena, Carlone, Carrillo, Latif, Scaramuzza, Neira, Reid & Leonard · IEEE TRO 2016The field’s consensus survey: the front-end/back-end anatomy of 3.1, robustness, and the open problems that became Phases 4–8. A Tutorial on Graph-Based SLAMGrisetti, Kümmerle, Stachniss & Burgard · IEEE ITS Magazine 2010Section 3.6 expanded into twenty implementable pages. The standard on-ramp to pose graphs. OctoMap: An Efficient Probabilistic 3D Mapping Framework Based on OctreesHornung, Wurm, Bennewitz, Stachniss & Burgard · Autonomous Robots 2013The paper behind Setup B: probabilistic octrees, clamping, multi-resolution queries, compression. A Volumetric Method for Building Complex Models from Range ImagesCurless & Levoy · SIGGRAPH 1996Where the TSDF comes from — thirty years old and section 3.4’s equations are still recognizably this paper. Voxblox: Incremental 3D Euclidean Signed Distance Fields for On-Board MAV PlanningOleynikova, Taylor, Fehr, Siegwart & Nieto · IROS 2017TSDF→ESDF propagation built explicitly for planners — the bridge between this phase and Phase 5. nvblox: GPU-Accelerated Incremental Signed Distance Field MappingMillane, Oleynikova, Wirbel, Steiner, Ramasamy, Tingdahl & Siegwart · ICRA 2024The same layers rebuilt in CUDA, an order of magnitude faster, with decay for dynamic scenes. Real-Time Loop Closure in 2D LIDAR SLAMHess, Kohler, Rapp & Andor · ICRA 2016Cartographer’s engine: submaps, scan-to-submap matching, and branch-and-bound loop search that made deterministic real-time closure practical. GLIM: 3D Range-Inertial Localization and Mapping with GPU-Accelerated Scan Matching FactorsKoide, Yokozuka, Oishi & Banno · Robotics and Autonomous Systems 2024Global matching-cost minimization instead of detected loops — what LIO-SLAM looks like designed in the GPU era. ORB-SLAM3: An Accurate Open-Source Library for Visual, Visual-Inertial and Multi-Map SLAMCampos, Elvira, Rodríguez, Montiel & Tardós · IEEE TRO 2021The keyframe-and-covisibility architecture in full: Atlas, DBoW2 relocalization, and the three-thread design of 3.7. Switchable Constraints for Robust Pose Graph SLAMSünderhauf & Protzel · IROS 2012Let the optimizer itself disable loop closures it cannot reconcile — the elegant endpoint of Setup A’s Huber experiment.

3.10Interview Questions

Why do occupancy grids update in log-odds, and why clamp the values?

Bayes-updating a binary cell multiplies odds ratios; in log space that multiplication becomes addition — numerically stable, fast, no renormalization, trivially invertible. Clamping bounds how certain a cell can get, which bounds how much contrary evidence is needed to flip it: an unclamped cell observed free for an hour would need an hour of hits to become occupied, so the map would stop adapting to change. Clamped, it flips after a few consistent observations — staleness is bounded by design.

Occupancy grid vs TSDF vs ESDF: who wants which, and why?

Match the map to the query. Occupancy answers "is this cell blocked/free/unknown" — planners doing collision checks and exploration logic (which needs explicit unknown) want it. TSDF stores where the surface is, with noise averaged out — reconstruction and RGB-D fusion want it, because the zero crossing localizes surfaces at sub-voxel precision. ESDF stores distance-to-nearest-obstacle everywhere, with gradients — trajectory optimizers want it, because d(x) and ∇d(x) are literally the collision cost term and its derivative. Production stacks commonly maintain occupancy (or TSDF) plus a derived ESDF, and that redundancy is the correct design, not waste.

What exactly does a loop closure buy that more odometry never can?

Odometry constraints chain consecutive poses, so error compounds multiplicatively along the chain without bound — more odometry just extends the chain. A loop closure constrains two temporally distant poses directly, converting the open chain into a cycle; the optimizer then redistributes the accumulated drift around the cycle, bounding the error of every pose in it. Information-theoretically it is the only measurement that correlates "now" with "long ago", which is why systems without loop closure (pure LIO/VIO) drift forever no matter how good, and why one loop closure can outweigh minutes of odometry.

Walk through the defenses against a false loop closure, in order.

(1) Conservative proposal: tune place recognition (Phase 4) for precision over recall — a missed loop costs accuracy, a false one costs the map. (2) Geometric verification: require registration (ICP/PnP) to succeed with strong fitness on the candidate pair. (3) Statistical gating: chi-squared test of the proposed constraint against the current graph’s uncertainty — a closure teleporting the robot 40 m against 2 m of plausible drift should fail the gate. (4) Robust kernels on loop edges (Huber/Cauchy) or switchable constraints, so an accepted lie saturates instead of folding the map. (5) Auditability: log closures so the map can be rebuilt without any single one. Kernels are the seatbelt, not the driving.

Cartographer builds submaps; ORB-SLAM keeps keyframes. Why do both designs exist?

They resolve the same tension — global optimization can’t touch every sensor frame — at different points. Submaps freeze small locally-consistent grids after seconds of odometry and optimize submap poses only: compute is bounded, dense grids ride along free, but intra-submap error is frozen forever. Keyframes keep every selected frame’s pose live so bundle adjustment can re-shape everything: maximal accuracy, natural fit for sparse landmarks, but compute grows with map size and dense data doesn’t fit the abstraction. Lidar grids favor submaps; sparse visual features favor keyframes — the representation choice drives the architecture.

A loop closure just moved the whole map 40 cm while the robot was driving a corridor at 2 m/s. What keeps it from hitting the wall?

The two-frame contract. The controller tracks its path in the continuous odometry frame, which never jumps — SLAM’s correction is expressed as a map→odom transform that absorbs the 40 cm discretely. The local planner’s obstacle view (built from recent sensor data in the odometry frame) is also jump-free. What must react is the global layer: the planner re-validates or re-plans the route in the corrected map frame. If instead the controller had been tracking map-frame coordinates directly, the setpoint would have teleported 40 cm sideways mid-corridor — which is precisely the classic failure this contract exists to prevent.

Why is the pose-graph Hessian sparse, and what would destroy that sparsity?

Each edge involves exactly two poses, so the Hessian has nonzero blocks only at (i,i), (j,j), (i,j) pairs that share an edge — a 10,000-pose graph is overwhelmingly zeros, and sparse Cholesky solves it in near-linear practical time. Two things densify it: marginalizing poses out (each removal ties all its neighbors together — the fixed-lag smoother’s price), and dense inter-connection like GLIM’s all-overlapping-pairs matching factors, which trades sparsity for consistency and pays with a GPU. Elimination ordering matters too: a bad ordering manufactures fill-in during factorization even when the graph itself is sparse.

Your TSDF mesh looks great but the robot keeps planning paths 3 cm through walls. Where do you look?

The mesh is the zero crossing; the planner consumes the ESDF — so look at the gap between them. Usual suspects: truncation distance τ too small relative to voxel size, so the ESDF propagation starts from a band too thin and underestimates distances near surfaces; voxel size too coarse for 3 cm tolerances (sub-voxel surfaces don’t survive into a discretized ESDF); weights not yet converged in newly observed areas (young voxels lie); and the classic — the planner queries distance at the robot’s center without inflating by its radius. The mesh looking good proves the TSDF; it says nothing about the derived field the planner actually reads.