ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Webots与ROS 2联合仿真:从零搭建自定义驱动包

Webots与ROS 2联合仿真:从零搭建自定义驱动包

1. 项目缘起与整体设计思路

做机器人仿真的人多半经历过这样的纠结:Webots 自带一套干净利落的控制器 API,写起来顺手,调试也直观;而 ROS 2 那套节点通信、话题订阅、TF 变换的生态又实在香,真机部署时几乎绕不开。于是问题就来了——能不能让 Webots 当“身体”,ROS 2 当“大脑”,两边各司其职?答案当然是可以的,而且官方早就铺好了路,也就是webots_ros2这套桥接包。但官方驱动包往往封装得太厚,新手拿过来跑个 demo 没问题,一旦想改点底层逻辑、加个自定义传感器、或者只想保留最核心的通信链路,就会觉得无从下手。

这个项目要做的,就是抛开官方那层厚厚的封装,从零搭一个初级自定义驱动包。所谓“初级”,不是指功能简陋,而是指结构清晰、依赖最少、每一行代码都能看懂。它解决的核心问题是:让 Webots 里的机器人模型能够接收 ROS 2 发来的速度指令,同时把仿真环境中的传感器数据(比如里程计、激光雷达)以标准 ROS 2 话题的形式发出去。适合谁参考?刚接触 Webots 和 ROS 2 联合仿真、想搞明白底层通信机制的开发者,或者需要为特定机器人定制轻量级驱动、不想被官方包绑死手脚的从业者。

整体设计思路遵循“最小可用闭环”原则。Webots 侧只保留一个控制器脚本,负责与仿真引擎交互;ROS 2 侧则建立对应的节点,处理指令下发与数据上报。两者之间通过 Webots 提供的 ROS 2 接口进行通信,而不是自己造轮子去写 socket 或共享内存。为什么这么选?因为 Webots 从 R2021b 版本开始就内置了对 ROS 2 的支持,底层用的是 Fast DDS 或者 Cyclone DDS 这类标准中间件,稳定性和实时性都有保障。自己搭通信链路不是不行,但调试成本太高,而且容易在时间同步、消息序列化这些细节上翻车。用官方接口,相当于站在巨人的肩膀上,只需要关注业务逻辑本身。

另一个关键决策是不依赖webots_ros2这个元包。官方元包虽然方便,但它把很多功能都打包进去了,比如webots_ros2_driver、webots_ros2_control、webots_ros2_msgs等等。对于初学者来说,这些包之间的依赖关系就像一团乱麻,出了问题很难定位。自定义初级驱动包的做法是:只安装最基础的webots_ros2_msgs用于消息定义,然后自己写节点逻辑。这样整个项目的依赖树非常浅,编译速度快,出问题也容易排查。实测下来,一个最简单的差速驱动机器人,从零到跑通,熟练的话半小时就能搞定。

还有一点值得提前说明:这个项目不涉及复杂的控制算法,也不做 SLAM 或导航。它的定位就是“驱动层”,负责把 ROS 2 的指令翻译成 Webots 能懂的电机转速,再把 Webots 的传感器读数翻译成 ROS 2 能懂的消息。至于上层怎么规划路径、怎么建图,那是另一个层面的事。把驱动层做扎实,上层才能稳。很多新手一上来就想搞全套,结果底层通信都没跑通,后面全是空中楼阁。

2. 环境准备与核心依赖解析

2.1 软件版本选型与兼容性核对

版本兼容性是联合仿真里最容易踩坑的地方。Webots 和 ROS 2 的版本更新节奏不一样,如果随便装,很可能出现“编译通过但运行报错”的尴尬局面。根据我多次折腾的经验,下面这几组搭配是比较稳的:

Webots 版本ROS 2 版本推荐 Ubuntu 版本备注
R2023bHumble22.04最推荐,长期支持
R2023aHumble22.04稳定可用
R2022bHumble22.04需要手动打补丁
R2022aGalactic20.04旧版组合,不推荐新手

为什么首选 Humble?因为它是 ROS 2 的 LTS 版本,支持到 2027 年,社区资料最全,遇到问题搜一下基本都有答案。Webots 选 R2023b 是因为它对 ROS 2 的支持最完善,尤其是webots_ros2_msgs里的消息类型定义很全,不用自己再补。Ubuntu 22.04 则是 Humble 的官方推荐系统,省去很多依赖库的麻烦。

