项目笔记 | YOLO26-MultiModal 单模态改双模态
[TOC]
YOLO26 单模态改多模态(RGB+IR 双流)完整改造笔记
一、背景与目标
标准 YOLO 检测模型只接受单一模态输入。我们要做融合任务,需要将 YOLO 改造为 多模态双流架构,即使之可以同时接收 RGB 和 IR 两种模态的输入,在不同层前插入融合模块,通过特征融合提升检测性能。
本笔记以 Ultralytics 家族中的 YOLOv26 为基线,详细记录了从单模态改为多模态的全部流程。本修改基于的基线 commit 为 7710ef05d,不出意外的话(指的是Ultralytics 修改的代码不与改版 YOLO 相关代码冲突)可以正常无脑 merge 上游,同时体验到 YOLO 的最新特性和本改版的多模态双流架构。即使冲突基本也可手动处理。
改造涉及以下层面:
- 数据加载层:让数据管线能同时加载 RGB 和 IR 图像,拼成 6 通道张量
- 网络算子层:新增
Multiin(通道拆分)和IN(索引占位)两个基础模块 - 模型解析层:修改
parse_model使其能解析双流 YAML 配置,包括新的fusion段 - 模型配置层:编写(实质上为迁移)各种融合策略的 YAML 文件(Early / Mid / Late / TransformerFusionBlock 等)
- 可视化层:训练和验证阶段正确绘制双流图片
- 工程层面:DDP 训练兼容、优化器兼容、数据集标签路径映射
二、整体架构概览
三、Step 1:数据加载
3.1 数据加载的“前世今生”
标准 YOLO 的 BaseDataset 只加载单张图像(3 通道)。要做双流,需要:
- 根据 RGB 图像路径自动推断对应 IR 图像的路径
- 将两者合并为一个 6 通道的 numpy 数组,作为模型的输入
- 在 batch 排序(rectangular training)时保持 RGB 和 IR 文件的一一对应
3.2 修改 ultralytics/data/base.py
这是核心改动。在 BaseDataset.__init__ 中,当检测到 channels > 3 时,自动启动推断 IR 文件列表;在 get_image 中读取并合并两种图像。
这里我们规定:RGB 图像在数据集目录下的文件夹是“images”,修改使之在channels > 3时自动在字符串后面追加为”images_ir”(或是可以在数据集配置文件里显式指定)。简而言之,规定 RGB 图像对应“images”,IR 图像对应“images_ir”。
diff --git a/ultralytics/data/base.py b/ultralytics/data/base.py--- a/ultralytics/data/base.py+++ b/ultralytics/data/base.py@@ -85,6 +85,7 @@ class BaseDataset(Dataset): classes: list[int] | None = None, fraction: float = 1.0, channels: int = 3,+ ir_dir: str = "images_ir", ): """Initialize BaseDataset with given configuration and options.""" super().__init__()@@ -115,6 +117,14 @@ class BaseDataset(Dataset): self.channels = channels self.cv2_flag = cv2.IMREAD_GRAYSCALE if channels == 1 else cv2.IMREAD_COLOR self.im_files = self.get_img_files(self.img_path)+ # Dual-stream: auto-infer IR image paths when channels > 3 (e.g. 6 for RGB+IR)+ self.ir_files = []+ self.ir_dir = ir_dir+ if self.channels > 3:+ self.ir_files = [+ f.replace(os.sep + "images" + os.sep, os.sep + self.ir_dir + os.sep) for f in self.im_files+ ]+ self._verify_multimodal_files() self.labels = self.get_labels() self.update_labels(include_class=classes) self.ni = len(self.labels)解释:
ir_dir参数允许在data.yaml中指定 IR 图像所在的文件夹名,默认为images_ir- 通过简单的字符串替换
images→images_ir推断 IR 路径 - 调用
_verify_multimodal_files()在启动时就检查所有 IR 文件是否存在,避免训练中途报错
新增验证方法:
def _verify_multimodal_files(self): """Verify that all inferred multimodal (e.g., IR) files exist.""" if not self.ir_files: return missing = [f for f in self.ir_files if not Path(f).exists()] if missing: raise FileNotFoundError( f"{self.prefix}Dual-stream dataset validation failed. {len(missing)} IR images are missing. " f"First missing file: {missing[0]}\n" f"Expected IR directory: '{self.ir_dir}'\n" f"Make sure you have correctly paired IR images for all RGB images." ) LOGGER.info(f"{self.prefix}Dual-stream dataset validation passed. Found {len(self.ir_files)} IR images.")在 get_image 中合并 RGB 和 IR,拼接成 6 维数组:
@@ -233,6 +239,11 @@ class BaseDataset(Dataset): else: # read image im = imread(f, flags=self.cv2_flag) # BGR+ # Dual-stream: merge IR image with RGB when channels > 3+ if self.channels > 3 and self.ir_files:+ im_ir = imread(self.ir_files[i], flags=self.cv2_flag)+ if im_ir is not None and im is not None:+ im = cv2.merge((*cv2.split(im), *cv2.split(im_ir))) # RGB(3ch) + IR(3ch) = 6ch解释:使用 cv2.merge 将 RGB 的 3 个通道和 IR 的 3 个通道拼接成 6 通道。顺序是 RGB 在前(0-2),IR 在后(3-5)。这个顺序很重要,后续 Multiin(1) 取前 3 通道为 RGB,Multiin(2) 取后 3 通道为 IR。
关于图像通道数:
在技术原理和本项目实现中,红外(IR)图像的通道数需要从两个层面来理解:
物理层面通常是单通道(灰度图)
在绝大多数红外热成像传感器中,输出的是**单通道(1-channel)**的灰度图,表示每个像素点的辐射强度(热量)。
- 本项目实现方法层面为处理成三通道
虽然原始图像是单通道,但在代码实现中,我们将其读取并处理为三通道。原因如下:
- OpenCV 的读取机制:在
base.py和loaders.py中,我们使用了cv2.IMREAD_COLOR标志。当 OpenCV 读取一张灰度图(IR 图像通常以灰度图形式存储在磁盘上)时,它会自动将这一个通道的数据复制三份,变成三个完全相同的通道(R=G=B)。- 网络结构的通用性:为了能让红外流(IR Stream)直接复用基线 YOLO 的 Backbone 结构(例如第一个卷积层通常是输入 3 通道的),我们将 1 通道的红外图转为 3 通道。这样 RGB 分支和 IR 分支的结构可以完全镜像,方便特征融合。
- 6 通道拼接逻辑:
- RGB 流:占用通道 0, 1, 2。
- IR 流:占用通道 3, 4, 5(这三个通道的内容目前是完全一样的)。
如果想更激进地节省计算量,也可以把网络和数据加载改为 4 通道(3 RGB + 1 IR),但这样会导致网络的第一层必须重新定义模型参数,且无法直接使用一些针对 3 通道优化的库。据此来看目前采用 3+3=6 的方案是最稳妥、改动代价最小的做法。
保持 rectangular training 时的文件同步:
@@ -355,6 +366,8 @@ class BaseDataset(Dataset): ar = s[:, 0] / s[:, 1] # aspect ratio irect = ar.argsort() self.im_files = [self.im_files[i] for i in irect]+ if self.ir_files: # Dual-stream: keep IR files in sync+ self.ir_files = [self.ir_files[i] for i in irect] self.labels = [self.labels[i] for i in irect]解释:rectangular training 按宽高比对图像重排序,必须让 IR 文件列表跟着一起排序,否则 RGB 和 IR 就对不上了。
3.3 修改 ultralytics/data/dataset.py
让 YOLODataset 从 data.yaml 读取 ch和 ir_dir 参数并传给 BaseDataset:
diff --git a/ultralytics/data/dataset.py b/ultralytics/data/dataset.py--- a/ultralytics/data/dataset.py+++ b/ultralytics/data/dataset.py@@ -85,7 +85,12 @@ class YOLODataset(BaseDataset): self.use_obb = task == "obb" self.data = data assert not (self.use_segments and self.use_keypoints)- super().__init__(*args, channels=self.data.get("channels", 3), **kwargs)+ super().__init__(+ *args,+ channels=self.data.get("ch", self.data.get("channels", 3)),+ ir_dir=self.data.get("ir_dir", "images_ir"),+ **kwargs+ )解释:
- 优先读
ch字段(YOLO 模型 YAML 的惯例),其次读channels,默认为 3 - 读取
ir_dir字段,允许用户自定义 IR 图像所在的文件夹名称
3.4 修改 ultralytics/data/utils.py——数据集检查时正确读取通道数
diff --git a/ultralytics/data/utils.py b/ultralytics/data/utils.py--- a/ultralytics/data/utils.py+++ b/ultralytics/data/utils.py@@ -443,7 +443,7 @@ def check_det_dataset(dataset, autodownload=True): data["names"] = check_class_names(data["names"])- data["channels"] = data.get("channels", 3)+ data["channels"] = data.get("ch", data.get("channels", 3))解释:check_det_dataset 在验证阶段被调用,如果不改这里,即使在 data.yaml 里写了 ch: 6,验证时依然会构建 3 通道的数据集,然后模型期望 6 通道输入,就会报错 expected input to have 6 channels, but got 3 channels。因此这里需要修改,使通道数一致。
3.5 修改 ultralytics/data/utils.py——IR 图像路径到标签的映射
diff --git a/ultralytics/data/utils.py b/ultralytics/data/utils.py--- a/ultralytics/data/utils.py+++ b/ultralytics/data/utils.py@@ -60,7 +60,15 @@ def img2label_paths(img_paths): """Convert image paths to label paths.""" sa, sb = f"{os.sep}images{os.sep}", f"{os.sep}labels{os.sep}"- return [sb.join(x.rsplit(sa, 1)).rsplit(".", 1)[0] + ".txt" for x in img_paths]+ res = []+ for x in img_paths:+ if sa in x:+ res.append(sb.join(x.rsplit(sa, 1)).rsplit(".", 1)[0] + ".txt")+ elif f"{os.sep}images_ir{os.sep}" in x:+ res.append(sb.join(x.rsplit(f"{os.sep}images_ir{os.sep}", 1)).rsplit(".", 1)[0] + ".txt")+ else:+ res.append(x.rsplit(".", 1)[0] + ".txt")+ return res解释:标准 YOLO 的 img2label_paths 假设图像在 images/ 目录下,标签在 labels/ 目录下。但 IR 图像路径含 images_ir/,如果不处理,就找不到对应的标签文件。这里让 images_ir/ 也能正确映射到 labels/。这个修改是为了方便把多模态数据集直接拿来做单流消融实验的。改掉原版代码生硬的字符串替换之后,标签文件就能被正确读取。
3.6 修改 ultralytics/data/loaders.py——推理时也支持双流
diff --git a/ultralytics/data/loaders.py b/ultralytics/data/loaders.py--- a/ultralytics/data/loaders.py+++ b/ultralytics/data/loaders.py@@ -385,6 +385,7 @@ class LoadImagesAndVideos: self.vid_stride = vid_stride self.bs = batch self.cv2_flag = cv2.IMREAD_GRAYSCALE if channels == 1 else cv2.IMREAD_COLOR+ self.channels = channels # Dual-stream: save for IR merge check@@ -445,6 +446,14 @@ class LoadImagesAndVideos: self.mode = "image" im0 = imread(path, flags=self.cv2_flag) # BGR+ # Dual-stream: merge IR image when channels > 3+ if getattr(self, 'channels', 3) > 3 and im0 is not None:+ ir_path = path.replace(os.sep + "images" + os.sep, os.sep + "images_ir" + os.sep)+ if not os.path.exists(ir_path):+ ir_path = os.path.splitext(path)[0] + '_ir' + os.path.splitext(path)[1]+ im_ir = imread(ir_path, flags=self.cv2_flag)+ if im_ir is not None:+ im0 = cv2.merge((*cv2.split(im0), *cv2.split(im_ir))) # RGB(3ch) + IR(3ch) = 6ch解释:LoadImagesAndVideos 用于 predict 模式。推理时也需将 IR 与 RGB 合并为 6 通道输入。合并顺序必须与 base.py(训练路径)保持一致,即 RGB 在前(通道 0-2),IR 在后(通道 3-5)。
四、Step 2:新增网络算子——输入拆分模块
4.1 为什么需要 Multiin 和 IN
在单模态 YOLO 中,输入直接进入第一个 Conv 层。双流架构需要:
- 将 6 通道输入拆成两个 3 通道子流,分别送入两条独立的骨干网络
- YAML 拓扑中需要一个占位节点来标记拆分前的 6 通道张量的索引位置
IN 是恒等映射(Identity Node),纯粹为了在 YAML 拓扑中占一个节点索引。
Multiin 是通道拆分模块,按通道维度将输入均分为 N 份,输出指定的那一份。
4.2 修改 ultralytics/nn/modules/block.py
diff --git a/ultralytics/nn/modules/block.py b/ultralytics/nn/modules/block.py--- a/ultralytics/nn/modules/block.py+++ b/ultralytics/nn/modules/block.py@@ -52,6 +52,8 @@ __all__ = ( "ResNetLayer", "SCDown", "TorchVision",+ "IN",+ "Multiin", )
+class IN(nn.Module):+ """Identity node for indexing in YAML topology.++ This module acts as a pass-through placeholder, useful for creating explicit+ index reference points in the YAML model definition when building dual-stream+ or multi-branch architectures.+ """++ def __init__(self):+ super().__init__()++ def forward(self, x: torch.Tensor) -> torch.Tensor:+ return x+++class Multiin(nn.Module):+ """Split multi-channel input into sub-streams along the channel dimension.++ Used in multi-stream architectures to separate a composite input tensor into+ independent tensors for parallel processing.++ Attributes:+ out (int): Which sub-stream to output (1-indexed).+ streams (int): Total number of sub-streams the tensor is composed of.+ """++ def __init__(self, out: int = 1, streams: int = 2):+ super().__init__()+ self.out = out+ self.streams = streams++ def forward(self, x: torch.Tensor) -> torch.Tensor:+ c = x.shape[1] // self.streams+ return x[:, (self.out - 1) * c : self.out * c]解释:
Multiin(out=1)取第一份(通道 0~2,即 RGB)Multiin(out=2)取第二份(通道 3~5,即 IR)streams参数允许支持更多模态(如三模态输入),默认为 2
4.3 修改 ultralytics/nn/modules/__init__.py——导出新模块
diff --git a/ultralytics/nn/modules/__init__.py b/ultralytics/nn/modules/__init__.py--- a/ultralytics/nn/modules/__init__.py+++ b/ultralytics/nn/modules/__init__.py@@ -51,7 +51,9 @@ from .block import ( HGBlock, HGStem, ImagePoolingAttn,+ IN, MaxSigmoidAttnBlock,+ Multiin, Proto,并将它们加入 __all__:
__all__ = ( ... "IN", "Index", ... "Multiin", "Pose",解释:Python 模块的导入和 __all__ 导出列表必须同步更新,否则 tasks.py 中 from ultralytics.nn.modules import Multiin 会报 ImportError。
五、Step 3:修改模型解析器——让 parse_model 理解双流拓扑
5.1 parse_model 的“前世今生”
YOLO 的模型结构完全由 YAML 配置驱动。parse_model 函数逐行读取 YAML 中的 backbone 和 head 列表,根据模块名实例化 PyTorch 模块,并维护一个通道数列表 ch 来自动推断每一层的输入/输出通道。
双流改造需要 parse_model 能够:
- 识别
Multiin模块并正确计算拆分后的通道数(6 → 3) - 解析新增的
fusion段(YAML 中 backbone 和 head 之间) - 识别各种融合模块(TransformerFusionBlock、Add、CAFF 等)并正确推断其输出通道数
5.2 修改 ultralytics/nn/tasks.py
首先,在文件头部导入新模块:
from ultralytics.nn.modules import ( ... IN, Index, LRPCHead, Multiin, Pose, ... Add, CAFF, CDC, CSSA, ChannelSwitching, CombinedFusionBlocks, ConcatFusion, ECABlock, ICFusion, ITFuse, MaxFusion, SFEG, SpatialAttention, TFusion, TransformerFusionBlock,)在 DetectionModel.__init__ 中保持 ch 字段:
@@ -388,6 +388,7 @@ class DetectionModel(BaseModel): self.yaml["backbone"][0][2] = "nn.Identity"
# Define model+ ch = self.yaml["ch"] = self.yaml.get("ch", ch) # input channels self.yaml["channels"] = ch解释:YAML 中用 ch: 6 表示输入通道数。如果不在这里持久化 ch 字段,parse_model 接收到的 ch 可能仍然是默认的 3。
在 parse_model 函数中,添加融合模块集合和遍历 fusion 段:
fusion_modules = frozenset( { Add, CSSA, ITFuse, TransformerFusionBlock, CDC, SFEG, ICFusion, MaxFusion, ConcatFusion, CAFF, TFusion, CombinedFusionBlocks, })for i, (f, n, m, args) in enumerate(d["backbone"] + d["head"]):for i, (f, n, m, args) in enumerate(d["backbone"] + d.get("fusion", []) + d["head"]):解释:原来只遍历 backbone + head,现在插入 fusion 段。d.get("fusion", []) 保证没有 fusion 段时也不报错(早期融合不需要独立的 fusion 段,同样也方便了消融实验)。
为 Multiin 添加通道数计算逻辑:
elif m is Multiin: # Multi-stream: divide channels by number of streams streams = args[1] if len(args) > 1 else 2 c2 = ch[f] // streams解释:当遇到 Multiin 时,输出通道数 = 输入通道数 / 流数(默认 2)。6ch 输入经过 Multiin 后变成 3ch。
为各类融合模块添加通道数计算逻辑:
elif m in fusion_modules: if m is TransformerFusionBlock: c2 = ch[f[0]] # 输出通道=输入通道 args = [c2, *args[1:]] elif m in [CDC]: c2 = ch[f[0]] args = [c2, args[1], args[2]] elif m in {SFEG, ConcatFusion, CAFF, ICFusion, TFusion}: c2 = ch[f[0]] # output channels = input channels args = [c2, *args] # prepend in_channels else: # No-arg fusion modules: Add, MaxFusion, CSSA, etc. for x in f: if x >= -1: c2 = ch[x] continue解释:不同融合模块的参数格式不同。有些需要传入 in_channels(如 TransformerFusionBlock),有些不需要参数(如 Add 直接逐元素相加)。这里根据模块类型分别处理参数和输出通道数。
六、Step 4:编写模型 YAML 配置
6.1 数据集 YAML(m3fd.yaml)
path: /path/to/datasets/M3FDtrain: images/trainval: images/valch: 6ir_dir: images_irnc: 6names: 0: People 1: Car 2: Bus 3: Motorcycle 4: Lamp 5: Truck关键字段:
ch: 6:告诉数据加载器要拼接双模态为 6 通道ir_dir: images_ir:IR 图像文件夹名(相对于images/的同级目录)
数据集目录结构应为:
M3FD/├── images/│ ├── train/│ │ ├── 00001.png (RGB)│ │ └── ...│ └── val/├── images_ir/│ ├── train/│ │ ├── 00001.png (IR)│ │ └── ...│ └── val/└── labels/ ├── train/ │ ├── 00001.txt │ └── ... └── val/6.2 基线模型 YAML(“yolo26.yaml` 单模态)
nc: 80end2end: Truereg_max: 1scales: n: [0.50, 0.25, 1024] s: [0.50, 0.50, 1024] m: [0.50, 1.00, 512] l: [1.00, 1.00, 512] x: [1.00, 1.50, 512]
backbone: - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2 - [-1, 1, Conv, [128, 3, 2]] # 1-P2/4 - [-1, 2, C3k2, [256, False, 0.25]] # 2 - [-1, 1, Conv, [256, 3, 2]] # 3-P3/8 - [-1, 2, C3k2, [512, False, 0.25]] # 4 - [-1, 1, Conv, [512, 3, 2]] # 5-P4/16 - [-1, 2, C3k2, [512, True]] # 6 - [-1, 1, Conv, [1024, 3, 2]] # 7-P5/32 - [-1, 2, C3k2, [1024, True]] # 8 - [-1, 1, SPPF, [1024, 5, 3, True]] # 9 - [-1, 2, C2PSA, [1024]] # 10
head: - [-1, 1, nn.Upsample, [None, 2, "nearest"]] - [[-1, 6], 1, Concat, [1]] - [-1, 2, C3k2, [512, True]] # 13 - [-1, 1, nn.Upsample, [None, 2, "nearest"]] - [[-1, 4], 1, Concat, [1]] - [-1, 2, C3k2, [256, True]] # 16 (P3/8) - [-1, 1, Conv, [256, 3, 2]] - [[-1, 13], 1, Concat, [1]] - [-1, 2, C3k2, [512, True]] # 19 (P4/16) - [-1, 1, Conv, [512, 3, 2]] - [[-1, 10], 1, Concat, [1]] - [-1, 1, C3k2, [1024, True, 0.5, True]] # 22 (P5/32) - [[16, 19, 22], 1, Detect, [nc]]6.3 Early Fusion(yolo26-dual-early.yaml)
早期融合是最简单的方案:直接把 6 通道输入喂给标准 backbone 的第一个 Conv 层,让网络自行学习如何利用双模态信息。
与基线的唯一区别:只添加了 ch: 6,骨干网和头部结构完全不变。
ch: 6 # dual-stream: 3ch RGB + 3ch IR = 6 channelsnc: 80end2end: Truereg_max: 1scales: n: [0.50, 0.25, 1024] # ... 同基线
# backbone 与 head 完全复用 yolo26.yaml 的结构# 第一个 Conv 层自动从 ch=3 变为 ch=6backbone: - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2 input: 6ch # ... 后续与基线完全相同
head: # ... 与基线完全相同优点:改动最小,不增加参数量(除了第一个 Conv 层的 kernel 从 3×3×3 变为 3×3×6)。 缺点:两种模态在输入层就被混合,网络无法学习模态特定的特征表示。
6.4 Mid Fusion(yolo26-dual-mid.yaml)
中期融合在 P2 特征层通过 Concat 合并两条流,然后用共享的后续层处理融合特征。
ch: 6nc: 80end2end: Truereg_max: 1scales: n: [0.50, 0.25, 1024] # ...
backbone: # --- Input split --- - [-1, 1, IN, []] # 0 identity placeholder (6ch) - [-1, 1, Multiin, [1]] # 1 stream A: first 3ch (RGB) - [-2, 1, Multiin, [2]] # 2 stream B: last 3ch (IR)
# --- P1/2: parallel Convs --- - [1, 1, Conv, [64, 3, 2]] # 3 stream A P1 - [2, 1, Conv, [64, 3, 2]] # 4 stream B P1
# --- P2/4: parallel Convs --- - [3, 1, Conv, [128, 3, 2]] # 5 stream A P2 - [4, 1, Conv, [128, 3, 2]] # 6 stream B P2
# --- P2 feature extraction --- - [5, 2, C3k2, [256, False, 0.25]] # 7 stream A - [6, 2, C3k2, [256, False, 0.25]] # 8 stream B
# --- FUSION at P2 --- - [[-2, -1], 1, Concat, [1]] # 9 fuse P2 features
# --- Shared backbone from here --- - [-1, 1, Conv, [256, 3, 2]] # 10-P3/8 - [-1, 2, C3k2, [512, False, 0.25]] # 11 - [-1, 1, Conv, [512, 3, 2]] # 12-P4/16 - [-1, 2, C3k2, [512, True]] # 13 - [-1, 1, Conv, [1024, 3, 2]] # 14-P5/32 - [-1, 2, C3k2, [1024, True]] # 15 - [-1, 1, SPPF, [1024, 5, 3, True]] # 16 - [-1, 2, C2PSA, [1024]] # 17
head: # ... 标准 FPN head,索引对应调整解读 YAML 拓扑语法:
[-1, 1, IN, []]:from=-1(来自上一层),重复 1 次,使用IN模块,无额外参数[-1, 1, Multiin, [1]]:from=-1,Multiin(out=1)取第一份通道[-2, 1, Multiin, [2]]:from=-2(跳过上一层,回到 IN 的输出),取第二份通道[1, 1, Conv, [64, 3, 2]]:from=1(引用节点 1 的输出,即 RGB 流)
6.5 Late Fusion(yolo26-dual-late.yaml)
晚期融合完整地复制两条骨干网,在 FPN head 中才通过 Concat 融合。
# yolo26-dual-late.yaml(结构概要,完整版约 99 行)ch: 6nc: 80
backbone: # --- Input split --- - [-1, 1, IN, []] # 0 - [-1, 1, Multiin, [1]] # 1 RGB - [-2, 1, Multiin, [2]] # 2 IR
# --- 完整的并行双骨干 --- # Stream A (RGB): P1→P2→P3→P4→P5 (nodes 3-20) # Stream B (IR): P1→P2→P3→P4→P5 (nodes 3-20, 但独立权重)
head: # --- 在每个尺度级别融合两条流 --- # P5: Upsample(A) + Upsample(B) → Concat(A,P4) + Concat(B,P4) → ... # 每一层都有 A/B 两个分支 # 最终从 6 个特征图检测: - [[31, 32, 37, 38, 43, 44], 1, Detect, [nc]]特点:参数量约为单流的 2 倍,但两条流完全独立学习,融合最晚。
6.6 TransformerFusionBlock 多级融合(yolo26-dual-mid-tfblock.yaml)
这是最精细的融合方案,使用 Transformer 注意力机制在 P3/P4/P5 三个尺度上进行跨模态特征融合。
ch: 6nc: 80end2end: Truereg_max: 1
# ===================== Dual Backbone =====================backbone: - [-1, 1, IN, []] # 0 identity (6ch) - [-1, 1, Multiin, [1]] # 1 RGB stream - [-2, 1, Multiin, [2]] # 2 IR stream
# ===== RGB Backbone ===== - [1, 1, Conv, [64, 3, 2]] # 3 P1/2 - [-1, 1, Conv, [128, 3, 2]] # 4 P2/4 - [-1, 2, C3k2, [256, False, 0.25]] # 5 P2 features - [-1, 1, Conv, [256, 3, 2]] # 6 P3/8 - [-1, 2, C3k2, [512, False, 0.25]] # 7 P3 features - [-1, 1, Conv, [512, 3, 2]] # 8 P4/16 - [-1, 2, C3k2, [512, True]] # 9 P4 features - [-1, 1, Conv, [1024, 3, 2]] # 10 P5/32 - [-1, 2, C3k2, [1024, True]] # 11 P5 features - [-1, 1, SPPF, [1024, 5, 3, True]] # 12 SPPF - [-1, 2, C2PSA, [1024]] # 13 C2PSA (RGB P5 final)
# ===== IR Backbone (独立权重, 结构镜像) ===== - [2, 1, Conv, [64, 3, 2]] # 14 P1/2 - [-1, 1, Conv, [128, 3, 2]] # 15 P2/4 - [-1, 2, C3k2, [256, False, 0.25]] # 16 P2 features - [-1, 1, Conv, [256, 3, 2]] # 17 P3/8 - [-1, 2, C3k2, [512, False, 0.25]] # 18 P3 features - [-1, 1, Conv, [512, 3, 2]] # 19 P4/16 - [-1, 2, C3k2, [512, True]] # 20 P4 features - [-1, 1, Conv, [1024, 3, 2]] # 21 P5/32 - [-1, 2, C3k2, [1024, True]] # 22 P5 features - [-1, 1, SPPF, [1024, 5, 3, True]] # 23 SPPF - [-1, 2, C2PSA, [1024]] # 24 C2PSA (IR P5 final)
# ===================== Multi-Level Fusion =====================fusion: # --- P3 fusion --- - [[7, 18], 1, TransformerFusionBlock, [128, 1]] # 25 fuse RGB-P3 & IR-P3
# --- P4 fusion cascade --- - [-1, 1, Conv, [512, 3, 2]] # 26 downsample fused P3 to P4 - [-1, 2, C3k2, [512, True]] # 27 - [[9, 20], 1, TransformerFusionBlock, [128, 2]] # 28 fuse RGB-P4 & IR-P4 - [[27, 28], 1, TransformerFusionBlock, [128, 3]] # 29 merge with fused P3 path
# --- P5 fusion cascade --- - [-1, 1, Conv, [1024, 3, 2]] # 30 downsample fused P4 to P5 - [-1, 2, C3k2, [1024, True]] # 31 - [-1, 1, SPPF, [1024, 5, 3, True]] # 32 - [-1, 2, C2PSA, [1024]] # 33 - [[13, 24], 1, TransformerFusionBlock, [256, 4]] # 34 fuse RGB-P5 & IR-P5 - [[33, 34], 1, TransformerFusionBlock, [256, 5]] # 35 merge with fused path
# ===================== Detection Head (FPN) =====================head: - [-1, 1, nn.Upsample, [None, 2, "nearest"]] # 36 - [[-1, 29], 1, Concat, [1]] # 37 cat with fused P4 - [-1, 2, C3k2, [512, False]] # 38
- [-1, 1, nn.Upsample, [None, 2, "nearest"]] # 39 - [[-1, 25], 1, Concat, [1]] # 40 cat with fused P3 - [-1, 2, C3k2, [256, False]] # 41 (P3/8-small)
- [-1, 1, Conv, [256, 3, 2]] # 42 - [[-1, 38], 1, Concat, [1]] # 43 cat with P4 - [-1, 2, C3k2, [512, False]] # 44 (P4/16-medium)
- [-1, 1, Conv, [512, 3, 2]] # 45 - [[-1, 35], 1, Concat, [1]] # 46 cat with fused P5 - [-1, 2, C3k2, [1024, True]] # 47 (P5/32-large)
- [[41, 44, 47], 1, Detect, [nc]] # 48 Detect(P3, P4, P5)解读 fusion 段的设计思路:
- 在 P3、P4、P5 三个尺度”级联融合”(cascade fusion)
- 每个尺度先对 RGB 和 IR 的对应特征做 TransformerFusionBlock 跨模态注意力融合
- 低尺度融合结果通过 Conv 下采样后,与高尺度融合结果再做一次融合,形成信息逐级聚合
- 最终 FPN head 接收的是融合后的多尺度特征
6.7 其他融合策略 YAML
除了 TransformerFusionBlock 外,还创建了以下融合策略的 YAML 配置,它们的 backbone 和 head 结构完全相同,只是 fusion 段使用不同的融合算子:
| YAML 文件名 | 融合算子 | 特点 |
|---|---|---|
yolo26-dual-mid-add.yaml | Add | 逐元素相加,最轻量 |
yolo26-dual-mid-maxfusion.yaml | MaxFusion | 逐元素取最大值 |
yolo26-dual-mid-concatfusion.yaml | ConcatFusion | Concat + 1×1 Conv 降维 |
yolo26-dual-mid-caff.yaml | CAFF | 通道注意力特征融合 |
yolo26-dual-mid-sfeg.yaml | SFEG | 空间特征增强门控 |
yolo26-dual-mid-tfblock.yaml | TransformerFusionBlock | Transformer 注意力融合 |
七、Step 5:可视化——训练和验证时正确绘制双流图片
7.1 可视化的“前世今生”
YOLO 训练时会绘制 train_batch{ni}.jpg、验证时绘制 val_batch{ni}_labels.jpg 和 val_batch{ni}_pred.jpg。这些绘图函数默认取 batch 图像的前 3 个通道。对于 6 通道输入,前 3 通道是 RGB,后 3 通道是 IR。如果不额外处理,就只能看到 RGB 的可视化,无法检查 IR 数据是否正确加载。
7.2 修改 ultralytics/models/yolo/detect/train.py
diff --git a/ultralytics/models/yolo/detect/train.py b/ultralytics/models/yolo/detect/train.py@@ -212,6 +212,15 @@ class DetectionTrainer(BaseTrainer): fname=self.save_dir / f"train_batch{ni}.jpg", on_plot=self.on_plot, )+ # Dual-stream: plot IR channels separately when input has > 3 channels+ if batch["img"].shape[1] > 3:+ ir_batch = {**batch, "img": batch["img"][:, 3:]}+ plot_images(+ labels=ir_batch,+ paths=batch["im_file"],+ fname=self.save_dir / f"train_batch_ir{ni}.jpg",+ on_plot=self.on_plot,+ )7.3 修改 ultralytics/models/yolo/detect/val.py
diff --git a/ultralytics/models/yolo/detect/val.py b/ultralytics/models/yolo/detect/val.py# 在 plot_val_samples 中添加 IR 标签绘制 if batch["img"].shape[1] > 3: ir_batch = {**batch, "img": batch["img"][:, 3:]} plot_images( labels=ir_batch, paths=batch["im_file"], fname=self.save_dir / f"val_batch_ir{ni}_labels.jpg", names=self.names, on_plot=self.on_plot, )
# 在 plot_predictions 中添加 IR 预测绘制 if batch["img"].shape[1] > 3: plot_images( images=batch["img"][:, 3:], labels=batched_preds, paths=batch["im_file"], fname=self.save_dir / f"val_batch_ir{ni}_pred.jpg", names=self.names, on_plot=self.on_plot, )7.4 修改 ultralytics/models/yolo/detect/predict.py
推理时,如果原始图像是 6 通道的,需要拆分后分别生成 RGB 和 IR 两组 Results:
diff --git a/ultralytics/models/yolo/detect/predict.py# construct_result 方法 # Dual-stream: split 6-channel original image into RGB and IR if orig_img.shape[-1] > 3: rgb_img = orig_img[..., 3:] # last 3 channels = RGB (BGR order) ir_img = orig_img[..., :3] # first 3 channels = IR (BGR order) ir_path = img_path.replace(os.sep + "images" + os.sep, os.sep + "images_ir" + os.sep) return [ Results(rgb_img, path=img_path, names=self.model.names, boxes=pred[:, :6]), Results(ir_img, path=ir_path, names=self.model.names, boxes=pred[:, :6]), ] return [Results(orig_img, path=img_path, names=self.model.names, boxes=pred[:, :6])]八、Step 6:工程适配——DDP 训练与优化器兼容
8.1 DDP find_unused_parameters 问题
双流架构引入了融合模块,某些融合策略可能导致部分参数在某些前向传播路径中不被使用。PyTorch 的 DistributedDataParallel(DDP)默认在 find_unused_parameters=True 时会在每次迭代中遍历整个计算图寻找未使用的参数,这带来严重的性能开销。
解决方案:通过移除未使用的融合层,确保所有模型参数都参与前向传播,然后将 find_unused_parameters 设为 False。
diff --git a/ultralytics/engine/trainer.py b/ultralytics/engine/trainer.py@@ -334,7 +334,7 @@ class BaseTrainer: if self.world_size > 1:- self.model = nn.parallel.DistributedDataParallel(self.model, device_ids=[RANK], find_unused_parameters=True)+ self.model = nn.parallel.DistributedDataParallel(self.model, device_ids=[RANK], find_unused_parameters=False)8.2 MuSGD/Muon 优化器兼容 3D 参数
何为 MuSGD/Muon 优化器?
YOLO26 在目标检测领域的一个重大创新,就是打破了计算机视觉(CV)和自然语言处理(NLP)之间的壁垒,将大语言模型(LLM)训练中卓有成效的优化技术引入了 CV 领域。这就催生了 YOLO26 核心的 MuSGD(Momentum-Unified Stochastic Gradient Descent)优化器。
为了更好地理解 MuSGD,我们需要先从它的基础——Muon 优化器说起。
何为 Muon 优化器?
Muon(Momentum Orthogonalizer)是一种最初在 LLM 训练(如 Moonshot AI 的 Kimi K2 和 NanoGPT 的优化中)取得显著成功的优化算法。它的核心思想是:在常规的动量更新之外,引入了 Newton-Schulz 迭代来对梯度进行正交化处理。这种正交化能够在高维参数空间中,极大地改善神经网络的收敛几何特性,从而让模型在训练时更加稳定,且收敛速度极快。
YOLO26 中的 MuSGD 是什么?
虽然 Muon 在大模型中表现优异,但直接生搬硬套到基于卷积(ConvNets)的 YOLO 架构中可能会面临泛化性问题。因此,Ultralytics 在 YOLO26 中设计了 MuSGD,这是一种将经典 SGD(随机梯度下降) 与 Muon 结合的混合优化器。MuSGD 既保留了传统 SGD 在计算机视觉任务中优秀的泛化能力,又融合了 Muon 带来的正交化稳定性。
核心数学原理
在标准的带动量的 SGD 中,梯度更新公式如下(其中 为梯度, 为动量系数): 而 MuSGD 对最终的权重更新轨迹进行了修改,将 Newton-Schulz 正交化注入其中( 为混合系数, 为学习率):
以TransformerFusionBlock 为例,此融合模块中的某些参数(如位置编码、投影矩阵)是 3D 张量(ndim == 3)。原来的 Muon 优化器只处理 4D(Conv 滤波器)和 2D(线性层)参数,遇到 3D 参数时会在 Newton-Schulz 正交化步骤中崩溃。
diff --git a/ultralytics/optim/muon.py b/ultralytics/optim/muon.py@@ -89,7 +89,7 @@ def muon_update(grad, momentum, beta=0.95, ...): momentum.lerp_(grad, 1 - beta) update = grad.lerp(momentum, beta) if nesterov else momentum- if update.ndim == 4: # for the case of conv filters+ if update.ndim > 2: # for the case of conv filters and >2D params like embeddings update = update.view(len(update), -1) update = zeropower_via_newtonschulz5(update)解释:将条件从 ndim == 4 放宽为 ndim > 2,使得 3D 参数也能被正确 reshape 成 2D 矩阵后进行正交化。
九、改造文件清单与修改总结
| 文件路径 | 改动内容 |
|---|---|
ultralytics/data/base.py | 添加 IR 文件推断、6ch 合并、验证、排序同步 |
ultralytics/data/dataset.py | 传递 ch 和 ir_dir 给 BaseDataset |
ultralytics/data/loaders.py | 推理时合并 IR 图像 |
ultralytics/data/utils.py | check_det_dataset 支持 ch 字段;img2label_paths 支持 images_ir/ |
ultralytics/nn/modules/block.py | 新增 IN、Multiin 模块 |
ultralytics/nn/modules/__init__.py | 导出新模块和融合模块 |
ultralytics/nn/tasks.py | 导入新模块;持久化 ch;解析 fusion 段;处理融合模块通道数 |
ultralytics/engine/trainer.py | DDP find_unused_parameters=False |
ultralytics/optim/muon.py | 兼容 3D 参数的正交化 |
ultralytics/models/yolo/detect/train.py | 绘制 IR 训练 batch |
ultralytics/models/yolo/detect/val.py | 绘制 IR 验证 batch(标签+预测) |
ultralytics/models/yolo/detect/predict.py | 推理结果拆成 RGB/IR 两组 |
ultralytics/cfg/models/26/yolo26-dual-early.yaml | 早期融合模型配置 |
ultralytics/cfg/models/26/yolo26-dual-mid.yaml | 中期融合(Concat)模型配置 |
ultralytics/cfg/models/26/yolo26-dual-late.yaml | 晚期融合模型配置 |
ultralytics/cfg/models/26/yolo26-dual-mid-tfblock.yaml | TransformerFusionBlock 融合配置 |
ultralytics/cfg/models/26/yolo26-dual-mid-add.yaml | Add 融合配置 |
ultralytics/cfg/models/26/yolo26-dual-mid-caff.yaml | CAFF 融合配置 |
ultralytics/cfg/models/26/yolo26-dual-mid-sfeg.yaml | SFEG 融合配置 |
ultralytics/cfg/models/26/yolo26-dual-mid-maxfusion.yaml | MaxFusion 融合配置 |
ultralytics/cfg/models/26/yolo26-dual-mid-concatfusion.yaml | ConcatFusion 融合配置 |
十、完整改造步骤速查
如果想要从基线仓库开始将一个基线 YOLO 改成多模态,按以下顺序操作:
-
准备数据集:在
images/同级创建images_ir/目录,放入对应的 IR 图像(文件名与 RGB 一一对应)。修改data.yaml添加ch: 6和ir_dir: images_ir。 -
改数据加载(
data/base.py、data/dataset.py、data/utils.py、data/loaders.py):让数据管线自动读取 IR 图像并合并为 6 通道。 -
添加拆分模块(
nn/modules/block.py、nn/modules/__init__.py):新增IN和Multiin。 -
改模型解析器(
nn/tasks.py):导入新模块,持久化ch字段,解析fusion段,处理Multiin和融合模块的通道数。 -
编写模型 YAML:从基线 YAML 出发,添加
ch: 6,在 backbone 开头添加IN+Multiin拆分层,复制一条并行骨干网,添加fusion段选择融合策略,调整 head 的引用索引。 -
改可视化(
detect/train.py、detect/val.py、detect/predict.py):绘制 IR 通道的可视化图。 -
工程适配:DDP 设置
find_unused_parameters=False;Muon 优化器兼容 3D 参数。
十一、训练启动示例
from ultralytics import YOLO
model = YOLO("yolo26-dual-mid-tfblock.yaml")model.train( data="m3fd.yaml", epochs=100, imgsz=640, batch=16, device=0,)以上就是将 YOLO26 从单模态改为多模态的全部流程。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!