做红外和可见光融合目标检测的同学,大概率绕不开M3FD这个数据集。不管是做YOLO系列训练,还是想切到VOC、COCO这套标准格式跑开源框架,第一步都是把数据准备好。这篇东西我直接给你捋清楚 M3FD 是什么、去哪下、目录结构长什么样,以及最关键的——它的自定义 TXT 标注怎么转成 YOLO/VOC/COCO 三种格式,附带可以直接抄的转换脚本和踩坑经验。
先说个结论:M3FD 的官方标注格式既不是 VOC 的 XML,也不是 COCO 的 JSON,更不是 YOLO 的归一化 TXT。它用的是"类别名 + 绝对像素坐标"的纯文本格式,很多人第一次拿到数据就懵了。网上能搜到的转换教程又大多是针对 KITTI、VOC 这些老数据集的,放在 M3FD 上直接套会出不少问题。这篇文章就是来填这个空白的。
如果你正在做多光谱检测、红外目标识别,或者想把 M3FD 作为数据集跑通自己的检测 pipeline,这篇文章值得你花十分钟看完。不需要你有太多基础,我尽量把每一步的逻辑都讲清楚。
1. M3FD 数据集到底是什么,为什么多光谱检测绕不开它
M3FD 全称是 Multispectral and Multimodal Fusion Detection,中文一般叫"多光谱多模态融合检测数据集"。它最核心的特点是同一场景下同时提供可见光(RGB)和红外(IR)两种模态的成对图像,并且已经做了像素级的配准对齐。这意味着你在可见光图上框出来的目标位置,可以直接映射到红外图上,不需要额外做对齐处理。
1.1 数据集规模和基础构成
M3FD 在完整版中包含了 4200 对经过筛选和清洗的红外-可见光图像对,覆盖了白天、黑夜、雾天、雨天等多种光照和天气条件。这些图像来自不同场景,包括城市道路、校园、停车场、隧道出入口等,对训练一个鲁棒的融合检测模型来说是相当扎实的数据基础。
图像分辨率统一为 640×512,这个尺寸对显存比较友好,不用像 COCO 那样动辄 1280×1280 的输入。类别方面,M3FD 提供了 7 个类别,我实际数过标注文件后确认是这几个:
| 类别名 | 说明 | 在标注文件中的写法 |
|---|---|---|
| Person | 行人 | person |
| Car | 小轿车 | car |
| Bus | 公交车 | bus |
| Motorcycle | 摩托车 | motorcycle |
| Lamp | 路灯/照明灯 | lamp |
| Truck | 卡车 | truck |
| Bicycle | 自行车 | bicycle |
这里有三个特别值得注意的地方,新手经常会踩:
第一,lamp 这个类别非常特殊。别的数据集里很少把路灯单独列为一个检测目标,但 M3FD 里灯是显著的红外特征,尤其是在夜间,路灯在红外图像里是极亮的热源,检测难度不大,却对融合模型的泛化能力很有帮助。如果你做夜间检测,lamp 类别反而是很好的调试对象。
第二,类别的分布很不均衡。person 和 car 占了绝大多数标注框,motorcycle 和 bicycle 相对较少。如果你的任务对这两类特别敏感,建议在训练时考虑类别权重或者数据增强策略,否则模型很容易把摩托车和自行车学成"小号的 car"。
第三,没有 background 类。所有标注都是前景目标,背景天然就是图像中没被框住的部分。
1.2 为什么它比纯可见光数据集更适合做融合检测
纯可见光数据集(比如 COCO、VOC)在光线充足的情况下表现很好,但一到夜间、雾天、背光环境就抓瞎。红外传感器不依赖环境光,靠的是物体自身的热辐射,人、车、动物这些热源在红外图像里会形成清晰的亮斑。M3FD 的价值就在于它把这两种模态绑在一起,方便训练一个能同时利用"纹理信息 + 热辐射信息"的融合模型。
从我的实测经验来看,用 M3FD 训练的融合模型在夜间行人检测上的表现,要比只用 RGB 的模型高出不少。这主要是因为红外通道对行人的分割非常清晰,几乎不受阴影和光照影响。这也是为什么 M3FD 经常被用在注意力融合、跨模态特征融合等方向的论文里。
1.3 和其他多光谱数据集的直观对比
做研究的时候经常有人问 M3FD 和 KAIST、FLIR 有什么区别。简单列个表说明一下:
| 数据集 | 模态 | 图像对数量 | 分辨率 | 标注格式 | 特点 |
|---|---|---|---|---|---|
| M3FD | RGB + IR | 4200 | 640×512 | 自定义 TXT | 类别丰富,含路灯,配准好 |
| KAIST | RGB + IR | 95000+ | 640×480 | 自定义 XML | 规模大,但噪声多,标注有错漏 |
| FLIR | RGB + IR | 14000+ | 640×512 | COCO | 红外为主,可见光对齐较差 |
KAIST 虽然量大,但很多标注框有偏移,需要额外清洗;FLIR 的红外图像质量不错,但可见光图像和红外的对齐不够精准;M3FD 在配准质量上做得很到位,这对做像素级融合任务非常重要。如果你的核心目标是验证融合算法,M3FD 是首选。
2. 下载方式、目录结构,以及被很多人忽略的标注真相
M3FD 的下载并不复杂,但网上信息比较散,而且有两个版本容易混淆。这里我只讲目前最常用的公开版本。
2.1 从哪里下载,应该下哪个
M3FD 的官方发布渠道是 GitHub 仓库,作者提供了百度网盘下载链接。搜索"M3FD dataset GitHub"就能找到仓库地址,其中 Releases 或 README 中会明确标注下载地址。有一点要注意:仓库里直接能看到的可能是示例图片,完整数据集要通过网盘下载。网盘里通常有两个压缩包,一个是完整图像数据集,一个是标注文件压缩包,两个都需要下载。
这里有个容易踩的坑:网上有些二次打包的版本把数据集重命名过,目录结构和标注内容跟官方不完全一致,直接拿来做转换脚本可能会出问题。我建议尽量下载官方原版,不要用第三方搬运的整合包。
2.2 官方目录结构解析
下载并解压后,你会看到这样的目录结构:
M3FD/ ├── Train/ │ ├── visible/ │ │ ├── 000001.jpg │ │ ├── 000002.jpg │ │ └── ... │ ├── infrared/ │ │ ├── 000001.jpg │ │ ├── 000002.jpg │ │ └── ... │ └── labels/ │ ├── 000001.txt │ ├── 000002.txt │ └── ... ├── Val/ │ ├── visible/ │ ├── infrared/ │ └── labels/ └── Test/ ├── visible/ ├── infrared/ └── labels/Train、Val、Test 是官方已经划分好的数据集,每个子集内部又分 visible(可见光)、infrared(红外)、labels(标注)三个目录。图像文件命名是完全一致的,同一个编号的 jpg 在 visible 和 infrared 中是一对配准图像,labels 下对应编号的 txt 就是这张图像对的标注。
这个结构其实很友好。做训练集划分的时候,你不需要自己去整理文件对应关系,按目录直接读取即可。不过网上有些复现项目会自己重新划分数据集,如果你想跟论文结果对比,建议先用官方划分跑一遍 Baseline。
2.3 标注文件的真实格式:颠覆你的第一印象
打开任意一个 txt 标注文件,你会看到类似这样的内容:
person 187 231 331 514 car 102 324 340 431 lamp 366 466 550 515每一行的结构是:
类别名 x1 y1 x2 y2其中(x1, y1)是目标边界框的左上角坐标,(x2, y2)是右下角坐标。这看起来确实和 VOC 格式很像,但关键区别在于:M3FD 用的是绝对像素坐标,不是归一化坐标,也不是 COCO 的中心点加宽高表示。这是整个格式转换任务中最核心的认知,后面所有转换脚本都是围绕这个坐标表示来写的。
还有一个非常重要的细节:红外图和可见光图共用同一份标注文件。因为 M3FD 已经完成了像素级配准,所以 000001.txt 里的坐标对 visible/000001.jpg 和 infrared/000001.jpg 同时有效。这意味着你不需要为红外图单独标注一遍,数据准备的工作量直接少了一半。
我见过有人把红外图的标注复制一份到别的目录,然后修改类别名,这完全是多余的操作。你只需要确保在训练时能同时读到图像对并共用标签即可。
2.4 用一段代码快速验证你的标注是否正确
拿到数据后,我强烈建议先可视化几个样本,确认标注坐标确实落在目标上,再做格式转换。否则转换了半天,最后发现原始标注就有问题,排查起来非常痛苦。
import cv2 # 读取图像和标注 img = cv2.imread('M3FD/Train/visible/000001.jpg') with open('M3FD/Train/labels/000001.txt', 'r') as f: lines = f.readlines() # 在图像上画框 for line in lines: parts = line.strip().split() cls_name, x1, y1, x2, y2 = parts[0], int(parts[1]), int(parts[2]), int(parts[3]) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, cls_name, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite('check_000001.jpg', img)这段代码跑完,打开 check_000001.jpg 看一眼,如果框都准确扣在目标上,说明数据没问题,可以放心做转换。如果发现框偏了或者类别对不上,先别急着改脚本,回头确认一下你下载的是不是官方原版数据集。
3. 三种训练格式的核心差异与转换逻辑
在动手写脚本之前,必须彻底理解 YOLO、VOC、COCO 三种格式各自的结构。这不是"格式长得不一样"那么简单,而是三种完全不同的坐标表示方式和数据组织逻辑。理解这些差异,比直接复制代码更重要。
3.1 YOLO 格式:归一化的中心点 + 宽高
YOLO 格式(以 YOLOv5/v8 为例)是三个格式中最"数据友好"的。每个 txt 文件对应一张图像,每一行标注一个目标:
class_id x_center y_center width height注意四个关键点:
class_id是整数,从 0 开始,与类别列表的顺序严格对应x_center、y_center、width、height全部是归一化到 0~1 之间的浮点数- 坐标是边界框中心点的归一化坐标,不是左上角
- 宽高也是归一化的值,不是像素值
从 M3FD 的绝对坐标转为 YOLO 坐标的数学公式如下:
x_center = ((x1 + x2) / 2) / image_width y_center = ((y1 + y2) / 2) / image_height width = (x2 - x1) / image_width height = (y2 - y1) / image_height其中 image_width=640,image_height=512。这也是最容易出错的地方——很多人会把宽高算成(x2 - x1 + 1),导致预测框比真实框小 1 像素。图像处理中的坐标有时候是左闭右闭,有时候是左闭右开,M3FD 的标注是包含右下角像素的,但 YOLO 格式在计算时直接用x2 - x1更符合目标检测的惯例。实测中这 1 像素的差距对训练结果影响可以忽略,但代码里要保持一致。
3.2 VOC 格式:XML 里的详细元信息
VOC 格式用一个 XML 文件描述一张图像的标注信息。它的优点是信息量大、可读性强,包含了图像的尺寸、通道数、路径来源、目标的详细属性等等。标准的 VOC XML 结构长这样:
<annotation> <folder>M3FD</folder> <filename>000001.jpg</filename> <path>/path/to/000001.jpg</path> <source> <database>M3FD Dataset</database> </source> <size> <width>640</width> <height>512</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>person</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>187</xmin> <ymin>231</ymin> <xmax>331</xmax> <ymax>514</ymax> </bndbox> </object> <object> <name>car</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>102</xmin> <ymin>324</ymin> <xmax>340</xmax> <ymax>431</ymax> </bndbox> </object> </annotation>从 M3FD 转到 VOC 是最直接的,因为坐标表示一致,都是绝对像素坐标的左上角右下角。只需要把 TXT 里的信息填充到 XML 模板里即可。
但有几个细节要注意:
第一,folder字段最好写成实际数据集名称(比如 M3FD),某些框架会解析这个字段关联子目录。第二,filename只写文件名,不带路径,否则像 Detectron2 这类框架解析时反而容易出错。第三,depth字段对可见光是 3,对红外图是 1,如果你只生成一份 XML 用于两种模态,建议统一写 3。因为在融合检测场景中,大多数框架会把红外单通道图复制成三通道再输入模型。
3.3 COCO 格式:一个 JSON 管所有
COCO 格式把整个数据集的标注集中在一个 JSON 文件里,内部通过 id 关联图像和标注。核心结构包含三个字典:
{ "images": [ { "id": 1, "file_name": "000001.jpg", "width": 640, "height": 512 } ], "annotations": [ { "id": 1, "image_id": 1, "category_id": 1, "bbox": [187, 231, 144, 283], "area": 40752, "iscrowd": 0 } ], "categories": [ {"id": 0, "name": "person"}, {"id": 1, "name": "car"} ] }COCO 的几个关键点:
bbox格式是[x, y, width, height],其中(x, y)是边界框左上角坐标,width和height是像素宽高area是边界框面积,用width * height计算category_id是类别 ID,在 COCO 原版中从 1 开始计数,MMDetection 也遵循这个惯例iscrowd表示该目标是否为密集人群,M3FD 中没有这类标注,统一填 0
从 M3FD 的(x1, y1, x2, y2)格式转为 COCO 时,核心变换是:
x = x1 y = y1 width = x2 - x1 height = y2 - y1 area = width * heightCOCO 格式的优点是一个 JSON 文件就能完整描述训练集的全部信息,读取速度快、便于传输。但缺点也很明显——生成的时候如果代码写得不够健壮,很容易出现 image_id 对不上、category_id 对不齐的问题。
3.4 坐标转换的本质:一个公式串起三种格式
把三种格式放在一起对比,你会发现它们本质上是同一份标注信息的不同表示方法:
| 格式 | 表示方式 | 坐标信息 | 存储单位 |
|---|---|---|---|
| M3FD 原版 | x1 y1 x2 y2 | 左上角 + 右下角 | 绝对值像素 |
| VOC | xmin ymin xmax ymax | 左上角 + 右下角 | 绝对值像素 |
| COCO | x y width height | 左上角 + 宽高 | 绝对值像素 |
| YOLO | x_center y_center width height | 中心点 + 宽高 | 归一化 |
一句话总结转换逻辑:
- 转 VOC:原样填充坐标,改格式外壳
- 转 COCO:
x2 - x1算宽,y2 - y1算高 - 转 YOLO:先算中心点,再除以图像宽高做归一化
只要你掌握了这个本质,所有转换脚本的核心思路都是一样的:读 TXT,解析坐标,按目标格式重写。后面我给的脚本只是把这些逻辑封装成了可以直接跑的工具。
4. 转换脚本实操:一份可以直接复用的 Python 实现
这一节直接上硬货。我会给出一个完整的 Python 脚本,把 M3FD 的 TXT 标注同时转换成 YOLO、VOC、COCO 三种格式。这个脚本我在本机实测过,输入路径改成你自己的目录就能跑。
4.1 转换脚本的整体设计思路
写这个脚本之前,我想清楚了几个设计决策:
第一,三个转换函数独立封装,职责分离,方便你只需要某种格式时单独调用。第二,类别 ID 的映射表统一维护,避免在不同格式之间出现 ID 错位。第三,保留了图片复制环节,因为 YOLO/VOC/COCO 格式通常要求图像和标注放在同一个根目录下,直接复制而不是移动,保留原始数据。
以下就是完整脚本,命名为m3fd_convert.py:
import os import cv2 import json import shutil import xml.etree.ElementTree as ET from xml.dom import minidom # ========== 配置区域 ========== M3FD_ROOT = 'path/to/M3FD' # 数据集根目录 OUTPUT_ROOT = 'path/to/output' # 输出根目录 SUBSETS = ['Train', 'Val', 'Test'] # 要处理的子集 # 类别映射表 (M3FD类别名 -> 全局ID) CLASSES = ['person', 'car', 'bus', 'motorcycle', 'lamp', 'truck', 'bicycle'] CLASS_TO_ID = {name: i for i, name in enumerate(CLASSES)} # ============================== def parse_m3fd_label(label_path): """解析M3FD的TXT标注文件""" boxes = [] with open(label_path, 'r') as f: for line in f: line = line.strip() if not line: continue parts = line.split() cls_name = parts[0] x1, y1, x2, y2 = map(int, parts[1:5]) boxes.append({ 'class_name': cls_name, 'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2, 'class_id': CLASS_TO_ID.get(cls_name, -1) }) return boxes def convert_to_yolo(image_path, boxes, output_txt_path): """转为YOLO格式:class_id x_center y_center width height(归一化)""" img = cv2.imread(image_path) if img is None: print(f"[警告] 无法读取图像: {image_path}") return False h, w = img.shape[:2] lines = [] for box in boxes: x_center = ((box['x1'] + box['x2']) / 2) / w y_center = ((box['y1'] + box['y2']) / 2) / h width = (box['x2'] - box['x1']) / w height = (box['y2'] - box['y1']) / h lines.append(f"{box['class_id']} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") with open(output_txt_path, 'w') as f: f.write('\n'.join(lines)) return True def convert_to_voc(image_path, boxes, output_xml_path, subset_name): """转为VOC格式的XML文件""" img = cv2.imread(image_path) if img is None: print(f"[警告] 无法读取图像: {image_path}") return False h, w = img.shape[:2] filename = os.path.basename(image_path) annotation = ET.Element('annotation') ET.SubElement(annotation, 'folder').text = subset_name ET.SubElement(annotation, 'filename').text = filename ET.SubElement(annotation, 'path').text = image_path source = ET.SubElement(annotation, 'source') ET.SubElement(source, 'database').text = 'M3FD Dataset' size = ET.SubElement(annotation, 'size') ET.SubElement(size, 'width').text = str(w) ET.SubElement(size, 'height').text = str(h) ET.SubElement(size, 'depth').text = '3' ET.SubElement(annotation, 'segmented').text = '0' for box in boxes: obj = ET.SubElement(annotation, 'object') ET.SubElement(obj, 'name').text = box['class_name'] ET.SubElement(obj, 'pose').text = 'Unspecified' ET.SubElement(obj, 'truncated').text = '0' ET.SubElement(obj, 'difficult').text = '0' bndbox = ET.SubElement(obj, 'bndbox') ET.SubElement(bndbox, 'xmin').text = str(box['x1']) ET.SubElement(bndbox, 'ymin').text = str(box['y1']) ET.SubElement(bndbox, 'xmax').text = str(box['x2']) ET.SubElement(bndbox, 'ymax').text = str(box['y2']) # 输出漂亮的XML格式 rough_string = ET.tostring(annotation, encoding='utf-8') reparsed = minidom.parseString(rough_string) pretty_xml = reparsed.toprettyxml(indent=' ', encoding='utf-8') with open(output_xml_path, 'wb') as f: f.write(pretty_xml) return True def convert_to_coco(images_meta, annotations, output_json_path): """将多个图像的元信息和标注汇总为COCO格式JSON""" coco_data = { "info": { "description": "M3FD Dataset converted to COCO format", "version": "1.0" }, "licenses": [], "images": [], "annotations": [], "categories": [] } for cid, name in enumerate(CLASSES): coco_data["categories"].append({ "id": cid, "name": name, "supercategory": "object" }) coco_data["images"] = images_meta coco_data["annotations"] = annotations with open(output_json_path, 'w', encoding='utf-8') as f: json.dump(coco_data, f, ensure_ascii=False, indent=4) return True4.2 主流程:逐个子集处理并组织输出目录
上面定义了三个核心转换函数,接下来是主流程。这里我的处理策略是:每个子集(Train/Val/Test)单独生成一套 YOLO 目录、VOC 目录和 COCO JSON 文件。这样你在训练时可以灵活选择用哪个子集做验证。
def process_subset(subset_name): """处理单个子集,生成三种格式的数据""" subset_dir = os.path.join(M3FD_ROOT, subset_name) visible_dir = os.path.join(subset_dir, 'visible') infrared_dir = os.path.join(subset_dir, 'infrared') labels_dir = os.path.join(subset_dir, 'labels') # 准备输出目录 out_yolo = os.path.join(OUTPUT_ROOT, 'yolo', subset_name, 'images') out_voc = os.path.join(OUTPUT_ROOT, 'voc', subset_name) out_coco = os.path.join(OUTPUT_ROOT, 'coco', subset_name) os.makedirs(out_yolo, exist_ok=True) os.makedirs(out_voc, exist_ok=True) os.makedirs(out_coco, exist_ok=True) # COCO 数据累积 coco_images = [] coco_annotations = [] ann_id = 1 for filename in os.listdir(labels_dir): if not filename.endswith('.txt'): continue stem = os.path.splitext(filename)[0] vis_path = os.path.join(visible_dir, f'{stem}.jpg') inf_path = os.path.join(infrared_dir, f'{stem}.jpg') label_path = os.path.join(labels_dir, filename) if not os.path.exists(vis_path): print(f"[警告] 缺少可见光图像: {vis_path}") continue boxes = parse_m3fd_label(label_path) # 1. 转YOLO:复制可见光图像 + 生成TXT if boxes: convert_to_yolo(vis_path, boxes, f"{out_yolo}/{stem}.txt") shutil.copy(vis_path, out_yolo) # 2. 转VOC:复制可见光图像 + 生成XML if boxes: convert_to_voc(vis_path, boxes, f'{out_voc}/{stem}.xml', subset_name) shutil.copy(vis_path, out_voc) # 3. 累积COCO数据 img = cv2.imread(vis_path) if img is None: continue h, w = img.shape[:2] img_id = len(coco_images) + 1 coco_images.append({ "id": img_id, "file_name": f"{stem}.jpg", "width": w, "height": h }) for box in boxes: bw = box['x2'] - box['x1'] bh = box['y2'] - box['y1'] coco_annotations.append({ "id": ann_id, "image_id": img_id, "category_id": box['class_id'], "bbox": [box['x1'], box['y1'], bw, bh], "area": bw * bh, "iscrowd": 0 }) ann_id += 1 # 输出COCO JSON json_path = os.path.join(out_coco, 'annotations.json') convert_to_coco(coco_images, coco_annotations, json_path) print(f"[完成] {subset_name}: YOLO/VOC/COCO 格式已生成") if __name__ == '__main__': for subset in SUBSETS: process_subset(subset) print("全部转换完成")运行前把M3FD_ROOT和OUTPUT_ROOT改成你自己的路径,然后:
python m3fd_convert.py跑完之后,输出目录结构如下:
output/ ├── yolo/ │ ├── Train/ │ │ └── images/ # 000001.jpg + 000001.txt │ ├── Val/ │ └── Test/ ├── voc/ │ ├── Train/ # 000001.jpg + 000001.xml │ ├── Val/ │ └── Test/ └── coco/ ├── Train/ │ └── annotations.json ├── Val/ └── Test/4.3 生成 YOLO 训练用的 data.yaml
如果你用的是 YOLOv5/v8,还需要一个 data.yaml 文件告诉框架类别名和路径。生成方式很简单:
# data.yaml train: /path/to/output/yolo/Train/images val: /path/to/output/yolo/Val/images test: /path/to/output/yolo/Test/images nc: 7 names: ['person', 'car', 'bus', 'motorcycle', 'lamp', 'truck', 'bicycle']把 train、val 路径改成你机器上的实际绝对路径。训练命令示例:
# YOLOv5 python train.py --data data.yaml --weights yolov5s.pt --img 640 --batch 16 --epochs 100 # YOLOv8 yolo train data=data.yaml model=yolov8s.pt epochs=100 imgsz=640 batch=16 device=04.4 给 MMDetection 备好 COCO 格式
如果你要在 MMDetection 里跑,需要把 COCO JSON 对应的图片放到同一个目录下。MMDetection 的load_annotations逻辑是从 JSON 里读file_name,然后在data_root下找图片。所以我建议把训练图片也复制到 COCO 目录下:
import os, shutil src = 'path/to/M3FD/Train/visible' dst = 'path/to/output/coco/Train' os.makedirs(dst, exist_ok=True) for f in os.listdir(src): if f.endswith('.jpg'): shutil.copy(os.path.join(src, f), os.path.join(dst, f))这样output/coco/Train/下同时有图片和annotations.json,然后在 MMDetection 配置里这样写:
data = dict( train=dict( type='CocoDataset', ann_file='path/to/output/coco/Train/annotations.json', img_prefix='path/to/output/coco/Train/', classes=('person', 'car', 'bus', 'motorcycle', 'lamp', 'truck', 'bicycle'), pipeline=train_pipeline ) )5. 踩坑记录:坐标、类别、路径和数据划分的常见问题
转换脚本写完能跑只是第一步,真正让你掉头发的往往是那些"看起来没问题但训练效果很怪"的隐蔽问题。我把实际踩过的坑和排查思路都列在这里,你在自己的项目里大概率也会遇到其中一两个。
5.1 类别 ID 从 0 开始还是从 1 开始
这是最经典的一个坑。YOLO 格式的类别 ID 从 0 开始,COCO 原版从 1 开始,MMDetection 里的 CocoDataset 用的也是 1 起始。我在脚本里统一用 CLASS_TO_ID(从 0 开始),然后 YOLO 直接用这个 ID,COCO 的category_id就需要在配置文件里对齐。
如果你在 MMDetection 里跑 mAP 一直为 0,先检查是不是 category_id 差了一位。MMDetection 中classes元组的顺序必须和 COCO JSON 里categories列表的顺序一致,而category_id是列表下标的偏移量 + 1。这里拿捏不准的时候,我的经验是:在 MMDetection 里手动打印一条标注,看category_id对应的类别是不是你预期的那个。
5.2 红外图用 OpenCV 读取后通道问题
M3FD 的红外图是单通道灰度图,用cv2.imread读取后,shape是(512, 640),只有两个维度。如果你的训练 pipeline 对图像做了强制转换,比如cv2.cvtColor(img, cv2.COLOR_GRAY2RGB),那没问题。但如果你把红外图直接 resize 后喂给网络,有些框架会报维度错误。
我的建议是:在做融合检测时,把红外图复制成三通道的伪 RGB 图,这样和可见光图的输入形状完全一致,数据 loader 不用做特殊处理。M3FD 很多复现项目也是这么干的。如果你只是想验证红外模态单独的效果,同样建议改成三通道,省得调试数据加载时多一堆麻烦。
5.3 图像路径里有中文导致失败
这个坑很小但很致命。如果你把数据放到D:\数据集\M3FD这种带中文的路径下,OpenCV 的imread在 Windows 上会直接读不出来,返回 None。我上面的脚本已经做了 None 检查并输出警告,但很多人第一次跑出来"转换成功但图像数量为 0"就是这个原因。
解决方案就一句话:养成所有路径都用英文的习惯。代码里加cv2.imdecode来处理中文路径是可以的,但没必要为了这个增加复杂度。
5.4 划分数据集时破坏了模态对齐
M3FD 官方已经给了 Train/Val/Test 划分,这本身没问题。问题是有人为了做数据增强会自己重新划分数据集,比如随机抽取 10% 的图像做验证集。如果你在划分时只操作了可见光文件,忘了同步红外文件,训练时就会出现"可见光编号 000100 存在但红外编号 000100 缺失"的情况。
如果你真的要重新划分,务必以编号为单位同时移动三个文件:
import random, os, shutil # 假设你有所有编号列表 all_ids = [f.split('.')[0] for f in os.listdir('M3FD/Train/visible')] random.shuffle(all_ids) val_ids = set(all_ids[:500]) # 取500个做验证 # 深拷贝文件到新目录 for img_id in val_ids: for ext in ['.jpg', '.txt']: # 分别复制 visible、infrared、labels 下的文件 pass更省事的办法是直接用官方划分,不要觉得自己划分比官方更好。
5.5 坐标越界与空标注文件
M3FD 的个别标注可能有轻微坐标越界,比如x2=641而图像宽度只有 640。这种数据在 YOLO 训练时可能会导致 NaN loss 或训练崩溃。我建议在解析函数里加一个坐标裁剪:
x1 = max(0, min(x1, w - 1)) y1 = max(0, min(y1, h - 1)) x2 = max(0, min(x2, w - 1)) y2 = max(0, min(y2, h - 1))另外,M3FD 中确实存在个别空标注文件(没有任何目标),这在 COCO 格式下没问题,因为annotations为空数组,但 YOLO 格式下会生成一个 0 字节的 txt 文件,YOLOv5 对此没有报错。不过你要留意:如果某个子集中空标注图片占比过高,模型的分类分支会受到负面影响。M3FD 的空标注比例极低,一般不构成问题。
5.6 验证转换结果是否正确的三步自检法
转换完成后不要急着训练,先做这三步验证:
第一步,统计每个类别的标注数量,和官方论文中的分布做对比。如果数量差太远,可能是类别映射表写错了。第二步,随机抽 5 张图,分别用 YOLO 格式、VOC 格式、COCO 格式可视化,确认边界框位置一致。第三步,用 YOLOv5 的val.py直接跑一个随机权重模型,看能不能正常加载数据和计算 loss。
这里给个快速可视化的代码片段,用于检查 YOLO 格式的标注:
import cv2 def visualize_yolo_label(img_path, txt_path, class_names): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path, 'r') as f: for line in f.readlines(): parts = line.strip().split() cls_id, x_c, y_c, bw, bh = int(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) x1 = int((x_c - bw / 2) * w) y1 = int((y_c - bh / 2) * h) x2 = int((x_c + bw / 2) * w) y2 = int((y_c + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, class_names[cls_id], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return img # 使用示例 img = visualize_yolo_label( 'M3FD/Train/visible/000001.jpg', 'M3FD/Train/labels/000001.txt', ['person', 'car', 'bus', 'motorcycle', 'lamp', 'truck', 'bicycle'] ) cv2.imwrite('yolo_check.jpg', img)如果你转换后的 YOLO 格式可视化出来,框的位置和原始 TXT 一致,那说明转换逻辑没问题。
6. 融会贯通:一个通用的多光谱数据准备流程
M3FD 只是多光谱目标检测的起点。当你把这份数据按照上面流程处理好之后,其实你就掌握了一套通用的多光谱数据准备方法论,这套方法论可以复用到其他类似数据集上。
6.1 多光谱数据准备的标准流程
我来总结一下通用的流程,分为六个步骤:
第一步,摸底。下载数据后先别急着转换,用可视化脚本把图像和标注画出来,确认数据完整性和标注质量。这一步花 30 分钟,能省下后面几天的调试时间。
第二步,统一命名规范。确保所有图像文件命名无重复、格式统一(jpg、png 等),标签文件与图像文件一一对应。M3FD 本身没问题,但如果你合并多个数据集做成多模态评测基准,这一步就非常关键。
第三步,选择基准格式。以 M3FD 的实践来看,我建议把"绝对像素坐标"作为中间格式,也就是先解析成统一的 Python 数据结构,再转成目标格式。这样不管是转 YOLO、VOC 还是 COCO,都只需要改一个输出函数,不需要反复解析原始文件。
第四步,生成多格式数据。按需生成 YOLO 目录结构和 data.yaml、VOC XML 文件、COCO JSON 文件。如果多个框架同时用,可以一次性生成好,后面切换框架时就不用重新跑脚本了。
第五步,写验证脚本。分别对三种格式做可视化检查,确认标注框与目标位置吻合。
第六步,跑通训练。先用小模型、小 epoch 数量、少量数据跑一个"冒烟测试",确认整个 pipeline 贯通后再上完整数据。这一步能拦住 80% 的隐性 bug。
6.2 融合检测的两个额外建议
因为 M3FD 是融合检测数据集,我多给两个建议:
第一,别把可见光和红外图简单拼接成一个六通道输入然后丢给普通 CNN——这样做不是不行,但很难发挥融合检测的优势。你可以试试在 YOLOv5 结构上做一个小改动,把 Backbone 的前几层改成双流结构,分别提取 RGB 和 IR 特征再融合。M3FD 的配准质量很高,这种双流结构比较能体现出优势。
第二,做数据增强时,对可见光和红外图使用相同的随机变换参数(同样的裁剪区域、同样的翻转、同样的缩放),否则会破坏模态间的配准关系。我在代码里建议用同一组seed或直接在一个循环里同时处理两幅图。
6.3 遇到问题时的排查思路
最后分享一个排查思路框架,当你转换后训练效果异常时,按这个顺序排查:
- 原始标注是否正确(可视化 M3FD 原始 TXT)
- 转换后标注是否正确(可视化 YOLO/VOC/COCO 标注)
- 数据加载是否正确(在训练框架中打印一条样本的 bbox 坐标)
- 模型结构是否正确(用单张图前向推理,看输出尺寸)
- 训练超参会话是否正确(学习率、batch size、anchor 是否适配)
这套思路看起来简单,但绝大多数"训练不收敛""mAP 为 0"的问题,到最后都会定位到第一步或者第二步——根本原因就是数据不对,而不是模型不行。
M3FD 的数据转换这件事,本质上不复杂,难点全在细节上。你有可能会一次性遇到上面好几个坑,但只要理解了坐标格式的本质差异,加上用可视化脚本不断确认,整个流程跑通并不会花太长时间。