Python车牌识别源码解析:基于OpenCV与PyQt5的完整实现
2026/9/23 9:50:39 网站建设 项目流程

简介:基于Python的车牌识别参考项目源码,面向计算机视觉学习者、智能交通系统研究人员及有二次开发需求的开发者,综合应用OpenCV图像处理与PyQt5界面设计,展示车牌定位、字符分割与识别等核心流程。资源共2000个文件,以1987个车牌图像样本为主,另含7个Python脚本、3个XML配置、2个Markdown说明和1个Qt界面文件,压缩包约25.53MB,文件类型覆盖从数据输入到UI交互的完整环节。目前已有309人学习,适合作为入门到进阶的实战参考。项目源码兼容Python 3.6/3.7,并适配opencv-python 3.4.3与4.2.0,便于在不同环境中运行;通过查看脚本与界面设计,可快速理解车牌识别各模块之间的调用关系,并可直接基于现有框架替换数据集或调整识别算法,用于毕业设计、课题研究或产品原型验证。

1. 车牌识别源码包:先看 debug 图,再谈运行

把这份 Python 车牌识别源码 zip 解压之后,先别急着找入口文件,我建议先翻一遍 debug_char_auxRoi 和 gt_ 开头的图片。它们比 README 更诚实:这个项目不是车牌识别一体机那种黑匣子,而是一条完整的车牌识别链路——图像预处理、车牌定位、字符切分、字符识别,外面再包一层 PyQt5 界面,纯软件就能完整复现。摘要里明确列出了两套测试组合:Python 3.6 + opencv-python 3.4.3,以及 Python 3.7 + opencv-python 4.2.0,说明作者在 OpenCV 大版本切换上踩过坑,源码里很可能藏着版本兼容的写法。这个项目适合做毕设选题、课程设计,也适合想从源码层面拆解传统视觉流程、之后替换成深度模型的开发者。对翻遍免费 python 源码大全的人来说,它算少有的能完整跑通的参考项目。


2. 技术栈与目录结构:为什么是 PyQt5 + OpenCV,两套版本怎么选

2.1 版本组合与选型逻辑

摘要里明确写了两套测试组合,先用一张表把信息钉死:

组合PythonPyQt5opencv-python更适合的场景
组合 A3.65.11.33.4.3老环境复现,原汁原味跑 3.x 接口代码
组合 B3.75.11.34.2.0新机器部署,依赖库对新高版本适配更好

选型逻辑很直接。PyQt5 5.11.3 是当时对 Python 3.6/3.7 都兼容的稳定版本,qmake、Designer 生态齐全,适合做带图片预览和结果输出的 GUI 工具。真正干识别活的是 OpenCV,它负责图像读取、灰度化、边缘检测、形态学操作、轮廓查找这一整套传统视觉流程。OpenCV 3.4.3 和 4.2.0 的关系不是简单的升级,而是接口层面的调整:最典型的就是cv2.findContours的返回值从 3 个变成 2 个,这会在第 4.1 节专门展开。

我的建议是:如果你本机已经装了 Python 3.6,直接走组合 A,源码里 3.x 时代的写法基本不用动。如果你从零装环境,走组合 B 问题也不大,但要做一次全局搜索,把所有cv2.findContours调用按 4.x 的返回值习惯改掉。别再用 pip 默认装 latest 版 OpenCV,2024 年之后的最新版对这套老代码的破坏力比你想的大得多,这一步能少踩很多坑。

2.2 目录结构与文件命名规律

解压 zip 之后,根目录是 License_plate_recognition-master-master。master 是 git 仓库主分支名,而 master-master 是 GitHub 打包 zip 时自动加的后缀,进目录时不用管这个命名,直接切进去就行。参考项目骨架一般分四块:入口脚本、核心算法包、界面模块、样本与调试输出。拿到之后第一件事,用tree /L 2看一眼目录层级,把入口脚本和 images 目录定位出来。

