ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+OpenCV车牌识别系统:两段式定位、分割与CNN识别

Python+OpenCV车牌识别系统:两段式定位、分割与CNN识别

简介:围绕Python与OpenCV实现车牌识别的毕业设计答辩PPT,面向计算机视觉入门者、本科毕业设计学生及希望梳理车牌识别流程的开发者,重点讲清车牌定位与字符识别两段式方案。内容从选题背景、我国车牌样式特点与技术难点切入,依次展开图像预处理、HSV色彩分割、Sobel边缘检测、二值化与闭运算、轮廓筛选及漫水填充定位,再说明字符分割和CNN分类识别,并给出36×128输入、三层卷积池化与全连接层的网络参数及结果界面展示,便于对照复现实验、理解两个同结构CNN分别承担车牌过滤与字符识别的设计思路。包内共1个pptx文件,约8.57MB,以答辩讲解页面和流程图为主,结构按研究背景、方案原理、结果展示与总结递进。已有4789人学习,适合用作课程汇报、毕设答辩参考和车牌识别入门路线梳理。

1. 一套跑在毕业设计里的车牌识别系统:两段式流水线比想象中耐用

凌晨的地下车库,手机闪光灯把蓝底车牌拍出一片过曝反光,某开发者把这张图丢进这套车牌识别系统的界面,定位框依然稳稳扣住车牌,七个字符逐个对上。这套基于 Python 和 OpenCV 的系统没有引入重型模型,走的是两段式流水线:先靠颜色掩模、Sobel 边缘和闭运算把车牌框出来,再用一个 CNN 对候选框做二分类过滤;定位之后用水平、垂直投影切字,第二个同构 CNN 在 67 个类别里挑出字符。它适合两类人:正在做课程设计、需要一个能端到端跑通原型的同学,和想把车牌识别塞进后台图片处理流程、又不愿意引入重型依赖的开发者。整套东西两个模型文件、几个 py 脚本、一个 600x600 的界面,断网也能跑。

2. 技术栈与工程结构:两个同构 CNN 撑起定位与识别

技术选型这块值得先交代清楚。车牌是一个先验极强的目标:物理尺寸统一,长宽比固定在 3.1 到 3.6 之间,底色只有蓝、黄、绿、白那么几种,字符排列也是固定的。这些先验用传统图像处理实现几乎零成本,CPU 上毫秒级就能产出几十个候选框;真正的麻烦在于候选框里混着广告牌、路牌、条纹衣服这类干扰,于是再补一个小 CNN 做二次判别。传统方法负责找回候选,CNN 负责提高精度,这套组合在算力有限的场景里非常务实,最大的好处是每一段都能单独可视化——想看边缘图就看边缘图,想看色域掩模就看掩模,出问题能一眼判断卡在哪一步。

2.1 依赖清单和目录骨架

工程拆得很平,没有任何框架包装,几个函数文件加一个界面文件就能跑起来。目录大致长这样:

plate-recog/ ├── data/ │ ├── detect/ # 定位网络数据集 │ │ ├── pos/ # 正样本:裁好的车牌图,约 2000 张 │ │ └── neg/ # 负样本:误检框,约 4000 张 │ └── chars/ # 字符数据集,按 67 个类别分子目录 ├── models/ │ ├── detect_cnn.h5 # 定位二分类模型 │ └── char_cnn.h5 # 字符 67 分类模型 ├── src/ │ ├── preprocess.py # 灰度、HSV、Sobel、Otsu、闭运算 │ ├── locate.py # 轮廓筛选、漫水填充、仿射校正 │ ├── segment.py # 水平/垂直投影切字符 │ ├── recognize.py # 字符识别封装 │ └── train.py # 两个模型共用的训练脚本 ├── gui/ │ └── main_window.py # 600x600 的 PyQt5 界面 └── requirements.txt

依赖装起来没几个包,但版本得卡一下:

# Python 3.8 环境实测最稳,3.10 之后部分 PyQt5 版本会缺系统库 pip install opencv-python==4.5.5.64 pip install numpy==1.21.6 pip install tensorflow==2.8.0 pip install PyQt5==5.15.4 pip install pillow

参数说明上有一个细节要留意:opencv-python4.5.x 的findContours返回两个值(轮廓、层级),而 4.0 之前返回三个,网上抄来的老代码直接扔进来会抛 unpack 错误。tensorflow用 2.x 是因为 Keras 已经内置,不用再单独装 keras 包,两个模型共用一个训练脚本也省事。

