BEVDet 算法详细解读 - 全网最全攻略
一、介绍

BEVDet 算法是鉴智机器人21 年开源的 BEV 感知算法。该论文研究将 LSS 算法应用到 BEV 3D 目标检测中。提出四阶段的范式:图像编码器、视图转换器、BEV 编码器、3D 目标检测头。并针对 3D 实际场景,开先河的提出 BEV 数据增强和 Scale NMS。
论文:https://arxiv.org/abs/2112.11790
官方代码:https://github.com/HuangJunJie2017/BEVDet
二、核心亮点
Framework 模块化的设计思想:和 BEV 语义分割框架类似,包括四个部分(Image-view Encoder、View Transformer、BEV Encoder、 Task-specific Head)
改进策略 1:在 BEV 空间中进行额外的数据增强。
改进策略 2: 改进 NMS,提高三维场景适应性。
三、 模块化结构设计

BEVDet 模块化设计: 由图像编码器、视图转换器、BEV编码器、 任务头四部分组成。
3.1. 图像编码器(Image-view Encoder)
作用:将图像编码为高维度特征。
方法:为了获得多分辨率特征,使用 backbone 提取高维特征,neck 对多分辨率特征进行融合。

多尺度特征的分辨率如下:
L0 = Tensor([bs * N,1024,H / 16,W / 16])
L1 = Tensor([bs * N,2048,H / 32,W / 32])
ResNet-50 提取多尺度图像特征图
# anaconda3/envs/bevdet/lib/python3.8/site-packages/mmdet/models/backbones/resnet.py
def forward(self, x):
"""Forward function."""
if self.deep_stem:
x = self.stem(x)
else:
x = self.conv1(x)
x = self.norm1(x)
x = self.relu(x)
x = self.maxpool(x)
outs = []
for i, layer_name in enumerate(self.res_layers):
res_layer = getattr(self, layer_name)
x = res_layer(x)
if i in self.out_indices:
outs.append(x) #输出特征层layer3【6,1024,26,44】,layer4【6,2048,13,22】
return tuple(outs)
neck 将多分辨率特征融合:
对主干网络提取的多尺度特征进行特征融合,融合后的特征为 Tensor([bs,N,512,H / 16,W / 16])。特征融合部分的数学描述如下:

其中 Up 代表上采样, Conv1×1 用于降维, Conv3×3 用于对融合后的特征图进一步提取特征。
# mmdet3d/models/detectors/bevdet.py
def image_encoder(self, img, stereo=False):
...
# Neck层
if self.with_img_neck: #True
x = self.img_neck(x)
if type(x) in [list, tuple]:
x = x[0]
#_, 256, 16, 44
_, output_dim, ouput_H, output_W = x.shape
x = x.view(B, N, output_dim, ouput_H, output_W)
3.2. 视图转换器(View Transformer)

作用:将图像特征映射到鸟瞰图坐标
3.2.1. 深度预测(Lift 操作)
对每个图像像素点,预测其深度分布概率。深度范围【1m, 60m】。
深度估计网络(DepthNet): 1x1 卷积;得到深度特征图和语义特征图,深度特征 softmax 得到深度概率分布。
# mmdet3d/models/necks/view_transformer.py
def forward():...
self.depth_net = nn.Conv2d(
in_channels, self.D + self.out_channels, kernel_size=1, padding=0)
#Conv2d(256, (D=59)+64, kernel_size=(1, 1), stride=(1, 1))
depth_digit = x[:, :self.D, ...]#前59层为深度图
tran_feat = x[:, self.D:self.D + self.out_channels, ...]#后64层为特征图
depth = depth_digit.softmax(dim=1) # 深度图softmax得到深度概率
3.2.2. 视锥坐标点->车体的坐标转换
图像视锥生成见 LSS(略)


