
如果你要把 Cityscapes 数据喂给一个按 PASCAL VOC 格式组织的数据管道最早让你抓狂的往往不是写代码而是想明白一件事同一张街景照片在两种标注体系里记录的“信息密度”根本不一样。Cityscapes 给每个物体画的是贴合轮廓的多边形PASCAL VOC 给每个物体下的定义却是一个矩形包围框外加一行 XML 属性。要把 Cityscapes 转成 PASCAL VOC 格式的目标检测数据集说到底就是在这两套“世界观”之间搭桥。这篇文章记录的是我从零做完这件事的完整过程。文章会把 Cityscapes 数据集的目录结构、多边形标注解析、包围框生成策略、类别映射方案、可直接运行的转换脚本以及转换完怎么自查、训练阶段会遇到哪些坑一步步讲清楚。如果你正在做车辆识别、行人检测或者任何想用 Cityscapes 街景数据扩充目标检测训练集的项目这份转换方案可以直接拿去用。1. 为什么要折腾这个转换两种标注体系的差异1.1 从分割数据到检测数据格式不是小事很多人的第一反应是不就是一个标注格式么写个脚本“翻译”一下不就行了真上手之后才会发现这件事的难点不在翻译而在于理解为什么这两套格式长得不一样。PASCAL VOC 的核心单元是 object。一张图里有多少个目标就有多少个 object 节点每个 object 包含类别名、包围框坐标以及 truncated、difficult 两个标志位。它的评价指标是 mAP关心的是“框得准不准、类别判得对不对”所以 XML 里只需要一个框不需要像素级信息。Cityscapes 的核心单元是像素。它是一套为语义分割和实例分割设计的街景数据集覆盖了数十个城市的真实道路场景精细标注图像有数千张。每个目标用一串多边形点坐标保存精确到实例轮廓连一辆车和它旁边的车身缝隙都分得清清楚楚。这种差异决定了转换工作不是机械映射而是“信息降维”从精细轮廓降成矩形框中间需要考虑遮挡、截断、极小目标等一系列问题。1.2 框和轮廓的哲学差距好在 Cityscapes 只标可见部分这里有一个对转换者来说非常关键的好消息Cityscapes 的多边形只标注目标的可见部分PASCAL VOC 检测标注里包围框描述的也是目标在当前画面中可见的区域截断和遮挡通过 truncated 标志补充说明。两者的“可见范围”语义是吻合的。所以从多边形直接求最小外接矩形得到的框和 VOC 标注员手绘的框在思路上是一致的不会出现“Cityscapes 轮廓是完整车身、而 VOC 框只画了露出来的半边”之类的错位。但也要注意另一个差别VOC 对一个实例只画一个框而 Cityscapes 的实例分割标注里一个实例对应一个多边形。好在 polygons.json 里每个实例本来就是独立的一个 objects 元素天然分隔好了我们不需要再从 instanceIds.png 里做连通域分组解析成本低了不少。2. 动手前先摸清 Cityscapes 的标注结构2.1 目录里都有什么Cityscapes 的标准目录分两块原始图像放在 leftImg8bit 下标注放在 gtFine 下。两者都按 splittrain / val / test继续分城市目录再以文件名为单位一一对应。文件类型路径示例说明原始图像leftImg8bit/train/城市目录/图片ID_leftImg8bit.png分辨率固定为 2048x1024多边形标注gtFine/train/城市目录/图片ID_gtFine_polygons.json每个实例一个多边形最核心的文件语义标注gtFine/train/城市目录/图片ID_gtFine_labelIds.png像素级类别标签实例标注gtFine/train/城市目录/图片ID_gtFine_instanceIds.png像素级实例编号做目标检测转换核心只用 polygons.json。labelIds.png 和 instanceIds.png 更多是给分割模型用的偶尔用来做交叉验证。另外注意 gtFine 和 gtCoarse 的差别gtCoarse 是粗标注虽然数量多但多边形精度不够转出来的检测框噪声大建议只对 gtFine 做转换。2.2 解析 polygons.json 的关键字段一个 polygons.json 文件打开后长这样{ imgWidth: 2048, imgHeight: 1024, objects: [ { label: car, polygon: [361, 452, 364, 452, 364, 453, ...], instanceId: 240001, tags: {} }, { label: person, polygon: [1023, 637, 1026, 637, ...], instanceId: 40001, tags: {} } ] }这里最容易踩的坑是 polygon 字段的格式它不是“坐标对”数组而是一个拍平后的一维数组偶数下标是 x奇数下标是 y即[x0, y0, x1, y1, x2, y2, ...]。我第一次转换时按“每三个数一组”去读结果算出来的框歪七扭八排查了半天才意识到是解析方式错了。imgWidth 和 imgHeight 直接决定了包围框的裁剪边界别用代码里写死的 2048 和 1024 去替代不同版本的数据集可能略有差异以 JSON 里的值为准。3. 从多边形到检测框核心转换逻辑3.1 外接矩形计算与整数化从多边形求包围框本身很简单取所有点的 x 最小值作为 xminx 最大值作为 xmaxy 同理。但我强烈建议在求值时注意两个细节。第一用int(min(xs))向下取整作为 xmin用int(max(xs))向上取整作为 xmax而不是直接强转成 int。多边形边界上的小数如果直接截断框可能把目标边缘像素切掉一列肉眼看不出来但训练时相当于给 GT 引入了一个像素级的系统误差。第二求完的坐标要 clamp 到图像范围内因为个别标注点会落在图像边缘外不裁剪的话生成的框可能超出图像尺寸很多检测框架训练时会直接报错。def polygon_to_bbox(polygon, img_w, img_h): xs polygon[0::2] ys polygon[1::2] xmin max(0, int(min(xs))) ymin max(0, int(min(ys))) xmax min(img_w - 1, int(max(xs)) 1) ymax min(img_h - 1, int(max(ys)) 1) if xmax xmin or ymax ymin: return None return xmin, ymin, xmax, ymax这段代码看起来短但已经把坐标解析、取整、越界裁剪三层逻辑都包进去了。3.2 空框、退化框与极小框的处理Cityscapes 是人工精细标注的数据绝大多数多边形质量很高但总有几个边角案例会出问题。最简单的办法是把xmax xmin或ymax ymin的框直接丢掉因为这种框面积为零写进 XML 后会让训练时的采样逻辑出乱子。极小目标也要单独考虑。2048x1024 的大图里一个几十米外的行人在标注多边形里可能只占不到十个像素转出来的框只有几像素宽。这种框不是不能训练而是很多检测框架的数据增强随机裁剪、缩放、翻转会把它弄丢或者计算出负数 anchor。我的建议是设一个最小边长阈值比如 3 像素或 5 像素。小于阈值的目标要么丢弃要么统一打上 difficult 标志让评估时自动忽略。具体阈值看你模型的下采样倍数通常取模型下采样倍数对应的像素数例如输出 stride 是 16那阈值设在 16 左右都有道理。3.3 截断目标的 truncated 标记处理VOC 里最容易被忽略的两个字段就是 truncated 和 difficult。Cityscapes 的多边形不会直接告诉你这个目标是否被图像边界截断但可以通过包围框是否触及图像边缘来判断。如果某个目标原本的多边形范围超出了图像尺寸经过 clamp 后它的包围框顶到了图片边缘这种目标大概率就是被截断的。我习惯在脚本里对比裁剪前后的坐标但凡发生越界就给 XML 的 truncated 字段置 1。这个字段影响不大但能让后续做评估时更规范。4. 类别映射最容易出乱子的地方4.1 官方类别和 VOC 类别怎么对应Cityscapes 的细标注定义了几十个类别其中车辆、行人相关的主要有person行人、rider骑手、car轿车、truck卡车、bus公交、train火车、motorcycle摩托车、bicycle自行车。而标准的 PASCAL VOC 20 类里能对上的只有少数几个。Cityscapes 类别VOC 20 类对应官方建议personperson直接映射carcar直接映射busbus直接映射traintrain直接映射motorcyclemotorbike直接映射bicyclebicycle直接映射truck无对应类自定义或丢弃rider无对应类自定义或丢弃这里最容易犯的错误是做“强行合并”把 truck 并到 car 里把 rider 并到 person 里强行凑出标准 20 类。如果你只是做一个“车辆检测”的简单 demo这种合并还能将就但如果你是在做严谨的多类别检测合并等于人为引入标注噪声。4.2 合并类别的坑我替你们踩过了我第一次做转换时为了省事把 rider 合并进了 person。结果训练出来的模型对“骑车的人”检测特别差而且经常把停在路边的自行车也识别成人。事后想明白了rider 的框里包含了骑手和一部分车体合并成 person 后模型等于在学“带轮子的人”这个特征本身就是错的。truck 合并进 car 的问题类似卡车和轿车在尺寸、姿态上有系统性差异模型学到的是“大一点的车就是 car”并不是真正理解类别。我的建议是如果你的训练框架支持自定义类别数就保留 truck 和 rider把类别数从 20 改成 22这样语义最干净。如果框架硬编码了 20 类的类别头那就老老实实把 truck 和 rider 丢弃宁缺毋错。另外还有一种思路是把 truck、bus、car、train 全合并成一个大类 vehicle只做“有无车辆”的二分类检测。这对简单的车流量统计场景是合理的但会牺牲类别粒度适合什么需求就用什么方案别盲目跟风。5. 转换脚本与目录落地5.1 参考实现一份可以直接跑的转换脚本下面这份脚本只依赖 Python 标准库不需要安装任何第三方包。它完成的工作包括遍历 split 下的所有 polygons.json、解析实例、做类别映射、生成包围框、写 VOC 格式 XML、把原始图像复制进 VOC 的 JPEGImages 目录。import json import os import shutil import xml.etree.ElementTree as ET CITY_TO_VOC { person: person, car: car, bus: bus, train: train, motorcycle: motorbike, bicycle: bicycle, # truck 和 rider 如果想保留取消下面两行注释 # truck: truck, # rider: rider, } IMG_ROOT /path/to/cityscapes/leftImg8bit GT_ROOT /path/to/cityscapes/gtFine OUT_ROOT /path/to/VOCCityscapes MIN_SIDE 3 # 只转换 gtFinesplit 名称对应目录名 SPLITS { train: [train], val: [val], } def parse_polygons(json_path): with open(json_path, r, encodingutf-8) as f: return json.load(f) def polygon_to_bbox(polygon, img_w, img_h): xs polygon[0::2] ys polygon[1::2] xmin max(0, int(min(xs))) ymin max(0, int(min(ys))) xmax min(img_w - 1, int(max(xs)) 1) ymax min(img_h - 1, int(max(ys)) 1) if xmax xmin or ymax ymin: return None if (xmax - xmin) MIN_SIDE or (ymax - ymin) MIN_SIDE: return None return xmin, ymin, xmax, ymax def write_voc_xml(img_id, size, objects, out_path): annotation ET.Element(annotation) folder ET.SubElement(annotation, folder) folder.text Cityscapes filename ET.SubElement(annotation, filename) filename.text img_id .png source ET.SubElement(annotation, source) database ET.SubElement(source, database) database.text Cityscapes size_node ET.SubElement(annotation, size) width ET.SubElement(size_node, width); width.text str(size[0]) height ET.SubElement(size_node, height); height.text str(size[1]) depth ET.SubElement(size_node, depth); depth.text 3 for obj in objects: obj_node ET.SubElement(annotation, object) name ET.SubElement(obj_node, name); name.text obj[name] truncated ET.SubElement(obj_node, truncated); truncated.text str(obj[truncated]) difficult ET.SubElement(obj_node, difficult); difficult.text str(obj[difficult]) bndbox ET.SubElement(obj_node, bndbox) xmin ET.SubElement(bndbox, xmin); xmin.text str(obj[bbox][0]) ymin ET.SubElement(bndbox, ymin); ymin.text str(obj[bbox][1]) xmax ET.SubElement(bndbox, xmax); xmax.text str(obj[bbox][2]) ymax ET.SubElement(bndbox, ymax); ymax.text str(obj[bbox][3]) tree ET.ElementTree(annotation) tree.write(out_path, encodingutf-8, xml_declarationTrue) def convert_split(split_name, source_dirs): jpeg_dir os.path.join(OUT_ROOT, JPEGImages) ann_dir os.path.join(OUT_ROOT, Annotations) imageset_dir os.path.join(OUT_ROOT, ImageSets, Main) for d in (jpeg_dir, ann_dir, imageset_dir): os.makedirs(d, exist_okTrue) ids [] for src_dir in source_dirs: gt_root os.path.join(GT_ROOT, src_dir) for city in os.listdir(gt_root): city_gt os.path.join(gt_root, city) if not os.path.isdir(city_gt): continue for fn in os.listdir(city_gt): if not fn.endswith(_gtFine_polygons.json): continue img_id fn.replace(_gtFine_polygons.json, ) json_path os.path.join(city_gt, fn) data parse_polygons(json_path) objects [] for obj in data[objects]: label obj[label] if label not in CITY_TO_VOC: continue bbox polygon_to_bbox( obj[polygon], data[imgWidth], data[imgHeight]) if bbox is None: continue objects.append({ name: CITY_TO_VOC[label], bbox: bbox, truncated: 0, difficult: 0, }) img_src os.path.join( IMG_ROOT, src_dir, city, img_id _leftImg8bit.png) shutil.copy2(img_src, os.path.join(jpeg_dir, img_id .png)) # 同一文件系统下可以改成 os.link 硬链接省一半磁盘空间 write_voc_xml( img_id, (data[imgWidth], data[imgHeight]), objects, os.path.join(ann_dir, img_id .xml)) ids.append(img_id) with open(os.path.join(imageset_dir, split_name .txt), w) as f: for img_id in ids: f.write(img_id \n) if __name__ __main__: for split_name, src_dirs in SPLITS.items(): convert_split(split_name, src_dirs) print(f{split_name} done: {len(open(os.path.join(OUT_ROOT, ImageSets, Main, split_name .txt)).readlines())} ids)脚本里几个值得注意的点一是文件 ID 直接沿用了 Cityscapes 原始命名去掉了_leftImg8bit后缀这样 XML 和图片能一一对应二是图片只做了copy2如果磁盘空间紧张可以改成os.link硬链接省一半空间且不影响读取。5.2 目录整理与 ImageSets 生成的格式细节转换完成后的目录结构应该是这样VOCCityscapes/ ├── JPEGImages/ │ └── 图片ID.png ├── Annotations/ │ └── 图片ID.xml └── ImageSets/ └── Main/ ├── train.txt └── val.txtImageSets 里的 txt 文件有个很容易被忽略的格式要求每个 ID 一行不带扩展名不加路径行尾不要有多余空格最后一行也要有换行符。很多检测框架的读取逻辑是line.strip()然后拼路径如果你写成xxx.png有的框架不报错有的框架直接找不到文件。更大的坑在于 XML 的 filename 字段。很多人转换时为了省事直接把原始 PNG 改名为.jpg但 XML 里 filename 还写着.png框架读取时按 jpg 路径去找图就会失败。我的做法是保留 PNG 原格式XML 里也写 PNG除非你明确知道框架对图片格式有硬性要求。6. 转换完别急着训练检查与避坑清单6.1 可视化自查别信任任何“跑完没报错”的脚本脚本跑完不代表转换正确尤其是坐标这种肉眼难辨的问题。我强烈建议在训练之前做一次可视化抽查把 XML 里的框画到原图上随机挑几十张图人工看一眼。import glob import xml.etree.ElementTree as ET from PIL import Image, ImageDraw for xml_path in sorted(glob.glob(/path/to/VOCCityscapes/Annotations/*.xml))[:20]: tree ET.parse(xml_path) root tree.getroot() img Image.open(/path/to/VOCCityscapes/JPEGImages/ root.find(filename).text) draw ImageDraw.Draw(img) for obj in root.findall(object): name obj.find(name).text box obj.find(bndbox) xmin int(box.find(xmin).text) ymin int(box.find(ymin).text) xmax int(box.find(xmax).text) ymax int(box.find(ymax).text) draw.rectangle([xmin, ymin, xmax, ymax], outlinered, width2) draw.text((xmin, ymin - 10), name, fillred) img.save(/tmp/check_ root.find(filename).text)如果发现大量框偏大或偏小优先怀疑坐标取整方式和裁剪边界而不是批量重跑整个流程。另外建议打印一份标注统计每个类别的框数量、框面积均值和中位数、最小框尺寸。面积分布能直接反映出极小目标比例如果极小目标太多训练前就得考虑把输入分辨率调高或者对这类目标做 oversampling。6.2 训练阶段的几个现实问题Cityscapes 的图像分辨率是 2048x1024长宽比固定 2:1这和 VOC 原始数据集里五花八门的图片比例完全不同。很多检测框架的默认配置是按 VOC 图片尺寸设计的比如输入尺寸 500x500直接把 Cityscapes 大图缩到 500x500 会严重变形。我自己的做法是保持 2:1 的长宽比把输入尺寸设为 1024x512anchor 的尺寸也要相应重新统计不能直接沿用 VOC 预训练模型的默认 anchor。还有一个很多人会忽略的问题Cityscapes 的类别分布极度不平衡。car 的数量可能是 train 的几十倍如果做多类别检测建议在数据加载阶段做类别重采样或者关注一下 mAP 按类别的分解结果而不是只看整体指标。最后提醒一句Cityscapes 的 test 集没有公开标注不要指望能直接用来做验证。如果你需要对转换流程做评估用 val 集或者自己从 train 里切一部分出来都能得到可靠结论。我在实际转换中的体会是这类格式转换工程真正花时间的从来不是写脚本而是把“格式背后的语义差异”想清楚。Cityscapes 到 PASCAL VOC 看似只是多边形变矩形但只有把截断、遮挡、类别对齐、极小目标这些边角情况都处理掉转出来的数据集才能让模型真正受益。第一次跑完千万别急着开训练先看一眼可视化结果你会发现这一眼能帮你省下后面几十次调试的时间。