news 2026/9/12 22:27:22

ArcFace + PyTorch 人脸识别实战:从损失函数到阈值调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ArcFace + PyTorch 人脸识别实战:从损失函数到阈值调优

简介:面向人脸识别入门与进阶开发者的一份ArcFace实战项目包,基于PyTorch实现。ArcFace作为主流的人脸识别算法,通过角度间隔度量学习将人脸映射到高维特征空间,本包则围绕该算法搭建了完整可运行的全流程工程。压缩包内含20个文件,以11个Python脚本为核心,覆盖数据加载、骨干网络与损失函数、训练与测试等核心模块,目录按数据、模型、配置与训练测试划分,并附模型结构可视化文档;另有配套环境配置说明与依赖列表,包体仅36KB,轻量便于快速部署调试。当前已有1550人学习下载,适合需要从零跑通ArcFace人脸识别流程的开发者。通过该工程可直接在自有数据集上执行训练与验证,配置好环境后按说明操作即可一键运行,并可根据配置文件调整参数,是实现从算法原理到工程应用的便捷参考。

1. 为什么人脸识别项目选择 ArcFace + PyTorch,而不是直接微调 Softmax

人脸识别有个反直觉的现象:同一个开源模型,在 LFW 上都刷到 99% 了,换到公司自己的摄像头、自己的光照和姿态下,准确率可能直接掉到 90% 以下。问题往往不是网络太浅,而是训练时缺少了一步——用 margin 把同一身份的特征从“分开”变成“压向同一类中心”。ArcFace 解决的就是这一层:在 Softmax 损失中显式加入角度间隔,让模型学到的特征在余弦空间里更有判别力。PyTorch 落地人脸识别,最稳的路径不是从零搭网络,而是拿一个预训练 backbone,把最后的全连接分类头换成 ArcFace Loss,再用自己的数据微调。这篇文章会沿着这条路,把损失函数实现、人脸对齐、训练参数、特征比对,以及最终业务阈值怎么定讲清楚。

2. ArcFace 的原理细节与 PyTorch 下的本质实现

2.1 从 Softmax 到 ArcFace:Margin 到底改了哪个地方

普通 Softmax 分类时,最后一个全连接层每个类有一组权重向量,特征向量与权重向量的点积变成 logits,再由交叉熵约束。这里隐含的目标是:特征与该类别的权重方向保持一致,但没有要求“同类样本彼此靠近”。ArcFace 的改动很直接:在计算目标类的 logits 时,先算出特征向量与该类权重向量的夹角 θ,然后把这个夹角加上一个固定角度 m,再送进交叉熵。

损失表达式如下:

L = -log( exp(s * cos(θ_y + m)) / ( exp(s * cos(θ_y + m)) + Σ_{j≠y} exp(s * cos θ_j) ) )

其中 s 是特征缩放因子,控制 logits 的尺度;θ_y 是样本与真实类别权重向量的夹角;m 是角度间隔。训练时网络必须把真实类别的夹角压到 θ_y + m 仍然小于其他类别的角度,才能有效降低损失。这就逼迫不同类别的样本在特征空间中互相推开,同类则聚向类中心。

在 PyTorch 里落地时重点关注三点:特征向量和类权重都需要做 L2 归一化;s 要当作固定超参而不是可学习参数;margin 加在角度上,而不是直接加在余弦值上。与 CosFace 不同,CosFace 是 cos(θ) - m,ArcFace 是 cos(θ + m),后者的几何解释更贴近球面上的角度距离,实际训练中也更稳定。

2.2 在 PyTorch 里写一个可用的 ArcFace Loss 模块

下面是一个可以放进项目的 ArcFace Loss 实现,输入是 backbone 提取的 embedding 和对应身份 label:

import torch import torch.nn as nn import torch.nn.functional as F class ArcFaceLoss(nn.Module): def __init__(self, embedding_size, num_classes, s=30.0, m=0.50): super().__init__() self.s = s self.m = m self.W = nn.Parameter(torch.FloatTensor(num_classes, embedding_size)) nn.init.xavier_normal_(self.W) def forward(self, embeddings, labels): # 特征和类权重都要归一化,保证余弦相似度范围在 [-1, 1] embeddings = F.normalize(embeddings, p=2, dim=1) W_norm = F.normalize(self.W, p=2, dim=1) cos_theta = torch.matmul(embeddings, W_norm.t().contiguous()) cos_theta = torch.clamp(cos_theta, -1.0 + 1e-7, 1.0 - 1e-7) # 利用 trig 展开计算 cos(theta + m),避免使用 acos 导致梯度不稳定 sin_theta = torch.sqrt(1.0 - torch.square(cos_theta)) cos_theta_m = cos_theta * torch.cos(self.m) - sin_theta * torch.sin(self.m) one_hot = F.one_hot(labels, num_classes=self.W.shape[0]).float() output = torch.where(one_hot.bool(), cos_theta_m, cos_theta) output *= self.s loss = F.cross_entropy(output, labels) return loss

