yolov5网络结构

网络模型解析

本篇主要讲解yolov5的网络模型结构以及其代码实现。
到yolov5为止,yolo系列的网络模型结构发展快速的是1,2,3三代,4,5逐渐稳定优化。网络结构主要分为3大部分,分别是:

  • Backbone
  • Neck
  • Head

如下有多张可视化的示意图:

网络模型的定义

yolov5有5种网络结构,不同之处在于网络复杂度。

本篇以yolov5s为例来讲解。yolov5中使用yaml文件定义网络模型的主干。
yolov5/models/yolov5s.yaml

# YOLOv5 🚀 by Ultralytics, AGPL-3.0 license

# Parameters
nc: 80  # 类别的数量
depth_multiple: 0.33  # 模型的深度
width_multiple: 0.50  # 模型的宽度
anchors:
  - [10,13, 16,30, 33,23]  # P3/8
  - [30,61, 62,45, 59,119]  # P4/16
  - [116,90, 156,198, 373,326]  # P5/32

# YOLOv5 v6.0 backbone
backbone:
  # [from, number, module, args]
  [[-1, 1, Conv, [64, 6, 2, 2]],  # 0-P1/2
   [-1, 1, Conv, [128, 3, 2]],  # 1-P2/4
   [-1, 3, C3, [128]],
   [-1, 1, Conv, [256, 3, 2]],  # 3-P3/8
   [-1, 6, C3, [256]],
   [-1, 1, Conv, [512, 3, 2]],  # 5-P4/16
   [-1, 9, C3, [512]],
   [-1, 1, Conv, [1024, 3, 2]],  # 7-P5/32
   [-1, 3, C3, [1024]],
   [-1, 1, SPPF, [1024, 5]],  # 9
  ]

# YOLOv5 v6.0 head
head:
  [[-1, 1, Conv, [512, 1, 1]],
   [-1, 1, nn.Upsample, [None, 2, 'nearest']],
   [[-1, 6], 1, Concat, [1]],  # cat backbone P4
   [-1, 3, C3, [512, False]],  # 13

   [-1, 1, Conv, [256, 1, 1]],
   [-1, 1, nn.Upsample, [None, 2, 'nearest']],
   [[-1, 4], 1, Concat, [1]],  # cat backbone P3
   [-1, 3, C3, [256, False]],  # 17 (P3/8-small)

   [-1, 1, Conv, [256, 3, 2]],
   [[-1, 14], 1, Concat, [1]],  # cat head P4
   [-1, 3, C3, [512, False]],  # 20 (P4/16-medium)

   [-1, 1, Conv, [512, 3, 2]],
   [[-1, 10], 1, Concat, [1]],  # cat head P5
   [-1, 3, C3, [1024, False]],  # 23 (P5/32-large)

   [[17, 20, 23], 1, Detect, [nc, anchors]],  # Detect(P3, P4, P5)
  ]

nc: 80
代表数据集中的类别数目,例如MNIST中含有0-9共10个类.

depth_multiple: 0.33
用来控制模型的深度,仅在number≠1时启用。 如第一个C3层的参数设置为[-1, 3, C3, [128]],其中number=3,表示在v5s中含有1个C3(3*0.33);同理,v5l中的C3个数就是3(v5l的depth_multiple参数为1)。yolov5的5种网络模型区别就在于depth_multiple和width_multiple不同。越复杂的网络深度值越大。

width_multiple: 0.50
用来控制模型的宽度,主要作用于args中的ch_out。如第一个Conv层,ch_out=64,那么在v5s实际运算过程中,会将卷积过程中的卷积核设为64x0.5,所以会输出32通道的特征图。

backbone

backbone:
  # [from, number, module, args]
  [[-1, 1, Conv, [64, 6, 2, 2]],  # 0-P1/2
   [-1, 1, Conv, [128, 3, 2]],  # 1-P2/4
   [-1, 3, C3, [128]],
   [-1, 1, Conv, [256, 3, 2]],  # 3-P3/8
   [-1, 6, C3, [256]],
   [-1, 1, Conv, [512, 3, 2]],  # 5-P4/16
   [-1, 9, C3, [512]],
   [-1, 1, Conv, [1024, 3, 2]],  # 7-P5/32
   [-1, 3, C3, [1024]],
   [-1, 1, SPPF, [1024, 5]],  # 9
  ]

from:-n代表是从前n层获得的输入,如-1表示从前一层获得输入
number:表示网络模块的数目,如[-1, 3, C3, [128]]表示含有3个C3模块
model:表示网络模块的名称,具体细节可以在./models/common.py查看,如Conv、C3、SPPF都是已经在common中定义好的模块
args:表示向不同模块内传递的参数,即[ch_out, kernel, stride, padding, groups],这里连ch_in都省去了,因为输入都是上层的输出(初始ch_in为3)。为了修改过于麻烦,这里输入的获取是从./models/yolo.py的def parse_model(md, ch)函数中解析得到的。

总体来说网络模型中包括的网络块有如下:

  • Conv
  • C3
  • SPPF
  • nn.Upsample
  • Concat
  • Detect

网络模型由这些网络块构成,熟悉这些网络块是学习网络模型必要的积累。下面就从代码的角度来分析这些块如何实现。网络块的介绍过程以yaml文件中定义的网络层结构为主,而不是可视化图形。

Conv

Conv又可以称为CBS,是由Conv2d + Batch + SiLU组成的模块。Conv是最基础的模块,很多高级模块会多次使用Conv模块。

class Conv(nn.Module):
    # Standard convolution with args(ch_in, ch_out, kernel, stride, padding, groups, dilation, activation)
    default_act = nn.SiLU()  # default activation

    def __init__(self, c1, c2, k=1, s=1, p=None, g=1, d=1, act=True):
        super().__init__()
        self.conv = nn.Conv2d(c1, c2, k, s, autopad(k, p, d), groups=g, dilation=d, bias=False)
        self.bn = nn.BatchNorm2d(c2)
        self.act = self.default_act if act is True else act if isinstance(act, nn.Module) else nn.Identity()

    def forward(self, x):
        return self.act(self.bn(self.conv(x)))

    def forward_fuse(self, x):
        return self.act(self.conv(x))

在所有的网络块定义中,forward方法是网络块执行的主要逻辑。Conv的主要逻辑就是一句话:self.act(self.bn(self.conv(x))),也就是对输入的数据先执行卷积,然后再执行bn,最后执行激活函数SILU。

C3

C3的整体网络结构如下:

其中包含一个Resx,也就是Bottleneck残差网络。首先将一下这个残差网络。

要想了解Bottleneck,还要从Resnet说起。在Resnet出现之前,人们的普遍为网络越深获取信息也越多,模型泛化效果越好。然而随后大量的研究表明,网络深度到达一定的程度后,模型的准确率反而大大降低。这并不是过拟合造成的,而是由于反向传播过程中的梯度爆炸和梯度消失。也就是说,网络越深,模型越难优化,而不是学习不到更多的特征

为了能让深层次的网络模型达到更好的训练效果,残差网络中提出的残差映射替换了以往的基础映射。结构如下:

对于输入x,期望输出H(x),网络利用恒等映射将x作为初始结果,将原来的映射关系变成F(x)+x。与其让多层卷积去近似估计H(x) ,不如近似估计H(x)-x,即近似估计残差F(x)。因此,ResNet相当于将学习目标改变为目标值H(x)和x的差值,后面的训练目标就是要将残差结果逼近于0。
残差模块有什么好处呢?
1.梯度弥散方面。加入ResNet中的shortcut结构之后,在反传时,每两个block之间不仅传递了梯度,还加上了求导之前的梯度,这相当于把每一个block中向前传递的梯度人为加大了,也就会减小梯度弥散的可能性。
2.特征冗余方面。正向卷积时,对每一层做卷积其实只提取了图像的一部分信息,这样一来,越到深层,原始图像信息的丢失越严重,而仅仅是对原始图像中的一小部分特征做提取。这显然会发生类似欠拟合的现象。加入shortcut结构,相当于在每个block中又加入了上一层图像的全部信息,一定程度上保留了更多的原始信息。

在resnet中,人们可以使用带有shortcut的残差模块搭建几百层甚至上千层的网络,而浅层的残差模块被命名为Basicblock(18、34),深层网络所使用的的残差模块,就被命名为了Bottleneck(50+)

class Bottleneck(nn.Module):
    # Standard bottleneck
    def __init__(self, c1, c2, shortcut=True, g=1, e=0.5):  # ch_in, ch_out, shortcut, groups, expansion
        super().__init__()
        c_ = int(c2 * e)  # hidden channels
        self.cv1 = Conv(c1, c_, 1, 1)
        self.cv2 = Conv(c_, c2, 3, 1, g=g)
        self.add = shortcut and c1 == c2

    def forward(self, x):
        return x + self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x))

resnet中的shortcut通过add实现,是特征图对应位置相加而通道数不变;而CSP中的shortcut通过concat实现,是通道数的增加。二者虽然都是信息融合的主要方式,但是对张量的具体操作又不相同

对于shortcut是可根据任务要求设置的,比如在backbone中shortcut=True,neck中shortcut=False。
当shortcut=True时,Resx如图:

当shortcut=False时,Resx如图:

C3网络结构:

class C3(nn.Module):
    # CSP Bottleneck with 3 convolutions
    def __init__(self, c1, c2, n=1, shortcut=True, g=1, e=0.5):  # ch_in, ch_out, number, shortcut, groups, expansion
        super().__init__()
        c_ = int(c2 * e)  # hidden channels
        self.cv1 = Conv(c1, c_, 1, 1)
        self.cv2 = Conv(c1, c_, 1, 1)
        self.cv3 = Conv(2 * c_, c2, 1)  # optional act=FReLU(c2)
        self.m = nn.Sequential(*(Bottleneck(c_, c_, shortcut, g, e=1.0) for _ in range(n)))

    def forward(self, x):
        return self.cv3(torch.cat((self.m(self.cv1(x)), self.cv2(x)), 1))

SPPF

spp原来并行的结构改成了串行结构,减少了计算量(速度更快,f即fast),而且发挥spp相同的作用。SPP的逻辑如下,通过最大池化,将一个特征经过不同的池化,最终输出一个固定大小的特征。

SSPF模块将经过CBS的x、一次池化后的y1、两次池化后的y2和3次池化后的self.m(y2)先进行拼接,然后再CBS提取特征。
仔细观察不难发现,虽然SSPF对特征图进行了多次池化,但是特征图尺寸并未发生变化,通道数更不会变化,所以后续的4个输出能够在channel维度进行融合。
这一模块的主要作用是对高层特征进行提取并融合,在融合的过程中作者多次运用最大池化,尽可能多的去提取高层次的语义特征。

