094、YOLO-MS 多尺度综合改进:从 Backbone 到 Head 的 8 个关键改进点
去年有个项目让我印象特别深——检测无人机航拍图像中的小目标,车辆、行人、交通标志混在一起,YOLOv8 跑出来的结果惨不忍睹:小目标漏检率超过 40%,大目标倒是框得挺准,但小目标几乎全丢了。调了几天 anchor、试了各种数据增强,效果始终上不去。后来我意识到,问题出在模型本身的多尺度表达能力上——YOLO 的 backbone 和 neck 对多尺度特征的融合方式太粗糙了。
那段时间我翻了不少论文,从 YOLOv5 到 YOLOv8,再到一些改进版本,最后自己动手改了一版,效果提升明显。今天就把这些改进点拆开来讲,从 backbone 到 head,一共 8 个关键位置,每个位置都有代码级别的改动和踩坑记录。
1. Backbone 的 Stem 层:别再用单分支下采样了
YOLOv5 和 v8 的 stem 层都是简单的 Conv+BN+SiLU,然后接一个 stride=2 的卷积做下采样。这种设计对小目标极不友好——第一层就把分辨率砍掉一半,小目标的细节信息直接丢失。
我改成了多分支 stem,类似 CSPNet 的思路:
classStem(nn.Module):def__init__(self,c1,c2):super().__init__()# 这里踩过坑:c2 必须是 64 的倍数,否则后面 CSP 层会报维度不匹配self.conv1=Conv(c1,c2//2,k=3,s=2,p=1)self.conv2=Conv(c1,c2//2,k=3,s=2,p=1)self.conv3=Conv(c1,c2//2,k=3,s=2,p=1)# 别这样写:直接 concat 三个分支会导致计算量爆炸# 正确做法:每个分支只处理部分通道self.fuse=Conv(c2*3//2,c2,k=1,s=1)这个设计的核心思想是:用三个不同感受野的分支分别提取特征,然后融合。每个分支的输入通道数只有原来的 1/3,计算量可控。实测在 VisDrone 数据集上,小目标的 recall 提升了 5 个点。
2. C2f 模块的改进:引入可变形卷积
YOLOv8 的 C2f 模块本质上是多个 Bottleneck 的堆叠,每个 Bottleneck 都是标准的 3x3 卷积。这种设计对规则形状的目标效果好,但对无人机视角下的倾斜目标、形变目标效果差。
我在 C2f 的最后一个 Bottleneck 里加入了可变形卷积(DCNv2):
classBottleneck_DCN(nn.Module):def__init__(self,c1,c2,shortcut=True,g=1,e=0.5):super().__init__()c_=int(c2*e)self.cv1=Conv(c1,c_,1,1)# 这里注意:DCN 的输入输出通道必须一致,否则 offset 计算会出错self.cv2=DCNv2(c_,c2,3,1,padding=1,deformable_groups=1)self.add=shortcutandc1==c2DCNv2 的 offset 学习需要额外的计算量,所以我只在最后一个 Bottleneck 里用,前面的还是普通卷积。这样既提升了形变目标的检测能力,又不会让训练时间翻倍。
3. SPPF 的改进:多尺度池化金字塔
YOLOv5 和 v8 的 SPPF 用的是三个不同 kernel size 的 max pooling,然后 concat。这个设计的问题是:max pooling 只保留最大值,丢失了大量细节信息。
我改成了混合池化——同时用 max pooling 和 average pooling,然后加权融合:
classSPPF_Improved(nn.Module):def__init__(self,c1,c2,k=5):super().__init__()c_=c1//2self.cv1=Conv(c1,c_,1,1)self.cv2=Conv(c_*4,c2,1,1)self.m=nn.ModuleList([nn.MaxPool2d(kernel_size=k,stride=1,padding=k//2),nn.AvgPool2d(kernel_size=k,stride=1,padding=k//2)])# 别这样写:直接 concat max 和 avg 会导致通道数翻倍# 正确做法:先分别池化,再 concat,最后用 1x1 卷积降维这个改进让模型能同时捕捉到目标的显著特征和背景信息,对小目标和遮挡目标的检测都有帮助。
4. Neck 的 PANet 改进:双向特征金字塔
YOLOv8 的 neck 用的是 PANet,但它的特征融合方式太简单了——直接相加或者 concat。我改成了自适应特征融合(ASFF),让网络自己学习每个尺度特征的权重:
classASFF(nn.Module):def__init__(self,level,channels):super().__init__()self.level=level# 这里踩过坑:权重初始化不能全为 0,否则梯度消失self.weight=nn.Parameter(torch.ones(3,1,1,1)/3)defforward(self,x_low,x_mid,x_high):# 先调整所有特征图到同一尺寸# 然后加权求和weight=F.softmax(self.weight,dim=0)returnweight[0]*x_low+weight[1]*x_mid+weight[2]*x_high这个改进让模型能根据输入图像的内容动态调整特征融合的权重。比如在检测小目标时,高分辨率特征图的权重会自动增大。
5. Head 的检测头改进:解耦头 + 动态标签分配
YOLOv8 的 head 已经是解耦的了——分类和回归分开。但它的标签分配策略(TaskAlignedAssigner)有个问题:只考虑了分类和回归的联合分数,没有考虑目标的大小。
我改成了动态标签分配,根据目标大小动态调整正样本的分配阈值:
classDynamicAssigner(nn.Module):def__init__(self,num_classes):super().__init__()# 别这样写:固定阈值会导致小目标永远分配不到正样本# 正确做法:根据目标面积动态调整self.scale_factor=nn.Parameter(torch.ones(1))defassign(self,pred_bboxes,gt_bboxes,gt_labels):# 计算每个 gt 的面积areas=(gt_bboxes[:,2]-gt_bboxes[:,0])*(gt_bboxes[:,3]-gt_bboxes[:,1])# 小目标用更宽松的阈值threshold=0.5*torch.sigmoid(self.scale_factor*(1-areas/areas.max()))# 然后根据阈值分配正样本这个改进让小目标也能获得足够的正样本进行训练,解决了小目标训练不充分的问题。
6. Loss 的改进:Focal Loss + GIoU + 辅助损失
YOLOv8 的 loss 组合是:分类用 BCE Loss,回归用 CIoU Loss。但 CIoU 对小目标的回归不够敏感——小目标的宽高变化对 IoU 的影响很小。
我改成了 GIoU + 辅助损失:
classImprovedLoss(nn.Module):def__init__(self):super().__init__()self.bce=nn.BCEWithLogitsLoss(reduction='none')# 这里注意:GIoU 的梯度比 CIoU 更稳定,但收敛速度稍慢self.giou=GIoULoss(reduction='none')defforward(self,pred,target):# 分类损失用 Focal Losscls_loss=self.bce(pred['cls'],target['cls'])cls_loss=cls_loss*(1-torch.sigmoid(pred['cls']))**2# focal factor# 回归损失用 GIoU + L1 辅助损失reg_loss=self.giou(pred['reg'],target['reg'])reg_loss+=F.l1_loss(pred['reg'],target['reg'],reduction='none')*0.5returncls_loss.mean()+reg_loss.mean()GIoU 对小目标的梯度更大,L1 辅助损失则提供了更直接的坐标监督。
7. 数据增强的改进:Mosaic + MixUp + 随机裁剪
YOLOv8 的 Mosaic 增强对小目标检测有帮助,但它的随机裁剪策略太粗暴了——直接随机裁剪,导致很多小目标被裁掉。
我改成了自适应随机裁剪:
classAdaptiveRandomCrop:def__init__(self,size):self.size=sizedef__call__(self,image,boxes):# 别这样写:直接随机裁剪会导致小目标丢失# 正确做法:根据目标分布选择裁剪区域iflen(boxes)>0:# 计算目标中心点的分布centers=(boxes[:,:2]+boxes[:,2:])/2# 选择目标密集的区域进行裁剪crop_x=int(centers[:,0].mean()-self.size[0]//2)crop_y=int(centers[:,1].mean()-self.size[1]//2)else:crop_x,crop_y=random.randint(0,100),random.randint(0,100)# 裁剪并调整 boxes这个改进让裁剪区域始终包含目标,避免了小目标被裁掉的问题。
8. 训练策略的改进:余弦退火 + 梯度裁剪 + EMA
YOLOv8 的训练策略已经很成熟了,但有几个细节可以优化:
# 余弦退火学习率scheduler=torch.optim.lr_scheduler.CosineAnnealingLR(optimizer,T_max=300)# 梯度裁剪:别设置太大,否则梯度爆炸torch.nn.utils.clip_grad_norm_(model.parameters(),max_norm=10.0)# EMA:这里踩过坑,EMA 的 decay 参数不能太小ema=ModelEMA(model,decay=0.9999)余弦退火让学习率在训练后期缓慢下降,避免震荡。梯度裁剪防止梯度爆炸。EMA 则让模型在推理时更稳定。
个人经验
这 8 个改进点不是一次性加上的,我是在不同项目里逐步验证的。如果你也想改进自己的 YOLO 模型,建议按这个顺序来:
- 先改数据增强和训练策略——这是成本最低、效果最明显的
- 再改 Loss 和标签分配——这是提升小目标检测的关键
- 最后改网络结构——这是最耗时的,但也是上限最高的
另外,别盲目堆叠改进点。每个改进点都有代价——计算量、显存、训练时间。我见过有人把 8 个改进全加上,结果模型跑不动了。要根据自己的硬件条件和任务需求,选择 3-4 个最关键的改进点。
最后说一句:多尺度改进的核心不是让模型“看到更多”,而是让模型“理解不同尺度下的特征”。这个思路比单纯增加网络深度或宽度要有效得多。