上周帮朋友调一套基于FAST-LIO的Gazebo仿真,算法在真机录好的rosbag上跑得飞起,一进仿真环境就趴窝。查了半天,发现算法订阅的是/livox/lidar,话题类型是livox_ros_driver2/msg/CustomMsg,而Gazebo里雷达发的却是/cloud,类型是标准的sensor_msgs/PointCloud2。两边名字对不上、类型也对不上,算法自然干等一场空。
这个错配,其实是所有想在Gazebo里预研Livox雷达SLAM算法的人都会撞上的第一堵墙。Livox系列(MID-360、Avia等)的非重复扫描特性,让它的驱动自定义了CustomMsg消息格式,而Gazebo原生雷达插件吐出来的只有标准点云。所以我花了半个下午写了一个转换节点,把所有PointCloud2转成CustomMsg,问题当场解决。这文章就是把这次转换的完整思路、代码和踩坑过程记录下来,给同样被这个问题卡住的人一个可以直接抄作业的参考。
1. 为什么Gazebo里跑不通基于Livox驱动的话题——先弄清楚错配根源
很多人在仿真里跑LIO-SAM、FAST-LIO这类算法时,习惯性认为"仿真=真机,话题直接复用真机包的就行",结果Gazebo里一启动,算法端始终等不到点云。这不是算法的问题,而是整个数据链路在仿真环境下被截断了。要理解为什么,得先看清Livox在真机上是怎么发数据的。
1.1 Livox硬件的消息链路与CustomMsg的由来
Livox雷达和传统机械多线雷达不一样,它用的是非重复扫描方式,每帧点云里包含的点ID、offset_time这些信息,直接反映了激光点在被扫描到的那一刻的相对时间偏移。算法端像FAST-LIO、Point-LIO之所以直接订阅CustomMsg,就是因为这些字段对运动补偿、畸变去除至关重要。
所以livox_ros_driver2发布消息时,默认话题是/livox/lidar,消息类型是livox_ros_driver2/msg/CustomMsg。如果你用rostopic info /livox/lidar去看,会发现它的Type不是常见的sensor_msgs/PointCloud2,而是一个自定义消息。
1.2 Gazebo默认雷达插件的输出格式
再看Gazebo这边。大多数人建机器人模型时,雷达用的是gazebo_ros_ray插件(或gazebo_ros_block_laser),它通过<sensor type="ray">标签模拟多线激光,输出的是标准ROS点云。这个插件设计时就只面向通用点云消费端,完全不知道Livox自定义消息的存在,所以话题类型一定是sensor_msgs/PointCloud2。即使你把topicName强行改成/livox/lidar,类型依然不匹配,算法订阅回调根本触发不了。
1.3 两条解决路线:改算法还是加转换节点
碰到这个问题,一般有两条路:
- 改算法端:把FAST-LIO、LIO-SAM里的
CustomMsg订阅改成PointCloud2,同时修改回调函数里的字段解析逻辑。优点是少写一个节点,缺点是以后每换一个算法都要改一遍,而且改完的代码在真机上又用不了,维护成本很高。 - 加转换节点:保留算法代码完全不动,在Gazebo和算法之间插一个中间节点,订阅
PointCloud2,解析并填充CustomMsg字段,重新发布到/livox/lidar上。算法端看到的话题、类型和真机完全一致,等于是把仿真环境伪装成了"一台Livox雷达"。
我选择的是第二种。理由很朴素:真机算法代码一行不改,仿真和真机共用一套启动脚本。后文讲的所有内容,都是围绕这条路展开的。
2. PointCloud2和CustomMsg到底差在哪里——转换前必须看懂的字段
写过转换节点的人都知道,PointCloud2和CustomMsg虽然都叫"点云",但内部结构差异非常大。如果不先把字段搞清楚就开写,代码跑通了你都不知道数据对不对。我花了不少时间在rostopic echo上一帧一帧对比,下面把两者核心差异列出来。
2.1 sensor_msgs/PointCloud2的字段与读法
PointCloud2是ROS标准消息,包含header、height、width、fields、is_bigendian、point_step、row_step、data等字段。它最灵活的地方也最麻烦:点云的xyz坐标并不是直接以"三个float数组"存在,而是被扁平化塞进一个data字节数组里。想取出某个点的 x 坐标,必须知道它在fields里的偏移量,然后从data对应位置解出float32。
这段话可能是整篇最劝退的地方。直观理解:PointCloud2就像一张有字段说明的大表格,每行是一个点,每列是一个属性(x、y、z、intensity等),point_step是每行字节数,fields告诉我们列的位置和类型。取某点坐标,等于在字节数组里按偏移去"切片"。
说到这,还得提醒一下:很多Gazebo雷达插件的PointCloud2里,fields是x、y、z,有的会带intensity,有的会带ring。转换时不能只写死"数据就是xyz float各4字节",必须先去读fields里每个field的offset,否则遇到带强度的点云,坐标解析就会全乱。
2.2 livox_ros_driver2的CustomMsg字段
CustomMsg是livox_ros_driver2里专门为Livox雷达自定义的消息,核心结构大致如下(不同版本可能略有差异,建议用rosmsg show livox_ros_driver2/msg/CustomMsg确认你机器上的具体版本):
std_msgs/Header header uint8 lidar_id uint8 device_type uint8 num_points CustomPoint[] points uint32 timebase uint8 stamp_type其中CustomPoint是关键:
uint32 offset_time float32 x float32 y float32 z uint8 reflectivity uint8 tag uint8 lineoffset_time在真机上表示该点从扫描周期开始到被采集到的相对时间偏移(纳秒级)。reflectivity是反射率。tag一般用于标记点云类型或异常状态,正常点通常是0。line表示扫描线号。转换时这些字段不能随便填,每一步都得有逻辑。
2.3 转换需要处理的核心差异
一句话总结转换的实质:把PointCloud2里扁平的字节数组,按照fields定义解出xyz和强度,再按照Livox的字段语义重新打包进CustomMsg,同时补上offset_time、line、tag这些PointCloud2里本来就没有的信息。
具体要处理三件事:
- 坐标和强度提取:遍历PointCloud2,按每个点的
point_step跳着读,从fields指定的偏移位置解出x、y、z(float32),强度(如果有)解出来对应到reflectivity。 - offset_time模拟:PointCloud2里没有相对时间概念,需要仿真时根据点的索引或扫描间隔自己算一个单调递增的相对时间戳给每个点。
- line和tag赋值:Gazebo的PointCloud2如果带ring字段,可以直接转成line;如果不带,只能按点的垂直角度或均匀分组来模拟线号。tag一般直接给0。
如果你只是用rviz看点云形状,那转换不转换无所谓,反正显示效果一样。但只要你后面要跑SLAM,这三个差异没处理好,算法就会出各种诡异问题——下一节就逐个讲怎么填。
3. 手写转换节点——完整实现与逐段讲解
这段是全文的核心。我会用Python(rospy)实现一个尽量可直接上手的转换节点。虽然是Python,但只要点云单帧不超过10万点,帧率在10Hz以内,跑起来完全够用。如果想追求更高吞吐,可以按同样逻辑改写C++版,后文会给出优化建议。
3.1 包依赖与消息定义确认
先在工作空间建包:
catkin_create_pkg pointcloud2_to_livox rospy sensor_msgs livox_ros_driver2livox_ros_driver2是消息依赖。如果你还没装,源码装一下,或者直接把消息定义拷到自己的包里也行。稳妥起见,先把包编译一遍,再执行:
rosmsg show livox_ros_driver2/msg/CustomMsg rosmsg show livox_ros_driver2/msg/CustomPoint确认字段名和上面一致。不同版本的livox_ros_driver2在某些字段上有差异,比如有的版本num_points是uint8,有的是uint32,你要以实际rosmsg show结果为准。
3.2 转换核心逻辑:读取PointCloud2数据
先看我写的核心转换函数:
#!/usr/bin/env python3 import rospy import struct import math from sensor_msgs.msg import PointCloud2, PointField from livox_ros_driver2.msg import CustomMsg, CustomPoint def read_pointcloud2(pc2_msg): """ 从PointCloud2的data字节数组中解析出xyz和reflectivity 返回列表,每项是(x, y, z, intensity) """ points = [] fmt = "<fff" # 默认xyz都是 float32 x_offset = None y_offset = None z_offset = None intensity_offset = None for f in pc2_msg.fields: if f.name == "x": x_offset = f.offset elif f.name == "y": y_offset = f.offset elif f.name == "z": z_offset = f.offset elif f.name in ("intensity", "reflectivity", "i"): intensity_offset = f.offset if x_offset is None or y_offset is None or z_offset is None: rospy.logerr("PointCloud2缺少xyz字段") return points data = pc2_msg.data point_step = pc2_msg.point_step for i in range(pc2_msg.width * pc2_msg.height): base = i * point_step x = struct.unpack_from("<f", data, base + x_offset)[0] y = struct.unpack_from("<f", data, base + y_offset)[0] z = struct.unpack_from("<f", data, base + z_offset)[0] intensity = 0 if intensity_offset is not None: intensity = struct.unpack_from("<f", data, base + intensity_offset)[0] # 有些驱动会把强度存成uint8,需要额外判断字段类型 points.append((x, y, z, intensity)) return points注意我特意没有直接用pc2_msg.data里连续的xyz去解析,而是先遍历fields找到每个字段的offset。就是因为你不知道上游Gazebo插件会不会在xyz里混入额外字段,这样写最稳妥。
3.3 offset_time 与仿真时间怎么模拟
真机上,offset_time是雷达扫描周期内每个激光点相对起点的时间差,单位纳秒。仿真里没有真实电机转动,所以这里要"合成"。我的做法是:假设一帧点云等间隔采集,把整帧点按索引均匀分布在一个虚拟扫描周期内。
参考代码:
def convert_to_custom_msg(pc2_msg, points, line_count=4, scan_period_ns=100000000): """ 将解析后的points列表转为CustomMsg scan_period_ns: 一帧点云的虚拟扫描周期,默认0.1秒即10Hz """ custom_msg = CustomMsg() custom_msg.header = pc2_msg.header custom_msg.header.frame_id = "livox_frame" custom_msg.lidar_id = 0 custom_msg.device_type = 1 # 1代表MID-360等类型,具体参考驱动代码 custom_msg.num_points = len(points) # timebase 是这一帧的基准时间(纳秒),通常取header里的stamp custom_msg.timebase = pc2_msg.header.stamp.to_nsec() if len(points) == 0: return custom_msg # 按点索引均匀分配offset_time dt = scan_period_ns / len(points) for i, (x, y, z, intensity) in enumerate(points): p = CustomPoint() p.offset_time = int(i * dt) p.x = x p.y = y p.z = z p.reflectivity = max(0, min(255, int(intensity * 255))) p.tag = 0 # 如果是多线雷达,根据垂直角分配line;非多线时给0 p.line = int(i % line_count) custom_msg.points.append(p) return custom_msg这里有个关键经验:offset_time 不用追求和真机完全一致,但要保证单调递增。好多SLAM前端会按offset_time做点云插值或运动补偿,如果时间戳顺序乱跳,优化会直接发散。我一开始图省事,直接把所有点offset_time设成0,结果算法跑起来轨迹一塌糊涂,后来改成均匀递增才正常。
还有个小细节:reflectivity在Gazebo里通常就是强度值,但取值范围不固定。有些仿真插件给的是0到1的小数,有些是0到255的整数。我上面做了归一化到0-255的处理,如果你的强度本来就是0-255整数,那段可以直接改合适的方式。
3.4 线数line与tag的赋值策略
真正Livox雷达的line字段反映的是扫描线ID。MID-360有4条扫描线,Avia有更多。Gazebo的gazebo_ros_ray模拟多线激光时,可以通过配置生成多行扫描点,但PointCloud2里不一定有可靠的ring字段。我测试过几种情况:
- PointCloud2里有ring字段:优先用它,这是最接近真实物理扫描的。
- 有垂直角度信息但无ring:根据点云中z值或垂直角计算线号。
- 纯随机分布点云(比如官方Livox gazebo插件模拟的非重复扫描):直接把
line全给0也行,很多SLAM算法只用xyz和offset_time,不太care line。
tag字段在真机上用来区分正常点/异常点/无效点,仿真环境可以一律给0,除非你想专门模拟遮挡或噪声。
我实际测试下来发现,很多算法在只有 x/y/z、reflectivity、offset_time 的情况下就能跑出不错的效果,line和tag更像"备胎"。但既然CustomMsg定义了这些字段,你在仿真里给它填上合理值,总比留空或乱填要好,省得后面换算法时又踩一遍。
4. 在Gazebo环境中把整个链路跑起来——仿真搭建与联调验证
转换节点写完了,不代表万事大吉。我这次联调是在Ubuntu + ROS Noetic + Gazebo的环境里做的,选用的雷达是Livox MID-360风格的仿真模型。整个过程包括雷达插件选择、launch文件编写、数据验证三步,每一步都有坑。
4.1 雷达仿真选型:gazebo_ros_ray 与官方Livox仿真插件的取舍
做Livox仿真,目前主流有两种做法:
一是直接用官方或社区提供的Livox Gazebo仿真模型,这类模型通常会模拟非重复扫描轨迹,能直接发CustomMsg话题。如果你找到的模型能直接发CustomMsg,那其实不需要本文的转换节点。但这类模型往往配置比较重,点云形态也和真机有偏差,而且不一定适配你的机器人模型。
二是像我这次,用gazebo_ros_ray插件生成通用PointCloud2,再通过转换节点转成CustomMsg。优点是简单、稳定、适配任何机器人URDF;缺点是需要自己写转换逻辑。考虑到大多数人手上已有Gazebo机器人模型,只是想把雷达话题格式统一成Livox风格,我推荐第二种。
下面是一个典型的gazebo_ros_ray雷达插件配置:
<sensor name="livox_lidar" type="ray"> <pose>0 0 0.2 0 0 0</pose> <visualize>true</visualize> <update_rate>10</update_rate> <ray> <scan> <horizontal> <samples>3600</samples> <resolution>1</resolution> <min_angle>-3.14159</min_angle> <max_angle>3.14159</max_angle> </horizontal> <vertical> <samples>4</samples> <resolution>1</resolution> <min_angle>-0.1745</min_angle> <max_angle>0.1745</max_angle> </vertical> </scan> <range> <min>0.1</min> <max>100.0</max> <resolution>0.01</resolution> </range> </ray> <plugin name="laser_plugin" filename="libgazebo_ros_ray.so"> <topicName>/cloud</topicName> <frameName>livox_frame</frameName> </plugin> </sensor>这里我故意把垂直扫描设成4线,和MID-360的扫描线数量对齐。Gazebo的ray插件对每帧返回的点数量有上限,太大容易拖垮仿真性能,3600x4这个量级在普通电脑上跑10Hz没问题。
4.2 launch文件与运行步骤
转换节点的launch文件很简单,我直接分享我用的:
<launch> <!-- Gazebo世界和机器人URDF加载省略 --> <!-- 启动转换节点 --> <node name="pointcloud2_to_livox" pkg="pointcloud2_to_livox" type="pc2_to_livox.py" output="screen"> <remap from="/cloud" to="/livox/lidar_raw_pc2" /> <param name="output_topic" value="/livox/lidar" /> <param name="frame_id" value="livox_frame" /> <param name="line_count" value="4" /> <param name="scan_period_ns" value="100000000" /> </node> </launch>需要提醒的是:转换节点输出的/livox/lidar话题,frame_id必须和Gazebo里雷达插件的frameName保持一致,或者手动remap成算法期望的坐标系名。很多SLAM算法的tf监听非常严格,frame_id对不上,点云数据即使发出来了,在rviz里也显示不出来,算法也不会处理。
启动顺序建议是:先启动Gazebo仿真,再启动转换节点,最后启动SLAM算法。如果不放心,可以写一个总launch把它们串起来,加上required="true"和respawn="true"避免节点退出后整个链路崩溃。
4.3 用rostopic、rviz和rosbag验证转换结果
转换节点跑起来以后,先不要着急跑算法,先用命令行验证数据:
rostopic list | grep livox rostopic info /livox/lidar rostopic hz /livox/lidar rostopic echo /livox/lidar -n 1重点看三点:
Type是不是livox_ros_driver2/msg/CustomMsg- 频率稳不稳定(10Hz左右)
num_points是不是和Gazebo雷达插件设置的samples数一致
然后在rviz里添加PointCloud2显示器,Fixed Frame选livox_frame,Topic选/livox/lidar(rviz通过插件可能能认CustomMsg,如果显示插件不支持,可以加一个PointCloud2转换显示,或先用pcl_ros转成PointCloud2看形状)。
最后用rosbag录一小段包:
rosbag record -O livox_test.bag /livox/lidar把包拿来喂给SLAM算法,如果算法能在仿真点云上正常建图,说明转换结果可以被算法消费。这一步很关键,因为有时候topic和type都对,但odom和点云的tf对不上,算法一样起不来。
4.4 GUI闪烁、点云缺失这类环境问题和数据问题怎么区分
感兴趣可以看这个:连调过程中我遇到过Gazebo界面一直闪的问题。一开始以为是雷达数据异常导致算法崩溃,后来发现是显卡驱动和Gazebo渲染之间的兼容性问题,跟点云格式半毛钱关系没有。这种情况下,点云话题一样正常发布,但人眼会被闪烁的界面误导,以为仿真世界出了问题。
我的排查经验是:遇到"看起来不对"的问题,先看命令行数据,再判断是不是渲染问题。如果rostopic hz和rostopic echo都正常,rviz里点云也正常显示,那就别管GUI闪不闪,继续跑算法就行。如果确实影响操作,可以关掉Gazebo的GUI,只用命令行或rviz订阅话题,Gazebo在无GUI模式下也能正常运行仿真。
5. 实测中踩过的坑与排查链路——这些坑比代码本身更值得记
代码总共写了几十行,真正花时间的是排查各种奇怪现象。这一节我把这次联调里踩过的坑整理出来,每一条都是真实经历,后面的排查思路你也可以直接复用。
5.1 frame_id不一致导致算法完全收不到数据
这是在联调时遇到最隐蔽的坑。算法端(FAST-LIO)期望的雷达坐标系是livox_frame,我启动launch时也设了frame_id=livox_frame,但Gazebo雷达插件里实际的frameName写的是base_laser,导致rviz里点云无论怎么切换Fixed Frame都看不到。重复检查话题列表、消息类型都正常,就是数据不显示。
排查链路是这样的:先用rostopic echo /livox/lidar -n 1 | grep frame_id看实际输出的frame_id,再用rosrun tf tf_echo map livox_frame看tf树里有没有这条边。发现frame_id对不上,直接在转换节点里强制覆盖header.frame_id为算法期望值,问题解决。
我的经验是:转换节点除了转换格式,最好提供一个frame_id覆盖参数。因为Gazebo里的坐标系定义往往和算法期望不一致,有覆盖参数就不用每次回去改URDF。
5.2 use_sim_time造成的时间戳错乱
转换节点用的是header.stamp,在Gazebo里如果你没有设置use_sim_time,ROS会使用系统时间;算法端如果同时开了use_sim_time,两边时间基准不一致,SLAM的tf和时间同步会出现微妙的偏差,表现就是算法启动后偶尔能收到点云,偶尔收不到。
排查链路:用rostopic echo /livox/lidar/header/stamp观察时间戳,再对比当前仿真时间rosservice call /clock之类的输出。发现不一致后,在所有相关launch里统一加上:
<param name="/use_sim_time" value="true"/>这算是个ROS仿真基础常识,但真的很容易漏。特别是从真实rosbag直接切到仿真环境的同学,最容易栽在这。
5.3 雷达分辨率参数与真实Livox不一致
这个坑主要体现在点云密度和分布形态上。Gazebo的ray插件是按固定角度分辨率均匀采样,而Livox是非重复扫描,它的点云在FOV内是逐渐加密的。如果你希望仿真点云更贴近真机,需要适当调大ray插件的samples数量,或者配合运动让扫描覆盖更密。MID-360在真机上10Hz一帧点云通常有几万到十几万点,而我的ray插件配置只有3600x4=14400点,跑一些对点数敏感的特征提取算法时,效果会打折。
如果想模拟更密集的点云,可以把horizontal samples提高到7200或10000,但要注意提高sample数会增加CPU负载和话题带宽。想在点数和帧率之间找一个平衡点,没有绝对标准,只能多试几档,看自己的算法能不能跑起来。
5.4 大点云转换带来的CPU压力与优化
Python版本转换节点在点数超过2万以后就开始有点吃力了,尤其是用struct.unpack_from一个点一个点地拆包,10万点一帧时CPU占用会明显上升,帧率也会掉。如果你只需要跑demo,这个性能能接受;要是做批量仿真调参,建议优化。
优化方向有三个:
- 用numpy.frombuffer:直接把PointCloud2的data按
point_step重组成二维数组,一次向量化取出xyz列,减少Python循环。 - 改写成C++节点:用
pcl::fromROSMsg读取PointCloud2,再遍历填充CustomMsg,性能提升非常明显,10万点也能轻松跑到20Hz以上。 - 在Gazebo端降低点云频率:如果算法不需要那么高的帧率,把ray插件的
update_rate降到5Hz,CPU占用直接减半。
我现在的做法是:demo用Python版本,正式批量仿真用C++版本,两者共用一个消息定义,切换成本很低。
另外还有一个不是在转换节点上的问题:livox_ros_driver2节点如果同时开启了仿真模式(某些版本支持在没有硬件的情况下发送模拟数据),可能会和你的转换节点抢同一个话题。启动前记得确保没有多余节点在发布/livox/lidar,否则算法会订阅到错误的流。排查方法就是rostopic hz和rostopic info多看一眼发布者列表。
最后再分享一个这类转换的小经验:不要相信肉眼看到的点云形状"差不多"就觉得转换对了,最可靠的验证方式是找一段真机录的rosbag,对比一下相同场景下真机CustomMsg的点数和分布,和你的仿真转换结果差多远。我一向是在仿真里先跑通全流程,再把同样的算法直接切到真机包上——能无缝切换,才说明中间的格式转换确实是正确且通用的。