☰
基于OpenCV与CNN的车牌识别系统实现与优化指南
2026/10/13 1:30:46 网站建设 项目流程

简介:这套基于Python与OpenCV开发的车牌识别系统,是一份可直接运行的毕业设计项目,适合计算机视觉方向的学生以及卷积神经网络入门者参考。系统内部分为车牌定位过滤与字符识别两个部分,每个部分均使用相同结构的卷积神经网络,完整覆盖图像预处理、车牌轮廓提取、字符分割与识别输出等核心步骤。网络输入层尺寸、卷积核大小与数量、池化窗口等参数配置都在代码中清晰呈现,方便学习者对照理解。压缩包共计两千个文件,内含大量车牌图片样本、训练好的模型权重、Python源码脚本以及模型配置文件,总大小约459MB,下载后加载模型即可直接测试,无需重新训练。目前已有八千四百零一人学习下载,可用于课程设计或实际车牌识别场景。代码同时保留了训练与推理完整流程,便于根据需求灵活调整网络结构或扩充数据集,是一份实用且易上手的车牌识别参考资料。

1. 车牌识别没有想象中那么玄:一套OpenCV+CNN的落地组合

车牌识别(License Plate Recognition)在停车场、园区门禁、高速收费里已经是成熟技术,但真到自己动手用Python做一套,很多人的第一反应是直接上YOLO、SSD这类目标检测模型,结果光是打标、训练、调参就把进度拖垮了。我在这条路上来回折腾过几次之后,回到最扎实的方案:OpenCV做车牌定位、字符分割,轻量级卷积神经网络做字符识别。这条链路不依赖GPU,不需要海量标注数据,一台普通笔记本就能把demo跑通,识别率在标准蓝底白字车牌上能做到90%以上。适合刚接触视觉项目的开发者,也适合要把车牌识别快速嵌入现有Python业务系统的工程人员。

2. 识别流程拆解:为什么先做定位再做字符识别

车牌识别最忌讳一上来就试图用一个大模型解决所有问题。把任务拆成定位、分割、识别三段,每一步都能独立验证,出了错也能精准定位到具体环节。

2.1 为什么不用端到端目标检测,而是分三步走

目标检测模型(YOLO、SSD 这类)不是不能用,在车牌识别这个任务上,用检测模型框出车牌区域的效果确实不差,但你需要准备上千张标注了车牌位置的图片,训练或迁移微调一轮,才可能在具体场景里稳定工作。而且端到端方案把问题封装成了一个黑匣子:输入图片、输出字符串,中间哪一步出了问题,你能做的只有换数据、换参数重训,很难有针对性地干预。

分三步的方案在面对固定摄像头场景时有先天优势。监控杆、停车闸机这些位置,车牌在画面里的尺度和角度基本稳定,OpenCV定位的失败模式是可以预判的,每一步都有中间结果可以可视化。比如定位环节画个绿框看看框准没有,分割环节把字符图拼成条带看看有没有切碎,这种可解释性对工程排障非常有价值,也是很多商业车牌识别系统至今保留传统定位加分类器结构的原因。

这套方案不仅算得更快,调试成本也低得多。在CPU上,OpenCV定位加分割一帧大约20到40毫秒,CNN单字符识别不到5毫秒,整条流水线能跑到实时。如果你未来有嵌入式部署的需求,比如树莓派这类低功耗设备,这套方案也比模型检测更容易移植。

2.2 第一步定位:HSV颜色过滤加轮廓筛选

这里放一段核心定位代码,用HSV色域过滤比RGB阈值稳定得多。RGB对光照太敏感,同样的蓝色车牌,正午和傍晚拍出来完全是两个数值范围,HSV把色相和明度分离,色相范围可以跨光照使用。

import cv2 import numpy as np def locate_plate(img): height, width = img.shape[:2] scale = 640 / width img_resized = cv2.resize(img, (640, int(height * scale))) hsv = cv2.cvtColor(img_resized, cv2.COLOR_BGR2HSV) # 蓝牌的H范围大约在100~130之间,要根据实际摄像头微调 lower_blue = np.array([100, 50, 50]) upper_blue = np.array([130, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) # 闭运算填充断裂区域 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 找轮廓并筛选 contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) area = w * h if area < 800: continue aspect = w / h if 2.0 < aspect < 5.0: candidates.append((x, y, w, h)) if not candidates: return None, img_resized x, y, w, h = max(candidates, key=lambda r: r[2] * r[3]) return (x, y, w, h), img_resized

