ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python+OpenCV双CNN车牌识别:定位、字符分割与识别全流程

Python+OpenCV双CNN车牌识别:定位、字符分割与识别全流程

简介:车牌识别系统详细讲解PPT面向计算机视觉入门者与本科毕业设计选题学生,围绕Python结合OpenCV实现后台图片车牌识别的完整流程展开。内容按车牌定位过滤与字符识别两部分组织:前者讲解灰度化、HSV色彩分割、Sobel边缘检测、最大类间方差二值化、闭运算连边、轮廓外接矩形筛选与漫水填充补全;后者覆盖字符分割及两个同构CNN的识别与过滤,并给出36×128输入、3×3卷积核、32/64/128通道、ReLU与2×2最大池化、三层全连接等参数细节。包内仅含1个pptx文件,压缩包约8.57MB,页面按选题背景、方案原理、结果与界面展示、研究总结与难点等模块编排,以流程图和界面截图辅助讲解,适合课堂汇报或答辩时直接参考。该PPT已有4789人学习,可帮助读者梳理传统图像处理与CNN结合的技术路线,理解中国车牌多底色、含汉字带来的识别难点,并据此搭建自己的毕业设计框架与实验方案。

1. 从一张歪斜的蓝色车牌说起:这套方案要解决什么

停车场入口的相机拍下来的照片,车牌倾斜了十几度,背景还有一块黄色广告牌——这是车牌识别系统最不想碰到的输入。做深度学习方向的毕设,很多人第一反应是直接上端到端检测加序列识别的大模型,可真要在几周内跑通,还要在答辩现场逐层讲清楚每个参数为什么这么设,我更倾向于另一条更可控的路:用 Python 加 OpenCV 搭一条图像处理流水线,在两个关键判断点上各放一个结构相同的卷积神经网络。整套车牌识别系统切成两段,前一段做车牌定位,靠色彩分割和边缘检测把候选区域压到个位数;后一段做字符识别,靠投影法切字符、再逐字分类。它适合要交毕设或课设、需要完整讲清原理与实现的人,也适合想把识别流程拆开单独调优的开发者。

2. 需求拆解与技术选型:两段式流水线为什么比端到端更适合交付

2.1 需求边界:定位和识别的分界线画在哪

把系统切成两段不是偷懒,是让两类完全不同的错误分开暴露。定位阶段的错误表现为"框错地方"——框到了广告牌、车身贴纸、远处另一辆车;识别阶段的错误表现为"字认错了"——把 Q 认成 0、把 D 认成 0、把某个省份简称认成相邻省份。这两类错误的排查手段完全不同:前者要去看掩模图、轮廓长宽比、闭运算核尺寸;后者要去看字符切分是否对齐、训练样本里这个字有没有足够变体。混在一个端到端网络里,出一个错你连该改哪一层都不知道,这就是典型的黑匣子。

输入输出的契约先定死。输入是一张 BGR 三通道图片,尺寸不限,来源可以是本地文件、也可以是后台推过来的相机帧;输出是一条结构化结果,包含车牌字符串、每个字符的分类置信度、车牌在原图中的外接矩形坐标。中间还要落一份可选产物:定位阶段的候选框列表和掩模图,用于失败时回看。这份契约定下来之后,模块之间就能并行开发,做界面的同学不用等识别模型训完。

国内车牌的实际复杂度得提前摊开讲。底色有蓝、黄、绿、白四种,首字符是省份简称汉字,后面跟字母与数字的组合,新能源绿牌是八位。汉字这一位是最容易被低估的地方:英文车牌识别里 36 个类就够,加进汉字之后类别数接近翻倍,而且汉字笔画密集,在低分辨率下极容易糊成一团。这个项目把定位和识别都交给 CNN,就是为了让网络自己去学这些笔画差异,而不是靠人工设计特征去硬编码。

功能需求按优先级排下来大致是这样:主链路必须支持本地图片导入并输出车牌字符串;必须覆盖蓝、黄、绿三种常见底色;界面上要能看到原图、定位框和识别结果三段信息;扩展项包括调用相机实时取帧、批量跑测试集出统计报表。把主链路和扩展项分开,验收的时候就不会因为相机驱动没装好而整场演示卡住。

2.2 三条路线的取舍:纯传统算法、双 CNN 流水线、端到端模型

