Эх сурвалжийг харах

refactor(人脸识别):
- `FaceUtil`: 重构人脸识别工具类,引入多重并发控制机制以解决竞态问题,提高稳定性。
- **线程安全**:
- 引入`Mutex`(`detectGate`, `arcLock`, `hlkLock`)和`ReentrantLock`(`cameraLock`),分别用于保护人脸预览处理、ArcSoft引擎生命周期、HLK模组调用和相机生命周期的并发访问,彻底解决了因多线程调用导致的引擎崩溃或状态错乱问题。
- 废弃了`inDetecting`标志位,改用更可靠的`Mutex`进行互斥。
- 使用固定的`FaceEngine-Serial`单线程调度器(`feDispatcher`)来串行化所有对ArcSoft引擎的操作(如`init`/`unInit`、检测、注册、比对),避免了引擎内部的并发冲突。
- **性能与体验**:
- 在`checkCamera`流程中实现“命中即停”逻辑:一旦人脸识别成功(相似度>0.5),立即通过`stopAfterHit`原子标志位拦截后续的所有预览帧处理,并停止相机,显著降低了识别成功后的CPU占用和延迟。
- 对`initCamera`和`checkCamera`的回调进行节流处理(1秒1次),避免因预览帧率过高而频繁触发上层UI更新,减轻UI线程压力。
- 在HLK模式下,`onPreview`回调中对MLKit的人脸检测也增加了节流和`mlDetectRunning`原子锁,防止并发处理。
- **鲁棒性与初始化**:
- 新增`ensureArcReady`方法,在执行ArcSoft相关操作前,会自动检查并完成引擎的激活和初始化,确保引擎始终处于可用状态。
- 通过`WeakReference`持有`Context`,避免内存泄漏,并用于在需要时懒加载引擎。
- 优化了`registerFace`和`verifyFaceArcSoft`方法,将其执行逻辑全部迁移到`feDispatcher`单线程调度器中,并增加了`ensureArcReady`保护。
- **HLK模组驱动**:
- `Hlk223Client`: 修正了`startVerifyWithNotes`中对人脸框(FaceState NOTE)坐标的解析逻辑。增加了对Q8.8定点数格式的判断和缩放处理,并正确实现了从传感器坐标系(640x480)到顺时针旋转270°后的显示坐标系(480x640)的转换,确保了人脸框在预览画面上的正确定位。
- `Hlk223PhotoEnroll`: 增强了照片注册流程的稳定性和超时处理。为分包发送的不同阶段(首包、中间包、末包)设置了不同的超时时间,并优化了REPLY响应的等待和解析逻辑,提高了大批量照片注册的成功率。
- `FaceOverlayView`: 重构了人脸框绘制控件的内部实现和注释,使其逻辑更清晰、性能更优。

feat(人脸识别):
- `checkCamera`回调接口增加`faceRect`参数,允许上层在识别成功时获取人脸位置框,用于UI绘制。
- 在登录弹窗 (`iscs_lock`, `iscs_mc`) 中,利用新增的`faceRect`参数,通过`FaceOverlayView`在预览画面上绘制出识别到的人脸框,提升了用户交互的直观性。

周文健 2 долоо хоног өмнө
parent
commit
aa50e3f683

+ 405 - 259
data/src/main/java/com/grkj/data/hardware/face/FaceUtil.kt

@@ -30,27 +30,30 @@ import com.grkj.shared.utils.extension.isInCenterArea
 import com.grkj.shared.utils.face.arcsoft.CameraHelper
 import com.grkj.shared.utils.face.arcsoft.CameraListener
 import com.grkj.shared.widget.FaceOverlayView
-import com.sik.sikcore.extension.toJson
 import com.sik.sikcore.thread.ThreadUtils
 import com.sik.sikimage.ImageConvertUtils
 import com.sik.sikimage.ImageUtils
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import org.json.JSONObject
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
 import java.io.File
+import java.lang.ref.WeakReference
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
 import java.util.zip.CRC32
-import kotlin.math.log
+import kotlin.coroutines.CoroutineContext
+import java.util.concurrent.locks.ReentrantLock
 
 /**
  * FaceUtil 人脸:兼容 HLK 模组;对外 API 不变
  * - HLK 模式:检测/活体/识别走模组 NOTE/REPLY;APP 只负责预览与回调
- * - ARC 模式:沿用原始 ArcSoft 流程
+ * - ARC 模式:沿用原始 ArcSoft 流程(现已串行化至单线程,避免并发踩崩)
+ * - 本版新增:多维度互斥锁,消除 onPreview/引擎生命周期/HLK 验证的并发竞态
+ * - checkCamera 命中即停:相似度>阈值立即 stop,并拦截后续 onPreview
  */
 object FaceUtil {
     private val logger: Logger = LoggerFactory.getLogger(FaceUtil::class.java)
@@ -69,9 +72,21 @@ object FaceUtil {
     @Volatile
     private var registerUserIdAndLocalUserId: HashMap<Int, Long> = hashMapOf()
 
+    // === 统一上下文持有:懒初始化 Arc 引擎 ===
+    @Volatile
+    private var appContextRef: WeakReference<Context>? = null
+    private fun stashAppContext(ctx: Context?) {
+        if (ctx == null) return
+        val app = ctx.applicationContext ?: ctx
+        if (appContextRef?.get() == null) appContextRef = WeakReference(app)
+    }
+
+    private fun appContext(): Context? = appContextRef?.get()
+
     @JvmStatic
     fun enableHlkBackend(client: Hlk223Client) {
-        hlkClient = client; backend = FaceBackend.HLK
+        hlkClient = client
+        backend = FaceBackend.HLK
         checkLive()
     }
 
@@ -79,19 +94,22 @@ object FaceUtil {
         if (hlkClient == null) return
         hlkVerifyJob?.cancel()
         hlkVerifyJob = ioScope.launch {
-            delay(200)
-            val version = hlkClient?.getVersion()
-            logger.info("hlk version: $version")
-            logger.info("配置参数")
-            hlkClient?.setUvcParam(byteArrayOf(0x20.toByte(), 0b00000010.toByte()))
-            logger.info("清除人脸")
-            hlkClient?.deleteAllUsers()
+            hlkLock.withLock {
+                delay(200)
+                val version = hlkClient?.getVersion()
+                logger.info("hlk version: $version")
+                logger.info("配置参数")
+                hlkClient?.setUvcParam(byteArrayOf(0x20.toByte(), 0b00000010.toByte()))
+                logger.info("清除人脸")
+                hlkClient?.deleteAllUsers()
+            }
         }
     }
 
     @JvmStatic
     fun disableHlkBackend() {
-        backend = FaceBackend.ARC; hlkClient = null
+        backend = FaceBackend.ARC
+        hlkClient = null
     }
 
     // ArcSoft 授权配置(仅 ARC 用)
@@ -118,11 +136,11 @@ object FaceUtil {
                 activeOfflineFilePath = offlineDat.absolutePath
             )
             cfg.writeText(JSONObject().apply {
-                put("appId", def.appId); put("sdkKey", def.sdkKey); put("activeKey", def.activeKey)
-                put("activeOnline", def.activeOnline); put(
-                "activeOfflineFilePath",
-                def.activeOfflineFilePath
-            )
+                put("appId", def.appId)
+                put("sdkKey", def.sdkKey)
+                put("activeKey", def.activeKey)
+                put("activeOnline", def.activeOnline)
+                put("activeOfflineFilePath", def.activeOfflineFilePath)
             }.toString())
             return def
         }
@@ -137,6 +155,29 @@ object FaceUtil {
         )
     }
 
+    // ====== 线程域与调度器(固定,不再动态创建) ======
+    private val appJob = SupervisorJob()
+
+    private val ioScope = CoroutineScope(Dispatchers.IO + appJob)
+    private val defaultScope = CoroutineScope(Dispatchers.Default + appJob)
+
+    // FaceEngine 专用单线程调度器,串行化所有引擎调用,避免并发崩溃
+    private val feExecutor = Executors.newSingleThreadExecutor { r ->
+        Thread(r, "FaceEngine-Serial").apply { isDaemon = true }
+    }
+    private val feDispatcher: CoroutineContext = feExecutor.asCoroutineDispatcher()
+    private val feScope = CoroutineScope(feDispatcher + appJob)
+
+    // —— 并发门闸 —— //
+    // 预览阶段:防止 onPreview 并发进入 ARC 检测(丢帧式)
+    private val detectGate = Mutex()
+    // ARC 引擎生命周期 & 注册/比对串行
+    private val arcLock = Mutex()
+    // HLK 验证流程串行
+    private val hlkLock = Mutex()
+    // 相机生命周期原子化
+    private val cameraLock = ReentrantLock()
+
     // 旧字段(保持)
     private var cameraHelper: CameraHelper? = null
     private var previewSize: Camera.Size? = null
