☰
OpenCV笔迹识别系统毕业设计:完整工程拆包与调优指南
2026/10/7 5:33:39 网站建设 项目流程

简介:这是一套面向高校计算机相关专业学生的毕业设计完整资料,围绕OpenCV图像识别技术实现笔迹识别系统,适合正在准备毕设或需要图像处理实战项目的学习者。资源包共29个文件,包含2个Python源码文件、9个PNG与4个JPG测试素材、9个DOCX及DOC文档、1个PPTX演示文稿、2个Markdown说明和1个TXT日志,压缩包约43.92MB,覆盖从代码到文档的全流程。源码部分实现了图像灰度化、二值化、边缘检测、最大外接矩形筛选,以及基于余弦距离与直方图相似度的笔迹比对逻辑,并将运行结果输出至TXT文件;界面采用PyQt5搭建,支持文件选取、图片展示与运行触发。文档方面提供需求分析、概要设计、详细设计、数据库设计、测试文档、安装部署及软件使用说明等,可直接参考撰写论文与答辩材料。目前已有147人学习,适合需要完整项目方案与文档模板的毕业生。

1. 笔迹识别系统拆包:一份能跑通的 OpenCV 毕业设计长什么样

很多做计算机视觉方向毕业设计的同学,卡点从来不是「不会调 OpenCV」,而是手里没有一份结构完整、能跑通、文档齐全的参考工程。网上搜到的代码要么只有单个main.py,要么依赖一堆没写进 requirements 的库,跑起来直接ModuleNotFoundError: No module named 'opencv'。这份基于 OpenCV 图像识别的笔迹识别系统,恰好补上了这个缺口——它不只是一个脚本,而是一整套带源码、测试素材、13 份设计文档和演示 PPT 的完整工程包。

它的核心逻辑是:把两张手写笔迹图片做灰度化、二值化、边缘检测,提取最大外接矩形并筛选,再用余弦距离加直方图相似度做两级比对,最终输出一个相似度数值。界面用 PyQt5 搭,能选文件、能展示图片、能触发识别。适合谁?正在做图像识别方向毕业设计、需要一份可复现参考工程的同学,以及想快速理解「传统 CV 做笔迹比对」完整链路的开发者。下面我按拆包顺序,把这份资源从结构到跑通、从参数到坑,一层层讲清楚。

2. 工程结构与运行环境:先看清包里有什么再动手

2.1 目录拆解与文件职责

拿到bijishibie-master.zip之后别急着双击运行,先解压看结构。这个包的组织方式很典型,源码、素材、文档三块分得很清楚:

路径/文件类型作用
main.py源码程序主入口,PyQt5 界面与事件绑定
distinguish.py源码图像处理与相似度比对核心逻辑
1.jpg/2.jpg素材界面图标或默认展示图
测试素材/素材叶青、李国富、云烟、秦晴各两张笔迹图
全部文档/文档需求、概要、详细设计、测试、部署等 13 份
README.md/README.en.md文档中英文说明
工作日志.txt文档开发过程记录
icon.png素材窗口图标

测试素材的命名规律值得注意:每个人两张图(如叶青1.png、叶青2.png),这正好对应「同一人笔迹相似度应该高、不同人应该低」的验证需求。你拿到手第一件事,就是用这四组素材跑一遍,看同人比对和跨人比对的结果差异是否明显。

2.2 环境依赖与安装

这套工程依赖的是 Python + OpenCV + PyQt5 + NumPy 这条经典组合。常见做法是建一个独立虚拟环境,避免和你机器上已有的包打架:

# 创建并激活虚拟环境(Windows 用 venv\Scripts\activate) python -m venv venv source venv/bin/activate # 安装核心依赖,版本不必锁死,但建议 opencv 用 4.x pip install opencv-python numpy pyqt5

这里有个高频翻车点:很多人装的是opencv-python,但代码里import cv2却报ModuleNotFoundError。原因通常是装到了全局环境、而运行时用的是虚拟环境,或者反过来。判断方法很简单,在报错的那个解释器里执行pip show opencv-python,看路径是否和python -c "import sys; print(sys.executable)"输出一致。不一致就说明装错环境了。

提示:如果你的系统里同时有 Python 2 和 Python 3,务必确认pip和python指向同一个版本,否则会出现「明明装了却找不到 cv2」的玄学问题。

2.3 首次运行与界面验证

依赖装好后,直接运行主程序:

python main.py

正常情况下会弹出一个 PyQt5 窗口,包含文件选择按钮、图片展示区和运行按钮。点「打开文件」选一张测试素材,再选第二张,点运行,界面会展示处理结果并输出相似度。如果窗口一闪而过,多半是main.py里某个 import 失败,用命令行运行而不是双击,就能看到完整报错栈。

