How to Fuse GNSS and SLAM for Reliable Outdoor Localization
Adrian Krzemiński,

TL;DR: To fuse GNSS and SLAM in ROS 2 for reliable outdoor localization, you run two Extended Kalman Filters from robot_localization: a local odometry EKF (wheel odometry + IMU + continuous SLAM/VO pose) publishing odom -> base_link, and a global EKF that adds GNSS via navsat_transform_node publishing map -> odom. This setup keeps the robot localized when GNSS drops out (GPS-denied zones like tunnels or dense canopy) and corrects SLAM drift on long outdoor traverses. Below you get a working launch file, a parameter YAML, the fusion diagram, and a GPS-denied fallback recipe suitable for a Leo Rover-class UGV.
Why you need to fuse GNSS and SLAM for outdoor localization
Outdoor mobile robots rarely get reliable pose from a single source. GNSS, with RTK corrections, can give you horizontal accuracy of about 1-2 cm in open sky, but degrades to meters under trees, near buildings, or indoors. SLAM (LiDAR or visual) gives you smooth, locally consistent pose but drifts over hundreds of meters and can fail in feature-poor environments like open fields or snow. Fusing GNSS and SLAM in ROS 2 lets you keep the strengths of both: global anchoring from satellites and local precision from SLAM, with wheel odometry and IMU filling the gaps.
This guide targets engineers running ROS 2 Humble or Jazzy on a UGV such as the Leo Rover used in research or similar field platforms. The same pattern applies to agricultural robots, inspection rovers, and analog planetary platforms used in space robotics testing.
What the GNSS + SLAM fusion architecture looks like
The canonical ROS 2 stack follows the REP-105 frame convention: map -> odom -> base_link. You split estimation into two filters so that local odometry stays continuous even when global corrections jump.
The data flow for fusing GNSS and SLAM in ROS 2 looks like this:
- Sensors: wheel encoders, IMU, GNSS receiver (ideally RTK), LiDAR or stereo camera.
- SLAM/odometry node: a LiDAR or visual SLAM front end publishes a continuous pose on
/slam/pose. If you useslam_toolbox, make sure it does not also publish a competingmap -> odomTF whenrobot_localizationowns that transform. - Local EKF (
ekf_local): fuses wheel odom + IMU + continuous SLAM/VO pose, outputs/odometry/filteredand TFodom -> base_link. - navsat_transform_node: converts
sensor_msgs/NavSatFix+ IMU heading + the current fused odometry estimate into an odometry message in themapframe. - Global EKF (
ekf_global): fuses wheel odom + IMU + GNSS-derived odometry, outputs/odometry/filtered_mapand TFmap -> odom.
Conceptually:
[wheel odom] + [IMU] + [SLAM/VO pose] -> ekf_local -> odom->base_link
[GNSS fix] + [IMU heading] + [filtered odometry] -> navsat_transform_node -> /odometry/gps
[wheel odom] + [IMU] + [/odometry/gps] -> ekf_global -> map->odom
Why two EKFs instead of one
A single global filter would propagate every GNSS jump into the odom frame, breaking the REP-105 guarantee that odom -> base_link is continuous. Path planners and controllers like Nav2 rely on that continuity. The two-EKF pattern, recommended in the robot_localization documentation, isolates discontinuities to the map -> odom transform.
How to configure robot_localization for GNSS and SLAM fusion
Install the dependencies first. On a Leo Rover or any ROS 2 Humble system:
sudo apt install ros-humble-robot-localization ros-humble-slam-toolbox ros-humble-nmea-navsat-driver
Below is a working ekf.yaml covering both filters. Place it under config/ekf.yaml in your robot bringup package.
ekf_local:
ros__parameters:
frequency: 30.0
two_d_mode: true
publish_tf: true
map_frame: map
odom_frame: odom
base_link_frame: base_link
world_frame: odom
odom0: /wheel/odometry
odom0_config: [false, false, false,
false, false, false,
true, true, false,
false, false, true,
false, false, false]
imu0: /imu/data
imu0_config: [false, false, false,
true, true, true,
false, false, false,
false, false, true,
true, true, true]
pose0: /slam/pose
pose0_config: [true, true, false,
false, false, true,
false, false, false,
false, false, false,
false, false, false]
ekf_global:
ros__parameters:
frequency: 30.0
two_d_mode: true
publish_tf: true
map_frame: map
odom_frame: odom
base_link_frame: base_link
world_frame: map
odom0: /wheel/odometry
odom0_config: [false, false, false, false, false, false,
true, true, false, false, false, true, false, false, false]
imu0: /imu/data
imu0_config: [false, false, false, true, true, true,
false, false, false, false, false, true, true, true, true]
odom1: /odometry/gps
odom1_config: [true, true, false, false, false, false,
false, false, false, false, false, false, false, false, false]
navsat_transform:
ros__parameters:
frequency: 30.0
delay: 3.0
magnetic_declination_radians: 0.0
yaw_offset: 0.0
zero_altitude: true
broadcast_utm_transform: false
publish_filtered_gps: true
use_odometry_yaw: false
wait_for_datum: false
Set magnetic_declination_radians and yaw_offset for your location and IMU convention. The values above are placeholders for an IMU that already reports ENU yaw with zero facing east.
How to launch the full stack
This minimal Python launch file wires everything together for fusing GNSS and SLAM on a ROS 2 robot:
from launch import LaunchDescription
from launch_ros.actions import Node
import os
from ament_index_python.packages import get_package_share_directory
def generate_launch_description():
cfg = os.path.join(get_package_share_directory('leo_localization'), 'config', 'ekf.yaml')
return LaunchDescription([
Node(package='robot_localization', executable='ekf_node', name='ekf_local',
parameters=[cfg],
remappings=[('odometry/filtered', '/odometry/filtered')]),
Node(package='robot_localization', executable='ekf_node', name='ekf_global',
parameters=[cfg],
remappings=[('odometry/filtered', '/odometry/filtered_map')]),
Node(package='robot_localization', executable='navsat_transform_node',
name='navsat_transform', parameters=[cfg],
remappings=[('imu/data', '/imu/data'),
('gps/fix', '/fix'),
('odometry/filtered', '/odometry/filtered_map'),
('odometry/gps', '/odometry/gps')]),
])
How to handle GPS-denied zones with SLAM fallback
The reason you fuse GNSS and SLAM rather than rely on GNSS alone is exactly the GPS-denied case. When the rover enters a tunnel, a forest canopy, or a steel-roofed greenhouse, the GNSS receiver either stops publishing or reports a degraded covariance.
Three practical safeguards keep your robot localized in GPS-denied conditions:
- Trust the covariance. A well-configured u-blox ZED-F9P or similar RTK module publishes
NavSatFix.position_covariancethat grows as fix quality drops from RTK fixed (cm-level) to RTK float (dm-level to sub-meter) to standalone GNSS (meter-level). The EKF naturally down-weights high-covariance measurements. Verify your driver actually populates this field; some NMEA-only drivers may not. - Gate by fix status. Add a small node that drops
NavSatFixmessages withstatus.status < 0(no fix) before they reachnavsat_transform_node. This prevents stale or invalid coordinates from being injected. - Let SLAM carry the pose. Inside the tunnel, the local EKF keeps fusing IMU, wheel odometry, and SLAM. When GNSS returns,
navsat_transform_noderesumes feeding/odometry/gpsand the global EKF corrects accumulated drift in themap -> odomtransform.
In a 40 m concrete underpass, a well-tuned configuration using slam_toolbox or another 2D LiDAR SLAM front end can keep lateral error in the low-decimeter range, with recovery after GNSS reacquisition depending on sensor quality, covariance tuning, and environment.
How to choose the right GNSS hardware
The fusion quality is bounded by the worst sensor. Consumer GNSS modules with 2-5 m accuracy will dominate SLAM corrections and inject noise into the global filter. For research and field robotics, an RTK-capable receiver is the practical baseline. Fictionlab offers a dedicated RTK GNSS module compatible with Leo Rover that publishes sensor_msgs/NavSatFix with covariance, which plugs directly into the configuration above.
Pair RTK with a tactical-grade or at minimum a well-calibrated MEMS IMU. The yaw estimate from an absolute heading source, such as a magnetometer-fused IMU or dual-antenna GNSS, is what navsat_transform_node uses to align the UTM frame to your map frame on startup, so a 5-degree heading error can become a meters-scale offset over longer traverses.
How to validate the fused localization
Before deploying, confirm the fusion behaves correctly with these checks:
- Run
ros2 run tf2_tools view_framesand verify the tree showsmap -> odom -> base_linkwith the right publishers. - Echo
/odometry/filtered(local) and confirm it stays smooth when you cover the GNSS antenna. - Echo
/odometry/filtered_map(global) and confirm it pulls back to the GNSS-anchored position when the fix returns. - Drive a closed loop of approximately 100 m outdoors and compare start and end positions in the
mapframe. With RTK and SLAM fused, the closure error should sit in the centimeter to low-decimeter range in open-sky conditions.
This same validation workflow is used in robotics education programs where students learn sensor fusion fundamentals on accessible hardware.
Frequently asked questions
Do you need RTK GNSS to fuse GNSS and SLAM?
No, but RTK significantly improves the global anchor quality. Standard GNSS at 2-5 m accuracy still works for coarse global positioning and bias correction of SLAM drift over long distances. Use RTK when you need decimeter or centimeter accuracy in the map frame.
Can you fuse GNSS with visual SLAM instead of LiDAR SLAM?
Yes. Replace slam_toolbox with rtabmap_ros or an ORB-SLAM3 ROS wrapper and feed its pose output into the local EKF’s pose0 input. The fusion architecture does not change. Visual SLAM is more sensitive to lighting and texture, so weight the covariance accordingly.
Why does the robot jump when GNSS reacquires after a tunnel?
The map -> odom transform corrects accumulated SLAM and odometry drift in one step. This is by design and respects REP-105. If the jump is too aggressive, increase the GNSS measurement covariance in the NavSatFix or converted odometry message, or tune the EKF rejection thresholds and process noise.
Should you publish the TF from both EKFs?
Set publish_tf: true on both, but the local EKF publishes odom -> base_link and the global EKF publishes map -> odom. The world_frame parameter determines which transform each filter emits. Never let two nodes publish the same transform.
How do you handle heading initialization without a magnetometer?
Set use_odometry_yaw: true in navsat_transform_node only if the odometry orientation already contains a globally meaningful yaw, for example from dual-antenna GNSS, a gyrocompass, or another absolute heading source. navsat_transform_node does not estimate initial heading from GNSS track just because the robot drives forward.
Can you use this setup with Nav2?
Yes. Nav2 can consume /odometry/filtered (local) for its controllers and use the map -> odom -> base_link TF chain for global planning. The two-EKF pattern is a common robot_localization configuration for outdoor Nav2 deployments.
What happens if SLAM loses tracking and GNSS is also unavailable?
The EKFs fall back to dead reckoning using wheel odometry and IMU integration. Expect drift on the order of 1-5% of distance traveled for well-calibrated wheel encoders on hard terrain, considerably worse on slippery or soft ground. Plan recovery behaviors (stop and wait for GNSS, or trigger relocalization) for this case.
Build your outdoor autonomy stack with the right platform
Fusing GNSS and SLAM is one block of a larger field autonomy stack. If you are integrating outdoor localization into a research project, an inspection deployment, or a custom UGV, Fictionlab’s team builds and supports robotic platforms ready for this exact workflow. Explore custom robotics solutions from Fictionlab to discuss your sensor stack, integration timeline, and field requirements.