
简介一份人脸识别学习实践压缩包整合YoloV5目标检测、ArcFace特征提取与活体检测三大模块面向想要从理论走向实操的开发者与算法爱好者。资源共54个文件包体仅3.4MB以26个Python源码文件为主体覆盖模型训练、测试、数据生成及识别推理完整流程同时包含7个YAML配置、2个PTH预训练权重及多张测试图片便于直接运行与二次修改。目前已有175人学习。内容提供清晰的目录结构和说明文档可逐步理解YoloV5如何快速定位人脸、ArcFace如何通过角度损失增强特征区分度以及活体检测如何防范照片与视频攻击配套代码示例与项目架构让学习者能对照实现整套人脸验证系统从数据准备到模型评估均有迹可循适合作为人脸识别入门到进阶的实战参考资料。1. 人脸识别项目源码打开之前先把“检测—识别—活体”这条链路想清楚一个门禁项目最怕的不是不认识人而是拿一张 A4 打印照片贴在镜头前就能开门。我当时接某个公司项目时第一版只用了 ArcFace 做识别模型在公开测试集上指标非常漂亮真机演示却直接翻车训练时的人脸识别被照片轻松突破管理员在验收现场脸色很难看。问题其实不在 ArcFace 本身而是我没有提前把任务拆成“先检测、再活体、后识别”三段。这套名为“人脸识别_YoloV5_ArcFace_活体检测_学习实践”的方案本质就是在同一个摄像头采集链路里让 YoloV5 先把人脸从画面里抠出来让活体模型判断眼前是真人还是照片或屏幕最后才交给 ArcFace 做特征比对。本文适合本地部署、离线识别、想在量产设备上自己完成训练和调参的工程师目标是把这条链路真正落地而不是只把 demo 跑通。2. 从 YoloV5 把人脸从场景里“抠”出来检测模型选型、训练与推理要点2.1 为什么检测不能省以及我为什么选 YoloV5大多数人脸识别模型在训练时都默认输入是已经对齐好的人脸比如 112×112 的 RGB 图。如果在真实场景里直接把整帧摄像头画面缩放到这个尺寸送进 ArcFace背景、身体、灯光都会参与计算识别率会下降得非常快。所以第一步必须有一个独立的检测模型把脸找出来再做裁剪和后续处理。检测模型省不掉它同时还承担了“找脸位置”的任务为活体检测和识别提供输入框。我一般会选 YoloV5 而不是 RetinaFace 或 MTCNN三个理由。第一YoloV5 的工程生态成熟训练、验证、导出 ONNX、量化都有现成脚本团队接手成本低遇到问题搜到的资料也多。第二MTCNN 虽然轻量但级联结构在小脸上召回率一般RetinaFace 精度高但配置和导出链相对重部署时的环境依赖容易出问题。第三YoloV5 在这里只做单类检测目标就是人脸一个类别它的泛化能力足够支撑门禁、考勤这类固定点位摄像头场景。迁移到新点位时往往只需要少量新数据微调就能恢复精度。2.2 把检测数据集整理成 YOLO 格式坐标转运与标注注意事项不管你是用公开的人脸检测数据集还是自己标注了一部分业务数据最终都要转成 YOLO 需要的 txt 标注。常见的数据集标注格式是左上角坐标加宽度高度而 YOLO 需要的是每个框的中心点坐标和宽高全部归一化到 0 到 1 之间。类别 id 这里只有 0代表人脸。转换脚本可以写得很短但有个细节容易被忽略标注框可能部分越出图像边界转出来会变成负坐标或大于 1 的坐标需要做边界裁剪。def convert_to_yolo(img_w, img_h, x, y, w, h): # 输入: x, y 为左上角坐标, w, h 为框宽高, 单位是像素 # 输出: YOLO 格式的一行标注, 包含类别 id 和归一化后的 x_center, y_center, w, h x_center (x w / 2.0) / img_w y_center (y h / 2.0) / img_h w_norm w / img_w h_norm h / img_h # 防止标注越界导致训练时 loss 为 NaN x_center min(max(x_center, 0.0), 1.0) y_center min(max(y_center, 0.0), 1.0) w_norm min(w_norm, 1.0) h_norm min(h_norm, 1.0) return f0 {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}\n这段代码每次只处理一个标注框批量处理时循环调用即可。注意我只对中心坐标做了边界裁剪没有让越出的边收缩回图像内是因为真实场景下手动标注的少量越界框影响可控但如果你的数据集里大量存在这种框建议在标注阶段就修正不要把希望寄托在代码上。另一个关键点是数据清洗筛掉小于 20 像素的人脸框、严重模糊的框以及遮挡过半的框这些样本在训练阶段会让模型学到错误的特征。如果业务场景摄像头装在侧面务必单独采集头部旋转 30 度以上的数据补进去否则后面会遇到漏检问题。2.3 YoloV5 训练参数怎么设从 640 到 1280 的取舍训练参数我一般不改太多主要关注输入尺寸、batch size 和训练轮数。默认配置里--img 640适合大多数场景但如果你的摄像头画面中的人脸普遍偏小比如闸机装在 1.5 米高、人脸只有 40 像素宽640 输入很可能漏检。常见做法是把输入尺寸提到 1280代价是推理速度明显下降在 CPU 设备上可能从每秒 20 帧掉到 8 帧。参数我的默认值调整建议img640小脸多则 1280先跑通再调batch8-32由显存决定批量太小易震荡epochs100作为对照组看 mAP 是否收敛patience30提前停止防止过拟合hyp默认不要一上来就大改学习率训练时记得打开 mosaic 增强它能把四张图拼在一起等于强制模型学习小尺寸目标对人脸检测非常有效。如果你的业务数据只有几千张建议额外做一次水平翻转增强但不要做上下翻转因为现实中不会出现倒立的人脸。训练完成后不要只看 mAP要看验证集上的 PR 曲线在低置信度区间是否有明显掉点。掉点严重说明模型对困难样本不够鲁棒这时候单纯降置信度阈值救不回来要回去补数据。2.4 推理阶段把 bbox 转成识别输入扩边裁剪是第一个隐藏参数检测模型输出的人脸框是紧贴脸部的直接裁剪下来送进活体或识别模型会损失额头、下巴和部分脸颊轮廓。ArcFace 这类模型虽然是对齐后人脸训练的但裁剪过紧仍然会造成特征漂移。我一般会在推理代码里对 bbox 做扩边处理扩边比例是一个需要认真调的隐藏参数。import cv2 def detect_and_crop(frame, model, conf_thres0.4, iou_thres0.45, expand0.3): # frame 是 BGR 图像, model 是训练好的 YoloV5 模型 results model(frame, conf_thresconf_thres, iou_thresiou_thres) boxes results.xyxy[0].cpu().numpy() # 每一行是 x1, y1, x2, y2, 置信度, 类别 crops [] for x1, y1, x2, y2, score, _ in boxes: w x2 - x1 h y2 - y1 # 以 bbox 中心为基准向四周扩边保留完整人脸区域 x1 max(0, x1 - expand * w) y1 max(0, y1 - expand * h) x2 min(frame.shape[1], x2 expand * w) y2 min(frame.shape[0], y2 expand * h) crop frame[int(y1):int(y2), int(x1):int(x2)] crops.append((crop, (int(x1), int(y1), int(x2), int(y2)), score)) return cropsexpand我通常取 0.3 到 0.5。取 0.3 适合框本身比较准的情况取 0.5 会把背景更多地卷进来对后续活体模型有利但可能让识别模型分心。conf_thres建议先设 0.4如果发现侧脸漏检降到 0.25 试试。需要注意的是降置信度阈值会带来更多误检框如果这些误检框进入了活体和识别流程会把整条链路拖慢必须在业务层对低置信度框做额外拦截。3. ArcFace 特征提取把一张脸变成 512 维向量比对的瓶颈在阈值3.1 ArcFace 凭什么让特征向量更好比对ArcFace 在训练时给分类损失增加了一个角度间隔让同一个人的特征在超球面上聚得更紧不同人的特征推得更远。它训练时看起来是分类问题每一类对应一个人但推理时分类头被丢掉我们拿到的是倒数第二层输出的 embedding 特征向量一般是 512 维。这个向量可以被 L2 归一化映射到单位球面上然后两个向量之间的余弦相似度就代表了两个人脸是不是同一个人。理解训练和推理的差异非常重要。很多人把 ArcFace 当成一个黑匣子只知道输入图片输出相似度遇到问题就调阈值忽略了 embedding 本身对输入分布很敏感。实际工程里同一个人在不同摄像头、不同光照下的特征向量会有明显偏移这就是为什么必须在真实设备上采集数据重新校准阈值而不是直接沿用某个开源 Demo 里的 0.5 或 0.6。3.2 特征提取代码与前处理顺序RGB/BGR 和归一化必须严格复刻ArcFace 模型的推理代码本身不难难在前处理必须和训练时完全一致。我见过不止一个项目因为颜色顺序或归一化方式不一致导致同一个人的两张照片相似度从 0.7 跌到 0.3。下面是封装好的提取器示例。import cv2 import numpy as np class ArcFaceExtractor: def __init__(self, engine_path, input_size(112, 112)): # 用 ONNX Runtime 加载模型, 省去 PyTorch 环境依赖 import onnxruntime as ort self.session ort.InferenceSession(engine_path) self.input_size input_size def preprocess(self, img): # 注意顺序: BGR 转 RGB, 再缩放, 再归一化 img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, self.input_size, interpolationcv2.INTER_AREA) img img.astype(np.float32) img (img - 127.5) / 127.5 # 很多公开权重使用这个归一化, 以实际训练脚本为准 img np.transpose(img, (2, 0, 1)) # 把 HWC 变成 CHW return np.expand_dims(img, 0).astype(np.float32) def get_embedding(self, face_crop): input_tensor self.preprocess(face_crop) # 取第一个输出, 展开成一维特征向量 feat self.session.run(None, {self.session.get_inputs()[0].name: input_tensor})[0] feat feat.flatten() # 统一做 L2 归一化, 后续余弦相似度简化为内积 norm np.linalg.norm(feat) 1e-8 return feat / norm预处理里有三个最容易踩坑的顺序问题。第一训练时如果用 OpenCV 读图一定是 BGR但如果训练代码里先做了cv2.cvtColor转 RGB你推理时必须跟着转不能省第二归一化是除以 127.5 还是 255不同实现各有习惯必须看模型权重对应的训练脚本第三resize 插值方式建议和训练时保持一致常用的是 INTER_AREA 在缩小但如果你训练代码用的是默认双线性这里也要改。我见过有人把这三个参数换了一遍识别性能从头到尾像换了个模型。3.3 注册库与比对向量化存储和 Top1 选择注册库的做法是把每个人某些固定的注册照片预先提取成 512 维向量。比较简单的存储方式是直接保存为 NumPy 格式人少时完全够用如果注册人数上千建议放到 SQLite 里按 id 索引加载。比对时只需要计算当前向量与库里所有向量的点积因为前面已经做了 L2 归一化点积就是余弦相似度。def identify(embedding, db_matrix, db_ids, threshold0.45): # db_matrix 是所有注册向量的堆叠, 形状为 N x 512 # db_ids 是对应的 id 列表 sims np.dot(db_matrix, embedding) # 一次批量算余弦相似度 top_idx int(np.argmax(sims)) top_score float(sims[top_idx]) if top_score threshold: return db_ids[top_idx], top_score return None, top_score # 低于阈值, 认为是陌生人这段代码逻辑很直接但threshold是最不能拍脑袋的参数。0.45 通常太松陌生人容易进来0.7 又可能让本人被认成陌生人。我习惯先设置 0.6 跑真实场景采集一周数据后再用最后一章的方法重新标定。另外注册照片的质量非常关键不要拿抓拍模糊帧直接注册尽量在光照均匀的环境下让用户正对摄像头连续取三帧选清晰度最高的那一帧入库。4. 活体检测在“是不是人”这道门槛前拦下打印照片与屏幕翻拍4.1 为什么识别模型自己挡不住攻击ArcFace 的价值在于区分不同的人但它从来不是为“区分真人和照片”设计的。把一张打印照片送到摄像头前照片上的人脸纹理、五官比例和真实人脸几乎一致ArcFace 计算出的相似度并不会显著降低。实际测试里很多 0.6 阈值的识别系统里翻拍照片的相似度能高达 0.7直接通过。活体检测必须作为独立的一层存在它关注的是画面中的面部是否有真实皮肤纹理、是否有自然微动、是否呈现三维结构。常见攻击手段包括打印照片、屏幕翻拍、视频回放、3D 面具。其中成本最低的是打印照片和屏幕翻拍绝大部分业务场景要先防住这两种。4.2 三种活体方案的选型对比不同方案有完全不同的成本曲线和部署方式选型取决于你是否能接受额外硬件。方案能拦住的攻击硬件成本部署注意RGB 纹理活体普通打印照片、部分屏幕翻拍无额外成本对光照变化敏感需要数据增强红外活体打印照片、屏幕翻拍需要红外摄像头与 RGB 图像需要配准成像纹理不同深度/结构光活体照片、视频、部分 3D 面具需要深度传感器算力开销小但硬件成本高我一般的落地顺序是先用 RGB 纹理活体拦掉大部分攻击因为零硬件成本如果安全等级要求高再叠加红外或者深度。RGB 活体对屏幕翻拍有一些天然优势屏幕在摄像头下会形成摩尔纹人眼看不见但模型能学到。不过手机屏幕亮度高、颜色艳时RGB 活体也可能误判需要用数据增强去模拟这些现象。4.3 轻量 RGB 活体模型的训练与推理套路活体模型的网络结构不需要太重输入 128×128 的裁剪图输出一个 spoof 概率即可。训练数据的核心是多样性而不是数量同一批真人脸加上不同角度、不同亮度、不同屏幕的照片和打印照片。数据增强必须做对这四件事——亮度抖动模拟现场光线变化随机模糊模拟对焦不准JPEG 压缩模拟低码率摄像头加摩尔纹模拟屏幕翻拍。只要增强足够一个轻量 CNN 也能把攻击通过率压到 1% 以下。def liveness_check(face_crop, model, threshold0.6): # face_crop 是检测模型裁剪并扩边后的人脸图 # model 是训练好的活体分类模型, 输出 [0, 1] 的 spoof 概率 img cv2.resize(face_crop, (128, 128)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img img.astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1))[None] # 转成 1 x 3 x 128 x 128 spoof_prob model(img).item() # 大于阈值判定为攻击, 小于阈值判定为真人 is_fake spoof_prob threshold return is_fake, spoof_probthreshold我习惯设在 0.6 而不是 0.5。默认 0.5 看起来公平但实际场景里照片攻击的得分往往在 0.7 以上真人的得分在 0.3 左右0.6 不仅能拦住攻击还能减少对高光环境下真人误判的影响。这个值同样要在现场数据上统计不要抄别的项目的。活体推理和识别推理可以共用同一张裁剪图不需要额外处理所以整个链路只增加了几毫秒到十几个毫秒的计算量。4.4 集成顺序先活体还是先识别三段式的顺序在架构上会影响整体延迟和拦截效果。我一般做法是先检测再活体最后识别。先活体的好处是攻击样本直接被拦截根本不会进入比对流程省去了跟注册库里几千人逐一比对的算力消耗。只有当活体判定为真人时才去执行识别。延迟敏感的场景下活体和识别可以并行计算因为它们的输入是同一个裁剪图共享一次检测结果但要注意并行会同时占用 CPU 核心设备性能不够时反而更慢。顺序上没有绝对正确答案先按“检测→活体→识别”串行跑测出延迟再决定要不要优化。5. 三段式流水线的避坑清单从检测、识别到活体的 5 个高频翻车点5.1 检测框裁得太紧识别率断崖下跌现象检测模型明明把人脸框得很准可同一个人的两帧画面识别结果时好时坏相似度在 0.4 和 0.75 之间反复跳。原因检测框紧贴脸部边缘ArcFace 的输入丢失了额头、下巴和颊侧轮廓导致特征不稳定。解决在裁剪前做扩边expand至少 0.3。我遇到过某项目因为这个参数没调识别率在 98% 直接掉到 80%。5.2 训练和推理预处理不一致模型像换了一个人现象训练集验证精度很高部署到另一台机器上性能骤降同一个人两张照片相似度只有 0.2。原因推理代码用了错误的颜色顺序或归一化方式最常见的坑是 OpenCV 读进来是 BGR而模型训练时用的是 RGB。解决不要让两份代码各自写预处理把预处理函数统一成同一个文件训练和推理共用回归测试时直接用两张同样的人脸图验证输出是否一致。这个坑属于典型的内存里翻车排查难度不高但很浪费时间。5.3 RGB 活体把高清打印照片放进来现象用打印机打了一张一百块的纸质照片怼在摄像头前直接通过。原因RGB 纹理活体依赖的是皮肤纹理细节高分辨率打印纸的表面纹理和真实皮肤在模型眼里差别不够大尤其是远距离拍摄时。解决数据增强里加入摩尔纹、纸张反光、颜色偏移阈值从 0.5 提高到 0.6 以上如果这个场景安全等级高直接加红外或深度方案不要指望纯 RGB 做到万无一失。5.4 数据集全是正脸摄像头装在侧边后漏检现象人员从侧面走过靠近摄像头角度偏 30 度以上检测框时有时无人停下来正对镜头又恢复正常。原因训练数据里正脸比例太大模型对侧脸的表达能力弱。解决补充带角度标注的头部姿态数据数据量不用太多几百张侧脸并做水平翻转就能见效同时把推理置信度阈值从 0.5 降到 0.3配合简单的同类目标跟踪让检测短暂丢失时用上一帧结果续上。5.5 阈值写死白天黑夜两套表现现象系统白天误拒率很低到了晚上或者走廊灯光下同一个人的识别相似度普遍下降 0.1导致大量本人被拒。原因光照变化让 ArcFace 特征向量发生了整体漂移而阈值是写在代码里的常量。解决把阈值改成配置文件项按时间段或光照传感器分场景设置不同阈值同时保证注册照片和识别现场的光照条件尽量一致如果注册时用的是室内白光晚上用黄色灯光比对相似的难度会成倍增加。这是我所有踩过的坑里最值得说的一条因为它直接决定了系统是否可交付。6. 把阈值当成配置项用真实分数分布找到最适合你场景的那个点6.1 采集正负样本分数的方法阈值校准不能靠感觉更不能照着开源项目的默认值抄。我会在系统上线后用影子模式采集真实数据让系统正常拦截和放行但把每一次识别请求的相似度记录到日志里。人工回看时给每条记录打上“同人”或“非同人”的标签积累几百条之后就能得到一组正负样本的分数分布。活体检测同理但正负样本分别来自真实人脸和照片、屏幕测试。6.2 用 FAR/FRR 曲线挑阈值的小脚本有了分数列表校准脚本其实很短。下面的函数在“攻击通过率不超过 1%”的前提下选出误拒率最低的阈值适合门禁这类安全性优先的场景。import numpy as np def pick_threshold(genuine_scores, impostor_scores, max_far0.01): # genuine_scores: 同一个人的合法比对分数列表 # impostor_scores: 不同人的非法比对分数列表 best_th float(genuine_scores.min()) best_frr 1.0 # 在分数范围内切出 1000 个候选阈值 for th in np.linspace(genuine_scores.min(), genuine_scores.max(), 1000): far (np.array(impostor_scores) th).mean() # 攻击通过率 frr (np.array(genuine_scores) th).mean() # 本人误拒率 # 在 FAR 上限内, 选择误拒率最低的阈值 if far max_far and frr best_frr: best_frr frr best_th float(th) return best_th, best_frrmax_far是可调参数追求通行体验时放宽到 0.05追求安全时收紧到 0.005。活体检测的阈值也可以用同一个思路把真人样本和攻击样本替换进去即可只是攻击样本得靠你自己拿照片和屏幕录。我后来的习惯是每套系统上线前先按这个脚本定初始阈值上线后再用真实日志复校一次。把阈值当作配置而不是常量后这个项目的识别模块终于不再像玄学一样忽好忽坏。对了调好的阈值要跟模型文件放在同一个版本目录里否则很容易出现模型更新了阈值还是旧的暗坑。希望帮到你。本文还有配套的精品资源点击获取