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.pydef 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.pybda_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.pyclass 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.pyclass 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









评论