从一次深夜调试说起

上周在部署YOLO到边缘设备时遇到一个诡异现象:同一个模型,在COCO数据集上mAP表现不错,但换到我们自己的工业缺陷检测场景,定位框总是“飘”。明明分类置信度很高,框的位置却差几个像素。排查了数据标注、预处理、甚至怀疑是量化误差,最后问题锁定在检测头

打开YOLOv5的head代码,看到那个经典的耦合头输出——分类和回归共享同一组卷积特征。那一刻突然意识到:分类要的是语义抽象,回归要的是空间敏感,这俩任务用一个特征图硬扛,在复杂场景里容易互相“打架”。

解耦头:为什么要把分类和回归分开?

早期YOLO以及许多one-stage检测器采用耦合头,本质是为了速度。一个分支输出85维向量(4+1+80),简单粗暴。但YOLOv2、v3时代大家就发现,分类和回归的任务特性不同:

  • 分类任务:关心“是什么”,需要高层语义特征,对空间位置变化相对鲁棒。
  • 回归任务:关心“在哪里”,需要精确的空间坐标,对特征图的空间细节敏感。

让同一组卷积同时学习这两种特征,相当于让一个学生既学数学又学美术,还得用同一套思维方式——不是不行,但天花板有限。

动手改:从耦合头到解耦头

来看原始耦合头的典型实现(简化版):

class CoupledHead(nn.Module):
    def __init__(self, in_channels, num_classes):
        super().__init__()
        self.conv = nn.Conv2d(in_channels, (5 + num_classes) * 3, 1)  # 3个anchor
        # 输出维度: [batch, (4+1+80)*3, H, W]
    
    def forward(self, x):
        return self.conv(x)

改成解耦头后:

class DecoupledHead(nn.Module):
    def __init__(self, in_channels, num_classes, width=256):
        super().__init__()
        # 共享的中间特征提取
        self.mid_conv = nn.Sequential(
            nn.Conv2d(in_channels, width, 3, padding=1),
            nn.BatchNorm2d(width),
            nn.SiLU()
        )
        
        # 回归分支:输出xywh + objectness
        self.reg_convs = nn.Sequential(
            nn.Conv2d(width, width, 3, padding=1),
            nn.BatchNorm2d(width),
            nn.SiLU(),
            nn.Conv2d(width, 4 * 3, 1)  # 4*3个anchor
        )
        self.obj_conv = nn.Conv2d(width, 1 * 3, 1)  # objectness单独学
        
        # 分类分支:完全独立
        self.cls_convs = nn.Sequential(
            nn.Conv2d(width, width, 3, padding=1),
            nn.BatchNorm2d(width),
            nn.SiLU(),
            nn.Conv2d(width, num_classes * 3, 1)
        )
        
    def forward(self, x):
        x = self.mid_conv(x)
        reg = self.reg_convs(x)
        obj = self.obj_conv(x)
        cls = self.cls_convs(x)
        
        # 拼接成兼容格式 [batch, (4+1+80)*3, H, W]
        # 注意顺序要和原版对齐,这里踩过坑!
        return torch.cat([reg, obj, cls], dim=1)

几个关键点:

  1. 中间共享层别太深:一般1-2层足够,保持两个分支有一定关联,但别过度共享。
  2. objectness单独抽出来:很多论文把objectness和回归放一起,但我实验发现分开效果更好——objectness更像“有没有物体”,和“框多准”不是一回事。
  3. 宽度width要控制:嵌入式部署时,256已经很大了,可以降到128甚至64,精度损失不大。

训练技巧:别急着一起训练

直接替换头就开始端到端训练,可能收敛变慢。建议分两步:

# 第一阶段:冻结主干,只训练新头
for param in model.backbone.parameters():
    param.requires_grad = False

# 训几轮让头先学会基本能力
train(head_only=True)

# 第二阶段:整体微调
for param in model.parameters():
    param.requires_grad = True

# 这时候学习率要调小,别把刚学好的头冲坏了
train(full_model=True, lr=base_lr*0.1)

部署时的现实问题

解耦头增加了参数量和计算量,这是要还的债。在Jetson Nano上测试:

  • 耦合头:8.2ms
  • 解耦头(width=256):11.7ms
  • 解耦头(width=128):9.1ms

建议:如果应用场景中分类难度大(比如要区分30种相似缺陷),用解耦头;如果只是检测“人/车”这种大类别差异,耦合头够用了,省下的算力可以换更大主干。

个人经验

  1. 别迷信论文指标:有些论文解耦头涨点明显,那是因为他们在COCO这种80类复杂数据集上测。你的应用如果只有3-5类,可能涨不到1个点,却多了30%计算量。
  2. objectness分支值得多关注:很多误检不是分类错了,而是objectness乱跳。试试给这个分支加个SE注意力,有时候有奇效。
  3. 从中间特征图可视化:把分类和回归分支的feature map用Grad-CAM可视化出来,你会看到它们关注区域确实不同——分类看整体,回归看边缘。这个图虽然不能放文章里,但自己看看心里踏实。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