相较于目前的基于BEV空间的3D感知算法,Occupancy Network算法可以更好的克服感知任务中存在的长尾问题以及更加准确表达物体的几何形状信息而受到来自工业界和学术界越来越广泛的关注。

Occupancy Network算法本质上是一个3D分割任务,通过将想要感知的3D空间划分成固定大小的体素网格,并让算法去预测每个体素网格被占用的概率以及可能包含的目标类别从而实现对全场景的感知。因其是对空间中的所有体素进行分类,所以对于数据集中未被标注的物体(比如土堆、石块等目标也可以预测为General Objects,从而实现开放集的目标检测,即更好的克服检测中的长尾问题);同时与直接输出一个物体粗糙3D框的算法相比,由于是直接对空间中的每个体素进行预测,所以对于不规则形状的目标,Occupancy Network算法可以给出更加细粒度的形状表示,从而得到每个物体更加丰富的细节结构信息。

尽管目前Occupancy Network相比于之前的基于BEV的3D感知算法有更好的感知优势,但因其将所要感知的环境空间利用3D体素特征进行中间表示,避免不了的会引入3D卷积等算子进行特征提取,无疑会大大增加模型的运算量和内存开销,从而为模型的上车部署造成不小的困难,严重影响了Occupancy Network算法的落地。

所以,本篇将要介绍的Flash-Occ算法(算法流程图见图一)则抛弃了长耗时、难部署的3D卷积模块,继续使用2D卷积模块来完成特征的提取任务。同时,为了减少模型的计算量,Flash-Occ不再使用Voxel体素特征,而是继续使用BEV特征来建模需要感知的3D空间。但为了完成Occupancy Network在3D空间的预测,Flash-Occ算法设计了一个通道-高度转换模块实现将BEV空间的输出结果提升到3D体素空间,完成最终的结果预测。

Flash-Occ_2d

图一:Flash-Occ算法整体

Flash-Occ算法的论文链接如下:

https://arxiv.org/abs/2311.12058 arxiv.org/abs/2311.12058

算法流程详解

Flash-Occ算法是在BEVDet-Occupancy的codebase上进行改进的,下面是Flash-Occ算法的开源仓库链接

https://github.com/Yzichen/FlashOCC github.com/Yzichen/FlashOCC

由于Flash-Occ的开源代码当中有很多的配置文件,为了方便大家的理解,本文是基于flashocc-r50-4d-stereo.py配置文件来进行梳理的,我们先看一下Flash-Occ算法的整体流程图,然后再根据源码部分进行细致的讲解~

Flash-Occ_2d_02

 图二:Flash-Occ算法详细流程图

整体来看,Flash-Occ的网络结构包括2D主干网络、Neck网络、深度估计模块、视角转换模块、2D BEV Encoder、Occupancy Head模块、通道高度转换模块、训练过程的Loss计算等部分组成。

在介绍各个网络模块具体实现细节之前,先定义一下输入到网络中的环视图像的张量大小Tensor([bs, frame, num_cam, 3, H, W])。其中,bs代表batch size大小,frame代表输入到模型中的帧数(该算法模型的输入还包括历史帧的数据),num_cam代表环视图像中相机的个数,H、W分别代表输入图像的高和宽。

2D主干网络

2D主干网络的作用就是对输入的环视图像进行多尺度的特征提取,针对上述提到的配置文件,2D主干网络采用的是ResNet-50。

需要注意的是,由于当前的config配置是将时序上连续的三帧环视图像一起输入到网络模型当中,所以模型对于不同帧的处理方式是不同的。

  • 如果当前时刻标记为t,那么对于t-2时刻的环视图像,2D主干网络只会输出降采样四倍的特征图用于后续进行双目立体的深度估计,代码中定义降采样4倍的特征为Stereo Feature。
  • 但是对于t和t-1时刻,2D主干网络在输出降采样4倍Stereo Feature的同时,还会输出降采样16倍和32倍的特征图,用于后续完成多尺度特征的信息融合。

相应的伪代码展示如下,伪代码中的self.extract_stereo_ref_feat()和self.image_encoder()函数均是ResNet-50网络,只是对应的输出特征的降采样步长不同。

# image即当前`t - 2`时刻的环视输入图像
if extra_ref_frame:  # `t - 2`时刻被定义为extra_ref_frame
    stereo_feat = self.extract_stereo_ref_feat(image)  # stereo_feat's downsample ratio = 4.
    return None, None, stereo_feat  # stereo_feat = Tensor([bs * num_cam, 256, H / 4, W / 4])

