简介:这是一份面向计算机专业课程设计与期末大作业场景的车牌识别系统源码包,项目基于Python与深度学习技术,覆盖车牌图像处理、车牌定位、字符分割与识别等核心流程,配有可运行主程序与说明文档,适合正在完成大作业或希望进行项目实战的计算机相关专业学生参考。压缩包共18个文件,体积约4.73MB,其中5个Python脚本负责图像处理、特征提取与模型推理,2个dat文件为SVM训练模型(含中文车牌字符模型),另有png/jpg图片素材用于测试,同时附有车牌识别.pptx和README.md,便于梳理项目结构与演示思路。已有483人学习下载,作为一套获得过98分评价的成熟大作业项目,源码整理完整、目录清晰,可帮助读者快速理解车牌识别系统的工程实现方式,也能在课程设计或期末答辩中直接作为演示与二次开发基础。
1. 一个“车牌识别系统”到底在做什么:从 OpenCV 边缘检测到深度学习,为什么选这条路
年关前后我接过好几个学生的咨询,手里的“计算机大作业设计源码基于Python车牌识别系统深度学习项目源码.zip”几乎都是同一个套路:下载、解压、跑通、换数据、截图、交作业。但真正动手时你会发现,车牌识别没有教程里那么“智能”——摄像头稍微侧一点、车牌沾泥、路灯下反光,识别结果就完全不可信。这个项目标题里最值钱的不是“源码”两个字,而是它同时踩中了 Python、深度学习、车牌识别三个需求:它是一套完整的视觉流水线,不是一段调用现成 OCR 的脚本。
它的典型落地路径是:先用目标检测模型把画面里的车牌“框”出来,再用一个序列识别模型把框内的字符读出来。前者解决“车牌在哪”,后者解决“牌上写了什么”。适合的人群很明确:要做深度学习课程设计的学生、想入门目标检测加 OCR 的开发者,以及需要快速做一版车牌识别 Demo 的工程新手。如果你只想调一个 API 完事,这套源码反而是绕远路;但凡你要在报告里写清楚“网络结构、训练策略、评价指标”,它就比任何黑匣子接口都好用。
2. 把车牌识别拆成两段:检测与识别,先想清楚再动手
2.1 端到端模型 vs 两阶段流水线:大作业为什么选后者
市面上做车牌识别的深度方案大致分两类:一类是端到端模型,输入一张图直接输出车牌字符串;另一类是两阶段流水线,先检测车牌区域,再对裁剪区域做字符识别。标题里的“车牌识别系统”更常见的组织方式是两阶段,原因很实际:端到端模型看起来省事,但训练数据要求极高——同一个网络既要学“车牌在哪”,又要学“字符是什么”,两个任务的梯度互相拉扯,小数据集很容易训出一个“什么都懂一点、什么都不精”的网络。
两阶段的最大优势是中间产物可控。检测阶段输出的是坐标框,你可以画出来看、存下来调试;识别阶段吃的是裁剪后的矩形图,输入尺寸固定,数据增强可以单独针对字符做。真出问题时,你能明确知道是检测漏了还是识别错了,而不是对着一整张图的输出发懵。
另一个隐藏优势是模型复用。检测端训好的权重可以换到别的任务上——反过来,识别端换一套字体或颜色也能单独重训。期末赶工时,这样做可以只重训一个阶段,省一半时间。
从报告角度说,两阶段也比端到端好写:目标检测讲 YOLO 的边框回归和 NMS,识别端讲 CTC Loss 和序列建模,两个模块各成一章,原理、实验、结果对应关系清清楚楚。
2.2 识别端选型:LPRNet 与 CRNN 的取舍
识别端这个位置,常见的备选是 LPRNet 和 CRNN。CRNN 是老牌选手,用 CNN 提特征、LSTM 建模序列、CTC 对齐,效果稳,但它有两个不适合大作业的毛病:一是模型体积大,推理速度相对慢;二是训练时需要把特征序列按时间步展开,对输入尺寸比较敏感。
LPRNet 的思路是“去序列化”——不依赖循环神经网络,纯卷积网络直接输出字符序列的概率分布,再用 CTC 做对齐。它的优势在于三点:输入图像可以不是等宽的,训练和推理时对车牌宽高比的变化更宽容;模型非常轻,CPU 上跑一帧也就几十毫秒;结构简单,论文和开源实现都多,报告里画网络结构图容易。
如果你的数据集里车牌角度比较正、字符间距规整,两者差距不大。但考虑到大作业大概率在 CPU 上演示,我一般建议用 LPRNet。它不需要 LSTM 的时间步概念,反向传播更直接,调参的玄学空间也小一些。
2.3 一个可落地的项目目录结构
拿到一个 zip 源码包,第一件事不是跑pip install -r requirements.txt,而是先看目录结构是否完整。一个能顺利复现的车牌识别项目,文件组织大致是这样:
license_plate_recognition/ ├── configs/ │ ├── detect.yaml # 检测模型配置,含类别名、输入尺寸 │ └── reco_config.yaml # 识别模型配置,含字符表、图像宽高 ├── data/ │ ├── images/ # 原始图片,含训练/验证/测试 │ ├── labels/ # YOLO 格式标注(类别 cx cy w h) │ └── char_mapping.txt # 字符索引表,一行一个字符 ├── src/ │ ├── detect/ │ │ ├── train.py # 检测模型训练入口 │ │ └── infer.py # 检测推理,输出车牌坐标框 │ ├── recognize/ │ │ ├── model.py # LPRNet 网络定义 │ │ ├── train.py # 识别模型训练入口 │ │ └── decode.py # CTC 解码,去重与空字符过滤 │ └── pipeline.py # 检测+识别串联,端到端推理 ├── weights/ │ ├── detect_best.pt │ └── lprnet_best.pth └── requirements.txt我见过很多翻车案例,问题不在模型,而是char_mapping.txt字符顺序和训练时不一致,推理结果全是乱的。所以拿到源码先做一件事:核对字符表,确保0-9、A-Z、省份简称汉字都在表里,且索引从 0 开始连续递增。字符表和模型权重是绑定的,换字符表必须重训识别模型。
3. 用 YOLOv8 把车牌从画面里“抠”出来:数据集、转换脚本与三个必调参数
3.1 数据从哪来来:CCPD 子集与手工标注的取舍
检测阶段的开源数据集,最常用的是 CCPD(中国城市车牌数据集),里面有近 30 万张带标注的车牌图。对大作业来说,不用全量,挑几千张就够。选数据时注意一个坑:CCPD 按天气和场景分了ccpd_blur、ccpd_rain、ccpd_snow、ccpd_night等子集,不要只挑大晴天正面的图,否则一到演示现场,灯光一暗就露馅。
如果你要检测的是特定场景——比如校园门禁、地下车库——CCPD 可能不够贴合。常见做法是先用 CCPD 训一版,再拿手机去目标场景拍 200 到 300 张,用 LabelImg 手工标注,把标注结果并进训练集做微调。手工标注车牌不算痛苦,车牌是矩形目标,框四个角就行,但要注意把整个车牌含白边一起框进去,不要只框字符区域。
3.2 标注文件转成 YOLO 格式:坐标归一化的四个细节
CCPD 自带的标注是 JSON 里的vertices字段,存的是四个角点的绝对坐标。而 YOLO 需要的是归一化后的中心点坐标和宽高(cx, cy, w, h)。转换脚本是关键一环,四角坐标转中心点宽高的写法如下:
import json import os def ccpd_json_to_yolo(json_path, img_w, img_h, out_txt_path): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) # CCPD 的 vertices 是四角点列表,格式为 [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] vertices = data["vertices"] xs = [p[0] for p in vertices] ys = [p[1] for p in vertices] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) # 用外接矩形计算中心点和宽高 box_w = x_max - x_min box_h = y_max - y_min cx = (x_min + x_max) / 2.0 cy = (y_min + y_max) / 2.0 # 归一化到 [0,1],注意除以的是图片宽高 cx /= img_w cy /= img_h box_w /= img_w box_h /= img_h # 单类别车牌,class id 固定为 0 with open(out_txt_path, "w", encoding="utf-8") as f: f.write(f"0 {cx:.6f} {cy:.6f} {box_w:.6f} {box_h:.6f}\n") if __name__ == "__main__": # 示例:单张图片转换 ccpd_json_to_yolo( json_path="data/raw/1.json", img_w=1160, img_h=720, out_txt_path="data/labels/1.txt" )逻辑上要盯住四个细节:第一,四角点转外接矩形时,如果原图有旋转车牌,外接矩形会把背景也框进来,解决办法是先用最小外接矩形算子拿到角度再做仿射矫正,而不是直接取min/max;第二,归一化的分母必须是原图尺寸,不是标注图尺寸,CCPD 的高清图下载后如果被压缩过,这一步会直接错位;第三,类别 ID 从 0 开始,在data.yaml里要写names: ['plate'];第四,XML 或 JSON 里的坐标原点在左上角,YOLO 的归一化坐标原点也在左上角,别画蛇添足做翻转。
3.3 训练与推理参数:一组能直接跑的命令
检测端我习惯用 ultralytics 的 YOLOv8,代码侵入少,训练和导出一条龙。训练前的data.yaml长这样:
path: ./data # 数据集根目录 train: images/train # 训练图片相对路径 val: images/val # 验证图片相对路径 nc: 1 # 类别数,只有车牌一类 names: 0: plate训练命令不需要写太花哨,关键是参数要贴合车牌这类小目标:
yolo detect train \ model=yolov8s.pt \ data=data.yaml \ epochs=60 \ imgsz=640 \ batch=16 \ lr0=0.01 \ mosaic=1.0 \ close_mosaic=10 \ patience=15参数说明:imgsz=640是速度和精度的折中,车牌在整张图中占比通常很小,低于 640 会直接把小目标“压没”;mosaic=1.0开启马赛克增强,等于把四张图拼成一张再训练,能显著提升小目标检测稳定性;close_mosaic=10表示最后 10 个 epoch 关闭马赛克增强,让模型在接近真实分布的数据上收尾;patience=15控制早停,验证集 mAP 连续 15 轮不涨就自动停。
推理时不要直接拿训练脚本改,单独写一个推理脚本更好控制输出:
from ultralytics import YOLO model = YOLO("weights/detect_best.pt") results = model.predict( source="data/test/street.jpg", imgsz=640, conf=0.35, # 置信度阈值,低一点能减少漏检,但误检会增加 iou=0.5, # NMS 阈值,车牌之间不会重叠,0.5 足够 max_det=10, # 单张图最多出 10 个框,防止误检堆叠 save_txt=False, save_crop=True # 自动保存裁剪后的车牌图,后续识别直接吃这些图 )这段代码里save_crop=True是最实用的参数——它会自动把检测到的车牌区域裁剪成小图存到runs/detect/predict/crops/plate/下,后面识别阶段直接从这读图就行,不用自己再写裁剪逻辑。conf调到 0.35 以下时,误检框会明显变多,建议只在夜间场景下适当放宽。
4. 车牌字符识别:LPRNet + CTC,不分割单字符的做法与训练细节
4.1 为什么不用单字符分割识别
早期车牌识别喜欢走“分割再分类”路线:先做垂直投影把字符一个个切出来,再对每个字符做分类。这套方法在字体规整、间距均匀的蓝牌上表现尚可,遇到新能源绿牌(多一位字符)、使馆黑牌、倾斜或遮挡场景就崩了——投影分割把字符切得七零八落,后续分类再准也没用。
深度学习方案里更稳的是把整张车牌图当成一个序列,让网络自己学习字符间的时序关系。这就是 CTC 的用武之地:网络输出一个比字符数长的概率序列,CTC 通过“去重 + 去空白”把序列压缩成最终字符串,不需要显式分割字符。推广到任意长度字符组合时,这套方案几乎不需要改代码。
4.2 LPRNet 模型要点与简化实现
LPRNet 的输入是固定高度的灰度图,宽度可以变化,通常高度取 24 像素,宽度取 94 像素。网络主体是一串卷积层,逐步下采样得到特征图,再通过一个全连接映射到字符概率分布。
一个能直接跑的简化版模型定义如下:
import torch import torch.nn as nn class SmallLPRNet(nn.Module): def __init__(self, num_classes): super().__init__() # 输入: (batch, 1, 24, 94) 灰度车牌图 self.backbone = nn.Sequential( nn.Conv2d(1, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 尺寸 -> 12 x 47 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 尺寸 -> 6 x 23 nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), ) # 把 6 x 23 的特征图按宽度方向展开成序列 # 后面的 1x1 卷积等价于对每个位置做分类 self.head = nn.Sequential( nn.Conv2d(256, num_classes, 1), ) def forward(self, x): x = self.backbone(x) # b, 256, 6, 23 x = self.head(x) # b, num_classes, 6, 23 # CTC 需要的形状: (time_steps, batch, num_classes) # 每个宽度位置是一个时间步 b, c, h, w = x.size() x = x.squeeze(2) # 高度已压缩到 1,直接去掉 x = x.permute(2, 0, 1) # (w, b, c) -> (23, b, num_classes) return x代码逻辑说明:backbone三段卷积逐步降低空间尺寸、增加通道数,最终把每张图压成6 x 23的特征网格;head用 1x1 卷积把每个位置的 256 维特征映射到num_classes维,相当于告诉模型“这个时间步属于哪个字符类别”。最后squeeze(2)是假设高度方向已经被压缩到 1,如果特征图高度不是 1,需要先用一个全局池化把高度方向压掉。
参数说明:输入高度固定 24 是为了配合两次MaxPool2d(2)的降采样链,输出特征图高度从 24 降到 6,所以网络设计上对高度有限定;宽度从 94 降到 23,对应 CTC 的 23 个时间步。num_classes必须在字符表基础上加 1,多出来的一个是 CTC 的空白字符,索引一般取最后一个。
4.3 训练数据怎么低成本凑齐
识别模型的训练数据和检测不同,不需要背景复杂的实拍图,要的是“车牌区域特写”——字符清晰、背景基本是车牌底色的矩形图。常规来源有两个:一是从 CCPD 检测标注框里直接裁剪出车牌区域;二是用开源工具合成车牌图。合成更适合用来补齐省份汉字和生僻字符,因为实拍数据集里省份简称分布不均匀,“京”“苏”“粤”特别多,“藏”“青”很少。
训练时输入处理有固定套路:转灰度、缩放到1 x 24 x 94、做归一化。数据增强方面,轻度仿射变换和随机亮度对比度最有用,因为实拍车牌的倾斜和光照变化是主要干扰。
import cv2 import torch from torch.utils.data import Dataset class PlateDataset(Dataset): def __init__(self, img_paths, labels, char_to_idx, img_w=94, img_h=24): self.img_paths = img_paths self.labels = labels self.char_to_idx = char_to_idx self.img_w = img_w self.img_h = img_h def __getitem__(self, idx): img = cv2.imread(self.img_paths[idx], cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (self.img_w, self.img_h)) img = img.astype("float32") / 255.0 img = torch.from_numpy(img).unsqueeze(0) # (1, 24, 94) # 标签转成字符索引序列,例如 "京A12345" -> [2, 5, 13, ...] label_indices = [self.char_to_idx[ch] for ch in self.labels[idx]] label_indices = torch.tensor(label_indices, dtype=torch.long) return img, label_indices这个 Dataset 有两个要点:字符索引不能直接从 0 开始,因为 0 通常留给 CTC 的 blank,实际字符索引从 1 开始映射,blank 留在最后一位;标签不需要 padding 对齐,PyTorch 的CTCLoss接受变长标签,在这里返回原始序列即可。
4.4 识别推理:greedy decode 与去重规则
推理时不需要做束搜索,车牌字符短、类别明确,用贪心解码就够了。核心逻辑是取每个时间步概率最大的字符,再去掉连续重复和 blank:
def greedy_decode(model_output, blank_idx): # model_output shape: (time_steps, batch, num_classes) probs = model_output.permute(1, 0, 2) # (batch, time_steps, num_classes) preds = probs.argmax(dim=2) # (batch, time_steps) decoded = [] for pred in preds: chars = [] prev = None for t in range(pred.size(0)): idx = pred[t].item() if idx != blank_idx and idx != prev: chars.append(idx) prev = idx decoded.append(chars) return decoded去重逻辑只有一条规则:当前时间步的类别和上一个时间步相同就跳过,同时遇到 blank 也跳过。这里有个容易忽略的细节——prev的更新必须在遇到 blank 时也执行,否则“A A blank A”这种情况会把中间的 A 去掉后,前后两个 A 又被合并成一个。如果你发现识别结果少了字符,先检查这里。
训练参数给一组参考起点:batch_size=64、学习率1e-3、Adam 优化器、Cosine 退火、训练 50 到 80 个 epoch。字符表大约 70 个类别时,LPRNet 单卡 CPU 训练几十个 epoch 就能在验证集上达到 95% 左右的字符准确率,这也是它适合大作业的原因——不需要等 GPU 排队。
5. 常见问题排查:我在这类项目上翻过的五个车
5.1 检测框把整辆车框住,车牌被漏掉
现象:推理结果里出现一个接近全图大小的框,但 save_crop 裁剪出来的“车牌图”里根本没有车牌,识别结果自然全错。
原因:训练数据里存在“整辆车”的标注噪声。CCPD 部分子集标注框偶尔会把车头整体框进去,YOLO 学到的是“有车就有框”,而不是“有车牌才有框”。
解决:训练前清洗数据是必须的,写脚本筛出宽高比大于 4 或小于 1 的标注框直接删掉,车牌宽高比通常在 2 到 4 之间;另外把conf阈值提到 0.5 以上,过滤低置信度的整框。这类问题靠调参是压不住的,必须清数据。
5.2 训练 loss 降了但识别出来全是“白板”
现象:LPRNet 训练时 loss 从十几降到 0.5 以下,推理时输出却是空字符串或乱码。
原因:字符表和模型输出维度对不上。最常见的是num_classes算错了——字符表 70 个字符加了 blank 应该是 71,但模型里写成了 70,CTC 的 blank 索引越界;另一种是解码时blank_idx写成了 0,而 0 是真实字符,所有空白被当成字符输出了。
解决:写一个自检脚本,训练前打印num_classes、blank_idx、len(char_list)三个值,确认num_classes == len(char_list) + 1且blank_idx == len(char_list)。
5.3 识别端把“京”识别成“冀”
现象:蓝底白字的车牌,其它字符都对,只有省份简称经常错一个字。
原因:汉字类别在数据集中占比严重不均。CCPD 里安徽、北京、广东的数据多,青海、西藏几乎没有,模型对低频汉字的置信度天然偏低。
解决:用合成数据补齐低频省份简称,把“藏”“青”“宁”“新”等各合成几百张加入训练集;如果嫌麻烦,可以在后处理里加一个汉字候选词表,让识别结果只从合法省份简称里选,这个规则在落地时非常实用。
5.4 GPU 显存不够,batch 一调小 loss 就爆炸
现象:yolo 训练时batch=16报 CUDA out of memory,改成batch=4后 loss 曲线震荡剧烈,训练无法收敛。
原因:batch 太小导致 BN 层的均值和方差估计不稳,学习率还按原来的大值设置,梯度更新方向来回摆动。
解决:先降学习率,lr0按 batch 缩小比例同步降低,从 0.01 降到 0.002;或者在data.yaml里把训练图统一缩到 512 尺寸,显存占用减少约三分之一,batch 就不用降太多。不要只调 batch 不动学习率,这是新手最容易踩的坑。
5.5 onnxruntime 推理结果和 PyTorch 对不上
现象:PyTorch 权重跑得好好的,转成 ONNX 后再推理,识别出来的车牌字符变乱。
原因:转 ONNX 时opset版本和 onnxruntime 版本不兼容,导致某些算子的计算结果有细微差异;更常见的是输入尺寸写死了94x24,但导出时把动态轴漏了,推理时输入的尺寸被强制改变。
解决:导出时固定opset=12,并把张量的宽高轴声明为动态轴;另外 ONNX 的输入是NCHW格式,车牌裁剪图是HWC的 OpenCV 顺序,推理前记得transpose((2, 0, 1))再unsqueeze(0)。
6. 用测试集量化“准不准”:别拿两张图说效果,要拿指标说事
大作业答辩时最尴尬的场景是老师问“准确率多少”,你支支吾吾说“感觉还行”。正确做法是准备一个没参与训练的测试集,至少 500 张图,跑通下面这个评估循环:检测阶段统计 mAP,识别阶段统计字符准确率,最后把两个阶段串起来统计端到端准确率。
评估脚本的核心片段可以这样写:
def evaluate_end_to_end(model_detect, model_reco, test_loader): total = 0 correct = 0 char_hit = 0 char_total = 0 for img, gt_text in test_loader: # 步骤1: 检测 dets = model_detect.predict(img, conf=0.4, imgsz=640) if len(dets) == 0: continue # 漏检或误检都算整体错误 crop = dets[0].crop # 取最高置信度框 # 步骤2: 识别 reco_text = model_reco.recognize(crop) total += 1 correct += (reco_text == gt_text) # 字符级准确率 for c_gt, c_pred in zip(gt_text, reco_text): char_total += 1 char_hit += (c_gt == c_pred) end_to_end_acc = correct / total char_acc = char_hit / char_total return end_to_end_acc, char_acc这里最关键的理解是:端到端准确率从来不是检测准确率“乘”识别准确率那么简单,而是两个误差叠加后的净结果。检测漏一张,这张的识别无论多准都是错;识别错一位字符,这张牌也判错。所以评估时要分别统计三组数:单独检测 mAP、单独识别字符准确率、端到端车牌准确率。某一项低了,定位到具体阶段再修。
我自己的习惯是跑完评估后把错误样本按类型归一下类——是夜间低照度导致的检测漏框,还是倾斜过大导致的识别错位,还是边缘车牌字符模糊。分类之后再决定要不要补数据、调增强、换模型,而不是盲目重训。这是我做这类项目最大的教训:车牌识别系统的瓶颈往往不在模型结构,而在你对错误分布是否有清晰的认知。希望帮到你。
本文还有配套的精品资源,点击获取