YOLOV4为例详解anchor_based目标检测训练过程

yolov4的代码使用的是Bubbliiiing大神提供的代码,代码地址是https://github.com/bubbliiiing/yolov4-pytorch。


target形成过程

将xml中的标注信息转为box形式

首先写入图片名,然后经过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的五个属性。

image-20211207104052151


读取box构建target

在目标检测任务中,无论检测还是训练,都会把图片缩放为指定尺寸作为输入。然后再做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形成过程

# 根据先验框获取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使用方式

  1. 转换先验框形式为[9, 4],前两列是[0, 0],后两列是anchor转换到特征层的宽高
anchor_shapes = torch.FloatTensor(torch.cat((torch.zeros((len(anchors), 2)), torch.FloatTensor(scaled_anchors)), 1))
  1. 将真实框转换形式为[n_gt, 4]。前两列是[0, 0],后两列是gt转换到特征层的宽高
# 这里的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的问题。

  1. 计算先验框和真实框的IOU,取跟真实框IOU最大的先验框的索引。
# 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映射到了先验框上。

  1. 遍历索引,构造y_true和no_objmask两类正负样本张量。
# 构造全零张量作为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: 形式为[batch_size, 3, 特征层宽,特征层高,5 + num_classes], 存放着正样本框的中心点和宽高、类别索引、各类别的one-hot编码。
  • noobj_mask: 形式为[batch_size, 3, 特征层宽,特征层高], 存放着负样本框。

这时我们会发现有个问题:正负样本也太不均衡了吧?我们y_true==1的数量就是可能比target的数量少,因为IOU最大的先验框索引可能不属于这一层。但是负样本却是特征点所有anchor的数量-正样本数量。

那怎么解决呢?前面说calculate_iou()的时候已经提到了,负样本会做NMS的。

别急,后面会解决的。先看看YOLOV4的网络结构在回到这个问题吧。


YOLO_BODY

在这里插入图片描述

YOLOV4的BODY部分主要有四部分组成:Backbone、SPP、PANet、Head

  1. Backbone使用的是CSPDarknet53,输出三个特征层:
  • [batch_size, 256, w/8, h/8]
  • [batch_size, 512, w/16, h/16]
  • [batch_size, 1024, w/32, h/32]
  1. 对32倍下采样的特征层做个卷积调整层数,然后SPP特征金字塔池化,在做三个卷积,得到[[batch_size, 512, h/32, w/32]
  2. 对backbone得到的前两个特征层以及做过SPP的最小特征层做PANet,输出三个有效特征层:
  • [batch_size, 128, w/8, h/8]
  • [batch_size, 256, w/16, h/16]
  • [batch_size, 512, w/32, h/32]
  1. 三个有效特征层分别经过一次Head,得到三个结果层。
    由于YOLO_BODY输出的三个特征层尺寸不一致,所有对每个特征层分别做YOLO_HEAD,YOLO_HEAD比较简单,只做两层卷积,先放大在缩小维度。最后输出3 * (5 + num_class)个维度。最后输出:
  • [batch_size, 3 * (5 + num_class), w/8, h/8]
  • [batch_size, 3 * (5 + num_class), w/16, h/16]
  • [batch_size, 3 * (5 + 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_maskNMS,减少负样本数量。

关于NMS可以参考博文由non_max_suppression思考box形成过程

noobj_mask是个形式为[batch_size, 3, 特征层宽,特征层高],全1的张量。表示的是不包含目标的先验框。在构造y_true的时候,只是把包含目标的先验框给置0,所以包含了众多的负样本。造成了正负样本数量极度不均衡,所以要通过NMS,把预测框和真实框IOU大于阈值的都当作不是不包含目标的先验框。

这个话术有点绕,但是我觉得如果表达为预测框和真实框IOU大于阈值的当作包含目标的先验框并不合理,因为y_ture[…,4] == 1表达的是包含目标的先验框。而对noobj_maskNMS,并没有对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张量:

  • y_true: 形式为[batch_size, 3, 特征层宽,特征层高,5 + num_classes], 存放着正样本框的中心点和宽高、类别索引、各类别的one-hot编码。
  • noobj_mask: 形式为[batch_size, 3, 特征层宽,特征层高], 存放着负样本框。

还有3个非常重要的预测框张量:

  • pred_boxes:形式为[batch_size, 3, in_w, in_h, 4],存放着预测框的中心点和宽高信息。
  • conf = torch.sigmoid(prediction[…, 4]):形式为[batch_size, 3, 特征层宽,特征层高],存放着预测框是否有目标的置信度。
  • pred_cls = torch.sigmoid(prediction[…, 5:]):形式为[batch_size, 3, 特征层宽,特征层高,num_classes],存放着预测框对于每个类别的分类置信度。

YOLO_LOSS

YOLO系列的损失函数通常都是三部分:

  1. 框的损失(IOU或各种优化形式)

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

  1. 置信度损失(二分类,通常都用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,然后求和。

  1. 分类损失(多分类,通常都用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。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