SLAM Toolbox in ROS 2: build a map from laser scans

Run SLAM Toolbox on supplied laser scans and odometry. Reproduce a missing-transform failure, repair the frame tree, and inspect the map produced by the real ROS node.

By 14 min read

What you will learn

  • Run the real SLAM Toolbox mapping node on a finite scan sequence.
  • Assign each transform to a single publisher.
  • Configure and activate a managed ROS node.
  • Diagnose scans that cannot reach the mapper through TF.
  • Check saved occupancy cells against the supplied room geometry.
  • Distinguish an occupancy grid from a transform and a saved pose graph.

Before you start

SLAM Toolbox builds a 2D map from laser scans and timestamped odometry transforms. It publishes an occupancy grid and a map → odom transform that connects its map estimate to local odometry.

This tutorial runs the real ROS 2 package on scans from a small synthetic room. The first run leaves the laser disconnected from the robot's frame tree. A fresh run supplies that missing transform, then checks the resulting map against the room that generated the scans.

What does SLAM Toolbox need?

The mapper needs a sensor_msgs/LaserScan stream, the laser's mounting transform, and the robot's odometry pose at each scan timestamp. Its parameters name the frames and scan topic. A correct topic name alone cannot place a range measurement in the map.

The asynchronous mapping node reads odometry through TF. It does not subscribe directly to an Odometry topic. A message on /odom therefore does not establish that the required transform exists. The odometry and tf2 tutorial demonstrates that distinction.

SLAM Toolbox uses odometry to help place incoming scans and scan matching to refine their alignment. The SLAM introduction explains the joint estimation problem; the ICP lesson works through a separate scan-registration example. This tutorial focuses on the ROS package's inputs, startup, and observable output.

Run the supplied mapping example

Save these four files in one directory: Dockerfile, slam_workflow.py, verify.sh, and mapper.yaml.

Open a host terminal there and run:

docker build --platform linux/arm64 -t dustin-slam-toolbox-lesson:jazzy .
mkdir -p slam-output
docker run --rm --network none --platform linux/arm64 \
  --mount "type=bind,source=$(pwd),target=/lesson,readonly" \
  --mount "type=bind,source=$(pwd)/slam-output,target=/output" \
  dustin-slam-toolbox-lesson:jazzy \
  bash /lesson/verify.sh --output /output/run

The build needs network access. It uses a pinned official ROS base and requests an exact ARM64 SLAM Toolbox package; dependency versions resolve during installation. The experiment runs without external network access and leaves the host ROS installation alone.

Choose a new directory under /output for each run. The script refuses an existing destination so a rerun cannot replace saved evidence. It retains the supplied scan data, room geometry, configuration readbacks, process logs, transform samples, and grid files under slam-output/run on your host.

Validation used ROS 2 Jazzy and SLAM Toolbox 2.8.5 on Ubuntu 24.04 ARM64 through Docker Desktop for macOS. Read the environment record, captured output, and README for exact versions, checks, and output paths. Other architectures are unverified; an unavailable pinned Debian package makes the build fail.

Define the room and scan sequence

Start with an 8 × 6 m room: local coordinates u = −4 to 4 m and v = −3 to 3 m. A rectangular obstacle spans u = 1 to 2 m and v = 1.5 to 2.5 m. The fixture rotates that whole layout by 0.1 rad around the origin before generating the scans.

For each local point (u, v), the supplied world coordinates are:

x = u cos(0.1) − v sin(0.1)
y = u sin(0.1) + v cos(0.1)

The fixture finds each range by intersecting a ray with those rotated segments and selecting the nearest positive hit. The rotation keeps the tested wall midpoints inside the grid’s axis-aligned raster bounds. Read reference-geometry.json for the exact segments and poses.

The body starts at (0, 0) with zero yaw. It moves 0.06 m forward between scans, ending at x = 1.14 m after twenty poses. Supplied odometry exactly follows that motion; the laser sits 0.2 m forward of the body origin with no relative rotation.

InputSupplied value
Scan topic and frame/scan, laser
Scan count20
Rays per scan361, from −π to +π radians
Header stamps100.0 through 109.5 seconds, every 0.5 seconds
Declared range limits0.05 to 25 m
Beam timingtime_increment: 0; all rays share one idealized acquisition time
Motion estimateExact odom → base_link transforms at the scan stamps

