Geometry, Mapping, and 3D/4D State › Simultaneous Localization and Mapping

Michael BrenndoerferJune 19, 202626 min read
World Models HandbookAll chapters
Part 4 of 12Space, Physics, and Causality
Chapter 19Geometry, Mapping, and 3D/4D State › Simultaneous Localization and Mapping
Reading section 4 of 9 · Simultaneous Localization and Mapping · 26 minSection 4 of 9 · 26 min

Book contents

Section 4 of 9 · 26 min read

Geometry, Mapping, and 3D/4D State › Simultaneous Localization and MappingOne section · Sections in this chapter4 / 9 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

This section covers how an agent builds and maintains geometric state over time: the coupling between localization and mapping, the classical EKF solution, occupancy grids for dense maps, and semantic extensions. The unifying theme is accumulation under uncertainty: single-frame estimates are noisy and partial, and the SLAM family of methods is about fusing many of them into a consistent whole without letting errors compound.

Why Localization and Mapping Are Coupled

To build a map from a moving sensor, you need to know where each observation was taken. To know where you are, it helps to have a map to match against. This chicken-and-egg structure is the defining feature of SLAM: simultaneous localization and mapping. It is worth spelling out why neither half alone suffices. Odometry, the integration of motion measurements, gives localization without a map, but without an absolute or map-relative correction its uncertainty accumulates and dead-reckoning drift is generally unbounded over long horizons. Individual zero-mean errors can cancel on a particular realization; it is the growing uncertainty, and systematic bias when present, that makes uncorrected odometry unreliable. Matching against a map gives map-relative localization that can bound or correct accumulated odometric drift, though map, calibration, association, and environmental errors remain. In an unknown environment without an external absolute reference such as a surveyed map, GNSS, motion capture, or known landmarks, the SLAM solution is to estimate pose and map together and let each constrain the other. Formally, the agent wants the joint posterior:

p(x1:t,m∣z1:t,u1:t)p(\mathbf{x}_{1:t}, m \mid \mathbf{z}_{1:t}, \mathbf{u}_{1:t})

over the entire trajectory x1:t\mathbf{x}_{1:t} and the map mm, given all observations and controls. Full-trajectory SLAM is a smoothing problem because today's map depends on where the robot was yesterday. EKF-SLAM, by contrast, is an online filtering formulation over the current pose and persistent map state. When the robot realizes, upon recognizing a landmark, that its pose from an hour ago was wrong, every observation taken since then was attached to the map at the wrong place and must be corrected retroactively. This is why SLAM is fundamentally harder than the filtering problems of Part II: the state whose history matters keeps growing. In Part II we met Bayesian filtering over a belief state; SLAM is what happens when the belief state grows to include a model of the world itself, and the state and the map become statistically coupled through every shared observation. The connection to world models is direct: SLAM is the classical instance of maintaining a persistent, action-conditioned belief over scene structure, updated by every observation and corrected by every act of re-observation.

EKF-SLAM

The extended Kalman filter approach makes the belief explicit. For the planar example below, the robot pose is x=(x,y,θ)\mathbf{x}=(x,y,\theta) and each landmark lj\mathbf{l}_j is a point in R2\mathbb{R}^2. The state vector stacks that pose and all landmark positions:

μ=[xl1⋮lM],Σ∈R(3+2M)×(3+2M)\mu = \begin{bmatrix} \mathbf{x} \\ \mathbf{l}_1 \\ \vdots \\ \mathbf{l}_M \end{bmatrix}, \qquad \Sigma \in \mathbb{R}^{(3+2M) \times (3+2M)}

The key structural fact is that Σ\Sigma is dense: observing a landmark from a new pose correlates the landmark estimate with the pose estimate and, through the pose, with every other landmark. This density is not an accident or an implementation nuisance; it is the mathematical expression of the chicken-and-egg coupling. If the pose is uncertain, then a landmark's inferred location inherits that uncertainty, and so does every other landmark placed relative to the same pose. Each measurement update tightens the whole web. Algorithmically, each step is the familiar predict-then-update cycle, with the motion model linearized in the pose block and the measurement model (range and bearing to a landmark) linearized jointly in the pose and landmark blocks. Linearization is needed because both models are nonlinear in the pose, through sines and cosines of the heading and through the arctangent in the bearing, and the EKF evaluates the local Jacobian at the current mean. As everywhere with extended Kalman filtering, the linearization is only trustworthy within a neighborhood, so large innovations or long un-observed stretches can push the estimate to where the linearization misleads.