2.2 一个请求从进入到返回:两段式数据流怎么走

把整条链路摊开看,一张图片要经过六个阶段才吐出字符串。每个阶段都有明确的输入输出,失败时表现也不一样,排查的时候对着这张表定位,比到处加 print 快得多。

阶段输入关键操作输出失败时的典型表现
预处理BGR 原图灰度化、均值滤波、HSV 掩模、Sobel候选区域二值图车身反光区域也进了掩模
定位候选区域闭运算、轮廓筛选、漫水填充若干外接矩形冒出一堆长宽比接近 3:1 的误检框
校正外接矩形透视变换到 36x128归一化车牌图车牌倾斜时字符被切掉一半
过滤36x128 图定位 CNN 二分类保留概率最高的框真车牌被判成负样本
分割车牌二值图水平/垂直投影单字符切片列表多切一块,或把边框当字符
识别单字符切片字符 CNN 67 分类车牌字符串汉字识别成字母

整条链路的耗时分布大致是:预处理和定位加起来占七成,两次 CNN 推理占三成。也就是说,想提升整体速度,先优化传统处理那段,减少候选框数量,比换网络结构见效快得多。这个判断在后面调性能的时候会用上。

2.3 两个 CNN 的分工:只差一个输出类别数

两个网络结构完全一样,只有最后一层输出维度不同:定位网络输出 2 类(车牌区域、非车牌区域),字符网络输出 67 类。这种一套结构复用两处的做法在毕设里很划算,训练脚本只改一个num_classes参数就能切换任务。

层参数输出尺寸(输入 36x128x3)
Conv13x3x32,SAME,stride 1,ReLU36x128x32
MaxPool12x2,stride 1,SAME36x128x32
Conv23x3x64,SAME,stride 1,ReLU36x128x64
MaxPool22x2,stride 1,SAME36x128x64
Conv33x3x128,SAME,stride 1,ReLU36x128x128
MaxPool32x2,stride 1,SAME36x128x128
Flatten—589824
FC11024,ReLU1024
FC21024,ReLU1024
FC31024,ReLU1024
Softmax2 或 67num_classes

训练超参是 Adam 优化器、学习率 0.001、交叉熵损失、迭代 1000 次、batch 67。为什么要 36x128 这个输入尺寸,原因很直接:中国车牌 440x140 的比例约等于 3.14:1,裁出来的候选框缩放到 36x128(比例 3.55:1)之后形变最小,正负样本的视觉特征不会因为拉伸而走样。

池化的strides=1这里先记一笔。原始描述里写“不想在 batch 和 channels 维度做池化,因此设置为 1”,这个理解本身没错——池化确实只该跨空间维度,但把步长写成 1 之后,空间尺寸压根没降,三次池化下来特征图还是 36x128,Flatten 出 589824 维。后果我在第 5 章会细说,那是这套方案里最值得改的一处。

3. 车牌定位拆解:从 RGB 像素到候选框的四步处理

定位这一段是整个系统里参数最多、也最容易调崩的部分。它的目标不是“一次找对”,而是“尽量不丢”。先把所有可能是车牌的像素区域捞出来,宁可多捞,后面有 CNN 兜底。理解了这一点,就不会纠结某个阈值是不是太松。

3.1 灰度化、HSV 色域分离与 Sobel 垂直边缘

第一步做两路并行:一路去灰度域找纹理,一路去 HSV 域找颜色。两条路的结果最后相乘,只有“既是蓝/黄色又有密集垂直边缘”的区域才留下来,这一步就能干掉绝大多数背景。

import cv2 import numpy as np def preprocess(img, color='blue'): # 1. 灰度化,三通道降到单通道,后续 sobel 和 otsu 都在灰度上做 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2. 均值滤波柔化噪点,核取 3x3,核太大会把字符笔画糊成一坨 gray = cv2.blur(gray, (3, 3)) # 3. HSV 域取蓝/黄掩模,S、V 下限别设太低,否则路面和车身也会进掩模 hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) if color == 'blue': lower = np.array([100, 80, 80]) # 蓝底车牌 H 大约 100~124 upper = np.array([124, 255, 255]) else: lower = np.array([15, 80, 80]) # 黄底车牌 H 大约 15~40 upper = np.array([40, 255, 255]) mask = cv2.inRange(hsv, lower, upper) # 4. sobel 取 x 方向梯度:车牌字符是竖排笔画,垂直边缘密集 sobel = cv2.Sobel(gray, cv2.CV_8U, 1, 0, ksize=3) _, sobel_bin = cv2.threshold(sobel, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 5. 颜色掩模与边缘图相乘,只保留同时满足两个条件的区域 region = cv2.bitwise_and(mask, sobel_bin) return gray, region

参数上最关键的是 HSV 的上下界。OpenCV 里 H 的取值范围是 0 到 179(不是 360),所以蓝色对应 100 到 124,黄色对应 15 到 40。S 和 V 给 80 的下限是个经验值:低了会把灰白路面纳进来,高了会让阴天拍摄的暗蓝车牌被漏掉。Sobel 用ksize=3效果比较稳,CV_8U输出的结果可能出现负数被截断,也可以先转CV_16S再取绝对值,这样边缘更完整但计算量翻一倍。

3.2 最大类间方差二值化加闭运算连边

拿到候选区域之后要把它变成干净的黑白图,这里不用手工给阈值,直接上最大类间方差法(Otsu)。它的思路是把 0 到 255 每个灰度值都当成候选阈值试一遍,每次把像素分成前景和背景两类,计算两类的像素占比 w0、w1 和平均灰度 u0、u1,类间方差等于 w0 × w1 × (u0 - u1)²。方差最大的那个阈值就是最佳分割点。光照均匀的时候 Otsu 比手工写 127 稳太多,换一张图不用重调。

def binarize_and_close(region, kernel_w=17, kernel_h=5): # Otsu 自动找阈值,光照变化时不需要手工重调 _, binary = cv2.threshold(region, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 闭运算 = 先膨胀再腐蚀,作用是填小孔、连断边,总位置和形状不变 # 结构元素宽大于高,因为车牌是横向长条,横向连边更重要 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_w, kernel_h)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=2) return closed

闭运算的结构元素尺寸是这段代码里最需要反复试的参数。(17, 5)这个配置的意思是用一个 17 像素宽、5 像素高的矩形核在图上滑动,宽高一变形,连边的效果就完全不同:核太小,车牌中间几个字符之间的裂缝连不起来,轮廓会碎成好几块;核太大,相邻的两个车牌或者车牌和后面的车身标识会被连成一片,长宽比一算就超出范围被误筛掉。iterations=2是在这个基础上再多做一轮,代价是核尺寸的等效作用范围翻倍,调的时候两者要一起改。

3.3 轮廓筛选、漫水填充与仿射校正

二值图里会留下不少连通区域,接下来要依次尝试把它们筛掉。先求外轮廓,再求每个轮廓的外接矩形,用长宽比和面积两道硬门槛卡一遍。

def locate_candidates(closed): contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if h == 0: continue ratio = w / float(h) area = w * h # 中国车牌长宽比约 3.1~3.6,放宽到 2.5~4.0 留余量 # 面积门槛用来挡掉文字、标线这类细碎区域 if 2.5 <= ratio <= 4.0 and area > 1500: candidates.append((x, y, w, h)) return candidates

筛完的候选框还不一定完整,可能因为反光或者污渍导致车牌一侧断掉。漫水填充就是用来补这个缺口的:在候选框内找一个种子点,从这个点出发把颜色相近的连通区域全部涂满,再对涂满的掩模重新求外接矩形。这样做有两个作用,一是把残破的车牌补齐,二是顺便再排掉一批非车牌区域——如果种子点周围根本没有蓝/黄色的成片像素,填充出来的面积会小得可怜,尺寸检查直接失败。

def flood_refine(gray, box, seed_offset=(3, 3)): x, y, w, h = box mask = np.zeros((gray.shape[0] + 2, gray.shape[1] + 2), np.uint8) seed = (x + seed_offset[0], y + seed_offset[1]) # loDiff/upDiff 给 30 左右,容忍车牌底色的渐变和脏污 # FLOODFILL_FIXED_RANGE 表示和种子点比较而不是和邻居比较,防止整片蔓延 cv2.floodFill(gray.copy(), mask, seed, 255, loDiff=(30,), upDiff=(30,), flags=8 | cv2.FLOODFILL_FIXED_RANGE) ys, xs = np.where(mask[1:-1, 1:-1] == 1) if len(xs) == 0: return box return (int(xs.min()), int(ys.min()), int(xs.max() - xs.min()), int(ys.max() - ys.min()))

最后一步是把不规则的车牌区域校正成标准矩形,方便后面切字。