安装 Webots 时,建议直接下载官方压缩包解压,而不是用 apt 安装。为什么?因为 apt 源里的 Webots 版本往往滞后,而且路径不好控制。解压到~/webots目录下,然后把webots可执行文件加到 PATH 里,这样在终端任何位置都能直接启动。ROS 2 的安装就按官方文档来,用 apt 装ros-humble-desktop即可,注意要 source 环境变量。

注意:如果你之前装过其他版本的 ROS 2,一定要确保当前终端里ROS_DISTRO环境变量指向的是 Humble,否则编译时会找不到对应的消息包。

2.2 工作空间创建与包结构规划

ROS 2 的工作空间结构比 ROS 1 清晰很多,但新手还是容易搞混src、build、install、log这四个目录的关系。简单说,src放源码,build放编译中间文件,install放最终可执行文件和库,log放编译日志。我们只需要关心src,其他三个目录是自动生成的,不要手动去改。

创建自定义驱动包的命令如下:

mkdir -p ~/webots_ros2_ws/src cd ~/webots_ros2_ws/src ros2 pkg create --build-type ament_python my_webots_driver --dependencies rclpy std_msgs geometry_msgs sensor_msgs webots_ros2_msgs

这里用ament_python而不是ament_cmake,是因为 Python 写起来快,调试方便,对于初级驱动包来说性能完全够用。依赖里rclpy是 ROS 2 的 Python 客户端库,std_msgs和geometry_msgs用于基础消息类型,sensor_msgs用于激光雷达和 IMU 数据,webots_ros2_msgs则是 Webots 特有的消息定义,比如BoolStamped之类的。

包名my_webots_driver可以随意改,但建议不要用中文或特殊字符。创建完成后,目录结构大致是这样:

my_webots_driver/ ├── my_webots_driver/ │ ├── __init__.py │ └── driver_node.py ├── resource/ │ └── my_webots_driver ├── package.xml ├── setup.py └── setup.cfg

driver_node.py是我们后面要重点写的文件,现在还是空的。package.xml里会自动生成依赖声明,但有时候ros2 pkg create不会把所有依赖都写全,需要手动检查一下。特别是webots_ros2_msgs,如果漏了,编译时不会报错,但运行时会找不到消息类型。

2.3 Webots 侧控制器配置要点

Webots 里的机器人模型需要挂一个控制器(Controller),这个控制器可以是 C、C++、Python 或 Java。既然我们 ROS 2 侧用 Python,Webots 侧也统一用 Python,这样语言一致,调试时不用来回切换思维。在 Webots 的场景树里,选中机器人节点,在controller字段里填入extern。这个extern是关键,它告诉 Webots:控制器不由 Webots 自己启动,而是由外部程序(也就是我们的 ROS 2 节点)来接管。

为什么用extern而不是直接指定一个 Python 脚本?因为联合仿真的本质是 ROS 2 节点在跑控制逻辑,Webots 只负责物理仿真和传感器数据生成。如果 Webots 自己启动控制器,那就变成两套逻辑在打架,通信会非常混乱。用extern模式,Webots 会等待外部连接,连接建立后才开始仿真步进。

另外,Webots 的仿真时间步长(basicTimeStep)需要和 ROS 2 节点的定时器周期匹配。默认是 32 毫秒,对应约 31.25 Hz。如果你的控制频率要求不高,这个值可以不动;但如果要做高频控制,比如 100 Hz,那就把basicTimeStep改成 10 毫秒。注意,改小时间步长会增加计算量,仿真速度会变慢,需要权衡。

提示:在 Webots 的WorldInfo节点里,可以设置optimalThreadCount来利用多核 CPU 加速仿真。一般设为 4 就够用了,设太高反而会因为线程调度开销导致性能下降。

3. 核心驱动逻辑与通信实现

3.1 Webots 控制器与 ROS 2 节点的握手流程

