虹膜+人脸双模态识别的工程化编排:活体门槛、静默退出与超时兜底

0 阅读13分钟

虹膜+人脸双模态识别的工程化编排:活体门槛、静默退出与超时兜底

背景:我们的项目是一台跑在 RK 主板上的 Android 智能终端(2GB 内存),虹膜相机 + 人脸相机 + NFC + 485 串口锁板,放在无人值守场景里做"刷眼/刷脸开门"。识别方式后来被固定为混合模式:虹膜和人脸两个 SDK 同时工作,谁先命中算谁。

这个"谁先命中算谁"听起来很美好,落地之后我们连续踩了四个坑:人脸被照片/视频攻破、识别页偶发卡死、路人走过被弹一脸"识别失败"、开发者进后台后识别页疯狂自拉起。这篇文章按"问题现象 → 根因 → 最终实现"的顺序把这四件事讲清楚,代码均来自生产代码精简改编,读者只需知道 Android 协程和基础并发概念即可。


一、混合识别的隐患:人脸命中 ≠ 真人到场

问题现象

混合模式上线后现场反馈:拿一张注册用户的高清照片正对人脸摄像头,门开了。

原因不复杂:我们的双相机模组里,虹膜相机天然具备强活体属性——虹膜特征提取要求红外成像下的清晰纹理,照片、屏幕翻拍基本提取不出有效特征;而人脸相机的可见光成像对 2D 攻击的防御要弱得多,尤其当人脸质量分/活体分的阈值为了通过率调得不够激进时,照片攻击的通过率会高到不能接受。

但混合模式"任一命中即通过"的语义,把系统的安全水位拉到了两者中更低的那一个。

方案:给"人脸成功"加一道虹膜活体门槛

【配图建议:时序图——人脸命中后挂起,等待 3 秒窗口内的虹膜特征时间戳】

思路是不改变两个模态各自的比对逻辑,只拦截人脸比对的"成功出口"

人脸比对成功后,不立即放行,而是检查"最近 3 秒内,本轮识别是否成功提取过一次虹膜特征"。有 → 真人到场,放行;没有 → 视为照片/视频攻击,拒绝并继续识别。

这里有个关键设计点:我们的设备只有 2GB RAM,活体判断只记录时间戳,不缓存任何虹膜图像或特征引用。虹膜特征帧用完即弃,全局只留下一个 AtomicLong

// 识别会话内共享的活体状态(无图像缓存,只有一个时间戳)
private val lastIrisFeatureAt = AtomicLong(0L)

private const val FACE_IRIS_LIVENESS_WINDOW_MS = 3_000L
private const val LIVENESS_POLL_INTERVAL_MS = 50L

// 虹膜流水线每次成功提取特征时打点
fun onIrisFeatureExtracted(feature: ByteArray) {
    processFeature(feature)          // 比对逻辑,用完即释放
    lastIrisFeatureAt.set(System.currentTimeMillis())
}

fun hasRecentIrisLiveness(now: Long = System.currentTimeMillis()): Boolean {
    val featureAt = lastIrisFeatureAt.get()
    return featureAt > 0L && now - featureAt <= FACE_IRIS_LIVENESS_WINDOW_MS
}

人脸命中时先等一等再给结论——这是一个协程挂起的短轮询,50ms 一次、最长 3 秒,期间识别主循环不受影响:

suspend fun waitForFaceIrisLiveness(user: User, score: Float): Boolean {
    if (!faceRequiresIrisLiveness) return true      // 配置可关闭
    if (hasRecentIrisLiveness()) return true        // 常见路径:虹膜早已打点

    setHint("人脸已匹配,正在确认虹膜活体...")
    val waitUntil = System.currentTimeMillis() + FACE_IRIS_LIVENESS_WINDOW_MS
    while (!isRecognitionCancelled && System.currentTimeMillis() < waitUntil) {
        if (hasRecentIrisLiveness()) return true
        delay(LIVENESS_POLL_INTERVAL_MS)
    }
    return false
}

suspend fun emitSuccess(mode: String, user: User, score: Float) {
    if (mode == "face" && !waitForFaceIrisLiveness(user, score)) {
        faceMatchedWithoutIrisLiveness.set(true)    // 记录拒绝原因,不终止识别
        setHint("未检测到真人虹膜,请正对摄像头重试")
        return
    }
    // ...正常成功路径
}

