之前,我们依据《YOLO目标检测》(ISBN:9787115627094)
一书,提出了新的YOLOV1架构,并解决前向推理过程中的两个问题,继续按照此书进行YOLOV1的复现。
经典目标检测YOLO系列(一)YOLOV1的复现(1)总体架构
经典目标检测YOLO系列(一)复现YOLOV1(2)反解边界框及后处理
YOLOV1中,正样本的匹配算法很简单,就是目标边界框的中心落到feature map的哪个网格中,哪个网格就是正样本
。如下图,黄色网格就是正样本。
后面会利用pytorch读取VOC数据集:
一批图像数据的维度是 [B, 3, H, W] ,分别是batch size,色彩通道数,图像的高和图像的宽。
标签数据是一个包含 B 个图像的标注数据的python的list变量(如下所示
),其中,每个图像的标注数据的list变量又包含了 M 个目标的信息(类别和边界框)。
获得了这一批数据后,图片是可以直接喂到网络里去训练的,但是标签不可以,需要再进行处理一下。
[
{
'boxes': tensor([[ 29., 230., 148., 321.]]), # bbox的坐标(xmin, ymin, xmax, ymax)
'labels': tensor([18.]), # 标签
'orig_size': [281, 500] # 图片的原始大小
},
{
'boxes': tensor([[ 0., 79., 416., 362.]]),
'labels': tensor([1.]),
'orig_size': [375, 500]
}
]
标签处理主要包括3个部分,
# 处理好的shape如下:
# gt_objectness
torch.Size([2, 169, 1]) # 169=13×13
# gt_classes
torch.Size([2, 169, 20])
# gt_bboxes
torch.Size([2, 169, 4])
# RT-ODLab/models/detectors/yolov1/matcher.py
import torch
import numpy as np
# YoloV1 正样本制作
class YoloMatcher(object):
def __init__(self, num_classes):
self.num_classes = num_classes
@torch.no_grad()
def __call__(self, fmp_size, stride, targets):
"""
fmp_size: (Int) input image size 用于最终检测的特征图的空间尺寸,即划分网格的尺寸
stride: (Int) -> stride of YOLOv1 output. 特征图的输出步长
targets: (Dict) dict{'boxes': [...],
'labels': [...],
'orig_size': ...} 一批数据的标签
targets是List类型的变量,每一个元素都是一个Dict类型,
包含boxes和labels两个key,
对应的value就是【一张图片中的目标框的尺寸】和【类别标签】。
"""
# prepare
# 准备一些空变量,后续我们会将正样本的数据存放到其中,比如gt_objectness,其shape就是[B, fmp_h, fmp_w, 1],
# 其中,B就是batch size,
# [fmp_h, fmp_w] 就是特征图尺寸,即网格,
# 1就是objectness的标签值
# 所有网格的值都会初始化为0,即负样本或背景,在后续的处理中,我们会一一确定哪些网格是正样本区域。
bs = len(targets)
fmp_h, fmp_w = fmp_size
gt_objectness = np.zeros([bs, fmp_h, fmp_w, 1])
gt_classes = np.zeros([bs, fmp_h, fmp_w, self.num_classes])
gt_bboxes = np.zeros([bs, fmp_h, fmp_w, 4])
# 第一层for循环遍历每一张图像的标签
for batch_index in range(bs):
# targets_per_image是python的Dict类型
targets_per_image = targets[batch_index]
# [N,] N表示一个图像中有N个目标对象
tgt_cls = targets_per_image["labels"].numpy()
# [N, 4]
tgt_box = targets_per_image['boxes'].numpy()
# 第二层for循环遍历这张图像标签的每一个目标数据
for gt_box, gt_label in zip(tgt_box, tgt_cls):
x1, y1, x2, y2 = gt_box
# xyxy -> cxcywh
xc, yc = (x2 + x1) * 0.5, (y2 + y1) * 0.5
bw, bh = x2 - x1, y2 - y1
# check
if bw < 1. or bh < 1.:
continue
# grid 计算这个目标框中心点所在的网格坐标
xs_c = xc / stride
ys_c = yc / stride
grid_x = int(xs_c)
grid_y = int(ys_c)
if grid_x < fmp_w and grid_y < fmp_h:
# objectness标签,采用0,1离散值
gt_objectness[batch_index, grid_y, grid_x] = 1.0
# classification标签,采用one-hot格式
cls_ont_hot = np.zeros(self.num_classes)
cls_ont_hot[int(gt_label)] = 1.0
gt_classes[batch_index, grid_y, grid_x] = cls_ont_hot
# box标签,采用目标框的坐标值
gt_bboxes[batch_index, grid_y, grid_x] = np.array([x1, y1, x2, y2])
# [B, M, C]
gt_objectness = gt_objectness.reshape(bs, -1, 1)
gt_classes = gt_classes.reshape(bs, -1, self.num_classes)
gt_bboxes = gt_bboxes.reshape(bs, -1, 4)
# to tensor
gt_objectness = torch.from_numpy(gt_objectness).float()
gt_classes = torch.from_numpy(gt_classes).float()
gt_bboxes = torch.from_numpy(gt_bboxes).float()
return gt_objectness, gt_classes, gt_bboxes
if __name__ == '__main__':
matcher = YoloMatcher(num_classes=20)
targets = [
{
'boxes': torch.tensor([[ 29., 230., 148., 321.]]), # bbox的坐标(xmin, ymin, xmax, ymax)
'labels': torch.tensor([18.]), # 标签
'orig_size': [281, 500] # 图片的原始大小
},
{
'boxes': torch.tensor([[ 0., 79., 416., 362.]]),
'labels': torch.tensor([1.]),
'orig_size': [375, 500]
}
]
gt_objectness, gt_classes, gt_bboxes = matcher(fmp_size=(13, 13),stride=32, targets=targets )
print(gt_objectness.shape)
print(gt_classes.shape)
print(gt_bboxes.shape)
关键代码解释:
尽管没有直接给出中心点偏移量和log处理后的宽高值的标签,但在回归时,我们已经用sigmoid和exp约束了模型的预测的偏移量
。因此,在训练时,模型仍旧会学习到我们希望他们能学习的正确形式,即预测的偏移量在sigmoid和exp处理后会是合理的值。以上就是训练阶段制作正样本的方法
,对于某次训练迭代所给的一批标签,经过YoloMatcher类的处理后,我们得到了包含objectness标签、classification标签、bbox标签的变量:gt_objectness、gt_classes、gt_bboxes 。下面,我们就可以编写计算训练的损失的代码。这里修改损失函数,将YOLOV1原本的MSE loss,分类分支替换为BCE loss,回归分支替换为GIou loss。
# RT-ODLab/models/detectors/yolov1/loss.py
import torch
import torch.nn.functional as F
from .matcher import YoloMatcher
from utils.box_ops import get_ious
from utils.distributed_utils import get_world_size, is_dist_avail_and_initialized
class Criterion(object):
def __init__(self, cfg, device, num_classes=80):
self.cfg = cfg
self.device = device
self.num_classes = num_classes
self.loss_obj_weight = cfg['loss_obj_weight']
self.loss_cls_weight = cfg['loss_cls_weight']
self.loss_box_weight = cfg['loss_box_weight']
# matcher
self.matcher = YoloMatcher(num_classes=num_classes)
def loss_objectness(self, pred_obj, gt_obj):
# 此函数内部会自动做数值稳定版本的sigmoid操作
# 因此,输入给该函数的预测值不需要预先做sigmoid函数处理,这也就是为什么在此前搭建的YOLOv1模型中的forward函数中看不到对objectness预测和classification预测做sigmoid处理,
# 当然,在推理时,我们还是要这么手动做的,这一点也能够在YOLOv1模型的inference函数中看到。
loss_obj = F.binary_cross_entropy_with_logits(pred_obj, gt_obj, reduction='none')
return loss_obj
def loss_classes(self, pred_cls, gt_label):
loss_cls = F.binary_cross_entropy_with_logits(pred_cls, gt_label, reduction='none')
return loss_cls
def loss_bboxes(self, pred_box, gt_box):
# regression loss
ious = get_ious(pred_box,
gt_box,
box_mode="xyxy",
iou_type='giou')
loss_box = 1.0 - ious
return loss_box
def __call__(self, outputs, targets, epoch=0):
device = outputs['pred_cls'][0].device
stride = outputs['stride']
fmp_size = outputs['fmp_size']
(
gt_objectness,
gt_classes,
gt_bboxes,
) = self.matcher(fmp_size=fmp_size,
stride=stride,
targets=targets)
# List[B, M, C] -> [B, M, C] -> [BM, C]
# 为了方便后续的计算,将预测和标签的shape都从[B, M, C]调整成[BM, C],
# 这一步没有任何数学意义,仅仅是出于计算的方便。
pred_obj = outputs['pred_obj'].view(-1) # [BM,]
pred_cls = outputs['pred_cls'].view(-1, self.num_classes) # [BM, C]
pred_box = outputs['pred_box'].view(-1, 4) # [BM, 4]
gt_objectness = gt_objectness.view(-1).to(device).float() # [BM,]
gt_classes = gt_classes.view(-1, self.num_classes).to(device).float() # [BM, C]
gt_bboxes = gt_bboxes.view(-1, 4).to(device).float() # [BM, 4]
pos_masks = (gt_objectness > 0)
num_fgs = pos_masks.sum() # 正样本的数量
if is_dist_avail_and_initialized():
torch.distributed.all_reduce(num_fgs)
# 考虑到我们可能会用到多张GPU,因此我们需要将所有GPU上的正样本数量num_fgs做个平均。
num_fgs = (num_fgs / get_world_size()).clamp(1.0)
# obj loss
# objectness损失,由于这一损失是全局操作,即所有的正样本和负样本都要参与进来,因此没有特殊的操作,直接计算即可,然后做归一化。
loss_obj = self.loss_objectness(pred_obj, gt_objectness)
loss_obj = loss_obj.sum() / num_fgs
# cls loss
# 对于classification损失,我们只计算正样本处的这部分损失
# 因此,我们需要先使用先前得到的pos_masks取出正样本处的预测和标签,然后再去计算损失和归一化。
pred_cls_pos = pred_cls[pos_masks]
gt_classes_pos = gt_classes[pos_masks]
loss_cls = self.loss_classes(pred_cls_pos, gt_classes_pos)
loss_cls = loss_cls.sum() / num_fgs
# box loss
# 对于bbox损失,操作基本同上,取出正样本处的预测和标签,然后计算损失,最后再做一次归一化。
pred_box_pos = pred_box[pos_masks]
gt_bboxes_pos = gt_bboxes[pos_masks]
loss_box = self.loss_bboxes(pred_box_pos, gt_bboxes_pos)
loss_box = loss_box.sum() / num_fgs
# total loss
losses = self.loss_obj_weight * loss_obj + \
self.loss_cls_weight * loss_cls + \
self.loss_box_weight * loss_box
# 最后,将所有的损失加权求和,存放在一个Dict变量里去,输出即可
loss_dict = dict(
loss_obj = loss_obj,
loss_cls = loss_cls,
loss_box = loss_box,
losses = losses
)
return loss_dict
损失函数的实现比较简单,这里重点介绍下GIou loss:
MSE作为损失函数的缺点:
原版YOLOV1中使用MSE作为损失函数,之后Fast R-CNN提出了Smooth L1的损失函数,它们的共同点都是使用两个角点,四个坐标作为计算损失函数的变量,我们称之为ln-norm算法。
绿色框是ground truth,黑色框是预测bounding box。我们假设预测框的左下角是固定的,只要右上角在以ground truth为圆心的圆周上这些预测框都有相同的 ln 损失值,但是很明显它们的检测效果的差距是非常大的。与之对比的是IoU和GIoU则在这几个不同的检测框下拥有不同的值,比较真实的反应了检测效果的优劣。
因此 ln -norm损失函数不和检测结果强相关。
IOU作为损失函数:
L I o U = 1 ? ∣ A ? B ∣ ∣ A ? B ∣ L_{IoU}=1-\frac{|A \bigcap B|}{|A \bigcup B|} LIoU?=1?∣A?B∣∣A?B∣?
IOU作为损失函数的特点:
尺度不变性:IoU反应的是两个检测框交集和并集之间的比例,因此和检测框的大小无关。而 ln 则是和尺度相关的,对于相同损失值的大目标和小目标,大目标的检测效果要优于小目标,因此IoU损失对于小目标的检测也是有帮助的;
IoU是一个距离:这个距离是指评估两个矩形框之间的一个指标,这个指标具有distance的一切特性,包括对称性,非负性,同一性,三角不等性。
IoU损失的最大问题是当两个物体没有互相覆盖时,损失值都会变成1,而不同的不覆盖情况明显也反应了检测框的优劣
,如下图所示。可以看出当ground truth(黑色框)和预测框(绿色框)没有交集时,IOU的值都是0,而GIoU则拥有不同的值,而且和检测效果成正相关。
GIOU作为损失函数:
GIoU损失拥有IoU损失的所有优点,但是也具有IoU损失不具有的一些特性。
GIoU的目标相当于在损失函数中加入了一个ground truth和预测框构成的闭包的惩罚,它的惩罚项是闭包减去两个框的并集后的面积在闭包中的比例越小越好
。
如下所示,闭包是红色虚线的矩形,我们要最小化阴影部分的面积除以闭包的面积。
计算公式如下:先计算两个框的最小闭包区域面积 Ac (通俗理解:同时包含了预测框和真实框的最小框的面积),再计算出IoU,再计算闭包区域中不属于两个框的区域占闭包区域的比重,最后用IoU减去这个比重得到GIoU。
G I o U = I o U ? ∣ A c ? U ∣ ∣ A c ∣ G_{IoU}=IoU-\frac{|A_c - U|}{|A_c|} GIoU?=IoU?∣Ac?∣∣Ac??U∣?
GIoU损失优化的是当两个矩形框没有重叠时候的情况
,而当两个矩形框的位置非常接近时,GIoU损失和IoU损失的值是非常接近的,因此在某些场景下使用两个损失的模型效果应该比较接近,但是GIoU应该具有更快的收敛速度。从GIoU的性质中我们可以看出GIoU在两个矩形没有重叠时,它的优化目标是最小化两个矩形的闭包或是增大预测框的面积
,但是第二个目标并不是十分直接。当然还有别的损失函数,可以参考:
IoU、GIoU、DIoU、CIoU损失函数的那点事儿 - 知乎 (zhihu.com)
GIoU的实现:
import numpy as np
import torch
def get_giou(bboxes1, bboxes2):
"""
计算GIOU值
:param bboxes1: 预测框
:param bboxes2: 真实框
:return:
"""
eps = torch.finfo(torch.float32).eps
bboxes1_area = (bboxes1[..., 2] - bboxes1[..., 0]).clamp_(min=0) \
* (bboxes1[..., 3] - bboxes1[..., 1]).clamp_(min=0) # 所有预测框的面积
bboxes2_area = (bboxes2[..., 2] - bboxes2[..., 0]).clamp_(min=0) \
* (bboxes2[..., 3] - bboxes2[..., 1]).clamp_(min=0) # 所有真实框的面积
w_intersect = (
torch.min(bboxes1[..., 2], bboxes2[..., 2]) # 相交区域的w
- torch.max(bboxes1[..., 0], bboxes2[..., 0])
).clamp_(min=0)
h_intersect = (
torch.min(bboxes1[..., 3], bboxes2[..., 3])
- torch.max(bboxes1[..., 1], bboxes2[..., 1]) # 相交区域的h
).clamp_(min=0)
area_intersect = w_intersect * h_intersect # 相交区域面积
area_union = bboxes2_area + bboxes1_area - area_intersect # 两个区域的并集
ious = area_intersect / area_union.clamp(min=eps) # 计算预测框和真实框之间的IOU值
g_w_intersect = torch.max(bboxes1[..., 2], bboxes2[..., 2]) - torch.min(bboxes1[..., 0], bboxes2[..., 0]) # 两个区域并集的w
g_h_intersect = torch.max(bboxes1[..., 3], bboxes2[..., 3]) - torch.min(bboxes1[..., 1], bboxes2[..., 1]) # 两个区域并集的h
ac_uion = g_w_intersect * g_h_intersect # Ac
gious = ious - (ac_uion - area_union) / ac_uion.clamp(min=eps)
return gious
if __name__ == '__main__':
pred_box = torch.tensor(np.asarray([
[1, 1, 3, 3.2],
[1, 1, 4, 4]
]) * 100)
gt_box = torch.tensor(np.asarray([
[2, 2, 4.4, 4.5],
[1, 1, 4, 4]
]) * 100)
print(get_giou(bboxes1=pred_box, bboxes2=gt_box))
现在,我们已经搭建好了模型,也写好了标签分配和计算损的代码,下一步即可准备开始训练我们的模型。
不过至今为止,我们都还没有详细讲数据一环,包括数据读取、数据预处理和数据增强
等十分重要的操作。因此,在正式开始训练我们的模型之前,还需要进行数据操作。