联合仿真的第一步是让 Webots 和 ROS 2 节点“握手”。Webots 在extern模式下启动后,会监听一个本地端口,等待外部控制器连接。ROS 2 节点则通过webots_ros2_driver提供的接口发起连接。但因为我们不用官方驱动包,所以需要自己处理这个连接过程。具体来说,是在 Python 节点里导入webots模块,然后调用Robot()构造函数。这个函数会自动尝试连接正在运行的 Webots 实例。

代码大概长这样:

from webots import Robot robot = Robot() timestep = int(robot.getBasicTimeStep())

Robot()构造函数会阻塞,直到连接成功。如果 Webots 没启动,或者场景里没有extern控制器,这里就会一直卡住。所以调试时一定要先启动 Webots,再运行 ROS 2 节点。反过来,如果 ROS 2 节点先跑,它会报连接超时错误。

连接建立后,robot.getBasicTimeStep()返回的是 Webots 场景里设置的时间步长,单位是毫秒。这个值后面要用来设置 ROS 2 定时器的周期。比如返回 32,那定时器周期就是 0.032 秒。注意单位换算,ROS 2 的create_timer用的是秒,不是毫秒,这里容易搞错。

握手完成后,就可以获取设备句柄了。比如左右轮电机:

left_motor = robot.getDevice('left wheel motor') right_motor = robot.getDevice('right wheel motor')

设备名称必须和 Webots 场景树里的节点名称完全一致,大小写敏感。如果名字写错,getDevice会返回None,后面调用setPosition或setVelocity时就会报AttributeError。这个错误很常见,排查时第一件事就是检查设备名称。

3.2 速度指令订阅与电机控制映射

ROS 2 侧订阅的话题通常是cmd_vel,消息类型是geometry_msgs/Twist。这个消息包含线速度linear.x和角速度angular.z。对于差速驱动机器人,需要把这两个值转换成左右轮的转速。转换公式如下:

v_left = linear.x - angular.z * wheel_separation / 2 v_right = linear.x + angular.z * wheel_separation / 2

其中wheel_separation是左右轮间距,单位是米。这个值需要根据你的机器人模型来填。比如 Webots 自带的e-puck机器人,轮间距大约是 0.052 米。如果不确定,可以在 Webots 里用测量工具量一下,或者查机器人的技术文档。

算出来的v_left和v_right是线速度,单位是米每秒。但 Webots 的电机setVelocity接受的是角速度,单位是弧度每秒。所以还需要除以轮子半径:

omega_left = v_left / wheel_radius omega_right = v_right / wheel_radius

轮子半径同样需要根据模型填写。e-puck的轮子半径约 0.0205 米。这两个参数如果填错,机器人要么不动,要么飞出去。实测中,很多人在这里翻车,就是因为把直径当半径用了。

订阅回调函数的实现:

def cmd_vel_callback(self, msg): linear = msg.linear.x angular = msg.angular.z v_left = linear - angular * self.wheel_separation / 2.0 v_right = linear + angular * self.wheel_separation / 2.0 self.left_motor.setVelocity(v_left / self.wheel_radius) self.right_motor.setVelocity(v_right / self.wheel_radius)

注意,setVelocity只是设置目标速度,电机不会立刻达到这个速度,而是有一个加速过程。Webots 的物理引擎会模拟这个加速,所以不用担心突变。但如果速度设得太大,比如超过电机最大转速,Webots 会截断到最大值,实际转速和指令不符。调试时可以在 Webots 里打开电机节点的显示,观察实际转速。

3.3 里程计数据计算与发布

里程计(Odometry)是导航的基础,它告诉上层机器人当前的位置和姿态。在仿真里,里程计可以通过轮式编码器数据积分得到,也可以直接从 Webots 的 GPS 和惯性单元读取。初级驱动包推荐用轮式编码器积分,因为更贴近真实机器人的实现方式。

Webots 的电机设备可以启用位置传感器:

left_motor.enablePositionSensor(timestep) right_motor.enablePositionSensor(timestep)

然后每个仿真步长读取一次:

left_pos = left_motor.getPositionSensor().getValue() right_pos = right_motor.getPositionSensor().getValue()

这两个值是轮子转过的弧度。结合轮子半径,可以算出左右轮走过的距离:

d_left = left_pos * wheel_radius d_right = right_pos * wheel_radius

然后计算车体位移和角度变化:

d_center = (d_left + d_right) / 2.0 d_theta = (d_right - d_left) / wheel_separation

