☰
YOLO车牌检测实战:1019张数据集从清洗到调参全链路
2026/10/1 5:00:48 网站建设 项目流程

简介:本资源为面向YOLO系列目标检测算法的车牌检测数据集,适合正在学习或落地车牌识别项目的开发者、学生及算法工程师使用,可直接用于模型训练与验证测试。数据集已按训练需求划分完毕,并附带data.yaml配置文件,兼容yolov5、yolov7、yolov8、yolov9、yolov10及yolo11等主流版本,省去自行整理与划分的繁琐工作。压缩包共约2000个文件,整体大小47.28MB,其中包含1019个xml标注文件、980个txt标注文件以及1个yaml配置文件;标签同时提供YOLO格式与VOC格式两套,分别存放于不同文件夹,YOLO格式采用类别索引与归一化中心点、宽高比例值,便于直接读取训练。目前已有153人学习下载,读者可借此快速搭建车牌检测实验环境,验证不同YOLO版本的训练效果,并对照两种标注格式理解数据组织方式,为后续调参与模型优化提供可靠基础。

1. 1019 张车牌图像到手后,先别急着喂给 YOLO

拿到一个名为yolo算法-车牌检测数据集-1019张图像带标签-.zip的压缩包,很多人的第一反应是解压、改data.yaml、开训。我见过太多人这么干,然后 mAP 卡在 0.6 上不去,回头怀疑是模型不行、显卡不行、YOLO 版本不行,唯独不怀疑数据本身。1019 张这个量级,说多不多,说少不少——它刚好卡在「够跑通一个 demo」和「撑不起一个工业级检测器」之间。车牌检测这个任务又有它的特殊性:目标长宽比极端(普遍在 3:1 到 5:1)、小目标占比高、光照和角度变化剧烈,还有蓝牌、黄牌、绿牌、白牌混在一起。这篇文章就围绕这个数据集,把从解压验收到训练调参、再到踩坑排查的完整链路讲清楚,适合手里已经攥着类似数据集、想真正把它跑出可用精度的人。下面所有操作我都按「先验证、再转换、后训练」的顺序来,别跳步。

2. 解压之后先做体检:1019 张车牌图的标签到底长什么样

2.1 目录结构与标注格式的三种常见形态

这类打包数据集,解压后大概率是下面几种结构之一,先tree或ls -R看一眼再动手:

# 先看整体结构,别急着写训练脚本 unzip yolo算法-车牌检测数据集-1019张图像带标签-.zip -d plate_dataset cd plate_dataset find . -maxdepth 2 -type d | sort find . -name "*.txt" | head -5 find . -name "*.xml" | head -5

常见形态有三种:第一种是已经切好images/和labels/的 YOLO 格式,.txt里每行是class x_center y_center width height,全部归一化到 0~1;第二种是 VOC 格式,一堆.xml,需要自己转;第三种最坑,图片和标签混在一个目录,标签文件名和图片名对不上。先确认属于哪种,再决定后面走哪条路。

判断 YOLO 标签是否合法的关键,是看数值有没有超过 1、有没有负数、每行是不是 5 个字段:

import os, glob def check_yolo_label(label_path): problems = [] with open(label_path, 'r') as f: lines = [l.strip() for l in f if l.strip()] for i, line in enumerate(lines): parts = line.split() if len(parts) != 5: problems.append(f"第{i}行字段数={len(parts)},应为5") continue cls, x, y, w, h = parts vals = list(map(float, parts[1:])) if any(v < 0 or v > 1 for v in vals): problems.append(f"第{i}行坐标越界: {vals}") if float(w) <= 0 or float(h) <= 0: problems.append(f"第{i}行宽高非正: w={w}, h={h}") return problems bad = 0 for lp in glob.glob("labels/**/*.txt", recursive=True): p = check_yolo_label(lp) if p: bad += 1 print(lp, p[:2]) print(f"问题标签文件数: {bad}")

这段脚本做三件事:字段数校验、归一化坐标范围校验、宽高正值校验。跑完如果bad是 0,说明标签格式基本干净;如果有一批文件报「字段数=4」或「坐标越界」,那多半是转换脚本写错了,得回头查。

2.2 用统计量判断这 1019 张值不值得训

光看格式不够,还要看分布。车牌检测最怕两件事:一是目标框小到几十像素,二是长宽比离谱。写个脚本把宽高分布和长宽比打出来:

