☰
CNN人脸识别实战:从数据对齐到ArcFace部署
2026/9/29 5:09:55 网站建设 项目流程

简介:这份资源是《DeepLearning tutorial(5)CNN卷积神经网络应用于人脸识别》一文的配套代码包,面向具备一定深度学习基础、希望动手实践卷积神经网络图像分类的开发者与学习者,帮助其理解并复现从数据加载、模型搭建到训练与预测的完整人脸识别流程。压缩包共4个文件,包含2个Python脚本分别负责训练与推理、1个pkl模型参数文件以及1个gif数据集图像,整体约14.64MB,结构精简,便于直接运行与二次修改。目前已有17745人学习下载,热度较高。借助这份代码,读者可以对照博文逐步调试CNN网络结构、观察训练过程中的参数保存与加载方式,并基于olivettifaces数据集完成人脸分类实验,从而掌握卷积层、池化层与全连接层的实际编码细节,为后续迁移到更复杂的人脸识别任务打下基础。

1. 从一张 112×112 的灰度图说起:CNN 人脸识别到底在识别什么

很多人第一次跑 CNN 人脸识别,都会卡在同一个地方:模型训练准确率 99%,换一张自己手机拍的照片就翻车。问题不在 CNN,而在你根本没搞清楚它识别的是「谁的脸」还是「哪张脸」。卷积神经网络做人脸识别,本质是把一张对齐后的人脸图映射成一个高维特征向量,再通过度量学习让同一个人不同角度的向量靠拢、不同人的向量拉开。它解决的不是「这是不是人脸」,而是「这是谁」。适合谁?适合已经会写 Python、装过 PyTorch、想从 MNIST 手写数字跳到真实人脸任务的工程师。下面这套流程我用过很多次,从数据对齐到模型导出,每一步都有可抄的代码,也有我踩过的坑。

2. 先把数据管线搭对:从原始人脸图到 112×112 对齐张量

2.1 为什么人脸识别不能直接 resize 就喂给 CNN

人脸识别和普通图像分类最大的区别在于:普通分类对位置不敏感,猫在左上角还是右下角都是猫;但人脸识别对五官相对位置极度敏感。你直接把原图 resize 成 112×112,眼睛间距、鼻尖位置全变了,CNN 学到的特征里混进了大量姿态和尺度噪声。常见做法是先做人脸检测,再用五点关键点(左右眼、鼻尖、左右嘴角)做相似变换对齐。对齐后的脸,眼睛大致在同一水平线,人脸占据画面固定比例,这样 CNN 才能专注学身份特征而不是学「这张图歪了多少」。

我一般用 MTCNN 或 RetinaFace 做检测加关键点,再用仿射变换把关键点映射到标准模板。标准模板用 ArcFace 那套:左眼 (38.2946, 51.6963),右眼 (73.5318, 51.5014),鼻尖 (56.0252, 71.7366),左嘴角 (41.5493, 92.3655),右嘴角 (70.7299, 92.2041),输出 112×112。这套坐标是行业里验证过无数次的,直接抄就行。

import cv2 import numpy as np # ArcFace 标准五点模板,112x112 输出 REFERENCE_POINTS = np.array([ [38.2946, 51.6963], # 左眼 [73.5318, 51.5014], # 右眼 [56.0252, 71.7366], # 鼻尖 [41.5493, 92.3655], # 左嘴角 [70.7299, 92.2041], # 右嘴角 ], dtype=np.float32) def align_face(img, landmarks): """landmarks: 检测器输出的 5x2 关键点,顺序同上""" # 用相似变换估计仿射矩阵,只允许旋转、缩放、平移 M, _ = cv2.estimateAffinePartial2D(landmarks, REFERENCE_POINTS) aligned = cv2.warpAffine(img, M, (112, 112), borderValue=0.0) return aligned

这段代码的关键在estimateAffinePartial2D,它只估计 4 个自由度(旋转、缩放、平移),不允许剪切和拉伸。如果你用estimateAffine2D估计 6 个自由度,遇到侧脸时会把脸「压扁」去凑关键点,反而引入形变。参数上borderValue=0.0表示填充黑色,因为后续归一化会减均值,填 0 比填 128 更干净。

2.2 训练集、验证集、测试集怎么切才不泄漏身份

人脸识别数据集切分有个铁律:同一个人不能同时出现在训练集和验证集。否则模型只是记住了这张脸,验证准确率虚高。我见过有人用 LFW 随机切 8:2,结果验证集里一半身份在训练集出现过,准确率直接飙到 99.5%,上线就废。正确做法是按身份切:随机选 80% 的身份做训练,10% 做验证,10% 做测试,每个身份的所有图片整体归入对应集合。

