YOLOv5+ArcFace+活体检测:人脸识别四段式流水线工程实践

发布时间:2026/11/2 19:12:21
YOLOv5+ArcFace+活体检测:人脸识别四段式流水线工程实践 简介这份压缩包面向人脸识别入门与进阶实践者围绕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(keylambda f: f[area], reverseTrue) 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_size640, 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), interpolationcv2.INTER_LINEAR) canvas np.full((new_size, new_size, 3), color, dtypenp.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) / scalepad_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] ], dtypenp.float32) def align_face(img, kps, out_size112): kps: (5,2) 关键点顺序必须与 TEMPLATE 一致 src np.asarray(kps, dtypenp.float32) # 只估计相似变换拒绝剪切形变 M, _ cv2.estimateAffinePartial2D(src, TEMPLATE, methodcv2.LMEDS) if M is None: # 估计失败时退回简单裁剪保证流水线不断 x1, y1 src.min(axis0).astype(int) x2, y2 src.max(axis0).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), flagscv2.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_dim512, num_classes1000, margin0.5, scale64.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_classesself.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, axis0))) 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. 验收与答辩一张指标表加一段演示脚本到了验收环节最怕的是被问「你怎么证明这套东西是好的」答不上来就只能翻代码。我的习惯是提前准备一张指标表所有数字都有对应的脚本可以复现指标计算方式关注点检测召回标注框与预测框 IoU0.5 判对小脸子集单独统计识别 Top1底库内查询图取最高分按光照/角度分组EER正负样本相似度分布交点决定阈值取值FAR陌生人被误放比例底库外人群测试活体 APCER攻击样本被误判为真比例分打印/翻拍两类活体 BPCER真人被误判为攻击比例与 APCER 一起看端到端 P95 耗时单帧全流程耗时分母含预处理表里的数字最好都来自同一批测试数据别东拼西凑。答辩演示建议写成一个可交互的脚本左边摄像头实时画面带框和关键点右边显示特征比对的前三名和分数下面一行滚动显示活体分和结论码。评委最喜欢看的是拒绝的过程比如拿手机翻拍一张照片系统当场标红显示 SPOOF比展示一百次正确识别更有说服力。演示脚本里记得留一个开关可以强制关掉活体用来对比「有关卡」和「无关卡」的差别。这个对比能把设计意图讲得非常清楚。从那以后我每次做这类项目都强制在验收前跑一遍「换设备、换光照、换陌生人」这三项回归跑完才敢把阈值定下来。希望帮到你需要这套 YOLOv5 检测加 ArcFace 识别、含活体门控的完整学习实践工程拿走照着改就行。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询