The first and last beams point in the same direction because the angular interval includes both endpoints. The LaserScan definition defines angles in radians and ranges in meters. Its header stamp refers to the first ray; this fixture removes within-scan motion by assigning all beams that same time.

The runner supplies /clock; the mapper uses use_sim_time: true. Bounded waits use wall time so a missing scan or stopped simulated clock can still produce a useful timeout. See the bag replay lesson for the differences between message stamps, scheduling times, and a node's current clock.

The reference geometry generates the inputs and supports the checks. The mapper receives ranges and transforms, with no room description or prebuilt map. These are noise-free teaching inputs, separate from Dustin's robot projects and real sensor recordings.

Connect scans through the frame tree

The required tree is map → odom → base_link → laser. Each arrow names a parent-child relationship. The associated transform converts coordinates from the child frame into the parent frame.

Scans, transforms, and a map

Give each scan a place in the frame tree

A scan supplies distances in the laser frame. Timestamped transforms connect those distances to the moving robot and its odometry estimate.

  1. 01 · Supply

    Laser ranges and odometry TF

    Each /scan message is a LaserScan in the laser frame. TF supplies the laser mounting transform and the robot’s odometry pose at the scan’s timestamp.

  2. 02 · Match and build

    SLAM Toolbox

    Uses odometry to help place each scan, then matches scans to refine its estimate while building the map.

  3. 03 · Publish

    A grid and a correction

    /map contains an OccupancyGrid. The map → odom transform connects the global estimate to the local odometry frame.

One publisher owns each transform

  1. map→odom
    SLAM Toolbox

    The mapping node publishes the global correction.

  2. odom→base_link
    The supplied odometry source

    The moving body pose must be available at the scan’s timestamp.

  3. base_link→laser
    The supplied sensor mount

    A static transform places the laser on the body.

The first run omits the final edge. The repaired run supplies the static base_link → laser transform while keeping the scans and odometry the same.

Read the two outputs separately

The occupancy grid
A raster of cell values, with a resolution and an origin in the map frame. Unknown cells are not evidence of free space.
The transform correction
A relationship between coordinate frames. It changes how the local odometry estimate is expressed in map; it does not replace the grid’s cell data.
This diagram explains the exercise’s data flow and frame ownership. The missing-transform check and the repaired run’s grid appear in the results below.

The fixture supplies the moving odom → base_link edge and the static laser mount. SLAM Toolbox owns map → odom. A real system could use the robot_localization EKF as its local odometry source, but that estimator is a separate exercise and does not run inside this package.

A scan stamped at 104 seconds needs a connected transform chain valid at 104 seconds. A transform available only at a later time cannot silently replace it. Check frame spelling, direction, and timestamps before changing the scan matcher.

The fixed mount here translates the laser 0.2 m along the body's x axis. Relabeling the scan's frame as base_link would erase that offset without transforming its ranges. The repair must supply the mounting transform.

Configure and activate the mapper

The downloaded mapper.yaml puts the settings under the node's name and ros__parameters. Its core choices are:

slam_toolbox:
  ros__parameters:
    use_sim_time: true
    mode: mapping
    map_frame: map
    odom_frame: odom
    base_frame: base_link
    scan_topic: /scan
    resolution: 0.05
    use_scan_matching: true
    do_loop_closing: false

Run the complete downloaded file; this excerpt omits timing, solver, and other settings. The grid resolution is 0.05 m per cell. Scan matching stays enabled, while loop closing is explicitly disabled to keep the experiment focused on local mapping.

minimum_travel_distance and minimum_travel_heading help decide when motion warrants a new scan. They are separate from map_update_interval, which controls occupancy-grid refresh, and transform_publish_period, which controls transform publication. Publishing twenty input messages does not by itself prove that twenty new scan nodes entered the internal graph.

SLAM Toolbox's mapping node uses the ROS lifecycle. Starting a process leaves startup work to lifecycle transitions: configure prepares the node, then activate starts its active operation. This runner requests both transitions through /slam_toolbox/change_state, checks their responses, and queries /slam_toolbox/get_state to require the active state.

