4. 激光雷达数据处理:从原始数据到可用点云
激光雷达的数据处理,说白了就是把传感器吐出来的原始数据,变成我们建图和定位真正能用的东西。我刚开始接触激光雷达时,以为拿到数据就能直接用,结果发现噪声、离群点、冗余数据一大堆,直接跑SLAM算法,地图根本建不干净。嗯,这一章我们就来聊聊怎么处理这些数据。
核心流程:原始激光数据 → 格式解析 → 点云滤波 → 点云分割与聚类 → 可用点云
4.1 激光雷达数据格式
ROS里最常见的两种激光雷达数据格式,就是 LaserScan 和 PointCloud2。你想想看,一个是一维的扫描线,一个是三维的点云,用法完全不一样。
4.1.1 LaserScan 消息
LaserScan 适合单线激光雷达,比如 RPLIDAR、SICK LMS 系列。它本质上就是一个距离数组,每个角度对应一个距离值。
# LaserScan 消息结构
std_msgs/Header header # 时间戳和坐标系
float32 angle_min # 起始角度 (rad)
float32 angle_max # 结束角度 (rad)
float32 angle_increment # 角度分辨率 (rad)
float32 time_increment # 每帧时间间隔 (s)
float32 scan_time # 扫描周期 (s)
float32 range_min # 最小测量距离 (m)
float32 range_max # 最大测量距离 (m)
float32[] ranges # 距离数组
float32[] intensities # 强度数组(可选)
我的经验:处理 LaserScan 时,记得先检查 range_min 和 range_max。我曾经遇到过雷达在近距离有盲区,没做过滤直接拿数据,结果机器人差点撞墙。
4.1.2 PointCloud2 消息
PointCloud2 是多线激光雷达和三维激光雷达的标准格式,比如 Velodyne、Ouster、Livox 等。它比 LaserScan 灵活得多,每个点可以带 x、y、z、intensity、ring、time 等字段。
# PointCloud2 消息结构
std_msgs/Header header # 时间戳和坐标系
uint32 height # 点云高度(1 表示无序点云)
uint32 width # 点云宽度(点数)
sensor_msgs/PointField[] fields # 每个点的字段定义
bool is_bigendian # 字节序
uint32 point_step # 每个点的字节数
uint32 row_step # 每行的字节数
uint8[] data # 点云数据(二进制)
bool is_dense # 是否包含无效点
| 特性 | LaserScan | PointCloud2 |
|---|---|---|
| 维度 | 2D(平面) | 3D(空间) |
| 适用雷达 | 单线激光雷达 | 多线/三维激光雷达 |
| 数据量 | 小(几百到几千点) | 大(几万到百万点) |
| 处理速度 | 快 | 慢(需要降采样) |
| ROS 转换工具 | 直接使用 | 需要 pointcloud_to_laserscan |
注意:PointCloud2 的数据是二进制格式,不能直接当数组用。ROS 提供了 pcl_ros 库来转换,千万别自己手动解析字节,容易出 bug。
4.2 点云滤波
点云滤波是预处理的核心步骤。说白了,就是把没用的点去掉,把有用的点留下来。我常用的三种滤波:直通滤波、体素滤波、统计滤波。
4.2.1 直通滤波(PassThrough Filter)
直通滤波就是按坐标轴切一刀。比如我只想保留前方 10 米内的点,或者只保留地面以上的点。这个在机器人导航中特别常用。
// C++ 示例:直通滤波
pcl::PassThrough<pcl::PointXYZ> pass;
pass.setInputCloud(cloud);
pass.setFilterFieldName("z"); // 沿 Z 轴过滤
pass.setFilterLimits(0.0, 1.5); // 保留 0~1.5 米高度
pass.filter(*cloud_filtered);
我建议:直通滤波的参数要根据实际场景调。室内地面高度一般在 0 米左右,但室外可能有坡度。我曾经在户外测试时没调 Z 轴范围,结果把地面点全滤掉了,定位直接飘了。
4.2.2 体素滤波(VoxelGrid Filter)
体素滤波是降采样的利器。它把空间分成一个个小立方体(体素),每个体素里只保留一个点(通常是重心)。这样点云数量能减少 90% 以上,但形状基本不变。
// C++ 示例:体素滤波
pcl::VoxelGrid<pcl::PointXYZ> voxel;
voxel.setInputCloud(cloud);
voxel.setLeafSize(0.05f, 0.05f, 0.05f); // 5cm 体素
voxel.filter(*cloud_filtered);
| 体素大小 | 点数减少比例 | 适用场景 |
|---|---|---|
| 0.01m (1cm) | 50%~70% | 高精度建图 |
| 0.05m (5cm) | 80%~95% | 实时导航 |
| 0.10m (10cm) | 95%~99% | 快速预览 |
避坑:体素大小不是越小越好。太小了降采样效果不明显,太大了会丢失细节。我一般从 0.05m 开始试,根据实际效果调整。
4.2.3 统计滤波(StatisticalOutlierRemoval Filter)
统计滤波用来去除离群点。它的原理很简单:计算每个点到它邻居的平均距离,如果距离太大,就认为是噪声点。这个在室内环境特别有用,因为墙壁反射、玻璃折射会产生很多飘散的点。
// C++ 示例:统计滤波
pcl::StatisticalOutlierRemoval<pcl::PointXYZ> sor;
sor.setInputCloud(cloud);
sor.setMeanK(50); // 邻居点数
sor.setStddevMulThresh(1.0); // 标准差倍数
sor.filter(*cloud_filtered);
参数说明:
setMeanK:邻居点数,越大滤波越平滑,但计算越慢setStddevMulThresh:标准差倍数,越小过滤越严格
4.3 点云分割与聚类
滤波之后,我们得到了一堆干净的点。但怎么区分哪些是地面、哪些是障碍物、哪些是动态物体?这就需要分割和聚类了。
4.3.1 地面分割
地面分割最常用的方法是 RANSAC 平面拟合。我习惯先做地面分割,把地面点去掉,剩下的点就是障碍物了。
// C++ 示例:RANSAC 平面分割
pcl::SACSegmentation<pcl::PointXYZ> seg;
pcl::ModelCoefficients::Ptr coefficients(new pcl::ModelCoefficients);
pcl::PointIndices::Ptr inliers(new pcl::PointIndices);
seg.setOptimizeCoefficients(true);
seg.setModelType(pcl::SACMODEL_PLANE);
seg.setMethodType(pcl::SAC_RANSAC);
seg.setDistanceThreshold(0.1); // 距离阈值
seg.setInputCloud(cloud);
seg.segment(*inliers, *coefficients);
我的经验:RANSAC 的 setDistanceThreshold 很关键。室内地面平坦,设 0.05~0.1m 就行。室外有坡度,我一般设到 0.2~0.3m,不然地面点会漏掉。
4.3.2 欧几里得聚类
地面去掉之后,剩下的点云需要聚类成一个个独立的物体。欧几里得聚类是最常用的方法,它根据点与点之间的距离来分组。
// C++ 示例:欧几里得聚类
pcl::EuclideanClusterExtraction<pcl::PointXYZ> ec;
std::vector<pcl::PointIndices> cluster_indices;
ec.setClusterTolerance(0.5); // 聚类半径
ec.setMinClusterSize(10); // 最小点数
ec.setMaxClusterSize(25000); // 最大点数
ec.setInputCloud(cloud);
ec.extract(cluster_indices);
| 参数 | 说明 | 典型值 |
|---|---|---|
| ClusterTolerance | 两点属于同一簇的最大距离 | 0.3~0.8m |
| MinClusterSize | 一个簇最少包含的点数 | 5~20 |
| MaxClusterSize | 一个簇最多包含的点数 | 10000~50000 |
注意:聚类半径设得太小,一个物体会被分成好几块;设得太大,多个物体会粘在一起。我一般先设 0.5m,然后根据实际效果微调。
4.3.3 区域生长分割
除了欧几里得聚类,区域生长分割也很有用。它不光看距离,还看法向量和曲率。适合分割墙面、桌面这种有明确平面的物体。
// C++ 示例:区域生长分割
pcl::RegionGrowing<pcl::PointXYZ, pcl::Normal> rg;
rg.setMinClusterSize(100);
rg.setMaxClusterSize(100000);
rg.setNumberOfNeighbours(30);
rg.setSmoothnessThreshold(3.0 / 180.0 * M_PI); // 法向量角度阈值
rg.setCurvatureThreshold(1.0); // 曲率阈值
rg.setInputCloud(cloud);
rg.setNormals(normals);
std::vector<pcl::PointIndices> clusters;
rg.extract(clusters);
总结一下:
- 直通滤波:快速切掉不需要的区域
- 体素滤波:降采样,减少计算量
- 统计滤波:去除离群噪声点
- 地面分割:分离地面和障碍物
- 欧几里得聚类:将障碍物分成独立物体
- 区域生长:分割平面结构
嗯,这一章的内容就到这里。激光雷达数据处理是SLAM的基础,数据质量直接决定了建图和定位的效果。我建议你在实际项目中多试试不同的参数组合,找到最适合你场景的那一套。