Let's implement a compact EKF-SLAM. To keep the demo focused on the estimation machinery, we make three explicit simplifications: the robot follows a known circular path with noisy odometry, measurements are range-bearing to landmarks with known data association (we are told which landmark each measurement corresponds to; the real problem of deciding this is the association problem), and landmark initialization uses the first measurement directly. Each simplification hides a real difficulty. The association problem, deciding whether this observation is of a previously mapped landmark or a new one, is a discrete inference problem layered on top of the continuous one, and a single wrong association can corrupt the entire dense covariance. Initialization from a single range-bearing reading is possible only because the sensor directly measures both range and angle; with a camera, a first sighting constrains only a ray, and the landmark's depth stays unknown until parallax accumulates. The time schedule also matters: the filter consumes the measurements at t=0t=0 without advancing the state, then performs one odometry prediction from t−1t-1 to tt before applying the measurements indexed by tt. Thus every stored estimate, measurement set, and ground-truth pose with the same index refers to the same simulation time. Because the demo has only relative range-bearing observations and unknown landmarks, its global SE(2)SE(2) frame is not identifiable from the measurements. The imperfect initial pose prior supplies the simulator-frame anchor, so the raw pose and map errors below are unaligned and depend on that gauge choice.

In[17]:
Code
## Simulation setup: a differential-drive robot circling a small landmark field.
rng = np.random.default_rng(3)
dt, T = 0.15, 200
v_true = 0.45
landmarks = np.array(
    [
        [1.2, 1.8],
        [-1.5, 1.6],
        [0.0, 0.2],
        [-1.8, -1.4],
        [1.6, -1.6],
        [0.3, 2.6],
    ]
)

## Ground-truth trajectory: explicit-Euler approximation to radius-2 m circular motion
truth = np.zeros((T, 3))
truth[0] = [2.0, 0.0, np.pi / 2]
for t in range(1, T):
    x, y, th = truth[t - 1]
    truth[t] = [
        x + v_true * np.cos(th) * dt,
        y + v_true * np.sin(th) * dt,
        th + (v_true / 2.0) * dt,
    ]


def wrap(angle):
    """Wrap an angle to [-pi, pi)."""
    return (angle + np.pi) % (2 * np.pi) - np.pi


def measure(state, lm):
    d = lm - state[:2]
    return np.array(
        [np.hypot(d[0], d[1]), wrap(np.arctan2(d[1], d[0]) - state[2])]
    )


sig_r, sig_b = 0.05, 0.01  # range and bearing noise
z_all = np.full((T, len(landmarks), 2), np.nan)
for t in range(T):
    for j, lm in enumerate(landmarks):
        if measure(truth[t], lm)[0] < 4.0:
            z_all[t, j] = measure(truth[t], lm)
In[18]:
Code
## EKF-SLAM: joint state is [pose, landmark_1, ..., landmark_M]
n = 3 + 2 * len(landmarks)
mu = np.zeros(n)
mu[:3] = truth[0] + np.array([0.15, -0.15, 0.05])  # imperfect initial pose
Sigma = np.eye(n) * 1e-6
Sigma[:3, :3] = np.diag([0.1, 0.1, 0.05])
initialized = np.zeros(len(landmarks), bool)

est_traj = np.zeros((T, 3))
lm_err = np.full(T, np.nan)

