demo(turtlebot3): FusionCore + icp_odometry feedback loop (issue #1418 Option A) (#1419)

* demo(turtlebot3): add FusionCore + icp_odometry feedback loop demo

Adds a TurtleBot3 Gazebo Harmonic demo (ref issue #1418 Option A) where
FusionCore (wheel + IMU UKF) and icp_odometry tighten each other in a
feedback loop: FusionCore's stable odom frame serves as the scan-match
initial guess via guess_frame_id, and the ICP result feeds back into
FusionCore as a second velocity source.  rtabmap SLAM consumes the ICP
output for mapping/loop-closure.  Nav2 uses /fusion/odom.

Files added:
  launch/turtlebot3/fusioncore/turtlebot3_fusioncore_icp.launch.py
  launch/turtlebot3/fusioncore/turtlebot3_sim_fusioncore_icp_demo.launch.py
  launch/turtlebot3/fusioncore/README.md
  params/fusioncore_tb3.yaml
  params/fusioncore_tb3_bridge.yaml          (TF-conflict-free bridge)
  params/turtlebot3_fusioncore_icp_nav2_params.yaml

* demo(turtlebot3): add FusionCore demo entry to rtabmap_demos README

* fix(fusioncore-demo): remove Odom/ResetCountdown from rtabmap, strip GPS params

* docs(demos): add FusionCore icp_odometry demo GIF to README

* fix(fusioncore-demo): add imu.frame_id override and docking_server params

* fix(fusioncore-demo): remove docking_server from nav2 params

* Fixing turtlebot3 demos on Jazzy

* Updated humble

* Working turtlebot3 demos on humble and jazzy

* Fixed Twist->TwistStamped. Fixed rviz2 crashing because missing docking server

* demo(turtlebot3): add FusionCore + icp_odometry feedback loop demo

Adds a TurtleBot3 Gazebo Harmonic demo (ref issue #1418 Option A) where
FusionCore (wheel + IMU UKF) and icp_odometry tighten each other in a
feedback loop: FusionCore's stable odom frame serves as the scan-match
initial guess via guess_frame_id, and the ICP result feeds back into
FusionCore as a second velocity source.  rtabmap SLAM consumes the ICP
output for mapping/loop-closure.  Nav2 uses /fusion/odom.

Files added:
  launch/turtlebot3/fusioncore/turtlebot3_fusioncore_icp.launch.py
  launch/turtlebot3/fusioncore/turtlebot3_sim_fusioncore_icp_demo.launch.py
  launch/turtlebot3/fusioncore/README.md
  params/fusioncore_tb3.yaml
  params/fusioncore_tb3_bridge.yaml          (TF-conflict-free bridge)
  params/turtlebot3_fusioncore_icp_nav2_params.yaml

* demo(turtlebot3): add FusionCore demo entry to rtabmap_demos README

* fix(fusioncore-demo): remove Odom/ResetCountdown from rtabmap, strip GPS params

* docs(demos): add FusionCore icp_odometry demo GIF to README

* fix(fusioncore-demo): add imu.frame_id override and docking_server params

* fix(fusioncore-demo): remove docking_server from nav2 params

* Fixing turtlebot3 demos on Jazzy

* Updated humble

* Fixed Twist->TwistStamped. Fixed rviz2 crashing because missing docking server

* fix(fusioncore-demo): tune IMU noise for Gazebo sim, add real-hardware comments

* docs(fusioncore-demo): add sim vs real hardware note to README

---------

Co-authored-by: manankharwar <[email protected]>
Co-authored-by: matlabbe <[email protected]>
This commit is contained in:
Manan Kharwar
2026-05-13 22:26:25 -07:00
committed by GitHub
co-authored by manankharwar matlabbe
parent d9cd332c53
commit aec4f91f61
7 changed files with 834 additions and 0 deletions
@@ -0,0 +1,91 @@
# FusionCore + icp_odometry: TurtleBot3 Gazebo Demo
This demo shows a feedback loop between [FusionCore](https://github.com/manankharwar/fusioncore) and rtabmap's `icp_odometry` where each node tightens the other.
## Architecture
```
/imu ──────────────────────┐
/odom (wheel) ──────────────┤──→ FusionCore (UKF)
/rtabmap/icp_odometry ──────┘ │
↑ │ publishes: odom → base_footprint TF
│ │ /fusion/odom
│ guess_frame_id: odom│
└────── icp_odometry ←───────┘
│ (publish_tf: false)
│
└──→ /rtabmap/icp_odometry ──→ rtabmap SLAM ──→ map → odom TF
```
**What each node contributes:**
| Node | Input | Provides |
|---|---|---|
| FusionCore | wheels + IMU | stable `odom` frame, continuous state at 100 Hz |
| icp_odometry | `/scan` + FusionCore's `odom` as initial guess | scan-level pose corrections |
| FusionCore encoder2 | icp_odometry output | tighter velocity corrections from ICP |
| rtabmap SLAM | icp_odometry output | global map, loop closures |
FusionCore gives `icp_odometry` a stable initial guess via `guess_frame_id: odom`.
Better initial guesses mean scan matching succeeds more often and with lower error.
The ICP result feeds back into FusionCore as a second velocity source (`encoder2`),
tightening the state estimate further. `Odom/ResetCountdown: 1` lets the system
auto-recover if ICP loses tracking.
## Simulation vs real hardware
In Gazebo, the DiffDrive plugin produces near-perfect wheel velocities with no slip or
encoder noise, while the simulated MPU9250 injects Gaussian noise. FusionCore fusing
both means the noisy IMU slightly degrades what is already a perfect odometry source,
so the `map → odom` correction on each scan update will be slightly larger than in the
standard wheel-odometry-only demo. On real hardware this completely inverts: wheel
encoders accumulate slip, terrain variation, and mechanical error that dwarfs IMU noise,
and fusion pays off measurably. The sim-tuned IMU noise values in `fusioncore_tb3.yaml`
(`gyro_noise: 0.002`, `accel_noise: 0.02`) reduce unnecessary filter uncertainty in
simulation; real MPU9250 users should use the hardware spec values noted in that file.
## Quick start
```bash
export TURTLEBOT3_MODEL=waffle
ros2 launch rtabmap_demos turtlebot3_sim_fusioncore_icp_demo.launch.py
```
Optional arguments:
```bash
# Localization mode (requires saved map from a previous mapping run)
ros2 launch rtabmap_demos turtlebot3_sim_fusioncore_icp_demo.launch.py localization:=true
# Different Gazebo world
ros2 launch rtabmap_demos turtlebot3_sim_fusioncore_icp_demo.launch.py world:=house
```
## Prerequisites
```bash
sudo apt install ros-jazzy-fusioncore-ros ros-jazzy-turtlebot3-gazebo ros-jazzy-rtabmap-ros ros-jazzy-nav2-bringup
export TURTLEBOT3_MODEL=waffle
```
## Files
| File | Purpose |
|---|---|
| `turtlebot3_sim_fusioncore_icp_demo.launch.py` | Complete demo: Gazebo + FusionCore + rtabmap + Nav2 |
| `turtlebot3_fusioncore_icp.launch.py` | Core only: FusionCore + icp_odometry + rtabmap (no Gazebo) |
| `../../params/fusioncore_tb3.yaml` | FusionCore config for TB3 Waffle |
| `../../params/turtlebot3_fusioncore_icp_nav2_params.yaml` | Nav2 config using `/fusion/odom` |
## Topic and TF summary
| Topic / TF | Publisher | Subscribers |
|---|---|---|
| `/imu` | Gazebo | FusionCore |
| `/odom` | Gazebo (wheel) | FusionCore |
| `/scan` | Gazebo (lidar) | icp_odometry, rtabmap |
| `/rtabmap/icp_odometry` | icp_odometry | FusionCore (encoder2), rtabmap |
| `/fusion/odom` | FusionCore | Nav2 |
| TF `odom → base_footprint` | FusionCore | icp_odometry (guess), Nav2 |
| TF `map → odom` | rtabmap | Nav2 |
@@ -0,0 +1,165 @@
"""
FusionCore + icp_odometry feedback loop for TurtleBot3.
Architecture (Option A from rtabmap_ros issue #1418):
FusionCore (wheels + IMU)
|-- publishes: odom -> base_footprint TF, /fusion/odom
|-- provides initial pose guess to icp_odometry via guess_frame_id
icp_odometry (/scan)
|-- guess_frame_id: odom (uses FusionCore's stable odom as scan match seed)
|-- publish_tf: false (FusionCore owns the odom TF)
|-- publishes: /rtabmap/icp_odometry
FusionCore encoder2
|-- topic: /rtabmap/icp_odometry
|-- ICP corrections fed back as a second velocity source
rtabmap SLAM
|-- subscribes to /rtabmap/icp_odometry for mapping
|-- Odom/ResetCountdown: 1 for auto-recovery if ICP loses tracking
|-- publishes: map -> odom TF
"""
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (DeclareLaunchArgument, EmitEvent,
OpaqueFunction, RegisterEventHandler, TimerAction)
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import LifecycleNode, Node
from launch_ros.event_handlers import OnStateTransition
from launch_ros.events.lifecycle import ChangeState
from lifecycle_msgs.msg import Transition
def launch_setup(context, *args, **kwargs):
use_sim_time = LaunchConfiguration('use_sim_time')
localization = LaunchConfiguration('localization').perform(context)
localization = localization in ('True', 'true')
pkg_demos = get_package_share_directory('rtabmap_demos')
fusioncore_config = os.path.join(pkg_demos, 'params', 'fusioncore_tb3.yaml')
# ── FusionCore lifecycle node ─────────────────────────────────────────────
fc = LifecycleNode(
package='fusioncore_ros',
executable='fusioncore_node',
name='fusioncore',
namespace='',
output='screen',
parameters=[fusioncore_config, {'use_sim_time': use_sim_time}],
remappings=[
('/imu/data', '/imu'), # TB3 Gazebo IMU topic
('/odom/wheels', '/odom'), # TB3 Gazebo wheel odometry topic
],
)
# Wait 2 s for node to spin up, then configure
configure = TimerAction(
period=2.0,
actions=[EmitEvent(event=ChangeState(
lifecycle_node_matcher=lambda a: a is fc,
transition_id=Transition.TRANSITION_CONFIGURE,
))],
)
# As soon as configuring -> inactive, activate
activate = RegisterEventHandler(OnStateTransition(
target_lifecycle_node=fc,
start_state='configuring',
goal_state='inactive',
entities=[EmitEvent(event=ChangeState(
lifecycle_node_matcher=lambda a: a is fc,
transition_id=Transition.TRANSITION_ACTIVATE,
))],
))
# ── icp_odometry ──────────────────────────────────────────────────────────
icp_parameters = {
'frame_id': 'base_footprint',
'odom_frame_id': 'odom',
'guess_frame_id': 'odom',
'publish_tf': False,
'publish_null_when_lost': False,
'use_sim_time': use_sim_time,
'Reg/Strategy': '1',
'Reg/Force3DoF': 'true',
'Odom/ResetCountdown': '1',
'RGBD/NeighborLinkRefining': 'True',
'Grid/RangeMin': '0.2',
}
icp_odometry_node = Node(
package='rtabmap_odom',
executable='icp_odometry',
output='screen',
parameters=[icp_parameters],
remappings=[
('scan', '/scan'),
('odom', '/rtabmap/icp_odometry'),
],
)
# ── rtabmap SLAM ──────────────────────────────────────────────────────────
slam_parameters = {
'frame_id': 'base_footprint',
'odom_frame_id': 'odom',
'use_sim_time': use_sim_time,
'subscribe_depth': False,
'subscribe_rgb': False,
'subscribe_scan': True,
'approx_sync': True,
'use_action_for_goal': True,
'Reg/Strategy': '1',
'Reg/Force3DoF': 'true',
'RGBD/NeighborLinkRefining': 'True',
'Grid/RangeMin': '0.2',
'Optimizer/GravitySigma': '0',
}
if localization:
slam_parameters['Mem/IncrementalMemory'] = 'False'
slam_parameters['Mem/InitWMWithAllNodes'] = 'True'
rtabmap_args = [] if localization else ['-d']
rtabmap_node = Node(
package='rtabmap_slam',
executable='rtabmap',
output='screen',
parameters=[slam_parameters],
remappings=[
('scan', '/scan'),
('odom', '/rtabmap/icp_odometry'),
],
arguments=rtabmap_args,
)
rtabmap_viz_node = Node(
package='rtabmap_viz',
executable='rtabmap_viz',
output='screen',
parameters=[slam_parameters, {'odometry_node_name': 'icp_odometry'}],
remappings=[
('scan', '/scan'),
('odom', '/rtabmap/icp_odometry'),
],
)
return [fc, configure, activate, icp_odometry_node, rtabmap_node, rtabmap_viz_node]
def generate_launch_description():
return LaunchDescription([
DeclareLaunchArgument(
'use_sim_time', default_value='true',
description='Use simulation (Gazebo) clock if true'),
DeclareLaunchArgument(
'localization', default_value='false',
description='Launch in localization mode (requires existing map)'),
OpaqueFunction(function=launch_setup),
])
@@ -0,0 +1,181 @@
"""
Complete TurtleBot3 demo: Gazebo (new) + FusionCore + icp_odometry + Nav2.
Launches in order:
1. Gazebo Harmonic (via ros_gz_sim) with turtlebot3_world
2. Robot state publisher + spawn TurtleBot3
3. Custom ros_gz_bridge WITHOUT the odom TF (FusionCore owns odom->base_footprint)
4. FusionCore lifecycle node (configure -> activate automatically)
5. icp_odometry using FusionCore's odom frame as scan-match initial guess
6. rtabmap SLAM subscribing to icp_odometry output
7. Nav2 using /fusion/odom
Usage:
export TURTLEBOT3_MODEL=waffle
ros2 launch rtabmap_demos turtlebot3_sim_fusioncore_icp_demo.launch.py
# Localization mode (requires existing map):
ros2 launch rtabmap_demos turtlebot3_sim_fusioncore_icp_demo.launch.py localization:=true
# Different world:
ros2 launch rtabmap_demos turtlebot3_sim_fusioncore_icp_demo.launch.py world:=house
Note on TF ownership:
The standard turtlebot3_gazebo bridge forwards the DiffDrive TF to ROS, which
conflicts with FusionCore's odom->base_footprint. This demo uses a custom bridge
config (fusioncore_tb3_bridge.yaml) that suppresses the Gazebo TF entry.
FusionCore is the sole publisher of odom->base_footprint.
"""
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (AppendEnvironmentVariable, DeclareLaunchArgument,
IncludeLaunchDescription, OpaqueFunction)
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def launch_setup(context, *args, **kwargs):
if 'TURTLEBOT3_MODEL' not in os.environ:
os.environ['TURTLEBOT3_MODEL'] = 'waffle'
tb3_model = os.environ['TURTLEBOT3_MODEL']
pkg_tb3_gz = get_package_share_directory('turtlebot3_gazebo')
pkg_ros_gz = get_package_share_directory('ros_gz_sim')
pkg_nav2 = get_package_share_directory('nav2_bringup')
pkg_demos = get_package_share_directory('rtabmap_demos')
world_name = LaunchConfiguration('world').perform(context)
world_file = os.path.join(pkg_tb3_gz, 'worlds', f'turtlebot3_{world_name}.world')
# ── Gazebo server + client ────────────────────────────────────────────────
gz_server = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(pkg_ros_gz, 'launch', 'gz_sim.launch.py')),
launch_arguments={
'gz_args': f'-r -s -v2 {world_file}',
'on_exit_shutdown': 'true',
}.items(),
)
gz_client = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(pkg_ros_gz, 'launch', 'gz_sim.launch.py')),
launch_arguments={'gz_args': '-g -v2', 'on_exit_shutdown': 'true'}.items(),
)
# ── Robot state publisher ─────────────────────────────────────────────────
robot_state_publisher = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(pkg_tb3_gz, 'launch', 'robot_state_publisher.launch.py')),
launch_arguments={'use_sim_time': 'true'}.items(),
)
# ── Spawn TurtleBot3 (entity only, no bridge) ─────────────────────────────
urdf_path = os.path.join(pkg_tb3_gz, 'models',
f'turtlebot3_{tb3_model}', 'model.sdf')
spawn_robot = Node(
package='ros_gz_sim',
executable='create',
arguments=[
'-name', tb3_model,
'-file', urdf_path,
'-x', LaunchConfiguration('x_pose'),
'-y', LaunchConfiguration('y_pose'),
'-z', '0.01',
],
output='screen',
)
# ── Custom bridge: all topics EXCEPT odom TF ──────────────────────────────
# FusionCore publishes odom->base_footprint; suppress the Gazebo DiffDrive TF.
bridge_config = os.path.join(pkg_demos, 'params', 'fusioncore_tb3_bridge.yaml')
bridge = Node(
package='ros_gz_bridge',
executable='parameter_bridge',
arguments=['--ros-args', '-p', f'config_file:={bridge_config}'],
output='screen',
)
# Camera image bridge (waffle only)
image_bridge = Node(
package='ros_gz_image',
executable='image_bridge',
arguments=['/camera/image_raw'],
output='screen',
)
# ── FusionCore + icp_odometry + rtabmap ───────────────────────────────────
fusioncore_icp = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(pkg_demos, 'launch', 'turtlebot3', 'fusioncore',
'turtlebot3_fusioncore_icp.launch.py')),
launch_arguments=[
('localization', LaunchConfiguration('localization')),
('use_sim_time', 'true'),
],
)
# ── Nav2 ──────────────────────────────────────────────────────────────────
nav2 = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(pkg_nav2, 'launch', 'navigation_launch.py')),
launch_arguments=[
('use_sim_time', 'true'),
('params_file', PathJoinSubstitution(
[FindPackageShare('rtabmap_demos'), 'params',
'turtlebot3_fusioncore_icp_nav2_params.yaml'])),
],
)
rviz = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(pkg_nav2, 'launch', 'rviz_launch.py')),
)
set_gz_resource_path = AppendEnvironmentVariable(
'GZ_SIM_RESOURCE_PATH',
os.path.join(pkg_tb3_gz, 'models'),
)
nodes = [
set_gz_resource_path,
gz_server,
gz_client,
robot_state_publisher,
spawn_robot,
bridge,
fusioncore_icp,
nav2,
rviz,
]
if tb3_model == 'waffle':
nodes.insert(6, image_bridge)
return nodes
def generate_launch_description():
return LaunchDescription([
DeclareLaunchArgument(
'localization', default_value='false',
description='Launch in localization mode.'),
DeclareLaunchArgument(
'world', default_value='world',
choices=['world', 'house', 'dqn_stage1', 'dqn_stage2',
'dqn_stage3', 'dqn_stage4'],
description='Turtlebot3 Gazebo world.'),
DeclareLaunchArgument(
'x_pose', default_value='-2.0',
description='Initial X position in Gazebo.'),
DeclareLaunchArgument(
'y_pose', default_value='-0.5',
description='Initial Y position in Gazebo.'),
OpaqueFunction(function=launch_setup),
])