这段代码中有几个值得留意的点。首先是 clamp,因为浮点误差会让 cos_theta 越界,如果之后用 sqrt 算 sin_theta,越界就会出现 NaN。其次是 one-hot 后使用 torch.where,只替换真实类别的 logits,其他类别保持原余弦值,这比直接改 label 的 logits 更清晰。第三是 s=30 或 64 都可以,论文里常用 64,但对小数据集或衰减明显的类别,30 更不容易过拟合。

如果只想要一个可复现的最小模型,embedding_size 一般取 512。这个数值不是拍脑袋定的:512 维在存储和检索速度上都合理,和主流模型保持一致,后续接入 Faiss 或 Milvus 也更方便。num_classes 则是训练集中身份的去重数量,要注意 ArcFace 的类中心向量 W 是参与训练的,新增身份时只能重新训练,不能像 CosFace 那样局部增量。

3. 人脸识别实战:数据准备、训练脚本与关键参数设置

3.1 人脸对齐:先做五点关键点对齐再送进网络,而不是直接 resize

ArcFace 系列模型的标准输入是 112×112 的 RGB 图。很多人直接把检测框裁剪后 resize,结果验证集上指标不错,一到真实场景就崩。原因在于训练时数据经过了人脸关键点对齐,推理时如果跳过这步,相当于换了一个数据分布。

常见流程是先用 SCRFD 或 RetinaFace 检测人脸并输出五个关键点,也就是左眼、右眼、鼻尖、左嘴角、右嘴角,然后通过相似变换把这五个点映射到参考位置。InsightFace 标准参考坐标大致如下:

import numpy as np REFERENCE_FACE = np.array([ [0.31556875, 0.461574107], [0.68262291, 0.461574107], [0.500262487, 0.640505353], [0.34947199, 0.824691963], [0.65329469, 0.824691963] ], dtype=np.float32)

拿到检测关键点后,用 OpenCV 的 estimateAffinePartial2D 可以算出从原图到参考坐标的仿射矩阵,再 warpAffine 即可:

import cv2 def align_face(img, landmarks, output_size=(112, 112)): dst_pts = REFERENCE_FACE.copy() M, _ = cv2.estimateAffinePartial2D(landmarks, dst_pts, method=cv2.LMEDS) aligned = cv2.warpAffine(img, M, output_size, borderValue=0.0) return aligned

estimateAffinePartial2D 只估计旋转、缩放和平移,不会引入剪切变形,这对保留人脸原始形态很重要。如果不放心检测结果,可以用 LMEDS 或 RANSAC 剔除外点。这个步骤在训练和推理里必须完全一致,不要在训练时对齐,推理时却只做中心裁剪。

3.2 自定义 DataSet:把身份 ID 映射成从 0 开始的标签

人脸数据集的组织方式通常是一个目录一个身份,或者一张 CSV 里存图片路径和身份名。PyTorch 里最直接的做法就是写一个 Dataset,返回对齐后的图像张量和整数标签:

from torch.utils.data import Dataset from PIL import Image import numpy as np class FaceDataset(Dataset): def __init__(self, image_paths, identity_labels, transform=None): self.image_paths = image_paths self.identity_labels = identity_labels self.transform = transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img = Image.open(self.image_paths[idx]).convert('RGB') if self.transform: img = self.transform(img) label = self.identity_labels[idx] return img, label

创建 Dataset 前,需要先把身份字符串映射成连续整数。一个常见做法是:

from pathlib import Path root = Path("face_dataset/train") paths, labels = [], [] id2label = {} for label_idx, person_dir in enumerate(sorted(root.iterdir())): if not person_dir.is_dir(): continue id2label[person_dir.name] = label_idx for img_path in sorted(person_dir.iterdir()): if img_path.suffix.lower() in ('.jpg', '.jpeg', '.png'): paths.append(str(img_path)) labels.append(label_idx)

训练时的数据增强要克制。人脸对齐后,图像的语义空间已经固定,强烈建议不要使用 RandomResizedCrop,否则会把对齐破坏掉。常用增强是随机水平翻转、颜色抖动、随机灰度化和轻微的仿射变化。还可以对输入做 normalize,ImageNet 的 mean/std 并不是绝对标准,很多人脸项目会直接使用 [-1, 1] 归一化,效果差异不大。

3.3 训练脚本核心:冻结 BN、缩放 s 和 learning rate schedule

训练 ArcFace 时,网络的 backbone 通常从 ImageNet 预训练模型初始化,最后接一个 512 维的全连接层作为 embedding 输出。需要注意这层和 ArcFaceLoss 里的 W 不是同一个东西:embedding 是每张图片的特征,W 是用于计算损失的类中心。

