简介:这份压缩包面向人脸识别入门与进阶实践者,围绕YoloV5人脸检测、ArcFace特征提取和活体检测三条技术线组织学习素材,适合希望从算法原理走向系统搭建的开发者参考。包内共54个文件,以26个Python源码为主体,辅以YAML配置、JPG示例图、TXT说明、PTH权重、Shell脚本及编译缓存等,整体约3.4MB,涵盖主程序、模型目录、工具函数、训练与测试脚本、配置文件和预训练权重等模块,目录结构便于按功能检索。已有179人学习下载。读者可据此梳理从人脸检测、特征比对到活体判别的完整项目架构,结合ArcFace角度边界损失与MiniFASNet活体模型理解关键实现,并借助训练、测试脚本和示例数据完成理论到代码的对照实践。对照片、视频等非真实人脸攻击的防范场景也有对应实现线索,适合课程设计、毕业设计原型和算法复现时参考,积累可复用的人脸识别工程经验。
1. 人脸识别方案最容易翻车的地方,不是选错模型
人脸识别这类项目最常见的翻车方式,不是模型选错,而是把整张现场图直接塞进一个分类网络,训练集上 99%,换一台摄像头就认不出人。根因是缺了环节:脸在哪、有没有摆正、这个人是谁、这张脸是不是真的。这份以 YOLOv5 做检测、ArcFace 做特征比对、外加活体检测的学习实践工程,把这几件事串成一条四段式流水线,每段都有明确的输入输出和失败降级。它适合正在做人脸识别毕设或课程设计、需要一套既能跑通又能讲清楚的人。下面按「需求怎么立、架构怎么划、模块怎么写、坑怎么避、最后怎么验收」的顺序拆开讲。
2. 总体架构:四段式流水线与四个模块的边界
2.1 数据流:一张图从进来到出身份要过四道关
一套能交付的人脸识别系统,从摄像头取到一帧 BGR 图像,到最终吐出一个身份字符串,中间要过四道关:检测、对齐、特征提取、判决。每一关都有独立的输入和输出,卡住哪一关,返回的错误码就不同,后面排查问题时不用靠猜。我一般会在项目一开始就把这张表定下来,写进 README,后面所有代码都围着它转:
| 关卡 | 输入 | 输出 | 失败时的降级 |
|---|---|---|---|
| 检测 | 原始 BGR 帧 | 人脸框列表 + 五点关键点 + 置信度 | 返回空列表,标记 NO_FACE |
| 对齐 | 单个人脸框 + 关键点 | 112×112 的对齐图 | 关键点置信度低,退回框裁剪 + 缩放 |
| 特征 | 对齐图 | 512 维 L2 归一化向量 | 模型异常返回 None,标记 FEAT_ERROR |
| 判决 | 特征 + 活体分 | 身份 + 相似度 + 结论码 | 低于阈值返回 UNKNOWN |
结论码这一列值得单独说一说。很多同学的项目里,识别不出来就返回一个空字符串,测试时看不出任何信息,验收时被问“这张为什么认不出”就卡壳。把结论拆成 OK、NO_FACE、LOW_QUALITY、SPOOF、UNKNOWN 五种,跑一批测试图之后统计每种的比例,哪一环最弱一目了然。这算是血泪经验:错误码设计得好,调参少熬三个晚上。
四个模块之间只传递数据结构,不共享全局变量。检测模块不知道 ArcFace 是什么,特征模块也不关心图是摄像头来的还是文件读的。这条边界守住,后面想把活体检测插在特征提取之前还是之后,都只是改一行调用顺序的事。
2.2 工程目录与配置项:把阈值和路径从代码里赶出去
刚上手时最容易把所有参数写死在 py 文件里,检测阈值 0.5、识别阈值 0.6,散落在十几个地方。等到换一个场景,人脸普遍偏小,检测召回掉下去,你得翻五个文件去改。正确的做法是先定目录和配置,再写逻辑:
face_pipeline/ ├── configs/ │ └── default.yaml # 所有阈值、路径、尺寸集中在这里 ├── models/ │ ├── det_best.pt # 检测权重 │ ├── rec_backbone.pt # 识别特征提取权重 │ └── live_cls.pt # 活体分类权重 ├── core/ │ ├── detector.py # 检测 + NMS │ ├── aligner.py # 五点仿射对齐 │ ├── embedder.py # 512 维特征 │ └── liveness.py # 活体打分 ├── pipeline.py # 四段串联入口 └── gallery/ # 底库特征,npy 格式对应的配置长这样,每一项后面都写清楚调大调小会发生什么:
detector: weights: models/det_best.pt input_size: 640 # letterbox 后的边长,太大速度掉得明显 conf_thres: 0.25 # 低于此值直接丢框,误检多就往上调 iou_thres: 0.40 # 人脸密集场景建议降到 0.3~0.35 min_face_px: 48 # 原图上小于这个边长的脸不送识别 recognition: weights: models/rec_backbone.pt embedding_dim: 512 match_thres: 0.38 # 余弦相似度阈值,必须用验证集标定 margin_thres: 0.06 # top1 与 top2 的最小差距,防止撞脸 liveness: weights: models/live_cls.pt frame_thres: 0.65 # 单帧活体分阈值 window: 7 # 多帧融合窗口长度 pass_ratio: 0.6 # 窗口内至少 60% 帧判定为活体才放行match_thres这一项千万别照抄别人的 0.6。ArcFace 系列模型输出的相似度分布跟训练数据规模、backbone 强相关,同一个阈值在 A 模型上误拒一半人,在 B 模型上误放一堆陌生人。正确做法是拿你自己的底库和陌生人集跑一遍,画 ROC 曲线,取等错误率附近的点,再往误拒方向偏 0.02 留余量。
2.3 推理入口:把四段串成一个可测试的函数
有了模块和配置,串联层要写得足够薄,薄到任何人看一遍就知道数据怎么流动:
import time import numpy as np from core.detector import Detector from core.aligner import align_face from core.embedder import Embedder from core.liveness import LivenessScorer class FacePipeline: def __init__(self, cfg, gallery): self.det = Detector(cfg["detector"]) self.emb = Embedder(cfg["recognition"]) self.live = LivenessScorer(cfg["liveness"]) self.cfg = cfg self.gallery = gallery # dict: name -> 512 维已归一化向量 def run(self, frame_bgr): t0 = time.time() # 第一关:检测。只保留面积最大的前 N 张脸 faces = self.det(frame_bgr) if not faces: return {"code": "NO_FACE", "cost_ms": (time.time() - t0) * 1000} faces.sort(key=lambda f: f["area"], reverse=True) face = faces[0] if face["side"] < self.cfg["detector"]["min_face_px"]: return {"code": "LOW_QUALITY", "cost_ms": (time.time() - t0) * 1000} # 第二关:对齐,关键点不可信时退回框裁剪 aligned = align_face(frame_bgr, face["kps"]) # 第三关:特征提取,返回已 L2 归一化的 512 维向量 feat = self.emb(aligned) # 第四关:活体门控 + 底库检索 live_score = self.live.score(aligned) if live_score < self.cfg["liveness"]["frame_thres"]: return {"code": "SPOOF", "live": live_score, "cost_ms": (time.time() - t0) * 1000} name, sim, margin = self._match(feat) code = "OK" if sim >= self.cfg["recognition"]["match_thres"] else "UNKNOWN" return {"code": code, "name": name, "score": sim, "margin": margin, "live": live_score, "cost_ms": (time.time() - t0) * 1000} def _match(self, feat): # 底库向量预先归一化,这里直接做内积即余弦相似度 names, sims = list(self.gallery.keys()), [] for n in names: sims.append(float(np.dot(feat, self.gallery[n]))) order = np.argsort(sims)[::-1] top1 = sims[order[0]] top2 = sims[order[1]] if len(order) > 1 else 0.0 return names[order[0]], top1, top1 - top2这段代码里有两个容易被忽略的设计点。一个是cost_ms打点,每段耗时都记下来,端到端跑 200 张图后统计 P50 和 P95,你就知道瓶颈在检测还是识别,不用凭感觉优化。另一个是margin,也就是 top1 和 top2 的差。真实场景里经常出现两个长得很像的人,top1 相似度 0.42 过了阈值,但其实底库里另一个人才是对的,加上 margin 约束能把这类撞脸挡掉,一般取 0.05 到 0.08。
side取的是人脸框两条边里较短的那条,不是面积开方。侧脸和戴口罩的场景下,框会变得又细又长,用面积判断会放过一堆低质量脸。
3. 检测与对齐:YOLv5 的框、关键点和仿射变换
3.1 检测头与输入尺寸:人脸检测为什么不能直接用现成配置
YOLOv5 原版的锚框是在通用目标数据集上聚类出来的,把人脸当成一个类别直接开训,小脸召回通常只有六七成。原因很直接:一张 1080P 的监控画面里,站在五米外的人脸可能只有 40 像素宽,而输入被 letterbox 压到 640 之后,这张脸只剩 20 多像素,特征图上早就糊成一团了。
工程上有三个常规处理。第一,把输入尺寸提到 960 或 1280,代价是推理耗时按面积平方增长,要在速度预算里权衡。第二,对大分辨率画面做切块推理,比如按 2×2 切图分别检测再统一做 NMS 合并,小脸召回提升明显,代价是重复框变多。第三,在自己的数据上重新聚类锚框,人脸大多是接近正方形的宽高比,聚类出来的锚框会比默认配置扁得多。
NMS 的 IoU 阈值也要跟着改。通用检测默认 0.45,但人脸在合影、排队场景下重叠严重,0.45 会把紧挨着的两张脸合并成一张。调到 0.3 到 0.35 更稳妥,代价是同一张脸偶尔出两个框,这时候再用「相同关键点距离过近」做一次去重。
预处理的 letterbox 一定要在训练和推理两端保持一致,这是最常见的不一致来源:
import cv2 import numpy as np def letterbox(img, new_size=640, color=(114, 114, 114)): """等比缩放并补边,保持宽高比不变,返回图和缩放/偏移参数""" h, w = img.shape[:2] scale = min(new_size / h, new_size / w) nh, nw = int(round(h * scale)), int(round(w * scale)) resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR) canvas = np.full((new_size, new_size, 3), color, dtype=np.uint8) top = (new_size - nh) // 2 left = (new_size - nw) // 2 canvas[top:top + nh, left:left + nw] = resized # 后处理要把框坐标还原回原图,必须用到 scale 和 pad return canvas, scale, (left, top)后处理还原框的时候,公式是(x - pad_x) / scale,pad_x和pad_y必须分别用上,只减一个会导致框整体偏移。这类偏移在可视化时非常明显,框会统一往一个角飘,但如果不画图只看数值指标,很容易被忽略。
3.2 五点关键点对齐:仿射变换的几个参数怎么定
对齐这一步,决定后面特征质量的上下限。同一张脸,眼睛位置差 5 个像素,ArcFace 出来的相似度可能就从 0.55 掉到 0.38。常用做法是取左眼、右眼、鼻尖、左嘴角、右嘴角五个点,与 112×112 的标准模板做相似变换。
这里我建议用estimateAffinePartial2D而不是完整的仿射变换,原因是它只包含旋转、等比缩放和平移,不允许剪切和拉伸。人脸在自然姿态下不会发生剪切形变,允许剪切反而会让模型把侧脸的畸变学进去,泛化变差。完整实现大概是十几行:
import cv2 import numpy as np # ArcFace 常用的 112x112 五点模板,顺序:左眼、右眼、鼻尖、左嘴角、右嘴角 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, kps, out_size=112): """kps: (5,2) 关键点,顺序必须与 TEMPLATE 一致""" src = np.asarray(kps, dtype=np.float32) # 只估计相似变换,拒绝剪切形变 M, _ = cv2.estimateAffinePartial2D(src, TEMPLATE, method=cv2.LMEDS) if M is None: # 估计失败时退回简单裁剪,保证流水线不断 x1, y1 = src.min(axis=0).astype(int) x2, y2 = src.max(axis=0).astype(int) crop = img[max(0, y1):y2, max(0, x1):x2] return cv2.resize(crop, (out_size, out_size)) return cv2.warpAffine(img, M, (out_size, out_size), flags=cv2.INTER_LINEAR)模板点的顺序必须和检测输出的关键点顺序严格一致。这两处顺序错位是新手最常踩的坑之一,表现是对齐后的脸被旋转了 90 度或者上下颠倒,但检测框看起来完全正常,光看框根本发现不了。
侧脸场景还需要额外判断。当左右眼关键点的距离小于某个比例,或者鼻子偏离中轴线太远,说明人脸偏转角度过大,这时候对齐出来的图本质上是畸变的,送进识别模型只会污染底库。我一般在aligner里加一个姿态评分,超过阈值直接返回 LOW_QUALITY,宁可拒掉也不要错认。
3.3 检测失败与多脸场景的降级策略
流水线跑起来之后,一定会遇到几种非理想输入,提前想好降级比事后补丁省事。
画面里没人脸,直接返回 NO_FACE,不要试图用全图去算特征。画面里有好几张脸,按面积排序取最大的一张作为主脸,其余丢弃,这是单人核验场景的标准做法;如果是多人通行场景,就得引入简单的跟踪,给每个人分配 ID,避免每帧都在不同人脸之间跳。
人脸过大占据整幅画面时,letterbox 会把它压缩得很小,反而丢细节。这种情况干脆跳过检测,直接把整帧缩放到 112 送识别,速度还更快。
光线极暗的帧,先做一次亮度评估,均值低于 30 就标记 LOW_QUALITY 并提示补光。这个判断放在检测之前,能省掉一次无意义的模型前向,实测能降低不少无效计算。
4. 身份判决:ArcFace 特征比对与活体门控的双关卡
4.1 ArcFace 的加性角度间隔:为什么它比普通分类更适合开集比对
普通 softmax 分类器训练出来的特征,类内紧凑度不够,换个没见过的类就乱。ArcFace 的核心是在角度空间上加一个间隔 m,把同类特征往同一个方向压紧,同时把不同类之间推出至少 m 的角度间隙。这样得到的 512 维向量,余弦相似度就直接具备判别意义,底库新增一个人也不影响其他人的特征分布,非常适合开集场景。
训练侧的关键实现是这样一段损失,核心只有几步:权重归一化、特征归一化、求角度、加间隔、乘缩放系数:
import math import torch import torch.nn as nn import torch.nn.functional as F class ArcFaceHead(nn.Module): def __init__(self, emb_dim=512, num_classes=1000, margin=0.5, scale=64.0): super().__init__() self.weight = nn.Parameter(torch.randn(num_classes, emb_dim)) nn.init.xavier_uniform_(self.weight) self.margin = margin # 角度间隔,0.3~0.5 之间调 self.scale = scale # 缩放系数,一般固定 64 # 预先算好 cos(m) 和 sin(m),避免每次前向重复计算 self.cos_m, self.sin_m = math.cos(margin), math.sin(margin) def forward(self, feat, label): # 特征和权重都做 L2 归一化,内积即余弦 feat = F.normalize(feat) w = F.normalize(self.weight) cos_t = F.linear(feat, w).clamp(-1 + 1e-7, 1 - 1e-7) sin_t = torch.sqrt(1.0 - cos_t ** 2) # cos(θ+m) = cosθcos m − sinθ sin m cos_m = cos_t * self.cos_m - sin_t * self.sin_m # 只对目标类加间隔,其余类保持原角度 one_hot = F.one_hot(label, num_classes=self.weight.size(0)).float() logits = (one_hot * cos_m + (1.0 - one_hot) * cos_t) * self.scale return F.cross_entropy(logits, label)margin和scale这两个参数是玄学区。margin 调到 0.6 以上,训练前期 loss 掉不下去甚至发散;调到 0.2 以下,类间区分度又不够,阈值很难标定。0.5 配 scale 64 是个稳妥起点,数据量小的时候先降到 0.35 跑通再说。
4.2 底库检索与相似度阈值标定
底库不建议一人一图。同一个人注册三到五张不同光照、不同角度的图,各自提特征后取平均再归一化,实测比单图注册的等错误率能降一截。存成 npy 文件,配合一个记录姓名的 json,加载时一次性读进内存,几万人的底库做内积检索也就毫秒级。
阈值标定要单独做,不能拿注册图自己跟自己比出来的 0.9 当参考。标准流程是:构造正样本对,即同一个人的不同图片;构造负样本对,即不同人的图片。两组各算一遍相似度,画两条分布曲线,找到交点就是等错误率对应的阈值。工程上我会在这个点的基础上,把阈值往误拒方向挪 0.02 到 0.03,因为实际使用中用户被误拒还能再试一次,被误放进来后果更严重。
import numpy as np def calibrate(genuine, impostor): """genuine/impostor: 两组余弦相似度数组,返回 EER 与推荐阈值""" thr = np.linspace(0, 1, 400) frr = [(genuine < t).mean() for t in thr] # 真同人被拒的比例 far = [(impostor >= t).mean() for t in thr] # 陌生人被放的比例 err = np.abs(np.array(frr) - np.array(far)) idx = int(np.argmin(err)) return (frr[idx] + far[idx]) / 2, thr[idx] - 0.02 # 略微向误拒方向偏移返回两个值,一个是等错误率用于写进报告,另一个是推荐阈值直接写进配置。这个脚本我一般会留在工程里,换模型之后重新跑一遍,比手拍阈值靠谱得多。
4.3 活体检测:单帧判据与多帧一致性的组合
活体检测要挡的三类攻击很明确:打印照片、屏幕翻拍、面具头模。对应到 RGB 单帧上,各有各的破绽。打印照片的纸张纹理会让高频分量偏弱,边缘锐度不自然;屏幕翻拍会出现摩尔纹,在频域上表现为规律的亮点;面具类攻击最大的问题往往是微表情缺失。
单帧分类模型能挡掉大部分低质量攻击,但对高质量翻拍效果有限,所以要叠一层多帧一致性。真人在连续帧里,人脸框位置有细微抖动,眼睛有轻微开合,头部有自然小幅度转动。屏幕是固定的,框抖动几乎为零;视频回放虽然也有抖动,但在光流上会出现不自然的跳帧。
def fuse_liveness(frame_scores, box_centers, cfg): """单帧分 + 运动一致性融合,返回最终活体判定""" ratio = float((np.array(frame_scores) > cfg["frame_thres"]).mean()) # 相邻帧人脸中心位移的方差,太小说明是固定图,太大说明是镜头晃动 centers = np.array(box_centers) motion = float(np.var(np.diff(centers, axis=0))) motion_ok = 0.4 < motion < 40.0 if ratio >= cfg["pass_ratio"] and motion_ok: return True, ratio, motion return False, ratio, motion这段逻辑不复杂,但参数需要拿现场数据重新标。motion的下限取决于摄像头帧率和分辨率,上限取决于是手持设备还是固定支架。固定支架场景下 40 可能太宽松,手持场景下 40 又太紧,只能实测。
另外提醒一点,活体检测的阈值不能为了「防住攻击」一味往高调。攻击拦截率 99.9% 但真人误拒率 20% 的系统,在实际场景里根本没法用。验收时两个指标必须一起看,这点后面还会展开。
5. 避坑与排查:五个最容易在验收前翻车的细节
5.1 换个摄像头就认不出:颜色空间和归一化不一致
现象很典型,开发机上跑得好好的,换到另一台设备或者换一段测试视频,相似度整体掉 0.1 以上,几乎所有人都在阈值边缘。
原因是 OpenCV 读进来默认是 BGR,而训练时用的是 RGB,中间某个环节少了一次通道转换;或者归一化参数不统一,训练用 ImageNet 的均值和方差,推理用了 0.5 的标准差。这类问题最阴险的地方是不会报错,只是效果悄悄变差。
解决办法是把预处理逻辑收成一个函数,训练和推理共用同一份代码,禁止各写一份。输入统一约定为 BGR,转换动作放在模型封装层内部。上线前用同一张图跑训练侧和推理侧的特征提取,余弦相似度应该大于 0.999,低于这个值就说明前后处理有偏差,先修这个再谈调参。
5.2 阈值在 A 机器好用、B 机器全拒:底库和阈值漂了
现象是同一套代码,换一批注册数据之后,原本 0.38 的阈值开始大量拒真。
原因通常是注册图的质量和查询图的质量不同分布。注册时用的是高清证件照或强光正脸,查询时是走廊监控的侧光,两边特征自然对不齐。另一个常见来源是换了识别模型权重,但配置里的阈值没跟着重新标定。
解决办法是写死一条规矩:任何一次模型替换或底库重建,必须重新跑一遍标定脚本,输出新的 EER 和推荐阈值,写进配置并记在版本变更里。标定集要包含真实场景的查询图,不能全用注册图自娱自乐。这条规矩看着笨,但能省掉无数“昨天还好好的今天就坏了”的排查时间。
5.3 活体把真人也拒了:多帧判据对光照突变太敏感
现象是用户在门口正常站立却被反复判定为攻击,尤其是人从暗处走到灯光下的那几帧。
原因是多帧窗口里混进了亮度剧烈变化的帧,单帧活体分类器在过曝或欠曝时输出不稳,窗口内通过比例掉到 0.6 以下,整段被判死。
解决办法有两层。一是加一个质量门控,亮度均值或对比度超出合理范围的帧直接不进窗口,不计入分母。二是把固定的window换成滑动窗口,最近 7 帧里满足条件的帧数够了就放行,不要死守固定长度。实测这两条改完,真人误拒率能降一个量级,而攻击拦截率基本不变。
5.4 同一个人相似度忽高忽低:检测框抖动带崩了对齐
现象是同一个人在连续帧里,识别分数在 0.35 到 0.55 之间跳,一会儿过一会儿不过。
根因在关键点。检测框每次有 2 到 3 个像素的抖动,五个关键点跟着抖,仿射变换的旋转角度也会小幅变化,对齐出来的图每次都略有不同,特征自然不稳定。
解决办法是在检测后加时序平滑。跟踪到同一个人的框,用前几帧的框位置做指数滑动平均,关键点同样处理。平滑系数取 0.6 到 0.7,太高会有拖影,太低没效果。另一种做法是对齐之后做一次固定的随机裁剪增强再提特征,对多次结果取平均,代价是耗时翻倍。前者更适合实时场景。
5.5 指标好看得不像话:同一个人不同帧被分到了训练和测试
现象是验证集准确率 99.5%,一上现场就掉到 70%。
原因只有一个,数据划分做错了。同一个人的不同帧、同一个视频的不同片段被随机分到了训练集和测试集,模型实际上是在「认人」而不是在「学特征」,指标虚高得离谱。
解决办法是划分数据时以身份为单位,同一个人只出现在一个集合里。训练集的人和测试集的人完全不重叠,这才是真实的开集场景。更严格一点,再留一批「底库外」的陌生人做误放测试,也就是这些人从来没进过训练集也没注册过,专门用来测 FAR。这个数字才是验收时最有说服力的。
6. 验收与答辩:一张指标表加一段演示脚本
到了验收环节,最怕的是被问「你怎么证明这套东西是好的」,答不上来就只能翻代码。我的习惯是提前准备一张指标表,所有数字都有对应的脚本可以复现:
| 指标 | 计算方式 | 关注点 |
|---|---|---|
| 检测召回 | 标注框与预测框 IoU>0.5 判对 | 小脸子集单独统计 |
| 识别 Top1 | 底库内查询图取最高分 | 按光照/角度分组 |
| EER | 正负样本相似度分布交点 | 决定阈值取值 |
| FAR | 陌生人被误放比例 | 底库外人群测试 |
| 活体 APCER | 攻击样本被误判为真比例 | 分打印/翻拍两类 |
| 活体 BPCER | 真人被误判为攻击比例 | 与 APCER 一起看 |
| 端到端 P95 耗时 | 单帧全流程耗时 | 分母含预处理 |
表里的数字最好都来自同一批测试数据,别东拼西凑。答辩演示建议写成一个可交互的脚本,左边摄像头实时画面带框和关键点,右边显示特征比对的前三名和分数,下面一行滚动显示活体分和结论码。评委最喜欢看的是拒绝的过程,比如拿手机翻拍一张照片,系统当场标红显示 SPOOF,比展示一百次正确识别更有说服力。
演示脚本里记得留一个开关,可以强制关掉活体,用来对比「有关卡」和「无关卡」的差别。这个对比能把设计意图讲得非常清楚。
从那以后我每次做这类项目,都强制在验收前跑一遍「换设备、换光照、换陌生人」这三项回归,跑完才敢把阈值定下来。希望帮到你,需要这套 YOLOv5 检测加 ArcFace 识别、含活体门控的完整学习实践工程,拿走照着改就行。
本文还有配套的精品资源,点击获取