- 新增 ApiServiceV2 接口,支持人脸缓存、增量数据、设备配置等接口 - ApiClient 中新增 apiServiceV2 对象,复用 Retrofit 实例,调整超时时间至60秒 - DeviceInitActivity 改用 netViewModelV2 进行人脸缓存全量拉取及设备配置读取 - BaseActivity 新增对人脸增量同步接口调用,调用成功后刷新本地识别缓存 - BaseActivity 新增清空本地人脸库功能,异步清理数据库并刷新识别缓存 - FaceApi 增加清空本地人脸库接口,重写数据库操作逻辑 - 升级 lib_face 模块 Room 数据库版本,新增字段以支持多标识人脸实体扩展 - FaceEntity 实体扩展会员编号、用户ID、人脸ID、会员标识、更新时间等字段 - FaceDao 新增按人脸ID查询和删除接口,支持多重人脸数据操作 - 优化网络层 OkHttpClient 配置,简化拦截器写法及日志级别判断 - 升级 lib_face 模块支持 arm64-v8a 架构,增强兼容性 - 规范模块间依赖关系,统一版本管理及包路径声明 - 完善 CLAUDE.md 文档,补充项目架构、模块划分与开发流程说明
520 lines
19 KiB
Kotlin
520 lines
19 KiB
Kotlin
package com.sw.platecabinet.activity
|
||
|
||
import android.Manifest
|
||
import android.content.Intent
|
||
import android.graphics.Point
|
||
import android.hardware.Camera
|
||
import android.os.Build
|
||
import android.os.CountDownTimer
|
||
import android.util.DisplayMetrics
|
||
import android.view.View
|
||
import android.view.ViewGroup
|
||
import android.view.ViewTreeObserver
|
||
import androidx.activity.viewModels
|
||
import androidx.annotation.RequiresApi
|
||
import androidx.lifecycle.Observer
|
||
import com.arcsoft.face.ErrorInfo
|
||
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.faceserver.FaceServer
|
||
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
|
||
import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel
|
||
import com.sw.platecabinet.utils.Debouncer
|
||
import com.sw.platecabinet.member.R
|
||
import com.sw.platecabinet.member.databinding.ActivityLoginFaceBinding
|
||
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
|
||
import com.sw.platecabinet.model.response.EquipmentUserInfo
|
||
import com.sw.platecabinet.network.task.TaskManager
|
||
import com.sw.platecabinet.utils.PermissionHelper
|
||
import org.greenrobot.eventbus.EventBus
|
||
import org.greenrobot.eventbus.Subscribe
|
||
import org.greenrobot.eventbus.ThreadMode
|
||
import timber.log.Timber
|
||
|
||
/**
|
||
* 人脸识别
|
||
*/
|
||
class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
|
||
ViewTreeObserver.OnGlobalLayoutListener {
|
||
private var countDownTimer: CountDownTimer? = null
|
||
|
||
private val CAMERA_PERMISSION_REQUEST_CODE = 100
|
||
private val REQUIRED_PERMISSIONS: Array<String> = arrayOf(
|
||
Manifest.permission.CAMERA,
|
||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||
)
|
||
|
||
override fun inflateViewBinding(): ActivityLoginFaceBinding {
|
||
return ActivityLoginFaceBinding.inflate(layoutInflater)
|
||
}
|
||
|
||
override fun inflateTitleBinding(): ItemTitleTimeBinding {
|
||
return binding.includeHeader
|
||
}
|
||
|
||
companion object {
|
||
private var instance: LoginByFaceActivity? = null
|
||
|
||
fun goInitActivity() {
|
||
instance?.goInitActivity()
|
||
}
|
||
}
|
||
|
||
override fun initialize() {
|
||
instance = this
|
||
// V2 引擎激活(appId/sdkKey/activeKey 来自 V2 设备配置)
|
||
netViewModelV2.activeEngine()
|
||
|
||
initCountTime()
|
||
initArcViewModel()
|
||
initArcView()
|
||
openRectInfoDraw = true
|
||
recognizeViewModel.setDrawRectInfoTextValue(true)
|
||
|
||
//开启人脸增量数据定时任务(V2)
|
||
startFaceTask()
|
||
|
||
binding.llToPwd.setOnClickListener {
|
||
val intent = Intent(this, LoginByPwdActivity::class.java)
|
||
startActivity(intent)
|
||
}
|
||
|
||
// TODO: 测试请求
|
||
TaskManager.startTask()
|
||
|
||
EventBus.getDefault().register(this)
|
||
}
|
||
|
||
private fun checkCameraPermission() {
|
||
Timber.i("checkCameraPermission")
|
||
val permissionHelper =
|
||
PermissionHelper.with(this, REQUIRED_PERMISSIONS, CAMERA_PERMISSION_REQUEST_CODE)
|
||
.onGranted {
|
||
Timber.d("checkCameraPermission 权限已允许")
|
||
// 权限已授予
|
||
recognizeViewModel.init()
|
||
initRgbCamera()
|
||
resumeCamera()
|
||
}
|
||
.build()
|
||
registerPermissionHelper(permissionHelper)
|
||
permissionHelper.checkAndRequest()
|
||
}
|
||
|
||
@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()
|
||
}
|
||
|
||
private var isRecognition = false
|
||
private var rgbCameraHelper: DualCameraHelper? = null
|
||
private var rgbFaceRectTransformer: FaceRectTransformer? = null
|
||
private val livenessType = LivenessType.RGB
|
||
private var openRectInfoDraw = false
|
||
|
||
private fun initArcViewModel() {
|
||
|
||
recognizeViewModel.setLiveType(livenessType)
|
||
|
||
recognizeViewModel.ftInitCode.observe(this, Observer { ftInitCode: Int? ->
|
||
if (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, Observer { frInitCode: Int? ->
|
||
if (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, Observer { flInitCode: Int? ->
|
||
if (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, Observer { recognizeConfiguration: RecognizeConfiguration? ->
|
||
Timber.i("recognizeConfiguration: observe = ${recognizeConfiguration.toString()}")
|
||
})
|
||
recognizeViewModel.recognizeNotice.observe(this, Observer { notice: String? ->
|
||
Timber.i("recognizeNotice observe notice = $notice")
|
||
})
|
||
|
||
recognizeViewModel.recognizeUserId.observe(this, Observer { result: CompareResult? ->
|
||
Timber.i(
|
||
"recognizeUserId observe userId=${result?.faceEntity?.userName}," +
|
||
"similar=${result?.similar},userType=${result?.faceEntity?.userType}"
|
||
)
|
||
loadFaceRecognizeResult(result)
|
||
})
|
||
|
||
recognizeViewModel.drawRectInfoText.observe(this, Observer { info ->
|
||
Timber.i("drawRectInfoText observe info = $info")
|
||
})
|
||
}
|
||
|
||
private fun initArcView() {
|
||
//在布局结束后才做初始化操作
|
||
binding.dualCameraTexturePreviewRgb.getViewTreeObserver().addOnGlobalLayoutListener(this)
|
||
recognizeViewModel.getCompareResultList().getValue()
|
||
}
|
||
|
||
/**
|
||
* 识别阈值,默认值为0.8
|
||
*/
|
||
private val faceSuccessThreshold by lazy { ConfigUtil.getRecognizeThreshold(this) }
|
||
|
||
/**
|
||
* 识别成功后开柜(查询用户信息)防抖,防止重复触发
|
||
*/
|
||
private val loginDebouncer = Debouncer(3000)
|
||
|
||
/**
|
||
* 处理识别结果:达到阈值走登录流程,未达阈值仅记录(本版本无采集模式)
|
||
*/
|
||
private fun loadFaceRecognizeResult(result: CompareResult?) {
|
||
val similar = result?.similar ?: return
|
||
|
||
Timber.i(
|
||
"loadFaceRecognizeResult 阈值=$faceSuccessThreshold, similar=$similar"
|
||
)
|
||
if (similar >= faceSuccessThreshold) {
|
||
val userId = result.faceEntity?.userName
|
||
recognizeSuccess(userId)
|
||
return
|
||
}
|
||
// 未达阈值:暂不处理(无采集模式),等待下一次识别
|
||
}
|
||
|
||
/**
|
||
* 识别成功:防抖后查询用户信息,走开柜门业务流程
|
||
*/
|
||
private fun recognizeSuccess(userId: String?) {
|
||
if (userId.isNullOrBlank()) {
|
||
Timber.i("recognizeSuccess 识别成功但 userId 为空,忽略")
|
||
return
|
||
}
|
||
Timber.i("recognizeSuccess userId=$userId")
|
||
loginDebouncer.debounce {
|
||
viewModel.getUserInfoById(memberId = userId)
|
||
}
|
||
}
|
||
|
||
override fun onDestroy() {
|
||
Timber.d("onDestroy")
|
||
instance = null
|
||
if (rgbCameraHelper != null) {
|
||
rgbCameraHelper!!.release()
|
||
rgbCameraHelper = null
|
||
}
|
||
|
||
recognizeViewModel.destroy()
|
||
|
||
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.getLayoutParams()
|
||
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()
|
||
}
|
||
|
||
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
|
||
) {
|
||
Timber.d("initRgbCamera Rgb onCameraOpened: cameraId = $cameraId, displayOrientation = $displayOrientation")
|
||
runOnUiThread({
|
||
val previewSizeRgb = camera.getParameters().getPreviewSize()
|
||
val layoutParams = adjustPreviewViewSize(
|
||
binding.dualCameraTexturePreviewRgb,
|
||
binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView,
|
||
previewSizeRgb, displayOrientation, 0.7f
|
||
)
|
||
Timber.d("initRgbCamera previewSizeRgb = ${previewSizeRgb.width}-${previewSizeRgb.height}")
|
||
Timber.d("initRgbCamera layoutParams = ${layoutParams.width}-${layoutParams.height}")
|
||
Timber.d(
|
||
"initRgbCamera isMirror = ${isMirror}, isDrawRgbRectHorizontalMirror = ${
|
||
ConfigUtil.isDrawRgbRectHorizontalMirror(
|
||
context
|
||
)
|
||
}, isDrawRgbRectVerticalMirror = ${
|
||
ConfigUtil.isDrawRgbRectVerticalMirror(
|
||
context
|
||
)
|
||
}"
|
||
)
|
||
rgbFaceRectTransformer = FaceRectTransformer(
|
||
previewSizeRgb.width,
|
||
previewSizeRgb.height,
|
||
layoutParams.width,
|
||
layoutParams.height,
|
||
90,
|
||
cameraId,
|
||
isMirror,
|
||
true,
|
||
true
|
||
)
|
||
|
||
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()
|
||
val facePreviewInfoList: MutableList<FacePreviewInfo?>? =
|
||
recognizeViewModel.onPreviewFrame(nv21, true)
|
||
if (facePreviewInfoList != null && rgbFaceRectTransformer != null) {
|
||
drawPreviewInfo(facePreviewInfoList)
|
||
}
|
||
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)
|
||
.previewOn(binding.dualCameraTexturePreviewRgb)
|
||
.cameraListener(cameraListener)
|
||
.build()
|
||
rgbCameraHelper!!.init()
|
||
rgbCameraHelper!!.start()
|
||
}
|
||
|
||
/**
|
||
* 绘制RGB、IR画面的实时人脸信息
|
||
*
|
||
* @param facePreviewInfoList RGB画面的实时人脸信息
|
||
*/
|
||
private fun drawPreviewInfo(facePreviewInfoList: MutableList<FacePreviewInfo?>) {
|
||
if (rgbFaceRectTransformer != null) {
|
||
val rgbDrawInfoList: MutableList<DrawInfo?>? = recognizeViewModel.getDrawInfo(
|
||
facePreviewInfoList,
|
||
LivenessType.RGB,
|
||
openRectInfoDraw
|
||
)
|
||
// 识别成功
|
||
binding.dualCameraFaceRectView.drawRealtimeFaceInfo(rgbDrawInfoList)
|
||
}
|
||
}
|
||
|
||
fun openRectInfoDraw(view: View?) {
|
||
openRectInfoDraw = !openRectInfoDraw
|
||
recognizeViewModel.setDrawRectInfoTextValue(openRectInfoDraw)
|
||
}
|
||
|
||
|
||
override fun onGlobalLayout() {
|
||
Timber.d("onGlobalLayout")
|
||
binding.dualCameraTexturePreviewRgb.getViewTreeObserver().removeOnGlobalLayoutListener(this)
|
||
// 请求权限
|
||
checkCameraPermission()
|
||
}
|
||
|
||
private fun resumeCamera() {
|
||
val helper = rgbCameraHelper
|
||
if (helper != null && helper.isStopped) {
|
||
isRecognition = true
|
||
helper.start()
|
||
} else {
|
||
// 相机未停止时,发送黑帧清除 ViewModel 内部识别缓存,防止恢复后使用旧数据
|
||
recognizeViewModel.onPreviewFrame(emptyFrame, true)
|
||
binding.dualCameraFaceRectView.clearFaceInfo()
|
||
binding.root.postDelayed({
|
||
isRecognition = true
|
||
}, 500)
|
||
}
|
||
}
|
||
|
||
override fun onResume() {
|
||
super.onResume()
|
||
// 清空上次识别结果和 FaceHelper 内部状态,防止短时间内再次识别无法触发
|
||
recognizeViewModel.resetFaceState()
|
||
resumeCamera()
|
||
viewModel.resetUserInfo()
|
||
countDownTimer?.let {
|
||
it.cancel()
|
||
it.start()
|
||
}
|
||
}
|
||
|
||
protected override fun onPause() {
|
||
pauseCamera()
|
||
super.onPause()
|
||
countDownTimer?.cancel()
|
||
}
|
||
|
||
private fun pauseCamera() {
|
||
isRecognition = false
|
||
recognizeViewModel.onPreviewFrame(emptyFrame, true)
|
||
}
|
||
|
||
/**
|
||
* 构造符合分辨率的 NV21 格式全黑帧
|
||
* @param width 图像宽度
|
||
* @param height 图像高度
|
||
* @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(默认值)
|
||
nv21Data.fill(0, 0, frameSize)
|
||
nv21Data.fill(128.toByte(), frameSize, nv21Data.size)
|
||
return nv21Data
|
||
}
|
||
|
||
private val emptyFrame = createBlackNV21Frame(1280, 720)
|
||
|
||
override fun handleLoginSuccess(equipmentUserInfo: EquipmentUserInfo, isAdmin: Boolean) {
|
||
super.handleLoginSuccess(equipmentUserInfo, isAdmin)
|
||
// goInitActivity()
|
||
}
|
||
|
||
fun initCountTime() {
|
||
val totalTimeInMillis = 30L * 1000
|
||
countDownTimer = object : CountDownTimer(totalTimeInMillis, 1000) {
|
||
override fun onTick(millisUntilFinished: Long) {
|
||
Timber.d("initCountTime onTick = ${(millisUntilFinished / 1000).toInt()}")
|
||
binding.tvToInit.text = "${(millisUntilFinished / 1000).toInt() + 1}"
|
||
}
|
||
|
||
override fun onFinish() {
|
||
goInitActivity()
|
||
}
|
||
}.start()
|
||
}
|
||
|
||
fun goInitActivity() {
|
||
val intent = Intent(context, InitActivity::class.java)
|
||
startActivity(intent)
|
||
}
|
||
|
||
|
||
} |