第八周点云分割课学到第八节。说句实话前几周的内容越学越像调参合集第一周滤波、第三周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::ConditionalEuclideanClusteringpcl::PointXYZINormal cec; cec.setInputCloud(cloud_with_normals); cec.setConditionFunction(customCondition); cec.setClusterTolerance(0.05f); cec.setMinClusterSize(200); cec.setMaxClusterSize(50000); std::vectorpcl::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::MinCutSegmentationpcl::PointXYZ seg; seg.setInputCloud(cloud); pcl::PointCloudpcl::Normal::Ptr normals(new pcl::PointCloudpcl::Normal); // 此处已计算好 normals seg.setNormalCloud(normals); seg.setRadius(0.05f); // 邻域搜索半径 seg.setSigma(0.15f); // 距离衰减系数 seg.setSourceWeight(0.6f); // 前景连接权重 pcl::PointCloudpcl::PointXYZ::Ptr foreground(new pcl::PointCloudpcl::PointXYZ); // 人为给定一个属于目标的点 foreground-push_back(seed_point); seg.setForegroundPoints(foreground); std::vectorpcl::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::SupervoxelClusteringpcl::PointXYZRGBA super(voxel_res, seed_res); super.setInputCloud(cloud); super.setColorImportance(0.2f); super.setSpatialImportance(1.0f); super.setNormalImportance(4.0f); std::mapuint32_t, pcl::Supervoxelpcl::PointXYZRGBA::Ptr supervoxels; super.extract(supervoxels);三个重要性权重的含义要和实际效果对应起来看。setNormalImportance通常给最大权重因为法向量的一致性最能反映是否在一个光滑表面上setSpatialImportance保持默认通常没问题setColorImportance只在颜色信息可靠的场景里开激光雷达数据里颜色字段经常是空的或者带噪声给了大权重反而会把分割搞乱。4.2 LCCP用凸凹性判断该不该合并拿到超体素之后LCCPLocal Convexity Connected Patches做的事情是判断相邻两个超体素该不该合并成一个物体。判断依据是局部凸凹性如果两个超体素之间的连接面是凸的说明它们更像是同一个物体的两个部位如果连接面是凹的说明这里大概率是两个物体的分界。这个判据在人造环境里特别有效因为像桌角、盒子棱、杯沿这类物体边界绝大多数都是凹连接。pcl::LCCPSegmentationpcl::PointXYZRGBA lccp; lccp.setInputSupervoxels(supervoxels); lccp.setConcavityToleranceThreshold(10); lccp.setSmoothnessThreshold(12); std::mapuint32_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 分割的同学花点时间把这三套方法封装成统一接口输入点云、输出簇索引内部再按参数切换实现。这样以后换场景、换数据调试效率会高很多也能把精力真正花在理解算法本身而不是反复抄示例代码。