robot_localization in ROS 2: configure an odometry and IMU filter

Run the ROS 2 EKF with supplied wheel odometry and IMU data. Select measurement fields, check frames and covariance, and see how a biased gyro changes the estimated path.

By 13 min read

What you will learn

  • Run the real robot_localization EKF with supplied ROS messages.
  • Read the fifteen-field measurement-selection arrays.
  • Assign pose, twist, and IMU measurements to their declared frames.
  • Distinguish measurement covariance from a persistent sensor bias.
  • Compare a wheel-only baseline with a filter that accepts a biased gyro.
  • Check filtered odometry and its matching transform against a known reference.

Before you start

The ROS 2 robot_localization package combines selected motion measurements into a state estimate. Its ekf_node can consume wheel odometry and IMU messages, publish odometry/filtered, and broadcast the corresponding transform. The configuration determines which fields the filter accepts.

This tutorial runs the real estimator on a supplied straight-line sequence. A wheel-only baseline follows that reference; enabling a deliberately biased gyro makes the estimated path turn. You can inspect the messages, change one selection bit, and check the resulting error without a physical robot.

What does robot_localization estimate?

The EKF predicts motion, then corrects that prediction using incoming measurements and their reported uncertainty. Its state contains position, orientation, linear and angular velocity, and linear acceleration. Each sensor's configuration chooses which parts of its message contribute to those corrections.

Review the extended Kalman filter lesson for the prediction, Jacobian, and measurement-update calculation. That lesson uses a separate landmark model. Here the task is configuring and checking the ROS package's own estimator.

A populated message field does not automatically enter the filter. A valid quaternion, for example, can accompany an odometry message whose configuration selects only velocity. Read the selection arrays before assuming that the filter receives an absolute heading.

Run the checked Jazzy example

Save these five files in one directory: Dockerfile, localization_workflow.py, verify.sh, baseline.yaml, and fuse-biased-gyro.yaml.

Open a host terminal there and run:

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

The build needs network access to install robot_localization into the pinned ROS base image. It requests one exact ARM64 package version; other dependency versions resolve during the build. The experiment itself runs without external network access and changes no host ROS installation.

Choose a new directory name under /output when repeating the run. The script refuses an existing output directory. It saves supplied inputs, both filtered trajectories, TF samples, parameter readbacks, logs, and report.json under localization-output/run on your host.

Validation used ROS 2 Jazzy, robot_localization 3.8.3, and Python 3.12.3 in Ubuntu 24.04 on ARM64 through Docker Desktop for macOS. See the environment record for package versions and the captured output for a checked run. This build targets ARM64; other architectures are unverified.

The README explains the output files and checks. If the pinned Debian package becomes unavailable, the build fails rather than silently substituting a newer estimator.

Define the motion and sensor messages

The supplied reference describes a body moving forward at 1 m/s with no rotation. If τ is elapsed time from the first sample, its pose is:

x(τ) = τ meters, y(τ) = 0 meters, yaw(τ) = 0 radians

The fixture supplies wheel odometry with forward velocity 1 m/s, lateral velocity zero, and yaw rate zero. A second stream supplies IMU angular velocity about the body z axis at +0.2 rad/s, despite the reference's zero turn rate. That fixed offset is an injected gyro bias.

Each run publishes 51 odometry messages and 51 IMU messages, stamped from 100.0 through 105.0 seconds in 0.1-second steps. The input topics are /fixture/odom and /fixture/imu. The runner supplies /clock, and the estimator uses use_sim_time: true.

It queues each pair before advancing simulated time to that pair's stamp, giving the estimator's timer and subscription callbacks time to run. Bounded waits use elapsed wall time. The script starts a fresh estimator process for each configuration and checks the loaded parameters through its ROS service.

The odometry pose fields contain the reference for inspection, but both filter masks exclude them. The checker uses the reference to measure error; no reference position enters a state update. The same sensor streams are published for both runs; the baseline excludes the IMU, while the changed configuration accepts its yaw rate. This is an intentionally inconsistent teaching dataset; it does not represent measurements from Dustin's robot or characterize a real IMU.

