4. 激光雷达数据处理:从原始数据到可用点云

激光雷达的数据处理,说白了就是把传感器吐出来的原始数据,变成我们建图和定位真正能用的东西。我刚开始接触激光雷达时,以为拿到数据就能直接用,结果发现噪声、离群点、冗余数据一大堆,直接跑SLAM算法,地图根本建不干净。嗯,这一章我们就来聊聊怎么处理这些数据。

核心流程:原始激光数据 → 格式解析 → 点云滤波 → 点云分割与聚类 → 可用点云

激光雷达数据处理流程 原始激光数据 格式解析 LaserScan/PointCloud2 点云滤波 直通/体素/统计 分割与聚类 地面/障碍物/聚类 可用点云 为什么要处理? • 原始数据含噪声 • 点云密度不均匀 • 存在离群点和冗余

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的基础,数据质量直接决定了建图和定位的效果。我建议你在实际项目中多试试不同的参数组合,找到最适合你场景的那一套。