ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深度学习车牌识别:OpenCV定位与CNN字符识别

深度学习车牌识别:OpenCV定位与CNN字符识别

简介:《车牌识别系统详细讲解》PPT面向做毕业设计、课程项目或准备答辩的学生,围绕Python与OpenCV构建车牌识别系统,梳理从图像预处理、车牌定位过滤到字符分割识别的完整技术路线,并说明双CNN结构在后台图片识别中的应用思路。压缩包仅含1个pptx文件,约8.57MB,以讲稿式页面串联研究背景与意义、车牌识别技术特点与难点、实验方案与原理、网络层参数、结果界面展示及课题总结,可直接用于汇报或二次整理。资源已有4789人学习下载,内容覆盖HSV色彩分割、Sobel边缘检测、Otsu二值化、闭运算、轮廓筛选与漫水填充等定位步骤,也列出输入层36×128、卷积池化与全连接层的配置,便于对照复现模型。对需要理解车牌定位与字符识别流程、准备答辩展示或拆解卷积网络参数的学习者,这份PPT能提供较完整的项目讲解框架与调试思路参考。

1. 车牌识别系统这套方案到底解决什么问题

去年帮人看一个车牌识别的课程设计,图片拖进去能出结果,换一张夜间抓拍就全黑,摄像头一开窗口直接卡成白板。这类问题多半不是模型不行,而是流水线上某个阈值没兜住。这套基于深度学习的车牌识别系统,用 Python 加 OpenCV 做图像处理,用两个结构相同的卷积神经网络分别承担车牌定位过滤和字符识别,输入统一压到 36x128,输出 2 类还是 67 类取决于加载哪个权重。它适合三类人:要交课程设计或毕业设计的同学、想搞明白传统图像处理和 CNN 怎么接起来的初级开发、以及需要在离线环境跑一个小规模识别 Demo 的工程人员。下面按能照着复现的顺序拆。

2. 整体架构拆解:两段式流水线为什么比端到端更稳

先给结论:这套系统没有走「一张图进、一串字符出」的端到端路线,而是拆成定位和识别两段,中间用字符分割硬切开。端到端模型在公开数据集上刷分好看,但一旦换城市、换光照、换车牌底色,标注成本会迅速失控;拆成两段之后,每一段的输入输出都能单独可视化,出错时知道该修哪一段,这对毕设和小规模落地来说比多两个点准确率重要得多。

第二段还有一个容易被忽略的设计:定位环节并不是纯传统图像处理,它最后还挂了一个二分类 CNN 做兜底。传统方法负责「快」,把候选框从全图缩到十几个;CNN 负责「准」,从候选框里挑出真正是车牌的那个。这种粗细结合的结构在算力有限的机器上比单一大模型更实用。

2.1 从一张抓拍图到六位字符:整条链路上的六个工序

把整条链路摊平,可以拆成六个工序,每个工序的输入输出都很清楚,这也是后面调参时唯一需要盯住的东西。

工序输入输出主要手段
图像输入本地图片或相机帧BGR 三通道矩阵cv2.imread / VideoCapture
预处理原始 BGR 图二值化的边缘掩模灰度化、均值滤波、HSV 取色、Sobel、Otsu、闭运算
候选定位边缘掩模若干候选矩形findContours、boundingRect、长宽比过滤
区域过滤候选矩形唯一车牌框漫水填充补全 + 二分类 CNN
字符分割校正后的车牌图单字符小图列表水平投影去边框、垂直投影切分
字符识别单字符小图字符 + 置信度67 类 CNN + softmax

这个表不是拿来背的,是拿来打日志的。每个工序结束后把中间结果 dump 成图片,出问题的时候直接翻目录,比在代码里打断点快十倍。我习惯在debug/下按01_gray.png、02_edge.png这种前缀编号存,事后一眼就能看出是哪一步塌了。

2.2 两个 CNN 的分工:一个只判是不是车牌,一个判是什么字符

两个网络结构完全一致,差别只在最后的分类层神经元个数。定位过滤网络输出 2 维,代表「车牌」和「非车牌」;字符识别网络输出 67 维,覆盖省份汉字、字母和数字。

