TriBand-BEV:用三通道高度编码让 LiDAR 行人检测跑到 49 FPS
一句话总结
将三维点云压缩为三个高度带的轻量 2D BEV 张量,再用双向特征金字塔融合细节与上下文——在消费级 GPU 上以 49 FPS 运行,行人检测超越 Complex-YOLO 最高 +12.6%。
为什么行人检测这么难?
3D 目标检测中,行人是公认的”硬骨头”:
- 点云稀疏:10 米外的行人可能只有 20-50 个点,汽车同距离有数百个点
- 尺寸小且相似:行人、自行车手的高度差异仅几十厘米
- 实时性要求高:自动驾驶要求 >20 FPS,机器人导航要求更高
现有方案的困境:
| 方案 | 精度 | 速度 | 问题 |
|---|---|---|---|
| PointPillars | 中 | 快 | 丢失高度信息 |
| VoxelNet | 高 | 慢 | 3D 卷积计算量大 |
| Complex-YOLO | 快 | 中 | 行人 AP 偏低 |
TriBand-BEV 的核心洞察:3D 信息并非全部有用,沿 Z 轴的三个关键高度带已足够区分人/车/骑手。
核心原理
直觉:为什么”三个高度带”够用?
想象你从正上方俯视停车场。你能分辨人和车,但看不到他们的高度。如果给你三张分层透视图——脚踝高度、腰部高度、头部高度——你立刻就能判断:只有脚踝层有点但腰部没有的,大概是矮障碍物;三层都有的,是站立的人。
TriBand-BEV 正是这个思路:把点云沿 Z 轴切成三段,每段独立统计,得到 [H, W, 3] 的 BEV 张量。
硬件层面:为什么 2D 卷积比 3D 快这么多?
在 GPU 上,3D 卷积的访存模式对缓存极不友好:
- 3D tensor
[D, H, W, C]的 spatial locality 差 - VoxelNet 类方案在 A100 上带宽利用率通常 <40%
2D BEV 卷积:
[H, W, C]在内存上连续,缓存命中率高- 可以直接复用成熟的 2D 检测框架(YOLO、FCOS)
TriBand 编码的三个通道
Z_low: 地面 ~ 0.5m (捕捉轮子、脚)
Z_mid: 0.5m ~ 1.5m (捕捉躯干)
Z_high: 1.5m ~ 2.5m (捕捉头部、车顶)
每个 cell 记录该高度带内点的最大反射率(而非点数),避免了近处密集点云造成的计数偏差。
代码实现
Baseline:朴素 BEV 编码(单通道)
import numpy as np
import torch
def naive_bev_encode(points, x_range=(-40, 40), y_range=(0, 70.4),
resolution=0.1, z_range=(-3, 1)):
"""
朴素实现:把所有高度的点压平到一个通道
问题:完全丢失高度信息,行人和矮障碍物无法区分
"""
W = int((x_range[1] - x_range[0]) / resolution)
H = int((y_range[1] - y_range[0]) / resolution)
bev = np.zeros((H, W), dtype=np.float32)
# 过滤范围外的点
mask = ((points[:, 0] > x_range[0]) & (points[:, 0] < x_range[1]) &
(points[:, 1] > y_range[0]) & (points[:, 1] < y_range[1]) &
(points[:, 2] > z_range[0]) & (points[:, 2] < z_range[1]))
pts = points[mask]
# 投影到 BEV 网格
xi = ((pts[:, 0] - x_range[0]) / resolution).astype(int)
yi = ((pts[:, 1] - y_range[0]) / resolution).astype(int)
bev[yi, xi] = np.maximum(bev[yi, xi], pts[:, 3]) # 取最大反射率
return bev # shape: [H, W]
问题分析:单通道 BEV 中,行人(窄、高)和柱子(窄、矮)的俯视形状几乎相同,网络完全靠反射率区分,召回率低。
TriBand-BEV 编码(核心优化)
def triband_bev_encode(points, x_range=(-40, 40), y_range=(0, 70.4),
resolution=0.1):
"""
三通道高度带编码:低/中/高三段各自统计最大反射率
输出:[3, H, W] 张量
"""
BANDS = [(-3.0, 0.5), (0.5, 1.5), (1.5, 2.5)] # 单位:米
W = int((x_range[1] - x_range[0]) / resolution)
H = int((y_range[1] - y_range[0]) / resolution)
bev = np.zeros((3, H, W), dtype=np.float32)
for band_idx, (z_lo, z_hi) in enumerate(BANDS):
mask = ((points[:, 0] > x_range[0]) & (points[:, 0] < x_range[1]) &
(points[:, 1] > y_range[0]) & (points[:, 1] < y_range[1]) &
(points[:, 2] >= z_lo) & (points[:, 2] < z_hi))
pts = points[mask]
if len(pts) == 0:
continue
xi = np.clip(((pts[:, 0] - x_range[0]) / resolution).astype(int), 0, W-1)
yi = np.clip(((pts[:, 1] - y_range[0]) / resolution).astype(int), 0, H-1)
# scatter max:同一格子取最大反射率
np.maximum.at(bev[band_idx], (yi, xi), pts[:, 3])
return torch.from_numpy(bev) # shape: [3, H, W]
为什么更快且更准:三通道编码完全不引入额外计算量(仍是 2D 操作),但给网络提供了隐式的高度线索,行人的 mid+high 通道激活模式与汽车(high 通道很强)截然不同。
IQR 滤波器:去除离群点云
论文用四分位距过滤噪声点,在 3D 重建前执行:
def iqr_filter_lidar(points, z_col=2, k=1.5):
"""
四分位距过滤 LiDAR 噪声点(玻璃反射、雨滴等)
只在 Z 轴上过滤,保留 XY 范围内的合理点
"""
z = points[:, z_col]
Q1, Q3 = np.percentile(z, 25), np.percentile(z, 75)
IQR = Q3 - Q1
lo, hi = Q1 - k * IQR, Q3 + k * IQR
return points[(z >= lo) & (z <= hi)]
常见坑:k=1.5 是标准设定,但点云场景中地面反射可能被误滤。建议先裁剪地面(z < -2.0 的点单独处理),再对剩余点应用 IQR。
网络骨干:Area Attention 的作用
Area Attention 是在特征图的局部矩形区域内做注意力,比全局 Self-Attention 计算量低 O(k²/HW) 倍:
import torch.nn as nn
import torch.nn.functional as F
class AreaAttention(nn.Module):
"""
在深层特征图上的局部区域注意力
替代全局 Self-Attention,降低计算量
适合 BEV 特征图(空间相关性主要在局部)
"""
def __init__(self, channels, area_size=7):
super().__init__()
self.area = area_size
self.qkv = nn.Conv2d(channels, channels * 3, 1)
self.proj = nn.Conv2d(channels, channels, 1)
def forward(self, x):
B, C, H, W = x.shape
qkv = self.qkv(x) # [B, 3C, H, W]
q, k, v = qkv.chunk(3, dim=1)
# 只在 area×area 窗口内做注意力
# 使用 unfold 提取局部块,省略完整实现...
attn = F.softmax(q * k / (C ** 0.5), dim=-1)
return self.proj(attn * v)
双向特征金字塔颈部(Bidirectional Neck)
P1→P4 自上而下 + 自下而上双向融合,确保小目标(行人)的细粒度特征不被大步幅卷积淹没:
class BidirectionalNeck(nn.Module):
"""
P1(大分辨率,小感受野) ↔ P4(小分辨率,大感受野) 双向融合
行人检测依赖 P1/P2 的细粒度特征
"""
def __init__(self, channels=[64, 128, 256, 512]):
super().__init__()
# 自上而下的上采样路径
self.up_convs = nn.ModuleList([
nn.ConvTranspose2d(channels[i+1], channels[i], 2, 2)
for i in range(len(channels)-1)
])
# 自下而上的下采样路径
self.down_convs = nn.ModuleList([
nn.Conv2d(channels[i], channels[i+1], 3, 2, 1)
for i in range(len(channels)-1)
])
def forward(self, feats):
# feats: [P1, P2, P3, P4],从高分辨率到低分辨率
# 第一步:自上而下
for i in range(len(feats)-2, -1, -1):
feats[i] = feats[i] + self.up_convs[i](feats[i+1])
# 第二步:自下而上
for i in range(len(feats)-1):
feats[i+1] = feats[i+1] + self.down_convs[i](feats[i])
return feats
从 BEV 输出重建 3D 框
检测头在 2D BEV 上预测,然后映射回 3D:
def bev_to_3d_boxes(bev_boxes, z_lo_band=(-3, 0.5), z_hi_band=(1.5, 2.5)):
"""
BEV 预测 [cx, cy, w, l, angle] → 3D 框 [cx, cy, cz, w, l, h, angle]
Z 中心和高度从高度带统计推断
"""
boxes_3d = []
for box in bev_boxes:
cx_bev, cy_bev, w, l, angle = box
# Z 范围:底面来自低带底部,顶面来自高带顶部
z_bottom = z_lo_band[0]
z_top = z_hi_band[1]
cz = (z_bottom + z_top) / 2.0
h = z_top - z_bottom
boxes_3d.append([cx_bev, cy_bev, cz, w, l, h, angle])
return np.array(boxes_3d)
注意:这里 z 范围是固定先验,对成人行人效果好。儿童或蹲下的人会有偏差——这是论文的局限之一。
性能实测
测试环境:RTX 3080(消费级 GPU),CUDA 12.1,KITTI val split
| 实现版本 | 行人 BEV AP (Easy/Mod/Hard) | FPS | 显存占用 |
|---|---|---|---|
| Complex-YOLO | 46.1 / 45.1 / 44.1 | 50 | ~2 GB |
| PointPillars | 59.2 / 53.2 / 48.0 | 28 | ~4 GB |
| TriBand-BEV | 58.7 / 52.6 / 47.2 | 49 | ~2.5 GB |
关键数据解读:
- 相比 Complex-YOLO:Easy 提升 +12.6%,Moderate +7.5%,Hard +3.1%
- 相比 PointPillars:速度提升 75%,精度略低(高度带先验的代价)
- Hard 案例提升最小:远距离稀疏行人,三带编码信息量不足
什么时候用 / 不用
| 适用场景 | 不适用场景 |
|---|---|
| 消费级/嵌入式 GPU(显存 ≤ 6 GB) | 需要精确高度估计的场景(如吊车臂检测) |
| 以行人/骑手为主的场景 | 点云极稀疏(< 16 线激光雷达) |
| 要求实时(>30 FPS)的机器人导航 | 需要检测趴下/坐轮椅等非站立姿态 |
| 地面平坦的结构化环境 | 山地/立体停车场等 Z 轴分布复杂场景 |
调试技巧
BEV 编码验证:先可视化三个通道,确认高度带切分合理:
import matplotlib.pyplot as plt
bev = triband_bev_encode(points)
fig, axes = plt.subplots(1, 3)
titles = ['Low (0~0.5m)', 'Mid (0.5~1.5m)', 'High (1.5~2.5m)']
for i, (ax, title) in enumerate(zip(axes, titles)):
ax.imshow(bev[i].numpy(), cmap='hot', origin='lower')
ax.set_title(title)
plt.savefig('triband_debug.png')
常见 Bug:
np.maximum.at在大点云上慢:可换 GPU 上的torch.scatter_reduce(需 PyTorch ≥ 2.0)- IQR 过滤后空点云崩溃:加
if len(points) == 0: return empty_bev - 旋转 IoU 损失数值不稳定:角度差超过 π 时梯度爆炸,需做角度归一化
Nsight 分析重点:
- BEV 编码的 scatter 操作通常是瓶颈(原子操作多)
- 骨干 Area Attention 的 unfold 操作注意 bank conflict
- 检测头的 Rotated-IoU 计算建议用 cuOSD 库的 CUDA 实现
Comments