for t in range(T):
    # z_all[0] belongs to the initial state. For later indices, advance once
    # from t-1 to t before consuming the measurement generated from truth[t].
    if t > 0:
        # --- Predict with noisy odometry ---
        v_odo = v_true + rng.normal(0, 0.03)
        w_odo = v_true / 2.0 + rng.normal(0, 0.15)
        x, y, th = mu[:3]
        mu[:3] = [
            x + v_odo * np.cos(th) * dt,
            y + v_odo * np.sin(th) * dt,
            wrap(th + w_odo * dt),
        ]
        F = np.eye(n)
        F[0, 2] = -v_odo * np.sin(th) * dt
        F[1, 2] = v_odo * np.cos(th) * dt
        Sigma = F @ Sigma @ F.T
        Sigma[:3, :3] += np.diag(
            [(0.03 * dt) ** 2, (0.03 * dt) ** 2, (0.15 * dt) ** 2]
        )

    # --- Update with each observed landmark ---
    for j in range(len(landmarks)):
        if np.isnan(z_all[t, j, 0]):
            continue
        z = z_all[t, j] + rng.normal(0, [sig_r, sig_b])
        sl = slice(3 + 2 * j, 5 + 2 * j)
        if not initialized[j]:
            # First sight: augment both the landmark mean and its covariance.
            r, phi = z
            bearing_world = phi + mu[2]
            c, s = np.cos(bearing_world), np.sin(bearing_world)
            mu[sl] = mu[:2] + r * np.array([c, s])
            J_pose = np.array([[1.0, 0.0, -r * s], [0.0, 1.0, r * c]])
            J_meas = np.array([[c, -r * s], [s, r * c]])
            R_meas = np.diag([sig_r**2, sig_b**2])
            landmark_cross = J_pose @ Sigma[:3, :].copy()
            Sigma[sl, :] = landmark_cross
            Sigma[:, sl] = landmark_cross.T
            Sigma[sl, sl] = (
                J_pose @ Sigma[:3, :3] @ J_pose.T + J_meas @ R_meas @ J_meas.T
            )
            initialized[j] = True
            continue
        d = mu[sl] - mu[:2]
        r = np.hypot(d[0], d[1])
        phi = wrap(np.arctan2(d[1], d[0]) - mu[2])
        H = np.zeros((2, n))
        H[0, 0], H[0, 1] = -d[0] / r, -d[1] / r
        H[0, sl] = (d[0] / r, d[1] / r)
        H[1, 0], H[1, 1], H[1, 2] = d[1] / r**2, -d[0] / r**2, -1.0
        H[1, sl] = (-d[1] / r**2, d[0] / r**2)
        innov = z - np.array([r, phi])
        innov[1] = wrap(innov[1])
        S = H @ Sigma @ H.T + np.diag([sig_r**2, sig_b**2])
        K = Sigma @ H.T @ np.linalg.inv(S)
        mu = mu + K @ innov
        mu[2] = wrap(mu[2])
        Sigma = (np.eye(n) - K @ H) @ Sigma

    est_traj[t] = mu[:3]
    if initialized.all():
        lm_err[t] = np.linalg.norm(
            mu[3:].reshape(-1, 2) - landmarks, axis=1
        ).mean()

pose_err = np.linalg.norm(est_traj[-1, :2] - truth[-1, :2])
Out[19]:
Console
Gauge-anchored final pose error:       0.172 m
Gauge-anchored final heading error:    0.1195 rad
Gauge-anchored mean landmark error:    0.455 m
Landmark-error coverage:              157 stored states, first index 43

The filter starts with a wrong pose and unknown landmarks. It first consumes the time-zero observations, then keeps every predict-update result aligned with the truth and measurements at the same index. Every measurement couples a landmark to the current pose; nonzero pose-landmark cross-covariances let later measurement updates adjust correlated initialized landmarks along with the pose. At first observation, the pose Jacobian transfers existing pose and state uncertainty into the new landmark covariance and its cross-covariances, while the measurement Jacobian adds range-bearing noise to the landmark's own covariance block. This is the "simultaneous" in SLAM: pose and map remain jointly estimated rather than one being treated as known. Across the 200 synchronized stored states (the initial state followed by 199 motion intervals), the final unaligned simulator-frame errors are 0.172 m for position, 0.1195 rad for heading, and 0.455 m for mean landmark position. The landmark-error time series begins only after all landmarks are initialized: index 43 through 199, or 157 stored states. These values are gauge-dependent because the imperfect initial pose prior is the only global-frame anchor; they demonstrate coupling within that chosen frame rather than absolute localization accuracy or monotonic convergence. The odometry predicts where the robot is, landmark observations disagree slightly with the mapped world, and the Kalman gain distributes the disagreement between pose and landmarks according to their modeled uncertainties. The dense covariance keeps those shared uncertainties explicit; it does not by itself guarantee a consistent estimator, which also depends on initialization, linearization, data association, and the noise model.