import glob import numpy as np ws, hs, ratios, per_img = [], [], [], [] for lp in glob.glob("labels/**/*.txt", recursive=True): with open(lp) as f: boxes = [l.split() for l in f if l.strip()] per_img.append(len(boxes)) for b in boxes: w, h = float(b[3]), float(b[4]) ws.append(w); hs.append(h) ratios.append(w / h if h > 0 else 0) ws, hs, ratios = np.array(ws), np.array(hs), np.array(ratios) print(f"总框数: {len(ws)}") print(f"每图平均框数: {np.mean(per_img):.2f}, 最多: {max(per_img)}") print(f"归一化宽 中位数: {np.median(ws):.3f}, 5%分位: {np.percentile(ws,5):.3f}") print(f"归一化高 中位数: {np.median(hs):.3f}, 5%分位: {np.percentile(hs,5):.3f}") print(f"长宽比 中位数: {np.median(ratios):.2f}, 90%分位: {np.percentile(ratios,90):.2f}")

参数怎么读:归一化宽高的 5% 分位如果低于 0.02,说明有大量极小目标,训练时imgsz至少开到 640,最好 960;长宽比中位数如果在 2.5 以上,说明这是典型的扁长目标,默认 anchor 可能不匹配,YOLOv8 这类 anchor-free 的会好一些,但数据增强里的缩放策略要小心,别把车牌压变形。每图平均框数如果接近 1,说明大部分图只有一块牌,那分类分支的压力小,重点全在定位。

提示:1019 张图如果每图平均只有 1 个框,总框数也就 1000 出头,这个量级训练时务必开强增强,否则两三个 epoch 就过拟合。

2.3 划分训练验证集时别用随机划分

车牌数据往往来自连续视频抽帧,相邻帧几乎一样。如果你用random.shuffle划分,训练集和验证集里会出现同一辆车的相邻帧,验证指标虚高,实际部署翻车。正确做法是按「场景/视频源/时间段」分组划分。如果数据集没给分组信息,至少按文件名前缀或拍摄日期粗分:

import os, glob, shutil, random imgs = sorted(glob.glob("images/**/*.jpg", recursive=True)) # 按文件名前缀分组,假设前缀代表不同来源 groups = {} for p in imgs: key = os.path.basename(p).split('_')[0] groups.setdefault(key, []).append(p) keys = list(groups.keys()) random.seed(42) random.shuffle(keys) val_keys = set(keys[:max(1, int(len(keys)*0.2))]) for p in imgs: key = os.path.basename(p).split('_')[0] split = "val" if key in val_keys else "train" dst = p.replace("images", f"images_{split}") os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copy(p, dst)

逻辑说明:先按前缀聚组,再对「组」做随机划分,保证同组图像只进一个集合。0.2是验证集比例,1019 张的话验证集约 200 张,够看趋势但不适合做精细调参。如果前缀分不出组,退而求其次按文件修改时间排序后按比例切,也比纯随机强。

3. 从 VOC 到 YOLO:转换脚本与四个边界坑

3.1 转换脚本要处理的四种异常

如果拿到的是 VOC 格式,转换是绕不过去的。网上抄来的脚本大多只处理正常情况,实际数据里这四种异常必踩:坐标越界(xmax 超过图像宽)、宽高为 0、类别名带空格、图像和 xml 对不上。下面这版脚本把这四种都兜住了:

import os, glob import xml.etree.ElementTree as ET from PIL import Image CLASS_MAP = {"plate": 0, "license_plate": 0, "car_plate": 0} # 按实际类别名补 def convert(xml_path, img_dir, out_dir): tree = ET.parse(xml_path) root = tree.getroot() img_name = root.find("filename").text img_path = os.path.join(img_dir, img_name) if not os.path.exists(img_path): return f"缺图: {img_name}" w, h = Image.open(img_path).size lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in CLASS_MAP: continue bbox = obj.find("bndbox") xmin = max(0, float(bbox.find("xmin").text)) ymin = max(0, float(bbox.find("ymin").text)) xmax = min(w, float(bbox.find("xmax").text)) ymax = min(h, float(bbox.find("ymax").text)) bw, bh = xmax - xmin, ymax - ymin if bw <= 1 or bh <= 1: continue # 丢弃退化框 xc = (xmin + xmax) / 2 / w yc = (ymin + ymax) / 2 / h lines.append(f"{CLASS_MAP[name]} {xc:.6f} {yc:.6f} {bw/w:.6f} {bh/h:.6f}") if not lines: return f"无有效框: {img_name}" out = os.path.join(out_dir, os.path.splitext(img_name)[0] + ".txt") with open(out, "w") as f: f.write("\n".join(lines)) return None errs = [] for xp in glob.glob("annotations/*.xml"): e = convert(xp, "images", "labels") if e: errs.append(e) print(f"异常数: {len(errs)}") for e in errs[:10]: print(e)

