第八周,点云分割课学到第八节。说句实话,前几周的内容越学越像"调参合集":第一周滤波、第三周KDTREE、第五周RANSAC提平面、第六周欧几里得聚类、第七周区域生长,每个方法跑通示例的时候都觉得自己行了,一换到自己采集的数据上立刻露馅——该分开的粘成一团,该完整的分得稀碎。这周的内容是条件欧几里得聚类、最小割分割、超体素加LCCP,三套方案终于让我找到了前面所有问题的症结:前几周的方法只盯着"局部关系",而这周的方法开始把"全局一致"和"先验信息"引进了分割决策里。这篇文章把这三类方法的原理、代码、参数调试心得一条条写清楚,正好卡在学习PCL分割部分的人最需要的点上。
1. 为什么前面几周的方法会在"粘连"和"渐变"面前失灵
1.1 欧几里得聚类的"半径魔咒"
欧几里得聚类的核心逻辑其实非常简单:给点云建一棵KDTREE,随机抓一个没访问过的点,以固定半径做邻域搜索,把搜到的点拉进当前簇里,再对这些新入簇的点继续扩展,直到再也找不到邻居为止。这本质上是"固定半径的洪水填充"。
问题恰恰出在这个"固定半径"上。真实场景点云密度从来不是均匀的:同一帧数据里,离传感器近的物体点间距可能只有两毫米,远处物体的点间距能拉开到两三厘米。半径设小了,近处物体被切得稀碎;半径设大了,远处两个原本独立的物体直接连成一片。我第七周处理过一个巷道场景,墙面和旁边的管道明明是两个目标,就因为管道表面点距恰好落在聚类半径内,被并成了一个簇,后处理怎么都分不开。
1.2 区域生长的"法向量连锁反应"
区域生长比欧几里得聚类聪明一点,它不再只看距离,而是把法向量夹角作为生长条件:两个相邻点法向量足够接近,才允许连接。对平面场景效果确实不错,但对曲面就麻烦了。
圆柱侧面、球面这类连续曲面,相邻点的法向量是慢慢旋转的,每个局部夹角都不大,可整体转了一圈之后首尾两个点的法向量方向完全相反。阈值设大了,整个圆柱被并成一类;阈值设小了,一个平面也会被切出好几块,因为法向量估计本身受邻域半径影响,局部有起伏就会触发"断裂"。区域生长还有一个隐患是种子点顺序敏感,从不同的点开始生长,结果经常不一样,这在工程上很难接受。
这两类方法失灵的本质是同一个:它们只根据局部空间关系做判断,缺少对"整体归属"的建模。第八周学的三套方法,解决的就是这个问题。
2. 条件欧几里得聚类:把"能不能连"的决定权交给我们
2.1 和普通聚类的差别到底在哪
条件欧几里得聚类(ConditionalEuclideanClustering)在结构上跟普通欧几里得聚类几乎一样,仍然是"先距离后扩展",区别是它允许我们在两个点的连接判定里插入一个自定义条件函数。距离在容差范围内还不够,条件函数必须返回 true,这两个点才会被算作同一个簇。
这就把"怎么算同一类"的规则彻底开放了。除了距离,我们可以看法向量夹角、看颜色差值、看强度值,甚至可以写一个查表的函数嵌入用户自己的业务规则。比如安检场景里想分开两个贴在一起的包裹,它们距离很近但表面材质不同,通过反射强度就能区分,这是普通欧几里得聚类根本做不到的。
2.2 核心代码结构
写起来也不复杂,核心就三段:估算法向量、定义条件函数、配置参数并执行。
#include <pcl/segmentation/conditional_euclidean_clustering.h> // 条件函数:只有距离足够近、法向量夹角足够小,才允许连接 bool customCondition(const pcl::PointXYZINormal& a, const pcl::PointXYZINormal& b, float squaredDist) { if (squaredDist > 0.0025f) return false; // 超过 5cm 直接断开 float dot = a.normal_x * b.normal_x + a.normal_y * b.normal_y + a.normal_z * b.normal_z; if (dot < 0.85f) return false; // 夹角超过约 31 度,断开 return true; } pcl::ConditionalEuclideanClustering<pcl::PointXYZINormal> cec; cec.setInputCloud(cloud_with_normals); cec.setConditionFunction(&customCondition); cec.setClusterTolerance(0.05f); cec.setMinClusterSize(200); cec.setMaxClusterSize(50000); std::vector<pcl::PointIndices> clusters; cec.segment(clusters);注意这里的 squaredDist 单位是平方米,对应的是setClusterTolerance传入值的平方。我一开始没注意单位,直接拿厘米数平方,把容差放大了十倍,结果分割出来全是一个大簇。
关于点云类型,建议直接用pcl::PointXYZINormal,它把位置、法向量、强度都装在一起,条件函数里取数据非常方便。如果用了PointXYZ,法向量就得另外存,条件函数里还得做类型转换,完全没有必要。
还有一个容易被忽略的行为:这个聚类是"传递式"扩展的。A和B满足条件、B和C满足条件,即便A和C之间的距离超过了容差,它们仍然会被归到同一个簇。也就是说,簇可以沿着点云表面一路"爬"过去。这个特性在细长物体上是优点,在散乱噪声点上是灾难,所以最小簇大小一定要设,否则每次都会多出几十个几像素的碎簇。
2.3 我在测试里踩的三个坑
第一,条件函数里不要做重计算。它会被极其高频地调用,每一个候选点对都要执行一次。我在函数里临时写了一堆特征加权、查表的逻辑,单次耗时从几十毫秒直接涨到好几秒,算法效果没提升多少,速度先崩了。正确的做法是把特征预计算好,塞进点云的字段里,条件函数只做比较。
第二,法向量质量决定条件函数的有效性。我对比过用 K 近邻和用半径搜索估算法向量的效果,在点密度不均的场景里,固定半径(比如 3 厘米)估算出的法向量明显更稳。K 近邻在稀疏区域会跨过物体边界去抓别的面上的点,法向量一乱,条件函数再怎么写都白搭。
第三,setMinClusterSize和setMaxClusterSize一定要设置。条件函数可能因为某些局部不连续把一个大物体切断,这时最小尺寸能把碎块过滤掉;反之条件过于宽松时,最大尺寸能限制住背景点无限蔓延。我习惯先跑一次统计滤波器,再配合这两个参数,分割稳定性会好很多。
3. 最小割分割:把前景背景问题做成"能量最小化"
3.1 图割在点云里是怎么建模的
最小割分割(MinCutSegmentation)的思路完全不一样,它把分割问题转换成图论里的"最小割"问题。做法是把每个点都当成图的一个节点,额外再加两个虚拟节点:源点代表前景,汇点代表背景。然后构建两类边。
一类是相邻点之间的边。两个点距离越近、法向量越一致,边的权重越大,意思就是"这两个点很难被切开"。另一类是每个点到源点、汇点的边,权重对应这个点属于前景或背景的先验强度。前景点连接到源点的边权重被调高,背景点连接到汇点的边权重被调高。
构建完图之后,算法通过最大流最小割找到一条切割路径:把点云切成两部分,使得被切断的所有边权重之和最小。直观理解就是,算法要找一条"最不费力"的边界,这条边界恰好绕开强关联的邻居关系,落在物体和背景的天然缝隙上。
3.2 核心参数与代码
PCL 的实现用起来很直白:
#include <pcl/segmentation/min_cut_segmentation.h> pcl::MinCutSegmentation<pcl::PointXYZ> seg; seg.setInputCloud(cloud); pcl::PointCloud<pcl::Normal>::Ptr normals(new pcl::PointCloud<pcl::Normal>); // 此处已计算好 normals seg.setNormalCloud(normals); seg.setRadius(0.05f); // 邻域搜索半径 seg.setSigma(0.15f); // 距离衰减系数 seg.setSourceWeight(0.6f); // 前景连接权重 pcl::PointCloud<pcl::PointXYZ>::Ptr foreground(new pcl::PointCloud<pcl::PointXYZ>); // 人为给定一个属于目标的点 foreground->push_back(seed_point); seg.setForegroundPoints(foreground); std::vector<pcl::PointIndices> clusters; seg.extract(clusters);三个参数的含义要记牢。setRadius决定哪些点算"相邻",它构建邻接边,半径太大会把背景和前景强行绑在一起,太小则图可能不连通。setSigma控制边的权重随距离衰减的速度,sigma 越小,远处的点边权重越小,切割越容易发生在远处,一般从 0.1 到 0.2 起步调试。setSourceWeight是前景先验的强度,值越大,算法越倾向于把更多点划给前景。
前景点怎么选是个工程问题。我常用的做法是先跑一遍 RANSAC 把大平面(比如桌面、地面)去掉,然后在剩余点云里取一个质心点或者交互点击一个目标上的点作为前景种子。如果场景比较杂,也可以用setBackgroundPoints传入一个背景点云作为约束,比如把降采样后的整个场景点云当作背景模型,算法会把靠近背景模型的点推向汇点一侧。
3.3 适用边界和性能注意
最小割分割的强项是"从背景里抠出单个物体"。桌上放一个杯子,周围一堆杂物,给一个前景点,它能把杯子的边界抠得很干净。它和条件欧几里得聚类的互补性很强:条件欧几里得擅长把多个物体彼此分开,但不知道哪个是目标;最小割恰恰需要"哪个是目标"的先验。
它也有明显的局限。首先,一次分割只处理一个前景目标,多个同等地位的物体要分割,就得循环做多次,每次重新建图重跑最大流,效率很低。其次,它对点云密度和法向量质量很敏感,稀疏区域很容易出现"切割线穿过物体"的情况。我踩过的坑是没降采样就直接跑,五百万点的场景里建图加最大流跑了好几分钟才出结果,把点云降采样到 2 到 5 厘米之后,效果几乎不变,时间降了两个数量级。
4. 超体素 + LCCP:先"抱团"再"合并"的两阶段思路
4.1 超体素:把点云变成"大颗粒"
超体素(Supervoxel)的思路借鉴了图像处理里的超像素。超像素是把一张图切成一堆颜色、纹理相近的小块,超体素就是把这个概念扩展到三维点云:先用规则网格在空间里布置一批种子点,每个种子点通过带权聚类吸收周围位置相近、法向量相近、颜色相近的点,形成一个个过分割的小块。这些小块才是后续分割的基本单元。
在 PCL 里用pcl::SupervoxelClustering实现,两个核心参数是setVoxelResolution和setSeedResolution。前者是八叉树和法向量估计的体素大小,一般取场景的平均点间距;后者是种子点的距离,直接决定超体素的颗粒度,通常设成体素分辨率的 8 到 10 倍。种子太密,超体素碎成一片;种子太疏,不同物体的边界会被糊在一起。
#include <pcl/segmentation/supervoxel_clustering.h> #include <pcl/segmentation/lccp_segmentation.h> pcl::SupervoxelClustering<pcl::PointXYZRGBA> super(voxel_res, seed_res); super.setInputCloud(cloud); super.setColorImportance(0.2f); super.setSpatialImportance(1.0f); super.setNormalImportance(4.0f); std::map<uint32_t, pcl::Supervoxel<pcl::PointXYZRGBA>::Ptr> supervoxels; super.extract(supervoxels);三个重要性权重的含义要和实际效果对应起来看。setNormalImportance通常给最大权重,因为法向量的一致性最能反映"是否在一个光滑表面上";setSpatialImportance保持默认通常没问题;setColorImportance只在颜色信息可靠的场景里开,激光雷达数据里颜色字段经常是空的或者带噪声,给了大权重反而会把分割搞乱。
4.2 LCCP:用"凸凹性"判断该不该合并
拿到超体素之后,LCCP(Local Convexity Connected Patches)做的事情是判断相邻两个超体素该不该合并成一个物体。判断依据是局部凸凹性:如果两个超体素之间的连接面是"凸"的,说明它们更像是同一个物体的两个部位;如果连接面是"凹"的,说明这里大概率是两个物体的分界。这个判据在人造环境里特别有效,因为像桌角、盒子棱、杯沿这类物体边界,绝大多数都是凹连接。
pcl::LCCPSegmentation<pcl::PointXYZRGBA> lccp; lccp.setInputSupervoxels(supervoxels); lccp.setConcavityToleranceThreshold(10); lccp.setSmoothnessThreshold(12); std::map<uint32_t, pcl::PointLCCP> labels; lccp.segment(labels);setConcavityToleranceThreshold控制算法对"什么程度算凹"的容忍度,单位是角度,值越大越倾向于把两块合并到一起。setSmoothnessThreshold是平滑性阈值,处理连续曲面上的渐变,避免把一个光滑柱面切成一圈小碎块。
4.3 这类方法真正适合的场景
超体素加 LCCP 最大的优势是"整体意识"。它不像欧几里得聚类那样容易被单个缝隙影响,也不像最小割那样只认一个目标,它可以在一帧点云里一次性把多个物体分开,而且对遮挡、噪声的容忍度高。我拿它处理过模拟工作台上叠加着杯子和盒子的场景,杂点很多,LCCP 依然把每个物体归到了独立的标签里。
代价是计算开销和调参成本。超体素提取本身要建八叉树、估计法向量、做迭代聚类,跑完 LCCP 还要做一次图上的合并判断,整套流程比前两种方法重不少。而且两个分辨率参数和两个阈值之间互相影响,每次换场景都得重新调一轮。我的习惯是先用默认参数跑通,再根据"过分割还是欠分割"去定向调参数:碎块太多就增大种子距离和 concavity 阈值,粘在一起就减小这两个值。
5. 三种方法在一个场景里的横向对比
5.1 一个统一的测试流程
为了把三种方法放在同一起跑线比较,我搭了一个固定的处理流程,这也算是我目前跑分割实验的标准模板:
- VoxelGrid 降采样,leaf size 取 0.005 到 0.01 米,控制点数同时保住细节。
- RANSAC 拟合最大平面,用 ExtractIndices 把平面点去掉,避免桌面、地面干扰后续分割。
- 用
NormalEstimation估算法向量,搜索方式优先半径搜索,半径取 2 到 3 倍平均点间距。 - 分别跑三种分割方法,统一用簇大小做后处理过滤。
- 可视化时每个簇分配随机颜色,先看簇数量,再看边界是否贴合真实物体轮廓。
这一步流程看起来简单,但顺序不能乱。先降采样再提平面,能省很多计算;先提平面再估计法向量,法向量不会受平面干扰;法向量质量不过关,后面三个方法全部会崩。
5.2 实测对比结果
我用的测试数据是一组模拟工作台点云:桌面上放着一个杯子、一个方盒子,旁边散落几颗螺丝和一些噪声点。三种方法在同一个预处理后的数据上跑了多次,效果差异很明显。
| 方法 | 核心输入 | 判定逻辑 | 适合场景 | 主要调参项 | 相对耗时 |
|---|---|---|---|---|---|
| 条件欧几里得 | 法向量 | 距离+自定义规则 | 粘连物体的细分割 | 容差、条件函数 | 低 |
| 最小割 | 法向量、前景点 | 全局能量最小化 | 单目标从背景分离 | radius、sigma、sourceWeight | 中 |
| 超体素+LCCP | 法向量、颜色 | 超体素+凸凹合并 | 多目标同时分割 | 体素分辨率、种子距离 | 高 |
从分割质量上说,杯子和盒子这种形状规整的目标,超体素加 LCCP 的完整度最高;两个物体贴在一起时,条件欧几里得配合法向量条件可以干净地切开;而要从大片背景中单独提取杯子,最小割的效果最稳定。
5.3 参数调试的顺序感
调参这件事,最忌讳上来就乱试。我的经验是先定"问题类型",再定方法,最后才动参数。容差从大往小调,先看到一个完整的过分割结果,再逐步收紧到边界贴合;最小割则反过来,sigma 和 sourceWeight 从小到大调,先看到一个小范围前景,再慢慢放开让算法自己去扩展边缘;超体素的调试速度最慢,建议先用较粗的种子距离跑通逻辑,确认方向正确后再细化。
一个值得注意的细节:条件欧几里得的容差最好设置为平均点间距的两倍左右,而不是随意拍一个数。平均点间距可以在降采样之后用 KDTREE 统计每个点的最近邻距离再求均值得到。有了这个基准,参数迁移到别的场景时至少有个合理的起点,不用每次都从零猜。
第八周学完,我最大的体会是这三套方法不是互相替代的关系,而是对应三类不同的问题。物体和物体粘在一起,先考虑条件欧几里得;要从大背景里抠出一个目标,最小割是正解;想一次性把整片场景切成多个合理物体,超体素加 LCCP 最靠谱。建议所有在学 PCL 分割的同学,花点时间把这三套方法封装成统一接口,输入点云、输出簇索引,内部再按参数切换实现。这样以后换场景、换数据,调试效率会高很多,也能把精力真正花在理解算法本身,而不是反复抄示例代码。