第四章:地面分割——让激光雷达看懂“路”在哪里

地面分割这件事,说白了就是让激光雷达分清「哪里是路,哪里是障碍物」。

我刚开始做自动驾驶感知时,犯过一个低级错误——直接把所有点云送进聚类算法。结果呢?地面被当成了一堵墙,车吓得不敢动。嗯,从那以后我明白了:地面分割是障碍物检测的第一步,也是最重要的一步。

核心观点:地面分割不是可选项,而是必选项。不做地面分割,后续的聚类、跟踪、预测全是空中楼阁。

4.1 地面分割的两种主流思路

目前工业界常用的地面分割方法,主要有两派:

  • 基于模型的方法:假设地面是一个平面(或几个平面),用RANSAC之类的算法去拟合。简单粗暴,但有效。
  • 基于几何的方法:利用激光雷达扫描线的几何特性,比如射线坡度、相邻点高度差。PatchWork就是这类方法的代表。

我个人习惯把这两种方法结合起来用。为什么?因为单一方法总有翻车的时候。我在项目中遇到过,RANSAC在颠簸路面会误分割,而纯几何方法在陡坡上又容易漏检。两者互补,才是王道。

4.2 基于RANSAC的平面拟合

RANSAC(Random Sample Consensus)是一种迭代式的模型拟合算法。它的核心思想很简单:从数据中随机采样,找到最能代表「大多数」的那个模型。

4.2.1 算法流程

  1. 从点云中随机选3个点(三点确定一个平面)
  2. 计算这3个点构成的平面方程
  3. 计算所有点到这个平面的距离
  4. 统计距离小于阈值的内点数量
  5. 重复步骤1-4,保留内点最多的那个平面
  6. 用所有内点重新拟合平面(最小二乘优化)

我的经验:阈值怎么设?我一般取0.1~0.2米。太大会把矮障碍物也当成地面,太小则地面分割不完整。你可以根据激光雷达的安装高度和路面平整度来微调。

4.2.2 代码实现

import numpy as np
import open3d as o3d

def ransac_plane_segmentation(pcd, distance_threshold=0.15, ransac_n=3, num_iterations=1000):
    """
    基于RANSAC的地面分割
    :param pcd: open3d点云对象
    :param distance_threshold: 点到平面的距离阈值
    :param ransac_n: 每次采样点数
    :param num_iterations: 迭代次数
    :return: 地面点云, 非地面点云
    """
    plane_model, inliers = pcd.segment_plane(
        distance_threshold=distance_threshold,
        ransac_n=ransac_n,
        num_iterations=num_iterations
    )
    
    # 提取地面点
    ground_cloud = pcd.select_by_index(inliers)
    # 提取非地面点(障碍物)
    obstacle_cloud = pcd.select_by_index(inliers, invert=True)
    
    return ground_cloud, obstacle_cloud

# 使用示例
pcd = o3d.io.read_point_cloud("lidar_scan.pcd")
ground, obstacles = ransac_plane_segmentation(pcd)
print(f"地面点: {len(ground.points)}, 障碍物点: {len(obstacles.points)}")

注意:RANSAC假设地面是单一平面。如果遇到坡道、减速带、坑洼路面,单一平面模型会失效。我曾经在矿山场景吃过这个亏——地面起伏超过30厘米,RANSAC直接把半个坡面当成了障碍物。

4.3 基于射线坡度的地面分割(PatchWork)

PatchWork是2021年提出的一种地面分割算法,名字很形象——像拼布一样,把地面分成一块一块来处理。

它的核心思想是:激光雷达的每条扫描线,在水平方向上是连续的。地面上的点,相邻射线的坡度变化应该很小。

4.3.1 算法原理

PatchWork把点云分成三个区域(近、中、远),每个区域用不同的策略处理:

区域 距离范围 处理策略
近区(Zone 1) 0~15米 用RANSAC拟合局部平面,坡度阈值严格
中区(Zone 2) 15~30米 用射线坡度法,允许一定坡度变化
远区(Zone 3) 30米以上 用射线坡度法,阈值放宽

为什么要分区?你想想看,近处的点密集、精度高,远处的点稀疏、噪声大。用同一套参数处理所有区域,肯定不合理。

4.3.2 射线坡度计算

对于每条扫描线,我们计算相邻点之间的坡度角:

def compute_ray_slope(points_ring):
    """
    计算一条扫描线上相邻点的坡度
    :param points_ring: 同一扫描线上的点云(按角度排序)
    :return: 每个点的坡度角(弧度)
    """
    slopes = []
    for i in range(1, len(points_ring)):
        p_prev = points_ring[i-1]
        p_curr = points_ring[i]
        
        # 计算水平距离和高度差
        d_horizontal = np.linalg.norm(p_curr[:2] - p_prev[:2])
        d_vertical = p_curr[2] - p_prev[2]
        
        # 坡度角 = arctan(高度差 / 水平距离)
        if d_horizontal > 0.01:  # 避免除零
            slope = np.arctan2(d_vertical, d_horizontal)
            slopes.append(slope)
        else:
            slopes.append(0.0)
    
    return np.array(slopes)

关键参数:坡度阈值一般设为5~10度。超过这个值,说明点与点之间发生了「跳跃」,大概率是障碍物。

4.3.3 PatchWork的完整流程

下面这张图展示了PatchWork的核心逻辑:

PatchWork地面分割算法流程图 原始点云输入 按距离分区(近/中/远) 近区(0~15m) RANSAC平面拟合 中区(15~30m) 射线坡度法(严格) 远区(30m+) 射线坡度法(宽松) 合并各区域地面点 地面点 + 障碍物点

4.3.4 PatchWork的优缺点

优点 缺点
对坡道、起伏路面鲁棒性好 计算量比纯RANSAC大
远距离也能保持较好的分割效果 参数调优需要经验(分区边界、坡度阈值)
不需要假设地面是平面 对激光雷达线束密度敏感

避坑指南:我曾经在16线激光雷达上跑PatchWork,发现远区(30米外)的点太稀疏,射线坡度法根本算不准。后来我把远区的阈值从8度放宽到15度,效果才勉强能看。如果你用的是低线束雷达,建议把远区范围缩小到20米以内。

4.4 两种方法的对比与选择

说了这么多,到底该用哪种?我个人的建议是:

  • 场景平坦、结构简单(如高速公路):RANSAC就够了,简单高效
  • 场景复杂、有坡道起伏(如城市道路、矿山):用PatchWork
  • 追求极致鲁棒性:两者结合——先用RANSAC粗分割,再用PatchWork精修

我在实际项目中,通常把RANSAC作为「快速初筛」,把PatchWork作为「精细分割」。这样既保证了实时性,又兼顾了准确性。

一句话总结:地面分割没有银弹。理解每种方法的假设和局限,根据场景灵活组合,才是工程师该有的思维方式。

好了,地面分割的原理和实战就讲到这里。代码我已经放在课程配套的仓库里了,建议你拿自己的数据跑一跑,看看效果。嗯,动手才是最好的学习方式。

可可精品资料库