☰
Cityscapes转VOC格式实战:多边形标注到检测框的完整转换指南
2026/10/15 13:49:45 网站建设 项目流程

做目标检测的人,手头最容易卡住的一步往往不是模型结构,而是数据格式转换。尤其是Cityscapes这个公开数据集,官方给的实例标注是一段一段的轮廓点坐标,跑检测模型却通常需要轴对齐的矩形框。你总不能训练到一半发现数据loader读不了标注,或者转错了坐标系导致所有bbox全部错位。与其网上东拼西凑找脚本,不如自己把转换逻辑完整走一遍,搞清楚每个字段的含义、每个过滤条件的理由,后面换任何数据集都能顺手写出来。

这篇文章我会从为什么需要转换讲起,再逐步拆解gtFine的json结构、类别映射方案、bbox提取与过滤、VOC XML生成、数据集划分,最后附上我实际跑数据时踩过的几个坑。整个过程针对Cityscapes转PASCAL VOC格式,代码直接可用,你也完全可以在此基础上改成COCO或YOLO格式。

1. 转换前先理清需求:为什么目标检测要改成VOC格式

1.1 Cityscapes原生标注不是检测框,是轮廓点

很多人第一次打开Cityscapes的标注文件时都会愣一下:gtFine目录里躺着一个_gtFine_polygons.json文件,里面每一帧图像的每个目标都存了一长串多边形顶点坐标,而不是你在VOC或COCO里常见的那种xmin, ymin, xmax, ymax。这是因为Cityscapes最初是为语义分割和实例分割设计的,标注人员需要沿着车、行人、路牌的边缘去描轮廓,生成的是高精度的多边形。

这带来一个现实问题:实例分割级别的标注信息是比检测框更细的,但目标检测模型要的是框。你不能拿语义分割模型的数据loader去跑检测模型,也不能把多边形的上千个点直接塞进一个torch.Tensor。所以转换的第一步,不是写代码,而是想清楚你要从原始数据里提取什么:每个实例的轮廓,最终都要换算成一个有代表性的矩形包围盒。

1.2 选择VOC格式的四个现实原因

目标检测数据格式有好几种:PASCAL VOC的xml、COCO的json、YOLO的txt。为什么这里专门讲转成VOC,而不是直接转YOLO呢?我实际用下来的理由有四个:

第一,VOC格式是“中间格式”的典范。很多检测框架,比如MMDetection,本身就支持把VOC格式作为数据集的输入形式,然后自己再转成内部训练格式。你只要生成VOC xml,等于同时拿到了通往COCO和YOLO的入口,后面有现成的转换脚本可以继续转。

第二,VOC的xml是文本文件,出错了容易排查。你可以直接打开看一眼,bbox坐标是否越界、类别名是否拼错、文件名是否对应,几秒钟就能定位问题。这一点在数据清洗阶段非常救命。

第三,VOC格式对单类别的约束非常少。每个xml里可以包含任意多个object,每个object有自己的类别名和bbox,不需要像COCO json那样维护全局的categories表,写起来简单很多。

第四,从气人程度来讲,VOC最不容易出错。COCO json的annotation id必须全局唯一,YOLO的txt要求归一化到0到1之间并且中心点坐标要精确到小数点后六位,而VOC只要整数坐标和标签名,处理起来非常直接。

1.3 转换的本质:多边形求外接矩形 + 重新打包

理解了上面的介绍,整个转换任务本质上就三步:

从每个object的多边形顶点中取出所有x坐标和y坐标,分别求最小值最大值,得到轴对齐外接矩形。这是检测框的第一手来源。

对得到的bbox做合法性检查,包括过滤掉过小的目标、裁剪越界坐标、剔除空多边形和无效标签。这一步直接决定最终训练数据质量。

把图像文件和xml文件按VOC的目录结构组织好,并且生成ImageSets/Main/train.txt、val.txt这样的划分文件。

把这三步写成一个脚本,批量执行,就能在十几分钟内把几千张图片全部转完。思路不复杂,但细节决定成败,下面每一节我都会拆开讲。