这一步的验证目标是:界面能起、能选图、能出结果。三个都过了,说明环境和代码基本匹配,可以进入下一章看核心算法到底怎么算的。

3. 图像预处理链路:灰度、二值、边缘检测与外接矩形

3.1 灰度化与二值化的参数选择

笔迹识别的第一步是把彩色图变成计算机好处理的形式。distinguish.py里的处理顺序是:读图 → 灰度 → 二值 → 边缘检测 → 找轮廓 → 生成最大外接矩形 → 筛选。先看灰度加二值这段:

import cv2 import numpy as np def preprocess(img_path): # 读取图片,第二个参数 0 表示直接以灰度模式读入 img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f"图片读取失败,检查路径: {img_path}") # 高斯模糊降噪,核大小 (5,5) 是经验值,笔迹图噪声不大时够用 blurred = cv2.GaussianBlur(img, (5, 5), 0) # 二值化:THRESH_OTSU 自动选阈值,避免手动调参 _, binary = cv2.threshold( blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU ) return img, binary

逻辑说明:IMREAD_GRAYSCALE直接读成单通道,省去手动cvtColor。高斯模糊的核大小决定降噪强度,(5,5)对扫描件级别的笔迹图足够,核太大会把细笔画糊掉。二值化这里用了THRESH_BINARY_INV,是因为笔迹通常是深色字、浅色底,反转后笔画变成白色前景,方便后续轮廓提取;THRESH_OTSU让 OpenCV 自己算阈值,比手写一个 127 稳得多。

参数怎么改:如果素材是手机拍的、光照不均,先加一步自适应阈值cv2.adaptiveThreshold效果更好;如果背景本身很干净,高斯模糊可以省掉,但省掉后边缘检测可能多出噪点轮廓。

3.2 边缘检测与外接矩形筛选

二值图之后是边缘检测和轮廓处理,这一步决定了「哪些区域会被拿去比对」:

def extract_rects(binary): # Canny 边缘检测,双阈值 50/150 是常用起点 edges = cv2.Canny(binary, 50, 150) # 找轮廓,RETR_EXTERNAL 只取最外层,避免嵌套轮廓干扰 contours, _ = cv2.findContours( edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) rects = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) # 筛选:过滤太小的噪点框和过大的整图框 if w * h < 500 or w * h > 0.9 * binary.shape[0] * binary.shape[1]: continue rects.append((x, y, w, h)) return rects

逻辑说明:Canny的双阈值里,低阈值管「弱边缘连不连」,高阈值管「强边缘起不起」。50/150 是 OpenCV 官方示例的经典比例,笔迹图一般够用。RETR_EXTERNAL只取外层轮廓,因为笔迹的笔画轮廓不需要层级关系。筛选条件里w*h < 500是去掉噪点小框,> 0.9 整图面积是去掉把整张图框住的无效矩形——这两个阈值就是「筛选不符合条件矩形」的具体落地。

参数怎么改:如果笔迹笔画很细、Canny 漏检,把低阈值降到 30;如果噪点多、框太碎,把低阈值提到 80。筛选的面积下限 500 是像素单位,图片分辨率高时要相应调大,否则会保留一堆小噪点框。

3.3 为什么用外接矩形而不是直接整图比对

这里有个设计取舍值得说清楚。直接拿两张整图做相似度,会受纸张大小、拍摄角度、留白多少影响,同一人两次写的字可能因为位置偏移就被判成不相似。先提取笔画区域的外接矩形,相当于把「有效内容」抠出来再比,抗平移能力更强。

但代价是:如果矩形提取不准(比如把两行字框成一个大矩形,或者把一个字拆成多个小矩形),后续比对就会错位。所以矩形筛选的阈值不是随便设的,它直接决定比对的基本单元。我一般会先把中间结果画出来看一眼:

# 调试用:把提取到的矩形画在原图上保存 debug = cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR) for (x, y, w, h) in rects: cv2.rectangle(debug, (x, y), (x + w, y + h), (0, 0, 255), 2) cv2.imwrite("debug_rects.png", debug)

跑一遍测试素材,打开debug_rects.png,如果红框正好套住每个笔画区域,说明参数合适;如果框得乱七八糟,先调这里,别急着往下走。这一步是后面所有相似度计算的地基。

4. 相似度判断:余弦距离加直方图的两级比对

4.1 第一级:矩形区域的余弦距离粗筛

相似度判断分两步走,第一步用余弦距离做粗筛,把明显不像的矩形对先排除掉:

def cosine_similarity(vec_a, vec_b): # 展平成一维向量并转 float,避免整型溢出 a = vec_a.flatten().astype(np.float32) b = vec_b.flatten().astype(np.float32) # 余弦相似度 = 点积 / (模长乘积),加 1e-8 防除零 denom = (np.linalg.norm(a) * np.linalg.norm(b)) + 1e-8 return float(np.dot(a, b) / denom)

