资讯详情 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数组里两个人就是两个objectlabel是细粒度类别名比如区分了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否则两个类别的遮挡场景会让模型很困惑。类别映射表的代码实现其实就是一个dictCITYSCAPES_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_side20): 过滤小目标并裁剪越界坐标 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, encodingutf-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_side20也就是说bbox的宽和高至少都要有20个像素。这个数值不是拍脑袋定的2048宽图像上的20像素大约是图像宽度的1%对应一个十几米外的行人基本是检测器能勉强感知的极限尺寸。同时一定要做边界裁剪。城市道路场景里行人走到画面边缘时polygon轮廓经常会有一部分超出图像边界导致bbox的坐标小于0或者大于图像宽高。不裁剪的话后面的VOC解析器拿到负数坐标会直接报错。def filter_boxes(boxes, width, height, min_side20): 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, encodingutf-8, xml_declarationTrue)这里我用的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_okTrue) 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_okTrue) os.makedirs(ann_out_dir, exist_okTrue) 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_side20) 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(fUnexpected 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(fOut-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 fAnnotations/{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(fcheck_{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的trainval为例常见的大致结果是car和person数量非常多加起来可能占七八成truck、bus等比car少一个数量级motorcycle和rider的数量最少。这种不平衡不是转换脚本的错误而是城市道路场景的真实分布。应对这个问题的常见做法一是训练时做类别重采样让每个类别每轮出现的样本数更接近二是把窄类别的阈值调低比如motorcycle的检测置信度阈值不要设到0.5以上。这些属于训练策略的范畴但你在转换数据时就要清楚自己的类别分布否则后面训练时看到摩托车AP特别低都不知道原因。4.4 转成COCO或YOLO格式的桥接方案VOC格式转换完成后如果你要用的框架只支持COCO或YOLO还可以再走一步。MMDetection里可以用工具把VOC format转成COCO formatYOLO系列训练时也可以用脚本把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还是保留PNGVOC格式的目录习惯叫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两种类别其他全部过滤用一个极简的二分类检测器跑通流程。等验证网络结构没问题再逐步扩展到多类别排查成本会低很多。还有一个小技巧转换完数据后我一般会保留一张映射清单文本记录原始类别和目标类别的对应关系、过滤阈值、生成时间。隔几个星期再回来看脚本时不用翻代码也能想明白当初为什么这么做。数据整理这类工作看起来只是体力活但它决定了模型训练的上限值得投入时间做扎实。