ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

YOLOv5 Focus层原理与实现解析:从切片到6x6卷积的演进

YOLOv5 Focus层原理与实现解析:从切片到6x6卷积的演进 做目标检测的人应该没人不知道 YOLOv5。这个项目从 2020 年发布开始就一直是很多人入门检测和做工程落地的首选框架。如果你仔细翻过它的网络结构大概率会在 Backbone 的第一层卡住这里为什么有个叫 Focus 的模块名字有点玄乎做的事情也跟传统卷积不太一样。今天我不聊 YOLOv5 的整体结构专门把 Focus 层拎出来讲清楚——它到底在做什么、为什么它这么设计、以及后来为什么 YOLOv5 又悄悄把它换掉了。无论你是要训练自己的数据集还是准备把这个模型部署到 Jetson Nano 这类边缘设备上做实时项目理解 Focus 层的原理和坑都会帮你省下不少折腾时间。1. Focus层到底在做什么从切片到卷积1.1 先看代码再看原理YOLOv5 里的 Focus 实现其实非常短核心代码就这一段import torch import torch.nn as nn class Focus(nn.Module): def __init__(self, c1, c2, k1, s1, pNone, g1, actTrue): super(Focus, self).__init__() self.conv Conv(c1 * 4, c2, k, s, p, g, act) def forward(self, x): return self.conv( torch.cat( [ x[..., 0::2, 0::2], x[..., 1::2, 0::2], x[..., 0::2, 1::2], x[..., 1::2, 1::2], ], 1, ) )先别急着往下背这段代码里真正决定 Focus 行为的不是Conv而是forward里的四个切片。x[..., 0::2, 0::2]表示从空间维度上行和列都从索引 0 开始每隔一个像素取一个点也就是取所有偶数行、偶数列的像素。同理x[..., 1::2, 0::2]取奇数行、偶数列x[..., 0::2, 1::2]取偶数行、奇数列x[..., 1::2, 1::2]取奇数行、奇数列。这一通操作把一张图从上到下、从左到右按照 2x2 的小格子切成了四份每份都只保留原来四分之一的空间位置。然后这四个特征图在通道维度上拼起来通道数变成原来的 4 倍。换句话说Focus 做的是把特征图里的“空间信息”成块地搬运到“通道信息”里。这个操作本质上就是很多框架里说的 space-to-depth也就是将一个空间邻域的像素点重组到通道维度上。1.2 切片顺序与 shape 变化搞清楚四个切片的顺序很重要因为它们直接决定了后面卷积核的权重排列。以输入一张 BCHW 的图片为例假设输入是B, 3, 640, 640经过 Focus 的切片拼接后得到的是B, 12, 320, 320。这里 12 来自 4 种切片方式乘以原来的 3 个通道顺序是第一部分所有像素中行偶、列偶的点原通道顺序不变第二部分行奇、列偶的点第三部分行偶、列奇的点第四部分行奇、列奇的点。这个顺序在代码里是固定的。如果你自己写一个等价功能一定不要打乱顺序否则后面接的卷积极有可能学到不同的特征表达对不上预训练权重。torch.cat完成后会自动得到一个空间尺寸减半、通道数变成 4C 的新张量。再送入一个Conv(c1*4, c2)比如在 YOLOv5s 里第一层通常配置成输出通道 16注意 YOLOv5s 的宽度缩放系数是 0.5所以基准 32 会变成 16最终输出就是B, 16, 320, 320。1.3 用一个小例子理解 Focus 的“空间变通道”很多教程讲到这里就结束了但我发现用一个 4x4 的小例子来手动推一遍会清楚得多。假设输入是一张单通道 4x4 的特征图像素值可以写成1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16Focus 切片后得到四个 2x2 的特征图偶数行偶数列1, 3, 9, 11奇数行偶数列5, 7, 13, 15偶数行奇数列2, 4, 10, 12奇数行奇数列6, 8, 14, 16然后这四个特征图堆叠在一起通道维度从 1 变成 4。可以看到原本一个 2x2 的邻域被拆到了四个不同的通道里。这么做的效果是让后续的卷积能够在同一个输出位置同时看到“中心像素”、“上下左右隔一个像素的邻居”等信息而不是像普通步长卷积那样只通过滑动窗口覆盖。这就是 Focus 的第一个价值下采样的同时不丢空间信息。2. 为什么要用Focus层下采样方案对比与设计动机2.1 主流的三种下采样方式神经网络里想降低特征图分辨率常见方式有三种池化、步长卷积、Focus 这种先切片再接卷积。池化里最典型的是 MaxPooling 和 AvgPooling它们不引入额外参数但会把一个邻域的信息压缩成一个值MaxPooling 保留最强响应AvgPooling 保留平均响应本质上是有损下采样。步长卷积则是通过设置 stride2让卷积核跳过相邻位置输出分辨率减半它比池化强的地方在于下采样过程中的滤波器是可学习的但缺点是每次只在一个 2x2 窗口内采样相邻像素之间的关系没有显式拆开。Focus 的做法和这两种都不一样。它没有池化的“取最大值或平均”这种聚合过程而是把 2x2 窗口里的四个像素原封不动地送到通道里。换句话说下采样过程本身没有信息丢失只是把原来一个空间位置上的“多像素内容”搬到了通道维度上暂存真正做特征提取的是后面那个卷积层。2.2 信息保留优势很多人会问普通 stride2 的 3x3 卷积不也能下采样吗本质上也能用可学习参数融合邻域信息为什么非要多此一举区别在于感受野的利用方式。普通 stride2 卷积在输出特征图的每一个位置只对输入的一个规则 2x2 或 3x3 窗口进行计算窗口之外的信息当前层用不到。而 Focus 先把整张图的 2x2 邻域拆开再让卷积在通道之间做融合。对于后续卷积来说相当于把原来缩小的特征图的通道数变多了卷积层可以在这个更高维的表示上做非线性组合信息流更丰富。在 YOLOv5 刚出现的那段时间这种结构是一种试验性设计也确实在当时的 COCO 和自定义数据集上表现不错。尤其是对小目标或者纹理密集的场景Focus 的空间信息保留能力比单纯池化更友好因为它没有强制做局部聚合而是给卷积更多“自己选特征”的空间。2.3 算一笔账FLOPs 和参数变化关于 Focus 层网上有一个很常见的误解是“Focus 能减少计算量”。严格说Focus 本身只是切片没有参数也没有 FLOPs。但如果把 Focus 后面的卷积一起算进来它并不比普通步长卷积省流量甚至有时候多。真正的特点是它在不增加输出分辨率的前提下增加了特征通道的表达维度。我列一张表以输入 3 通道、640x640输出通道 32 为例对比一下几种下采样方案的实际开销方式模块配置参数量理论 FLOPsFocus 3x3 ConvFocus(3, 32, 3)12 x 32 x 9 3456320 x 320 x 12 x 32 x 9 ≈ 353.9M3x3 Stride2 ConvConv(3, 32, 3, 2)3 x 32 x 9 864320 x 320 x 3 x 32 x 9 ≈ 88.5M6x6 Stride2 ConvConv(3, 32, 6, 2, 2)3 x 32 x 36 3456320 x 320 x 3 x 32 x 36 ≈ 353.9M这张表告诉我们两件事。第一Focus 之后接 3x3 卷积的参数量和 FLOPs跟直接用一个 6x6 stride2 卷积完全一样。第二它的计算量比普通 3x3 stride2 卷积大得多因为它没有丢弃任何输入像素而是把所有像素都“喂”给了网络。Focus 并不是为了省算力而是为了在同样的输出空间尺寸下让第一层卷积能接触更多跨位置的信息。2.4 为什么后来 YOLOv5 又把它换掉了YOLOv5 v6.0 之后官方把 Focus 层替换成了Conv(3, 32, 6, 2, 2)也就是一个 6x6 卷积stride2padding2。从上面的参数量和 FLOPs 可以看出这种替换在数学和计算开销上几乎是对等的。官方原话大意是新的 6x6 卷积在 GPU/TPU 上更容易利用底层优化算子同时便于模型导出和部署。Focus 层的切片操作在 PyTorch 里只是几个切片加 cat实际推理时会在内存中产生非连续访问。在普通 GPU 上这些操作没办法像卷积那样用 cuDNN 深度优化反而增加了核函数启动开销。尤其在导出 ONNX 或部署到 TensorRT 时Focus 会变成一串 Slice 和 Concat 节点对端侧推理并不友好。你可以把 Focus 理解为 YOLOv5 早期探索阶段的产物后来的 6x6 卷积是它在工程上的“等价替身”。虽然 v6.0 之后结构里没有了 Focus但理解它依然是读懂旧项目、迁移老权重、破解各种历史博客代码的关键。3. 源码级拆解Focus在YOLOv5里的真实实现3.1 common.py 中的 Focus 类YOLOv5 的老版本models/common.py里Focus 的实现就是我们第一部分看到的代码。这里有几个细节容易踩坑第一Conv不是普通nn.Conv2d而是 YOLOv5 自己封装的卷积块内部包括卷积、BatchNorm 和 SiLU 激活。所以 Focus 里的 conv 也会带 BN 和激活这在整个 Backbone 里是统一的。第二Focus的默认k1是个坑。如果你的模型配置文件里只写了[Focus, [3, 32]]那它后面的卷积就是 1x1但在 YOLOv5 的常见配置里写的是[Focus, [3, 32, 3]]才会用 3x3 卷积。很多人自己改写模型时漏了3导致第一层变成 1x1 卷积整个网络行为都不一样了。3.2 在 yolov5s.yaml 中的配置看老版本yolov5s.yaml的 Backbone第一行通常是backbone: [[-1, 1, Focus, [3, 32, 3]], ...]这里的含义是输入来自上一层层数只有 1 层模块是 Focus参数为输入通道 3、输出通道 32、卷积核大小 3。但实际跑yolov5s.yaml时第一层输出不是 32而是 16因为 YOLOv5 会根据width_multiple对通道数进行缩放。yolov5s的width_multiple是 0.5所以 32 乘 0.5 得到 16。这一点对“训练自己的数据集”非常重要。你看到的模型结构图里的通道数往往不是 yaml 里的原始数字而是缩放后的结果。如果你要修改模型结构或对齐某些中间层需要先搞清楚depth_multiple和width_multiple对每一层的影响。很多人尝试在 YOLOv5 里加检测头或替换 Backbone第一层就栽在这。3.3 等价替换Focus 3x3 Conv 与 6x6 Stride-2 Conv既然 v6.0 把 Focus 换成了 6x6 stride2 的卷积那两者就存在一个权重转换关系。Focus 输出的 12 通道本质上是把原图 3 个通道按 2x2 邻域拆成了四组再在通道维度堆叠。后面的 3x3 卷积可以等价地看成一个在原始输入上按特定排列展开的 6x6 卷积核只不过 Focus 版本的卷积核权重是被拆成了 4 个 3x3 块分别作用在四组切片图上。如果你要从旧版 Focus 结构迁移到新版 6x6 卷积结构最省事的做法不是手工重排权重而是直接加载旧权重后把模型第一层替换成新的 6x6 卷积再在新数据集上微调几百到几千个 iteration。因为新旧结构虽然可重排权重但 BN 统计量和激活值的分布会有所差异微调能快速拉齐。若你只是推理也可以写一个脚本把旧的 Focus 卷积权重重新映射到 6x6 卷积的位置但需要特别小心切片的排序稍有不慎权重就会错位。4. 训练与部署中的Focus层实操要点4.1 输入尺寸对齐为什么必须是 32 的倍数Focus 本身要求输入宽高是偶数因为要按 2x2 切片。实际 YOLOv5 训练时会做 letterbox 到 640x640、1280 之类的固定尺寸并且尺寸基本都是 32 的倍数。为什么是 32 而不是 2因为网络后面还有多次步长为 2 的下采样最后特征图会缩小到输入的 1/32。如果输入不是 32 的倍数后面的特征图尺寸会产生小数部分算子会直接报错。我自己在训练一个水果检测数据集时一开始为了省内存直接把一批图片 resize 到 352x352问题不大因为 352 是 32 的倍数。后来有个同学图省事直接 resize 到 350x350结果第一层 Focus 就报了 shape mismatch因为它要求在空间维度上能整除 2这还只是第一步后面还有 5 次下采样350 会碎一地。所以无论你有没有用 Focus只要继续使用 YOLOv5 的训练流程强烈建议所有输入尺寸保持 32 的倍数。4.2 加载预训练权重时的坑在老版本 YOLOv5 里训练自己的数据集一般会用 COCO 预训练权重初始化 Backbone。如果你用的是老代码且权重和模型结构匹配那没有问题。但如果你把旧版权重加载到一个已经替换了 Focus 的模型上state_dict里第一层卷积的 key 和 shape 会对不上。具体表现是老版 Focus 的第一层 conv 权重形状是[out_ch, 12, 3, 3]新版 6x6 卷积的权重形状是[out_ch, 3, 6, 6]。加载时会直接报 Unexpected key 或者 size mismatch。解决办法有两个一是先把旧权重里的第一层去掉只加载后面 Backbone 的权重然后重新初始化第一层二是把旧权重做重排转换但要确保 12 通道到 6x6 卷积的位置映射是正确的。我个人比较推荐第一种简单且足够安全因为第一层参数不多从头训练也不影响整体收敛前提是你数据集别太小到离谱。4.3 部署到 ONNX 和 Jetson 的优化思路如果你要把 YOLOv5 部署到 Jetson Nano 或边缘设备Focus 层经常会成为性能瓶颈之一。原因很好理解Focus 的切片操作虽然直观但在 TensorRT 或 ONNX Runtime 里可能被拆成一堆低效算子例如多个 Slice、Concat甚至有时候需要额外写插件才能融合。我自己在 Jetson Nano 上跑一个车牌识别模型时一开始用的还是老版 Focus 结构TensorRT 导出后前几层的推理延迟明显比后几层高。后来我直接把模型结构换成 v6.0 的 6x6 stride2 卷积重新微调了几百步整包推理速度提升明显。如果你的项目还没有上线建议直接用新版结构训练如果已经用老版训练好也可以在转换阶段做等价替换并微调。设备端的优化不是越花哨越好能用标准卷积解决的问题就不要用自定义算子。5. 常见问题与排查技巧实录5.1 训练时报 Shape Mismatch这是我见过最多的错误。如果你自定义数据集时忘了做 letterbox或者随机裁剪后的尺寸不是偶数Focus 层会直接报类似RuntimeError: Sizes of tensors must match except in dimension 1这是因为四个切片的输出尺寸不一致导致torch.cat不能拼接。解决办法也很简单在datasets.py的加载逻辑里保证送入网络的图像宽高是偶数并且最好是 32 的倍数。为了快速定位可以在 Focus 的forward里临时加一行打印print(x.shape)虽然这不是最终版该留的东西但调试时很管用。我见过有人改了网络结构之后看都不看中间 shape一路往后推到最后才发现尺寸全错了这种排查方式极其浪费时间。最好的习惯是先把每个模块的输入输出 shape 都打印一遍确认无误后再去掉调试代码。5.2 如何验证自己改写的 Focus 是否正确如果你尝试把 Focus 替换成 6x6 卷积或者手动重排权重验证方法其实很简单取一张固定输入分别用两个模型跑一次前向对比输出张量的绝对误差。只要结构等价输出应该非常接近误差只在浮点精度级别。示例验证代码import torch x torch.randn(1, 3, 640, 640) # 假设 focus_model 是旧版模型conv_model 是新版模型 with torch.no_grad(): y1 focus_model(x) y2 conv_model(x) diff (y1 - y2).abs().max().item() print(max diff:, diff)如果 diff 小于 1e-5说明结构等价做得没问题。如果 diff 很大大概率是权重重排的顺序错了或者 padding 设置不一致。5.3 性能调优什么时候保留 Focus什么时候换掉保留还是换掉取决于你的场景。如果你只是复现旧项目、跑通代码并且训练和推理都在 PC GPU 上Focus 留着完全可行精度和召回不会因为这个结构而出现明显劣化。如果你要导出到移动端、TensorRT 或者设备端实时推理我更建议直接用 v6.0 之后的 6x6 卷积或者至少提前做算子兼容性测试。另外如果你在自己的项目里加入了新的检测头或额外任务判断第一层用什么结构不应只看 FLOPs还要看实际内存带宽和算子调度。Focus 切片在 PyTorch 里生成的是非连续内存视图虽然cat之后会复制成连续张量但这个复制和拼接过程也有开销。标准 6x6 卷积可以直接走 cuDNN 优化在大多数设备上更省心。我个人在实际项目里的体会是Focus 层本身不难难的是它牵扯出来的结构变更、权重迁移和部署适配。很多人只看图以为它是某种神秘模块实际上它就是一次“图像像素重排 卷积”。搞清楚了这一点你再去看 YOLOv5 的老代码、各种魔改版或者自己动手替换结构思路都会清楚很多。最后再分享一个小技巧不管你是用 Focus 还是 6x6 卷积训练自己的数据集时第一层尽量不要随意改通道数要么跟着预训练权重来要么做好从头训练的准备不然 Backbone 前几层的特征分布不稳后续检测头再怎么调也很难收敛好。
返回列表