It also reads the configured parameters back from the running node. That catches a YAML file applied under the wrong node name or a setting that did not take effect. The official asynchronous launch file can perform startup transitions automatically; this exercise makes them explicit.

This checks configure/activate startup. It does not demonstrate a full lifecycle manager coordinating deactivate, cleanup, recovery, and shutdown across a navigation stack.

Reproduce the missing laser transform

The first case starts a fresh mapper and sends the scan sequence with valid odometry transforms. It deliberately omits base_link → laser. The scan's frame therefore has no route to odom, the target frame of the mapper's TF message filter.

The script checks that the missing chain fails a timestamped TF query. It also observes the mapper's map and transform outputs over the finite sequence. Keeping the process alive, parameters loaded, and node active separates this input failure from a startup failure.

The repaired case starts a new mapper in a fresh ROS domain, with the same scans, odometry, and settings. It adds the fixed laser transform. A new process avoids confusing newly accepted scans with data queued during the broken case.

In the captured run, both cases published twenty scans. The missing-frame case had no usable laser-to-odometry transform at any scan stamp and produced zero maps and zero map-to-odom transforms. The repaired case had that transform available at all twenty scan stamps and produced a grid with the expected wall, obstacle, and free-space cells.

Read each case's mapper.log and report.json together. A scan publisher and an active node establish part of the input path; the accepted poses and checked grid establish progress through the mapper.

This result is bounded by the supplied sequence and observation window. It does not establish that every empty-map problem has the same cause. A disconnected frame is one reproducible failure with a specific repair.

Inspect the captured occupancy grid

Captured output

Read the grid produced by the repaired run

This figure renders the saved occupancy values from one checked run. It is an output of SLAM Toolbox on the supplied scans.

Captured SLAM Toolbox occupancy grid171 columns by 136 rows, at 0.05 meters per cell. Light cells are free, dark cells are occupied, and gray cells are unknown. Grid columns increase to the right and rows increase upward.
  • Free 18,026
  • Occupied 583
  • Unknown 4,647
Grid dimensions
171 × 136 cells
Cell resolution
0.05 m per cell
Frame
map
Origin x, y
(-4.29, -3.40) m
The view uses grid axes: columns increase right and rows increase up. The saved origin places this grid in map. Inspect the captured grid JSON for every cell and the complete origin pose. Your run generates a new grid; this reference does not imply identical cell values on every run.

The figure renders the actual saved reference grid. It uses the grid's cell values and flips row order for a screen where y increases downward. It does not redraw the ideal room and label it as measured output.

For this package version, 0 means free, 100 means occupied, and −1 means unknown in the published grid. The SLAM Toolbox grid conversion defines these values. Unknown cells remain unresolved by the available evidence; they do not provide clearance for a robot.

Read the metadata before comparing cells with world coordinates. resolution gives meters per cell, and origin places the grid's cell (0, 0) corner in its reference frame. For this axis-aligned grid, the center of column c and row r is:

x = origin.x + (c + 0.5) × resolution
y = origin.y + (r + 0.5) × resolution

The data index is r × width + c. A rotated grid origin would also require applying its orientation; the checker explicitly requires the identity orientation for this fixture.

The checks look for occupied cells near declared wall and obstacle points and free cells near one clear interior point. They allow a 0.2 m neighborhood to account for cell quantization and scan matching. These local probes establish recognizable geometry; they do not score every cell or establish map accuracy on sensor data.

Grid boundaries also matter. An initial axis-aligned trial put exact upper-wall endpoints outside the allocated grid. Karto rounds both its grid dimensions and endpoint indices, then drops out-of-bounds hits; see the 2.8.5 grid implementation. The supplied rotated room keeps the checked wall midpoints away from that maximum boundary without changing the probe tolerance.

The captured report records a 171 × 136 grid at 0.05 m per cell. Its 23,256 cells included 583 occupied, 18,026 free, and 4,647 unknown cells. The captured figure above renders those exact values.

The final accepted body pose at 109.5 seconds was approximately (1.139996, 0.002443) m with yaw 0.00349 rad. The supplied reference was (1.14, 0) m with zero yaw. This small difference is a result from the noise-free teaching fixture, not an accuracy specification for a robot.