几个实现细节值得说:

  • 只拦出口,不拦过程。人脸比对照常跑,被拒绝后识别循环继续(faceMatchedWithoutIrisLiveness 只是打个标记)。真人用户在人脸先命中后只要把视线对准虹膜相机,一两秒内虹膜特征到手,下一次人脸命中就能通过——体感上只是"慢了一拍",而不是"失败重来"。
  • 虹膜命中不受此门槛限制。虹膜特征能提取出来本身就是强活体证据,直接放行。
  • 拒绝场景有专属失败文案。如果整轮超时结束时只有"人脸命中但被活体门槛拦下"的记录,最终提示是"未检测到真人虹膜,请正对摄像头重试"而不是笼统的"识别未通过",并且用 2 秒延迟收尾强制回调首页(这点在第三节展开)。
  • 做成配置项。设备管理页有"人脸识别依赖虹膜活体"开关,默认开。某些现场光照条件差、虹膜提取率低的场景可以关掉,回到纯人脸行为。

为什么不用厂商的人脸活体检测?

模组自带的人脸活体(可见光)对高清照片和屏幕回放的防御能力因固件版本波动很大,而虹膜特征提取失败是我们的设备上物理上更难伪造的信号。用"虹膜特征是否出现过"当活体证据,等于把信任锚定在更强的那一路传感器上。代价是用户必须真的"看"一眼虹膜相机——这在我们的交互流程里本来就是必须的(识别距离 20~55cm,正对摄像头),所以门槛几乎是零成本的。


二、识别状态机:为什么必须有"绝对总超时"

问题现象

识别页偶发卡死:页面停在"正在识别...",提示不再更新,返回键有效但下次进来直接黑屏或停在最后一帧。

根因:分级超时管得住"正常路径",管不住"所有路径"

我们的识别主循环是一个"机会式并行"状态机:人脸和虹膜两条流水线各自按固定间隔(400ms / 500ms)提取特征、异步比对,主循环每 50ms 巡查一次状态,根据一堆分级条件决定退出:

// 分级超时(全部基于"最近发生过某事"的相对判断)
if (now - lastFaceDetectedAt > NO_FACE_TIMEOUT_MS) {           // 10s 无人脸
    noFaceTimedOut = true; break
}
if (now - lastValidFeatureAt > NO_FEATURE_TIMEOUT_MS) {        // 10s 无有效特征
    noValidFeatureTimedOut = true; break
}
if (validFeatureElapsed >= NO_REGISTERED_USER_TIMEOUT_MS &&    // 8s 匹配次数足够但仍未命中
    enoughNoRegisteredMatches) {
    noRegisteredUserTimedOut = true; break
}

这些条件每一条都假设:对应的流水线还在正常产出时间戳。一旦某条流水线异常(特征提取抛异常、比对协程泄漏、lastIrisFeatureAt 不再更新但人脸又一直有——比如有人举着照片站在门前不动),所有分级条件都可能永远不满足,循环空转。

修复方式是在循环最顶部加一条不依赖任何内部状态的绝对总超时

while (!isRecognitionCancelled && !resultHandled.get()) {
    val elapsed = System.currentTimeMillis() - startTime

    // 绝对总超时:任何分支异常都不能让识别页卡死
    if (!keepRecognizingWhileFacePresent && elapsed > TOTAL_RECOGNITION_TIMEOUT_MS) {
        LogUtil.i(TAG, "[RECOG] total recognition timeout, elapsed=${elapsed}ms")
        break
    }
    // ...分级超时与业务判断
}

(自动触发模式下这个总超时被"有人脸就持续识别"的策略取代,但人脸消失 10 秒的分级超时仍在兜底。)

收尾同样要兜底:延迟退出必须 force

失败收尾不是直接 finish(),而是先展示失败文案 2 秒再退出,避免用户看不清原因。问题是 2 秒后 Activity/Fragment 可能已经被系统或用户操作折腾到"结果不可受理"的状态。所以延迟到期后用 force = true 绕过受理检查,无条件回调宿主:

private fun finishDelayed(failureMessage: String?) {
    isRecognitionCancelled = true
    isResultHandled = true
    finishDelayJob?.cancel()
    finishDelayJob = lifecycleScope.launch(Dispatchers.Main.immediate) {
        delay(2_000)
        if (!isAdded || activity == null) return@launch
        finishWithResult(null, failureMessage, force = true)  // 绕过 canHandleAuthResult
    }
}

