简介:围绕CNN卷积神经网络在人脸识别中的落地实现,这份配套代码面向前置掌握Python与深度学习基础、希望从原理走向完整实践的学习者,重点解决卷积网络如何用于人脸图像训练、参数保存与预测识别的问题。资源包共4个文件,以2个Python脚本为主,分别承担模型训练与调用预测流程,另含1个pkl模型参数文件和1个gif演示文件,压缩包约14.64MB,便于直接对照复现。相关内容已有17754人学习,具备较高参考热度。读者可从中获得一条较完整的人脸识别实验链路:从Olivetti Faces数据读取、CNN网络搭建与训练,到保存模型参数并加载进行识别验证,同时借助演示文件直观理解运行效果。代码结构简洁,适合课程实验、入门项目复现及CNN人脸识别流程梳理,也能为后续调整网络结构、排查训练与预测环节问题提供可操作的参考。
1. 从一张 112×112 的人脸图说起:CNN 人脸识别到底卡在哪一步
去年帮一个做门禁的团队看 Demo:训练集准确率 99.2%,装到闸机上第一周就被投诉——两个人被认成同一个,戴口罩的同事一律判成陌生人。拆开工程发现,问题不在 CNN,而在数据划分、人脸对齐和比对阈值三件事上,模型结构一行没改,误识率就压下去了。人脸识别这条链路里,卷积网络只是中间一环,前面的检测与对齐、后面的特征比对与阈值标定,哪一环糊弄都会在真实场景里翻车。这里拆的是一套能跑通的完整流程:原始图整理、5 点对齐、四层 CNN 与迁移学习两套结构、ArcFace 度量学习训练,一直到最后阈值标定和排查清单,代码按 PyTorch 写,逐段可复现。适合两类人:想把课程设计做扎实、答辩时讲得清每一步为什么的人;以及要在自有数据上快速搭一个可用 baseline 的工程师。
2. 数据管线:把散装人脸图整理成能训练的 batch
CNN 人脸识别项目里,八成的时间花在这章。模型结构换来换去收益只有一两个点,数据管线做对与做错,差距是几十个点。常见做法是先用现成的人脸检测器把框和关键点拿到,再走对齐、清洗、划分、增强四步,这四步的产物是后面所有实验的共同地基,改一次就要重跑一遍。
2.1 清洗先于建模:四条必须执行的筛选线
原始素材通常是手机拍的合照、监控截图、从视频里抽的帧,质量参差。直接把整张图缩到 112×112 喂进网络,背景占了七成像素,卷积核学到的多半是墙面纹理。所以第一道工序永远是检测裁剪,裁剪之后再按下面几条线过滤:
| 过滤项 | 阈值建议 | 丢弃原因 |
|---|---|---|
| 人脸框面积占原图比例 | < 0.05 | 远景小脸,放大后全是插值噪声 |
| 检测置信度 | < 0.90 | 侧脸、遮挡框,关键点不可信 |
| 双眼像素距离 | < 20 px | 对齐后仍是糊的,特征无法区分 |
| 单身份样本数 | < 8 张 | 度量学习撑不起类内方差,直接合并或剔除 |
面积比例这一条按业务调:闸机场景人脸天然占画面大,门禁抓拍机可能只占十分之一,阈值压到 0.02 也合理。单身份样本数不够时,不要用简单复制来凑数,复制出来的样本在同一个 batch 里会拉高类内相似度,训练出的 embedding 会过度聚集,跨人区分度反而下降。
另外一定要做近重复去重。同一个人的连续视频帧,相邻两帧差异极小,散落到训练集和验证集两侧就是数据泄漏。用图片的感知哈希做粗筛,汉明距离小于 6 的只保留一张,成本极低,收益很大。
2.2 五点对齐:把眼睛固定到同一个坐标系
对齐这一步决定了 CNN 到底在学习人脸结构还是在学习姿态。做法是用 5 个关键点(左右眼中心、鼻尖、左右嘴角)与标准模板做相似变换,把五官摆到固定位置。下面是完整的对齐函数,模板坐标取的是 112×112 输入下的常用值:
import cv2 import numpy as np # 112x112 输出的标准 5 点模板(左眼、右眼、鼻尖、左嘴角、右嘴角) ARC_TEMPLATE = 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, out_size=112): """img: BGR 原图; landmarks: (5,2) 关键点像素坐标""" # 只允许相似变换(旋转+缩放+平移),不使用透视变换,避免拉伸五官 M, _ = cv2.estimateAffinePartial2D(landmarks, ARC_TEMPLATE, method=cv2.LMEDS) if M is None: return None # 模板是按 112 设计的,其他尺寸按比例缩放平移矩阵 scale = out_size / 112.0 S = np.array([[scale, 0, 0], [0, scale, 0]], dtype=np.float32) M = S @ np.vstack([M, [0, 0, 1]]) return cv2.warpAffine(img, M[:2], (out_size, out_size), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)逻辑上有两个地方容易写错。第一是变换类型,用estimateAffinePartial2D而不是estimateAffine2D或透视变换,原因是对齐的目的是消除姿态差异,不是把脸“掰正”,允许各向异性缩放会让窄脸和宽脸被拉成同一个形状,反而抹掉了身份信息。第二是borderMode,默认的黑色填充会在边缘制造强梯度,卷积核第一层就会响应这些假边缘,改成BORDER_REPLICATE用边缘像素延展,实测在侧脸样本上更稳。
关键点来源可以用轻量的人脸检测网络一次前向拿到框和 5 点,不必串两个模型。检测阶段顺手把置信度低于 0.9 的直接丢弃,比后面靠损失函数去容错划算得多。
2.3 按身份切分:把同一个人锁在同一个集合里
这一步是人脸识别项目里最容易被忽略、后果最严重的环节。如果按图片随机切分,同一个人的不同照片会同时出现在训练集和验证集,模型只要记住这张脸就能在验证集上拿高分,验证准确率 99%,上线就崩。正确的切法是按身份切:
import random from collections import defaultdict def split_by_identity(samples, val_ratio=0.2, seed=42): """samples: [(path, identity_id), ...] 返回 train/val,两个集合的身份完全不重叠(open-set 评估) """ by_id = defaultdict(list) for path, pid in samples: by_id[pid].append(path) ids = sorted(by_id.keys()) random.Random(seed).shuffle(ids) n_val = max(1, int(len(ids) * val_ratio)) val_ids = set(ids[:n_val]) train = [(p, i) for i, ps in by_id.items() if i not in val_ids for p in ps] val = [(p, i) for i, ps in by_id.items() if i in val_ids for p in ps] return train, val这里要注意val_ratio的含义变了:它切的是身份数,不是图片数。想按图片比例控制,就得在身份层面做加权抽样。还有一个取舍——如果业务是封闭集(固定的一批员工),更贴近实际的评估方式是留出一部分图片做 closed-set 分类;如果业务是开放集(陌生人必须被拒),就必须用身份隔离的 open-set 验证,也就是 1:1 比对。两者数字差得非常远,报告里必须写清用的是哪一种。
2.4 Dataset 与 DataLoader:增强写进管线,不要写进离线文件
增强放在 Dataset 里做,不要提前生成增强后的图片存盘。存盘会让数据量翻几倍,而且同一个样本的增强结果固定,等于没做随机性。人脸的增强要克制,能用的其实就三类:
import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image train_tf = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), # 人脸近似左右对称,安全 transforms.ColorJitter(0.2, 0.2, 0.15, 0.02), # 亮度/对比度/饱和度/色调 transforms.RandomRotation(8, fill=128), # 小角度,覆盖拍摄倾斜 transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), ]) val_tf = transforms.Compose([ transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), ]) class FaceDataset(Dataset): def __init__(self, samples, tf): self.samples = samples self.tf = tf def __len__(self): return len(self.samples) def __getitem__(self, idx): path, pid = self.samples[idx] img = Image.open(path).convert("RGB") # 统一成 RGB,训练与推理必须一致 return self.tf(img), pid loader = DataLoader(FaceDataset(train, train_tf), batch_size=64, shuffle=True, num_workers=4, drop_last=True, pin_memory=True)几个参数值得单独说。drop_last=True在度量学习里几乎是必须的,最后一个小 batch 里可能每个身份只有一两张图,ArcFace 的类中心更新会失真,BN 统计也会抖。RandomRotation的fill=128是因为输入归一化到 [-1,1] 之后,128 灰度约等于 0,填充后的边缘不会被当成强信号。颜色抖动里饱和度上限压到 0.15,原因是抓拍机与手机摄像头的色彩差异本来就有限,抖太狠会让肤色这类强身份线索被破坏。
颜色通道顺序是另一个常见翻车点。OpenCV 读出来是 BGR,PIL 读出来是 RGB,如果训练用 PIL、部署用 cv2 且没做转换,准确率会莫名其妙掉几个点,而且报错都不报,属于典型的黑匣子问题。我一般会在训练脚本里写死一个to_rgb(),部署侧同样调它,不给第二种可能。
最后补一个样本均衡的细节。身份数多、每类样本数差异大时,用WeightedRandomSampler按类频率的倒数加权,让每个 batch 里的身份分布更均匀。这一步对 ArcFace 收敛速度的影响比换优化器大得多。
3. 网络结构选型:四层 CNN 够不够,什么时候该上迁移学习
结构选型这件事,很多教程的默认答案是“上 ResNet50”,但在人脸识别场景里,参数量和身份数必须匹配。几十个人的门禁、上百人的课堂签到,用大模型只会过拟合到训练集那几个人的纹理细节上。这章给两套结构,一套从零手写看清每个张量的形状变化,一套直接改预训练网络,用来跑数据量更大的场景。
3.1 选型的三个判断依据
第一个依据是身份数量级。身份数在一百以内、每人十几张图,可用样本总量只有一两千,模型参数控制在百万以下比较安全。身份数上千、每人数十张,参数量可以放开到千万级。
第二个依据是硬件。四层小 CNN 在纯 CPU 上也能把推理压到十几毫秒,适合嵌入式闸机;ResNet18 级的主干在 CPU 上单张推理要几十毫秒,通常得配一个加速推理框架或者带 NPU 的板子。
第三个依据是你要不要可解释性。课程设计、答辩、给非技术方演示的场景,自己写的网络能把每一层的感受野、通道数讲清楚,迁移学习那套则绕不开“预训练权重里到底学了什么”这个问题。我的习惯是两套都写,用小 CNN 把流程跑通、把数据管线验证正确,再换成主干网络提精度,这样出问题时能立刻判断是管线问题还是模型问题。
3.2 四层 baseline:把感受野和通道数算清楚
结构上走的是“卷积-批归一化-激活”堆叠,四段之后接全局平均池化,最后输出一个固定维度的 embedding:
import torch import torch.nn as nn def conv_bn_relu(cin, cout, stride=1): return nn.Sequential( nn.Conv2d(cin, cout, 3, stride, 1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), ) class SmallFaceCNN(nn.Module): def __init__(self, emb_dim=128, num_classes=None): super().__init__() # 输入 112x112 -> 56 -> 28 -> 14 -> 7 self.features = nn.Sequential( conv_bn_relu(3, 32, 2), # 56x56x32 conv_bn_relu(32, 32), conv_bn_relu(32, 64, 2), # 28x28x64 conv_bn_relu(64, 64), conv_bn_relu(64, 128, 2), # 14x14x128 conv_bn_relu(128, 128), conv_bn_relu(128, 256, 2), # 7x7x256 conv_bn_relu(256, 256), ) self.pool = nn.AdaptiveAvgPool2d(1) self.emb = nn.Linear(256, emb_dim, bias=False) # embedding 头 self.cls = nn.Linear(emb_dim, num_classes) if num_classes else None def forward(self, x, return_emb=False): x = self.pool(self.features(x)).flatten(1) x = self.emb(x) if return_emb or self.cls is None: return x return self.cls(x)参数说明:emb_dim=128是比对用的特征维度,门禁这种千级以下身份的场景够用,身份数上万时常见做法是提到 256 或 512。bias=False加在卷积和 embedding 全连接上,是因为紧跟着 BN 或后续要做 L2 归一化,偏置会被抵消掉,留着只是多一组无用参数。AdaptiveAvgPool2d(1)而不是固定尺寸的AvgPool2d,好处是换输入分辨率时不用改结构,代价是空间信息全丢——对人脸识别来说这不是问题,比对靠的是整体特征。
这个网络总参数量不到 50 万,在 112×112 输入下,最后一层特征图是 7×7,单个神经元的有效感受野已经能覆盖大半张脸。再往下堆卷积,感受野超出人脸范围,学到的就是背景了,这也是为什么小数据场景下加深网络没有收益。
3.3 迁移学习:改预训练主干的最后一段
数据量上来了,换成带残差连接的主干更快出结果。改造的关键只有两处:把最后的分类全连接换成 embedding 层,以及决定冻结多少层。
import torch.nn as nn from torchvision import models def build_resnet_face(emb_dim=256, num_classes=None, freeze_until="layer2"): net = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 换成人脸常用的 3x3 小卷积 stem,减少 112 输入下的下采样过猛 net.conv1 = nn.Conv2d(3, 64, 3, 1, 1, bias=False) net.maxpool = nn.Identity() in_feat = net.fc.in_features net.emb = nn.Linear(in_feat, emb_dim, bias=False) net.cls = nn.Linear(emb_dim, num_classes) if num_classes else None net.fc = nn.Identity() # 只冻结浅层,深层跟着数据微调 frozen = True for name, m in net.named_children(): if name == freeze_until: frozen = False for p in m.parameters(): p.requires_grad = not frozen return net第一处改动是把 7×7 大核卷积换成 3×3,并去掉紧跟的maxpool。原版 ResNet 是为 224×224 设计的,在 112×112 输入上,那个maxpool会把特征图直接压到 14×14,再经过四段下采样只剩 4×4,位置信息丢得太快。第二处是冻结策略,freeze_until="layer2"的意思是conv1、bn1、layer1、layer2都冻住,只训layer3之后的部分。浅层学的是边缘、色块这类通用特征,小数据集上微调反而破坏;深层是语义特征,必须跟着自己的数据走。
提示:冻结 BN 层时,除了
requires_grad=False,还要调用net.eval()把 BN 切到推理模式,否则前向时仍会用当前 batch 的均值方差更新 running stats,冻结等于没冻。
3.4 输出层设计:分类头还是 embedding 头
两种头对应两种训练目标,别混着用还怪模型不收敛。
| 设计 | 输出 | 适用评估 | 缺点 |
|---|---|---|---|
| 纯分类头 | num_classes 维 logits | closed-set 分类准确率 | 新增人员要重训,无法处理陌生人 |
| 纯 embedding 头 | emb_dim 维特征 | 1:1 相似度比对 | 训练信号弱,收敛慢 |
| embedding + ArcFace | emb_dim 维特征 | open-set 验证 | 需要调 margin 和 scale |
实际工程里几乎都是第三种。结构上前向只走 embedding,分类头在训练时接在 embedding 后面参与损失计算,推理时丢掉。这样部署阶段拿到的就是一个特征提取器,新增人员只需注册几张图算特征入库,不用动模型。
4. 训练策略:ArcFace 的两个参数比网络结构更影响结果
结构定下来之后,能拉开差距的就剩损失函数和训练调度。人脸识别领域这些年结构变化不大,指标提升主要来自度量学习的损失设计。这章把 Softmax 和 ArcFace 的差别讲透,再给一份能直接跑的循环。
4.1 Softmax 为什么不够:可分不等于可分辨
普通 Softmax 只要求同类样本落在分类面正确的一侧,不要求它们聚得紧。结果就是训练集分类准确率很高,但同一个人的两张不同光照照片,特征余弦相似度可能只有 0.6,而另外两个人的相似度是 0.55,阈值根本卡不住。这就是“可分但不可分辨”。
解决办法是加角度间隔:在目标类别的角度上强行加一个惩罚 m,逼着模型把类内角度压小、类间角度拉大。ArcFace 用的是加法角度间隔,几何意义清晰,实现也简单。
4.2 ArcFace 的实现与 s、m 两个参数
import math import torch import torch.nn as nn import torch.nn.functional as F class ArcFaceHead(nn.Module): def __init__(self, emb_dim, num_classes, s=32.0, m=0.5): super().__init__() self.weight = nn.Parameter(torch.randn(num_classes, emb_dim)) nn.init.xavier_uniform_(self.weight) self.s = s # 缩放因子,控制 logits 量级 self.m = m # 角度间隔,单位弧度 def forward(self, emb, labels): # 特征和权重都做 L2 归一化,内积即余弦 cos = F.linear(F.normalize(emb), F.normalize(self.weight)) cos = cos.clamp(-1 + 1e-7, 1 - 1e-7) theta = torch.acos(cos) # 只在目标类别上加间隔 target = theta.gather(1, labels.view(-1, 1)) + self.m one_hot = torch.zeros_like(cos).scatter_(1, labels.view(-1, 1), 1.0) logits = torch.where(one_hot.bool(), torch.cos(target), cos) * self.s return logitss建议从 32 起,样本少、batch 小时降到 16~24,太大容易在前期梯度爆炸。m是真正影响类间分离度的参数,0.5 是通用起点;数据量在几千张以内时用 0.3~0.35 更稳,因为间隔太大前期根本学不动,损失会长时间卡在高位。身份数上千、每类样本充足,再往 0.5 以上推。
这段代码里torch.where那一行有个容易写错的地方:不能直接对整张 cos 矩阵加 m,那样非目标类别也被推开了,梯度方向就错了。另外clamp必须加,acos在 ±1 处导数是无穷大,不加这个保护,训练到中期会突然出现 NaN,属于典型的玄学崩训练。
4.3 训练循环:学习率、混合精度与早停
import torch from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR from torch.cuda.amp import autocast, GradScaler device = "cuda" if torch.cuda.is_available() else "cpu" model = SmallFaceCNN(emb_dim=128, num_classes=None).to(device) head = ArcFaceHead(128, num_classes=len(id2label), s=32.0, m=0.35).to(device) opt = AdamW(list(model.parameters()) + list(head.parameters()), lr=1e-3, weight_decay=5e-4) sched = CosineAnnealingLR(opt, T_max=60, eta_min=1e-5) scaler = GradScaler() for epoch in range(60): model.train() head.train() for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) opt.zero_grad(set_to_none=True) with autocast(): emb = model(imgs, return_emb=True) loss = torch.nn.functional.cross_entropy(head(emb, labels), labels) scaler.scale(loss).backward() scaler.unscale_(opt) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(opt) scaler.update() sched.step() # 每 5 轮在验证集上算一次 1:1 准确率,而不是看训练 loss if epoch % 5 == 4: acc, thr = evaluate_1to1(model, val_loader, device) print(f"epoch {epoch} val@1:1 {acc:.4f} thr {thr:.3f}")关键参数逐个说。weight_decay=5e-4配 AdamW 是解耦权重衰减的写法,比 Adam 里 L2 正则更干净,小数据场景下对抑制过拟合的作用很明显。T_max=60是余弦退火的总步数,如果中途早停,把T_max改成实际轮数重跑一遍,最后几轮的低学习率对收敛很关键。max_norm=5.0的梯度裁剪是 ArcFace 训练的保险丝,配合前面的clamp一起用,能把大部分 NaN 挡在门外。
混合精度这块,GradScaler必须成对使用scale/step/update,中间做梯度裁剪前要先unscale_,否则裁的是被放大过的梯度,等于没裁。这套写法在各类 GPU 上都能跑,CPU 上把autocast去掉即可。
早停的判据千万别用训练 loss。人脸识别的训练 loss 会一直降,因为它衡量的是分类边界,而你要的是特征分布。判据用验证集上的 1:1 准确率,或者更严格的 EER,详见第六章。
4.4 曲线诊断:三种形态对应三种问题
第一种,loss 前期就卡在log(num_classes)附近不动。原因是间隔 m 太大或者 s 太大,梯度被推到了饱和区。把 m 降到 0.2 先让它动起来,收敛后再逐步加到目标值,这种 warmup 式的做法在度量学习里很常见。
第二种,loss 平稳下降但验证集 1:1 准确率不涨甚至下滑。八成是过拟合,去看训练集和验证集的身份划分是否真的隔离了,再检查增强是不是太弱。数据量确实不够时,冻结更多层、把 embedding 维度降到 64,比加 Dropout 有效。
第三种,训练到某个点 loss 突然变 NaN。除了上面说的acos和保护,还有一种可能是某个 batch 里出现了全是同一身份的样本,类中心被拉爆。drop_last=True加WeightedRandomSampler基本能规避。
5. 避坑与排查:五条血泪经验
这章列的五个问题,都是我在实际项目里真实遇到过、并且排查花掉半天以上的。共同特点是:不报错、指标看着正常、上线才暴露。
5.1 数据侧的两个坑
现象一:验证集准确率 99%,换一批新员工就完全不认。原因:验证集和训练集共享了身份,模型在验证集上做的是“闭集分类”,跟部署时的“开集比对”是两回事。 解决:改成按身份切分,验证集里的每一个人在训练集里都不存在。评估指标同步换成 1:1 比对准确率,报告里注明用的是 open-set 协议。
现象二:训练集上同一个人的两张图,余弦相似度只有 0.5,比不同人之间还低。原因:近重复样本和低质量样本混在一起,且对齐失败的部分没有剔除。关键点检测在侧脸、遮挡时会给出离谱的坐标,estimateAffinePartial2D依然能算出一个矩阵,只是把脸拉变形了。 解决:对齐后加一道质量回检——把对齐结果再送一次检测,如果检测不到人脸或者关键点位置偏离模板超过 10 像素,直接丢弃。同时用感知哈希做近重复去重,同一个人在连续帧里的样本保留间隔至少 5 帧。
5.2 训练侧的两个坑
现象三:训练 loss 降到很低,但特征分布可视化出来是一坨,阈值怎么调都不对。原因:忘了对 embedding 做 L2 归一化,或者训练时归一化了、推理时忘了。ArcFace 的几何前提是特征落在超球面上,不归一化的话,s 缩放的就是模长而不是角度,整个间隔机制失效。 解决:把归一化写进模型的前向里,而不是放在损失函数里,这样导出模型时天然带着这一步。部署侧再算一次余弦相似度,输入一定是单位向量,可以断言abs(norm - 1) < 1e-3来做自检。
现象四:batch size 从 64 降到 8 之后(换小显存机器),同样的代码精度掉了一大截。原因:BN 在小 batch 上的均值方差估计极不稳定,running stats 偏差大,推理时用的统计量和训练时对不上。 解决:batch 小于 16 时,把主干里的 BN 换成 GroupNorm,或者冻结 BN 只用作推理。另一个办法是梯度累积,把 4 个 batch 的梯度累加后再更新,等效 batch 回到 32,代价是训练时间变长。测过这两条路,GroupNorm 在小数据上更省事。
5.3 评估与部署侧的一个坑
现象五:离线评估误识率 0.1%,上线后一周内出现两次误开门。原因:阈值是在验证集上按“准确率最高”挑的,而准确率这个指标对误识和拒识的代价是等权处理的。门禁场景里,误识的代价远高于拒识。 解决:改成看 ROC 曲线,按业务可接受的 FAR(误识率)反查阈值。比如要求 FAR ≤ 1e-3,就在验证集上找对应的点,接受此时的 FRR(拒识率)。这个阈值还要留一点余量,因为线上光照、角度分布跟验证集不完全一致。
注意:阈值的绝对值跟训练时的 s、m 强相关,换一次损失参数就得重新标定,不能沿用旧阈值。
6. 上线前的最后一公里:阈值标定与特征库自检
模型训完只是半成品,真正决定体验的是阈值和特征库。我通常的做法是先用验证集把所有身份两两配对,算出同人对和异人对的相似度分布,再画 ROC:
import numpy as np def pair_scores(model, samples, device, batch=64): """返回 (同人对分数, 异人对分数)""" feats, ids = [], [] model.eval() with torch.no_grad(): for i in range(0, len(samples), batch): chunk = samples[i:i + batch] imgs = torch.stack([val_tf(Image.open(p).convert("RGB")) for p, _ in chunk]) emb = model(imgs.to(device), return_emb=True) feats.append(torch.nn.functional.normalize(emb).cpu()) ids += [pid for _, pid in chunk] feats = torch.cat(feats) sim = feats @ feats.T same, diff = [], [] n = len(ids) for i in range(n): for j in range(i + 1, n): (same if ids[i] == ids[j] else diff).append(sim[i, j].item()) return np.array(same), np.array(diff)拿到两组分数后,按业务要求定点。下面这张表是常见的取值参考,具体数字一定要在自己的数据上跑,别抄:
| 业务场景 | 目标 FAR | 典型阈值(余弦) | 此时 FRR |
|---|---|---|---|
| 门禁闸机(1:1) | 1e-3 | 0.55 ~ 0.60 | 3% ~ 8% |
| 手机解锁(1:1) | 1e-4 | 0.62 ~ 0.68 | 8% ~ 15% |
| 课堂签到(1:N,N<50) | 1e-2 | 0.45 ~ 0.50 | 5% ~ 10% |
1:N 场景要注意一个额外效应:误识率随底库规模线性放大。底库一万人时,即使单次比对 FAR 只有 1e-3,一次搜索的期望误识次数也到了 10 次量级。所以底库超过几千人,必须加二次验证或者用更严格的排序策略——取 Top-1 之后,再要求 Top-1 与 Top-2 的分数差超过某个 margin,这个技巧在工程上很实用,成本几乎为零。
还有两件上线前必做的事。第一是特征库自检:把所有注册样本重新过一遍模型,确认同一身份内部的自相似度中位数在 0.7 以上,低于这个数说明这个人的注册照片质量不行,应该提示重新采集,而不是硬塞进库里。第二是通道一致性检查:训练用 RGB、均值 0.5 归一化,部署侧必须一模一样。我吃过一次亏,部署脚本用了 BGR 且没报错,整批比对分数整体平移了 0.1,阈值还是按旧数据定的,结果一半人刷不开门,排查了整整一个下午才发现是通道顺序。
活体这块也提一句。照片翻拍是门禁场景最常见的攻击方式,纯 CNN 特征比对对它没有抵抗力。低成本方案是在采集端要求配合动作(眨眼、转头),用多帧的时序一致性做判定;有条件的话再叠一个纹理分类的小模型,专门分辨屏幕摩尔纹和纸张反射。这部分不在本文的训练流程里,但真实项目绕不过去。
现在回头看,这套流程里最值钱的不是网络结构,而是数据划分和阈值标定这两步。从那以后我每次启动新的人脸识别项目,都会先把身份隔离的划分脚本和 1:1 评估函数写完,再去碰模型,哪怕模型还是那个四层小 CNN。希望帮到你。
本文还有配套的精品资源,点击获取