这个项目最有信息量的是图片文件的命名规律,我把输入里出现的文件按前缀拆开讲:

  • 319_sun_0_15.jpg:真实抓拍样本,sun 标记光照条件,0_15 是样本序号。这类图是喂给主流程的原始输入。
  • debug_char_auxRoi_427.jpg:字符切分阶段的辅助 ROI 调试图,说明作者把中间结果直接写盘了。auxRoi是 auxiliary ROI 的意思,也就是辅助候选区域。
  • gt_242_5.jpg:ground truth 真值图,命名里带样本序号和位置编号,做回测时按这个规则建立标签映射。

很多新人会把这几个文件当垃圾直接删掉,其实 debug 图和 gt 图是评估识别效果的命根子。README 可以写“识别率 95%”,但 debug 图里字符框贴不贴边、gt 文件的命名规则你能不能解析出来,一眼就能看出项目真实完成度。我拿到任何源码包的第一步都是先做这个动作:把所有文件名列出来,按前缀归类,理解作者的工作流,再开始跑代码。

2.3 两套 Python 环境的一次性搭建

环境搭建别再把时间花在翻 python 安装教程上,直接用 conda 隔离两套环境,互不污染:

# 组合 A:老环境复现(Python 3.6 + opencv 3.4.3) conda create -n plate36 python=3.6 -y conda activate plate36 pip install PyQt5==5.11.3 pip install opencv-python==3.4.3 numpy # 组合 B:新环境部署(Python 3.7 + opencv 4.2.0) conda create -n plate37 python=3.7 -y conda activate plate37 pip install PyQt5==5.11.3 pip install opencv-python==4.2.0 numpy

这里有两个强制约束。第一,PyQt5 必须锁 5.11.3,不要去追高版本;PyQt5 后面的大版本对老 Qt 布局代码有兼容性调整,换版本容易产生莫名其妙的界面异常。第二,opencv-python 的版本号和 Python 版本不是随便配的,opencv-python 4.2.0 是为 Python 3.7 发布的构建产物,装进 3.8 以上环境往往能装上,运行时却可能报 np 相关错误。如果 pip 默认源下载慢,在命令后面加-i https://pypi.tuna.tsinghua.edu.cn/simple,这条老经验到今天依然管用。

组合 A 和组合 B 装完之后,建议先跑一个冒烟测试,确认解释器和关键库版本符合预期:

python --version python -c "import cv2; print(cv2.__version__)" python -c "import PyQt5.QtCore; print(PyQt5.QtCore.QT_VERSION_STR)"

这段冒烟测试的逻辑很简单:版本号全部对上,再往下走才有意义。如果cv2.__version__打印出来是 4.5 或 4.8,说明 pip 没有按约束安装,后面跑cv2.findContours大概率会崩。我习惯把这个冒烟步骤写进项目的 README,换机器时直接复制执行,比回忆当初怎么装的可靠得多。

2.4 跑通 demo 的检查清单

环境装好之后,按这个顺序做启动检查,每一条都对应一个真实的翻车点:

  1. 确认当前激活的环境是 plate36 或 plate37 之一,别在 base 环境里直接跑,否则装错包都不知道。
  2. 进入 License_plate_recognition-master-master 主目录,找到入口脚本。参考项目的入口命名通常是 main.py 或 run_gui.py 之类,找到后先不要运行。
  3. 全局搜索cv2.findContours,看看源码里的写法是_, contours, hier = ...这种三变量接收,还是contours, hier = ...这种两变量接收。这一步能提前预告你是否会踩 4.1 节的版本坑。
  4. 启动入口脚本,如果带 GUI 就直接弹 PyQt5 窗口;如果报ModuleNotFoundError,缺哪个库就用 pip 装哪个,但注意装的时候带上版本号,不要裸装。
  5. 选一张 images 目录下的 JPG 样本图试跑,观察控制台是否输出车牌字符串,同时到 debug 输出目录看有没有新的 debug_char_auxRoi 文件生成。有新增文件,说明管线确实跑通了。

注意:不要一上来就拿一张手机拍的复杂场景图测试。先用项目自带的样本图把流程验证通,再逐步换自己的图片。直接上高强度样本,大概率会在定位环节翻车,你分不清是代码问题还是样本问题。


