2026/10/17 1:11:26

mmsegmentation训练自定义分割数据集:从路径配置到palette对齐的完整避坑指南

mmsegmentation训练自定义分割数据集:从路径配置到palette对齐的完整避坑指南 1. 为什么“用mmsegmentation训练自己的分割数据集”这件事90%的人卡在第一步就放弃了你是不是也这样下载好了mmsegmentation跑通了官方COCO-Stuff的demo信心满满地准备喂进自己的数据——结果FileNotFoundError: [Errno 2] No such file or directory: data/my_dataset/ann_dir/train/001.png直接弹出来或者更隐蔽的训练启动了loss掉得飞快但验证时mIoU死死卡在0.3可视化一看模型把整张图全涂成背景色又或者改了配置文件加了--cfg-options data.train.data_rootxxx却报错KeyError: train翻遍GitHub Issues发现几十个类似提问但没人说清楚到底该动哪几行、为什么动。这不是你手笨。这是mmsegmentation作为工业级框架的典型特征它不拒绝你但也不主动拉你一把。它的设计哲学是“为有经验者提供极致自由”而不是“为新手铺平所有路”。它默认你已理解语义分割的数据组织范式、类别ID映射逻辑、数据增强链的执行顺序、以及最重要的——配置继承机制的本质不是复制粘贴而是参数覆盖的优先级树。我带过三届某高校实验室的图像处理实训每届都有至少7名同学在这个环节卡住超48小时。最典型的失败路径是从网上抄一份configs/segformer/segformer_mit-b0_512x512_160k_ade20k.py把里面所有ade20k替换成my_dataset再把num_classes150改成num_classes4就以为万事大吉。结果训练完一测mIoU0.0。问题出在哪根本不在模型结构而在classes (background, road, car, person)这行代码没写更致命的是palette [(0,0,0), (128,0,0), (0,128,0), (0,0,128)]这个颜色映射表没配——而mmsegmentation在验证和可视化阶段严格依赖palette中索引位置与类别ID的一一对应关系。如果你只改了num_classes却漏了classes和palette模型内部的类别ID0~3会按默认顺序映射到错误的语义标签上导致预测结果完全错乱。这背后是语义分割任务一个常被忽略的底层约定数据集不是一堆图片标注图的简单集合而是一个带有严格元信息契约的结构化实体。你的标注图像素值不是任意数字而是必须代表预定义的类别ID你的classes元组顺序就是ID 0,1,2,3所对应的语义名称你的palette列表就是ID 0,1,2,3在可视化时应渲染的颜色。三者缺一不可且必须严格对齐。mmsegmentation不会帮你校验这个对齐关系它只相信你给它的配置。所以这篇文章不讲“如何安装mmsegmentation”因为那三行命令pip install mmcv-full、pip install mmsegmentation网上一搜一大把也不讲“如何修改config文件”因为照着模板改参数谁都会。我要带你走一条真实的、带着泥泞和报错日志的路从你拿到原始标注数据那一刻起到第一轮有效验证指标出现为止每一个必须亲手敲下的命令、每一处必须手动核对的细节、每一个看似微小却会导致后续全盘崩溃的陷阱。这条路没有捷径但走通一次你就真正拿到了语义分割项目的入场券。2. 数据准备不是“放对文件夹”而是构建一个可被mmsegmentation无歧义解析的契约体系很多人以为数据准备就是把图片放进img_dir、标注图放进ann_dir。错了。mmsegmentation对数据目录结构的解析本质上是一次严格的模式匹配。它不关心你文件夹叫什么只关心你告诉它的路径是否能通过os.path.join(data_root, img_dir, filename)和os.path.join(data_root, ann_dir, filename)拼出真实存在的文件。而这个“filename”的生成逻辑由你的数据集类如CustomDataset和data_prefix共同决定。这才是90%人栽跟头的第一步。2.1 目录结构的黄金法则绝对路径思维取代相对路径直觉假设你的原始数据长这样/home/user/my_project/ ├── raw_images/ │ ├── 001.jpg │ ├── 002.jpg │ └── ... ├── raw_annotations/ │ ├── 001.png │ ├── 002.png │ └── ...你绝不能直接把raw_images和raw_annotations当作img_dir和ann_dir。因为mmsegmentation的CustomDataset默认会尝试读取data_root/img_dir/filename和data_root/ann_dir/filename。如果你设data_root/home/user/my_project/img_dirraw_images那么它会去找/home/user/my_project/raw_images/001.jpg——这没问题。但如果你的标注图是raw_annotations/001.png而你在配置里写ann_dirraw_annotations它确实能找到。问题在于文件名后缀和匹配逻辑。mmsegmentation默认认为图片和标注图的文件名不含后缀必须完全一致。也就是说001.jpg必须对应001.png。但现实中你的标注图可能是001_mask.png、001_label.png甚至001.json需转换。这时候你不能靠重命名来解决因为重命名会破坏原始数据的可追溯性。正确做法是在数据集类中重写get_data_info()方法显式控制img_path和ann_path的生成逻辑。但别急着写代码。先用最稳妥的“零侵入”方案统一重命名并建立符号链接。这是我给所有初学者的硬性建议因为它规避了所有自定义数据集类的复杂性且100%兼容mmsegmentation所有内置功能包括分布式训练、多尺度测试等。操作步骤Linux/macOS终端# 进入你的项目根目录 cd /home/user/my_project # 创建标准数据结构目录 mkdir -p data/my_dataset/{img_dir,ann_dir} # 批量重命名并软链接将raw_annotations/001_mask.png - data/my_dataset/ann_dir/001.png for f in raw_annotations/*_mask.png; do basename$(basename $f _mask.png) ln -sf $(realpath $f) data/my_dataset/ann_dir/${basename}.png done # 同理处理图片raw_images/001.jpg - data/my_dataset/img_dir/001.jpg for f in raw_images/*.jpg; do ln -sf $(realpath $f) data/my_dataset/img_dir/$(basename $f) done提示使用ln -sf创建软链接而非cp复制是为了保留原始数据的完整性避免磁盘空间浪费且后续修改原始数据时训练数据自动同步更新。2.2 标注图的像素值一场关于ID合法性的静默战争即使文件路径完美无缺训练仍可能失败。原因往往藏在标注图的像素值里。mmsegmentation要求标注图是单通道灰度图PIL mode L且每个像素值必须是0到num_classes-1之间的整数。但你的标注工具如LabelMe、CVAT导出的PNG很可能包含以下致命问题像素值溢出你定义了4个类别但标注图里出现了像素值5、10、255。这通常是因为LabelMe的JSON导出时将不同形状的label字符串映射成了非连续ID。像素值偏移你期望road1但实际标注图里road区域全是像素值2。这是因为某些工具默认将背景background设为0而第一个前景类别从1开始编号但你的classes元组却把background放在了索引0导致ID错位。Alpha通道污染PNG文件带了透明通道mode RGBAPIL读取时会将其转为四通道而mmsegmentation的LoadAnnotations变换只处理单通道。验证方法Python脚本务必运行from PIL import Image import numpy as np import os ann_dir data/my_dataset/ann_dir all_files [f for f in os.listdir(ann_dir) if f.endswith(.png)] if not all_files: print(警告ann_dir为空) exit() # 随机取3个文件检查 for f in np.random.choice(all_files, min(3, len(all_files)), replaceFalse): path os.path.join(ann_dir, f) try: img Image.open(path) arr np.array(img) print(f{f}: mode{img.mode}, shape{arr.shape}, unique_values{np.unique(arr)}) if len(arr.shape) 2: print(f ❌ 错误{f} 不是单通道图shape{arr.shape}) if np.max(arr) 4: # 假设num_classes4 print(f ❌ 错误{f} 包含非法像素值 {np.max(arr)}超出[0,3]) if np.min(arr) 0: print(f ❌ 错误{f} 包含负值 {np.min(arr)}) except Exception as e: print(f{f} 读取失败{e})修复方案批量处理from PIL import Image import numpy as np import os def fix_annotation_image(src_path, dst_path, num_classes4, background_id0): 将任意标注图转换为符合mmsegmentation要求的格式 img Image.open(src_path) # 强制转为单通道 if img.mode ! L: img img.convert(L) arr np.array(img) # 创建映射字典将原始像素值映射到0~num_classes-1 # 这里需要你根据实际情况填写例如{0:0, 255:1, 128:2, 64:3} # 更安全的做法是先用上面的验证脚本找出所有unique值再人工确认其语义 value_map {0: background_id} # 背景ID固定为0 # 假设你已知原始255road, 128car, 64person if num_classes 1: value_map.update({255: 1}) if num_classes 2: value_map.update({128: 2}) if num_classes 3: value_map.update({64: 3}) # 应用映射 new_arr np.zeros_like(arr) for old_val, new_val in value_map.items(): new_arr[arr old_val] new_val # 确保所有值都在合法范围内 new_arr np.clip(new_arr, 0, num_classes-1) # 保存为PNG Image.fromarray(new_arr.astype(np.uint8)).save(dst_path) # 批量处理 for f in os.listdir(raw_annotations): if f.endswith(_mask.png): src os.path.join(raw_annotations, f) dst os.path.join(data/my_dataset/ann_dir, f.replace(_mask.png, .png)) fix_annotation_image(src, dst, num_classes4)注意value_map的构建是核心。你必须亲自打开几张标注图用图像查看器如GIMP、Photoshop的吸管工具点选每个语义区域记录下其RGB值或灰度值再与你的classes元组顺序一一对应。这是无法自动化、必须人工介入的环节。跳过这一步等于在训练前就埋下了无法调试的定时炸弹。2.3 classes与palette让模型“看懂”你画的语义地图当路径和像素值都合规后最后的堡垒是classes和palette。它们不是可选项而是mmsegmentation进行类别ID到语义名称、ID到可视化颜色的强制映射表。缺失或错位会导致Class names are not provided警告虽不中断训练但影响日志可读性mIoU计算时类别混淆因为评估脚本用classes来索引混淆矩阵可视化结果颜色错乱show_result函数用palette来上色正确配置示例在你的config文件中# 在config文件顶部或dataset配置块内 classes (background, road, car, person) palette [(0, 0, 0), (128, 0, 0), (0, 128, 0), (0, 0, 128)]关键规则len(classes)必须等于num_classeslen(palette)必须等于len(classes)palette[i]是一个三元组(R, G, B)对应classes[i]这个类别的可视化颜色classes[0]必须是背景类background其ID为0palette[0]为其颜色为什么background必须是第一个因为mmsegmentation的评估指标如mIoU默认将ID 0视为忽略类别ignore_index但在CustomDataset中ignore_index默认为-1。真正的约束来自mmseg/core/evaluation/metrics.py中的eval_metrics函数它用classes列表的索引来构建结果字典。如果你把car放在第一位那么ID 0就代表car而你的标注图里ID 0其实是background整个评估就崩了。实操检查清单✅ 打开一张标注图用np.unique(np.array(Image.open(xxx.png)))确认最大像素值是3num_classes4时✅ 检查classes元组索引0是background索引1是road...✅ 检查palette列表palette[0]是(0,0,0)黑色palette[1]是(128,0,0)暗红...✅ 运行python tools/misc/print_config.py configs/_base_/datasets/my_dataset.py确认输出中classes和palette字段已正确加载3. 配置文件改造不是“改几个数字”而是理解mmsegmentation的配置继承树与参数覆盖优先级mmsegmentation的配置系统是其强大与混乱的根源。它采用多层继承_base_和动态覆盖--cfg-options机制。很多人的错误源于把配置文件当成一个扁平的参数列表而忽略了它是一棵有父子关系、有覆盖优先级的树。3.1 解剖一个标准配置segformer_mit-b0_512x512_160k_ade20k.py的骨架我们以configs/segformer/segformer_mit-b0_512x512_160k_ade20k.py为蓝本逐层拆解其结构。这不是为了背诵而是为了建立“配置即代码”的直觉。# 第一层基础组件继承_base_ _base_ [ ../_base_/models/segformer_mit-b0.py, # 模型结构定义 ../_base_/datasets/ade20k.py, # 通用数据集配置含pipeline ../_base_/default_runtime.py, # 运行时设置log、checkpoint等 ../_base_/schedules/schedule_160k.py # 学习率调度 ] # 第二层当前配置的局部覆盖同级覆盖 crop_size (512, 512) # 覆盖了ade20k.py里的crop_size data_preprocessor dict( typeSegDataPreProcessor, mean[123.675, 116.28, 103.53], # 覆盖了base里的mean std[58.395, 57.12, 57.375], bgr_to_rgbTrue, pad_val0, seg_pad_val255) # 第三层数据集具体实例化覆盖ade20k.py里的data部分 data_root data/ade20k # 这是关键它覆盖了ade20k.py里data_root的默认值 train_dataloader dict( batch_size2, datasetdict( data_rootdata_root, # 继承上一行 data_prefixdict( img_pathimages/training, seg_map_pathannotations/training), pipelinetrain_pipeline)) # pipeline来自ade20k.py看到这里你应该明白data_root不是一个孤立的变量它是整个数据集配置的锚点。当你想切换到自己的数据集时你不是在“修改一个配置”而是在“覆盖一棵子树”。ade20k.py定义了一个完整的、可工作的数据集配置你只需用data_root和data_prefix将其指向你的目录并用classes/palette注入你的语义信息。3.2 创建你的专属配置my_dataset.py的最小可行版本在configs/_base_/datasets/目录下新建my_dataset.py。内容如下请逐行理解不要复制# configs/_base_/datasets/my_dataset.py # 1. 继承通用数据集配置提供pipeline、dataloader等骨架 _base_ [../ade20k.py] # 复用ade20k的pipeline因其已针对分割任务优化 # 2. 定义你的数据集元信息核心 dataset_type CustomDataset data_root data/my_dataset # 你的数据根目录必须是绝对路径或相对于configs的相对路径 # 3. 定义你的语义类别必须 classes (background, road, car, person) palette [(0, 0, 0), (128, 0, 0), (0, 128, 0), (0, 0, 128)] # 4. 构建数据集字典覆盖ade20k.py里的data部分 # 注意这里没有写train_pipeline因为它已由_base_继承而来 train_dataloader dict( batch_size2, num_workers2, persistent_workersTrue, samplerdict(typeInfiniteSampler, shuffleTrue), datasetdict( typedataset_type, data_rootdata_root, data_prefixdict( img_pathimg_dir, # 对应你创建的data/my_dataset/img_dir seg_map_pathann_dir), # 对应你创建的data/my_dataset/ann_dir pipeline${...train_pipeline}, # 继承ade20k.py里的train_pipeline # 关键注入classes和palette metainfodict(classesclasses, palettepalette))) val_dataloader dict( batch_size1, num_workers2, persistent_workersTrue, samplerdict(typeDefaultSampler, shuffleFalse), datasetdict( typedataset_type, data_rootdata_root, data_prefixdict( img_pathimg_dir, seg_map_pathann_dir), pipeline${...test_pipeline}, metainfodict(classesclasses, palettepalette))) test_dataloader val_dataloader # 测试集通常与验证集相同 # 5. 评估配置覆盖ade20k.py里的val_evaluator val_evaluator dict(typeIoUMetric, iou_metrics[mIoU]) test_evaluator val_evaluator这段配置的精妙之处在于${...train_pipeline}语法。它表示“继承上一级即ade20k.py中定义的train_pipeline变量”。这意味着你无需重复书写复杂的LoadImageFromFile、Resize、RandomFlip等数据增强流程它们已由ade20k.py为你精心配置好。你只需专注在metainfo上注入你的classes和palette这就是“最小改动最大效果”的工程智慧。3.3 主训练配置如何选择基线模型并注入你的数据集现在创建你的主配置文件例如configs/my_segformer_b0_512x512_160k_mydataset.py# configs/my_segformer_b0_512x512_160k_mydataset.py # 1. 继承模型、你的数据集、运行时、学习率调度 _base_ [ ../_base_/models/segformer_mit-b0.py, # 模型结构 ../_base_/datasets/my_dataset.py, # 你的数据集刚创建的 ../_base_/default_runtime.py, # 运行时 ../_base_/schedules/schedule_160k.py # 学习率 ] # 2. 覆盖模型的num_classes必须 model dict( decode_headdict( num_classes4 # 你的类别数必须与classes元组长度一致 ) ) # 3. 覆盖数据预处理器的num_classes必须 data_preprocessor dict( typeSegDataPreProcessor, mean[123.675, 116.28, 103.53], std[58.395, 57.12, 57.375], bgr_to_rgbTrue, pad_val0, seg_pad_val255, size(512, 512) # 输入尺寸与crop_size一致 ) # 4. 覆盖训练轮数可选schedule_160k.py默认160k iters train_cfg dict( typeIterBasedTrainLoop, max_iters160000, val_interval1000) # 每1000次迭代验证一次 # 5. 覆盖优化器可选使用schedule_160k.py里的默认AdamW # 如果你想调学习率可以在这里覆盖 # optim_wrapper dict( # optimizerdict( # lr0.0001 # 默认是0.00006可微调 # ) # )提示model.decode_head.num_classes4和data_preprocessor中的size是两个最容易遗漏的覆盖点。前者决定了模型最后一层的输出维度后者决定了输入网络的图像尺寸。如果size与你的crop_size不一致数据增强后的图像会被二次缩放引入插值伪影。3.4 启动训练命令行的艺术与常见报错直击一切就绪启动训练# 单卡训练最常用便于调试 python tools/train.py configs/my_segformer_b0_512x512_160k_mydataset.py # 多卡训练正式训练 torchrun --nproc_per_node4 tools/train.py configs/my_segformer_b0_512x512_160k_mydataset.py # 带配置覆盖的训练快速实验 python tools/train.py configs/my_segformer_b0_512x512_160k_mydataset.py \ --cfg-options model.decode_head.num_classes4 \ data_preprocessor.size(512,512) \ train_cfg.val_interval500常见报错与直击方案KeyError: train你的my_dataset.py里没有定义train_dataloader字典或者_base_继承路径写错了如写成../ade20k漏了.py。FileNotFoundError: .../img_dir/001.jpg检查data_root和data_prefix.img_path拼出的绝对路径是否存在。用ls -l $(python -c print(data/my_dataset/img_dir/001.jpg))验证。AssertionError: The length of classes should be equal to the number of classesmodel.decode_head.num_classes与classes元组长度不一致。RuntimeError: CUDA out of memory降低batch_size或在train_dataloader中添加persistent_workersFalse。4. 训练监控与结果验证从loss曲线到像素级可信度的完整闭环训练启动后屏幕滚动的loss值只是冰山一角。真正的挑战在于如何判断模型是否真的在学习它的预测是否可信哪里出了问题这需要一套完整的监控与验证闭环。4.1 TensorBoard实时监控不止看loss更要盯住梯度与权重mmsegmentation默认启用TensorBoard日志。训练开始后立即开启监控tensorboard --logdirwork_dirs/my_segformer_b0_512x512_160k_mydataset --port6006在浏览器访问http://localhost:6006重点关注SCALARS标签页train/loss: 总损失应平稳下降。若剧烈震荡检查batch_size是否过小或学习率过大。train/decode.loss_seg: 解码头的分割损失是核心指标。若它下降而总loss不降说明辅助头auxiliary head在捣鬼。learning_rate: 确认学习率按预期衰减如schedule_160k.py是poly衰减。HISTOGRAMS标签页model/decode_head.conv_seg.weight: 权重分布。训练初期应呈正态分布后期若出现大量0值说明有神经元死亡。model/decode_head.conv_seg.bias: 偏置项。若某类别的bias远大于其他说明该类别样本少模型倾向于预测它。IMAGES标签页train/input_img: 输入图像确认数据增强如RandomFlip、PhotoMetricDistortion是否生效。train/gt_seg_map: 真实标注图确认palette颜色是否正确背景黑、road暗红...。注意IMAGES标签页默认只记录前4张图。若想看更多修改my_dataset.py中train_dataloader.dataset.pipeline在PackSegInputs之前插入ShowResult变换但这会显著降低训练速度仅用于调试。4.2 验证集mIoU不只是一个数字而是类别平衡的晴雨表mIoUmean Intersection over Union是语义分割的黄金指标。但它是一个平均值会掩盖类别不平衡问题。例如background占90%像素person只占0.1%mIoU0.7可能意味着background的IoU是0.95而person只有0.05。因此必须查看详细类别IoU。训练过程中mmsegmentation会在日志中打印2023-10-01 12:00:00,000 - mmengine - INFO - Validating [] 100/100, elapsed: 120s, ETA: 0s 2023-10-01 12:02:00,000 - mmengine - INFO - Evaluating mIoU... 2023-10-01 12:02:05,000 - mmengine - INFO - ------------------------------------ | Class | IoU | Acc | F1 | Recall| ------------------------------------ | backgr | 0.923 | 0.951 | 0.937 | 0.923 | | road | 0.781 | 0.824 | 0.801 | 0.781 | | car | 0.652 | 0.712 | 0.681 | 0.652 | | person | 0.321 | 0.405 | 0.358 | 0.321 | ------------------------------------ | mIoU | 0.669 | | | | ------------------------------------关键洞察person的IoU0.321远低于其他类说明模型对小目标、边缘模糊的目标泛化能力差。此时不应盲目增加训练轮数而应检查person类别的训练样本是否过少标注质量是否较差边缘模糊数据增强是否过度裁剪了小目标解决方案数据层面对person类别样本进行过采样在train_dataloader.sampler中使用ClassBalancedSampler。增强层面在train_pipeline中为小目标增加RandomCrop的cat_max_ratio0.75确保裁剪后person区域占比不低于75%。损失层面改用CrossEntropyLoss的class_weight参数为person类赋予更高权重。4.3 可视化预测结果用眼睛做最终仲裁数字指标再好也不如亲眼所见。mmsegmentation提供了强大的可视化工具# 对单张图进行推理并保存可视化结果 python tools/test.py configs/my_segformer_b0_512x512_160k_mydataset.py \ work_dirs/my_segformer_b0_512x512_160k_mydataset/iter_160000.pth \ --show-dir work_dirs/my_segformer_b0_512x512_160k_mydataset/vis_results \ --out work_dirs/my_segformer_b0_512x512_160k_mydataset/preds.pkl # 或者交互式可视化需要GUI环境 python demo/image_demo.py configs/my_segformer_b0_512x512_160k_mydataset.py \ work_dirs/my_segformer_b0_512x512_160k_mydataset/iter_160000.pth \ demo/demo.jpg \ --show可视化结果会生成三张图demo.jpg: 原图demo_pred.png: 预测的语义分割图用palette上色demo_overlay.png: 原图与预测图的叠加半透明此时用“侦探眼光”审视边缘一致性预测的road边缘是否与原图道路边缘严丝合缝还是存在1-2像素的偏移偏移说明模型对边界定位不准需加强EdgeAwareLoss或使用BoundaryLoss。内部连贯性一辆car的预测结果是否是完整、连贯的一个区域还是被切成几块碎片碎片化说明模型感受野不足或上下文建模失败可尝试换用更大的backbone如mit-b2或增加ASPP模块。背景污染person区域内部是否有road或background的杂色斑点这通常是dropout率过高或正则化过强的表现可降低model.backbone.drop_path_rate。经验之谈我曾调试一个car分割项目mIoU稳定在0.75但可视化发现所有car预测图都像“打了马赛克”。最终定位到train_pipeline中PhotoMetricDistortion的brightness_delta32过大导致模型只记住了车的大致亮度范围而忽略了精细纹理。将brightness_delta降至16后马赛克消失mIoU提升至0.79。5. 从训练完成到部署落地模型导出、推理加速与跨平台兼容性实战训练出一个高mIoU的模型只是万里长征第一步。真正的价值在于让这个模型走出实验室跑在你的嵌入式设备、手机App或Web服务上。这中间隔着模型导出、推理引擎适配、性能调优三座大山。5.1 模型导出ONNX是通用语言但细节决定成败mmsegmentation支持导出为ONNX格式这是跨平台部署的基石。但直接tools/deployment/pytorch2onnx.py常常失败原因在于SegDataPreProcessor中的动态操作如pad无法被ONNX静态图捕获。正确导出流程分两步# 步骤1导出不带预处理的纯模型core model python tools/deployment/pytorch2onnx.py \ configs/my_segformer_b0_512x512_160k_mydataset.py \ work_dirs/my_segformer_b0_512x512_160k_mydataset/iter_160000.pth \ --output-file work_dirs/my_segformer_b0_512x512_160k_mydataset/model.onnx \ --shape 1 3 512 512 \ --dynamic-export \ --without-postprocess # 步骤2编写独立的预处理脚本preprocess.py import cv2 import numpy as np def preprocess_image(image_path, target_size(512, 512)): 复现mmsegmentation的SegDataPreProcessor逻辑 img cv2.imread(image_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # Resize img cv2.resize(img, target_size, interpolation