# mmdet3d/models/necks/view_transformer.py
def get_lidar_coor(self, sensor2ego, ego2global, cam2imgs, post_rots, post_trans,
bda):
B, N, _, _ = sensor2ego.shape
#----------------------------------
#-1.图像坐标(增强后)->(减去平移)->(去除旋转)->矫正到图像增强前的坐标系)
# B x N x D x H x W x 3
#----------------------------------
points = self.frustum.to(sensor2ego) - post_trans.view(B, N, 1, 1, 1, 3) #视锥去除图像增强的平移
points = torch.inverse(post_rots).view(B, N, 1, 1, 1, 3, 3)
.matmul(points.unsqueeze(-1)) #视锥去除图像增强的旋转
# --------------------------------
#-2.图像坐标系->透视逆变换*depth->相机坐标系
#---------------------------------
'''
points:(B, N, D, H, W, 3, 1)。视为 (..., 3, 1)
points[..., :2, :] :取前两个分量(u,v) ,points[..., 2:3, :] :取第三个分量(depth)
points[..., :2, :] * points[..., 2:3, :] : 将u,v与depth相乘,得到(u*depth, v*depth),得到相机坐标系下的坐标(立柱->视锥):Xc.Yc.Zc,Z轴沿着=光轴位置
torch.cat(..., 5) : 将Xc.Yc.Zc 拼接, 维度为(B, N, D, H, W, 3)
'''
points = torch.cat(
(points[..., :2, :] * points[..., 2:3, :], points[..., 2:3, :]), 5) #图像坐标系->相机坐标系
# --------------------------------
#-3. 相机坐标系->旋转平移->ego坐标系
#---------------------------------
combine = sensor2ego[:,:,:3,:3].matmul(torch.inverse(cam2imgs)) # combine = R_{cam->ego} * 内参取逆K^-1,
points = combine.view(B, N, 1, 1, 1, 3, 3).matmul(points).squeeze(-1) # 相机坐标系->世界坐标系(旋转)
points += sensor2ego[:,:,:3, 3].view(B, N, 1, 1, 1, 3) # 加上平移
# --------------------------------
#-4.BEV 数据增强 车体坐标系->BDA(旋转+平移)
#---------------------------------
points = bda[:, :3, :3].view(B, 1, 1, 1, 1, 3, 3).matmul(
points.unsqueeze(-1)).squeeze(-1) # 在平面上旋转
points += bda[:, :3, 3].view(B, 1, 1, 1, 1, 3) # 加上平移
return points
3.2.3. BEV 池化
(详细见 LSS)

# 调用CUDA内核,实现并行计算
bev_pool_v2_ext.bev_pool_v2_forward(
depth, #深度图
feat, #特征图
out, #输出
ranks_depth, #排序后的点的原始空间索引
ranks_feat, # ranks_feat: 特征回溯,排序后点的特征空间索引。核心作用: 至关重要! 在池化时,通过它可以找到该点对应的原始 2D 图像特征向量(来自哪个 Batch、哪个相机、哪个特征图位置 (h, w))。同一个 (b, n, h, w) 位置的所有深度采样点共享相同的 ranks_feat,方便后续操作。
ranks_bev, #排序后的体素唯一标识数组,相同值标识同一个体素
interval_lengths, #每个体素包含的点序列的长度
interval_starts,#排序后,每个体素包含的点序列的开始索引
)#每个CUDA线程处理一个体素
ctx.save_for_backward(ranks_bev, depth, feat, ranks_feat, ranks_depth)
3.3. BEV 编码器(BEV Encoder)

参数定义
# configs/bevdet/bevdet-r50.py
numC_Trans = 64
img_bev_encoder_backbone=dict(
type='CustomResNet',
numC_input=numC_Trans,
num_channels=[numC_Trans * 2, numC_Trans * 4, numC_Trans * 8]),
img_bev_encoder_neck=dict(
type='FPN_LSS',
in_channels=numC_Trans * 8 + numC_Trans * 2,
out_channels=256),
BEV 编码器网络结构
#mmdet3d/models/detectors/bevdet.py
def bev_encoder(self, x):
x = self.img_bev_encoder_backbone(x)
x = self.img_bev_encoder_neck(x)
if type(x) in [list, tuple]:
x = x[0]
return x
提取多尺度特征:mmdet3d/models/backbones/resnet.py
def forward(self, x):
feats = []
x_tmp = x
for lid, layer in enumerate(self.layers):
if self.with_cp:
x_tmp = checkpoint.checkpoint(layer, x_tmp)
else:
x_tmp = layer(x_tmp)
if lid in self.backbone_output_ids:
feats.append(x_tmp)
return feats
多尺度特征融合:mmdet3d/models/necks/lss_fpn.py
def forward(self, feats):
x2, x1 = feats[self.input_feature_index[0]], #B0 = Tensor([bs,128,64,64])
feats[self.input_feature_index[1]] #B1 = Tensor([bs,256,32,32])
if self.lateral:
x2 = self.lateral_conv(x2)
x1 = self.up(x1) #[([bs,256,32,32])] -> up-> x1=([bs, 512, 64, 64])
x = torch.cat([x2, x1], dim=1) #拼接: ([bs, 640, 64, 64])
if self.input_conv is not None:
x = self.input_conv(x)
x = self.conv(x) #通道变换:([bs, 512,64,64])
if self.extra_upsample:
x = self.up2(x) #上采样([bs, 256, 128,128])
return x
3.4. 3D Detection Head