Out[20]:
Visualization
Line plot overlaying gauge-anchored, unaligned estimated and reference trajectories across 200 synchronized stored states, with true and estimated landmark positions.
Gauge-anchored, unaligned EKF-SLAM estimates and ground truth across 200 synchronized stored states. The imperfect initial pose prior supplies the simulator-frame anchor, and the time-zero measurement is applied before the first of 199 motion predictions.
Line plot of gauge-anchored mean landmark error fluctuating from stored-state index 43 through 199, the 157 states after all landmarks are initialized.
Gauge-anchored mean landmark position error after all landmarks are initialized, covering stored-state indices 43 through 199 (157 states). The error fluctuates rather than decreasing monotonically, illustrating that joint updates do not guarantee monotonic ground-truth error.

EKF-SLAM is a beautiful teaching model but has a real limitation: the covariance has O(M2)O(M^2) entries, and every update touches all of them. For a few dozen landmarks this is trivial; for the hundreds of thousands of features in a building-scale map it is prohibitive, and the linearization of a single joint filter over so many variables is fragile besides. Large-scale systems therefore moved to graph-based SLAM (factor graphs, pose graphs): pose estimates become nodes, measurements become edges, and the full problem is solved by nonlinear least squares, incrementally and with relinearization. The graph formulation changes the computational structure in an important way: measurement factors are sparse and local before elimination, and sparse linear algebra makes building-scale problems tractable. In incremental solvers, a new factor can limit re-elimination to an affected Bayes-tree region, although fill-in and propagated dependencies may make that region broader than the factor's immediate variables. The other essential ingredient at scale is loop closure: when the robot recognizes a place it has seen before, a valid association adds a long-range constraint between revisited poses. After successful graph optimization, that factor can reduce and redistribute accumulated drift. It does not guarantee global consistency in the presence of false associations, calibration error, weak observability, or local minima. Geometrically, loop closure supplies information that the chain of local odometry edges lacks, and the least-squares solve balances the new factor against the other measurements across the trajectory.

The endpoint effect is easy to demonstrate at small scale. The next cell simulates noisy odometry around a closed path, then applies a deliberately simple proxy for one loop-closure constraint: it forces the endpoint back to the start and distributes that endpoint error along the trajectory. This is not a pose-graph optimizer, but it isolates what the long-range endpoint constraint contributes.

In[21]:
Code
import numpy as np

rng_lc = np.random.default_rng(7)
T_lc, dt_lc, v_lc = 240, 1.0, 0.052  # circle radius ~2 m in 240 steps
truth_lc = np.zeros((T_lc, 2))
ang = np.linspace(0.0, 2 * np.pi, T_lc)
truth_lc[:, 0] = 2.0 * np.cos(ang)
truth_lc[:, 1] = 2.0 * np.sin(ang)

## Odometry follows the true tangent increments with accumulated heading bias
## and small step-length noise.
bias = np.cumsum(rng_lc.normal(0, 0.02, T_lc))
drift = np.zeros((T_lc, 2))
drift[0] = truth_lc[0]
for t in range(1, T_lc):
    truth_step = truth_lc[t] - truth_lc[t - 1]
    step = np.linalg.norm(truth_step) + rng_lc.normal(0, 0.003)
    head = np.arctan2(truth_step[1], truth_step[0]) + bias[t]
    drift[t] = drift[t - 1] + step * np.array([np.cos(head), np.sin(head)])

## Loop closure: at the final step, snap the end error to zero and distribute
## it uniformly along the trajectory (equal-mass correction, tie-free).
end_err = truth_lc[-1] - drift[-1]
frac = np.linspace(0.0, 1.0, T_lc)[:, None]
closed = drift + frac * end_err
Out[22]:
Visualization
True circle, drifting odometry path, and endpoint-corrected path.
A closed ground-truth path, noisy drifting odometry, and a linear endpoint correction that distributes the final closure error along the trajectory.

Recognition-based loop closure is itself an inference problem, often approached with learned or hand-crafted place descriptors followed by geometric or probabilistic verification. False loop closures are especially dangerous because a wrong long-range constraint can warp an otherwise consistent map.

Occupancy Grids

