☰
mmsegmentation自定义数据集训练实战:配置、避坑与调优
2026/10/5 13:35:06 网站建设 项目流程

1. 引子:从一次自定义数据集训练翻车说起

先说个我自己的经历。之前做工地安全帽检测,拿到的标注是LabelMe导出的JSON,想着直接转成VOC格式喂给mmsegmentation训练,结果第一轮loss就没降过,一路飙到NaN。排查了半天,发现是标注文件里有一个类别的像素值写成了255,而我的config里num_classes还写的21。当时的感受就是:mmsegmentation这套工具链本身很成熟,但真正卡住人的往往是数据准备和config配置这些“看不见的细节”。

后来把整个过程踩过的坑整理出来,形成了这套相对顺畅的流程。如果你正准备用mmsegmentation训练自己的语义分割模型,但又不太想啃那几百页文档,这篇教程应该能帮你省下至少两三天的时间。

适合谁看:已经装好mmsegmentation环境,想做自定义数据集训练的人。不管你是做遥感、医疗影像,还是工业质检,核心流程完全一致。我会从数据组织开始,讲到config改哪些参数、怎么改,再到训练命令怎么敲,最后附上我遇到过的高频问题和解决办法。

2. 自定义数据集:先搞懂mmsegmentation期望什么格式

2.1 目录结构:最简单的组织方式

mmsegmentation官方支持很多数据集格式,但自己造数据时,我建议从简。VOC风格是最省事的,目录结构就是:

data/ └── my_dataset/ ├── img_dir/ │ ├── train/ │ │ ├── 0001.jpg │ │ ├── 0002.jpg │ └── val/ │ ├── 0001.jpg │ └── 0002.jpg └── ann_dir/ ├── train/ │ ├── 0001.png │ ├── 0002.png └── val/ ├── 0001.png └── 0002.png

图像格式不限,jpg、png都行。但标注图请一律使用png。为什么?因为jpg是有损压缩,如果你把mask存成jpg,类别边缘会出现一圈“渐变色”,导致像素值不再是干净的0、1、2这些整数,训练时Loss直接乱掉。这是最隐蔽也最常见的坑。

2.2 标注图的像素值规则

标注图里,每个像素的值就是这个像素所属的类别ID。比如我做过一个路面病害分割项目,定义了三类:背景=0、裂缝=1、修补区域=2。那标注图里,背景区域的所有像素值都是0,裂缝区域都是1,修补区域都是2。

这类标注图用肉眼直接看是“纯黑”的,因为像素值普遍很小,接近黑色。所以最稳妥的检查方式是代码验证,而不是用眼睛看:

import numpy as np from PIL import Image mask = np.array(Image.open("data/my_dataset/ann_dir/train/0001.png")) print("像素值集合:", np.unique(mask)) print("图像尺寸:", mask.shape, "通道数:", mask.ndim)

正常输出应该类似:像素值集合: [0 1 2]。如果出现255、128这类值,就要回头检查标注工具和转换脚本了。

注意:标注图最好是单通道(灰度模式)或者调色板模式。如果保存成了三通道的RGB图,三个通道的值虽然一样,但mmseg读取时可能做one-hot编码出错。建议统一用PIL的'L'模式保存:

from PIL import Image import numpy as np mask = np.array(...) # HxW的整数数组 mask_img = Image.fromarray(mask.astype(np.uint8), mode='L') mask_img.save("data/my_dataset/ann_dir/train/0001.png")

2.3 从零写一个Dataset类:以CustomDataset为例

目录结构准备好了之后,需要让mmsegmentation认识你的数据集。最好的办法是直接继承官方现成的CustomDataset,只需要改三个东西:CLASSES、PALETTE、还有类名本身。

在mmseg/datasets/目录下新建一个文件,比如mydataset.py:

