UA-DETRAC是我特别喜欢拿来练手的车辆检测数据集——真实监控视角、车辆尺度跨度大、遮挡严重、还有不少逆光和雨雪场景,比在整理得干干净净的VOC或者COCO上训练有挑战得多。但很多人下载下来第一件事就卡住了:解压后是一堆XML标注文件,图片也是按视频序列放在不同文件夹里,而YOLO训练要的是一张图片对应一个txt标签文件。这篇文章就把这个卡点彻底解决,讲清楚UA-DETRAC的标注结构和YOLO格式之间到底差在哪,并给出一份完整可用的转换脚本,附带坐标归一化、过滤规则、数据集划分和可视化验证的细节。无论你接下来要跑YOLOv5、YOLOv8,还是现在最新的YOLO系列版本,只要读懂这套转换逻辑,整个流程都能顺利跑通。
1. 先搞清楚数据集两边到底差在哪
1.1 UA-DETRAC标注文件里装了什么
UA-DETRAC的全称是University at Albany DETRAC dataset,原始资料里面包含约10小时真实交通监控视频,累计14万帧以上,标注框数量超过121万个,覆盖了car、van、bus、others这四类车辆。整个数据集划分成60个训练序列和40个测试序列,每个序列用MVI_20011这种编号命名。
我拿到手后的第一反应是:这数据量做车辆检测算是相当扎实了,监控视角下的密集车流、行人、非机动车干扰,比单纯用街景数据集更能锻炼模型的泛化能力。但它的标注文件不是那种“一个文件对应一张图”的直观结构,而是每个视频序列对应一个XML文件,所有帧的目标框都堆在里面。
打开一个XML文件,结构大致是这样:
<annotations> <seqname>MVI_20011</seqname> <frame> <frameNum>0</frameNum> <target id="0" traffic="1" ignore="0"> <box left="251" top="249" width="52" height="47" /> </target> <target id="1" traffic="0" ignore="1"> <box left="838" top="163" width="25" height="23" /> </target> </frame> <frame> <frameNum>1</frameNum> ... </frame> </annotations>这里有几个关键信息:每个frame节点代表一帧,frameNum对应图片序号;target节点代表一个目标框,id是车辆跟踪编号,traffic表示是否属于正常交通参与者,ignore表示这个框是否应该被忽略;真正的坐标在box节点里,用left、top、width、height四个属性表示。我第一次转格式时栽过的跟头就是没搞清楚ignore=1的含义,把所有框一股脑都转出来,训练完看指标才发现验证集里全是本来要被忽略的模糊小目标,直接影响了模型评估的公平性。
UA-DETRAC官方提供的图片文件是按序列存放的,每个序列目录下是img00001.jpg、img00002.jpg这种连续图片。所以你手上实际上有两条可以走的路线:直接用官方提取好的jpg图片,或者用原始视频自己抽帧。这个后面具体说。
1.2 YOLO系列要的标注格式是什么样
YOLO从v5到v11,不管损失函数怎么改、网络结构怎么换,标注格式始终没变过:每张图片对应一个同名的txt文件,文件里的每一行表示一个目标,格式固定为下面五个数字:
class_id center_x center_y width height注意这里的center_x、center_y、width、height全部是归一化后的相对值,也就是要除以图像的宽和高。这么做的好处是YOLO网络在做anchor计算和损失计算时,可以跟输入图像的绝对尺寸解耦,不管你的训练图是640x640还是1280x1280,标签始终保持在0到1这个范围内。
举个例子:一张960x540的图中,某个car的左上角坐标是(251, 249),右下角坐标是(303, 296),那么:
- 中心点x = (251 + 303) / 2 = 277,归一化后是 277 / 960 ≈ 0.2885
- 中心点y = (249 + 296) / 2 = 272.5,归一化后是 272.5 / 540 ≈ 0.5046
- 宽度 = 303 - 251 = 52,归一化后是 52 / 960 ≈ 0.0542
- 高度 = 296 - 249 = 47,归一化后是 47 / 540 ≈ 0.0870
所以标签文件里这一行就是0 0.2885 0.5046 0.0542 0.0870。
| 项目 | UA-DETRAC原始格式 | YOLO格式 |
|---|---|---|
| 存储方式 | 一个序列一个XML文件 | 一图一个txt文件 |
| 坐标形式 | left, top, width, height(像素) | center_x, center_y, width, height(归一化) |
| 类别表达 | 字符串(car/van/bus/others) | 整数编号(0/1/2/3) |
| 是否需要忽略标记 | 有ignore字段 | 没有,忽略框需要提前过滤 |
| 文件命名 | XML内用frameNum关联帧 | txt文件名必须与图片文件名完全一致 |
这张表基本就是转换脚本的全部需求。搞清楚两边格式差异之后,你的转换逻辑就非常明确了:遍历XML里的所有frame,解析每个target,把像素坐标转成归一化中心点加宽高,把类别字符串映射成整数编号,最后按frameNum写入到对应图片名相同的txt文件里。
2. 动手前需要想清楚的三件事
2.1 图片从哪来:抽帧还是直接用现成图像
UA-DETRAC官方发布的数据包里,既提供了原始视频,也提供了按帧提取好的图片。如果你下载的是包含DETRAC-Train_Images这种目录的版本,那直接用现成图片就行,路径下每个序列文件夹里已经是连续的img00001.jpg、img00002.jpg这种命名,省去抽帧的麻烦。
但如果你只拿到了视频文件,就得先自己抽帧。这里我建议直接用OpenCV的VideoCapture,脚本很短,而且能保证抽出来的帧号和视频原始帧索引完全一致。不要用ffmpeg默认参数去抽,因为容易遇到帧率四舍五入导致的帧序号对不齐问题。
import cv2 import os video_path = "raw/MVI_20011.mp4" output_dir = "images/MVI_20011" os.makedirs(output_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) frame_idx = 0 while True: ret, frame = cap.read() if not ret: break cv2.imwrite(os.path.join(output_dir, f"img{frame_idx + 1:05d}.jpg"), frame) frame_idx += 1 cap.release() print(f"完成,共提取 {frame_idx} 帧")抽帧时注意两点:一个是编码问题,cv2.imwrite写入jpg默认质量参数是95,对训练来说够用了,没必要强行压缩;另一个是帧数校验,抽完帧后跟XML里的最大frameNum对一下,如果长度不一致,优先以XML为准去检查视频是不是被裁剪过。
2.2 ignore和traffic标记到底怎么处理
这是转换UADETRAC时最容易被忽略、却直接影响训练效果的环节。前文提过,ignore=1表示这个目标框对训练没有贡献,通常是太小、太模糊或者被严重遮挡的目标。traffic=0表示这个目标不属于正常交通参与者,可能是路边的行人、推车或者其他杂物。
我的建议是:在转换脚本里默认把ignore=1的框过滤掉,但保留traffic=0的框。原因很直接:UA-DETRAC的标注初衷是检测道路上的车辆,所以ignore框质量差、数量多,混进训练集只会增加噪声;而traffic=0的框虽然类别上属于others,但它依然是真实出现在画面里的目标,对模型学习“哪些不该检测成车辆”有正面帮助。如果你做的是纯车辆检测项目,也可以把traffic=0一并过滤,只看car/van/bus,这样训练目标更聚焦,但数据量会少一小部分。
2.3 按视频序列划分数据集,别按帧划分
很多新人在划分训练集和验证集时习惯按比例随机抽帧,这在普通图片数据集上没问题,但在UA-DETRAC这种视频序列数据集上是大忌。因为同一个视频序列里相邻帧高度相似,如果训练集和验证集里混入了同一段视频的连续帧,验证指标会被严重高估,模型看起来效果不错,一到新场景就露馅。
正确做法是:按序列文件夹来划分。官方已经帮我们把100个序列拆成了60个训练序列和40个测试序列,如果你还需要额外的验证集,就从训练序列中再抽出几个独立序列。我自己常用的比例是50个序列做训练、10个序列做验证,尽量保证不同的监控场景、光照条件和车流密度都能在两边出现,这样评估才可靠。
3. 转换脚本的核心实现
3.1 设计思路与模块划分
整个脚本我按四个模块来写:XML解析、坐标转换、数据过滤、文件写出。模块拆得清楚点,好处是以后你换其他数据集(比如VOC、Comma2k19、BDD100K)时,只需要改XML解析那一块,坐标转换和文件写出逻辑都能复用。后面我也会讲怎么从VOC转,思路是一样的。
import os import xml.etree.ElementTree as ET from pathlib import Path def parse_vbb_annotation(xml_path): tree = ET.parse(xml_path) root = tree.getroot() width = None height = None if root.find('width') is not None: width = int(root.find('width').text) height = int(root.find('height').text) frames = {} for frame_node in root.findall('frame'): frame_num = int(frame_node.findtext('frameNum')) objects = [] for target in frame_node.findall('target'): box_node = target.find('box') if box_node is None: continue x1 = float(box_node.get('left')) y1 = float(box_node.get('top')) x2 = x1 + float(box_node.get('width')) y2 = y1 + float(box_node.get('height')) obj = { 'class': target.get('class', 'other'), 'ignore': int(target.get('ignore', 0)), 'traffic': int(target.get('traffic', 1)), 'box': [x1, y1, x2, y2] } objects.append(obj) frames[frame_num] = objects return width, height, frames这里有个小坑:有些UA-DETRAC转换过的XML里,target节点上可能没有class属性,类别信息是藏在<attribute>或者<label>子节点里的。所以我代码里用了target.get('class', 'other')做兜底,并建议你先打印一段XML确认字段名称再跑全量数据。XML解析这块,宁可多写几行防御代码,也不要默认所有文件结构完全一致。
3.2 坐标转换与归一化的代码细节
YOLO的坐标要求是中心点加宽高,并且全部归一化。转换时最容易翻车的点在于:UA-DETRAC原始框可能超出图像边界,比如车刚出现在监控画面边缘时,框的left可能是负数,或者left+width大于图像宽度。这类框直接拿来算中心点会得到负数或者大于1的异常值,训练时YOLO会直接忽略或者产生NaN梯度。
所以我在坐标转换函数里加了一个针对性操作:先把坐标clip到图像范围内,再计算中心点和宽高。这样一个框即使只有一小部分在画面内,也能被正确编码。
def convert_to_yolo_box(box, img_w, img_h): x1, y1, x2, y2 = box # 将坐标限制在图像范围内 x1 = max(0, min(x1, img_w)) y1 = max(0, min(y1, img_h)) x2 = max(0, min(x2, img_w)) y2 = max(0, min(y2, img_h)) if x2 - x1 <= 1 or y2 - y1 <= 1: return None cx = (x1 + x2) / 2 / img_w cy = (y1 + y2) / 2 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h # 归一化后的值必须在0~1之间,异常就丢弃 if any(v <= 0 or v > 1 for v in [cx, cy, w, h]): return None return f"{cx:.6f} {cy:.6f} {w:.6f} {h:.6f}"这里我保留了小数点后6位,因为对960x540的图片来说,6位小数已经足够精确到亚像素级别,再多也没有实际意义。另外宽度或高度小于1像素的框我直接丢弃,这种框在监控场景里基本是误标注或者极端远距离目标,对训练没有正向帮助。
之前有人问我要不要做Mosaic增强时对label的影响,其实YOLO的Mosaic是在训练阶段动态完成的,输入标签依然是归一化坐标,模型内部会自己处理拼接和坐标变换,所以你不需要在转换阶段额外考虑增强逻辑。
3.3 类别映射表怎么定更合理
UA-DETRAC官方四类分别是car、van、bus、others,我的映射表是这样定义的:
CLASS_MAPPING = { 'car': 0, 'van': 1, 'bus': 2, 'others': 3, 'other': 3 }为什么把others和other都映射到3?因为我实际跑数据时发现有些版本的XML里类别名被写成了other,如果映射表里没考虑这种写法,会直接抛KeyError异常。把两者都归为同一类,可以避免这种由于数据来源不一致导致的转换中断。
另一个值得思考的问题是类别粒度。如果你只关心“车辆检测”这个任务,完全可以把car、van、bus全部映射成类别0,做成单类检测。这样模型容量可以更集中在位置回归上,对小目标的检测效果通常更好。但如果你后续要做车型分类或者车辆属性分析,就保持四类映射。这个没有绝对的对错,取决于你的业务目标,我建议初学时保留四类,因为类别信息本身也是一种监督信号,对特征提取有好处。
3.4 主流程:遍历图片目录与XML关联
最后把前三步串起来,写一个重要提示:图片文件名和frameNum的关联必须严谨。官方图片命名是img00001.jpg这种五位数格式,而XML里的frameNum是从0开始的。也就是说,img00001.jpg对应frameNum=0,img00002.jpg对应frameNum=1,以此类推。很多人在这一步犯了错,把frameNum直接当文件名后缀用,结果标签和图片错位了一整帧。
我的主循环代码:
def convert_dataset(images_root, annos_root, output_root): seq_dirs = [d for d in os.listdir(images_root) if os.path.isdir(os.path.join(images_root, d))] os.makedirs(output_root, exist_ok=True) for seq in seq_dirs: seq_image_dir = os.path.join(images_root, seq) xml_path = os.path.join(annos_root, seq + '.xml') if not os.path.exists(xml_path): print(f"警告:{seq} 缺少标注文件,跳过") continue width, height, frames = parse_vbb_annotation(xml_path) for img_name in sorted(os.listdir(seq_image_dir)): if not img_name.lower().endswith(('.jpg', '.png')): continue frame_num = int(img_name.replace('img', '').replace('.jpg', '').replace('.png', '')) - 1 if frame_num not in frames: continue img_path = os.path.join(seq_image_dir, img_name) label_lines = [] for obj in frames[frame_num]: if obj['ignore'] == 1: continue if obj['class'] not in CLASS_MAPPING: continue yolo_line = convert_to_yolo_box(obj['box'], width, height) if yolo_line: cls_id = CLASS_MAPPING[obj['class']] label_lines.append(f"{cls_id} {yolo_line}") txt_name = img_name.rsplit('.', 1)[0] + '.txt' txt_path = os.path.join(output_root, txt_name) with open(txt_path, 'w') as f: f.write('\n'.join(label_lines))如果你不想把转换后的所有图片和标签混在一个大目录里,而是按序列保持文件夹结构,那就在循环里再加上一层序列目录的创建。YOLO训练时images和labels支持子目录扫描,两种方式都能用。我个人习惯打平成一层目录,这样在生成训练列表文件时更方便。
4. 完整跑通:从原始数据集到YOLO训练目录
4.1 目录组织与运行方式
拿到官方数据集后,我通常先建一个干净的工作目录,把原始图片和转换产物分开。我的目录结构长这样:
ua_detrac_yolo/ ├── original/ │ ├── DETRAC-Train_Images/ │ └── DETRAC-Train_Annotations/ ├── yolodata/ │ ├── images/ │ │ ├── train/ │ │ └── val/ │ ├── labels/ │ │ ├── train/ │ │ └── val/ │ └── ua_detrac.yamloriginal目录放官方原始数据,yolodata目录放转换后的训练数据。train和val的划分我按序列来做,比如把MVI_20011到MVI_20060这50个序列的图片划到train,再选10个序列划到val。转换成YOLO格式时,我建议先生成一整套中间文件,再做train/val划分,这样脚本逻辑简单,不容易出错。
具体做法是给每个图像文件计算一个相对路径,然后生成train.txt和val.txt两个列表文件,YOLOv5早期版本就是靠这个文件列表来加载数据的。YOLOv8和更新版本则是通过data.yaml里的路径直接扫描目录,所以更省事一点。
我实际跑的时候会用下面这条命令来执行转换脚本:
python convert_uadetrac_to_yolo.py \ --images original/DETRAC-Train_Images \ --annotations original/DETRAC-Train_Annotations \ --output yolodata/images \ --label-output yolodata/labels \ --val-sequences 10脚本里--val-sequences参数的作用是:把所有训练序列打乱后,抽出指定数量作为验证集。不是随机抽帧,而是抽整个序列,保证验证场景和训练场景不重叠。
4.2 生成data.yaml配置文件
一旦转换脚本跑完,YOLOv8及以上版本只需要一个data.yaml文件就能开始训练。文件内容如下:
path: /data/ua_detrac_yolo/yolodata train: images/train val: images/val names: 0: car 1: van 2: bus 3: others注意names的索引顺序必须和前面CLASS_MAPPING里定义的一致。如果你映射表改成了单类车辆检测,那么names也要相应改成0: vehicle。这个文件虽然小,但很多人容易忽略索引一致性,出现过标签文件和yaml类别名错位的情况,训练出来的模型预测结果全乱套。
对于YOLOv5,你还需要额外确保images和labels目录的父路径存在,并且训练命令里用--data指向这个yaml文件。有的老版本YOLOv5还需要在yaml里加nc字段:
nc: 4 names: ['car', 'van', 'bus', 'others'] name: ua_detrac新版本YOLOv8会自动从names列表长度推断类别数,不需要手动写nc。
4.3 可视化验证标注效果
转换完别急着训练,先随机抽几张图可视化一下标注效果。这一步能快速发现坐标转换错误、类别映射错误、以及过滤规则是否合理。我写了一个简单脚本,用OpenCV画框:
import cv2 import glob def visualize(image_path, label_path, class_names): img = cv2.imread(image_path) h, w = img.shape[:2] with open(label_path, 'r') as f: lines = f.readlines() for line in lines: cls_id, cx, cy, bw, bh = line.split() cls_id = int(cls_id) cx, cy, bw, bh = map(float, [cx, cy, bw, bh]) x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) color = (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, class_names[cls_id], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2) cv2.imshow('check', img) cv2.waitKey(0) class_names = ['car', 'van', 'bus', 'others'] visualize("yolodata/images/val/MVI_20061/img00100.jpg", "yolodata/labels/val/MVI_20061/img00100.txt", class_names)打开可视化窗口后,仔细看看几个关键点:框是否贴着车辆边缘、有没有明显偏移半个车身的情况;类别标签是否和车辆类型对得上,尤其是大卡车是不是被标成了bus;有没有框特别大却根本没有目标的误标。我跑第一次转换时,几十号序列里总有那么几个框的坐标因为XML里width和height写反,画出来是扁的,可视化一眼就能揪出来。
4.4 启动训练前的一些参数建议
转换成YOLO格式后,训练参数方面可以参考我常用的几组设置。如果你跑YOLOv8s,输入尺寸建议640或者960。UA-DETRAC的原图是960x540,车辆目标普遍偏小,如果缩到640训练,很多远处的车就只剩十几个像素,模型很难收敛。我实际测试下来,640训练的话,小目标的召回率明显低于960训练,所以如果显存允许,尽量用960。
yolo train data=ua_detrac.yaml model=yolov8s.pt \ epochs=100 imgsz=960 batch=16 device=0如果显存不够,可以用imgsz=640加rect=True,让YOLO按原始宽高比进行矩形训练,避免把960x540的图像强行resize成正方形造成分辨率浪费。YOLOv5和旧版本里这个参数叫rect,YOLOv8里也会自动适配部分模式。另外,因为UA-DETRAC类别不均衡,car占据绝大多数,可以考虑给少数类别加一些weight,或者在loss里面做focal参数调整,这个就看你对具体指标的追求程度了。
5. 实测遇到的问题与排查对照
5.1 帧数对不上,转换出的标签比图片少
这是我遇到最多的情况。表现是转换后某个序列的txt文件数量比图片数量少了几十个,训练时老提示“found no labels for some images”。排查思路是先对比XML里的最大frameNum和图片文件数。
我遇到过一种情况:视频是完整的,但XML标注在末尾几帧没有新目标出现,于是XML解析结果里就少了这几帧,导致图片存在而标签为空。这本身不算错误,YOLO训练时会自动跳过没有标签的图片。但我建议在转换脚本里统计一下每个序列的空标签图片数量,如果超过比如5%,就要回头检查一下是不是解析逻辑漏了帧,而不是真的没有目标。我加的统计代码很简单:
empty_count = 0 total_count = 0 # 在写txt时统计 ... if len(label_lines) == 0: empty_count += 1 total_count += 1 # 打印每个序列的空标签比例 print(f"{seq}: 空标签 {empty_count}/{total_count}")5.2 图片宽高读取与XML不一致
UA-DETRAC官方图片基本统一是960x540,但转换脚本里我直接用了XML里的width和height字段。第二次跑的时候,朋友发来一个别人打包过的数据集,图片被重新裁剪过,尺寸变成了640x360,XML里却还是960x540,结果所有归一化坐标全部错位。
所以稳妥的做法是:不要信任XML里的宽高字段,而是直接用PIL或OpenCV读取每张图片的实际尺寸。修改很简单,在写标签前加一行:
from PIL import Image with Image.open(img_path) as im: img_w, img_h = im.size这样哪怕数据集被RESIZE过,转换结果也是正确的。这是我强烈建议加进去的一个改动。
5.3 空标签文件引发训练中断
某些序列的帧里确实没有任何有效目标,转换后生成一个0字节的txt文件。YOLOv5训练时遇到空标签一般会跳过,但旧版本可能会在验证或者数据增强时报错。我的处理方式是在转换脚本里把空标签文件也保留,因为训练代码自己会处理;如果遇到报错,干脆删除空标签文件并把对应图片移动到unlabeled目录,从训练列表中剔除,这样最省心。
5.4 类别不均衡问题怎么缓解
UA-DETRAC的四类目标分布极不均衡,car占了绝大多数,bus和others占比很小。直接训练的话,模型会倾向于把一切目标都预测成car,换来一个虚高的mAP。我在实操中尝试过两种缓解办法,效果都不错:
第一种是做简单的类别重加权,在训练命令里给少数类别更大的loss权重。YOLOv8支持通过cls超参调整分类损失权重,但我更多是自己改数据集:对少数类别做简单的复制增强,把bus和van的图片多复制几次进训练集,让模型多看到这些样本。
第二种是把模型换成带注意力机制或者更深的版本,比如接一个C2f的改进模块,让特征提取对小目标更友好。不过这超出本文的数据转换范围了,只能说UA-DETRAC对模型能力的要求不低,别指望小模型一出马就刷很高分。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 标签框整体偏移 | frameNum与图片名错位一帧 | 检查frameNum从0开始,图片名从1开始 |
| 标签坐标为负数或大于1 | 目标框超出图像边界 | 转换时clip坐标 |
| 个别类别没有被识别 | XML里类别名写成了other | 映射表增加多写兜底 |
| 训练时大量图片无标签 | XML最后一帧无目标 | 保留空txt,YOLO会自动跳过 |
| 框画出来是扁的 | width和height解析错位 | 打印原始XML字段核对名称 |
| 验证指标虚高 | 按帧划分了train/val | 必须按视频序列划分 |
| 模型只预测car | 类别样本不均衡 | 复制增强或调整loss权重 |
| 转换后txt文件中文乱码 | XML编码问题 | 用utf-8-sig读取XML文件 |
最后想再聊两句
UA-DETRAC转YOLO格式这件事,本质上就是一次坐标系的换算和文件重新组织,算不得多高深,但细节极多。我做这些转换脚本时,最大的体会是:数据集格式转换的坑,永远不会出现在你写转换函数的那些行代码上,而是出现在你理所当然认为“应该如此”的地方,比如帧号从0还是从1开始、XML字段名称是否统一、图片有没有被resize过。所以我的习惯是:转换先抽一个序列跑通,可视化验证没问题,再全量执行,别一上来就用for循环扫全部数据。
整套脚本跑通后,后续还能做很多扩展:把转换结果接一个车辆跟踪模型做多目标跟踪,或者用UA-DETRAC做迁移学习,先在这个数据上预训练,再在自己的业务场景微调,效果普遍比从COCO预训练直接起步要好。毕竟监控视角和自然图像差异挺大,UA-DETRAC的车辆尺度分布更接近真实路况。按文中这套流程把数据准备好之后,后面无论你换YOLO哪个版本,都能直接接上,不白费功夫。