【yolov5系列】yolov5的原理与核心代码解析

打算写yolov5源码阅读和总结,已经打算了一年,如今已经更新到yolov8,只能说自己行动太慢了,哭泣(๑>؂<๑)。趁着看要yolov8一起赶紧把yolov5总结总结。

一、Yolov5的网络结构

【yolov5系列】yolov5的原理与核心代码解析_第1张图片
模型主要分为3部分

  • backbone:C3、SPPF、Conv_BN_SiLU(strides=2,用于下采样)
  • neck:FPN、PAN
  • head:Conv_BN_SiLU、输出

1 backbone模块

1.1 ConvBNSiLU

对与yolov5,最基础的模块 卷积+BN+激活层 为【Conv_BN_SiLU】,下面使用conv直接指代,这部分的实现如下

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().__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 forward_fuse(self, x):
       return self.act(self.conv(x))

1.2 C3

C3代替了yolov5较早版本中使用的CSP,起到作用:简化了体系结构,减少了参数计数,并在推理时更好地利用fuse。这里只花了C3的结构如下
【yolov5系列】yolov5的原理与核心代码解析_第2张图片

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))
       
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))

1.3 SPP vs SPPF

先摆出SPP和SPPF的对比图
【yolov5系列】yolov5的原理与核心代码解析_第3张图片
分析一下两者区别

  • SPP(空间金字塔池化):
    原论文解读在 SPPNet,这里的SPP是借鉴了SPPNet中的模块,使用不同kernel的池化层对featuremap进行信息提取然后concat。具体的如下:
    1)先在通过一个ConvBNSiLU。
    2)然后分别通过三个MaxPool2D,默认stride=1,且进行padding,如此整个过程不会改变featuremap的尺寸;
    对应的kernel分别为5,9,13,对应的感受野分别为5*5,9*9,13*13。
    3)然后进行concat,再通过ConvBNSiLU得到模块最后的输出。
  • SPPF:
    与SPP不同的是MaxPool2D的堆叠方式。

分析:

  • 对于9*9的MaxPool2D 等价与 两个5*5的MaxPoolD2D的堆叠;13*13的MaxPoolD等价于三个5*5的MaxPoolD2D的堆叠,可将SPP的结构修改成下图左图;然后对左图是对右图的pool进行了合并连接。其中左图是完全等价SPP的,右图是则SPPF,与上图右图完全一致。
    如此,SPPF相较于SPP的效果精度并未改变的同时,节省了三个k=5的MaxPool2D的计算,较大的提升了运行时间
    【yolov5系列】yolov5的原理与核心代码解析_第4张图片
  • 注意:
    特征图的输入尽量保证尺寸 大于等于13、且靠近13,此时网络的状态将会相对更优。
    1)若SPPF的输入尺寸为13,神经网络此时输入尺寸为13*32=418。此时效果和运行时间达到最佳
    2)若SPPF的输入尺寸为8(小于13较多),MaxPool2D提供13的感受野 将会有冗余的操作以及冗余的时间消耗。此时对于SPPF可去掉一个MaxPool2D,节省时间的同时获取相近的效果。
    3)若SPPF的输入尺寸为25 (大于13较多),MaxPool2D提供的13的感受野 无法获取到图像的全局特征,此时效果将会降低一些,自己在训练时可留意这里的处理。
class SPP(nn.Module):
   # Spatial Pyramid Pooling (SPP) layer https://arxiv.org/abs/1406.4729
   def __init__(self, c1, c2, k=(5, 9, 13)):
       super().__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)
       with warnings.catch_warnings():
           warnings.simplefilter('ignore')  # suppress torch 1.9.0 max_pool2d() warning
           return self.cv2(torch.cat([x] + [m(x) for m in self.m], 1))

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))

2 neck模块

neck模块部分, 包含 FPN (Feature Pyramid Network)、PAN (Path Aggregation Network)。两者都是在解决目标检测中 多尺度检测任务上的不足,都是将高层的语义信息与低层的空间信息结合,然后再进行信息提取。换句话说就是 自上而下或自下而上地融合不同尺度信息的特征。

FPN和PAN都有对应的论文和完整的网络结构,但在后来的使用中,很少指原论文中的网络结构,而是来强调 不同特征进行融合的模块。FPN是unsample+merge,PAN是downsample+merge,其中merge有add、concat,具体为哪种形式看每个工程自己的使用情况。在yolov5中,使用的是concat。
【yolov5系列】yolov5的原理与核心代码解析_第5张图片


2.1 FPN

Feature Pyramid Network是由FAIR在2017年提出的一种处理多尺度问题的方法。
【yolov5系列】yolov5的原理与核心代码解析_第6张图片
上图是多尺度问题,其中(d)为FPN。具体的细节如下,主打一个upsample + concat,将不同尺度的特征图融合,然后再特征提取
【yolov5系列】yolov5的原理与核心代码解析_第7张图片