代码逻辑分四段:缩放、颜色过滤、闭运算、轮廓筛选。缩放这一步很关键,不同来源的图片分辨率差异很大,统一到640宽后,后续的形态学核大小和面积阈值才有意义。HSV的H值100~130覆盖标准蓝牌色相,但各品牌摄像头的自动白平衡会让H值漂移,这块参数后面避坑章会展开讲。

闭运算的核取17×5,横向比纵向宽,是为了把字符之间的缝隙填上——车牌字符密集,不处理好容易把整个车牌区域断成几块。轮廓筛选用两个硬条件:面积不小于800像素,宽高比在2.0到5.0之间。标准蓝牌宽高比约3.14,放宽区间是为了容忍摄像头视角倾斜。最后取面积最大的候选框,因为绝大多数场景里画面中最显眼的蓝色矩形就是车牌。

2.3 第二步分割:垂直投影法切出单个字符

定位到车牌后,把车牌区域裁出来,接下来要按字符边界切成七个单字符图。垂直投影法是最常用的做法:把车牌图二值化,统计每一列白色像素数量,字符列的投影明显高于背景列,按这个差异就能切分。

def segment_chars(plate_img): gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (3, 3), 0) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) h, w = binary.shape col_sum = np.sum(binary, axis=0) // 255 threshold = h * 0.05 in_char = False char_cols = [] for i in range(w): if col_sum[i] > threshold: if not in_char: start = i in_char = True else: if in_char: end = i if end - start >= 8: char_cols.append((start, end)) in_char = False if in_char: if w - 1 - start >= 8: char_cols.append((start, w - 1)) # 过宽的区间按中点切开,处理汉字和粘连字符 refined = [] for start, end in char_cols: char_w = end - start if char_w > w * 0.2: mid = (start + end) // 2 refined.append((start, mid)) refined.append((mid, end)) else: refined.append((start, end)) return refined, binary

分割逻辑中,大津法(OTSU)二值化是自适应阈值的经典选择,能根据图像本身的灰度分布自动找到背景和前景的分界,比固定阈值抗光照能力强很多。阈值取图像高度的5%,低于这个值的列视为字符间隙,这个比例在标准车牌上表现稳定。

宽度小于8像素的区间直接忽略,过滤掉边缘噪点和铆钉。最后一步“过宽切开”处理的是汉字分割。汉字笔画复杂、内部有零散空白,投影后宽度常常比数字字母大,超过车牌宽度20%后按中点切两半,让后续识别阶段自行判断合法字符。真实代码里这一步还不够完整,车牌左右边框和铆钉也会混进候选区,需要一个后处理过滤,留到避坑章讲。

2.4 字符归一化与颜色适配:识别前的一致性处理

分割出的字符宽高各不相同,直接送进神经网络没法固定输入维度。常见做法是把每个字符图等比缩放到32×32,然后做一次中心化。这里要注意:不要直接拉伸变形。汉字如果被纵向拉扁,省份简称(比如“冀”“赣”这类密集字)很容易被认错。等比缩放后如果尺寸不足32像素,用黑色填充空白区域,不改变字符比例。

这一步的细节直接决定识别率上限。训练时的字符来自字体渲染,测试时的字符是真实拍摄的扛噪图,两者如果归一化方式不一致,模型很容易在边缘分布上栽跟头。我在项目里固定只用一种预处理顺序:灰度到二值化、投影分割、等比缩放、中心化、转Tensor,训练和推理完全一致,避免出现“训练97%、推理70%”这种玄学级效果差异。

颜色适配方面,如果你的场景里出现新能源绿牌,把HSV阈值改成lower_green = np.array([35, 43, 46])、upper_green = np.array([77, 255, 255])就能覆盖。黄牌和黑牌同理,黄牌的H值在15到35之间,白牌的S值很低,需要额外加灰度阈值判断。我在代码里把颜色参数抽象成一个字典,按车牌颜色切换,比硬编码蓝牌灵活得多。

3. CNN字符识别模型:网络结构、训练数据与参数设定

定位和分割处理的是图像层面的问题,从这一步开始进入卷积神经网络的范畴。车牌字符识别的本质是一个多分类问题,网络不必太深,重点在于数据组织和训练口径要严。

3.1 字符集构成与分类任务定义

先明确分类目标。标准蓝牌第一位是省份汉字,总共31个汉字(京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼渝川贵云藏陕甘青宁新);第二位是发牌机关字母,共24个字母(去掉I和O,因为I像1、O像0);第三到第七位是字母和数字混合,共34个类。所以整个任务是65分类。

