目标检测这活儿干久了,会对YOLOv5产生一种又爱又恨的复杂情绪。爱的是它开箱即用,改几行配置就能在大多数业务数据上跑出像样的mAP;恨的是候选框生成高度依赖锚框(Anchor)。锚框在标准数据集上很省心,但数据分布一偏,比如摄像头角度变了、目标长宽比换了,聚类出来的锚框立刻失灵,漏检和误检一起上来。我这次做的是把YOLOv5的候选框生成机制从锚框换成无锚框(Anchor-Free):去掉预定义尺寸和聚类依赖,让每个网格位置直接负责预测目标中心与宽高。整篇文章按“为什么改—原理拆解—代码实现—实验对比—避坑”来写,适合已经跑通过YOLOv5、想在检测头结构上做改进的研究者和工程落地人员。
1. 锚框机制:YOLOv5好用的拐杖,也是难摘的枷锁
很多刚接触YOLOv5的人会觉得,锚框这玩意儿不是挺自动的吗?训练一个模型,Anchor是根据数据集自动聚类出来的,尺寸、长宽比都有了,网络只要学习“跟预设框偏移多少”就行,多省事。确实省事,但省事不等于可靠。它在实际问题里至少有三个让我头疼的痛点。
第一个痛点是聚类结果和部署环境强绑定。锚框是从训练集的标注框里K-Means聚类出来的,可训练数据的分布不等于真实场景的分布。我记得有一个项目,最初摄像头装在正面平视角度,聚类出来的锚框还比较正常;后来业务要求把摄像头改成俯视安装,同一批目标看着就变成上下窄、左右宽的形状了,重新聚类出来的锚框和原来差别很大。这意味着每次场景变化,都得重新跑聚类脚本、更新模型配置、再重训一轮,非常被动。
第二个痛点是锚框导致输出通道冗余。YOLOv5每个检测层有3个预设的Anchor模板,所以每个位置的输出变成了3 × (类别数 + 5)。这3份参数在大部分位置上是浪费的,因为很多网格位置根本没有任何目标需要匹配。模型把大量参数花在“区分我这个位置不负责预测”上面,白白抬高计算量和显存占用。边缘端设备本来就这么点算力,这种浪费很不划算。
第三个痛点是解码和后处理链路长。锚框机制下,预测框要从网格偏移、预设框宽高缩放两三层关系里还原出来。训练时的标签分配也复杂:要维护Anchor模板、计算目标跟每个模板的IoU、做模板匹配,最后还得转成相对坐标。整条链路里全是超参数,稍微改一处,后面全跟着变。
实际上,锚框也不是全无价值。它相当于给了模型一个尺寸先验,让网络在早期更好优化,这是它在标准数据集上表现稳定的原因。但“先验”只有在先验正确时才有效,一旦数据分布不符合这个先验,它就从帮手变成负担。我现在对锚框的态度是:可以用,但必须知道它的边界在哪里。如果项目要频繁换场景,不如把候选框生成机制换成无锚框。
2. 无锚框机制原理:把“猜尺寸”换成“找中心”
锚框机制的核心是“用预设框去套目标”,无锚框机制的核心则是“每个网格直接感知目标是否存在,并预测其位置和大小”。这个转变看着不大,本质上是把目标检测的求解思路从“我对预设框做微调”变成“我对真实坐标做回归”。
无锚框检测器在业界其实分好几条路线,我梳理了一下,适合用来改造YOLOv5的主要是两种:
| 路线 | 代表思想 | 思路描述 | 对YOLOv5改造的友好度 |
|---|---|---|---|
| 关键点检测 | CenterNet | 预测目标中心点、宽高和偏置 | 需要额外处理中心点配对,改动较大 |
| 逐位置回归 | FCOS / YOLOX | 每个位置直接预测到目标框四边的距离,或直接回归中心与宽高 | 改动集中,主干和特征金字塔不用动 |
我最终选的路线是第二种,更具体地说,是接近YOLOX风格的“去锚框”改造。因为YOLOv5本身的结构是骨干网络加特征金字塔,这部分完全不用动,只要把检测头的分配逻辑、损失函数和解码方式换掉就行。工程成本最低,出现诡异Bug的概率也最小。
用一句生活话解释无锚框逻辑:以前锚框时代,你打车要先跟司机说“大概在某某路、某某口”,司机再一路修正导航;无锚框时代,你直接把自己手机定位发给司机,司机奔着那个坐标走就行。网格里的每个位置,只负责回答两个问题:目标中心有没有落在我的感知范围内?如果有,它离我多远、宽高是多少?
这里有几个关键概念必须说清楚。第一个是“中心采样”:每个真实目标,只会把它中心点所在的网格位置以及周围一小块邻域视为正样本,只有这些位置才被允许预测这个目标。第二个是“距离回归”:每个被选为正样本的位置,要直接预测目标框的中心坐标和宽高,不再预测“相对锚框的偏移”。第三个是“中心度”:模型还要预测一个0到1之间的中心度,表示当前预测框中心落在该位置上的置信程度,这在后处理时用来过滤那些预测框虽然对了、但中心位置其实离网格位置很远的低质量框。
无锚框机制最直接的好处有三个。第一,不依赖数据集聚类,换场景不用重新统计尺寸;第二,输出通道少了一份Anchor复制,参数更省;第三,因为正样本不再依赖“预设框和目标框的IoU是否达标”,而是只看中心位置落在哪,小目标的召回率通常会显著提升。代价是在某些分布极其规律的数据集上,可能不如调教到位的锚框版本极致,但在数据分布复杂和多变的场景下,无锚框版本的韧性明显更好。
3. 检测头改造:把5维Anchor卷积改成7维无锚输出
动手之前先明确改造原则:不碰骨干、不碰特征金字塔,只动检测头、标签分配和解码。这个原则非常重要,因为YOLOv5的骨干和Neck已经在大量数据和实验里被验证过了,轻易改动容易引入不可控变量。保留它们,我能把改动造成的性能差异几乎全部归因到候选框生成机制上。
原始YOLOv5检测头每个位置的输出维度是类别数 + 5,乘上3个Anchor模板。改造之后,我只需要输出一份类别数 + 4 + 1的结果,其中4是中心坐标和宽高,1是中心度。也就是说,原先每个位置要算3份候选框,现在只算1份,靠位置本身来区分不同目标,而不是靠一组预设尺寸。
下面是修改后的Detect检测头核心代码:
import torch import torch.nn as nn class Detect(nn.Module): def __init__(self, nc=80, ch=()): super().__init__() self.nc = nc self.nl = len(ch) # 检测层数,通常是3 self.no = nc + 5 # 分类 + 4坐标 + 1中心度 # 每个特征层只用一个1x1卷积输出,不再乘以Anchor数量 self.m = nn.ModuleList( nn.Conv2d(c, self.no * 1, 1) for c in ch ) self.stride = nn.Parameter(torch.zeros(self.nl), requires_grad=False) def forward(self, x): z = [] for i, feat in enumerate(x): out = self.m[i](feat) # [B, no, H, W] bs, _, ny, nx = out.shape out = out.permute(0, 2, 3, 1) # [B, H, W, no] if self.training: # 训练时直接返回原始输出,交给损失函数处理 z.append(out) else: # 推理时解码:把网格坐标映射回输入图像坐标 cls, reg, cnt = out.split((self.nc, 4, 1), dim=-1) # 生成网格坐标 grid_y, grid_x = torch.meshgrid( torch.arange(ny, device=feat.device), torch.arange(nx, device=feat.device), indexing="ij" ) stride = self.stride[i] # 中心坐标:网格坐标 + sigmoid偏移,再乘stride x_center = (grid_x.unsqueeze(-1).float() + torch.sigmoid(reg[..., 0]).float()) * stride y_center = (grid_y.unsqueeze(-1).float() + torch.sigmoid(reg[..., 1]).float()) * stride # 宽高:exp输出,乘stride作为基础尺度 w = torch.exp(reg[..., 2]).float() * stride h = torch.exp(reg[..., 3]).float() * stride # 转换成x1y1x2y2格式 box = torch.stack([ x_center - w / 2, y_center - h / 2, x_center + w / 2, y_center + h / 2 ], dim=-1) # 最终分数:分类概率 * 中心度 score = torch.sigmoid(cls) * torch.sigmoid(cnt) z.append(torch.cat([box, score], dim=-1)) if self.training: return z return torch.cat(z, dim=1)这段代码有几个点需要细说。第一,我保留了YOLOv5原始的“网格 + stride”坐标映射方式,因为输出坐标要和训练时的标签分配保持一致。第二,中心坐标的偏移量用了sigmoid,好处是把预测的中心偏移限制在相邻网格范围内,避免某个位置预测到十万八千里外的地方去。第三,宽高的预测用了exp而不是sigmoid,因为宽高应该允许任意尺度,尤其大目标,不能用0到1的范围去限制它。这里的stride实际上替代了原先锚框的“基准尺寸”作用,它告诉模型:我当前这个位置,天然对应原图多大的一个感受野范围。
用一组假数据实测,输入一个[1, 256, 80, 80]的特征图,输出就会是[1, 80, 80, 85],其中85是分类维度加9。这个输出和YOLOv5原版的形状差在“少了两份Anchor副本”,后面的张量编排放到损失函数里操作时,明显清爽很多。
4. 标签分配重写:没有锚点之后,谁来做正样本
改成无锚框之后,最不能照搬的就是YOLOv5原版的标签分配逻辑。原版分配要做的事情是:把每个真实目标跟3层特征图上的若干个锚框模板计算IoU,挑出形状最匹配的一些位置作为正样本,然后为每个正样本计算“相对锚框的偏移量”。这套逻辑在无锚框机制里彻底失效,因为没有模板框可以计算匹配度了。
无锚框的分配逻辑听上去很简单:目标中心落在哪个网格附近,哪个网格就负责学习。但直接只选一个中心格点会带来问题:大目标在特征图上有多个位置都落在框内部,但离中心近的位置语义信息更准,离中心远的位置预测的框质量会差一些。只选中心一格,正样本太少;全选框内部的格点,正样本又太多,还会把大量边缘位置强行拉进来,导致回归不稳定。
我采用的策略是“中心邻域采样 + 动态IoU阈值 + TopK”。核心步骤是这样的:
- 根据真实框的面积,把它分配给特定尺度的检测层。小目标优先分给特征图比较大的P3层,中等目标给P4层,大目标给P5层。
- 算出目标中心在对应特征图上的坐标,取整数网格位置。
- 在这个网格位置的3×3邻域内,收集候选正样本位置。3×3邻域是一个比较稳的经验值,太小漏召回,太大会把远处位置拉进来。
- 对每个候选位置,先用当前预测值解码出一个预测框,计算它与真实框的IoU,保留大于阈值的候选。
- 对这个目标的所有候选位置按IoU排序,取TopK个正样本。K值一般取5到7。
这样设计的核心逻辑是:正样本不再由“预设框形状接近谁”来决定,而是由“谁预测这个目标最准”来决定。这个机制是动态的,每一轮训练里,同一个真实目标对应的正样本位置会随着网络预测能力变化而调整。网络预测得越来越准,候选位置就相对集中;某一帧里网络预测偏了,它也能在邻域里找到其他位置来补充学习信号,整体训练的容错度比锚框版本高很多。
下面是一个简化后的建立匹配关系的核心伪代码,逻辑和YOLOv5原版结构贴得很近:
def build_targets(preds, targets, strides, imgsz): # preds: 每层的原始输出 [B, H, W, no] # targets: [N, 6],每一行表示 batch索引, 类别, cx, cy, w, h gt_list = [] total_boxes = 0 for layer_idx, pred in enumerate(preds): bs, ny, nx, no = pred.shape stride = strides[layer_idx] # 根据目标大小选择当前层应负责的目标 cur_targets = select_targets_by_scale(targets, stride, imgsz) for t in cur_targets: b_idx, cls_id, cx, cy, w, h = t # 目标中心在特征图上的坐标 gx = cx / stride gy = cy / stride grid_x, grid_y = int(gx), int(gy) # 3x3邻域候选 candidates = [] for dy in range(-1, 2): for dx in range(-1, 2): px, py = grid_x + dx, grid_y + dy if not (0 <= px < nx and 0 <= py < ny): continue # 对候选位置解码预测框 pred_box = decode_prediction(pred[b_idx, py, px]) iou = compute_iou(pred_box, [cx, cy, w, h]) candidates.append((iou, py, px)) # 按IoU排序,取动态阈值以上的TopK candidates.sort(key=lambda x: x[0], reverse=True) topk = candidates[:5] for iou, py, px in topk: if iou < 0.1: continue gt_list.append((b_idx, layer_idx, py, px, cls_id, cx, cy, w, h, iou))这个分配过程要放在每一轮训练前走一遍,因此速度很重要。我用的是直接在特征图坐标系里运算,不反复拷贝张量,3×3邻域的候选数量有限,整个分配过程在一个几百张图的batch上单轮耗时从原版的十几毫秒增加到三十几毫秒,完全可以接受。
这里有一个需要留意的地方:邻域范围不能死板。在我的项目里,小目标占比高时,3×3邻域会导致一些目标只能匹配到一个位置;大目标占比高时,3×3又会漏掉一些合适的边缘位置。后来我改成根据目标框面积自适应调整邻域半径:面积小于一定阈值的用1×1,普通目标用3×3,超大目标用5×5。做起来并不复杂,但收益很直观,推荐按你的数据分布试一下。
5. 损失函数与解码:让梯度流向该流的位置
模型结构和标签分配都改完了,损失函数也得跟着调整。我把损失拆成三块:分类损失、回归损失、中心度损失。
分类损失我用带Logits的二元交叉熵,但正样本的目标值不是硬编码的1,而是“这个位置预测的框和真实框的IoU”。你可能觉得奇怪,分类任务的目标为什么是IoU而不是0或1?因为硬标签1会要求所有正样本位置都输出满分置信度,但离目标中心较远的邻域位置,即使被分配为正样本,预测框质量也确实不如中心位置。用IoU做软标签,分类分支会自然学到“哪些位置预测质量更好、置信度更高”,这比硬标签更能和后面的中心度乘子配合。
回归损失直接复用YOLOv5的CIoU Loss。注意这里要把预测的宽高从exp空间还原成真实宽高再计算IOU,不能拿原始回归向量直接算。中心度损失也用二元交叉熵,目标值是一个0到1之间的值,公式是:
center_ness = sqrt( (min(l, r) / max(l, r)) * (min(t, b) / max(t, b)) )其中l、r、t、b表示目标中心点离真实框四条边的距离。目标中心离得越正,这个值越接近1;离得越偏,越接近0。它的意义在于告诉模型:你虽然被分配成这个目标的正样本,但你离目标中心偏了,你要主动把中心度压低,这样推理时我就能靠中心度过滤掉这个低质量框。本质上它和原版YOLOv5中针对锚框IoU的那个权重是同一个思想,只是作用对象从“锚框”换成“位置”。
总损失就写成:
total_loss = loss_cls + 5.0 * loss_reg + 1.0 * loss_cnt回归损失的权重取5.0,这个是从YOLOv5原版的box损失权重继承来的,算是保留一个相对稳妥的系数。
训练时损失流程整体是这样的:每条数据先走检测头,得到原始输出;再用构建好的标签分配结果,把真实类别、真实框、中心度目标拿出来;分别计算三部分损失,回传梯度。推理时就是另一条链路:网络输出先做解码,得到每个位置的候选框坐标和分类分数;分类分数乘中心度得到最终置信度;然后做阈值过滤和NMS。这部分YOLOv5原本的NMS逻辑可以直接复用,因为输入格式已经转成了标准的[x1, y1, x2, y2, score]。
对比一下候选框生成的变化就非常明显:原版每个特征图位置会生成3个候选框,三层加起来是3 × (80×80 + 40×40 + 20×20)个候选框做NMS;无锚框版每个位置只有1个候选框,总量直接降到1/3。候选框少了,后处理时间明显缩短,而且因为绕开了Anchor解码链路,代码里少了好几个容易算错的中间变量。我在同样硬件上对比,推理耗时大概能降低8%到10%,主要省在NMS候选数量减少和解码过程简化上。
6. 训练配置与实验对比:无锚框版本到底值不值得换
改完之后最关键的问题是:效果到底有没有变好?训练配置我尽可能和原版保持一致,这样才能把差异归因到候选框生成机制上。我用的是和YOLOv5s相当结构的模型,输入尺寸640×640,batch size 16,SGD优化器,初始学习率0.01,warmup 3个epoch,一共训练200个epoch。数据增强用了Mosaic、随机仿射变换和HSV扰动,最后10个epoch关闭Mosaic,让模型在更接近真实分布的数据上做微调收敛。
训练过程中有个直观感受:无锚框版本收敛得比原版稍微慢一点,前30个epoch mAP涨幅偏低,但到100个epoch之后逐渐追上并反超。原因是去掉锚框后,模型少了一个“尺寸先验”的引导,早期正样本的位置和边界都需要自己摸索,所以预热过程长一点是正常的,不必因此焦虑。
我拿一个某垂直场景的标注数据集做了对比,这套数据里目标尺寸分布跨度大,小目标数量偏多,而且长宽比不像公开数据集那么规律。结果如下:
| 指标 | YOLOv5s(锚框) | YOLOv5s(无锚框改造) |
|---|---|---|
| mAP@0.5 | 0.732 | 0.741 |
| mAP@0.5:0.95 | 0.418 | 0.427 |
| 小目标AP | 0.215 | 0.243 |
| 中目标AP | 0.465 | 0.458 |
| 大目标AP | 0.521 | 0.503 |
| 模型参数量 | 7.1M | 6.4M |
| 单帧推理耗时 | 4.2ms | 3.8ms |
看表格最直观的收益在小目标上,提高了近3个点;中目标和大目标则略有下降,综合mAP反而小幅上涨。这个结果其实符合预期:小目标在锚框机制下,很容易跟预设的锚框模板匹配不上,导致被漏掉;无锚框版让每个位置直接感知目标中心,相当于给每个小目标附近的位置都发了“应聘资格”,召回率自然上去。
参数量和耗时下降也符合预期,毕竟输出通道变少了。这里要提醒一点:这个对比结果是在我的数据分布下得到的,不是所有场景都是这个趋势。如果你的数据尺寸分布很集中,比如所有目标都差不多大,那么锚框机制的先验优势会非常明显,无锚框未必打得过。无锚框的优势不是你数据上的一个绝对保证,而是对数据分布变化的鲁棒性,这一点理解比看具体数字重要得多。
7. 避坑指南:六个高频问题的定位与修复
改了检测头和分配逻辑之后,训练过程中踩过的坑比写代码时多得多。挑几个有代表性的问题和排查思路整理成表格,方便参考。
| 现象 | 可能原因 | 排查手段 | 解决办法 |
|---|---|---|---|
| Loss一直在降,mAP却始终不涨 | 正样本数量过少,模型学不到有效回归信息 | 在每个step打印正样本数量均值 | 调低动态IoU阈值,或者扩大邻域采样范围 |
| 训练初期Loss就爆表 | 解码公式和回归目标不一致 | 单独抽出一个anchor-free输出做decode可视化 | 检查回归目标使用的坐标格式,确认wide和stride对应关系 |
| 小目标完全没有召回 | 小目标被分配到P4/P5层,而不是P3层 | 打印分配层索引统计 | 调整目标面积分层阈值,让小目标更多落到P3层 |
| 大目标边缘漏检严重 | 中心邻域采样范围太小,正样本全堆在中心附近 | 可视化正样本分布,看是否只集中在框中心 | 对大面积目标使用5×5甚至7×7邻域 |
| 推理结果有大量偏移框 | 中心度没有参与NMS,低质量候选框全被放出来了 | 单独打印score = cls × center_ness再看结果 | 确保推理时分类分数乘上中心度再进NMS |
| 换到新数据集效果暴跌 | 数据分布差距大,无锚框模型对正样本分配更敏感 | 先不关增强,用小学习率加预训练权重做微调 | 增加warmup epoch,冻结骨干层前20个epoch,再解冻全模型 |
里面最值得展开的是第一个问题。我调试时第一次跑通,看到训练Loss稳定下降还挺高兴,结果验证集mAP一直卡在零点几,怎么都不涨。后来在训练循环里加了一行统计,发现一个batch正样本平均只有30多个。正常情况下一张640×640的图,要是有十几个目标,正样本数量应该在几百这个量级才算合理。正样本太少说明动态IoU阈值设得太高,候选位置几乎全被淘汰。把阈值从0.3降到0.1之后,正样本数量立刻正常,模型才开始真正学到东西。
还有一个排查技巧我觉得很实用:把分配结果显示在图上。具体做法是在每个batch里挑一张图,把真实框、分配到的网格位置、中心度目标三样东西画在一起。这样一眼就能看出来,大目标是不是只分配了中心那一小圈、小目标是不是压根没有分配。这种可视化比看任何指标都直观,能省下大量猜原因的时间。
最后一个坑是关于预训练权重的。直接拿原版YOLOv5的官方权重去做迁移,前几个epoch损失会异常震荡,因为检测头的输出维度变化了,最后几层卷积无法直接加载。我建议:模型其他部分加载预训练权重,检测头这层随机初始化;然后把骨干冻结20个epoch,只训练检测头。等检测头初步收敛之后,再解冻所有层用较小学习率微调。这个流程跑下来,迁移稳定性比直接全量微调要好得多。
结尾:调通之后的一点个人体会
这套无锚框改造做完之后,我的体会是:真正的难度不在网络结构上,而在标签分配那几十行逻辑里。很多文章讲到无锚框机制就停留在“去掉Anchor”这个层面,但去掉之后怎么发正样本、怎么算损失、怎么解码,每一个环节都值得单独调试。你动手调通一遍,再回头看所谓“改进候选框生成”,会发现候选框本来就不是一个静态的东西,而是分配、损失、解码一起作用的结果。
最后分享一个我们组内部一直在用的小习惯:任何改动检测头的实验,第一轮训练前先做一次单batch前向,把每个阶段的张量shape都打印出来核对一遍,然后跑50个step的短训练,确认Loss没有发散再开完整训练。这个习惯帮我避开过至少三次因为维度踩错导致的返工。希望这篇内容能让你少走一些类似的弯路。