Robotics Engineering · Lesson 9 of 26

Downstream Perception
Maps, detection & the handoff to planning

Localization says the robot is here to two centimetres. This is the layer that turns that pose into a decision — which cells are free, which are wall, which is the forklift that just moved.

Prerequisites: Bayes’ rule + the idea of a probability. The log-odds algebra is built here from scratch.
7
Chapters
8
Interactive Sims
3
Code Labs

Chapter 0: The Hook

Localization says the robot is here — a pose, good to two centimetres. That is a triumph of the last eight lessons, and it is also, on its own, completely useless.

Because the planner does not want to know where the robot is. It wants to know what is around it. Which cells ahead are free floor it can drive over. Which are the wall it must not hit. And which is the forklift that was not there thirty seconds ago and is now sitting squarely in the only aisle to the loading dock.

A pose is a point. A decision needs a neighbourhood. This chapter — and this whole lesson — is the layer that turns one accurate point into a map of free, occupied, and unknown space that a planner can actually route through.

Why a point is not enough — counted, not asserted

“A pose is a point” is a slogan until you count the actual decision-relevant information on each side. So let us count.

A 2-D pose is exactly three numbers: x, y, and heading θ. That is the entire output of the localizer — three floats, and the planner cannot get a fourth one out of it no matter how it asks.

Now count what a local planner actually needs to route one step forward. Take the disc of cells within a 20-cell planning radius around the robot (at a 5 cm grid, that is a 1 m reach — roughly one robot-length of look-ahead). The number of cells in that disc is the area of a circle of radius 20 cells:

Ncells = π · R² = π · (20)² = π · 400 ≈ 1257 cells

Each of those ~1257 cells carries at least one bit of free-vs-occupied state the planner reads before it commits to a motion. Compare the two payloads directly:

1257 cells ÷ 3 numbers ≈ 419×

So the map the planner routes through carries on the order of 400 times more decision-relevant state than the pose itself — and that is only the local disc, ignoring the rest of the warehouse. The pose is not the answer to the planner’s question; it is only the index that tells you where in the map to start reading. Building and maintaining that other 99.8% of the state is the whole job of this lesson.

To see why this is a real problem and not a formality, picture the raw material the robot is handed. A 2-D lidar spins and returns, ten times a second, a list of a few hundred numbers: at bearing 0°, the beam travelled 3.2 m before it hit something; at 1°, 3.19 m; at 2°, …. That is it. No cells, no walls, no forklift — just ranges, each one noisy, each one arriving from a slightly different robot pose than the last.

The gap between that and “the aisle to the dock is blocked” is the entire subject of downstream perception. It has four moving parts, and each is a chapter:

  1. A representation. You need a data structure that answers “is cell (x, y) free?” in constant time and can be updated as new scans arrive. That is the occupancy grid, and the update that makes it work is a running sum in log-odds — Chapter 1.
  2. The third dimension. A drone or a manipulator cannot live on a flat grid; it needs free space and surfaces in 3-D. That is the voxel grid and its sharper cousin the TSDF — Chapter 2.
  3. Semantics. “Occupied” is not enough. A wall and a forklift are both occupied, but you inflate around one and yield to the other. That takes detection, segmentation and tracking — Chapter 3.
  4. The handoff. Where, precisely, does the pose plus the map become the thing a planner consumes? What runs at what rate, and what breaks at the seam? — Chapter 4.

Then Chapter 5 is the payoff: a live bench where you drive a robot with a range sensor through a scene and watch the occupancy grid fill in, cell by cell, from nothing but noisy beams.

What this lesson leans on, and does not repeat

The site already teaches the theory underneath several of these ideas. If a derivation below feels like it skips a step, the step is in one of these lessons — read it first; this one builds on it rather than restating it.

If you want…Read
Occupancy mapping and frontier exploration in the AA274A course, with the ray-based inverse sensor modelOccupancy Mapping & Frontier Exploration
How 3-D geometry works at all: depth, point clouds, camera models, surface fitting3D Vision
Simulated sensors and the physics of range/depth returns in a robotics simRobotics Simulation
Covariance, the Mahalanobis distance, gating — used in Chapter 3’s tracker and Chapter 4’s associationUncertainty, Least Squares & Robust Costs

See the whole gap in one picture

Before any algebra, look at the transformation this lesson performs. On the left is what the sensor gives you: a fan of range returns from a single pose. On the right is what the planner needs: a grid coloured free / occupied / unknown. Drag the slider to sweep the robot through the scene and watch the raw fan on the left turn into the accumulated grid on the right.

From a fan of ranges to a decision map

Left: the raw lidar fan at the current pose (each ray is one number). Right: the occupancy grid those rays have carved out so far — teal is free, warm is occupied, dim is unknown. The right panel is everything the sensor never directly measured: it is inferred, cell by cell, from the rays.

robot position along aisle 20%
The one-sentence version of the whole lesson: perception downstream of localization is the machinery that converts a stream of noisy, pose-stamped measurements into a persistent, queryable model of the world — free space, surfaces, and the moving things — that a planner can ask questions of in constant time.

Sizing the thing: what does that map actually cost?

Before we build the map, decide how big it is — because that single number sets the memory budget, the update rate, and whether this runs on the robot at all. This is the DESIGN question the widget above hides behind pretty colours, and it is answerable with arithmetic.

Take a concrete site: a 30 m × 15 m warehouse floor, mapped at 5 cm resolution (0.05 m per cell). Count the cells along each axis, then the total:

nx = 30 ÷ 0.05 = 600    ny = 15 ÷ 0.05 = 300
N = nx · ny = 600 · 300 = 180,000 cells

Store each cell as one byte of log-odds (the standard costmap_2d choice — a signed 8-bit value is plenty of dynamic range once you clamp it), and the map is:

180,000 cells × 1 byte = 180,000 bytes = 180 KB

That fits in L2 cache on a modern robot compute module — which is exactly why the whole grid can be re-touched at the sensor rate. If the lidar runs at 10 Hz, the mapper integrates a fresh scan every 100 ms, and every scan is a scatter-add over a few thousand cells into that 180 KB buffer. Halve the resolution to 2.5 cm and every number quadruples: 720 KB, four times the cells to touch per scan. That trade — fidelity against cells-touched-per-second — is the first decision the perception engineer makes, and it is made in bytes, not adjectives.

The design reflex: resolution is a squared cost. Doubling how finely you resolve the world quadruples memory and the per-scan update work. 5 cm is the workhorse indoor resolution precisely because it is the coarsest grid that still resolves a pallet edge.

Reading the map back — three colours, two thresholds

The widget paints each cell teal (free), warm (occupied), or dim (unknown). Those three colours are not three stored values — every cell holds one number, its log-odds ℓ, and the colour is a decode. Two thresholds do it. A cell counts as occupied once ℓ > +2 (p > 0.88), free once ℓ < −2 (p < 0.12), and unknown anywhere in between — including the exact prior ℓ = 0 the cell started at. Work three cells through the decode:

Cell’s log-odds ℓp = 1 − 1/(1+e)DecodeColour
+2.4 (three hits)0.917ℓ > +2 ⇒ occupiedwarm
  0.0 (never seen)0.500−2 ≤ ℓ ≤ +2 ⇒ unknowndim
−2.4 (six misses)0.083ℓ < −2 ⇒ freeteal

This is why the map has three states and not two: unknown is the honest default. A cell no beam has touched keeps ℓ = 0 and stays dim forever — the planner treats it as “do not route through here blindly, I have no evidence,” which is exactly right and is information a naive occupied/free binary throws away.

Worked example 1 — one range return becomes one cell

Here is the atom of everything downstream: turn a single lidar return into the index of the cell it hit. Put the robot at pose (x = 2.0 m, y = 5.0 m), take the beam at bearing 0° (pointing along +x), and say it came back with range r = 3.2 m. The grid resolution is 0.25 m per cell. Where did that beam hit?

First the endpoint in world metres — the pose plus the beam vector:

endpoint = pose + r · (cosθ, sinθ)
ex = 2.0 + 3.2 · cos(0°) = 2.0 + 3.2 · 1 = 5.2 m
ey = 5.0 + 3.2 · sin(0°) = 5.0 + 3.2 · 0 = 5.0 m

Now snap metres to a cell index by dividing by the resolution and flooring:

cellx = ⌊ 5.2 ÷ 0.25 ⌋ = ⌊ 20.8 ⌋ = 20
celly = ⌊ 5.0 ÷ 0.25 ⌋ = ⌊ 20.0 ⌋ = 20

So this one beam deposits “occupied” evidence into cell (20, 20). That is the exact computation the widget above runs 60 times per scan, once per ray — and the floor is where the sensor’s continuous world becomes the grid’s discrete one. (Watch that boundary: an endpoint at 5.25 m would land in cell 21, not 20 — off-by-one at a cell edge is the classic mapping bug.)

Worked example 2 — one hit moves the belief

Depositing “occupied” is not writing a 1 — it is adding to a running log-odds sum (the full derivation is Chapter 1; here we just watch one addition). Start the cell knowing nothing, so its log-odds is ℓ = 0 (that is the prior p = 0.5). The beam’s inverse-sensor-model increment for a hit is Locc = 0.85. Add it:

ℓ = 0 + 0.85 = 0.85

Now read that back out as a probability. The inverse of log-odds is the logistic:

p = 1 − 11 + e = 1 − 11 + e0.85 = 1 − 11 + 2.34 = 1 − 0.30 = 0.70

One hit and the cell went from “no idea” (50%) to 70% likely occupied. A second identical hit would add another 0.85 to reach ℓ = 1.70, p ≈ 0.85 — the belief climbs with each consistent measurement but in shrinking steps, so a single glitch never flips a well-observed cell. That saturation is not a bug; it is the whole reason the log-odds form is the one the field settled on.

From scratch — the two functions that are the mapper

Both worked examples above are exactly the code the widget runs. Here is that core, from scratch — a ray-to-cell for the hit, and a ray-march that walks the free cells the beam passed through. Nothing is imported that you could not write yourself; this is the whole idea in twenty lines.

python
import math

