简介:这套基于机器学习的手写签名真伪识别系统,是一份面向图像处理、模式识别与机器学习学习者的 C++ 工程资料包,覆盖签名图像去噪与归一化、边缘轮廓强化、特征提取与模式匹配、SVM 分类、特征向量分析及演化计算优化等完整流程,并包含训练集与测试集划分设计。压缩包共 40 个文件,以 .h/.cpp 源码为核心,配合 .sln/.vcxproj 等工程配置、.bmp/.ico 图像资源、txt/md/docx 说明文档与可执行程序,整体仅 1.08MB,便于快速查看和运行调试。资源提供使用说明、README、演示图像及附赠文档,可据此复现签名特征构建与真假判别的技术路线,适合课程设计、毕业设计或算法对比研究。目前已有 84 人学习下载,可作为入门到进阶的参考资料。
1. 手写签名真伪识别为什么值得做:从图像预处理到特征向量的完整落地路径
手写签名真伪识别看起来是个小众需求,但它每天出现在合同审批、银行柜台、司法鉴定和电子签章系统里。人工鉴定一份签名往往要请专家反复比对笔迹特征,耗时长、成本高,而且结论很难量化。基于机器学习的手写签名真伪识别系统,核心思路并不复杂:把签名图像做预处理,再做特征提取与模式匹配,最后用支持向量机或深度学习算法训练一个二分类模型,对真伪给出概率判断。这套方案适合做毕业设计、做文档风控、做签核类产品的开发者直接参考落地。
一个反直觉的结论先说在前面:笔画复杂的签名未必更难伪造,因为伪造者更容易在复杂笔画里暴露顿笔和连笔的破绽;反而是结构简单的签名,机器学习模型往往难以稳定区分真伪。后面每一章都会围绕这个判断展开,从预处理、特征向量、SVM 分类,到演化计算优化参数,最后给出真实项目里最常见的踩坑点。
2. 图像预处理与特征提取:让签名从像素变成可分类的特征向量
2.1 灰度化、去噪与二值化:预处理三步与关键参数
签名图像的来源很杂:扫描仪出来的、手机拍照的、电子签屏导出的,背景可能是白纸,也可能是带格线的表格纸。预处理的目标只有一个——把所有图像统一成干净的二值墨迹图,去掉纸张底色和噪点,但不破坏笔锋。我常用的方案是 OpenCV 四步走:灰度化、中值滤波去噪、大津法二值化、统一尺寸。
import cv2 import numpy as np # 1. 灰度化:后续所有计算都基于单通道,减少颜色干扰 img = cv2.imread("signature_raw.png", cv2.IMREAD_GRAYSCALE) # 2. 去噪:中值滤波对扫描产生的椒盐噪点效果好 img_denoise = cv2.medianBlur(img, 5) # 3. 二值化:大津法自动计算阈值,墨迹置为白色,背景置为黑色 _, img_bin = cv2.threshold( img_denoise, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU ) # 4. 统一尺寸:128x64 保留横向走势,过小丢笔锋,过大增噪声 img_resized = cv2.resize(img_bin, (128, 64), interpolation=cv2.INTER_AREA)第二步的中值滤波是这四步里最容易被忽略的。中值滤波的 ksize 取 3 还是 5,直接决定后续特征质量:ksize 取 3 对细笔迹保留好,但去噪不彻底;取 5 更干净,却可能把连笔断开。如果原始图像是手机拍照、光照不均,单靠大津法会二值化出大面积黑块,这时候要把第 3 步换成自适应阈值。
参数建议整理如下,按采集设备类型选:
- medianBlur 的 ksize:扫描件用 5,手机拍照用 3;笔迹很细的先试 3,看二值化后笔划是否断裂
- 阈值策略:大津法适合背景均匀的扫描件;adaptiveThreshold 适合光照不均的拍照件
- resize 尺寸:128×64 是底线,配合 8×4 的网格特征时每个格子还有 16×8 像素可统计
注意,二值化方向必须统一。我习惯用 THRESH_BINARY_INV 把墨迹置为白色、背景置为黑色,这样后面统计白色像素密度时,数值越大代表墨迹越多,语义直观。如果方向反了,后面所有特征的含义都要反过来解释,这是很隐蔽的坑。
2.2 全局几何特征与局部网格密度特征:两种特征怎么写
预处理完成之后,签名就是一张 128×64 的二值图。接下来要做特征提取,也就是把像素矩阵压缩成一个固定长度的特征向量。实践中我一般把特征分成两组:全局几何特征和局部网格密度特征。
全局几何特征描述整张签名的形态,包括墨迹面积占比、宽度比、高度比、重心位置等。它计算快、有明确语义,但对伪造辨识力偏弱,因为伪造者通常能模仿整体的宽高比例。局部网格密度特征把签名图切成若干格子,统计每个格子里的墨迹密度,位置敏感,能捕捉签名内部的结构分布。
def grid_density_feature(img, rows=8, cols=4): """网格密度特征:每个格子墨迹像素占比,位置敏感""" h, w = img.shape cell_h, cell_w = h // rows, w // cols feats = [] for r in range(rows): for c in range(cols): cell = img[r*cell_h:(r+1)*cell_h, c*cell_w:(c+1)*cell_w] feats.append(np.count_nonzero(cell) / cell.size) return np.array(feats) def global_geometry_feature(img): """全局几何特征:面积占比、宽高比、重心位置""" h, w = img.shape ys, xs = np.nonzero(img) # 空白图像直接返回全 0,避免除零和 NaN if len(xs) == 0: return np.zeros(4) area_ratio = len(xs) / (h * w) # 墨迹覆盖面积 width_ratio = (xs.max() - xs.min()) / w # 横向跨度 height_ratio = (ys.max() - ys.min()) / h cy, cx = ys.mean() / h, xs.mean() / w # 重心坐标 return np.array([area_ratio, width_ratio, height_ratio, cx * cy])组合特征时需要注意,全局特征和网格特征量纲不同。网格密度是 0 到 1 的小数,而如果后面加入惯性矩、笔画数这类特征,数值会到几千。直接拼接喂给 SVM 会让距离计算被大数值特征主导,所以标准化是必需步骤。
提示:网格密度中的 blanks 处理要提前做。空签名或大面积掉墨的图像会让某个格子的密度变成 0,这本身是有效信号,但如果整张图是空白,后面的标准化会报错。在特征函数里加空图判断兜底,省得排错时一头雾水。
2.3 特征标准化与降维:SVM 收敛和泛化的前提
特征向量拼好之后,标准化这块我见过太多人跳过,结果 SVM 的准确率怎么调都上不去。StandardScaler 做的事情是把每个特征变成零均值、单位方差,让 C 和 gamma 的取值有意义。关键点在开头就必须做对:scaler 只 fit 训练集,测试集用同一个已拟合的 scaler 做 transform,不能把测试集数据混进来拟合,否则就是信息泄露,测试指标会虚高。
from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA # X 是所有样本的特征向量堆叠,y 是标签(1 真,0 假) X = np.vstack([features_real, features_forged]) y = np.array([1] * len(features_real) + [0] * len(features_forged)) # 只对训练部分 fit,测试部分用同一 scaler 转换 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # PCA 降维:保留 95% 方差,样本量小的时候防止维度灾难 pca = PCA(n_components=0.95) X_pca = pca.fit_transform(X_scaled) print(X_pca.shape)PCA 这一步不是必需的,但如果特征维度超过 100 而样本只有两三百份签名,我建议做。签名识别本质上是小样本问题,特征维度和样本量的比例一旦失衡,SVM 很容易在训练集上表现完美、换批数据就崩。降维后保留主成分的累计方差比例可以用 0.95 起步,如果效果不理想再往 0.90 调,看验证集 F1 的变化。
3. 支持向量机分类:核函数选型、参数寻优与训练集划分
3.1 为什么选 SVM:小样本二分类问题的稳定选择
手写签名真伪识别是个典型的二分类问题:真签名是一类,伪造签名是另一类。但它的特殊性在于样本量通常很小——一个人留下 10 份真签名已经很不容易,伪造样本更稀缺。在这种场景下,SVM 比决策树、KNN 和逻辑回归都更合适:决策树在小样本上容易过拟合,KNN 对特征尺度敏感而且推理时要计算全部距离,逻辑回归对非线性分类边界拟合吃力。
SVM 的核心思路是找最大间隔分类超平面。间隔越大,泛化能力越强,这正好契合签名识别里样本少、噪声大的特点。RBF 核可以把特征映射到高维空间,让线性不可分的真假签名在高维里变得可分。这就是标题里「支持向量机分类」和「特征向量分析」的组合逻辑:特征工程负责把图像变成向量,SVM 负责在向量空间里划边界。
深度学习当然也能做这个任务,而且特征提取能力更强,但纯端到端 CNN 需要大量数据。标题同时提到「深度学习算法应用」,常见做法是用深度模型做特征提取、用 SVM 做最终分类,这个混合方案我在最后一章展开。中小数据集上,SVM 加手工特征往往比裸 CNN 更稳,迭代也快。
3.2 RBF 核的 C 与 gamma 调参:网格搜索与概率输出
用 RBF 核的 SVM 有两个真正需要调的超参数:C 和 gamma。C 是误分类惩罚系数,C 越大模型越努力把训练样本分对,也越容易过拟合;gamma 控制单个训练样本的影响半径,gamma 越大决策边界越弯曲,gamma 越小边界越平滑。这两个参数组合起来,搜索空间是二维的,所以用网格搜索加上交叉验证就能找到不错的区域。
from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV param_grid = { 'C': [0.1, 1, 10, 100], 'gamma': [0.001, 0.01, 0.1, 1], 'kernel': ['rbf'] } svc = SVC(probability=True, random_state=42) grid = GridSearchCV(svc, param_grid, cv=5, scoring='f1', n_jobs=-1) grid.fit(X_train_scaled, y_train) print("best params:", grid.best_params_) print("best score:", grid.best_score_)这里有几个点容易被忽略。scoring 选 'f1' 而不是默认的 accuracy,是因为真假样本往往不平衡——伪造签名收集难度大,数量天然少于真签名,accuracy 会被多数类带偏。probability=True 让模型输出 predict_proba,这是后面做真伪阈值判定的关键,如果不开启,你只能拿到硬分类结果,无法控制误拒和误收的平衡。
我自己的经验范围:C 在 1 到 100 之间有效,gamma 在 0.001 到 0.1 之间有效,再往大基本都是过拟合。网格搜索先用粗粒度跑一遍定位好区域,再在附近细粒度加密,比一次性铺开大网格省时间得多。细粒度搜索用交叉验证评估时,每组参数要跑 5 次训练,网格一大,时间成本不是线性的。
注意:交叉验证的划分也必须按人分组。如果同一个人的签名在训练折和验证折里同时出现,模型相当于见过这个人的笔迹变体,验证分数会虚高。下面这节能解决这个问题。
3.3 训练集与测试集的划分协议:同人伪造与随机伪造不能混
这是整个手写签名真伪识别系统里最容易翻车、也最容易被新手忽略的一步。普通分类任务随机切分训练集测试集没有问题,但签名数据不行。原因在于同一个人的多份签名之间高度相似,如果把一个人的 8 份真签名随机拆进训练集和测试集,测试时模型见到的其实是它已经见过的笔迹变体,这分数不能代表真实场景的表现。
真实场景要解决的是:这个人以前没见过的签名,是真还是假。所以必须按签名者分组划分,让同一个人的所有签名只出现在训练集或只出现在测试集。scikit-learn 的 GroupShuffleSplit 就是干这个的。
from sklearn.model_selection import GroupShuffleSplit # author_ids 和 X 的行一一对应,同一人的所有签名共享同一个 id gss = GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=42) train_idx, test_idx = next(gss.split(X_scaled, y, groups=author_ids)) X_train, X_test = X_scaled[train_idx], X_scaled[test_idx] y_train, y_test = y[train_idx], y[test_idx]伪造签名也要分两种类型分别报告:随机伪造和熟练伪造。随机伪造是随意写的别人名字,字形差异大,模型很容易区分;熟练伪造是模仿笔迹练习后的结果,形态接近,这才是考验系统能力的地方。测试时把两种伪造分开统计准确率,如果只报一个总的准确率,会被随机伪造样本拉高好几个百分点,无法反映真实水平。
训练集内部再用 StratifiedKFold 做交叉验证,保持每一折里真假比例一致,避免某一折全是真签名导致评估失真。整套划分协议固定下来之后,调参、特征选择、演化计算优化,全都要在这个协议下做,否则后面的结果没有可比性。
4. 演化计算优化:用遗传算法自动找 SVM 参数与特征组合
4.1 为什么用演化计算代替网格搜索:个体编码与迭代流程
网格搜索在参数少的时候够用,但当你同时想调 C、gamma,还想筛选特征组合时,搜索空间会爆炸。假设 C 有 5 档、gamma 有 5 档、特征开关有 30 个,穷举是 5×5×2^30 种组合,根本跑不完。演化计算优化,也就是标题里的「演化计算优化」,用遗传算法在这类高维组合问题上做启发式搜索,几十代迭代就能定位到不错的区域。
遗传算法的流程不复杂:初始化一群随机个体,每个个体是一组候选解;评估每个个体的适应度;按适应度选择优秀个体;交叉产生新个体;变异增加随机扰动;重复迭代。把 SVM 的参数寻优套进去,个体就是一组 [C, gamma],适应度就是交叉验证的 F1 分数。
编码方式要重点设计。C 和 gamma 的合理范围横跨多个数量级,直接编码成原始数值,小数值区域会被大数值区域吞掉,搜索效率极低。常见做法是取对数再编码,让搜索空间均匀分布。我一般把 C 编码在 10^-2 到 10^3 之间,gamma 编码在 10^-4 到 10^1 之间,正好覆盖实际有效范围。
4.2 用适应度函数封装交叉验证精度:DEAP 落地代码
DEAP 是 Python 生态里比较成熟的遗传算法框架,用它写演化计算优化比手写整个循环要可靠。核心逻辑只有三块:定义适应度和个体类型、注册各种算子、运行进化循环。下面是完整的最小实现。
import random import numpy as np from deap import base, creator, tools, algorithms from sklearn.svm import SVC from sklearn.model_selection import cross_val_score, StratifiedKFold # 个体编码为 [log10(C), log10(gamma)] # C 范围 10^-2 ~ 10^3,gamma 范围 10^-4 ~ 10^1 LOW = [-2.0, -4.0] HIGH = [3.0, 1.0] creator.create("FitnessMax", base.Fitness, weights=(1.0,)) creator.create("Individual", list, fitness=creator.FitnessMax) def eval_svm(individual): """适应度函数:把个体解码成 SVM 参数,返回 5 折 CV 的 F1 均值""" C = 10 ** individual[0] gamma = 10 ** individual[1] model = SVC(kernel='rbf', C=C, gamma=gamma) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(model, X_train, y_train, cv=cv, scoring='f1') return (scores.mean(),) toolbox = base.Toolbox() toolbox.register("attr", random.uniform, LOW, HIGH) toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr, 2) toolbox.register("population", tools.initRepeat, list, toolbox.individual) toolbox.register("evaluate", eval_svm) toolbox.register("mate", tools.cxBlend, alpha=0.5) toolbox.register("mutate", tools.mutGaussian, mu=0.0, sigma=0.3, indpb=0.4) toolbox.register("select", tools.selTournament, tournsize=3) pop = toolbox.population(n=24) algorithms.eaSimple(pop, toolbox, cxpb=0.7, mutpb=0.25, ngen=20, verbose=True) best = tools.selBest(pop, k=1)[0] print("best C =", 10 ** best[0], "best gamma =", 10 ** best[1])适应度函数里每次评估都要做 5 次 SVM 训练,一个个体一次评估的成本就是 5 次训练,24 个个体迭代 20 代是 2400 次训练。所以特征维度越少,演化过程越快。这也是为什么我坚持在前一章里先做 PCA 降维,让 X_train 控制在几十维以内,否则演化计算优化会因为训练太慢变得不可用。
参数上,种群规模 24、迭代 20 代是起点。cxpb 是交叉概率,控制个体配对的频率;mutpb 是变异概率,控制随机扰动的强度。这两个值一高一低是套路,我通常用 0.7 和 0.25。变异用高斯扰动,sigma 取 0.3 意味着对数坐标里走一个较小的步长,太大会让参数在最优区域附近震荡,太小的变异又容易被交叉主导、提前收敛。
4.3 固定随机种子、早停与收敛判断:防止演化优化跑偏
演化计算优化有个让新手头疼的特点:每次运行结果不一样。这不是 bug,而是遗传算法本身带随机性——初始化种群随机、交叉和变异也随机。如果不固定随机种子,同一个数据集跑三次,得到三组略有差异的最优参数,后面就没法复现实验。在 import 之后立刻加 random.seed(42) 和 np.random.seed(42),保证可复现。
早停是另一个实用技巧。遗传算法迭代到后期,最优适应度往往不再提升,继续跑只是浪费计算时间。常见做法是记录每一代的最优适应度,连续 N 代没有提升就提前终止。这里的 N 我一般取 5 到 8,太小的 patience 容易在适应度暂时平台期就误杀,太大则失去早停的意义。
best_fitness_history = [] def early_stop(history, patience=5): """连续 patience 代最优适应度无提升则停止""" if len(history) < patience: return False recent = history[-patience:] return max(recent) == min(recent)还要提醒一个细节:遗传算法找到的是「还行」的参数,不一定是全局最优。面对同一份数据,网格搜索在小范围内往往能找到更精确的局部最优,但遗传算法能在更大范围里快速定位好区域。我实际项目中常用两段式策略:先用遗传算法粗定位 C 和 gamma 的有效区间,再用小范围网格加密精调。这样既省时间,又能拿到稳定的结果。
提示:演化计算的适应度是带噪声的——5 折交叉验证本身因划分不同会有波动。所以不要用单次评估结果严格比较两个几乎相等的个体,更稳的做法是每组参数重复 3 次取平均,代价是训练时间翻三倍。样本规模小时可以接受,样本大时只对最终选出的前几名做重复验证即可。
5. 手写签名识别常见的 5 个坑与排查方法
5.1 现象:测试集里所有签名都被判为真,模型形同虚设
这是签名真伪识别里最典型的翻车现场:训练完的模型在测试集上准确率很高,但把所有样本都预测成真签名,仔细一看 F1 分数惨不忍睹。原因几乎都是类别不平衡——伪造签名收集难度大,真实数据里真签名占 80% 以上,模型学到的策略就是全部判真,因为这样准确率也有 80%。
解决思路有两条。第一,在 SVM 上设置 class_weight='balanced',让少数类的错误代价自动放大,这是最省事的做法。第二,用 SMOTE 对伪造类做过采样。更根本的手段是控制数据比例,伪造样本尽量占到 30% 以上。如果项目里伪造签名实在难收集,至少要保证每个签名者都有对应的高质量伪造样本,否则「真伪鉴别」无从谈起。
5.2 现象:换一批扫描件准确率断崖式下跌
在实验室数据集上表现很好的模型,换成另一台扫描仪或者手机拍摄的签名图像,准确率可能从 95% 掉到 60%。原因不是模型变了,而是预处理环节没做好对不同采集设备的适配:分辨率不同导致 resize 后笔迹粗细不一致,纸张底色不同导致二值化阈值失效,光照不均导致墨迹断裂。
解决方法是把预处理参数放在验证协议里一起评估。我一般会特意留出一个「陌生采集设备」的数据组,在模型评估最后一轮才放出来测试。如果断崖式下跌出现在陌生设备上,优先检查二值化是否用了自适应阈值、resize 后是否统一了笔迹宽度。必要时用形态学膨胀或腐蚀把笔迹粗细归一化,再重新提取特征。
5.3 现象:训练精度接近 100%,测试精度只有 60%
训练集上表现完美、测试集上明显退化,这是过拟合的典型症状。签名识别场景里具体诱因有三个:特征维度太高、C 参数调得太大、或者训练测试划分时没有按人分组导致同人笔迹泄露。前两个是模型层面的问题,第三个是评估协议层面的问题,而且第三个最隐蔽——表面上看划分比例正常,实际上同一个人的签名同时出现在两边,模型记住了人而不是记住了真伪模式。
解决方法是逐一排查:先按第 3 章的 GroupShuffleSplit 重新划分,确认测试集里没有训练集出现过的签名者;然后用 PCA 把特征维度压下来;最后把 C 往小调几个数量级观察验证集变化。这三个动作做完,训练和测试精度的差距通常会显著收窄。
5.4 现象:真签名被频繁误拒,用户投诉不断
误拒率高意味着系统把大量真签名判成了伪造。原因常常不是模型差,而是签名本身有自然波动——人在不同时间、不同姿势、不同纸张上签出来的字形不可能完全一致。如果训练集里这个人只提供了 3 份签名,模型的判断标准会非常严苛,稍微偏离就判假。
解决方法是给每个签名者采集更多真签名样本,至少 8 到 10 份,作为签名波动范围的建模基础。特征提取时可以对该签名者的所有样本计算均值和标准差,形成统计模板,而不是只用单张图片。同时把判决阈值从硬分类改成软阈值:SVM 输出 0.7 以上判真、0.3 以下判假、中间进入人工复核,这是生产环境里更务实的做法。
5.5 现象:遗传算法每次跑出来的最优参数都不一样
演化计算优化的参数在两次运行之间变化很大,这是随机性没有被管理好。具体原因有三个:没固定随机种子、种群规模太小、适应度函数本身有噪声。种群只有 10 个个体时,初始化随机性直接影响搜索结果;而适应度来自交叉验证,不同的折划分带来不同分数,导致选择压力不稳定。
解决方法是三步:所有随机源固定 seed;种群规模提到 20 到 30,代数可以相应减少,因为大种群比多代数的探索效率更高;对最终候选参数做重复验证,同一组参数在固定划分下跑 3 次取平均。稳定复现之后,最优参数才有资格进入最后的主模型训练。
6. 深度学习与 SVM 混合思路:最后的调优与验证技巧
中小数据集上,我最后习惯把深度学习和 SVM 结合起来用:用 CNN 提取深度特征,替代手工设计的网格密度和几何特征,再把特征向量喂给 SVM 分类。这个方案的好处是特征表达能力强,同时保留了 SVM 在小样本上的泛化优势。常见的落地方式是用预训练的 CNN 去掉全连接层,只取卷积部分的输出做全局平均池化,得到一个固定长度的特征向量。
from tensorflow.keras.applications import VGG16 from tensorflow.keras.models import Model # 输入按 (64, 128, 3) 组织,对应之前的二值图转三通道 base = VGG16(weights='imagenet', include_top=False, input_shape=(64, 128, 3)) feature_extractor = Model(inputs=base.input, outputs=base.output) features = feature_extractor.predict(x_rgb) # 输出形状是 (样本数, 高, 宽, 通道数),全局平均池化后压成向量 deep_feat = features.mean(axis=(1, 2))手工特征和深度特征可以拼接,但要注意维度匹配。深度特征通常几百维,拼接后建议再做一次 PCA,压到 50 维左右,避免小样本下维度爆炸。评估指标也要换一个更专业的:等错误率 EER。它描述的是误拒率和误收率相等时的错误水平,一张表格就能讲清楚模型好坏。
验证协议最后固定成一套:按人分组划分,训练集里只留随机伪造样本,测试集里同时放随机伪造和熟练伪造,分别报告准确率,再给出 EER。三件事——固定随机种子、按人分组、统一预处理参数——在任何签名识别项目里都绕不开。
我自己做过的一个教训是:最初用随机划分跑了半年数据,参数调得很漂亮,后来换成按人分组,所有指标掉了十几个点。从那以后,我所有的实验都在三套协议上同时跑:按人分组、真假样本均衡、固定随机种子。这三点不满足,指标再好看也只是自欺欺人。希望帮到你。
本文还有配套的精品资源,点击获取