from tensorflow.keras import layers, models def build_net(num_classes): """两个网络共用这套主干,只换最后一层的类别数""" model = models.Sequential([ layers.Input(shape=(36, 128, 3)), # 高36 宽128,贴合车牌约1:3.1的长宽比 layers.Conv2D(32, (3, 3), padding='same', activation='relu'), layers.MaxPooling2D((2, 2)), # 36x128 -> 18x64 layers.Conv2D(64, (3, 3), padding='same', activation='relu'), layers.MaxPooling2D((2, 2)), # 18x64 -> 9x32 layers.Conv2D(128, (3, 3), padding='same', activation='relu'), layers.MaxPooling2D((2, 2)), # 9x32 -> 4x16 layers.Flatten(), layers.Dense(1024, activation='relu'), layers.Dense(1024, activation='relu'), layers.Dense(1024, activation='relu'), layers.Dense(num_classes, activation='softmax'), # 2 或 67 ]) return model plate_net = build_net(2) # 定位过滤 char_net = build_net(67) # 字符识别

这段代码里有三个点值得说。第一,padding='same'保证每层卷积前后特征图尺寸不变,尺寸只被池化层砍半,这样输入宽高比才不会被拉伸破坏。第二,三次池化后特征图是 4x16x128,展平后 8192 维,接 1024 全连接层时单层就有八百多万参数,整个模型绝大部分体积都在这里,如果只是想跑通,把 1024 降到 256 能砍掉九成体积,识别率掉得不多。第三,两个网络结构一致意味着主干代码可以抽出来复用,训练脚本只传不同的num_classes和数据集路径,维护成本直接减半。

原文里提到池化时「不想在 batch 和 channels 维度做池化,所以设置为 1」,这句话落地到 Keras 就是默认行为,MaxPooling2D((2, 2))只在空间维度滑动,batch 和通道维度不会被动。如果用别的框架,注意别把 stride 写成四元组。

2.3 目录分层和数据流:权重、样本、调试图各放一处

工程目录如果一开始不规划,半个月后自己都不认识。这套方案我建议按下面这个结构铺:

plate_recognition/ ├── config.py # HSV阈值、长宽比范围、模型路径等常量 ├── main_gui.py # GUI 入口,600x600 窗口 ├── pipeline.py # 串起定位和识别的主流程 ├── locate/ │ ├── preprocess.py # 灰度、滤波、Sobel、Otsu、闭运算 │ ├── contour.py # 轮廓提取与长宽比过滤 │ ├── flood.py # 漫水填充补全与仿射校正 │ └── filter_net.py # 二分类 CNN 推理封装 ├── recognize/ │ ├── split.py # 水平/垂直投影字符分割 │ ├── model.py # build_net 主干定义 │ └── infer.py # 67 类推理与结果拼接 ├── weights/ │ ├── plate_filter.h5 │ └── char_recog.h5 ├── dataset/ │ ├── filter/ # plate / noplate 两个子目录 │ └── chars/ # 按 67 个类别分子目录 ├── debug/ # 中间结果图片,按工序编号命名 └── requirements.txt

config.py单独拎出来的好处是调阈值不用翻代码。蓝色车牌的 HSV 下限、长宽比允许范围、闭运算核尺寸这些参数在不同城市、不同摄像头下都要微调,集中在一处,出问题时改一个文件重跑就行,不用满项目搜数字。

3. 车牌定位链路:从灰度图到候选框的四个步骤

定位这一段的成败决定了后面所有工序。字符识别再准,框错了地方也是白搭。这一段全是传统图像处理,没有训练成本,但也最吃阈值,属于典型的「参数玄学区」。

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

四步的顺序不能随便换。先灰度化是为了把三通道压成一通道,Sobel 只在单通道上算,速度快三倍;HSV 取色必须留在彩色图上做,因为灰度图里蓝牌和黄牌的灰度值可能撞在一起;最后把颜色掩模和边缘图相乘,等于给边缘加了个颜色过滤器,路面标线、护栏这些非车牌底色的边缘会被直接抹掉。

import cv2 import numpy as np def preprocess(bgr): # 1. 灰度化,数据量降到三分之一 gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) # 2. 均值滤波 3x3,压掉路面颗粒和压缩噪点,核再大字符边缘会糊 blur = cv2.blur(gray, (3, 3)) # 3. Sobel 只取 x 方向,车牌字符的垂直边缘响应远强于水平方向 sobel = cv2.Sobel(blur, cv2.CV_8U, 1, 0, ksize=3) # 4. HSV 域按阈值抠蓝色和黄色两种底色 hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) blue = cv2.inRange(hsv, np.array([100, 43, 46]), np.array([124, 255, 255])) yellow = cv2.inRange(hsv, np.array([15, 80, 80]), np.array([35, 255, 255])) color = cv2.bitwise_or(blue, yellow) # 5. 颜色掩模与边缘图相乘,只保留车牌底色区域的边缘 edge = cv2.bitwise_and(sobel, sobel, mask=color) # 6. Otsu 自动阈值二值化,再闭运算把断裂的垂直边缘连成整块 _, binary = cv2.threshold(edge, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return gray, closed

参数逐个说。cv2.blur的 3x3 是经验值,抓拍图噪点重可以上 5x5,但超过 5 字符笔画就开始糊。Sobel的dx=1, dy=0表示只算水平方向梯度,垂直边缘就是车牌字符的竖笔画,这是整段预处理的物理依据。HSV 阈值里 OpenCV 的 H 通道范围是 0 到 180,不是 360,蓝色落在 100 到 124 之间,黄色落在 15 到 35 之间,这两组数字在不同白平衡下要微调。THRESH_OTSU会自己算一个最佳阈值,原理是把灰度直方图分成两类,让类间方差最大,不用手填 127 这种拍脑袋的数。闭运算核(17, 5)是横向长条,因为车牌在图像里是横向拉伸的,核的长宽比要跟着走。

3.2 轮廓提取与长宽比粗筛:先把十几个候选框缩出来

二值图上的白色块就是候选区域,用findContours全部捞出来,再用长宽比和面积砍掉明显不对的。这一步的目标不是精确,是尽量不漏,宁可由后面的 CNN 误杀,也不能在这里就把真车牌筛掉。

def locate_candidates(closed, min_ratio=2.2, max_ratio=4.2): contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for c in contours: x, y, w, h = cv2.boundingRect(c) if h < 10 or w < 60: continue # 太小的直接丢,多半是噪点 ratio = w / float(h) area = w * h # 标准车牌 440x140,长宽比约 3.14,透视变形留出上下余量 if min_ratio <= ratio <= max_ratio and area > 1200: boxes.append((x, y, w, h)) return boxes

min_ratio=2.2和max_ratio=4.2这个区间的来历是:中国小型汽车车牌 440x140 毫米,比值 3.14;斜拍时横向会被压缩,比值可能掉到 2.4 左右;正面略俯拍时可能拉到 3.8。区间开到 2.2 到 4.2 能覆盖绝大多数角度,但也会放进一些护栏和广告牌,所以后面必须有一道 CNN 过滤。area > 1200是像素面积下限,对应图像里车牌至少占 60x20 左右,再小的话字符分割出来宽度不足 5 像素,识别网络根本认不出。

3.3 漫水填充补全与仿射校正:把歪的车牌掰正

候选框里有一部分是残的,比如左半边被前车挡住,或者边缘被闭运算吃掉一块。漫水填充的作用是从框中心取一个种子点,把颜色相近的连通区域填满,能补回残缺的部分;补完后再算一次外接矩形,尺寸不对的候选就被二次排掉了。最后一步是把矩形框做透视变换拉成标准 128x36,后面的网络只认这个尺寸。

def flood_complete(bgr, box): x, y, w, h = box roi = bgr[y:y + h, x:x + w].copy() mask = np.zeros((h + 2, w + 2), np.uint8) # 掩模要比原图宽高各多2 seed = (w // 2, h // 2) # 固定范围模式,容差 20,容忍光照不均但不至于漫到车身 cv2.floodFill(roi, mask, seed, (0, 255, 0), (20, 20, 20), (20, 20, 20), cv2.FLOODFILL_FIXED_RANGE) return roi, mask def warp_plate(bgr, box): x, y, w, h = box src = np.float32([[x, y], [x + w, y], [x + w, y + h], [x, y + h]]) dst = np.float32([[0, 0], [128, 0], [128, 36], [0, 36]]) M = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(bgr, M, (128, 36))

掩模尺寸必须是(h + 2, w + 2),这是floodFill的硬性约定,少一个像素就会抛异常,第一次写的人基本都在这翻过车。容差(20, 20, 20)对应 BGR 三通道各自的允许偏差,晚上光线暗的时候可以放宽到 35,但放到 60 以上就会从车牌漫到保险杠。透视变换的四个目标点固定成 128x36,和网络输入对齐,后面就不用再 resize,避免二次插值损失。

3.4 定位 CNN 兜底:二分类网络把误检压下去

候选框做完几何过滤后,剩下的十几个里面可能只有一个是真车牌。把每个候选裁出来统一缩放到 36x128,送进二分类网络,取 softmax 概率最大且超过阈值的那个。训练数据就是正负样本两个文件夹,正样本是人工裁出来的车牌,负样本从候选框里随机抠,比例大概 1:3,负样本多一些模型才不会见谁都说是车牌。

推理时不要只取最大概率,要加一个置信度下限,比如 0.85。低于这个值的候选全部丢掉,返回空结果。宁可漏检也别误检,因为误检会把后面的字符分割带到一个完全不相关的区域,输出一串莫名其妙的字符,比直接说「没找到车牌」难排查得多。

4. 字符分割与 67 类识别:投影切分加三卷积网络

定位拿到一张 128x36 的车牌图之后,还有两件事:把七个字符切成七张小图,再逐个送网络分类。这一步的难点不在网络,在切分,切歪一个字符后面全错位。

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

车牌四周有一圈白边和铆钉,直接切会把边框当成字符。水平投影统计每一行的白点数量,白点少的行就是上下边框,砍掉;垂直投影统计每一列的白点数量,字符之间必然有一段空白列,白点数接近零,按这个断点切。

def split_chars(binary_plate): h, w = binary_plate.shape # 水平投影:行方向白点数,少于总宽 20% 的算边框 row_sum = np.sum(binary_plate > 0, axis=1) rows = np.where(row_sum > w * 0.2)[0] if len(rows) == 0: return [] top, bottom = rows[0], rows[-1] cropped = binary_plate[top:bottom + 1, :] # 垂直投影:列方向白点数,阈值按字符高度动态算 col_sum = np.sum(cropped > 0, axis=0) cols = np.where(col_sum > (bottom - top) * 0.15)[0] chars, start = [], cols[0] for i in range(1, len(cols)): if cols[i] - cols[i - 1] > 2: # 间隔超过2列认为是字符缝隙 chars.append(cropped[:, start:cols[i - 1] + 1]) start = cols[i] chars.append(cropped[:, start:cols[-1] + 1]) # 宽度小于5像素的碎片丢掉,正好排掉第2、3字符之间的圆点 return [c for c in chars if c.shape[1] > 5]

阈值0.2和0.15都是相对值,不能写成固定像素。车牌在画面里可大可小,如果按固定像素判,小图切不出东西,大图切出十几段。最后那个shape[1] > 5的过滤很关键,新能源车牌第二位和第三位之间有个圆点分隔符,投影上会形成一个很窄的连通块,宽度过滤能自然把它排掉,比专门写规则识别圆点省事。

4.2 三卷积三池化三层全连接:参数都花在哪了

网络结构就是 2.2 节build_net里那套,这里把每一层的输出尺寸和参数量摊开,方便判断该砍哪里。

层输出尺寸参数量
Conv1 3x3x3x3236x128x32896
Pool1 2x218x64x320
Conv2 3x3x32x6418x64x6418496
Pool2 2x29x32x640
Conv3 3x3x64x1289x32x12873856
Pool3 2x24x16x1280
FC1 102410248389632
FC2 102410241049600
FC3 102410241049600
输出层2 或 672050 / 68675

卷积部分加起来不到十万参数,全连接部分占了九百多万。这个分布意味着两件事:模型体积几乎全在 FC 层,想压缩就砍 FC;卷积层参数量少但计算量大,推理慢的瓶颈在卷积不在全连接。如果目标机器是低配笔记本,把三层 1024 改成 256、把第一次池化改成 3x3,速度能提一大截,字符识别率掉两三个点,对课程演示完全够用。

4.3 训练配置:学习率 0.001、交叉熵、批量 64

训练这块没什么花活,优化器用 Adam,学习率 0.001,损失函数交叉熵,标签做 one-hot。原文提到迭代 1000 次,实际跑的时候 1000 个 epoch 大概率会过拟合,正确做法是加早停和验证集。

import tensorflow as tf model = build_net(num_classes=67) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss='categorical_crossentropy', metrics=['accuracy'] ) callbacks = [ tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True), tf.keras.callbacks.ModelCheckpoint('weights/char_recog.h5', save_best_only=True) ] model.fit(x_train, y_train, batch_size=64, epochs=1000, validation_split=0.1, callbacks=callbacks)

