一句话总结

将三维点云压缩为三个高度带的轻量 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 实现

延伸阅读

  • BEV 感知综述BEVFusion,多模态融合的工程实践
  • Distribution Focal Loss 原始论文GFocal,理解 side offset 的概率建模
  • 旋转 IoU 损失PIoU Loss,旋转框回归的梯度稳定技巧
  • TriBand-BEV 官方代码GitHub 链接(论文中承诺开源)
  • KITTI 数据集的 3D 评测协议:官方 devkit 中 evaluate_object_3d_offline.cpp 值得仔细读