简介:这是一套基于深度学习的人脸静默活体检测项目,采用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, stride=1): super().__init__() self.depthwise = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=stride, padding=1, groups=in_channels, bias=False) self.pointwise = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(in_channels) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) 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_ratio=2, stride=1): 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, bias=False) self.bn1 = nn.BatchNorm2d(hidden_dim) self.conv2 = nn.Conv2d(hidden_dim, hidden_dim, 3, stride=stride, padding=1, groups=hidden_dim, bias=False) self.bn2 = nn.BatchNorm2d(hidden_dim) self.conv3 = nn.Conv2d(hidden_dim, out_channels, 1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) 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的输入为例,我习惯把左眼放在(38,38),右眼放在(74,38),鼻尖放在(56,58),左右嘴角分别在(44,76)和(68,76)。这样一套标准坐标在 ArcFace 一类人脸识别项目里被广泛使用,做活体检测时沿用即可。变换矩阵用 OpenCV 的estimateAffinePartial2D求,然后warpAffine对齐。
import cv2 import numpy as np def align_face(img, kps, size=112): # kps: 5个关键点按 [左眼, 右眼, 鼻尖, 左嘴角, 右嘴角] 排列 dst_pts = np.array([ [38, 38], [74, 38], [56, 58], [44, 76], [68, 76] ], dtype=np.float32) src_pts = kps.astype(np.float32) M, _ = cv2.estimateAffinePartial2D(src_pts, dst_pts, method=cv2.LMEDS) aligned = cv2.warpAffine(img, M, (size, size), borderValue=0.0) return alignedestimateAffinePartial2D求出的是只有旋转、平移、缩放的相似变换矩阵,不包含切变和任意拉伸。这样处理的好处是,人脸不会被“压扁”或“拉长”,符合真实设备的成像方式。代码里的cv2.LMEDS是稳健估计方法,对个别关键点标错的情况有容忍度。如果你的数据标注质量一般,这个参数能替你挡掉不少“脏点”。
对齐之后,图像的预处理还差最后一步归一化。我采用 mean=0.5, std=0.5,把像素映射到 [-1, 1]。这个选择主要是为了配合训练时的数值稳定性,换 ImageNet 的 mean/std(0.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(p=0.5), transforms.ColorJitter(brightness=0.35, contrast=0.35, saturation=0.25, hue=0.05), transforms.GaussianBlur(kernel_size=3, 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.py(Dataset与数据加载)、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_classes=2).to(device) # 类别不平衡时,给少数类更高的权重 class_weights = torch.tensor([1.0, 2.0]).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights) optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) train_loader = DataLoader(LivenessDataset("data/train.csv", is_train=True), batch_size=128, shuffle=True, num_workers=8, pin_memory=True) val_loader = DataLoader(LivenessDataset("data/val.csv", is_train=False), batch_size=128, shuffle=False, num_workers=8, pin_memory=True) 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_norm=5.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)), dim=1)[:, 1] all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) auc = roc_auc_score(all_labels, all_preds) print(f"epoch {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(mean=0.5, std=0.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_classes=2) model.load_state_dict(torch.load("best_liveness.pth", map_location="cpu")) 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_version=11 ) 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 训练时用的是 RGB,OpenCV 默认读进来的是 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, fps=30): # 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, d=1.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 峰值。这三件事只要有一件不达标,都不建议全量上线。
这个项目走到这里,主体工作已经结束。回看我做过的那几个活体检测项目,最深的教训是:别把宝押在模型的某一个技巧上,数据杂度、预处理一致性、阈值选点和时序策略,每块都要及格,才能端出一个能扛现场的产品。现在的我每次拿到一个新数据集,第一件事是先看负样本长什么样、预处理有没有对齐,而不是急着调网络结构。希望帮到你。
本文还有配套的精品资源,点击获取