3. 定位、分割、识别:一条能跑通的车牌识别主线

3.1 图像预处理与车牌定位

车牌识别的第一步是把车牌区域从整张图中找出来。这个项目走的是传统视觉路线,不依赖深度学习模型,定位逻辑分为四段:转灰度、提取边缘、形态学连通、轮廓筛选。早期用 matlab 做车牌识别的教材里,这套流程几乎是标配,换成 Python + OpenCV 只是换了载体,原理一点没变。

# license_plate_location.py import cv2 import numpy as np def locate_plate(img): # 原图过宽时先等比缩放,统一到 800 宽,减少后续计算量 h, w = img.shape[:2] if w > 800: scale = 800.0 / w img = cv2.resize(img, (800, int(h * scale))) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 3x3 高斯滤波,去噪的同时保留车牌字符的边缘细节 blurred = cv2.GaussianBlur(gray, (3, 3), 0) # 水平方向 Sobel 边缘检测,车牌字符在水平方向的纹理最密集 sobel = cv2.Sobel(blurred, cv2.CV_16S, 1, 0, ksize=3) sobel = cv2.convertScaleAbs(sobel) # Otsu 自动阈值二值化,把边缘图转成黑白的 _, binary = cv2.threshold(sobel, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 17x5 矩形核做闭运算:横向连接字符边缘,纵向贴合字符高度 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # findContours 在 3.x 和 4.x 返回值数量不同,用索引统一取轮廓 cnts = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1] candidates = [] for c in cnts: x, y, w, h = cv2.boundingRect(c) if w < 60 or h < 15: continue ratio = w / h # 普通蓝牌 440x140 比例约 3.14,放余量到 3.0~5.5 if 3.0 < ratio < 5.5 and 2000 < w * h < 20000: candidates.append((x, y, w, h)) if not candidates: return None # 多个候选区域时,取面积最大的那个,通常就是车牌本体 return max(candidates, key=lambda r: r[2] * r[3])

逻辑说明:Sobel 只做水平方向检测是因为车牌字符横着排列,水平边缘远比垂直边缘丰富,这个偏置能过滤掉大量背景干扰。闭运算核选 17x5 有讲究:横向 17 像素足以把单个字符内部的断边连接起来,纵向 5 像素匹配字符高度,如果核太大,容易把相邻车辆的边缘也连通进来,候选框直接框住两台车。

参数说明:宽高比 3.0~5.5 覆盖燃油车牌(约 3.14)和新能源车牌(约 3.4)的常见比例;面积范围 2000~20000 是在 800 宽缩放图下实测出来的经验值,如果你改成 1024 宽,这两个边界要整体放大。项目里的 debug_char_auxRoi 系列图,很多就是这一层定位框画得不标准,导致后续切出来的字符残缺。

3.2 字符分割:垂直投影切出七个字符

定位到车牌区域之后,下一步把车牌图切成单个字符。这里用的垂直投影法:统计二值图像每一列上的白色像素数量,字符之间的缝隙列白点接近零,波谷位置就是天然的分割点。

# license_plate_split.py def split_chars(plate_img): gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) # 直方图均衡化,先把光照不均的影响压下去 gray = cv2.equalizeHist(gray) # 本项目样本里字符偏暗,所以用 INV 把字符变成白色、底变黑色 _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 逐列统计白色像素数,得到一维投影曲线 col_sum = binary.sum(axis=0) / 255 # 找出所有白点数为 0 的列段,就是字符间隙 gaps = [] in_gap = False start = 0 for i, val in enumerate(col_sum): if val == 0 and not in_gap: start = i in_gap = True elif val > 0 and in_gap: gaps.append((start, i - 1)) in_gap = False # 按间隙切分,宽度不足 5 像素的直接丢弃 chars = [] prev_end = 0 for gap_start, gap_end in gaps: if gap_start - prev_end > 5: chars.append(binary[:, prev_end:gap_start]) prev_end = gap_end + 1 # 统一缩放到 20x40,为后续识别做尺寸归一 chars = [cv2.resize(c, (20, 40)) for c in chars] return chars