原则一句话:识别页的每一个入口,都必须存在一条不依赖业务状态的成功退出路径。循环有绝对超时,收尾有 force 回调,宿主侧还有 onStop 强制隐藏(见第四节),三层兜底缺一不可。


三、"未检测到人员":一次主动的静默退出设计

问题现象

设备放在走廊边,路人经过触发帧差唤醒 → 识别页拉起 → 10 秒无人脸超时 → 弹一个"未检测到人员"的提示框,首页再弹一个 5 秒的失败 Toast。一天下来屏幕上全是这种无意义提示,现场运维以为设备坏了。

分析:这个"失败"对谁都没有信息量

"未检测到人员"不是用户的错,也不是设备的错——它甚至不算一次失败,只是自动唤醒机制的一次空触发。对这种结果弹提示,等于让设备向空气道歉。而且提示框还会挡住首页、打断正在进行的其他操作。

实现:用一个专用常量贯穿两层静默

我们把"未检测到人员"定义为专用失败原因常量,识别页和首页分别识别它并跳过提示:

companion object {
    // 全程未检测到人员的失败原因:识别页和首页均不展示该提示
    const val AUTH_FAILURE_NO_PERSON = "未检测到人员"
}

// IrisAuthFragment:循环收尾
val finalHint = when {
    noFaceTimedOut -> AUTH_FAILURE_NO_PERSON
    noValidFeatureTimedOut -> "未检测到有效人脸或虹膜,请重新尝试"
    noRegisteredUserTimedOut -> "未查询到已注册人员"
    faceMatchedWithoutIrisLiveness.get() && !hasValidIrisFeature ->
        "未检测到真人虹膜,请正对摄像头重试"
    hasEverDetectedBio -> "识别未通过,请重试"
    else -> AUTH_FAILURE_NO_PERSON
}
// 注意:静默 ≠ 吞掉。失败记录照常写,2 秒延迟退出时序照常走,只是不展示
if (finalHint != AUTH_FAILURE_NO_PERSON) {
    mViewModel.setHint(finalHint)
}
finishDelayed(finalHint)
// MainActivity:收到识别结束回调
override fun onIrisAuthFinished(intent: Intent) {
    val failureMessage = intent.getStringExtra(EXTRA_AUTH_FAILURE_MESSAGE)
    hideIrisAuthOverlay(...)
    if (employeeNo.isNullOrBlank()) {
        if (!failureMessage.isNullOrBlank() && failureMessage != AUTH_FAILURE_NO_PERSON) {
            showFailureToast(failureMessage)   // 其他失败原因照常提示
        }
        // AUTH_FAILURE_NO_PERSON:什么都不弹,直接回到首页
    }
}

两个细节:

  • 静默只作用于 UI 层。操作记录(含失败原因、时间戳)照常入库,事后运维可以按记录追溯"设备是不是频繁空触发",这是诊断帧差唤醒灵敏度的数据来源。
  • 其他失败原因全部保留提示。"识别未通过"是真人用户的真实失败,必须告知;"未查询到已注册人员"提示用户去注册——这些都有明确的信息量,不该静默。

这条改动的体验收益远超代码量:设备从"路过的狗都要弹个框"变成"没事别烦我,有事说清楚"。


四、开发者入口与自动识别的打架:抑制标记 + 生命周期对账

问题现象

设备首页支持"画面有变化/检测到人脸就自动拉起识别页"(自动监控)。后来我们给开发者留了一个隐藏入口:识别页左下角双击进入开发者登录页。上线后出现两个连环 bug:

  1. 退回即弹:从开发者登录页返回首页,识别页立刻自动拉起,盖在登录页底下;
  2. 画面卡死:再退出识别页后,首页人脸预览停在最后一帧,自动识别再也不触发。

根因分析

