☰
PointCloud2与CustomMsg互转:Livox雷达ROS开发实战指南
2026/10/6 18:30:04 网站建设 项目流程

搞机器人感知这几年,跟Livox激光雷达打交道是绕不开的。不管你是用MID-360做园区巡检,还是拿AVIA做无人机建图,只要进了ROS这套生态,就一定会撞上一个问题:rosbag里存的是livox_ros_driver2发出来的CustomMsg,而SLAM、导航、点云处理那一堆现成工具链,认的都是PointCloud2。格式对不上,后面全卡壳。这篇文章就专门聊这个事:PointCloud2和CustomMsg到底差在哪,怎么在各种场景下做双向无缝转换,以及我在实际项目中踩过的那些坑。

文章适合正在配Livox驱动、看不懂rostopic type输出、或者被rqt里“没有可用话题”折磨的ROS开发者。内容不搞虚的,从数据结构讲到CMake配置,从C++代码贴到bag回放验证,全程按我实际复现过的路子来写,你可以照着敲。

1. 先搞清楚你要面对的两头“怪物”:PointCloud2与CustomMsg的区别

1.1 ROS生态里的通用点云格式PointCloud2

PointCloud2是ROS传感器消息里的标准点云格式,sensor_msgs/PointCloud2。它本质上是把一堆点塞进一个字节流里,再靠字段描述(fields)告诉我们每个点里有哪些通道、每个通道占几个字节、偏移量在哪。xyz是三个float32,强度是float32还是uint8,全靠fields定义。好处是通用,几乎所有点云库都认这套格式;PCL的pcl::PointCloud 能直接转成PointCloud2,rviz能直接显示,pcl_ros的滤波、分割、配准节点也全靠它。

但要注意,PointCloud2本身不区分“这是一帧完整扫描”还是“这是半圈攒出来的碎片”。它对时间戳的处理也很简单,整条消息只有一个header.stamp,所有点默认属于同一时刻。这在大部分场景够用,但在Livox这种非重复扫描的固态激光雷达上,就有问题了。

1.2 Livox为什么偏要整一套CustomMsg

Livox搞了个自定义消息,livox_ros_driver2/msg/CustomMsg,原因很实际。Livox的雷达是非重复扫描方式,点不是按传统“线束+角度”排列的,而是随着时间不断累积、打出一个不规则的覆盖图案。如果直接把一帧100ms内的点全部塞进PointCloud2并用同一个时间戳,做去畸变和运动补偿的时候根本分不清哪个点是哪一刻打的,精度直接崩。

CustomMsg的设计就贴心得多了。每条点带一个offset_time,单位是纳秒,表示这帧里这个点相对帧头的偏移时间;还带tag标记点类型(正常点、无效点等),另外保留了线束信息line。更关键的是,CustomMsg允许一包消息里塞多个“回波”或者多段扫描数据,转成PointCloud2时会丢信息,反过来也一样,不是简单改改字段名就能补回来的。这就是互转的核心难点,也是最容易出事的地方。

1.3 字段对照一眼看懂

项目sensor_msgs/PointCloud2livox_ros_driver2/msg/CustomMsg
消息依赖sensor_msgslivox_ros_driver2
点位置fields里定义x/y/z偏移直接用float32 x/y/z
时间信息只有header.stampheader.stamp + 每个点offset_time
线束信息没有有uint8 line
回波信息没有有uint8 tag
坐标帧header.frame_idheader.frame_id
兼容性PCL/ROS生态全家桶仅Livox相关工具链

看完这张表你就能理解,为什么“无脑转”会出问题。PointCloud2里没有offset_time的天然通道,你要么把offset_time塞进自定义fields,要么就丢。很多教程里写的“直接转”,其实已经把时间信息丢了,这在静态场景没事,动态场景一上就露馅。

2. 方案设计:互转的核心思路与选型

2.1 先想清楚:你到底需要哪个方向

转换方向不是随便选的。文章标题把“PointCloud2到CustomMsg”放在最后,但我实际项目里遇到最多的是反过来的CustomMsg到PointCloud2,因为要喂给FAST-LIO、LIO-SAM、autoware这些工具。

