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的实现和后面的模型训练有密切的关系,网络的输出在模型训练中只是起点,后续会介绍模型训练相关的细节。