资讯动态

OpenCV手写笔迹识别毕业设计:从图像预处理到HOG+SVM与CNN实战

发布时间:2026/10/1 19:05:22 来源:尧图企业网站定制
简介这份资源是面向高校学生与图像识别初学者的笔迹识别系统完整项目包可直接用于毕业设计选题、课程实践或自学OpenCV图像处理。项目以Python与OpenCV为核心覆盖图像灰度化、二值化、边缘检测、最大外接矩形生成与矩形筛选等基础处理流程并采用余弦距离与直方图相似度两级比对策略完成笔迹相似度判断最终将运行时间、结果与程序状态输出至txt文件界面部分由PyQt5实现支持文件选取、图片展示与一键运行。压缩包共29个文件约43.92MB包含2个py源码、9个png与4个jpg测试素材、9个docx及doc、pptx等文档涵盖需求分析、概要设计、详细设计、数据库设计、测试与安装部署等完整说明书另附README与工作日志。已有146人学习适合需要完整源码、文档模板与实现思路的读者参考复用。1. 从一张手写签到表说起OpenCV 笔迹识别到底在识别什么很多人第一次听到「笔迹识别」脑子里浮现的是刑侦剧里比对签名真伪的画面。但毕业设计里真正能落地的笔迹识别绝大多数不是「验真伪」而是「认内容」——把一张手写数字、手写字母或者手写汉字的图片经过 OpenCV 图像识别的一整套预处理再喂给分类模型最后输出这个字是什么。这个区别非常关键因为它直接决定了你要不要做笔迹风格建模、要不要采集同一批人的多份样本、要不要处理书写力度和连笔。我见过太多同学一上来就想做「签名真伪鉴别」结果卡在数据采集上同一个人的签名本身就千变万化不同人的签名又可能高度相似没有几百份带标签的真实签名根本训不出东西。而手写字符识别就友好得多MNIST 这种现成数据集直接能用OpenCV 负责把图片洗干净剩下的交给分类器。所以这篇笔记锁定的方向是用 OpenCV 做图像预处理与特征提取搭一套能跑通、能演示、能写进论文的手写笔迹识别系统。它适合谁适合正在做计算机毕业设计、手里有 Python 基础但没碰过图像处理的同学也适合想快速搞懂 OpenCV 图像处理项目完整链路的开发者。整套流程不依赖昂贵设备一台普通笔记本加一个摄像头就能演示实时识别。接下来我会把「怎么装、怎么洗图、怎么提特征、怎么训、怎么避坑」一层层拆开让你照着能复现。2. 环境搭建与 OpenCV 安装别在第一步就翻车2.1 为什么选 Python OpenCV 这套组合做笔迹识别语言和库的选择其实没那么多纠结。Python 生态里有 OpenCV、NumPy、scikit-learn、PyTorch从图像读取到模型训练一条龙而且社区资料多到溢出遇到报错基本都能搜到答案。C 版本的 OpenCV 性能更好但对毕业设计来说开发效率比那点性能重要得多。你不需要为了跑一个手写数字识别去折腾 CMake 编译除非你的题目明确要求 C 实现。OpenCV 在这个系统里承担的角色是「图像清洗工」。原始的手写图片往往有噪声、有倾斜、有光照不均、有粗细不一的笔画直接丢给分类器效果会很差。OpenCV 提供的灰度化、二值化、去噪、轮廓检测、尺寸归一化这些操作就是把一张「脏图」变成一张「干净的标准图」。这一步做得好不好直接决定后面模型的准确率上限。常见做法是OpenCV 做预处理和特征工程scikit-learn 或 PyTorch 做分类。如果你的题目要求「深度学习图像识别」那就把预处理后的图喂给 CNN如果要求轻量、可解释就用 HOG 特征加 SVM。两条路我都会讲。2.2 安装 OpenCV 的三种方式和踩坑点安装 OpenCV 看着简单但ModuleNotFoundError: No module named opencv这个报错常年霸榜。核心原因是包名和导入名不一致你pip install的是opencv-python但代码里import的是cv2。记住这个对应关系能省你半小时。# 方式一pip 直接装最推荐适合绝大多数人 pip install opencv-python # 如果需要额外模块如 SIFT、跟踪算法装 contrib 版 pip install opencv-contrib-python # 方式二conda 装适合用 Anaconda 管理环境的同学 conda install -c conda-forge opencv # 方式三验证安装是否成功 python -c import cv2; print(cv2.__version__)上面三条命令里第一条是主力第二条在你需要用到专利保护或实验性算法时才装。注意不要同时装opencv-python和opencv-contrib-python两者会冲突表现为cv2能导入但某些函数报错。第三条是验证命令能打印出版本号就说明装好了。如果你用的是 PyCharm还要注意解释器选对没有。很多人 pip 装到了系统 Python但 PyCharm 项目用的是虚拟环境结果就是命令行能跑、IDE 里报错。解决办法是在 PyCharm 的 Settings 里把项目解释器切到你 pip 安装的那个环境或者直接在 PyCharm 的 Terminal 里重新装一遍。提示国内网络装包慢的话加个镜像源参数-i https://pypi.tuna.tsinghua.edu.cn/simple不要用来源不明的第三方包。2.3 配套依赖与目录结构约定除了 OpenCV你还需要 NumPy矩阵运算、Matplotlib可视化调试、scikit-learn分类器和评估、Pillow图像格式兼容。如果要上深度学习再加 PyTorch 或 TensorFlow。一次性装齐pip install numpy matplotlib scikit-learn pillow目录结构建议按下面这样组织后面所有代码都基于这个结构你照着建就不会出现路径找不到的问题handwriting_recognition/ ├── data/ │ ├── raw/ # 原始手写图片 │ └── processed/ # 预处理后的图片 ├── models/ # 保存训练好的模型 ├── src/ │ ├── preprocess.py # 图像预处理 │ ├── features.py # 特征提取 │ ├── train.py # 训练脚本 │ └── predict.py # 预测/演示脚本 └── requirements.txt这个结构的好处是数据和代码分离预处理结果可复用模型文件独立存放。写论文时截图目录结构也清晰。requirements.txt里把版本号固定下来避免换台电脑跑不起来——这是血泪经验答辩前换机器演示结果环境不对当场翻车的不在少数。3. 图像预处理流水线把脏图洗成标准输入3.1 灰度化、二值化与去噪的先后顺序预处理不是随便调几个函数顺序错了效果差很多。正确的流水线是读图 → 灰度化 → 去噪 → 二值化 → 形态学处理 → 轮廓裁剪 → 尺寸归一化。为什么去噪要放在二值化之前因为彩色或灰度图上的噪声点在二值化后可能变成孤立的黑白斑点形态学处理虽然能去掉但会连带腐蚀笔画边缘。先在灰度域用高斯滤波平滑再二值化笔画更完整。二值化方法的选择也有讲究。全局阈值cv2.threshold适合光照均匀的扫描图自适应阈值cv2.adaptiveThreshold适合手机拍摄、光照不均的图。笔迹识别里手写图片来源杂我一般直接用自适应阈值省心。import cv2 import numpy as np def preprocess_image(image_path, target_size(28, 28)): # 1. 读取图片兼容中文路径的坑后面讲 img cv2.imread(image_path) if img is None: raise ValueError(f图片读取失败检查路径: {image_path}) # 2. 灰度化三通道压成单通道减少计算量 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 3. 高斯滤波去噪3x3 核适合手写图核太大会糊掉细笔画 blurred cv2.GaussianBlur(gray, (3, 3), 0) # 4. 自适应二值化blockSize 取 11~15C 取 2~5 binary cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 15, 4 ) # 5. 形态学开运算去掉孤立噪点保留笔画 kernel np.ones((2, 2), np.uint8) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 6. 尺寸归一化到统一大小 resized cv2.resize(opened, target_size, interpolationcv2.INTER_AREA) return resized这段代码里每个参数都有含义。GaussianBlur的(3,3)是卷积核大小必须是奇数手写图笔画细用 5x5 就可能把笔画抹掉。adaptiveThreshold的blockSize15表示每个像素参考周围 15x15 区域算阈值图片分辨率高就调大低就调小C4是从计算出的阈值里减去的常数值越大背景越干净但笔画越容易断。THRESH_BINARY_INV让笔画变成白色、背景变成黑色因为后续轮廓检测默认找白色前景。MORPH_OPEN是先腐蚀后膨胀专门去小白点。核用(2,2)是因为笔画本身不粗核大了会把笔画也腐蚀掉。最后resize到 28x28 是为了对齐 MNIST 格式如果你用别的分类器改成对应输入尺寸即可。3.2 轮廓检测与笔画区域裁剪真实拍摄的手写图字不会正好占满画面周围一堆空白。直接缩放会把字缩得很小特征丢失。正确做法是用轮廓检测找到字的边界框裁剪出来再归一化。def crop_digit_region(binary_img): # 找外部轮廓RETR_EXTERNAL 只取最外层 contours, _ cv2.findContours( binary_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if not contours: return binary_img # 取面积最大的轮廓假设画面里主体就是那个字 largest max(contours, keycv2.contourArea) x, y, w, h cv2.boundingRect(largest) # 加一点边距避免笔画贴边被切 pad 4 x max(0, x - pad) y max(0, y - pad) w min(binary_img.shape[1] - x, w 2 * pad) h min(binary_img.shape[0] - y, h 2 * pad) cropped binary_img[y:yh, x:xw] return croppedfindContours在 OpenCV 不同版本里返回值个数不一样3.x 返回三个值4.x 返回两个值。上面用contours, _接两个值对应 4.x。如果你装的是老版本报「too many values to unpack」改成_, contours, _即可。contourArea用来算轮廓面积取最大的那个就是主体笔画。加pad边距是防止笔画紧贴边界裁剪后边缘像素丢失。注意cv2.findContours会修改传入的图像如果你后面还要用原图先.copy()一份。3.3 尺寸归一化与数据增强的取舍归一化到统一尺寸是必须的但怎么归一化有讲究。直接resize会改变长宽比把瘦长的「1」压成矮胖的。更稳妥的做法是先按长边等比缩放再把短边用黑色填充到正方形。这样字形不变形。def normalize_with_padding(binary_img, target_size(28, 28)): h, w binary_img.shape scale target_size[0] / max(h, w) new_w, new_h int(w * scale), int(h * scale) resized cv2.resize(binary_img, (new_w, new_h), interpolationcv2.INTER_AREA) # 创建黑色画布把缩放后的图居中放进去 canvas np.zeros(target_size, dtypenp.uint8) x_offset (target_size[1] - new_w) // 2 y_offset (target_size[0] - new_h) // 2 canvas[y_offset:y_offsetnew_h, x_offset:x_offsetnew_w] resized return canvas数据增强要不要做如果你的训练数据只有几十张自己拍的图那必须做否则模型过拟合到没眼看。增强手段包括随机旋转 ±10 度、随机平移几个像素、轻微缩放。但注意别用水平翻转——数字「2」翻转后就不是「2」了这种增强是有害的。旋转角度也别太大手写字符的正常倾斜不会超过 15 度。def augment_image(binary_img): # 随机旋转 -10 到 10 度 angle np.random.uniform(-10, 10) h, w binary_img.shape M cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) rotated cv2.warpAffine(binary_img, M, (w, h), borderValue0) # 随机平移 -2 到 2 像素 tx np.random.randint(-2, 3) ty np.random.randint(-2, 3) M_trans np.float32([[1, 0, tx], [0, 1, ty]]) shifted cv2.warpAffine(rotated, M_trans, (w, h), borderValue0) return shiftedgetRotationMatrix2D的中心点取图像中心borderValue0保证旋转后空白区域是黑色和背景一致。平移矩阵用np.float32是warpAffine的要求用默认 float64 会报类型错误。这两个增强叠加使用能把几十张样本的有效训练量放大好几倍。4. 特征提取与分类器HOGSVM 还是直接上 CNN4.1 HOG 特征为什么适合手写字符HOG方向梯度直方图的核心思想是字符的形状信息主要体现在笔画边缘的梯度方向上。它把图像分成小格子统计每个格子里像素梯度的方向分布再拼成特征向量。手写字符笔画方向明确HOG 能很好地捕捉这种结构信息而且对光照变化不敏感——因为梯度是像素差值整体亮度变化会被抵消。用 OpenCV 提取 HOG 特征很直接def extract_hog_features(binary_img): # 输入必须是单通道灰度图尺寸建议 28x28 或 32x32 hog cv2.HOGDescriptor( _winSize(28, 28), # 窗口大小和图片尺寸一致 _blockSize(14, 14), # 块大小通常是窗口的一半 _blockStride(7, 7), # 块滑动步长 _cellSize(7, 7), # 单元格大小 _nbins9 # 梯度方向分 9 个 bin ) features hog.compute(binary_img) return features.flatten()参数之间的关系要理清winSize必须等于输入图尺寸blockSize要能被winSize整除blockStride要能被blockSize减cellSize的结果整除。这套(28,28)/(14,14)/(7,7)/(7,7)/9是经典配置直接抄不会错。nbins9表示把 360 度分成 9 个 40 度的区间这是 HOG 原论文的推荐值改大改小收益都不明显。提取出来的特征向量维度是固定的28x28 图大概能得到几百维。维度太高会拖慢 SVM 训练太低又丢信息。如果你觉得效果不好优先调cellSize改小能保留更多细节但维度暴涨。4.2 SVM 训练与参数调优拿到 HOG 特征后用 SVM 分类是最稳的选择。scikit-learn 的SVC用 RBF 核在小样本上表现好但训练慢线性核快但需要特征本身可分。手写字符 HOG 特征维度高线性核往往就够用。from sklearn.svm import SVC from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report import numpy as np def train_svm(features, labels): # 划分训练集和测试集stratify 保证类别比例一致 X_train, X_test, y_train, y_test train_test_split( features, labels, test_size0.2, random_state42, stratifylabels ) # C 是惩罚系数越大越容易过拟合gamma 是 RBF 核宽度 clf SVC(kernelrbf, C10, gammascale, probabilityTrue) clf.fit(X_train, y_train) y_pred clf.predict(X_test) print(classification_report(y_test, y_pred)) return clfC10是经验起点数据量小就调小到 1数据量大且干净可以调到 100。gammascale让 sklearn 自动按特征方差算比手动设省事。probabilityTrue会启用概率估计预测时能输出置信度演示时很有用但会拖慢训练。stratifylabels很重要如果某个字符样本特别少不分层可能导致测试集里根本没有这个类评估结果失真。4.3 用 CNN 替代传统方案的时机如果你的题目明确要求「深度学习」或者你手头有几千张以上的样本那 CNN 是更好的选择。CNN 能自动学特征省去手工设计 HOG 的环节准确率通常也更高。一个轻量 CNN 在 MNIST 上能轻松跑到 99%。import torch import torch.nn as nn class HandwritingCNN(nn.Module): def __init__(self, num_classes10): super().__init__() # 两个卷积块每个块后接池化 self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), ) # 28x28 经过两次池化变成 7x7 self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x))这个网络结构简单但够用。padding1保证卷积后尺寸不变MaxPool2d(2)每次把尺寸减半。Dropout(0.5)是防过拟合的关键小数据集上不加它训练准确率能到 100% 但测试集惨不忍睹。输入张量形状要是(batch, 1, 28, 28)单通道别写成 3。选 HOGSVM 还是 CNN我的判断标准是样本少于 500 张、要求可解释、算力有限选 HOGSVM样本上千、追求准确率、有 GPU选 CNN。毕业设计里两者都能写但 HOGSVM 的每一步你都能讲清楚原理答辩时不容易被问倒。5. 避坑与排查那些让系统跑不起来的细节5.1 中文路径导致 imread 返回 None现象cv2.imread不报错但返回None后面所有操作全崩。原因OpenCV 的imread在 Windows 上对中文路径支持不好底层用的编码和系统不一致。解决用np.fromfile读成字节流再解码或者干脆把图片路径改成全英文。def imread_chinese_path(path): # 用 numpy 读字节流再用 imdecode 解码 data np.fromfile(path, dtypenp.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) return img这个函数能读中文路径代价是多一步内存拷贝。如果图片量大建议还是把数据集路径统一改成英文一劳永逸。5.2 二值化后笔画断裂或背景残留现象预处理后的图笔画中间有断口或者背景一片黑斑。原因自适应阈值的blockSize和C没调好。blockSize太小局部阈值波动大笔画容易断C太小背景噪点去不掉。解决blockSize从 15 开始试图片分辨率高就往上加C从 4 开始背景脏就加到 6 或 8。调参时把中间结果用cv2.imshow显示出来肉眼看着调最快。5.3 findContours 版本差异报 unpack 错误现象contours, _ cv2.findContours(...)报ValueError: too many values to unpack。原因OpenCV 3.x 返回三个值(image, contours, hierarchy)4.x 返回两个值(contours, hierarchy)。解决先打印cv2.__version__确认版本4.x 用两个变量接3.x 用三个。或者写兼容代码result cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours result[-2] # 无论几个返回值倒数第二个都是轮廓5.4 训练集准确率高但测试集一塌糊涂现象训练时准确率 99%测试集只有 60%。原因过拟合或者训练集和测试集分布不一致。解决先检查数据划分有没有问题同一张图有没有同时出现在训练和测试集里再检查增强是不是过度把字符增强得面目全非最后加正则化SVM 调小CCNN 加Dropout和权重衰减。还有一个隐蔽原因预处理时用了测试集的统计信息比如全局均值导致数据泄漏这个要避免。5.5 实时摄像头识别延迟高现象用摄像头做实时演示时画面卡顿识别结果滞后好几秒。原因每帧都跑完整预处理加分类计算量太大。解决降低摄像头分辨率到 640x480预处理只在检测到画面中有明显笔画时才触发分类器换成轻量模型。演示场景下帧率比单帧准确率更重要宁可牺牲一点精度也要保证流畅。6. 从能跑到好用一个提升识别率的具体技巧前面把系统跑通了但你可能发现准确率卡在 90% 上下上不去。这里分享一个我反复验证有效的技巧在预处理阶段做笔画细化骨架提取再提取 HOG 特征。手写字符的粗细因人而异同一个人不同时候写的粗细也不一样这种粗细变化对分类是干扰。细化把笔画压成单像素宽只保留拓扑结构能显著降低类内差异。OpenCV 的ximgproc模块里有thinning函数但需要装opencv-contrib-python。如果不想装 contrib可以用形态学方法近似def skeletonize(binary_img): # 基于形态学的骨架提取不需要 contrib 模块 img binary_img.copy() skel np.zeros(img.shape, np.uint8) kernel cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3)) while True: # 开运算去掉当前能去掉的边界 opened cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel) # 原图减去开运算结果得到被去掉的边界 temp cv2.subtract(img, opened) # 腐蚀原图 eroded cv2.erode(img, kernel) # 骨架加上边界 skel cv2.bitwise_or(skel, temp) img eroded.copy() # 图被腐蚀空了就结束 if cv2.countNonZero(img) 0: break return skel这段代码的逻辑是迭代地剥掉边界像素直到只剩骨架。MORPH_CROSS用十字核而不是矩形核是为了保证骨架连通性矩形核容易在拐角处产生断裂。countNonZero判断图是否被腐蚀空是循环终止条件。细化之后再做 HOG特征维度不变但信息更集中。我在手写数字数据集上试过同样的 SVM 参数加细化后测试准确率能提升 2 到 4 个百分点。代价是预处理时间增加实时场景要权衡。还有一个配套技巧在训练时对特征做标准化。HOG 特征各维度数值范围差异大用StandardScaler统一量纲SVM 收敛更快也更稳。from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline pipeline Pipeline([ (scaler, StandardScaler()), (svm, SVC(kernelrbf, C10, gammascale)) ]) pipeline.fit(X_train, y_train)用Pipeline把标准化和分类器串起来预测时会自动对新样本做同样的标准化不会出现训练时标准化了、预测时忘了的翻车。这个习惯我从做第一个图像项目保持到现在凡是特征量纲不统一的场景都这么写。最后说个验证方法别只看总体准确率一定要看混淆矩阵。手写识别里「1」和「7」、「3」和「8」、「4」和「9」是最容易混的。把混淆矩阵打出来针对混淆严重的类别单独补样本或调预处理参数比盲目调模型参数有效得多。from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt cm confusion_matrix(y_test, y_pred) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.xlabel(Predicted) plt.ylabel(True) plt.show()我自己的习惯是每换一次预处理参数就重新跑一遍混淆矩阵盯着那几个易混类别看有没有改善。这个笨办法比追求花哨模型结构实在得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