
简介面向计算机相关专业学生及毕业设计、期末大作业场景这份基于PyTorch与OpenCV的初级车牌识别项目提供了可直接运行的完整源码与预训练模型适合需要快速搭建图像识别实战项目的学习者。压缩包共7个文件包含3个Python脚本、2个模型权重文件、1个图片数据集及1个说明文档总大小约25.38MB结构简洁清晰。脚本按字符网络、车牌网络与整体识别流程拆分便于理解车牌定位、字符分割与识别的完整链路模型权重可直接加载免去自行训练的时间成本。项目经导师指导并评审为98分代码均经过本地编译调试通过难度适中内容符合助教审定要求可放心下载使用。目前已有64人学习下载对正在完成课程设计、毕业设计或希望入门OCR方向的同学是一份可复现、易上手的参考资料。1. 车牌识别项目为什么选择PyTorch和OpenCV两段式路线车牌识别第一次跑通并不难难的是让它稳定地踩中所有坑车拍角度歪一点、曝光过曝、字符粘连、中文汉字样本不足任何一个环节都能让最终输出面目全非。很多人一开始就试图用PyTorch训练一个端到端模型把整张车图直接映射成字符串但这类模型对数据量、算力和标注质量的要求都偏高初级项目很难在有限周期内收敛。常见做法是反过来OpenCV负责从原图中定位车牌、做透视矫正和字符分割PyTorch只负责最后一步字符分类。任务拆开后每一段都有明确的输入输出可以单独看中间结果也可以在汇报时画出一条清晰的流水线。下面我按这条流水线把定位、分割、训练和推理完整过一遍代码可以直接拼成一个小项目。适合有PyTorch基础、想第一次完整接触图像分类实战的读者参考。2. 车牌定位用OpenCV图像处理把候选区域框出来2.1 为什么把检测和识别分两段来做车牌这个目标在图像里先验非常强它有固定长宽比、有矩形轮廓、有相对稳定的颜色。对初级项目来说这些先验如果不用反而要用几百上千张标注框去训练一个目标检测器投入产出比很低。先用OpenCV基于边缘和轮廓做定位一两段代码就能拿到候选区域之后再把候选区域喂给PyTorch识别字符整个系统的逻辑就变成了一条可以逐级检查的管道。这条管道顺序固定原图 → 车牌定位 → 透视矫正 → 字符分割 → 字符识别 → 文本拼接。前两步由OpenCV完成字符识别由PyTorch模型完成。这里没有涉及复杂的目标检测模型也没有循环神经网络所有中间结果都能保存成图片直接观察。你在一张测试图上如果发现定位框选歪了只需要检查轮廓筛选参数如果发现字符识别错了就去看训练数据里这个字符的数量是不是太少。问题定位是分模块的调试成本低这正是初级项目能拿高分的关键。两段式还有一个好处字符识别模型可以单独复用。你不一定只用它识别车牌训练好的字符串分类器同样能用于快递单号、验证码一类场景。反过来如果哪一天你换了一套更好的定位算法字符模型依然不用改动只要保证传给它的还是等距排列的二值字符图。2.2 用Canny、形态学和轮廓找到车牌矩形车牌在图像中通常比周围物体更“尖锐”边缘轮廓整齐所以定位的第一步是提取边缘。先把图像转为灰度用高斯滤波去掉噪点再用Canny算子得到边缘图。接着用一定宽度和高度的矩形核对边缘图做闭运算把字符内部的边缘连成整块最后用findContours找出候选轮廓。import cv2 import numpy as np def locate_plate(img): # 灰度化以后车牌边缘会比彩色图更容易提取 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny的低阈值和高阈值需要配合图片分辨率调整 edged cv2.Canny(blurred, 100, 200) # 闭运算的核故意做成宽扁形因为车牌区域本身就是宽扁的 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(edged, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(closed, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) area w * h # 太小或占比太低的区域不可能是车牌 if area 2000 or area 0.001 * img.shape[0] * img.shape[1]: continue ratio max(w, h) / min(w, h) # 车牌长宽比大致在2.5到6之间过宽过窄都排除 if 2.5 ratio 6.0 and min(w, h) 20: candidates.append(cnt) candidates.sort(keycv2.contourArea, reverseTrue) return candidates[0]Canny的两个阈值控制边缘提取的敏感度。如果一张车里完全找不到轮廓先把低阈值从100降到50再试如果边缘过于碎杂反而要调高到150左右。闭运算的核大小也很关键对1920×1080的图片核取17×5通常够用对低分辨率摄像头图像这个尺寸要往下调否则两个字符之间的缝隙会被全部填满导致最终定位框把整块车头连起来。轮廓筛选这里用了面积、占比和长宽比三重限制。只有矩形特征还不够我一般还会要求轮廓的最小边长大于20像素避免远处的小车牌照被当成噪点丢弃。有的图片里车牌颜色很浅边缘检测后车牌内部可能断开这时可以放弃一版参数直接观察候选轮廓叠加图确认到底是车牌被漏检还是被错误候选淹没。下面这组函数参数是定位环节最常打交道的几个值得记一下函数在本项目中的作用需要特别关注的参数cv2.GaussianBlur平滑图像减少边缘噪点核尺寸越大车牌边缘越容易被“抹平”cv2.Canny提取图像边缘低阈值影响弱边缘是否保留cv2.morphologyEx闭运算连接断裂边缘核形状要尽量贴近车牌宽高比cv2.findContours得到候选轮廓模式参数影响嵌套轮廓的取舍2.3 透视矫正得到规整的车牌图像车牌很少正对着摄像头多数情况下会带一点侧倾。轮廓切割得到的矩形可能包含车漆背景这样切割出的字符会变形。为了解决这个问题需要把原始四边形区域矫正成一个正面视角。典型的做法是先拿到轮廓的最小外接矩形再通过透视变换映射到目标矩形。def order_points(pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) diff np.diff(pts, axis1) rect[0] pts[np.argmin(s)] # 左上 rect[2] pts[np.argmax(s)] # 右下 rect[1] pts[np.argmin(diff)] # 右上 rect[3] pts[np.argmax(diff)] # 左下 return rect def license_plate_crop(color_img, pts): rect order_points(pts) (tl, tr, br, bl) rect widthA np.linalg.norm(br - bl) widthB np.linalg.norm(tr - tl) heightA np.linalg.norm(tr - br) heightB np.linalg.norm(tl - bl) maxWidth max(int(widthA), int(widthB)) maxHeight max(int(heightA), int(heightB)) dst np.array([[0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(color_img, M, (maxWidth, maxHeight))locate_plate返回值是一个轮廓直接使用也可以但更好的做法是用cv2.minAreaRect拿到旋转矩形的角点再传给license_plate_crop。minAreaRect对侧倾车牌的适应力比boundingRect强得多因为它不要求矩形与图像坐标轴平行。rect_rot cv2.minAreaRect(locate_plate(img)) corners cv2.boxPoints(rect_rot).reshape(4, 2) plate license_plate_crop(img, corners)透视变换可能会让车牌里的文字产生一定拉伸但这种拉伸是均匀的对后续字符分割影响不大。矫正后保存的plate图像可以直接作为第三个章节的输入。3. 字符分割把车牌区域变成PyTorch能用的字符样本3.1 分割前先做二值化和去边框拿到矫正后的车牌图后先别急着丢进网络。车牌上有边框、铆钉、安装螺丝这些都会干扰字符分割。常见做法是先做灰度化再用中值滤波去噪最后用Otsu阈值做二值化。Otsu会根据灰度直方图自动确定一个分割阈值在光照变化较大的车牌图上比固定阈值更稳。plate cv2.imread(plate.jpg) gray cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY) gray cv2.medianBlur(gray, 3) # Otsu自动阈值二值化 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 蓝底白字车牌二值化后白字在黑色背景上如果是黄底黑字需要反色 if np.mean(binary) 127: binary cv2.bitwise_not(binary) # 去掉上边框、下边框和左右边框避免把车牌框当字符切割 plate binary[8:binary.shape[0] - 8, 8:binary.shape[1] - 8]中值滤波在保留文字边缘方面比高斯滤波更合适它主要去除孤立噪点不会像均值滤波那样把笔画边缘变得模糊。条件判断np.mean(binary) 127的思路是统计二值图中白色像素的占比。蓝底车牌中文字是白的二值化后背景是黑色平均值偏低所以不需要反色黄底车牌正好相反。处理好这一步后可以直接用cv2.imwrite保存中间结果观察边框是否被完全去掉。3.2 用轮廓法分割字符并按x坐标排序字符分割有很多路子垂直投影法多见于文本行分割但车牌字符间隔并不均匀汉字与字母之间还会存在空隙。对初级项目来说轮廓法更直观找到每个连通域再根据字符的高度、宽度和面积过滤。def split_chars(binary): # binary是去掉边框的二值图 chars [] contours, _ cv2.findContours(binary.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) h binary.shape[0] for c in contours: x, y, w, hh cv2.boundingRect(c) # 字符高度不到车牌高度30%的大概率是噪声或边框碎片 if hh h * 0.3: continue # 宽度超过整张车牌宽度30%的可能是多个字符连在一起 if w binary.shape[1] * 0.3: continue if w * hh 50: continue chars.append((x, y, w, hh)) chars.sort(keylambda t: t[0]) return chars过滤条件需要针对实际图片调整。第一项高度约束是最重要的它能滤掉车牌底部的水平装饰线。第二项宽度约束能防止字符粘连后产生一个超大包围盒如果你发现“苏”和“A”总是被合并成一个候选区域可以把阈值从0.3降到0.2。第三项面积过滤适合去掉零散噪点但不要设太高否则汉字里较细的笔画会被丢弃。排序必须按x坐标从小到大否则最终拼接出的车牌字符串会出现乱序。有些项目会在这一步把“·”字符单独忽略因为中文车牌里的分隔点对识别结果没有参考价值也可以在训练时专门加一个“dot”类我一般更偏向直接忽略这样能减少类别不平衡。下面这个表是三个过滤条件的快速参考过滤条件作用误设后果高度 车牌高度×0.3去掉水平噪声和上下边框残留设太高会漏掉汉字宽度 车牌宽度×0.3避免粘连字符混合切割设太低会把“川”切成两半面积 50去除孤立噪点设太大会把笔画较细的字符丢掉3.3 把分割结果落盘为训练集目录PyTorch的ImageFolder要求数据按类别目录组织因此分割后需要把每个字符单独保存并以字符标签作为文件夹名。你可以手工整理一批不含标注的图片先通过一个不太准的模型预测出结果再人工修正用这种方式“启动”第一版训练集。这个过程不需要很复杂只要把字符图像写入对应目录即可。import os import time import cv2 def save_char_samples(car_image_path, corners, label_str, save_dir): img cv2.imread(car_image_path) plate_bgr license_plate_crop(img, corners) plate_binary preprocess_plate(plate_bgr) chars split_chars(plate_binary) # 假设label_str和分割出的字符数量一致比如“京A12345” for i, (x, y, w, h) in enumerate(chars): char_img plate_binary[y:y h, x:x w] label_dir os.path.join(save_dir, label_str[i]) os.makedirs(label_dir, exist_okTrue) filename f{int(time.time() * 1000)}_{i}.png cv2.imwrite(os.path.join(label_dir, filename), char_img)这段代码的关键在于分割顺序必须与字符串顺序一致。如果标签是“京A12345”那么分割出的第一个连通域应该对应“京”第二个对应“A”以此类推。汉字和字母的宽度不同不要用固定宽度切分。写完目录后用torchvision.datasets.ImageFolder就可以直接读取。4. 用PyTorch基础框架搭建字符识别模型4.1 Dataset和DataLoader的写法PyTorch基础框架里训练数据先要包装成Dataset然后用DataLoader做批量加载、打乱和多进程读取。如果你的数据集已经按字符目录整理了直接使用ImageFolder最省事更通用一点的写法是自定义Dataset方便打印和检查异常路径。import os import cv2 import torch from torch.utils.data import Dataset class CharDataset(Dataset): def __init__(self, root, transformNone): self.samples [] self.transform transform self.char_to_idx {} for idx, label in enumerate(sorted(os.listdir(root))): label_path os.path.join(root, label) if not os.path.isdir(label_path): continue self.char_to_idx[label] idx for fname in os.listdir(label_path): self.samples.append((os.path.join(label_path, fname), idx)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img cv2.imread(path, cv2.IMREAD_GRAYSCALE) if self.transform: img self.transform(img) return img, label这里的char_to_idx字典必须保存下来推理时要把模型输出的索引映射回字符。如果直接使用datasets.ImageFolder它会自动根据目录顺序生成一个classes_列表同样要在训练结束后序列化保存。数据增强和归一化放在transform里做from torchvision import transforms transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ]) from torch.utils.data import DataLoader dataset CharDataset(dataset/char/train, transformtransform) loader DataLoader(dataset, batch_size64, shuffleTrue, num_workers2)num_workers在Windows上容易报多进程错如果遇到直接设成0。字符图像是单通道灰度图所以Normalize的均值方差也只有一个值这里用0.5做标准化意思是把0到1的像素范围映射到-1到1区间。4.2 一个比LeNet稍宽一点的CNN分类网络字符识别的输入尺寸被resize到32×32这个尺寸对车牌字符足够且训练速度非常快。网络结构不需要很深三层卷积加全连接层已经能拿到不错的效果。下面这个网络刻意加入了Dropout防止小样本情况下过拟合。import torch.nn as nn class CharNet(nn.Module): def __init__(self, num_classes): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(), nn.AdaptiveAvgPool2d((4, 4)) ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))32×32的输入经过两次池化后变成8×8第三层卷积后特征图还是8×8。这里使用AdaptiveAvgPool2d把最终特征图强制缩放到4×4好处是即使你后来把输入尺寸改成64×64全连接层前的维度也不会变化。车牌字符大部分是规整笔画第一层用32个卷积核提取边缘第二层用64个卷积核组合出笔画结构第三层128个卷积核负责区分汉字里的复杂交叉。在训练脚本里根据类别数量初始化模型model CharNet(num_classeslen(dataset.char_to_idx))如果分类结果中数字和字母很准但汉字混乱不要急着加深网络。先看看汉字类别样本数量是不是远小于数字类别如果是优先做数据平衡或简单复制增强比如对字符做轻微的平移再放进训练集。下表是这个网络的尺寸变化层输出尺寸对应作用输入灰度图1×32×32单通道文字图像第一组卷积池化32×16×16提取点、横竖边缘第二组卷积池化64×8×8提取角、笔画组合第三组卷积池化128×4×4提取汉字局部结构全连接Dropout256防止过拟合的分类特征输出层num_classes每个类别的置信度4.3 训练循环和损失函数选择字符识别是一个标准的多分类问题用CrossEntropyLoss。优化器选择Adam初始学习率1e-3。车牌字符类别总数大约在65到70之间如果只做单字分类并不需要特别复杂的损失函数。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model CharNet(len(dataset.char_to_idx)).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(30): model.train() total_loss 0.0 correct 0 total 0 for images, labels in loader: images images.to(device) labels labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() correct (outputs.argmax(1) labels).sum().item() total labels.size(0) print(fEpoch {epoch 1:02d} Loss {total_loss / len(loader):.4f} fAcc {correct / total:.4f})outputs.argmax(1)是取每个样本中得分最高的类别索引。要提醒的是训完以后不要只保存模型权重还要把classes_列表保存成jsonimport json with open(classes.json, w, encodingutf-8) as f: json.dump(dataset.char_to_idx, f, ensure_asciiFalse) torch.save(model.state_dict(), char_model.pth)保存权重时不要直接torch.save(model)state_dict只保存参数体积小而且加载时只要模型结构一致就能恢复不会因为代码里临时变量的变化导致加载失败。5. 联调推理把OpenCV和PyTorch模型串成一个完整命令5.1 环境搭建与训练命令整个项目涉及PyTorch、OpenCV和numpy依赖关系并不复杂。常用的做法是先创建一个conda环境再分别安装两个库。如果只是在CPU上做验证完全没必要装GPU版字符分类网络很小一个epoch在CPU上也只要几十秒。conda create -n plate python3.9 -y conda activate plate conda install pytorch torchvision cpuonly -c pytorch pip install opencv-python numpy python train.py --data_root dataset/char/train --epochs 30cpuonly是PyTorch官方conda频道中的CPU版本标志安装包更小也不会和本机CUDA版本打架。训练脚本写成命令行参数风格后后面换目录调epoch不需要改代码。如果你本机已经装了合适版本的PyTorch跳过conda安装步骤直接用pip install opencv-python numpy也可以核心流程区别不大。5.2 推理脚本的完整骨架训练完成后将第2章的定位函数、第3章的预处理函数和第4章的模型加载合并到一个推理脚本中。推理时不需要梯度计算所以用with torch.no_grad()包裹这样可以减少显存占用并提高速度。import cv2 import torch import json from torchvision import transforms with open(classes.json, r, encodingutf-8) as f: char_to_idx json.load(f) # 将“字符-索引”反转为“索引-字符” idx_to_char {v: k for k, v in char_to_idx.items()} model CharNet(num_classeslen(char_to_idx)) model.load_state_dict(torch.load(char_model.pth, map_locationcpu)) model.eval() transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ]) def recognize_car_plate(image_path): img cv2.imread(image_path) corners locate_plate(img) plate_bgr license_plate_crop(img, corners) plate_binary preprocess_plate(plate_bgr) chars split_chars(plate_binary) result [] with torch.no_grad(): for x, y, w, h in chars: char_img plate_binary[y:y h, x:x w] tensor transform(char_img).unsqueeze(0) pred model(tensor).argmax(1).item() result.append(idx_to_char[pred]) return .join(result) print(recognize_car_plate(car_001.jpg))这里有一个容易踩的坑locate_plate返回的是最大轮廓而不是旋转矩形的四角点。如果你的定位函数没有做cv2.minAreaRect转换需要额外包一层。推理时对每个字符的图像transform内部先转成PIL再resize到32×32最后转成tensor。unsqueeze(0)是增加一个batch维度因为模型默认接收形状为(batch, channel, height, width)的张量。5.3 推理结果不对时按表格排查联调阶段最容易出现定位能出来但识别结果乱掉的情况。这往往不是模型本身的问题而是输入给模型的字符图带了边框或背景。下面这张表可以直接作为排查手册。现象优先检查的位置调整建议一张图都找不到车牌locate_plate的轮廓筛选降低Canny低阈值、减少面积阈值、放宽长宽比车牌找到但字符切成一整块分割前的闭运算没有去掉二值化后不要做膨胀优先用轮廓直接切识别结果多出无意义字符边框没有完全去除把裁剪边距从8像素加大到12像素中文识别结果对数字错误汉字样本太少增加汉字字符数据或对汉字样本做平移增强识别字符顺序错乱字符排序方式检查split_chars是否按x坐标排过序6. 最后要把精度钉死的几个操作6.1 用分类报告代替单看一眼总体准确率很多同学看完Acc 0.97就觉得项目可以交了但0.97很可能是因为十个数字类别占了样本的大部分。车牌识别项目最容易翻车的地方恰恰是省份汉字因为汉字类别多、样本少类别不均衡非常严重。正确的做法是在验证集上输出每个类别的准确率和召回率用classification_report能一次看清哪个字符在拖后腿。from sklearn.metrics import classification_report with torch.no_grad(): y_true [] y_pred [] for images, labels in val_loader: outputs model(images) y_true.extend(labels.tolist()) y_pred.extend(outputs.argmax(1).tolist()) target_names [idx_to_char[i] for i in range(len(idx_to_char))] print(classification_report(y_true, y_pred, target_namestarget_names))如果发现某个汉字召回率在0.5以下不要立刻改网络结构先去检查训练集里这个字符的样本数量。一种有效的做法是把每个类别限定到相同数量比如从样本多的数字类里随机抽样样本少的汉字类反复出现在每个epoch里。这种类别平衡调整起来简单却往往比把模型从三卷积改成四卷积提升更明显。6.2 利用HSV先验降低OpenCV定位的误检轮廓法在干净背景上表现不错但如果车头附近有大灯、散热格栅等强边缘就可能把非车牌区域选出来。一个低成本的过滤方法是统计候选区域内蓝色像素的比例。车牌区域即使被透视拉伸蓝色像素依然占据明显面积而其他矩形噪点通常没有这个特征。hsv cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2HSV) mask cv2.inRange(hsv, (100, 80, 80), (130, 255, 255)) blue_ratio cv2.countNonZero(mask) / (plate_bgr.shape[0] * plate_bgr.shape[1]) if blue_ratio 0.2: return None这里的HSV范围针对蓝底车牌。如果把项目扩展到新能源绿牌可以把范围改成绿色对应的H值。这个过滤放在透视矫正之后因为矫正后的矩形区域才是真正的车牌区域统计更准确。如果过滤条件设置得太严格比如阈值超过0.4会在晨昏光线不足时把正常的蓝牌也判为误检所以建议先用一批测试图观察blue_ratio分布再确定阈值。6.3 用开源车牌检测识别模型做定位对比当图片来源从单张照片变成摄像头抓拍车牌角度变化大且图像模糊时OpenCV轮廓法会明显变弱。这不是你的代码写错了而是传统视觉方法的天然边界。初级项目做到这一段已经可以收尾但如果还有精力可以把定位环节替换成一个已有的开源车牌检测模型让模型直接输出车牌框坐标后续的透视矫正、字符分割和PyTorch分类继续沿用。这样做不需要重新训练字符识别模型等于是给项目加了一条可对比的技术路线。替换后建议在项目根目录建一张result.csv把图片路径、定位框坐标、识别结果、是否人工纠正四列记录下来。后面做参数对比时直接看csv里定位成功率和识别准确率比每次打印一行stdout更容易定位具体是哪一步引入了误差。对“高分项目”来说这种可量化的对比方式往往比多调0.2个百分点更能体现你对整个流水线的完整理解。本文还有配套的精品资源点击获取