先说从CustomMsg到PointCloud2。这个方向几乎必做,因为Livox自己的驱动虽然能发CustomMsg,也能配置成发PointCloud2,但很多老版本驱动、别人的bag、或者中间被转发过的数据,到了你手里就是CustomMsg。你的下游算法只认PointCloud2,那就得转。

再说PointCloud2到CustomMsg。这个方向很多人觉得没必要,但如果你在搞仿真,或者在做Livox雷达的模拟器,又或者你要把Velodyne、Ouster的数据灌给只支持Livox格式的建图算法,就必须反向转。仿真环境里gazebo一般发的是sensor_msgs/PointCloud2,而livox的建图算法或真机回放链路要CustomMsg,必须搭一座桥。

2.2 选型:现成工具 vs 自己写

路上有两条线。第一条,直接用livox_ros_driver2自带的点云转换功能。新版驱动里,livox_ros_driver2的节点可以配置publish_fx_pointcloud2这个参数,让驱动直接额外发布PointCloud2话题。这是最快路径,不需要写一行C++。

第二条,自己写转换节点。什么时候必须自己写?一是bag里的CustomMsg不是最新驱动格式,配置了参数也发不出PointCloud2;二是你需要在转换的同时做时间同步、坐标变换、去畸变,官方转换不给你插一脚的空间;三是你要把PointCloud2反向转成CustomMsg,官方驱动没有这个功能,只能自己实现。

我个人的选型原则是这样的:能用官方参数解决的,绝不多写代码;一但涉及“要在转换中间加逻辑”的,自己开一个独立节点,不污染驱动进程。实时系统里,驱动进程本来就忙着收雷达数据,你再塞点处理逻辑进去,丢包风险会明显上升。

2.3 时间同步:容易被忽略的致命细节

格式转换看起来是“改数据结构”,但本质是“信息映射”。最大的信息损失点是时间。

CustomMsg的header.stamp是这一帧里第一个有效点的时间,每个点的offset_time是相对这个时间的纳秒偏移。转换成PointCloud2时,如果只是“把header.stamp带过去,点数据不管”,下游做运动补偿时就全乱套。解决办法有两个层次。

第一个层次:保守处理,把offset_time塞进PointCloud2的额外字段里。在fields里加一个名为“offset_time”的uint64字段,点的字节里存上每个点的偏移。这样PointCloud2依然能被PCL正常读取(PCL只解析它认识的字段),同时自定义代码能通过getFieldIndex把时间取回来。

第二个层次:真正需要高质量运动补偿时,建议别想着“单条PointCloud2全塞进去”,而是把一帧CustomMsg按时间切碎,比如每10ms拆成一小段PointCloud2,每段用各自接近实际的时间戳发布。这样下游拿到的时间本身就接近真实时间,不需要再去查offset_time。代价是话题频率变高,但很多SLAM算法反而更喜欢这种“接近连续时间”的输入。我实测下来,LIO-SAM和FAST-LIO对这种输入兼容性很好。

3. 从CustomMsg到PointCloud2的完整实操

3.1 环境准备:一套能跑起来的ROS+Livox组合

我这里的实操环境是Ubuntu 20.04 + ROS Noetic + livox_ros_driver2,雷达是MID-360。如果你用的是Ubuntu 22.04 + ROS 2 Humble,代码思路完全一致,改一下package.xml和CMake里的依赖名就行。

需要装的依赖有:

sudo apt install ros-noetic-pcl-ros ros-noetic-pcl-conversions ros-noetic-rviz

Livox驱动我建议直接源码编译,别只靠apt装旧版。旧版驱动发出来的消息字段跟新版有细微差别,特别是frame_id和base_frame的处理逻辑,会导致你后面做tf的时候找不到坐标系。

cd ~/catkin_ws/src git clone https://github.com/Livox-SDK/livox_ros_driver2.git cd .. catkin_make

编译后启动驱动,能看到两个话题。一个叫/livox/lidar,类型是livox_ros_driver2/msg/CustomMsg;另一个是/livox/pointcloud,类型是livox_ros_driver2/msg/CustomPointCloud,这个不是标准PointCloud2,别认错。

注意:有些版本驱动配置了publish_fx_pointcloud2后,会发一个/livox/pointcloud2出来,类型才是sensor_msgs/PointCloud2。在没有该参数的情况下,你拿不到现成的PointCloud2,就得自己转。

3.2 C++实现:用PCL做中转站