更新位姿:

x += d_center * cos(theta + d_theta / 2.0) y += d_center * sin(theta + d_theta / 2.0) theta += d_theta

这个积分方法叫“中点积分”,比简单的欧拉积分精度高一些。对于低速机器人,误差可以接受。如果要做高精度导航,建议用 Webots 的 GPS 设备直接读位置,但那样就失去了轮式里程计的意义。

发布里程计消息时,需要填充nav_msgs/Odometry,包括位姿(pose)和速度(twist)。位姿里的协方差矩阵可以填一个对角阵,表示不确定性。初级驱动包可以简单填 0.01 或者直接全零,但全零在有些导航包里会报错,建议至少给个很小的值。

注意:里程计的坐标系要符合 ROS 2 的约定。通常odom坐标系是固定在世界坐标系下的,base_link是机器人本体。发布时header.frame_id填odom,child_frame_id填base_link。如果填反了,RViz 里会显示异常。

3.4 激光雷达数据转换与话题发布

激光雷达是仿真里最常用的传感器之一。Webots 的Lidar设备可以获取点云数据,但格式和 ROS 2 的sensor_msgs/LaserScan不完全一样,需要转换。首先在 Webots 场景里给机器人加一个Lidar节点,设置好horizontalResolution(水平分辨率)、fieldOfView(视场角)、maxRange(最大量程)等参数。

然后在控制器里启用:

lidar = robot.getDevice('lidar') lidar.enable(timestep) lidar.enablePointCloud()

enablePointCloud()是关键,它让 Lidar 以点云形式输出数据,而不是简单的深度图。读取数据:

ranges = lidar.getRangeImage()

ranges是一个浮点数列表,长度等于水平分辨率。每个值是对应角度上的距离,单位是米。如果某个方向没有检测到障碍物,值会是inf。ROS 2 的LaserScan消息里,inf是允许的,但有些可视化工具处理不好,可以替换成maxRange。

填充LaserScan消息:

scan = LaserScan() scan.header.stamp = self.get_clock().now().to_msg() scan.header.frame_id = 'lidar_link' scan.angle_min = -field_of_view / 2.0 scan.angle_max = field_of_view / 2.0 scan.angle_increment = field_of_view / (horizontal_resolution - 1) scan.range_min = 0.1 scan.range_max = max_range scan.ranges = [r if not math.isinf(r) else max_range for r in ranges]

angle_increment的计算容易出错。如果水平分辨率是 512,视场角是 2π,那增量就是 2π/511,而不是 2π/512。因为首尾两个角度都包含在内,所以分母是分辨率减一。这个细节不注意的话,激光雷达数据在 RViz 里会显示错位。

发布频率方面,LaserScan 通常比控制频率低。比如控制跑 30 Hz,激光雷达跑 10 Hz 就够了。可以在回调里加一个计数器,每三次控制周期发布一次激光数据。这样既保证了实时性,又不会让话题消息太多导致网络拥塞。

4. 实操流程与关键环节实现

4.1 从零搭建的完整操作步骤

假设你已经装好了 Webots R2023b 和 ROS 2 Humble,下面是从零开始搭建的完整流程。每一步都经过实测,照着做基本不会出问题。

第一步,创建 ROS 2 工作空间和包:

mkdir -p ~/webots_ros2_ws/src cd ~/webots_ros2_ws/src ros2 pkg create --build-type ament_python my_webots_driver --dependencies rclpy std_msgs geometry_msgs sensor_msgs nav_msgs webots_ros2_msgs

第二步,编写驱动节点。在my_webots_driver/my_webots_driver/目录下新建driver_node.py,把前面几节讲的逻辑整合进去。完整代码结构包括:导入模块、定义类、初始化 ROS 2 节点、连接 Webots、获取设备句柄、创建订阅者和发布者、设置定时器、在定时器回调里读取传感器并发布数据。

第三步,修改setup.py,添加入口点:

entry_points={ 'console_scripts': [ 'driver_node = my_webots_driver.driver_node:main', ], },

第四步,编译工作空间:

cd ~/webots_ros2_ws colcon build --packages-select my_webots_driver source install/setup.bash