from mmseg.registry import DATASETS from .basesegdataset import BaseSegDataset @DATASETS.register_module() class MyDataset(BaseSegDataset): METAINFO = dict( classes=('background', 'crack', 'repair'), palette=[[0, 0, 0], [255, 0, 0], [0, 255, 0]] ) def __init__(self, img_suffix='.jpg', seg_map_suffix='.png', reduce_zero_label=False, **kwargs): super().__init__( img_suffix=img_suffix, seg_map_suffix=seg_map_suffix, reduce_zero_label=reduce_zero_label, **kwargs)

这里有个参数很容易被忽略:reduce_zero_label。它的作用是:如果标注里有类别ID为0的像素,且你又不想让0参与训练(比如远程感知里ignore_index),就把它设成True。它会把所有类别ID减1,原ID 0变成255(ignore),原ID 1变成0,依次类推。如果你已经把背景标成了0,那这个参数必须保持False,否则你的模型会莫名其妙少一个类别。

然后在mmseg/datasets/__init__.py里把自定义类导出来:

from .mydataset import MyDataset

2.4 关于__init__.py和注册机制

第一次接触OpenMMLab系列的人会被“注册器”这个概念绕晕。我换个生活化的说法:注册器就像酒店的前台。你到酒店check-in,前台把你的名字(MyDataset)和你的房间号(类定义)登记在册。之后经理(config)说“找MyDataset来干活”,前台就能直接带你进去。

DATASETS.register_module()就是一个check-in的动作。它告诉mmseg:“这个类以后可以用了”。所以如果你只写了文件、忘了在__init__.py里导入,等于办了入住但没告诉前台,报错就很常见:

KeyError: 'MyDataset is not in the dataset registry'

这个错误我见过身边人踩了无数次,基本可以绕开。这个注册机制贯穿整个mmsegmentation,模型、backbone、decode_head、loss,全都是这种思路,所以花十分钟理解它不亏。

3. Config文件:训练的灵魂

3.1 Config的整体组成结构

mmsegmentation的config文件采用“继承”机制。官方把所有通用配置拆成了_base_目录下的几个组件,写了两次以上训练实验后,你会发现这个设计特别优雅:

  • _base_/datasets/:数据集配置
  • _base_/schedules/:训练策略,比如学习率、epoch、优化器
  • _base_/default_runtime.py:运行时配置,比如log、checkpoint保存
  • 模型自身的配置,通常写在具体模型config里

继承的好处是复用,坏处是难以追踪最终参数状态。我第一次改config时曾疑惑“到底哪些参数生效了?”后来习惯性用官方提供的工具查看:

python tools/print_config.py /path/to/your/config.py

这个命令会把所有继承合并后的最终配置打印出来,排查参数问题非常高效。

3.2 Dataset相关配置:哪些参数必须动

以我常用的PSPNet为例,自定义数据集时,核心配置长这样:

dataset_type = 'MyDataset' data_root = 'data/my_dataset/' train_pipeline = [ dict(type='LoadImageFromFile'), dict(type='LoadAnnotations'), dict(type='Resize', img_scale=(2048, 1024), ratio_range=(0.5, 2.0)), dict(type='RandomCrop', crop_size=(512, 512), cat_max_ratio=0.75), dict(type='RandomFlip', prob=0.5), dict(type='PackSegInputs') ] train_dataloader = dict( batch_size=4, num_workers=4, persistent_workers=True, sampler=dict(type='InfiniteSampler', shuffle=True), dataset=dict( type=dataset_type, data_root=data_root, data_prefix=dict( img_path='img_dir/train', seg_map_path='ann_dir/train'), pipeline=train_pipeline) )