Landmarks are sparse. For planning, agents often want a dense answer to a dense question: for each cell of space, what is the probability it is occupied? Collision checking and path planning must reason about every point the robot might sweep through; detected features alone are insufficient. A landmark map tells you where distinctive things are; an occupancy map estimates where space is free, occupied, or unknown and provides a key input to collision checking and path planning. Occupancy grids discretize space into cells and maintain, per cell, a Bernoulli belief. Two strong assumptions concern the map itself, and it is important to state them:

  • Cell independence: the map posterior is approximated as factorizing across cells conditional on the pose and measurement history. This is clearly false at boundaries, where adjacent cells belong to the same surface, but it buys an enormous simplification: instead of one inference problem over the joint map, we get a grid of tiny independent problems.
  • Static world: occupancy does not change over time, so evidence can be accumulated indefinitely. This is what allows the filter to integrate over the whole history, and it is also what breaks when furniture moves.

The standard recursive update also conditions on a known or estimated sensor pose and treats measurements as conditionally independent given the map, pose, and inverse sensor model. Correlated rays or pose errors violate that approximation and can make repeated evidence accumulate too confidently.

Under these map and measurement assumptions, each cell admits a small binary filter, and the standard trick is to run it in log-odds form. For occupancy probability pp, define l=log⁡p1−pl = \log \frac{p}{1-p}. Bayes updates multiply odds, which become additions in log space; the update for cell ii given observation z\mathbf{z} is:

li←li+log⁡p(mi∣zt)p(¬mi∣zt)−log⁡p(mi)p(¬mi)l_i \leftarrow l_i + \log \frac{p(m_i \mid \mathbf{z}_t)}{p(\lnot m_i \mid \mathbf{z}_t)} - \log \frac{p(m_i)}{p(\lnot m_i)}

The first term is the inverse sensor model: given "this ray passed through here and stopped there," how much does that raise or lower each cell's occupancy? Rays mark traversed cells as likely free and their endpoint as likely occupied. Everything before a valid endpoint is evidence against occupancy because the measurement indicates that the beam traversed those cells before returning from the endpoint. The endpoint is evidence for occupancy because something returned or stopped the beam there. The subtraction of the prior log-odds prevents repeatedly re-adding prior belief on every update; without it, a cell would drift toward the prior's preference every time any ray touched it, even a completely uninformative one. Clipping keeps probabilities from saturating at 0 or 1, preserving the ability to revise beliefs later. This last detail is easy to dismiss and important to keep: a cell that has saturated at "certainly free" cannot absorb future evidence that it is occupied, and in any environment where the map might be wrong or the world might change, certainty is a liability.

Why Log-Odds?

Log-odds turn Bayes multiplication into addition, make extreme probabilities representable without floating-point underflow, and make the update a simple per-cell increment. Converting back with a sigmoid recovers probability estimates from the accumulated log-odds; whether those estimates are calibrated depends on the prior, sensor model, dependence assumptions, and empirical validation. The same trick reappears throughout probabilistic state estimation, anywhere a binary variable is updated by many small pieces of evidence.

The deeper lesson for world-model builders is what this filter does with negative evidence. A cell being consistently observed as free is information, just as real as an obstacle. The ray that traveled through a cell and kept going has testified that nothing solid was there, and after enough such rays the cell's probability drops far below the prior. Learned world models must therefore be evaluated for whether they distinguish unknown space from sensor-supported free space. Occupancy grids are a reminder that an observed ray traversal is evidence of free space under the sensor model. Mere absence of a measurement is not automatically free-space evidence, because it may reflect occlusion, field of view, dropout, or missing coverage. A state representation that cannot distinguish "unknown" from "known empty" will mislead any planner that relies on it.

Let's build a small occupancy map from simulated lidar.

In[23]:
Code
## Ground-truth world at fine resolution, belief at coarse resolution
fine_res, coarse_res, world = 200, 50, 10.0


def to_fine(x):
    return int((x + world / 2) / world * fine_res)


def to_coarse(x):
    return int((x + world / 2) / world * coarse_res)


gt = np.zeros((fine_res, fine_res), bool)
## A wall segment, a box, and a pillar (rows index y, columns index x)
gt[to_fine(0.0) : to_fine(2.0), to_fine(-1.0) : to_fine(1.0)] = True
gt[to_fine(-3.0) : to_fine(-1.5), to_fine(2.0) : to_fine(3.5)] = True
gt[to_fine(2.8) : to_fine(3.2), to_fine(-3.2) : to_fine(-2.8)] = True

