BEVFormer解读(提问版)

一、参考资料

万字长文理解纯视觉感知算法 —— BEVFormer: 知乎
论文链接: BEVFormer
视频链接: B站up

二、 BEVformer代码流程处理

2.1 输入数据和预处理

2.1.1 配置文件

configs/bevformer

这个文件夹下有3个不同大小的文件,一个tiny、一个small、一个base
具体不同可以去文件中查看

采用6个camera的图像作为输入,每个序列包含3个历史帧(3*6)【t-3,t-2,t-1,t】

2.1.2 数据预处理流程

以tiny.py中为例:

train_pipeline = [
    dict(type='LoadMultiViewImageFromFiles', to_float32=True),
    dict(type='PhotoMetricDistortionMultiViewImage'),
    dict(type='LoadAnnotations3D', with_bbox_3d=True, with_label_3d=True, with_attr_label=False),
    dict(type='ObjectRangeFilter', point_cloud_range=point_cloud_range),
    dict(type='ObjectNameFilter', classes=class_names),
    dict(type='NormalizeMultiviewImage', **img_norm_cfg),
    dict(type='PadMultiViewImage', size_divisor=32),
    dict(type='DefaultFormatBundle3D', class_names=class_names),
    dict(type='CustomCollect3D', keys=['gt_bboxes_3d', 'gt_labels_3d', 'img'])
]

train_pipeline和test_pipeline中:
1)加载多视角图像
2)图像增强(光度失真、图像标准化、随机缩放)
3)加载3D标注
4)过滤处理(点云范围: point_cloud_range = [-51.2, -51.2, -5.0, 51.2, 51.2, 3.0]、类别: class_names = [
‘car’, ‘truck’, ‘construction_vehicle’, ‘bus’, ‘trailer’, ‘barrier’,
‘motorcycle’, ‘bicycle’, ‘pedestrian’, ‘traffic_cone’
])
5)图像填充
6)数据格式化(转为处理后的tensor,选取用于训练的数据和标签)

最后处理完,输入模型的数据格式:

  - img_metas: <class 'mmcv.parallel.data_container.DataContainer'>
  - gt_bboxes_3d: <class 'mmcv.parallel.data_container.DataContainer'>
  - gt_labels_3d: <class 'mmcv.parallel.data_container.DataContainer'>
  - img: <class 'mmcv.parallel.data_container.DataContainer'>
   img_metas包括了:
  - 4个历史序列,每个序列中又包括6张环视图像(路径)filename
  - ori_shape  (900, 1600, 3)  *6 
  - img_shape (928, 1600, 3) *6
  - lidar2img 4*4矩阵
  **注意:**  
		1. BEV 坐标系 这里指 lidar 坐标系
		2. 这里提到的`lidar2img`是经过坐标变换的,一般分成三步
			   第一步:lidar 坐标系 -> ego vehicle 坐标系
			   第二步:ego vehicle 坐标系 -> camera 坐标系
			   第三部:camera 坐标系 通过相机内参 得到像素坐标系
			   以上这三步用到的所有平移和旋转矩阵都合并到了一起,形成了`lidar2img` 旋转平移矩阵
  -lidar2cam 4*4矩阵
  -pad_shape	  (928, 1600, 3)	
  -scale_factor  1.0
  -box_mode_3d   <Box3DMode.LIDAR: 0>
  -box_type_3d   <class 'mmdet3d.core.bbox.structures.lidar_box3d.LiDARInstance3DBoxes'>
  -img_norm_cfg {'mean': array([103.53 , 116.28 , 123.675], dtype=float32), 'std': array([1., 1., 1.], dtype=float32), 'to_rgb': False},
  -sample_idx  bfb240c35d3c4a52bd06ecf4968811d0
  -prev_idx   e4d098ea95664f45999c5009953b45ad
  -next_idx    497dddd2036749619a3f60901ef1ccd9
  -pts_filename   data/nuscenes/samples/LIDAR_TOP/n015-2018-08-03-15-00-36+0800__LIDAR_TOP__1533279680650385.pcd.bin
  -scene_token  4098aaf3c7074e7d87285e2fc95369e0
  -can_bus (18,)对应18个不同参数
  -prev_bev_exists True/False