这里有个工程细节:把训练类别定死为“省份汉字”和“字母数字”两个独立模型,还是一个统一的65类模型?两种方案都有人用。统一模型的好处是流程简洁,缺点是个别省份汉字的训练样本天然少(比如“藏”“琼”),网络容易欠拟合。分开两个模型后,汉字模型只处理第一位,字母数字模型处理后六位,两类数据都能训练得比较充分。我在实际项目里用的是分离式,多维护一个模型文件,换来的稳定性是值得的。

3.2 网络结构设计:两个卷积层就够用

参考LeNet-5的结构做一个轻量CNN:第一层卷积提取笔画边缘,第二层卷积组合出字符的局部结构,两层池化降低分辨率,最后接两个全连接层输出分类分数。

import torch import torch.nn as nn class PlateCNN(nn.Module): def __init__(self, num_classes=65): super(PlateCNN, self).__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Linear(32 * 8 * 8, 128), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(128, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x

参数量大约22万,运算量非常小。输入是32×32单通道灰度图,两次MaxPool后特征图降到8×8,全连接层输入是32×8×8=2048维。Dropout放在全连接层前,是专门防小数据量训练过拟合的。如果你有GPU,可以加大通道数到32和64,识别率会小幅提升,但CPU推理时间会翻倍。

3.3 训练数据:合成数据加真实数据混合

训练数据这块最容易翻车。纯合成数据训练出来的模型,在真实摄像头照片上识别率会掉十几个点,主要原因是真实环境里的模糊、透视、光照不均、二值化噪声,合成数据很难完全模拟。我的做法是:以字体渲染合成数据为主体,保证每个类别数量充足,再用一两百张真实车牌切割出的字符做增量微调。

from PIL import Image, ImageDraw, ImageFont import numpy as np import cv2 import os import random PROVINCES = "京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼" CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" def generate_char_image(char, font_path, size=32): img = Image.new('L', (size, size), 0) draw = ImageDraw.Draw(img) font = ImageFont.truetype(font_path, 24) bbox = draw.textbbox((0, 0), char, font=font) w = bbox[2] - bbox[0] h = bbox[3] - bbox[1] x = (size - w) // 2 - bbox[0] y = (size - h) // 2 - bbox[1] draw.text((x, y), char, fill=255, font=font) return np.array(img) def generate_dataset(dst_dir, font_path, samples_per_class=3000): os.makedirs(dst_dir, exist_ok=True) all_chars = list(PROVINCES) + list(CHARS) for c in all_chars: cls_dir = os.path.join(dst_dir, c) os.makedirs(cls_dir, exist_ok=True) for i in range(samples_per_class): img = generate_char_image(c, font_path) # 随机轻微模糊模拟真实拍摄 img = cv2.GaussianBlur(img, (3, 3), 0.8) # 随机平移制造扰动 m = np.float32([[1, 0, random.randint(-1, 1)], [0, 1, random.randint(-1, 1)]]) img = cv2.warpAffine(img, m, (32, 32)) cv2.imwrite(os.path.join(cls_dir, f'{i:04d}.jpg'), img)

生成逻辑不复杂:用PIL在纯黑背景上绘制白色字符,居中,然后加高斯模糊和随机平移,模拟真实拍摄的抖动和失焦。省份汉字生成时,建议选一款笔画规整的字体,比如思源黑体或文泉驿正黑,避免带强烈装饰衬线的字体,那会和车牌字体差太远。

3.4 训练配置与参数口径

训练脚本和参数是可直接抄作业的部分。数据加载用torchvision的ImageFolder,它会按子目录名自动生成类别索引,注意这个类别顺序决定了推理时类别索引的映射,建议训练完把类别列表单独保存一份。

from torch.utils.data import DataLoader from torchvision import transforms, datasets transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_data = datasets.ImageFolder(root='dataset/train', transform=transform) train_loader = DataLoader(train_data, batch_size=64, shuffle=True) model = PlateCNN(num_classes=len(train_data.classes)) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) for epoch in range(20): model.train() running_loss = 0.0 for imgs, labels in train_loader: optimizer.zero_grad() out = model(imgs) loss = criterion(out, labels) loss.backward() optimizer.step() running_loss += loss.item() avg_loss = running_loss / len(train_loader) print(f'epoch {epoch+1:02d} loss {avg_loss:.4f}') torch.save(model.state_dict(), f'plate_cnn_epoch{epoch+1:02d}.pth') # 保存类别顺序,推理时按同样顺序加载 with open('classes.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(train_data.classes))