这三条路我都试过或见过别人做,差别不在先进与否,在于交付成本。纯传统算法那一套全部用 OpenCV 的形态学和轮廓函数堆出来,不训练任何模型,好处是跑得快、代码短;坏处是阈值全靠人肉调,换个光照条件就得重调一遍,这种反复调参说不清道理,答辩时被追问"这个阈值为什么是 85 不是 90"很难接住。端到端模型那一套用一个检测网络出车牌框、再接一个序列识别网络直接输出整串字符,精度上限高,但对标注数据量的要求也不是一个课设能凑齐的,训练一次几小时,改一版结构再等几小时,时间成本压不住。

我选的是中间那条:传统图像处理负责把候选区域从整张图缩到几个,CNN 负责在这几个候选里做最终判定和字符分类。这样传统部分承担的是"粗筛",允许它有漏有误,因为后面的网络会把错误候选剔掉;CNN 部分承担的是"精判",输入已经被裁到固定尺寸,样本分布干净,收敛快。两边各干自己擅长的事,这也是我把两个网络设计成相同结构的原因——同一套代码、同一份训练脚本、同一套调参经验可以复用两遍,工程上省下来的时间比精度上多榨那零点几个百分点值钱得多。

维度纯传统算法双 CNN 流水线(本方案)端到端检测识别
训练数据需求无中等,定位千级、字符每类百级高,需大量带框带串标注
光照鲁棒性差,需反复调阈值中上,网络可吸收部分干扰好
单帧耗时最快中等,两次前向推理较慢
失败可解释性强强,分段定位弱
答辩讲解难度低但难自圆其说适中,每步都有依据高,结构复杂讲不完
改一版的成本改参数即可改网络或改预处理重训全流程

2.3 运行环境与数据准备清单

环境不需要多豪华,一台有独立显卡的笔记本足够,没有显卡用 CPU 训小网络也能等得起,只是训练轮次要压缩。依赖清单按用途分组列在下面,版本以各自稳定版为准,装的时候优先保证 OpenCV 和深度学习框架的版本互相兼容,这两个是主要矛盾。

组件用途备注
Python 3主语言虚拟环境隔离依赖
OpenCV图像预处理、轮廓、漫水填充、仿射变换核心,几乎每步都用到
NumPy掩模矩阵运算、投影统计与 OpenCV 配合
深度学习框架(Keras 风格接口)构建与训练两个 CNN顺序模型即可满足
Pillow界面里把数组转成可显示图像与 GUI 库搭配
Tkinter 或 PyQt图形界面前者系统自带,上手快

数据准备分三份,别混在一起。第一份是定位网络的训练集,正样本是从各种场景裁出来的车牌小图,负样本是从同一批原图里随机裁的等尺寸非车牌区域,正负比例控制在 1:2 左右,负样本要故意包含广告牌、蓝色车身、黄底招牌这些"长得像"的区域,网络才学得会分辨。第二份是字符识别训练集,按类别建文件夹,每个字符一个目录,每个类至少准备一两百张,同一个人写的、同一块车牌裁出来的样本要打散到训练集和验证集,不能整块车牌全进训练集。第三份是端到端测试集,专门留出来不参与训练,用不同时间、不同地点拍的图,这份数据决定你答辩时报出来的数字可信不可信。

2.4 总体流水线与工程目录结构

整条链路我拆成五步:图像输入、图像预处理、车牌定位、字符分割、字符识别。前四步是传统处理,第三步和第五步各嵌一个 CNN。每一步的输出都落成明确的中间产物,写文件也好、放在内存里传也好,关键是能单独拎出来看。调试的时候哪一步出问题一目了然,不用在整条链路上撒网式打日志。

plate_recognition/ ├── data/ │ ├── plate_cls/ # 定位网络数据:positive/ negative/ 两个子目录 │ └── char_cls/ # 字符网络数据:按类别名建子目录 ├── models/ │ ├── plate_cls.h5 # 定位网络权重 │ └── char_cls.h5 # 识别网络权重 ├── core/ │ ├── preprocess.py # 灰度、HSV、Sobel、Otsu │ ├── locate.py # 轮廓筛选、漫水填充、仿射校正 │ ├── segment.py # 水平/垂直投影切字符 │ └── recognize.py # 字符分类与前向推理 ├── train/ │ ├── train_plate.py # 定位网络训练脚本 │ └── train_char.py # 识别网络训练脚本 ├── ui/ │ └── app.py # 600x600 主界面 └── main.py # 命令行入口,便于批量测试

