Cityscapes是计算机视觉领域最知名的街景理解数据集之一,包含50个城市在不同季节、天气条件下的街景图像。我第一次接触这个数据集是在2018年做自动驾驶项目时,当时为了找到高质量的街景标注数据,几乎翻遍了所有公开数据集,最终Cityscapes以其精细的标注和丰富的场景成为首选。
数据集主要包含两部分:
对于大多数语义分割和实例分割任务,我们只需要使用精细标注集。下载时要注意,原始图像(leftImg8bit)和标注数据(gtFine)需要分别下载,总大小约11GB。这里有个小技巧:如果网络不稳定,可以使用wget的-c参数支持断点续传:
bash复制wget -c https://www.cityscapes-dataset.com/file-handling/?packageID=1
wget -c https://www.cityscapes-dataset.com/file-handling/?packageID=3
下载完成后,你会得到两个zip文件:
leftImg8bit_trainvaltest.zip - 原始街景图像gtFine_trainvaltest.zip - 精细标注数据解压时建议使用-j参数跳过中间目录结构,这样能保持文件路径的一致性:
bash复制unzip -j leftImg8bit_trainvaltest.zip -d cityscapes/images
unzip -j gtFine_trainvaltest.zip -d cityscapes/annotations
Cityscapes提供了专门的Python工具包cityscapesscripts来处理标注数据,这个工具包需要从GitHub克隆安装:
bash复制git clone https://github.com/mcordts/cityscapesScripts.git
cd cityscapesScripts
pip install -e .
安装完成后,需要设置环境变量指向你的数据集根目录。我在实际项目中发现,最好把这个设置写入.bashrc或.zshrc中:
bash复制echo 'export CITYSCAPES_DATASET=/path/to/cityscapes' >> ~/.bashrc
source ~/.bashrc
解压后的gtFine目录包含这些关键文件:
*_color.png - 可视化标注(给人类看的)*_instanceIds.png - 实例分割原始标注*_labelIds.png - 语义分割原始标注*.json - 多边形标注的原始数据这里有个容易混淆的点:labelIds和后续要生成的labelTrainIds是不同的。原始labelIds包含所有34个类别,而labelTrainIds只包含19个常用训练类别,其他类别会被标记为255(忽略)。
运行以下命令生成语义分割训练所需的labelTrainIds.png:
python复制python cityscapesscripts/preparation/createTrainIdLabelImgs.py
这个脚本会在原标注目录下生成新文件,命名格式为*_labelTrainIds.png。我曾经遇到过这个脚本报错的问题,大多数情况是因为环境变量没设置正确,可以通过在脚本中硬编码路径临时解决:
python复制# 在createTrainIdLabelImgs.py开头添加
import os
os.environ['CITYSCAPES_DATASET'] = "/your/actual/path"
实例分割需要两步处理:
运行官方提供的脚本:
python复制python cityscapesscripts/preparation/createTrainIdInstanceImgs.py
这个脚本会生成*_instanceTrainIds.png文件,其中每个实例都有唯一的ID值。在实际项目中,我发现摩托车后座上的乘客经常会被错误合并到同一个实例,这时就需要后期人工调整标注。
Cityscapes默认的19类可能不符合所有项目需求。要修改类别,需要编辑cityscapesscripts/helpers/labels.py。例如,如果我们不需要"摩托车"类:
python复制labels = [
# ...
Label('motorcycle', 5, 7, 'vehicle', False, True, 255),
# 改为
Label('motorcycle', 5, 7, 'vehicle', False, True, 255, True),
# ...
]
修改后需要重新运行前面的标注生成脚本。这里有个坑:修改labels.py后,必须删除之前生成的*_trainIds.png文件,否则脚本可能不会重新生成。
原始数据是按城市分组的,但训练时我们通常需要按train/val/test组织。下面这个脚本可以帮你重组目录结构:
python复制import os
import shutil
from tqdm import tqdm
def reorganize_dataset(img_src, label_src, dest_root):
splits = ['train', 'val', 'test']
for split in splits:
img_dest = os.path.join(dest_root, 'images', split)
label_dest = os.path.join(dest_root, 'labels', split)
os.makedirs(img_dest, exist_ok=True)
os.makedirs(label_dest, exist_ok=True)
cities = os.listdir(os.path.join(img_src, split))
for city in tqdm(cities, desc=f'Processing {split}'):
img_files = os.listdir(os.path.join(img_src, split, city))
for f in img_files:
if 'leftImg8bit' in f:
# 移动图像文件
shutil.move(
os.path.join(img_src, split, city, f),
os.path.join(img_dest, f)
)
# 移动对应的标注文件
label_f = f.replace('leftImg8bit', 'gtFine_labelTrainIds')
shutil.move(
os.path.join(label_src, split, city, label_f),
os.path.join(label_dest, label_f)
)
使用PyTorch的Dataset类时,有几点性能优化技巧:
python复制from torch.utils.data import Dataset
import cv2
import numpy as np
class CityscapesDataset(Dataset):
def __init__(self, img_dir, label_dir, transform=None):
self.img_dir = img_dir
self.label_dir = label_dir
self.transform = transform
# 预加载所有文件路径
self.img_files = sorted([f for f in os.listdir(img_dir) if f.endswith('.png')])
self.label_files = sorted([f for f in os.listdir(label_dir) if f.endswith('.png')])
# 验证图像和标注匹配
assert len(self.img_files) == len(self.label_files)
for img, lbl in zip(self.img_files, self.label_files):
assert img.replace('leftImg8bit', 'gtFine_labelTrainIds') == lbl
def __len__(self):
return len(self.img_files)
def __getitem__(self, idx):
img_path = os.path.join(self.img_dir, self.img_files[idx])
label_path = os.path.join(self.label_dir, self.label_files[idx])
# 使用OpenCV加速加载
img = cv2.imread(img_path, cv2.IMREAD_COLOR)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
label = cv2.imread(label_path, cv2.IMREAD_GRAYSCALE)
if self.transform:
augmented = self.transform(image=img, mask=label)
img = augmented['image']
label = augmented['mask']
return img, label
对于街景图像,我推荐这些增强组合:
python复制import albumentations as A
train_transform = A.Compose([
A.RandomResizedCrop(512, 1024, scale=(0.5, 2.0)),
A.HorizontalFlip(p=0.5),
A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1, p=0.5),
A.RandomGamma(gamma_limit=(80, 120), p=0.5),
A.Blur(blur_limit=3, p=0.2),
A.RandomShadow(shadow_roi=(0, 0, 1, 1), num_shadows_lower=1,
num_shadows_upper=2, shadow_dimension=5, p=0.2),
A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
ToTensorV2()
])
val_transform = A.Compose([
A.Resize(512, 1024),
A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
ToTensorV2()
])
特别提醒:增强后的标注也需要相应变换。Albumentations会自动处理mask的变换,但如果使用自定义增强,务必确保图像和标注同步变换。
当使用多GPU时,DataLoader的配置很关键。这是我的推荐配置:
python复制from torch.utils.data import DataLoader
def get_loaders(train_dir, val_dir, train_label_dir, val_label_dir,
batch_size, num_workers, pin_memory=True):
train_ds = CityscapesDataset(
img_dir=train_dir,
label_dir=train_label_dir,
transform=train_transform
)
val_ds = CityscapesDataset(
img_dir=val_dir,
label_dir=val_label_dir,
transform=val_transform
)
train_loader = DataLoader(
train_ds,
batch_size=batch_size,
num_workers=num_workers,
pin_memory=pin_memory,
shuffle=True,
drop_last=True
)
val_loader = DataLoader(
val_ds,
batch_size=batch_size,
num_workers=num_workers,
pin_memory=pin_memory,
shuffle=False,
drop_last=False
)
return train_loader, val_loader
几个关键参数说明:
num_workers:通常设为GPU数量的4倍pin_memory:在CUDA设备上必须设为Truedrop_last:训练时设为True避免最后不完整的batchCityscapes中"道路"类像素占比很大,而"摩托车"类很少。解决方法有:
python复制class_weights = torch.tensor([...]) # 计算每个类的权重
criterion = nn.CrossEntropyLoss(weight=class_weights)
python复制from torch.utils.data import WeightedRandomSampler
class_counts = [...] # 每个类的像素总数
class_weights = 1. / torch.tensor(class_counts, dtype=torch.float)
sample_weights = [class_weights[c] for c in train_labels]
sampler = WeightedRandomSampler(
weights=sample_weights,
num_samples=len(sample_weights),
replacement=True
)
loader = DataLoader(..., sampler=sampler)
官方评估脚本比较复杂,这里给出简化版的mIoU实现:
python复制def compute_iou(pred, target, n_classes=19):
ious = []
pred = pred.view(-1)
target = target.view(-1)
for cls in range(n_classes):
pred_inds = pred == cls
target_inds = target == cls
intersection = (pred_inds[target_inds]).long().sum().item()
union = pred_inds.long().sum().item() + target_inds.long().sum().item() - intersection
if union == 0:
ious.append(float('nan'))
else:
ious.append(float(intersection) / float(union))
return np.nanmean(ious)
使用时注意:
训练时使用的归一化参数必须与部署时一致:
python复制# 训练时的归一化
transform = A.Normalize(
mean=(0.485, 0.456, 0.406),
std=(0.229, 0.224, 0.225)
)
# 部署推理时也必须使用相同的参数
def preprocess(image):
image = image / 255.0
image = (image - [0.485, 0.456, 0.406]) / [0.229, 0.224, 0.225]
return image.transpose(2, 0, 1).astype(np.float32)
建议把这些参数写在配置文件中,确保训练和推理使用相同的预处理流程。