第五步,准备 Webots 世界文件。可以自己建一个,也可以用 Webots 自带的示例。关键是机器人节点的controller字段要设为extern,并且机器人上要有左右轮电机、位置传感器、激光雷达等设备。设备名称要和代码里的一致。

第六步,启动仿真。先打开 Webots,加载世界文件,点击运行按钮。此时 Webots 会显示“Waiting for extern controller...”。然后在另一个终端里运行:

ros2 run my_webots_driver driver_node

如果一切正常,Webots 会开始仿真,终端里会看到 ROS 2 节点的日志输出。此时可以用ros2 topic list查看话题,用ros2 topic echo /odom查看里程计数据。

第七步,发送速度指令测试:

ros2 topic pub /cmd_vel geometry_msgs/Twist "{linear: {x: 0.1}, angular: {z: 0.0}}"

机器人应该会向前移动。如果不动,检查电机名称、轮子半径、轮间距这些参数。

4.2 参数配置与调试技巧

参数配置是联合仿真里最磨人的环节。下面这张表列出了关键参数及其典型值,供参考:

参数名含义典型值(e-puck)单位
wheel_separation左右轮间距0.052米
wheel_radius轮子半径0.0205米
max_motor_speed电机最大角速度6.28弧度/秒
basicTimeStep仿真步长32毫秒
lidar_max_range激光最大量程3.0米
lidar_fov激光视场角6.28弧度

调试时,建议先用ros2 topic echo看原始数据,确认数据在变化。如果里程计数据一直是零,可能是位置传感器没启用,或者getValue()返回的是弧度但被当成了米。如果激光数据全是inf,可能是 Lidar 的maxRange设得太小,或者场景里没有障碍物。

另一个常见问题是时间同步。Webots 的仿真时间和 ROS 2 的系统时间默认是不同步的。如果上层导航包依赖use_sim_time参数,需要在 ROS 2 节点里设置:

self.set_parameters([rclpy.parameter.Parameter('use_sim_time', rclpy.Parameter.Type.BOOL, True)])

同时发布/clock话题,把 Webots 的仿真时间广播出去。/clock消息类型是rosgraph_msgs/Clock,每个仿真步长发布一次。这样 RViz 和其他节点就能用仿真时间了。

提示:如果发现 RViz 里机器人模型抖动厉害,可能是/clock发布频率太低。把发布频率提高到和控制频率一致,抖动会明显改善。

4.3 仿真运行与效果验证

跑通之后,验证效果分几个层次。第一层,看机器人能不能动。发一个cmd_vel,机器人往前走,说明速度指令链路通了。第二层,看里程计准不准。让机器人走一个正方形,回到原点时看/odom的位姿是不是接近零。如果偏差很大,检查轮间距和轮子半径是否准确。第三层,看激光雷达数据。在 RViz 里添加LaserScan显示,应该能看到一圈点云,和 Webots 场景里的障碍物对应。

实测中,e-puck 机器人走一个 1 米见方的正方形,回到原点时位置误差大约在 5 厘米以内,角度误差在 3 度以内。这个精度对于初级驱动包来说已经够用了。如果要做更精确的里程计,可以考虑用 Webots 的 GPS 和 IMU 做融合,但那就超出“初级”的范畴了。

还有一个验证方法是录包。用ros2 bag record /odom /scan /cmd_vel把数据录下来,然后回放分析。这样可以反复检查数据质量,而不用每次都重新跑仿真。录包时注意磁盘空间,激光雷达数据量比较大,跑几分钟可能就几百兆。

5. 常见问题与排查技巧实录

5.1 连接失败与设备获取异常

连接失败是最常见的问题,表现是 ROS 2 节点启动后卡住不动,或者报Connection refused。原因通常有三个:Webots 没启动、控制器没设为extern、或者端口被占用。排查顺序是:先确认 Webots 在运行,再检查机器人节点的controller字段,最后用netstat看端口占用情况。Webots 默认用的端口是 1234,如果被其他程序占了,可以在 Webots 的偏好设置里改。

设备获取异常的表现是getDevice返回None。原因九成是设备名称写错了。Webots 的设备名称是区分大小写的,而且空格也不能多不能少。比如left wheel motor和left_wheel_motor是两个不同的名字。建议直接从 Webots 场景树里复制节点名称,粘贴到代码里,避免手打出错。

