【深度学习|目标检测】YOLO系列anchor-based原理详解
YOLO之anchor-based
- 一、关于anchors的设置
- 二、网络如何利用anchor来训练
- 关于register_buffer
- 训练阶段的anchor使用
- 推理阶段的anchor使用
- 三、训练时的正负样本匹配
- anchor匹配
- grid匹配
总结起来其实就是:基于anchor-based的yolo就是基于三个检测头的分支上的grids和anchors来预测与gt的偏移量,同时考量该grid中是否含有物体,并且是什么样的物体,然后在满足这三者条件的最小loss下不断迭代模型的权重参数。
一、关于anchors的设置
在yolov5的模型yaml文件中已经设置了一套默认的anchors的尺寸(针对640*640的输入):
# anchors
anchors:
- [10,13, 16,30, 33,23] # P3/8
- [30,61, 62,45, 59,119] # P4/16
- [116,90, 156,198, 373,326] # P5/32
其中p3,p4,p5分别代表三个尺度的特征图,中括号内的数字两两一对,表示在640*640输入基准上的anchor尺寸。8,16,32分别代表各自的stride步长,即相对输入的降采样倍数,也代表了该特征图每一个grid的边长。
那么为什么降采样越多的检测头预设的anchor大小越大呢,因为降采样越多,grid的个数越少,因此每一个grid对于大目标的感知能力越强。降采样越少的,grid个数越多,对于小目标的感知能力越强,模型参数收敛的更快。因此我们会发先降采样越多的头,预设的anchor越大。即为了更快的收敛。
虽然给我们设置好了默认的anchors,但是我们可以不使用这个默认anchor,在训练的时候,打开autoanchor开关:
parser.add_argument("--noautoanchor", action="store_true", help="disable AutoAnchor")
然后会进入check_anchors函数中进行判断是否需要重新生成预设anchor:
check_anchors(dataset, model=model, thr=hyp["anchor_t"], imgsz=imgsz) # run AutoAnchor
其中thr表示这一波数据集的标注中,宽高比的最大阈值,这个参数在hyp的yaml文件中有设置好,是4。核查的代码如下所示:
def metric(k): # compute metric
"""Computes ratio metric, anchors above threshold, and best possible recall for YOLOv5 anchor evaluation."""
r = wh[:, None] / k[None]
x = torch.min(r, 1 / r).min(2)[0] # ratio metric
best = x.max(1)[0] # best_x
aat = (x > 1 / thr).float().sum(1).mean() # anchors above threshold
bpr = (best > 1 / thr).float().mean() # best possible recall
return bpr, aat
返回的bpr参数就是用于来判断是否需要重新计算anchor的主要依据,当大于0.98时,我们则不需要为了这个数据集重新计算anchor。当小于0.98时,会自动重新计算。计算的方式就是使用kmeans聚类算法,根据我们这个数据集的标注情况来调整anchor的宽高和比例,计算完成后,还会再计算一次现在的bpr,然后和默认的bpr进行比较,如果小于默认的bpr则继续使用默认的anchor设置。
二、网络如何利用anchor来训练
在train.py中我们看到了train的函数中,建立网络整体架构的代码:
model = Model(cfg or ckpt["model"].yaml, ch=3, nc=nc, anchors=hyp.get("anchors")).to(device) # create
我们进入Model类中,会进入到DetectionModel类中,这个类在初始化的过程中,会读取我们传入的模型结构yaml文件,然后通过parse_model方法来返回一个pytorch搭建的网络框架,包含了backbone+head。backbone就按照正常的流程构建,当搭建到head时,会进入Detect类中,初始化如下:
def __init__(self, nc=80, anchors=(), ch=(), inplace=True): # detection layer
super().__init__()
self.nc = nc # number of classes // 目标的类别个数
self.no = nc + 5 # number of outputs per anchor // 每一个grid的输出维度
self.nl = len(anchors) # number of detection layers // 在几个尺度的特征层上进行设置anchor
self.na = len(anchors[0]) // 2 # number of anchors // 每一个grid上有的anchor的个数
self.grid = [torch.zeros(1)] * self.nl # init grid
self.anchor_grid = [torch.zeros(1)] * self.nl # init anchor grid
self.register_buffer('anchors', torch.tensor(anchors).float().view(self.nl, -1, 2)) # shape(nl,na,2)
self.m = nn.ModuleList(nn.Conv2d(x, self.no * self.na, 1) for x in ch) # output conv
self.inplace = inplace # use in-place ops (e.g. slice assignment)
其中register_buffer即将anchor注册到网络结构中的一步,在下面会详细讲解这个函数的作用。
head的最后输出会经过一层头部的卷积层(p3,p4,p5分别对应一个),这个卷积层的输入维度以yolov5s为例子的话,分别是(128,256,512),输出维度是(xywh + objectness + nc)* 3,其中3是每个grid上的锚框个数。以640* 640的输入为例,我们将三个锚框的维度移动到grid个数上,那么最后三个检测头的输出维度分别是:(bs, 80* 80 * 3, xywh + objectness + nc) ,(bs, 40* 40 * 3, xywh + objectness + nc),(bs, 20* 20 * 3, xywh + objectness + nc),将这三个头合起来之后就是网络的输出结果,即(bs,25200,xywh + objectness + nc)。
关于register_buffer
nn.Module.register_buffer 是 PyTorch 提供的方法,用于向模型中注册一个缓冲区(buffer)。这些缓冲区是与模型相关的固定数据,在模型训练和保存时非常有用,但它们不会参与梯度计算,也不会被优化器更新。
使用场景:
● 存储模型所需的固定参数(例如锚点、均值、方差等)。
● 在保存和加载模型时,确保这些数据一并存储和恢复。
● 用于在模型中共享某些不可训练的参数。
import torch
import torch.nn as nn
class MyModel(nn.Module):
def __init__(self):
super(MyModel, self).__init__()
# 注册一个缓冲区
self.register_buffer("my_buffer", torch.tensor([1.0, 2.0, 3.0]))
def forward(self, x):
# 使用缓冲区中的数据
return x + self.my_buffer
# 创建模型实例
model = MyModel()
print("缓冲区内容:", model.my_buffer)
# 保存模型
torch.save(model.state_dict(), "model.pth")
# 加载模型
new_model = MyModel()
new_model.load_state_dict(torch.load("model.pth"))
print("加载后的缓冲区内容:", new_model.my_buffer)
示例说明:
- register_buffer 的作用:
○ self.register_buffer(name, tensor) 会将 tensor 注册为缓冲区,名称为 name。
○ 例如,代码中的 my_buffer 是模型的一部分,但它不会参与梯度计算。 - 访问缓冲区:
○ 缓冲区可以通过模型属性直接访问,例如 model.my_buffer。 - 保存与加载:
○ 缓冲区会被存储在模型的 state_dict 中,使用 torch.save 和 torch.load 保存/加载。
训练阶段的anchor使用
在训练阶段中,并没有在检测头中直接使用anchor,而是在其他两个地方使用anchor的信息,一个是正负样本匹配的过程中,一个是损失函数计算的过程中。
那么我们看一下,训练的时候,DetectionModel类到底做了什么吧:
parse_model之后便是拿到模型的最后一层,记为m,然后做个判断,是否是检测头或者分割头,是的话进入代码段中,然后以256 * 256的尺寸为例,输入_forward_once中进行一次模型的输出,得到了最后三个检测头的输出,遍历这三个输出,分别取特征图的尺寸,用输入除以特征图的尺寸得到stride张量,说实话个人感觉这一步有点脱裤子放屁的意思。然后就是检查anchor和stride的对应关系,以及将每一个检测头的anchor尺寸缩放到该特征图尺度上的对应大小。然后就是初始化偏置和初始化权重了:
# Build strides, anchors
m = self.model[-1] # Detect()
if isinstance(m, (Detect, Segment)):
def _forward(x):
"""Passes the input 'x' through the model and returns the processed output."""
return self.forward(x)[0] if isinstance(m, Segment) else self.forward(x)
s = 256 # 2x min stride
m.inplace = self.inplace
m.stride = torch.tensor([s / x.shape[-2] for x in _forward(torch.zeros(1, ch, s, s))]) # forward
check_anchor_order(m)
m.anchors /= m.stride.view(-1, 1, 1)
self.stride = m.stride
self._initialize_biases() # only run once
# Init weights, biases
initialize_weights(self)
self.info()
LOGGER.info("")
推理阶段的anchor使用
由于在我们的train.py中,我们首先是创建网络,此时还没有到model.train()的状态,因此在训练调试代码时,会先进入检测头的not self.training中,这里我们就可以看到在推理阶段是如何使用anchor来进行预测的。首先我们会使用_make_grid方法来对我们的三个尺度的特征图进行grid的和anchor的搭建,即预设好grid的左上角的框的坐标以及对应的anchor的大小。然后我们将这个预设好的grid点的左上角坐标和每个grid上的anchor给到模型最后的output,从output的最后一个维度拆分,拿到xy,wh,conf =(objectness,nc)一共三组结果,其中conf的结果是可以直接使用的,但是xy,wh还需要和我们预设好的grids和anchors,以及每个检测头的stride来得到最后的精确检测结果。最后将每个检测头上设置的anchor的个数乘到grid上,即代表了我们的结果一共有na * (80 * 80 + 40 * 40 + 20 * 20)个,然后输出的结果维度便是(1,25200,6)。
if not self.training: # inference
if self.dynamic or self.grid[i].shape[2:4] != x[i].shape[2:4]:
self.grid[i], self.anchor_grid[i] = self._make_grid(nx, ny, i)
if isinstance(self, Segment): # (boxes + masks)
xy, wh, conf, mask = x[i].split((2, 2, self.nc + 1, self.no - self.nc - 5), 4)
xy = (xy.sigmoid() * 2 + self.grid[i]) * self.stride[i] # xy
wh = (wh.sigmoid() * 2) ** 2 * self.anchor_grid[i] # wh
y = torch.cat((xy, wh, conf.sigmoid(), mask), 4)
else: # Detect (boxes only)
xy, wh, conf = x[i].sigmoid().split((2, 2, self.nc + 1), 4)
xy = (xy * 2 + self.grid[i]) * self.stride[i] # xy
wh = (wh * 2) ** 2 * self.anchor_grid[i] # wh
y = torch.cat((xy, wh, conf), 4)
z.append(y.view(bs, self.na * nx * ny, self.no))
三、训练时的正负样本匹配
首先,什么是正负样本匹配。正样本匹配即通过计算gt与被选中的grid和anchor来计算偏移量来调整网络的权重参数。正样本的匹配然后loss计算是为了让模型朝着更小的损失去迭代更新这样的权重,负样本的作用则是让模型的权重往更远离能检测出错误目标的权重方向迭代。
yolov5在正负样本匹配在v3, 和v4的基础上作出了改进,yolov5通过跨分支(检测头)和跨grid和跨anchor来匹配多个grid和多个anchor,目的就是增加正样本的数量。yolov5的正样本匹配过程分为两步,一个是匹配grid点,一个是匹配anchor。
首先来看匹配anchor。
anchor匹配
在YoloV5网络中,一共设计了9个不同大小的先验框。每个输出的特征层对应3个先验框。
对于任何一个真实框gt,YoloV5不再使用iou进行正样本的匹配,而是直接采用高宽比进行匹配,即使用真实框和9个不同大小的先验框计算宽高比。
如果真实框与某个先验框的宽高比例大于设定阈值,则说明该真实框和该先验框匹配度不够,将该先验框认为是负样本。
比如此时有一个真实框,它的宽高为[200, 200],是一个正方形。YoloV5默认设置的9个先验框为[10,13], [16,30], [33,23], [30,61], [62,45], [59,119], [116,90], [156,198], [373,326]。设定阈值门限为4。
此时我们需要计算该真实框和9个先验框的宽高比例。比较宽高时存在两个情况,一个是真实框的宽高比先验框大,一个是先验框的宽高比真实框大。因此我们需要同时计算:真实框的宽高/先验框的宽高;先验框的宽高/真实框的宽高。然后在这其中选取最大值。
下个列表就是比较结果,这是一个shape为[9, 4]的矩阵,9代表9个先验框,4代表真实框的宽高/先验框的宽高;先验框的宽高/真实框的宽高。
[[20. 15.38461538 0.05 0.065 ]
[12.5 6.66666667 0.08 0.15 ]
[ 6.06060606 8.69565217 0.165 0.115 ]
[ 6.66666667 3.27868852 0.15 0.305 ]
[ 3.22580645 4.44444444 0.31 0.225 ]
[ 3.38983051 1.68067227 0.295 0.595 ]
[ 1.72413793 2.22222222 0.58 0.45 ]
[ 1.28205128 1.01010101 0.78 0.99 ]
[ 0.53619303 0.61349693 1.865 1.63 ]]
我们自然可以看出[59,119], [116,90], [156,198], [373,326]是满足条件的anchor。
grid匹配
确定了满足条件的anchor之后,我们就该找具体是哪里的grid了。
在过去的Yolo系列中,grid的选择是看gt框的中心点所处的网格的坐上角(即当前grid)。对于yolov5而言,对于被选中的特征层,首先计算gt落在哪个网格内,此时该网格左上角特征点便是一个负责预测的特征点。同时利用四舍五入规则,找出最近的两个网格,将这三个网格都认为是负责预测该真实框的。如下图所示:
红色点表示该真实框的中心,除了当前所处的网格外,其2个最近的邻域网格也被选中。从这里就可以发现预测框的XY轴偏移部分的取值范围不再是0-1,而是0.5-1.5。
找到对应特征点后,对应特征点的刚才anchor匹配中被选中的anchor负责该真实框的预测。