# 针对`t - 1`以及`t`时刻,网络会同时输出降采样16倍和32倍的Feature List,记作`x`;以及降采样4倍的特征stereo_feat
x, stereo_feat = self.image_encoder(image, stereo=True)

# x = [Tensor([bs * num_cam, 1024, H / 16, W / 16]), Tensor([bs * num_cam, 2048, H / 32, W / 32])]

Neck网络

Neck网络的主要作用是对2D主干网络输出的多尺度特征进行融合,从而实现多尺度语义信息的汇聚。需要注意的是,只有对t和t-1时刻处理后的多尺度环视特征送入到Neck网络进行语义特征的汇聚。

Neck部分的处理逻辑可以总结为以下几个部分

  • 输入多尺度特征:[Tensor([bs * num_cam, 1024, H / 16, W / 16]), Tensor([bs * num_cam, 2048, H / 32, W / 32])]
  • 第一步:采用Conv1x1卷积降低2D主干网络输出的环视图像多尺度特征的通道数
  • 第二步:将32倍降采样的特征图进行双线性差值得到降采样16倍的特征图
  • 第三步:将融合后的特征过一遍Conv3x3卷积进一步拟合融合后的特征图
  • 输出融合后的特征:Tensor([bs * num_cam, 256, H / 16, W / 16])

相应的伪代码展示如下

第一步:采用Conv1x1卷积降低通道数
laterals = [
    lateral_conv(inputs[i + self.start_level])
    for i, lateral_conv in enumerate(self.lateral_convs)
]

第二步:将32倍降采样的特征图进行双线性差值得到降采样16倍的特征图
prev_shape = laterals[i - 1].shape[2:]
laterals[i - 1] += F.interpolate(laterals[i], size=prev_shape, **self.upsample_cfg)

第三步:将融合后的特征过一遍Conv3x3卷积进一步拟合融合后的特征图
outs = [self.fpn_convs[i](laterals[i]) for i in self.out_ids]



>>> self.lateral_convs
>>> ModuleList(
  (0): ConvModule(
    (conv): Conv2d(1024, 256, kernel_size=(1, 1), stride=(1, 1))
  )
  (1): ConvModule(
    (conv): Conv2d(2048, 256, kernel_size=(1, 1), stride=(1, 1))
  )
)

>>> self.fpn_convs
>>> ModuleList(
  (0): ConvModule(
    (conv): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  )
)

深度估计模块

根据配置文件中的Stereo关键词可以看出,该算法模型是借鉴了BEVStereo论文中的思路,采用双目深度估计的思路,利用两个连续的时序帧的特征对深度信息进行估计。生成的深度信息同时考虑了深度特征和语义特征,其具体的生成逻辑可以总结为以下几个部分

  • 第一步:为了更好的对深度信息进行估计,代码中将相机的内外参数、bda增强矩阵等参数也喂入到深度估计模块当中,并过了一个BN层,该部分记作mlp_input参数(参考BEVStereo论文中的做法)
  • 第二步:将Neck网络输出的特征图过一层3x3的卷积,该卷积层记作reduce_conv层
  • 第三步:利用包含两层的MLP网络对mlp_input进行编码,该MLP网络记作context_mlp层
  • 第四步:利用通道注意力模块SENet和context_mlp层编码后的mlp_input对reduce_conv层输出的语义特征进行调整得到更加合理的上下文特征context
  • 第五步:将第四步得到的context上下文特征过一层Conv1x1卷积调整特征图的通道数
  • 第六步:利用包含两层的MLP网络对mlp_input进行编码,该网络与第三步的MLP网络并不共享参数,记作depth_mlp层
  • 第七步:利用通道注意力模块SENet和depth_mlp层编码后的mlp_input对第六步输出的深度信息进行调整,得到更加准确的深度信息depth
  • 第八步:同时该深度估计模块还利用了两帧之间的双目立体信息来估计Cost Volumn,从而提高网络对于深度信息的估计能力
  • 第九步:利用两组级连的步长为2的3x3卷积对Cost Volumn特征图进行下采样运算,最后和第七步得到的depth信息concat到一起
  • 第十步:将concat到一起的depth信息利用ASPP模块进行不同感受野分支的特征提取,从而实现多尺度信息的聚合       
  • 最终,深度估计模块的输出如下
  • 深度特征为Tensor([bs * num_cam , 88, H / 16, W / 16])
  • 语义特征为Tensor([bs * num_cam, 80, H / 16, W / 16])