def affine_fix(img, box, target_w=128, target_h=36): x, y, w, h = box crop = img[y:y+h, x:x+w] # 四个角点直接映射到目标矩形,等价于带插值的 resize # 想做真正的倾斜校正,就把 src 换成 minAreaRect 的四个角点 src = np.float32([[0, 0], [w, 0], [0, h], [w, h]]) dst = np.float32([[0, 0], [target_w, 0], [0, target_h], [target_w, target_h]]) M = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(crop, M, (target_w, target_h))

我一般会把src换成cv2.minAreaRect返回的四个角点,再按 x+y 和 x-y 排序,这样倾斜 15 度以内的车牌也能拉正。直接映射矩形的写法在正对拍摄时够用,角度一大字符就会在缩放中变形,识别率明显掉。

3.4 定位 CNN 的训练脚本与参数说明

传统处理负责找回候选,CNN 负责判断这个框到底是不是车牌。训练脚本两个任务共用,靠num_classes和数据集路径区分。

import tensorflow as tf def build_cnn(num_classes, input_shape=(36, 128, 3)): model = tf.keras.Sequential([ # 输入 36x128,卷积全部 SAME,尺寸只由池化决定 tf.keras.layers.Conv2D(32, (3, 3), padding='same', activation='relu', input_shape=input_shape), tf.keras.layers.MaxPool2D((2, 2), strides=1, padding='same'), tf.keras.layers.Conv2D(64, (3, 3), padding='same', activation='relu'), tf.keras.layers.MaxPool2D((2, 2), strides=1, padding='same'), tf.keras.layers.Conv2D(128, (3, 3), padding='same', activation='relu'), tf.keras.layers.MaxPool2D((2, 2), strides=1, padding='same'), tf.keras.layers.Flatten(), tf.keras.layers.Dense(1024, activation='relu'), tf.keras.layers.Dense(1024, activation='relu'), tf.keras.layers.Dense(1024, activation='relu'), tf.keras.layers.Dense(num_classes, activation='softmax'), ]) model.compile(optimizer=tf.keras.optimizers.Adam(0.001), loss='categorical_crossentropy', metrics=['accuracy']) return model # 定位任务 2 类,字符任务 67 类,结构只差最后一个 Dense detect_model = build_cnn(num_classes=2) detect_model.fit(train_x, train_y, batch_size=67, epochs=1000)

参数说明:padding='same'保证卷积之后尺寸不变,输出的空间维度只受池化影响;三个全连接层都是 1024 个神经元,是原始方案定下的配置,实际训练时第一层全连接的参数量会非常大(原因在第 5 章展开)。batch 给 67 是个有点奇怪的数字,小数据集上经常随手设成能整除样本量的值,实际跑的时候用 32 或 64 对内存更友好,梯度也更稳。

4. 字符分割与识别:投影法切字,67 类 CNN 出结果

定位框扣准之后,事情只完成了一半。车牌是一个横条,里面 7 个字符紧挨着,必须先切成单字才能送进分类网络。这一步用的是最经典的投影法,逻辑简单但参数敏感,切错一个字符整个结果就串位。

4.1 水平投影去边框、垂直投影切字符

投影法的本质是把二维问题降成一维。先按行统计白点数量,车牌上下边框对应的行白点特别多,找到中间那段连续的有效行裁掉头尾;再按列统计,字符所在的列白点密集,字符之间的空隙白点稀疏,用这个密度变化找分割点。

def segment_chars(plate_binary, min_width=10): # plate_binary:校正后的车牌二值图,字符为白、底色为黑 h, w = plate_binary.shape # 水平投影:按行统计白点数,去掉上下白色边框 row_sum = (plate_binary > 0).sum(axis=1) rows = np.where(row_sum > w * 0.2)[0] if len(rows) > 0: plate_binary = plate_binary[rows.min():rows.max() + 1, :] # 垂直投影:按列统计白点数,连续有字的列构成一个字符 col_sum = (plate_binary > 0).sum(axis=0) chars, start = [], None for i, v in enumerate(col_sum): if v > 2 and start is None: start = i elif v <= 2 and start is not None: chars.append((start, i)) start = None if start is not None: chars.append((start, w)) # 宽度过滤:太窄的是第 2、3 字符中间的小圆点,太宽的是左右边框 widths = [e - s for s, e in chars] if not widths: return [] avg = sum(widths) / len(widths) return [(s, e) for s, e in chars if e - s >= avg * 0.5]

三个阈值决定成败。w * 0.2是水平投影的行门槛,白点数超过这一行总宽度的两成才算“有字行”,太小会把上下边框留下,太大会把字符顶部削掉。垂直投影里的v > 2判断这一列有没有字,给 2 是为了容忍二值化残留的孤立噪点。最后的avg * 0.5是宽度过滤,中文车牌第二位和第三位之间有一个小圆点,分割时会单独切出一块很窄的区域,跟平均字符宽度一比就能排除掉。

4.2 字符识别网络:结构照搬,类别换成 67

字符网络的搭建跟定位网络一行不差,只是把输出层换成 67 类。这 67 类的构成是:10 个数字、24 个字母(去掉容易混淆的 I 和 O)、31 个省份简称汉字,再加上 2 个特殊字符(警、学)凑满。

CHARS = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ" \ "京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼警学" def recognize(plate_img, char_model): boxes = segment_chars(plate_img) result = [] for s, e in boxes[:7]: # 普通车牌 7 位,新能源 8 位 ch = plate_img[:, s:e] # 注意:这里强制拉到 36x128,单字符比例会严重失真 ch = cv2.resize(ch, (128, 36)) ch = ch.reshape(1, 36, 128, 3).astype('float32') / 255.0 pred = char_model.predict(ch, verbose=0) result.append(CHARS[int(np.argmax(pred))]) return ''.join(result)

CHARS这个字符串的顺序必须跟训练时类别目录的编号严格一致,差一位就会出现“所有汉字都识别成同一个字”的玄学现象。因为 Keras 的image_dataset_from_directory是按目录名排序生成标签的,所以要么训练前把目录名改成带序号的,要么直接把标签映射写死存进一个 json 文件,后者更稳妥。

单字符强制缩放到 128x36 是这套方案里一个明显的妥协。单个字符的宽高比接近 1:2,拉到 3.55:1 之后笔画被横向拉长,看起来完全不像正常字。模型能学出来是因为训练和推理用了同样的失真方式,但这条路的上限不高。想提升字符识别率,把输入改成 20x40 这类接近真实比例的尺寸,比调整网络层数有效得多。

4.3 GUI 界面与三种底色车牌的实测表现

界面用的是 PyQt5,窗口固定 600x600,右下角横排三个按钮,上方一块 QLabel 显示图片和识别结果。

import sys, cv2 from PyQt5.QtWidgets import (QApplication, QWidget, QPushButton, QLabel, QFileDialog) from PyQt5.QtGui import QImage, QPixmap class PlateWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle('车牌识别系统') self.setFixedSize(600, 600) # 原始方案定的 600x600 self.img_label = QLabel(self) self.img_label.setGeometry(0, 0, 600, 500) self.btn_open = QPushButton('打开图片', self) self.btn_open.setGeometry(100, 530, 100, 40) self.btn_cam = QPushButton('打开相机', self) self.btn_cam.setGeometry(250, 530, 100, 40) self.btn_show = QPushButton('展示图片', self) self.btn_show.setGeometry(400, 530, 100, 40) self.btn_open.clicked.connect(self.open_file) self.model = load_char_model('models/char_cnn.h5') def open_file(self): path, _ = QFileDialog.getOpenFileName(self, '选图', '', '*.jpg *.png') if not path: return # 中文路径不能直接传给 cv2.imread,先读字节再解码 img = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR) plate = run_pipeline(img, self.model) self.img_label.setText(plate) def load_char_model(p): import tensorflow as tf return tf.keras.models.load_model(p) if __name__ == '__main__': app = QApplication(sys.argv) w = PlateWindow() w.show() sys.exit(app.exec_())

原始描述里按钮写的是“高 5xp、宽 20xp”,显然是 50x200 的笔误,600 宽的窗口里三颗按钮横排,每颗 100x40 比较协调。界面本身没什么技术含量,但它是答辩现场最直观的部分,识别结果直接拼在 QLabel 上显示,配合原图一起看,评委一眼就能判断对不对。

三种底色的实测表现差异挺大。蓝色车牌最稳,HSV 阈值就是照着它调的,正常光照下定位和切字都顺。黄色车牌次之,H 区间往 15 到 40 挪一下,但黄底和车身上的金色标识、黄黑相间的工程车涂装容易混,误检框会多一些。绿色新能源车牌最麻烦,它的底色是渐变绿,H 跨度从 35 一直到 85,单靠一个区间覆盖不住,得分两段阈值取并集,S 的下限还得降到 43 左右,否则渐变部分会被漏掉。

5. 跑之前先看这几条:车牌识别系统常见的五个翻车点

这套方案的每一段都有独立参数,串起来跑的时候问题会互相掩盖。下面五条是我见过的、也是自己踩过的,按现象、原因、解决三段写,对着排查能省不少时间。

5.1 颜色阈值硬编码,换底色车牌直接哑火

现象:蓝色车牌识别得好好的,换成黄色或绿色车牌,定位框一个都出不来,界面上显示的候选区域是一片空白。

原因:preprocess里的 HSV 上下界是写死的常量,蓝色 100 到 124 那个区间对黄色车牌毫无响应,inRange出来的掩模基本全黑,跟 Sobel 结果相乘自然也是黑的。这不是参数没调好,是设计上就没考虑多色场景。

解决:把颜色参数抽成配置字典,按车牌底色查表。绿色新能源车牌的渐变特性决定它需要两段区间取并集,代码大致是这样:

HSV_RANGES = { 'blue': [(100, 80, 80), (124, 255, 255)], 'yellow': [(15, 80, 80), (40, 255, 255)], 'green': [((35, 43, 46), (85, 255, 255))], # 新能源渐变绿,S 下限降到 43 } def color_mask(hsv, color): mask = np.zeros(hsv.shape[:2], np.uint8) for lower, upper in HSV_RANGES[color]: mask |= cv2.inRange(hsv, np.array(lower), np.array(upper)) return mask

不想手工选颜色的,可以先跑一遍 KMeans 把图像主色聚成几类,再看哪一类接近已知底色,代价是多几十毫秒。

5.2 闭运算核尺寸不匹配,车牌被切碎

现象:二值图上看车牌是断成两三截的,轮廓筛选之后每个候选框的长宽比都不对,全被 2.5 到 4.0 这个区间挡在外面。

原因:闭运算的结构元素太小,车牌字符之间的缝隙没连通;或者iterations设成 1,膨胀腐蚀各做一遍,作用范围不够。反过来核太大也不行,车牌会和背景的同类色块粘在一起,外接矩形一量宽高比直接飙到 6 以上。

解决:把kernel_w、kernel_h、iterations三个参数暴露到配置文件里,跑一批样本手动扫一遍。经验值是宽度取车牌预期宽度的 1/10 到 1/8,高度取字符笔画间隙的两倍左右。640 宽的图上车牌通常占 200 到 300 像素,对应kernel_w给 17 到 25 比较合适。调的时候把中间结果存图,肉眼比对,比盲猜快。

5.3 字符分割多块少块,识别结果串位

现象:识别出来的字符串长度不对,有时候 5 位,有时候 9 位,汉字位置对不上。

原因:垂直投影的列阈值v > 2太松,二值化残留的噪点被当成一个字符的开头;或者avg * 0.5这个过滤比例没调好,第 2、3 字符之间的小圆点没被排掉。另一种情况是左右边框被算进字符,因为边框那一列的白点数也很密集。

解决:分割前先做一次开运算(先腐蚀后膨胀)去掉孤立噪点,比调阈值更根治。然后在切完之后加一道结构校验:普通车牌固定 7 位,第一位必须是汉字,第二位必须是字母,后面五位是字母或数字的组合,任何一条不满足就回退到次优分割方案。这个校验逻辑不复杂,但能把大半的串位问题挡在识别之前。

def validate(text): if len(text) not in (7, 8): # 8 位是新能源车牌 return False if not ('\u4e00' <= text[0] <= '\u9fa5'): # 首位必须是汉字 return False return text[1:].isalnum()

5.4 模型 600MB、训练一小时,池化步长背的锅

现象:第一次训练字符网络,model.summary()打出来的参数量是六亿多,h5 文件存下来 600MB 起步,一个 epoch 在普通笔记本上跑十几分钟,1000 次迭代根本等不到头。

原因:三个池化层都写了strides=1,空间尺寸压根没降。36x128 的输入经过三次池化还是 36x128x128,Flatten 之后是 589824 维,接第一个 1024 神经元的全连接层,光是这一层的权重就是 589824 × 1024 ≈ 6 亿个参数。原始描述里“不想在 batch 和 channels 维度做池化所以设成 1”的思路是对的,但 Keras 的MaxPool2D默认只池化空间维度,根本不需要手动把步长压到 1。

解决:把strides改成 2,或者干脆不写让它跟pool_size一致。改完之后特征图依次是 18x64、9x32、5x16,Flatten 后是 10240 维,第一层全连接参数量降到千万级,模型文件直接掉到 5MB 以内,训练时间从几小时缩到十几分钟。这条改动对准确

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进