用Keras实现Siamese网络:图像相似性比较实战指南
2026/9/11 8:39:47 网站建设 项目流程

简介:面向图像相似性比较场景的深度学习工具包,基于Keras框架实现暹罗网络,适合人脸验证、版权保护、内容检索等需要判断两张图片内容是否一致的开发者和研究者。压缩包共有24个文件,核心是11个Python脚本,覆盖网络结构定义、模型训练、预测推理、数据增广、数据加载以及回调函数等完整流程;还附带5份Markdown说明文档、4张示例图片、2个文本文件、许可证和忽略规则配置,整体压缩后只有34KB,非常轻巧,便于直接阅读源码和二次开发。目前已有73人学习/下载,适合正在学习孪生网络或图像相似性任务的读者快速搭建实验。资源不仅提供了可以运行的训练与推理逻辑,还附有环境依赖清单、常见问题汇总和示例图片,可以帮助使用者省去环境配置和从零编码的时间。通过配套代码与说明,读者能够直接理解如何构造配对样本、计算嵌入距离、评估模型效果,很适合快速验证想法和作为教学参考。

1. 两张图到底有多像,比出来才算数

图像相似性比较是个被低估的难题。你给模型两张图,问它是不是同一个东西,它不能回答“是”或“不是”,它得给出一个可比较的度量——像不像、差多少、阈值设在哪。传统做法是先抽特征再做距离计算,但手工特征对光照、角度、遮挡的鲁棒性差,换一个场景就崩。Siamese网络(暹罗网络)解决的是另一条路:不去学图像本身的标签,而是学一个“映射函数”,让相似的图在特征空间里靠近,不相似的图被推开。这个思路最早用在签名验证和人脸识别上,后来扩展到图像查重、商品搜索、视频帧去重等场景。Keras 实现这类网络非常顺手,函数式 API 天然支持权重共享。这篇文章不聊怎么调别人的库,而是把用 Siamese + Keras 从数据构造到模型训练再到阈值判定的完整路径讲清楚,里面那些参数和坑,才是实际项目里真正耗时间的地方。

2. Siamese网络的核心机制:权重共享与距离度量

2.1 两个分支一个大脑:共享权重的意义

Siamese 网络的“共享权重”是最容易被人忽略的设计点。两个输入分支各自经过同一个特征提取器,它们的权重完全一样,不单独训练。网络在训练时收到的不是“这张图属于哪类”,而是“这两个输入来自同一类还是不同类”。这个设计有一个直接的后果:模型不学习具体类别的判别边界,而学习“区分是否为同一身份”的几何空间结构。

权重共享带来的直接收益是参数量减半。假设特征提取器有 1000 万参数,两个分支各自独立的话就是 2000 万,共享之后还是 1000 万。同时,由于两个分支共用一个特征空间,推理阶段可以让一张图只过一次网络得到特征向量,之后无论和多少张图做对比,都只需要算向量距离,而不需要重新跑模型。这才是实际项目里性能优化的关键——相似性比对通常发生在图片入库时,查询阶段不应再走完整网络。

实现上,Keras 的函数式 API 是首选,用Model定义两个输入,内部重复调用同一个特征网络。这里不能直接load_model后分别建两个模型再拼接,那样会复制权重。常见做法是先定义特征提取网络为独立的函数或模型,再分别调用它处理两个输入。

import tensorflow as tf from tensorflow.keras import layers, Model def build_encoder(input_shape=(128, 128, 3)): inputs = layers.Input(shape=input_shape) x = layers.Conv2D(32, (3, 3), activation='relu', padding='same')(inputs) x = layers.MaxPooling2D((2, 2))(x) x = layers.Conv2D(64, (3, 3), activation='relu', padding='same')(x) x = layers.MaxPooling2D((2, 2))(x) x = layers.Conv2D(128, (3, 3), activation='relu', padding='same')(x) x = layers.GlobalAveragePooling2D()(x) x = layers.Dense(512, activation='relu')(x) x = layers.Dense(256, activation=None)(x) return Model(inputs, x, name='encoder') # 共享权重分支 input_a = layers.Input(shape=(128, 128, 3)) input_b = layers.Input(shape=(128, 128, 3)) encoder = build_encoder() feature_a = encoder(input_a) feature_b = encoder(input_b)

这个网络里encoder(input_a)encoder(input_b)是两个分支,但它们内部是同一个Model实例,所以权重是共享的。注意最后一层 Dense 不加激活函数,原因后面讲对比损失时会说明。

2.2 没有 Softmax 的分类任务:距离函数的选择

Siamese 网络的输出层不是概率分布,而是特征向量。比较两个输入的相似度时,常见的做法是计算两个向量之间的距离。常用的距离度量有三种:欧氏距离、曼哈顿距离、余弦距离。选择哪一种,取决于你的目标场景。

距离方式公式示意特点适用场景
欧氏距离sqrt(sum((a-b)^2))对特征的绝对数值敏感,缩放由网络自动学习人脸验证、商品图片比对
曼哈顿距离sum(abs(a-b))梯度更稳定,对异常值不那么敏感签名验证、指纹比对
余弦距离1 - cos(a,b)只关注方向,忽略特征的长度文本语义检索、向量数据库检索

实际做图像相似性时,我一般优先用欧氏距离。原因是 Keras 实现对比损失的经典公式就是基于欧氏距离来写的,而且模型在训练过程中会学习将同类图的特征推向一个小球体表面,欧氏距离在这种“聚拢”语义下非常直观。余弦距离更适合特征向量的模长本身没有意义、只有方向有意义的场景,例如预训练模型(如 ResNet 最后一层之前)输出的特征。

3. 用 Keras 搭建 Siamese 网络:从数据对构造到训练循环

3.1 样本对怎么生成:正样本对与负样本对的平衡

训练 Siamese 网络需要的是配对数据,而不是单张图的标签。构造数据对的策略直接决定模型质量。最常见的方法是:取同一类的两张图构成正样本对(label=1),取不同类的两张图构成负样本对(label=0)。听起来简单,但有两个细节必须处理。

第一,负样本对的采样要随机且数量足够。如果数据集中有 100 个类别,每类 10 张图,全部可能的正样本对是 100 × C(10,2),负样本对是 C(1000,2) 减去正样本对,数量级差距很大。所以负样本对要随机采样,不用全量。我一般把正负样本对比例控制在 1:1,训练时每个 batch 内正负各一半,避免模型倾向把所有对都判为相似。

第二,类别不平衡会体现在距离分布上。如果负样本对太多,模型会学成“把所有东西推开”,特征空间里没有聚集效应。解决办法之一是使用难样本挖掘(hard negative mining)。第一轮训练用随机负样本,得到初步模型后,用模型计算所有随机负样本对的距离,取距离最小的那些(模型认为最像的真负样本)作为下一轮的训练对。这样模型会把精力放在“非常像但不是同一张”的图上。

import numpy as np from tensorflow.keras.utils import Sequence class SiameseDataGenerator(Sequence): def __init__(self, images, labels, batch_size=32): self.images = images self.labels = labels self.batch_size = batch_size self.class_to_indices = {} for idx, label in enumerate(labels): self.class_to_indices.setdefault(label, []).append(idx) self.classes = list(self.class_to_indices.keys()) def __len__(self): return len(self.classes) * 10 // self.batch_size def __getitem__(self, idx): batch_a = [] batch_b = [] batch_y = [] for _ in range(self.batch_size // 2): # 正样本对 cls = np.random.choice(self.classes) idx1, idx2 = np.random.choice(self.class_to_indices[cls], 2, replace=False) batch_a.append(self.images[idx1]) batch_b.append(self.images[idx2]) batch_y.append(1) # 负样本对 cls1, cls2 = np.random.choice(self.classes, 2, replace=False) idx1 = np.random.choice(self.class_to_indices[cls1]) idx2 = np.random.choice(self.class_to_indices[cls2]) batch_a.append(self.images[idx1]) batch_b.append(self.images[idx2]) batch_y.append(0) return [np.array(batch_a), np.array(batch_b)], np.array(batch_y)

这个生成器每个 batch 内部正负样本各占一半。注意replace=False保证不使用同一个图的自己作为正样本对,那样模型会直接学成恒等映射。负样本对采样是随机的,第一次训练时可以先跑一轮,后面再换成难样本挖掘。

3.2 对比损失与三元组损失的选型

训练 Siamese 网络最主流的损失函数是对比损失(Contrastive Loss)。它的想法是:如果两个输入是相似的,那么它们的特征向量距离越小越好;如果是不相似的,距离要大于一个边界值 margin。这个 margin 很关键,它防止所有负样本对的距离都被无穷大推开,只需要推开到 margin 之外即可。

对比损失的 Keras 实现如下:

import tensorflow as tf def contrastive_loss(margin=1.0): def loss(y_true, dist): y_true = tf.cast(y_true, tf.float32) # 同类:dist^2;异类:max(margin - dist, 0)^2 loss_value = y_true * tf.square(dist) + (1 - y_true) * tf.square(tf.maximum(margin - dist, 0)) return tf.reduce_mean(loss_value) return loss

这个实现里y_true是 1/0 标签,dist是欧氏距离。同类对时只有第一项在工作,梯度会把特征距离往下压;异类对时第二项生效,当距离已经大于 margin 时梯度为 0,模型就不再去推远那些已经足够远的样本。这样避免了“所有负样本对都被推得无限远”的训练浪费。

另一个常用选型是三元组损失(Triplet Loss),输入是锚点图、正样本图、负样本图三个。三元组损失的优点是推理阶段只需要一个锚点特征和待比较特征之间的距离,这与验证场景高度一致。但缺点是采样复杂度更高:必须选那些“比正样本离锚点更近的负样本”才有梯度,否则大部分样本对损失为 0。工程上我一般这样取舍:数据量小、正负样本构造容易用对比损失;数据量大、类别清晰,用三元组损失加难样本挖掘。

3.3 训练中的关键参数:margin、学习率与 batch size

训练损失函数时有一组参数需要具体调。margin 的值会影响特征空间的紧凑程度。margin 设得太大(比如 2.0),要求负样本对距离非常远,特征空间会被拉得很开,模型训练变慢;设得太小(比如 0.2),同类和异类之间的距离分界不明显,验证时阈值不好选。经验值一般取 0.5 到 1.5 之间,一个常见起点是 1.0。实际调试时,可以把训练集上所有正样本对和负样本对的距离分布画出来,调整 margin 让两个分布有足够的间隔。

学习率方面,Siamese 网络的特征提取层如果不冻结预训练权重,初始学习率建议调到 1e-4 级别。直接用 1e-3 容易让预训练特征被破坏。batch size 影响负样本对的有效性,batch size 太小时,每个 batch 内可能只有很少的难负样本,梯度方向不稳定。我一般最少用 32,尽量用 64 或 128。

提示:训练时建议每个 epoch 结束后,在验证集上重新计算一个全局阈值,而不是只看 loss 变化。因为 Siamese 网络的实际评估指标是给定阈值下的精确率和召回率,loss 降了不一定阈值好用。

4. 模型训练之外的工程细节:数据增强、推理与持久化

4.1 数据增强必须对两个分支同步操作

图像相似性网络需要对同一张图做增强后的正样本对来提升鲁棒性,例如两张拍摄角度不同的商品图。这时数据增强如果对input_ainput_b分别随机做,那么同一张原图会被增强成完全不同的两个版本,语义上可能产生错误的对齐。正确的做法是利用 TensorFlow 的随机种子机制,使同一个 batch 内的增强参数一致。

import tensorflow as tf def random_crop_flip(img): seed = tf.random.uniform([2], maxval=10000, dtype=tf.int32) img = tf.image.stateless_random_flip_left_right(img, seed) img = tf.image.stateless_random_brightness(img, 0.1, seed) return img def augment_pair(input_a, input_b): input_a = random_crop_flip(input_a) input_b = random_crop_flip(input_b) return input_a, input_b

