3. 点云预处理:滤波去噪与地面点去除

点云数据拿到手,第一件事不是急着做检测。你想想看,雷达直接吐出来的原始点云,里面什么妖魔鬼怪都有——噪声点、离群点、地面反射杂波。我做了这么多年雷达感知,可以负责任地告诉你:预处理做不好,后面的算法再牛也白搭。

这一节,咱们就聊聊点云预处理的两个核心任务:滤波去噪地面点去除。说白了,就是把脏数据洗干净,把没用的地面点扔掉,让后续的目标检测算法能专注在真正的目标上。

3.1 滤波去噪:三种常用方法

滤波去噪,我习惯把它分成三类:体素滤波、统计滤波、半径滤波。每种方法都有自己的脾气,用对了地方效果立竿见影。

3.1.1 体素滤波(Voxel Filter)

体素滤波的核心思想很简单:把空间切成一个个小立方体(体素),每个体素内只保留一个点。这个点通常是体素内所有点的重心,或者直接取中心点。

为什么要这么做?因为毫米波雷达的点云密度其实不低,尤其是近处。几十万个点直接扔给后续算法,计算量太大。体素滤波相当于降采样,同时还能平滑掉一些微小的噪声。

关键参数:体素大小

体素大小决定了滤波的强度。我一般这样设:

  • 近场(0-30m):0.1m - 0.2m
  • 中远场(30-80m):0.3m - 0.5m
  • 远场(80m+):0.5m - 1.0m

体素太小,降采样效果不明显;体素太大,会丢失细节特征。

// PCL 实现体素滤波
pcl::VoxelGrid<pcl::PointXYZ> voxel_filter;
voxel_filter.setInputCloud(cloud);
voxel_filter.setLeafSize(0.2f, 0.2f, 0.2f);  // 体素边长 0.2m
voxel_filter.filter(*cloud_filtered);

我的经验:体素滤波有个坑——如果体素内点太少,重心计算会不稳定。我遇到过极端情况,一个体素里就1个点,重心就是它自己,滤波等于没做。所以建议先做一次统计滤波,把孤立点干掉,再做体素滤波。

3.1.2 统计滤波(Statistical Outlier Removal)

统计滤波,说白了就是「看邻居」。每个点周围找K个最近邻,计算这些邻居到该点的平均距离。如果这个平均距离明显大于全局平均值,那这个点大概率是离群噪声。

为什么会这样?因为真实目标的点云是连续的,每个点周围都有很多邻居。而噪声点往往是孤立的,周围没什么点,或者距离很远。

参数 含义 推荐值
K(邻居数) 计算距离时考虑的最近邻个数 20 - 50
std_dev_mult(标准差倍数) 判定阈值:平均距离 > 均值 + std_dev_mult * 标准差 1.0 - 2.0
// PCL 实现统计滤波
pcl::StatisticalOutlierRemoval<pcl::PointXYZ> sor;
sor.setInputCloud(cloud);
sor.setMeanK(30);           // 取30个最近邻
sor.setStddevMulThresh(1.5); // 标准差倍数1.5
sor.filter(*cloud_filtered);

注意:统计滤波对稀疏区域不友好。比如远场目标,本来点就少,邻居距离自然大。我曾经在高速场景吃过亏,把80米外的卡车点云全滤掉了。后来我学乖了:远场用半径滤波,近场用统计滤波。

3.1.3 半径滤波(Radius Outlier Removal)

半径滤波的逻辑更直接:在指定半径的球体内,如果点的数量少于某个阈值,就认为它是离群点。

你想想看,一个真实目标上的点,周围半径0.3米内至少得有3-5个点吧?如果半径内就它一个光杆司令,那八成是噪声。

// PCL 实现半径滤波
pcl::RadiusOutlierRemoval<pcl::PointXYZ> ror;
ror.setInputCloud(cloud);
ror.setRadiusSearch(0.3);   // 搜索半径 0.3m
ror.setMinNeighborsInRadius(3); // 至少3个邻居
ror.filter(*cloud_filtered);

