尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

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 到 ArcFaceMargin 到底改了哪个地方普通 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(θ) - mArcFace 是 cos(θ m)后者的几何解释更贴近球面上的角度距离实际训练中也更稳定。2.2 在 PyTorch 里写一个可用的 ArcFace Loss 模块下面是一个可以放进项目的 ArcFace Loss 实现输入是 backbone 提取的 embedding 和对应身份 labelimport torch import torch.nn as nn import torch.nn.functional as F class ArcFaceLoss(nn.Module): def __init__(self, embedding_size, num_classes, s30.0, m0.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, p2, dim1) W_norm F.normalize(self.W, p2, dim1) 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_classesself.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 更清晰。第三是 s30 或 64 都可以论文里常用 64但对小数据集或衰减明显的类别30 更不容易过拟合。如果只想要一个可复现的最小模型embedding_size 一般取 512。这个数值不是拍脑袋定的512 维在存储和检索速度上都合理和主流模型保持一致后续接入 Faiss 或 Milvus 也更方便。num_classes 则是训练集中身份的去重数量要注意 ArcFace 的类中心向量 W 是参与训练的新增身份时只能重新训练不能像 CosFace 那样局部增量。3. 人脸识别实战数据准备、训练脚本与关键参数设置3.1 人脸对齐先做五点关键点对齐再送进网络而不是直接 resizeArcFace 系列模型的标准输入是 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] ], dtypenp.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, methodcv2.LMEDS) aligned cv2.warpAffine(img, M, output_size, borderValue0.0) return alignedestimateAffinePartial2D 只估计旋转、缩放和平移不会引入剪切变形这对保留人脸原始形态很重要。如果不放心检测结果可以用 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, transformNone): 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否则会把对齐破坏掉。常用增强是随机水平翻转、颜色抖动、随机灰度化和轻微的仿射变化。还可以对输入做 normalizeImageNet 的 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_blockingTrue) labels labels.cuda(non_blockingTrue) embeddings model(images) loss criterion(embeddings, labels) optimizer.zero_grad() loss.backward() optimizer.step() if global_step % log_freq 0: print(fstep {global_step} | loss {loss.item():.4f})这里的 criterion 就是前面实现的 ArcFaceLossmodel 输出的是 512 维特征而不是类别概率。如果模型里还有 BN 层且 batch size 不够大建议冻结 BN 参数只训练卷积层和最后的 FC 层。冻结方式是把所有 BN 的 momentum 设成 0或者在 forward 前用 eval 的 BN 行为但这样会失去 BN 的归一化能力不如用大 batch_size SyncBN。一个比较省心的初始参数组合如下表参数推荐值说明embedding size512主流特征维度s30小数据集用 30百万级数据用 64m0.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()), lr0.01, momentum0.9, weight_decay5e-4) scheduler CosineAnnealingLR(optimizer, T_max10, eta_min0.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_size512): 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_locationcuda) # 如果训练时用了 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, strictFalse) 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, decay0.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(), alpha1 - self.decay) def apply_shadow(self): self.model.load_state_dict(self.shadow, strictTrue)EMA 的 decay 取 0.999 代表约 1000 步平均一次batch size 大时可以把 decay 调到 0.9999。推理或导出 ONNX 前应用 shadow 权重即可。第二个是 BN 统计量校准。如果训练时开了 SyncBNeval 模式下的 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_version17, dynamic_axes{input: {0: batch}, embedding: {0: batch}} )验证时随机挑几张测试图片分别用 PyTorch 和 onnxruntime 跑比较输出向量最大绝对误差通常要求低于 1e-4。如果超过这个量级常见原因是模型里用到了 torch.amax 或某些只支持固定 shape 的 op这时把 opset 升到 17 以上或者打开dynamic_axes后重新导出。这一步做完模型才能真正交到 C 或云服务手里。本文还有配套的精品资源点击获取
返回列表