Python人脸静默活体检测算法实战:从数据增强到ONNX部署

发布时间:2026/10/5 6:21:01
Python人脸静默活体检测算法实战:从数据增强到ONNX部署 简介面向计算机相关专业学生与深度学习初学者的静默活体检测项目资源基于Python与深度学习框架实现可用于毕业设计、期末大作业或人脸安全方向实战。资源解决无交互指令场景下区分真实人脸与照片、屏幕翻拍等攻击的核心问题难度适中适合已有基础Python与神经网络知识的学习者。压缩包共8个文件约10.82MB包含Python源码mtcnn.py、demo.py、模型权重fas.h5、mtcnn.pb、说明文档、效果示意图及演示视频覆盖模型加载到检测演示的完整流程src与model分模块组织目录结构清晰便于阅读调试。目前已有72人学习下载。项目附带详细文档与运行演示可帮助快速复现算法流程源码经本地编译调试可直接运行适合作为课程设计参照或进一步扩展人脸活体检测功能的基座。1. 人脸静默活体检测为什么“照片骗得过人眼骗不过深度学习”做安防和刷脸闸机的人多半都碰到过同一个尴尬用户拿一张打印照片或者手机屏幕照片怼到摄像头前系统居然放行了。人脸识别本身做得再好也拦不住这种最廉价的攻击。静默活体检测Silent Liveness要解决的就是这个——不要求用户做眨眼、摇头、张嘴之类的动作只在摄像头前正常站一下算法就能判断对面是活人还是一张纸、一块屏幕。相比配合式活体动嘴唇、眨眼睛静默式对用户体验友好得多也正因为如此它成了门禁、支付、App 实名认证场景里的标配前置模块。这个项目标题里的“Python 人脸静默活体检测算法”落到工程上就是一套基于深度学习的分类或回归模型输入一张 RGB 人脸图输出一个活体得分。难点不在模型结构有多新奇而在数据、预处理和阈值标定。我最早做这类项目时以为拿个开源模型就能直接上实际调试后发现真正影响上线效果的是“什么样的数据算活体”和“光照/分辨率一变模型行为差多少”。这篇笔记就沿着这个思路把 Python 侧的数据处理、模型选择、训练调参与部署踩坑讲清楚适合刚准备入门的同学跟着跑通也适合已经跑过 demo 但卡在精度或稳定性上的工程师对照排查。2. 静默活体检测的方案选型从传统特征到深度学习为什么最终要落在 CNN 上2.1 三种常见技术路线的对比纹理、频域、深度网络活体检测在深度学习普及之前主要靠手工特征。最常用的是 LBP局部二值模式加 SVM原理是人脸皮肤在高频纹理上有细微差异——打印照片的墨点纹理、屏幕的摩尔纹和真实皮肤的反射特征不一样。LBP 提取这些纹理统计量SVM 做二分类。优点是计算量极小树莓派都能跑缺点是泛化能力差换一种屏幕、换一台打印机准确率立刻往下掉。第二种是频域分析。真实皮肤表面有细微的出汗、毛孔、微血管导致的频谱特征而照片和屏幕在这些频段上相对平坦或者呈现规则栅格。常见做法是做人脸区域的 FFT 或 DCT把频谱的高频分量作为特征。这类方法抗分辨率变化的能力比 LBP 强但对光照非常敏感侧光或者强逆光下频谱完全乱掉误报率飙升。第三种就是本项目标题强调的“基于深度学习”。深度学习模型能同时学习纹理、反光、边缘和局部运动信息尤其是在大规模数据上预训练后再微调泛化能力远超传统方法。在静默场景下最主流的做法不是设计一个全新的网络而是复用 ImageNet 预训练的分类网络如 ResNet、MobileNet、EfficientNet把最后一层改成二分类。为什么不用动作信息因为静默活体不允许用户配合唯一的动态线索是微表情、血管搏动这类极弱信号普通摄像头根本捕捉不到所以不如老老实实做单帧判别。2.2 单帧分类模型是首选MobileNet 与 EfficientNet 的取舍如果训练数据量不大几千到几万分我一般直接用 MobileNetV3 或 EfficientNet-B0 做二分类。理由很简单这两个模型在 ImageNet 上预训练过特征提取能力足够参数规模小训练和推理都快方便快速迭代。用 ResNet50 也行但计算量贵了将近一个量级对静默活体这种“本身就不需要特别深语义信息”的任务属于浪费。有人会问为什么不直接用专门的人脸活体模型比如 FAS-Net这类模型通常需要额外的深度传感器输入如 Kinect、双目相机或者需要多帧时序信息。项目标题写的是“基于深度学习的 Python 人脸静默活体检测”默认输入的应该就是普通 RGB 摄像头所以单帧分类模型是最贴合、也最容易落地的方案。贴一段我用 PyTorch 搭最小模型的代码。先定义数据增强和模型结构# models/liveness_model.py import torch.nn as nn from torchvision.models import mobilenet_v3_small class LivenessNet(nn.Module): 静默活体二分类输入RGB人脸图输出logits0表示打印/屏幕攻击1表示活体 def __init__(self, num_classes2, dropout0.2): super().__init__() self.backbone mobilenet_v3_small(pretrainedTrue) in_features self.backbone.classifier[-1].in_features # 替换最后全连接层输出2类 self.backbone.classifier[-1] nn.Linear(in_features, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): return self.backbone(x)mobilenet_v3_small 的预训练权重来自 ImageNet原本的最后一个全连接层输出 1000 类这里重写成 2 类。dropout 加在全连接之前防止小数据集上过拟合。pretrainedTrue在首次运行时会自动下载权重下载慢的话可以提前用torchvision.models的load_state_dict手动加载。2.3 数据增强是静默活体的命根子随机裁剪、色彩抖动、高斯模糊静默活体最容易翻车的一点是训练时数据长得太“干净”测试时真实摄像头画面带了噪声、压缩痕迹、运动模糊于是模型把噪声当成了活体特征。解决办法就是做充分的数据增强尤其是模拟真实环境里的分辨率下降和色彩偏移。我这里用 albumentations 做增强相比 torchvision 自带 transform它的随机裁剪和模糊控制更细# train_pipeline.py import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.RandomResizedCrop(size(224, 224), scale(0.8, 1.0), ratio(0.9, 1.1)), A.ColorJitter(brightness0.3, contrast0.3, saturation0.2, hue0.02), A.GaussianBlur(blur_limit(3, 7), p0.3), A.HorizontalFlip(p0.5), A.CoarseDropout(max_holes8, max_height20, max_width20, fill_value0, p0.2), A.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ToTensorV2(), ]) val_transform A.Compose([ A.Resize(224, 224), A.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ToTensorV2(), ])参数说明RandomResizedCrop模拟摄像头采集时人脸大小变化ColorJitter的 hue 只给了 0.02因为人脸肤色对色相很敏感太大容易把活体学成青绿色GaussianBlur模拟低端摄像头的失焦CoarseDropout模拟局部遮挡比如刘海、口罩边缘。这些增强直接决定模型在真实闸机上的鲁棒性比调网络结构重要得多。3. 从数据集到训练样本自己造数据和清洗数据的完整流程3.1 开源数据集与自采数据的取舍静默活体检测领域有几个公开数据集比如 NUAA、CASIA-SURF、OULU-NPU。但这些数据集要么是实验室条件下采集的背景死板、摄像头固定要么是包含多模态信息的深度图、红外图和实际 RGB 摄像头场景有偏差。我在工程上常用的做法是用公开数据做预训练再用自采数据做微调。自采数据的平均成本其实很低——找 5 到 10 个人用手机前置摄像头录几十秒视频再通过脚本切帧几分钟就能造出上千张活体样本。攻击样本打印照片、屏幕照片造起来稍微麻烦点但也完全可以自动化。把活体照片打印出来再对着打印件重新拍摄就是打印攻击样本把活体照片在另一台手机或平板上显示出来再对着拍摄就是屏幕攻击样本。有个细节容易忽略打印照片本身会反光屏幕照片会带摩尔纹这些都在真实攻击中存在的自然现象我们刻意保留它反而有助于模型学到判别性特征。3.2 视频切帧和人脸对齐的关键代码拿到原始视频后第一步是切帧并做人脸检测。人脸检测我习惯用 OpenCV 的 DNN 模块加载 YuNet因为它在近景和中景下精度足够速度还快。切帧时不要每帧都保存按间隔 3 到 5 帧抽一帧即可否则数据冗余太重训练时模型会偏向于把连续帧的相似背景当成特征。# extract_frames.py import cv2 import os def extract_frames(video_path, output_dir, interval4): os.makedirs(output_dir, exist_okTrue) cap cv2.VideoCapture(video_path) detector cv2.FaceDetectorYN_create( face_detection_yunet_2023mar.onnx, , (320, 320), 0.6, 0.3, 0.3, 0.35 ) frame_id 0 save_id 0 while True: ret, frame cap.read() if not ret: break if frame_id % interval ! 0: frame_id 1 continue # 检测人脸并裁剪 _, faces detector.detect(frame) if faces is not None and len(faces) 0: # 取最大一个人脸框排除远处小脸 x, y, w, h faces[0][:4].astype(int) margin int(0.1 * w) x1 max(0, x - margin) y1 max(0, y - margin) x2 min(frame.shape[1], x w margin) y2 min(frame.shape[0], y h margin) face frame[y1:y2, x1:x2] # 只保留面积大于 64x64 的人脸避免低分辨率噪样本 if face.shape[0] 64 and face.shape[1] 64: cv2.imwrite(os.path.join(output_dir, fframe_{save_id:05d}.jpg), face) save_id 1 frame_id 1 cap.release() print(fextracted {save_id} faces from {video_path})代码里的margin是做人脸外扩目的是让模型不只看到五官区域还能看到部分下巴、额头和边缘肤色这些区域的反光差异对活体判别很重要。interval4在 30fps 的视频里相当于每 0.13 秒取一帧足够覆盖轻微头部转动带来的多角度变化又不至于训练集过度重复。3.3 清洗样本剔除模糊、过曝、重复帧切出的帧里总有垃圾运动模糊导致面部像涂抹过一样、强曝光导致皮肤死白、还有人脸检测出框但大部分面积被手指挡住。这些样本如果直接进训练集模型会学到很多错误的关联——比如“模糊就是攻击”“过曝就是活体”。我一般写个小脚本用 Laplacian 方差评估清晰度用像素均值评估亮度范围。# clean_samples.py import cv2 import numpy as np import os def laplacian_score(img): gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) return cv2.Laplacian(gray, cv2.CV_64F).var() def filter_samples(image_dir, min_score80, skip_patternNone): for fname in os.listdir(image_dir): if not fname.endswith((.jpg, .png)): continue path os.path.join(image_dir, fname) img cv2.imread(path) if img is None: os.remove(path) continue score laplacian_score(img) if score min_score: os.remove(path) # 太模糊删掉 print(fremoved blurry: {fname}, score{score:.1f}) # 检查是否过曝灰度均值接近255 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if gray.mean() 240: os.remove(path) print(fremoved overexposed: {fname})这个脚本在训练前期跑一次就行不要放在实时推理里否则会拖慢流程。min_score 的值需要根据摄像头分辨率调整720p 下人脸的剪裁图一般 100 左右以上算清晰1080p 可以设到 150 以上。如果设太高会误删一些虽然看起来糊但包含了反光细节的攻击样本——攻击样本的模糊本身就是一种线索。4. 训练静默活体分类模型损失函数、超参数与过拟合控制4.1 用什么损失函数二分类交叉熵的坑与改进静默活体二分类本质上就是 0/1 问题最自然的损失是交叉熵。但实际训练中正负样本往往不平衡——活体样本容易造攻击样本难造尤其是屏幕反射攻击需要专门录制。网上常见的做法是给损失函数加类别权重但只加权重会让模型倾向于把所有边界样本归到样本多的类反而降低召回。我通常用带难样本挖掘的 Focal Loss。Focal Loss 在交叉熵基础上按预测概率调制梯度让模型把精力集中在那些“像活体又像攻击”的困难样本上。对数形式如下# losses.py import torch.nn.functional as F def focal_loss(logits, targets, gamma2.0, alpha0.75): alpha: 正类活体权重用于平衡两类数量 gamma: 聚焦参数越大越关注难样本 probs F.softmax(logits, dim1) p_t probs[:, 1] # 活体概率 # 将目标转为one-hot计算 ce_loss F.cross_entropy(logits, targets, reductionnone) # film: p_t 应当对应目标类的概率 pt torch.where(targets 1, p_t, 1 - p_t) loss alpha * (1 - pt) ** gamma * ce_loss return loss.mean()实现上有两个注意点torch.where要避开梯度为 0 的分支类别权重alpha不是全局固定最好在验证集上试一下 —— 活体和攻击数量比在 1:1 附近时alpha 设 0.75 比较稳。gamma 默认 2.0 是论文推荐值但我的经验是如果数据集只有几百张把 gamma 降到 1.0 到 1.5否则模型收敛极慢因为几乎每个样本都是难样本。4.2 训练超参数的工程化设定学习率、Batch Size 与 Epoch这种小数据集微调任务最常见的问题是过拟合。参数上我比较保守初始学习率 1e-4比分类任务常见的 1e-3 低一档batch size 32训练 30 个 epoch用 CosineAnnealingLR 调度。为什么学习率要低因为预训练模型已经学到强特征我们只需要微调最后几层学习率太大容易把预训练权重冲坏。# train.py import torch from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model LivenessNet() optimizer AdamW(model.parameters(), lr1e-4, weight_decay0.05) scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-6) for epoch in range(30): train_one_epoch(model, train_loader, optimizer, loss_fn) scheduler.step() val_acc, val_auc validate(model, val_loader) print(fepoch {epoch:02d} | val_acc {val_acc:.4f} | val_auc {val_auc:.4f}) # 保存最优模型 if val_auc best_auc: torch.save(model.state_dict(), best_liveness.pth)AdamW 配 weight_decay 0.05 是最近训练视觉模型常见的推荐值比原来的 1e-4 更稳。CosineAnnealing 到最后一个 epoch 把学习率降到 1e-6让模型在局部最小值附近做精细收敛。val_auc 比 val_acc 更适合作为保存模型的指标因为正负样本比例略有不平衡时AUC 能更好地反映分类能力而准确率会被多的那类主导。4.3 过拟合信号怎么识别训练 loss 低、验证 loss 高的实际操作训练到一半如果发现训练集准确率到 99%验证集准确率只有 85%基本是过拟合了。盲目的做法是加 dropout 比例或者换更小的模型但这往往治标不治本。更有效的是先检查训练样本量分布——活体样本有 2000 张攻击样本只有 300 张模型当然会把攻击样本“背下来”而学不到共性。我的做法是把训练集按“活体/打印/屏幕”三类分开统计动态调整采样权重。比如攻击样本少训练时每个 epoch 对打印类多采样几轮用WeightedRandomSampler实现# sampler.py from torch.utils.data import WeightedRandomSampler labels [sample_label(i) for i in range(len(dataset))] class_counts torch.bincount(torch.tensor(labels)) weights 1.0 / class_counts[labels] # 样本少的类别权重高 sampler WeightedRandomSampler(weights, num_sampleslen(dataset)*2, replacementTrue)这样每一个 epoch 训练的样本数翻倍且小类别的样本重复采样多次模型见到的攻击样本多了拟合就好很多。注意replacementTrue表示同一个样本可以在一个 epoch 里出现多次这是加权采样的必要条件。如果觉得这样提升了过拟合风险可以在每个样本进入网络前多做几步随机增强缓解重复采样带来的记忆效应。5. 静默活体检测落地避坑5 个必须提前知道的问题5.1 现象训练时 AUC 0.99上闸机后 70% 的屏幕攻击漏过原因训练数据里的屏幕攻击是用同一台手机录的屏幕的亮度、色温完全一致真实场景里攻击者用的是不同品牌、不同亮度的屏幕模型从来没看过这种分布。解决自采数据时至少借 3 台不同品牌的手机和平板每台亮度调成高、中、低三档分别录尽量把屏幕压在 45 度角以内因为真实攻击时攻击者手持设备会晃动正面直怼摄像头的攻击反而不多。我后来专挑小米、华为、iPhone 三种屏各录了一批漏检率直接降了一半。5.2 现象打印照片在室内灯光下检测没问题走廊强光下一律判成活体原因强光照射在打印纸上会产生明显的镜面反射反射特征和真实皮肤的高光区域在视觉上非常接近模型没有见过这种“打印纸加直射光”的组合。解决数据增强里加入随机光照变换不单是调亮度而是模拟一侧强光和一侧弱光的侧光效果。albumentations 里的RandomBrightnessContrast只能调整体不够。我自己写了 2x2 的网格遮罩按块调亮度模拟斑驳光照。另外在采集数据时尽量加入侧光场景站在窗户边、只开台灯模型才能学到反射纹理差异。5.3 现象模型对视频帧抖动特别敏感画面一晃就报活体原因训练时用的全是清晰静止帧推理时摄像头自动曝光和人物微动会导致帧间亮度变化模型把这些变化当成了活体的“微纹理”信号。解决推理端做帧级平滑取最近 5 帧的预测概率做平均值。如果单帧得分 0.6另一帧 0.3平均后只剩 0.45再和阈值 0.5 比较误判就能减少。但这有个副作用——响应速度会慢 5 帧如果是做闸机大约慢 200ms可接受如果是做支付级别的强校验最好改成加权平均最近帧权重更大。5.4 现象小图人脸像素小于 60x60几乎全部误判原因人脸检测框太小输入模型前被迫放大到 224x224放大后马赛克严重。模型在训练时见过的是清晰大图马赛克图像的特征分布完全不同。解决训练时专门加入随机缩小到 80x80 再放大回 224x224 的降采样增强。用A.Downscale(scale_min0.3, scale_max0.6)模拟这种近似的低分辨率输入。真实闸机最好限制人脸框最小尺寸检测框小于 60 像素就直接拒绝——用户体验上只是让人走近一步但能避免一半以上的误判。5.5 现象打印攻击检测很准但戴眼镜用户的全是误报原因眼镜片的反光和打印纸反光在高频纹理上有一丝相似模型把两者混为一谈。尤其是防蓝光镜片带有淡黄色镀膜正好落在模型容易混淆的颜色区间。解决训练数据里加入佩戴眼镜的活体样本或使用 mixup 增强让模型对这类边界样本更平滑。采集时让测试人员戴上平光镜、墨镜各录几段。如果还不行就在推理端加一个质量判断人脸检测框带出眼镜概率模型输出活体得分但眼镜区域单独算一个局部得分如果眼镜区域纹理强度异常高给整体打分降权。6. 从模型到可用的推理服务ONNX 导出与阈值校准的实战细节模型在 PyTorch 里跑通只是第一步上线部署时一般要转成 ONNX 格式用 ONNX Runtime 或者 OpenVINO 做推理省掉 PyTorch 运行时开销。导出过程本来很顺畅但第一个大坑就是torch.onnx.export的 dynamic_axes 没设好——人脸输入尺寸虽然统一是 224但真实场景里摄像头给到的人脸框宽高不固定如果模型内部有用到 adaptive average pooling导出时要把尺寸维度标记为动态。# export_onnx.py import torch from models.liveness_model import LivenessNet model LivenessNet() model.load_state_dict(torch.load(best_liveness.pth, map_locationcpu)) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, liveness.onnx, opset_version12, input_names[input], output_names[output], dynamic_axes{ input: {0: batch_size}, output: {0: batch_size}, } )opset_version12是一个稳定不落后的版本ONNX Runtime 1.10 以上都支持。如果模型里用了torch.where等动态操作导出时可能会失败把torch.where改写成(targets 1).float() * p_t (targets ! 1).float() * (1 - p_t)就能绕过。导出后再用onnxruntime跑一次推理比对输出数值差异应该在 1e-4 量级。阈值校准比许多人想的重要得多。训练时模型输出概率我们默认 0.5 以上是活体但实际中 0.5 这个点往往不是最优。校准办法很简单拿一段在线上实际采集的包含 attacks 和 real 的测试视频逐帧得到模型得分画出两条分布曲线把阈值设在两条曲线交点的靠右一点稍微偏严宁可把活体误判成攻击也不要放攻击进来。这种评估脚本不需要多高级录两分钟视频切帧跑推理再用 matplotlib 画出直方图就行。最后一章再分享一个我觉得最值钱的实践经验把模型得分和检测框的面积、位置一起作为特征用一个简单的逻辑回归做二次决策。为什么这么做因为静默活体攻击者的脸通常比较正、比较大、居中——他们为了通过检测会把屏幕怼到镜头前。模型得分 0.8 且人脸框占画面 60% 以上看起来太完美大概率就是攻击。而活体用户的脸通常忽远忽近框占比波动大。把这个先验知识作为后处理融合能把误攻率再降一两个点。这种做法不增加模型复杂度只多了一个逻辑回归权重文件工程上非常划算。我每次上线这类活体算法必做三件事第一用自采集的多人多设备混合数据先跑一遍全流程确认没有单设备特异性第二在医院、便利店这类强光环境做现场测试侧面验证光照鲁棒性第三把每帧的得分都打印到日志里上线后观察一周看分布是否和测试时一致。这三件事救过我很多次也让我少挨了不少骂。希望这篇笔记能帮你把人脸静默活体检测真正落地而不是停留在跑通 demo 的阶段。本文还有配套的精品资源点击获取