几个值得说透的参数。学习率0.001配Adam,是这类小分类模型稳妥的起点;如果训练loss在后期震荡不降,降到0.0003再跑10轮。batch size取64,CPU也能扛,也不用担心显存。epoch设20是参考值,合成数据量大时模型在5到8轮就收敛,后面基本是惯性下降。能观察到验证准确率不升反降,就是过拟合了,回调Dropout比例到0.6或增加数据扰动更有效。

提示:不要一上来用0.01的学习率。车牌字符分类任务虽然简单,但不同汉字在32×32分辨率下视觉差异很小,学习率太大会导致loss先降后升,白白浪费大量训练时间。

判断模型有没有训好,不要只看总数准确率,要看每个类别的recall。省份汉字里“皖”“赣”“鄂”这些笔画密集的字,经常出现个别类别识别率只有70%的情况。解决办法是计算混淆矩阵,把错得最多的类别挑出来,单独补充该字体的合成样本。如何打印和分析混淆矩阵,就是第6章的内容。

4. 完整可运行代码:从照片输入到车牌字符串输出

前几章把三个模块分别讲清楚了,这一章把它们统一成一条可直接运行的流水线。实践顺序是:先把依赖装好,再逐个文件跑通,最后把单张照片或摄像头画面喂进去看输出。

4.1 环境依赖:最小化安装清单与两个经典报错

先说依赖。用到的库只有四个:opencv-python、numpy、torch、torchvision。PIL其实也会用到,但它会跟着torchvision一起装好,不用单独管。

pip install opencv-python numpy torch torchvision

安装时最容易遇到两个坑。一是pip默认源在国外,下载torch这种大包经常超时,建议指定国内镜像源:

pip install opencv-python numpy torch torchvision -i https://pypi.tuna.tsinghua.edu.cn/simple

二是conda环境装OpenCV后导入报错,常见的是ModuleNotFoundError: No module named 'cv2'。这种问题九成是装到了别的Python环境,先在当前终端里跑python -c "import cv2; print(cv2.__version__)"确认解释器环境里有没有cv2,再换到对应环境的终端重新pip install一次,通常就能解决。

4.2 主控脚本:串联定位、分割、识别

把locate_plate、segment_chars、PlateCNN三个模块串起来的主控脚本是这套系统的门面。字符识别的类别顺序必须和训练时完全一致,前一步保存的classes.txt在这里派上用场。网络定义建议单独放到model.py,让主控脚本保持干净。

import cv2 import numpy as np import torch from model import PlateCNN # 把第3章的网络类存为model.py # 读取训练时保存的类别顺序 with open('classes.txt', 'r', encoding='utf-8') as f: class_list = [line.strip() for line in f] model = PlateCNN(num_classes=len(class_list)) model.load_state_dict(torch.load('plate_cnn.pth', map_location='cpu')) model.eval() def preprocess_char(char_img): char_img = cv2.resize(char_img, (32, 32)) # 和训练时完全一致的归一化方式 char_img = (char_img.astype(np.float32) / 255.0 - 0.5) / 0.5 return torch.from_numpy(char_img).unsqueeze(0).unsqueeze(0) def predict_plate(img_path): img = cv2.imread(img_path) if img is None: print(f'无法读取图片: {img_path}') return None box, img_resized = locate_plate(img) if box is None: print('未定位到车牌区域') return None x, y, w, h = box # 向外扩几个像素,避免车牌边缘字符被切掉 plate_img = img_resized[max(0, y-5):y+h+5, max(0, x-5):x+w+5] cv2.imwrite('debug_plate.jpg', plate_img) chars, binary = segment_chars(plate_img) if len(chars) < 7: print(f'分割出{len(chars)}个字符,小于7,疑似定位框不准') return None result = '' with torch.no_grad(): for start, end in chars: char_img = binary[:, start:end] tensor = preprocess_char(char_img) out = model(tensor) pred = out.argmax(dim=1).item() result += class_list[pred] return result if __name__ == '__main__': plate = predict_plate('test.jpg') print('识别结果:', plate)

主控脚本的核心是preprocess_char里的归一化,训练时用(x/255-0.5)/0.5,推理必须一模一样,少一步结果都会飘。预测整个流程跑在torch.no_grad()下,不保存梯度,速度能差好几倍。map_location='cpu'保证没有GPU的机器也能加载权重。

4.3 实时摄像头识别:守卫循环写法

