ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python+OpenCV笔迹识别:图像预处理与相似度判定实战

Python+OpenCV笔迹识别:图像预处理与相似度判定实战 简介基于Python与OpenCV的笔迹识别系统项目源码面向计算机视觉课程设计、毕业设计以及图像识别入门学习者旨在解决笔迹检测、特征提取和比对识别等典型问题。该项目为已获导师指导并通过的九十七分高分课程大作业代码完整下载后即可运行也便于按需修改与功能扩展。压缩包大小约三十八兆字节内含完整的项目工程以Python源码为核心覆盖图像读取、灰度化、二值化、轮廓提取、特征计算、笔迹匹配及结果可视化等模块结构清晰适合深入研读。目前已有九百五十八人学习说明该资源受到较多使用者认可可靠度较高。通过本项目读者既能系统掌握图像处理与模式识别的基本方法也能获得一个可直接用于演示或答辩的完整系统适合快速完成课程作业、毕设选题或项目实战实用性很强。1. 基于PythonOpenCV的笔迹识别这套系统到底解决什么问题一套基于Python和OpenCV的笔迹识别系统输入是几张手写笔迹的扫描图或拍照图输出是“这些笔迹是否来自同一个人”。它做的是图像识别里典型的“相似度判定”任务先用OpenCV把笔迹从背景里干净地抠出来再把每张图的形状特征变成一串数字最后用距离公式判断两串数字靠不靠近。这个方向适合两类人一是要做图像识别课程设计或毕业设计的开发者二是想把笔迹比对做成工具的原型验证。它不依赖深度学习框架不需要GPU纯CPU就能跑核心功夫全在图像预处理和特征选得好不好。下面按复现顺序拆开讲从预处理到比对再到最容易踩坑的地方。2. 图像预处理与特征提取从彩色图到干净前景的必经步骤2.1 笔迹识别的前提把笔画从背景里干净地抠出来笔迹识别的第一道坎不是算法而是图像本身太脏。扫描件上有纸张纹理、有不均匀的照明、有扫描噪点手机拍照还会有阴影和透视变形。如果直接拿彩色图去算特征光照变化和纸张底色会干扰所有数值。常见的做法是先把彩色图转成灰度再做二值化把“是笔迹”的像素变成白色其余全变成黑色。这一步做对了后面所有特征才有意义。下面是最小可用的预处理函数import cv2 import numpy as np def preprocess(image_path): # 读入图像OpenCV 默认返回 BGR 三通道 img cv2.imread(image_path) if img is None: raise FileNotFoundError(f无法读取图片: {image_path}) # 转灰度笔迹识别不依赖颜色灰度能减少光照和纸张底色的干扰 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊核大小选 3 或 5过大会把细笔画的边缘也抹掉 blur cv2.GaussianBlur(gray, (5, 5), 0) # OTSU 自动二值化逆二值化让笔画变白、背景变黑方便后续轮廓处理 _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) return binary这里有两个关键选择。第一是为什么用cv2.THRESH_OTSU而不是固定阈值手写笔迹的灰度分布很不均匀同一张纸上有的笔画墨水深、有的浅固定阈值顾此失彼OTSU 会在灰度直方图上自动找一个峰谷分割点对不同深浅的笔迹都相对公平。第二是高斯模糊的核大小(5, 5)是折中值——核越大去噪越强但细笔画的边缘也会被磨掉如果原图是 300dpi 扫描件可以降到(3, 3)。这一步最常见的翻车是二值化后前景和背景反了。cv2.THRESH_BINARY_INV表示“灰度值大于阈值的设为 0”因为笔迹颜色深、灰度值低所以要加INV把笔画翻成白色。如果忘了加INV前景变黑背景变白后续findContours找出来的会是一大块白色背景而不是笔画轮廓。2.2 倾斜校正同一句话写成15度斜的特征就全变了预处理里最容易忽略的是倾斜校正。同一个人随手写的两张纸行倾斜角差个十度很常见。如果不校正任何跟宽高、方向有关的特征全都会偏移。做笔迹识别时我一般会在二值化之后加一步把整张二值图里所有非零像素当成一个整体用最小外接矩形求出平均倾斜角再旋转回水平。def deskew(binary): # 找到所有非零像素点坐标相当于把所有笔画当成一个整体 coords cv2.findNonZero(binary) # minAreaRect 返回 ((cx, cy), (w, h), angle)angle 范围是 [-90, 0) rect cv2.minAreaRect(coords) angle rect[-1] # 换算成 getRotationMatrix2D 需要的旋转角 if angle -45: angle -(90 angle) else: angle -angle h, w binary.shape center (w // 2, h // 2) M cv2.getRotationMatrix2D(center, angle, 1.0) # 旋转后空白区域用黑色填充确保背景仍是 0 rotated cv2.warpAffine(binary, M, (w, h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_CONSTANT, borderValue0) return rotated这里的角度换算是个容易绕晕的细节。cv2.minAreaRect返回的角度是矩形宽边与水平线的夹角范围在[-90, 0)。OpenCV 的旋转矩阵默认逆时针为正所以要分两段换算角度小于-45时说明矩形是竖着的需要先转成-(90 angle)否则直接用-angle。这个换算不仔细核对的话旋转方向反了笔迹会越转越歪。整个倾斜校正对“单行文字”的样本效果很稳但对“整页手写笔记”会出问题——页面上不同行的倾斜角可能不一样强行旋转整页反而会让部分行更歪。如果源码包里的样本是整页扫描件我一般会先按行分割每行单独校正倾斜再做特征提取。2.3 把预处理串成一条可复用流水线上面两步单独写没问题但实际调试时要反复改参数、反复看中间结果。所以我会把它们封装成一个完整的流水线并且随时能输出中间图像方便定位问题出在模糊、二值化还是旋转。def build_pipeline(image_path, debugFalse): img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0) _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 对单行笔迹样本做倾斜校正 binary deskew(binary) if debug: # 调试模式下保存中间结果方便肉眼确认每一步是否正常 cv2.imwrite(debug_gray.png, gray) cv2.imwrite(debug_binary.png, binary) return binarydebug参数是我自己写这类系统时一定会留的口子。图像处理是典型的黑匣子系统参数一多你光看最终结果根本不知道是哪一步出了问题。把每一步的中间图落盘一次就能看清“原图、灰度、二值化、旋转后”四个状态哪里坏了一眼定位。这个习惯在调阈值时特别值得后面第五章还会提到。3. 笔迹比对核心实现轮廓特征与相似度判定的完整链路3.1 用findContours提取每一笔的几何轮廓预处理完成后得到的是黑底白笔画的二值图。接下来要做的是把每一块连通的白色区域找出来这就是cv2.findContours的职责。对笔迹识别来说轮廓本身携带了字形的大量信息笔画的长短、粗细、弯曲程度都体现在轮廓的形状里。# OpenCV 4.x 的 findContours 返回两个值轮廓列表和层级关系 contours, hierarchy cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)这里有两个参数必须说清楚。第一个是RETR_EXTERNAL它只取最外层的轮廓适合字与字之间分隔清楚的样本。如果笔迹里有很多封闭字形“口”“日”“回”内部的白洞也是特征那就得改成RETR_CCOMP或RETR_LIST否则字形内部的空白信息会全部丢光。第二个是CHAIN_APPROX_SIMPLE它只保留轮廓的端点压缩掉直线上的冗余点对性能友好如果要计算曲率这类精细特征再换CHAIN_APPROX_NONE。拿到轮廓之后不能直接全用。二值化后的图像通常有椒盐噪点这些噪点也会形成小轮廓。我会先做一个面积过滤把明显小于正常笔画的轮廓丢掉避免它们污染后续特征统计。def filter_contours(contours, min_area20): filtered [] for c in contours: area cv2.contourArea(c) if area min_area: # 小于阈值的轮廓大概率是噪点或纸张纤维直接丢弃 continue x, y, w, h cv2.boundingRect(c) if w 2 or h 2: # 宽度或高度只有 1 像素的边缘轮廓通常是孤立噪点 continue filtered.append(c) return filteredmin_area是个跟具体数据集强相关的参数。300dpi 扫描的正常汉字笔画单个轮廓面积通常在几百到几千像素如果是手机拍的低分辨率图一个笔画可能只有几十像素。这个值设大了会把细笔画过滤掉设小了噪点混进来。我的习惯是先把二值图上所有轮廓的面积分布打出来然后取面积的 5% 分位数作为初始阈值。3.2 挑选稳定的特征面积、宽高比、周长和像素密度轮廓有了下一步是把每个轮廓变成几个数值这些数值就是“特征”。特征选得好不好决定了比对的上限。对笔迹识别来说我一般选三到四个几何特征它们都满足同一个要求对同一人的不同书写状态相对稳定对不同人的笔迹有区分度。def extract_features(binary, min_area20): contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) feats [] for c in contours: if cv2.contourArea(c) min_area: continue x, y, w, h cv2.boundingRect(c) area cv2.contourArea(c) perimeter cv2.arcLength(c, True) # 紧凑度面积相同的情况下周长越小形状越接近圆 compactness 4 * np.pi * area / (perimeter * perimeter 1e-6) # 长宽比反映这个字形的扁与长写成“一”和“丨”差异极大 aspect_ratio w / (h 1e-6) # 像素密度轮廓区域内实际笔画像素占的比例 roi binary[y:y h, x:x w] density cv2.countNonZero(roi) / (w * h 1e-6) feats.append([compactness, aspect_ratio, density]) return np.array(feats)这三个特征各有讲究。紧凑度的取值范围是 0 到 1越接近 1 说明形状越像圆同一个人写“口”字时外轮廓的紧凑度通常稳定在某个小区间而换成另一个人写同一个字方框的长宽比例会明显不同。长宽比是用来区分字形走向的这个特征对潦草的连笔字尤其敏感——连笔会把竖向笔画拉长长宽比变化很大。像素密度则反映了笔画的粗细习惯有人写字重、笔画粗同一个字的轮廓框里非零像素占比就高。注意分母都加了1e-6这是为了防止perimeter或h为 0 导致除零错误。代码里每个轮廓都被看作一个独立样本一张笔迹图最后得到的特征矩阵形状是(N, 3)N 是轮廓数量。比对时不能只拿某一个轮廓去比而要把整张图所有轮廓的特征聚合起来常用的做法是取每列特征的均值和标准差拼成一个长向量。均值反映整体风格标准差反映书写稳定性——同一人的笔迹各字大小均匀标准差就小不同人的书写忽大忽小标准差差异明显。3.3 相似度计算归一化距离的坑特征提取完最后一步就是判定。判定逻辑很直接把待测样本的特征向量和库里每个模板的特征向量算距离距离小于阈值的判为“同一个人”否则判为“不是同一个人”。def normalize_features(feats): # 按列做 min-max 归一化特征数值都压到 0~1 区间 min_vals feats.min(axis0) max_vals feats.max(axis0) eps 1e-6 return (feats - min_vals) / (max_vals - min_vals eps) def compute_distance(feat_a, feat_b): # 归一化后的欧氏距离数值越小表示越相似 return np.linalg.norm(feat_a - feat_b)归一化是这里最不能省的一步。如果跳过它原始特征里“面积”可能是几千上万的数量级而“紧凑度”只有 0 到 1面积会把其他特征完全压垮算出来的距离基本只反映面积差异等于其他特征白提了。归一化之后所有特征都变成 0 到 1 的数值每个维度对距离的贡献才公平。我见过不少翻车案例都是卡在这一步没做归一化距离永远都很大于是阈值设得很大最后所有样本都判成“同一人”准确率看起来很高实际完全没用。正确的做法是先对特征矩阵做归一化再把归一化后的距离分布打出来看同一人笔迹的距离和不同人笔迹的距离有没有明显分界然后在这个分界附近选阈值。不要拍脑袋设阈值距离分布是真实的、可以看的选阈值就不该靠猜。4. 复现源码包的常见报错排查环境、数据类型与路径4.1 import cv2 报模块找不到先分清是没装还是装错包ModulenotFoundError: No module named cv2是复现这个项目时出现频率最高的报错几乎每个新手都会遇见。现象很直接运行程序第一行import cv2就崩。原因通常不是没装而是装进了错误的 Python 环境。最常见的是 PyCharm 下项目解释器选成了系统自带的 Python而pip install opencv-python装进了 conda 的另一个环境两边互相看不见。解决的第一步是先确认当前解释器路径再决定往哪装python -c import sys; print(sys.executable) pip install opencv-python numpy如果是已经安装了opencv-python-headless导致cv2.imshow报错那是另一个坑headless 版本去掉了 GUI 支持适合服务器端但本地调试看不了图。解决办法是卸载重装完整版pip uninstall opencv-python-headless然后pip install opencv-python。我一般会建议新手在项目根目录建一个requirements.txt把opencv-python和numpy的版本写进去换机器重装时一条命令搞定省得每换一台电脑就重新踩一遍环境坑。4.2 cv2.error: OpenCV(4.4.0) 断言失败findContours的输入和返回值陷阱运行到cv2.findContours时报类似cv2.error: OpenCV(4.4.0) ... error: (-215:Assertion failed) npoints 0 ...这是图像识别项目里最典型的翻车现场。现象有两种一是程序直接抛异常崩溃二是明明图像看起来正常却报输入格式错误。第一个原因是 OpenCV 版本升级后的返回值变化。OpenCV 3.x 的findContours返回三个值(image, contours, hierarchy)OpenCV 4.x 返回两个值(contours, hierarchy)。源码包如果是照着老版本写的在 4.x 上解包就会报ValueError: not enough values to unpack。解决方法是把contours, hierarchy cv2.findContours(...)改成四个变量的写法但说实话直接改成两个变量的写法更干净因为image那个返回值在 4.x 已经没有意义了。查自己装的版本用cv2.__version__。第二个原因是输入图像不是单通道二值图。findContours要求输入是uint8类型的单通道图。有些源码里先做了cv2.cvtColor转灰度接着不小心又做了一次cv2.cvtColor把灰度转成了三通道或者二值化后没有转成uint8都会触发断言失败。解决方法是传参前加一道保险binary np.asarray(binary, dtypenp.uint8)并检查len(binary.shape)必须等于 2。4.3 全部匹配同一人阈值与归一化的连环坑识别结果“全部判为同一个人”或者“全部判为不同人”这种结果通常不是代码逻辑错了而是数没算对。现象是跑完比对脚本准确率要么 100% 要么 0%怎么看都觉得不对劲。原因九成出在特征没有归一化。前面提过面积特征的数值可能是紧凑度的一万倍欧氏距离会被面积完全主导不同人的笔迹面积差异大算出来的距离全部很大阈值稍微设高点就全军覆没“全中”设低点就“全不中”。另一个原因是我见过有人把距离阈值设成float(inf)或一个巨大的数来“确保有匹配结果”这等于没有判定。解决方法是先归一化特征再画距离分布图。把同一人笔迹之间的距离和不同人笔迹之间的距离分别画成直方图两个分布有重叠就说明特征选得不够重叠越小说明特征区分度越高。阈值取两个分布峰值的中间位置比任何拍脑袋的数值都可靠。注意归一化的min和max必须在训练集上计算不能用全部数据——否则等于让模型偷看了答案换新样本就失效。4.4 图片路径报错源码包目录结构与中文路径最后一道坎是路径问题。源码包解压后图片放在data/目录下代码里写的是相对路径data/samples/001.jpg。如果你在别的目录下运行脚本Python 的工作目录跟代码目录不一致就会报FileNotFoundError。这几乎是压缩包类源码的通病。解决方法是不要依赖“当前工作目录”而是把路径基于脚本所在目录计算from pathlib import Path # 用脚本所在目录作为基准而不是当前终端所在目录 BASE_DIR Path(__file__).resolve().parent img_path BASE_DIR / data / samples / 001.jpg另一个更隐蔽的坑是中文路径。cv2.imread在 Windows 下对带中文的路径支持不好通常不会报错而是静默返回None导致后续代码拿空值计算报错信息跟路径毫无关系。如果样本文件夹名字带了中文我习惯用cv2.imdecode配合np.fromfile绕过这个限制import numpy as np import cv2 def imread_unicode(path): # 先用 numpy 读取原始字节再用 imdecode 解码绕过中文路径限制 data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)判断读图是否成功在imread后立刻检查返回值是否为None是就打印路径并跳过别等到后面空指针崩溃再回头查。5. 让识别率从能跑变成能用参数调节与效果验证5.1 先立一个评估标准把“像不像”变成数字很多复现者跑通之后就停了觉得“能出结果”就是胜利。实际上程序能跑和系统能用是两回事。要判断参数调得好不好得先把“准确率”这个数字定义出来。常见的做法是留出法每个人准备两组笔迹一组做模板库一组做待测样本测试时把待测样本跟模板库里每个人比对看能否正确匹配到本人。def evaluate_system(samples_by_person, threshold10.0): persons list(samples_by_person.keys()) total 0 correct 0 # 遍历所有成对组合同一人的两两算一对不同人的两两也算一对 for i in range(len(persons)): for j in range(i 1, len(persons)): for feat_a in samples_by_person[persons[i]]: for feat_b in samples_by_person[persons[j]]: dist compute_distance(feat_a, feat_b) predicted_same dist threshold actual_same persons[i] persons[j] total 1 if predicted_same actual_same: correct 1 return correct / total if total else 0评估函数里必须同时包含“同一人”和“不同人”的样本对否则结果会失真。如果只测同一人的匹配把所有样本都判成同一人准确率也有 100%一点参考价值都没有。真实场景里拒认把本人判错和误认把别人判成自己是两码事评估脚本里这两个错误率要分开看。把predicted_same和actual_same的四种组合打印出来你能直观看到错误是偏向哪个方向。5.2 三个最值得调的参数二值化阈值、最小轮廓面积、距离阈值整个系统里最影响结果的参数有三个其他的都可以先放着不动。这三个参数相互牵连调的时候要按顺序来否则容易越调越乱。参数常见范围影响调参方向二值化阈值OTSU 自动或 150~200阈值高笔画变细断笔阈值低笔画粘连有粘连时降低有断笔时升高最小轮廓面积10~50 像素过滤噪点也过滤细碎笔画先看面积分布直方图再定距离阈值归一化后 0~2决定判定松紧越小越严格按距离分布的分位数取比如 10% 分位调参顺序建议从图开始先不要碰代码里的数字。把二值化后的图像保存下来用图片查看器放大看笔画有没有断、有没有粘连、噪点是不是已经被滤干净了。图干净了再调最小轮廓面积把轮廓数量打印出来跟图像上肉眼可见的笔画数量对比。最后才调距离阈值这一步必须依赖评估脚本的数字来选看哪个阈值下拒认率和误认率的总和最低。我自己的经验是这三个参数每换一批扫描设备或纸张大概率要重新调一遍。这不是代码写错了是图像本身的分辨率和噪声特性变了。所以源码包里如果带了批处理脚本我会提前留一个参数配置文件把这三个值单独放在文件头部不需要为改参数去翻几百行代码。5.3 用形态学操作处理连笔膨胀腐蚀的边界设置手写笔迹最难处理的问题是连笔。字与字之间的游丝、笔画之间的细连接会让findContours把两个字当成一个轮廓轮廓数量骤减特征统计完全失真。解决连笔的常见做法是形态学开运算——先腐蚀再膨胀可以切断细的连接线同时保留笔画的粗壮主体。kernel cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)开运算的核大小非常敏感。(2, 2)的核能切断很细的连接但对笔画正常的字几乎无影响(3, 3)的核会明显削掉笔画边缘让字整体变细特征里的密度值会下降。我的建议是从(2, 2)开始观察开运算后的结果图如果连笔还在逐渐加大核的大小直到边界情况——某个字的正常笔画开始出现断点这时候回退到上一档就是当前数据集的最优核。这个操作只对“细连接”有效。如果两个人的字已经重重叠在一起开运算会把真正的笔画也干掉。对于这种样本任何形态学操作都救不回来能做的就是放弃该区域的轮廓只保留完整独立的字形参与特征统计。识别系统跟人一样遇到太潦草的字也会犹豫与其给一个错的答案不如直接标记为“低置信度需要人工复核”。6. 进阶方向从轮廓特征到轻量级模型的可扩展路径这套基于轮廓特征的方案天花板在于连笔严重时特征会失真而且它本质上是“特征工程 距离度量”对潦草字的泛化能力有限。如果样本量足够、识别率不够用我有三个已验证的进阶方向可以顺着现有代码改。第一个方向是把特征从几何统计换成方向梯度直方图HOG。HOG 关注的是笔画局部的方向分布比面积、周长这类全局统计更抗连笔干扰。OpenCV 里cv2.HOGDescriptor可以直接算特征维度从 3 维涨到 300 多维归一化后仍用欧氏距离或余弦距离判定代码改动的部分只是特征提取那一段后面比对的链路完全复用。第二个方向是用特征点匹配替代轮廓比对。对笔迹做 SIFT 或 SURF 关键点提取然后比对两幅图的关键点匹配率。这个思路对同一人笔迹的局部形变更鲁棒但手写笔迹纹理弱关键点可能不够多实际效果取决于样本清晰度需要先验证再决定是否投入。第三个方向是上轻量级模型。每个人准备 5 份以上笔迹样本裁剪成固定尺寸的灰度图用小型 CNN两三个卷积层就够训练成特征提取器取倒数第二层的输出作为笔迹 embedding。这一步能显著提升对连笔字的鲁棒性代价是标注成本——至少要几十个人的样本才能训起来。验证方法也顺手把提取到的特征向量导出成 CSV用 t-SNE 降维到二维平面画散点图同一人的笔迹点聚得拢说明特征有效聚不拢就回头加数据或调整网络结构。最后分享一个我自己的教训最初做这个系统时调参全靠目测觉得“看起来挺像”就完事。后来同一批算法换了一台扫描仪识别率从 85% 直接掉到 40%。从那以后我再也不凭感觉设阈值每次都会把距离分布直方图打出来看两个分布的重叠面积决定要不要动特征而不是只调阈值硬掰。这套系统的价值不在于算法多高级而在于它把“像不像”变成了可量化、可复现的数字——这本身就是图像识别项目里最值钱的一步。希望帮到你。本文还有配套的精品资源点击获取
返回列表