做语义分割的应该都遇到过这个坎:想快速验证一个新想法,找了一圈数据集,要么太大下载折腾半天,要么类别太复杂预处理写得想哭。我当时就是这么从几个主流街景数据集里筛来筛去,最后老老实实用CamVid把流程跑通的。这个数据集虽然年头不短,图像数量也只有701张,但作为自动驾驶视角的逐像素分割基准,数据结构清晰、类别划分干净,特别适合用来搭Pytorch的数据读取和训练管线。今天这篇就把CamVid从下载到读取、从标签转换到DataLoader配置完整拆一遍,代码可以直接抄,顺便把我踩过的坑也一并列出来。
适合谁看?刚入门语义分割、想在Pytorch里跑通一个端到端训练流程的人;已经在用更大数据集但想先拿小数据调试网络的人;还有被各种自定义数据集的“脏标签”折磨过、想看看正规benchmark怎么做数据读取的人。读完你至少能收获三件事:搞清楚CamVid到底存了什么、怎么把彩色标签变成模型能吃的类别索引、以及一套能直接复用的Dataset模板。
1. 认识CamVid数据集:老牌但不过时的语义分割基准
1.1 这是给谁准备的,先跑通哪些任务
CamVid本质上是一组由车载摄像头拍摄的城市道路视频序列,对其中关键帧做了逐像素标注。早期做自动驾驶感知的研究者拿它来验证分割模型,因为场景里包含道路、车辆、行人、建筑、树木这些典型的街景元素,和真实行车场景的分布非常接近。现在它仍然适合用来做三件事:跑通语义分割训练pipeline、验证小模型的快速迭代效果、以及作为数据读取代码的调试样本。
我推荐大家先用它入门,原因很简单:图像数量少,只有701张标注帧,即便用CPU跑一个简单的分割网络也能在合理时间内完成一次完整的训练闭环。和大几千张甚至几万张的数据集相比,CamVid让你把精力放在“怎么把流程跑对”而不是“怎么熬过下载和解压”。等你在小数据上把模型、损失函数、评估指标都调顺了,再切到更大的数据也不迟。
1.2 11类还是32类,为什么社区都爱用11类
CamVid原始标注包含32个类别,粒度非常细,什么卡车、摩托车、人行横道、车道线都单独标了。但问题也随之而来:很多类别在整个数据集里只占极少像素,比如摩托车可能只有几百个像素,训练时模型根本学不到有效特征。如果直接按32类去做,类别不平衡会非常折磨人,mIoU也很难看。
所以社区里流传最广的其实是合并后的11类版本:Sky、Building、Pole、Road、Sidewalk、Tree、SignSymbol、Fence、Car、Pedestrian、Bicyclist。这11类把语义相近的小类别做了合并,比如把Car、Truck、Van统一成Car,把Pedestrian、Cyclist这类“动态小目标”单独保留。合并后的粒度对大多数分割模型评测足够用了,训练稳定性也好很多。
初次上手我建议直接选11类版本。等你把训练流程熟悉了,再去看32类原始标注也不迟,那时候你对类别分布的理解会更具体。
1.3 它和后来的大型数据集比,优势到底在哪
现在一提起街景分割,很多人第一反应是Cityscapes或者VOC这类大而全的数据集。但CamVid有一个独特优势:它是少见的带视频时序信息的街景分割数据,帧与帧之间存在连续的运动关系,图像尺寸也相对统一。
| 数据集 | 图像规模 | 分辨率 | 类别数 | 场景 | 适用阶段 |
|---|---|---|---|---|---|
| CamVid | 701帧 | 960×720或360×480 | 11/32 | 驾驶视角街景 | 快速原型验证、pipeline调试 |
| Cityscapes | 5000张精细标注 | 2048×1024 | 19 | 驾驶视角街景 | 正式训练、SOTA评测 |
| PASCAL VOC | 1711张 | 约375×500 | 20 | 通用物体 | 通用分割、迁移学习 |
从表格能看出来,CamVid的分辨率不算高,类别也不算多,但它胜在轻量。你要是在一台没有顶级GPU的机器上做实验,CamVid是唯一能在几分钟内跑完一个epoch、还能稳定复现结果的选项。我自己习惯拿它当“冒烟测试”,新写的读取代码、新的增强策略先在CamVid上跑一遍,确认没问题再换大数据。
2. 下载与文件结构:动手前必须搞清楚的细节
2.1 数据集目录长什么样
CamVid的下载包通常解压后是这样一个结构:
CamVid/ ├── images/ │ ├── 0001TP_000000.png │ ├── 0001TP_000005.png │ └── ... ├── labels/ │ ├── 0001TP_000000.png │ ├── 0001TP_000005.png │ └── ... ├── train.txt ├── val.txt └── test.txtimages目录放原始视频帧,labels目录放对应的标注图,两者通过文件名一一对应。train.txt、val.txt、test.txt记录的是三个集合各自包含哪些图像文件名,这是官方推荐的划分方式。
我之前还见过另一种组织方式,把train/val/test分别放在六个目录里,但不管目录怎么变,核心只有一条:图像和标签是同名PNG,训练时按文件名去两边找即可。如果你的下载版本里没有txt划分文件,可以自己写脚本按一定比例随机切分,但后面会说到,CamVid这种视频序列数据切分是有讲究的,先按官方txt用最省心。
2.2 标签为什么是彩色PNG,而不是灰度索引图
这是新手最容易懵的地方。很多人在Pytorch里做图像分类,习惯了target是一个整数标签,所以拿到语义分割数据时也下意识以为标签图是一个单通道灰度图,每个像素的值就是类别ID。
CamVid的设计不是这样。它的labels目录下每一张图是RGB彩色PNG,每个像素由三个通道组成,颜色和类别是一一映射的,比如道路是特定的一种紫色,天空是灰色,树木是绿色。这样设计的好处是方便人工可视化检查:直接打开PNG就能看清标注质量,不用额外写伪彩色渲染脚本。
但训练时必须把这层颜色外壳剥掉。Pytorch的CrossEntropyLoss要求target是[B, H, W]的LongTensor,每个位置的值是0到类别数减1的索引,而不是一个三维RGB三元组。所以读取标签后,第一步永远是“RGB转class_id”。
2.3 11类颜色映射表与查表转换技巧
11类版本的颜色映射如下,我建议直接把这份映射写进你的代码文件里,后面能省很多事:
| 类别ID | 类别名 | RGB颜色 |
|---|---|---|
| 0 | Sky | (128, 128, 128) |
| 1 | Building | (128, 0, 0) |
| 2 | Pole | (192, 192, 192) |
| 3 | Road | (128, 64, 128) |
| 4 | Sidewalk | (0, 0, 192) |
| 5 | Tree | (128, 128, 0) |
| 6 | SignSymbol | (192, 128, 128) |
| 7 | Fence | (64, 64, 0) |
| 8 | Car | (64, 192, 0) |
| 9 | Pedestrian | (128, 64, 64) |
| 10 | Bicyclist | (0, 128, 192) |
RGB转class_id最粗暴的写法是遍历每个像素,判断它属于哪种颜色。但如果有960×720的图像,遍历一次需要69万个像素,每张图都这样搞,训练速度会慢得让人失去耐心。
更优雅的做法是构建一个查表LUT。思路很简单:把所有可能的RGB三元组合并成一个唯一的数,R×256×256 + G×256 + B,这样一个RGB颜色就对应一个整数索引。预先把所有合法颜色的索引位置填上类别ID,其他位置填255,之后每个像素只要做一次乘法加法查表就能得到类别,速度比遍历判断快一个数量级。
import numpy as np CLASS_COLORS = [ [128, 128, 128], # 0 Sky [128, 0, 0], # 1 Building [192, 192, 192], # 2 Pole [128, 64, 128], # 3 Road [0, 0, 192], # 4 Sidewalk [128, 128, 0], # 5 Tree [192, 128, 128], # 6 SignSymbol [64, 64, 0], # 7 Fence [64, 192, 0], # 8 Car [128, 64, 64], # 9 Pedestrian [0, 128, 192], # 10 Bicyclist ] def build_color_lut(ignore_index=255): # 16.7M大小的LUT,占内存约16MB lut = np.full(256 * 256 * 256, ignore_index, dtype=np.uint8) for cls_id, (r, g, b) in enumerate(CLASS_COLORS): lut[(r << 16) | (g << 8) | b] = cls_id return lut def rgb_to_class_id(rgb_img, lut): img = np.asarray(rgb_img) keys = (img[:, :, 0].astype(np.int32) << 16) | (img[:, :, 1].astype(np.int32) << 8) | img[:, :, 2].astype(np.int32) return lut[keys]这个LUT大小为256的3次方,也就是1677万个元素,每个元素是uint8,占16MB内存,完全可接受。查表时用numpy数组直接索引,整个过程是向量化的,比逐像素判断快很多。
3. 手写Pytorch Dataset类:核心读取代码全解析
3.1 为什么要手写Dataset类
有些人可能会问:torchvision里不是已经封装了很多数据集的加载接口吗,为什么还要自己写?
确实有现成的接口可以加载一些常见的城市场景数据,但CamVid这种需要手动下载和解析的数据并不包含在torchvision的默认加载列表中,而且每个版本的目录结构都可能有点差别。更重要的是,训练语义分割模型时,你通常要自定义数据增强、划分方式、类别过滤逻辑,这些需求只有自己实现Dataset类才能完全掌控。
自己写Dataset的另一个好处是能真正理解数据管线的每一步。等哪一天你拿到一批没有标准格式的自定义数据,这套模板稍微改改就能用,不用到处找现成代码。所以我一直建议,做视觉方向的同学第一件事就是把Dataset类写熟。
3.2 Dataset类完整实现
下面这份代码我按照实际项目里的习惯写好,包含注释,直接复制就能跑。
import os import torch import numpy as np from PIL import Image from torch.utils.data import Dataset COLOR_LUT = build_color_lut() class CamVidDataset(Dataset): def __init__(self, data_root, split='train', joint_transform=None, image_transform=None, ignore_index=255): super().__init__() self.data_root = data_root self.split = split self.joint_transform = joint_transform self.image_transform = image_transform self.ignore_index = ignore_index self.image_dir = os.path.join(data_root, 'images') self.mask_dir = os.path.join(data_root, 'labels') split_file = os.path.join(data_root, f'{split}.txt') if os.path.exists(split_file): with open(split_file, 'r') as f: self.filenames = [line.strip() for line in f if line.strip()] else: # 如果没有txt,则取所有同名PNG self.filenames = sorted(os.listdir(self.image_dir)) def __len__(self): return len(self.filenames) def __getitem__(self, idx): name = self.filenames[idx] image = Image.open(os.path.join(self.image_dir, name + '.png')).convert('RGB') mask = Image.open(os.path.join(self.mask_dir, name + '.png')).convert('RGB') # 图像和标签必须用同一套空间变换 if self.joint_transform is not None: image, mask = self.joint_transform(image, mask) # RGB彩色标签转成单通道类别索引 mask = rgb_to_class_id(mask, COLOR_LUT) mask = torch.from_numpy(mask.astype(np.int64)) if self.image_transform is not None: image = self.image_transform(image) return image, mask这里几个关键点说一下:
PIL的Image.open是懒加载,真正读入磁盘数据是在convert或者np.array调用时才发生,所以Dataset里先open没有问题。图像必须convert('RGB'),防止某些灰度PNG被读成单通道;标签也必须convert('RGB'),因为我们后面做颜色匹配需要三个通道。
mask转成的是torch.int64,这正好是CrossEntropyLoss需要的target类型。千万不要转成float或者int32,Pytorch很多算子对target的dtype有严格检查,类型不对会直接报错。
3.3 拆分、缓存与数据增强的同步问题
很多人的第一个坑是增强时“图像变了,标签没跟着变”。torchvision的transforms大多独立作用在一张图上,如果对图像做随机翻转、对标签做随机翻转但随机种子不同,那两者就对不上了。
解决方式是自己写同步变换。核心思路是让同一个transform对象同时接收image和mask,内部用同一个随机数决定是否翻转、裁剪哪块区域。以下是我常用的几个同步变换:
import random from PIL import Image class RandomHorizontalFlip: def __init__(self, p=0.5): self.p = p def __call__(self, image, mask): if random.random() < self.p: image = image.transpose(Image.FLIP_LEFT_RIGHT) mask = mask.transpose(Image.FLIP_LEFT_RIGHT) return image, mask class Resize: def __init__(self, size): self.size = size def __call__(self, image, mask): # 图像可以用双线性,标签必须用最近邻,否则会插出不存在的新颜色 image = image.resize(self.size, Image.BILINEAR) mask = mask.resize(self.size, Image.NEAREST) return image, mask class ToTensorAndNormalize: def __call__(self, image): image = np.array(image).astype(np.float32) / 255.0 image = image.transpose(2, 0, 1) image = torch.from_numpy(image) # 使用ImageNet统计值做标准化 mean = torch.tensor([0.485, 0.456, 0.406]) std = torch.tensor([0.229, 0.224, 0.225]) image = (image - mean[:, None, None]) / std[:, None, None] return image注意Resize里标签图用NEAREST而不是BILINEAR。原因是最近邻插值只会取邻近像素的值,不会创造出新的颜色;而双线性插值会在两个像素之间加权平均,产生一个原映射表里不存在的RGB值,这个值在LUT里查不到,会被当成255丢进ignore,等于给标签挖了坑。
4. DataLoader配置与数据验证:训练前的最后一公里
4.1 参数怎么配不显尴尬:batch_size、num_workers、pin_memory
Dataset写好之后,关键就在DataLoader的参数上。以CamVid这种小数据量,一般建议:
from torch.utils.data import DataLoader train_loader = DataLoader( dataset, batch_size=8, shuffle=True, num_workers=4, pin_memory=True, drop_last=True, )batch_size取决于你的显存,小模型在10G以下的卡上可以用8,如果用的网络比较深,降到4更稳。num_workers在Linux下可以设成4到8,Windows下我建议设成0或者2,因为Windows的多进程数据加载偶尔会卡死,这是平台本身对spawn支持的问题。pin_memory建议开启,它是把数据从CPU内存锁定到页锁定内存,GPU训练时拷贝会更快。
还有个容易被忽略的参数是drop_last。CamVid的Mnist训练集样本数不一定能被batch_size整除,最后一个batch可能只有一两张图。如果网络里有BatchNorm层,小batch的统计量会很不稳定。我的习惯是设置drop_last=True,宁可丢掉最后不完整的batch,也不让它影响BN的统计。
4.2 用可视化脚本验证数据管道
Dataset写完后第一件事不是开训练,而是打印shape、检查数值范围、可视化几个样本。这一步能发现80%的读取问题,比训练到一半报错再回头排查高效得多。
检查代码很简单:
images, masks = next(iter(train_loader)) print(images.shape, images.dtype) # 期望 torch.Size([8, 3, H, W]) torch.float32 print(masks.shape, masks.dtype) # 期望 torch.Size([8, H, W]) torch.int64 print(torch.unique(masks[0])) # 期望包含0到10以内的整数unique的结果如果出现了比如128、192这种三位数,说明你的mask还是彩色RGB没转换干净。如果出现了255,说明有部分像素没匹配到任何合法颜色,需要检查颜色映射表是否和下载的标签版本一致。
可视化更直观,可以把mask从类别ID映射回RGB颜色:
import matplotlib.pyplot as plt color_map = np.array(CLASS_COLORS, dtype=np.float32) / 255.0 def mask_to_rgb(mask): # mask: [H, W] return color_map[mask.numpy()] fig, axes = plt.subplots(2, 4, figsize=(16, 8)) for i in range(4): axis = axes[0, i] # 反标准化,还原图像显示 img = images[i].permute(1, 2, 0).numpy() img = img * [0.229, 0.224, 0.225] + [0.485, 0.456, 0.406] img = img.clip(0, 1) axis.imshow(img) axis.axis('off') axis = axes[1, i] axis.imshow(mask_to_rgb(masks[i])) axis.axis('off') plt.show()把图像和标签两行并排显示,能一眼看出标签是否对齐、增强是否一致、颜色映射是否正确。我强烈建议大家把这段保存成一个脚本,以后换任何数据集都改一下路径直接复用。
4.3 类别权重计算:给Loss准备一份“看不到的配置”
CamVid的类别不平衡问题相当突出,道路和建筑占据大部分像素,行人和骑行者占比可能只有1%左右。如果你直接用普通的CrossEntropyLoss,模型会倾向于把所有像素预测成优势类别,mIoU很难看。
解决方案之一是在Loss里传class_weight。计算方式最常用的是每个类别像素占比的倒数,然后归一化。为了避免遍历每个样本,我一般写一个函数快速统计:
def compute_class_weights(dataset, num_classes=11, ignore_index=255): counts = torch.zeros(num_classes) loader = DataLoader(dataset, batch_size=1, num_workers=4) for _, mask in loader: mask = mask.squeeze(0).flatten() valid = mask != ignore_index values, counts_ = torch.unique(mask[valid], return_counts=True) counts[values.long()] += counts_.float() total = counts.sum() weights = total / (counts + 1e-6) weights = weights / weights.sum() * num_classes return weights class_weights = compute_class_weights(dataset) criterion = torch.nn.CrossEntropyLoss(ignore_index=255, weight=class_weights)这里weights做了归一化,让平均权重为1,这比直接传原始倒数更好调试,因为loss量级不会出现明显变化。ignore_index必须和Dataset里的ignore保持同一个值。
5. 常见问题与排查实录:这些坑我踩过,你别再踩
5.1 标签shape对不上:RGB彩色标签不是target
这个坑几乎每个新手都会踩一次。训练时报错形如“Expected target size [8, 3, 352, 480], got [8, 352, 480]”,或者更常见的是CrossEntropyLoss直接把target解释成二维了,然后维度不匹配。
原因很简单:labels目录下的PNG是彩色图,读进来是[H, W, 3],模型输出是[N, C, H, W],期望的target是[N, H, W]。如果你忘了做颜色到类别的转换,直接把RGB三维数组丢给Loss,它当然不知道该怎么处理。
排查思路也简单:打印一个batch的mask的shape和dtype。只要看到shape是4维、dtype是float32,就说明没转换干净,问题一定出在Dataset的getitem里。
5.2 类别数比你想象得多:黑色像素和解不开的颜色
不少人在统计类别时发现,unique的结果里不仅有0到10,还有255或者更大的值,更奇怪的是还有大量黑色像素。
CamVid标签图里确实存在一部分不属于11类的区域,比如车身、遮挡边缘、失真区域,这些像素在原始32类版本里有自己的类别,但在11类合并版本里没有对应关系。当你的LUT查不到这些颜色,默认的返回值就是255(也就是ignore_index)。
遇到这种情况不用慌张,这正是ignore_index存在的意义。只要确保这些像素占比不高(一般不超过2%),直接忽略即可。如果发现黑色像素占比异常大,那可能是下载的标签版本和你用的颜色映射表版本不一致,建议去找对应的11类合并版标签,或者自己写脚本统计所有出现的颜色再人工归并。
5.3 验证集信息泄露:随机切分的隐藏陷阱
CamVid的视频序列里,同一段视频相邻几帧的内容高度相似。如果你不管三七二十一,把所有图像放一起随机划分成train和val,那么很可能同一段路的第5帧在训练集、第6帧在验证集,验证指标会虚高到没有参考价值。
正确的做法是使用官方划分,或者按视频片段切分,保证同一段连续视频不能同时出现在两个集合里。这也是为什么我推荐直接用txt划分文件的原因——别人已经帮你考虑了时间相关性。
如果你非要自己划分,至少先按文件名前缀把图片分组,同一个前缀属于同一段视频,再在组级别做随机分配。
5.4 加载慢、内存爆:小数据集也有大麻烦
有些人训练CamVid时发现每个epoch的数据加载时间比训练时间还长,CPU占用拉满,GPU却一直空转。
问题通常出在RGB转class_id这一步。虽然LUT查表已经很快了,但如果你用的是逐像素遍历版本,或者每次都重新构建LUT,都会拖慢速度。另外PNG是压缩格式,每次读取都要解码,频繁IO对Linux的page cache也好不哪去。
解决办法很直接:CamVid只有701张图,完全可以在训练启动前一次性把所有标签转成单通道npy并缓存到内存。对于720p的图像,700张标签大概占用不到200MB内存,这个成本完全可以接受。
import pickle def build_cached_masks(dataset): cache_path = f'cache_{dataset.split}_masks.pkl' if os.path.exists(cache_path): with open(cache_path, 'rb') as f: return pickle.load(f) cache = [] for idx in range(len(dataset)): _, mask = dataset[idx] cache.append(mask.numpy()) with open(cache_path, 'wb') as f: pickle.dump(cache, f) return cache之后每次getitem直接从cache里取mask,图像单独读PNG即可,训练速度会有肉眼可见的提升。
5.5 类别极度不均衡:模型只认识道路和建筑怎么办
如果你发现训练到后期,预测结果里几乎整张图都是Road和Building,小类别像素被“吃掉”了,这就是典型的类别不平衡问题。
除了前面说的class_weight,还有几个实用技巧。一个是中位数频率平衡,它比简单的倒数权重更平滑,不会让特别稀有的类别权重爆炸;另一个是OHEM或focal loss,让模型更关注难分类像素。不过在CamVid这种小数据上,我建议先别急着上复杂loss,先做两件事:一是把ignore_index用对,别把未标注区域的梯度也传给网络;二是把class_weight调好,多数情况下就能看到小类别的mIoU明显回升。
我自己的调参顺序是:先不加权重跑一个baseline,看各类别IoU分布,确认瓶颈;然后加倒数权重,观察小类别的变化;如果还不够,再考虑中位数频率平衡或者focal loss。一步一步来,别一上来就上全套。
6. 跑通之后还能怎么扩展
数据读取只是第一步,跑通之后你会发现CamVid这个规模特别适合做快速实验。我个人的习惯是把它当作“分割模型调试场”:新写了一个模块、改了一个loss、换了一种增强策略,先在小数据集上跑几个epoch验证趋势,再决定是否迁移到大数据集。
扩展方向可以有几个。一是加一个简单的训练脚本,配合前文的class_weight和ignore_index,直接对标mIoU;二是把数据读取部分抽象成一个接口,后续替换成其他数据集时只需要改颜色映射和txt划分读取逻辑;三是尝试更复杂的同步增强,比如随机裁剪、色彩抖动,但记住标签永远只能用最近邻插值。
最后说一个我自己的习惯:每次拿到新数据集,我不会直接开train,而是先花10分钟写一个数据可视化脚本,打印shape、统计类别分布、画出几张原图和标签叠加图。这个习惯帮我避免了很多次“模型训练到一半发现标签是错的”的尴尬。CamVid是个很干净的起点,你在这套流程上踩过的每一个坑,都会在未来接触更大数据集时变成实实在在的经验。