我不止一次听人吐槽YOLOv5的锚框机制:换数据集就要重跑聚类、AutoAnchor每次都要额外算一阵子、遇到长宽比极端的物体就莫名漏检。这些吐槽看起来零散,其实指向同一个深层问题——基于锚框机制的候选框生成,本质上是在用一套从训练集统计出来的形状先验,替网络决定“目标大概是什么比例”。一旦数据分布和这套先验对不上,网络就要花大量epoch去慢慢纠正它。这篇文章要聊的,就是怎么把YOLOv5默认的锚框机制彻底去掉,改成无锚框机制直接生成候选框,从网格点本身回归物体中心和宽高。我会把原理讲透,把改造代码给全,再聊聊训练联动和实测中踩过的坑。适合已经把YOLOv5s训练流程跑通、想动手改检测头结构但还不太清楚从哪下刀的开发者。
1. 锚框不是免费午餐:YOLOv5默认候选框机制的三笔账
1.1 默认机制回顾:每个位置为什么要长出9个候选
YOLOv5的检测头默认在每一个输出位置布置3个不同尺度的锚框,每个尺度再配3种长宽比,所以大家常听到的“9个锚框”指的就是这个配置。输入640分辨率时,P3、P4、P5三层特征图分别是80×80、40×40、20×20,网格总量8400个;因为每个位置有3个锚框,实际输出的候选框数量是8400×3,也就是25200个。
模型在这套机制下学的东西和我们直觉相反——它并不直接预测框的绝对宽高,而是预测相对于某个锚框的偏移和缩放。具体到YOLOv5,每个预测输出5+nc个通道,其中前4个分别代表tx、ty、tw、th,解码时用锚框中心加偏移,锚框宽高乘指数缩放。换句话说,网络的任务是“把某个预置框修正到靠近GT”,而不是“从零想出一个框”。
这种设计在早期检测器里是合理的。一个没有任何形状先验的网络,如果直接回归坐标,训练初期很容易跑飞;锚框相当于给回归一个初始参考点。但代价就是,锚框的质量直接决定了训练起点,甚至决定了正样本分配的范围。这对数据分布稳定、目标形状规律的任务很友好,对分布差异大、目标形状多变的任务就成了包袱。
1.2 计算账、数据账、泛化账
第一笔是计算账。每个位置带3个锚框,意味着检测头最后的输出通道数要乘以3,NMS前要处理的候选框数量也是无锚框版本的三倍。虽然卷积本身的计算量增加不算大,但后处理阶段每个候选都要算置信度、做类别筛选、参与NMS抑制,候选越多耗时越大。在CPU端做后处理或者部署到边缘设备时,这个差异会被进一步放大。
第二笔是数据账。YOLOv5的训练流程里包含一个自动搜索锚框的步骤,本质是在训练集所有GT框的宽高上做聚类,找到一组覆盖度最好的先验。问题是这组先验只对当前数据有效。换个数据集、换个相机架设角度、甚至同一批目标在不同尺度下重新采集,聚类结果都会变。如果不重新算,网络前期就要一边训练一边费力适应“错误先验”,损失曲线会明显震荡。
第三笔是泛化账。锚框等于把“目标长宽比大概是多少”这个假设强行编码进模型,它帮网络缩小了搜索空间,但也划定了搜索边界。当某个GT的长宽比离最近的锚框也很远时,网络需要输出很大的缩放系数才能修正回来;如果这类样本在训练集里是少数,模型就倾向于忽略它们,表现为极端形状目标漏检率偏高。
1.3 什么场景下锚框确实帮了倒忙
举一个我在实际项目中遇到的例子。某条生产线上用固定相机拍零部件,同一种零件因为来料方向和角度不同,画面里的长宽比可以从1:1到1:5,聚类出来的锚框会非常零散。此时锚框先验提供的信息量很低,甚至是一种噪声。
另一个典型场景是小目标密集排列,比如包装盒上的喷码文字检测。小目标中心点小,匹配到的锚框本来就少,如果聚类结果里大目标占比高,小目标对应的锚框就容易被忽略。这种情况下加再多的候选框,不如让每个位置都能独立去回归一个框来得直接。
还有迁移场景:很多人喜欢在预训练权重基础上换到自己的数据微调。锚框机制要求在训练前重新计算数据集的锚框,如果用的是默认在通用检测数据集上聚类出来的那组锚框,迁移初期损失就会很难看。无锚框机制没有这个步骤,启动链路会清爽很多。
2. 无锚框候选框生成的核心原理:把“猜先在框里”换成“从点长出框”
2.1 中心点即锚
无锚框机制把“位置”和“形状”解耦。每个特征图位置不再绑定一组预设框,而是直接把自身当作一个虚拟锚点,通过卷积输出一组数值,编码一个候选框的中心坐标和宽高。换句话说,覆盖整张图像的是分布在每个网格点上的无限可能,而不是有限个固定形状。
这样做最直接的好处是候选框数量回到8400,而不是25200。同时,模型的学习目标也从“修正锚框”变成“从空白状态下估计框”,这个转变省掉了聚类和匹配锚框的整个环节。代价是训练初期的自由度过大,更容易出现振荡,后面我会专门讲如何用warmup和clamp稳住。
2.2 宽高回归的两种编码方式:自己选一条合适的路
无锚框机制下,宽高和中心点的编码方式并没有统一标准。我梳理下来主要有两类,它们决定了后续解码和损失函数的写法。
| 编码方式 | 预测内容 | 代表方案 | 解码公式(示意) | 优点 | 缺点 |
|---|---|---|---|---|---|
| xywh偏移回归 | 相对网格的中心偏移 + 宽高 | YOLOx、PP-YOLO | cx = (sigmoid(tx) + gx) × stride,w = exp(tw) × stride | 与YOLOv5原有坐标体系最接近,改造量小;训练稳定 | 仍依赖一个基准尺度 |
| ltrb距离回归 | 中心点到左、上、右、下四条边的距离 | FCOS、Nanodet | x1 = gx - left,x2 = gx + right | 边框表达直观,天然配合center-ness;不依赖任何先验尺度 | 解码和损失需要另写;每个点都被当作中心,低质量框多 |
我在实际改造中优先推荐第一种,也就是xywh偏移回归。原因很简单:YOLOv5原有的CIoU损失、坐标后处理、标签分配框架都能最大限度复用,改造量最小,适合作为无锚框机制的起点。第二种ltrb距离回归更适合想做FCOS风格、或者想引入center-ness做更精细质量评估的场景。
有人可能会问:第一种方案里的“基准尺度”不还是锚框吗?确实,完全不带一点尺度先验的版本是第二种。但第一种方案中我用的基准尺度只是该层特征图的stride,比如P3层就是8像素,P4层16像素,P5层32像素,它是网络结构确定的常数,不是从数据聚类出来的,不需要针对数据集重新计算。所以它仍然属于无锚框机制,只是保留了最基础的尺度参照。
2.3 center-ness:给弱中心点降权
无锚框机制的一个副作用是会产生大量“中心点离GT中心比较远、但宽高碰巧正确”的候选框。这些框位置偏移明显,IoU不高,却可能带有较高的类别得分,直接进入NMS会影响精度。FCOS提出的center-ness就是来解决这个问题的。
center-ness分支输出一个0到1之间的值,衡量当前预测点距离GT中心的贴近程度。对每个正样本点,给定它到GT四边的距离l、t、r、b,center-ness的目标值定义为:
center-ness = sqrt( (min(l,r) × min(t,b)) / (max(l,r) × max(t,b)) )
这个值在最中心处等于1,越往边缘越小。推理时把center-ness与分类置信度相乘,距离GT中心远的位置即使分类得分高,最终分数也会被压低。这个机制不需要锚框,只需要知道点到边的距离,所以它和ltrb编码是天然搭档;如果用xywh偏移回归,想引入center-ness需要额外输出一个质量分支,也可以做,但语义上不如ltrb自然。
2.4 多尺度是如何被FPN接管的
锚框在YOLOv5里除了提供形状先验,还承担了尺度选择的功能:不同尺度的目标分配给不同层级的锚框来处理。换成无锚框之后,这个职责并没有消失,而是被FPN结构接手了。
P3层的stride是8,每个网格覆盖8×8像素区域,更适合预测小目标;P5层的stride是32,每个网格覆盖32×32像素区域,适合预测大目标。无锚框版本中,一个目标是否在某层被激活,取决于它的中心点落在哪个网格,以及它在训练时被分配到的正采样范围。尺度信息不再来自“这一层的锚框有多大”,而是来自“哪一层特征图对该尺度的目标响应更强”。这也是为什么改造时Backbone和Neck可以完全不动,因为多尺度表达本来就在那里。
3. 从Detect层动刀:无锚框版本的网络结构修改全过程
3.1 改动范围与工程准备
动手之前先明确边界:Backbone、Neck、损失函数的大体框架都不需要动,真正要改的只有两部分——模型定义里的Detect检测头,以及正样本分配逻辑。我把正样本分配放到下一章重点讲,这一章先专注网络结构。
工程上建议三步走。第一步,把原始YOLOv5的训练流程完整跑通一遍,保留一份基线损失曲线,后面改造后才有对比对象。第二步,在模型配置文件中把anchors字段清空或置为一个占位空列表,同时把Detect初始化时对anchors的依赖全部去掉。第三步,给改造后的检测头单独起一个类名(比如DetectAF),和原版Detect共存,方便随时切回对比。
这一步容易被忽略但很重要:改造完成后,检测头初始化的bias不再沿用原版“基于锚框尺寸匹配率”的初始化策略,否则网络一开始就可能输出极端值。所以我在新检测头里统一采用小权重初始化、偏置置零的做法。
3.2 Detect层的无锚框实现:去掉na和anchor_grid
下面给出一个可以直接替换原始Detect的最小实现。它去掉了self.na、self.anchor_grid这两个核心变量,输出卷积的通道数也从原来的no×na改成了no。
import torch import torch.nn as nn class DetectAF(nn.Module): def __init__(self, nc=80, ch=()): super().__init__() self.nc = nc # 类别数 self.no = nc + 5 # xy + wh + obj + cls self.nl = len(ch) # 检测层数,通常为3 self.stride = nn.Parameter(torch.zeros(self.nl), requires_grad=False) self.grid = [torch.zeros(1)] * self.nl self.m = nn.ModuleList(nn.Conv2d(x, self.no, 1) for x in ch) for m in self.m: nn.init.normal_(m.weight, 0, 0.01) nn.init.constant_(m.bias, 0) def _make_grid(self, nx, ny): yv, xv = torch.meshgrid(torch.arange(ny), torch.arange(nx), indexing='ij') return torch.stack((xv, yv), 2).float().view(1, ny, nx, 2) def forward(self, x): z = [] for i in range(self.nl): out = self.m[i](x[i]) bs, _, ny, nx = out.shape out = out.view(bs, self.no, ny, nx).permute(0, 2, 3, 1).contiguous() if not self.training: if self.grid[i].shape[2:4] != out.shape[1:3]: self.grid[i] = self._make_grid(nx, ny).to(out.device) xy, wh, obj, cls = out.split((2, 2, 1, self.nc), dim=-1) grid_xy = (self.grid[i] + 0.5) * self.stride[i] pred_xy = (xy.sigmoid() * 2 - 0.5) * self.stride[i] + grid_xy pred_wh = torch.exp(wh.clamp(-3, 3)) * self.stride[i] pred_obj = obj.sigmoid() pred_cls = cls.sigmoid() pred = torch.cat([pred_xy, pred_wh, pred_obj, pred_cls], dim=-1) z.append(pred.view(bs, ny * nx, self.no)) return z if self.training else torch.cat(z, 1)这个类里有两个设计值得说明。第一,我把wh的解码写成exp(wh.clamp(-3, 3)) × stride,对应预测宽高在0.05倍stride到20倍stride之间。原版YOLOv5用的是sigmoid乘锚框上限4倍的约束,无锚框后我们没有“锚框上限”可以依赖,所以用clamp防爆,给网络一个有限的回归空间。第二,中心点解码是sigmoid(tx)*2-0.5再加网格坐标,允许中心点偏移到相邻网格范围内,保留了YOLO系“中心点不锁死”的灵活性。
3.3 前向解码逻辑:训练和推理要分清楚
训练阶段和推理阶段对检测头前向的要求不一样。YOLOv5原版在训练时返回未解码的原始输出,让损失函数自己去做坐标变换;推理时才一次性解出像素坐标。这样做的好处是训练时所有数值保持在一个相对小的范围内,避免中间步骤数值波动干扰梯度。
我的DetectAF也沿用这个习惯:训练时直接返回out的原始形态;推理时走完整解码,输出拼接后的(中心x, 中心y, 宽, 高, obj, cls...),单位为像素。这样后续把输出接到NMS后处理时,不需要再关心stride和网格坐标。
3.4 怎么验证结构没改错
替换完检测头,建议做三个快速自检。
第一,跑一个单batch的前向,输入1×3×640×640的随机张量。训练模式下应该得到三个张量,形状分别为(1,80,80,no)、(1,40,40,no)、(1,20,20,no);推理模式下得到(1,8400,no)。如果第二维不是8400,检查是否漏改了na相关的维度计算。
第二,打印stride的值。YOLOv5在模型第一次前向时会用输出特征图尺寸反推检测头stride,正常应该是8、16、32。如果出现其他值,说明检测特征层的索引对应关系有误。
第三,加载原版预训练权重时,务必跳过或重新初始化检测头部分的权重。因为无锚框版本的输出通道虽然可能和原版一致(nc+5),但语义已经不同,直接复用会让前几次训练出现莫名其妙的loss波动。
4. 训练联动改造:正样本分配、损失函数与warmup策略
4.1 YOLOv5原有正样本分配逻辑拆开看
YOLOv5训练时不是简单地“每个GT找IoU最高的锚框”。它的build_targets流程大致是:先把GT的宽高和所有候选锚框做一次比例匹配,筛选出形状接近的锚框;然后根据GT中心点落在哪个网格,再结合中心点偏移方向,把附近的一两个网格也纳入正样本;最终同一个GT可能同时出现在多个层、多个锚框、多个网格的组合下,正样本数量被显著放大。
这套逻辑和锚框耦合得非常紧,几乎每个环节都在问“哪个锚框负责这个目标”。无锚框机制下没有锚框这个概念,所以不能继续用这套分配器,否则代码里会出现大量除以0或空匹配的问题。
4.2 中心扩展采样:无锚框版的正样本分配实现
无锚框版的正样本分配核心只有一句话:以GT中心点所在的网格为中心,取周围一小片邻域作为正样本点,回归目标就是GT框本身。
我给出一个能直接改到训练循环里的简化思路:
def build_af_targets(gt_boxes, gt_cls, shape, stride, expand=1): """为单张图生成一层特征图上的训练目标。 gt_boxes: (n, 4),格式为 [x_center, y_center, w, h],像素坐标 gt_cls: (n,) shape: (h, w) 当前层特征图尺寸 stride: 当前层下采样倍数 """ h, w = shape indices, tboxes, tcls = [], [], [] for box, cls in zip(gt_boxes, gt_cls): cx, cy = box[0] / stride, box[1] / stride gi, gj = int(cx), int(cy) for di in range(-expand, expand + 1): for dj in range(-expand, expand + 1): ni, nj = gi + dj, gj + di if 0 <= ni < w and 0 <= nj < h: indices.append((nj, ni)) # 注意顺序:y在前,x在后 tboxes.append(box) tcls.append(cls) return indices, tboxes, tcls中心扩展范围expand=1时是3×3邻域,expand=2时是5×5。3×3是我用得最多的配置,能保证每个GT有9个正样本点,回归信息充足,又不会让正样本之间过于重叠。如果你检测的任务里小目标特别多,可以试试expand=2,但要注意正样本点密集会让同一个小目标附近产生大量相似的预测框,NMS的阈值需要相应调整。
这里还需要一个可选的尺度约束:如果某个GT的宽高除以当前层stride后明显过大或过小,说明这层特征图对它的尺度不敏感,可以跳过这层,让它在更合适的层被分配。简单实现就是加一个范围判断:当box[2] / stride和box[3] / stride落在预设区间内时才生成正样本。
4.3 回归损失、分类损失与obj的组合
无锚框版的损失函数在形式上可以保持YOLOv5原版的框架:分类用BCE,回归用CIoU,置信度用BCE。关键区别在于正样本点从“锚框匹配结果”变成了“中心扩展采样的网格点”。
我用原版CIoU做回归损失的最大好处是复用成熟实现,不需要自己写IoU计算。具体在训练循环里,把每一层的原始输出先按检测头里相同的解码方式换算成xywh像素坐标,然后取出正样本位置的预测框和GT框,直接算CIoU。
置信度分支我建议保留。它的作用在无锚框版本里相当于一个“中心质量估计”,正样本位置标为1,负样本位置标为0。如果你想更精细一点,可以回归到本章之前讲的center-ness思路,把置信度目标改成按点到GT边的距离计算的分段值。实际测试中,center-ness对最终mAP提升不太明显,但能让输出框质量更集中,NMS保留下来的框更干净。
4.4 warmup和初始权重:无锚框训练容易炸的根源
去掉锚框之后,最常遇到的问题是训练到中途loss突然飙高。根因通常不在优化器,而在回归分支的输出空间太大了。
原版YOLOv5的wh输出由sigmoid加锚框上限约束,最大值是锚框宽高的4倍,天然可控。无锚框版本如果直接exp(tw),网络在早期一个异常梯度就能把tw推到一个很大值,exp放大后loss直接爆掉。我的经验是四件事一起做:
- 训练和推理都用
wh.clamp(-3, 3)约束回归输出,这是最关键的一步; - warmup从原版的3个epoch拉长到5个epoch,尤其让box loss的权重前几轮小一点;
- 初始学习率比锚框版低10%到20%,等预测框稳定后再恢复;
- 给wh分支的bias初始化为-1或-2,这样网络初始输出的exp值更小,早期预测框不至于大得离谱。
这四招配合起来,能把无锚框训练早期的不稳定性控制在一个可接受的范围内。
5. 实际训练与推理:收敛特性、三个深坑和部署差异
5.1 训练曲线变化和一个意外收益
我在一个约三千张的工业零件检测数据集上做了对照实验,输入分辨率640,模型规模是YOLOv5s。锚框版和无锚框版各训练100轮,结果有几点印象很深。
前20轮,无锚框版的mAP爬升明显更快,因为中心扩展采样让每个GT的正样本点更多,梯度信号不再被“锚框是否匹配”这个门槛过滤。但到60轮之后,无锚框版的提升幅度放缓,最终AP和锚框版基本持平,略高不到一个点。
一个意外收益出现在换数据集的场景。同一个无锚框模型结构迁移到另一个装配场景的数据集时,训练脚本里不再有AutoAnchor的步骤,省掉了每换一次数据就要重新聚类的等待时间。而且前几个epoch的loss表现更加“可控”,不会因为锚框先验不匹配而出现大幅震荡,这对频繁迭代的工程项目是实实在在的好处。
5.2 坑一:没有先验约束后回归值发散
这个坑我在前面原理部分预告过。实际训练时它表现为某轮loss从4突然跳到2700以上,打印中间变量发现wh分支的输出出现了类似e^18这种值。
原因是某个GT的尺寸特别大,对应位置的回归梯度大,把tw推向极端,exp放大后形成一个巨大的预测框,再反馈到loss上,形成恶性循环。解决手段就是我说的那个clamp。用torch.exp(wh.clamp(-3, 3))后,预测宽高被限制在stride的0.05倍到20倍之间,P3层的预测框最大不会超过160像素,P5层最大约640像素。虽然这个范围仍然很宽,但至少在合理边界内。
如果clamp还不够稳定,可以考虑把超过图像边界的预测框直接从回归损失里过滤掉,只让它们在分类置信度上受到惩罚。
5.3 坑二:极端形状目标的尾部效应
无锚框版本在细长型目标上会表现出一种“尾部效应”:整体精度看着还行,但长宽比特别大的那一类目标召回率偏低。
分析原因,锚框版本里长宽比先验帮助网络在同一类别内部稳定了输出形状;无锚框版本里,细长目标中心附近的每个正样本点都尝试直接回归很大的宽高比,不同点之间的回归目标差异不小,网络只能在这些目标之间取一个折中。
我尝试过几种缓解方法。最简单有效的是把中心采样从正方形邻域改成更关注目标长轴方向的扩展方式,比如对横向长条物体只在左右方向多扩散,上下方向保持1格。另外把wh的clamp范围从-3到3扩到-4到4,同时调整初始bias,给极端比值更大的回归空间。这两种做法能缓解尾部问题,但很难根治。如果你的数据集里细长目标占比很高,锚框版可能仍然是更稳妥的选择。
5.4 坑三:导出ONNX/TensorRT时的网格和score计算
无锚框版本导出推理模型时经常会踩到网格生成相关的报错,原因很直接:原版导出路径里有锚框网格和anchor_grid的拼接逻辑,改掉检测头后这些变量已经不存在了。
导出时要将DetectAF的forward保持在推理模块,确保输出的是解码后的像素坐标张量。最终导出模型的输出shape从原版的(1, 25200, no)变成(1, 8400, no),每个尺度的预测结果已经被拼接进第一维。TensorRT侧如果用动态shape,输出维度变化需要重新配置输出profile;固定shape导出则没有这个问题。
另外还要重新调一遍后处理阈值。无锚框版本候选框少了2/3,置信度分布整体走低,原来0.25的score阈值可能把很多有效框误杀,我一般会降到0.1到0.15再调。NMS的IoU阈值建议从0.45放宽到0.5左右,因为无锚框版本中心点附近的候选之间重叠本来就高。
5.5 策略对比:什么时候保留锚框、什么时候换无锚框
把这次改造的适用场景整理成一张表,方便决策:
| 项目场景 | 推荐方案 | 说明 |
|---|---|---|
| 通用目标检测,目标形状比例稳定 | 锚框版 | 有先验可依赖,训练稳定,无需折腾 |
| 小目标密集、目标分布极不均匀 | 无锚框版 | 中心扩展采样能带来更多正样本,召回更有优势 |
| 极端长宽比目标占比高 | 锚框版 | 先验对极端形状更友好;无锚框需要专门调回归空间 |
| 频繁换数据集、快速迁移 | 无锚框版 | 省去AutoAnchor,启动快,迁移初期的损失更平滑 |
| 边缘设备推理,对后处理耗时敏感 | 无锚框版 | 候选框数减少,NMS阶段耗时下降明显 |
参数和计算量上,两个版本几乎一致,差别主要在检测头最后几个卷积的通道数,整体FLOPs变化不大。真正拉开体验差异的是后处理速度和迁移便利性。
5.6 我个人对这次改造的取舍心得
改完这套无锚框版本之后,我并不会逢人就推荐全面替换掉锚框。它解决的问题是“候选框生成对数据先验的依赖”,而不是模型精度本身。如果你的项目数据稳定、锚框聚类结果一直很健康,改造的收益并不明显;但如果你经常换数据集、做迁移训练,或者目标形状分布极不均匀,这套方案的价值就很直接——省下的不只是聚类时间,还有锚框先验错配时前几个epoch的无效训练。
我的建议是不要一上来就全盘上最复杂的那种解法,先从这个保留xywh回归语义、保留obj置信度分支的最小版本跑通,确认训练稳定了,再考虑要不要切到ltrb距离回归加center-ness的进阶方案。这条路比较平滑,出问题时也容易定位到底是在结构、标签还是对策上出了问题。