class SPPF(nn.Module):
    # Spatial Pyramid Pooling - Fast (SPPF) layer for YOLOv5 by Glenn Jocher
    def __init__(self, c1, c2, k=5):  # equivalent to SPP(k=(5, 9, 13))
        super().__init__()
        c_ = c1 // 2  # hidden channels
        self.cv1 = Conv(c1, c_, 1, 1)
        self.cv2 = Conv(c_ * 4, c2, 1, 1)
        self.m = nn.MaxPool2d(kernel_size=k, stride=1, padding=k // 2)

    def forward(self, x):
        x = self.cv1(x)
        with warnings.catch_warnings():
            warnings.simplefilter('ignore')  # suppress torch 1.9.0 max_pool2d() warning
            y1 = self.m(x)
            y2 = self.m(y1)
            return self.cv2(torch.cat((x, y1, y2, self.m(y2)), 1))

Concat

concat就是pytorch的一个普通的张量连接操作,只会增加张量的长宽,不会增加张量的维度

class Concat(nn.Module):
    # Concatenate a list of tensors along dimension
    def __init__(self, dimension=1):
        super().__init__()
        self.d = dimension

    def forward(self, x):
        return torch.cat(x, self.d)

Detect

class Detect(nn.Module):
    # YOLOv5 Detect head for detection models
    stride = None  # strides computed during build
    dynamic = False  # force grid reconstruction
    export = False  # export mode

    def __init__(self, nc=80, anchors=(), ch=(), inplace=True):  # detection layer
        super().__init__()
        self.nc = nc  # number of classes
        self.no = nc + 5  # number of outputs per anchor
        self.nl = len(anchors)  # number of detection layers
        self.na = len(anchors[0]) // 2  # number of anchors
        self.grid = [torch.empty(0) for _ in range(self.nl)]  # init grid
        self.anchor_grid = [torch.empty(0) for _ in range(self.nl)]  # init anchor grid
        self.register_buffer('anchors', torch.tensor(anchors).float().view(self.nl, -1, 2))  # shape(nl,na,2)
        self.m = nn.ModuleList(nn.Conv2d(x, self.no * self.na, 1) for x in ch)  # output conv
        self.inplace = inplace  # use inplace ops (e.g. slice assignment)

    def forward(self, x):
        z = []  # inference output
        '''
        (Pdb) x[0].shape
        torch.Size([1, 128, 80, 80])
        (Pdb) x[1].shape
        torch.Size([1, 256, 40, 40])
        (Pdb) x[2].shape
        torch.Size([1, 512, 20, 20])
        '''
        for i in range(self.nl):
            x[i] = self.m[i](x[i])  # conv
            '''
            ModuleList(
              (0): Conv2d(128, 21, kernel_size=(1, 1), stride=(1, 1))
              (1): Conv2d(256, 21, kernel_size=(1, 1), stride=(1, 1))
              (2): Conv2d(512, 21, kernel_size=(1, 1), stride=(1, 1))
            )
            '''

            bs, _, ny, nx = x[i].shape  # x(bs,255,20,20) to x(bs,3,20,20,85)
            x[i] = x[i].view(bs, self.na, self.no, ny, nx).permute(0, 1, 3, 4, 2).contiguous()
            '''
            输出的一个数据:xywh + conf + 2个类别。现在输出的只是80*80*7形状的张量,和结果还没有关系
            (Pdb) pp x[i][0,0,0,0]
            tensor([-1.40918, -0.84424,  2.11133, -0.08771, -7.38672, -1.87695, -1.65625], 
            device='cuda:0', dtype=torch.float16, grad_fn=<SelectBackward0>)
            '''

            if not self.training:  # inference
                if self.dynamic or self.grid[i].shape[2:4] != x[i].shape[2:4]:
                    self.grid[i], self.anchor_grid[i] = self._make_grid(nx, ny, i)

                if isinstance(self, Segment):  # (boxes + masks)
                    xy, wh, conf, mask = x[i].split((2, 2, self.nc + 1, self.no - self.nc - 5), 4)
                    xy = (xy.sigmoid() * 2 + self.grid[i]) * self.stride[i]  # xy
                    wh = (wh.sigmoid() * 2) ** 2 * self.anchor_grid[i]  # wh
                    y = torch.cat((xy, wh, conf.sigmoid(), mask), 4)
                else:  # Detect (boxes only)
                    xy, wh, conf = x[i].sigmoid().split((2, 2, self.nc + 1), 4)
                    xy = (xy * 2 + self.grid[i]) * self.stride[i]  # xy
                    wh = (wh * 2) ** 2 * self.anchor_grid[i]  # wh
                    y = torch.cat((xy, wh, conf), 4)
                z.append(y.view(bs, self.na * nx * ny, self.no))

        return x if self.training else (torch.cat(z, 1),) if self.export else (torch.cat(z, 1), x)

YOLOv5的输出端head头代码解读 - 海_纳百川 - 博客园
网络结构包含三个部分,分别是:backbone、Neck、Head。head是三者中运算较多,技巧较多的部分。

输入输出

Detect 的输入是三个不同尺度的CSP的输出。
Detect 的处理流程大体分成两个方向,一个是训练时的处理,一个是推理时的处理。两者不同之处在于模型的输出结果处理。模型经过前面的网络处理之后,输出的是相对于grid cell 左上角的偏移量,该偏移量是归一化处理的结果。对于这个结果,训练和推理的处理方式并不一样。

推理

xy, wh, conf = x[i].sigmoid().split((2, 2, self.nc + 1), 4)
xy = (xy * 2 + self.grid[i]) * self.stride[i]  # xy
wh = (wh * 2) ** 2 * self.anchor_grid[i]  # wh
y = torch.cat((xy, wh, conf), 4)

模型推理的输出,首先需要将预测值回归到网格中,然后需要将偏移量还原成到当前尺度上(当前尺度是2020,4040,80*80,并不是原图),包括对中心点和宽高。

根据该计算公式可以看出,模型网络输出的结果经常相应的运算才能还原成坐标框。

xy = (xy * 2 + self.grid[i]) * self.stride[i]

xy的范围是[-0.5, 1.5],这表明坐标点可以超越该grid cell,出现在网格以外。这么设计的原因是当目标落在边界上时往往预测会比较困难,将参数回归到0或1比较难。但是如果预测的极限不在0,1,而是-0.5,1.5,那么就相对容易。

wh = (wh * 2) ** 2 * self.anchor_grid[i]  # wh

wh的范围是[0, 4],表示可以预测的范围是anchor的宽高的0倍至4倍。
注意:中心点xy的坐标是相对网格左上角的偏移量,而宽高的取值是相对anchor的比例。

训练

训练的输出并不需要做任何处理,那是因为在训练后面的损失计算流程中会做和推理相同的操作,在Detect中没做,后面也会完成。Detect中训练直接返回预测结果,预测结果包括三个尺度的结果。
如下在损失计算时会将训练的预测值回归到网格中,和推理时的操作一致。

小结

yaml中定义了yolov5的网络结构,可以看到整个网络的组成。再分析每一个网络块的实现,深入理解网络细节。特别是Detect的实现和后面的模型训练有密切的关系,网络的输出在模型训练中只是起点,后续会介绍模型训练相关的细节。

posted @ 2024-07-05 10:08  金色旭光  阅读(645)  评论(0编辑  收藏  举报