1458张VOC车牌数据集:从标注解析到YOLOv8识别实战
简介中国车辆车牌号识别数据集内含1458张真实车牌图片覆盖数字和字母所有图片均配有VOC格式的标注文件可直接用于车牌检测、字符识别等计算机视觉模型的训练与评估。压缩包采用ZIP格式整体约19.49MB包内文件以车牌图片和VOC标注文件为主图片与标注一一对应目录结构简洁清晰便于快速预览、分类使用和二次扩展也能方便地接入主流的检测与识别算法。该数据集主要面向计算机视觉学习者、智能交通系统开发者以及算法竞赛参与者能够缓解训练数据采集困难、人工标注成本高、数据格式不统一等常见痛点帮助使用者把精力集中在模型设计与性能优化上。目前资源在CSDN上已有683人浏览学习属于较受关注的数据集之一适合用于模型快速迭代、算法效果对比与实际场景测试。获取后无需额外格式转换即可直接开展训练、调优和验证是车牌识别项目从实验走向应用的高效素材。1. 1458张VOC标注图做车牌识别小数据集也能跑出能用的检测器停车场道闸前的抓拍杆晚上七点车灯直射牌照被照成一片白这时候模型如果还能读出“粤B12345”抬杆就能照常进行。这个中国车辆车牌号识别数据集1458张已标记图片VOC格式XML标注覆盖车牌数字和字母识别就是为这类场景准备的。它的定位不是几万张的工业级数据而是让你半天内跑通“检测识别”流程的起步数据。适合做车牌识别项目选型的工程师也适合用YOLO做课程设计的学生。提前说结论1458张对车牌这种强结构目标够用但前提是把标注体检、数据划分和字符集三件事做对否则多少图都白搭。2. 拆开zip先看结构VOC格式的车牌标注里藏着哪些关键信息拿到压缩包第一件事不是解压完立刻开训而是先花十分钟把目录结构摸清楚。VOCVisual Object Classes格式是目标检测数据集的老牌规范由 PASCAL VOC 竞赛带起来后来成了标注工具 LabelImg 的默认输出格式。这个数据集声明支持 VOC 格式解压后大概率就是三件套JPEGImages 放原始图片Annotations 放同名 XML 标注ImageSets/Main 放划分好的训练、验证、测试文件名列表。三者目录名和文件命名规则只要严格对应后续转到 YOLO 或 MMDetection 都是机械活。注意一点如果你在 Windows 上解压后直接拷到 Linux 训练服务器文件名里的中文、空格和\路径分隔符很容易在脚本里出幺蛾子。先统一成英文小写命名再往下走能省掉后面大量莫名其妙的报错。提示解压后先在终端跑find . -type f | head -50确认没有隐藏的__MACOSX目录或重复文件。这类 zip 里经常混着不该有的垃圾文件不清理会影响后续按文件名匹配标注的脚本。2.1 JPEGImages、Annotations、ImageSets/MainVOC三件套怎么对应三件套的对应关系很简单一张JPEGImages/20240611_01.jpg对应一份Annotations/20240611_01.xml以及它在 ImageSets/Main 某个 txt 里的一行不带后缀的文件名20240611_01。XML 里的filename字段写的也是同一个文件名这就构成了“图片→标注→划分”的完整索引链。XML 的核心结构长这样annotation根节点下size记录图片宽高和通道数每个object代表一个目标里面的name是类别名bndbox是 xmin、ymin、xmax、ymax 四个整数坐标。对车牌数据集来说name可能有两种情况一种是整个车牌一个对象name 写成plate另一种是按字符标注每个字符一个对象name 直接是0到9、A到Z。拿到手先确认是哪一种这决定了你后面走检测还是走 OCR。这个区分是后续所有训练策略的分岔口。整牌框适合训练“先找车牌在哪”的检测器字符框可以直接拿来当字符分类数据也能反向拼出完整车牌字符串。很多人在这一步没看一眼标注粒度直接套 YOLO 训练训完才发现类别数、目标尺度和预期全对不上。2.2 用Python解析标注XML把车牌号和字符从bndbox里读出来写个最小的解析脚本随机抽十张 XML 看标注内容。我用 Python 自带的 xml.etree不需要额外依赖import xml.etree.ElementTree as ET from pathlib import Path def inspect(xml_path): root ET.parse(xml_path).getroot() filename root.findtext(filename) size root.find(size) w, h int(size.findtext(width)), int(size.findtext(height)) print(f图片: {filename} ({w}x{h})) for obj in root.findall(object): name obj.findtext(name).strip() box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) print(f 目标: {name:8s} bbox({xmin:.0f},{ymin:.0f})-({xmax:.0f},{ymax:.0f})) if __name__ __main__: for xml_file in sorted(Path(Annotations).glob(*.xml))[:10]: inspect(xml_file)逻辑说明findtext直接取子节点文本比find(...).text少一层判空str.strip()去掉类别名里可能混入的空格和换行这是标注工具最常见的脏数据来源。参数上我只打印前 10 个文件够判断粒度即可全量打印反而看不出规律。运行后重点看两件事一是每个 XML 里object的数量如果多数是 1 到 2 个大概率是整牌框如果出现 5 到 7 个就是字符级标注。二是 name 的取值集合如果有汉字说明数据集还带了省份信息只是说明文件没写。2.3 训练前体检类别分布、字符频次和bbox宽高比决定训练策略抽了十张还不够全量 1458 张跑一遍统计确认没有标注缺失和类别混用。这一步相当于给数据集做体检花五分钟能避免后面几小时的白训。import xml.etree.ElementTree as ET from pathlib import Path from collections import Counter labels, char_counter Counter(), Counter() aspect [] for xml_file in Path(Annotations).glob(*.xml): root ET.parse(xml_file).getroot() for obj in root.findall(object): name obj.findtext(name).strip() labels[name] 1 if len(name) 1: char_counter[name] 1 box obj.find(bndbox) w float(box.findtext(xmax)) - float(box.findtext(xmin)) h float(box.findtext(ymax)) - float(box.findtext(ymin)) aspect.append(w / max(h, 1e-6)) print(类别分布:, labels.most_common()) print(单字符频次Top10:, char_counter.most_common(10)) print(宽高比中位数: %.2f % sorted(aspect)[len(aspect) // 2])逻辑说明max(h, 1e-6)防止高度为 0 时除零崩溃len(name) 1用来筛出单字符标注因为车牌字符类和整牌类plate的长度不一样。宽高比是判断标注粒度的硬指标——整牌框宽高比在 2.5 到 4 之间单个字符框在 0.8 到 1.5 之间中位数一出来立刻知道手里是什么货。这组统计还回答了另一个问题字符频次的尾部类别比如 Q、X 这种车牌里少见但必须有的字母如果只有几十张训练时容易被模型当成噪声。看到频次严重不均衡就得在数据增强阶段给稀有字符做额外过采样或者在损失函数里给稀有类加权重。这个决策现在不做训练完看混淆矩阵再回头补就晚了。3. 处理数据集用于YOLOv8训练VOC转YOLO、分组划分与最小训练配置1458 张图不算多但检测器选型空间不大走 YOLOv8 的预训练权重微调是当前最稳的路。VOC 格式不能直接喂给 YOLOv8它要的是每个图片对应一个同名 txt——每行一个目标格式是“类别id 中心x 中心y 框宽 框高”全部坐标除以图片宽高做了归一化。这一步转换没有难度但有三个细节处理不好就会埋雷类别表顺序、坐标越界和空标注过滤。3.1 VOC转YOLO格式的转换脚本坐标归一化和类别映射的细节转换脚本我一般这么写关键点都标在注释里import cv2 import xml.etree.ElementTree as ET from pathlib import Path # 类别表先跑 2.3 的统计脚本把出现过的 name 收集全再写死到这里 CLASSES [plate] # 字符级标注就改成 [0,1,...,9,A,...,Z] def voc2yolo(xml_file, img_dir, out_label_dir): root ET.parse(xml_file).getroot() img_path Path(img_dir) / root.findtext(filename) img cv2.imread(str(img_path)) if img is None: return f图片缺失: {img_path.name} h, w img.shape[:2] # 真实宽高不信任 xml 里的 size lines [] for obj in root.findall(object): name obj.findtext(name).strip() if name not in CLASSES: continue # 未登记类别先跳过稍后统一处理 box obj.find(bndbox) xmin max(0, float(box.findtext(xmin))) ymin max(0, float(box.findtext(ymin))) xmax min(w, float(box.findtext(xmax))) ymax min(h, float(box.findtext(ymax))) if xmax - xmin 2 or ymax - ymin 2: continue # 过滤宽度或高度小于 2px 的退化框 cx (xmin xmax) / 2 / w cy (ymin ymax) / 2 / h bw (xmax - xmin) / w bh (ymax - ymin) / h lines.append(f{CLASSES.index(name)} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) if not lines: return f无有效标注: {xml_file.name} out_name Path(xml_file).stem .txt Path(out_label_dir).mkdir(parentsTrue, exist_okTrue) (Path(out_label_dir) / out_name).write_text(\n.join(lines), encodingutf-8) return fOK: {out_name}逻辑说明CLASSES列表的顺序就是最终模型的类别 id确定后不要改动否则之前转出来的 txt 全部作废。用img.shape[:2]而不是 XML 里的size是因为图片在数据集流传中可能被压缩过XML 的宽高未必和实际一致这一点后面避坑章节还会展开。参数说明out_label_dir要和图片目录保持同级YOLOv8 的约定是train/images对应train/labels它自动去同名 labels 目录找标注。转完后检查一下生成的 txt 数量正常应该和 XML 数量一致空标注的除外少了就说明有图片缺失或全部目标被过滤。3.2 划分train/val/test按拍摄批次分组别让同一辆车跨两个集VOC 的 ImageSets/Main 里通常自带 train.txt、val.txt但那个划分未必可靠。常见问题是停车场连拍同一辆车在几秒内被拍了几十张随机洗牌后这些高度相似的帧被拆到 train 和 val 两边验证集指标虚高换到没见过的场景立刻露馅。我一般按文件名的拍摄批次前缀重新分组再划分。import random from pathlib import Path img_paths sorted(Path(JPEGImages).glob(*.jpg)) random.seed(20240611) # 1) 按文件名前缀分组例如 20240611_01.jpg - 前缀 20240611视为同一拍摄批次 groups {} for p in img_paths: key p.stem.split(_)[0] groups.setdefault(key, []).append(p) # 2) 对组而不是对单张图洗牌 keys list(groups.keys()) random.shuffle(keys) n len(keys) tr, va keys[:int(n*0.8)], keys[int(n*0.8):int(n*0.9)] # 3) 输出切分清单沿用 VOC 的 ImageSets/Main 结构 for split, ks in [(train, tr), (val, va)]: out Path(fImageSets/Main/{split}.txt) out.parent.mkdir(parentsTrue, exist_okTrue) files sorted(p.stem for k in ks for p in groups[k]) out.write_text(\n.join(files) \n, encodingutf-8) print(f{split}: {len(files)} 张)逻辑说明p.stem.split(_)[0]是把文件名按下划线切第一段作为分组键前提是这个数据集文件名带拍摄时间或相机 ID 前缀。如果文件名没有规律就改用 EXIF 里的拍摄时间戳按“同一分钟内”聚合效果等价。比例上用 8:1:11458 张大概划分成 train 1166、val 146、test 146test 那部分先不动等全部调完参最后测一次。3.3 YOLOv8训练车牌检测器plate.yaml、最小命令和模型选型转换完成、划分做完接下来就是 YOLOv8 的标准流程。先写数据配置文件指向划分后的目录# plate.yaml path: /data/plate # 数据集根目录改成你的绝对路径 train: train/images val: val/images test: test/images nc: 1 # 检测类别数只检测“车牌”这一类 names: [plate]然后一条命令起训练yolo detect train \ dataplate.yaml \ modelyolov8n.pt \ epochs120 \ imgsz640 \ batch16 \ device0 \ patience20参数说明yolov8n.pt是 nano 版本1458 张图先用最小模型跑通流程最划算imgsz640对大多数抓拍图够用但如果原图是 1920x1080 而车牌在画面里小于 30 像素建议直接提到 1280或者走切块训练否则小目标漏检是必然patience20表示验证集指标连续 20 个 epoch 不涨就早停小数据集过拟合很快不需要硬跑满 120。模型选型方面我的经验是n 和 s 之间二选一m 及以上在这个数据量上容易过拟合。模型参数量训练速度1458张下的表现yolov8n约3.2M最快能跑通流程精度略低yolov8s约11.2M中等常用折中推荐yolov8m约25.9M慢容易过拟合不推荐训完做一次验证确认产物没问题yolo detect val dataplate.yaml modelruns/detect/train/weights/best.pt看验证输出里的 mAP50 和 mAP50-95。车牌检测是个相对简单的任务标注质量正常的话 mAP50 应该能到 0.9 以上达不到别急着加数据先回 2.3 的体检脚本查标注。4. 车牌数字和字母识别检测框之后的OCR分支与字符集设计检测器解决了“车牌在哪”接下来才是“车牌是什么”。标题里强调可识别数字和字母说明这个数据集的核心价值在字符识别这一半。这里的选择直接影响整个系统架构值得先把路线想清楚再动手。4.1 两条识别路线字符级检测 与 整牌检测加OCR路线A是字符级检测直接把每个数字和字母当成检测目标模型输出每个字符的框和类别按 xmin 排序拼成车牌字符串。好处是一个模型搞定检测和识别坏处是模糊字符的框质量不稳定而且必须依赖字符级标注。路线B是整牌检测加OCR分支先用整牌检测器把车牌裁出来再做字符识别。OCR 模块可以是一个轻量分类网络对每个字符做分类也可以用 LPRNet、CRNN 这类序列识别网络直接输出整串车牌。工程上我更推荐 B理由很实际停车场、门禁这些场景的摄像头角度固定整牌检测的难度远低于字符检测而裁出来的小图可以做专门的旋转校正和增强识别精度更容易控制。路线做法优点缺点A 字符级检测每个字符一个框单模型字符位置天然对齐模糊字符框不稳需要字符级标注B 整牌检测OCR先框整牌再裁图识别模块解耦各调各的容错高多一个模块多一次调用这个数据集如果标注是字符级的两条路都能走路线A直接训字符检测路线B把字符级标注转成 OCR 训练数据。如果只有整牌框那就只能走路线B字符标注得自己补。4.2 把字符级VOC标注抠成字符图片分类数据集的制作脚本假设标注是字符级的我一般会把它同时利用起来——抠出每个字符做成分类数据集或者给序列识别模型当底料。抠图脚本很简单import cv2 import xml.etree.ElementTree as ET from pathlib import Path xml_dir, img_dir Path(Annotations), Path(JPEGImages) out_root Path(char_crops) for xml_file in sorted(xml_dir.glob(*.xml)): root ET.parse(xml_file).getroot() img cv2.imread(str(img_dir / root.findtext(filename))) if img is None: continue # 图片缺失直接跳过不中断进程 for idx, obj in enumerate(root.findall(object)): name obj.findtext(name).strip() if len(name) ! 1: continue # 只处理单字符标注过滤掉整牌框 box obj.find(bndbox) xmin int(float(box.findtext(xmin))) ymin int(float(box.findtext(ymin))) xmax int(float(box.findtext(xmax))) ymax int(float(box.findtext(ymax))) crop img[ymin:ymax, xmin:xmax] if crop.size 0: continue out_dir out_root / name out_dir.mkdir(parentsTrue, exist_okTrue) cv2.imwrite(str(out_dir / f{xml_file.stem}_{idx}.jpg), crop)逻辑说明len(name) ! 1是核心过滤条件单字符标注的 name 长度必定是 1整牌框被自动剔除文件名用XML文件名_对象序号保证不重复。抠出来的图按类别放进char_crops/0/、char_crops/A/这样的目录之后可以直接用torchvision.datasets.ImageFolder加载训练字符分类器。参数说明idx是当前 XML 内对象的遍历序号用来拼唯一文件名。如果你后面要合并回车牌字符串命名里最好保留 XML 名方便反查来源。4.3 端到端车牌识别LPRNet/CRNN的CTC方案与字符集设计字符分类器适合车牌位置固定的场景但很多摄像头抓拍的车牌有倾斜、逆光和拉伸逐字符抠图会累积定位误差。更通用的做法是整牌裁图后直接接 LPRNet 或 CRNN用 CTC 解码输出整个车牌字符串。LPRNet 的常见输入是 94x24 的灰度图输出是每个时间步的字符概率CTC 负责把不定长的字符序列和真实标签对齐。训练数据组织方式很简单每行一个样本图片路径加车牌字符串标签。从字符级 VOC 标注拼出完整车牌字符串的脚本如下from collections import defaultdict plate_texts defaultdict(list) for xml_file in sorted(Path(Annotations).glob(*.xml)): root ET.parse(xml_file).getroot() chars [] for obj in root.findall(object): name obj.findtext(name).strip() if len(name) 1: box obj.find(bndbox) chars.append((name, float(box.findtext(xmin)))) if chars: chars.sort(keylambda c: c[1]) # 按 xmin 从左到右 plate_texts[xml_file.stem] .join(c[0] for c in chars) for stem, text in list(plate_texts.items())[:5]: print(stem, text)逻辑说明chars.sort(keylambda c: c[1])是按字符框的 xmin 排序拼接前提是车牌在画面里基本水平。如果抓拍图里车牌倾斜这一步出来的字符串就是乱的需要先做旋正或者直接用 LPRNet 这类带空间变换的模型。参数上xmin 取浮点比较避免整数排序在字符宽度接近时出现不稳定。字符集设计是这里最容易被忽略的坑。民用蓝牌的标准字符集是 31 个省份汉字简称加 0-9 加 24 个字母去掉 I 和 O新能源车牌是 8 位第 6 位固定是 D 或 F。如果你的数据集只标了数字和字母省份汉字就缺位输出层就按 34 类设计如果要兼容完整车牌汉字类别还得想别的办法补数据。像臻识这类车牌识别一体机固件配置字符集时也会把 I 和 O 去掉模型输出层和这个白名单对齐能省掉很多混淆样本。5. 车牌识别数据集实战避坑5个解压后容易踩的坑1458 张图不大但该翻的车一个都不会少。下面五条都是解压后到训练完这个周期里真实会碰到的问题按“现象、原因、解决”写照着排查比瞎调参快得多。5.1 现象mAP50卡在0.4上不去原因整牌框和字符框混着标现象用 YOLOv8 训练loss 正常下降验证集 mAP50 卡在 0.4 左右画出来的预测框有时框住整个车牌有时只框住一个字符。原因这个数据集如果来自多个标注批次很可能一部分 XML 按整张车牌标一个对象另一部分按字符标了五六个对象。类别名恰好都短训练脚本不会报错但目标尺度分布是双峰的模型在两种尺度之间反复横跳。解决训之前先跑宽高比统计把两类标注分开。一条命令验证python - EOF import xml.etree.ElementTree as ET from pathlib import Path for xml_file in Path(Annotations).glob(*.xml): root ET.parse(xml_file).getroot() for obj in root.findall(object): name obj.findtext(name).strip() box obj.find(bndbox) w float(box.findtext(xmax)) - float(box.findtext(xmin)) h float(box.findtext(ymax)) - float(box.findtext(ymin)) print(name, round(w / max(h, 1e-6), 2)) EOF输出里宽高比出现明显的两簇1 附近一堆、3 附近一堆就是混标了。只选一种粒度当检测目标字符级标注留到第四章做 OCR 数据别再混着训。5.2 现象转格式报IndexError原因XML的name不在类别表里现象跑转换脚本时在CLASSES.index(name)那一行抛ValueError少数 XML 处理到一半整个进程崩溃。原因人工标注时字符集没对齐XML 里混进了你类别表里没有的东西最常见的是汉字省份简称“京”“粤”混进来或者把字母O当成了数字0标进 XML。解决转换前先收集全量类别名用统计脚本把出现过的 name 全部打出来再决定类别表怎么写from collections import Counter all_names Counter() for xml_file in Path(Annotations).glob(*.xml): root ET.parse(xml_file).getroot() for obj in root.findall(object): all_names[obj.findtext(name).strip()] 1 print(all_names)如果看到京: 214这样的条目说明数据里其实带了省份汉字只是标题没写。这时要么把汉字加进类别表要么在转换脚本里过滤掉取决于你的项目要不要省份信息。转换脚本里遇到未登记类别建议打印告警而不是静默跳过不然你不知道丢了什么。5.3 现象验证集指标虚高原因同一辆车跨了train和val现象训练时 mAP50 到 0.95模型自信满满换到没见过的停车场视频抽帧测试识别率掉到六成。原因随机洗牌把同一辆车在同一时间段连拍的帧拆到了 train 和 val 两侧验证集和训练集高度相似指标失去参考意义。这是小数据集最容易出现的乐观偏差。解决按拍摄批次分组划分就是 3.2 的做法。分组键选文件名前缀、时间戳聚类或者相机 ID 都可以原则是同一个物理车牌在同一个时间窗口内的所有帧必须进同一个集合。验证集指标从此会变得“难看”一些但那个数字才是真实水平。5.4 现象0和O、1和I分不清原因字符集设计就错了现象训练完混淆矩阵里 0 和 O、1 和 I 的错检特别集中怎么加数据增强都压不下去。原因民用蓝牌字符集根本没有 I 和 O但标注阶段如果人工录入不严谨会把 0 标成 O、把 1 标成 I。模型学到的这两个类几乎同特征靠卷积本身消不掉这种歧义。解决字符频次统计里先查 I 和 O 是不是存在存在就合并或删除输出层用 34 类而不是 36 类。这一步要在定类别表之前做类别表一旦定了改一次所有 txt 标签全要重转。记得同步到识别相机和算法的字符集白名单里比如臻识那些一体机的字符集配置统一标准才能不出乱子。5.5 现象检测框整体偏移原因XML的size和真实分辨率对不上现象训练能跑loss 也降但画出来的框整体往左上或右下偏移尤其在大图上特别明显。原因数据集流传过程中图片被压缩过长边从 1920 缩到 1280XML 里的size却没跟着更新。归一化坐标用的分母是旧宽高转换出来全部偏移。这种错最阴因为不报错、loss 照降只有可视化才能看出来。解决转换脚本里坚持用cv2.imread拿真实宽高不读 XML 的 size——3.1 的代码里就是这么处理的。顺带提醒有些下载包是 zip 伪加密解压工具会提示要密码用 7-Zip 或 Python 的zipfile直接读一般都能正常解开别急着删文件先确认是不是伪加密标志在作怪。6. 让1458张标注图的价值翻倍增强边界、伪标注复核与上线验证小数据集靠模型硬扛不如靠策略补。数据增强、伪标注和验证三板斧用好了1458 张的效果能接近小几千张的数据。6.1 车牌专用增强HSV扰动最有效Mosaic别用到OCR上车牌识别场景的主要干扰是光照HSV 抖动里的亮度和饱和度扰动性价比最高。旋转增强控制在 ±10 度以内超过 15 度车牌产生明显透视畸变模型反而学歪。检测阶段可以开 Mosaic但 OCR 阶段别开——字符图被切碎后标签就对不上了。还有一个原则不要对车牌做水平翻转汉字车牌不是对称目标翻转后字符顺序反过来标签直接错。6.2 伪标注扩充用高置信度推理结果做第二批训练数据1458 张训出来的检测器丢到没标注的监控录像里抽帧挑置信度大于 0.9 的检测框导出人工复核这批高置信度结果比从头画框快得多。复核过关的样本并回训练集再训一轮等于让模型自己找数据、你只做质检这是小数据集少有的后悔药。注意每轮伪标注的量控制在原数据的三成以内别让模型陷入自己的错误预测里。6.3 上线验证混淆矩阵、漏检率和现场实拍抽帧训练结束后别只看 mAP。打开训练输出里的confusion_matrix.png看类别间错检重点盯车牌检测的recall0.5漏检比误检致命因为漏一辆车意味着道闸不抬。最后一步永远是用目标场景的实拍视频抽帧验证停车场入口、地下车库、夜间、雨雾各抽 100 张统计从“图片进来”到“车牌字符串输出”的端到端识别率而不是单看检测模型指标。我现在拿到任何车牌数据集第一件事永远是跑标注体检脚本确认字符集和标注粒度再谈训练。这个习惯帮我少翻了好几次车——数据没看清之前所有模型调参都是玄学。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →