目标检测——YOLO系列学习(一)YOLOv1

发布于:2024-04-16 ⋅ 阅读:(21) ⋅ 点赞:(0)

YOLO可以说是单阶段的目标检测方法的集大成之作,必学的经典论文,从准备面试的角度来学习一下yolo系列。


YOLOv1

1.RCNN系列回顾

RCNN系列,无论哪种算法,核心思路都是Region Proposal(定位)+ classifier(修正定位+分类)。所以也被称为两阶段算法。但是难以达到实时检测的效果,因此yolov1将其修改为单阶段的算法,yolov1虽然牺牲了一定的精度,但是检测速度大幅提升,而后续的yolo版本在其之上改进,现在已经有yolov9和yolo-world了,成为主流的目标检测模型。

2.YOLOv1

(部分内容和图参考保姆级教程:图解目标检测算法YOLOv1 - 知乎 (zhihu.com)

论文原文:

1506.02640.pdf (arxiv.org)icon-default.png?t=N7T8https://arxiv.org/pdf/1506.02640.pdf在讲解过程中会出现很多专业词汇,会挨着进行说明。

YOLOv1的核心思路就是舍弃Region Proposal这个极其耗时的过程,转而进行回归。怎么实现舍弃RP的,就是学习的关键。

(1)核心思想

采用利用整张图作为网络的输入,将图像划分为S*S个grid,某一个grid只关注于预测物体中心在这个grid中的目标,整个网络最后直接在输出层回归 bounding box 的位置和 bounding box 所属的类别。

Grid和Bouding Box

这里可能会产生一点误解,故区分一下。

Grid:将图片直接划分为S*S个grid,位置是固定死的,比如上图中,划分为了7*7个grid。

Bouding Box:就是最后检测出物体的框,如上图中框住狗狗的红色框,在算法流程中,可以用两种数据形式表示,一种是使用中心坐标+长宽的形式(Cx,Cy,H,W),一种是使用左上和右下角点坐标的形式(x1,y1,x2,y2)。而每个框除了要包含位置信息,还包含了该框是否包含物体的置信度,这个置信度怎么计算的我们后面讲解,这里只需要记住每个Bounding Box其实对应了5个数据。

置信度(Confidence)的计算

置信度就是算法的自信心得分,这个值越高,代表这个BoundigBox里越有可能包含物体。计算方式如下:

Confidence=Pr(Object)*IOU^{truth}_{pred}

Pr(Object)为边界框内存在对象的概率,若存在对象,Pr⁡(Object)=1,否则Pr⁡(Object)=0。

但是这里要注意一下,其实我们整个网络的计算中是不需要用这个公式计算的,网络输出一个0~1的值就好。

IOU(Intersection over Union ratio)

IOU又叫做交并比,其实很好理解,就是两个框计算出来的一个值,意义上来看,IOU值越大,表示两个框的重合度越高,从公式上来看:

IOU=\frac{GT\cap Pred}{GT\cup Pred}

一个实现代码如下:

def calculate_iou(bbox1,bbox2):
    """计算bbox1=(x1,y1,x2,y2)和bbox2=(x3,y3,x4,y4)两个bbox的iou"""
    intersect_bbox = [0., 0., 0., 0.]  # bbox1和bbox2的交集
    if bbox1[2]<bbox2[0] or bbox1[0]>bbox2[2] or bbox1[3]<bbox2[1] or bbox1[1]>bbox2[3]:
        pass
    else:
        intersect_bbox[0] = max(bbox1[0],bbox2[0])
        intersect_bbox[1] = max(bbox1[1],bbox2[1])
        intersect_bbox[2] = min(bbox1[2],bbox2[2])
        intersect_bbox[3] = min(bbox1[3],bbox2[3])

    area1 = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1])  # bbox1面积
    area2 = (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1])  # bbox2面积
    area_intersect = (intersect_bbox[2] - intersect_bbox[0]) * (intersect_bbox[3] - intersect_bbox[1])  # 交集面积
    # print(bbox1,bbox2)
    # print(intersect_bbox)
    # input()

    if area_intersect>0:
        return area_intersect / (area1 + area2 - area_intersect)  # 计算iou
    else:
        return 0

按照这个思路,我们可以简要理一下网络的输入输出:

输入一张固定大小的图像,规定划分的格子数S*S,规定每个格子要预测几个框B。

输出为一个S*S*(B*5+Class),S和B对应输入,5就是boundingbox中包含的五个信息,class就是类别的预测,这里类别使用的是one-hot编码。

以作者在论文里提到的PASCAL VOC上的实验为例:

S=7,B=5,有20个类别,故输出tensor的维度为7*7*(5*2)

这里有个很容易错误理解的点,就是这个class的分类结果其实是对应了这一个grid的,一个grid输出一个20维的分类结果,而不是整个grid所得到的两个BoundingBox的分类结果,

(2)网络结构

YOLOv1的数据流如下:

  • resize图片尺寸(没有ROI)
  • 输入网络,输出tensor
  • 非极大值抑制(NMS)

网络的结构如下:

这里光看图可能很多初学的同学不是很看的懂,我们来看看一个简单的pytorch版本:

参考:动手学习深度学习pytorch版——从零开始实现YOLOv1_自己实现的yolov-CSDN博客

这一部分需要说明一下,由于原论文是采用自己设计的20层卷积层先在ImageNet上训练了一周,完成特征提取部分的训练。我们作为学习者而非发明者来说,花一周时间训练实在是太长了。因此,在这里我打算对原论文的结构做一点改变。YOLOv1的前20层是用于特征提取的,也就是随便替换为一个分类网络(除去最后的全连接层)其实都行。因此,我打算使用ResNet34的网络作为特征提取部分。这样做的好处是,pytorch的torchvision中提供了ResNet34的预训练模型,训练集也是ImageNet,等于说有先成训练好的模型可以直接使用,从而免去了特征提取部分的训练时间。然后,除去ResNet34的最后两层,再连接上YOLOv1的最后4个卷积层和两个全连接层,作为我们训练的网络结构。
  此外,还进行了一些小调整,比如最后增加了一个Sigmoid层,以及在卷积层后增加了BN层等等。具体代码如下:

import torchvision.models as tvmodel
import torch.nn as nn
import torch

class YOLOv1_resnet(nn.Module):
    def __init__(self):
        super(YOLOv1_resnet,self).__init__()
        resnet = tvmodel.resnet34(pretrained=True)  # 调用torchvision里的resnet34预训练模型
        resnet_out_channel = resnet.fc.in_features  # 记录resnet全连接层之前的网络输出通道数,方便连入后续卷积网络中
        self.resnet = nn.Sequential(*list(resnet.children())[:-2])  # 去除resnet的最后两层
        # 以下是YOLOv1的最后四个卷积层
        self.Conv_layers = nn.Sequential(
            nn.Conv2d(resnet_out_channel,1024,3,padding=1),
            nn.BatchNorm2d(1024),  # 为了加快训练,这里增加了BN层,原论文里YOLOv1是没有的
            nn.LeakyReLU(),
            nn.Conv2d(1024,1024,3,stride=2,padding=1),
            nn.BatchNorm2d(1024),
            nn.LeakyReLU(),
            nn.Conv2d(1024, 1024, 3, padding=1),
            nn.BatchNorm2d(1024),
            nn.LeakyReLU(),
            nn.Conv2d(1024, 1024, 3, padding=1),
            nn.BatchNorm2d(1024),
            nn.LeakyReLU(),
        )
        # 以下是YOLOv1的最后2个全连接层
        self.Conn_layers = nn.Sequential(
            nn.Linear(7*7*1024,4096),
            nn.LeakyReLU(),
            nn.Linear(4096,7*7*30),
            nn.Sigmoid()  # 增加sigmoid函数是为了将输出全部映射到(0,1)之间,因为如果出现负数或太大的数,后续计算loss会很麻烦
        )

    def forward(self, input):
        input = self.resnet(input)
        input = self.Conv_layers(input)
        input = input.view(input.size()[0],-1)
        input = self.Conn_layers(input)
        return input.reshape(-1, (5*NUM_BBOX+len(CLASSES)), 7, 7)  # 记住最后要reshape一下输出数据

这里我们主要关注最后两个fc层,是没有使用池化操作的,直接使用view和resize就实现了三维张量和二维张量的转换。

(3)非极大值抑制

非极大值抑制的目的就是去掉一些冗余框。

