简介:这是一套基于Python与OpenCV、深度学习的中文车牌检测识别系统源码,面向计算机视觉初学者、机器学习开发者及智能交通项目人员,可用于蓝牌、黄牌、新能源等十余类车牌的自动定位与字符识别。代码包含图像预处理、边缘检测、轮廓定位、字符分割及CNN识别等完整流程,并针对新能源车牌颜色与格式做了专门优化。资源包共159个文件,以42个Python源码为核心,辅以YAML配置文件、PyTorch模型权重(.pth)、CCPD2019数据集样本及Dockerfile部署环境,整体压缩包约36.51MB,便于本地复现与二次开发。已有512人学习下载。通过阅读源码可掌握车牌检测识别全链路实现思路,同时可利用附带的数据与模型权重快速验证效果,适合深入研究或作为课程设计、毕业设计基础。
1. 为什么车牌识别在 2025 年仍然是 Python 工程师绕不开的硬骨头
停车场道闸抬杆速度从 2 秒压到 0.8 秒,靠的是边缘设备上的轻量模型;高速 ETC 门架在 120km/h 车速下抓拍识别,靠的是端到端检测加字符序列解码;城市大脑的违停抓拍球机,一天要处理上万张包含车牌的图。这些场景背后,中文车牌识别始终是计算机视觉里最考验工程落地能力的任务之一——它不像通用目标检测那样只要框出物体,还要把框里的字符一个不差地读出来,而且国内车牌种类远比你想象的多:蓝牌、黄牌、双层黄牌、农用车牌、警车、校车、教练车、港澳车、使领馆、新能源渐变绿牌,每一种在颜色、字符排列、物理尺寸上都有差异。我拆过不少开源车牌项目,也自己从零训过模型,今天借一个聚合了 CCPD2019 数据集和完整训练推理链路的 Python 源码包,把中文车牌检测和识别的全流程、参数细节和踩坑点一次说透。这套源码适合两类人:一是刚入门想弄清楚检测和识别到底怎么串起来的开发者,二是已经在做安防或交通项目、需要覆盖多车种识别、想快速迁移到自有数据的工程师。
2. 拆解 CCPD2019 与 HyperLPR:从数据集到模型选型
2.1 CCPD2019 数据集的构成与使用方式
项目正文里同时出现了 CCDP 和 CCPD2019,它俩是同一件事:CCPD 即 Chinese City Parking Dataset,由中国科学技术大学标注发布,是目前中文车牌识别领域影响最大的开源数据集。CCPD2019 是后续扩充版本,图片主要来自合肥等城市的停车场出入口,分辨率统一为 720x1160 左右,覆盖了蓝牌、黄牌、新能源牌、双层黄牌等类别。文件名本身就携带标注信息,这是 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的图片,用下划线和连字符分隔成几段:025是车牌号样本编号,95_113表示倾斜角度,154&383_386&473是车牌左上和右下角坐标,后面的 4 组点坐标是车牌的四个顶点精确位置,最后几段是车牌字符内容和亮度信息。理解这个命名规则很重要,因为训练自己的检测模型时,可以直接从文件名解析出标注框,不需要额外转换 VOC 或 COCO 格式——我一般写一个 Python 脚本,把文件名中的坐标段提取出来,转成 YOLO 的 txt 格式,再用 OpenCV 读取图片确认标注框没有越界。
import re import os def ccpd_filename_to_yolo(filename, img_width=1160, img_height=720): parts = filename.split("-") # parts[2] 形如 154&383_386&473,表示左上角和右下角坐标 corners = re.findall(r"\d+", parts[2]) x1, y1, x2, y2 = map(int, corners[:4]) if x2 <= x1 or y2 <= y1: return None x_center = (x1 + x2) / 2 / img_width y_center = (y1 + y2) / 2 / img_height w = (x2 - x1) / img_width h = (y2 - y1) / img_height return f"0 {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}" sample = "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" label = ccpd_filename_to_yolo(sample) print(label)这里把坐标归一化到 0~1 之间,对应 YOLO 训练的标签格式。注意我固定用 1160x720 的宽高做分母,因为 CCPD 数据集图片尺寸统一;如果你拿到的是原始未裁剪图,先读图片再动态取宽高会更稳妥。解析时一定要加x2 <= x1这类合法性校验,CCPD 里确实存在个别文件名坐标顺序异常的情况,不校验的话训练时会直接报 Negative or zero box size 错误。
2.2 检测与识别的两阶段方案为什么仍是主流选型
当前中文车牌识别主要有三条技术路线:端到端单阶段模型、两阶段检测加识别、以及基于文字检测加识别的通用 OCR 方案。端到端方案的代表是 LPRNet 和基于注意力机制的解码器,它们的优势是推理速度快,适合嵌入式设备,但问题也很明显——对倾斜、模糊、小尺寸车牌的鲁棒性不如两阶段;通用 OCR 方案如 PaddleOCR 虽然对印刷体文字效果好,但车牌字符存在特定字体、特定排列和颜色约束,直接用通用模型反而容易出现误识别。因此 HyperLPR 这类成熟项目始终保持检测和识别分离:检测模块先用目标检测网络框出车牌区域,识别模块再对裁剪出的车牌图做字符序列识别。这样两个模块可以独立优化,检测模型升级不影响识别模型,识别模型想加入注意力机制也不用重训检测部分,工程上维护成本更低。源码包里出现box_overlaps.c这类的 Cython 扩展文件,说明检测部分做了 NMS 或 IoU 计算的 C 加速,这正是两阶段方案在工程优化上的典型手法。
2.3 检测模型选型:YOLOv5s 还是更轻量的 RPNE
HyperLPR 早期版本用的是基于 Faster R-CNN 的 RPNE 模型,它把车牌检测定义为一个回归问题:先在特征图上生成候选区域,再用边界框回归修正位置。RPNE 的优点是召回率高,对 CCPD 里大量存在倾角的样本处理得不错,但速度较慢。后来社区普遍迁移到 YOLOv5s 或 YOLOv8s,在 CCPD2019 上 mAP 可以到 98% 以上,单张推理时间在 CPU 上约 50ms,GPU 上 5ms 左右。我建议直接用 YOLOv5s 作为检测器,理由有三个:一是 CCPD 数据集上预训练权重容易获取,二是模型体积只有 14MB 左右,适合部署到 Jetson Nano 这类边缘设备,三是从 YOLOv5 导出 ONNX 再转 TensorRT 的工具链最成熟。下面的实验记录是 YOLOv5s 在 CCPD2019 训练集上 100 个 epoch 的典型表现,可以对照自己训练时的指标判断收敛情况:
| 指标 | 数值 |
|---|---|
| 训练集图片数 | 约 20 万张 |
| 验证集 mAP@0.5 | 98.6% |
| 验证集 mAP@0.5:0.95 | 87.3% |
| 单张推理耗时 GPU | 4.8ms |
| 模型大小 | 14.1MB |
参数背后的含义是:mAP@0.5 达到 98% 以上说明车牌框得足够准,不会出现漏检或框偏移过大的问题;但如果你的场景是停车场出口那种近距离、车牌占画面比例大的情况,可以适当降低置信度阈值到 0.3 来减少漏检;而高速卡口那种远距离、小目标场景,则需要把输入分辨率从 640 提升到 960 或 1280,代价是推理耗时翻倍。
3. 车牌定位实战:图像预处理、候选框回归与误检抑制
3.1 图像增强与颜色分析在预处理中的分工
车牌识别系统的第一个环节是图像预处理,但很多人容易把预处理做得过重,反而破坏了原始特征。我的经验是分两步走:第一步做基础质量修复,包括灰度化、直方图均衡化和高斯去噪,这一步的目的是应对弱光、逆光和夜间场景;第二步做针对性增强,根据天气或光照条件选择不同的策略。直方图均衡化(CLAHE)在这里比全局均衡化更实用,因为它采用局部对比度限制,不会把车牌字符和背景的对比度拉得过高,下面的代码展示完整预处理流程:
import cv2 def preprocess_plate_region(image): gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray_clahe = clahe.apply(gray) blurred = cv2.GaussianBlur(gray_clahe, (3, 3), 0) _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) return gray_clahe, binary img = cv2.imread("tmp9890.jpg") gray_norm, binary_mask = preprocess_plate_region(img)代码里clipLimit=2.0控制对比度增强的强度,值太大会把噪点也增强,太小则效果不明显;tileGridSize=(8, 8)表示把图像分成 8x8 的小块做局部直方图均衡。二值化用 Otsu 自动计算阈值而不是固定阈值,因为车牌灰度分布在不同光照下差异很大。需要特别提醒的是,二值化后的图像只用于形态学分析和边缘检测辅助定位,不能直接作为字符识别输入——字符识别需要保留更多灰度细节,直接喂二值图会丢失笔画边缘的渐变信息。
3.2 颜色特征的取舍:为什么新能源车牌靠颜色定位是双刃剑
蓝牌、黄牌、新能源绿牌在颜色上差异明显,传统的 HSV 颜色空间分割方法至今仍在用,尤其是处理新能源车牌时,绿色区域是很好的定位线索。但纯颜色定位的缺陷在于受环境光影响严重——停车场黄色照明灯下蓝牌会偏绿,树荫下绿牌会偏暗,此时 HSV 阈值就失效了。我建议把颜色分析用于检测后处理而不是检测本身:先用目标检测模型框出候选区域,再用颜色直方图验证该区域的色相分布是否匹配对应车牌类型,这样可以过滤掉广告牌、车身贴纸等误检框。以新能源车牌的渐变绿为例,HSV 空间中绿色色相范围在 35 到 77 之间,但这个范围在夜晚需要放宽到 30 到 85,否则真实车牌会被过滤掉。另一个技巧是用 HSV 空间计算绿色像素占比,绿色像素占整个候选框的比例超过 15% 才认定为新能源车牌,这个阈值是我在 CCPD2019 上试出来的,准确率和召回率比较平衡。
3.3 候选框回归与 NMS 参数调节:box_overlaps.c 的作用
检测模型输出的候选框往往不止一个,同一块车牌可能出现多个重叠框,此时非极大值抑制 NMS 是决定最终输出质量的关键步骤。源码里的box_overlaps.c用 Cython 重写了 IoU 计算逻辑,在批量框处理时比纯 Python 实现快 20 倍以上。NMS 的核心参数是 IoU 阈值和置信度阈值,它们的选择需要根据场景动态调整:
import numpy as np def py_nms(boxes, scores, iou_threshold=0.45): x1 = boxes[:, 0] y1 = boxes[:, 1] x2 = boxes[:, 2] y2 = boxes[:, 3] areas = (x2 - x1 + 1) * (y2 - y1 + 1) order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) w = np.maximum(0.0, xx2 - xx1 + 1) h = np.maximum(0.0, yy2 - yy1 + 1) inter = w * h iou = inter / (areas[i] + areas[order[1:]] - inter) idx = np.where(iou <= iou_threshold)[0] order = order[idx + 1] return keep这段代码就是 Cython 版本的纯 Python 对照实现。IoU 阈值 0.45 的含义是:两个候选框的交并比超过这个值就认为它们框的是同一个目标,保留得分更高的那个。停车场场景建议调到 0.5,因为车辆静止、抓拍清晰,多框重叠现象少;高速卡口场景建议调到 0.3,因为运动模糊会让检测框抖动,阈值太高会把同一块车牌拆成多个结果。NMS 在检测模块之后、识别模块之前执行,它的输出质量直接影响后续字符分割的稳定性——如果 NMS 没把误检框清干净,识别模块会对非车牌区域做字符推理,产出一堆垃圾结果。
4. 字符分割与 CNN 识别:从垂直投影到序列建模的实现路径
4.1 字符分割的经典方法:垂直投影与连通域分析的边界在哪里
定位到车牌区域后,接下来的任务是把车牌上的字符一个一个切分出来。传统方法以垂直投影法为主流思路:对二值化后的车牌图像做垂直方向投影,统计每一列像素的分布,字符区域会有明显的波峰,字符间隙对应波谷,从波谷处切分就能得到单个字符。垂直投影法的实现非常直观,以标准七位蓝牌为例,切分顺序和字符规则是:第一位是省份简称汉字(如京、沪、粤),第二位是发牌机关字母(A-Z),后五位是字母和数字混合。这个方法在小角度倾斜和清晰图片上效果不错,但一旦车牌有透视变形或者字符粘连,投影波谷就消失了,切分位置随之偏移。
import cv2 import numpy as np def split_characters_by_projection(plate_binary): h, w = plate_binary.shape vertical_sum = np.sum(plate_binary, axis=0) // 255 in_char = False char_regions = [] start = 0 for x in range(w): if vertical_sum[x] > 0 and not in_char: in_char = True start = x elif vertical_sum[x] == 0 and in_char: in_char = False char_regions.append((start, x)) filtered = [] for s, e in char_regions: if e - s >= w * 0.03: filtered.append((s, e)) return filtered代码中的w * 0.03是宽度过滤阈值,用来剔除噪点投影产生的窄区域。实际使用中我还会加一个高度过滤条件,要求字符区域的高度至少占车牌高度的 40%,否则可能是边缘干扰。垂直投影法的硬伤在于不能处理字符粘连,比如汉字“京”的结构复杂,投影后中间可能出现断裂,被误切为两段。这时候连通域分析作为补充手段更合适——用cv2.connectedComponentsWithStats找到所有连通域,再根据连通域的面积、宽高比和位置关系合并或拆分。特征维度至少需要面积、外接矩形宽高、中心点坐标这 5 个,组合判断规则可以覆盖大部分切分异常。
4.2 CNN 分类模型的结构设计与去重后处理
字符切分完成后,每个字符图块被缩放到统一尺寸送入 CNN 分类模型。这里有一个工程上常见的坑:识别模型输出的类别数是 31 个汉字加 24 个字母加 10 个数字,但字母 I 和 O 在车牌中不使用,数字 0 和字母 O 容易混淆,数字 1 和字母 I 也容易混淆,所以最终的类别映射需要手动排除重复项。推荐的结构是轻量的卷积网络:输入 32x96 灰度图,经过两层卷积加池化,再经过两层全连接,输出类别概率分布。训练时用 CCPD2019 裁剪出的字符图块,按 8:2 划分训练集和验证集。下面是推理时的后处理示例,关键点在于对模型输出的置信度做二次校验:
import numpy as np char_dict = "京沪粤津冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘桂琼渝川贵云藏陕甘青宁新港澳使领警学农挂ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" def postprocess_recognized_chars(raw_outputs, conf_threshold=0.7): results = [] for output in raw_outputs: prob = np.array(output) idx = int(np.argmax(prob)) conf = float(prob[idx]) if conf < conf_threshold: results.append("_") else: results.append(char_dict[idx]) return "".join(results)raw_outputs是 CNN 每个字符位置的输出概率向量,conf_threshold设为 0.7 表示置信度低于这个值就标记为未知字符。这个后处理策略在实际项目中很重要——停车场收费系统宁可让用户手动确认,也不能把车牌识别错。如果不加置信度过滤,模型会用较低的置信度强行输出一个字符,导致整块车牌识别错误。另外,识别结果还需要根据车牌规则做合法性校验:省份汉字必须在指定集合内,第二位必须是字母,新能源车牌是 6 位字符且后六位中至少有一位是字母,不符合规则的识别结果直接丢弃并要求重新识别。
4.3 端到端序列识别:CRNN 与注意力机制何时值得引入
垂直投影加 CNN 的路线在标准蓝牌上准确率可以到 97% 左右,但在双层黄牌和大角度倾斜场景下,字符切分的误差会传导到识别环节,整体准确率跌破 90%。这时就需要换用序列识别方案,把字符分割和识别合并为一个整体。CRNN 是常用选择:CNN 提取特征后,把特征图按时间步展开,输入双向 LSTM 建模序列关系,最后用 CTC 损失函数对齐预测序列和真实车牌文本。CRNN 不需要显式切分字符,模型自己学会字符边界在哪里,这在双层黄牌上效果提升明显。HyperLPR 的识别模块就集成了类似思路的深度学习模型,源码目录里没有单独列出 CRNN 文件夹,但识别部分的模型定义文件实现的就是序列识别逻辑。
使用 CRNN 需要注意一点:CTC 解码时容易出现重复字符压缩问题,比如车牌“京A12345”中的连续数字 1 和 2 不会受影响,但遇到字母和数字相同的情况,如“京A11345”,CTC 的 blank 机制可能把两个连续的 1 合并成一个,导致输出变成“京A1345”。解决方法是修改 CTC 解码的 collapsed 规则,或者在训练数据中做数据增强,刻意生成连续相同字符的样本。我在 CCPD 上训练 CRNN 时,会在车牌字符间随机插入轻微扰动,模拟真实场景中的字符重叠,这个操作让连续字符错误率下降了 0.8 个百分点。
5. 多车种适配与验证:新能源牌、双层黄牌和港澳牌的差异化处理
5.1 新能源车牌的识别逻辑:从渐变绿到校验位
新能源车牌和传统蓝牌最大的区别是颜色和位数字符数:小型新能源车牌是渐变绿色,6 位字符,第二位是字母 D 或 F,D 代表纯电动,F 代表混动;大型新能源车牌是黄绿双拼色。源码在检测模块中通过 HSV 颜色分析判断是否新能源车牌,识别模块则根据车牌类型动态调整字符数。具体做法是:检测到绿色像素占比超过阈值后,把字符切分数从 7 改为 6,识别模型的输出维度也对应调整。这里有个容易忽略的细节——新能源车牌的字符间距略微大于传统蓝牌,用固定宽度切分会导致第 5 和第 6 个字符被切到同一块,需要在字符分割阶段对新能源车牌放宽投影波谷的阈值。我在处理 CCPD2019 里的新能源样本时,把垂直投影的波谷判定从width > w * 0.03调低到width > w * 0.02,并用车牌颜色信息动态切换参数。
5.2 双层黄牌与农用车牌:字符重排和单双层判断
大型车辆挂的黄牌分单层和双层两种,双层黄牌的上排是省份简称和发牌机关代码,下排是 6 位编号。识别双层黄牌最直接的方式是先用水平投影把车牌区域切分成上下两行,再分别对每一行做垂直投影字符切分。但实际部署时,我通常采用一个更简单的方案:检测模型在输出车牌框的同时,额外输出一个是否双层的分类置信度;如果是双层车牌,就把裁剪区域按高度中点拆分,上下两行分别送入识别模型,最后把结果拼接。农用车牌在字符集上与普通蓝牌一致,但底色是黄色,且车牌宽度更窄,字符长宽比不同,所以识别模型的输入尺寸需要从 32x96 调整为 32x80,否则字符会被压扁变形,CNN 提取的特征会偏移。解决方法是训练两个识别模型,一个针对蓝牌和新能源牌,另一个针对黄牌和农用车牌,在检测阶段根据车牌颜色路由到不同的识别模型。
5.3 使领馆、警车、校车与教练车的字符集特化处理
使领馆车牌、警车车牌、校车车牌和教练车车牌的特殊性在于字符数量和字符规则不同。使领馆车牌通常是 6 位,其中后 3 位是数字编号;警车车牌以“警”字结尾;校车车牌带有“校”字;教练车车牌带有“学”字。这里有个关键点:这些特殊类型车牌的汉字位置不同,警车和校车在末尾,教练车在中间,处理方式不能简单套用蓝牌的模块化规则。我在识别模块里维护了一个车牌类型字典,先根据检测模型输出的颜色和字符数粗判类型,再决定字符分割策略——比如识别到末尾字符是“警”时,不会把它切分到倒数第二位。这个决策逻辑写在识别流程的预处理阶段,具体实现是对每个字符位置输出一个类型预测,再联合所有位置的预测结果做全局最优解码,而不是逐字符独立取最大概率。
5.4 模型评估与验收:如何用 CCPD2019 的测试集量化改进效果
做完整套系统后,评估环节不能只看几张测试图片的识别结果,需要用测试集做统计度量。以 CCPD2019 里划分出的测试集为例,核心指标包括车牌检测的 mAP、字符识别的准确率、整牌识别准确率和单字符识别准确率。整牌识别准确率是最严格的指标——只有车牌中所有字符全部识别正确才算通过,它对字符混淆错误极其敏感。单字符识别准确率能帮助定位问题:如果汉字准确率明显低于字母数字,说明汉字训练样本不足,需要额外补充各省简称的样本;如果新能源车牌整牌准确率低,说明渐变绿背景对字符分割产生了干扰。下面的评估脚本展示了计算整牌准确率的方法,其中recognize_plate是项目源码里的识别入口函数:
import os import re def parse_plate_label(filename): plate_part = filename.split("-")[4] chars = re.findall(r"\d+", plate_part) plate_number = "" for c in chars: if 0 < int(c) < 100: plate_number += chr(int(c) + 64) if int(c) < 27 else chr(int(c) + 70) return plate_number def evaluate_dataset(image_dir): total = 0 correct = 0 char_total = 0 char_correct = 0 for fname in os.listdir(image_dir): if not fname.endswith(".jpg"): continue label = parse_plate_label(fname) pred = recognize_plate(os.path.join(image_dir, fname)) total += 1 if pred == label: correct += 1 for p, l in zip(pred, label): char_total += 1 if p == l: char_correct += 1 return { "plate_accuracy": correct / total, "char_accuracy": char_correct / char_total, "evaluated_samples": total, }这里的parse_plate_label把 CCPD 文件名里的字符编码段解析为真实车牌号。评估结果需要按车牌类型拆分统计,至少拆出蓝牌、黄牌、新能源牌三个子集,否则整体准确率会被数据量大的蓝牌样本掩盖问题。我在做项目验收时还会专门验证夜间和强逆光条件下的表现——如果夜间整牌准确率低于白天超过 5 个百分点,优先检查预处理中的 CLAHE 参数是否合适,其次检查训练数据是否包含足够多的夜间样本,最后才考虑换更大的检测模型,前两个因素解决的是数据分布问题,第三个因素解决的是模型容量问题,顺序不能颠倒。
5.5 部署到实际场景前必须做的三个性能优化
源码包在本地跑通只是第一步,部署到停车场道闸或移动端时通常会遇到性能瓶颈。第一个优化是固定输入尺寸并开启检测模型的 TensorRT 加速,以 YOLOv5s 为例,导出的 ONNX 模型在 TensorRT FP16 精度下推理耗时可以从 5ms 压到 2ms;第二个优化是给识别模块加缓存——同一辆车在道闸前停留的几秒内,抓拍的多帧画面中车牌变化极小,把前一次完整的识别结果和检测框位置缓存起来,下一帧检测框中心点位移小于 15 像素时直接复用识别结果,可以省掉重复的 CNN 推理;第三个优化是线程池加流水线,检测和识别使用不同的线程,检测线程持续从相机拉流,识别线程消费检测结果队列,避免串行等待。第三个优化的关键是控制队列长度,队列太长会积压旧帧导致实时性下降,太短又会浪费检测线程的吞吐能力,一般设置队列容量为 4 到 8 帧,根据实际帧率调整。
本文还有配套的精品资源,点击获取