看懂身份验证中的活体检测(一):动作活体
目录
- 开场:为什么现在要重新看活体检测
- 1. 动作活体是什么
- 2. Demo 配置:先选择活体类型、动作集合和回调
- 3. CameraX 帧如何进入 SDK
- 4. Bitmap 如何进入 ML Kit
- 5. ML Kit 背后的 native 人脸检测链路
- 6. 多张脸时如何选择最大脸
- 7. 主状态机:先做无脸、距离和多脸检查
- 8. 动作状态机:读取 pitch、yaw、眼睛、微笑和嘴唇轮廓
- 9. 动作通过后,状态如何推进
- 10. 结构性边界:动作活体验证呈现,不验证来源
- 11. 动作活体解决了什么,又没有解决什么
- 12. 防御侧怎么加固动作活体
- 13. 小结:动作活体是第一层,不是最后一层
开场:为什么现在要重新看活体检测
最近几年,AI 应用和基础模型的发展速度很快。一个可观察到的变化是:增长带来新体验的同时,也把账号滥用、自动化注册、合成身份和身份验证风险等问题推到了更前面的位置。
围绕 AI 产品账号验证、年龄验证和身份核验的讨论正在增多。对普通用户来说,"为什么平台要确认你是真人/本人"不再只是金融开户或 eKYC 场景里的问题;活体检测这类原本偏风控的技术,也因此被更多人看见。
但活体检测不是一个按钮,也不是一个单点能力。它是一条不断加厚的防线:每一代方案都在回答一个问题:上一代漏掉了什么?新的攻击面又转移到了哪里?
这个系列想做的不是"怎么绕过活体",而是站在防御侧,把活体检测一层一层拆开:这一层解决了什么、它的判断依据是什么、为什么还不够、下一层为什么出现。
第一篇先讲最基础也最常见的一层:动作活体(Motion Liveness)。
本文以 FaceAISDK 的公开代码流程为例。具体项目只是样本,主角不是某个 SDK,而是动作活体这一类设计的防御边界。
1. 动作活体是什么
动作活体的思路很直观:系统让用户做一组动作,例如张嘴、眨眼、微笑、摇头、点头;检测端判断画面里的人脸是否完成了这些动作。
换成防御语言,动作活体是在验证:
输入画面里的人脸特征,是否在一段时间内呈现出指定动作序列。
这句话很关键。它说明动作活体比静态照片校验更强,但它的默认验证对象仍然是"画面里的呈现",而不是"这段画面的来源"。
下面按 FaceAISDK 公开代码总结里的流程,只拆动作检测链路:参数如何进入、帧如何变成 ML Kit 的Face、状态机如何读取动作特征,以及动作完成后如何推进状态。组合模式里的其他活体层不在本篇展开。
2. Demo 配置:先选择活体类型、动作集合和回调
[F1:87] private void initLivenessParam() { [F1:88] FaceProcessBuilder faceProcessBuilder = new FaceProcessBuilder.Builder(this) [F1:89] .setLivenessOnly(true) [F1:90] .setLivenessType(faceLivenessType) [F1:91] .setLivenessDetectionMode(MotionLivenessMode.ACCURACY) [F1:92] .setMotionLivenessTypes(motionLivenessTypes) [F1:93] .setProcessCallBack(new ProcessCallBack() {这一段是 Demo 的活体参数入口。setLivenessOnly(true)表示只做活体,不做 1:1 人脸比对;setLivenessType(...)决定活体类型;setLivenessDetectionMode(...)选择动作检测模式;setMotionLivenessTypes(...)决定动作集合;setProcessCallBack(...)接收动作过程提示和最终结果。
[F8:11] public void onLivenessDetected(float f, Bitmap bitmap) { [F8:17] public void onFailed(int i, String str) { [F8:20] public abstract void onProcessTips(int i); [F8:22] public void onTimeCountDown(float f) {对动作活体来说,这里最关键的是onProcessTips(...):它承载当前动作提示、动作完成和失败状态。本文只讨论动作检测相关回调,不展开组合模式里的其他活体层。
3. CameraX 帧如何进入 SDK
[F1:138] cameraXFragment.setOnAnalyzerListener(imageProxy -> { [F1:141] faceVerifyUtils.goVerifyWithImageProxy(imageProxy); [F1:144] }); [F2:215] public void goVerifyWithImageProxy(ImageProxy imageProxy) { [F2:216] goVerify(imageProxy);CameraX 每拿到一帧ImageProxy,Demo 就把它交给faceVerifyUtils.goVerifyWithImageProxy(...)。这是检测流程的帧入口。
[F2:86] int rotationDegrees = imageProxy.getImageInfo().getRotationDegrees(); [F2:87] int width = imageProxy.getWidth(); [F2:88] int height = imageProxy.getHeight(); [F2:93] o0oo0ooo.OooOO0o = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); [F2:95] DataConvertUtils.o0oOooO0(imageProxy, o0oo0ooo.OooOO0o); [F2:97] o0oo0ooo.o0oOooO0(null, o0oo0ooo.OooOO0o);这一段做了两件事:读取帧尺寸和旋转信息;把ImageProxy转进复用的 ARGB Bitmap,再交给后面的状态机。也就是说,后续活体逻辑处理的是一张张 Bitmap 及其检测结果。
这里讨论的是防御侧内部检测流程,不是输入替换或复现路径。本文只分析 SDK 如何消费帧,不展开任何攻击侧输入方式。
4. Bitmap 如何进入 ML Kit
[F3:211] public final void o0oOooO0(Bitmap bitmap, Bitmap bitmap2) { [F3:215] this.OooO00o = true; [F3:224] this.f59o0Oo0Ooo.o0oOooO0(false, bitmap2, new o0oOooO0(this, bitmap2, bitmap)); [F4:182] public final void o0oOooO0(boolean z, Bitmap bitmap, o0ooODOO o0ooodoo) { [F4:183] this.f80o0oOooO0.process(InputImage.fromBitmap(bitmap, 0)).addOnSuccessListener((v4) -> { [F4:184] o0oOooO0(r2, r3, r4, v4);Bitmap 进入 ML Kit 的关键点是InputImage.fromBitmap(bitmap, 0)。ML Kit 负责做人脸检测,然后把检测到的人脸列表交回 SDK。
这一层输出的是Face相关结果,包括人脸框、关键点、轮廓、姿态角、眼睛开合概率、微笑概率等。动作活体后面用的不是"真人证明",而是这些视觉检测特征。
5. ML Kit 背后的 native 人脸检测链路
L10 Google ML Kit native 检测链路: Java API com.google.mlkit.vision.face.internal.FaceDetectorImpl.process(...) MLTask com.google.mlkit.vision.face.internal.zzh.zze(InputImage) Binder client com.google.android.gms.internal.mlkit_vision_face.zzoy.zzd(...) Binder server com.google.mlkit.vision.face.bundled.internal.zza.zzb(...) JNI wrapper com.google.android.gms.vision.face.FaceDetectorV2Jni.zzd(...) native entry detectFacesImageByteBufferJni从 SDK 视角看,process(InputImage.fromBitmap(...))是一个 Java API 调用;但它背后并不止停在 Java 层。根据代码总结里的 L10 索引,ML Kit 会从FaceDetectorImpl.process(...)进入内部 task,经 bundled detector 的 Binder client / server,最后通过FaceDetectorV2Jni.zzd(...)调到 native 人脸检测入口。
L11 / N1 libface_detector_v2_jni.so 模型推理地址链: [N1:0x288514] detectFacesImageByteBufferJni(...) [N1:0x288608] sub_289890(detector, imagePtr, metadataBuf, result) [N1:0x2898D4] sub_289914(...) // 检测分发 [N1:0x433C98] tracker / detector 主流程 [N1:0x446568] sub_446568(...) // FssdDetector::Detect-like [N1:0x455740] sub_455740(...) // FaceDetectorTfliteClient::Detect [N1:0x455D34] DetectResizedAndRotatedFaces [N1:0x455F14] RGB 输入/推理/输出读取 [N1:0x71E1C8] TensorFlow Lite Subgraph::Invoke这条 native 链路的职责是人脸检测,不是活体打分。它把图像输入、metadata 和 detector 状态送入 bundled face detector / TFLite 推理链,再经过检测、decode、阈值、NMS、pose、tracker 等处理,最终回到 Java 层的Face/List<Face>。
这一步对第一篇很关键:动作活体读取的 pitch、yaw、眼睛开合概率、微笑概率和嘴唇轮廓,并不是动作状态机自己从像素里直接算出来的,而是建立在上游人脸检测 native 链路输出的Face结果之上。因此,动作检测的完整防御侧链路应写成:
CameraX frame -> Bitmap -> ML Kit Java API -> ML Kit internal task -> bundled detector Binder -> JNI -> libface_detector_v2_jni.so / TFLite -> Java Face / List<Face> -> 动作状态机本文只把这条链路作为"人脸检测如何产出动作状态机所需 Face 特征"来解释,不讨论任何攻击侧输入路径或复现方式。
6. 多张脸时如何选择最大脸
[F4:190] public final void o0oOooO0(o0ooODOO o0ooodoo, Bitmap bitmap, boolean z, List list) { [F4:191] if (list.isEmpty()) { [F4:192] o0ooodoo.o0oOooO0(null, null, 0); [F4:196] Face face = (Face) Collections.max(list, f77o0Oo0Oo); [F4:201] o0ooodoo.o0oOooO0(face, bitmap, list.size());如果没有检测到人脸,状态机会收到空结果;如果检测到多张脸,SDK 选择最大脸继续处理,同时把人脸数量传下去。
这一步解释了动作状态机的输入:最大Face、原始 Bitmap、人脸数量。后面的动作判断,都围绕这个Face输出的姿态、概率和轮廓特征展开。
7. 主状态机:先做无脸、距离和多脸检查
[F5:123] if (face == null) { [F5:132] state.f51o0Oo0OOO.onProcessTips(-7); [F5:135] state.f51o0Oo0OOO.onProcessTips(-3); [F5:138] return;没有人脸时,状态机会给出过程提示,严重时中止。这是质量门,不是活体模型判断。
[F5:144] final int faceWidth = face.getBoundingBox().width(); [F5:145] final int minScreen = Math.min(fullBmp.getWidth(), fullBmp.getHeight()); [F5:148] if (faceWidth < state.f49o0ooODOO + 150) { [F5:150] state.f51o0Oo0OOO.onProcessTips(-9); [F5:153] } else if (faceWidth > minScreen) { [F5:155] state.f51o0Oo0OOO.onProcessTips(-8); [F5:163] if (faceCount > 1 && now - state.OooOO0O > 1000) { [F5:164] state.f51o0Oo0OOO.onProcessTips(-12);脸太小、脸太大、多脸,都会触发对应提示。这些判断依赖Face.boundingBox和faceCount,主要保证检测条件可用。
8. 动作状态机:读取 pitch、yaw、眼睛、微笑和嘴唇轮廓
[F5:246] final int currentActionType = state.f57o0Oo0OoO[state.f54o0Oo0OOo]; [F5:247] final ArrayList<o0Oo0o00.o0ooODOO> samples = state.f58o0Oo0oOo; [F5:248] o0Oo0o00.o0oOooO0.f162o0oOooO0 = state.o0Oo0o0O.getLivenessDetectionMode(); [F5:251] switch (currentActionType) {进入动作分支后,当前动作类型来自动作序列,样本列表用于累计连续帧里的动作特征。状态机按动作类型进入不同分支。
[F5:253] case 5: { [F5:254] float pitch = face.getHeadEulerAngleX(); [F5:258] samples.add(new o0Oo0o00.o0ooODOO(5, pitch)); [F5:273] actionPassed = checkRange(samples, 5, lowMin, highMin, lowMax, highMax, 11);点头读取的是头部 X 轴欧拉角,也就是 pitch 的变化。状态机会把一段时间内的 pitch 样本收集起来,再判断变化范围是否满足动作条件。
[F5:279] case 4: { [F5:280] float yaw = face.getHeadEulerAngleY(); [F5:284] samples.add(new o0Oo0o00.o0ooODOO(4, yaw)); [F5:299] actionPassed = checkYawRange(samples, 4, lowMin, highMin, lowMax, highMax);摇头读取的是 Y 轴欧拉角,也就是 yaw 的变化。动作状态机并不直接理解"人",它读取的是人脸检测器输出的姿态角时间序列。
[F5:304] case 3: { [F5:305] Float rightEye = face.getRightEyeOpenProbability(); [F5:307] && Math.abs(face.getHeadEulerAngleY()) < 12f [F5:308] && Math.abs(face.getHeadEulerAngleX()) < 12f) { [F5:309] samples.add(new o0Oo0o00.o0ooODOO(3, rightEye)); [F5:310] samples.add(new o0Oo0o00.o0ooODOO(3, face.getLeftEyeOpenProbability())); [F5:319] actionPassed = checkBlink(samples, openHi, 0.2f, 0.66f);眨眼读取左右眼开合概率,同时要求头部姿态在一定范围内。这个设计是合理的:头部大幅偏转时,眼睛概率本身会更不稳定。
[F5:325] case 2: { [F5:326] Float smile = face.getSmilingProbability(); [F5:328] samples.add(new o0Oo0o00.o0ooODOO(2, smile)); [F5:332] actionPassed = checkSmile(samples, smileHi, 0.12f);微笑读取的是 ML Kit 输出的getSmilingProbability()。状态机关注的是概率随时间变化是否满足要求。
[F5:337] case 1: { [F5:340] for (FaceContour c : face.getAllContours()) { [F5:341] if (c.getFaceContourType() == 9) upperLip = c; [F5:342] if (c.getFaceContourType() == 10) lowerLip = c; [F5:347] delta = lo.get(lo.size() / 2).y - up.get(up.size() / 2).y; [F5:349] samples.add(new o0Oo0o00.o0ooODOO(1, delta)); [F5:351] actionPassed = checkMouthOpen(samples, 6f, 11f, 5f);张嘴读取上下唇轮廓点,计算中间点的纵向距离变化。这里同样是在使用 2D 人脸检测结果,不是在验证 3D 结构、采集链路或设备来源。
这一节是动作活体的核心:它的判断依据是 ML KitFace输出的 pitch、yaw、眼睛概率、微笑概率和嘴唇轮廓变化。它验证的是这些特征在时间上是否呈现出指定动作。
9. 动作通过后,状态如何推进
[F5:366] if (actionPassed) { [F5:367] samples.clear(); [F5:368] int nextIdx = state.f54o0Oo0OOo + 1; [F5:369] state.f54o0Oo0OOo = nextIdx; [F5:372] if (nextIdx > state.f55o0Oo0Oo0 - 1 && state.f51o0Oo0OOO != null) { [F5:376] state.f51o0Oo0OOO.onTimeCountDown(1.0f); [F5:377] state.f51o0Oo0OOO.onProcessTips(20);每个动作通过后,状态机会清空样本并推进到下一个动作。全部动作完成后,触发onProcessTips(20)。在本文范围内,这个状态可以理解为动作检测链路已经完成。
动作完成后,在组合模式中可能进入其他活体层;这些后续层,尤其炫彩逻辑,留到第二篇展开。
10. 结构性边界:动作活体验证呈现,不验证来源
回到动作活体本身。结合上面的代码路径,它的核心边界可以压缩成一句话:
它验证"呈现了动作",不验证"动作来自哪里"。
动作状态机读取的是 ML KitFace的姿态角、眼睛概率、微笑概率和嘴唇轮廓等结果。只要输入画面在这些特征上呈现出符合规则的时间变化,动作状态机就能继续推进。
这不是某个项目独有的问题,而是动作活体这一层的结构性边界。
动作活体问的是:
画面里的人脸有没有按要求动起来?
但更高强度的身份验证还需要追问:
这段画面是否来自可信采集链路?
当前帧是否真的来自预期采集路径?
输入链路有没有异常中间层、重放迹象或采集环境不一致?
单帧 / 多帧的纹理、成像、光照、深度、链路完整性是否互相一致?
如果只回答第一个问题,防线就停在"动作呈现"这一层。
注意,这里讨论的是防御边界,不是复现路径。公开分析应停在成因层:评分逻辑覆盖了动作呈现,没有覆盖来源可信。本文不提供任何攻击实现、复现步骤、参数、攻击视频或攻击样本。
11. 动作活体解决了什么,又没有解决什么
动作活体比静态照片校验更强。
如果系统只检查"画面里有没有一张脸",那么打印照片、屏幕照片、低质量翻拍都可能进入检测范围。动作活体至少要求画面里的人脸随时间变化,并且完成系统指定动作。这会抬高低级呈现攻击的成本,也能过滤掉大量静态输入。
从防御演进角度看,动作活体解决的是上一层的一个问题:静态输入太容易伪装。
但这个进步仍然停留在"呈现层"。它能证明一段输入呈现了动作,不能单独证明输入来源可信。
这也是为什么后续会出现光照响应、红外 / 双目、深度、链路完整性等更多层:每一层都在补上一层没覆盖的问题,同时引入自己的新假设和新边界。
12. 防御侧怎么加固动作活体
站在防御方,动作活体不该被废弃。它仍然能过滤低成本静态攻击,也能提供用户交互层的动态信号。
但它不应被当作唯一防线。
更稳的设计至少要把动作信号放进更大的验证框架里:
动作只作为一类证据,不作为唯一结论。
动作完成只能说明"输入呈现了动作",不能单独说明"来源可信"。增加来源与链路完整性校验。
防御方需要关注输入是否来自预期采集链路,是否存在异常中间层、重放迹象或采集环境不一致。融合其他活体层和成像信号。
动作层看的是动作序列,不应替代光照响应、成像一致性、深度或链路完整性等其他信号。把动作完成与整体验证通过拆开理解。
onProcessTips(20)说明动作序列完成,但在组合模式里,它不等于所有活体层都已完成。集成方需要明确每个状态码对应哪一层。把评估从"能不能过"改成"哪一层在起作用"。
真正有价值的 benchmark,不是简单给一个 SDK 贴好坏标签,而是拆出每一层的职责、证据、边界和失分原因。
这也是 DeltaSee 后续要持续做的事:不做攻击配方,而是建立一张活体检测防御强度地图。
13. 小结:动作活体是第一层,不是最后一层
动作活体的价值很清楚:它让系统从"看见一张脸"升级到"看见一段动作"。
它的边界也同样清楚:它验证呈现,不验证来源。
这不是否定动作活体,而是给它放回正确的位置。防御方真正要问的不是"有没有活体",而是:
这一层活体在验证什么?
没验证什么?
下一层应该补哪一个盲区?
第二篇进入炫彩 / 光照响应相关逻辑,讨论它试图补什么、又有什么边界。
活体检测的防御演进,就是这样一层一层长出来的。
代码索引说明:文中的[F*]/[N1]标记来自作者对 FaceAISDK 公开代码流程与 native 人脸检测链路的整理,仅用于定位防御侧代码证据;本文不包含攻击实现、复现路径或攻击样本。
