news 2026/10/19 17:02:28

YOLO26改进:C3k2融合EIEM边缘增强模块,专治小缺陷漏检

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
YOLO26改进:C3k2融合EIEM边缘增强模块,专治小缺陷漏检

在工业视觉质检这个方向待久了,你会发现小缺陷漏检才是最磨人的问题。我之前在一个表面缺陷检测项目里,模型对大划痕、大污渍的召回已经做到九成五以上,但那种只有十几个像素的麻点、夹杂物,怎么调都差一口气。这一篇是 YOLO26 改进系列的第 33 篇,核心就一件事:把 C3k2 这个基础模块和一个我起名叫 EIEM(Edge Information Enhancement Module,边缘特征与空间信息增强模块)的组件融合在一起,同时补上高频边缘响应和空间位置编码,专门对付小缺陷。整套改法参数增量不到 4%,实测在某公开钢材表面缺陷数据集上 mAP@0.5 从 65.2 涨到 69.8,小缺陷 AP 从 21.4 涨到 28.6。如果你也在做工业质检、小目标检测,或者想给 YOLO 系列做低成本改进,这篇可以直接照着抄。

1. 为什么要给 C3k2 动刀:小缺陷检测的痛点分析

1.1 小缺陷检测的本质是高频信息检测

先算一笔账。输入分辨率 640×640,一个 16×16 像素的小缺陷,面积只占全图的 0.06%。网络主干通常要做 5 次下采样,到 stride 32 那层时,特征图只有 20×20,这个缺陷摊进去已经不足 1 个像素,基本等于消失。也就是说,深层特征图对于小缺陷的语义响应是非常弱的,如果主干没有在前面的层把关键信息保留下来,后面无论怎么加检测头都救不回来。

再看小缺陷的判别依据。小缺陷和背景的灰度差异往往不大,它真正区别于背景的,是边缘处那种灰度、纹理的突变,也就是高频分量。大目标的判别可以靠纹理、形状、语义,但小缺陷没有足够的像素去承载这些,边界突变几乎就是唯一能用的信息。你可以类比在一张白纸上找一根断发的划痕,人眼靠的就是那道阴影边界。换句话说,小缺陷检测本质上是高频信息的检测——谁能在特征提取过程中保住并放大高频边缘响应,谁就能少漏检。

标准的卷积网络其实是低通滤波器。卷积本质是对局部邻域的加权平均,下采样又会进一步抹平高频,所以缺陷一旦变小,信号会一路衰减。这不是某个模块的问题,是整个特征提取范式对小目标天然不友好,但恰恰是改进切入点:在网络大框架不变的前提下,显式地注入高频信息通道,相当于给网络额外配了一根“放大镜”。这也是我后来选择从基础模块动刀而不是直接换检测头的原因——检测头解决的是“从特征里找目标”,而小缺陷的问题出在“特征里根本没有目标”。

1.2 C3k2 的结构拆解与两个明显短板

C3k2 是 YOLO 系列近几代沿用下来的基础模块,本质上是 C2f 的变体。它先经过一个 1×1 卷积 cv1 把通道扩张成两路,一路直连,另一路经过若干个 C3k 小模块(C3k 里是 kernel size 可调的 Bottleneck,带残差),最后把两路拼起来,再用 1×1 卷积 cv2 压回输出通道。这种“一半直连、一半深加工”的设计,让梯度传递更顺,参数也相对省。YOLO26 基线里,骨干和颈部基本都由它堆叠而成,所以它的表达能力直接决定了整个模型的感知上限。

短板一是边缘响应没有显式建模。C3k 内部的 Bottleneck 是 1×1 降维、3×3(或 5×5)卷积、1×1 升维的组合,这个卷积做的还是邻域加权平均这一套,没有任何机制去强调梯度突变。BN 和 SiLU 对响应做了归一化,小缺陷那种本来就弱的边界信号经过归一化之后,经常被进一步压到噪声区。换句话说,C3k2 的输出对“哪里有边界”这件事并不敏感,这对小缺陷是致命伤。