这一部分可以参考一下:目标检测入门之非最大值抑制(NMS)算法 - 知乎 (zhihu.com)

(4)损失函数

损失函数是理解YOLOv1训练的关键,具体形式如下:

这里的损失函数包括五项:

前两项对应BoundingBox的损失函数(针对x, y, H, W进行学习)

接下来两项对应Confidence的损失函数(针对置信度进行学习)

最后一项对应分类的损失(针对类别label进行学习)

细节上来说:

1.公式中每一个均方误差的系数: 1^{obj}_{i,j}表示的是第i个grid的第j个BoundingBox是否负责Object,每个grid对应的B个BoudingBox中,与GT的IOU最大的BoundingBox才负责这个Object,其余的为1^{noobj}_{i,j},这一部分可以简单看一下代码:

if iou1 >= iou2:
    coor_loss = coor_loss + 5 * (torch.sum((pred[i,0:2,m,n] - labels[i,0:2,m,n])**2) \
              + torch.sum((pred[i,2:4,m,n].sqrt()-labels[i,2:4,m,n].sqrt())**2))
    obj_confi_loss = obj_confi_loss + (pred[i,4,m,n] - iou1)**2
    # iou比较小的bbox不负责预测物体,因此confidence loss算在noobj中,注意,对于标签的置信度应该是iou2
    noobj_confi_loss = noobj_confi_loss + 0.5 * ((pred[i,9,m,n]-iou2)**2)

这里计算obj_confi_loss和noobj_confi_loss使用的pred和IOU都是不一样的,pred[i,4,m,n]中的4对应的是IOU更大的框,9对应的是IOU更小的框。

2.这里对 (w,ℎ) 在损失函数中的处理分别取了根号,原因在于,如果不取根号,损失函数往往更倾向于调整尺寸比较大的预测框。例如,20 个像素点的偏差,对于 800x600 的预测框几乎没有影响,此时的IOU数值还是很大,但是对于 30x40 的预测框影响就很大。取根号是为了尽可能的消除大尺寸框与小尺寸框之间的差异。

3.此时再来看 \lambda _{coord}与 \lambda _{noobj} ,YOLO 面临的物体检测问题,是一个典型的类别数目不均衡的问题(Focal Loss就是解决这个问题的,一个面试中常问的点)。其中 49 个格点,含有物体的格点往往只有 3、4 个,其余全是不含有物体的格点。此时如果不采取点措施,那么物体检测的mAP不会太高,因为模型更倾向于不含有物体的格点。 \lambda _{coord}与 \lambda _{noobj}的作用,就是让含有物体的格点,在损失函数中的权重更大,让模型更加“重视”含有物体的格点所造成的损失。在论文中,\lambda _{coord} 与 \lambda _{noobj}的取值分别为 5 与 0.5 。

最后整个Loss部分的代码如下:

class Loss_yolov1(nn.Module):
    def __init__(self):
        super(Loss_yolov1,self).__init__()

    def forward(self, pred, labels):
        """
        :param pred: (batchsize,30,7,7)的网络输出数据
        :param labels: (batchsize,30,7,7)的样本标签数据
        :return: 当前批次样本的平均损失
        """
        num_gridx, num_gridy = labels.size()[-2:]  # 划分网格数量
        num_b = 2  # 每个网格的bbox数量
        num_cls = 20  # 类别数量
        noobj_confi_loss = 0.  # 不含目标的网格损失(只有置信度损失)
        coor_loss = 0.  # 含有目标的bbox的坐标损失
        obj_confi_loss = 0.  # 含有目标的bbox的置信度损失
        class_loss = 0.  # 含有目标的网格的类别损失
        n_batch = labels.size()[0]  # batchsize的大小

        # 可以考虑用矩阵运算进行优化,提高速度,为了准确起见,这里还是用循环
        for i in range(n_batch):  # batchsize循环
            for n in range(7):  # x方向网格循环
                for m in range(7):  # y方向网格循环
                    if labels[i,4,m,n]==1:# 如果包含物体
                        # 将数据(px,py,w,h)转换为(x1,y1,x2,y2)
                        # 先将px,py转换为cx,cy,即相对网格的位置转换为标准化后实际的bbox中心位置cx,xy
                        # 然后再利用(cx-w/2,cy-h/2,cx+w/2,cy+h/2)转换为xyxy形式,用于计算iou
                        bbox1_pred_xyxy = ((pred[i,0,m,n]+n)/num_gridx - pred[i,2,m,n]/2,(pred[i,1,m,n]+m)/num_gridy - pred[i,3,m,n]/2,
                                           (pred[i,0,m,n]+n)/num_gridx + pred[i,2,m,n]/2,(pred[i,1,m,n]+m)/num_gridy + pred[i,3,m,n]/2)
                        bbox2_pred_xyxy = ((pred[i,5,m,n]+n)/num_gridx - pred[i,7,m,n]/2,(pred[i,6,m,n]+m)/num_gridy - pred[i,8,m,n]/2,
                                           (pred[i,5,m,n]+n)/num_gridx + pred[i,7,m,n]/2,(pred[i,6,m,n]+m)/num_gridy + pred[i,8,m,n]/2)
                        bbox_gt_xyxy = ((labels[i,0,m,n]+n)/num_gridx - labels[i,2,m,n]/2,(labels[i,1,m,n]+m)/num_gridy - labels[i,3,m,n]/2,
                                        (labels[i,0,m,n]+n)/num_gridx + labels[i,2,m,n]/2,(labels[i,1,m,n]+m)/num_gridy + labels[i,3,m,n]/2)
                        iou1 = calculate_iou(bbox1_pred_xyxy,bbox_gt_xyxy)
                        iou2 = calculate_iou(bbox2_pred_xyxy,bbox_gt_xyxy)
                        # 选择iou大的bbox作为负责物体
                        if iou1 >= iou2:
                            coor_loss = coor_loss + 5 * (torch.sum((pred[i,0:2,m,n] - labels[i,0:2,m,n])**2) \
                                        + torch.sum((pred[i,2:4,m,n].sqrt()-labels[i,2:4,m,n].sqrt())**2))
                            obj_confi_loss = obj_confi_loss + (pred[i,4,m,n] - iou1)**2
                            # iou比较小的bbox不负责预测物体,因此confidence loss算在noobj中,注意,对于标签的置信度应该是iou2
                            noobj_confi_loss = noobj_confi_loss + 0.5 * ((pred[i,9,m,n]-iou2)**2)
                        else:
                            coor_loss = coor_loss + 5 * (torch.sum((pred[i,5:7,m,n] - labels[i,5:7,m,n])**2) \
                                        + torch.sum((pred[i,7:9,m,n].sqrt()-labels[i,7:9,m,n].sqrt())**2))
                            obj_confi_loss = obj_confi_loss + (pred[i,9,m,n] - iou2)**2
                            # iou比较小的bbox不负责预测物体,因此confidence loss算在noobj中,注意,对于标签的置信度应该是iou1
                            noobj_confi_loss = noobj_confi_loss + 0.5 * ((pred[i, 4, m, n]-iou1) ** 2)
                        class_loss = class_loss + torch.sum((pred[i,10:,m,n] - labels[i,10:,m,n])**2)
                    else:  # 如果不包含物体
                        noobj_confi_loss = noobj_confi_loss + 0.5 * torch.sum(pred[i,[4,9],m,n]**2)

        loss = coor_loss + obj_confi_loss + noobj_confi_loss + class_loss
        # 此处可以写代码验证一下loss的大致计算是否正确,这个要验证起来比较麻烦,比较简洁的办法是,将输入的pred置为全1矩阵,再进行误差检查,会直观很多。
        return loss/n_batch

3.YOLOv1的缺点

  • 由于输出层为全连接层,因此在检测时,YOLO训练模型只支持与训练图像相同的输入分辨率。
  • 虽然每个格子可以预测B个bounding box,但是最终只选择只选择IOU最高的bounding box作为物体检测输出,即每个格子最多只预测出一个物体。当物体占画面比例较小,如图像中包含畜群或鸟群时,每个格子包含多个物体,但却只能检测出其中一个。这是YOLO方法的一个缺陷。
  • YOLO loss函数中,大物体IOU误差和小物体IOU误差对网络训练中loss贡献值接近(虽然采用求平方根方式,但没有根本解决问题)。因此,对于小物体,小的IOU误差也会对网络优化过程造成很大的影响,从而降低了物体检测的定位准确性。