Choose which fields enter the filter

Every odomN_config or imuN_config array has fifteen booleans in a fixed order. The 3.8.3 parameter template defines them as:

x, y, z,
roll, pitch, yaw,
vx, vy, vz,
vroll, vpitch, vyaw,
ax, ay, az

The mask selects components in the input frame for that part of the message. Odometry twist uses its child frame; IMU angular velocity uses its header frame. Those axes matter when a sensor is rotated.

The v entries describe velocities; vyaw is angular velocity about z. An IMU still uses the full fifteen-entry array, even though it does not provide a position measurement.

Both runs select these wheel-odometry fields:

odom0_config: [false, false, false,
               false, false, false,
               true,  true,  false,
               false, false, true,
               false, false, false]

That chooses vx, vy, and vyaw. The measured zero lateral velocity is a useful constraint for this ideal body, which cannot move sideways. It would be a poor assumption for a slipping robot or a platform that can translate laterally.

The baseline has an all-false imu0_config. The changed configuration enables just vyaw, entry 12 when counting from one:

imu0_config: [false, false, false,
              false, false, false,
              false, false, false,
              false, false, true,
              false, false, false]

The downloadable YAML files wrap these settings in a node name and ros__parameters:

ekf_filter_node:
  ros__parameters:
    use_sim_time: true
    two_d_mode: true
    frequency: 20.0
    world_frame: odom
    odom_frame: odom
    base_link_frame: base_link
    publish_tf: true
    odom0: /fixture/odom
    imu0: /fixture/imu

This excerpt omits the masks and other settings; run the complete supplied files. The runner starts ekf_node with the matching node name and passes the selected YAML through --ros-args --params-file. A YAML file under a different node name can leave the intended parameters unapplied.

The IMU stream exists in both cases. The single changed bit makes its biased turn rate an accepted measurement in the second run. Orientation and linear acceleration remain excluded.

Messages, masks, and frames

Choose which measurements reach the filter

The source publishes the same messages in both runs. The second configuration adds the IMU’s yaw-rate measurement to the wheel measurements.

  1. 01 · Supply

    Odometry and IMU

    /fixture/odom carries wheel motion.
    /fixture/imu carries a biased yaw rate.

  2. 02 · Select and estimate

    The EKF node

    ekf_node fuses the selected fields using their covariances. two_d_mode constrains the estimate to planar motion.

  3. 03 · Publish

    Pose, twist, and TF

    /odometry/filtered contains the estimate. With publish_tf enabled, the node also publishes the odom → base_link transform.

The 15-field configuration order

Indices start at zero. Odometry uses this mask in both runs. The IMU column shows the second run; its baseline mask is entirely false.

Index · fieldOdomIMU
0xfalsefalse
1yfalsefalse
2zfalsefalse
3rollfalsefalse
4pitchfalsefalse
5yawfalsefalse
6v_xtruefalse
7v_ytruefalse
8v_zfalsefalse
9v_rollfalsefalse
10v_pitchfalsefalse
11v_yawtruetrue
12a_xfalsefalse
13a_yfalsefalse
14a_zfalsefalse

Read each value in its frame

Odometry pose
header.frame_id is odom. These pose fields are excluded by the mask.
Odometry twist
child_frame_id is base_link. The selected forward speed, lateral speed, and yaw rate are body-frame quantities.
IMU angular velocity
header.frame_id is base_link. The fixture already uses the body axes, so it needs no separate sensor mounting transform.
Filter output
world_frame is odom. The output pose describes base_link in odom; its twist is in base_link.

A selected field can still be wrong. The supplied IMU has a yaw-rate bias; selecting it does not guarantee a better path.

This local estimate has one TF owner: the EKF publishes odom → base_link. No global position source or map → odom correction is part of this exercise.