这个划分方式有个好处:core下面四个文件互相之间只有数据依赖,没有调用依赖,可以单独拿某一个文件写测试。比如只想验证色彩分割的效果,直接调preprocess.py把掩模存成图片看,不用跑后面的网络。训练脚本和推理脚本也分开,模型训完存成权重文件,界面启动时只加载不重训,答辩演示不会因为误触而卡在训练上。

中间产物的尺寸约定要提前统一。原图保持原样,不做全局缩放;定位阶段裁剪出的每个候选框统一缩放到 36×128 再送进定位网络;字符分割出来的单个字符也统一缩放到 36×128 送进识别网络。字符被横向拉长看着别扭,但这么做是为了复用同一套网络结构,字符的宽高比信息在分类阶段并不关键,笔画拓扑才是关键,这个取舍我后面还会提到。

3. 车牌定位实现:HSV 色彩分割、Sobel 边缘与闭运算的联合过滤

3.1 预处理的四步走:灰度化、HSV 取色、Sobel 取边、Otsu 二值化

预处理的目标不是把图变好看,是把"可能是车牌"的区域从整张图里凸出来。四步的顺序有讲究:先降维减数据量,再按颜色粗筛,再按纹理细筛,最后二值化定形。颜色和纹理两个掩模取交集,能同时压掉纯色背景和纯纹理背景两类干扰。

import cv2 import numpy as np def preprocess(img_bgr): """输入 BGR 原图,输出候选区域的二值掩模""" # 第一步:灰度化,三通道降到单通道,后续 Sobel 和阈值都只在单通道上算 gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) # 第二步:转 HSV 取蓝、黄两种底色。H 通道对颜色最敏感, # 比在 RGB 上分别卡三个通道稳定得多 hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) blue = cv2.inRange(hsv, np.array([100, 70, 70]), np.array([124, 255, 255])) yellow = cv2.inRange(hsv, np.array([15, 70, 70]), np.array([34, 255, 255])) color_mask = cv2.bitwise_or(blue, yellow) # 第三步:Sobel 取垂直边缘。车牌上字符笔画多为竖直方向, # 对 x 方向求梯度能把字符边缘和车牌左右边界都点亮 sobel_x = cv2.Sobel(gray, cv2.CV_16S, 1, 0, ksize=3) sobel_x = cv2.convertScaleAbs(sobel_x) # 第四步:对边缘响应图做 Otsu 二值化,自动找分割阈值 _, edge_mask = cv2.threshold( sobel_x, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) # 颜色与边缘取交集:既是蓝/黄,又有密集竖边的区域才留下 return cv2.bitwise_and(color_mask, edge_mask)

这段代码里有两个参数组值得展开。HSV 的下限[100, 70, 70]和[15, 70, 70]中,后两个数是饱和度和明度的下限,压到 70 是为了滤掉灰白色和浅色背景——白车身上的反光虽然色相可能落在蓝色区间,但饱和度很低,会被这道门槛挡掉。如果你的测试图偏暗,把明度下限降到 50 左右能捞回一些漏检,代价是噪声变多。Sobel 的ksize=3是标准 3×3 核,够用且快;图分辨率特别高的时候可以升到 5,但收益不明显。

还有一点容易忽略:convertScaleAbs这步不能省。Sobel 在 16 位有符号整型上算,负梯度直接截断会丢掉一半边缘,取绝对值再转回 8 位才能让正负边缘都被保留下来。这个细节在教材里常常一笔带过,实际写的时候忘了就是边缘只剩一半,闭运算怎么连都连不成块。

3.2 Otsu 的类间方差推到极值,与闭运算核尺寸的确定

最大类间方差法的思路很朴素:把灰度值从 0 到 255 逐个当阈值试一遍,每次把像素分成前景和背景两类,算这两类的类间方差,方差最大的那个阈值就是最佳阈值。类间方差的定义是两类均值之差的平方乘以两类各自的像素占比,占比越均衡、均值差越大,方差就越大。工程上不用自己写循环,OpenCV 的THRESH_OTSU标志已经把这段穷举封进去了,调用时把阈值参数填 0 占位即可。

闭运算的位置在这条链路里是关键一步。做完交集之后,车牌区域通常是一堆断断续续的竖条,字符之间有间隙、车牌边框也可能断,直接找轮廓会得到几十上百个小碎块。闭运算是先膨胀后腐蚀,膨胀把邻近的亮块粘在一起,腐蚀再把整体尺寸收回去,净效果是填平小孔、弥合裂缝而总位置基本不变。