这两个问题其实是同一个根因的两个侧面——Activity 生命周期和识别状态机对不上账

  • 误拉起:双击跳转时,hideIrisAuthOverlay 会延迟 450ms 做一次"相机预热 + 恢复自动监控"。这 450ms 内 MainActivity 往往还在前台,预热完成后自动监控发现相机里还残留着开发者的人脸,立刻重新拉起识别页。更糟的是厂商层的 stopCamera() 不清人脸缓存,返回瞬间陈旧的人脸数据也会直接触发。
  • 卡死:识别页被拉起盖住开发者登录页 → 宿主 Activity 走 onStop → 厂商层 stopCamera() 杀掉相机和检测线程。但我们的 IrisAuthFragment 没有覆写 onStopisPreviewReady 等标记还是 true;返回后"恢复预热"逻辑一看标记为 true 就跳过启动——标记说相机活着,实际相机死了,画面永远停在最后一帧。

最终实现:三件套

1)抑制标记 autoAuthSuppressed——跳转开发者登录前置位,所有自动拉起入口检查:

private var autoAuthSuppressed = false
private var autoAuthSuppressJob: Job? = null

fun onDeveloperLoginRequested() {
    autoAuthSuppressed = true                          // 跳转前置位
    autoAuthSuppressJob?.cancel()
    hideIrisAuthOverlay("developer login", scheduleWarmup = false)
    startActivity(Intent(this, DeveloperLoginActivity::class.java))
}

fun canStartAutoAuthentication(): Boolean =
    isMainResumed && !isHomeInteractionBusy() && !autoAuthSuppressed

从开发者登录返回后给一个 3 秒宽限期再解除,避免返回瞬间相机里的人脸残留立刻再触发:

override fun onResume() {
    // ...
    if (autoAuthSuppressed) {
        autoAuthSuppressJob?.cancel()
        autoAuthSuppressJob = lifecycleScope.launch {
            delay(3_000L)          // 宽限期,之后恢复自动识别
            autoAuthSuppressed = false
        }
    }
}

2)hideIrisAuthOverlay 增加 scheduleWarmup 参数。跳转其他页面、宿主停止这类场景传 false,不再调度那个 450ms 后的预热/自动监控——相机在后台被拉起本身就是隐患。

3)生命周期对账:宿主和 Fragment 各自补 onStop 覆写,把状态拉回真实:

// MainActivity:宿主被覆盖时若识别页还显示着,强制退出
override fun onStop() {
    if (irisAuthOverlay.isShowing()) {
        hideIrisAuthOverlay("host stopped", stopAuth = true, scheduleWarmup = false)
    }
    super.onStop()
}

// IrisAuthFragment:厂商层 onStop 会杀相机,这里同步复位标记
override fun onStop() {
    isPreviewReady = false
    isFaceCameraOpen = false
    isCameraStartRequested = false
    isCameraStartScheduled = false
    super.onStop()
}

同时给厂商层 stopCamera() 补了一行 mFaceInfo = null,杜绝陈旧人脸缓存在下次启动时误触发(改 SDK 封装层时顺手做的,一行代码省掉一类 bug)。


五、经验总结

四个坑串起来,其实是无人值守生物识别终端的四条通用原则:

  1. 多模态融合的安全水位 = 最弱一环。只要有一条弱模态能独立开门,强模态就白搭了。修复方式不一定是给弱模态加活体算法,也可以是用强模态给弱模态的成功出口加一道交叉验证——成本低、见效快、还能做成开关。
  2. 活体证据按"最小留存"设计。2GB 内存设备上,一个 AtomicLong 时间戳顶得上一套图像缓存方案。先问"我真的需要留下这张图吗",多数时候答案是不需要。
  3. 状态机的每一层都要有"不依赖业务状态"的兜底:循环级绝对总超时、收尾级 force 回调、宿主级 onStop 强制退出。分级超时再精细,也只覆盖你想得到的路径。
  4. "沉默"也是一种需要设计的交互。自动触发的设备必然有空触发,空触发的反馈就该是静默——但记录要照写,可观测性不能因为 UI 静默而丢掉。
  5. 隐藏入口/调试入口是状态机的"野指针"。它们绕过正常流程跳转,最容易和自动监控、预热这类延迟任务打架。给这类入口配一个显式抑制标记 + 宽限期,比到处打补丁干净得多。

适用场景

  • 无人值守/半无人值守的生物识别门禁、柜机、考勤终端;
  • 多模态(虹膜/人脸/指纹/刷卡)任一命中即通过的融合识别系统;
  • 有自动唤醒(帧差、人体感应)机制、需要处理空触发的设备;
  • 内存受限(2GB 级)Android 嵌入式设备上的长驻识别状态机。