点云数据基础:从结构到坐标,再到属性
各位同学,今天我们来聊聊点云数据。说实话,点云是激光雷达的「语言」。你如果不懂它的数据结构,后面做滤波、配准、目标检测,基本就是瞎忙活。我最早接触点云时,也被 ROS 的 PointCloud2 消息搞晕过——字段多,还带个变长数组。但摸清楚之后,你会发现它设计得其实挺巧妙。
点云数据结构:PointCloud2 消息
在 ROS 生态里,点云的标准载体就是 sensor_msgs/PointCloud2。它不是简单的 xyz 数组,而是一个带元数据的结构化容器。
核心字段一览
| 字段 | 类型 | 说明 |
|---|---|---|
header |
std_msgs/Header | 时间戳、坐标系 frame_id |
height |
uint32 | 有序点云的行数;无序时为 1 |
width |
uint32 | 有序点云的列数;无序时为总点数 |
fields |
数组 | 每个通道的名称、偏移量、数据类型 |
point_step |
uint32 | 单个点占用的字节数 |
row_step |
uint32 | 一行占用的字节数(height×point_step) |
data |
uint8[] | 原始二进制数据 |
is_dense |
bool | 是否包含无效点(NaN/Inf) |
你可能会问:为什么不用结构体数组,非要用这种「扁平化」设计?
我个人理解,原因有两个。第一,序列化效率高——整块内存直接拷贝,不用逐个字段打包。第二,字段可扩展——你可以在 fields 里声明任意通道,比如 intensity、ring、time,甚至自定义的标签。我在做多线激光雷达驱动时,就靠这个特性把原始数据直接映射到 PointCloud2,省掉了中间转换的开销。
小技巧:用 pcl::fromROSMsg 和 pcl::toROSMsg 在 PCL 点云和 ROS 消息之间互转。但注意,如果 fields 里包含非标准通道,转换时会丢失数据。我习惯先检查 fields 列表,再决定用哪个转换函数。
点云坐标系:别让坐标「漂」了
点云的坐标值本身没有意义,有意义的是它相对于哪个坐标系。ROS 里用 header.frame_id 来标识。常见的坐标系包括:
- sensor_frame:激光雷达自身的坐标系,原点在雷达中心,Z 轴通常朝前或朝上(取决于安装)。
- base_link:机器人底盘坐标系,一般在地面投影中心。
- odom:里程计坐标系,用于局部定位。
- map:全局地图坐标系,用于建图和导航。
我见过不少新手踩的坑:直接把 sensor_frame 下的点云当成 map 坐标用。结果建出来的地图是歪的,定位也飘。说白了,坐标系变换是点云处理的基石。你至少得会用 tf2 把点云从 sensor_frame 变换到 base_link 或 map。
避坑指南:我曾经在调试多雷达融合时,发现两个雷达的点云始终对不齐。查了两天,最后发现是其中一个雷达的 frame_id 写错了——发布时写的是 "laser_left",但 tf 树里对应的 frame 叫 "laser_left_frame"。名字差一个后缀,整个变换链就断了。所以,frame_id 的命名必须和 tf 树严格一致,大小写、下划线都不能错。
点云的基本属性:xyz、强度、时间戳
每个点云点,最核心的属性就三个:位置(xyz)、强度(intensity)、时间戳(time)。但它们的含义和用法,值得细说。
xyz 坐标
这是点云的基础。每个点代表激光束打到物体表面后的反射位置。单位是米。注意,xyz 是三维空间中的笛卡尔坐标,不是极坐标。雷达原始数据通常是距离和角度,驱动层会帮你转成 xyz。如果你自己写驱动,记得用 r * cos(vertical) * sin(horizontal) 这类公式,别搞反了。
强度(intensity)
强度值反映目标表面对激光的反射率。值越高,说明反射越强。不同雷达的强度范围不一样:有的 0~255,有的 0~65535。我习惯在预处理时做归一化,方便后续算法统一处理。
强度有什么用?举个例子:车道线通常比沥青路面反射更强,所以强度图可以用来做地面分割和道路标识提取。我在做园区无人车时,就用强度值区分了斑马线和普通路面,效果比纯几何方法好得多。
时间戳(time)
每个点的时间戳,不是整帧的时间。对于旋转式激光雷达,一帧内不同点的采集时间可能相差 100ms。如果你做运动畸变校正,就必须用每个点的时间戳,而不是帧头的时间。
时间戳的两种常见形式
| 形式 | 说明 | 适用场景 |
|---|---|---|
| 相对时间 | 相对于帧起始的偏移量,单位微妙 | 运动畸变校正、插值 |
| 时间 | ROS 时间戳(秒+纳秒) | 多传感器同步、回放 |
嗯,这里要注意:不是所有雷达都提供逐点时间戳。Velodyne 的 VLP-16 有,但一些低端雷达只有帧时间。如果你需要做高精度运动补偿,选雷达时就得把这个参数考虑进去。
知识体系总览
下面这张图,把点云数据基础的核心脉络串起来了。你可以把它当作本章的「地图」。
代码示例:解析 PointCloud2 中的点
光说不练假把式。下面这段代码,演示如何从 PointCloud2 消息中提取每个点的 xyz 和 intensity。我用的是 C++,但思路同样适用于 Python。
// 假设 cloud 是 sensor_msgs::PointCloud2 的引用
sensor_msgs::PointCloud2ConstIterator<float> iter_x(cloud, "x");
sensor_msgs::PointCloud2ConstIterator<float> iter_y(cloud, "y");
sensor_msgs::PointCloud2ConstIterator<float> iter_z(cloud, "z");
sensor_msgs::PointCloud2ConstIterator<uint8_t> iter_intensity(cloud, "intensity");
for (size_t i = 0; i < cloud.width * cloud.height; ++i, ++iter_x, ++iter_y, ++iter_z, ++iter_intensity)
{
float x = *iter_x;
float y = *iter_y;
float z = *iter_z;
uint8_t intensity = *iter_intensity;
// 过滤掉无效点(NaN 或 Inf)
if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z))
continue;
// 你的处理逻辑...
// 比如:根据强度阈值筛选点
if (intensity > 100) {
// 高反射点
}
}
个人习惯:我一般不用 pcl::PointXYZI 直接接收,因为一旦 fields 顺序不对,数据就乱了。用迭代器按字段名读取,虽然代码多几行,但更安全。尤其是当你需要同时处理多个雷达的数据时,这种显式读取能避免很多隐蔽的 bug。