简介面向正在学习车牌识别与图形界面开发的中级实践者这份资源围绕车牌识别系统的界面搭建与识别结果可视化展开提供可直接对照运行的 Python 工程与配套样本数据适用于停车场管理、交通监控等场景的原型验证与课程设计。压缩包共 2002 个文件、约 22.95MB主体为 1995 张 jpg 车牌与字符区域样本图另含 2 个 py 脚本界面逻辑与识别流程、1 个 7z 样本归档及少量 txt、js 辅助文件数据与代码分层明确便于按模块查阅已有 174 人学习下载。界面设计侧重图片上传、识别进度反馈与结果展示所附样本数据可用于调试字符分割、颜色判断等环节读者可据此复现完整交互流程理解模型输出如何转化为车牌框选与字符高亮并参考无车牌、模糊、遮挡等异常情形下的提示设计快速搭建一个可演示、易操作的车牌识别界面。1. 一张歪着拍的入口照这套 python 车牌识别 UI 能跑到什么程度地下车库入口的相机装在 1.8 米高的位置车一停车牌是斜的地面补光灯还会在漆面上反出一片白。拿这种图去试手头的开源 demo十有八九卡在第一步——定位框飘到保险杠或者中网上去了。这套「车牌识别系统 UI 界面设计及显示」的源码解决的正是从一张原图到界面上出结果这一整条链路python 做调度OpenCV 负责定位和字符切分机器学习模型负责单字符分类最后把原图、定位框、识别文本和耗时一起显示在桌面窗口里。它适合两类人一类是做课程设计、需要能现场演示的完整闭环另一类是模型已经跑通、但一接摄像头界面就假死的开发者。往下不聊算法多先进只聊每个参数为什么这么给、界面怎么不阻塞、哪里最容易翻车。2. 车牌定位与字符分割HSV 掩膜加垂直投影把整图切成 7 个字符块整条流水线里最容易让人产生「模型不行」错觉的其实是定位这一步。识别错了多数时候不是分类器的问题而是送进分类器的图根本不是车牌——可能是中网、可能是保险杠上的一块蓝漆。所以这一章把定位和切分拆开讲清楚先把候选框框准再谈切得对。2.1 为什么先用颜色分割而不是直接上检测模型车牌本身有很强的先验底色统一、宽高比固定、在画面里是一块高饱和度的矩形。这三点决定了传统方法在这个任务上并不吃亏尤其是样本量只有几百张、又不想花时间标注的时候。颜色分割的好处是可解释。掩膜为什么空、轮廓为什么被过滤掉中间每一步都能cv2.imwrite存下来看调参有明确方向。检测模型的坏处是黑匣子框偏了你只能猜是 anchors 的问题还是样本分布的问题对课程设计这类需要写清楚每一步在干什么的场景不友好。但边界也要说清楚。颜色分割在均匀光照下召回很高遇到夜间、逆光、牌照脏污或者泥点遮挡掩膜会碎成好几块这时候再怎么做形态学也救不回来。常见的补法是加一路边缘检测Canny 之后做同样的闭运算两路掩膜取并集或者按轮廓投票谁的候选框填充率更高就用谁。如果手上的样本能到几千张、还带标注直接换成轻量检测器是更省心的选择这套代码的分层结构也留了这个口子——定位函数返回的是(x, y, w, h)换实现不影响下游。我一般会保留颜色分割这条路做兜底因为它对硬件要求低纯 CPU 跑 720p 单帧定位在几十毫秒量级界面预览不会卡。2.2 定位函数的参数账HSV 区间、核尺寸、面积与宽高比下面这段是定位的核心参数看着多其实每个都对应一个具体的失败场景。import cv2 import numpy as np # 常见车牌底色的 HSV 区间按需切换蓝牌用得最多 HSV_RANGES { blue: ((100, 90, 90), (124, 255, 255)), green: ((35, 43, 46), (77, 255, 255)), yellow:((15, 80, 80), (35, 255, 255)), } def locate_plate(img_bgr, colorblue, min_area2000, max_area60000, ratio(2.2, 5.5), fill_thr0.45): 返回按面积降序排列的候选框列表以及中间掩膜方便调试落盘 hsv cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) lower, upper [np.array(v, dtypenp.uint8) for v in HSV_RANGES[color]] mask cv2.inRange(hsv, lower, upper) # 横向长核车牌是扁的横向闭运算能把字符之间的缝隙连成整块 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations1) found cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours found[0] if len(found) 2 else found[1] # 兼容 OpenCV 3/4 boxes [] for c in contours: x, y, w, h cv2.boundingRect(c) area w * h if not (min_area area max_area): continue if not (ratio[0] w / float(h) ratio[1]): continue # 填充率真实车牌轮廓接近实心矩形格栅、饰条往往很稀疏 if cv2.contourArea(c) / float(area) fill_thr: continue boxes.append((x, y, w, h)) boxes.sort(keylambda b: b[2] * b[3], reverseTrue) return boxes, mask逻辑上分四段颜色掩膜、形态学、轮廓筛选、排序。参数怎么给逐个说。lower/upper这三个区间是国内常见底色的经验值蓝色区间最容易受白平衡影响白天偏青、晚上偏紫都会掉出范围。稳妥做法是把S的下限从 90 降到 70 左右代价是天空和蓝色车身也会被框进来靠后面的宽高比和填充率再筛掉。核尺寸(17, 5)是横向长条因为车牌宽度通常是高度的三倍以上。核太小字符之间的空隙连不上一个车牌会被切成五六个小轮廓核太大相邻的两辆车牌会粘成一块。720p 输入下 17 是个比较稳的起点换成 1080p 要按比例放大到 25 左右。min_area/max_area用来卡掉噪点和整块背景。720p 下 2000 像素大约对应画面里 55×36 的矩形比这个还小的基本是反光点。ratio取 2.2 到 5.5 是留了透视余量的标准蓝牌宽高比约 3.14但侧拍时会被压扁或拉长。fill_thr是我加的一道保险。只按面积排序很容易把前脸的镀铬饰条选成第一候选——它面积不小、形状也偏长条。加上填充率之后实心车牌基本能稳定排在前面。提示调定位参数时把mask和每个候选框都存成图片按bbox_x_y_w_h.jpg命名。看一眼就知道是掩膜碎了还是筛选条件太严比在代码里打断点快得多。2.3 垂直投影切分先裁边再找谷点定位拿到的是整块车牌识别模型要的是单个字符。中间这一步靠垂直投影把二值化后的车牌按列统计白点数量字符所在的列白点多字符之间的间隙白点少投影曲线上的「谷」就是切分点。def split_chars(plate_bgr, out_size(20, 40), pad4, min_w4, min_ratio1.2): 输入车牌小图输出归一化后的字符图列表灰度、白字黑底 gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) h, w gray.shape[:2] # 先裁边车牌自带的边框和铆钉会被当成字符pad 太小必然多切一块 gray gray[pad:h - pad, pad:w - pad] # 反向二值化让字符变白OTSU 自适应阈值比固定阈值稳 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) proj (binary 0).sum(axis0) thresh max(2, int(binary.shape[0] * 0.08)) # 低于 8% 行高视为间隙 spans, start [], None for i, v in enumerate(proj): if v thresh and start is None: start i elif v thresh and start is not None: spans.append((start, i)) start None if start is not None: spans.append((start, len(proj))) chars [] for s, e in spans: if e - s min_w: continue roi binary[:, s:e] rows np.where(roi.sum(axis1) 0)[0] if len(rows) 0: continue top, bottom rows[0], rows[-1] # 字符通常瘦高宽高比过小的多半是残留边框 if (bottom - top) / float(e - s) min_ratio: continue char roi[top:bottom 1, :] chars.append(cv2.resize(char, out_size, interpolationcv2.INTER_AREA)) return charspad4是按常见比例给的分辨率高的时候要跟着放大。thresh用行高的 8% 而不是固定值是为了适配不同的车牌高度。min_ratio1.2这个条件会带来一个副作用汉字字符接近方形宽高比大多小于 1.2会被直接筛掉。这不是 bug是取舍——如果要识别首位汉字就得给汉字单独走一条分支把阈值放宽到 0.8 左右或者干脆用一个独立的汉字分类器。多数课程设计场景只要求后 6 位这里直接跳过。2.4 字符归一化与样本落盘切出来的字符图尺寸不统一直接喂模型会出问题。统一缩放到 20×40宽×高灰度单通道字符为白。这个尺寸不是随便定的再小会丢掉「8」和「B」这种形近字的细节再大对这个小网络的收益不明显反而拖慢训练。样本落盘按照类别建文件夹一个字符一个目录dataset/ 0/ 1/ 2/ ... 9/ A/ B/ ... Z/ # 去掉 I 和 O避免和 1、0 混淆目录名就是标签训练脚本直接遍历目录名生成类别表。用文件夹而不是 CSV 的好处是加样本只需要拖文件不用改任何代码。采集的时候我一般每类先凑 80 到 150 张来源包括实拍切分、公开数据集裁剪、以及少量字体渲染后加噪声的合成样本后者用来补「D」「Q」「0」这类实拍里出现频率低的类别。3. 机器学习识别从模板匹配换到小 CNN样本和推理各要做对什么切分之后每个字符只有 20×40这个尺度上做分类可选的路其实不多。这一段说说选型以及训练脚本和推理封装里那些看起来无关紧要、实际上决定实拍效果的细节。3.1 模板匹配、SVM、小 CNN 的边界在哪模板匹配是最省事的每个类别存一张标准模板算相关系数取最大。它在印刷体、光照均匀的条件下准确率能到 90% 以上代码不到三十行。但它对形变极其敏感车牌稍微倾斜几度、字符粗细变一点「6」和「8」、「D」和「0」就开始互相抢。而且模板数量随类别线性增长后期维护很烦。SVM 是折中方案。把 20×40 的图拉平成 800 维向量用 RBF 核训练几百个样本就能收敛训练时间以秒计。它的短板是丢掉了二维结构信息对污损、断裂的字符比较脆弱。如果你只是要一个能演示的版本SVM 完全够用而且不引入深度学习框架那一堆依赖。小 CNN 是这套代码默认走的路。两三层卷积参数量不到十万CPU 上单字符推理在毫秒级。它对局部形变更鲁棒加一点旋转、模糊、亮度扰动的增强之后实拍准确率比 SVM 高出一截。代价是需要框架环境配置会麻烦一些。我的建议是按交付场景选如果只是答辩演示、样本量在千级以下SVM 足够如果要做成能长期跑的东西、还要接摄像头实时识别直接上小 CNN别在模板匹配上浪费时间。3.2 样本集组织与训练脚本训练这块最常见的错误不是网络结构而是预处理不一致。训练时用cv2.resize缩放到 20×40 并除以 255推理时却忘了归一化训练集准确率 99%、实拍掉到 60%八成是这个原因。所以归一化必须抽成一个函数训练和推理都调它。import os import cv2 import numpy as np from tensorflow import keras from tensorflow.keras import layers IMG_H, IMG_W 40, 20 CHARS 0123456789ABCDEFGHJKLMNPQRSTUVWXYZ # 去掉 I、O CHAR2ID {c: i for i, c in enumerate(CHARS)} def normalize(img_gray): 唯一的归一化入口训练和推理都必须走这里 img cv2.resize(img_gray, (IMG_W, IMG_H), interpolationcv2.INTER_AREA) return img.astype(float32) / 255.0 def load_dataset(root): xs, ys [], [] for ch in CHARS: d os.path.join(root, ch) if not os.path.isdir(d): continue for name in os.listdir(d): img cv2.imread(os.path.join(d, name), cv2.IMREAD_GRAYSCALE) if img is None: continue xs.append(normalize(img)) ys.append(CHAR2ID[ch]) x np.expand_dims(np.array(xs), -1) return x, np.array(ys) def build_model(num_classeslen(CHARS)): model keras.Sequential([ layers.Input(shape(IMG_H, IMG_W, 1)), layers.Conv2D(32, 3, paddingsame, activationrelu), layers.MaxPooling2D(), layers.Conv2D(64, 3, paddingsame, activationrelu), layers.MaxPooling2D(), layers.Flatten(), layers.Dropout(0.4), layers.Dense(128, activationrelu), layers.Dense(num_classes, activationsoftmax), ]) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) return model def train(root, epochs30, out_pathplate_cnn.h5): x, y load_dataset(root) # 增强只作用在训练集上验证集保持原始分布 aug keras.Sequential([ layers.RandomRotation(0.06), layers.RandomZoom(0.08), layers.RandomBrightness(0.15), ]) model build_model() model.fit(x, y, batch_size64, epochsepochs, validation_split0.15, callbacks[keras.callbacks.EarlyStopping(patience5, restore_best_weightsTrue)]) model.save(out_path) return modelCHARS里去掉I和O是必须的。国内车牌本身就不使用这两个字母留在类别里只会让模型在「1 和 I」「0 和 O」上反复纠缠白掉几个点准确率。这个改动很小但收益直接。validation_split0.15是按样本量估的。每类 100 张左右时留 15% 做验证刚好能看出过拟合趋势。如果样本里有大量同源图片比如同一段视频里连拍的一定要按车牌分组切分不能让同一块车牌同时出现在训练和验证里否则验证准确率虚高实拍立刻现原形。增强只用了旋转、缩放、亮度三项幅度都压得比较小。字符识别和自然图像分类不一样大角度旋转会破坏字符结构转多了反而是噪声。3.3 推理封装置信度过滤与固定位约束模型输出的是每个类别的概率直接取 argmax 会把所有位置都填满包括那些本来就是噪声的切分块。所以推理层要加两道约束。def predict_chars(model, char_imgs, min_conf0.6): 返回 [(字符, 置信度), ...]低置信度位置用 ? 占位 results [] if not char_imgs: return results batch np.expand_dims( np.array([normalize(c) for c in char_imgs]), -1) probs model.predict(batch, verbose0) for p in probs: idx int(p.argmax()) conf float(p[idx]) results.append((CHARS[idx], conf) if conf min_conf else (?, conf)) return results def format_plate(pairs, expect6): 固定位数约束不足补 ?超出按置信度截断 text .join(c for c, _ in pairs) if len(text) expect: text text ? * (expect - len(text)) elif len(text) expect: pairs sorted(pairs, keylambda x: -x[1])[:expect] text .join(c for c, _ in pairs) avg sum(c for _, c in pairs) / max(len(pairs), 1) return text, round(avg, 3)min_conf0.6是个经验阈值。低于它的字符用?占位界面上再标成橙色用户一眼就知道哪一位不可信——这比强行输出一个错误字符要好得多尤其是在演示场合被人追问「这个准不准」的时候。expect6对应的是后 6 位。如果要输出完整 7 位把首位汉字的识别单独接上然后把expect改成 7。固定位数这个约束看着土实际很管用切分多切出来一块时按置信度截断能自动丢掉最可疑的那块比在切分阶段死磕阈值稳。4. UI 界面设计与显示Tkinter 三栏布局、子线程采集与画布刷新界面这一层工作量比想象中大。识别流程本身可能两百行就写完了但要让窗口在摄像头模式下不掉帧、点按钮不假死、结果显示不串行得把线程模型想清楚。这套代码的 UI 部分按三栏拆左边控制、中间画布、右边结果表。4.1 Tkinter 和 PyQt5 怎么选Tkinter 是标准库自带不用装任何东西打包出来也小。它的短板是控件样式偏旧做复杂布局要手写grid权重高分辨率屏上字体发虚。PyQt5 控件丰富、样式表好用、图像显示性能也更好代价是依赖体积大打包容易出问题许可证上也要留意。我的判断标准很简单如果这东西要装到别人的机器上跑演示选 Tkinter装个 Python 就能启动不会在答辩现场卡在pip install上如果要长期维护、界面要做得像样选 PyQt5多出来的学习成本很快能赚回来。下面的示例用 Tkinter它的线程模型和 PyQt 的QThread signal思路是一样的迁移不难。4.2 界面骨架三栏布局与控件绑定布局用pack配合左右Frame中间画布expandTrue吃掉剩余空间。右侧结果用Treeview两列展示字段和值比一行行Label好维护。import queue import tkinter as tk from tkinter import ttk, filedialog import cv2 from PIL import Image, ImageTk class PlateApp: def __init__(self, root, recognize_fn): self.root root self.recognize recognize_fn # (img, fastFalse) - Result self.task_q queue.Queue(maxsize2) # 有界队列满了就丢帧 self.result_q queue.Queue() self.cap None self.current None self._build_ui() self.root.after(50, self._poll_result) def _build_ui(self): self.root.title(车牌识别演示) self.root.geometry(1100x620) left ttk.Frame(self.root, width180) left.pack(sideleft, filly, padx8, pady8) ttk.Button(left, text打开图片, commandself.open_image).pack(fillx, pady4) ttk.Button(left, text打开摄像头, commandself.toggle_camera).pack(fillx, pady4) ttk.Button(left, text重新识别, commandself.re_run).pack(fillx, pady4) self.info tk.StringVar(value就绪) ttk.Label(left, textvariableself.info, wraplength160).pack(fillx, pady12) self.canvas tk.Canvas(self.root, bg#1e1e1e, highlightthickness0) self.canvas.pack(sideleft, fillboth, expandTrue, padx4, pady8) right ttk.Frame(self.root, width260) right.pack(sideright, filly, padx8, pady8) self.tree ttk.Treeview(right, columns(field, value), showheadings) self.tree.heading(field, text字段) self.tree.heading(value, text值) self.tree.column(field, width90, anchorw) self.tree.column(value, width150, anchorw) self.tree.pack(fillboth, expandTrue)左栏三个按钮对应三个入口info用来显示耗时和状态。画布背景给深色是因为识别框和文字用亮绿色画上去对比度高看起来不容易糊。中间的Canvas只负责显示一张PhotoImage不直接画矩形。所有矩形、文字都在交给画布之前用 OpenCV 或 PIL 画到图上了这样缩放逻辑只有一处改起来不散。4.3 摄像头模式采集线程、队列与 after 轮询界面假死的根本原因是主线程被阻塞。cv2.VideoCapture.read()在读不到帧的时候会卡住识别一次几百毫秒也会卡住主线程一卡窗口就变白。解决办法是把这些活扔到子线程主线程只做两件事取队列里的结果刷新界面、定时把新帧放进任务队列。def toggle_camera(self): if self.cap is None: self.cap cv2.VideoCapture(0) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) self.info.set(摄像头已开启) def _worker(self): 常驻子线程只消费任务队列不碰任何 tk 控件 running True while running: try: kind, payload self.task_q.get(timeout0.3) except queue.Empty: continue if kind stop: running False elif kind image: self.result_q.put((payload, self.recognize(payload, fastFalse))) elif kind frame: self.result_q.put((payload, self.recognize(payload, fastTrue))) def _poll_result(self): 主线程定时器刷新结果 抓下一帧单次耗时必须短 try: while True: frame, res self.result_q.get_nowait() self._render(frame, res) except queue.Empty: pass if self.cap is not None and self.cap.isOpened(): ok, frame self.cap.read() if ok and self.task_q.full() is False: try: self.task_q.put_nowait((frame, frame)) except queue.Full: pass # 宁可丢帧也不让界面排队等待 self.root.after(30, self._poll_result)线程只启动一个由_worker常驻消费。任务队列maxsize2是关键摄像头每秒产生 30 帧识别速度跟不上的时候如果队列无界积压会越来越多界面显示的永远是几秒前的旧画面。限长之后满了就丢画面始终跟得上实时。after(30, ...)定的是刷新节奏30 毫秒约等于 33 帧/秒。cap.read()放在这里调用单摄像头本地设备一般 30 毫秒内返回可以接受。但如果接的是网络流读视频的设备在网络抖动时可能阻塞几百毫秒甚至更久这时必须把采集也挪到独立线程否则主线程照样卡。这是我踩过的坑后面还会提到。_worker里严格不碰任何 tk 控件只往result_q里塞数据这是 Tkinter 的硬性要求控件操作必须在主线程执行跨线程改控件在某些平台上会直接崩而且崩得毫无规律。4.4 结果叠加显示框、文字、耗时渲染函数的职责是把 BGR 帧转成能在画布上显示的图像顺便把定位框和识别文本画上去。def _render(self, frame_bgr, res): canvas_w max(self.canvas.winfo_width(), 1) canvas_h max(self.canvas.winfo_height(), 1) img frame_bgr.copy() if res is not None and res.box: x, y, w, h res.box cv2.rectangle(img, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(img, res.text, (x, max(y - 8, 12)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) pil Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) pil.thumbnail((canvas_w, canvas_h)) # 等比缩放避免撑爆窗口 self.tk_img ImageTk.PhotoImage(pil) # 必须留引用否则图会消失 self.canvas.delete(all) self.canvas.create_image(canvas_w // 2, canvas_h // 2, imageself.tk_img) self.tree.delete(*self.tree.get_children()) for k, v in [(识别结果, res.text), (置信度, f{res.conf:.2f}), (定位耗时, f{res.t_locate} ms), (识别耗时, f{res.t_recog} ms)]: self.tree.insert(, end, values(k, v)) self.info.set(f{res.text} {res.t_total} ms)self.tk_img ...这一行看着多余实际上不能省。PhotoImage如果没有被 Python 对象持有引用会被垃圾回收画布上就变成一片空白。这个现象很迷惑——代码不报错图就是不显示。cv2.putText只能渲染 ASCII 字符写中文会变成一串问号。识别结果本身是字母数字没问题但如果要在图上叠加「识别成功」「未检测到车牌本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?