@file:Suppress("DEPRECATION") package com.sw.platecabinet.activity import android.Manifest import android.content.Intent import android.graphics.Bitmap import android.graphics.Point import android.hardware.Camera import android.net.Uri import android.os.Build import android.provider.Settings import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.FrameLayout import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.arcsoft.face.ErrorInfo import com.bumptech.glide.Glide import com.sw.inbound.utils.DateTimeUtils import com.sw.plate.utils.Base64 import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.arcface.ConfigUtil import com.sw.plate.utils.arcface.ErrorCodeUtil import com.sw.plate.utils.arcface.FaceRectTransformer import com.sw.plate.utils.arcface.FaceRectView import com.sw.plate.utils.arcface.FaceRectView.DrawInfo import com.sw.plate.utils.arcface.PreviewConfig import com.sw.plate.utils.arcface.camera.CameraListener import com.sw.plate.utils.arcface.camera.DualCameraHelper import com.sw.plate.utils.arcface.face.constants.LivenessType import com.sw.plate.utils.arcface.face.model.CompareResult import com.sw.plate.utils.arcface.face.model.FacePreviewInfo import com.sw.plate.utils.arcface.face.model.RecognizeConfiguration import com.sw.plate.utils.arcface.facedb.FaceDatabase import com.sw.plate.utils.arcface.facedb.entity.FaceEntity import com.sw.plate.utils.arcface.model.UserFaceInfo import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel.REGISTER_STATUS_READY import com.sw.platecabinet.R import com.sw.platecabinet.databinding.ActivityLoginFaceBinding import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.dialog.CustomDialog import com.sw.platecabinet.ext.clickWithDebounce import com.sw.platecabinet.ext.gone import com.sw.platecabinet.ext.visible import com.sw.platecabinet.model.response.UserFaceModelV2 import com.sw.platecabinet.utils.BitmapSaver import com.sw.platecabinet.utils.CountDownUtil import com.sw.platecabinet.utils.Debouncer import com.sw.platecabinet.utils.LogFileUtil import com.sw.platecabinet.utils.RegisterCallbackHandler import com.sw.platecabinet.utils.SoundPoolUtil import com.sw.platecabinet.utils.mego.PlcCallback import com.sw.platecabinet.utils.mego.PlcHelper import com.sw.platecabinet.utils.mego.PlcStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import timber.log.Timber import java.io.File import java.util.concurrent.atomic.AtomicBoolean /** * 人脸识别 */ class LoginByFaceActivity : BaseActivity(), ViewTreeObserver.OnGlobalLayoutListener { override fun inflateViewBinding(): ActivityLoginFaceBinding { return ActivityLoginFaceBinding.inflate(layoutInflater) } override fun inflateTitleBinding(): ItemTitleTimeBinding { return binding.includeHeader } companion object { private const val TAG = "LoginByFaceActivity" private const val COLLECT_SUCCESS = "collect_success" private const val RECOGNIZE_SUCCESS = "recognize_success" private const val NO_PLATE_REMIND = "no_plate_remind" } override fun initialize() { netViewModelV2.activeEngine() PlcHelper.getInstance(this@LoginByFaceActivity) .openSerialPort(object : PlcCallback { override fun onSuccess(result: String) { ToastUtils.showToast("打开串口 → 成功: ${result}") } override fun onError(message: String) { ToastUtils.showToast("打开串口 → 失败: $message") } }) initArcViewModel() initArcView() openRectInfoDraw = true recognizeViewModel.setDrawRectInfoTextValue(true) EventBus.getDefault().register(this) //开启人脸增量数据定时任务 startFaceTask() val soundMap = hashMapOf() soundMap[COLLECT_SUCCESS] = R.raw.collect_success soundMap[RECOGNIZE_SUCCESS] = R.raw.recognize_success soundMap[NO_PLATE_REMIND] = R.raw.no_plate_remind SoundPoolUtil.getInstance().loadR(this, soundMap) binding.btnCollect.clickWithDebounce { //先重置,后采集 resetRecognizeState() //采集数据按钮 retryFailCount = 0 isCollectFace = true isCollecting.set(true) collectFace() } binding.btnRetry.clickWithDebounce { //重新识别按钮 resetRecognizeState() } } // 检查相机权限 private fun checkCameraPermission() { Timber.i("checkCameraPermission") val hasCameraPermission = ContextCompat.checkSelfPermission( this, Manifest.permission.CAMERA ) == android.content.pm.PackageManager.PERMISSION_GRANTED if (hasCameraPermission) { // 权限已授予 loadLogInfo("checkCameraPermission 权限已允许") recognizeViewModel.init() initRgbCamera() resumeCamera() } else { // 无权限,请求相机权限 requestCameraPermission() } } private fun requestCameraPermission() { requestPermission(Manifest.permission.CAMERA) { isGranted -> if (isGranted) { // 权限已授予 loadLogInfo("checkCameraPermission 权限已允许") recognizeViewModel.init() initRgbCamera() resumeCamera() } else { // 权限被拒绝 loadLogInfo("checkCameraPermission 权限被拒绝") CustomDialog( activity = this, content = "请在设置中开启相机权限", onConfirm = { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", context.packageName, null) } startActivity(intent) { checkCameraPermission() } }, onCancel = { }).show() } } } @Subscribe(threadMode = ThreadMode.MAIN) fun getFaceEntity(insertEntity: FaceEntity) { Timber.tag("performSync").e("-time=%s", insertEntity.registerTime) recognizeViewModel.addFace(insertEntity) Timber.tag("performSync over").e("-time=%s", insertEntity.registerTime) } override fun onLeftDoubleClick() { finish() } override fun onRightDoubleClick() { PlcSettingActivity.start(this) } private var isRecognition = false private var rgbCameraHelper: DualCameraHelper? = null private var rgbFaceRectTransformer: FaceRectTransformer? = null private val livenessType = LivenessType.RGB private var openRectInfoDraw = false private val leftDebouncer = Debouncer(30 * 1000) /** 识别超时倒计时(15秒无操作跳回初始页) */ private val recognizeCountDownUtil = CountDownUtil() /** 用户离开画面后的倒计时(llCollectButton 显示状态下无人脸时触发) */ private val leftCountDownUtil = CountDownUtil() private var registerHandler: RegisterCallbackHandler? = null private fun isFitData(userFaceInfo: UserFaceInfo?): Boolean { if (userFaceInfo == null) return false val headBmp = userFaceInfo.headBmp loadLogInfo("collectFace,isFitData:w=${headBmp?.width},height=${headBmp?.height}") if (headBmp == null) return false // if (headBmp.width <= 200 || headBmp.height <= 200) { // loadLogInfo("collectFace,isFitData:拿到人脸提取特征数据照片过小-----------") // return false // } return true } private fun initArcViewModel() { recognizeViewModel.setLiveType(livenessType) recognizeViewModel.prepareRegister() recognizeViewModel.setOnRegisterFinishedCallback { facePreviewInfo, userFaceInfo -> registerHandler?.setCallback { loadLogInfo("collectFace,registerHandler?.setCallback") if (!isCollectFace || !isCollecting.get()) { loadLogInfo("collectFace,isCollectFace=${isCollectFace},isCollecting=${isCollecting.get()}") return@setCallback } loadLogInfo("collectFace,userFaceInfo==null:${userFaceInfo==null}") if (this.userFaceInfo == null && isFitData(userFaceInfo)) { val headBmp = userFaceInfo.headBmp ?: run { loadLogInfo("collectFace,isFitData:headBmp 为 null,跳过") return@setCallback } if (headBmp.width <= 200 || headBmp.height <= 200) { loadLogInfo("collectFace,isFitData:拿到人脸提取特征数据照片过小w:${headBmp.width},h:${headBmp.height}-----------") binding.llCollectButton.visible() binding.btnRetry.gone() binding.btnCollect.text = "重新采集" return@setCallback } recognizeViewModel.updateRegisterStatus(RecognizeViewModel.REGISTER_STATUS_DONE) this.userFaceInfo = userFaceInfo getFaceDataBlock() } } } recognizeViewModel.ftInitCode.observe(this) { ftInitCode: Int? -> if (ftInitCode != null && ftInitCode != ErrorInfo.MOK) { val error: String? = getString( R.string.specific_engine_init_failed, "ftEngine", ftInitCode, ErrorCodeUtil.arcFaceErrorCodeToFieldName(ftInitCode) ) Timber.e("ftInitCode observe = $error") ToastUtils.showToast(error) } } recognizeViewModel.frInitCode.observe(this) { frInitCode: Int? -> if (frInitCode != null && frInitCode != ErrorInfo.MOK) { val error: String? = getString( R.string.specific_engine_init_failed, "frEngine", frInitCode, ErrorCodeUtil.arcFaceErrorCodeToFieldName(frInitCode) ) Timber.e("frInitCode observe = $error") ToastUtils.showToast(error) } } recognizeViewModel.flInitCode.observe(this) { flInitCode: Int? -> if (flInitCode != null && flInitCode != ErrorInfo.MOK) { val error: String? = getString( R.string.specific_engine_init_failed, "flEngine", flInitCode, ErrorCodeUtil.arcFaceErrorCodeToFieldName(flInitCode) ) Timber.e("flInitCode observe = $error") ToastUtils.showToast(error) } } recognizeViewModel.recognizeConfiguration .observe(this) { recognizeConfiguration: RecognizeConfiguration? -> Timber.i("recognizeConfiguration: observe = ${recognizeConfiguration.toString()}") } recognizeViewModel.recognizeNotice.observe(this) { notice: String? -> Timber.i("recognizeNotice observe notice = $notice") } recognizeViewModel.recognizeUserId.observe(this) { result: CompareResult? -> loadLogInfo("collectFace,recognizeUserId observe userId=${result?.faceEntity?.userName},similar=${result?.similar},userType=${result?.faceEntity?.userType}") loadFaceRecognizeResult3(result) } recognizeViewModel.drawRectInfoText.observe(this) { info -> Timber.i("drawRectInfoText observe info = $info") } } /** * 识别失败重试次数 */ private var retryFailCount = 0 /** * 识别失败最大次数 */ private val maxFailCount = 5 /** * 配置阈值,默认值为0.8 */ private val faceSuccessThreshold by lazy { ConfigUtil.getRecognizeThreshold(this) } private val openPlateDebouncer = Debouncer(3000) private val isCollecting = AtomicBoolean(false) private fun loadFaceRecognizeResult3(result: CompareResult?) { val similar = result?.similar ?: return binding.tvSimilarValue.text = "$similar,${retryFailCount}次" loadLogInfo( "collectFace,recognizeUserId 阈值 success=$faceSuccessThreshold, similar=$similar, retry=$retryFailCount" ) if (similar >= faceSuccessThreshold) { val userId = result.faceEntity?.userName recognizeSuccess(userId) return } if (isCollectFace || isCollecting.get()) { loadLogInfo("collectFace,recognizeUserId 正在采集中,忽略失败结果 similar=$similar") return } if (retryFailCount >= maxFailCount) { loadLogInfo( "collectFace,recognizeUserId 识别失败,开始采集 similar=$similar, " + "retry=$retryFailCount, time=${DateTimeUtils.getDateTimeString()}" ) if (!isCollecting.compareAndSet(false, true)) { loadLogInfo("collectFace 已在采集中,忽略重复触发") return } recognizeFail() return } retryFailCount++ loadLogInfo("collectFace,recognizeUserId 中间态重试,retryFailCount=$retryFailCount") } private fun recognizeSuccess(userId: String?) { if (userId.isNullOrBlank()) { loadLogInfo("collectFace,recognizeUserId 识别成功但 userId 为空,忽略") return } loadLogInfo("collectFace,recognizeUserId 识别成功 userId=$userId, time=${DateTimeUtils.getDateTimeString()}") // 成功后必须重置状态 retryFailCount = 0 isCollecting.set(false) isCollectFace = false currentUserId = userId // 防抖,防止重复开闸 openPlateDebouncer.debounce("openPlateDebouncer") { openPlate(userId) } } private fun recognizeFail() { recognizeCountDownUtil.cancelCountDown() binding.tvToInit.text = "" binding.tvSimilarValue.text = "" binding.llCollectButton.visible() binding.btnRetry.visible() binding.btnCollect.run { visible() text = "新用户采集" } binding.ivCollectPhoto.visible() } private fun newUserCollect() { recognizeCountDownUtil.cancelCountDown() binding.tvToInit.text = "" binding.tvSimilarValue.text = "" binding.llCollectButton.visible() binding.btnRetry.gone() binding.btnCollect.run { visible() text = "新用户采集" } binding.ivCollectPhoto.gone() openPlateDebouncer.reset() plateEnable = true resumeCamera() } private var plateEnable = false /** * 打开餐盘柜:执行出盘,出盘后查询剩余餐盘状态并播放对应提示音 */ private fun openPlate(userId: String?) { loadLogInfo("collectFace,openPlate: userId=$userId,plateEnable=$plateEnable") if (plateEnable.not()) return PlcHelper.getInstance(this@LoginByFaceActivity) .outPlate(object : PlcCallback { override fun onSuccess(result: String) { loadLogInfo("collectFace,openPlate: 出盘完成,result=$result,开始查询剩余餐盘") onOutPlateSuccess(result, RECOGNIZE_SUCCESS) } override fun onError(message: String) { onOutPlateError(message) } }) } /** * 新用户注册采集成功后立即出盘(与上传后台并行,互不阻塞)。 * 故意不经过 recognizeSuccess:避免使用临时 userId、避免与识别成功的防抖语义混淆。 */ private fun outPlateForRegister() { loadLogInfo("collectFace,outPlateForRegister: 新用户采集成功,立即出盘,plateEnable=$plateEnable") if (plateEnable.not()) return PlcHelper.getInstance(this@LoginByFaceActivity) .outPlate(object : PlcCallback { override fun onSuccess(result: String) { loadLogInfo("collectFace,outPlateForRegister: 出盘完成,result=$result") onOutPlateSuccess(result, COLLECT_SUCCESS) } override fun onError(message: String) { onOutPlateError(message) } }) } /** * 出盘成功后的统一处理: * 1. 置 plateEnable=false、取消识别倒计时,防止重复出盘; * 2. result 为 "false" 表示出盘后已无餐盘,播放无盘提示音并跳转; * 3. 否则播放提示音并查询剩余餐盘状态。 * * @param result PLC 返回结果("false" 表示出盘后无餐盘) * @param successSound 出盘成功播放的提示音 key(识别成功 / 采集成功) */ private fun onOutPlateSuccess(result: String, successSound: String) { plateEnable = false recognizeCountDownUtil.cancelCountDown() if (result.equals("false", ignoreCase = true)) { loadLogInfo("collectFace,onOutPlateSuccess: result=false,餐盘已空") SoundPoolUtil.getInstance().play(NO_PLATE_REMIND, 0) goInitActivity() return } SoundPoolUtil.getInstance().play(successSound, 0) // 已注册用户:出盘成功且有盘时,上报取餐盘时刻(P-09a) if (successSound == RECOGNIZE_SUCCESS) { reportPlatePickup(currentUserId, "recognize") } checkRemainingPlate() } /** * 出盘失败后的统一处理:提示失败并跳转回初始页 */ private fun onOutPlateError(message: String) { ToastUtils.showToast("出盘失败,message=$message") loadLogInfo("collectFace,onOutPlateError: 出盘失败,message=$message") goInitActivity() } /** * 上报取餐盘时刻(P-09a):记录用户开始就餐时刻,是后续就餐记录(P-09)的强前置。 * * 调用时机: * - 已注册用户:识别成功且出盘成功(有盘)后,userId 取自 [currentUserId](来源 "recognize"); * - 新用户:addUserFace 注册成功拿到真实 userId 后(出盘已由 outPlateForRegister 并行触发,来源 "collect")。 * * 上报失败仅记录日志,不影响吐盘主流程。 * * @param userId 用户 id(餐盘号 = userId),为空或非数字时跳过 * @param source 调用来源标识,仅用于日志区分 */ private fun reportPlatePickup(userId: String?, source: String) { val id = userId?.toLongOrNull() if (id == null) { loadLogInfo("collectFace,reportPlatePickup[$source]: userId 为空或非数字,跳过上报,userId=$userId") return } loadLogInfo("collectFace,reportPlatePickup[$source]: 上报取餐盘时刻,userId=$id") netViewModelV2.platePickup(id) { success, msg -> loadLogInfo("collectFace,reportPlatePickup[$source]: 上报完成,success=$success,msg=$msg") } } /** * 出盘成功后查询剩余餐盘状态,无盘则播放提示音 */ private fun checkRemainingPlate() { PlcHelper.getInstance(this@LoginByFaceActivity) .getStatus(object : PlcCallback { override fun onSuccess(result: PlcStatus) { if (isFinishing || isDestroyed) return loadLogInfo("collectFace,checkRemainingPlate: isEmptyPlate=${result.isEmptyPlate}") if (result.isEmptyPlate) { // 餐盘已空,播放无盘提示音 SoundPoolUtil.getInstance().play(NO_PLATE_REMIND, 0) //跳转到餐盘设置页面 PlcSettingActivity.start(this@LoginByFaceActivity, showTips = true) } else { loadLogInfo("collectFace,checkRemainingPlate: 剩余餐盘,继续使用") goInitActivity() } } override fun onError(message: String) { if (isFinishing || isDestroyed) return // 状态查询失败,不影响主流程,直接跳转 loadLogInfo("collectFace,checkRemainingPlate: 获取PLC状态失败,message=$message") goInitActivity() } }) } private val openDebouncer by lazy { Debouncer(5000) } private fun initArcView() { //在布局结束后才做初始化操作 binding.dualCameraTexturePreviewRgb.viewTreeObserver.addOnGlobalLayoutListener(this) val list = recognizeViewModel.getCompareResultList().getValue() loadLogInfo("initArcView:list.size=${list?.size}") } override fun onDestroy() { loadLogInfo("onDestroy") rgbCameraHelper?.release() rgbCameraHelper = null recognizeViewModel.destroy() PlcHelper.getInstance(this@LoginByFaceActivity).release() EventBus.getDefault().unregister(this) super.onDestroy() } /** * 调整View的宽高,使预览显示正常且采集框固定为380x380 * * @param rgbPreview RGB预览View * @param previewView 显示预览数据的view * @param faceRectView 画框的view (380x380) * @param previewSize 预览大小 * @param displayOrientation 相机旋转角度 * @param scale 缩放比例 * @return 调整后的LayoutParams */ private fun adjustPreviewViewSize( rgbPreview: View, previewView: View, faceRectView: FaceRectView, previewSize: Camera.Size, displayOrientation: Int, scale: Float ): ViewGroup.LayoutParams { // val layoutParams = previewView.layoutParams // val measuredWidth = previewView.measuredWidth // val measuredHeight = previewView.measuredHeight // var ratio = (previewSize.height.toFloat()) / previewSize.width.toFloat() // if (ratio > 1) { // ratio = 1 / ratio // } // if (displayOrientation % 180 == 0) { // layoutParams.width = measuredWidth // layoutParams.height = (measuredWidth * ratio).toInt() // } else { // layoutParams.height = measuredHeight // layoutParams.width = (measuredHeight * ratio).toInt() // } // if (scale < 1f) { // val rgbParam = rgbPreview.layoutParams // layoutParams.width = (rgbParam.width * scale).toInt() // layoutParams.height = (rgbParam.height * scale).toInt() // } else { // layoutParams.width = (layoutParams.width * scale).toInt() // layoutParams.height = (layoutParams.height * scale).toInt() // } // // val metrics = DisplayMetrics() // windowManager.defaultDisplay.getMetrics(metrics) // // if (layoutParams.width >= metrics.widthPixels) { // val viewRatio = layoutParams.width / (metrics.widthPixels.toFloat()) // layoutParams.width = (layoutParams.width / viewRatio).toInt() // layoutParams.height = (layoutParams.height / viewRatio).toInt() // } // if (layoutParams.height >= metrics.heightPixels) { // val viewRatio = layoutParams.height / (metrics.heightPixels.toFloat()) // layoutParams.width = (layoutParams.width / viewRatio).toInt() // layoutParams.height = (layoutParams.height / viewRatio).toInt() // } val layoutParams = FrameLayout.LayoutParams(776, 582); Timber.d("adjustPreviewViewSize,${layoutParams.width},${layoutParams.height}}") previewView.setLayoutParams(layoutParams) faceRectView.setLayoutParams(layoutParams) return layoutParams } private fun initRgbCamera() { val cameraListener: CameraListener = object : CameraListener { override fun onCameraOpened( camera: Camera, cameraId: Int, displayOrientation: Int, isMirror: Boolean ) { loadLogInfo("initRgbCamera Rgb onCameraOpened: cameraId = $cameraId, displayOrientation = $displayOrientation") runOnUiThread { val previewSizeRgb = camera.parameters.previewSize val layoutParams = adjustPreviewViewSize( binding.dualCameraTexturePreviewRgb, binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView, previewSizeRgb, displayOrientation, 0.7f //0.6f ) loadLogInfo("initRgbCamera previewSizeRgb = ${previewSizeRgb.width}-${previewSizeRgb.height}") loadLogInfo("initRgbCamera layoutParams = ${layoutParams.width}-${layoutParams.height}") loadLogInfo( "initRgbCamera isMirror = ${isMirror}, isDrawRgbRectHorizontalMirror = ${ ConfigUtil.isDrawRgbRectHorizontalMirror( context ) }, isDrawRgbRectVerticalMirror = ${ ConfigUtil.isDrawRgbRectVerticalMirror( context ) }" ) rgbFaceRectTransformer = FaceRectTransformer( previewSizeRgb.width, previewSizeRgb.height, layoutParams.width, layoutParams.height, 0, cameraId, isMirror, false, false ) recognizeViewModel.onRgbCameraOpened(camera) recognizeViewModel.setRgbFaceRectTransformer(rgbFaceRectTransformer) } } @RequiresApi(api = Build.VERSION_CODES.Q) override fun onPreview(nv21: ByteArray?, camera: Camera?) { if (!isRecognition) { return } binding.dualCameraFaceRectView.clearFaceInfo() facePreviewInfoList = recognizeViewModel.onPreviewFrame(nv21, true) if (facePreviewInfoList != null && rgbFaceRectTransformer != null) { drawPreviewInfo(facePreviewInfoList) } if (facePreviewInfoList.isNullOrEmpty()) { currentUserId = null userFaceInfo = null binding.btnCollect.tag = "" binding.tvSimilarValue.text = "" // 注意:此处不再重置 retryFailCount。 // 人脸检测会抖动(短暂丢帧),若每次无人脸就清零,"尝试几次失败"将无法累计。 // 失败计数仅在识别成功、会话重启、确认用户离开时清零。 // 人脸离开时,发送空帧清除 ViewModel 内部识别缓存,防止再次进入时使用旧数据 recognizeViewModel.onPreviewFrame(emptyFrame, true) if (binding.llCollectButton.isVisible) { leftDebouncer.debounce("leftDebouncer") { leftCountDown() } } } recognizeViewModel.clearLeftFace(facePreviewInfoList) } override fun onCameraClosed() { Timber.i("initRgbCamera onCameraClosed: ") } override fun onCameraError(e: java.lang.Exception) { Timber.i("initRgbCamera onCameraError: %s", e.message) e.printStackTrace() } override fun onCameraConfigurationChanged(cameraID: Int, displayOrientation: Int) { Timber.i("initRgbCamera onCameraConfigurationChanged: threadName = ${Thread.currentThread().name}") if (rgbFaceRectTransformer != null) { rgbFaceRectTransformer?.cameraDisplayOrientation = displayOrientation } Timber.i("initRgbCamera onCameraConfigurationChanged: $cameraID $displayOrientation") } } val previewConfig: PreviewConfig = recognizeViewModel.previewConfig rgbCameraHelper = DualCameraHelper.Builder() .previewViewSize( Point( binding.dualCameraTexturePreviewRgb.measuredWidth, binding.dualCameraTexturePreviewRgb.measuredHeight ) ) .rotation(windowManager.defaultDisplay.rotation) .additionalRotation(0) // 角度 .previewSize(recognizeViewModel.loadPreviewSize()) .specificCameraId(previewConfig.rgbCameraId) // .isMirror(true) .isMirror(true) .previewOn(binding.dualCameraTexturePreviewRgb) .cameraListener(cameraListener) .build() rgbCameraHelper?.init() rgbCameraHelper?.start() } /** * 绘制RGB、IR画面的实时人脸信息 * * @param facePreviewInfoList RGB画面的实时人脸信息 */ private fun drawPreviewInfo(facePreviewInfoList: MutableList?) { if (rgbFaceRectTransformer != null) { val rgbDrawInfoList: MutableList? = recognizeViewModel.getDrawInfo( facePreviewInfoList, LivenessType.RGB, openRectInfoDraw ) // 识别成功 binding.dualCameraFaceRectView.drawRealtimeFaceInfo(rgbDrawInfoList) } } // fun openRectInfoDraw(view: View?) { // openRectInfoDraw = !openRectInfoDraw // recognizeViewModel.setDrawRectInfoTextValue(openRectInfoDraw) // } override fun onGlobalLayout() { loadLogInfo("onGlobalLayout") binding.dualCameraTexturePreviewRgb.viewTreeObserver.removeOnGlobalLayoutListener(this) // 请求权限 checkCameraPermission() } private fun resumeCamera() { // isRecognition = true val helper = rgbCameraHelper if (helper != null && helper.isStopped) { loadLogInfo("collectFace,resumeCamera: ") isRecognition = true helper.start() } else { //recognizeViewModel.onPreviewFrame(ByteArray(1382400), true) recognizeViewModel.onPreviewFrame(emptyFrame, true) binding.dualCameraFaceRectView.clearFaceInfo() recognizeViewModel.clearLeftFace(facePreviewInfoList) binding.root.postDelayed({ isRecognition = true }, 500) } } /** * 构造符合分辨率的 NV21 格式全黑帧 * @param width 图像宽度(如 1920) * @param height 图像高度(如 1080) * @return 合法的全黑帧字节数组 */ private fun createBlackNV21Frame(width: Int, height: Int): ByteArray { val frameSize = width * height val nv21Data = ByteArray(frameSize * 3 / 2) // NV21 格式长度 = 宽×高×1.5 // Y 分量(亮度)设为 0(全黑),UV 分量设为 128(默认值) // 1. 填充 Y 分量(前 frameSize 个字节) // Arrays.fill(nv21Data, 0, frameSize, 0.toByte()) nv21Data.fill(0, 0, frameSize) // 2. 填充 UV 分量(后 frameSize/2 个字节) // Arrays.fill(nv21Data, frameSize, nv21Data.size, 128.toByte()) nv21Data.fill(128.toByte(), frameSize, nv21Data.size) return nv21Data } // 调用示例:构造 1920×1080 的全黑帧(长度 1382400) // private val emptyFrame = createBlackNV21Frame(1920, 1080) private val emptyFrame = createBlackNV21Frame(1280, 720) // 传递“空帧” recognizeViewModel.onPreviewFrame(emptyFrame, true) // /** // * 临时用户id // */ // private var tempUserId: String = "" override fun onResume() { super.onResume() plateEnable = true try { if (isFirstOpen) { isFirstOpen = false goInitActivity() return } lifecycleScope.launch { try { val faceDao = FaceDatabase.getInstance(this@LoginByFaceActivity).faceDao() val faceCount = withContext(Dispatchers.IO) { faceDao.faceCount } if (faceCount == 0) { newUserCollect() } else { resetRecognizeState() } } catch (e: Exception) { e.printStackTrace() } } } catch (e: Exception) { e.printStackTrace() } } /** * 重置识别状态 */ private fun resetRecognizeState() { binding.ivCollectPhoto.gone() binding.ivCollectPhoto.setImageDrawable(null) binding.tvToInit.text = "15" binding.llCollectButton.gone() retryFailCount = 0 binding.tvSimilarValue.text = "" isCollecting.set(false) isCollectFace = false plateEnable = true collectCount = 0 // tempUserId = "${System.currentTimeMillis()}" openPlateDebouncer.reset() // 清空上次识别结果和 FaceHelper 内部状态,防止短时间内再次识别无法触发 recognizeViewModel.resetFaceState() resumeCamera() registerHandler = RegisterCallbackHandler() recognizeCountDownUtil.startCountDown( total = 15, lifecycleScope = lifecycleScope, onTick = { remaining -> loadLogInfo("resetRecognizeState剩余时间:$remaining 秒") // if (judgeRecognizeState()) return@startCountDown loadLogInfo("initCountTime onTick = $remaining") binding.tvToInit.text = "$remaining" }, onFinish = { loadLogInfo("resetRecognizeState倒计时结束") // if (judgeRecognizeState()) return@startCountDown if (facePreviewInfoList.isNullOrEmpty()) { goInitActivity() } else { loadLogInfo("recognizeFail---initCountTime") recognizeFail() } } ) } private var isFirstOpen = true override fun onPause() { pauseCamera() super.onPause() recognizeCountDownUtil.cancelCountDown() } private fun pauseCamera() { isRecognition = false recognizeViewModel.onPreviewFrame(emptyFrame, true) } // private fun judgeRecognizeState(): Boolean { // val rectColor = binding.dualCameraFaceRectView.rectColor // if (rectColor == RecognizeColor.COLOR_SUCCESS && currentUserId.isNullOrBlank() // .not() && facePreviewInfoList.isNullOrEmpty().not() // ) { // //人未离开,再次识别 // loadLogInfo("userId=$currentUserId") // openPlateDebouncer.debounce { // openPlate(currentUserId) // } // return true // } // return false // } fun goInitActivity() { val intent = Intent(context, InitActivity::class.java) startActivity(intent) } /** * 采集人脸 */ private fun collectFace() { loadLogInfo("collectFace: -----------1,开始采集人脸,开始时间:${DateTimeUtils.getDateTimeString()}----------") if (isFirstOpen) return loadLogInfo("collectFace: ----------2,isFirstOpen=false-----------") loadLogInfo("collectFace: ----------4,检测到人脸信息-----------") currentUserId = null userFaceInfo = null recognizeViewModel.updateRegisterStatus(REGISTER_STATUS_READY) loadLogInfo("collectFace: ----------5,人脸采集中-----------") } private val uploadFaceDebouncer = Debouncer(10000) /** * 改为上传人脸数据,不需要发生人脸数据到收银端 */ private fun sendFaceData(faceData: String, headBmp: Bitmap) { loadLogInfo("collectFace,sendFaceData: ----------11,发送人脸数据-----------") val imageFile = BitmapSaver.saveToAppFilesDir( headBmp, this, "IMG_CROP_${System.currentTimeMillis()}.jpg" ) loadLogInfo("collectFace,sendFaceData:${imageFile?.absolutePath}") if (imageFile == null) { ToastUtils.showToast("获取人脸照片失败") return } uploadFaceDebouncer.debounce("uploadFaceDebouncer") { addUserFace(faceData, imageFile) } } private fun addUserFace(faceData: String, imageFile: File) { netViewModelV2.addUserFace(faceData, imageFile) { status, faceModel -> loadLogInfo("collectFace,saveFaceInfo: ----------12,人脸数据发送完成时间:${DateTimeUtils.getDateTimeString()},结果:$status-----------") if (status.not()) { ToastUtils.showToast("新增数据失败") loadLogInfo("collectFace,sendFaceData: ----------12,发送人脸数据失败,faceData=${faceData}") return@addUserFace } if (faceModel == null) { ToastUtils.showToast("新增数据成功,但未拿到接口数据") loadLogInfo("collectFace,sendFaceData: ----------12,发送人脸数据成功但接口返回为null,faceData=${faceData}") return@addUserFace } // 新用户:注册成功拿到真实 userId 后,上报取餐盘时刻(P-09a) reportPlatePickup(faceModel.userId, "collect") lifecycleScope.launch(Dispatchers.IO) { saveFaceInfo(faceModel) { runOnUiThread { Glide.with(this@LoginByFaceActivity).load(imageFile) .into(binding.ivCollectPhoto) } } uploadFaceDebouncer.reset() } } } private fun getFaceDataBlock() { if (userFaceInfo?.faceFeature?.featureData == null) { loadLogInfo("collectFace,getFaceData: ----------7,拿到人脸提取特征数据为null-----------") return } val faceData: String? = Base64.encode(userFaceInfo?.faceFeature?.featureData) if (faceData.isNullOrBlank()) { loadLogInfo("collectFace,getFaceData: ----------7,拿到人脸提取特征数据Base64编码为空") return } loadLogInfo("collectFace,getFaceData: ----------7,拿到人脸提取特征数据时间:${DateTimeUtils.getDateTimeString()}-----------") val headBmp = userFaceInfo!!.headBmp //block(faceData, headBmp) isCollecting.set(false) isCollectFace = false // 采集成功后立即出盘,与上传后台并行执行,确保上传失败时仍能吐盘 outPlateForRegister() // 异步上传后台(上传成功后才入库),不阻塞出盘 sendFaceData(faceData, headBmp) } /** * 加载日志 * @param msg 日志内容 */ private fun loadLogInfo(msg: String) { Timber.tag(TAG).d(msg) LogFileUtil.saveLog(msg) } private var facePreviewInfoList: MutableList? = null private var userFaceInfo: UserFaceInfo? = null private var currentUserId: String? = null private fun saveFaceInfo(faceModel: UserFaceModelV2, block: () -> Unit = {}) { try { loadLogInfo("collectFace,saveFaceInfo: ----------8,保存人脸取特征数据-----------") val faceDao = FaceDatabase.getInstance(this).faceDao() val faceEntity = faceModel.toFaceEntity() // 先查后插:按 userFaceId 判重,避免采集入库与增量同步各插一条 val userFaceId = faceModel.userFaceId val existing = if (!userFaceId.isNullOrEmpty()) faceDao.queryByUserFaceId(userFaceId) else null val faceId = if (existing != null) { // 已存在记录,更新特征等字段并复用主键,避免重复 faceEntity.faceId = existing.faceId faceDao.updateFaceEntity(faceEntity) existing.faceId } else { faceDao.insert(faceEntity) } faceEntity.faceId = faceId loadLogInfo("collectFace,saveFaceInfo: ----------10,人脸数据保存完成-----------") recognizeViewModel.refreshFaceList() loadLogInfo("collectFace,saveFaceInfo: ----------11,刷新人脸数据-----------") runOnUiThread { //采集完成,停止识别(出盘已在采集成功时由 outPlateForRegister 触发,此处仅负责入库) isRecognition = false } loadLogInfo("---------------------------------") collectCount = 0 block() } catch (e: Exception) { e.printStackTrace() loadLogInfo("collectFace,saveFaceInfo: ----------13,人脸采集异常:${e.message}-----------") collectCount++ loadLogInfo("collectFace,saveFaceInfo: ----------14,${collectCount}次人脸采集异常:${e.message}-----------") ToastUtils.showToast("人脸采集异常:${e.message}") } } private var collectCount = 0 private var isCollectFace = false private fun leftCountDown() { leftCountDownUtil.startCountDown(total = 15, lifecycleScope = lifecycleScope, onStart = { loadLogInfo("onPreview-----------leftCountDown-------------") }, onTick = { loadLogInfo("leftCountDown.onTick:$it") if (facePreviewInfoList.isNullOrEmpty().not()) { loadLogInfo("leftCountDown.onTick-cancel()") leftCountDownUtil.cancelCountDown() return@startCountDown } }, onFinish = { if (facePreviewInfoList.isNullOrEmpty().not()) { loadLogInfo("leftCountDown.onFinish-人脸未离开,不跳转") return@startCountDown } // 确认用户已离开画面,重置识别失败计数,下次进入重新累计 retryFailCount = 0 goInitActivity() }) } }