Morrison.J Android Dev Engineer

YOLOv5网络结构与代码实现

2021-06-19
Jasper
AI

YOLOv5的网络结构展示,结合分析代码实现,使用Netron进行可视化。

YOLOv5网络结构图

看大图去https://zhuanlan.zhihu.com/p/172121380,或者另存为。

ONNX

可视化部分是先将pt导出为ONNX模型,再通过netron查看。其中,Conv和BN被融合在一起,这是因为BN在推理时无需更新参数,且推理过程满足Conv的计算公式,能合二为一。
好处是加快了推理,在量化任务中,也提高了精度(在高精度先乘,相比转换为低精度再乘,减小了精度损失)。
详情见参考文档

Focus

Focus 模块来源于 TResnet 中的 SpaceToDepth。要知道,传统的缩减尺寸的方法是采用大的kernel,比如Resnet50,7x7的kernel size,而更精细的kernel size应该是3x3。为了降低大卷积核的弊端,SpaceToDepth达到了快速缩减尺寸,同时保留信息的目的。

class Focus(nn.Module):
    # Focus wh information into c-space
    def __init__(self, c1, c2, k=1, s=1, p=None, g=1, act=True):  # ch_in, ch_out, kernel, stride, padding, groups
        super(Focus, self).__init__()
        self.conv = Conv(c1 * 4, c2, k, s, p, g, act)
        # self.contract = Contract(gain=2)

    def forward(self, x):  # x(b,c,w,h) -> y(b,4c,w/2,h/2)
        return self.conv(torch.cat([x[..., ::2, ::2], x[..., 1::2, ::2], x[..., ::2, 1::2], x[..., 1::2, 1::2]], 1))
        # return self.conv(self.contract(x))

接Conv的目的是使得输出通道是确定可知的。

切片举例,x[…, 1::2, ::2],对最后两维进行间隔为2的索引,倒数第二维从1开始,最后一维从0开始。

ONNX使用了两个连续的Slice完成切分,每一个Slice完成一个维度的切分,共两个维度。

CBL

class Conv(nn.Module):
    # Standard convolution
    def __init__(self, c1, c2, k=1, s=1, p=None, g=1, act=True):  # ch_in, ch_out, kernel, stride, padding, groups
        super(Conv, self).__init__()
        self.conv = nn.Conv2d(c1, c2, k, s, autopad(k, p), groups=g, bias=False)
        self.bn = nn.BatchNorm2d(c2)
        self.act = nn.SiLU() 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 fuseforward(self, x):
        return self.act(self.conv(x))

CBL: Conv2d + BatchNorm2d + activation,L原来表示LeakyReLU。在代码中,它等于一个Conv

fuse:将Conv2d 和 BatchNorm2d融合为一层,此时bn可以是去掉或者是nn.Identity()

nn.Identity(): output = input

activation:默认是SiLU,也可以是给定的继承nn.Module的其它激活函数,比如ReLU或者自定义的,甚至是不需要激活函数

SiLU:ONNX没有直接实现,仍然转换为 sigmoid + Mul,Mul是两个输入的按位乘

Res unit

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(Bottleneck, self).__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单元,但也不完全相同,原始的Resnet unit有三个Conv2d,且是先相加,后接激活函数。这里是两个Conv2d,且是先激活,后与输入相加。原始Resnet-Bottleneck的实现:

    def forward(self, x: Tensor) -> Tensor:
        identity = x

        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)
        out = self.relu(out)

        out = self.conv3(out)
        out = self.bn3(out)

        if self.downsample is not None:
            identity = self.downsample(x)

        out += identity
        out = self.relu(out)

        return out

conv1/2/3:Conv2d

本人并不清楚这么做的原因,浏览网络上别人实现的CSResnet的做法中,renset block 是先加后激活。比如: https://github.com/horuakut1/CspDarkNet53_pytoch/blob/main/CspDarkNet-53.py

ONNX可视化也很明确,两个Conv,input直接加到output上。

值得注意的是,Res unit只在CSP中使用。

CSP

yolov5中的CSP模块,设计来源自CSPNet的思路。

参考文档中给出了两个CSP,包括CSP1_X和CPS2_X,并表示一个在backbone中使用,另一个在neck/predict中使用。在yolov5代码中,只有一个,C3。shortcut=True表示CSP1_X,shortcut=False表示CSP2_X。

参考文档的CSP内的Conv模块同样存在先相加还是先激活的差异。

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(C3, self).__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)  # act=FReLU(c2)
        self.m = nn.Sequential(*[Bottleneck(c_, c_, shortcut, g, e=1.0) for _ in range(n)])
        # self.m = nn.Sequential(*[CrossConv(c_, c_, 3, 1, g, 1.0, shortcut) for _ in range(n)])

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

