1 前言
1.1实训背景
随着生产生活对电力能源的需求量不断增加,我国对电力生产产生了更高的需求,受多方面因素的影响,我国电力生产事故的发生频率处于较高水平,对城市的安全生产造成了威胁,因此,必须要将全方位的安全生产理念引入到电力生产作业中,为电力系统的正常运行提供保障,电力生产作业过程中工序复杂,日常巡检和维修过程中会接触高压电力设备,如操作不当或未佩戴防护装备,容易出现安全事故,为确保电力工作人员在采用合规防护措施和操作流程,采用机器学习相关算法对施工现场进行合规检测,诣在提高工作人员安全防护意识,提升现场安全作业水平。
赛题描述及数据说明
根据广东电网公司规定,作业人员每天需要全身穿着工作服,保持员工精神面貌整齐统一,也为户外操作的作业人员提供了一份安全屏障。而监护人员,则需要额外在工作服上佩戴一个红色的袖章。 在该场景提供的训练数据集中,包含的具体标签及解释见下:
- badge:监护袖章(只识别红色修章) person:图中出现的所有在场人员 clothes:合规工作服
wrongclothes(包含“wrongbottom” 、“wrongtop” 、“wrongsuit”标签):不合规工作服(含有上衣开襟、挽裤腿、挽袖、不成套等现象) 通过上述信息,要求选手能够在电网现场作业过程中,使用算法模型,最终以“人”为单位自动检测出以下内容:识别出所有在场人员,并具体区分出监护人员(佩戴红色袖章); - 识别出合规穿戴工作服的作业人员;
- 识别出不合规穿戴工作服的作业人员。其中工作服上装下装不配套,工作服穿着有开襟、挽裤腿、挽袖子等情况,均属于不规范穿戴工作服的场景;
提交说明 选手需要在测评集合上进行预测,提交评估结果。要求选手以单个json文件(通过Matlab的gason保存,或Python的json.dump保存)提交。具体文件要求如下: [{ “image_id”: int, “category_id”: int, “bbox”: [xmin,ymin,xmax,ymax], “score”: float }]
- image_id:为A榜测试数据中image在csv当中的序列号,从0开始,为0、1、2、3……的int类型数字,其最大值小于599,超出则会报错;
- 因为存在同一个框多个识别结果的情况,提交结果的条数不设限,可以超过600条。
- category_id:为需要检测的结果类别,同样为int,具体数字见下方描述,category_id每次只提交1个类别,若一个框满足多个识别结果,需要分block来写。
提交说明 选手需要提交的测试结果为以下几类,且图像的category_id应与下方的注释保持相同: a) guarder(监护人员) b) rightdressed(合规穿戴工作服人员) c) wrongdressed(不合规穿戴工作服人员) 注意:本赛题中所有出现的人物(包括电网作业人员、监护人员、旁观的电网工作人员、路人等)均需进行工作服穿戴的识别。 例:有一位身着工作服的电网工作人员出现在现场,非当场作业人员也并非监护人员,仍需识别其工作服着装是否合规。如果有一位路人(没穿工作服)出现,需要判断其为wrongdressed(不合规穿戴工作服人员)。
评估标准 本次比赛的提交评测,在主办方提供的评测平台上进行。评估函数按照mAP(IoU=0.5)计算AP成绩,计算mAR(max=100)的值,最终成值为mAP和mAR两部分组成,总成绩=2mAPmAPmARmAR/(mAPmAP+mARmAR)。
比赛规则 1.选手可以参考外部的合法开源数据和预训练模型,以及“广东电网智慧现场作业挑战赛”其他赛道的训练数据来辅助优化模型,但需在提交的代码中署名开源数据与预训练模型的出处和合法使用权,其他赛道的训练数据不允许人工标注或更改标注。 2.验证集只能用于验证,禁止在训练阶段以其他方式使用验证集,例如,将验证集合并到训练集中,使用验证集过滤训练集中嘈杂的图像。 3.禁止任何形式的作弊,例如人工标注。选手若使用“广东电网智慧现场作业挑战赛”其他赛道的数据集来扩充训练,须注意的是其他赛道的训练数据也不允许进行人工标注或更改标注。
2 实训内容
2.1概述
数据准备 从比赛官网下载训练集和测试集到本地。训练集和图片和说明文件如下:
在比赛中我们只需要F列的数据,F列的数据是个json类型的文件,数据如下:
{
"meta":{
},
"id":"1b1a9984-2d57-4fb1-a40b-e0cb40ebb546",
"items":[
{
"meta":{
"geometry":[
1402,
1103,
1642,
1473
],
"type":"BBOX"
},
"id":"202cced3-e995-4152-8ec2-3a88908b5a2e",
"properties":{
"create_time":1620957672452,
"accept_meta":{
},
"mark_by":"FIRST_QC",
"is_system_map":false
},
"labels":{
"标签":"badge"
}
}
],
"properties":{
"seq":"3992"
},
"labels":{
"invalid":"false"
},
"timestamp":1620957718666
}
官方给的csv文件不能直接用,所以得先转化为xml的格式。由于每个照片的ID都保存在一个文件中,所以我们可以直接通过读取图片的id来从csv文件中抽取信息。我们需要抽取csv文件中的图片路径名、图片宽度、图片高度、文件夹的名字等。然后创建dom节点,在dom节点下创建xml格式,主要的格式是坐标框(xmin,ymin,xmax,ymax)、标签名。
csvtoxml(r'1train_rname.csv')
转化完成后,格式如下图,其中的path是我们训练数据的相对路径,filename是文件的图片id,size是图片的(RGB),bnbox是目标的坐标框,name是标签名。
- 实验用VOC格式进行训练,将上述的xml文件放在VOCdevkit文件夹下的Annotation中。
- 将图片文件放在VOCdevkit文件夹下的VOC2007文件夹下的JPEGImages中
- 在训练前利用voc2yolo4.py文件生成对应的txt,xmlfilepath是存放的xml文件,saveBasePath是需要保存的路径。
运行根目录下的voc_annotation.py,(不能使用中文标签),注意在下列的classes中的顺序一定要的model_data里的txt一样。这里我们分成了四个类,其实关于wrongclothes可以继续细分。
此时会生成对应的2007_train.txt, 每一行对应其图片位置及其真实框的位置
2.2 相关技术
在目标检测中,目前开源的优秀的框架当属于YOLO,这个实验我们使用YOLOv4框架(论文地址[2004.10934] YOLOv4: Optimal Speed and Accuracy of Object Detection (itp.ac.cn)),通过对两千多张图片进行一百多Epoch的训练,最后的到模型!再根据模型对两百多张测试集进行测试,然后得出score,根据赛制我们用评估函数按照mAP(IoU=0.5)计算AP成绩,计算mAR(max=100)的值,最终成值为mAP和mAR两部分组成,总成绩=2mAPmAPmARmAR/(mAPmAP+mARmAR)。 目标检测器通用框架,无论是two-stage还是one-stage都可以划分如下结构。
其中得Input: 是输入的数据有图像,图像金字塔等
Backbone:是神经网络中的主干网络, 用来提取图像特征,在不同图像细粒度上聚合并形成图像特征的卷积神经网络,供后面的网络使用。通常这类网络我们可以直接使用顶会论文发表的一些网络结构,因为这些网络已经证明了在这类问题上的特诊提取是很强的,在这些网络后面再使用我们自己写的一些网络。让网络的这两个部分同时训练, 因为加载的backbone模型已经具有提取特征的能力了,在我们的训练过程中,会对他进行微调。就像在本次实验中,我们把前50Epoch进行冻结训练然后再解冻训练。主要有resnet系列、inception系列、以及新兴的transformer结构。
Neck:一般放在head和backbone之间,是为了更好的利用backbone提取的特征,是一系列混合和组合图像特征的网络层, 并将图像特征传递到预测层。通常Neck中使用(NaiveNeck FPN Bi-FPN PANet NAS-FPN)。采样方法有上下采样、路径聚合、NAS搜索、加权聚合、非线性聚合、无限堆叠。目标检测器由用于特征提取的骨干部分(backbone)和用于目标检测的头部构成。而为了检测不同大小的目标,需要使用一种分层结构,使得头部可探测不同空间分辨率的特征图。为了让输入头部的信息更丰富,在输入头部前,会将来自自底向上和自上而下的数据流按逐元素的方式相加或相连。因此,头部的输入将包含来自自底向上数据流的丰富空间信息以及来自自上而下数据流的丰富语义信息。 Dense Prediction(head):密集预测,对图像特征进行预测, 生成边界框和预测类别 Sparse Prediction(head):稀疏预测,对图像特征进行预测, 生成边界框和预测类别
YOLOv4 consists of:
- Backbone: CSPDarknet53
- Neck: SPP, PAN
- Head: YOLOv3
YOLOv4是一个深度学习算法,当输入自然环境中拍摄的图片后将其送到训练好的网络,将会得到一个预测结果显示在图片上,算法会找出图片中存在的物体并对其进行识别。当训练好网络后,检测简单流程为: 输入图像–>CSPDarknet53结构–>SPP结构–>PANet结构–>YOLOv3head结构–>解码网络输出值–>非极大抑制–>获取最终结果显示在原图中。 在YOLOv4中使用的BackBone是CSPDarknet53, 这个网络借鉴了CSPNet(Cross Stage Partial NetWorks)和YOLOv3中的Darknet53;使用的Neck是SPP(Spatial pyramid pooling)+PAN(Path Aggregation Network) 其中的CSPNet解决了大型卷积神经网络框架Backbone中网络优化的梯度信息重复问题, 将梯度的变化从头到尾地集中到特征图中,因为减少了模型地参数量和FlOPS数值,既保证了推理速度和准确率又减少了模型尺寸。 YOLOv4集成了许多新的优化方法以及模型策略,如:Mosaic, PANet, CmBN, SAT训练, CIOU loss, Mish激活函数, label smoothing、学习率余弦退火衰减等等。 Mish激活函数:提高了网络的学习能力, 提升了传递效率。Mish激活函数的公式为
class Mish(nn.Module):
def __init__(self):
super(Mish, self).__init__()
def forward(self, x):
return x * torch.tanh(F.softplus(x))
从图中我们可以看出Mish激活函数和ReLu一样都是无正向边界的,可以避免梯度饱和; 其次Mish激活函数处处光滑(目前的普遍看法是,平滑的激活函数允许更好的信息深入神经网络,从而得到更好的准确性和泛化), 并且在绝对值较小的负值区域允许一些负值。
IOU损失函数:鉴于论文中提到的MSE存在的一些问题“However , to directly estimate the coordinate values of each point of the BBox is to treat these points as independent varables , but in fact does not consider the integrity of the object itself”, 意思就是MSE损失函数将检测框中心点坐标和宽高等信息作为独立的变量对待, 但实际上它们之间室友关系的。 更直接的来说, 框的中心点和宽高的确存在一定的关系。所以可以用CIOU损失代替MSE损失。
具体的实现方法如下:
def box_ciou(b1, b2):
"""
输入为:
----------
b1: tensor, shape=(batch, feat_w, feat_h, anchor_num, 4), xywh
b2: tensor, shape=(batch, feat_w, feat_h, anchor_num, 4), xywh
返回为:
-------
ciou: tensor, shape=(batch, feat_w, feat_h, anchor_num, 1)
"""
b1_xy = b1[..., :2]
b1_wh = b1[..., 2:4]
b1_wh_half = b1_wh/2.
b1_mins = b1_xy - b1_wh_half
b1_maxes = b1_xy + b1_wh_half
b2_xy = b2[..., :2]
b2_wh = b2[..., 2:4]
b2_wh_half = b2_wh/2.
b2_mins = b2_xy - b2_wh_half
b2_maxes = b2_xy + b2_wh_half
intersect_mins = torch.max(b1_mins, b2_mins)
intersect_maxes = torch.min(b1_maxes, b2_maxes)
intersect_wh = torch.max(intersect_maxes - intersect_mins, torch.zeros_like(intersect_maxes))
intersect_area = intersect_wh[..., 0] * intersect_wh[..., 1]
b1_area = b1_wh[..., 0] * b1_wh[..., 1]
b2_area = b2_wh[..., 0] * b2_wh[..., 1]
union_area = b1_area + b2_area - intersect_area
iou = intersect_area / torch.clamp(union_area,min = 1e-6)
center_distance = torch.sum(torch.pow((b1_xy - b2_xy), 2), axis=-1)
enclose_mins = torch.min(b1_mins, b2_mins)
enclose_maxes = torch.max(b1_maxes, b2_maxes)
enclose_wh = torch.max(enclose_maxes - enclose_mins, torch.zeros_like(intersect_maxes))
enclose_diagonal = torch.sum(torch.pow(enclose_wh,2), axis=-1)
ciou = iou - 1.0 * (center_distance) / torch.clamp(enclose_diagonal,min = 1e-6)
v = (4 / (math.pi ** 2)) * torch.pow((torch.atan(b1_wh[..., 0]/torch.clamp(b1_wh[..., 1],min = 1e-6)) - torch.atan(b2_wh[..., 0]/torch.clamp(b2_wh[..., 1],min = 1e-6))), 2)
alpha = v / torch.clamp((1.0 - iou + v),min=1e-6)
ciou = ciou - alpha * v
return ciou
Mosaic数据增强:这种数据增强方式可以把四张图片,通过随机缩放、随机裁剪、随机排布的方式进行拼接。优点是丰富物体的背景和小目标, 并且再计算batch normalizaiton的时候一次会计算四张图片的数据。
SAT训练:自对抗训练(SAT)也代表了一种新的数据增强技术,它在两个前向后向阶段运行。在第一阶段,神经网络改变原始图像而不是网络权值。通过这种方式,神经网络对其自身执行对抗性攻击,改变原始图像,以制造图像上没有所需对象的欺骗。在第二阶段,训练神经网络,以正常的方式在修改后的图像上检测目标
CmBN:用来解决训练数据的分布和测试数据的分布不一样的问题。 因为归一化到均值为0方差为1的分布会使网络的表达能力变弱,因此增加了两个可以学习的参数?和Y,对数据进行缩放和平移。能够加速收敛。 消除网格敏感 边界框 b 的计算方式为: 对于 b?=c? 和 b?=c?+1 的情况,我们需要 t? 分别具有很大的负值和正值。但我们可以将 σ 与一个比例因子(>1.0)相乘,从而更轻松地实现这一目标。
Label smoothing:标签平滑, 是一种正则化方法,可以使得标签在某种程度上软化,增加了模型的泛化能力,一定程度上防止过拟合。
def smooth_labels(y_true, label_smoothing,num_classes):
return y_true * (1.0 - label_smoothing) + label_smoothing / num_classes
Cosine annealing scheduler(学习率余弦退火衰减):使得学习率按照周期变化, 在一个周期内先下降,后上升。 Dropblock:这个的主要问题就是随机drop特征, 这一点在FC层是有效的,在卷积层无效。
2.3 系统分析
首先看看YOLOv4的神经网络的整体原理图(来源网络)如下:
1. CSPDarknet53 相比于基于 ResNet 的设计,CSPDarknet53 模型的目标检测准确度更高,不过 ResNet 的分类性能更好一些。但是,借助后文将讨论的 Mish 和其它技术,CSPDarknet53 的分类准确度可以得到提升。因此,YOLOv4 最终选择了 CSPDarknet53。CSPDarknet53可以增强CNN学习能力, 能够在轻量化的同时保持准确性、降低计算瓶颈、降低内存成本。这里使用416416(608608的机器带不起来)的输入。整个CSPDarknet53的网络代码如下: YOLOv4 使用了CSP 与Darknet-53 作为特征提取的骨干。
class CSPDarkNet(nn.Module):
def __init__(self, layers):
super(CSPDarkNet, self).__init__()
self.inplanes = 32
self.conv1 = BasicConv(3, self.inplanes, kernel_size=3, stride=1)
self.feature_channels = [64, 128, 256, 512, 1024]
self.stages = nn.ModuleList([
Resblock_body(self.inplanes, self.feature_channels[0], layers[0], first=True),
Resblock_body(self.feature_channels[0], self.feature_channels[1], layers[1], first=False),
Resblock_body(self.feature_channels[1], self.feature_channels[2], layers[2], first=False),
Resblock_body(self.feature_channels[2], self.feature_channels[3], layers[3], first=False),
Resblock_body(self.feature_channels[3], self.feature_channels[4], layers[4], first=False)
])
self.num_features = 1
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 forward(self, x):
x = self.conv1(x)
x = self.stages[0](x)
x = self.stages[1](x)
out3 = self.stages[2](x)
out4 = self.stages[3](out3)
out5 = self.stages[4](out4)
return out3, out4, out5
下面分别介绍各个部分的结构
第一个卷积层如下所示,将卷积、归一化、激活函数封装成一个基本的块
class BasicConv(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, stride=1):
super(BasicConv, self).__init__()
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, kernel_size//2, bias=False)
self.bn = nn.BatchNorm2d(out_channels)
self.activation = Mish()
def forward(self, x):
x = self.conv(x)
x = self.bn(x)
x = self.activation(x)
return x
接着就是Resblock_body,如下图所示,红色的边框表示需要完成一次下采样, 图像的长和宽都会缩小到原来的一般。其中Conv5和Conv6是残差块,Layer7表示Conv6输出结果和Conv4的输出结果相加。Layer9就是右侧的大残差边和左侧残差网络的concat,输出的结果会使通道数加倍。
CSPdarknet的结构块 首先利用ZeroPadding2D和一个步长为2x2的卷积块进行高和宽的压缩,然后建立一个大的残差边shortconv、这个大残差边绕过了很多的残差结构,主干部分会对num_blocks进行循环,循环内部是残差结构。对于整个CSPdarknet的结构块,就是一个大残差块+内部多个小残差块
class Resblock_body(nn.Module):
def __init__(self, in_channels, out_channels, num_blocks, first):
super(Resblock_body, self).__init__()
self.downsample_conv = BasicConv(in_channels, out_channels, 3, stride=2)
if first:
self.split_conv0 = BasicConv(out_channels, out_channels, 1)
self.split_conv1 = BasicConv(out_channels, out_channels, 1)
self.blocks_conv = nn.Sequential(
Resblock(channels=out_channels, hidden_channels=out_channels//2),
BasicConv(out_channels, out_channels, 1)
)
self.concat_conv = BasicConv(out_channels*2, out_channels, 1)
else:
self.split_conv0 = BasicConv(out_channels, out_channels//2, 1)
self.split_conv1 = BasicConv(out_channels, out_channels//2, 1)
self.blocks_conv = nn.Sequential(
*[Resblock(out_channels//2) for _ in range(num_blocks)],
BasicConv(out_channels//2, out_channels//2, 1)
)
self.concat_conv = BasicConv(out_channels, out_channels, 1)
def forward(self, x):
x = self.downsample_conv(x)
x0 = self.split_conv0(x)
x1 = self.split_conv1(x)
x1 = self.blocks_conv(x1)
x = torch.cat([x1, x0], dim=1)
x = self.concat_conv(x)
return x
其中最主要的部分就是下图中的残差块
CSPdarknet的结构块的组成部分,内部堆叠的残差块
class Resblock(nn.Module):
def __init__(self, channels, hidden_channels=None):
super(Resblock, self).__init__()
if hidden_channels is None:
hidden_channels = channels
self.block = nn.Sequential(
BasicConv(channels, hidden_channels, 1),
BasicConv(hidden_channels, channels, 3)
)
def forward(self, x):
return x + self.block(x)
到此,CSPDarknet的网络分析完毕,最终会获得三个有效的特征层,它们的shape分别使(52,52,256)、(26,26,512)、(13, 13,1024),前两个特征层指向PANet, 后一个指向SPP。
- SPP
SPP全称为Spatial Pyramid Pooling, 即空间金字塔池化,利用不同大小的池化核(分别为 55,99,1313)进行池化, 池化后再堆叠,目的是增加网络的感受野。这三层的处理不会再改变输入图像的尺寸,每个最大池化的结果都是512个通道,最后还要融合加上输入的x,一共5124个通道,但是尺寸不变。网络结构如下图所示:
SPP结构,利用不同大小的池化核进行池化,池化后堆叠。下图展示了SPP是如何整合YOLO的。
class SpatialPyramidPooling(nn.Module):
def __init__(self, pool_sizes=[5, 9, 13]):
super(SpatialPyramidPooling, self).__init__()
self.maxpools = nn.ModuleList([nn.MaxPool2d(pool_size, 1, pool_size//2) for pool_size in pool_sizes])
def forward(self, x):
features = [maxpool(x) for maxpool in self.maxpools[::-1]]
xx =x
a = [x]+features
features = torch.cat(features + [x], dim=1)
return features
从整体架构中我们可以看出在CSPDarknet中通过三次卷积才和SPP相连,我们将三个卷积层封装名为make_three_conv
def make_three_conv(filters_list, in_filters):
m = nn.Sequential(
conv2d(in_filters, filters_list[0], 1),
conv2d(filters_list[0], filters_list[1], 3),
conv2d(filters_list[1], filters_list[0], 1),
)
return m
- PANet
PANet(Path Aggregation Network路径聚合网络):促进信息的流动,通过自底向上的路径增强,利用准确的底层定位信号增强整个特征层次, 从而缩短了低层与顶层特征之间的信息路径。其的输入是CSPDarknet53的两个特征输出(52, 52, 256)和(26, 26, 512),还有一个来自SPP结构的的(13, 13, 1024)。再输入之前还得进行一个卷积操作。SPP经过三层卷积后生成(13, 13, 512),PANet的两个特征输出经过卷积后的shape为 (52, 52, 128)和(26, 26, 256)
根据图,我们发现特征层是通过不断地卷积、上采样、下采样、堆积等来实现的。下面来详细介绍首先获取CSPdarknet的三个输出,从上往下分别是x2, x1, x0
backbone
x2, x1, x0 = self.backbone(x)
X0需要进行SPP的池化操作加上三层卷积
13,13,1024 -> 13,13,512 -> 13,13,1024 -> 13,13,512 -> 13,13,2048
P5 = self.conv1(x0)
P5 = self.SPP(P5)
13,13,2048 -> 13,13,512 -> 13,13,1024 -> 13,13,512
P5 = self.conv2(P5)
13,13,512 -> 13,13,256 -> 26,26,256
P5_upsample = self.upsample1(P5)
这个时候的P5分成两路走,一路是通过卷积加上采样然后P4concat, 一路是与P4处理完进行下采样的堆叠和5个卷积操作,最后输出给YOLO Head X1会经过一层卷积,然后和P5_upsample进行concat和5层卷积操作,最后再经过一个卷积加上采样操作。
P4 = self.conv_for_P4(x1)
P4 = torch.cat([P4,P5_upsample], axis=1)
P4 = self.make_five_conv1(P4)
P4_upsample = self.upsample2(P4)
X2也需要先进行一层卷积然后和P4_upsample进行concat加上5层卷积。最后将结果P3分成两路,一路指向Yolo Head, 临另一路进行下采样,和P4concat+5层卷积后也分成两路,一路走向yolo Head, 另一路经过下采样和P5concat加5层卷积输出到Yolo Head
# 52,52,256 -> 52,52,128
P3 = self.conv_for_P3(x2)
# 52,52,128 + 52,52,128 -> 52,52,256
P3 = torch.cat([P3,P4_upsample],axis=1)
# 52,52,256 -> 52,52,128 -> 52,52,256 -> 52,52,128 -> 52,52,256 -> 52,52,128
P3 = self.make_five_conv2(P3)
# 52,52,128 -> 26,26,256
P3_downsample = self.down_sample1(P3)
-
YOLO Head 让最后P3、P4、P5经过YOLO Head得出三个特征层 P4 = torch.cat([P3_downsample,P4],axis=1) 26,26,512 -> 26,26,256 -> 26,26,512 -> 26,26,256 -> 26,26,512 -> 26,26,256 P4 = self.make_five_conv3(P4) 26,26,256 -> 13,13,512 P4_downsample = self.down_sample2(P4) 13,13,512 + 13,13,512 -> 13,13,1024 P5 = torch.cat([P4_downsample,P5],axis=1) 13,13,1024 -> 13,13,512 -> 13,13,1024 -> 13,13,512 -> 13,13,1024 -> 13,13,512 P5 = self.make_five_conv4(P5) #---------------------------------------------------# 第三个特征层 y3=(batch_size,27,52,52) #---------------------------------------------------# out2 = self.yolo_head3(P3) #---------------------------------------------------# 第二个特征层 y2=(batch_size,27,26,26) #---------------------------------------------------# out1 = self.yolo_head2(P4) #---------------------------------------------------# 第一个特征层 y1=(batch_size,27,13,13) #---------------------------------------------------# out0 = self.yolo_head1(P5)
其中的详细代码忽略,最后的结果会得到的三个prediction。Prediction1适合小物体, prediction2适合中等大小的物体, prediction3适合大目标的物体
- 后处理
对于out0、out1、 out2, 都是四维的数据,我们需要对输出的格式进行转换,因为这个格式不适合后续的处理,所以得对他进行维度转换。拿predicton1为例,假设此时batch-size = 1, 维度是(1, 27, 52, 52)?(1, 3, 9, 52, 52) ?(1, 3, 52, 52, 9)。其中9 = 4+1+4,第一个4代表中心坐标和宽高值, 1为置信度, 4为类别。这里得到的预测框的中心坐标和宽高都不是真实值(算作偏移量)。实际值需要通过先验框加上该预测的值。 关于先验框我们可以通过一下代码得到:
import glob
import xml.etree.ElementTree as ET
import numpy as np
def cas_iou(box,cluster):
pass
def avg_iou(box,cluster):
pass
def kmeans(box,k):
pass
def load_data(path):
pass
if __name__ == '__main__':
SIZE = 416
anchors_num = 9
path = r'./VOCdevkit/VOC2007/Annotations'
data = load_data(path)
out = kmeans(data,anchors_num)
out = out[np.argsort(out[:,0])]
print('acc:{:.2f}%'.format(avg_iou(data,out) * 100))
print(out*SIZE)
data = out*SIZE
f = open("yolo_anchors.txt", 'w')
row = np.shape(data)[0]
for i in range(row):
if i == 0:
x_y = "%d,%d" % (data[i][0], data[i][1])
else:
x_y = ", %d,%d" % (data[i][0], data[i][1])
f.write(x_y)
f.close()
运行这个代码会得到先验框 10,10, 19,21, 32,47, 32,165, 48,82, 70,121, 90,76, 99,181, 181,216
- 非极大抑制
因为每个预测目标都有好几个预测框, 因此我们需要通过非极大抑制选出最好的那个框。代码如下:
def non_max_suppression(prediction, num_classes, conf_thres=0.5, nms_thres=0.4):
box_corner = prediction.new(prediction.shape)
box_corner[:, :, 0] = prediction[:, :, 0] - prediction[:, :, 2] / 2
box_corner[:, :, 1] = prediction[:, :, 1] - prediction[:, :, 3] / 2
box_corner[:, :, 2] = prediction[:, :, 0] + prediction[:, :, 2] / 2
box_corner[:, :, 3] = prediction[:, :, 1] + prediction[:, :, 3] / 2
prediction[:, :, :4] = box_corner[:, :, :4]
output = [None for _ in range(len(prediction))]
for image_i, image_pred in enumerate(prediction):
class_conf, class_pred = torch.max(image_pred[:, 5:5 + num_classes], 1, keepdim=True)
conf_mask = (image_pred[:, 4] * class_conf[:, 0] >= conf_thres).squeeze()
image_pred = image_pred[conf_mask]
class_conf = class_conf[conf_mask]
class_pred = class_pred[conf_mask]
if not image_pred.size(0):
continue
detections = torch.cat((image_pred[:, :5], class_conf.float(), class_pred.float()), 1)
unique_labels = detections[:, -1].cpu().unique()
if prediction.is_cuda:
unique_labels = unique_labels.cuda()
detections = detections.cuda()
for c in unique_labels:
detections_class = detections[detections[:, -1] == c]
keep = nms(
detections_class[:, :4],
detections_class[:, 4] * detections_class[:, 5],
nms_thres
)
max_detections = detections_class[keep]
output[image_i] = max_detections if output[image_i] is None else torch.cat((output[image_i], max_detections))
return output
该代码用于查看网络结构
import torch
from torchsummary import summary
from nets.yolo4 import YoloBody
if __name__ == "__main__":
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = YoloBody(3,20).to(device)
summary(model, input_size=(3, 416, 416))
运行结果如下:
Conv2d-1 [-1, 32, 416, 416] 864
BatchNorm2d-2 [-1, 32, 416, 416] 64
Mish-3 [-1, 32, 416, 416] 0
……中间忽略
Total params: 63,953,841 Trainable params: 63,953,841
2.4 系统设计
在之前,我们已经得到了准备数据并且部署到了响应的位置。现在下载预训练模型放入model_data中。 ├─img ├─logs ├─model_data ├─nets ├─result ├─utils ├─VOCdevkit │ └─VOC2007 │ ├─Annotations │ ├─ImageSets │ │ └─Main │ └─JPEGImages └─__pycache__
Img文件夹:存放测试用的图片 Logs文件夹:存放每个Epoch保存的模型权重、loss图像、loss损失值Model_data文件夹:存放测试集的csv文件(里面包含每个图片名以及最后提交需要用的image_id)、先验框数据文件、先验框类信息 Nets文件夹:里面存放着模型的backbone、neck、head主要代码 Results文件夹:存放着运行结果的图片 Utils文件夹:存放着关于数据输入的相关代码dataloader、以及一些数据增强的代码 VOCdevkit文件夹:存放着训练数据的信息 还有一些代码直接放在运行空间下,有三类,分别是关于trian、test、get_map。
-
训练数据 训练时,先建立yolo模型,预训练权重就选择coco数据集里已经训练好的。前0到50个Epoch进行冻结训练,即将backbone中的参数给冻结,先训练neck和head中的参数;51到150Epoch进行解冻训练。准备Dataloader,建立优化器和损失函数。 在训练过程中(train.py文件),除了需要调用torch自带的包外, 还需要 调用net文件夹中的yolo4.py和yolo_training.py以及utils文件夹下的dataloader.py,时序图如下: -
测试数据 测试中需要建立yolov4模型, 直接调用net文件夹下的yolo.py然后载入训练后得到的权重,为三个输出建立三个特征层解码操作,通过裁剪后放入net中,得到测试的结果并解码,如果返回了结果的话就直接对结果进行分类、画框、添加到数组中。如果没有返回结果的话那么直接对图片进行翻转操作,比如左转45度、右转45度,然后把翻转的图像再放入网络中去预测。详细的时序图如下所示: -
获取map 因为提交的json结果官方会自动计算mAP,然后给我们一个socre,所以这里就没有对测试集进行数据标注。但我们可以对训练集做mAP,可以分别运行get_dr_txt.py 和get_gt_txt.py, 再运行get_map.py即可。
2.5 系统实现
编码与测试 a) 新建工程yolov4_pytorch, 准备数据源放入相应位置。
b) 新建train.py文件
Cuda = True
normalize = True
input_shape = (416, 416)
anchors_path = 'model_data/tianchi_anchors.txt'
classes_path = 'model_data/tianchi_classes.txt'
mosaic = False
Cosine_lr = True
smoooth_label = 0
class_names = get_classes(classes_path)
anchors = get_anchors(anchors_path)
num_classes = len(class_names)
model = YoloBody(len(anchors[0]), num_classes)
weights_init(model)
初始化权重,选择从给定均值和标准差的正态分布N(mean, std)中生成值,填充输入的张量或变量
def weights_init(net, init_type='normal', init_gain=0.02):
def init_func(m):
classname = m.__class__.__name__
if hasattr(m, 'weight') and classname.find('Conv') != -1:
if init_type == 'normal':
torch.nn.init.normal_(m.weight.data, 0.0, init_gain)
elif init_type == 'xavier':
torch.nn.init.xavier_normal_(m.weight.data, gain=init_gain)
elif init_type == 'kaiming':
torch.nn.init.kaiming_normal_(m.weight.data, a=0, mode='fan_in')
elif init_type == 'orthogonal':
torch.nn.init.orthogonal_(m.weight.data, gain=init_gain)
else:
raise NotImplementedError('initialization method [%s] is not implemented' % init_type)
elif classname.find('BatchNorm2d') != -1:
torch.nn.init.normal_(m.weight.data, 1.0, 0.02)
torch.nn.init.constant_(m.bias.data, 0.0)
print('initialize network with %s type' % init_type)
net.apply(init_func)
加载权值文件,全部放入cuda中并开始并行计算
model_path = "logs/Epoch118-Total_Loss3.5682-Val_Loss3.8455.pth"
print('Loading weights into state dict...')
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model_dict = model.state_dict()
pretrained_dict = torch.load(model_path, map_location=device)
pretrained_dict = {k: v for k, v in pretrained_dict.items() if np.shape(model_dict[k]) == np.shape(v)}
model_dict.update(pretrained_dict)
model.load_state_dict(model_dict)
print('Finished!')
net = model.train()
if Cuda:
net = torch.nn.DataParallel(model)
cudnn.benchmark = True
net = net.cuda()
再训练的过程中需要记录Loss的历史记录以便我们观察
loss_history = LossHistory("logs/")
class LossHistory():
def __init__(self, log_dir):
pass
def append_loss(self, loss, val_loss):
pass
def loss_plot(self):
可以根据显卡的性能选择合适的batch_size, 我在NVIDIA Tesla K80中设置的batch_size=9, 并选择Adam优化器喝退火余弦lr_schedualer。将训练集和验证集按照8:2的划分,并封装DataLoader,代码忽略 最后就是完成每个Epoch
for epoch in range(Freeze_Epoch, Unfreeze_Epoch):
fit_one_epoch(net, yolo_loss, epoch, epoch_size, epoch_size_val, gen, gen_val, Unfreeze_Epoch, Cuda)
lr_scheduler.step()
在一个Epoch中,训练需要net.train()一下, 然后分别取出images和targets。Images就是(r, g, b), targets是一个二维数组,每一行表示坐标框的位置和目标类,如下图所示: 然后需要梯度清零、前向传播(获取预测的output)、计算损失。计算损失需要对每一维度进行计算。
def fit_one_epoch(net, yolo_loss, epoch, epoch_size, epoch_size_val, gen, genval, Epoch, cuda):
total_loss = 0
val_loss = 0
net.train()
print('Start Train')
with tqdm(total=epoch_size, desc=f'Epoch {epoch + 1}/{Epoch}', postfix=dict, mininterval=0.3) as pbar:
for iteration, batch in enumerate(gen):
if iteration >= epoch_size:
break
images, targets = batch[0], batch[1]
with torch.no_grad():
if cuda:
images = torch.from_numpy(images).type(torch.FloatTensor).cuda()
targets = [torch.from_numpy(ann).type(torch.FloatTensor) for ann in targets]
else:
images = torch.from_numpy(images).type(torch.FloatTensor)
targets = [torch.from_numpy(ann).type(torch.FloatTensor) for ann in targets]
optimizer.zero_grad()
outputs = net(images)
losses = []
num_pos_all = 0
for i in range(3):
loss_item, num_pos = yolo_loss(outputs[i], targets)
losses.append(loss_item)
num_pos_all += num_pos
loss = sum(losses) / num_pos_all
total_loss += loss.item()
loss.backward()
optimizer.step()
pbar.set_postfix(**{'total_loss': total_loss / (iteration + 1),
'lr': get_lr(optimizer)})
pbar.update(1)
net.eval()
print('Start Validation')
with tqdm(total=epoch_size_val, desc=f'Epoch {epoch + 1}/{Epoch}', postfix=dict, mininterval=0.3) as pbar:
for iteration, batch in enumerate(genval):
if iteration >= epoch_size_val:
break
images_val, targets_val = batch[0], batch[1]
with torch.no_grad():
if cuda:
images_val = torch.from_numpy(images_val).type(torch.FloatTensor).cuda()
targets_val = [torch.from_numpy(ann).type(torch.FloatTensor) for ann in targets_val]
else:
images_val = torch.from_numpy(images_val).type(torch.FloatTensor)
targets_val = [torch.from_numpy(ann).type(torch.FloatTensor) for ann in targets_val]
optimizer.zero_grad()
outputs = net(images_val)
losses = []
num_pos_all = 0
for i in range(3):
loss_item, num_pos = yolo_loss(outputs[i], targets_val)
losses.append(loss_item)
num_pos_all += num_pos
loss = sum(losses) / num_pos_all
val_loss += loss.item()
pbar.set_postfix(**{'total_loss': val_loss / (iteration + 1)})
pbar.update(1)
loss_history.append_loss(total_loss / (epoch_size + 1), val_loss / (epoch_size_val + 1))
print('Finish Validation')
print('Epoch:' + str(epoch + 1) + '/' + str(Epoch))
print('Total Loss: %.4f || Val Loss: %.4f ' % (total_loss / (epoch_size + 1), val_loss / (epoch_size_val + 1)))
print('Saving state, iter:', str(epoch + 1))
torch.save(model.state_dict(), 'logs/Epoch%d-Total_Loss%.4f-Val_Loss%.4f.pth' % (
(epoch + 1), total_loss / (epoch_size + 1), val_loss / (epoch_size_val + 1)))
loss_item, num_pos = yolo_loss(outputs[i], targets)关键的是计算损失函数。output的shape为 bs, 3*(5+num_classes), 13, 13 bs, 3*(5+num_classes), 26, 26 bs, 3*(5+num_classes), 52, 52
计算步长,每一个特征点对应原来的图片上多少个像素点 如果特征层为13x13的话,一个特征点就对应原来的图片上的32个像素点。 如果特征层为26x26的话,一个特征点就对应原来的图片上的16个像素点。 如果特征层为52x52的话,一个特征点就对应原来的图片上的8个像素点。 stride_h = stride_w = 32、16、8
输入的input一共有三个,它们的shape分别是 batch_size, 3, 13, 13, 5 + num_classes batch_size, 3, 26, 26, 5 + num_classes batch_size, 3, 52, 52, 5 + num_classes 获得置信度,是否有物体 conf = torch.sigmoid(prediction[…, 4]) 种类置信度 pred_cls = torch.sigmoid(prediction[…, 5:]) 找到哪些先验框内部包含物体 利用真实框和先验框计算交并比 mask batch_size, 3, in_h, in_w 有目标的特征点 noobj_mask batch_size, 3, in_h, in_w 无目标的特征点 t_box batch_size, 3, in_h, in_w, 4 中心宽高的真实值 tconf batch_size, 3, in_h, in_w 置信度真实值 tcls batch_size, 3, in_h, in_w, num_classes 种类真实值
mask, noobj_mask, t_box, tconf, tcls, box_loss_scale_x, box_loss_scale_y = self.get_target(targets, scaled_anchors,in_w, in_h, self.ignore_threshold) 将预测结果进行解码,判断预测结果和真实值的重合程度,如果重合程度过大则忽略,因为这些特征点属于预测比较准确的特征点 作为负样本不合适。 noobj_mask, pred_boxes_for_ciou = self.get_ignore(prediction, targets, scaled_anchors, in_w, in_h, noobj_mask) 计算预测结果和真实结果的CIOU ciou = (1 - box_ciou( pred_boxes_for_ciou[mask.bool()], t_box[mask.bool()]))* box_loss_scale[mask.bool()] 计算置信度的loss loss_conf = torch.sum(BCELoss(conf, mask) * mask) + torch.sum(BCELoss(conf, mask) * noobj_mask) loss_cls = torch.sum(BCELoss(pred_cls[mask == 1], smooth_labels(tcls[mask == 1],self.label_smooth,self.num_classes))) loss = loss_conf * self.lambda_conf + loss_cls * self.lambda_cls + loss_loc * self.lambda_loc 详解的关于CIOU的loss看提交的代码
c) 新建predcit.py 读取官方给的csv文件,循环读出每个图片并转化为计算机可以读取的(r,g,b)格式,然后调用yolo.detect_image(image, index. Result)返回一个画框的图片,保存到result文件夹中。其中的result是一个数组,程序中所有的结果都保存在这个数组中,相当于c++中的引用。最后打包成json文件,该文件就是我们需要提交的结果。 现在主要分析YOLO类中的detect_image()方法,在该YOLO首先得设置一些属性 _defaults = { “model_path” :“logs/Epoch118-Total_Loss2.8951-Val_Loss3.7786.pth”, “anchors_path”: ‘model_data/tianchi_anchors.txt’, “classes_path”: ‘model_data/tianchi_classes.txt’, “model_image_size”: (416, 416, 3) “confidence”: 0.5, “iou”: 0.5, “cuda”: False }
过程也得到裁剪、获取output、解码、得到坐标框和种类的信息。 会获取四个信息 top_index = batch_detections[:, 4] * batch_detections[:, 5] > self.confidence top_conf = batch_detections[top_index, 4] * batch_detections[top_index, 5] top_label = np.array(batch_detections[top_index, -1], np.int32) top_bboxes = np.array(batch_detections[top_index, :4]) 对toplabel进行遍历, 获取bbox信息,但是每个预测的结果只是某个类别的框坐标,我们还需要进行逻辑判断,来确定它们之间的归属。
2.6 系统测试
部署运行 因为本地电脑没有可以运行的GPU,所以得部署到矩池云中训练。 矩池云的官网如下:矩池云 - 专注于人工智能领域的云服务商 (matpool.com)
编译与运行 在矩池云的环境中,进入yolo4_pytorch的工作空间。
-
观察训练图片信息 -
执行python train.py命令 权重信息会保存到logs文件夹中, 目前最好的是Epoch118-Total_Loss2.8951-Val_Loss3.7786.pth; 损失信息会保存在loss.txt中截取前34个。从下面的图片中我们发现loss下降的特别明显。 -
执行python predict.py命令 打印出每个图片的labels信息如下,0代表着person、代表badge、1代表着wrongclothes、2代表着wrongclothes、3代表着clothes -
观察测试的图片 -
提交结果 -
在测试结束后会得到一个json文件,下面是事例
[
{
"image_id":212,
"category_id":1,
"bbox":[
2211,
2023,
506,
1989
],
"score":0.536
},
{
"image_id":212,
"category_id":2,
"bbox":[
2205,
2026,
510,
1990
],
"score":0.622
},
{
"image_id":213,
"category_id":2,
"bbox":[
4389,
1591,
750,
2013
],
"score":0.51
},
{
"image_id":213,
"category_id":1,
"bbox":[
1574,
1633,
595,
2175
],
"score":0.69
}
]
上传到官网后得分和名次是
3 遇到的问题及解决方法
问题:在预测时候发现score太低了,只靠训练是提高不上去的。 解决办法(从训练的角度去看)
- 在数据增强的过程中,我们使用了随即裁剪、马赛克增强、退火余弦、标签平滑、图片翻转、色域变化,但在实验中我发现关闭了色域变化后效果会更好,而且score会提高很多,之后需要开启optimizer优化器中的weight_decay,设为5e-4。
解决办法(从测试的角度看) - 原来Image读取的图片会自动翻转图像,所以需要用cv2读取图片的(b,g,r)信息, 然后转成Image类型。代码如下:
image = cv2.imread(imagepath)
image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
- 首先就是iou和confidence的调整,将它们都设置为0.5;随后通过观察图片,有的斜着的照片没有预测出来,虽然这些图片不多,但是却对整体的准确率影响很大。我们先观察一张斜着图片
做到这里的时候其实有两个思路,一个思路是将给训练的图片添加一个左旋45度、右旋45度的翻转,但是有的图片真的是斜的过分。尝试了这种做法,但是效果不是很好。最后选择了第二种思路,写一个函数,将没有预测出来的图片放入该函数中,该函数实现翻转的功能。通过观察图片,大多数都是顺时针旋转40或者逆时针旋转40, 当然为了防止一些倾斜过分的图片,我把图片,先经过逆时针旋转40度,如果预测不出图片的话,就将原来的图片顺时针旋转40度,如果还是预测不出来的话就继续旋转到80度,直到360,也就是旋转到原始的图片。如果还是没有预测出来的话只能跳过。但事实证明,这种方法把所有的图片都预测出来了,而且效果很好。旋转过后的图片如下:
- 对于旋转的图片,由于测试的结果是绝对值,还需要将测试结果的坐标做一些变换。代码如下:
def convertloc(self, x, y, theta, x0, y0):
X = (x - x0) * np.cos(theta) - (y - y0) * np.sin(theta)+x0
Y = (x - x0) * np.sin(theta) + (y - y0) * np.cos(theta)+y0
return int(X), int(Y)
然后在把预测的bbox放到下面的方法中,但需要注意的是,一定需要把旋转的角度转化为弧度制,不然结果框就不对了。
def convert_box(self,box,theta,origin_image ):
top, left, bottom, right = box
originx1 = left
originy1 = top
originx2 = right
originy2 = bottom
theta1 = (theta) * np.pi / 180
x1, y1 = self.convertloc(originx1, originy1, theta1, origin_image.size[0] / 2, origin_image.size[1] / 2)
x2, y2 = self.convertloc(originx1, originy2, theta1, origin_image.size[0] / 2, origin_image.size[1] / 2)
x3, y3 = self.convertloc(originx2, originy1, theta1, origin_image.size[0] / 2, origin_image.size[1] / 2)
x4, y4 = self.convertloc(originx2, originy2, theta1, origin_image.size[0] / 2, origin_image.size[1] / 2)
newx1 = np.min([x1, x2, x3, x4])
newy1 = np.min([y1, y2, y3, y4])
newx2 = np.max([x1, x2, x3, x4])
newy2 = np.max([y1, y2, y3, y4])
box = int(newy1), int(newx1), int(newy2), int(newx2)
return box
我们观察一下斜着图片的预测效果
- 做了以上的优化后,所有图片中的类别都能几乎都能正确识别出来了,通过测试后发现效果有了很明显的提高。现在我们需要将它们做一下归属。比如,如何判定这个衣服是那个人的,或者这个badge是这个人的。一开始,按照正常生活的思维,衣服坐标框就是在人的坐标框之内, 而红袖肩章的坐标就是在也是在人的坐标里面。 但是通过观察200多张测试集后发现,有的衣服坐标框居然在人的外面。所以得稍微改变一下,比如衣服得坐标减少原来得八分之一。
问题:还有一个问题就是人物得重叠度太高,会严重导致红袖肩章得归属问题,比如下面这个图,如果只凭目标检测的话根本判断不出来,就连骨骼关键点检测也不好用。
问题:关于本模型的目标检测问题,还需要许多可以优化的地方。
-
是外物遮挡的问题,下面的这个图片就是外物的影响比较严重。 -
是自身装备的影响,下面的图片中就是自身安全带影响了裤脚的识别。本该识别出“穿戴”不正确,但由于安全带的影响被误判了。 -
小目标检测问题,yolo虽然可以检测出小物体,但下面的这张图片目标过于小,虽然把人都预测出来了,但是红袖肩章仍然没有预测出来。
所以对于这种情况,找了许多资料还是没有好的办法。等以后通过进一步的学习和研究或许能解决,就像排名前几的参赛者。
|