learning_rate=1e-3是 Adam 的常用起点,损失曲线在前 20 个 epoch 没明显下降就说明数据有问题,不是学习率的事。patience=15表示验证损失连续 15 轮不降就停,restore_best_weights=True会自动回滚到最好的那一版权重,不用自己记轮次。数据进网络前必须除以 255 归一化到 0 到 1,这一步漏了的话损失会卡在 4.1 附近不动,是最常见的翻车原因之一。

4.4 67 维向量拼回字符串:类别表和置信度都要留

67 类的构成是省份简称加字母加数字。推理时对每张小图取概率最大的下标,查表转成字符,同时保留置信度用于后续校验。

PROVINCES = '京津沪渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新' CHARS = PROVINCES + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' def recognize_char(char_img, model): img = cv2.resize(char_img, (128, 36)).astype('float32') / 255.0 img = np.stack([img] * 3, axis=-1) # 单通道复制成三通道,对齐输入 probs = model.predict(img[None, ...], verbose=0)[0] idx = int(np.argmax(probs)) return CHARS[idx], float(probs[idx])

注意np.stack([img] * 3, axis=-1)这一步,投影切出来的是单通道二值图,网络输入是三通道,不补通道会直接报维度不匹配。另外CHARS的顺序必须和训练时生成标签的顺序完全一致,训练脚本和推理脚本共用一个常量文件,别各写一份。

5. 跑通与排查:环境、GUI 事件和五个必踩的坑

代码能跑和项目能用之间隔着一段距离,这段距离基本都由环境、界面事件和阈值组成。

5.1 依赖安装与最小运行环境

这套系统依赖很轻,OpenCV 加 TensorFlow 加 Pillow 就够,GUI 用 tkinter,Python 自带。

python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install opencv-python==4.8.1.78 pip install tensorflow==2.13.0 pip install pillow numpy

版本别乱跳。OpenCV 4.5 之后findContours的返回值从三个变成两个,网上抄的旧代码直接报解包错误。TensorFlow 2.13 之后tf.keras独立成包,from tensorflow.keras import layers在某些版本要改成from keras import layers。虚拟环境一定要建,多个项目共用全局环境时 TensorFlow 版本冲突能折腾一下午。

5.2 GUI 三个按钮的事件绑定

界面按原文设计是 600x600 的窗口,右下角三个按钮:打开图片、打开相机、展示图片。按钮不用铺满,用place按相对坐标摆右下角就行。

import tkinter as tk from tkinter import filedialog import cv2 from PIL import Image, ImageTk root = tk.Tk() root.geometry('600x600') panel = tk.Label(root) panel.place(x=20, y=20, width=560, height=420) def show_frame(bgr): rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) img = ImageTk.PhotoImage(Image.fromarray(rgb).resize((560, 420))) panel.configure(image=img) panel.image = img # 必须持有引用,否则图片被回收显示空白 def open_file(): path = filedialog.askopenfilename( filetypes=[('图片', '*.jpg *.png *.bmp')]) if not path: return bgr = cv2.imread(path) show_frame(bgr) # 这里接 pipeline(bgr),把识别结果写到下方标签 tk.Button(root, text='打开图片', width=20, height=5, command=open_file).place(x=120, y=480) tk.Button(root, text='打开相机', width=20, height=5, command=open_camera).place(x=340, y=480) tk.Button(root, text='展示图片', width=20, height=5, command=show_result).place(x=470, y=480) root.mainloop()

