1. 项目定位与整体设计思路
1.1 为什么需要从零构建多分类分割数据集
做UNet多分类分割,最头疼的往往不是模型本身,而是数据集。很多人一开始习惯去网上找现成的公开数据集,但真正做实际项目时就会发现,公开数据集的类别体系跟你想要的经常对不上。比如你要识别遥感影像里的建筑、道路、植被、水体四类,公开数据集通常是COCO那种八十类,或者只有单一类别的前景背景分割,根本没法直接拿来用。
更现实的问题在于数据分布。公开数据集采集自特定地区、特定传感器、特定季节,换到你自己的应用场景,同样的模型表现会明显下降。还有一类情况是数据隐私约束,医疗影像、施工场地、厂区内部场景这些数据不能外传,只能在本地从零标注。所以构建自己的多分类分割数据集,不是“有没有必要”的问题,而是做实际项目必须跨过的一道门槛。
这篇文章围绕“从零构建UNet多分类分割数据集”讲的就是一套完整流程:类别体系怎么设计、用哪个标注工具、标注时有哪些规范、标注结果怎么转成UNet能读的格式、训练环境如何搭建、训练参数怎么调,以及中途会踩到哪些坑。适合正在做图像分割、语义分割项目的人参考,无论是遥感、医学影像、工业质检还是街景理解,方法论是一样的。
1.2 类别体系设计:先想清楚再动手
很多人拿到一批图片,打开标注工具就开始画,画到一半发现类别定义模糊,有些目标分不清归哪一类,又得返工重标。这一步是项目失败率最高的地方,必须一开始就定清楚。
类别设计有几个原则。第一,类别之间要有明确的语义边界。比如做城市地物分割,“人行道”和“道路”看起来好分,但实际画的时候树荫遮挡、边界磨损,画出来的掩码总是纠缠在一起,不如直接合并成一个“可行驶路面”类别,标注一致性会高很多。第二,语义类别数量要控制在合理范围。UNet虽然能处理多类别,但类别越多,标注成本、训练难度、样本平衡问题都成倍增加,建议从四到六类起步,把核心目标先做出来,再逐步增加类别。第三,要为“背景”或者“其他”单独设置类别。多分类分割的标签通道数量 = 类别数,背景也占一个类别通道,不要省略。
这里有一个容易被忽略的细节:标注规范文档。动工之前,花半小时写一份标注规范,写明每个类别的定义、包含与不包含的情况、边界重叠时的优先级。团队多人标注时,这份文档是保证一致性的关键。我自己见过最典型的例子是标注“建筑”时,有人把院子里的棚子画进去,有人不画,导致模型在棚子区域反复出错。规范里写一句“永久性建筑结构,不含临时搭建物”,就能避免这类问题。
2. 标注工具选型与标注实操
2.1 主流标注工具对比与选择
当前用得比较多的开源标注工具是Labelme、CVAT、X-AnyLabeling这几个。我做过的项目里三种都用过,各自定位差异挺明显。
| 工具 | 标注形式 | 适用场景 | 优势 | 不足 |
|---|---|---|---|---|
| Labelme | 多边形、矩形、圆形 | 中小编码量、个人项目 | 轻量、JSON格式直观、跨平台 | 不支持多人协作,大图标注卡顿 |
| CVAT | 多边形、分割、跟踪 | 团队协作、大规模标注 | 基于Web、多人协作、内置质检 | 部署稍复杂,新手上手成本略高 |
| X-AnyLabeling | 多边形、SAM辅助 | 中量级、希望AI辅助标注 | 支持SAM一键分割,效率高 | 依赖模型推理,CPU环境较慢 |
个人项目、图片量在几百张以内,用Labelme就够了,安装简单,导出格式转换也不难。团队项目、图片几千张、需要分工协作,强烈建议用CVAT,分配任务、审核标注结果都在Web界面完成,可控性好很多。X-AnyLabeling是进阶选择,配合SAM模型可以明显减少人工点击,适合分割边界复杂、目标不规则的场景,比如遥感影像里的建筑物轮廓。
工具选定之后,建议先在十张图上跑通“标注—导出—训练”全流程,确认格式没问题再大规模标注。我见过不止一个人标注了几百张图之后,发现导出的JSON格式跟训练代码不匹配,又要重新处理,非常被动。
2.2 标注过程中的关键规范与技巧
标注不只是“把轮廓描出来”,实际操作里有很多直接影响训练效果的细节,这里分享几个自己踩过坑之后总结出来的要点。
首先是边界处理。分割模型学的是像素到像素的映射,标注边界的精度直接决定预测边界质量。很多新手标注时用大笔触快速画完,边缘是锯齿状的,训练出来的模型预测边界也是毛躁的。尽量用多边形贴近目标边缘,密集程度看目标复杂度,直线段每隔几个像素落一个点就够,曲线处适当加密。标注完成后花点时间把明显不贴合边缘的点调整一下,这在后面对mIoU的提升有奇效。
其次是重叠与遮挡规则。多分类分割里不同物体可能有重合,比如树冠覆盖在道路上方。这里要写清楚优先级规则。常规做法是“高程优先”,也就是相机视角下看得见的物体优先标注,被遮挡的部分归后面的物体。换句话说,树冠挡住道路,道路标签在遮挡区域就缺失,这符合真实成像逻辑,模型训练也更稳定。如果两种物体在物理空间上是叠加关系而不是遮挡关系,那就需要独立标注语义层,这个问题具体项目具体分析,但必须定好规则再开工。
第三是小目标处理。多分类分割里,小目标容易被模型忽略,常见原因是标注时不够仔细,或者标签像素占比太少。建议标注时对小目标单独放大后再勾边,并且在数据统计阶段记录每个类别的像素占比,对占比极低的类别考虑过采样或者加权重。
最后是质检环节。让标注者自己标注自己看,很容易形成思维盲区。建议每隔一定数量抽检一次,重点看标注边界是否准确、类别是否错漏、是否有未闭合的多边形。CVAT里可以直接分配审核员角色去检查,一个简单的flase-positive、flase-negative统计就能发现问题。
3. 数据整理与格式转换
3.1 标注格式统一:从JSON到PNG掩码
Labelme导出的数据是JSON文件,里面记录了每个多边形的顶点坐标和类别标签。UNet训练时一般需要的是PNG格式的掩码图,每个像素值对应一个类别ID。所以从JSON到PNG的分割掩码转换是构建数据集的第一个技术操作。
这里用一个Python转换脚本作为示例,把Labelme生成的JSON批量转换成与图像同尺寸的单通道掩码PNG。
import json import os import numpy as np import cv2 from labelme import utils def labelme_json_to_mask(json_path, output_dir, class_id_map): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_path = os.path.join(os.path.dirname(json_path), data['imagePath']) img = cv2.imread(img_path) h, w = img.shape[:2] mask = np.zeros((h, w), dtype=np.uint8) for shape in data['shapes']: label = shape['label'] points = np.array(shape['points'], dtype=np.int32) if label not in class_id_map: continue class_id = class_id_map[label] cv2.fillPoly(mask, [points], class_id) base_name = os.path.splitext(os.path.basename(json_path))[0] mask_out = os.path.join(output_dir, base_name + '_mask.png') cv2.imwrite(mask_out, mask) print(f'saved: {mask_out}') class_id_map = { 'background': 0, 'building': 1, 'road': 2, 'vegetation': 3, 'water': 4 } json_dir = 'path/to/json' mask_dir = 'path/to/mask' os.makedirs(mask_dir, exist_ok=True) for file_name in os.listdir(json_dir): if file_name.endswith('.json'): labelme_json_to_mask(os.path.join(json_dir, file_name), mask_dir, class_id_map)转换时有一个很关键的注意点:掩码类别ID必须是0到类别数减一之间的连续整数。PyTorch的CrossEntropyLoss默认把类别ID作为索引,如果类别ID跳号,训练阶段会直接报错或者产生无法解释的梯度问题。另外,掩码图中0通常保留给背景,这个约定要统一,不要某个图背景是1、某个图背景是0,否则模型训练会混乱。
3.2 数据增强与数据集划分
数据增强是分割任务里绕不开的一环,尤其当标注样本量有限时,合理的数据增强能明显提升模型泛化能力。常见增强方式分为几何增强和色彩增强两类。几何增强包括随机翻转、随机旋转、随机缩放、随机裁剪,这些操作需要图像和掩码同步进行,否则标签对不上,这属于新手最常见的失误。色彩增强包括亮度调整、对比度调整、高斯噪声扰动,它们只作用于图像,不改变掩码。
实现同步增强常用albumentations库,下面是参考实现。
import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ A.RandomResizedCrop(height=512, width=512, scale=(0.7, 1.0), p=0.8), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.2), A.RandomBrightnessContrast(p=0.3), A.GaussNoise(p=0.2), ], additional_targets={'mask': 'mask'})使用这个Compose时,读取图像和掩码后直接调用train_transform(image=image, mask=mask),返回的字典里有增强后的image和mask。
数据集划分同样要提前规划好。训练集、验证集、测试集的比例建议按样本量调整,一般500张以上用70%、15%、15%划分。注意分割任务和分类任务不同,如果多张图像来自同一个场景或同一段视频序列,要把它们放在同一个数据集分块里,防止数据泄漏导致验证集分数虚高。划分完成后,统计每个子集中各类别像素占比,确认分布一致,这一步能提前发现划分偏差问题。
4. 训练环境搭建与UNet模型选型
4.1 训练环境准备
深度学习的训练环境看起来简单,实际配置过程中有许多容易出问题的环节。我自己最常用的环境管理工具是conda,可以快速创建独立的Python环境,避免不同项目之间的依赖冲突。
conda create -n unet_seg python=3.9 conda activate unet_seg conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia pip install opencv-python numpy pillow matplotlib albumentations tqdm安装PyTorch时务必确认CUDA版本和显卡驱动匹配。你可以通过nvidia-smi查看驱动支持的CUDA版本,再选择对应的PyTorch版本。如果装错版本,训练时会出现CUDA error: no kernel image is available这类报错,处理起来很折腾。
显存容量决定了batch size和图像分辨率的上限。UNet属于编码器-解码器结构,显存占用随输入分辨率升高而快速增长,一个512×512输入配合UNet训练,推荐至少8GB显存起步。如果显存吃紧,可以降低输入尺寸、缩小batch size、使用梯度累积,甚至换用轻量级backbone,选型思路在后文展开。
4.2 UNet结构、多分类改造与改进方向
UNet名字源于它的U型结构,左边是编码器收缩路径,逐步提取语义特征;右边是解码器扩展路径,逐步恢复空间分辨率;中间通过跳跃连接把浅层细节和深层语义融合起来,所以UNet对小目标、弱边界目标的表现一直不错。这些特性使它成为医学图像分割、遥感影像分割等场景的经典基线模型。
多分类分割和普通二分类分割的差异,主要体现在输出层和损失函数上。UNet最后一层卷积的输出通道数要改成类别总数,后面接CrossEntropyLoss直接计算分类损失。二分类时常用的BCEWithLogitsLoss在这里不适用,因为BCE的每个输出通道是独立的,无法表达多类别之间的互斥关系,这是很多新手刚转多分类时会犯的错误。
UNet本身结构简单,直接训练也能跑出不错的结果,但实际项目中可以根据任务特点做针对性改进。如果目标尺度差异大,可以在跳跃连接中引入注意力模块,对浅层特征做噪声抑制;如果语义类别多、边界复杂,可以在损失函数中叠加DiceLoss来缓解类别不平衡。这些改进不需要改动UNet的主干结构,只要在关键位置加模块、调整损失函数,效果提升明显。改进前先跑通最简单的UNet,作为后续实验的基准,我习惯把这个基准模型的mIoU当作后续所有改进的对比参照。
5. 训练实操与参数调优
5.1 数据加载与训练流程搭建
数据准备完毕,环境也搭好了,接下来是整个项目的核心环节:把数据集和UNet组装成训练流程。PyTorch中实现这一步分三个模块:Dataset类负责读数据、做变换、返回图像和掩码;DataLoader负责批量加载和打乱顺序;训练循环负责前向传播、计算损失、反向传播、更新参数和定期验证。
Dataset类的参考实现如下,重点是返回的掩码必须是torch.long类型。
import os import cv2 import torch from torch.utils.data import Dataset class SegmentationDataset(Dataset): def __init__(self, image_dir, mask_dir, transform=None): self.image_dir = image_dir self.mask_dir = mask_dir self.transform = transform self.images = sorted(os.listdir(image_dir)) def __len__(self): return len(self.images) def __getitem__(self, idx): img_name = self.images[idx] img_path = os.path.join(self.image_dir, img_name) mask_name = os.path.splitext(img_name)[0] + '_mask.png' mask_path = os.path.join(self.mask_dir, mask_name) image = cv2.imread(img_path) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) if self.transform: aug = self.transform(image=image, mask=mask) image = aug['image'] mask = aug['mask'] image = torch.from_numpy(image.transpose(2, 0, 1)).float() / 255.0 mask = torch.from_numpy(mask).long() return image, mask细节上要注意归一化方式。上面直接除以255,把所有像素缩放到0到1区间,这是最常用的做法。如果数据集色彩分布特殊,可以改用ImageNet的均值和标准差做标准化,但对分割任务差异通常不大,0~1归一化往往就够用。
训练循环的框架基本固定,这里给出一个200行以内的训练脚本主干,包含训练和验证两部分逻辑。
from torch.utils.data import DataLoader from torchvision.models.segmentation import deeplabv3_resnet50 # 这里以UNet自定义实现为例 from unet_model import UNet import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') train_dataset = SegmentationDataset('data/train/images', 'data/train/masks', train_transform) val_dataset = SegmentationDataset('data/val/images', 'data/val/masks', val_transform) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=8, shuffle=False, num_workers=4) model = UNet(in_channels=3, n_classes=5).to(device) criterion = torch.nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) def train_one_epoch(loader, model, criterion, optimizer, device): model.train() total_loss = 0.0 for images, masks in loader: images, masks = images.to(device), masks.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, masks) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) def validate(loader, model, criterion, device): model.eval() total_loss = 0.0 with torch.no_grad(): for images, masks in loader: images, masks = images.to(device), masks.to(device) outputs = model(images) loss = criterion(outputs, masks) total_loss += loss.item() return total_loss / len(loader) for epoch in range(100): train_loss = train_one_epoch(train_loader, model, criterion, optimizer, device) val_loss = validate(val_loader, model, criterion, device) scheduler.step(val_loss) print(f'Epoch {epoch+1}: train_loss={train_loss:.4f},val_loss={val_loss:.4f}')只是跑通还不够,追求训练效率有几个经验。数据加载用num_workers>0开多进程预取,否则GPU等待数据,利用率为0的场景很常见。另外,建议每个epoch结束都保存一次权重,文件命名带上epoch和val_loss,方便回滚到最优版本,我一般保存最好的和最后一组两个版本。
5.2 训练参数设置与调优心得
UNet训练过程中的参数选择,直接影响模型最终效果,这里分享几个自己实测有效的默认基线,再讲偏离这些默认值时应该怎么判断。
学习率是分割训练里最重要的超参数。Adam优化器配合1e-3的初始学习率,对大多数任务是一个不错的起点。如果损失震荡明显,可以降一个数量级到1e-4;如果前几个epoch损失下降很慢,可以提高一点到3e-3试试。学习率下降策略我常用ReduceLROnPlateau,也就是验证损失连续5个epoch不下降时,学习率减半,这个策略省心且效果不错。
batch size的选择受显存制约,8是一个常见起步值。batch size越小,梯度噪声越大,训练越不稳定。如果显存只够batch size设为2或者4,建议打开梯度累积,用多个小batch累积梯度再更新,等效于更大的batch size。参考实现里在loss.backward()之后判断当前累积步数,达到累积步数再执行optimizer.step()和zero_grad()。
损失函数的选择和类别平衡问题绑定在一起。类别像素占比差距悬殊时,直接用CrossEntropyLoss,少数类别基本学不起来。解决办法有两个方向,一是给CrossEntropyLoss传weight参数,按类别像素占比的倒数分配权重;二是组合损失,将CrossEntropyLoss和DiceLoss按一定比例相加。组合损失里CrossEntropyLoss负责保证收敛稳定性,DiceLoss负责提升少量类别的分割质量,比例上CrossEntropy权重放在0.6~0.7,Dice权重放在0.3~0.4,是一种不错的起点。
评估指标不能只看loss。多分类分割最常用的指标是mIoU和Dice系数。mIoU计算每个类别的IoU再取平均,能反映整体分割精度;Dice系数在类别极度不平衡时比IoU更直观。验证集上除了打印数值指标,还应把模型预测的掩码可视化,直接观察边界质量、小目标保持情况,数值指标再高,视觉上一塌糊涂也说明模型没学好。
6. 常见问题与排查技巧实录
6.1 标注与数据阶段的高频问题
标注阶段返工率最高的坑,几乎都集中在类别不一致和几何边界这两个方面。
类别不一致的表现是:标注员A把树冠下的阴影标成“建筑”,标注员B同等情况标成“植被”。轻则验证集mIoU偏低,重则模型在阴影区域产生固化错误预测。解决办法是标注规范里增加“典型疑难点示例图”,并做过标注一致性测试,取几张图让不同标注者标完对比差异,把不一致的地方统一掉再正式开工。
几何边界的坑则出在“细节不足”上。许多标注工具默认多边形放大倍率有限,小目标标出来只有几个像素,模型很难从这些掩码中学到正确的边界模式。标注时遇到小目标,先把视图放大到目标宽度占据屏幕的三分之一以上,再开始描边。另外,仓库里若有自动生成的掩码,建议目检一遍,比如从遥感影像自动导出的道路网格掩码,经常会把建筑物屋顶误标成道路。
再有就是忘记留背景类别。有些标注工具允许只标前景不标背景,导致最后导出的掩码只有0和前景类,背景区域全是0,看起来没问题。但仔细核对类别ID映射时会发现背景ID被定义为了最后一个类别,跟0号通道的实际含义对不上,在训练时造成无法排查的偏差。建立映射表时,每定义一个类别ID就回到掩码图像里核对一遍,是最省事的办法。
6.2 训练阶段的典型故障与处理后
训练过程中的坑更多且更难排查,把遇到过的代表性故障和排查路径整理成下面这个速查参考。
| 故障表现 | 可能原因 | 排查与解决 |
|---|---|---|
| 显存不足(out of memory) | batch size过大 / 输入分辨率过高 / 模型过大 | 逐步调小batch size,使用梯度累积,裁剪输入尺寸 |
| loss不降或者不收敛 | 学习率过大/过小,或数据标准化不一致 | 先固定学习率1e-3测试,检查图像的归一化范围和掩码类型 |
| 验证loss低但mIoU也低 | 类别不平衡,尾部类别被忽略 | 给损失函数增加类别权重,或叠加DiceLoss |
| 预测结果全为同一类 | 最后一层未正确修改通道数 / 背景类占比过大 | 检查模型输出通道数、统计类别像素占比并分配权重 |
| 训练和验证loss持续下降但泛化差 | 数据增强不够 / 模型过拟合 | 增加数据增强、加大dropout、缩小模型容量 |
| CUDA报错 no kernel image | PyTorch版本与显卡驱动不匹配 | 按驱动CUDA版本重新安装对应PyTorch |
| 掩码与原图对不上 | 增强时未同步变换掩码 | 使用albumentations并传入mask参数,变换前确认类型 |
实际训练中最困扰的是模型坍缩问题,也就是训练到某个epoch后,所有预测结果都变成背景类。这种情况常见于少数类别占比过低且初始学习率偏大,模型快速收敛到局部最优,放弃少数类别。解决办法是降低学习率、增加少数类别的损失权重、在训练前几个epoch用warmup策略让模型稳定起步。加入学习率warmup之后,这类问题明显减少。
数据相关问题的排查优先于网络结构调整。我个人的习惯是:出现bad case先看训练集掩码和图像是否对齐,再看验证集预测可视化,最后才改模型和损失。很多看起来像模型的问题,实际是掩码标注错误或格式转换时ID偏移造成的,花点时间检查数据往往比调参更高效。
最后再分享一个自己的习惯:每次训练任务前,在项目目录里记录下数据集版本、类别映射表、标注规范版本和训练参数。踩过几次坑之后,这个习惯帮我在模型复现、结果回溯的时候节省了大量时间。数据集构建和训练其实是一个迭代过程,第一版不会一步到位,但当你把数据流程做扎实了,模型改进就有了可靠的评估基础。