开始之前:人脸识别到底在识别什么
人脸识别这几年被炒得很热,从手机解锁到安防闸机,再到刷脸支付,好像什么场景都能插一脚。但你真让我用一句话说清楚它做了什么,我会这么讲:它做的事情本质上就是“看图说话”——把一张人脸照片变成一个数学向量,然后拿这个向量和数据库里的向量做比对,距离够近就算同一个人。
这里面的核心技术,就是卷积神经网络,圈内习惯叫CNN。CNN不是什么新鲜词,2012年之后它几乎统治了所有图像类任务,人脸识别只是它的一个应用场景而已。这篇我就把人脸识别的完整链路讲清楚,从传统方法到CNN的核心原理,再落到怎么用CNN把一个人脸识别系统搭起来。内容尽量做到既要原理可追溯,也要代码能落地,适合刚接触图像识别的学生、转行做算法工程师的朋友,以及工作中需要快速上手人脸识别方案的开发者。
说句实在话,很多人最开始接触人脸识别,脑子里全部是“深度学习”“神经网络”这些词,但其实人脸识别并不是深度学习火了之后才有的。早些年基于几何特征和局部二值模式的方法也能做,只是精度和鲁棒性差不少。要真想搞明白现在的人脸识别为什么做得这么好,得先把CNN内部那几层结构的来龙去脉搞清楚。
1. 人脸识别的整体技术链路与方案选择
1.1 人脸识别的四个核心环节
以我做过的一个模拟项目X为例,当时要在某园区出入口做一套刷脸通行系统。整体流程跑下来,我把它归纳为四个环节:人脸检测、人脸对齐、特征提取、特征比对。
人脸检测负责从画面里找到人脸在哪,输出一个矩形框。这一步看似基础,但实际影响非常大,因为后面所有步骤都依赖这个框是否准确。如果框偏了,或者把背景框进来了,对齐和特征提取都会受影响。主流方案还是以MTCNN和RetinaFace为主,它们在小目标、遮挡、夸张姿态上各有取舍。
人脸对齐是把检测到的人脸做几何校正,让眼睛、鼻子、嘴巴的位置尽量标准化。因为同一个人的脸,在不同照片里角度和尺度都不一样,如果不先把脸“摆正”,提取特征的时候就会混淆“脸长什么样”和“脸朝哪个方向”。这一步通常通过检测关键点,然后做相似变换来搞定。
特征提取是整个流程的核心。这一步把一张人脸图像压缩成一个固定长度的向量,比如128维或512维。这个向量在理想情况下,同一个人的不同照片应该距离很近,不同人的照片应该距离很远。人脸识别算法比拼的大部分能力,都集中在这一步。
特征比对就是拿提取出来的向量去和库里存的特征去算距离,常用的是余弦相似度和欧氏距离。设定一个阈值,超过就判定为同一个人,低于就拒绝。这个阈值的选择很有讲究,定得太严容易误拒,定得太松容易误识。
1.2 传统方法为什么被人脸识别淘汰
很多人以为人脸识别天生就是深度学习的样子,其实不是。早年的传统做法,比如基于几何特征的方法,是标定眼睛、鼻子、嘴巴等关键位置,然后测量它们之间的距离和角度关系,构造一个特征向量。这个思路听着很直观,但实操时极其脆弱——光照一变、表情一变,那些距离值就跟着乱跳。
后来流行过一段时间局部二值模式直方图的方法,用局部纹理描述人脸,在当年算是挺优雅的方案。但它的天花板很低,因为底层用的还是人工设计的特征。人工设计特征的问题在于,做特征的人要事先猜测“什么信息对区分人脸最重要”,这个猜测很难做到全面。
CNN的出现改变的是这个底层逻辑:不再人为指定特征,而是让网络在训练过程中自己学习该关注什么。我举个生活化的类比,传统方法就像你要给朋友描述一个人的长相,只能按你想到的维度去描述,比如“眼睛大不大、鼻梁高不高”;而CNN是直接把一大堆照片丢给网络,让它自己归纳出哪些维度最能区分人脸,可能是肤色,可能是轮廓,也可能是某种人眼说不清道不明的组合,反正它自己说了算。
1.3 从图像分类到人脸识别的本质区别
CNN最开始大规模应用是在ImageNet图像分类上,比如区分一张图是猫还是狗。但人脸识别不是一个“分类”问题,它是一个“度量学习”问题。
分类问题的输出是类别标签,比如1000类物体,网络最后接一个1000维的Softmax输出。人脸识别不一样,它要应对的是“训练时没见过的人”。你不能把每个人当成一个类别去训练,因为世界上有几十亿人,而且识别阶段出现的人脸可能完全没参与过训练。
所以人脸识别的主流做法是:把网络当做一个特征提取器,训练时用分类的损失函数去约束特征分布,但真正做识别时丢掉最后的分类层,只取前面部分的特征向量来做比对。
举个具体例子,我用某个开源人脸模型时,输入是112x112的RGB图像,经过一系列卷积和池化后,得到一个512维的特征向量。训练的时候这个512维向量后面还会接一个全连接层去做分类,比如训练集有1万个人,那就接一个1万维的分类层。但真正部署识别的时候,这一层会被去掉,只用前面的512维向量。
2. CNN核心概念拆解:卷积、池化、全连接
2.1 卷积层到底在算什么
CNN里最核心的构件是卷积层。很多人第一次接触卷积觉得头大,其实可以换个角度看:卷积层做的事情,就是用一个小的“扫描窗口”在图像上滑动,每滑到一个位置,就把窗口里的像素值和窗口本身的权重做加权求和,得到一个输出值。
这个“扫描窗口”叫卷积核,也叫滤波器。它的大小通常是3x3或5x5,深度和输入图像的通道数一致。举个例子,输入是一张112x112x3的图,卷积核是3x3x3,它就在图上一格一格地滑动,每算一次得到一个数值。把所有滑过的位置算一遍,得到一张新的特征图。
为什么要这么做?因为卷积核本质上是一个局部模式探测器。比如某个卷积核的权重恰好能检测水平边缘,那它在图像中所有存在水平边缘的位置都会产生较大的响应值。网络的浅层卷积核会学习到边缘、颜色块这类基础特征,深层卷积核则会组合出眼睛、鼻子这类复杂结构。
有一个关键参数叫“感受野”,指的是输出特征图上的一个像素,对应回输入图像上多大的一块区域。这是CNN能够理解全局结构的基础。以人脸识别为例,浅层卷积核看到的是局部纹理,而深层卷积核通过层层叠加,能感知到两只眼睛之间的距离、脸型轮廓这种全局关系。
2.2 池化层的作用与为什么不能省
池化层通常跟在卷积层后面,最常见的做法是最大池化。它做的事情很简单:在2x2的窗口里取最大值,然后把图像尺寸减半。
很多人觉得池化不就是降采样吗,为什么非要取最大值,平均值不行吗?我的理解是这样的:最大池化保留了窗口内最强烈的响应,相当于把“这个位置有没有出现某个特征”这个信息保留下来,而平均池化会把强响应和弱响应互相抵消,反而模糊了特征。所以在图像识别里,最大池化更好用。
池化的意义不只是减少计算量,更关键的是它带来了某种程度的平移不变性。什么意思呢?如果人脸在图像里偏移了几个像素,经过池化后,特征图上的响应位置变化会变小。这正好符合人脸识别的需求——脸并不总是在画面正中央。
当然,现在很多新网络架构开始用带步长的卷积替代池化,功效类似,但池化层的概念还是要理解,很多经典网络的代码里都在用。
2.3 全连接层和特征向量之间的关系
卷积层和池化层反复堆叠之后,得到的是三维特征图,高度和宽度越来越小,通道数越来越多。最后要把这些特征变成一个向量,就需要把三维特征图“拉平”,接上全连接层。
全连接层就是传统的神经网络层,每个输入节点和每个输出节点之间都有权重连接。它的参数量极其庞大,所以现代网络设计倾向于用全局平均池化替代部分全连接层,直接把每个通道的所有值取平均,得到一个长度等于通道数的向量。
在人脸识别网络里,最后那个特征向量通常来自最后一个池化层或一个维度较小的全连接层。这个向量就是“人脸特征”的数字表达。我在实际项目里经常把这个向量直接存数据库,比对的时候就做向量运算。
这里有一个很重要的点:特征向量的维度选择要平衡精度和速度。128维的向量在嵌入式设备上跑得非常快,但精度上限不如512维。我测试过一个场景,在RK3588这类边缘设备上,512维向量的余弦相似度计算一万次大约要十几毫秒,实际影响不是特别大,但如果是超大规模人脸库,尤其是千万级以上的,维度的影响就会被放大。
3. 从零实现一个CNN人脸识别模型
3.1 选型与准备:为什么用PyTorch
现在做人脸识别,框架基本就两个选项:PyTorch或TensorFlow。个人偏向PyTorch,原因有三:一是动态图机制调试方便,可以随时打印中间变量;二是学术界开源的人脸识别模型,像ArcFace、CosFace这些,基本都是PyTorch版优先;三是部署时转ONNX再转各种推理引擎的链路已经非常成熟。
硬件方面,训练一个像样的人脸识别模型,一张像样的GPU是少不了的。我没法给你一个绝对最低配置,但以我的经验,显存低于8G的话,很多开源的训练代码跑起来会非常痛苦,光是调batch size就够折腾的。这几年很多云服务商都提供按小时的GPU实例,短期实验完全不用自己买卡。
数据集方面,学术上常用的是CASIA-WebFace和MS-Celeb-1M。不过MS-Celeb-1M因为授权问题已经停止公开下载了,现在用得更多的是Glint360K这类清洗过的数据集。自己从头训练一个大规模模型成本不低,我建议初学阶段先下载一个预训练模型做细粒度调优,或者干脆在小型数据集上把整个训练流程跑通。
3.2 数据预处理:人脸对齐的代码级解剖
不管是训练还是推理,数据预处理流程是一样的。以我常用的方式为例,完整链路是:读图 -> 人脸检测 -> 关键点定位 -> 相似变换对齐 -> 归一化 -> 送入模型。
对齐这一步是用相似变换把五个关键点(左眼、右眼、鼻尖、左嘴角、右嘴角)映射到标准位置。标准位置是固定的,比如在112x112的图像上,左眼通常在(38.2946, 51.6963),右眼在(73.5318, 51.5014)。这些数值不是拍脑袋定的,而是从数据集统计出来的,目的是让人脸在图像中占的比例一致。
import cv2 import numpy as np def align_face(image, keypoints, size=(112, 112)): # keypoints 是 5x2 的数组,顺序为:左眼、右眼、鼻尖、左嘴角、右嘴角 dst = 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) src = keypoints.astype(np.float32) # 计算相似变换矩阵 transform = cv2.estimateAffinePartial2D(src, dst)[0] aligned = cv2.warpAffine(image, transform, size) return aligned这个代码看起来简单,但有个细节容易被忽略:estimateAffinePartial2D算出来的是部分仿射变换,只包含旋转、平移和缩放,不包含切变。人脸对齐不应该有切变,因为脸的角度变化是刚体变换,强行加入切变会把脸拉变形。
对齐做完之后还要归一化,常见做法是每个像素减127.5再除以128,这样像素值范围大概落在[-1, 1]区间。这一步能帮助网络训练时梯度更稳定。如果你用的是ONNX导出的模型,预处理方式必须和训练时完全一致,否则精度掉得很厉害。
3.3 模型训练:ArcFace损失函数与度量学习
模型结构上,可以用ResNet50或MobileFaceNet做骨干网络。MobileFaceNet是为移动端设计的轻量模型,参数量小且精度不错,我在多个边缘设备项目里都用的它。
训练的时候,光用Softmax损失是不够的。Softmax优化的是“分类正确”,但人脸识别要求“类间距离大,类内距离小”。ArcFace就是针对这个目标设计的损失函数,它在Softmax基础上给角度加上一个惩罚项,让网络学到的特征在超球面上分布得更开。
我用一段简化代码来展示ArcFace的思路。
import torch import torch.nn as nn import torch.nn.functional as F class ArcFace(nn.Module): def __init__(self, in_features, out_features, s=64.0, m=0.5): super().__init__() self.weight = nn.Parameter(torch.FloatTensor(out_features, in_features)) nn.init.xavier_normal_(self.weight) self.s = s self.m = m def forward(self, inputs, labels): # 归一化输入和权重 inputs = F.normalize(inputs, p=2, dim=1) weight = F.normalize(self.weight, p=2, dim=1) # 计算余弦相似度 cos_theta = F.linear(inputs, weight) # 计算角度 theta = torch.acos(cos_theta.clamp(-1.0 + 1e-7, 1.0 - 1e-7)) # 对目标类别加上角度惩罚 target_mask = torch.zeros_like(cos_theta) target_mask.scatter_(1, labels.view(-1, 1), 1.0) target_logits = torch.cos(theta + self.m) output = cos_theta * (1 - target_mask) + target_logits * target_mask output *= self.s return output这段代码里有两个超参数值得关注:s是缩放系数,设置为64,m是角度惩罚,设置为0.5。s越大,特征的分布越集中,但太大容易导致训练不稳定;m越大,类间推开的力度越大,但太大也难收敛。这组参数是论文作者试出来的经验值,新手最好不要乱改。
训练时batch size尽量调大一点。人脸识别训练特别吃batch size,因为损失函数需要在一个batch内部构造正负样本对比,batch太小的话,模型很难学到有区分度的特征。我个人的经验是,至少64起步,能到128或256更好。如果你的显存不够,可以尝试用梯度累积来模拟大batch的效果。
3.4 特征提取与相似度计算
训练完成后,真正部署时只需要用网络的前向部分做特征提取。以我常用的方式为例,加载训练好的模型,去掉最后一层分类层,输入一张对齐后的112x112人脸图,输出512维向量。
import torch import torchvision.transforms as transforms def extract_feature(model, face_image): model.eval() transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) img_tensor = transform(face_image).unsqueeze(0) with torch.no_grad(): feature = model(img_tensor) # L2归一化 feature = F.normalize(feature, p=2, dim=1) return feature.cpu().numpy().flatten()提取出的特征建议做L2归一化,好处是计算余弦相似度时可以直接用点积替代余弦公式,省一次除法运算。归一化之后,两个特征向量之间的余弦相似度就等于它们的点积。
相似度计算也很直接:
import numpy as np def cosine_similarity(feat1, feat2): return np.dot(feat1, feat2) def euclidean_distance(feat1, feat2): return np.linalg.norm(feat1 - feat2)经验上,余弦相似度比欧氏距离更适合人脸识别。原因是L2归一化后,余弦相似度等价于关注两个向量的方向一致性,对向量模长不敏感。而欧氏距离会把特征向量的“长度”信息也纳入考量,这个信息通常没什么用,反而会引入噪声。
3.5 阈值怎么定:从相似度分数到识别决策
特征比对只是得到了一个分数,真正要做出“是不是同一个人”的判断,还得定一个阈值。阈值的选择必须基于实际的验证集测试,不能拍脑袋。
我的做法是:准备一批正样本对(同一个人的两张照片)和负样本对(不同人的照片),全部算出相似度分数,然后画出分布曲线。正样本对的分数通常集中在0.5到0.9之间,负样本对的分数通常在-0.1到0.3之间。阈值就选在两类分布重叠最少的地方。
这个阈值在不同数据集上差异很大。在某个人脸数据集上,0.4的阈值可能已经很合适,但换个数据集,可能得调到0.55。所以如果换了一批训练数据,阈值一定要重新标定,这是新手最容易踩的坑。
4. 实操中遇到的典型问题与排查思路
4.1 预处理不一致导致的精度暴跌
我在做项目时遇到过一个很典型的问题:训练时用得是112x112的RGB输入,但到部署阶段,代码里读图用的是灰度模式,通道数对不上,模型直接报警告,精度也掉了不少。这类问题最隐蔽,因为代码能跑,只是结果不对。
排查手段其实很基础,就是打印输入模型的张量形状,确认通道数、尺寸、数值范围。我建议在模型推理入口加一个断言,比如:
assert input_tensor.shape == (1, 3, 112, 112), f"unexpected shape: {input_tensor.shape}" assert input_tensor.min() >= -1.0 and input_tensor.max() <= 1.0, "input not normalized"这种断言能帮你在开发早期就暴露问题,而不是等到上线了才发现精度不对。
4.2 光照和角度变化导致误判
人脸识别在实际环境中最大的敌人不是算法本身,而是光照、角度、遮挡这些“环境噪声”。同一个角度下,不同角度的同一张脸,相似度可能差别很大。
针对光照问题,一个实用做法是数据增强。训练时随机调整亮度、对比度、饱和度,甚至加一些随机遮挡块,模拟真实场景中的干扰。我在一个项目中把训练数据增加了随机光照扰动之后,模型在暗光环境下的通过率提升了接近十个百分点。
针对角度问题,目前比较有效的方法是训练时做随机水平翻转。还有一个思路是多角度注册,也就是录入人脸时,不只拍一张正面照,而是拍几张不同角度的照片都存入特征库,比对时取最高的相似度作为最终结果。
4.3 模型在嵌入式设备上的推理速度优化
如果你要把模型部署到嵌入式设备上,比如门禁机、考勤机,就需要关注推理速度。MobileFaceNet在CPU上单张112x112人脸推理大约能跑到20到50毫秒,具体取决于芯片。如果速度不达标,有几步优化可以做:先把模型转成ONNX,再用ONNX Runtime或TensorRT推理,通常比PyTorch原版快不少。其次可以做INT8量化,以少量精度损失换取接近两倍的加速。
我一般建议先用ONNX Runtime的CPU模式跑一遍,如果速度还不行,再考虑量化。不要一上来就直接量化工序,因为量化调试还挺麻烦,要对齐校准数据集,搞不好精度掉得厉害。
4.4 小样本注册问题与增量更新
实际项目中还有一个常见场景:注册库里的照片只有一张,但用户每天上班的角度、表情、光线都在变。这种情况下,单张注册的特征往往不够鲁棒。
一个有效的方案是“动态特征更新”。每次识别通过且相似度超过一个较高阈值(比如0.8)时,就把当前帧提取的特征和库里的旧特征做加权平均,更新库里的特征向量。这样特征库会随着使用越来越贴合这个人的实际外观变化。
不过这种做法要加一个保护机制:只有在相似度非常高的前提下才更新,否则会把误识别的特征混进库里,造成污染。我在一个实际项目里设定的规则是相似度大于0.85才允许更新,小于这个值只能读不能写。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 模型训练loss不降 | 学习率过大或过小 | 调低学习率到1e-3附近,确认归一化正常 |
| 训练精度高但测试精度低 | 过拟合 | 增加数据增强,或加大正则化权重 |
| 部署后精度明显下降 | 预处理不一致 | 检查输入尺寸、均值方差、通道顺序 |
| 推理速度慢 | 模型过大或框架不合适 | 转ONNX,做量化,或换轻量网络 |
| 同一个人相似度波动大 | 光照角度变化 | 数据增强,多角度注册,动态特征更新 |
| 误识率高 | 阈值过低 | 重新标定阈值,检查特征归一化 |
5. 模型部署与服务化:从离线到实时
5.1 模型导出与推理引擎选择
训练好模型只是第一步,真正让人脸识别跑起来,还得把它部署成服务或者集成到应用里。模型导出的标准格式是ONNX,它相当于各种深度学习框架之间的“翻译官”。
PyTorch导出ONNX的代码很简单:
import torch model = load_model("mobilenet_face.pth") model.eval() dummy_input = torch.randn(1, 3, 112, 112) torch.onnx.export( model, dummy_input, "face_net.onnx", input_names=["input"], output_names=["feature"], dynamic_axes={"input": {0: "batch"}, "feature": {0: "batch"}}, opset_version=11 )导出之后,可以用ONNX Runtime来推理。它的优势是轻量、跨平台,CPU推理速度比PyTorch快不少。如果是NVIDIA的GPU环境,可以考虑TensorRT,速度能进一步翻倍,但配置稍微繁琐一些。
5.2 本地推理服务示例
写一个简单的Flask服务,接收图片返回特征向量,这样方便前后端分离对接。实际生产环境通常用gRPC,性能和吞吐更好,但逻辑是一样的。
from flask import Flask, request, jsonify import cv2 import numpy as np import onnxruntime as ort app = Flask(__name__) session = ort.InferenceSession("face_net.onnx") def preprocess(image_bytes): image = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR) aligned = align_face(image, detect_keypoints(image)) aligned = (aligned - 127.5) / 128.0 aligned = aligned.transpose(2, 0, 1).astype(np.float32) return np.expand_dims(aligned, axis=0) @app.route("/embedding", methods=["POST"]) def get_embedding(): image_bytes = request.files["image"].read() img = preprocess(image_bytes) feature = session.run(None, {"input": img})[0][0] return jsonify({"feature": feature.tolist()}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)这只是一个最小可用示例,生产环境还需要加鉴权、限流、批量处理等机制,但流程是通的。特提取出来后,比对还是在服务端做,或者把特征下发到设备端做,取决于架构设计。
5.3 单机比对效率与大规模方案取舍
人脸特征比对本身计算量不算大。一万个人的人脸库,每人512维浮点特征,总大小约20MB,加载到内存里做线性扫描,一次完整比对大概也就几十毫秒。这个量级完全不用上专门的向量数据库。
但当你的人脸库到十万、百万量级时,线性扫描就吃力了,这时候得考虑近似最近邻索引。常用的方案有FAISS和Milvus。以FAISS为例,用IVF索引可以把百万级库的检索时间从几百毫秒压缩到几十毫秒以内。
我的建议是:项目初期不要上重组件,一个小型库用内存比对完全够用。等技术方案验证成功、业务量确实起来了,再引入FAISS或者Milvus,系统性平滑迁移并不难。
6. 人脸识别项目的工程落地经验
6.1 从Demo到产品的关键差异
一个在实验室跑通的模型Demo和一套实际可用的产品之间有挺长的路要走。实验室环境里,所有人脸都是正脸、光照均匀、分辨率足够;现实环境是反着来的。
我自己做过一个校园门禁项目,最初在测试集上准确率99%,看着很漂亮,一上真实场景立刻暴露问题:逆光环境下人脸过曝、戴帽子遮挡额头、手机屏幕反光等,各种情况都冒出来了。解决方案不是死磕算法,而是从硬件和工程角度做优化:调整摄像头安装位置避免逆光,增加补光灯,设计合理的闸机等待区让用户能正面面对镜头。
做项目的同学和工程师一定要记住一句话:人脸识别是“算法加工程”的综合问题,光有模型准确率解决不了所有事。
6.2 隐私安全与合规注意事项
人脸数据属于个人敏感信息,采集、存储、使用都要特别注意。做项目的时候,有几条底线不要碰:不收集与识别无关的人脸信息、不明文存储原始人脸图像、不将人脸数据用于授权范围之外的用途。
技术上可以做几件事来降低风险:原始图片只在内存中做检测和特征提取,不落盘;数据库里只存特征向量,不存原始照片;特征向量本身建议加密存储。这样即使数据库泄露,攻击者拿到的也只是一堆无法还原成图像的数值。
6.3 后续还能怎么扩展
人脸识别做到能跑,后续扩展方向其实挺多。比如加上活体检测,防止照片或视频冒充。活体检测分两类:配合式(让你眨眨眼、摇摇头)和非配合式(静默判断),后者在安防场景更友好,但技术难度高一些。
还可以做人脸属性分析,性别、年龄、表情这些都能从特征里再派生出来。又或者做跨摄像头的人脸轨迹追踪,在多个摄像头画面中确认同一个人出现的时间和位置,这些在商业场景中都很常见。
我个人最推荐初学的人把精力放在“特征质量”这件事上,因为不管上层应用怎么变,特征总是最核心的东西。特征分得开、稳得住,上面做表情分析也好、做轨迹也好,效果都不会差。
最后再分享一个做项目时特别实用的习惯:每次跑实验都要记录输入图像的来源、预处理参数、模型版本、阈值设置,最好连同最终效果一起存下来。人脸识别实验的变量非常多,不记录的话,你可能今天跑出一个好结果,下周想复现却怎么也想不起来当时是怎么配的了。做好记录,比多调几次参有用得多。