做目标检测久了你就会发现,小目标检测从来不是“模型不够大”的问题,而是“信息压根没走到最后”的问题。我在一个无人机巡检项目里,被这事折腾过很久:飞行高度一拉高,画面里的电塔、绝缘子就变成几十像素大小的色块,靠普通YOLO去检测,漏检率高得让人怀疑是标注错了。后来把主干里的几个下采样点换成SPD-Conv,也就是空间深度转换卷积,小目标的召回率肉眼可见地上来了。这篇就是我在YOLO26系列改进过程中,把SPD-Conv放进框架后沉淀下来的完整记录,从原理、代码、配置到踩坑都会讲到,适合正在做小目标检测、想在现有YOLO结构上做低风险改进的友商参考。
1. 项目背景与改进思路:小目标检测为什么难,SPD-Conv为什么对症
1.1 小目标检测难在哪里
小目标检测的难点,本质上不在于“模型不聪明”,而在于输入图像里的目标信息本身就少得可怜。拿COCO的标准来说,32×32像素以下就算小目标,但在无人机巡检、卫星遥感、智能交通这些场景里,目标往往只有十几个像素。你在屏幕上肉眼能看到的那个“小黑点”,经过网络前向传播后,可能连一个完整的特征点都凑不齐。
另一个让人头疼的地方是采样密度。普通目标占了图像面积的百分之几甚至更多,但小目标可能只有万分之几。特征金字塔和各类注意力机制可以在一定程度上缓解,但它们的输入是从主干网络传过来的特征图,如果特征图在生成过程中已经把目标信息磨没了,后半段怎么抢救都有限。这一点我在实际项目里体会特别深:模型对大小目标的精度差异可以达到二十个点以上,而且怎么调损失函数都拉不回来。
还有一个容易忽略的问题,标注本身。小目标边缘模糊,人眼都很难精确框准,标注质量直接影响训练信号。我见过不少数据集里,小目标的框和实际物体存在好几个像素的偏移,训练时网络学到的边界信号又模糊又互相矛盾。所以做小目标检测,前期的数据清洗比改网络结构更先一步,不然你自己都分不清效果变好是结构功劳还是标注修正的功劳。这项改进我放在后面“常见问题”里详细说,因为前期准备不做扎实,后面调结构很容易得出错误结论。
1.2 传统Conv的下采样让信息过早丢失
YOLO系列的主干网络通常要经历多次stride=2的卷积下采样,每做一次,特征图的空间尺寸就减半。这个操作在宏观上是必要的,它能扩大感受野、聚合语义信息,但对小目标来说就是灾难。比如一个8×8像素的目标,经过一次stride=2卷积后变成4×4,再来一次就变成2×2,到了深层特征图里,目标占据的区域往往不足一个像素,后续卷积核再怎么扫也扫不出纹理特征。
问题出在标准卷积的实现方式上。一个stride=2的3×3卷积,本质上是在做“隔点采样”,虽然它会同时考虑周围像素,但采样位置毕竟是稀疏的,落在采样网格之间的细节就被跳过了。类比一下:你用一张粗网眼的筛子筛面粉,细颗粒和粗颗粒都会在筛网上留下痕迹,但筛子真正收走的只有网眼范围内的部分,其余信息在结构上就是被忽略了。神经网络对这个过程没有“惋惜”机制,信息丢了就丢了,后面不会自动补回来。
值得注意的是,传统FCN架构里的这个“丢信息”,在高分辨率输入场景里还不算致命伤,因为目标再小也有足够像素冗余。但无人机巡检这类场景,输入分辨率通常被压到640×640甚至更小来换取推理速度,这时候每一层下采样都在侵蚀本就稀缺的小目标像素。所以我当时的第一反应是:能不能设计一种“下采样但不丢像素”的替代模块?这样既能保持网络整体的多尺度特征提取能力,又能让小目标的纹理在通道维度里延续下去。
1.3 为什么最终选择了SPD-Conv而不是其他补救方案
在决定引入SPD-Conv之前,我其实对比过几条常见的改进路线。第一种是空洞卷积,它能在不改变空间尺寸的情况下扩大感受野,理论上对小目标有好处,但空洞卷积有一个很麻烦的gridding effect,采样点过于稀疏时,小目标的精细纹理容易变成网格状伪影,尤其是堆叠多层之后,效果反而不稳定。第二种是加强特征金字塔,比如增加一个更高分辨率的检测头,这确实能提升小目标召回,但代价是计算量和显存占用直线上升,对于实时性要求高的项目不太好接受。
第三种是注意力机制,比如SE、CBAM之类,它们的思路是让网络更关注重要特征通道或空间位置,本身不产生空间信息,而是对已有特征做重标定。问题是小目标的特征在深层已经弱化,注意力机制能做的只是“在一堆不清晰的特征里挑相对清晰的”,天花板很低。所以这些方案只能是辅助手段,不适合作为核心修正。
SPD-Conv进入视野是因为它直接从数据组织的角度解决问题:通过Space-to-Depth操作,把空间维度的信息“折叠”进通道维度,完成下采样但保留所有像素,再配一个stride=1的普通卷积做特征提取。这就等于把筛子换成了抽屉,所有颗粒都收进抽屉里重新分拣,而不是直接漏掉。它和小目标检测的匹配度高,且改动范围可控,放在YOLO主干里就像给关键下采样点换了一个更懂空间细节的部件。这也是我这轮改进的核心方向:不是推翻整个框架,而是精准替换最容易丢信息的那几个环节。
2. SPD-Conv原理图解与PyTorch实现:空间深度转换到底做了什么
2.1 从Space-to-Depth说起
Space-to-Depth,中文一般叫空间深度转换,它做的事情可以用一个很直观的比喻解释:把一张大图按固定大小的窗口拆开,再把每个窗口里的像素按顺序叠成一摞。假设输入特征图是C×H×W,现在以2×2的窗口为单位,把窗口内四个像素分别放到通道方向,那么输出就变成4C×(H/2)×(W/2)。空间尺寸减半,但没有任何像素被丢弃,只是从空间排列变成了通道排列。
这个过程是PixelShuffle的逆向操作,PyTorch里它就叫PixelUnshuffle,一个API调用就能完成。很多论文里SPD-Conv的SPD就是指这一步操作,后面的“Conv”部分则是一个stride=1的普通卷积,对通道数做混合和压缩。注意这里的普通卷积步长是1,没有下采样功能,真正的尺寸变化全部由Space-to-Depth完成,所以不会出现传统的隔点采样信息丢失问题。
用具体数值走一遍更好理解。假设输入是256×64×64,经过PixelUnshuffle之后变成1024×32×32,通道数变成了原来的4倍,但高度和宽度都缩了一半。如果后面接的是一个1×1的卷积,它可以看作在“4倍通道”这个增广特征上进行线性组合;如果接3×3卷积,则是在一个空间尺寸缩小过的特征图上提取局域特征,感受野范围和普通下采样卷积其实是接近的,但输入里包含了所有原始像素的信息。
这种设计对小目标特别友好。一个8×8的小目标,在传统stride=2卷积下采样之后变成4×4,再下采样变成2×2,特征图上的信息量是骤减的。而SPD-Conv第一次下采样时,这个8×8目标在通道维度中被拆分成了4份4×4的信息块,后续卷积依然可以从4个方向同时观察目标的局部结构,相当于把原本容易碎裂的信号并行保存了下来。等到后续层再处理时,模型获得的“素材”比传统路线多得多。
2.2 PyTorch实现核心代码
SPD-Conv的代码并不复杂,我自己实现的是一个可配置下采样的模块,核心代码如下:
import torch import torch.nn as nn class SPDConv(nn.Module): def __init__(self, in_channels, out_channels, downsample=True, kernel_size=3): super().__init__() self.downsample = downsample # 下采样时使用 PixelUnshuffle,把空间信息折叠到通道 if downsample: self.spd = nn.PixelUnshuffle(downscale_factor=2) else: self.spd = nn.Identity() factor = 4 if downsample else 1 mid_channels = in_channels * factor self.conv = nn.Conv2d( mid_channels, out_channels, kernel_size=kernel_size, stride=1, padding=kernel_size // 2, bias=False, ) self.bn = nn.BatchNorm2d(out_channels) self.act = nn.SiLU(inplace=True) def forward(self, x): x = self.spd(x) x = self.conv(x) return self.act(self.bn(x))几个细节我想专门说明一下。第一,downsample=False的场景并不常见,但有时你只想在某个位置做“纯SPD处理”而不改变尺寸,保留这个开关会更灵活。第二,我用了3×3卷积而不是1×1卷积,原因是3×3核能覆盖下采样后相邻位置的空间关系,尤其在后面接普通卷积时,能更好地利用SPD后特征图里的局部连续性;如果你对速度极敏感,换1×1也完全可行,只是精度上会略低一点。第三,激活函数用SiLU而不是ReLU,是为了和YOLO主干的风格保持一致,方便直接接入而不需要额外调整激活层的分布。
顺手我也说一下依赖版本。我用的是PyTorch 2.x,nn.PixelUnshuffle是内置模块,如果你的代码环境比较老,PyTorch 1.9以上基本都支持。如果确实没有这个API,可以手动实现四个切片后concat,效果等价,但代码会冗长一些。
2.3 嵌入YOLO26的具体位置和维度对齐
SPD-Conv嵌入YOLO主干,最核心的问题不是怎么写代码,而是放在哪些位置,以及怎么保证通道和步长对齐。YOLO26(这里指的是我们在项目里维护的YOLO系列分支版本)的主干大体遵循CSP/ELAN结构,在stage之间用stride=2卷积做下采样。我的做法是把下采样卷积直接替换成SPDConv,输入输出尺寸与传统下采样一致:H和W都减半,通道数由out_channels参数控制,无需额外调整后续模块。
具体来说,我替换了两个位置:主干中第三个下采样点和第四个下采样点。这两个位置是浅层空间信息向深层语义信息过渡的关键节点,也是小目标空间细节最容易流失的地方。第一、第二个下采样点虽然也在丢信息,但输入分辨率还比较高,目标像素相对丰富,替换收益不明显,反而会增加不少计算量。所以我更建议从后端下采样点开始改,成本更低,针对性更强。
还有一个很容易踩的坑是通道数要求。PixelUnshuffle要求输入通道数能被4整除,而YOLO主干中大多数层的通道数都是128、256、512这类偶数,通常都能满足。但如果你的自定义主干里出现了奇数通道或者不是4的倍数,就需要先用一个1×1卷积把通道调整成能整除的数,再送入SPDConv,否则会直接报错。我在第二次实验时换了一个自定义轻量主干,通道数是96,虽然也是4的倍数,但设计权重的同事一开始写成了80+16,结果后续通道对齐出了一堆问题,排查了很久才意识到是整除约束。
如果你不想改动主干结构,也可以把SPD-Conv当作检测头前的增强分支来用:在FPN的某一个特征层后,加一条SPDConv分支并和原有特征做融合。这种接法相当于给检测头额外补充一路“高保真下采样”的特征,改动更小,我也试过,效果略弱于直接替换主干下采样点,但胜在稳定和低风险。
3. 实操记录:在YOLO26中接入SPD-Conv的完整步骤
3.1 建立基线:环境、数据与评估口径
任何结构改进都不能跳过基线实验。我先在原有YOLO26代码库上跑了一版完整训练,作为后续所有对比的基准。训练环境用的是常见的深度学习容器,PyTorch 2.x,CUDA环境常规配置,显卡是消费级主流型号,12GB显存这个级别。数据集方面,我取的是某无人机巡检采集数据的子集,包含可见光和红外两个模态,目标类别包括设备、车辆、建筑物角落等,其中大量目标的长边不足40像素,换算成COCO口径就是典型的小目标密集分布。
评估指标我同时看了mAP@0.5、mAP@0.5:0.95和AR_s(小目标平均召回率)。只盯着mAP@0.5容易给人错觉,因为小目标只要框住一点就算命中,实际定位精度可能很差;mAP@0.5:0.95更严厉,能反映检测框和标注框的贴合度。AR_s则是判断“召回”是否真的改善的核心,因为小目标漏检率高,改进如果只是把少量目标从低置信度推到高置信度,mAP会涨但不一定代表整体感知能力变强。
基线训练时我顺手记录了两个重要指标:训练损失收敛情况和各尺度目标的PR曲线。这一方面是为了后续对比Loss行为是否正常,另一方面,PR曲线比单点数值更能反映阈值选择的余地。我建议大家也养成存PR曲线的习惯,尤其是做小目标项目,mAP相近的两个模型,PR曲线形态可能完全不同,直接影响你在实际产品里的阈值设定。
3.2 改动网络结构的实际操作记录
具体进入代码层面,我按四步完成了改动。第一步,在模型定义文件里新建一个模块文件,把上一节的SPDConv类放进去。这里我建议直接在YOLO的模型目录下新建spdconv.py,而不是塞进utils或者common,因为后续你还会想试其他变体,独立文件会让代码清爽很多。
第二步,在主干构建逻辑中引入SPDConv。YOLO系列通常用yaml配置文件来描述网络结构,我们维护的YOLO26分支也保留了类似的配置化机制。我在yaml的backbone部分,把其中一个下采样层的行从:
- [-1, 1, Conv, [256, 3, 2]]改为:
- [-1, 1, SPDConv, [256, 3, 2]]注意这里第二个参数是重复次数,1就代表单层;第三个是模块类型名,必须在代码里注册过;第四个参数列表则对应SPDConv的构造参数:输出通道、卷积核大小、是否下采样。如果你直接复制YOLO原来的配置但只替换类型名,很容易漏掉参数顺序,导致out_channel或kernel_size传错。我用的传参签名是(out_channels, kernel_size, downsample),和普通Conv的参数排列不完全一致,所以我在SPDConv的类注释里专门写了一份参数说明。
第三步,也是很多人会漏掉的,就是同步修改forward函数中主干网络层的索引。YOLO主干里的层是按顺序编号的,替换某个下采样点之后,后续层的输入来源索引也要确认是否仍指向正确的层。如果代码里某些地方硬编码了层编号,替换可能导致张量尺寸不匹配。我实验时因为后备网络定义纯粹由yaml驱动,所以没有这个麻烦,但如果你在代码里直接写死结构,这一步一定要核查。
第四步,跑一个前向传播测试,用一个随机张量验证替换后模型的输出shape是否和原来一致。这个步骤可以省很多时间,我通常会在训练脚本里加一段debug模式:输入(1,3,640,640)的随机数据,打印每一层输出尺寸。只要这一步过了,基本能保证后面训练不会出现shape相关的低级错误。
3.3 训练策略调整与显存优化
SPD-Conv引入后,训练策略并不是一成不变的。最直观的变化是显存占用会有所上升。原因不难理解:PixelUnshuffle把空间信息转到通道,中间层的通道数变大了,比如256通道的feature map在接受SPD处理时会先变成1024通道,尽管后续卷积会压回256,但这个中间状态的显存开销是真实存在的。我第一次跑全量替换时,batch size原本是32,直接爆显存了,改成16才稳住。
如果既想保住batch size又想用SPD-Conv,我的建议是开启梯度累积,也可以用混合精度训练,AMP在PyTorch 2.x里已经非常成熟。不过要注意,SPD阶段的通道扩展会增加activation memory,混合精度能缓解一部分,但不能完全等价于降低batch。所以实操上我更推荐先减batch,再配合梯度累积,这样训练更稳定。
学习率方面,我发现SPD-Conv的收敛行为和普通Conv有一定差异。也许是因为通道维度扩大后,初始特征分布的方差变化了,用原来的初始学习率跑,前几轮loss波动会大一点。我最后采用的方案是保持warmup做5个epoch,学习率峰值降到原来的0.7倍左右,然后逐步回落到baseline水平。这个数值不是从数学上推导出来的,而是跑了两轮对比后的经验值,大家换个数据集也许要微调,但方向是:不要一上来就用激进的学习率。
另外一个值得注意的点是EMA(指数移动平均)。我们实验时EMA确实让整体指标稳定了一些,尤其在小目标检测这种波动较大的任务里,EMA的平滑作用能避免训练后期震荡带来的效果时好时坏。如果你发现SPD-Conv的指标曲线“上蹿下跳”,先别急着改结构,看看是不是EMA或warmup没有配置好。
4. 消融对比与常见问题排查:让改进真正落地
4.1 量化对比结果怎么看
我最终跑了几组消融实验,主要对比三个层级:基线、只替换第三个下采样点、替换第三和第四个下采样点。为了不受数据集本身波动干扰,每组跑了完整训练,并固定随机种子。整理后的趋势大致如下:
| 实验设置 | mAP@0.5 | mAP@0.5:0.95 | 小目标召回AR_s | 推理耗时/帧 | 显存占用 |
|---|---|---|---|---|---|
| 基线(原版下采样) | 基准 | 基准 | 基准 | 基准 | 基准 |
| 仅替换第三下采样点 | +约1.2% | +约0.8% | +约2.1% | +约3% | +约8% |
| 替换第三、四下采样点 | +约2.0% | +约1.4% | +约3.5% | +约6% | +约13% |
| 全量替换所有下采样点 | +约2.3% | +约1.5% | +约3.8% | +约10% | +约18% |
表格里我用的是相对变化趋势,没有放绝对数值,因为不同数据集的底数差异太大,放绝对指标反而容易误导。但趋势是一致的:SPD-Conv对小目标召回提升最明显,整体mAP也有正向帮助,推理耗时增加的百分比小于显存增加的百分比,说明计算密度还有优化空间。
全量替换的收益相比只替换两个下采样点并不显著,但耗电和显存成本却高不少。这说明“改得越多越好”的直觉在这里不成立,小目标改进的关键在于替换关键环节,而非无脑全换。后续我又试了一版“替换两个下采样点+检测头前融合SPD分支”的组合,小目标AP还能再涨一点,但训练时间增加了近15%,感觉性价比一般,就没有纳入最终推荐方案。
从可视化上看,改进效果也很直观。把热力图叠加到原图上时,基线模型对小目标区域的高亮响应往往是一团模糊、位置偏移;换成SPD-Conv之后,热力图会更集中地贴到目标中心附近。这种变化在只有十几个像素的色块上尤其明显,也让我对指标的提升多了一分信任。
4.2 高频报错与训练异常排查速查表
训练过程中我确实遇到了不少问题,整理了下面这个速查表,基本覆盖了常见的排查路径:
| 异常现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前向传播报“shape mismatch” | PixelUnshuffle输入通道不是4的倍数 | 检查该层输入通道数,必要时先加1×1卷积对齐 |
| 显存OOM | SPD阶段通道扩展,activation memory增大 | 调低batch size,开梯度累积或AMP混合精度 |
| 训练震荡,loss不收敛 | 学习率过高或warmup步数不够 | 降低峰值学习率,延长warmup到5个epoch |
| 小目标mAP微涨但大目标mAP下降 | 替换层数过多,浅层大目标特征被过度压缩 | 回退为只替换第三、四采样点,或加入残差旁路 |
| 推理速度明显变慢 | 通道扩展后中间层计算量大 | 考虑在低分辨率层使用SPD,避免在高分辨率层滥用 |
| 换数据集后效果不升反降 | 数据集中小目标占比太低,SPD收益被修正覆盖 | 先统计目标尺寸分布,小目标占比低于10%时谨慎使用 |
前三个问题我在实验里都踩过,尤其是第一个,最容易出现在自定义主干上,代码本身没bug,但通道对齐问题让模型完全跑不起来。后三个问题则是在换数据集和调优过程中发现的。SPD-Conv并不是“银弹”,如果你的目标分布偏大,它的收益可能不明显,甚至因为下采样方式改变影响原有特征组织。做结构改进时,一定要结合自己任务的目标尺寸分布来判断,而不是照搬论文结论。
4.3 合理组合与个人经验总结
经过这轮改进,我个人认为SPD-Conv在YOLO系列中的最佳定位是“针对性补强模块”,不是“全局通用替代”。我的推荐组合是:把主干第三、第四下采样点换成SPDConv,其余保持不变;训练阶段用更低的学习率和更长的warmup来配合;如果还想再压榨小目标性能,在检测头前增加一个SPD分支做特征融合,但要接受训练时间的增加。这套组合在我的巡检数据集上表现稳定,也迁移到了另一个车辆检测场景。
关于代码维护和复现,我还想分享一个经验:做模型结构实验时,一定要把改动抽象成可配置项,而不是直接改主干代码。我在项目里给SPDConv加了一个开关,通过命令行传入“替换位置列表”来选择哪些下采样点启用SPD。这个习惯让我后续做消融实验特别方便,不需要反复改结构重跑代码。另外一个技巧是开启模型profiler,记录每层耗时和显存,很多看似“玄学”的性能下降,按层排查之后都会找到具体原因。
从我实际踩坑的经验来看,最想提醒大家的是:不要因为看到指标涨了就立刻全量上线。先检查小目标可视化结果,确认改进后的框是否更贴合真实物体。有一次我的指标确实涨了,但看图片发现模型把背景里的相似纹理误检成目标,SPD-Conv增强了纹理保留能力,连不该保留的噪声也一起保留了。后来我在数据层面加了更严格的正样本筛选和背景难例挖掘,这个问题才解决。这说明结构改进能拉高上限,但数据质量和后处理策略同样决定最终落地效果。
最后再分享一个小技巧:如果你手头有多张卡,可以同时跑baseline和SPD-Conv的训练,每天对比验证集loss曲线。两条曲线分离的时点很有价值——如果从一开始就有差距,说明SPD-Conv改变了主干的特征组织方式;如果前几十个epoch都在重合、后期才拉开,那说明它不是改变基础表征,而是在具体任务适配中发挥优势。了解这一点,能帮你判断这个模块在你的项目里到底扮演什么角色,也方便后续决定是否需要配合其他改进一起使用。