项目笔记 | YOLO26-MultiModal 单模态改双模态

7189 字
36 分钟
项目笔记 | YOLO26-MultiModal 单模态改双模态

[TOC]

YOLO26 单模态改多模态(RGB+IR 双流)完整改造笔记#

一、背景与目标#

标准 YOLO 检测模型只接受单一模态输入。我们要做融合任务,需要将 YOLO 改造为 多模态双流架构,即使之可以同时接收 RGB 和 IR 两种模态的输入,在不同层前插入融合模块,通过特征融合提升检测性能。

本笔记以 Ultralytics 家族中的 YOLOv26 为基线,详细记录了从单模态改为多模态的全部流程。本修改基于的基线 commit 为 7710ef05d,不出意外的话(指的是Ultralytics 修改的代码不与改版 YOLO 相关代码冲突)可以正常无脑 merge 上游,同时体验到 YOLO 的最新特性和本改版的多模态双流架构。即使冲突基本也可手动处理。

改造涉及以下层面:

  1. 数据加载层:让数据管线能同时加载 RGB 和 IR 图像,拼成 6 通道张量
  2. 网络算子层:新增 Multiin(通道拆分)和 IN(索引占位)两个基础模块
  3. 模型解析层:修改 parse_model 使其能解析双流 YAML 配置,包括新的 fusion
  4. 模型配置层:编写(实质上为迁移)各种融合策略的 YAML 文件(Early / Mid / Late / TransformerFusionBlock 等)
  5. 可视化层:训练和验证阶段正确绘制双流图片
  6. 工程层面:DDP 训练兼容、优化器兼容、数据集标签路径映射

二、整体架构概览#

graph TD subgraph 输入层 RGB["RGB 图像<br/>(H×W×3)"] IR["IR 图像<br/>(H×W×3)"] end RGB --> Merge["cv2.merge<br/>拼接为 6 通道 (H×W×6)"] IR --> Merge Merge --> IN["IN<br/>Identity 占位节点"] IN --> M1["Multiin(1)<br/>取通道 0-2"] IN --> M2["Multiin(2)<br/>取通道 3-5"] subgraph RGB_Backbone["RGB Backbone"] M1 --> RC1["Conv P1/2"] RC1 --> RC2["Conv P2/4"] RC2 --> RC3["C3k2 P2"] RC3 --> RC4["Conv P3/8"] RC4 --> RC5["C3k2 P3"] RC5 --> RC6["Conv P4/16"] RC6 --> RC7["C3k2 P4"] RC7 --> RC8["Conv P5/32"] RC8 --> RC9["C3k2 P5"] RC9 --> RC10["SPPF + C2PSA"] end subgraph IR_Backbone["IR Backbone (独立权重)"] M2 --> IC1["Conv P1/2"] IC1 --> IC2["Conv P2/4"] IC2 --> IC3["C3k2 P2"] IC3 --> IC4["Conv P3/8"] IC4 --> IC5["C3k2 P3"] IC5 --> IC6["Conv P4/16"] IC6 --> IC7["C3k2 P4"] IC7 --> IC8["Conv P5/32"] IC8 --> IC9["C3k2 P5"] IC9 --> IC10["SPPF + C2PSA"] end subgraph Fusion["多级融合 (以 TransformerFusionBlock 为例)"] RC5 --> FP3["TFBlock P3 融合"] IC5 --> FP3 RC7 --> FP4["TFBlock P4 融合"] IC7 --> FP4 FP3 -.->|"下采样 + 级联"| FP4 RC10 --> FP5["TFBlock P5 融合"] IC10 --> FP5 FP4 -.->|"下采样 + 级联"| FP5 end subgraph Head["FPN Head"] FP5 --> UP1["Upsample"] UP1 --> Cat1["Concat + C3k2<br/>P4 特征"] FP4 --> Cat1 Cat1 --> UP2["Upsample"] UP2 --> Cat2["Concat + C3k2<br/>P3 特征"] FP3 --> Cat2 Cat2 --> D1["P3/8 小目标"] Cat2 --> Down1["Conv 下采样"] Down1 --> Cat3["Concat + C3k2<br/>P4 特征"] Cat1 --> Cat3 Cat3 --> D2["P4/16 中目标"] Cat3 --> Down2["Conv 下采样"] Down2 --> Cat4["Concat + C3k2<br/>P5 特征"] FP5 --> Cat4 Cat4 --> D3["P5/32 大目标"] end D1 --> Detect["Detect<br/>多尺度输出"] D2 --> Detect D3 --> Detect style RGB fill:#4a90d9,color:#fff style IR fill:#e74c3c,color:#fff style Merge fill:#2ecc71,color:#fff style IN fill:#95a5a6,color:#fff style M1 fill:#5dade2,color:#fff style M2 fill:#ec7063,color:#fff style FP3 fill:#f39c12,color:#fff style FP4 fill:#f39c12,color:#fff style FP5 fill:#f39c12,color:#fff style Detect fill:#8e44ad,color:#fff

三、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
  • 通过简单的字符串替换 imagesimages_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. 物理层面通常是单通道(灰度图)

在绝大多数红外热成像传感器中,输出的是**单通道(1-channel)**的灰度图,表示每个像素点的辐射强度(热量)。

  1. 本项目实现方法层面为处理成三通道

虽然原始图像是单通道,但在代码实现中,我们将其读取并处理为三通道。原因如下:

  • OpenCV 的读取机制:在 base.pyloaders.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#

YOLODatasetdata.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.pyfrom ultralytics.nn.modules import Multiin 会报 ImportError


