前言
Hello,大家好,我是
GISer Liu
😁,一名热爱AI技术的GIS开发者。本系列是作者参加DataWhale 2025年6月份Yolo原理组队学习的技术笔记文档,这里整理为博客,希望能帮助Yolo的开发者少走弯路!
🚀 欢迎来到YOLO进阶系列教程的核心,也是最后一篇文章——模型“魔改”!在目标检测领域,YOLO系列凭借其卓越的速效平衡成为了标杆。然而,无论是为了发表学术论文,还是应对复杂多变的业务场景,仅仅满足于使用官方模型、调整参数是远远不够的。我们需要的,是突破“调参工程师”的局限,真正深入模型内部,进行结构级的创新。
这正是Datawhale YOLO Master
项目的初衷。它提供了一套即插即用的先进模块和一套系统性的魔改方法论,旨在帮助开发者:
- 系统性理解YOLO架构:拆解模型为Backbone、Neck、Head等核心组件。
- 掌握模块化创新:像搭乐高一样,将前沿的模块(如SwinTransformer, CBAM等)无缝集成到YOLOv8/v10/v11中。
- 提升工程与科研能力:从源码层面理解并改造SOTA模型,为自己的项目或研究注入创新力。
本教程将手把手带你走完从环境准备到模型改造、训练的全过程。无论你是希望在CV领域深造的大学生,还是寻求技术突破的开发者,相信本教程都能为你提供坚实的起点。OK,让我们开始“造”自己的YOLO吧!
更新记录:(本文随时更新)
20250709:本文当前只是理论的堆砌,阅读感觉并不好;不够直观;后续我作者通过一个具体的需求,例如遥感影像目标识别去魔改我们的yolo模型结构;测试性能的变化;
一、YOLO“魔改”:从“调参”到“改结构”
1. 为什么要“魔改”YOLO?
标准的YOLO模型虽然强大,但在特定任务上未必是最优解。例如,在遥感影像中检测微小目标,或是在工业流水线上识别密集物体,都对模型的特征提取能力、多尺度融合等方面提出了更高的要求。此时,仅仅调整学习率、优化器等超参数,带来的性能提升是有限的。
真正的突破来自于对网络结构的创新——也就是我们常说的“魔改”。这好比我们不是简单地调整一辆车的悬挂软硬(调参),而是给它换上一台更强劲的发动机或者更先进的空气动力学套件(魔改)。
模型魔改是网络结构上的修改和替换,而非简单调参;这需要开发者对模型组成和原理有深刻的理解🤔
2. “魔改”的哲学:像搭乐高一样构建网络
YOLO Master
项目的核心思想是将复杂的神经网络解构成一系列可插拔的、标准化的“积木块”。YOLO模型经典的三段式结构(主干、颈部、头部)为这种模块化改造提供了完美的框架。
- 主干网络 (Backbone):负责从输入图像中提取基础特征,是模型的“地基”。我们可以将其替换为更先进的结构,如
SwinTransformer
、ConvNeXtV2
等,以获取更强的特征表达能力。 - 颈部网络 (Neck):负责融合主干网络在不同阶段提取出的特征图,增强模型对不同尺寸目标的感知能力。可以引入
GFPN
等结构进行优化。 - 检测头 (Head):根据融合后的特征进行最终的边界框回归和类别预测。我们可以尝试
DyHead
等动态头部来提升检测性能。 - 注意力机制 (Attention):像“插件”一样,可以插入到网络中的任何位置,让模型“关注”到最重要的特征区域。
CBAM
和SE
是常用的选择。
二、准备工作:搭建你的“魔改”基础环境
在开始之前,我们需要准备好两个核心的代码库:ultralytics
官方库和yolo-master
魔改项目库。
1. 克隆项目仓库
我们提供三种下载方式,推荐使用git clone
,如果遇到网络问题,可以尝试国内的GitCode镜像。
① 下载 ultralytics
源码
# 方法一:从GitHub直接克隆 (推荐)
git clone https://github.com/ultralytics/ultralytics.git
# 方法二:从国内镜像GitCode克隆
git clone https://gitcode.com/gh_mirrors/ul/ultralytics.git
其中,我们要对ultralytics文档目录结构有个相对完整的了解:
ultralytics/
assets:静态资源:测试图像、预训练模型等示例文件
cfg:配置文件中心
- datasets/:数据集定义(路径、类别、预处理)
- models/:模型架构配置(YOLOv8n/v8s/v8m等)
- trackers/:跟踪算法参数
- default.yaml:全局默认配置(训练/推理/导出参数)
data/:数据预处理与增强逻辑
engine/:核心功能引擎
- exporter.py:模型导出(ONNX/TensorRT等)
- model.py:模型生命周期管理
- predictor.py:推理接口
- trainer.py:训练流程控制
- validator.py:验证指标计算
hub/:PyTorch Hub 集成接口
models/:模型架构实现
- yolo/:YOLO系列主代码
- detect/:检测任务
- segment/:分割任务
- pose/:姿态估计
- classify/:分类任务
- model.py:模型构建核心
- rtdetr/:实时DETR架构
- nas/:神经架构搜索
- sam/:SAM优化策略
- fastsam/:快速分割模型
nn/:神经网络组件:自定义层/激活函数等
solutions/:高级应用模块:线计数/热力图生成等场景化解决方案
trackers/:多目标跟踪(MOT)算法实现
utils/:工具库
- callbacks/:训练回调函数
- metrics.py:性能评估指标
- plotting.py:可视化工具
- torch_utils.py:PyTorch扩展功能
- downloads.py:资源下载管理
- ops.py:张量操作扩展
init.py:包初始化入口(版本/API暴露)
② 下载 yolo-master
魔改教程源码
git clone https://github.com/datawhalechina/yolo-master.git
2. 环境配置
进入yolo-master
目录,其中包含了requirements.txt
文件,我们可以使用pip
进行安装。为了加速下载,建议使用国内的清华镜像。
# 进入yolo-master项目目录
cd yolo-master
# 安装依赖,-e . 表示以可编辑模式安装ultralytics
# 假设你的ultralytics目录与yolo-master在同一级
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install -e ../ultralytics
pip install -e .
的-e
代表 “editable”(可编辑)。这种方式安装后,你对ultralytics
源码的任何修改(比如我们后续的“魔改”操作)会立刻生效,而无需重新安装。这对于模型开发和调试至关重要。
三、主干(BackBone)的替换
要实现真正的“即插即用”,我们需要对ultralytics
的源码进行一些通用性的改造,让它能够识别并正确处理我们添加的自定义模块,特别是复杂的主干网络。修改的核心位于ultralytics/nn/tasks.py
文件中,这个文件负责解析YAML配置文件并构建整个神经网络模型。
1. 魔改的挑战与思路
挑战:ultralytics
原生的parse_model
函数设计时,主要考虑的是构建由一系列“标准”层(如Conv
、C2f
、Concat
)组成的网络。这些层有一个共同点:输入一个张量,输出一个张量。而我们想替换的先进主干网络(如RepViT, SwinTransformer等)通常是作为一个整体模块,它输入一个图像张量,一次性输出多个不同尺度的特征图(例如,同时输出P3, P4, P5三个层级的特征)。原生的解析和前向传播逻辑无法直接处理这种“一对多”的复杂模块。
解决思路:我们的魔改思路可以分为两步,就像进行一次精密的“外科手术”:
- 改造
parse_model
函数(模型构建阶段):让它在解析YAML时,能够“识别”出我们自定义的、作为整体的主干网络。识别后,它需要特殊处理:不再将它看作一个普通层,而是作为一个特殊的“多输出模块”,并正确记录下它所有输出头的通道信息。 - 改造
_predict_once
函数(模型推理阶段):让它在前向传播时,如果遇到这个被标记过的特殊主干网络,就执行特殊的传播逻辑。这个逻辑会一次性接收主干网络输出的多个特征图,并将它们正确地存放到一个列表中(y
列表),以供后续的Neck网络层使用。
2. 实战:一步步改造YOLOv8主干
主干网络是决定模型性能的基石。YOLO Master提供了大量先进的Backbone供我们选择。
可选主干网络 | 核心思想 |
---|---|
RepViT | 融合CNN的效率和ViT的性能 |
StarNet | 轻量级、高效的星状结构 |
EfficientViT | 高效的Vision Transformer变体 |
FasterNet | 极速推理,专注于硬件友好 |
ConvNeXtV2 | 现代化的纯卷积网络,性能媲美Transformer |
SwinTransformer | 经典的层级化窗口注意力Transformer |
VanillaNet | 极简主义设计,返璞归真但效果强大 |
… | (还有更多) |
让我们以RepViT
为例,演示完整的替换步骤。
接下来,我们将以RepViT为例,完整地展示如何实现这一“手术”。这个方法具有普适性,适用于本文中介绍的所有主干网络。
① 准备模块代码和配置文件
第一步:安放模块代码
在ultralytics/ultralytics/nn/
目录下,创建一个新文件夹new_modules
。然后,将yolo-master
项目中的Backbone_RepViT.py
文件复制到这个新文件夹中,并重命名为repvit.py
。
- 目的:将我们自定义的模块代码与
ultralytics
的官方代码分离开,便于管理和维护。
- 目的:将我们自定义的模块代码与
第二步:安放YAML文件
将yolo-master
中的RepViT-P345.yaml
文件复制到ultralytics/cfg/models/v8/
(或者你指定的其他版本目录)下。这个YAML文件描述了使用RepViT作为主干的网络结构。
- 目的:让YOLO的训练引擎能够找到并加载我们的新模型定义。
② 引用新模块
打开ultralytics/nn/tasks.py
文件,在文件的开头部分,找到导入模块的区域,添加以下代码:
# ultralytics/nn/tasks.py
# ... 其他 import 语句 ...
from ultralytics.utils.torch_utils import (fuse_conv_and_bn, fuse_deconv_and_bn, initialize_weights, intersect_dicts,
make_divisible, model_info, scale_img, time_sync)
# ==================== 在这里添加我们的新模块导入 ====================
from .new_modules.repvit import *
from .new_modules.starnet import * # 为后续其他模块预留
from .new_modules.efficientvit import * # 为后续其他模块预留
# ... 你可以继续添加其他自定义模块的导入 ...
# ===================================================================
#LOGGER = logging.getLogger(__name__)
# ... 文件后续内容 ...
- 目的:将
repvit.py
中定义的所有类(如repvit_m2_3
)导入到tasks.py
的全局命名空间中,这样parse_model
函数在解析YAML时才能通过字符串名字找到并实例化它们。
③ 核心改造一:parse_model
函数
在tasks.py
中,使用Ctrl+F
找到parse_model
函数。这是整个魔改工程的核心。我们将分三部分进行修改。
i) 修改读取模型参数部分(增强解析器的灵活性)
这部分修改的目的是让解析器更加健壮和灵活,能够处理更复杂的参数和模块名,为后续所有类型的魔改(包括主干、颈部等)打下基础。
原代码:
for i, (f, n, m, args) in enumerate(d['backbone'] + d['head']): # from, number, module, args
m = getattr(torch.nn, m[3:]) if 'nn.' in m else globals()[m] # get module
for j, a in enumerate(args):
if isinstance(a, str):
with contextlib.suppress(ValueError):
args[j] = locals()[a] if a in locals() else ast.literal_eval(a)
```
* **修改后代码**:
```python
is_backbone = False
for i, (f, n, m, args) in enumerate(d['backbone'] + d['head']): # from, number, module, args
# ==================== 增强的模块获取逻辑 ====================
# 原理:使用 try-except 块来优雅地处理模块查找。
# 原生代码直接使用 globals()[m] 查找,如果m不是一个已知的模块名,程序会报错退出。
# 修改后,我们尝试获取模块,如果失败(比如m是一个我们后续要特殊处理的字符串),
# 就暂时跳过,给予后续代码处理它的机会。
try:
if m == 'node_mode': # 为更复杂的颈部(如GFPN)预留的逻辑
m = d[m]
if len(args) > 0:
if args[0] == 'head_channel':
args[0] = int(d[args[0]])
t = m # 临时保存模块名字符串,用于打印日志
m = getattr(torch.nn, m[3:]) if 'nn.' in m else globals()[m] # get module
except:
pass # 如果在globals()中找不到模块名,暂时忽略
# ==========================================================
# ==================== 增强的参数解析逻辑 ====================
# 原理:同样使用 try-except 块增强鲁棒性。
# 有些参数可能是字符串(如路径),ast.literal_eval 会解析失败。
# 修改后,如果解析失败,就保持其原始的字符串类型。
for j, a in enumerate(args):
if isinstance(a, str):
with contextlib.suppress(ValueError):
try:
args[j] = locals()[a] if a in locals() else ast.literal_eval(a)
except:
args[j] = a # 解析失败时,保留为字符串
# ==========================================================
ii) 添加自定义主干的参数接收逻辑
这是识别我们自定义主干的“秘密握手”。
- 实现思路:我们在
parse_model
函数的循环内部,计算每个模块的输出通道数c2
之后,添加一个判断。如果m
是我们自定义的主干网络类(如repvit_m2_3
),我们就调用它特有的一个方法(我们约定所有自定义主干都实现一个名为channel
的属性或方法)来获取它所有输出层的通道列表。
在c1, c2 = ch[f], args[0]
这行代码之后,添加如下逻辑:
# ... 在 parse_model 函数的循环内 ...
if m in (Classify, Detect, RTDETR, Segment):
# ... 省略 ...
elif m is nn.BatchNorm2d:
# ... 省略 ...
else:
c2 = ch[f] if c2 == -1 else c2
# ==================== 自定义主干接收参数部分 ====================
# 原理:这是识别自定义主干的核心。
# 我们约定,所有即插即用的主干网络模块,都会有一个名为 `channel` 的属性,
# 这个属性返回一个列表,包含了它所有输出特征图的通道数。
# 通过检查模块 m 是否有 'channel' 属性,我们就能识别出它。
if hasattr(globals()[t], 'channel'): # 使用临时变量t(模块名字符串)来检查
# 实例化主干网络。注意,这里的m是模块的类本身。
m = m()
# 获取输出通道列表,例如 [128, 256, 512]
c2 = m.channel
# ==============================================================
m_ = nn.Sequential(*(m(*args) for _ in range(n))) if n > 1 else m(*args) # module
# ... 后续代码 ...
iii) 修改模型实例化部分
这部分是整个改造的“执行”阶段。在这里,我们将真正地区分处理标准层和我们的自定义主干。
原代码:
# 原代码部分一
m_ = nn.Sequential(*(m(*args) for _ in range(n))) if n > 1 else m(*args) # module
t = str(m)[8:-2].replace('__main__.', '') # module type
m_.np = sum(x.numel() for x in m_.parameters()) # number params
m_.i, m_.f, m_.type = i, f, t # attach index, 'from' index, type
if verbose:
LOGGER.info(f'{i:>3}{str(f):>20}{n_:>3}{m_.np:10.0f} {t:<45}{str(args):<30}') # print
save.extend(x % i for x in ([f] if isinstance(f, int) else f) if x != -1) # append to savelist
# 原代码部分二
if i == 0:
ch = []
ch.append(c2)
修改后代码 (整合了ii和iii的逻辑):
# ... 在 c1, c2 = ch[f], args[0] 之后 ...
# 这是更完整的逻辑,取代了ii)中的简单判断
if m in (Conv, ConvTranspose, GhostConv, Bottleneck, GhostBottleneck, SPP, SPPF, DWConv, Focus,
BottleneckCSP, C1, C2, C2f, C3, C3TR, C3Ghost, nn.Conv2d, DWConvTranspose, C3x, RepC3):
c2 = m.get_nc(ch, f, args) # get c2 output channels
# ... (省略其他elif)
# ==================== 修改后的模型实例化 ====================
# --- 步骤 1: 实例化与识别 ---
# 巧妙的识别方法:我们先按常规方式实例化模块。
# 如果 m 是我们的自定义主干,它没有输入参数,直接 m() 即可。
# 然后,我们检查它的 `channel` 属性,得到 `c2`。
# 如果 `c2` 是一个列表,就说明这是一个自定义主干!
if isinstance(c2, list):
# 是自定义主干,is_backbone标志位设为True
is_backbone = True
# m_ 直接就是我们实例化的主干对象 m (在之前的步骤中已经 m=m() )
m_ = m
# 给模块实例动态添加一个 'backbone' 属性,值为 True。
# 目的:这是一个“标记”,为了在后续的前向传播 `_predict_once` 函数中识别它。
m_.backbone = True
else:
# 是标准层,按原逻辑构建
m_ = nn.Sequential(*(m(*args) for _ in range(n))) if n > 1 else m(*args) # module
t = str(m)[8:-2].replace('__main__.', '') # module type
# --- 步骤 2: 计算参数并附加信息 ---
# 计算参数量
m_.np = sum(x.numel() for x in m_.parameters()) # number params
# 附加索引信息。注意这里的 `i+4`
# 原理:我们的自定义主干会输出多个特征图,为了给这些特征图在内部留出索引位置(0,1,2,3),
# 我们将主干模块本身的索引号人为地增加,例如 `0 -> 4`。
# 这样,后续Neck部分的层索引就不会与Backbone的输出索引冲突。
# 4是一个经验值,通常主干输出P2-P5四层特征,但只要比主干输出层数多即可。
m_.i, m_.f, m_.type = i + 4 if is_backbone else i, f, t # attach index, 'from' index, type
if verbose:
LOGGER.info(f'{i:>3}{str(f):>20}{n_:>3}{m_.np:10.0f} {t:<45}{str(args):<30}') # print
# 将需要保存的层的索引添加到 savelist。同样,对主干索引进行偏移。
save.extend(x % (i + 4 if is_backbone else i) for x in ([f] if isinstance(f, int) else f) if x != -1)
# --- 步骤 3: 追踪通道数 ---
if i == 0:
ch = []
# 关键修改!
if isinstance(c2, list):
# 如果 c2 是列表 (我们的主干),则用 extend 将所有输出通道加入 ch 列表
ch.extend(c2)
# 补位操作:确保 ch 列表的长度至少为5。
# 目的:为了与 `_predict_once` 中的逻辑对齐,方便通过索引访问不同尺度的特征。
# 即使某个主干不输出P1, P2层,也用0占位,避免索引错误。
for _ in range(5 - len(ch)):
ch.insert(0, 0)
else:
# 如果是标准层,按原逻辑 append 单个输出通道
ch.append(c2)
# ==============================================================
④ 核心改造二:_predict_once
函数
这个函数负责执行模型的前向传播。我们需要修改它,以便能正确处理我们标记了backbone=True
的特殊模块。
原代码:
def _predict_once(self, x, profile=False, visualize=False, embed=None):
y, dt, embeddings = [], [], [] # outputs
for m in self.model:
if m.f != -1: # if not from previous layer
x = y[m.f] if isinstance(m.f, int) else [x if j == -1 else y[j] for j in m.f] # from earlier layers
if profile:
self._profile_one_layer(m, x, dt)
x = m(x) # run
y.append(x if m.i in self.save else None) # save output
# ... 省略 visualize 和 embed 的代码 ...
return x
修改后代码:
def _predict_once(self, x, profile=False, visualize=False, embed=None):
y, dt, embeddings = [], [], [] # outputs
for m in self.model:
if m.f != -1: # if not from previous layer
x = y[m.f] if isinstance(m.f, int) else [x if j == -1 else y[j] for j in m.f] # from earlier layers
if profile:
self._profile_one_layer(m, x, dt)
# ==================== 自定义主干前向传播逻辑 ====================
# 原理:检查在 parse_model 中添加的 'backbone' 标记。
if hasattr(m, 'backbone'):
# 如果是主干模块,直接调用它,它会返回一个特征图列表
x = m(x)
# 补位操作,与 parse_model 中的逻辑对应。
# 目的:确保输出列表 x 的长度固定,即使主干输出的特征图数量不同,
# 后续的层可以通过固定的索引(如 y[4])来获取特征。
for _ in range(5 - len(x)):
x.insert(0, None) # 用 None 填充不存在的低层特征
# 遍历主干输出的每一层特征图
for i_idx, i in enumerate(x):
# 根据 savelist 判断这一层是否需要保存给后续层使用
if i_idx in self.save:
y.append(i)
else:
y.append(None) # 如果不需要,用 None 占位
# 将主干的最后一层输出作为下一个模块的输入
x = x[-1]
else:
# 如果是标准层,执行原有的逻辑
x = m(x) # run
y.append(x if m.i in self.save else None) # save output
# ==============================================================
if visualize:
feature_visualization(x, m.type, m.i, save_dir=visualize)
if embed and m.i in embed:
# ... (省略)
return x
如此修改后,模型的网络结构就会发生变化:
四、颈部(Neck)的替换
我们已经成功地改造了tasks.py
,建立了一个强大的、可兼容自定义主干的框架。现在,我们将利用这个框架,对模型的“颈部”进行替换。
1-GFPN (GiraffeDet FPN)
GFPN(长颈鹿特征金字塔网络)通过其独特的、类似长颈鹿脖子的交错连接方式,高效地融合深层语义信息和浅层空间信息。我们将用它来替换YOLOv8原生的PANet结构。
① 文件准备与引用(此步骤与之前一致)
- 代码:
Neck-GFPN.py
->new_modules/GFPN.py
- 配置:
GFPN-P345.yaml
->cfg/models/v8/
- 引用:在
tasks.py
中添加from .new_modules.GFPN import *
② GFPN的YAML配置与parse_model
的联动
思考:GFPN是如何被我们的新parse_model
函数解析的?
让我们深入GFPN-P345.yaml
和tasks.py
的代码;。
第一步:分析
GFPN-P345.yaml
的配置打开
GFPN-P345.yaml
,你会发现它巧妙地使用变量来定义网络结构,这是一种非常优雅的工程实践。# ultralytics/cfg/models/v8/GFPN-P345.yaml (内容示例) # ------------------ YAML 顶层参数定义 ------------------ # 定义了颈部和头部的默认通道数 widen_factor: 1.0 head_channel: 256 # 核心:定义了颈部中重复使用的核心模块的名称 # 这样做的好处是,如果想把所有 CSPStage 换成其他模块, # 只需修改下面这一行,无需改动 head 中的每一处。 node_mode: CSPStage # ... (nc, depth_multiple, width_multiple 等定义) # ------------------ Backbone (主干) ------------------ backbone: - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2 # ... (主干网络定义,这里可能是标准的YOLOv8主干,也可能是我们之前替换的RepViT等) # 假设主干的第4、6、9层分别输出P3, P4, P5 特征 # ------------------ Head (颈部 + 头部) ------------------ head: # in_channels: [256, 512, 1024] # 来自Backbone的 P3, P4, P5 # out_channels: [256, 512, 1024] # --- GFPN 颈部结构 --- - [-1, 1, Conv, [256, 1, 1]] # 10 - [-1, 1, nn.Upsample, [None, 2, 'nearest']] # 11 - [[-1, 6], 1, Concat, [1]] # 12 (Concat P4) - [-1, 3, node_mode, [head_channel, 3]] # 13 <--- 关键!使用了node_mode - [-1, 1, Conv, [256, 1, 1]] # 14 - [-1, 1, nn.Upsample, [None, 2, 'nearest']] # 15 - [[-1, 4], 1, Concat, [1]] # 16 (Concat P3) - [-1, 3, node_mode, [head_channel, 3]] # 17 <--- 关键!使用了node_mode - [-1, 1, Conv, [256, 3, 2]] # 18 - [[-1, 14], 1, Concat, [1]] # 19 - [-1, 3, node_mode, [head_channel, 3]] # 20 <--- 关键!使用了node_mode - [-1, 1, Conv, [256, 3, 2]] # 21 - [[-1, 10], 1, Concat, [1]] # 22 - [-1, 3, node_mode, [head_channel, 3]] # 23 <--- 关键!使用了node_mode # --- Detect Head --- - [[17, 20, 23], 1, Detect, [nc]] # Detect(P3, P4, P5)
第二步:追踪
parse_model
的执行流程当
parse_model
函数解析到第13层[-1, 3, node_mode, [head_channel, 3]]
时,我们之前修改的代码开始发挥作用:# 在 ultralytics/nn/tasks.py 的 parse_model 中 # 此时,循环变量的值为: # i = 13, f = -1, n = 3, m = 'node_mode', args = ['head_channel', 3] try: # 1. 检查到 m == 'node_mode',条件成立 if m == 'node_mode': # 2. 将 m 的值从字符串 'node_mode' 替换为 YAML 顶层定义的实际值 # m = d['node_mode'] --> m 变成了 'CSPStage' m = d[m] # 3. 检查 args 列表 if len(args) > 0: # len(['head_channel', 3]) > 0, 成立 # 4. 检查第一个参数是否为 'head_channel' if args[0] == 'head_channel': # 成立 # 5. 将 args[0] 从字符串 'head_channel' 替换为 YAML 顶层定义的实际值 # args[0] = int(d['head_channel']) --> args[0] 变成了整数 256 args[0] = int(d[args[0]]) # 经过处理后,模块定义从 'node_mode', ['head_channel', 3] # 变成了实际的 'CSPStage', [256, 3] t = m # t 被赋值为 'CSPStage' # 6. 最后,通过 globals()['CSPStage'] 找到我们导入的 CSPStage 类 m = getattr(torch.nn, m[3:]) if 'nn.' in m else globals()[m] except: pass # ... 后续代码将使用 m = CSPStage 类, args = [256, 3] 来实例化模块
结论:我们为
parse_model
添加的try-except
和if m == 'node_mode'
逻辑,本质上是创建了一个“宏替换”机制。它使得YAML的编写者可以像定义宏一样预设模块名和参数,极大地增强了配置文件的灵活性和复用性。
③ 魔改前后模型参数对比
我们可以通过打印模型结构来直观地看到变化。
魔改前模型结构 (标准 YOLOv8 Neck)
# yolov8.yaml summary: 225 layers, 3157200 parameters, 3157184 gradients, 8.9 GFLOPs
... (backbone)
10 -1 1 ultralytics.nn.modules.conv.Conv [512, 1, 1]
11 -1 1 torch.nn.modules.upsampling.Upsample [None, 2, 'nearest']
12 [-1, 6] 1 ultralytics.nn.modules.container.Concat [1]
13 -1 3 ultralytics.nn.modules.block.C2f [512, True]
14 -1 1 ultralytics.nn.modules.conv.Conv [256, 1, 1]
15 -1 1 torch.nn.modules.upsampling.Upsample [None, 2, 'nearest']
16 [-1, 4] 1 ultralytics.nn.modules.container.Concat [1]
17 -1 3 ultralytics.nn.modules.block.C2f [256, True]
18 -1 1 ultralytics.nn.modules.conv.Conv [256, 3, 2]
19 [-1, 14] 1 ultralytics.nn.modules.container.Concat [1]
20 -1 3 ultralytics.nn.modules.block.C2f [512, True]
21 -1 1 ultralytics.nn.modules.conv.Conv [512, 3, 2]
22 [-1, 10] 1 ultralytics.nn.modules.container.Concat [1]
23 -1 3 ultralytics.nn.modules.block.C2f [1024, True]
24 [17, 20, 23] 1 ultralytics.nn.modules.head.Detect [80]
魔改后模型结构 (GFPN-P345 Neck)
# GFPN-P345.yaml summary: 237 layers, 3348644 parameters, 3348628 gradients, 9.2 GFLOPs
... (backbone)
10 -1 1 ultralytics.nn.modules.conv.Conv [256, 1, 1]
11 -1 1 torch.nn.modules.upsampling.Upsample [None, 2, 'nearest']
12 [-1, 6] 1 ultralytics.nn.modules.container.Concat [1]
13 -1 3 new_modules.GFPN.CSPStage [256, 3] # <-- 核心模块被替换
14 -1 1 ultralytics.nn.modules.conv.Conv [256, 1, 1]
15 -1 1 torch.nn.modules.upsampling.Upsample [None, 2, 'nearest']
16 [-1, 4] 1 ultralytics.nn.modules.container.Concat [1]
17 -1 3 new_modules.GFPN.CSPStage [256, 3] # <-- 核心模块被替换
18 -1 1 ultralytics.nn.modules.conv.Conv [256, 3, 2]
19 [-1, 14] 1 ultralytics.nn.modules.container.Concat [1]
20 -1 3 new_modules.GFPN.CSPStage [512, 3] # <-- 核心模块被替换
21 -1 1 ultralytics.nn.modules.conv.Conv [512, 3, 2]
22 [-1, 10] 1 ultralytics.nn.modules.container.Concat [1]
23 -1 3 new_modules.GFPN.CSPStage [1024, 3] # <-- 核心模块被替换
24 [17, 20, 23] 1 ultralytics.nn.modules.head.Detect [80]
通过对比可以清晰地看到,原生的C2f
模块被我们自定义的CSPStage
(来自GFPN.py
)所取代,这证明我们的魔改成功了。
五、头部(Head)的革新
1-DyHead (Dynamic Head)
DyHead通过统一的注意力机制,动态地选择最重要的特征,极大地提升了检测头的表征能力。
① 文件准备与引用(同上)
- 代码:
Head-DyHead.py
->new_modules/DyHead.py
- 配置:
DyHead-P345.yaml
->cfg/models/v8/
- 引用:
tasks.py
中添加from .new_modules.DyHead import *
② 核心解析:DyHead的“无缝”集成
思考:DyHead为什么不需要对tasks.py
做任何新的修改?
答案在于,DyHead的设计模式与YOLO原生Detect
头高度兼容,并且我们之前对parse_model
的通用化改造已经能够处理它。
第一步:分析DyHead-P345.yaml
的结构
DyHead模块将替换掉原Detect
层以及之前的一些卷积层。
# DyHead-P345.yaml (head部分示例)
head:
# ... (颈部融合层)
# 假设颈部输出三层特征分别在索引 17, 20, 23
# 原生YOLOv8中,这里会有一系列解耦的卷积层,最后连接Detect层
# 使用DyHead后,直接将三层特征输入给DyHead模块
- [[17, 20, 23], 1, DyHead, [nc]] # <--- 直接替换
第二步:追踪_predict_once
的执行流程
当模型前向传播到DyHead
层时:
# 在 ultralytics/nn/tasks.py 的 _predict_once 中
# 此时,m 是实例化的 DyHead 对象,m.f 是 [17, 20, 23]
# 1. 进入获取输入的逻辑
if m.f != -1: # 成立
# 2. m.f 是一个列表, 执行 else 分支
# 这行代码会从 y 列表中,根据索引 17, 20, 23,
# 取出对应的三层特征图张量,并打包成一个新的列表 `x`
# x = [y[17], y[20], y[23]]
x = y[m.f] if isinstance(m.f, int) else [x if j == -1 else y[j] for j in m.f]
# 3. 检查 'backbone' 属性, DyHead没有这个标记, 跳过
if hasattr(m, 'backbone'):
...
else:
# 4. 执行常规的前向传播
# 将包含三个特征图的列表 x, 整体传递给 DyHead 模块
# x = m(x) 等价于 outputs = dyhead_instance([feature_p3, feature_p4, feature_p5])
x = m(x)
y.append(x if m.i in self.save else None)
结论:DyHead
模块本身被设计为接收一个特征图列表作为输入。而YOLO的_predict_once
函数中,处理多输入的from
(如[-1, 6]
或[17, 20, 23]
)的逻辑,天然地就会将多个来源的特征图打包成一个列表。两者一拍即合,实现了无缝对接。我们不需要为DyHead
编写任何特殊的解析或执行代码。
六、注意力机制(Attention)的融合
欢迎来到“魔改”系列中最灵活、最有趣的部分——集成注意力机制。
核心思想与类比:想象一下,当您在一张杂乱的桌面上寻找钥匙时,您的大脑并不会平均地扫描每一个平方厘米。相反,您的目光会自动聚焦于桌面上的高亮区域,比如金属反光处、颜色鲜艳的物体旁。注意力机制(Attention Mechanism)赋予了神经网络类似的能力。它让模型在处理海量信息时,能够智能地“聚焦”于最关键的特征,并“忽略”次要或无关的背景,从而用有限的计算资源做出更精准的判断。
本节,我们将以CBAM (Convolutional Block Attention Module) 为例,进行一次完整、详尽的“即插即用”式集成。我们将一起分析其代码原理,选择合适的插入位置,完成一次无死角的YAML文件修改,并最终验证我们的工作。
1. 深入理解CBAM模块
在动手之前,我们先快速理解CBAM的工作原理,这将有助于我们决定将它放在网络中的哪个位置。
CBAM由两个串联的子模块组成:
- 通道注意力模块 (Channel Attention):它回答的问题是“什么特征更重要?”。比如,在一个人像识别任务中,包含“眼睛”、“鼻子”等信息的特征通道,其重要性就应该高于包含“背景墙壁”信息的通道。该模块会学习一个权重,对各个通道进行加权,增强重要特征,抑制次要特征。
- 空间注意力模块 (Spatial Attention):它回答的问题是“特征图的哪个位置更重要?”。在识别出一张人脸后,其五官所在的位置显然比背景区域更关键。该模块会生成一个空间“热力图”,告诉网络应该重点关注特征图上的哪些像素区域。
最重要的特性:CBAM模块的forward
函数接收一个张量x
,经过内部一系列计算后,输出一个与x
尺寸完全相同 的张量。这正是它能被“即插即用”的关键。
2. 集成CBAM
① 准备工作
- 代码: 将
yolo_master/.../Attention-CBAM.py
复制到ultralytics/ultralytics/nn/new_modules/
并重命名为CBAM.py
。 - 引用: 在
ultralytics/nn/tasks.py
的顶部添加from .new_modules.CBAM import *
。
② CBAM应该放在哪里?
这是一个开放性问题,但有一些常用的策略:
- 放在特征提取之后:通常将注意力模块放置在核心特征提取块(如
C2f
)之后。这样做的好处是,C2f
已经产生了丰富的特征组合,此时使用CBAM可以立刻对这些新特征进行“精炼”和“筛选”,让最有用的信息传递给下一层。 - 放在下采样之前:在网络通过步进卷积(
Conv
)进行下采样、缩小特征图尺寸之前,使用CBAM可以确保在信息被压缩前,关键特征已经被充分“关注”,减少重要信息的丢失。
本教程决策:我们将遵循以上策略,选择在Backbone的第4个模块(一个C2f
层)之后,第5个模块(一个Conv
下采样层)之前插入CBAM。这个位置非常理想,它能精炼P3级别的特征,再将其传递给更深的网络。
③ YAML修改
这是最关键的一步。我们将以yolov8n.yaml
为蓝本,创建yolov8n-CBAM.yaml
。
第一步:复制并重命名
yolov8n.yaml
为yolov8n-CBAM.yaml
。第二步:进行修改。 下面是完整的修改前后对比,所有改动都用注释明确标出。
yolov8n.yaml
(原始文件)
# ultralytics/cfg/models/v8/yolov8n.yaml
nc: 80 # number of classes
depth_multiple: 0.33 # model depth multiple
width_multiple: 0.25 # layer channel multiple
# anchors
anchors:
- [10,13, 16,30, 33,23] # P3/8
- [30,61, 62,45, 59,119] # P4/16
- [116,90, 156,198, 373,326] # P5/32
# YOLOv8.0n backbone
backbone:
# [from, number, module, args]
- [-1, 1, Conv, [64, 3, 2]] # 0-P1/2
- [-1, 1, Conv, [128, 3, 2]] # 1-P2/4
- [-1, 3, C2f, [128, True]] # 2
- [-1, 1, Conv, [256, 3, 2]] # 3-P3/8
- [-1, 6, C2f, [256, True]] # 4
- [-1, 1, Conv, [512, 3, 2]] # 5-P4/16
- [-1, 6, C2f, [512, True]] # 6
- [-1, 1, Conv, [1024, 3, 2]] # 7-P5/32
- [-1, 3, C2f, [1024, True]] # 8
- [-1, 1, SPPF, [1024, 5]] # 9
# YOLOv8.0n head
head:
- [-1, 1, nn.Upsample, [None, 2, 'nearest']] # 10
- [[-1, 6], 1, Concat, [1]] # 11
- [-1, 3, C2f, [512, False]] # 12
- [-1, 1, nn.Upsample, [None, 2, 'nearest']] # 13
- [[-1, 4], 1, Concat, [1]] # 14
- [-1, 3, C2f, [256, False]] # 15
- [-1, 1, Conv, [256, 3, 2]] # 16
- [[-1, 12], 1, Concat, [1]] # 17
- [-1, 3, C2f, [512, False]] # 18
- [-1, 1, Conv, [512, 3, 2]] # 19
- [[-1, 9], 1, Concat, [1]] # 20
- [-1, 3, C2f, [1024, False]]# 21
- [[15, 18, 21], 1, Detect, [nc]] # Detect(P3, P4, P5)
yolov8n-CBAM.yaml
(修改后的文件)
# ultralytics/cfg/models/v8/yolov8n-CBAM.yaml
# ... (nc, depth_multiple, width_multiple, anchors 定义与上面完全相同) ...
# YOLOv8.0n backbone with CBAM
backbone:
# [from, number, module, args]
- [-1, 1, Conv, [64, 3, 2]] # 0
- [-1, 1, Conv, [128, 3, 2]] # 1
- [-1, 3, C2f, [128, True]] # 2
- [-1, 1, Conv, [256, 3, 2]] # 3
- [-1, 6, C2f, [256, True]] # 4
- [-1, 1, CBAM, [256]] # 5 <--- 新增CBAM层. 它接收第4层的256通道输出
- [-1, 1, Conv, [512, 3, 2]] # 6 (原索引为5)
- [-1, 6, C2f, [512, True]] # 7 (原索引为6)
- [-1, 1, Conv, [1024, 3, 2]] # 8 (原索引为7)
- [-1, 3, C2f, [1024, True]] # 9 (原索引为8)
- [-1, 1, SPPF, [1024, 5]] # 10 (原索引为9)
# YOLOv8.0n head with updated indices
head:
- [-1, 1, nn.Upsample, [None, 2, 'nearest']] # 11
# Concat 融合 Neck P5 和 Backbone P4. Backbone P4 现在是第7层
- [[-1, 7], 1, Concat, [1]] # 12 (原为[-1, 6]) <--- 索引更新!
- [-1, 3, C2f, [512, False]] # 13
- [-1, 1, nn.Upsample, [None, 2, 'nearest']] # 14
# Concat 融合 Neck P4 和 Backbone P3. Backbone P3 的输出现在经过了第4层的C2f和第5层的CBAM,所以我们从第5层引出
- [[-1, 5], 1, Concat, [1]] # 15 (原为[-1, 4]) <--- 索引更新!
- [-1, 3, C2f, [256, False]] # 16
- [-1, 1, Conv, [256, 3, 2]] # 17
# Concat 融合 Neck P3 和 Neck P4(第13层)
- [[-1, 13], 1, Concat, [1]] # 18 (原为[-1, 12]) <--- 索引更新!
- [-1, 3, C2f, [512, False]] # 19
- [-1, 1, Conv, [512, 3, 2]] # 20
# Concat 融合 Neck P4 和 Backbone P5(第10层)
- [[-1, 10], 1, Concat, [1]] # 21 (原为[-1, 9]) <--- 索引更新!
- [-1, 3, C2f, [1024, False]] # 22
# Detect 层的输入来自第16, 19, 22层
- [[16, 19, 22], 1, Detect, [nc]] # Detect(P3, P4, P5) (原为[15, 18, 21]) <--- 索引更新!
(4) 追踪parse_model
如何处理CBAM
让我们看看parse_model
解析新增的第5层[-1, 1, CBAM, [256]]
时,发生了什么。
- 确定输入通道
c1
:from
是-1
,所以c1
来自上一层(第4层)的输出。我们知道第4层C2f
的输出通道是256,所以c1 = 256
。 - 确定模块
m
:m
是我们导入的CBAM
类。 - 确定参数
args
:args
是列表[256]
。 - 实例化模块
m_
:对于CBAM
这样的通用模块,parse_model
会执行m(c1, *args)
来实例化。这会调用CBAM(256, 256)
。 - 确定输出通道
c2
:对于通用模块,parse_model
会默认将args[0]
作为输出通道数,即c2 = 256
。 - 更新通道列表
ch
:执行ch.append(c2)
,将256添加到通道列表中。
结论:由于CBAM输入和输出通道数相同(c1=c2=256
),它完美地融入了网络的数据流,对后续层的通道数计算没有任何影响。唯一的、也是最容易出错的复杂性,在于手动更新所有后续层(尤其是Concat
和Detect
层)的from
索引。
(5) 可视化对比:魔改前后的模型结构
下面是模拟model.info()
命令输出的文本,可以清晰地看到变化。
魔改前模型结构 (yolov8n.yaml)
# ... (层 0-3)
4 -1 6 ultralytics.nn.modules.block.C2f 119296 (256, 128, 6, True, False, 1, 0.5)
5 -1 1 ultralytics.nn.modules.conv.Conv 147968 (512, 256, 3, 2)
6 -1 6 ultralytics.nn.modules.block.C2f 476160 (512, 256, 6, True, False, 1, 0.5)
# ...
11 [-1, 6] 1 ultralytics.nn.modules.container.Concat 0 (1)
# ...
14 [-1, 4] 1 ultralytics.nn.modules.container.Concat 0 (1)
# ...
22 [15, 18, 21] 1 ultralytics.nn.modules.head.Detect 649920 (80, (256, 512, 1024))
魔改后模型结构 (yolov8n-CBAM.yaml)
# ... (层 0-3)
4 -1 6 ultralytics.nn.modules.block.C2f 119296 (256, 128, 6, True, False, 1, 0.5)
+ 5 -1 1 new_modules.CBAM.CBAM 704 (256, None) # <--- 新增层,参数量极小
6 -1 1 ultralytics.nn.modules.conv.Conv 147968 (512, 256, 3, 2)
7 -1 6 ultralytics.nn.modules.block.C2f 476160 (512, 256, 6, True, False, 1, 0.5)
# ...
- 11 [-1, 6] 1 ultralytics.nn.modules.container.Concat 0 (1)
+ 12 [-1, 7] 1 ultralytics.nn.modules.container.Concat 0 (1) # <--- 索引更新
# ...
- 14 [-1, 4] 1 ultralytics.nn.modules.container.Concat 0 (1)
+ 15 [-1, 5] 1 ultralytics.nn.modules.container.Concat 0 (1) # <--- 索引更新
# ...
- 22 [15, 18, 21] 1 ultralytics.nn.modules.head.Detect 649920 (80, (256, 512, 1024))
+ 23 [16, 19, 22] 1 ultralytics.nn.modules.head.Detect 649920 (80, (256, 512, 1024)) # <--- 索引更新
通过这个对比,我们可以百分之百地确认,我们的CBAM模块已成功插入,并且整个模型的后续连接也已正确更新。对于SE模块的集成,过程与此完全相同,不再赘述。
七、核心组件的优化
在掌握了对Backbone、Neck、Head三大件的“大刀阔斧”式改造后,我们再来学习如何对网络中的基础“零件”——如上下采样和卷积模块——进行“精雕细琢”的单元。这要求我们更深入地理解YOLO的parse_model
函数是如何处理标准模块的。
1. 上下采样模块:EUCB (Efficient Up-sampling with Channel Balancing)
背景与目的:在特征金字塔网络(FPN)中,上采样负责将高层(小尺寸、强语义)的特征图放大,以便与低层(大尺寸、强细节)的特征图融合。YOLOv8默认使用的nn.Upsample
(配合mode='nearest'
)虽然速度极快,但它是一种固定的、非学习性的插值方法,仅仅是简单地复制像素,可能会在放大过程中产生伪影或丢失细节。
EUCB (Efficient Up-sampling with Channel Balancing) 旨在解决这个问题。它是一种可学习的上采样模块。这意味着网络可以通过反向传播,学习到如何以最优的方式从低分辨率特征中“生成”高分辨率特征,同时还能调整通道数量,从而可能带来更平滑、更有效的特征融合效果。
① 集成步骤
- 代码: 将
yolo_master/.../Upsample-EUCB.py
复制到ultralytics/ultralytics/nn/new_modules/
并重命名为EUCB.py
。 - 引用: 在
tasks.py
的顶部添加from .new_modules.EUCB import *
。
② 核心解析:EUCB的参数与parse_model
的自动适配
思考:nn.Upsample
和 EUCB
在参数上有何不同?parse_model
如何处理这种不同?
第一步:分析模块的
__init__
签名torch.nn.Upsample
的定义很简单,它只关心缩放因子和模式,不改变通道数。其YAML中的args
为[None, 2, 'nearest']
,None
代表输出尺寸(由scale_factor=2
决定),通道数保持不变。我们打开
new_modules/EUCB.py
文件(或根据其用法推断),可以发现EUCB
模块的定义更像一个卷积层。一个合理的__init__
签名应该是:def __init__(self, c1, c2, scale_factor=2):
。c1
: 输入通道数。c2
: 输出通道数。这是与nn.Upsample
最大的不同。scale_factor
: 缩放因子。
第二步:修改YAML文件并追踪解析过程
现在,我们在
yolov8.yaml
的head
部分进行替换。# yolov8-EUCB.yaml (head 部分示例) head: # ... # 假设解析到第14层,其输入来自第13层(C2f),输出通道数为512 # ch = [..., 512] # 第15层: 上采样层 # --- 原代码 --- # - [-1, 1, nn.Upsample, [None, 2, 'nearest']] # 15 # --- 修改后 --- # 我们希望上采样后,通道数从512变为256 - [-1, 1, EUCB, [256, 2]] # 15. args为[输出通道数, 缩放因子] # 第16层: Concat层 # 它的输入来自上一层(第15层)和主干的第4层 (假设通道数为256) - [[-1, 4], 1, Concat, [1]] # 16 # 第17层: C2f层 # 它的输入来自第16层的Concat。Concat后的通道数 = 256(来自EUCB) + 256(来自Backbone) = 512 - [-1, 3, C2f, [256, False]] # 17. 该C2f层输出通道数为256 # ...
当
parse_model
解析到我们修改的第15层[-1, 1, EUCB, [256, 2]]
时,其标准模块处理逻辑会执行以下操作:# 在 ultralytics/nn/tasks.py 的 parse_model 中 # 此时: m = EUCB 类, args = [256, 2] # 假设上一层的输出通道数 ch[-1] 是 512 # 1. 获取输入通道数 c1 # c1 = ch[f] --> c1 = ch[-1] --> c1 = 512 c1 = ch[f] # 2. 获取输出通道数 c2 # c2 = args[0] if isinstance(args[0], int) else ... # 这里的 args[0] 是 256, 是整数。所以 c2 被赋值为 256 c2 = args[0] # 3. 实例化模块 # m_ = m(*args) 等价于 m_ = EUCB(512, 256, 2) # 注意!这里的`*args`展开会把所有参数传进去,所以我们的模块定义要与之匹配 # 一个更严谨的写法是在YAML中只写输出通道数,让模块内部处理 # 假设YAML为 [-1, 1, EUCB, [256]] # 那么实例化将是 m_ = EUCB(c1, *args) --> EUCB(512, 256) m_ = nn.Sequential(...) if n > 1 else m(c1, *args) # 注意这里隐式的 c1 参数 # 4. 更新通道列表 ch # ch.append(c2) --> ch.append(256) # 现在 ch 列表的最后一个元素是 256,供下一层使用 ch.append(c2)
结论:我们不需要为
EUCB
编写任何特殊解析代码。只要一个模块遵循“接收输入通道c1
,并通过args
接收其他参数(包括输出通道c2
)”这一标准模式,parse_model
的通用逻辑就能自动完成输入/输出通道的推断和模块的正确实例化。
③ 魔改前后模型结构对比
魔改前 (使用 nn.Upsample)
# ...
# 假设第14层输出512通道
14 ... [..., 512, True]
15 -1 1 torch.nn.modules.upsampling.Upsample [None, 2, 'nearest']
# 第15层输出通道仍为512
# ...
魔改后 (使用 EUCB)
# ...
# 第14层输出512通道
14 ... [..., 512, True]
15 -1 1 new_modules.EUCB.EUCB [512, 256, 2]
# 第15层输出通道变为256,参数量增加,因为它有可学习的权重
# ...
2. 卷积模块:C2f_CMUNeXtBlock
这是对YOLOv8中最重要的特征提取单元C2f
的直接替换。CMUNeXtBlock
可能借鉴了ConvNeXt
的设计,例如使用更大的卷积核、深度可分离卷积等,旨在用相似的参数量换取更强的特征表达能力。
① 集成步骤(同上)
- 代码:
C2f_CMUNeXtBlock.py
->new_modules/C2f_CMUNeXtBlock.py
。 - 引用: 在
tasks.py
中添加from .new_modules.C2f_CMUNeXtBlock import *
。
② 实现“无痛”替换
思考:为什么C2f_CMUNeXtBlock
可以如此轻易地替换C2f
?
答案在于接口兼容性。C2f_CMUNeXtBlock
在设计时,刻意模仿了C2f
的__init__
参数签名,使得它可以直接使用C2f
在YAML文件中的参数定义。
第一步:对比
__init__
签名C2f
的签名(简化后):__init__(self, c1, c2, n=1, shortcut=False, ...)
C2f_CMUNeXtBlock
的签名(推断):__init__(self, c1, c2, n=1, shortcut=False, ...)
只要两者都接收相同的核心参数(输入通道
c1
,输出通道c2
,重复次数n
,快捷连接shortcut
),它们在YAML层面就是可互换的。第二步:修改YAML
这个修改是最简单的,只需更换模块名即可。# yolov8-CMUNeXt.yaml (backbone 部分示例) backbone: - [-1, 1, Conv, [64, 3, 2]] # 0 - [-1, 1, Conv, [128, 3, 2]] # 1 # --- 原代码 --- # - [-1, 3, C2f, [128, True]] # 2 # --- 修改后 --- - [-1, 3, C2f_CMUNeXtBlock, [128, True]] # 2. 参数完全相同!
第三步:追踪
parse_model
的执行流程
这个流程与解析C2f
时完全一样。parse_model
获取模块名C2f_CMUNeXtBlock
。- 它从
ch
列表获取输入通道c1
。 - 它从
args
([128, True]
)中获取输出通道c2=128
以及其他参数。 - 它根据
n=3
,循环3次来实例化模块。 - 它将输出通道
c2=128
添加到ch
列表中。
整个过程行云流水,因为C2f_CMUNeXtBlock
完美地扮演了C2f
的角色。
结论:对于想要替换现有标准模块的自定义模块,最佳实践就是让新模块的参数接口与旧模块保持高度一致。这样就能实现“无痛”替换,将修改成本降至最低,仅需更改YAML中的一个字符串。
OK,今天我们就学习到这里🏆🎉👌!
文章参考
- YOLO系列官方论文:
- 核心项目与代码:
拓展阅读
- Ultralytics GitHub 仓库 (YOLOv3/v5/v8 的主流实现)
- 作者的算法专栏 (包含更多YOLO技术文章)
💖 感谢您的耐心阅读!
如果您觉得本文对您理解和实践YOLO模型改造有所帮助,请考虑点赞、收藏或分享给更多有需要的朋友。您的支持是我持续创作优质内容的动力!欢迎在评论区交流讨论,共同进步。