Occupancy grid mapping
The simplest map representation: a 2D grid of 'free vs occupied' probabilities. Build it from laser scans with the inverse-sensor-model trick. The map under every Nav2 costmap.
Before fancy 3D meshes and Gaussian splats, mobile robots used the simplest possible map: a 2D grid of cells, each marked "free," "occupied," or "unknown." Build it from a sequence of laser scans with the inverse sensor model. It's not glamorous, but Nav2's costmap, every TurtleBot demo, and most warehouse robots in 2026 still ride on this layer.
The data structure
A 2D array of floats. Each cell stores the log-odds probability that the cell is occupied:
l = log(p / (1-p))
Why log-odds: updates are additions instead of multiplications, avoiding numerical issues with extreme probabilities. p = 0.5 ↔ l = 0 (unknown); p → 1 ↔ l → ∞ (occupied); p → 0 ↔ l → -∞ (free).
Initialize all cells at l = 0 (unknown). At each new laser scan, walk the grid and update cells along each beam.
The inverse sensor model
For a laser beam from the robot's position, ending at distance r:
- Cells along the beam from 0 to r: probably free → decrement log-odds (e.g., −0.4 per observation).
- Cell at exactly r: probably occupied → increment log-odds (e.g., +0.85).
- Cells beyond r: unknown — no information from this beam.
The exact log-odds increments are tunable hyperparameters. Sensor more reliable → larger updates; less reliable → smaller. Standard ROS values: free −0.4, occupied +0.85.
The classic update loop
def update_occupancy(grid, robot_pose, laser_scan, resolution=0.05):
rx, ry, rtheta = robot_pose
for angle, range_ in laser_scan:
beam_angle = rtheta + angle
if range_ >= MAX_RANGE:
continue # no detection
# Free space along the beam
n_steps = int(range_ / resolution)
for s in range(n_steps):
x = rx + s * resolution * cos(beam_angle)
y = ry + s * resolution * sin(beam_angle)
grid[world_to_cell(x, y)] -= 0.4
grid[world_to_cell(x, y)] = max(grid[world_to_cell(x, y)], -2)
# Occupied at the endpoint
x = rx + range_ * cos(beam_angle)
y = ry + range_ * sin(beam_angle)
grid[world_to_cell(x, y)] += 0.85
grid[world_to_cell(x, y)] = min(grid[world_to_cell(x, y)], 3.5)
return grid
Forty lines for a working 2D mapper. Production code uses Bresenham's line algorithm for the ray traversal (faster than step-stepping); cap log-odds to prevent runaway certainty.
The frame question
The grid lives in a fixed frame (typically map). To project a laser scan into the grid, you need:
- The robot's pose in the map frame (from SLAM/AMCL/odometry).
- The laser sensor's mount transform (from URDF / TF).
- The grid's origin and resolution (from the map metadata).
If any of these are wrong, the map is a smeared mess. Coordinate frames are everything (see Foundations track on coordinate frames).
The 2D map's limitations
- No height: a low table and a high one project the same. Robot can't drive under either; can't drive over neither. Workaround: use the laser's mount height as the implicit "what's traversable" cutoff.
- Static: cells don't decay; old occupations stay even if the obstacle has moved.
- Resolution tradeoff: finer grids (1 cm) give precise maps but high memory; coarse (10 cm) work for navigation but lose details. Standard: 5 cm.
- Open-space rooms get noisy: lasers on smooth walls reflect specularly; cells near walls oscillate between occupied and free.
Maps in production
SLAM Toolbox (the modern ROS 2 SLAM default) outputs occupancy grids that:
- Get saved as
map.pgm+map.yaml. - Get loaded by AMCL for localization in a known map.
- Get loaded by Nav2's costmap layer as the static layer.
- Layer with dynamic obstacles (current laser scan) for live navigation.
This pipeline — SLAM → save → AMCL + Nav2 — is the canonical 2026 mobile-robot stack.
3D extensions
For 3D mapping, the natural generalization is OctoMap: an octree where each leaf is an occupancy grid cell. Memory-efficient (large empty regions are single nodes); supports raycasting through 3D.
Used in many indoor robots with 3D lidar or RGB-D cameras. Slower to update than 2D grids; faster than dense voxel grids.
Modern alternatives
- Truncated Signed Distance Functions (TSDF): each voxel stores distance to nearest surface. Smoother surface reconstruction; KinectFusion-era technique.
- Neural fields (NeRF, Gaussian splats): continuous-density representations. Great for visualization, slow to update, increasingly used in research SLAM.
- Point clouds: keep the raw data; defer mapping. Used when the downstream consumer (perception, planning) doesn't need a structured map.
For mobile-robot navigation in 2026, occupancy grids are still the practical choice. The fancier representations have their uses but don't replace the workhorse.
Common gotchas
- Map drift: bad odometry → cells move under the laser updates. Symptom: walls duplicate. Fix: localization (AMCL or SLAM correcting).
- Wrong sensor frame: laser mounted upside-down or rotated wrong → map is mirrored or rotated. Symptom: doors are walls. Check TF.
- Max-range hits: no return = beam went forever. The classical model treats this as "unknown beyond max range." If the sensor returns a fixed max-range value, ignore those.
- Dynamic obstacles encoded as static: walking people leave occupied trails. Use a separate "dynamic obstacle layer" rather than baking them in.
Exercise
In a 2D simulator, drive a robot around a virtual room with a simulated laser. Implement the log-odds update above. Watch the grid fill in over time. Then introduce a "moving table" that disappears halfway through; observe how the static map shows it forever. The bug is what motivates the multi-layer costmap design.
Next
Visual SLAM with ORB-SLAM3 — what one of the best open-source SLAM systems looks like under the hood.
Comments
Sign in to post a comment.