底盘、传感器与调试
启动机器人基础功能
启动基础功能时,先连接 ROS 2、真实底盘和雷达,再进行第一次低速遥控。bringup_lidar.launch.py 本身不主动让底盘运动,但会占用底盘串口和雷达;键盘控制、手柄控制和行为控制会驱动实体底盘。
执行底盘运动前,先确认电量、场地和停止方式。首次测试可架空底盘,或放在空旷平整地面低速短时移动。
启动底盘与雷达
bringup_lidar.launch.py 会访问实体底盘和雷达。启动前如果不确定底盘串口是否被占用,先检查并释放目标串口。
检查并释放底盘串口
以下命令在 Jetson 主机终端或 JupyterLab Terminal 中执行,不要求进入固定目录。命令访问 /dev/ttyTHS* 串口设备,与当前所在目录无关。
Jetson Orin Nano 底盘串口为 /dev/ttyTHS0,Jetson Orin NX 底盘串口为 /dev/ttyTHS1,以系统实际列出的串口为准。更多说明见 下位机教程:串口占用检查与释放。
ls /dev/ttyTHS*
PORT=/dev/ttyTHS1
sudo fuser -v $PORT
ls /dev/ttyTHS* 会列出当前系统中的串口,例如:
/dev/ttyTHS1 /dev/ttyTHS2
sudo fuser -v $PORT 有输出时,目标串口正在被某个进程占用。输出中的数字是 PID,也就是进程编号。例如:
USER PID ACCESS COMMAND
/dev/ttyTHS1: jetson 1805 F.... python
其中 1805 是占用 /dev/ttyTHS1 的进程编号。后面的 fuser 释放命令会按 $PORT 处理占用该串口的进程,不需要手动输入 PID。
如需确认占用来源,将输出中的 PID 代入下面命令查看进程信息:
ps -fp <PID>
例如输出中显示 1805,就执行:
ps -fp 1805
这一步不是必须执行。
发送 TERM 信号,让占用目标串口的进程正常退出:
sudo fuser -TERM -k $PORT
sleep 2
sudo fuser -v $PORT
如果 sudo fuser -v $PORT 仍然有输出,说明目标串口仍未释放。确认当前 $PORT 是要释放的目标串口后,再只针对该目标串口执行强制释放:
sudo fuser -KILL -k $PORT
sleep 1
sudo fuser -v $PORT
最后一条 sudo fuser -v $PORT 没有输出,目标串口已经释放。sudo fuser -KILL -k $PORT 只结束占用当前 $PORT 设备文件的进程,不是结束所有 Python 进程。不要使用结束所有 Python 进程的方式释放资源;只释放目标串口或目标摄像头的占用进程。
如果 ps -fp <PID> 显示进程路径中包含 ugv_jetson/app.py,说明占用来源为 Web 主程序。
释放该串口后,Web 控制页面的底盘控制会暂时不可用。完成 ROS 2 测试后,重启或运行程序
sudo reboot
恢复Web端页面。
开发者补充:让 Web 主程序开机后不自动恢复
Web 主程序自启由 autorun.sh 写入当前账号 crontab 的 @reboot 项。JupyterLab 使用 ugv_jupyter.service,两者不是同一个启动入口。不同镜像的自启方式以当前设备为准。
如果只是临时切换到 ROS 2 或 Notebook 独占底盘串口,不要禁用 Web 主程序开机自启。停止 Web 主程序后,完成测试并重启设备,默认启动项会恢复 Web 控制页面。
先查看当前 crontab:
crontab -l
如果看到包含 ~/ugv_jetson/app.py 的 @reboot 行,先保存该行,再编辑 crontab:
crontab -e
删除或注释包含 ~/ugv_jetson/app.py 的 @reboot 行后保存。再次查看:
crontab -l
恢复自启时,把记录的 @reboot ... ~/ugv_jetson/app.py ... 行加回 crontab。
如果当前镜像改用 systemd,先查实际服务名:
systemctl list-unit-files | grep -Ei "ugv|jetson|web|app|access"
systemctl list-units --type=service | grep -Ei "ugv|jetson|web|app|access"
确认服务名后查看:
systemctl status <SERVICE_NAME>
禁用并停止该服务:
sudo systemctl disable --now <SERVICE_NAME>
systemctl is-enabled <SERVICE_NAME>
恢复自启时执行:
sudo systemctl enable --now <SERVICE_NAME>
systemctl status <SERVICE_NAME>
禁用 Web 主程序自启后,设备重启后 Web 控制页面、视频流或依赖 Web 主程序的功能不会自动恢复。记录服务名或 crontab 原始行和恢复命令,避免后续无法回到默认状态。
-
在 Docker ROS 2 终端中加载 ROS 2 环境。
cd /home/ws/ugv_wssource /opt/ros/humble/setup.bashsource install/setup.bash -
设置型号并启动底盘与雷达:
export UGV_MODEL=ugv_rover
export LDLIDAR_MODEL=ld19
ros2 launch ugv_bringup bringup_lidar.launch.py use_rviz:=false
Docker ROS 2 终端 1
启动该命令的终端也可称为 bringup 终端。后续检查 topic、手动控制底盘、手柄控制、RViz 查看雷达扫描等需要实体底盘和雷达数据的步骤,都需要保持该终端继续运行,不要关闭。
该命令会启动实体底盘、雷达和相关 ROS 2 节点,不打开 RViz。命令会启动 robot_state_publisher、joint_state_publisher、ugv_bringup、ugv_driver、ldlidar_node、rf2o_laser_odometry_node、base_node 等进程。雷达数据是否进入 ROS 2,后面通过 /scan topic 检查;需要图形化查看模型和雷达扫描时,再进入“RViz 模型与传感器可视化”章节。
启动后查看终端输出,看到以下日志时,bringup 正在运行:
ldlidar communication is normal;Publish topic message:ldlidar scan data;Got first Laser Scan;- 终端停留在 launch 输出中,没有回到命令提示符。
如需停止,回到启动 bringup_lidar.launch.py 的 MobaXterm 终端,按 Ctrl + C。停止后,底盘节点、雷达节点和相关里程计节点会随 launch 一起退出。
检查 ROS 2 节点和 topic 状态
保持上一步启动 bringup_lidar.launch.py 的 Docker ROS 2 终端 1 继续运行,不要关闭。另开一个 Docker ROS 2 终端 2,用于查看当前正在运行的 node 和 topic。
node 和 topic 检查只读取 ROS 2 状态,不主动驱动底盘,也不需要打开 RViz。
如果 Docker ROS 2 终端 1 已经关闭,请先回到 启动底盘与雷达 重新启动。
-
在 Docker ROS 2 终端 2 中加载 ROS 2 环境。
cd /home/ws/ugv_wssource /opt/ros/humble/setup.bashsource install/setup.bash -
查看当前节点和 topic:
ros2 node list
ros2 topic list
Docker ROS 2 终端 1 中的 bringup_lidar.launch.py 运行时,ros2 node list 会列出 /LD19、/base_node、/rf2o_laser_odometry、/ugv_bringup、/ugv_driver、/ugv/robot_state_publisher 等节点。ros2 topic list 会列出 /scan、/tf、/tf_static、/odom、/odom/odom_raw、/odom_rf2o、/imu/data、/imu/data_raw、/imu/mag、/voltage 等 topic。终端中出现同名节点提示时,继续按 topic 列表检查,不影响 topic 读取。
- 按关键词检查常见 topic:
ros2 topic list | grep scan
ros2 topic list | grep tf
ros2 topic list | grep odom
ros2 topic list | grep imu
ros2 topic list | grep voltage
以 ros2 topic list 的实际输出为准。如果某个 topic 没有出现,不要直接对它执行 echo。
- 用
/scan检查雷达扫描数据。
检查雷达是否发布数据,不需要打开 RViz。/scan topic 存在并持续发布数据,表示雷达扫描数据已经进入 ROS 2。
确认 /scan 存在后,查看发布频率:
ros2 topic hz /scan
看到频率输出后,按 Ctrl + C 结束频率查看。再查看一条雷达扫描消息:
ros2 topic echo /scan --once
如果 /scan 不存在,不要继续执行 echo,先检查 Docker ROS 2 终端 1 是否仍在运行。
- 只在 topic 已存在时查看其它数据。
确认 /odom 存在后,查看一条里程计数据:
ros2 topic echo /odom --once
如果终端提示:
WARNING: topic [...] does not appear to be published yet
Could not determine the type for the passed topic
表示当前没有节点正在发布该 topic,或该 topic 不适用于当前启动配置。先确认 Docker ROS 2 终端 1 中的 bringup_lidar.launch.py 仍在运行,再根据 ros2 topic list 的实际输出选择要查看的 topic。
ros2 topic hz /scan 刚启动时会先等待数据,随后持续输出 average rate。频率稳定在约 10 Hz 时,/scan 正在持续发布。
对 /scan 执行 ros2 topic echo /scan --once 后,终端会输出一条 LaserScan 消息,其中包含 frame_id: base_lidar_link、angle_min、angle_max、range_min、range_max、ranges 和 intensities。ranges 中的数字是雷达到障碍物的距离,.nan 表示该角度没有有效距离读数。
对 /odom 执行 ros2 topic echo /odom --once 后,终端会输出一条里程计消息,其中包含 frame_id: odom、child_frame_id: base_footprint、pose 和 twist。这表示里程计 topic 已经发布。
手动控制底盘
键盘控制和手柄控制都是手动输入方式。控制节点会把按键或手柄摇杆转换成 /cmd_vel 速度指令,Docker ROS 2 终端 1 中的底盘驱动接收指令后,让实体底盘移动。测试前保持 Docker ROS 2 终端 1 中的 bringup_lidar.launch.py 继续运行;如果 Docker ROS 2 终端 1 已经关闭,先回到 启动底盘与雷达 重新启动。
键盘控制和手柄控制会主动驱动实体底盘。首次测试前让机器人悬空,或放在空旷地面,并远离人、宠物、桌脚和线缆。测试结束前先发送停止指令,再退出控制节点。
使用键盘低速移动
保持 Docker ROS 2 终端 1 中的 bringup_lidar.launch.py 继续运行,不要关闭。另开一个 Docker ROS 2 终端 2 运行键盘控制命令。
-
在 Docker ROS 2 终端 2 中加载 ROS 2 环境。
cd /home/ws/ugv_wssource /opt/ros/humble/setup.bashsource install/setup.bash -
确认
/cmd_veltopic。
ros2 topic info /cmd_vel
- 启动键盘控制。
ros2 run ugv_tools keyboard_ctrl
常用按键如下:
| 按键 | 动作 |
|---|---|
I | 前进 |
, | 后退 |
J | 左转 |
L | 右转 |
K 或空格 | 停止 |
Ctrl + C | 退出键盘控制 |
键盘控制终端必须保持输入焦点。每次短按方向键或控制键后,立即按 K 或空格停止。不要长时间持续按方向键。测试结束前先按 K 或空格停止,确认机器人停止后,再按 Ctrl + C 退出键盘控制节点。
使用手柄控制
手柄控制会把手柄输入转换为 /cmd_vel,从而控制底盘运动。先完成键盘低速移动测试,并确认停止方式后,再使用手柄控制。
先将手柄的 2.4G USB 接收器插入 Jetson 主板 USB 接口。打开手柄背面的电源开关,手柄指示灯闪红灯后,再启动 ROS 2 手柄控制节点。

