跳到正文
TranquilYu's Blog
返回

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

约 31 分钟 · 4,506 字
项目文档
文章目录39 节
  1. 一、背景与目标
  2. 二、整体架构概览
  3. 三、Step 1:数据加载
  4. 3.1 数据加载的“前世今生”
  5. 3.2 修改 ultralytics/data/base.py
  6. 3.3 修改 ultralytics/data/dataset.py
  7. 3.4 修改 ultralytics/data/utils.py——数据集检查时正确读取通道数
  8. 3.5 修改 ultralytics/data/utils.py——IR 图像路径到标签的映射
  9. 3.6 修改 ultralytics/data/loaders.py——推理时也支持双流
  10. 四、Step 2:新增网络算子——输入拆分模块
  11. 4.1 为什么需要 Multiin 和 IN
  12. 4.2 修改 ultralytics/nn/modules/block.py
  13. 4.3 修改 ultralytics/nn/modules/__init__.py——导出新模块
  14. 五、Step 3:修改模型解析器——让 parse_model 理解双流拓扑
  15. 5.1 parse_model 的“前世今生”
  16. 5.2 修改 ultralytics/nn/tasks.py
  17. 六、Step 4:编写模型 YAML 配置
  18. 6.1 数据集 YAML(m3fd.yaml)
  19. 6.2 基线模型 YAML(“yolo26.yaml` 单模态)
  20. 6.3 Early Fusion(yolo26-dual-early.yaml)
  21. 6.4 Mid Fusion(yolo26-dual-mid.yaml)
  22. 6.5 Late Fusion(yolo26-dual-late.yaml)
  23. 6.6 TransformerFusionBlock 多级融合(yolo26-dual-mid-tfblock.yaml)
  24. 6.7 其他融合策略 YAML
  25. 七、Step 5:可视化——训练和验证时正确绘制双流图片
  26. 7.1 可视化的“前世今生”
  27. 7.2 修改 ultralytics/models/yolo/detect/train.py
  28. 7.3 修改 ultralytics/models/yolo/detect/val.py
  29. 7.4 修改 ultralytics/models/yolo/detect/predict.py
  30. 八、Step 6:工程适配——DDP 训练与优化器兼容
  31. 8.1 DDP find_unused_parameters 问题
  32. 8.2 MuSGD/Muon 优化器兼容 3D 参数
  33. 何为 MuSGD/Muon 优化器?
  34. 何为 Muon 优化器?
  35. YOLO26 中的 MuSGD 是什么?
  36. 核心数学原理
  37. 九、改造文件清单与修改总结
  38. 十、完整改造步骤速查
  39. 十一、训练启动示例
文章目录39 节
  1. 一、背景与目标
  2. 二、整体架构概览
  3. 三、Step 1:数据加载
  4. 3.1 数据加载的“前世今生”
  5. 3.2 修改 ultralytics/data/base.py
  6. 3.3 修改 ultralytics/data/dataset.py
  7. 3.4 修改 ultralytics/data/utils.py——数据集检查时正确读取通道数
  8. 3.5 修改 ultralytics/data/utils.py——IR 图像路径到标签的映射
  9. 3.6 修改 ultralytics/data/loaders.py——推理时也支持双流
  10. 四、Step 2:新增网络算子——输入拆分模块
  11. 4.1 为什么需要 Multiin 和 IN
  12. 4.2 修改 ultralytics/nn/modules/block.py
  13. 4.3 修改 ultralytics/nn/modules/__init__.py——导出新模块
  14. 五、Step 3:修改模型解析器——让 parse_model 理解双流拓扑
  15. 5.1 parse_model 的“前世今生”
  16. 5.2 修改 ultralytics/nn/tasks.py
  17. 六、Step 4:编写模型 YAML 配置
  18. 6.1 数据集 YAML(m3fd.yaml)
  19. 6.2 基线模型 YAML(“yolo26.yaml` 单模态)
  20. 6.3 Early Fusion(yolo26-dual-early.yaml)
  21. 6.4 Mid Fusion(yolo26-dual-mid.yaml)
  22. 6.5 Late Fusion(yolo26-dual-late.yaml)
  23. 6.6 TransformerFusionBlock 多级融合(yolo26-dual-mid-tfblock.yaml)
  24. 6.7 其他融合策略 YAML
  25. 七、Step 5:可视化——训练和验证时正确绘制双流图片
  26. 7.1 可视化的“前世今生”
  27. 7.2 修改 ultralytics/models/yolo/detect/train.py
  28. 7.3 修改 ultralytics/models/yolo/detect/val.py
  29. 7.4 修改 ultralytics/models/yolo/detect/predict.py
  30. 八、Step 6:工程适配——DDP 训练与优化器兼容
  31. 8.1 DDP find_unused_parameters 问题
  32. 8.2 MuSGD/Muon 优化器兼容 3D 参数
  33. 何为 MuSGD/Muon 优化器?
  34. 何为 Muon 优化器?
  35. YOLO26 中的 MuSGD 是什么?
  36. 核心数学原理
  37. 九、改造文件清单与修改总结
  38. 十、完整改造步骤速查
  39. 十一、训练启动示例

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 训练兼容、优化器兼容、数据集标签路径映射