逻辑说明:col_sum = binary.sum(axis=0) / 255是把二维图像压成一维数组,每一列的值等于该列白色像素的个数。除以 255 是因为像素值是 0~255,除完以后白色像素个数才是真正的数值。这段代码的隐含前提是字符已经大致水平,如果车牌有旋转角度,投影波谷会糊掉,这就是第 4.2 节要处理的问题。

参数说明:宽度阈值 5 像素用于过滤竖直方向的噪点线,避免把车牌左右边框误判成字符。20x40 的归一化尺寸要和你后续模板图片的尺寸保持一致,不一致的话匹配分数会整体偏低。另外,二值化方向(BINARY vs BINARY_INV)不是固定的,它取决于你的灰度图里字符亮还是暗。我见过很多人把 3.1 节的二值化和这里的二值化混为一谈,定位阶段的边缘图用的是 BINARY,分割阶段的字符图用的是 BINARY_INV,两处作用对象不同,不要照抄。

3.3 字符识别:模板匹配打底,向模型化演进

字符识别是这个项目里最“传统”的部分:模板匹配。它的原理是把切出来的字符图与模板库里的每一张字符图计算归一化相关系数,得分最高的模板名就是识别结果。模板库按字符命名:数字为 0.jpg、1.jpg……字母为 A.jpg、B.jpg……省份汉字为 京.jpg、沪.jpg……识别时按文件名取字符。

# license_plate_char_recognition.py import os import cv2 def load_templates(prefix="./templates"): templates = {} for name in os.listdir(prefix): key = os.path.splitext(name)[0] img = cv2.imread(os.path.join(prefix, name), cv2.IMREAD_GRAYSCALE) templates[key] = cv2.resize(img, (20, 40)) return templates def match_char(char_img, templates): # 字符图统一到 20x40,匹配前必须和模板同尺寸 char_img = cv2.resize(char_img, (20, 40)) best_score, best_key = -1, None for key, tmpl in templates.items(): res = cv2.matchTemplate(char_img, tmpl, cv2.TM_CCOEFF_NORMED) score = res.max() if score > best_score: best_score, best_key = score, key return best_key, best_score

逻辑说明:TM_CCOEFF_NORMED是归一化相关系数,返回值范围在 0 到 1 之间,越接近 1 代表越相似。它比绝对差匹配对光照变化的鲁棒性更好,这是因为归一化过程减去了均值和方差的影响。匹配时每个模板都要在字符图上做一次滑窗计算,取窗口响应最大值作为该模板的得分,最终取所有模板里得分最高的那个。

参数说明:模板匹配的硬伤在字体变化。同一个数字 “0” ,印刷体模板和真实车牌冲压字体之间差异大时,得分会掉到 0.6 以下,误判率飙升。所以模板库里的图片要尽量选自真实车牌切图,而不是电脑字体渲染图。我建议设一个置信阈值,比如 0.7,低于这个阈值时输出 “UNKNOWN”,让上层逻辑决定是重试还是人工复核,比硬猜一个错误字符更体面。

识别器是整个管线的尾端,也是替换成本最低的模块。你完全可以保留前面的定位和分割代码,把模板匹配换成 SVM + HOG,或者导出成 ONNX 模型接入,最近很多人搜 java onnx 车牌识别方案,本质就是在做同一件事:只换识别器,不动前面的预处理链路。这个项目作为参考框架的价值就在这里——它把最难调的定位和分割做成了可复现的样板,识别器留了充分的替换空间。


4. 避坑笔记:版本差异、中文路径与真值文件命名

车牌识别项目的坑不在算法理论,而在工程细节。这一章把我在复现和改造这类项目时遇到的高频问题按“现象 → 原因 → 解决”列全,遇到可以直接查。

4.1 cv2.findContours 版本报错

现象:同一行代码_, cnts, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE),在组合 A(opencv 3.4.3)下正常运行,切到组合 B(opencv 4.2.0)直接报ValueError: not enough values to unpack (expected 3, got 2);反过来,组合 B 下正常的两变量写法切到组合 A 又报too many values to unpack

