简介:这份Python项目源码面向计算机视觉与深度学习方向的课程设计、毕业设计学生及入门开发者,提供一套完整的车牌识别系统实现方案,可用于交通管理、智能停车场等场景的算法学习与二次开发。项目围绕车牌定位、字符分割与字符识别三大核心环节展开,结合OpenCV的高斯模糊、Sobel算子等图像处理技术增强车牌特征,并借助TensorFlow或PyTorch构建卷积神经网络完成字符识别,同时提供PyQt交互界面与模型训练优化接口,便于按需调整。压缩包为zip格式,整体约27.74MB,文件类型以Python源码、模型文件及界面资源为主,结构清晰便于按模块查阅。目前已有134人学习下载,适合希望掌握车牌识别全流程、积累深度学习项目实战经验的读者参考借鉴。
1. 车牌识别系统到底难在哪:从一张过曝的卡口图说起
很多刚接触 python 深度学习 车牌识别系统 的朋友,第一反应是「这不就是个 OCR 吗」。我一开始也这么想,直到拿到一张中午逆光的卡口图:车牌区域过曝成一片白,边缘和车身糊在一起,传统阈值分割直接失效。车牌识别系统真正的难点从来不是「认字」,而是「在乱七八糟的光照、角度、遮挡下,先把车牌框准,再把字符切干净」。这个 python 项目基于深度学习的思路,本质是把检测和识别拆成两段神经网络,让模型自己去学那些你写不出来的规则。
这套方案适合谁?适合已经会 python 基础语法、装过 pytorch 或 tensorflow、想找一个能跑通全流程的深度学习实战项目案例的人。它不要求你从零推导反向传播,但要求你能看懂张量维度、会调 dataloader。整条链路是:数据标注 → 车牌检测(定位框)→ 字符识别(序列预测)→ 后处理矫正。下面我按自己复现时的顺序,把每一步的参数、坑和验证方法讲清楚,你照着能跑出一个可用的 demo,也能看清工业级落地还差什么。
2. 先想清楚检测和识别怎么分工:两阶段还是端到端
2.1 为什么我建议新手从两阶段做起
车牌识别系统 python 代码 在网上能搜到一堆,但很多是「检测+识别」揉在一个网络里的端到端方案。端到端听起来优雅,实际训练时两个任务的 loss 会互相拉扯,收敛慢,调参玄学。我一般会拆成两阶段:第一阶段用目标检测网络把车牌框出来,第二阶段把框内的图送进识别网络读字符。这样做的好处是每一段都能单独验证——检测框不准,你一眼能从可视化结果看出来;识别错字,你能单独换识别模型,不用重训检测。
两阶段的代价是推理速度慢一点,多了一次裁剪和缩放。但对学习和中小规模落地来说,可调试性远比那几毫秒重要。常见做法是检测用 YOLO 系列或轻量 SSD,识别用 CRNN+CTC 或轻量 Transformer。选型理由很直接:YOLO 生态成熟、预训练权重多、部署工具链全;CRNN 对不定长字符序列友好,CTC 省去了字符分割这一步,正好绕开车牌字符粘连的老大难问题。
2.2 数据标注的格式与目录约定
动手前先把数据理顺。检测阶段需要每张图里车牌的边界框,识别阶段需要每个车牌图的字符文本。我习惯用这样的目录结构,后面写 dataloader 时直接按这个读:
dataset/ ├── detection/ │ ├── images/ # 原始卡口图 │ └── labels/ # 每张图对应一个 txt,每行 class x_center y_center w h(归一化) ├── recognition/ │ ├── crops/ # 从检测框裁出的车牌小图 │ └── labels.txt # 每行:文件名 车牌文本 └── charset.txt # 字符集,一行一个字符检测标签用归一化的中心点加宽高,是 YOLO 格式的通用约定,换别的检测框架时记得转换。识别标签里,中文车牌要特别注意省份简称和字母数字混排,charset 里必须包含所有可能出现的字符,漏一个字符模型永远学不会它。我踩过的坑是 charset 里把「O」和「0」当成一个,结果模型在两者之间反复横跳,后来强制分开才稳定。
2.3 用 python 检查数据分布的最小脚本
标注完别急着训练,先跑个统计脚本看看框的尺寸分布和字符长度分布,这能提前暴露很多问题:
import os from collections import Counter label_dir = "dataset/detection/labels" widths, heights, char_lens = [], [], [] for name in os.listdir(label_dir): with open(os.path.join(label_dir, name)) as f: for line in f: parts = line.strip().split() if len(parts) != 5: print(f"格式异常: {name} -> {line.strip()}") continue _, _, _, w, h = map(float, parts) widths.append(w) heights.append(h) with open("dataset/recognition/labels.txt") as f: for line in f: text = line.strip().split()[-1] char_lens.append(len(text)) print("框宽分布:", Counter([round(w, 2) for w in widths]).most_common(5)) print("框高分布:", Counter([round(h, 2) for h in heights]).most_common(5)) print("字符长度分布:", Counter(char_lens).most_common())这段脚本做三件事:校验每行标签是不是 5 个字段,统计框的宽高分布,统计车牌字符长度分布。参数上,round(w, 2)是为了把连续值离散化方便计数,你可以按自己数据调整精度。如果发现某些框宽高接近 0 或者超过 1,说明标注坐标没归一化或者标错了,必须回去修。字符长度分布如果出现 4 或 9 这种异常值,多半是标签文本写错了,趁早发现省得训练完才后悔。
3. 检测网络怎么训:从 anchor 设置到数据增强
3.1 检测模型的输入尺寸与 anchor 匹配
车牌在整张卡口图里占比很小,这是检测阶段最大的特点。如果你直接把 1920×1080 的原图塞进网络,车牌可能只有几十个像素,特征还没提取就没了。我一般会把输入缩放到 640×640,同时确认缩放后车牌短边不低于 20 像素。低于这个值,检测召回率会明显掉。
anchor 的设置要贴合你数据里车牌的宽高比。中国蓝牌大致是 440×140,宽高比约 3.14:1,新能源绿牌略宽。用统计脚本算出的框宽高分布,取几个聚类中心当 anchor,比默认的 COCO anchor 强很多。常见做法是用 k-means 对标注框做聚类,聚出 6 到 9 个 anchor。这一步不做,模型会花大量时间在明显不匹配的 anchor 上浪费算力。
3.2 训练命令与关键超参
假设你用 ultralytics 的 YOLO 实现,训练命令大概长这样:
yolo detect train \ data=plate.yaml \ model=yolov8n.pt \ imgsz=640 \ epochs=100 \ batch=16 \ lr0=0.01 \ lrf=0.01 \ mosaic=1.0 \ degrees=10.0 \ translate=0.1 \ scale=0.5 \ name=plate_det逐项说:imgsz=640是输入边长,显存不够就降到 512,但别低于 416,否则小目标吃亏。batch=16按显存调,8G 显存跑 yolov8n 大概能到 16。lr0=0.01是初始学习率,lrf=0.01是最终学习率相对初始值的比例,余弦退火到 1e-4 左右。mosaic=1.0是马赛克增强,对小目标检测帮助很大,但训练最后 10 个 epoch 建议关掉,让模型适应真实分布。degrees=10.0做小角度旋转增强,车牌倾斜是常态,这个必须开。scale=0.5做缩放增强,模拟远近不同的车。
3.3 训练过程看什么指标
训练时别只盯着 loss。检测任务重点看 mAP@0.5 和 mAP@0.5:0.95 两个指标。mAP@0.5 高但 mAP@0.5:0.95 低,说明框的位置不够准,可能是回归分支学得不好,或者 anchor 匹配有问题。如果训练集 mAP 一直涨但验证集不涨,就是过拟合,加数据增强或者减模型容量。
还有一个容易被忽略的指标是每类的小目标召回。车牌检测只有一个类,但你可以按框的面积分桶看召回率。面积小于 32×32 的桶如果召回明显低,说明小目标没学好,要么提高输入分辨率,要么在数据增强里多保留小目标样本。我一般会在验证脚本里加一段按面积分桶统计的代码,比只看总体 mAP 有用得多。
4. 识别网络怎么训:CRNN 的输入对齐与 CTC 解码
4.1 车牌裁剪图的预处理
检测框裁出来的车牌图,尺寸五花八门,识别网络需要统一输入。CRNN 的常见输入是 32×100 或 32×160,高度固定 32,宽度按比例缩放后 padding。这里有个细节:缩放时保持宽高比,短边补齐,别直接拉伸,拉伸会让字符变形,识别率掉得厉害。
import cv2 import numpy as np def preprocess_plate(img, target_h=32, target_w=100): h, w = img.shape[:2] ratio = target_h / h new_w = int(w * ratio) resized = cv2.resize(img, (new_w, target_h)) if new_w < target_w: pad = np.zeros((target_h, target_w - new_w, 3), dtype=np.uint8) resized = np.hstack([resized, pad]) else: resized = cv2.resize(resized, (target_w, target_h)) return resized逻辑是:先按高度缩放到 32,宽度等比变化;如果宽度不够目标宽度就右侧补黑边,如果超了就再压一次。参数target_w=100对应能容纳大约 7 到 8 个字符,中国车牌 7 位,留点余量。补边用黑色是因为车牌背景通常不是纯黑,补边区域和真实字符区域区分明显,CTC 更容易对齐。
4.2 CRNN 网络结构与 CTC loss
CRNN 的结构是 CNN 提特征 + RNN 学序列 + CTC 解码。CNN 部分用几个卷积池化把高度压到 1,宽度保留时间步。RNN 用双向 LSTM,输出每个时间步的字符概率分布。CTC 负责把不定长的序列预测和不定长的标签对齐,不需要字符级标注。
import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes): super().__init__() self.cnn = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(), nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(), nn.MaxPool2d((2, 1)), nn.Conv2d(256, 512, 3, padding=1), nn.ReLU(), nn.MaxPool2d((2, 1)), ) self.rnn = nn.LSTM(512, 256, bidirectional=True, batch_first=True) self.fc = nn.Linear(512, num_classes) def forward(self, x): feat = self.cnn(x) # B, 512, 1, W' feat = feat.squeeze(2).permute(0, 2, 1) # B, W', 512 out, _ = self.rnn(feat) return self.fc(out) # B, T, num_classes关键点:池化层高度方向用 (2,1) 是为了把高度压到 1 而宽度保留,宽度方向的时间步就是 CTC 的序列长度。num_classes要包含字符集大小加 1,那个 1 是 CTC 的 blank 标签。训练时用nn.CTCLoss,注意输入要先 log_softmax,target 要拼接成一维并给出长度。
4.3 训练参数与解码验证
识别训练的学习率比检测小,我一般用 1e-3 起步,Adam 优化器,batch 64。训练 50 到 100 个 epoch,看验证集的字符错误率 CER。CER 降到 1% 以下基本可用,但要注意验证集不能和训练集有同一辆车的不同帧,否则指标虚高。
解码用贪心解码就够入门,每个时间步取概率最大的字符,去掉重复和 blank。如果发现模型输出大量重复字符,多半是 CTC 的 blank 没学好,检查 target 长度和输入长度是否满足T >= 2*L+1的约束,不满足会直接报错或者学不动。这个约束是 CTC 的硬性要求,输入时间步必须大于等于两倍标签长度加一,否则无法对齐。
5. 避坑与排查:那些让我重训三次的问题
5.1 检测框抖动导致识别输入不稳定
现象:视频流里同一辆车连续几帧,识别结果在正确和错误之间跳。原因:检测框每帧位置有轻微抖动,裁剪出的车牌图内容偏移,识别网络对偏移敏感。解决:在检测后加一个简单的跟踪或平滑,对连续帧的框做指数移动平均,或者用 IoU 匹配关联前后帧。我一般用box = 0.7 * box_new + 0.3 * box_old这种简单平滑,效果立竿见影。
5.2 字符集顺序不一致导致标签错乱
现象:训练 loss 正常下降,但推理结果全是乱码。原因:训练时 charset 的顺序和推理时不一致,模型输出的索引对应的字符变了。解决:把 charset 存成文件,训练和推理都从这个文件读,绝不硬编码。这个坑很隐蔽,因为 loss 只关心索引对不对,不关心索引映射到什么字符。
5.3 过曝和欠曝样本被增强放大
现象:模型在正常光照下很好,一到强光或夜间就崩。原因:数据增强里的亮度对比度调整是双向的,把本来就过曝的图调得更过曝,模型学到了极端噪声。解决:对已经过曝的样本,增强时只做变暗不做变亮,或者干脆对过曝样本单独处理。我一般会在 dataloader 里判断图像平均亮度,超过阈值就限制增强幅度。
5.4 中文省份简称识别率低
现象:字母数字识别很准,但省份简称经常错。原因:省份简称样本少,且字形复杂,CTC 在样本不均衡时偏向高频字符。解决:对省份简称做过采样,或者在 loss 里给低频字符加权。另一个办法是把省份识别单独拆出来做一个分类头,因为省份位置固定,分类比序列预测更稳。
5.5 推理时 batch 维度和训练不一致
现象:训练好的模型推理时报维度错误。原因:训练时 batch 维是 1,推理时忘了加 batch 维,或者反过来。解决:推理前统一img = img.unsqueeze(0),并且确认模型 eval 模式。这个坑低级但高频,尤其是从训练脚本复制代码到推理脚本时容易漏。
6. 把 demo 推到可用:量化、批处理与一个验证技巧
走到这里你有一个能跑的模型了,但离「可用」还差一步。我一般会做三件事:导出 ONNX 做推理加速、写一个批处理脚本跑整个测试集、用一个混淆矩阵定位最差的字符对。
导出 ONNX 的命令很简单,重点是确认输入输出名字和动态维度:
import torch model = CRNN(num_classes=len(charset) + 1) model.load_state_dict(torch.load("crnn_best.pth", map_location="cpu")) model.eval() dummy = torch.randn(1, 3, 32, 100) torch.onnx.export( model, dummy, "crnn.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} )dynamic_axes把 batch 维设成动态,这样推理时 batch 可以变。导出后用 onnxruntime 跑一遍,对比 pytorch 和 onnx 的输出差异,差异在 1e-4 以内算正常。如果差异大,检查有没有 opset 不支持的算子。
批处理验证脚本的核心是统计每个字符的识别准确率,生成混淆矩阵。我习惯把结果按字符排序,找出错误率最高的 10 个字符,回头看这些字符的训练样本是不是有问题。很多时候不是模型不行,是那几类样本标注错了或者太少。这个习惯帮我省了大量盲目调参的时间。
最后一个技巧:用真实场景的短视频做端到端验证,而不是只看测试集指标。测试集是静态图,视频里有运动模糊、帧间抖动、光照渐变,这些才是真实工况。我一般会录一段 30 秒的路口视频,跑完整流程,人工数 100 辆车牌,算实际准确率。这个数字比任何 mAP 都诚实。希望帮到你。
本文还有配套的精品资源,点击获取