poses = [
    (-4.2, -4.2, 45),
    (0.0, -4.5, 90),
    (4.2, -4.2, 135),
    (-4.5, 0.0, 0),
    (4.5, 0.0, 180),
    (0.0, 4.5, -90),
]

max_range, step = 6.0, 0.05


def cast_ray(x, y, angle):
    """March a ray through the ground-truth grid; return stop distance and hit flag."""
    dx, dy = np.cos(angle), np.sin(angle)
    d = step
    while d < max_range:
        px, py = x + d * dx, y + d * dy
        ix, iy = to_fine(px), to_fine(py)
        if not (0 <= ix < fine_res and 0 <= iy < fine_res):
            return d, False
        if gt[iy, ix]:
            return d, True
        d += step
    return max_range, False


## Log-odds belief grid
belief = np.zeros((coarse_res, coarse_res))
l_free = np.log(0.30 / 0.70)
l_occ = np.log(0.85 / 0.15)
l_clip = 5.0
raw_free_step_samples = 0
free_cell_updates = 0
ray_measurements = 0

for rx, ry, rdeg in poses:
    for k in range(61):  # 61 rays spanning a 180-degree field of view
        ray_measurements += 1
        ang = np.deg2rad(rdeg) + np.deg2rad(-90 + 3 * k)
        d, hit = cast_ray(rx, ry, ang)

        # The fine marcher samples more densely than the coarse belief grid.
        # Deduplicate coarse cells so one ray contributes at most one free
        # likelihood increment per cell. If the ray hit, reserve its endpoint
        # cell for the single occupied increment.
        endpoint_cell = None
        if hit:
            px, py = rx + d * np.cos(ang), ry + d * np.sin(ang)
            endpoint_cell = (to_coarse(py), to_coarse(px))

        free_cells = []
        seen_on_ray = set()
        for i in range(1, int(d / step)):
            raw_free_step_samples += 1
            px, py = rx + i * step * np.cos(ang), ry + i * step * np.sin(ang)
            cx, cy = to_coarse(px), to_coarse(py)
            cell = (cy, cx)
            if not (0 <= cx < coarse_res and 0 <= cy < coarse_res):
                continue
            if cell == endpoint_cell or cell in seen_on_ray:
                continue
            seen_on_ray.add(cell)
            free_cells.append(cell)

        assert len(free_cells) == len(seen_on_ray)
        assert endpoint_cell is None or endpoint_cell not in seen_on_ray
        for cy, cx in free_cells:
            belief[cy, cx] = np.clip(belief[cy, cx] + l_free, -l_clip, l_clip)
        free_cell_updates += len(free_cells)

        if endpoint_cell is not None:
            cy, cx = endpoint_cell
            belief[cy, cx] = np.clip(belief[cy, cx] + l_occ, -l_clip, l_clip)

occupancy_prob = 1.0 / (1.0 + np.exp(-belief))

Each ray now contributes at most one free increment to each coarse cell it traverses, and a valid hit contributes one occupied increment to its endpoint cell instead of also marking that cell free. This per-ray deduplication is part of the inverse sensor model: the 0.050.05 m ray-marching step locates intersections in the fine ground-truth grid, but it is not a source of repeated independent measurements for a 0.20.2 m belief cell.

Note the deliberate asymmetry in these illustrative sensor-model constants: a hit marks its endpoint cell occupied with fairly high confidence (loccl_{occ} near 1.7), while one ray's traversal through a coarse cell marks that cell free more weakly (lfreel_{free} near −0.85-0.85). This example therefore treats one occupied endpoint measurement as stronger evidence than one free-cell traversal measurement. Whether that asymmetry matches a real sensor depends on its beam model, cell size, incidence angle, material response, return behavior, and calibration. The ratio of these two numbers controls how the map balances commission errors (marking free space occupied) against omission errors (marking occupied space free), so tuning it has direct planning consequences.

Out[24]:
Console
Ray measurements:                         366
Raw fine-step free-space samples:          32943
Unique non-endpoint coarse-cell updates:   9840
Deduplication factor:                    3.3479x

