
很多朋友第一次接触语义分割时眼睛都会死死盯在模型结构上好不容易把网络跑通了却发现训练出来的结果一塌糊涂明明损失很低预测的 mask 边缘却歪歪扭扭甚至整片区域完全对不上。这时候才反应过来问题大概率出在语义分割的数据处理上。我自己的经验是语义分割项目里大概有六成的时间要花在数据上——格式转换、掩码对齐、样本平衡、增强策略任何一个环节出了偏差模型再怎么调也白搭。这篇就按我自己做项目的实际流程把语义分割数据处理的完整链路拆开讲一遍包括标注格式怎么选、掩码怎么生成、图像和标签怎么对齐、增强怎么做才不把标签给增强坏了最后再附上一份踩坑清单希望能帮还没摸透这块的读者少走点弯路。1. 先搞懂语义分割的四种标注格式语义分割的数据处理和分类、目标检测有个根本区别它的标签不是简单的一个数字或者一个框而是一张和原图尺寸相同、每个像素点都有类别信息的掩码。但我们在实际项目里拿到的原始标注很少直接就是能喂给模型的数组更多时候是下面这四种格式混着出现。格式典型文件形式特点常见来源多边形标注JSON/XML存顶点坐标直观、灵活、节省存储标注平台、开源数据集编码掩码RLE 字符串或 COCO 格式紧凑压缩、便于传输COCO 数据集、目标检测复用标注索引掩码PNG 单通道像素值类别ID最常用、可直接训练Cityscapes、ADE20KOne-Hot 掩码多通道二值图显式表达类别概率教学Demo、某些第三方标注工具这四种格式里面日常最坑的是多边形标注和编码掩码。因为模型训练时基本只认索引掩码你没把这些格式转过去就得自己处理转换逻辑。另外有一些标注工具导出的会是 RGB 颜色掩码每个类别用一种颜色画出来——初看很方便但转索引掩码时必须维护一张颜色到类别ID的映射表而且这张表一旦有两条相近的颜色转换结果就会糊成一片。顺手说一句 RLE 编码。COCO 数据集很喜欢用它本质是连续相同值计数起始位置的压缩算法。你要是直接从 COCO 的分割标注里把 RLE 拿过来训练需要先解码成二进制掩码再经过多边形或者直接重新标记处理。相关工具在 pycocotools 里已经封装好了decode 函数一行就能解出 mask但很多新手完全不知道这一点一直在手写解析逻辑。所以我的建议是不管你从哪个平台拿的数据第一件事是统一格式成索引掩码 PNG。所有后续的裁剪、归一化、增强、加载都围绕这一种格式来写心智负担能减少一大半。2. 从JSON多边形到训练掩码转换链路的全流程先解释一下为什么不能直接拿多边形坐标喂给模型。语义分割的输出是一个逐像素的类别预测而多边形是一系列坐标点两者差了栅格化这一步。多边形描述的是一块区域的轮廓掩码描述的是这块区域内所有像素点的归属所以必须有一个栅格化的过程把轮廓围出来的区域填成像素。我自己在复现某份数据集时遇到过标注里同一个目标既有主轮廓、又有内部孔洞的情况。比如一张桌子的标注除了桌面轮廓还有四个抽屉孔洞如果只简单填充外部轮廓不处理内部孔洞生成的掩码就会把抽屉区域也标成桌子。这一点常被忽略但影响很大。下面是一个我常用的 JSON 转掩码处理脚本逻辑不算复杂包含了主轮廓填充、孔洞扣除、类别映射三步import json import numpy as np import cv2 from pycocotools import mask as coco_mask def polygon_to_mask(polygons, img_h, img_w, holesNone, fill_value1): 将多边形转成二值掩码。 polygons: 外部轮廓点列表格式为 [[x1, y1, x2, y2, ...], ...] holes: 内部孔洞点列表格式同 polygons可为空 fill_value: 填入的像素值 mask np.zeros((img_h, img_w), dtypenp.uint8) # 先填充所有外部轮廓 for poly in polygons: pts np.array(poly, dtypenp.int32).reshape(-1, 2) cv2.fillPoly(mask, [pts], colorfill_value) # 再用孔洞把对应区域扣掉 if holes is not None: for hole in holes: pts np.array(hole, dtypenp.int32).reshape(-1, 2) cv2.fillPoly(mask, [pts], color0) return mask def json_to_label(json_path, img_h, img_w, class_map): 将标注 JSON 转成索引掩码。 class_map: 类别名 - 类别ID 的字典例如 {background: 0, car: 1} with open(json_path, r, encodingutf-8) as f: anns json.load(f) label np.zeros((img_h, img_w), dtypenp.uint8) for ann in anns[objects]: class_name ann[class_name] if class_name not in class_map: continue cls_id class_map[class_name] polygons [ann[polygon]] # 有些标注里会有多个外轮廓按需扩展 holes ann.get(holes, None) single_mask polygon_to_mask(polygons, img_h, img_w, holes, fill_value1) # 类别ID 0 作为背景直接在原图上叠加 label[single_mask 1] cls_id return label这段代码有三个要注意的细节。第一坐标类型。JSON 里导出的坐标可能是 float直接传给cv2.fillPoly大概率会报错或者因为四舍五入产生边缘偏差。建议先转成np.int32并且在转换前统一做一次 round避免坐标点出现 0.5 像素这种尴尬情况。第二孔洞顺序。有些标注平台把孔洞和外部轮廓无差别地放在同一个数组里这种时候没法靠先画外部再扣孔洞的逻辑处理只能依赖标注本身的层级关系。遇到这种情况我会写一个前置判断比较每个多边形之间的包含关系用点在多边形内部的数量来判断谁是外框、谁是孔洞。这个计算量不大但能省去大量人工核对时间。第三类别覆盖顺序。上面脚本里用的是直接覆盖逻辑——如果两个不同类别标注在像素层面有重叠后画的会盖掉先画的。多数数据集设计时不会让类别重叠但实际标注偶尔会出现所以建议在处理完一张图后顺手统计一下各类别像素数量如果发现某个类别的像素数异常少多半是被覆盖了要及时检查原标注。转换完成后还有一个验证步骤把索引掩码可视化与原始图像叠在一起看边缘对齐情况。这一步我每次都会做因为 JSON 里的坐标如果和原图分辨率不匹配错位的视觉反馈会非常直接。可视化工具不复杂import matplotlib.pyplot as plt def visualize_overlay(image, label, class_names, save_pathNone): overlay image.copy() # 用一组经典调色板给不同类别上色便于肉眼辨别 colors np.array([ [0, 0, 0], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255] ], dtypenp.uint8) colored_mask colors[label % len(colors)] alpha 0.6 overlay cv2.addWeighted(overlay, 1 - alpha, colored_mask, alpha, 0) plt.figure(figsize(12, 6)) plt.subplot(1, 3, 1) plt.imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) plt.title(image) plt.subplot(1, 3, 2) plt.imshow(colored_mask) plt.title(colored mask) plt.subplot(1, 3, 3) plt.imshow(cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB)) plt.title(overlay) if save_path: plt.savefig(save_path) plt.show()3. 数据集的目录组织与样本平衡网络结构可以反复实验但数据组织如果一开始就乱后面写数据加载器、做训练测试划分、做错误分析的时候会极其痛苦。我经历过一次数据集目录大改造那个痛到现在还记得标注文件散落在七八个文件夹里命名规则三天变了两次写个加载器硬是被迫写了三百行兼容逻辑。规范的目录结构其实没有那么多花样关键是要有明确约定并坚持到底dataset/ ├── images/ │ ├── train/ │ │ ├── 000001.jpg │ │ ├── 000002.jpg │ │ └── ... │ ├── val/ │ └── test/ ├── masks/ │ ├── train/ │ │ ├── 000001.png │ │ └── ... │ ├── val/ │ └── test/ ├── class_names.txt └── dataset_info.json这里有个容易犯的错误很多人图省事把test集的标注也一起放在masks/test里。如果只是为了提交结果这没问题但如果你后期还想做错误分析、模型蒸馏测试集就用不了。所以我一般至少保留一个没有标注的真·测试集标注只用来做线下验证。另外class_names.txt的排列顺序要和索引掩码的类别ID严格对应这个文件同时也是模型输出解码的关键顺序一旦错一整张预测就得调整。接下来是样本平衡问题。语义分割的类别不平衡比分类场景更隐蔽。分类任务里你至少一眼能看出哪个类别样本少但分割里一个类可能出现在很多张图里每张图只占边缘几个像素——按图片数量统计看不出问题按像素数量统计才能看到真相。我每次都会做一个像素分布统计用一张表来直观反映类别图像数像素总数占比背景1200152,000,00096.8%道路8003,200,0002.0%电线杆4289,0000.06%远景植被6001,700,0001.1%这种背景主导 稀疏小目标的结构非常典型。如果直接用原始分布训练模型会倾向于把所有像素都预测成背景因为只预测背景就能拿到很高的像素准确率。常用的调整手段有这么几种。类别权重在损失函数里给像素占比小的类别加大权重例如使用weight 1 / sqrt(freq)或者中位数频率加权。这个最简单也是我第一优先级尝试的方案。裁剪采样对包含稀缺类别的区域做密集裁剪让每个 batch 里稀缺类别的像素占比高一些。这里有一个实现细节不能简单固定裁剪左上角坐标而要以稀缺类别的像素坐标为中心随机偏移后裁剪偏移范围控制在裁剪框大小的 50% 以内否则容易裁出大量不包含目标的图像块。牺牲背景训练时随机把部分背景像素的标签忽略掉让模型不用花太多容量去拟合背景。ignore_index在多数分割框架里都支持直接设置一个特殊值即可。补充一个实操经验用类别权重时权重的计算时机最好在每轮训练前重新算一次或者至少每几个 epoch 算一次。因为在线增强会随机裁剪同一个 epoch 内的类别像素分布和全局统计往往有偏差固定权重容易帮倒忙。4. 图像预处理与掩码对齐很多教程会把预处理简化为resize 到 512×512归一化转 tensor但语义分割里有个隐藏要求对图像做的任何空间变换必须对掩码做一模一样的位置变换。这句话说起来轻巧做起来特别容易出错。最典型的就是cv2.resize的插值方式问题。图像可以用双线性插值因为相邻像素颜色变化是渐变的但掩码是离散类别标签如果用双线性或三次插值会在类别边界处产生中间值。比如像素左边是类别 1右边是类别 2插值后中间就会出现类别 1.5 这种根本不存在的标签模型的损失函数看到这种标签直接乱套。所以掩码缩放必须用最近邻插值def resize_image_and_mask(image, mask, target_size): h, w target_size resized_img cv2.resize(image, (w, h), interpolationcv2.INTER_LINEAR) # 关键点mask 用最近邻保证标签值不产生新值 resized_mask cv2.resize(mask, (w, h), interpolationcv2.INTER_NEAREST) return resized_img, resized_mask除了 resize归一化也要小心。图像归一化是对 RGB 三通道做均值方差缩放只作用于图像掩码绝对不能做归一化它里面的整数本身就是语义信息一归一化就变成浮点了模型输出的 target 跟着就乱。另外 mind 一个小细节如果用了ImageNet的均值和方差做归一化那你的图像必须是 RGB 顺序很多 OpenCV 读出来是 BGR转一遍再归一化不然颜色通道倒置会让模型收敛慢一些。还有一个和 padding 相关的坑。为了让 batch 里所有图尺寸一致很多人会直接 pad 到固定大小。对于图像pad 部分通常填 0 或者边界像素复制影响不大但 mask 的 pad 部分必须填一个特殊值推荐用255并设置成损失函数的ignore_index。不要用 0 去填 mask 的 padding 区域因为 0 在绝大多数数据集里是背景类别这样操作等于强迫模型去学习一个空白区域背景的错误规律特别是在真实目标位于边界时误判率会明显上升。完整的数据加载链路可以按这个思路实现class SegDataset: def __init__(self, img_dir, mask_dir, class_names, target_size(512, 512), augmentNone): self.img_paths sorted(glob.glob(os.path.join(img_dir, *.jpg))) self.mask_paths [ os.path.join(mask_dir, os.path.basename(p).replace(.jpg, .png)) for p in self.img_paths ] self.target_size target_size self.augment augment def __getitem__(self, idx): img cv2.imread(self.img_paths[idx]) # BGR img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) img, mask resize_image_and_mask(img, mask, self.target_size) # 归一化时只用图像 img img.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) img (img - mean) / std if self.augment is not None: img, mask self.augment(img, mask) # 增强函数必须同时作用于两者 # 转 tensor图像 HWC - CHWmask 直接转 long img torch.from_numpy(img.transpose(2, 0, 1)).float() mask torch.from_numpy(mask).long() return img, mask这份代码虽然短但已经把最容易出错的几个点都涵盖进去了BGR/BRG 转换、mask 灰度读取、最近邻 resize、归一化只做图像、mask 保持整数标签。我建议把它当作基线代码后续的增强、裁剪、多尺度都在这个框架上扩展。5. 数据增强中的同步变换问题数据增强是提升分割模型泛化能力最直接的手段之一但分割任务里增强要比分类严格得多因为几何类的增强必须保证图像和掩码做完全相同的变换。常见增强可以分成三类几何变换、光度变换、像素级合成增强它们的规则各不相同。几何变换包括随机翻转、随机旋转、随机裁剪、随机缩放。这类变换会改变像素位置所以掩码必须同步执行相同操作。操作时注意保持随机种子一致import random import torchvision.transforms.functional as TF def sync_geo_augment(img, mask): # 随机水平翻转同步作用于 mask if random.random() 0.5: img TF.hflip(img) mask TF.hflip(mask) # 随机旋转角度同步作用于 mask angle random.uniform(-10, 10) img TF.rotate(img, angle, fill0) # 注意mask 旋转时要用最近邻插值 mask TF.rotate(mask, angle, fill255, interpolationTF.InterpolationMode.NEAREST) return img, mask这里有两个细节很多人没意识到。第一是旋转的fill值图像旋转后角落会产生空白区域填 0 通常没问题但 mask 的 fill 若填 0 就会引入错误类别所以上面用 255 作为 ignore 处理。第二是旋转的插值方式即使通过 torchvision 操作也要显式指定 mask 用NEAREST否则同样会出现不存在的中间标签。光度变换包括亮度、对比度、饱和度、高斯噪声、模糊等。这类变换只改变像素值不改变位置所以绝不能作用到 mask 上只对图像操作即可实现时把 mask 原样返回。像素级合成增强典型代表是 CutMix、混合样本之类的技巧。这类增强通过拼接、覆盖、混合两张图的区域来生成新样本操作对象是图像和掩码处理时需要特别小心类别边界的连续性和掩码的空间一致性。我自己尝试过一个简化版 CutMix把 A 图的一个矩形区域粘贴到 B 图上掩码也对应粘贴用这个方式提高模型对遮挡场景的鲁棒性效果还不错但要注意粘贴后重叠区域的边界是否自然。如果只是把掩码硬贴过去模型可能会学习到矩形边界即是目标边界的错误先验。增强策略里还有个常见误区在验证或测试时继续做随机增强。测试集和验证集只需要确定性的 resize 和归一化不该做随机翻转、随机裁剪之类的操作否则每次评估的结果会不一致模型好坏根本没法比较。所有随机增强都放在train_loader验证测试加载器保持确定性这是铁律。6. 踩坑清单与调试工具数据处理环节的 bug 不容易直接报错经常悄悄让模型学坏等发现时训练已经跑了大半天。这里列出我踩过且最有代表性的几个坑做了个表格方便快速对照。坑描述表现排查思路解决方法mask 读取时用了彩色模式cv2.imread(path)训练 loss 不降或震荡剧烈检查 label 数组维度打印np.unique(mask)的类别数读取掩码必须用cv2.IMREAD_GRAYSCALE或 PIL 直接转 L 模式resize 时 mask 用了双线性插值评估时 mIoU 低可视化掩码边缘出现混色打印 resize 后 mask 的 unique 值发现非整数标签mask 强制INTER_NEAREST类别 ID 从 0 还是从 1 开始混乱训练始终无法拟合loss 徘徊在极大值统计各类别像素数发现背景类被当成类别 0 而真实类别却有偏差统一class_names.txt和 mask 的像素值确认N_CLASSES与最大类别 ID 匹配图像是 RGB 但归一化按 BGR 处理训练能收敛但精度比预期低单张图可视化归一化前后对比通道颜色整体偏蓝偏红确认通道顺序再做归一化只按图像数量抽样稀缺类别被漏掉小类目标完全预测不出来按像素统计类别分布发现稀缺类别样本占比极低用裁剪采样或类别权重矫正增强只对图像操作mask 保持不变训练 loss 低但验证很差可视化和原图对不上随机选一批增强后的图像/掩码对进行可视化重写增强函数保证几何变换同步作用于 mask除了上面表格我再分享一个排查工具打印一张图的唯一标签值。这个方法看起来简单但能快速发现大量问题def inspect_mask(mask_path): mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) unique_vals, counts np.unique(mask, return_countsTrue) print(shape:, mask.shape) print(unique values:, unique_vals) print(counts:, counts)如果打印出类似[0, 1, 2, 255]这样的值就要注意 255 是不是被你定义成了空白区域。如果打印出[0, 0.5, 1.5]那几乎可以确定是加载或插值出了问题。还有一个建议训练开始前先固定随机种子取固定几个 batch 的数据把图像和掩码分别可视化保存人工过一遍。这一步花十几分钟很多时候能提前几十个小时发现问题。我在实际项目中还养成了一个习惯每个 epoch 结束后在验证集里选 5 张代表性图片把原图、真实掩码、预测掩码三张图拼在一起保存。这个习惯帮助我快速判断模型是在学边缘还是学纹理也能及时看到数据处理上有没有新的问题暴露出来。很多异常现象其实不是训练出来的而是数据管道从源头一步步带偏的可视化越早、越勤后面返工越少。