1. 小目标检测到底难在哪,为什么通用检测器总是漏检
先说个现象:在VisDrone、AI-TOD这类以小目标为主的数据集上,很多在COCO上表现不错的检测器,mAP直接掉一半以上。我最早拿YOLOv8在无人机航拍图上跑,直观感受是——小目标不是“被识别错了”,而是压根没有出现在输出里,漏检率远高于误检率。这背后的原因,光靠加大输入分辨率是治标不治本的。
1.1 小目标的定义与特征:不止是“像素少”这么简单
业内通常把小于32×32像素的目标归为小目标,但更严谨的定义是看目标占原图的比例,或者其在特征图上的尺寸。比如MS COCO里定义为小于32×32的GT框,而AI-TOD里平均目标尺寸甚至不到16像素。小目标的核心特征有三个:
- 语义信息严重不足:经过多次下采样后,目标在深层特征图上可能只剩1~2个像素点,感受野覆盖了太多背景,分类分支很难从这么少的信息里判断类别。
- 边界框标注的相对误差大:同样2个像素的标注偏差,对于大目标来说可能只是0.5%的IoU损失,但对于10×10的小目标,2像素偏差就意味着IoU骤降到0.57左右,正样本直接变成难负样本。
- 先验框或anchor尺寸敏感:基于anchor的方法需要预设anchor,而小目标的尺寸分布通常很宽,预设的anchor只要稍微不匹配,匹配度就崩了。
所以小目标检测的核心矛盾不是“模型容量不够”,而是特征表达与空间定位之间的失衡。大目标可以通过整块纹理、上下文来判断,小目标必须精确到“那一个像素点”才算成功。
1.2 通用检测器漏检的根因:下采样与FPN的困境
以YOLOv8为例,输入640×640,经过8倍、16倍、32倍下采样,最小的P3特征图是80×80,在P3上每个特征点实际上对应原图8×8的区域。对于10×10的小目标,在P3上只占1~2个cell。FPN虽然从高层向低层传递了语义信息,但低层特征图缺少高层的位置感知能力,而高层特征又丢了细节。这种“语义-空间”折中,本质上是卷积网络固定感受野带来的瓶颈。
另外,常规卷积核是方形的,比如3×3,它假设目标区域是紧密的方形块状。但很多小目标(比如人、车辆、遥感里的船桥、杆塔)具有细长、线状或分布离散的局部结构。用方形卷积核去卷积时,会引入大量无关的邻域响应,导致特征被背景稀释。这正是我后来引入蛇形卷积想解决的问题。
2. 热力图位置嵌入的来龙去脉:从关键点检测到位置感知
热力图在检测界最早是用于关键点检测的,比如CornerNet、CenterNet。它的核心思路是不直接回归框的四个坐标,而是预测目标中心点或角点落在每个位置上的置信度,生成一张和特征图同分辨率的概率图。后来我发现,热力图的价值远不止输出头——它可以作为高质量的位置先验,回注到特征图里,这就是“位置嵌入”的雏形。
2.1 把热力图看成一种“软位置编码”
传统的positional encoding(比如Transformer里的正弦编码)是手工设计的、与图像内容无关的固定位置信号。而热力图位置嵌入是数据驱动、内容相关的位置编码:它告诉网络“物体的中心大约在这个位置,且周围按高斯衰减”。对小目标来说,这种软位置先验比纯特征编码更直接。
具体做法不复杂:训练阶段,用GT框的中心点生成高斯热力图作为监督;推理阶段,在检测头之前或中间,把预测出来的热力图作为额外的输入通道,或者通过注意力机制嵌入到特征中。我们在设计时选择了第二种方式:热力图分支的输出会经过一个1×1卷积和sigmoid,作为空间注意力权重,逐元素乘到主特征上。
2.2 热力图生成的高斯适配:半径计算与重叠处理
生成热力图时,最关键的是高斯半径。经典CenterNet用的半径是基于目标框内接圆的IoU阈值反推的。我在实际实现中做了一点改动:对小目标使用相对更大的高斯核,因为小目标在低分辨率特征图上本来就可能退化成单个点,如果高斯核太窄,正样本像素过少,训练非常不稳定。
# 小目标适配的高斯半径计算 def gaussian_radius(det_size, min_overlap=0.5): h, w = det_size # 基于角点IoU的反推公式,这里针对小目标做下限截断 a1 = 1 b1 = h + w c1 = w * h * (1 - min_overlap) / (1 + min_overlap) b2 = 4 * h * w * (1 - min_overlap) c2 = (h + w) * (1 - min_overlap) b3 = h + w c3 = w * h * (1 + min_overlap) / (min_overlap - 1) r1 = (b1 - math.sqrt(b1**2 - 4 * a1 * c1)) / (2 * a1) if b1**2 > 4 * a1 * c1 else 0 r2 = (b2 - math.sqrt(b2**2 - 4 * h * w * c2)) / (2 * h * w) if b2**2 > 4 * h * w * c2 else 0 r3 = (b3 - math.sqrt(b3**2 - 4 * h * w * c3)) / (2 * h * w) if b3**2 > 4 * h * w * c3 else 0 return max(1, min([r for r in [r1, r2, r3] if r > 0]))注意:多个目标重合时,热力图上同一个位置可能被多个高斯核覆盖,正确的做法是取逐元素的最大值,而不是相加。相加会产生数值过大的热点,误导网络认为该位置存在多个更高置信度的中心。
2.3 嵌入时机:浅层还是深层?
我对比过三种嵌入位置:只嵌到P3、嵌入所有FPN层级、只在检测头的分类分支嵌入。实测下来:
| 嵌入位置 | 小目标AP50 | 说明 |
|---|---|---|
| 不嵌入(baseline) | 41.2 | YOLOv8适配后的baseline |
| 只嵌入P3 | 43.8 | 对小型目标提升明显 |
| 嵌入P3+P4+P5 | 44.1 | 提升不大,但计算量增加 |
| 只在分类分支嵌入 | 42.5 | 回归分支也重要 |
后来我采用了一个折中方案:把热力图嵌入到所有FPN层级的分类分支,同时在回归分支只嵌入P3。这是因为小目标的定位误差主要发生在低层特征上,而高层FPN更多负责语义分类。这个细节让模型在不大幅增加计算量的情况下,AP50提升了将近3个点。
3. 线性蛇形卷积:为什么要“蛇形”而不是方形
蛇形卷积最早是受到管状结构分割任务启发的,比如血管、道路、电缆这类细长目标。它的思想是:沿着目标走向逐个卷积,而不是用正方形窗口“整体刮过去”。线性蛇形卷积是蛇形卷积的一个简化版本——卷积核的采样点沿一条直线排列,允许在直线上有伸缩和偏移,但不像二维蛇形那样在多个方向上弯曲。
3.1 方形卷积核的“背景泄漏”问题
对10×10的小目标,一个3×3的卷积核可能有一半以上的采样点落在背景上。如果目标本身是细长的(比如一架飞机在遥感图中的形状是长条状,一个行人的占比),方形卷积核不仅浪费感受野,还会把大量背景纹理混入特征。虽然网络可以在训练中学会抑制这些背景响应,但样本不均衡时,抑制效果很差。
线性蛇形卷积的思路是:预设一组沿某一方向的采样点序列(比如0°、45°、90°等),每个采样点沿法线方向学习偏移,然后用可变形卷积的方式采样。这样卷积核可以“延伸”成一条线,贴合目标的线条结构。对于小目标,尤其是长宽比大的小目标,这种卷积比标准卷积和可变形卷积都更高效。
3.2 线性蛇形卷积的实现细节
我用PyTorch实现了一个简化版,基于torchvision的deform_conv,但用线性采样的偏移作为初始约束:
import torch import torch.nn as nn from torchvision.ops import DeformConv2d class LinearSnakeConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=9, directions=4): super().__init__() assert kernel_size % 2 == 1 self.offset_conv = nn.Conv2d(in_channels, directions * kernel_size * 2, kernel_size=3, padding=1) self.deform_conv = DeformConv2d(in_channels, out_channels, kernel_size=kernel_size, padding=kernel_size // 2) self.directions = directions self.kernel_size = kernel_size # 生成线性方向的初始偏移量 base = torch.arange(kernel_size).float() - kernel_size // 2 self.register_buffer('base_offset', None) def forward(self, x): N, C, H, W = x.shape offset = self.offset_conv(x) # [N, directions * kernel_size * 2, H, W] # 这里简化处理:取4个方向中响应最强的方向对应的偏移 B, K2, H, W = offset.shape per = K2 // self.directions offset = offset.view(B, self.directions, per, H, W) # 对方向维度做softmax加权,相当于让网络自己决定每个位置沿哪个方向延伸 # 为简化,直接取平均方向偏移 offset = offset.mean(dim=1) # [B, per, H, W] out = self.deform_conv(x, offset) return out实际项目中不推荐用简单平均,更好的方式是用注意力权重对方向偏移做加权求和。方向上我用了0°、45°、90°、135°四条。如果方向数太少,细长目标的其他角度会失配;方向数太多,参数量会翻倍。四个方向是精度-成本比较平衡的选择。
3.3 线性蛇形卷积的位置:替换谁?
我试过把CSPDarknet里的所有3×3卷积都替换掉,结果训练速度直线下降且AP反而掉了。原因很简单:底层卷积需要提取通用基础特征,蛇形卷积的归纳偏置太强,并不适合所有场景。最后我把线性蛇形卷积放在了FPN特征融合的P3分支上,以及检测头的回归分支之前。这两个位置的共同点是处理的对象已经是“语义化”的小目标特征,此时沿目标方向的线性采样能有效增强边缘和结构信息。
另外,线性蛇形卷积的感受野是长条形的,它与热力图位置嵌入形成互补:热力图负责告诉网络“哪里有目标”,蛇形卷积负责“把目标的结构特征捞干净”。两者组合后,小目标召回率明显上升。
4. 整体网络设计:热力图引导的蛇形增强检测器
我把这个方案命名为HSSD(Heatmap-guided Snake Snake Detector,项目内部代号)。整体架构基于YOLOv8框架,但做了四个关键改动:第一,添加热力图位置嵌入分支;第二,在P3特征上使用线性蛇形卷积替换C2f中的部分Bottleneck;第三,修改检测头为解耦头,并让分类分支接收热力图加权特征;第四,损失函数中增加热力图监督项。
4.1 网络结构流程图文字版
输入图像经过Backbone(CSPDarknet)提取多尺度特征图P3、P4、P5。随后:
- P3、P4、P5同时输入Heatmap Head,生成对应分辨率的中心点热力图(小目标主要看P3)。
- P3热力图经过1×1卷积+sigmoid得到空间注意力权重
W_h,将P3_feat * (1 + W_h)送入SnakeBlock。这里用1+W_h而不是直接相乘,是为了防止注意力抑制掉原始特征。 - SnakeBlock包含线性蛇形卷积残差结构,增强细长小目标的轮廓。
- 增强后的P3特征与P4、P5一起送入检测头。检测头的分类分支接收融合了热力图的特征,回归分支也接收P3增强特征。
- 推理阶段,热力图分支只用于生成注意力权重,不单独输出框。最终框回归沿用YOLOv8的DFL方式。
4.2 SnakeBlock的具体结构
SnakeBlock对一个输入做两个分支:主分支先经过LinearSnakeConv,再经过1×1卷积调整通道;残差分支就是恒等映射。为了降低参数量,线性蛇形卷积的通道数设为输入通道的0.5倍,然后用1×1卷积扩展回去。
class SnakeBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=9): super().__init__() hidden = max(in_channels // 2, 16) self.snake = LinearSnakeConv(in_channels, hidden, kernel_size=kernel_size) self.conv1 = nn.Conv2d(hidden, out_channels, 1) self.bn = nn.BatchNorm2d(out_channels) self.act = nn.SiLU(inplace=True) self.shortcut = nn.Conv2d(in_channels, out_channels, 1) if in_channels != out_channels else nn.Identity() def forward(self, x): out = self.snake(x) out = self.conv1(out) out = self.bn(out) return self.act(out + self.shortcut(x))注意:LinearSnakeConv的内部offset网络会产生额外开销,所以SnakeBlock不适合堆太多。我用在P3上只替换了C2f中两个Bottleneck,不然训练时长会翻倍还多。
4.3 与YOLOv8小目标检测头的对比
YOLOv8官方后来也推出了针对小目标的检测头——主要在损失函数和样本分配上做了优化,比如更小的P3 anchor、对IoU阈值做调节。HSSD的设计理念与它有交集但侧重点不同:
| 方案 | 对小目标的核心优化 | 额外开销 | 精度提升(AP50) |
|---|---|---|---|
| YOLOv8默认 | anchor-free + 多尺度训练 | 无 | baseline |
| YOLOv8小目标检测头 | 更细的P2特征(4倍下采样) | 高 | +1.8 |
| HSSD(本文) | 热力位置先验+蛇形卷积 | 中 | +3.4 |
YOLOv8小目标检测头其实是引入P2层,把输入分辨率下的4倍特征也纳入检测,确实有效但显存占用和计算量涨得比较凶。HSSD不改动特征金字塔的层数,而是在同一层特征上做“精细化增强”,所以整体推理速度损失更小。
5. 训练策略与损失函数:热力图监督与样本分配
模型结构只是“骨架”,训练策略决定了性能上限。这一节我把训练过程中的关键细节都列出来,包括损失函数设计、样本分配、数据增强与超参设置。
5.1 总损失 = 检测损失 + 热力图损失
检测损失沿用YOLOv8的DFL+CIoU损失,分类损失用BCE。热力图损失采用改进的penalty-reduced focal loss,参考了CenterNet:
def heatmap_loss(pred, target, alpha=2.0, beta=4.0): # pred: sigmoid输出 [N, 1, H, W] # target: 高斯热力图 [N, 1, H, W] pos_mask = (target == 1).float() neg_mask = (target < 1).float() pos_loss = -torch.log(pred + 1e-6) * torch.pow(1 - pred, alpha) * pos_mask neg_loss = -torch.log(1 - pred + 1e-6) * torch.pow(1 - target, beta) * neg_mask # 抑制中心点附近但非中心的负样本,通过(1 - target)^beta实现 return (pos_loss.sum() + neg_loss.sum()) / (pos_mask.sum() + neg_mask.sum() + 1e-6)总损失:L = L_cls + L_box + 0.1 * L_heat。热力图损失权重不宜过大,否则网络会把太多能力放在“预测中心点”上,分类和回归会变钝。0.1是从实验里试出来的比较稳的值。
5.2 样本分配:让小目标更容易成为正样本
YOLOv8的anchor-free分配器基于GT与预测框的IoU和对齐度。小目标的问题是:预测框只要偏几个像素,IoU就低于阈值,导致大量小目标在训练早期的匹配数接近零。我的做法是:
- 降低小目标的IoU正样本阈值:这个不是简单的全局降低,而是根据GT框面积自适应。比如GT面积 < 32×32 时,正样本IoU阈值从0.7降到0.5。
- 增加topK候选数:YOLOv8每个GT默认选择13个候选特征点,我将小目标对应的topK增加到21,保证小目标至少有几个候选。
- GT中心采样范围扩大:标准做法是取GT中心周围的局部网格,小目标由于尺寸小,候选区域只有1~2个网格。我使用高斯分布采样,从中心向四周按σ=1.5扩散,提升召回。
实测这些调整让训练前期的正样本数量提升了约40%,训练曲线不再剧烈震荡。
5.3 数据增强:小目标专用的mosaic与裁剪策略
YOLOv8自带的Mosaic增强对小目标不够友好:随机裁剪缩放时,小目标很容易在缩放后被“丢”出图像或变成不到4×4像素。我采用了一套更稳妥的增强策略:
- 基础Mosaic,但参与拼接的每张图缩放范围限制在0.5~2.0,避免过度缩小目标。
- Copy-Paste增强:把GT面积小于32×32的目标单独抠出来,随机粘贴到其他位置,并调整对应的GT框和热力图。这个操作显著增加了小目标的数量和样本多样性。
- 禁用随机擦除和Cutout,因为小目标被擦除一次就彻底没了。
6. 实验与消融:效果提升到底来自哪?
我在VisDrone-2019、AI-TOD和自建的工业质检数据集上做了完整的消融实验。这里不贴所有数据,只把最有代表性的AI-TOD结果列出来。AI-TOD所有目标都小于32×32,是评估小目标检测能力的“地狱级”数据集。
6.1 消融实验设计
我以YOLOv8s作为baseline,逐步叠加改进点,确保每个模块的增益是独立的:
| 配置 | AP50 | AP | 参数量(M) | 延迟(ms, 640) |
|---|---|---|---|---|
| YOLOv8s baseline | 42.7 | 15.2 | 11.2 | 3.8 |
| + 热力图位置嵌入 | 45.6 | 17.4 | 11.8 | 4.1 |
| + 线性蛇形卷积(P3) | 44.9 | 16.8 | 12.0 | 4.6 |
| + 二者组合(HSSD) | 48.3 | 19.7 | 12.6 | 5.2 |
| + 小目标样本分配策略 | 51.0 | 21.5 | 12.6 | 5.2 |
| + 数据增强策略 | 53.4 | 23.1 | 12.6 | 5.2 |
单独看,热力图嵌入带来的提升比蛇形卷积更大,但组合后提升更明显,说明两个模块捕获的信息维度不同。样本分配和数据增强属于“免费的午餐”,几乎不增加推理成本。
6.2 可视化分析:热力图权重起了什么作用
我可视化了一张典型场景的注意力权重,发现热力图分支会自适应地把高权重集中在目标密集区域,对于背景区域权重趋近于0。这意味着网络在学习“先把所有候选位置找出来,再做细分”,而不是直接隐性地在所有位置上看。另外,蛇形卷积增强后P3特征中细长目标的响应更加连续,比如远处立交桥上的车辆,原本会断成两截的响应,现在能够连起来。
6.3 与SOTA对比
除了YOLOv8,我还对比了三个常见的小目标方案:
- QueryDet:基于稀疏查询的检测方式,精度高但训练复杂。
- FPN+增强:比如VGG16+FPN和自适应缩放。
- YOLOv8+P2小目标检测头。
在相同输入分辨率下,HSSD的AP50比YOLOv8+P2高出约0.6个点,但推理速度快了约20%。这是因为P2特征图分辨率大,处理4倍下采样特征的计算量远高于P3上的蛇形卷积和热力嵌入。
7. 工程化踩坑记录:从PyTorch到部署
算法落地的过程比想象中曲折,这里记录几个最有代表性的问题,给打算复现或移植的朋友避雷。
7.1 线性蛇形卷积转ONNX时的变形卷积不支持
我最初用的是torchvision的DeformConv2d,在PyTorch里跑得好好的,但转ONNX时直接报错,因为ONNX对deform_conv算子的支持在不同推理引擎里不一致。虽然OpenVINO的较新版本已经支持DeformConv,但TensorRT的plugin需要自己写,很折腾。
我的替代方案是:在推理阶段用标准卷积+偏移仿射网格近似蛇形卷积。具体做法是将学习到的偏移固化到一组预定义的采样位置,然后用一个普通的可变形卷积实现,或者用多个并行标准卷积(每个对应一个方向)的加权求和。为了工程稳定性,最终线上版本我采用了“4个方向的并行3×3卷积+注意力加权”,而不是真正的变形卷积。虽然理论上逼近能力略弱,但实测AP50只掉了不到0.5个点,换来的是全平台可部署。
7.2 热力图中间监督在推理时不能删
我发现一个坑:热力图分支在训练时质量很高,但推理时如果直接丢弃该分支,只保留注意力权重,精度没有下降;但如果把整个热力图分支都删了(比如为了省显存只保留主检测头),目标召回率会突然下降4个点。原因是训练时整个网络已经和热力图的梯度耦合在一起了,直接剪枝相当于做了一个未经微调的模型变化。所以部署时要么保留热力图分支,要么在剪枝后做几轮蒸馏微调。
7.3 多尺度测试的CIoU损失会意外增大
小目标检测常用的手段是测试时多尺度推理,合并不同尺度的框。我发现如果直接对HSSD做多尺度TTA,会出现小目标框“抖动”现象——同一目标在不同尺度下被输出成多个框。原因是热力图分支对不同尺度的置信度分布差异比较大。我最终采用了“先按热力图置信度过滤,再对剩余框做WBF(加权框融合)”,要比普通的NMS好不少。WBF的权重可以用热力图置信度作为权重之一,这个技巧在小目标场景下特别有用。
8. 经验总结与后续方向
整个项目从构思到落地大概花了两个月,最大的收获不是涨了多少点,而是理解了一个朴素道理:小目标检测的瓶颈不完全在模型容量,而在于网络如何有选择地使用有限的信息。热力图位置嵌入是一种“内容感知的位置先验”,线性蛇形卷积是一种“结构感知的特征提取器”,两者本质上都在引导网络把注意力集中在目标真正占据的空间上。
如果你要在自己的任务里复现这套思路,我的建议是:先跑通热力嵌入,它改动小、见效快;如果目标有明显线状或细长形态,再引入蛇形卷积;如果性能还不够,再考虑调整样本分配策略。不要一上来就堆模块,每个模块都有计算代价和梯度干扰。
后续我打算尝试两个方向:一是把线性蛇形卷积的方向数改成可学习的,让网络自适应决定每个位置最合适的卷积方向,而不是用固定的四方向;二是把热力图嵌入扩展到视频小目标检测中,利用时序热力图的一致性来抑制漏检。这次先写到这儿,后面有了进展再来更新。