逐参数解释一下:

  • img_scale=(2048, 1024):这个尺寸不是输入模型的大小,而是“随机缩放”的基准尺寸。训练时mmseg会在这个尺度附近随机缩放后再裁剪。如果你的GPU显存不大,这个值建议调小,比如(1024, 512),或者干脆不给这个参数,让模型按原始尺寸处理。
  • crop_size=(512, 512):这是真正送入模型的裁剪尺寸。显存吃紧就调小,但别低于256,不然分割细节会严重丢失。
  • cat_max_ratio=0.75:这个参数很有意思,它限制了裁剪框里某个类别最多占多少面积。如果你是做道路、背景占比很大的数据,这个参数一定要设。否则RandomCrop很容易截到一整块纯背景区域,训练基本没效果。0.75的意思是:如果随机裁剪区域里背景占了超过75%,就重新裁一次。

验证集pipeline就简单多了,只做缩放和归一化:

val_pipeline = [ dict(type='LoadImageFromFile'), dict(type='LoadAnnotations'), dict(type='Resize', img_scale=(2048, 1024), ratio_range=None, keep_ratio=True), dict(type='PackSegInputs') ]

注意验证集的Resize不要加RandomCrop,用keep_ratio=True保持原始宽高比,模型会对全图推理。

3.3 模型配置里的num_classes和通道数

模型config中需要修改的核心是decode_head和auxiliary_head(如果有的话)。

