看懂身份验证中的活体检测(一):动作活体 目录开场:为什么现在要重新看活体检测1. 动作活体是什么2. Demo 配置:先选择活体类型、动作集合和回调3. CameraX 帧如何进入 SDK4. Bitmap 如何进入 ML Kit5. 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/ListFace。这一步对第一篇很关键:动作活体读取的 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 / ListFace - 动作状态机本文只把这条链路作为人脸检测如何产出动作状态机所需 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 ArrayListo0Oo0o00.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 人脸检测链路的整理,仅用于定位防御侧代码证据;本文不包含攻击实现、复现路径或攻击样本。