Weekly Robotics logo
Weekly Robotics Beginner-friendly tutorials, every week
Nav2 Costmaps & Local Planners
Control Systems

Nav2 Costmaps & Local Planners

How Nav2 turns a map and live sensor data into a grid of costs, and how the local controller uses that grid to follow a path without bumping into things.

Arms are only half of robotics. Last week our manipulator learned to pick things up; this week we put a robot on wheels and ask how it gets around. In Week 18 we launched Nav2 and clicked “Nav2 Goal” in RViz. It worked, and it felt a bit like magic. Now we open the hood on two pieces that decide whether the magic works on your robot: the costmaps (how the robot understands the space around it) and the local planner, called the controller in Nav2 (how it turns a path into wheel commands).

What You’ll Learn

  • What a costmap is and why it’s more useful than a plain occupancy map
  • The layers that build one: static, obstacle, voxel, and inflation
  • How the global and local costmaps differ
  • What the controller server does, and the controller plugins you can choose from
  • A minimal, working parameter config, and the gotchas that trip up most beginners

From Map to Costmap

A SLAM map says, for each grid cell, “free,” “occupied,” or “unknown.” That’s not enough to drive on. A cell next to a wall is technically free, but a robot that is 40 cm wide will scrape the wall if it drives there.

A costmap fixes this by giving each cell a cost, a number that says how bad it is for the robot to be there. Low cost means open floor. High cost means “you will hit something.” Planners then minimize cost instead of merely avoiding obstacles, so paths naturally stay in the middle of corridors.

Nav2 keeps two of them:

Global costmapLocal costmap
Used byGlobal plannerController
CoversThe whole mapA window around the robot
Moves with the robot?No (fixed in the map frame)Yes (rolling_window: true)
Typical job”Which route across the building?""Don’t hit that chair right now”

Both are built from the same ingredients, called layers, stacked on top of each other.

The Layers

Each costmap has a plugins list. Nav2’s defaults are static_layer, obstacle_layer, and inflation_layer, and you can add more (costmap_2d docs):

  • Static layer (nav2_costmap_2d::StaticLayer): copies in the saved map. This is the permanent furniture: walls and doorframes.
  • Obstacle layer (nav2_costmap_2d::ObstacleLayer): marks cells from live sensors such as a 2D LiDAR, and clears cells when the sensor ray passes through them, so a person who walked away disappears.
  • Voxel layer (nav2_costmap_2d::VoxelLayer): like the obstacle layer, but it keeps 3D information from point clouds (for example a depth camera), so a table top at head height can block you while the open space underneath is still treated as free. It needs the source’s data_type set to PointCloud2.
  • Inflation layer (nav2_costmap_2d::InflationLayer): grows a fading halo of cost around every obstacle. This is the layer that makes the robot keep its distance.

Order matters: the inflation layer goes last so it can spread cost around everything the earlier layers marked.

Inflation, the layer you will tune most

Inflation has two knobs. inflation_radius (default 0.55 m) is how far the halo extends. cost_scaling_factor (default 10.0) is how quickly cost decays across it (inflation docs). Inside the robot’s inscribed radius (the largest circle that fits inside its footprint), any cell is a guaranteed collision. Beyond that, cost falls off exponentially with distance, following the classic costmap_2d model:

cost ≈ 252 · e−k · (d − rinscribed) Inflation decay

Here d is the distance to the nearest obstacle and k is cost_scaling_factor. The takeaway: a bigger cost_scaling_factor makes cost drop faster, so the robot is happy to squeeze closer to walls; a smaller one makes it timid and keeps it in the middle of the hallway. The exact constants come from the costmap source rather than the docs page, so treat them as a mental model, not a spec.

wall inside robot radius: collision inflation_radius inscribed radius cost decays with distance cost
Cost as you move away from a wall. Planners prefer cells with low cost, so the robot drifts toward the middle of open space.

From Path to Wheels: The Controller Server

The global planner hands back a path. The controller server runs a loop (20 Hz by default) that takes the path plus the local costmap and outputs a velocity command on cmd_vel. It also runs two small helpers: a progress checker (are we actually moving?) and a goal checker (are we close enough to stop?) (controller server docs).

The controller itself is a plugin, so you choose the behavior. The stock options:

ControllerIdeaGood for
DWB (dwb_core::DWBLocalPlanner)Samples many candidate velocities, simulates each briefly, scores themFlexible, including omnidirectional bases; lots of tuning
MPPISamples and weights many short future trajectories, then blends themSmooth motion, dynamic obstacles; heavier compute
Regulated Pure PursuitChases a point on the path ahead, slowing down near obstacles and in tight curvesSimple, predictable differential-drive robots

DWB is the default FollowPath plugin. Regulated Pure Pursuit’s own docs note it works on omnidirectional bases but can’t use their sideways motion, and point to DWB for that case (RPP docs). For a first differential-drive robot, Regulated Pure Pursuit is a friendly place to start.

A Minimal Config

Here are the key pieces for a small differential-drive robot. Parameter names shift a little between distros: in Humble the controller server takes progress_checker_plugin: "progress_checker" (singular), while newer releases use the plural progress_checker_plugins list. Check the docs for the distro you run.

local_costmap:
  local_costmap:
    ros__parameters:
      update_frequency: 5.0
      publish_frequency: 2.0
      global_frame: odom          # local costmap lives in the odom frame
      robot_base_frame: base_link
      rolling_window: true
      width: 3                    # meters
      height: 3
      resolution: 0.05            # meters per cell
      robot_radius: 0.22          # or give a `footprint` polygon instead
      plugins: ["obstacle_layer", "inflation_layer"]
      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
        observation_sources: scan
        scan:
          topic: /scan
          data_type: "LaserScan"
          marking: true
          clearing: true
      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"
        inflation_radius: 0.55
        cost_scaling_factor: 3.0

global_costmap:
  global_costmap:
    ros__parameters:
      global_frame: map
      robot_radius: 0.22
      resolution: 0.05
      plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
      # ...same layer settings as above, with static_layer added

The plugins and per-layer plugin: keys are the pattern to copy; many older tutorials use plugin_names / plugin_types, which are outdated. To watch your costmaps live, open RViz, add a Map display, and point it at /local_costmap/costmap or /global_costmap/costmap.

Common Pitfalls

Robot radius or footprint is wrong. The costmap only protects the robot you told it about. Too small and you clip door frames; too big and Nav2 reports “no path” through doors you could fit through. Measure, then set robot_radius or a footprint polygon.

Inflation so large nothing is reachable. This is the classic “the robot refuses to move” cause from Week 18. If the halo from both sides of a corridor overlaps, the whole corridor becomes high-cost. Shrink inflation_radius, or increase cost_scaling_factor.

Wrong data_type for the sensor. The obstacle layer assumes LaserScan. A depth camera publishing PointCloud2 needs data_type: "PointCloud2" on that source, or nothing gets marked.

Frames that don’t line up. Costmaps and controllers silently depend on a healthy TF tree (map → odom → base_link → sensor frames). Stale or missing transforms are behind a large share of “costmap not updating” reports.

Tuning the wrong layer. If the robot wobbles along the path, that’s the controller (speeds, lookahead, or PID-like gains). If it takes odd routes, that’s the global costmap and planner. If it stops short of obstacles, that’s inflation. Name the symptom before you change numbers.

Recap

IdeaTakeaway
CostmapA grid where every cell has a cost, so planning is “cheapest path,” not just “avoid walls”
LayersStatic (map) + obstacle/voxel (sensors) + inflation (safety halo), inflation last
Global vs. localGlobal is the whole map for routing; local is a rolling window for reactions
Inflationinflation_radius sets how far; cost_scaling_factor sets how fast cost decays
Controller serverRuns a loop turning path + local costmap into cmd_vel using a plugin (DWB, MPPI, RPP)
Biggest beginner errorWrong footprint and over-inflation

The global planner quietly did a lot of work in this post: it found the route the controller follows. Next Monday brings our next Robotics Dispatch, and the week after we look under the planner’s hood with Path Planning Algorithms (A*, Dijkstra, RRT).