CCPD数据集解析与YOLOv5车牌识别全流程实战
2026/9/11 12:37:31 网站建设 项目流程

简介:基于CNN与YOLOv5的车牌检测与识别项目,采用CCPD官方数据集,是一套面向毕业设计、课程设计、实训与大作业等场景的完整可运行工程。压缩包共含10个文件,大小约44.33MB,核心内容包括Python源码、YOLOv5权重文件、TensorFlow模型文件、YAML配置、Jupyter Notebook训练脚本及说明文档,覆盖模型训练、推理、参数配置等环节,能够直接满足复现需求。依托CCPD官方数据集的真实停车场景,模型训练数据具有较强代表性;项目代码经过严格测试,功能正常,设计报告可参考,答辩平均分达96分,完成度较高。目前已有77人学习/下载,适合希望在车牌识别方向快速搭建项目、理解CNN与YOLOv5应用流程的开发者。使用中遇到问题可联系作者获得指导,便于顺利复现或扩展功能。

1. CCPD与YOLOv5车牌识别流水线:从文件名里挖出标签

CCPD这套车牌数据集的标签不在JSON里、不在xml里,而是直接压缩在图片文件名中。第一次用的时候,我把它当普通编号跳过,结果YOLOv5训练出来框是歪的。直到按官方说明拆开文件名,才发现每一段都对应着车牌面积占比、倾斜角、角点坐标和具体车牌号。这里把YOLOv5车牌检测和CNN识别串成一条完整流水线:先用YOLOv5从整图中定位车牌区域,再把裁剪出的车牌图交给CNN完成字符识别,最终输出类似“京A12345”的结果。对做毕业设计、课程设计或工程实训的人来说,理解“文件名标签→YOLOv5检测→CNN识别”这条数据链路,比跑通单个demo重要得多,它能直接决定你复现时数据对不对、训练有没有效、答辩能不能讲清楚。

2. 解析CCPD数据集:把文件名当标签用,转出YOLO训练格式

2.1 CCPD的目录结构与文件名分段

CCPD全称China City Parking Dataset,图片大多拍摄自城市停车场景,背景复杂、车辆密集、车牌倾斜角度大。数据集按场景分成了多个子集,最常用来做训练的是ccpd_base,这个子集里的图片保留了完整的停车场景环境,车牌只占画面的一小部分,所以YOLOv5能学到“车牌和车脸背景”的区分,而不是对着纯车牌图做检测。

CCPD最反直觉的设计,是它把标注信息全部塞进了文件名。拿一条典型记录来看:

025-95_113-154&383_386&473-386&473_177&454_154&383_363&402-0_0_22_27_27_33_16-37-15.jpg

-分割后,每一段都有明确含义。

分段示例含义
第1段025车牌面积占整图面积的比例
第2段95_113水平倾斜角和垂直倾斜角
第3段154&383_386&473车牌外接矩形的左上角与右下角坐标
第4段386&473_177&454_154&383_363&402车牌四个顶点的像素坐标,顺时针排列
第5段0_0_22_27_27_33_16车牌号码,每段是字符在映射表中的索引
第6段37图像亮度
第7段15图像模糊度,数值越大越模糊

第5段就是训练时唯一的“真实标签”。它一共7位,对应大陆车牌的第1位汉字省份简称加后6位字母数字。映射规则常见做法是:0-9表示数字,10-33附近一段表示字母(部分字母如I、O会被跳过),再往后是31个省份汉字简称。具体索引顺序在不同版本里略有差异,资源里的plate_dec.py维护着一张官方顺序的映射表,解密时直接复用它,不要自己重新发明。

2.2 用Python解析文件名并还原车牌号

解析脚本的核心是字符串切分和角点坐标换算。下面这个函数可以直接放在数据处理脚本的开头:

import re def parse_ccpd_filename(filename): # 去掉扩展名,按 - 分段 name = filename.replace('.jpg', '') parts = name.split('-') # 第3段:左上角与右下角坐标,格式 154&383_386&473 lt_x, lt_y, rb_x, rb_y = map(int, parts[2].replace('&', '_').split('_')) # 第4段:四个角点,顺时针方向,用来验证外接矩形 corners = list(map(int, parts[3].replace('&', '_').split('_'))) # 第5段:车牌字符索引,7位数字 plate_idx = parts[4].split('_') return { 'bbox': (lt_x, lt_y, rb_x, rb_y), 'corners': corners, 'plate_idx': plate_idx, 'brightness': int(parts[5]), 'blur': int(parts[6]) } # 用上面那条文件名测试 info = parse_ccpd_filename('025-95_113-154&383_386&473-386&473_177&454_154&383_363&402-0_0_22_27_27_33_16-37-15.jpg') print(info['bbox'])

这段代码做的事很简单:先把文件名按照-切开,然后对第3段做两次替换,把&_统一成下划线,再按_拆出4个整数,得到外接矩形的左上和右下坐标。这里有一个容易踩的细节,CCPD的第4段角点坐标同样用&连接,如果直接按split('_')处理,拿到的不是干净的数字,必须先replace成统一分隔符再转int,否则类型转换会报错。

拿到bbox后,车牌区域就有了。这一步是整个数据准备的地基,后面的YOLO格式标签、训练时的样本裁剪,全部依赖这一段解析逻辑。至于第5段的plate_idx,先原样保存,等到写评估脚本时再查表还原成字符串。

2.3 转成YOLO格式并切分训练集与验证集

YOLOv5不认CCPD的角点格式,它需要的是归一化后的x_center y_center width height。转换时直接用外接矩形四个值去算中心点和宽高,再除以图片尺寸。因为CCPD的标注本身是近似轴对齐的矩形,直接取外接矩形不会引入额外噪声,不需要做旋转框处理。

import os import random import cv2 def ccpd_to_yolo(img_path, tag, save_dir): # 读取图片尺寸,用来归一化 img = cv2.imread(img_path) h, w = img.shape[:2] info = parse_ccpd_filename(os.path.basename(img_path)) lt_x, lt_y, rb_x, rb_y = info['bbox'] # 转成中心点 + 宽高 + 归一化 box_w = rb_x - lt_x box_h = rb_y - lt_y x_c = lt_x + box_w / 2.0 y_c = lt_y + box_h / 2.0 x_c_norm, y_c_norm = x_c / w, y_c / h w_norm, h_norm = box_w / w, box_h / h # 写入YOLO标签文件:类别ID固定为0,只有一个'plate'类 label_path = os.path.join(save_dir, tag) with open(label_path, 'w') as f: f.write(f'0 {x_c_norm:.6f} {y_c_norm:.6f} {w_norm:.6f} {h_norm:.6f}\n')

转换脚本里有一处需要额外注意:box_wbox_h必须用rb_x - lt_x这种差值计算,不能直接拿角点坐标当宽高用。我刚上手时把rb_x误当成宽度,结果训练了20个epoch后画出来的标注框全是横向拉长的矩形,最后排查到是这里写错了。

数据划分按常见做法保持9:1,并且固定随机种子。训练集和验证集要从目录维度打散,不要按文件名首字母排序后直接切前90%,那样会让同段道路拍摄的图片全部挤在训练集里,验证集完全失去意义。建议在生成标签文件的循环里加一个random.random() < 0.9判断,边转换边决定是写入train列表还是val列表。

3. YOLOv5检测端训练:plate.yaml、超参数选择与结果判读

3.1 配置plate.yaml

数据准备好之后,先在YOLOv5工程目录下新建plate.yaml。这个文件告诉训练脚本去哪里读图片、有几类目标、类名是什么。资源里已经配好了plate.yaml,你只需要确认里面的路径和你转换后的目录结构对得上。

# plate.yaml path: ../ccpd_format # 数据根目录 train: images/train # 训练图片相对路径 val: images/val # 验证图片相对路径 nc: 1 # 类别数,这里只有'plate'一个类 names: ['plate'] # 类别名,建议用英文,防止绘图时中文乱码

三个字段缺一不可:path是相对于你执行训练命令的目录来写的,如果你的数据放在YOLOv5工程外面,写成绝对路径更省事;trainval指向的是图片目录而不是标签目录,YOLOv5会按同名规则自动去找与图片同名的txt标签文件,所以标签目录结构必须和图片目录完全一致。nc为1代表只做单类检测,这个项目不需要区分蓝牌和黄牌,都归为plate即可。

3.2 训练命令与超参数选择

训练命令使用YOLOv5官方train.py,权重建议用yolov5s.pt起步,s版本在精度和推理速度之间最均衡。对GPU显存小于8G的环境,如果跑不动640分辨率,可以把--img降到512,但代价是对小目标的召回率会明显下降,因为CCPD里车牌宽高往往只有整图的十几分之一。

python train.py \ --data plate.yaml \ --weights yolov5s.pt \ --img 640 \ --batch-size 16 \ --epochs 100 \ --hyp data/hyps/hyp.scratch-low.yaml \ --project runs/plate_train

这里有几个参数值得单独调。--hyp默认很多人不设,但车牌检测属于单类别小目标场景,我一般会用hyp.scratch-low.yaml而不是high。high配置里有更强的色彩抖动和随机仿射变换,对小目标检测来说,过强的mosaic数据增强会把车牌裁切到很小的局部块,反而让模型学到错误的上下文特征。low配置保留基本增强,收敛更稳定,CCPD这种百万级图片规模的数据集不需要靠极端增强来凑数量。

yolov5超参数文件里的lr0初始学习率是另一个关键点。hyp.scratch-low.yaml默认lr0: 0.01,配合batch-size 16不需要手动调整;如果你把batch-size加到32或64,学习率要做同步缩放,常见做法是按lr *= batch / 16等比放大。不改学习率硬上大batch,训练前期loss会下降得特别慢。

参数推荐值说明
--img640车牌是小目标,太小丢失特征;太大显存不够
--batch-size168G显存的安全值,16G可以上32
--epochs100车牌单类目标50轮基本收敛,100轮留足余量
--hyplow弱增强更适合小目标检测
--weightsyolov5s.pt用COCO预训练权重做迁移学习,收敛更快

训练启动后,重点不是盯着终端刷epoch,而是去看runs/plate_train/exp/目录下生成的文件。train_batch0.jpgval_batch0_labels.jpg这两张图会展示前几个batch的标注框叠加效果,如果这里框和车牌对不上,说明上一章的坐标转换脚本写错了,这时应该立刻Ctrl+C去查解析逻辑,而不是继续等100个epoch训完。

3.3 从results.csv判读训练状态

每轮训练结束后,YOLOv5会把所有关键指标写入runs/plate_train/exp/results.csv。用下面的命令可以实时看损失下降趋势:

tail -n 20 runs/plate_train/exp/results.csv

正常收敛过程中,train/box_losstrain/obj_loss应该在前30个epoch内从0.07左右降到0.03以下,metrics/mAP@0.5逐步逼近0.95以上。如果box_loss震荡不降,优先检查标签是否出现大面积为0的归一化值——CCPD文件名解析时如果宽高算成负数,写入txt的坐标值就会越界,YOLOv5不会报错,但loss永远不收敛。

YOLOv5的backbone默认采用CSPNet结构,它在残差连接的基础上做了梯度截断,让CNN在深层网络中仍能保持较强的特征学习能力。对车牌这种目标小、纹理边界清晰的任务,CSPNet在浅层就能捕获到车牌边缘的强响应,所以即便只用s版本,精度也足够应对CCPD的复杂背景。等这一轮训练结束,runs/plate_train/exp/weights/best.pt就是后续推理使用的最终权重。

4. CNN识别端:saved_model加载、预处理与字符解码

4.1 为什么车牌字符识别要单独接CNN

YOLOv5检测端输出的只是一组坐标框,它只回答“车牌在哪里”,不回答“车牌号是多少”。要把框里的内容变成文本,需要第二个模型。车牌识别本质上是一个定长的序列分类问题,大陆车牌固定7个字符,第一位是省份简称汉字,后面6位是字母和数字,这种强结构非常适合用CNN卷积神经网络做整图多标签分类。

那为什么不直接把YOLOv5的分类头扩展成车牌子类别?因为定位和字符识别是两种粒度不同的问题。YOLOv5负责在整张场景图中找到目标,它的分类能力更多体现在“区分车和背景”上;而车牌识别要把70x30左右的小图细分出31个省份汉字加34个字母数字的差异,这需要专门的纹理特征提取网络。两级串接可以把检测和识别分开调优,检测召回率不够就加数据,识别精度不够就换识别网络,互不干扰,这是实际工程里更稳妥的做法,也方便答辩时单独讲每一段的改进空间。