2.2 PAN

Path Aggregation Network是由Megvii在2018年提出的一种处理多尺度问题的方法。与FPN类似,PAN也是一种金字塔式的特征提取网络,但是它采用的是自下而上的特征传播方式。
【yolov5系列】yolov5的原理与核心代码解析_第8张图片

3 head

对于检测头,就是一个ConvBNSiLU,然后得到神经网络的输出。
【yolov5系列】yolov5的原理与核心代码解析_第9张图片
共有三个尺度的输出分别为 1x255x20x20、1x255x40x40、1x255x80x80。其中

  • 1】 为batch
  • 255】 为 (80+1+4)*3:
    • 80:class】 为coco数据集中目标检测的类别数,将其转换成one-hot形式;
    • 1:confidence】 为是否为目标;
    • 4:box】 为检测框对应的xywh,具体的为:xy检测框的中心下采样到某层输出上,距离grid ceil 左上角的偏移,wh为检测框的长宽与当前anchor的长宽的比值。
    • 3】 为在每个grid cell 上预测3个检出信息,分别对应3个anchor。
  • 80x80/40x40/20x20】不同降采样层的输出尺寸,80x80对应有80x80个grid cell。
    【yolov5系列】yolov5的原理与核心代码解析_第10张图片

二、标签与损失函数

1 标签的正负样本分配

当我们有了一个标签[class, x,y,w,h],那么我们将它与网络输出矩阵的哪些维度进行loss呢?

  • 标签的wh:决定了与网络输出的哪一层哪一个anchor对应的维度进行匹配。
  • 标签的xy:决定了与网络输出的哪一grid_ceil进行匹配,然后再进行正样本扩充
  • 标签的class:决定了onehot的表达

如下图中的标签与输出矩阵 在图中青蓝色的部分匹配上,然后对齐两者的数值表达最后进行loss
【yolov5系列】yolov5的原理与核心代码解析_第11张图片

1.1 anchor分配到每个输出层

首先回一下 anchor的相关内容。在yolov5中会先匹配数据集中的anchor和工程中默认的anchor,如果差异较大,则启动自动计算anchor操作。
已知特征图尺寸越大,感受野相对较小。遵循“大尺寸预测小物体,小尺寸预测大物体”的原则,将9个anchor分配到3个输出层,平均每层3个anchor。

1.2 标签的与anchor的匹配(由标签的wh决定)

在yolov5中,标签的分配采用静态分配策略。网络有三层输出层,每层输出层有3个anchor。如何将每个标签分配到具体的位置上进行loss的计算呢?
使用anchor和目标框的高宽比进行筛选的。具体的分配规则如下: r w = w g t / w a t r h = h g t / h a t r w m a x = max ⁡ ( r w , 1 / r w ) r h m a x = max ⁡ ( r h , 1 / r h ) r m a x < a n c h o r t \begin{aligned} &r_w = w_{gt}/w_{at} \\ &r_h = h_{gt}/h_{at} \\ &r^{max}_{w} = \max (r_w, 1/r_w) \\ &r^{max}_{h} = \max (r_h, 1/r_h) \\ &r^{max} rw=wgt/watrh=hgt/hatrwmax=max(rw,1/rw)rhmax=max(rh,1/rh)rmax<anchort其中 a n c h o r t anchor_t anchort在yolov5中默认设置为4。这代表着在grid ceil 中预测的框的大小为对应anchor的1/4或者4倍。
通过上面的方式,就可确定了哪一层的哪一个anchor对应的输出channel上的 [是否为目标项] 就可设置为有效的数值(一般的默认为1,yolov5中使用的是输出框和标签框的ciou,后面会讲),计算loss进行梯度
【yolov5系列】yolov5的原理与核心代码解析_第12张图片

1.3正样本扩充(由标签的xy决定)

目标检测中,通常背景(负样本,没有目标的grid ceil)占据比例较大,为了降低正负样本的失衡程度,需要扩增正样本。在YOLO-V5的代码中,是在正样本的上/下/左/右四个位置,选择两个进行扩增。举个例子:
【yolov5系列】yolov5的原理与核心代码解析_第13张图片

  • 蓝点:目标框的中心位置
    黄色grid ceil:正常样本
    淡橙色grid ceil:备选的四个扩充样本
    黑色 ∗ * :正常样本的grid ceil的左上角
    红色 ∗ * :扩充样本的grid ceil的左上角
  • 那么如何确定扩充样本的位置呢?
    备选的扩充正样本的位置为 标准正样本所在的grid ceil 的上下左右4个grid ceil。从其中选两个为扩充正样本,选择标准直观的理解是:目标框中心更靠近的两个grid ceil,具体操作为:
    • x方向,框中心偏移<0.5,就选择左边的为扩充正样本;框中心偏移>0.5,就选择右边的为扩充正样本
      y方向,框中心偏移<0.5,就选择上边的为扩充正样本;框中心偏移>0.5,就选择下边的为扩充正样本

