A C++ ROS 2 package that estimates a mobile robot's planar pose (x, y, yaw) in a known occupancy grid map. It combines wheel odometry with laser range measurements using a particle filter, publishes the estimated pose and particle cloud, and broadcasts the map-to-odometry transform.
The package contains the localization node and filter implementation. A robot or simulator, map publisher, sensor drivers, and their TF transforms must be supplied separately; no launch files, maps, or simulation worlds are included.
- Wait for an occupancy grid, odometry, and laser scan, then look up the laser's pose relative to the robot base.
- Initialize particles around
(0, 0, 0)in the global frame, with standard deviations of0.1 min x/y andπ/4 radin yaw. - Apply an odometry motion model with configurable translation and rotation noise.
- Sample beams from the scan and ray-cast through the map to calculate expected ranges. Score particles using hit, short-return, maximum-range, and random-return terms.
- Normalize particle weights and perform systematic resampling when the effective particle count is below the configured particle count.
- Compute the weighted mean state and publish the pose, particle markers, and TF transform.
The node runs a fixed 30 Hz loop using the latest received messages. Updates are not synchronized to scan timestamps and can reuse the same scan across loop iterations.
Use a ROS 2 environment with ament_cmake, colcon, a C++ compiler, and CMake 3.8 or newer. The build requires:
rclcpp,std_msgs,sensor_msgs,nav_msgs,geometry_msgs, andvisualization_msgstf2,tf2_ros, andtf2_geometry_msgs- Eigen3
ament_lint_autoandament_lint_commonwhen building with testing enabled
No specific ROS 2 distribution is declared or verified in this repository. Source your ROS 2 installation before building. Place this repository at ~/ros2_ws/src/pf_localization, then run:
cd ~/ros2_ws
rosdep install --from-paths src --ignore-src -r -y
colcon build --packages-select pf_localization
source install/setup.bashThe current package.xml lists tf2_ros2, while CMakeLists.txt requires tf2_ros, and does not declare Eigen3. If dependency resolution fails, correct these manifest entries or ensure tf2_ros and Eigen3 are installed separately before building. On Ubuntu, Eigen3 is provided by libeigen3-dev.
Start your robot or simulator, map publisher, and TF publishers, then launch the node from a terminal with the workspace sourced:
ros2 run pf_localization pf_localizationThe executable is pf_localization; its ROS node name is pf_localization_node. It begins initializing only after all three input topics have delivered a message. A successful initialization logs pf created!.
For a robot using different topic and frame names:
ros2 run pf_localization pf_localization --ros-args \
-r diff_drive/odometry:=/odom \
-r diff_drive/scan:=/scan \
-p base_frame_id:=base_link \
-p odom_frame_id:=odom \
-p global_frame_id:=map \
-p num_particles:=500 \
-p laser_beams:=16For simulation or bag playback using /clock, add -p use_sim_time:=true and ensure a clock publisher is running.
Save the following as pf_params.yaml in your working directory:
pf_localization_node:
ros__parameters:
base_frame_id: diff_drive/base_link
odom_frame_id: diff_drive/odom
global_frame_id: map
num_particles: 100
laser_beams: 8
laser_sigma_hit: 0.2
laser_z_hit: 0.95
laser_z_rand: 0.05
laser_z_short: 0.1
laser_z_max: 0.05
laser_lambda_short: 0.1
odom_alpha1: 0.2
odom_alpha2: 0.2
odom_alpha3: 0.2
odom_alpha4: 0.2Load it with:
ros2 run pf_localization pf_localization --ros-args --params-file pf_params.yamlParameters are read at startup; changing them while the node runs does not reconfigure the filter.
Topic names below are relative and resolve under the node's namespace. All subscriptions and publishers use a queue depth of 10 with the default ROS 2 QoS settings.
| Topic | Message type | Purpose |
|---|---|---|
map |
nav_msgs/msg/OccupancyGrid |
Known map for predicting laser ranges. |
diff_drive/odometry |
nav_msgs/msg/Odometry |
Robot x/y position and yaw for the motion model. |
diff_drive/scan |
sensor_msgs/msg/LaserScan |
Laser ranges; the first scan also supplies scan geometry and the sensor frame. |
| Topic | Message type | Purpose |
|---|---|---|
pose |
geometry_msgs/msg/PoseStamped |
Estimated robot pose in global_frame_id, stamped with the node's current time. |
particles |
visualization_msgs/msg/MarkerArray |
Green arrows showing particle positions and headings in global_frame_id. |
The pose output does not include covariance.
The expected frame tree with default parameters is:
map global_frame_id
└── diff_drive/odom odom_frame_id
└── diff_drive/base_link base_frame_id
└── <laser frame> LaserScan.header.frame_id
This node broadcasts global_frame_id → odom_frame_id (map → diff_drive/odom by default). Your robot must provide the odometry-to-base and base-to-laser transforms. The node looks up the latest available transforms.
The map-to-odometry transform is calculated as:
T_map_odom = T_map_base × inverse(T_odom_base)
Use only one publisher for this map-to-odometry transform.
Distances are in meters and angles are in radians.
| Parameter | Default | Meaning |
|---|---|---|
base_frame_id |
diff_drive/base_link |
Robot base frame used in TF lookups. |
odom_frame_id |
diff_drive/odom |
Odometry frame and child frame of the published TF. |
global_frame_id |
map |
Global frame for the estimated pose, particles, and TF parent. |
num_particles |
100 |
Number of pose hypotheses. More particles increase computation. |
laser_beams |
8 |
Controls beam subsampling using an index stride of ranges.size() / laser_beams; the actual number of beams evaluated can exceed this value. |
laser_sigma_hit |
0.2 |
Width of the Gaussian score for measured versus predicted range. |
laser_z_hit |
0.95 |
Weight of the expected-obstacle hit term. |
laser_z_rand |
0.05 |
Weight of the uniform random-return term. |
laser_z_short |
0.1 |
Weight of unexpected returns shorter than the predicted range. |
laser_z_max |
0.05 |
Weight of maximum-range returns. |
laser_lambda_short |
0.1 |
Exponential decay rate for short returns. |
odom_alpha1 |
0.2 |
Rotation noise caused by rotation. |
odom_alpha2 |
0.2 |
Rotation noise caused by translation. |
odom_alpha3 |
0.2 |
Translation noise caused by translation. |
odom_alpha4 |
0.2 |
Translation noise caused by rotation. |
The laser weights are used directly as scoring coefficients and are not normalized by the implementation. Keep num_particles > 0, laser_sigma_hit > 0, and odometry noise coefficients nonnegative. Scans need at least two range samples, and laser_beams must satisfy 1 <= laser_beams <= ranges.size() to avoid a zero sampling stride or division by zero. These constraints are not validated by the node.
In RViz2, set the fixed frame to global_frame_id (default map) and add:
- Map for
/map. - Pose for
/pose. - MarkerArray for
/particles. - TF to inspect the frame tree.
Adjust topic names if running under a namespace or using remappings. You can inspect the inputs and output from the command line:
ros2 node info /pf_localization_node
ros2 topic echo /pose --once
ros2 topic info /map --verbose
ros2 topic info /diff_drive/scan --verbose
ros2 run tf2_ros tf2_echo diff_drive/odom diff_drive/base_linkIf no pose appears, confirm that all input topics have delivered messages and that base-to-laser TF is available. Repeated Could not transform warnings during initialization indicate a missing or mismatched laser transform. The odometry-to-base lookup during pose publication is not caught; a missing transform at that stage can terminate the node.
Check QoS compatibility if topics exist but messages do not arrive. The default reliable subscriptions may not connect to best-effort sensor publishers. The map subscription is volatile, so it does not request a retained map from a transient-local publisher; ensure a map is published after the node starts or adapt the subscription QoS.
- Initialization is local around the global origin. There is no
/initialposesubscriber, configurable initial pose, or global particle initialization. The previous odometry state also starts at zero, so the first odometry reading is treated as motion from that origin. - The occupancy grid is converted and cached on the first localization update; later map changes are not incorporated into the filter. Only cells with occupancy value
0are considered free; unknown and nonzero cells block ray casting. - Map coordinates use the map origin's translation and resolution, but ignore its orientation. Use an axis-aligned map whose coordinates agree with
global_frame_id. - Scan angles and maximum range are captured at filter creation. The filter assumes evenly spaced scan angles and does not explicitly filter invalid readings or use
range_min. - Pose yaw is calculated with an arithmetic weighted mean, which can give an incorrect result when particle headings straddle
-πandπ. - The repository provides lint integration through
ament_lint_auto, but no dedicated algorithm tests or scripts to reproduce the plots below.
pf_localization/
├── CMakeLists.txt Build and installation rules
├── package.xml ROS 2 package metadata
├── include/pf_localization/ Public node, filter, and utility headers
├── src/
│ ├── main.cpp ROS initialization and node entry point
│ ├── pf_localization.cpp Topics, parameters, TF, and visualization
│ ├── particle_filter.cpp Motion model, laser scoring, and resampling
│ └── utils.cpp Pose conversions, grid conversion, and ray casting
└── images/ Example trajectory, particle cloud, and error plots
The build produces the pf_localization executable and the shared libraries pf_localization_core, particle_filter, and pf_utils.
These images are included in the repository as example results; the dataset and commands used to generate them are not included.