2.2 模型结构与数据流

2.2.1 整体架构

1)图像backbone:Restnet50
2) Neck:FPN
3) BEV Encoder:PerceptionTransformer(3层
BEVFormerEncoder)
4) 检测Decoder:6层 DetectionTransformerDecoder
5)检测头:BEVFormerHead

2.2.2 BEV表示

tiny使用50*50的网格,覆盖范围为:x [-51.2,51.2]米,y [-51.2,51.2]米,z [-5,3]米 ,每个网格约为2米。

2.2.3 BEV表征生成

使用PerceptionTransformer将环视图转换为BEV表征。

  1. BEV Query初始化(Learned PostionalEncoding可学习的位置编码),初始化Query
  2. Encoder处理(BEVFormerEncoder)
    Encoder内部流程:
    a. TemporalselfAttention(包括当前时刻的BEV query与历史时刻BEV query特征交互,自车前后时刻特征对齐(旋转平移))
    b. spatialcross Attention (每个BEV Query与6张图像交互)
    c. FFN (两层MLP)

2.2.4 Decoder

1、number_query = 900 【DETR的操作】
2、经过MultiheadAttention和可变形注意力机制

2.2.5检测头输出

Bevformer将Decoder输出转化为最终的检测结果
1、分类损失:预测10个类别
2、回归损失:预测3D边界框
3、IoU损失

三、问题汇总(大部分是代码方面的)

问题1:BEV Query和num_query的区别

BEV Query用在编码器中,目得是构建BEV,大小为50*50;
num_query用在解码器中,用于检测场景中的目标。

问题2:p=(x,y)的Query Qp 和真实世界(x’,y’)的关系

两个坐标系是不一样的,Query 坐标系是BEV的坐标系,图像原点位于BEV的左下角,而真实世界(x’,y’)是自车坐标系,也就说要从BEV坐标系转化到自车坐标系。
x’ = (x- w/2)*s
y’ = (y -h/2)*s
todo:画图说明

问题3:如何用相机内外参矩阵将从柱状Query中采样的3D参考点投影到各个相机平面呢?然后再如何判断该点是否落在相机的视野范围内?

  1. 使用外参矩阵将3D点从世界坐标系转到相机坐标系(3D点)(x,y,z)
  2. 使用内参矩阵将相机坐标系投影到2D图像平面(u,v)
  3. 判断点是否落在相机视野范围内(a.点必须在相机前方 b.投影点必须在图像范围内)

这里再说明一下3D到2D之间投影的关系是如何建立的
首先先把BEV提升到3维度(类似于魔方),得到每个网格的立体版了,再这这个立体网格上间隔选取4个点,每个点的索引坐标(x,y,z)就有了,然后再利用相机的内外参得到投影到图像上的坐标,但是不是每个图像上都会存在投影点(这就对了,前面的摄像头拍摄的图像,后面摄像头拍不到吧?),既然不是每个图像都能投影到,那就选那些有效的投影点。

