介绍
物体检测的两个步骤可以概括为:
步骤一:检测目标位置(生成矩形框)
步骤二:对目标物体进行分类
物体检测主流的算法框架大致分为one-stage与two-stage。two-stage算法代表有R-CNN系列,one-stage算法代表有Yolo系列。two-stage算法将步骤一与步骤二分开执行,输入图像先经过候选框生成网络(例如faster rcnn中的RPN网络),再经过分类网络;one-stage算法将步骤一与步骤二同时执行,输入图像只经过一个网络,生成的结果中同时包含位置与类别信息。two-stage与one-stage相比,精度高,但是计算量更大,所以运算较慢。
YOLO
YOLOv1
【论文解读】Yolo三部曲解读——Yolov1
YOLOv1的网络架构如下图:
直接上结构图,输入图像大小为448乘448,经过若干个卷积层与池化层,变为7乘7乘1024张量(图一中倒数第三个立方体),最后经过两层全连接层,输出张量维度为7乘7乘30,这就是Yolo v1的整个神经网络结构,和一般的卷积物体分类网络没有太多区别,最大的不同就是:分类网络最后的全连接层,一般连接于一个一维向量,向量的不同位代表不同类别,而这里的输出向量是一个三维的张量(7乘7乘30)。上图中Yolo的backbone网络结构,受启发于GoogLeNet,也是v2、v3中Darknet的先锋。本质上来说没有什么特别,没有使用BN层,用了一层Dropout。除了最后一层的输出使用了线性激活函数,其他层全部使用Leaky Relu激活函数。网络结构没有特别的东西,不再赘述。
输出张量维度的意义:
(1)7乘7的含义
7乘7是指图片被分成了7乘7个格子,如下图:
在Yolo中,如果一个物体的中心点,落在了某个格子中,那么这个格子将负责预测这个物体。而那些没有物体中心点落进来的格子,则不负责预测任何物体。这个设定就好比该网络在一开始,就将整个图片上的预测任务进行了分工,一共设定7乘7个按照方阵列队的检测人员,每个人员负责检测一个物体,大家的分工界线,就是看被检测物体的中心点落在谁的格子里。当然,是7乘7还是9乘9,是上图中的参数S,可以自己修改,精度和性能会随之有些变化。
(2)30的含义
刚才设定了49个检测人员,那么每个人员负责检测的内容,就是这里的30(注意,30是张量最后一维的长度)。在Yolo v1论文中,30是由$(4+1) \times 2 +20$得到的。其中$4+1$是矩形框的中心点坐标(x,y)、长宽(w,h)以及是否属于被检测物体的置信度c;2是一个格子共回归两个矩形框,每个矩形框分别产生5个预测值(每个格子预测矩形框个数,是可调超参数;论文中选择了2个框,当然也可以只预测1个框,具体预测几个矩形框,无非是在计算量和精度之间取一个权衡。如果只预测一个矩形框,计算量会小很多,但是如果训练数据都是小物体,那么网络学习到的框,也会普遍比较小,测试时如果物体较大,那么预测效果就会不理想;如果每个格子多预测几个矩形框,如上文中讲到的,每个矩形框的学习目标会有所分工,有些学习小物体特征,有些学习大物体特征等;在Yolov2、v3中,这个数目都有一定的调整。);20代表预测20个类别。这里有几点需要注意:1. 每个方格(grid) 产生2个预测框,2也是参数,可以调,但是一旦设定为2以后,那么每个方格只产生两个矩形框,最后选定置信度更大的矩形框作为输出,也就是最终每个方格只输出一个预测矩形框。2. 每个方格只能预测一个物体。虽然可以通过调整参数,产生不同的矩形框,但这只能提高矩形框的精度。所以当有很多个物体的中心点落在了同一个格子里,该格子只能预测一个物体。也就是格子数为7乘7时,该网络最多预测49个物体。
如上述原文中提及,在强行施加了格点限制以后,每个格点只能输出一个预测结果,所以该算法最大的不足,就是对一些邻近小物体的识别效果不是太好,例如成群结队的小鸟。
损失函数:
论文中Loss函数,密密麻麻的公式初看可能比较难懂。其实论文中给出了比较详细的解释。所有的损失都是使用平方和误差公式。
(1)预测框的中心点(x,y)。造成的损失是上图中的第一行。其中$\mathbb{I}_{ij}^{obj}$为控制函数,在标签中包含物体的那些格点处,该值为 1 ;若格点不含有物体,该值为 0。也就是只对那些有真实物体所属的格点进行损失计算,若该格点不包含物体,那么预测数值不对损失函数造成影响。(x,y)数值与标签用简单的平方和误差。
(2)预测框的宽高。造成的损失是上图的第二行。$\mathbb{I}_{ij}^{obj}$的含义一样,也是使得只有真实物体所属的格点才会造成损失。这里对在损失函数中的处理分别取了根号,原因在于,如果不取根号,损失函数往往更倾向于调整尺寸比较大的预测框。例如,20个像素点的偏差,对于800乘600的预测框几乎没有影响,此时的IOU数值还是很大,但是对于30乘40的预测框影响就很大。取根号是为了尽可能的消除大尺寸框与小尺寸框之间的差异。
(3)第三行与第四行,都是预测框的置信度C。当该格点不含有物体时,该置信度的标签为0;若含有物体时,该置信度的标签为预测框与真实物体框的IOU数值(IOU计算公式为:两个框交集的面积除以并集的面积)。
(4)第五行为物体类别概率P,对应的类别位置,该标签数值为1,其余位置为0,与分类网络相同。
此时再来看$\lambda_{coord}$与$\lambda_{noobj}$,Yolo面临的物体检测问题,是一个典型的类别数目不均衡的问题。其中49个格点,含有物体的格点往往只有3、4个,其余全是不含有物体的格点。此时如果不采取点措施,那么物体检测的mAP不会太高,因为模型更倾向于不含有物体的格点。$\lambda_{coord}$与$\lambda_{noobj}$的作用,就是让含有物体的格点,在损失函数中的权重更大,让模型更加“重视”含有物体的格点所造成的损失。在论文中, 取值分别为5与0.5。
一些技巧:
(1)回归offset代替直接回归坐标
不直接回归中心点坐标数值,而是回归相对于格点左上角坐标的位移值。例如,第一个格点中物体坐标为$(2.3, 3.6)$,另一个格点中的物体坐标为$(5.4, 6.3)$,这四个数值让神经网络暴力回归,有一定难度。所以这里的offset是指,既然格点已知,那么物体中心点的坐标一定在格点正方形里,相对于格点左上角的位移值一定在区间$[0, 1)$中。让神经网络去预测$(0.3, 0.6)$与$(0.4, 0.3)$会更加容易,在使用时,加上格点左上角坐标$(2, 3)$、$(5, 6)$即可。
(2)同一格点的不同预测框有不同作用
前文中提到,每个格点预测两个或多个矩形框。此时假设每个格点预测两个矩形框。那么在训练时,见到一个真实物体,我们是希望两个框都去逼近这个物体的真实矩形框,还是只用一个去逼近?或许通常来想,让两个人一起去做同一件事,比一个人做一件事成功率要高,所以可能会让两个框都去逼近这个真实物体。但是作者没有这样做,在损失函数计算中,只对和真实物体最接近的框计算损失,其余框不进行修正。这样操作之后作者发现,一个格点的两个框在尺寸、长宽比、或者某些类别上逐渐有所分工,总体的召回率有所提升
(3)使用非极大抑制生成预测框
通常来说,在预测的时候,格点与格点并不会冲突,但是在预测一些大物体或者邻近物体时,会有多个格点预测了同一个物体。此时采用非极大抑制技巧,过滤掉一些重叠的矩形框。不过此时mAP提升并没有像在RCNN或DPM中那样显著提升。
(4)推理时将类别预测最大值乘以预测框最大值作为输出置信度
在推理时,使用物体的类别预测最大值p乘以预测框的最大值c,作为输出预测物体的置信度。这样也可以过滤掉一些大部分重叠的矩形框。输出检测物体的置信度,同时考虑了矩形框与类别,满足阈值的输出更加可信。
YOLOv2
【论文解读】Yolo三部曲解读——Yolov2
Yolov2论文标题就是更好,更快,更强。Yolov1发表之后,计算机视觉领域出现了很多trick,例如批归一化、多尺度训练,v2也尝试借鉴了R-CNN体系中的anchor box,所有的改进提升,下面逐一介绍。
- Batch Normalization(批归一化)
检测系列的网络结构中,BN逐渐变成了标配。在Yolo的每个卷积层中加入BN之后,mAP提升了2%,并且去除了Dropout。 - High Resolution Classifier(分类网络高分辨率预训练)
在Yolov1中,网络的backbone部分会在ImageNet数据集上进行预训练,训练时网络输入图像的分辨率为224乘224。在v2中,将分类网络在输入图片分辨率为448乘448的ImageNet数据集上训练10个epoch,再使用检测数据集(例如coco)进行微调。高分辨率预训练使mAP提高了大约4%。 - Convolutional With Anchor Boxes(Anchor Box替换全连接层)
第一篇解读v1时提到,每个格点预测两个矩形框,在计算loss时,只让与ground truth最接近的框产生loss数值,而另一个框不做修正。这样规定之后,作者发现两个框在物体的大小、长宽比、类别上逐渐有了分工。在v2中,神经网络不对预测矩形框的宽高的绝对值进行预测,而是预测与Anchor框的偏差(offset),每个格点指定n个Anchor框。在训练时,最接近ground truth的框产生loss,其余框不产生loss。在引入Anchor Box操作后,mAP由69.5下降至69.2,原因在于,每个格点预测的物体变多之后,召回率大幅上升,准确率有所下降,总体mAP略有下降。
v2中移除了v1最后的两层全连接层,全连接层计算量大,耗时久。文中没有详细描述全连接层的替换方案,这里笔者猜测是利用1乘1的卷积层代替(欢迎指正),具体的网络结构原文中没有提及,官方代码也被yolo v3替代了。v2主要是各种trick引入后的效果验证,建议不必纠结于v2的网络结构。 - Dimension Clusters(Anchor Box的宽高由聚类产生)
这里算是作者的一个创新点。Faster R-CNN中的九个Anchor Box的宽高是事先设定好的比例大小,一共设定三个面积大小的矩形框,每个矩形框有三个宽高比:1:1,2:1,1:2,总共九个框。而在v2中,Anchor Box的宽高不经过人为获得,而是将训练数据集中的矩形框全部拿出来,用kmeans聚类得到先验框的宽和高。例如使用5个Anchor Box, 那么kmeans聚类的类别中心个数设置为5。
加入了聚类操作之后,引入Anchor Box之后,mAP上升。
需要强调的是,聚类必须要定义聚类点(矩形框)之间的距离函数,文中使用(1-IOU)数值作为两个矩形框的的距离函数,这里的运用也是非常的巧妙。 - Direct location prediction(绝对位置预测)
Yolo中的位置预测方法很清晰,就是相对于左上角的格点坐标预测偏移量。这里的Direct具体含义,应该是和其他算法框架对比后得到的。比如其他流行的位置预测公式是先预测一个系数,系数又需要与先验框的宽高相乘才能得到相较于参考点的位置偏移,而在yolov2中,系数通过一个激活函数直接产生偏移位置数值,与矩形框的宽高独立开,变得更加直接。 - Fine-Grained Features(细粒度特征)
在26乘26的特征图,经过卷积层等,变为13乘13的特征图后,作者认为损失了很多细粒度的特征,导致小尺寸物体的识别效果不佳,所以在此加入了passthrough层。passthrough层就是将26乘26乘1的特征图,变成13乘13乘4的特征图,在这一次操作中不损失细粒度特征。 - Multi-Scale Training(多尺寸训练)
很关键的一点是,Yolo v2中只有卷积层与池化层,所以对于网络的输入大小,并没有限制,整个网络的降采样倍数为32,只要输入的特征图尺寸为32的倍数即可,如果网络中有全连接层,就不是这样了。所以Yolo v2可以使用不同尺寸的输入图片训练。
作者使用的训练方法是,在每10个batch之后,就将图片resize成{320, 352, …, 608}中的一种。不同的输入,最后产生的格点数不同,比如输入图片是320乘320,那么输出格点是10乘10,如果每个格点的先验框个数设置为5,那么总共输出500个预测结果;如果输入图片大小是608乘608,输出格点就是19乘19,共1805个预测结果。
在引入了多尺寸训练方法后,迫使卷积核学习不同比例大小尺寸的特征。当输入设置为544乘544甚至更大,Yolo v2的mAP已经超过了其他的物体检测算法。
YOLOv3
【论文解读】Yolo三部曲解读——Yolov3
Yolov3使用Darknet-53作为整个网络的分类骨干部分(见上图虚线部分)。
Darknet-53的架构如下图:
backbone部分由Yolov2时期的Darknet-19进化至Darknet-53,加深了网络层数,引入了Resnet中的跨层加和操作。Darknet-53处理速度每秒78张图,比Darknet-19慢不少,但是比同精度的ResNet快很多。Yolov3依然保持了高性能。
网络结构解析:
- Yolov3中,只有卷积层,通过调节卷积步长控制输出特征图的尺寸。所以对于输入图片尺寸没有特别限制。
- Yolov3借鉴了金字塔特征图思想,小尺寸特征图用于检测大尺寸物体,而大尺寸特征图检测小尺寸物体。特征图的输出维度为$N \times N \times [3 \times (4+1+80)]$, $N \times N$为输出特征图格点数,一共3个Anchor框,每个框有4维预测框数值和1维预测框置信度,80维物体类别数。
- Yolov3总共输出3个特征图,第一个特征图下采样32倍,第二个特征图下采样16倍,第三个下采样8倍。输入图像经过Darknet-53(无全连接层),再经过Yoloblock生成的特征图被当作两用,第一用为经过3乘3卷积层、1乘1卷积之后生成特征图一,第二用为经过1乘1卷积层加上采样层,与Darnet-53网络的中间层输出结果进行拼接,产生特征图二。同样的循环之后产生特征图三。
- concat操作与加和操作的区别:加和操作来源于ResNet思想,将输入的特征图,与输出特征图对应维度进行相加,即$y=f(x)+x$;而concat操作源于DenseNet网络的设计思路,将特征图按照通道维度直接进行拼接,例如8乘8乘16的特征图与8乘8乘16的特征图拼接后生成8乘8乘32的特征图。
- 上采样层(upsample):作用是将小尺寸特征图通过插值等方法,生成大尺寸图像。例如使用最近邻插值算法,将8乘8的图像变换为16乘16。上采样层不改变特征图的通道数。
Yolo的整个网络,吸取了Resnet、Densenet、FPN的精髓,可以说是融合了目标检测当前业界最有效的全部技巧。
YOLOv3与YOLOv2和YOLOv1相比最大的改善就是对boundingbox进行了跨尺度预测(Prediction Across Scales),提高YOLO模型对不同尺度对象的预测精度。
YOLO_v3论文解读
结合上图看,卷积网络在79层后,经过下方几个黄色的卷积层得到一种尺度的检测结果。相比输入图像,这里用于检测的特征图有32倍的下采样。比如输入是416乘416的话,这里的特征图就是13乘13了。由于下采样倍数高,这里特征图的感受野比较大,因此适合检测图像中尺寸比较大的对象。
为了实现细粒度的检测,第79层的特征图又开始作上采样(从79层往右开始上采样卷积),然后与第61层特征图拼接(Concatenation),这样得到第91层较细粒度的特征图,同样经过几个卷积层后得到相对输入图像16倍下采样的特征图。它具有中等尺度的感受野,适合检测中等尺度的对象。
最后,第91层特征图再次上采样,并与第36层特征图拼接(Concatenation),最后得到相对输入图像8倍下采样的特征图。它的感受野最小,适合检测小尺寸的对象。
随着输出的特征图的数量和尺度的变化,先验框的尺寸也需要相应的调整。YOLO2已经开始采用K-means聚类得到先验框的尺寸,YOLO3延续了这种方法,为每种下采样尺度设定3种先验框,总共聚类出9种尺寸的先验框。在COCO数据集这9个先验框是:
分配上,在最小的13乘13特征图上(有最大的感受野)应用较大的先验框$(116 \times 90),(156 \times 198),(373 \times 326)$,适合检测较大的对象。中等的26乘26特征图上(中等感受野)应用中等的先验框$(30 \times 61),(62 \times 45),(59 \times 119)$,适合检测中等大小的对象。较大的52乘52特征图上(较小的感受野)应用较小的先验框$(10 \times 13),(16 \times 30),(33 \times 23)$,适合检测较小的对象。
YOLOv3前向解码过程:
根据不同的输入尺寸,会得到不同大小的输出特征图,以图二中输入图片$256 \times 256 \times 3$为例,输出的特征图为$8 \times 8 \times 255$、$16 \times 16 \times 255$、$32 \times 32 \times 255$。在Yolov3的设计中,每个特征图的每个格子中,都配置3个不同的先验框(就是下面的锚框),所以最后三个特征图,这里暂且reshape为$8 \times 8 \times 3 \times 85$、$16 \times 16 \times 3 \times 85$、$32 \times 32 \times 3 \times 85$,这样更容易理解,在代码中也是reshape成这样之后更容易操作。
三张特征图就是整个Yolo输出的检测结果,检测框位置(4维)、检测置信度(1维)、类别(80维)都在其中,加起来正好是85维。特征图最后的维度85,代表的就是这些信息,而特征图其他维度$N \times N \times 3$,$N \times N$代表了检测框的参考位置信息,3是3个不同尺度的先验框。
三个特征图一共可以解码出 $8 × 8 × 3 + 16 × 16 × 3 + 32 × 32 × 3 = 4032$ 个box以及相应的类别、置信度。这4032个box,在训练和推理时,使用方法不一样:
- 训练时4032个box全部送入打标签函数,进行后一步的标签以及损失函数的计算。
- 推理时,选取一个置信度阈值,过滤掉低阈值box,再经过nms(非极大值抑制),就可以输出整个网络的预测结果了。
YOLOv3训练策略(反向过程):
- 预测框一共分为三种情况:正例(positive)、负例(negative)、忽略样例(ignore)。
- 正例:任取一个ground truth,与4032个框全部计算IOU,IOU最大的预测框,即为正例。并且一个预测框,只能分配给一个ground truth。例如第一个ground truth已经匹配了一个正例检测框,那么下一个ground truth,就在余下的4031个检测框中,寻找IOU最大的检测框作为正例。ground truth的先后顺序可忽略。正例产生置信度loss、检测框loss、类别loss。预测框为对应的ground truth box标签;类别标签对应类别为1,其余为0;置信度标签为1。
- 忽略样例:正例除外,与任意一个ground truth的IOU大于阈值(论文中使用0.5),则为忽略样例。忽略样例不产生任何loss。
- 负例:正例除外(与ground truth计算后IOU最大的检测框,但是IOU小于阈值,仍为正例),与全部ground truth的IOU都小于阈值(0.5),则为负例。负例只有置信度产生loss,置信度标签为0。
YOLOv3源码
从头实现YOLOv3的源码见:
YOLOv3 in PyTorch
该源码的视频讲解见:
YOLOv3 from Scratch
模型架构
整个YOLOv3的模型架构如下配置:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32# 对于里面的元素
# 如果是元组,代表:(输出通道, 卷积核尺寸, 步长)
# YOLOv3中所有的卷积块(注意是卷积块,它由卷积层+批标准化层+LeakyReLU层构成)都是相同的,在下面的代码中用CNNBlock类实现
# 如果是列表,"B"代表残差块Residual Block,后面的次数代表重复次数,在下面用ResidualBlock类实现
# 如果是字符,那么"S"代表Scale不同尺度预测块,在此处计算损失,在下面用ScalePrediction类实现
# "U"代表Upsampling上采样,且与上一层进行连接,生成新的尺度预测
config = [
(32, 3, 1),
(64, 3, 2),
["B", 1],
(128, 3, 2),
["B", 2],
(256, 3, 2),
["B", 8],
(512, 3, 2),
["B", 8],
(1024, 3, 2),
["B", 4], # 到这里就是Darknet-53 backbone,53是全部卷积层的个数,它会在imagenet上进行预训练
(512, 1, 1),
(1024, 3, 1),
"S",
(256, 1, 1),
"U",
(256, 1, 1),
(512, 3, 1),
"S",
(128, 1, 1),
"U",
(128, 1, 1),
(256, 3, 1),
"S",
]
首先看一下CNN卷积块的实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class CNNBlock(nn.Module):
# 此处会加上一个BN层的开关,如果关了BN层,就相当于是只有卷积层,而不是卷积块
# 不加BN和ReLU层的纯卷积层是会在尺度预测的地方用到,即网络末端的卷积是纯卷积层
# 同时使用kwargs参数接收其他参数,比如kerneal size,stride,padding等参数
# 在整个网络中图像的宽高变化即维度压缩,是通过卷积块的stride参数来实现的
# 由下面的分析可知,在残差块中图像宽高不变,但两个残差块中间的卷积块的stride为2,此时会对图像的宽高进行压缩减半
# 整个网络中压缩最厉害的分支是一共压缩了5次,即压缩了32倍,另外两支分别压缩了16倍和8倍
def __init__(self, in_channels, out_channels, bn_act=True, **kwargs):
super().__init__()
# 如果使用了BN,那么偏置这个参数就没必要了,所以此处会根据BN层的有无进行偏置bias参数的开关
self.conv = nn.Conv2d(in_channels, out_channels, bias=not bn_act, **kwargs)
self.bn = nn.BatchNorm2d(out_channels)
self.leaky = nn.LeakyReLU(0.1)
self.use_bn_act = bn_act
def forward(self, x):
if self.use_bn_act:
return self.leaky(self.bn(self.conv(x)))
else:
return self.conv(x)
再看一下残差块的实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31class ResidualBlock(nn.Module):
# 这里给出了是否使用残差连接的开关,在darknet-53部分都是打开残差连接,但到了尺度预测部分,该残差块是关闭了残差连接
def __init__(self, channels, use_residual=True, num_repeats=1):
super().__init__()
# 整个残差块是一个ModuleList
self.layers = nn.ModuleList()
# 根据重复次数进行循环
for repeat in range(num_repeats):
self.layers += [
# 每个残差块中的第一个卷积块都是通道数减半,卷积核尺寸为1,步长是默认的1,填充是默认的0,因此图像的输入和输出宽高不变
# 第二个卷积块通道数变为两倍,卷积核尺寸为3,填充为1,步长仍是默认的1,因此图像的输入和输出宽高不变
# 所以,总的来说,经过一个残差块后,图像的通道数、宽和高都不会变
nn.Sequential(
CNNBlock(channels, channels // 2, kernel_size=1),
CNNBlock(channels // 2, channels, kernel_size=3, padding=1),
)
]
self.use_residual = use_residual
self.num_repeats = num_repeats
def forward(self, x):
for layer in self.layers:
# 如果启用残差连接,那么就直接将x和经过处理后的x相加
if self.use_residual:
x = x + layer(x)
# 如果没有启用残差连接,那么就直接处理x,不管作为输入的x
else:
x = layer(x)
return x
再来看不同尺度预测的类实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25class ScalePrediction(nn.Module):
def __init__(self, in_channels, num_classes):
super().__init__()
self.pred = nn.Sequential(
# 在每个尺度预测块中,先用一个卷积块将通道数加倍,同时通过设置卷积核尺寸为3,填充为1,步长是默认的1,来保持宽高不变
CNNBlock(in_channels, 2 * in_channels, kernel_size=3, padding=1),
# 然后将得到的特征图通过一个卷积块转化为最终想要的向量的模样
# 3指的是对于对于每一个grid cell,都有3个anchor boxes
# 对于每一个anchor box,都需要有num_classes+5个元素,前面是类别数目,比如20,5是包含了x, y, w, h和置信度
# 注意这里不使用BN层,同时卷积核为1,步长是默认的1,填充是默认的0,因此宽高不变
CNNBlock(
2 * in_channels, (num_classes + 5) * 3, bn_act=False, kernel_size=1
),
)
self.num_classes = num_classes
def forward(self, x):
return (
self.pred(x)
# 对x进行预测后,需要对结果进行reshape,形状依次为batch size、3、类别数+5、特征图宽度、特征图高度
.reshape(x.shape[0], 3, self.num_classes + 5, x.shape[2], x.shape[3])
# 再交换一下维度,把宽、高提到(类别数+5)的前面
# 比如某一个尺度预测后,得到的向量形状为N x 3 x 13 x 13 x (5+num_classes),grid cell就是13x13大小
.permute(0, 1, 3, 4, 2)
)
最后看整个模型的架构,即将上面的组件组合起来:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99class YOLOv3(nn.Module):
# 输入通道默认为3, 类别数默认为80
def __init__(self, in_channels=3, num_classes=80):
super().__init__()
self.num_classes = num_classes
self.in_channels = in_channels
self.layers = self._create_conv_layers()
def _create_conv_layers(self):
# 将所有模型组件都放在ModuleList中
layers = nn.ModuleList()
in_channels = self.in_channels
# 开始解析上面的config配置
for module in config:
# 如果元素是个元组,代表它是个卷积块
if isinstance(module, tuple):
# 取出卷积块的相应配置
out_channels, kernel_size, stride = module
# 往整个网络里添加卷积块
layers.append(
CNNBlock(
in_channels,
out_channels,
kernel_size=kernel_size,
stride=stride,
# 如果卷积核为3,则填充为1,否则就填充为0,这样是为了当卷积核为3、步长为2时,填充设为1,此时宽高减半
# Pytorch默认卷积层的尺寸计算是向下取整,即(k+2*1-3)/2+1=k/2+floor(-0.5)+1=k/2-1+1=k/2
padding=1 if kernel_size == 3 else 0,
)
)
# 更新通道数
in_channels = out_channels
# 如果元素是个列表,代表是残差块
elif isinstance(module, list):
# 取出残差块的相应配置
num_repeats = module[1]
# 往整个网络里添加残差块
layers.append(ResidualBlock(in_channels, num_repeats=num_repeats,))
# 如果元素是个字符,那么就进入尺度预测模块
elif isinstance(module, str):
# 如果是S,代表要进行在某一尺度上的预测了
if module == "S":
layers += [
# 下面这三块的网络架构参考下面那张YOLOv3的架构图
# 原码中残差块只重复了1次,为了与下面架构图中的YoloBlock相对应,这里改为重复2次,影响不大,因为在残差块中不改变图像大小
# 同时注意此时残差块关闭了残差连接
ResidualBlock(in_channels, use_residual=False, num_repeats=2),
CNNBlock(in_channels, in_channels // 2, kernel_size=1),
ScalePrediction(in_channels // 2, num_classes=self.num_classes),
]
# 更新一下通道数
in_channels = in_channels // 2
# 如果是U,则进入上采样
elif module == "U":
layers.append(nn.Upsample(scale_factor=2),)
# 通道数变为3倍,原因是这个地方进行了通道连接concatenation操作
# 特别注意的是,不要在这个地方推导图像在整个模型中的处理过程,因为此时会发现前后通道数是不符的,因为通道一下从256跳到了768
# 这个地方不是forward函数,并不是真正的数据处理过程,可以理解成这个地方仅是模型架构定义
in_channels = in_channels * 3
return layers
def forward(self, x):
# 每一个尺度下都有一个output,这里用一个列表来承载三个output
outputs = []
# 存放进入不同预测分支的中间计算结果
route_connections = []
# 对网络中的每一层进行遍历
for layer in self.layers:
# 如果是尺度预测层,表示进入某一尺度的预测阶段,即进入某一个预测分支
if isinstance(layer, ScalePrediction):
# 将预测结果添加进outputs中,注意这个地方是对x的一个分叉计算
# 即x在这里走了两条路,一条路是进入尺度预测模块进行计算,另一条路是继续呆在主分支中,用于后续计算
outputs.append(layer(x))
# 返回到主分支中
continue
# 对常规的网络层进行计算,包含卷积块和重复次数不为8的残差块
x = layer(x)
# 对于重复次数为8的残差块,由架构图可知,都是在这里进入不同的尺度预测分支
if isinstance(layer, ResidualBlock) and layer.num_repeats == 8:
# 将需要进入某分支的结果存放起来
route_connections.append(x)
# 如果是遇到上采样模块
elif isinstance(layer, nn.Upsample):
# 就会将当前x与存放中间结果的route中的最后一个中间结果进行连接concatenation
# 这个地方会将通道数变为3倍,因为上采样后的图像为n_channel,原主分支中的图像为2*n_channel,连接后就变为3*n_channel
x = torch.cat([x, route_connections[-1]], dim=1)
# 用完最后一个元素就把它丢了,这样就能在下一次取到上一个存储的中间结果
route_connections.pop()
# 最终outputs里是存放了三个尺度的预测模型
return outputs
YOLOv3架构图重新贴一下:
数据集
作者提供了YOLO格式的PASCAL VOC和MS COCO数据集的下载,分别在下面链接:
Pascal voc dataset used in YOLOv3 video
MS-COCO-YOLOv3
关于数据集的格式可以参见下面的介绍:
Train Custom Data
1 | class YOLODataset(Dataset): |
损失计算
YOLOv3中的损失有三种,一种是xywh带来的误差,即检测框loss;一种是置信度带来的误差,即是否是个物体obj带来的loss,称为置信度loss;一种是类别带来的误差,称为类别loss。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81class YoloLoss(nn.Module):
def __init__(self):
super().__init__()
self.mse = nn.MSELoss() # 均方差损失计算
self.bce = nn.BCEWithLogitsLoss() # 加了Sigmoid的二进制交叉熵损失
self.entropy = nn.CrossEntropyLoss() # 交叉熵损失
self.sigmoid = nn.Sigmoid()
# 确定损失函数中的各个权重常数,用来控制不同loss之间的比例
self.lambda_class = 1 # 类别损失权重常数
self.lambda_noobj = 10 # 负例损失权重常数
self.lambda_obj = 1 # 正例损失权重常数
self.lambda_box = 10 # 检测框损失权重常数
def forward(self, predictions, target, anchors):
# 判断每个尺度上的格点上是否是物体,即正例还是负例
# 1为正例,0为负例,-1则为忽略样例
obj = target[..., 0] == 1 # in paper this is Iobj_i
noobj = target[..., 0] == 0 # in paper this is Inoobj_i
# ======================= #
# 负例造成的置信度损失 #
# ======================= #
# 使用二进制交叉熵计算损失
no_object_loss = self.bce(
# 取出置信度数值,即第0个元素
# 这里使用0:1的形式,而不是直接使用0来取得元素,是为了保持维度不变
# [noobj] 是使用了numpy的布尔索引,从而取出那些负例
(predictions[..., 0:1][noobj]), (target[..., 0:1][noobj]),
)
# ==================== #
# 正例造成的置信度损失 #
# ==================== #
# 这个地方正例损失按上面的解析应该使用一个简单的bce即可,同时置信度标签在yolov3中是1和0二分类,而这里原作者使用的是IoU来作为置信度标签,即如下形式:
object_loss = self.bce(
(predictions[..., 0:1][obj]), (target[..., 0:1][obj]),
)
# # 原来的代码中是如下形式,这里先不仔细研究异同了
# anchors = anchors.reshape(1, 3, 1, 1, 2)
# box_preds = torch.cat([self.sigmoid(predictions[..., 1:3]), torch.exp(predictions[..., 3:5]) * anchors], dim=-1)
# ious = intersection_over_union(box_preds[obj], target[..., 1:5][obj]).detach()
# object_loss = self.mse(self.sigmoid(predictions[..., 0:1][obj]), ious * target[..., 0:1][obj])
# ======================== #
# 检测框损失 #
# ======================== #
# 这个地方涉及检测框的解码部分
# 注意只取出正例造成的损失
predictions[..., 1:3] = self.sigmoid(predictions[..., 1:3]) # x, y坐标
target[..., 3:5] = torch.log(
(1e-16 + target[..., 3:5] / anchors)
) # width, height coordinates
box_loss = self.mse(predictions[..., 1:5][obj], target[..., 1:5][obj])
# ================== #
# 类别损失 #
# ================== #
# 计算交叉熵损失,注意只取出正例造成的损失
class_loss = self.entropy(
(predictions[..., 5:][obj]), (target[..., 5][obj].long()),
)
#print("__________________________________")
#print(self.lambda_box * box_loss)
#print(self.lambda_obj * object_loss)
#print(self.lambda_noobj * no_object_loss)
#print(self.lambda_class * class_loss)
#print("\n")
return (
self.lambda_box * box_loss
+ self.lambda_obj * object_loss
+ self.lambda_noobj * no_object_loss
+ self.lambda_class * class_loss
)
超参数配置文件
此处就是将超参数配置都摘出来放在一个统一的配置文件中。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27DATASET = 'PASCAL_VOC'
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# seed_everything() # If you want deterministic behavior
NUM_WORKERS = 4
BATCH_SIZE = 32
IMAGE_SIZE = 416
NUM_CLASSES = 20 #类别数
LEARNING_RATE = 1e-5
WEIGHT_DECAY = 1e-4
NUM_EPOCHS = 100
CONF_THRESHOLD = 0.05
MAP_IOU_THRESH = 0.5
NMS_IOU_THRESH = 0.45
S = [IMAGE_SIZE // 32, IMAGE_SIZE // 16, IMAGE_SIZE // 8]
PIN_MEMORY = True
LOAD_MODEL = True
SAVE_MODEL = True
CHECKPOINT_FILE = "checkpoint.pth.tar"
IMG_DIR = DATASET + "/images/"
LABEL_DIR = DATASET + "/labels/"
# 通过在训练集上kmeans聚类得到的锚框的大小
ANCHORS = [
[(0.28, 0.22), (0.38, 0.48), (0.9, 0.78)],
[(0.07, 0.15), (0.15, 0.11), (0.14, 0.29)],
[(0.02, 0.03), (0.04, 0.07), (0.08, 0.06)],
] # Note these have been rescaled to be between [0, 1]
训练函数
1 | def train_fn(train_loader, model, optimizer, loss_fn, scaler, scaled_anchors): |