yolo系列是目标识别的重头戏,为了更好的理解掌握它,我们必须从源码出发深刻理解代码。下面我们来讲解pytorch实现的yolov3源码。在讲解之前,大家应该具备相应的原理知识yolov1,yolov2,yolov3。
大部分同学在看论文时并不能把所有的知识全部掌握。我们必须结合代码(代码将理论变成实践),它是百分百还原理论的,也只有在掌握代码以及理论后,我们才能推陈出新有所收获,所以大家平时一定多接触代码,这里我们会结合yolov3的理论知识让大家真正在代码中理解思想。
yolov3的inference部分我们主要分为四个部分去分析:
PyTorch实现yolov3代码详细解密(一)
PyTorch实现yolov3代码详细解密(二)
PyTorch实现yolov3代码详细解密(三)
PyTorch实现yolov3代码详细解密(四)
yolov3的train部分我们主要分为两个部分去分析:
Pytorch实现yolov3(train)训练代码详解(一)
Pytorch实现yolov3(train)训练代码详解(二)
首先我们知道yolov3将resnet改造变成了具有更好性能的Darknet作为它的backbone,称为darknet。
配置文件
官方代码(authored in C)使用一个配置文件来构建网络,即 cfg 文件一块块地描述了网络架构。我们开始要做的就是用pytorch来读取网络结构形成自己的module进行前向与反向传播。
我们看到图中有两个卷积层与一个用于残差相加的跳转连接,下面我们逐一讲解darknet中所有的层级。
1.卷积层
这个很简单,就是普通的卷积操作,filters其实就是输出的通道数,激活函数用的是leaky,这里batch_normalize=1 相当于一个flag并不是bn的参数。
详细解释一下pytorch中的bn,如图BN这个类需要一些参数,num_features是feature map的通道数,因为bn最终优化的参数是γ和β,这两个参数是相对于每个维度来讲的,即一个维度对应一组参数。其他的参数不是很重要,想了解的话可以去官网查看。
2.跳转连接
它于残差网络中使用的结构类似,参数 from 为-3 表明该层的输出为前一层的输出加上前三层的输出(要求维度相同,两组输出直接相加)。
3.上采样
通过参数 stride 在前面层级中双线性上采样特征图。
4.路由层(Route)
它的参数 layers 有一个或两个值。当只有一个值时,它输出这一层通过该值索引的特征图。在我们的实验中设置为了-4,所以层级将输出路由层之前第四个层的特征图。
当层级有两个值时,它将返回由这两个值索引的拼接特征图。在我们的实验中为-1 和 61,因此该层级将输出从前一层级(-1)到第 61 层的特征图,并将它们按深度拼接。
5.YOLO
这里的9个anchor是论文中提出用k-means求出来的,yolov3有三条预测之路(多尺度由此而来)mask=0,1,2代表第一条支路,分别对应其anchors。
还有一个net,其描述的不是层的信息,而是训练相关的参数。
解析配置文件
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
"""
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] != '#']
lines = [x.rstrip().lstrip() for x in lines]
block = {}
blocks = []
for line in lines:
if line[0] == "[": # This marks the start of a new block
if len(block) != 0:
blocks.append(block)
block = {}
block["type"] = line[1:-1].rstrip()
else:
key, value = line.split("=")
block[key.rstrip()] = value.lstrip()
blocks.append(block)
return blocks
我们定义一个函数 parse_cfg,该函数使用配置文件的路径作为输入。这个函数是将cfg中的网络结构读取出来并将属性和键值存储到词典block中,最后将这些block添加到列表blocks中由函数返还。lines以list形式将cfg按照‘/n’分隔开并存储,然后删除空行,注释,以及每行的空格。
for line in lines:遍历list中每个元素,按照要求将key和value组成block字典存入blocks中。
构建块
现在我们将使用上面 parse_cfg 返回的列表来构建 PyTorch 模块。列表中有 5 种类型的层。PyTorch 为 convolutional 和 upsample 提供预置层。我们将通过扩展 nn.Module 类为其余层写自己的模块。
def create_modules(blocks):
net_info = blocks[0] # Captures the information about the input and pre-processing
module_list = nn.ModuleList()
index = 0 # indexing blocks helps with implementing route layers (skip connections)
prev_filters = 3
output_filters = []
我们知道blocks[0]是net中的信息,我们将它于层分离, prev_filters = 3因为RGB图输入通道数为3,output_filters = []我们初始化输出通道数。
nn.ModuleList
nn.Sequential它是Module的子类,在构建数个网络层之后会自动调用forward()方法,从而有网络模型生成。而nn.ModuleList仅仅类似于pytho中的list类型,只是将一系列层装入列表,并没有实现forward()方法,因此也不会有网络模型产生的副作用。
路由层(route layer)从前面层得到特征图(可能是拼接的)。如果在路由层之后有一个卷积层,那么卷积核将被应用到前面层的特征图上,精确来说是路由层得到的特征图。因此,我们不仅需要追踪前一层的卷积核数量,还需要追踪之前每个层。随着不断地迭代,我们将每个模块的输出卷积核数量添加到 output_filters 列表上。
for x in blocks:
module = nn.Sequential()
现在,我们的思路是迭代模块的列表,并为每个模块创建一个 PyTorch 模块。nn.Sequential 类被用于按顺序地执行 nn.Module 对象,一个 convolutional 类型的模块有一个批量归一化层、一个 leaky ReLU 激活层以及一个卷积层。我们使用 nn.Sequential 将这些层串联起来,得到 add_module 函数。
if (x["type"] == "net"):
continue
# If it's a convolutional layer
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 = Upsample(stride)
upsample = nn.Upsample(scale_factor=2, mode="nearest")
module.add_module("upsample_{}".format(index), upsample)
接下来,我们看看路由层于跳转连接层
#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":
from_ = int(x["from"])
shortcut = EmptyLayer()
module.add_module("shortcut_{}".format(index), shortcut)
因为路由层layers会出现两个数,即两层concat,所以开始用‘,’将数据隔开,一个用start表示,一个用end表示,并将输出的通道数记录以便后面使用。
这里出现了EmptyLayer()
class EmptyLayer(nn.Module):
def __init__(self):
super(EmptyLayer, self).__init__()
顾名思义,就是空的层。因为Route layer,shortcut layer执行的操作很简单一个是相加,一个是拼接(在特征图上调用 torch.cat)像上述过程那样设计一个层将导致不必要的抽象,增加样板代码。取而代之,我们可以将一个假的层置于之前提出的路由层的位置上,然后直接在代表 darknet 的 nn.Module 对象的 forward 函数中执行拼接运算。
YOLO 层
最后,我们将编写创建 YOLO 层的代码:
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)
x[“mask”]这个键值里面存放的是mask的值,我们用DetectionLayer保存用于检测边界框的锚点。
最后,我们看到由106个sequential组成了ModuleList这个列表。这一部分我们仅仅完成了ModuleList列表的构建,即我们把darknet的框架搭建完成了,但是还缺少网络的前向传播,下面一章我们来完成前向传播。