学习目标
YOLO系列算法是一类典型的one-stage目标检测算法,其利用anchor box将分类与目标定位的回归问题结合起来,从而做到了高效、灵活和泛化性能好,所以在工业界也十分受欢迎,接下来我们介绍YOLO 系列算法。
Yolo算法采用一个单独的CNN模型实现end-to-end的目标检测,核心思想就是利用整张图作为网络的输入,直接在输出层回归 bounding box(边界框) 的位置及其所属的类别,整个系统如下图所示:
首先将输入图片resize到448x448,然后送入CNN网络,最后处理网络预测结果得到检测的目标。相比R-CNN算法,其是一个统一的框架,其速度更快。
在介绍Yolo算法之前,我们回忆下RCNN模型,RCNN模型提出了候选区(Region Proposals)的方法,先从图片中搜索出一些可能存在对象的候选区(Selective Search),大概2000个左右,然后对每个候选区进行对象识别,但处理速度较慢。
Yolo意思是You Only Look Once,它并没有真正的去掉候选区域,而是创造性的将候选区和目标分类合二为一,看一眼图片就能知道有哪些对象以及它们的位置。
Yolo模型采用预定义预测区域的方法来完成目标检测,具体而言是将原始图像划分为 7x7=49 个网格(grid),每个网格允许预测出2个边框(bounding box,包含某个对象的矩形框),总共 49x2=98 个bounding box。我们将其理解为98个预测区,很粗略的覆盖了图片的整个区域,就在这98个预测区中进行目标检测。
只要得到这98个区域的目标分类和回归结果,再进行NMS就可以得到最终的目标检测结果。那具体要怎样实现呢?
YOLO的结构非常简单,就是单纯的卷积、池化最后加了两层全连接,从网络结构上看,与前面介绍的CNN分类网络没有本质的区别,最大的差异是输出层用线性函数做激活函数,因为需要预测bounding box的位置(数值型),而不仅仅是对象的概率。所以粗略来说,YOLO的整个结构就是输入图片经过神经网络的变换得到一个输出的张量,如下图所示:
网络结构比较简单,重点是我们要理解网络输入与输出之间的关系。
网络的输入是原始图像,唯一的要求是缩放到448x448的大小。主要是因为Yolo的网络中,卷积层最后接了两个全连接层,全连接层是要求固定大小的向量作为输入,所以Yolo的输入图像的大小固定为448x448。
网络的输出就是一个7x7x30 的张量(tensor)。那这个输出结果我们要怎么理解那?
根据YOLO的设计,输入图像被划分为 7x7 的网格(grid),输出张量中的 7x7 就对应着输入图像的 7x7 网格。或者我们把 7x7x30 的张量看作 7x7=49个30维的向量,也就是输入图像中的每个网格对应输出一个30维的向量。如下图所示,比如输入图像左上角的网格对应到输出张量中左上角的向量。
30维的向量包含:2个bbox的位置和置信度以及该网格属于20个类别的概率
Pr(Object)是bounding box内存在对象的概率
Yolo支持识别20种不同的对象(人、鸟、猫、汽车、椅子等),所以这里有20个值表示该网格位置存在任一种对象的概率.
在进行模型训练时,我们需要构造训练样本和设计损失函数,才能利用梯度下降对网络进行训练。
将一幅图片输入到yolo模型中,对应的输出是一个7x7x30张量,构建标签label时对于原图像中的每一个网格grid都需要构建一个30维的向量。对照下图我们来构建目标向量:
对于输入图像中的每个对象,先找到其中心点。比如上图中自行车,其中心点在黄色圆点位置,中心点落在黄色网格内,所以这个黄色网格对应的30维向量中,自行车的概率是1,其它对象的概率是0。所有其它48个网格的30维向量中,该自行车的概率都是0。这就是所谓的"中心点所在的网格对预测该对象负责"。狗和汽车的分类概率也是同样的方法填写
训练样本的bbox位置应该填写对象真实的位置bbox,但一个对象对应了2个bounding box,该填哪一个呢?需要根据网络输出的bbox与对象实际bbox的IOU来选择,所以要在训练过程中动态决定到底填哪一个bbox。
预测置信度的公式为:
IOUtruthpredIOUpredtruth利用网络输出的2个bounding box与对象真实bounding box计算出来。然后看这2个bounding box的IOU,哪个比较大,就由哪个bounding box来负责预测该对象是否存在,即该bounding box的Pr(Object)=1,同时对象真实bounding box的位置也就填入该bounding box。另一个不负责预测的bounding box的Pr(Object)=0。
上图中自行车所在的grid对应的结果如下图所示:
损失就是网络实际输出值与样本标签值之间的偏差:
yolo给出的损失函数:
注:其中1obji1iobj表示目标是否出现在网格单元i中,1objij1ijobj表示单元格i中的第j个边界框预测器负责该预测,YOLO设置 λcoord=5λcoord=5 来调高位置误差的权重, λnoobj=0.5λnoobj=0.5 即调低不存在对象的bounding box的置信度误差的权重。
Yolo先使用ImageNet数据集对前20层卷积网络进行预训练,然后使用完整的网络,在PASCAL VOC数据集上进行对象识别和定位的训练。
Yolo的最后一层采用线性激活函数,其它层都是Leaky ReLU。训练中采用了drop out和数据增强(data augmentation)来防止过拟合.
将图片resize成448x448的大小,送入到yolo网络中,输出一个 7x7x30 的张量(tensor)来表示图片中所有网格包含的对象(概率)以及该对象可能的2个位置(bounding box)和可信程度(置信度)。在采用NMS(Non-maximal suppression,非极大值抑制)算法选出最有可能是目标的结果。
优点
缺点
YOLOv2相对v1版本,在继续保持处理速度的基础上,从预测更准确(Better),速度更快(Faster),识别对象更多(Stronger)这三个方面进行了改进。其中识别更多对象也就是扩展到能够检测9000种不同对象,称之为YOLO9000。 下面我们看下yoloV2的都做了哪些改进?
批标准化有助于解决反向传播过程中的梯度消失和梯度爆炸问题,降低对一些超参数的敏感性,并且每个batch分别进行归一化的时候,起到了一定的正则化效果,从而能够获得更好的收敛速度和收敛效果。在yoloV2中卷积后全部加入Batch Normalization,网络会提升2%的mAP。
YOLO v1使用ImageNet的图像分类样本采用 224x224 作为输入,来训练CNN卷积层。然后在训练对象检测时,检测用的图像样本采用更高分辨率的 448x448 的图像作为输入。但这样切换对模型性能有一定影响。
YOLOV2在采用 224x224 图像进行分类模型预训练后,再采用 448x448 的高分辨率样本对分类模型进行微调(10个epoch),使网络特征逐渐适应 448x448 的分辨率。然后再使用 448x448 的检测样本进行训练,缓解了分辨率突然切换造成的影响。
使用该技巧后网络的mAP提升了约4%。
YOLO1并没有采用先验框,并且每个grid只预测两个bounding box,整个图像98个。YOLO2如果每个grid采用5个先验框,总共有13x13x5=845个先验框。通过引入anchor boxes,使得预测的box数量更多(13x13xn)。
Faster-rcnn选择的anchor比例都是手动指定的,但是不一定完全适合数据集。YOLO2尝试统计出更符合样本中对象尺寸的先验框,这样就可以减少网络微调先验框到实际位置的难度。YOLO2的做法是对训练集中标注的边框进行聚类分析,以寻找尽可能匹配样本的边框尺寸。
YoloV2选择了聚类的五种尺寸最为anchor box。
Yolov2中将边框的结果约束在特定的网格中:
其中,
bx,by,bw,bhbx,by,bw,bh是预测边框的中心和宽高。 Pr(object)∗IOU(b,object)Pr(object)∗IOU(b,object)是预测边框的置信度,YOLO1是直接预测置信度的值,这里对预测参数toto进行σ变换后作为置信度的值。 cx,cycx,cy是当前网格左上角到图像左上角的距离,要先将网格大小归一化,即令一个网格的宽=1,高=1。 pw,phpw,ph是先验框的宽和高。 σ是sigmoid函数。 tx,ty,tw,th,totx,ty,tw,th,to是要学习的参数,分别用于预测边框的中心和宽高,以及置信度。
如下图所示:
由于σ函数将 tx,tytx,ty约束在(0,1)范围内,预测边框的蓝色中心点被约束在蓝色背景的网格内。约束边框位置使得模型更容易学习,且预测更为稳定。
假设网络预测值为:
anchor框为:
则目标在特征图中的位置:
在原图像中的位置:
图像中对象会有大有小,输入图像经过多层网络提取特征,最后输出的特征图中,较小的对象可能特征已经不明显甚至被忽略掉了。为了更好的检测出一些比较小的对象,最后输出的特征图需要保留一些更细节的信息。
YOLO2引入一种称为passthrough层的方法在特征图中保留一些细节信息。具体来说,就是在最后一个pooling之前,特征图的大小是26x26x512,将其1拆4,直接传递(passthrough)到pooling后(并且又经过一组卷积)的特征图,两者叠加到一起作为输出的特征图。
具体的拆分方法如下所示:
YOLO2中没有全连接层,可以输入任何尺寸的图像。因为整个网络下采样倍数是32,采用了{320,352,…,608}等10种输入图像的尺寸,这些尺寸的输入图像对应输出的特征图宽和高是{10,11,…19}。训练时每10个batch就随机更换一种尺寸,使网络能够适应各种大小的对象检测。
yoloV2提出了Darknet-19(有19个卷积层和5个MaxPooling层)网络结构作为特征提取网络。DarkNet-19比VGG-16小一些,精度不弱于VGG-16,但浮点运算量减少到约⅕,以保证更快的运算速度。
yoloV2的网络中只有卷积+pooling,从416x416x3 变换到 13x13x5x25。增加了batch normalization,增加了一个passthrough层,去掉了全连接层,以及采用了5个先验框,网络的输出如下图所示:
VOC数据集可以检测20种对象,但实际上对象的种类非常多,只是缺少相应的用于对象检测的训练样本。YOLO2尝试利用ImageNet非常大量的分类样本,联合COCO的对象检测数据集一起训练,使得YOLO2即使没有学过很多对象的检测样本,也能检测出这些对象。
yoloV3以V1,V2为基础进行的改进,主要有:利用多尺度特征进行目标检测;先验框更丰富;调整了网络结构;对象分类使用logistic代替了softmax,更适用于多标签分类任务。
YOLOv3是YOLO (You Only Look Once)系列目标检测算法中的第三版,相比之前的算法,尤其是针对小目标,精度有显著提升。
yoloV3的流程如下图所示,对于每一幅输入图像,YOLOv3会预测三个不同尺度的输出,目的是检测出不同大小的目标。
通常一幅图像包含各种不同的物体,并且有大有小。比较理想的是一次就可以将所有大小的物体同时检测出来。因此,网络必须具备能够“看到”不同大小的物体的能力。因为网络越深,特征图就会越小,所以网络越深小的物体也就越难检测出来。
在实际的feature map中,随着网络深度的加深,浅层的feature map中主要包含低级的信息(物体边缘,颜色,初级位置信息等),深层的feature map中包含高等信息(例如物体的语义信息:狗,猫,汽车等等)。因此在不同级别的feature map对应不同的scale,所以我们可以在不同级别的特征图中进行目标检测。如下图展示了多种scale变换的经典方法。
(a) 这种方法首先建立图像金字塔,不同尺度的金字塔图像被输入到对应的网络当中,用于不同scale物体的检测。但这样做的结果就是每个级别的金字塔都需要进行一次处理,速度很慢。
(b) 检测只在最后一层feature map阶段进行,这个结构无法检测不同大小的物体
© 对不同深度的feature map分别进行目标检测。SSD中采用的便是这样的结构。这样小的物体会在浅层的feature map中被检测出来,而大的物体会在深层的feature map被检测出来,从而达到对应不同scale的物体的目的,缺点是每一个feature map获得的信息仅来源于之前的层,之后的层的特征信息无法获取并加以利用。
(d) 与©很接近,但不同的是,当前层的feature map会对未来层的feature map进行上采样,并加以利用。因为有了这样一个结构,当前的feature map就可以获得“未来”层的信息,这样的话低阶特征与高阶特征就有机融合起来了,提升检测精度。在YOLOv3中,就是采用这种方式来实现目标多尺度的变换的。
在基本的图像特征提取方面,YOLO3采用了Darknet-53的网络结构(含有53个卷积层),它借鉴了残差网络ResNet的做法,在层之间设置了shortcut,来解决深层网络梯度的问题,shortcut如下图所示:包含两个卷积层和一个shortcut connections。
yoloV3的模型结构如下所示:整个v3结构里面,没有池化层和全连接层,网络的下采样是通过设置卷积的stride为2来达到的,每当通过这个卷积层之后图像的尺寸就会减小到一半。
下面我们看下网络结构:
1、CBL:Yolov3网络结构中的最小组件,由Conv+Bn+Leaky_relu激活函数三者组成。 2、Res unit:借鉴Resnet网络中的残差结构,让网络可以构建的更深。 3、ResX:由一个CBL和X个残差组件构成,是Yolov3中的大组件。每个Res模块前面的CBL都起到下采样的作用,因此经过5次Res模块后,得到的特征图是608->304->152->76->38->19大小。
1、Concat:张量拼接,会扩充两个张量的维度,例如26×26×256和26×26×512两个张量拼接,结果是26×26×768。
2、Add:张量相加,张量直接相加,不会扩充维度,例如104×104×128和104×104×128相加,结果还是104×104×128。
每个ResX中包含1+2×X个卷积层,因此整个主干网络Backbone中一共包含1+(1+2×1)+(1+2×2)+(1+2×8)+(1+2×8)+(1+2×4)=52,再加上一个FC全连接层,即可以组成一个Darknet53分类网络。不过在目标检测Yolov3中,去掉FC层,仍然把Yolov3的主干网络叫做Darknet53结构。
yoloV3采用K-means聚类得到先验框的尺寸,为每种尺度设定3种先验框,总共聚类出9种尺寸的先验框。
在COCO数据集这9个先验框是:(10x13),(16x30),(33x23),(30x61),(62x45),(59x119),(116x90),(156x198),(373x326)。在最小的(13x13)特征图上(有最大的感受野)应用较大的先验框(116x90),(156x198),(373x326),适合检测较大的对象。中等的(26x26)特征图上(中等感受野)应用中等的先验框(30x61),(62x45),(59x119),适合检测中等大小的对象。较大的(52x52)特征图上(较小的感受野)应用,其中较小的先验框(10x13),(16x30),(33x23),适合检测较小的对象。
直观上感受9种先验框的尺寸,下图中蓝色框为聚类得到的先验框。黄色框式ground truth,红框是对象中心点所在的网格。
预测对象类别时不使用softmax,而是被替换为一个1x1的卷积层+logistic激活函数的结构。使用softmax层的时候其实已经假设每个输出仅对应某一个单个的class,但是在某些class存在重叠情况(例如woman和person)的数据集中,使用softmax就不能使网络对数据进行很好的预测。
输入416×416×3的图像,通过darknet网络得到三种不同尺度的预测结果,每个尺度都对应N个通道,包含着预测的信息;
每个网格每个尺寸的anchors的预测结果。
YOLOv3共有13×13×3 + 26×26×3 + 52×52×3个预测 。每个预测对应85维,分别是4(坐标值)、1(置信度分数)、80(coco类别概率)。
YOLO之父在2020年初宣布退出CV界,YOLOv4 的作者并不是YOLO系列 的原作者。YOLO V4是YOLO系列一个重大的更新,其在COCO数据集上的平均精度(AP)和帧率精度(FPS)分别提高了10% 和12%,并得到了Joseph Redmon的官方认可,被认为是当前最强的实时对象检测模型之一。
yoloV4总结了大部分检测技巧,然后经过筛选,排列组合,挨个实验(ablation study)哪些方法有效,总体来说,Yolov4并没有创造新的改进,而是使用了大量的目标检测的技巧。在这里我们主要给大家看下它的网络架构:
Yolov4的结构图和Yolov3是相似的,不过使用各种新的算法思想对各个子结构都进行了改进。 先整理下Yolov4的结构组件
注意:
网络的输入大小不是固定的,在yoloV3中输入默认是416×416,在yoloV4中默认是608×608,在实际项目中也可以根据需要修改,比如320×320,一般是32的倍数。 输入图像的大小和最后的三个特征图的大小也是对应的,比如416×416的输入,最后的三个特征图大小是13×13,26×26,52×52, 如果是608×608,最后的三个特征图大小则是19×19,38×38,76×76。
总结
YOLO的整个结构就是输入图片经过神经网络的变换得到一个输出的张量
对于原图像中的每一个网格grid都需要构建一个30维的向量:分类,置信度,回归的目标值
损失函数分为3部分:分类损失,回归损失,置信度损失
使用了BN层,高分辨率训练,采用Anchorbox,聚类得到anchorbox的尺寸,改进边界框预测的方法,特征融合,多尺度训练,网络模型使用darknet19,利用imagenet数据集识别更多的目标
在YOLOv3中采用FPN结构来提高对应多尺度目标检测的精度,当前的feature map利用“未来”层的信息,将低阶特征与高阶特征进行融合,提升检测精度。
以darknet-53为基础,借鉴resnet的思想,在网络中加入了残差模块,利于解决深层次网络的梯度问题
整个v3结构里面,没有池化层和全连接层,只有卷积层
网络的下采样是通过设置卷积的stride为2来达到的
采用K-means聚类得到先验框的尺寸,为每种尺度设定3种先验框,总共聚类出9种尺寸的先验框。
预测对象类别时不使用softmax,而是使用logistic的输出进行预测
对于416×416×3的输入图像,在每个尺度的特征图的每个网格设置3个先验框,总共有 13×13×3 + 26×26×3 + 52×52×3 = 10647 个预测。每一个预测是一个(4+1+80)=85维向量,这个85维向量包含边框坐标(4个数值),边框置信度(1个数值),对象类别的概率(对于COCO数据集,有80种对象)。
学习目标
该案例中我们依然使用VOC数据集来进行目标检测,不同的是我们要利用tfrecord文件来存储和读取数据,首先来看一下tfrecord文件的相关内容。
为什么要使用tfrecord文件?
TFRecord 是Google官方推荐的一种数据格式,是Google专门为TensorFlow设计的一种数据格式,利用这种方式存储数据可以使其与网络架构更适配。TFRecord是一种二进制文件,其能更好的利用内存,与csv,hdf5文件是类似的。
TFRecord的文件的内容如下图所示:
TFRecord内部包含多个tf.train.Example,一般来说对应一个图像数据,在一个Example消息体中包含了一系列的tf.train.feature属性,而 每一个feature是一个key-value的键值对,其中,key 是string类型,而value 的取值有三种:
TFRecord 并非是TensorFlow唯一支持的数据格式,也可以使用CSV或文本等其他格式,但是对于TensorFlow来说,TFRecord 是最友好的,最方便的,而且tensorflow也提供了丰富的API帮助我们轻松的创建和获取TFRecord文件。
对于中大数据集来说,Google官方推荐先将数据集转化为TFRecord数据, 这样可加快在数据读取, 预处理中的速度。接下来我们就将VOC数据集转换为Records格式,在这里首先读取标注XML文件,并找到对应的图像数据,最后将数据写入TFRecords文件中。
VOC数据集的标注信息存储在xml文件中,在VOC2007的数据中主要获取fIlename,width,height,和object(图像中的目标)下的name(目标名称)和bndbox(框的位置)。具体大家可以看下在FasterRCNN中的介绍,代码如下所示:
import xml.dom.minidom as xdom
# VOC数据集中的类别信息
voc_classes = {
'none': 0,
'aeroplane': 1,
'bicycle': 2,
'bird': 3,
'boat': 4,
'bottle': 5,
'bus': 6,
'car': 7,
'cat': 8,
'chair': 9,
'cow': 10,
'diningtable': 11,
'dog': 12,
'horse': 13,
'motorbike': 14,
'person': 15,
'pottedplant': 16,
'sheep': 17,
'sofa': 18,
'train': 19,
'tvmonitor': 20,
}
# 读取XML文件中的信息
def Prase_Singel_xml(xml_path):
DOMTree = xdom.parse(xml_path)
RootNode = DOMTree.documentElement
#获取XML文件对应的图像
image_name = RootNode.getElementsByTagName("filename")[0].childNodes[0].data
#获取图像宽和高
size = RootNode.getElementsByTagName("size")
image_height = int(size[0].getElementsByTagName("height")[0].childNodes[0].data)
image_width = int(size[0].getElementsByTagName("width")[0].childNodes[0].data)
#获取图像中目标对象
all_obj = RootNode.getElementsByTagName("object")
bndbox_lable_dic = []
# 遍历所有的对象
for one_obj in all_obj:
# 获取目标的标注信息
obj_name = one_obj.getElementsByTagName("name")[0].childNodes[0].data
# 获取对应的label值
obj_label = voc_classes[obj_name]
# 获取bbox
bndbox = one_obj.getElementsByTagName("bndbox")
# 获取目标的左上右下的位置
xmin = int(bndbox[0].getElementsByTagName("xmin")[0].childNodes[0].data)
ymin = int(bndbox[0].getElementsByTagName("ymin")[0].childNodes[0].data)
xmax = int(bndbox[0].getElementsByTagName("xmax")[0].childNodes[0].data)
ymax = int(bndbox[0].getElementsByTagName("ymax")[0].childNodes[0].data)
# 将目标框和类别组合在一起
bndbox_lable_dic.append([xmin, ymin, xmax, ymax, obj_label])
# 返回相应的信息
return image_name, image_width, image_height, bndbox_lable_dic
接下来我们读取一个XML文件看下效果:
# 展示效果
print(Prase_Singel_xml('VOCdevkit/VOC2007/Annotations/000007.xml'))
结果如下所示:
('000007.jpg', 500, 333, [[141, 50, 500, 330, 7]])
从中可以看出,对应的图像是000007.jpg,图像的宽高是500, 333,图像中只包含一个目标,位置是141, 50, 500, 330,类别是7 car.
在将数据写入时,我们可以使用tf.io.TFRecordWriter来完成,主要步骤是:
1、使用tf.io.TFRecordWriter打开TFRecords文件
2、使用tf.train.Int64List,tf.train.BytesList或tf.train.FloatList对数据进行类型转换
3、将类型转换后的数据传入tf.train.Feature创建的特征中
4、将特征传入tf.train.Example创建的example中
5、使用example.SerializeToString()将example序列化为字符串
6、使用writer.write将序列化后的example写入TFRecords文件
7、最后使用writer.close()关闭文件
import tensorflow as tf
import glob
import os
# 指明xml文件,tfrecord文件和图像的位置
def write_to_tfrecord(all_xml_path, tfrecord_path, voc_img_path):
# 1、使用tf.io.TFRecordWriter打开TFRecords文件
writer = tf.io.TFRecordWriter(tfrecord_path)
# 遍历所有的XML文件
for i, single_xml_path in enumerate(all_xml_path):
# 读取xml文件中的内容
image_name, image_width, image_height, bndbox_lable_dic = Prase_Singel_xml(single_xml_path)
# 获取图像的路径
sigle_img_path = os.path.join(voc_img_path, image_name)
# 读取图像
image_data = open(sigle_img_path, 'rb').read()
xmin = []
ymin = []
xmax = []
ymax = []
obj_label = []
# 遍历box和label信息,并记录下来
for j in range(len(bndbox_lable_dic)):
xmin.append(bndbox_lable_dic[j][0])
ymin.append(bndbox_lable_dic[j][1])
xmax.append(bndbox_lable_dic[j][2])
ymax.append(bndbox_lable_dic[j][3])
obj_label.append(bndbox_lable_dic[j][4])
# 创建特征:图像,size,box和label
# 2、使用tf.train.Int64List,tf.train.BytesList或tf.train.FloatList对数据进行类型转换
# 3、将类型转换后的数据传入tf.train.Feature创建的特征中
feature = {
'image': tf.train.Feature(bytes_list=tf.train.BytesList(value=[image_data])),
'width': tf.train.Feature(float_list=tf.train.FloatList(value=[image_width])),
'height': tf.train.Feature(float_list=tf.train.FloatList(value=[image_height])),
'xmin': tf.train.Feature(float_list=tf.train.FloatList(value=xmin)),
'ymin': tf.train.Feature(float_list=tf.train.FloatList(value=ymin)),
'xmax': tf.train.Feature(float_list=tf.train.FloatList(value=xmax)),
'ymax': tf.train.Feature(float_list=tf.train.FloatList(value=ymax)),
'label': tf.train.Feature(int64_list=tf.train.Int64List(value=obj_label))
}
# 4、将特征传入tf.train.Example创建的example中
example = tf.train.Example(features=tf.train.Features(feature=feature))
# 将example写入到tfrecord文件中
# 5、使用example.SerializeToString()将example序列化为字符串
# 6、使用writer.write将序列化后的example写入TFRecords文件
writer.write(example.SerializeToString())
# 最后使用writer.close()关闭文件
writer.close()
print('第{}张图片写入完毕'.format(i))
接下来调用上述方法将VOC数据写入到TFRecord文件中:
# 获取所有的xml文件
all_xml_path = glob.glob('VOCdevkit/VOC2007/Annotations/*.xml')
# 指定tfrecords文件的路径
tfrecord_path = 'voc_2007.tfrecords'
# 指定图像所在的路径
voc_img_path = 'VOCdevkit/VOC2007/JPEGImages'
# 将信息写入到tfrecord文件中
write_to_tfrecord(all_xml_path, tfrecord_path, voc_img_path)
结果如下所示:
VOC数据集已经被写入到TFRecord文件中了,那我们就要从TFrecord文件中将数据读取出来。只需要简单的使用 tf.data.TFRecordDataset 就能够轻松的读取数据。
import tensorflow as tf
import os
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
# 获取tfreocrd中的所有数据
raw_datasets = tf.data.TFRecordDataset('voc_2007.tfrecords')
# 定义特征的描述方法:图像,box和label,注意:要和写入时是一一对应的
feature_description = {
'image': tf.io.FixedLenFeature([], tf.string),
'width': tf.io.FixedLenFeature([], tf.float32),
'height': tf.io.FixedLenFeature([], tf.float32),
'xmin': tf.io.VarLenFeature(tf.float32),
'ymin': tf.io.VarLenFeature(tf.float32),
'xmax': tf.io.VarLenFeature(tf.float32),
'ymax': tf.io.VarLenFeature(tf.float32),
'label': tf.io.VarLenFeature(tf.int64),
}
# 将tfrecord中的数据转换为原始图像和标注信息(只能对一个数据进行处理)
def parse_example(example_string):
# 将tfreocord文件中的一个example映射回原始数据
feature_dict = tf.io.parse_single_example(example_string, feature_description)
# 获取图像数据
image_data = tf.io.decode_jpeg(feature_dict['image'])
# 获取box
boxes = tf.stack([tf.sparse.to_dense(feature_dict['xmin']),
tf.sparse.to_dense(feature_dict['ymin']),
tf.sparse.to_dense(feature_dict['xmax']),
tf.sparse.to_dense(feature_dict['ymax'])], axis=1)
# 获取标注信息
boxes_category = tf.sparse.to_dense(feature_dict['label'])
# 返回结果
return image_data, feature_dict['width'], feature_dict['height'], boxes, boxes_category
# 利用map方法调用parse_example方法对所有数据进行处理,得到最终的经过
raw_datasets = raw_datasets.map(parse_example)
我们将从TFRecord文件中读取的数据展示出来:
# 将VOC_class字典的key和value进行翻转
new_voc_class = {v:k for k,v in voc_classes.items()}
# 将tfrecord中的图像进行展示
plt.figure(figsize=(15, 10))
# 初始化:第几个图像
i = 0
# 从raw_datasets中选取3个样本,获取图像,大小,框的标注信息和类别信息
for image, width, height, boxes, boxes_category in raw_datasets.take(3):
# 进行绘图
plt.subplot(1, 3, i+1)
# 绘制图像
plt.imshow(image)
# 获取坐标区域
ax = plt.gca()
# 遍历所有的框
for j in range(boxes.shape[0]):
# 绘制框
rect = Rectangle((boxes[j, 0], boxes[j, 1]), boxes[j, 2] -boxes[j, 0], boxes[j, 3]-boxes[j, 1], color='r', fill=False)
# 将框显示在图像上
ax.add_patch(rect)
# 显示标注信息
# 获取标注信息的id
label_id = boxes_category[j]
# 获取标准信息
label = new_voc_class.get(label_id.numpy())
# 将标注信息添加在图像上
ax.text(boxes[j, 0], boxes[j, 1] + 8, label,color='w', size=11, backgroundcolor="none")
# 下一个结果
i += 1
# 显示图像
plt.show()
结果为:
使用数据处理的tf.data.Dataset模块中pipline机制,可实现CPU多线程处理输入的数据,如读取图片和图片的一些的预处理,这样GPU可以专注于训练过程,而CPU去准备数据。
Dataset支持一类特殊的操作:Transformation。一个Dataset通过Transformation变成一个新的Dataset。通常我们可以通过Transformation完成数据变换,打乱,组成batch,生成epoch等一系列操作。常用的Transformation有:map、batch、shuffle和repeat。解析tfrecord文件得到的数据都可以使用这些方法,例如我们前面使用的:
# 利用map方法调用parse_example方法对所有数据进行处理,得到最终的经过
raw_datasets = raw_datasets.map(parse_example)
下面我们分别介绍:
使用 tf.data.Dataset.map,我们可以很方便地对数据集中的各个元素进行预处理。因为输入元素之间时独立的,所以可以在多个 CPU 核心上并行地进行预处理。map 变换提供了一个 num_parallel_calls参数去指定并行的级别。
dataset = dataset.map(map_func=parse_fn, num_parallel_calls=FLAGS.num_parallel_calls)
repeat的功能就是将整个序列重复多次,主要用来处理机器学习中的epoch,假设原先的数据是一个epoch,使用repeat(5)就可以将之变成5个epoch的数据。
tf.data.Dataset.prefetch 提供解耦了 数据产生的时间 和 数据消耗的时间。具体来说,在数据被请求前,就从 dataset 中预加载一些数据,从而进一步提高性能。prefech(n) 一般作为最后一个 transformation,其中 n 为 batch_size。 prefetch 的使用方法如下:
# 最后一个变换
dataset = dataset.prefetch(buffer_size=FLAGS.prefetch_buffer_size)
return dataset
另外还可使用bacth方法组成批次数据送入网络中,也可使用shuffle方法对数据打乱。
yoloV3模型的输入图像的大小是32的倍数,所以我们需要对图像进行处理。在这里我们将图像的尺度调整为416x416的大小,为了保持长宽比,我将四周为0的像素以灰度值128进行填充。
def preprocess(image, bbox, input_shape=(416, 416)):
# 增加batch维
image = tf.expand_dims(image, axis=0)
# 获取图像的高宽[height, width]
img_shape = image.shape[1:3]
# 将图像进行调整,插值方法是双三次插值,保留长宽比
resize_image = tf.image.resize(
image, input_shape, method=tf.image.ResizeMethod.BICUBIC, preserve_aspect_ratio=True)
# 获取图像的宽高[height,width]
resize_shape = resize_image.shape[1:3]
# 图像上方的填充大小
top_pad = (input_shape[0] - resize_shape[0]) // 2
# 图像下方的填充大小
bottom_pad = input_shape[0] - resize_shape[0] - top_pad
# 图像左方的填充大小
left_pad = (input_shape[1] - resize_shape[1]) // 2
# 图像右方的填充大小
right_pad = input_shape[1] - resize_shape[1] - left_pad
# 将图像周围填充128
resize_image = tf.pad(resize_image, [[0, 0], [top_pad, bottom_pad], [
left_pad, right_pad], [0, 0]], constant_values=128)
# 类型转化
image_data = tf.cast(resize_image, tf.float32) / 255.
# 对标注框进行调整:进行尺度和平移调整
# 尺度变换
bbox = bbox * tf.convert_to_tensor(
[resize_shape[1], resize_shape[0], resize_shape[1], resize_shape[0]], dtype=tf.float32)
# 除以原图像大小
bbox = bbox / tf.convert_to_tensor(
[img_shape[1], img_shape[0], img_shape[1], img_shape[0]], dtype=tf.float32)
# 平移,获取最终的结果
bbox = bbox + tf.convert_to_tensor(
[left_pad, top_pad, left_pad, top_pad], dtype=tf.float32)
# 返回
return image_data, bbox
经过图像处理的送入到网络中的图像结果为:
# 将VOC_class字典的key和value进行翻转
new_voc_class = {v:k for k,v in voc_classes.items()}
# 将tfrecord中的图像进行展示
plt.figure(figsize=(15, 10))
i=0
# 从raw_datasets中选取3个样本,获取图像,大小,框的标注信息和类别信息
for image, width, height, boxes, boxes_category in raw_datasets.take(3):
# 图像处理
image, boxes = preprocess(image, boxes)
# 进行绘图
plt.subplot(1, 3, i+1)
# 绘制图像
plt.imshow(image[0])
# 获取坐标区域
ax = plt.gca()
# 遍历所有的框
for j in range(boxes.shape[0]):
# 绘制框
rect = Rectangle((boxes[j, 0], boxes[j, 1]), boxes[j, 2] -boxes[j, 0], boxes[j, 3]-boxes[j, 1], color='r', fill=False)
# 将框显示在图像上
ax.add_patch(rect)
# 显示标注信息
# 获取标注信息的id
label_id = boxes_category[j]
# 获取标准信息
label = new_voc_class.get(label_id.numpy())
# 将标注信息添加在图像上
ax.text(boxes[j, 0], boxes[j, 1] + 8, label,color='w', size=11, backgroundcolor="none")
# 下一个结果
i += 1
# 显示图像
plt.show()
效果如下图所示:
yoloV3的模型结构如下所示:整个v3结构里面,没有池化层和全连接层,网络的下采样是通过设置卷积的stride为2来达到的,每当通过这个卷积层之后图像的尺寸就会减小到一半。
基本组件指蓝色方框内部分:
Yolov3网络结构中的最小组件,由Conv+Bn+Leaky_relu激活函数三者组成,
源码实现如下:
def ConvBlock(input_shape, filters, kernel_size, strides=(1, 1), padding=None):
# padding根据步长的大小进行修改
padding = 'valid' if strides == (2, 2) else 'same'
# 输入
inputs = tf.keras.Input(shape=input_shape)
# 卷积层:加入L2正则化的卷积层
conv = tf.keras.layers.Conv2D(filters, kernel_size=kernel_size, strides=strides,
padding=padding, kernel_regularizer=tf.keras.regularizers.l2(l=5e-4))(inputs)
# BN 层
bn = tf.keras.layers.BatchNormalization()(conv)
# 激活函数
relu = tf.keras.layers.LeakyReLU(alpha=0.1)(bn)
# 模型构建
return tf.keras.Model(inputs=inputs, outputs=relu)
残差组件借鉴Resnet网络中的残差结构,让网络可以构建的更深,ResX由一个CBL和X个残差组件构成,是Yolov3中的大组件。每个Res模块前面的CBL都起到下采样的作用。
def ResBlock(input_shape, filters, blocks):
# 指定输入
inputs = tf.keras.Input(shape=input_shape)
# 对输入进行pad
pad = tf.keras.layers.ZeroPadding2D(padding=((1, 0), (1, 0)))(inputs)
# 卷积步长为2
results = ConvBlock(pad.shape[1:], filters=filters,
kernel_size=(3, 3), strides=(2, 2))(pad)
# 构建残差单元
for i in range(blocks):
# 卷积
results_conv = ConvBlock(
results.shape[1:], filters=filters // 2, kernel_size=(1, 1))(results)
# 卷积
results_conv = ConvBlock(
results_conv.shape[1:], filters=filters, kernel_size=(3, 3))(results_conv)
# 融和
results = tf.keras.layers.Add()([results_conv, results])
# 返回模型
return tf.keras.Model(inputs=inputs, outputs=results)
BackBone是DarkNet53构成,用来进行特征提取,主要是ResX模块。
def Body(input_shape):
# 模型输入
inputs = tf.keras.Input(shape=input_shape)
# 卷积结果(batch, 416, 416, 32)
cb = ConvBlock(inputs.shape[1:], filters=32, kernel_size=(3, 3))(
inputs)
# 残差模块 (batch, 208, 208, 64)
rb1 = ResBlock(cb.shape[1:], filters=64, blocks=1)(
cb)
# (batch, 104, 104, 128)
rb2 = ResBlock(rb1.shape[1:], filters=128, blocks=2)(
rb1)
# (batch, 52, 52, 256)
rb3 = ResBlock(rb2.shape[1:], filters=256, blocks=8)(
rb2)
# (batch, 26, 26, 512)
rb4 = ResBlock(rb3.shape[1:], filters=512, blocks=8)(
rb3)
# (batch, 13, 13, 1024)
rb5 = ResBlock(rb4.shape[1:], filters=1024, blocks=4)(
rb4)
return tf.keras.Model(inputs=inputs, outputs=(rb5, rb4, rb3))
输出是3个尺度输出的CBL串联结构:
def Output(input_shape, input_filters, output_filters):
# 输入数据
inputs = tf.keras.Input(shape=input_shape)
# 输出连续的六个模块
cb1 = ConvBlock(
inputs.shape[1:], filters=input_filters, kernel_size=(1, 1))(inputs)
cb2 = ConvBlock(
cb1.shape[1:], filters=input_filters * 2, kernel_size=(3, 3))(cb1)
cb3 = ConvBlock(cb2.shape[1:], filters=input_filters,
kernel_size=(1, 1))(cb2)
cb4 = ConvBlock(
cb3.shape[1:], filters=input_filters * 2, kernel_size=(3, 3))(cb3)
cb5 = ConvBlock(cb4.shape[1:], filters=input_filters,
kernel_size=(1, 1))(cb4)
cb6 = ConvBlock(
cb5.shape[1:], filters=input_filters * 2, kernel_size=(3, 3))(cb5)
# 最后的第七个卷积块
cb7 = ConvBlock(
cb6.shape[1:], filters=output_filters, kernel_size=(1, 1))(cb6)
return tf.keras.Model(inputs=inputs, outputs=(cb5, cb7))
将模型的backbone输出的特征图进行融合后送入到output模块,构建整个yoloV3模型。
def YOLOv3(input_shape, class_num=80):
# anchor数目
anchor_num = 3
# 输入数据
inputs = tf.keras.Input(shape=input_shape)
# 获取backbone输出的3个特征图
large, middle, small = Body(inputs.shape[1:])(inputs)
# 较大目标的检测
x1, y1 = Output(large.shape[1:], 512, anchor_num * (class_num + 5))(large)
# reshape成最终的数据结果
y1 = tf.keras.layers.Reshape(
(input_shape[0] // 32, input_shape[1] // 32, 3, 5 + class_num))(y1)
# 中等目标的检测
cb1 = ConvBlock(x1.shape[1:], filters=256, kernel_size=(1, 1))(x1)
# 上采样
us1 = tf.keras.layers.UpSampling2D(2)(cb1)
# 拼接
cat1 = tf.keras.layers.Concatenate()([us1, middle])
# 计算输出结果
x2, y2 = Output(cat1.shape[1:], 256, anchor_num * (class_num + 5))(cat1)
# reshape成最终的数据结果
y2 = tf.keras.layers.Reshape(
(input_shape[0] // 16, input_shape[1] // 16, 3, 5 + class_num))(y2)
# 较小目标检测
cb2 = ConvBlock(x2.shape[1:], filters=128, kernel_size=(1, 1))(x2)
# 上采样
us2 = tf.keras.layers.UpSampling2D(2)(cb2)
# 拼接
cat2 = tf.keras.layers.Concatenate()([us2, small])
# 计算输出结果
x3, y3 = Output(cat2.shape[1:], 128, anchor_num * (class_num + 5))(cat2)
# reshape成最终的数据结果
y3 = tf.keras.layers.Reshape(
(input_shape[0] // 8, input_shape[1] // 8, 3, 5 + class_num))(y3)
# 返回结果
return tf.keras.Model(inputs=inputs, outputs=(y1, y2, y3))
网络的输出结果是:
坐标是对anchor的修正,将其转换中心点坐标和宽高的形式,在预测过程和计算损失函数时使用。
V3网络输出的结果为$ t_x,t_y,t_w,t_h$ 与边框表示 bx,by,bw,bhbx,by,bw,bh之间的关系是:
cx,cycx,cy是当前网格左上角到图像左上角的距离, pw,phpw,ph是先验框的宽和高。根据上述关系对网络的输出进行修正。
另外对于分类的输出结果应送入到Sigmoid激活函数中进行处理。
在这里我们使用了一个常见的方法,它的作用是把任意的表达式function作为一个“Layer”对象:
keras.layers.Lambda(function, output_shape=None, mask=None, arguments=None)
参数:
转换过程如下:
# 将网络的输出结果转换为bbox的坐标及宽高
def OutputParser(input_shape, img_shape, anchors):
# feats/input_shape的意义:[batch,height,width,anchor_num,(1(delta x) + 1(delta y) + 1(width scale) + 1(height scale) + 1(object mask) + class_num(class probability))]
feats = tf.keras.Input(input_shape)
# 获取网格grid的左上角x,y坐标,对应着cx,cy
# 获取行y的坐标
# 1.使用tf.shape获取feats的高
# 2.使用tf.cast进行类型转换,转换为float32类型
# 3.使用tf.range创建数字序列
# 4.使用tf.reshape进行形状转换为(height,1,1,1)
# 5.使用tf.tile对上述结果按照列数x进行平铺
# 6.使用tf.keras.layers.Lambda转换成层
grid_y = tf.keras.layers.Lambda(lambda x: tf.tile(tf.reshape(tf.range(tf.cast(tf.shape(
x)[1], dtype=tf.float32), dtype=tf.float32), (-1, 1, 1, 1)), (1, tf.shape(x)[2], 1, 1)))(feats)
# 获取列x的坐标
# 1.使用tf.shape获取feats的宽
# 2.使用tf.cast进行类型转换,转换为float32类型
# 3.使用tf.range创建数字序列
# 4.使用tf.reshape进行形状转换为(1,width,1,1)
# 5.使用tf.tile对上述结果按照行数y进行平铺
# 6.使用tf.keras.layers.Lambda转换成层
grid_x = tf.keras.layers.Lambda(lambda x: tf.tile(tf.reshape(tf.range(tf.cast(tf.shape(
x)[2], dtype=tf.float32), dtype=tf.float32), (1, -1, 1, 1)), (tf.shape(x)[1], 1, 1, 1)))(feats)
# 构建grid的网格表示
# grid.shape = (grid h, grid w, 1, 2)
grid = tf.keras.layers.Concatenate(axis=-1)([grid_x, grid_y])
# 获取每一个检测结果中心点坐标:将预测结果转换为中心点坐标
# box_xy = (delta x, delta y) + (priorbox upper left x,priorbox upper left y) / (feature map.width, feature map.height)
# box_xy.shape = (batch, grid h, grid w, anchor_num, 2)
box_xy = tf.keras.layers.Lambda(lambda x: (tf.math.sigmoid(x[0][..., 0:2]) + x[1]) / tf.cast(
[tf.shape(x[1])[1], tf.shape(x[1])[0]], dtype=tf.float32))([feats, grid])
# box_wh.shape = (batch, grid h, grid w, anchor_num, 2)
# 获取检测结果的宽高
# box_wh = (width scale, height scale) * (anchor width, anchor height) / (image.width, image.height)
box_wh = tf.keras.layers.Lambda(lambda x, y, z: tf.math.exp(x[..., 2:4]) * y / tf.cast(
[z[1], z[0]], dtype=tf.float32), arguments={'y': anchors, 'z': img_shape})(feats)
# 获取某一个anchor中包含目标的概率
box_confidence = tf.keras.layers.Lambda(
lambda x: tf.math.sigmoid(x[..., 4]))(feats)
# 获取某一个anchor属于某一个类别的概率
box_class_probs = tf.keras.layers.Lambda(
lambda x: tf.math.sigmoid(x[..., 5:]))(feats)
# 返回输出结果
return tf.keras.Model(inputs=feats, outputs=(box_xy, box_wh, box_confidence, box_class_probs))
YoloV3的损失函数分为三部分:
只有负责检测的gridcell中的anchor才会计入损失,对x,y,w,h分别求均方误差
置信度的损失是二分类的交叉熵损失函数,所有的box都计入损失计算
分类的损失是二分类的交叉熵损失,只有负责检测目标的才计算损失
def Loss(img_shape, class_num=80):
# anchor的尺度:分别检测小,中,大的目标
anchors = {2: [[10, 13], [16, 30], [33, 23]], 1: [[30, 61], [
62, 45], [59, 119]], 0: [[116, 90], [156, 198], [373, 326]]}
# 构建计算损失函数的数组
input_shapes = [
(img_shape[0] // 32, img_shape[1] // 32, 3, 5 + class_num),
(img_shape[0] // 16, img_shape[1] // 16, 3, 5 + class_num),
(img_shape[0] // 8, img_shape[1] // 8, 3, 5 + class_num)
]
# 网络的输出值
inputs = [tf.keras.Input(input_shape) for input_shape in input_shapes]
# 目标值
labels = [tf.keras.Input(input_shape) for input_shape in input_shapes]
losses = list()
# 遍历三个尺度的输出
for l in range(3):
# 获取当前尺度的形状
input_shape_of_this_layer = input_shapes[l]
# 获取当前尺度的anchor
anchors_of_this_layer = anchors[l]
# 获取网络输出
input_of_this_layer = inputs[l]
# 获取对应的目标值
label_of_this_layer = labels[l]
# YOLOV3模型输出的结果:中心点坐标,宽高,置信度
pred_xy, pred_wh, pred_box_confidence, pred_class = OutputParser(
input_shape_of_this_layer, img_shape, anchors_of_this_layer)(input_of_this_layer)
# 预测框
pred_box = tf.keras.layers.Concatenate()([pred_xy, pred_wh])
# 真实值
true_box = tf.keras.layers.Lambda(
lambda x: x[..., 0:4])(label_of_this_layer)
true_box_confidence = tf.keras.layers.Lambda(
lambda x: x[..., 4])(label_of_this_layer)
true_class = tf.keras.layers.Lambda(
lambda x: x[..., 5:])(label_of_this_layer)
# 获取box的置信度
object_mask = tf.keras.layers.Lambda(
lambda x: tf.cast(x, dtype=tf.bool))(true_box_confidence)
# 计算MSE损失:只有正样本参与损失计算
pos_loss = tf.keras.layers.Lambda(lambda x:
tf.math.reduce_sum(tf.keras.losses.MSE(
tf.boolean_mask(x[0], x[2]),
tf.boolean_mask(x[1], x[2])
))
)([true_box, pred_box, object_mask])
# 置信度的损失:交叉熵损失
confidence_loss = tf.keras.layers.Lambda(lambda x:
# 正样本的损失
tf.keras.losses.BinaryCrossentropy(from_logits=False)(
tf.boolean_mask(
x[0], x[2]),
tf.boolean_mask(
x[1], x[2])
) +
# 负样本的损失
100 * tf.keras.losses.BinaryCrossentropy(from_logits=False)(
tf.boolean_mask(
x[0], tf.math.logical_not(x[2])),
tf.boolean_mask(
x[1], tf.math.logical_not(x[2]))
)
)([true_box_confidence, pred_box_confidence, object_mask])
# 分类损失:只有正样本计算损失
class_loss = tf.keras.layers.Lambda(lambda x:
tf.keras.losses.BinaryCrossentropy(from_logits=False)(
tf.boolean_mask(x[0], x[2]),
tf.boolean_mask(x[1], x[2])
)
)([true_class, pred_class, object_mask])
# 损失结果
loss = tf.keras.layers.Lambda(lambda x: tf.math.add_n(x))(
[pos_loss, confidence_loss, class_loss])
losses.append(loss)
# 计算损失值
loss = tf.keras.layers.Lambda(lambda x: tf.math.add_n(x))(losses)
return tf.keras.Model(inputs=(*inputs, *labels), outputs=loss)
在上述的loss计算中,负责进行目标预测的anchor就是正样本,而不负责进行目标预测的就是负样本,也就是背景,那在这里我们是如何设置正负样本的呢?如下图所示:
在实现的时候,为了提高计算速度做了优化,在计算是否为正样本时,我们认为anchor和目标的中心点是相同的,直接利用anchor和目标box的宽高计算交并比,确定正样本。实现如下:
YOLOv3_anchors = np.array([[10, 13], [16, 30], [33, 23], [30, 61], [62, 45], [
59, 119], [116, 90], [156, 198], [373, 326]], dtype=np.int32)
def bbox_to_tensor(bbox, label, input_shape=(416, 416), anchors=YOLOv3_anchors, num_classes=80):
# bbox:真实值坐标表示为(xmin,ymin,xmax,ymax),是相对坐标
# label: 每个bbox的类别
# anchors = (9,2)
# 返回:anchor对应的真实值,即正负样本的标记结果
# 获取有几个尺度的输出,每个尺度对应3个anchor:3
num_layers = anchors.shape[0] // 3
# anchor对应的特征图掩码:第一个特征图对应第6,7,8个anchor...
anchor_mask = tf.cond(tf.equal(num_layers, 3), lambda: tf.constant(
[[6, 7, 8], [3, 4, 5], [0, 1, 2]]), lambda: tf.constant([[3, 4, 5], [1, 2, 3]]))
# bbox的相对中心点坐标
true_boxes_xy = (bbox[..., 0:2] + bbox[..., 2:4]) / 2.
# bbox的相对宽高
true_boxes_wh = tf.math.abs(bbox[..., 2:4] - bbox[..., 0:2])
# bbox的结果:将中心点坐标和宽高拼接在一起
true_boxes = tf.concat([true_boxes_xy, true_boxes_wh], axis=-1)
# bbox的绝对坐标和绝对宽高
boxes_xy = true_boxes[..., 0:2] * input_shape
boxes_wh = true_boxes[..., 2:4] * input_shape
# 生成与yoloV3输出结果相同大小的全0数组:y_true.shape[layer] = (height, width, anchor num, 5 + class num)
y_true = tuple((np.zeros(shape=(input_shape[0] // {0: 32, 1: 16, 2: 8}[l], input_shape[1] // {0: 32, 1: 16, 2: 8}[
l], tf.shape(anchor_mask[l, ...])[0], 5 + num_classes), dtype=np.float32) for l in range(num_layers)))
# 扩展一个维度,用来存放anchor的索引
anchors = tf.expand_dims(tf.convert_to_tensor(anchors, dtype=tf.float32), 0)
# 用于计算交并比
# 以anchor中心为原点,计算右下角坐标
anchor_maxes = anchors / 2.
# 以anchor中心为原点,计算左上角坐标
anchor_mins = -anchor_maxes
# 创建一个mask,指明目标是否存在,宽度大于0的认为是真实的目标
valid_mask = tf.greater(boxes_wh[..., 0], 0)
# 获取真实的目标的宽高
wh = tf.boolean_mask(boxes_wh, valid_mask)
# 获取真实目标的box:valid_true_boxes.shape = (valid box num, 4)
valid_true_boxes = tf.boolean_mask(boxes, valid_mask)
# 获取真实目标的标签值:valid_label.shape = (valid box num)
valid_label = tf.boolean_mask(label, valid_mask)
# 当图像中存在目标时,计算与目标交并比最大的anchor作为正样本,并设置标记结果
if wh.shape[0] > 0:
# 扩展一个维度,用来存放对应的anchor:wh.shape = (valid box num, 1, 2)
wh = tf.expand_dims(wh, -2)
# 以box的中心点为原点:计算右下角坐标:max of width, height, box_maxes.shape = (valid box num, 1, 2)
box_maxes = wh / 2
# 以box的中心点为原点:计算左上角坐标:min of width, height, box_mins.shape = (valid box num, 1, 2)
box_mins = -box_maxes
# 计算box与anchor交的左上角坐标:intersect_mins.shape = (valid box num, anchor num(9), 2)
intersect_mins = tf.math.maximum(box_mins, anchor_mins)
# 计算box与anchor交的右下角坐标:intersect_maxes.shape = (valid box num, anchor num(9), 2)
intersect_maxes = tf.math.minimum(box_maxes, anchor_maxes)
# 计算交集的宽高:intersect_wh.shape = (valid box num, anchor num(9), 2)
intersect_wh = tf.math.maximum(intersect_maxes - intersect_mins, 0.)
# 计算交集的面积:intersect_area.shape = (valid box num, anchor num(9))
intersect_area = intersect_wh[..., 0] * intersect_wh[..., 1]
# 计算box的面积:box_area.shape = (valid box_num, 1)
box_area = wh[..., 0] * wh[..., 1]
# 计算anchor的面积:anchor_area.shape = (1, anchor num(9))
anchor_area = anchors[..., 0] * anchors[..., 1]
# 计算交并比:iou.shape = (valid box num, anchor num(9))
iou = intersect_area / (box_area + anchor_area - intersect_area)
# 计算与box交并比最大的anchor,将其作为正样本:best_anchor.shape = (valid box num)
best_anchor = tf.math.argmax(iou, axis=-1, output_type=tf.int32)
# 遍历与box匹配成功的anchor
for t in range(tf.shape(best_anchor)[0]):
# 获取第t个anchor
n = best_anchor[t]
# 获取anchor的位置
pos = tf.where(tf.equal(anchor_mask, n))
# 获取尺度值:0,1,2
l = pos[0][0]
# 获取对应的anchor索引
k = pos[0][1]
# 获取anchor对应的grid cell的列数,限制在0到最大值之间
i = int(tf.clip_by_value(
valid_true_boxes[t, 1] * y_true[l].shape[0], clip_value_min=0, clip_value_max=y_true[l].shape[0] - 1))
# 获取anchor对应的grid cell的行数,限制在0到最大值之间
j = int(tf.clip_by_value(
valid_true_boxes[t, 0] * y_true[l].shape[1], clip_value_min=0, clip_value_max=y_true[l].shape[1] - 1))
# 获取anchor的类别
c = valid_label[t]
# box的位置:(x,y,width,height)
y_true[l][i, j, k, 0:4] = valid_true_boxes[t, 0:4]
# 匹配上的都包含目标,置信度设为1
y_true[l][i, j, k, 4] = 1
# 类别信息
y_true[l][i, j, k, 5 + c] = 1
# 返回3个尺度对应的真实值
return (tf.convert_to_tensor(y_true[0]), tf.convert_to_tensor(y_true[1]), tf.convert_to_tensor(y_true[2]))
接下来我们利用已搭建好的网络和数据进行模型训练:
1.定义方法进行数据处理和获取目标值
def map_function_impl(image, bbox, label):
# 图像尺度调整
image, bbox = preprocess(image, bbox, random=True)
# 获取对应的目标值
label1, label2, label3 = bbox_to_tensor(bbox, label)
# 返回结果
return image, label1, label2, label3
2.使用py_function来提高性能
def map_function(image, width, height, boxes, boxes_category):
# 对数据进行处理,获取图像及目标值:提升性能
image, label1, label2, label3 = tf.py_function(map_function_impl, inp=[
image, boxes, boxes_category], Tout=[tf.float32, tf.float32, tf.float32, tf.float32])
# 对图像和目标值进行尺度调整
image = tf.reshape(image, (416, 416, 3))
label1 = tf.reshape(label1, (13, 13, 3, 85))
label2 = tf.reshape(label2, (26, 26, 3, 85))
label3 = tf.reshape(label3, (52, 52, 3, 85))
# 返回结果
return image, (label1, label2, label3)
3.使用map方法对从TFRcords中读取的数据进行处理
# 从TFRecord文件中获取数据,并进行处理
batch_size=10
trainset = raw_datasets.map(map_function).shuffle(batch_size).batch(
batch_size).prefetch(tf.data.experimental.AUTOTUNE)
yolov3 = YOLOv3((416, 416, 3,), 20)
yolov3_loss = Loss((416,416,3), 20)
# 定义优化方法
optimizer = tf.keras.optimizers.Adam(1e-4)
1.定义tf.GradientTape的作用域,计算损失值
2.使用 tape.gradient(ys, xs)自动计算梯度
3.使用 optimizer.apply_gradients(grads_and_vars)自动更新模型参数
完成网络训练,并保存训练结果
# 遍历图像和目标值,进行更新
for images, labels in trainset:
# 定义作用域
with tf.GradientTape() as tape:
# 将图像送入网络中
outputs = yolov3(images)
# 计算损失函数
loss = yolov3_loss([*outputs, *labels])
# 计算梯度
grads = tape.gradient(loss, yolov3.trainable_variables)
try:
# 进行梯度检查
grads_check = [tf.debugging.check_numerics(
grad, 'the grad is not correct! cancel gradient apply!') for grad in grads]
with tf.control_dependencies(grads_check):
# 梯度更新
optimizer.apply_gradients(
zip(grads, yolov3.trainable_variables))
except BaseException as e:
print(e.message)
# 保存模型训练结果
yolov3.save('yolov3.h5')
我们使用训练好的模型进行预测,在这里我们通过yoloV3模型进行预测,预测之后转换为绝对坐标后,获取多个尺度的预测结果拼接在一起,使用NMS进行检测框的筛选。
首先定义预测类:
# 定义预测类
class Predictor(object):
指明anchor的大小:
# anchorbox的大小
anchors = {2: [[10, 13], [16, 30], [33, 23]], 1: [[30, 61], [
62, 45], [59, 119]], 0: [[116, 90], [156, 198], [373, 326]]}
进行模型初始化
# 初始化
def __init__(self, input_shape=(416, 416, 3), class_num=80, yolov3=None):
# 输入大小
self.input_shape = input_shape
# 模型初始化
self.yolov3 = tf.keras.models.load_model('yolov3.h5', compile = False)
# 将结果转换为坐标值
self.parsers = [OutputParser(tuple(
self.yolov3.outputs[l].shape[1:]), self.input_shape, self.anchors[l]) for l in range(3)]
在这里加入NMS方法:
def predict(self, image, conf_thres=0.5, nms_thres=0.5):
# conf_thres:置信度的阈值,NMS中交并比的阈值
# 增加一维batch
images = tf.expand_dims(image, axis=0)
# 图像变形
resize_images = tf.image.resize(
images, self.input_shape[:2], method=tf.image.ResizeMethod.BICUBIC, preserve_aspect_ratio=True)
# 图像变形后的大小
resize_shape = resize_images.shape[1:3]
# 图像在上下左右填充的大小
top_pad = (self.input_shape[0] - resize_shape[0]) // 2
bottom_pad = self.input_shape[0] - resize_shape[0] - top_pad
left_pad = (self.input_shape[1] - resize_shape[1]) // 2
right_pad = self.input_shape[1] - resize_shape[1] - left_pad
# 填充为128
resize_images = tf.pad(resize_images, [[0, 0], [top_pad, bottom_pad], [
left_pad, right_pad], [0, 0]], constant_values=128)
# 标准差
deviation = tf.constant([left_pad / self.input_shape[1],
top_pad / self.input_shape[0], 0, 0], dtype=tf.float32)
# 尺度的变换
scale = tf.constant([
self.input_shape[1] /
resize_shape[1], self.input_shape[0] / resize_shape[0],
self.input_shape[1] /
resize_shape[1], self.input_shape[0] / resize_shape[0]
], dtype=tf.float32)
# 类型转换
images_data = tf.cast(resize_images, tf.float32) / 255.
# 输出结果
outputs = self.yolov3(images_data)
# 目标值
whole_targets = tf.zeros((0, 6), dtype=tf.float32)
# 遍历每一个尺度
for i in range(3):
# 获取预测的位置、置信度和分类结果
pred_xy, pred_wh, pred_box_confidence, pred_class = self.parsers[i](
outputs[i])
# 获取目标框的位置
pred_box = tf.keras.layers.Concatenate(axis=-1)([pred_xy, pred_wh])
#目标框的置信度大于阈值的部分:target_mask.shape = (h, w, anchor num)
target_mask = tf.greater(pred_box_confidence, conf_thres)
# 获取大于阈值的部分的置信度:pred_box_confidence = (pred target num, 1)
pred_box_confidence = tf.boolean_mask(
pred_box_confidence, target_mask)
# 在最后增加一维
pred_box_confidence = tf.expand_dims(pred_box_confidence, axis=-1)
# 获取对应的目标框检测结果 pred_box.shape = (pred target num, 4)
pred_box = tf.boolean_mask(pred_box, target_mask)
# 归一化处理
pred_box = (pred_box - deviation) * scale * \
[image.shape[1], image.shape[0], image.shape[1], image.shape[0]]
# 分类结果:pred_class.shape = (pred target num, 1)
pred_class = tf.boolean_mask(pred_class, target_mask)
# 获取每个类别最大的索引
pred_class = tf.math.argmax(pred_class, axis=-1)
# 类型转换
pred_class = tf.cast(tf.expand_dims(
pred_class, axis=-1), dtype=tf.float32)
# 将预测结果拼接在一起 targets,sgaoe = (pred target num, 6)
targets = tf.keras.layers.Concatenate(
axis=-1)([pred_box, pred_box_confidence, pred_class])
# 将多个尺度的结果拼接在一起
whole_targets = tf.keras.layers.Concatenate(
axis=0)([whole_targets, targets])
# 进行NMS,排序以置信度排序,从大到小排序
descend_idx = tf.argsort(whole_targets[..., 4], direction='DESCENDING')
i = 0
# 遍历
while i < descend_idx.shape[0]:
# 获取索引值
idx = descend_idx[i]
# 左上角坐标
cur_upper_left = whole_targets[idx,0:2] - whole_targets[idx, 2:4] / 2
# 右下角坐标
cur_down_right = cur_upper_left + whole_targets[idx, 2:4]
# 宽高
wh = whole_targets[idx, 2:4]
# 获取面积
area = wh[..., 0] * wh[..., 1]
# 下一个检测框的索引
following_idx = descend_idx[i+1:]
# 下一个检测框
following_targets = tf.gather(whole_targets, following_idx)
# 下一个检测框的左上角坐标
following_upper_left = following_targets[...,0:2] - following_targets[..., 2:4] / 2
# 下一个检测框的右下角坐标
following_down_right = following_upper_left + following_targets[..., 2:4]
# 下一个检测框的宽高
following_wh = following_targets[..., 2:4]
# 下一个检测框的面积
following_area = following_wh[..., 0] * following_wh[..., 1]
# 计算交并比
# 计算交的左上角坐标
max_upper_left = tf.math.maximum(cur_upper_left, following_upper_left)
# 计算交的右下角坐标
min_down_right = tf.math.minimum(cur_down_right, following_down_right)
# 交的宽高
intersect_wh = min_down_right - max_upper_left
# 将宽高大于0,保持不变,小于0的置为0
intersect_wh = tf.where(tf.math.greater(intersect_wh, 0), intersect_wh, tf.zeros_like(intersect_wh))
# 计算交的面积
intersect_area = intersect_wh[..., 0] * intersect_wh[..., 1]
# 计算交并比
overlap = intersect_area / (area + following_area - intersect_area)
# 获取小于NMS阈值的保留,其他的舍弃
indices = tf.where(tf.less(overlap, nms_thres))
# 进行切片,保留结果
following_idx = tf.gather_nd(following_idx, indices)
# 将其添加到descend中即可
descend_idx = tf.concat([descend_idx[:i + 1], following_idx], axis=0)
i += 1
# 获取最终的结果
whole_targets = tf.gather(whole_targets, descend_idx)
# 左上角坐标
upper_left = (whole_targets[..., 0:2] - whole_targets[..., 2:4] / 2)
# 右下角坐标
down_right = (upper_left + whole_targets[..., 2:4])
# 获取检测结果
boundings = tf.keras.layers.Concatenate(axis=-1)([upper_left, down_right, whole_targets[..., 4:]])
return boundings
模型的预测效果:
import cv2
import numpy as np
import matplotlib.pyplot as plt
# 图像读取
img = cv2.imread("image.jpg")
# 实例化
predictor = Predictor()
# 获取结果
boundings = predictor.predict(img)
# 显示图像
plt.imshow(img[:, :, ::-1])
# 获取坐标区域
ax = plt.gca()
# 加载模型:模型训练是在COCO数据集中进行的,
# coco数据集中的类别信息
classes = ['person', 'bicycle', 'car', 'motorcycle', 'airplane', 'bus',
'train', 'truck', 'boat', 'traffic light', 'fire hydrant',
'stop sign', 'parking meter', 'bench', 'bird', 'cat', 'dog',
'horse', 'sheep', 'cow', 'elephant', 'bear', 'zebra', 'giraffe',
'backpack', 'umbrella', 'handbag', 'tie', 'suitcase', 'frisbee',
'skis', 'snowboard', 'sports ball', 'kite', 'baseball bat', 'baseball glove',
'skateboard', 'surfboard','tennis racket', 'bottle', 'wine glass', 'cup', 'fork',
'knife', 'spoon', 'bowl', 'banana', 'apple', 'sandwich', 'orange', 'broccoli',
'carrot', 'hot dog', 'pizza', 'donut', 'cake', 'chair', 'couch', 'potted plant',
'bed', 'dining table', 'toilet', 'tv', 'laptop', 'mouse', 'remote', 'keyboard',
'cell phone', 'microwave', 'oven', 'toaster', 'sink', 'refrigerator', 'book',
'clock', 'vase', 'scissors', 'teddy bear', 'hair drier', 'toothbrush']
for bounding in boundings:
# 绘制框
rect = Rectangle((bounding[0].numpy(), bounding[1].numpy()), bounding[2].numpy(
) - bounding[0].numpy(), bounding[3].numpy()-bounding[1].numpy(), color='r', fill=False)
# 将框显示在图像上
ax.add_patch(rect)
# 显示类别信息
# 获取类别信息的id
label_id = bounding[5].numpy().astype('int32')
# 获取类别
label = classes[label_id]
# 将标注信息添加在图像上
ax.text(bounding[0].numpy(), bounding[1].numpy() + 8,
label, color='w', size=11, backgroundcolor="none")
# 下一个结果
# 显示图像
plt.show()
预测结果如下图所示:
总结
TFRecord是Google官方推荐使用的数据格式化存储工具,为TensorFlow量身打造的。TFRecord内部包含多个tf.train.Example,一般来说对应一个图像数据,在一个Example消息体中包含了一系列的tf.train.feature属性,而 每一个feature是一个key-value的键值对。
基本组件的构建,backbone,output, yoloV3, 输出值的转换
知道对图像进行resize,保持宽高比,进行pad的方法
, ‘umbrella’, ‘handbag’, ‘tie’, ‘suitcase’, ‘frisbee’,
‘skis’, ‘snowboard’, ‘sports ball’, ‘kite’, ‘baseball bat’, ‘baseball glove’,
‘skateboard’, ‘surfboard’,‘tennis racket’, ‘bottle’, ‘wine glass’, ‘cup’, ‘fork’,
‘knife’, ‘spoon’, ‘bowl’, ‘banana’, ‘apple’, ‘sandwich’, ‘orange’, ‘broccoli’,
‘carrot’, ‘hot dog’, ‘pizza’, ‘donut’, ‘cake’, ‘chair’, ‘couch’, ‘potted plant’,
‘bed’, ‘dining table’, ‘toilet’, ‘tv’, ‘laptop’, ‘mouse’, ‘remote’, ‘keyboard’,
‘cell phone’, ‘microwave’, ‘oven’, ‘toaster’, ‘sink’, ‘refrigerator’, ‘book’,
‘clock’, ‘vase’, ‘scissors’, ‘teddy bear’, ‘hair drier’, ‘toothbrush’]
for bounding in boundings:
# 绘制框
rect = Rectangle((bounding[0].numpy(), bounding[1].numpy()), bounding[2].numpy(
) - bounding[0].numpy(), bounding[3].numpy()-bounding[1].numpy(), color=‘r’, fill=False)
# 将框显示在图像上
ax.add_patch(rect)
# 显示类别信息
# 获取类别信息的id
label_id = bounding[5].numpy().astype(‘int32’)
# 获取类别
label = classes[label_id]
# 将标注信息添加在图像上
ax.text(bounding[0].numpy(), bounding[1].numpy() + 8,
label, color=‘w’, size=11, backgroundcolor=“none”)
# 下一个结果
plt.show()
预测结果如下图所示:
[外链图片转存中...(img-4wDe0yzV-1652848417340)]
------
**总结**
- 熟悉TFRecord文件的使用方法
TFRecord是Google官方推荐使用的数据格式化存储工具,为TensorFlow量身打造的。TFRecord内部包含多个tf.train.Example,一般来说对应一个图像数据,在一个Example消息体中包含了一系列的tf.train.feature属性,而 每一个feature是一个key-value的键值对。
- 知道YoloV3模型结构及构建方法
基本组件的构建,backbone,output, yoloV3, 输出值的转换
- 知道数据处理方法
知道对图像进行resize,保持宽高比,进行pad的方法
- 能够利用yoloV3模型进行训练和预测
知道损失函数,正负样本设置,进行训练,并预测的过程。