逻辑说明:余弦距离衡量的是两个向量方向的一致性,对亮度整体偏移不敏感,适合做粗筛。这里把矩形区域像素展平当向量用,是最朴素的用法。1e-8是防止某个矩形全黑导致模长为零的后悔药,不加的话会出nan或除零警告。

参数怎么改:余弦相似度的阈值一般设在 0.6~0.8 之间做粗筛。设太低,粗筛没起到过滤作用;设太高,可能把真正相似的矩形也筛掉。这个值需要拿测试素材试出来,没有万能值。

4.2 第二级:直方图相似度精判

粗筛留下的矩形对,再用直方图相似度做精判,这一步才是决定「算不算相同」的关键:

def histogram_similarity(rect_a, rect_b): # 对两个矩形区域分别算灰度直方图,256 个 bin hist_a = cv2.calcHist([rect_a], [0], None, [256], [0, 256]) hist_b = cv2.calcHist([rect_b], [0], None, [256], [0, 256]) # 归一化,消除区域大小差异的影响 cv2.normalize(hist_a, hist_a, 0, 1, cv2.NORM_MINMAX) cv2.normalize(hist_b, hist_b, 0, 1, cv2.NORM_MINMAX) # 相关性比较,返回值越接近 1 越相似 return cv2.compareHist(hist_a, hist_b, cv2.HISTCMP_CORREL)

逻辑说明:直方图描述的是像素灰度分布,对笔画的粗细、浓淡变化比较敏感,正好补上余弦距离只看方向的短板。HISTCMP_CORREL返回的是相关性系数,范围大致 -1 到 1,越接近 1 越像。归一化那步不能省,否则区域大的直方图数值天然大,比较会失真。

参数怎么改:判定「相同」的阈值,摘要里写的是「相似度高于一定数值」,这个数值通常落在 0.7~0.9。设低了误判率高(不同人也判相同),设高了漏判率高(同一人也判不同)。建议拿四组测试素材各跑一遍,画一条阈值-准确率的曲线来定。

4.3 最终相似度的计算与输出

两级比对跑完,最终结果按摘要里的公式来:相同数量除以所有比较次数。

def final_similarity(rects_a, rects_b, cos_thresh=0.7, hist_thresh=0.8): same_count = 0 total_count = 0 for ra in rects_a: for rb in rects_b: total_count += 1 # 第一级:余弦粗筛 if cosine_similarity(ra, rb) < cos_thresh: continue # 第二级:直方图精判 if histogram_similarity(ra, rb) >= hist_thresh: same_count += 1 # 防除零:没有任何比较时返回 0 return same_count / total_count if total_count else 0.0

逻辑说明:双重循环把 A 图每个矩形和 B 图每个矩形都比一遍,total_count记录总比较次数,same_count记录两级都通过的次数。最终比值就是整体相似度。这个设计的好处是简单直观,缺点是计算量随矩形数量平方增长,矩形多的时候会慢。

参数怎么改:cos_thresh和hist_thresh是两个核心旋钮,建议先固定一个调另一个。另外如果矩形数量差异很大(比如一张图 5 个框、另一张 50 个框),这个比值会被拉低,可以考虑先对矩形数量做归一化,或者只取面积最大的前 N 个矩形参与比对。

4.4 结果输出到 txt

程序会把运行结果落盘,输出项包括当前时间、运行结果、程序运行状态:

import datetime def save_result(similarity, status, path="result.txt"): with open(path, "a", encoding="utf-8") as f: now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") f.write(f"时间: {now}\n") f.write(f"相似度: {similarity:.4f}\n") f.write(f"状态: {status}\n") f.write("-" * 30 + "\n")

逻辑说明:用追加模式"a"而不是覆盖模式"w",这样多次运行的结果会累积,方便对比不同参数下的表现。encoding="utf-8"是为了避免中文在 Windows 默认 GBK 下写乱码。状态字段可以填「成功」「图片读取失败」「无有效矩形」等,排查问题时一眼能看出卡在哪一步。

5. 避坑与常见问题排查

5.1 装了 opencv 却报 No module named 'cv2'

现象:pip install opencv-python显示成功,运行main.py却报ModuleNotFoundError: No module named 'opencv'或找不到cv2。

原因:最常见的是 pip 和 python 不在同一个环境。系统里可能有多个 Python,pip 装到了 A,运行时用的是 B。其次是虚拟环境没激活就装了包。

解决:先跑python -c "import sys; print(sys.executable)"确认当前解释器路径,再跑pip show opencv-python看安装位置,两者对不上就重新在正确环境里装。养成习惯:装包前先确认虚拟环境已激活。

5.2 图片读取返回 None 导致后续全崩