损失加在 embedding 上,分类头并不存在于模型 forward 输出里,所以在训练循环里要特别小心。

model.train() for images, labels in train_loader: images = images.cuda(non_blocking=True) labels = labels.cuda(non_blocking=True) embeddings = model(images) loss = criterion(embeddings, labels) optimizer.zero_grad() loss.backward() optimizer.step() if global_step % log_freq == 0: print(f"step {global_step} | loss {loss.item():.4f}")

这里的 criterion 就是前面实现的 ArcFaceLoss,model 输出的是 512 维特征,而不是类别概率。如果模型里还有 BN 层,且 batch size 不够大,建议冻结 BN 参数,只训练卷积层和最后的 FC 层。冻结方式是把所有 BN 的 momentum 设成 0,或者在 forward 前用 eval 的 BN 行为,但这样会失去 BN 的归一化能力,不如用大 batch_size + SyncBN。

一个比较省心的初始参数组合如下表:

参数推荐值说明
embedding size512主流特征维度
s30小数据集用 30,百万级数据用 64
m0.50.5 是常用值,过大不收敛,过小区分度差
batch size256尽量大,保证 BN 统计量稳定
lr0.01 ↓ 0.0001SGD momentum 0.9,按 epoch 衰减
warmup5 epoch避免大 lr 训坏随机初始化层

配合学习率调度,可以在每个 epoch 更新:

from torch.optim.lr_scheduler import CosineAnnealingLR optimizer = torch.optim.SGD(filter(lambda p: p.requires_grad, model.parameters()), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=10, eta_min=0.0001) for epoch in range(total_epochs): train_one_epoch(model, optimizer, criterion, train_loader) scheduler.step()

如果是多节点训练,建议在模型定义时使用 DistributedDataParallel,并打开 SyncBN。否则每个卡上的 BN 统计量只看到本卡数据,在大规模人脸数据上误差会被 margin 放大。

4. 特征提取、相似度计算与阈值选择的落地细节

4.1 从模型得到 512 维特征的正确姿势:去掉分类头,只留 embedding

推理阶段,我们不再需要 ArcFaceLoss 里的 W,只需要 backbone 输出的 embedding。所以保存 checkpoint 时建议只保存 backbone 的 state_dict,避免把类中心向量也存进来。加载模型时不能直接用训练时完整的 model,而是要构造一个只包含主干和 embedding 层的网络。

import torch import torch.nn as nn import torch.nn.functional as F def build_embedding_model(backbone, embedding_size=512): """ backbone 需要输出一个 feature map,最后接一个 Linear 到 embedding_size。 这里以 ResNet50 为例,替换最后的全连接层。 """ backbone.fc = nn.Linear(backbone.fc.in_features, embedding_size) return backbone model = build_embedding_model(backbone) state = torch.load("arcface_checkpoint.pth", map_location="cuda") # 如果训练时用了 nn.DataParallel 或 DDP,要去掉 module. 前缀 if list(state.keys())[0].startswith("module."): state = {k.replace("module.", ""): v for k, v in state.items()} model.load_state_dict(state, strict=False) model.eval().cuda()

推理时注意两步:输入图像一定要和训练时一致的预处理链路,包括人脸对齐、resize 到 112×112、归一化;特征输出后要再做一次 L2 normalize,因为训练时损失函数里已经归一化了,如果推理时不归一化,余弦相似度的结果会和训练时的度量空间不一致。

4.2 用余弦相似度做比对,并设置阈值

特征归一化后,两个特征向量的相似度可以直接用点积代替余弦距离:

import numpy as np def feature_similarity(feature1, feature2): # 这里假定两个特征都已经做过 L2 归一化 return float(np.dot(feature1, feature2))

如果你的特征没有归一化就存库,比较时还是要除以模长。建议写入底库时就统一归一化并存入 float32 数组,之后无论是做实时比对还是批量检索,速度都会快很多。百万级底库不建议自己写 for 循环,直接用 Faiss 的 IndexFlatIP 就好。

阈值怎么定,这是人脸识别项目里比模型训练更影响体感的环节。在业务验证集上,通常计算不同阈值下的 TAR 和 FAR:

阈值TAR(通过率)FAR(误识率)
0.4598.4%1.31%
0.5097.6%0.52%
0.5595.8%0.17%
0.6092.3%0.04%

选择阈值不是越高越好。门禁和支付场景要求 FAR 极低,阈值可以压在 0.55 以上;日志考勤或人脸解锁这种能容忍一次重试的场景,阈值可以放到 0.45 附近。关键是验证集要尽量模拟真实场景:不只采样高质量自拍图,还要混入低亮度、运动模糊、侧脸的样本,否则阈值会在上线后被真实数据教育。