【代码里不光选了有效的投影点(就是论文中的参考点ref_2D),还把query做了筛选,相当于是从 BEV 查询(query)中提取有效网格的特征,按相机重新分配,用于提问。】

        zs = torch.linspace(0.5, ref_3d_z - 0.5, num_points_in_pillar, dtype=dtype,
                            device=device).view(-1, 1, 1).expand(num_points_in_pillar, bev_h, bev_w) / ref_3d_z
        xs = torch.linspace(0.5, bev_w - 0.5, bev_w, dtype=dtype,
                            device=device).view(1, 1, bev_w).expand(num_points_in_pillar, bev_h, bev_w) / bev_w
        ys = torch.linspace(0.5, bev_h - 0.5, bev_h, dtype=dtype,
                            device=device).view(1, bev_h, 1).expand(num_points_in_pillar, bev_h, bev_w) / bev_h
        ref_3d = torch.stack((xs, ys, zs), -1)
        ref_3d = ref_3d.permute(0, 3, 1, 2).flatten(2).permute(0, 2, 1)
        ref_3d = ref_3d[None].repeat(bs, 1, 1, 1)  # bs, num_points_in_pillar, bev_h * bev_w, xyz # torch.Size([1, 4, 50*50, 3])
        # ref_3d:
        #   zs: (0.5 ~ 8-0.5) / 8
        #   xs: (0.5 ~ 50-0.5) / 50
        #   ys: (0.5 ~ 50-0.5) / 50
        # return ref_3d
        # ----------------------get_reference_points end----------------------
        # ref_2d = self.get_reference_points(
        #     bev_h, bev_w, dim='2d', bs=bev_query.size(1), device=bev_query.device, dtype=bev_query.dtype)
        # ---------------------get_reference_points start---------------------
        ref_y, ref_x = torch.meshgrid(
            torch.linspace(
                0.5, bev_h - 0.5, bev_h, dtype=dtype, device=device),
            torch.linspace(
                0.5, bev_w - 0.5, bev_w, dtype=dtype, device=device)
        )
        ref_y = ref_y.reshape(-1)[None] / bev_h
        ref_x = ref_x.reshape(-1)[None] / bev_w
        ref_2d = torch.stack((ref_x, ref_y), -1)
        ref_2d = ref_2d.repeat(bs, 1, 1).unsqueeze(2)  # ref_2d其实就是ref_3d torch.Size([1, 4(4个高度), 50*50, 3(x,y,z)]) 去掉高度的一部分,
        # bs, bev_h * bev_w, None, xy # torch.Size([1, 50*50, 1, 2])
        # return ref_2d
        # ----------------------get_reference_points end----------------------
        # reference_points_cam, bev_mask = self.point_sampling(ref_3d, self.pc_range, kwargs['img_metas'])
        # ------------------------point_sampling start------------------------
        reference_points = ref_3d
        # NOTE: close tf32 here.
        allow_tf32 = torch.backends.cuda.matmul.allow_tf32
        torch.backends.cuda.matmul.allow_tf32 = False
        torch.backends.cudnn.allow_tf32 = False

        lidar2img = []      
        for img_meta in ohb_img_metas:
            lidar2img.append(img_meta['lidar2img'])  # lidar2img 激光雷达到图像坐标的转换,将激光雷达坐标系当作自车坐标系
        lidar2img = np.asarray(lidar2img)
        lidar2img = reference_points.new_tensor(lidar2img)  # torch.Size([1, 6, 4, 4]) 相机内外参合成一个矩阵4*4
        reference_points = reference_points.clone()

        # reference_points = ref_3d # normalize 0~1 # torch.Size([1, 4, 50*50, 3])
        # pc_range = [-51.2, -51.2, -5.0, 51.2, 51.2, 3.0]  长102.4  宽102.4  高8
        # 映射到自车坐标系下, 尺度被反归一化为真实尺度(米)   
        reference_points[..., 0:1] = reference_points[..., 0:1] * \
            (pc_range[3] - pc_range[0]) + pc_range[0]  # x 0~1 缩放到 0~102.4
        reference_points[..., 1:2] = reference_points[..., 1:2] * \
            (pc_range[4] - pc_range[1]) + pc_range[1]  # y 0~1 缩放到 0~102.4
        reference_points[..., 2:3] = reference_points[..., 2:3] * \
            (pc_range[5] - pc_range[2]) + pc_range[2]  # z 0~1 缩放到 0~8

        # 变成齐次坐标
        reference_points = torch.cat((reference_points, torch.ones_like(reference_points[..., :1])), -1)
        # bs, num_points_in_pillar, bev_h * bev_w, xyz1  齐次坐标通过升维将平移纳入矩阵乘法 [x,y,z]-->[x,y,z,1]齐次坐标形式,为了和lidar2img[1, 6, 4, 4]做乘法

        reference_points = reference_points.permute(1, 0, 2, 3)
        # num_points_in_pillar, bs, bev_h * bev_w, xyz1
        D, B, num_query = reference_points.size()[:3]
        num_cam = lidar2img.size(1)  # 6

        reference_points = reference_points.view(D, B, 1, num_query, 4).repeat(1, 1, num_cam, 1, 1).unsqueeze(-1)
        ref_3d = ref_3d[None].repeat(bs, 1, 1, 1)  # bs, num_points_in_pillar, bev_h * bev_w, xyz # torch.Size([1, 4, 50*50, 3])
         # bs, num_points_in_pillar, bev_h * bev_w, xyz # torch.Size([1, 4, 50*50, 3])
        lidar2img = lidar2img.view(1, B, num_cam, 1, 4, 4).repeat(D, 1, 1, num_query, 1, 1)
        # num_points_in_pillar, bs, num_cam, bev_h * bev_w, 4, 4   

        reference_points_cam = torch.matmul(lidar2img.to(torch.float32), reference_points.to(torch.float32)).squeeze(-1)  # 每个 3D 点投影到 6 个相机上的齐次坐标 (u, v, s, 1)
        # num_points_in_pillar, bs, num_cam, bev_h * bev_w, uvs1  s代表比例(x,y,s == kx,ky,ks  s==1,x/s, y/s)
        eps = 1e-5

        bev_mask = (reference_points_cam[..., 2:3] > eps)  # 只保留位于相机前方的点
        # 齐次坐标下除以比例系数得到图像平面的坐标真值 : 将投影结果从 (u, v, s) 转换为实际的 2D 像素坐标 (x, y)
        reference_points_cam = reference_points_cam[..., 0:2] / torch.maximum(
            reference_points_cam[..., 2:3], torch.ones_like(reference_points_cam[..., 2:3]) * eps)
        # num_points_in_pillar, bs, num_cam, bev_h * bev_w, uv

        # 坐标归一化 都在0-1之间
        reference_points_cam[..., 0] /= ohb_img_metas[0]['img_shape'][0][1]
        reference_points_cam[..., 1] /= ohb_img_metas[0]['img_shape'][0][0]

        # 去掉图像以外的点  先保证uv坐标都在0-1之间【在相机的视场内】  num_points_in_pillar, bs, num_cam, bev_h * bev_w, uv
        bev_mask = (bev_mask & (reference_points_cam[..., 1:2] > 0.0)
                    & (reference_points_cam[..., 1:2] < 1.0)
                    & (reference_points_cam[..., 0:1] < 1.0)
                    & (reference_points_cam[..., 0:1] > 0.0))

