☰
YOLOv5+SEAMHead:注意力机制提升小目标检测定位精度实战
2026/10/17 7:08:08 网站建设 项目流程

在野外蛙类调查中,最磨人的往往不是“认不出”,而是“定位不准”。一只褐斑蛙趴在一片枯叶上,脊背纹理和叶脉几乎融为一体;两只蛙在繁殖季挤在同一块石头上,边界框稍微偏一点就会把两只并成一只。去年我参与了一个生物多样性监测方向的图像识别任务,数据来自红外相机和普通手机拍摄的蛙类照片,目标很明确:图像中的每一只蛙都要给出类别和精确包围框,服务于后续的种群密度估算和个体行为统计。最初我用的是标准YOLOv5,效果能用但谈不上可靠,尤其是小尺寸蛙类和高密度重叠场景,经常出现漏检和边框偏移。后来在检测头部分加了一个名为SEAMHead的注意力模块——Squeeze-and-Excitation Attention Module与检测头的耦合改进——才把定位误差压到了业务可接受的范围。这篇文章不是理论推演,而是把从数据标注、模型改造、调参到部署的完整过程记录下来,希望能给做小目标识别或者需要精确定位的朋友提供一个可复用的路线。

1. 青蛙图像识别的难点拆解:为什么通用模型在这类场景会翻车

1.1 “找得到”和“定得准”是两回事:定位误差在生态统计里意味着什么

很多人觉得目标检测模型只要能出框、框里有目标就算成功,但蛙类识别这类生态监测任务对框的质量要求比普通场景高得多。种群密度估算通常靠的是“单位面积内个体数”,如果两个相邻个体的预测框发生偏移、互相侵入,程序在做计数去重时会把两个个体判定为一个,密度直接被低估。个体行为统计也是同理,追踪一只蛙在连续视频帧中的位置变化,需要中心点坐标在帧间保持稳定,哪怕只有几个像素的抖动,都可能让轨迹断掉,后续的移动距离和行为时间统计全部失真。

所以我在项目启动时就跟团队约定:不看单一的mAP,还要看平均中心点误差和框回归的稳定性。所谓平均中心点误差,就是把预测框中心与真实标注中心的欧氏距离,按所有正样本统计平均值。在640分辨率输入下,标准YOLOv5s的该项指标大约是6.7个像素,听起来不大,但很多青蛙个体在画面里只有二三十个像素高,6.7像素的偏移已经足够让框从头部滑到躯干上,导致后续的纹理比对和个体识别全部出错。

1.2 天然伪装与密集个体:三个具体翻车点

青蛙识别天然带着三个让通用检测模型头疼的属性。第一是仿生伪装。蛙类的背部颜色、纹理和它栖息环境高度一致,枯叶蛙的灰褐色脊背在卷积神经网络眼里跟枯叶背景几乎是同类特征,模型很容易把注意力分配错误。第二是小目标占比极高。野外拍摄时相机距离目标远,一只成体蛙在2048×1536原图中的尺寸经常不到64×64像素,下采样到640输入后只剩十几个像素,标准检测头对它几乎没有响应。第三是密集重叠,繁殖季的水塘边蛙群密集,个体之间互相遮挡,边界框的交并比极高,NMS阶段很容易把两个真值合并成一个预测。

这三个问题叠加在一起,恰好同时踩中了YOLOv5最容易暴露的短板:P3小目标分支的特征表达能力不足,以及检测头在输出层前没有针对任务做特征重标定。单纯把模型换成更大的版本,比如从YOLOv5s换到YOLOv5l,能缓解小目标漏检,但推理速度和显存占用成倍上升,在边缘设备上根本跑不动。这也是我最终决定“维持YOLOv5s骨架、只改检测头”的原因。

1.3 数据采集的现实约束:样本分布与标注质量的平衡

野外数据最大的问题是“没得选”。项目早期团队拿到的素材里,晴天正午的照片占了六成,阴天、晨昏、雨后反光等恶劣光照条件严重不足;蛙的种类也不均衡,最常见的泽蛙占了70%,而需要重点监测的珍稀物种只有几十张。如果直接拿这套数据训练,模型会对“正午+常见种”过拟合,遇到真实部署时的复杂光照马上就崩。

我的处理方式分两步。第一步是主动筛选和补采,尽量把光照条件分散开,并针对珍稀物种单独扩充样本,哪怕只有一两百张也单独建一个验证子集。第二步是在标注阶段严格统一规则,明确“框住可见躯干”“遮挡超过70%的不标”“两蛙紧贴时各自框取可见主体部分”。这批标注规则看似简单,却是后续模型定位精度能否提升的基础。因为注意力模块学到的特征分布完全由标签驱动,标签边缘一模糊,模型输出框自然也是跟着模糊的。