2. 前置准备:看懂gtFine的JSON结构,再定类别映射

2.1 用到的数据文件与目录结构

开始转换之前,先确认你已经下载了Cityscapes数据集里的两个部分:leftImg8bit和gtFine。前者是图像,后者是精细标注。你不需要下载gtCoarse,因为粗标注用于分割训练,对检测任务意义不大。

Cityscapes原始目录大概长这样:

cityscapes/ ├── leftImg8bit/ │ ├── train/ │ │ ├── aachen/ │ │ ├── ... │ └── val/ │ └── ... └── gtFine/ ├── train/ │ ├── aachen/ │ │ ├── aachen_000000_000019_gtFine_polygons.json │ │ ├── aachen_000000_000019_gtFine_instanceIds.png │ │ ├── ... └── val/ └── ...

注意图像和标注文件的对应关系:一张aachen_000000_000019_leftImg8bit.png,对应的标注文件是aachen_000000_000019_gtFine_polygons.json。另外还有几个png文件,是掩码图,我们这次用不到,但不要删,后续做实例分割或者可视化还会用到。

每个城市目录下包含若干序列,序列名后缀的000019是帧编号。转换后的文件名建议保留这种天然唯一的名字,直接用原始名称去掉后缀即可,避免重名冲突。

2.2 gtFine_polygons.json 里到底存了什么

我截取一段真实结构来拆解,打开某个json文件之后你会发现,顶层就是一个dict,包含图像的高宽和objects数组:

{ "imgWidth": 2048, "imgHeight": 1024, "objects": [ { "label": "car", "polygon": [ [821, 602], [820, 606], [817, 611], ... ], "instanceId": 28001 }, { "label": "person", "polygon": [ [1230, 520], ... ], "instanceId": 29001 } ] }

这里需要记住三个重点:坐标是像素坐标,不是归一化坐标,取值范围大致在图像宽高范围内;每个object只代表一个独立实例,persons数组里两个人就是两个object;label是细粒度类别名,比如区分了car和truck,但这对检测来说可能需要合并。

用代码读取时,我建议直接以objects数组为准遍历,不要自己解析整个文件的其他部分。polygon顶点数量一般从几个到几十个不等,不存在固定的边界点数,所以写解析逻辑时要用动态的min/max计算,不能写死索引。

2.3 类别映射表怎么做才合理

Cityscapes原始类别有三十多个,但许多类在检测场景并不重要,比如地形、墙面、交通标志等。经过实际测试,我建议保留以下这几个对自动驾驶和行人检测最有意义的类别,并做少量合并:

Cityscapes原始类别转换后的检测类别保留原因
personperson行人检测的核心类
riderperson骑车人与行人同属易受伤害群体
carcar最常见车辆
trucktruck保留卡车类别
busbus公交车单独成类
motorcyclemotorcycle骑手数量少但重要
bicyclebicycle自行车与摩托车可分开
trailertruck挂车并入卡车
caravantruck房车并入卡车
其他类别忽略与检测目标无关

如果你就是要用Cityscapes训练一个行人检测器,可以只保留person,其他一切过滤掉,这样类别简单,模型也更容易收敛。反过来,如果你想做车辆细分类,那么car、truck、bus这三个类建议按原样保留,别把bus合并进truck,否则两个类别的遮挡场景会让模型很困惑。

类别映射表的代码实现其实就是一个dict:

CITYSCAPES_LABEL_MAP = { "person": "person", "rider": "person", "car": "car", "truck": "truck", "bus": "bus", "motorcycle": "motorcycle", "bicycle": "bicycle", "trailer": "truck", "caravan": "truck", }

这看起来很简单,但背后有个重要原则:宁可在映射表里少写几个类,也不要图省事把整个objects数组的label全部映射过去。因为Cityscapes里有ego vehicle、rectification border这类背景杂项,如果你在映射表里漏掉了,系统会把它们当成前景目标一起转出来,等于给你导入了一堆没意义的框。