五、Step 3:修改模型解析器——让 parse_model 理解双流拓扑#

5.1 parse_model 的“前世今生”#

YOLO 的模型结构完全由 YAML 配置驱动。parse_model 函数逐行读取 YAML 中的 backbonehead 列表,根据模块名实例化 PyTorch 模块,并维护一个通道数列表 ch 来自动推断每一层的输入/输出通道。

双流改造需要 parse_model 能够:

  1. 识别 Multiin 模块并正确计算拆分后的通道数(6 → 3)
  2. 解析新增的 fusion 段(YAML 中 backbone 和 head 之间)
  3. 识别各种融合模块(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/M3FD
train: images/train
val: images/val
ch: 6
ir_dir: images_ir
nc: 6
names:
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: 80
end2end: True
reg_max: 1
scales:
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,骨干网和头部结构完全不变。

yolo26-dual-early.yaml
ch: 6 # dual-stream: 3ch RGB + 3ch IR = 6 channels
nc: 80
end2end: True
reg_max: 1
scales:
n: [0.50, 0.25, 1024]
# ... 同基线
# backbone 与 head 完全复用 yolo26.yaml 的结构
# 第一个 Conv 层自动从 ch=3 变为 ch=6
backbone:
- [-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 合并两条流,然后用共享的后续层处理融合特征。

yolo26-dual-mid.yaml
ch: 6
nc: 80
end2end: True
reg_max: 1
scales:
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=-1Multiin(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: 6
nc: 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 三个尺度上进行跨模态特征融合。

yolo26-dual-mid-tfblock.yaml
ch: 6
nc: 80
end2end: True
reg_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.yamlAdd逐元素相加,最轻量
yolo26-dual-mid-maxfusion.yamlMaxFusion逐元素取最大值
yolo26-dual-mid-concatfusion.yamlConcatFusionConcat + 1×1 Conv 降维
yolo26-dual-mid-caff.yamlCAFF通道注意力特征融合
yolo26-dual-mid-sfeg.yamlSFEG空间特征增强门控
yolo26-dual-mid-tfblock.yamlTransformerFusionBlockTransformer 注意力融合

七、Step 5:可视化——训练和验证时正确绘制双流图片#

7.1 可视化的“前世今生”#

YOLO 训练时会绘制 train_batch{ni}.jpg、验证时绘制 val_batch{ni}_labels.jpgval_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 中,梯度更新公式如下(其中 gtg_t 为梯度,β\beta 为动量系数): vt+1=βvt+gtv_{t+1} = \beta \cdot v_t + g_t 而 MuSGD 对最终的权重更新轨迹进行了修改,将 Newton-Schulz 正交化注入其中(α\alpha 为混合系数,η\eta 为学习率): θt+1=θtη(αvt+1+(1α)NewtonSchulz(gt))\theta_{t+1} = \theta_t - \eta \cdot (\alpha \cdot v_{t+1} + (1 - \alpha) \cdot \text{NewtonSchulz}(g_t))

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传递 chir_dir 给 BaseDataset
ultralytics/data/loaders.py推理时合并 IR 图像
ultralytics/data/utils.pycheck_det_dataset 支持 ch 字段;img2label_paths 支持 images_ir/
ultralytics/nn/modules/block.py新增 INMultiin 模块
ultralytics/nn/modules/__init__.py导出新模块和融合模块
ultralytics/nn/tasks.py导入新模块;持久化 ch;解析 fusion 段;处理融合模块通道数
ultralytics/engine/trainer.pyDDP 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.yamlTransformerFusionBlock 融合配置
ultralytics/cfg/models/26/yolo26-dual-mid-add.yamlAdd 融合配置
ultralytics/cfg/models/26/yolo26-dual-mid-caff.yamlCAFF 融合配置
ultralytics/cfg/models/26/yolo26-dual-mid-sfeg.yamlSFEG 融合配置
ultralytics/cfg/models/26/yolo26-dual-mid-maxfusion.yamlMaxFusion 融合配置
ultralytics/cfg/models/26/yolo26-dual-mid-concatfusion.yamlConcatFusion 融合配置

十、完整改造步骤速查#

如果想要从基线仓库开始将一个基线 YOLO 改成多模态,按以下顺序操作:

  1. 准备数据集:在 images/ 同级创建 images_ir/ 目录,放入对应的 IR 图像(文件名与 RGB 一一对应)。修改 data.yaml 添加 ch: 6ir_dir: images_ir

  2. 改数据加载data/base.pydata/dataset.pydata/utils.pydata/loaders.py):让数据管线自动读取 IR 图像并合并为 6 通道。

  3. 添加拆分模块nn/modules/block.pynn/modules/__init__.py):新增 INMultiin

  4. 改模型解析器nn/tasks.py):导入新模块,持久化 ch 字段,解析 fusion 段,处理 Multiin 和融合模块的通道数。

  5. 编写模型 YAML:从基线 YAML 出发,添加 ch: 6,在 backbone 开头添加 IN + Multiin 拆分层,复制一条并行骨干网,添加 fusion 段选择融合策略,调整 head 的引用索引。

  6. 改可视化detect/train.pydetect/val.pydetect/predict.py):绘制 IR 通道的可视化图。

  7. 工程适配: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 从单模态改为多模态的全部流程。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

项目笔记 | YOLO26-MultiModal 单模态改双模态
https://mjy.js.org/posts/yolo26-multimodal-guide/
作者
MaJianyu
发布于
2026-03-13
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
MaJianyu
永远相信,美好的事情即将发生。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
36
分类
7
标签
105
总字数
185,069
运行时长
0
最后活动
0 天前

目录