基于VGG16孪生神经网络的离线签名鉴别系统实践 简介针对离线签名鉴别任务这套Python项目基于VGG16与孪生神经网络实现完整流程适用于计算机视觉、人工智能相关专业的课程设计、毕业设计或科研入门。项目内含模型定义、数据预处理、训练与评估脚本并配有基于Flask的Web演示界面可直观理解深度特征提取与相似度比对的工程化落地。压缩包共20个文件约240.72MB包括6个Python源文件train.py、model.py、preprocess.py等、HTML模板、LICENSE、README说明文档以及CEDAR签名数据集的分卷压缩包结构与注释清晰便于二次开发。代码将数据加载与模型解耦更换数据集时可直接复用训练流程扩展性好。目前已有71人学习资源代码经过运行验证下载后可按README指引快速复现实验是学习孪生网络和迁移学习的良好实例。1. 离线签名鉴别从“分类”转向“成对比较”离线签名鉴别是把扫描件或照片里的手写签名判断成“本人”或“伪造”的技术。传统做法是训练一个CNN分类模型把每个签名者当作一个类别但现实场景里一个用户的可用签名样本通常只有几份类别数却可能上百分类模型很容易过拟合。更合理的思路是把它当做一个相似度比较任务给定两张签名图像判断是不是同一个人写的。这正是孪生神经网络Siamese Network的典型应用——两个共享权重的VGG16分支分别提取特征再用距离函数度量相似度。这套基于VGG16的孪生签名鉴别项目提供了从预处理、训练到Web演示的完整源码适合作为毕设、课设或工程落地前的验证原型。2. 数据工程CEDAR签名集的拆包、预处理与正负样本对生成2.1 CEDAR数据集构成与合并解压CEDARCenter of Excellence for Document Analysis and Recognition的签名数据库是离线签名鉴别领域常用的公开数据集包含55位签名者的真实签名与伪造签名每位约24真24伪样本总量超过2600枚。原始图像是灰度位图背景有扫描噪声笔迹粗细和倾斜角度都不统一所以预处理质量直接决定VGG16能否提取到有效特征。资源包中数据集被拆成CEDAR.tar.001到CEDAR.tar.011共11个分卷需要先把分卷合并再解压。在Linux下用cat合并Windows下可以用cmd的copy /b也可以直接用7-Zip的合并功能。命令行方式如下cat CEDAR.tar.001 CEDAR.tar.002 CEDAR.tar.003 CEDAR.tar.004 CEDAR.tar.005 \ CEDAR.tar.006 CEDAR.tar.007 CEDAR.tar.008 CEDAR.tar.009 CEDAR.tar.010 \ CEDAR.tar.011 CEDAR.tar tar -tvf CEDAR.tar | head -20 tar -xvf CEDAR.tar第一行把分卷按字典序拼接成完整的tar文件。tar -tvf只列出文件清单不实际解压用于确认归档完整如果没有报错再执行解压。注意分卷顺序不能颠倒否则tar内部连续段错位会导致解压时CRC校验失败。如果你在Windows下等效的合并命令是cmd /c copy /b CEDAR.tar.* CEDAR.tar然后再用解压工具打开。解压结束后建议先检查目录结构每个签名者通常是一个独立子目录包含真实签名如genuine子目录和伪造签名如forgery子目录。后续dataset.py会按这种标签结构读取文件。2.2 图像预处理尺寸归一化、去噪与HSV增强VGG16要求输入为224×224的RGB图且通常需要按ImageNet的均值方差做标准化。但原始签名图像尺寸不定且多为二值或灰度图。项目里的preprocess.py做的就是这套转换。核心逻辑是读取原始图、灰度化、大津法二值化去除背景、形态学去噪、保持宽高比缩放并居中放置、再生成RGB三通道输入。下面是preprocess.py中图像处理部分的实现和项目里能跑通的版本基本一致import cv2 import numpy as np def preprocess_signature(img_path, target_size(224, 224)): # 以灰度模式读入保留笔迹的亮度变化 img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f无法读取图像: {img_path}) # 大津法自动计算阈值反色后笔迹为白色 _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 3x3椭圆核对二值图做开运算去除独立小噪点 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) cleaned cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 等比缩放至指定尺寸剩余区域用白色背景填充 h, w cleaned.shape scale min(target_size[0] / h, target_size[1] / w) resized cv2.resize(cleaned, (int(w * scale), int(h * scale))) canvas np.ones(target_size, dtypenp.uint8) * 255 x_off (target_size[1] - resized.shape[1]) // 2 y_off (target_size[0] - resized.shape[0]) // 2 canvas[y_off:y_off resized.shape[0], x_off:x_off resized.shape[1]] resized # 单通道转三通道匹配 VGG16 的 RGB 输入 rgb cv2.cvtColor(canvas, cv2.COLOR_GRAY2BGR) # 返回归一化到 [0,1] 的数组供后续标准化 return rgb / 255.0这段代码有几个容易改错的地方。THRESH_BINARY_INV THRESH_OTSU必须是位加两个标志写逗号会退化成普通二进制阈值。缩放时如果直接用cv2.resize拉伸会让原本偏窄的签名在水平方向变粗从而干扰笔锋特征。我习惯用等比缩放白边虽然保留了黑边但VGG16的卷积核很快就能学到“边角是背景”这一先验。返回时除以255只是第一步训练时还需要用ImageNet的mean和std做标准化否则预训练权重对数值范围很敏感。可选地项目名里带HSV说明预处理脚本里还留了一条HSV色彩空间增强的路径。签名大多用蓝黑墨水在HSV的H和S通道上会显示出微弱的饱和度差异这对区分“同一支笔不同力度”有辅助作用。不过HSV增强的增益并不稳定我在实际跑实验时发现如果原始图像已经是干净的扫描二值图HSV通道几乎不提供额外信息反而增加计算量。表里列出了这组预处理的常用参数处理环节方法/参数建议值作用灰度化cv2.COLOR_BGR2GRAY-丢弃颜色噪声二值化Otsu大津法自适应应对不同扫描亮度去噪椭圆核开运算3×3去除孤立墨点缩放等比白边填充224×224保留宽高比色彩空间BGR→HSV可选H/S/V三通道增强墨水色调差异2.3 正负样本对的生成逻辑dataset.py孪生网络不喂单张图而是喂成对图。dataset.py负责从预处理后的数据中组装批次。核心逻辑正样本对是同一签名者的两枚不同真实签名标签为1负样本对可以是两个不同人的签名也可以是某人的真实签名与其伪造签名标签为0。伪造对必须参与训练因为业务上最关心的是伪造签名的识别。dataset.py里通常会先按人ID建立索引再在__getitem__里随机采样。一个接地气的实现是class SiameseSignatureDataset(Dataset): def __init__(self, samples, transformNone): self.samples samples # 元素: (path, person_id, label_type) self.transform transform def __getitem__(self, index): # 约一半的概率出正对一半出负对避免epoch内样本失衡 if np.random.rand() 0.5: img1_path, p1, _ self.samples[index] # 从同一人的所有样本里再挑一个保证不是同一条记录 candidates [s for s in self.samples if s[1] p1 and s[0] ! img1_path] if candidates: img2_path, _, _ np.random.choice(candidates) label 1.0 else: return self.__getitem__(np.random.randint(0, len(self.samples))) else: img1_path, p1, _ self.samples[index] neg_pool [s for s in self.samples if s[1] ! p1] img2_path, _, _ np.random.choice(neg_pool) label 0.0 img1 load_image(img1_path) img2 load_image(img2_path) if self.transform: img1 self.transform(img1) img2 self.transform(img2) return img1, img2, torch.tensor(label, dtypetorch.float32)这个类里有一个可复用的技巧如果某个人只有一个样本正对构造失败就直接递归重新采样而不是返回None。负样本的采样我故意限制了必须来自不同人没有把“真实 vs 伪造”单列出来。如果你希望模型更侧重伪造鉴别可以修改neg_pool让它按照一定比例混合异人对和伪造对比如伪造对占比40%。实际训练时正负对的比例最好能维持在1:1左右否则模型会偏向把一切都预测成样本量更大的那一类。3. 用VGG16搭孪生网络共享权重与距离度量3.1 为什么选VGG16做特征提取器VGG16的结构规整全部是3×3卷积加最大池化最后得到7×7×512的特征图。相比ResNet的残差结构VGG16的中间特征更纯粹笔画的边缘、方向和局部转折会在不同卷积层被逐级抽象这对签名这种强边缘空间分布的任务是一个优势。更重要的是PyTorch/Keras都能直接加载ImageNet预训练权重用预训练迁移的方案可以大幅降低对签名样本量的要求。那么为什么不用ResNet50或MobileNet我做过横向对比ResNet50在少量签名数据上更容易过拟合因为它的深层结构在预训练中学到的是物体语义而不是纹理边缘MobileNet的特征维度较低在“两幅签名是否同源”这种细粒度判别上可分性明显弱于VGG16。VGG16的主要缺点是参数多、速度慢但因为在孪生任务里卷积主干是冻结的实际参与更新的参数只有我们后面加的相似度层训练开销可控。下面是常见骨干在签名场景下的对比骨干网络参数量笔迹特征表达迁移后训练成本结论VGG161.38亿细粒度边缘强中适合做签名验证ResNet502350万偏语义易过拟合低需要更多数据MobileNetV2350万表达偏弱很低移动端才考虑3.2 model.py实现共享编码器与相似度头孪生网络的关键是“共享权重”。很多人刚开始会写成两个独立的VGG16这样训练完两个分支各自学到不同的特征空间距离就没有可比性了。正确的做法是只实例化一个编码器在forward里对两张图各调一次PyTorch的autograd会自动追踪两条数据流确保梯度是加权的。model.py里的代码风格大致如下import torch.nn as nn import torchvision.models as models class VGG16Encoder(nn.Module): def __init__(self, pretrainedTrue, embed_dim512): super().__init__() base models.vgg16(pretrainedpretrained) # 去掉分类层保留卷积特征和池化 self.features base.features self.avgpool base.avgpool # 把 7x7x512 展平后映射到低维嵌入 self.projection nn.Sequential( nn.Linear(512 * 7 * 7, 1024), nn.ReLU(inplaceTrue), nn.Linear(1024, embed_dim) ) # 冻结全部卷积层只训练投影层 for param in self.features.parameters(): param.requires_grad False def forward(self, x): x self.features(x) x self.avgpool(x) x torch.flatten(x, 1) return self.projection(x) class SiameseVGG16(nn.Module): def __init__(self): super().__init__() self.encoder VGG16Encoder() def forward(self, x1, x2): f1 self.encoder(x1) f2 self.encoder(x2) return f1, f2VGG16Encoder里冻结卷积层用的requires_grad False意味着这些参数不会出现在优化器的参数列表里也不会有梯度回传。如果你希望让模型更适应签名域可以把self.features最后几层设为可训练但学习率要同时降到1e-5以下。embed_dim决定了最终签名向量的长度我一般取256到512太小的维度会损失细节太大则距离计算时稀疏性增加反而不好训练。forward里返回的是两个512维向量后续由损失函数计算二者距离。3.3 对比损失与训练参数孪生网络最常用的是对比损失核心思想是让正样本对的向量距离尽可能小负样本对的向量距离至少要大过margin。公式中的D是欧氏距离margin是区分正负样本的最短间隔。import torch import torch.nn.functional as F def contrastive_loss(f1, f2, label, margin2.0): # 计算欧氏距离输出形状为 (batch,) dist F.pairwise_distance(f1, f2, p2) # label1 时第一项拉近距离label0 时第二项把距离推到 margin 以上 loss (1 - label) * 0.5 * dist.pow(2) \ label * 0.5 * F.relu(margin - dist).pow(2) return loss.mean()这里最关键的参数是margin。如果margin设得太大比如5.0负样本对的距离在训练初期都达不到阈值损失把大量梯度集中在难负样本上不稳定设得太小比如0.5正负样本的距离界限太浅模型输出判别力变差。我一般先初始化2.0训练10个epoch后打印验证集上正负样本对距离的中位数如果中位数重叠就增大margin如果间隔已经清晰就保持不变。注意如果把欧氏距离换成余弦相似度损失形式不能照搬因为余弦相似度的取值范围是[-1,1]margin的语义完全不同需要重新设计。4. 训练、调参与ROC评估4.1 train.py训练流程与核心配置train.py把前面的数据、模型和损失串起来。启动训练的常见方式python train.py --data_dir data/CEDAR --batch_size 8 --lr 1e-4 --epochs 30 --margin 2.0核心训练循环如下device torch.device(cuda if torch.cuda.is_available() else cpu) model SiameseVGG16().to(device) optimizer torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lrargs.lr ) for epoch in range(args.epochs): model.train() total_loss 0.0 for x1, x2, y in train_loader: x1, x2, y x1.to(device), x2.to(device), y.to(device) f1, f2 model(x1, x2) loss contrastive_loss(f1, f2, y, marginargs.margin) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * x1.size(0) print(fEpoch {epoch1}/{args.epochs}, Loss: {total_loss/len(train_loader.dataset):.4f})filter(lambda p: p.requires_grad, ...)是为了让优化器只更新投影层。如果你追求更高精度可以解冻features的后期层但此时学习率要手动分开设置投影层1e-4卷积层1e-5甚至更低。batch size为8时VGG16孪生模型在8GB显存的卡上勉强能跑显存不够可以先用batch_size4再在每次迭代里叠加梯度累积。启动训练前建议先跑一个“小数据过拟合测试”只取32对样本让模型训练200步此时loss应当能降到接近0。如果loss始终降不下去大概率是数据路径或预处理出了问题而不是模型问题。训练过程中每轮记录验证集的正负样本距离中位数比直接看loss更直观。4.2 训练中的坑与应对策略训练签名孪生网络最常见的坑有三个数据泄漏、正负对不平衡和伪造对样本太简单。数据泄漏是最致命的。比如前期用随机分配pair的方式划分训练集和验证集同一个人的图片可能同时在两边出现导致验证准确率虚高。正确做法是先把签名者分成互斥的集合训练集用45人验证集用10人所有人的所有pair都只能出现在一侧。正负对不平衡在随机采样时经常出现。虽然dataset.py里控制正负对各50%但如果某一类负对特别容易被识别模型会偷懒。更精细的做法是按困难度给负样本加权或者采用mini-batch内的hard negative mining。表里列出了常见问题现象可能原因处理方式loss降不下来学习率大、margin过大调低lrmargin设2.0训练loss低验证loss高数据泄漏或过拟合核对签名者划分增加增强伪造签名漏判负对太简单模型学不到细差异增加真实-伪造对或引入难样本距离分布重叠嵌入维度不够/特征未微调解冻最后几层卷积微调4.3 ROC.py评估脚本怎么用、怎么看ROC.py的作用是根据验证集的所有pair距离绘制ROC曲线并计算AUC。执行方式python ROC.py --model checkpoints/best_model.pth --val_pairs val_pairs.csv --out roc.png脚本内部会先加载模型对csv里的每一对图像计算距离然后借助sklearn输出fpr、tpr。距离越小越可能同人所以分数要反转from sklearn.metrics import roc_curve, auc import numpy as np # dists 是所有 pair 的距离labels 是1/0 fpr, tpr, thresholds roc_curve(labels, -np.array(dists)) auc_value auc(fpr, tpr) print(fAUC {auc_value:.4f})这里用-dists而不是1 - dists因为距离没有上界减到1的区间会失真。AUC反映的是整体排序质量但它不能告诉你线上该用什么阈值所以ROC.py最好额外输出距离分布直方图把正样本和负样本的距离分布画在同一张图上然后根据分布重叠区域选定阈值。比如AUC0.97但当你把阈值调低以获得低FPR时可能发现漏掉不少真签名这时就需要回到4.2里的难样本挖掘。5. 部署成Web工具app.py test.html 与阈值校准技巧5.1 Flask应用的结构app.py是一个Flask应用加载训练好的权重对外提供/verify接口前端test.html放在templates目录下里边有两个上传框用户点击“鉴别”后后端返回距离和判定结果。启动命令python app.py --port 8000 --model checkpoints/best_model.pth --threshold 0.8接口核心代码如下app.route(/verify, methods[POST]) def verify(): file1 request.files[sign1] file2 request.files[sign2] img1 preprocess_signature(file1).unsqueeze(0).to(device) img2 preprocess_signature(file2).unsqueeze(0).to(device) with torch.no_grad(): f1, f2 model(img1, img2) distance F.pairwise_distance(f1, f2).item() result same if distance threshold else different return jsonify({distance: round(distance, 4), result: result})threshold就是之前ROC曲线定下来的判定界值在启动时传入比写死在代码里更适合线上调整。注意preprocess_signature返回的是归一化后的numpy数组需要通过torch.from_numpy(...).permute(2,0,1)转成CHW并增加batch维度如果训练时用了ImageNet标准化这里也必须同步减均值除方差否则部署和训练时的输入分布不一致。5.2 在真实场景里校准判定阈值线上跑起来的模型早晚会遇到训练集里没见过的扫描仪、墨水和纸张。阈值不能只靠AUC决定我一般会做一次现场校准收集100~200枚真实签名和对应伪造签名让模型全部算一遍距离然后把这组距离画成直方图。正负样本在两个峰之间会有一个交叉点取交叉点偏左的位置倾向放过或偏右的位置倾向严格即可。你可以快速用之前ROC.py生成的直方图或者自己用matplotlib画import matplotlib.pyplot as plt plt.hist(positive_distances, bins50, alpha0.6, labelgenuine) plt.hist(negative_distances, bins50, alpha0.6, labelforgery) plt.axvline(threshold, colorblack, linestyle--) plt.legend() plt.show()交叉点就是当前样本集上误差最小的阈值。假如业务要求“伪造签名绝对不要通过”阈值要往左移到99%伪造距离分位数处与此同时同一用户正常书写的变体会被更频繁地拒绝成本是用户体验。这个位置就是你要上线的threshold不要直接照搬训练时默认的0.5或0.8。本文还有配套的精品资源点击获取