ARTICLE DETAIL

资讯详情

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

Livox点云格式转换:CustomMsg与PointCloud2互转完整指南

Livox点云格式转换:CustomMsg与PointCloud2互转完整指南 做机器人激光感知的同学十有八九都会在Livox雷达的数据格式上卡一下。手里拿的是Livox Mid-360或者Livox Avia驱动装好、话题一收出来的点云是livox_ros_driver2发的CustomMsg格式而你后续要接的SLAM、点云分割、目标检测算法往往只认ROS标准里的sensor_msgs/PointCloud2。反过来也一样有些工具或算法只吐PointCloud2但你要喂给Livox配套的建图流程它又只认CustomMsg。两边对不上整个链路就断在格式这一环。这篇文章就把这两个格式从字段、内存布局到转换节点实现一次讲透。我会给你能直接编译运行的C代码骨架包括CustomMsg到PointCloud2的正向转换、PointCloud2到CustomMsg的逆向转换顺带说清楚转换过程中最容易丢信息的坑时间戳、tag字段、line编号以及如何在转换后保留这些信息。无论你是刚把雷达跑起来的新手还是正在做多传感器融合的老手这篇都能帮你少走几步弯路。1. 为什么Livox偏要用CustomMsg而不是直接用PointCloud2先聊一个很多人会问的问题Livox明明是个大厂为什么不在驱动里直接发ROS标准的PointCloud2非要搞一个自家定义的CustomMsg这个问题的答案其实藏在Livox雷达的工作原理里。Livox用的是非重复扫描技术它的扫描轨迹不是传统机械雷达那种一圈一圈的固定线束而是随时间不断变化的类花瓣状图案。点云的分布是时间累积的结果扫描时间越长点云覆盖越密。这种工作方式给每个点带来两个重要属性一个是每个点有精确的发射时间offset_time另一个是每个点属于哪条laser line激光线。在做运动补偿、去畸变、特征提取的时候这两个信息必须要带上否则后续算法无法把一帧点云里的点纠正到同一个时刻。而ROS标准的PointCloud2消息虽然也定义了time字段但它的设计目标是通用性字段是动态配置的。不同厂商、不同驱动往里面塞什么字段、time字段塞的是绝对时间还是相对时间都没有统一约束。实际用下来多数情况下从PointCloud2里取出来的时间戳要么是空的要么只有帧级别的header.stamp根本没有逐点时间。这种粒度对传统机械式雷达够用但放到Livox这种需要精细时间补偿的传感器上就明显不够用了。所以Livox的驱动直接做了一套自己的消息类型也就是livox_ros_driver2里的CustomMsg核心数据结构就是CustomPoint的数组每个点除了xyz坐标和反射率还带offset_time、tag、line这几个字段所有逐点的时间信息和结构化信息都保留得清清楚楚。注意ROS 1里livox_ros_driver发的是livox_ros_driver/CustomMsgROS 2里livox_ros_driver2发的是livox_ros_driver_msgs/msg/CustomMsg。虽然包名和命名空间不同但字段结构基本一致本文代码思路两边通用只需修改头文件和包名。这样说你就明白了CustomMsg不是故意搞特殊而是Livox点云确实需要这些专有字段。很多人在格式转换上出问题根本原因就是没搞清楚两个格式的信息承载能力不对等硬转之后信息丢了后续算法效果自然掉链子。2. 两种格式的核心结构与信息差异拆解做转换之前先把两个消息的字段结构摊开看一遍。这一步不能省因为代码里每个字段怎么赋值全依赖你对消息内存布局的理解。2.1 CustomMsg的消息结构以livox_ros_driver2为例CustomMsg的定义大致长这样// ROS 2环境下livox_ros_driver_msgs/msg/CustomMsg std_msgs/msg/Header header uint64 timebase // 整帧数据的基准时间单位纳秒 uint32 point_num // 点云中点的数量 uint8 lidar_id // 雷达id uint8 rsvd[] // 保留字段 livox_ros_driver_msgs/msg/CustomPoint[] points // 逐点数据其中的CustomPoint又是另一层结构float x // 点坐标单位米 float y float z float reflectivity // 反射率范围0~255 uint8 tag // 点属性标签0正常1是坏点2是无效点等 uint8 line // 激光线编号Livox Avia是0~5Mid-360也按线束区分 uint8 reserved uint32 offset_time // 该点相对timebase的时间偏移单位纳秒这里最关键的就是offset_time。它记录了这个点相对整帧基准时间的精确偏移注意单位是纳秒。做去畸变、运动补偿时算法拿到这个偏移量配合IMU或者轮速计才能把每个点补偿到统一时刻。这也是CustomMsg最有价值的地方。2.2 PointCloud2的消息结构再看PointCloud2。它的定义大家应该更熟悉std_msgs/msg/Header header uint32 height // 如果是有序点云等于行数无序点云固定为1 uint32 width // 无序点云为主体点数有序点云为每行点数 sensor_msgs/msg/PointField[] fields // 描述每个字段的类型、偏移和名称 bool is_bigendian uint32 point_step // 每个点的字节数 uint32 row_step // 每行数据的字节数 uint8[] data // 点云数据本体按字节连续存储 bool is_dense // 是否无无效点PointCloud2的消息体是一个大的字节数组data所有点数据都按二进制连续排列。要读某个点的某个字段必须先找到fields里对应字段的offset然后在data里按偏移量去取值。point_step就是相邻两个点起始地址的距离row_step则是行与行之间的距离。比如一个典型的三维点云fields可能是x、y、z、intensity四个字段每个float占4字节那point_step就是16。第一个点的x在data[0~3]y在data[4~7]z在data[8~11]intensity在data[12~15]。第二个点就从data[16]开始。理解了这个字节布局你才能理解为什么不能直接把data整块memcpy走。2.3 两者之间的信息不对称到底在哪把两个结构放在一起对比差异就很明显了信息维度CustomMsgPointCloud2转换时是否容易丢失坐标XYZ有有不丢反射率有有常见命名intensity不丢但字段名需映射逐点时间偏移有offset_time单位纳秒不一定有标准驱动基本不填极易丢失激光线编号有line字段标准PointCloud2没有该字段定义极易丢失点状态标签有tag字段标准PointCloud2没有该字段定义极易丢失帧级时间戳header.stampheader.stamp不丢PointCloud2虽然理论上可以自定义任意字段包括往fields里塞一个叫line的float字段但这需要在生成点云时做额外配置而且下游算法通常也只读x、y、z、intensity。实际项目里如果你收的是标准驱动或PCL库转换出来的PointCloud2那line和tag基本就没了。换句话说CustomMsg转PointCloud2是把富信息转换成穷信息PointCloud2转CustomMsg则是一个信息补全的过程。真正有技术含量的就在后面这个方向。3. CustomMsg到PointCloud2的正向转换实现先说正向转换也就是把livox_ros_driver2发出来的CustomMsg转成ROS标准的PointCloud2。这是大多数做感知算法同学的硬需求因为你下载的很多开源算法输入接口写死了sensor_msgs/PointCloud2你不可能为了一个雷达驱动去把算法接口全改一遍。3.1 基于PCL的快速方案如果你项目里已经装了PCL库最简单粗暴的做法是直接借助PCL的点云类型。因为PCL的pcl::PointCloud pcl::PointXYZI 的结构就是x、y、z、intensity四个字段正好能对应上PointCloud2的标准结构。思路是这样的先把CustomMsg里的点塞进pcl::PointCloud pcl::PointXYZI 再用pcl::toROSMsg把它转成PointCloud2消息。代码骨架如下#include rclcpp/rclcpp.hpp #include livox_ros_driver2/msg/custom_msg.hpp #include sensor_msgs/msg/point_cloud2.hpp #include sensor_msgs/point_cloud2_iterator.hpp #include pcl/point_cloud.h #include pcl/point_types.h #include pcl_conversions/pcl_conversions.h sensor_msgs::msg::PointCloud2 convertCustomMsgToPCLPointCloud2( const livox_ros_driver2::msg::CustomMsg::SharedPtr msg) { pcl::PointCloudpcl::PointXYZI cloud; cloud.header.stamp pcl_conversions::toPCL(msg-header.stamp); cloud.header.frame_id msg-header.frame_id; cloud.width msg-point_num; cloud.height 1; cloud.is_dense false; cloud.points.reserve(msg-point_num); for (const auto pt : msg-points) { pcl::PointXYZI point; point.x pt.x; point.y pt.y; point.z pt.z; point.intensity pt.reflectivity; cloud.points.push_back(point); } sensor_msgs::msg::PointCloud2 pc2; pcl::toROSMsg(cloud, pc2); return pc2; }这套方案的优点是简洁几乎不会出逻辑错误因为PCL帮你处理了字节排布。缺点也明显绕了一圈PCL中间多了一次数据拷贝在高频率、高点数场景下会产生额外开销。另外这种方式把offset_time、line、tag全丢了转换后无法再回头做运动补偿。3.2 直接操作数据缓冲区的零拷贝思路如果你对性能有要求或者点的频率太高导致PCL路线CPU吃紧那可以考虑直接操作PointCloud2的data区域。核心思路分三步第一步声明PointCloud2消息并配置字段。用sensor_msgs::PointCloud2Modifier可以动态创建字段sensor_msgs::msg::PointCloud2 pc2; pc2.header.stamp msg-header.stamp; pc2.header.frame_id msg-header.frame_id; pc2.height 1; pc2.width msg-point_num; pc2.is_dense false; sensor_msgs::PointCloud2Modifier modifier(pc2); modifier.setPointCloud2FieldsByString(2, xyz, 1, intensity); modifier.resize(msg-point_num);注意这里有个点容易踩坑setPointCloud2FieldsByString虽然可以按名字自动生成字段布局但字段顺序和类型是固定的x、y、z默认是float32intensity也是float32。也就是说这里的反射率会被存成float32类型而CustomMsg里的reflectivity本身是float类型所以数值可以直接赋值不需要转类型。第二步用迭代器往缓冲区内写入数据sensor_msgs::PointCloud2Iteratorfloat iter_x(pc2, x); sensor_msgs::PointCloud2Iteratorfloat iter_y(pc2, y); sensor_msgs::PointCloud2Iteratorfloat iter_z(pc2, z); sensor_msgs::PointCloud2Iteratorfloat iter_intensity(pc2, intensity); for (size_t i 0; i msg-point_num; i) { const auto src msg-points[i]; *iter_x src.x; *iter_y src.y; *iter_z src.z; *iter_intensity src.reflectivity; iter_x; iter_y; iter_z; iter_intensity; }这一步等于逐个字段填充虽然看起来琐碎但避免了PCL中间容器的创建整体内存开销更小。迭代器底层是封装的指针偏移性能损失可以忽略。第三步如果需要保留tag或line信息可以把PointCloud2的fields扩展到更多维度。比如顺便加一个line字段modifier.setPointCloud2FieldsByString(3, xyz, 2, intensity, tag_line);然后把tag、line拼到一个float里传出去。不过这种做法下游算法读取时比较麻烦实际项目里这么做的不多。大多数人就是保留下xyz和intensity其他信息用同步的时间戳去推算。3.3 rviz里的显示验证转换完成后先在rviz里确认一下效果。启动你的转换节点把话题改成转换后的输出话题在PointCloud2的显示配置里把Size调小到0.01左右Color Transformer改成Intensity看反射率通道是否正常。如果点云能正常刷出来颜色按距离或强度有层次说明转换链路基本通了。如果点云显示成一条直线或者位置完全不对先检查坐标系。Livox驱动默认输出的frame_id一般是livox_frame或者laser_link确保rviz的Fixed Frame和这个保持一致别让TF变换把点云转到unknown位置。4. 反向转换PointCloud2转回CustomMsg的完整实现正向转换做完了接下来是难啃的骨头PointCloud2转CustomMsg。这个需求出现的场景也很典型。比如你用一个开源工具拿到了某段PCD文件或bag里的PointCloud2数据装到Livox驱动框架里做回放和SLAM建图就必须把它转回CustomMsg才能被livox的算法链识别。还有一些做多传感器融合的同学需要把别的雷达的点云伪装成CustomMsg喂给同一个建图管线同样绕不开这个方向。4.1 时间戳信息的补全策略正向转换时我们丢掉的offset_time在反向转换时必须想办法补回来。这里有个原则如果源数据的老家本来就是CustomMsg那你的PointCloud2里理论上可以藏下这些时间信息如果源数据是别的正规PointCloud2那就只能按时间均匀分布来合并。先看能找回时间信息的情况。如果你是从livox驱动发出来的数据已经转了PointCloud2只要转换时没有丢弃time字段那么每个点的时间都能从PointCloud2的字段里读出来。常见情况是有人把offset_time以额外的float字段直接塞进PointCloud2。这时候你反向转换只需要重新解析这些字段即可。只不过现实中大多数转换脚本图省事不会特意保留所以多数情况下你得自己生成。最常用的补全策略是线性插值。思路是假设这一帧内点云采集是均匀分布的对于Livox的非重复扫描其实不均匀但在没有更多先验信息时均匀假设是最合理的近似。已知当前帧的接收时间戳和上一帧的接收时间戳把帧与帧之间的时间跨度按点数量均匀切分给每个点分配一个逐步递增的offset_time。代码里我会用一个更稳妥的方案不依赖上一帧而是基于当前帧自身的时间跨度生成插值时间。具体做法是取header.stamp作为帧的起点再假设点云采集持续了固定周期T一般等于1/帧率让每个点的offset_time在0到T之间均匀分布。实现逻辑如下#include rclcpp/rclcpp.hpp #include livox_ros_driver2/msg/custom_msg.hpp #include sensor_msgs/msg/point_cloud2.hpp #include sensor_msgs/point_cloud2_iterator.hpp livox_ros_driver2::msg::CustomMsg convertPointCloud2ToCustomMsg( const sensor_msgs::msg::PointCloud2::SharedPtr pc2) { livox_ros_driver2::msg::CustomMsg msg; msg.header pc2-header; msg.timebase static_castuint64_t(pc2-header.stamp.sec) * 1000000000ULL static_castuint64_t(pc2-header.stamp.nanosec); msg.point_num pc2-width * pc2-height; // 假设单帧扫描周期约等于 0.1s (10Hz) constexpr double FRAME_PERIOD_NS 100000000.0; double interval_ns FRAME_PERIOD_NS / std::max(1u, msg.point_num); sensor_msgs::PointCloud2ConstIteratorfloat iter_x(*pc2, x); sensor_msgs::PointCloud2ConstIteratorfloat iter_y(*pc2, y); sensor_msgs::PointCloud2ConstIteratorfloat iter_z(*pc2, z); sensor_msgs::PointCloud2ConstIteratorfloat iter_i(*pc2, intensity); msg.points.resize(msg.point_num); for (uint32_t i 0; i msg.point_num; i) { livox_ros_driver2::msg::CustomPoint pt; pt.x *iter_x; pt.y *iter_y; pt.z *iter_z; pt.reflectivity *iter_i; pt.tag 0; pt.line 0; pt.offset_time static_castuint32_t(i * interval_ns); msg.points[i] pt; iter_x; iter_y; iter_z; iter_i; } return msg; }这里line全部置0确实粗糙但对大多数不依赖线序的SLAM算法影响不大。如果算法里明确用了line字段做特征提取比如Livox的loam系列那这个坑就躲不过去了必须想别的办法拿到原始线编号。4.2 从原始bag或pcd里抢救丢失的字段如果你手里有一份原始bag里面有livox_ros_driver2直接发的CustomMsg那你完全没必要做先转成PointCloud2再转回CustomMsg这种绕弯操作。直接单独把CustomMsg话题抽出来转成pcd存着下次使用直接从pcd读回xyz和反射率同时从原话题里离线读取对应时间戳把offset_time、line、tag统一补偿回去。这才是数据链路最稳的做法。我整理过一段离线恢复的Python脚本思路核心是遍历原始bag建立时间戳到CustomMsg的映射表再对转好的点云数据按时间对齐并恢复字段。具体代码在这里不展开了但思路非常重要信息的丢失如果能避免就不要等丢了再补。4.3 一个规避信息丢失的build设计建议这里分享一个能让你的工程少踩坑的设计习惯不管做正向还是反向转换都不要只转换够用的字段。每次转换时顺手把能带的额外字段全部带上。正向转换时PointCloud2的fields里加一个uint32的offset_time字段再加一个uint8的line字段虽然下游算法不读但你的bag文件里就保留了恢复的所有原材料。反向转换时哪怕PointCloud2里没有这些字段你也能从字段列表里直接发现数据从这里开始就缺了而不是被一个表面正常的点云蒙混过去。实际上我自己在工程里就是这么干的。从CustomMsg转PointCloud2时我会在PointCloud2里额外保留三个字段字段名类型存储内容offset_timefloat32原始offset_time强制转float存line_numfloat32原始line编号转floattag_typefloat32原始tag转float虽然这三个字段写在PointCloud2里对算法感知没用但调试时价值巨大。哪个点有问题拉出来一查就知道原始tag是不是标记了无效点原始line是不是某个线束上集中出现的异常。5. 转换节点的工程化落地与常见问题代码逻辑讲完了最后聊聊怎么把这些代码真正放进一个节点的生命周期里以及上线之后会踩到哪些坑。5.1 一个完整的转换节点应该怎么组织我的建议是不要写一堆回调函数乱接话题而是设计成可复用的转换节点类。一个典型的正向转换节点大概包含这几个部分初始化函数里声明订阅器和发布器订阅的topic是livox的原始点云话题发布的是转换后的PointCloud2话题。回调函数里先做参数检查确认点云非空然后调用转换函数转换结果直接发布。主函数里创建节点、实例化转换类、spin等待回调。用ROS 2的rclcpp写的话骨架大致是class CustomMsgToPointCloud2Node : public rclcpp::Node { public: CustomMsgToPointCloud2Node() : Node(custommsg_to_pointcloud2) { sub_ this-create_subscriptionlivox_ros_driver2::msg::CustomMsg( /livox/lidar, rclcpp::SensorDataQoS(), std::bind(CustomMsgToPointCloud2Node::callback, this, std::placeholders::_1)); pub_ this-create_publishersensor_msgs::msg::PointCloud2( /livox/lidar/points, rclcpp::SensorDataQoS()); } private: void callback(const livox_ros_driver2::msg::CustomMsg::SharedPtr msg) { // 校验点云非空 if (msg-points.empty()) { RCLCPP_WARN(this-get_logger(), Empty point cloud); return; } auto pc2 convertCustomMsgToPointCloud2(msg); pub_-publish(pc2); } rclcpp::Subscriptionlivox_ros_driver2::msg::CustomMsg::SharedPtr sub_; rclcpp::Publishersensor_msgs::msg::PointCloud2::SharedPtr pub_; };特别提醒一下QoS的配置。激光雷达点云话题的数据量大、频率高如果按默认的Reliable QoS遇到网络波动和小背压反而容易丢帧或者延迟暴涨。livox官方驱动用的是SensorDataQoS翻译过来就是best_effort加大容量队列你的订阅器和发布器也尽量保持一致不然高频下稳定性差异很大。5.2 高频场景下的性能优化经验点云转换本身不复杂复杂度都在数据拷贝上。如果你的雷达是Mid-360单帧大概也就2万点左右随便怎么转都不压力。但如果你带的是多个雷达或者点了高频率的Avia单帧上十万点也很正常。这时候能优化几点第一复用消息对象。不要每个回调里都重新new一个PointCloud2可以在类成员里维护一个预分配好的PointCloud2每次只resize到当前点数减少重复的内存申请和析构。第二如果代码允许用PCL的那个方案时pcl::PointCloud的points容器也可以提前reserve避免push_back过程中反复扩容。第三用多线程订阅器。ROS 2里可以用rclcpp::SubscriptionOptions让回调在单独的callback group里执行避免点云转换的长尾影响其他话题的实时性。实测这种写法在传感器较多的机器人上效果明显主线程不会因为点云转换而卡顿。rclcpp::SubscriptionOptions opts; opts.callback_group create_callback_group( rclcpp::CallbackGroupType::MutuallyExclusive); sub_ this-create_subscriptionlivox_ros_driver2::msg::CustomMsg( /livox/lidar, rclcpp::SensorDataQoS(), std::bind(...), opts);5.3 常见问题速查表这里把我实际踩过和一些朋友问过的问题汇总成一张速查表方便你按图索骥排查。现象可能原因解决办法转换后rviz里点云不动header.stamp时间戳没更新或为0确保转换后PointCloud2.header.stamp正确赋值rviz里点云只有一条线或斜线Fixed Frame与点云frame_id不一致将rviz Fixed Frame设为livox_frame或对应TF反射率显示一片黑色intensity通道没有对应到reflectivity检查fields里的intensity字段是否成功赋值反向转换后算法认为点全部无效tag被设置成非0算法滤除无效点把tag统一置0或者根据源数据是否有效标记对应设置点云转换后坐标值特别大x、y、z单位弄错Livox的xyz是米PointCloud2里也是米确认没有毫米/厘米换算转换后帧率下降明显resize和重新申请内存太频繁适当复用消息对象或用预分配缓冲多雷达场景下frame_id错乱分配frame_id时用了统一字符串每个雷达设置独立的frame_id再统一做TF外参映射5.4 跨平台的坑字节序和架构差异还有一个比较容易忽视但挺致命的点字节序。PointCloud2本身带了is_bigendian标志位正常情况下一台机器上转换没任何问题但如果你把数据从x86平台录制好拿到ARM板卡上解析小端大端不一致就会导致坐标错乱甚至直接读成NaN。Livox的CustomMsg和PointCloud2在x86和ARM板卡上都是按标准字节序处理的一般默认都按小端算。但跨平台传输bin文件或者跨架构解析bag时一定要先检查is_bigendian字段。正常的点云解析代码应该主动判断这个字段if (pc2-is_bigendian) { // 需要做字节序转换或者直接报错提示数据源异常 }很多工具链没有主动检查这一项导致在ARM上跑的时候点云显示一团乱。这类问题定位起来非常耗时间建议一开始就把检查逻辑写进去。6. 写在最后的经验之谈做这个转换功能最大的心得体会是格式转换从来不只是一个数据搬运问题它考验的是你对消息里每个字段的业务语义有没有吃透。我刚开始做CustomMsg转PointCloud2时也没想太多直接拿PCL转了一遍结果下游做运动补偿的同事拿着我转出来的点云跑了半天发现效果比原始数据差了一大截最后debug到源头才知道是offset_time全丢了点云失去了逐点时间语义。后来我把转换节点升级成保留全字段的方案这类问题再没发生过。所以在你自己动手写转换代码的时候我建议你多看一眼前面那个字段对照表想清楚这个下游到底需要哪些信息再决定你的PointCloud2里到底要保留哪些字段。不要嫌麻烦数据流的完整性和可追溯性比一次转换的代码复杂度重要得多。如果你现在正在搭Livox的感知链路不妨按我给的代码骨架先跑通正向转换把rviz里的显示调正常再考虑反向转换的需求。遇到具体问题随时回来翻翻排查表大部分坑上面都有对应的解法。祝你的点云链路一次跑通。
返回列表