论文地址:1506.02640] You Only Look Once: Unified, Real-Time Object Detection (arxiv.org)
代码地址:pjreddie/darknet: Convolutional Neural Networks (github.com)
YOLOv1的中文名称是"你只看一次",这个名字源于算法的工作原理。相比于传统的目标检测算法,YOLOv1采用了全新的思路。**它将目标检测问题转化为一个回归问题,并将整个图像作为输入,一次性地在图像上进行目标检测和定位(单阶段检测模型)。这与传统的滑动窗口或区域提议方法不同,传统方法如RCNN系列(两阶段检测模型)**需要在图像上进行多次检测。
YOLOv1的网络结构由卷积层和全连接层组成,可以将输入图像分割成较小的网格(grid cell)。对于每个网格,YOLOv1预测多个边界框以及每个边界框中是否包含目标物体及其类别。通过对预测结果进行置信度评估和非极大值抑制,可以得到最终的目标检测结果。
相比于传统的目标检测算法,YOLOv1具有较快的检测速度,可以实现实时目标检测。然而,由于网络结构的限制,YOLOv1在小目标检测和物体定位精度上可能存在一定的问题(主要因为YOLO中的一个gird cell只能预测判别一个物体,后文细说)。在后续的版本中,如YOLOv2和YOLOv3等,一些改进措施被提出来解决这些问题(比如从Faster Rcnn中引入anchor等),我们在后续文章中会对YOLO其他版本进行继续讲解。
我们可以将YOLOv1网络看作一个黑盒,在推理阶段,我们将图像resize到448 * 448后输入到网络中,输出的是一个SxSx(B*5+C)的张量,该张量是关于检测框的位置信息以及检测目标的类别信息等,再经过非极大值抑制处理(关于非极大值抑制NMS,如果不理解,可以参考我们以前的内容:【目标检测】 非极大值抑制—NMS_卖报的大地主的博客-CSDN博客),得到最后的检测结果(最终的预测框位置信息、类别及其置信度信息)。
网络输入和分割:将一幅待检测图像分割分割成S*S个网格,,比如7 * 7, 13 * 13,在原文中S=7。
边界框预测: 每个网格预测B个边界框(bounding box),在原文中B=2,**每个边界框包含5个基本参数:位置(x、y坐标)、宽度w、高度h和置信度C。**其中:(x、y)表示边界框的中心相对于其所属网格左上角的位置偏移量;宽度w、高度h表示相对于整个图像的比例;C表示边界框的置信度,confidence = Pr(object) * IOU (pred, truth), Pr(object)代表边界框中存在物体的概率,非0即1,IOU (pred, truth)代表预测框与真实标签框的交并比。
在训练时,某物体的真实边界框的中心落在哪个网格上就由哪个网格负责预测该物体,并且在网格所生成的B个边界框中,与真实边界框的IOU最大的边界框负责预测该物体,这一部分在loss函数中有所体现。每个网格只能预测一个物体(这也是造成YOLOv1对小物体和密集型物体检测效果差的主要原因)。
类别预测: 每个网格还预测一组类别的条件概率Pr(class | object) ,即在当前边界框已包含物体的先验条件下该物体为各类别的概率,用于确定边界框中物体的类别。
在进行推理预测时,每个边界框的类别预测概率与该边界框的置信度相乘,得到最终的类别置信度,即Pr(class | object) * Pr(object) * IOU (pred, truth) = Pr(class) * IOU (pred, truth)。根据同样的方法可以计算得到7 x 7 x 2 = 98个边界框的confidence score(当S =7, B =2时),然后根据confidence score对预测得到的98个边界框进行非极大值抑制,得到最终的检测结果。
网络输出: 网络的输出是一个SxSx(B*5+C)的张量,相当于每个网格都输出一个形状为B * 5+C的张量,包含B个边界框中每个边界框的位置信息和置信度信息(边界框的中心点xy坐标和宽w高h以及置信度信息),以及该网格所负责预测物体的类别概率(C个值,所属C个类别的概率,原文中C等于20,即检测20个类别)。这些位置预测结果是相对于输入图像的尺度的,因此可以通过将相对尺度乘以图像的宽度和高度来得到实际的边界框。
置信度评估和非极大值抑制: 对于每个边界框,根据其置信度和类别置信度进行综合评估。通常,可以使用阈值来筛选置信度较高的边界框,并使用非极大值抑制来消除重叠的边界框,从而得到最终的目标检测结果。
YOLOv1的网络结构比较简单,有24个卷积层以及两个全连接层组成。
如下给出网络的PyTorch实现:
import torch
import torch.nn as nn
import torch.nn.functional as F
class YOLOv1(nn.Module):
def __init__(self, num_classes, num_boxes):
super(YOLOv1, self).__init__()
self.num_classes = num_classes
self.num_boxes = num_boxes
# 网络的各层定义
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3)
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2)
self.conv2 = nn.Conv2d(64, 192, kernel_size=3, stride=1, padding=1)
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2)
self.conv3 = nn.Conv2d(192, 128, kernel_size=1, stride=1, padding=0)
self.conv4 = nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1)
self.conv5 = nn.Conv2d(256, 256, kernel_size=1, stride=1, padding=0)
self.conv6 = nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1)
self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2)
self.conv7 = nn.Conv2d(512, 256, kernel_size=1, stride=1, padding=0)
self.conv8 = nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1)
self.conv9 = nn.Conv2d(512, 256, kernel_size=1, stride=1, padding=0)
self.conv10 = nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1)
self.conv11 = nn.Conv2d(512, 256, kernel_size=1, stride=1, padding=0)
self.conv12 = nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1)
self.conv13 = nn.Conv2d(512, 256, kernel_size=1, stride=1, padding=0)
self.conv14 = nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1)
self.conv15 = nn.Conv2d(512, 512, kernel_size=1, stride=1, padding=0)
self.conv16 = nn.Conv2d(512, 1024, kernel_size=3, stride=1, padding=1)
self.pool4 = nn.MaxPool2d(kernel_size=2, stride=2)
self.conv17 = nn.Conv2d(1024, 512, kernel_size=1, stride=1, padding=0)
self.conv18 = nn.Conv2d(512, 1024, kernel_size=3, stride=1, padding=1)
self.conv19 = nn.Conv2d(1024, 512, kernel_size=1, stride=1, padding=0)
self.conv20 = nn.Conv2d(512, 1024, kernel_size=3, stride=1, padding=1)
self.conv21 = nn.Conv2d(1024, 1024, kernel_size=3, stride=1, padding=1)
self.conv22 = nn.Conv2d(1024, 1024, kernel_size=3, stride=2, padding=1)
self.conv23 = nn.Conv2d(1024, 1024, kernel_size=3, stride=1, padding=1)
self.conv24 = nn.Conv2d(1024, 1024, kernel_size=3, stride=1, padding=1)
self.fc1 = nn.Linear(7 * 7 * 1024, 4096)
self.fc2 = nn.Linear(4096, 7 * 7 * (self.num_classes + 5 * self.num_boxes))
self.softmax = nn.Softmax(dim=1)
def forward(self, x):
x = F.leaky_relu(self.conv1(x), negative_slope=0.1)
x = self.pool1(x)
x = F.leaky_relu(self.conv2(x), negative_slope=0.1))
x = self.pool2(x)
x = F.leaky_relu(self.conv3(x), negative_slope=0.1))
x = F.leaky_relu(self.conv4(x), negative_slope=0.1))
x = F.leaky_relu(self.conv5(x), negative_slope=0.1))
x = self.pool3(x)
x = F.leaky_relu(self.conv6(x), negative_slope=0.1))
x = F.leaky_relu(self.conv7(x), negative_slope=0.1))
x = F.leaky_relu(self.conv8(x), negative_slope=0.1))
x = F.leaky_relu(self.conv9(x), negative_slope=0.1))
x = F.leaky_relu(self.conv10(x), negative_slope=0.1))
x = F.leaky_relu(self.conv11(x), negative_slope=0.1))
x = F.leaky_relu(self.conv12(x), negative_slope=0.1))
x = F.leaky_relu(self.conv13(x), negative_slope=0.1))
x = F.leaky_relu(self.conv14(x), negative_slope=0.1))
x = F.leaky_relu(self.conv15(x), negative_slope=0.1))
x = F.leaky_relu(self.conv16(x), negative_slope=0.1))
x = self.pool4(x)
x = F.leaky_relu(self.conv17(x), negative_slope=0.1))
x = F.leaky_relu(self.conv18(x), negative_slope=0.1))
x = F.leaky_relu(self.conv19(x), negative_slope=0.1))
x = F.leaky_relu(self.conv20(x), negative_slope=0.1))
x = F.leaky_relu(self.conv21(x), negative_slope=0.1))
x = F.leaky_relu(self.conv22(x), negative_slope=0.1))
x = F.leaky_relu(self.conv23(x), negative_slope=0.1))
x = F.leaky_relu(self.conv24(x), negative_slope=0.1))
x = x.view(x.size(0), -1)
x = F.leaky_relu(self.fc1(x), negative_slope=0.1))
x = self.fc2(x)
x = self.softmax(x)
return x
YOLOv1将检测视为回归任务,所以选用回归常用的平方和损失作为loss函数,主要由位置坐标回归误差、置信度回归误差、类别预测误差三部分组成。
位置坐标误差只统计负责检测物体的边界框的定位误差,由边界框的中心坐标定位误差和宽高定位误差组成。
置信度回归误差对负责检测物体的边界框和不负责的边界框都进行惩罚,公式中的置信度预测值为模型正向计算的输出结果中的置信度,置信度真实值为confidence = Pr(object) * IOU (pred, truth)。
类别预测误差仅对存在物体的网格(真实边界框的中心点落在该网格中)进行错误惩罚.
Note:
平方和loss对各类误差一视同仁,图像中不包含物体的网格的边界框置信度等于0,容易超过包含物体网格的gradient,会导致模型训练不稳定,所以论文中采用了不同的权重因子,对定位误差和分类误差进行权衡。λcoord
=5 — 增强边界框定位误差;λnoobj
=5 — 削弱不包含物体的边界框置信度误差;
平方和loss对大小边界框的惩罚力度一致,但我们应该使得大框的小偏差比小框中的偏差对loss训练优化的影响要小,原文中将Loss中的宽高进行了开平方根,而不是直接使用宽高,使得小框的偏差对总体误差的影响更加敏感。
YOLOv1 Loss的Pytorch实现:
import torch
import numpy as np
import torch.nn as nn
import torch.nn.functional as F
class MSEWithLogitsLoss(nn.Module):
def __init__(self, ):
super(MSEWithLogitsLoss, self).__init__()
def forward(self, logits, targets):
inputs = torch.clamp(torch.sigmoid(logits), min=1e-4, max=1.0 - 1e-4)
pos_id = (targets==1.0).float()
neg_id = (targets==0.0).float()
pos_loss = pos_id * (inputs - targets)**2
neg_loss = neg_id * (inputs)**2
loss = 5.0*pos_loss + 1.0*neg_loss
return loss
def generate_dxdywh(gt_label, w, h, s):
xmin, ymin, xmax, ymax = gt_label[:-1]
# 计算边界框的中心点
c_x = (xmax + xmin) / 2 * w
c_y = (ymax + ymin) / 2 * h
box_w = (xmax - xmin) * w
box_h = (ymax - ymin) * h
if box_w < 1e-4 or box_h < 1e-4:
# print('Not a valid data !!!')
return False
# 计算中心点所在的网格坐标
c_x_s = c_x / s
c_y_s = c_y / s
grid_x = int(c_x_s)
grid_y = int(c_y_s)
# 计算中心点偏移量和宽高的标签
tx = c_x_s - grid_x
ty = c_y_s - grid_y
tw = np.log(box_w)
th = np.log(box_h)
# 计算边界框位置参数的损失权重
weight = 2.0 - (box_w / w) * (box_h / h)
return grid_x, grid_y, tx, ty, tw, th, weight
def gt_creator(input_size, stride, label_lists=[]):
# 必要的参数
batch_size = len(label_lists)
w = input_size
h = input_size
ws = w // stride
hs = h // stride
s = stride
gt_tensor = np.zeros([batch_size, hs, ws, 1+1+4+1])
# 制作训练标签
for batch_index in range(batch_size):
for gt_label in label_lists[batch_index]:
gt_class = int(gt_label[-1])
result = generate_dxdywh(gt_label, w, h, s)
if result:
grid_x, grid_y, tx, ty, tw, th, weight = result
if grid_x < gt_tensor.shape[2] and grid_y < gt_tensor.shape[1]:
gt_tensor[batch_index, grid_y, grid_x, 0] = 1.0
gt_tensor[batch_index, grid_y, grid_x, 1] = gt_class
gt_tensor[batch_index, grid_y, grid_x, 2:6] = np.array([tx, ty, tw, th])
gt_tensor[batch_index, grid_y, grid_x, 6] = weight
gt_tensor = gt_tensor.reshape(batch_size, -1, 1+1+4+1)
return torch.from_numpy(gt_tensor).float()
def compute_loss(pred_conf, pred_cls, pred_txtytwth, targets):
batch_size = pred_conf.size(0)
# 损失函数
conf_loss_function = MSEWithLogitsLoss()
cls_loss_function = nn.CrossEntropyLoss(reduction='none')
txty_loss_function = nn.BCEWithLogitsLoss(reduction='none')
twth_loss_function = nn.MSELoss(reduction='none')
# 预测
pred_conf = pred_conf[:, :, 0] # [B, HW,]
pred_cls = pred_cls.permute(0, 2, 1) # [B, C, HW]
pred_txty = pred_txtytwth[:, :, :2] # [B, HW, 2]
pred_twth = pred_txtytwth[:, :, 2:] # [B, HW, 2]
# 标签
gt_obj = targets[:, :, 0] # [B, HW,]
gt_cls = targets[:, :, 1].long() # [B, HW,]
gt_txty = targets[:, :, 2:4] # [B, HW, 2]
gt_twth = targets[:, :, 4:6] # [B, HW, 2]
gt_box_scale_weight = targets[:, :, 6] # [B, HW,]
batch_size = pred_conf.size(0)
# 置信度损失
conf_loss = conf_loss_function(pred_conf, gt_obj)
conf_loss = conf_loss.sum() / batch_size
# 类别损失
cls_loss = cls_loss_function(pred_cls, gt_cls) * gt_obj
cls_loss = cls_loss.sum() / batch_size
# 边界框txty的损失
txty_loss = txty_loss_function(pred_txty, gt_txty).sum(-1) * gt_obj * gt_box_scale_weight
txty_loss = txty_loss.sum() / batch_size
# 边界框twth的损失
twth_loss = twth_loss_function(pred_twth, gt_twth).sum(-1) * gt_obj * gt_box_scale_weight
twth_loss = twth_loss.sum() / batch_size
bbox_loss = txty_loss + twth_loss
# 总的损失
total_loss = conf_loss + cls_loss + bbox_loss
return conf_loss, cls_loss, bbox_loss, total_loss