4.2 加载plateRec_model并确定输入尺寸

资源里的plateRec_model保存的是TensorFlow SavedModel格式,目录下带有keras_metadata.pbvariables文件夹,说明模型是用tf.keras导出的。加载方式如下:

import tensorflow as tf # 加载整个saved_model目录 model = tf.keras.models.load_model('plateRec_model') # 打印输入输出张量的shape,确认预处理尺寸 print("input shape:", model.inputs[0].shape) print("output shape:", model.outputs[0].shape)

model.inputs[0].shape是(1, height, width, channels)或者(None, height, width, channels),常见的车牌识别网络输入是(70, 30, 3)或(168, 48, 3)这个数量级的尺寸。model.outputs[0].shape如果是(None, 7, 65),说明网络直接输出7个字符各自的分类概率;如果看到的是(None, 455),那是7乘65摊平后的结果,后面需要reshape。

拿到输入尺寸后,写一个通用的预处理函数,让resize目标尺寸从模型中动态读取,而不是硬编码。这样换模型时不需要改代码:

def preprocess_plate_crop(crop_bgr): # crop_bgr: YOLOv5裁剪出的车牌BGR图 h, w = model.inputs[0].shape[1], model.inputs[0].shape[2] resized = cv2.resize(crop_bgr, (w, h)) # 注意是(w, h)顺序 rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB) # BGR转RGB normalized = rgb.astype('float32') / 255.0 # 归一化到[0,1] return tf.expand_dims(normalized, axis=0) # 增加batch维度

这里有一个高频率踩坑点:cv2.resize的第二个参数是(width, height),而model.inputs[0].shape返回的顺序是(height, width, channels),直接拿shape[1]和shape[2]去resize会正好把宽高对调。我习惯先把h和w单独取出来,再按(w, h)传给resize,避免这个低级错误。

4.3 字符映射与plate_dec.py解码

plate_dec.py在项目里的作用是把模型输出的概率向量还原成车牌字符串。它内部维护一张索引到字符的映射表,常见结构是:

# 省份简称,索引从35开始;前0~9是数字,10~34是字母 PROVINCES = ['京', '津', '冀', '晋', '蒙', '辽', '吉', '黑', '沪', '苏', '浙', '皖', '闽', '赣', '鲁', '豫', '鄂', '湘', '粤', '桂', '琼', '渝', '川', '贵', '云', '藏', '陕', '甘', '青', '宁', '新'] LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ' # 部分版本不含I和O DIGITS = '0123456789' def idx_to_char(idx): if idx < 10: return DIGITS[idx] if idx < 10 + len(LETTERS): return LETTERS[idx - 10] return PROVINCES[idx - 10 - len(LETTERS)]

解码的完整逻辑是:模型输出先做一次argmax,拿到7个字符各自的索引,然后逐位查表拼成字符串。如果模型输出层是摊平结构,需要先reshape成(7, num_classes)再做argmax:

def decode_plate(output_logits): import numpy as np if len(output_logits.shape) == 2: # (1, 455) -> reshape成7个字符 output_logits = output_logits.reshape(1, 7, -1) pred_idx = np.argmax(output_logits, axis=-1) # 每个字符取概率最大的索引 plate = ''.join(idx_to_char(i) for i in pred_idx[0]) return plate

整图识别比字符分割方案更好的地方,在于它不需要先做字符定位。车牌字符之间本身有间隙,但受倾斜、反光和污损影响,字符分割经常把“1”和“I”切断,或者把“2”和“Z”黏在一起。CNN整图分类通过卷积核感受野自动覆盖到邻近字符的上下文,端到端优化下来的鲁棒性明显优于先分割后分类的传统流程。

5. 端到端串接:从YOLOv5的box到最终车牌输出与精度统计

5.1 把检测和识别串成一个函数

现在把YOLOv5的检测输出和CNN识别模型串成完整链路。项目里的plate_rec.py做的就是这件事,下面是它的核心骨架:

import cv2 import torch def detect_and_recognize(img_path, detect_model, rec_model, conf_thresh=0.4): # 读图并做YOLOv5推理 img_bgr = cv2.imread(img_path) results = detect_model(img_bgr) # 返回检测结果对象 boxes = results.xyxy[0].cpu().numpy() # 每行: x1, y1, x2, y2, conf, cls plates = [] for box in boxes: x1, y1, x2, y2, conf, cls = box if conf < conf_thresh: continue # 用安全距离裁剪车牌区域,防止边缘字符被切掉 x1 = max(0, int(x1 - 2)); y1 = max(0, int(y1 - 2)) x2 = min(img_bgr.shape[1], int(x2 + 2)); y2 = min(img_bgr.shape[0], int(y2 + 2)) crop = img_bgr[y1:y2, x1:x2] input_tensor = preprocess_plate_crop(crop) # 复用第4章的预处理 output_logits = rec_model.predict(input_tensor, verbose=0) plate_text = decode_plate(output_logits) # 复用第4章的解码函数 plates.append({'box': (x1, y1, x2, y2), 'text': plate_text, 'conf': float(conf)}) return plates

这里有一个我在串接时踩过的坑:YOLOv5裁剪出的车牌图经常把左右边缘的字符贴边截掉一半,导致识别端把“1”误判成“I”。解决办法是在裁剪时向外扩展2到3个像素的安全边距,让CNN能看到完整的字符轮廓。注意扩展后要做边界截断,否则坐标会越界。

5.2 用CCPD文件名标签做精度统计

CCPD的最大便利是每张图的车牌号可以从文件名解析出来,这正好用来做自动化评估。批量跑完检测后,把结果写入CSV,同时和文件名标签比对,统计检测率和识别率。

import csv import os def evaluate_on_dataset(image_dir, detect_model, rec_model): rows = [] for img_name in os.listdir(image_dir): if not img_name.endswith('.jpg'): continue info = parse_ccpd_filename(img_name) # 第2章的解析函数 true_plate = ''.join(idx_to_char(int(i)) for i in info['plate_idx']) pred_plates = detect_and_recognize( os.path.join(image_dir, img_name), detect_model, rec_model) # 只取置信度最高的检测框作为预测结果 hit = any(p['text'] == true_plate for p in pred_plates) rows.append([img_name, true_plate, pred_plates[0]['text'] if pred_plates else '', pred_plates[0]['conf'] if pred_plates else 0.0, int(hit)]) with open('eval_results.csv', 'w', newline='') as f: writer = csv.writer(f) writer.writerow(['image', 'true_plate', 'pred_plate', 'conf', 'correct']) writer.writerows(rows) total = len(rows) correct = sum(r[4] for r in rows) print(f'识别准确率: {correct / total:.4f} ({correct}/{total})')

correct定义为整字符串完全匹配,而不是逐字符匹配,是刻意为之。答辩时如果只报逐字符准确率,评委追问“整牌准确率多少”就会露怯;反过来,整牌准确率能过,逐字符准确率一定更高,从整牌指标反推逐字符指标也更符合评价习惯。

5.3 用CCPD的模糊度字段定位识别瓶颈

如果整体准确率只有90%,别急着调模型,先用CCPD文件名第7段的模糊度字段做一次分层统计。这个字段的值越大代表图片越模糊,按0-5、6-10、11-20、20以上分四档,分别跑一次识别率,通常能看到一条明显的下降曲线。

def group_by_blur(eval_csv): groups = {'0-5': [0, 0], '6-10': [0, 0], '11-20': [0, 0], '21+': [0, 0]} with open(eval_csv) as f: for row in csv.DictReader(f): blur = int(parse_ccpd_filename(row['image'])['blur']) if blur <= 5: key = '0-5' elif blur <= 10: key = '6-10' elif blur <= 20: key = '11-20' else: key = '21+' groups[key][0] += int(row['correct']) groups[key][1] += 1 for k, (corr, total) in groups.items(): print(f'blur {k}: {corr / total:.4f} ({corr}/{total})')

这个统计能直接回答答辩老师最爱问的“你的模型在什么情况下会失效”。如果发现高模糊档位准确率骤降,说明瓶颈在识别端,优先尝试对裁剪图做锐化预处理;如果所有档位都有大量漏检,说明瓶颈在检测端,去调YOLOv5的置信度阈值或换成更大分辨率的输入。把改进前后的分层统计放在一起对比,比单贴一张效果图有说服力得多。

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

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

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

立即咨询