基于OpenCV和Python的笔迹识别系统:图像预处理、特征提取与分类实战

发布时间:2026/10/8 7:35:31
基于OpenCV和Python的笔迹识别系统:图像预处理、特征提取与分类实战
简介一套基于Python与OpenCV的笔迹识别系统项目源码面向计算机视觉方向的课程设计与毕业设计场景主要用于实现笔迹检测、特征提取与分类识别。该项目已获导师指导并通过作为九十七分高分课程大作业代码完整且可直接运行可作为同类课题的参考蓝本。压缩包为ZIP格式容量约三十八点二二MB内含源代码及依赖资源解压即可查看目录结构与核心模块。目前已有九百五十八人学习下载口碑良好。通过学习本资源读者能够掌握图像预处理、边缘检测、轮廓分析、特征匹配等常见图像识别流程理解从样本输入到识别结果输出的完整工程化实现。该资源覆盖从图像读取、预处理到特征提取与识别的完整链路便于学习者迁移至其他图像识别场景是快速上手OpenCV实战项目的高质量素材。1. 笔迹识别是图像识别里最容易被低估的一道题很多人以为笔迹识别就是“比两张图像不像”打开 OpenCV 比对像素就行。真做起来才发现同一句话用笔压重一点、写快一点像素级差异比不同人写的还大。这套基于 Python OpenCV 的笔迹识别系统是我见过把图像识别课程大作业做到能答辩、能演示、还能扩展的源码包之一。它解决的并不是“鉴定谁写的”这种刑侦问题而是通过图像预处理、特征提取和分类器完成对扫描或拍照笔迹的区分与匹配。对正在找 OpenCV 项目练手、准备课程设计、或者想搞懂图像识别完整流程的人来说这套代码的价值在于它把“图像识别”从黑匣子拆成了三段——预处理、特征、分类每一段都有能改的参数、能看的中间结果也有能踩的坑。下面我就按实际跑码的顺序把这套系统的原理、复现步骤和踩坑记录完整拆开。2. 从图像到特征笔迹识别系统的核心流程与选型理由2.1 图像预处理灰度化、二值化与去噪为什么要按这个顺序笔迹识别第一步不是直接抓特征而是把输入图像变成干净、稳定的二值图。常见做法是彩色图像转灰度灰度做二值化二值图再去做形态学处理。这个顺序不能乱。如果先二值化再去灰度等于在三个通道上分别做阈值分割结果会因为光照偏色产生大量随机噪声先灰度再二值化则把亮度信息统一到一个维度上阈值才好选。以这套源码为例预处理函数一般长这样import cv2 import numpy as np def preprocess(image_path): # 读取图像保留原始色彩空间做备份 img cv2.imread(image_path) # 第一步转灰度 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 第二步高斯模糊抑制扫描噪点 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_BINARY_INV cv2.THRESH_OTSU是组合标志表示“先按 Otsu 算出最佳阈值再做反色二值化”。反色的目的是让笔迹变成白色、背景变成黑色因为 OpenCV 的轮廓检测函数默认在黑色背景上找白色前景效果更好。GaussianBlur的核取(5, 5)是为了去扫描纹理如果原图清晰度很高可以缩小到(3, 3)否则笔画边缘会被磨掉。我一般会在这一步把中间结果cv2.imwrite(debug_binary.png, binary)存下来肉眼确认二值图里笔画连续、背景干净。很多人跳过去噪直接二值化结果纸上纤维、网格线全变成白色噪点特征提取直接翻车。2.2 特征提取轮廓、Hu矩、像素密度特征向量怎么拼预处理做完图像就是一张黑白分明的单通道图。接下来要把它变成一组数字也就是特征向量。这套系统用了几类特征组合连通域轮廓数、笔画像素密度、以及基于轮廓的 Hu 矩。先看轮廓相关特征def contour_features(binary): # 查找所有白色笔迹区域的外轮廓 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 过滤掉少于10个像素点的小噪声轮廓 valid [c for c in contours if cv2.contourArea(c) 10] # 返回轮廓数量和总面积占比 total_area binary.shape[0] * binary.shape[1] ink_ratio sum(cv2.contourArea(c) for c in valid) / total_area return len(valid), ink_ratioRETR_EXTERNAL只取最外层轮廓避免笔画内部的孔洞被当成额外特征。CHAIN_APPROX_SIMPLE压缩轮廓点数只保留端点降低计算量。ink_ratio是墨迹面积占比对笔的粗细和书写力度很敏感——同样一个字用力大的人墨迹面积明显更大。Hu 矩则是轮廓的形状特征它有 7 个不变矩对平移、旋转、缩放不敏感。OpenCV 提供了现成函数def hu_moments_feature(binary): # 计算图像矩 moments cv2.moments(binary) # 转为Hu矩取绝对值和log压缩避免数值差异过大 hu cv2.HuMoments(moments).flatten() hu -np.sign(hu) * np.log10(np.abs(hu) 1e-10) return hu这里np.log10压缩是因为 Hu 矩各阶数值量级差距很大第一阶可能是1e-2第七阶可能到1e-9不压缩直接进分类器小数值特征会被大数值淹没。 1e-10是防止log10(0)出现。常见的错误是不取对数直接拼特征导致分类器基本只看第一阶矩识别率上不去。最终特征向量就是np.concatenate([contour_features, hu_moments_feature])大概 9 维。别嫌它维度低对课程设计来说低维特征配合小样本反而更稳不容易过拟合。2.3 分类器怎么选距离度量、SVM 还是简单阈值这套源码在分类部分没有上深度学习而是用了传统机器学习里的两种方案最近邻距离和 SVM。对于几十到几百份笔迹样本的课程项目深度学习不仅训练慢而且极易在样本不足时过拟合。最近邻则直接把每类笔迹的特征均值存成模板预测时算欧氏距离或余弦相似度。代码里核心逻辑是这样的class HandwritingRecognizer: def __init__(self): self.templates [] # 每类笔迹的特征均值 self.labels [] def train(self, features_list, labels): # 同一标签的特征做平均形成模板 unique_labels set(labels) for lb in unique_labels: feats [f for f, l in zip(features_list, labels) if l lb] self.templates.append(np.mean(feats, axis0)) self.labels.append(lb) def predict(self, feat, metriceuclidean): # 计算待识别特征与所有模板的距离 dists [] for tpl in self.templates: if metric euclidean: d np.linalg.norm(feat - tpl) elif metric cosine: d 1 - np.dot(feat, tpl) / (np.linalg.norm(feat) * np.linalg.norm(tpl) 1e-8) dists.append(d) return self.labels[int(np.argmin(dists))], min(dists)参数metric可以切换距离类型。我自己的测试经验是欧氏距离对笔画粗细差异明显的情况更敏感适合区分书写力度不同的笔迹余弦相似度更关注特征的方向分布适合同一人笔迹在轻微平移旋转下的匹配。SVM 的版本在源码里也有用sklearn.svm.SVC核函数默认rbf。如果你发现最近邻识别率卡在 85% 上不去换成 SVM 往往能拉到 92% 以上但前提是样本必须归一化否则不同量纲的特征会让核函数计算失真。3. 复现源码的三个步骤环境配置、目录结构与核心模块拆解3.1 环境配置Python 版本、OpenCV 与 NumPy 的坑下载这套源码后第一步不是直接跑python test.py而是把环境锁定。源码里的requirements.txt一般写着opencv-python、numpy、scikit-learn。这里有个常见坑OpenCV 4.x 和 NumPy 1.x 的组合在部分 Python 3.7 环境下会出现cv2.error: OpenCV(4.4.0) ... Assertion failed这类报错问题多半出在二值化或findContours的输入数据类型不对不是 OpenCV 本身坏了。我的习惯是新建虚拟环境conda create -n handwriting python3.8 conda activate handwriting pip install opencv-python4.5.5.64 numpy1.21.6 scikit-learn1.0.2Python 3.8 是兼容性比较好的选择OpenCV 4.5.5 对findContours的返回值处理更规范NumPy 1.21 不会出现np.float被移除的报错。如果你用 Python 3.10np.float这类旧写法会直接AttributeError很多老项目源码就是因为这个跑不起来。装完先跑一句验证python -c import cv2, numpy; print(cv2.__version__, numpy.__version__)如果输出4.5.5 1.21.6环境就稳了。别急着升级最新版OpenCV 4.8 对部分旧版模型的读取方式变了容易多出莫名其妙的AttributeError: module cv2 has no attribute face之类的错。3.2 跑通训练与预测主流程代码逐段说明这套源码的入口通常是main.py分两个模式train和test。训练模式读取dataset/目录下按人分好的样本输出特征模板到model/测试模式读取一张待识别图片打印识别结果和信心值。核心主流程可以拆成三段# 第一段扫描数据集构建样本列表 def load_dataset(root_dir): samples [] # (label, image_path) for person in os.listdir(root_dir): person_dir os.path.join(root_dir, person) if not os.path.isdir(person_dir): continue for img_name in os.listdir(person_dir): if img_name.endswith(.png) or img_name.endswith(.jpg): samples.append((person, os.path.join(person_dir, img_name))) return samples这段要求dataset/下每个子目录名代表人名或笔迹编号子目录内放该人的多张笔迹图片。这是最常见的组织方式改起来也容易。注意os.listdir的顺序不固定如果后续要做随机划分需要先shuffle否则训练和测试可能用同一人的同批图。# 第二段对每张样本提取特征 def extract_all_features(samples): X, y [], [] for label, path in samples: binary preprocess(path) feat build_feature_vector(binary) X.append(feat) y.append(label) return np.array(X), np.array(y)build_feature_vector就是把上一章那几个特征函数拼接起来。调试时建议打印每个特征的范围比如print(feat.min(), feat.max())如果某个特征的方差接近 0说明它对当前数据集没有区分度可以考虑去掉。# 第三段训练模板并评估 X, y extract_all_features(samples) # 划分训练集和测试集注意stratify按标签分层 from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) recognizer HandwritingRecognizer() recognizer.train(X_train, y_train) # 在测试集上计算准确率 correct 0 for feat, true_label in zip(X_test, y_test): pred, conf recognizer.predict(feat) if pred true_label: correct 1 print(fAccuracy {correct / len(X_test):.2f})这里stratifyy是保证每个人在训练集和测试集中的比例一致不然可能某个人的样本全分到测试集识别率瞬间掉到 0。random_state42是固定随机种子确保两次跑出来的结果可以对比。3.3 换自己的笔迹样本数据组织与标注格式很多同学下载源码后拿自带的样本跑通了一换成自己写的字就报错或者识别率暴跌。问题九成出在图片规格和标注组织上。这套源码默认输入是单字或短词图片不是整页手写扫描件。如果你拿一张 A4 纸的整页笔记直接喂进去预处理后图像里有大量文字、行线、页边距特征向量会被干扰到无法使用。正确做法是先把每个字裁剪出来保存成单独的图片。我一般用这一小段脚本批量切字import cv2 import numpy as np def crop_chars(image_path, output_dir): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) # 二值化反色让笔迹为白色 _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 找连通域按外接矩形切出单个字 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(binary, connectivity8) for i in range(1, num_labels): x, y, w, h, area stats[i] if area 50 or w 200 or h 200: continue # 过滤噪声和过大的粘连块 char_img img[y:yh, x:xw] cv2.imwrite(f{output_dir}/char_{i}.png, char_img)参数connectivity8表示八邻域连通适合笔画粘连不太严重的场景。area 50过滤掉小噪点w 200过滤掉可能是整行文字的横条。如果切出来文字被腰斩适当调大面积阈值如果两个字粘在一起切不出来就得先做一次腐蚀断开笔画但这又会改字形特征属于一个两难取舍后面避坑章会细说。数据的标注格式就是dataset/标签/图片.png标签可以是“张三”“李四”这种文本也可以是person_01。源码训练时直接把目录名当标签不需要额外 CSV。如果要做更细的区分比如区分同一个人的正常书写和快速书写目录名可以写成zhangsan_normal、zhangsan_fast这样分类器学到的就是风格差异不只是人的差异。4. 参数调优与识别效果阈值、形态学核、特征权重怎么调4.1 二值化阈值Otsu 与固定阈值的差距预处理里最影响识别率的参数就是二值化阈值。固定阈值cv2.threshold(gray, 127, 255, ...)面对的坑是扫描件亮度不均时纸张阴影区域会被误判为背景笔画较浅的部分则被误删。Otsu 自动计算阈值能在大多数均匀光照下给出比固定阈值好得多的结果。但 Otsu 不是万能的。如果原始图片有强烈阴影梯度Otsu 会被阴影拉偏阈值。一个简单补救是自适应阈值binary cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize31, C10)blockSize31是计算局部阈值的邻域大小必须为奇数。C10是从邻域均值中减去的常量越大被判定为前景的像素越少。我试过把C从 5 调到 15笔迹整体变细但对光照不均的鲁棒性明显提升。什么时候用哪种我的经验是扫描件用 Otsu手机随手拍的有阴影照片用自适应阈值。这套源码默认 Otsu改动时只需替换一行。4.2 形态学操作膨胀腐蚀对笔画断连的影响二值化之后笔迹可能出现断点比如圆珠笔的滚珠痕迹会把“横”写成虚线。传统图像识别里常用开运算先腐蚀后膨胀去掉小噪点闭运算先膨胀后腐蚀连接断裂笔画。源码里常见的操作是kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations1)(3, 3)的矩形核比较保守只连接几个像素内的断点。iterations加大到 2笔画会更粗更连续但代价是两个字之间也容易被连成一片。如果识别目标是大段的整页图像腐蚀膨胀会在特征提取前改变笔画形态Hu 矩会随之变化。我的建议是做单字识别时闭运算核不超过(5, 5)做整页版面分析时干脆不做形态学处理靠连通域过滤来去噪。4.3 特征权重为什么 Hu 矩不是万能的Hu 矩对形状的全局描述能力很强但对笔画内部的细微差异不敏感。两个不同人写同一个字整体轮廓可能非常接近但起笔收笔的角度、笔画间距差异明显。这时只靠 Hu 矩区分度很低。这套源码的做法是把轮廓特征与 Hu 矩拼接。如果你发现识别率偏低可以先做一次特征重要性对比from sklearn.ensemble import RandomForestClassifier clf RandomForestClassifier(n_estimators100, random_state42) clf.fit(X_train, y_train) # 输出特征重要性 print(clf.feature_importances_)跑完你会发现ink_ratio这类像素密度特征往往排在前面而 Hu 矩的后几阶重要性接近 0。这说明当前数据集的主要差异在笔画粗细和浓淡上而不是形状拓扑。此时可以保留前 4 阶 Hu 矩后 3 阶丢掉识别率不降反升。这是典型的“特征不是越多越好”的场景。我自己就遇到过加满 7 阶矩后准确率掉了 3 个百分点的案例剪掉低方差特征反而更稳。如果还想提升可以把图像划分成3x3或4x4的格子统计每个格子内的墨迹占比形成局部密度特征再拼到原特征里。这种方法对空间布局差异的捕捉比全局 Hu 矩强得多改造成本也不高。5. 笔迹识别避坑指南从 OpenCV 报错到识别不准的 5 个常见问题5.1 现象cv2.error: OpenCV(4.4.0) ... assertion failed这个问题在非虚拟环境里用系统 Python 跑老项目时出现频率最高。报错信息经常指向findContours或threshold但代码看起来完全正常。原因OpenCV 4.4 与某些版本的 NumPy 在数据类型处理上不兼容尤其是传入findContours的图像矩阵不是连续内存或不是uint8单通道时会抛断言错误。另外用pip install opencv-python默认装的是最新版可能与项目代码里的旧 API 冲突。解决严格复现源码要求的环境将 OpenCV 锁定到 4.5.5.64NumPy 锁定到 1.21.6。如果报错仍然存在在findContours前加一句binary np.ascontiguousarray(binary, dtypenp.uint8)强制内存连续。这个操作能解决 90% 的诡异断言。5.2 现象训练准确率 95%测试准确率只有 60%这是最常见的过拟合信号。原因有两层一是数据划分时没有打乱训练集和测试集来自同一批照片的左右半边特征过于相似二是样本量太少每个人只有 35 张图模型把噪声当成了特征。解决先检查train_test_split是否设置了shuffleTrue和random_state。然后增加数据增强把每张原图做微小平移、旋转 12 度、缩放 0.981.02 倍生成多份变体作为训练样本。注意旋转角度不能超过 5 度否则笔迹结构就变了等于换了一个人。5.3 现象同一个人的两张字识别成两个不同的人这种翻车往往出在预处理阈值不一致上。第一张图扫描亮度正常第二张图偏暗Otsu 算出的阈值不同二值化后笔画粗细差异巨大ink_ratio直接变了 30%。解决不要直接对原图提取特征先做一次亮度归一化。常见做法是使用cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX)让每张图的灰度范围拉伸到 0255。这样即便原图亮度不同进入二值化前的灰度分布也一致。源码里如果没有这步建议自己加。5.4 现象findContours返回的轮廓数量爆增每张图的轮廓特征都不一样问题出在不同图像上残留的小墨点、纸纤维被当成有效轮廓。contourArea 10这个过滤条件太弱对高分辨率扫描件一个墨点可能就有几百像素面积。解决把面积阈值改成动态值比如max(10, binary.shape[0] * binary.shape[1] * 0.0005)。同时增加一个宽高比限制过滤掉明显是长条状噪声的区域。如果你发现切出来的“字”其实是一道墨水痕大概率就是长宽比异常的轮廓没被过滤。5.5 现象换用 SVM 后准确率反而比最近邻更低原因SVM 对特征尺度敏感。Hu 矩经过 log 压缩后量级在1e-2到1e-3而像素密度特征在0.2到0.8两者差距悬殊RBF 核计算时直接忽视小量级特征。最近邻用欧氏距离时同样存在这个问题只是距离度量对尺度没有 SVM 的核函数那么敏感。解决先做特征标准化再进分类器。from sklearn.preprocessing import StandardScaler scaler StandardScaler().fit(X_train) X_train_scaled scaler.transform(X_train) X_test_scaled scaler.transform(X_test)标准化的意思是让每个特征的均值为 0、方差为 1。这之后 SVM 才真正开始生效。如果你用最近邻标准化后还要重新训练模板因为模板均值必须在标准化后的空间里计算。这一点源码里容易漏建议训练和预测用同一个scaler实例不能对预测数据单独fit。6. 进阶技巧用轮廓匹配做单字验证把识别结果可视化输出当你跑通整体流程后可以进一步用 OpenCV 的轮廓匹配函数cv2.matchShapes做单字级别的细验证。这个函数返回两个形状的相似度数值越接近 0 越相似。它能弥补全局特征在局部笔画差异上的迟钝。def match_char(binary1, binary2): contours1, _ cv2.findContours(binary1, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours2, _ cv2.findContours(binary2, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours1 or not contours2: return float(inf) # 取最大轮廓做匹配 c1 max(contours1, keycv2.contourArea) c2 max(contours2, keycv2.contourArea) return cv2.matchShapes(c1, c2, cv2.CONTOURS_MATCH_I2, 0.0)用CONTOURS_MATCH_I2模式时数值受形状面积差异影响较大换CONTOURS_MATCH_I1则更偏重拓扑比例。实际项目中我会把matchShapes的结果当成一个额外特征拼进原来的特征向量里相当于每个测试样本多了一个“与标准模板的相似度”识别率通常还能再涨 23 个百分点。另一个值得做的改进是可视化输出。源码自带的测试模式只打印文字结果不直观。我给main.py加了一个画框逻辑识别某张图后把原图中匹配到的区域用绿色矩形框出并写上预测标签和置信度方便答辩演示。def visualize_result(image_path, pred_label, conf): img cv2.imread(image_path) # 在整幅图像上画一个外边框表示识别区域 h, w img.shape[:2] cv2.rectangle(img, (5, 5), (w - 5, h - 5), (0, 255, 0), 2) label_text f{pred_label} ({conf:.2f}) cv2.putText(img, label_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2) cv2.imwrite(result.jpg, img)putText的中文会显示成问号因为 OpenCV 自带字形不支持中文字符。我的做法是改用 PIL 画中文标签OpenCV 只负责画框。这个细节在答辩现场很加分评审老师一眼能看出你理解图像基础操作。把那以后我每次调笔迹识别系统都会先存三张中间图原图、二值图、轮廓图。哪个环节出了问题看中间图比看日志快十倍。特征向量拼好之后也强制打印一遍统计值确认没有nan或全零列。这套源码的整体质量在课程作业里算很扎实的但终究是给人去理解和改造的样本不是拿来直接上生产的产品。你按我说的环境配置跑通一遍再动手改几个阈值和特征维度就能真正摸清图像识别从图像到数字的完整链路。希望帮到你。本文还有配套的精品资源点击获取