问题4: 生成的PKL中有什么内容

pkl是create_data.py文件生成的,是字典格式,有2个键值对:metainfo和data_list;
mateinfo 中包含数据集基本信息,例如:categories, dataset, info_version;
data_list 列表形式:由字典组成,包含了单个样本所有信息

问题5: 代码中bev_mask的作用


        bev_mask = torch.zeros((bs, bev_h, bev_w), device=bev_queries.device).to(dtype)
        # bev_pos = self.positional_encoding(bev_mask).to(dtype)
        # 这里只用到了bev_mask的shape和device信息,与bev_mask的值无关

        bev_mask = (reference_points_cam[..., 2:3] > eps)  # 只保留位于相机前方的点
        # 齐次坐标下除以比例系数得到图像平面的坐标真值 : 将投影结果从 (u, v, s) 转换为实际的 2D 像素坐标 (x, y)
        reference_points_cam = reference_points_cam[..., 0:2] / torch.maximum(
            reference_points_cam[..., 2:3], torch.ones_like(reference_points_cam[..., 2:3]) * eps)
        # num_points_in_pillar, bs, num_cam, bev_h * bev_w, uv

        # 坐标归一化 都在0-1之间
        reference_points_cam[..., 0] /= ohb_img_metas[0]['img_shape'][0][1]
        reference_points_cam[..., 1] /= ohb_img_metas[0]['img_shape'][0][0]

        # 去掉图像以外的点  先保证uv坐标都在0-1之间【在相机的视场内】  num_points_in_pillar, bs, num_cam, bev_h * bev_w, uv
        bev_mask = (bev_mask & (reference_points_cam[..., 1:2] > 0.0)
                    & (reference_points_cam[..., 1:2] < 1.0)
                    & (reference_points_cam[..., 0:1] < 1.0)
                    & (reference_points_cam[..., 0:1] > 0.0))

