简介这是一套基于深度学习的人脸静默活体检测项目采用Python实现面向正在做课程设计、毕业设计或希望练习深度学习项目的计算机专业学生。资源聚焦于无需用户交互即可判别真实人脸与照片、视频等伪造人脸的应用场景涵盖MTCNN人脸检测与FAS活体检测两大核心模块并提供可直接运行的源码与配套说明文档。压缩包共8个文件总大小10.82MB包含2个Python脚本、2个模型文件h5格式活体检测模型与pb格式人脸检测模型、2张结果示例截图、1份Markdown说明文档和1个MP4演示视频覆盖代码、模型、文档与演示素材便于对照学习和复现结果。项目源码已本地编译并可正常运行整体难度适中适合作为毕业设计或期末大作业的参考演示视频与结果图可帮助快速理解算法流程README文档梳理了项目结构与运行方式。目前已有72人学习对人脸活体检测方向的学习者具有较好的参考价值。1. 静默活体检测在防什么一张照片就能骗过的时代早就过去了人脸识别落地的第一道坎不是识别率而是“你怎么确定摄像头前面是一张真实的人脸”。拿一张打印照片、一台平板上播放的录屏视频或者一个高仿硅胶面具都能让不少模型直接放行。静默活体检测Silent Liveness Detection解决的就是这个问题不做眨眼、摇头、张嘴这类的交互指令只靠摄像头当前采集到的画面判断镜头前的对象是“真脸”还是“伪造媒介”。它被大量用在刷脸打卡、支付验证、App实名认证等对无感体验要求极高的场景里用户什么都不用做机器自己完成判定。它的难点也恰恰来自“静默”这两个字。因为没有主动动作可依赖模型只能从画面的精细纹理、光照反射、屏幕摩尔纹、肤色微变化这些极隐晦的线索里找证据。很多初学者跑通一个二分类模型测试集准确率能到九成以上一换摄像头、一换伪造方式立刻崩盘这不是代码写得不对而是压根没把“活体特征”当回事。这篇笔记我会把一条完整的路子讲透从模型结构选型、数据构建、训练调参到部署验证最后再聊几个最容易让人翻车的细节。适合正在做人脸相关项目、想在自己业务里加一道防破的工程师也适合准备做相关课题的学生。下面直接从模型设计开始。2. 模型结构与输入设计如何用 MobileFaceNet 把静默检测做成二分类2.1 静默活体检测的问题定义二分类不是姿态估计拿到这个任务第一件事是把它简化成计算机视觉里最经典的问题图片分类。输入是一张已经裁好并对齐的人脸图输出是一个分数代表“真脸”的概率。框架上不需要检测框、不需要关键点回归、不需要分割mask这些工作应该由前面的人脸检测和人脸对齐模块完成。静默活体模型本身只负责一件事从对齐后的人脸区域里提取出“活体”与“非活体”的可分特征。把这个定义想清楚可以避免很多弯路。常见的错误是把活体检测和多任务扯在一起比如在同一个模型里既做活体判断又做年龄估计、表情识别美其名曰“多任务学习互相促进”。但在工程落地时多任务会增加标注成本、拖慢训练收敛还会让中间特征被其他任务“带偏”。我一般会把活体检测做成一个纯二分类的backbone加一个分类头输入严格限定为人脸对齐后的112x112或128x128图。这里还有一个容易被忽略的边界静默活体检测和“动作活体检测”是两条不同的技术路线。动作活体靠的是用户眨眼、摇头、张嘴这类配合性动作防照片攻击很有效但对录屏视频攻击几乎无能为力而且体验上多了一步交互。静默活体不依赖动作它必须在单帧或者连续几帧里找到“照片/屏幕/面具”与真实人脸之间的物理差异。这也是为什么后面会讲到单帧模型的输出并不稳定需要配合时序策略补一刀但模型的骨干确实是分类网络。2.2 用 MobileFaceNet 做骨干的三个理由网络结构方面我强烈建议从 MobileFaceNet 起步而不是一上来就上 ResNet50、EfficientNet 这些大模型。理由有三条。第一Static Features 本身不需要太大的感受野人脸区域里真假差异集中在皮肤反光、屏幕条纹、边缘振铃这些中高频信息上大模型并不会带来质的提升反而容易在小数据集上过拟合。第二MobileFaceNet 是专门为人脸任务设计的轻量网络它的 head 用 Global Depthwise Convolution 替代了全局平均池化能更好地保留人脸的空间分布特征。第三部署阶段不管是转 ONNX 还是上端侧芯片MobileFaceNet 的速度和体积都很有优势实测在CPU上跑一张112x112的人脸图大约只需要十毫秒量级。下面是一份可直接参考的骨干结构。它和标准 MobileFaceNet 一致通道数做了工程折中最后的分类头是两层全连接加一个2维输出。import torch import torch.nn as nn # 深度可分离卷积块 class DepthwiseSeparableConv(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.depthwise nn.Conv2d(in_channels, in_channels, kernel_size3, stridestride, padding1, groupsin_channels, biasFalse) self.pointwise nn.Conv2d(in_channels, out_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(in_channels) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.bn1(self.depthwise(x))) x self.bn2(self.pointwise(x)) return x # 倒残差块 class InvertedResidual(nn.Module): def __init__(self, in_channels, out_channels, expand_ratio2, stride1): super().__init__() hidden_dim in_channels * expand_ratio self.use_shortcut (in_channels out_channels and stride 1) self.conv1 nn.Conv2d(in_channels, hidden_dim, 1, biasFalse) self.bn1 nn.BatchNorm2d(hidden_dim) self.conv2 nn.Conv2d(hidden_dim, hidden_dim, 3, stridestride, padding1, groupshidden_dim, biasFalse) self.bn2 nn.BatchNorm2d(hidden_dim) self.conv3 nn.Conv2d(hidden_dim, out_channels, 1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): residual x x self.relu(self.bn1(self.conv1(x))) x self.relu(self.bn2(self.conv2(x))) x self.bn3(self.conv3(x)) if self.use_shortcut: x residual return x这段代码里有几个点要说清楚。DepthwiseSeparableConv 里的 pointwise 卷积本质上是通道间的线性组合它的作用是把 depthwise 阶段提取的“每个通道上的空间特征”融合起来在不增加太多计算量的前提下提高表达能力。InvertedResidual 的 expand_ratio 默认取2是考虑到人脸图分辨率不高、不需要像 ImageNet 那样的大扩张比否则低分辨率特征容易被噪声淹没。整个网络的组装顺序是一个普通卷积下采样、四个 InvertedResidual 堆叠、然后接 Global Depthwise Conv 和分类头。需要注意的是Network 内部的所有 BatchNorm 在训练和推理时的行为不一样推理时均值方差是固定的。如果你在部署时用 ONNX Runtime导出的图里通常已经包含了 BN 的 folding 结果不需要手动处理但在 PyTorch 里用model.eval()是必须的否则推理结果会和训练时不一致。2.3 预处理环节人脸对齐比网络结构更决定上限静默活体检测这个方向预处理是比网络结构更重要的环节。同一个模型喂凌云对齐的人脸和喂原图裁切的人脸准确率能差出十几个点。原因是网络其实相当“小聪明”它会去学背景里的桌子、墙壁、屏幕边框这些与活体无关的线索。一旦换场景这些线索不成立了模型就翻车。所以标准的做法是先用一个人脸检测器定位人脸框再用关键点双眼、鼻尖、嘴角做仿射变换把脸摆到一个固定位置。仿射变换的参数是固定的目标坐标。以112x112的输入为例我习惯把左眼放在3838右眼放在7438鼻尖放在5658左右嘴角分别在4476和6876。这样一套标准坐标在 ArcFace 一类人脸识别项目里被广泛使用做活体检测时沿用即可。变换矩阵用 OpenCV 的estimateAffinePartial2D求然后warpAffine对齐。import cv2 import numpy as np def align_face(img, kps, size112): # kps: 5个关键点按 [左眼, 右眼, 鼻尖, 左嘴角, 右嘴角] 排列 dst_pts np.array([ [38, 38], [74, 38], [56, 58], [44, 76], [68, 76] ], dtypenp.float32) src_pts kps.astype(np.float32) M, _ cv2.estimateAffinePartial2D(src_pts, dst_pts, methodcv2.LMEDS) aligned cv2.warpAffine(img, M, (size, size), borderValue0.0) return alignedestimateAffinePartial2D求出的是只有旋转、平移、缩放的相似变换矩阵不包含切变和任意拉伸。这样处理的好处是人脸不会被“压扁”或“拉长”符合真实设备的成像方式。代码里的cv2.LMEDS是稳健估计方法对个别关键点标错的情况有容忍度。如果你的数据标注质量一般这个参数能替你挡掉不少“脏点”。对齐之后图像的预处理还差最后一步归一化。我采用 mean0.5, std0.5把像素映射到 [-1, 1]。这个选择主要是为了配合训练时的数值稳定性换 ImageNet 的 mean/std0.485/0.456/0.406也能用但不建议在训练和推理之间两套标准混用否则大概率白训一场。后面在避坑章节里会再展开这个问题。3. 数据构建与预处理照片、屏幕重放与真脸的三类样本3.1 正负样本构成别让模型学“分辨率”而不是学“活体”数据是静默活体检测的生死线。很多团队模型效果差问题不出在网络上而出在负样本的结构性缺失。所谓负样本就是“非真脸”的数据它必须覆盖主流的攻击方式。按目前业务里最常见的伪造手法至少要有三类打印照片含剪孔、弯折、手持等形态、电子屏幕重放把录好的视频或者照片放在手机、平板上对着摄像头、以及硅胶面具或写真头模。这三类样本的比例要相对均衡否则模型会“偷懒”。举个例子如果你的负样本里百分之九十是屏幕重放模型可能只需要学到“画面里有摩尔纹就是假脸”一旦攻击者把屏幕亮度调低或换上高刷新率设备模型立刻失效。我再强调一遍活体检测模型学到的特征必须是“材质和光学”层面的而不是“某一个攻击方式的外观”。从正样本角度真实人脸数据也不能太单一。至少要把几个变量打散不同肤色、不同光源自然光、白炽灯、日光灯、屏幕补光、不同角度正脸为主±30度以内、不同设备手机前置、USB摄像头、监控枪机。这里有个特别容易忽略的点训练集里真脸照片的像素质量必须和负样本匹配。有些人用专业单反拍的真脸去训练用低清摄像头做推理结果模型只是学到了“清晰的就是真的”这个模型一到现场就会被打穿。正样本采集的具体做法是把摄像头视频流按帧保存每帧过一遍人脸检测和关键点对齐再人工筛掉模糊帧、遮挡帧和重复帧。负样本的制作稍微麻烦一点但逻辑是一致的把照片或屏幕内容放到真实采集环境下用同一套摄像头拍一遍。关键是要保持“攻击场景”和“真实场景”的光照、距离一致否则负样本会带有明显的人工痕迹。3.2 数据集划分与防泄漏按人切分不按图片切分数据准备好了划分训练集、验证集、测试集时有一条铁律同一个人的所有图片必须放在同一个集合里绝对不能让他的一部分图片出现在训练集、另一部分出现在验证集。活体检测模型对身份是相当敏感的它会记住某个人的皮肤质感、胡须、眼镜一旦同一个人跨集合出现验证集指标会虚高到失真。亲测过一个反例当时图省事按帧随机切分数据集训练集和验证集里都出现了同一个人在不同帧的图片验证集AUC跑到了0.998表面上看完美实际上线第一天就被一张手机翻拍的照片破了防。原因就是模型记住的是“陈某某长这样”而不是“这个人具有真实皮肤的反射特性”。按人切分后同质量的模型AUC立刻掉到0.97左右这才是真实水平。别被漂亮的离线指标麻醉离线好的模型不一定能用离线差的模型一定不能用。划分比例我一般卡在训练集60%、验证集15%、测试集25%。测试集里的攻击样本要单独注明类型这样在汇报指标时能分开看打印照片的通过率是多少、屏幕重放的通过率是多少、面具的通过率是多少。合并成一个总分很多隐蔽的问题会被平均掉。3.3 数据增强光照扰动越狠模型越稳活体检测数据增强和一般分类任务有相同的地方比如随机裁剪、水平翻转、小角度旋转。但它有自己特殊的增强手段按优先级排序我认为是颜色抖动 高斯模糊 随机亮度和对比度 有限度的仿射扰动。颜色抖动很关键因为它直接模拟了不同屏幕色温下的肤色差异。随机亮度和对比度模拟的是环境光照的剧烈变化这在闸机、考勤机这类户外或半户外场景非常常见。高斯模糊不能加太狠因为打印照片本身也会因为对焦问题产生模糊如果模糊过头了模型会误以为“所有模糊的都是假的”导致真人快速转动时被误杀。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.35, contrast0.35, saturation0.25, hue0.05), transforms.GaussianBlur(kernel_size3, sigma(0.1, 1.2)), transforms.RandomAffine(degrees(-8, 8), translate(0.05, 0.05)), transforms.ToTensor(), transforms.Normalize(mean[0.5, 0.5, 0.5], std[0.5, 0.5, 0.5]) ])上面的参数取值是我反复调过的经验值解释一下为什么这样定。brightness和contrast给到 0.35 是因为实际场景里太阳直射和阴影切换的亮度差异远大于这个值太小起不到泛化作用。hue只给 0.05因为色相扰动太大会造成肤色不自然反而让模型去学“偏色即假脸”这种错误规律。GaussianBlur的 sigma 给到 1.2 是因为要模拟摄像头脱焦和轻微运动模糊太大会让照片攻击反而变得更“真”。RandomAffine的旋转限定在8度以内超出这个角度的人脸本身就不应该出现在活体判定的范围内给多了只会加噪声。4. 训练与调参跑通一个 baseline 的完整命令与参数解读4.1 训练环境的依赖与工程结构代码我统一用 PyTorch 2.x 写训练和推理全部面向 GPU但不依赖任何重型的库。安装依赖只需要下面几条命令Pillow 负责读图opencv-python 负责预处理tqdm 打进度条。pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python pillow tqdm numpy onnx onnxruntime工程上我会把脚本拆成六个文件data.pyDataset与数据加载、model.py网络定义对应上一章的代码、train.py训练入口、utils.py指标计算、模型保存、infer.py单图推理、export_onnx.py模型导出。新手容易把代码全塞进一个 Jupyter Notebook 里跑通没问题可一旦到了要反复调参的阶段这种组织方式会让你吃尽苦头。4.2 训练脚本核心逻辑训练脚本的主流程是读配置文件、加载数据、初始化模型、定义损失和优化器、进入 epoch 循环。一个值得专门说的小点是在每个 epoch 结束后用验证集计算准确率和 AUC并且只在验证集 AUC 创新高时保存模型。这样最终拿到的是验证集最优模型而不是最后一个 epoch 的模型能有效避开训练后期过拟合带来的指标回落。# train.py 核心逻辑 import torch import torch.nn as nn from torch.utils.data import DataLoader from sklearn.metrics import roc_auc_score from data import LivenessDataset device torch.device(cuda if torch.cuda.is_available() else cpu) model build_mobilefacenet(num_classes2).to(device) # 类别不平衡时给少数类更高的权重 class_weights torch.tensor([1.0, 2.0]).to(device) criterion nn.CrossEntropyLoss(weightclass_weights) optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) train_loader DataLoader(LivenessDataset(data/train.csv, is_trainTrue), batch_size128, shuffleTrue, num_workers8, pin_memoryTrue) val_loader DataLoader(LivenessDataset(data/val.csv, is_trainFalse), batch_size128, shuffleFalse, num_workers8, pin_memoryTrue) best_auc 0.0 for epoch in range(30): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) preds model(images) loss criterion(preds, labels) optimizer.zero_grad() loss.backward() # 梯度裁剪可以防止偶发的脏样本把 loss 打爆 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: preds torch.softmax(model(images.to(device)), dim1)[:, 1] all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) auc roc_auc_score(all_labels, all_preds) print(fepoch {epoch:02d}, loss {loss.item():.4f}, auc {auc:.4f}) if auc best_auc: best_auc auc torch.save(model.state_dict(), best_liveness.pth)关于训练脚本里的超参有几个需要重点解释。优化器选 SGD 而不是 Adam是因为这类二分类小模型用 SGD 加余弦退火能收敛到更平滑的极值点Adam 在前几百步 loss 下降很快但最终准确率往往干不过 SGD。学习率初始值给到 0.01这在 BatchNorm 较多的人脸网络上是比较安全的取值如果 BatchSize 提到 256 以上学习率可以同步上调。class_weights我设为 1:2因为实际业务中负样本数量通常是正样本的 1.5 到 2 倍这里给少数类更高的损失权重让模型更努力地去学“假”的形态。4.3 三个必调的参数BatchSize、损失函数与输入分辨率如果只盯着三个参数调我的排序是 BatchSize、损失函数、输入分辨率。BatchSize 直接影响的是 BatchNorm 统计量的稳定性而非单纯的显存限制。BatchSize 小于32时BatchNorm 的均值方差波动很大尤其在训练初期每个 batch 的统计量抖来抖去模型会很长时间不收敛。往大了调模型对单张图的“突刺”越来越不敏感泛化更好但显存占用会线性上涨。我在这个任务上一般用 64 到 128低于 32 就要考虑用 GroupNorm 替换掉 BatchNorm否则epoch数再多也难收敛。损失函数方面开头直接用 CrossEntropyLoss 没问题但如果发现真脸误杀率高可以换到 ArcFace 或 CenterLoss 这类带 margin 的度量损失。ArcFace 的做法是把特征向量归一化后在超球面上加上角度间隔让模型学到类内更紧凑的特征。活体检测里用 ArcFace 有个额外好处它会让“活体”这一类在特征空间里聚得更紧从而提升对未知攻击方式的鲁棒性。实现上只需把分类头替换成 ArcFace Head并将 backbone 输出的特征维度固定为512。输入分辨率也是一个容易被经验误导的参数。112x112 是速度与精度的平衡点但如果你在门禁机上跑摄像头距离人脸普遍在一米左右人脸像素数不会太多这时候 112x112 反而够用。如果是手机前置摄像头自拍场景人脸占比大、细节丰富把输入提到 160x160 或 192x192 通常能把屏幕重放攻击多拦下来几个点。但每提升一个档位计算量是平方增长的部署前务必做真机帧率测试。5. 避坑静默活体检测最常见的五次翻车记录5.1 模型记住的摩尔纹而不是活体特征现象是模型在测试集上 AUC 高达 0.99但把攻击设备换成一款采用最新 OLED 屏的手机后拦截率立刻掉到不足三成。原因出在负样本的单一化训练集里的屏幕重放攻击全部来自同一台旧 LCD 屏手机摩尔纹的纹理和间距高度一致。模型根本没有学“屏幕内容”只记住了“这个间距的条纹等于假脸”。解决方法是采集多设备、多分辨率的屏幕。我后来整理数据时要求团队至少用四台不同品牌的手机和平板各录制一批重放样本并且在录制时随机调整屏幕亮度、播放角度和拍摄距离。数据里的攻击形态越杂模型才会退而求其次去学更本质的东西。5.2 人脸对齐被忽略模型靠背景辨真假现象是离线验证集指标一切正常但上线后同一台设备在不同背景环境下真脸误杀率忽高忽低完全没有规律。原因是对齐环节没做严格训练时有的图是检测框裁出来的有的图是原图缩放脸的尺度、角度、位置都不一致。模型在训练时偷偷用背景里的墙面纹理、桌面反光来判断真假一到新环境背景变了它就开始乱猜。解决的是把对齐做成一条硬性流水线检测关键点、仿射变换、裁剪训练和推理用完全相同的代码和参数不允许任何一步偷懒。别小看这个工作量它能减少至少十个百分点的场景漂移。5.3 归一化参数在训练和推理时不一致现象是模型在 PyTorch 推理时表现正常导出成 ONNX 后所有输入图片的输出概率都被压到 0.5 附近模型完全失效。原因是用了 OpenCV 的imread读图BGR 顺序、做了img / 255.0缩放到0到1但忘了做(x - 0.5) / 0.5这一步标准差归一化。PyTorch 训练时数据经过了完整的Normalize(mean0.5, std0.5)而推理代码里根本没有这一步等价的输入分布完全不同。解决技巧是在导出 ONNX 之前先写一个和训练完全一致的预处理函数并且要对同一个输入图分别跑一遍 PyTorch 模型和 ONNX Runtime对比输出的概率差不差到小数点后四位不算完。这类问题一旦出现通常不是“模型没学好”而是输入分布出了偏差。5.4 模型对“面具”和“打印照片”的误判方向完全相反现象是打印照片攻击的拦截率很高但硅胶面具的攻击几乎全部通过。原因在负样本结构上打印照片和屏幕重放确实能提供大量高频纹理差异但高仿面具在纹理、肤色、反光上和真人极其接近它们之间的差异主要在轮廓的立体度上。如果你的模型只用单帧静态图本来就很难区分立体脸和面具脸因为它缺了最关键的信息来源视差。解决这个问题的唯一办法是在数据里加入面具样本并使用多帧输入。可以让模型同时接收三个连续帧在通道维度上拼接9通道输入这样网络相当于隐式地学到了轻微的视角变化带来的像素位移能够捕捉到“立体脸与平面脸”的区别。这种做法不需要改网络结构只需调整输入层成本很低。5.5 阈值设成 0.5 被按在地上摩擦现象是模型输出概率分布非常极端要么接近0要么接近1但真脸误杀和假脸漏放的数量依然让人无法接受。原因是你拿 0.5 当真假的天然分界线了但这个概率输出是 softmax 的结果它并不是一个校准过的概率直接从它里面读“置信度”没有意义。不同的业务对“宁可不放真”和“宁可不放假”的权衡完全不同。解决方法是把输出分数先跑一遍测试集做阈值扫描画出 ROC 曲线然后根据业务要求选点。做考勤门禁要求漏放率低于百分之一那就选一个能让假脸通过率最低的阈值做自助终端要求用户体验优先就把阈值调低一点换取真脸通过率最大化。阈值是上线前的必调参数不是一个固定的0.5。6. 部署与进阶多帧投票与屏幕闪烁检测把单帧结果变成可用分数6.1 导出 ONNX 并在 CPU 上跑通推理模型训练好后第一步是把它从 PyTorch 的权重文件转成 ONNX。这里需要留意的是把动态轴打开否则导出的模型只能在固定尺寸的输入上工作换一张不同分辨率的图就会报错。import torch from model import build_mobilefacenet model build_mobilefacenet(num_classes2) model.load_state_dict(torch.load(best_liveness.pth, map_locationcpu)) model.eval() dummy_input torch.randn(1, 3, 112, 112) torch.onnx.export( model, dummy_input, liveness.onnx, input_names[img], output_names[score], dynamic_axes{img: {0: batch}}, opset_version11 ) print(export done)dynamic_axes只对 batch 维度放开不放开宽高目的是防止推理侧传入非 112x112 的图导致 BatchNorm 或者全连接层维度对不上。opset_version用 11 足够覆盖 Conv、BN、Relu 这些基础算子ONNX Runtime 对 opset 11 兼容性最好需要更新算子时再往上升。用 ONNX Runtime 推理时记得把输入数据转成float32并且顺序是 NCHW。OpenCV 读出来的是 HWC 的uint8要先转换、再归一化、再增加 batch 维。最容易出错的是通道顺序PyTorch 训练时用的是 RGBOpenCV 默认读进来的是 BGR如果不处理模型看到的是“蓝红颠倒”的世界输出自然全乱。推理代码里必须显式cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。6.2 多帧窗口投票与闪烁检测单帧模型在真实场景里不够用原因很简单照片和屏幕重放都有可能在某一个瞬间骗过单帧判定。工程上最实用的补救措施是“多帧得分平滑”维护一个长度为 N 的滑动窗口窗口内的每一帧都单独跑一次模型得到假脸概率最终判定看窗口内超过阈值的帧数比例。例如窗口 5 帧至少有 3 帧被判为“真脸”才放行。这个策略能把偶发的误判平均掉换回来的稳定性非常可观。如果还想再往上走一步可以加入屏幕闪烁检测。OLED 和 LCD 屏幕在摄像头采集下的亮度波动存在高频成分而真实面部在连续视频帧中的亮度变化是平缓的。对连续 30 帧的同一个人脸区域提取平均亮度做一次快速傅里叶变换如果高频能量占比过高就直接判为屏幕攻击。我一直认为屏幕重放是静默活体里最值得花精力防的攻击形式因为它的伪造门槛最低——只需要一段别人的视频成本几乎为零。import numpy as np def detect_screen_flicker(frames_mean_brightness, fps30): # frames_mean_brightness: 连续N帧人脸区域平均亮度数组 n len(frames_mean_brightness) if n 16: return False # 去趋势后做FFT trend np.polyfit(np.arange(n), frames_mean_brightness, 1) detrended frames_mean_brightness - np.polyval(trend, np.arange(n)) spectrum np.abs(np.fft.rfft(detrended)) # 高频段能量占比4Hz 认为是屏幕刷新导致的闪烁 freqs np.fft.rfftfreq(n, d1.0 / fps) high_mask freqs 4 high_energy spectrum[high_mask].sum() total_energy spectrum.sum() 1e-6 return (high_energy / total_energy) 0.2这个检测逻辑的原理不复杂真实人脸在视频帧间的亮度变化主要由环境光决定频谱集中在低频屏幕重放时摄像头会捕捉到屏幕刷新和亮度调制的周期信号这些信号集中在高频。0.2 的阈值不是拍脑袋定的它是用同一个摄像头在室内正常光照下录制真脸和屏幕各 30 段视频统计高频能量占比后取的一个中位分界值。不同摄像头的卷帘快门特性会导致这个值偏移换设备后至少用十段视频重新标定一次。6.3 三个在真实环境验证模型的方法离线指标再漂亮都不能替代现场验证。我的习惯是在部署后的前三天做三件事。第一件事是“照片穿透测试”拿打印照片、手机屏幕和 iPad 在真实光照条件下从 30 度、60 度、90 度夹角各试十次记录通过率。第二件事是“真脸压力测试”找 20 个不同肤色的人在早中晚三个时段各刷脸二十次统计真脸通过率和平均判定耗时。第三件事是“长稳测试”连续运行 8 小时观察模型的响应时间有没有劣化、内存有没有增长、有没有出现偶发性的 CPU 峰值。这三件事只要有一件不达标都不建议全量上线。这个项目走到这里主体工作已经结束。回看我做过的那几个活体检测项目最深的教训是别把宝押在模型的某一个技巧上数据杂度、预处理一致性、阈值选点和时序策略每块都要及格才能端出一个能扛现场的产品。现在的我每次拿到一个新数据集第一件事是先看负样本长什么样、预处理有没有对齐而不是急着调网络结构。希望帮到你。本文还有配套的精品资源点击获取