我的习惯:半径滤波和统计滤波我经常搭配使用。先半径滤波干掉明显的孤立点,再统计滤波处理那些「半孤立」的点。顺序别搞反,否则统计滤波的计算量会大很多。

3.2 地面点去除:RANSAC与平面拟合

地面点去除,是点云预处理里最关键的步骤之一。为什么?因为地面点占了点云总量的30%-50%,而且它们对目标检测没有任何帮助——你总不能把路面当成障碍物吧?

地面点去除的核心假设:地面是一个平面。虽然实际路面有起伏,但在局部范围内,这个假设基本成立。

3.2.1 RANSAC平面拟合

RANSAC(Random Sample Consensus)是一种鲁棒的模型拟合方法。它的思路很暴力:随机选3个点确定一个平面,然后数一数有多少点落在这个平面上。重复这个过程,找到支持点最多的那个平面。

为什么用RANSAC?因为最小二乘法对噪声太敏感。你想想看,如果点云里有个路沿或者减速带,最小二乘法会被带偏。RANSAC天生抗噪声,因为它只关心「大多数点」的共识。

// PCL 实现 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.setMaxIterations(100);      // 最大迭代次数
seg.setDistanceThreshold(0.15); // 点到平面的距离阈值
seg.setInputCloud(cloud);
seg.segment(*inliers, *coefficients);

关键参数:距离阈值

这个参数决定了「多远的点算地面」。我一般这样设:

  • 平整路面(高速、城市道路):0.1m - 0.15m
  • 起伏路面(乡村、山路):0.2m - 0.3m
  • 颠簸路面(工地、越野):0.3m - 0.5m

阈值太小,地面点去不干净;阈值太大,会把低矮障碍物(路沿、减速带)也当成地面。

3.2.2 平面拟合的改进方法

RANSAC虽然好用,但有个问题:它假设整个场景只有一个平面。实际场景中,地面可能有坡度,或者有多个不同高度的平面(比如上下坡)。

我常用的改进方法有两种:

  1. 分区域拟合:把点云按距离分成多个区域,每个区域独立做RANSAC。近处地面平坦,远处可能有坡度,分开处理更合理。
  2. 渐进式拟合:先拟合一个初始平面,去掉地面点,对剩余点再拟合一次。这样能处理多层地面(比如有台阶的场景)。
// 分区域拟合示例(伪代码)
for (int i = 0; i < num_regions; i++) {
    // 提取第i个区域内的点
    pcl::PointCloud<pcl::PointXYZ>::Ptr region_cloud = extractRegion(cloud, i);
    
    // 对该区域做RANSAC
    pcl::SACSegmentation<pcl::PointXYZ> seg;
    seg.setInputCloud(region_cloud);
    seg.setDistanceThreshold(0.15);
    seg.segment(*inliers, *coefficients);
    
    // 提取地面点和非地面点
    extractGroundAndNonGround(region_cloud, inliers, ground, non_ground);
}

我曾经踩过的坑:有一次在隧道里做测试,RANSAC把隧道墙壁当成了地面。为什么?因为隧道墙壁也是一个大平面,而且点云数量比地面还多。后来我加了约束条件:地面的法向量必须朝上(与Z轴夹角小于15度),才把这个问题解决。

3.3 预处理流程总结

说了这么多,我总结一下我自己在项目中常用的预处理流程:

  1. 体素滤波:降采样,减少计算量。体素大小0.2m。
  2. 半径滤波:干掉明显的孤立噪声点。半径0.3m,最少3个邻居。
  3. 统计滤波:处理半孤立点。K=30,标准差倍数1.5。
  4. RANSAC地面去除:分区域拟合,距离阈值0.15m,法向量约束。

这个流程我用了好几年,在高速、城市、乡村各种场景都验证过。当然,具体参数要根据你的雷达型号和应用场景微调。嗯,这就是经验的价值所在。

最后一个小建议:预处理不是越狠越好。滤波太强,会把小目标(行人、自行车)的点云也滤掉。我一般会在预处理前后各保存一份点云,对比看看有没有误删。毕竟,数据丢了就再也找不回来了。