
如果你长期在这个圈子里泡着应该也感觉到了YOLO系列卷到今天模型结构层面的“大改”越来越少取而代之的是各种精细化改进。真正想把检测精度往上推一档尤其是针对小目标靠堆参数量已经不是最优解了。这次分享的是我基于YOLO26做的一轮完整改进实战融合了ICME-2024提出的PPA注意力模块同时自己设计了一个轻量级的小目标检测头最终在自建数据集上把mAP50提升了12%以上mAP50-95也涨了近6个点。文章里没有玄学全是可复现的代码、配置和实测对比。先交代一下背景。YOLO26继承了Ultralytics系列的工程化优势训练、部署、导出都很顺手但默认检测头在应对小目标时依然存在特征分辨率不足的问题。PPA注意力Parallel Pyramid Attention是ICME-2024上出现的一种并行金字塔注意力结构擅长在多层特征之间建立跨尺度联系。把这两者结合起来等于一边增强特征表达一边给网络补上小目标的“分辨率短板”。下面我会把整个改进过程拆开揉碎从思路、代码、训练到踩坑一条线讲完适合正在做检测任务优化的同学直接参考。1. 整体设计思路与方案选型1.1 为什么选择PPA注意力而非其他注意力注意力机制现在是个“烂大街”的方向但有实际增益的模块并不多。我们最初对比过几种方案SE注意力侧重通道权重在大模型上提升稳定但幅度小CBAM加了空间注意力对小目标有一定帮助不过结构偏简单特征交互不充分Transformer类注意力效果好但对算力要求高在边缘设备上根本不友好。PPA注意力这个模块的设计思路是很典型的“金字塔结构并行注意力”组合。它把输入特征分解成多个尺度的子特征分别施加注意力最后融合起来。这样做有几个好处第一多尺度分解让小目标的上下文信息不会因为下采样而丢失第二并行结构不会显著增加推理耗时符合我这次“精度与效率兼顾”的调性第三PPA模块的计算是标准卷积和矩阵运算组合工程落地很顺。实际跑下来的感受是PPA在浅层特征上的增益比深层更明显这正好契合小目标检测的需求。小目标的形状、纹理信息主要集中在浅层高分辨率特征图上在这里强化注意力等于直接把钱花在刀刃上。1.2 自研小目标检测头的设计逻辑YOLO26默认的检测头是三尺度输出也就是80x80、40x40、20x20的特征层输入640x640时。80x80这一层负责小目标但8倍下采样对于几个像素尺寸的目标来说依然太粗。更麻烦的是默认检测头的feature map通道数较少小目标的语义信息经不起几轮卷积的损耗。我的自研检测头核心思路是添加一个更高分辨率的检测分支。具体来说在原来80x80分支的基础上引出160x160的特征层参与检测。这个新分支融合了第2层的浅层特征4倍下采样和经过上采样的深层特征既有细节又有语义。然后特殊设计的检测头对这个P2分支设置了更细的anchor匹配策略和独立的损失权重让网络在训练初期就把更多注意力放在微小目标上。有人可能会问加一个分支不会让训练变慢、显存爆炸吗这个担心有道理。我的做法是新分支只在训练阶段承担辅助监督作用推理时还是用原本的三个输出头配合蒸馏式的特征融合既保证了精度又不增加部署负担。当然如果你显存管够也可以把P2分支完整保留到推理阶段我代码里给了两套配置。1.3 任务适配小目标检测到底难在哪做改进之前得先明确一个问题小目标检测的“难”是结构性的不是单纯涨点那么简单。小目标在特征图上占据的像素极少经过几十层卷积后真正留下来的有效特征可能只有几个神经元。这就导致分类分支和回归分支学到的信息都很弱。常见的COCO数据集中小目标面积小于32x32像素的AP普遍比中目标低20个百分点这不是换个大模型就能解决的。我这次的数据集是工业质检场景下的零部件瑕疵检测缺陷目标最小只有几个像素宽。用YOLO26原版跑小目标的召回率只有50%出头错检误检也比较多。在做改进时我给自己定了三条硬性标准推理速度基本上不能下降超过15%对原有中大型目标的检测精度不能有明显回退指标提升必须是可复现的不能靠随机种子运气。围绕这三条PPA模块被插入到Backbone的深层位置和Neck的Cross-Scale融合之前P2检测头以辅助分支的形式加入。这样改动量比较保守训练稳定性也有保障。2. 环境准备与基线搭建2.1 软件环境和硬件配置我的实际运行环境如下仅供参考操作系统Ubuntu 22.04 LTSGPUNVIDIA RTX 4090 24GB单卡训练CUDA11.8 cuDNN 8.9Python3.10PyTorch2.1.2原始框架ultralytics YOLO26源码仓库GitHub最新版这里要提醒一个点Ultralytics仓库更新很频繁API变动很大。我建议你固定版本不要用latest否则YOLO26的yaml配置和回调接口可能不兼容。我用的是仓库commit固定的版本保证可复现。pip install torch2.1.2 torchvision0.16.2 --index-url https://download.pytorch.org/whl/cu118 pip install ultralytics -U2.2 数据集准备与划分我这里用的是自建的工业零部件瑕疵数据集总共12000张图。目标类别有6类划痕、凹坑、裂纹、异物、焊点偏移、边缘缺损。图像分辨率统一resize到640x640输入原始IMGSZ也是640。标注格式用YOLO格式的txt文件每个txt里记录的是归一化后的类别id和bbox坐标。小目标占比统计下来很有意思宽度或高度小于32像素的目标约占总目标数的68%。这就意味着如果不做针对性优化模型整体AP会被小目标拖垮。数据集划分采用8:1:1train、val、test各9600、1200、1200张并且划分前按照图像ID做了分层抽样保证同一场景的图像不会既出现在训练集又出现在验证集避免数据泄漏导致的虚高。2.3 构建基线模型并验证先把原始YOLO26跑一遍当作基线这一步非常重要。没有baseline的改进都是耍流氓。训练参数统一为epochs: 150batch: 16optimizer: SGDlr0.01, momentum0.937, weight_decay0.0005warmup epochs: 3mosaic: 1.0前70个epoch开启close_mosaic: 15amp: Truehsv_h/hsv_s/hsv_v: 默认值我建议大家完全复现一轮baseline再改网络。很多人在改进后说“涨点了”但其实是换了超参数或者随机种子带来的波动。固定所有变量才能让后续的每一步改进都有据可循。基线训练完成后的结果如下模型mAP50mAP50-95参数量M推理耗时ms/evalYOLO26s baseline71.446.811.23.2YOLO26m baseline74.950.325.86.1接着用这个baseline模型跑test集输出每个类别的AP可以看到小目标类别划痕、裂纹的AP明显低于其他类别。这就是后面改进的核心观察点。3. 核心代码实现与模块接入细节3.1 PPA注意力模块完整代码PPA模块的实现并不复杂核心是并行金字塔池化与注意力路径。我先给出可以直接添加到项目的Python/PyTorch代码。这里采用标准卷积、全局平均池化、1x1卷积和Sigmoid组合不做花哨操作。import torch import torch.nn as nn import torch.nn.functional as F class PPA(nn.Module): Parallel Pyramid Attention module. Reference: ICME 2024 PPA. 输入: 任意通道数的特征图 [B, C, H, W] 输出: 与输入相同shape已做残差融合 def __init__(self, in_channels, reduction8): super().__init__() self.in_channels in_channels # 分支1全局上下文注意力 self.gap nn.AdaptiveAvgPool2d(1) self.global_fc nn.Sequential( nn.Conv2d(in_channels, in_channels // reduction, kernel_size1, biasFalse), nn.BatchNorm2d(in_channels // reduction), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // reduction, in_channels, kernel_size1, biasFalse), nn.Sigmoid() ) # 分支2金字塔池化注意力多尺度 self.pyramid_branches nn.ModuleList([ nn.Sequential( nn.AdaptiveAvgPool2d(size), nn.Conv2d(in_channels, in_channels // reduction, kernel_size1, biasFalse), nn.BatchNorm2d(in_channels // reduction), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // reduction, in_channels // reduction, kernel_size3, padding1, biasFalse), nn.BatchNorm2d(in_channels // reduction), nn.ReLU(inplaceTrue) ) for size in (1, 2, 4) ]) # 分支3原始细节注意力路径 self.detail_conv nn.Sequential( nn.Conv2d(in_channels, in_channels // reduction, kernel_size3, padding1, biasFalse), nn.BatchNorm2d(in_channels // reduction), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // reduction, in_channels // reduction, kernel_size1, biasFalse), nn.BatchNorm2d(in_channels // reduction), nn.ReLU(inplaceTrue) ) # 融合 self.fuse nn.Sequential( nn.Conv2d(in_channels // reduction * 4, in_channels, kernel_size1, biasFalse), nn.Sigmoid() ) def forward(self, x): identity x # 全局注意力 ga self.gap(x) ga self.global_fc(ga) # 金字塔注意力 py_outs [] for branch in self.pyramid_branches: w branch(x) w F.interpolate(w, sizex.shape[-2:], modebilinear, align_cornersFalse) py_outs.append(w) # 细节路径 detail self.detail_conv(x) # 拼接 生成最终注意力权重 fusion_feat torch.cat([detail] py_outs, dim1) attention_map self.fuse(fusion_feat) # 残差连接 out x * attention_map x * ga return out有几个地方需要特别说明金字塔池化用了三种尺度1x1、2x2、4x4这组参数不是拍脑袋定的是在小目标数据集上调出来的。更大的池化尺度8x8对小目标增益不大反而增加计算量。细节注意力的3x3卷积是为了保留原始细节路径避免金字塔池化带来的过度平滑。最终注意力与输入相乘后又加了全局注意力的乘积作为残差增强。这是整个模块中收益最大的一行代码。3.2 自研小目标检测头的代码结构P2检测分支的实现需要动YOLO的模型结构定义。我的做法是扩展原始的DetectionHead增加一个P2分支并在训练阶段额外计算P2分支的损失。这里给出的代码片段是精简版本用于展示接入方式。class SmallObjectHead(nn.Module): 自研小目标检测头P2辅助头 输入来自 Backbone 第2层的浅层特征 [B, C2, H/4, W/4] def __init__(self, in_channels, num_classes, anchors): super().__init__() self.num_classes num_classes self.anchors anchors # 每个尺度上的anchor数量 # P2特征先做轻量特征对齐 self.reduce_conv nn.Sequential( nn.Conv2d(in_channels, in_channels // 2, kernel_size1, biasFalse), nn.BatchNorm2d(in_channels // 2), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // 2, in_channels, kernel_size3, padding1, biasFalse), nn.BatchNorm2d(in_channels), nn.ReLU(inplaceTrue) ) # 分类 回归分支 self.cls nn.Conv2d(in_channels, self.anchors * self.num_classes, 1) self.reg nn.Conv2d(in_channels, self.anchors * 4, 1) def forward(self, x): x self.reduce_conv(x) cls_out self.cls(x) reg_out self.reg(x) # 返回格式与检测头对齐便于计算损失 return torch.cat([reg_out, cls_out], dim1) def build_p2_head_from_yaml(cfg, ch, num_classes): 从yaml配置中读取P2头通道返回SmallObjectHead实例 p2_in_channels ch[0] # 对应backbone第2层 anchors len(cfg[anchors][0]) // 2 if isinstance(cfg[anchors], list) else 3 return SmallObjectHead(p2_in_channels, num_classes, anchors)P2检测头的设计细节reduce_conv的第一个1x1卷积用于压缩通道我实际测试过通道减半再还原不会明显掉点但显存占用和训练速度改善明显。分类分支和回归分支采用纯卷积输出没有额外的全连接这样在推理时可以兼容不同输入尺寸。训练阶段P2头的损失权重设置为0.25低于主头的1.0避免辅助头喧宾夺主。需要注意如果你用的YOLO26源码已经内置了P2分支的配置直接用官方配置不需要重复实现。我这里指的是源码中没有P2分支、需要自己额外添加的情况。3.3 在YOLO26主网络中接入PPA和检测头接入位置非常关键。以YOLO26s结构为例我的修改如下PPA模块插入到Backbone的第5层之后也就是SPPF之前和第7层之后Neck部分在Concat之前也插入一个PPAP2检测头从Backbone第2层引出并将特征送入Neck的更高层进行融合。如果你使用的是Ultralytics的yaml配置可以这样改模型文件# yolov26s_custom.yaml 片段 backbone: - [-1, 1, Conv, [64, 3, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 1, C3k2, [256, False, 0.25]] - [-1, 1, Conv, [256, 3, 2]] - [-1, 1, C3k2, [512, False, 0.25]] - [-1, 1, PPA, [512]] # 新增PPA模块 - [-1, 1, SPPF, [512, 5]] - [-1, 1, PPA, [512]] # 新增第二次PPA ... # head部分 head: - [-1, 1, nn.Upsample, [None, 2, nearest]] - [[-1, 3], 1, Concat, [1]] - [-1, 1, C3k2, [256, False, 0.25]] - [-1, 1, PPA, [256]] # 新增Neck中PPA ... - [-1, 1, SmallObjectHead, [256, nc]] # 新增P2小目标检测头需要注意yaml中的通道号需要根据实际模型做对应调整。我给的配置是s版本M/L版本要按比例放大但P2头的通道我建议固定在128-256之间不要过度放大否则训练显存会爆。3.4 损失函数调整与小目标权重优化YOLO26默认用的是CIoU损失加BCE分类损失。小目标检测中回归损失对小目标的梯度贡献天然偏低因为同等的像素偏差在归一化坐标系中会显得更小。我的做法是引入一种简单有效的调整对回归损失按照目标面积进行动态加权。def small_object_weighted_iou_loss(pred_boxes, target_boxes, reductionmean): 小目标动态加权目标面积越小回归权重越大 # 计算目标宽高 target_w target_boxes[..., 2] target_h target_boxes[..., 3] target_area target_w * target_h 1e-7 # 权重 1 / sqrt(归一化面积)面积越小权重越大 weights 1.0 / torch.sqrt(target_area) weights weights / weights.mean().detach() # 归一化保持尺度稳定 # 调用原始IoU loss并加权 iou_loss torchvision.ops.complete_box_iou_loss(pred_boxes, target_boxes, reductionnone) if reduction mean: return (iou_loss * weights).mean() return iou_loss * weights这个加权公式不是凭空想的它等价于把损失函数从“绝对尺度优先”变成了“相对尺度优先”。实际效果是小目标回归的梯度被放大训练时网络会更认真地学习小目标的边界。还有一个更简单的思路在数据增强阶段对小目标做over-sampling或者简单粗暴地降低Mosaic的剪切阈值把小目标保留下来。但这些增强手段不如直接改损失函数精确。我的最终方案是两者结合增强策略照旧损失函数做动态加权效果最稳定。4. 训练策略与实测对比4.1 训练配置与超参数调整模型结构改完之后不能直接沿用baseline的训练参数。因为增加PPA模块和P2辅助头之后浅层网络的梯度和深层网络的比例会发生变化。我调整后的训练配置如下epochs: 180比baseline多了30轮给新增模块充分收敛时间lr0: 0.005比baseline稍微降低避免浅层梯度震荡lrf: 0.01warmup_epochs: 5batch: 16显存不够可以调成8实验证明8和16的最终精度差异在0.3%以内weight_decay: 0.0005box: 7.5回归损失权重cls: 0.5分类损失权重小目标分类仍然重要dfl: 1.5DFL损失权重值得注意的是加了P2头后训练过程中的loss曲线会比原来波动大一些这是正常的。尤其在epoch 20-40之间P2头开始真正生效loss会有明显回落。不要一看到波动就关掉AMP或者降低学习率。我用的训练启动命令yolo train taskdetect modelcfg/models/v6/yolov26s_custom.yaml \ datadatasets/industrial/data.yaml \ epochs180 batch16 imgsz640 lr00.005 \ device0 workers8 \ optimizerSGD weight_decay0.0005 \ box7.5 cls0.5 dfl1.5 \ mosaic1.0 close_mosaic15 \ projectruns/train_custom nameexp_ppa_p24.2 消融实验PPA和P2头各自的贡献做改进实验如果只给一个最终结果说服力是不足的。我把消融拆分成了四组组A原始YOLO26s baseline组B只加PPA模块组C只加自研P2小目标检测头组DPPA P2头全量方案每组训练都一样评估集也一致。结果如下方案mAP50mAP50-95小目标AP50参数量M推理耗时msYOLO26s baseline71.446.845.011.23.2 PPA only76.550.152.312.83.6 P2 head only78.251.756.915.64.4PPA P2 head83.956.763.817.24.9从这个表能看出两个信息PPA单独使用的增益是1.5-2个点的mAP50不算惊艳但非常稳定P2检测头单独使用的增益更大尤其在小目标AP50上提升了近12个点说明高分辨率分支才是关键两者叠加的组合增益并非简单相加而是出现了正交互效应PPA增强了浅层特征的表示质量P2头则充分利用了这些增强后的特征。推理耗时从3.2ms增加到4.9ms增幅约53%。这个增幅跟我最初“不超过15%”的目标有差距所以我又做了一版推理优化。4.3 推理阶段优化如何保住速度前面说过P2头在部署时可以去掉。我去掉P2头、只保留PPA模块的结构进行推理测试部署方案mAP50mAP50-95推理耗时ms完整训练模型 P2输出83.956.74.9去掉P2头只保留PPA 主头81.254.33.5完整结构但TensorRT FP1683.756.51.8实际部署时我推荐第三种方案训练阶段用完整结构推理时导出为TensorRT FP16这样既能拿到接近全量模型的精度速度也能压到2ms以内。TensorRT对PPA模块里的普通卷积和Sigmoid支持很好没有遇到算子兼容问题。如果你不想费劲折腾TensorRT也可以采用剪枝方案把P2头删掉后重新导出ONNX只损失约2个mAP点换取推理速度回升这是工程上一个很实用的折中。4.4 实测结果与可视化分析最终测试集上的逐类AP对比如下类别baseline mAP50改进后 mAP50提升划痕38.258.620.4凹坑55.169.214.1裂纹42.061.119.1异物76.888.311.5焊点偏移89.294.14.9边缘缺损74.385.711.4划痕和裂纹这两类原本最弱的目标提升最明显分别涨了20个点和19个点。这符合预期这两类目标尺寸小、纹理细最依赖高分辨率特征和注意力增强。而焊点偏移这种本身就是中等尺寸的目标提升有限。从可视化热图上也能看到PPA模块让模型在背景复杂的区域减少了误检P2头让模型能捕捉到更细的目标边界。我记得有一张裂纹测试图原版模型完全漏检改进后不仅正确框出了裂纹置信度还到了0.82这个观感上的提升比数字更直观。5. 训练过程中遇到的坑与排错实录5.1 P2头梯度爆炸问题第一次加入P2头训练时大概在第10个epochloss突然飙升到几千随后训练直接崩掉。排查后发现是P2头内部的BatchNorm在训练初期统计量不稳定加上输入是小分辨率特征BatchNorm的均值和方差跳动很大。解决方案有两个任选其一在P2头的每个BatchNorm后面加一个可学习的scale层初始化为0.1让网络先“小步走”给P2头的损失乘上一个动态warmup系数前10个epoch从0逐步升到1。我用的是方案2代码很简单def p2_loss_weight(epoch, warmup_epochs10): return min(1.0, epoch / warmup_epochs) * 0.255.2 小目标AP提升但大目标AP回退在最初的实验版本里mAP50整体涨了8个点但我看逐类指标时发现外来异物、边缘缺损这两类大中目标的AP掉了2-3个点。这是一个典型的“此消彼长”问题新检测头过度强调小目标导致网络在特征分配上“偏心”。解决方法是在损失函数中加入类别感知的平衡权重对大中目标的分类损失稍微加权。我在原始cls权重0.5的基础上对大目标类别的分类损失乘了1.2的系数。这样网络在优化小目标的同时也不会放松对大目标的检测要求。调整后大目标AP基本回到baseline水平。5.3 PPA模块在底层设备上的部署兼容性如果你的目标设备是Jetson或者RK3588这类边缘设备需要注意PPA模块里的F.interpolate在ONNX导出时可能被转成Resize算子部分硬件加速库对Resize的align_corners参数支持不完整。我遇到过一次在RKNN工具链上转换失败的情况。排错思路是把PPA模块里的bilinear插值换成nearest插值。测试后发现对小目标检测来说nearest和bilinear在精度上的差异不到0.5个点但兼容性提升很大。如果你的部署工具链支持bilinear那就没必要换。5.4 训练数据不均怎么处理小目标数据集往往有一个问题小目标数量很多但标签噪声占比也高。几个像素的边界框人工标注很容易多个一两像素。我在训练过程中发现某些epoch验证集AP会在几个点之间剧烈波动一开始以为是过拟合后来抽样检查发现是标注边界不一致导致的。处理方式是使用简单但有效的标签平滑和数据清洗把那些宽度或高度小于3像素的标注直接过滤掉同时对长宽比超过20:1的极端框做裁剪。这一步操作让我最终模型的稳定性好了很多。6. 代码结构、复现步骤与扩展建议6.1 完整项目文件结构我整理后的项目结构如下yolo26_custom/ ├── cfg │ └── models │ ├── v6 │ │ ├── yolov26s_custom.yaml │ │ └── yolov26m_custom.yaml ├── ultralytics │ ├── nn │ │ ├── modules │ │ │ ├── ppa.py │ │ │ └── small_head.py │ │ └── tasks.py ├── data │ └── industrial.yaml ├── scripts │ ├── train.sh │ ├── val.sh │ └── export_onnx.py └── runs └── train_custom6.2 复现改进效果的关键步骤第一步把ppa.py和small_head.py放到ultralytics/nn/modules/目录下然后在__init__.py里注册。第二步修改tasks.py在parse_model函数中增加对PPA和SmallObjectHead的判断逻辑把它们的参数正确传进去。具体就是在循环解析每层时遇到PPA就调用PPA(c2, *args)遇到SmallObjectHead就调用SmallObjectHead(c2, nc, anchors)。第三步写好自定义的yaml配置文件通道对齐。第四步跑训练脚本。如果你照着我上面的步骤来12%的mAP50提升是可以复现的。我在另一台3090上验证过一次幅度稍微低一点但也在10%以上。6.3 进一步扩展方向这套方案并非只适用于工业缺陷检测任何小目标占比高的任务都可以借鉴遥感图像中的飞机、船舶检测小目标密集PPA的多尺度金字塔特别适合无人机视角下的行人检测行人像素少P2头能提升召回率医学影像中的细胞检测目标小且边界模糊注意力增强会很实用。如果想继续压榨精度还可以在PPA模块后面再接一层轻量的Transformer编码层或者在损失函数里引入Shape-IoU这类IoU变体。不过这些改动需要更多显存和调参精力要看你的业务需求来定。最后想说的做模型改进最忌讳的是“只贴结果不给过程”。我这次把从baseline到最终方案的每一步都记录下来了包括踩过的坑和绕过的弯就是希望大家能少走点弯路。PPA注意力加自研P2小目标检测头的组合不一定是最好的方案但在我这个工业质检场景下确实把精度顶上去了一大截。你在自己的数据集上实验时一定要根据目标尺寸分布来调节P2头的通道数和损失权重这些超参对最终结果的影响往往比模块本身还大。