☰
中国车牌识别数据集1458张VOC标注,车牌检测与OCR实战指南
2026/10/1 18:47:45 网站建设 项目流程

简介:面向目标检测与OCR车牌识别开发者的中文车牌识别数据集,适用于算法入门与工程落地,提供1458张已标注车牌图像。图像标注采用VOC格式,每张原图对应XML标注文件,包含车牌位置框及数字和字母类别信息,可直接接入YOLO、Faster R-CNN等常见检测框架,用于车牌定位、字符识别模型的训练和验证。压缩包为zip格式,整体大小19.49MB,解压后即可获得规范标注的数据,便于直接搭建训练流程。数据覆盖多种车牌类型、不同拍摄角度与光照条件,可帮助算法工程师、研究生完成车牌检测及字符识别相关的项目实践、算法迭代与效果对比,并比较不同网络结构下的识别性能。目前已有683人学习下载,适合需要真实场景数据开展模型测试、课程设计或毕业设计的开发者使用。

1. 中国车辆车牌号识别数据集:1458张VOC标记图,能撑起车牌检测与OCR两条任务线

做车牌识别项目时,最花时间的往往不是模型选型,而是标注数据。这份中国车辆车牌号识别数据集把车牌号和数字字母的标记直接做到了VOC格式,1458张标记图片解压即用,不用自己拉网找标注工具重新标一遍。它适合两类人:一是想快速跑通车辆或车牌检测流程的初学者,二是有自己的摄像头数据、需要先在一个干净数据集上调通完整训练管线再迁移到业务场景的开发者。

很多人拿到手先问“1458张够不够”。我的判断是:做车牌整体检测,它足够验证一条完整训练链路;做数字和字母的字符级识别,把标注框单独抽出来当字符切分的先验也顺手。别拿它和万级大规模数据集比,要把它当成一个标得干净、格式统一、能直接进训练脚本的数据起点。下面从目录结构开始拆,一步步把这份资源变成能运行的检测模型。

2. 读懂VOC标注:目录结构、XML坐标与标注字段的完整拆解

2.1 拿到压缩包后先做三件事

第一件事是解压并核对数量。VOC格式的常见组织方式是图片放在 JPEGImages,标注放在 Annotations,但也有人把图片和标注混在同一目录。先列目录,再统计 jpg 和 xml 数量,确认 1458 张标记图片这个说法是否成立。

unzip 中国车辆车牌号识别数据集.zip -d car_plate_dataset cd car_plate_dataset find . -type f -name "*.jpg" | wc -l find . -type f -name "*.xml" | wc -l

逻辑说明:unzip -d 指定解压目标目录;find 结合 -name 只匹配指定扩展名;wc -l 统计行数,也就是文件数。这一步能快速暴露两个问题:图片和标注数量不相等,说明有图片漏标或标注多余;数量对不上时就别急着训练,先回头补数据。

参数说明:-d 后面的目录名可以按自己习惯改。如果压缩包是分卷 zip,需要先合并再解压,这里不展开。

第二件事是随机抽几个 XML 用文本编辑器打开,先看 bndbox 落在图像的什么位置,再确认框里包的是整块车牌还是单个字符。第三件事是统计 name 字段都有哪些值。这一步直接决定了你后面走的是“整体车牌检测”还是“字符级识别”路线。对这份资源来说,name 如果直接是车牌号字符串,说明每个框同时包含了“哪里是车牌”和“这车牌是什么”两层信息,非常值钱。

一个常见的理解误区是“VOC格式就是XML”。其实VOC标注的价值在于它保留了目标类别、位置和难易标记三层结构,很多标注工具比如 labelImg 导出的就是这种格式。既然这份数据集支持VOC格式标记,就意味着你可以直接拿 labelImg 打开它继续补充标注,不需要重新做一个标注工具。

2.2 一个典型XML标注的字段逐行拆解

以下是这份资源里最常见的单目标XML:

<annotation> <folder>car_plate</folder> <filename>plate_0001.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>京A12345</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>864</xmin> <ymin>412</ymin> <xmax>1064</xmax> <ymax>472</ymax> </bndbox> </object> </annotation>

