4. 三维感知任务概览

点云分割,说白了就是把一堆散乱的点,按语义拆成不同的组。地面、车辆、行人、树……各归各的。我刚开始做激光雷达感知时,觉得分割不就是聚类嘛,后来踩了坑才明白——地面不先干掉,聚类全是噪声。这一章咱们就聊聊地面分割、欧式聚类和区域生长,都是实战里最趁手的家伙。

点云分割核心方法 地面分割 欧式聚类 区域生长 RANSAC 平面拟合 迭代重加权最小二乘 高度阈值 + 法向量 去除地面,保留障碍物 KD-Tree 近邻搜索 半径阈值 + 最小点数 连通组件标记 实例级聚类 种子点选取 法向量 / 曲率相似 平滑约束生长 平面/曲面分割 核心算法 输出/应用

4.1 地面分割 —— RANSAC 与平面拟合

地面分割是点云感知的第一步,也是最重要的一步。你想想看,64线激光雷达一帧能扫出十几万个点,其中一半以上都是地面。如果不把地面去掉,后面的聚类根本没法看——地面和车辆会糊成一团。

RANSAC(随机采样一致性)是我最常用的地面分割方法。它的思路很暴力:随机选三个点,算一个平面,然后数有多少点落在这个平面上。重复几百次,选点数最多的那个平面作为地面。

核心流程:

  1. 从点云中随机采样3个点(不共线)
  2. 计算平面模型 ax+by+cz+d=0
  3. 计算所有点到该平面的距离,距离小于阈值 t 的点视为内点
  4. 重复步骤1-3,保留内点最多的平面
  5. 用所有内点重新拟合平面(可选加权最小二乘)

我个人习惯把距离阈值设在 0.1~0.2 米。太大会把路沿、矮障碍物也吞掉,太小又会导致地面漏检。我在项目里遇到过一段颠簸路面,RANSAC 死活拟合不出完整平面——后来加了高度先验,把种子点限制在自车附近,效果才稳下来。

// 伪代码:RANSAC 地面分割
int bestInliers = 0;
Plane bestPlane;
for (int i = 0; i < maxIterations; i++) {
    // 随机选3个点
    Point p1 = cloud[rand() % N];
    Point p2 = cloud[rand() % N];
    Point p3 = cloud[rand() % N];
    Plane plane = fitPlane(p1, p2, p3);
    int inliers = countInliers(cloud, plane, distThreshold);
    if (inliers > bestInliers) {
        bestInliers = inliers;
        bestPlane = plane;
    }
}
// 用所有内点重新优化平面
bestPlane = refinePlane(cloud, bestPlane, distThreshold);
💡 避坑指南: 我曾经把 RANSAC 迭代次数设成 50,结果在高速场景地面分割经常崩。后来改成 200 次,稳定性明显提升。另外,如果点云已经做了体素滤波,记得把距离阈值适当调小,因为下采样后的点间距变大了。

4.2 欧式聚类 —— 把障碍物一个个拎出来

地面去掉之后,剩下的点基本都是障碍物了。但怎么区分哪坨点是车、哪坨点是行人?欧式聚类就是干这个的。它的原理特别直观:如果两个点距离小于某个半径,就认为它们属于同一个物体。

我最早用 PCL 的 EuclideanClusterExtraction,调参调到头秃。核心参数就两个:聚类半径和最小点数。半径设大了,会把相邻的车粘在一起;设小了,一辆大卡车会被切成好几块。

⚠️ 注意: 欧式聚类对点云密度非常敏感。近处点密,远处点稀。如果你用固定半径,近处聚类过分割,远处欠分割。我一般会根据距离动态调整半径:radius = baseRadius + k * distance。

下面是一个典型的欧式聚类流程:

  • 构建 KD-Tree 加速近邻搜索
  • 选一个未访问的点作为种子
  • 在半径 r 内找邻居,加入当前簇
  • 对簇内每个点重复搜索,直到没有新点加入
  • 如果簇内点数 < minPoints,视为噪声丢弃
// 欧式聚类核心逻辑 (C++风格)
std::vector<Cluster> euclideanClustering(PointCloud& cloud, double radius, int minPts) {
    KDTree tree(cloud);
    std::vector<bool> visited(cloud.size(), false);
    std::vector<Cluster> clusters;
    for (int i = 0; i < cloud.size(); i++) {
        if (visited[i]) continue;
        Cluster cluster;
        std::queue<int> q;
        q.push(i);
        visited[i] = true;
        while (!q.empty()) {
            int idx = q.front(); q.pop();
            cluster.push_back(idx);
            auto neighbors = tree.radiusSearch(cloud[idx], radius);
            for (int n : neighbors) {
                if (!visited[n]) {
                    visited[n] = true;
                    q.push(n);
                }
            }
        }
        if (cluster.size() >= minPts)
            clusters.push_back(cluster);
    }
    return clusters;
}

4.3 区域生长 —— 让点云自己“长”出结构

区域生长和欧式聚类不同,它不光看距离,还看点的“长相”——法向量、曲率、颜色(如果有的话)。我一般用它来分割建筑物立面、树干这类有平滑表面的物体。

算法也不复杂:先选曲率最小的点作为种子(曲率小意味着局部平坦),然后检查邻居的法向量夹角。如果夹角小于阈值,就把邻居吞进来。不断生长,直到没有新点满足条件。

区域生长 vs 欧式聚类:

对比项欧式聚类区域生长
相似性度量欧氏距离法向量夹角 + 曲率 + 距离
适用场景任意形状物体平面/光滑曲面
参数敏感性半径、最小点数角度阈值、曲率阈值
计算速度快(KD-Tree)中等(需计算法向量)

我记得有一次做园区无人车,路边的灌木丛用欧式聚类总是分成好几块,因为枝叶之间有空隙。换成区域生长,把法向量平滑阈值设到 30°,居然把整棵灌木完整分割出来了。嗯,这里要注意:区域生长需要提前计算法向量,如果点云噪声大,法向量会不准,建议先做滤波。

🔧 实战建议: 我通常的管线是:
① 体素滤波下采样 → ② RANSAC 地面分割 → ③ 欧式聚类(动态半径)→ ④ 对聚类结果做 bounding box 提取。
如果场景里有大量平面结构(比如停车场),会在第③步之前加一个区域生长,把墙面、地面残留再滤一遍。

4.4 三种方法怎么选?

没有银弹。我个人的经验是:

  • 地面分割:RANSAC 是标配,简单可靠。如果路面起伏大,可以试试平面拟合+高度阈值双保险。
  • 欧式聚类:通用性最强,90% 的场景都能用。调参时先固定最小点数(比如 5~10),再调半径。
  • 区域生长:适合精细分割,比如区分相邻的车辆和行人(如果它们挨得很近)。但计算量稍大,实时性要求高的项目慎用。

最后说一句:点云分割没有标准答案。我在不同项目里用过完全不同的参数组合,甚至自己写过基于深度图的分割。但不管怎么变,理解每种方法背后的几何假设,才是调参的底气。