开头是一个真实场景:我手里有一幅激光雷达扫描的室内点云,地面、墙面、桌面乱七八糟堆在一起。用最小二乘拟合地面平面,结果平面被桌椅腿拽歪了,歪得很离谱——最小二乘把离群点当成合法数据一起优化,出一个"既不是地面也不是桌面"的中间产物。换成RANSAC之后,同一份数据,同一套代码思路,平面稳稳落在真正的地面上。当时我只有一个感觉:这不是算法,这是常识。
这篇文章是散点云处理笔记系列的第二篇,专门聊RANSAC。上一篇整理了点云滤波,这篇重点解决"从一堆散乱点里把属于某个几何模型的内点捞出来"这个问题。RANSAC的全称是Random Sample Consensus,随机采样一致性,是点云处理流程里绕不开的模型估计工具,常用于平面分割、圆形检测、球面拟合,以及在PCL点云地图处理中做地面过滤、墙面提取、管道识别等操作。适合刚开始接触点云分割的读者,也适合那些已经跑通过PCL基础流程但还没搞懂参数为什么这么调的人。
我尽量不堆公式堆到劝退,但该算的还是要算清楚,毕竟迭代次数和距离阈值,你理解了背后逻辑,才不会每次靠猜。
1. 我的RANSAC初体验:为什么最小二乘会输给"少数服从多数"
大概两年前,我做了一个室内移动机器人项目,需要从多帧拼接后的点云地图中提取出地面和墙面,方便后续导航和避障。当时第一个想到的自然是平面拟合。用最小二乘拟合平面,原理简单,代码也容易写:对所有点求协方差矩阵,分解出最小特征值对应的特征向量就是法向量。听起来很顺,结果一跑,平面法向量偏了差不多15度。原因一目了然——房间里有桌子、椅子、柜子,这些不属于地面的点全都参与了最小二乘计算,它们数量占比不小,直接把拟合结果拉变形了。
后来换用RANSAC,理论上的差别就出来:RANSAC允许一部分数据完全不遵守目标模型,甚至可以容忍超过一半的异常数据。它不是让所有点一起妥协出一个模型,而是反复试出"最能代表大多数点的模型",然后让异常点靠边站。这种"少数服从多数但不被少数绑架"的特性,正是散点云分割中最重要的能力。
那个项目里,我设置了距离阈值0.03米,最大迭代次数1000次,平面模型直接拟合地面,一次性就把地面和障碍物分开了。地面点的平均残差从最小二乘的0.08米降到了0.01米以内。从那个项目以后,凡是点云里带明显离群场景的模型拟合,我都默认先跑RANSAC,而不是老老实实最小二乘。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RANSAC的工作原理拆解:随机抽3个点,然后重复一千次
理解RANSAC,千万别把它想得太神秘。它本质上是一个"反复试探—打分—保留最高分"的过程。下面把这套逻辑拆开讲。
2.1 从点到平面:最小样本集怎么选
RANSAC每一步迭代,都要先随机抽几个点来估计模型。这个"几个"是最小样本集,不是越多越好,而是刚好够求出模型参数的最少数量。对平面来说,空间中的三个不共线点就能确定一个平面,所以最小样本集是3。对球面来说,四个点可以确定一个球心和一个半径,最小样本集是4。圆柱面麻烦一点,需要计算轴向、半径,至少需要5个点。
为什么用最小样本集而不是像最小二乘那样把一堆点都拿来估计?因为每次随机采样,我们希望采到恰好能支持真实模型的那组点。样本越少,全部落在真实模型上的概率越高。举个例子,如果内点占比是50%,平面拟合每次抽3个点,全为内点的概率是0.5^3=12.5%,看起来不高,但只要迭代几百次,总能撞到一次;如果抽100个点全部是内点,概率几乎是0,永远等不到。所以RANSAC抽样本的原则是:能少则少,但必须能唯一确定模型。
对于平面拟合,还要避免抽出共线的三个点。PCL内部对每次采样会做退化检测,如果三个点距离太近或者接近共线,它可能直接放弃这一组,换一组重来。自己在写Python实现时,也要加一个点数间距检查,否则奇异的平面法向量会导致整轮内点统计失真。
2.2 内点与残差:用距离给点投票
模型参数求出后,RANSAC要拿这个模型去检验所有点,看谁支持这个模型。在平面模型场景,检验方法是计算每个点到该平面的距离。落在距离阈值之内的点,被标记为内点;超过阈值的,管它点再多,也属于外点。内点数量就是这个模型的得分。
这样处理的意义在于,RANSAC没有让人均摊误差,而是把容错范围划出来。你可以把距离阈值想象成一条"双黄线",离线太近的你认为是遵守模型的,离线太远的直接判违规。这个阈值直接决定了一个点在当前模型下"算不算数"。阈值设置过小,内点数量少,模型可能不完整;阈值设置过大,外点被大量纳入,平面被"增肥",后续提取的平面点云区域会覆盖到许多非目标物体上。
PCL的SACSegmentation中,距离阈值需要自己在setDistanceThreshold中传。注意单位是米,不是厘米。激光雷达点云的精度多在厘米级,地面分割通常取0.01到0.05米;深度相机点云密度高但噪声小,取0.005到0.02米;毫米波雷达点云极稀疏且噪声大,阈值可能要放到0.1米以上。配准后的点云误差更大,要结合配准残差来判断阈值。
2.3 迭代次数不是越多越好,有个数学公式撑着
很多人以为RANSAC迭代次数设个几万次心里才踏实,其实从概率上算,合理值往往远小于你的直觉。假设我们希望至少一次采样“这组点全为内点”的概率达到 p(通常取0.99),并且已知内点比例是 w,单次采样所需样本数为 s,那么一次采样全是内点的概率是 w 的 s 次方。采样失败的概率是 1 - w^s。迭代 N 次,全部失败的概率是 (1 - w^s)^N。要保证成功率至少 p,可得到:
N = log(1 - p) / log(1 - w^s)
举例:平面拟合 s=3,假设内点比例 w=0.5,要求成功率 p=0.99,则 N = log(0.01) / log(0.875) ≈ 34.3,向上取整35次。也就是说,哪怕只有一半内点,迭代35次就有99%的把握找到那组纯内点样本。
如果你把内点比例降到 w=0.3,同样的平面拟合,N = log(0.01) / log(1-0.3^3) = log(0.01)/log(0.973) ≈ 170次左右。仍然不算多。PCL里默认的最大迭代次数通常是50到100,但因为不知道w,保守起见可以设到500到1000。盲目设成十万次,单帧点云几十万点,每一次迭代全量计算点到模型的距离,性能会非常吃紧。更聪明的办法是设置一个终止条件:如果当前内点数量已经占总点数的比例足够高,比如达到预期内点比例,就可以提前退出迭代。PCL里有SAC 相关接口可以配合使用,但日常直接设一个合理的最大迭代次数就够了。
3. 点云分割中最常用的实例:平面模型与PCL代码
平面分割是RANSAC在点云应用里最经典、最稳的场景。很多点云处理流程的第一步都是"先把地面去掉"或者"把墙面提出来",RANSAC平面分割正是完成这类操作的默认工具。
3.1 PCL的SACSegmentation使用流程
在PCL里跑一个RANSAC平面分割,通常需要配合pcl::SACSegmentation和pcl::ExtractIndices一起使用。分割阶段负责计算平面方程,提取阶段负责按索引把属于平面的点从原始点云里捞出来。下面给一个可以直接参考的C++片段:
cpp复制#include <pcl/segmentation/sac_segmentation.h>
#include <pcl/filters/extract_indices.h>
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZ>);
// 假设cloud已经加载好点云数据
pcl::ModelCoefficients::Ptr coefficients(new pcl::ModelCoefficients);
pcl::PointIndices::Ptr inliers(new pcl::PointIndices);
pcl::SACSegmentation<pcl::PointXYZ> seg;
seg.setOptimizeCoefficients(true);
seg.setModelType(pcl::SACMODEL_PLANE);
seg.setMethodType(pcl::SAC_RANSAC);
seg.setDistanceThreshold(0.03);
seg.setMaxIterations(1000);
seg.setInputCloud(cloud);
seg.segment(*inliers, *coefficients);
if (inliers->indices.empty()) {
PCL_ERROR("没有找到有效的平面模型\n");
return -1;
}
pcl::ExtractIndices<pcl::PointXYZ> extract;
extract.setInputCloud(cloud);
extract.setIndices(inliers);
extract.setNegative(false);
pcl::PointCloud<pcl::PointXYZ>::Ptr plane_cloud(new pcl::PointCloud<pcl::PointXYZ>);
extract.filter(*plane_cloud);
setOptimizeCoefficients(true)的意思是在拿到内点集合后,再用所有内点做一次最小二乘精修,让平面系数更准确。这一步实际上是在RANSAC粗分割之后做了个"内部精修",对提高平面精度很有帮助,代价是消耗一点点额外计算时间。
3.2 平面方程和法向量的读取
分割完成后,平面模型系数存在coefficients里,对SACMODEL_PLANE来说,存储的是平面方程 ax + by + cz + d = 0 的四个系数:(a, b, c, d)。其中(a, b, c)是平面法向量,但不保证是单位向量。如果需要单位法向量,可以自己除模长。在给机器人导航避障时,判断一个平面是否水平,可以看法向量与Z轴(0,0,1)的夹角。我习惯这样算:
cpp复制float a = coefficients->values[0];
float b = coefficients->values[1];
float c = coefficients->values[2];
float norm = std::sqrt(a * a + b * b + c * c);
float nx = a / norm;
float ny = b / norm;
float nz = c / norm;
float angle_to_z = std::acos(std::fabs(nz)) * 180.0f / M_PI;
夹角小于10度,认为这是近水平面;夹角在80到100度之间,认为是竖直墙面。不过这个度数阈值要根据实际场景去调,不能照搬。
3.3 参数设置实战:距离阈值、最大迭代次数、期望内点比重
平面分割最关键的参数就是3个:距离阈值、最大迭代次数、期望内点数量或比重。
距离阈值前面说过,和传感器精度强相关。给一个我自己用的初始化参考表:
| 数据来源 | 距离阈值建议范围 | 说明 |
|---|---|---|
| 深度相机(如Kinect、RealSense) | 0.005~0.02米 | 近距离精度高,但边界有噪声 |
| 16线机械式激光雷达 | 0.02~0.05米 | 点间距大,地面点跨度不均匀 |
| 64线/128线激光雷达 | 0.01~0.03米 | 点云较密,阈值可以收紧 |
| 配准后的里程计拼接地图 | 0.03~0.10米 | 误差累积导致点云有厚度 |
最大迭代次数在地面分割场景,我觉得1000次是性价比很高的选择。如果内点比例实在很低,比如地面只占10%,1000次也能保证很高概率找到模型。如果你更追求实时性,可以降为200次,代价是偶尔拟合的平面不稳定。期望内点比重在有些封装中不需要人工指定,PCL的segment会自己返回最佳模型。
3.4 分割地面后还要做点小动作
用RANSAC把地面点提取出来后,通常不是直接把点云拿走就好。地面通常是一个大平面,但地面以下往往还有坑洼或者台阶,一次RANSAC分割到的“地面”只是当前最大的那个平面。如果地面不是完整的一块,比如有坡道、台阶,一个平面模型只能抓住主要坡面,其他部分会被丢到残差点里。这种情况有两种处理方式:一是设置RANSAC的最小内点数量(PCL里没有直接参数,但可以通过判定inliers数量实现类似逻辑),二是分割完成后对残差点再跑一次RANSAC,用"多平面提取"的思路迭代处理。我自己在做点云地图处理时,常写一个循环,每次提取最大平面,保留后再对剩余点继续分割,直到剩余点太少或找不到有效平面为止。
4. 别只盯着平面:圆形、球面和自定义模型的实际选择
RANSAC并不只做平面。在工业视觉里,经常需要从点云中找圆心、球体、圆柱或任意可参数化的几何模型。这时候选用正确的模型类型比调整一堆参数更关键。
4.1 从平面到圆:模型本身的约束不同
比如要找一个货架腿的圆心位置,直接用SACMODEL_CIRCLE3D或SACMODEL_CIRCLE2D会方便很多。SACMODEL_CIRCLE2D适用于2D点云或投影到平面的点集,它需要3个点确定一个圆,模型系数输出圆心坐标和半径。SACMODEL_CIRCLE3D则是3D空间中的圆,需要额外估计法向方向。实际工作中,如果圆孔或圆形工件在一张深度图上投影比较清楚,我会先把点云投影到平面,再跑2D圆拟合,这样参数少、更稳定。
球面拟合的场景更多出现在球状物体的定位,比如机器人抓取球体,或检测药丸、水果等。球面需要4个点确定球心和半径。RANSAC球面拟合对噪声相对敏感,因为它有4个自由参数,随机四点在真实球面上的概率低一些,需要适当提高迭代次数。半径如果已知,可以限制半径范围,PCL里有的模型支持设置半径下限和上限,在PCL的SACSegmentation中通过setRadiusLimits设置,可以大幅提升拟合精度和速度。
4.2 圆柱模型:一个典型的"多参数"案例
管道、电线杆、树干这类物体,用SACMODEL_CYLINDER来分割。圆柱模型需要拟合轴向,采样的最小样本数是5,而且输出的是轴方向、轴上一个点和半径。由于参数多,随机采样遇到纯内点的概率更小,所以迭代次数要相应提高,距离阈值也要根据圆柱表面噪声调整。我在给室外场景做电线杆提取时,通常把半径限制在0.1~0.5米之间,最大迭代次数设为5000,效果明显好于让模型盲搜。
4.3 我的经验:先做平面,再做非平面
无论最终目标是什么,我强烈建议在点云处理流程的前半段,先用平面分割把场景中的大平面消掉。为什么?因为随机采样这类算法的效率高度依赖内点比例。一个室内场景中,地板、天花板、墙面可能占了50%以上的点。如果先把这些平面移除,剩余待处理点云的内点比例会大幅提升,后续找圆形、球体、管道的速度和精度都会上一个台阶。
在一些公开点云数据集上,我们也常看到处理流程都遵循这种"先分割平面,再聚类或拟合剩余结构"的思路。平面分割不只是结果本身,更是给后续所有步骤减轻负担的清理动作。
5. 实战场上必然会遇见的RANSAC翻车记录
这部分我要认真聊聊踩过的坑。RANSAC虽然鲁棒,但不是万能的。用久了会发现,它有一批十分典型的失败模式,每一条都值得记下来。
5.1 翻车一:对称模型导致"你的平面"不是"我想要的平面"
RANSAC的标准模型是"内点数量最多"的模型,但在很多场景里,内点数量最多不代表语义上正确。比如在一个走廊点云里,地面和左侧墙面积相近,两点都多,RANSAC可能随机采样先找到一个墙面模型,因为它内点数够了,就判定为最佳。结果你原本想提取地面,却把墙面当成了平面输出。
解决办法有不少,我常用的是给RANSAC加"法向量约束"。PCL里有SACMODEL_NORMAL_PLANE,它可以结合点云法线方向来约束平面,或者自己在上层判断:在拿到平面系数后,计算法向量与目标轴向的夹角,不满足预期方向就跳过,重新在剩余点里继续RANSAC。另一个办法是预先通过直通滤波(PassThrough)把Z轴范围限制在地面附近,只保留z=0附近的部分点,这样地面的内点比例会显著高于墙面,RANSAC自然锁定地面。
还有一个现实教训:不要指望一次RANSAC就把所有目标区域提取干净。比如地面被桌子遮挡,剩余地面碎成几块,第一次平面拟合可能只找到面积最大的一块,你还需要提取剩余点、再次拟合,迭代两三次,才能拼出完整地面。
5.2 翻车二:距离阈值拍脑袋,整个平面被"切肉"切歪
有段时间我做三维扫描重建,扫描一个立方体物件,想提取六个平面。第一次拟合得到的平面,点云中连"立方体棱线"都被吸进了平面里,边缘糊了。原因是距离阈值设了0.05米,而那个扫描仪的单点噪声只有0.002米,立方体边角过渡区域的许多点离两个面都有距离,在0.05米容差下,全被拉进任意一个平面了。
后来把阈值改到0.005米,平面边缘才稍微利索。这里的关键是:距离阈值必须和噪声水平匹配。在边角处的点本来就处于两平面交界,一点微小噪声都可能让它们被错误归类。你设置的阈值越大,误分类区域越厚。
我现在的习惯是:先在点云上人工选一小块已知平面区域,计算点到拟合平面的标准偏差,以标准偏差的3倍作为距离阈值初始值,再人工微调。这样比瞎拍一个0.01或0.05更有依据。
5.3 翻车三:一次只找一个模型,却忘了点云里有很多"模型"
RANSAC的另一个局限是"一次拟合一个模型"。现实中的点云往往包含多个平面:墙壁、地面、桌面、窗户、柜子。直接跑一次RANSAC,只能拿到最大那个面。如果你想要多个平面,需要做"循环分割":分割出最大平面后,把它的点从点云里剔除,再用剩余点继续分割,重复执行。我在处理房间点云时,采用下面这段逻辑:
cpp复制while (剩余点数 > 阈值) {
seg.segment(*inliers, *coefficients);
if (inliers->indices.size() < 最小点数) break;
提取平面点云;
从原cloud中移除这些点;
}
这个过程中有一个细节:每次分割后,剩余点云会越来越碎,RANSAC找到的"平面"实际上可能是一块弯曲曲面的一部分。为了避免把弯曲物面误当平面,我会在循环中顺便检查平面内点集的法向量标准差,如果偏大,说明这个平面太"弯曲",停止循环。
5.4 性能优化:降采样、边界估计与并行化
RANSAC最耗时的是每轮迭代全量计算点到模型的距离。点云如果有一百万个点,迭代500次,就是五亿次距离计算。优化方式第一选择是体素降采样(VoxelGrid),把点云下采样到2cm或5cm分辨率,再做RANSAC获取模型系数。拿到系数后,可以回到原始点云上,用更严格的阈值重新提取精确内点。第二步是提前剔除明显不在模型周边的点,比如只取高度范围或者区域范围,减少参与计算的数据量。
PCL中还有一种方法,是在SACSegmentation里设置OptimizeCoefficients和Probability。Probability表示希望RANSAC至少成功一次的概率,默认0.99。如果你觉得实时性不够,可以把它设成0.95,迭代次数会减少一些。不过真正影响性能的,还是距离计算本身。在多线程场景下,也可以考虑用pcl::RandomSampleConsensus的并行版本或自己用OpenMP对距离统计循环做加速,不过那是后话,一般先用降采样就够了。
6. RANSAC之后的精修,与点云地图处理的完整闭环
RANSAC不是终点。如果只是把一个平面粗糙地从点云里摘出来,其实后面的处理还很多。真正用得顺手,是把RANSAC和最小二乘、聚类、边缘提取结合起来,走一条完整流程。
6.1 一分隔,二精修:先RANSAC后最小二乘
RANSAC找到的模型精度,取决于内点数量阈值和内点范围,它的目标不是最小化所有内点的误差,而是找到“哪些点属于模型”。因此你以为拿到了准确平面,实际还是会有少量靠近模型边界的外点混进来,而且模型参数本身没有用所有内点做精细优化。
正确的做法是:先靠RANSAC获得内点集合,再用这些内点做一次最小二乘拟合,保留RANSAC的“鲁棒性”,再吸收最小二乘在纯内点下的“精确性”。这一步在PCL里就是setOptimizeCoefficients(true),它能利用全部内点重新估计模型系数。线下处理时,我还会用Python对所得内点再跑一遍SVD平面拟合,计算每个点到新平面的残差并剔除法向相差大的点,反复两三次,最后得到的平面参数通常能达到亚毫米级稳定。
我建议处理点云地图时,可以把RANSAC得到的平面作为"粗分割阶段",后续用欧式聚类或区域生长把平面边缘的悬空点消除。不必强求RANSAC一次输出完美结果。
6.2 点云地图处理中的RANSAC实践:地面过滤与目标提取
点云地图处理是个典型应用场景。比如机器人建图后得到的全局点云地图,第一件事常常是去除地面。地面不干净,后续做障碍物检测时,小物体容易被淹在起伏的地面点云里。RANSAC平面分割在这里派上大用场。
我的标准流程是:
- 对原始地图做体素降采样,分辨率设为0.02米。
- 用RANSAC平面分割提取最大平面。
- 判断最大平面是否接近水平,是则视为地面。
- 从地图中去除地面点,剩余点云进行欧式聚类,生成障碍物列表。
- 如果地图是楼道或多楼层,可能还需要提取墙面和天花板,这时候迭代分割循环更合适。
地面过滤后,经常会出现一种情况:有几个孤立点、小碎石也被当成非地面簇导致误报。我会在聚类参数里设置最小点数,也可以使用半径异常值移除,把地面噪声和激光雷达镜面反射造成的孤点删掉。其实这些效果都是在RANSAC"粗切割"之上叠加的精细后处理,不能省略。
6.3 结合法线估计与边缘提取,RANSAC的结果还能再升值
RANSAC提取出的平面,后面常跟两件事:法线估计和边界提取。平面点云已经被找出来了,算法线不用再用K近邻全场景搜索,只要在平面点云内部做PCA,主方向就是平面法向。这样计算量小,而且法向量稳定。边缘提取可以用PCL的BoundaryEstimation,然后基于边缘点做直线拟合,或者直接对平面点云做凸包求轮廓,可用于墙面的开洞测量。
我见过不少团队,用RANSAC做地平面检测后,直接用平面方程去剔除障碍物,这是可以的。但如果你要精确估计机器人到墙面的距离,或要计算物体体积,建议对平面点云做二次校验,把法向量与平面系数的偏差、点到平面的残差分布都打出来看,确认没有混入大块非平面组织。
最后分享一个我自己的个人习惯:调RANSAC参数时别只盯着输出结果,我也会把每轮分割的“内点数量”“模型系数”打印出来,连续跑几帧数据对比。如果内点数量在相邻帧之间跳变剧烈,说明阈值多半选小了,模型在来回跳;如果相邻帧的平面系数差很大,可能是有东西挡了一下导致最大平面切换。这种"看参数变化"的方法,比单帧调试更容易发现问题。
另外,如果你也常处理PCL点云地图,建议把地面分割的RANSAC参数写成配置文件,不同传感器、不同环境用不同配置,避免每次改代码重新编译。比如室内小场景距离阈值0.02,室外无人车0.05,工厂大场景0.08。配置化以后,现场调试的时间起码省一半。
说到底,RANSAC不复杂,但想用好,真得把每个参数和场景特性对上号。多折腾几次,你就知道它什么时候该信任,什么时候该怀疑了。
