☰
静默活体检测项目实战 算法原理【附代码】
2026/10/1 21:42:04 网站建设 项目流程

1. 什么是静默活体检测?

静默活体检测,就是在用户不做任何动作、无感知的情况下,判断镜头前那张脸是真人还是假体。不需要眨眼、张嘴、摇头,一帧图像就能给出真假结论。

2. 为什么需要它?

因为假脸太容易做了。打印照片、手机屏幕翻拍、硅胶面具、3D 头模,都能骗过普通人脸识别。刷脸支付、门禁考勤、远程开户这些场景,没有活体这道闸,识别再准也是给别人开的门。

配合式活体(让你眨眨眼)体验差、耗时久,还会被"视频注入"绕过;静默活体用户零配合、一帧出结果,是线上主流形态。

3. 算法效果

模型

FLOPs

参数量

端上单帧耗时

FPR=1e-5 时 TPR

基线 MobileFaceNet

0.224G

0.991M

—

—

剪枝主干 V1

0.081G0.414M

—

—

剪枝主干 V2

0.081G0.435M

—

—

端侧融合模型

84M

≈0.42M

20 ms97.8%

实测机型速度(单帧):麒麟 990 5G19ms,麒麟 99023ms,骁龙 84524ms,麒麟 81025ms,RK328890ms。

简单说:四毛钱的参数量(0.4M)、八千万次浮点运算、手机上一帧 20 毫秒、万分之一误识率下还能抓住 97.8% 的真人。这个量级,才敢往门禁机和 APP 里塞。

下面是两张示例图的实际判定结果(红框假脸、蓝框真脸,框上带置信度):

模型

FLOPs

参数量

端上单帧耗时

FPR=1e-5 时 TPR

基线 MobileFaceNet

0.224G

0.991M

—

—

剪枝主干 V1

0.081G0.414M

—

—

剪枝主干 V2

0.081G0.435M

—

—

端侧融合模型(开源)

84M

≈0.42M

20 ms97.8%

高精度模型(未开源)

162M

—

40 ms

99.7%

实测机型速度(单帧):麒麟 990 5G19ms,麒麟 99023ms,骁龙 84524ms,麒麟 81025ms,RK328890ms。

简单说:四毛钱的参数量(0.4M)、八千万次浮点运算、手机上一帧 20 毫秒、万分之一误识率下还能抓住 97.8% 的真人。这个量级,才敢往门禁机和 APP 里塞。

下面是两张示例图的实际判定结果(红框假脸、蓝框真脸,框上带置信度):

算法架构

整体思路:用一个超轻量的分类网络做主干,训练时额外挂一个"频谱回归"的小分支当老师逼着主干学频域特征,训练完把老师扔掉;推理时用同一张脸的不同尺度裁图喂给多个模型,概率加和投票出结果。

整个网络叫MultiFTNet,拆开看就是"一条主干 + 一条临时支路":

① 主干:剪枝后的轻量分类网络

输入3×80×80,走的是经典的DW(Depthwise 可分离卷积)+ 残差块堆叠:

classMiniFASNet(Module): def__init__(self, keep, embedding_size, conv6_kernel=(7, 7), drop_p=0.0, num_classes=3, img_channel=3): ... self.conv1 = Conv_block(img_channel, keep[0], kernel=(3,3), stride=(2,2), padding=(1,1)) self.conv2_dw = Conv_block(keep[0], keep[1], kernel=(3,3), stride=(1,1), padding=(1,1), groups=keep[1]) self.conv_23 = Depth_Wise(c1[0], c2[0], c3[0], kernel=(3,3), stride=(2,2), padding=(1,1), groups=keep[3]) self.conv_3 = Residual(c1, c2, c3, num_block=4, ...) ...

这里最骚的是那个keep列表,也就是剪枝后每层保留多少通道的手工配置表:

keep_dict = {'1.8M': [32, 32, 103, 103, 64, 13, 13, 64, 26, 26, 64, 13, 13, 64, 52, 52, 64, 231, 231, 128, 154, 154, 128, 52, 52, 128, 26, 26, 128, 52, 52, 128, 26, 26, 128, 26, 26, 128, 308, 308, 128, 26, 26, 128, 26, 26, 128, 512, 512], ...}

注意里面103、231、308、13这种一看就不是人手拍的数字——这是剪枝算法搜出来的"每层最优通道数",不是 32/64/128 这种凑整的美学。剪枝的本质就是:原网络里有些通道是打酱油的,砍掉它们对精度几乎没影响,但算力和参数量实打实降下来。MobileFaceNet 原版 0.224G / 0.991M,剪完 0.081G / 0.41M,直接砍掉六成算力和一半参数,精度还基本不掉。

② 辅助分支:傅里叶频谱回归(FTGenerator)

支路从主干的conv_4(输出128×10×10)抽头,接三个卷积就把特征"翻译"成一张频谱图:

classFTGenerator(nn.Module): def__init__(self, in_channels=128, out_channels=1): self.ft = nn.Sequential( nn.Conv2d(in_channels, 128, kernel_size=(3,3), padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 64, kernel_size=(3,3), padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, out_channels, kernel_size=(3,3), padding=1), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) )

③ 训练/推理双模式

这个设计最妙的地方在forward里,一行if就完事了:

defforward(self, x): ... cls = self.model.prob(x1) # 分类主分支 if self.training: ft = self.FTGenerator(x) # 只有训练时才跑辅助分支 return cls, ft else: return cls # eval 时辅助分支整段不执行

简单理解:训练的时候,我请了个"频谱老师"盯着主干的中层特征,逼它必须能还原出这张脸的频谱;等毕业(推理)了,老师直接走人,模型文件大小、参数量、推理耗时一点没变,但主干已经"沾了老师的光",学到的特征里天然带着频域信息。这就是所谓的free lunch(白嫖提升)。

损失也极其朴素,两个损失各占一半:

loss_cls = self.cls_criterion(embeddings, labels) # 三分类交叉熵 loss_fea = self.ft_criterion(feature_map, imgs[1]) # 频谱 MSE loss = 0.5 * loss_cls + 0.5 * loss_fea

④ 为什么频谱能当监督信号?

真实人脸是"一次成像":光线打在立体皮肤上,反射进镜头,高频细节丰富、频谱过渡自然。而翻拍脸是"二次成像":真脸 → 相机 → 屏幕/纸张 → 相机,多出来的这一次转换会丢高频、引入摩尔纹、屏幕像素栅格、纸张纹理,甚至反光斑。这些差异在空域里肉眼不一定看得出来,但在频域里非常明显。

下面这张图是我用工程里那套generate_FT逻辑,对示例图实算出来的对比(左:人脸区域,中:频谱图,右:频谱行能量曲线):

可以明显看到:真脸的频谱能量铺得更开、更"毛糙";翻拍脸的频谱更集中、更"干净"——二次成像把高频信息抹平了。这就是辅助监督能起作用的物理前提。

⑤ 频谱真值怎么来的?答案是:算出来的,不用标

defgenerate_FT(image): image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 转灰度 f = np.fft.fft2(image) # 二维傅里叶变换 fshift = np.fft.fftshift(f) # 低频移到中心 fimg = np.log(np.abs(fshift) + 1) # 取幅度 + log 压缩动态范围 # 归一化到 [0,1] fimg = (fimg - minn + 1) / (maxx - minn + 1) return fimg

这是整套方案最省钱的一点:频谱标签是numpy几行代码在线算出来的,不需要任何人工标注,也不额外占磁盘,加在DatasetFolderFT.__getitem__里随 batch 实时生成。相当于免费请了个老师。

⑥ 尺寸怎么对上的?一个很讲究的小细节

频谱真值被 resize 到10×10,正好等于主干conv_4输出特征图的空间尺寸。而这个尺寸不是拍脑袋定的:

defget_kernel(height, width): kernel_size = ((height + 15) // 16, (width + 15) // 16) return kernel_size # 80×80 输入 → kernel = (5, 5) # 频谱标签尺寸跟着 kernel 走 conf.ft_height = 2 * conf.kernel_size[0] # 10 conf.ft_width = 2 * conf.kernel_size[1] # 10

主干最后那层conv_6_dw用的是kernel=(5,5)、无 padding 的深度卷积,把5×5的特征图一把卷成1×1,连 GAP 和全连接都省了。输入从 80×80 换成别的尺寸,kernel 自动跟着变,频谱标签尺寸也跟着变,整套代码不用改一行。

2.算法流程

1. 数据怎么组织:三类 + 多尺度

训练集按3 类分目录(真人脸 / 翻拍 / 面具头模等),再按"原图 or 不同 scale 的 patch"分成多份:

├── datasets └── RGB_Images ├── org_1_80x60 # 原图直接 resize │ ├── 0 ├── 1 └── 2 ├── 1_80x80 # 人脸框按 scale=1 扩边后裁剪 ├── 2.7_80x80 # scale=2.7 └── 4_80x80 # scale=4

为什么搞这么多种尺度?看这张官方给的 patch 示例就懂了:

  • scale 小(只框住脸)

    :看的是皮肤纹理、边缘细节,能抓纸张纹理和屏幕栅格;

  • scale 大(连脖子带背景一圈)

    :看的是"周围环境",比如照片边缘、手持的边框、屏幕的黑边、脖子处的接缝;

  • org 整图

    :看全局光照和场景信息。

同一张脸,用三种"视野"去看,各自训一个模型,最后投票。这比死磕单个模型的收益大得多——因为你没法预判攻击者会用什么介质,多尺度等于多买几份保险。

裁剪逻辑也就二十行,核心是"以框为中心按 scale 扩边,同时保证不越界":

classCropImage: @staticmethod def_get_new_box(src_w, src_h, bbox, scale): x, y, box_w, box_h = bbox scale = min((src_h-1)/box_h, min((src_w-1)/box_w, scale)) # 防止扩边超出原图 new_width, new_height = box_w * scale, box_h * scale center_x, center_y = box_w/2 + x, box_h/2 + y left_top_x, left_top_y = center_x - new_width/2, center_y - new_height/2 right_bottom_x, right_bottom_y = center_x + new_width/2, center_y + new_height/2 # 四个方向分别做越界回推 if left_top_x < 0: right_bottom_x -= left_top_x; left_top_x = 0 ... returnint(left_top_x), int(left_top_y), int(right_bottom_x), int(right_bottom_y)
2. 训练配置
conf.lr = 1e-1 conf.milestones = [10, 15, 22] # 多阶段降学习率 conf.gamma = 0.1 conf.epochs = 25 conf.momentum = 0.9 conf.batch_size = 1024 conf.num_classes = 3# 三分类,不是二分类! conf.embedding_size = 128

数据增强走的是经典四件套:

train_transform = trans.Compose([ trans.ToPILImage(), trans.RandomResizedCrop(size=tuple(conf.input_size), scale=(0.9, 1.1)), trans.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4, hue=0.1), trans.RandomRotation(10), trans.RandomHorizontalFlip(), trans.ToTensor() ])