还有一种情况是设备存在但没启用。比如位置传感器,如果不调用enablePositionSensor,getValue()会返回NaN。激光雷达如果不调用enable和enablePointCloud,getRangeImage()会返回空列表。这些启用操作必须在Robot()构造函数之后、进入主循环之前完成。

5.2 数据异常与话题通信故障

数据异常的表现多种多样:里程计不动、激光数据全零、速度指令没反应。排查时先看话题有没有数据:

ros2 topic hz /odom ros2 topic echo /scan --once

如果话题频率是零,说明发布者没在工作。检查定时器是否启动,回调函数是否被调用。可以在回调里加print语句,看终端有没有输出。如果有输出但话题没数据,可能是发布者的publish调用被遗漏了,或者消息填充不完整导致序列化失败。

话题通信故障还可能是 QoS 配置不匹配。ROS 2 默认的 QoS 是reliable,但有些传感器数据适合用best_effort。如果订阅者和发布者的 QoS 不一致,消息就传不过去。可以在创建发布者时指定:

from rclpy.qos import QoSProfile, ReliabilityPolicy qos = QoSProfile(depth=10, reliability=ReliabilityPolicy.BEST_EFFORT) self.scan_pub = self.create_publisher(LaserScan, '/scan', qos)

注意:RViz 默认用reliable订阅,如果发布者用best_effort,RViz 里可能看不到数据。要么把发布者改成reliable,要么在 RViz 里手动改订阅 QoS。

5.3 性能优化与实时性调优

仿真跑起来后,如果发现卡顿或者数据延迟大,可以从几个方面优化。第一,降低仿真步长。basicTimeStep从 32 毫秒改成 16 毫秒,控制频率翻倍,但计算量也翻倍。如果 CPU 吃不消,反而会更卡。第二,减少不必要的传感器。激光雷达的分辨率从 512 降到 256,数据量减半,对初级应用来说精度足够。第三,关闭 Webots 的渲染。在 Webots 偏好设置里把rendering设为no rendering,仿真速度能提升好几倍,但就看不到画面了,适合后台跑批量测试。

ROS 2 侧的优化主要是减少消息拷贝。Python 的rclpy在发布消息时会做序列化,如果消息很大(比如高分辨率点云),序列化开销不可忽略。可以考虑用array.array或者numpy数组来填充ranges字段,比 Python 列表快一些。另外,发布频率不要设太高,激光雷达 10 Hz 足够了,设成 30 Hz 除了增加 CPU 负载,对导航精度没有实质帮助。

还有一个容易被忽略的点是终端输出。如果在回调里频繁print,终端 I/O 会成为瓶颈。调试完成后记得把print删掉或者改成self.get_logger().debug,并且把日志级别调到INFO以上,避免调试信息刷屏。

5.4 常见问题速查表

现象可能原因排查方法解决方案
节点卡住不启动Webots 未运行或控制器非 extern检查 Webots 状态和场景树启动 Webots,设 controller 为 extern
getDevice 返回 None设备名称错误对比场景树和代码复制粘贴设备名称
里程计数据为零位置传感器未启用检查 enablePositionSensor在初始化时启用传感器
激光数据全 infLidar 未启用或量程太小检查 enable 和 maxRange启用 Lidar,调大 maxRange
机器人不动速度指令未收到或参数错误echo /cmd_vel,检查轮径轮距修正参数,确认订阅正常
RViz 看不到激光QoS 不匹配检查发布者和订阅者 QoS统一改为 reliable 或 best_effort
仿真卡顿步长太小或渲染开销大查看 CPU 占用调大步长,关闭渲染
时间不同步未设置 use_sim_time检查参数和 /clock设置 use_sim_time,发布 /clock

这张表基本覆盖了八成以上的常见问题。实际调试时,建议按表里的顺序逐一排查,不要跳步。很多时候问题不止一个,比如既没设extern又写错了设备名,只解决一个还是跑不起来。

6. 个人实操心得与扩展思路

6.1 踩过的坑与经验总结

第一个坑是单位换算。Webots 的getBasicTimeStep()返回毫秒,ROS 2 的定时器要秒,这个换算我至少忘了三次。后来养成了习惯,在代码里写timestep_sec = timestep / 1000.0,变量名带单位,一眼就能看出问题。

