之前讲了动作活体要用户配合“表演”:眨眼、摇头、张嘴。三色活体又多了一位灯光师:屏幕要依次发出不同颜色。
本篇拆解的静默活体链把这两件事都拿掉了:它直接观察当前帧的人脸区域,再输出一个外观分类分数。
所谓“静默”,只是交互方式更安静。本文这份实现回答的仍是:当前这块普通人脸图像,在模型看来更接近哪一侧。
前两篇分别讲了动作活体如何使用 Face 的时序特征,以及三色活体如何计算人脸区域的光照响应。
这一篇只沿另一条线往下走:SilentLiveEngine、libLiveness.so 与 ncnn 如何把一帧人脸 crop变成静默活体分。
本文只解释检测机制与防御边界,不提供攻击复现;FaceAISDK 仅作为公开项目样本,不作漏洞结论。
1. 先看全图:一帧人脸怎样变成静默活体分

这张图的前半段负责准备输入,后半段才是本文重点:native 怎样裁出人脸、让模型打分并把结果送回 Java。下面只用最少代码接上前两篇,再把篇幅留给 libLiveness.so。
2. 节点①:引擎和模型怎样就位
状态机创建时,只要活体检测没有关闭,就会创建 SilentLiveEngine。包装层随后加载 so、读取本地配置并让 native 加载模型:
[F3:101] if (livenessType != FaceLivenessType.NONE) {
[F3:102] val engine = SilentLiveEngine(context.assets)
[F3:104] engine.init()
[SEJ1:Component.<init>] System.loadLibrary(LiveDetector.tag)
[SEJ1:LiveDetector.loadModel] val configs = parseConfig(assetManager)
[SEJ1:LiveDetector.loadModel] return nativeLoadModel(assetManager, configs)
这里知道一件事就够了:模型随 SDK 资源在端侧加载,config.json 决定 native 加载哪个模型以及怎样准备输入。
3. 节点②—③:从前两篇接过当前帧和人脸框
CameraX 取帧、Bitmap 转换、ML Kit 生成 Face 以及多脸时选择最大脸的完整过程,已经在第一篇:动作检测中展开;第二篇:三色 / 炫彩 / RGB 检测也复用了同一条输入链,本篇不再重复贴代码。
没看过前文只需记住:Face 是 ML Kit 的人脸检测结果,不是活体结论;boundingBox 只标出脸在当前 Bitmap 中的位置。
无脸、距离和多脸检查都发生在静默模型打分之前。
4. 节点④—⑤:Java 怎样把当前帧送进 libLiveness.so
前两篇留下的是当前 Bitmap 和 Face.boundingBox。Java 侧只做三步:包装脸框、转换图像、调用引擎。
[F5:200-F5:205] SilentFaceBox box = new SilentFaceBox(left, top, right, bottom, 0.0f);
[F5:207-F5:208] byte[] nv21 = LibYuv.bitmapToNv21(fullBmp, width, height);
[F5:210-F5:211] float raw = state.OooOOOo.detectLive(nv21, width, height, 0, box);
包装层只校验 NV21 长度并展开脸框,随即进入 JNI:
[SEJ1:LiveDetector.detect] if (width * height * 3 / 2 != yuv.size) throw IllegalArgumentException("Invalid yuv data")
[SEJ1:LiveDetector.detect] return nativeDetectYuv(yuv, width, height, orientation, left, top, right, bottom)
进入 libLiveness.so 的输入只有四类:整帧 NV21、帧尺寸、方向值和脸框边界。
5. 节点⑥—⑧:libLiveness.so 里发生了什么
以下地址化伪代码由作者根据当前 ARM64 libLiveness.so 样本的反汇编结果整理,不是上游 C++,地址也只绑定当前样本。
native 读到哪些模型资产
assets/liveness/ 中有两组 ncnn 模型;关键配置按原字段重排后是:
[
{"name":"liveness_model_1","scale":2.7,"width":80,"height":80},
{"name":"liveness_model_2","scale":4.0,"width":80,"height":80}
]
两个模型都会把各自的 crop 缩放为 80×80 后再推理。
一次调用怎样从 NV21 走到分数
[N2:0x1698AC] nativeDetectYuv(nv21, w, h, angle, left, top, right, bottom)
[N2:0x169960] NV21_to_RGB(nv21, w, h, rgb)
[N2:0x16999C] swap(rgb.R, rgb.B)
[N2:0x169D84] maxSafeScale = min((frameW - 1) / faceW, (frameH - 1) / faceH)
[N2:0x169E60] effectiveScale = min(config.scale, maxSafeScale)
[N2:0x169F08] Mat::from_pixels_crop_resize(bgr, PIXEL_BGR, cropRect, config.width, config.height)
[N2:0x16A048] extractor = net.create_extractor()
[N2:0x16A07C] extractor.input("data", mat)
[N2:0x16A0AC] extractor.extract("softmax", out)
[N2:0x16A0B4] live_value = out[1]
[N2:0x169D98] score_sum += live_value
[N2:0x16A140] return score_sum / model_count
这位于总图的节点⑥ native 预处理层、节点⑦模型层和节点⑧聚合层。
这段 native 代码可以压成三步:
- **换颜色格式:**NV21 → RGB → BGR。
- **裁人脸区域:**按脸框扩边,靠近画面边缘时自动缩小范围。
- **计算分数:**crop 送入 ncnn,每个模型取
out[1],最后求平均。
crop 到底是什么
crop 不是一种新图片格式,也不是模型的神秘中间结果。它就是根据人脸框,从整帧画面中裁出来的模型输入区域。
| crop |
画面里保留什么 |
2.7 倍 |
人脸占比更大,更集中于脸部 |
4.0 倍 |
保留更多头发、轮廓、颈部和周围背景 |
两个 crop 都以脸框中心为基准;靠近画面边缘时,实际观察范围会缩小。
模型真正读的是哪些人脸信号,又怎样算出静默分
先看“画面怎样变成一个分数”:

模型读入的是两块人脸 crop 的像素,不是姓名、身份证号、身份模板或真实深度图;每个模型最终输出 3 个 Softmax 类别值。
它不是用固定 RGB 阈值判断肤色,而是可能从 crop 中综合观察这些线索:
| 信号 |
画面里可能看到什么 |
不能误读成 |
| 色泽与光照 |
颜色分布、明暗过渡、屏幕发光、材质反光 |
肤色深浅等于真假 |
| 纹理 |
皮肤连续性、打印网点、屏幕像素、摩尔纹、压缩痕迹 |
当前权重一定依赖某一种纹理 |
| 立体外观 |
鼻梁、眼窝、面颊的轮廓、阴影与遮挡 |
测得真实物理深度 |
| 区域一致性 |
脸、头发、耳朵、颈部、边缘和背景的衔接 |
证明画面来自可信摄像头 |
| 成像痕迹 |
扭曲、异常光影、重采样、边缘重复 |
某一种痕迹就能单独决定真假 |
现有模型结构与开源测试支持把 out[1] 解释为真人/活体方向。上面的 native 代码已经给出打分算法,换成公式就是:
静默分 =(模型 1 当前 crop 的真人类别值 + 模型 2 当前 crop 的真人类别值)÷ 2
| 模型 |
三分类输出示意 |
代码实际取值 |
| model_1 |
[0.12, 0.78, 0.10] |
out[1] = 0.78 |
| model_2 |
[0.08, 0.86, 0.06] |
out[1] = 0.86 |
| 最终静默分 |
(0.78 + 0.86) ÷ 2 |
0.82 |
0.82 只是计算示意,不是实测结果,也不等于“系统有 82% 把握证明镜头前是真人”。
6. 节点⑨—⑩:静默分怎样进入最终判断
模型返回后,Java 保存三位小数静默分,再交给上层 Demo:
[F5:213] state.OooOOo0 = Math.round(raw * 1000f) / 1000f;
[F5:820] callback.onLivenessDetected(OooOOo0, bitmap);
[F1:102] public void onLivenessDetected(float livenessValue, Bitmap bitmap) {
[F1:104] if (livenessValue > 0.75) {
[F1:111] finishFaceVerify(10, R.string.liveness_detection_done, livenessValue);
[F1] }
[F1] }
OooOOo0 是静默活体类别分,不是身份分、三色 gainScore 或校准概率;回调不再推理,只把它交给业务层。
三色链路可回看第二篇。
7. 从三个商业样本看静默活体的现实形态
FaceAISDK 把一个端侧单帧模型讲得很清楚,但它还不能代表完整商业认证系统。基于作者对三个匿名 Android 商业身份核验样本的代码与运行链路观察,至少可以看到三种不同组合:
| 脱敏样本 |
观察到的检测形态 |
给第三篇的启发 |
| 样本 A |
端侧分类和多任务反伪模型被放进多帧、光照响应状态机 |
单帧模型分可能只是流程中的一个状态信号 |
| 样本 B |
RGB 反伪模型之外,还出现纹理统计、多帧一致性和可选近红外(NIR)路径 |
商业检测会把模型、传统图像特征和多模态能力组合起来 |
| 样本 C |
端侧负责人脸质量、流程与素材采集,服务端继续结合风险信号和身份比对裁决 |
端侧检测通过不等于整条认证链已经通过 |
**“静默”首先描述用户体验,而不是统一的算法名称。**三个样本共同说明:本文的端侧单帧模型只可能是商业认证系统的一层,其他实现还可能组合多帧、可选多模态、设备风险、云端反伪和身份比对;这些是方向性观察,不代表所有厂商。
8. 这一层在防御体系中的位置
| 问题 |
结论 |
| 它补了什么 |
无需用户动作或屏幕发光,也能为当前人脸 crop 输出一条外观分类分数 |
| 它没覆盖什么 |
不证明身份、真实深度、采集来源或摄像头到 SDK 的链路完整性 |
| 防御方怎样使用 |
分层记录输入质量、静默分、其他检测分、最终结果、失败原因和版本 |
9. 小结
静默活体之所以“静默”,是因为用户不必完成动作,屏幕也不必为这一层打三色光;不是因为模型已经越过图像,直接知道了“真人是谁、画面从哪里来”。
从这份代码证据看,它的完整路径是:
当前帧与人脸框 → NV21 与 SilentFaceBox → libLiveness.so → crop / resize → ncnn → 累加 out[1] 并平均 → 三位小数 OooOOo0 → onLivenessDetected → Demo 业务判断。
把这条链路读准确,防御方才能既不低估静默活体,也不把一个单帧外观分类分数抬成整条身份可信结论。
下一篇,我们把动作、三色 / RGB 与静默三层重新放回同一条画面信任链,讨论一个更贴近现实的问题:为什么 AI 生成视频可能同时挑战这三层活体防护?
这是前三篇的综合收尾,只讲三层共享的信任假设与防御意义,不做攻击复现。
代码索引说明:文中的 [F*]、[SEJ1] 与 [N2] 标记来自作者对 FaceAISDK 公开流程、包装层和当前 ARM64 样本的防御侧整理,只用于定位流程证据;本文不包含攻击实现、复现路径或攻击样本。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!