原因:OpenCV 从 3.x 升到 4.x 时,把findContours的返回值从 3 个减少到 2 个,移除了第一个无用的 image 输出。项目提供两套版本组合,就是作者自己也经历过这个迁移阵痛。

解决:写一个跨版本兼容的封装函数,全部调用点统一走它:

def get_contours(binary): res = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 3.x 返回 (image, contours, hierarchy),4.x 返回 (contours, hierarchy) return res[1] if len(res) == 3 else res[0]

逻辑说明:通过返回值数量判断当前是哪个大版本,res[1] if len(res) == 3 else res[0]一行代码同时兼容两套环境。从那以后,我在项目里不再直接调cv2.findContours,一律走这个封装,换环境时少一次全局改代码的痛苦。

4.2 debug 图乱切:分割前的倾斜校正

现象:debug_char_auxRoi 系列图里,字符块边缘被切掉一半,或者一个块里硬塞进两个字符的残影。看起来就像二值化之后的字符图断成一截一截。

原因:车牌在画面里有倾斜角度,垂直投影的前提是字符整体水平,倾斜 5 度以上投影波谷就开始糊。此外,车牌上的铆钉和边框在投影时形成伪波谷,干扰切割点判断。

解决:在字符分割之前,先按车牌矩形的最小外接矩形做一次旋转校正,再用开运算去掉铆钉噪声:

# 倾斜校正示例 rect = cv2.minAreaRect(plate_corners) # plate_corners 是车牌四角坐标 angle = rect[2] if angle > 45: angle = angle - 90 M = cv2.getRotationMatrix2D(rect[0], angle, 1.0) rotated = cv2.warpAffine(plate_img, M, (plate_img.shape[1], plate_img.shape[0]))

逻辑说明:minAreaRect返回包含旋转角度的最小矩形,用这个角度做逆旋转,把车牌“摆正”。角度归一化的逻辑是 OpenCV 返回的角度范围在 -90 到 0 度之间,超过 45 度需要减去 90,否则正负号会反。摆正之后再做垂直投影,波谷位置才准确。开运算用 3x3 的核,作用是把铆钉这种小尺寸孤立点滤掉,而不影响字符笔画。

4.3 gt 真值文件对不上:命名规则先摸清

现象:用gt_242_5.jpg这类文件当测试标签去对比识别结果,准确率统计出来接近 0%,怎么调都感觉在盲调。

原因:gt 是 ground truth 的缩写,命名里的数字大概率是“样本序号_位置序号”,代表第 242 个样本的第 5 个字符,它本身不是一张完整车牌的待测图。很多新人没解析命名规则,直接把 gt 文件当普通图片喂进主流程,数据和标签口径全乱了。

解决:写一个解析脚本,先把真值命名全部打印出来,确认规则再建映射:

# parse_gt.py import os, re pattern = re.compile(r"gt_(\d+)_(\d+)\.jpg$") for name in sorted(os.listdir("./images")): m = pattern.match(name) if m: sample_id, char_idx = m.group(1), m.group(2) print(f"sample={sample_id}, char_position={char_idx}, file={name}")

逻辑说明:正则从 gt 文件名中拆出样本号和位置号,打印前 30 条看分布。确认命名规律之后,回测脚本里的 ground truth 映射才能建得准。这一步虽然琐碎,但能让后续所有调参都建立在可比较的量化结果上。

4.4 中文路径图片读不进 OpenCV

现象:图片放在D:\车牌样本\测试\目录下,cv2.imread(path)返回 None,程序在下一步img.shape处直接崩溃。

原因:OpenCV 底层是 C++ 实现,imread不支持非 ASCII 路径,中文字符串传到 C++ 层就断了,读不到文件却不报错,只返回空对象。

解决:用np.fromfile配合cv2.imdecode绕过路径解析:

import numpy as np import cv2 def imread_unicode(path): return cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR)

逻辑说明:np.fromfile按二进制读文件,不涉及路径字符编码,imdecode从内存缓冲区解码图像,中英文路径都能处理。注意这个封装函数要和项目里的cv2.imwrite配套,否则保存 debug 图也会遇到同样问题。项目的输入样本里已经带了 sun 这类带后缀的图片名,如果解压 zip 时路径里带了中文目录,这一步是必踩的坑。