3.4.1 共享卷积层
一个标准的 3x3 卷积:
ConvModule(
(conv): Conv2d(256, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(activate): ReLU(inplace=True)
)
3.4.2 检测头
作用:根据所执行任务设计输出头(3D 物体检测旨在检测行人、车辆、障碍物等可移动物体的位置、比例、方向和速度),代码如下:
1. reg分支:沿着x,y轴方向的偏移量[1,2,128,128]
Sequential(
(0): ConvModule(
(conv): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(activate): ReLU(inplace=True))
(1): Conv2d(64, 2, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)))
2. height分支: z轴也就是预测物体的高度信息 [1,1,128,128]
Sequential(
(0): ConvModule(
(conv): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(activate): ReLU(inplace=True))
(1): Conv2d(64, 1, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)))
3. dim分支: 物体的尺寸大小信息(长-宽-高)[1,3,128,128]
Sequential(
(0): ConvModule(
(conv): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(activate): ReLU(inplace=True))
(1): Conv2d(64, 3, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)))
4. rot分支:物体偏航角的正、余弦(一般车辆在行驶过程中不会涉及俯仰角和滚动角 )[1,2,128,128]
Sequential(
(0): ConvModule(
(conv): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(activate): ReLU(inplace=True))
(1): Conv2d(64, 2, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)))
5. vel分支: 物体沿x,y轴方向的速度 [1,2,128,128]
Sequential(
(0): ConvModule(
(conv): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(activate): ReLU(inplace=True))
(1): Conv2d(64, 2, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)))
6. heatmap分支:分类置信度 [1,10,128,128]
Sequential(
(0): ConvModule(
(conv): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(activate): ReLU(inplace=True))
(1): Conv2d(64, 1, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)))
通过对上述属性的预测,就可以实现最终的 3D 目标检测任务。
四、数据增强策略
4.1 图像增强
4.1.1 原理

4.1.2 数据增强对比图

4.1.3 代码详细解析
# configs/bevdet/bevdet-r50.py
data_config = {
'cams': [
'CAM_FRONT_LEFT', 'CAM_FRONT', 'CAM_FRONT_RIGHT', 'CAM_BACK_LEFT',
'CAM_BACK', 'CAM_BACK_RIGHT'
],
'Ncams':6,
'input_size': (256, 704),
'src_size': (900, 1600),
'resize': (-0.06, 0.11),
'rot': (-5.4, 5.4),
'flip': True,
'crop_h': (0.0, 0.0),
'resize_test': 0.00,
}
# mmdet3d/datasets/pipelines/loading.py(精华操作)
#图像数据增强:(缩放、裁剪、翻转、旋转)
def img_transform_core(self, img, resize_dims, crop, flip, rotate):
# adjust image
img = img.resize(resize_dims)
img = img.crop(crop)
if flip:
img = img.transpose(method=Image.FLIP_LEFT_RIGHT)
img = img.rotate(rotate)
return img
# 数据增强(缩放、裁剪、翻转、旋转)并更新对应的仿射变换矩阵
# 该类支持两种变换方法
def img_transform(self, img, post_rot, post_tran, resize, resize_dims,
crop, flip, rotate):
'''
args: resize: 0.44, resize_dims: (709, 399),
crop:(3, 143, 707, 399), flip: 1, rotate:-3.54
'''
# 不使用opencv的数据增强方法
if not self.opencv_pp:
img = self.img_transform_core(img, resize_dims, crop, flip, rotate)
#--------------------------------------------------
# 求变换对应的仿射矩阵:post—rot,post_tran
#--------------------------------------------------
#-1 缩放处理
post_rot *= resize #【0.443,0】缩放变换累积到旋转矩阵
# -2 裁剪 (很有意思的取值范围,把1/3上半部分裁掉,去掉天空位置)
post_tran -= torch.Tensor(crop[:2])#【-3,143】#减去裁剪区域的左上角坐标(坐标系平移)
#-3 水平翻转处理
if flip:
A = torch.Tensor([[-1, 0], [0, 1]]) #翻转矩阵,实现x轴镜像
b = torch.Tensor([crop[2] - crop[0], 0])#平移补偿b:[crop宽度,0],保证翻转后位置正确
post_rot = A.matmul(post_rot) #更新旋转矩阵
post_tran = A.matmul(post_tran) + b #更新平移矩阵
#-4 旋转变换
A = self.get_rot(rotate / 180 * np.pi) #获取旋转矩阵 角度->弧度
b = torch.Tensor([crop[2] - crop[0], crop[3] - crop[1]]) / 2 #中心点
b = A.matmul(-b) + b #计算旋转补偿 先把中心点移到坐标原点->绕原点旋转->移回中心点位置
post_rot = A.matmul(post_rot) #更新旋转矩阵
post_tran = A.matmul(post_tran) + b #更新平移向量
if self.opencv_pp:
img = self.img_transform_core_opencv(img, post_rot, post_tran, crop)
return img, post_rot, post_tran
4.2 BEV 空间增强
4.2.1 问题与策略(仅 train 用)
因训练时,BEV 空间特征学习容易过拟合,而图像空间的数据增强对 BEV 编码器无效。所以,设计了基于 BEV 空间的数据增强方法。这在之后的 BEV 算法中一直沿用。
在鸟瞰图空间进行随机旋转、缩放、平移和翻转(水平/垂直)。
同时保持 3D 检测框(gt_box)、点云(points)和图像数据的空间一致性。