Message counts and cell assignments can vary between runs. The checker tests geometry, valid frames, bounded pose/transform differences, and final progress rather than requiring a byte-identical map.

The runner requires an accepted mapper pose at the final scan timestamp, then checks the grid and transform stamps. With restamp_tf: false and transform_timeout: 0.2, this run stamps the transform 0.2 seconds after the accepted scan; that configured validity offset is not a measured processing delay. A map header alone can advance before a scan reaches the matcher, so it cannot establish that the final scan was accepted.

The runner also checks the mapper's map → odom transform. Exact supplied odometry and a first scan at the chosen origin make a small correction expected here. A near-identity correction confirms frame consistency for this fixture; it does not demonstrate recovery from odometry drift.

Map publication and transform publication answer different questions. The grid describes cell occupancy. The transform connects the global pose estimate to a local odometry frame and lets consumers compose a map-frame body pose.

Save a grid or a pose graph

The repaired run saves map.json, map.pgm, and map.yaml. JSON retains the received cell values, header stamp, and spatial metadata. The supplied exporter writes the PGM image with rows in image order and describes its resolution and origin in YAML.

This exporter belongs to the exercise and does not call SLAM Toolbox's save_map service. The 2.8.5 map saver source shows that service invoking Nav2's map-saver executable.

The YAML uses free_thresh: 0.196 and occupied_thresh: 0.65. With negate: 0, the gray pixel value 205 represents occupancy about 0.196078. A free threshold of 0.25 would therefore turn unknown cells into free space on reload. A separate October 12 check loaded the prior reference PGM with the corrected YAML through Nav2 map_server 1.3.13 and recovered all 23,256 cells exactly, including 4,647 unknown cells. The reload report records the original failure and corrected result; this check did not rerun SLAM or alter its captured grid. The Nav2 loader source documents the threshold conversion.

An occupancy image stores a grid suitable for downstream map consumers. SLAM Toolbox's serialization interface saves .posegraph and .data files for its mapping state. Saving a PGM alone does not preserve that graph for continued mapping.

This package checks a short mapping sequence with loop closing disabled. It does not test graph serialization, loading a prior session, localization mode, goal navigation, or hardware performance.

Diagnose your next mapping run

ObservationNext check
The process runs but mapping never startsQuery lifecycle state and read back the loaded parameters.
/scan exists but no map arrivesCheck scan delivery, QoS, frame IDs, and TF at the scan's timestamp.
TF works for “latest” but scans still failQuery the actual header stamp and check the clock source and transform history.
The map shifts while local odometry stays smoothInspect map → odom separately from odom → base_link.
A saved map looks mirrored or upside downCheck cell indexing, image row order, resolution, and origin.
Free space is smaller than expectedDistinguish unknown cells from observed free cells and inspect occlusion.

The QoS tutorial covers a topic whose endpoints discover each other but cannot exchange data. The timestamped tf2 example separates frame and time failures. Use the saved logs and input stamps here to decide which part of that chain needs inspection.

Check two mapping decisions

A scan has header.frame_id: laser, and the body-to-laser offset is 0.2 m. The static transform is missing. Would changing the header to base_link be an equivalent repair?

Show the frame repair

No. The range origins still sit at the laser, so changing a label would place them at the wrong point on the body. Supply the measured body-to-laser transform. Then check the complete chain at the scan timestamp.

A repaired run publishes a plausible grid and a map → odom correction close to identity. Has this experiment shown that SLAM Toolbox removes odometry drift and closes loops?

Show the evidence boundary

No. The supplied odometry has no drift, the body follows a short line, and loop closing is disabled. The run checks scan transport, lifecycle startup, transforms, and basic map geometry. A drift or loop-closure claim needs a separate experiment with a defined error and suitable reference.

Sources and further study

To follow a saved map into navigation, run the Nav2 costmap inflation example. It uses a separate, explicitly authored grid to isolate how inflation settings change raw costs.

Continue with occupancy grid mapping to examine how a beam updates free and occupied cells. Then use the recording and replay example to inspect what a saved message stream preserves about time.