简介:中国车辆车牌号识别数据集是为计算机视觉与深度学习算法研发准备的标注数据资源,面向需要训练车牌检测、字符识别或OCR模型的开发者与研究人员,可帮助解决国内车牌场景数据匮乏、标准不一的问题。压缩包约19.49MB,内含1458张带标记的车辆车牌图片,标记内容覆盖车牌数字与字母,并采用VOC格式的XML标注文件,便于直接接入YOLO、Faster R-CNN、SSD等主流目标检测框架的数据管线使用。数据集中所标注的车牌字符类型清晰,可用于车牌定位、字符分割与端到端车牌识别等任务,也适合作为模型训练、验证与算法对比的基准数据。目前已有684人学习使用该资源,适合入门级与进阶研究者快速获取带标注的中国车牌图像样本,减少数据采集和清洗成本,提升实验迭代效率。
1. 这套中国车牌识别数据集能做什么:数字字母识别的起点与边界
平时做车牌识别,最怕的不是模型选型,而是数据标注口径不对。一个名为“中国车辆车牌号识别数据集,可识别车牌数字和字母,支持VOC格式的标记,1458张标记图片.zip”的数据集,自带VOC格式的字符级标注,覆盖车牌上的数字和字母,适合用来启动停车计费、门禁道闸这类需要读出车牌号码的小模型。先说结论:1458张不算大,但配合迁移学习和数据增强,能训练出一版不依赖云端的本地识别模型;它更适合做方案验证和生产环境的冷启动,不是“标完就毕业”的终点。适合第一次碰车牌识别的新手、被VOC转YOLO折腾过的工程师,以及想快速出一版可演示demo的产品同学。
2. VOC格式标记拆解:目录、XML结构与字符级标注
2.1 解压后先做这三件事:核对图片和xml是否一一对应
先把zip解到干净目录,我一般会立即核对三件事:图片数量、xml数量、文件名能否一一对应。VOC的典型目录结构是三个文件夹:Annotations/放xml标记,JPEGImages/放原图,ImageSets/Main/放train.txt、val.txt之类的划分文件。如果压缩包顶层没有这三兄弟,就用 find 命令把xml全部找出来,再和图片配对。
| 目录 | 内容 | 常见作用 |
|---|---|---|
| Annotations/ | 每张图片一个同名xml | VOC标记主体,存目标类别和坐标 |
| JPEGImages/ | 原始图片 | 训练和验证的图像来源 |
| ImageSets/Main/ | txt划分文件 | 官方惯用的train/val划分 |
拿到压缩包先做的基本确认,目的是避免后续脚本大面积报错。执行这样的命令看数量:
ls JPEGImages/ | wc -l ls Annotations/ | wc -l # 1458张图就应该有1458个xml,多一个少一个都要先查为什么这么较真:VOC转YOLO的前提是“每张图都有且只有一个同名xml”。漏标xml会导致训练时图片被跳过,多出来的xml会产生孤儿标注没人消费。这个数量核对三分钟能做完,能省掉后面一半的排错时间。
2.2 XML里的标记字段:name、bndbox、difficult在说什么
一个典型的VOC xml长这样:
<annotation> <folder>JPEGImages</folder> <filename>IMG_0001.jpg</filename> <size> <width>1920</width> <height>1080</height> </size> <object> <name>0</name> <bndbox> <xmin>120</xmin> <ymin>300</ymin> <xmax>160</xmax> <ymax>340</ymax> </bndbox> </object> <object> <name>A</name> <bndbox> <xmin>170</xmin> <ymin>300</ymin> <xmax>210</xmax> <ymax>340</ymax> </bndbox> </object> </annotation>要提取的就是 object 下的 name 和 bndbox:name 是这个字符框的类别,bndbox 的四个值以图片左上角为原点、像素为单位。size 里的宽高在转换归一化坐标时要用到,但我习惯在脚本里用cv2读一遍图片实际尺寸,而不是完全信任xml里的size字段——标注工具偶尔会把分辨率写错,转换完才发现坐标全部偏移,回头排查非常费劲。
difficult=1 的目标表示“难例”,通常是遮挡、模糊、太小这类样本。车牌识别场景我一般建议直接剔除,因为训练时它会拉低正样本纯度;如果数量不多,留着也未必有害,但清洗数据的原则是宁缺毋滥。
2.3 字符级标注vs整牌标注:为什么数字和字母需要单独框
标题说“可识别车牌数字和字母”,按我的理解,这套数据集的标注粒度应该是字符级,也就是每个字符单独一个框,而不是一整块车牌一个框。两者用途完全不同:整牌框只能告诉你“这里有一块车牌”,做不了号码识别;字符级标注才能训练出“读出车牌号”的模型。
中国车牌标准布局通常是:蓝牌7位,第一位是省份简称汉字,第二位是发牌机关字母,后面是5位数字加字母组合;新能源绿牌是8位,后段6位。如果数据里只标了数字和字母,那省份汉字这一位在模型输出里是缺的。常见做法是另外用一个汉字分类器补上,或者在后处理阶段用业务规则处理。拿到数据后要做的第一件事,就是确认字符框到底框的是什么——先看xml里的object数量,再对照图片画几个框出来,一分钟就能判断标注粒度。
3. 把VOC转成YOLO格式:转换脚本与四个必调参数
3.1 归一化坐标怎么算:从bndbox到YOLO的txt
YOLO系列训练时读取的标签是txt文件,每行五个数:class_id、x_center、y_center、width、height,全部归一化到0~1。
从VOC的绝对像素坐标转换的公式是:
x_center = ((xmin + xmax) / 2) / img_width
y_center = ((ymin + ymax) / 2) / img_height
box_width = (xmax - xmin) / img_width
box_height = (ymax - ymin) / img_height
为什么归一化:不同图片分辨率相差很大,模型推理时会把不同尺度的输入统一到相同分辨率,归一化后的坐标不会因为原图大小而漂移。转换时还要注意:xmin、ymin 读出来是整数,除法前必须转成浮点数,否则整数除法会把坐标直接抹掉。如果框的坐标有1~2个像素越界,归一化后会出现小于0或大于1的值,训练时有些框架会报警告;我一般在转换脚本里用clip把结果限制在[0,1]区间。
3.2 转换脚本:读取xml并写出训练标签
import xml.etree.ElementTree as ET import os import cv2 def voc_to_yolo(xml_path, img_dir, out_dir, class_names): tree = ET.parse(xml_path) root = tree.getroot() filename = root.findtext("filename") if not filename: return img_path = os.path.join(img_dir, filename) img = cv2.imread(img_path) if img is None: print(f"图片读取失败,跳过: {img_path}") return h, w = img.shape[:2] out_txt = os.path.join(out_dir, os.path.splitext(filename)[0] + ".txt") lines = [] for obj in root.findall("object"): name = obj.findtext("name") if name not in class_names: # 类别表里没有的标记,直接跳过,避免训练时报下标越界 continue cls_id = class_names.index(name) bbox = obj.find("bndbox") xmin = float(bbox.findtext("xmin")) ymin = float(bbox.findtext("ymin")) xmax = float(bbox.findtext("xmax")) ymax = float(bbox.findtext("ymax")) x_center = (xmin + xmax) / 2.0 / w y_center = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h # 防止坐标越界,越界直接裁剪到0~1 x_center = min(1.0, max(0.0, x_center)) y_center = min(1.0, max(0.0, y_center)) bw = min(1.0, max(0.0, bw)) bh = min(1.0, max(0.0, bh)) lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}") # 空标注也生成空txt,保持和xml一一对应 with open(out_txt, "w", encoding="utf-8") as f: f.write("\n".join(lines)) if __name__ == "__main__": xml_dir = "Annotations" img_dir = "JPEGImages" out_dir = "labels_raw" os.makedirs(out_dir, exist_ok=True) # 先把所有xml扫一遍,收集全部类别名再排序 all_names = set() for xml_file in os.listdir(xml_dir): tree = ET.parse(os.path.join(xml_dir, xml_file)) for obj in tree.getroot().findall("object"): all_names.add(obj.findtext("name").strip()) class_names = sorted(all_names) print("类别列表:", class_names) for xml_file in os.listdir(xml_dir): if xml_file.endswith(".xml"): voc_to_yolo(os.path.join(xml_dir, xml_file), img_dir, out_dir, class_names)逻辑说明:脚本先把xml里的图片名拼到原图路径,读图取真实宽高;然后遍历每一个object,把name映射成整数类别id,bbox从像素坐标换算成归一化中心点坐标和宽高;最后写成一个和图片同名的txt。空标注也生成空txt,是为了之后核对文件数时一一对应,不会被误判成漏标。
参数说明:class_names的顺序就是训练时模型的类别顺序,一旦确定就不能随便改,否则前面标0后面标1,混淆矩阵全是乱的。这段代码里类别是从xml里动态收集的,比手写一个列表更稳,前提是xml里的name没有空格和乱码,所以用了strip()清理两侧空白。out_dir建议独立目录,不要覆盖原xml。
3.3 训练集/验证集划分:8:1:1还是用官方划分
import os import random import shutil from glob import glob random.seed(42) xml_files = glob("Annotations/*.xml") random.shuffle(xml_files) total = len(xml_files) train_end = int(total * 0.8) val_end = int(total * 0.9) split_files = { "train": xml_files[:train_end], "val": xml_files[train_end:val_end], "test": xml_files[val_end:] } for split_name, files in split_files.items(): img_out = f"images/{split_name}" label_out = f"labels/{split_name}" os.makedirs(img_out, exist_ok=True) os.makedirs(label_out, exist_ok=True) for xml_path in files: stem = os.path.splitext(os.path.basename(xml_path))[0] img_src = f"JPEGImages/{stem}.jpg" if not os.path.exists(img_src): img_src = f"JPEGImages/{stem}.png" # 兼容png原图 if os.path.exists(img_src): shutil.copy(img_src, f"{img_out}/{os.path.basename(img_src)}") txt_src = f"labels_raw/{stem}.txt" if os.path.exists(txt_src): shutil.copy(txt_src, f"{label_out}/{stem}.txt")说明:固定随机种子是为了保证可复现,你重新跑一遍划分结果不会变。8:1:1是起步值,1458张数据训练集约1166张,够跑demo;val保留约146张,test保留约146张。划分时图片和标签必须同名拷进对应目录,名称不匹配会导致训练时图片被静默跳过,这类问题看日志很难发现,只能在划分后立刻复查文件数。
提示:如果压缩包自带ImageSets/Main/train.txt,直接用官方划分,省得自己切。
3.4 转换完的核对三连:文件数、坐标范围、类别分布
转换和划分都做完,我习惯做三件事再开训练。第一,文件数核对:images/train下的jpg数量必须和labels/train下的txt数量一致。第二,坐标范围核对:随机打开几个txt,所有值都应该在0~1之间。第三,类别计数核对:确认没有只出现一两次的“幽灵类别”。
# 检查标签坐标是否都在0~1 awk '{for(i=2;i<=5;i++) if($i<0 || $i>1) print $0}' labels/train/*.txt # 统计各类别样本数 awk '{count[$1]++} END {for(k in count) print k, count[k]}' labels/train/*.txt | sort这三项分别对应漏标、坐标越界、类别不平衡。坐标越界多半是除以了xml里错误的宽高;类别不平衡则直接影响后续训练,比如某个字母只有2个样本,模型基本学不会。
4. 避坑:1458张图片训练车牌识别时不翻车的五条数据经验
4.1 现象:loss降了但mAP上不去——先怀疑字符框不贴边
loss降不代表模型学对了。最典型的情况是标注框把整个车牌框进去了,而不是贴着每个字符。原因:如果做检测模型的标注,框住整牌是对的;但做字符识别,框里同时含有多个字符和蓝色底板的纹理,模型学到的特征是“车牌区域”而不是某个字符。解决:打开xml和原图叠加画框,逐个样本检查;字符框的四边应当贴合字符外轮廓,宁可略紧不要松散。如果数据集太大,至少按比例抽查100张,错得离谱就重新标注。
4.2 现象:数字“0”和字母“O”互相认错——类别设计有问题
标准中国车牌不含字母I和O,但很多数据集的标注者会把“0”标成“O”,或者反过来。原因:车牌字体里0和O高度相似,人也容易标混,训练出的模型自然分不清。解决:先在第3节的类别计数里看有没有O和I;有就合并成数字类或直接剔除。另一种更实用的做法,是保留所有类别训练,在后处理阶段用车牌规则白名单过滤输出,把I/O这类不可能出现的字母改成最相似的数字。
4.3 现象:训练没收敛或者val指标先升后降——数据太少,模型太贪
1458张图片对分类任务不算少,但对字符级车牌识别来说,每一个字符框都是一个检测目标,一个框预测错误就会拉低整牌结果。原因:模型复杂度过高,例如用yolov8x或yolov8m,backbone太强,训练到120轮就开始记忆标签。解决:优先用yolov8n或yolov8s;开mosaic、hsv增强;关掉fliplr;epochs设120,加patience=30早停。val指标先升后降基本就是过拟合,不要硬跑到300轮。
4.4 现象:xml里的中文类别名读取报错——编码问题
有些标注工具在Windows下保存xml默认不是UTF-8,而是ANSI/GBK。现象是:ET.parse能读,但obj.findtext("name")返回乱码,或者直接抛UnicodeDecodeError。解决:把xml统一转成UTF-8再解析;更省事的办法是类别名里根本不用中文,统一用ASCII字符。比如省份简称映射成“BJ”“SH”这类拼音,字母和数字保持原样。这个习惯能少踩很多坑,yaml文件、终端打印、日志输出都更干净。
4.5 现象:数据只有蓝牌没有绿牌,场景太单一
拿到数据先统计,不要以为“中国车牌数据集”就一定覆盖所有车型。常见情况是:蓝牌占绝大部分,新能源绿牌很少甚至没有;场景全是白天正面无遮挡。原因:数据采集来源单一,比如只拍了停车场入口。解决:先用补数据的方式增加绿牌、夜间、雨天、倾斜角度样本。我自己会拿CCPD和BDD100K这类自动驾驶场景公开数据集做补充预训练,再把这套1458张的标记数据拿来微调。补充数据同样要做清洗,不能直接拼进去,否则标注口径不一致比数据少更麻烦。
5. 用YOLOv8训练自己的车牌识别数据集:命令与参数设置
5.1 数据目录怎么摆:先写一个plate.yaml
第3节转换和划分完之后,目录结构应该类似这样:
plate_dataset/ images/train/ images/val/ images/test/ labels/train/ labels/val/ labels/test/YOLOv8训练时的data参数要指向一个yaml,内容如下:
# plate.yaml path: /path/to/plate_dataset train: images/train val: images/val test: images/test 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' # 按刚才class_names实际顺序继续补names顺序必须和第3节转换脚本里的class_names完全一致。path建议写绝对路径,相对路径在切换工作目录后很容易出问题。yaml文件不要用tab缩进,YOLO的yaml解析对这个很敏感。names手动写容易漏,我一般把3.2里打印出来的类别列表直接粘过来再生成。
5.2 最小训练命令:从yaml到train
yolo detect train data=plate.yaml model=yolov8n.pt epochs=120 batch=16 imgsz=640 patience=30 fliplr=0.0 amp=True device=0这个命令由ultralytics包提供,安装ultralytics后即可使用。model=yolov8n.pt会下载COCO预训练权重,如果不想依赖预训练权重,也可以直接用yolov8n.yaml从零训练,但1458张数据冷启动效果会差很多。device=0表示第一张GPU;没有显卡就device=cpu,训练时间要放宽。amp=True默认开启,显存不够时它能省下不少显存。
5.3 关键参数怎么调:img、epochs、batch、patience
| 参数 | 起步值 | 说明 |
|---|---|---|
| imgsz | 640 | 车牌字符偏小,不建议低于480;字符密集,太高会加重显存 |
| epochs | 120 | 配合patience使用,别一上来就跑300 |
| batch | 16 | 8GB显存可用16,OOM就降到8 |
| patience | 30 | val指标30轮不涨就停 |
| workers | 4 | Windows下建议2,否则数据加载容易卡 |
| amp | True | 出现NaN就关掉amp再试 |
fliplr这个参数必须单独说。YOLOv8默认开启水平翻转增强,但对车牌字符识别是有害的。数字和字母镜像后,序号顺序直接反转,模型会学到错误的方向特征。命令里已经用fliplr=0.0把它关掉,这是很多人忽略的细节,不是玄学,是数据增强和任务性质不匹配。
5.4 训练结果怎么评估:不要只看mAP50
训练完成后,去runs/detect/train/下看confusion_matrix.png、PR_curve.png和results.csv。对于车牌识别,mAP50不是最该盯的指标,因为目标是整张车牌号码识别正确,而不是某个字符框有没有被检测到。要按字符框的x坐标从左到右排序、拼成字符串,再和真实车牌比对。val混淆矩阵里重点看0/O、1/I这两组是否集中错。如果出现NaN,先从amp、batch、学习率三个方向排查。
6. 让字符识别更稳的进阶技巧:车牌规则后处理与数据补充
6.1 用正则校验车牌结构
模型输出是一堆框和类别,拼串之前先过一次规则。蓝牌是“省份汉字+字母+5位数字字母”,新能源是“省份汉字+字母+6位数字字母”。我习惯按x坐标排序后拼出候选串,再用类似 ^[一-龥][A-Z][A-HJ-NP-Z0-9]{5,6}$ 的表达式过滤。一旦第二位之后出现I/O这类字母,基本可以判定是模型把数字认错了,直接替换成0或1。字符级后处理和检测同等重要,正则过滤能救回不少误识别。
6.2 用补充数据做微调
如果业务场景有夜间、绿牌、倾斜角度,拿1458张做基座是不够的。我会先用CCPD这类公开车牌数据集子集预训练一个通用模型,再拿这套数据微调,相当于把“通用字符识别能力”迁移到当前场景。补充数据要保证标注口径一致,宁可少加不乱加。
6.3 最终验收方法:整牌全对才算对
训练结束后,拿10张模型没见过的现场图,只要整牌字符全对、顺序全对才算识别正确,统计全对率。我最早偷懒用整牌框训练,val的mAP50有0.9,看起来很美,一上真实门禁全错,后来改成字符级标注加白名单后处理才真正能用。这个教训我一直留着,也提醒自己:车牌识别数据集的验收标准永远是整牌号码,不是单框精度。希望帮到你。
本文还有配套的精品资源,点击获取