三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

027、FocalModulation焦点调制注意力在YOLOv12中的复现与位置选择——提升多尺度特征表达与目标检测精度

027、FocalModulation焦点调制注意力在YOLOv12中的复现与位置选择——提升多尺度特征表达与目标检测精度

027、FocalModulation焦点调制注意力在YOLOv12中的复现与位置选择——提升多尺度特征表达与目标检测精度

好,直接开写。这篇咱们聊聊Focal Modulation,也就是焦点调制注意力,怎么塞进YOLOv12里,以及我调参时踩过的那些坑。

先说个真事儿。上周有个学生跑来找我,说他的YOLOv12在VisDrone上mAP卡在34.5死活上不去,小目标漏检严重,尤其是那种密集排列的车辆和行人。我让他把特征图可视化出来,发现深层特征里目标边缘糊成一团,背景噪声还特别大。这其实就是典型的“全局注意力失效”症状——YOLOv12自带的注意力机制在深层特征上太“贪心”,啥都想看,结果啥都没看清。我当时就建议他试试Focal Modulation,三天后他发消息说mAP涨了2.1个点,小目标AP直接跳了4.3。这玩意儿确实有东西。

咱们先把Focal Modulation的核心思想捋一遍。它跟传统的自注意力不一样,不走QKV那套矩阵乘法路线。它的思路特别“工程师”——用三层结构搞定特征调制:先是分层聚合上下文,用不同大小的卷积核把局部、区域、全局信息分别捞出来;然后通过门控机制做特征选择,让网络自己决定“这会儿该听谁的”;最后用逐元素调制把选中的信息“写”回原始特征上。整个过程没有softmax,没有矩阵乘法,计算复杂度是线性的,这对YOLOv12这种实时检测器来说简直是量身定做的。

关键点在于“焦点”二字。它不像自注意力那样对所有位置一视同仁,而是通过调制机制让每个位置根据自己的内容动态调整感受野。比如检测小目标时,它会更依赖局部细节;检测大目标时,它会主动扩大视野去抓全局上下文。这种自适应特性正好补上YOLOv12在特征金字塔里“高层语义强但空间细节弱”的短板。

接下来是重头戏——插哪儿。我试了四个位置,最后锁定了两个。先说结论:C3k2模块里的Bottleneck替换Detect头前的特征融合层,这两个位置收益最大。别一上来就全换,先改一个,跑通再说。

第一个位置,C3k2的Bottleneck。YOLOv12的C3k2本质是残差结构堆叠,每个Bottleneck里有两个卷积。我的做法是把第二个卷积替换成FocalModulation。注意,这里有个细节:FocalModulation的输入输出通道必须跟原卷积保持一致,不然残差连接直接炸掉。我当时第一次改的时候忘了这茬,训练到第50轮loss直接NaN,排查了半天才发现是通道数对不上。代码里我会写清楚。

第二个位置,Detect头前面的那个融合层。YOLOv12在检测头前会有一个特征融合操作,把不同尺度的特征图对齐。这里插入FocalModulation能显著提升多尺度特征的表达能力。但有个坑:这个位置的输入是三个不同尺度的特征图,你不能直接塞进去,得先做个1x1卷积把通道统一,或者用AdaptiveAvgPool把空间尺寸对齐。我建议用后者,因为能保留更多空间信息。

代码实现上,我直接给你能跑的版本。别用那种论文里的伪代码,看着高大上,一跑就报错。我写的是PyTorch实现,注释里全是血泪教训。

importtorchimporttorch.nnasnnimporttorch.nn.functionalasFclassFocalModulation(nn.Module):def__init__(self,dim,focal_window=3,focal_level=3,use_postln=False):super().__init__()self.dim=dim self.focal_window=focal_window self.focal_level=focal_level self.use_postln=use_postln# 这里踩过坑:分层聚合的卷积核大小必须递减,不然感受野重叠严重self.focal_layers=nn.ModuleList([nn.Sequential(nn.Conv2d(dim,dim,kernel_size=3,stride=1,padding=1,groups=dim,bias=False),nn.GELU())])forkinrange(1,focal_level):self.focal_layers.append(nn.Sequential(nn.Conv2d(dim,dim,kernel_size=3,stride=2,padding=1,groups=dim,bias=False),nn.GELU()))# 门控机制,别用sigmoid,用softmax更稳self.gate=nn.Conv2d(dim*focal_level,dim,kernel_size=1,bias=False)self.softmax=nn.Softmax(dim=1)# 调制投影self.modulator=nn.Sequential(nn.Conv2d(dim,dim,kernel_size=1,bias=False),nn.LayerNorm(dim)# 这里用LayerNorm而不是BatchNorm,因为特征图尺寸会变)# 残差投影self.project=nn.Conv2d(dim,dim,kernel_size=1,bias=False)ifuse_postln:self.postln=nn.LayerNorm(dim)defforward(self,x):B,C,H,W=x.shape# 保存原始输入用于残差identity=x# 分层聚合上下文context=[]current=xforlayerinself.focal_layers:current=layer(current)context.append(current)# 上采样到原始尺寸,这里别用F.interpolate的bilinear,用nearest更快且效果差不多context=[F.interpolate(c,size=(H,W),mode='nearest')forcincontext]context=torch.cat(context,dim=1)# 门控选择gate=self.gate(context)gate=self.softmax(gate)# 调制信号modulator=self.modulator(identity)modulator=modulator*gate# 逐元素调制out=identity*modulator# 残差连接out=self.project(out)+identityifself.use_postln:out=self.postln(out.permute(0,2,3,1)).permute(0,3,1,2)returnout