import os import random from collections import defaultdict def split_by_identity(root_dir, train_ratio=0.8, val_ratio=0.1): identities = os.listdir(root_dir) random.seed(42) random.shuffle(identities) n = len(identities) train_ids = identities[:int(n * train_ratio)] val_ids = identities[int(n * train_ratio):int(n * (train_ratio + val_ratio))] test_ids = identities[int(n * (train_ratio + val_ratio)):] return train_ids, val_ids, test_ids

random.seed(42)是为了可复现,别小看这个,调参时如果每次切分不一样,你根本分不清是模型变了还是数据变了。身份数少于 100 的小数据集,验证集身份太少会导致指标抖动大,这时候可以把 val_ratio 提到 0.2,或者用交叉验证。

2.3 数据增强:哪些能开,哪些开了就是自毁

人脸识别的增强有明确边界。水平翻转可以开,因为人脸近似左右对称;但垂直翻转绝对不能开,倒着的脸不是人脸。随机裁剪要小心,裁太狠会把眼睛裁掉,关键点就废了。颜色抖动可以开,模拟不同光照;但色相偏移别太大,否则肤色变得不像人。我一般用:水平翻转 p=0.5,随机亮度对比度 ±0.2,高斯模糊 p=0.1,不做旋转(因为对齐已经固定了姿态)。

import torchvision.transforms as T train_transform = T.Compose([ T.RandomHorizontalFlip(p=0.5), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1), T.RandomApply([T.GaussianBlur(kernel_size=3)], p=0.1), T.ToTensor(), T.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]), ])

注意Normalize的 mean/std 都是 0.5,这是把人脸图从 [0,1] 映射到 [-1,1]。如果你用 ImageNet 的 mean/std,输入分布和预训练模型不匹配,微调时 loss 会先震荡一阵。自己从头训就用 0.5,用预训练权重就跟着预训练走。

3. 选骨干与损失函数:为什么 ResNet 加 ArcFace 是当前最稳的组合

3.1 骨干网络:从 LeNet 到 ResNet 的选型逻辑

CNN 基础结构无非卷积、池化、全连接堆叠,但人脸识别对骨干的要求是:足够深以提取判别性特征,又不能太深导致小数据集过拟合。我试过 LeNet 和 VGG,LeNet 太浅,特征区分度不够;VGG 参数量大,小数据集上验证 loss 下不去。ResNet 的残差连接解决了深层退化问题,18 层或 34 层在几万张人脸的数据集上刚好。如果你数据量到百万级,可以上 ResNet100 或 IR-SE 结构,但大多数从业者手里的数据也就几万张,ResNet18 起步最稳。

import torch.nn as nn from torchvision.models import resnet18 class FaceBackbone(nn.Module): def __init__(self, embedding_size=512): super().__init__() self.backbone = resnet18(weights=None) # 替换最后的全连接层,输出 embedding 而不是分类 logits self.backbone.fc = nn.Linear(512, embedding_size) self.bn = nn.BatchNorm1d(embedding_size) def forward(self, x): x = self.backbone(x) x = self.bn(x) # L2 归一化,让 embedding 落在单位超球面上 x = nn.functional.normalize(x, p=2, dim=1) return x

weights=None表示从头训。如果你有 ImageNet 预训练权重,可以设weights='IMAGENET1K_V1',但要注意人脸和 ImageNet 分布差异大,预训练只加速收敛,不保证最终精度更高。BatchNorm1d加在 embedding 后、归一化前,是为了稳定训练初期 embedding 的分布。L2 归一化是 ArcFace 的前提,不归一化的话角度间隔就失去意义了。

3.2 ArcFace 损失:把分类问题变成角度间隔问题

普通 Softmax 只要求同类特征比异类更靠近分类边界,但边界附近的样本仍然容易混。ArcFace 的做法是在角度空间里给同类样本加一个间隔 m,让类内更紧、类间更开。公式是 cos(θ + m),m 一般取 0.5(弧度,约 28.6 度)。这个间隔不能太大,太大会导致训练不收敛;也不能太小,太小退化成 Softmax。我实测 0.5 在几万身份的数据集上最平衡,身份数少可以降到 0.3。

