做自动驾驶和机器人感知的朋友应该都有过这种体验:点云聚类结果乱得没法看,行人在连续帧里被劈成两半,车辆轮廓黏黏糊糊,栅栏和墙壁糊成一片。我之前调试一台搭载16线激光雷达的小车时,连续聚类算法输出了一堆“四不像”目标,排查了整整一个下午,最后发现问题根本不在聚类参数,而是最前面的预处理环节——没有把地面点分割干净。那台车用的方法就是RANSAC地面点分割,原因很简单:地面点占了整帧点云的大头,不把它们剔除,下游的聚类、检测、追踪全都会被污染。
这篇文章我就把RANSAC地面点分割这件事拆开揉碎地讲:从它背后的数学原理和公式推导,到PCL库里的C++实现,再到我实际项目里用来让16线雷达也能实时跑的优化手段,最后会分享几类我在真实点云数据上踩过的坑。适合手里有雷达数据但分割效果一直不理想的朋友,也适合刚接触点云、想先把原理弄明白再写代码的初学者。不管你是做机器人、自动驾驶,还是用深度相机做室内感知,这套思路基本是通用的。
1. 为什么“先分地面”是所有下游算法的隐形前提
1.1 一个连续聚类翻车案例
我当时调试的场景是园区低速物流小车,激光雷达装在车顶,距离地面大约1.6米。刚跑起来的时候,聚类结果里经常出现一个巨大无比的目标,把路面、花坛和行人全部包在一起。我一开始以为是聚类半径阈值设得太大,反复调ClusterTolerance,从0.5米一路调到0.1米,结果目标倒是变小了,但行人还是被切成好几段,车辆目标也残缺不全。
后来把每一帧点云可视化出来我才反应过来:地面点就像一张巨大的背景布,把所有障碍物的底部全部连在一起。聚类算法按欧氏距离判断归属,地面点彼此密集相连,又跟行人脚底、车轮、墙根贴在一起,自然会把不相干的物体合并成一个簇。想靠调聚类半径来解决这个问题是死路一条——半径调大了,跨目标粘连更严重;半径调小了,一个目标又被拆散成好几块。
1.2 地面点对下游模块的实际干扰
如果你只是做离线点云展示,不分割地面也无所谓,反正人眼能自动忽略地面。但一旦进入自动化处理流程,地面点几乎每个模块都嫌弃:
- 连续聚类:地面点把独立目标串联成连通域,这是最常见的翻车原因。
- 目标检测与分类:地面点进入特征计算后,目标的尺寸、形状描述子全部失真,一个站立的人会被算成“连着地面的巨大物体”。
- Occupancy Grid Map(占据栅格地图):地面点反复落在栅格中,会导致地面栅格被标记为占用,小车建图时地图会变得一团糟。
- 配准与里程计:ICP这类精配准算法对离群点敏感,地面点数量大、分布广,会把配准结果往地面方向带偏。
所以RANSAC地面点分割虽然只是流水线里的一个步骤,但它决定了整条感知链路的下限。地面分不干净,后面对聚类参数、检测阈值怎么调,都是在垃圾进垃圾出的前提下做无用功。
2. 随机采样+平面拟合:RANSAC到底在算什么
2.1 从“三个点确定一个平面”说起
RANSAC的全称是Random Sample Consensus,中文一般翻译成“随机采样一致性”。它解决的是一类很常见的问题:一堆数据点里既有符合模型的“内点”,又有乱入的“外点”,怎么在不知道外点是谁的情况下,把模型参数估计出来。
放到地面分割这个场景里,模型就是三维空间中的一个平面,用方程式表达是:
ax + by + cz + d = 0平面方程里有4个未知数,但因为a、b、c可以同时缩放,实际需要3个不共线的点就能唯一确定一个平面。RANSAC的思路就是重复干这件事:
- 从点云里随机挑3个点,算出平面方程系数a、b、c、d。
- 遍历点云中的所有点,计算每个点到这个平面的距离。
- 距离小于阈值T的点,判定为这个平面的“内点”,记一下内点数量。
- 重复以上步骤N次,选出内点数量最多的那个平面,用它作为最终的地面模型。
这里最关键的有两点。第一,选点必须随机,这样即使点云里有一半是噪声点,也总有机会抽到三个都是真正地面的点。第二,内点判定靠的是距离阈值,这个阈值直接决定了“多近才算地面”,是后面调参的重头戏。
2.2 迭代次数公式:k = log(1-p) / log(1-w^n) 的真实含义
很多人写代码时直接把setMaxIterations(100)一写,完全不思考100次够不够。实际上迭代次数不是拍脑袋定的,它跟内点比例强相关。
假设点云中真正的地面点占比是w,我们随机抽3个点,三次全抽到地面点的概率是w的三次方。那么反过来,抽一次“没抽好”(至少有一个点不是地面点)的概率是1-w³。做k次独立重复采样,全都没抽好的概率就是(1-w³)^k。我们希望这个失败概率小于1-p,p是置信度,一般取0.99或0.95,于是:
(1 - w^3)^k < 1 - p两边取对数就得到:
k > log(1-p) / log(1-w^3)举一个具体例子。假设一帧点云里地面点占比一半,也就是w等于0.5。抽3个点全部落到地面上的概率是0.5³等于0.125,看起来很惨对不对?所以需要的迭代次数大约是:
k = log(0.01) / log(0.875) ≈ 34.5也就是说,迭代35次,就有99%的把握至少抽到一组全地面点的样本。如果地面点只占20%,同样置信度下需要:
k = log(0.01) / log(1 - 0.2^3) ≈ 574这个数字就很吓人了,对应到C++实现里就是每帧多跑几百次平面拟合和全点遍历。所以RANSAC的迭代次数不是一个可以随便写的参数,它直接跟场景中的地面占比挂钩。
2.3 为什么RANSAC比最小二乘更适合地面分割
也有朋友问过我:直接用最小二乘拟合一个平面不行吗?原理上当然可以,但实际数据里最小二乘很容易翻车。最小二乘的目标是让所有点到平面的距离平方和最小,这意味着哪怕只有1%的噪声点飘在特别远的地方,它们产生的平方误差就能把整个平面拉歪。
RANSAC的思路刚好相反,它走的是“少数服从多数”的投票路线,先找内点再拟合平面。只要内点占比大于外点,而且阈值设置合理,那几个乱飞的离群点根本影响不了结果。实际点云里,行人身上的点、车辆侧面点、飞鸟点,都不符合“地面平面”模型,RANSAC天然就能把这些外点排除在外。
不过RANSAC不是银弹。当场景里地面占比极低、或者地面本身严重非平面时,RANSAC也会找不到一个靠谱的平面。这也是后面要讲优化的原因。
3. PCL关键参数的物理含义与调参顺序
3.1 距离阈值:决定“多近算地面”
PCL里用setDistanceThreshold设置内点距离阈值,单位是米。它表示一个点距离拟合出的平面多近,才会被判定为地面点。这个参数的物理含义比名字看起来重要得多,因为它直接影响了分割的“松紧”:
- 阈值调小,地面分割更严格,只有贴着扫描平面的点才算地面,但真实地面稍微有点起伏(比如车辙、石子路),就会产生大量漏检。
- 阈值调大,能把起伏路面和缓坡都包进地面,但路沿、台阶、低矮障碍物也会被误吞,导致下游丢失有效目标。
以Velodyne VLP-16为例,在平整柏油路上测过的实际点位噪声大概是±3厘米。但地面通常不是纯平面,草地从根部到叶尖高度差很容易到10厘米以上,碎石路起伏更大。我在园区道路上常用的初始值是0.2米,这个值来自经验:既容忍了地面本身的粗糙度,又不会把10厘米高的马路牙子整个吃进去。
注意:如果雷达安装高度变化,或者地面有积雪、积水,阈值需要重新评估。厚积雪的松软层会让雷达点穿透一段距离,地面点分布变得很“发散”,这时候0.2米很可能不够。
3.2 迭代次数、优化系数和模型类型
PCL里SACSegmentation类的核心参数有这么几个:
| 参数 | 作用 | 我的常用初始值 | 备注 |
|---|---|---|---|
setModelType | 设置模型类型 | pcl::SACMODEL_PLANE | 地面局部近似平面 |
setMethodType | 设置算法方法 | pcl::SAC_RANSAC | 也可用SAC_MSAC等变体 |
setDistanceThreshold | 内点距离阈值 | 0.1~0.3(米) | 按传感器和路面情况调 |
setMaxIterations | RANSAC最大迭代次数 | 100 | 根据内点比例估算 |
setOptimizeCoefficients | 是否用全部内点重新拟合平面 | true | 推荐开启 |
setOptimizeCoefficients(true)值得多说一句。它的意思是,RANSAC找到最优内点集合之后,再用这个集合里所有点做一次最小二乘拟合,得到更平滑、更准确的平面系数。从原理上看,RANSAC的输出其实是个“投票选出来的平面”,拿选票的产品质量还能再精加工一次。这一步计算量不大,但能让平面参数稳定很多,我建议一直开着。
setMaxIterations这里,如果你用我后面会讲的降采样预处理,点云规模变小,内点比例通常也会变高,100次在多数场景下足够。但如果你的点云里地面占比不到20%,记得按前面公式算一下,或者直接提到500以上。
3.3 一套可以抄的调参顺序
很多人拿到代码就随手把参数一写,效果不好就开始乱调。我的习惯是先固定其他参数,单独扫一个关键参数,用可视化确认效果,再动下一个。
第一步,先设一个保守的距离阈值0.1米,迭代次数100,优化开关打开。跑一帧数据,用pcl_viewer看分割出来的地面点颜色分布。如果地面点断断续续,把阈值加大到0.2、0.3,直到地面基本连成片。第二步,看误分割点,如果路沿和台阶被大量吞进地面,说明阈值太大,回头往回调。第三步,确认地面占比,估算迭代次数,把setMaxIterations设成一个带安全余量的数值,不要亏待它。最后,如果地面受雷达安装角度影响不水平,再考虑换SACMODEL_NORMAL_PLANE加法线约束。
这套顺序我用了很多年,比“感觉不对就乱拧参数”高效得多。核心思想是:每一步只动一个自由度,效果的好坏才能准确归因。
4. 一段能直接编译的C++地面分割实现
4.1 基础版实现
PCL(Point Cloud Library)的C++封装已经很成熟了,用起来基本是傻瓜式的。下面是一段我经常作为模板的完整实现,读取PCD文件,做直通滤波后执行RANSAC平面分割,并提取地面点。
#include <pcl/point_types.h> #include <pcl/point_cloud.h> #include <pcl/io/pcd_io.h> #include <pcl/filters/passthrough.h> #include <pcl/filters/voxel_grid.h> #include <pcl/segmentation/sac_segmentation.h> #include <pcl/filters/extract_indices.h> int main(int argc, char** argv) { if (argc < 2) { PCL_ERROR("Usage: %s input.pcd\n", argv[0]); return -1; } pcl::PointCloud<pcl::PointXYZ>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZ>); if (pcl::io::loadPCDFile<pcl::PointXYZ>(argv[1], *cloud) == -1) { PCL_ERROR("Cannot load PCD file\n"); return -1; } std::cout << "Loaded points: " << cloud->size() << std::endl; // 1. 直通滤波:只保留地面附近高度范围的点,剔除过高的干扰 pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_filtered(new pcl::PointCloud<pcl::PointXYZ>); pcl::PassThrough<pcl::PointXYZ> pass; pass.setInputCloud(cloud); pass.setFilterFieldName("z"); pass.setFilterLimits(-0.5, 3.0); // 按雷达安装高度调整 pass.filter(*cloud_filtered); // 2. 体素降采样:降低点密度,加速后续RANSAC pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_downsampled(new pcl::PointCloud<pcl::PointXYZ>); pcl::VoxelGrid<pcl::PointXYZ> voxel; voxel.setInputCloud(cloud_filtered); voxel.setLeafSize(0.1f, 0.1f, 0.1f); voxel.filter(*cloud_downsampled); // 3. RANSAC平面分割 pcl::SACSegmentation<pcl::PointXYZ> seg; pcl::PointIndices::Ptr inliers(new pcl::PointIndices); pcl::ModelCoefficients::Ptr coefficients(new pcl::ModelCoefficients); seg.setOptimizeCoefficients(true); seg.setModelType(pcl::SACMODEL_PLANE); seg.setMethodType(pcl::SAC_RANSAC); seg.setDistanceThreshold(0.2); seg.setMaxIterations(100); seg.setInputCloud(cloud_downsampled); seg.segment(*inliers, *coefficients); if (inliers->indices.size() == 0) { PCL_ERROR("Could not estimate a planar model\n"); return -1; } // 4. 提取地面点 pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_ground(new pcl::PointCloud<pcl::PointXYZ>); pcl::ExtractIndices<pcl::PointXYZ> extract; extract.setInputCloud(cloud_downsampled); extract.setIndices(inliers); extract.setNegative(false); extract.filter(*cloud_ground); std::cout << "Ground points: " << cloud_ground->size() << " / " << cloud_downsampled->size() << std::endl; std::cout << "Coefficients: " << coefficients->values[0] << ", " << coefficients->values[1] << ", " << coefficients->values[2] << ", " << coefficients->values[3] << std::endl; return 0; }对应的CMakeLists.txt长这样,PCL版本建议1.10以上:
cmake_minimum_required(VERSION 3.10) project(ground_seg) find_package(PCL 1.10 REQUIRED COMPONENTS common io filters segmentation) add_executable(ground_seg main.cpp) target_link_libraries(ground_seg ${PCL_LIBRARIES}) add_definitions(${PCL_DEFINITIONS})4.2 关于代码里几个容易被忽视的细节
第一,setFilterLimits的上下限要根据雷达安装高度定。我的雷达离地1.6米,所以保留-0.5到3.0米的点。如果雷达装得更高,比如无人车头顶3米,这组数值就要整体往上挪。
第二,seg.segment()执行完之后,coefficients->values依次保存的是a、b、c、d。有时候你想判断地面是否平整,直接看一眼系数就行——如果c接近-1而a、b接近0,说明这个平面几乎是水平的。
第三,ExtractIndices有个setNegative参数。设false提取地面点,设true则提取非地面点。实际项目里通常两个都要:地面点拿去做路面建模,非地面点送给聚类模块。
4.3 没有PCD文件时怎么快速验证
如果你想先跑通流程但没有PCD文件,可以用PCL的pcl::io::loadPCDFile配合网上公开数据集,比如KITTI转出来的PCD,或者自己用pcl::PointCloud<pcl::PointXYZ>手动生成一片模拟平面加一些噪声点:
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZ>); for (float x = -10.0f; x < 10.0f; x += 0.1f) { for (float y = -10.0f; y < 10.0f; y += 0.1f) { pcl::PointXYZ p; p.x = x; p.y = y; p.z = 0.05 * sin(x * 0.5) + 0.02 * (rand() % 100) / 100.0f; // 模拟起伏+噪声 cloud->push_back(p); } }这样就能在自己机器上快速调通整个编译运行链路,再替换真实数据。
5. 让16线雷达也能实时跑的性能优化三板斧
5.1 体素降采样:用一点精度换数倍速度
16线雷达一帧点云大约3万到12万个点,直接拿全量点云跑RANSAC,每次迭代都要遍历所有点算距离,CPU时间很肉疼。体素降采样(VoxelGrid)把这些点装进一个个小立方体,每个立方体只保留一个重心点,点云规模可以减小到原来的五分之一甚至十分之一。
我常用的体素尺寸是0.1米到0.3米。0.1米基本能保留地面起伏和障碍物轮廓,0.3米以上虽然更快,但小目标(比如路沿、锥桶)的几何特征会明显损失。我的建议是:RANSAC分割这一步用降采样后的点云,分割完拿到地面内点和平面系数之后,再回到原始分辨率点云上提取非地面点,这样精度和性能可以兼顾。
这里有一个很容易掉进去的坑:如果你直接用降采样后的点云去做下游聚类,目标边界会变得粗糙,体积计算也不准。所以降采样的角色是“加速器”,不是“替代品”。
5.2 法线一致性检查:对付雷达安装倾斜和地面不水平
有些场景里雷达安装本身有个俯仰角,地面在点云里看起来是一个斜平面,普通SACMODEL_PLANE也能拟合出来,但平面系数会偏向雷达的安装姿态。更麻烦的是,当车在坡道上时,拟合出的“地面平面”并不垂直于重力方向,这时候直接拿来分割,一部分远处的真正地面点会被漏掉。
PCL里有个增强版模型叫SACMODEL_NORMAL_PLANE,它要求平面法线和用户指定的参考方向夹角在某个范围内。用法是先估计每个点的法线,再把法线作为附加信息传给分割器。这样分割出来的平面会优先选择那些法线方向与重力方向一致的点。
代价是法线估计本身要花时间。我的建议是:只有当地面有明显倾斜、或者雷达安装角度导致平面系数异常时才启用,一般的水平地面场景用基本平面模型就够了。
5.3 阈值动态化:远距离点云的分割精度修正
激光雷达点云有一个天然特性:距离越远,点间距越大,单点的测量噪声也越大。同一个距离阈值在近处可能偏严,在远处可能偏松。具体表现是:近处地面分割干净利落,远处地面点却经常断裂或者被误判为障碍物。
简单的解决办法是让阈值随距离变化。设雷达位置为原点,每个点的水平距离r = sqrt(x² + y²),那么阈值可以写成一个分段函数:
float adaptive_threshold(float r) { if (r < 20.0f) return 0.2f; if (r < 40.0f) return 0.3f; return 0.4f; }这个思路听起来简单,但实现的时候记得不要对每个点单独跑一次RANSAC,那样效率太低。常见做法是:先用一个大阈值(比如0.5米)跑一次RANSAC把大体地面找出来,然后对每个内点按距离重新判定一次,距离太远且偏差较大的点从地面集合里剔除。
6. 那些数据集里看不到的地面分割翻车现场
6.1 车身点污染:小偷小摸的“伪地面”
我第一次在真车上调试时,分割出的“地面”里有很大一片竟然是车头引擎盖。原因很好理解:引擎盖也是一个又平又大的平面,高度又紧贴雷达视野的下部,RANSAC只认“内点最多”,才不管你是地球表面还是车体表面。
解决思路有两层。第一层是在直通滤波阶段把高度限制在路面可能存在的范围,比如雷达离地1.6米,那地面点的高度不可能是2米。第二层更稳健,是把已知的车身区域用掩膜直接屏蔽,比如把雷达正前方0到2米范围内高于轮胎高度的点全部剔除。很多公开数据集不会遇到这个问题,但自己改装的车上几乎必踩。
6.2 坡道:单平面模型的无力时刻
RANSAC平面模型的前提是“地面是平的”。遇到地下车库的上坡、丘陵地形的连续起伏,这个前提就不成立了。我实际测过一次地库坡道,RANSAC拟合出了一个斜平面,把这个斜平面当“地面”的结果是:上坡前的地面向远处延伸时,真实路面的后半段被判定成了障碍物。
解决坡道问题的常用思路是分而治之。把点云按水平距离分段,每段各自跑RANSAC,得到一串“微平面”拼成地面。另一种思路是先跑一次RANSAC拿到主平面,再对剩余点做区域生长(Region Growing)找次平面,从而把连续坡面拆成多个平面段分别拟合。这两种方法都比直接升级模型参数靠谱得多。
6.3 雨雾噪声和稀疏点云:RANSAC的隐藏对手
雨雾天气下,雷达点云里会出现大量悬空杂点。RANSAC对离群点本身是鲁棒的,但这些杂点会把有效内点比例拉低。按前面的公式,内点比例从0.7掉到0.5,迭代次数需要翻倍;如果掉到0.3以下,100次迭代的置信度就非常危险了。
碰到这种情况,我建议先跑一遍统计滤波(Statistical Outlier Removal)或半径离群点去除,把孤立杂点清掉,再做地面分割。另外,稀疏点云场景里(比如16线雷达跑到50米开外),地面的点可能只有几十个,这时候即使抽到地面点,算出的平面也很不稳定。经验做法是把远处点先截断,只对30米以内的点做地面分割,远处留给其他传感器或算法处理。
7. 再做一点扩展:从平面模型到地面感知
严格来说,RANSAC平面分割解决的是“有没有一个主平面”的问题,而不是“整片地面长什么样”的问题。随着场景复杂度上升,我在实际项目中已经不太把它当成终点,而是当成一个“粗分割器”:先用它把最大的主平面干掉,剩下的点云再交给栅格高度图或区域生长来做更精细的地面提取。
相对完整的处理流程通常是这样的:原始点云进来,先直通滤波,再体素降采样加速RANSAC分割,拿到地面点和非地面点后,非地面点做聚类,地面点可以用于构建局部高程栅格图。高程图的好处是对坡道、路沿的容忍度高,因为每个栅格存的是“这个格子里的最大高度差”,天然比单一平面表达式更擅长描述复杂路面。
根据我个人经验,不管用什么方法,调参时一定要可视化确认。pcl_viewer能用不同颜色把inliers和outliers渲染出来,肉眼看一眼分割边界,比读一行内点数量指标高效得多。RANSAC地面点分割是一个“看着代码简单、实际坑不少”的模块,但它确实是感知流水线里性价比最高的预处理步骤之一。把这一步吃透,后面处理聚类、检测、追踪都会轻松很多。