
简介这套基于Python与OpenCV开发的车牌识别系统是一份可直接运行的毕业设计项目适合计算机视觉方向的学生以及卷积神经网络入门者参考。系统内部分为车牌定位过滤与字符识别两个部分每个部分均使用相同结构的卷积神经网络完整覆盖图像预处理、车牌轮廓提取、字符分割与识别输出等核心步骤。网络输入层尺寸、卷积核大小与数量、池化窗口等参数配置都在代码中清晰呈现方便学习者对照理解。压缩包共计两千个文件内含大量车牌图片样本、训练好的模型权重、Python源码脚本以及模型配置文件总大小约459MB下载后加载模型即可直接测试无需重新训练。目前已有八千四百零一人学习下载可用于课程设计或实际车牌识别场景。代码同时保留了训练与推理完整流程便于根据需求灵活调整网络结构或扩充数据集是一份实用且易上手的车牌识别参考资料。1. 车牌识别没有想象中那么玄一套OpenCVCNN的落地组合车牌识别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, keylambda 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, axis0) // 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_classes65): super(PlateCNN, self).__init__() self.features nn.Sequential( nn.Conv2d(1, 16, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Linear(32 * 8 * 8, 128), nn.ReLU(inplaceTrue), 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×82048维。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, size32): img Image.new(L, (size, size), 0) draw ImageDraw.Draw(img) font ImageFont.truetype(font_path, 24) bbox draw.textbbox((0, 0), char, fontfont) 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, fill255, fontfont) return np.array(img) def generate_dataset(dst_dir, font_path, samples_per_class3000): os.makedirs(dst_dir, exist_okTrue) all_chars list(PROVINCES) list(CHARS) for c in all_chars: cls_dir os.path.join(dst_dir, c) os.makedirs(cls_dir, exist_okTrue) 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(rootdataset/train, transformtransform) train_loader DataLoader(train_data, batch_size64, shuffleTrue) model PlateCNN(num_classeslen(train_data.classes)) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.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(fepoch {epoch1:02d} loss {avg_loss:.4f}) torch.save(model.state_dict(), fplate_cnn_epoch{epoch1:02d}.pth) # 保存类别顺序推理时按同样顺序加载 with open(classes.txt, w, encodingutf-8) as f: f.write(\n.join(train_data.classes))几个值得说透的参数。学习率0.001配Adam是这类小分类模型稳妥的起点如果训练loss在后期震荡不降降到0.0003再跑10轮。batch size取64CPU也能扛也不用担心显存。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, encodingutf-8) as f: class_list [line.strip() for line in f] model PlateCNN(num_classeslen(class_list)) model.load_state_dict(torch.load(plate_cnn.pth, map_locationcpu)) 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):yh5, max(0, x-5):xw5] 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(dim1).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_locationcpu保证没有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, ksize3) edge_density np.mean(np.abs(sobel) 80) return edge_density 0.05白像素密度和边缘密度两项组合后误报会大幅下降。但阈值别卡太死晚上车牌反光弱时边缘密度本身会掉阈值记得跟着放宽。5.2 光照不均导致整块定位失败现象上午十点的停车场隔着车玻璃拍出来的车牌定位框偏到一边黄昏逆光时干脆什么都找不到。原因HSV里的V分量在强光下爆掉H分量也跟着偏移逆光时车牌反光集中在几个字符上色相严重失真。解决做一次自适应直方图均衡化再进HSV。createCLAHE能限制对比度增强的幅度对车牌这种小目标比全局直方图均衡更温和不会把噪声一起放大。clahe cv2.createCLAHE(clipLimit2.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(keylambda seg: abs((seg[1]-seg[0]) - plate_width/7)) filtered filtered[:7] filtered.sort(keylambda 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_locationcpu) 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(dim1) 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, cmapBlues) plt.xticks(range(len(class_list)), class_list, rotation90) 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的方案优点是每个环节都可观测缺点是环节多、参数组合爆炸养成单变量实验的习惯能少掉一半头发。希望帮到你。本文还有配套的精品资源点击获取