import math import torch import torch.nn as nn class ArcFaceLoss(nn.Module): def __init__(self, embedding_size, num_classes, margin=0.5, scale=64): super().__init__() self.weight = nn.Parameter(torch.randn(num_classes, embedding_size)) nn.init.xavier_uniform_(self.weight) self.margin = margin self.scale = scale self.cos_m = math.cos(margin) self.sin_m = math.sin(margin) self.th = math.cos(math.pi - margin) self.mm = math.sin(math.pi - margin) * margin def forward(self, embedding, label): # embedding 已 L2 归一化,weight 也归一化 cosine = nn.functional.linear( embedding, nn.functional.normalize(self.weight, dim=1)) sine = torch.sqrt(1.0 - cosine.pow(2).clamp(0, 1)) phi = cosine * self.cos_m - sine * self.sin_m # 当角度超过 pi - margin 时,用线性近似防止梯度爆炸 phi = torch.where(cosine > self.th, phi, cosine - self.mm) one_hot = torch.zeros_like(cosine) one_hot.scatter_(1, label.view(-1, 1), 1) output = (one_hot * phi) + ((1.0 - one_hot) * cosine) output *= self.scale return nn.functional.cross_entropy(output, label)

scale=64是温度系数,放大 logits 让梯度更明显。th和mm那两行是 ArcFace 原论文里的数值稳定技巧,当 cos(θ) 小于 cos(π - m) 时,直接用 cos(θ) - m·sin(π - m) 近似,避免 sqrt 里出现负数导致 NaN。这个细节很多复现文章都漏了,结果训练到一半 loss 变 NaN,还以为是学习率问题。

3.3 训练循环:学习率、优化器、epoch 怎么设

优化器用 SGD 加动量 0.9,学习率从 0.1 开始,在第 10、16、20 个 epoch 各降 10 倍,总共训 24 个 epoch。这是 ArcFace 原论文的配置,我试过 Adam,收敛快但最终精度低 0.5 个点左右。batch size 尽量大,128 起步,256 更好,因为 ArcFace 的间隔在 batch 内样本多时才能充分推开。如果显存不够,用梯度累积模拟大 batch。

optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones=[10, 16, 20], gamma=0.1) for epoch in range(24): model.train() for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() emb = model(imgs) loss = criterion(emb, labels) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()

weight_decay=5e-4是正则化,防止 embedding 过拟合。如果你发现训练 loss 降得很快但验证集指标不涨,先看 weight_decay 是不是太小,再检查数据增强是不是太弱。验证时不要用 ArcFace 的间隔,直接用余弦相似度算准确率,因为间隔只在训练时起作用。

4. 推理与部署:从 embedding 到门禁机上的 200ms 响应

4.1 人脸比对:余弦相似度阈值怎么定

训练完模型,推理时不再需要 ArcFace 的分类头,只保留骨干网络输出 embedding。两张脸的相似度用余弦距离,阈值一般取 0.35 到 0.45 之间。这个阈值不是拍脑袋定的,要在验证集上画 ROC 曲线,找 FAR(误接受率)和 FRR(误拒绝率)的平衡点。门禁场景对 FAR 更敏感,因为放错人进来比拒真更严重,所以阈值可以提到 0.45 甚至 0.5。

def cosine_similarity(emb1, emb2): # emb 已经 L2 归一化,点积就是余弦相似度 return float(torch.dot(emb1, emb2)) def verify(emb1, emb2, threshold=0.42): sim = cosine_similarity(emb1, emb2) return sim > threshold, sim

threshold=0.42是我在几个公开数据集上试出来的经验值,实际项目一定要在自己的验证集上重新标定。注意 embedding 必须归一化后再算,否则余弦相似度会受向量模长影响。

4.2 导出 ONNX 并量化:让模型在边缘设备上跑起来

训练用 PyTorch,部署往往要转 ONNX 甚至量化成 INT8。导出时注意输入尺寸固定为 1×3×112×112,动态轴只留 batch 维。量化用 ONNX Runtime 的静态量化,校准集准备 100 到 200 张对齐后的人脸图,覆盖不同光照和角度。

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

opset_version=11兼容性最好,别用太新的版本,有些边缘设备的推理引擎不支持。导出后一定要用 onnxruntime 跑一遍,对比 PyTorch 和 ONNX 的输出差异,差异大于 1e-4 就说明有算子没对齐,常见的是 BatchNorm 和 Normalize 的融合问题。

4.3 门禁机场景的工程约束:活体检测和底库管理

人脸识别门禁机不是只跑识别就完事。活体检测必须加,否则拿张照片就能开门。常见做法是 RGB 单目活体(检测屏幕反光和纹理)或红外双目活体。底库管理上,每个身份存一条平均 embedding,注册时采 3 到 5 张不同角度的人脸取均值,比单张 embedding 稳定。底库超过 1 万条时,暴力比对会超时,要用 FAISS 建索引,IVF 或 HNSW 都行,召回率 99% 以上时延迟能压到 10ms 以内。