关键参数说明:CLASS_MAP必须按你数据里真实的类别名改,大小写敏感;bw <= 1这个阈值是经验值,小于 1 像素的框留着只会污染损失;坐标裁剪用max/min夹到图像边界内,避免归一化后出现负数或大于 1。跑完看errs,如果「缺图」很多,说明 xml 里的 filename 和实际文件名不一致,得先修文件名。

3.2 转换后必须做一次可视化抽检

数字对不代表框对。转换完随机抽 20 张画框看一眼,这一步能省掉后面几小时的无效训练:

import cv2, glob, random samples = random.sample(glob.glob("images/*.jpg"), 20) for ip in samples: img = cv2.imread(ip) h, w = img.shape[:2] lp = ip.replace("images", "labels").replace(".jpg", ".txt") if not os.path.exists(lp): continue with open(lp) as f: for line in f: c, xc, yc, bw, bh = map(float, line.split()) x1 = int((xc - bw/2) * w); y1 = int((yc - bh/2) * h) x2 = int((xc + bw/2) * w); y2 = int((yc + bh/2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(f"vis_{os.path.basename(ip)}", img)

逻辑说明:把归一化坐标反算回像素坐标画框,如果框的位置和车牌对不上、或者框明显偏移,说明转换时宽高算错了(常见是把xmax-xmin写成了xmax)。这一步别偷懒,我见过太多人跳过可视化,训到一半才发现框全是错的。

3.3 data.yaml 的写法与路径陷阱

YOLO 训练靠data.yaml找数据,路径写错是最常见的「训练启动即报错」原因:

path: /abs/path/to/plate_dataset train: images_train val: images_val nc: 1 names: ['plate']

注意path用绝对路径,train/val是相对path的子目录。nc是类别数,车牌检测通常就是 1 类,但如果你数据里有蓝牌黄牌分开标注,就得按实际类别数改。names的顺序必须和标签里的 class id 对应,错一位整个训练就废了。

4. 用 YOLOv8 把 1019 张跑起来:参数怎么设、指标怎么看

4.1 训练命令与关键参数逐条解释

环境装好后,一条命令就能起训,但参数不能全默认:

yolo detect train \ data=plate_dataset/data.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=960 \ batch=16 \ lr0=0.01 \ lrf=0.01 \ mosaic=1.0 \ mixup=0.1 \ degrees=10 \ translate=0.1 \ scale=0.5 \ fliplr=0.5 \ patience=30 \ project=runs/plate \ name=v8n_960

逐条说:model=yolov8n.pt用预训练权重,1019 张这个量级别从零训;imgsz=960是因为车牌目标小,640 会丢细节,但 960 显存占用翻倍,batch 要相应降到 16 甚至 8;lr0=0.01是初始学习率,小数据集可以降到 0.005 更稳;mosaic=1.0是 YOLO 的招牌增强,但车牌是扁长目标,mosaic 拼接后可能出现不自然的车牌,训到后期建议关掉(YOLOv8 默认最后 10 个 epoch 自动关);degrees=10限制旋转角度,车牌旋转太大会让框变得很怪;patience=30是早停,验证指标 30 轮不涨就停,省时间。

注意:如果训练中 loss 突然变 nan,八成是学习率太高或 batch 太小导致 BN 统计不稳,先把lr0降到 0.001、batch提到能塞下的最大值再试。

4.2 训练过程中该盯哪几个指标

YOLO 训练会输出一堆指标,别只看 mAP50。车牌检测重点看三个:metrics/mAP50-95(综合精度)、val/box_loss(定位损失)、metrics/precision(误检率)。如果 mAP50 高但 mAP50-95 低,说明框的位置不够准,得加定位相关的增强或提高 imgsz;如果 precision 低 recall 高,说明误检多,可以调高置信度阈值或加负样本。

训练完在验证集上跑一次评估,拿到混淆矩阵和 PR 曲线:

yolo detect val \ model=runs/plate/v8n_960/weights/best.pt \ data=plate_dataset/data.yaml \ imgsz=960 \ conf=0.25 \ iou=0.6 \ plots=True

conf=0.25是评估时的置信度阈值,iou=0.6是 NMS 的 IoU 阈值。这两个值直接影响 precision/recall,部署时按实际场景调——宁可误检不可漏检的场景把 conf 降到 0.1,反之提到 0.4。

4.3 1019 张训出来的模型大概什么水平

按我的经验,1019 张干净标注、场景不太单一的车牌数据,YOLOv8n 在 960 分辨率下训 150 epoch,验证集 mAP50 大概能到 0.92~0.96,mAP50-95 在 0.65~0.75。如果 mAP50 低于 0.85,先别调模型,回去查数据:标签有没有错、验证集有没有和训练集重复、有没有大量模糊到人眼都看不清的图。这个量级的数据,模型不是瓶颈,数据质量才是。

5. 训练翻车现场:五个我真实踩过的坑

5.1 现象:mAP 从第 20 轮开始不涨,loss 也不降

原因:学习率衰减太慢,或者数据增强太弱导致模型早早拟合了训练集。1019 张图,如果mosaic和scale都开得很小,模型几轮就记住了所有样本。

解决:把scale提到 0.5、mosaic保持 1.0,lr0从 0.01 降到 0.005,cos_lr=True打开余弦退火。改完重训,通常 mAP 能再涨 2~3 个点。

5.2 现象:验证集 mAP 很高,但拿真实视频一测全是漏检

原因:验证集和训练集同源,分布太像。1019 张如果全是一个摄像头、一个时间段拍的,验证集根本代表不了真实场景。

解决:手动挑一批不同光照、不同角度的图单独做测试集,哪怕只有 50 张。训练时把val指向这批图,指标才有参考意义。这一步没有捷径,只能人工筛。

5.3 现象:训练报错Label class X is not in the data.yaml

原因:标签里的 class id 超过了nc,或者names列表长度和nc对不上。常见于转换脚本里CLASS_MAP写错,把两个类别映射成了同一个 id 又没改nc。

解决:跑一遍全量标签的 class id 统计,确认最大值小于nc:

import glob ids = set() for lp in glob.glob("labels/**/*.txt", recursive=True): with open(lp) as f: for line in f: if line.strip(): ids.add(int(line.split()[0])) print("出现的class id:", sorted(ids))

如果输出是[0, 1]而nc=1,把nc改成 2 并补上names。

5.4 现象:显存爆了,batch 降到 4 还是 OOM

原因:imgsz=960加上mosaic增强,单张图实际占用远超预期。YOLOv8 的 mosaic 会把 4 张图拼成一张,等效 batch 翻 4 倍。

解决:先把imgsz降到 640 跑通,确认流程没问题再往上加;或者用batch=-1让 YOLO 自动找最大可用 batch。另外workers设太大也会占内存,设成 CPU 核数的一半就行。

5.5 现象:推理时框重叠严重,同一块车牌出好几个框

原因:NMS 的 IoU 阈值设太高,或者模型对同一目标输出了多个高置信度框。车牌长宽比大,两个框稍微错位 IoU 就低于阈值,NMS 去不掉。

解决:推理时把iou从默认 0.7 降到 0.5,或者用agnostic_nms=True。如果还不行,说明模型没训好,回去看val/box_loss是不是一直偏高。

6. 把 1019 张榨干:小数据集的进阶技巧

数据量固定的时候,能提升的空间全在「怎么用」上。第一个技巧是切片推理(SAHI):车牌在整图里占比小,直接缩放会丢细节,把大图切成带重叠的小块分别检测再合并,小目标召回能涨 5~10 个点。代价是推理变慢,适合离线场景。第二个技巧是用检测框做二次分类:先检测出车牌,再裁出来送一个小分类网络判断颜色或类型,比在检测头里硬塞多分类稳。第三个技巧是伪标签:拿训好的模型去跑未标注的车牌视频,把高置信度的框当标签加进训练集,一轮下来通常能补几百张有效样本,但置信度阈值要卡到 0.7 以上,否则错误会累积。

验证一个车牌检测模型到底行不行,别只看 mAP。我的习惯是准备一段 30 秒的真实道路视频,跑完统计三个数:漏检率、误检率、单帧耗时。漏检率超过 5% 就不能上线,误检率超过 10% 要回去调 conf,单帧耗时超过 50ms 就得考虑换更小的模型或 TensorRT 加速。这套流程我用了几年,比任何指标都实在。希望帮到你。

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

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

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

立即咨询