0. 简介

作为22年比较重磅的物体识别算法,作者觉得不得不说一说,虽然作者目前主要方向不是深度学习了,但是里面很多重要的操作还是值得回味的。这里就从想要大致了解Yolo v7同学的眼光来对v7的算法进行介绍。并按照原文《YOLOv7: Trainable bag-of-freebies sets new state-of-the-art for real-time object detectors》的顺序,来向读者介绍相较于之前的Yolo算法加了什么操作。与它的前辈一样,Yolo v7的代码是完全开源的,代码在Github上https://github.com/WongKinYiu/yolov7
在这里插入图片描述

1. Yolo v7的性能与结构

为了让我们对比,论文中,官方将YOLOv7相同体量下比YOLOv5精度更高,速度快120%(FPS),比 YOLOX 快180%(FPS),比 Dual-Swin-T 快1200%(FPS),比 ConvNext 快550%(FPS),比 SWIN-L快500%(FPS)。同时在精度为56.8% AP下模型仍可达到30 FPS以上的检测速率。这也是截止到目前最优目标识别算法了。
在这里插入图片描述
下面我们来看一下Yolo v7模型中整个流程图,其实比较简单的来看,其实Yolo v7将整个模型分成了三个大块。具体结构和Yolo v5挺类似的,主要就是对网络内部组件进行了更换,比如辅助训练头、标签分配思想等,但是基本上整体结构还是保持了Yolo的那一套处理。
在这里插入图片描述

2. Yolo v7 中新的模块

在Yolo v7中,我们可以看大,算法框架中多出了三种模块:E-ELAN模块、SPPSPC模块、REP模块。

2.1 Extended-ELAN模块

首先我们来看一下E-ELAN模块,该模块是通过控制最短最长的梯度路径,让更深的网络可以有效的学习和收敛的,作者提出的E-ELAN对基数(Cardinality)做了扩展(Expand)、乱序(Shuffle)、合并(Merge cardinality),能在不破坏原始梯度路径的情况下,提高网络的学习能力。
在这里插入图片描述
但是实际上作者并未实现E-ELAN,所以在这篇博文中,作者指出E-ELAN的改进,其等效网络就是两个ELAN(红框里)的Concat,作者的解释是:

For E-ELAN architecture, since our edge device do not support group convolution and shuffle operation, we are forced to implement it as an equivalence architecture.

在这里插入图片描述
对应部分在yolov7.yaml
ELAN (backbone)

# ELAN1
   [-1, 1, Conv, [64, 1, 1]],
   [-2, 1, Conv, [64, 1, 1]],
   [-1, 1, Conv, [64, 3, 1]],
   [-1, 1, Conv, [64, 3, 1]],
   [-1, 1, Conv, [64, 3, 1]],
   [-1, 1, Conv, [64, 3, 1]],
   [[-1, -3, -5, -6], 1, Concat, [1]],
   [-1, 1, Conv, [256, 1, 1]],  # 11

简化结构如下,其中CBS是Conv+BN+LeakyReLu的结合。
在这里插入图片描述

ELAN-W (head)

# ELAN2
   [-1, 1, Conv, [256, 1, 1]],
   [-2, 1, Conv, [256, 1, 1]],
   [-1, 1, Conv, [128, 3, 1]],
   [-1, 1, Conv, [128, 3, 1]],
   [-1, 1, Conv, [128, 3, 1]],
   [-1, 1, Conv, [128, 3, 1]],
   [[-1, -2, -3, -4, -5, -6], 1, Concat, [1]],
   [-1, 1, Conv, [256, 1, 1]], # 63

在这里插入图片描述

2.2 MP结构

我们可以看到这个结构存在两个头用于concat,之前下采样我们通常最开始使用maxpooling,之后大家又都选用stride = 2的3*3卷积,在Yolo v7中作者同时使用了max pooling 和 stride=2的conv。需要注意head中的MP前后通道数是不变的,而backbone处通道数减少一半。

MP-1 (backbone)

   [-1, 1, Conv, [256, 1, 1]],  # 11

   # MPConv
   [-1, 1, MP, []],
   [-1, 1, Conv, [128, 1, 1]],
   [-3, 1, Conv, [128, 1, 1]],
   [-1, 1, Conv, [128, 3, 2]],
   [[-1, -3], 1, Concat, [1]],  # 16-P3/8

在这里插入图片描述
MP-2 (head)

   [-1, 1, Conv, [128, 1, 1]], # 75

   # MPConv Channel × 2
   [-1, 1, MP, []],
   [-1, 1, Conv, [128, 1, 1]],
   [-3, 1, Conv, [128, 1, 1]],
   [-1, 1, Conv, [128, 3, 2]],
   [[-1, -3, 63], 1, Concat, [1]],

在这里插入图片描述

2.3 SPPCSPC模块

类似于yolov5中的SPPF,不同的是,使用了5×5、9×9、13×13最大池化。在在yolov7.yaml中,只使用了一次SPPSPC模块,在backbone与head对接的地方。可以看到,总的输入会被分成两段进入不同的分支,最中间的分支其实就是金字塔池化操作,上侧则为一个point conv,最后将所有分支输出的信息流进行concat。

# yolov7 head
head:
  [[-1, 1, SPPCSPC, [512]], # 51

   [-1, 1, Conv, [256, 1, 1]],
   [-1, 1, nn.Upsample, [None, 2, 'nearest']],
   [37, 1, Conv, [256, 1, 1]], # route backbone P4
   [[-1, -2], 1, Concat, [1]],

在这里插入图片描述

3. RepConv(重参数卷积)

模块重参化是近年来一个比较流行的研究课题。这种方法在训练过程中将一个整体模块分割为多个相同或不同的模块分支,但在推理过程中将多个分支模块集成到一个完全等价的模块中。模型重参化策略在推理阶段将多个模块合并为一个计算模块,可以看作是一种集成技术(model ensemble,其实笔者觉得更像是一种基于feature的distillation)。对于模型级重新参数化有两种常见的操作:

  • 一种是用不同的训练数据训练多个相同的模型,然后对多个训练模型的权重进行平均。
  • 一种是对不同迭代次数下模型权重进行加权平均。

在这里插入图片描述
这个重参数卷积(Planned re-parameterized convolution)也是一个非常重要的创新,RepConv在VGG中有比较优异的性能,但当它直接应用于ResNet、DenseNet或者其他架构时,精度会明显降低。这是因为RepConv中的直连(Identity connection)破坏了ResNet中的残差和DenseNet中的连接。
在这里插入图片描述
基于上述原因,作者使用没有identity连接的RepConv结构。图4显示了作者在PlainNet和ResNet中使用的“计划型重参化卷积”的一个示例。
在这里插入图片描述
我们知道重参数化可以在保证模型性能的条件下加速网络,主要是对卷积+BN层以及不同卷积进行融合,合并为一个卷积模块。下面给出了卷积+BN融合的公式化过程,红色表示卷积参数(权重和偏置),蓝色是BN参数($m$是输入均值,$v$是输入标准差,$\gamma$和$\beta$是两个可学习的参数),最终经过一系列化简,融合成了一个卷积:
在这里插入图片描述
在YOLOv7中,REP在训练和部署的时候结构不同,在训练的时候由3_3的卷积添加1_1的卷积分支,同时如果输入和输出的channel以及$h,w$的size一致时,再添加一个BN的分支,三个分支相加输出,在部署时,为了方便部署,会将分支的参数重参数化到主分支上,取3*3的主分支卷积输出。

   [75, 1, RepConv, [256, 3, 1]],
   [88, 1, RepConv, [512, 3, 1]],
   [101, 1, RepConv, [1024, 3, 1]],

在这里插入图片描述
下面是对应的代码:

class RepConv(nn.Module):
    '''重参数卷积
    训练时:
        deploy = False
        rbr_dense(3*3卷积) + rbr_1x1(1*1卷积) + rbr_identity(c2 == c1时) 三者相加
        rbr_reparam = None
    推理时:
        deploy = True
        rbr_reparam = Conv2d
        rbr_dense = None
        rbr_1x1 = None
        rbr_identity = None
    '''
    def __init__(self, c1, c2, k=3, s=1, p=None, g=1, act=True, deploy=False):
        super(RepConv, self).__init__()

        self.deploy = deploy
        self.groups = g
        self.in_channels = c1
        self.out_channels = c2

        assert k == 3
        assert autopad(k, p) == 1

        padding_11 = autopad(k, p) - k // 2

        self.act = nn.SiLU() if act is True else (act if isinstance(act, nn.Module) else nn.Identity())

        # 推理阶段,仅有一个3×3的卷积来替换
        if deploy:
            self.rbr_reparam = nn.Conv2d(c1, c2, k, s, autopad(k, p), groups=g, bias=True)

        else:
            # 训练阶段,当输入和输出的通道数相同时,会在加一个BN层
            self.rbr_identity = (nn.BatchNorm2d(num_features=c1) if c2 == c1 and s == 1 else None)
            # 3×3的卷积(padding=1)
            self.rbr_dense = nn.Sequential(
                nn.Conv2d(c1, c2, k, s, autopad(k, p), groups=g, bias=False),
                nn.BatchNorm2d(num_features=c2),
            )
            # 1×1的卷积
            self.rbr_1x1 = nn.Sequential(
                nn.Conv2d(c1, c2, 1, s, padding_11, groups=g, bias=False),
                nn.BatchNorm2d(num_features=c2),
            )

    def forward(self, inputs):
        if hasattr(self, "rbr_reparam"):
            return self.act(self.rbr_reparam(inputs))

        if self.rbr_identity is None:
            id_out = 0
        else:
            id_out = self.rbr_identity(inputs)

        return self.act(self.rbr_dense(inputs) + self.rbr_1x1(inputs) + id_out)

    # Conv2D + BN -> Conv2D
    def fuse_conv_bn(self, conv, bn):

        std = (bn.running_var + bn.eps).sqrt()
        bias = bn.bias - bn.running_mean * bn.weight / std

        t = (bn.weight / std).reshape(-1, 1, 1, 1)
        weights = conv.weight * t

        bn = nn.Identity()
        conv = nn.Conv2d(in_channels=conv.in_channels,
                         out_channels=conv.out_channels,
                         kernel_size=conv.kernel_size,
                         stride=conv.stride,
                         padding=conv.padding,
                         dilation=conv.dilation,
                         groups=conv.groups,
                         bias=True,
                         padding_mode=conv.padding_mode)

        conv.weight = torch.nn.Parameter(weights)
        conv.bias = torch.nn.Parameter(bias)
        return conv

    # 在推理阶段才执行重参数操作
    def fuse_repvgg_block(self):
        if self.deploy:
            return
        print(f"RepConv.fuse_repvgg_block")

        self.rbr_dense = self.fuse_conv_bn(self.rbr_dense[0], self.rbr_dense[1])
        self.rbr_1x1 = self.fuse_conv_bn(self.rbr_1x1[0], self.rbr_1x1[1])
        rbr_1x1_bias = self.rbr_1x1.bias
        # self.rbr_1x1.weight [256, 128, 1, 1]
        # weight_1x1_expanded [256, 128, 3, 3]
        weight_1x1_expanded = torch.nn.functional.pad(self.rbr_1x1.weight, [1, 1, 1, 1])

        # Fuse self.rbr_identity
        if (isinstance(self.rbr_identity, nn.BatchNorm2d) or isinstance(self.rbr_identity,
                                                                        nn.modules.batchnorm.SyncBatchNorm)):
            # print(f"fuse: rbr_identity == BatchNorm2d or SyncBatchNorm")
            identity_conv_1x1 = nn.Conv2d(
                in_channels=self.in_channels,
                out_channels=self.out_channels,
                kernel_size=1,
                stride=1,
                padding=0,
                groups=self.groups,
                bias=False)
            identity_conv_1x1.weight.data = identity_conv_1x1.weight.data.to(self.rbr_1x1.weight.data.device)
            identity_conv_1x1.weight.data = identity_conv_1x1.weight.data.squeeze().squeeze()
            # print(f" identity_conv_1x1.weight = {identity_conv_1x1.weight.shape}")
            identity_conv_1x1.weight.data.fill_(0.0)
            identity_conv_1x1.weight.data.fill_diagonal_(1.0)
            identity_conv_1x1.weight.data = identity_conv_1x1.weight.data.unsqueeze(2).unsqueeze(3)
            # print(f" identity_conv_1x1.weight = {identity_conv_1x1.weight.shape}")

            identity_conv_1x1 = self.fuse_conv_bn(identity_conv_1x1, self.rbr_identity)
            bias_identity_expanded = identity_conv_1x1.bias
            weight_identity_expanded = torch.nn.functional.pad(identity_conv_1x1.weight, [1, 1, 1, 1])
        else:
            # print(f"fuse: rbr_identity != BatchNorm2d, rbr_identity = {self.rbr_identity}")
            bias_identity_expanded = torch.nn.Parameter(torch.zeros_like(rbr_1x1_bias))
            weight_identity_expanded = torch.nn.Parameter(torch.zeros_like(weight_1x1_expanded))

            # print(f"self.rbr_1x1.weight = {self.rbr_1x1.weight.shape}, ")
        # print(f"weight_1x1_expanded = {weight_1x1_expanded.shape}, ")
        # print(f"self.rbr_dense.weight = {self.rbr_dense.weight.shape}, ")

        self.rbr_dense.weight = torch.nn.Parameter(
            self.rbr_dense.weight + weight_1x1_expanded + weight_identity_expanded)
        self.rbr_dense.bias = torch.nn.Parameter(self.rbr_dense.bias + rbr_1x1_bias + bias_identity_expanded)

        self.rbr_reparam = self.rbr_dense
        # 前向推理时,使用重参数化后的 rbr_reparam 函数
        self.deploy = True

        if self.rbr_identity is not None:
            del self.rbr_identity
            self.rbr_identity = None

        if self.rbr_1x1 is not None:
            del self.rbr_1x1
            self.rbr_1x1 = None

        if self.rbr_dense is not None:
            del self.rbr_dense
            self.rbr_dense = None

4. 辅助头检测

最后一块值得注意的就是辅助检测头,深度监督是一种常用于训练深度网络的技术,其主要概念是在网络的中间层增加额外的辅助头,以及以辅助损失为指导的浅层网络权重。Yolo v7将head部分的浅层特征提取出来作为Auxiliary head(辅助头),深层特征也就是网络的最终输出作为Lead head(引导头)。
在这里插入图片描述
图中的(d),(e)是基于lead head预测,生成从粗到细的层次标签,分别用于lead head和auxiliary head的学习。(d)让较浅的auxiliary head学习lead head已经学习到的信息,而输lead head则可以更专注于为学习到的残差信息。(e)图中,会生成两组软标签,即粗标签和细标签。auxiliary head不如lead head学习能力强,因此要重点优化它的召回率,避免丢失掉需要学习的信息。其中值得注意的如下:

  • lead head中每个网格与gt如果匹配上,附加周边两个网格,而aux head附加4个网格。
  • lead head中将top10个样本iou求和取整,而aux head中取top20。
  • aux head更关注于recall,而lead head从aux head中精准筛选出样本。
    在这里插入图片描述

以training/yolov7-w6.yaml为例,最后detect模块的前四层为lead head,后四层为aux head,在推理时,只取前四层作为detect层的输出:
在这里插入图片描述

5. 动态标签分配

这部分内容博客已经写得很详细了,这里稍作理解整合,Yolo v7主要是参考了YOLOV5 和YOLOV6使用的当下比较火的simOTA。主要部分主要分成6步,其中前三步与辅助检测头相关。

  1. 训练前,会基于训练集中gt框,通过k-means聚类算法,先验获得9个从小到大排列的anchor框。(可选)

    1. 将每个gt与9个anchor匹配:Yolov5为分别计算它与9种anchor的宽与宽的比值(较大的宽除以较小的宽,比值大于1,下面的高同样操作)、高与高的比值,在宽比值、高比值这2个比值中,取最大的一个比值,若这个比值小于设定的比值阈值,这个anchor的预测框就被称为正样本。一个gt可能与几个anchor均能匹配上(此时最大9个)。所以一个gt可能在不同的网络层上做预测训练,大大增加了正样本的数量,当然也会出现gt与所有anchor都匹配不上的情况,这样gt就会被当成背景,不参与训练,说明anchor框尺寸设计的不好。

    2. 扩充正样本。根据gt框的中心位置,将最近的2个邻域网格也作为预测网格,也即一个groundtruth框可以由3个网格来预测;可以发现粗略估计正样本数相比前yolo系列,增加了三倍(此时最大27个匹配)。图下图浅黄色区域,其中实线是YOLO的真实网格,虚线是将一个网格四等分,如这个例子中,GT的中心在右下虚线网格,则扩充右和下真实网格也作为正样本。(辅助头则选择周围4个邻域网格)
      在这里插入图片描述

    3. 获取与当前gt有top10最大iou的prediction结果。将这top10 (5-15之间均可,并不敏感)iou进行sum,就为当前gt的k。k最小取1。

    4. 根据损失函数计算每个GT和候选anchor损失(前期会加大分类损失权重,后面减低分类损失权重,如1:5->1:3),并保留损失最小的前K个。

    5. 去掉同一个anchor被分配到多个GT的情况。

6. 参考链接

https://zhuanlan.zhihu.com/p/540068099

https://blog.csdn.net/u012863603/article/details/126118799

https://zhuanlan.zhihu.com/p/553720816

https://blog.csdn.net/weixin_42206075/article/details/126246076

https://blog.csdn.net/weixin_43799388/article/details/126314633