相应的伪代码展示如下

# 第一步:将mlp_input过一遍BN层,其中mlp_input包含相机内外参、bda增强等信息
mlp_input = self.bn(mlp_input.reshape(-1, mlp_input.shape[-1]))

# 第二步:对Neck网络输出的`x`特征过一层Conv3x3卷积
x = self.reduce_conv(x)

# 第三步:利用两层MLP网络对mlp_input进行编码
context_se = self.context_mlp(mlp_input)[..., None, None]

# 第四步:将语义特征`x`和`mlp_input`编码后的特征喂入到SENet通道注意力模块,得到修正后的上下文特征`context`
context = self.context_se(x, context_se)

# 第五步:利用Conv1x1卷积调整`context`特征的通道数
context = self.context_conv(context)

# 第六步:利用两层MLP网络对mlp_input进行编码
depth_se = self.depth_mlp(mlp_input)[..., None, None]

# 第七步:将语义特征`x`和`mlp_input`编码后的特征喂入到SENet通道注意力模块,得到修正后的深度信息`depth`
depth = self.depth_se(x, depth_se)

# 第八步:利用两帧之间的Stereo Feature计算Cost Volumn视差
with torch.no_grad():
    cost_volumn = self.cost_volumn_net(cost_volumn)

# 第九步:利用两组级连的步长为2的3x3卷积对Cost Volumn特征图进行下采样运算
cost_volumn = self.cost_volumn_net(cost_volumn)

# 第十步:将得到的Cost Volumn和depth预测信息Concat到一起喂入到ASPP模块中,得到更加精准的结果
depth = torch.cat([depth, cost_volumn], dim=1)
depth = self.depth_conv(depth)


>>> self.bn
>>> BatchNorm1d(27, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)

>>> self.reduce_conv
>>> Sequential(
  (0): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (2): ReLU(inplace=True)
)

>>> self.context_mlp / self.depth_mlp
>>> Mlp(
  (fc1): Linear(in_features=27, out_features=256, bias=True)
  (act): ReLU()
  (drop1): Dropout(p=0.0, inplace=False)
  (fc2): Linear(in_features=256, out_features=256, bias=True)
  (drop2): Dropout(p=0.0, inplace=False)
)

>>> self.context_se / self.depth_se
>>> SELayer(
  (conv_reduce): Conv2d(256, 256, kernel_size=(1, 1), stride=(1, 1))
  (act1): ReLU()
  (conv_expand): Conv2d(256, 256, kernel_size=(1, 1), stride=(1, 1))
  (gate): Sigmoid()
)

>>> self.context_conv
>>> Conv2d(256, 80, kernel_size=(1, 1), stride=(1, 1))