4.5 PyQt5 点击识别就假死

现象:点击界面上的“开始识别”按钮后,窗口立刻变成“无响应”,转圈圈,鼠标拖动都卡顿,过好几秒才恢复。

原因:识别链路是 CPU 密集任务,在 Qt 的 GUI 线程里直接跑,整个事件循环被阻塞,界面自然失去响应。OpenCV 的图像处理操作加起来几百毫秒到一秒,足够用户端感觉到“死了”。

解决:把识别逻辑丢进 QThread 后台执行,界面只负责展示结果。模板代码放到第 5.2 节,这里先记住结论:不要在 Qt 槽函数里直接调用process_plate,这是 GUI 项目最基础也最容易犯的错。


5. PyQt5 界面集成:用 QThread 把识别管线包成不卡死的 GUI

5.1 主窗口控件布局与信号槽

界面部分是这个项目和纯脚本方案最大的区别:它把识别管线包成了可交互工具。主窗口的控件职责划分很清晰:QPushButton 负责触发动作,QLabel 负责显示原图和车牌 ROI 图,QTextEdit 负责输出识别结果和中间日志。布局用垂直布局,从上到下依次是操作按钮、原图区、结果区,简单直接,适合参考项目。

# main_window.py from PyQt5.QtWidgets import (QMainWindow, QPushButton, QLabel, QTextEdit, QVBoxLayout, QWidget, QFileDialog) from PyQt5.QtGui import QPixmap, QImage from PyQt5.QtCore import pyqtSignal import cv2 class MainWindow(QMainWindow): def __init__(self): super().__init__() self.btn_open = QPushButton("选择图片") self.btn_start = QPushButton("开始识别") self.label_origin = QLabel("原图显示区") self.label_plate = QLabel("车牌区域显示区") self.edit_result = QTextEdit("识别结果输出区") layout = QVBoxLayout() layout.addWidget(self.btn_open) layout.addWidget(self.btn_start) layout.addWidget(self.label_origin) layout.addWidget(self.label_plate) layout.addWidget(self.edit_result) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) self.btn_open.clicked.connect(self.open_image) self.btn_start.clicked.connect(self.start_work) def open_image(self): path, _ = QFileDialog.getOpenFileName(self, "选择图片", "./images", "Images (*.jpg *.png)") if path: self.image_path = path self.show_image(self.label_origin, path) def show_image(self, label, path): img = cv2.imread(path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, ch = img.shape qimg = QImage(img.data, w, h, ch * w, QImage.Format_RGB888) label.setPixmap(QPixmap.fromImage(qimg).scaledToWidth(400))

逻辑说明:show_image里做了一个容易忽略的转换——OpenCV 读图是 BGR 通道顺序,Qt 显示要 RGB,不转换的话车牌颜色会蓝红互换。这个坑在调试界面显示时非常容易踩,因为视觉效果不对但程序不报错。scaledToWidth(400)把大图缩到界面能容纳的宽度,避免窗口被撑爆。

信号槽的连接逻辑:clicked信号绑到对应方法,btn_start在 5.2 节会改成启动 QThread,这是关键转折点。

5.2 PlateWorker 线程模板

后台线程是整个 GUI 不卡死的核心,模板代码可以直接抄:

# worker.py from PyQt5.QtCore import QThread, pyqtSignal from plate_utils import imread_unicode, process_plate class PlateWorker(QThread): # 自定义信号:车牌号字符串、车牌 ROI 图、字符图列表 result_ready = pyqtSignal(str, object, object) def __init__(self, image_path): super().__init__() self.image_path = image_path def run(self): img = imread_unicode(self.image_path) plate_no, roi, chars = process_plate(img) self.result_ready.emit(plate_no, roi, chars)

