Pytorch语义分割入门:CamVid数据集读取与实战避坑指南
很多做语义分割的朋友一开始总喜欢直接拿几十GB的大规模驾驶数据集练手结果光在数据下载和加载上就耗掉大半热情。我个人始终觉得CamVid数据集才是把整套 Pytorch 分割流程跑通的最佳起点。它规模适中、标注统一、类别覆盖了典型的道路场景用来学习“数据集怎么读、模型怎么喂、结果怎么验”再合适不过。这篇文章就用 Pytorch 版本为例从数据集结构讲到完整读取代码再把实操中踩过的一些坑一并写出来。1. 为何拿它当语义分割练手的第一站CamVid的定位与家底1.1 这份数据是什么来头CamVid全称是 Cambridge-driving Labeled Video Database是早期专门为自动驾驶场景理解而发布的视频语义分割数据集。它不是某个大型比赛的附属产物而是把真实行车记录仪的驾驶视频按帧切出来、对关键帧做像素级标注。换句话说你看到的每一张图都是从一段行驶视频里抽出来的真实画面包含城市街道、住宅区、高速公路等不同环境非常适合做道路场景分割算法的验证。数据集发布时间大概在2008年前后所以它的图像分辨率放在今天看不算高原始帧尺寸是 960×720共701张带像素级标注的图像。很多整理版本会按训练、验证、测试三个子集划分常见数量是训练367张、验证101张、测试233张。也有其他划分方式使用时要先确认你手里的文件列表是哪一种。1.2 规模、分辨率和类别结构数字背后的意义701张图听起来不多但对语义分割入门来说这个规模反而是优势。为什么这么说模型训练中的很多问题比如数据加载器写法错误、标签读取错误、损失函数维度不匹配在几千张乃至几万张的大数据集上往往要跑好几个epoch才发现而在 CamVid 这类小规模数据上几十步迭代就能看出端倪整个调试周期被大大缩短。分辨率方面960×720在当年是主流但直接塞给现代GPU跑会偏大所以实际实验里通常会缩放到 480×360 或 512×512 附近。这也意味着训练显存压力小普通显卡也能轻松跑起来。类别方面CamVid 原始标注有32个语义类别但很多类别过于细碎日常研究都会合并成11类加一个“忽略”类使用。我整理了一张常见合并表class_id类别RGB颜色(常见版本)0Void忽略(0, 0, 0)1天空(128, 128, 128)2建筑(128, 0, 0)3柱子/杆(192, 192, 192)4道路(128, 64, 128)5人行道(60, 40, 222)6树木(128, 128, 0)7标志符号(192, 128, 128)8栅栏(64, 64, 128)9车辆(64, 64, 0)10行人(0, 64, 64)11自行车(0, 128, 192)注意这张表的RGB是网上流传较广的一个版本不代表所有镜像都一样。当你下载数据集之后务必用原标签图抽样核对别因为色表不一致导致最终模型输出全是一团黑。后面讲标签编码时会再强调这一点。1.3 为什么我推荐它当入门数据集第一类别结构合理。11个常用类别覆盖了自动驾驶场景最主要的静态物体和动态目标既能练分割基本功又不会因为类别太多导致标注和验证变得繁琐。第二标签风格统一。CamVid 的标注整体比较工整物体边界相对清晰不像某些自然图像数据集那样存在大量边缘毛刺和难分样本。新手可以先把主要精力放在模型流程上而不是一开始就跟“标注噪声”搏斗。第三生态成熟。只要搜“CamVid Pytorch”就有大量项目可以参考各种预处理方式、训练策略都有现成结果。你可以拿自己的实现和别人公开的数据对齐方便排查到底是数据处理错了还是模型结构写错了。2. 拿到手之后先搞清楚这三件事目录结构、标签编码与官方划分2.1 目录结构常见整理形式与文件清单CamVid 官网提供的原始文件比较分散所以社区里流传的整理版式五花八门。最常见的两类组织方式一种是按数据集子集分文件夹CamVid/ train_images/ 0001TP_008550.png 0001TP_008760.png ... train_labels/ 0001TP_008550_L.png 0001TP_008760_L.png ... val_images/ ... val_labels/ ... test_images/ ... test_labels/ ...另一种是所有图片和标签各自放在总目录中再通过文本文件索引CamVid/ images/ 0001TP_008550.png ... labels/ 0001TP_008550_L.png ... train.txt val.txt test.txt两种方式各有优劣。按子集分目录代码写起来直觉但遇到需要调整划分比例时比较麻烦文本索引方式更加灵活而且很多预处理流程都用文本文件记录训练集列表你后续跑别的数据集时会发现同样的套路。我个人习惯用第二种因为它天然支持“只需要改一个txt就重新划分数据”的玩法。文件名匹配是一个容易被忽略的细节。CamVid 的标签图通常在原图文件名后加_L后缀比如0001TP_008550.png对应0001TP_008550_L.png。写 Dataset 时要么用后缀拼接要么从标签目录独立扫描再按序号配对千万别默认“原图叫 A标签也叫 A”。2.2 标签到底用什么编码存RGB色表与灰度ID两条路线这是读取 CamVid 的核心问题。官方早期发布的标签图是彩色RGB标注也就是每个像素点的颜色属于某个类别的固定RGB值后来很多整理版本会额外提供灰度ID标注每个像素点的灰度值直接就是类别编号。两种方案各有对应处理方式。如果你拿到的是单通道灰度ID版本读取非常简单label Image.open(label_path).convert(L) label np.array(label, dtypenp.int64)这句代码读出来像素值0就是Void像素值1就是天空依此类推。但请注意别人整理灰度ID的方式可能不同有人把Void设为255有人把Void设为0有人把类别从1编号而不是从0编号。所以拿到新数据集后第一件事是打印一下np.unique(label)看看实际存在哪些像素值再确认类别编号策略。如果你拿到的是彩色RGB标签处理就麻烦一些。直接convert(L)是错的做法因为灰度化是RGB三个通道加权求和的结果根本不是类别ID。正确思路是建立RGB到类别的映射表然后逐像素查表。比如以11类版本为例可以先构建一个字典color_to_id { (128, 128, 128): 1, (128, 0, 0): 2, (192, 192, 192): 3, (128, 64, 128): 4, (60, 40, 222): 5, (128, 128, 0): 6, (192, 128, 128): 7, (64, 64, 128): 8, (64, 64, 0): 9, (0, 64, 64): 10, (0, 128, 192): 11, }不过逐像素用for循环查表速度太慢。更高效的做法是利用 numpy 全量映射。先给每个通道建一个0到255的索引偏移再用哈希映射或者干脆把整张彩色标签压成三维索引后通过一个预先构造的查找数组完成转换。代码可以这样写def rgb_to_id(rgb_label, color_to_id): h, w, c rgb_label.shape id_label np.zeros((h, w), dtypenp.int64) for rgb, class_id in color_to_id.items(): mask np.all(rgb_label np.array(rgb), axis-1) id_label[mask] class_id return id_label类别一共只有十来个每次循环做一次布尔比较性能完全可以接受。如果只是数据预处理阶段转换一次离线保存成npy或直接用灰度ID版本训练时就能省去这步。2.3 官方划分不代表一切理解train/val/test的局限CamVid 的常用划分里验证集只有101张且这些帧和训练集同属少数几个视频序列。这意味着验证集和训练集的场景分布非常接近指标会比在全新路段上表现得更乐观。如果你要对比模型A和模型B那没问题大家都在同一套划分上跑但如果你想对外宣称“这个分割精度在真实驾驶场景中如何如何”就要冷静一点。此外网上流传的模型性能数字不一定都基于同一份划分。有些论文用367/101/233有些用Masata等人后来发布的扩展标注有些只在11类上评测有些把Void类也当作第12个类别参与计算。对比指标前先确认对方用了什么协议否则会得到完全错误的结论。3. Pytorch代码实现从读取一张图到喂进DataLoader这一部分给出可直接照抄的实现补齐各种细节让文件列表、同步数据增强、数据加载一条龙都跑通。3.1 自定义Dataset骨架文件列表匹配逻辑我平时最顺手的写法是文本文件索引模式。假设目录结构是images/、labels/加三个txt文件每个txt里每行是不带后缀的文件名。Dataset代码如下。import os import numpy as np import torch from PIL import Image from torch.utils.data import Dataset class CamVidDataset(Dataset): def __init__(self, root_dir, split, size(480, 360), use_color_labelFalse): root_dir: CamVid数据集根目录 split: train, val, test size: (height, width)用于统一缩放 use_color_label: 标签是否为彩色RGB编码 self.root_dir root_dir self.image_dir os.path.join(root_dir, images) self.label_dir os.path.join(root_dir, labels) self.size size self.use_color_label use_color_label with open(os.path.join(root_dir, f{split}.txt), r) as f: self.file_names [line.strip() for line in f if line.strip()] # 对彩色标签模式建立颜色映射与ID颜色表 self.color_to_id { (128, 128, 128): 1, (128, 0, 0): 2, (192, 192, 192): 3, (128, 64, 128): 4, (60, 40, 222): 5, (128, 128, 0): 6, (192, 128, 128): 7, (64, 64, 128): 8, (64, 64, 0): 9, (0, 64, 64): 10, (0, 128, 192): 11, } self.id_to_color {v: k for k, v in self.color_to_id.items()} def __len__(self): return len(self.file_names) def __getitem__(self, idx): fname self.file_names[idx] image_path os.path.join(self.image_dir, fname) label_path os.path.join(self.label_dir, fname.replace(.png, _L.png)) image Image.open(image_path).convert(RGB) if self.use_color_label: label_rgb np.array(Image.open(label_path).convert(RGB)) label self._rgb_to_id(label_rgb) else: label np.array(Image.open(label_path).convert(L), dtypenp.int64) image np.array(image.resize((self.size[1], self.size[0]), Image.BILINEAR)) label np.array(Image.fromarray(label.astype(np.uint8)).resize( (self.size[1], self.size[0]), Image.NEAREST), dtypenp.int64) image image / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) image (image.astype(np.float32) - mean) / std image torch.from_numpy(image.transpose(2, 0, 1)).float() label torch.from_numpy(label).long() return image, label几个关键细节解释一下。第一标签在缩放时使用Image.NEAREST而不是双线性插值。双线性会对类别边界产生新的插值像素值制造出不存在的新类别ID这是训练loss直接变成nan或者类别数爆炸的常见原因。第二图像归一化采用的均值和标准差是一组常用参数不是随机选的。如果你后续会在预训练权重基础上微调模型当初训练时就是按这组参数归一化的推理时也要保持一致。如果是从零开始训练这组参数并不是必须的但用了也没有坏处习惯保持一致能省很多事。第三__getitem__里返回的是浮点图像和整数标签类型一定不能混。Pytorch 的CrossEntropyLoss会要求label是torch.long类型输入是torch.float类型一旦搞错报错信息往往很隐晦。3.2 同步数据增强图像和标签必须“一起变形”分割任务的数据增强最忌讳的是图像做了翻转、裁剪标签没跟着做。如果标签和原图错位模型等于在拿错误标注训练而且这种错误很难从最终指标上直接看出来。这里提供两个轻量级同步变换不用额外安装框架。import random import torch.nn.functional as F def random_hflip(image, label, p0.5): if random.random() p: image np.flip(image, axis1).copy() label np.flip(label, axis1).copy() return image, label def random_crop(image, label, crop_h, crop_w): h, w image.shape[:2] top np.random.randint(0, h - crop_h 1) if h crop_h else 0 left np.random.randint(0, w - crop_w 1) if w crop_w else 0 image image[top:top crop_h, left:left crop_w] label label[top:top crop_h, left:left crop_w] return image, label在__getitem__里先做同步增强再做缩放和归一化。顺序有讲究平移、翻转这类几何变换不会改变像素灰度语义可以在缩放前做如果把缩放放在前面再做随机裁剪也行但裁剪框坐标要按原始比例换算反而容易出错。我的建议是先随机裁剪、再随机翻转、最后统一缩放到固定尺寸。如果你希望省事直接把albumentations这个库引进来它天然支持同步变换image和mask。以我的习惯小项目里为了减少依赖手写上面几个函数完全够了要上复杂增强如颜色扰动、随机遮挡时再引入外部工具库把代码可维护性和性能平衡好。3.3 让代码跑起来数据加载、可视化验证与最小示例写好Dataset后组装DataLoader非常简单。from torch.utils.data import DataLoader train_dataset CamVidDataset( root_dir./CamVid, splittrain, size(360, 480), use_color_labelFalse ) val_dataset CamVidDataset( root_dir./CamVid, splitval, size(360, 480), use_color_labelFalse ) train_loader DataLoader( train_dataset, batch_size8, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue ) val_loader DataLoader( val_dataset, batch_size4, shuffleFalse, num_workers2, pin_memoryTrue )pin_memory在GPU训练时能减少CPU到GPU的拷贝时间。drop_last对训练集设置为True可以避免最后一个batch尺寸太小导致BatchNorm统计异常验证集则不需要。数据加载完成后的第一件事不是直接训练而是做一次可视化检查。我会写一个辅助函数把模型输入转回直观的彩色分割图def decode_segmap(label_tensor, id_to_color, void_id0): h, w label_tensor.shape color_map np.zeros((h, w, 3), dtypenp.uint8) for class_id, color in id_to_color.items(): color_map[label_tensor class_id] color # void类保持黑色 return color_map取一个batch的图片和标签打印np.unique(label_tensor.numpy())确认类别ID和预期一致再用上面的函数把标签转回彩色图和原图放在一起肉眼对比。这一步值得花10分钟做扎实它可以过滤掉大半后续可能出现的诡异问题。比如标签暗了一个色阶、路面和天空互换、Void边缘没有去掉等都在这一步暴露出来。3.4 常见实现的性能注意点读取速度也是分割项目里容易拖后腿的环节。CamVid 图片不大但如果你在__getitem__里反复进行 PIL 转 numpy、numpy 转 PIL、再缩放等操作单卡训练时很容易让CPU成为瓶颈。几个实用优化思路离线预处理如果所有训练图像都是固定的(360, 480)可以提前把所有缩放、ID转换都做完保存成npy或直接使用专门的张量格式训练时只负责读取和增强。标签转ID别放训练循环里反复做彩色RGB标签的逐类遍历虽然只循环11次但每次都做全尺寸布尔运算在频繁读取时仍然有额外开销。建议预处理时一次性转成灰度ID版本保存。num_workers不是越大越好过高的worker数量会导致大量进程频繁切换反而降低吞吐。在常见8核CPU上取2到4比较稳妥。可以先用小脚本测最快配置再全量跑训练。避免重复文件扫描Dataset的__init__中一次性读取文件列表不要在__getitem__中每次都做os.path.exists之类的操作。4. 这个数据集的坑与解法来自实操的复盘记录4.1 彩色标签图的“伪灰度”问题新手最容易翻车的地方网上很多教程会把彩色标签的_L.png说成灰度标签但实际下载后就会发现用convert(L)读出来的像素值并不等于类别ID。因为很多镜像里的标签本质是RGB三通道彩色图每个类别的颜色虽然大多是灰色系但经过灰度转换后会落到一些非常接近却不同的灰度值上。我见过的最经典翻车现场是有人直接用Image.open(label).convert(L)读标签然后训练loss下降正常但可视化出来的分割结果完全错乱因为标签里的“道路”和“建筑”在灰度化后被挤压到了同一个值附近。这种问题不查颜色映射表根本发现不了最终跑了几天实验才发现是读取方式错了。所以拿到标签后先抽样检查这是铁律。具体做法是把标签图的三个通道拆开来看如果R、G、B三个通道的像素值完全相等说明是真的灰度图只要有任一通道值不同就按彩色标签处理走RGB映射路线。4.2 图像尺寸与模型输出不匹配的连锁反应分割模型里下采样因子往往让输出特征图尺寸不等于原图尺寸。比如输入 480×360经过几次池化或stride卷积后特征图可能是 60×45 或 120×90需要上采样回原尺寸计算loss或者把标签下采样到特征图尺寸再计算loss。两种做法我都有过失败经历。标签下采样时如果用了默认的线性插值会产生大量非整数的标签值CrossEntropyLoss拿到这些小数会直接报错或者静默跳过大量有效像素。正确做法是使用torch.nn.functional.interpolate时明确指定modenearest或者干脆使用torchvision的分割模型自带的输出上采样让模型直接输出和输入一样的分辨率。另一个连锁反应是输入尺寸不统一。如果DataLoader没做固定尺寸缩放Pytorch会自动把不同尺寸的样本尝试stack到一起然后报形状不匹配错误。CamVid虽然原始分辨率统一但经过不同方式裁剪后很容易出现尺寸不一致所以Dataset里固定resize这一步不能省。4.3 类别严重不平衡道路和天空吃掉绝大部分像素如果按像素统计CamVid里“道路”“天空”“建筑”加起来占了绝大多数标签面积而“行人”“自行车”“柱子/杆”这类细长物体可能只占总像素的百分之几。这不是Bug而是真实驾驶场景的固有分布。直接在原始分布上训练模型的mIoU表面看可能还不错但把每个类单独拆开看时行人、自行车这类小目标往往几乎没被学出来。解决思路有这么几种用加权CrossEntropy按类别像素占比的倒数设置权重让小类在loss里获得更大贡献。忽略Void类训练时设置ignore_index0。Void像素大多是图像边缘或标注不确定区域参与计算只会让模型变得更困惑。提高输入分辨率细长物体在低分辨率下丢失严重把输入从 360×480 提到 720×960 能明显改善小物体召回但显存开销也会增大。侧重指标评估时使用mIoU而不是全局准确率因为全局准确率会被大类别主导很难反映出小类别的真实表现。我在CamVid上做实验时最常用的是加权CrossEntropy加上ignore_index0。类别权重可以在Dataset里预统计一次保存下来反复用没必要每个epoch都重新算。4.4 Windows多进程的经典报错RecursionError与数据加载器在Windows上跑Pytorch的DataLoader如果代码没有按脚本入口保护经常会碰到RecursionError甚至子进程无限重启。原因是Windows上没有forkDataLoader的每个worker都会重新导入主模块导致数据集构建语句被反复执行。解决办法非常简单把数据加载、训练循环这些代码都放进if __name__ __main__:或者单独写一个train.py文件从命令行运行。虽然这个问题不止CamVid会遇到但只要写过一次就记住了。建议你把“加载数据集并打印信息”当作冒烟测试在跑训练之前先确认数据加载没问题。4.5 可视化调试验证肉眼判断读对没有最后分享一个我一直在用的验证流程。拿到数据集后不急着搭模型先完成“读取一张图 - 读同一张图的标签 - 彩色还原标签 - 拼接保存”这样一次完整链路。保存出来的图应该像这样原图在左彩色分割掩码在右要能清楚看到道路、建筑、天空各自被填上了对应颜色且大结构边缘和原图对齐。如果这一步通过了再开训练。如果训练过程loss出现明显异常优先怀疑的不是模型结构而是数据部分。很多问题在loss曲线上看起来都像是“模型不收敛”但其实就是数据加载时标签已经错了。回归这个最小验证流程能帮你节省大量无效调参时间。写在最后的个人习惯我每次新拿到一套分割数据都会强制自己先做一遍“10分钟可视化检查”再谈训练。这套流程在CamVid上帮我排查过两三次标签读取错误后来迁移到其他数据集也一样适用。另外一个小技巧是训练时可以先只取两三个类别、几十张图片跑两三个epoch确认损失确实在下降再放开到全部类别和全部数据。这个最小闭环一旦跑通后面就是加数据、调模型、刷指标的事。CamVid虽然年代久远但作为Pytorch分割流程的试验田它给我的收益远超数据本身的精度价值。