@@ -146,12 +187,14 @@ object FaceUtil {
     private val cameraHeight: Int = 480
     private var afCode = -1
     private val processMask: Int = FaceEngine.ASF_MASK_DETECT or FaceEngine.ASF_LIVENESS
-    private val registerFaceFeatureJob get() = CoroutineScope(Dispatchers.IO + SupervisorJob())
     private const val ACTION_REQUEST_PERMISSIONS: Int = 0x001
     var isActivated = false
 
     @Volatile
-    var inDetecting = false
+    var inDetecting = false // 保留字段但不作为互斥依据
+
+    // —— 命中即停(仅 checkCamera 使用)——
+    private val stopAfterHit = AtomicBoolean(false)
 
     // HLK NOTE 缓存
     @Volatile
@@ -165,15 +208,31 @@ object FaceUtil {
 
     @Volatile
     private var hlkVerifyRunning = false
-    private val ioScope get() = CoroutineScope(Dispatchers.IO + SupervisorJob())
 
-    // 成员区
+    // MLKit & 节流
     @Volatile
     private var mlDetector: com.google.mlkit.vision.face.FaceDetector? = null
 
     @Volatile
     private var lastMlDetectTs = 0L
 
+    @Volatile
+    private var mlDetectRunning = AtomicBoolean(false)
+
+    // 回调节流(统一控制 UI 侧压力)
+    private const val CALLBACK_MIN_INTERVAL_MS = 1000L
+    private val lastInitCbTs = AtomicLong(0L)
+    private val lastCheckCbTs = AtomicLong(0L)
+
+    private fun shouldEmit(lastTs: AtomicLong, now: Long = System.currentTimeMillis()): Boolean {
+        val prev = lastTs.get()
+        return if (now - prev >= CALLBACK_MIN_INTERVAL_MS) {
+            lastTs.set(now); true
+        } else {
+            false
+        }
+    }
+
     private var lastNoFaceTipTs = 0L
     private fun maybeLogNoFaceTip() {
         val now = System.currentTimeMillis()
@@ -198,21 +257,19 @@ object FaceUtil {
         val opts = FaceDetectorOptions.Builder()
             .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
             .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
-            .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) // 不用笑容/眨眼
-            .enableTracking()  // 拿 trackingId 可做稳定性
+            .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
+            .enableTracking()
             .build()
         return com.google.mlkit.vision.face.FaceDetection.getClient(opts).also { mlDetector = it }
     }
 
-
     // ===== ARC 激活/初始化 =====
     fun checkActiveStatus(context: Context) {
+        stashAppContext(context)
         if (backend == FaceBackend.HLK) {
             isActivated = true; return
         }
-        if (isActivated) {
-            return
-        }
+        if (isActivated) return
         val cfg = readOrInitConfig(context)
         val code = try {
             if (cfg.activeOnline) FaceEngine.activeOnline(
@@ -232,37 +289,62 @@ object FaceUtil {
             }
         }
         val afi = ActiveFileInfo()
-        if (FaceEngine.getActiveFileInfo(
-                context,
-                afi
-            ) == ErrorInfo.MOK
-        ) logger.info("getActiveFileInfo: $afi")
+        if (FaceEngine.getActiveFileInfo(context, afi) == ErrorInfo.MOK)
+            logger.info("getActiveFileInfo: $afi")
     }
 
     fun initEngine(context: Context) {
+        stashAppContext(context)
         if (backend == FaceBackend.HLK) return
-        if (faceEngine != null) {
-            return
+        runBlocking(feDispatcher) {
+            arcLock.withLock {
+                if (faceEngine != null) return@withLock
+                faceEngine = FaceEngine()
+                afCode = faceEngine!!.init(
+                    context,
+                    DetectMode.ASF_DETECT_MODE_VIDEO,
+                    DetectFaceOrientPriority.ASF_OP_0_ONLY, 1,
+                    FaceEngine.ASF_FACE_DETECT or FaceEngine.ASF_MASK_DETECT or
+                            FaceEngine.ASF_LIVENESS or FaceEngine.ASF_FACE_RECOGNITION
+                )
+                logger.info("initEngine: $afCode")
+            }
         }
-        faceEngine = FaceEngine()
-        afCode = faceEngine!!.init(
-            context,
-            DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_0_ONLY, 1,
-            FaceEngine.ASF_FACE_DETECT or FaceEngine.ASF_MASK_DETECT or FaceEngine.ASF_LIVENESS or FaceEngine.ASF_FACE_RECOGNITION
-        )
-        logger.info("initEngine: $afCode")
     }
 
     fun unInitEngine() {
         if (backend == FaceBackend.HLK) return
-        if (afCode == 0) {
-            afCode = faceEngine!!.unInit(); logger.info("unInitEngine: $afCode")
+        runBlocking(feDispatcher) {
+            arcLock.withLock {
+                if (afCode == 0 && faceEngine != null) {
+                    afCode = faceEngine!!.unInit()
+                    logger.info("unInitEngine: $afCode")
+                }
+                faceEngine = null
+            }
+        }
+    }
+
+    @Synchronized
+    private fun ensureArcReady(): Boolean {
+        if (backend == FaceBackend.HLK) return true
+        return runBlocking(feDispatcher) {
+            arcLock.withLock {
+                if (isActivated && faceEngine != null && afCode == 0) return@withLock true
+                val ctx = appContext()
+                if (ctx == null) {
+                    logger.error("ensureArcReady: 缺少可用 Context"); return@withLock false
+                }
+                if (!isActivated) checkActiveStatus(ctx)
+                if (faceEngine == null || afCode != 0) initEngine(ctx)
+                val ok = isActivated && faceEngine != null && afCode == 0
+                if (!ok) logger.error("ensureArcReady: 初始化失败 isActivated=$isActivated, faceEngine=${faceEngine != null}, afCode=$afCode")
+                ok
+            }
         }
     }
 
     private fun mirrorRect(src: Rect, width: Int): Rect {
-        // ML Kit 坐标:以图像 buffer 为参考,左上为原点
-        // 镜像:x' = width - (x + w)
         val left = width - (src.left + src.width())
         val right = width - src.left
         return Rect(left, src.top, right, src.bottom)
@@ -276,6 +358,8 @@ object FaceUtil {
         needCheckCenter: Boolean = false,
         callBack: (Bitmap?, Int, Boolean) -> Unit
     ) {
+        stashAppContext(preview.context)
+
         if (rgbCameraId == null) {
             rgbCameraId = findCameraIdByFacing(Camera.CameraInfo.CAMERA_FACING_FRONT)
                 ?: findCameraIdByFacing(Camera.CameraInfo.CAMERA_FACING_BACK)
@@ -298,18 +382,20 @@ object FaceUtil {
                     hlkVerifyJob = ioScope.launch {
                         ensureMlDetector()
                         try {
-                            hlkClient?.startVerifyWithNotes(
-                                timeoutSec = 60, loop = true,
-                                onFaceState = { rect, state, yaw, pitch, roll ->
-                                    logger.info("onFaceState: $rect, $state, $yaw, $pitch, $roll")
-                                    lastFaceRectByHlk = rect
-                                    lastAliveByHlk = true
-                                    faceOverlayView?.setFaceRect(rect?.let { listOf(it) }
-                                        ?: emptyList())
-                                },
-                                onLiveness = { alive -> lastAliveByHlk = true },
-                                onResult = { userId -> lastUserIdByHlk = userId }
-                            )
+                            hlkLock.withLock {
+                                hlkClient?.startVerifyWithNotes(
+                                    timeoutSec = 60, loop = true,
+                                    onFaceState = { rect, state, yaw, pitch, roll ->
+                                        lastFaceRectByHlk = rect
+                                        lastAliveByHlk = true
+                                        ThreadUtils.runOnMain {
+                                            faceOverlayView?.setFaceRect(rect?.let { listOf(it) } ?: emptyList())
+                                        }
+                                    },
+                                    onLiveness = { _ -> lastAliveByHlk = true },
+                                    onResult = { userId -> lastUserIdByHlk = userId }
+                                )
+                            }
                         } finally {
                             hlkVerifyRunning = false
                         }
@@ -320,77 +406,82 @@ object FaceUtil {
             override fun onPreview(nv21: ByteArray, camera: Camera?) {
                 val p = previewSize ?: return
 
+                // HLK 路径:仅做 MLKit 画框与节流回调
                 if (backend == FaceBackend.HLK) {
-                    // HLK:持续回预览;人脸数量固定 1;活体来自 NOTE
-                    // —— ML Kit 节流:~120ms 一次 ——
                     val now = System.currentTimeMillis()
                     val doDetect = (now - lastMlDetectTs) > 120
-                    if (doDetect) {
+                    if (doDetect && mlDetectRunning.compareAndSet(false, true)) {
                         lastMlDetectTs = now
-                        // rotation:基于你已有的 DisplayUtils
-                        val rotation = DisplayUtils.getRotation(preview.context) // 0/90/180/270
+                        val rotation = DisplayUtils.getRotation(preview.context)
                         val imageData = ImageUtils.rotateNV21(nv21, p.width, p.height, 270)
                         val image = InputImage.fromByteArray(
                             imageData, p.width, p.height, rotation, ImageFormat.NV21
                         )
-                        val bmp = ImageConvertUtils.nv21ToBitmap(imageData, p.width, p.height)
-                        ensureMlDetector().process(image)
+
+                        ensureMlDetector()
+                            .process(image)
                             .addOnSuccessListener { faces ->
-                                // 镜像修正:相机是 isMirror(true) → X 轴翻转
-                                val rects = faces.map { f ->
-                                    val r = f.boundingBox
-                                    mirrorRect(r, p.width) // 见下方函数
+                                val rects = faces.map { f -> mirrorRect(f.boundingBox, p.width) }
+                                defaultScope.launch(Dispatchers.Main) {
+                                    faceOverlayView?.setFaceRect(rects)
                                 }
-                                faceOverlayView?.setFaceRect(rects)
-                                // 可做中心判定
-                                val inCenter =
-                                    rects.firstOrNull()?.isInCenterArea(p.width, p.height) ?: false
+                                val inCenter = rects.firstOrNull()?.isInCenterArea(p.width, p.height) ?: false
                                 if (!inCenter) maybeLogNoFaceTip()
-                                // 不在此处回调活体;活体交给 HLK NOTE
-                                // —— 回调:人脸数量来自 MLKit(最近一次),活体来自 HLK NOTE ——
-                                // 为了简单起见,这里用 overlay 里最新的 rect 数量(或你自己存储 lastFaceCountByMl)
-                                val faceCount = faceOverlayView?.lastRectsCount()
-                                    ?: (if (lastFaceRectByHlk != null) 1 else 0)
-
-                                // HLK 路径下仍然以 HLK 的活体为准
-                                callBack(bmp, faceCount, lastAliveByHlk)
+
+                                if (shouldEmit(lastInitCbTs)) {
+                                    ioScope.launch {
+                                        val bmp = ImageConvertUtils.nv21ToBitmap(imageData, p.width, p.height)
+                                        ThreadUtils.runOnMain { callBack(bmp, rects.size, lastAliveByHlk) }
+                                    }
+                                }
                             }
                             .addOnFailureListener {
-                                // 静默或日志
                                 logger.warn("MLKit detect failed: ${it.message}")
                             }
+                            .addOnCompleteListener {
+                                mlDetectRunning.set(false)
+                            }
                     }
                     return
                 }
 
-                // ARC:原流程
-                if (inDetecting) return
-                inDetecting = true
-                val fe = faceEngine ?: run { inDetecting = false; return }
-                val faces = mutableListOf<FaceInfo>()
-                var code = fe.detectFaces(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces)
-                faceOverlayView?.setFaceRect(faces.map { it.rect })
-                if (code != ErrorInfo.MOK || faces.isEmpty()) {
-                    maybeLogNoFaceTip(); inDetecting = false; return
-                }
-                code =
-                    fe.process(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces, processMask)
-                if (code != ErrorInfo.MOK) {
-                    inDetecting = false; return
-                }
-                val liveList = mutableListOf<LivenessInfo>()
-                if (fe.getLiveness(liveList) != ErrorInfo.MOK) {
-                    inDetecting = false; return
-                }
-                if (liveList.none { it.liveness == LivenessInfo.ALIVE }) {
-                    callBack(null, faces.size, false); inDetecting = false; return
-                }
-                if (needCheckCenter && !faces[0].rect.isInCenterArea(p.width, p.height)) {
-                    inDetecting = false; return
+                // ARC 路径(FaceEngine 串行执行 + 丢帧互斥)
+                if (!ensureArcReady()) return
+                if (!detectGate.tryLock()) return
+
+                feScope.launch {
+                    try {
+                        val fe = faceEngine ?: return@launch
+                        val faces = mutableListOf<FaceInfo>()
+
+                        var code = fe.detectFaces(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces)
+                        withContext(Dispatchers.Main) { faceOverlayView?.setFaceRect(faces.map { it.rect }) }
+                        if (code != ErrorInfo.MOK || faces.isEmpty()) {
+                            maybeLogNoFaceTip(); return@launch
+                        }
+
+                        code = fe.process(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces, processMask)
+                        if (code != ErrorInfo.MOK) return@launch
+
+                        val liveList = mutableListOf<LivenessInfo>()
+                        if (fe.getLiveness(liveList) != ErrorInfo.MOK) return@launch
+                        val alive = liveList.any { it.liveness == LivenessInfo.ALIVE }
+                        if (!alive) {
+                            if (shouldEmit(lastInitCbTs)) ThreadUtils.runOnMain { callBack(null, faces.size, false) }
+                            return@launch
+                        }
+                        if (needCheckCenter && !faces[0].rect.isInCenterArea(p.width, p.height)) return@launch
+
+                        if (shouldEmit(lastInitCbTs)) {
+                            val bmp = withContext(Dispatchers.IO) { ImageConvertUtils.nv21ToBitmap(nv21, p.width, p.height) }
+                            ThreadUtils.runOnMain { callBack(bmp, faces.size, true) }
+                        }
+                    } catch (e: Throwable) {
+                        logger.warn("ARC detect error: ${e.message}", e)
+                    } finally {
+                        if (detectGate.isLocked) detectGate.unlock()
+                    }
                 }
-                val bmp = ImageConvertUtils.nv21ToBitmap(nv21, p.width, p.height)
-                callBack(bmp, faces.size, true)
-                inDetecting = false
             }
 
             override fun onCameraClosed() {}
@@ -410,15 +501,26 @@ object FaceUtil {
             .previewOn(preview)
             .cameraListener(listener)
             .build()
-        cameraHelper!!.init()
-        cameraHelper!!.start()
+
+        cameraLock.lock()
+        try {
+            cameraHelper!!.init()
+            cameraHelper!!.start()
+        } finally {
+            cameraLock.unlock()
+        }
     }
 
-    // ================= checkCamera =================
+    // ================= checkCamera:命中即停 =================
     fun checkCamera(
         preview: View,
-        callBack: (Bitmap?, Long?) -> Unit
+        callBack: (Bitmap?, face: Rect?, Long?) -> Unit
     ) {
+        stashAppContext(preview.context)
+
+        // 进入 checkCamera 时重置“命中即停”标记
+        stopAfterHit.set(false)
+
         if (rgbCameraId == null) {
             rgbCameraId = findCameraIdByFacing(Camera.CameraInfo.CAMERA_FACING_FRONT)
                 ?: findCameraIdByFacing(Camera.CameraInfo.CAMERA_FACING_BACK)
@@ -439,15 +541,16 @@ object FaceUtil {
                     hlkVerifyJob?.cancel()
                     hlkVerifyJob = ioScope.launch {
                         try {
-                            hlkClient?.startVerifyWithNotes(
-                                timeoutSec = 15, loop = true,
-                                onFaceState = { rect, state, yaw, pitch, roll ->
-                                    logger.info("onFaceState: $rect, $state, $yaw, $pitch, $roll")
-                                    lastFaceRectByHlk = rect; lastAliveByHlk = (state == 0)
-                                },
-                                onLiveness = { alive -> lastAliveByHlk = alive },
-                                onResult = { userId -> lastUserIdByHlk = userId }
-                            )
+                            hlkLock.withLock {
+                                hlkClient?.startVerifyWithNotes(
+                                    timeoutSec = 15, loop = true,
+                                    onFaceState = { rect, state, _, _, _ ->
+                                        lastFaceRectByHlk = rect; lastAliveByHlk = (state == 0)
+                                    },
+                                    onLiveness = { alive -> lastAliveByHlk = alive },
+                                    onResult = { userId -> lastUserIdByHlk = userId }
+                                )
+                            }
                         } finally {
                             hlkVerifyRunning = false
                         }
@@ -458,56 +561,85 @@ object FaceUtil {
             override fun onPreview(nv21: ByteArray?, camera: Camera?) {
                 val p = previewSize ?: return
 
+                // 命中后拦截一切后续 onPreview
+                if (stopAfterHit.get()) return
+
                 if (backend == FaceBackend.HLK) {
-                    val bmp = ImageConvertUtils.nv21ToBitmap(nv21, p.width, p.height)
-                    callBack(bmp, registerUserIdAndLocalUserId[lastUserIdByHlk])
+                    if (shouldEmit(lastCheckCbTs)) {
+                        ioScope.launch {
+                            val data = nv21 ?: return@launch
+                            val bmp = ImageConvertUtils.nv21ToBitmap(data, p.width, p.height)
+                            ThreadUtils.runOnMain {
+                                callBack(bmp, lastFaceRectByHlk, registerUserIdAndLocalUserId[lastUserIdByHlk])
+                            }
+                        }
+                    }
                     return
                 }
 
-                // ARC:原流程
-                if (inDetecting) return
-                inDetecting = true
-                val bmp = ImageConvertUtils.nv21ToBitmap(nv21, p.width, p.height)
-                val fe = faceEngine ?: run { inDetecting = false; return }
-                val faces = mutableListOf<FaceInfo>()
-                var code = fe.detectFaces(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces)
-                if (code != ErrorInfo.MOK || faces.isEmpty()) {
-                    inDetecting = false; return
-                }
-                code =
-                    fe.process(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces, processMask)
-                if (code != ErrorInfo.MOK) {
-                    inDetecting = false; return
-                }
-                val liveList = mutableListOf<LivenessInfo>()
-                val lc = fe.getLiveness(liveList)
-                if (lc != ErrorInfo.MOK) {
-                    inDetecting = false; return
-                }
-                if (liveList.none { it.liveness == LivenessInfo.ALIVE }) {
-                    callBack(null, null); inDetecting = false; return
-                }
-                val ft = FaceFeature()
-                fe.extractFaceFeature(
-                    nv21,
-                    p.width,
-                    p.height,
-                    FaceEngine.CP_PAF_NV21,
-                    faces[0],
-                    ExtractType.RECOGNIZE,
-                    0,
-                    ft
-                )
-                val searchResult =
-                    runCatching { if (fe.faceCount > 0) fe.searchFaceFeature(ft) else null }.getOrNull()
-                logger.info("人脸结果:${searchResult?.maxSimilar}")
-                if ((searchResult?.maxSimilar ?: 0f) > 0.5f) {
-                    callBack(bmp, searchResult?.faceFeatureInfo?.searchId?.toLong())
-                } else {
-                    callBack(null, null)
-                }
-                ThreadUtils.runOnMainDelayed(1000) {
-                    inDetecting = false
+                if (!ensureArcReady()) return
+                if (!detectGate.tryLock()) return
+
+                feScope.launch {
+                    var requestStop = false
+                    try {
+                        val data = nv21 ?: return@launch
+                        val fe = faceEngine ?: return@launch
+                        val faces = mutableListOf<FaceInfo>()
+                        var code = fe.detectFaces(data, p.width, p.height, FaceEngine.CP_PAF_NV21, faces)
+                        if (code != ErrorInfo.MOK || faces.isEmpty()) return@launch
+                        code = fe.process(data, p.width, p.height, FaceEngine.CP_PAF_NV21, faces, processMask)
+                        if (code != ErrorInfo.MOK) return@launch
+
+                        val liveList = mutableListOf<LivenessInfo>()
+                        val lc = fe.getLiveness(liveList)
+                        if (lc != ErrorInfo.MOK) return@launch
+                        val alive = liveList.any { it.liveness == LivenessInfo.ALIVE }
+                        if (!alive) {
+                            if (shouldEmit(lastCheckCbTs)) ThreadUtils.runOnMain { callBack(null, null, null) }
+                            return@launch
+                        }
+
+                        val ft = FaceFeature()
+                        fe.extractFaceFeature(
+                            data, p.width, p.height, FaceEngine.CP_PAF_NV21,
+                            faces[0], ExtractType.RECOGNIZE, 0, ft
+                        )
+
+                        val searchResult = runCatching { if (fe.faceCount > 0) fe.searchFaceFeature(ft) else null }.getOrNull()
+                        val score = searchResult?.maxSimilar ?: 0f
+                        logger.info("人脸库数:${fe.faceCount},相似度:${score}")
+
+                        // 命中即停:>0.5 立即回调 + 停止相机 + 拦截后续帧
+                        if (score > 0.5f) {
+                            if (stopAfterHit.compareAndSet(false, true)) {
+                                if (shouldEmit(lastCheckCbTs)) {
+                                    val bmp = withContext(Dispatchers.IO) { ImageConvertUtils.nv21ToBitmap(data, p.width, p.height) }
+                                    ThreadUtils.runOnMain {
+                                        callBack(
+                                            bmp,
+                                            faces[0].rect,
+                                            searchResult?.faceFeatureInfo?.searchId?.toLong()
+                                        )
+                                    }
+                                }
+                                requestStop = true // 解锁之后再 stop()
+                            }
+                            return@launch
+                        }
+
+                        // 未命中:按原逻辑回调空
+                        if (shouldEmit(lastCheckCbTs)) ThreadUtils.runOnMain { callBack(null, null, null) }
+
+                    } catch (e: Throwable) {
+                        logger.warn("ARC check error: ${e.message}", e)
+                    } finally {
+                        if (detectGate.isLocked) detectGate.unlock()
+                        // 命中后再真正停止,避免持锁调用 stop()
+                        if (requestStop) {
+                            ThreadUtils.runOnMain { stop() }
+                        }
+                    }
                 }
             }
 
@@ -528,109 +660,120 @@ object FaceUtil {
             .previewOn(preview)
             .cameraListener(listener)
             .build()
-        cameraHelper!!.init()
-        cameraHelper!!.start()
+
+        cameraLock.lock()
+        try {
+            cameraHelper!!.init()
+            cameraHelper!!.start()
+        } finally {
+            cameraLock.unlock()
+        }
     }
 
     fun stop() {
-        cameraHelper?.release()
-        cameraHelper = null
-        hlkVerifyJob?.cancel()
-        hlkVerifyJob = null
-        hlkClient?.stopVerify() // 双保险:让设备侧监听循环马上跳出
+        cameraLock.lock()
+        try {
+            cameraHelper?.release()
+            cameraHelper = null
+            hlkVerifyJob?.cancel()
+            hlkVerifyJob = null
+            ioScope.launch {
+                hlkLock.withLock {
+                    hlkClient?.stopVerify()
+                }
+            }
+        } finally {
+            cameraLock.unlock()
+        }
+        // 注意:不 cancel appJob,便于 FaceUtil 复用生命周期
+        // 如需彻底销毁,可在外部生命周期结束时调用:
+        // appJob.cancel(); feExecutor.shutdownNow()
     }
 
-    // ----------------- 注册/比对(仅 ARC 有效) -----------------
+    // ----------------- 注册/比对(仅 ARC 有效,已串行化) -----------------
     fun registerFace(faceData: List<Pair<Long, String>>) {
         if (backend == FaceBackend.HLK) {
-            // HLK 下请走 Hlk223PhotoEnroll / ENROLL_ITG
-
             faceData.forEach { (uid, b64) ->
                 ThreadUtils.runOnIO {
-                    val jpegBytes = ImageCompress.base64ToJpegUnder(b64, 1000 * 2)
-                    val crc32 = CRC32().apply { update(jpegBytes) }.value.toInt()
+                    val jpegBytes = ImageCompress.base64ToJpegUnder(b64, 1500)
+                    val crc32 = CRC32().apply { update(jpegBytes) }.value
                     logger.info("图片大小:{}", jpegBytes.size)
                     val userId = hlkClient?.enrollWithPhoto(jpegBytes, crc32 = crc32)
-                    userId?.let {
-                        registerUserIdAndLocalUserId[it] = uid
-                    }
+                    userId?.let { registerUserIdAndLocalUserId[it] = uid }
                     logger.info("注册Id:{}", userId)
                 }
             }
             return
         }
-        faceEngine?.removeFaceFeature(-1)
-        faceData.forEach { (uid, b64) ->
-            val bmp = decodeBase64ToBitmap(b64)
-            val img = bitmapToBgr24(bmp)
-            val faces = mutableListOf<FaceInfo>()
-            val code =
-                faceEngine?.detectFaces(img, bmp.width, bmp.height, FaceEngine.CP_PAF_BGR24, faces)
-            if (faces.isNullOrEmpty()) return@forEach
-            val faceFeature = FaceFeature()
-            faceEngine?.extractFaceFeature(
-                img,
-                bmp.width,
-                bmp.height,
-                FaceEngine.CP_PAF_BGR24,
-                faces[0],
-                ExtractType.REGISTER,
-                0,
-                faceFeature
-            )
-            val info = FaceFeatureInfo(uid.toInt(), faceFeature.featureData)
-            faceEngine?.registerFaceFeature(info)
-//            try {
-//                if ((faceEngine?.searchFaceFeature(faceFeature)?.maxSimilar ?: 0f) > 0.5) {
-//                    faceEngine?.updateFaceFeature(info)
-//                } else {
-//                    faceEngine?.registerFaceFeature(info)
-//                }
-//            } catch (_: Exception) {
-//                faceEngine?.registerFaceFeature(info)
-//            }
+        if (!ensureArcReady()) return
+
+        feScope.launch {
+            arcLock.withLock {
+                logger.info("注册人脸:${faceData.map { it.first }}")
+                faceData.forEach { (uid, b64) ->
+                    val bmp = decodeBase64ToBitmap(b64)
+                    val img = bitmapToBgr24(bmp)
+                    val faces = mutableListOf<FaceInfo>()
+                    val code = faceEngine?.detectFaces(
+                        img, bmp.width, bmp.height, FaceEngine.CP_PAF_BGR24, faces
+                    )
+                    logger.info("人脸检查结果:${uid},${faces.size},${code}")
+                    if (faces.isNullOrEmpty() || code != ErrorInfo.MOK) return@forEach
+                    val faceFeature = FaceFeature()
+                    faceEngine?.extractFaceFeature(
+                        img, bmp.width, bmp.height, FaceEngine.CP_PAF_BGR24,
+                        faces[0], ExtractType.REGISTER, 0, faceFeature
+                    )
+                    val info = FaceFeatureInfo(uid.toInt(), faceFeature.featureData)
+                    if ((faceEngine?.faceCount ?: 0) > 0) {
+                        if (faceEngine?.getFaceFeature(uid.toInt()) != null) {
+                            faceEngine?.updateFaceFeature(info)
+                        } else {
+                            faceEngine?.registerFaceFeature(info)
+                        }
+                    } else {
+                        faceEngine?.registerFaceFeature(info)
+                    }
+                }
+            }
         }
     }
 
     fun verifyFaceArcSoft(b64a: String, b64b: String, threshold: Float = 0.7f): Boolean {
         if (backend == FaceBackend.HLK) return false
         if (b64a.isEmpty() || b64b.isEmpty()) return false
-        val bmpA = decodeBase64ToBitmap(b64a)
-        val bmpB = decodeBase64ToBitmap(b64b)
-        val facesA = mutableListOf<FaceInfo>()
-        val facesB = mutableListOf<FaceInfo>()
-        val imgA = bitmapToBgr24(bmpA)
-        val imgB = bitmapToBgr24(bmpB)
-        val codeA =
-            faceEngine?.detectFaces(imgA, bmpA.width, bmpA.height, FaceEngine.CP_PAF_BGR24, facesA)
-        val codeB =
-            faceEngine?.detectFaces(imgB, bmpB.width, bmpB.height, FaceEngine.CP_PAF_BGR24, facesB)
-        if (codeA != ErrorInfo.MOK || codeB != ErrorInfo.MOK || facesA.isEmpty() || facesB.isEmpty()) return false
-        val ftA = FaceFeature();
-        val ftB = FaceFeature()
-        faceEngine?.extractFaceFeature(
-            imgA,
-            bmpA.width,
-            bmpA.height,
-            FaceEngine.CP_PAF_BGR24,
-            facesA[0],
-            ExtractType.RECOGNIZE,
-            0,
-            ftA
-        )
-        faceEngine?.extractFaceFeature(
-            imgB,
-            bmpB.width,
-            bmpB.height,
-            FaceEngine.CP_PAF_BGR24,
-            facesB[0],
-            ExtractType.RECOGNIZE,
-            0,
-            ftB
-        )
-        val sim = FaceSimilar()
-        if (faceEngine?.compareFaceFeature(ftA, ftB, sim) != ErrorInfo.MOK) return false
-        return sim.score >= threshold
+        if (!ensureArcReady()) return false
+
+        return runBlocking(feDispatcher) {
+            arcLock.withLock {
+                val bmpA = decodeBase64ToBitmap(b64a)
+                val bmpB = decodeBase64ToBitmap(b64b)
+                val facesA = mutableListOf<FaceInfo>()
+                val facesB = mutableListOf<FaceInfo>()
+                val imgA = bitmapToBgr24(bmpA)
+                val imgB = bitmapToBgr24(bmpB)
+                val codeA = faceEngine?.detectFaces(
+                    imgA, bmpA.width, bmpA.height, FaceEngine.CP_PAF_BGR24, facesA
+                )
+                val codeB = faceEngine?.detectFaces(
+                    imgB, bmpB.width, bmpB.height, FaceEngine.CP_PAF_BGR24, facesB
+                )
+                if (codeA != ErrorInfo.MOK || codeB != ErrorInfo.MOK || facesA.isEmpty() || facesB.isEmpty()) return@withLock false
+                val ftA = FaceFeature()
+                val ftB = FaceFeature()
+                faceEngine?.extractFaceFeature(
+                    imgA, bmpA.width, bmpA.height, FaceEngine.CP_PAF_BGR24,
+                    facesA[0], ExtractType.RECOGNIZE, 0, ftA
+                )
+                faceEngine?.extractFaceFeature(
+                    imgB, bmpB.width, bmpB.height, FaceEngine.CP_PAF_BGR24,
+                    facesB[0], ExtractType.RECOGNIZE, 0, ftB
+                )
+                val sim = FaceSimilar()
+                if (faceEngine?.compareFaceFeature(ftA, ftB, sim) != ErrorInfo.MOK) return@withLock false
+                sim.score >= threshold
+            }
+        }
     }
 
     // ===== 工具 =====
@@ -640,7 +783,7 @@ object FaceUtil {
     }
 
     private fun bitmapToBgr24(bitmap: Bitmap): ByteArray {
-        val w = bitmap.width;
+        val w = bitmap.width
         val h = bitmap.height
         val pixels = IntArray(w * h); bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
         val bgr = ByteArray(w * h * 3)
@@ -654,13 +797,16 @@ object FaceUtil {
     }
 
     /**
-     * 删除人脸
+     * 删除人脸(HLK)
      */
     fun clearFace(userId: Long?) {
-        userId?.let { userId ->
-            registerUserIdAndLocalUserId.entries.find { it.value == userId }?.key?.let {
+        userId?.let { uid ->
+            registerUserIdAndLocalUserId.entries.find { it.value == uid }?.key?.let {
                 ThreadUtils.runOnIO {
-                    hlkClient?.deleteUser(it)
+                    hlkLock.withLock {
+                        faceEngine?.removeFaceFeature(it)
+                        hlkClient?.deleteUser(it)
+                    }
                 }
             }
         }

+ 85 - 17
data/src/main/java/com/grkj/data/hardware/face/hlk/Hlk223Client.kt

@@ -243,22 +243,90 @@ class Hlk223Client(
                                 when (nid) {
                                     NID_FACE_STATE -> if (data.size >= 1 + 16) {
                                         logger.info("人脸数据:${data.toHexStrings()}")
-                                        fun s(i: Int) = s16(u8(data[i + 1]), u8(data[i]))
-                                        val st = s(1)
-                                        val left = s(3);
-                                        val top = s(5);
-                                        val right = s(7);
-                                        val bottom = s(9)
-                                        val yaw = s(11);
-                                        val pitch = s(13);
-                                        val roll = s(15)
-                                        val rect = if (right > left && bottom > top) Rect(
-                                            left,
-                                            top,
-                                            right,
-                                            bottom
-                                        ) else null
-                                        logger.info("人脸位置:${left},${top},${right},${bottom}")
+
+                                        // --- 基础读法:LE,从 index=1 开始连读 8 个 int16_t ---
+                                        fun u8(b: Byte) = b.toInt() and 0xFF
+                                        fun s16(lo: Int, hi: Int) = ((hi shl 8) or lo).toShort().toInt()
+                                        fun sAt(i: Int) = s16(u8(data[i]), u8(data[i + 1])) // LE: lo=data[i], hi=data[i+1]
+
+                                        val st    = sAt(1)
+                                        val leftD = sAt(3)
+                                        val topD  = sAt(5)
+                                        val rightD= sAt(7)
+                                        val botD  = sAt(9)
+                                        val yaw   = sAt(11)
+                                        val pitch = sAt(13)
+                                        val roll  = sAt(15)
+
+                                        // --- 传感器原始分辨率(未旋转):你说的是 640x480 ---
+                                        val sensorW = 640
+                                        val sensorH = 480
+
+                                        // --- 若数值远超尺寸,按 Q8.8 归一(÷256);否则不缩放 ---
+                                        val maxDim = maxOf(sensorW, sensorH)
+                                        val scale = if (
+                                            kotlin.math.abs(leftD)  > maxDim * 4 ||
+                                            kotlin.math.abs(topD)   > maxDim * 4 ||
+                                            kotlin.math.abs(rightD) > maxDim * 4 ||
+                                            kotlin.math.abs(botD)   > maxDim * 4
+                                        ) 256f else 1f
+
+                                        fun fs(v: Int) = v / scale
+
+                                        // --- “到边距离” -> 原始坐标 LTRB(可能有负值,允许越界) ---
+                                        val l0 = fs(leftD)
+                                        val t0 = fs(topD)
+                                        val r0 = sensorW - fs(rightD)
+                                        val b0 = sensorH - fs(botD)
+
+                                        // --- 旋转:顺时针 270° 从传感器坐标 -> 显示坐标(显示为 480x640) ---
+                                        fun rotPointCW270(x: Float, y: Float, w: Int, h: Int): Pair<Float, Float> {
+                                            // 270°: (x, y) -> (y, w - 1 - x)
+                                            return Pair(y, (w - 1) - x)
+                                        }
+                                        fun rotRectCW270(l: Float, t: Float, r: Float, b: Float, w: Int, h: Int): android.graphics.Rect {
+                                            val pts = arrayOf(
+                                                rotPointCW270(l, t, w, h),
+                                                rotPointCW270(r, t, w, h),
+                                                rotPointCW270(l, b, w, h),
+                                                rotPointCW270(r, b, w, h),
+                                            )
+                                            val xs = pts.map { it.first }
+                                            val ys = pts.map { it.second }
+                                            val L = xs.min().toInt()
+                                            val R = xs.max().toInt()
+                                            val T = ys.min().toInt()
+                                            val B = ys.max().toInt()
+                                            return android.graphics.Rect(L, T, R, B)
+                                        }
+
+                                        val rectSensor = android.graphics.Rect(
+                                            l0.toInt(), t0.toInt(), r0.toInt(), b0.toInt()
+                                        )
+
+                                        // 旋转到显示坐标(宽高会互换:480x640)
+                                        val rectDisplay = rotRectCW270(
+                                            l0, t0, r0, b0,
+                                            sensorW, sensorH
+                                        )
+
+                                        // 可选:裁剪到显示区域(避免越界影响绘制)
+                                        val displayW = 480
+                                        val displayH = 640
+                                        val clipped = android.graphics.Rect(
+                                            rectDisplay.left.coerceIn(-displayW, displayW * 2),
+                                            rectDisplay.top.coerceIn(-displayH, displayH * 2),
+                                            rectDisplay.right.coerceIn(-displayW, displayW * 2),
+                                            rectDisplay.bottom.coerceIn(-displayH, displayH * 2)
+                                        )
+                                        val rect =
+                                            if (clipped.right > clipped.left && clipped.bottom > clipped.top) clipped else null
+
+                                        logger.info(
+                                            "人脸原始(LTRB@sensor): ${rectSensor.left},${rectSensor.top},${rectSensor.right},${rectSensor.bottom} " +
+                                                    " -> 显示(LTRB@270°): ${rect?.left},${rect?.top},${rect?.right},${rect?.bottom} scale=$scale"
+                                        )
+
                                         onFaceState(rect, st, yaw, pitch, roll)
                                     }
 
@@ -328,7 +396,7 @@ class Hlk223Client(
     }
 
     // ========= 照片下发注册 =========
-    suspend fun enrollWithPhoto(jpg: ByteArray, bioType: Int = 1, crc32: Int): Int {
+    suspend fun enrollWithPhoto(jpg: ByteArray, bioType: Int = 1, crc32: Long): Int {
         val helper = Hlk223PhotoEnroll(protocol, deviceId)
         return helper.enroll(jpg, bioType, crc32)
     }

+ 107 - 29
data/src/main/java/com/grkj/data/hardware/face/hlk/Hlk223PhotoEnroll.kt

@@ -8,6 +8,7 @@ import com.sik.comm.core.policy.ChainPolicy
 import com.sik.comm.core.protocol.LinkIO
 import com.sik.comm.impl_modbus.ModbusProtocol
 import org.slf4j.LoggerFactory
+import java.util.concurrent.TimeoutException
 import kotlin.math.min
 
 /**
@@ -33,19 +34,36 @@ class Hlk223PhotoEnroll(
     }
 
     private fun be16(v: Int) = byteArrayOf(((v ushr 8) and 0xFF).toByte(), (v and 0xFF).toByte())
-    private fun be32(v: Int) = byteArrayOf(
-        ((v ushr 24) and 0xFF).toByte(),
-        ((v ushr 16) and 0xFF).toByte(),
-        ((v ushr 8) and 0xFF).toByte(),
-        (v and 0xFF).toByte()
+    private fun be32(u: Long) = byteArrayOf(
+        ((u ushr 24) and 0xFF).toByte(),
+        ((u ushr 16) and 0xFF).toByte(),
+        ((u ushr 8) and 0xFF).toByte(),
+        (u and 0xFF).toByte()
     )
 
-    private fun firstData(totalLen: Int, bioType: Int, crc32: Int): ByteArray {
-        // 不同版本文档对首字节有“保留位”描述,这里置 0 兼容(与你已有实现保持一致)
-        return byteArrayOf(0x00) + be32(totalLen) + byteArrayOf(bioType.toByte()) + be32(crc32)
+    // 固定 MTU=246
+    private val MTU = 246
+
+    private fun chunk(payload: ByteArray): List<ByteArray> {
+        val out = ArrayList<ByteArray>((payload.size + MTU - 1) / MTU)
+        var p = 0
+        while (p < payload.size) {
+            val n = minOf(MTU, payload.size - p)
+            out += payload.copyOfRange(p, p + n)
+            p += n
+        }
+        return out
     }
 
-    private fun buildPlan(payload: ByteArray, bioType: Int, crc32: Int): TxPlan {
+    // Seq=0 的控制头:len(4B BE) + bioType(1B) + crc32(4B BE)
+    private fun firstData(photoLen: Int, bioType: Int, crc32: Long): ByteArray {
+        require(photoLen >= 0)
+        return be16(0) + be32(photoLen.toLong() and 0xFFFFFFFFL) +
+                byteArrayOf((bioType and 0xFF).toByte()) +
+                be32(crc32 and 0xFFFFFFFFL)
+    }
+
+    private fun buildPlan(payload: ByteArray, bioType: Int, crc32: Long): TxPlan {
         val frames = mutableListOf<CommMessage>()
         // Seq=0
         frames += Hlk223.msg(Hlk223.MID.ENROLL_WITH_PHOTO, firstData(payload.size, bioType, crc32))
@@ -53,10 +71,9 @@ class Hlk223PhotoEnroll(
         val parts = chunk(payload)
         parts.forEachIndexed { idx, part ->
             val seq = idx + 1
-            val seqBytes = be16(seq)
-            frames += Hlk223.msg(Hlk223.MID.ENROLL_WITH_PHOTO, seqBytes + part)
+            frames += Hlk223.msg(Hlk223.MID.ENROLL_WITH_PHOTO, be16(seq) + part)
         }
-        return TxPlan(frames = frames)
+        return TxPlan(frames)
     }
 
     private fun pickBestFrame(frames: List<Pair<Int, ByteArray>>): Pair<Int, ByteArray> {
@@ -67,33 +84,94 @@ class Hlk223PhotoEnroll(
     }
 
     private fun policy(): ChainPolicy = object : ChainPolicy {
-        override suspend fun afterSendStep(stepIndex: Int, sent: CommMessage, io: LinkIO): ChainStepResult {
-            // 每发一包就等一次 REPLY(如需处理 NOTE,可在此多读一次 NOTE)
-            val ack = io.readRaw(timeoutMs = 3000, expectedSize = null, silenceGapMs = 30)
-            logger.info("返回数据:${ack.payload}")
-            val (mid, data) = pickBestFrame(Hlk223.autoParse(ack.payload))
-            require(mid == Hlk223.MID.REPLY && data.size >= 2) { "Expect REPLY" }
-            val reqMid = data[0].toInt() and 0xFF
-            val result = data[1].toInt() and 0xFF
-            require(reqMid == Hlk223.MID.ENROLL_WITH_PHOTO) { "Ack MID mismatch" }
-            require(result == 0) { "Step=$stepIndex failed, result=$result" }
-            return ChainStepResult(received = listOf(ack), continueNext = true, interFrameDelayMs = 10)
+
+        override suspend fun afterSendStep(
+            stepIndex: Int,
+            sent: CommMessage,
+            io: LinkIO
+        ): ChainStepResult {
+
+            val isFirst = stepIndex == 0
+            val isLast  = sent.metadata["is_last"] as? Boolean ?: false // 没这个元数据就按索引判断
+            val timeoutMs = when {
+                isFirst -> 6000   // 首包:校验头/CRC,慢
+                isLast  -> 12000  // 末包:解析/入库,最慢
+                else    -> 4000
+            }
+
+            val stopAt = System.currentTimeMillis() + timeoutMs
+            val bag = mutableListOf<CommMessage>()
+            var gotReplyOk = false
+
+            while (System.currentTimeMillis() < stopAt) {
+                val ack = try {
+                    io.readRaw(timeoutMs = 600, expectedSize = null, silenceGapMs = 80)
+                } catch (_: TimeoutException) {
+                    continue
+                } catch (_: Throwable) {
+                    continue
+                }
+                bag += ack
+
+                // 解析这口里的所有帧
+                val frames = runCatching { Hlk223.autoParse(ack.payload) }.getOrElse { emptyList() }
+                frames.forEach { (mid, data) ->
+                    if (mid != Hlk223.MID.REPLY || data.size < 2) return@forEach
+                    val reqMid = data[0].toInt() and 0xFF
+                    val result = data[1].toInt() and 0xFF
+                    if (reqMid != Hlk223.MID.ENROLL_WITH_PHOTO) return@forEach
+
+                    if (result != 0) {
+                        error("ENROLL step=$stepIndex failed, result=$result")
+                    }
+                    // 到这一步:本 step 的 REPLY 成功
+                    gotReplyOk = true
+                    // 对于中间包,拿到 OK 即可进入下一步
+                    if (!isLast) {
+                        return ChainStepResult(
+                            received = bag.toList(),
+                            continueNext = true,
+                            interFrameDelayMs = 50 // 节流,别顶爆模块 RX
+                        )
+                    }
+                    // 末包:继续留在 while 里“吃尾巴”,等携带 userId 的最终 REPLY
+                    // 直接不 return,落到 while 继续读
+                }
+            }
+
+            if (gotReplyOk) {
+                // 末包读完窗口:把拿到的都上交
+                return ChainStepResult(
+                    received = bag.toList(),
+                    continueNext = false,
+                    interFrameDelayMs = 0
+                )
+            } else {
+                error("ENROLL step=$stepIndex no REPLY within ${timeoutMs}ms")
+            }
         }
     }
 
     /** 执行注册,返回 userId(末包 REPLY 通常携带) */
-    suspend fun enroll(payload: ByteArray, bioType: Int = 0, crc32: Int): Int {
+    suspend fun enroll(payload: ByteArray, bioType: Int = 1, crc32: Long): Int {
         val rsps = protocol.sendChain(deviceId, buildPlan(payload, bioType, crc32), policy())
-        val last = rsps.lastOrNull() ?: error("No reply")
-        logger.info("返回数据:${last.payload}")
-        val (_, data) = pickBestFrame(Hlk223.autoParse(last.payload))
-        // 常见格式:REPLY data 尾部 2 字节为 userId(BE),若固件不同需对齐偏移
+        // policy 已经在末包阶段“多等了”,这里只需在所有收到的帧里挑“最后一个对应 REPLY”
+        val all = rsps.flatMap { Hlk223.autoParse(it.payload) }
+        val finalReply = all.lastOrNull { it.first == Hlk223.MID.REPLY &&
+                (it.second.getOrNull(0)?.toInt()?.and(0xFF) == Hlk223.MID.ENROLL_WITH_PHOTO) }
+            ?: error("No REPLY for ENROLL_WITH_PHOTO")
+
+        val data = finalReply.second
+        val result = data[1].toInt() and 0xFF
+        require(result == 0) { "Enroll failed, result=$result" }
+
+        // 尾部两字节为 userId(BE)
         val uid = if (data.size >= 4) {
             val hi = data[data.size - 2].toInt() and 0xFF
             val lo = data[data.size - 1].toInt() and 0xFF
             (hi shl 8) or lo
         } else 0
-        require(uid >= 0) { "Enroll success but userId missing" }
+        require(uid > 0) { "Enroll success but userId missing" }
         return uid
     }
 }

+ 4 - 1
iscs_lock/src/main/java/com/grkj/iscs/features/login/dialog/LoginDialog.kt

@@ -185,7 +185,10 @@ class LoginDialog(
         ActivityTracker.getCurrentActivity()?.let { context ->
             FaceUtil.checkCamera(
                 mBinding.preview!!
-            ) { bitmap, userId ->
+            ) { bitmap, faceRect, userId ->
+                faceRect?.let {
+                    mBinding.faceOverlayView.setFaceRect(listOf(it))
+                }
                 if (bitmap == null || userId == null) {
                     ToastEvent.sendToastEvent(CommonUtils.getStr(com.grkj.ui_base.R.string.face_login_failed))
                     return@checkCamera

+ 1 - 1
iscs_lock/src/main/java/com/grkj/iscs/features/main/dialog/CheckFaceDialog.kt

@@ -161,7 +161,7 @@ class CheckFaceDialog(
         ActivityTracker.getCurrentActivity()?.let { context ->
             FaceUtil.checkCamera(
                 mBinding.preview!!,
-            ) { bitmap, userId ->
+            ) { bitmap,faceRect, userId ->
                 if (bitmap == null || userId == null) {
                     ToastEvent.sendToastEvent(CommonUtils.getStr(com.grkj.ui_base.R.string.face_login_failed))
                     return@checkCamera

+ 16 - 11
iscs_lock/src/main/res/layout/dialog_login.xml

@@ -22,8 +22,8 @@
                 android:id="@+id/iv_icon"
                 android:layout_width="@dimen/dialog_common_root_height_login_and_check"
                 android:layout_height="@dimen/dialog_common_root_height_login_and_check"
-                android:tint="?attr/colorPrimary"
-                android:layout_marginBottom="@dimen/iscs_space_1" />
+                android:layout_marginBottom="@dimen/iscs_space_1"
+                android:tint="?attr/colorPrimary" />
 
             <TextView
                 android:id="@+id/tv_tip"
@@ -39,7 +39,12 @@
                 android:id="@+id/preview"
                 android:layout_width="match_parent"
                 android:layout_height="match_parent"
-                android:visibility="invisible" />
+                android:visibility="visible" />
+
+            <com.grkj.shared.widget.FaceOverlayView
+                android:id="@+id/face_overlay_view"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent" />
         </FrameLayout>
 
         <LinearLayout
@@ -57,8 +62,8 @@
                 android:layout_width="match_parent"
                 android:layout_height="@dimen/login_dialog_input_height"
                 android:layout_marginBottom="@dimen/iscs_space_1"
-                app:i18nHint='@{"please_input_account"}'
-                android:textSize="@dimen/iscs_text_sm" />
+                android:textSize="@dimen/iscs_text_sm"
+                app:i18nHint='@{"please_input_account"}' />
 
             <EditText
                 android:id="@+id/et_password"
@@ -66,9 +71,9 @@
                 android:layout_width="match_parent"
                 android:layout_height="@dimen/login_dialog_input_height"
                 android:layout_marginBottom="@dimen/iscs_space_1"
-                app:i18nHint='@{"please_input_password"}'
                 android:inputType="textPassword"
-                android:textSize="@dimen/iscs_text_sm" />
+                android:textSize="@dimen/iscs_text_sm"
+                app:i18nHint='@{"please_input_password"}' />
 
             <TextView
                 android:id="@+id/tv_login"
@@ -76,8 +81,8 @@
                 android:layout_width="match_parent"
                 android:layout_height="@dimen/login_dialog_btn_height"
                 android:layout_marginBottom="@dimen/iscs_space_1"
-                app:i18nKey='@{"login"}'
-                android:textSize="@dimen/iscs_text_sm" />
+                android:textSize="@dimen/iscs_text_sm"
+                app:i18nKey='@{"login"}' />
 
             <TextView
                 android:id="@+id/tv_cancel"
@@ -85,8 +90,8 @@
                 android:layout_width="match_parent"
                 android:layout_height="@dimen/login_dialog_btn_height"
                 android:background="@drawable/white_stroke_bg"
-                app:i18nKey='@{"cancel"}'
-                android:textSize="@dimen/iscs_text_sm" />
+                android:textSize="@dimen/iscs_text_sm"
+                app:i18nKey='@{"cancel"}' />
         </LinearLayout>
     </com.google.android.material.card.MaterialCardView>
 </layout>

+ 1 - 1
iscs_mc/src/main/java/com/grkj/iscs_mc/features/login/dialog/LoginDialog.kt

@@ -173,7 +173,7 @@ class LoginDialog(
         inDetecting = false
         ActivityTracker.getCurrentActivity()?.let { context ->
             FaceUtil.checkCamera(mBinding.preview!!
-            ) { bitmap, userId ->
+            ) { bitmap,faceRect, userId ->
                 if (bitmap == null || userId == null) return@checkCamera
                 viewModel.loginWithUserId(userId).observe(lifecycleOwner) {
                     if (it == LoginResultEnum.FACE_VERIFY_FAILED) {

+ 1 - 1
iscs_mc/src/main/java/com/grkj/iscs_mc/features/login/fragment/LoginFragment.kt

@@ -307,7 +307,7 @@ class LoginFragment : BaseFragment<FragmentLoginBinding>() {
         inDetecting = false
         ActivityTracker.getCurrentActivity()?.let { context ->
             FaceUtil.checkCamera(binding.preview
-            ) { bitmap, userId ->
+            ) { bitmap,faceRect, userId ->
                 if (bitmap == null || userId == null) return@checkCamera
                 viewModel.loginWithUserId(userId).observe(this) {
                     if (it == LoginResultEnum.FACE_VERIFY_FAILED) {

+ 38 - 41
shared/src/main/java/com/grkj/shared/widget/FaceOverlayView.kt

@@ -1,22 +1,16 @@
 package com.grkj.shared.widget
 
 import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.Paint
-import android.graphics.Rect
-import android.graphics.RectF
+import android.graphics.*
 import android.util.AttributeSet
 import android.view.View
+import kotlin.math.min
 
 /**
- * 自定义人脸框绘制控件。
- * 根据摄像头预览尺寸与 View 尺寸的等比缩放,对应绘制人脸检测到的 Rect 区域。
- *
- * 使用方法:
- * 1. 在布局中引入 FaceOverlayView
- * 2. 调用 setCameraPreviewSize(width, height)
- * 3. 调用 setFaceRect(Rect 或 RectF)
+ * 等比缩放的人脸框叠加层:
+ * - 传入相机预览分辨率(未旋转的原始宽高)
+ * - 传入基于相机坐标系的 Rect/RectF
+ * - 自动按 View 的宽高做等比缩放并居中映射
  */
 class FaceOverlayView @JvmOverloads constructor(
     context: Context,
@@ -24,62 +18,65 @@ class FaceOverlayView @JvmOverloads constructor(
     defStyleAttr: Int = 0
 ) : View(context, attrs, defStyleAttr) {
 
-    private var cameraWidth = 0f
-    private var cameraHeight = 0f
-    private var faceRectF: List<RectF>? = null
+    private var cameraWidth = 0f   // 相机预览宽(像素)
+    private var cameraHeight = 0f  // 相机预览高(像素)
+    private var faceRects: List<RectF>? = null
 
-    private val paint = Paint().apply {
+    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
         color = Color.GREEN
         style = Paint.Style.STROKE
         strokeWidth = 5f
-        isAntiAlias = true
     }
 
-    /**
-     * 设置摄像头预览的分辨率,用于等比缩放映射
-     */
+    /** 设置相机预览分辨率(未旋转)。*/
     fun setCameraPreviewSize(width: Int, height: Int) {
         cameraWidth = width.toFloat()
         cameraHeight = height.toFloat()
         invalidate()
     }
 
-    /**
-     * 设置人脸检测得到的 Rect 坐标(基于摄像头分辨率)
-     */
+    /** 传入相机坐标系的人脸框(Rect)。*/
     fun setFaceRect(rects: List<Rect>?) {
-        faceRectF = rects?.map { RectF(it) }
+        faceRects = rects?.map { RectF(it) }
         invalidate()
     }
 
-    // 在 FaceOverlayView 增加:
-    fun lastRectsCount(): Int = faceRectF?.size ?: 0
+    /** 传入相机坐标系的人脸框(RectF)。*/
+    fun setFaceRectF(rects: List<RectF>?) {
+        faceRects = rects?.map { RectF(it) }
+        invalidate()
+    }
 
+    /** 最近一次的人脸框数量(调试用)。*/
+    fun lastRectsCount(): Int = faceRects?.size ?: 0
 
     override fun onDraw(canvas: Canvas) {
         super.onDraw(canvas)
-        val rects = faceRectF ?: return
-        if (cameraWidth <= 0 || cameraHeight <= 0) return
+        val rects = faceRects ?: return
+        if (cameraWidth <= 0f || cameraHeight <= 0f) return
 
         // View 尺寸
         val vw = width.toFloat()
         val vh = height.toFloat()
-        // 等比缩放系数
-        val scale = minOf(vw / cameraWidth, vh / cameraHeight)
-        // 计算预览内容缩放后大小
+
+        // 等比缩放系数(保持比例,不拉伸)
+        val scale = min(vw / cameraWidth, vh / cameraHeight)
+
+        // 缩放后内容大小(letterbox)
         val scaledW = cameraWidth * scale
         val scaledH = cameraHeight * scale
+
         // 居中偏移
-        val dx = (vw - scaledW) / 2
-        val dy = (vh - scaledH) / 2
-        rects.forEach { rect ->
-            // 映射到 View 坐标系
-            val left = rect.left * scale + dx
-            val top = rect.top * scale + dy
-            val right = rect.right * scale + dx
-            val bottom = rect.bottom * scale + dy
+        val dx = (vw - scaledW) * 0.5f
+        val dy = (vh - scaledH) * 0.5f
 
-            canvas.drawRect(left, top, right, bottom, paint)
+        // 把相机坐标映射到 View 坐标
+        rects.forEach { r ->
+            val l = r.left * scale + dx
+            val t = r.top * scale + dy
+            val rr = r.right * scale + dx
+            val b = r.bottom * scale + dy
+            canvas.drawRect(l, t, rr, b, paint)
         }
     }
-}
+}