地面分割:从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不行,那换个思路。你想想看,雷达扫描一圈,每条射线从近到远扫过去。地面上的点,高度应该是逐渐上升的(因为雷达安装高度固定)。如果某个点突然比前一个点高很多,那它大概率是障碍物。
这就是射线坡度法的核心逻辑。我把它拆成三步:
- 极坐标转换:把点云从笛卡尔坐标转到极坐标,按角度分组
- 径向排序:每条射线上的点按距离排序
- 坡度判断:相邻点的高度差超过阈值,就标记为障碍物
代码实现大概是这样的:
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的代码实现比前两种复杂一些,但核心逻辑很清晰:
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。别一上来就搞复杂的,容易把自己绕进去。
最后说一句,地面分割没有银弹。每种方法都有自己的脾气,关键是要理解它们的假设和局限。我在项目中吃过不少亏,但每次踩坑都让我对算法理解更深一层。嗯,这就是工程实践的乐趣吧。