一个值得注意的细节是,ArcFace 特征是归一化到球面上的点,欧氏距离和余弦相似度本质是单调等价的。有些项目为了兼容早期代码,会计算欧氏距离再转相似度,这没问题,但阈值直接套开源模型的 0.5 会很危险,因为特征分布和开源模型不一样。每次自己训练后,都应该重新统计验证集上的相似度分布,再决定阈值。

5. 人脸识别项目收尾:EMA 权重、BN 校准和 ONNX 一致性验证

训练收敛后,还有三个容易被忽略但能显著提升稳定性的操作。第一个是使用 EMA 权重。训练过程最后几十轮模型可能在大 margin 约束下反复震荡,直接拿最终权重不如取权重滑动平均。维护一个 EMA 副本的简单实现如下:

class EMA: def __init__(self, model, decay=0.999): self.model = model self.decay = decay self.shadow = {} for k, v in model.state_dict().items(): self.shadow[k] = v.clone().detach() def update(self, model): with torch.no_grad(): for k, v in model.state_dict().items(): self.shadow[k].mul_(self.decay).add_(v.detach(), alpha=1 - self.decay) def apply_shadow(self): self.model.load_state_dict(self.shadow, strict=True)

EMA 的 decay 取 0.999 代表约 1000 步平均一次,batch size 大时可以把 decay 调到 0.9999。推理或导出 ONNX 前应用 shadow 权重即可。

第二个是 BN 统计量校准。如果训练时开了 SyncBN,eval 模式下的 running_mean 和 running_var 已经包含了分布式信息,但不同数据分布的照片仍可能导致统计量偏移。一个实用的技巧是选取几百张有代表性的图片,把模型切到 train 模式但截止 BN 参数更新,只跑一次 forward,让 BN 重新收集统计量,然后再切回 eval。这个操作在最终导出模型前做,能明显改善跨域性能。

第三个是 ONNX 导出后的一致性验证。ArcFace 模型结构简单,导出没有太多障碍,关键是要保证 PyTorch 和 ONNX Runtime 输出绝对接近。导出时用整张输入图和归一化层一起导出,别把预处理留在代码里:

import torch.onnx model.eval() dummy_input = torch.randn(1, 3, 112, 112).cuda() torch.onnx.export( model, dummy_input, "arcface.onnx", input_names=["input"], output_names=["embedding"], opset_version=17, dynamic_axes={"input": {0: "batch"}, "embedding": {0: "batch"}} )

验证时随机挑几张测试图片,分别用 PyTorch 和 onnxruntime 跑,比较输出向量最大绝对误差,通常要求低于 1e-4。如果超过这个量级,常见原因是模型里用到了 torch.amax 或某些只支持固定 shape 的 op,这时把 opset 升到 17 以上,或者打开dynamic_axes后重新导出。这一步做完,模型才能真正交到 C++ 或云服务手里。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 22:26:51

第十三届蓝桥杯Web开发试题与源码包使用指南

简介:第十三届蓝桥杯Web开发赛题源码包是面向大学生竞赛参与者,以及计算机、数学、电子信息等专业学习者的完整备赛参考,可直接用于课程设计、期末大作业和毕业设计项目借鉴。压缩包内共三百零五个文件,大小约二十六兆&#xff0c…

作者头像 李华
网站建设 2026/9/12 22:26:19

Suricata源码解析:从TCP流重组到规则匹配的入侵检测系统Demo

简介:这是一套基于Suricata的网络入侵检测系统毕业设计demo,面向计算机、网络安全、电子信息等专业学生,特别适合课程设计、期末大作业或毕设参考。资源包含完整可运行源码与项目说明文档,覆盖Suricata核心检测模块、流处理、应用…

作者头像 李华
网站建设 2026/9/12 22:25:57

YOLOv9 PCB缺陷检测实战:1297张图数据集训练与优化全解析

简介:该数据集面向PCB电路板质检与计算机视觉缺陷检测场景,采用YOLOv9格式标注,包含1297张真实PCB板图片,整体识别准确率可达99.8%。压缩包内共2000个文件,其中702张JPG原图、1297个TXT标注文件以及1个YAML配置文件&am…

作者头像 李华
网站建设 2026/9/12 22:24:53

YOLOv5 FPS自动瞄准系统实战:目标检测与鼠标控制全解析

简介:基于YOLOV5的FPS类游戏自动瞄准系统,是一套面向游戏AI与计算机视觉学习者的完整工程源码,适合小白或进阶学习者用作毕设、课程设计或工程实训。资源共110个文件,以29个Python脚本、28个YAML配置文件、3个预训练PT模型及各类图…

作者头像 李华
网站建设 2026/9/12 22:24:44

SpringBoot+Vue3全栈开发厨艺交流平台实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华