stateless_random_*系列函数用同一个 seed 产生同一种随机扰动方式,这样两个分支的历史输入虽然图形内容不同,但扰动规则一致。另一个思路是“增强一次,复制两份”,即先对原图做一次随机增强,然后复制成两个输入——这种方法会构造出完全相同版本的样本对,模型学不到“同图不同视角”,因为两个输入完全一样。所以使用同 seed 的 stateless 增强更合理。

4.2 推理阶段:图像入库走一次网络,查询阶段只做向量运算

推理阶段的关键优化是:预先对图库中所有图过一次特征提取网络,把输出特征保存为向量文件。查询时,只需对查询图跑一次网络,然后在向量集合中做最近邻搜索。这一步的复杂度从“每对比一对就跑两次网络”变成“全库跑 N 次网络,之后查询只做浮点运算”。

import numpy as np def extract_features(model, image_batch): features = model.predict(image_batch, verbose=0) return features def find_top_k(query_feature, gallery_features, k=5): dists = np.linalg.norm(gallery_features - query_feature, axis=1) topk_idx = np.argsort(dists)[:k] return topk_idx, dists[topk_idx]

这里model是整个网络取其中一个分支,也就是encoder本身。注意保存模型时要加载整个 Siamese 模型,再通过model.get_layer('encoder')取出共享分支模型保存为单独的.keras文件,这样查询阶段加载的就是单分支模型,避免双输入带来的额外开销。

4.3 阈值怎么定:用验证集画 ROC 曲线

模型训练完成后,你需要一个判定“相似/不相似”的阈值。直接在验证集上计算所有正样本对和负样本对的距离分布,然后选择一个让 F1 最高的阈值。这里不建议用训练时的 margin,因为 margin 只是训练时的约束,真实判定时的阈值往往和 margin 不一致。

from sklearn.metrics import roc_curve, f1_score def find_best_threshold(dist_pos, dist_neg): y_true = np.concatenate([np.ones(len(dist_pos)), np.zeros(len(dist_neg))]) y_score = np.concatenate([dist_pos, dist_neg]) fpr, tpr, thresholds = roc_curve(y_true, y_score) best_thr = thresholds[np.argmax(tpr - fpr)] return best_thr

dist_posdist_neg分别是验证集中正、负样本对的距离集合。选择阈值时只看验证集,不看测试集,否则阈值就过拟合到测试集上了。

5. 验证技巧与调优手段:欧氏距离分布直方图与难样本挖掘

5.1 画出距离分布:最快定位模型问题的方式

训练结束后第一件事不是看准确率,而是画验证集上正负样本对的距离分布直方图。这个步骤能直接告诉你模型是“发散”还是“聚类不够紧”。理想的形状是正样本对距离集中在 0 到 0.3 区间,负样本对集中在 0.7 以上,两个分布之间有明显的“山谷”。如果正负分布大量重叠,说明模型没有把两类分开,此时调 margin 和学习率都是空谈,要回查数据对构造是否正确、特征提取网络是否太浅。

import matplotlib.pyplot as plt def plot_distances(dist_pos, dist_neg): plt.hist(dist_pos, bins=50, alpha=0.7, label='positive pairs') plt.hist(dist_neg, bins=50, alpha=0.7, label='negative pairs') plt.xlabel('Euclidean distance') plt.ylabel('count') plt.legend() plt.show()

5.2 用难负样本挖掘提升判别力

当分布已经分开但阈值附近仍然有较多错误时,常见做法是进行难负样本挖掘。取当前模型认为“最相似的负样本对”(距离低于当前阈值的负样本对),把它们加入下一轮训练中。注意难样本挖掘不是只训练在这部分数据上,否则模型会过拟合到那一小撮数据。我一般的做法是:首轮随机负样本训练完成后,从训练集中挑出距离最小的 10% 负样本对作为难样本集;随后每个 epoch 有 30% 的概率从难样本集中采样负样本对,70% 从全量负样本中采样。这样模型既保持泛化,又针对难例强化学习边界。

核心技巧是:当模型已经能分开大部分样本时,降低负样本对的随机采样率,提高难样本的优先级。这个策略不改变网络结构,也不需要重新定义损失函数,只改动数据生成器中的采样逻辑,就能让验证集上的阈值附近错误率显著下降。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询