手把手教你实现YOLOv3 (一)

1. 引言

最近整理了YOLO系列相关论文阅读笔记,发现仅仅靠阅读论文还是有很多内容一知半解,吃得不是很透彻.

尽管网络上有很多博客都在讲解,但是很多实现细节细究起来还是有些困难.

俗话说的好:

Talk is cheap. Show me the code.

鉴于已在CV行业内卷四年,近期打算来写个教程和大家一起从零开始实现YOLOv3,顺便带大家一起入门目标检测的大坑...

闲话少说,我们直接开始吧...

2. YOLOV3算法思想

鉴于已在前篇详细介绍过YOLO系列的算法过程,这里我们仅简单对其核心思想进行回顾.

YOLO的作者将目标检测问题视为回归问题,首先将整副图划分成 [公式] 的网格,如果目标框的中心点落在这个网格中,那么这个网格就负责预测这个目标.

每一个网格都会预测bounding box,confidence以及class probability map:

  • bounding box包含四个值: [公式] 其中 [公式] 代表预测框的中心点, [公式] 代表预测框的宽和高
  • confidence表示预测框包含目标的可能性,训练时的真值为预测框和真值框的IOU
  • class probability map表明这个目标所属类别的置信度

3. YOLOV3网络架构

YOLO(You Only Look Once)将整个图像输入到网络中,可以直接预测目标位置和对应的类别.这使得YOLO推理速度很快并且保持较高的精度.

3.1. Darknet53

YOLOv3采用了53层卷积层作为主干,又被叫做Darknet-53,网络结构如下所示:

观察上图,发现Darknet-53是由卷积层和残差层组成.同时需要注意的时,最后三层 Avgpool,Connected和softmax层是用来在ImageNet数据集上训练分类任务时使用的.当我们使用Darknet-53作为YOLOv3中提取图像特征的主干时,则不再使用最后三层.

接着我们来用代码实现DarkNet-53, 代码如下:

class Darknet53(nn.Module):
    def __init__(self):
        super(Darknet53, self).__init__()
        self.dark0 = BaseConv(3, 32, 3, 1)  
        self.dark1 = nn.Sequential(
            BaseConv(32, 64, 3, 2),  
            BasicBlock(64, 64, shortcut=True)  
        )
        self.dark2 = nn.Sequential(
            BaseConv(64, 128, 3, 2),
            BasicBlock(128, 128, True),
            BasicBlock(128, 128, True),
        )  
        self.dark3 = nn.Sequential(
            BaseConv(128, 256, 3, 2),
            BasicBlock(256, 256, True),  
            BasicBlock(256, 256, True),  
            BasicBlock(256, 256, True), 
            BasicBlock(256, 256, True),  
            BasicBlock(256, 256, True), 
            BasicBlock(256, 256, True),  
            BasicBlock(256, 256, True), 
            BasicBlock(256, 256, True), 
        )
        self.dark4 = nn.Sequential(
            BaseConv(256, 512, 3, 2), 
            BasicBlock(512, 512, True), 
            BasicBlock(512, 512, True),  
            BasicBlock(512, 512, True),  
            BasicBlock(512, 512, True),  
            BasicBlock(512, 512, True),  
            BasicBlock(512, 512, True),  
            BasicBlock(512, 512, True),  
            BasicBlock(512, 512, True), 
        )
        self.dark5 = nn.Sequential(
            BaseConv(512, 1024, 3, 2),  
            BasicBlock(1024, 1024, True),  
            BasicBlock(1024, 1024, True), 
            BasicBlock(1024, 1024, True),  
            BasicBlock(1024, 1024, True),  
        )
        
    def forward(self,x):
        out0 = self.dark0(x)
        out1 = self.dark1(out0)
        out2 = self.dark2(out1)
        out3 = self.dark3(out2)
        out4 = self.dark4(out3)
        out5 = self.dark5(out4)
        return out3,out4,out5

验证代码如下:

if __name__ == "__main__":
    x = torch.randn((1,3,416,416))
    model = Darknet53()
    out3,out4,out5 = model(x)
    print(out3.shape)
    print(out4.shape)
    print(out5.shape)

运行结果如下:

torch.Size([1, 256, 52, 52])
torch.Size([1, 512, 26, 26])
torch.Size([1, 1024, 13, 13])

符合预期,输出的三个特征图为采样8倍,16倍和32倍的结果.

3.2. YOLOv3网络结构

有了上述主干网络DarkNet53的结构后,接下来我们来分析YOLOv3的整体结构,如下所示:

观察上图,我们可以知道:

  • YOLOv3在3个scale的特征图上分别预测不同大小的目标.即在8倍,16倍和32倍的特征图上进行预测.也就是说如果我们的输入为416X416,那么YOLOv3预测时采用的特征图的大小分别为52X52,32X32以及13X13
  • 对于第一个scale, YOLOV3将输入降采样为13X13,在82层进行预测,此时预测输出的3维Tensor的大小为13X13X255
  • 之后,YOLOv3从第79层获取特征图,接着应用一个卷积层进行通道压缩,然后将其上采样2倍,大小为26 x 26。然后,该特征图与第61层的特征图进行concat操作。最后,将concat后的特征图在经过几个卷积层进一步提取特征,直到在第94层作为第二个尺度检测的特征图。第二个sace预测输出的3维Tensor的大小为26 x 26 x 255。
  • 对于第三个尺度,重复上述操作.即第91层的特征图先接一个卷积层进行通道压缩,然后上采样2倍,大小为52X52,然后与第36层的特征图进行concat操作.接着是几层卷积操作,最终预测层在106层完成,产生的三维Tensor的大小为52X52X255.
  • 总之,YOLOv3在3种不同尺度的特征图上进行检测,因此如果我们输入416x416大小的图像,它将产生3种不同的输出形状张量,13 x 13 x 255、26 x 26 x 255和52 x 52 x 255。

上述结构,虽然便于理解,但是代码实现起来还是不太清晰,不过不用怕,我们还有更详细的网络结构图,如下所示:

在上图中,我们可以看到尺寸为416x416的输入图片在进入Darknet-53网络后得到3个分支。这些分支经历一系列卷积、上采样、合并和其他操作。最终获得三个不同尺寸的特征图,形状分别为[13,13,255]、[26,26,255]和[52,52,255].

我们用代码实现上述结构如下:

class YOLOV3(nn.Module):
    def __init__(self,num_calss):
        super(YOLOV3, self).__init__()
        self.darknet =  Darknet53()
        # branch1
        self.feature1 = YOLOBlock(1024,512)
        self.out_head1 = YOLO_HEAD(512, 1024, 3*(num_calss+1+4))
	# branch2
        self.cbl1 = self._make_cbl(512,256,1)
        self.upsample = nn.Upsample(scale_factor=2, mode='nearest')
        self.feature2  = YOLOBlock(768,256)
        self.out_head2 = YOLO_HEAD(256, 512, 3*(num_calss+1+4))
	# branch3
        self.cbl2 = self._make_cbl(256, 128, 1)
        self.feature3  = YOLOBlock(384, 128)
        self.out_head3 = YOLO_HEAD(128, 256, 3*(num_calss+1+4))
        self.num_class = num_calss
    
    def _make_cbl(self, _in, _out, ks):
        return BaseConv(_in, _out, ks, stride=1, activate="lrelu")

    def forward(self,x):
        # backbone
        out3,out4,out5 = self.darknet(x)
        # branch1 13X13X255
        feature1 = self.feature1(out5)
        out_large = self.out_head1(feature1)
        # branch2 26X26X255
        cb1 = self.cbl1(feature1)
        up1 = self.upsample(cb1)
        x1_in = torch.cat([up1, out4], 1)
        feature2 = self.feature2(x1_in)
        out_medium = self.out_head2(feature2)
        # branch3 52X52X255
        cb2 = self.cbl2(feature2)
        up2 = self.upsample(cb2)
        x2_in = torch.cat([up2, out3], 1)
        feature3 = self.feature3(x2_in)
        out_small = self.out_head3(feature3)
        
        return (out_large,out_medium,out_small)