Both configurations use two_d_mode: true. This constrains out-of-plane motion for the planar example. It does not supply an absolute x, y, or yaw observation and cannot correct an incorrect turn-rate measurement.

The odometry pose fields remain excluded. On a real wheel-odometry source, pose and twist often derive from the same encoders; fusing both as independent evidence can count the same information twice. The package's configuration guide explains this dependency.

Keep input and output frames explicit

The Odometry message contract assigns pose to header.frame_id and twist to child_frame_id. The package transforms selected pose measurements into its world frame and selected twist measurements into its body frame.

This example uses:

  • Input odometry pose: header.frame_id: odom. Pose coordinates refer to the local odometry frame; pose fields are excluded from fusion.
  • Input odometry twist: child_frame_id: base_link. Forward and lateral velocity refer to the body axes.
  • Input IMU: header.frame_id: base_link. The fixture already expresses angular velocity in body axes.
  • Filter world frame: world_frame: odom. The output is a local drifting estimate.
  • Filter body frame: base_link_frame: base_link. The estimated body has this frame ID.
  • Filter TF output: publish_tf: true. The filter owns odom → base_link.

Using base_link directly for this synthetic IMU removes a mounting transform from the experiment. For a real IMU with its own frame, provide the correct static transform between the body and sensor. Follow the package's sensor-data conventions, including ENU orientation conventions and right-handed axes; renaming a frame does not rotate its measurements.

Only the filter broadcasts odom → base_link here. If a wheel driver already publishes that same edge, choose one authority and disable the other. Publishing an Odometry message and broadcasting a TF edge are separate actions, as the tf2 tutorial demonstrates.

Report uncertainty without hiding bias

The wheel message assigns variance 0.01 to each selected velocity component. The IMU assigns yaw-rate variance 0.0001 rad²/s². Its reported standard deviation is therefore 0.01 rad/s, while the injected bias is 0.2 rad/s: twenty times that value.

Both configurations use those same reported variances. The IMU's claimed yaw-rate uncertainty is much smaller than the wheel source's, so this setup gives the wrong gyro reading substantial influence. The values are deliberate teaching inputs, not recommendations for tuning real sensors.

The fixture marks IMU orientation and acceleration unavailable, and both masks exclude them. Process noise and initial state covariance retain this package version's defaults; the README records that choice. No covariance tuning or bias calibration occurs during the run.

A diagonal covariance entry is a variance, with squared units. Linear-velocity variance uses m²/s²; angular-velocity variance uses rad²/s². Off-diagonal entries describe cross-covariance between quantities. These values affect the correction together with the predicted state covariance and process model.

Setting a selected field's variance to zero does not tell the filter to ignore it. Use the selection mask to exclude a field. The IMU message definition has separate conventions: an all-zero covariance block means unknown covariance, while a first entry of −1 marks an unavailable measurement block.

The package's fifteen-state model has no gyro-bias state. Enabling a biased rate therefore does not ask it to estimate and remove that bias. In this fixture the sensor disagreement reaches the filter as competing turn-rate measurements, and accepting the bad rate bends the estimated trajectory.

Lower reported uncertainty cannot establish that a sensor is correct. Calibration, frame conventions, units, and time alignment all need their own checks. See the Kalman filter lesson for how measurement uncertainty affects the gain.

Compare the two estimated paths

At the final input stamp of 105 seconds, the supplied reference is (5, 0) m with zero yaw. One captured run produced these rounded results:

QuantityWheel-only baselineWith biased IMU yaw rate
Final x (m)5.0004.245
Final y (m)0.0002.238
Yaw (rad)0.0000.990
Position error (m)0.0002.362

The position error uses the same origin and axes as the supplied reference:

error = √((estimated x − 5)² + estimated y²)

There is no fitted rotation or translation that aligns the estimated path afterward. The changed run's heading reaches roughly 57°, so integrating its forward body velocity carries it away from the reference line.

Open baseline-filtered.csv and fuse-biased-gyro-filtered.csv to inspect the received estimates. Their matching -tf.csv files preserve the transform samples. report.json records the final errors and checks; the -parameters.json files record settings read from the running nodes.