保持 Docker ROS 2 终端 1 中的 bringup_lidar.launch.py 继续运行,不要关闭。另开新的 Docker ROS 2 终端运行手柄控制命令。
-
在新的 Docker ROS 2 终端中加载 ROS 2 环境。
cd /home/ws/ugv_wssource /opt/ros/humble/setup.bashsource install/setup.bash -
启动手柄控制:
ros2 launch ugv_tools teleop_twist_joy.launch.py
手柄控制会主动驱动底盘。测试时先小幅移动摇杆,按下面对应关系检查底盘动作:
| 手柄动作 | 底盘动作 |
|---|---|
| 左摇杆向上 | 前进 |
| 左摇杆向下 | 后退 |
| 右摇杆向左 | 左转 |
| 右摇杆向右 | 右转 |
测试结束前先松开摇杆并确认底盘停止,再按 Ctrl + C 退出手柄控制 launch。
检查 TF 坐标关系
TF 用来描述机器人不同坐标系之间的位置和方向关系,可以理解为 ROS 2 中的坐标换算关系。它不是控制命令,也不是传感器数据,而是让 ROS 2 知道车体、雷达、IMU、相机、轮子、里程计坐标之间如何对齐。
在 UGV 上,雷达扫描来自雷达坐标系,底盘运动与车体坐标系和里程计坐标系有关。TF 关系正常时,RViz、SLAM 和 Navigation 才能把机器人模型、雷达扫描、地图和机器人位置放到同一个空间中显示和计算。TF 正常不等于建图或导航完成。
保持前面启动 bringup_lidar.launch.py 的 Docker ROS 2 终端 1 继续运行。另开一个 Docker ROS 2 终端,用于检查 TF。
-
在新的 Docker ROS 2 终端中加载 ROS 2 环境。
cd /home/ws/ugv_wssource /opt/ros/humble/setup.bashsource install/setup.bash -
查看 TF topic。
ros2 topic list | grep tf
输出中会看到 /tf 和 /tf_static。/tf_static 保存固定安装关系,例如车体到雷达、IMU、相机和轮子的关系。/tf 保存运行中会变化的关系,例如 odom 到 base_footprint 或 base_link 的关系。
- 查看固定安装关系。
查看雷达相对车体的位置和方向:
ros2 run tf2_ros tf2_echo base_link base_lidar_link
查看 IMU 相对车体的位置和方向:
ros2 run tf2_ros tf2_echo base_link base_imu_link
如果终端持续输出 Translation、Rotation 和 Matrix,表示 ROS 2 可以查询到对应的坐标关系。执行 tf2_echo 后,前几秒会出现 Waiting for transform 或 frame does not exist。随后开始持续输出 Translation、Rotation 和 Matrix 时,该 TF 关系已经可以查询到。
- 查看动态坐标关系。
ros2 run tf2_ros tf2_echo odom base_footprint
移动或转动 UGV 时,输出中的 Translation 或 Rotation 会变化。如果只是原地转向,主要关注 Rotation 或 yaw 变化。
如果 odom -> base_footprint 没有输出,尝试:
ros2 run tf2_ros tf2_echo odom base_link
不同工作空间或模型配置中,动态 TF 的 child frame 不完全相同,以终端输出为准。
- 生成 TF 树 PDF。
ros2 run tf2_tools view_frames
该命令会监听几秒 TF 数据,并在当前终端目录生成 frames_*.pdf。这个 PDF 用于检查当前系统的完整 TF 树,不作为理解 TF 的唯一材料。
在 MobaXterm 左侧文件栏勾选 Follow terminal folder,可以在当前目录找到并下载生成的 frames_*.pdf。

