个人感觉YOLOv3论文写的真的很随意,首先大家可以感受下。作者在Introduction中是这样开头的:
"Sometimes you just kinda phone it in for a year, you know? I didn’t do a whole lot of research this year. Spent a lot of time on Twitter."
"其实有时候有些人一年的时间就这样蹉跎了,你懂的。所以去年我也没做什么研究,主要用来刷 Twitter 了。"
作者是这样结尾的:“In closing, do not @ me. (because I finally quit Twitter)”.
真是太随意了,不过毕竟是大牛,说的废话都可以被当做经典。不废话了,说说YOLOv3到底干了个啥。
首先回想一下 YOLOv2中提出的Darknet-19网络结构作为主干特征提取网络。考虑到对于小物体的检测,结合FPN(特征金字塔)的思想,YOLOv2简单添加一个 passthrough layer,把浅层特征图(分辨率为26 × 26,即提取特征图的倒数第二卷积层结果)连接到深层特征图。通过把高低分辨率的特征图做连结,叠加相邻特征到不同通道(而非空间位置),类似于ResNet中的identity mappings。
在YOLOv3中,作者可能觉得Darknet-19网络还是不够深(因为更深的网络结构可以学习到更加丰富的特征),故再次借鉴ResNet网络和FPN(特征金字塔)的思想,提出了Darknet-53
网络结构,如下图所示(图片来源)。
Darknet53中的Residual Block
进行一次3X3、步长为2的卷积,然后保存该卷积结果layer;再进行一次1X1的卷积和一次3X3的卷积,并把这个结果加上layer作为最后的结果。 残差网络的特点是容易优化,并且能够通过增加相当的深度来提高准确率。其内部的残差块使用了跳跃连接,缓解了在深度神经网络中增加深度带来的梯度消失问题。
上图中左半部分虚线框内即为Darknet-53网络机构,可以看到该网络结构的输入为 416×416×3
,之后通过一个3×3
的卷积层来扩增通道数。接下来通过堆叠一系列Residual Block来构建网络,其具体个数为[1, 2, 8, 8, 4]
,最终主干网络输出大小为13×13、26×26、52×52
三个大小的特征图,目的是可以检测到图像中更小的物体。特征图分割越密集,则每一个特征点相对于原图中的区域越小,从而可以监测到更小的物体。
下图为9种先验框的尺寸,其中蓝色框为聚类得到的先验框。黄色框是ground truth,红框是检测对象中心点所在的网格。
Darknet-53主干网络代码如下:
import torch
import torch.nn as nn
import math
from collections import OrderedDict
# 基本的darknet块
class BasicBlock(nn.Module):
def __init__(self, inplanes, planes): # resnet block中是 先进行一个1×1卷积 再进行一个3×3卷积
super(BasicBlock, self).__init__()
self.conv1 = nn.Conv2d(inplanes, planes[0], kernel_size=1, # 1×1卷积目的是下降通道数
stride=1, padding=0, bias=False)
self.bn1 = nn.BatchNorm2d(planes[0])
self.relu1 = nn.LeakyReLU(0.1)
self.conv2 = nn.Conv2d(planes[0], planes[1], kernel_size=3, # 3×3卷积目的是扩张通道数,注意这里并不减少特征图的大小!!
stride=1, padding=1, bias=False) # 这样做可以帮助减少参数量
self.bn2 = nn.BatchNorm2d(planes[1])
self.relu2 = nn.LeakyReLU(0.1)
def forward(self, x):
residual = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu1(out)
out = self.conv2(out)
out = self.bn2(out)
out = self.relu2(out)
out += residual
return out
class DarkNet(nn.Module):
def __init__(self, layers):
super(DarkNet, self).__init__()
self.inplanes = 32
self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=3, stride=1, padding=1, bias=False) # 第一个卷积 3->32
self.bn1 = nn.BatchNorm2d(self.inplanes)
self.relu1 = nn.LeakyReLU(0.1)
self.layer1 = self._make_layer([32, 64], layers[0])
self.layer2 = self._make_layer([64, 128], layers[1])
self.layer3 = self._make_layer([128, 256], layers[2])
self.layer4 = self._make_layer([256, 512], layers[3])
self.layer5 = self._make_layer([512, 1024], layers[4])
self.layers_out_filters = [64, 128, 256, 512, 1024]
# 进行权值初始化
for m in self.modules():
if isinstance(m, nn.Conv2d):
n = m.kernel_size[0] * m.kernel_size[1] * m.out_channels
m.weight.data.normal_(0, math.sqrt(2. / n))
elif isinstance(m, nn.BatchNorm2d):
m.weight.data.fill_(1)
m.bias.data.zero_()
def _make_layer(self, planes, blocks): # 进行下采样且不断堆叠残差块
layers = []
# 下采样,步长为2,卷积核大小为3,用于减少特征图尺寸
layers.append(("ds_conv", nn.Conv2d(self.inplanes, planes[1], kernel_size=3,
stride=2, padding=1, bias=False)))
layers.append(("ds_bn", nn.BatchNorm2d(planes[1])))
layers.append(("ds_relu", nn.LeakyReLU(0.1)))
# 加入darknet模块
self.inplanes = planes[1]
for i in range(0, blocks):
layers.append(("residual_{}".format(i), BasicBlock(self.inplanes, planes)))
return nn.Sequential(OrderedDict(layers))
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu1(x)
x = self.layer1(x)
x = self.layer2(x)
out3 = self.layer3(x)
out4 = self.layer4(out3)
out5 = self.layer5(out4)
return out3, out4, out5
# pretrained为权重文件路径
def darknet53(pretrained, **kwargs):
model = DarkNet([1, 2, 8, 8, 4])
if pretrained:
if isinstance(pretrained, str):
model.load_state_dict(torch.load(pretrained))
else:
raise Exception("darknet request a pretrained path. got [{}]".format(pretrained))
return model
网络结构的右半部分包括将主干网络Darknet-53的输出进行生成特征金字塔。具体做法为首先将13×13×1024
的特征层进行上采样变为26×26×256
,再与26×26×512
的特征层进行堆叠,其结果为26×26×768
大小的特征;同理,大小为52×52×256
的特征层与下一层网络堆叠后的结果为52×52×384
。
对于这三个堆叠后的结果,分别在进行5次卷积操作,最后接上一个3×3
的卷积和1×1
的卷积,用于输出预测结果,三个层对应的输出结果大小分别为13×13×75、26×26×75、52×52×75
。其中75 = (4+1+20)×3
,4表示一个边界框对应的调整参数x, y, w, h
,1表示置信度,20表示VOC数据集分为20个类,3表示特征图上的每一个点对应有3个anchor。
输入该主干网络的图片为任意大小,故在进行特征提取之前需要进行Resize
。这里将图片的相对较长的边缩小到416,在保持图片比例不变的情况下对短边进行缩减;最后用灰色条对空白部分进行填充。这样操作使得原图片不会失真,如下图若直接对原图进行resize为416×416
,图片中的孩子就没那么可爱了。。。
图片Resize函数代码:
def letterbox_image(image, size):
iw, ih = image.size
w, h = size
scale = min(w/iw, h/ih)
nw = int(iw*scale)
nh = int(ih*scale)
image = image.resize((nw, nh), Image.BICUBIC)
new_image = Image.new('RGB', size, (128, 128, 128))
# 将原图像paste到中心
new_image.paste(image, ((w-nw)//2, (h-nh)//2))
return new_image
YOLOv3论文中也没有对训练过程中的损失函数进行详细介绍,通过查看源码、参考各种博客、观察运行过程中的数据弄明白了loss的计算过程。
Loss的计算共由四部分组成:
下面将分别对真实框和预测框在以上四个方面的表示进行详细介绍。
我们知道Loss是由图片的真实标签和网络对图片的预测结果两者通过一定的函数计算出来的。对于真实标签,仍利用以下这张图举例。我们知道 .xml文件
是对每张图片的标注,这张图片对应的 .xml
文件标注内容如下。
这张图片的大小为 500×333
。可以看到,对于该图像的标注,给定的是标注框的左上角坐标(168, 2)
和右下角坐标(500, 331)
,经过Resize后图片尺寸成为416×416
,并且将该图片的左上角和右下角标注进行相应变换。
而在计算损失Loss时,首先将该图片对应的真实框转换为 [0.230, 0.200, 1, 0.795, 12]
(预估计),即首先需要将真实框的坐标转换为相对于原点的归一化坐标,12 表示person这个类。其次以下将全部基于特征图尺寸的大小进行。真正输入到Loss函数中进行计算的真实标签值是下图中表示的tx ,tx
即为真实框中心点对应于该中心点落入特征图某点的左上角的尺寸(假定特征图每个点的长度为1),ty
同理。
下图将粉色框放大以表示tx, ty
的值:
以上为真实框中心点用于计算Loss的tx, ty
的表示,对于真实框用于计算Loss的宽高tw, th
的表示如下:
对于置信度Conf
损失项,真实框的置信度均为1;
对于类别Cls
损失项,真实框对应的类别为1,其余类别均为0。
之前提到过,网络预测结果的输出大小为13×13×75、26×26×75、52×52×75
,其中75 = (4+1+20)×3,4表示一个边界框对应的调整参数x, y, w, h
,1表示置信度,20表示VOC数据集分为20个类,3表示特征图上的每一个点对应有3个anchor。对于每个anchor,都有以下的25个数字为一组的预测结果。
0 1 2 3 4 5:25
x的调整参数 y的调整参数 w的调整参数 h的调整参数 置信度 20种类别的预测概率
对以上预测结果拆分成四个部分对应Loss的四个部分,即可输入到损失函数中进行计算。
那么预测结果中的这些调整参数到底有什么用呢?在下图中,黑色虚线框是原anchor尺寸,蓝色框是通过x, y, w, h
的调整参数调整之后的框框,目的就是使anchor经过调整后更加毕竟真实框。
YOLOv3的损失函数,不同部分的损失项使用了不同的损失函数。首先介绍使用到的两种损失函数,再具体对应到哪一损失项使用了那种损失函数。
(1) BCELoss(Binary Cross Entropy)
该损失函数用于二分类任务的交叉熵计算函数,其计算公式为:
= − ×() - (1 − )×(1 − )
函数代码块为:
def BCELoss(pred, target):
epsilon = 1e-7
pred = clip_by_tensor(pred, epsilon, 1.0 - epsilon)
output = -target * torch.log(pred) - (1.0 - target) * torch.log(1.0 - pred)
return output
def clip_by_tensor(t, t_min, t_max):
t = t.float()
result = (t >= t_min).float() * t + (t < t_min).float() * t_min
result = (result <= t_max).float() * result + (result > t_max).float() * t_max
return result
(2)MSELoss(Mean Square Entropy)
,即为平方差损失函数,其代码块为:
def MSELoss(pred, target):
return 0.5 * (pred-target)**2
损失函数Loss的四个部分具体使用的损失函数对应如下:
可以看到,仅有w, h 的调整参数项使用了MSELoss
,其余均为BCELoss
。整个损失函数的部分代码块如下:
# x为预测调整值 tx为真实调整值
loss_x = torch.sum(BCELoss(x, tx) / bs * box_loss_scale * mask)
loss_y = torch.sum(BCELoss(y, ty) / bs * box_loss_scale * mask)
loss_w = torch.sum(MSELoss(w, tw) / bs * 0.5 * box_loss_scale * mask)
loss_h = torch.sum(MSELoss(h, th) / bs * 0.5 * box_loss_scale * mask)
loss_conf = torch.sum(BCELoss(conf, mask) * mask / bs) + torch.sum(BCELoss(conf, mask) * noobj_mask / bs)
loss_cls = torch.sum(BCELoss(pred_cls[mask == 1], tcls[mask == 1])/bs)
# loss 为对每一项分别乘对应的系数后求和,即为总loss
loss = loss_x * self.lambda_xy + loss_y * self.lambda_xy + \
loss_w * self.lambda_wh + loss_h * self.lambda_wh + \
loss_conf * self.lambda_conf + loss_cls * self.lambda_cls
论文地址:YOLOv3: An Incremental Improvement
以上是对YOLOv3模型的具体阐释总结,包括Darknet-53特征提取网络、其图片预处理方式、训练过程的具体损失函数计算等等,具体学习还需要系统的阅读代码。
作者源代码地址:https://github.com/bubbliiiing/yolo3-pytorch
本人fork自学注释后github地址:https://github.com/Bryce-HJ/yolov3_pytorch
该代码附有详细注释,结合本文应该可以很顺利的看懂。若有问题欢迎评论区留言。
还是老样子,本文使用的网络 .pth
权重文件 ,大小为236M,下载该权重文件放入对应文件夹model_data
,即可直接对图片进行预测。
权重文件获取方式:关注【OAOA】回复【yolo3】即可获取。