测试代码如下:

if __name__ == "__main__":
    x = torch.randn((1,3,416,416))
    model = YOLOV3(80)
    cnt = 0
    out = model(x)
    out_large,out_medium,out_small= model(x)
    print(out_large.shape)
    print(out_medium.shape)
    print(out_small.shape)

运行结果如下:

torch.Size([1, 255, 13, 13])
torch.Size([1, 255, 26, 26])
torch.Size([1, 255, 52, 52])

3.3. Residual module

残差模块(Residual module)最显著的特点是使用了一种shortcut机制,以缓解因增加神经网络中的深度而导致的梯度消失的问题,从而使神经网络更易于训练。它主要使用identity mapping 在输入和输出之间建立连接.

实现细节如下:

class BasicBlock(nn.Module):
    def __init__(self,in_chl,out_chl,shortcut=True,activate='lrelu'):
        super(BasicBlock, self).__init__()
        down_chl  = in_chl // 2
        self.conv1 = BaseConv(in_chl,down_chl,ksize=1,stride=1,activate=activate)
        self.conv2 = BaseConv(down_chl,out_chl,ksize=3,stride=1,activate=activate)
        self.shortcut = shortcut and in_chl == out_chl

    def forward(self,x):
        conv1 = self.conv1(x)
        out = self.conv2(conv1)
        if self.shortcut:
           out = out + x
        return out

3.4. 特征图分析

要详细了解YOLO的输出含义,首先需要了解什么是特征图。

特征图

在我们讨论CNN网络结构的时候,我们总经常使用的一个词汇叫做 feature map. 简单地说,输入图像与卷积核进行卷积操作后就可以获得图像特征。

一般来说,当输入图像经过CNN提取特征时,特征图的数量(卷积核的数量)将增加,同时空间信息将减少,当然提取到的特征也会越来越抽象.我们以著名的VGG16网络为例,其特征图尺度变化如下:

随着网络变深,特征图的空间尺寸越来越小,但通道数目越来越大。这是CNN的特性。

特征向量

当我们涉及到特征图时,我们经常会听到在人脸识别领域提到它。一般来说,此时的特征图的含义实际上是由最后一个全连接层提取到的特征向量。早在2006年,深度学习的创始人Hinton就在《Science》杂志上发表了一篇论文。文中首次使用神经网络从mnist手写字符数据集中提取特征向量(2D或3D向量)。

当CNN网络自下而上从输入图像中提取特征时,生成的特征图通常在空间尺寸上越来越小,在通道数目上越来越深:

上述特性与ROI到特征图的映射有关。如上图所示:将原始图像中的ROI映射到CNN网络空间后,特征图上的空间大小会变小,甚至是一个点,但该点的通道信息会非常丰富。

该信息是CNN网络上映射的ROI区域中图像信息的特征表示。由于图像中的像素在空间上紧密相连,这导致了空间上的巨大冗余。因此,我们通常通过减少空间维度和增加通道维度来消除这种冗余,并尝试在最小维度中获得其最重要的特征:

举例,上图中输入图像左上角的红色ROI经过CNN映射,在特征图上仅获得一个点,但该点有85个通道线。因此,ROI的维度已经从原来的[32,32,3]变为当前的85维.

这实际上是YOLO网络对ROI执行特征提取后获得的85维特征向量。该特征向量的前四个维度表示候选框信息,中间维度表示判断对象是否存在的概率,接下来的80个维度表示80个类别的分类概率信息。

4. 总结

本节主要实现了YOLO v3网络主干的讲解和代码实现,重点集中于Darknet-53主干和YOLO v3的检测头的实现.

嗯嗯,时间原因,今天就先到这里吧.

下篇计划讲解YOLOv3的预测部分.