UGV Rover 的简化 TF 树如下:
base_footprint
└── base_link
├── base_lidar_link
├── base_imu_link
├── 3d_camera_link
├── left_up_wheel_link
├── left_down_wheel_link
├── right_up_wheel_link
├── right_down_wheel_link
└── pt_base_link
└── pt_link1
└── pt_link2
└── pt_camera_link
| 坐标系 | 含义 |
|---|---|
base_footprint | 底盘在地面上的参考点 |
base_link | 车身主体 |
base_lidar_link | 雷达安装位置 |
base_imu_link | IMU 安装位置 |
3d_camera_link | 3D 相机安装位置 |
| 四个 wheel link | 轮子模型位置,主要用于模型显示和关节状态 |
pt_base_link → pt_link1 → pt_link2 → pt_camera_link | 云台和云台相机链路 |
只启动 Docker ROS 2 终端 1 中的 bringup_lidar.launch.py 时,重点关注 odom → base_footprint 或 odom → base_link。启动 SLAM / Navigation 后,重点关注 map → odom → base_footprint。没有启动 SLAM 或定位时,看不到 map 不属于错误。
开发者补充:查看 /tf 原始消息
/tf 原始消息会输出大量 frame_id、child_frame_id、translation 和 rotation 数据,适合排查具体 transform。普通检查优先使用 view_frames 查看 TF 树,或使用 tf2_echo 查看一对坐标关系。
ros2 topic echo /tf --once
挑战任务:用 TF 观察车体与雷达的坐标关系
这个挑战用临时脚本让 UGV 低速原地转向,并生成 HTML 动画,用于观察车体、雷达、IMU 和 /scan 如何通过 TF 放到同一个 odom 坐标空间。
要求和注意事项:
- 保持 Docker ROS 2 终端 1 中的
bringup_lidar.launch.py继续运行。 - 脚本会发布
/cmd_vel,让 UGV 低速原地转动。 - 必须添加
--confirm-move参数,脚本才会发布运动指令。 - 测试前停止键盘控制、手柄控制等其它会发布
/cmd_vel的程序。 - 动画用于理解 TF 坐标转换,不代表建图或导航完成。
该挑战会驱动实体底盘。请将 UGV 放在地面,周围留出安全空间,不要架空测试。
脚本结束后会自动发布停止命令。如果需要中途停止,可以在运行脚本的终端按 Ctrl + C,脚本会尝试发送停止命令。必要时也可以直接关闭机器人电源。
展开查看操作步骤、脚本和排查
- 检查
/cmd_vel和/scan。
ros2 topic info /cmd_vel
ros2 topic hz /scan
ros2 topic info /cmd_vel 输出中 Subscription count 大于 0,表示有节点正在订阅 /cmd_vel。ros2 topic hz /scan 能看到频率输出,表示雷达扫描数据正在发布。按 Ctrl + C 退出 hz 检查。
- 创建临时脚本。
cd /home/ws/ugv_ws
mkdir -p tools
创建临时脚本:
cat > tools/tf_auto_turn_lidar_demo.py <<'PY'
#!/usr/bin/env python3
import argparse
import csv
import json
import math
import os
import signal
import time
import rclpy
from geometry_msgs.msg import Twist
from rclpy.node import Node
from sensor_msgs.msg import LaserScan
from tf2_ros import Buffer, TransformListener
def yaw_from_quaternion(q):
siny = 2.0 * (q.w * q.z + q.x * q.y)
cosy = 1.0 - 2.0 * (q.y * q.y + q.z * q.z)
return math.atan2(siny, cosy)
def transform_to_pose(tf_msg):
t = tf_msg.transform.translation
r = tf_msg.transform.rotation
return t.x, t.y, yaw_from_quaternion(r)
def apply_pose(x, y, pose):
px, py, yaw = pose
c = math.cos(yaw)
s = math.sin(yaw)
return px + c * x - s * y, py + s * x + c * y
def clamp_scan_points(scan, base_pose, lidar_pose, max_points):
if scan is None:
return []
step = max(1, len(scan.ranges) // max_points)
points = []
angle = scan.angle_min
for i, distance in enumerate(scan.ranges):
if i % step:
angle += scan.angle_increment
continue
if math.isfinite(distance) and scan.range_min <= distance <= scan.range_max:
lx = lidar_pose[0] + math.cos(lidar_pose[2] + angle) * distance
ly = lidar_pose[1] + math.sin(lidar_pose[2] + angle) * distance
ox, oy = apply_pose(lx, ly, base_pose)
points.append([round(ox, 3), round(oy, 3)])
angle += scan.angle_increment
return points
class Demo(Node):
def __init__(self, args):
super().__init__("tf_auto_turn_lidar_demo")
self.args = args
self.scan = None
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
self.cmd_pub = self.create_publisher(Twist, "/cmd_vel", 10)
self.create_subscription(LaserScan, "/scan", self.on_scan, 10)
def on_scan(self, msg):
self.scan = msg
def lookup_pose(self, parent, child):
tf_msg = self.tf_buffer.lookup_transform(parent, child, rclpy.time.Time())
return transform_to_pose(tf_msg)
def publish_turn(self):
msg = Twist()
msg.angular.z = self.args.angular
self.cmd_pub.publish(msg)
def stop(self):
msg = Twist()
for _ in range(12):
self.cmd_pub.publish(msg)
rclpy.spin_once(self, timeout_sec=0.03)
def write_csv(path, rows):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f,
fieldnames=["t", "x", "y", "yaw", "scan_points", "lidar_x", "lidar_y", "imu_x", "imu_y"],
)
writer.writeheader()
writer.writerows(rows)
def write_html(path, frames, parent, base):
html = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>TF Auto Turn Lidar Demo</title>
<style>
body { margin: 0; background: #ffffff; font-family: Arial, "Microsoft YaHei", sans-serif; color: #0b1f3a; }
.wrapper { width: 1200px; margin: 24px auto; }
h1 { margin: 0 0 8px 0; font-size: 30px; }
p { margin: 6px 0 16px 0; color: #5a6b80; font-size: 18px; }
canvas { width: 1200px; height: 720px; border: 2px solid #cfe0f5; border-radius: 18px; background: #f8fbff; }
.legend { margin-top: 12px; padding: 14px 18px; border: 1px solid #cfe0f5; border-radius: 12px; background: #fff; font-size: 16px; color: #30445c; line-height: 1.8; }
code { background: #eef5ff; padding: 2px 6px; border-radius: 6px; }
</style>
</head>
<body>
<div class="wrapper">
<h1>TF 自动转向与雷达可视化</h1>
<p><code>""" + parent + """</code> 是参考坐标系,<code>""" + base + """</code> 是车体坐标系,蓝色点是 <code>/scan</code> 雷达扫描数据经过 TF 转换后的显示。</p>
<canvas id="canvas" width="1200" height="720"></canvas>
<div class="legend">
<div>蓝色网格:<code>""" + parent + """</code> 坐标系。</div>
<div>橙色车体:<code>""" + base + """</code>,箭头方向表示车头方向。</div>
<div>蓝色点:经过 TF 转换后的 <code>/scan</code> 雷达扫描数据。</div>
<div>绿色点:<code>base_lidar_link</code>。</div>
<div>紫色点:<code>base_imu_link</code>。</div>
<div>该动画用于理解 TF 如何把车体、雷达和 IMU 放到同一个坐标空间,不代表建图或导航完成。</div>
</div>
</div>
<script>
const frames = """ + json.dumps(frames, ensure_ascii=False) + """;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let index = 0;
function px(x, y) { return [600 + x * 180, 360 - y * 180]; }
function drawGrid() {
ctx.strokeStyle = "#d8e7f7";
ctx.lineWidth = 1;
for (let x = 0; x <= 1200; x += 60) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, 720); ctx.stroke(); }
for (let y = 0; y <= 720; y += 60) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(1200, y); ctx.stroke(); }
ctx.strokeStyle = "#82aee0";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 360); ctx.lineTo(1200, 360); ctx.stroke();
ctx.beginPath(); ctx.moveTo(600, 0); ctx.lineTo(600, 720); ctx.stroke();
}
function drawRobot(f) {
const p = px(f.x, f.y);
ctx.save();
ctx.translate(p[0], p[1]);
ctx.rotate(-f.yaw);
ctx.fillStyle = "#f5a142";
ctx.strokeStyle = "#9a5a00";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(40, 0); ctx.lineTo(-26, -24); ctx.lineTo(-18, 0); ctx.lineTo(-26, 24); ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawPoint(p, color, size) {
const q = px(p[0], p[1]);
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(q[0], q[1], size, 0, Math.PI * 2); ctx.fill();
}
function drawInfo(f) {
ctx.fillStyle = "rgba(255,255,255,0.92)";
ctx.fillRect(900, 22, 270, 130);
ctx.fillStyle = "#0b1f3a";
ctx.font = "18px Arial";
ctx.fillText("x: " + f.x.toFixed(3), 922, 56);
ctx.fillText("y: " + f.y.toFixed(3), 922, 84);
ctx.fillText("yaw: " + f.yaw.toFixed(3), 922, 112);
ctx.fillText("scan_points: " + f.scan.length, 922, 140);
}
function draw() {
if (!frames.length) return;
const f = frames[index];
ctx.clearRect(0, 0, 1200, 720);
drawGrid();
for (const p of f.scan) drawPoint(p, "#2274d9", 2.6);
drawRobot(f);
drawPoint(f.lidar, "#1ba784", 6);
drawPoint(f.imu, "#8a4fd3", 6);
drawInfo(f);
index = (index + 1) % frames.length;
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>
"""
with open(path, "w", encoding="utf-8") as f:
f.write(html)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--confirm-move", action="store_true")
parser.add_argument("--duration", type=float, default=8.0)
parser.add_argument("--angular", type=float, default=0.5)
parser.add_argument("--parent", default="odom")
parser.add_argument("--base", default="base_footprint")
parser.add_argument("--max-scan-points", type=int, default=120)
parser.add_argument("--outdir", default="/home/ws/ugv_ws/tf_auto_turn_lidar_outputs")
args = parser.parse_args()
if not args.confirm_move:
print("未添加 --confirm-move,脚本不会发布 /cmd_vel。")
return
rclpy.init()
node = Demo(args)
stopping = False
def handle_signal(signum, frame):
nonlocal stopping
stopping = True
signal.signal(signal.SIGINT, handle_signal)
os.makedirs(args.outdir, exist_ok=True)
rows = []
frames = []
start = time.time()
try:
while rclpy.ok() and not stopping and time.time() - start <= args.duration:
rclpy.spin_once(node, timeout_sec=0.05)
node.publish_turn()
try:
base_pose = node.lookup_pose(args.parent, args.base)
lidar_pose = node.lookup_pose(args.base, "base_lidar_link")
imu_pose = node.lookup_pose(args.base, "base_imu_link")
except Exception as exc:
print("waiting for tf:", exc)
continue
lidar_in_parent = apply_pose(lidar_pose[0], lidar_pose[1], base_pose)
imu_in_parent = apply_pose(imu_pose[0], imu_pose[1], base_pose)
scan_points = clamp_scan_points(node.scan, base_pose, lidar_pose, args.max_scan_points)
elapsed = time.time() - start
row = {
"t": round(elapsed, 3),
"x": round(base_pose[0], 4),
"y": round(base_pose[1], 4),
"yaw": round(base_pose[2], 5),
"scan_points": len(scan_points),
"lidar_x": round(lidar_in_parent[0], 4),
"lidar_y": round(lidar_in_parent[1], 4),
"imu_x": round(imu_in_parent[0], 4),
"imu_y": round(imu_in_parent[1], 4),
}
rows.append(row)
frames.append({
"t": row["t"],
"x": row["x"],
"y": row["y"],
"yaw": row["yaw"],
"scan": scan_points,
"lidar": [row["lidar_x"], row["lidar_y"]],
"imu": [row["imu_x"], row["imu_y"]],
})
print("x={x:.3f} y={y:.3f} yaw={yaw:.3f} scan_points={scan_points}".format(**row))
finally:
node.stop()
csv_path = os.path.join(args.outdir, "tf_auto_turn_lidar_samples.csv")
html_path = os.path.join(args.outdir, "tf_auto_turn_lidar_animation.html")
write_csv(csv_path, rows)
write_html(html_path, frames, args.parent, args.base)
print("stopped /cmd_vel")
print(csv_path)
print(html_path)
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
PY
python3 -m py_compile tools/tf_auto_turn_lidar_demo.py
- 运行挑战脚本。
python3 tools/tf_auto_turn_lidar_demo.py --confirm-move --duration 8 --angular 0.50 --parent odom --base base_footprint
该命令会让 UGV 低速原地转动约 8 秒,并生成动画文件。
如果转动太快,降低角速度:
python3 tools/tf_auto_turn_lidar_demo.py --confirm-move --duration 8 --angular 0.40 --parent odom --base base_footprint
如果 base_footprint 显示不符合预期,改用 base_link:
python3 tools/tf_auto_turn_lidar_demo.py --confirm-move --duration 8 --angular 0.50 --parent odom --base base_link
输出目录:
/home/ws/ugv_ws/tf_auto_turn_lidar_outputs
输出文件:
tf_auto_turn_lidar_animation.html
tf_auto_turn_lidar_samples.csv
在 MobaXterm 左侧文件栏勾选 Follow terminal folder,进入 /home/ws/ugv_ws/tf_auto_turn_lidar_outputs,下载 tf_auto_turn_lidar_animation.html,然后用 Windows 浏览器打开。
运行脚本后会看到以下内容:
- 终端会持续输出
x、y、yaw和scan_points; - UGV 会低速原地转向;
- 脚本结束后会自动发布停止命令;
- 下载并打开 HTML 后,可以看到车体箭头随 yaw 变化转动;
- 蓝色点表示经过 TF 转换后的雷达扫描数据;
- 绿色点表示
base_lidar_link; - 紫色点表示
base_imu_link; - 该动画用于理解 TF 如何把车体、雷达和 IMU 放到同一个坐标空间,不代表建图或导航完成。
如果 UGV 不动,先检查 /cmd_vel:
ros2 topic info /cmd_vel
如果 Subscription count 为 0,说明当前没有节点订阅 /cmd_vel,先检查 Docker ROS 2 终端 1 中的 bringup_lidar.launch.py 是否仍在运行。如果 Docker ROS 2 终端 1 已经关闭,先回到 启动底盘与雷达 重新启动。
再做短时阈值测试:
timeout 3 ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0}, angular: {z: 0.50}}"
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0}, angular: {z: 0.0}}"
如果 0.50 可以让 UGV 原地转向,但挑战脚本不能转动,请检查脚本是否在 /home/ws/ugv_ws 下运行,以及是否添加了 --confirm-move。
RViz 模型与传感器可视化
RViz 用于图形化查看模型、TF、雷达扫描和常见显示项,只负责显示,不会主动驱动底盘。
下面的命令会启动底盘与雷达 bringup,并同时打开 RViz,会占用底盘串口和雷达。
如果前面已经运行 bringup_lidar.launch.py use_rviz:=false,请先在 Docker ROS 2 终端 1 按 Ctrl + C 停止,再运行下面命令,避免重复占用底盘串口和雷达。
export UGV_MODEL=ugv_rover
export LDLIDAR_MODEL=ld19
ros2 launch ugv_bringup bringup_lidar.launch.py use_rviz:=true
如果 Docker ROS 2 终端 1 已经启动 bringup_lidar.launch.py use_rviz:=false,也可以另开终端单独打开 RViz。打开后需要在左侧 Displays 面板中检查或添加 RobotModel、TF、LaserScan 等显示项。
cd /home/ws/ugv_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 run rviz2 rviz2
查看优先使用前面的 use_rviz:=true 命令,避免空 RViz 配置带来的困惑。
RViz 界面出现后,左侧是 Displays 面板,中间区域是 3D 视图,右侧是 Views 面板。左侧 Displays 面板用于检查当前任务需要的显示项;TF 的命令行检查见前面的“检查 TF 坐标关系”。
| 显示项 | 常见 topic / 设置 | 作用 |
|---|---|---|
RobotModel | /ugv/robot_description | 显示机器人模型 |
TF | /tf、/tf_static | 显示坐标系 |
LaserScan | /scan | 显示 2D 雷达扫描 |
Grid | RViz 网格显示 | 显示参考网格 |
Map | /map | 显示 2D 地图 |
Path | 以当前 Nav2 输出为准 | 显示规划路径 |
先看左侧状态:Global Status 显示 Ok,表示当前 RViz 配置整体正常;RobotModel 显示 Status: Ok,表示机器人模型已经加载。

查看雷达扫描时,在左侧 Displays 面板中找到或添加 LaserScan,并将 Topic 设置为:
/scan
Topic 为空时,RViz 会显示 Error subscribing: Empty topic name。这表示 LaserScan 显示项还没有订阅雷达 topic,不代表雷达硬件损坏。设置为 /scan 后,LaserScan 变为 Status: Ok,并在 3D 视图中显示彩色点段。


这些彩色点段是 2D 雷达扫描数据,表示雷达检测到的周围障碍物边缘,不是摄像头画面,也不是地图。点段的位置和形状会随周围环境变化。默认显示的网格 Grid 和机器人模型 RobotModel 用于查看机器人在 3D 视图中的位置和朝向。

移动实体 UGV 后,RViz 中的模型位置、朝向和雷达扫描画面会随之变化。如果 TF 关系正常,RobotModel、LaserScan 等显示项才能对齐到同一个空间。这只表示模型显示和雷达可视化正常,不等于建图或导航完成。
开发者补充:只查看 URDF 模型
如果只想查看机器人 URDF 模型和基础 TF,不连接实体底盘和雷达,可以使用:
cd /home/ws/ugv_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
export UGV_MODEL=ugv_rover
ros2 launch ugv_description display.launch.py use_rviz:=true
该命令主要用于查看模型,不主动驱动底盘,也不读取实体雷达,不会发布真实 /scan 数据。查看实体底盘和雷达时,优先使用前面的 bringup_lidar.launch.py use_rviz:=true。
rosbag 数据记录、复现与离线分析
rosbag 是调试和复现工具,不是基础启动流程的一部分。建议先完成底盘、雷达、TF、RViz、2D 建图和 Navigation 的基本流程后,再使用 rosbag 记录问题现场或保存可复现数据。
rosbag 用于把 ROS 2 中已经发布的 topic 数据保存成文件,例如雷达扫描、TF、里程计和 IMU 数据。记录后的数据可以回放,用于复现问题、离线查看和对比调试。rosbag 不是启动功能的命令,只负责保存和回放已经发布出来的数据。
记录底盘、雷达、TF 和里程计数据
该任务用于记录 UGV 低速转向时的雷达、TF 和里程计数据,便于后续检查雷达扫描、车体姿态和坐标关系是否同步变化。
保持 Docker ROS 2 终端 1 继续运行。记录任务本身不驱动底盘;为了让记录内容有观察价值,需要在记录过程中让 UGV 低速原地转向。
另开 Docker ROS 2 终端,加载 ROS 2 环境。
cd /home/ws/ugv_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
检查当前 topic。
ros2 topic list | grep -E "scan|tf|odom|imu|voltage|cmd"
本任务默认只记录传感器和状态 topic,不记录 /cmd_vel。/cmd_vel 是底盘速度指令;如果把它录进 rosbag,回放时也会重新发布该控制指令。只有在明确需要复现控制输入时,才记录 /cmd_vel,并在回放前确认真实底盘不会响应该 topic。
开发者补充:记录控制 topic 的风险
如果需要分析控制输入,可以把 /cmd_vel 加入记录命令。但回放包含 /cmd_vel 的 bag 前,先确认真实底盘驱动不会接收该 topic,或在安全环境中测试。
创建保存目录并记录数据。
mkdir -p /home/ws/ugv_ws/rosbag_records
ros2 bag record -o /home/ws/ugv_ws/rosbag_records/ugv_lidar_tf_odom \
/scan \
/tf \
/tf_static \
/odom \
/imu/data_raw \
/voltage
这里使用完整路径 /home/ws/ugv_ws/rosbag_records,便于在 MobaXterm 左侧文件栏中直接找到。
如果某个 topic 在当前 topic 列表中不存在,请从记录命令中删除该 topic 后再执行。
记录期间,另开一个 Docker ROS 2 终端,让记录数据产生变化。
cd /home/ws/ugv_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
timeout 5 ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0}, angular: {z: 0.50}}"
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0}, angular: {z: 0.0}}"
该命令会让 UGV 低速原地转向约 5 秒。请将 UGV 放在地面,周围留出安全空间。动作结束后会发送一次 0 速度命令。
也可以在记录期间使用 键盘控制 短按 J / L 让 UGV 原地转向,随后按 K 或空格停止。
回到 ros2 bag record 终端,按 Ctrl + C 停止记录,并等待数据写入完成。
记录完成后会看到以下内容:
ros2 bag record终端会显示正在记录的 topic;- UGV 低速转向时,
/tf、/odom、/scan等数据会随时间变化; - 停止记录后,
/home/ws/ugv_ws/rosbag_records/ugv_lidar_tf_odom目录中会生成metadata.yaml和.db3数据文件; - 使用
ros2 bag info可以查看记录时长、topic 列表和消息数量。
在 MobaXterm 左侧文件栏勾选 Follow terminal folder,进入 /home/ws/ugv_ws/rosbag_records/ugv_lidar_tf_odom,可以看到本次记录生成的文件。
metadata.yaml 是 rosbag 的索引说明文件,可以用文本编辑器打开。里面会记录 bag 版本、存储格式、记录时长、起始时间、总消息数量、每个 topic 的名称、消息类型和消息数量。例如 /scan 对应 sensor_msgs/msg/LaserScan,/odom 对应 nav_msgs/msg/Odometry。
.db3 文件是 SQLite 数据库文件,保存实际消息数据。它不适合直接用文本编辑器阅读。日常查看优先使用 ros2 bag info 和 ros2 bag play;需要查看数据库表时,可用 SQLite 工具打开 .db3 文件,里面主要包含 topic 信息和按时间保存的 ROS 2 消息数据。
查看与回放 rosbag
先加载 ROS 2 环境。
cd /home/ws/ugv_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
查看 rosbag 信息。
ros2 bag info /home/ws/ugv_ws/rosbag_records/ugv_lidar_tf_odom
回放 rosbag。
ros2 bag play /home/ws/ugv_ws/rosbag_records/ugv_lidar_tf_odom
终端显示 Opened database ... for READ_ONLY、Set rate to 1 和暂停 / 调速快捷键提示时,表示 rosbag 已经打开 .db3 数据库并开始回放。回放时终端不会逐条打印 /scan、/tf、/odom 等消息,而是在后台重新发布 bag 中保存的 topic。需要停止回放时,在该终端按 Ctrl + C。
如需确认回放数据正在发布,保持 ros2 bag play 终端继续运行,不要停止。另开 Docker ROS 2 终端执行下面命令。
cd /home/ws/ugv_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 topic list | grep -E "scan|tf|odom|imu|voltage"
ros2 topic echo /scan --once
ros2 topic list 会输出本次回放中的 topic,例如 /scan、/tf、/tf_static、/odom、/imu/data_raw 和 /voltage。ros2 topic echo /scan --once 会输出一条 LaserScan 消息,其中包含 frame_id: base_lidar_link、角度范围、距离范围和 ranges 数组。ranges 中的数字是雷达到周围物体的距离,.nan 表示该角度没有有效距离读数。
如果 ros2 bag play 已经结束,或在回放完成后才执行 ros2 topic echo /scan --once,终端会提示 WARNING: topic [/scan] does not appear to be published yet 和 Could not determine the type for the passed topic。这表示当前没有节点正在发布 /scan,不是 rosbag 文件损坏。重新启动 ros2 bag play 后,再另开终端检查。