C3:CSP Bottleneck with 3 convolutions,包括三个Conv的CSP模块。

Bootleneck:可以有n个。

CSP模块总结起来就是:

  1. 将输入拆分为两个部分,通道数均等于输出通道的一半(后面会cat起来),x1和x2
  2. x1经过Res unit(n个)
  3. output = Conv(x1 + x2)

注意红色字体,正式上面总结的x1,x2和output,Res unit有3个。

当shortcut=False时,ONNX可视化如下:

过了SPP之后,后面的CSP模块,全部都是CSP2_x:

在yaml中,CSP2_X配置为:[-1, 3, C3, [1024, False]],第二个参数是False。

SPP

class SPP(nn.Module):
    # Spatial pyramid pooling layer used in YOLOv3-SPP
    def __init__(self, c1, c2, k=(5, 9, 13)):
        super(SPP, self).__init__()
        c_ = c1 // 2  # hidden channels
        self.cv1 = Conv(c1, c_, 1, 1)
        self.cv2 = Conv(c_ * (len(k) + 1), c2, 1, 1)
        self.m = nn.ModuleList([nn.MaxPool2d(kernel_size=x, stride=1, padding=x // 2) for x in k])

    def forward(self, x):
        x = self.cv1(x)
        return self.cv2(torch.cat([x] + [m(x) for m in self.m], 1))

MaxPool2d采用不同的kernel,分别对经过Conv的x进行池化操作,得到 len(k) 份feature map,并且,feature map的尺寸与它本身的输入尺寸相同。
cv2的输入通道数正好是被放大了len(k)+1倍,= c_ * (len(k) + 1)

1x1卷积与下采样

整个网络的结构非常清晰,凡是网络中的组成模块,除了独立的CBL,都是以1x1卷积对输入x进行处理。在backbone中,独立的CBL负责下采样,且全部是3x3的卷积核,每次缩放1/2.

1x1卷积的好处:减少参数,降低计算量,加强通道间信息交换,因此,网络可以做得更深更灵活。

比如前面CSP的第一个Conv,就是1x1卷积核,实现了可学习的通道拆分。

再加上Focus、CSPNet的使用,整个网络可以做得非常小,速度非常快。

Neck与特征金字塔

YOLOv5通过上下采样通过两个方向(FPN + PAN)融合了特征信息,盗图:

序号1、2、3正是最终的三个输出节点。

上图对应到顶端的网络结构图:

yaml及其配置

以YOLOv5s.yaml为例.

所有的解析任务都落在函数:def parse_model(d, ch): # model_dict, input_channels(3)

for i, (f, n, m, args) in enumerate(d['backbone'] + d['head']):  # from, number, module, args
    对应于[from, number, module, args]
    n = max(round(n * gd), 1) if n > 1 else n  # depth gain,直接乘系数,不小于1
    c2 = make_divisible(c2 * gw, 8) # width gain,8的倍数
# parameters
nc: 80  # number of classes
depth_multiple: 0.33  # model depth multiple
width_multiple: 0.50  # layer channel multiple

# anchors
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 backbone
backbone:
  # [from, number, module, args]
  [[-1, 1, Focus, [64, 3]],  # 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, 9, 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, 1, SPP, [1024, [5, 9, 13]]],
   [-1, 3, C3, [1024, False]],  # 9
  ]

# YOLOv5 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)
  ]

网络的深度:层数,越多,越深
网络的宽度:通道数,越多,越宽
具体见本小节的代码注释。

depth_multiple:深度配置乘以该系数
width_multiple:宽度配置乘以该系数

5-P4/16:表示第5层,FPN中的P4,缩放16倍。缩放的倍数是输入image的尺寸与当前feature map的倍数关系,可以是2、4、8、16、32。
backbone中的这种信息就表明,Focus层缩放2倍,接着每一个Conv缩放2倍。

backbone和head中的配置,每一项表示:
[from, number, module, args]
并且,除了C3以外,其它的number都是1,对于C3,number表示内含CBL 或者 是Res unit的个数,等于前面CPS1_X和CSP2_X中的X。

当from是一个list时,比如[-1, 4],表示当前层要与第4层进行融合,融合的方法取决于module的类型,只有Concat和Detect会融合多层信息。

总结

YOLOv5的网络结构非常简单,使用了很多trick,SpaceToDepth、CSPNet、FPN/PAN都是很棒的技巧。

代码中还出现了C3TR,想必不久之后就有支持vision transformer的版本出来。

参考

https://zhuanlan.zhihu.com/p/172121380
https://github.com/ultralytics/yolov5
https://github.com/onnx/onnx/blob/master/docs/Operators.md
https://github.com/pytorch/vision/blob/master/torchvision/models/resnet.py
TResnet Paper
融合Conv和BN,加速推理


下一篇 torch nn.Module

Comments

Content