ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CNN人脸识别实战:从数据对齐到ArcFace部署

CNN人脸识别实战:从数据对齐到ArcFace部署 简介这份资源是《DeepLearning tutorial5CNN卷积神经网络应用于人脸识别》一文的配套代码包面向具备一定深度学习基础、希望动手实践卷积神经网络图像分类的开发者与学习者帮助其理解并复现从数据加载、模型搭建到训练与预测的完整人脸识别流程。压缩包共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], # 右嘴角 ], dtypenp.float32) def align_face(img, landmarks): landmarks: 检测器输出的 5x2 关键点顺序同上 # 用相似变换估计仿射矩阵只允许旋转、缩放、平移 M, _ cv2.estimateAffinePartial2D(landmarks, REFERENCE_POINTS) aligned cv2.warpAffine(img, M, (112, 112), borderValue0.0) return aligned这段代码的关键在estimateAffinePartial2D它只估计 4 个自由度旋转、缩放、平移不允许剪切和拉伸。如果你用estimateAffine2D估计 6 个自由度遇到侧脸时会把脸「压扁」去凑关键点反而引入形变。参数上borderValue0.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_ratio0.8, val_ratio0.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_idsrandom.seed(42)是为了可复现别小看这个调参时如果每次切分不一样你根本分不清是模型变了还是数据变了。身份数少于 100 的小数据集验证集身份太少会导致指标抖动大这时候可以把 val_ratio 提到 0.2或者用交叉验证。2.3 数据增强哪些能开哪些开了就是自毁人脸识别的增强有明确边界。水平翻转可以开因为人脸近似左右对称但垂直翻转绝对不能开倒着的脸不是人脸。随机裁剪要小心裁太狠会把眼睛裁掉关键点就废了。颜色抖动可以开模拟不同光照但色相偏移别太大否则肤色变得不像人。我一般用水平翻转 p0.5随机亮度对比度 ±0.2高斯模糊 p0.1不做旋转因为对齐已经固定了姿态。import torchvision.transforms as T train_transform T.Compose([ T.RandomHorizontalFlip(p0.5), T.ColorJitter(brightness0.2, contrast0.2, saturation0.1), T.RandomApply([T.GaussianBlur(kernel_size3)], p0.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 和 VGGLeNet 太浅特征区分度不够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_size512): super().__init__() self.backbone resnet18(weightsNone) # 替换最后的全连接层输出 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, p2, dim1) return xweightsNone表示从头训。如果你有 ImageNet 预训练权重可以设weightsIMAGENET1K_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, margin0.5, scale64): 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, dim1)) 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)scale64是温度系数放大 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(), lr0.1, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones[10, 16, 20], gamma0.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_decay5e-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, threshold0.42): sim cosine_similarity(emb1, emb2) return sim threshold, simthreshold0.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_version11 )opset_version11兼容性最好别用太新的版本有些边缘设备的推理引擎不支持。导出后一定要用 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()包住。如果用了 Dropouteval 模式也会自动关闭。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, threshold0.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) # 找 FAR1e-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, accroc_auc反映模型整体排序能力best_th是 FAR0.1% 时的阈值acc是该阈值下的准确率。LFW 上 ResNet18 ArcFace 大概能到 99.2% 到 99.5%低于 99% 说明管线有问题先查对齐再查归一化。这个脚本我每次训完新模型都会跑一遍比看训练 loss 靠谱得多。最后说个习惯我从来不信训练日志里的准确率只信独立测试集上的 ROC。模型上线前一定用真实场景采集的 200 张图做一次盲测侧脸、暗光、戴口罩各占三分之一。这一步花不了半小时但能帮你省掉上线后被业务方追着问「为什么刷不开」的尴尬。希望帮到你。本文还有配套的精品资源点击获取
返回列表