扩充后目标框中心与扩充正样本的左上角的偏移范围变成了(-0.5,1.5),yolov3中的偏移范围为(0,1),左右各扩大了0.5的偏移。
假设蓝点的xy距离自身grid ceil左上角的偏移为 (0.2, 0.4),此时的扩充正样本为【上、左】的两个grid ceil。此时xy与两个扩充样本的grid ceil的左上角的偏移为上(0.2, 1.4),左(1.2, 0.4)。
目标框中心与其余两个没被选上的grid ceil的偏移分别为下(0.2, -0.6),右(-0.8, 0.4),我们可以发现它超过了(-0.5,1.5)范围。

1.4 负样本

在yolov5中没有忽略样本。上面的正样本分配和扩充的位置除外,其余的都是负样本,这里可以先提一下,负样本在loss中只进行 object loss的反向传播,关于xywh和类别的loss不进行反向传播

1.5 相关代码解析

【yolov5系列】yolov5的原理与核心代码解析_第14张图片【yolov5系列】yolov5的原理与核心代码解析_第15张图片【yolov5系列】yolov5的原理与核心代码解析_第16张图片【yolov5系列】yolov5的原理与核心代码解析_第17张图片

2 模型输出与loss

2.1 模型输出的box转换

网络的输出矩阵的元素,会经过sigmoid函数,将其范围处理到(0,1)之间。上面代码处理后,标签和匹配的anchor是的尺寸是【原图尺寸下的标签尺寸下采样到某一个输出层时的尺寸】(这里的原图尺寸 指的是图片像素级resize到神经网络输入时的尺寸,代码中备注的也是)。
两者是不对齐,是不能进行loss的。那么就需要将网络输出的xywh转换成与标签尺度一致的尺寸。

  • 在yolov2和v3中使用的是
    【yolov5系列】yolov5的原理与核心代码解析_第18张图片
  • 在yolov5中有所改进:
    b x = ( 2 ∗ σ ( t x ) − 0.5 ) + c x b y = ( 2 ∗ σ ( t y ) − 0.5 ) + c y b w = p w ∗ ( 2 σ ( t w ) ) 2 b h = p h ∗ ( 2 σ ( t h ) ) 2 \begin{aligned} b_x&=(2*\sigma (t_x)-0.5)+c_x \\ b_y&=(2*\sigma (t_y)-0.5)+c_y \\ b_w&=p_w*(2\sigma(t_{w}))^2 \\ b_h&=p_h*(2\sigma(t_{h}))^2 \end{aligned} bxbybwbh=(2σ(tx)0.5)+cx=(2σ(ty)0.5)+cy=pw(2σ(tw))2=ph(2σ(th))2其中, t x , t y , t w , t h t_x,t_y,t_w,t_h tx,ty,tw,th为网络输出的部分内容; b x , b y , b w , b h b_x,b_y,b_w,b_h bx,by,bw,bh为预测框的中心和宽高(归一化后的); p w , p h p_w,p_h pw,ph是归一化后的先验框的宽高; c x , c y c_x,c_y cx,cy是预测框处于的grid ceil 在对应featuremap中的位置。

那么这样改动带来了什么变化呢?

  • 1)模型输出转换后的中心点偏移范围从(0,1)调整到(-0.5,1.5)。原因是为了正样本扩充,因为标签在扩充后的正样本的xy的数值范围在(-0.5,1.5)之间,所以需要转换后的偏移 ( 2 ∗ σ ( t x ) − 0.5 ) (2*\sigma (t_x)-0.5) (2σ(tx)0.5)范围也是(-0.5,1.5)。
  • 2)模型输出后转换后的高宽与anchor的高宽的比例范围为(0,4)。 原因是yolov5在标签静态分配时,选择的是在输出层的尺度下,标签与anchor的宽高之间比例 ( 2 σ ( t w ) ) 2 (2\sigma(t_{w}))^2 (2σ(tw))2范围为(0.25,4)。这样标签的xywh和网络输出转换后的具有范围,有效的能够训练。
    在yolov3中,box方程有一个严重的缺陷:宽度和高度是完全无限制的,out=exp(in)可能导致梯度爆炸、训练不稳定。
    【yolov5系列】yolov5的原理与核心代码解析_第19张图片

2.2 loss function

