YOLOV4
为例详解anchor_based目标检测训练过程yolov4的代码使用的是Bubbliiiing大神提供的代码,代码地址是https://github.com/bubbliiiing/yolov4-pytorch。
首先写入图片名,然后经过convert_annotation
方法增加box属性。
list_file.write('%s/VOC%s/JPEGImages/%s.jpg'%(os.path.abspath(VOCdevkit_path), year, image_id))
convert_annotation(year, image_id, list_file)
数据经labelimg标注过,生成的xml中有类别、左上角右下角坐标的属性。将类别转为自然数列索引形式,生成的box由五个元素组成,前两个元素是左上角坐标,第3-4个元素是右下角坐标,第5个元素是类别索引。
def convert_annotation(year, image_id, list_file):
# 读取xml
in_file = open(os.path.join(VOCdevkit_path, 'VOC%s/Annotations/%s.xml'%(year, image_id)), encoding='utf-8')
tree=ET.parse(in_file)
root = tree.getroot()
for obj in root.iter('object'):
difficult = 0
if obj.find('difficult')!=None:
difficult = obj.find('difficult').text
# 获取类别索引
cls = obj.find('name').text
if cls not in classes or int(difficult)==1:
continue
cls_id = classes.index(cls)
xmlbox = obj.find('bndbox')
# 获取左上角右下角坐标
b = (int(float(xmlbox.find('xmin').text)), int(float(xmlbox.find('ymin').text)), int(float(xmlbox.find('xmax').text)), int(float(xmlbox.find('ymax').text)))
# 最后组成五个元素。
list_file.write(" " + ",".join([str(a) for a in b]) + ',' + str(cls_id))
这样就构建好了数据集信息,list_file
的每行是文件名+每个box的五个属性。
在目标检测任务中,无论检测还是训练,都会把图片缩放为指定尺寸作为输入。然后再做backbone和后续的neck、head。那么在dataset类中将gt的box缩放为模型输入层尺寸也是很有必要的,缩放后的box更方便找到在对应特征层的位置。
现在较为流行的做法是先将图片和携带的box缩放为指定输入尺寸中,在做归一化,使用的时候乘以对应特征层的宽高,就转换成了对应特征层的box尺寸,可以用于跟预测框做对比。
# 获得图像的高宽
iw, ih = image.size
# 获得模型输入层的高宽
h, w = input_shape
# 不失真缩放,获得缩放尺度
scale = min(w/iw, h/ih)
# 缩放后的宽
nw = int(iw*scale)
# 缩放后的高
nh = int(ih*scale)
# 图像缩放后起始点的坐标
dx = (w-nw)//2
dy = (h-nh)//2
# 这时的box是xml中的左上右下坐标形式
box = np.array([np.array(list(map(int,box.split(',')))) for box in line[1:]])
if len(box)>0:
np.random.shuffle(box)
# 缩放后,x轴两个坐标的位置
box[:, [0,2]] = box[:, [0,2]]*nw/iw + dx
# 缩放后,y轴两个坐标的位置
box[:, [1,3]] = box[:, [1,3]]*nh/ih + dy
# 如果左上角越界,则置为目标的左上角
box[:, 0:2][box[:, 0:2]<0] = 0
# 如果右小角越界,则置为目标的右下角
box[:, 2][box[:, 2]>w] = w
box[:, 3][box[:, 3]>h] = h
# 删除无效框,有可能缩放后没有了长宽。
box_w = box[:, 2] - box[:, 0]
box_h = box[:, 3] - box[:, 1]
box = box[np.logical_and(box_w>1, box_h>1)] # discard invalid box
# 归一化处理
box[:, [0, 2]] = box[:, [0, 2]] / w
box[:, [1, 3]] = box[:, [1, 3]] / h
# 归一化后的宽高
box[:, 2:4] = box[:, 2:4] - box[:, 0:2]
# 归一化后的中心点坐标
box[:, 0:2] = box[:, 0:2] + box[:, 2:4] / 2
至此,gt的box已经针对模型输入的高宽做了归一化。**还是五个元素,前两个是归一化后的中心点坐标,第三四个是归一化后的宽高,第五个是分类索引。**做了归一化以后就有了相对位置,相对位置乘以特征层的宽高就可以将box转换到特征层上。
# 根据先验框获取anchor列表
anchors = [float(x) for x in anchors.split(',')]
# 将anchor转换为[9, 2]形式,一共九个anchor,每个特征框两个元素,表示宽高
anchors = np.array(anchors).reshape(-1, 2)
# 使用时,根据缩放倍率将先验框调整到特征层尺寸
# in_w, in_h是特征层的宽高
stride_h = self.input_shape[0] / in_h
stride_w = self.input_shape[1] / in_w
# 此时获得的scaled_anchors是相对于特征层的anchor尺寸
scaled_anchors = [(a_w / stride_w, a_h / stride_h) for a_w, a_h in self.anchors]
接触过anchor_based目标检测的小伙伴应该都明白,我们一般会准备9个先验框。一般网络输出3个特征层,针对每个特征层的每个特征点生成3个不同尺寸的特征框。
本次使用的方式没有对每个特征层传递进3个先验框,而是对每个特征层都做9个先验框,在使用的时候判断想要的先验框是否属于这一层,我觉得有一些问题,后续可以优化一下。
先验框anchor是通过真实框聚类得到的,本文使用的是[12, 16, 19, 36, 40, 28, 36, 75, 76, 55, 72, 146, 142, 110, 192, 243, 459, 401]。
对于三个特征层,代码设置了anchor_mask=[[6, 7, 8], [3, 4, 5], [0, 1, 2]],来限制后面获得的anchor索引在不在对应层上。
target
使用方式anchor_shapes = torch.FloatTensor(torch.cat((torch.zeros((len(anchors), 2)), torch.FloatTensor(scaled_anchors)), 1))
# 这里的in_w, in_h是特征层的宽高
batch_target[:, [0,2]] = targets[:, [0,2]] * in_w
batch_target[:, [1,3]] = targets[:, [1,3]] * in_h
batch_target[:, 4] = targets[:, 4]
# bacth_target为target转换为对应特征层,前两位是gt转换后的中心点坐标,[2:4]是宽高,[4]是类别索引
gt_box = torch.FloatTensor(torch.cat((torch.zeros((batch_target.size(0), 2)), batch_target[:, 2:4]), 1))
tip: 这里填两列0的目的是因为,做真实框和先验框的IOU,和后面负样本NMS
做IOU,调用的是同一个calculate_iou()
,所以补充了一下格式,读到后面再看一眼代码就能理解,不要纠结这两列0的问题。
# self.calculate_iou()返回的形式为[n_gt, 9]
# 对-1取argmax,得到针对每个gt_box,iou最大的先验框的索引
best_ns = torch.argmax(self.calculate_iou(gt_box, anchor_shapes), dim=-1)
有了先验框的索引就知道是哪个先验框,那么就可以把这个先验框当作正样本。就把target映射到了先验框上。
# 构造全零张量作为y_true,表示包含目标的先验框,[batch_size, 3, 特征层宽,特征层高,5 + num_classes],发现正样本在指定位置的[...,4]填1
y_true = torch.zeros(bs, 3, in_h, in_w, 5 + num_classes, requires_grad = False)
# 构造全1张量作为noobj_mask,表示不包含目标的先验框,[batch_size, 3, 特征层宽,特征层高],发现正样本在指定位置填0
noobj_mask = torch.ones(bs, 3, in_h, in_w, requires_grad = False)
for t, best_n in enumerate(best_ns):
# 在这里判断了iou最大的anchor是不是属于这一层
# l表示第几个特征层,self.anchors_mask = [[6,7,8], [3,4,5], [0,1,2]]
if best_n not in self.anchors_mask[l]:
continue
# 判断这个框是当前特征点的哪一个先验框
k = self.anchors_mask[l].index(best_n)
# 获得这个框属于真实框的哪个网格点
i = torch.floor(batch_target[t, 0]).long()
j = torch.floor(batch_target[t, 1]).long()
# 取出这个框的种类
c = batch_target[t, 4].long()
# noobj_mask代表无目标的特征点
noobj_mask[b, k, j, i] = 0
# y_true[..., 4] = 1 代表包含目标的先验框
y_true[b, k, j, i, 0] = batch_target[t, 0]
y_true[b, k, j, i, 1] = batch_target[t, 1]
y_true[b, k, j, i, 2] = batch_target[t, 2]
y_true[b, k, j, i, 3] = batch_target[t, 3]
y_true[b, k, j, i, 4] = 1
# y_ture[..., 5:]构造成one-hot形式
y_true[b, k, j, i, c + 5] = 1
至此,我们得到了2个非常重要的target张量:
这时我们会发现有个问题:正负样本也太不均衡了吧?我们y_true==1的数量就是可能比target的数量少,因为IOU最大的先验框索引可能不属于这一层。但是负样本却是特征点所有anchor的数量-正样本数量。
那怎么解决呢?前面说calculate_iou()
的时候已经提到了,负样本会做NMS
的。
别急,后面会解决的。先看看YOLOV4
的网络结构在回到这个问题吧。
YOLO_BODY
YOLOV4
的BODY部分主要有四部分组成:Backbone、SPP、PANet、Head
Backbone
使用的是CSPDarknet53
,输出三个特征层:SPP
特征金字塔池化,在做三个卷积,得到[[batch_size, 512, h/32, w/32]SPP
的最小特征层做PANet
,输出三个有效特征层:YOLO_BODY
输出的三个特征层尺寸不一致,所有对每个特征层分别做YOLO_HEAD,YOLO_HEAD比较简单,只做两层卷积,先放大在缩小维度。最后输出3 * (5 + num_class
)个维度。最后输出:num_class
), w/8, h/8]num_class
), w/16, h/16]num_class
), w/32, h/32]输入图片经YOLO_BODY
后,获得三个特征层, 分别对输入图片做了8、16、32尺寸的下采样,每层特征图中,3为每个特征点有3个anchor,5为(中心点x的偏移,中心点y的偏移,anchor宽,anchor高,置信度), num_class
为每个类别的得分。
有了YOLO_BODY
输出的三个特征层,我们就可以解码成预测框了吖。解码的过程就是将每个网格点加上它对应的x_offset和y_offset,加完后的结果就是预测框的中心,然后再利用先验框和h、w结合计算出预测框的长和宽。这样就能得到整个预测框的位置了。
# 预测框的中心位置的调整参数
x = torch.sigmoid(prediction[..., 0])
y = torch.sigmoid(prediction[..., 1])
# 预测框的宽高调整参数
w = prediction[..., 2]
h = prediction[..., 3]
# 生成网格
grid_x = torch.linspace(0, in_w - 1, in_w).repeat(in_h, 1).repeat(
int(bs * len(self.anchors_mask[l])), 1, 1).view(x.shape).type(FloatTensor)
grid_y = torch.linspace(0, in_h - 1, in_h).repeat(in_w, 1).t().repeat(
int(bs * len(self.anchors_mask[l])), 1, 1).view(y.shape).type(FloatTensor)
# 生成预测框的宽高
scaled_anchors_l = np.array(scaled_anchors)[self.anchors_mask[l]]
anchor_w = FloatTensor(scaled_anchors_l).index_select(1, LongTensor([0]))
anchor_h = FloatTensor(scaled_anchors_l).index_select(1, LongTensor([1]))
anchor_w = anchor_w.repeat(bs, 1).repeat(1, 1, in_h * in_w).view(w.shape)
anchor_h = anchor_h.repeat(bs, 1).repeat(1, 1, in_h * in_w).view(h.shape)
# 计算调整后的预测框中心与宽高
pred_boxes_x = torch.unsqueeze(x + grid_x, -1)
pred_boxes_y = torch.unsqueeze(y + grid_y, -1)
pred_boxes_w = torch.unsqueeze(torch.exp(w) * anchor_w, -1)
pred_boxes_h = torch.unsqueeze(torch.exp(h) * anchor_h, -1)
# pred_boxes的形式为[batch_size, 3, w/下采样倍率, h/下采样倍率, 4]
pred_boxes = torch.cat([pred_boxes_x, pred_boxes_y, pred_boxes_w, pred_boxes_h], dim = -1)
至此,我们得到了预测框信息的张量。
pred_boxes:形式为[batch_size, 3, in_w, in_h, 4],存放着预测框的中心点和宽高信息。
现在开始解决正负样本不均衡的问题:根据IOU对负样本noobj_mask
做NMS
,减少负样本数量。
关于NMS
可以参考博文由non_max_suppression思考box形成过程
noobj_mask
是个形式为[batch_size, 3, 特征层宽,特征层高],全1的张量。表示的是不包含目标的先验框。在构造y_true的时候,只是把包含目标的先验框给置0,所以包含了众多的负样本。造成了正负样本数量极度不均衡,所以要通过NMS,把预测框和真实框IOU大于阈值的都当作不是不包含目标的先验框。
这个话术有点绕,但是我觉得如果表达为预测框和真实框IOU大于阈值的当作包含目标的先验框并不合理,因为y_ture[…,4] == 1表达的是包含目标的先验框。而对noobj_mask
做NMS
,并没有对y_ture做任何操作,只是减少了负样本的数量,没有增加正样本的数量。所以把这个话术写成了不是不包含目标的先验框。
# 遍历每一张图片
for b in range(bs):
# 转换形式为[3 * in_w * in_h, 4]
pred_boxes_for_ignore = pred_boxes[b].view(-1, 4)
# targets的形式本来就是[n_gt, 4]
batch_target = torch.zeros_like(targets[b])
batch_target[:, [0,2]] = targets[b][:, [0,2]] * in_w
batch_target[:, [1,3]] = targets[b][:, [1,3]] * in_h
batch_target = batch_target[:, :4]
# 计算IOU
anch_ious = self.calculate_iou(batch_target, pred_boxes_for_ignore)
# pred_boxes_for_ignore的每个点有3个特征框
# 选择pred_boxes_for_ignore上每个点上与batch_target交并比最大的特征框
# 形式为[3 * in_w * in_h,]
anch_ious_max, _ = torch.max(anch_ious, dim=0)
# 转为[3, in_w, in_h]
anch_ious_max = anch_ious_max.view(pred_boxes[b].size()[:3])
# 每个点3个特征框里与target交并比最大的不当作不包含目标
noobj_mask[b][anch_ious_max > self.ignore_threshold] = 0
至此,我们得到了2个非常重要的target张量:
还有3个非常重要的预测框张量:
YOLO_LOSS
YOLO
系列的损失函数通常都是三部分:
Yolov4
一般用的是CIOU_loss
loss_loc = torch.sum(1 - self.box_ciou(pred_boxes[y_true[..., 4] == 1], y_true[..., :4][y_true[..., 4] == 1]))
pred_boxes的形式为[batch_size, 3, h/下采样倍数, w/下采样倍数, 4],表示batch_size数量,特征点数量为[(h/下采样倍数) * (w/下采样倍数)],每个点有3个预测框,每个框有4个值,分别表示[预测框中心点x坐标,预测框中心点y坐标,预测框的宽,预测框的高]
取真实框(即y_true[..., 4] == 1
)对应的预测框,与真实框的前四项做CIOU
。
BCELoss
)conf = torch.sigmoid(prediction[..., 4])
loss_conf = torch.sum(self.BCELoss(conf, y_true[..., 4]) * y_true[..., 4]) + torch.sum(self.BCELoss(conf, y_true[..., 4]) * noobj_mask)
conf
的形式是[batch_size, 3, w/下采样倍数, h/下采样倍数,],表示每个预测框的置信度
这次的计算分为两个部分:分别计算了预测框与正样本的BCELoss
和与负样本的BCELoss
,然后求和。
BCELoss
)pred_cls = torch.sigmoid(prediction[..., 5:])
loss_cls = torch.sum(self.BCELoss(pred_cls[y_true[..., 4] == 1], y_true[..., 5:][y_true[..., 4] == 1]))
pred_cls
的形式是[batch_size, 3, w/下采样倍数, h/下采样倍数, num_class
],表示每个预测框对于各类型的得分。
关于BCELoss
可以参考博文 利用pytorch来深入理解CELoss、BCELoss和NLLLoss之间的关系
anchor_based的目标检测一般有三个很重要的元素:先验框anchor、真实框target、预测框prediction
yolo的anchor一般都是9个,每个特征层有三个不同大小的anchor,anchor可以自己聚类生成,但是用默认的也够用。
真实框是手动标注好的,一般储存形式为xml。在制作dataset阶段将其归一化,记录为中心点坐标+宽高。在训练过程中,将归一化的target根据特征层的宽高进行放大,计算在该特征层属于哪个网格。同时找到与target最匹配的anchor,用于当作正样本。
预测框就是经网络前向传播获得的张量。
输入图像经backbone和body部分输出三个有效特征层,分别做32、16、8倍下采样。对每个特征层分别做head预测,输出预测层。每个预测层的每个特征点携带三个anchor的信息,信息内容为5+类别数。5分别为预测中心x,预测中心y,预测框宽,预测框高,置信度。
然后获取正样本的位置,与预测框对应位置的数据进行损失函数计算。yolo的损失函数分成三部分:box损失,置信度损失,分类损失。
box损失可以用iou或者iou的各种优化形态。置信度损失是做二分类损失,CELOSS或者BCELoss都行。分类损失是多分类损失,一般用BCELOSS。