地面分割:从RANSAC到Patchwork

地面分割这件事,说白了就是让雷达知道「哪是路,哪是障碍物」。我刚开始做自动驾驶时,觉得这步很简单——地面不就是个平面吗?后来在实际项目中被狠狠教育了一顿。嗯,今天咱们就来聊聊三种主流的地面分割方法,从最经典的RANSAC,到工程上常用的射线坡度法,再到目前效果最好的Patchwork。

一、RANSAC平面拟合:最朴素的想法

RANSAC(Random Sample Consensus)的思路特别直白:地面大概率是一个大平面,那我们就从点云里随机找三个点,拟合出一个平面,然后看看有多少点落在这个平面上。重复这个过程,找到点数最多的那个平面,就是地面。

核心思想:随机采样 + 一致性检验。简单粗暴,但有效。

代码实现其实不复杂,我贴一段核心逻辑:

def ransac_plane_fitting(points, max_iter=100, distance_threshold=0.2):
    best_plane = None
    best_inliers = []
    
    for _ in range(max_iter):
        # 随机选3个点
        idx = np.random.choice(len(points), 3, replace=False)
        p1, p2, p3 = points[idx]
        
        # 计算平面参数
        normal = np.cross(p2 - p1, p3 - p1)
        d = -np.dot(normal, p1)
        
        # 计算所有点到平面的距离
        distances = np.abs(np.dot(points, normal) + d) / np.linalg.norm(normal)
        inliers = points[distances < distance_threshold]
        
        if len(inliers) > len(best_inliers):
            best_inliers = inliers
            best_plane = (normal, d)
    
    return best_plane, best_inliers

这段代码看着简单,但实际用起来坑不少。我曾经在一个矿区项目里用RANSAC做地面分割,结果雷达一照到斜坡,算法直接把斜坡当成了地面——因为斜坡也是个平面啊!

避坑指南:RANSAC假设地面是平面,但现实世界哪有那么平?遇到坡道、减速带、路沿,RANSAC很容易翻车。我曾经在高速场景测试,RANSAC把路肩当成了地面,差点导致车辆误判。

RANSAC的优点很明显:实现简单,计算快。缺点也致命:对非平面地面束手无策,而且迭代次数不好调——太少找不到地面,太多又浪费时间。

二、射线坡度法(Ray Ground Filter):工程界的宠儿

RANSAC不行,那换个思路。你想想看,雷达扫描一圈,每条射线从近到远扫过去。地面上的点,高度应该是逐渐上升的(因为雷达安装高度固定)。如果某个点突然比前一个点高很多,那它大概率是障碍物。

这就是射线坡度法的核心逻辑。我把它拆成三步:

  1. 极坐标转换:把点云从笛卡尔坐标转到极坐标,按角度分组
  2. 径向排序:每条射线上的点按距离排序
  3. 坡度判断:相邻点的高度差超过阈值,就标记为障碍物

代码实现大概是这样的:

def ray_ground_filter(points, angle_resolution=0.5, slope_threshold=0.3):
    # 转极坐标
    angles = np.arctan2(points[:, 1], points[:, 0])
    ranges = np.sqrt(points[:, 0]**2 + points[:, 1]**2)
    
    # 按角度分组
    angle_bins = np.arange(-np.pi, np.pi, np.deg2rad(angle_resolution))
    ground_mask = np.zeros(len(points), dtype=bool)
    
    for i in range(len(angle_bins) - 1):
        mask = (angles >= angle_bins[i]) & (angles < angle_bins[i+1])
        bin_points = points[mask]
        bin_ranges = ranges[mask]
        
        # 按距离排序
        sorted_idx = np.argsort(bin_ranges)
        sorted_points = bin_points[sorted_idx]
        
        # 坡度判断
        for j in range(1, len(sorted_points)):
            dz = sorted_points[j, 2] - sorted_points[j-1, 2]
            dr = np.linalg.norm(sorted_points[j, :2] - sorted_points[j-1, :2])
            slope = dz / dr if dr > 0 else 0
            
            if slope < slope_threshold:
                ground_mask[np.where(mask)[0][sorted_idx[j]]] = True
    
    return ground_mask

