第四章:地面分割——让激光雷达看懂“路”在哪里
地面分割这件事,说白了就是让激光雷达分清「哪里是路,哪里是障碍物」。
我刚开始做自动驾驶感知时,犯过一个低级错误——直接把所有点云送进聚类算法。结果呢?地面被当成了一堵墙,车吓得不敢动。嗯,从那以后我明白了:地面分割是障碍物检测的第一步,也是最重要的一步。
核心观点:地面分割不是可选项,而是必选项。不做地面分割,后续的聚类、跟踪、预测全是空中楼阁。
4.1 地面分割的两种主流思路
目前工业界常用的地面分割方法,主要有两派:
- 基于模型的方法:假设地面是一个平面(或几个平面),用RANSAC之类的算法去拟合。简单粗暴,但有效。
- 基于几何的方法:利用激光雷达扫描线的几何特性,比如射线坡度、相邻点高度差。PatchWork就是这类方法的代表。
我个人习惯把这两种方法结合起来用。为什么?因为单一方法总有翻车的时候。我在项目中遇到过,RANSAC在颠簸路面会误分割,而纯几何方法在陡坡上又容易漏检。两者互补,才是王道。
4.2 基于RANSAC的平面拟合
RANSAC(Random Sample Consensus)是一种迭代式的模型拟合算法。它的核心思想很简单:从数据中随机采样,找到最能代表「大多数」的那个模型。
4.2.1 算法流程
- 从点云中随机选3个点(三点确定一个平面)
- 计算这3个点构成的平面方程
- 计算所有点到这个平面的距离
- 统计距离小于阈值的内点数量
- 重复步骤1-4,保留内点最多的那个平面
- 用所有内点重新拟合平面(最小二乘优化)
我的经验:阈值怎么设?我一般取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的核心逻辑:
4.3.4 PatchWork的优缺点
| 优点 | 缺点 |
|---|---|
| 对坡道、起伏路面鲁棒性好 | 计算量比纯RANSAC大 |
| 远距离也能保持较好的分割效果 | 参数调优需要经验(分区边界、坡度阈值) |
| 不需要假设地面是平面 | 对激光雷达线束密度敏感 |
避坑指南:我曾经在16线激光雷达上跑PatchWork,发现远区(30米外)的点太稀疏,射线坡度法根本算不准。后来我把远区的阈值从8度放宽到15度,效果才勉强能看。如果你用的是低线束雷达,建议把远区范围缩小到20米以内。
4.4 两种方法的对比与选择
说了这么多,到底该用哪种?我个人的建议是:
- 场景平坦、结构简单(如高速公路):RANSAC就够了,简单高效
- 场景复杂、有坡道起伏(如城市道路、矿山):用PatchWork
- 追求极致鲁棒性:两者结合——先用RANSAC粗分割,再用PatchWork精修
我在实际项目中,通常把RANSAC作为「快速初筛」,把PatchWork作为「精细分割」。这样既保证了实时性,又兼顾了准确性。
一句话总结:地面分割没有银弹。理解每种方法的假设和局限,根据场景灵活组合,才是工程师该有的思维方式。
好了,地面分割的原理和实战就讲到这里。代码我已经放在课程配套的仓库里了,建议你拿自己的数据跑一跑,看看效果。嗯,动手才是最好的学习方式。