# 闭运算:横向结构元把竖直边缘连成整块 # 车牌在图像里是扁长条,横向需要连的间距大,纵向小 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(candidate_mask, cv2.MORPH_CLOSE, kernel, iterations=2)

结构元尺寸 (17, 5) 不是随便写的。横向 17 对应字符之间的最大间隙,字符间距一般在十几个像素量级,核宽小于这个值就连不上;纵向 5 是因为车牌高度方向上本来就连贯,核太高会把上下相邻的非车牌区域一起粘进来。iterations=2是跑两遍闭运算,对断得更厉害的区域补一刀。这个参数最容易翻车的地方是核给太大,一次把车牌和旁边的车身装饰条连成一大片,后面按长宽比筛选时反而把整块都筛掉了——宁可小一点多迭代几次,也别一次给太猛。

3.3 轮廓筛选与漫水填充:把几百个候选压到个位数

闭运算之后找外轮廓,每个轮廓求外接矩形,然后按三个条件筛:高度下限、宽度下限、长宽比区间。国内单层蓝牌和黄牌的尺寸约 440×140,宽高比约 3.14;新能源绿牌 480×140,约 3.43;双层黄牌接近 2.0。综合下来宽高比放 2.2 到 4.0 比较宽松,既能捞回透视变形导致的偏差,又不至于放进太多干扰。

def filter_contours(mask): """按尺寸和长宽比筛轮廓,返回候选框列表""" contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if w < 45 or h < 15: # 太小的直接丢,多为噪点 continue ratio = w / float(h) if not (2.2 <= ratio <= 4.0): # 不符合车牌形制的丢掉 continue boxes.append((x, y, w, h)) return boxes

筛完之后通常还剩几个到十几个框,这时候上漫水填充。它在预处理阶段没补全的轮廓这里能补回来,同时还能再剔一批假的。做法是在候选框内部找种子点,种子点的颜色必须是蓝色或黄色,从种子点开始向四周扩散填充,填充后看掩模的覆盖面积占比,占比太低说明这个框里大部分不是车牌底色,直接判掉。

def flood_fill_check(img_bgr, box): """在候选框内做漫水填充,返回补全后的掩模与面积占比""" x, y, w, h = box roi = img_bgr[y:y + h, x:x + w] mask = np.zeros((h + 2, w + 2), np.uint8) # 种子点取框中心,loDiff/upDiff 给 20 容忍同色系渐变 cv2.floodFill(roi, mask, (w // 2, h // 2), (0, 255, 0), loDiff=(20, 20, 20), upDiff=(20, 20, 20), flags=8 | cv2.FLOODFILL_FIXED_RANGE) filled = mask[1:-1, 1:-1] return filled, filled.sum() / float(w * h)

面积占比这个指标很好用。真车牌的底色是连续的大色块,填充后占比通常超过一半;广告牌、车身局部这些区域虽然颜色接近,但形状不规则或被别的颜色割裂,填充后占比明显偏低。阈值给 0.5 左右,结合长宽比一起用,候选数量能压到个位数。

最后一步是仿射校正。定位到的车牌可能带倾斜,直接用会拉低后面的识别率。做法是取框的四个角点,映射到一个标准矩形上,用cv2.getPerspectiveTransform求变换矩阵,再用cv2.warpAffine或cv2.warpPerspective做变换。

def deskew(img_bgr, box, out_size=(128, 36)): """按候选框做透视校正,输出统一尺寸的牌照图""" x, y, w, h = box src = np.float32([[x, y], [x + w, y], [x, y + h], [x + w, y + h]]) dst = np.float32([[0, 0], [out_size[0], 0], [0, out_size[1]], [out_size[0], out_size[1]]]) mat = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(img_bgr, mat, out_size)

校正之后每个候选都变成 128×36 的规整小图,这一批图就是定位网络的输入。注意这里out_size的写法是 (宽, 高),而网络输入写的是 36×128(高×宽),两者顺序相反,写代码的时候这一处极容易搞混,混了之后网络看到的是一张横竖颠倒的图,训练损失降不下去还查不出原因,属于典型的一行代码卡两天的坑。

3.4 定位 CNN:36×128 输入的三卷积二分类过滤器

定位网络干的事就一件:给每个候选框打一个二分类标签,车牌区域还是非车牌区域,取概率大的那一类。结构上三层卷积三层池化三层全连接,最后 softmax 输出两维。