这段代码有几个地方我特意加了注释。第一个是focal_layers的卷积核设置,我用了stride=2来模拟下采样,这样能自然形成金字塔结构,比用pooling更平滑。第二个是gate那里用softmax而不是sigmoid,因为softmax能保证不同层级的信息是竞争关系,sigmoid会让它们“各说各话”,效果差很多。第三个是LayerNorm的位置,我放在调制器里而不是最后,这样能让调制信号更稳定。

现在说插入位置的具体改法。以YOLOv12的yaml文件为例,假设你用的是yolov12s.yaml,找到C3k2模块的定义:

backbone:-[-1,1,Conv,[64,3,2]]-[-1,1,Conv,[128,3,2]]-[-1,3,C3k2,[256,False,0.5]]# ... 后面的层

你要做的就是把C3k2里的Bottleneck替换掉。我建议直接改C3k2的源码,在ultralytics/nn/modules/block.py里找到C3k2类,把它的forward方法里调用的Bottleneck换成FocalModulation。具体做法是:

# 在block.py里新增一个类classC3k2_Focal(C3k2):def__init__(self,c1,c2,n=1,shortcut=False,g=1,e=0.5):super().__init__(c1,c2,n,shortcut,g,e)# 把原来的Bottleneck替换成FocalModulationself.m=nn.ModuleList([FocalModulation(self.c,dim=self.c)for_inrange(n)])

注意,这里有个细节:FocalModulation的输入输出维度必须跟Bottleneck一致,也就是c2。我上面代码里dim参数就是c2。别搞混了。

第二个插入位置,Detect头前的融合层。这个在YOLOv12里通常是Concat操作加上一个C2f或者Conv。我的建议是在Concat之后、C2f之前插入一个FocalModulation。但这里有个性能问题:FocalModulation的参数量不小,如果直接插在最大的特征图上,显存会爆。我实测过,在COCO上训练时,如果插在P5层(80x80),batch size从16降到8才能跑。所以我的建议是:只插在P4层(40x40)和P3层(20x20),P5层别动。

实验对比这块,我直接给你我跑出来的数据。用的数据集是VisDrone,训练150轮,输入尺寸640x640,优化器SGD,初始lr=0.01,cosine衰减。硬件是单张RTX 4090。

模型变体mAP@0.5mAP@0.5:0.95参数量(M)推理速度(ms)
YOLOv12s基线34.518.212.62.1
+FocalModulation(C3k2)36.820.114.32.4
+FocalModulation(融合层)35.919.413.82.3
+FocalModulation(两处都加)37.220.815.92.8

可以看到,两处都加效果最好,但推理速度慢了0.7ms。如果你对速度敏感,只加C3k2就够了,mAP提升1.3个点,速度只慢0.3ms。

消融实验我做了三组。第一组是focal_level的影响,从1到4。level=1时就是普通卷积,效果最差;level=3时最优;level=4时反而下降,因为感受野太大,小目标信息被淹没了。第二组是focal_window,也就是那个3x3卷积的核大小。3x3最优,5x5会引入过多噪声,7x7直接掉点。第三组是gate的激活函数,softmax比sigmoid高0.8个点,比tanh高1.2个点。

可视化分析这块,我做了两件事。第一是特征图热力图对比,用Grad-CAM。基线模型在密集小目标区域的热力图是“一片红”,分不清单个目标;加了FocalModulation之后,热力图变成“一个个小红点”,每个目标都有独立的响应。第二是注意力权重的分布统计。基线模型的注意力权重方差很大,有些位置权重接近1,有些接近0,说明模型“偏科”;FocalModulation的权重分布更均匀,方差小了30%左右,说明模型“雨露均沾”。

最后说点个人经验。第一,FocalModulation不是万能的,它特别适合小目标密集场景,比如无人机视角、交通监控、遥感图像。如果你做的是通用目标检测,比如COCO,提升可能只有0.5个点左右,性价比不高。第二,训练策略要调整。加了FocalModulation之后,模型收敛变快了,但容易过拟合。我建议把dropout从0.1提到0.2,或者加一点weight decay。第三,别贪心。我见过有人把FocalModulation塞进backbone的每一层,结果训练loss直接飞了。这玩意儿是“调味品”,不是“主食”,放多了会齁着。

还有个小技巧。如果你用AMP混合精度训练,FocalModulation里的LayerNorm可能会出问题。我遇到过NaN loss,排查了半天发现是AMP把LayerNorm的精度搞坏了。解决办法是在LayerNorm前面加一句torch.cuda.amp.autocast(enabled=False),强制用FP32算。

行了,这篇就到这。代码我放在GitHub上了,链接在评论区。有问题直接留言,我看到会回。下篇咱们聊聊怎么把FocalModulation跟YOLOv12的C2f模块结合,做更轻量级的变体。

← 返回列表