在这篇博客里,我们以yolo-fastest-xl为例,构建yolov3的模型。由于yolo-fastest-xl速度更快,参数数量更少,因此我们选择yolo-fastest-xl而不是yolov3-tiny。之后的关于yolov3的部署,也是使用本博客所构建的网络模型。
参考github
https://github.com/dog-qiuqiu/Yolo-Fastest
附上代码
https://github.com/qqsuhao/YOLOv3-YOLOv3-tiny-yolo-fastest-xl–pytorch
网络上较多的开源代码都使用.cfg格式的文件描述网络结构,所以我们从.cfg文件开始讲解yolov3的网络的搭建。
[net] # 方括号表示参数类别。例如[net]表示下边这些参数都是网络的超参数,是在网络初始化时需要用到的
batch=128 # 有些参数值并不会被代码使用,而是在程序中自己另行设置
subdivisions=1
width=320
height=320
channels=3
momentum=0.949
decay=0.0005
angle=0
saturation=1.5
exposure=1.5
hue=.1
learning_rate=0.001
burn_in=4000
max_batches=500000
policy=steps
steps=400000,450000
scales=.1,.1
[convolutional] # [convolutional] 表示下面这些参数都是卷积层的参数
filters=16 # 输出通道数
size=3 # 卷积核大小
pad=1 # 填充
stride=2 # 步长
batch_normalize=1 # 是否在卷积层之后进行Batchnorm归一化
activation=leaky # 激活层:使用leakyRelu
[dropout] # droupout层。防止过拟合
probability=.2 # 随机丢失的概率
[shortcut] # shortcut层是残差模块中的残差连接层
from=-5 # from=-5是指从当前解析顺序出发,倒数第5个层用于残差连接
activation=linear # 激活层
[upsample] # 上采样层
stride = 2 # 上采样的倍数
[route] # 融合层
layers=-1,81 # yolov3中需要将两个不同尺度的特征图进行融合,
# layers指定了网络的序号,即需要对哪几个网络层的输出进行融合
[yolo] # yolo层
mask = 0,1,2 # 指定这一个yolo层所使用的anchors的序号,即使用12, 18, 37, 49, 52,132,
anchors = 12, 18, 37, 49, 52,132, 115, 73, 119,199, 242,238 # anchors
classes=80 # 类别数量
num=6 # 总的anchors的数量
jitter=.15 # 利用数据抖动产生更多数据,不过我们并没有用这个参数,数据增强是在加载数据时完成的
ignore_thresh = .5 # 判断有无目标的门限
truth_thresh = 1 # **这个我也不太懂,程序中没有用到**
random=0 # 如果为1,每次迭代图片大小随机从320到608,步长为32,如果为0,每次训练大小与输入大小一致
# random我们一样的没有用,这一步操作在是在加载数据时完成的
上述代是yolo-fastest-xl.cfg的一部分。
代码参考https://github.com/eriklindernoren/PyTorch-YOLOv3,我加了一些注释
def parse_model_config(path):
"""解析模型配置文件"""
file = open(path, 'r')
lines = file.read().split('\n') # 一次性读取所有的行
lines = [x for x in lines if x and not x.startswith('#')] # 删除注释行
lines = [x.rstrip().lstrip() for x in lines] # rstrip() 删除 string 字符串末尾的指定字符(默认为空格).
module_defs = []
for line in lines:
if line.startswith('['): # "["表示一个新的网络块的开始
module_defs.append({}) # 向列表中加入一个孔得字典
module_defs[-1]['type'] = line[1:-1].rstrip() # 字典创建键“type”,值为模块名称
if module_defs[-1]['type'] == 'convolutional':
module_defs[-1]['batch_normalize'] = 0 # 默认 'batch_normalize' = 0
else:
key, value = line.split("=")
value = value.strip() # 删除不必要的空格
module_defs[-1][key.rstrip()] = value.strip() # 将其他参数值加入字典
return module_defs
这个函数实现了解析.cfg文件,并返回列表module_defs。module_defs中的元素是字典,一个字典存放一个.cfg文件中的网络参数,比如第n个网络是卷积层,想要访问卷积层的卷积核大小,就使用module_defs[n][“size”]。
代码参考https://github.com/eriklindernoren/PyTorch-YOLOv3,我加了一些注释。在源代码基础上,我添加了分组卷积的参数,droupout层,略微修改了上采样层的代码。
def creat_modules(module_defs):
"""
Constructs module list of layer blocks from module configuration in module_defs
根据cfg配置文件创建网络
"""
hyperparams = module_defs.pop(0) # 超参数
output_filters = [int(hyperparams["channels"])] # 输入层的输出通道数
module_list = nn.ModuleList() # 存放模块的列表
for module_i, module_def in enumerate(module_defs):
modules = nn.Sequential()
if module_def["type"] == "convolutional": # 根据配置文件创建卷积块,包含BN层+卷积层+激活函数层
bn = int(module_def["batch_normalize"]) # 表示是否要进行BN
filters = int(module_def["filters"]) # 输出通道数
kernel_size = int(module_def["size"]) # 卷积核大小
pad = (kernel_size - 1) // 2 # 填充
groups = int(module_def["groups"]) if "groups" in module_def.keys() else 1 # 分组卷积
modules.add_module(
f"conv_{module_i}",
nn.Conv2d(
in_channels=output_filters[-1], # 输入通道数:上一个网络模块的输出
out_channels=filters,
kernel_size=kernel_size,
stride=int(module_def["stride"]),
padding=pad,
groups=groups,
bias=not bn, # 如果要进行BN就没有偏执,如果不进行BN,就没有偏置
# 因为如果要进行BN,偏置会在BN的计算过程中抵消掉,不起作用,因此还不如直接取消偏置,减少参数量
),
)
if bn:
modules.add_module(f"batch_norm_{module_i}", nn.BatchNorm2d(filters, momentum=0.9, eps=1e-5)) # 添加BN
if module_def["activation"] == "leaky":
modules.add_module(f"leaky_{module_i}", nn.LeakyReLU(0.1)) # 添加LeakyReLU
elif module_def["type"] == "maxpool": # 最大池化层
kernel_size = int(module_def["size"])
stride = int(module_def["stride"]) # 步长
if kernel_size == 2 and stride == 1:
modules.add_module(f"_debug_padding_{module_i}", nn.ZeroPad2d((0, 1, 0, 1))) # 0填充
# nn.ZeroPad2d沿着四个方向进行补零操作
maxpool = nn.MaxPool2d(kernel_size=kernel_size, stride=stride, padding=int((kernel_size - 1) // 2)) # 最大池化
modules.add_module(f"maxpool_{module_i}", maxpool)
elif module_def["type"] == "upsample": # 上采样
upsample = nn.Upsample(scale_factor=int(module_def["stride"]), mode='nearest')
modules.add_module(f"upsample_{module_i}", upsample)
elif module_def["type"] == "route": # 融合层
layers = [int(x) for x in module_def["layers"].split(",")]
filters = sum([output_filters[1:][i] for i in layers])
modules.add_module(f"route_{module_i}", EmptyLayer()) # 作者创建了一个空层,相关操作在后续
elif module_def["type"] == "shortcut": # 残差网络中的相加
filters = output_filters[1:][int(module_def["from"])]
modules.add_module(f"shortcut_{module_i}", EmptyLayer()) # 作者创建了一个空层,相关操作在后续
## 我自己加的dropout
elif module_def["type"] == "dropout":
drop = nn.Dropout(p=float(module_def["probability"]))
modules.add_module(f"dropout_{module_i}", drop)
elif module_def["type"] == "yolo":
anchor_idxs = [int(x) for x in module_def["mask"].split(",")] # Anchor的序号,yolov3中每个特征图有3个Anchor
# Extract anchors
anchors = [int(x) for x in module_def["anchors"].split(",")]
anchors = [(anchors[i], anchors[i + 1]) for i in range(0, len(anchors), 2)]
anchors = [anchors[i] for i in anchor_idxs] # 提取3个Anchor
num_classes = int(module_def["classes"])
img_size = int(hyperparams["width"])
# Define detection layer
yolo_layer = YOLOLayer(anchors, num_classes, img_size)
modules.add_module(f"yolo_{module_i}", yolo_layer)
module_list.append(modules) # 向列表中添加模块
output_filters.append(filters)
return hyperparams, module_list
这个函数通过module_defs,依次访问列表中的字典,构建网络模型,并保存在module_list中。module_list是一个torch.nn.ModuleList()对象,主要用于存放每个网络块,每个网络块使用torch.nn.Sequence()声明。需要注意的是每个网络块在module_list中的顺序是固定,这才能使得,构建shortcut层和route层时不会出现错误。另外,在这个函数中,shortcut层和route层只是创建了一个空层,并计算了输出通道数,并没有进行其他操作。这是因为这两个层都是需要对当前特征图和前面的特征图进行同时操作,但是我们又没有在这个函数中进行前向传播,所以只能等到真正进行前向传播时再进行shortcut层和route的具体操作。
注意到,我们创建了两个特殊的网络层,一个是空层,上面已经讲到了。另一个是yolo层。这两个网络层的代码如下:
class EmptyLayer(nn.Module):
"""Placeholder for 'route' and 'shortcut' layers"""
def __init__(self):
super(EmptyLayer, self).__init__()
class YOLOLayer(nn.Module):
def __init__(self, anchors, num_classes, img_dim):
super(YOLOLayer, self).__init__()
self.num_classes = num_classes
self.num_anchors = len(anchors)
self.anchors = anchors
self.img_dim = img_dim
def forward(self, inputs):
self.grid_size = inputs.size(2)
self.num_samples = inputs.size(0)
inputs_view = inputs.view(self.num_samples, self.num_anchors, self.num_classes + 5,
self.grid_size, self.grid_size).permute(0, 1, 3, 4, 2).contiguous()
FloatTensor = torch.cuda.FloatTensor if inputs.is_cuda else torch.FloatTensor
LongTensor = torch.cuda.LongTensor if inputs.is_cuda else torch.LongTensor
# Get outputs
# x,y,w,h都是预测的偏置值
x = torch.sigmoid(inputs_view[..., 0]) # Center x
y = torch.sigmoid(inputs_view[..., 1]) # Center y
w = inputs_view[..., 2] # Width
h = inputs_view[..., 3] # Height
pred_conf = torch.sigmoid(inputs_view[..., 4]) # Conf 置信度
pred_cls = torch.sigmoid(inputs_view[..., 5:]) # Cls pred. 每个类别对应的概率
# 重新计算偏置
self.stride = torch.floor_divide(self.img_dim, self.grid_size) # 计算图像到特征图的缩放倍数 416/13=32
# Calculate offsets for each grid
g = self.grid_size
# self.grid_x = torch.arange(g).repeat(g, 1).view([1, 1, g, g]).type(FloatTensor)
# self.grid_y = torch.arange(g).repeat(g, 1).t().view([1, 1, g, g]).type(FloatTensor)
'''
使用上述方法求grid_x会导致dnn加载onnx出错,即使转onnx的时候并不会出错
dnn内部似乎不支持arange和repeat这两种操作,因此我们使用列表推导的方式替代arange和repeat
'''
self.grid_x = FloatTensor([i for j in range(self.grid_size) for i in range(self.grid_size)])\
.view([1, 1, self.grid_size, self.grid_size])
# 有点像meshgrid的意思,先构造一个二维矩阵,每行都是从0到13的13个整数,然后view成一个四个维度的矩阵
# 对应了yolo输出的第5个维度的第一层,即prediction[..., 0]
self.grid_y = FloatTensor([j for j in range(self.grid_size) for i in range(self.grid_size)])\
.view([1, 1, self.grid_size, self.grid_size])
self.anchor_w = [self.anchors[i][0]/self.stride for i in range(self.num_anchors)]
self.anchor_h = [self.anchors[i][1]/self.stride for i in range(self.num_anchors)] # 列表self.anchor_h里的元素是tensor
# 在偏置值的基础上得到预测的边框的位置
# 源代码写作 x.data + self.grid_x, 转为onnx并使用dnn加载会出错
X = FloatTensor() # x 和 self.grid_x维度并不完全相同,为了转onnx成功,需要写成这样
for i in range(self.num_anchors):
X = torch.cat( (X, torch.add(x[:, i:i+1, :, :], self.grid_x)), 1)
Y = FloatTensor()
for i in range(self.num_anchors):
Y = torch.cat( (Y, torch.add(y[:, i:i+1, :, :], self.grid_y)), 1)
W = FloatTensor()
expw = torch.exp(w.data)
for i in range(self.num_anchors):
W = torch.cat( (W, torch.mul(expw[:, i:i+1, :, :], self.anchor_w[i])), 1)
H = FloatTensor()
exph = torch.exp(h.data)
for i in range(self.num_anchors):
H = torch.cat( (H, torch.mul(exph[:, i:i+1, :, :], self.anchor_h[i])), 1)
'''
这里源代码写作 W = torch.exp(w.data) * self.anchor_w
但是源代码中的torch.exp(w.data), self.anchor_w的唯独不相等,默认使用了广播乘法
但是这样转出来的onnx不被dnn支持
所以我们干脆将self.anchor_w作为一个普通list求解,然后通过torch.mul()求解
此外对切片进行赋值也会导致转onnx出错,因此我们使用cat完成这一过程。
'''
outputs = torch.cat(
(
torch.mul(X, self.stride).view(self.num_samples, 1, -1, 1),
torch.mul(Y, self.stride).view(self.num_samples, 1, -1, 1),
torch.mul(W, self.stride).view(self.num_samples, 1, -1, 1),
torch.mul(H, self.stride).view(self.num_samples, 1, -1, 1),
pred_conf.view(self.num_samples, 1, -1, 1),
pred_cls.view(self.num_samples, 1, -1, self.num_classes),
),
-1,
) # 沿着倒数第一个维度将上述三个矩阵进行拼接
# inputs 维度为n*(3*85)*g*g, outputs是预测结果维度是n*(3*g*g)*85
return outputs
源代码在yolo层中完成了yolo的前向传播以及对loss的求解,然而过多的操作不利于我们的模型导出为onnx。后面我们会讲到模型转为onnx以及被opencv DNN模块成功对构建模型中涉及的代码要求极为苛刻。因此我们把源代码yolo层中求解loss的部分移出,并另外构建求解loss的类。
class YOLOv3(nn.Module):
'''
YOLOv3 模型
'''
def __init__(self, config_path):
super(YOLOv3, self).__init__()
self.module_defs = parse_model_config(config_path) # 解析网络配置文件
# self.hyperparams是一个字典
# self.module_list是存放网络结构的列表,其中的元素都是每个网络层或者网络结构对象或者nn.Sequence()
self.hyperparams, self.module_list = creat_modules(self.module_defs)
self.yolo_layers = [layer[0] for layer in self.module_list if isinstance(layer[0], YOLOLayer)]
# 单独拿出Yolo层,yolo-tiny有两个yolo层
self.img_size = int(self.hyperparams["width"])
self.seen = 0
self.header_info = np.array([0, 0, 0, self.seen, 0], dtype=np.int32)
def forward(self, x):
layer_outputs, yolo_outputs = [], []
FloatTensor = torch.cuda.FloatTensor if x.is_cuda else torch.FloatTensor
yolo_outputs_2 = FloatTensor() # 转换后的输出,预测的位置
for i, (module_def, module) in enumerate(zip(self.module_defs, self.module_list)):
# zip() 函数用于将可迭代的对象作为参数,将对象中对应的元素打包成一个个元组,然后返回由这些元组组成的列表。
if module_def["type"] in ["convolutional", "upsample", "maxpool", "dropout"]:
x = module(x)
elif module_def["type"] == "route": # 融合层,特征图拼接
x = torch.cat([layer_outputs[int(layer_i)] for layer_i in module_def["layers"].split(",")], 1)
elif module_def["type"] == "shortcut":
layer_i = int(module_def["from"]) # 残差模块
x = layer_outputs[-1] + layer_outputs[layer_i]
elif module_def["type"] == "yolo":
out = module(x)
yolo_outputs.append(out)
layer_outputs.append(x)
# 每次经过一个模块,其输出保存在layer_output中,方便随时访问中间层的输出,便于route和shotcut操作
# layer_outputs并不占用额外的内存,因为append只是浅拷贝
# yolo_outputs.append(yolo_outputs_2)
# 如果有三个yolo层,yolo_outputs则有4个元素,前三个是yolo层不经过转换的输出,维度分别为n*255*13*13
# n*255*26*26和n*255*52*52, 第四个元素是经过位置转换的yolo层输出的拼接,维度n*(3*13*13+3*26*26+3*52*52)*85
# 如果输入图像大小是416*416,则最多预测3*13*13+3*26*26+3*52*52个目标
# yolo_outputs = torch.cat(yolo_outputs, 1)
return yolo_outputs
前面的代码只是构建了模型,并保存在module_list中。从这里开始,我们要依次读取module_list中的网络块并进行前向传播。代码中使用layer_outputs保存每个网络层输出的特征图,以便于shortcut层和route层读取不同尺度的特征图并进行融合。特别注意shortcut层和route层的前向传播方法。对于route层的前向传播,输入是不同尺度的特征图,这个时候就用到了.cfg中的layers参数,这就必须要保证module_list和layer_outputs中的网络块的顺序是一致的。对于shortcut层的前向传播,由于它是一个残差连接,所以是先前的特征图加上当前的特征图,所以是layer_outputs[-1] + layer_outputs[layer_i]
。
最后每个yolo层都一个输出结果,所以我们把所有yolo层的输出结果保存进yolo_output列表中。
代码中我加了大量的注释,以方便读者了解每个语句的作用。
目录