4. 2D/3D障碍物检测:从YOLO到PointPillars的实战之路
各位同学,欢迎来到第四章。这一章我们聊聊障碍物检测,说白了就是让车“看见”周围的东西。2D检测用YOLO,3D检测用PointPillars或PointRCNN,再加上检测框生成和NMS——这套组合拳打好了,你的感知系统才算真正入门。
我个人习惯把障碍物检测分成两条线:一条是2D图像上的框,另一条是3D空间中的框。两条线各有各的坑,咱们一个一个来填。
4.1 YOLO系列:2D目标检测的扛把子
YOLO(You Only Look Once)这名字起得好,看一眼就完事。它把检测问题当成回归问题来做,一张图进去,直接输出边界框和类别概率。
我在项目中用过YOLOv3、v5和v8。说实话,v3虽然老,但稳定;v5速度快,适合嵌入式;v8精度高,但吃显存。怎么选?看你的硬件条件。
核心思想:YOLO把图像划分成S×S的网格,每个网格负责预测中心点落在该网格内的物体。每个网格预测B个边界框和C个类别概率。
举个例子,假设输入是416×416的图像,网格是13×13,每个网格预测3个框,类别有80种。那么输出就是13×13×(3×(4+1+80)) = 13×13×255 的张量。其中4是框坐标(x,y,w,h),1是置信度,80是类别概率。
# YOLOv5 检测后处理伪代码
def postprocess_yolo(prediction, conf_thres=0.25, iou_thres=0.45):
# prediction: [batch, num_anchors, (x,y,w,h,obj_conf,cls_conf)]
# 1. 过滤低置信度
mask = prediction[..., 4] > conf_thres
detections = prediction[mask]
# 2. 解码边界框(从网格坐标到图像坐标)
boxes = decode_boxes(detections)
# 3. NMS去除重复框
keep = nms(boxes, detections[..., 4], iou_thres)
return boxes[keep], detections[keep][..., 5:]
嗯,这里要注意:YOLO的坐标是相对于网格的偏移量,解码时要乘上网格的步长。我曾经在这里栽过跟头,输出框全跑到图像外面去了。
4.2 PointPillars与PointRCNN:3D检测的两条路
2D检测只能告诉你“那里有辆车”,但3D检测能告诉你“那辆车在哪个位置、多长多宽、朝向哪边”。这对自动驾驶来说至关重要。
PointPillars和PointRCNN是两种典型思路。前者把点云投影成伪图像,用2D卷积处理;后者直接在点云上做3D卷积。说白了,一个取巧,一个硬刚。
4.2.1 PointPillars:把点云变成“图像”
PointPillars的核心是把点云沿着Z轴方向切成一个个柱子(Pillar),每个柱子内的点云特征被聚合起来,形成一个伪图像。然后就可以用2D的检测头了。
我为什么喜欢PointPillars?因为它快。在Jetson Orin上能跑到30FPS以上,而且精度不差。但有个缺点:对高度方向的信息压缩太狠,小物体容易漏检。
# PointPillars 点云到伪图像的转换(简化版)
def pointcloud_to_pseudo_image(points, grid_size=(0.16, 0.16), x_range=(-40, 40), y_range=(-40, 40)):
# 1. 计算每个点所在的网格索引
x_idx = ((points[:, 0] - x_range[0]) / grid_size[0]).astype(int)
y_idx = ((points[:, 1] - y_range[0]) / grid_size[1]).astype(int)
# 2. 按网格分组,每个网格内的点构成一个Pillar
pillars = {}
for i in range(len(points)):
key = (x_idx[i], y_idx[i])
if key not in pillars:
pillars[key] = []
pillars[key].append(points[i])
# 3. 对每个Pillar提取特征(均值、最大值等)
pseudo_image = np.zeros((max_x, max_y, feature_dim))
for (x, y), pts in pillars.items():
pillar_feat = extract_pillar_features(pts)
pseudo_image[x, y] = pillar_feat
return pseudo_image
避坑指南:我曾经在PointPillars的Pillar数量上吃过亏。Pillar太多,显存爆炸;Pillar太少,特征丢失。建议每个样本的Pillar数量控制在12000-16000之间,超出部分随机采样,不足部分补零。
4.2.2 PointRCNN:直接在点云上做检测
PointRCNN走的是另一条路——直接在原始点云上做3D检测。它先用PointNet++提取点云特征,然后生成3D候选框,最后做框的细化。
说实话,PointRCNN精度确实高,在KITTI数据集上能刷榜。但速度是个硬伤,我试过在2080Ti上跑,也就10FPS左右。所以它更适合离线处理或高精度要求的场景。
为什么会这样?因为PointRCNN需要对每个点做特征提取,计算量太大了。而PointPillars通过投影大大降低了计算维度。
4.3 检测框(Bounding Box)生成
不管是2D还是3D,最终都要输出检测框。2D框是(x, y, w, h),3D框是(x, y, z, w, l, h, yaw)。
我个人习惯把框生成分成两步:第一步是粗定位,第二步是精调。粗定位用Anchor或者Center-based的方法,精调用回归头。
| 维度 | 参数 | 说明 |
|---|---|---|
| 2D框 | (x, y, w, h) | 中心点坐标、宽、高 |
| 3D框 | (x, y, z, w, l, h, yaw) | 中心点坐标、宽、长、高、朝向角 |
这里有个细节:3D框的朝向角yaw,范围通常是[-π, π]。但网络输出的是残差,需要加上Anchor的朝向。我刚开始做的时候没注意这个,结果框的方向全反了。
4.4 非极大值抑制(NMS)
NMS是检测的最后一道关卡。它的作用很简单:把重复的框去掉,只保留最好的那个。
标准NMS的流程是这样的:
- 把所有框按置信度从高到低排序
- 选置信度最高的框,保留它
- 计算它和其他框的IoU,把IoU大于阈值的框删掉
- 重复步骤2-3,直到没有框剩下
def nms(boxes, scores, iou_threshold=0.5):
# boxes: [N, 4] 格式为 (x1, y1, x2, y2)
# scores: [N]
# 按置信度排序
order = scores.argsort()[::-1]
keep = []
while order.size > 0:
i = order[0]
keep.append(i)
# 计算当前框与其余框的IoU
ious = compute_iou(boxes[i], boxes[order[1:]])
# 保留IoU小于阈值的框
inds = np.where(ious <= iou_threshold)[0]
order = order[inds + 1]
return keep
注意:标准NMS有个问题——如果两个物体靠得很近,IoU很大,其中一个会被误删。我在高速场景遇到过这种情况,两辆车并排行驶,结果只检测出一辆。后来改用Soft-NMS,效果好了很多。
Soft-NMS的思路是:不直接删除IoU大的框,而是降低它们的置信度。这样既保留了重复框,又不会让低质量的框干扰结果。
4.5 本章知识体系
下面这张图展示了本章的核心逻辑:从传感器数据到最终检测框的完整流程。
从图中可以看到,2D和3D检测最终都会汇聚到检测框生成和NMS。不管你用哪种方法,最后一步都是去重和精调。
我的建议:初学者先从2D检测入手,把YOLO玩透了再碰3D。3D检测的坑比2D多得多,光是点云预处理就能让你调一整天。我当年第一次跑PointPillars,光是把点云数据格式搞对就花了三天。
好了,这一章的内容就到这里。记住:检测不是终点,关联才是。下一章我们会讲如何把检测到的障碍物在时间维度上关联起来,那才是真正的挑战。
可可精品资料库