我推荐用PCL做中转,而不是手写字节流。原因很现实:PCL的pcl_ros库已经封装好了PointCloud2的序列化,你只需要把CustomMsg的点填进pcl::PointCloud pcl::PointXYZI ,然后调用pcl_conversions::toPCL一次性转换。

核心代码大概是这个思路:

#include <ros/ros.h> #include <livox_ros_driver2/msg/custom_msg.hpp> #include <pcl/point_cloud.h> #include <pcl/point_types.h> #include <pcl_conversions/pcl_conversions.h> void customMsgToPointCloud2(const livox_ros_driver2::msg::CustomMsg& custom_msg, sensor_msgs::msg::PointCloud2& output) { pcl::PointCloud<pcl::PointXYZI> cloud; cloud.header.stamp = custom_msg.header.stamp; cloud.header.frame_id = custom_msg.header.frame_id; cloud.points.reserve(custom_msg.point_num); for (const auto& pt : custom_msg.points) { pcl::PointXYZI p; p.x = pt.x; p.y = pt.y; p.z = pt.z; p.intensity = pt.reflectivity; cloud.points.push_back(p); } cloud.width = cloud.points.size(); cloud.height = 1; cloud.is_dense = true; pcl::toROSMsg(cloud, output); }

注意这里的custom_msg.point_num是有效点数量。Livox一帧消息里预留的点数可能比实际有效点大,如果用points.size(),会把无效的空点也带上。实测中,用point_num和用points.size()在高动态场景下会有明显区别,空点会让下游算法算出错误距离。

如果想保留offset_time,就不能用pcl::PointXYZI这种固定类型,得自己构建PointCloud2:

sensor_msgs::msg::PointCloud2 output; output.header = custom_msg.header; output.height = 1; output.width = custom_msg.point_num; output.fields.resize(4); output.fields[0].name = "x"; output.fields[0].offset = 0; output.fields[0].datatype = sensor_msgs::msg::PointField::FLOAT32; output.fields[0].count = 1; output.fields[1].name = "y"; output.fields[1].offset = 4; output.fields[1].datatype = sensor_msgs::msg::PointField::FLOAT32; output.fields[1].count = 1; output.fields[2].name = "z"; output.fields[2].offset = 8; output.fields[2].datatype = sensor_msgs::msg::PointField::FLOAT32; output.fields[2].count = 1; output.fields[3].name = "offset_time"; output.fields[3].offset = 12; output.fields[3].datatype = sensor_msgs::msg::PointField::UINT64; output.fields[3].count = 1; output.point_step = 20; output.row_step = output.point_step * output.width; output.data.resize(output.row_step); // 然后逐点填充字节

这个方案的优点是信息完整,缺点是PCL的标准函数认不出offset_time字段,你只能在自定义代码里靠field offset直接读内存。

3.3 CMakeLists.txt正确配置

写好了转换节点,最怕编译不过。ROS 2和ROS 1的CMake写法还不一样,这里以ROS 2为例,因为你新项目大概率上Humble。

cmake_minimum_required(VERSION 3.8) project(pointcloud_converter) if(CMAKE_COMPILER_IS_GNUCXX) add_compile_options(-Wall -Wextra -Wpedantic) endif() find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(sensor_msgs REQUIRED) find_package(livox_ros_driver2 REQUIRED) find_package(pcl_conversions REQUIRED) find_package(pcl_ros REQUIRED) find_package(geometry_msgs REQUIRED) add_executable(custom_to_pc2 src/custom_to_pc2.cpp) ament_target_dependencies(custom_to_pc2 rclcpp sensor_msgs livox_ros_driver2 pcl_conversions pcl_ros geometry_msgs) install(TARGETS custom_to_pc2 DESTINATION lib/${PROJECT_NAME}) ament_package()

package.xml里记得加上livox_ros_driver2的依赖声明:

<depend>livox_ros_driver2</depend> <depend>sensor_msgs</depend> <depend>pcl_conversions</depend> <depend>pcl_ros</depend>

有个坑:livox_ros_driver2在ROS 2下的包名可能不是liblivox_ros_driver2,而是livox_ros_driver2,find_package的时候注意别拼错。另外pcl_ros在ROS 2 humble里已经拆成了pcl_ros和pcl_conversions,两个都要找。