如果你要跑实时视频流,把单张图片改成循环读取摄像头帧就行。按q键退出,按s键保存当前帧方便排查。

cap = cv2.VideoCapture(0) if not cap.isOpened(): print('摄像头打开失败') exit() # 限制输入分辨率,避免每帧处理过慢 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 960) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 540) while True: ret, frame = cap.read() if not ret: break box, frame_resized = locate_plate(frame) if box is not None: x, y, w, h = box cv2.rectangle(frame_resized, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.imshow('plate', frame_resized) key = cv2.waitKey(1) & 0xFF if key == ord('q'): break elif key == ord('s'): cv2.imwrite('capture_frame.jpg', frame_resized) print('帧已保存') cap.release() cv2.destroyAllWindows()

这条循环里有两个工程细节。一是摄像头分辨率默认可能很高,每帧都跑全分辨率定位会明显卡顿,开头限制到960宽,细腻度对车牌识别足够。二是定位函数内部已经缩放到640宽,但如果源头分辨率太高,OpenCV的resize本身也会消耗时间,在摄像头端限制分辨率更合理。

5. 避坑指南:车牌识别最常见的5个翻车点

做车牌识别项目,真正让人崩溃的往往不是模型训练,而是那些看似简单却反复出现的细节。下面五条都是我自己踩过的坑,按现象到原因到解决的顺序写。

5.1 蓝色物体误报:把车身广告、蓝色货柜当车牌

现象:定位框画在蓝色卡车车头上,或者画在蓝色广告牌上,识别结果输出一串乱码。

原因:HSV颜色过滤只认颜色和宽高比,蓝色卡车头、蓝色雨棚布完全符合条件。特征没约束住,几何和颜色之外没有做内容验证。

解决:加一道内容验证。车牌区域内部应该有大量字符边缘,二值化后白色像素密度通常在15%到40%之间,低于这个范围直接排除。再用边缘密度做二次确认,车牌区域在Sobel边缘图上响应很强,这是纯色车漆没有的。

def is_plate_region(plate_img): gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) sobel = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) edge_density = np.mean(np.abs(sobel) > 80) return edge_density > 0.05

白像素密度和边缘密度两项组合后,误报会大幅下降。但阈值别卡太死,晚上车牌反光弱时边缘密度本身会掉,阈值记得跟着放宽。

5.2 光照不均导致整块定位失败

现象:上午十点的停车场,隔着车玻璃拍出来的车牌定位框偏到一边;黄昏逆光时干脆什么都找不到。

原因:HSV里的V分量在强光下爆掉,H分量也跟着偏移;逆光时车牌反光集中在几个字符上,色相严重失真。

解决:做一次自适应直方图均衡化再进HSV。createCLAHE能限制对比度增强的幅度,对车牌这种小目标比全局直方图均衡更温和,不会把噪声一起放大。

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray = cv2.cvtColor(img_resized, cv2.COLOR_BGR2GRAY) equalized = clahe.apply(gray) img_equalized = cv2.cvtColor(equalized, cv2.COLOR_GRAY2BGR) hsv = cv2.cvtColor(img_equalized, cv2.COLOR_BGR2HSV)

把这一步放在定位之前,很多光照恶劣的场景就能顶过去。如果还不行,就按时间段切换HSV范围:正午的H值偏小、黄昏偏大,建立两套参数实测取折中。

5.3 汉字分割失败:要么切碎要么黏住

现象:分割结果里“川”字被切成两半,“冀”字黏在一起,或者把车牌左右边框当成字符。

原因:汉字笔画复杂,内部笔画导致垂直投影在一个字中间出现低谷,直接把字符切断了;密集汉字笔画间空隙太小,投影段又连到一起;铆钉和边框的特征和字符相似。

解决:组合三个手段。第一,分割前做一次开运算(3×1的核)去掉单像素噪点。第二,按车牌宽度约束字符数量和位置,标准蓝牌是7个字符,第1位汉字宽、第2位字母窄、第3到7位均匀分布,用这个先验过滤投影段。第三,宽度下限设为车牌宽度的5%,把窄的边框和铆钉排除。

def filter_chars(char_cols, plate_width): filtered = [] for start, end in char_cols: w = end - start if w < plate_width * 0.05: continue # 太窄,多半是铆钉或边框 if w > plate_width * 0.25: continue # 太宽大概率不是单字符,交给后续逻辑 filtered.append((start, end)) if len(filtered) > 7: # 数量超出时按“字符宽度越接近均值越可信”保留7段 filtered.sort(key=lambda seg: abs((seg[1]-seg[0]) - plate_width/7)) filtered = filtered[:7] filtered.sort(key=lambda seg: seg[0]) return filtered