短板二是空间位置信息缺失。卷积天然平移等变,同一个特征块换个位置,网络很难知道它本来的坐标。大目标无所谓,缺陷“在哪”不影响大局;但小缺陷不同,你需要在粗糙的特征图里尽量保留精确的位置痕迹,否则检测头回归框的时候就只能靠猜。SE 注意力那种全局压成标量的做法在这里帮不上忙,反而会把位置信息彻底洗掉。这两条短板合在一起,就形成了一个尴尬的局面:C3k2 在小缺陷面前既“看不见”(高频缺失),又“找不着”(位置模糊)。

2. EIEM 模块设计:边缘分支和空间分支怎么配合

EIEM 是整个改进的核心组件,拆开看其实就是两条并行的小分支加一个残差门控。边缘分支负责显式提取高频边缘响应,空间分支负责给特征注入行列位置编码,最后通过一个可学习的缩放系数把增强结果回填到原特征上。下面逐个讲设计理由。

2.1 边缘分支:用固定 Sobel 算子提取方向梯度

边缘分支的设计目标是显式地告诉网络:这里灰度突变。我选的做法是用 Sobel 算子——x 方向核和 y 方向核——分别初始化两个 depthwise 卷积。选择 Sobel 而不是拉普拉斯,是因为它有方向性,既能定位边界,又天然带一点方向编码,对后面融合步骤更友好。depthwise 卷积每个通道独立做梯度计算,参数是 3×3×通道数,非常省。

这里有一个关键取舍:我把 Sobel 权重固定住,不让它参与训练。原因很简单——边缘提取是一个稳定先验,如果权重可学习,训练初期会出现各种畸变模板,反而增加不确定性;固定之后,整个分支的输出完全可预期,后面接一个 1×1 融合卷积就足够表达“哪些通道的边缘该被放大”了。你可能会问,固定算子会不会限制表达上限?实践中我发现,只要后面的 1×1 卷积通道数给够,边缘响应可以被任意线性组合,固定 Sobel 带来的只是约束而不是损失。

import torch import torch.nn as nn class EdgeBranch(nn.Module): """边缘提取分支:固定 Sobel 算子提取 x/y 方向梯度""" def __init__(self, c, k=3, sobel_frozen=True): super().__init__() assert k == 3, "Sobel 初始化目前按 3x3 写,k=5 需要改初始化" self.gx = nn.Conv2d(c, c, k, 1, k // 2, groups=c, bias=False) self.gy = nn.Conv2d(c, c, k, 1, k // 2, groups=c, bias=False) sobel_x = torch.tensor([[-1., 0., 1.], [-2., 0., 2.], [-1., 0., 1.]]) sobel_y = torch.tensor([[-1., -2., -1.], [0., 0., 0.], [1., 2., 1.]]) with torch.no_grad(): for conv, kernel in zip((self.gx, self.gy), (sobel_x, sobel_y)): w = conv.weight.data # [c, 1, 3, 3] w.zero_() w[:, 0, :, :] = kernel.to(w.device) if sobel_frozen: self.gx.weight.requires_grad = False self.gy.weight.requires_grad = False self.fuse = nn.Sequential( nn.Conv2d(2 * c, c, 1, 1, bias=False), nn.BatchNorm2d(c), nn.SiLU(inplace=True), ) def forward(self, x): gx = self.gx(x) # 水平方向梯度 gy = self.gy(x) # 垂直方向梯度 return self.fuse(torch.cat([gx, gy], dim=1))

forward 里我把 gx、gy 的响应在通道维度拼起来,送进一个 1×1 卷积做融合。之所以拼方向而不是直接取幅值,是希望融合卷积自己去学两个方向的权重,而不是由我拍脑袋定。早期版本我也试过 abs 之后相加,工程表现差不多,但拼接版的表达能力更强,训练中也更稳定,所以保留了当前这个写法。

2.2 空间分支:用坐标注意力保留行列位置响应

空间分支要解决的是位置感的问题。SE 注意力把每个通道压成全局标量,这对通道关系很有用,但对位置完全无感。坐标注意力的思路是:沿着 H 方向做一次池化,得到 c×h×1 的向量,那里保留每一行的整体响应;沿着 W 方向再做一次池化,得到 c×1×w 的向量,保留每一列的整体响应。这样一来,特征图里每个位置都能参考自己所在的行列上下文,相当于把“坐标感”重新注入了特征。

对小缺陷来说,这一点比单纯全局注意力可靠得多。小缺陷的位置随机分布,而且它周围的上下文——比如金属表面纹理的方向、光照的渐变——本身就有规律,坐标池化能把这种列方向或行方向的统计特征抽出来,帮助网络判断某个位置是不是异常突变。

class SpatialBranch(nn.Module): """空间信息分支:坐标注意力风格,保留行列位置响应""" def __init__(self, c, reduction=4): super().__init__() mid = max(c // reduction, 8) self.mlp_h = nn.Sequential( nn.Conv2d(c, mid, 1, 1, bias=False), nn.BatchNorm2d(mid), nn.SiLU(inplace=True), ) self.mlp_w = nn.Sequential( nn.Conv2d(c, mid, 1, 1, bias=False), nn.BatchNorm2d(mid), nn.SiLU(inplace=True), ) self.att_h = nn.Conv2d(mid, c, 1, 1, bias=False) self.att_w = nn.Conv2d(mid, c, 1, 1, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): b, c, h, w = x.shape x_h = x.mean(dim=3, keepdim=True) # [b,c,h,1] x_w = x.mean(dim=2, keepdim=True).permute(0, 1, 3, 2) # [b,c,1,w] a_h = self.sigmoid(self.att_h(self.mlp_h(x_h))) # [b,c,h,1] a_w = self.sigmoid(self.att_w(self.mlp_w(x_w))) # [b,c,1,w] return a_h * a_w # [b,c,h,w]

实现上我没有照搬坐标注意力的共享卷积写法,而是用两个独立的轻量 MLP。因为 H 方向和 W 方向的语义不完全等价,分开学更直接,训练也更稳。池化方式用的是 mean,早期试过 max,在缺陷数据集上 mean 更平滑,对噪声不那么敏感。

2.3 融合策略:残差门控 + 可学习缩放系数

两个分支各自产出后怎么融回主干,我对比过两种方案。方案 A 是把 edge_feat、att_map 和原特征 concat,再用 1×1 卷积压回原通道。这个方案表达能力最强,但通道翻倍带来的参数和显存开销都不小,而且训练初期容易因为梯度路径变复杂而收敛变慢。方案 B 就是我现在用的残差门控:

EIEM 输出 = 原特征 + gamma × (edge_feat + att_map × 原特征)

其中 gamma 是一个可学习的通道级缩放参数,初始值设为 0.1。残差的妙处在于,这个模块在小权重状态下接近恒等映射,不会破坏训练初期主干的表示;随着训练推进,网络自己决定要把边缘和空间信息放大多少倍,比手工设定阈值靠谱得多。在 EIEM 内部,edge_feat 走的是加法注入,att_map 走的是乘法门控,两者分工明确:加法负责把缺失的高频成分补回来,乘法负责决定哪些位置的原始特征应该被强调——一个管“有没有”,一个管“信不信”。

3. C3k2 融合 EIEM 的代码实现与接法

3.1 三种插入位置,为什么我最终选了 cv2 之后

给 C3k2 融 EIEM,第一反应可能是把 EIEM 塞进每个 Bottleneck 里。我当时也确实试过三种位置。位置 A:放在 C3k2 输入之前,相当于给整个 block 先做一次增强。实测下来效果一般,因为此时特征还没经过深加工,增强完传到后面容易被新一轮卷积洗掉。位置 B:塞进每个 C3k 内部,理论上效果最好,可每个 Bottleneck 都要挂两个分支,参数翻好几倍,训练也明显变慢,最后提升被额外噪声抵消。位置 C:放在整个 C3k2 的 cv2 输出之后,只挂一个 EIEM,参数最少,而 cv2 之后的特征已经是当前 block 的最终产物,信息最成熟,增强结果可以被下一层直接利用。

综合提升和代价,我选了位置 C,也就是下面的实现。另外要注意,EIEM 我只加在输出 stride 8 和 stride 16 的层。stride 32 那层分辨率太低,小缺陷早变成亚像素,边缘分支在那边提取到的基本是混叠噪声,加了反而掉点。

3.2 EIEM 与 C3k2_EIEM 的完整代码

下面这份代码我做了去业务化处理,Conv、C3k 沿用你本地仓库的定义,重点看 EIEM 和融合逻辑。

class EIEM(nn.Module): """边缘特征与空间信息增强模块""" def __init__(self, c, k=3, reduction=4, init_lambda=0.1): super().__init__() self.edge_branch = EdgeBranch(c, k=k) self.spatial_branch = SpatialBranch(c, reduction=reduction) self.gamma = nn.Parameter(torch.full((1, c, 1, 1), init_lambda)) def forward(self, x): edge_feat = self.edge_branch(x) # 高频边缘增强 att_map = self.spatial_branch(x) # 空间门控 enhanced = edge_feat + att_map * x return x + self.gamma * enhanced # 残差门控回填 class C3k2_EIEM(nn.Module): """挂载了 EIEM 的 C3k2:保持原模块语义,在 cv2 之后做增强""" def __init__(self, c1, c2, n=1, shortcut=False, g=1, e=0.5, k=3): super().__init__() self.c = int(c2 * e) self.cv1 = Conv(c1, 2 * self.c, 1, 1) self.cv2 = Conv(2 * self.c, c2, 1) self.m = nn.Sequential(*(C3k(self.c, self.c, n, shortcut, g, e=1.0, k=k) for _ in range(max(1, n)))) self.eiem = EIEM(c2, k=k) def forward(self, x): y = list(self.cv1(x).chunk(2, 1)) y.extend(m(y[-1]) for m in self.m) out = self.cv2(torch.cat(y, 1)) return self.eiem(out)

这份代码里最需要注意的就是 EIEM 的 forward 分工。gamma 参数每个通道独立,训练中它会自适应地放大或收缩增强幅度,这也是整个模块唯一需要手工关注超参的地方。C3k2_EIEM 保持原模块的输入输出契约不变,所以替换时下游结构完全不用动。

3.3 YAML 配置与代价评估

模块写完,接网络就简单了。你只需要把 backbone 或 neck 里原来写 C3k2 的层替换成 C3k2_EIEM,然后保证注册表能 import 到新类。比如下面的示例配置:

# 以 backbone 为例,在 stride=8 和 stride=16 的位置使用 C3k2_EIEM backbone: - [-1, 1, Conv, [32, 3, 2]] - [-1, 1, Conv, [64, 3, 2]] - [-1, 3, C3k2_EIEM, [64, True, 1, 1, 3]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 3, C3k2_EIEM, [128, True, 1, 1, 3]]

实际仓库的解析规则不一样,参数位置按你当前 repo 里 C3k2 的写法照抄即可。改完配置,训练前先打印模型结构确认参数量变化,我这边实测的数据大概是这样的:

模型参数量 (M)GFLOPsmAP@0.5 (%)AP_small (%)
基线(C3k2)8.9010.065.221.4
+ EIEM(仅P3/P4)9.2110.369.828.6

可以看到全程只增加了约 0.31M 参数、0.3 GFLOPs,这个增量主要来自边缘分支的 1×1 融合卷积和空间分支的注意力卷积。比常见的 SE、CBAM 大一点,但换来的是小缺陷 AP 的明显提升。如果以后部署条件紧张,还可以考虑把 gamma 值训练完后很小的通道剪掉,这是我在资源优化时发现的一个后门。

4. 训练设置、消融实验与可视化分析

4.1 实验环境与训练超参

实验用的是某个公开的表面缺陷检测数据集,包含麻点、划痕、脏污、夹杂物四类,图像边长 512×512。标注框最小的只有 8×8 像素,最大 80×80,我按常规口径统计,小于 32×32 的目标约占总样本的 31%,正好拿来测小缺陷能力。基线是 YOLO26 默认结构,骨干和颈部全部用 C3k2。

训练超参我直接列出来:

超参值
输入分辨率640×640
优化器SGD(momentum=0.937, weight_decay=5e-4)
初始学习率0.01(cosine,warmup 3 epochs)
batch size16
epochs300
数据增强mosaic 0.8 + mixup 0.2,最后 20 epochs 关 mosaic
训练硬件一张普通专业卡,约 9 小时

这些超参没什么特殊的,关键是保证基线和改进模型在同一套配置下对比,不然任何结论都没意义。训练时我特意没动增强策略,因为 mosaic 对密集小缺陷有天然的正负样本比例影响,临时改增强会混淆变量。

4.2 消融实验:两个分支各自贡献了多少

先做消融。我把 EIEM 拆成边缘分支和空间分支,分别挂到 C3k2 后面,看各自能涨多少:

配置mAP@0.5 (%)mAP@0.5:0.95 (%)AP_small (%)参数量 (M)
基线 C3k265.233.121.48.90
+ 边缘分支67.334.525.19.08
+ 空间分支66.133.823.09.02
+ 边缘 + 空间(EIEM)69.836.928.69.21

数据很直观:边缘分支单独用就能把 AP_small 从 21.4 拉到 25.1,说明小缺陷的难点确实主要在高频信息上;空间分支单独用涨得少,但它和边缘分支结合后不是简单的加法,而是直接跳到了 28.6。我理解是空间门控起到“选择”作用——不是所有边缘都值得放大,空间分支告诉大家该看哪里,两个分支存在互补关系。

我也顺手对比了社区里常加的几个注意力模块,用同样的位置和训练配置跑了一轮:

附加模块mAP@0.5 (%)AP_small (%)参数量增量 (M)
SE65.822.3+0.06
CBAM66.423.0+0.12
CA66.723.6+0.07
EIEM(本文)69.828.6+0.31

SE、CBAM、CA 对小缺陷都有帮助,但都停留在 2 个点以内,原因是它们解决的是通道选择或局部空间选择,没有显式地补高频信息。EIEM 的思路从一开始就不一样——它不是在“选已有特征”,而是在“补缺失特征”,方向对了,提升幅度自然不同。

4.3 可视化:特征图和预测结果的反馈

光看指标不够,我做了两层可视化。第一层是特征图对比:取 P4 层某检测点,基线模型的特征图里,缺陷边缘的响应和背景纹理的响应混在一起,几乎分不出;加了 EIEM 后,缺陷边缘出现了一条清晰的高亮带,背景纹理被压缩。也就是说,边缘分支确实在特征层面重建了“边界”这个概念。

第二层是预测框分布。改进后召回明显回升的目标,基本都是 16×16 左右甚至更小的麻点、小夹杂物;剩下还漏检的样本,集中在缺陷和背景几乎完全同灰、肉眼看都必须仔细辨认的极端样本。这部分已经不是单纯靠模块能解决的,需要换成像元级分割那种思路。看到这个结果我心里反而踏实——模块解决的问题边界很清晰,不是靠玄学涨点。

5. 踩坑记录与调参经验

5.1 训练初期 loss 抖动:gamma 初始值很关键

第一次跑的时候我把 gamma 初始成 1.0,结果前 30 个 epoch 的 loss 曲线抖得很凶,mAP 迟迟起不来。原因不难理解:初始化状态下边缘分支输出的高频响应里,有一大半是我们不需要的背景细节,用等于 1 的权重直接回填,等于在训练初期给主干灌了一堆噪声。改成 0.1 之后训练立刻稳定,loss 曲线重新变得平滑。

训练结束后我统计了 gamma 的分布,大部分通道落在 0.3~0.6 区间,说明网络学到的增强幅度远没有初始的 1 那么大。这个经验后来在别的注意力模块上也有验证:全局缩放初始值宁可小,也不要大。模块收敛慢一点没关系,但一开始把主干扰动得太厉害,后面很难掰回来。

5.2 EIEM 不是每层都该加:位置与分辨率要匹配

我一开始图省事,把 backbone 里所有 C3k2 全都换成 C3k2_EIEM,结果 AP_small 反而掉了 0.8。排查后发现是 stride 32 那层的问题——输入 640×640 时特征图只有 20×20,小缺陷早就亚像素化,这个尺度下的“边缘”基本是混叠造成的假边缘,边缘分支越放大越乱。后来我只保留 P3 和 P4 位置(stride 8/16)的 EIEM,效果立刻回来。

空间分支也存在类似问题:当特征图的高或宽达到 1 时,坐标池化直接退化,等于白加一层。所以建议加模块之前先看清你目标尺寸和特征图分辨率的匹配关系,不是越多越好。这个教训在做任何注意力模块的时候都适用——先想清楚它要作用在哪个尺度上。

5.3 推理导出时的算子注意事项

最后提醒部署环节。EIEM 里有两个算子在生产环境容易踩坑。一是 groups=c 的 depthwise 卷积,在部分推理引擎里优化不充分,转换引擎前建议先用基准测试确认耗时;二是坐标池化里的 mean(dim=2/3) 加 permute,在导出固定维度模型时如果不做静态形状对齐,可能会报错。

我的处理办法是导出前把特征图尺寸固定成训练时一致的分辨率,然后把 EdgeBranch 的 depthwise 卷积和后面的 1×1 融合卷积预先合并成一个标准 3×3 卷积,耗时能再压掉一截。实测整体推理速度下降约 4%,在可接受范围。

最后说一点个人体会。这个模块的设计动机很单纯:既然小缺陷漏检的病根是高频信息丢失和位置感缺失,那就针对性地补这两个东西。它在有清晰边缘的小缺陷数据上效果明显,但如果缺陷本身边缘很模糊(比如低对比度的脏污、渐变划痕),边缘分支的增益会下降。我的下一步是把手动设计的 Sobel 换成可学习的边缘滤波器,甚至尝试可变形卷积去适应不规则的边界。这一篇先到这里,后续实验结果稳定了再更新。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 17:01:16

从内置到自定义:LangChain复杂任务编排的Custom Chain与Agent实战

把LangChain从“会用”推向“能真正处理复杂任务”,核心就在于自己动手写Custom Chain和Custom Agent。内置的链解决线性流程还行,一旦任务要求循环判断、多渠道取数、多工具协同、中途修改参数,搭积木式的内置方案就完全不够用了。这篇文章我…

作者头像 李华
网站建设 2026/10/19 16:58:18

开源代码审查CLI:Git集成+本地LLM+可审计提示工程

1. 项目概述:一个真正能落地的开源代码审查 CLI 工具到底长什么样?“open-code-review”这个名字乍看平平无奇,但拆开来看——open不是泛指“开源”,而是强调开放可审计、开放可定制、开放可集成;code review也不是简单…

作者头像 李华
网站建设 2026/10/19 16:56:14

STM32 HTTP远程升级实战:告别串口烧录,实现OTA固件更新

简介:这份资源面向嵌入式开发者与STM32学习者,聚焦通过HTTP协议实现设备固件的远程升级,解决传统现场烧录维护效率低的问题。压缩包共202个文件,约5.23MB,以C源码与头文件为核心,包含36个.c、38个.h&#x…

作者头像 李华
网站建设 2026/10/19 16:52:16

Linux进程控制实战:从优先级、信号到僵尸进程

上一篇我们把进程从“是什么”讲到了“怎么看”,ps、top、进程状态这些基本操作应该都摸过一遍了。但一旦你真去维护一台服务器,或者写脚本拉起一堆任务,就会发现进程真正麻烦的地方其实在于“控制”:为什么关掉终端,程…

作者头像 李华
网站建设 2026/10/19 16:51:06

代码评审最佳实践:用GitLab与SonarQube搭建开放式审查流程

其实“代码评审”这件事,我们团队踩过的坑比收获的经验还多。最早是轮流在会议室里过PPT,后来改成在IM上丢链接让大家自己看,结果经常是合并之后才发现评审意见还挂在聊天记录里。直到我们把整套流程搬进代码仓库、用工具链把“评审”变成一道…

作者头像 李华