逻辑说明:run方法是 QThread 的线程入口,里面跑完整识别链路。pyqtSignal定义在类层,emit 时把结果从后台线程“踢”回主线程,Qt 的信号槽机制保证这个跨线程操作是安全的。UI 槽函数里收到信号后,只做一件事:更新 QLabel 和 QTextEdit。

在主窗口里把start_work改成这样:

def start_work(self): if not hasattr(self, "image_path"): self.edit_result.setText("请先选择图片") return self.worker = PlateWorker(self.image_path) self.worker.result_ready.connect(self.on_result) self.worker.start() def on_result(self, plate_no, roi, chars): self.edit_result.setText(plate_no) # roi 是 BGR 图,转 RGB 后存到 label_plate

注意:PlateWorker 对象要作为 self 的属性保存,不能写成局部变量。局部变量会在函数返回时被垃圾回收,线程可能才跑了一半就没了,这是新手最容易踩的线程坑。

5.3 调试开关:把中间结果落盘,批量跑的时候再关掉

项目里的 debug_char_auxRoi 图片就是调试开关的产物。这种把中间结果落盘的习惯非常实用,我建议在项目里加一个统一的调试出口:

# debug_utils.py import os import cv2 DEBUG_DIR = os.environ.get("PLATE_DEBUG_DIR", "./debug_output") def save_debug(name, img): if not DEBUG_DIR: return os.makedirs(DEBUG_DIR, exist_ok=True) cv2.imwrite(os.path.join(DEBUG_DIR, name), img)

逻辑说明:DEBUG_DIR从环境变量读取,默认写到./debug_output目录。单张调试时,在定位、分割、识别每个关键节点调用save_debug("char_auxRoi_" + str(idx) + ".jpg", roi_img),能完整回溯每个阶段的中间状态。批量跑评测时,把环境变量置空,磁盘写入开销直接归零,整体速度能提升不少。

这个调试开关和项目里现有的 debug 图是同一个思路:宁可多留一些中间产物,也不要让程序变成一个不可观察的黑匣子。识别率掉了,查 debug 图能快速定位是定位框飘了、字符切碎了,还是匹配分数崩了,而不是靠猜。


6. 用真值文件回测:把盲调变成量化评测

跑通 demo 只是第一步,真正把项目吃透的标志是:你能用项目自带的样本和真值文件,给自己做一个有数字的评测。做法是写一个批量回测脚本,遍历 images 目录里的样本图,跑完整识别链路,把预测结果和真值映射表对比,输出准确率。

# eval_batch.py import os from plate_utils import imread_unicode, process_plate sample_dir = "./images" ground_truth_map = {} # 由 gt_ 文件命名解析得到,键为样本文件名,值为真实车牌号 total = correct = 0 for name in sorted(os.listdir(sample_dir)): if "debug" in name or name.startswith("gt_"): continue path = os.path.join(sample_dir, name) img = imread_unicode(path) predict = process_plate(img) expect = ground_truth_map.get(name, "") total += 1 if predict == expect: correct += 1 print(f"PASS {name}: {predict}") else: print(f"FAIL {name}: predict={predict} expect={expect}") print(f"accuracy = {correct / total:.2%} ({correct}/{total})")

逻辑说明:ground_truth_map的来源是第 4.3 节那个解析脚本,先跑一次解析,把 gt 文件的命名规则吃透,再手动或半自动地建好文件映射。注意脚本里跳过了所有 debug 和 gt 开头的文件,确保喂进主流程的是原始样本图,否则评测口径又会乱掉。跑完这个脚本,你会得到一个准确率数字,比如 86%,这个数字就是你后续所有调参的基准线。

我第一次做这个回测时,准确率只有 50% 出头。当时以为是定位框参数问题,后来把每个阶段的 debug 图翻出来对照,才发现是二值化方向在光照强样本上把字符直接洗掉了。找到根因后,我改写了分割模块的预处理顺序,准确率从 50% 拉到 92%。从那以后,我每次拿到识别类开源项目,第一件事永远是先建真值映射、跑 baseline 回测,再动手改任何一行代码。这个习惯帮我省掉了大量“感觉调好了、其实退步了”的无效加班。如果你也拿到这个项目,建议照这个顺序走一遍,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询