简介:面向计算机视觉与图像识别学习者,这份基于Python+OpenCV的笔迹识别系统项目源码,是一套经过完整验证的高分课程大作业工程。项目在导师指导下完成,获得97分评价,代码可直接下载运行,适合作为课程设计、毕业设计或竞赛项目的参考骨架。内容围绕笔迹识别任务,覆盖图像预处理、特征提取、分类器构建与识别结果输出等核心流程,能够帮助读者直观理解OpenCV在模式识别中的实际运用。压缩包整体约38.22MB,文件结构清晰,源码工程完整,解压后即可按说明使用;目前已有956人学习下载,属于口碑较好的实战型资源。从项目结构到关键算法实现均有清晰脉络,读者可据此梳理图像识别系统搭建思路,并迁移到其他笔迹、字体或形状识别场景,对快速上手图像识别实战、完善课程报告或扩展二次开发能力颇具价值。
1. 基于 OpenCV 的笔迹识别系统:这门 97 分课设到底做了什么
笔迹识别是 OpenCV 图像识别课程设计里出镜率很高的题目,但这门拿到 97 分的课设没有上深度学习,而是用经典图像处理的路数把整条链路跑通了:读入 → 预处理 → 特征提取 → 匹配 → 出结果。拿到这份基于 Python + OpenCV 的源码包,你会发现它不是一个只会对着固定样本比对的玩具,而是一套能替换样本、能调阈值、能输出置信度的完整工程。
它的核心价值在于解决了三件事:一是把深浅不一、带噪点的扫描稿变成干净的骨架图;二是把图像转成网格密度、投影和 Hu 矩这些能相互比较的特征;三是用模板匹配和特征匹配两套方案给出识别结果与可信度。适合的人群很明确:正在做图像识别课设的学生、需要快速搭一个 OpenCV 图像识别 Demo 的开发者、以及想从源码里学预处理和特征工程套路的人。如果你是这三类里的任何一类,这份源码值得在动手前先完整过一遍,它能把「笔迹识别」这件事从黑匣子变成你能逐行改的代码。
2. 预处理管线:把扫描稿变成电脑能算的骨架图
预处理是笔迹识别里最影响准确率的一段。我见过不少同学跳过这一步直接拿原图去匹配,结果识别率惨不忍睹,回头还以为是算法不行。这一章把灰度化、二值化、去噪、骨架提取四步拆开讲,每一步都给代码和参数说明。
2.1 灰度化与二值化:识别准确率的第一道分水岭
笔迹识别的输入大多是扫描件或者手机拍的白纸,颜色信息除了干扰基本没别的用处。纸张发黄、红笔批注、网格线,这些都会在匹配阶段变成噪音。所以第一步就是把三通道彩色图压成单通道灰度图,再进一步压成只有黑和白二值的图。二值化这一步尤其关键:图片最终只保留「有没有笔画」这个信息,后面所有特征统计都建立在这一步的结果上。
import cv2 import numpy as np def preprocess(img_path): # 读图:OpenCV 默认按 BGR 三通道读入 img = cv2.imread(img_path) if img is None: raise FileNotFoundError(f"读不到图片: {img_path}") # 灰度化:BGR -> Gray,把颜色信息扔掉 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊:压制扫描件上的细碎噪点 # 核 (5, 5) 是经验值,太小没效果,太大会把笔画边缘糊掉 blurred = cv2.GaussianBlur(gray, (5, 5), 0) # Otsu 自动阈值:不用手调阈值,适合笔画深浅不一的作业纸 # THRESH_BINARY_INV 让笔迹变成白色(255)、背景变成黑色(0) _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) return binary这段代码的逻辑是把彩色图先降维,再用 Otsu 自动找一个能把前景和背景分开的阈值。参数说明里有两个点值得记:cv2.threshold的第二个参数传 0 是因为 Otsu 模式会忽略它,真正的阈值由算法自己算;THRESH_BINARY_INV反色之后,白色像素代表笔画,后面统计白色像素占比时语义就顺了。如果你用的是普通固定阈值,比如恒为 127,换一张光线不同的扫描件就会翻车,所以源码里走 Otsu 是更稳的选择。
遇到纸张亮度不均、一半亮一半暗的情况,固定阈值和 Otsu 都会在暗区出错。我一般在这种情况下改用自适应阈值:
binary = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 5)blockSize=31表示每个像素参照周围 31×31 邻域计算局部阈值,必须是奇数;C=5表示在邻域均值基础上减 5 作为阈值,C 越大越容易把浅笔画滤掉。这个参数组合对网格纸、暗角照片都挺能扛,代价是计算慢一些。
2.2 去噪、形态学操作与骨架提取
二值化之后的图仍然不干净:扫描仪的灰尘点、纸张纤维都会变成小白块。普通高斯模糊对这种椒盐式的孤立噪点效果有限,更合适的是中值滤波加开运算。做完这两步之后再做骨架提取,也就是教程里常说的「细化」,把笔画压成单像素宽的骨架,这样特征的形状信息会更纯粹。
# 中值滤波:对椒盐噪点比高斯滤波更有效 denoised = cv2.medianBlur(binary, 3) # 开运算:先腐蚀后膨胀,去掉孤立小点,同时保持笔画粗细 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) opened = cv2.morphologyEx(denoised, cv2.MORPH_OPEN, kernel) # 骨架提取:把笔画压成单像素宽,方便后续做形状特征 try: import cv2.ximgproc as xip skeleton = xip.thinning(opened) except ImportError: # 没装 opencv-contrib-python 时的备用实现 skeleton = zhang_suen_thinning(opened)开运算里的kernel=(3, 3)是常用配置,太大会把细笔画的头尾削掉。骨架提取这里有个环境注意点:cv2.ximgproc.thinning需要安装opencv-contrib-python才有,如果只装了opencv-python,import 会直接报错。源码包里一般会带一个手写的 Zhang-Suen 细化函数作为兜底,它的原理是反复执行两步子迭代,把边缘像素按八个邻域的拓扑关系一层层剥掉,直到只剩单像素骨架。
Zhang-Suen 的实现思路不算复杂,核心是每次迭代标记出满足删除条件的边界点,统一处理后再进入下一轮,直到没有像素可删。手写版本在纯 Python 循环下会比较慢,但对课设规模的小图完全够用。骨架提取的价值在于:同一个字,正常写和用力写笔画粗细可能差一倍,但骨架结构基本一致,后续特征匹配就少吃「粗细」这个变量的亏。
2.3 中间结果可视化:不保存调试图就没法调参
预处理有没有做对,光看最终识别率是很难定位的。我习惯把每一步的中间结果拼在一张图里存下来,哪一步出了问题一眼就能看见。这个习惯在调笔迹识别这类图像任务时特别管用,因为很多问题的根源就藏在灰度、二值化或骨架某一步里。
def debug_visualize(original, binary, skeleton): # 三个图横向拼接,方便对比 # 灰度图要先用 cvtColor 转回三通道,否则 hstack 会因为通道数不一致报错 parts = [ original, cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR), cv2.cvtColor(skeleton, cv2.COLOR_GRAY2BGR), ] canvas = np.hstack(parts) cv2.imwrite("debug_pipeline.png", canvas)np.hstack要求所有图的高度一致,所以二值图和骨架图都要先转回三通道。看调试图的时候我按这个顺序排查:如果二值化图里笔画断裂,说明阈值参数太严或模糊过度;如果骨架图在笔画交叉处断开,说明细化输入里就有断点,要回头调形态学的开闭运算;如果骨架图里全是毛刺,说明去噪不充分。这条排查路径基本能把预处理阶段 80% 的问题定位掉。把这一步固定写进主流程,每次换样本都自动生成一张调试图,比临时写cv2.imshow去弹窗高效得多。
3. 特征提取与比对:笔迹凭什么被认出来
预处理做完,得到的是一张干净的骨架图。但图本身不能直接拿去比较,因为像素级比较对位置、大小、倾斜都太敏感。这一章讲清楚特征向量怎么设计、两套比对方案怎么选、以及结果怎么输出才可信。
3.1 特征向量设计与尺寸归一化
把图像转成特征向量,本质上是在做「压缩」:把几万个像素压缩成几十个有判别力的数字。笔迹识别里常用的三件套是网格密度、投影直方图和 Hu 矩。网格密度统计每个局部区域里笔画占多少,能反映字的整体分布;投影直方图分别统计每行、每列的笔画总量,能刻画字的胖瘦和伸展方向;Hu 矩是七个对平移、旋转、缩放不敏感的全局形状描述子,正好应对同一人不同写字姿势的差异。
def extract_feature(binary_img): h, w = binary_img.shape # 统一缩放到 64x64,避免样本大小不同导致特征不可比 resized = cv2.resize(binary_img, (64, 64), interpolation=cv2.INTER_AREA) # 网格密度:把图分成 4x4 共 16 个格子,统计每个格子的白色像素占比 grid_feat = [] for i in range(4): for j in range(4): cell = resized[i * 16:(i + 1) * 16, j * 16:(j + 1) * 16] grid_feat.append(np.count_nonzero(cell) / 256.0) # 投影特征:每行、每列分别统计白色像素数,再归一化到 [0,1] row_proj = np.count_nonzero(resized, axis=1).astype(float) / 64.0 col_proj = np.count_nonzero(resized, axis=0).astype(float) / 64.0 # Hu 矩:7 个值,数值跨度极大,取 log 压缩 moments = cv2.HuMoments(cv2.moments(resized)).flatten() hu_feat = -np.sign(moments) * np.log10(np.abs(moments) + 1e-10) # 拼接成完整特征向量:16 + 64 + 64 + 7 = 151 维 feat = np.hstack([grid_feat, row_proj, col_proj, hu_feat]) return feat.astype(np.float32)尺寸归一化放在最前面,是为了消除扫描时字号不同带来的影响;INTER_AREA在缩小图像时比默认的双线性插值更抗锯齿。网格密度用了 4×4 的划分,格子里白色像素占比的计算用np.count_nonzero配合格子面积 256 做归一化,这样特征值落在 0 到 1 之间,不同格子之间可比。Hu 矩的数值范围可能差十几个数量级,直接拿去算相似度时大数会淹没小数,所以用 log 变换压缩一下,符号保留原样,这是这类特征处理里的常规操作。
3.2 模板匹配与特征匹配:两套方案的取舍
比对方案决定了整个识别系统的性格。模板匹配用cv2.matchTemplate,逻辑是把查询图当模板在目标图里滑窗,找到相似度最高的位置。它适合印刷体、票据这类位置和大小都很稳定的场景,但对笔迹这种同一个人两次写出来都不同、位置大小全在抖的输入,它特别容易误判。特征匹配则先把每张图都压缩成特征向量,再算向量之间的余弦相似度或欧氏距离,对位置偏移和轻微形变的容忍度高得多,所以笔迹识别项目里我更推荐以后者为主。
def match_template_score(target_img, template_img): # TM_CCOEFF_NORMED 对亮度变化不敏感,结果范围 [-1, 1] res = cv2.matchTemplate(target_img, template_img, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ = cv2.minMaxLoc(res) return max_valdef match_feature(feat_query, feat_db): scores = [] for name, feat in feat_db.items(): # 余弦相似度:比较两个特征向量的方向一致性 dot = np.dot(feat_query, feat) denom = np.linalg.norm(feat_query) * np.linalg.norm(feat) + 1e-10 scores.append((name, float(dot / denom))) scores.sort(key=lambda x: x[1], reverse=True) return scores模板匹配的TM_CCOEFF_NORMED是归一化相关系数,它对整体亮度不敏感,值越接近 1 说明越像。特征匹配里的余弦相似度则把每个特征向量当成空间里的一个点,方向越接近说明笔画结构越像。倒排之后取最大值,就是系统认为最可能的笔迹主人。两套方案我会同时保留在工程里:模板匹配留给「待识别图像和库图像是同源扫描件」这种极端对齐的场景做快速粗筛,特征匹配作为主识别器。源码包里两个函数都齐了,跑的时候可以用同一个样本分别试两种方案,对比结果就知道各自适合什么输入。
3.3 阈值、Top-K 与识别结果输出
识别不是简单回答「是谁」,而是要回答「有多大的把握是谁」。如果来一个陌生人,特征匹配也可能硬选出一个相似度最高的,所以必须设置信度阈值,低于阈值就判定为「未知笔迹」。输出方面,保留前三名而不是只给第一名,能让你看到召回结果里是否出现了「同字不同人」的干扰项。
def recognize(binary, feat_db, top_k=3, threshold=0.75): feat = extract_feature(binary) ranking = match_feature(feat, feat_db) # 最高相似度低于阈值,直接判为未知,不给硬凑的结果 if ranking[0][1] < threshold: return "unknown", ranking[:top_k] return ranking[0][0], ranking[:top_k]阈值 0.75 不是拍脑袋定的,它是拿一批「本人样本」和「他人样本」分别跑相似度之后,选的一个能把两类分开的经验值。不同笔迹库的分布不一样,这个值必须重新标定。具体做法是:把所有人两两组合算一遍相似度,画出本人相似度和他人相似度的分布,取两者交叠区域中间的数当阈值。Top-K 的意义在于,当第一名和第二名的分数接近时,大概率是这两个人的字形确实相近,这时候只看第一名就会掩盖这个信息。输出格式可以是控制台打印,也可以在工程里直接改成 JSON,方便后续接界面。
4. 主程序与模块拆分:把识别流程串成能复用的工程
预处理和特征匹配单独都能跑,但没有一个清晰的主程序把它们串起来,换样本、调参数就会变得很痛苦。这一章看的是工程组织方式:文件怎么分、参数放哪、命令行怎么接。
4.1 文件结构:一眼看懂这个工程怎么组织
拿到源码包解压之后,先别急着运行,花两分钟看一遍目录结构。笔迹识别这类 OpenCV 项目,一般会按处理阶段拆成独立模块,这样每一步都能单独测试。下面是这份源码里常见的一层结构,也是我自己在搭同类工程时习惯用的组织方式:
| 文件 / 目录 | 职责 |
|---|---|
main.py | 入口,负责串流程、解析命令行参数 |
preprocessing.py | 灰度、二值化、去噪、骨架提取 |
features.py | 特征提取与向量归一化 |
matching.py | 模板匹配、特征匹配、Top-K 排序 |
config.py | 集中管理阈值、尺寸、路径等全局参数 |
samples/ | 待识别的样本图片 |
db/ | 笔迹库原始图与特征向量缓存 |
模块拆分的标准是「每一步能不能单独 import 测试」。preprocessing.py里放一个preprocess(),features.py里放extract_feature(),matching.py里放match_feature(),各自都能拿一张图独立验证输出,这样的工程你改任何一环都不用把整条链路跑一遍。config.py的存在尤其重要,把散落在代码里的魔法数字都收进来,调参的时候只改一个文件就行。
4.2 核心调用流程与参数集中管理
主程序的核心就是一条流水线:读样本 → 预处理 → 提特征 → 和库里每个样本比对 → 排序输出。参数集中到config.py之后,主流程的代码会变得非常短,而且每个参数都有明确的注释,换数据集的时候知道该动哪里。
# config.py RESIZE_SIZE = 64 # 特征提取前的统一尺寸 GRID_N = 4 # 网格密度划分的格数 GAUSSIAN_K = (5, 5) # 高斯模糊核 THRESH = 0.75 # 置信度阈值,低于此值判为 unknown TOP_K = 3 # 输出前几个候选 DB_PATH = "db/features.npy" # 特征库保存路径# main.py import argparse import cv2 import numpy as np from config import THRESH, TOP_K from preprocessing import preprocess from features import extract_feature from matching import match_feature def main(): parser = argparse.ArgumentParser(description="笔迹识别") parser.add_argument("--query", required=True, help="待识别图片路径") parser.add_argument("--db", default="db/features.npy", help="特征库文件") args = parser.parse_args() # 1. 读入并预处理 binary = preprocess(args.query) # 2. 提取查询样本的特征 query_feat = extract_feature(binary) # 3. 加载特征库并比对 feat_db = np.load(args.db, allow_pickle=True).item() ranking = match_feature(query_feat, feat_db) # 4. 判断置信度并输出 if ranking[0][1] < THRESH: print("识别结果: unknown,最高相似度 {:.3f}".format(ranking[0][1])) else: print("识别结果: {},相似度 {:.3f}".format(ranking[0][0], ranking[0][1])) for name, score in ranking[:TOP_K]: print(" candidate: {}, score: {:.3f}".format(name, score)) if __name__ == "__main__": main()np.load(..., allow_pickle=True).item()把特征库读成一个字典,键是样本名,值是特征向量,这是 OpenCV 项目里轻量持久化的常见做法,不需要引入数据库。整个主流程只有十个左右的调用点,每一行都在说清楚「现在做到哪一步了」。如果识别效果不对,先用 debug 图确认预处理,再单独打印特征向量看是不是有 NaN,最后才怀疑匹配逻辑——这个排错顺序能省下大量时间。
4.3 命令行运行与调参入口
工程跑起来之后,调参就变成了高频操作。把查询图片路径、候选数量、阈值全部提到命令行,每次实验不用改代码。这是让这个课设从「能跑」变成「好用」的一步,答辩的时候演示换样本、换阈值,比贴代码更有说服力。
# 基本用法:识别一张样本 python main.py --query samples/01.png # 指定特征库和输出候选数 python main.py --query samples/02.png --db db/features.npy --top-k 5 # 调低置信度阈值,观察低相似度样本的排序变化 python main.py --query samples/03.png --threshold 0.6--top-k和--threshold覆盖了config.py里的同名参数,命令行传参优先级更高。调参的时候我一般会固定一个查询样本,改变一个参数,记录识别结果和所有候选分数,这样做上十组,哪个参数对结果影响最大就有数了。源码包里如果已经内置了样本和特征库,先原样跑一遍得到基线结果,再改成你的笔迹样本,这样每一步改动都有对照,不会出现「改了一堆参数但不知道是哪一步让识别率变差」的情况。
5. 避坑指南:OpenCV 笔迹识别项目里五个高频翻车点
这一章是我自己跑 OpenCV 图像识别项目时真实踩过的坑,每条都按「现象 → 原因 → 解决」来写。这五个问题在课程设计里出现的频率极高,提前看一眼能省下大半天的排错时间。
5.1 装了 OpenCV 还是报 No module named 'cv2'
现象:在终端里执行python main.py,第一行就抛出ModuleNotFoundError: No module named 'cv2',但明明已经用pip install opencv-python装过了。
原因:绝大多数情况是把包装进了另一个 Python 环境。系统自带的 Python 和虚拟环境用的是不同的 site-packages 目录,pip命令指向的环境不一定是你运行脚本的环境。还有一种可能是只装了opencv-python却没装opencv-contrib-python,导致需要cv2.ximgproc的代码 import 失败。
解决:先确认运行环境,再在该环境内安装:
# 确认当前 pip 属于哪个 Python python -m pip --version # 在当前 python 环境内安装两个包,contrib 提供 ximgproc 等扩展模块 python -m pip install opencv-python opencv-contrib-python # 安装后验证 python -c "import cv2; print(cv2.__version__)"用python -m pip install而不是裸pip install,能保证装进当前这个 Python 解释器对应的环境。如果项目用的是 conda 环境,记得先conda activate再安装。版本验证打印出的版本号如果是 4.x,就说明 OpenCV 4 的 API 已经就绪。
5.2 findContours 返回值变化与 contourArea 未定义
现象:从网上复制的轮廓代码,在自己环境里跑contour, hierarchy = cv2.findContours(...)直接报错,提示返回值数量不对;或者调用cv2.contourArea()时 IDE 提示未定义标识符。
原因:OpenCV 3.x 和 4.x 之间findContours的返回值结构变了。3.x 返回三个值(image, contours, hierarchy),4.x 返回两个值(contours, hierarchy),直接把旧代码的赋值方式搬过来就会崩。contourArea未定义通常是 IDE 的静态检查没识别到,运行时其实能用,但返回值解包的问题在运行时确实会崩。
解决:统一按 OpenCV 4 的写法解包,并且不依赖第一个返回值:
# OpenCV 4.x 写法:第一个返回值丢弃 contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 过滤太小的噪声轮廓 contours = [c for c in contours if cv2.contourArea(c) > 50]RETR_EXTERNAL只取最外层轮廓,CHAIN_APPROX_SIMPLE压缩水平、垂直、对角方向的线段端点,能大幅减少轮廓点数。面积阈值 50 是经验值,具体要看图的尺寸;如果图缩放过,这个阈值也要按比例缩放。笔迹识别里轮廓一般用在「检测笔迹区域」或「用 matchShapes 做形状比对」,所以拿到轮廓后的第一步通常是面积过滤,把灰尘点产生的微型轮廓直接清掉。
5.3 中文路径让 imread 读不出图
现象:代码逻辑完全没错,图片放到项目根目录也能读出来,但一旦路径里带中文目录或中文文件名,cv2.imread返回的就是None。
原因:OpenCV 的imread底层用的是 C++ 的文件接口,对中文等非 ASCII 路径支持一直不好,Windows 环境下尤其明显。这个问题和 Python 本身没关系,纯是 OpenCV 的历史兼容问题。
解决:用np.fromfile配合cv2.imdecode绕过它,这是图像处理里处理中文路径的标准做法:
def imread_unicode(path): # 用 numpy 以字节方式读文件,再交给 imdecode 解码 data = np.fromfile(path, dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)np.fromfile读入的是原始字节流,cv2.imdecode负责按图像格式解码,这个组合对中文路径和绝对路径都稳定。注意cv2.imread返回None时不会抛异常,只会静默失败,所以读图之后一定检查是否为空。我在预处理函数里加了一个if img is None: raise的判断,就是为了一开始在入口就把这个问题暴露出来,而不是让它在后面变成维度不匹配的诡异报错。
5.4 骨架提取慢到像死循环
现象:调用细化函数后程序长时间没反应,CPU 占满,或者跑一张几百万像素的大图要好几分钟,等得人想直接关掉终端。
原因:手写的 Zhang-Suen 细化,如果逐像素用 Python 循环加多次迭代,复杂度会很高。扫描件原图往往有几百万像素,而细化要求的输入是二值图,大部分区域是背景,逐像素检查邻域条件的开销会非常大。
解决:先缩小,再细化,缩小用我们前面特征提取那步的 resize 思路;同时把输入限制在笔迹区域而不是整张纸。细化之前还可以把所有笔画白色像素的连通域求出来,大块的区域才值得细化,孤立噪点直接丢弃。如果你用的是cv2.ximgproc.thinning,它本身是 C++ 实现的,速度尚可,但输入图过大时同样建议先缩放到统一尺寸再做,比如 64×64 或 128×128。骨架提取对识别精度的影响不敏感到必须以原图分辨率进行,缩小后反而能让特征更稳定。
5.5 二值化后笔画断成一截一截
现象:预处理调试图里,字的横折钩在转角处断掉,撇捺中间出现空洞,导致后面特征统计完全不靠谱。
原因:阈值选得太严,或者纸张有底色、光照不均。固定阈值 127 在这种场景下会把较浅的笔画直接滤掉;Otsu 处理全局不均的光照时,亮区暗区的分割标准不一致,也会让笔画断裂。
解决:优先换自适应阈值,把blockSize调小一些(比如从 31 改到 21),C从 5 改到 2,让判断更贴近局部亮度。如果断裂还是存在,在形态学那一层开运算之后补一个闭运算,用cv2.MORPH_CLOSE把细小的断口接上。闭运算的参数kernel同样取(3, 3),太大会把本来分开的两个笔画黏在一起。调这一层参数时,每次只改一个值,对照调试图看效果,不要同时动三个参数,那样你根本不知道是哪个改动起了作用。
6. 进阶:用形状描述子 + 留一验证把识别率再往上顶
基础流程跑通之后,识别率还是不够的话,通常有两个改进方向:换更强的形状描述子,以及用严谨的验证方法找到真正的瓶颈。这一章给出两个可以直接落地的技巧。
6.1 用 matchShapes 补一刀,缓解字迹抖动
特征向量虽然对位置和大小有一定容忍度,但同一人写字时笔画的角度、弧度变化仍然是误差来源。cv2.matchShapes是基于轮廓的 Hu 矩比对,它对轮廓的整体形状做归一化比较,正好适合用手写轮廓做二次确认。做法是:在特征匹配选出前两名之后,把查询图的轮廓和这两名的库图轮廓再算一次matchShapes,用它的结果修正最终排序。
def refine_with_shapes(binary_query, db_contours, ranking): # 提取查询图最外层轮廓 contours, _ = cv2.findContours(binary_query, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(contours) == 0: return ranking query_contour = max(contours, key=cv2.contourArea) refined = [] for name, score in ranking: # matchShapes 返回的是距离,越小越相似 dist = cv2.matchShapes(query_contour, db_contours[name], cv2.CONTOURS_MATCH_I2, 0) refined.append((name, score - dist * 0.1)) refined.sort(key=lambda x: x[1], reverse=True) return refinedCONTOURS_MATCH_I2是三种匹配模式里对形变最敏感的一种,距离值越小说明轮廓越像。我在最终分数里减去dist * 0.1,相当于给形状距离一个较小的惩罚权重,让它去微调而不是覆盖原有的特征匹配排序。这个权重就是调参入口,如果发现形状距离太激进导致误判,就把权重降到 0.05;如果感觉微调没起作用,再往上加。这样等于给识别系统加了第二个视角,两个视角结论一致时答案基本是稳的。
6.2 留一验证法:给识别率一个可信的数字
改进之后到底提升了多少,不能靠「试了几张感觉还行」,要跑一遍留一验证法。做法很简单:每个人的笔迹样本里,轮流拿一张当查询,剩下全部当库,统计命中率。这样每个样本都被检过一次,识别率的数字是完整算出来的,答辩时也能直接拿出来讲。
def leave_one_out(samples_by_person): correct = 0 total = 0 for person, imgs in samples_by_person.items(): for i in range(len(imgs)): # 当前样本当查询,其余样本为库 query = imgs[i] db = {} for p, ims in samples_by_person.items(): for j, im in enumerate(ims): if p == person and j == i: continue db[f"{p}_{j}"] = extract_feature(im) pred, _ = recognize(query, db) correct += (pred == person) total += 1 print("留一验证识别率: {:.1f}%".format(100.0 * correct / total))每个人的样本至少要有三张以上,这个验证才有统计意义。跑完之后如果识别率卡在 90% 上下,去看哪些样本被判错了,它们往往是字形本身和他人极度相似的那几张。这个验证方法也暴露了一个真相:笔迹识别的上限不只是算法决定的,还取决于库内样本之间的区分度。从那以后我每次做完一个识别项目,都会强制走一遍留一验证,把误判样本单独存一个目录反复看,而不是只盯着总正确率。这种方式逼着你去面对具体失败的案例,比盲目调参有用得多。希望这份基于 Python + OpenCV 的笔迹识别源码和这些调参思路,能帮你在课设或练习里少走几步弯路,把精力留给真正影响结果的那几个参数。
本文还有配套的精品资源,点击获取