层配置说明
输入36×128×3校正后的候选小图
卷积 13×3,32 核,步长 1,SAME,ReLU提取低层边缘纹理
池化 12×2 最大池化,步长 1只在空间维池化
卷积 23×3,64 核,步长 1,SAME,ReLU组合成笔画级特征
池化 22×2 最大池化,步长 1同上
卷积 33×3,128 核,步长 1,SAME,ReLU字符排布级特征
池化 32×2 最大池化,步长 1同上
全连接 1-3各 1024 神经元,ReLU特征整合
输出2 神经元,softmax车牌 / 非车牌
from tensorflow.keras import layers, models, optimizers def build_classifier(num_classes): """构造定位网络与识别网络共用的结构""" model = models.Sequential([ layers.Input(shape=(36, 128, 3)), layers.Conv2D(32, (3, 3), strides=1, padding='same', activation='relu'), layers.MaxPooling2D((2, 2), strides=1), layers.Conv2D(64, (3, 3), strides=1, padding='same', activation='relu'), layers.MaxPooling2D((2, 2), strides=1), layers.Conv2D(128, (3, 3), strides=1, padding='same', activation='relu'), layers.MaxPooling2D((2, 2), strides=1), layers.Flatten(), layers.Dense(1024, activation='relu'), layers.Dense(1024, activation='relu'), layers.Dense(1024, activation='relu'), layers.Dense(num_classes, activation='softmax'), ]) model.compile( optimizer=optimizers.Adam(learning_rate=0.001), loss='categorical_crossentropy', metrics=['accuracy']) return model

池化步长设成 1 是原文明确的做法,目的是不想在 batch 和通道维度上做池化。默认的池化窗口只作用在空间维,步长设 1 的话每次只缩小一个像素,三次池化下来特征图尺寸从 36×128 降到 30×122,空间信息保留得比较完整。代价是展开后接 1024 全连接这一层参数量会非常大,训练时显存吃紧的话有两种改法:把池化步长改成 2 让尺寸快速降下来,或者在卷积后面接一个全局平均池化直接压成一维向量。我一般先按原配置跑,显存报错再改,改了要同步调整全连接的输入维度。

训练配置就是常规那套:交叉熵损失,学习率 0.001,迭代 1000 轮,批次按样本量定。0.001 这个学习率对这个规模的网络比较稳,太大前期损失震荡,太小收敛慢。1000 轮不一定要跑到头,训练脚本里加一个验证集准确率连续若干轮不提升就提前停的回调,能省掉不少等待时间。定位网络二分类任务简单,通常几十轮就收敛得差不多了。

4. 字符分割与识别实现:投影法切分与 67 类 CNN 分类器

4.1 水平投影与垂直投影:七个字符是怎么切出来的

定位给到的是一整块车牌小图,识别要的是单个字符。切分用的是投影法,本质是统计每一行、每一列上的前景像素个数,利用字符之间有间隙这个事实来下刀。顺序是先水平后垂直:水平投影确定字符主体的上下边界,把车牌四周可能残留的白色边框和上下边缘切掉;垂直投影在剩下的条带里找列间隙,一刀一刀分出字符。

