本教程包含五个部分:
1. YOLO 的工作原理
2. 创建 YOLO 网络层级
3. 实现网络的前向传播
4. objectness 置信度阈值和非极大值抑制
5. 设计输入和输出管道
全卷积神经网络
YOLO 仅使用卷积层,这就使其成为全卷积神经网络(FCN)。它拥有 75 个卷积层,还有跳过连接和上采样层。它不使用任何形式的池化,使用步幅为 2 的卷积层对特征图进行下采样。这有助于防止通常由池化导致的低级特征丢失。
作为 FCN,YOLO 对于输入图像的大小并不敏感。然而,在实践中,我们可能想要持续不变的输入大小,因为各种问题只有在我们实现算法时才会浮现出来。
这其中的一个重要问题是:如果我们希望按批次处理图像(批量图像由 GPU 并行处理,这样可以提升速度),我们就需要固定所有图像的高度和宽度。这就需要将多个图像整合进一个大的批次(将许多 PyTorch 张量合并成一个)。
YOLO 通过被步幅对图像进行上采样。例如,如果网络的步幅是 32,则大小为 416×416 的输入图像将产生 13×13 的输出。通常,网络层中的任意步幅都指层的输入除以输入。
解释输出
典型地(对于所有目标检测器都是这种情况),卷积层所学习的特征会被传递到分类器/回归器,从而进行预测(边界框的坐标、类别标签等)。
在 YOLO 中,预测是通过卷积层完成的(它是一个全卷积神经网络,请记住!)其核心尺寸为:
1×1×(B×(5+C))
现在,首先要注意的是我们的输出是一个特征图。由于我们使用了 1×1 的卷积,所以预测图的大小恰好是之前特征图的大小。在 YOLO v3(及其更新的版本)上,预测图就是每个可以预测固定数量边界框的单元格。
虽然形容特征图中单元的正确术语应该是「神经元」,但本文中为了更为直观,我们将其称为单元格(cell)。
深度方面,特征图中有 (B x (5 + C))* *个条目。B 代表每个单元可以预测的边界框数量。根据 YOLO 的论文,这些 B 边界框中的每一个都可能专门用于检测某种对象。每个边界框都有 5+C 个属性,分别描述每个边界框的中心坐标、维度、objectness 分数和 C 类置信度。YOLO v3 在每个单元中预测 3 个边界框。
如果对象的中心位于单元格的感受野内,你会希望特征图的每个单元格都可以通过其中一个边界框预测对象。(感受野是输入图像对于单元格可见的区域。)
这与 YOLO 是如何训练的有关,只有一个边界框负责检测任意给定对象。首先,我们必须确定这个边界框属于哪个单元格。
因此,我们需要切分输入图像,把它拆成维度等于最终特征图的网格。
让我们思考下面一个例子,其中输入图像大小是 416×416,网络的步幅是 32。如之前所述,特征图的维度会是 13×13。随后,我们将输入图像分为 13×13 个网格。
输入图像中包含了真值对象框中心的网格会作为负责预测对象的单元格。在图像中,它是被标记为红色的单元格,其中包含了真值框(被标记为黄色)的中心。
现在,红色单元格是网格中第七行的第七个。我们现在使特征图中第七行第七个单元格(特征图中的对应单元格)作为检测狗的单元。
现在,这个单元格可以预测三个边界框。哪个将会分配给狗的真值标签?为了理解这一点,我们必须理解锚点的概念。
请注意,我们在这里讨论的单元格是预测特征图上的单元格,我们将输入图像分隔成网格,以确定预测特征图的哪个单元格负责预测对象。
锚点框(Anchor Box)
预测边界框的宽度和高度看起来非常合理,但在实践中,训练会带来不稳定的梯度。所以,现在大部分目标检测器都是预测对数空间(log-space)变换,或者预测与预训练默认边界框(即锚点)之间的偏移。
然后,这些变换被应用到锚点框来获得预测。YOLO v3 有三个锚点,所以每个单元格会预测 3 个边界框。
回到前面的问题,负责检测狗的边界框的锚点有最高的 IoU,且有真值框。
中心坐标
注意:我们使用 sigmoid 函数进行中心坐标预测。这使得输出值在 0 和 1 之间。原因如下:
正常情况下,YOLO 不会预测边界框中心的确切坐标。它预测:
——与预测目标的网格单元左上角相关的偏移;
——使用特征图单元的维度(1)进行归一化的偏移。
以我们的图像为例。如果中心的预测是 (0.4, 0.7),则中心在 13 x 13 特征图上的坐标是 (6.4, 6.7)(红色单元的左上角坐标是 (6,6))。
但是,如果预测到的 x,y 坐标大于 1,比如 (1.2, 0.7)。那么中心坐标是 (7.2, 6.7)。注意该中心在红色单元右侧的单元中,或第 7 行的第 8 个单元。这打破了 YOLO 背后的理论,因为如果我们假设红色框负责预测目标狗,那么狗的中心必须在红色单元中,不应该在它旁边的网格单元中。
因此,为了解决这个问题,我们对输出执行 sigmoid 函数,将输出压缩到区间 0 到 1 之间,有效确保中心处于执行预测的网格单元中。
边界框的维度
我们对输出执行对数空间变换,然后乘锚点,来预测边界框的维度。
检测器输出在最终预测之前的变换过程
得出的预测 bw 和 bh 使用图像的高和宽进行归一化。即,如果包含目标(狗)的框的预测 bx 和 by 是 (0.3, 0.8),那么 13 x 13 特征图的实际宽和高是 (13 x 0.3, 13 x 0.8)。
Objectness 分数
Object 分数表示目标在边界框内的概率。红色网格和相邻网格的 Object 分数应该接近 1,而角落处的网格的 Object 分数可能接近 0。
objectness 分数的计算也使用 sigmoid 函数,因此它可以被理解为概率。
类别置信度
类别置信度表示检测到的对象属于某个类别的概率(如狗、猫、香蕉、汽车等)。在 v3 之前,YOLO 需要对类别分数执行 softmax 函数操作。
但是,YOLO v3 舍弃了这种设计,作者选择使用 sigmoid 函数。因为对类别分数执行 softmax 操作的前提是类别是互斥的。简言之,如果对象属于一个类别,那么必须确保其不属于另一个类别。这在我们设置检测器的 COCO 数据集上是正确的。但是,当出现类别「女性」(Women)和「人」(Person)时,该假设不可行。这就是作者选择不使用 Softmax 激活函数的原因。
在不同尺度上的预测
YOLO v3 在 3 个不同尺度上进行预测。检测层用于在三个不同大小的特征图上执行预测,特征图步幅分别是 32、16、8。这意味着,当输入图像大小是 416 x 416 时,我们在尺度 13 x 13、26 x 26 和 52 x 52 上执行检测。
该网络在第一个检测层之前对输入图像执行下采样,检测层使用步幅为 32 的层的特征图执行检测。随后在执行因子为 2 的上采样后,并与前一个层的特征图(特征图大小相同)拼接。另一个检测在步幅为 16 的层中执行。重复同样的上采样步骤,最后一个检测在步幅为 8 的层中执行。
在每个尺度上,每个单元使用 3 个锚点预测 3 个边界框,锚点的总数为 9(不同尺度的锚点不同)。
作者称这帮助 YOLO v3 在检测较小目标时取得更好的性能,而这正是 YOLO 之前版本经常被抱怨的地方。上采样可以帮助该网络学习细粒度特征,帮助检测较小目标。
输出处理
对于大小为 416 x 416 的图像,YOLO 预测 ((52 x 52) + (26 x 26) + 13 x 13)) x 3 = 10647 个边界框。但是,我们的示例中只有一个对象——一只狗。那么我们怎么才能将检测次数从 10647 减少到 1 呢?
目标置信度阈值:首先,我们根据它们的 objectness 分数过滤边界框。通常,分数低于阈值的边界框会被忽略。
非极大值抑制:非极大值抑制(NMS)可解决对同一个图像的多次检测的问题。例如,红色网格单元的 3 个边界框可以检测一个框,或者临近网格可检测相同对象。
实现
YOLO 只能检测出属于训练所用数据集中类别的对象。我们的检测器将使用官方权重文件,这些权重通过在 COCO 数据集上训练网络而获得,因此我们可以检测 80 个对象类别。
该教程的第一部分到此结束。这部分详细讲解了 YOLO 算法。如果你想深度了解 YOLO 的工作原理、训练过程和与其他检测器的性能规避,可阅读原始论文:
1. YOLO V1: You Only Look Once: Unified, Real-Time Object Detection (https://arxiv.org/pdf/1506.02640.pdf)
2. YOLO V2: YOLO9000: Better, Faster, Stronger (https://arxiv.org/pdf/1612.08242.pdf)
3. YOLO V3: An Incremental Improvement (https://pjreddie.com/media/files/papers/YOLOv3.pdf)
4. Convolutional Neural Networks (http://cs231n.github.io/convolutional-networks/)
5. Bounding Box Regression (Appendix C) (https://arxiv.org/pdf/1311.2524.pdf)
6. IoU (https://www.youtube.com/watch?v=DNEm4fJ-rto)
7. Non maximum suppresion (https://www.youtube.com/watch?v=A46HZGR5fMw)
8. PyTorch Official Tutorial (http://pytorch.org/tutorials/beginner/deep_learning_60min_blitz.html)
如果你打开配置文件,你将看到如下一些网络架构:
[convolutional]
batch_normalize=1
filters=64
size=3
stride=2
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=32
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=64
size=3
stride=1
pad=1
activation=leaky
[shortcut]
from=-3
activation=linear
我们看到上面有四块配置,其中 3 个描述了卷积层,最后描述了 ResNet 中常用的捷径层或跳过连接。下面是 YOLO 中使用的 5 种层级:
1. 卷积层
[convolutional]
batch_normalize=1
filters=64
size=3
stride=1
pad=1
activation=leaky
2. 跳过连接
[shortcut]
from=-3
activation=linear
跳过连接与残差网络中使用的结构相似,参数 from 为-3 表示捷径层的输出会通过将之前层的和之前第三个层的输出的特征图与模块的输入相加而得出。
3.上采样
[upsample]
stride=2
通过参数 stride 在前面层级中双线性上采样特征图。
4.路由层(Route)
[route]
layers = -4
[route]
layers = -1, 61
路由层需要一些解释,它的参数 layers 有一个或两个值。当只有一个值时,它输出这一层通过该值索引的特征图。在我们的实验中设置为了-4,所以层级将输出路由层之前第四个层的特征图。
当层级有两个值时,它将返回由这两个值索引的拼接特征图。在我们的实验中为-1 和 61,因此该层级将输出从前一层级(-1)到第 61 层的特征图,并将它们按深度拼接。
5.YOLO
[yolo]
mask = 0,1,2
anchors = 10,13, 16,30, 33,23, 30,61, 62,45, 59,119, 116,90, 156,198, 373,326
classes=80
num=9
jitter=.3
ignore_thresh = .5
truth_thresh = 1
random=1
YOLO 层级对应于上文所描述的检测层级。参数 anchors 定义了 9 组锚点,但是它们只是由 mask 标签使用的属性所索引的锚点。这里,mask 的值为 0、1、2 表示了第一个、第二个和第三个使用的锚点。而掩码表示检测层中的每一个单元预测三个框。总而言之,我们检测层的规模为 3,并装配总共 9 个锚点。
Net
[net]
# Testing
batch=1
subdivisions=1
# Training
# batch=64
# subdivisions=16
width= 320
height = 320
channels=3
momentum=0.9
decay=0.0005
angle=0
saturation = 1.5
exposure = 1.5
hue=.1
配置文件中存在另一种块 net,不过我不认为它是层,因为它只描述网络输入和训练参数的相关信息,并未用于 YOLO 的前向传播。但是,它为我们提供了网络输入大小等信息,可用于调整前向传播中的锚点。
解析配置文件
在开始之前,我们先在 darknet.py 文件顶部添加必要的导入项。
from __future__ import division
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.autograd import Variable
import numpy as np
我们定义一个函数 parse_cfg,该函数使用配置文件的路径作为输入。
def parse_cfg(cfgfile):
"""
Takes a configuration file
Returns a list of blocks. Each blocks describes a block in the neural
network to be built. Block is represented as a dictionary in the list
"""
这里的思路是解析 cfg,将每个块存储为词典。这些块的属性和值都以键值对的形式存储在词典中。解析过程中,我们将这些词典(由代码中的变量 block 表示)添加到列表 blocks 中。我们的函数将返回该 block。
我们首先将配置文件内容保存在字符串列表中。下面的代码对该列表执行预处理:
file = open(cfgfile, 'r')
lines = file.read().split('\n') # store the lines in a list
lines = [x for x in lines if len(x) > 0] # get read of the empty lines
lines = [x for x in lines if x[0] != '#'] # get rid of comments
lines = [x.rstrip().lstrip() for x in lines] # get rid of fringe whitespaces
然后,我们遍历预处理后的列表,得到块。
block = {}
blocks = []
for line in lines:
if line[0] == "[": # This marks the start of a new block
if len(block) != 0: # If block is not empty, implies it is storing values of previous block.
blocks.append(block) # add it the blocks list
block = {} # re-init the block
block["type"] = line[1:-1].rstrip()
else:
key,value = line.split("=")
block[key.rstrip()] = value.lstrip()
blocks.append(block)
return blocks
创建构建块
现在我们将使用上面 parse_cfg 返回的列表来构建 PyTorch 模块,作为配置文件中的构建块。
列表中有 5 种类型的层。PyTorch 为 convolutional 和 upsample 提供预置层。我们将通过扩展 nn.Module 类为其余层写自己的模块。
create_modules 函数使用 parse_cfg 函数返回的 blocks 列表:
def create_modules(blocks):
net_info = blocks[0] #Captures the information about the input and pre-processing
module_list = nn.ModuleList()
prev_filters = 3
output_filters = []
在迭代该列表之前,我们先定义变量 net_info,来存储该网络的信息。
nn.ModuleList
我们的函数将会返回一个 nn.ModuleList。这个类几乎等同于一个包含 nn.Module 对象的普通列表。然而,当添加 nn.ModuleList 作为 nn.Module 对象的一个成员时(即当我们添加模块到我们的网络时),所有 nn.ModuleList 内部的 nn.Module 对象(模块)的 parameter 也被添加作为 nn.Module 对象(即我们的网络,添加 nn.ModuleList 作为其成员)的 parameter。
当我们定义一个新的卷积层时,我们必须定义它的卷积核维度。虽然卷积核的高度和宽度由 cfg 文件提供,但卷积核的深度是由上一层的卷积核数量(或特征图深度)决定的。这意味着我们需要持续追踪被应用卷积层的卷积核数量。我们使用变量 prev_filter 来做这件事。我们将其初始化为 3,因为图像有对应 RGB 通道的 3 个通道。
路由层(route layer)从前面层得到特征图(可能是拼接的)。如果在路由层之后有一个卷积层,那么卷积核将被应用到前面层的特征图上,精确来说是路由层得到的特征图。因此,我们不仅需要追踪前一层的卷积核数量,还需要追踪之前每个层。随着不断地迭代,我们将每个模块的输出卷积核数量添加到 output_filters 列表上。
现在,我们的思路是迭代模块的列表,并为每个模块创建一个 PyTorch 模块。
for index, x in enumerate(blocks[1:]):
module = nn.Sequential()
#check the type of block
#create a new module for the block
#append to module_list
nn.Sequential 类被用于按顺序地执行 nn.Module 对象的一个数字。如果你查看 cfg 文件,你会发现,一个模块可能包含多于一个层。例如,一个 convolutional 类型的模块有一个批量归一化层、一个 leaky ReLU 激活层以及一个卷积层。我们使用 nn.Sequential 将这些层串联起来,得到 add_module 函数。例如,以下展示了我们如何创建卷积层和上采样层的例子:
if (x["type"] == "convolutional"):
#Get the info about the layer
activation = x["activation"]
try:
batch_normalize = int(x["batch_normalize"])
bias = False
except:
batch_normalize = 0
bias = True
filters= int(x["filters"])
padding = int(x["pad"])
kernel_size = int(x["size"])
stride = int(x["stride"])
if padding:
pad = (kernel_size - 1) // 2
else:
pad = 0
#Add the convolutional layer
conv = nn.Conv2d(prev_filters, filters, kernel_size, stride, pad, bias = bias)
module.add_module("conv_{0}".format(index), conv)
#Add the Batch Norm Layer
if batch_normalize:
bn = nn.BatchNorm2d(filters)
module.add_module("batch_norm_{0}".format(index), bn)
#Check the activation.
#It is either Linear or a Leaky ReLU for YOLO
if activation == "leaky":
activn = nn.LeakyReLU(0.1, inplace = True)
module.add_module("leaky_{0}".format(index), activn)
#If it's an upsampling layer
#We use Bilinear2dUpsampling
elif (x["type"] == "upsample"):
stride = int(x["stride"])
upsample = nn.Upsample(scale_factor = 2, mode = "bilinear")
module.add_module("upsample_{}".format(index), upsample)
路由层/捷径层
接下来,我们来写创建路由层(Route Layer)和捷径层(Shortcut Layer)的代码:
#If it is a route layer
elif (x["type"] == "route"):
x["layers"] = x["layers"].split(',')
#Start of a route
start = int(x["layers"][0])
#end, if there exists one.
try:
end = int(x["layers"][1])
except:
end = 0
#Positive anotation
if start > 0:
start = start - index
if end > 0:
end = end - index
route = EmptyLayer()
module.add_module("route_{0}".format(index), route)
if end < 0:
filters = output_filters[index + start] + output_filters[index + end]
else:
filters= output_filters[index + start]
#shortcut corresponds to skip connection
elif x["type"] == "shortcut":
shortcut = EmptyLayer()
module.add_module("shortcut_{}".format(index), shortcut)
创建路由层的代码需要做一些解释。首先,我们提取关于层属性的值,将其表示为一个整数,并保存在一个列表中。
然后我们得到一个新的称为 EmptyLayer 的层,顾名思义,就是空的层。
route = EmptyLayer()
其定义如下:
class EmptyLayer(nn.Module):
def __init__(self):
super(EmptyLayer, self).__init__()
等等,一个空的层?
现在,一个空的层可能会令人困惑,因为它没有做任何事情。而 Route Layer 正如其它层将执行某种操作(获取之前层的拼接)。在 PyTorch 中,当我们定义了一个新的层,我们在子类 nn.Module 中写入层在 nn.Module 对象的 forward 函数的运算。
对于在 Route 模块中设计一个层,我们必须建立一个 nn.Module 对象,其作为 layers 的成员被初始化。然后,我们可以写下代码,将 forward 函数中的特征图拼接起来并向前馈送。最后,我们执行网络的某个 forward 函数的这个层。
但拼接操作的代码相当地短和简单(在特征图上调用 torch.cat),像上述过程那样设计一个层将导致不必要的抽象,增加样板代码。取而代之,我们可以将一个假的层置于之前提出的路由层的位置上,然后直接在代表 darknet 的 nn.Module 对象的 forward 函数中执行拼接运算。(如果感到困惑,我建议你读一下 nn.Module 类在 PyTorch 中的使用)。
在路由层之后的卷积层会把它的卷积核应用到之前层的特征图(可能是拼接的)上。以下的代码更新了 filters 变量以保存路由层输出的卷积核数量。
if end < 0:
#If we are concatenating maps
filters = output_filters[index + start] + output_filters[index + end]
else:
filters= output_filters[index + start]
捷径层也使用空的层,因为它还要执行一个非常简单的操作(加)。没必要更新 filters 变量,因为它只是将前一层的特征图添加到后面的层上而已。
YOLO 层
最后,我们将编写创建 YOLO 层的代码:
#Yolo is the detection layer
elif x["type"] == "yolo":
mask = x["mask"].split(",")
mask = [int(x) for x in mask]
anchors = x["anchors"].split(",")
anchors = [int(a) for a in anchors]
anchors = [(anchors[i], anchors[i+1]) for i in range(0, len(anchors),2)]
anchors = [anchors[i] for i in mask]
detection = DetectionLayer(anchors)
module.add_module("Detection_{}".format(index), detection)
我们定义一个新的层 DetectionLayer 保存用于检测边界框的锚点。
检测层的定义如下:
class DetectionLayer(nn.Module):
def __init__(self, anchors):
super(DetectionLayer, self).__init__()
self.anchors = anchors
在这个回路结束时,我们做了一些统计(bookkeeping.)。
module_list.append(module)
prev_filters = filters
output_filters.append(filters)
这总结了此回路的主体。在 create_modules 函数后,我们获得了包含 net_info 和 module_list 的元组。
return (net_info, module_list)
测试代码
你可以在 darknet.py 后通过输入以下命令行测试代码,运行文件。
blocks = parse_cfg("cfg/yolov3.cfg")
print(create_modules(blocks))
你会看到一个长列表(确切来说包含 106 条),其中元素看起来如下所示:
(9): Sequential(
(conv_9): Conv2d (128, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
(batch_norm_9): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True)
(leaky_9): LeakyReLU(0.1, inplace)
)
(10): Sequential(
(conv_10): Conv2d (64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(batch_norm_10): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
(leaky_10): LeakyReLU(0.1, inplace)
)
(11): Sequential(
(shortcut_11): EmptyLayer(
)
)