逐个字段说明:

  • folder:打包者写入的目录名,不影响训练,但转换脚本如果硬编码了路径要留意。
  • filename:图片文件名。转换脚本必须以这个字段为准去拼接图片路径,而不是用列表下标去猜。
  • size/width 和 size/height:真实图像尺寸,也是后面归一化坐标的分母。如果原图被压缩过而 xml 没更新,坐标会整体偏掉。
  • object/name:类别名。如果 name 直接就是车牌号字符串,说明这是一个既带检测框又带字符语义的标注。
  • bndbox:框的左上角 xmin、ymin 和右下角 xmax、ymax,单位是像素。坐标可以超出图像范围,标注工具手滑时就会这样,清洗阶段要处理。
  • difficult:表示该目标是否难以辨认,值为 1 时很多框架默认不参与训练。如果原始标注里大量 difficult=1,那可能是模糊样本被正确标记了,这时不要一味删除,反而可以用作难例挖掘。

这份资源的一个细节在于“可识别车牌数字和字母”。如果 name 是完整车牌串,那么框内字符就是车牌号本身,检测下游可以直接接字符分类器;如果 name 统一是 plate,那就只适合做整体检测。

2.3 用OpenCV把标注框画回图上,做可视化体检

拿到任何数据集我都有个固定动作:把标注框画回原图,保存成一张拼图。这个过程几行代码就能完成,但能避免后面训练一路翻车。