The consequence of the two constants is easiest to see as evidence accumulation curves: how many observations of each kind it takes to move a cell's probability away from the 0.5 prior before the implemented ±5\pm 5 log-odds clipping bounds turn further identical evidence into a plateau.

In[25]:
Code
import numpy as np

n_obs = np.arange(0, 21)
l_free_demo = np.log(0.30 / 0.70)
l_occ_demo = np.log(0.85 / 0.15)
free_logits = np.clip(n_obs * l_free_demo, -l_clip, l_clip)
occ_logits = np.clip(n_obs * l_occ_demo, -l_clip, l_clip)
prob_free_seq = 1.0 / (1.0 + np.exp(-free_logits))
prob_occ_seq = 1.0 / (1.0 + np.exp(-occ_logits))
Out[26]:
Visualization
Two clipped sigmoid curves starting at probability 0.5: repeated free traversals fall to a plateau near 0.0067 and occupied hits rise to a plateau near 0.9933.
Occupancy probability after repeated free-space traversals versus occupied hits, starting from the 0.5 prior. Free evidence accumulates more gradually; hits reach the implemented log-odds clipping plateau faster, and both curves stop at the probabilities implied by bounds of -5 and +5.
Out[27]:
Visualization
Binary map of three obstacles with robot positions marked.
Ground-truth obstacles and the six lidar poses used to sense them. The poses ring the scene so that rays cover the obstacles from several directions.
Grayscale occupancy probability map produced by per-ray deduplicated coarse-cell updates, with occupied cells along three obstacle boundaries and gray unknown cells in obstacle interiors and shadowed regions.
Recovered occupancy probabilities after each lidar ray updates every traversed coarse cell at most once and applies one occupied update at a valid hit endpoint. Occupied boundary cells are recovered while obstacle interiors and shadowed regions remain unknown.

The reconstructed map records occupied boundary cells for all three obstacles, but notice the structure of what it does not know: obstacle interiors, cells behind obstacles, and regions no ray ever entered sit at or near the prior probability 0.5 unless another ray supplies evidence. This shadowing is not a bug. It is the honest representation of perspective dependence, and it is precisely what a naive "render the world from the last frame" world model loses. A rendered frame shows surfaces that happened to be visible; it has no way to say that the region behind the pillar is unverified, and a planner reading it has no way to ask. This distinction supports information-aware planning: a planner can prefer to gather information before committing when its objective explicitly penalizes uncertainty or rewards information gain. This is an early instance of a theme that will recur throughout the book: a state representation with honest uncertainty does not just support estimation, it actively shapes behavior, driving exploration and information gathering as rational responses to what the state admits it does not know.

Semantic Maps

Occupancy answers "is something here?" Most tasks also need "what is here?" A robot that knows a region is occupied but not whether it is a table, a person, or a pile of boxes can still choose a conservative detour or gather more information. Occupancy alone cannot choose well among all task-specific responses, such as whether waiting or interaction is appropriate. Semantic maps attach class distributions to geometric elements, either per cell (Bayesian kernel semantic fusion: each labeled observation casts a kernel of evidence into the grid) or per object in a landmark-style map. The per-cell version treats every classified pixel or point as a noisy categorical observation of the cell it lands in, and fuses those observations with the same Bayesian accumulation machinery as occupancy; the per-object version instead maintains discrete object hypotheses with class posteriors, which is more compact and more natural for manipulation but requires solving the object segmentation and data association problems first. A prominent representation in autonomous-driving research is 3D semantic occupancy: a volumetric grid where every cell carries both occupancy and class, effectively fusing detection and mapping into one representation. Its appeal is that it answers, in a single queryable structure, both the geometric question a planner asks and the semantic question a policy asks, without requiring objects to have been detected as objects at all. Semantic maps introduce new failure modes that pure geometry avoids: class priors can override evidence (the map "knows" tables do not float), and semantic errors accumulate through categorical, often correlated likelihoods rather than exactly like geometric errors. Multi-view semantic fusion can improve a class estimate, but it lacks the same direct metric constraint that triangulation provides for depth. Two cameras seeing the same surface from different angles can cross-validate a depth estimate; classifiers seeing the same object from different angles may share biases and agree on a wrong label with high confidence. Semantic disagreement is therefore often harder to diagnose than geometric inconsistency.