简介:面向车牌检测与识别任务的中文车辆数据集,涵盖中国车牌中的数字和字母,适用于智能交通、停车场管理、安防监控等场景,也适合计算机视觉初学者及算法工程师用于模型训练、验证与调参。资源以zip压缩包形式发布,内含1458张已标注车牌图片,整体大小约19.49MB;标注采用VOC格式,每张图片对应包含类别与边界框信息的标注文件,可直接接入YOLO、Faster R-CNN等主流目标检测框架。现有684人学习或下载,说明数据集已具备一定实用性和参考价值。使用后可省去自行采集与标注数据的繁琐环节,快速获得训练所需的正样本集合;VOC格式兼容性好,可依需转换为其他格式,从而让读者更专注于网络结构与训练策略本身,适合作为车牌识别项目的起步数据,也能用于算法精度对比与模型迭代实验,帮助读者在有限样本下快速验证模型效果,缩短实验周期。
1. 字符级标注才难找:这个车牌数据集解决的是什么问题
做车牌识别、想训练字符识别模型的工程师,大概率都会卡在同一个地方——整牌检测的数据好找,字符级标注难寻。这个中国车辆车牌号识别数据集就是冲着这个缺口来的:1458张标记图片,每张图上车牌里的数字和字母都被单独框出来,类别名就是字符本身,标注格式是Pascal VOC的XML。
所以它解决的不是“车在哪”的检测问题,而是“车牌上每个字是什么”的识别问题。数据量不算大,但字符级、VOC格式这两个特征放一起,在开源车牌数据里算稀缺的。
适合两类人:一类是停车、门禁项目里已经有了车牌检测框、缺字符识别训练数据的工程师;另一类是想拿一份真实标注数据,自己动手跑通“VOC转YOLO再训练”全流程的新手。这份数据包解压后就是标准的VOC目录结构,下面从标注结构开始拆,每一步都给能直接抄的脚本和参数。
2. 吃透VOC标注:XML字段拆解与1458张图的体检脚本
2.1 VOC标注拆解:一个车牌字符的XML长什么样
VOC格式的核心是“一图对应一个XML”。Annotations目录下每个xml文件名与JPEGImages里图片文件名一致,只是后缀不同,xml里记录了图片尺寸、来源和所有标注框。这个数据集标注的是车牌上的数字和字母,所以每个object就是一个字符。
<annotation> <folder>JPEGImages</folder> <filename>IMG_0001.jpg</filename> <source> <database>plate_char_dataset</database> </source> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>0</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>620</xmin> <ymin>400</ymin> <xmax>650</xmax> <ymax>430</ymax> </bndbox> </object> <object> <name>A</name> <bndbox> <xmin>655</xmin> <ymin>400</ymin> <xmax>685</xmax> <ymax>430</ymax> </bndbox> </object> </annotation>以上是典型结构,实际字符顺序和坐标以包内xml为准。几个关键字段值得注意。
filename是图片名,脚本配对时用去掉扩展名的文件名主体做key。size里的width和height决定坐标归一化的分母,如果xml里的size和实际图片分辨率不一致,后面转YOLO标签时所有框会整体偏移,这一条在避坑章会展开讲。
object/name是字符内容,数字或字母。同一张图有多个字符就有多个object,xml里的排列顺序不一定对应车牌上从左到右的顺序,后处理拼接时要重新排序。bndbox记录像素坐标,xmin和ymin一般从0算起,xmax和ymax按闭区间处理即可,不同标注工具对边界定义有细微差异,但转成YOLO格式时差别可以忽略。
字符框有个特点:普遍贴得比较紧,边框和笔画之间余量很小。标注紧的框训练出来的模型定位更准,但前提是别把笔画切掉。我建议拿到数据第一周内就随机抽几张图,用cv2.rectangle把bndbox画回原图,肉眼看一遍贴合度。画框检查这个动作花不到五分钟,能过滤掉后面一整类标注质量问题。
2.2 数据集体检:文件配对、类别统计与类别数判断
拿到数据集的第一件事不是写训练脚本,而是体检。VOC数据在流传过程中最容易出现xml丢失、图片重命名、类别大小写不一这三种问题,全部能用脚本扫出来。
import os import xml.etree.ElementTree as ET from collections import Counter IMG_DIR = "JPEGImages" # 图片目录 ANN_DIR = "Annotations" # XML目录 # 1) 文件配对检查:图片与XML一一对应 imgs = {f.split('.')[0] for f in os.listdir(IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))} anns = {f.split('.')[0] for f in os.listdir(ANN_DIR) if f.endswith('.xml')} print("图片数:", len(imgs), " XML数:", len(anns)) print("缺XML的图片:", sorted(imgs - anns)[:10]) print("缺图片的XML:", sorted(anns - imgs)[:10]) # 2) 类别统计:确认标注字符集合,发现混标 cats = Counter() for f in os.listdir(ANN_DIR): root = ET.parse(os.path.join(ANN_DIR, f)).getroot() for obj in root.findall('object'): cats[obj.findtext('name')] += 1 print("类别总数:", len(cats)) for name, cnt in cats.most_common(): print(f"{name}: {cnt}")逻辑说明:第一步用两个文件名的集合做差集,imgs - anns就是有图但缺xml的,anns - imgs是有xml但缺图的,任一非空都说明包不完整,需要跟资源方确认或用脚本补齐。第二步遍历所有xml,统计每个字符类别的出现次数。
参数说明:目录名按解压后的实际结构改;后缀判断里加.lower()是防止图片后缀大小写不一致。这个脚本只统计出现在object里的name,同一个车牌里同一个字符出现两次会正确计数两次,因为循环会对每个object各加一次。
类别统计的作用还不止于“看看有哪些字符”。它直接决定第3章的类别映射表,也能帮你判断数据集的真实类别范围。正常中国车牌字符数据集里数字0-9和字母A-Z都会出现,但某些字母因为车牌规则限制样本可能极少。如果统计结果里有你预期之外的类别名,先搞清楚是标注错误还是业务需要,不要直接带进训练。
体检还应该看一眼ImageSets/Main目录。VOC数据集一般带着train.txt和val.txt,写明官方划分;这个数据包如果带划分文件,转换标签时要同步按划分文件分配train/val,如果没带就按8:2随机划分。但字符识别场景下,我对官方划分持保留态度,原因就是第5章的划分泄漏问题。养成习惯:先体检,再划分,最后才碰训练命令。
3. 把VOC转成YOLO标签:换算公式、批量转换与校验
3.1 为什么必须转:VOC与YOLO标签的本质差异
YOLO系训练器(v5/v8)的原生标签是txt文件,每行五个值:class_id x_center y_center width height,坐标全部归一化到0-1。VOC的xml存的是像素级bndbox,两者差两层:一是坐标系从绝对像素变成相对比例,二是类别从字符串变成整数id。
换算公式很简单,其中W和H来自xml里size.width和size.height:
x_center = (xmin + xmax) / 2 / W
y_center = (ymin + ymax) / 2 / H
box_w = (xmax - xmin) / W
box_h = (ymax - ymin) / H
这套公式没有任何高级之处,但几乎每个自己手写过转换脚本的人都至少翻一次车。最常见的两个错:把x_center写成xmin/W,或者忘记除H只除了W。前者会让所有框整体偏左,后者让归一化坐标整行错误,而训练器不会报错,只会默默地把错误的框当成训练目标,最后mAP上不去还找不到原因。
网上有现成的VOC转YOLO脚本,但通用脚本往往处理不了两件事:一是自定义类别映射(尤其是跳过或合并某些字符),二是字符级小目标的数值范围校验。所以自己写这二十几行代码更可控,后面排查问题也知道每一步做了什么。
3.2 批量转换脚本:从Annotations生成labels
import os import xml.etree.ElementTree as ET ANN_DIR = "Annotations" LABEL_DIR = "labels" os.makedirs(LABEL_DIR, exist_ok=True) # 类别映射:基于第2章统计结果手工整理 # 数字0-9 -> 0-9,字母A-Z -> 10-33 # 如果统计里出现I/O或省份简称,按实际结果追加 CLASS_MAP = { '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, 'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15, 'G': 16, 'H': 17, 'J': 18, 'K': 19, 'L': 20, 'M': 21, 'N': 22, 'P': 23, 'Q': 24, 'R': 25, 'S': 26, 'T': 27, 'U': 28, 'V': 29, 'W': 30, 'X': 31, 'Y': 32, 'Z': 33, } for xml_name in os.listdir(ANN_DIR): if not xml_name.endswith('.xml'): continue root = ET.parse(os.path.join(ANN_DIR, xml_name)).getroot() size = root.find('size') W = int(size.findtext('width')) H = int(size.findtext('height')) lines = [] for obj in root.findall('object'): name = obj.findtext('name') if name not in CLASS_MAP: print(f"[跳过] {xml_name} 中未映射的类别: {name}") continue box = obj.find('bndbox') xmin = int(box.findtext('xmin')) ymin = int(box.findtext('ymin')) xmax = int(box.findtext('xmax')) ymax = int(box.findtext('ymax')) # 归一化:中心点与宽高都以图画宽高做分母 x_center = ((xmin + xmax) / 2) / W y_center = ((ymin + ymax) / 2) / H w = (xmax - xmin) / W h = (ymax - ymin) / H lines.append(f"{CLASS_MAP[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") base = os.path.splitext(xml_name)[0] with open(os.path.join(LABEL_DIR, base + ".txt"), "w") as f: f.write("\n".join(lines))逻辑说明:遍历每个xml,先读size拿到W和H,再遍历object,把name通过CLASS_MAP换成整数id,把bndbox四个像素值转成归一化中心点与宽高,写成一个与xml同名的txt。多个字符就是多行,行与行之间用换行分隔。
参数说明:CLASS_MAP是整个流程里最容易出错的配置,它的id顺序必须和后面第4章data.yaml里的names保持完全一致。示例里数字0-9对应0-9,字母A-Z对应10-33,跳过了I和O是因为部分车牌规则里不出现这两个字母;如果你统计结果里有I/O或其他字符,按统计结果自行追加。未映射的字符打印日志跳过,而不是直接当成0,是为了避免类别静默错位——宁可让图片缺一个框,也不能让所有类别往后错一位。
这里有个小经验:转换脚本跑完后,抽查几个txt文件看数值量级。车牌字符框占图比例不高,归一化后w一般在0.05-0.2之间,h在0.1-0.4之间。如果你看到w接近0.9或者h接近0.98,不用怀疑,坐标换算十有八九写错了,回去检查是不是把xmax直接除以了W。
3.3 转换后校验:越界检查与可视化抽查
import os LABEL_DIR = "labels" for txt in os.listdir(LABEL_DIR): path = os.path.join(LABEL_DIR, txt) for line_no, line in enumerate(open(path, "r"), 1): parts = line.strip().split() if len(parts) != 5: print(f"[格式错误] {txt}:{line_no} 列数={len(parts)}") continue cls_id = parts[0] vals = [float(v) for v in parts[1:]] # 归一化坐标必须落在0-1,否则训练时会被resize逻辑裁掉 if any(v < 0 or v > 1 for v in vals): print(f"[越界] {txt}:{line_no} -> {line.strip()}")逻辑说明:遍历每个txt,检查每行是否恰好五列、后四个归一化值是否都在0-1区间。越界的框在训练时会被letterbox逻辑裁剪或直接丢弃,你看不到报错,但能观察到loss和mAP对不上。这个脚本就是用来把静默错误变成明文报错的。
校验通过后再做一次可视化抽查:用cv2.rectangle把坐标画回原图,一个字符一个框,跟原xml的框对比一遍。我自己做这一步时会同时打印xml里的原始坐标和转换后的坐标,两个框如果不重合超过2个像素,就去查是不是读错了字段。
提示:如果只有少数几张图越界,可以直接在转换脚本里把坐标clamp到[0,W]和[0,H]区间再写txt,写法见第5章5.2。如果是大面积越界,先别急着改数据,回去检查size字段和真实图片分辨率是否一致。
4. 用YOLOv8训练车牌字符模型:参数设置与结果判断
4.1 模型选型:为什么字符识别优先YOLOv8n
用YOLOv8训练自己的数据集,最核心的一步其实不在模型,而在标签格式和类别映射是否一致。这个数据集的VOC标注,转换成第3章的txt之后,就是标准的yolo训练输入。
1458张图、字符级小框、几十个类别,这个规模训YOLOv8n刚好合适,YOLOv8m就有过拟合风险。选nano还有两个现实理由:一是Ultralytics生态对新手友好,data.yaml写对就能跑;二是字符识别是小目标,nano级模型在普通NVIDIA卡上能开batch 32还带得起imgsz 960,训练时间可控。
如果原图分辨率不高,比如低于720p,字符实际尺寸本来就小,yolov8n够用;如果很多车牌图是模糊或倾斜的,考虑yolov8s加一点数据增强。起步阶段不要上大模型,等baseline出来看置信度和召回率再决定要不要升级,大模型在这个数据量下多半是白烧显卡。
4.2 data.yaml与训练命令:路径、names和关键参数
先把转换后的数据组织成YOLO惯例的目录结构。常见做法是软链接或复制,不要动原始Annotations:
# 创建目录 mkdir -p dataset/images/train dataset/images/val mkdir -p dataset/labels/train dataset/labels/val # 按第2章体检时确认的划分,把图片和标签分别放进去 # 示例:train.txt里的文件名复制到train目录 while read name; do cp JPEGImages/${name}.jpg dataset/images/train/ cp labels/${name}.txt dataset/labels/train/ done < ImageSets/Main/train.txt # val.txt同样处理 while read name; do cp JPEGImages/${name}.jpg dataset/images/val/ cp labels/${name}.txt dataset/labels/val/ done < ImageSets/Main/val.txt这段命令的逻辑是:按VOC自带的ImageSets划分文件,把图片和对应的txt标签同步复制到train和val目录。注意labels目录里的txt文件名必须和图片文件名完全一致,YOLO靠这个配对,差一个字符都找不到标签。
data.yaml内容如下,path用绝对路径,names顺序必须与第3章CLASS_MAP完全一致:
path: /data/plate_dataset # 绝对路径,别写相对路径 train: images/train val: images/val names: 0: '0' 1: '1' 2: '2' 3: '3' 4: '4' 5: '5' 6: '6' 7: '7' 8: '8' 9: '9' 10: 'A' 11: 'B' 12: 'C' 13: 'D' 14: 'E' 15: 'F' 16: 'G' 17: 'H' 18: 'J' 19: 'K' 20: 'L' 21: 'M' 22: 'N' 23: 'P' 24: 'Q' 25: 'R' 26: 'S' 27: 'T' 28: 'U' 29: 'V' 30: 'W' 31: 'X' 32: 'Y' 33: 'Z'names的索引就是训练时的类别id,和第3章CLASS_MAP差一个数字,模型学出来就整体错位,而且不会有任何报错,只能靠验证集mAP异常低来发现。所以转换脚本和data.yaml最好一次性写对,改来改去容易两边不同步。
训练命令:
yolo detect train \ data=plate.yaml \ model=yolov8n.pt \ imgsz=960 \ batch=32 \ epochs=150 \ patience=25 \ workers=8 \ device=0关键参数说明:
| 参数 | 建议值 | 说明 |
|---|---|---|
| imgsz | 960 | 字符是小目标,960比640能明显提升小框召回;显存不够降到640 |
| batch | 16-32 | 受显存控制;3060及以上直接32,老卡先降imgsz |
| epochs | 100-150 | 1458张图150轮足够收敛,配合早停防止过拟合 |
| patience | 25 | val指标25轮不涨就早停,省时间 |
| workers | 4-8 | Windows下别超4,Linux可以8 |
| device | 0 | 有GPU用0,没有就删掉这行跑CPU,慢但能跑通 |
epochs这个参数很多人喜欢往大了设,其实没必要。1458张图跑150轮,正常三四个小时能出结果。前几十轮mAP没动静是正常的,字符小、类别多,模型前期主要在学怎么把字符框出来,别急着按patience停,等过了第50轮再看趋势。
4.3 验证与指标:mAP之外,重点看混淆矩阵
训练完用最佳权重验证:
yolo detect val \ data=plate.yaml \ model=runs/detect/train/weights/best.pt输出结果看mAP50和mAP50-95。字符识别场景里,mAP50-95不用太纠结,因为字符框高度相似且目标极小,这个指标天然不会高到离谱。真正要看的是Recall和混淆矩阵。
训练日志里会生成confusion_matrix.png,打开先找形近对:0/O、1/I、2/Z、7/T这类易混组合。如果混淆集中在这几对,说明模型学到的是形状特征,这是正常的,业务上靠后处理规则去纠正——车牌号每一位的合法字符范围天然能把O和0分开。
如果整体Recall上不去,先怀疑类别长尾。回第2章的类别统计,看有没有样本数少于50的字符类别。对这类字符,一是做离线复制粘贴增强,把该字符的框从原图裁出来随机贴到其他车牌图上,把低频类别样本补到100以上;二是实在补不了就合并成一个“其他字符”类,让模型只负责检测位置、不负责精确分类,字符内容交给后处理规则。不要迷信loss权重,样本太少时权重加了也只是过拟合几个固定的图。
5. 避坑指南:VOC数据训车牌字符的五个翻车点
这五个坑是我在这个数据规模下反复踩过的,前四个在训练前就能发现,第五个要等训练完看混淆矩阵才暴露。按顺序检查能省下不少试错时间。
5.1 文件名对不上:训练时大量图片对应不到标签
现象:启动训练后日志里不断刷warning,警告找不到某张图的标签,数据加载数量比预期少了一大截;或者转换脚本跑完,labels目录里只有800个txt,而图片有1458张。
原因:数据集在搬运过程中有的xml被改名、有的图片被重复,Annotations和JPEGImages目录无法一一对应。如果没做第2章的配对检查就直接转格式,所有缺xml的图片都会被静默跳过。
解决:先跑第2章的配对脚本,把两个差集列出来。差集里文件少于30张的,人工看一遍补xml或补图;数量很大就直接找资源方确认包是否完整。配对干净之后再转换,训练时宁可把没标签的图剔出去,也不要留在images目录里让训练器反复告警。
5.2 坐标越界:框超出图片边界
现象:训练过程中loss偶尔出现一个极端尖峰,或者验证时某些预测框整框落在画面之外。检查labels目录里的txt,发现有的值大于1.0,有的小于0。
原因:标注工具允许画框时略微超出图片边缘,xml里记录了xmax大于W或ymin小于0的坐标。这类非法框在VOC工具链里能正常显示,但YOLO训练器的letterbox会把它们裁掉或产生错位。
解决:转换脚本中对坐标做一次clamp,把越界的边拉回图片边界:
W = int(size.findtext('width')) H = int(size.findtext('height')) xmin = max(0, int(box.findtext('xmin'))) ymin = max(0, int(box.findtext('ymin'))) xmax = min(W, int(box.findtext('xmax'))) ymax = min(H, int(box.findtext('ymax')))如果clamp之后框的宽或高小于等于1像素,说明原始标注本身就错了,直接跳过这个对象并在日志里输出xml名。另外提醒:如果大面积越界,先检查xml里的size和真实图片分辨率是否一致,很多越界其实是size写错了。
5.3 形近字符混标:0与O、1与I在同类别里互相污染
现象:模型训练完,mAP50看着有0.9,但标注人员肉眼复核发现,同一张图上同一个形状的字符一会儿被标成0一会儿被标成O,预测结果也在两个类别间来回跳。
原因:车牌字符识别任务的形近字符(0/O、1/I、2/Z)形状几乎一样,数据集构建时没统一标注口径,或者多人标注各按各的理解。这类误差在图像层面无解,因为模型看到的就是同一个形状。
解决:先统计类别频次,看0/1/2和O/I/Z是不是同时出现。然后统一口径:中国车牌规则里I和O通常不会出现在字符位,可以直接删掉这两个类别并把样本归到1和0;或者更简单,在CLASS_MAP里把O和0映射成同一个id。二选一,取决于业务输出需要。在转换阶段合并类别是成本最低的方案,后处理阶段再按车牌位数规则恢复含义。
5.4 train/val划分泄漏:同一辆车出现在两个集合里
现象:训练时val的mAP50-95高达0.93,感觉很美好;一上真实场景测试掉到0.7,而且错误集中在同一辆车的不同角度图上。
原因:VOC自带的ImageSets/Main如果只是简单顺序切分,同一辆车不同时刻拍的照片可能被分到train和val两侧。字符识别模型的泛化能力本来就集中在形状,车一旦跨了集合,模型相当于“见过”这份验证题的答案。
解决:自己重新划分。这个数据集的图片文件名如果带车辆或场景前缀,按前缀分组再划分;没带前缀就按文件名哈希分桶,确保同一个前缀只进一个集合。用sklearn的GroupKFold按文件名前缀分组做交叉验证,是这类任务的标准做法。从那之后,我拿到任何带划分的标注数据都不会直接信任官方划分,先自己分一遍。
5.5 低频字符类别被模型放弃
现象:混淆矩阵某一列几乎全黑,某个字符在所有验证图里的召回率是0;训练日志里这个类别的instance数量比其他类少一个数量级。
原因:类别长尾。1458张图摊到几十个字符类别上,有的类别只出现一二十次,训练时贡献的loss占比太小,模型倾向输出高频类。低频类不是学不会,是压根没被学到。
解决:先看2.2的类别统计,找出样本数少于50的类别。对这类字符,一是做离线复制粘贴增强,把该字符的框裁出来随机贴到其他车牌图上,把样本补到100以上;二是实在补不了,就合并进一个“其他字符”类,让模型只负责检测位置、不负责精确分类,字符内容交给后处理规则。不要指望调loss权重能解决,样本太少时权重加了也只是过拟合几个固定的框。
6. 进阶:字符框拼接成车牌号,以及一个避免验证失真的划分习惯
6.1 从检测结果到车牌字符串:排序与后处理规则
模型输出是一堆字符框和类别概率,要变成车牌字符串,还差一个后处理。车牌字符天然从左到右排列,所以流程固定为:置信度过滤、按x坐标排序、重叠框去重、拼接。
| 步骤 | 做法 | 说明 |
|---|---|---|
| 置信度过滤 | 保留conf大于0.5的框 | 字符框面积小,阈值太低会混入背景误检 |
| 水平排序 | 按框中心x坐标升序排序 | 忽略y方向的微小抖动,只按x排 |
| 重叠去重 | IOU大于0.6的框只保留conf最高的 | 模型偶尔对同一字符输出双框 |
| 拼接输出 | 按排序顺序拼接类别字符串 | 得到不含分隔符的车牌文本 |
实际操作时,我会把每张图的检测结果按行读出来,先过滤conf,再按x_center排序,逐对计算IOU做去重,最后join成字符串。排序这一步有个细节:如果车牌省份简称在左边,它的x_center就是最小的,但字符识别数据集往往只包含数字和字母,省份简称是否出现取决于你训练时有没有纳入这个类,拼接时别写死位置。
6.2 划分习惯:同一个车牌别跨训练/验证集
最后分享一个让我少返工两次的习惯。车牌字符数据集中,同一辆车经常有不同角度、不同光照的多张图,如果train和val里同时出现这辆车的图,验证分数就会虚高到失真。我现在拿到数据集,先看文件名前缀,凡是同一辆车(通常是文件名里同一段ID)的图,按车ID做group维度的划分;数据少就直接按车ID做group shuffle一分为二,数据多就按车ID做KFold。
这个习惯最早是被5.4那个坑教训之后养成的。从那以后,我每次拿到字符级标注的车牌数据集,都强制先跑一遍数据体检脚本,再做按车ID的划分,最后才写训练命令,顺序从来没变过。数据集的坑几乎都藏在xml和文件名里,模型反而没啥玄学,按着这个流程走完,1458张图就能复现出一份能用的字符识别权重。希望帮到你。
本文还有配套的精品资源,点击获取