1. 环境配置
1.1 Anaconda安装
1.2 Pycharm安装
1.3 TensorFlow安装
2. 训练数据集准备
2.1 数据集标注
3. 训练数据集预处理
3.1 解析标签文件XML
3.2 读取图片
4. 真实标签格式处理
4.1 单张图片
4.2 批量图片
5. 模型搭建与权重初始化
5.1 模型搭建
5.2 权重初始化
6. 损失计算
6.1 制作网格坐标
6.2 坐标损失计算
6.3 类别损失计算
6.4 置信度计算
7. 模型训练与保存
8. 模型验证
运行环境:
Python:3.6
TensorFlow: 2.0.0+
cuda: 10.0
cundnn: 7.4
Pycharm: 发行版
我使用的是Windows系统,当然,使用Ubuntu也可以,没有什么区别。
下载Anaconda3,下载链接:https://pan.baidu.com/s/1xzrb7kqigl5SYigVO2NdWw,提取码:41tg
将Anaconda3下载完成后,然后安装。
下载Pycharm, 下载链接:https://pan.baidu.com/s/1SOhs72JK9YY6GAFImrwdBQ,提取码:bqsn
将Pycharm下载完成后,然后安装
1. 创建一个Python虚拟环境,使用Anaconda Prompt 或者 Anaconda Navigator都可以,我使用的是Prompt, ubuntu系统可以使用终端或者Navigator。
conda create -n Tensorflow-GPU python=3.6
环境的名字可以任意选择。
2. 激活环境,在该环境中安装TensorFlow2.0,我这里介绍一种简单的方法。
conda install tensorflow-gpu==2.0.0 #gpu版本
# conda install tensorflow==2.0.0 #cpu版本
通过该命令会将TensorFlow-gpu版本自动安装成功,包含配套的cuda, cudnn。在ubuntu上一样的命令,如果安装失败,一般都是因为网速的问题,可以考虑将conda的源换为国内源,这里就不再多赘述,CSDN中有很多博客介绍。
3. 打开Pycharm配置环境即可。
目标检测数据集一般是VOC格式的,YOLO与SSD都是这种格式。
1. 首先将采集好的原图,全部resize成网络输入的尺寸,比如YOLOV2的输入尺寸是512X512。
# -*- coding: utf-8 -*-
import cv2
import os
def rebuild(path_src, path_dst, width, height):
"""
:param path_src: 原图相对地址
:param path_dst: 保存图相对地址
:return: None
"""
i = 1
image_names = os.listdir(path_src)
for image in image_names:
if image.endswith('.jpg') or image.endswith('.png'):
img_path = path_src + image
save_path = path_dst + image
img = cv2.imread(img_path)
resize_img = cv2.resize(img, (width, height))
cv2.imwrite(save_path, resize_img)
print("修改第 " + str(i), " 张图片:", save_path)
i = i + 1
if __name__ == "__main__":
# 原图相对地址,也可以使用绝对地址
path_src = "pikachu/"
# 保存图相对地址,也可以使用绝对地址
path_dst = "pikachu_new/"
width = 512
heght = 512
rebuild(path_src, path_dst, width, heght)
2. 使用labelImg进行目标标注,使用别的标注工具也可以
labelImg安装方法1:直接下载软件,然后放在桌面双击打开即可,不需要安装
链接:https://pan.baidu.com/s/1_wdd_tChBCrfcicKC-Nxgg 提取码:tsz7
labelImg安装方法2:去github下载源码编译, github链接:https://github.com/tzutalin/labelImg
请下载文件://download.csdn.net/download/qq_37116150/12289197
该文件包含完整代码
每张图片的标签信息全部保存在.xml(使用labelImg标注图片生成的文件)文件中,标签文件中包含原图路径,原图名,目标位置信息(左上角坐标,右下角坐标,够成一个矩形框),类别名,我们需要的是原图路径, 目标位置信息以及类别名,所有我们需要将这些信息从xml标签文件中提取出来。
xml_parse.py, 可将该文件直接下载下来,由于YOLO整个项目比较大,代码量比较多,所以分成几个文件,一起编写。
# -*- coding: utf-8 -*-
import os, glob
import numpy as np
import xml.etree.ElementTree as ET
"""
该文件主要用于解析xml文件,同时返回原图片的路径与标签中目标的位置信息以及类别信息
"""
def paras_annotation(img_dir, ann_dir, labels):
"""
:param img_dir: image path
:param ann_dir: annotation xml file path
:param labels: ("class1", "class2",...,), 背景默认为0
:function: paras annotation info from xml file
:return:
"""
imgs_info = [] #存储所有图片信息的容器列表
max_boxes = 0 #计算所有图片中,目标在一张图片中所可能出现的最大数量
# for each annotation xml file
for ann in os.listdir(ann_dir): # 遍历文件夹中所有的xml文件, 返回值是xml的地址
tree = ET.parse(os.path.join(ann_dir, ann)) #使用xml内置函数读取xml文件,并返回一个可读取节点的句柄
img_info = dict() # 为每一个标签xml文件创建一个内容存放容器字典
boxes_counter = 0 # 计算该标签文件中所含有的目标数量
# 由于每张标签中,目标存在数量可能大于1, 所有将object内容格式设置为列表,以存放多个object
img_info['object'] = []
for elem in tree.iter(): # 遍历xml文件中所有的节点
if 'filename' in elem.tag: # 读取文件名,将文件绝对路径存储在字典中
img_info['filename'] = os.path.join(img_dir, elem.text)
# 读取标签中目标的宽,高, 通道默认为3不进行读取
if 'width' in elem.tag:
img_info['width'] = int(elem.text)
# assert img_info['width'] == 512 #用于断言图片的宽高为512 512
if 'height' in elem.tag:
img_info['height'] = int(elem.text)
# assert img_info['height'] == 512
if 'object' in elem.tag or 'part' in elem.tag: # 读取目标框的信息
# 目标框信息存储方式:x1-y1-x2-y2-label
object_info = [0, 0, 0, 0, 0] # 创建存储目标框信息的容器列表
boxes_counter += 1
for attr in list(elem): # 循环读取子节点
if 'name' in attr.tag: # 目标名
label = labels.index(attr.text) + 1 # 返回索引值 并加1, 因为背景为0
object_info[4] = label
if 'bndbox' in attr.tag: # bndbox的信息
for pos in list(attr):
if 'xmin' in pos.tag:
object_info[0] = int(pos.text)
if 'ymin' in pos.tag:
object_info[1] = int(pos.text)
if 'xmax' in pos.tag:
object_info[2] = int(pos.text)
if 'ymax' in pos.tag:
object_info[3] = int(pos.text)
# object shape: [n, 5],是一个列表,但包含n个子列表,每个子列表有5个内容
img_info['object'].append(object_info)
imgs_info.append(img_info) # filename, w/h/box_info
# (N,5)=(max_objects_num, 5)
if boxes_counter > max_boxes:
max_boxes = boxes_counter
# the maximum boxes number is max_boxes
# 将读取的object信息转化为一个矩阵形式:[b, max_objects_num, 5]
boxes = np.zeros([len(imgs_info), max_boxes, 5])
# print(boxes.shape)
imgs = [] # filename list
for i, img_info in enumerate(imgs_info):
# [N,5]
img_boxes = np.array(img_info['object']) # img_boxes.shape[N, 5]
# overwrite the N boxes info
boxes[i, :img_boxes.shape[0]] = img_boxes
imgs.append(img_info['filename']) # 文件名
# print(img_info['filename'], boxes[i,:5])
# imgs: list of image path
# boxes: [b,40,5]
return imgs, boxes
# 测试代码
# if __name__ == "__main__":
# img_path = "data\\val\\image" #图片路径
# annotation_path = "data\\val\\annotation" # 标签路径
# label = ("sugarbeet", "weed") # 自定义的标签名字,背景不写,默认为0
#
# img, box = paras_annotation(img_path, annotation_path, label)
# print(img[0])
# print(box.shape)
# print(box[0])
paras_annotation返回值imgs, boxes, 其中imgs是个列表,它包含了每张图片的路径,boxes是一个三维矩阵,它包含了每张图片的所有目标位置与类别信息,所以它的shape是[b, max_boxes, 5],b: 图片数量,max_boxes: 所有图片中最大目标数,比如图片A有3个目标,图片B有4个目标,图片C有10个目标,则最大目标数就是10;5: x_min, y_min, x_max, y_max, label(在xml中就是name)。
之所以有max_boxes这个参数设置,是为了将所有的标签文件的信息都放在一个矩阵变量中。因为每张图片的目标数必然是不一样的,如果不设置max_boxes这个参数,就无法将所有的标签文件信息合在一个矩阵变量中。如果一个图片的目标数不够max_boxes怎么办,例如图片A有3个目标,max_boxes是10,则假设图片A有10个目标,只是将后7个目标的数据全部置为0,前三个目标的数据赋值于它原本的数值,这也是开始为什么用np.zeros()初始化boxes。
请下载文件://download.csdn.net/download/qq_37116150/12289208
该文件包含完整代码
我们训练需要的是图片的内容信息,不是路径,所以我们需要通过图片路径来读取图片,以获得图片信息,通过3.1可以获得所有训练图片的路径。
def preprocess(img, img_boxes):
# img: string
# img_boxes: [40,5]
x = tf.io.read_file(img)
x = tf.image.decode_png(x, channels=3)
x = tf.image.convert_image_dtype(x, tf.float32) # 将数据转化为 =>[0~ 1]
return x, img_boxes
使用tensorflow自带的读取图片函数tf.io.read_file来读取图片,不用使用for循环一个一个的读取图片,然后使用tf.image.decode_png将图片信息解码出来,如果你的训练图片是jpg,则使用tf.image.decode_jpeg来解码。tf.image.convert_image_dtype(x, tf.float32)可将数据直接归一化并将数据格式转化为tf.float32格式。
为了更加方便训练,我们需要构建一个tensorflow队列,将解码出来的图片数据与标签数据一起加载进队列中,而且通过这种方式,也可以使图片数据与标签数据一一对应,不会出现图片与标签对照絮乱的情况。
def get_datasets(img_dir, ann_dir,label,batch_size=1):
imgs, boxes = paras_annotation(img_dir, ann_dir, label)
db = tf.data.Dataset.from_tensor_slices((imgs, boxes))
db = db.shuffle(1000).map(preprocess).batch(batch_size=batch_size).repeat()
# db = db.map(preprocess).batch(batch_size=batch_size).repeat()
return db
通过该函数也可以动态的调节训练数据集批量。
最后就是做数据增强,由于代码较多,就不再赘述,可下载文件观看。
通过3.1,3.2,我们就得到了用于训练的数据队列,该队列中包含图片数据,真实标签数据。
请下载文件://download.csdn.net/download/qq_37116150/12289213
该文件包含完整代码
到了这一步,训练数据预处理算是完成了一小半,后面则是更加重要的训练数据预处理。首先,我们要明白一个问题,目标检测和目标分类是不一样的。目标分类的输出是一个二维张量[batch, num_classes],目标分类的真实标签通过热编码后也是一个二维张量,所有不需要多做处理,只做一个one-hot就可以啦。而目标检测的输出并不是一个二维张量,比如YOLOV2输出的就是五维张量 [batch, 16, 16, 5, 25]。而我们的标签shape则是[batch, max_boxes, 5],明显真实标签shape与网络预测输出shape不一致,无法做比较,损失函数就不能完成,为了完成损失函数或者说是真实标签与网络预测输出作比较,需要修改真实标签的形状。在修改真实标签shape之前,需要了解YOLOV2的损失函数是由几部分构成的。
YOLOV2损失函数包含三部分:
类别损失: class,根据自己的标签设定
置信度损失: confidence, anchors与真实框的IOU
针对损失函数,需要预先准备四个变量,分别是真实标签掩码,五维张量的真实标签,转换格式的三维张量真实标签,只包含类别的五维张量。请看具体代码:
def process_true_boxes(gt_boxes, anchors):
"""
计算一张图片的真实标签信息
:param gt_boxes:
:param anchors:YOLO的预设框anchors
:return:
"""
# gt_boxes: [40,5] 一张真实标签的位置坐标信息
# 512//16=32
# 计算网络模型从输入到输出的缩小比例
scale = IMGSZ // GRIDSZ # IMGSZ:图片尺寸512,GRIDSZ:输出尺寸16
# [5,2] 将anchors转化为矩阵形式,一行代表一个anchors
anchors = np.array(anchors).reshape((5, 2))
# mask for object
# 用来判断该方格位置的anchors有没有目标,每个方格有5个anchors
detector_mask = np.zeros([GRIDSZ, GRIDSZ, 5, 1])
# x-y-w-h-l
# 在输出方格的尺寸上[16, 16, 5]制作真实标签, 用于和预测输出值做比较,计算损失值
matching_gt_box = np.zeros([GRIDSZ, GRIDSZ, 5, 5])
# [40,5] x1-y1-x2-y2-l => x-y-w-h-l
# 制作一个numpy变量,用于存储一张图片真实标签转换格式后的数据
# 将左上角与右下角坐标转化为中心坐标与宽高的形式
# [x_min, y_min, x_max, y_max] => [x_center, y_center, w, h]
gt_boxes_grid = np.zeros(gt_boxes.shape)
# DB: tensor => numpy 方便计算
gt_boxes = gt_boxes.numpy()
for i,box in enumerate(gt_boxes): # [40,5]
# box: [5], x1-y1-x2-y2-l,逐行读取
# 512 => 16
# 将左上角与右下角坐标转化为中心坐标与宽高的形式
# [x_min, y_min, x_max, y_max] => [x_center, y_center, w, h]
x = ((box[0]+box[2])/2)/scale
y = ((box[1]+box[3])/2)/scale
w = (box[2] - box[0]) / scale
h = (box[3] - box[1]) / scale
# [40,5] x_center-y_center-w-h-l
# 将第 i 行的数据赋予计算得到的新数据
gt_boxes_grid[i] = np.array([x,y,w,h,box[4]])
if w*h > 0: # valid box
# 用于筛选有效数据,当w, h为0时,表明该行没有目标,为无效的填充数据0
# x,y: 7.3, 6.8 都是缩放后的中心坐标
best_anchor = 0
best_iou = 0
for j in range(5):
# 计算真实目标框有5个anchros的交并比,选出做好的一个anchors
interct = np.minimum(w, anchors[j,0]) * np.minimum(h, anchors[j,1])
union = w*h + (anchors[j,0]*anchors[j,1]) - interct
iou = interct / union
if iou > best_iou: # best iou 筛选最大的iou,即最好的anchors
best_anchor = j # 将更加优秀的anchors的索引赋值与之前定义好的变量
best_iou = iou # 记录最好的iou
# found the best anchors
if best_iou>0: #用于判断是否有anchors与真实目标产生交并
# 向下取整,即是将中心点坐标转化为左上角坐标, 用于后续计算赋值
x_coord = np.floor(x).astype(np.int32)
y_coord = np.floor(y).astype(np.int32)
# [b,h,w,5,1]
# 将最好的一个anchors赋值1,别的anchors默认为0
# 图像坐标系的坐标与数组的坐标互为转置:[x,y] => [y, x]
detector_mask[y_coord, x_coord, best_anchor] = 1
# [b,h,w,5,x-y-w-h-l]
# 将最好的一个anchors赋值真实标签的信息[x_center, y_center, w, h, label],别的anchors默认为0
matching_gt_box[y_coord, x_coord, best_anchor] = \
np.array([x,y,w,h,box[4]])
# [40,5] => [16,16,5,5]
# matching_gt_box:[16,16,5,5],用于计算损失值
# detector_mask:[16,16,5,1],掩码,判断哪个anchors有目标
# gt_boxes_grid:[40,5],一张图片中目标的位置信息,转化后的格式
return matching_gt_box, detector_mask, gt_boxes_grid
1. 在标签文件.xml中,目标框的记载方式是[x_min, y_min, x_max, y_max],我们需要将这种格式转化为[x_center, y_center, w, h]这种格式,因为网络输出的格式就是[x_center, y_center, w, h]这种格式,而且anchors也是宽高形式。note:在后文中,x_center, y_center统一使用x,y代替,另外x,y并不是坐标,而是偏置,所有我们后续需要构建一个16x16的坐标网格,w, y则是倍率。
x = ((box[0]+box[2])/2)/scale
y = ((box[1]+box[3])/2)/scale
w = (box[2] - box[0]) / scale
h = (box[3] - box[1]) / scale
# [40,5] x_center-y_center-w-h-l
# 将第 i 行的数据赋予计算得到的新数据
gt_boxes_grid[i] = np.array([x,y,w,h,box[4]])
gt_boxes_grid就是转换格式的真实标签,shape:[max_boxes, 5], 5:[x, y, w, h, label],该变量存储的是一张图片的信息,后续会扩展为多张图片。这个变量是用来计算置信度损失的,将在计算损失函数部分使用。
2. 格式转换完成后,得到所有真实目标框的中心坐标[x, y],宽高[w, h]。网络模型的最后输出shape是16x16,每个网格中有5个anchors。在所有的网格中,计算每个网格中每个anchors(共5个anchors)与中心值落在该网格的目标的IOU,至于IOU如何计算,这里就不再赘述。根据IOU的值,来判断该网格中5个anchors哪个anchors与真实目标框匹配最好。
if w*h > 0: # valid box
# 用于筛选有效数据,当w, h为0时,表明该行没有目标,为无效的填充数据0
# x,y: 7.3, 6.8 都是缩放后的中心坐标
best_anchor = 0
best_iou = 0
for j in range(5):
# 计算真实目标框有5个anchros的交并比,选出做好的一个anchors
interct = np.minimum(w, anchors[j,0]) * np.minimum(h, anchors[j,1])
union = w*h + (anchors[j,0]*anchors[j,1]) - interct
iou = interct / union
if iou > best_iou: # best iou 筛选最大的iou,即最好的anchors
best_anchor = j # 将更加优秀的anchors的索引赋值与之前定义好的变量
best_iou = iou # 记录最好的iou
因为使用了max_boxes这个参数,所以gt_boxes.shape[max_boxes, 5]的内容并不全是有效数据,前面讲过,一张图片有几个目标,就赋值几个目标的信息于gt_boxes, 当该图片的目标数不足max_boxes时,不足部分填充0。所以gt_boxes中为0的部分全是无效数据。通过 if w*h > 0 可以有效筛选掉无效数据,然后使用一个循环将5个anchors中与目标的IOU最大的一个anchors挑选出来,并记录该anchors的索引序号与IOU。
if best_iou>0: #用于判断是否有anchors与真实目标产生交并
# 向下取整,即是将中心点坐标转化为左上角坐标, 用于后续计算赋值
x_coord = np.floor(x).astype(np.int32)
y_coord = np.floor(y).astype(np.int32)
# [b,h,w,5,1]
# 将最好的一个anchors赋值1,别的anchors默认为0
# 图像坐标系的坐标与数组的坐标互为转置:[x,y] => [y, x]
detector_mask[y_coord, x_coord, best_anchor] = 1
# [b,h,w,5,x-y-w-h-l]
# 将最好的一个anchors赋值真实标签的信息[x_center, y_center, w, h, label],别的anchors默认为0
matching_gt_box[y_coord, x_coord, best_anchor] = np.array([x,y,w,h,box[4]])
因为矩阵中第一维表示行,第二维表示列,比如a[4, 3],a有4行3列;但在图像坐标系中,横轴是x, 纵轴是y, 这也就是说y的值是图像的行数,x的值是图像的列数。所以在赋值中,需要将y写在第一维,x写在第二维,即 detector_mask[y_coord, x_coord, best_anchor] = 1。根据之前计算的IOU,可以知道与目标匹配最好的anchors的索引序号,然后对该anchors赋予相对应的值。
掩码detector_mask赋值1,表示该网格的某个anchors与落在该网格的目标有很好的匹配,即IOU值很大。也可以理解为该网格具有真实目标中心。
matching_gt_box则在匹配最好的一个anchors上赋值位置信息与标签,即[x, y, w, h, label],matching_gt_box这个变量就是用来与网络预测值做比较用的。
接下来就是多张图片处理,这个比较简单。
在训练过程中,训练batch_size一般不是1,有可能为2,4, 8, 16等等,所以需要将保存单张图片标签信息的变量合成为保存多张图片的变量,使用列表,然后矩阵化即可,至于矩阵化的原因,是因为矩阵容易操作,而且tensorflow中基本都是张量。具体代码如下:
def ground_truth_generator(db):
"""
构建一个训练数据集迭代器,每次迭代的数量由batch决定
:param db:训练集队列,包含训练集原图片数据信息,标签位置[x_min, y_min, x_max, y_max, label]信息
:return:
"""
for imgs, imgs_boxes in db:
# imgs: [b,512,512,3] b的值由之前定义的batch_size来决定
# imgs_boxes: [b,40,5],不一定是40,要根据实际情况来判断
# 创建三个批量数据列表
# 对应上面函数的单个图片数据变量
batch_matching_gt_box = []
batch_detector_mask = []
batch_gt_boxes_grid = []
# print(imgs_boxes[0,:5])
b = imgs.shape[0] # 计算一个batch有多少张图片
for i in range(b): # for each image
matching_gt_box, detector_mask, gt_boxes_grid = \
process_true_boxes(gt_boxes=imgs_boxes[i], anchors=ANCHORS)
batch_matching_gt_box.append(matching_gt_box)
batch_detector_mask.append(detector_mask)
batch_gt_boxes_grid.append(gt_boxes_grid)
# 将其转化为矩阵形式并转化为tensor,[b, 16,16,5,1]
detector_mask = tf.cast(np.array(batch_detector_mask), dtype=tf.float32)
# 将其转化为矩阵形式并转化为tensor,[b,16,16,5,5] x_center-y_center-w-h-l
matching_gt_box = tf.cast(np.array(batch_matching_gt_box), dtype=tf.float32)
# 将其转化为矩阵形式并转化为tensor,[b,40,5] x_center-y_center-w-h-l
gt_boxes_grid = tf.cast(np.array(batch_gt_boxes_grid), dtype=tf.float32)
# [b,16,16,5]
# 将所有的label信息单独分出来,用于后续计算分类损失值
matching_classes = tf.cast(matching_gt_box[...,4], dtype=tf.int32)
# 将标签进行独热码编码 [b,16,16,5,num_classes:3],
matching_classes_oh = tf.one_hot(matching_classes, depth=num_classes)
# 将背景标签去除,背景为0
# x_center-y_center-w-h-conf-l0-l1-l2 => x_center-y_center-w-h-conf-l1-l2
# [b,16,16,5,2]
matching_classes_oh = tf.cast(matching_classes_oh[...,1:], dtype=tf.float32)
# [b,512,512,3]
# [b,16,16,5,1]
# [b,16,16,5,5]
# [b,16,16,5,2]
# [b,40,5]
yield imgs, detector_mask, matching_gt_box, matching_classes_oh,gt_boxes_grid
不光将保存单张图片标签信息的变量合并为保存一个batch_size的变量,还需要创建一个类别变量,这个 变量在前面说过,是为了分类损失函数使用的,即用来分类的。
# [b,16,16,5]
# 将所有的label信息单独分出来,用于后续计算分类损失值
matching_classes = tf.cast(matching_gt_box[...,4], dtype=tf.int32)
# 将标签进行独热码编码 [b,16,16,5,num_classes:3],
matching_classes_oh = tf.one_hot(matching_classes, depth=num_classes)
# 将背景标签去除,背景为0
# x_center-y_center-w-h-conf-l0-l1-l2 => x_center-y_center-w-h-conf-l1-l2
# [b,16,16,5,2]
matching_classes_oh = tf.cast(matching_classes_oh[...,1:], dtype=tf.float32)
如何将类别单独分出来,并另存为一个变量,就比较简单,matching_gt_box的shape为[b, 16, 16, 5, 5],最后一维代表的值为真实目标的坐标(x, y, w, h)和类别(label),所有只需要取该变量的最后一维的第5个值就可以,如上面代码所示。得到matching_classes变量后,事情并没有做完,因为网络输出shape为[b, 16, 16, 5, 7] note: 我的训练集只有2类,所以7表示x-y-w-h-confidece-label1-label2,不包含背景,类别数可以根据你的类别数修改。但实际类别是3类,即背景-label1-label2,虽然在网络输出中不包含背景,但自己需要知道在目标检测中,背景默认为一类,这也是为什么在xml解析这一小节中,制作标签时,默认将标签数加1,因为背景默认为0。
因为网络输出不包含背景,所有我们需要将真实标签中的背景去除,去除的方法也比较简单,先将matching_classes热编码,另存为matching_classes_oh: [b, 16, 16, 5, 3],在matching_classes_oh的最后一维中的第一个值就是背景类别,只需要使用切片即可,如代码所示。最后matching_classes_oh的shape为[b, 16, 16, 5, 2],在最后一维的值形式为:[1, 0]:label1, [0, 1]:label2, [0, 0]:背景,也表示该anchors没有真实目标,这段红字后面会详细解释。
到此为止,数据预处理才算完成了90%,为了后面训练方便,将该函数的返回值做成数据生成器,而不是简单的return, yield可以有效的节省计算资源,而且后面也不需要再制作数据迭代器iter()啦。
最后就是数据增强,这一部分就不再赘述,比较麻烦,可以下载源码阅读。
请下载文件://download.csdn.net/download/qq_37116150/12289219
请下载权重文件:https://pan.baidu.com/s/1DZ7BLkh8JUDQ8KZbKVjP1A 提取码:ugod
该文件包含完整代码
权重文件包含预训练所需的权重参数
GRIDSZ = 16 # 最终输出尺寸
class SpaceToDepth(layers.Layer):
def __init__(self, block_size, **kwargs):
self.block_size = block_size
super(SpaceToDepth, self).__init__(**kwargs)
def call(self, inputs):
x = inputs
batch, height, width, depth = K.int_shape(x)
batch = -1
reduced_height = height // self.block_size
reduced_width = width // self.block_size
y = K.reshape(x, (batch, reduced_height, self.block_size,
reduced_width, self.block_size, depth))
z = K.permute_dimensions(y, (0, 1, 3, 2, 4, 5))
t = K.reshape(z, (batch, reduced_height, reduced_width, depth * self.block_size **2))
return t
def compute_output_shape(self, input_shape):
shape = (input_shape[0], input_shape[1] // self.block_size, input_shape[2] // self.block_size,
input_shape[3] * self.block_size **2)
return tf.TensorShape(shape)
# input_image = layers.Input((512,512, 3), dtype='float32')
input_image = tf.keras.Input(shape=(512, 512, 3))
# unit1
# [512, 512, 3] => [512, 512, 32]
x = layers.Conv2D(32, (3,3), strides=(1,1),padding='same', name='conv_1', use_bias=False)(input_image)
x = layers.BatchNormalization(name='norm_1')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# [512, 512, 32] => [256, 256, 32]
x = layers.MaxPooling2D(pool_size=(2,2))(x)
# unit2
# [256, 256, 32] => [256, 256, 64]
x = layers.Conv2D(64, (3,3), strides=(1,1), padding='same', name='conv_2',use_bias=False)(x)
x = layers.BatchNormalization(name='norm_2')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# [256, 256, 64] => [128, 128, 64]
x = layers.MaxPooling2D(pool_size=(2,2))(x)
# Layer 3
# [128, 128, 64] => [128, 128, 128]
x = layers.Conv2D(128, (3,3), strides=(1,1), padding='same', name='conv_3', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_3')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 4
# [128, 128, 128] => [128, 128, 64]
x = layers.Conv2D(64, (1,1), strides=(1,1), padding='same', name='conv_4', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_4')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 5
# [128, 128, 64] => [128, 128, 128]
x = layers.Conv2D(128, (3,3), strides=(1,1), padding='same', name='conv_5', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_5')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# [128, 128, 128] => [64, 64, 128]
x = layers.MaxPooling2D(pool_size=(2, 2))(x)
# Layer 6
# [64, 64, 128] => [64, 64, 256]
x = layers.Conv2D(256, (3,3), strides=(1,1), padding='same', name='conv_6', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_6')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 7
# [64, 64, 256] => [64, 64, 128]
x = layers.Conv2D(128, (1,1), strides=(1,1), padding='same', name='conv_7', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_7')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 8
# [64, 64, 128] = [64, 64, 256]
x = layers.Conv2D(256, (3,3), strides=(1,1), padding='same', name='conv_8', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_8')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# [64, 64, 256] => [32, 32, 256]
x = layers.MaxPooling2D(pool_size=(2, 2))(x)
# Layer 9
# [32, 32, 256] => [32, 32, 512]
x = layers.Conv2D(512, (3, 3), strides=(1, 1), padding='same', name='conv_9', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_9')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 10
# [32, 32, 512] => [32, 32, 256]
x = layers.Conv2D(256, (1, 1), strides=(1, 1), padding='same', name='conv_10', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_10')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 11
# [32, 32, 256] => [32, 32, 512]
x = layers.Conv2D(512, (3, 3), strides=(1, 1), padding='same', name='conv_11', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_11')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 12
# [32, 32, 512] => [32, 32, 256]
x = layers.Conv2D(256, (1, 1), strides=(1, 1), padding='same', name='conv_12', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_12')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 13
# [32, 32, 256] => [32, 32, 512]
x = layers.Conv2D(512, (3, 3), strides=(1, 1), padding='same', name='conv_13', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_13')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# for skip connection:后续拼接操作
skip_x = x # [b,32,32,512]
# [32, 32, 512] => [16, 16, 512]
x = layers.MaxPooling2D(pool_size=(2, 2))(x)
# Layer 14
# [16, 16, 512] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_14', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_14')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 15
# [16, 16, 1024] => [16, 16, 512]
x = layers.Conv2D(512, (1, 1), strides=(1, 1), padding='same', name='conv_15', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_15')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 16
# [16, 16, 512] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_16', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_16')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 17
# [16, 16, 1024] => [16, 16, 512]
x = layers.Conv2D(512, (1, 1), strides=(1, 1), padding='same', name='conv_17', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_17')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 18
# [16, 16, 512] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_18', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_18')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 19
# [16, 16, 1024] => [16, 16, 512]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_19', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_19')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 20
# [16, 16, 512] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_20', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_20')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 21
# [32, 32, 512] => [32, 32, 64]
skip_x = layers.Conv2D(64, (1, 1), strides=(1, 1), padding='same', name='conv_21', use_bias=False)(skip_x)
skip_x = layers.BatchNormalization(name='norm_21')(skip_x)
skip_x = layers.LeakyReLU(alpha=0.1)(skip_x)
# [32, 32, 64] => [16, 16, 64*2*2]
skip_x = SpaceToDepth(block_size=2)(skip_x)
# concat
# [16,16,1024], [16,16,256] => [16,16,1280]
x = tf.concat([skip_x, x], axis=-1)
# Layer 22
# [16,16,1280] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_22', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_22')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
x = layers.Dropout(0.5)(x) # add dropout
# [16,16,5,7] => [16,16,35]
# [16, 16, 1024] => [16, 16, 35]
x = layers.Conv2D(5 * 7, (1, 1), strides=(1, 1), padding='same', name='conv_23')(x)
# [16, 16, 35] => [16, 16, 5, 7]
output = layers.Reshape((GRIDSZ, GRIDSZ, 5, 7))(x)
# create model
model = tf.keras.models.Model(input_image, output)
网络模型基于darknet-19改进的,输入是[512, 512, 3], 输出是[16, 16, 5, 7]。在网络模型的第21层,是一个拼接操作,拼接的是13层和20层的输出,其中13层的输出shape:[32, 32, 512], 20层的输出shape:[16, 16, 1024],所以需要将13层的输出reshape成[16,16]。创建一个自定义层类,在该类中实现13层shape的改变。其实不创建自定义层类也可以实现,不知道为什么龙龙老师有这个操作,可能会使代码更加规范。
我写了一个简单的函数,也可以实现层shape改变,通过model.summary()打印出的内容,和使用自定义层打印出的内容一致,感兴趣的同学可以尝试一哈,代码如下:
def compute_shape(skip_x, scale):
"""
:function 修改层shape
:param skip_x: 要修改的层
:param scale: 需要减少或增加的倍率
:return: 修改后的层
"""
print(skip_x.shape)
skip_reshape_1 = tf.reshape(skip_x, shape=[-1, 16, 2, 16, 2, 64])
print(skip_reshape_1.shape)
skip_reshape_2 = tf.transpose(skip_reshape_1, perm=[0, 1, 3, 2, 4, 5])
print(skip_reshape_2.shape)
skip_reshape_3 = tf.reshape(skip_reshape_2, shape=[-1, 16, 16, scale * scale * 64])
print(skip_reshape_3.shape)
skip_x = skip_reshape_3
return skip_x
这一部分根据你的训练集来进行选择是否使用,如果你是大佬,训练集很多,那就不用finetuning啦,当然大佬也不会看我的博客啦。使用finetuning适用于训练集较少的情况,可以使你的网络收敛更快。因为使用的主干网络是darknet-19,所有就需要使用别人训练好的darknet-19网络权重来进行finetuning。网络权重文件已经上传至网盘,请自行下载,科学上网很重要。
代码就不写啦,可以自行下载源码文件,里面包含具体的操作,需要提醒的是,倒数第二层即第23层,不使用finetuning, 而是使用正态函数随机初始化权重和偏置。至于为什么这样做,因为我们的检测目标和别人的不一样,不能所有层都进行fintuning,对于一些浅层卷积层可以finetuning。
ckpt.h5文件是龙龙老师根据当前网络已经训练好的权重参数,如果不想finetuning, 可以直接加载该文件,但是你的检测目标和龙龙老师的检测目标是不一样的,所以还是需要finetuning或随机初始化。
##--------------------------------------------------------
# 预训练好的权值,可以偷懒直接加载
# model.load_weights('./model/ckpt.h5')
##-------------------------------------------------
终于到这一步啦,我已经不想写啦,累。
还是老样子,先自行下载完整代码:
请下载文件://download.csdn.net/download/qq_37116150/12289229
该文件包含完整代码
目标检测的损失函数和目标分类的损失有很大的不同,目标检测需要输出目标的坐标,类别,置信度,既然输出了这三个值,那训练的时候,也需要针对这三个参数计算损失值。
这一步其实算是整个目标检测中最重要和复杂的一部分啦。
由于需要计算坐标损失,而且坐标损失都带有坐标两字啦,那就需要在训练前制作一个坐标系,该坐标系为16x16,即x轴16,y轴16。制作坐标系的代码如下:
x_grid = tf.tile(tf.range(GRIDSZ), [GRIDSZ])
# [1,16,16,1,1]
# [b,16,16,5,2]
x_grid = tf.reshape(x_grid, (1, GRIDSZ, GRIDSZ, 1, 1))
x_grid = tf.cast(x_grid, tf.float32)
# [1,16_1,16_2,1,1]=>[1,16_2,16_1,1,1]
y_grid = tf.transpose(x_grid, (0, 2, 1, 3, 4))
# [1,16_2,16_1,1,1] => [1, 16, 16, 1, 2]
xy_grid = tf.concat([x_grid, y_grid], axis=-1)
# [1,16,16,1,2]=> [b,16,16,5,2]
xy_grid = tf.tile(xy_grid, [y_pred.shape[0], 1, 1, 5, 1])
xy_grid的最后一维存储的就是坐标值,从[0,0] -> [15, 15] 共有256对坐标值。至于为什么要建立坐标系,是因为网络预测输出的x,y并不是坐标值,而是偏移量,经过激活函数后,还需要加上建立的坐标系才是真正的坐标值。比如网络预测输出[0, 1, 1, 0, 0:2] = (0.3, 0.4), 然后加上坐标系,那中心坐标值就是(1.3,1.4),这个值才是绝对坐标值。怕有些同学不懂这个[0, 1, 1, 0, 0:2]矩阵的含义,解释一哈,0:第1张图片,索引都是从0开始;1,1:输出的16x16网格中的第2行第2列的一个网格,0:该网格中的第一个anchors,0:2,该anchors中的x,y值。
现在开始损失函数计算。
# [b,16,16,5,7] x-y-w-h-conf-l1-l2
# pred_xy 既不是相对位置,也不是绝对位置,是偏移量
# 通过激活函数转化为相对位置
pred_xy = tf.sigmoid(y_pred[..., 0:2])
# 加上之前设定好的坐标,变为绝对位置
# [b,16,16,5,2]
pred_xy = pred_xy + xy_grid
# [b,16,16,5,2]
pred_wh = tf.exp(y_pred[..., 2:4])
# [b,16,16,5,2] * [5,2] => [b,16,16,5,2]
# w,h为倍率,要乘上anchors,才是宽高
pred_wh = pred_wh * anchors
# 计算真实目标框的数量,用来做平均
# 由于detector_mask的值为0和1,所以可以不用比较,直接求和即可
n_detector_mask = tf.reduce_sum(tf.cast(detector_mask > 0., tf.float32)) # 方法一
# n_detector_mask = tf.reduce_sum(detector_mask) # 方法二
# print("真实目标框数量:",float(n_detector_mask))
# [b,16,16,5,1] * [b,16,16,5,2]
# 只计算有object位置处的损失,没有的就不计算,所有要乘以掩码
xy_loss = detector_mask * tf.square(matching_gt_boxes[..., :2] - pred_xy)/(n_detector_mask + 1e-6)
xy_loss = tf.reduce_sum(xy_loss)
wh_loss = detector_mask * tf.square(tf.sqrt(matching_gt_boxes[..., 2:4]) -
tf.sqrt(pred_wh)) / (n_detector_mask + 1e-6)
wh_loss = tf.reduce_sum(wh_loss)
# 1. coordinate loss
coord_loss = xy_loss + wh_loss
坐标损失计算完成后,开始计算分类损失,因为我们的网络需要分类出目标的类别,所以需要分类损失函数。
分类损失函数使用交叉熵损失函数,这个函数在逻辑回归中有很好的效果,具体代码如下:
# 2. class loss
# [b,16,16,5,2]
pred_box_class = y_pred[..., 5:]
# [b,16,16,5,2] => [b,16,16,5]
true_box_class = tf.argmax(matching_classes_oh, axis=-1)
# [b,16,16,5] vs [b,16,16,5,2]
# 使用sparse_categorical_crossentropy函数,可以不将标签one_hot化
# 计算分类损失,返回值是每个anchors的交叉熵损失值,总共有[b, 16, 16, 5]个值
class_loss = losses.sparse_categorical_crossentropy(y_true=true_box_class,
y_pred=pred_box_class,
from_logits=True)
# 使用categorical_crossentropy,需要将标签one_hot化,
# 两种损失函数经测试,差距不大
# class_loss = losses.categorical_crossentropy(y_true=matching_classes_oh,
# y_pred=pred_box_class,
# from_logits=True)
# [b,16,16,5] => [b,16,16,5,1]* [b,16,16,5,1]
# 增加一个维度进行矩阵元素相乘,返回有目标的损失值
class_loss = tf.expand_dims(class_loss, -1) * detector_mask
# 求个平均值,即每个目标分类的损失值
class_loss = tf.reduce_sum(class_loss) / (n_detector_mask + 1e-6)
这个计算方法和目标分类没有区别,就是真实目标的标签与网络预测目标的标签做比较,使用的函数是交叉熵损失函数。这也是为什么在前面一节中有个操作,将背景类别去除,因为在目标分类中就没得背景这个类别,而且背景也无法进行训练。
有一点需要注意的是,tf.keras.losses.sparse_categorical_crossentropy(y_true, y_pred, from_logits)与tf.keras.losses.categorical_crossentropy(y_true, y_pred, from_logits)是有一点区别的,这两个都是交叉熵损失函数,但是前面一个的y_true的输入值是未经过one_hot化的标签,也就是真实标签,比如[1, 2, 0, 4, 3, 4],这样的标签;后一个交叉熵损失函数的y_true是经过one_hot化的标签,比如[[0,0,1],[1,0,0],[0,1,0]]。这两个损失函数计算的结果是差不多的,我使用30张图片进行测试,它们两个的平均损失值分别是:
可以看到,差别不大。
因为之前为了将背景类别去除,已经将标签one_hot化啦,所有如果使用tf.keras.losses.sparse_categorical_crossentropy(y_true, y_pred, from_logits)损失函数,就需要将one_hot化的标签去one_hot化,即通过tf.argmax()就可以得到真实标签。
最后将得到的类别损失函数乘以掩码,然后求和,和坐标损失一样,我们同样只计算有目标的分类损失值。
现在谈一下4.2留下的问题,matching_classes_oh[b, 16, 16, 5, 2],最后一维的值是:[1, 0], [0, 1], [0, 0], 现在问题来了,[0, 0]它表示的是啥?背景?可是我们已经将背景去除了啊,然而它就是背景,它的原型是[1, 0, 0], 将第一列全部去除后,就剩下苦逼的[0, 0]。这个标签[0, 0]所在的anchors表示该anchors是背景,没有目标。这个时候,掩码就显示出它的威力啦,因为在没有目标的anchors处,它的值是0,然后用掩码乘以使用交叉熵损失函数计算的损失值。这样虽然计算了没有目标的anchors的损失值,即将[0, 0]也参与计算啦,但是我们乘以了一个掩码,就消除了没有目标的anchors的损失值,使其为0,最后求和不影响损失值。
第三个损失函数啦,坚持就是胜利!!!
先谈一谈什么是置信度,置信度就是在这个网格中的每个anchors有目标的概率,比如第2行第2列网格的第2个anchors,我们给它起个名叫小Y,在训练中,经过网络预测,网络说小Y啊,你只有30%的概率,不可信啊,这个30%概率就是这个anchors小Y的预测置信度。那小Y的真实置信度如何计算呢?对了,还需要解释一下什么是预测置信度,什么是真实置信度,这个真实置信度只会出现在训练中,额,损失函数也是训练中才会有的,哈哈。预测置信度是经过网络预测的置信度,真实置信度就是真实目标标签坐标与预测目标标签的IOU。现在说说如何计算真实置信度,简单,我们有真实目标的[x, y, w, h],小Y也有[x, y, w, h],只需要计算这两个坐标的IOU(交并比)就可以得到小Y的真实置信度,代码如下:
def compute_iou(x1, y1, w1, h1, x2, y2, w2, h2):
"""
:function 用于计算预测框与真实目标框的IOU
:return:
"""
# x1...:[b,16,16,5]
# x,y都是中心坐标
# 计算出左上角与右下角坐标
xmin1 = x1 - 0.5 * w1
xmax1 = x1 + 0.5 * w1
ymin1 = y1 - 0.5 * h1
ymax1 = y1 + 0.5 * h1
xmin2 = x2 - 0.5 * w2
xmax2 = x2 + 0.5 * w2
ymin2 = y2 - 0.5 * h2
ymax2 = y2 + 0.5 * h2
# (xmin1,ymin1,xmax1,ymax1) (xmin2,ymin2,xmax2,ymax2)
# 交集宽
interw = np.minimum(xmax1, xmax2) - np.maximum(xmin1, xmin2)
# 交集高
interh = np.minimum(ymax1, ymax2) - np.maximum(ymin1, ymin2)
# 交集
inter = interw * interh
# 并集
union = w1 * h1 + w2 * h2 - inter
# 交并比,并集加上 1e-6为防止分母为0
iou = inter / (union + 1e-6)
# [b,16,16,5]
return iou
IOU计算还算比较简单,就不再多做解释,有不懂得同学,可在下方评论,哈哈,还能骗个评论。
现在知道了如何计算小Y的真实置信度,我们不能只计算小Y同学的置信度啊,别的同学(anchors)也不开心啊,所以为了让别的同学也开心,将所有的anchors的真实置信度都计算,鲁迅说“不患寡之患不均啊”。
# 4.3 object loss
# nonobject_mask
# iou done!
# [b,16,16,5]
x1, y1, w1, h1 = matching_gt_boxes[..., 0], matching_gt_boxes[..., 1], \
matching_gt_boxes[..., 2], matching_gt_boxes[..., 3]
# [b,16,16,5]
x2, y2, w2, h2 = pred_xy[..., 0], pred_xy[..., 1], pred_wh[..., 0], pred_wh[..., 1]
# 计算每个真实目标框与预测框的IOU
ious = compute_iou(x1, y1, w1, h1, x2, y2, w2, h2)
# [b,16,16,5,1]
ious = tf.expand_dims(ious, axis=-1)
所有anchors的预测置信度代码如下:
# [b,16,16,5,1]
pred_conf = tf.sigmoid(y_pred[..., 4:5])
要经过预测置信度sigmoid()处理,使置信度值维持在0~1范围内。
真实置信度ious需要增加一个维度,因为人家预测置信度的维度是5维,真实置信度只是4维,所以在最后一维增加一维。
预测置信度与真实置信度都已经计算处来了,那就开始计算损失值吧,代码如下:千说万说,不如代码一说
obj_loss = tf.reduce_sum(detector_mask * tf.square(ious - pred_conf)) / (n_detector_mask + 1e-6)
置信度损失也是使用均方差损失函数,然后乘以掩码,只计算有真实目标的anchors的损失值。
写到这里,有目标的置信度损失值已经计算完成,下一步就是计算没有目标的anchors的置信度损失。
之所以说置信度损失比较麻烦,是因为在置信度损失这一部分中,不仅需要计算有目标的anchors的置信度损失,还需要计算没有真实目标的anchors的置信度损失。
没有真实目标的anchors的置信度损失如何计算呢?它和有目标的anchors的置信度损失计算方式基本相同。
它的计算过程有点复杂,希望同学能够耐心阅读。
1. 预测置信度:这个不用说了,再上面就已经谈论过,而且它的值,也求解出来了,就是pred_conf,额,要经过sigmoid()处理一下哈,要保持它的值维持在0~1,额,在求解有目标的anchors的置信度的过程中,已经将pred_conf求解出来了,这一步就可以省略啦。
2. IOU组合大匹配:它的作用先不提,后面会说,先说说它的求解过程。这一部分也比较复杂,唉,都复杂。这一步是计算网络输出的位置坐标[x_min, y_min, x_max, y_max]与真实目标的位置坐标[x_min, y_min, x_max, y_max]的IOU,它们的匹配可不是一一对应匹配,而是每个网络输出的anchors与所有的真实目标anchors相匹配, note: anchors与anchors相匹配都是anchors中的位置坐标(x_min, y_min, x_max, y_max)匹配。比如网络预测有10个anchors,真实目标有5个,那就有50中匹配可能。说这么多,不如看代码:
# [b,16,16,5,2] => [b,16,16,5, 1, 2]
pred_xy = tf.expand_dims(pred_xy, axis=4)
# [b,16,16,5,2] => [b,16,16,5, 1, 2]
pred_wh = tf.expand_dims(pred_wh, axis=4)
pred_wh_half = pred_wh / 2.
pred_xymin = pred_xy - pred_wh_half
pred_xymax = pred_xy + pred_wh_half
# [b, 40, 5] => [b, 1, 1, 1, 40, 5]
true_boxes_grid = tf.reshape(gt_boxes_grid,
[gt_boxes_grid.shape[0], 1, 1, 1,
gt_boxes_grid.shape[1],
gt_boxes_grid.shape[2]])
true_xy = true_boxes_grid[..., 0:2]
true_wh = true_boxes_grid[..., 2:4]
true_wh_half = true_wh / 2.
true_xymin = true_xy - true_wh_half
true_xymax = true_xy + true_wh_half
# predxymin, predxymax, true_xymin, true_xymax
# [b,16,16,5,1,2] vs [b,1,1,1,40,2]=> [b,16,16,5,40,2]
intersectxymin = tf.maximum(pred_xymin, true_xymin)
# [b,16,16,5,1,2] vs [b,1,1,1,40,2]=> [b,16,16,5,40,2]
intersectxymax = tf.minimum(pred_xymax, true_xymax)
# [b,16,16,5,40,2]
intersect_wh = tf.maximum(intersectxymax - intersectxymin, 0.)
# [b,16,16,5,40] * [b,16,16,5,40]=>[b,16,16,5,40]
# 交集
intersect_area = intersect_wh[..., 0] * intersect_wh[..., 1]
# [b,16,16,5,1]
pred_area = pred_wh[..., 0] * pred_wh[..., 1]
# [b,1,1,1,40]
true_area = true_wh[..., 0] * true_wh[..., 1]
# [b,16,16,5,1]+[b,1,1,1,40]-[b,16,16,5,40]=>[b,16,16,5,40]
# 并集
union_area = pred_area + true_area - intersect_area
# [b,16,16,5,40]
# 交并比
iou_score = intersect_area / union_area
# [b,16,16,5]
# 选出每个anchors的最大交并比
best_iou = tf.reduce_max(iou_score, axis=4)
# [b,16,16,5,1]
best_iou = tf.expand_dims(best_iou, axis=-1)
根据代码来详细解释, pred_xy在坐标损失值计算的过程中就已经计算出来啦,先在最后一维的前一维增加1维,具体功能是为了混合大匹配,pred_wh同理。将[x, y, w, h] => [x_min, y_min, x_max, y_max],这一步简单,得到pred_xymin, pred_xymax,网络输出坐标格式已经转换完成。
接下来就是处理真实目标坐标值,存储真实目标坐标值的变量gt_boxes_grid的shape[b, 40, 5],它的shape和pred_xymin, pred_xymax不匹配,就无法进行计算,现在对它变形,开始变形,通过reshape,将它的shape变形为[b, 1, 1, 1, 40, 5],pred_xymin的shape为[b, 16, 16, 5, 1, 2],然后使用和网络输出处理相同操作,得到true_xymin, true_xymax。
开始计算IOU啦,将pred_xymin和true_xymin相比较取大值,将pred_xymax和true_xymax相比较取小值,然后将两者返回的结果相减,并和0比较,返回大于0的值。
intersect_wh = tf.maximum(intersectxymax - intersectxymin, 0.)
为什么还要有个maximum()操作呢?是因为,我们将所有的预测anchors与所有的真实anchors中目标坐标想比较,计算IOU,总会有两个目标框没有交集的情况出现,如果它们没有交集,计算的intersectxymax - intersectxymin的值为负,然后使用maximum()和0比较,就将这种情况筛选掉啦。保留的都是有交集的。
然后就是计算IOU啦,简单操作,没啥好说的。
# 选出每个anchors的最大交并比
best_iou = tf.reduce_max(iou_score, axis=4)
这条代码,是为了选出每个anchors中最大的IOU交并比,因为每个anchors都会与所有的真实目标值想匹配,所有每个anchors中都会有多个IOU,这么多IOU对我们是没有用的,我们做混合匹配的目的就是选出每个anchors与所有真实目标值的最优匹配。
这里面包含一个难点,同学如果认真阅读,应该就能发现。那就是每个预测anchors中的坐标值如何与每个真实anchors中的坐标进行比较的,我前面提到要将pred_xy, pred_wh最后一维的前一维增加1维,gt_boxes_grid汽车人变形,就是这个作用的。
# [b,16,16,5,2] => [b,16,16,5, 1, 2]
pred_xy = tf.expand_dims(pred_xy, axis=4)
# [b,16,16,5,2] => [b,16,16,5, 1, 2]
pred_wh = tf.expand_dims(pred_wh, axis=4)
# [b, 40, 5] => [b, 1, 1, 1, 40, 5]
true_boxes_grid = tf.reshape(gt_boxes_grid, [gt_boxes_grid.shape[0], 1, 1, 1, gt_boxes_grid.shape[1], gt_boxes_grid.shape[2]])
两个不同的矩阵,在不同的维度前增加一维,然后进行交互操作,比如相加,相乘,比较大小等,就可以实现两两相互的匹配,最后一维就是进行交互的内容。
下面是一个小程序,可以通过这个小程序来理解这个具体原理
import numpy as np
np.random.seed(50)
a = np.random.randint(low=0, high=100,size=(2,3,2) ,dtype=np.int32)
print("a: ",a)
# print(a[0, 0, :])
b = np.random.randint(low=0, high=100,size=(5,2) ,dtype=np.int32)
print("b: ",b)
print("开始一一对应匹配,匹配维度为第2维,第一个值为x,第二个值为y")
# a[2,3,1,2]
a = np.expand_dims(a, axis=2)
print(a.shape)
# b[1, 1, 5, 2]
b = np.reshape(b, newshape=(1,1,5,2))
print(b.shape)
intersectxymin = np.maximum(a, b)
print(intersectxymin.shape)
print("intersectxymin: ", intersectxymin)
3. 无目标的anchors掩码:在计算有目标的anchors的置信度的过程中,用到了掩码detector_mask, 只是这个掩码是有真实目标的掩码,即有目标为1,无目标为0。现在需要求解无目标的掩码nonobj_mask,它的含义是有目标的anchors为0,无目标的anchors为1。有同学可能又会说,博主,这个好求解,用nonobj_mask = 1 - detector_mask就可以了撒,得到的结果就是没有目标的掩码,想想也对撒,此时的nonobj_mask的值含义就是有目标的anchors为0, 无目标的anchors为1。同学你误我啊,这是不对滴,因为这是基于真实标签制作的掩码,计算出来的结果都是基于我们打标注的真实标签,不会出现误差。要多考虑一哈,我们现在处于训练阶段,处于计算损失函数这一阶段,要向网络预测值靠,这样才能通过减小损失,提升网络检测精度。上一小节IOU组合大匹配计算出了best_iou, 这个值其实也是概率,它的shape为[b, 16, 16, 5],通过这个shape我们就可以明白它是输出的16x16网格中每个anchors的IOU值,然后将这个IOU与阈值(自己设定,根据实际情况,我设为0.6)相比较,小于阈值的,我们都认为该anchors没有目标,具体代码如下:
# [b,16,16,5,1]
best_iou = tf.expand_dims(best_iou, axis=-1)
# 设定当IOU小于0.6时,就认为没有目标
nonobj_detection = tf.cast(best_iou < 0.6, tf.float32)
有同学可能又会问,唉,同学你咋这么多问题呢?这位同学问啥呢?他问博主best_iou虽然可以理解成概率值或置信度,可是每个anchors,网络不都会预测一个置信度吗,比如pre_conf。我们要明白两个问题,1. 我们处于训练阶段,YOLO又是有监督学习,损失函数如果没有真实标签数据参与,就无法有效减小损失函数,快速收敛网络;2. 我们之前计算的IOU都是网络预测网格与真实网格一一对应计算的,万一哪个anchors出轨了咋办?它和隔壁老王家的anchors中的真实目标有更好的IOU。正是基于这种情况,YOLO作者才会想到,让它们来个混合大匹配,所有的anchors都进行匹配计算一次,选出最好的一个,如果这样你的IOU还比阈值小,说明你是真没有目标。
到这一步,所有的工作基本都完成啦,还差最后一个小操作,就是将一些网络预测错的网格anchors筛选掉:
# 计算预测框没有目标的掩码
nonobj_mask = nonobj_detection * (1 - detector_mask)
这条代码的含义,举个例子,应该就晓得啦。
咱还拿小Y(小Y是谁?参照本节开头)来说,小Y说我是没有目标的,嘘,别告诉它,是网络骗它的,用网络预测小Y的位置坐标与所有的真实目标坐标做匹配,计算IOU,计算的最大IOU是0.2(大于0.6就认为有目标),可是在真实的对应网格anchors中,是有目标的。这样就会产生一个问题,小Y到底有没有目标呢?网络说你没有,实际的情况确是有的,我们实事求是,既然人家小Y有目标,那我们就不能说人家没得,通过乘以(1-detector_mask)就可以解决这种问题。下面举个例子,希望同学能够更加理解,毕竟这个概念有点难理解。
小Q | 小Y | 隔壁老王 | anchors3 | anchors4 | |
---|---|---|---|---|---|
真实值 | 0 | 1 | 0 | 0 | 0 |
best_iou | 0.8 | 0.2 | 0.32 | 0.4 | 0.11 |
nonobj_detection | 0 | 1 | 1 | 1 | 1 |
*(1-detector_mask) | 0 | 0 | 1 | 1 | 1 |
通过上面的表格,我想大家应该都明白了1-detector_mask的作用啦。
4. 计算无目标的数量:就是将没有目标的anchors数量统计一哈,比较容易理解
# nonobj counter
n_nonobj = tf.reduce_sum(tf.cast(nonobj_mask > 0., tf.float32))
5. 计算无目标位置处的损失值:最后的美人终于出来了,因为要计算无目标位置处的损失值,那就说明在真实标签中,该位置没有目标,那应该如何计算它的损失值呢,在前面提到过,网络输出值中含有置信度,我们使用这个置信度即可。因为计算的是无目标处的损失值,无目标一旦出现目标,说明就是预测错误,所以该置信度越小越好,当然最后要乘以一个无目标掩码,之前计算过的,然后求和,求平均值。
nonobj_loss = tf.reduce_sum(nonobj_mask * tf.square(-pred_conf)) / (n_nonobj + 1e-6)
通过看小Y沉冤得雪史的表格,可以晓得,小Q的值是错误的,这就是网络的预测误差,通过上面的nonobj_loss损失函数再加上网络反向传播,可使得小Q的值纠正过来,在纠正过程中,网络也会变得更加收敛。虽然pred_conf只是网络预测置信度,但是nonobj_mask有真实参数参入,真实标签会监督网络,使损失值越来越小,无目标处的pred_conf越来越小。
到此,所有的损失值已经计算完成,工作到这里基本已经完成啦,额,还有一个,就是我们追求的是网络检测精度,所以,要给有目标的置信度损失权重加大,代码如下:
loss = coord_loss + class_loss + nonobj_loss + 5 * obj_loss
这个loss,就是最终的损失值啦,损失函数到此是真正的构建完成啦。
这一步没有多大难度,就是一些参数调节问题
def train(epoches,train_gen,model):
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-4, beta_1=0.9,
beta_2=0.999,epsilon=1e-08)
for epoch in range(epoches):
for step in range(30):
img, detector_mask, matching_true_boxes, matching_classes_oh, true_boxes = next(train_gen)
with tf.GradientTape() as tape:
y_pred = model(img, training=True)
loss, sub_loss = yolo_loss(detector_mask, matching_true_boxes,
matching_classes_oh, true_boxes, y_pred)
grads = tape.gradient(loss, model.trainable_variables)
optimizer.apply_gradients(zip(grads, model.trainable_variables))
print(epoch, step, float(loss), float(sub_loss[0]), float(sub_loss[1]), float(sub_loss[2]))
# 保存权重
model.save_weights('model/YOLO_epoch10.ckpt')
最后就是用验证数据集验证哈我们训练的网络检测效果如何,代码如下:
def visualize_result(img_path, model):
"""
用于结果可视化
:param img:
:param model:
:return:
"""
model.load_weights("./model/YOLO_epoch10.ckpt")
# [512,512,3] 0~255, BGR
img = cv2.imread(img_path)
img = img[...,::-1]/255.
img = tf.cast(img, dtype=tf.float32)
# [1,512,512,3]
img = tf.expand_dims(img, axis=0)
# [1,16,16,5,7]
y_pred = model(img, training=False)
x_grid = tf.tile(tf.range(GRIDSZ), [GRIDSZ])
# [1, 16,16,1,1]
x_grid = tf.reshape(x_grid, (1, GRIDSZ, GRIDSZ, 1, 1))
x_grid = tf.cast(x_grid, dtype=tf.float32)
y_grid = tf.transpose(x_grid, (0,2,1,3,4))
xy_grid = tf.concat([x_grid,y_grid], axis=-1)
# [1, 16, 16, 5, 2]
xy_grid = tf.tile(xy_grid, [1, 1, 1, 5, 1])
anchors = np.array(ANCHORS).reshape(5,2)
pred_xy = tf.sigmoid(y_pred[...,0:2])
pred_xy = pred_xy + xy_grid
# normalize 0~1
pred_xy = pred_xy / tf.constant([16.,16.])
pred_wh = tf.exp(y_pred[...,2:4])
pred_wh = pred_wh * anchors
pred_wh = pred_wh / tf.constant([16.,16.])
# [1,16,16,5,1]
pred_conf = tf.sigmoid(y_pred[...,4:5])
# l1 l2
pred_prob = tf.nn.softmax(y_pred[...,5:])
pred_xy, pred_wh, pred_conf, pred_prob = \
pred_xy[0], pred_wh[0], pred_conf[0], pred_prob[0]
boxes_xymin = pred_xy - 0.5 * pred_wh
boxes_xymax = pred_xy + 0.5 * pred_wh
# [16,16,5,2+2]
boxes = tf.concat((boxes_xymin, boxes_xymax),axis=-1)
# [16,16,5,2]
box_score = pred_conf * pred_prob
# [16,16,5]
box_class = tf.argmax(box_score, axis=-1)
# [16,16,5]
box_class_score = tf.reduce_max(box_score, axis=-1)
# [16,16,5]
pred_mask = box_class_score > 0.45
# [16,16,5,4]=> [N,4]
boxes = tf.boolean_mask(boxes, pred_mask)
# [16,16,5] => [N]
scores = tf.boolean_mask(box_class_score, pred_mask)
# 【16,16,5】=> [N]
classes = tf.boolean_mask(box_class, pred_mask)
boxes = boxes * 512.
# [N] => [n]
select_idx = tf.image.non_max_suppression(boxes, scores, 40, iou_threshold=0.3)
boxes = tf.gather(boxes, select_idx)
scores = tf.gather(scores, select_idx)
classes = tf.gather(classes, select_idx)
# plot
fig, ax = plt.subplots(1, figsize=(10,10))
ax.imshow(img[0])
n_boxes = boxes.shape[0]
ax.set_title('boxes:%d'%n_boxes)
for i in range(n_boxes):
x1,y1,x2,y2 = boxes[i]
w = x2 - x1
h = y2 - y1
label = classes[i].numpy()
if label==0: # sugarweet
color = (0,1,0)
else:
color = (1,0,0)
rect = patches.Rectangle((x1.numpy(), y1.numpy()), w.numpy(), h.numpy(), linewidth = 3, edgecolor=color,facecolor='none')
ax.add_patch(rect)
plt.show()
到这里,整个YOLOV2算是真正完成啦,这篇博客也算是我最认真写的吧,花了3天的时间,也许有些部分过于啰嗦,也请见谅,有些部分可能也没有讲清楚,欢迎在评论区评论。
最后就是anchors的计算,它是通过K-means聚类计算出来的,我后续可能会写篇博客介绍如何计算anchors的吧。在本文中的anchors是imagenet官方通过大量图片计算出来的,还算挺好的。
算了,就说这些吧