
简介面向计算机视觉入门与实战的中国车牌识别数据集内含1458张已标注车牌图片标注采用VOC格式覆盖数字与字母适用于目标检测、OCR识别等模型的训练与算法验证也可服务停车场管理、智能交通等场景适合深度学习初学者及项目开发者。资源以zip压缩包形式提供整体约19.49MB包含图片文件及对应的XML标注文件可直接用于YOLO、SSD等检测模型训练或按需转换为其他标注格式。目前已有683人学习下载数据规模适中且标注规范能帮助用户快速建立训练集。通过该数据集用户无需从零采集与整理数据可集中精力完成模型搭建、参数调优与效果评估同时可用于对比不同车牌识别算法的准确性与鲁棒性是入门车牌识别任务的高性价比练习资料。1. 这个车牌识别数据集为什么值得先跑一遍做车牌识别训练时最缺的往往不是网络结构而是一批标记干净的“中国车辆车牌号识别数据集”。这个 zip 压缩包里放着 1458 张带有 VOC 格式标记的图片目标是让模型先学会定位画面里的车牌再配合 OCR 模块读出上面的数字和字母。我在实际项目里见过不少把 VOC 标记当万能钥匙翻车的例子有人拿到 XML 就开始跑训练结果发现类别名不统一、坐标越界、train/val 划分混乱最后模型在真实路口一张都认不出来。所以这篇笔记的重点不是夸这个数据集有多大而是告诉你拿到 zip 之后先做什么再做什么。VOC 格式能保存目标框和类别但它并不能直接告诉模型“这是车牌上的数字 7 还是 B”。你真正要做的是把问题拆成两步先用检测框找到车牌再对裁剪出来的车牌区域做字符识别。适合的读者是刚起步做车牌识别、需要一份可以直接导入训练脚本的干净数据的开发者如果你手上已经有大规模私有数据这份 1458 张标记图更适合用来跑通流程和搭建验证体系。2. 拆开 ZIP 看 VOC 结构1458 张图片的标记到底存了什么拿到 zip 先别急着解压后直接扔进训练脚本。我一般会先确认目录结构再写一个解析器看标记内容。VOC 格式在车牌识别任务里有两个容易被误读的点一是 XML 里存的是目标所在位置不是车牌上的字符二是图片和 XML 文件靠文件名对应没有一一对应时训练脚本会静默跳数据。常见的数据集目录结构像这样dataset/ ├── JPEGImages/ │ ├── 0001.jpg │ ├── 0002.jpg │ └── ... ├── Annotations/ │ ├── 0001.xml │ ├── 0002.xml │ └── ... └── ImageSets/ └── Main/ └── train.txtJPEGImages 是原图Annotations 是 VOC 标记文件ImageSets/Main 里的 txt 文件习惯用来说明哪些图片进训练、哪些进验证。很多压缩包不附带 ImageSets需要自己划分这部分会放到第 3 章处理。zip 解压后第一步一定是看文件总数确认 JPEG 和 XML 是否都能一一对上否则后续转格式时会漏掉图片而不自知。2.1 JPEGImages 与 AnnotationsXML 里存的不是字符是坐标VOC 格式的标记文件是 XML最典型的字段组合是 annotation 下的 object。每个 object 代表一张图里的一个目标车牌识别里通常一个目标就是一块车牌。object 内部有 name 表示类别bndbox 里面有 xmin、ymin、xmax、ymax 四个整数分别表示车牌框的左边界、上边界、右边界、下边界坐标。关键点是“坐标是像素值”不是归一化比例也不是字符内容。很多第一次接触 VOC 格式的人会去 XML 里找车牌上的字符比如“沪A12345”结果找不到。这不是数据不完整而是 VOC 本身定位为“目标检测格式”目标检测只需要回答“东西在哪”字符识别是另一个任务。等到第 6 章用 OCR 回读你才真正拿到数字和字母。所以我建议拿到这个数据集之后先把 XML 当成一个标准目标框文件来使用不要指望它替你把车牌字符一起标注好。字段也很重要。difficult1 表示这张牌比较难认可能是极小、严重遮挡或角度倾斜。训练车牌识别模型时我通常不会把 difficult 全部过滤因为真实卡口场景里大量图片就是倾斜、模糊、带遮挡的全部过滤会让模型在实战中明显退化。2.2 用 Python 读 VOC XML把标记解析成可统计的字典为了确认这 1458 张图片的标记质量我会先写一个解析脚本把 XML 转成 Python 字典。这样能快速看每张图有多少块车牌、框有多大、类别是不是统一。import xml.etree.ElementTree as ET def parse_voc(xml_path): tree ET.parse(xml_path) root tree.getroot() result { image_name: root.findtext(filename), width: int(root.findtext(size/width)), height: int(root.findtext(size/height)), objects: [] } for obj in root.findall(object): name obj.findtext(name) difficult int(obj.findtext(difficult, 0)) truncated int(obj.findtext(truncated, 0)) box obj.find(bndbox) result[objects].append({ class: name, difficult: difficult, truncated: truncated, bbox: [ int(box.findtext(xmin)), int(box.findtext(ymin)), int(box.findtext(xmax)), int(box.findtext(ymax)) ] }) return result demo parse_voc(Annotations/0001.xml) print(demo[image_name], demo[width], demo[height]) for obj in demo[objects]: print(obj[class], obj[bbox])逻辑说明这段代码故意把 bbox 从 XML 的字符串转成 int因为后面转换 YOLO 格式时要用整数坐标做归一化运算。findtext 在字段缺失时返回 Noneint(None) 会抛异常所以我在 difficult 和 truncated 字段上给了默认值“0”。如果训练脚本需要严格区分难例这里不要给默认值直接留空等报错也是一种标记检查方式。参数说明width 和 height 来自 size 子节点读的是原图尺寸不是车牌框尺寸。如果原图尺寸缺失说明 XML 不完整后续转换 YOLO 坐标时根本无法归一化应跳过该文件。bbox 的顺序固定为 xmin、ymin、xmax、ymax千万别调换YOLO 坐标换算公式依赖这个顺序。2.3 理解 object 里的字段类别、难例、截断与边界框VOC 的 name 字段在车牌数据集里可能出现多种写法plate、car、license、car_plate。如果看到不止一种类别名就要在转换前统一映射否则训练脚本会把它们当多类目标。目标检测领域总说“模型能识别车牌数字和字母”事实上 VOC 标记的类别名只会告诉模型这是不是车牌不会告诉它是 7 还是 B。数字和字母的区分能力来自后面 OCR 部分的字符分类器。truncated 表示目标被图片边界截断。车牌被裁掉一半时如果强行用它训练框的坐标会包含虚假的“完整车牌”语义。常见做法是保留截断样本但放大边界框约 5%让模型学习部分车牌到远处车牌的过渡而不是非要把半个车牌拟合成一个完整矩形。这也是我建议在解析脚本里同时输出 difficult 和 truncated 的原因。边界框的数值还会暴露另一个问题如果 xmin 0 或 ymin 0说明标记工作者把框拉到了图片外面。这类样本在目标检测训练中会让模型学到一个错误的“车牌中心点”影响很大的反而是边缘回归。遇到这种情况直接截断到 0 到 宽/高 范围或者干脆丢掉这个样本。3. 把 VOC 车牌标记转成 YOLO 格式从 1458 张到可用 txt 标签现在主流的检测器特别是 yolov8 训练自己的数据集时更习惯读 YOLO 风格的 txt 标签。VOC 转 YOLO 是绕不开的一步每个 XML 要转成一个同名 txt 文件每一行对应一个车牌框格式是“类别id 中心点x 中心点y 框宽 框高”。这个转换不复杂但坑很多常见的是整数除法导致所有坐标变成 0以及 train/val 划分时把同一段视频的连续帧拆到两边。3.1 VOC 坐标与 YOLO 坐标的换算关系VOC 里记录了 xmin、ymin、xmax、ymax这四个值都在原图坐标系里。YOLO 格式要求换成归一化中心点坐标和宽高cx (xmin xmax) / 2 / image_width cy (ymin ymax) / 2 / image_height box_width (xmax - xmin) / image_width box_height (ymax - ymin) / image_height因为 YOLO 标签不关心原图尺寸它只关心目标中心在整张图上的相对位置以及目标相对整张图的宽高比例。这样做的好处是无论训练图缩放到 640 还是 1280标签都不需要重新计算。容易出错的地方有两处。第一xmin、xmax 是整数如果不除以 2 之前先转成 floatPython 3 里整数除法已经是浮点数但 Python 2 或某些脚本里会自动截断导致中心点偏左好几个像素。第二w 和 h 必须用 float(image_width) 或 float(image_height) 去除否则结果被截断成 0训练脚本就会直接忽略这个目标。我习惯写出全部 float 转换省得旧环境里翻车。3.2 批量转换与 train/val 划分代码下面这段脚本可以一次性把 Annotations 下的 XML 全部转成 labels 下的 txt同时按 9:1 划分训练集和验证集。类别名统一映射到 0 类因为车牌识别通常只需要一个检测类别。import os import random import xml.etree.ElementTree as ET CLASSES { plate: 0, car_plate: 0, license: 0, plate_l: 0 } def voc_to_yolo(xml_path, txt_path): tree ET.parse(xml_path) root tree.getroot() img_w int(root.findtext(size/width)) img_h int(root.findtext(size/height)) lines [] for obj in root.findall(object): name obj.findtext(name).strip() if name not in CLASSES: continue cls_id CLASSES[name] box obj.find(bndbox) xmin int(box.findtext(xmin)) ymin int(box.findtext(ymin)) xmax int(box.findtext(xmax)) ymax int(box.findtext(ymax)) cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h bw (xmax - xmin) / float(img_w) bh (ymax - ymin) / float(img_h) lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) with open(txt_path, w, encodingutf-8) as f: f.write(\n.join(lines)) xml_dir Annotations txt_dir labels os.makedirs(txt_dir, exist_okTrue) for xml_name in os.listdir(xml_dir): if not xml_name.endswith(.xml): continue stem os.path.splitext(xml_name)[0] voc_to_yolo( os.path.join(xml_dir, xml_name), os.path.join(txt_dir, stem .txt) ) random.seed(42) names [os.path.splitext(f)[0] for f in os.listdir(xml_dir)] random.shuffle(names) split int(len(names) * 0.9) train_names names[:split] val_names names[split:] with open(train.txt, w, encodingutf-8) as f: f.write(\n.join(train_names)) with open(val.txt, w, encodingutf-8) as f: f.write(\n.join(val_names))逻辑说明CLASSES 字典把常见的车牌类别名全部归到 0 类。如果遇到未知类别脚本会跳过而不报错这能容忍脏数据但也可能掩盖真正的多类问题所以你最好在处理完之后统计一下被跳过的 XML。random.seed(42) 保证每次划分结果一致否则今天训练明天推理验证集总在变。参数说明split 是训练集占比0.9 表示 1458 张里约 1312 张训练、146 张验证。实际项目里我更倾向 8:2因为该数据集本身只有 1458 张验证集太小评估波动大。写入 txt 的坐标格式保留了小数点后 6 位足够 YOLOv8 这类检测器读取过多的小数位只会增加文件体积。3.3 转换完了怎么自查长度、类别、越界转完不能直接跑去训练。我一般会在 Linux 或 Git Bash 里跑几条命令自查# 统计 labels 下 txt 文件数量应该等于 XML 数量 ls labels/*.txt | wc -l # 检查有没有空标签文件空文件说明原 XML 里没有合法对象 find labels -name *.txt -size 0 # 查看所有标签里的值是否超出 0~1 范围 awk { if ($3 0 || $3 1 || $4 0 || $4 1) print $0 } labels/*.txt第一个命令检查文件一一对应第二个命令检查空标签。如果大量 txt 文件为空说明之前 CLASSES 映射没有覆盖真实类别名需要回到 XML 里把 name 字段打印出来重新映射。第三个命令检查越界正常情况下归一化坐标不会越界出现大于 1 的值多半是分母读取原图尺寸时写错或原图本身在预处理时已经被缩放过。还要检查类别 id。如果最后一列全是 0但你本来打算把蓝牌和绿牌分成两类那说明这个数据集只标记了车牌整体没有标记颜色。想区分颜色只能靠训练好后端规则或者单独做颜色分类VOC 标记解决不了这个问题。4. 车牌识别训练前的数据检查与增强1458 张能学到什么这个数据集的规模只有 1458 张拿来训练车牌检测勉强够用但直接硬训很容易发生过拟合。在做增强之前建议先搞清楚类别设置、车牌颜色分布和样本质量因为增强只能弥补多样性不能弥补标记错误。4.1 类别设置只画整体车牌还是拆数字和字母我在项目里见过两种车牌识别方案一种是检测整体车牌然后接 OCR另一种是把车牌上每个字符单独标注像文字检测一样识别。用这个 VOC 数据集默认只能做整体车牌检测因为 XML 里大概率只有一块车牌的边界框没有每个字符的框。1458 张图标注整体框成本低如果标注字符框每张图至少几十个坐标点工作量大得多。我一般建议先按整体车牌检测来做理由有二。第一车牌整体框是字符识别的前置条件检测到车牌后裁剪图片交给 OCR 或专用车牌识别网络数字和字母照样能出来。第二这个数据集只有 1458 张如果强行去做字符级检测每个字符的样本量会被拆分得很稀疏模型很难收敛。字符识别更适合用单独的字符分类数据集去训练。如果你的项目真的需要字符级位置也不是不能用 VOC 数据集而是要把 XML 里的整体框当作裁剪候选另找一个字符标注数据集来补字符位置。这种“检测整体车牌 字符级识别”的组合在停车场出入口设备里很常见现在很多车牌识别一体机跑的就是这套流程普适性和可落地性比单纯端到端识别更强。4.2 新能源车牌颜色与遮挡干扰怎么处理真实路面上有蓝底白字、绿底黑字、黄底黑字、白底黑字还有新能源渐变绿牌。如果这个 VOC 数据集里蓝牌占大多数绿牌和黄牌很少检测模型会对蓝色框比较敏感对绿牌容易漏检。我看过几个翻车项目模型在小区入口对蓝牌识别率 99%但对绿牌只有 70%问题就出在训练数据颜色不均。先把数据集颜色分布统计出来是个好习惯。可以写一个简单脚本根据车牌框中心的像素颜色素描统计把蓝、绿、黄、白四类数量打出来。如果绿色太少就用颜色增强模拟把蓝色车牌通道做 HSV 平移让它变成绿色或黄色。但要注意颜色增强不能改太多否则模型学到的不是“车牌颜色”而是“图片整体色调偏移”在真实夜间灯光下反而更差。遮挡干扰也很现实。框架上装了泥点车牌被保险杠遮住一角或者跟车太近导致车牌进入盲区这些情况下整体框仍然是完整矩形但框内内容被遮挡。增强阶段可以对标记框内区域加入随机黑色块和模糊模拟泥点遮挡也可以随机把车牌框的边角切掉让模型学会从局部特征判断这里是一块车牌。这类增强比单纯水平翻转更能提升真实卡口场景的鲁棒性。4.3 有效增强手段复制粘贴背景、模糊、光照抖动1458 张图的原图背景往往集中在道路、停车场、高速入口。为了模拟更多真实环境我常用 albumentations 组合增强。这里给出一个适合车牌检测的训练管线。import albumentations as A train_transform A.Compose([ A.RandomBrightnessContrast(p0.6, brightness_limit(-0.2, 0.2)), A.HueSaturationValue(p0.5, hue_shift_limit10, sat_shift_limit20), A.MotionBlur(p0.3, blur_limit5), A.GaussNoise(p0.2, var_limit(10.0, 30.0)), A.RandomScale(scale_limit0.1, p0.5), A.RandomRotate90(p0.3), A.ShiftScaleRotate(shift_limit0.1, rotate_limit10, p0.4), ], bbox_paramsA.BboxParams(formatpascal_voc, label_fields[class_labels]))逻辑说明RandomBrightnessContrast 模拟白天和夜晚的光照差异HueSaturationValue 调节色调来近似不同车牌底色MotionBlur 模拟车速快拍出来的模糊GaussNoise 模拟低照度传感器噪点。RandomScale 和 ShiftScaleRotate 模拟相机安装角度和车牌偏移。注意 bbox_params 里的 format 要写“pascal_voc”因为增强库期望的坐标顺序是 xmin、ymin、xmax、ymax与 VOC 原样一致。参数说明rotate_limit 只给了 10 度实际车牌很少会旋转超过 15 度如果给到 45 度会让框内字符变形严重。blur_limit 给 5 已经足够真实车辆运动模糊比这更复杂但过度模糊会让模型误以为所有车牌都是糊的。在进行增强后还要做一次边界裁剪把超出图像范围的框同步截断否则增强后的坐标越界会导致训练 loss 异常。5. 避坑排查ZIP 解压、标记脏数据与训练翻车的常见现场这章写的都是我在车牌识别项目里实际踩过的坑几乎每回都是白天一个特征看着很好晚上一到真实场景就翻车。把这几条提前列出来能省掉一整天的调试时间。5.1 文件名乱码与 ZIP 伪加密导致解压失败现象zip 文件下载后解压到一半突然报密码错误或者解压出来的文件名全是“锟斤拷”。原因一部分网上流传的数据集 zip 使用非 UTF-8 编码的文件名Windows 自带解压工具会当成乱码。还有一类 zip 被做过“伪加密”文件头里留着加密标记但内容根本没有加密导致解压工具误以为需要密码。解决先用 7-Zip 打开 zip选择文件后按 F4 重命名或者右键“保持原有编码”解压。如果确实遇到伪加密可以用 Python 的 zipfile 包强制读取import zipfile with zipfile.ZipFile(dataset.zip) as zf: for f in zf.infolist(): f.flag_bits 0xFFF7 # 清除第 6 位加密标记 try: zf.extract(f, dataset) except RuntimeError: pass这个修复思路是flag_bits 的第 6 位是加密标志直接按位与清除后普通解压工具就能正常读出来。不要把它当成破解别人的加密文件这只是处理某些分享场景里误设置加密标记的可用手段。5.2 XML 里缺 name 节点或坐标为零的坏标签现象训练时突然报错提示条目对象没有属性 name或者 loss 正常下降但验证集 mAP 一直上不去检查发现很多标注框坐标全为 0。原因目标标注工具导出 VOC 时偶尔会漏写 name 字段只保留 bndbox坐标为零则可能是标注时误点原点或者导出脚本把未赋值坐标写成了 0。这种坏标签在少量出现时不会导致程序崩溃但会污染训练数据。解决在解析脚本里加一层校验任何缺失关键字段或 bbox 面积为零的 XML 直接跳过并输出一份 bad_list.txt方便回头人工补标。这样既能保证转换脚本不崩又能定位问题文件。另外获得干净标签后建议在训练数据集里保留至少 50 张带 difficult1 的图片防止模型只在干净图上过拟合。5.3 转换后出现 NaN因为读取尺寸时用了错误字段现象YOLO 训练的时候 loss 突然变成 nan检查生成的 txt 标签看到里面有“nan”字符串。原因VOC XML 里 size 字段缺失或者写成了空值int(None) 会直接抛异常但有些代码里用了 int() 却返回 0后续除以 0 就变成了 inf再到归一化就变成 nan。还有一个常见原因是我自己在写转换脚本时把 object 里的 bndbox 当成了图片尺寸导致分母变成车牌框宽高。解决在 voc_to_yolo 函数开头加入 assert 语句确保 width 和 height 都大于 0。出现 nan 的标签文件可以直接用 awk 过滤后删除但更根本的办法还是回到 XML 里找缺失 size 的文件把尺寸补上。这个坑最经典的地方在于训练脚本往往不会直接提示是哪张图出了问题只能用二分法删标签慢慢定位。5.4 同源图片被分进 train 和 val验证指标虚高现象模型在验证集上 mAP 达到 99%但拿到真实路口一测识别率直接掉到 80%。回头检查发现验证集里很多图片和训练集图片来自同一段视频只是相邻几帧。原因车牌数据集很多是连续抓拍采集的同一个车牌在连续几帧里都出现背景几乎一样只是角度和光照略有变化。直接按文件名随机划分会把同一序列的帧同时分到训练和验证验证时模型相当于见了熟脸指标完全不反映真实场景。解决划分集合前先按车牌号或拍摄时间分组同一组内所有图片只能进入同一个集合。用文件名前缀或序列 ID 分组是最稳妥的。如果拿不到分组信息还有一个退路按图片的平均像素哈希做相似度去重把相似度超过阈值的图片留在同一侧。虽然 1458 张不算多但拿来做验证集评估时这个操作能避免你发一篇虚高的论文或者上线一套跑不通的系统。5.5 训练 loss 降了但字符回读不准误以为检测模型出了问题现象YOLO loss 一直在降检测框也框得准但把框内图片交给 OCR 后数字字母错误率很高。原因检测框标的是整块车牌但车牌上有汉字、小圆点、边框、螺丝孔和底色纹理OCR 直接处理这些内容时会把汉字和装饰物也当成字符来识别。检测模型本身没毛病问题出在“检测”和“识别”两个任务没有在数据边界上对齐。解决在训练 OCR 或字符识别器之前把车牌的汉字去掉或单独训练。中国车牌第一位是省份简称汉字后面才是字母和数字如果识别任务只需要数字和字母就用一个简单的颜色阈值或先验位置把字符区域截取出来再喂给 OCR。实际部署时我一般还会把检测框往内缩 5% 到 10%避开车牌白色边框这样 OCR 输入的字符区域更干净。6. 验证车牌识别效果用检测结果接 OCR 回读数字字母验证是最后一步也是很多人偷懒的一步。mAP 高不代表车牌能读对我习惯把检测结果接上 OCR直接用字符级准确率做验收。6.1 检测车牌框并交给 OCR 回读用训练好的 YOLO 模型跑一张验证图得到车牌框后裁剪再调用 OCR 工具回读。下面是一个简单的验证脚本import pytesseract from PIL import Image crop Image.open(cropped_plate.jpg) text pytesseract.image_to_string( crop, config--psm 7 -c tessedit_char_whitelistABCDEFGHJKLMNPQRSTUVWXYZ0123456789 ) print(text)逻辑说明psm 7 表示按单行文本识别适合车牌这种单行字符。tessedit_char_whitelist 里排除掉了 I 和 O因为中国车牌字母和数字里通常不用这两个字符避免把数字 1 和 0 误判成字母。实际项目中我更建议用专门的车牌识别模型或者 PaddleOCR 的 PP-OCRv4tesseract 只适合做快速验证。验证指标要做两个字符准确率预测字符串和真实字符串每字符对比以及车牌级准确率完全一致才算对。1458 张图全部跑一遍把结果按蓝牌、绿牌、黄牌分组统计就能发现模型到底对哪种颜色更弱。如果绿色车牌字符错误率特别高说明之前颜色平衡没做好增强数据时要单独增加绿牌比例。6.2 两个小技巧按清晰度过滤预测框用颜色校准识别阈值第一个技巧是计算抓拍图像的清晰度。车牌识别对清晰度极其敏感训练时加入 MotionBlur 后模型能找到模糊车牌但 OCR 容易翻车。部署时我会用拉普拉斯方差对框内区域做一个清晰度过滤方差低于 25 的图直接判定为“无法识别”而不是强行给一个带噪声的结果。这样虽然牺牲了部分召回率但大幅减少了误识别。第二个技巧是按车牌颜色调整 OCR 预处理。蓝色车牌的字符是白色绿色车牌字符是黑色把彩色图先做灰度化再二值化会丢失这种先验。更好的做法是在裁剪图上按色调做分区蓝色区域提亮并做反色绿色和黄色区域直接二值化。这个步骤并不需要改模型只需要在预处理函数里加两个分支但验证准确率通常能提升 3 到 5 个百分点。最后我想说车牌识别没有黑盒也没有后悔药。我自己的习惯是拿到任何车牌数据集先花半小时做 zip 解压检查、VOC 解析、类别统计和人眼抽样把脏标签和颜色分布摸清再训练虽然看起来慢了但后期调试时间能省下一大半。希望这套流程能帮你少踩几个坑把这 1458 张标记图片真正用起来。本文还有配套的精品资源点击获取