panel.image = img这行是 tkinter 的经典坑,不给 Label 挂一个引用,图片对象会被垃圾回收,界面显示一片空白但程序不报错,排查起来毫无头绪。相机那条线不要在主线程里写while True: ret, frame = cap.read(),会把mainloop堵死,窗口拖都拖不动。正确做法是用root.after(30, update)递归调度,每 30 毫秒读一帧。

5.3 五条血泪踩坑记录:现象、原因和解决

现象一:蓝色车牌在隧道或地库里完全检不出,debug 图里边缘掩模全黑。原因:HSV 阈值是按白天正常白平衡定的,暗光下饱和度 S 掉得很低,inRange直接把整片区域判成非蓝。 解决:把 S 通道下限从 43 降到 20 左右,或者干脆在低照度场景关掉颜色掩模,只走 Sobel 加形态学。更好的做法是先用cv2.createCLAHE做一次直方图均衡再进 HSV。

现象二:点开相机后窗口卡死不响应,任务管理器显示 CPU 占满。原因:相机读取循环写在了按钮回调里,同步阻塞了 tkinter 的事件循环。 解决:改成root.after定时拉帧,或者起一个独立线程读帧写进队列,主线程只负责从队列取最新一帧渲染。

现象三:训练时损失一直在 4.1 附近不动,准确率跟随机猜一样。原因:图片像素值 0 到 255 直接喂进网络,没有归一化,梯度爆炸或消失。 解决:在ImageDataGenerator里加rescale=1./255,或者手动除以 255。顺便检查标签是不是 one-hot,categorical_crossentropy配整数标签也会出这个问题,要么换sparse_categorical_crossentropy。