def split_chars(plate_bgr): """输入校正后的车牌小图,返回按从左到右排序的字符图列表""" gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 反相二值化:让字符变白、背景变黑,便于统计前景像素 _, binary = cv2.threshold( gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 水平投影:统计每一行的白点数,只保留字符主体所在的纵向带 row_sum = (binary > 0).sum(axis=1) rows = np.where(row_sum > 0.4 * binary.shape[1])[0] if len(rows): binary = binary[rows[0]:rows[-1] + 1, :] # 垂直投影:统计每一列的白点数,连续的 0 就是字符间隙 col_sum = (binary > 0).sum(axis=0) segs, start = [], None for i, v in enumerate(col_sum): if v > 0 and start is None: start = i elif v == 0 and start is not None: segs.append((start, i)) start = None if start is not None: segs.append((start, len(col_sum))) # 用平均宽度过滤:太窄的块多半是第二个字符后的小圆点 widths = [e - s for s, e in segs] if not widths: return [] avg_w = sum(widths) / len(widths) chars = [] for s, e in segs: if (e - s) < 0.45 * avg_w: continue char = binary[:, s:e] chars.append(cv2.resize(char, (128, 36))) return chars

这段里有两个判断阈值值得说。水平投影用的0.4 * 列数,意思是某一行里至少有四成像素是白的才算字符主体,门槛太低会把上下边框留下,太高会把上下带笔画少的字符截断。垂直投影里过滤用的0.45 * avg_w,专门对付第二个字符和第三个字符之间那个小圆点——这个点在投影上也是一个独立的连通段,宽度只有正常字符的三分之一左右,用平均宽度的比例能干净地剔掉。如果车牌上有螺丝孔或者污渍,也可能产生细小的段,同一个规则顺带处理。

切出来的字符数量不一定正好是七个。新能源牌是八位,个别字符粘连会少切,污渍会多切。工程上的做法是先把切分结果的数量当成一个信号:数量小于六或大于八的,直接判为定位或分割失败,交给上游重试;数量在范围内的,再逐字分类,最后按位置拼成字符串。不要试图用一套固定规则覆盖所有情况,承认失败并重试比强行凑数稳。

4.2 标签表与字符归一化:67 类怎么定

识别网络的输出维度由标签表决定。车牌字符串由省份简称汉字、字母、数字组成,把数据集中实际出现过的所有字符去重排序,建一个索引到字符的映射表,表长就是输出维度。原文提到 67 类,这个数字是汉字、字母、数字合起来统计的结果。注意字母表里通常要去掉 I 和 O,因为它们和数字 1、0 字形太接近,保留只会让网络在歧义样本上反复拉锯。

标签表要落成文件存在项目里,训练和推理共用同一份,别在两处各写一遍。顺序错一位,训练出来的模型全体错位,而且这种错不会报错,只会让识别率莫名其妙地低。我习惯在标签文件旁边加一行注释记录生成时间和数据来源,换数据集的时候不至于拿旧表配新权重。

字符图的预处理和训练时保持一致是死规矩。训练时把字符图缩放到 128×36、归一化到 0 到 1、如果是单通道就复制成三通道;推理时这几步一个都不能少、顺序也不能变。少做一步归一化,模型输出会变成一堆几乎相等的概率,看着像没训练好,其实是输入分布对不上。这种问题排查起来很磨人,所以我把预处理封成一个函数,训练脚本和推理脚本都调它,从源头上断了不一致的可能。

4.3 识别 CNN 的训练配置与增强策略

识别网络和定位网络共用同一份结构定义,只是输出维度从 2 换成类别数。同一套结构跑两个任务的好处很实在:调过一次的学习率、批次大小、训练轮数可以直接搬过去,不用为第二个网络再摸一遍参数。

# 复用同一份结构定义,只改输出维度 char_model = build_classifier(num_classes=len(label_list)) # 数据增强:小样本字符识别靠这个撑住泛化 train_datagen = tf.keras.preprocessing.image.ImageDataGenerator( rescale=1.0 / 255, # 归一化,与推理端保持一致 rotation_range=8, # 轻微旋转,模拟拍摄倾斜 width_shift_range=0.08, # 水平平移,模拟切分偏移 height_shift_range=0.08, zoom_range=0.1, # 缩放,模拟远近差异 brightness_range=(0.8, 1.2), # 亮度扰动,模拟不同光照 validation_split=0.2) history = char_model.fit( train_datagen.flow_from_directory( 'data/char_cls', target_size=(36, 128), batch_size=67, class_mode='categorical', subset='training'), validation_data=train_datagen.flow_from_directory( 'data/char_cls', target_size=(36, 128), batch_size=67, class_mode='categorical', subset='validation'), epochs=1000, callbacks=[tf.keras.callbacks.EarlyStopping(patience=30, restore_best_weights=True)])

增强参数给得都比较克制,旋转 8 度、平移 8%,因为这些扰动模拟的是真实场景里切分不准和拍摄角度偏差的量级,给太大反而让字符变形到不像字。亮度扰动对小样本特别有用,同一个字在不同曝光下差异可以很大,增强之后网络对光照的敏感度明显下降。批次 67 是按显存和每类样本量折中的结果,显存够可以加大,不够就减半,学习率跟着按比例微调。

训练过程要盯两条曲线。训练准确率涨、验证准确率不涨甚至往下掉,是过拟合,先加增强、再加数据,最后才考虑减网络容量。两条都卡在低位不涨,多半是标签表或者预处理有问题,回去查标签顺序和归一化。识别网络比定位网络难训,因为类别多、类间差异小,收敛通常要慢一些,耐心等 EarlyStopping 触发就行。

4.4 GUI 联调:600×600 界面与三种底色车牌的端到端验证

界面部分不求花哨,能把链路串起来、能演示清楚就行。窗口 600×600,右下角三个按钮:打开图片、打开相机、开始识别。左侧显示原图和定位框,右侧显示裁剪出的车牌小图和切分后的字符条,底部一行文本显示识别结果和每个字符的置信度。

import tkinter as tk from tkinter import filedialog from PIL import Image, ImageTk import cv2 class PlateApp: def __init__(self, root): self.root = root root.geometry('600x600') self.canvas = tk.Canvas(root, width=600, height=500, bg='#f0f0f0') self.canvas.pack() # 三个按钮,宽 20 高 5(字符单位),横排放在右下角 tk.Button(root, text='打开图片', width=20, height=5, command=self.open_image).pack(side='left', padx=4) tk.Button(root, text='打开相机', width=20, height=5, command=self.open_camera).pack(side='left', padx=4) tk.Button(root, text='开始识别', width=20, height=5, command=self.run).pack(side='left', padx=4) self.img_bgr = None def open_image(self): # 用 imdecode 读,避开中文路径下 imread 返回 None 的问题 path = filedialog.askopenfilename() if not path: return data = np.fromfile(path, dtype=np.uint8) self.img_bgr = cv2.imdecode(data, cv2.IMREAD_COLOR) self.show(self.img_bgr) def show(self, img_bgr): rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) pil = Image.fromarray(rgb).resize((480, 360)) self.photo = ImageTk.PhotoImage(pil) self.canvas.create_image(300, 240, image=self.photo)

界面和算法之间只通过一个函数通信:传进去一张 BGR 图,返回车牌字符串、框坐标、每个字符的置信度。这样界面层完全不关心内部怎么实现,算法层换实现也不用改界面。回调里把耗时也打出来,答辩演示时能顺口报一句"单帧处理多少毫秒",比空口说快有说服力。

联调顺序建议按底色分三批走:先蓝牌,样本最多、最容易出结果;再黄牌,黄牌的黄色和某些车身颜色接近,容易误定位;最后绿牌,新能源牌位数多一位,切分和拼接逻辑要单独验证一遍。三批都跑通之后,再拿包含多辆车的图测一次,确认多候选场景下按置信度排序取最高的逻辑没问题。这个顺序走下来,每一批失败都能定位到具体环节,不至于一上来就被最难的样本劝退。

5. 避坑与排查:五类翻车现场的原因与处理

5.1 白天识别率九十多,一到傍晚就掉到三成

现象是同一套代码、同一批车,中午测得好好的,傍晚光线一暗就大面积失败,定位框直接框不出来。原因在色彩分割这一层:HSV 的明度下限卡在 70,光线一暗整张图的 V 通道整体下移,车牌底色被这道门槛挡在外面,掩模几乎是全黑的,后面所有步骤都无从谈起。解决方法是把明度下限做成随图像平均亮度自适应,先算全图 V 通道均值,均值低于某个水平时把下限按比例调低,同时把饱和度下限也一起放宽。另一种做法是在预处理前加一步自适应直方图均衡,把暗图的动态范围拉回来再做分割,这个改动更彻底,但要重新校对一遍其他阈值。

5.2 定位框住的是广告牌和车身蓝色贴纸

现象是输出框位置明显不对,框里是一块纯蓝的招牌或者车身侧面。原因是色彩分割只看颜色,蓝色招牌和蓝牌在 HSV 上几乎无差别,长宽比恰好也落在区间里,前面两层筛都拦不住。这时候定位 CNN 就是最后一道闸。要让这道闸真正起作用,负样本里必须专门放这类图:从训练原图里裁出蓝色招牌、黄色灯箱、车身贴纸的块,标成非车牌类,比例占到负样本的三成左右。只喂随机背景块的话,网络学到的只是"有没有密集纹理",遇到同样是纹理的招牌还是会判错。补完负样本重训一轮,这类误框基本能压下去。

5.3 字符切多了,中间那个点被当成一个字

现象是切出来的字符数比实际多一个,识别结果里凭空多出一个字符。原因是车牌第二个和第三个字符之间那个分隔圆点在垂直投影上也是一个独立的连通段,如果没有过滤规则,它会被当成一个窄字符送去分类,分类器只能硬凑一个最像的类别出来。解决方法是按字符平均宽度做比例过滤,宽度不到平均值一半的段直接丢弃。这个比例不能给太狠,给到 0.6 以上容易把数字 1 这种本身就很窄的字符一起误杀,0.4 到 0.5 之间比较稳。另外可以在切分之后加一道数量校验,数量超出合理范围就重试,别让错误结果一路走到输出。

5.4 训练集准确率 99%,换个摄像头就认不出

现象是训练日志漂亮得不像话,实际拿新图测惨不忍睹。原因是训练集和验证集是从同一批图里随机切的,同一块车牌的多个字符变体分散在两边,模型实际上是在认车牌而不是认字符,验证准确率虚高。解决方法是按车牌分组划分数据集,同一块车牌的所有字符样本只能进训练集或者只能进验证集,不能跨。再留一份完全独立、不同时间不同设备拍的测试集,只用它报最终数字。这个划分方式会让训练准确率掉下来几个点,但那个数字才是真的。

5.5 OpenCV 读图和界面显示打架、中文路径读成空

现象是cv2.imread返回 None,或者图片在界面上颜色发蓝、发灰。前一个问题的原因是 OpenCV 的读图函数对非 ASCII 路径支持不好,路径里带中文就直接失败还不报错。解决方法是用np.fromfile读成字节流再cv2.imdecode解码,这条路绕开了路径编码问题。后一个问题的原因是 OpenCV 内部是 BGR 顺序,而界面库认 RGB,直接用会导致红蓝通道互换,人脸发蓝、车牌底色偏色。转换的地方固定写一句cv2.cvtColor(img, cv2.COLOR_BGR2RGB),全项目只在一个函数里做这件事,别到处散着转。

6. 验收与答辩:用一张十二组样本的测试表把结论说死

答辩现场最容易被问倒的不是模型结构,是"你这个准确率怎么来的"。含糊地说"测了几十张,大部分都对",评审一追问样本从哪来、怎么算对、失败的是哪类,就答不上来了。我的做法是提前准备一张固定格式的测试表,样本按维度分组,每组至少三张,全部来自没参与训练的数据,跑完之后逐行填结果,表本身就是答辩材料。

组别样本条件考察点通过标准
A白天正拍、蓝牌、背景干净主链路是否通字符串完全正确
B白天斜拍十五度以上、蓝牌仿射校正是否生效字符串完全正确
C逆光、蓝牌预处理对暗图的鲁棒性定位命中且至少六位正确
D黄牌、车身同为黄色色彩分割的干扰抑制定位框不与车身粘连
E绿牌、八位字符切分位数与拼接逻辑八位全部输出
F图中有两辆车多候选排序策略输出面积最大或置信度最高的一块
G夜间补光、蓝牌明度自适应是否起作用定位命中,允许一位错
H车牌有污渍或遮挡分割失败的降级处理不崩溃,给出低置信度提示
I非车牌图片(风景)拒识能力输出"未检测到车牌"而非乱码
J大幅倾斜加模糊系统边界允许失败,但要有明确提示
K中文路径下的图片文件读图健壮性正常加载并识别
L连续十张批量输入稳定性与耗时无崩溃,记录平均单帧耗时

这张表的用法有讲究。A 到 C 是必须全过的,任何一组挂了都要回去改,不能带着问题往下走。D 到 F 是区分度最高的三组,也最容易被追问,答的时候直接说清楚每组的失败原因和改进方向,比硬撑全对更让人信服。I 组特别重要,很多人只顾着让系统认车牌,忘了它能识别"这里没有车牌",一张风景图丢进去输出一串乱码,评审立刻会怀疑整个系统的判断逻辑。J 组放在表里是主动承认边界,说清楚"这种条件下当前版本会失败,原因是分辨率不足导致笔画粘连",反而显得心里有数。

演示节奏也要设计。别一上来就放最难的图,先用 A 组跑通,让评审看到完整链路:原图、定位框、裁剪出的车牌、切分出的字符条、最终字符串,五段信息一屏展示。然后挑一组失败的,现场讲原因和改法,这一段的印象分往往比全对更高。耗时数据提前跑好记在本子上,被问到直接报"单帧平均多少毫秒,其中定位占多少、识别占多少",分段数据能说明你真的拆开测过。

从那以后我每次带这类项目,都会在动手写代码之前先把这张验收表列出来,样本先收集齐、分组先定好,再倒推每一组需要什么能力、对应哪一段代码要重点打磨。表填不满就说明方案还有洞,比事后补测试省事得多。希望这套结构能帮到你,把它改成自己项目的样子直接用就行。

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

返回列表