做过机器人SLAM的朋友应该都有这种体会:跑通一套建图算法不难,真正麻烦的是搞清楚数据从哪儿来、长什么样、该怎么处理。我前阵子拿到一台宇树机器狗GO2,打算在上面做一套完整的SLAM建图方案,第一个要啃的骨头就是点云格式。这期先不讲算法怎么调参,也不讲里程计怎么配,单纯把点云这件事说透——因为不管你后面上fast-lio、LIO-SAM还是别的方案,点云数据本身搞不明白,后面全是坑。
GO2出厂自带一个Livox MID-360雷达,配合机载主控跑的是ROS2。虽然官方默认有一套建图Demo能直接跑,但你要真想拿它做自己的东西,比如接入自研算法、改话题类型、自己保存数据集,就必须从点云消息结构开始理解。这篇我会把PointCloud2消息字段、坐标系关系、实操中如何查看和保存点云,以及Livox这类非重复扫描雷达点云的独特性质一次讲清楚,顺便把我在GO2上踩过的几个典型坑也整理出来。
这期内容适合手上有GO2、准备深入研究SLAM的开发者,也适合那些在模拟器和普通ROS2机器人上跑过SLAM、但没碰过固态激光雷达的朋友。就算你只是对点云数据结构好奇,看完也能搞清楚点云到底是个什么东西。
1. 为什么做SLAM之前,必须先把点云格式吃透
先说个我自己的感受:很多新手拿到GO2,第一反应是直接跑官方Demo,看到RViz2里绿油油的点云地图刷出来,觉得挺酷,然后就不知道怎么往下走了。等到想换算法、想改配置、想把点云数据存下来离线调试时,才发现自己对数据本身一无所知——话题名字不清楚、消息类型对不上、坐标系关系一团浆糊,半天时间全耗在查报错上。
1.1 点云在SLAM链路里的位置
一台四足机器人做SLAM建图,数据的流转链路大致是这样的:雷达发射激光、接收回波,驱动节点把这些原始测量转换成点云消息,发布到ROS2话题上;SLAM算法订阅这个点云消息,结合IMU数据做帧间匹配和位姿估计,再把匹配好的点云投影到全局坐标系,不断拼接成地图。
整个过程里,点云是所有后续计算的“原材料”。你用什么算法、调什么参数,本质上都是在处理这些三维点。如果连原材料的格式都没搞清,比如不知道每个点除了xyz还有没有强度值、不知道点云坐标系是雷达系还是机体系,那后面调试算法时基本都是盲人摸象。
1.2 GO2这套硬件上的点云链路特殊在哪
GO2用的Livox MID-360不是传统机械式激光雷达,而是固态非重复扫描雷达。它的扫描模式决定了点云的分布规律跟Velodyne那类16线、32线雷达完全不同——这个后面我会单独展开讲。
另外GO2的软件层是基于ROS2 Humble的,点云消息走的是ROS2的sensor_msgs/msg/PointCloud2。虽然这套消息类型在ROS1里也有同名版本,但ROS2在DDS传输、QoS策略这些底层机制上跟ROS1差别很大。很多从ROS1转过来的人,会在这上面吃不少亏。
1.3 三种常见点云数据格式,别搞混
在实际工作中,点云数据至少有三个层面的存在形式:
| 格式 | 说明 | 使用场景 |
|---|---|---|
| PointCloud2消息 | ROS和ROS2里的标准点云消息类型,用于节点间实时传输 | 话题通信、算法处理 |
| PCD文件 | Point Cloud Data,PCL库定义的磁盘存储格式 | 保存点云数据、离线调试、数据集制作 |
| LAS/LAZ文件 | 行业标准点云格式,带分类、强度等丰富属性 | 测绘、GIS、地形建模 |
这三者经常被混为一谈,但事实上它们的用途完全不同。你在GO2上通过ros2 topic echo看到的是一帧一帧的PointCloud2消息;你把点云用pointcloud_to_pcd节点保存下来,得到的是PCD文件;你要把地图导入到专业测绘软件里做后处理,可能需要转成LAS。搞清楚这三者的区别,能帮你避免很多“数据明明是好的,为什么用不了”的困惑。
2. PointCloud2消息解剖:字段、坐标、时间戳一个都不能少
PointCloud2是ROS2里点云数据的通用容器。看起来就是一堆字节流,但读明白它并不难,关键是要掌握几个核心概念:消息头、字段列表、数据布局。
2.1 先看字段定义
我直接用一个实际话题来说明。在GO2上,MID-360雷达的点云话题通常是/livox/lidar或者类似的名字,消息类型就是sensor_msgs/msg/PointCloud2。它的核心字段是这样的:
Header header uint32 seq # 序列号 time stamp # 时间戳 string frame_id # 坐标系标识 uint32 height # 点云高度(2D扫描时为1) uint32 width # 点云宽度(点数或每行点数) PointField[] fields # 每个点的字段定义 uint8 datatype # 数据类型(INT8/UINT8/INT16/UINT16/INT32/UINT32/FLOAT32/FLOAT64) string name # 字段名(x、y、z、intensity等) uint32 offset # 字段在点数据中的字节偏移 uint8 is_bigendian # 是否大端字节序 uint32 point_step # 单点占用的字节数 uint32 row_step # 一行数据的字节数 uint8[] data # 实际的点云数据 bool is_dense # 是否包含无效点(NaN)这里最重要的就是fields数组。以MID-360为例,它发布的点云通常包含以下字段:
| 字段名 | 含义 | 数据类型 | 常见偏移 |
|---|---|---|---|
| x | X轴坐标(米) | FLOAT32 | 0 |
| y | Y轴坐标(米) | FLOAT32 | 4 |
| z | Z轴坐标(米) | FLOAT32 | 8 |
| intensity | 反射强度 | FLOAT32 | 12 |
| tag | 属性标签 | UINT8 | 16 |
| offset_time | 相对时间偏移 | FLOAT32 | 17 |
注意,不同驱动版本、不同雷达型号,字段组成可能不一样。有些雷达会加一个ring字段表示线束编号,有些会有timestamp字段。所以看一个PointCloud2消息,第一件事不是直接取数,而是看它的fields里到底有什么。
2.2 理解数据的线性布局
PointCloud2的消息体是一个扁平字节数组,看起来唬人,其实规矩很简单:一帧点云展开后,前point_step个字节是第一个点的数据,接下来的point_step个字节是第二个点,以此类推。一个点的数据内部,不同字段按offset指定的偏移量排列。
举个例子,如果point_step是20字节,x字段的偏移是0、y偏移是4、z偏移是8,那么第10个点的x坐标就位于data[9 * 20 + 0]到data[9 * 20 + 3]这4个字节里。用Python的struct模块解析时,直接按这个规律读就好。
我在实际处理时更推荐用现成库。ROS2 Python客户端里,可以这样取点:
from sensor_msgs.msg import PointCloud2 from sensor_msgs_py import point_cloud2 as pc2 # 假设拿到了msg points = pc2.read_points(msg, field_names=["x", "y", "z", "intensity"], skip_nans=True) for p in points: print(p) # (x, y, z, intensity)C++侧则直接用PCL的fromROSMsg接口,一下就能转成pcl::PointCloud<pcl::PointXYZI>。这也是大家平时最常用的路子,毕竟谁也不想手撸字节解析。
2.3 坐标系:搞清楚frame_id是雷达系还是机体系
点云消息里的frame_id信息很关键,它告诉你当前这帧点云的参照坐标系是什么。在GO2上,如果frame_id是livox_frame,说明点云是相对于雷达本体坐标系的;如果经过TF变换后frame_id变成了base_link,那就是相对于机体中心坐标系的。
这个区别在SLAM里非常重要。fast-lio这类算法做状态估计时,通常把机体坐标系作为核心参照,雷达相对机体的安装位置和姿态,是通过外参标定得到的。如果你拿到一帧点云后,不去看frame_id就直接当作机体系去用,那姿态和位置全都会偏,建出来的图也会扭曲。
我之前调试时遇到过一次“地图像喝醉了酒一样歪斜”的情况,排查到晚上才发现是直接把livox_frame的点云输给了算法,安装外参被重复施加了一次,相当于把同一个变换做了两遍。后来我习惯性地在每一条链路前面都打印一次frame_id,这坑才被彻底绕开。
2.4 时间戳与你该关心的“时间同步”
PointCloud2的Header里带一个stamp,表示雷达采集这帧数据的时刻。在ROS2里,时间戳用于消息过滤、时间同步和TF查询,不是可有可无的东西。
GO2上跑多传感器融合时,时间戳尤其重要。激光雷达和IMU都有各自的时钟源,如果不同传感器的时间戳不同步,融合算法就会算出完全错误的位姿。一般有两种做法:一是用硬件时间同步,让雷达和IMU共用同一套时钟基准;二是在软件层把不同传感器的时间戳通过滤波器对齐。GO2的驱动层本身已经做了很多时间同步的工作,但你自己写算法时,仍然要检查时间戳的精度是否满足需求。
另外注意一个小细节:ROS2的时间戳用的是纳秒,ROS1用的是秒加纳秒。做数据转换时,例如把旧ROS1的数据包转过来用,很容易在时间上直接乘个1000之类导致精度丢失,这种错误非常隐蔽。
3. 实操:在GO2上看点云、存点云、转格式
理论聊得差不多了,接下来上真机操作。我下面这套操作流程都是我在GO2上实际跑过的,每一步都可以直接复现。
3.1 先看点云长什么样
启动GO2的雷达驱动,然后打开终端查看话题列表:
ros2 topic list输出里能看到类似下面的结果:
/livox/lidar /livox/imu /tf /tf_static /odom/livox/lidar就是我们要关注的点云话题。查看它的消息类型和频率:
ros2 topic info /livox/lidar ros2 topic hz /livox/lidartopic info输出的Type应该就是sensor_msgs/msg/PointCloud2。topic hz会显示发布频率,GO2的MID-360通常以10Hz发布点云,不同固件版本可能有差异。
要偷看消息内容,用topic echo是最直接的:
ros2 topic echo /livox/lidar --once终端会刷出一大段JSON格式的消息,我建议别盯着data字段看,先看fields和point_step,这两个字段能直观反映数据结构。如果只输出一部分就截断了,可以加--fields限定字段:
ros2 topic echo /livax/lidar --once --fields header.stamp,header.frame_id,width,height,point_step这样能看到更精简的信息,比如时间戳、frame_id、点数和单点步长。
3.2 RViz2可视化点云
实时看点云,最方便的还是RViz2。启动方式:
rviz2在RViz2里做三件事:
- 把Fixed Frame改成
livox_frame或base_link,取决于TF树里你用哪个坐标系做参照; - 添加一个PointCloud2显示组件,在Topic一栏填入
/livox/lidar; - 把Size(Pixels)调成2左右,ColorTransformer可以选Intensity,这样点云会按反射强度着色,看起来比单一颜色清楚很多。
如果RViz2里看不到点云,先别急着怀疑雷达坏了,八成是Fixed Frame和消息里的frame_id对不上,或者TF树没连上。确认一下TF树的状态:
ros2 run tf2_tools view_frames这个命令会在当前目录生成一个frames.pdf,打开就能看到坐标系之间的关系。在GO2上,一般会有livox_frame→base_link→ ... 这样的链路,如果链路断了,点云就无法被正确显示在全局坐标系下。
3.3 保存点云为PCD文件
离线调试是SLAM开发里省不了的一步。把实时点云存成本地文件,就可以反复回放,不用每次都在真机上折腾。保存PCD最简单的方式是用pcl_ros的pointcloud_to_pcd节点:
ros2 run pcl_ros pointcloud_to_pcd --ros-args -r input:=/livox/lidar -p prefix:=./bag_pcd执行之后,它会按时间戳连续保存PCD文件,文件名为prefix + time_stamp.pcd。保存时目录要存在,否则会静默失败或者报错,这点我踩过:第一次跑的时候prefix写了一个不存在的路径,命令看起来在运行,实际一个文件都没存下来。
如果你想自己控制保存逻辑,比如过滤掉无效点再存,或者只存每隔多少帧存一次,可以直接写一个简单的ROS2 Python节点,订阅PointCloud2,转换成numpy数组再保存。下面是个精简版示例:
import numpy as np import rclpy from rclpy.node import Node from sensor_msgs.msg import PointCloud2 from sensor_msgs_py import point_cloud2 as pc2 class PcdSaver(Node): def __init__(self): super().__init__('pcd_saver') self.sub = self.create_subscription( PointCloud2, '/livox/lidar', self.callback, 10) self.count = 0 def callback(self, msg): if self.count % 10 != 0: self.count += 1 return self.count += 1 gen = pc2.read_points(msg, field_names=["x", "y", "z", "intensity"], skip_nans=True) points = np.array(list(gen), dtype=np.float32) if len(points) == 0: return self.save_pcd(f"frame_{self.count:06d}.pcd", points) def save_pcd(self, filename, points): with open(filename, 'w') as f: f.write("# .PCD v0.7 - Point Cloud Data file format\n") f.write("VERSION 0.7\n") f.write("FIELDS x y z intensity\n") f.write("SIZE 4 4 4 4\n") f.write("TYPE F F F F\n") f.write("COUNT 1 1 1 1\n") f.write(f"WIDTH {len(points)}\n") f.write("HEIGHT 1\n") f.write("VIEWPOINT 0 0 0 1 0 0 0\n") f.write(f"POINTS {len(points)}\n") f.write("DATA binary\n") f.write(points.tobytes())这个节点每10帧存一帧,存下来的文件可以用CloudCompare打开查看。需要注意PCD的binary存储格式是严格按字段顺序和大小排列的,写文件时的SIZE、TYPE、COUNT必须和实际字节完全一致,耗时点在于空格和换行都不能错,否则PCL读不出来。
3.4 点云格式转换:PCD转PLY、LAS
从算法跑出来的地图,如果要给别人看或者做后续处理,经常需要转成通用格式。CloudCompare是最省事的工具,图形界面里直接File → Open打开PCD,再Save As选目标格式就行。命令行也可以批量处理:
CloudCompare -SILENT -O frame_000010.pcd -SAVE_CLOUDS FILE frame_000010.ply如果你对规模有要求,比如地图点云几十GB,需要转成LAS格式给GIS软件用,可以考虑用PDAL库做批处理,速度快而且可以同时做抽稀、裁剪。PDAL安装好之后,一条命令:
pdal translate input.pcd output.lazPDAL还内置了filters.voxeldownsize、filters.outlier这类过滤器,可以顺便做预处理。另外,从PointCloud2直接转LAS也不是不行,但一般路径还是“先转PCD,再转LAS”更顺,因为PCD已经被PCL生态验证得很成熟了。
4. Livox点云的特殊性:它跟传统雷达不是一回事
如果你之前用过机械式雷达,拿到MID-360的点云后会有种强烈的“不适感”。这很正常,因为Livox的固态扫描方案跟传统旋转式雷达有本质区别。这一节讲清楚这些区别,后面做建图时会少走很大弯路。
4.1 非重复扫描:为什么点云像“毛玻璃”
传统机械雷达靠电机旋转带动激光头,扫描线轨迹是固定的重复圆环,所以每一帧点云都有明确的“线”结构。而MID-360的扫描是花瓣形的非重复轨迹,每一帧的采样点位置都在变化,多帧叠加之后视场内的点会越积越密。
这个特性带来的直接好处是:时间越长,同一区域的点云覆盖率越高,对小物体的探测能力越强。但对算法来说,它意味着“单帧点云”里没有明确的线束编号概念,传统基于线束特征做特征提取的算法(比如很多基于Loam的方案)需要做适配。
这也是为什么fast-lio这类直接对原始点云做匹配的算法,能在Livox雷达上有天然优势——它们不做线束假设,而是直接处理点云集合。所以网上说到“mid360使用fast-lio建图”很流行,这背后是有硬件逻辑支撑的。
4.2 环状伪影和运动畸变
Livox点云另一个特点是容易在扫描边缘出现环状伪影(比如墙角处点云“拉环”),这是因为激光打在锐利边缘时,测距值在真值和虚假回波之间抖动,产生一串无规律的点。另外,如果雷达载体本身在运动,一帧点云内部的点并不是同一时刻采样的,会带运动畸变,也就是旋转和位移导致的点位置错位。
这两个问题在建图时都会直接影响精度。环状伪影一般通过距离滤波和角度变化率滤除;运动畸变则靠SLAM算法的去畸变处理——fast-lio就是利用IMU和运动模型做逐点去畸变的,所以它在GO2这种动态载体上表现不错。
日常调试时你可以做个简单实验:让GO2原地静止,在RViz2里观察一帧单帧点云,边缘的环状伪影比较少;然后让机器狗原地转圈,再观察单帧点云,会发现边缘伪影明显增多。这就是运动畸变和扫描边缘抖动叠加的效果,理解了这个过程,你就知道为什么各算法都在“去畸变”上花那么多精力。
4.3 体素降采样不是可选项,是必选项
MID-360每秒能产生20万左右个点,这个话题听起来“也就那样”,但如果连续跑几分钟建图,地图点云动辄几千万甚至上亿个点。你不可能拿这些原始点直接去做配准、回环检测和全局优化,必须降采样。
体素降采样(Voxel Grid Downsample)的原理很简单:把三维空间划分成固定尺寸的小立方体(体素),每个体素内部只保留一个代表点,这个点通常是体素内所有点的重心。体素尺寸越大,点数越少,细节损失也越大。
在GO2上做中近距离的室内建图,我常用的体素边长是0.1米到0.2米;做室外大场景,0.3米左右就够了。不要小看这个参数,它直接影响建图速度和地图精细度。设太小,帧率掉得厉害;设太大,地图会显得“糊”。你可以在RViz2里同时打开原始点云和降采样后的点云做对比,找到感觉。
4.4 LIVOX驱动和ROS2的QoS是个大坑
聊点实操,还没真机跑过的人基本不知道:Livox点云话题在ROS2里默认的QoS策略,跟常见雷达有差异。有些用户习惯用默认的QoS参数订阅话题,结果发现一直收不到数据。
这类问题典型报错是“waiting for messages”。解决方案是在自己的节点里显式指定与发布端兼容的QoS,比如rmw_qos_profile_sensor_data或对应的rclpy.qos.QoSProfile(depth=10, reliability=QoSReliabilityPolicy.BEST_EFFORT)。因为点云和IMU这类传感器数据通常用BEST_EFFORT、容忍丢帧,而不是RELIABLE传输。
这个话题在ROS2里讨论得很频繁,你只要记住一点:在写自己的订阅节点之前,先用ros2 topic info /livox/lidar --verbose查看发布端的QoS参数,然后照着设置,基本就能避开这个坑。
5. 常见问题与排查技巧实录
下面这些是我实际调试GO2点云时遇到的问题汇总。很多问题看起来五花八门,根因其实就那么几个:坐标系、字段、时间戳、QoS。附上一个速查表,方便你对照排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| RViz2里看不到点云 | Fixed Frame与frame_id不一致 | 查看TF树,设置正确的Fixed Frame |
| 点云颜色全是纯色 | ColorTransformer设成FlatColor | 改为Intensity或RGB |
| 点云在RViz2里乱飞、漂移 | 时间戳不同步,或坐标系跳变 | 查看stamp是否连续,检查TF |
| 自己写的节点订阅不到点云 | QoS策略不兼容 | 查看发布端QoS,用BEST_EFFORT订阅 |
| 保存的PCD用PCL打不开 | PCD头信息写错,或二进制字节不对 | 核对FIELDS、SIZE、TYPE、COUNT |
| 点云边缘出现大量远距离杂点 | 雷达边缘扫描抖动 | 增加距离滤波、角度滤波 |
| 建图时地图发生扭曲 | 外参标定不准确 | 重新标定雷达-IMU外参 |
| 内存占用不断上涨 | 没有做降采样或没有做点云释放 | 引入体素降采样,及时清理历史帧 |
5.1 帧率正常但点云稀疏怎么办
这个现象一般出现在暗光环境或远距离场景,也就是雷达回波微弱导致有效点减少。先确认话题频率没有明显下降(ros2 topic hz),如果频率正常,那就是点密度确实低,可以做多帧叠加或调大雷达的功率档位。另外检查一下is_dense字段,如果为False,说明消息里有NaN点,在读取时用skip_nans=True过滤就好。
5.2 点云部分区域“撕裂”
“撕裂”很多时候是在启动阶段的雷达运动畸变导致的。车/机器狗启动加速时,单帧点云的起点和终点位置差距大,如果不做点云去畸变,拼接时就会在边缘出现撕裂。解决办法是确保SLAM算法配好了IMU,并且在启动阶段动作慢一点,等算法完成初始化就正常了。
5.3 怎么确认自己存下来的点云跟原始一致
离线数据调试时,数据一致性很重要。我会用一个最简单的自查方法:把保存的PCD文件拖进CloudCompare,看整体形状跟RViz2里是否一致;再对比点数和边界范围。如果点数对不上,大概率是保存时没过滤NaN,或者字段读错了。边界范围可以用CloudCompare的Bounding Box功能看,设置成跟RViz2里的坐标范围对比。
6. 给GO2上做点云处理的一些小建议
写到最后,把实际经验里觉得最值得跟新手分享的点列一下。
一是在动手写算法前,先把数据可视化这件事做到“顺手”。RViz2 + CloudCompare这两个工具用熟了,后面调试效率至少翻一倍。
二是养成看话题元数据的习惯。每拿到一个新的点云话题,顺手ros2 topic info+ros2 topic echo --once,很多问题就不会出现。
三是不要盲目追求点云“越多越好”。对SLAM来说,合适的密度才是关键,我用的是“地图点数不爆炸、特征不过滤干净”的标准来选降采样体素,这个标准你在实施时会有自己的体会。
四是保存数据集时把条件记清楚。哪台机器、什么雷达、什么高度、什么速度、光照条件如何,这些看似不经意的信息,之后回放数据时就是救命稻草。
我自己做下来最大的感受是:点云格式这一关,是后面所有SLAM工作的地基。地基不牢,后面每一步都可能出问题。下一篇我会接着讲GO2上的IMU数据与标定,以及它和点云之间如何配合,有兴趣的朋友可以持续关注。如果你在点云这一步遇到了别的奇怪问题,也欢迎在评论区把现象和报错贴出来,大家一起分析。