model = dict( type='EncoderDecoder', backbone=dict( type='ResNetV1c', depth=50, num_stages=4, out_indices=(0, 1, 2, 3), dilations=(1, 1, 2, 4), strides=(1, 2, 1, 1), norm_cfg=dict(type='BN', requires_grad=True), norm_eval=False, style='pytorch', contract_dilation=True ), decode_head=dict( type='PSPHead', in_channels=2048, in_index=3, channels=512, pool_scales=(1, 2, 3, 6), dropout_ratio=0.1, num_classes=3, # 这里改成自定义的类别数 loss_decode=dict(type='CrossEntropyLoss', use_sigmoid=False, loss_weight=1.0) ), auxiliary_head=dict( type='FCNHead', in_channels=1024, in_index=2, channels=256, num_convs=1, concat_input=False, dropout_ratio=0.1, num_classes=3, # 和decode_head保持一致 loss_decode=dict(type='CrossEntropyLoss', use_sigmoid=False, loss_weight=0.4) ), train_cfg=dict(), test_cfg=dict(mode='whole') )

这里有几个点容易出错。第一,num_classes要改成你的实际类别数,背景也算一类。第二,auxiliary_head辅助损失头,参数和主decode_head的num_classes必须一致,否则训练时维度不匹配直接报错。第三,decode_head的in_channels和backbone的输出通道有关,我用的ResNet50最后一层输出是2048,这个基本不用动。

损失函数:默认CrossEntropyLoss,如果你的类别特别不平衡(比如裂缝只占整张图的1%),可以考虑加OHEM或者换Dice Loss。但别一开始就上花活,先用基础CE跑通整个流程。

3.4 热词中提到的:为什么有人会给config加“用户名”

有朋友问过:“config里写上自己的用户名是干嘛的?”其实这是团队协作时的一种约定。有些人会把--work-dir里加上自己的名字缩写或用户名,避免多人共用一台服务器训练时,输出目录互相覆盖。比如:

python tools/train.py configs/my_model/pspnet_r50_my_dataset.py \ --work-dir work_dirs/wanghui_pspnet_r50

这在多人共享GPU服务器的场景下很实用。config本身是Python文件,里面存在环境变量或自定义字段也完全合法,但更推荐的做法是训练命令里指定--work-dir,而不是把用户名写死在config里。code review时,config里出现个人信息其实不太好——因为config是要进版本库、给别人复现用的。

我有一次就是因为没有区分work_dir,跑了一晚上的模型被另一个同事第二天早上顶掉重开,血泪教训。后来我们团队规定:训练统一加--work-dir,并在末尾带上用户名缩写,问题再没出现过。

3.5 Schedules配置:学习率和迭代次数怎么定

自定义数据集推荐用官方现成的schedule配置,比如_base_/schedules/schedule_160k.py:

# optimizer optimizer = dict(type='SGD', lr=0.01, momentum=0.9, weight_decay=0.0005) optim_wrapper = dict(type='OptimWrapper', optimizer=optimizer, clip_grad=None) # learning policy param_scheduler = [ dict( type='PolyLR', eta_min=1e-4, power=0.9, begin=0, end=160000, by_epoch=False) ] # training schedule for 160k train_cfg = dict(type='IterBasedTrainLoop', max_iters=160000, val_interval=5000) val_cfg = dict(type='ValLoop') test_cfg = dict(type='TestLoop')

lr=0.01是官方在ImageNet预训练模型上的默认值,如果你换了自己的backbone,比如用了自己训练的SwinTransformer,学习率通常要调小,比如0.001起步。PolyLR是学习率衰减策略,power=0.9意思是训练结束时学习率衰减到初始的约eta_min附近,这个不用改,是通用设置。

最关键的是max_iters和val_interval。自定义数据集通常远小于Cityscapes(Cityscapes有2975张训练图,一个自定义数据集很可能只有几百张),所以我一般把迭代次数砍到20000,验证间隔设为1000。

你如果完全按默认的160000次迭代训练自己只有几百张的数据,会发生什么?模型大概率过拟合——训练loss一路下降,验证mIoU在某个点之后开始下跌。所以自定数据下,先把max_iters缩小,跑通之后再慢慢加。最稳妥的方式是先跑debug模式看单步是否能跑通。

4. 训练实操:命令、显存与监控

4.1 单卡训练与多卡训练

单卡训练最简单:

python tools/train.py configs/my_model/pspnet_r50_my_dataset.py --work-dir work_dirs/wanghui_pspnet

多卡训练用官方脚本:

bash tools/dist_train.sh configs/my_model/pspnet_r50_my_dataset.py 4 --work-dir work_dirs/wanghui_pspnet

这里的4是GPU数量。注意PyTorch和CUDA版本老的人经常栽在分布式通信包上,报错NCCL error基本是环境问题,要不就是多进程共享了同一个显存,要不就是socket配置。一个快速排查方式:

python -c "import torch; print(torch.cuda.device_count())"

先确认PyTorch能看到所有GPU再说别的。

4.2 显存不够怎么办

跑分割训练,显存永远是最重要的资源。我自己在用一张8G显存显卡跑自定义数据时,做过的调整经验比较值得分享:

  1. 降低crop_size:比如(512, 512)降到(320, 320),显存占用几乎按平方下降(裁剪面积缩小)。代价是模型对物体边界的还原能力下降,但对前期复现跑通流程来说,优先级最高。
  2. 减小batch_size:从4改到2,显存减半。很多人不敢改batch_size,其实只要同步把学习率调低一点(比如从0.01降到0.005),效果差别不大。
  3. 开启amp混合精度:这是最推荐的做法,显存能省接近一半,速度几乎不降:
python tools/train.py configs/my_model/pspnet_r50_my_dataset.py --amp --work-dir work_dirs/wanghui_pspnet

新版mmseg已经原生支持--amp,这是一个很好的功能,实测能解决大部分显存焦虑。

注意:如果你的模型里用了Dice Loss这类稳定性较差的损失,开启amp可能偶发NaN。真遇到就先关掉amp试试。

4.3 使用Debug模式快速验证流程

“改了20个文件,一跑就报错,都不知道错在哪个环节。”这是新手最容易遇到的困境。我建议新config第一次训练前,先用debug模式跑两个iteration:

python tools/train.py configs/my_model/pspnet_r50_my_dataset.py --work-dir work_dirs/debug --debug

debug模式会把batch_size强制设为1,并且只跑一次迭代,用来检查数据读取、数据增强、前向传播、loss计算、反向传播整条链路是否能走通。如果这个模式都没报错,说明你的代码链路没问题,再正式训练,人也不用守在终端前盯着看。

4.4 训练过程中的指标怎么看

训练日志默认输出到work_dirs/xxx/下的.log文件,或者画图工具里。最直观的指标是loss和mIoU。没有养成看日志习惯的人经常问我:到底训练到什么程度算收敛?

看分两类。第一,loss曲线:前5000步loss通常会快速下降,之后缓慢下降甚至波动,这很正常。第二,mIoU曲线:验证集mIoU应该整体上升,如果某个节点后mIoU开始下降而loss还在降低,大概率过拟合了,这时候该减小迭代次数或加正则。

5. 常见问题排查与避坑实录

我把自己和周围人踩过的坑整理成一个速查表,按照报错信息和现象排列,方便直接对照:

现象可能原因解决办法
FileNotFoundError: xxx.jpgdata_prefix路径写错,或img_suffix和文件后缀不一致打印data_root+data_prefix拼接后的完整路径,检查是否存在
AssertionError:img_fieldsare not defined indata`pipeline配置里少了PackSegInputs仔细核对pipeline末是否加了PackSegInputs和LoadAnnotations
KeyError: MyDataset is not in the dataset registry忘记在__init__.py里导入自定义类打开mmseg/datasets/__init__.py確認有from .mydataset import MyDataset
训练loss为NaN标注图里有255或异常像素值;学习率过大;amp不稳定用np.unique(mask)检查标注;降低lr;关闭amp试试
验证mIoU为0num_classes与标注类别数不一致;类别ID不连续打印所有标注的unique值,修正类别映射或标注文件
显存OOMcrop_size过大 / batch_size过大缩小crop_size、batch_size,开启--amp
多卡训练NCCL报错CUDA/网络环境问题单卡先跑通;确认torch.cuda.is_available();重启机器或重新安装NCCL
模型只输出背景类背景占比过高,cat_max_ratio没设置设置cat_max_ratio=0.75,或做类别均衡

5.1 标注图255像素值问题

这个值得单独拎出来强调一次。很多标注工具导出时会把“待标注区域”或“边缘”标成255。255在模型里是ignore_index的默认值——不是“第255类”,而是“忽略这些像素,不算loss”。这个设计本身没问题,问题在于:如果你的背景ID是0,而255出现在标注中,它被忽略后,模型不会对那块区域学习任何内容,训练结束后对那块区域输出什么全靠猜,视觉上就是一片花斑。

我的习惯是:每次新数据进来,先全局扫描一遍所有标注图的所有像素值:

import glob import numpy as np from PIL import Image ann_files = glob.glob("data/my_dataset/ann_dir/train/*.png") all_vals = set() for f in ann_files: mask = np.array(Image.open(f)) all_vals.update(np.unique(mask).tolist()) print("全数据集标注像素值:", sorted(all_vals))

如果出现非预期值,宁可重新转一遍标注,也别将就着训练。

5.2 数据增强导致的目标混淆

有次一个做医疗影像的朋友跟我说,他训练了一个分割模型,发现模型把“肝脏”和“脾脏”混淆得很厉害。我问他数据增强怎么配的,他说开了RandomFlip、RandomRotate还有RandomBrightnessContrast。

图像分割和分类最大的不同是:你做的几何变换(翻转、旋转、缩放)必须同时作用在原图和mask上。mmsegmentation自带的RandomFlip、RandomRotate是能做到的,但如果你在pipeline里用了第三方库的增强(比如albumentations),就一定要确认mask也被同步处理了。不然原图转了90度,mask还是原来的方向,模型训练时收到的标签就是错的,它当然学不会。

所以我的原则是:自定义数据集的pipeline优先用mmseg自带算子,等流程成熟了再考虑引入albumentations。这在节省调试时间上很有价值,尤其前期数据和代码逻辑没有完全理顺时。

5.3 VSCode远程连接与config文件管理

现在很多人习惯用VSCode远程连服务器开发。关于改config这里有个小技巧:如果你在Windows/Mac本地编辑config,再上传到服务器,一定要注意换行符和编码。理论上Linux和Windows的换行符不同可能导致解析异常,虽然config是Python文件,一般不会出问题,但保险起见,我建议所有config和代码的修改都在服务器上直接完成,或者用VSCode Remote时默认会让文件在服务器上保存。

一个更实用的小技巧:VSCode Remote的“远程资源管理器”里可以直接打开configs/_base_目录,查看官方默认配置,边看边改非常方便。很多人不知道Ctrl+点击config里的字符串(比如'./configs/_base_/datasets/cityscapes.py')可以直接跳转到被继承的文件,这比一个个翻目录快得多。

5.4 Cityscapes子集与自定义数据混合的映射问题

热词里提到“mmsegmentation训练cityscapes”和“某配置文件里加上用户名”。有一个相关的问题我经常被问到:能不能把自己的一小批自定义数据混进Cityscapes一起训练?

可以,但要处理好类别映射。比如Cityscapes有19类,自定义数据有3类。直接用MyDataset加载Cityscapes路径,num_classes改成19与3的并集数,会出问题,因为两边的类别ID可能冲突。更可行的做法有几种:

  • 方案一:把自定义数据转成Cityscapes的类别ID体系。比如自定义数据里的“路面”映射到Cityscapes的“road”,“裂缝”则作为Cityscapes里不存在的类别处理。这个过程比较繁琐,但训练效果最稳定。
  • 方案二:训练两个模型,一个在Cityscapes上训练,一个在自定义数据上训练,自己写推理脚本融合两个模型的输出。操作更简单,但工程上更笨重。
  • 方案三:如果自定义数据量足够,直接只用自己的数据训练。很多情况下,预训练权重(比如ImageNet上的backbone)已经提供了充分的先验,不需要强行混合数据集。

5.5 关于模型推理和可视化

训练完之后,大家最想看到的是分割效果。官方的推理命令:

python tools/test.py configs/my_model/pspnet_r50_my_dataset.py \ work_dirs/wanghui_pspnet/iter_20000.pth \ --show-dir results/

--show-dir会保存预测mask叠加在原图上的可视化结果。这里有个容易忽略的点:预测得到的mask是单通道灰度图,保存成彩色图时,你看到的不是“五颜六色”的分割效果,而是一片灰雾。想要pseudo-color效果,需要在config中设置调色板,或者在测试pipeline里处理。

6. 一些体会和扩展建议

在实际操作里,我尽量遵循“先跑通,再调优”的节奏。第一版训练先把所有参数调得保守,比如max_iters=20000、crop_size=(512,512)、batch_size=2,等模型能正常跑完并出结果,再逐步增加迭代次数、调整增强策略。这样能避免“一次调太多参数,出问题都不知道改哪一步导致的”。

自定义数据集的迭代过程中,数据质量永远是第一位。有次我花了一个星期调模型结构、换loss、改学习率,mIoU始终停在82%左右,后来认真检查标注,发现有几十张图的边界标注偏了一两个像素,把标注修正后直接跳到87%。所以做分割项目,花在数据上的时间永远值得。

如果后续想扩展,方向也很明确:

  • 用mmsegmentation的tools/dataset_converters/下现成脚本,把更多标注格式(labelme、coco、cityscapes等)转成标准格式,减少手工转换成本。
  • 尝试用MMSegmentation官方config zoo里不同的backbone(比如Swin-T、SegFormer)做精度对比,找到最适合自己数据的模型。
  • 用MMDeploy导出ONNX/TensorRT模型,把训练好的分割模型部署到实时推理场景。

最后分享一个小习惯:每跑完一组实验,我都会把改过的config带上日期和备注复制一份存档,比如pspnet_r50_my_dataset_20250612_0815.py。因为训练一个模型动辄几个小时,等到一周后回看,你可能完全记不起当时改了哪几个参数。保存历史版本,是团队协作和自我复盘时最值得做的投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询