简介:这份资源是面向高校计算机相关专业学生与OpenCV初学者的笔迹识别系统完整项目包,可直接用于毕业设计选题、课程实践或图像识别入门练手。项目以Python与OpenCV为核心,覆盖图像灰度化、二值化、边缘检测、最大外接矩形生成与矩形筛选等预处理流程,并采用余弦距离与直方图相似度两级比对策略完成笔迹相似度判定,最终将运行时间、结果与状态输出至txt文件,界面部分由PyQt5实现,包含文件选取、图片展示与运行触发等功能。包内共29个文件,含9个png与4个jpg测试素材、9个docx及doc、pptx等文档,另有2个py源码、2个md说明与1个txt日志,压缩包约43.92MB,涵盖需求分析、概要设计、详细设计、数据库设计、测试与使用说明等完整文档链路。目前已有147人学习,适合需要从源码到文档一站式参考的读者。
1. 从一张作业本照片到可检索的笔迹特征:这套 OpenCV 方案到底在做什么
很多人第一次接触笔迹识别,是导师甩过来一句“做个能认出手写字的系统”,然后自己搜到一堆 OCR 接口,调通之后发现识别的是印刷体,手写一塌糊涂。这套基于 OpenCV 图像识别的笔迹识别系统,解决的正是这个落差:它不依赖云端接口,从一张手机拍的作业本照片开始,做灰度化、二值化、去噪、倾斜校正、字符切分,再提取笔迹的几何与纹理特征,最后落到分类或比对。适合谁?做计算机毕业设计、需要一套能跑通、能讲清楚原理、能自己改参数的本科或硕士同学;也适合想入门 OpenCV 图像处理、拿一个完整小项目练手的工程师。核心词就三个:OpenCV、图像识别、笔迹识别。下面按“先跑通再调优”的顺序拆开讲。
2. 环境与数据准备:把 OpenCV 装对,把笔迹样本收齐
2.1 安装 OpenCV 的三种路径与选型理由
标题里带“源码+教程+全部数据文档”,意味着拿到压缩包后第一件事不是读代码,而是把运行环境对齐。OpenCV 的安装方式直接决定后面会不会出现ModuleNotFoundError: No module named 'opencv'或者cv2.error这类翻车现场。常见做法有三条路,我一般按项目类型选:
第一条是 pip 安装,最省事,适合纯 Python 的笔迹识别脚本:
# 推荐用虚拟环境隔离,避免和系统里的其他包打架 python -m venv venv # Windows 激活 venv\Scripts\activate # Linux / macOS 激活 source venv/bin/activate # 安装主包,opencv-python 带 GUI 功能,适合本地调试 pip install opencv-python==4.8.1.78 # 如果只需要算法、不需要 imshow 窗口,用 headless 版更小 # pip install opencv-python-headless这里参数说明一下:opencv-python包含cv2.imshow等可视化函数,调试笔迹切分效果时能直接弹窗看中间结果;opencv-python-headless去掉了 GUI 依赖,适合部署到没有桌面的服务器。版本号建议锁在 4.5 以上,因为cv2.findContours的返回值和旧版 3.x 不同,网上很多老教程还在用三返回值写法,直接抄会报ValueError: not enough values to unpack。
第二条是 conda 安装,适合已经用 Anaconda 管理毕业设计环境的同学:
conda create -n handwriting python=3.9 conda activate handwriting conda install -c conda-forge opencvconda 的好处是会自动处理底层依赖,Windows 上少踩编译坑。第三条是源码编译,只有你需要 CUDA 加速或者改 OpenCV 内部函数时才值得做,普通笔迹识别项目完全没必要,编译一次半小时起步,血泪经验是别在 deadline 前一周折腾。
装完必须验证,别信“安装成功”的提示:
import cv2 import numpy as np # 打印版本,确认不是装到了别的 Python 环境 print("OpenCV version:", cv2.__version__) # 造一张纯白图,验证基本读写和数组操作正常 img = np.ones((100, 100), dtype=np.uint8) * 255 cv2.imwrite("test_white.png", img) reload = cv2.imread("test_white.png", cv2.IMREAD_GRAYSCALE) print("shape:", reload.shape, "min/max:", reload.min(), reload.max())如果import cv2报ModuleNotFoundError,九成是 pip 和 python 不是同一个环境,用which python(Linux/macOS)或where python(Windows)确认解释器路径,再pip show opencv-python看装到哪去了。这个排查顺序能省掉大量玄学时间。
2.2 笔迹数据集的采集与目录规范
笔迹识别和印刷体 OCR 最大的区别在数据:印刷体有现成的大规模标注集,笔迹样本往往要自己收。毕业设计里常见的做法是找 20 到 50 个同学,每人按模板写若干遍,形成“书写人—样本”两级结构。目录建议这样组织:
dataset/ ├── writer_01/ │ ├── sample_01.png │ ├── sample_02.png │ └── ... ├── writer_02/ │ └── ... └── labels.csvlabels.csv记录每张图对应的书写人 ID 和采集条件:
filepath,writer_id,pen_type,paper_type writer_01/sample_01.png,01,gel,lined writer_01/sample_02.png,01,ballpoint,blank writer_02/sample_01.png,02,gel,lined为什么要记笔和纸?因为笔迹识别模型很容易把“笔的类型”当成“人的特征”学进去,换一支笔准确率就掉。把采集条件写进标签,后面做数据增强和误差分析时能定位问题。样本量上,每人至少 10 张,总样本低于 200 张时,传统特征加 SVM 往往比硬上深度学习更稳,这也是这套 OpenCV 方案的价值所在——它不要求你有 GPU 和大数据集。
采集时统一用手机后置摄像头、正对纸面、光线均匀,避免阴影横跨文字。分辨率控制在 1000×750 到 2000×1500 之间,太大后面处理慢,太小笔画糊成一团。这一步偷懒,后面预处理再花哨也救不回来。
3. 预处理流水线:灰度、二值化、去噪、倾斜校正
3.1 从彩色图到二值图的关键参数
笔迹识别的预处理目标只有一个:把“笔画的形状”干净地留下来,把纸张纹理、光照不均、拍摄噪声全部干掉。第一步灰度化:
import cv2 import numpy as np img = cv2.imread("writer_01/sample_01.png") # 灰度化,笔迹识别不需要颜色信息 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊,核大小必须是奇数,5x5 适合 1000px 级别的图 blur = cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值,比全局阈值更能扛光照不均 binary = cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize=25, C=10 ) cv2.imwrite("step_binary.png", binary)逻辑说明:THRESH_BINARY_INV让笔画变成白色(255)、背景变成黑色(0),因为后续findContours默认找白色前景。blockSize=25表示每个像素参考周围 25×25 区域算阈值,图越大这个值要相应调大,一般取图像短边的 1/20 到 1/30。C=10是常数修正项,值越大越容易把浅色噪点判成背景。如果二值化后笔画断裂,先把C调小到 5;如果背景出现大片黑斑,把blockSize调大。
全局阈值cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)也能用,但它对光照敏感,同一批样本里有的偏亮有的偏暗时,固定阈值会翻车。自适应阈值是这套方案里我强烈建议保留的一步。
3.2 去噪与形态学:别把笔画当噪声删掉
二值化之后通常还有孤立噪点和小桥连。形态学开运算能去噪,但核不能太大:
# 3x3 开运算:先腐蚀后膨胀,去掉小噪点,保留笔画主体 kernel = np.ones((3, 3), np.uint8) opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1) # 闭运算:先膨胀后腐蚀,填补笔画内部小孔 closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations=1) cv2.imwrite("step_morph.png", closed)参数说明:iterations=1是安全值,迭代两次以上细笔画会被吃掉,尤其是用铅笔写的样本。如果样本笔画本来就细,可以跳过开运算,只做闭运算。判断标准很简单:把step_morph.png和原图并排看,笔画该连的连、该断的断,没有多余黑点,就合格。
3.3 倾斜校正:霍夫变换与最小外接矩形两条路
手机拍的作业本很难保证水平,倾斜会让后续切分和特征提取全部偏移。校正思路是先找到文字区域的主方向,再旋转回来。常见做法有两种:
第一种用最小外接矩形,适合整页文字方向一致的情况:
# 找所有前景点的坐标 coords = np.column_stack(np.where(closed > 0)) # 计算最小外接矩形,返回 (中心, 尺寸, 角度) rect = cv2.minAreaRect(coords) angle = rect[-1] # minAreaRect 的角度范围是 [-90, 0),需要归一化 if angle < -45: angle = 90 + angle (h, w) = closed.shape[:2] center = (w // 2, h // 2) M = cv2.getRotationMatrix2D(center, angle, 1.0) rotated = cv2.warpAffine(closed, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) cv2.imwrite("step_rotated.png", rotated)第二种用霍夫直线检测,适合有横线格的作业本,先检测横线再算倾角。cv2.HoughLinesP的参数里threshold和minLineLength最关键,线格样本可以把minLineLength设成图像宽度的 1/3。两条路我都试过,最小外接矩形代码短、对无格纸更稳,霍夫对有格纸更准。毕业设计里建议先跑最小外接矩形,效果不满意再换。
校正后一定要回看step_rotated.png,如果文字被转成倒立,说明角度符号取反了,把angle前面加负号即可。这个坑几乎每个人都会踩一次。
4. 笔迹特征提取与识别:从切分到分类的完整链路
4.1 字符切分:投影法与轮廓法的取舍
预处理完的图是一整页文字,要识别笔迹,先得把单个字切出来。投影法是最直观的:
# 水平投影,找行 row_sum = np.sum(closed, axis=1) # 阈值取最大投影的 5%,低于它的行视为空白 row_thresh = row_sum.max() * 0.05 rows = [] in_line = False for i, v in enumerate(row_sum): if v > row_thresh and not in_line: start = i; in_line = True elif v <= row_thresh and in_line: rows.append((start, i)); in_line = False # 对每一行做垂直投影,找字 chars = [] for (r0, r1) in rows: line = closed[r0:r1, :] col_sum = np.sum(line, axis=0) col_thresh = col_sum.max() * 0.05 in_char = False for j, v in enumerate(col_sum): if v > col_thresh and not in_char: c0 = j; in_char = True elif v <= col_thresh and in_char: chars.append((r0, r1, c0, j)); in_char = False print("切出字符数:", len(chars))逻辑说明:投影法假设字与字之间有空白列,对工整书写有效,对连笔字会切错。阈值系数 0.05 是经验值,字距小的样本调到 0.02,字距大的调到 0.1。轮廓法用cv2.findContours找每个连通域,再按面积过滤,适合连笔但容易把一个字的偏旁拆成两块。实际项目里我一般先用投影法,切分结果画框回看,错得多的区域再局部用轮廓法补。
4.2 特征设计:几何特征加纹理特征
切出单字后,直接拿像素喂分类器效果很差,因为书写位置、大小、倾斜都会变。要提取对书写人稳定、对书写条件不敏感的特征。常用组合:
| 特征类别 | 具体特征 | 维度 | 作用 |
|---|---|---|---|
| 几何特征 | 宽高比、笔画密度、重心偏移 | 5 | 区分字形习惯 |
| 投影特征 | 水平/垂直投影直方图 | 32 | 描述笔画分布 |
| 纹理特征 | HOG 方向梯度直方图 | 可调 | 描述笔画走向 |
| 矩特征 | Hu 矩 7 个不变量 | 7 | 对旋转缩放不敏感 |
HOG 是这套方案里性价比最高的:
from skimage.feature import hog # 统一缩放到固定尺寸,消除大小差异 char_img = cv2.resize(closed[r0:r1, c0:c1], (64, 64)) # HOG 提取,orientations 是方向 bin 数 features = hog(char_img, orientations=9, pixels_per_cell=(8, 8), cells_per_block=(2, 2), block_norm='L2-Hys') print("HOG 维度:", features.shape)参数说明:orientations=9把 0 到 180 度分成 9 个方向 bin,是行人检测里的经典值,笔迹识别也够用;pixels_per_cell=(8,8)表示每个 cell 8×8 像素,64×64 的图会得到 8×8 个 cell;cells_per_block=(2,2)做块归一化,抵抗光照残差。维度算下来是 9×7×7×4=1764 维,对几百个样本来说偏高,可以用 PCA 降到 100 到 200 维再分类。
4.3 分类器选择:SVM 与 KNN 的实测差异
特征有了,分类器选型看样本量。样本少于 500 时,SVM 配 RBF 核通常比 KNN 稳:
from sklearn.svm import SVC from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.metrics import classification_report # X 是特征矩阵,y 是书写人标签 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y) clf = make_pipeline( StandardScaler(), SVC(C=10, gamma='scale', kernel='rbf', probability=True) ) clf.fit(X_train, y_train) pred = clf.predict(X_test) print(classification_report(y_test, pred))参数说明:C=10是惩罚系数,越大越容易过拟合,样本少时从 1 开始试;gamma='scale'让 sklearn 自动按特征方差定核宽度,比手写gamma=0.01省心;stratify=y保证训练测试集里每个书写人比例一致,否则某个人的样本全进测试集,准确率会虚高。KNN 的n_neighbors一般取 3 到 5,样本分布均匀时表现接近 SVM,但预测时要算所有距离,样本上万后变慢。
识别阶段还要注意:如果目标是“识别写了什么字”,那是 OCR 任务,需要字符类别标签;如果目标是“识别是谁写的”,那是书写人识别,标签是书写人 ID。标题里的“笔迹识别”两种都可能指,毕业设计里建议先做书写人识别,因为不需要标注每个字是什么,数据成本低,效果也更容易讲清楚。
5. 避坑与排查:那些让准确率一夜归零的细节
5.1 二值化后笔画断裂,识别全乱
现象:预处理输出的图里,横画中间断开,竖画缺一截,切分时一个字被切成两三块。原因:自适应阈值的C值偏大,或者高斯模糊核太大把细笔画糊没了。解决:先把C从 10 降到 5,再把模糊核从 5×5 降到 3×3,重新看step_binary.png。如果还断,检查原图分辨率,低于 800px 宽的手机照片本身细节就不够,重新采集比调参有效。
5.2 训练准确率 99%,测试掉到 60%
现象:训练集上几乎全对,换一批新样本就崩。原因:同一个人的多张样本被随机分到了训练和测试集,模型记住了这个人的特定笔画位置,而不是书写习惯。解决:按书写人划分数据集,同一个人要么全在训练集,要么全在测试集。用GroupShuffleSplit代替train_test_split:
from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=42) train_idx, test_idx = next(gss.split(X, y, groups=writer_ids))groups传书写人 ID 数组,这样切分才反映真实泛化能力。这个坑在毕业设计答辩时最容易被老师问穿。
5.3 换一支笔,准确率断崖下跌
现象:用中性笔采集的样本训练,换成圆珠笔测试,准确率从 85% 掉到 50%。原因:笔迹的粗细、灰度分布和笔的类型强相关,模型把笔的特征当成了人的特征。解决:采集阶段就混入多种笔,标签里记录笔型,训练时做数据增强,比如随机膨胀腐蚀模拟笔画粗细变化:
kernel = np.ones((2, 2), np.uint8) # 随机选择膨胀或腐蚀,模拟不同笔的粗细 if np.random.rand() > 0.5: aug = cv2.dilate(closed, kernel, iterations=1) else: aug = cv2.erode(closed, kernel, iterations=1)增强只在训练集做,测试集保持原始条件,这样才能测出真实鲁棒性。
5.4 cv2.imread 读进来是 None
现象:cv2.imread返回None,后面cvtColor直接报Assertion failed。原因:路径里有中文或空格,OpenCV 在 Windows 上对非 ASCII 路径支持不好。解决:用cv2.imdecode配合np.fromfile读:
# 兼容中文路径的读取方式 data = np.fromfile("数据集/样本_01.png", dtype=np.uint8) img = cv2.imdecode(data, cv2.IMREAD_COLOR)或者干脆把数据集目录改成纯英文,这是最省事的后悔药。
5.5 切分结果里混入标点和边框
现象:投影法把作业本上的横线和标点也切成了“字符”,特征维度对不上。原因:预处理没去掉长直线,投影阈值太低。解决:在二值化后用霍夫直线检测找出长横线并抹掉,或者按切分框的宽高比过滤,宽高比大于 8 或小于 0.1 的框直接丢弃。这一步加在切分之后、特征提取之前,能明显减少脏样本。
6. 把准确率再抬一截:特征融合与交叉验证的实操技巧
走到这里,系统已经能跑通,但毕业设计想拿高分,或者实际项目想上线,还得在验证方法上做文章。我一般会做两件事:特征融合和分层交叉验证。
特征融合是把 HOG 和投影直方图拼在一起,让分类器同时看到局部纹理和全局分布:
from skimage.feature import hog def extract_features(char_img): # 统一尺寸 img = cv2.resize(char_img, (64, 64)) # HOG 纹理特征 hog_feat = hog(img, orientations=9, pixels_per_cell=(8, 8), cells_per_block=(2, 2), block_norm='L2-Hys') # 水平与垂直投影直方图,各 32 维 h_proj = np.sum(img, axis=1) / 255.0 v_proj = np.sum(img, axis=0) / 255.0 h_hist, _ = np.histogram(h_proj, bins=32, range=(0, 64)) v_hist, _ = np.histogram(v_proj, bins=32, range=(0, 64)) # 拼接,归一化避免量纲差异 feat = np.concatenate([hog_feat, h_hist, v_hist]) return feat / (np.linalg.norm(feat) + 1e-8)逻辑说明:HOG 描述笔画走向,投影直方图描述笔画在字框里的分布,两者互补。最后做 L2 归一化,防止 HOG 的大数值淹没直方图。维度从 1764 涨到 1828,增加不多,但实测在 300 样本、20 个书写人的数据上,准确率能比单用 HOG 高 3 到 5 个百分点。
第二件事是分层交叉验证,别只做一次 train_test_split:
from sklearn.model_selection import StratifiedGroupKFold from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline import numpy as np sgkf = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=42) scores = [] for train_idx, test_idx in sgkf.split(X, y, groups=writer_ids): clf = make_pipeline(StandardScaler(), SVC(C=10, gamma='scale', kernel='rbf')) clf.fit(X[train_idx], y[train_idx]) scores.append(clf.score(X[test_idx], y[test_idx])) print("5 折准确率:", np.round(scores, 3)) print("均值:", np.mean(scores).round(3), "标准差:", np.std(scores).round(3))StratifiedGroupKFold同时保证每折里类别比例一致、同一书写人不跨折,是笔迹识别里最该用的验证方式。标准差比均值更能说明问题:均值 0.85、标准差 0.02,说明模型稳;均值 0.85、标准差 0.12,说明某一折崩了,得回去看那折的样本是不是采集条件特殊。
还有一个容易被忽略的技巧:把预处理参数也纳入调参。很多人只调 SVM 的C和gamma,却把blockSize、C值、形态学核大小写死。实际上这几组参数对最终准确率的影响不比分类器小。我习惯用网格搜索把预处理和分类器一起搜,虽然慢,但能避免“分类器调到头,其实是二值化没调好”的尴尬。搜索空间不用大,blockSize取 [15, 25, 35],阈值C取 [5, 10, 15],SVM 的C取 [1, 10, 100],组合数可控。
最后说个习惯:每改一次参数,都把中间图存下来,按step_01_gray.png、step_02_binary.png这样编号。笔迹识别这条链路环节多,出问题时靠肉眼回看中间结果,比盯着最终准确率猜要快得多。我吃过亏,曾经花一晚上调 SVM,最后发现是二值化把笔画切断了。希望帮到你。
本文还有配套的精品资源,点击获取