3.4 效果验证:别急着跑算法,先在rviz里看

转完以后先别急着接SLAM,先在rviz里检查。rviz里的PointCloud2话题如果显示正常,说明xyz和intensity通道没问题;如果显示一团乱麻,大概率是fields顺序或者字节宽度搞错了。

再用命令行验证时间戳是否合理:

rostopic echo -n 1 /converted_pointcloud2/header

看看stamp是不是跟着帧在走,不是固定值、不是0。如果时间戳恒定不变,下游SLAM会把它当成同一时刻的数据,里程计直接起飞。

还有一个我经常用的验证手段:把原始CustomMsg和转换后的PointCloud2同时加载到rviz里,把CustomMsg的显示方式改成PointCloud2的渲染,两个点云应该完全重合。如果REPLY在rviz里显示的点数和大小都对,但转换后缺失边缘点,就要回去检查point_num有没有用错。

4. 反向转换:PointCloud2到CustomMsg

4.1 哪个场景需要反向转换

很多朋友看到标题里“PointCloud2到CustomMsg”,第一反应是:这需求是不是反了?其实不反。我在做Livox雷达仿真的时候,需要在gazebo里模拟非重复扫描的效果,但gazebo的激光传感器生成的是sensor_msgs/PointCloud2。为了让仿真数据和真机数据格式统一,让同一套建图代码无缝切换,就必须在仿真链路里把PointCloud2转成CustomMsg。

另一个场景是做多传感器融合时,把其他雷达的数据“伪装”成Livox格式,喂给只认CustomMsg的算法。这里面有个问题:CustomMsg里的线束信息line和回波tag,其他雷达根本没有,你只能用默认值填。如果下游算法对line字段有强依赖(比如按线束做分割),就得格外小心了。

4.2 字段拆分与时间戳恢复

从PointCloud2到CustomMsg,核心是“拆字段”。你需要从PointCloud2的字节流里把x、y、z、intensity取出来,然后依次填入CustomMsg的points数组。注意PointCloud2里数据是按点连续存储的,每个点的point_step字节里有多个字段,所以填的时候得按偏移量取。

基础版代码大致这样:

void pointCloud2ToCustomMsg(const sensor_msgs::msg::PointCloud2& input, livox_ros_driver2::msg::CustomMsg& output) { output.header = input.header; output.point_num = input.width; output.points.resize(input.width); int x_offset = -1, y_offset = -1, z_offset = -1, intensity_offset = -1; for (const auto& field : input.fields) { if (field.name == "x") x_offset = field.offset; if (field.name == "y") y_offset = field.offset; if (field.name == "z") z_offset = field.offset; if (field.name == "intensity") intensity_offset = field.offset; } for (size_t i = 0; i < input.width; i++) { const uint8_t* pt_ptr = &input.data[i * input.point_step]; output.points[i].x = *reinterpret_cast<const float*>(pt_ptr + x_offset); output.points[i].y = *reinterpret_cast<const float*>(pt_ptr + y_offset); output.points[i].z = *reinterpret_cast<const float*>(pt_ptr + z_offset); if (intensity_offset >= 0) { output.points[i].reflectivity = *reinterpret_cast<const float*>(pt_ptr + intensity_offset); } output.points[i].offset_time = 0; // 如果没有时间字段,只能置0 output.points[i].line = 0; output.points[i].tag = 0; } }

请注意,这里把offset_time全部置0了,等于放弃所有点级时间信息。如果下游算法需要做运动补偿,就会出问题。更好的做法是从PointCloud2里找一个时间通道,比如我们在正向转换时塞进去的offset_time字段,取出来后还原回去,形成闭环。

4.3 做反向转换前必须考虑的“畸形点”问题

PointCloud2里经常包含无效点、NaN点,尤其经过滤波或融合后。而Livox的CustomMsg通过tag字段标记点状态,tag为0表示正常点,非0表示异常点。反向转换时,如果直接把所有点一股脑填进去,下游可能误判。

我的做法是:转换前先逐点判断点的有效性,x、y、z任意一个不是有限数,就把点的tag置为1,也就是无效点。这样可以保留Livox数据里的“质量信息”,后面做点云预处理时,既能直接过滤,也能做统计。

if (!std::isfinite(pt.x) || !std::isfinite(pt.y) || !std::isfinite(pt.z)) { output.points[i].tag = 1; }

另外一个高频坑:PointCloud2的width和height。无组织点云height为1,width就是点数;有组织点云height可能大于1,按行存储。如果直接把有组织点云当无组织处理,转换结果就是错乱的。转换前检查height,如果大于1,先调用pcl::PassThrough或者手动把它拉平。

5. 实战中的高频问题与排查

5.1 典型报错与解决方案速查表

现象根源解决
转换后rviz里无显示frame_id缺失或tf树里找不到检查header.frame_id和tf配置,Livox常见base_link/livox_frame
点云看起来“有洞”point_num取成了points.size()改用custom_msg.point_num
下游SLAM里程计漂移offset_time被丢弃正向转换时增加offset_time字段,或切帧发布
编译报找不到livox_ros_driver2find_package拼写或安装路径问题确认源码编译过,用ament找不到时设置CMAKE_PREFIX_PATH
转换后点云整体偏移x/y/z字段顺序或字节偏移错位打印fields的offset做对照,别靠猜
内存暴涨每帧new大数组没释放用reserve + resize,提前分配好容量
时间戳全是0转换时没给header.stamp赋值ros时间戳必须从消息拷贝,不能新建默认时间

这里面最隐蔽的是“字节偏移错位”。PointCloud2的fields偏移不是固定的,有的驱动把x放offset 0,有的放offset 4,因为前面可能有个padding。如果你直接硬编码偏移量,换了数据源就翻车。正确姿势是解析fields数组去拿offset,别写死。

5.2 性能优化:别让转换节点成为瓶颈

转换节点本质是“点云搬运工”,但搬得不好,CPU占用能吃掉半个核。我实测,不优化的情况下,MID-360每秒10万个点,逐点push_back到一个vector,CPU占用能到30%以上,还伴随频繁的内存拷贝。

三个优化参数最有效。第一个是预留容量,一开始就custom_msg.points.reserve或cloud.points.reserve,避免vector自动扩容的拷贝开销;第二个是用指针或引用来遍历,别把点结构体按值传来传去;第三个是如果只是xyz+intensity,尝试开NEON或者SIMD指令集,虽然要写平台相关代码,但收益明显。

还有一个容易忽略的点:发布频率。如果一帧CustomMsg是100ms一次,那么转换节点发布PointCloud2也是10Hz,这个频率对SLAM来说偏低。我的建议是转换时把一帧切成4~5段,每段20ms,发布频率直接到50Hz。代价是下游收到的帧数变多,但时间精度提升带来的收益远大于这点CPU开销。

5.3 时间同步的高级玩法:用message_filters对齐多个传感器

转换和同步经常是连在一起的需求。比如你要把Livox的CustomMsg转成PointCloud2后,还要和IMU消息对齐,才能做去畸变。ROS里做多话题同步有现成的message_filters,但对PointCloud2这种大消息,同步时要格外小心。

一个常见错误是把PointCloud2和IMU直接用ApproximateTime同步,结果发现点云频率低、IMU频率高,同步出来的点云数量稀少。更合理的做法是:用IMU最近邻插值,或者只同步时间戳接近的帧,把IMU作为辅助信息源而不是同步主源。

实际项目里,我更倾向于把时间同步逻辑挂在转换节点内部,而不是外面再套一层同步器。这样每条点云发布时,我能同时计算它对应的IMU时间段,直接把插值后的姿态信息附在自定义消息里一起发出去。虽然这已经超出“格式转换”本身,但如果你要做实车,迟早会遇到这个需求。

结尾

我在实际项目中来回捣鼓这两个格式的次数,比写业务代码还多。第一次靠官方驱动里的参数直接转,觉得很省事;后来跑FAST-LIO遇到时间戳漂移,回头查才发现是offset_time被丢了。这年头做机器人感知,格式转换看着简单,但每一个字段背后都有真实物理意义,丢了就是精度损失。我的习惯是:任何转换节点,先在rviz里肉眼验证三分钟,再跑算法验证精度。宁可慢一点,也别让问题藏到建图完成才发现。如果你在转换时也踩过奇怪的坑,或者反向转换时有更好的时间戳恢复思路,非常欢迎一起交流,这玩意儿细节太多了,一个人扛不完。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询