>>> self.cost_volumn_net
>>> Sequential(
  (0): Conv2d(88, 88, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
  (1): BatchNorm2d(88, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (2): Conv2d(88, 88, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
  (3): BatchNorm2d(88, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)

>>> self.depth_conv <=> 3 * Res blocks + ASPP/DCN + Conv
Sequential(                                                                                                                                                                                                                                             
(0): BasicBlock(                                                                                                                                                                                                                                                    
     (conv1): Conv2d(344, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)                                                                                                                                                                          
     (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)                                                                                                                                                                           
     (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)                                                                                                                                                                          
     (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)                                                                                                                                                                           
     (relu): ReLU(inplace=True)                                                                                                                                                                                                                                        
     (downsample): Conv2d(344, 256, kernel_size=(1, 1), stride=(1, 1))                                                                                                                                                                                                 )                                                                                                                                                                                                                                                                   
(1): BasicBlock(                                                                                                                                                                                                                                                    
     (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)                                                                                                                                                                          
     (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)                                                                                                                                                                           
     (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)                                                                                                                                                                          
     (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)                                                                                                                                                                           
     (relu): ReLU(inplace=True))                                                                                                                                                                                                                                    
(2): BasicBlock(
     (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
     (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
     (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
     (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
     (relu): ReLU(inplace=True))
(3): ASPP(
     (aspp1): _ASPPModule(
     (atrous_conv): Conv2d(256, 96, kernel_size=(1, 1), stride=(1, 1), bias=False)
     (bn): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
     (relu): ReLU())

     (aspp2): _ASPPModule(
     (atrous_conv): Conv2d(256, 96, kernel_size=(3, 3), stride=(1, 1), padding=(6, 6), dilation=(6, 6), bias=False)
     (bn): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
     (relu): ReLU())

     (aspp3): _ASPPModule(
     (atrous_conv): Conv2d(256, 96, kernel_size=(3, 3), stride=(1, 1), padding=(12, 12), dilation=(12, 12), bias=False)
     (bn): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
     (relu): ReLU())

     (aspp4): _ASPPModule(
     (atrous_conv): Conv2d(256, 96, kernel_size=(3, 3), stride=(1, 1), padding=(18, 18), dilation=(18, 18), bias=False)
     (bn): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
     (relu): ReLU())

     (global_avg_pool): Sequential(
     (0): AdaptiveAvgPool2d(output_size=(1, 1))
     (1): Conv2d(256, 96, kernel_size=(1, 1), stride=(1, 1), bias=False)
     (2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
     (3): ReLU())

     (conv1): Conv2d(480, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
     (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
     (relu): ReLU()
     (dropout): Dropout(p=0.5, inplace=False)

(4): Conv2d(256, 88, kernel_size=(1, 1), stride=(1, 1))
)

视角转换模块

由于深度估计模块已经得到了丰富的语义特征context以及深度信息depth,作者这里是直接利用了原BEVDet生成BEV特征的模块Voxel Pooling v2,从而获得BEV空间特征:Tensor([bs, 80, BEV_H, BEV_W])。

Voxel Pooling v2模块的代码如下

def voxel_pooling_v2(self, coor, depth, feat):
    ranks_bev, ranks_depth, ranks_feat, \
    interval_starts, interval_lengths = \
    self.voxel_pooling_prepare_v2(coor)
    if ranks_feat is None:
        print('warning ---> no points within the predefined '
              'bev receptive field')
        dummy = torch.zeros(size=[
            feat.shape[0], feat.shape[2],
            int(self.grid_size[2]),
            int(self.grid_size[0]),
            int(self.grid_size[1])
        ]).to(feat)
        dummy = torch.cat(dummy.unbind(dim=2), 1)
        return dummy
    feat = feat.permute(0, 1, 3, 4, 2)
    bev_feat_shape = (depth.shape[0], int(self.grid_size[2]),
                      int(self.grid_size[1]), int(self.grid_size[0]),
                      feat.shape[-1])  # (B, Z, Y, X, C)
    bev_feat = bev_pool_v2(depth, feat, ranks_depth, ranks_feat, ranks_bev,
                           bev_feat_shape, interval_starts,
                           interval_lengths)
    # collapse Z
    if self.collapse_z:  # self.collapse_z = True
        bev_feat = torch.cat(bev_feat.unbind(dim=2), 1)
    return bev_feat

再得到BEV特征之后,继续使用2D卷积层进行特征提取,该2D卷积层记作self.pre_process_net(),从而获得某一帧对应的BEV Feature = Tensor([bs, 80, BEV_H, BEV_W])

对应的代码如下

bev_feat = self.pre_process_net(bev_feat)[0]


>>> self.pre_process_net
>>> CustomResNet(
  (layers): Sequential(
    (0): Sequential(
      (0): BasicBlock(
        (conv1): Conv2d(80, 80, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
        (bn1): BatchNorm2d(80, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
        (conv2): Conv2d(80, 80, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
        (bn2): BatchNorm2d(80, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
        (relu): ReLU(inplace=True)
        (downsample): Conv2d(80, 80, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
      )
    )
  )
)

2D BEV Encoder模块

上文有提到,当前梳理的代码同时用到了历史帧的信息,所以对于2D BEV Encoder模块的输入BEV Feature实际上是一组BEV Feature沿着通道concat的结果,即t时刻和t-1时刻的BEV Feature沿着通道维度concat的结果。

针对2D BEV Encoder模块而言,其包括img_bev_encoder_backbone()和img_bev_encoder_neck()两个子模块,分别完成多尺度的2D特征的提取以及多尺度2D特征的融合。

  • img_bev_encoder_backcone

该模块用于实现对视角转换模块输出的BEV特征进行多尺度的特征提取。输入特征Tensor([bs, 160, BEV_H, BEV_W])

对应的伪代码如下

x = self.img_bev_encoder_backbone(x)

# 输出多尺度的BEV特征
- Tensor([bs, 160, BEV_H / 2, BEV_W / 2]) = f0
- Tensor([bs, 320, BEV_H / 4, BEV_W / 4]) = f1
- Tensor([bs, 640, BEV_H / 8, BEV_W / 8]) = f2
img_bev_encoder_neck

该模块用于实现对img_bev_encoder_backbone模块输出的多尺度特征进行融合,首先将降采样8倍的BEV特征f2上采样4倍大小,然后与f1特征图沿着通道concat到一起,利用卷积操作进行通道上的特征融合。最后输出的特征继续上采样2倍差值以及2D卷积层,获得输出特征Tensor([bs, 256, 200, 200])

对应的伪代码如下

f2, f1 = feats[self.input_feature_index[0]], feats[self.input_feature_index[1]]

f2 = self.up(f2)    
f1 = torch.cat([f2, f1], dim=1)     
x = self.conv(f1)  
x = self.up2(x)     

>>> self.up
>>> Upsample(scale_factor=4.0, mode=bilinear)

>>> self.conv
>>>  Sequential(
  (0): Conv2d(800, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
  (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (2): ReLU(inplace=True)
  (3): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
  (4): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (5): ReLU(inplace=True)
)

>>> self.up2
>>> Sequential(
  (0): Upsample(scale_factor=2.0, mode=bilinear)
  (1): Conv2d(512, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
  (2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (3): ReLU(inplace=True)
  (4): Conv2d(256, 256, kernel_size=(1, 1), stride=(1, 1)))

Occupancy Head模块

对于Occupancy Head,用于去预测空间中的每个体素是否被占据,如果没有被占据就是预测为free类别,如果被占据则预测对应的所属类别。

在进行最终的类别预测之前,首先会将2D BEV Encoder模块的输出过下2D卷积层,然后去联合预测BEV空间每个网格的高度和语义特征,输出Tensor([bs, 200, 200, 288 = num_classes * height])。

对应的伪代码如下

occ_pred = self.final_conv(img_feats).permute(0, 3, 2, 1)        
occ_pred = self.predicter(occ_pred)

>>> self.final_conv
>>> (final_conv): ConvModule(
    (conv): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (activate): ReLU(inplace=True))

>>> self.predicter
>>> Sequential(
    (0): Linear(in_features=256, out_features=512, bias=True)
    (1): Softplus(beta=1, threshold=20)
    (2): Linear(in_features=512, out_features=288, bias=True))

通道高度转换模块

通道高度转换模块也是本文所提出的核心插件,可以理解成Occupancy Head的预测头类似YOLO中的思路,将语义特征和高度联合在一起进行预测,最后通过Tensor的view操作实现2D特征转换为3D的体素预测结果。

occ_pred = occ_pred.view(bs, Dx, Dy, Dz, num_classes)
# Tensor([bs, 200, 200, 288]) -> Tensor([bs, 200, 200, 16, 18])

>>> bs = batch size; 
>>> Dx = BEV空间的大小; Dy = BEV空间的大小; Dz = 体素高度; num_classes = 类别数目

Loss的计算

代码当中主要对模型预测的深度depth信息以及最终的3D分割结果进行了监督学习(计算Loss,更新模型的参数)

  • 针对depth预测的监督,代码当中只对当前帧预测出来的depth信息计算了损失,对于前几帧估计的depth信息不会进行损失的计算。
  • 针对3D体素分割预测的监督,代码对于Occupancy Head输出的结果会利用Cross Entropy计算相应的loss,从而更新模型的参数。

实验部分

下面放一下Flash-Occ的实验效果

Flash-Occ_人工智能_03

不同Occupancy Network算法对比

Flash-Occ_人工智能_04

训练、推理时间,内存占用对比

结论

目前,Occupancy Network由于可以天然的缓解3D感知中的长尾问题,并且还能更加细粒度的描述目标的几何形状信息,所以受到了来自自动驾驶社区非常广泛的关注。但由于对于3D体素的特征表示以及后续的特征提取过程非常占用内存,且3D卷积模块无法部署,严重阻碍了Occupancy Network的上车部署。

本文介绍的Flash-Occ算法提出了一个通道高度转换模块,使得所有操作均和之前的BEV感知操作类似,极大提高了模型的推理速度,降低了模型的运算量,对上车部署更加友好。