bev_mask的值并不重要,它的目得是记录哪些BEV的网格点投影到图像平面后,有效的投影点(即哪些点落在了相机的视野范围内)。

问题6: 位置编码bev_pos为什么不用nn.parameter?

首先要明确 bev_queries 是干什么的? 它是 Transformer 编码器的 Query,用于生成 BEV 表示。为 BEV 空间的每个网格点提供一个初始的“问题”,例如“这个网格点对应的 3D 空间中有什么物体?”。代码中:

        # BH_positional_encoding
        pe_num_feats, pe_row_num_embed, pe_col_num_embed = pos_dim, bev_h, bev_w
        pe_row_embed = nn.Embedding(pe_row_num_embed, pe_num_feats).cuda()
        pe_col_embed = nn.Embedding(pe_col_num_embed, pe_num_feats).cuda()

        pe_h, pe_w = bev_mask.shape[-2:]  # bev_mask torch.Size([1, 50, 50])
        pe_x = torch.arange(pe_w, device=bev_mask.device)
        pe_y = torch.arange(pe_h, device=bev_mask.device)
        pe_x_embed = pe_col_embed(pe_x)  # torch.Size([50, 128])
        pe_y_embed = pe_row_embed(pe_y)  # torch.Size([50, 128])
        pe_pos = torch.cat((pe_x_embed.unsqueeze(0).repeat(pe_h, 1, 1), pe_y_embed.unsqueeze(1).repeat(1, pe_w, 1)),
                           dim=-1).permute(2, 0, 1).unsqueeze(0).repeat(bev_mask.shape[0], 1, 1, 1)
        # return pos
        bev_pos = pe_pos  # torch.Size([1, 256, 50, 50])  TODO: nn.Parameter([1, 256, 50, 50])?

上面代码自定义了一个可学习的参数,而没有用到nn.Parameter。
原因1:参数效率问题。当前代码中,pe_row_embedpe_col_embed 的参数数量分别是 bev_h * pe_num_featsbev_w * pe_num_feats,例如 50 * 128 = 6400 个参数(总共 12800 个参数)。
如果直接定义 bev_pos 作为一个 nn.Parameter,形状为 (1, 256, 50, 50),则参数数量是 256 * 50 * 50 = 640,000 个参数。这会极大地增加模型的参数量,导致过拟合和计算开销。

深度学习模型的参数越多,占用的 GPU 内存就越大。640,000 个参数(假设每个参数是 4 字节的 float32)占用约 2.56 MB,而 12,800 个参数只占用约 51.2 KB。参数量的增加会显著增加模型的内存需求,尤其是在大规模训练或多 GPU 并行时。

通过嵌入层的方式,参数数量大大减少,同时仍然能够为每个网格点生成独立的位置编码。

问题7:时间自注意力中,上一时刻的BEV特征如何与当前时刻对齐?

总的来看:对齐BEV,需要车辆的移动方向、上一帧的朝向、当前帧的朝向(这完全是估算出来的:bev_angle = ego_angle - translation_angle )
根据车辆的CAN_bus数据,计算从上一帧到当前帧的平移距离(translation_length)、平移方向(translation_angle)和旋转角度(bev_angle)
B站视频截图
几个概念:
平移:车辆在x、y方向移动的距离
旋转:车辆的朝向变换(代表BEV要旋转)

B站视频截图
来自大佬的画图

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值