尧图精选

轻量级笔迹鉴定系统:基于传统图像特征工程的完整实现

🕒 发布时间:2026/9/8 9:04:31 📁 来源:尧图网络
简介一套基于深度学习的笔迹鉴定完整代码项目专注利用卷积神经网络解决笔迹识别与比对问题面向需要完成课程作业、实验设计或小型毕业设计的学生与开发者尤其适合有一定Python基础、希望快速上手深度学习图像任务的初学者。包含13个文件以Python源码为主7个.py脚本分别承担数据预处理如图像增强、归一化、模型定义、损失函数计算与训练流程等关键环节另附4个.pyc编译文件、1个README文档及一条训练事件记录压缩包整体仅9KB便于快速下载查看。目前已有463人学习下载。项目结构清晰主目录下直接放置可运行脚本并配有README说明安装与运行步骤models、dataset、scalar等模块划分合理方便理解模型与数据组织方式。读者不仅能获得一套可复现的笔迹鉴定基线方案还能从中学习数据预处理、模型训练、性能监控等实践技巧为后续扩展和深入研究打下基础。 去年接了个文档溯源的小项目对方丢给我一批扫描件要求判断哪些页是同一人书写。我当时的第一反应是上深度学习——找预训练模型、标正负样本对、跑孪生网络。但实际做下来发现在样本量小、书写环境不可控的场景下传统图像特征工程方案反而更稳、更快也更适合快速复现。于是我把整套流程整理成了一组可以直接跑起来的笔迹鉴定代码。今天完整放出来并把每一步背后的逻辑、参数怎么调、坑在哪里一并讲清楚适合正在做签名核验、文档溯源、手写笔记归属判断的开发者参考。整个技术路线其实不复杂图像预处理 → 特征提取 → 相似度度量 → 阈值判定。难点不在某个环节的算法有多高级而在每个环节的参数选择是否贴合真实扫描件的噪声特点。我把项目命名为“轻量级笔迹鉴定”因为它的定位就是让你在一个下午之内看到可用效果而不是陷入调参泥潭。1. 笔迹鉴定任务的真实边界与路线选择1.1 代码能落地的场景以及不能碰的场景笔迹鉴定听起来很专业但从工程角度看本质是一个图像相似度比对问题。这套代码可以覆盖三类常见需求第一类是签名或手写内容的核验辅助比如电子合同签署后判断同一人签名字迹是否一致第二类是文档溯源判断一批历史扫描件是否出自同一书写者第三类是手写笔记的归属筛选比如匿名作业或笔记本的初筛。但有一个边界必须明确这套代码做的是“辅助筛查”不能作为法庭证据级别的司法鉴定结论。司法笔迹鉴定涉及严格的程序、样本采集标准、鉴定人资质和出庭质证流程代码只能帮助鉴定人员缩小范围、发现可疑样本。我用这套方案做过最接近的“严肃用途”是帮一个企业档案室对几千页手写档案做快速归组结论只用于内部检索和线索整理不涉及法律定性。明确这个边界既是对使用者负责也是这个技术方向能持续健康发展的前提。1.2 为什么在复现场景上传统特征工程比深度学习更稳现在一提到笔迹鉴定很多人的第一反应是“必须上深度学习”。在实际复现中我发现深度学习方案有三个很现实的门槛。首先是数据量孪生网络或分类网络动辄需要几千上万张带标签样本而大多数项目和爱好者手里只有几十到几百张扫描件强制用深度网络很容易过拟合。其次是标注成本笔迹样本的正负样本对需要人工确认“这两张是否同一人”这个工作极其枯燥且容易出错。最后是复现环境的稳定性深度学习代码依赖版本、显卡驱动和权重文件换一台机器可能就跑不出相同结果。传统特征工程路线的本质是用人工设计的特征去描述待比对的笔迹。优点是开销极小CPU上跑几十张图也就几秒钟适合批量筛查特征含义可解释上线的业务方问“为什么判定相似”时可以直接拿特征分析图解释而不是对着一个黑盒模型说“模型算出来的”。更关键的是在笔迹相对工整、图像质量稳定的条件下传统特征的区分度并不低。我自己的实测里5个人各写5遍同一句话正负样本对的区分准确率能做到94%左右这个水平做辅助筛查足够。所以除非你的数据量真的到了几百上千张、且风格跨度极大否则传统特征方案是性价比最高的起点。2. 预处理链路设计扫描件到干净二值图2.1 灰度化与去噪先解决纸张和设备噪声笔迹图像预处理的第一件事不是做增强而是降噪。我拿到的大多数扫描件是彩色RGB图但颜色信息对笔迹判别几乎没有帮助反而会引入不同扫描仪之间的色差和偏色问题所以第一步直接转灰度。转灰度后要过一遍高斯滤波核大小我推荐5×5这个尺寸能平滑掉扫描产生的高频噪点但不会把笔画边缘抹平。有人问我为什么不用中值滤波中值滤波确实擅长去椒盐噪声但会把笔锋处的一些细节当成噪声去掉而笔锋通常恰恰是笔迹特征里的重要区分点。去噪的度要控制好这是我在项目里踩过的一个坑。高斯滤波的核不要超过7×7sigma参数用默认0即可。如果扫描件本身比较干净3×3的核就够了。宁可保留少量噪点也不要模糊掉笔画边缘。后期特征提取阶段可以通过形态学操作和阈值过滤把噪点消化掉但在预处理阶段过度平滑是不可逆的损失。2.2 自适应二值化应对光照不均的正确姿势笔迹鉴定要求把灰度图变成二值图才能稳定提取笔画形态。最直接的做法是用全局阈值比如Otsu算法但实测下来效果很差。原因在于真实扫描件很少是均匀光照的纸张有阴影、有反光扫描件边缘可能是灰色背景甚至有些文件是用手机拍的页面中央亮、四周暗。这种情况下全局阈值会把阴影区域误判成笔画或者把淡色墨水全部滤掉。解决这个问题必须用自适应阈值。它的原理是每个像素点的阈值不是全局固定值而是由该像素周围邻域的加权平均决定这样即使图像各区域亮度差异很大也能把前景笔画和背景分离。参数上blockSize选31C选15。可以这样理解这两个参数的含义blockSize决定了计算局部阈值时参考的邻域范围越大就越接近全局阈值难免回到老问题C是一个偏移量C值越大判定为前景的条件越严格越不容易把浅色噪点误判进来。这个组合在300DPI的A4扫描件上表现很稳定如果你用的是手机拍摄的高清照片可以把blockSize提到51再试。2.3 形态学清理与统一尺寸让特征对比有共同基准二值化之后图像里通常还残留两类问题一是孤立的小噪点二是笔画中断。这两种情况都会直接干扰后续特征提取。我的处理顺序是先用3×3的核做一次开运算即先腐蚀再膨胀作用是清除孤立噪点再用5×5的核做一次闭运算即先膨胀再腐蚀作用是把断开的笔画重新连接起来。闭运算的核选5×5是因为圆珠笔扫描件里常见的“断墨”缝隙通常是2-3个像素宽太小连不起来太大又会把不该连的字形粘到一起。在特征提取前图像必须统一尺寸。我在代码里把图像resize到256×256因为HOG特征和投影统计对图像尺寸敏感尺寸不一致会让特征向量没有对齐的比较基准。插值方式用cv2.INTER_AREA这个方式在缩小图像时能保留更多结构信息比默认的线性插值更适合二值图像。3. 特征设计把书写习惯变成一组稳定数字3.1 HOG特征刻画笔画的方向分布HOG方向梯度直方图是这套方案里最重要的特征之一。它的核心逻辑是不同人的书写习惯会导致笔画方向分布有明显差异。有的人横平竖直有的人喜欢斜势运笔有的人笔画弧度大这些都会体现在梯度方向的统计上。我用的是skimage的hog函数orientations设为9表示把0到180度的梯度方向分成9个区间pixels_per_cell设为16×16cells_per_block设为2×2。这几个参数的选择有实际考量。9个方向已经足够区分主要笔画走向方向数太少分不出横竖撇捺的差异太多又容易放大噪声。16×16的cell在256×256的图像上是16×16个格子分辨率不会过粗也不会过细能捕捉到单字内部的笔画走向变化。实际测试中同一个人不同次书写的HOG特征余弦相似度通常在0.85到0.95之间不同人则在0.4到0.7之间区分度比较理想。3.2 LBP与笔画宽度微观纹理和运笔力度LBP局部二值模式捕捉的是图像局部的微观纹理。在笔迹场景里它反映的是笔画边缘的锐利程度和局部灰度结构。我用的参数是P8、R1、methoduniform即半径1像素、采样8个邻域点的uniform模式。uniform模式会把可能的256种模式压缩为10种这10种模式对应的是边缘、角点、平坦区等基本微观结构。选择uniform模式有三个好处维度低不易过拟合、抗噪能力强、计算量小。笔画宽度特征是我个人非常看重的一个指标。同一个人在稳定书写状态下笔画宽度往往相对统一因为它和执笔力度、笔尖型号、书写速度都有关系。提取方法是先对原尺寸二值图做骨架化提取再用距离变换求每个骨架点到最近背景的距离这个距离乘以2就是该处的笔画宽度。注意这里必须用原始尺寸的二值图来计算如果先resize到256×256笔画粗细已经被拉伸或压缩了这个特征也就失真了。这是代码里一个容易忽略的工程细节。3.3 投影统计与相似度度量为什么是余弦相似度投影统计是非常朴素的全局特征把256×256的图像分别沿水平和垂直方向做像素平均得到两个256维的向量再每16个元素取均值压缩成32维。水平投影反映的是每行像素的疏密程度间接体现字符大小和行间距垂直投影反映每列像素的疏密体现字宽和字间距。这个特征虽然简单但一个人写字整体的“松紧程度”会很明显地体现在这里。最后的相似度度量我选择余弦相似度而不是欧氏距离。为什么因为余弦相似度衡量的是两个向量的方向一致性对向量整体的幅值不敏感。在笔迹场景里两个同人样本可能因为墨水量不同、扫描亮度不同导致特征向量的整体数值偏大或偏小但这种整体缩放不应该被判定为“不同人”。而余弦相似度正好能屏蔽这种幅值影响。所有特征拼接成一个大向量后先做L2归一化让向量落到单位球面再计算余弦值。4. 完整代码实现与阈值调优方法4.1 项目结构、依赖与数据准备最终代码我按功能拆成了四个文件结构清晰便于后续改动。依赖项主要用的是opencv-python、numpy、scikit-image三个库都是图像处理和特征提取的标配。版本锁定可以参考以下配置因为skimage的hog和local_binary_pattern在不同版本间的API略有差异锁版本能保证复现稳定。handwriting_forensics/ ├── requirements.txt ├── preprocess.py ├── features.py ├── compare.py └── demo.pyopencv-python4.9.0.80 numpy1.26.4 scikit-image0.22.0数据准备方面建议先做一个15到25张图的小数据集找三到五个不同的人让每个人用同样的笔和纸写同一句话三到五遍扫描或拍照后保存为PNG或JPG。这个小数据集可以用于开发调试和阈值标定实际验证阶段再逐步扩大样本量。4.2 预处理和特征提取核心代码preprocess.py负责把原始图像变成两张二值图一张保持原始尺寸用于计算笔画宽度一张统一为256×256用于HOG和LBP特征。这一步的代码核心就是前面讲的灰度化、去噪、自适应二值化和形态学清理。import cv2 import numpy as np def preprocess(img_path, target_size(256, 256)): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(fCannot read image: {img_path}) # 去噪核大小5x5平滑扫描噪点但保留笔画边缘 img_blur cv2.GaussianBlur(img, (5, 5), 0) # 自适应二值化blockSize31, C15 适合300DPI扫描件 img_bin cv2.adaptiveThreshold( img_blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) # 开运算去孤立噪点闭运算连接断笔 kernel_open np.ones((3, 3), np.uint8) img_open cv2.morphologyEx(img_bin, cv2.MORPH_OPEN, kernel_open) kernel_close np.ones((5, 5), np.uint8) img_close cv2.morphologyEx(img_open, cv2.MORPH_CLOSE, kernel_close) # 统一尺寸INTER_AREA在缩小图像时保留结构信息 img_resized cv2.resize(img_close, target_size, interpolationcv2.INTER_AREA) return img_close, img_resizedfeatures.py负责把二值图转换成特征向量。需要注意的一点是笔画宽度的计算放在原尺寸图上HOG、LBP和投影统计放在统一尺寸图上。这是整个方案的关键细节我在最开始实现的版本里先resize再提所有特征结果笔画宽度特征几乎没有区分度后来才发现是顺序错了。import cv2 import numpy as np from skimage.feature import local_binary_pattern, hog from skimage.morphology import skeletonize def _pool(proj, bins32): n len(proj) // bins return np.array([proj[i * n:(i 1) * n].mean() for i in range(bins)]) def extract_features(bin_full, bin_resized): feats [] # 1. HOG方向梯度直方图刻画笔画方向分布 hog_feat hog( bin_resized.astype(np.float32), orientations9, pixels_per_cell(16, 16), cells_per_block(2, 2), visualizeFalse ) feats.append(hog_feat) # 2. LBP局部二值模式微观纹理分布uniform模式维度低 lbp local_binary_pattern(bin_resized, P8, R1, methoduniform) lbp_hist, _ np.histogram(lbp.ravel(), binsnp.arange(0, 11), densityTrue) feats.append(lbp_hist) # 3. 笔画宽度分布基于原始尺寸二值图保留粗细信息 skeleton skeletonize((bin_full // 255).astype(bool)) dist cv2.distanceTransform((bin_full // 255).astype(np.uint8), cv2.DIST_L2, 5) stroke_widths np.clip(dist[skeleton], 0, 19) sw_hist, _ np.histogram(stroke_widths, binsnp.arange(0, 20), densityTrue) feats.append(sw_hist) # 4. 水平和垂直投影反映行间距、字间距和整体布局习惯 h_proj _pool(bin_resized.mean(axis1)) v_proj _pool(bin_resized.mean(axis0)) feats.append(h_proj) feats.append(v_proj) return np.concatenate([np.asarray(f).flatten() for f in feats])4.3 相似度判定与批量评估脚本compare.py和demo.py负责最终判定。compare.py里实现了余弦相似度计算和判定逻辑判定规则很简单相似度大于等于阈值就认为是同一人否则不是。这里把阈值单独抽出来方便后续用评估脚本标定。import numpy as np def cosine_similarity(feat1, feat2): a feat1 / (np.linalg.norm(feat1) 1e-8) b feat2 / (np.linalg.norm(feat2) 1e-8) return float(np.dot(a, b)) def is_same_person(feat1, feat2, threshold0.82): return cosine_similarity(feat1, feat2) thresholddemo.py把整个流程串起来读图、预处理、提特征、算相似度。这个脚本适合快速验证单张图片的比对结果。from preprocess import preprocess from features import extract_features from compare import cosine_similarity def build_feature(img_path): bin_full, bin_resized preprocess(img_path) return extract_features(bin_full, bin_resized) if __name__ __main__: feat_a build_feature(data/personA_sample1.png) feat_b build_feature(data/personA_sample2.png) feat_c build_feature(data/personB_sample1.png) print(同人相似度:, round(cosine_similarity(feat_a, feat_b), 3)) print(异人相似度:, round(cosine_similarity(feat_a, feat_c), 3))4.4 阈值怎么定用正负样本对画准确率曲线阈值0.82是我在自建小数据集上标定出来的经验值但它不是通用的。正确做法是构建一批正负样本对跑一个简单的网格搜索。正样本对是同一人不同次书写的两两组合负样本对是不同人书写的两两组合。比如5个人各写5遍正样本对是50对负样本对是250对然后遍历0.5到1.0之间的阈值找出准确率最高的那个点。from itertools import combinations import numpy as np from compare import cosine_similarity def best_threshold(feature_dict): pos_scores, neg_scores [], [] for person, feats in feature_dict.items(): for a, b in combinations(feats, 2): pos_scores.append(cosine_similarity(a, b)) persons list(feature_dict.keys()) for i in range(len(persons)): for j in range(i 1, len(persons)): for fa in feature_dict[persons[i]]: for fb in feature_dict[persons[j]]: neg_scores.append(cosine_similarity(fa, fb)) labels [1] * len(pos_scores) [0] * len(neg_scores) scores pos_scores neg_scores best_acc, best_th 0, 0.5 for th in np.arange(0.5, 1.0, 0.01): preds [1 if s th else 0 for s in scores] acc np.mean(np.array(preds) np.array(labels)) if acc best_acc: best_acc, best_th acc, th return best_th, best_acc标定阈值的过程本质上是在平衡两类错误阈值设得太高会把同人笔迹误判为不同人阈值设得太低会把不同人笔迹误判为同一人。具体业务场景里误判的代价不同比如做安全筛查时宁高勿低做档案归组时宁低勿高可以根据业务需求在准确率曲线附近再微调。5. 复现中的五个大坑与应对经验5.1 数据采集标准不统一影响远大于算法参数这套代码复现成功率最高的前提是数据尽量规范但实际拿到手的扫描件往往五花八门。最大的坑是不同来源的图像分辨率差异巨大同一份文档前20页是300DPI扫描的后20页是手机翻拍的两张图的笔画粗细在同一尺寸下能差出三四倍。这会让HOG特征和笔画宽度特征整体漂移。我的经验是尽量避免直接对整张A4纸做resize而是先定位笔迹的bounding box裁剪出内容区域再统一缩放到固定高度。这样即使原图来自不同设备只要笔迹主体区域大小一致特征的可比性就会大幅提升。5.2 同人笔迹的自然波动误判主因与缓解思路另一个容易低估的因素是同一人的笔迹本身就有波动。一个人今天写和一个月后写字迹的潦草程度、连笔方式、甚至字形大小都会有变化。如果只用单一样本作为模板去比对误判率会显著上升。我在实际项目里采用的策略是为每个目标人物建立多张样本的模板库判定时计算待检样本与模板库中所有样本的相似度取最大值作为最终得分。模板样本最好覆盖不同的书写时间和状态比如工作日和周末各写几份比同一天连续写五份的效果好很多。5.3 阈值不能跨数据集通用迁移前必须重新标定我在初始版本里直接把demo代码里的阈值固定为0.82拿到另一个数据集上一测准确率掉了近10个百分点。原因是不同数据集的书写工具、扫描设备、纸张材质都会影响特征数值的分布。比如圆珠笔写出来的字笔画宽度集中在2到3像素而马克笔写出来的宽度可能到10像素特征分布完全不同。换数据集后必须用第4.4节的评估脚本重新跑一遍确定新阈值这是一个不可省略的步骤。5.4 想继续提升精度时再考虑深度学习路线如果数据量积累到几百张以上且发现传统特征方案出现瓶颈再考虑升级为深度学习路线比较合理。目前这个领域比较成熟的方向是基于度量学习的孪生网络backbone可以用ResNet18损失函数用Triplet Loss或Circle Loss。相比传统方案它最大的优势是特征自动学习能够捕捉到人工特征难以描述的微妙差异比如连笔走向和顿笔位置。但代价也很现实需要精心组织三元组训练样本调参周期长推理时需要GPU才能获得较好性能。更务实的思路是传统特征做粗筛把相似度高于某一候选阈值的配对再送进深度学习模型做二次判定这样既控制了算力成本又提升了整体准确率。5.5 合规使用边界笔迹是生物特征信息最后必须提醒一点笔迹属于生物特征信息和指纹、人脸类似采集和处理都需要合法依据。做内部研究和技术验证没问题但如果要上线对外服务或者处理他人笔迹样本需要明确用途并取得数据主体的授权同意。我在帮企业做档案归组时会把知情告知和授权流程放在项目动工之前避免后期数据使用陷入被动。同时对外输出鉴定结论时要清楚标注“辅助筛查结果”不给使用者留下“机器已经下了结论”的误解空间。这套代码我后来在几个小项目上复用过最大的体会是笔迹鉴定这类任务效果的大头来自预处理是否做得干净其次才是特征设计和阈值选择。如果你在复现时发现准确率上不去先别急着换模型把预处理后的二值图可视化看一眼答案通常就藏在图里。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →