085、小目标检测层 P2 添加:高分辨率特征图层增加、Anchor 重新聚类与 Loss 权重调整
一、从一次深夜调试说起
凌晨两点,我盯着 TensorBoard 上那条死活下不去的 mAP@0.5:0.95 曲线,心里骂了句脏话。项目是无人机航拍图像中的行人检测——小目标多到离谱,一个 1080p 的图里能塞进两百个 20x20 像素的人头。YOLOv8 默认的 P3、P4、P5 检测层,对小目标的感受野太大了,特征图下采样 8 倍后,20x20 的物体只剩 2.5x2.5 个格子,模型根本学不到有效信息。
我试过调大输入尺寸到 1280,显存直接爆了。试过加小目标数据增强,效果微乎其微。最后翻到 YOLOv5 的早期 issue 里有人提过 P2 检测层,抱着死马当活马医的心态改了网络结构——结果 mAP 直接跳了 6 个点。今天就把这个“救命”的操作拆开揉碎讲清楚。
二、P2 检测层:把“放大镜”怼到特征图上
YOLO 默认的检测层从 P3 开始(下采样 8 倍),P4(16 倍),P5(32 倍)。对小目标来说,P3 的 8 倍下采样已经太大了——一个 16x16 的物体在特征图上只有 2x2 个格子,Anchor 稍微偏一点就匹配不上。
P2 层对应下采样 4 倍的特征图,分辨率是 P3 的 2 倍。这意味着同样大小的物体在 P2 上能占据 4x4 个格子,模型能捕捉到更精细的边缘和纹理信息。但代价也很明显:特征图尺寸翻倍,计算量和显存占用跟着涨。
代码实现时踩过的坑:别直接在 backbone 的 P2 输出上接检测头。YOLO 的 neck 部分(FPN+PAN)需要跨尺度融合,P2 特征图太大,直接塞进 PAN 会让后续层计算量爆炸。正确做法是:从 backbone 的 P2 输出(比如 YOLOv8 的model.4层)引出一条分支,经过一个 1x1 卷积降通道到 128 或 256,再上采样到与 P3 对齐,最后拼接到 FPN 里。
# 伪代码,实际需根据模型结构调整classP2Detection(nn.Module):def__init__(self,in_channels=256,out_channels=128):super().__init__()# 这里踩过坑:直接用 3x3 卷积会导致感受野重叠,小目标特征被稀释self.conv1=nn.Conv2d(in_channels,out_channels,1,1,0)# 1x1 降维,保留空间信息self.conv2=nn.Conv2d(out_channels,out_channels,3,1,1)# 3x3 提取局部特征defforward(self,x):x=self.conv1(x)x=self.conv2(x)returnx别这样写:直接把 P2 特征图下采样到 P3 大小再拼接。这等于把高分辨率信息又丢了,加 P2 的意义何在?
三、Anchor 重新聚类:别让先验框拖后腿
加了 P2 检测层后,默认的 Anchor 尺寸(基于 COCO 数据集聚类得到)完全不对。COCO 里小目标占比不高,聚类出来的 Anchor 偏大。对于无人机航拍场景,目标尺寸集中在 10x10 到 40x40 像素,默认 Anchor 最小也有 16x16,匹配率极低。
重新聚类的正确姿势:用 k-means 对训练集所有标注框的宽高做聚类,聚类数 = 检测层数 × 每层 Anchor 数。比如 P2、P3、P4、P5 四层,每层 3 个 Anchor,总共 12 个。
# 聚类代码片段,注意这里用 IoU 距离而非欧氏距离defkmeans_anchors(bboxes,k=12,iterations=100):# bboxes: (N, 2) 宽高# 别这样写:直接对原始宽高聚类,大目标会主导结果# 正确做法:对宽高取 log,缩小尺度差异bboxes=np.log(bboxes+1e-6)# 初始化聚类中心centroids=bboxes[np.random.choice(len(bboxes),k,replace=False)]for_inrange(iterations):# 计算 IoU 距离distances=1-iou_distance(bboxes,centroids)labels=np.argmin(distances,axis=1)# 更新中心foriinrange(k):ifnp.sum(labels==i)>0:centroids[i]=np.mean(bboxes[labels==i],axis=0)# 还原到原始尺度returnnp.exp(centroids)聚类完成后,把 Anchor 按尺寸从小到大分配到各检测层:最小的 3 个给 P2,中间 3 个给 P3,以此类推。这里踩过坑:如果 P2 层 Anchor 太小(比如 4x4),会导致大量背景框被匹配为正样本,训练初期 Loss 爆炸。建议最小 Anchor 不小于 8x8。
四、Loss 权重调整:给小目标“开小灶”
加了 P2 层、重新聚类后,模型开始能检测到小目标了,但大目标的精度反而掉了。原因很简单:P2 层产生的正样本数量远多于其他层(小目标多),导致 Loss 被小目标主导,大目标学不好。
解决方案:对不同检测层的 Loss 赋予不同权重。P2 层权重降低,P4、P5 层权重提高。具体数值需要根据数据集调,我常用的初始值:P2:0.5, P3:1.0, P4:1.5, P5:2.0。
# 在 Loss 计算中调整权重loss_weights=[0.5,1.0,1.5,2.0]# 对应 P2, P3, P4, P5total_loss=0fori,(loss_i,weight)inenumerate(zip(layer_losses,loss_weights)):total_loss+=loss_i*weight别这样写:直接对每个小目标样本的 Loss 乘以一个系数。这会导致训练不稳定,因为小目标本身回归难度大,Loss 波动剧烈,再放大系数会让梯度爆炸。
另一个技巧:在分类 Loss 中引入 Focal Loss,对小目标(通常置信度低)加大惩罚。YOLOv8 默认的分类 Loss 是 BCE,换成 Focal Loss 后小目标召回率能再提 2-3 个点。
classFocalLoss(nn.Module):def__init__(self,alpha=0.25,gamma=2.0):super().__init__()self.alpha=alpha self.gamma=gammadefforward(self,pred,target):# 这里踩过坑:alpha 参数要针对正负样本分别设置# 正样本 alpha=0.25,负样本 alpha=0.75bce_loss=F.binary_cross_entropy_with_logits(pred,target,reduction='none')pt=torch.exp(-bce_loss)focal_loss=self.alpha*(1-pt)**self.gamma*bce_lossreturnfocal_loss.mean()五、训练策略与调参经验
学习率要降:加了 P2 层后模型参数量增加,学习率建议从默认的 0.01 降到 0.005,否则 Loss 容易震荡。我习惯用余弦退火调度器,前 10 个 epoch 用 warmup 从 0 升到 0.005。
Batch Size 别贪心:P2 层特征图是 160x160(输入 640x640),显存占用比 P3 层大 4 倍。如果原来能跑 16 batch,加了 P2 后建议降到 8,否则 OOM 警告。
先冻结 backbone 训练:P2 层刚加进去时权重随机,直接全量训练会导致 backbone 被带偏。我习惯先冻结 backbone 训练 20 个 epoch,只更新新增的 P2 检测头和 FPN 部分,再解冻全量训练。
验证集要分层采样:小目标和大目标的评估指标差异大,如果验证集里大目标多,mAP 可能不升反降。建议按目标尺寸分层采样,确保验证集分布与训练集一致。
六、个人经验总结
加 P2 检测层不是万能药。如果你的数据集里目标尺寸分布均匀,或者小目标占比低于 10%,加 P2 带来的收益可能抵不上计算量增加。我遇到过最坑的情况:加了 P2 后推理速度从 30fps 掉到 18fps,但 mAP 只涨了 0.5 个点,最后不得不回退。
另一个容易被忽略的点:P2 层对输入分辨率敏感。如果输入分辨率低于 512x512,P2 特征图只有 128x128,信息量不够,加了等于白加。建议输入分辨率至少 640x640,最好 1280x1280。
最后,Anchor 聚类不是一劳永逸的。数据集更新后要重新聚类,否则旧 Anchor 匹配新数据会出问题。我写了个脚本,每次训练前自动跑聚类,把结果写进配置文件,省心不少。
加 P2 层、调 Anchor、改 Loss 权重,这三板斧下来,小目标检测的 mAP 通常能涨 5-10 个点。但记住:没有银弹,每个数据集都有自己的脾气,多试多调才是正道。