ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Gazebo仿真中Livox雷达点云格式转换:从PointCloud2到CustomMsg实践

Gazebo仿真中Livox雷达点云格式转换:从PointCloud2到CustomMsg实践 上周帮朋友调一套基于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 typeray标签模拟多线激光输出的是标准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、zfloat32强度如果有解出来对应到reflectivity。offset_time模拟PointCloud2里没有相对时间概念需要仿真时根据点的索引或扫描间隔自己算一个单调递增的相对时间戳给每个点。line和tag赋值Gazebo的PointCloud2如果带ring字段可以直接转成line如果不带只能按点的垂直角度或均匀分组来模拟线号。tag一般直接给0。如果你只是用rviz看点云形状那转换不转换无所谓反正显示效果一样。但只要你后面要跑SLAM这三个差异没处理好算法就会出各种诡异问题——下一节就逐个讲怎么填。3. 手写转换节点——完整实现与逐段讲解这段是全文的核心。我会用Pythonrospy实现一个尽量可直接上手的转换节点。虽然是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_count4, scan_period_ns100000000): 将解析后的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 namelivox_lidar typeray pose0 0 0.2 0 0 0/pose visualizetrue/visualize update_rate10/update_rate ray scan horizontal samples3600/samples resolution1/resolution min_angle-3.14159/min_angle max_angle3.14159/max_angle /horizontal vertical samples4/samples resolution1/resolution min_angle-0.1745/min_angle max_angle0.1745/max_angle /vertical /scan range min0.1/min max100.0/max resolution0.01/resolution /range /ray plugin namelaser_plugin filenamelibgazebo_ros_ray.so topicName/cloud/topicName frameNamelivox_frame/frameName /plugin /sensor这里我故意把垂直扫描设成4线和MID-360的扫描线数量对齐。Gazebo的ray插件对每帧返回的点数量有上限太大容易拖垮仿真性能3600x4这个量级在普通电脑上跑10Hz没问题。4.2 launch文件与运行步骤转换节点的launch文件很简单我直接分享我用的launch !-- Gazebo世界和机器人URDF加载省略 -- !-- 启动转换节点 -- node namepointcloud2_to_livox pkgpointcloud2_to_livox typepc2_to_livox.py outputscreen remap from/cloud to/livox/lidar_raw_pc2 / param nameoutput_topic value/livox/lidar / param nameframe_id valuelivox_frame / param nameline_count value4 / param namescan_period_ns value100000000 / /node /launch需要提醒的是转换节点输出的/livox/lidar话题frame_id必须和Gazebo里雷达插件的frameName保持一致或者手动remap成算法期望的坐标系名。很多SLAM算法的tf监听非常严格frame_id对不上点云数据即使发出来了在rviz里也显示不出来算法也不会处理。启动顺序建议是先启动Gazebo仿真再启动转换节点最后启动SLAM算法。如果不放心可以写一个总launch把它们串起来加上requiredtrue和respawntrue避免节点退出后整个链路崩溃。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_frameTopic选/livox/lidarrviz通过插件可能能认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_idlivox_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_timeROS会使用系统时间算法端如果同时开了use_sim_time两边时间基准不一致SLAM的tf和时间同步会出现微妙的偏差表现就是算法启动后偶尔能收到点云偶尔收不到。排查链路用rostopic echo /livox/lidar/header/stamp观察时间戳再对比当前仿真时间rosservice call /clock之类的输出。发现不一致后在所有相关launch里统一加上param name/use_sim_time valuetrue/这算是个ROS仿真基础常识但真的很容易漏。特别是从真实rosbag直接切到仿真环境的同学最容易栽在这。5.3 雷达分辨率参数与真实Livox不一致这个坑主要体现在点云密度和分布形态上。Gazebo的ray插件是按固定角度分辨率均匀采样而Livox是非重复扫描它的点云在FOV内是逐渐加密的。如果你希望仿真点云更贴近真机需要适当调大ray插件的samples数量或者配合运动让扫描覆盖更密。MID-360在真机上10Hz一帧点云通常有几万到十几万点而我的ray插件配置只有3600x414400点跑一些对点数敏感的特征提取算法时效果会打折。如果想模拟更密集的点云可以把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降到5HzCPU占用直接减半。我现在的做法是demo用Python版本正式批量仿真用C版本两者共用一个消息定义切换成本很低。另外还有一个不是在转换节点上的问题livox_ros_driver2节点如果同时开启了仿真模式某些版本支持在没有硬件的情况下发送模拟数据可能会和你的转换节点抢同一个话题。启动前记得确保没有多余节点在发布/livox/lidar否则算法会订阅到错误的流。排查方法就是rostopic hz和rostopic info多看一眼发布者列表。最后再分享一个这类转换的小经验不要相信肉眼看到的点云形状差不多就觉得转换对了最可靠的验证方式是找一段真机录的rosbag对比一下相同场景下真机CustomMsg的点数和分布和你的仿真转换结果差多远。我一向是在仿真里先跑通全流程再把同样的算法直接切到真机包上——能无缝切换才说明中间的格式转换确实是正确且通用的。
返回列表