ROS 2 Nav2 Costmap2D Guide 2026
Configure Nav2 costmaps for reliable robot navigation: global costmap with StaticLayer + ObstacleLayer, local rolling-window costmap with VoxelLayer for 3D sensors, and InflationLayer tuning with inflation_radius and cost_scaling_factor.
Two Costmaps, One Robot
Nav2 uses two costmaps simultaneously: the global costmap (full map, updated slowly) for path planning from A to B, and the local costmap (3-5m rolling window, updated fast) for obstacle avoidance as the robot moves. Each is configured independently in nav2_params.yaml.
Nav2 Costmap2D — Layered Architecture
Nav2's costmap2d maintains two costmaps: global (for path planning) and local (for real-time obstacle avoidance). Each costmap is built from stacked plugins called layers.
Nav2 Costmap2D Architecture
═══════════════════════════════════════════════════════
Global Costmap (used by planner_server) Local Costmap (used by controller_server)
Dimensions: typically 5–50 m Rolling window: 3–5 m radius around robot
Resolution: 0.05 m/cell (5 cm) Resolution: 0.05 m/cell
Update rate: 1–5 Hz Update rate: 10–20 Hz
Layer stack (applied in order, bottom to top):
┌──────────────────────────────────────┐
│ InflationLayer │ Expands obstacles by robot radius
│ (inflation_radius: 0.55 m) │ Creates gradient cost → planner stays away
├──────────────────────────────────────┤
│ ObstacleLayer │ Marks/clears from LiDAR scan
│ (sensor: /scan or /pointcloud) │ Raycasting clears free space
├──────────────────────────────────────┤
│ StaticLayer (global only) │ Map server occupancy grid
│ (/map topic, costmap.yaml) │ Known walls and static obstacles
└──────────────────────────────────────┘
Cost values (0–254):
0 = FREE (safe to drive)
1–252 = Cost gradient (inflation zone)
253 = INSCRIBED (robot center would touch obstacle)
254 = LETHAL (occupied — obstacle itself)
255 = NO_INFORMATION (unknown)
Key parameters:
robot_radius — used by inflation layer
inflation_radius — how far to inflate obstacles
cost_scaling_factor — inflation gradient steepnessGlobal Costmap YAML Configuration
The global costmap uses StaticLayer + ObstacleLayer + InflationLayer. It covers the full map and is updated at low frequency.
# nav2_params.yaml — global_costmap section
global_costmap:
global_costmap:
ros__parameters:
# Coordinate frame
global_frame: map
robot_base_frame: base_link
# Update rates
update_frequency: 1.0 # Hz — how often to update the costmap
publish_frequency: 1.0 # Hz — how often to publish for RViz
# Map dimensions (auto from map_server if using StaticLayer)
resolution: 0.05 # meters per cell
# Robot footprint (circle OR polygon)
robot_radius: 0.22 # for circular robots (use footprint: for polygon)
# footprint: "[[0.25, 0.22], [0.25, -0.22], [-0.25, -0.22], [-0.25, 0.22]]"
# Layer plugins (applied in order listed)
plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
static_layer:
plugin: "nav2_costmap_2d::StaticLayer"
map_subscribe_transient_local: True # latched /map subscription
enabled: True
obstacle_layer:
plugin: "nav2_costmap_2d::ObstacleLayer"
enabled: True
observation_sources: scan # comma-separated list of sensors
scan:
topic: /scan # LaserScan topic
max_obstacle_height: 2.0 # m — ignore points above this
clearing: True # raycasting clears free space
marking: True # sensor marks obstacles
data_type: "LaserScan"
raytrace_max_range: 3.0 # m — max raytrace distance
raytrace_min_range: 0.0
obstacle_max_range: 2.5 # m — max obstacle marking range
obstacle_min_range: 0.0
inflation_layer:
plugin: "nav2_costmap_2d::InflationLayer"
cost_scaling_factor: 3.0 # larger = steeper gradient (faster decay)
inflation_radius: 0.55 # m — match or exceed robot_radiusinflation_radius should be >= robot_radius. A value 10-20% larger gives the planner safety margin. cost_scaling_factor=3.0 creates a smooth gradient; lower values (1.0-2.0) make the robot stay further from walls.
Local Costmap YAML — Rolling Window
The local costmap uses a rolling window centered on the robot. It only contains ObstacleLayer + InflationLayer (no StaticLayer — the robot shouldn't plan around the global map at close range).
# nav2_params.yaml — local_costmap section
local_costmap:
local_costmap:
ros__parameters:
# Coordinate frame — odom (not map) for local real-time updates
global_frame: odom
robot_base_frame: base_link
# Rolling window settings
rolling_window: true
width: 3 # meters wide (centered on robot)
height: 3 # meters tall
resolution: 0.05 # 5 cm cells
# Update at higher frequency than global costmap
update_frequency: 5.0 # Hz
publish_frequency: 2.0 # Hz
robot_radius: 0.22
# Local costmap: no StaticLayer (would be too slow to update)
plugins: ["obstacle_layer", "inflation_layer"]
obstacle_layer:
plugin: "nav2_costmap_2d::ObstacleLayer"
enabled: True
observation_sources: scan
scan:
topic: /scan
max_obstacle_height: 2.0
clearing: True
marking: True
data_type: "LaserScan"
raytrace_max_range: 3.0
obstacle_max_range: 2.5
inflation_layer:
plugin: "nav2_costmap_2d::InflationLayer"
cost_scaling_factor: 3.0
inflation_radius: 0.55
# Keep obstacles for 0.5 seconds even after sensor stops seeing them
# Helps with dynamic obstacles that briefly leave sensor FOV
# obstacle_layer.track_unknown_space: false # treat unknown as freeUse global_frame: odom (not map) for the local costmap. The local costmap doesn't need map correction — it only needs to track obstacles relative to the robot's immediate environment.
3D Obstacle Layer — PointCloud2 Sensors
For 3D LiDAR or RGBD cameras, use VoxelLayer (3D voxel grid) instead of ObstacleLayer (2D scan). VoxelLayer handles multi-height obstacles correctly.
# 3D sensors: use VoxelLayer instead of ObstacleLayer
global_costmap:
global_costmap:
ros__parameters:
plugins: ["static_layer", "voxel_layer", "inflation_layer"]
voxel_layer:
plugin: "nav2_costmap_2d::VoxelLayer"
enabled: True
publish_voxel_map: True # debug visualization
origin_z: 0.0
z_resolution: 0.05 # vertical voxel height (5 cm)
z_voxels: 16 # 16 voxels × 5 cm = 0.8 m height
max_obstacle_height: 2.0
mark_threshold: 0 # voxels needed to mark a cell
observation_sources: pointcloud
pointcloud:
topic: /cloud/filtered # processed PointCloud2
max_obstacle_height: 2.0
min_obstacle_height: 0.05 # ignore floor returns
clearing: True
marking: True
data_type: "PointCloud2"
raytrace_max_range: 3.0
obstacle_max_range: 2.5
# ── For RGBD cameras (RealSense, Orbbec) ────────────────────
# Use depth_image_proc to convert to PointCloud2 first:
# ros2 run depth_image_proc point_cloud_xyz_node \
# --ros-args \
# -r image_rect:=/camera/depth/image_rect_raw \
# -r camera_info:=/camera/depth/camera_info
# ── Footprint instead of robot_radius ────────────────────────
# For non-circular robots (rectangular, etc.)
global_costmap:
global_costmap:
ros__parameters:
footprint: "[[0.25, 0.22], [0.25, -0.22], [-0.25, -0.22], [-0.25, 0.22]]"
footprint_padding: 0.01 # m — extra safety paddingDebug Costmap Problems
When the robot won't navigate, can't find a path, or drives too close to walls, use these techniques to inspect costmap state.
# ── Visualize costmaps in RViz2 ─────────────────────────────
# Add → By Topic:
# /global_costmap/costmap — global occupancy grid
# /global_costmap/costmap_updates — efficient updates
# /local_costmap/costmap — local rolling window
# Display options:
# Color scheme: costmap (shows inflation gradient in blue→red)
# Max color scale value: 100
# ── CLI inspection ────────────────────────────────────────────
# Check costmap update rate
ros2 topic hz /global_costmap/costmap_updates
ros2 topic hz /local_costmap/costmap_updates
# Inspect a single costmap message
ros2 topic echo /global_costmap/costmap --once
# ── Common problems and fixes ─────────────────────────────────
# Problem: "No plan found" even in open space
# Cause: inflation_radius too large → whole corridor is INSCRIBED
# Fix: Reduce inflation_radius or footprint size
# Problem: Robot drives through obstacles
# Cause: ObstacleLayer sensor not publishing or wrong QoS
# Fix: ros2 topic hz /scan → if 0 Hz, fix sensor driver
# Check data_type matches (LaserScan vs PointCloud2)
# Problem: Ghost obstacles (obstacles stuck in costmap after clearing)
# Cause: clearing: False in observation_sources
# Fix: Set clearing: True; raytrace_max_range >= max sensor range
# Problem: Costmap never updates
# Cause: TF not available (global_frame → robot_base_frame)
# Fix: ros2 run tf2_ros tf2_echo map base_link
# → if fails, TF tree is broken (fix odom→base_link)
# ── Dynamic reconfigure costmap params at runtime ─────────────
ros2 param set /global_costmap/global_costmap inflation_layer.inflation_radius 0.3
ros2 param set /local_costmap/local_costmap obstacle_layer.enabled falseQuick Reference
| Parameter | Details |
|---|---|
| global_frame (global) | map — aligned to map server |
| global_frame (local) | odom — follows robot, no map correction needed |
| rolling_window | true for local costmap — centers on robot |
| StaticLayer | Loads /map occupancy grid — global costmap only |
| ObstacleLayer | LaserScan marking/raycasting — 2D sensors |
| VoxelLayer | PointCloud2 3D voxels — use for RGBD/3D LiDAR |
| InflationLayer | Expands obstacles by inflation_radius |
| robot_radius | Must match physical robot — used by InflationLayer |
| inflation_radius | ≥ robot_radius; larger = more conservative paths |
| cost_scaling_factor | 3.0 = steep gradient; 1.0 = shallow (stays farther from walls) |