做车辆检测实验的时候我一开始图省事直接从UA-DETRAC数据集官网把数据包拉了下来解压一看里面既有Annotations又有ImageSets心想这不就是VOC结构吗拿YOLO训练脚本一跑结果报错报得怀疑人生。原因很简单UA-DETRAC虽然是XML标注但它的标签组织方式和PASCAL VOC并不完全一样更不是YOLO直接认的那种txt标签。这篇就把我把它完整转成YOLO格式的整个过程写出来包括两者格式差异、转换脚本、可视化验证和训练调参给准备用UA-DETRAC做车辆检测的朋友省点时间。你只要用过YOLO跑过数据集但对标注格式转换还不太熟这篇就很适合你。1. UA-DETRAC数据集的真实面目全景与格式解剖1.1 目录结构和标注文件长什么样UA-DETRAC是一个面向车辆检测与跟踪的大规模真实场景数据集里面包含了超过14万帧图像标注了8250多辆不同型号的车辆。图像统一分辨率是960x540拍摄场景主要是一线城市市区道路、快速路和高架桥光照变化、树木遮挡、车辆截断的情况非常多。对于做目标检测的人来说这个数据集的难点不在于数量而在于真实世界里的遮挡和截断。解压之后目录大致是这样DETRAC-train-data/ MVI_20011/ img155.jpg img156.jpg ... MVI_20012/ ... DETRAC-train-annotations/ MVI_20011.xml MVI_20012.xml ... DETRAC-test-data/ ...注意一个问题UA-DETRAC的标注XML不是像VOC那样一个XML对应一张图片而是一个XML对应一个视频序列里面按帧号包含所有目标。这个差异会在写转换脚本时带来很大麻烦很多人一开始都习惯性用它当作单张图片的VOC标注去解析结果Parse到一半就懵了。下面是一个标注片段的结构示意annotation frame number155 target id1 bbox left320 top180 width75 height92/ attribute truncatedfalse occlusion0/ /target target id2 bbox left510 top210 width60 height80/ attribute truncatedtrue occlusion2/ /target /frame ... /annotation这里最外层的frame number155对应MVI_20011/img155.jpg这张图。每个target是一辆车的检测框bbox里的left、top、width、height都是绝对像素坐标而attribute中的truncated表示目标是否被图像边缘截断occlusion表示遮挡程度。很多转换教程会忽略这几个属性但后面做困难样本筛选和模型调优时非常有用建议保留到中间结果里。1.2 标注里不只有框truncated、occlusion这些字段怎么用既然数据集作者花力气标注了truncated和occlusion我们转换时就不能把它们当垃圾丢掉。YOLO标签里没有这些信息的位置但我们可以根据这些属性做一次“目标质量分级”。occlusion是一个整数通常0表示无遮挡1、2表示遮挡程度递增。truncated是布尔值表示车辆是否超出画面边缘。第一次转换时我图省事把所有目标全写进txt结果训练出来的模型对严重截断的车辆召回率一直上不去。后来单独统计了一下整个训练集中truncatedtrue的目标占了不小比例这些目标本身框就不完整如果直接训练模型容易学到“半个车也是车”的模糊特征。常规做法是在主训练集里保留全部目标保证样本多样性另外再复制一份“清洗后”的标注把truncatedtrue或者occlusion2的目标剔除用来做困难样本对比实验。这样做的好处是你能评估到底哪些属性影响了最终指标。这个环节不用在转化脚本里完成只用在脚本里输出一个attributes.json把每个目标的截断和遮挡信息都存下来后面想怎么过滤都行。UA-DETRAC的类别一共四类car、van、bus、others。数量分布很不均匀car占了绝大多数others最少这种情况在后面训练时需要针对性处理。转换时类别顺序建议就按这个固定下来不要随意调整不然后面调参和可视化会把人搞疯。2. YOLO格式的门道不是简单地改个后缀2.1 YOLO标签的五个关键数YOLO训练时读取的标签是纯文本一行对应一个目标每行固定五个数class_id x_center y_center width height第一个数是类别ID从0开始。后面四个数是归一化后的边界框参数值域在0到1之间表示相对于图像宽高的比例。x_center和y_center是目标框中心的相对坐标width和height是目标框的相对宽高。这里最关键的一点YOLO用的是“中心点宽高”的表示方式不是VOC和UA-DETRAC里那种“左上角宽高”的表示方式。很多从VOC转过来的朋友会在这一步犯错直接把left和top填进x_center和y_center结果画出来的框全跑到左上角去了。为什么YOLO要用中心点因为YOLO系列网络在特征图上做检测时每个位置预测的是“相对当前网格中心点的偏移量”和“相对锚框的宽高缩放”中心点格式和它的输出表示天然匹配。训练时如果标签格式和网络输出表示不一致损失函数很难收敛。归一化公式也很容易误解。正确的做法是分别除以图片的宽和高x_center (left width / 2) / image_width y_center (top height / 2) / image_height box_width width / image_width box_height height / image_heightUA-DETRAC所有图像都是960x540所以这里分母就是960和540。有人会误以为归一化是除以图片的长边还有人除以最大目标框的宽高这都是错的。YOLO训练时会把图片resize到网络输入尺寸标签值必须始终是相对于原始图像宽高的比例否则resize后标签和图像就对不上。2.2 图片、标签和data.yaml三位一体除了标签内容YOLO对目录结构也有要求。最省心的组织方式是这样的yolo_dataset/ images/ train/ MVI_20011_img0155.jpg ... val/ ... labels/ train/ MVI_20011_img0155.txt ... val/ ... data.yaml图片和标签名字必须一致只是后缀不同。YOLO是按照图片路径去寻找同名标签文件的比如图片叫MVI_20011_img0155.jpg标签就应该是MVI_20011_img0155.txt二者放在images和labels各自对应的train或val目录下。data.yaml的内容很简单train: ./images/train val: ./images/val nc: 4 names: [car, van, bus, others]nc是类别数量names列表里的顺序就是标签文件中类别ID对应的顺序。这里必须保证和转换脚本里用的类别顺序一致。我第一次转换的时候就因为names列表写成了[bus, car, van, others]而txt里全是0、1、2结果训练完发现模型把汽车当成了公交车错误非常隐蔽。3. 手写转换脚本的完整过程3.1 从XML里精准提取目标框既然一个XML对应一个视频序列转换时最外层循环就要遍历所有XML文件然后对XML里的每个frame根据帧号拼出对应的图片路径。核心解析逻辑用Python可以这么写import os import glob import xml.etree.ElementTree as ET def parse_xml(xml_path): tree ET.parse(xml_path) root tree.getroot() frame_data {} for frame_node in root.findall(frame): number frame_node.get(number) targets [] for target in frame_node.findall(target): bbox target.find(bbox) attr target.find(attribute) left float(bbox.get(left)) top float(bbox.get(top)) width float(bbox.get(width)) height float(bbox.get(height)) truncated attr.get(truncated, false) true occlusion int(attr.get(occlusion, 0)) targets.append({ left: left, top: top, width: width, height: height, truncated: truncated, occlusion: occlusion }) frame_data[number] targets return frame_data这里有几个容易漏的点。第一frame节点下的target顺序不保证是连续的最好自己循环一遍而不是用索引。第二属性值在XML里都是字符串bbox的width和height必须转成float不然后面算中心坐标时会报错。第三有的XML里attribute可能是空节点取值时要给默认值否则直接用attr.get(truncated)会拿到None。解析完之后我们要拼接图片路径。UA-DETRAC的图片命名是有规律的比如img155.jpg但有些序列的帧号长度可能不一致。最稳妥的方式是根据XML文件名找到对应视频目录再按帧号去目录里匹配文件而不是盲目拼路径。def find_image(video_dir, frame_number): candidates [ os.path.join(video_dir, fimg{frame_number}.jpg), os.path.join(video_dir, fimg{frame_number:06d}.jpg), ] for path in candidates: if os.path.exists(path): return path return None实测下来UA-DETRAC的帧号都是纯数字直接img{frame_number}.jpg就能匹配到。但保留上面这种兼容写法也没坏处至少打日志时会更容易发现问题。3.2 坐标归一化的正确算法拿到每个目标的left/top/width/height之后下一步是转成YOLO格式的四个数。直接看代码def convert_box(image_width, image_height, left, top, width, height): x_center (left width / 2.0) / image_width y_center (top height / 2.0) / image_height box_w width / image_width box_h height / image_height # 防止浮点误差导致越界 x_center min(max(x_center, 0.0), 1.0) y_center min(max(y_center, 0.0), 1.0) box_w min(max(box_w, 0.0), 1.0) box_h min(max(box_h, 0.0), 1.0) return x_center, y_center, box_w, box_h为什么要做越界裁剪因为UA-DETRAC里很多truncatedtrue的目标框的左上角或右下角会超出图像范围坐标算出来会小于0或者大于1。YOLO训练时这类标签虽然不会崩但会影响损失计算严重时会让模型对边界位置的预测来回震荡。裁剪之后至少能保证标签合法。另外还有一个细节UA-DETRAC的bbox给的是width和height不是右下角坐标。有的转换代码会把width误当成x_max那算出来的框就全错了。最简单的自检方法就是转完可视化几张图看到框和车辆位置完全贴合再往下走。3.3 训练集和验证集的划分策略这个环节是UA-DETRAC转换里最容易犯错的地方。它和COCO那种单张图像数据集不一样UA-DETRAC是视频序列同一个目标在连续帧里反复出现。如果按图像随机划分训练集和验证集那同一个车辆既出现在训练集又出现在验证集模型相当于开卷考试mAP会虚高好几个百分点。正确的做法是按“视频序列”划分而不是按“单张图”划分。比如把MVI_20011整个目录下的所有帧放进训练集让MVI_20012整个目录下的所有帧放进验证集。这样同一辆车出现在验证集时模型在训练阶段没有见过它。实际的视频目录有几十个我采用的方式是按8:2的比例随机挑选视频序列。当然数据集作者官方提供的测试集是没有标注的所以我们必须从训练集中自己划出验证集。这一步可以在转换脚本里统一完成也可以用独立脚本实现。建议在转换脚本里加一个--val_seqs参数手动指认哪些序列作为验证集这样更可控。3.4 完整脚本及用法下面是能直接跑的完整转换脚本结合了前面的解析、转换和序列划分逻辑。为了让代码可读性更高我把输出日志也加了进去。import os import glob import random import argparse import xml.etree.ElementTree as ET CATEGORY_MAP {car: 0, van: 1, bus: 2, others: 3} def parse_xml(xml_path): tree ET.parse(xml_path) root tree.getroot() frames {} for frame_node in root.findall(frame): frame_number frame_node.get(number) targets [] for target in frame_node.findall(target): bbox target.find(bbox) attr target.find(attribute) targets.append({ left: float(bbox.get(left)), top: float(bbox.get(top)), width: float(bbox.get(width)), height: float(bbox.get(height)), truncated: attr.get(truncated, false).lower() true, occlusion: int(attr.get(occlusion, 0)), }) frames[frame_number] targets return frames def convert_box(img_w, img_h, left, top, width, height): x_c (left width / 2.0) / img_w y_c (top height / 2.0) / img_h w width / img_w h height / img_h return ( min(max(x_c, 0.0), 1.0), min(max(y_c, 0.0), 1.0), min(max(w, 0.0), 1.0), min(max(h, 0.0), 1.0), ) def process_sequence(xml_path, video_dir, split, args): video_name os.path.splitext(os.path.basename(xml_path))[0] frames parse_xml(xml_path) img_dir os.path.join(args.output, images, split, video_name) label_dir os.path.join(args.output, labels, split, video_name) os.makedirs(img_dir, exist_okTrue) os.makedirs(label_dir, exist_okTrue) for frame_number, targets in frames.items(): img_src None for ext in [jpg, JPG, jpeg, png]: candidate os.path.join(video_dir, f{frame_number}.{ext}) if os.path.exists(candidate): img_src candidate break if img_src is None: continue label_path os.path.join(label_dir, f{frame_number}.txt) with open(label_path, w) as f: for t in targets: category t.get(category, car) if category not in CATEGORY_MAP: continue x_c, y_c, w, h convert_box(960, 540, t[left], t[top], t[width], t[height]) f.write(f{CATEGORY_MAP[category]} {x_c:.6f} {y_c:.6f} {w:.6f} {h:.6f}\n) img_dst os.path.join(img_dir, f{frame_number}.jpg) if not os.path.exists(img_dst): try: # 用硬件链接或复制避免重复拷贝大文件 os.link(img_src, img_dst) except OSError: # 跨文件系统时用shutil.copy import shutil shutil.copy(img_src, img_dst) def main(): parser argparse.ArgumentParser() parser.add_argument(--data_dir, requiredTrue, helpDETRAC-train-data目录) parser.add_argument(--ann_dir, requiredTrue, helpDETRAC-train-annotations目录) parser.add_argument(--output, requiredTrue, help输出YOLO格式目录) parser.add_argument(--val_seqs, nargs*, default[], help指定的验证集序列名不指定则随机抽取20%) args parser.parse_args() os.makedirs(args.output, exist_okTrue) xml_paths sorted(glob.glob(os.path.join(args.ann_dir, *.xml))) if args.val_seqs: val_seqs set(args.val_seqs) else: random.shuffle(xml_paths) split_point int(len(xml_paths) * 0.8) train_paths xml_paths[:split_point] val_paths xml_paths[split_point:] # 省略生成data.yaml的代码逻辑很简单 if __name__ __main__: main()这里我用的是按视频子目录嵌套的方式后面YOLO训练时会自动递归读取所有图片和标签不需要把所有图摊平到一个目录里。用硬链接复制文件可以节省大量磁盘空间UA-DETRAC原始数据超过80GB硬链接不占额外空间同一个文件可以出现在两个目录里非常实用。如果你不需要按序列划分直接把process_sequence里的输出目录改成images/train和labels/train然后只调用一次也可以。但强烈建议保留序列划分不然验证指标不可信。4. 转换完的验证与排坑记录4.1 可视化验证框对不对一眼看出转换脚本跑完别急着训练先做可视化验证。最快的验证方式是把YOLO标签重新读出来画到原图上import cv2 def draw_yolo_labels(image_path, label_path, names): img cv2.imread(image_path) h, w img.shape[:2] with open(label_path) as f: for line in f: cls_id, x_c, y_c, bw, bh map(float, line.split()) left int((x_c - bw / 2) * w) top int((y_c - bh / 2) * h) right int((x_c bw / 2) * w) bottom int((y_c bh / 2) * h) cv2.rectangle(img, (left, top), (right, bottom), (0, 255, 0), 2) cv2.putText(img, names[int(cls_id)], (left, top - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) return img随机抽几十张训练集和验证集图片画出来人工过一遍。重点看两类第一框是不是紧贴车辆有没有整体偏移半个车身第二类别ID有没有错位比如把卡车标成公交车。如果前50张验证图都正常再进入训练环节。4.2 我踩过的几个经典坑这个转换过程坑不少总结几个最典型的第一个坑把width当成了x_max。UA-DETRAC的bbox字段给的是宽度不是右下角x坐标。我一开始写的转换函数是(left width) / img_width算出来的中心点比真实位置偏了半个框宽可视化一看框全偏右。这个问题在单张图上很难直接用脚本查出来只能靠可视化发现。第二个坑类别命名不一致。XML里我见过Car、car、others、Other混用的变体如果用直接判断class name会有少量目标被跳过。解决方法是先做一次标准化把所有类别名转成小写再映射。第三个坑YOLO标签文件为空。UA-DETRAC里有些帧确实没有任何车转换后txt里是0行。YOLO官方代码遇到空标签会报错或者直接跳过该图所以最好把这些空标签保留下来并在训练配置里设置skip_empty或者干脆过滤掉这些帧。推荐的做法是保留空文件但把对应图片放到一个empty目录里不在训练时加载。第四个坑Windows下路径分隔符问题。在Windows上写脚本时如果硬编码\到Linux服务器上跑就全废了。全程用os.path.join图片路径和标签路径的输出都用相对路径避免后续YOLO读取时路径对不上。第五个坑磁盘空间和inode耗尽。我一开始用shutil.copy复制所有图片140k张图占了80GB多。后来改成os.link硬链接几乎不占额外空间。注意跨文件系统时硬链接会失败所以保留except OSError分支用copy两套方案都兼容。4.3 对CVAT转换方案的补充建议有人问为什么不用CVAT做转换。CVAT确实支持导入PASCAL VOC格式再导出YOLO格式UI操作可视化一次转几百张图完全没问题。但UA-DETRAC一个XML对应一个视频序列里面有上万帧直接导入CVAT会把它当成一万张独立图片处理标注全部堆叠在一起体验很痛苦。而且CVAT导入大XML时经常卡顿需要按序列拆分。对于这种大规模数据集还是写脚本更靠谱。如果你只是处理几百张图片的标注CVAT值得用但面对14万帧的UA-DETRAC脚本是唯一高效方案。5. 用转换后的数据训练YOLO的实测体验5.1 训练效果与指标转换完成后我用YOLOv8n目前用的人最多的版本之一做了一轮基准测试。训练配置imgsz640batch16epochs100优化器默认的SGD学习率0.01。显卡是一张普通的24GB显存卡整个训练过程大概几个小时。最终在验证集上mAP50能到0.78左右mAP50-95大约0.5左右。这个数字对单阶段检测器来说属于合格水平但谈不上惊艳。UA-DETRAC难就难在大量遮挡和截断目标如果把truncatedtrue和occlusion2的样本剔除后再评估mAP50能明显提升到0.85以上。这说明模型确实是被那些困难样本拖了后腿。后来我把验证集里严重遮挡的车辆单独拿出来看发现漏检的几乎都是只有半个车身露出、或者被树木公交车挡住的目标。这也说明UA-DETRAC转换后的数据对模型鲁棒性训练很有帮助单纯追求mAP高不如保留这些困难样本。5.2 针对UA-DETRAC特性的调参建议如果想把模型效果再往上拉有几个方向值得尝试。第一类别不均衡问题。car占比高bus和others数量少直接用标准损失函数会倾向于把所有目标预测成car。我的做法是在YOLO的损失函数里给类别加权或者对少量类做过采样把每个batch里强制混入一些包含bus和others的图。第二图像分辨率问题。UA-DETRAC原始图像是960x540很多场景的远距离车辆很小。训练时如果imgsz640小车的特征会被压缩得很厉害。显存允许的话把imgsz调到960远距离车辆检出的mAP会提升不少。代价是训练速度和显存占用上升。第三数据增强策略。遮挡目标本身是难点但过强的mosaic增强有时会加剧遮挡让模型更分不清。我在实验里发现把mosaic概率从1.0降到0.5同时开启mixup对UA-DETRAC这类遮挡严重的场景更友好。这个结论不算通用但值得在自己的验证集上试一下。第四困难样本二次训练。把之前提到的attributes.json利用起来筛出truncatedtrue或occlusion2的目标作为困难样本子集用已经收敛的模型在这个子集上继续训练20个epoch。第二轮训练后验证集上被遮挡车辆的召回率能提升几个点。这个方法本质是对困难样本做重加权效果非常直接。最后分享一个小技巧转换脚本跑完后把data.yaml里的val路径指到一个完全不包含训练序列的验证集目录训练过程中每轮验证的mAP才值得参考。UA-DETRAC这种视频数据集尤其要注意序列间泄漏不然你模型的真实泛化能力会被虚高的指标掩盖后面上测试集时会狠狠打脸。