029、Anchor-Free 检测头原理:中心点 x y 预测、宽高 w h 回归与网格偏移 029、Anchor-Free 检测头原理中心点 x y 预测、宽高 w h 回归与网格偏移从一次诡异的漏检说起去年做工业质检项目检测手机屏幕上的微小划痕。模型跑起来召回率死活上不去尤其是那些刚好跨在网格边界上的划痕——模型要么完全忽略要么预测框偏移得离谱。我盯着特征图可视化看了三天最后发现问题是检测头对“网格偏移”的理解有偏差。当时用的还是Anchor-Based的YOLOv5但那次调试让我彻底想明白一件事检测头本质上是在做“相对位置”的回归而不是绝对坐标的预测。后来切到Anchor-Free方案这个问题反而迎刃而解。今天这篇笔记我们就从Anchor-Free检测头的核心设计出发把中心点预测、宽高回归、网格偏移这三个东西掰开揉碎。代码基于YOLOv8的检测头实现但原理通用。检测头结构从特征图到预测张量先看一个典型的Anchor-Free检测头长什么样。以YOLOv8为例输入是Backbone输出的特征图比如80x80、40x40、20x40三个尺度。每个尺度对应一个检测头结构极其简单classDetect(nn.Module):def__init__(self,nc80,ch()):super().__init__()self.ncnc# 类别数self.nllen(ch)# 检测头层数一般是3self.reg_max16# DFL用的后面讲self.noncself.reg_max*4# 每个anchor的输出通道数# 每个尺度一个卷积把特征图映射到输出通道self.cv2nn.ModuleList(nn.Sequential(Conv(x,x,3),Conv(x,x,3),nn.Conv2d(x,4*self.reg_max,1))forxinch)self.cv3nn.ModuleList(nn.Sequential(Conv(x,x,3),Conv(x,x,3),nn.Conv2d(x,self.nc,1))forxinch)注意这里cv2负责回归4个边界每个用reg_max个bin表示cv3负责分类。没有Anchor的概念每个网格直接预测一个目标。中心点预测网格坐标 偏移量Anchor-Free最核心的思想每个网格负责预测其中心点落在该网格内的目标。假设特征图尺寸是80x80那么就有6400个网格每个网格对应原图的一个区域。预测的中心点坐标是相对于网格左上角的偏移量范围在[0,1]之间。但实际训练时我们通常用sigmoid把输出压缩到(0,1)避免边界溢出。# 推理时的中心点解码defdecode_bboxes(bboxes,stride):# bboxes shape: [batch, 4, h, w] - 4对应 ltrb (left, top, right, bottom)# 这里先转成xywh格式方便理解y,xtorch.meshgrid(torch.arange(h,devicedevice),torch.arange(w,devicedevice),indexingij)# 网格中心坐标注意是网格中心不是左上角grid_xytorch.stack([x,y],dim-1).float()# [h, w, 2]# 预测的偏移量经过sigmoidxy(bboxes[...,:2].sigmoid()*2-0.5grid_xy)*stride wh(bboxes[...,2:4].sigmoid()*2)**2*stridereturnxy,wh这里有个细节为什么偏移量要乘以2再减0.5这是YOLOv8的一个trick。原始YOLOX只用sigmoid把偏移限制在(0,1)但这样中心点只能落在网格内部。乘以2减0.5后偏移范围变成(-0.5, 1.5)允许中心点稍微超出网格边界。别小看这0.5的扩展它让模型能处理那些刚好压在网格线上的目标——我踩过的坑就是这个。宽高回归从DFL到实际尺寸宽高回归在YOLOv8里用了Distribution Focal LossDFL而不是直接回归一个值。DFL把每个边的距离离散化成16个binreg_max16网络输出的是每个bin的概率分布。# DFL解码从概率分布得到具体距离defdist2bbox(distance,anchor_points,xywhTrue,dim-1):# distance shape: [batch, 4, reg_max, h, w] - 4对应l,t,r,b# 先softmax得到概率projF.softmax(distance,dimdim)# [batch, 4, reg_max, h, w]# 每个bin对应的距离值0,1,2,...,15bin_valuestorch.arange(self.reg_max,dtypetorch.float,devicedevice)# 加权求和得到最终距离dist(proj*bin_values).sum(dimdim)# [batch, 4, h, w]# 转成xywh格式ifxywh:# l,t,r,b - cx,cy,w,hx1anchor_points[...,0]-dist[...,0]y1anchor_points[...,1]-dist[...,1]x2anchor_points[...,0]dist[...,2]y2anchor_points[...,1]dist[...,3]returntorch.stack([(x1x2)/2,(y1y2)/2,x2-x1,y2-y1],dim-1)为什么用DFL直接回归一个值不香吗这里有个经验直接回归宽高容易让模型陷入局部最优尤其是小目标。DFL相当于让模型学习一个分布梯度更平滑收敛更稳定。而且DFL天然支持不确定性估计——如果分布很分散说明模型对这个框的尺寸没把握后处理时可以过滤掉。网格偏移训练时的正样本分配训练时Anchor-Free的正样本分配比Anchor-Based简单得多。每个目标只分配给一个网格——那个包含目标中心点的网格。# 训练时的正样本分配简化版defassign_targets(gt_boxes,gt_labels,stride,grid_size):# gt_boxes: [num_gt, 4] xywh格式归一化到0-1# 计算每个GT的中心点落在哪个网格cxgt_boxes[:,0]*grid_size# 网格坐标cygt_boxes[:,1]*grid_size grid_xcx.long()grid_ycy.long()# 计算偏移量用于回归目标offset_xcx-grid_x.float()# 范围[0,1)offset_ycy-grid_y.float()# 这里有个坑如果中心点刚好在网格边界上比如0.999# 取整后可能落到下一个网格导致分配错误# 解决方案用floor而不是long或者加一个epsilongrid_xtorch.floor(cx).long()grid_ytorch.floor(cy).long()returngrid_x,grid_y,offset_x,offset_y注意上面代码里的注释。用long()取整等价于floor但float转long时0.999会变成0没问题。但如果是1.0就会变成1超出网格索引范围。所以实际代码里通常先clamp一下或者用floorclamp。损失函数中心点、宽高、分类的协同Anchor-Free的损失函数通常包含三部分分类损失BCE或Focal Loss每个网格预测一个类别回归损失CIoU或GIoU直接优化预测框和GT框的IoUDFL损失对每个边的分布做交叉熵# 损失计算伪代码defcompute_loss(pred_bboxes,pred_scores,gt_bboxes,gt_labels,target_bboxes):# 分类损失只计算正样本cls_lossF.binary_cross_entropy_with_logits(pred_scores[pos_mask],gt_labels[pos_mask])# 回归损失CIoUiou_lossCIoU(pred_bboxes[pos_mask],target_bboxes[pos_mask])# DFL损失对每个边的分布做交叉熵dfl_lossDFL(pred_dist[pos_mask],target_dist[pos_mask])returncls_lossiou_lossdfl_loss这里有个关键点回归损失用CIoU而不是MSE。MSE对尺度敏感大目标和小目标的loss量级不一样。CIoU天然尺度不变而且考虑了重叠区域、中心点距离和宽高比。个人经验与建议网格偏移的边界处理训练时如果目标中心点刚好落在网格边界上比如0.5分配时容易产生歧义。我的做法是给每个GT分配两个最近的网格用soft label加权。YOLOv8的TaskAlignedAssigner就是这么干的效果比硬分配好很多。DFL的reg_max选择reg_max16是经验值对应最大距离15个像素在特征图尺度上。如果检测大目标可以适当增大比如32。但别太大否则分布太稀疏梯度消失。中心点偏移的初始化新训练时建议把偏移量的bias初始化为0.5对应sigmoid后的0.5这样初始预测中心点落在网格中心而不是角落。YOLOv8就是这么做的收敛快很多。多尺度融合Anchor-Free对尺度变化敏感尤其是小目标。建议在检测头之前加一个简单的特征金字塔融合比如BiFPN或者用更深的检测头比如YOLOv8的P5/P6结构。调试技巧如果发现模型预测的框总是偏左或偏上检查一下网格坐标的计算。我犯过一个低级错误把网格索引当成像素坐标忘了乘以stride结果所有框都缩在左上角。最后说一句Anchor-Free不是银弹。对于密集小目标场景Anchor-Based的YOLOv5可能更好调参。但如果你追求简洁的代码和更少的超参数Anchor-Free值得一试。下一篇我们聊聊如何把Anchor-Free检测头魔改成旋转框检测——那个坑更多。