2. 从SENet到SEAMHead:注意力机制放在检测头到底改了什么

2.1 为什么单一通道注意力不够:SE头在检测场景的局限

SENet的核心思路很好理解:把每个特征通道压缩成一个全局描述符,再用一个小型全连接网络学习各通道的重要性权重,最后把权重乘回到原特征图上。这个操作对分类任务非常有效,因为分类只需要知道“这张图里是什么”,全局信息足够了。但检测任务要输出精确位置,回归分支需要的是“目标在哪一片空间区域”,光有全局通道权重远远不够。一只青蛙可能只占特征图的十分之一,SE会把全图统计信息混进通道权重里,背景纹理反而会把目标通道的响应稀释掉。

我实际测试过在YOLOv5骨干末端直接挂一个标准SE模块,mAP和定位误差几乎没变化,原因就在这里。通道注意力是“管结构”的,空间注意力才是“管位置”的,检测头恰恰需要后者来辅助回归分支确定目标边界。

2.2 YOLOv5检测头的问题:最后一层卷积之前缺少特征净化

YOLOv5标准检测头的实现很简洁,每个尺度的特征图经过几次C3结构加工后,直接用一个1×1卷积映射成输出张量,张量里同时包含边界框回归参数、目标置信度和分类得分。这个设计效率高,但存在一个隐患:所有任务共享同一份特征,而分类和回归对特征的要求并不一致。分类希望特征对“是不是蛙”敏感,回归希望特征对“边界在哪”敏感,如果直接让一个1×1卷积干两件事,网络只能折中,折中的结果就是框边缘模糊。

SEAMHead的思路是在1×1卷积前,对每个尺度的特征图先做一次“通道+空间”双重注意力重标定,让网络自己决定哪些通道、哪些位置应该主导后续的回归预测。空间注意力分支会把目标区域和背景区域区分开,输出一个类似热力图的权重,叠加之后回归分支就更容易聚焦在目标轮廓上。它不是增加新的任务头,只是给原有检测头加了一个前置净化器,属于改动极小、兼容性极好的插拔式设计。

2.3 参数量和计算开销的实测账:这笔投资值不值

做工程最怕“加了模块涨了几个点,模型却从轻量级变成跑不动”。SEAMHead的参数量我非常在意,所以动手之前先算了一笔账。YOLOv5s检测头三个尺度的通道数分别是128、256、512,SEAM的通道注意力部分用reduction=8降维,每个尺度增加约C×C/8×2个参数,也就是分别为4K、16K、64K,总计约84K;空间注意力部分只是一个7×7卷积,三个尺度加起来不到300个参数。折合下来SEAMHead总新增约85K参数,相比YOLOv5s的700多万参数,增量在1%左右。

实际训练完后的FLOPs变化也基本符合预期:640分辨率输入下,YOLOv5s原始推理在RTX 3060上单张约5.2ms,加上SEAMHead后约5.8ms,增幅不到12%。换来的是定位误差下降和mAP上升,这笔账我认为是划算的。当然如果你的部署设备算力极其紧张,也可以把空间注意力的7×7卷积换成3×3,速度损失更小,效果略降。

3. 数据集构建与标注规范:定位精度的起点是标签而不是模型

3.1 采集与筛选:把“废图”踢出训练集

数据筛选这一步容易被低估,但其实直接决定后续训练成败。我定的筛选标准很简单:图像模糊程度过高、目标占比过小且无法辨认种类、被遮挡超过70%的图,一律不进训练集。这些“废图”不是没用,而是应该单独归到一个hard例子里用于后续测试,用来观察模型在极端条件下的表现,而不是训练时把它们当噪音塞进去。花了一天时间筛完数据,训练集从原始的5000多张降到3900张,但训练收敛速度和最终精度反而更好了。

3.2 遮挡与边缘截断目标的标注规则:统一口径才有可比性

标注规则里最容易吵的是“这只蛙只露出一半,标还是不标”。我最后定的口径是:可见躯干部分完整且大于整体30%的,标注完整外接框,哪怕有部分在画面外;小于30%的只保留中心点,不作为正样本参与框回归。这样既不会浪费信息,又不会让回归分支被大量截断框带偏。紧贴的两只蛙则分别标注各自可见主体,允许两个框之间有部分交叠,后续交给NMS和注意力模块去区分。

3.3 针对性数据增强:模拟蛙类特有的光照与水面反光