# --- the atom: one range return -> the occupied cell (worked example 1) ---
def range_to_cell(pose, bearing, r, res):
    px, py, ptheta = pose                       # robot pose (m, m, rad)
    a  = ptheta + bearing                        # beam heading in world frame
    ex = px + r * math.cos(a)                    # endpoint x  (2.0 + 3.2*1 = 5.2)
    ey = py + r * math.sin(a)                    # endpoint y  (5.0 + 3.2*0 = 5.0)
    return (int(ex // res), int(ey // res))     # floor to cell -> (20, 20)

# --- integrate one beam into a flat log-odds grid ---
L_OCC, L_FREE = 0.85, -0.40            # inverse-sensor-model increments

def integrate_beam(grid, GX, pose, bearing, r, res, step=0.5):
    px, py, ptheta = pose
    a = ptheta + bearing
    d = step
    while d < r - step:                          # ray-march the FREE cells
        fx = int((px + d*math.cos(a)) // res)
        fy = int((py + d*math.sin(a)) // res)
        grid[fy*GX + fx] += L_FREE               # beam passed through -> free
        d += step
    ox, oy = range_to_cell(pose, bearing, r, res)
    grid[oy*GX + ox] += L_OCC                     # beam stopped here -> occupied

That while loop is a walk from the sensor outward, carving free space until it reaches the return, where it deposits the one occupied hit — the same free/occupied split the widget draws in teal and warm. (A production mapper swaps the fixed-step walk for a Bresenham integer line so every cell on the beam is touched exactly once with no floating-point gaps; the idea is identical.) The full accumulate-and-threshold version, matching the widget’s integrate(), is built up over Chapter 1.

Does it fit the budget? — one scan, counted

Run the counting reflex on integrate_beam before trusting it on the robot. A single 2-D scan is a fan of about 60 rays. Each ray marches free cells for its whole length; at a 5 cm grid a 4 m beam walks

4 m ÷ 0.05 m = 80 cells per ray

so one scan is on the order of

60 rays × 80 cells ≈ 4,800 cell-touches per scan

Each touch is a single float add into the 180 KB buffer. Even at a deliberately pessimistic 10⁸ scatter-adds per second on one core, that is

4,800 ÷ 10⁸ s⁻¹ ≈ 48 µs per scan

against the 100 ms the 10 Hz lidar gives you between scans — a headroom of over 2,000×. That is why the naive grid update ships unmodified: the whole map fits in cache and the per-scan work is microseconds, so the perception engineer spends the millisecond budget on the semantic layers of Chapter 3, not on the grid. Order-of-magnitude first; it tells you where the time actually goes.

DEBUG — the failure that carves a corridor through a forklift

Here is the failure that teaches the most about this whole pipeline, because it hides in the seam between localization and perception rather than in either alone.

The bug. Every scan is integrated at a poseintegrate_beam takes pose as its first real argument. If the pose used to place a scan is stale by one control cycle (the mapper grabbed last cycle’s pose while the robot kept moving), every free-cell ray-march starts from the wrong origin. The beams that should have stopped at the forklift are now marched as free straight through it — because from the stale pose, the geometry says the forklift is somewhere else.

The observable symptom. The occupancy grid shows a phantom free corridor punched clean through a solid obstacle: the aisle the planner is about to route through looks drivable, and the forklift looks like it has a hole in it.

The revealing metric. Don’t eyeball the picture — watch the log-odds at the true obstacle cell over successive scans. In a healthy map it climbs past +2 within a couple of hits (recall from Chapter 1’s numbers: two consistent hits reach ℓ ≈ 2.2, p ≈ 0.9). Under the stale-pose bug that cell’s log-odds stays pinned near 0 — it is being carved free by mis-placed beams as fast as it is being hit — while a neighbouring empty cell mysteriously accumulates the occupied evidence that belonged to the forklift. A cell that should be past +2 sitting at 0 is the fingerprint of a pose/scan timestamp mismatch, not a sensor fault.

The transferable tell: a solid obstacle that will not accumulate occupancy while an adjacent cell picks up phantom hits ⇒ the map is being built at the wrong pose. Suspect the time-sync between the localizer and the scan, not the lidar. (Time and sync is Lesson 2; it comes back to bite here.)

FRONTIER — where this idea is today

The log-odds occupancy update you just watched is not a historical curiosity — it is the core of the map every modern robot carries. The 3-D generalisation that made it practical at scale is OctoMap (Hornung et al., 2013), which stores the same per-cell log-odds occupancy in an octree so empty space costs almost nothing to represent. The surface-reconstruction cousin we preview in Chapter 2 — fusing depth into a truncated signed-distance field instead of a binary grid — traces to KinectFusion (Newcombe et al., 2011). Same spine, richer cell: from “is this occupied?” to “how far to the nearest surface?”

A perfect localizer reports the robot’s pose with zero error. Why is that still not enough to plan a path to the loading dock?

Chapter 1: Occupancy Grids & Costmaps

Chop the floor into a grid of little squares — say 5 cm on a side. Give each square one number: the probability that it is occupied. That is the whole idea of an occupancy grid: a raster of the world where every cell holds P(cell is occupied), and free space, walls and the unexplored beyond are all just different values of that one number.

The genius is not the grid — it is the update. Every scan gives you weak, noisy evidence about many cells at once. We need a rule that fuses one more scan into the current belief without ever revisiting the old scans. That rule is a recursive Bayes filter, and when you write it in the right variable it collapses to something you can do with a running sum.

Derive the update: Bayes, then a change of variable

Fix one cell. Let m be the binary event “this cell is occupied”, and let z1:t be all the measurements so far. We want the posterior p(m | z1:t). Apply Bayes’ rule to the newest measurement zt, and assume measurements are conditionally independent given the map (the standard occupancy assumption):

p(m | z1:t) = p(zt | m) · p(m | z1:t−1)p(zt | z1:t−1)

Here p(zt | m) is the forward sensor model and p(m | z1:t−1) is the belief we already carried. The trouble is the denominator: it needs a marginal over m, so this does not chain cleanly. The trick is to write the same equation for the complementary event ¬m (“cell is free”) and divide the two. The nasty normaliser p(zt | z1:t−1) is identical in both and cancels:

p(m | z1:t)p(¬m | z1:t) = p(zt | m)p(zt | ¬m) · p(m | z1:t−1)p(¬m | z1:t−1)

That ratio p / (1−p) is the odds of occupancy. So the equation says: new odds = (a likelihood ratio from this measurement) × (old odds). It chains — but it is still a product, and products are awkward. Take the logarithm of both sides. The product becomes a sum, and we define the log-odds:

ℓ(m) ≡ log p(m)1−p(m),    ℓt = ℓt−1 + log p(zt | m)p(zt | ¬m)

The update is now a single addition. The measurement contributes a fixed increment — the inverse-sensor-model log-odds, written ℓocc when the beam says “hit here” and ℓfree when the beam passes through. In practice we do not model p(z|m) explicitly; we just pick two increments directly, which is the inverse sensor model.

Why log-odds is the right variable. Three reasons, all load-bearing. (1) The update is addition, so integrating a scan is a scatter-add over cells — branch-free and cache-friendly. (2) It is symmetric and unbounded: p = 0 and p = 1 are ±∞, so no measurement can ever push a cell to a hard 0 or 1 that later evidence cannot undo. (3) Order does not matter — addition commutes — so you can integrate scans in any order and get the same map.

A hand-worked example: watch a cell converge

Pick the two increments. A common lidar choice: a beam that reports the cell occupied carries phit = 0.7, and a beam passing through reports pmiss = 0.4. Convert each to log-odds with natural logs:

occ = log 0.70.3 = log(2.333) = +0.847
free = log 0.40.6 = log(0.667) = −0.405

Start a cell at the prior p = 0.5, so ℓ0 = log(1) = 0 (a cell we know nothing about). Now hit it three scans in a row. Each scan adds ℓocc = 0.847:

Scanℓ = ℓ + 0.847p = 1 − 1/(1+e)
start0.0000.500
hit 10.8470.700
hit 21.6950.845
hit 32.5420.927

Three consistent hits and the cell is 93% occupied. Notice the belief climbs but with diminishing steps — that is the log-odds saturation doing its job. Now suppose the same cell, having reached 5 hits (ℓ = 4.237, p = 0.986), gets two spurious misses because a person walked through the beam. Each miss subtracts 0.405:

ℓ = 4.237 − 0.405 − 0.405 = 3.426 ⇒ p = 0.969

The cell barely flinches. Five real hits built a wall of belief that two stray misses cannot knock down — and that robustness came for free from summing evidence. This is the entire reason occupancy grids work in a world full of transient noise.

Sanity check on the asymmetry. One hit plus one miss is not a wash: 0.847 − 0.405 = +0.442, which is p = 0.609. Because phit and pmiss are not symmetric about 0.5, a hit is “louder” than a miss. That is a design choice — you trust hits more than passes — and it is why a cell that has ever been hit tends to stay leaning occupied.
One cell, watched over 40 scans

A single cell whose true state is occupied. Each scan reports a hit with probability (1 − noise) and a false miss otherwise. Drag the noise up and watch the belief flicker; the clamp caps how far a run of one kind of evidence can push ℓ, trading responsiveness for stability.

sensor noise (false-miss rate) 0.10
log-odds clamp ± 8.0

DESIGN: real numbers for a real grid

Occupancy is not a toy. Here is what a warehouse robot actually carries. A local costmap of 10 m × 10 m at 5 cm resolution is a 200 × 200 grid — 40,000 cells. Store ℓ as a single byte (a fixed-point log-odds), and that is 40 KB, small enough to live in L2 cache and update in well under a millisecond.

QuantityTypical valueWhy it matters
Resolution5 cm (indoor), 10–20 cm (outdoor)Finer = more cells = O(n²) memory. Below the robot’s footprint precision is wasted.
Local costmap~4–10 m square, robot-centred, rollingScrolls with the robot; old cells fall off the edge. Bounded memory forever.
Update rate5–20 Hz (matches the lidar)Each update is a raycast per beam + a scatter-add. Must finish before the next scan.
Cost per beamO(range / resolution) cellsA 10 m beam at 5 cm touches ~200 cells. 1,000 beams ⇒ ~200k cell writes per scan.
Byte per cell1 (log-odds) or a cost 0–254Fixed-point keeps the whole map in cache; float would triple the footprint.

In the ROS nav2 stack this object is costmap_2d, and it is built in layers: a static layer (the pre-built map), an obstacle layer (live lidar via exactly the log-odds update above), and an inflation layer.

From occupancy to cost: the inflation layer

A planner cannot treat the robot as a point; it has width. If you route a 60 cm robot through a 55 cm gap, the map says “free” and the robot scrapes the wall. The fix is to inflate obstacles: every occupied cell radiates a decaying cost outward to a radius, so the cost of a cell is high next to a wall and fades to zero far from it. A common form is an exponential falloff beyond the robot’s inscribed radius ri:

cost(d) = (costmax − 1) · e−α(d − ri),   for d > ri

where d is the distance from the nearest obstacle and α controls how fast the cost decays. Inside ri the cost is lethal (the robot centre cannot be there). The planner then just plans through low-cost cells, and staying in low cost automatically keeps clearance — the geometry of the robot is baked into the map, so the planner can stay a point.

Inflation is where planning safety lives. Set α too high and the robot hugs walls (cost drops off fast, so grazing an obstacle is cheap); too low and it refuses to enter any corridor narrower than its comfort zone, even when the gap is genuinely passable. Tuning inflation is tuning the robot’s courage.
Inflation: turning an occupied cell into a cost field

A little scene with two walls and a gap. The dark band is lethal cost (robot centre forbidden); the glow is the inflated cost that fades with distance. Raise α and the safe corridor widens or narrows — watch whether the gap in the middle stays passable.

inflation decay α 1.5
robot inscribed radius (cells) 1.5

CODE: the update from scratch, then the library form

The from-scratch version is startlingly short, because the derivation did all the work. Every cell along a beam is free until the endpoint; the endpoint is occupied.

python # from-scratch: integrate one range beam into a log-odds grid
import numpy as np

L_OCC  = np.log(0.7 / 0.3)   # +0.847  a hit
L_FREE = np.log(0.4 / 0.6)   # -0.405  a pass-through
L_MIN, L_MAX = -8.0, 8.0       # clamp: keep beliefs reversible

def integrate_beam(grid, cells_free, hit_cell):
    for (cx, cy) in cells_free:          # cells the ray passed through
        grid[cy, cx] = np.clip(grid[cy, cx] + L_FREE, L_MIN, L_MAX)
    if hit_cell is not None:            # the endpoint the beam struck
        hx, hy = hit_cell
        grid[hy, hx] = np.clip(grid[hy, hx] + L_OCC, L_MIN, L_MAX)

def to_prob(grid):
    return 1 - 1 / (1 + np.exp(grid))  # log-odds -> probability

The library form (ROS nav2) hides the loop but is doing exactly this: the obstacle layer raytraces each beam, marks free cells, marks the hit, and clamps — the same three lines, in C++, over a rolling window.

yaml # nav2 costmap_2d obstacle layer config (the log-odds is inside)
obstacle_layer:
  plugin: "nav2_costmap_2d::ObstacleLayer"
  observation_sources: scan
  scan:
    topic: /scan
    max_obstacle_height: 2.0
    clearing: true          # raytrace free space (the L_FREE pass)
    marking: true           # mark the hit cell (the L_OCC pass)
    obstacle_max_range: 2.5

DEBUG: the map fills with phantom walls

Symptom. The robot maps a clean hallway, but a diagonal streak of “occupied” cells appears in the middle of open floor, and the planner refuses to cross it. Nothing is physically there.

Cause. The classic culprit is a missing clearing pass combined with a moving object: a person walked through, the lidar hit them, cells got the ℓocc increment — and then the person left, but nothing ever subtracts it back. Without ray-clearing (marking every cell along the beam as free), occupancy is monotone: it only ever goes up. Transient hits become permanent phantom walls.

The metric that reveals it. Log the ratio of marked cells to cleared cells per scan. A healthy scan clears far more than it marks (a 10 m beam clears ~200 cells and marks 1). If your clear:mark ratio is near 1, clearing is broken, and phantoms are inevitable. The fix is to ensure every beam runs the free pass (the clearing: true flag above) — and to clamp ℓ so even a legitimate wall can be un-learned when the beam later passes through where it used to be.

The deeper lesson: the clamp is not a numerical nicety. Without ℓmin/ℓmax, a wall observed 500 times reaches ℓ = 400, and un-learning it (say it was a temporary pallet, now moved) would take 1,000 clearing passes. Clamping at ±8 caps the “stubbornness” of any cell, so the map can adapt to a changing world in bounded time.

FRONTIER: the grid is old, and still winning

The log-odds occupancy grid dates to Elfes and Moravec’s work in the mid-1980s (Elfes, “Occupancy Grids: A Probabilistic Framework for Robot Perception and Navigation”, 1989). It is one of the oldest ideas in robotics still in daily production use, which is remarkable. But the frontier is alive: OctoMap (Hornung et al., Autonomous Robots, 2013) stores the same log-odds values in an octree, collapsing large uniform regions into single nodes so a whole-building 3-D map fits in megabytes. And the current wave replaces the fixed inverse-sensor model with a learned one: networks that predict occupancy (and even completion of unseen space) from raw sensor data — e.g. occupancy-prediction heads in autonomous-driving stacks (the “occupancy networks” line, Tesla’s occupancy grid at CVPR 2022; and academic work such as OccNet, 2023). The representation survives; the model that feeds it keeps getting smarter.

Why do occupancy grids store log-odds rather than probabilities directly?

Chapter 2: Voxels & the TSDF

A ground robot can live on a flat grid. A drone cannot — it needs to know there is a beam over its head and clear air below. A manipulator reaching into a bin needs the exact surface of the parts, not just “there is stuff here.” So we go up a dimension.

The obvious move is to make the grid 3-D: a voxel grid, each cell a little cube holding a log-odds occupancy. The update from Chapter 1 carries over unchanged — a beam clears the cubes it passes through and marks the cube it hits. And it works. But it has a flaw that gets worse as you demand more precision.

Why a hard voxel grid is not enough

Occupancy is binary in spirit: a cube is occupied or free. But a surface is not aligned to your voxel lattice. When a wall runs diagonally through a 5 cm cube, the cube is “half occupied,” and the grid rounds it to fully occupied — so a smooth wall becomes a jagged staircase of cubes, accurate only to the voxel size. To halve the error you must halve the voxel, which octuples the memory. Precision and memory fight, and precision loses.

The core idea of the TSDF: instead of asking “is this voxel occupied?”, ask “how far is this voxel from the nearest surface, and on which side?” That turns a binary label into a smooth signed number — and a smooth field can pin a surface to sub-voxel precision, because the surface is exactly where the field crosses zero.

Deriving the TSDF from a depth measurement

A TSDFTruncated Signed Distance Function — stores in each voxel a value D(v):

Here is how one depth pixel updates it. The camera sits at a known pose and a pixel measures depth d along a ray. For a voxel v at range r from the camera along that same ray, the signed distance to the surface the pixel found is simply:

sdf(v) = d − r

Read it: if the voxel is closer than the measured depth (r < d), it is in front of the surface, sdf > 0, free space. If it is farther (r > d), it is behind the surface, sdf < 0, occluded. If r = d, the voxel is exactly on the surface, sdf = 0. Then truncate and normalise to [−1, 1]:

D(v) = clamp( d − rμ, −1, +1 )

Fusion: many noisy depths, one clean surface

One depth image gives one noisy estimate of D at each voxel. The magic is the fusion rule — a running weighted average (Curless & Levoy, 1996):

Dnew = W·D + w·dW + w,   Wnew = W + w

where D, W are the stored value and weight and d, w are the new observation and its weight. This is just an incremental mean, and its power is worth stating plainly: the zero-crossing of the averaged field lands on the true surface even when no single depth image put it there. Two cameras that each mis-measure the depth — one short, one long — average out to the truth.

A hand-worked example: fusion finds the surface

Work in 1-D so the arithmetic is visible. A true surface sits at x = 5.0 m. Truncation μ = 4.0. Camera A measures depth dA = 4.5 (0.5 m short); camera B measures dB = 5.5 (0.5 m long). Both look along +x from the origin, so a voxel at x has range r = x. Compute D for a few voxels near the surface, equal weights so fusion is a plain average:

voxel xDA = clamp((4.5−x)/4)DB = clamp((5.5−x)/4)fused = ½(DA+DB)
3+0.375+0.625+0.500
4+0.125+0.375+0.250
5−0.125+0.1250.000
6−0.375−0.125−0.250
7−0.625−0.375−0.500

Camera A alone crosses zero at x = 4.5 (its wrong depth). Camera B alone crosses at x = 5.5. But the fused field crosses zero at exactly x = 5.0 — the truth — and it did so with a single average, no iteration. Extract the surface by finding where the field changes sign and interpolating: between two voxels with fused values +0.25 and −0.25 the crossing is exactly halfway. That linear interpolation is what buys the sub-voxel precision a hard grid could never reach.

This is why depth-fusion mapping is smooth. The surface is not a set of occupied cubes; it is the zero-level-set of a continuous field, reconstructed by interpolation. That is the difference between a Minecraft wall and a scanned statue.
TSDF fusion in 1-D: two wrong cameras, one right surface

The true surface is the green line. Camera A (warm) and camera B (blue) each measure a wrong depth; their zero-crossings miss. The fused field (teal) is their weighted average — drag the camera errors and watch its zero-crossing stay glued to the truth, until one camera’s weight dominates.

camera A depth error (m) -0.5
camera A weight share 0.50

DESIGN: what a TSDF costs, and where it runs

The truncation is the whole budget story. A dense TSDF of a 4 m × 4 m × 3 m room at 2 cm voxels is 200 × 200 × 150 = 6 million voxels — but only a thin shell of ±μ around actual surfaces ever holds a non-trivial value. So real systems store it sparsely, in voxel hash tables or octrees, and only the ~1–5% of voxels near a surface exist.

QuantityTypical valueNote
Voxel size1–4 cm (indoor scanning), 5–20 cm (mapping)Sub-voxel interpolation makes 4 cm behave like ~1 cm of surface precision.
Truncation μ3–5 voxelsToo small: noise punches through the shell. Too big: distant surfaces bleed together.
StorageVoxel hashing / octree, only near-surface voxelsTurns O(volume) into O(surface area).
UpdatePer depth frame: one weighted-average write per voxel in each frustumEmbarrassingly parallel — the reason it lives on the GPU.
Surface extractionMarching Cubes on the zero-crossingRun on demand for a mesh; the planner can query D directly without meshing.

To turn the field into a triangle mesh you run Marching Cubes (Lorensen & Cline, 1987): march through the voxels, and wherever the eight corners of a cube straddle zero, emit triangles interpolated to the crossings. The mesh is the surface at sub-voxel resolution, ready for rendering or collision-checking.

DEBUG: the reconstructed wall is doubled or blurred

Symptom. You scan a flat wall and the mesh comes out as two parallel surfaces a few centimetres apart, or as a thick fuzzy slab instead of a clean plane.

Cause. Almost always a pose error, not a TSDF error. If the camera-to-world pose drifts by a few centimetres between two frames, the two depth images write their surfaces at slightly different world positions, and the fused zero-crossing sits between them — a ghost. The TSDF faithfully averaged two inconsistent inputs. Garbage poses in, blurred surface out. (A too-small truncation μ can also cause it: if μ is smaller than the pose jitter, the two shells do not overlap and you literally get two surfaces.)

The metric that reveals it. Track the average voxel weight at the zero-crossing versus the gradient magnitude |∇D| there. A healthy TSDF has |∇D| ≈ 1/μ at the surface (the field passes cleanly from +1 to −1 over 2μ). A blurred or doubled surface shows |∇D| well below 1/μ — the field is mushy because inconsistent observations flattened it. If the gradient is fine but you see two crossings, it is pose drift; tighten the front-end (Chapter 4) or reduce the frames fused per surface.

FRONTIER: KinectFusion and its descendants

The TSDF went from a graphics technique to a robotics workhorse with KinectFusion (Newcombe et al., ISMAR 2011): a depth camera, a GPU, real-time dense fusion and tracking against the very surface being built. Its descendants define modern dense mapping. Voxblox (Oleynikova et al., IROS 2017) added incremental Euclidean signed distance extraction for planning (so a planner can read clearance directly off the field) and voxel hashing for scale. nvblox (NVIDIA, 2022–) is the GPU-native, real-time descendant used on robots today. And the neural wave — iSDF (Ortiz et al., RSS 2022) and the NeRF/neural-SDF line — replaces the explicit voxel field with a small network that is the signed-distance function, trained online. The signed-distance idea proved so good that even the deep-learning era kept it — it just moved the field from a voxel array into a network’s weights.

Why can a TSDF recover a surface to sub-voxel precision, while a hard occupancy grid cannot?

Chapter 3: Detect, Segment, Track

The occupancy grid tells the planner a cell is occupied. It does not tell it whether that cell is a wall it should route around forever, or a forklift that will drive off in ten seconds and should be predicted, not inflated. Both are “occupied.” The difference is semantics, and semantics come from three tasks that a robotics engineer must reason about even when a vendor library does the heavy lifting.

This chapter is deliberately “enough to reason about them.” We are not training a detector; we are learning the three quantities you must be able to define and defend when a perception result feeds a plan: the box overlap metric (IoU), the filter that removes duplicate detections (NMS), and the association rule that keeps a track alive across frames (a gate).

Detection: boxes, and the metric that scores them

Object detection outputs, per frame, a set of boxes each with a class and a confidence: “forklift, 0.92, at pixels (120, 80)–(260, 300).” Every downstream decision — is this the same forklift as last frame? is it close enough to yield to? — rests on one geometric primitive: how much do two boxes overlap? That is Intersection over Union (IoU):

IoU(A, B) = area(A ∩ B)area(A ∪ B) = area(A ∩ B)area(A) + area(B) − area(A ∩ B)

IoU is 0 for disjoint boxes and 1 for identical ones. Work an example by hand. Box A spans pixels (10,10) to (60,60); box B spans (30,30) to (80,80). The intersection is the overlap rectangle, from the max of the corners to the min:

A ∩ B: x from max(10,30)=30 to min(60,80)=60; y from 30 to 60 ⇒ 30 × 30 = 900
area(A) = 50×50 = 2500,  area(B) = 2500,  union = 2500 + 2500 − 900 = 4100
IoU = 900 / 4100 = 0.220

Two boxes overlapping by 22% — below the usual 0.5 threshold, so a detector would call these different objects. That single number decides duplicate-vs-distinct, match-vs-new-track, and correct-vs-false in the evaluation. Everything hinges on it.

Why union? Deriving the denominator from scratch

The formula has a subtlety worth pausing on: why divide by the union and not, say, by the smaller box, or by the average area? Because the union is the only denominator that makes IoU a true, symmetric fraction of the combined footprint — it counts every pixel either box touches exactly once, and asks what share of that combined region both boxes agree on. Divide by the smaller box instead and a tiny box fully inside a huge one scores 1.0 (“perfect match”) even though the boxes describe wildly different objects; the union punishes that size mismatch because the big box inflates the denominator. Union is what makes IoU scale-honest.

And the denominator is not a second thing to measure — it is derived from the two areas and the one intersection you already have, by inclusion–exclusion:

area(A ∪ B) = area(A) + area(B) − area(A ∩ B)

Read it as counting pixels: add up A’s pixels and B’s pixels, and every pixel in the overlap got counted twice — once in A, once in B — so subtract one copy of the intersection to count it once. That is the whole identity. Let us verify it numerically on a second, small pair where we can literally count every cell. Put box A over grid cells (columns 0–3) × (rows 0–2) — a 4×3 block, 12 cells — and box B over (columns 2–5) × (rows 1–3), also 12 cells:

A ∩ B: x from max(0,2)=2 to min(4,6)=4; y from max(0,1)=1 to min(3,4)=3 ⇒ 2 × 2 = 4
by the identity: union = 12 + 12 − 4 = 20

Now check it the honest way — by directly counting the distinct cells the two boxes cover. A alone has 12 cells; B alone has 12; but 4 of them are shared, so the number of distinct cells is 12 + 12 − 4 = 20 — the same 20. The identity is not an approximation; it is exact cell-counting. So IoU(A,B) = 4/20 = 0.200. Two independent pairs, two hand computations — the metric is now something you can produce cold at a whiteboard, denominator and all.

The one bug everyone writes. When the boxes do not overlap, min(x-corners) − max(x-corners) goes negative. If you forget to clamp it to zero, a negative width times a negative height gives a spurious positive intersection — two disjoint boxes report overlap. Always max(0, x1−x0) × max(0, y1−y0). That single max(0, ·) is the difference between a correct IoU and a silent, plausible-looking wrong one.

NMS: killing the duplicates

A detector fires many overlapping boxes on the same object — a cluster of slightly different guesses. Non-Maximum Suppression (NMS) prunes them: sort by confidence, keep the highest, delete every remaining box whose IoU with it exceeds a threshold (say 0.5), repeat. What survives is one box per object.

python # greedy NMS from scratch
def iou(a, b):
    x0, y0 = max(a[0], b[0]), max(a[1], b[1])
    x1, y1 = min(a[2], b[2]), min(a[3], b[3])
    inter = max(0, x1 - x0) * max(0, y1 - y0)
    ua = (a[2]-a[0])*(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter
    return inter / ua if ua > 0 else 0.0

def nms(boxes, scores, thr=0.5):
    order = sorted(range(len(boxes)), key=lambda i: -scores[i])
    keep = []
    while order:
        i = order.pop(0)                 # highest-confidence survivor
        keep.append(i)
        order = [j for j in order if iou(boxes[i], boxes[j]) < thr]
    return keep

Set thr too low and NMS deletes real neighbours (two people standing close become one); too high and duplicates survive. It is a threshold with real failure modes on both sides.

NMS by hand: four boxes, two passes, watch it prune

Code is not understanding. Trace the algorithm on paper and the “keep the best, kill its duplicates, repeat” loop stops being three words and becomes something you can execute cold. Here are four detections, each a 100×100 box (area 10 000), across two real objects — a forklift on the left, a person on the right — each object fired a strong box and a slightly-shifted duplicate:

boxcorners (x0,y0)–(x1,y1)confidencewhat it really is
A(0,0)–(100,100)0.95forklift — the good box
B(20,0)–(120,100)0.90forklift — a duplicate of A
C(60,0)–(160,100)0.75person — the good box
D(80,0)–(180,100)0.70person — a duplicate of C

First compute the IoU of every pair against the box we are testing, by hand. Every box is 100×100, so each pairwise union is 10 000 + 10 000 − intersection = 20 000 − inter. The overlap of two boxes that share full height (100) and differ only in x-offset is (100 − offset) × 100:

IoU(A,B): offset 20 ⇒ inter = 80×100 = 8000; union = 20000 − 8000 = 12000 ⇒ 8000/12000 = 2/3 ≈ 0.667
IoU(A,C): offset 60 ⇒ inter = 40×100 = 4000; union = 16000 ⇒ 4000/16000 = 1/4 = 0.250
IoU(A,D): offset 80 ⇒ inter = 20×100 = 2000; union = 18000 ⇒ 2000/18000 = 1/9 ≈ 0.111
IoU(C,D): offset 20 ⇒ inter = 80×100 = 8000; union = 12000 ⇒ 8000/12000 = 2/3 ≈ 0.667

Now run the greedy loop with threshold 0.5. Sort by confidence: the order is A (0.95), B (0.90), C (0.75), D (0.70).

Pass 1 — pop A (highest), KEEP it
Test every remaining box against A. IoU(A,B) = 0.667 > 0.5 ⇒ delete B (it is the forklift again). IoU(A,C) = 0.25 < 0.5 ⇒ C survives. IoU(A,D) = 0.111 < 0.5 ⇒ D survives. Survivors going into pass 2: {C, D}.
↓ the list is now [C, D], still sorted by confidence
Pass 2 — pop C (highest left), KEEP it
Test the remainder against C. IoU(C,D) = 0.667 > 0.5 ⇒ delete D (it is the person again). Survivors: {} — the list is empty.
↓ nothing left to pop
Result: KEEP = {A, C}
Four raw boxes → two clean detections: one forklift (A), one person (C). Exactly one box per object, and the survivor of each cluster is its highest-confidence box.

Notice what the two passes bought us. In pass 1 the loop could not have deleted D by comparing it to A — their IoU is only 0.111, far below threshold, because A and D are on different objects that barely touch. D was only killable by C, and C only became the “current best” once A had finished suppressing its own cluster. That is why NMS repeats: suppression is always relative to the current highest-confidence survivor, and each object’s cluster is pruned by that object’s own winner, not by an unrelated neighbour.

NMS live: drag the threshold, watch boxes get suppressed

The four boxes above. Solid = kept, faded = suppressed. Each survivor is labelled with the box it deleted. Slide the IoU threshold: at very low thresholds NMS is aggressive and even the two real objects (A’s and C’s clusters overlap by 0.25) collapse into one; at very high thresholds the duplicates B and D survive. The 0.5 default is the balance.

IoU threshold 0.50

Segmentation: from a box to a mask

Semantic segmentation labels every pixel with a class — road, sidewalk, person — instead of drawing a box. Instance segmentation goes further: it separates the individual objects (this person vs that person) with per-object masks. For a robot, the payoff is precision at the object boundary: a bounding box around an L-shaped pallet claims a big rectangle of free floor as occupied; a mask claims only the pallet. That extra clearance can be the difference between a passable and a blocked aisle.

Segmentation is where perception meets the traversability question: not just “is there stuff?” but “is this stuff drivable?” A grassy verge and a concrete curb may look similar in a depth map; a semantic label tells them apart.

The box lies about free floor: an L-pallet worked by hand

That last claim — “a box claims free floor, a mask does not” — sounds like a slogan until you count the cells. So count them. Put an L-shaped pallet on a 4×4 grid of floor cells (16 cells total). The pallet physically occupies an L: the whole bottom row plus the left column above it. In grid coordinates (x right, y up, origin bottom-left), the occupied cells are:

bottom row: (0,0) (1,0) (2,0) (3,0)   left column: (0,1) (0,2) (0,3)  ⇒  7 cells of real pallet

Now ask a detector for a box. A bounding box is by definition the tightest axis-aligned rectangle that contains every occupied cell. The occupied cells span x from 0 to 3 and y from 0 to 3, so the box is the entire 4×4 grid:

box = (0,0)–(4,4) ⇒ box area = 4 × 4 = 16 cells

The mask claims 7 cells; the box claims 16. The difference is the lie:

free floor the box falsely calls occupied = 16 − 7 = 9 cells

Read that ratio. The box marks 16 cells as impassable; only 7 are. 9 of the 16 — 56% of the box — is drivable floor the box has just walled off. The mask marks exactly the 7 real cells and leaves the other 9 open. In a warehouse aisle exactly one pallet wide, those 9 cells are the difference between the robot squeezing past on the free side of the L and declaring the aisle blocked. The concave notch of an L, a U, or any non-convex object is precisely where a bounding box — which can only ever be a rectangle — is forced to lie, and the mask is the only representation that tells the truth cell-by-cell.

Box vs mask over an L-pallet: the free floor the box steals

The grid is the floor. Teal cells are the real pallet (the mask). The dashed warm rectangle is the bounding box. Every cell inside the box but outside the mask — the hatched red cells — is passable floor the box wrongly blocks. Slide the pallet’s notch depth: a deeper notch (a more concave L) makes the box lie about more floor, while the mask stays exact. At notch = 0 the pallet is a full rectangle and box = mask (no lie).

notch depth (how concave the L is) 3

Scoring a mask: IoU still, but over pixels

How do you tell a good mask from a bad one? The same IoU you already know, but the sets are now the pixels labelled foreground, not box corners. Mask IoU = |predicted ∩ ground‑truth| / |predicted ∪ ground‑truth|, counted per pixel. Because a mask can be any shape, this rewards getting the boundary right, which a box-IoU cannot even represent — two very different masks can share the identical bounding box and thus identical box-IoU. Mask IoU (the mIoU averaged over classes is the standard segmentation benchmark number) is the metric that finally distinguishes “roughly here” from “exactly this silhouette.”

And there is a second reason a robot prefers the mask: the box-vs-mask gap we just counted is lost clearance, so the mask’s free-floor recovery can be scored directly — the fraction of the box that the mask reclaims as drivable. For the L-pallet that fraction is 9/16 = 0.56. Let us implement exactly that computation from scratch.

DEBUG: the mask has ragged, flickering edges

Symptom. The segmentation of a static pallet is stable in the middle but its boundary shimmers frame to frame — a fringe of pixels flips between pallet and floor — and the costmap built from it grows and shrinks a cell-wide halo around the object every frame, so the planner keeps nudging its path.

Cause. Boundary uncertainty. A segmentation net outputs a per-pixel probability, and the mask is that probability thresholded (usually at 0.5). Pixels right on the object edge sit near 0.5, so tiny per-frame noise flips them across the threshold. It is the segmentation twin of the ID-switch problem: the interior is confident and stable, the boundary is a coin-toss. The box does not have this failure — it has the opposite one (it is stable but wrong by 9 cells).

The metric that reveals it. Track boundary IoU (mask IoU restricted to a thin band around the contour) separately from full-mask IoU. If full-mask IoU is high and steady but boundary IoU is low and noisy frame-to-frame, the interior is fine and only the edge is unstable — point the fix at the boundary: hysteresis (two thresholds, one to turn a pixel on, a lower one to keep it on) or temporal smoothing of the probability map before thresholding, exactly as a Schmitt trigger de-bounces a noisy signal.

Tracking: identity through time, and the association gate

A detector is memoryless — it re-detects from scratch every frame and hands you an unordered bag of boxes. Multi-object tracking adds the missing thing: identity. It answers “the forklift I saw last frame — which of this frame’s boxes is it?” so you can estimate its velocity and predict where it is going.

The heart of tracking is data association: matching this frame’s detections to existing tracks. The clean, classical rule uses a motion filter (a constant-velocity Kalman filter per track — see the uncertainty lesson for the covariance machinery). Each track predicts where its object should appear this frame, with a covariance P describing the uncertainty of that prediction. A detection z is a candidate match only if it falls inside the prediction’s validation gate — measured by the squared Mahalanobis distance:

d² = (z − ẑ)T P−1 (z − ẑ) < γ

where ẑ is the predicted position and γ is a chi-squared threshold. For a 2-D position at 99% confidence, γ = χ²2(0.99) = 9.21. Work it: a track predicts a detection at (5, 5) with P = diag(1, 1). Two detections arrive:

detectionresidual (z − ẑ)d² = rTP−1rgate (9.21)
d1 at (5.5, 5.2)(0.5, 0.2)0.25 + 0.04 = 0.29PASS — a match
d2 at (9, 9)(4, 4)16 + 16 = 32.0REJECT — too far

d1 is well inside the gate (0.29 « 9.21) — it is the forklift. d2 is far outside (32 > 9.21) — a different object, which will start its own track. The gate is what stops the tracker from teleporting a track onto an unrelated detection across the frame. Among all detections that pass a track’s gate, the assignment is resolved globally (the Hungarian algorithm minimises total Mahalanobis cost), but the gate is the primitive.

The validation gate: which detections can be this track?

A track predicts a position (teal cross) with a covariance ellipse. Detections are the dots — green if they pass the Mahalanobis gate, red if they are rejected. Stretch the two principal axes (σmajor, σminor) and rotate the covariance (θ); watch the gate become an oriented ellipse, not a circle, so a detection along the uncertain (long) axis is forgiven while the same Euclidean distance across the certain (short) axis is rejected. Concretely, with σmajor = 4, σminor = 1 and γ = 9.21, a residual of 3.5 units along the long axis gives d² = 3.5²/4² = 0.77 (a match), while the same 3.5 units across the short axis gives d² = 3.5²/1² = 12.25 (rejected). Rotate θ and that forgiveness rotates with the ellipse.

major-axis uncertainty σmajor 2.5
minor-axis uncertainty σminor 1.0
rotation θ (radians) 0.50
gate γ (chi-squared) 9.21

DESIGN: the perception stack’s real budget

StageRate / latencyOutput
Detector (e.g. YOLO-class)30–60 Hz on a GPU; 5–15 Hz on an embedded NPU~10–100 boxes/frame + class + score
NMS< 1 ms1 box/object
Tracker (predict + associate + update)> 100 Hz (it is cheap Kalman + Hungarian on < 100 objects)Stable track IDs + velocities
End-to-end perception latency budgetOften < 100 ms to be useful for controlThe detector dominates it.

Note the asymmetry: the detector is the expensive, GPU-hungry stage; the tracker is nearly free. That is why production stacks run a heavy detector at a modest rate and a light tracker fast, using the tracker’s motion model to interpolate object positions between detections.

DEBUG: the track ID keeps flipping

Symptom. A single pedestrian walks across the frame and the tracker assigns them ID 4, then 4 vanishes and ID 9 appears on the same person, then 12. Downstream, the velocity estimate resets every time, and the planner keeps re-reacting to a “new” obstacle.

Cause. An ID switch, and the usual driver is a detection gap. If the detector misses the person for a few frames (occlusion, motion blur), the track’s prediction coasts on its motion model, its covariance P inflates each missed frame, and when a detection reappears it may fall outside the (still finite) gate — so the old track is killed and a new one is born. The gate that protects you from wrong matches also, if the object is lost too long, prevents the right re-match.

The metric that reveals it. The standard MOT metric is exactly this: ID switches (IDS), and the composite MOTA = 1 − (FN + FP + IDS)/GT. A rising IDS count with stable FN/FP points straight at association, not detection. The fix is a longer track “coast” tolerance (keep a track alive through N missed frames) plus an appearance descriptor so re-identification does not rely on position alone — which is precisely the leap the frontier made.

FRONTIER: from motion-only to motion-plus-appearance

Classical tracking-by-detection is SORT (Bewley et al., ICIP 2016): a Kalman filter per track and the Hungarian algorithm on IoU — fast, and exactly the machinery above. Its weakness is ID switches through occlusion, which DeepSORT (Wojke et al., ICIP 2017) fixed by adding a learned appearance embedding to the association cost, so a re-appearing object is matched by looks, not just position. ByteTrack (Zhang et al., ECCV 2022) squeezed more out of the pipeline by associating even low-confidence detections in a second pass, recovering objects the first pass would have dropped. And detection itself moved to transformers — DETR (Carion et al., ECCV 2020) reframed detection as set prediction and made NMS optional, since the model learns not to emit duplicates. The metric IoU, though, still scores all of them.

A tracker predicts an object at (5,5) with covariance P = diag(1,1). Why does a detection at (5.5, 5.2) match the track while a detection at (9,9) does not?

Chapter 4: The Handoff to Planning

We have a pose (from localization), a costmap (Chapter 1), maybe a 3-D map (Chapter 2), and a set of tracked dynamic objects (Chapter 3). Now the question that gets glossed over in every block diagram: where, exactly, do these become the input a planner consumes? Draw the seam wrong and the robot plans against a map that is a beat behind the world, or in the wrong coordinate frame — and it drives into a wall while every module reports healthy.

The handoff is a coordinate-frame contract

The single most important fact about the handoff: the costmap and the plan must live in a consistent, well-defined frame, and the pose is what places the robot inside that frame. The standard robotics frame tree (REP-105) is worth memorising because the handoff is this tree:

map
Global, fixed, drift-free-but-jumpy. Loop closures snap it. Planning happens here.
↓ localization publishes this transform (~1–10 Hz)
odom
Smooth, continuous, drifts slowly. No jumps — safe for control.
↓ odometry publishes this transform (fast, ~50–200 Hz)
base_link
The robot body. Sensors hang off it by fixed calibration transforms.

Why two frames and not one? Because a global pose and a smooth pose are irreconcilable. The map→base_link transform is accurate but jumps when a loop closure corrects drift — you cannot run a wheel controller against something that teleports 20 cm in one tick. The odom→base_link transform is smooth and continuous but slowly drifts. Localization publishes the map→odom correction so that composing the two gives the global pose. Control consumes odom (smooth); planning consumes map (accurate). That split is the handoff.

The rule to carry: the costmap is built and the path is planned in the map frame; the trajectory is tracked in the odom frame. The perception-to-planning handoff is precisely the act of expressing the pose and the obstacles in the frame the planner expects — and every obstacle must carry the timestamp of the pose that placed it.

Deriving the composition: map ← odom ← base_link, by hand

“Composing the two gives the global pose” is a sentence that hides the whole mechanism. Let us do it explicitly, in 2-D, because a ground robot on a floor is exactly an SE(2) problem — a planar position (x, y) and a heading θ. The trick that makes composition a single multiply is the homogeneous transform: pack a rotation and a translation into one 3×3 matrix so that chaining frames becomes chaining matrices.

A pose (x, y, θ) of frame B expressed in frame A becomes the matrix ATB:

ATB = [ cosθ  −sinθ   x ;   sinθ   cosθ   y ;   0   0   1 ]

The top-left 2×2 block is the rotation R(θ); the top-right column is the translation t = (x, y); the bottom row (0, 0, 1) is what lets a translation ride along inside a matrix multiply. A point p = (px, py) in frame B is lifted to (px, py, 1) and transformed by a single matrix–vector product: Ap = ATB · [px, py, 1], which unpacks to the familiar R·p + t. The bottom-row 1 carries the translation; that is the entire point of homogeneous coordinates.

Now the handoff. The frame tree gives us two links, and the global pose is their product:

mapTbase = mapTodom · odomTbase

Read right-to-left, the way transforms chain: first place the robot body inside the smooth odom frame (that is odometry’s fast, drifting estimate), then apply the localization correction that places odom inside the global map frame. The correction is mapTodom — usually small, because good odometry keeps the two frames nearly aligned, and it only grows when localization has drift to undo.

“Grows with drift” is quantifiable, and the numbers tell you when a correction is healthy versus alarming. A decent wheeled odometry drifts on the order of 2% of distance travelled in translation and about 0.1° per metre in heading (integrated wheel slip and yaw-rate bias). Between loop closures that drift accumulates, and the correction mapTodom is exactly what localization must apply to cancel it:

Distance since last loop closureTranslation correction (2%)Heading correction (0.1°/m)
10 m0.20 m1.0°
25 m0.50 m2.5°
50 m1.00 m5.0°
100 m2.00 m10.0°

Now look back at the correction we are about to compose — a (2, 1) offset and 10°. Its translational magnitude is √(22 + 12) = 2.24 m, and its heading is 10°. Read against the table, that is consistent with roughly 100 m of travel since the last loop closure — a large but not pathological correction. This is why the composed pose earlier moved so much when the 10° correction rotated the odometry offset: a 10° correction is not a rounding term, it is a hundred metres of un-cancelled drift, and the R·p+t coupling makes its effect grow with how far ahead the obstacle sits.

The practical upshot for a monitoring dashboard: plot the correction magnitude over time and it should sawtooth — ramping up as drift accrues, then snapping toward zero at each loop closure. A correction that ramps and never resets means loop closures are not firing (the robot is lost but does not know it); a correction that is always near zero on a long traverse means either the odometry is unusually good or, more often, the localizer is silently rejecting fixes and the “drift-free” global pose is a fiction. The single number — magnitude of mapTodom — is one of the cheapest health signals a navigation stack exposes.

Let us grind through concrete numbers so nothing is hidden. Say odometry reports the body at (3, 0) with heading 30° in the odom frame, and localization has published a correction that offsets odom by (2, 1) and rotates it 10° to undo accumulated drift. Write both as matrices (cos 30° = 0.8660, sin 30° = 0.5000; cos 10° = 0.9848, sin 10° = 0.1736):

odomTbase = [ 0.8660  −0.5000   3 ;   0.5000   0.8660   0 ;   0   0   1 ]
mapTodom = [ 0.9848  −0.1736   2 ;   0.1736   0.9848   1 ;   0   0   1 ]

Multiply them. The rotation part composes by adding the angles — 10° + 30° = 40° — so the top-left block is R(40°) with cos 40° = 0.7660, sin 40° = 0.6428. The translation is the interesting part: it is not just (2, 1) + (3, 0), because the correction rotates the odom translation before adding its own. The rule is tmap,base = R(10°) · todom,base + tmap,odom:

tx = 0.9848 · 3 − 0.1736 · 0 + 2 = 2.9544 + 2 = 4.9544
ty = 0.1736 · 3 + 0.9848 · 0 + 1 = 0.5209 + 1 = 1.5209

So the composed global pose of the robot is:

mapTbase = [ 0.7660  −0.6428   4.9544 ;   0.6428   0.7660   1.5209 ;   0   0   1 ]

The robot is at (4.95, 1.52) in the map frame, heading 40°. Notice what the 10° correction did: it did not merely shift the pose, it rotated the odometry offset, nudging x from a naive 3 + 2 = 5 down to 4.95 and lifting y from 1 to 1.52. That coupling — rotation acting on the downstream translation — is exactly why you cannot add poses component-wise and must compose matrices. Get it wrong and the robot’s reported position is off by centimetres that grow with the correction angle.

Proving the rotation block is exact: RR = I, by hand

Before we push a single point through this pose, pause on a claim the rest of the chapter leans on but never shows: that the rotation block R(40°) is orthonormal — its transpose is its inverse, RR = I. Everything downstream (the closed-form inverse baseTmap, the “round-trip returns identity” guarantee) rests on this, so let us not assert it — let us multiply it out. Take R = R(40°) with cos 40° = 0.7660, sin 40° = 0.6428:

R = [ 0.7660  −0.6428 ;   0.6428   0.7660 ]    R = [ 0.7660   0.6428 ;  −0.6428   0.7660 ]

The transpose just flips the two off-diagonal terms across the diagonal — no arithmetic, you copy the matrix and swap the signed corners. Now form RR entry by entry. The top-left entry is row 0 of R dotted with column 0 of R:

(RR)00 = 0.7660·0.7660 + 0.6428·0.6428 = 0.5868 + 0.4132 = 1.0000

That is the Pythagorean identity cos2θ + sin2θ = 1 falling out of a matrix product — the diagonal entries are 1 precisely because sine and cosine live on the unit circle. The off-diagonal entry is row 0 of R dotted with column 1 of R:

(RR)01 = 0.7660·(−0.6428) + 0.6428·0.7660 = −0.4924 + 0.4924 = 0.0000

The two products are equal and opposite — the same ±cos·sin term with flipped signs — so they cancel exactly. By the identical algebra (RR)10 = 0 and (RR)11 = 1, giving:

RR = [ 1.0000   0.0000 ;   0.0000   1.0000 ] = I
This is the number that makes the inverse free. Because RR = I is exact (not approximate — it is two trig identities, not a fit), the inverse of a homogeneous transform never needs a matrix solver: transposing the 2×2 block genuinely un-rotates. That is why the “push a goal back to the controller” section below can write (mapTbase)−1 in closed form, and why sending a point map→base→map returns it unchanged to the last decimal.

Now transform an obstacle. Suppose the lidar, mounted so its point comes out already in the body frame, sees a wall at (4, 0) in base_link — 4 m dead ahead. Apply mapTbase as R·p + t:

Stepx componenty component
R(40°) · (4, 0)0.7660·4 − 0.6428·0 = 3.06420.6428·4 + 0.7660·0 = 2.5712
+ t = (4.9544, 1.5209)3.0642 + 4.9544 = 8.01862.5712 + 1.5209 = 4.0921

The wall the sensor called “4 m ahead” lands at (8.02, 4.09) on the global costmap the planner will read. That single number pair — produced by two matrix multiplies — is the perception-to-planning handoff for one point. Every obstacle the planner ever sees arrived through exactly this arithmetic.

One worry is worth killing before it takes root: did we get the same answer only because we composed the matrices first? What if we had instead carried the point one frame at a time — lift it into odom, then into map? If those disagreed, the whole “compose the tree” shortcut would be a lie. Let us check, because matrix multiplication is associative and this is where you feel it. Path B lifts the sensor point (4, 0) into the odom frame first, using odomTbase = R(30°)·(4,0) + (3,0):

podom = [0.8660·4 + 3,   0.5000·4 + 0] = [3.4641 + 3,   2.0] = (6.4641, 2.0)

Now lift that into map with the correction mapTodom = R(10°)·(6.4641, 2.0) + (2, 1):

px = 0.9848·6.4641 − 0.1736·2.0 + 2 = 6.3659 − 0.3472 + 2 = 8.0186
py = 0.1736·6.4641 + 0.9848·2.0 + 1 = 1.1222 + 1.9697 + 1 = 4.0921
PathWhat it doesResult in map
A: compose then apply(mapTodom · odomTbase) · p(8.0186, 4.0921)
B: apply one frame at a timemapTodom · (odomTbase · p)(8.0186, 4.0921)

Identical to the last decimal — the difference is 2×10−15, pure floating-point dust. That is associativity made physical: it does not matter whether you pre-multiply the transforms into one mapTbase and apply it, or walk the point up the tree link by link. This is why a robot can cache one composed transform per frame and reuse it for a thousand lidar points instead of re-walking the tree for each — the answer is provably the same, so you pay the composition cost once.

Why the multiply order and timestamp are inseparable. The correction mapTodom is the value at the pose’s timestamp. Compose today’s correction with yesterday’s odometry offset and the two rotations no longer describe the same instant — the R·p+t coupling above then places the obstacle at a pose that never existed. That is why the handoff carries a timestamp: the matrices in the product must all be sampled at the same clock tick.

The handoff runs both ways: pushing a goal back to the controller

We just carried an obstacle forward — from the sensor, up through the frame tree, onto the planner’s global costmap. But the seam is bidirectional. The planner finishes its work in the map frame and hands back a goal (a lookahead point on the path, or the next waypoint). The controller does not steer in map; it steers relative to the robot body, in base_link. So the goal must make the inverse trip — map → base_link — before the wheels can chase it. Skip this and the robot turns toward a point defined in the wrong frame, which on a spinning robot means turning the wrong way entirely.

The inverse of a homogeneous transform is not the inverse matrix you would reach for blindly — there is a closed form that never actually inverts anything. For mapTbase = [R   t ; 0   1], the inverse is:

baseTmap = (mapTbase)−1 = [ R   −Rt ;   0   1 ]

Why this works: a rotation matrix is orthonormal, so its inverse is just its transpose, R−1 = R — no arithmetic, you literally flip the 2×2 block across its diagonal. The translation part then follows because undoing “rotate then shift by t” means “shift back by t, then un-rotate,” which lands on −Rt. Applying baseTmap to a map-frame point p is therefore R(p − t): subtract the robot’s position, then un-rotate by its heading.

Grind it with the pose we just built — the robot at t = (4.9544, 1.5209) in map, heading 40°, so R = R(40°) with cos 40° = 0.7660, sin 40° = 0.6428. Suppose the planner returns a goal at (8.0, 4.0) in map — essentially the wall corner the robot detected, a point it must approach. Transpose the rotation (R just swaps the off-diagonal signs), subtract t, and multiply:

p − t = (8.0 − 4.9544,   4.0 − 1.5209) = (3.0456,   2.4791)
ComponentR(p − t) arithmeticResult
goal x (base)cos40·3.0456 + sin40·2.4791 = 0.7660·3.0456 + 0.6428·2.47912.3329 + 1.5937 = 3.9266
goal y (base)−sin40·3.0456 + cos40·2.4791 = −0.6428·3.0456 + 0.7660·2.4791−1.9577 + 1.8990 = −0.0586

The goal lands at (3.93, −0.06) in base_link — and read what that says physically: the point is 3.93 m almost dead ahead of the robot (x-forward), only 6 cm off the centreline. That is a goal a differential-drive controller can act on directly: drive nearly straight, trim a hair right. The forward trip put the wall on the planner’s map at (8.02, 4.09); the inverse trip pulls the planner’s waypoint back onto the robot’s own axes. Same two-multiply machinery, run in reverse — and because RR = I, composing the two round-trips returns the identity, the arithmetic proof that the inverse is exact and nothing was lost crossing the seam twice.

“Returns the identity” is another asserted-but-never-shown claim — so let us make it runnable. Build the closed-form inverse from scratch (transpose R, negate Rt), confirm RR is the identity, then send a point out to base_link and straight back and check it lands on itself to the last decimal:

python # prove the inverse is exact: R^T R = I, and map->base->map is a no-op
import numpy as np

def se2(x, y, theta):
    c, s = np.cos(theta), np.sin(theta)
    return np.array([[c, -s, x], [s, c, y], [0, 0, 1]])

def inv_se2(T):                              # closed form: no solver, just a transpose
    R, t = T[:2, :2], T[:2, 2]
    Ti = np.eye(3)
    Ti[:2, :2] = R.T                       # R^-1 = R^T  (orthonormal)
    Ti[:2, 2]  = -R.T @ t                    # undo the shift, then un-rotate
    return Ti

T_map_base = se2(4.9544, 1.5209, np.radians(40))
R = T_map_base[:2, :2]
print(np.round(R.T @ R, 6))              # [[1. 0.] [0. 1.]]  <- exactly I

T_base_map = inv_se2(T_map_base)
p_map  = np.array([8.0186, 4.0921, 1.0])   # the wall the forward trip produced
p_base = T_base_map @ p_map                  # pull it down to base_link
p_back = T_map_base @ p_base                 # push it straight back up to map
print(np.round(p_base[:2], 4))           # [4. 0.]  <- the original sensor point!
print(np.round(p_back[:2], 4))           # [8.0186 4.0921]  <- back to where it started
print(float(np.max(np.abs(p_back - p_map))))  # 0.0  <- zero drift, exact
The round-trip drift is exactly 0.0. Not “small” — zero, because RR = I is an identity, not an approximation. And notice the middle print: the map-frame wall pulled back into base_link lands on (4, 0) — the very “4 m dead ahead” the sensor originally reported. The seam is lossless in both directions; a goal handed down and an obstacle handed up are the same point viewed from two frames, nothing more.
The seam is a two-way street. Perception pushes obstacles up into map; planning pushes goals down into base_link. The same R·p+t going one way is R(p−t) coming back — and both must use the transform sampled at the same timestamp, or the goal you chase belongs to a pose you have already left.

Rates and latencies: the numbers at the seam

Draw the pipeline with real clocks on it, because the failures at the handoff are almost all timing failures.

ProducerRateConsumerLatency budget
Wheel/IMU odometry (→ odom)50–200 HzLow-level controller< 5 ms — it is the control loop
Localization (→ map→odom)1–10 HzThe frame tree (TF)Can lag; the odom frame covers the gap
Costmap update (lidar → grid)5–20 HzGlobal + local plannerMust beat the next scan
Detector + tracker (dynamic obstacles)10–30 HzLocal planner (as predicted obstacles)< 100 ms to be actionable
Global planner (A*/Dijkstra on the costmap)0.5–2 Hz (on replan)Local planner as a reference pathSeconds OK; it is strategic
Local planner / controller (DWA, TEB, MPC)10–30 HzMotor commands< 50 ms — reactive

Read the structure: a slow, global planner finds a route on the whole costmap; a fast, local planner follows that route while dodging what perception just saw. The dynamic obstacles from Chapter 3 enter at the local planner, as short-horizon predictions (the tracked velocity says where the forklift will be in 1–2 s), so the robot yields to where it is going, not where it was.

Put real ratios on that “slow vs fast” so the widget below is not a hand-wave. Take a 20 Hz controller, a 5 Hz localizer, and a global planner that replans at 1 Hz. Divide the rates and the nesting falls out:

control ticks per global replan = 20 Hz1 Hz = 20    localization fixes per replan = 51 = 5    control ticks per fix = 205 = 4

So inside the one second the global planner spends thinking, the controller has already issued 20 motor commands and localization has snapped the pose 5 times. At 1.5 m/s the robot covers 1.5 m per global replan but only 1.5/20 = 7.5 cm per control tick — which is exactly why control cannot wait for the global planner: in the 1 s it takes to replan, an uncorrected robot would have driven a metre and a half blind. The fast local loop, running on the smooth odom frame, fills that gap 20 commands at a time. This nesting — 20 fast ticks wrapping every slow strategic decision — is precisely what the pulsing blocks in the widget below make visible.

“Where it will be in 1–2 s” is another sentence that hides arithmetic — let us compute an actual predicted cell, because that is what the planner marks lethal. Take the forklift the frontend already placed on the global costmap at (8.02, 4.09) in map (the very point our forward handoff produced). Chapter 3’s tracker attached a velocity to that track: say (−0.6, 0.0) m/s — it is rolling toward the robot along the map’s x-axis at 0.6 m/s. A constant-velocity projection over a horizon of 1.5 s is just p + v·Δt:

v·Δt = (−0.6 · 1.5,   0.0 · 1.5) = (−0.90,   0.00) m
ppred = (8.02, 4.09) + (−0.90, 0.00) = (7.12, 4.09)

Now turn that into a costmap cell, because the planner never sees metres — it sees a grid. At a typical 5 cm resolution (0.05 m/cell) with the grid origin at map (0, 0), the cell index is floor(coord / resolution):

Instantmap coord (m)cell = floor(coord / 0.05) (col, row)
now(8.0186, 4.0921)(floor(160.37), floor(81.84)) = (160, 81)
+1.5 s(7.1186, 4.0921)(floor(142.37), floor(81.84)) = (142, 81)

The prediction shifts the lethal cell by 160 − 142 = 18 columns — exactly 18 × 0.05 = 0.90 m, the distance we said the forklift covers. So the planner does not merely mark cell (160, 81); it inflates the whole swept corridor from column 160 back to 142 along row 81, and routes around where the forklift is heading. And note the update budget the rates table demands: at the 12 Hz costmap rate the forklift advances 0.6 m/s × (1/12 s) = 0.05 m per frame — precisely one cell per update, so a costmap running any slower would let the obstacle skip cells between frames and tear a gap in the swept corridor. The rate in the table above is not arbitrary; it is set by how fast a cell can be crossed.

One more layer: from lethal cell to inflated corridor, by hand

Marking cells (160, 81) through (142, 81) lethal is not yet a costmap a planner can steer on — the robot is not a point, so a path that grazes a lethal cell would clip the obstacle with the robot’s body. The costmap fixes this with an inflation layer: around every lethal cell it grows a halo of decreasing cost, so the planner is repelled before it touches the obstacle. Let us size that halo in cells, because that is what actually gets written to the grid.

The inflation radius is the robot’s footprint radius plus a safety margin. Take a footprint radius of 0.30 m and a 0.10 m margin, so the inflation radius is 0.40 m. At the same 0.05 m/cell resolution that is:

inflation radius = 0.40 m0.05 m/cell = 8 cells

So each lethal cell grows an 8-cell halo. Along row 81 the lethal core already spans 18 columns (142–160); the inflation extends 8 cells past each end, so the corridor the planner must avoid is:

span = 18 + 2·8 = 34 cells = 34 × 0.05 = 1.70 m wide

The 0.90 m swept core has become a 1.70 m no-go corridor — the extra 0.80 m is the robot’s own body plus margin, kept clear on both sides. Now the halo is not a cliff; it decays, so the planner prefers routes that give the obstacle a wide berth but will squeeze closer if it must. nav2’s decay is exponential in the distance d from the nearest lethal cell, past an inscribed radius of 0.20 m:

cost(d) = 252 · exp[ −3.0 · (d − 0.20) ]   (for d > 0.20 m)

Grind the cost at three distances out from the core, converting cells to metres as d = cells × 0.05:

Distance from corecost = 252·exp(−3·(d−0.20))cell cost
4 cells = 0.20 m252·exp(−3·0.00) = 252·1.000253 (inscribed → lethal)
6 cells = 0.30 m252·exp(−3·0.10) = 252·0.741186.7
8 cells = 0.40 m252·exp(−3·0.20) = 252·0.549138.3

The cost falls off smoothly — 253 at the inscribed edge, 187 a cell-and-a-half out, 138 at the full inflation radius. Solve for where it crosses a “prefer to avoid” threshold of 50 and you get d = ln(252/50)/3 + 0.20 = 0.74 m ≈ 15 cells: past there the planner treats the space as essentially free. That gradient is what turns a binary lethal grid into a field the global A* can descend — it does not just avoid the forklift, it leans away from it in proportion to how close the geometry forces it.

The inflation is why the costmap is a costmap and not an occupancy grid. An occupancy grid answers “is this cell blocked?” The inflation layer answers “how much do I want to be here?” — and that continuous answer, computed by the exp decay above around every lethal cell the handoff produced, is precisely the input the planner in the next section consumes. Get the inflation radius wrong (too small) and the planner clips corners with the robot’s shoulder; too large and it refuses gaps it could fit through.

Watch that field actually steer a plan. Global A* sums a cell’s traversal cost (the inflation value) into the path cost, so given two routes of similar length it prefers the one that keeps more clearance. Take two candidate cells the planner weighs: cell P sits 6 cells (0.30 m) from the forklift’s lethal core, cell Q sits 12 cells (0.60 m) out. From the same exp decay:

cost(P) = 252·exp(−3·(0.30 − 0.20)) = 252·0.741 = 186.7
cost(Q) = 252·exp(−3·(0.60 − 0.20)) = 252·0.301 = 75.9

Cell Q is 110.8 cost-units cheaper to traverse. If the planner weights inflation cost at, say, λ = 0.02 metres-of-detour per cost-unit, it will happily accept up to 0.02 × 110.8 = 2.2 m of extra path to route through the roomier Q instead of squeezing past P. That single number — how much detour a clearance is worth — is the entire behavioural difference between a robot that hugs walls and one that gives forklifts a wide berth, and it falls straight out of the exp decay we sized above. Tune the decay steeper and the robot becomes timid; flatten it and it cuts corners.

The pipeline, with the clock running

Each block pulses at its true rate. Watch the fast control loop tick many times per slow global replan — and drag the localization rate down to see the pose age (the gap between the latest pose and now) grow, which is exactly the “planning against a stale map” failure below.

localization rate (Hz) 5.0

CODE (from scratch): compose two SE(2) transforms and move a point

Before reaching for a library, build the handoff by hand so nothing is opaque. The whole operation is: turn each pose (x, y, θ) into a 3×3 homogeneous matrix, @-multiply T_map_odom by T_odom_base to compose the frames, then apply the product to a sensor point as R·p + t. This is the arithmetic we just did by hand — here it is in nine lines of numpy, and it prints the very same (8.02, 4.09):

python # the handoff, from scratch: SE(2) compose + apply, no ROS
import numpy as np

def se2(x, y, theta):                          # pose -> 3x3 homogeneous transform
    c, s = np.cos(theta), np.sin(theta)
    return np.array([[c, -s, x],
                     [s,  c, y],
                     [0, 0, 1]])

T_odom_base = se2(3.0, 0.0, np.radians(30))   # body in the smooth odom frame
T_map_odom  = se2(2.0, 1.0, np.radians(10))   # localization's drift correction

T_map_base  = T_map_odom @ T_odom_base           # COMPOSE: map <- odom <- base

p_base = np.array([4.0, 0.0, 1.0])            # obstacle 4 m ahead, homogeneous
p_map  = T_map_base @ p_base                      # apply: this IS R@p + t

print(np.round(T_map_base, 4))                 # [[0.766 -0.6428 4.9544] ...]
print(np.round(p_map[:2], 4))                # [8.0186 4.0921]  <- planner reads this

# the R@p+t is not magic: the bottom (0,0,1) row of se2() carries the
# translation THROUGH the matmul. Peel it apart to see it plainly:
R, t = T_map_base[:2, :2], T_map_base[:2, 2]
print(np.round(R @ p_base[:2] + t, 4))       # [8.0186 4.0921]  <- identical
Run it and you get exactly the hand-worked answer. The composed matrix is R(40°) with translation (4.9544, 1.5209), and the obstacle the sensor called “4 m ahead” lands at (8.0186, 4.0921) in the planner’s map frame. Nothing was delegated: se2() builds the matrix, @ composes the frames, and the last two prints show the matmul is R·p + t — the same result two ways.

CODE (library form): the same handoff as one TF lookup

In ROS the entire handoff collapses to one transform lookup — the library builds and composes those matrices for you across the whole tree. Take an obstacle observed in the sensor frame at time t, and express it in the planning (map) frame using the transform as it was at time t. Getting the time right is the whole game.

python # the handoff: put a sensor-frame obstacle into the planner's frame
def obstacle_to_map(pt_sensor, stamp, tf_buffer):
    # look up map <- sensor AT THE MEASUREMENT TIME, not "now"
    T = tf_buffer.lookup_transform("map", pt_sensor.frame_id,
                                   stamp)              # <-- stamp is load-bearing
    return do_transform_point(pt_sensor, T)          # now planner can consume it

# the classic bug: using rclpy.time.Time() (== "latest") instead of `stamp`
# composes a fresh pose with an old measurement -> the obstacle lands
# displaced by however far the robot moved during the latency.

What lookup_transform(…, stamp) hides: interpolating between two samples

That one library call looks like a dictionary lookup, but the scan’s stamp almost never lands exactly on a buffered transform. Transforms arrive at discrete ticks — odometry feeds TF at, say, 10 Hz — and the scan is stamped between two of them. So the library does not fetch a stored transform; it interpolates a new one straddling the query time. Understanding that interpolation is the difference between trusting the call and being able to reproduce it when the buffer is empty and you must do it by hand.

The buffer holds two samples of mapTbase: sample A at tA and sample B at tB. The scan is stamped at tscan in between. First compute the interpolation fraction α — how far along the interval the scan falls:

α = (tscan − tA)(tB − tA)

Then blend the two poses at that fraction. Translation is easy — a straight-line lerp (linear interpolation), component by component. Rotation is the subtle part: you cannot lerp two heading numbers naively (359° and 1° would average to 180°, pointing backwards). You take the shortest arc between them — the 2-D case of slerp (spherical linear interpolation) — which is just an angle lerp after wrapping the difference into [−π, π].

That parenthetical — “359° and 1° average to 180°” — is worth grinding out, because it is the single trap that turns a working interpolator into one that snaps the robot’s heading a half-turn backwards for one frame. Suppose sample A is heading 359° and sample B is heading — physically 2° apart, a hair either side of straight-north. The naive lerp at the midpoint α = 0.5 treats these as plain numbers:

θnaive = 359 + 0.5·(1 − 359) = 359 + 0.5·(−358) = 359 − 179 = 180°

The interpolated heading is 180° — pointing due south, the exact opposite of the ~0° the robot is actually facing. For one interpolated tick the entire scan is placed rotated 180°, and every obstacle lands on the wrong side of the robot. The bug is that the raw difference b − a = 1 − 359 = −358° took the long way around the circle instead of the short −2° hop across the 360°/0° seam.

The fix is the wrap formula from the code below, d = (b − a + π) mod 2π − π, which folds any difference into [−180°, 180°] so it always describes the shortest arc. Work it in degrees (using 180° for π, 360° for 2π):

d = (1 − 359 + 180) mod 360 − 180 = (−178) mod 360 − 180 = 182 − 180 = +2°

Now the shortest arc is a clean +2°, and the midpoint becomes θ = 359 + 0.5·2 = 360° ≡ — straight-north, exactly where it should be, instead of 180° backwards. The modulo did the whole job: adding 180° recentres the seam, mod 360 wraps the −178 up to 182, and subtracting 180° brings it back to +2. One line of arithmetic separates a heading that points where the robot looks from one that points where it does not.

The wrap is not optional near the seam. Two samples straddling the 0°/360° boundary is not an edge case — a robot spinning in place crosses it every revolution. Any interpolator that lerps raw heading numbers will, twice per turn, flip the whole scan 180° for a frame. That is a phantom-obstacle burst timed exactly to when the robot pirouettes — the same manoeuvre the DEBUG section flags as most dangerous.

Make it concrete. Sample A at tA = 1.00 s: pose (4.90, 1.50, 38°). Sample B at tB = 1.10 s: pose (5.06, 1.62, 44°). The scan is stamped tscan = 1.06 s. The fraction is:

α = (1.06 − 1.00)(1.10 − 1.00) = 0.060.10 = 0.60

Lerp the translation at α = 0.60 — move 60% of the way from A to B:

x = 4.90 + 0.60·(5.06 − 4.90) = 4.90 + 0.60·0.16 = 4.996
y = 1.50 + 0.60·(1.62 − 1.50) = 1.50 + 0.60·0.12 = 1.572

Slerp the heading. The shortest arc from 38° to 44° is +6° (already inside [−180°, 180°], no wrap needed), so:

θ = 38° + 0.60·(44° − 38°) = 38° + 0.60·6° = 41.6°

The interpolated transform at the scan’s exact stamp is therefore (4.996, 1.572, 41.6°) — and this matrix, not either buffered neighbour, is what places the scan. That case sat comfortably mid-range; now run the same machinery across the seam to prove the wrap earns its place inside the loop. Keep α = 0.60 but make the samples straddle 0°: sample A heading 358°, sample B heading (physically 6° apart, straddling north). The naive heading lerp gives:

θnaive = 358 + 0.60·(4 − 358) = 358 + 0.60·(−354) = 358 − 212.4 = 145.6°

145.6° — the robot is facing roughly north, and the interpolator claims it is facing south-east, off by more than a right angle. Feed the difference through the wrap instead:

d = (4 − 358 + 180) mod 360 − 180 = (−174) mod 360 − 180 = 186 − 180 = +6°
θ = 358 + 0.60·6 = 358 + 3.6 = 361.6° ≡ 1.6°

The wrapped answer is 1.6° — 60% of the way along the short +6° arc from 358°, landing just past north, exactly right. The gap between 1.6° and the naive 145.6° is 144° of pure error, injected on precisely the frames where the robot crosses heading zero. This is why the % (2*np.pi) line in the code below is load-bearing and not decoration: delete it and the interpolator is correct everywhere except the one place a spinning robot visits twice a revolution.

Here is the whole interpolation from scratch, the arithmetic the library performs invisibly on every lookup — the shortest-arc line is the same (b - a + π) % (2π) - π we just ground out by hand:

python # what lookup_transform(..., stamp) does BETWEEN two buffered samples
import numpy as np

def interp_se2(poseA, tA, poseB, tB, t):
    alpha = (t - tA) / (tB - tA)                 # fraction along the interval
    x = poseA[0] + alpha*(poseB[0] - poseA[0])   # lerp translation x
    y = poseA[1] + alpha*(poseB[1] - poseA[1])   # lerp translation y
    d = (poseB[2] - poseA[2] + np.pi) % (2*np.pi) - np.pi  # shortest arc
    theta = poseA[2] + alpha*d                    # slerp (2-D case)
    return (x, y, theta)

A = (4.90, 1.50, np.radians(38))     # buffered sample at t=1.00 s
B = (5.06, 1.62, np.radians(44))     # buffered sample at t=1.10 s
x, y, th = interp_se2(A, 1.00, B, 1.10, 1.06)  # scan stamp between them
print(round(x,3), round(y,3), round(np.degrees(th),1))  # 4.996 1.572 41.6

It is worth pricing out all three ways to answer “what was the pose at t = 1.06 s?” side by side, because the two lazy options both look reasonable in code. Grab the earlier sample A, grab the later sample B, or interpolate — and measure each against the true interpolated heading of 41.6°, converting the heading error to the lateral throw of a 5 m beam (r·tan(err)):

StrategyHeading usedError vs 41.6°Throw of a 5 m beam
snap to nearest-older (A)38.0°3.6°5·tan(3.6°) = 31.5 cm
snap to nearest-newer (B)44.0°2.4°5·tan(2.4°) = 21.0 cm
interpolate at α = 0.6041.6°0.0°0.0 cm

Both snaps land a beam a fifth to a third of a metre off — and note the asymmetry: snapping to the older sample A is worse (31.5 cm) than snapping to the newer B (21.0 cm) here only because α = 0.60 puts the true pose closer to B. Slide the scan stamp earlier and the ranking flips. There is no “safe” sample to grab; the only answer with zero error is the interpolated one. The translation snaps badly too — snapping to A misplaces x by 4.996 − 4.90 = 9.6 cm — but on a lidar the heading error dominates because it multiplies by range.

Why interpolating, not snapping to the nearest sample, matters. Suppose you shrugged and grabbed sample B (44°) instead of interpolating to 41.6°. That is a 2.4° heading error — and a beam 5 m out then swings sideways by 5·tan(2.4°) ≈ 21 cm (the middle row of the table above). The same smear the DEBUG section below chases, produced not by latency but by refusing to interpolate. Getting the fraction α right is as load-bearing as getting the timestamp right.

DEBUG: obstacles smear when the robot turns fast

Symptom. The robot drives straight and the costmap is crisp. But the moment it rotates quickly, walls in the costmap smear into thick arcs, and the local planner suddenly refuses to move, boxed in by phantom obstacles that appear on rotation and clear when it stops.

Cause. A timestamp / transform-timing mismatch at the handoff. The lidar scan is stamped at time t, but the code transforms it into the map frame using the pose at time now (t + latency). During fast rotation the robot’s orientation changed appreciably in that latency, so every beam is placed at the wrong bearing — the whole scan is rotated by the angle the robot turned during the delay, smearing surfaces into arcs. When the robot is still, the latency does not matter; when it spins, it dominates.

The metric that reveals it. Log the age of the transform used for each scan: now − scan.stamp. If smearing correlates with angular velocity × transform-age, it is the timing. Do not take the “24 cm” on faith — derive it. Suppose a 30 ms transform latency while the robot yaws at 90°/s. In that window the robot turns:

Δθ = latency × yaw rate = 0.030 s × 90 °/s = 2.700°

The whole scan is stamped with the old orientation but placed with the new one, so every beam is rotated by that 2.7°. To see how far that throws a point, convert to radians and take the lateral offset at the beam’s range. A point 5 m out swings sideways by range × tan(Δθ):

Δθ = 2.700° × π180 = 0.04712 rad
sideways = 5 m × tan(0.04712) = 5 × 0.04716 = 0.2358 m ≈ 24 cm

Twenty-four centimetres — exactly the width of the smear on the costmap, and far more than the 5–10 cm inflation radius a planner uses, so the phantom arc really does box the robot in. The lateral throw scales with range: the same 2.7° error is only 4.7 cm at 1 m but 47 cm at 10 m, which is why the smear fans out with distance. The fix is to transform using the pose at the scan’s timestamp (a TF lookup at scan.stamp, buffered), never at now.

Turn the single number into a design table. One derivation gives one data point; an engineer needs the whole surface, because the question in review is never “is 30 ms bad?” but “which combinations of turn speed and pipeline latency am I allowed to ship?” Hold the range fixed at 5 m and sweep yaw rate against transform latency — each cell is just Δθ = yaw·latency, then smear = 5·tan(Δθ):

Yaw rateTransform latencyΔθ = yaw × latencySmear @ 5 m
45 °/s (gentle arc)10 ms0.45°5·tan(0.45°) = 3.9 cm
90 °/s (brisk turn)30 ms2.70°5·tan(2.70°) = 23.6 cm
90 °/s (brisk turn)50 ms4.50°5·tan(4.50°) = 39.4 cm
180 °/s (in-place spin)30 ms5.40°5·tan(5.40°) = 47.3 cm

Read it the way you would defend it at a whiteboard. The middle row (23.6 cm, rounding to the 24 cm we derived above) already blows past a 5–10 cm inflation radius. Halving the pipeline to a tighter 10 ms while slowing the turn to 45°/s drops the smear to 3.9 cm — under the inflation, so it hides inside the safety margin and the costmap stays crisp. But let latency slip to 50 ms at a brisk 90°/s and you are at 39 cm; ask for an in-place 180°/s pirouette and even a good 30 ms pipeline smears 47 cm. The lesson the table teaches, that one row cannot: the tolerable latency is a function of how fast you let the robot rotate. A stack that is fine cruising in straight lines can be unshippable the instant it spins in place — which is exactly the manoeuvre (docking, turning in a doorway) where obstacles are closest and a phantom arc is most dangerous. Either cap the yaw rate or buy back the latency; the table tells you the exchange rate.

The smear has two components, not one. We have chased only the rotational smear so far — but during the same latency window the robot also translated, and that adds a second, independent displacement. Keep the 30 ms latency and the 90°/s turn, but now let the robot also creep forward at a docking speed of 0.4 m/s. The translation smear is simply how far the body moved in the window:

translation smear = v × latency = 0.4 m/s × 0.030 s = 0.012 m = 1.2 cm

The crucial difference from the rotational smear: this 1.2 cm is range-independent — the whole scan slides 1.2 cm regardless of how far each beam reaches, because the sensor origin itself moved. The rotational smear, by contrast, is range×tan(Δθ) — it fans out with distance. Tabulate both at the same 2.7° rotation to see where each dominates:

Beam rangeRotational: r·tan(2.7°)Translational: v·LWhich dominates
1 m4.7 cm1.2 cmrotation (4×)
2 m9.4 cm1.2 cmrotation (8×)
5 m23.6 cm1.2 cmrotation (20×)
10 m47.2 cm1.2 cmrotation (39×)

Solve r·tan(2.7°) = v·L for the crossover range and you get r = 0.012 / tan(2.7°) ≈ 0.25 m: beyond a mere quarter-metre the rotational smear already exceeds the translational one, and the gap widens linearly with range. This is why the DEBUG hunt fixates on rotation — for any obstacle worth avoiding (well past 0.25 m out) the turning error swamps the driving error. But the translational 1.2 cm never vanishes; it is the noise floor of the smear, present even when the robot rotates not at all, which is exactly why a pure-translation stack still wants its scans transformed at scan.stamp and not at now.

A second timing number: the stale pose at the seam. The same clock mismatch bites even when the robot drives straight, through the pose rather than the scan. Localization runs slow — say 2 Hz — so between fixes the freshest global pose can be a full update period old:

max pose age = 12 Hz = 0.500 s

If the robot is moving at 1.5 m/s and the code composes an obstacle against that stale map→odom correction, the obstacle is displaced by how far the robot travelled during the staleness:

positional error = speed × pose age = 1.5 m/s × 0.500 s = 0.75 m

Three-quarters of a metre — enough to plant an obstacle on the wrong side of a doorway. This is precisely why the frame tree keeps the smooth odom frame between the slow map correction and the fast controller: odom covers the 500 ms gap continuously, so the pose the robot acts on is never 0.75 m stale even though the global correction is. (Drag the localization-rate slider in the pipeline widget above down to 2 Hz and watch the “pose age” readout climb into the red — that number times your speed is this error.)

This is the single most common perception-to-planning bug in the field, and it is invisible in unit tests because tests rarely rotate fast with realistic latency. It is a seam bug: every module is correct in isolation; the fault lives in how their clocks are joined.

FRONTIER: closing the perception–planning gap

The classical handoff is a hard interface: perception produces a costmap, planning consumes it, and the two are designed separately. The frontier is erasing that seam — and it is being attacked from two directions at once, one engineering, one learned.

The systems thread keeps the interface explicit but industrialises it. nav2 (Macenski et al., Science Robotics / IROS 2020) is the modern, production ROS-2 navigation stack that standardises exactly the pipeline this chapter walked — costmap layers → global/local planners → controllers — and orchestrates it with behaviour trees so the timestamped frame handoff we hand-derived becomes battle-tested plumbing rather than glue code you rewrite per robot. The seam stays visible; the contribution is making it reliable, recoverable, and reconfigurable.

The learning thread dissolves the interface instead of hardening it. Here the seam itself is trained through: “perception-aware planning” systems make the planner differentiable back into perception, so gradients from a bad plan sharpen the representation that fed it. In driving, occupancy-flow and end-to-end stacks — e.g. UniAD (CVPR 2023 best paper) — predict future occupancy and plan on it jointly, so the handoff is a learned latent representation rather than a hand-drawn costmap and there is no discrete grid to hand across at all.

The two threads disagree on whether the seam should be sharpened or erased, but they agree on what cannot be waived. The frame contract — smooth odom vs global map, and timestamps that match — survives every architecture, learned or classical, because it is a statement about physics and clocks, not a design choice you can train away.

Why does a robot maintain both a map frame and an odom frame instead of a single global pose?

Chapter 5: The Mapping Bench

This is the payoff. Drive a robot with a range sensor through a scene and watch an occupancy grid build itself out of nothing but noisy beams. Chapter 1 introduced the pieces; this chapter puts them together and makes each one a knob you can turn, so we re-derive the update here from scratch rather than send you back for it.

An occupancy grid is a lattice of cells, each holding one number: how sure we are that this cell is solid. Storing that as a probability p ∈ [0,1] is a trap — combining a new reading means multiplying likelihoods and renormalising, which is fiddly and drifts numerically. The fix is to store the log-odds l = ln(p/(1−p)) instead. In log-odds, Bayes’ rule for a fresh independent reading collapses to a single addition: l ← l + Δ, where Δ is a fixed increment per hit or per pass. No multiply, no renormalise. To read the belief back out you invert with the sigmoid, p = 1/(1+e−l). A cell at l = 0 is p = 0.5, maximal ignorance; that is why an unseen cell starts at 0.

Why this chapter lives entirely in addition. Every cell’s history is a running sum of ±Δ increments — one per beam that touched it. Positive increments (hits) push it toward “wall”; negative increments (pass-throughs) push it toward “free”. Order does not matter, addition commutes, so the same beams in any order give the same map. That commutativity is the whole reason a robot can integrate thousands of beams a second and never has to remember which came first.

There is no separate quiz section — but answer this one before you touch a slider.

You drop the log-odds clamp from ±8 to ±2 and notice that after a burst of false readings a settled wall recovers to “occupied” faster than it did with the high clamp. Why?
Three things to try, in order. (1) Drive the robot along the corridor and watch the grid fill: teal free space carving out ahead of the beam, warm walls hardening at the hits, dim unknown behind them. (2) Push sensor noise up and watch cells that had settled start to flicker between free and occupied — each false reading fights the accumulated evidence. (3) Drop the log-odds clamp and watch the flicker calm down: a low clamp means no cell can become so stubbornly occupied that a stray beam flips it, but also that a real wall stays only mildly confident.

DESIGN: the numbers this bench actually runs

None of the parameters below are arbitrary, and it is worth surfacing them because in an interview the follow-up to “build an occupancy grid” is always “how many rays, over what fan, at what update size, and why those.” This bench fires 90 rays across a 270° fan (fov = 1.5π radians) out to a 26-cell range. Three numbers, three reasons:

The subtlest parameter is the 0.35-cell clearing step. When a beam clears the free cells in front of a hit, it marches outward in increments of 0.35 cells (for d = 0.3; d < end−0.35; d += 0.35) and floors each sample to a cell index. Why 0.35 and not, say, 1.0? Because a diagonal beam crosses a cell’s corner: sampling in whole-cell steps would skip cells the ray truly passes through, leaving un-cleared holes in the free space (the same gap Bresenham solves exactly). A sub-cell step of 0.35 guarantees every cell along the ray gets sampled at least once. But it also means a single cell can be sampled two or three times by one beam — so we must not add the full free-increment each sample or we would triple-count one beam’s evidence. That is why the clearing pass adds L_FREE · 0.35, a fraction sized to the step: three samples of one beam through one cell sum to roughly one full L_FREE, and the beam is counted once, not thrice. This is the classic double-counting hazard of the inverse-sensor model, handled by scaling the increment to the oversampling rate.

Worked example 1 — the bench’s own log-odds, by hand. The bench sets L_OCC = logodds(0.72) and L_FREE = logodds(0.38). Substitute and turn the crank:
 
L_OCC: odds = 0.72/(1−0.72) = 0.72/0.28 = 2.5714. Then L_OCC = ln(2.5714) = +0.9445. A hit adds +0.9445 to the cell.
L_FREE: odds = 0.38/(1−0.38) = 0.38/0.62 = 0.6129. Then L_FREE = ln(0.6129) = −0.4895. A clean pass-through adds −0.4895 (before the ×0.35 clearing scale).
 
Now integrate three clean hits on one wall cell (no noise). We just add:
l = 0 + 0.9445 + 0.9445 + 0.9445 = 3 × 0.9445 = +2.833.
Convert back to a probability with the sigmoid: p = 1/(1 + e−2.833) = 1/(1 + 0.0588) = 1/1.0588 = 0.9445. After just three hits the cell is 94% sure it is a wall. (The clamp at ±8 never fires here — l = 2.833 is well inside it — so the belief is free to keep climbing with more hits, up to the cap.)
Worked example 2 — what the clamp actually caps. Keep hitting the same wall. Each hit adds +0.9445, so the raw sum after N hits is 0.9445 N. The clamp at ±8 stops that sum at l = 8: it takes N = 8.0/0.9445 = 8.47 hits, so by the ninth clean hit the cell is pinned at l = 8.
 
At the cap, plug l = 8 into the sigmoid: p = 1/(1 + e−8) = 1/(1 + 0.000335) = 0.99966 — a 99.97%-certain wall. Now compare the ±2 clamp: it pins the same cell at l = 2 after only 2.0/0.9445 = 2.12 hits, and p = 1/(1 + e−2) = 1/(1 + 0.1353) = 0.8808 — only 88% sure.
 
That gap is the whole story of the clamp slider. A clamp-8 wall sitting at l = 8 has to absorb 44 erroneous clearing passes (each −0.1713, from L_FREE × 0.35) before it falls below the p = 0.6 “occupied” threshold; a clamp-2 wall at l = 2 falls below it after only about 9. Stubborn vs nimble — and the arithmetic, not intuition, tells you exactly how stubborn.
FRONTIER — where this flat grid came from, and what it gives up. The log-odds occupancy grid you are driving is the original occupancy-grid formulation of Moravec & Elfes (“High Resolution Maps from Wide Angle Sonar,” ICRA 1985); the additive log-odds recursion and inverse-sensor model are stated cleanly in Thrun, Burgard & Fox, Probabilistic Robotics (2005, ch. 9). Its weakness is memory: a flat 2-D array stores every cell, including the vast empty and never-observed regions, and a 3-D version cubes that cost. The modern successor is OctoMap (Hornung et al., Autonomous Robots, 2013), which keeps the same log-odds-per-cell update but stores it in an octree: uniform regions (all-free or all-unknown) collapse into a single large node, so the map spends bits only where the world has structure. What the bench’s flat grid gives up for its simplicity is exactly that adaptivity — it pays full price for the empty corridor. TSDF/voxel maps (KinectFusion-style) make the opposite trade: they store a signed distance instead of occupancy, which reconstructs smooth surfaces beautifully but is heavier still.
Live occupancy mapping — drive, and watch the grid fill

Left: the true scene and the robot’s current lidar fan. Right: the occupancy grid the robot has built via log-odds — teal free, warm occupied, dim unknown. Use Drive to sweep the robot through, or the position slider to place it. Then turn up noise and watch cells flicker; turn down the clamp and watch them settle.

robot position 8%
sensor noise (false-reading rate) 0.05
log-odds clamp ± 8.0
grid: 0 free · 0 occupied · rest unknown

Watch the corners. A cell the robot has driven straight past, seen from many angles, hardens into a confident wall. A cell it glimpsed once, from far, at a grazing angle, stays uncertain. This is the difference between the parts of a map you can trust and the parts you cannot — and it is exactly the information a good exploration policy uses to decide where to look next (the frontier between known and unknown, which the occupancy-mapping lesson turns into an exploration strategy).

The one experiment that teaches the most: set noise to zero, drive fully, then jump noise to 0.35 and watch a settled wall. With a high clamp, a run of false readings can slowly erode even a well-established wall toward uncertainty — the map is only ever as good as the ratio of good beams to bad ones. With a low clamp, the wall never became over-confident, so it recovers the instant good beams return. That trade — stubbornness vs adaptability — is the clamp, and there is no free lunch in it.

One beam, start to finish

The bench integrates 90 beams every frame, but a single beam is the whole algorithm in miniature. Follow one all the way through — it is exactly four steps, and every cell in the grid is built out of nothing but these four repeated.

1 · Raycast
March a point outward from the robot at 0.2-cell steps along the beam angle until it either enters a wall cell (a hit, at distance end) or reaches maxr = 26 (a miss, nothing occupied to mark).
2 · Clear the free cells
Re-walk the beam from just outside the robot (d = 0.3) to just before the hit (d < end − 0.35) in 0.35-cell steps. Each sampled cell is provably empty (the beam passed through it), so add L_FREE × 0.35 = −0.1713 to its log-odds, clamped.
3 · Mark the hit
Only if the beam actually hit: add L_OCC = +0.9445 to the single endpoint cell. One beam contributes one occupied-vote to one cell.
4 · Clamp
After every add, saturate the cell at ±clamp so no cell’s belief can run away to ±∞ and become un-revisable. This is the one line that keeps the map reversible.

Trace it concretely. Say the robot is at cell (2, 2), a beam points along +x, and the true wall is at cell (7, 2) — so end ≈ 5 cells. Step 1 raycasts and returns a hit at (7, 2). Step 2 clears the cells the beam crossed — (3,2), (4,2), (5,2), (6,2) — each getting −0.1713; a cell that starts unknown at l = 0 drops to l = −0.1713, i.e. p = 1/(1+e0.1713) = 0.457, now leaning free. Step 3 marks (7,2) with +0.9445, so l = +0.9445, p = 0.720, leaning occupied. Step 4 does nothing yet — both values are far inside ±8. Next frame, the same beam from a slightly different pose repeats all four, and (7,2) climbs from 0.720 toward the clamp while the free cells sink toward 0. That accumulation is the map.

Why a miss marks nothing occupied. Notice step 3 only fires on a hit. A beam that reaches maxr without striking anything still clears free space all the way out (step 2), but it does not mark an occupied cell — there is no evidence of a wall, only evidence of emptiness. Under noise, the bench sometimes flips a real hit into a phantom miss (the wall “disappears” for that beam): the beam then clears straight through the wall cell, adding −0.1713 to it. One such beam barely dents a clamped wall; a burst of them is what you see eroding it when you crank noise to 0.35.

Under the hood: how one beam finds its cells

The bench above does something on every beam that we have taken for granted: it walks the straight line of cells from the robot to the hit, marking each free, and marks the endpoint occupied. That walk is Bresenham’s line algorithm — an integer-only rasteriser that steps through exactly the grid cells a ray passes through, no floating-point rounding, no gaps. It is the workhorse under the “clearing” pass, and the Studio session at the top of the dock is built around it. Implement it once and you own the free-space update.

The rule looks cryptic stated as “e2 = 2·err; if e2 > −dy step x, if e2 < dx step y” — so before you code it, execute it by hand once. It is the kind of thing that is opaque until you turn the crank and obvious forever after.

Worked example 3 — Bresenham (2,2) → (7,5), every err shown. Setup: dx = |7−2| = 5, dy = |5−2| = 3, sx = +1, sy = +1, and the running error starts at err = dx − dy = 5 − 3 = 2. The invariant: e2 = 2·err; e2 > −dy earns an x-step (and err −= dy), e2 < dx earns a y-step (and err += dx). Both can fire in one iteration (a diagonal move). Turn the crank:
 
at (2,2): err = 2, e2 = 2·2 = 4. Is 4 > −3? yes → err = 2−3 = −1, x→3. Is 4 < 5? yes → err = −1+5 = 4, y→3.  Now at (3,3).
at (3,3): err = 4, e2 = 8. Is 8 > −3? yes → err = 4−3 = 1, x→4. Is 8 < 5? no → y stays.  Now at (4,3).
at (4,3): err = 1, e2 = 2. Is 2 > −3? yes → err = 1−3 = −2, x→5. Is 2 < 5? yes → err = −2+5 = 3, y→4.  Now at (5,4).
at (5,4): err = 3, e2 = 6. Is 6 > −3? yes → err = 3−3 = 0, x→6. Is 6 < 5? no → y stays.  Now at (6,4).
at (6,4): err = 0, e2 = 0. Is 0 > −3? yes → err = 0−3 = −3, x→7. Is 0 < 5? yes → err = −3+5 = 2, y→5.  Now at (7,5) — the endpoint, stop.
 
The walk is (2,2) → (3,3) → (4,3) → (5,4) → (6,4) → (7,5): six cells, no gaps, no cell skipped by more than one step in each axis. The first five are the free cells (each gets a clearing pass); the last, (7,5), is the hit (it gets L_OCC). This is exactly the sequence the check in the lab below asserts — you have just computed the answer key.

Chapter 6: The Field Guide

It is 2 a.m., you are on-call, and the robot just froze in the doorway of an open lab — wheels stopped, planner reporting “no valid path,” a clear three-metre gap dead ahead. The map on your laptop shows a smear of “occupied” cells streaking across the floor where nothing exists. You have maybe ninety seconds before someone asks what is wrong. Where do you look first?

You do not re-derive occupancy theory at 2 a.m. You reach for the one signal that names the fault, confirm it, and push a fix. That reflex — symptom → the metric that reveals it → the one-line correction — is what this chapter loads into muscle memory. (For the frozen-doorway case: the phantom streak is transient hits that were never cleared, and the metric is the clear:mark ratio — if it has collapsed toward 1, ray-clearing is off. Section 4 catalogues the rest.)

Chapters 1–5 derived the ideas from zero; this chapter is what stays loaded when you are actually building or debugging a stack under time pressure — the equations compressed to one line, the numbers you should be able to produce cold, and the failure catalogue with its diagnostic metric. Read it once through, work the arithmetic in sections 3 and 6 by hand until it is automatic, then treat it as a lookup.

1 · The cheat sheet

ConceptThe 30-second versionKey equationToolClassic2020+
Occupancy grid Raster of P(occupied); update is a running sum in log-odds — a hit adds, a pass subtracts, clamp to keep it reversible. t = ℓt−1 + ℓocc/free nav2 costmap_2d Elfes, Occupancy Grids, 1989 Occupancy networks (Tesla CVPR 2022; OccNet 2023)
Costmap / inflation Occupancy → cost; obstacles radiate decaying cost so a point-planner keeps robot-width clearance for free. cost(d) = (cmax−1)e−α(d−ri) nav2 inflation layer Lu & Milios layered maps Gradient / social costmaps
TSDF Store signed distance to the nearest surface, truncated; the surface is the zero-crossing, found to sub-voxel precision. D = clamp((d−r)/μ, −1, 1) Voxblox, nvblox Curless & Levoy, 1996 iSDF (RSS 2022), neural SDF
Fusion Weighted running average of D; the zero-crossing lands on truth even when no single frame did. D←(WD+wd)/(W+w) KinectFusion pipeline KinectFusion, ISMAR 2011 nvblox (GPU, 2022)
Marching Cubes Turn the TSDF into a triangle mesh by interpolating each cube’s zero-crossing. surface = {v : D(v)=0} Open3D, PCL Lorensen & Cline, 1987 Dual contouring, neural marching cubes
Detection / IoU Boxes + class + score; IoU is the overlap metric that decides match vs distinct. IoU = |A∩B| / |A∪B| YOLO, DETR R-CNN family DETR (ECCV 2020, NMS-free)
NMS Keep the top box, delete neighbours with IoU > thr, repeat — one box per object. drop if IoU > τ torchvision nms Greedy NMS Soft-NMS, learned NMS, DETR (none)
Tracking / gate Kalman per track; a detection matches only inside the Mahalanobis gate; Hungarian resolves the assignment. d² = rTP−1r < γ SORT, DeepSORT SORT (ICIP 2016) ByteTrack (ECCV 2022)
Handoff / frames Plan in map (accurate, jumpy), track in odom (smooth, drifts); transform obstacles at the measurement timestamp. map→odom→base_link ROS TF2, nav2 REP-105 nav2 (Sci. Robotics 2020), UniAD (2023)

2 · System-design patterns

3 · Coding drills (do these from memory)

  1. Write the log-odds update: given a prior l, a hit adds log(0.7/0.3), a pass adds log(0.4/0.6), clamp to ±8, convert back with 1 - 1/(1+exp(l)). (Code Lab 1.)
  2. Raycast a beam into a grid with Bresenham: mark every cell along the ray free, the endpoint occupied. Predict the free-cell list for a ray from (2,2) to (7,5). (Studio build kernel.)
  3. Compute a TSDF from a depth: clamp((depth - range)/mu, -1, 1). Fuse two and show the zero-crossing lands between them. (Code Lab 3.)
  4. Write IoU and greedy NMS. Hand-compute IoU for boxes A = (10,10,60,60) and B = (30,30,80,80) in (x₁,y₁,x₂,y₂) form. Intersection: the overlap box runs from max(10,30)=30 to min(60,80)=60 in x, and max(10,30)=30 to min(60,80)=60 in y, so its width and height are both 60−30 = 30, giving 30×30 = 900. Union: each box is 50×50 = 2500, so union = 2500 + 2500 − 900 = 4100 (subtract the overlap once, or you double-count it). IoU = 900 / 4100 = 0.220. Say “below a 0.5 NMS threshold, so these survive as two separate boxes” as you write it.
  5. Write the Mahalanobis gate: r @ inv(P) @ r < gamma. With P = diag(1,1), inv(P) = diag(1,1) too, so d² is just the plain squared length of the residual. For a residual of (0.5, 0.2): d² = 0.5²/1 + 0.2²/1 = 0.25 + 0.04 = 0.29 — well under the χ²2(0.99) = 9.21 gate, so it passes. For (4, 4): d² = 4² + 4² = 16 + 16 = 32, far above 9.21, so it is rejected. Note that a non-identity P shrinks the axes it is confident about — the same 4-unit residual could pass or fail depending on the covariance.

4 · Debugging catalogue — symptom → cause → the metric that reveals it

SymptomLikely causeThe metric that names it
Phantom walls streak across open floor Missing ray-clearing: transient hits never subtracted (occupancy went monotone) clear:mark ratio per scan — healthy » 1; near 1 means clearing is off
Robot refuses a genuinely passable gap Inflation decay α too low / inscribed radius too large Cost at the gap centre vs the lethal threshold
Reconstructed wall is doubled or blurred Pose drift between fused frames (or μ smaller than the jitter) |∇D| at the zero-crossing (healthy ≈ 1/μ); two crossings = pose drift
A cell can never be un-learned No log-odds clamp — belief ran to ±hundreds max |ℓ| across the grid; should sit at the clamp, not grow unbounded
Two nearby objects detected as one NMS IoU threshold too low Recall on crowded scenes; count merged instances
A track ID flips repeatedly on one object ID switch through a detection gap; gate rejects the re-appearance ID switches (IDS) and MOTA; rising IDS with flat FN/FP = association
Obstacles smear into arcs when the robot turns fast Scan transformed with the pose at “now” instead of the scan’s timestamp transform age (now − scan.stamp) × angular velocity vs smear width
Planner reacts late to a moving obstacle Feeding the tracked object’s past position, not its predicted future Prediction horizon vs the object’s time-to-collision

5 · Classical vs modern

QuestionClassical answerModern answer
2-D free spaceLog-odds occupancy grid + hand-tuned inverse sensor modelLearned occupancy prediction that completes unseen space
3-D surfacesVoxel TSDF, Marching Cubes, hand-set μNeural SDF / NeRF trained online; the field lives in a network
DetectionHand-crafted features + sliding window; heavy NMSTransformers (DETR) as set prediction; NMS optional
TrackingKalman + Hungarian on IoU (SORT)Learned appearance embeddings (DeepSORT, ByteTrack)
Perception–planning seamHard interface: costmap produced, planner consumesDifferentiable / end-to-end: occupancy-flow, jointly trained (UniAD)
What survives?The representations — occupancy, signed distance, IoU, the frame contract — outlive every model. The learned era swapped the estimator, not the thing being estimated.

6 · The numbers to carry

These are the seven you should be able to produce cold, with the arithmetic in your head, not the answers memorised. In an interview the derivation is the answer — anyone can quote “0.22,” only someone who has built it can walk the intersection out of the coordinates. Work each chain below once by hand before you rely on the result.

7 · Recommended reading

8 · The field-guide sandbox

A reference you cannot re-run is just trivia. Below, the three mini-calculations from sections 3 and 6 — the log-odds belief after N hits, the IoU of two boxes, and the Mahalanobis gate test — recompute live as you drag one slider each. Watch the number move: log-odds is convex (the first hit buys the most belief), IoU falls to zero the instant two boxes stop touching, and the gate flips from PASS to REJECT the moment d² crosses 9.21. If you can predict where each threshold sits before you drag to it, you have the numbers cold.

Field-guide sandbox — the three calculations, live

Left: log-odds belief vs number of consecutive hits (curve), with your N marked. Middle: two 50×50 boxes, the second offset by the slider — overlap shaded, IoU printed. Right: a residual of length r against the χ²2(0.99) = 9.21 gate; d² and PASS/REJECT update as you drag.

consecutive hits N 3
box B offset (px) 20
residual length r 0.54
Read the three thresholds off the sandbox. Set N = 3 and the belief reads 0.927 (the section-6 chain). Set the box offset to 20 px and IoU reads 0.220 (the boxes are 50 px, offset 20, so overlap is 30×30 = 900 over union 4100). Push the residual toward r ≈ 3.03 — where r² = 9.21 — and the gate flips exactly at the χ²2(0.99) line. Three sliders, three numbers you must never guess in an interview.
A colleague pastes a screenshot: the occupancy map shows a bright bar of “occupied” cells streaking across a stretch of open floor the robot just drove over. Which diagnostic metric names this fault, and what does its value tell you?
The through-line of the whole lesson. A pose is a point; a decision needs a neighbourhood. Downstream perception is the machinery that turns a stream of noisy, pose-stamped measurements into a persistent model of free space, surfaces and moving things — queryable in constant time by a planner. Get the representation right (log-odds, signed distance), fuse honestly (sums and weighted averages), keep semantics for the things that move, and join the clocks correctly at the handoff. Every model in the field changes; those four ideas do not.

“The map is not the territory — but a robot has nothing else. Build it honestly.”