注意RandomResizedCrop的 scale 是(0.9, 1.1)——只做很小的尺度扰动,不像分类任务那样 0.08~1.0 大砍大裁。因为活体检测靠的是纹理和频域线索,裁太狠会把这些线索破坏掉。

3. 推理:多模型软投票
for model_name in os.listdir(model_dir): h_input, w_input, model_type, scale = parse_model_name(model_name) param = {"org_img": image, "bbox": image_bbox, "scale": scale, "out_w": w_input, "out_h": h_input, "crop": True} if scale isNone: # 模型名以 org_ 开头 → 直接用整图 param["crop"] = False img = image_cropper.crop(**param) prediction += model_test.predict(img, os.path.join(model_dir, model_name)) label = np.argmax(prediction) value = prediction[0][label] / 2# 除以模型个数 = 平均概率

模型名自带全部元信息,靠parse_model_name一行解析,加模型不用改代码,往目录里丢文件就行:

defparse_model_name(model_name): info = model_name.split('_')[0:-1] h_input, w_input = info[-1].split('x') # '4_0_0_80x80_MiniFASNetV1SE.pth' → 80, 80 model_type = model_name.split('.pth')[0].split('_')[-1] # → MiniFASNetV1SE if info[0] == "org": scale = None else: scale = float(info[0]) # → 4.0 returnint(h_input), int(w_input), model_type, scale

投票方式是"softmax 概率逐元素相加再取 argmax",本质是软投票集成。多个模型各有各的视野偏好,一个犹豫的时候另一个能补上,鲁棒性比单模型好一大截。业务侧还能在此基础上再加阈值:置信度高于阈值才判真脸,宁可拒真也不放伪(门禁场景通常就是这么调的)。

3.算法运行结果

下面是整套流程跑起来的终端输出(检测 → 多尺度裁剪 → 频谱生成 / 训练 / 推理三段):

4.总结

整套方案其实就三板斧:

  1. 主干够小:剪枝把 MobileFaceNet 砍到 0.081G / 0.41M,端上单帧 20ms,这是能落地的前提;
  2. 训练够聪明:挂一个傅里叶频谱辅助分支当免费老师,逼主干学频域差异,训完老师走人,推理零开销;
  3. 推理够稳:多尺度 patch 多个模型软投票,再配业务阈值,宁可拒真也不放伪。

从工程角度看,它最值得学的不是"用了傅里叶变换"这个点子,而是"怎么在不增加部署成本的前提下,把额外信息塞进训练过程"这个思路。辅助分支、多尺度集成、模型名即配置、尺寸参数联动——全都是成本低、收益稳的工程技巧。

联系我们

极目视觉团队有十余年算法经验,主要提供两类服务:

课设或论文指导。如果你在课程设计、毕业论文或课题中卡在算法实现、实验设计、系统搭建等环节,可以私聊我。

图像相关算法定制开发。专注工业生产安全、智慧园区/校园、交通、农业与森林、人脸识别等场景,擅长边缘计算下的算法优化与部署。支持 Jetson、RK、算能、高通、海思、地瓜等边缘设备,无 NPU 设备也能高效部署,可适配 Android、Debian、Ubuntu、Linux、CentOS 等多平台。

有课设/论文指导需求或图像算法开发、商务合作,欢迎联系并注明来意。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询