BlueROV2 underwater robot simulation project using ROS 2 Jazzy, Gazebo Harmonic, SLAM Toolbox, Nav2, and robot_localization EKF.
This project demonstrates:
- BlueROV2 underwater simulation in Gazebo Harmonic
- ROS 2 bridge from Gazebo to ROS 2
- Simulated DVL velocity generated from odometry
- IMU, depth, odometry, and 2D lidar integration
- EKF localization using
robot_localization - 2D lidar mapping with SLAM Toolbox
- Saved-map navigation with Nav2 and AMCL
/cmd_velto BlueROV2 thruster control- Automatic depth control for vertical thrusters
- Custom Gazebo underwater world and BlueROV2 model
Tested with:
- Ubuntu 24.04
- ROS 2 Jazzy
- Gazebo Harmonic
- SLAM Toolbox
- Nav2
- robot_localization
- ros_gz_bridge
- RViz2
- Python 3
unknown_bluerov2_ws/
├── README.md
├── .gitignore
├── assets/
│ ├── bluerov2_drive.gif
│ ├── bluerov2_mapping.gif
│ └── bluerov2_navigation.gif
└── src/
├── bluerov2_gz/
│ ├── models/
│ │ ├── axes/
│ │ ├── bluerov2/
│ │ ├── bluerov2_heavy/
│ │ ├── bluerov2_ping/
│ │ └── sand_heightmap/
│ ├── worlds/
│ │ ├── bluerov2_underwater.world
│ │ ├── bluerov2_heavy_underwater.world
│ │ └── bluerov2_ping.world
│ ├── scripts/
│ ├── params/
│ ├── images/
│ ├── package.xml
│ └── CMakeLists.txt
├── rviz2/
│ ├── bluerov2_mapping.rviz
│ └── bluerov2_navigation.rviz
├── unknown_bluerov2_bringup/
│ ├── unknown_bluerov2_bringup/
│ │ ├── gazebo.launch.py
│ │ └── bridge.launch.py
│ ├── package.xml
│ ├── setup.py
│ └── setup.cfg
├── unknown_bluerov2_description/
│ ├── unknown_bluerov2_description/
│ │ └── __init__.py
│ ├── package.xml
│ ├── setup.py
│ └── setup.cfg
└── unknown_bluerov2_nav/
├── config/
│ ├── ekf_dvl_imu_depth.yaml
│ ├── nav2_saved_map_params.yaml
│ ├── nav2_slam_params.yaml
│ └── slam_toolbox.yaml
├── launch/
│ ├── depth_control.launch.py
│ ├── ekf_localization.launch.py
│ ├── mapping.launch.py
│ ├── nav2_saved_map.launch.py
│ ├── nav2_slam.launch.py
│ ├── static_tf.launch.py
│ ├── tf_with_odom.launch.py
│ └── thruster_mixer.launch.py
├── maps/
│ ├── bluerov2_slam_map.yaml
│ └── bluerov2_slam_map.pgm
├── unknown_bluerov2_nav/
│ ├── cmd_vel_thruster_mixer.py
│ ├── depth_thruster_controller.py
│ ├── odom_tf_publisher.py
│ ├── odom_to_depth_pose.py
│ ├── odom_to_dvl_twist.py
│ └── wait_for_nav_ready.py
├── package.xml
├── setup.py
└── setup.cfg
cd ~
git clone https://github.com/wattanatum/unknown_BlueROV2.git unknown_bluerov2_ws
cd ~/unknown_bluerov2_wssudo apt update
sudo apt install -y \
ros-jazzy-ros-gz-bridge \
ros-jazzy-ros-gz-sim \
ros-jazzy-slam-toolbox \
ros-jazzy-navigation2 \
ros-jazzy-nav2-bringup \
ros-jazzy-robot-localization \
ros-jazzy-tf2-ros \
ros-jazzy-tf2-tools \
ros-jazzy-rviz2 \
ros-jazzy-nav-msgs \
ros-jazzy-geometry-msgs \
ros-jazzy-sensor-msgs \
ros-jazzy-std-msgs \
ros-jazzy-tf2-msgs \
python3-colcon-common-extensions \
python3-rosdep \
python3-pipThe DVL in this project is simulated by the custom node:
unknown_bluerov2_nav/odom_to_dvl_twist.py
No separate DVL package is required.
cd ~/unknown_bluerov2_ws
colcon build --symlink-install
source install/setup.bashTo source automatically every new terminal:
echo "source ~/unknown_bluerov2_ws/install/setup.bash" >> ~/.bashrc
source ~/.bashrcsource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_bringup gazebo.launch.pyThis launch starts Gazebo Harmonic with:
src/bluerov2_gz/worlds/bluerov2_underwater.world
Expected Gazebo / bridge source topics include:
/clock
/scan
/model/bluerov2/odometry
/model/bluerov2/odometry_with_covariance
/world/bluerov2_underwater/model/bluerov2/link/base_link/sensor/imu_sensor/imu
source ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_bringup bridge.launch.pyThis bridge creates ROS 2 topics:
/clock
/imu
/scan
/odom
/thruster1/cmd_thrust
/thruster2/cmd_thrust
/thruster3/cmd_thrust
/thruster4/cmd_thrust
/thruster5/cmd_thrust
/thruster6/cmd_thrust
Check topics:
ros2 topic listCheck odometry:
ros2 topic echo /odom --onceCheck lidar:
ros2 topic echo /scan --onceCheck IMU:
ros2 topic echo /imu --oncesource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav static_tf.launch.pyThis publishes:
base_link -> imu_link
base_link -> dvl_link
base_link -> depth_link
base_link -> laser_link
Check:
ros2 run tf2_ros tf2_echo base_link laser_link
ros2 run tf2_ros tf2_echo base_link imu_link
ros2 run tf2_ros tf2_echo base_link dvl_link
ros2 run tf2_ros tf2_echo base_link depth_linkIf you need odom -> base_link from raw /odom, run:
source ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav tf_with_odom.launch.pyThis starts the static TFs and:
unknown_bluerov2_nav/odom_tf_publisher.py
Manual run:
ros2 run unknown_bluerov2_nav odom_tf_publisher \
--ros-args \
-p use_sim_time:=true \
-p odom_topic:=/odom \
-p parent_frame:=odom \
-p child_frame:=base_linkImportant:
Do not publish odom -> base_link from two places at the same time.
If EKF publishes odom -> base_link, do not run odom_tf_publisher.
Check EKF TF setting:
ros2 param get /ekf_filter_node publish_tfRun EKF and sensor conversion nodes:
source ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav ekf_localization.launch.pyThis launch starts:
odom_to_dvl_twist
odom_to_depth_pose
ekf_filter_node
The sensor conversion nodes generate:
/dvl/twist
/depth/pose
The EKF output is:
/odometry/filtered
Check simulated DVL:
ros2 topic echo /dvl/twist --onceCheck depth pose:
ros2 topic echo /depth/pose --onceCheck EKF output:
ros2 topic echo /odometry/filtered --onceCheck TF:
ros2 run tf2_ros tf2_echo odom base_linkNote: if
/dvl/twistor/depth/posedoes not publish, check the odometry topic used insrc/unknown_bluerov2_nav/launch/ekf_localization.launch.py. If your bridge only publishes/odom, setinput_odom_topicto/odom.
The project includes depth control for the vertical thrusters.
Run depth controller:
source ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav depth_control.launch.pyThis launch starts:
depth_thruster_controller
The depth controller reads odometry from:
/odom
and sends thrust commands to:
/thruster5/cmd_thrust
/thruster6/cmd_thrust
Default depth behavior is configured in:
src/unknown_bluerov2_nav/launch/depth_control.launch.py
Important parameters:
mapping_depth_m: 9.0
shutdown_depth_m: 2.0
kp_depth: 20.0
max_thrust: 150.0
min_active_thrust: 20.0
depth_tolerance_m: 0.15
Check odometry used for depth:
ros2 topic echo /odom --onceCheck vertical thruster output:
ros2 topic echo /thruster5/cmd_thrust
ros2 topic echo /thruster6/cmd_thrustThe thruster mixer converts Nav2 /cmd_vel commands into BlueROV2 horizontal thruster commands.
Typical flow:
Nav2 /cmd_vel -> cmd_vel_thruster_mixer -> Gazebo thrusters
Run thruster mixer:
source ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav thruster_mixer.launch.pyThis launch starts the node:
/cmd_vel_to_thrusters
The mixer subscribes to:
/cmd_vel
and publishes to:
/thruster1/cmd_thrust
/thruster2/cmd_thrust
/thruster3/cmd_thrust
/thruster4/cmd_thrust
Important parameters are configured in:
src/unknown_bluerov2_nav/launch/thruster_mixer.launch.py
Important parameters:
linear_gain
yaw_gain
max_thrust
force_forward_only
block_rotate_in_place
invert_yaw
yaw_deadband
max_yaw_when_moving
yaw_scale_when_moving
Check Nav2 velocity:
ros2 topic echo /cmd_velCheck mixer node:
ros2 node list | grep cmd_velCheck mixer node info:
ros2 node info /cmd_vel_to_thrustersManual forward test:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.2}, angular: {z: 0.0}}" -r 10Manual yaw test:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.0}, angular: {z: 0.2}}" -r 10Stop command:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.0}, angular: {z: 0.0}}" --onceCheck horizontal thruster output:
ros2 topic echo /thruster1/cmd_thrust
ros2 topic echo /thruster2/cmd_thrust
ros2 topic echo /thruster3/cmd_thrust
ros2 topic echo /thruster4/cmd_thrustUse this mode to create a new map.
Do not run saved-map navigation at the same time as SLAM Toolbox mapping.
source ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_bringup gazebo.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_bringup bridge.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav tf_with_odom.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav thruster_mixer.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav depth_control.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav mapping.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav nav2_slam.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
rviz2 -d ~/unknown_bluerov2_ws/src/rviz2/bluerov2_mapping.rvizIf the RViz config does not load, run:
rviz2Then set:
Fixed Frame: map
Useful displays:
/map
/scan
/tf
/odometry/filtered
/local_costmap/costmap
/global_costmap/costmap
/plan
After mapping, save the map:
cd ~/unknown_bluerov2_ws/src/unknown_bluerov2_nav/maps
ros2 run nav2_map_server map_saver_cli -f bluerov2_slam_mapExpected files:
bluerov2_slam_map.yaml
bluerov2_slam_map.pgm
Rebuild after saving maps:
cd ~/unknown_bluerov2_ws
colcon build --packages-select unknown_bluerov2_nav --symlink-install
source install/setup.bashUse this mode after a map has already been created.
Do not run SLAM Toolbox during saved-map navigation.
source ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_bringup gazebo.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_bringup bridge.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav tf_with_odom.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav thruster_mixer.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav depth_control.launch.pysource ~/unknown_bluerov2_ws/install/setup.bash
ros2 launch unknown_bluerov2_nav nav2_saved_map.launch.pyThis launch starts saved-map localization and navigation nodes such as:
map_server
amcl
controller_server
smoother_server
planner_server
behavior_server
bt_navigator
wait_for_nav_ready
lifecycle_manager_localization
lifecycle_manager_navigation
source ~/unknown_bluerov2_ws/install/setup.bash
rviz2 -d ~/unknown_bluerov2_ws/src/rviz2/bluerov2_navigation.rvizIn RViz2:
Fixed Frame: map
Use:
2D Pose Estimate
to set the initial pose if AMCL does not localize automatically.
Then use:
Nav2 Goal
to send a navigation goal.
Check if Nav2 nodes are active:
ros2 lifecycle get /map_server
ros2 lifecycle get /amcl
ros2 lifecycle get /controller_server
ros2 lifecycle get /planner_server
ros2 lifecycle get /behavior_server
ros2 lifecycle get /bt_navigatorExpected:
active [3]
During SLAM mode, /map_server and /amcl may not be running. They are mainly for saved-map navigation.
Required TF tree for mapping and saved-map navigation:
map -> odom -> base_link -> laser_link
Check:
ros2 run tf2_ros tf2_echo odom base_link
ros2 run tf2_ros tf2_echo map odom
ros2 run tf2_ros tf2_echo map base_link
ros2 run tf2_ros tf2_echo base_link laser_linkNotes:
- During mapping,
map -> odomis published by SLAM Toolbox. - During saved-map navigation,
map -> odomis published by AMCL. odom -> base_linkis published by EKF orodom_tf_publisher.base_link -> laser_linkis published by static TF.
Check map:
ros2 topic echo /map --onceCheck scan:
ros2 topic echo /scan --onceCheck raw odom:
ros2 topic echo /odom --onceCheck EKF odom:
ros2 topic echo /odometry/filtered --onceCheck simulated DVL:
ros2 topic echo /dvl/twist --onceCheck depth pose:
ros2 topic echo /depth/pose --onceCheck AMCL pose:
ros2 topic echo /amcl_pose --onceCheck Nav2 velocity:
ros2 topic echo /cmd_velCheck global plan:
ros2 topic echo /plan --onceGenerate TF frames PDF:
ros2 run tf2_tools view_framesNormally use:
ros2 launch unknown_bluerov2_bringup bridge.launch.pyManual equivalent:
ros2 run ros_gz_bridge parameter_bridge \
/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock \
/world/bluerov2_underwater/model/bluerov2/link/base_link/sensor/imu_sensor/imu@sensor_msgs/msg/Imu[gz.msgs.IMU \
/scan@sensor_msgs/msg/LaserScan[gz.msgs.LaserScan \
/model/bluerov2/odometry@nav_msgs/msg/Odometry[gz.msgs.Odometry \
/model/bluerov2/joint/thruster1_joint/cmd_thrust@std_msgs/msg/Float64]gz.msgs.Double \
/model/bluerov2/joint/thruster2_joint/cmd_thrust@std_msgs/msg/Float64]gz.msgs.Double \
/model/bluerov2/joint/thruster3_joint/cmd_thrust@std_msgs/msg/Float64]gz.msgs.Double \
/model/bluerov2/joint/thruster4_joint/cmd_thrust@std_msgs/msg/Float64]gz.msgs.Double \
/model/bluerov2/joint/thruster5_joint/cmd_thrust@std_msgs/msg/Float64]gz.msgs.Double \
/model/bluerov2/joint/thruster6_joint/cmd_thrust@std_msgs/msg/Float64]gz.msgs.DoubleCheck bridge:
ros2 node list | grep bridge
ros2 topic list | grep odomCheck:
ros2 topic echo /odom --onceStart bridge again:
ros2 launch unknown_bluerov2_bringup bridge.launch.pyThe DVL is simulated by:
odom_to_dvl_twist.py
Run:
ros2 launch unknown_bluerov2_nav ekf_localization.launch.pyThen check:
ros2 topic echo /dvl/twist --onceIf missing, check odometry topics:
ros2 topic list | grep odom
ros2 topic echo /odom --once
ros2 topic echo /model/bluerov2/odometry --onceIf your bridge publishes only /odom, edit:
src/unknown_bluerov2_nav/launch/ekf_localization.launch.py
and set both input_odom_topic parameters to:
/odom
Check EKF:
ros2 node list | grep ekf
ros2 topic echo /odometry/filtered --onceCheck TF:
ros2 run tf2_ros tf2_echo odom base_linkIf EKF is not publishing TF, use:
ros2 launch unknown_bluerov2_nav tf_with_odom.launch.pyDo not publish the same TF twice.
During mapping:
ros2 node list | grep slam
ros2 run tf2_ros tf2_echo map odomDuring saved-map navigation:
ros2 node list | grep amcl
ros2 topic echo /amcl_pose --once
ros2 run tf2_ros tf2_echo map odomIf AMCL has not localized, set initial pose in RViz2 using:
2D Pose Estimate
Check:
ros2 run tf2_ros tf2_echo map base_link
ros2 run tf2_ros tf2_echo odom base_linkClear costmaps:
ros2 service call /local_costmap/clear_entirely_local_costmap nav2_msgs/srv/ClearEntireCostmap "{}"
ros2 service call /global_costmap/clear_entirely_global_costmap nav2_msgs/srv/ClearEntireCostmap "{}"Make sure the initial pose is correct in RViz2.
Check Nav2 command:
ros2 topic echo /cmd_velIf /cmd_vel publishes but BlueROV2 does not move, check the thruster mixer:
ros2 node list | grep cmd_vel
ros2 node info /cmd_vel_to_thrustersManual forward test:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.2}, angular: {z: 0.0}}" -r 10Manual yaw test:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.0}, angular: {z: 0.2}}" -r 10If the robot still does not move, check thruster topics:
ros2 topic echo /thruster1/cmd_thrust
ros2 topic echo /thruster2/cmd_thrust
ros2 topic echo /thruster3/cmd_thrust
ros2 topic echo /thruster4/cmd_thrustIn Nav2 DWB parameters, use:
FollowPath:
min_vel_x: 0.0If you want small reverse only when the path is opposite the robot heading, use:
FollowPath:
min_vel_x: -0.025and add PreferForward critic:
critics: [
"RotateToGoal",
"Oscillation",
"BaseObstacle",
"PreferForward",
"GoalAlign",
"PathAlign",
"PathDist",
"GoalDist"
]
PreferForward.scale: 5.0If it moves backward too often:
PreferForward.scale: 8.0For narrow corridors, use smaller costmap radius and inflation:
local_costmap:
local_costmap:
ros__parameters:
robot_radius: 0.34
inflation_layer:
inflation_radius: 0.55
cost_scaling_factor: 4.0For safer obstacle distance, use larger values:
local_costmap:
local_costmap:
ros__parameters:
robot_radius: 0.50
inflation_layer:
inflation_radius: 1.05
cost_scaling_factor: 2.0If a location is a V-shaped trap, edit the map or add a keepout zone.
Reduce AMCL particles in:
src/unknown_bluerov2_nav/config/nav2_saved_map_params.yaml
Example:
amcl:
ros__parameters:
min_particles: 120
max_particles: 500Or hide the AMCL particle display in RViz2.
Close heavy RViz2 displays.
Reduce costmap update rates:
global_costmap:
global_costmap:
ros__parameters:
update_frequency: 1.0
publish_frequency: 1.0
local_costmap:
local_costmap:
ros__parameters:
update_frequency: 5.0
publish_frequency: 2.0Reduce AMCL particles:
amcl:
ros__parameters:
min_particles: 100
max_particles: 400Kasiphat Uppaphak
GitHub: wattanatum


