tutorial
Nav2 costmap inflation: turn obstacles into navigation costs
Run a Nav2 costmap on a saved occupancy grid. Compare two inflation radii, inspect raw cell costs, and separate robot size from a preference for distance from obstacles.
What you will learn
- Load a saved grid through the real Nav2 map server.
- Run a costmap with static and inflation layers.
- Distinguish occupancy values from raw navigation costs.
- Explain the separate effects of robot size, inflation radius, and decay rate.
- Compare two captured costmaps against a distance-based reference.
- Identify what an inflated map does and does not establish about motion.
Before you start
- Free, occupied, and unknown cells
- Robot geometry and collision clearance
- ROS frames and timestamped transforms
- A terminal and an installed, running Docker engine
Nav2's inflation layer assigns costs to cells near obstacles, including cells that the map marks free. These costs let downstream planning and control algorithms account for proximity to obstacles. A free occupancy cell can therefore carry a high navigation cost.
This tutorial loads a small saved map into the real Nav2 map server, then builds two costmaps. Only the inflation radius changes. The comparison checks the raw cell values against obstacle distance and shows which settings control the result.
Why can free space have a navigation cost?
An occupancy grid describes evidence about what occupies a cell. A costmap represents costs and special states for navigation. These are related quantities with different meanings.
A cell center can be outside an obstacle while a robot centered there would overlap it. Cells farther away can receive lower, nonzero costs that encourage a route to leave more space. The collision-checking lesson develops the corresponding geometric clearance calculation.
Here a static layer reads a saved map, and an inflation layer spreads costs from its lethal obstacle cells. No planner or controller runs. The output lets you inspect the cost field before asking a robot to follow a path through it.
Run the checked Nav2 example
Save these seven files in one directory: Dockerfile, costmap_workflow.py, verify.sh, map.yaml, map.pgm, narrow.yaml, and wide.yaml.
Open a host terminal there and run:
docker build --platform linux/arm64 -t dustin-nav2-costmap-lesson:jazzy .
mkdir -p costmap-output
docker run --rm --network none --platform linux/arm64 \
--mount "type=bind,source=$(pwd),target=/lesson,readonly" \
--mount "type=bind,source=$(pwd)/costmap-output,target=/output" \
dustin-nav2-costmap-lesson:jazzy \
bash /lesson/verify.sh --output /output/run
The build needs network access. It pins the official ROS Jazzy base and exact ARM64 map-server and costmap packages from Nav2 1.3.13. Other dependencies resolve during installation. The experiment runs without external network access and does not require a host ROS installation.
Use a new output directory for each run. The checker refuses an existing destination, preserving earlier evidence. It saves the input map, raw costmaps, published footprints, loaded settings, and process logs under costmap-output/run on your host.
The README, tested environment, and captured output explain the run and its checks.
The tested standalone costmap binary faulted during shutdown after producing correct grids. The runner keeps its installed layer library loaded until exit, using a setting scoped to that executable. It checks clean process exits after lifecycle cleanup. The README records this version-specific workaround; the host environment stays unchanged.
Load the same map twice
The saved map has 21 × 21 cells, each 0.1 m wide. Its origin is (−1.05, −1.05) m with no rotation. Cell indices start at zero; columns increase right and rows increase up. Cell (10, 10) is occupied, the top three rows are unknown, and every other cell is free.
That gives 377 free, 1 occupied, and 63 unknown cells. The map server reads a PGM image through this YAML:
image: map.pgm
mode: trinary
resolution: 0.1
origin: [-1.05, -1.05, 0.0]
negate: 0
occupied_thresh: 0.65
free_thresh: 0.196
The image uses pixel values 0 for the obstacle, 254 for free space, and 205 for the unknown strip. The thresholds classify those pixels; the published nav_msgs/OccupancyGrid uses 100, 0, and −1, respectively. Image rows run down while map rows run up, so the checker verifies that conversion as well as the counts.
A supplied static transform puts base_link at (−0.8, −0.8) m in map, with zero yaw. This keeps the robot away from the central obstacle and gives the costmap a valid pose. It is a fixed teaching input, not a localization estimate.
The example uses an authored grid so every input cell is explicit. The SLAM Toolbox tutorial produces a separate grid from supplied scans. Its captured map helps connect the stages, but it is not the input to this cost comparison.
Configure the layers and robot geometry
Both costmap configurations use these settings. The full files also specify update rates, map subscription behavior, and startup timeouts.
costmap:
ros__parameters:
global_frame: map
robot_base_frame: base_link
rolling_window: false
robot_radius: 0.15
footprint: '[]'
footprint_padding: 0.0
track_unknown_space: true
plugins: [static_layer, inflation_layer]
static_layer:
plugin: nav2_costmap_2d::StaticLayer
map_topic: /map
footprint_clearing_enabled: false
inflation_layer:
plugin: nav2_costmap_2d::InflationLayer
inflation_radius: 0.3
cost_scaling_factor: 3.0
inflate_unknown: false
inflate_around_unknown: false
wide.yaml changes only inflation_radius to 0.6. The empty polygon setting makes Nav2 use robot_radius; zero padding adds no extra geometric margin. Disabling footprint clearing prevents the static layer from erasing cells beneath the supplied robot pose.
Keep the layers in the intended order: the static layer establishes the obstacle cells, then inflation uses them as sources. A missing static layer or an inactive map server is a different problem from a small inflation radius.
The map server and costmap are managed ROS nodes. The runner configures and activates them, queries their state, and checks loaded settings. That makes startup observable before interpreting the output.
Read the raw cost values
The checker requests the complete raw map through nav2_msgs/srv/GetCostmap. These bytes have a different scale from an occupancy grid:
| Raw value | Meaning in this example |
|---|---|
| 0 | Free space with no inflation cost |
| 1–252 | Graded cost that decreases with obstacle distance |
| 253 | Inscribed inflated obstacle |
| 254 | Lethal obstacle cell |
| 255 | Unknown space |
The separate costmap topic published as an OccupancyGrid compresses graded costs into 1–98, maps 253 to 99 and 254 to 100, and represents unknown as −1. That display encoding loses raw cost detail. The Nav2 publisher implementation defines the conversion.
An occupancy value and a raw cost can share a number without sharing its meaning. In the source map, 100 marks an occupied cell. In the costmap, a graded cost of 100 describes part of the distance penalty. Read the message type and encoding before comparing those values.
Separate distance, radius, and decay
For this one-obstacle fixture, let d be the center-to-center distance from a cell to the obstacle cell. Let rᵢ be the footprint's inscribed radius and k the cost scaling factor. Within the inflation kernel, Nav2 assigns:
d = 0: cost = 254
0 < d ≤ rᵢ: cost = 253
d > rᵢ: cost = floor(252 × exp(−k × (d − rᵢ)))
The inflation-layer implementation applies that formula. Outside the kernel, this fixture's known free cells retain cost zero.
The configured robot radius is 0.15 m, but Nav2 1.3.13 approximates the circular footprint with 16 vertices. The configured radius reaches the vertices. Its inscribed radius reaches a polygon edge:
rᵢ = 0.15 × cos(π / 16) ≈ 0.147117792 m
The runner checks that geometry against the published footprint. Using exactly 0.15 m in the decay formula would miss some raw values.
At a cell 0.2 m from the obstacle center, both runs use the same calculation:
floor(252 × exp(−3 × (0.2 − 0.147117792))) = 215
At 0.4 m, the larger kernel gives 118; the smaller kernel leaves that cell at 0. The layer rounds its radius up to an integer number of cells. Here the effective kernel radii are three and six cells. In general, a requested metric radius does not produce a perfectly smooth boundary on a raster.
Increasing inflation_radius extends the region in which the layer can assign costs. With robot geometry and decay rate held fixed, it leaves the existing distance formula unchanged. The farther extent is useful only where that formula still produces a nonzero integer cost.
Increasing cost_scaling_factor makes the exponential drop faster. At the same distance beyond the inscribed radius, the result becomes smaller. The name can be misleading if you expect a larger factor to raise every cost.
Changing the robot footprint changes the geometry used to compute the inscribed radius. That affects the region marked as an inscribed inflated obstacle and the offset used in the decay. It is a different change from extending the inflation radius.
Compare the captured costmaps
The illustration renders captured values from the actual nodes. It uses the same axes and colors for both raw costmaps, with a separate label for the source map's encoding.
The smaller radius produces 20 graded cells; the larger radius produces 104. All 84 additional cells were previously free with zero cost. The eight inscribed cells, one lethal cell, and 63 unknown cells stay unchanged. Every previously nonzero known cost also stays unchanged.
The cell probes in the figure sit 0.2 m and 0.4 m from the obstacle center. The first already lies inside both kernels; only the second gains a cost when the radius expands.
The source map still contains exactly one occupied cell. A larger blue region represents a wider preference for distance from that obstacle; it does not mean the obstacle grew.
The checker compares the raw grids, their metadata, and their loaded parameters. It also checks the input map across runs. A picture that looks wider is useful evidence to inspect; the cell values establish exactly what changed.
Interpret the map before planning motion
An inflation radius is not a guarantee of physical clearance. Robot geometry, grid resolution, sensor errors, and the choices made by a planner and controller all affect the motion that follows. This example evaluates a cost field without generating or tracking a path.
The collision-checking lesson shows another limit: testing a few clear poses can miss a collision between them. Inflating a static raster does not replace a swept-motion check for the chosen geometry.
Unknown cells remain 255 in these captures. track_unknown_space preserves the unknown state, and inflate_around_unknown: false prevents those cells from becoming inflation sources. The unknown strip is deliberately farther from the obstacle than either kernel reaches.
Do not generalize that result to every unknown boundary. In Nav2 1.3.13, inflate_unknown: false still allows inscribed or lethal inflation costs to replace unknown cells close enough to a source. The unknown-cell update rule matters when the kernel overlaps unobserved space. Whether a future planner may travel through unknown cells is another policy to configure and test.
This workflow uses a fixed map and a fixed supplied robot transform. It does not exercise live obstacle observations, a rolling local costmap, moving objects, localization, or recovery behaviors. Those require additional inputs and separate checks.
Check two inflation decisions
1. Which setting changes the shared cell?
Cell (12, 10) has cost 215 with either inflation radius. You now increase only the cost scaling factor from 3 to 6. Does this cell's graded cost rise, fall, or stay the same?
Show the reasoning
It falls. The cell is beyond the inscribed radius, so a larger factor makes the negative exponent more negative. The formula gives approximately 183 after truncation. Changing only the kernel radius had left this cell's distance and decay unchanged; changing the factor alters the decay itself. The captured runs use factor 3, so 183 is a calculated prediction for a separate experiment.
2. Did the robot get wider?
The 0.6 m run has 84 more nonzero cells, while the robot radius remains 0.15 m. Can you conclude that the robot requires 0.6 m of physical clearance or that every nonzero cell is forbidden?
Show the reasoning
No. The inflation radius bounds where the layer spreads cost; it is separate from the robot footprint. Graded costs let downstream algorithms weigh distance from obstacles, and those algorithms determine how to use them. This run checks neither a planned route nor its execution. Keep geometry, cost preferences, and motion validation separate when interpreting the result.
Sources and further study
- Nav2 1.3.13 inflation cost formula, including lethal, inscribed, and graded costs.
- Inflation propagation and unknown-cell updates.
- Circular footprint construction and cell-radius rounding.
- Raw GetCostmap service and display conversion.
- Static-layer occupancy conversion, map-server lifecycle, and costmap transform readiness.
Use the Nav2 path-planning tutorial to follow costs into a real planner request. It loads a separate saved map into the planner server's global costmap and checks a reachable and an unreachable goal.
Continue with collision checking to test what happens between sampled robot poses. To inspect where a saved grid came from, run the SLAM Toolbox example.