4.2.2 BEV 增强前后视锥对比图
在 LSS 算法中(见上 3.2.2),对 ego 坐标系下视锥应用_bda_数据增强,并对应的改变 gt_box,实现空间一致性。


4.2.3 实现代码
参数设置
# configs/bevdet/bevdet-r50.py
bda_aug_conf = dict(
rot_lim=(-22.5, 22.5), #旋转区间
scale_lim=(0.95, 1.05), #缩放区间
flip_dx_ratio=0.5, #翻转x轴概率
flip_dy_ratio=0.5) #翻转y轴概率
3D 边界框数据
# mmdet3d/datasets/pipelines/loading.py
class BEVAug(object):...
#--------------------------------
# 对3D框进行几何变换
#--------------------------------
def bev_transform(self, gt_boxes, rotate_angle, scale_ratio, flip_dx,
flip_dy, tran_bda):
'''
gt_boxes: 形状为(N, 9)的张量, 表示N个3D边界框,
每行包含9个属性:[x, y, z, l, w, h, yaw, vx, vy]
rotate_angle: 旋转角度
scale_ratio: 缩放比例
flip_dx: 是否沿x轴翻转(bool)
flip_dy: 是否沿y轴翻转(bool)
tran_dba: 平移向量[dx, dy, dz]
return : 变换后的gt_boxes, 变换矩阵rot_mat
'''
#-1. 旋转矩阵(绕Z轴的旋转矩阵)
rotate_angle = torch.tensor(rotate_angle / 180 * np.pi) # 度转弧度
rot_sin = torch.sin(rotate_angle)
rot_cos = torch.cos(rotate_angle)
rot_mat = torch.Tensor([[rot_cos, -rot_sin, 0], [rot_sin, rot_cos, 0],
[0, 0, 1]]) #z坐标不随旋转改变
#-2. 缩放矩阵
scale_mat = torch.Tensor([[scale_ratio, 0, 0], [0, scale_ratio, 0],
[0, 0, scale_ratio]])
#-3. 翻转矩阵
flip_mat = torch.Tensor([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
if flip_dx: #X轴翻转->X坐标取反
flip_mat = flip_mat @ torch.Tensor([[-1, 0, 0], [0, 1, 0],
[0, 0, 1]])
if flip_dy: #Y轴翻转->Y坐标取反
flip_mat = flip_mat @ torch.Tensor([[1, 0, 0], [0, -1, 0],
[0, 0, 1]])
#-4. 组合基础变换矩阵
rot_mat = flip_mat @ (scale_mat @ rot_mat)
#-------------------------------
#-5. 应用变换到3D框(核心)
#-------------------------------
if gt_boxes.shape[0] > 0: #非空边框
# 1.中心点变换
gt_boxes[:, :3] = (
rot_mat @ gt_boxes[:, :3].unsqueeze(-1)).squeeze(-1)
# 2.尺寸缩放 (w, l, h)->(w, l, w)*scale_ratio
gt_boxes[:, 3:6] *= scale_ratio
# 3.偏航角更新
gt_boxes[:, 6] += rotate_angle
if flip_dx: #X轴翻转:yaw = π - yaw
gt_boxes[:, 6] = 2 * torch.asin(torch.tensor(1.0)) - gt_boxes[:, 6]
if flip_dy: #Y轴翻转:yaw = -yaw
gt_boxes[:, 6] = -gt_boxes[:, 6]
# 4.速度向量变换 (仅需x,y平面的旋转、缩放、翻转)
gt_boxes[:, 7:] = (
rot_mat[:2, :2] @ gt_boxes[:, 7:].unsqueeze(-1)).squeeze(-1)
# 5.平移变换
gt_boxes[:, :3] = gt_boxes[:, :3] + tran_bda
return gt_boxes, rot_mat
空间一致性:点云增强,图像增加 bda_mat
# mmdet3d/datasets/pipelines/loading.py
class BEVAug(object):...
#--------------------
#-4. *^-^点云变换 ^-^*(对x,y,z,坐标旋转平移)
#--------------------
if 'points' in results:
points = results['points'].tensor
points_aug = (bda_rot @ points[:, :3].unsqueeze(-1)).squeeze(-1)#坐标旋转
points[:,:3] = points_aug + tran_bda #坐标平移
points = results['points'].new_point(points) #更新点云坐标
results['points'] = points
#---------------
#-5. 构建仿射变换矩阵
# [ R | T ]
# [ 0 | 1 ]
#---------------
bda_mat[:3, :3] = bda_rot
bda_mat[:3, 3] = torch.from_numpy(tran_bda)
if len(gt_boxes) == 0: # 无边界框时,返回空张量
gt_boxes = torch.zeros(0, 9)
results['gt_bboxes_3d'] =
LiDARInstance3DBoxes(gt_boxes, box_dim=gt_boxes.shape[-1],
origin=(0.5, 0.5, 0.5))
#使用LiDARInstance3DBoxes类封装边界框,并设置锚点(0.5, 0.5, 0.5)
if 'img_inputs' in results:
imgs, rots, trans, intrins = results['img_inputs'][:4]
post_rots, post_trans = results['img_inputs'][4:]
results['img_inputs'] = (imgs, rots, trans, intrins, post_rots,
post_trans, bda_mat) #添加bda_mat
#-----------------------
# -6. *^-^体素数据增强^-^*#未执行
#-----------------------
if 'voxel_semantics' in results:
if flip_dx: # X轴翻转->翻转对应的分割图,【::-1,...】X轴翻转
results['voxel_semantics'] = results['voxel_semantics'][::-1,...].copy()
results['mask_lidar'] = results['mask_lidar'][::-1,...].copy()
results['mask_camera'] = results['mask_camera'][::-1,...].copy()
if flip_dy: #【:,::-1,...】Y轴翻转
results['voxel_semantics'] = results['voxel_semantics'][:,::-1,...].copy()
results['mask_lidar'] = results['mask_lidar'][:,::-1,...].copy()
results['mask_camera'] = results['mask_camera'][:,::-1,...].copy()
return results
五、Scale-NMS
5.1 问题与策略
在BEV
空间中,不同类别的物体占用的面积本质上是不同的,因此预测结果之间的IoU
分布会因类别而异。如果对象占用的区域都很小,可能会使预测结果与真正的结果之间没有交集,这将使依赖IoU
的经典NMS
算法失效。
作者提出了Scale-NMS
。Scale-NMS
在执行经典NMS
算法之前,会根据每个对象的类别对其大小进行缩放,缩放的比例因子是特定于类别的,它们是通过在验证集上的超参数搜索生成的。通过这种方式,真阳性和冗余结果之间的IOU
分布会被调整以匹配经典NMS
算法。
如下图所示,在预测小目标的时候,Scale-NMS
通过放大对象大小来构建结果之间的空间关系,这使得经典的NMS
算法能够根据IOU
指标丢弃冗余的预测结果。在实践中,作者将Scale-NMS
应用于除barrier
以外的所有类别,因为该类别包含的对象的大小各不相同。

5.2 实现代码
#configs/bevdet/bevdet-r50.py
# Scale-NMS
nms_type=['rotate'],
nms_thr=[0.2],
nms_rescale_factor=[[1.0, 0.7, 0.7, 0.4, 0.55,
1.1, 1.0, 1.0, 1.5, 3.5]] #定义不同类的缩放因子
#mmdet3d/models/dense_heads/centerpoint_head.pymmdet3d/models/dense_heads/centerpoint_head.py
predictions_dicts = []
...
if isinstance(factor, list):
for cid in range(len(factor)):
box_preds[cls_labels == cid, 3:6] =
box_preds[cls_labels == cid, 3:6] * factor[cid] #box,w,h,l *类别缩放因子
else:
box_preds[:, 3:6] = box_preds[:, 3:6] * factor
# Apply NMS in birdeye view
....
if isinstance(factor, list):
for cid in range(len(factor)):
box_preds[top_labels == cid, 3:6] =
box_preds[top_labels == cid, 3:6] / factor[cid] # 除以缩放因子,去除影响
else:
box_preds[:, 3:6] = box_preds[:, 3:6] / factor

十三Tech
还未添加个人签名 2019-04-24 加入
公众号:十三Tech
评论