import cv2 import xml.etree.ElementTree as ET import os xml_dir = "car_plate_dataset/Annotations" img_dir = "car_plate_dataset/JPEGImages" for idx, xml_file in enumerate(os.listdir(xml_dir)[:16]): xml_path = os.path.join(xml_dir, xml_file) tree = ET.parse(xml_path) root = tree.getroot() img_name = root.find("filename").text img = cv2.imread(os.path.join(img_dir, img_name)) if img is None: print(f"image missing: {img_name}") continue for obj in root.findall("object"): name = obj.find("name").text.strip() box = obj.find("bndbox") xmin = int(box.find("xmin").text) ymin = int(box.find("ymin").text) xmax = int(box.find("xmax").text) ymax = int(box.find("ymax").text) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, name, (xmin, max(0, ymin - 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imwrite(f"check_{idx:02d}.jpg", img) print("check done")

逻辑说明:遍历 Annotations 下前 16 个 XML,用 filename 找图像并读进来;对每一个 object 的 bndbox 画矩形,再把类别名画在框上方。这里直接使用变量名 name 表示类别文本;如果 XML 里的 name 是车牌号字符串,打印到图上后一眼就能核对“框是否框住整个车牌、字符标签是否正确”。

参数说明:切片 [:16] 只抽查 16 张,实测建议抽查到 50 张以上;rectangle 线宽在 1920 宽的大图上用 2,在小图上用 1;putText 的位置要防止 ymin 小于字体高度导致文字画出图像外,所以我加了 max(0, ymin-10)。如果不做这个保护,图像顶部的框会让 putText 直接坐标越界,虽然 OpenCV 不崩溃但会丢失文字。

可视化体检时重点看三类样本:

  1. 框和车牌边缘的贴合度:如果框边离车牌字符太近,说明标注把字符裁了一部分,后续 OCR 准确率会受影响。
  2. 同一张图多个框的情况:如果有两个 bndbox 离得很近且重叠,可能是同一块车牌的重复标注,训练时会产生歧义。
  3. 车牌类型分布:蓝牌、绿牌、黄牌是否有足够覆盖,这一步直接影响泛化能力。

这一章的结论是:别急着训练,先把标注看明白。目录结构、XML 字段和可视化三关都过一遍,数据集真实质量你就心里有数了。

3. 把VOC转成YOLO训练集:转换脚本、参数说明与四个边界坑

3.1 为什么要转格式:VOC与YOLO标注的空间差异

VOC 给的是像素坐标系下的左上角与右下角,YOLO 系列要求的是归一化后的中心点坐标与宽高。换算关系固定:

x_center = (xmin + xmax) / 2 / img_w y_center = (ymin + ymax) / 2 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h

这四行是把 XML 变成可训练标签的关键。归一化的好处是同一份标注可以在不同输入分辨率下复用;模型读入图片时会按 data.yaml 配置的 imgsz 做缩放,如果保留像素坐标,缩放后框位就全错位了。

所以转换脚本不只是格式翻译,它同时承担坐标校准和类别映射两层职责。下面给一个可直接落地的版本。

3.2 转换脚本:从XML目录到txt标签目录

import os import xml.etree.ElementTree as ET IMG_DIR = "car_plate_dataset/JPEGImages" XML_DIR = "car_plate_dataset/Annotations" OUT_DIR = "car_plate_dataset/labels" os.makedirs(OUT_DIR, exist_ok=True) def xml_to_yolo_line(obj, img_w, img_h, class_id): box = obj.find("bndbox") xmin = int(box.find("xmin").text) ymin = int(box.find("ymin").text) xmax = int(box.find("xmax").text) ymax = int(box.find("ymax").text) # 坐标裁剪到图像范围内 xmin = max(0, min(xmin, img_w)) xmax = max(0, min(xmax, img_w)) ymin = max(0, min(ymin, img_h)) ymax = max(0, min(ymax, img_h)) x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h return f"{class_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}" for xml_file in os.listdir(XML_DIR): if not xml_file.endswith(".xml"): continue tree = ET.parse(os.path.join(XML_DIR, xml_file)) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) txt_name = xml_file.replace(".xml", ".txt") lines = [] for obj in root.findall("object"): lines.append(xml_to_yolo_line(obj, img_w, img_h, 0)) with open(os.path.join(OUT_DIR, txt_name), "w") as f: f.write("\n".join(lines) + "\n") print(f"done, {len(os.listdir(XML_DIR))} xml files")

逻辑说明:先确保输出目录存在,再对每个 XML 解析宽高;xml_to_yolo_line 函数负责把单个 object 转成一行 txt。函数内部先做一次坐标裁剪,把越界坐标拉回图像范围内,然后按公式归一化并保留 6 位小数。这里 class_id 固定传 0,是“整体车牌检测”的常用做法。

参数说明:如果你不是做整体检测而是做字符级识别,class_id 就不能固定 0。需要先收集所有 name 并建立映射表,把每个字符或每个车牌串映射成递增数字。用 ultralytics YOLOv8 训练时,txt 第一列必须是整数 id,不能直接写中文字符串,这点要先想清楚再动手。

我常用的是先收集全部 name,排序后生成 classes.txt,再回写进 txt:

names = [] for xml_file in os.listdir(XML_DIR): root = ET.parse(os.path.join(XML_DIR, xml_file)).getroot() for obj in root.findall("object"): names.append(obj.find("name").text.strip()) unique_names = sorted(set(names)) name2id = {n: i for i, n in enumerate(unique_names)} with open("car_plate_dataset/classes.txt", "w") as f: f.write("\n".join(unique_names)) print(name2id)

逻辑说明:set 去重后必须排序,保证类别顺序稳定,不能依赖集合的随机顺序,否则每次运行类别 id 都不一样,训练与推理就会错位。classes.txt 就是后续 data.yaml 里 names 的蓝本。

提示:如果只做车牌检测,更省事的办法是把所有 name 归并成 plate 一个类别,转换时 class_id 写 0。归并前先统计一遍原始 name,确认没有其他类别混杂。

3.3 四个边界坑

我在转这类车牌数据集时,稳定会遇到四个坑。

坑一:filename 与文件名不一致。有的 XML 里 filename 写的是 plate_0001.jpg,但图片实际叫 0001.jpg,转换脚本按 IMG_DIR + filename 拼接就会全部找不到图。解决:转换时打印失败图片路径;另一个做法是忽略 filename 字段,改用 xml 文件名转成 jpg 名再去找图片,但前提是先确认一一对应。

坑二:size 字段与真实图像宽高不一致。有些标注工具在图像被 resize 后没有更新 XML。解决:转换前批量读取图片宽高,和 XML 对照,不一致就以图片实际宽高为准重新计算。

坑三:空 object。部分 XML 没有任何 object 节点,转换出来是空 txt。空 txt 训练时会被当作背景图,少量问题不大,大面积出现就会让模型过度往背景方向优化。解决:把空 xml 统计打印出来,人工确认是漏标还是本来就是背景图。漏标就补标,补不了就放进 val 里当负样本。

坑四:多类别但转换时写成固定 0。如果数据里包含不同车牌类型或不同字符串,全部归 0 会丢失类别信息;反过来,如果没归并而某个 name 带了换行符,它会变成一个独立类别,模型输出类别数量瞬间膨胀。解决:先统一任务,再决定类别映射。只做检测就归并为 plate;要做字符识别,就按字符拆分并按映射表写 id。

3.4 转换结果的批量校验

转出来的 txt 必须先过一遍质量检查。一个实用脚本:

import os label_dir = "car_plate_dataset/labels" for txt in os.listdir(label_dir): path = os.path.join(label_dir, txt) for line in open(path): parts = line.split() if len(parts) != 5: print(f"bad line in {txt}: {line}") break vals = [float(p) for p in parts[1:]] if any(v < 0 or v > 1 for v in vals): print(f"out of range in {txt}: {line}") break else: continue break print("validation finished")

逻辑说明:对每个 txt 逐行读取,要求每行恰有 5 个字段,即 class_id x_center y_center w h;再检查归一化后的坐标是否都在 0 到 1 之间。任何越界值都说明坐标或宽高计算有问题,要回到转换脚本排查。

参数说明:如果发现大量越界,优先检查 img_w 和 img_h 是否在转换前被正确赋值。常见错误是图片是竖图,XML 里却记录了横图的宽高,归一化之后 y_center 直接超过 1。

4. 跑通车牌检测训练:数据划分、YOLOv8配置与训练命令

4.1 数据划分:决定你的mAP有没有水分

划分不只在训练集与验证集之间,而是 train/val/test 三层。1458 张实在不多,我一般按 8:1:1 切,折下来大约 1166 张训练、146 张验证、146 张测试。留独立测试集,是因为车牌识别容易过拟合,没有独立测试集看到的 mAP 会偏乐观。

import os import random image_dir = "car_plate_dataset/JPEGImages" images = [f for f in os.listdir(image_dir) if f.endswith(".jpg")] random.seed(666) random.shuffle(images) total = len(images) val_n = int(total * 0.1) test_n = int(total * 0.1) val_files = images[:val_n] test_files = images[val_n:val_n + test_n] train_files = images[val_n + test_n:] for split_name, split_files in [("train", train_files), ("val", val_files), ("test", test_files)]: with open(f"car_plate_dataset/{split_name}.txt", "w") as f: for file_name in split_files: f.write(os.path.join(image_dir, file_name) + "\n")

逻辑说明:先把所有 jpg 读入并打乱,再按比例截取三份。写出的 txt 每一行是完整图片路径,YOLO 直接按行读取。没设随机种子的话,每次运行顺序都不同,前后两次实验无法对比,所以 seed 必须固定。

参数说明:seed 写成 666 只是为了可复现,你也可以用具体日期当种子。val_n 和 test_n 都是 10%,如果觉得验证集太少,改成 15% 也是普遍做法,代价是训练集少几十张。

更严谨的做法是按前缀分组。车牌图通常来自几段视频,同一段视频的图片编号前缀一致。改成按前缀分组:

groups = {} for f in images: prefix = f.rsplit("_", 1)[0] # 按最后一个下划线切 groups.setdefault(prefix, []).append(f) group_keys = list(groups.keys()) random.shuffle(group_keys)

逻辑说明:这是防止数据泄漏的关键。如果连续帧里的同一辆车同时出现在训练集和验证集,验证分数完全失真,部署到真实停车场视频时立刻现原形。

4.2 data.yaml:类别顺序是命门

YOLOv8 需要一份 data.yaml。这里给出适配该数据集的配置:

path: /absolute/path/to/car_plate_dataset train: train.txt val: val.txt test: test.txt names: 0: plate

如果转换脚本把 name 统一成了 0,names 里只写 plate 一行。如果保留了原始车牌号字符串作为类别,这里要按 classes.txt 里的顺序原样展开,第 0 行对应第一类,第 1 行对应第二类,顺序错一位就是全盘错。

注意:data.yaml 不要用制表符缩进,YOLO 的解析器只认空格。path 建议写成绝对路径,避免不同机器上相对路径解析不一致。

4.3 训练命令与参数详解

yolo detect train \ data=car_plate_dataset/data.yaml \ model=yolov8n.pt \ imgsz=640 \ epochs=100 \ batch=16 \ device=0 \ project=runs/detect \ name=plate_exp \ patience=20

逻辑说明:model 用 yolov8n.pt 作为预训练起点,而不是随机初始化;patience=20 表示验证集 20 轮没有提升就早停,防止 1458 张小数据在后期发生过拟合。yolov8 训练默认会自动下载预训练权重,网络慢时容易卡住,建议提前手动下载 yolov8n.pt 放到工作目录,命令里写本地文件名。

参数说明:

  • imgsz:输入分辨率,车牌是中等尺寸目标,640 是合理起点。提到 960 显存占用约涨 2.25 倍,batch 要相应下调。
  • batch:16 对 Nano 级模型比较合理,实测单张 6G 显存也能跑;如果 OOM,降到 8 或 4。
  • epochs:100 不是定律,先按 100 跑,日志里看到 val 指标还在涨就续跑。
  • device:多卡可以写 0,1,小数据集单卡更稳。

4.4 训练输出怎么读

训练结束后的关键产出是 runs/detect/plate_exp/weights/best.pt 与 last.pt。results.png 里的字段需要逐个看:

字段含义我关注什么
train/box_loss训练边框损失是否持续下降
val/box_loss验证边框损失先降后升就是过拟合
metrics/precision检出的框中真正含车牌的比例误检是否严重
metrics/recall真实车牌中被召回的比例漏检是否严重
metrics/mAP50IoU=0.5 时的平均精度能否稳定在 0.85 以上
metrics/mAP50-95多个 IoU 阈值的平均精度更严格,虚高更少

对 1458 张这种小数据集,合理预期是 mAP50 不低于 0.85。如果明显低于这个值,先回头检查转换脚本归一化是否出错,再把 epochs 调到 150。loss 曲线前期波动大属于常见玄学现场,主要看整体趋势,不要被单轮反弹带偏。

训练完用命令验证单图:

yolo detect predict \ model=runs/detect/plate_exp/weights/best.pt \ source=car_plate_dataset/JPEGImages/plate_0001.jpg \ conf=0.25 \ save=True

save=True 会把画了框的推断图保存到 runs/detect/predict 目录。逐张看预测图能发现哪些框是漏检、哪些是误检。低光照图把阴影误判成车牌是高频问题,这时调高 conf,或者在增强里加入亮度扰动。

5. 避坑与排查:车牌数据集实战里最常见的五个翻车现场

5.1 训练顺利但预测框全空

现象:训练 loss 正常下降,val mAP 也不差,但 predict 一跑,一张图都检测不出车牌。

原因:最常见是数据泄漏导致 val 虚高,训练和验证里出现同一来源图片,模型记住了而不是学到,推理时面对真实现场就失效。另一个高频原因是 conf 阈值太高。

解决:先降低 conf 到 0.1 再预测,如果依然全空说明模型确实没学到车牌特征,回到数据集查类别 id 映射;若只是 conf 不够,调回 0.25 即可。更重要的是检查 train.txt 和 val.txt 里是否有相同文件名,同一车牌出现在两个集合里时必须重新划分。

5.2 XML里name带着换行导致类别数量爆炸

现象:打印 classes.txt 时出现很多带反斜杠的类别,或者明明只有一个类别,模型却预测出几十种类别名。

原因:name 字段首尾有换行或空格,转换时没做 strip,去重之后每种空白变体都成了一个类别。

解决:在转换脚本里统一处理:

name = "".join(name.split()) # 去掉所有空白字符

这个处理必须在转换前做。转换后再处理就晚了,txt 里的类别 id 已经按错误类别生成。

5.3 bndbox坐标出界引发的nan

现象:训练日志出现 nan 损失,或者 loss 直接变成 inf;查看 labels 目录,txt 里出现负数坐标,有时 x_center 大于 1。

原因:XML 标注时框拖出了图像边界,转换脚本没有做 clip;归一化分母如果又取错,负值更明显。

解决:转换时对坐标做 clip,同时校验图片真实宽高。写一个异步检查脚本,凡 XML 里 size 和图片实际尺寸不一致的一律列入异常清单,人工修订后再进训练集。

5.4 绿牌识别差到离谱

现象:蓝牌 mAP 很高,绿牌基本漏检。中国车牌里有新能源绿牌,如果数据集中绿牌占比很低,模型天然会把所有车牌都按蓝牌特征来识别。

原因:小数据集上的类别不均衡。检测模型学到的是颜色与特征的强相关,绿色样本太少,颜色支路就把绿牌当背景。

解决:给绿牌样本做针对性增强。ultralytics 自带的 hsv_h、hsv_s 参数默认值分别为 0.015 和 0.7,已经能缓解大部分颜色敏感问题。如果还不够,用 albumentations 额外加 HueSaturationValue,hue_shift_limit 设为 20。注意不要把整张图调成黑白,那样会连反光特征一起丢掉。

注意:HSV 增强只能缓解颜色偏差,不能替代补充真实绿牌样本。条件允许时,尽量多收几种光照下的新能源车牌。

5.5 同一辆车同时出现在训练集和验证集

现象:val mAP 接近 1.0,但拿到停车场新拍画面完全崩盘。

原因:划分时按图片随机切,同一辆车的连续帧被分到两个集合里。车还是那辆车,光照角度只是微变,模型等于在开卷考试。

解决:按拍摄片段或车牌唯一标识分组,整组划入同一个集合。没有场景标识时,按文件名前缀分组是可靠的近似做法。这也是 4.1 里为什么要把前缀分组单独拿出来写的原因。

6. 进阶验证:把检测框接到字符识别上,顺带做数据增强

6.1 用检测框驱动数字字母识别

如果 name 直接是车牌号字符串,这份资源的检测框能承担更重要的角色:训练完车牌检测之后,把预测框裁剪出来,接一个轻量 OCR 模型,实测能完成“检测车牌 → 识别数字和字母”的完整链路。这个做法最妙的地方是反向检验检测框质量:OCR 识别准,说明框贴得正;OCR 识别乱,说明框带着背景,甚至只框住半个字符。

一个可视化验证脚本骨架:

import cv2 from ultralytics import YOLO model = YOLO("runs/detect/plate_exp/weights/best.pt") img = cv2.imread("car_plate_dataset/JPEGImages/plate_0088.jpg") results = model.predict(img, conf=0.25) for r in results: for box in r.boxes.xyxy.cpu().numpy(): x1, y1, x2, y2 = [int(v) for v in box] crop = img[y1:y2, x1:x2] cv2.imwrite("plate_crop.jpg", crop) # 后续把 crop 交给车牌OCR模型识别数字字母

逻辑说明:用训练好的 best.pt 预测单张图,拿到框坐标后裁剪出车牌区域。后续接 OCR 时,只要识别结果和 XML 中 name 对得上,就说明检测框质量过关。

参数说明:conf=0.25 是常见默认置信度;如果框太小导致剪裁图模糊,把 imgsz 提到 960 重新预测,同一张图的框会更完整。

6.2 数据增强与验证习惯

数据增强方面,我常用两个提高泛化的招数。一是随机透视变换,模拟车辆从不同角度驶过摄像头画面;二是把裁剪出的车牌随机错位拼回新背景,增加背景多样性。两种操作都必须对标签同步做相同的变换,否则车牌框会错位。

从那以后,我拿到任何新数据集都强制走一遍同样流程:先可视化核对、再统一检测目标类别、再划分三个集合、最后才跑训练。顺序不乱,项目就不会白跑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询