Equal-timestamp inputs can initialize the filter in a different order. If the IMU arrives first, the default initial covariance makes forward velocity rise gradually from near zero; wheel-first initialization starts it at 1 m/s. A later verified run ended at (3.993, 2.232) m with 2.448 m error, while reaching the same rounded yaw.

The table therefore describes one captured run. Startup order can change the transient and endpoint, and output counts can vary. The checks use bounds: less than 0.05 m baseline position error, and more than 1.5 m error with positive turning in the biased-gyro case. Those bounds verify this fixture's intended effect; they are not an accuracy specification for a robot.

The comparison changes the IMU selection bit while keeping the supplied sequence and covariance settings fixed. The wheel-only case has an advantage by construction: its velocities agree with the reference. This result demonstrates a configuration effect, not a general reason to omit IMUs.

The script checks output timestamps, frame IDs, finite values, and the transform associated with the output pose. A topic that publishes successfully can still contain an incorrect estimate; the reference comparison checks a different part of the problem.

Diagnose an unexpected estimate

ObservationCheck before tuning
No filtered messagesInspect sensor topics, types, selected fields, compatible QoS, and clock readiness.
A valid IMU appears to have no effectRead imu0_config; orientation, angular velocity, and acceleration are separate choices.
The robot curves while the reference stays straightCompare both yaw-rate measurements, units, axis signs, and reported variances.
TF consumers see conflicting motionCheck whether two nodes publish the same parent-child edge.
The filter is certain but wrongCheck bias, duplicated evidence, timing, and model assumptions before reducing covariance.

Use ROS 2 QoS diagnosis when endpoints exist but messages do not arrive. Use the bag recording and replay lesson to separate message stamps from a node's current time. The download here supplies its own messages and clock; those other tutorials use separate fixtures.

The settings differential and relative solve different pose-input problems. Differential mode turns successive pose changes into velocity measurements; relative mode expresses poses relative to the first received pose. Neither is a gyro-bias correction, and neither is needed for the selected velocity fields here.

Separate local odometry from global localization

The output integrates local motion from a chosen origin. Without an absolute position or heading source, it cannot recover a global map pose or remove accumulated drift. Producing an odom → base_link transform does not create a map.

The SLAM Toolbox tutorial adds a mapping node that consumes scans and odometry TF, publishes a grid, and owns map → odom. Its supplied odometry is a separate fixture; the two downloads do not run as one combined stack.

The package also supports other sensors and frame arrangements. With a map world frame, its TF role changes and another source must supply odom → base_link; read the state-estimation node reference before copying a configuration into a larger stack.

This check covers supplied planar velocity measurements and one persistent gyro error. It does not test calibration, hardware timing, wheel slip, accelerometer integration, GNSS, a UKF, or statistical consistency across real sensor runs. The saved reference errors do not establish a localization benchmark.

For a complete trajectory comparison, continue with timestamp association and trajectory-error metrics. For the separate problem of estimating a pose while building a map, continue with SLAM.

Check two configuration decisions

Exercise 1. Your IMU message contains orientation, angular velocity, and linear acceleration. Its imu0_config enables only entry 12. Which quantity enters the filter? Would giving the orientation a smaller covariance change this selection?

Check the selected IMU quantity

Entry 12 is vyaw, the angular velocity about z. Orientation and acceleration remain excluded. Changing the orientation covariance does not enable its fields; selection comes from the configuration mask. First decide which measurements you intend to fuse, then provide their frames and justified uncertainty.

Exercise 2. A wheel driver and the EKF both publish odom → base_link. The EKF also publishes odometry/filtered. Does subscribing to that odometry topic remove the conflicting TF authority?

Check the transform authority

No. Topic subscriptions do not disable transform publishers. Choose the source that should own the edge and disable the other TF publication. The driver can continue publishing wheel odometry as a filter input while the EKF broadcasts the filtered transform.

Sources and further study