这个方法我在实际项目中用了很久。说实话,它比RANSAC靠谱多了。特别是城市道路场景,路面基本平坦,射线坡度法能稳定地把地面和障碍物分开。

个人经验:射线坡度法有两个参数很关键:角度分辨率和坡度阈值。我一般把角度分辨率设在0.5°~1°,坡度阈值设在0.2~0.4。但具体数值得根据雷达型号和安装高度调。记得有一次换了台雷达,同样的参数直接崩了——因为新雷达的垂直视场角不一样。

不过射线坡度法也有短板。遇到陡坡或者颠簸路面,它会把地面点误判为障碍物。还有就是,它假设地面是连续的,如果中间有个大坑...嗯,你懂的。

三、Patchwork地面分割:目前的最优解

前面两种方法各有各的问题。RANSAC太理想化,射线坡度法又太依赖局部连续性。那有没有一种方法,既能处理非平面地面,又能保持鲁棒性?

Patchwork就是干这个的。它的思路很巧妙:把整个点云空间划分成一个个小区域(patch),每个区域独立做地面分割,最后再合并。这样即使某个区域的地面不平,也不影响其他区域。

我画了一张图来展示Patchwork的核心流程:

Patchwork地面分割核心流程 原始点云 极坐标网格划分 (环形+扇形分区) 局部平面拟合 (每个patch独立RANSAC) 地面/非地面 分类结果 关键步骤详解: 1. 极坐标网格划分:将点云按距离和角度分成多个环形区域 2. 每个环形区域再按角度分成扇形,形成一个个patch 3. 每个patch独立进行RANSAC平面拟合,判断是否为地面 4. 考虑patch之间的连续性,平滑地面边界 5. 合并所有地面patch,得到最终地面点云 优势:能处理非平面地面、陡坡、颠簸路面,鲁棒性极强

Patchwork的代码实现比前两种复杂一些,但核心逻辑很清晰:

class PatchworkGroundSegmentation:
    def __init__(self, num_rings=20, num_sectors=40):
        self.num_rings = num_rings
        self.num_sectors = num_sectors
        
    def segment(self, points):
        # 1. 极坐标网格划分
        rings, sectors = self._polar_grid(points)
        
        # 2. 每个patch独立处理
        ground_mask = np.zeros(len(points), dtype=bool)
        for ring in range(self.num_rings):
            for sector in range(self.num_sectors):
                mask = (rings == ring) & (sectors == sector)
                patch_points = points[mask]
                
                if len(patch_points) < 10:
                    continue
                
                # 3. 局部RANSAC拟合
                plane, inliers = ransac_plane_fitting(patch_points)
                
                # 4. 判断是否为地面
                if self._is_ground(plane, ring):
                    ground_mask[mask] = inliers
        
        return ground_mask

为什么Patchwork效果好?因为它把「全局平面假设」换成了「局部平面假设」。每个patch很小,地面近似为平面这个假设就合理多了。再加上环形分区考虑了雷达的物理特性——近处点密,远处点稀。

我在一个复杂场景测试过这三种方法:城市道路、高速公路、乡村土路、地下停车场。RANSAC在土路上直接崩溃,射线坡度法在停车场表现不佳(因为天花板反射),只有Patchwork在所有场景都稳定输出。

三种方法对比

方法 适用场景 计算速度 鲁棒性 实现难度
RANSAC 平坦路面 简单
射线坡度法 城市道路 中等 中等 中等
Patchwork 全场景 较慢 复杂

我的建议:如果项目刚起步,先用射线坡度法快速验证。等到了量产阶段,再上Patchwork。别一上来就搞复杂的,容易把自己绕进去。

最后说一句,地面分割没有银弹。每种方法都有自己的脾气,关键是要理解它们的假设和局限。我在项目中吃过不少亏,但每次踩坑都让我对算法理解更深一层。嗯,这就是工程实践的乐趣吧。

专注资料整理