ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PyTorch车牌识别实战:从CNN建模到OpenCV全流程解析

PyTorch车牌识别实战:从CNN建模到OpenCV全流程解析 停车场闸机的车牌识别现在大家见怪不怪了。但真正自己动手用PyTorch做一版车牌识别很多人才发现不是装好torch、跑个模型就能出结果。我自己在完成“第P10周Pytorch实现车牌识别”这个项目时走弯路最多的不是模型代码而是没有把整个任务拆开看。所以这篇博文不只是给你一段能跑的代码而是从任务拆解、环境准备、字符数据集、模型搭建、训练调参到全流程串联完整记录一次可复现的车牌识别实践。如果你想顺便把PyTorch环境搭建、卷积神经网络这些基础过一遍又想做出一个看得见效果的视觉项目这篇内容可以当一份操作地图。1. 车牌识别到底在做什么先拆解任务再动手很多人一听到“车牌识别”四个字第一反应就是“我要训练一个深度学习模型”。这个想法不算错但如果直接跳到“用YOLO检测车牌”大概率会在数据标注和环境依赖上卡很久。实际上传统车牌识别在工业里已经跑了很多年核心逻辑非常清晰先找到车牌在哪再把车牌上的字符挨个认出来。只要把这两步拆开整个项目就从“黑盒”变成了“白盒”。1.1 车牌识别不是单一模型而是一条流水线一个完整的车牌识别流程至少包含四层图像预处理把摄像头拍到的画面做缩放、去噪、增强对比度为后续定位做准备。车牌定位从整张图里找到车牌区域。常见做法有边缘检测、颜色筛选、轮廓过滤当然也可以用目标检测网络。字符分割把车牌区域里的每个字符单独切出来。国内蓝牌通常是7个字符第一个是省份简称后面是字母和数字。字符识别把切出来的字符图片输入分类器输出具体的汉字、字母或数字。这四层是递进关系前一层做得不干净后一层必然跟着遭殃。项目里最容易忽略的是第三层很多人辛辛苦苦训练了一个高精度字符分类模型结果分割时多切了一个边框或者两个字符粘在一起最终准确率被拉到惨不忍睹。所以我的建议是做车牌识别项目时不要一头扎进训练代码先把这条流水线在纸上画出来再逐层实现。1.2 路线怎么选传统定位 字符识别最合适技术选型上我对比过三条路线。技术路线实现难度数据需求精度上限适合场景传统图像处理定位 CNN字符识别中中等只需要字符级标注高在规整场景下很稳课程项目、固定闸口、道路卡口目标检测网络YOLO等直接检测车牌较高需要大量整车图片和车牌框标注高但训练成本更高复杂场景、移动端抓拍端到端识别网络LPRNet等高需要整张车牌图配字符串标签高但对数据多样性要求也高需要一步到位、遮挡少的中低分辨率场景最终我选了第一条路线。原因很直接车牌这个东西有天然强规则例如颜色固定、长宽比固定、字符排列固定。用传统图像处理去做定位和分割不仅速度快而且每一步都能可视化出了问题很好排查。字符识别部分交给PyTorch训练一个卷积神经网络正好覆盖了卷积、池化、步长、核、填充这些基础概念项目做完你对CNN的直觉会强很多。2. 环境搭建不是盲选PyTorch安装与项目骨架从搜索引擎里的热搜词就能看出来很多人第一步就卡在“PyTorch安装”上。有问Anaconda怎么配的有问CPU版和GPU版怎么选的还有问PyTorch下载太慢怎么办的。以我的经验安装本身不复杂复杂的是“没想清楚自己需要什么版本”。2.1 先看机器再装框架CPU版也能跑我建议动手前先执行一个命令确认自己机器上有没有NVIDIA显卡以及驱动能支持到哪个CUDA版本nvidia-smi如果输出里显示CUDA Version比如12.1那就可以安装GPU版PyTorch。我这次用的组合是Python 3.10 PyTorch 2.8.0 CUDA 12.1使用Anaconda创建独立环境命令大致是这样conda create -n plate python3.10 conda activate plate pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121如果没有NVIDIA显卡也不用换电脑直接安装CPU版pip install torch torchvision在车牌字符识别这个任务里数据集通常不会特别大CPU版也能把模型训练出来只是速度慢一些耐心等就行。安装完成后一定要验证一下框架是否可用import torch print(torch.__version__) print(torch.cuda.is_available())如果安装的是GPU版torch.cuda.is_available()应该返回True。这一步能排除掉很多后续莫名其妙的问题。注意不要随便在官网首页复制一段安装命令就执行你机器的驱动版本、Python版本和PyTorch版本必须匹配。下载速度慢的话可以配置国内镜像源但一定要保证镜像源里提供的包与CUDA版本对应。2.2 项目目录与依赖列表环境配好后我先把项目目录整理出来。清晰的目录结构会让你在写训练代码时少一半焦虑plate_recognition/ ├── data/ │ ├── train/ # 训练集字符图片 │ ├── val/ # 验证集字符图片 │ └── raw/ # 原始车牌图片 ├── checkpoints/ # 模型权重保存位置 ├── datasets.py # 数据加载 ├── model.py # 模型定义 ├── train.py # 训练脚本 ├── predict.py # 单张车牌识别脚本 └── requirements.txtrequirements.txt里的依赖通常只有这几样torch torchvision opencv-python numpy matplotlib tqdmtorchvision不是必须的但我建议装因为它既提供了预训练模型也提供了常用的图像变换工具。opencv-python承担车牌定位和预处理这是传统视觉部分的核心工具。3. 训练数据从哪来字符集构建与Dataset封装模型识别的是车牌上的单个字符所以需要准备一套字符分类数据集。国内蓝牌字符大致包括各省份简称汉字、24个大写字母一般不用I和O但实际数据里偶尔会出现、10个数字。加起来类别数在70个左右。这个数量不大但数据质量很关键。3.1 真实车牌切字符 vs 合成字符常见做法有两种。第一种是从公开车牌数据集中切字符。例如CCPD数据集它是一整套国内车牌图片但重点在于车牌的全局标注并不直接给字符级分割框。你想用它训练字符分类器就得先用OpenCV把车牌区域定位出来再用投影法切字符。这个流程有一个鸡生蛋的问题字符分割不够准后面训练出来的模型也难好。所以操作上通常只筛选切得很干净的样本。第二种是合成字符数据集。用PIL或OpenCV把单个字符渲染到类似车牌的背景上随机加一些旋转、模糊、光照变化。合成数据的好处是标签绝对准确生成一万张都不费劲缺点是真实感不足。我最终的方案是“合成为主、真实为辅”先用合成数据把模型整体训练起来再拿少量真实切图做微调。这样既保证了数据量又弥补了分布差异。生成合成样本的思路大致是这样from PIL import Image, ImageDraw, ImageFont def create_char_image(char, font_path, size(32, 32)): img Image.new(RGB, (64, 64), (0, 0, 0)) draw ImageDraw.Draw(img) font ImageFont.truetype(font_path, 42) draw.text((15, 5), char, fontfont, fill(255, 255, 255)) img img.resize(size) return img注意字体要选择接近车牌字形的字体比如黑体或专用于车牌的字体。生成后按标签放进对应文件夹即可。3.2 一个顺手的数据加载类数据放到data/train/京、data/train/A、data/train/3这样的目录结构之后就可以用torchvision.datasets.ImageFolder帮助我们生成标签映射。不过为了更灵活我习惯自己写一个Dataset类方便在加载时做归一化import torch from torch.utils.data import Dataset from PIL import Image from torchvision import transforms class CharDataset(Dataset): def __init__(self, root, transformNone): self.paths [] self.labels [] self.classes sorted(os.listdir(root)) self.class_to_idx {cls: i for i, cls in enumerate(self.classes)} self.idx_to_class {i: cls for cls, i in self.class_to_idx.items()} self.transform transform for cls in self.classes: cls_dir os.path.join(root, cls) for fname in os.listdir(cls_dir): self.paths.append(os.path.join(cls_dir, fname)) self.labels.append(self.class_to_idx[cls]) def __len__(self): return len(self.paths) def __getitem__(self, idx): img Image.open(self.paths[idx]).convert(RGB) if self.transform: img self.transform(img) label torch.tensor(self.labels[idx], dtypetorch.long) return img, label训练时补上一组经过验证的变换train_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomAffine(degrees10, translate(0.05, 0.05)), transforms.ColorJitter(brightness0.3, contrast0.3), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) ]) val_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) ])这里把图片统一成64x64比常见的32x32保留更多笔画细节尤其适合汉字识别。Normalize的参数是(0.5, 0.5, 0.5)和(0.5, 0.5, 0.5)等价于把像素从[0,1]映射到[-1,1]训练和推理必须保持一致。4. 字符识别网络轻量CNN与ResNet18的对比选择字符识别模型是项目的核心。这一步我同时尝试了“自己搭CNN”和“ResNet18迁移学习”两条路。两者都能收敛但体验差别很大。4.1 自己搭一个两层卷积网络参数怎么定为了把卷积、池化、步长、填充这些概念落到实处我搭了一个非常经典的轻量CNNimport torch.nn as nn import torch.nn.functional as F class SimpleCharCNN(nn.Module): def __init__(self, num_classes): super(SimpleCharCNN, self).__init__() self.conv1 nn.Conv2d(3, 32, kernel_size3, stride1, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, stride1, padding1) self.pool nn.MaxPool2d(kernel_size2, stride2) self.fc1 nn.Linear(64 * 16 * 16, 256) self.fc2 nn.Linear(256, num_classes) self.dropout nn.Dropout(0.5) def forward(self, x): x self.pool(F.relu(self.conv1(x))) x self.pool(F.relu(self.conv2(x))) x x.view(x.size(0), -1) x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x输入是3x64x64的图像经过了两次“卷积池化”特征图从64x64变成16x16。为什么padding要设成1因为kernel_size是3如果padding为0特征图每过一层就会缩小2个像素做两层之后尺寸变化会加剧不如保持特征图尺寸把空间压缩完全交给池化层。全连接层的输入维度算起来也简单64个通道乘以16乘16。这个模型只有几十万参数训练起来非常快。在字符分类这种任务上精度已经很够用。它的最大好处是“每层你都知道它在干嘛”问题排查也容易。如果你想在CPU上快速跑通项目建议先用这个。4.2 ResNet18迁移学习省事但要有前提如果不想自己设计网络想用现成的预训练模型可以这样改import torchvision.models as models def get_resnet18(num_classes): model models.resnet18(pretrainedTrue) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) return model这里有一个关键前提ResNet18默认的输入是224x224三通道图像而上面数据加载用的尺寸是64x64。如果你直接用64x64喂进去虽然不会报错但预训练模型在ImageNet上学到的很多特征在低分辨率下会失效。所以要么把图像Resize到224x224要么把最后一层卷积的输出特征作相应调整。我实际测试下来在数据量只有几千张的情况下迁移学习的收敛速度确实比自己搭CNN更快验证集准确率也更容易过95%。但模型的体积和推理速度差距也很明显。如果项目目的是练手建议两个模型都跑一遍对比一下理解更深。提示使用迁移学习时如果车牌字符是白字可以考虑把预训练模型的输入当作RGB三通道即使原始字符是灰度的也没关系三通道复制后模型仍然能学到有效特征。不要为了“省计算量”强行改成单通道输入。5. 训练过程与调参从loss下降到准确率上90%模型定义完之后训练代码本身不难写但有几个细节很影响最终效果。这一章我直接讲训练时容易踩的坑和调参经验。5.1 训练循环里最常见的三个坑第一个坑是忘记在验证阶段切换model.eval()。如果你的模型里有Dropout或BatchNorm那么训练时和验证时的行为是完全不同的。不切eval()会导致验证准确率忽高忽低。正确写法是def validate(model, val_loader, criterion, device): model.eval() total 0 correct 0 total_loss 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) _, predicted torch.max(outputs, dim1) total labels.size(0) correct (predicted labels).sum().item() total_loss loss.item() return total_loss / len(val_loader), correct / total第二个坑是训练集和验证集的shuffle设置。训练集必须打乱数据否则模型会学到奇怪的样本顺序收敛速度变慢。验证集不需要打乱但如果你在验证集上也做了数据增强那验证结果就完全失真了。我的习惯是训练集做RandomAffine和ColorJitter验证集只做Resize和Normalize。第三个坑是归一化参数不一致。你在训练集上用了Normalize([0.5,0.5,0.5],[0.5,0.5,0.5])到了预测阶段却不做同样的Normalize模型输出会直接变成一团乱码。这种错误特别隐蔽因为程序不会报错但准确率会掉到接近随机水平。训练循环主体可以这样写for epoch in range(epochs): model.train() train_loss 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() val_loss, val_acc validate(model, val_loader, criterion, device) print(fEpoch {epoch1}: train_loss{train_loss:.4f}, val_loss{val_loss:.4f}, val_acc{val_acc:.4f})5.2 数据增强和相似字符处理训练时我使用的优化器是AdamW学习率初始为0.001并配合ReduceLROnPlateauoptimizer torch.optim.AdamW(model.parameters(), lr0.001, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3, verboseTrue )ReduceLROnPlateau会在验证loss连续3个epoch不下降时把学习率减半。这个策略比固定学习率省心很多。如果你发现验证准确率卡在90%左右不动优先检查两件事一是数据增强是否太强导致字符被旋转到不可辨认二是是否存在相似字符混淆比如数字0和字母O、数字1和字母I、数字2和字母Z。对车牌场景很多省份号牌为了规则性不会同时使用这些近似字符但在模型输出时还是要做一个规则过滤如果模型在0和O之间犹豫优先输出数字0。类别不均衡问题在车牌字符里也存在。比如“京”“苏”这类常见省份字符样本很多而一些冷门省份简称可能只有几十张。这会导致模型对冷门字符的泛化能力差。解决方法是给CrossEntropyLoss传一个class_weightclass_weights torch.tensor([...], dtypetorch.float).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)权重可以简单按“总样本数 / (类别数 * 该类样本数)”来计算也可以直接用sklearn的class_weight工具先生成。6. 全流程串联OpenCV定位 投影分割 CNN识别训练好的字符分类器只是“眼睛”完整的车牌识别还需要“手”把字符从原始图片里拿出来。这章讲我把它们串成完整脚本的过程。6.1 车牌定位颜色、边缘、轮廓三管齐下车牌定位我用了OpenCV的颜色过滤加轮廓筛选。对于蓝牌来说蓝色区域是最明显的信号。先把图像从BGR转到HSV空间设定蓝色范围生成掩膜再进行形态学处理最后找到外接矩形。import cv2 import numpy as np def locate_plate(image): hsv cv2.cvtColor(image, cv2.COLOR_BGR2HSV) blue_lower np.array([100, 100, 50]) blue_upper np.array([130, 255, 255]) mask cv2.inRange(hsv, blue_lower, blue_upper) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) best None best_area 0 for cnt in contours: x, y, w, h cv2.boundingRect(cnt) area w * h ratio w / h if 2.0 ratio 4.5 and area best_area: best_area area best (x, y, w, h) return best车牌宽高比一般在2.2到3.5之间蓝牌标准是440x140比例约3.14。这个条件能过滤掉大量不相关的蓝色块。形态学闭运算的作用是把可能断裂的蓝色区域连成一块对提取完整车牌很重要。6.2 字符分割与识别结果还原得到车牌区域后先做预处理和透视矫正再分割字符。国标蓝牌的第一个字符一般是省份简称中文汉字和后面的字母数字在笔画结构上有较大差异所以分割前最好先把车牌区域二值化成白字黑底def preprocess_plate_region(plate_img): gray cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) return binary垂直投影分割是最基础的方法统计每一列白色像素的数量白色的波峰区域很可能是一个字符波谷则是字符间隙。通过设定最小宽度和最大宽度可以在一定程度上过滤掉噪点。def split_characters(binary): col_sums binary.sum(axis0) chars [] in_char False start 0 for i, val in enumerate(col_sums): if val 0 and not in_char: in_char True start i elif val 0 and in_char: in_char False if i - start 10: # 过滤掉太窄的噪声 chars.append((start, i)) return chars分割出的每个小区域先扩边再缩放到64x64然后交给训练好的CNN把输出索引映射回字符。这里要注意训练时的class_to_idx和预测时的idx_to_class必须加载同一个版本否则结果全错。def recognize_chars(model, plate_img, transform, idx_to_class, device): binary preprocess_plate_region(plate_img) char_boxes split_characters(binary) result for (x1, x2) in char_boxes: char_img plate_img[:, x1:x2] pil_img Image.fromarray(cv2.cvtColor(char_img, cv2.COLOR_BGR2RGB)) tensor transform(pil_img).unsqueeze(0).to(device) with torch.no_grad(): logits model(tensor) pred_idx torch.argmax(logits, dim1).item() result idx_to_class[pred_idx] return result我实际测试时单纯靠垂直投影对正拍、平视的照片效果很好但遇到透视畸变就会出错。所以工业项目里的升级方向是先用检测网络找到四角点做透视矫正再做投影分割。那个复杂度就上来了但在“第P10周”这个项目里先把正拍场景跑通更有成就感。7. 实测和翻车复盘哪些地方最容易出错项目做到这一步我拿了大概30张不同环境下的车牌图片去测试整体识别准确率在正拍场景下能到90%以上但依然翻了好几次车。复盘下来主要是三个问题。7.1 边缘、粘连和分布偏差三个典型翻车现场第一个翻车现场是车牌边框和铆钉被当成字符。投影分割时如果车牌左右边框的白色区域和相邻字符连在一起分割结果会多出一个“字符”导致最终输出长度超过7位。这个问题我后来通过在分割前做一次crop把车牌区域左右各裁掉3到5个像素以及设置最小笔画宽度过滤来解决。第二个翻车现场是字符粘连。当车辆稍微倾斜或者摄像头视角较低时字符投影的波谷可能被白色噪点填满导致两个字符被切成一块。处理办法是先做透视矫正或者把二值化后的图像做一次竖直方向的开运算分离轻微粘连。但如果粘连太严重传统方法就很难救回来这也是很多人最终转向检测网络的原因。第三个翻车现场是训练数据分布单一。我一开始用合成数据训练模型白天上坡场景识别效果很好但到了晚上或者强逆光环境模型准确率立刻下滑。合成数据再逼真也无法完全模拟真实摄像头的噪声、反光和运动模糊。最后的补救方法是收集更多真实车牌字符样本加入训练集并增加随机亮度、噪声和模糊增强。这一步对最终泛化能力提升最大。7.2 还能往哪个方向继续做做完这一版之后扩展空间仍然很大。最直接的升级是把车牌定位从传统图像处理换成YOLO等目标检测网络让系统可以适应更复杂的背景和角度。其次是引入端到端识别模型例如LPRNet这种模型直接接收整张车牌图、输出字符串省去字符分割步骤部署更省事。再有就是模型部署可以把训练好的PyTorch模型导出为ONNX格式再转换到NCNN或TensorRT上用在边缘设备或者移动端。目前很多工业项目都开始偏向“PyTorch训练 ONNX部署”这条链路你在做完基础项目之后顺着这个方向走不会错。最后再分享一点个人经验车牌识别这个项目最容易让人挫败的不是模型训练而是“流程里每一环都会犯错”。字符识别模型再强定位偏了、分割错了最终结果都是零。所以建议你调试时不要只盯着准确率而是把每一步的中间结果都存下来看一看。我在项目目录里建了一个debug/文件夹定位出来的车牌、分割出来的字符、每个字符的预测概率都会单独保存翻车的时候一眼就能看出是哪一环出了问题。这种“可视化调错”的习惯放到任何视觉项目里都不会吃亏。
返回列表