第二个坑是坐标系方向。Webots 默认的坐标系和 ROS 2 不完全一致。Webots 里 Y 轴向上,ROS 2 里 Z 轴向上。如果直接拿 Webots 的 GPS 数据填 ROS 2 的 Odometry,位姿会完全错乱。解决办法是在代码里做轴变换,或者干脆只用轮式里程计,不碰 GPS。轮式里程计是在二维平面上积分,不受三维坐标系影响,省事很多。

第三个坑是消息队列深度。ROS 2 的发布者默认队列深度是 10,如果发布频率高但订阅者处理慢,消息会堆积,导致延迟越来越大。对于传感器数据,队列深度设 1 或 2 就够了,旧数据丢了不可惜。对于控制指令,队列深度可以设 10,避免指令丢失。

第四个坑是Python 的 GIL。rclpy的回调是在单独的线程里执行的,但 Python 的全局解释器锁意味着同一时刻只有一个线程在跑 Python 字节码。如果回调里做了大量计算,会阻塞其他回调。解决办法是把耗时操作放到单独的线程或者用 C++ 重写。初级驱动包一般计算量不大,但激光雷达数据转换如果分辨率很高,还是要注意。

6.2 从初级到进阶的扩展方向

这个初级驱动包跑通之后,可以往几个方向扩展。第一个方向是多机器人仿真。在同一个 Webots 世界里放多个机器人,每个机器人一个 ROS 2 命名空间,话题前面加前缀。这样就能做多机协同的实验,比如编队控制、任务分配。Webots 对多机器人支持很好,只要给每个机器人不同的controller名称就行。

第二个方向是传感器融合。把轮式里程计、IMU、激光雷达的数据用扩展卡尔曼滤波融合起来,得到更准的位姿估计。ROS 2 里有robot_localization包,可以直接用。但要注意,仿真里的 IMU 数据是理想的,没有噪声,融合效果可能反而不如纯轮式里程计。可以在 Webots 里给 IMU 加噪声模型,模拟真实情况。

第三个方向是接入导航栈。把/odom、/scan、/tf这些话题接上nav2,就能做自主导航了。nav2的配置比较复杂,但官方有详细的教程。初级驱动包只要保证话题格式正确、时间戳同步,nav2就能正常工作。实测中,e-puck 机器人在一个 5 米见方的房间里做自主导航,效果还不错,就是激光雷达量程有点小,需要把房间里的障碍物放得近一些。

第四个方向是自定义消息。如果 Webots 里有特殊的传感器,比如触觉传感器或者自定义的视觉模块,可以定义自己的 ROS 2 消息类型,然后在驱动包里发布。定义消息需要修改package.xml和CMakeLists.txt,比纯 Python 包麻烦一些,但也不难。关键是消息类型要在 Webots 和 ROS 2 两边都注册,否则序列化会失败。

6.3 一些实用的小技巧

调试时,可以在 Webots 里把机器人的轨迹画出来。在Robot节点下加一个Pen设备,然后在控制器里调用pen.write(True),机器人走过的地方就会留下痕迹。这样一眼就能看出里程计和实际轨迹的偏差。

如果觉得每次手动启动 Webots 和 ROS 2 节点太麻烦,可以写一个启动脚本,用ros2 launch把两者一起拉起来。webots_ros2包里有现成的 launch 文件可以参考,但因为我们不用官方包,需要自己写。核心是用ExecuteProcess启动 Webots,然后延迟几秒再启动 ROS 2 节点,等 Webots 准备好连接。

还有一个技巧是用仿真时间做基准。在代码里不要用time.time()或者datetime.now(),而是用self.get_clock().now()。这样当use_sim_time为 True 时,时间戳来自/clock话题,和仿真时间一致。如果混用系统时间和仿真时间,TF 变换会报“extrapolation into the future”错误,很难排查。

最后,建议把常用的参数放到 YAML 文件里,用ros2 param load加载。这样改参数不用重新编译,调参效率高很多。比如轮子半径、轮间距、激光雷达量程这些,都适合做成参数。初级驱动包虽然简单,但把这些工程习惯养好,后面做复杂项目会轻松很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进