简介:该数据集提供534张欧盟车牌实拍图像,配套一一对应的VOC格式XML标注文件,标注工作基于VOTT工具完成,面向目标检测、车牌定位与识别等计算机视觉任务的学习者及算法工程师。图像内容涵盖白天、夜晚及不同行车场景,有助于检验检测模型在光照变化和复杂背景下的鲁棒性。包内共有1070个文件,主体为jpg图像与XML标注的成对组合,另附加1份txt说明与1份md文档,方便快速了解标注规范与数据组织方式;整体压缩包约428MB,可直接用于YOLO、Faster R-CNN等主流检测框架的训练或微调。目前已有185人学习下载,适合需要真实欧洲车牌数据开展模型训练、算法效果对比或毕业设计的开发者使用。
1. 拆一个 534 张欧盟车牌数据集:VOTT 标的 VOC 能不能直接训练
做车牌检测的同行可能都有过这种经历:从项目里翻出一个标注好的数据集,压缩包名字写着“带VOC格式标签、VOTT标注”,解压后却不知道能不能直接拿去训练。这个五百多张的欧盟车牌包就是一个典型例子,图片来自白天和夜间行车视频的抽帧,标注用 VOTT 完成,标签走的是 Pascal VOC 格式。我能确认的是,这类数据做欧盟车牌检测够用,但直接丢进 YOLO 十有八九会翻车——白天效果好、夜间漏检、框整体偏移、XML 和图片对不上,这些问题我在复现时都实际遇到过。这篇笔记就把拆包的整个过程写清楚:文件命名透露了什么、VOC 字段怎么读、VOC 转 YOLO 的脚本长什么样,以及训练前必须做的避坑排查。适合两类人:一是手头只有 VOC 标注数据想转 YOLO 训练的车牌检测开发者,二是刚接触标注工具选型、想知道 VOTT 标出来的数据该怎么落地的初学者。
2. 这包数据的家底:文件命名、VOC 标签字段与 VOTT 的选型理由
很多数据集拿到手第一反应是看图片、数张数,我一般会先看文件名。文件名在标注工具导出时往往会保留原始信息,而这个信息对后续训练拆分验证集非常关键。
2.1 文件名里的信息量:时段、批次与视频时间戳
这个包里图片的命名长这样:nightride_type3_001.mp4#t=352.jpg。拆开看,前面部分nightride_type3_001说明它来自夜间行车视频,type3 是拍摄批次或路段编号,001 是视频序号,#t=352表示抽帧点在视频播放到第 352 秒的位置。同理,dayride_type1_001.mp4#t=1199.jpg就是白天视频在第 1199 秒抽出的帧。
这层信息对训练很有用:夜间和白天的样本天然分开,不需要再去翻拍摄日志。拿到数据后我习惯先按前缀做一次数量统计,确认昼夜比例,避免验证集随手一划就把某个时段的数据全划走。
# 按白天/夜间前缀分别统计数量 ls *.jpg | grep -c '^dayride' ls *.jpg | grep -c '^nightride' # 看 type 字段的分布,了解拍摄批次 ls *.jpg | awk -F'_' '{print $2}' | sort | uniq -c第一条和第二条分别输出白天、夜间图片张数;第三条用awk按下划线切分文件名,统计 type1、type3 这类批次字段的数量。操作上要注意,grep -c统计的是行数,如果文件只有名字没有换行符,输出可能不准,但这在常规ls输出下没影响。后面划分 train/val 时,我会按前缀比例分层抽样,而不是纯随机,这样白天夜间在验证集里都能有代表。
2.2 VOC 标签里到底写了什么:bndbox、difficult 与 truncated
VOC 格式的标签是 XML 文件,每张图对应一个。打开看一下,结构并不复杂,但字段之间主次分明,转换到 YOLO 时哪些留哪些丢,需要心里有数。
<annotation> <folder>nightride_type3_001</folder> <filename>nightride_type3_001.mp4#t=352.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>plate</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>842</xmin> <ymin>356</ymin> <xmax>1013</xmax> <ymax>428</ymax> </bndbox> </object> </annotation>filename记录的是原始图片名,注意它带着#t=352这一段,后面做文件配对时容易在这里出问题;size里是图片宽、高、通道数,转换 YOLO 坐标要靠它做归一化分母;object下的name是类别名,bndbox四个值是左上角和右下角的像素坐标。truncated表示目标是否被图像边界截断,difficult表示这个目标是否属于难例。
对车牌检测这个场景,最需要关注的是bndbox和difficult。欧盟车牌大多是白底黑字的长条形,占图面积不大,但对比度差异明显,白天和夜间的框质量差别也大。difficult字段在 VOC 比赛里表示“人类都难辨认”的样本,转 YOLO 时如果你没有意图做难例挖掘,我一般建议直接过滤掉,否则模型会在模糊样本上浪费拟合能力。下面这个表格可以用来对照字段在转换时的去留:
| 字段 | 含义 | 转 YOLO 时是否使用 |
|---|---|---|
| folder | 图片所在目录 | 不使用 |
| filename | 图片文件名 | 用于配对,需注意特殊字符 |
| size/width | 图片宽 | 作为归一化分母 |
| size/height | 图片高 | 作为归一化分母 |
| object/name | 类别名 | 映射到 class id |
| object/bndbox | 左上角与右下角坐标 | 换算成中心点、宽高 |
| object/truncated | 是否截断 | 可用于过滤极端截断样本 |
| object/difficult | 是否难例 | 建议过滤 |
2.3 为什么是 VOTT:从视频抽帧到导出一气呵成
这个包标注用的是 VOTT,标注工具选型上它和 LabelImg、CVAT 走的路线不太一样。VOTT 这类工具专门处理视频,标注时可以按时间戳抽帧,像我前面提到的#t=352这种帧,在 VOTT 里能直接定位到对应时间点,框完一帧继续走下一帧,不需要先自行把视频拆成图片再导入。这种“边看视频边标注”的流程对行车记录仪素材很友好。
LabelImg 是纯图片标注工具,更通用但需要先完成抽帧步骤;CVAT 功能更强、支持多人协作,但要部署服务端,对小规模单机标注来说有点重。个人感受是,几百张规模的视频抽帧标注,VOTT 在“能看视频”和“导出 VOC”这两点上刚好够用,学习成本也不高。它导出 VOC 时还会保留原始文件名,这也是这个包里图片名带mp4#t=后缀的原因之一。
这里也顺带提醒一句,VOTT 导出的 XML 命名不保证和图片完全一致,解压后第一步应该是做清单配对,后面避坑章节会再展开。我习惯的做法是先列目录树,确认 JPEG 和 XML 是不是一一对应,再进入格式转换环节。
3. 把 VOC 喂给 YOLO:一份可改的 Python 脚本与类名映射
VOC 和 YOLO 的标签坐标系不一样,不能直接复制粘贴。VOC 存的是左上角和右下角的像素坐标,YOLO 要的是归一化后的中心点坐标与宽高。这一步出问题,模型训练出来框全是歪的,而且很难从 loss 曲线看出来。
3.1 转换思路:坐标系与两个容易忽略的点
转换公式本身不复杂:中心点x_center = (xmin + xmax) / 2 / 图片宽,宽w = (xmax - xmin) / 图片宽,高度方向同理。但有两个点我每次都会强调:一是分母必须用图片的实际宽度和高度,而不是 XML 里size字段写的值。视频抽帧后如果有人对图片做过缩放、裁剪黑边,XML 里的尺寸就可能和实际图不一致,强行用它做分母,所有框都会整体偏移。二是difficult和truncated字段要提前定好策略,是保留还是丢弃。
我在转换脚本里会加一层兜底:解析完 XML 的 size 之后,再用 OpenCV 读一次图片真实尺寸,如果对不上就打印警告,并以实际尺寸为准计算归一化坐标。这个小动作不少项目能省下一轮返工。
3.2 转换脚本:XML 解析、合法性检查与输出
下面这份脚本可以直接用,按你的实际类别名改一下CLASSES就行。
import xml.etree.ElementTree as ET from pathlib import Path import cv2 # 按实际情况改成你的类别列表,单类车牌就这一行 CLASSES = ["plate"] def parse_voc(xml_path: Path): """解析单个 VOC XML,返回图片尺寸和所有目标框。""" tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") img_w = int(size.find("width").text) img_h = int(size.find("height").text) boxes = [] for obj in root.iter("object"): name = obj.find("name").text if name not in CLASSES: continue difficult_el = obj.find("difficult") difficult = int(difficult_el.text) if difficult_el is not None else 0 if difficult == 1: continue # 直接过滤难例 bndbox = obj.find("bndbox") xmin = float(bndbox.find("xmin").text) ymin = float(bndbox.find("ymin").text) xmax = float(bndbox.find("xmax").text) ymax = float(bndbox.find("ymax").text) boxes.append((xmin, ymin, xmax, ymax, name)) return img_w, img_h, boxes def convert_voc_to_yolo(xml_dir: Path, img_dir: Path, out_dir: Path): """遍历目录下所有 XML,逐个转成 YOLO 格式的 txt。""" out_dir.mkdir(parents=True, exist_ok=True) for xml_path in sorted(Path(xml_dir).glob("*.xml")): img_w, img_h, boxes = parse_voc(xml_path) # 兜底:以图片实际尺寸为准 img_path = Path(img_dir) / (xml_path.stem + ".jpg") if img_path.exists(): h, w = cv2.imread(str(img_path)).shape[:2] if (w, h) != (img_w, img_h): print(f"[warn] size mismatch: {xml_path.name}, use image size") img_w, img_h = w, h txt_path = out_dir / (xml_path.stem + ".txt") with open(txt_path, "w", encoding="utf-8") as f: for xmin, ymin, xmax, ymax, name in boxes: # 防御 XML 里坐标写反的情况 xmin, xmax = min(xmin, xmax), max(xmin, xmax) ymin, ymax = min(ymin, ymax), max(ymax, ymax) x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w_norm = (xmax - xmin) / img_w h_norm = (ymax - ymin) / img_h if not (0 <= x_center <= 1 and 0 <= y_center <= 1): print(f"[warn] x_center out of range: {xml_path.name}") if not (0 < w_norm <= 1 and 0 < h_norm <= 1): print(f"[warn] width/height out of range: {xml_path.name}") cls_id = CLASSES.index(name) f.write(f"{cls_id} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}\n") if __name__ == "__main__": convert_voc_to_yolo( xml_dir=Path("Annotations"), img_dir=Path("JPEGImages"), out_dir=Path("labels"), )逻辑上分三步:先解析 XML 拿到尺寸和所有框,再对每张图做坐标换算,最后写入 txt。文本里每一行对应一个目标,格式是类别id 中心x 中心y 宽 高,全部是 0 到 1 之间的小数。参数上,xml_dir放 VOC 的 XML 目录,img_dir放同级图片目录,out_dir是输出目录。脚本运行时如果终端打印了size mismatch或out of range,说明数据里存在脏样本,不要忽略,后续要回源头检查。
3.3 参数怎么改:多类别、难例保留与 data.yaml 的同步
如果你这个包里的 XML 不只包含plate一个类别,直接把CLASSES扩成多类即可,顺序决定 id,比如["plate", "car"],后续训练配置里的names必须和这个顺序一致。想保留difficult的框,就把脚本里if difficult == 1: continue这一行注释掉。想做缩框处理,可以在算完xmin/xmax后乘一个收缩系数,比如向内收缩 5%,这在检测框普遍偏大时很有效。
转完 txt 之后,还要同步一份 YOLO 训练用的数据配置。下面这个dataset.yaml是常见写法,路径按自己的目录结构改:
path: ./dataset train: images/train val: images/val nc: 1 names: ["plate"]nc是类别总数,names的顺序严格对应CLASSES列表。如果图里既有白天又有夜间,建议 train 和 val 都按时段比例抽样,YOLOv5/v8 仓库里常见的启动命令类似python train.py --data dataset.yaml --epochs 100,批次大小再按显存调整。我自己习惯先跑 50 轮,只看验证集 mAP 的走势,确认标签没有大问题后再加轮数。
4. 训练前的避坑排查:VOC 转 YOLO 时最容易翻车的四类问题
很多训练效果差不是模型玄学,而是标签在转换时悄悄变形了,并且这类问题几乎不会在 loss 曲线上直接暴露。以下四类问题是我在这个车牌数据集上实际撞到过的,按“现象 → 原因 → 解决”整理。
4.1 XML 和图片对不上,训练提示 No labels found
现象:训练时日志里频繁出现No labels found in ...,或者某几张图完全没有对应的 txt,但数据包里的 XML 明明存在。
原因:VOTT 导出的 VOC XML 文件名和图片名不是简单的一一对应关系,尤其当原始图片名里带着mp4#t=352这种字符串时,Windows 会把#当特殊字符处理,某些工具解析路径时直接截断;另外 XML 后缀也可能因为导出选项不同而变成xxx.jpg.xml,和图片的 stem 对不上。
解决:解压后先跑一遍配对脚本,把 JPEG 和 XML 的 stem 列出来对比,确认所有图片都有对应标注。不一致时统一重命名成纯数字编号,比如000001.jpg,同时把 XML 也改成同名,再跑转换脚本。从那以后我每拿到一个 VOC 数据集,第一步永远是做这个配对检查,不然后面全是连锁反应。
4.2 白天效果不错,夜间几乎全漏检
现象:模型在白天验证集上 AP 很高,夜间帧上基本检不出车牌,或者框住的是车灯而不是车牌。
原因:这个数据包的白天和夜间抽帧比例本身就不均衡,夜间图片占比如果再被随机划分冲淡,模型看到的夜间样本更少;另一个隐藏原因是夜间图片里车牌对比度差,标注时容易把亮斑和牌照边缘混在一起,框的真实性不如白天帧。
解决:用第 2 章里的统计命令确认 dayride 和 nightride 各自占比,按前缀分层划分 train/val。训练时对夜间样本做数据增强,比如亮度抖动、直方图均衡,或者把夜间图片单独复制一份进训练集做样本均衡。如果夜间样本实在太少,退一步的做法是白天夜间各训一个模型,推理时输入端按亮度判断走哪个模型。
4.3 框整体偏大或贴在图片边缘,坐标没有真正归一化
现象:转出来的 txt 可视化后,框比车牌大一圈,或者框的一条边紧贴图片边界,像是被人为拉长了。
原因:最常见的是分母用错。XML 的size字段写的是 1280x720,但实际 jpg 因为去黑边、缩放变成了 1248x704,直接用旧尺寸归一化,所有框都会偏移;另一种可能是有目标本身就超出画面边缘,xmin或xmax出现负值或超过图宽。
解决:转换脚本里我已经加了实际尺寸兜底,读到size mismatch告警时,回到原图确认宽高。更稳妥的方式是把所有 XML 里超出边界的坐标先裁剪到[0, 图片宽/高]范围内,再算归一化值,我个人会在脚本里加一步 clamp 处理,避免一条坏数据拖累整个训练。
注意:不要只看一两个框正常就跳过全部检查,这类问题往往是按批次出现的,某个时段抽帧的图会集体中招。
4.4 中心点全挤在角落,宽高比变成细长条
现象:可视化标签时发现某个时段图片的框中心点全部堆在图像左上角或右下角,框的形状变成很细的长条。
原因:坐标写反了。XML 里如果xmin和xmax的顺序不统一,或者混入了归一化和未归一化两种坐标系的数据,换算后中心点会跑到奇怪的位置。还有一次我发现是分割文件名时把type3当成了类别 id,导致类别编号错位,视觉上就成了一个个乱跳的框。
解决:写一个简单的合法性自检,遍历所有 txt 检查0 <= x_center <= 1、0 < w <= 1,不满足就打印文件名。另外每次转换完我都会挑 20 张图把框画回原图上肉眼扫一眼,比对框和车牌边缘的距离。画框脚本放在下一章,这个动作虽然原始,但比看任何统计数字都直接。
5. 验证动作:画框回归脚本与数据质量体检
转换完标签,正式训练前,我建议你先花十分钟做一个回归校验,把标签和图片重新对齐一遍。
5.1 校验脚本:把 txt 和图片尺寸再对一遍
import cv2 from pathlib import Path for txt in Path("labels").glob("*.txt"): img_path = Path("images") / (txt.stem + ".jpg") if not img_path.exists(): print(f"[missing] {txt.stem}") continue img = cv2.imread(str(img_path)) h, w = img.shape[:2] for line in txt.read_text().splitlines(): cls_id, x, y, bw, bh = map(float, line.split()) x1 = int((x - bw / 2) * w) y1 = int((y - bh / 2) * h) x2 = int((x + bw / 2) * w) y2 = int((y + bh / 2) * h) if x1 < 0 or y1 < 0 or x2 > w or y2 > h: print(f"[out-of-bound] {txt.stem}: ({x1}, {y1}, {x2}, {y2})") cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(f"check_{txt.stem}.jpg", img)这段脚本读回 txt 里的归一化值,乘以实际图片尺寸画框,同时检查框是否越界。它验证的不是模型,而是你手里的标签是否还能对应回原图。跑完后随便打开几张生成的check_*.jpg,看框和车牌的贴合程度:框比车牌大一圈的、框斜着跨到隔壁车道的、盖在车灯上的,都要回源头重新处理。
5.2 画框抽查:看清标注的边界和时段的差异
夜间抽帧的图尤其建议多抽几张,看框是不是明显比白天的松。欧盟车牌本身是长方形蓝边白底,光照不足时边缘位置标不准很正常,如果单张图偏差超过 10%,我会把那批夜间图打回重新标注,而不是靠模型硬扛。这个动作也能帮你判断要不要做缩框处理:抽查 20 张里超过三分之一框的外边距明显大于车牌本身,就值得在转换脚本里加一个 5% 的收缩系数。
从那以后我每次换数据集,哪怕是已经标注好的包,也会强制走一遍配对统计、合法性检查和画框抽查这三步,再进训练流程。这套动作不复杂,但能省下大量调参和排错的时间。希望帮到你。
本文还有配套的精品资源,点击获取