宽度过滤加数量约束后,分割稳定性提升明显。但要注意新能源绿牌是8位字符(比蓝牌多一位),按7位硬编码会翻车。正确做法是先判断车牌颜色,再决定期望字符数,蓝牌7位、绿牌8位,黄牌和黑牌按白字还是黑字区分。

5.4 省份汉字识别率明显低于数字字母

现象:整体识别率92%,但“皖”“赣”“鄂”“琼”这类字单独看只有70%出头,错的基本都是混到形状相近的字上。

原因:各省份车牌汉字使用频率不均,真实场景里“京”“粤”“苏”出现次数远高于“藏”“琼”,如果没有主动做数据均衡,模型对低频汉字的特征学习不充分。另外这些字笔画密集,32×32分辨率下笔画边缘粘连严重。

解决:训练数据里对每个汉字类别单独保证数量,不能按真实分布采样。合成阶段每个汉字各生成3000张,微调阶段也要手动确保低频汉字有足够样本。识别时如果连续多位结果可疑,结合车牌结构先验做纠错:第一位必须是31个汉字之一,第二位必须是24个字母之一,非法组合直接标注为低置信度,不要让错误结果混进输出。

5.5 模型文件加载报错:key不匹配

现象:训练时一切正常,一跑推理就报Missing key(s) in state_dict,或者size mismatch for classifier.4.weight。

原因:有两种可能。一是网络定义改动过(比如num_classes从65改成31),但加载的权重还是旧结构;二是训练时用了DataParallel,保存的权重带module.前缀,推理时单卡加载不上。

解决:加载时做一次状态字典清洗,把module.前缀剥掉再加载。

state = torch.load('plate_cnn.pth', map_location='cpu') new_state = {} for k, v in state.items(): if k.startswith('module.'): new_state[k[len('module.'):]] = v model.load_state_dict(new_state)

这个坑几乎每个PyTorch项目都会遇到一次,提前把清洗逻辑写成函数能省不少时间。改动网络结构后,最稳妥的办法是重新全量训练,别赌旧权重能迁移。

6. 识别率怎么验证:自制测试集与混淆矩阵分析

把模型和流水线跑通只是开始,真正要说服自己这套方案能用,必须有客观的评估数据。我的习惯是单独留出两百张不在训练集里的真实照片,涵盖白天、傍晚、逆光、不同拍摄距离,组成一个固定测试集。每次改完参数,用同一套测试集跑一遍记录准确率变化。不要拿训练集里的图反复验证,那只是自我感动。

评估工具只看两个:分类准确率和混淆矩阵。准确率是总分,混淆矩阵负责定位问题。代码不复杂,但能从矩阵里读出哪个字被认成了哪个字:

from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt import torch def evaluate(model, loader, class_list): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for imgs, labels in loader: out = model(imgs) preds = out.argmax(dim=1) all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) cm = confusion_matrix(all_labels, all_preds) # 打印每一类的recall,找出拖后腿的类别 for i, cls in enumerate(class_list): recall = cm[i, i] / (cm[i].sum() + 1e-6) if recall < 0.85: print(f'{cls}: recall {recall:.3f}') plt.figure(figsize=(16, 12)) plt.imshow(cm, cmap='Blues') plt.xticks(range(len(class_list)), class_list, rotation=90) plt.yticks(range(len(class_list)), class_list) plt.tight_layout() plt.savefig('confusion_matrix.png')

看到热力图后,处理思路就很清晰:哪一类混淆严重,就回去补哪一类的训练样本,或者是这个字符本身在32×32下确实难分,比如“0”和“O”如果同时出现在字符集里,几乎必然混。这也是我在第3章坚持去掉字母I和O的原因,在字符集定义阶段就把这种区分度极低的组合先排除掉。

另一个很实用的习惯是保存所有识别失败的样例图,按预测类别分类归档。每周翻一次“错题本”,你会发现很多失败集中在某个特定的拍摄角度或某个字体的再现。针对性地增加那类真实样本进微调集,比盲目增大整体数据量更划算。

最后说个血泪经验:每轮改动只动一个变量。改HSV阈值就只改HSV阈值,测完再改分割参数,同时动多个变量出了问题根本说不清是哪个环节的锅。这套OpenCV加CNN的方案,优点是每个环节都可观测,缺点是环节多、参数组合爆炸,养成单变量实验的习惯能少掉一半头发。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询