提示:门禁机上的摄像头畸变会严重影响对齐精度,部署前一定要用棋盘格标定,把畸变系数传给检测和对齐模块。

5. 避坑与排查:那些让准确率一夜回到解放前的细节

5.1 训练 loss 正常但验证指标不涨

现象:训练集 loss 稳步下降,验证集准确率卡在 60% 不动。原因通常是数据泄漏或验证集身份太少。先检查切分脚本,确认验证集身份没在训练集出现;再看验证集身份数,少于 50 个身份时指标抖动极大,建议扩充验证集或改用交叉验证。另一个可能是 embedding 没归一化就送进验证,导致余弦相似度计算错误。

5.2 推理时同一张图两次结果不一样

现象:同一张人脸图连续推理两次,相似度差了 0.1 以上。原因一般是模型没切 eval 模式,BatchNorm 还在用 batch 统计量。推理前必须model.eval(),并且用torch.no_grad()包住。如果用了 Dropout,eval 模式也会自动关闭。ONNX 导出时同样要确保模型在 eval 模式,否则导出的计算图里 BN 参数是错的。

5.3 侧脸和戴口罩场景准确率断崖下跌

现象:正脸准确率 98%,侧脸掉到 70%,戴口罩掉到 50%。原因是训练集里缺乏这些样本,模型没学过。解决不是改损失函数,而是补数据。侧脸可以用 3D 人脸模型合成,戴口罩可以用口罩贴图增强。如果没法补数据,退而求其次用更大的预训练模型微调,但效果有限。我一般会在训练集里强制加入 20% 的侧脸和遮挡样本,哪怕准确率暂时降一点,泛化性会好很多。

5.4 量化后精度掉超过 2 个点

现象:FP32 模型准确率 97%,INT8 量化后掉到 94%。原因是校准集没覆盖真实分布,或者某些层对量化敏感。先检查校准集是不是只用了正脸,补上侧脸和暗光样本。如果还不行,对第一层卷积和最后的全连接层保持 FP32,只量化中间层。ONNX Runtime 支持按层配置,op_types_to_quantize里排除Conv的第一层即可。

5.5 底库大了之后检索变慢

现象:底库从 1000 涨到 5 万,单次比对从 5ms 涨到 300ms。原因是暴力比对复杂度是 O(N)。解决用 FAISS 建 IVF 索引,nlist设成 sqrt(N) 左右,5 万条设 256。查询时nprobe设 16 到 32,召回率和延迟平衡。注意 FAISS 的索引要定期重建,底库增删频繁时索引会退化。

6. 一个能直接跑的验证脚本:用 LFW 测你的模型到底行不行

训练完别急着上线,先用 LFW 做一次标准验证。LFW 有 6000 对人脸对,一半同人一半异人,是行业公认的基线。下面这个脚本读 LFW 的 pair 文件,对齐后算相似度,输出 ROC 和最佳阈值。

import numpy as np from sklearn.metrics import roc_curve, auc def evaluate_lfw(model, pairs, threshold=0.42): scores, labels = [], [] for img1, img2, label in pairs: emb1 = model(align_face(img1, detect_landmarks(img1))) emb2 = model(align_face(img2, detect_landmarks(img2))) scores.append(cosine_similarity(emb1, emb2)) labels.append(label) fpr, tpr, thresholds = roc_curve(labels, scores) roc_auc = auc(fpr, tpr) # 找 FAR=1e-3 时的阈值 idx = np.argmin(np.abs(fpr - 1e-3)) best_th = thresholds[idx] acc = np.mean((np.array(scores) > best_th) == np.array(labels)) return roc_auc, best_th, acc

roc_auc反映模型整体排序能力,best_th是 FAR=0.1% 时的阈值,acc是该阈值下的准确率。LFW 上 ResNet18 + ArcFace 大概能到 99.2% 到 99.5%,低于 99% 说明管线有问题,先查对齐,再查归一化。这个脚本我每次训完新模型都会跑一遍,比看训练 loss 靠谱得多。

最后说个习惯:我从来不信训练日志里的准确率,只信独立测试集上的 ROC。模型上线前一定用真实场景采集的 200 张图做一次盲测,侧脸、暗光、戴口罩各占三分之一。这一步花不了半小时,但能帮你省掉上线后被业务方追着问「为什么刷不开」的尴尬。希望帮到你。

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

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

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

立即咨询