yolov5目标检测的loss function分为3项:box loss,object loss,class loss。工程为 L o s s = w b o x ∗ l b o x + w o b j ∗ l o b j + w c l s ∗ l c l s Loss = w_{box}*lbox+w_{obj}*lobj + w_{cls}*lcls Loss=wboxlbox+wobjlobj+wclslcls

  • box function】在得到每个输出尺寸下的标签和网络的输出,然后损失函数设置为 IOUloss=(1-IOU).mean()。在yolov5中选择了CIOUloss,box的损失函数发展IOULoss、GIOUloss、DIOUloss、CIOUloss…
  • object function】使用的是sigmoid交叉熵。
    • 这里的标签不是1,而是输出和标签的CIOU,所以该项也可称作为IOU confidence。这样训练好测试时可以用这一维度的输出代表框检测的质量,当该值较大,说明预测的检测框与真实框更贴近(虽然此时是没有真实框)。但有个明显的影响是label值的减小使得预测的值也变小。
    • 在三个输出层,对应的特征点的数量为 4:1:0.25,所以该损失在每层设置了不同的权重。在工程中设置了4:1:0.4,相似的比例可达相同的效果。
  • class function】使用的是sigmoid交叉熵。因为输出层的某一个grid ceil可能存在多个目标框的中心,所以是同一个数据对应了多个类别标签。所以需要使用sigmoid交叉熵,而不是softmax交叉熵。

    常用的分类损失函数有两种:
    1) softmax交叉熵:适用于多分类的情况,数据和标签是一对一的,每个类别之间是互斥的。在pytoch中的api为【nn.CrossEntropyLoss()
    2) sigmoid交叉熵:适用于2分类,或数据和标签一对多的情况。在pytorch中的api为【nn.BCEWithLogitsLoss()
    这两个api的入参有个权重的信息,可一定程度上解决数据集中类别样本不均衡的问题。

正负样本的训练:

  • 当为正样本,三个损失都参与反向传播;当为负样本时,只有object loss参与反向传播

损失函数这里主要设置的超参数为:

  • 损失函数中每个部分的权重:hyp['box ']、hyp['obj ']、hpy['cls ']
    是否为目标的2分类、目标类别分类之间的权重分别为:hpy['cls_pw ']、hpy['obj_pw ']
    【yolov5系列】yolov5的原理与核心代码解析_第20张图片【yolov5系列】yolov5的原理与核心代码解析_第21张图片【yolov5系列】yolov5的原理与核心代码解析_第22张图片

三、数据读取与增强

首先我们需要先了解pytorch框架的数据读取的常规操作pytorch记录】torch.utils.data.Dataset、DataLoader、分布式读取并数据。
然后为了更方便debug每一步数据处理都操作了什么:

  • 在【train.py】最开始时添加【os.environ["CUDA_VISIBLE_DEVICES"]="0" 】,使得工程为单GPU训练模式
    在这里插入图片描述
  • 在【dataloaders.py】中的【def create_dataloader()】函数中将【num_workers=nw】改为【num_workers=0】,不给数据读取分配额外的线程,使得数据读取不具有随机性,在debug数据处理时 每次运行呈现的是相同的图片
    【yolov5系列】yolov5的原理与核心代码解析_第23张图片

在yolov5工程中,数据读取与增强的操作主要有:

  • def __init__() 中判断是否已将标签保存为cache文件。
    否:读取路径或列表中的标签文件,提前保存成cache文件,节省训练时读取标签并处理的时间。
    是:判断cache内存储的版本、判断cache内保存的哈希值和 数据集路径列表的所有文件大小总值的哈希值是否一致。如果出现不一致说明数据集标签发生了变化,则重新保存cache文件。
  • def __getitem__()中,包含了图片的增强处理。增强超参数设置可选择其是否进行某项增强。具体的增强方式有:
    • mosaic、mixup

    • 旋转、平移、缩放、错切、透视
      【yolov5系列】yolov5的原理与核心代码解析_第24张图片【yolov5系列】yolov5的原理与核心代码解析_第25张图片
    • HSV色域泛化、左右翻转、上下翻转

我们先定义个函数draw,将标签画到图片上并保存用于可视化(我这里是服务器上运行,所以只能保存图片可视化),注意这里的标签输入是xyxy

def draw(path, image, labels):    
    img_T = image.copy()
    for s in range(len(labels)):
        l = labels[s].astype(np.int)
        cv2.rectangle(img_T, (l[1],l[2]),(l[3],l[4]), (0, 0, 255), 3)
    cv2.imwrite(path, img_T)

然后多次修改判断条件,加上保存图片的调用。
【yolov5系列】yolov5的原理与核心代码解析_第26张图片【yolov5系列】yolov5的原理与核心代码解析_第27张图片

你可能感兴趣的:(yolo系列,YOLO,目标检测,python)