输入两张图片 A、B,输出相似度,以及“是否判为同一个人”。计算顺序是:

A → 检测人脸 → 对齐 → 同一个特征网络 → 向量 a
B → 检测人脸 → 对齐 → 同一个特征网络 → 向量 b
a、b → 余弦相似度 → 与阈值比较

两张原图不必尺寸相同,进入身份网络的脸图必须遵守同一套输入规则。无脸或有多张脸时,本例直接报错,不擅自选一张脸。

1. 找到脸

检测器预测人脸框,有的还预测双眼、鼻尖和嘴角。神经网络检测器一般先产生候选框和分数,再用非极大值抑制(NMS)去掉重叠候选。

框可以写成 (x, y, width, height)。YuNet、RetinaFace 是常见选择;通用目标检测模型需要专门的人脸权重,才能承担这个任务。人脸框的位置用于裁剪,关键点用于对齐,检测分数只表示候选像不像脸。

2. 对齐五官

同一个人歪头 15°,五官的图像坐标会一起旋转。直接把矩形框缩放到固定大小,眼睛的位置仍可能不同。

常见做法是把检测到的关键点 p_i 对应到模板点 q_i,求缩放 s、二维旋转 R 和平移 t:

min⁡s,R,t∑i∥sRpi+t−qi∥2\min_{s,R,t}\sum_i\|sRp_i+t-q_i\|^2

再用这个相似变换重采样图片。眼睛和嘴的位置统一后,网络比较容易把注意力放在身份差异上。

二维对齐主要修正位置、尺度和画面内旋转,不能补出大侧脸被遮住的半张脸。模板、输入尺寸、颜色通道和归一化要与权重一致。

3. 提取身份特征

设对齐后的脸图为 x,特征网络为 fθ:

z=fθ(x),e=z∥z∥2z=f_\theta(x),\qquad e=\frac{z}{\|z\|_2}

z 是特征向量(embedding),e 是单位长度的向量。维数由模型决定,常见实现有 128、512 维;换模型后,即使维数相同也不能交叉比较。

卷积层用局部滤波器计算边缘、明暗和纹理响应,后续层组合更大范围的模式。训练调整这些滤波器,使最终向量对身份有区分力。某一维通常不对应“鼻子宽度”或“眼睛大小”,不能逐维当作人工定义的五官测量值。

同一个网络分别处理 A、B,不需要每来两张照片就重新训练。训练时也不必包含这次要比较的人:网络学到的表征可以用于未见过的身份,但效果仍取决于训练数据和图片条件。

4. ArcFace 怎样训练

ArcFace 是训练时使用的角度间隔损失。它约束身份特征的分布;推理时仍然提取向量并计算距离。

普通身份分类训练希望图片被分到正确的人。ArcFace 将特征与分类权重归一化,用夹角表示接近程度;对正确身份额外加入角度间隔 m,让分类要求更严格。

单个样本的损失可写成:

L=−log⁡exp⁡(scos⁡(θy+m))exp⁡(scos⁡(θy+m))+∑j≠yexp⁡(scos⁡θj)L=-\log\frac{\exp(s\cos(\theta_y+m))} {\exp(s\cos(\theta_y+m))+\sum_{j\ne y}\exp(s\cos\theta_j)}

y 是正确身份,θ_y 是特征与该身份分类权重的夹角,s 是尺度。训练反复更新网络参数,使同一身份的向量集中、不同身份更容易分开。推理时通常去掉训练用的身份分类层,使用 embedding。ArcFace 论文给出了这个损失及其几何解释。

另一条常见路线是 FaceNet 的三元组损失:让同人的距离小于异人的距离,并留出间隔。LBPH 则统计局部纹理直方图,原理简单,但对姿态、光照变化的适应性有限。

5. 比较两个向量

余弦相似度计算方向的接近程度:

c=a⋅b∥a∥2∥b∥2c=\frac{a\cdot b}{\|a\|_2\|b\|_2}

取值范围为 [-1, 1],越大越相似。余弦距离常定义为 d = 1 - c,范围为 [0, 2],越小越相似。代码里必须看清用的是哪一个。

用二维向量算一次:a=(1,0),b=(0.8,0.6),两个向量长度都为 1。因此 c=0.8、d=0.2。若为演示暂取相似度阈值 0.75,这一对会被判为同人。这个阈值仅用于算例。

import numpy as np
def cosine_similarity(a, b):
a, b = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
if a.ndim != 1 or a.shape != b.shape or a.size == 0:
raise ValueError("需要两个等长的一维特征")
if not np.isfinite(a).all() or not np.isfinite(b).all():
raise ValueError("特征含无效数值")
# 先缩放,避免很大的有限数值在求平方和时溢出。
sa, sb = np.max(np.abs(a)), np.max(np.abs(b))
if sa == 0 or sb == 0:
raise ValueError("特征不能是零向量")
a, b = a / sa, b / sb
a, b = a / np.linalg.norm(a), b / np.linalg.norm(b)
return float(np.clip(a @ b, -1, 1))
score = cosine_similarity([1, 0], [0.8, 0.6])
print(score) # 0.8

对于单位向量,欧氏距离与余弦相似度的关系是:||a-b||² = 2-2c。这时两种度量的排序一致,阈值需要换算。

6. 选择阈值

准备同人的不同照片对,以及异人照片对。固定检测、对齐和特征网络,计算每一对的分数。对一个候选相似度阈值 τ:

误接受率 FAR = 异人分数 ≥ τ 的对数 / 异人总对数
误拒绝率 FRR = 同人分数 < τ 的对数 / 同人总对数

提高阈值通常减少误接受、增加误拒绝。阈值在独立验证集上选择,最终效果再用未参与选择的测试集衡量。拆分时避免同一张图或近重复图同时出现在两边。

c=0.8 不表示“80% 是同一个人”。它是向量几何分数,尚未经过概率校准。OpenCV 官方 SFace 教程在不同数据集上也列出了不同阈值,说明阈值依赖模型与评测条件。来源

完整的两图片实现见算法示例,其中保留了检测、对齐、特征和余弦计算的各个步骤。