资讯详情

资讯详情

Ultralytics卷积模块全解析:从Conv到CBAM的代码与设计

做目标检测的朋友应该都有过打开 ultralytics 源码找模块的经历。无论你是想改 backbone 的通道数还是想在 neck 里插一个注意力机制第一个要找的文件大概率就是nn/modules/conv.py。这个文件命名朴素却是整个模型构建的骨架YOLO 系列 yaml 配置里的 Conv、DWConv、Focus、CBAM、Concat这些字符串最终都会被解析成 conv.py 里的具体类。这篇文章我把这个文件从头到尾过一遍逐个类讲清楚顺便把代码背后的设计取舍和我在实际改模型中踩过的坑一起说透。不管你是刚接触目标检测的新手还是想自定义模块的老人应该都能找到有用的东西。1. 全景速览conv.py 里到底装了多少种“卷积”1.1 模块家族一览先通览一下文件的全貌。以当前主流的 ultralytics 版本为准conv.py 中主要包含这些类类名作用关键参数Conv标准卷积块卷积BN激活c1, c2, k, s, p, g, d, actLightConv轻量卷积1x1 卷积 深度卷积c1, c2, k, s, actDWConv深度可分离卷积c1, c2, k, s, d, actDWConvTranspose2d深度可分离的转置卷积c1, c2, k, sConvTranspose转置卷积块c1, c2, k, s, p, bn, actFocus切片式降采样模块c1, c2, k, sChannelAttention通道注意力channelsSpatialAttention空间注意力kernel_sizeCBAM通道空间混合注意力c1, kernel_sizeConcat特征拼接dimensionGhostConv幻影卷积c1, c2, k, s, g除了这些类文件里还定义了autopad填充计算函数和fuse_conv_and_bn融合函数。也就是说这个文件不光是“搭建网络”用的也为推理阶段“优化网络”提供了底层工具。这些类之间的关系其实很有层次。Conv 是根DWConv 继承 Conv 并改写分组数ConvTranspose 是转置卷积版本LightConv 组合了 Conv 和 DWConvFocus 内部是一个 Conv 处理拼接后的特征GhostConv 是两个 Conv 的组合变体CBAM 则组合了 ChannelAttention 和 SpatialAttention。理解这种继承与组合关系比死记每个构造参数更有价值。自定义新模块时大多数情况下都是拿这些现成小块拼出新结构而不是从零写一个 nn.Module。1.2 为什么这些模块能直接在 yaml 里引用YOLO 系列的模型结构是通过 yaml 文件描述的。比如 backbone 里经常出现这一行- [-1, 1, Conv, [64, 3, 2]]它的含义是取上一层输出作为输入使用 1 个 Conv 模块参数列表[64, 3, 2]对应输出通道 64、卷积核大小 3、步长 2。ultralytics 在解析 yaml 时会把字符串 Conv 映射到这个文件中的 Conv 类这个过程由解析器调用工厂方法完成。所以设计上的硬性要求是每个模块类必须有规范的构造函数和 forward 实现参数顺序也要能对上。这也是为什么你在看这些类时会发现它们的__init__参数顺序几乎和 yaml 里参数列表严格一致。这种“配置文件驱动 模块文件分类”的组织方式工程上很实用。Conv 在 backbone、neck、head 里到处出现Focus 只在输入层出现CBAM 是可选插件拆成独立文件后yaml 解析器可以统一处理也方便做单元测试。最直观的好处是你想给模型加注意力增强不需要在主干网络基础上改代码只要在 yaml 里插入一行引用 CBAM 的模块配置即可。2. 核心基石Conv 类逐行精读2.1 构造函数参数与 autopad 的完整逻辑Conv 是 conv.py 里最核心的类也是被实例化次数最多的模块。经典实现如下class Conv(nn.Module): default_act nn.SiLU() def __init__(self, c1, c2, k1, s1, pNone, g1, d1, actTrue): super().__init__() self.conv nn.Conv2d(c1, c2, k, s, autopad(k, p, d), groupsg, dilationd, biasFalse) self.bn nn.BatchNorm2d(c2) self.act self.default_act if act is True else act if isinstance(act, nn.Module) else nn.Identity() def forward(self, x): return self.act(self.bn(self.conv(x))) def forward_fuse(self, x): return self.act(self.conv(x))先看参数c1 和 c2 是输入输出通道数k 是卷积核尺寸s 是步长p 是 padding。注意 p 的默认值是 None不是 0。这是因为真正的 padding 需要根据卷积核尺寸和是否使用空洞卷积来计算pNone 时交给 autopad 自动计算。g 是分组数d 是空洞率。autopad 的实现看上去很短但很值得嚼一嚼def autopad(k, pNone, d1): # 计算等效卷积核大小 if d 1: k d * (k - 1) 1 if isinstance(k, int) else [d * (x - 1) 1 for x in k] # 自动填充 if p is None: p k // 2 if isinstance(k, int) else [x // 2 for x in k] return p当 d1 时逻辑很简单p k // 2。k3 时 p1k1 时 p0k5 时 p2这样在 stride1 时能保持输出尺寸与输入一致。当 d1 时情况就不同了。空洞卷积的等效卷积核大小是d*(k-1)1比如 k3、d2 时等效核是 5所以 padding 要取 2。如果不重新计算直接用 1 作为 padding输出尺寸会比预期小特征图尺寸逐层累积下来就会差很多。Conv 类另一个容易被忽略的设计是类属性default_act nn.SiLU()。这意味着所有没有单独指定激活函数的 Conv 实例默认都会用 SiLU。注意这里用的是类属性而不是实例属性多个实例共享同一个激活函数对象内存上更省。2.2 为什么去掉 bias为什么默认 SiLU第一个问题为什么 self.conv 创建时是biasFalse原因很简单后面跟了 BatchNorm2d。BN 在训练时会做归一化再通过可学习的缩放因子 γ 和平移因子 β 恢复表达能力。卷积的偏置项 bias 在 BN 归一化过程中会被减去等价地被 BN 的平移项吸收掉。所以保留 bias 不仅对最终结果没有帮助还会增加参数量和计算量。实测下来同一份数据集上带 bias 的 ConvBN 和去 bias 的 ConvBN 最终精度基本没有差别但去 bias 的模型更干净导出的计算图也更简单。第二个问题为什么默认激活函数从 YOLOv5 时代的 LeakyReLU 换成了 SiLUSiLU 的公式是x * sigmoid(x)曲线在负区间平滑而非线性在正区间近似线性。对深层网络来说这种平滑的激活函数能让梯度流动更顺畅很多实验结果也支持 SiLU 在目标检测任务上的精度优势。代价是前向计算时多了 sigmoid 运算推理速度会比 LeakyReLU 略慢。不过现代 GPU 对这类激活函数的计算优化已经很好加上模型融合之后这个差距在常规硬件上并不明显。2.3 forward 和 forward_fuse推理融合的伏笔Conv 类里有两个 forward 方法。常规的 forward 执行三连conv - bn - act。第二个 forward_fuse 执行的是 conv - act中间跳过了 BN。为什么要留一个不带 BN 的入口因为推理阶段我们可以把 BN 的参数融合进卷积层fuse_conv_and_bn 干的活融合后网络里就不存在 BN 层了自然要有一个对应的 forward 路径。在 ultralytics 的模型融合逻辑里遍历到 Conv 模块时先调用 fuse_conv_and_bn 把 self.conv 和 self.bn 合并成一个带 bias 的新卷积然后把 self.bn 置为 Identity()同时把 forward 切换到 forward_fuse。这个过程在导出 ONNX 或者 TensorRT 时尤其重要因为很多推理引擎对 BN 层支持得不如融合后的普通卷积好融合也能减少一次内存读写进一步降低延迟。3. 轻量化组合三件套DWConv、LightConv 与 GhostConv3.1 DWConv 的分组数为什么用 GCD直接上代码class DWConv(Conv): def __init__(self, c1, c2, k1, s1, d1, actTrue): super().__init__(c1, c2, k, s, gmath.gcd(c1, c2), dd, actact)DWConv 继承 Conv唯一改动的是把 groups 设置成了math.gcd(c1, c2)。常规深度可分离卷积里每个输入通道对应一组卷积核即分组数等于输入通道数 c1。但这里有个限制PyTorch 的 nn.Conv2d 要求输出通道数必须是 groups 的整数倍。如果 c1 和 c2 不相等比如 c164、c296分组数取 64 的话输出通道数 96 不是 64 的倍数会直接报错。用 gcd 就是为了兜这个底。gcd(64, 96)32输出通道 96 是 32 的 3 倍输入通道 64 是 32 的 2 倍可以正常工作。当然这和“标准”的深度可分离卷积并不完全等价——每组输入通道有 2 个每组输出通道有 3 个。但在多数实际场景中DWConv 被用在通道数不变的地方比如 LightConv 的第二层输入输出都是 c2gcd 就退化为 c2等于标准的深度卷积。3.2 LightConv先用 1×1 压缩再深度卷积class LightConv(nn.Module): def __init__(self, c1, c2, k1, s1, actTrue): super().__init__() self.conv1 Conv(c1, c2, 1, actact) self.conv2 DWConv(c2, c2, k, s, actact) def forward(self, x): return self.conv2(self.conv1(x))这个模块的整体思路很好理解先用 1×1 卷积做通道间的信息融合和通道数变换再用 DWConv 对空间维度做特征提取。相比普通 Conv1×1 卷积负责通道交互深度卷积负责空间感知算力消耗大幅下降。有一点值得注意这里第一个 conv1 用的 kernel1stride 默认也是 1也就是说降采样的活儿全交给了第二层的 DWConv。如果你想控制步长实际是在 DWConv 的 s 参数上动手而不是在第一层。我在自定义模块时一开始就写反过把步长传给了第一层 1×1 卷积结果特征图尺寸完全没变直到打印 shape 才发现问题。3.3 GhostConv幻影特征生成的思路GhostConv 的核心想法是不需要用那么多卷积核去生成全部输出通道一部分特征图用普通卷积生成另一部分特征图用便宜的线性变换“幻影”出来。直接看代码class GhostConv(nn.Module): def __init__(self, c1, c2, k1, s1, g1, actTrue): super().__init__() c_ c2 // 2 self.cv1 Conv(c1, c_, k, s, None, g, actact) self.cv2 Conv(c_, c_, 5, 1, None, c_, actact) def forward(self, x): y self.cv1(x) return torch.cat((y, self.cv2(y)), 1)c_ c2 // 2也就是说 cv1 只生成一半的输出通道。cv2 的输入输出通道相同分组数等于 c_等价于一个 5×5 的深度卷积对 cv1 的结果做空间变换生成另一半通道最后在通道维拼起来。整个过程生成恒定的 c2 个通道但第一部分大卷积的计算量只有原来的一半后一半是轻量的深度卷积整体算力开销明显下降。使用 GhostConv 时要注意一个细节如果 c2 是奇数c_ c2 // 2 会向下取整。比如 c265c_32cat 后输出是 64不是 65。实际使用时最好让 c2 是偶数或者自己改代码把后半部分也处理成 c2 - c_。这种边界情况在 yaml 里写通道数时不一定能意识到等跑起来才发现 shape 对不上。4. Focus、ConvTranspose 与 DWConvTranspose2d特殊降采样和上采样4.1 Focus 的切片拼接是怎么做到的Focus 类在 YOLOv5 时代非常出名因为它出现在每个 yaml 的 backbone 第一层。代码很短class Focus(nn.Module): def __init__(self, c1, c2, k1, s1, pNone, g1, actTrue): super().__init__() self.conv Conv(c1 * 4, c2, k, s, p, g, actact) def forward(self, x): return self.conv(torch.cat((x[..., ::2, ::2], x[..., 1::2, ::2], x[..., ::2, 1::2], x[..., 1::2, 1::2]), 1))forward 里做了 4 次步长为 2 的切片分别取偶数行偶数列、奇数行偶数列、偶数行奇数列、奇数行奇数列的像素然后在通道维拼接。比如输入是[Batch, 3, 640, 640]经过切片后变成[Batch, 12, 320, 320]最后送入 Conv 做通道压缩输出[Batch, 64, 320, 320]。这样在不丢失任何像素信息的前提下完成了一次 2 倍降采样。这个思路在当时比较取巧。它避免了直接 stride2 卷积里可能的像素重叠因为传统卷积在下采样时相邻区域会有信息交互而 Focus 把像素分成 4 个等分再让卷积去学习如何组合。但从工程实践看Focus 有个隐藏问题切片后的拼接操作会带来额外的内存拷贝在 GPU 上并不算高效特征图越大这个问题越明显。4.2 为什么 YOLOv8 不再用 Focus到了 YOLOv8主干网第一层改成了普通Conv(3, 64, 3, 2)。很多刚接触 YOLO 的朋友会疑惑Focus 不是精度更好吗怎么说不用就不用了主要原因是硬件适配。Focus 的 4 次切片拼接在推理引擎里会展开成多个 slice 和 concat 节点这些节点在 TensorRT、OpenVINO 等推理框架里未必都有最高效的实现。而 stride2 的普通 3×3 卷积几乎在所有推理引擎里都经过深度优化计算结果又快又稳。从模型整体的精度差异看两者在多数任务上差距很小但部署便利性和速度上的优势让普通卷积成了更务实的选择。另一个原因是模块设计的简化。Focus 把通道数直接乘 4导致后续所有层的通道设计都得围绕这个变化来调整。移除 Focus 后backbone 结构更干净也更容易理解。如果你还在 YOLOv5 代码里见到 Focus可以把它理解为历史产物想法不错但新框架不再需要它了。4.3 ConvTranspose 与 DWConvTranspose2d 的差异转置卷积通常用于上采样像分割模型或者部分检测模型的高分辨率分支中。ConvTranspose 在 Conv 的基础上做了一点修改class ConvTranspose(nn.Module): default_act nn.SiLU() def __init__(self, c1, c2, k2, s2, p0, bnTrue, actTrue): super().__init__() self.conv_transpose nn.ConvTranspose2d(c1, c2, k, s, p, biasnot bn) self.bn nn.BatchNorm2d(c2) if bn else nn.Identity() self.act self.default_act if act is True else act if isinstance(act, nn.Module) else nn.Identity() def forward(self, x): return self.act(self.bn(self.conv_transpose(x)))注意这里和 Conv 类有两个关键差异。第一它提供了 bn 参数可以选择是否接 BN 层。默认 bnTrue但如果 bnFalseself.bn 是 Identity()相当于没有 BN。第二卷积层自带偏置且 bias 的取值是not bn意思是用了 BN 就不要偏置不用 BN 就保留偏置。这个联动逻辑和 Conv 类里 biasFalse 是同一个道理BN 能吸收偏置没必要重复。DWConvTranspose2d 则完全不同它是个极简的包装class DWConvTranspose2d(nn.Module): def __init__(self, c1, c2, k1, s1, p10, p20): super().__init__() self.conv_transpose nn.ConvTranspose2d(c1, c2, k, s, p1, p2, groupsmath.gcd(c1, c2), biasFalse) def forward(self, x): return self.conv_transpose(x)它使用分组数math.gcd(c1, c2)的深度可分离转置卷积没有 BN没有激活函数。所以使用时需要明白这个模块只是一个“算子容器”如果你想得到类似 ConvTranspose 那样带 BN 带激活的块需要自己在外面继续拼接模块。5. 注意力模块与 ConcatCBAM 三件套解读5.1 ChannelAttention用 1×1 卷积学通道权重先说 ChannelAttention。它通常搭配 SpatialAttention 一起出现在 CBAM 里但单独拿出来也很有用class ChannelAttention(nn.Module): def __init__(self, channels): super().__init__() self.pool nn.AdaptiveAvgPool2d(1) self.fc nn.Conv2d(channels, channels, 1, 1, 0, biasTrue) self.act nn.Sigmoid() def forward(self, x): return x * self.act(self.fc(self.pool(x)))forward 的过程可以拆三步先对空间维度做全局平均池化每个通道变成 1×1再用 1×1 卷积学习通道之间的关系最后过 sigmoid 生成 0 到 1 之间的通道权重乘回原始特征图。一个值得注意的细节这里没有做通道降维。经典的 SE 模块会先用一个较小的比例 r 压缩通道再扩张回来以控制参数量。这里的 ChannelAttention 直接让 fc 的卷积核尺寸是 channels×channels参数量是通道数的平方。如果通道数比较小比如 64、128这个开销还能接受但如果通道数到了 512 甚至更大参数量会变得很可观。所以在你的 yaml 里引用 CBAM 时最好评估一下通道数是否适合直接使用默认实现。5.2 SpatialAttention均值 最大值组合SpatialAttention 关注的是“空间上哪些位置更重要”class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() assert kernel_size in (3, 7), kernel size must be 3 or 7 padding 3 if kernel_size 7 else 1 self.cv1 nn.Conv2d(2, 1, kernel_size, paddingpadding, biasFalse) self.act nn.Sigmoid() def forward(self, x): return x * self.act(self.cv1(torch.cat([torch.mean(x, 1, keepdimTrue), torch.max(x, 1, keepdimTrue)[0]], 1)))它对输入特征图在通道维分别算平均和最大得到两个单通道特征图拼成 2 个通道再用一个 7×7 或 3×3 的卷积生成空间注意力权重最后乘回原图。这里把均值信息和最大值信息混在一起输入目的是同时保留“整体强度”和“局部峰值”两种描述让卷积层能学到更有区分度的空间权重。代码顶部的 assert 是个不易察觉的约束kernel_size 只能是 3 或 7。为什么不能是 5 或其他奇数因为 padding 的逻辑只有两档7 时取 33 时取 1。如果传 5映射表里没有对应的 padding就等着报错。所以用这个模块时要么改代码支持通用奇数核要么老老实实用默认的 7。5.3 CBAM 如何串联两个注意力CBAM 就是把上面两个模块按顺序接起来class CBAM(nn.Module): def __init__(self, c1, kernel_size7): super().__init__() self.channel_attention ChannelAttention(c1) self.spatial_attention SpatialAttention(kernel_size) def forward(self, x): return self.spatial_attention(self.channel_attention(x))先做通道注意力再做空间注意力这个先后顺序是被实验验证过的。通道注意力作用于每个通道让网络先关注“哪些通道重要”空间注意力作用于每个位置让网络再关注“哪些位置重要”。两者互补形成了一个完整的注意力链路。在 yaml 中使用 CBAM 时你需要正确传参。比如在某个层后面插入 CBAM通常这样写- [-1, 1, CBAM, [64]]这里的 64 指输入通道数 c1即 CBAM 从上层拿到的特征图的通道数。如果写错第一个 ChannelAttention 的 1×1 卷积维度不匹配运行时会直接报维度错误。5.4 Concat 模块为什么值得单独成类Concat 可能是整个文件里代码量最少的类之一class Concat(nn.Module): def __init__(self, dimension1): super().__init__() self.d dimension def forward(self, x): return torch.cat(x, self.d)它的作用和 torch.cat 完全一样。但为什么还要专门包一个类因为 yaml 解析器和模型遍历器只认 nn.Module 对象在配置文件中需要把一个拼接操作声明为网络里的一个节点。有了 Concat 类yaml 的 from 列表里就可以写多个输入解析器再把多个输入张量传给这个模块的 forward。这种设计让网络图可以用声明式语言描述同时保持 forward 逻辑的纯粹。在 YOLOv8 的 neck 中Concat 通常默认 dimension1也就是通道维拼接。如果你想在 batch 维或者空间维做拼接可以通过 yaml 参数覆盖。不过绝大多数场景用默认值就够了。6. fuse_conv_and_bn推理阶段的神来之笔6.1 融合的数学原理与公式推导融合 BN 到卷积是部署技巧里最经典的一个。它的核心是一个数学恒等式。卷积层的输出是y W * x bBN 的输出是z γ * (y - μ) / sqrt(σ² ε) β代入后得到z (γ / sqrt(σ² ε)) * W * x (γ / sqrt(σ² ε)) * b β - (γ * μ / sqrt(σ² ε))所以合并后的卷积权重为W W * γ / sqrt(σ² ε)合并后的偏置为b (b - μ) * γ / sqrt(σ² ε) β。这里的 μ 和 σ² 是 BN 层统计出的 running_mean 和 running_var不是当前 batch 的统计量这一点很重要。训练阶段 BN 用的是 batch 内统计推理阶段用的是累积统计所以融合时必须用 running_var。6.2 代码逐行拆解ultralytics 里对应的函数是 fuse_conv_and_bn核心逻辑如下def fuse_conv_and_bn(conv, bn): fusedconv nn.Conv2d(conv.in_channels, conv.out_channels, kernel_sizeconv.kernel_size, strideconv.stride, paddingconv.padding, dilationconv.dilation, groupsconv.groups, biasTrue).to(conv.weight.device) w_conv conv.weight.clone().view(conv.out_channels, -1) w_bn torch.diag(bn.weight.div(torch.sqrt(bn.eps bn.running_var))) fusedconv.weight.copy_(torch.mm(w_bn, w_conv).view(fusedconv.weight.shape)) b_conv torch.zeros(conv.weight.size(0), deviceconv.weight.device) if conv.bias is None else conv.bias b_bn bn.bias - bn.weight.mul(bn.running_mean).div(torch.sqrt(bn.running_var bn.eps)) fusedconv.bias.copy_(torch.mm(w_bn, b_conv.reshape(-1, 1)).reshape(-1) b_bn) return fusedconv第一段创建一个同尺寸、带 bias 的新卷积 fusedconv用于承接融合后的参数。第二段把卷积核 reshape 成[out_channels, -1]并构造一个对角矩阵 w_bn对角线上是γ/sqrt(σ²ε)矩阵乘的结果等价于对每个输出通道的卷积核乘以对应缩放系数。第三段负责偏置如果原卷积没有 bias 就当作 0 处理再加上 BN 的修正项最终得到融合后的 bias。6.3 实际应用场景导出 ONNX、加速推理这个函数最典型的使用场景是模型导出。在 ultralytics 里把模型转到 ONNX 格式时会默认执行一次融合操作把所有 ConvBN 的组合变成单个卷积。融合后的模型少了很多 BN 节点图结构更简洁导入到 TensorRT 等推理引擎时也更容易触发算子融合。实际动手时我的经验是不要自己手动去调用 fuse_conv_and_bn而是走模型自带的 fuse 方法。它会递归遍历所有 module自动判断哪些是 Conv、哪些是 BN再调用对应函数。如果你真的想手动操作记得在融合前把模型切到 eval 模式并且确保 BN 层已经累积了足够的 running_mean 和 running_var。否则融合出来的权重就是错的精度会直接崩掉。7. 实操中常见的坑与排查技巧7.1 改通道后维度对不上怎么定位我相信每个用过 ultralytics 的人都被 “size mismatch” 报错折磨过。比如你在 yaml 里把某个 Conv 的输出通道从 64 改成了 128结果后面的模块没有同步调整程序跑到一半直接炸。排查的时候最管用的方法就是逐层打印中间 shape。我一般会在 model.py 的 forward 里临时加打印或者用注册 hook 的方式输出每一层的输入输出。不需要额外装工具PyTorch 的 print 就能搞定def hook_fn(module, input, output): print(module.__class__.__name__, input[0].shape, output.shape) for name, module in model.named_modules(): module.register_forward_hook(hook_fn)跑一次前向看到哪一层输出和下一层预期对不上就能定位到问题模块。改通道时还要注意 groups 参数比如 DWConv 的分组数是 gcd(c1, c2)改完通道后如果 c1 和 c2 不满足整除关系也会在构造时报错。7.2 DWConv 与注意力模块的边界情况DWConv 的坑在前面已经提过再强调一次当 c1 不等于 c2 且两者没有足够大的公约数时gcd 算出来的 groups 能工作但语义上已经不是标准的深度卷积了。这不算 bug但会影响你对模型行为的预期。如果你想严格的深度卷积最好保证 c1 和 c2 相等或者手动指定 groupsc1 并额外用 1×1 卷积调整通道。注意力模块这边最常见的报错是 kernel_size 传了 5。SpatialAttention 的 assert 只允许 3 或 7传 5 会直接崩溃。还有单通道灰度图输入时channel attention 和 spatial attention 都能跑但效果非常有限因为通道维根本没有足够的信息供注意力学习。遇到这种情况我会建议先确认输入不是单通道或者考虑在灰度图上先用一个卷积把通道扩出来。7.3 自定义模型导出时 BN 融合失败自己拼模型时很容易忘记 BN 和 bias 之间的关系。如果你创建了一个不带 bias 的卷积后面直接跟一个 Identity 层然后调用模型的 fuse() 方法融合函数会尝试读取 bn.weight结果得到 None 或者报错。反过来如果你创建卷积时带了 bias后面又接了 BN融合时会先对 bias 做一次缩放再加 BN 的偏移语义上等价于去掉 bias 再融合只是多了一次无意义运算。所以自定义模块时我的建议是严格遵循 conv.py 里的惯例卷积 BN 的组合卷积一律不带 bias没有 BN 的组合卷积可以保留 bias。这样导出和融合都不会出问题。大概也就是这些了。回到开头那个问题为什么要花时间读 conv.py 这样一个看似底层的文件我的答案是它帮你建立了一个很实用的心智模型——当你面对任何新的检测网络结构拿到 yaml 就能在脑子里快速推演出每一层的具体实现哪里改了通道、哪里加了注意力、哪里用了轻量化卷积都能一目了然。如果你之后要改模型、做蒸馏、做剪枝或者部署这个基础会省掉大量查代码的时间。顺带一提我在实际项目中凡是自定义模块都会优先考虑在 conv.py 的组件基础上组合而不是另起炉灶这样能最大限度兼容 ultralytics 的 yaml 解析和导出流程。希望这篇记录能帮你省去一些踩坑时间。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →