1. 从零构建自定义语义分割数据集:两条主流技术路线的深度拆解
语义分割这个方向,做了几年之后你会发现一个很现实的问题:公开数据集上跑出来的指标再漂亮,一落到自己的业务场景里,往往直接“翻车”。道路裂缝、医学影像、工业质检、遥感地物分类,每个场景的类别定义、标注规范、图像特性都完全不同。真正决定项目成败的,从来不是模型结构有多新,而是自定义数据集这一环做得够不够扎实。
这篇内容围绕两条主流路线展开:一条是基于 PaddleSeg 的自定义语义分割数据集全流程,另一条是基于 Detectron2 的自定义语义分割数据集全流程。我会把数据准备、标注格式转换、配置文件编写、训练调参、评估推理这几个环节全部拆开讲透,并且重点解释每一步“为什么这么做”。无论你是刚接触语义分割的新手,还是已经跑通过公开数据集、准备迁移到自己数据上的从业者,都能从里面找到可以直接抄作业的部分。
需要先说明一点:PaddleSeg 和 Detectron2 分属两个不同的深度学习生态,前者是国产飞桨体系下的分割工具库,后者是 Meta 开源的通用目标检测与分割框架。它们在数据组织方式、配置文件风格、训练入口上差异很大,但底层逻辑是相通的——都是把“图像 + 像素级标签”喂给网络,通过损失函数约束每个像素的分类结果。理解了这个共性,再看两条路线的差异,就不会觉得混乱。
我个人的经验是,数据集构建占整个项目 60% 以上的工作量,模型训练反而相对标准化。所以下面会把大量篇幅放在数据环节,尤其是标注格式的坑,这部分踩过一次就再也不想踩第二次。
2. 语义分割自定义数据集的核心概念与常见格式
2.1 语义分割到底在解决什么问题
语义分割的本质是逐像素分类。给定一张 H×W 的彩色图像,模型要输出一张同样 H×W 的标签图,图上每个像素点的值代表它所属的类别。比如一张街景图,天空、道路、车辆、行人各自被涂上不同的类别编号,这就是语义分割的结果。
它和实例分割的区别在于:语义分割不区分同类别的不同个体。图里有三辆车,语义分割只会把它们都标成“车”这一类,而实例分割会把三辆车分别标成车1、车2、车3。所以语义分割更适合“关心区域属性、不关心个体数量”的场景,比如遥感地物分类、医学器官分割、路面病害识别。
理解这一点很关键,因为它直接决定了你的标注方式。如果你的业务需要数出每个个体的数量,那语义分割就不合适,应该考虑实例分割或者目标检测。
2.2 主流标注格式对比
自定义数据集最头疼的就是格式问题。不同工具产出的标注格式不一样,不同框架要求的输入格式也不一样,中间往往需要写转换脚本。下面这张表是我整理的高频格式对照,建议收藏。
| 格式名称 | 典型产出工具 | 存储形式 | 适用框架 | 特点 |
|---|---|---|---|---|
| PNG 掩膜图 | LabelMe、Labelme2COCO | 单通道灰度图 | PaddleSeg、多数分割框架 | 像素值即类别 ID,最通用 |
| COCO JSON | LabelMe、CVAT | JSON + 原图 | Detectron2、MMDetection | 支持多边形、RLE 编码 |
| VOC XML | LabelImg | XML + 原图 | 早期检测框架 | 分割支持弱,多用于检测 |
| Cityscapes 格式 | 官方标注工具 | 灰度图 + 颜色映射 | 分割研究 | 有专门的 labelIds 和 trainIds |
| RLE 编码 | COCO 生态 | JSON 中的字符串 | Detectron2 | 压缩存储,适合密集标注 |
对于语义分割,PNG 掩膜图是最省心的格式。一张灰度图,像素值 0 代表背景,1 代表类别一,2 代表类别二,以此类推。PaddleSeg 原生就吃这种格式,Detectron2 虽然主推 COCO 格式,但也可以通过转换脚本把掩膜图转成 COCO 的 RLE 编码。
2.3 类别定义与标签规范
在动手标注之前,必须先定好类别体系。这一步看起来简单,实际上最容易埋雷。我见过太多项目因为类别定义模糊,导致标注员理解不一致,最后数据集里同一类物体被标成不同类别,模型怎么训都上不去。
几个实操原则:
- 类别互斥:每个像素只能属于一个类别,不能出现“既算 A 又算 B”的情况。如果业务上确实有重叠,要么合并类别,要么定义优先级。
- 背景单独成类:背景通常编号为 0,不参与某些评估指标的计算,但必须存在。
- 类别数量控制在合理范围:类别越多,标注成本越高,模型越难收敛。一般建议先做 5 到 10 类的 MVP 版本,跑通后再扩展。
- 写一份标注规范文档:把每个类别的定义、边界情况、典型示例图都写清楚,标注员人手一份。这份文档的价值远超你的想象。
提示:类别编号建议从 0 开始连续编号,不要跳号。有些框架对类别 ID 的连续性有隐含假设,跳号可能导致训练时类别权重计算异常。
3. PaddleSeg 自定义数据集全流程实操
3.1 数据目录组织与格式要求
PaddleSeg 对数据集的目录结构有比较明确的要求。推荐的组织方式如下:
dataset/ ├── images/ │ ├── train/ │ │ ├── 0001.jpg │ │ └── 0002.jpg │ └── val/ │ ├── 0003.jpg │ └── 0004.jpg ├── annotations/ │ ├── train/ │ │ ├── 0001.png │ │ └── 0002.png │ └── val/ │ ├── 0003.png │ └── 0004.png ├── train.txt └── val.txt其中train.txt和val.txt是索引文件,每行一条样本,格式为“图像路径 标注路径”,中间用空格分隔:
images/train/0001.jpg annotations/train/0001.png images/train/0002.jpg annotations/train/0002.png这里有个细节很多人会忽略:路径建议用相对路径,相对于数据集根目录。这样整个数据集目录可以整体迁移,不会因为绝对路径变化而失效。我早期用绝对路径,换了一台机器就得重新生成索引文件,非常麻烦。
标注图必须是单通道 PNG,不能用 JPG。JPG 是有损压缩,会把像素值改掉,导致类别 ID 错乱。这一点是硬性要求,务必检查。
3.2 标注工具选型与掩膜生成
标注工具我用过不少,LabelMe 是最通用的选择。它支持多边形标注,导出 JSON,然后通过官方脚本labelme2voc.py或社区脚本转成掩膜图。
转换的核心逻辑是:把 JSON 里的多边形坐标填充成对应类别 ID 的区域。LabelMe 导出的 JSON 里,每个 shape 有一个 label 字段,你需要维护一个 label 到 ID 的映射表,转换时按这个表填充。
一个常见的坑是多边形自相交。标注员手抖画出的多边形如果自己和自己交叉,填充算法可能产生意外结果。转换后一定要抽查几张掩膜图,用可视化脚本叠在原图上看看是否吻合。
import numpy as np from PIL import Image # 检查掩膜图的类别分布 mask = np.array(Image.open("annotations/train/0001.png")) unique, counts = np.unique(mask, return_counts=True) for u, c in zip(unique, counts): print(f"类别 {u}: {c} 像素, 占比 {c / mask.size:.2%}")这段脚本能快速告诉你每张图里各类别的像素占比。如果某个类别占比异常高或异常低,就要怀疑标注是否有问题。
3.3 配置文件编写与关键参数
PaddleSeg 的配置文件是 YAML 格式,主要包含三块:数据集配置、模型配置、优化器与训练配置。下面是一个精简示例:
batch_size: 4 iters: 40000 train_dataset: type: Dataset dataset_root: dataset train_path: dataset/train.txt num_classes: 5 transforms: - type: RandomHorizontalFlip - type: Resize target_size: [512, 512] - type: Normalize mode: train val_dataset: type: Dataset dataset_root: dataset val_path: dataset/val.txt num_classes: 5 transforms: - type: Resize target_size: [512, 512] - type: Normalize mode: val model: type: FCN backbone: type: HRNet_W18 pretrained: https://paddle-imagenet-models-name.bj.bcebos.com/dygraph/HRNet_W18_pretrained.pdparams num_classes: 5 optimizer: type: SGD momentum: 0.9 weight_decay: 4.0e-5 lr_scheduler: type: PolynomialDecay learning_rate: 0.01 power: 0.9几个关键参数的解释:
- num_classes:必须和你的类别数一致,包含背景。如果背景 + 4 个前景类,就是 5。
- target_size:输入网络的尺寸。太小丢失细节,太大显存吃不消。512×512 是个稳妥的起点。
- batch_size:受显存限制。显存不够就调小,配合梯度累积也能达到类似效果。
- learning_rate:初始学习率。SGD 一般从 0.01 起步,太大容易震荡,太小收敛慢。
注意:
num_classes配错是最常见的报错来源。如果训练时出现 “label out of range” 之类的错误,第一件事就是检查这个参数和掩膜图的实际类别数是否匹配。
3.4 训练启动与日志解读
PaddleSeg 的训练入口是train.py,基本命令:
python train.py \ --config configs/mydataset.yml \ --do_eval \ --use_vdl \ --save_interval 2000 \ --save_dir output--do_eval表示训练过程中周期性评估,--use_vdl开启可视化日志,--save_interval控制模型保存频率。
训练日志里重点看几个指标:loss、lr、miou、acc。loss 应该是整体下降趋势,如果一直震荡不降,多半是学习率太大或者数据有问题。miou 是语义分割的核心指标,它比单纯的 accuracy 更能反映类别不平衡下的真实性能。
我个人的经验是,前 2000 个 iteration 主要看 loss 是否正常下降,这时候 miou 可能还很低,不用慌。等到 loss 平稳后,miou 才会慢慢爬升。如果训练到一半 miou 突然掉下去,多半是学习率衰减策略或者数据增强出了问题。
4. Detectron2 自定义数据集全流程实操
4.1 COCO 格式注册与数据集准备
Detectron2 走的是 COCO 格式路线。语义分割在 Detectron2 里通常通过COCOInstance或自定义的语义分割数据集注册来实现。核心是把数据组织成 COCO 的 JSON 结构,然后通过DatasetCatalog.register注册进去。
COCO 格式的 JSON 结构大致如下:
{ "images": [ {"id": 1, "file_name": "0001.jpg", "height": 512, "width": 512} ], "annotations": [ {"id": 1, "image_id": 1, "category_id": 1, "segmentation": {"counts": "...", "size": [512, 512]}, "area": 1234, "bbox": [10, 20, 30, 40], "iscrowd": 0} ], "categories": [ {"id": 1, "name": "category_a"}, {"id": 2, "name": "category_b"} ] }其中segmentation字段用 RLE 编码存储掩膜。如果你手上是 PNG 掩膜图,需要写脚本转成 RLE。Detectron2 提供了pycocotools里的maskUtils.encode来做这件事。
from pycocotools import mask as maskUtils import numpy as np from PIL import Image def png_to_rle(png_path): mask = np.array(Image.open(png_path)) # 二值化,只保留当前类别 binary = (mask == 1).astype(np.uint8) rle = maskUtils.encode(np.asfortranarray(binary)) rle["counts"] = rle["counts"].decode("utf-8") return rle这里有个容易踩的坑:RLE 编码要求数组是 Fortran 顺序,也就是列优先。如果直接用 numpy 默认的 C 顺序,编码结果会错乱。np.asfortranarray这一步不能省。
4.2 注册数据集与元数据配置
注册数据集是 Detectron2 的核心步骤。你需要告诉框架:数据集叫什么名字、怎么读取、有哪些类别。
from detectron2.data import DatasetCatalog, MetadataCatalog from detectron2.data.datasets import load_coco_json def register_my_dataset(): DatasetCatalog.register("my_seg_train", lambda: load_coco_json("annotations/train.json", "images/train")) MetadataCatalog.get("my_seg_train").set( thing_classes=["category_a", "category_b", "category_c"], evaluator_type="coco" )thing_classes的顺序必须和 JSON 里category_id的映射一致。如果 JSON 里 category_id 从 1 开始,而 thing_classes 列表从 0 开始索引,中间需要一个映射关系。这个映射错位是 Detectron2 新手最常见的 bug,表现为评估时类别名对不上号。
4.3 模型配置与训练调参
Detectron2 用LazyConfig或CfgNode配置模型。语义分割常用Mask R-CNN或SemanticFPN。以 SemanticFPN 为例:
from detectron2.config import get_cfg from detectron2 import model_zoo cfg = get_cfg() cfg.merge_from_file(model_zoo.get_config_file("COCO-SemanticSegmentation/semantic_fpn_R_50_1x.yaml")) cfg.DATASETS.TRAIN = ("my_seg_train",) cfg.DATASETS.TEST = ("my_seg_val",) cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url("COCO-SemanticSegmentation/semantic_fpn_R_50_1x.yaml") cfg.SOLVER.IMS_PER_BATCH = 4 cfg.SOLVER.BASE_LR = 0.001 cfg.SOLVER.MAX_ITER = 40000 cfg.MODEL.SEM_SEG_HEAD.NUM_CLASSES = 3NUM_CLASSES同样要和实际类别数一致。BASE_LR在 Detectron2 里通常比 PaddleSeg 小一个量级,因为它的默认优化器和 batch 设置不同。
训练启动:
python train_net.py --config-file configs/my_seg.yaml --num-gpus 1Detectron2 的日志会输出 total_loss、loss_mask、loss_cls 等分项。语义分割主要关注 loss_sem_seg,其他分项如果模型结构里没有对应头,可能不显示。
4.4 推理与结果可视化
训练完成后,用DefaultPredictor做推理:
from detectron2.engine import DefaultPredictor predictor = DefaultPredictor(cfg) outputs = predictor(image) sem_seg = outputs["sem_seg"].argmax(dim=0).cpu().numpy()sem_seg是每个类别的概率图,取 argmax 得到最终的类别图。可视化时建议用固定的颜色映射表,保证不同图片之间颜色一致,方便对比。
5. 两条路线的对比与选型建议
5.1 核心差异对照
| 维度 | PaddleSeg | Detectron2 |
|---|---|---|
| 数据格式 | PNG 掩膜图为主 | COCO JSON + RLE |
| 配置方式 | YAML 文件 | Python 代码 / YAML |
| 生态定位 | 专注分割 | 检测、分割、关键点通用 |
| 上手难度 | 较低,文档中文友好 | 中等,需要理解注册机制 |
| 模型丰富度 | 分割模型齐全 | 分割模型相对少 |
| 部署支持 | Paddle Inference 完善 | 依赖 TorchScript / ONNX |
5.2 什么场景选哪条路线
如果你的项目纯做语义分割,且团队对飞桨生态不排斥,PaddleSeg 是更省心的选择。它的数据格式简单,配置文件直观,中文文档齐全,从训练到部署的链路很顺。
如果你的项目同时涉及检测、实例分割、语义分割,或者团队已经在用 PyTorch 生态,Detectron2 的通用性优势就体现出来了。一套框架解决多个任务,数据注册机制虽然学习曲线陡一点,但灵活度更高。
我个人的做法是:快速验证阶段用 PaddleSeg,因为改配置就能跑,迭代快;需要和检测任务联动时切到 Detectron2,避免维护两套数据管线。
6. 常见问题与排查技巧实录
6.1 训练不收敛的排查思路
训练不收敛是最高频的问题。按下面这个顺序排查,基本能定位到原因:
- 检查数据:掩膜图类别 ID 是否连续?是否有像素值超出 num_classes 范围?用前面那段统计脚本跑一遍。
- 检查学习率:太大导致震荡,太小导致下降缓慢。可以先跑几百个 iteration 看 loss 曲线。
- 检查归一化:图像是否做了 Normalize?均值和方差是否和预训练模型匹配?
- 检查类别不平衡:如果背景占比 95% 以上,模型会倾向于全预测背景。这时候需要加类别权重或者用 Dice Loss。
6.2 评估指标异常的定位方法
miou 很低但 accuracy 很高,这是典型的类别不平衡表现。accuracy 被背景类拉高了,miou 才是真实水平。解决办法:
- 在损失函数里给稀有类别更高权重
- 用 OHEM(在线难例挖掘)聚焦难分像素
- 数据层面做重采样,增加稀有类别样本
6.3 常见报错速查表
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
| label out of range | num_classes 配小 | 检查掩膜最大像素值,调大 num_classes |
| CUDA out of memory | batch_size 或输入尺寸太大 | 调小 batch_size 或 target_size |
| KeyError: 'category_id' | JSON 结构不完整 | 检查 COCO JSON 的 annotations 字段 |
| RLE counts 解码失败 | 编码时未用 Fortran 顺序 | 用 np.asfortranarray 转换 |
| miou 为 0 | 类别映射错位 | 核对 thing_classes 与 category_id |
6.4 独家避坑经验
坑一:标注图用 JPG 存。JPG 有损压缩会改变像素值,导致类别 ID 错乱。必须用 PNG。
坑二:训练集和验证集有重叠。如果同一张图既在训练集又在验证集,验证指标会虚高。生成索引文件时务必去重。
坑三:忽略图像和掩膜的文件名对应。图像叫 0001.jpg,掩膜叫 0001_mask.png,转换脚本里如果按文件名匹配,很容易漏掉。建议统一命名规则。
坑四:数据增强同时作用于图像和掩膜。翻转、旋转、缩放必须同步应用到图像和掩膜上,否则标签和图像对不上。PaddleSeg 和 Detectron2 的 transforms 都处理了这一点,但自己写脚本时要特别注意。
坑五:验证集太小。验证集至少占总数据的 10% 到 20%,且要覆盖所有类别。如果某个类别在验证集里一个样本都没有,miou 计算会出问题。
7. 数据集迭代与持续优化
数据集不是一次做完就完事的。模型训练完,看混淆矩阵,找出哪些类别容易混,然后针对性补充这类样本。这个迭代过程通常要跑两到三轮,miou 才能到一个可用的水平。
我一般会准备一个badcase 收集流程:推理一批新图,把预测结果和原图叠在一起,人工快速过一遍,把明显错的挑出来,重新标注后加入训练集。这样数据集会越来越贴合真实场景,模型性能也会稳步提升。
另外,标注一致性检查也很重要。同一个类别,不同标注员的理解可能有偏差。定期抽样复查,统一标准,比盲目增加数据量更有效。
最后分享一个小技巧:先用小数据集跑通全流程,再规模化标注。我见过太多团队一上来就标几千张,结果格式错了、类别定义改了,全部返工。先用 50 到 100 张跑通训练和评估,确认流程没问题,再批量标注,能省下大量时间。