ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于OpenCV和Python的笔迹识别系统:特征提取与相似度比对实战

基于OpenCV和Python的笔迹识别系统:特征提取与相似度比对实战 简介这是一份基于 Python 与 OpenCV 实现的笔迹识别系统项目源码主要面向计算机视觉、数字图像处理等课程的本科学生也适合需要完成图像识别类毕设或课程大作业的读者。项目以 OpenCV 为图像处理核心涵盖图像预处理、边缘检测、特征提取与笔迹比对等关键流程代码模块划分清晰可直接运行使用便于学习完整实现思路并做二次扩展。整个源码压缩包约 38.22 MB目前已有 956 人学习下载。项目曾作为课程大作业获导师指导并通过属于 97 分高分的完整作品下载后既可用于理解笔迹识别的基本原理也能作为撰写报告、答辩演示和毕业设计参考的成体系素材对快速上手 OpenCV 实战开发很有帮助。1. 笔迹识别系统它解决什么问题谁适合拿来上手手写笔迹识别这个方向听起来有点冷门但真正常见落地场景并不少课堂作业是不是同一人代写的、合同签名与留底笔迹是否一致、纸质档案里的手写批注能不能自动比对。这类任务用纯 Python 和 OpenCV 就能搭出一套可运行的图像识别系统不需要 GPU不需要深度学习框架甚至不需要懂神经网络。项目标题里点名的“笔迹识别系统项目源码”做的是同一件事输入两张手写图片输出一个相似度分数再由阈值判断是否属于同一人。这套方案的价值在于轻。OpenCV 图像处理负责把照片变成干净的二值图像再用轮廓、矩、像素密度这些手工特征描述书写风格最后做特征距离比对。它适合正在做课程设计、毕业设计的同学也适合刚入门图像处理、想避开深度学习黑匣子的工程师。但要说清楚一个反直觉结论笔迹鉴定的核心难点不在“识别字形”而在控制采集条件、提取稳定特征、标定阈值这三件事而这三件事恰恰最容易翻车。2. 技术路线先立住为什么传统 OpenCV 方案更适合笔迹比对2.1 先分清识别目标字形内容识别还是书写人鉴定拿到“笔迹识别”这个需求第一件事不是写代码而是和需求方对齐到底要识别什么。常见有两个方向一是手写内容识别把纸上写的字转成文本这本质是 OCR属于另一套技术栈二是书写人鉴定判断两段笔迹是否出自同一人这才是项目标题里“笔迹识别系统”最可能指的方向。方向不同技术选型完全不同。如果是内容识别传统 OpenCV 基本做不动得上 PaddleOCR 或深度学习模型如果是书写人鉴定OpenCV 反而有优势——同一人笔迹的重复采样在实际业务里通常很少深度学习需要的大量同人样本来得并不容易而传统特征在十来个样本上就能给出一个像样的相似度排序。这也决定了这套源码的核心流程预处理、特征提取、相似度比对而不是端到端神经网络。2.2 三段式流水线预处理、特征、比对传统图像识别方案基本逃不开这个三段式先把手写图片变成标准化的二值图再从中提取描述书写习惯的特征向量最后计算特征距离。笔迹识别也不例外。预处理负责消除拍照环境带来的干扰包括光照不均、背景噪点、纸张纹理、倾斜角度。特征提取负责把“书写风格”这个抽象概念转换为数字常规做法是用轮廓信息描述字形结构用 Hu 矩描述整体形状分布用像素密度描述用笔轻重。特征比对负责给出结论常用欧氏距离或余弦相似度再配合阈值判断是否为同一人。这套结构的好处是每一段都可解释、可调试。预处理效果不好二值图一眼就能看出来特征选得不合适同人样本和异人样本的距离分布会重叠换个特征或调个权重就能定位问题。这在深度学习方案里很难做到——你只能看到准确率数字看不到中间过程。2.3 取舍标准传统特征与深度学习在小样本下的区别接触图像识别的人现在第一反应都是上深度学习但在笔迹鉴定这个场景里纯深度方案经常栽跟头。笔迹识别的样本获取太困难了要求同一个人在不同时间、不同心情、不同纸张上写大量字业务上根本不现实。深度学习图像识别在 ImageNet 这类大规模数据集上效果确实好但它依赖数据分布稳定而笔迹恰恰是个体差异极大、样本量极小的任务。传统 OpenCV 特征在几十个样本上就能建立可用模型这是它在这个场景里活下来的核心理由。另外法庭或人事场景对可解释性有要求——判断依据是哪些特征相似、哪些不相似手工特征可以逐个列出来深度学习给不出这种解释。当然传统方案的边界也很清楚同人字迹差异太大时会误拒不同人刻意模仿时会误识这些后面讲避坑时会细说。2.4 最小环境准备Python 安装与 OpenCV 版本对齐这套代码依赖 Python 和 OpenCV装环境是第一个坑。我用的是 conda 建独立环境避免把系统 Python 搞乱。Python 安装教程网上很多这里直接给最简命令conda create -n handwriting python3.10 -y conda activate handwriting pip install opencv-python4.8.1.78 numpy版本号建议固定。OpenCV 3.x 与 4.x 的findContours返回值不同高版本还改了部分 API不锁版本抄代码时会遇到ModuleNotFoundError之外的兼容性问题。安装完成后验证一下import cv2 import numpy as np print(cv2.__version__)能打印出版本号就说明环境正常。注意这里装的是opencv-python纯 CPU 推理即可笔迹识别是慢速离线任务单张图处理时间在一秒以内完全可接受不需要装 CUDA 版 OpenCV。后面所有代码都基于这个环境运行。3. 把一幅手写照片变成可对比的特征预处理与特征提取全流程3.1 预处理流水线灰度化、去噪、二值化与形态学清理手写笔迹图片最常见的状态是手机拍的背景有阴影、纸张纹理明显、笔迹灰度不均。这种情况下直接提取特征噪声会全部混进特征里后面的相似度计算基本没法看。预处理是我最重视的一步也是这套 OpenCV 方案里投入产出比最高的一步。import cv2 import numpy as np def preprocess(image_path): img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) _, binary cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) cleaned cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations1) cleaned cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel, iterations1) return cleaned这段代码的逻辑是先转灰度图再用高斯滤波去除传感器噪点然后用 Otsu 大津法自动计算阈值做二值化最后用开运算去掉孤立小点、用闭运算把断开的笔画接起来。参数说明(5, 5)是高斯核大小核越大去噪越强但笔画边缘也会变糊笔迹较细时建议缩到(3, 3)THRESH_BINARY_INV把深色笔迹变成前景白色、背景黑色因为findContours找的是白色区域形态学核(3, 3)对大多数手写场景是安全值。这里有一个细节值得注意Otsu 阈值是自动算的但它是全局阈值光照不均时左半边亮右半边暗全局阈值会丢掉一部分浅色笔画。遇到这个问题可以在做 Otsu 之前加一步cv2.divide(gray, cv2.GaussianBlur(gray, (0, 0), 50), scale255)做光照归一化这一招比换任何自适应阈值都稳。3.2 倾斜矫正手写纸拍歪了怎么拉正手机拍手写纸很少能端端正正拍平纸面倾斜 5 度到 10 度是常态。倾斜对轮廓特征影响非常大——同一个字旋转后外接矩形长宽比、Hu 矩都会变化笔迹比对前必须矫正。不需要上复杂的文本行检测对整张纸做一次透视校正就足够。常规做法是找纸张外轮廓用approxPolyDP拟合成四边形再做透视变换。代码里注意传入的是预处理前的彩色图def deskew(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue)[:1] rect cv2.minAreaRect(contours[0]) angle rect[-1] if angle -45: angle 90 angle rows, cols gray.shape matrix cv2.getRotationMatrix2D((cols / 2, rows / 2), angle, 1.0) rotated cv2.warpAffine(image, matrix, (cols, rows), flagscv2.INTER_CUBIC) return rotated这个方案只处理整体旋转不做单字矫正。它基于一个假设整张纸是同一方向拍的笔画方向一致。如果遇到纸面有折痕、局部拱起复杂畸变得用透视变换或薄板样条但就笔迹识别而言按角度旋转到水平已经能挽回大部分相似度损失。minAreaRect返回的角度范围是[-90, 0]angle -45时的修正逻辑是为了让纸张的长边对齐水平方向这个细节不处理竖版纸会被横着拉正。3.3 四个特征描述子轮廓、Hu 矩、像素密度与边缘方向预处理完成后核心问题变成用什么数字描述一个人的书写风格我的经验是从四个维度同时描述单一特征扛不住手写的自然波动。def extract_features(binary_img): contours, hierarchy cv2.findContours( binary_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) area_total cv2.countNonZero(binary_img) h, w binary_img.shape density area_total / (h * w) moments cv2.moments(contours[0]) if contours else None hu cv2.HuMoments(moments).flatten() if moments else np.zeros(7) hu -np.sign(hu) * np.log10(np.abs(hu) 1e-10) edges cv2.Canny(binary_img, 50, 150) edge_hist cv2.calcHist([edges], [0], None, [8], [0, 256]).flatten() edge_hist edge_hist / (np.sum(edge_hist) 1e-6) return np.concatenate([hu, [density], edge_hist])逻辑说明findContours拿到所有连通域的外轮廓moments基于第一个最大轮廓计算 Hu 矩——七个不变矩描述形状的全局几何特征对平移、缩放、旋转不敏感正好对治拍照角度差异。countNonZero统计白色笔迹像素占比反映书写整体的用墨量。Canny 边缘加直方图描述笔画方向分布直方图归一化后让数值不受图片尺寸影响。参数说明Hu 矩数值跨度极大小的接近1e-10大的到1e2直接进距离计算会把距离度量带偏所以用log10压缩量纲保留符号是为了保持矩的正负特性。Canny 的(50, 150)是高低阈值笔迹对比度差时调低到(30, 100)。calcHist的[8]表示输出 8 个 binbin 越小对局部方向变化越不敏感对整体书写风格更稳。3.4 findContours 的版本兼容写法OpenCV 从 3.x 到 4.x 折腾过findContours的返回值结构3.x 返回三个值(image, contours, hierarchy)4.x 返回两个值(contours, hierarchy)。网上抄来的代码经常在这里直接报ValueError: not enough values to unpack。建议统一按兼容写法处理def get_contours(binary_img): result cv2.findContours(binary_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(result) 3: _, contours, hierarchy result else: contours, hierarchy result return contours, hierarchy逻辑说明findContours在 4.x 下第一个返回值是被修改的原图3.x 下是完整的三元组。用len(result)判断返回值数量两种版本都能跑不依赖具体版本号写死分支。这个兼容函数建议直接放进公共工具模块整个项目所有需要轮廓的地方都走它避免散落各处的版本判断。实际踩坑里最常见的情况是项目在本机跑通、换台机器就崩绝大多数就是这种版本差异引起的。4. 相似度计算与阈值划定让“像不像”变成可解释的分数4.1 特征向量相似度余弦距离与欧氏距离怎么选特征提取完成之后每张笔迹图片对应一个几十维的向量笔迹识别变成了向量的距离度量问题。选哪个距离度量不是玄学要看特征向量的性质。def cosine_similarity(vec_a, vec_b): dot np.dot(vec_a, vec_b) norm_a np.linalg.norm(vec_a) norm_b np.linalg.norm(vec_b) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b) def euclidean_score(vec_a, vec_b): dist np.linalg.norm(vec_a - vec_b) return 1.0 / (1.0 dist)逻辑说明cosine_similarity关注方向一致性、不受向量长度影响适合 Hu 矩这种经过log10压缩、各维度量纲相近的特征。euclidean_score把欧氏距离映射到(0, 1]区间距离越小分值越接近 1适合密度和边缘分布这类本身已经归一化的特征。两个函数输出都在[0, 1]区间方便后面加权融合。实际使用中我倾向于优先用余弦相似度。笔迹特征向量里真正稳定的是“形状比例的相对关系”不是绝对数值。同一个人写的同一个字笔画粗细不同会导致像素密度有波动但轮廓各部分的相对比例关系变化不大。余弦相似度天然忽略这种整体缩放影响。4.2 多特征加权目标不是完美分类而是稳定排序单特征判定不可靠多特征融合是常规做法。加权融合的目标不是追求单个样本识别率百分百而是让同人样本的分数稳定高于异人样本阈值才好划定。特征相似度函数建议权重失效条件Hu 矩余弦相似度0.4字形局部形变严重时失效像素密度欧氏得分0.2拍摄距离变化、纸张大小不同时失效边缘方向直方图余弦相似度0.3书写工具更换圆珠笔变毛笔时失效轮廓数量/面积方差欧氏得分0.1文字内容差异大时失效权重设定的经验是结构特征权重大、统计特征权重小并且所有特征必须经过归一化再加权。代码实现很简单def merge_score(scores, weights): return float(np.dot(np.array(scores), np.array(weights)))参数说明scores是各特征相似度组成的列表weights是对应权重np.dot做加权求和。注意权重之和为 1这样融合后的分数保持在[0, 1]方便统一设定阈值。调权重时只看一个指标同人样本的最低分是否明显高于异人样本的最高分。如果不满足先回去补预处理而不是硬调权重。4.3 阈值标定用同人重复笔迹圈定安全区间这部分是整个系统里最容易“翻车”也最容易被忽略的环节。很多人在代码里随便写一个if score 0.8完全没想过这个 0.8 从哪来。阈值必须由你的样本分布决定不能拍脑袋。import random def calibrate_threshold(same_pairs, diff_pairs): same_scores [score_pair(p) for p in same_pairs] diff_scores [score_pair(p) for p in diff_pairs] same_min np.percentile(same_scores, 5) diff_max np.percentile(diff_scores, 95) threshold (same_min diff_max) / 2.0 return threshold, same_scores, diff_scores逻辑说明same_pairs是同一人不同次书写的笔迹对diff_pairs是不同人的笔迹对。分别取同人分数分布的 5 分位数和异人分数分布的 95 分位数再取两者中点作为阈值。用百分位数而不是最大值最小值是因为手写样本总有极端情况最小值和最大值容易把阈值带偏。参数说明5和95是安全边际样本量越大、越稳定可以收紧到 1 和 99样本量少于 20 对时用 10 和 90 更稳。这个脚本输出的分数分布直方图要保留下来后续新增样本时直接对比看分布是否漂移。我的习惯是保留一份“已标定”的配置写入 JSON 文件换数据集就重跑标定绝不复用旧阈值。5. 笔迹识别避坑清单五个翻车现场与对应的补救操作5.1 铅笔与浅色笔迹的二值化翻车现象铅笔书写的笔迹经过 Otsu 二值化后大面积消失笔画断裂成一截一截特征提取结果和同一人圆珠笔书写的差异巨大。浅色水笔也有类似问题扫描图能看到字二值化之后就成了虚线。原因Otsu 是全局阈值铅笔灰度值偏低与纸张背景的对比度不够自动阈值会把一部分浅色笔画归为背景。另一个隐含原因是纸张本身偏灰偏黄压缩了前景和背景的灰度差。解决先在灰度图上做光照归一化增强对比度再重新二值化。我通常加一步CLAHE自适应直方图均衡clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) enhanced clahe.apply(gray) _, binary cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU)这个改动对铅笔字和浅色笔迹改善明显代价是可能放大纸张纹理噪声所以形态学开运算必须跟着做。如果 CLAHE 后背景出现颗粒噪声把clipLimit从2.0降到1.0。5.2 同一人“同字不同形”导致分数异常现象同一人前后写同一个字结构存在肉眼可见的差异——一个字写得紧凑、另一个写得松散计算出的相似度反而低于不同人的分数。原因手写本身存在自然波动同一个字不可能每笔每画完全一致。Hu 矩描述的是整体形状对“整体姿态”的变化敏感如果两次书写时字在纸上的位置、大小、倾斜角度都不同即使同一个字也会输出低相似度。解决从两个角度补救。预处理阶段确保两张图都经过倾斜矫正和归一化缩放把字调整到统一大小特征阶段降低对 Hu 矩的权重提高边缘方向直方图的权重因为方向分布比绝对形状更稳定。另外不要拿单字做比对至少截取一行或一个词语区域作为比对单位整体稳定性会显著提升。5.3 findContours 层级返回值不一致的崩溃现象项目在本机跑得好好的部署到另一台机器上运行进入特征提取环节直接报错退出错误信息是ValueError: not enough values to unpack (expected 3, got 2)。原因OpenCV 主版本升级把findContours返回值从三个改成了两个代码里写死了三值解包换环境就崩。这个坑在复制项目源码时尤其常见——源码里已经写好的代码可能是在特定版本下写的。解决统一走版本兼容函数前面 3.4 节已经给出。另外在项目入口处加一个版本断言import cv2 assert int(cv2.__version__.split(.)[0]) 4, OpenCV 4.x required实际遇到这个问题时优先检查 conda 环境是否和项目要求一致而不是直接改代码。如果项目里到处散落_, contours, _ ...这种写法手动改很容易漏建议全局替换为兼容函数。5.4 阈值拍脑袋定的后果现象相似度阈值设为 0.8测试时发现一部分异人笔迹分数超过 0.8被判成同一人把阈值调到 0.9又有一批同人笔迹被误拒。原因阈值没有基于实际样本分布标定0.8 或 0.9 只是经验值不同数据集的特征分布完全不同。拍照条件、书写工具、文字内容都会让分数整体偏移。解决按 4.3 节的标定流程用同一人重复笔迹和不同人笔迹各不少于 20 对计算分布再定阈值。另一个经验是宁可牺牲一点误拒率也要保证误识率尽可能低。误识意味着把两个不同人的笔迹判成同一人一旦用于签字核验后果很难挽回。5.5 预处理顺序不同结果相差很远现象同一组图片先做灰度再做 CLAHE 与先做 CLAHE 再转灰度得到的识别分数差异明显。部分笔迹经过特定预处理流程后效果很好换一个预处理顺序就掉点。原因图像处理操作不是可交换的CLAHE对三通道彩色图逐通道处理会引入色偏转灰度后处理则会保留亮度信息高斯滤波放在二值化之前是去噪放在二值化之后是平滑边缘语义完全不同。解决把预处理流水线固定下来作为配置项写进项目不允许每次运行时临时调整。我一般把预处理定义为一个类传入参数选择是否开启光照归一化、形态学核大小、Canny 阈值参数全部来自配置文件改参数不改代码。每调整一次预处理必须重新跑标定流程因为特征分布会跟着变。6. 进阶验证用批量比对脚本给识别结果算一个可信度6.1 批量验证脚本一次性算出误识率与拒识率单张比对只能说明“这张像不像”要证明这个笔迹识别系统可用需要一批样本输出一个可信度指标。写一个批量验证脚本遍历数据集统计误识率和拒识率import os import itertools def batch_evaluate(base_dir, threshold): persons [d for d in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, d))] same_total, same_pass 0, 0 diff_total, diff_pass 0, 0 for person in persons: samples [os.path.join(base_dir, person, f) for f in os.listdir(os.path.join(base_dir, person))] for a, b in itertools.combinations(samples, 2): same_total 1 if compare(a, b) threshold: same_pass 1 for p1, p2 in itertools.combinations(persons, 2): s1 os.listdir(os.path.join(base_dir, p1))[0] s2 os.listdir(os.path.join(base_dir, p2))[0] diff_total 1 if compare(os.path.join(base_dir, p1, s1), os.path.join(base_dir, p2, s2)) threshold: diff_pass 1 frr 1 - same_pass / same_total far diff_pass / diff_total return frr, far逻辑说明frr是拒识率本该同人却被判不同人的比例far是误识率本该不同人却被判同一人的比例。两个指标是一对矛盾阈值抬高frr上升、far下降阈值放低则相反。数据集按照“一个文件夹一个人”的结构整理文件夹内放同一人的多张笔迹图。6.2 提速与评估小改动带来明显收益笔迹比对对单张图的处理时间不敏感但批量验证时几十上百对样本跑下来每张图都做一次findContours和 Canny耗时就会显现。两个小改动收益明显一是预处理和特征提取拆开做每张图只算一次特征并缓存到数组里比对阶段只做向量点积几百对样本瞬间出结果二是所有图统一缩放到宽度 800 像素再处理处理速度提升接近一倍对特征的影响很小。评估结果不要只看最终分数。把同人分数和异人分数的分布直方图画出来如果两条曲线有明显间隔说明特征和阈值都合理如果重叠严重说明问题不在阈值而在预处理或特征选择。这套流程走完你手里就有了一套可解释的识别数据而不只是一个“跑起来了”的脚本。我现在做笔迹识别项目已经养成了一个习惯任何一次调整权重也好、阈值也好、预处理参数也好都会先重跑一遍批量评估确认误识率没有上升才进入下一步。这个习惯帮我避开过不少“调完参数当时过、换批数据就翻车”的状况。把这些基础工作做扎实比追求更复杂的模型更值得投入。希望帮到你。本文还有配套的精品资源点击获取
返回列表