简介:面向计算机相关专业学生及毕业设计、期末大作业场景,这份基于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(key=cv2.contourArea, reverse=True) 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), dtype="float32") s = pts.sum(axis=1) diff = np.diff(pts, axis=1) 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]], dtype="float32") 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(key=lambda 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_ok=True) 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, transform=None): 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", transform=transform) loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=2)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_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), 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_classes=len(dataset.char_to_idx))如果分类结果中数字和字母很准,但汉字混乱,不要急着加深网络。先看看汉字类别样本数量是不是远小于数字类别,如果是,优先做数据平衡或简单复制增强,比如对字符做轻微的平移再放进训练集。
下表是这个网络的尺寸变化:
| 层 | 输出尺寸 | 对应作用 |
|---|---|---|
| 输入灰度图 | 1×32×32 | 单通道文字图像 |
| 第一组卷积池化 | 32×16×16 | 提取点、横竖边缘 |
| 第二组卷积池化 | 64×8×8 | 提取角、笔画组合 |
| 第三组卷积池化 | 128×4×4 | 提取汉字局部结构 |
| 全连接+Dropout | 256 | 防止过拟合的分类特征 |
| 输出层 | 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(), lr=1e-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(f"Epoch {epoch + 1:02d} Loss {total_loss / len(loader):.4f} " f"Acc {correct / total:.4f}")outputs.argmax(1)是取每个样本中得分最高的类别索引。要提醒的是,训完以后不要只保存模型权重,还要把classes_列表保存成json:
import json with open("classes.json", "w", encoding="utf-8") as f: json.dump(dataset.char_to_idx, f, ensure_ascii=False) 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 python=3.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", encoding="utf-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_classes=len(char_to_idx)) model.load_state_dict(torch.load("char_model.pth", map_location="cpu")) 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_names=target_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个百分点更能体现你对整个流水线的完整理解。
本文还有配套的精品资源,点击获取