现象四:字符切出来 5 段或 8 段,识别结果整体错位。原因:垂直投影用了固定像素阈值,车牌在画面里偏小或偏大时阈值失效,粘连的字符切不开,或者一个字符被切成两半。 解决:阈值改成相对值,按字符高度乘以 0.15 动态计算。切完之后再按平均宽度过滤一遍,宽度不到平均值一半的碎片合并回相邻块。

现象五:数字和字母识别率九成以上,省份汉字只有六七成。原因:训练集里汉字样本集中在少数几个省份,且不同车牌的汉字字体差异大,长尾问题明显。 解决:按类别做均衡采样,每个省份至少凑够同等数量;对汉字小图单独做数据增强,加轻微旋转、缩放、亮度扰动。字符集的 67 类里如果混进了字母 I 和 O,还要确认训练时有没有把它们和数字 1、0 混淆标注,这类标注噪声对汉字类别的影响最直接。

6. 二次开发:换底色、换主干、加视频流的三条改造路径

拿到这套代码之后,最常被问的三个改造方向是:支持更多车牌底色、把模型换小、从单图识别扩到视频流。三条路的改动量差别很大,先看下面这张表。

改造方向主要改动文件改动量风险点
增加绿色新能源车牌config.py加 HSV 区间,preprocess.py加一路掩模半天绿牌 8 位字符,分割和 67 类表都要动
换成 MobileNet 主干recognize/model.py换 backbone一天输入尺寸要重新对齐,权重得重训
单图扩到视频流main_gui.py加定时器,pipeline.py加跳帧半天每帧都跑 CNN 会掉帧,要做间隔推理

绿色车牌这条最容易上手,也最容易翻车。绿牌的底色 HSV 大概在 H 35 到 85 之间,加一路inRange和蓝黄做bitwise_or就行。但新能源车牌是 8 位字符,第一位省份,第二位字母,后面六位字母数字混排,split_chars切出来的段数会变成 8,字符类别表也要加长。如果只是演示,可以先把 8 位图缩放成 7 位硬凑,正式用就得老老实实改分割逻辑。

换主干这条,思路是把build_net里的三层卷积替成 MobileNetV2 的前置层,include_top=False,后面接自己的全连接。输入尺寸建议保持 36x128 不变,因为预处理和透视变换全是按这个尺寸写死的,改尺寸要连带改warp_plate的目标点和分割后的 resize,牵一发动全身。换完主干必须重训,别指望加载旧权重,形状对不上会直接抛错。

视频流改造的关键是别每帧都推理。抓拍场景里车牌在画面中停留至少半秒,按 15 帧每秒算有七八帧可用,每隔三帧跑一次完整流水线,中间帧只做显示,帧率能从个位数拉回二十以上。识别结果做一次简单的多数投票,连续三帧输出相同才认为有效,能压掉不少闪烁。

我自己的习惯是每改一个模块,先把该模块的中间结果单独 dump 成图片看一眼,确认这一段的输入输出没问题再接下一段。之前图省事跳过这步,直接改了三处阈值一起跑,结果输出全是乱码,回头一个个二分排查花了大半天。从那以后我每次动这类多阶段流水线,都强制先把每一阶段的中间图落到本地目录再调参,不再凭感觉改数字。这份讲解用的 PPT 把选题背景、技术特点与难题、实验方案与原理、结果与界面展示这几块串成了一条线,配合上面这套代码一起看,定位和识别两段各自在做什么会比较清楚,需要拿去改的地方也都标出来了。希望帮到你。

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

RELATED READING

延伸阅读

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