现象:程序跑到灰度化那步报cv2.error,或者img.shape报NoneType没有属性。

原因:cv2.imread读不到文件时不会抛异常,而是静默返回None。路径里有中文、路径拼错、文件格式不支持,都会触发。

解决:在读图后立刻加if img is None判断并抛出明确错误(见 3.1 的代码)。另外 OpenCV 对中文路径支持不好,路径里尽量别带中文,或者用cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)绕过。

5.3 矩形提取过多或过少

现象:调试图里红框要么密密麻麻全是小框,要么一个大框套住整张图,导致相似度结果忽高忽低。

原因:Canny 双阈值和面积筛选阈值不匹配当前图片分辨率。分辨率高时,500 像素的面积下限太小,噪点全被保留;分辨率低时,0.9 整图面积的上限又太宽松。

解决:先看debug_rects.png,再针对性调。噪点多就提高 Canny 低阈值或加大面积下限;框太大就降低面积上限比例。记住这两个阈值是像素单位,换一批素材就要重新校准。

5.4 相似度结果不稳定、同人比对也偏低

现象:同一人的两张笔迹,相似度只有 0.3 左右,明显不合理。

原因:多半是矩形没有对齐。同一人两次写的字位置不同,矩形提取出来的区域内容不对应,余弦和直方图自然比不中。另外阈值设太高也会压低结果。

解决:先确认矩形提取是否稳定(看调试图),再适当降低cos_thresh和hist_thresh。如果位置偏移是主因,可以考虑在比对前对矩形按位置排序,或者引入简单的模板对齐。这是传统 CV 方法的固有边界,心里要有数。

5.5 PyQt5 界面按钮点了没反应

现象:窗口能弹出,但点「打开文件」或「运行」没任何反应。

原因:信号与槽没绑定成功,或者槽函数里抛了异常被吞掉。PyQt5 里槽函数报错有时不会直接崩,而是静默失败。

解决:在槽函数入口加print或日志,确认是否被调用。如果被调用但中途失败,把槽函数体用try/except包起来打印异常。另外确认按钮的clicked.connect写在了正确的初始化位置。

6. 进阶玩法:把相似度阈值调优做成可复现的实验

跑通只是起点,这份工程真正有价值的地方在于它给你留了调参空间。我一般会做一件事:把四组测试素材(叶青、李国富、云烟、秦晴各两张)组织成一个小的评测集,同人两张算正样本,跨人组合算负样本,然后扫一遍阈值,看准确率怎么变。

import itertools # 测试素材按人分组 groups = { "叶青": ["测试素材/叶青1.png", "测试素材/叶青2.png"], "李国富": ["测试素材/李国富1.png", "测试素材/李国富2.png"], "云烟": ["测试素材/云烟1.png", "测试素材/云烟2.png"], "秦晴": ["测试素材/秦晴1.png", "测试素材/秦晴2.png"], } def evaluate(cos_thresh, hist_thresh): correct, total = 0, 0 names = list(groups.keys()) # 正样本:同人两张 for name in names: a, b = groups[name] sim = run_pipeline(a, b, cos_thresh, hist_thresh) total += 1 if sim >= 0.5: # 同人判定阈值 correct += 1 # 负样本:跨人组合 for n1, n2 in itertools.combinations(names, 2): sim = run_pipeline(groups[n1][0], groups[n2][0], cos_thresh, hist_thresh) total += 1 if sim < 0.5: correct += 1 return correct / total # 网格搜索 for ct in [0.6, 0.7, 0.8]: for ht in [0.7, 0.8, 0.9]: acc = evaluate(ct, ht) print(f"cos={ct}, hist={ht}, acc={acc:.2f}")

逻辑说明:run_pipeline是你把前面预处理、矩形提取、两级比对串起来的封装函数。正样本期望相似度高,负样本期望相似度低,用 0.5 作为判定分界。网格搜索把两个阈值各取三档,跑九组组合,看哪组准确率最高。这套流程的价值在于:它把「阈值怎么设」从拍脑袋变成了可复现的实验,答辩时也能拿数据说话。

参数怎么改:判定分界 0.5 可以按你的数据分布调整;阈值档位可以加密到 0.05 步长。如果正负样本区分度不够,说明特征本身不够强,可以考虑在直方图之外再加一个笔画宽度或投影特征。

注意:四组素材样本量很小,网格搜索出来的最优阈值只能作为参考,别当成通用结论。样本越多,结论越稳。

从那以后我每次拿到这类传统 CV 工程,都强制先跑一遍中间结果可视化,再动任何阈值——因为血泪经验告诉我,跳过可视化直接调参,最后一定是在错误的地基上盖楼。希望这份拆解能帮你少走几个弯路,把这份资源真正用起来。

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

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

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

立即咨询