二、整体架构概览

FPN Head

多级融合 (以 TransformerFusionBlock 为例)

IR Backbone (独立权重)

RGB Backbone

输入层

下采样 + 级联

下采样 + 级联

RGB 图像

(H×W×3)

IR 图像

(H×W×3)

cv2.merge

拼接为 6 通道 (H×W×6)

IN

Identity 占位节点

Multiin(1)

取通道 0-2

Multiin(2)

取通道 3-5

Conv P1/2

Conv P2/4

C3k2 P2

Conv P3/8

C3k2 P3

Conv P4/16

C3k2 P4

Conv P5/32

C3k2 P5

SPPF + C2PSA

Conv P1/2

Conv P2/4

C3k2 P2

Conv P3/8

C3k2 P3

Conv P4/16

C3k2 P4

Conv P5/32

C3k2 P5

SPPF + C2PSA

TFBlock P3 融合

TFBlock P4 融合

TFBlock P5 融合

Upsample

Concat + C3k2

P4 特征

Upsample

Concat + C3k2

P3 特征

P3/8 小目标

Conv 下采样

Concat + C3k2

P4 特征

P4/16 中目标

Conv 下采样

Concat + C3k2

P5 特征

P5/32 大目标

Detect

多尺度输出

FPN Head

多级融合 (以 TransformerFusionBlock 为例)

IR Backbone (独立权重)

RGB Backbone

输入层

下采样 + 级联

下采样 + 级联

RGB 图像

(H×W×3)

IR 图像

(H×W×3)

cv2.merge

拼接为 6 通道 (H×W×6)

IN

Identity 占位节点

Multiin(1)

取通道 0-2

Multiin(2)

取通道 3-5

Conv P1/2

Conv P2/4

C3k2 P2

Conv P3/8

C3k2 P3

Conv P4/16

C3k2 P4

Conv P5/32

C3k2 P5

SPPF + C2PSA

Conv P1/2

Conv P2/4

C3k2 P2

Conv P3/8

C3k2 P3

Conv P4/16

C3k2 P4

Conv P5/32

C3k2 P5

SPPF + C2PSA

TFBlock P3 融合

TFBlock P4 融合

TFBlock P5 融合

Upsample

Concat + C3k2

P4 特征

Upsample

Concat + C3k2

P3 特征

P3/8 小目标

Conv 下采样

Concat + C3k2

P4 特征

P4/16 中目标

Conv 下采样

Concat + C3k2

P5 特征

P5/32 大目标

Detect

多尺度输出


三、Step 1:数据加载

3.1 数据加载的“前世今生”

标准 YOLO 的 BaseDataset 只加载单张图像(3 通道)。要做双流,需要:

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)

解释

新增验证方法:

+    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
+        )

解释

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 层。双流架构需要:

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]

解释

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

关键字段

数据集目录结构应为:

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 拓扑语法

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 段的设计思路

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


分享文章:

  1. 【论文阅读 | TCSVT 2024 | MMI-Det:探索可见光与红外目标检测的多模态融合】论文阅读 · MMI-Det:探索可见光与红外目标检测的多模态融合
  2. Python学习笔记其四笔记 · Pytorch教程
  3. Python学习笔记其三笔记 · PyTorch教程
上一篇
【论文阅读 | TPAMI 2026 | CDTFusion:跨域与跨任务协同的红外-可见光图像融合方法】
下一篇
OpenClaw 从安装到稳定运行:超详细配置教程(Debian/Ubuntu/WSL,含工具权限/浏览器/通道/排错)