通用增强里我保留了mosaic、随机仿射和HSV扰动,但额外加了两个针对蛙类场景的处理。第一个是随机水面反光模拟,因为野外大量照片是逆光水面拍摄,高光条纹盖在蛙身上,我用随机叠加的白色半透明多边形来模拟水面高光,增强模型对局部高光干扰的鲁棒性。第二个是低频模糊增强,用于模拟雨天或镜头水汽导致的图像模糊,这对定位同样关键——模型见过模糊输入后,在推理遇到轻微失焦时框的抖动明显减少。这两个增强都只应用在训练集,验证集保持原始图像。

4. YOLOv5-SEAMHead的PyTorch实现与融合改造

4.1 SEAMHead核心模块代码:通道注意力与空间注意力的组合

直接贴可用的核心代码,这是我在实际项目中经过简化并与YOLOv5s的通道数匹配过的版本:

import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, channels, reduction=8): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) hidden = max(channels // reduction, 8) self.mlp = nn.Sequential( nn.Conv2d(channels, hidden, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(hidden, channels, 1, bias=False)) def forward(self, x): avg_out = self.mlp(self.avg_pool(x)) max_out = self.mlp(self.max_pool(x)) return torch.sigmoid(avg_out + max_out) class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super().__init__() self.conv = nn.Conv2d(2, 1, kernel_size, padding=kernel_size // 2, bias=False) def forward(self, x): avg_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) out = torch.cat([avg_out, max_out], dim=1) return torch.sigmoid(self.conv(out)) class SEAM(nn.Module): def __init__(self, channels, reduction=8): super().__init__() self.channel = ChannelAttention(channels, reduction) self.spatial = SpatialAttention(kernel_size=7) def forward(self, x): x = x * self.channel(x) x = x * self.spatial(x) return x

通道注意力部分用平均池化和最大池化并行得到两个全局描述,再各自过共享MLP后相加,最后接sigmoid。空间注意力部分在通道维度上做平均和最大池化,拼成两通道特征图,再用7×7卷积融合出位置权重。整体结构参考了CBAM的思路,但重点是放在检测头每个尺度之前,而不是放在整个骨干网络末端。

4.2 与YOLOv5检测层的融合:改动集中在Detect类内部

最省事的接入方式是把SEAMHead嵌入YOLOv5原本的Detect类里,改动量只有两处。第一处在Detect.__init__中,根据检测头的各层通道数创建SEAM模块列表:

self.seam = nn.ModuleList(SEAM(c, reduction=8) for c in ch)

第二处在forward里,对每个尺度的输入特征先过SEAM再做预测:

for i in range(self.nl): x[i] = self.seam[i](x[i]) x[i] = self.m[i](x[i]) # 原始的1x1预测卷积

这样改的好处是不动骨干、不动Neck、不动损失函数,YOLOv5的训练流程、断点续训、数据加载全部原样复用。官方训练脚本里最复杂的部分其实是anchor和标签匹配,我没有碰,因为SEAM只改变特征表示,不改变标签分配逻辑。实践中接入后第一轮epoch就能正常跑通,说明改动对训练流程是透明的。

4.3 训练配置与超参数选择:比默认值保守一些

因为检测头是被改过的,我不建议直接用默认的lr0=0.01从头炸一遍,第一次跑我就翻过车。最终成型的训练配置如下:

python train.py \ --data frog.yaml \ --cfg yolov5s.yaml \ --weights yolov5s.pt \ --epochs 160 \ --batch-size 16 \ --imgsz 640 \ --cos-lr \ --freeze 10

frog.yaml里最关键的是只有一个类别,nc: 1,names: ['frog']。学习率我降到lr0=0.003,配合cosine下降。--freeze 10的意思是最开始10个epoch冻结骨干层,只让Neck和检测头里的SEAM模块学习,防止预训练权重刚加载就被新加的随机初始化模块带动产生剧烈震荡。10个epoch之后再解冻全模型精调,这时候SEAM已经对任务特征有了初步响应,骨干微调起来稳定很多。

5. 训练过程实录:收敛曲线、指标解读与三个失败案例

5.1 从预训练权重开始的分阶段训练策略:为什么不是直接全量训练

加载YOLOv5s.pt预训练权重时,YOLOv5会自动过滤掉和nc=1不匹配的检测头卷积权重,所以检测头的1×1卷积和SEAM模块都是随机初始化的,只有骨干和Neck继承预训练参数。如果开机就全量训练,随机初始化的检测头会输出大量无意义梯度,反向传播把骨干特征也搞乱,前几轮loss会飙得很高。我的做法分两阶段:先冻结骨干10个epoch,让Neck和检测头先“站稳”,然后再解冻。实际观察里,解冻时的loss值大约在0.09左右,解冻后会在两三个epoch内短暂上升0.02,随后继续下降,这是正常的。

5.2 指标要看两层:mAP与定位误差的联合评估

训练到第120个epoch后基本收敛,我把最终结果和原始YOLOv5s做了对比,评估集是500张未参与训练的野外图像,输入尺寸640。数据如下:

模型mAP@0.5mAP@0.5:0.95小目标AP@0.5平均中心点误差(px)推理耗时(ms, FP16)
YOLOv5s原始0.8840.5970.7686.75.2
YOLOv5-SEAMHead0.9130.6490.8354.95.8

最让我意外的是小目标AP的提升幅度,从0.768涨到0.835,涨了6.7个百分点。说明SEAM的空间注意力对P3小目标分支的增益最明显,这也符合直觉:小目标在特征图上响应弱,空间注意力把“这种位置值得关注”的权重放大后,回归分支更容易在低响应特征里锁定边界。中心点误差从6.7降到4.9,意味着个体轨迹跟踪的断线率明显降低。

5.3 三个失败案例与针对性调整:调参不是玄学

第一个失败案例是初次训练时用了默认的lr0=0.01,结果前20个epoch的loss不断震荡,mAP卡在0.7上不去。排查后确认是检测头随机初始化导致的梯度爆炸,降到0.003后问题消失。第二个失败案例是把--freeze设成30个epoch,想多冻一会,结果检测头在冻结期把有限样本过拟合了,解冻后mAP反而下跌。最后调整为10个epoch才稳定。第三个失败案例出在autoanchor上,数据里大量细长蛙体导致k-means聚类出的anchor宽高比极端,训练早期框回归不稳定,我选择在训练前先跑一次python train.py --noautoanchor看一眼聚类结果,再决定是否强制使用默认anchor,排掉了这个不稳定因素。

6. 模型导出与边缘部署:保精度还是保速度的一次取舍

6.1 ONNX导出与精度一致性检查

训练完成后要落地到红外相机边缘设备,第一步是把PyTorch权重导出为ONNX。YOLOv5官方的export.py可以直接用,我推荐加上--simplify做一次图优化,随后转到TensorRT的FP16引擎。这里有个容易踩的坑:导出后必须跑一个“精度一致性检查”,拿同一组验证集图片分别跑PyTorch原模型和ONNX/TensorRT推理,对比输出框的坐标和置信度。FP16量化之后小目标分支偶尔会损失一点响应值,出现置信度略降、个别小蛙漏检的情况。我的处理是加一个小的调优策略:Tensort推理时保留FP16但关闭图层次的小算子融合,或者把输入分辨率从640提高到768,用小输入反而省下的延迟去换更多有效像素。

6.2 推理加速配置与实测性能

最终部署环境是一台低功耗工控机加一个摄像端处理盒,TensorRT FP16引擎在640分辨率下达到单张4.6ms的推理耗时,加上NMS后处理,整体端到端约9ms,对于实时视频流每帧30fps来说余量充足。显存占用控制在1.2GB左右,工控机也能带得动。SEAMHead增加的85K参数对显存和延迟的影响都很小,实测没有碰到因为添加模块导致帧率下降的问题。

需要提醒的是,NMS后处理尽量不要用TensorRT插件版里的默认阈值,蛙类密集场景里IOU阈值设0.45会合并太多相邻个体,我最后把NMS的IOU阈值调到0.38,定位精度虽然只变化零点几个点,但密集场景漏计明显减少。

6.3 后续还可以扩展的方向:目标检测只是第一步

这套检测模型稳定后,我又在它上面接了一个轻量级的个体追踪模块,用检测框的中心点和尺寸作为追踪特征,在没有身份标注的情况下实现了初步的跨帧个体轨迹关联。后续要做的事是收集更多带个体身份的数据,训练一个细粒度识别分支,把“检测定位”升级成“检测+个体识别”,这样种群统计就能从数量统计推进到个体行为规律分析。SEAMHead作为基础检测器的定位精度提升,正好给这些上游任务打下了相对干净的地基。

最后再分享一个小经验:这类注意力改进模块,先不要急着在完整数据集上烧机器,拿300张样本跑10个epoch看趋势,如果定位误差没有下降迹象,多半是注意力放错了位置,赶紧调整结构比盲目加大训练时长有效得多。我自己就是先在小实验上验证SEAM放检测头有效,才推全量训练的,省下不少没必要的算力和时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询