3. 核心转换脚本:多边形转边框、过滤、生成XML、划分数据集

3.1 整体流程和目录结构设计

转换脚本我建议按“一套主线 + 四个函数”的结构来写。主线负责遍历原图目录和标注目录;四个函数分别负责解析json、过滤bbox、生成xml、写划分文件。下面是转换后的输出目录结构规划:

VOC2007_style/ ├── JPEGImages/ │ ├── aachen_000000_000019_leftImg8bit.png │ └── ... ├── Annotations/ │ ├── aachen_000000_000019_leftImg8bit.xml │ └── ... └── ImageSets/ └── Main/ ├── train.txt └── val.txt

这样组织的好处是,MMDetection的VOC格式读取、Darknet的VOC转YOLO脚本、以及Detectron2的自定义注册方式,都能直接套用。

先看一眼总体代码框架:

import json import os import shutil import xml.etree.ElementTree as ET from PIL import Image # 类别映射 CITYSCAPES_LABEL_MAP = {...} def parse_polygon_json(json_path): """从gtFine json中解析出bbox列表""" def filter_boxes(boxes, width, height, min_side=20): """过滤小目标并裁剪越界坐标""" def write_voc_xml(save_path, filename, width, height, boxes): """生成VOC xml""" def convert_cityscapes_to_voc(cs_root, output_root, splits=("train", "val")): """主流程"""

3.2 从polygon计算bbox:这一步最不能想当然

多边形求外接矩形听上去简单,但我见过有人把坐标的x和y写反,也有人用了min()和max()之后忘记转整数,导致生成的bbox带小数点,VOC解析器读不了。正确做法:

def parse_polygon_json(json_path): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) width, height = data["imgWidth"], data["imgHeight"] boxes = [] for obj in data["objects"]: label = obj["label"] target_label = CITYSCAPES_LABEL_MAP.get(label) if target_label is None: continue polygon = obj["polygon"] if len(polygon) < 3: continue xs = [p[0] for p in polygon] ys = [p[1] for p in polygon] xmin = int(min(xs)) ymin = int(min(ys)) xmax = int(max(xs)) ymax = int(max(ys)) boxes.append({ "name": target_label, "xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax, "instance_id": obj.get("instanceId", -1) }) return boxes, width, height

有人会问,多边形的外接矩形一定会比实际轮廓大,尤其是一辆车斜着停的时候,框里会包含大量背景,这正常吗?非常正常。检测框本来就是轴对齐的,模型学习的目标也是输出水平矩形,所以用外接矩形的结果符合训练预期。如果你强行去用旋转框,反而和主流检测器的输出头不匹配。

这里还有一个细节:不要对xmin单独向下取整,却对xmax也向下取整。xmax方向应该向上取整。用int(max(xs))在绝大多数情况下没问题,因为python的int是截断取整,但严谨一点可以用math.ceil(max(xs)),保证矩形边界不会收缩到多边形内。

3.3 小目标过滤与边界裁剪:想过为什么阈值是20吗

Cityscapes图像是2048x1024的分辨率,远处的人可能只有十几个像素宽。这种小目标框在VOC里不是不能存,问题在于小到极点的框在训练时几乎没有有效信息,而且数量多了会干扰模型的学习重点。所以一般建议过滤掉短边小于一定像素的框。

我常用的方案是min_side=20,也就是说bbox的宽和高至少都要有20个像素。这个数值不是拍脑袋定的,2048宽图像上的20像素大约是图像宽度的1%,对应一个十几米外的行人,基本是检测器能勉强感知的极限尺寸。

同时一定要做边界裁剪。城市道路场景里,行人走到画面边缘时,polygon轮廓经常会有一部分超出图像边界,导致bbox的坐标小于0或者大于图像宽高。不裁剪的话,后面的VOC解析器拿到负数坐标会直接报错。

def filter_boxes(boxes, width, height, min_side=20): filtered = [] for box in boxes: w = box["xmax"] - box["xmin"] h = box["ymax"] - box["ymin"] if w < min_side or h < min_side: continue # 裁剪越界坐标 box["xmin"] = max(0, box["xmin"]) box["ymin"] = max(0, box["ymin"]) box["xmax"] = min(width, box["xmax"]) box["ymax"] = min(height, box["ymax"]) # 裁剪后再次检查尺寸,避免出现0宽或0高 if box["xmax"] <= box["xmin"] or box["ymax"] <= box["ymin"]: continue filtered.append(box) return filtered

注意,裁剪之后需要再做一次宽高检查。因为极端情况下,一个目标可能整体都在画面外,裁剪后xmax和xmin相等,这种框必须丢弃。

3.4 生成VOC XML:结构完整,别漏字段

PASCAL VOC格式的xml结构固定,很多检测框架解析时会检查某些字段是否存在。我建议直接按标准结构写全,不要图省事少写pose、truncated、difficult这些字段,否则某些数据loader的xml解析类可能会因为取不到字段而报错。

def write_voc_xml(save_path, filename, width, height, boxes): annotation = ET.Element("annotation") ET.SubElement(annotation, "folder").text = "JPEGImages" ET.SubElement(annotation, "filename").text = filename source = ET.SubElement(annotation, "source") ET.SubElement(source, "database").text = "CityscapesConverted" ET.SubElement(source, "annotation").text = "Cityscapes gtFine polygon" ET.SubElement(source, "image").text = "cityscapes" size = ET.SubElement(annotation, "size") ET.SubElement(size, "width").text = str(width) ET.SubElement(size, "height").text = str(height) ET.SubElement(size, "depth").text = "3" ET.SubElement(annotation, "segmented").text = "0" for box in boxes: obj = ET.SubElement(annotation, "object") ET.SubElement(obj, "name").text = box["name"] ET.SubElement(obj, "pose").text = "Unspecified" ET.SubElement(obj, "truncated").text = "0" ET.SubElement(obj, "difficult").text = "0" bndbox = ET.SubElement(obj, "bndbox") ET.SubElement(bndbox, "xmin").text = str(box["xmin"]) ET.SubElement(bndbox, "ymin").text = str(box["ymin"]) ET.SubElement(bndbox, "xmax").text = str(box["xmax"]) ET.SubElement(bndbox, "ymax").text = str(box["ymax"]) tree = ET.ElementTree(annotation) tree.write(save_path, encoding="utf-8", xml_declaration=True)

这里我用的filename是带扩展名的完整文件名,比如aachen_000000_000019_leftImg8bit.png。VOC的xml里filename字段通常只写文件名,不写路径,要注意和你最终放在JPEGImages目录里的文件名保持一致。

另外,xml文件的命名要和图像文件去掉扩展名后完全一致,不能一个叫xxx.png.xml,一个叫xxx.xml。我用的是统一替换后缀的方式:image_name.replace("_leftImg8bit.png", "") + ".xml"。去掉_leftImg8bit是为了让xml文件名简洁,但这个不是必须的,你完全可以保留完整前缀,只要图上和xml一一对应就行。

3.5 数据集划分:官方train和val直接复用

Cityscapes官方已经把train和val分好了,我们不需要额外划分,直接把原始目录下的train和val两个子目录扫描一遍就是最终划分。这一步写一个简单函数:

def generate_image_sets(output_root, cityscapes_root, splits=("train", "val")): for split in splits: list_path = os.path.join(output_root, "ImageSets", "Main", f"{split}.txt") os.makedirs(os.path.dirname(list_path), exist_ok=True) file_names = [] split_dir = os.path.join(cityscapes_root, "leftImg8bit", split) for city in sorted(os.listdir(split_dir)): city_dir = os.path.join(split_dir, city) for img_name in sorted(os.listdir(city_dir)): if img_name.endswith("_leftImg8bit.png"): file_names.append(img_name.replace("_leftImg8bit.png", "")) with open(list_path, "w") as f: f.write("\n".join(file_names))

注意,这里拿到的是不带扩展名的纯文件名,VOC的ImageSets里就是这么约定的,不要画蛇添足写后缀。训练时框架会根据prefix自动去JPEGImages目录补齐文件名。

主流程代码里,还有一个容易忽略的操作:把图片复制或移动到JPEGImages目录。这一步不要用软链接,因为很多训练框架读数据时按文件名找图片,如果链接跨目录挂掉会排查到怀疑人生。直接复制更稳妥,虽然会多占一点磁盘空间。

def convert_cityscapes_to_voc(cs_root, output_root, splits=("train", "val")): for split in splits: img_split_dir = os.path.join(cs_root, "leftImg8bit", split) gt_split_dir = os.path.join(cs_root, "gtFine", split) img_out_dir = os.path.join(output_root, "JPEGImages") ann_out_dir = os.path.join(output_root, "Annotations") os.makedirs(img_out_dir, exist_ok=True) os.makedirs(ann_out_dir, exist_ok=True) for city in sorted(os.listdir(img_split_dir)): img_city_dir = os.path.join(img_split_dir, city) gt_city_dir = os.path.join(gt_split_dir, city) for img_name in sorted(os.listdir(img_city_dir)): if not img_name.endswith("_leftImg8bit.png"): continue stem = img_name.replace("_leftImg8bit.png", "") gt_json_path = os.path.join(gt_city_dir, stem + "_gtFine_polygons.json") shutil.copy( os.path.join(img_city_dir, img_name), os.path.join(img_out_dir, img_name) ) boxes, width, height = parse_polygon_json(gt_json_path) boxes = filter_boxes(boxes, width, height, min_side=20) write_voc_xml( os.path.join(ann_out_dir, stem + ".xml"), img_name, width, height, boxes ) print(f"[{split}] {img_name}: {len(boxes)} objects") generate_image_sets(output_root, cs_root, splits)

我实际测过,全量转换train和val,大约三千多张图,在普通笔记本上运行时间是5到10分钟,瓶颈基本在文件读写而不是解析逻辑。所以不用追求过度优化,直接线性遍历即可。

4. 跑完别急着训练:校验数据和统计分布

4.1 用程序校验XML合法性

转换脚本跑完,第一件事不是打开训练脚本,而是校验产出的XML文件是否合法。写一个小脚本,遍历所有xml,解析出每个object的bbox,检查坐标是否在图像尺寸范围内、类别名是否在预期集合内、有没有重复的box等。

import glob import xml.etree.ElementTree as ET EXPECTED_CLASSES = {"person", "car", "truck", "bus", "motorcycle", "bicycle"} for xml_path in glob.glob("Annotations/*.xml"): tree = ET.parse(xml_path) root = tree.getroot() for obj in root.findall("object"): name = obj.findtext("name") if name not in EXPECTED_CLASSES: print(f"Unexpected class {name} in {xml_path}") bndbox = obj.find("bndbox") xmin = int(bndbox.findtext("xmin")) ymin = int(bndbox.findtext("ymin")) xmax = int(bndbox.findtext("xmax")) ymax = int(bndbox.findtext("ymax")) if xmin < 0 or ymin < 0 or xmax > width or ymax > height: print(f"Out-of-bound box in {xml_path}: {xmin}, {ymin}, {xmax}, {ymax}")

这个脚本看起来很笨,但真的能抓出不少问题。我遇到过一种情况是json解析出来坐标是float,直接写入xml后,某些框架读取时对int强制转换失败。所以你可以在写出xml时就用int强制转换,生成完之后再用上面的脚本抽查一遍。

4.2 可视化抽查bbox:顺手救回不少反常识的框

程序化校验检查的是“格式合法”,不代表“内容合理”。我强烈建议随机挑几张图,把xml里的bbox画到原图上,肉眼扫一遍。写个简单的可视化脚本很快:

import cv2 import random import xml.etree.ElementTree as ET img_files = random.sample(glob.glob("JPEGImages/*.png"), 10) for img_file in img_files: stem = os.path.splitext(os.path.basename(img_file))[0] xml_path = f"Annotations/{stem}.xml" img = cv2.imread(img_file) tree = ET.parse(xml_path) for obj in tree.findall("object"): name = obj.findtext("name") bndbox = obj.find("bndbox") xmin = int(bndbox.findtext("xmin")) ymin = int(bndbox.findtext("ymin")) xmax = int(bndbox.findtext("xmax")) ymax = int(bndbox.findtext("ymax")) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, name, (xmin, ymin - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite(f"check_{stem}.jpg", img)

重点检查这么几类情况:离得很近的行人是不是被某个大框覆盖了;边缘处的目标有没有被裁剪掉一半;公交车的框是不是明显比实际车身大太多。如果出现上述情况,不要慌,可以回来调过滤策略和类别映射。

4.3 看类别分布:不平衡在哪儿,训练心里要有数

转换完成之后,统计一下每个类别的bbox数量,能让你对接下来的训练心里有底。用一个Counter遍历所有xml即可:

from collections import Counter counter = Counter() for xml_path in glob.glob("Annotations/*.xml"): tree = ET.parse(xml_path) for obj in tree.findall("object"): counter[obj.findtext("name")] += 1 print(counter)

以Cityscapes的train+val为例,常见的大致结果是:car和person数量非常多,加起来可能占七八成;truck、bus等比car少一个数量级;motorcycle和rider的数量最少。这种不平衡不是转换脚本的错误,而是城市道路场景的真实分布。

应对这个问题的常见做法,一是训练时做类别重采样,让每个类别每轮出现的样本数更接近;二是把窄类别的阈值调低,比如motorcycle的检测置信度阈值不要设到0.5以上。这些属于训练策略的范畴,但你在转换数据时就要清楚自己的类别分布,否则后面训练时看到摩托车AP特别低都不知道原因。

4.4 转成COCO或YOLO格式的桥接方案

VOC格式转换完成后,如果你要用的框架只支持COCO或YOLO,还可以再走一步。MMDetection里可以用工具把VOC format转成COCO format;YOLO系列训练时也可以用脚本把xml里的绝对坐标转成归一化的中心点坐标和宽高。

这里分享一个YOLO txt转换小片段,因为实操中最常被问到:

def voc_bbox_to_yolo(xmin, ymin, xmax, ymax, img_width, img_height): x_center = (xmin + xmax) / 2.0 / img_width y_center = (ymin + ymax) / 2.0 / img_height w = (xmax - xmin) / img_width h = (ymax - ymin) / img_height return x_center, y_center, w, h

注意,YOLO格式的坐标是归一化的,所以你在做可视化调试时不能直接拿txt里的坐标去画图,要先乘回图像宽高。这一点经常有人搞错。

5. 转换过程中最容易踩的坑清单(逐个说透)

5.1 坐标到底是像素坐标还是归一化坐标

这个问题在各类开源转换脚本的issue区经常出现。Cityscapes的gtFine_polygons.json里,polygon坐标就是像素坐标,单位是图像像素,不需要额外除以2048或1024。如果你自己写解析脚本时顺手给所有坐标除以了图像宽,后面画框或者训练时会发现所有bbox都挤在图像左上角那一小块区域,非常难排查。

判断标准很简单:原始json的x坐标范围应该在0到2048之间,y坐标范围在0到1024之间。如果打开json发现坐标都是零点零几的小数,那就是从某个预处理版本拿到的归一化坐标,需要重新审视数据来源。

5.2 空标注文件必须单独处理

有些图像中没有任何可检测的目标,比如纯路面或者只有墙面、天空的镜头。此时objects数组是空的,脚本可以正常生成一个没有object节点的xml。这个文件在VOC格式下合法,但如果某些训练框架对空标注处理不友好,会直接抛错或者跳过整个batch。

稳妥做法是生成xml时如果boxes为空,就在Main列表里把这张图剔除。这个方法比保留空xml更省心,因为ImageSets里不存在的图片不会进入训练流程,完全不影响正常样本学习。下面是主流程里的一处修改建议:

boxes = filter_boxes(boxes, width, height) if len(boxes) == 0: continue # 不复制图片、不生成xml、不写入ImageSets

我采用的是这个策略,转换完的train.txt里图片数会比原始Cityscapes的2975张略微少一点,属于正常现象。

5.3 一个语义区域里有多个实例千万别合并

这是初学者最容易犯的错误:看到json里两个person的polygon坐标很接近,尤其是相互遮挡时,图省事把它们合并成一个大的外接矩形。表面上看,两个行人的检测框变成了一个框,检测难度降低了,但实际上类别语义完全乱了,模型会学着把两个人一起框出来当作person,这会让评测指标(尤其是mAP的IoU计算)非常难看。

解决办法是,在遍历objects数组时,每个object的polygon单独计算bbox,绝对不要做任何合并。如果你发现某些相邻目标因为遮挡导致两个框高度重叠,这是正常情况,训练阶段模型会通过NMS处理,不需要你在数据阶段干预。

5.4 图片转成JPG还是保留PNG

VOC格式的目录习惯叫JPEGImages,但不代表里面必须放jpg文件。Cityscapes原图是png,通常为8位RGB,文件大小也比较大。我个人的做法是,如果磁盘空间充足,保留png;如果后续训练框架在读取时有格式限制,才批量转成jpg。

转jpg时注意两点:一是VOC的xml里filename字段要同步改成.jpg后缀,二是不要用opencv默认的低质量编码。建议用:

cv2.imwrite("xxx.jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 95])

这样转出来的jpg体积大幅下降,但视觉质量和检测精度影响很小。

不过我必须说,如果你主要做自动驾驶目标检测,保留png更利于后续做多任务学习时结合分割数据,因为分割掩码也依赖原始像素信息。转了jpg后虽然还能用,但心里总觉得亏了点什么。

5.5 全量转换前先跑小规模验证

这个习惯帮我省掉了很多麻烦。拿Cityscapes里某一个城市的5张图做小批量转换,走完生成xml、校验、可视化的整个流程,确认无误后再全量跑。因为全量转换本身只需要几分钟,所以很多人跳过这一步,结果等全量跑完才发现类别映射漏了一个关键类,又得从头再来。

小批量验证还可以顺便测试时间:从原始目录读取5张图的json,记录解析耗时,乘上总文件数,就能估算完整转换需要多久。如果单张图解析超过0.2秒,考虑换一个更强的CPU或者优化json解析方式。

5.6 类别名别混大小写

写类别映射表时,“person”的p千万不要写成大写,因为both PASCAL VOC的惯例全小写,很多框架校验类别名时用精确匹配。我有一个朋友把“person”映射成了“Person”,训练到一半框架直接报未知类别的错,查了半天。

如果你要用自己的类别名,建议全部使用小写英文,不要带空格,不要带中文。VOC格式虽然理论上支持任意字符串,但下各种框架时,小写无空格的类别名兼容性最好。

6. 我实际转换过程中的一点体会

整个流程跑下来之后,我的感觉是:转换这一个环节的技术难度确实不高,但决定后续训练顺利程度的,往往是你做过滤和校验时有多耐心。比如min_side的阈值,设大了会丢掉大量远处小行人,设小了会引入大量低质量框;类别映射多合并一个类,整个模型的输出结构都跟着变。这些东西没有任何脚本能帮你想清楚,必须自己根据任务目标做决定。

如果只是快速验证一个检测网络能不能在这个数据上学到东西,我建议先保留person、car两种类别,其他全部过滤,用一个极简的二分类检测器跑通流程。等验证网络结构没问题,再逐步扩展到多类别,排查成本会低很多。

还有一个小技巧,转换完数据后我一般会保留一张映射清单文本,记录原始类别和目标类别的对应关系、过滤阈值、生成时间。隔几个星期再回来看脚本时,不用翻代码也能想明白当初为什么这么做。数据整理这类工作,看起来只是体力活,但它决定了模型训练的上限,值得投入时间做扎实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询