Author SHA1 Message Date
mazengfei 97e5d74c4b 吐盘机: 避免相机关闭后预览画面残留
- 将双击判定窗口时间由300ms延长至600ms,提升触摸屏操作体验
- 为标题时间布局的左右TextView添加最小高度和内边距,优化显示效果
- 相机关闭时清空预览画面,防止TextureView冻结并残留上一用户画面
- 重启相机前先清空残留帧,避免闪现上一用户画面
2026-08-19 17:03:52 +08:00
mazengfei 524837ec20 feat(face): 支持按userFaceId查询单条人脸记录
- FaceDao新增queryByUserFaceId接口,实现按userFaceId精确查询
- FaceApi新增queryByUserFaceId方法,封装Dao查询逻辑
- LoginByFaceActivity插入人脸时,先按userFaceId查询,存在则更新避免重复
- NetViewModelV2同步人脸数据时,优先用userFaceId判重,减少重复入库
- 修正FaceEntity中userId字段数据库列名错误(user_id替代user_idd)
- 增加注释说明及代码格式优化,提升代码可读性和健壮性
2026-08-18 09:12:40 +08:00
mazengfei 747e243122 feat(face): 按 userFaceId 删除单条人脸记录
- 在 FaceDao 中新增按 userFaceId 删除人脸记录的SQL语句
- 在 FaceApi 类中新增 deleteByUserFaceId 方法调用该DAO方法
- 在 NetViewModelV2 中删除人脸时改为按 userFaceId 删除,避免误删所有同用户特征
2026-08-12 14:43:29 +08:00
mazengfei 6be9f4f1cf fix(plc): 统一添加超时兜底机制避免调用无响应挂起
- 在 PlcHelper 中新增 withTimeout 方法,包装所有 SDK 动作和查询接口
- 通过定时器实现超时检测,超时触发 onError 回调,避免永久挂起
- 确保 onSuccess 和 onError 回调至多调用一次,防止重复回调
- 为所有 PLC 操作接口分别设置合理超时时长(3s - 10s不等)
- 修改所有内部调用使用 withTimeout 包装,保证异常场景的错误回调触发
- 更新生产环境基础 URL 地址为平台测试环境地址
2026-08-10 14:02:30 +08:00
mazengfei c9dd294f87 feat(api): 新增取餐盘时刻上报接口及相关调用
- 在 ApiServiceV2 中添加 platePickup 接口用于上报用户开始就餐时刻
- 新增请求体 IdDTO,封装用户 id(餐盘号)字段
- RemoteRepositoryV2 添加对应的 platePickup 方法调用接口
- NetViewModelV2 实现业务方法 platePickup,支持异步回调上报结果
- LoginByFaceActivity 中识别成功出盘后及新用户注册成功后调用 platePickup 上报时刻
- reportPlatePickup 方法完善调用逻辑及日志记录,错误时不会影响主流程
2026-07-10 09:37:41 +08:00
mazengfei 5b29c99b34 refactor(login): 优化出盘流程并增强错误处理
- 抽取出盘成功与失败的统一处理方法,简化代码逻辑
- 新增新用户注册采集成功后立即出盘功能,避免与识别成功逻辑冲突
- 出盘成功后取消识别倒计时并控制出盘开关,防止重复出盘
- 出盘失败时统一提示错误并跳转回初始页
- 采集成功后立即异步出盘,确保上传失败时仍能出盘
- 调整识别失败计数重置逻辑,避免无人脸短暂丢帧导致计数异常
- 在人脸离开时重置识别失败计数,保证下次重新累计
- 网络请求增加异常捕获,防止因异常导致回调阻塞无反馈
2026-07-08 18:30:14 +08:00
mazengfei 9a8ac83d33 feat(face): 新增人脸库展示与增量更新支持
- 在PLC设置界面新增人脸库统计模块,显示人脸总数并支持刷新
- 人脸总数数据从本地数据库异步查询并更新界面
- 修改API接口调用,采用POST请求体方式提交人脸数据(包含url和特征字符串)
- 在网络调用中实现人脸数据的增量分页获取,递归拉取所有数据页
- 维护人脸数据的最大时间戳,确保增量更新只处理最新数据
- 优化人脸数据缓存逻辑,第一页重置数据并同步时间戳
- 添加FeatureBody数据类封装人脸特征请求体参数
- 修正相关仓库层方法以适配新的API接口参数和返回结构
2026-07-07 17:33:04 +08:00
mazengfei cb107c2da2 feat(face): 集成5.0口罩检测引擎并升级数据库版本
- 将FaceDatabase版本升级至2并添加回退破坏性迁移方案
- FaceHelper中新增maskEngine字段与相关处理逻辑,支持口罩检测
- 更新RecognizeViewModel,初始化和管理口罩检测引擎maskEngine
- 在人脸识别流程中使用maskEngine进行口罩检测处理
- 修改ndk配置支持arm64-v8a架构
- 替换项目Gradle和JDK版本为corretto-17
- 优化人脸活体检测代码,移除不再使用的needUpdateFaceData逻辑
- 增加.idea项目设置文件,配置Firebase Crashlytics与设备选择器设置
2026-07-01 16:00:32 +08:00
mazengfei dbb4288217 feat(face): 集成5.0口罩检测引擎并升级数据库版本
- 将FaceDatabase版本升级至2并添加回退破坏性迁移方案
- FaceHelper中新增maskEngine字段与相关处理逻辑,支持口罩检测
- 更新RecognizeViewModel,初始化和管理口罩检测引擎maskEngine
- 在人脸识别流程中使用maskEngine进行口罩检测处理
- 修改ndk配置支持arm64-v8a架构
- 替换项目Gradle和JDK版本为corretto-17
- 优化人脸活体检测代码,移除不再使用的needUpdateFaceData逻辑
- 增加.idea项目设置文件,配置Firebase Crashlytics与设备选择器设置
2026-07-01 15:59:13 +08:00
lvmeng d1e9c4d34f refactor(config): 更新环境配置URL映射关系
- 将TEST_BASE_URL替换为LOCAL_BASE_URL作为本地开发地址
- 将UAT_BASE_URL替换为TEST_BASE_URL作为测试环境地址
- 更新环境切换对话框中的单选按钮映射逻辑
- 修改应用启动时的设备ID判断逻辑和默认环境配置
- 移除部分临时测试代码并调整注释状态
2026-06-30 16:58:11 +08:00
lvmeng 38a00e8d34 refactor(face): 更新FaceEntity实体类并调整引擎激活逻辑
- 为FaceEntity添加默认构造函数并初始化注册时间
- 为FaceEntity的所有构造函数添加@Ignore注解
- 补充FaceEntity的Parcel序列化和反序列化逻辑
- 完善FaceEntity的getter和setter方法实现
- 更新equals和hashCode方法以包含新增字段
- 将人脸引擎激活从userViewModel切换到netViewModelV2
- 添加lint配置文件忽略通知权限检查警告
2026-06-30 10:31:06 +08:00
lvmeng 95b4f5e8f2 feat(network): 添加API V2版本支持并重构网络层
- 新增ApiServiceV2接口定义,支持人脸缓存、增量数据、设备配置等功能
- 添加NetViewModelV2视图模型,实现新版本API的数据处理逻辑
- 创建RemoteRepositoryV2仓库,封装API V2的网络请求操作
- 新增UserFaceModelV2数据模型,优化人脸特征数据结构
- 添加DeviceConfigV2设备配置模型,支持更多设备参数
- 在ApiClient中注册apiServiceV2实例
- 更新BaseActivity使用NetViewModelV2替代UserViewModel
- 修改DeviceInitActivity中的人脸数据获取和设备配置逻辑
- 重构FaceDao数据库访问对象,优化用户类型查询和删除操作
- 更新FaceEntity实体类,增加更多用户相关字段
- 移除BaseViewModel中的旧版网络基础功能代码
- 优化人脸数据同步和存储流程,提升性能表现
2026-06-30 10:08:10 +08:00
42 changed files with 2689 additions and 527 deletions
+21
View File
@@ -2,5 +2,26 @@
<project version="4"> <project version="4">
<component name="AppInsightsSettings"> <component name="AppInsightsSettings">
<option name="selectedTabId" value="Firebase Crashlytics" /> <option name="selectedTabId" value="Firebase Crashlytics" />
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component> </component>
</project> </project>
+3 -8
View File
@@ -2,16 +2,11 @@
<project version="4"> <project version="4">
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="SmartPlateCabinet.app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-03-18T06:09:00.079142300Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=192.168.1.161:5555;connection=2c537abb" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>
+1 -1
View File
@@ -6,7 +6,7 @@
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="azul-17" /> <option name="gradleJvm" value="corretto-17" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="UsePropertyAccessSyntax" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
</profile>
</component>
+1 -1
View File
@@ -1,6 +1,6 @@
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="zulu-17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">
+1 -1
View File
@@ -27,7 +27,7 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { ndk {
abiFilters.addAll(listOf("armeabi-v7a"/*, "arm64-v8a"*/)) abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a"))
} }
setProperty("archivesBaseName", "zncpg_${versionName}") setProperty("archivesBaseName", "zncpg_${versionName}")
} }
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<!-- Glide库中的NotificationTarget触发了此检查,但本kiosk应用不使用通知功能 -->
<issue id="NotificationPermission" severity="ignore" />
</lint>
@@ -38,9 +38,9 @@ object GlobalData {
/** /**
* 具体业务 BaseUrl * 具体业务 BaseUrl
*/ */
const val TEST_BASE_URL: String = "http://192.168.1.201:14801" const val LOCAL_BASE_URL: String = "http://192.168.10.101:24801"
const val UAT_BASE_URL: String = "https://dev.yixiong-tech.com:8083" const val TEST_BASE_URL: String = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL: String = "https://api.dm.yixiong-tech.com:8443" const val PROD_BASE_URL: String = "https://platform-api.uat.shuziweidao.com"
/** /**
* 设备id * 设备id
@@ -42,12 +42,15 @@ class MyApp : App() {
} else { } else {
GlobalData.appBaseUrl = GlobalData.appBaseUrl =
if ("14d2dad5-7d33-3ced-8a59-f14cb1ef2d21" == deviceId) if ("14d2dad5-7d33-3ced-8a59-f14cb1ef2d21" == deviceId)
GlobalData.TEST_BASE_URL GlobalData.LOCAL_BASE_URL
else else
GlobalData.PROD_BASE_URL // GlobalData.PROD_BASE_URL
GlobalData.TEST_BASE_URL
} }
// TODO: 临时测试---------------- // TODO: 临时测试----------------
// GlobalData.appBaseUrl = GlobalData.TEST_BASE_URL
// GlobalData.deviceId = "79814f20-f253-397c-9da7-8430ffa5ef00"
// if ("2987f0c5-5754-33e9-b00a-251db5e2e55f" == deviceId) { // if ("2987f0c5-5754-33e9-b00a-251db5e2e55f" == deviceId) {
// GlobalData.deviceId = "79814f20-f253-397c-9da7-8430ffa5ef00" // GlobalData.deviceId = "79814f20-f253-397c-9da7-8430ffa5ef00"
// } // }
@@ -55,6 +58,7 @@ class MyApp : App() {
GlobalData.appVersion = AppUtil.getAppVersionCode(this).toString() GlobalData.appVersion = AppUtil.getAppVersionCode(this).toString()
GlobalData.globalEquipmentCode = "202501171634" GlobalData.globalEquipmentCode = "202501171634"
// GlobalData.appBaseUrl = "http://192.168.1.201:14801" // GlobalData.appBaseUrl = "http://192.168.1.201:14801"
} }
} }
@@ -25,11 +25,10 @@ import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel
import com.sw.platecabinet.R import com.sw.platecabinet.R
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.ext.clickWithCoroutines import com.sw.platecabinet.ext.clickWithCoroutines
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.utils.IntervalExecutor import com.sw.platecabinet.utils.IntervalExecutor
import com.sw.platecabinet.utils.SpTool import com.sw.platecabinet.utils.SpTool
import com.sw.platecabinet.view.CustomLoadingDialog import com.sw.platecabinet.view.CustomLoadingDialog
import com.sw.platecabinet.viewmodel.UserViewModel import com.sw.platecabinet.viewmodel.NetViewModelV2
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -48,10 +47,8 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
// private val permissionHelpers = mutableMapOf<Int, PermissionHelper>() // private val permissionHelpers = mutableMapOf<Int, PermissionHelper>()
// protected var keyEventHelper: ScanGunKeyEventHelper? = null // protected var keyEventHelper: ScanGunKeyEventHelper? = null
// 用户对应的viewModel
protected val userViewModel by viewModels<UserViewModel>()
public val recognizeViewModel by viewModels<RecognizeViewModel>() public val recognizeViewModel by viewModels<RecognizeViewModel>()
public val netViewModelV2 by viewModels<NetViewModelV2>()
private var startTime: Long = 0 private var startTime: Long = 0
@@ -132,12 +129,16 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
fun updateTime() { fun updateTime() {
headerBinding?.let { headerBinding?.let {
// 双击判定窗口放宽至 600ms:默认 300ms 对触摸屏上的手动双击过短,
// 两次点击间隔常在 300~600ms,导致设置页"点很多次才进"
it.tvLeftDate.clickWithCoroutines( it.tvLeftDate.clickWithCoroutines(
doubleClickInterval = 600,
onDoubleClick = { onDoubleClick = {
onLeftDoubleClick() onLeftDoubleClick()
} }
) )
it.tvRightTime.clickWithCoroutines( it.tvRightTime.clickWithCoroutines(
doubleClickInterval = 600,
onDoubleClick = { onDoubleClick = {
onRightDoubleClick() onRightDoubleClick()
} }
@@ -298,84 +299,23 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
// private val dealyMillis = 10 * 60 * 1000L // private val dealyMillis = 10 * 60 * 1000L
private val initialDelay = 1 * 1000L private val initialDelay = 1 * 1000L
private val dealyMillis = 10 * 1000L private val dealyMillis = 10 * 1000L
fun startFaceTask(block: (List<String?>) -> Unit) { fun startFaceTask() {
faceTaskJob = faceTaskJob =
intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) { intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) {
getFaceIncrementList(action = block) getFaceIncrementList()
} }
} }
private var faceTimestamp = 0L private var faceTimestamp = 0L
//private var taskPageNo = 1 //private var taskPageNo = 1
private fun getFaceIncrementList(pageNo: Int = 1, action: (List<String?>) -> Unit) { private fun getFaceIncrementList(pageNo: Int = 1) {
val timestamp = SpTool.lastFaceTimestamp val timestamp = SpTool.lastFaceTimestamp
userViewModel.getFaceIncrementList( netViewModelV2.getFaceIncrementList(
pageNo = pageNo, pageNo = pageNo,
timestamp = timestamp timestamp = timestamp
) { list -> ) {
runOnUiThread {
if (pageNo == 1 && list.isEmpty()) {
return@runOnUiThread
}
updateFaceData(list)
val userIdList = list.map { it.userId }
action(userIdList)
if (list.isNotEmpty()) {
faceTimestamp = list.last().faceUpdateTimestamp ?: 0
}
SpTool.lastFaceTimestamp = faceTimestamp
}
}
}
private fun updateFaceData(list: List<UserFaceModel>) {
lifecycleScope.launch(Dispatchers.IO) {
val faceList = mutableListOf<FaceEntity>()
val faceDao = FaceDatabase.getInstance(this@BaseActivity).faceDao()
try {
list.forEach { model ->
if (model.faceDeleted == true) {
//删除数据
faceDao.deleteFaceById(model.userId)
} else {
// 获取该用户所有已存特征,判断当前特征是否已存在
val existList = faceDao.queryAllByUserName(model.userId)
val featureMatches =
existList.map { Base64.encode(it.featureData) == model.faceFeatureStr }
Timber.d("userId=${model.userId}, 已存记录数=${existList.size}, 各条特征是否与服务器一致=$featureMatches")
val alreadyExists = featureMatches.any { it }
if (!alreadyExists) {
//当前特征不存在,保存
runCatching { getNewFaceEntity(model) }
.onSuccess { faceList.add(it) }
.onFailure { Timber.e(it, "人脸特征解码失败: userId=${model.userId}") }
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
try {
if (faceList.isNotEmpty()) {
FaceDatabase.getInstance(this@BaseActivity).faceDao().insert(faceList)
}
recognizeViewModel.refreshFaceList() recognizeViewModel.refreshFaceList()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun getNewFaceEntity(model: UserFaceModel): FaceEntity {
return FaceEntity(
model.userId,
null,
Base64.decode(model.faceFeatureStr)
).also {
//1-会员、2-临时用户
it.userType = if (model.member) "1" else "2"
it.cardNo = model.cardNo
} }
} }
@@ -3,7 +3,6 @@ package com.sw.platecabinet.activity
import android.content.Intent import android.content.Intent
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.FaceApi
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.databinding.ActivityDeviceInitBinding import com.sw.platecabinet.databinding.ActivityDeviceInitBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.databinding.ItemTitleTimeBinding
@@ -32,7 +31,7 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
//val count = FaceApi().queryFaceCount() //val count = FaceApi().queryFaceCount()
//if (count == 0) { //if (count == 0) {
if (SpTool.firstGetFace) { if (SpTool.firstGetFace) {
userViewModel.getUserFaceCache { status, msg -> netViewModelV2.getUserFaceCache { status, msg ->
SpTool.firstGetFace = false SpTool.firstGetFace = false
getDeviceConfig() getDeviceConfig()
} }
@@ -44,7 +43,7 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
} }
private fun getDeviceConfig() { private fun getDeviceConfig() {
userViewModel.getDeviceConfig { deviceConfig -> netViewModelV2.getDeviceConfig(onSuccess = { deviceConfig ->
runOnUiThread { runOnUiThread {
if (deviceConfig == null) { if (deviceConfig == null) {
hideWaitingDialog() hideWaitingDialog()
@@ -61,7 +60,11 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
goLoginActivity() goLoginActivity()
}, 500) }, 500)
} }
}, onFailure = { errMsg ->
runOnUiThread {
ToastUtils.showToast(errMsg ?: "接口异常")
} }
})
} }
override fun registerDataChange() { override fun registerDataChange() {
@@ -20,7 +20,6 @@ import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.arcsoft.face.ErrorInfo import com.arcsoft.face.ErrorInfo
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.gson.Gson
import com.sw.inbound.utils.DateTimeUtils import com.sw.inbound.utils.DateTimeUtils
import com.sw.plate.utils.Base64 import com.sw.plate.utils.Base64
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
@@ -33,7 +32,6 @@ import com.sw.plate.utils.arcface.PreviewConfig
import com.sw.plate.utils.arcface.camera.CameraListener import com.sw.plate.utils.arcface.camera.CameraListener
import com.sw.plate.utils.arcface.camera.DualCameraHelper import com.sw.plate.utils.arcface.camera.DualCameraHelper
import com.sw.plate.utils.arcface.face.constants.LivenessType import com.sw.plate.utils.arcface.face.constants.LivenessType
import com.sw.plate.utils.arcface.face.constants.RecognizeColor
import com.sw.plate.utils.arcface.face.model.CompareResult 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.FacePreviewInfo
import com.sw.plate.utils.arcface.face.model.RecognizeConfiguration import com.sw.plate.utils.arcface.face.model.RecognizeConfiguration
@@ -49,7 +47,7 @@ import com.sw.platecabinet.dialog.CustomDialog
import com.sw.platecabinet.ext.clickWithDebounce import com.sw.platecabinet.ext.clickWithDebounce
import com.sw.platecabinet.ext.gone import com.sw.platecabinet.ext.gone
import com.sw.platecabinet.ext.visible import com.sw.platecabinet.ext.visible
import com.sw.platecabinet.model.response.UserFaceModel import com.sw.platecabinet.model.response.UserFaceModelV2
import com.sw.platecabinet.utils.BitmapSaver import com.sw.platecabinet.utils.BitmapSaver
import com.sw.platecabinet.utils.CountDownUtil import com.sw.platecabinet.utils.CountDownUtil
import com.sw.platecabinet.utils.Debouncer import com.sw.platecabinet.utils.Debouncer
@@ -92,7 +90,7 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
} }
override fun initialize() { override fun initialize() {
userViewModel.activeEngine() netViewModelV2.activeEngine()
PlcHelper.getInstance(this@LoginByFaceActivity) PlcHelper.getInstance(this@LoginByFaceActivity)
.openSerialPort(object : PlcCallback<String> { .openSerialPort(object : PlcCallback<String> {
@@ -113,9 +111,7 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
EventBus.getDefault().register(this) EventBus.getDefault().register(this)
//开启人脸增量数据定时任务 //开启人脸增量数据定时任务
startFaceTask { startFaceTask()
loadLogInfo("collectFace增量接口数据用户id:${Gson().toJson(it)}")
}
val soundMap = hashMapOf<String, Int>() val soundMap = hashMapOf<String, Int>()
soundMap[COLLECT_SUCCESS] = R.raw.collect_success soundMap[COLLECT_SUCCESS] = R.raw.collect_success
@@ -421,27 +417,94 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
PlcHelper.getInstance(this@LoginByFaceActivity) PlcHelper.getInstance(this@LoginByFaceActivity)
.outPlate(object : PlcCallback<String> { .outPlate(object : PlcCallback<String> {
override fun onSuccess(result: String) { 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<String> {
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 plateEnable = false
recognizeCountDownUtil.cancelCountDown() recognizeCountDownUtil.cancelCountDown()
// result 为 false 时表示出盘后无餐盘,直接播放无盘提示音
if (result.equals("false", ignoreCase = true)) { if (result.equals("false", ignoreCase = true)) {
loadLogInfo("collectFace,openPlate: result=false,餐盘已空") loadLogInfo("collectFace,onOutPlateSuccess: result=false,餐盘已空")
SoundPoolUtil.getInstance().play(NO_PLATE_REMIND, 0) SoundPoolUtil.getInstance().play(NO_PLATE_REMIND, 0)
goInitActivity() goInitActivity()
return return
} }
loadLogInfo("collectFace,openPlate: 出盘完成,result=$result,开始查询剩余餐盘") SoundPoolUtil.getInstance().play(successSound, 0)
// result 为 true,出盘成功,再查询是否还有剩余餐盘 // 已注册用户:出盘成功且有盘时,上报取餐盘时刻(P-09a)
SoundPoolUtil.getInstance().play(RECOGNIZE_SUCCESS, 0) if (successSound == RECOGNIZE_SUCCESS) {
reportPlatePickup(currentUserId, "recognize")
}
checkRemainingPlate() checkRemainingPlate()
} }
override fun onError(message: String) { /**
* 出盘失败后的统一处理:提示失败并跳转回初始页
*/
private fun onOutPlateError(message: String) {
ToastUtils.showToast("出盘失败,message=$message") ToastUtils.showToast("出盘失败,message=$message")
loadLogInfo("collectFace,openPlate: 出盘失败,message=$message") loadLogInfo("collectFace,onOutPlateError: 出盘失败,message=$message")
goInitActivity() 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=$successmsg=$msg")
}
} }
/** /**
@@ -621,7 +684,9 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
userFaceInfo = null userFaceInfo = null
binding.btnCollect.tag = "" binding.btnCollect.tag = ""
binding.tvSimilarValue.text = "" binding.tvSimilarValue.text = ""
retryFailCount = 0 // 注意:此处不再重置 retryFailCount。
// 人脸检测会抖动(短暂丢帧),若每次无人脸就清零,"尝试几次失败"将无法累计。
// 失败计数仅在识别成功、会话重启、确认用户离开时清零。
// 人脸离开时,发送空帧清除 ViewModel 内部识别缓存,防止再次进入时使用旧数据 // 人脸离开时,发送空帧清除 ViewModel 内部识别缓存,防止再次进入时使用旧数据
recognizeViewModel.onPreviewFrame(emptyFrame, true) recognizeViewModel.onPreviewFrame(emptyFrame, true)
if (binding.llCollectButton.isVisible) { if (binding.llCollectButton.isVisible) {
@@ -633,6 +698,8 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
override fun onCameraClosed() { override fun onCameraClosed() {
Timber.i("initRgbCamera onCameraClosed: ") Timber.i("initRgbCamera onCameraClosed: ")
// 相机关闭时清空预览画面,防止 TextureView 冻结残留上一位用户的帧
clearPreviewFrame()
} }
override fun onCameraError(e: java.lang.Exception) { override fun onCameraError(e: java.lang.Exception) {
@@ -698,11 +765,30 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
checkCameraPermission() checkCameraPermission()
} }
/**
* 清除预览 TextureView 中缓存的最后一帧画面(提交一帧全黑)。
* 相机关闭后 TextureView 会冻结保留最后一帧,若不清除,
* 下一位用户重启识别时会先闪现上一位用户的画面。
*/
private fun clearPreviewFrame() {
val previewView = binding.dualCameraTexturePreviewRgb
if (!previewView.isAvailable) return
runCatching {
val canvas = previewView.lockCanvas() ?: return@runCatching
canvas.drawColor(android.graphics.Color.BLACK)
previewView.unlockCanvasAndPost(canvas)
}.onFailure {
loadLogInfo("clearPreviewFrame 失败: ${it.message}")
}
}
private fun resumeCamera() { private fun resumeCamera() {
// isRecognition = true // isRecognition = true
val helper = rgbCameraHelper val helper = rgbCameraHelper
if (helper != null && helper.isStopped) { if (helper != null && helper.isStopped) {
loadLogInfo("collectFace,resumeCamera: ") loadLogInfo("collectFace,resumeCamera: ")
// 重启相机前先清空残留帧,避免闪现上一位用户的画面
clearPreviewFrame()
isRecognition = true isRecognition = true
helper.start() helper.start()
} else { } else {
@@ -887,7 +973,7 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
} }
private fun addUserFace(faceData: String, imageFile: File) { private fun addUserFace(faceData: String, imageFile: File) {
userViewModel.addUserFace(faceData, imageFile) { status, faceModel -> netViewModelV2.addUserFace(faceData, imageFile) { status, faceModel ->
loadLogInfo("collectFace,saveFaceInfo: ----------12,人脸数据发送完成时间:${DateTimeUtils.getDateTimeString()},结果:$status-----------") loadLogInfo("collectFace,saveFaceInfo: ----------12,人脸数据发送完成时间:${DateTimeUtils.getDateTimeString()},结果:$status-----------")
if (status.not()) { if (status.not()) {
ToastUtils.showToast("新增数据失败") ToastUtils.showToast("新增数据失败")
@@ -899,6 +985,8 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
loadLogInfo("collectFace,sendFaceData: ----------12,发送人脸数据成功但接口返回为null,faceData=${faceData}") loadLogInfo("collectFace,sendFaceData: ----------12,发送人脸数据成功但接口返回为null,faceData=${faceData}")
return@addUserFace return@addUserFace
} }
// 新用户:注册成功拿到真实 userId 后,上报取餐盘时刻(P-09a)
reportPlatePickup(faceModel.userId, "collect")
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
saveFaceInfo(faceModel) { saveFaceInfo(faceModel) {
runOnUiThread { runOnUiThread {
@@ -926,6 +1014,9 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
//block(faceData, headBmp) //block(faceData, headBmp)
isCollecting.set(false) isCollecting.set(false)
isCollectFace = false isCollectFace = false
// 采集成功后立即出盘,与上传后台并行执行,确保上传失败时仍能吐盘
outPlateForRegister()
// 异步上传后台(上传成功后才入库),不阻塞出盘
sendFaceData(faceData, headBmp) sendFaceData(faceData, headBmp)
} }
@@ -943,27 +1034,29 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
private var currentUserId: String? = null private var currentUserId: String? = null
private fun saveFaceInfo(faceModel: UserFaceModel, block: () -> Unit = {}) { private fun saveFaceInfo(faceModel: UserFaceModelV2, block: () -> Unit = {}) {
try { try {
loadLogInfo("collectFace,saveFaceInfo: ----------8,保存人脸取特征数据-----------") loadLogInfo("collectFace,saveFaceInfo: ----------8,保存人脸取特征数据-----------")
val faceDao = FaceDatabase.getInstance(this).faceDao() val faceDao = FaceDatabase.getInstance(this).faceDao()
val faceEntity = FaceEntity( val faceEntity = faceModel.toFaceEntity()
faceModel.userId, // 先查后插:按 userFaceId 判重,避免采集入库与增量同步各插一条
null, val userFaceId = faceModel.userFaceId
Base64.decode(faceModel.faceFeatureStr) val existing = if (!userFaceId.isNullOrEmpty()) faceDao.queryByUserFaceId(userFaceId) else null
).also { val faceId = if (existing != null) {
//1-会员、2-临时用户 // 已存在记录,更新特征等字段并复用主键,避免重复
it.userType = if (faceModel.member) "1" else "2" faceEntity.faceId = existing.faceId
faceDao.updateFaceEntity(faceEntity)
existing.faceId
} else {
faceDao.insert(faceEntity)
} }
val faceId = faceDao.insert(faceEntity)
faceEntity.faceId = faceId faceEntity.faceId = faceId
loadLogInfo("collectFace,saveFaceInfo: ----------10,人脸数据保存完成-----------") loadLogInfo("collectFace,saveFaceInfo: ----------10,人脸数据保存完成-----------")
recognizeViewModel.refreshFaceList() recognizeViewModel.refreshFaceList()
loadLogInfo("collectFace,saveFaceInfo: ----------11,刷新人脸数据-----------") loadLogInfo("collectFace,saveFaceInfo: ----------11,刷新人脸数据-----------")
runOnUiThread { runOnUiThread {
//采集完成,不再识别,直接提示成功 //采集完成,停止识别(出盘已在采集成功时由 outPlateForRegister 触发,此处仅负责入库)
isRecognition = false isRecognition = false
recognizeSuccess("${System.currentTimeMillis()}")
} }
loadLogInfo("---------------------------------") loadLogInfo("---------------------------------")
collectCount = 0 collectCount = 0
@@ -995,6 +1088,8 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
loadLogInfo("leftCountDown.onFinish-人脸未离开,不跳转") loadLogInfo("leftCountDown.onFinish-人脸未离开,不跳转")
return@startCountDown return@startCountDown
} }
// 确认用户已离开画面,重置识别失败计数,下次进入重新累计
retryFailCount = 0
goInitActivity() goInitActivity()
}) })
} }
@@ -5,6 +5,7 @@ import android.content.Intent
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.plate.utils.arcface.faceserver.FaceServer import com.sw.plate.utils.arcface.faceserver.FaceServer
import com.sw.platecabinet.databinding.ActivityPlcSettingBinding import com.sw.platecabinet.databinding.ActivityPlcSettingBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.databinding.ItemTitleTimeBinding
@@ -60,6 +61,7 @@ class PlcSettingActivity : BaseActivity<ActivityPlcSettingBinding>() {
initAlarmButton() initAlarmButton()
initParamButtons() initParamButtons()
initStatusButton() initStatusButton()
initFaceCount()
binding.btnSwitchEnv.setOnClickListener { binding.btnSwitchEnv.setOnClickListener {
EnvSwitchDialog(context = this, onEnvChanged = { EnvSwitchDialog(context = this, onEnvChanged = {
showWaitingDialog("重置人脸数据中……") showWaitingDialog("重置人脸数据中……")
@@ -68,7 +70,7 @@ class PlcSettingActivity : BaseActivity<ActivityPlcSettingBinding>() {
FaceServer.getInstance().clearAllFaces() FaceServer.getInstance().clearAllFaces()
} }
} }
userViewModel.getUserFaceCache(pageNo = 1, callback = { state, errMsg -> netViewModelV2.getUserFaceCache(pageNo = 1, callback = { state, errMsg ->
hideWaitingDialog() hideWaitingDialog()
if (state) { if (state) {
recognizeViewModel.refreshFaceList() recognizeViewModel.refreshFaceList()
@@ -334,6 +336,30 @@ class PlcSettingActivity : BaseActivity<ActivityPlcSettingBinding>() {
""".trimIndent() """.trimIndent()
} }
// ==================== 人脸库统计 ====================
/**
* 初始化人脸库统计区:页面加载时查询并显示人脸总数,绑定刷新按钮
*/
private fun initFaceCount() {
refreshFaceCount()
binding.btnRefreshFaceCount.clickWithDebounce {
refreshFaceCount()
}
}
/**
* 从数据库查询人脸总数并更新 UI
*/
private fun refreshFaceCount() {
lifecycleScope.launch {
val count = withContext(Dispatchers.IO) {
FaceDatabase.getInstance(this@PlcSettingActivity).faceDao().faceCount
}
binding.tvFaceCount.text = "人脸总数:${count}"
}
}
// ==================== 生命周期 ==================== // ==================== 生命周期 ====================
/** /**
@@ -39,8 +39,8 @@ class EnvSwitchDialog(
override fun initView() { override fun initView() {
// 根据当前 appBaseUrl 预选对应 RadioButton,不匹配则不选 // 根据当前 appBaseUrl 预选对应 RadioButton,不匹配则不选
when (GlobalData.appBaseUrl) { when (GlobalData.appBaseUrl) {
GlobalData.TEST_BASE_URL -> binding.rbTest.isChecked = true GlobalData.LOCAL_BASE_URL -> binding.rbTest.isChecked = true
GlobalData.UAT_BASE_URL -> binding.rbUat.isChecked = true GlobalData.TEST_BASE_URL -> binding.rbUat.isChecked = true
GlobalData.PROD_BASE_URL -> binding.rbProd.isChecked = true GlobalData.PROD_BASE_URL -> binding.rbProd.isChecked = true
} }
@@ -55,8 +55,8 @@ class EnvSwitchDialog(
return@setOnClickListener return@setOnClickListener
} }
val newUrl = when (binding.rgEnv.checkedRadioButtonId) { val newUrl = when (binding.rgEnv.checkedRadioButtonId) {
binding.rbTest.id -> GlobalData.TEST_BASE_URL binding.rbTest.id -> GlobalData.LOCAL_BASE_URL
binding.rbUat.id -> GlobalData.UAT_BASE_URL binding.rbUat.id -> GlobalData.TEST_BASE_URL
binding.rbProd.id -> GlobalData.PROD_BASE_URL binding.rbProd.id -> GlobalData.PROD_BASE_URL
else -> return@setOnClickListener else -> return@setOnClickListener
} }
@@ -1,5 +1,7 @@
package com.sw.platecabinet.model package com.sw.platecabinet.model
import com.google.gson.annotations.SerializedName
/** /**
* 错误信息 code == 200 成功 * 错误信息 code == 200 成功
*/ */
@@ -19,6 +21,27 @@ data class DeviceConfig(
var arcsoftActiveKey: String? = null var arcsoftActiveKey: String? = null
) )
data class DeviceConfigV2(
/** 业务服务器 BASE URL */
val appPackageUrl: String? = null,
/** 食堂/餐厅名称 */
val canteenName: String? = null,
/** 食堂/餐厅 ID(后续所有业务 API 的 restId */
val canteenId: String? = null,
/** 虹软人脸 SDK App ID */
val arcsoftAppId: String? = null,
/** 虹软人脸 SDK Key */
val arcsoftSdkKey: String? = null,
/** 虹软人脸 SDK Active Key */
val arcsoftActiveKey: String? = null,
/** MQTT 客户端服务器 IP */
val clientServerIp: String? = null,
/** 智慧食堂服务器 IP(MQTT 端口) */
val zhstServerIp: String? = null
)
data class CodeMsg( data class CodeMsg(
val code: String? = "", val code: String? = "",
val msg: String? = "" val msg: String? = ""
@@ -0,0 +1,6 @@
package com.sw.platecabinet.model.request
data class FeatureBody(
val url:String,
val featureChar:String
)
@@ -0,0 +1,13 @@
package com.sw.platecabinet.model.request
/**
* 通用 ID 请求体
*
* 用于按用户 id(餐盘号)查询或上报类的接口,例如:
* - P-09a 取餐盘(/neglect/pickup/plate-pickup
*
* @property id 用户 id(餐盘号 = userId
*/
data class IdDTO(
val id: Long
)
@@ -9,19 +9,6 @@ import kotlinx.parcelize.Parcelize
*/ */
@Parcelize @Parcelize
data class UserFaceModel( data class UserFaceModel(
// @SerializedName("faceFeature")
// val faceFeature: String? = "",
// @SerializedName("faceFeatureString")
// val faceFeatureString: String? = "",
// @SerializedName("faceType")
// val faceType: String? = "",
// @SerializedName("userFaceId")
// val userFaceId: String? = "",
// @SerializedName("userId")
// val userId: String? = "",
// @SerializedName("updateTimeStamp")
// val updateTimeStamp: Long = 0,
val userId: String? = "", val userId: String? = "",
val faceFeatureStr: String? = "", val faceFeatureStr: String? = "",
val faceUpdateTimestamp: Long? = null, val faceUpdateTimestamp: Long? = null,
@@ -32,10 +19,10 @@ data class UserFaceModel(
/** /**
* 会员编号 * 会员编号
*/ */
val cardNo: String, val cardNo: String? = null,
/** /**
* 是否会员 * 是否会员
*/ */
val member: Boolean val member: Boolean? = null
) : Parcelable ) : Parcelable
@@ -0,0 +1,44 @@
package com.sw.platecabinet.model.response
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import kotlinx.parcelize.Parcelize
/**
* 用户人脸信息
*/
@Parcelize
data class UserFaceModelV2(
val userFaceId: String? = null,
val userId: String? = null,
val faceFeature: String? = null,
val faceFeatureStr: String? = null,
val faceFeatureString: String? = null,
val faceUpdateTimestamp: Long? = null,
val cardNo: String? = null,
val member: Boolean? = false,
val faceDeleted: Boolean? = false,
val personType: String? = null
) : Parcelable {
fun resolveFeatureStr(): String? {
return faceFeature?.takeIf { it.isNotEmpty() }
?: faceFeatureStr?.takeIf { it.isNotEmpty() }
?: faceFeatureString?.takeIf { it.isNotEmpty() }
}
fun toFaceEntity(): FaceEntity {
val featureBase64 = resolveFeatureStr()
return FaceEntity(
userId,
android.util.Base64.decode(featureBase64, android.util.Base64.DEFAULT),
personType,
cardNo,
userId,
userFaceId,
member ?: false,
faceUpdateTimestamp ?: 0L
)
}
}
@@ -3,6 +3,7 @@ package com.sw.platecabinet.network
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.MyApp import com.sw.platecabinet.MyApp
import com.sw.platecabinet.network.api.ApiService import com.sw.platecabinet.network.api.ApiService
import com.sw.platecabinet.network.api.ApiServiceV2
import com.sw.platecabinet.network.interceptor.RequestInterceptor import com.sw.platecabinet.network.interceptor.RequestInterceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -37,4 +38,8 @@ object ApiClient {
val apiService: ApiService by lazy { val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java) retrofit.create(ApiService::class.java)
} }
val apiServiceV2: ApiServiceV2 by lazy {
retrofit.create(ApiServiceV2::class.java)
}
} }
@@ -0,0 +1,78 @@
package com.sw.platecabinet.network.api
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.request.FeatureBody
import com.sw.platecabinet.model.request.IdDTO
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Url
interface ApiServiceV2 {
/**
* 获取人脸缓存数据
*/
@POST
suspend fun getUserFaceCache(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/common/face/page",
@Body param: Map<String, Int>
): ApiResponse<List<UserFaceModelV2>>
/**
* 获取人脸增量数据
*/
@POST
suspend fun getFaceIncrementList(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/common/face/increment",
@Body param: Map<String, Long>
): ApiResponse<List<UserFaceModelV2>?>
/**
* 获取设备配置数据
*/
@GET
suspend fun getDeviceConfig(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/pickup/device/config"
): ApiResponse<DeviceConfigV2?>
/**
* 添加人脸数据
*/
@POST
suspend fun addUserFace(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/user/add-by-face",
@Body faceData: FeatureBody
): ApiResponse<UserFaceModelV2?>
/**
* 上传图片
*/
@Multipart
@POST
suspend fun uploadImage(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/upload",
@Part file: MultipartBody.Part,
): ApiResponse<String?>
/**
* 取餐盘(P-09a):记录用户开始就餐时刻
* 调用一次即 upsert 到主单 plate_pickup_time,是 P-09 提交就餐记录的强前置。
* @param param IdDTOid 为用户 id(餐盘号 = userId
* @return ApiResponse<String?>data 为主单 recordNo
*/
@POST
suspend fun platePickup(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/pickup/plate-pickup",
@Body param: IdDTO
): ApiResponse<String?>
}
@@ -0,0 +1,82 @@
package com.sw.platecabinet.repository
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.request.FeatureBody
import com.sw.platecabinet.model.request.IdDTO
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import com.sw.platecabinet.network.api.ApiServiceV2
import com.sw.platecabinet.utils.FileUtils
import java.io.File
/**
* 远程数据处理
*/
class RemoteRepositoryV2 constructor(
private val apiService: ApiServiceV2
) : BaseRepository() {
/**
* 获取人脸数据
*/
suspend fun getUserFaceCache(
pageNum: Int,
pageSize: Int = 100,
): ApiResponse<List<UserFaceModelV2>> {
return safeApiCall {
apiService.getUserFaceCache(
param = mapOf(
"pageNum" to pageNum, "pageSize" to pageSize
)
)
}
}
suspend fun getFaceIncrementList(
pageNum: Long, pageSize: Long = 100L, timestamp: Long
): ApiResponse<List<UserFaceModelV2>?> {
return safeApiCall {
apiService.getFaceIncrementList(
param = mapOf(
"pageNum" to pageNum, "pageSize" to pageSize, "timestamp" to timestamp
)
)
}
}
suspend fun getDeviceConfig(): ApiResponse<DeviceConfigV2?> {
return safeApiCall { apiService.getDeviceConfig() }
}
suspend fun addUserFace(faceData: String, imageUrl: String): ApiResponse<UserFaceModelV2?> {
return safeApiCall {
apiService.addUserFace(
faceData = FeatureBody(
url = imageUrl, featureChar = faceData
)
)
}
}
suspend fun uploadImage(file: File): ApiResponse<String?> {
return safeApiCall {
val part = FileUtils.genRequestPart(file)
if (part == null) {
ApiResponse(code = "-1", msg = "解析图片失败")
} else {
apiService.uploadImage(file = part)
}
}
}
/**
* 取餐盘(P-09a):上报用户开始就餐时刻
* @param userId 用户 id(餐盘号 = userId
* @return ApiResponse<String?>data 为主单 recordNo
*/
suspend fun platePickup(userId: Long): ApiResponse<String?> {
return safeApiCall { apiService.platePickup(param = IdDTO(id = userId)) }
}
}
@@ -4,10 +4,16 @@ import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import com.megoai.plc_aar.SDK import com.megoai.plc_aar.SDK
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* PLC 操作工具类(单例) * PLC 操作工具类(单例)
* 封装 SDK 所有接口,统一回调到主线程 * 封装 SDK 所有接口,统一回调到主线程
*
* 注意:底层 PLC SDKFlutter MethodChannel 封装)的 Listener 只有 onMessage 一个回调,
* Flutter 侧 error()/notImplemented() 均为空实现,串口异常或引擎未就绪时不会回调。
* 因此本类对所有动作类、查询类接口统一加超时兜底:超时未响应则触发 onError,
* 保证上层回调链不会因“无响应”而永久挂起。
*/ */
class PlcHelper private constructor(context: Context) { class PlcHelper private constructor(context: Context) {
@@ -28,6 +34,59 @@ class PlcHelper private constructor(context: Context) {
} }
} }
// ==================== 超时兜底封装 ====================
/**
* 包装 SDK 动作/查询调用:超时未响应时主动触发 onError。
*
* 背景:底层 SDK 的 Listener 仅在 Flutter 侧 success() 时回调 onMessage
* error()/notImplemented() 为空实现,串口异常时 Android 侧收不到任何回调,
* 会导致上层回调永久挂起。本方法用定时器兜底,并保证 onSuccess/onError 至多触发一次。
*
* @param timeoutMs 超时时长(毫秒)
* @param callback 上层回调(统一在主线程回调)
* @param action 实际发起 SDK 调用的逻辑,参数为经过防重入包装的“安全回调”,
* 内部所有成功/失败出口都应通过该回调触发
* @param <T> 回调结果类型
*/
private inline fun <T> withTimeout(
timeoutMs: Long,
callback: PlcCallback<T>,
crossinline action: (safeCallback: PlcCallback<T>) -> Unit
) {
// 标记回调是否已触发,保证 onSuccess/onError 至多执行一次
val fired = AtomicBoolean(false)
// 包装后的安全回调,所有出口都经过它,避免重复回调
val safeCallback = object : PlcCallback<T> {
override fun onSuccess(result: T) {
if (fired.compareAndSet(false, true)) callback.onSuccess(result)
}
override fun onError(message: String) {
if (fired.compareAndSet(false, true)) callback.onError(message)
}
}
// 超时定时器:到期未收到 SDK 回调则触发 onError
val timeoutRunnable = Runnable {
safeCallback.onError("PLC 操作超时(${timeoutMs}ms)未响应")
}
mainHandler.postDelayed(timeoutRunnable, timeoutMs)
// 发起实际 SDK 调用,所有成功/失败都通过 safeCallback 回调
action(object : PlcCallback<T> {
override fun onSuccess(result: T) {
// 成功后取消超时定时器
mainHandler.removeCallbacks(timeoutRunnable)
safeCallback.onSuccess(result)
}
override fun onError(message: String) {
// 失败同样取消超时定时器
mainHandler.removeCallbacks(timeoutRunnable)
safeCallback.onError(message)
}
})
}
// ==================== 连接与模式 ==================== // ==================== 连接与模式 ====================
/** /**
@@ -43,8 +102,10 @@ class PlcHelper private constructor(context: Context) {
* 打开自动模式(应用启动时调用一次) * 打开自动模式(应用启动时调用一次)
*/ */
fun openAutoMode(callback: PlcCallback<String>) { fun openAutoMode(callback: PlcCallback<String>) {
withTimeout(5000, callback) { safe ->
sdk.actionOpenAutoMode { result -> sdk.actionOpenAutoMode { result ->
mainHandler.post { callback.onSuccess(result.toString()) } mainHandler.post { safe.onSuccess(result.toString()) }
}
} }
} }
@@ -52,76 +113,90 @@ class PlcHelper private constructor(context: Context) {
* 故障复位(出现 alarmCode 或补盘完成后调用) * 故障复位(出现 alarmCode 或补盘完成后调用)
*/ */
fun alarmReset(callback: PlcCallback<String>) { fun alarmReset(callback: PlcCallback<String>) {
withTimeout(5000, callback) { safe ->
sdk.actionAlarmReset { result -> sdk.actionAlarmReset { result ->
mainHandler.post { callback.onSuccess(result.toString()) } mainHandler.post { safe.onSuccess(result.toString()) }
}
} }
} }
// ==================== 出盘操作 ==================== // ==================== 出盘操作 ====================
/** /**
* 出盘 * 出盘(机械动作,超时 10s
*/ */
fun outPlate(callback: PlcCallback<String>) { fun outPlate(callback: PlcCallback<String>) {
withTimeout(10000, callback) { safe ->
sdk.actionOutPlate { result -> sdk.actionOutPlate { result ->
mainHandler.post { callback.onSuccess(result.toString()) } mainHandler.post { safe.onSuccess(result.toString()) }
}
} }
} }
// ==================== 平台控制 ==================== // ==================== 平台控制 ====================
/** /**
* 平台下降(开始补盘) * 平台下降(开始补盘,超时 8s
*/ */
fun platformDown(callback: PlcCallback<String>) { fun platformDown(callback: PlcCallback<String>) {
withTimeout(8000, callback) { safe ->
sdk.actionPlatformDown { result -> sdk.actionPlatformDown { result ->
mainHandler.post { callback.onSuccess(result.toString()) } mainHandler.post { safe.onSuccess(result.toString()) }
}
} }
} }
/** /**
* 平台上升(补盘完成) * 平台上升(补盘完成,超时 8s
*/ */
fun platformUp(callback: PlcCallback<String>) { fun platformUp(callback: PlcCallback<String>) {
withTimeout(8000, callback) { safe ->
sdk.actionPlatformDownStop { result -> sdk.actionPlatformDownStop { result ->
mainHandler.post { callback.onSuccess(result.toString()) } mainHandler.post { safe.onSuccess(result.toString()) }
}
} }
} }
/** /**
* 补盘完成返回营业(自动模式 + 故障复位) * 补盘完成返回营业(自动模式 + 故障复位,超时 5s
*/ */
fun backToBusiness(callback: PlcCallback<String>) { fun backToBusiness(callback: PlcCallback<String>) {
withTimeout(5000, callback) { safe ->
sdk.actionOpenAutoModeAndActionAlarmReset { result -> sdk.actionOpenAutoModeAndActionAlarmReset { result ->
mainHandler.post { callback.onSuccess(result.toString()) } mainHandler.post { safe.onSuccess(result.toString()) }
}
} }
} }
// ==================== 状态查询 ==================== // ==================== 状态查询 ====================
/** /**
* 获取 PLC 状态(解析为 PlcStatus 对象) * 获取 PLC 状态(解析为 PlcStatus 对象,超时 3s
*/ */
fun getStatus(callback: PlcCallback<PlcStatus>) { fun getStatus(callback: PlcCallback<PlcStatus>) {
withTimeout(3000, callback) { safe ->
sdk.getPlcStatus { json -> sdk.getPlcStatus { json ->
mainHandler.post { mainHandler.post {
if (json != null) { if (json != null) {
callback.onSuccess(PlcStatus.Companion.fromJson(json.toString())) safe.onSuccess(PlcStatus.Companion.fromJson(json.toString()))
} else { } else {
callback.onError("获取状态失败") safe.onError("获取状态失败")
}
} }
} }
} }
} }
/** /**
* 获取 PLC 状态(原始 JSON 字符串) * 获取 PLC 状态(原始 JSON 字符串,超时 3s
*/ */
fun getStatusJson(callback: PlcCallback<String>) { fun getStatusJson(callback: PlcCallback<String>) {
withTimeout(3000, callback) { safe ->
sdk.getPlcStatus { json -> sdk.getPlcStatus { json ->
mainHandler.post { mainHandler.post {
if (json != null) callback.onSuccess(json.toString()) if (json != null) safe.onSuccess(json.toString())
else callback.onError("获取状态失败") else safe.onError("获取状态失败")
}
} }
} }
} }
@@ -129,17 +204,19 @@ class PlcHelper private constructor(context: Context) {
// ==================== 参数设置 ==================== // ==================== 参数设置 ====================
/** /**
* 设置 PLC 参数 * 设置 PLC 参数(超时 3s
* @param paramType 参数类型(如 "18FD" 表示主电机运行最长时间) * @param paramType 参数类型(如 "18FD" 表示主电机运行最长时间)
* @param paramValue 参数值(int,单位 0.1s * @param paramValue 参数值(int,单位 0.1s
*/ */
fun setParams(paramType: String, paramValue: Int, callback: PlcCallback<String>) { fun setParams(paramType: String, paramValue: Int, callback: PlcCallback<String>) {
withTimeout(3000, callback) { safe ->
sdk.setPLCParams( sdk.setPLCParams(
{ result -> mainHandler.post { callback.onSuccess(result.toString()) } }, { result -> mainHandler.post { safe.onSuccess(result.toString()) } },
paramType, paramType,
paramValue paramValue
) )
} }
}
// ==================== 生命周期 ==================== // ==================== 生命周期 ====================
@@ -1,74 +1,74 @@
package com.sw.platecabinet.viewmodel //package com.sw.platecabinet.viewmodel
//
import androidx.lifecycle.ViewModel //import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope //import androidx.lifecycle.viewModelScope
import com.sw.plate.utils.ToastUtils //import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.model.response.ApiResponse //import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.network.ApiClient //import com.sw.platecabinet.network.ApiClient
import com.sw.platecabinet.repository.RemoteRepository //import com.sw.platecabinet.repository.RemoteRepository
import kotlinx.coroutines.flow.MutableStateFlow //import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow //import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch //import kotlinx.coroutines.launch
import timber.log.Timber //import timber.log.Timber
//
abstract class BaseViewModel() : ViewModel() { //abstract class BaseViewModel() : ViewModel() {
protected val repository = RemoteRepository(ApiClient.apiService) // protected val repository = RemoteRepository(ApiClient.apiService)
//
// 显示进度条 // // 显示进度条
private val _showLoading = MutableStateFlow<Boolean>(false) // private val _showLoading = MutableStateFlow<Boolean>(false)
val showLoading: StateFlow<Boolean> = _showLoading // val showLoading: StateFlow<Boolean> = _showLoading
//
/** // /**
* 带进度条的请求 // * 带进度条的请求
*/ // */
protected fun launchWithLoading(block: suspend () -> Unit) { // protected fun launchWithLoading(block: suspend () -> Unit) {
viewModelScope.launch { // viewModelScope.launch {
try { // try {
_showLoading.value = true // _showLoading.value = true
block() // block()
} catch (e: Exception) { // } catch (e: Exception) {
// 错误处理可被子类重写 // // 错误处理可被子类重写
handleError(e) // handleError(e)
} finally { // } finally {
_showLoading.value = false // _showLoading.value = false
}
}
}
/**
* 不带进度条的请求
*/
protected fun launch(block: suspend () -> Unit) {
viewModelScope.launch() {
try {
block()
} catch (e: Exception) {
// 错误处理可被子类重写
handleError(e)
}
}
}
protected open fun parseResponse(response: ApiResponse<*>): Boolean {
if (response.isSuccess()) {
return true
}
Timber.d("msg = ${response.msg}, code = ${response.code}")
ToastUtils.showToast("${response.msg}(${response.code})")
return false
}
fun handleError(exception: Exception) {
Timber.d("handleError ${exception.message}")
}
// fun parseEquipmentInfo(equipmentInfo: EquipmentInfo) {
//// GlobalData.appBaseUrl = equipmentInfo.appPackageUrl!!
//// GlobalData.appBaseUrl="https://vip.shuziweidao.com"
//// GlobalData.sdkKey = equipmentInfo.arcsoftSdkKey!!
//// GlobalData.appId = equipmentInfo.arcsoftAppId!!
//// GlobalData.activeKey = equipmentInfo.arcsoftActiveKey!!
//// GlobalData.restId = equipmentInfo.canteenId!!
// } // }
} // }
// }
//
// /**
// * 不带进度条的请求
// */
// protected fun launch(block: suspend () -> Unit) {
// viewModelScope.launch() {
// try {
// block()
// } catch (e: Exception) {
// // 错误处理可被子类重写
// handleError(e)
// }
// }
// }
//
// protected open fun parseResponse(response: ApiResponse<*>): Boolean {
// if (response.isSuccess()) {
// return true
// }
// Timber.d("msg = ${response.msg}, code = ${response.code}")
// ToastUtils.showToast("${response.msg}(${response.code})")
// return false
// }
//
//
// fun handleError(exception: Exception) {
// Timber.d("handleError ${exception.message}")
// }
//
//// fun parseEquipmentInfo(equipmentInfo: EquipmentInfo) {
////// GlobalData.appBaseUrl = equipmentInfo.appPackageUrl!!
////// GlobalData.appBaseUrl="https://vip.shuziweidao.com"
////// GlobalData.sdkKey = equipmentInfo.arcsoftSdkKey!!
////// GlobalData.appId = equipmentInfo.arcsoftAppId!!
////// GlobalData.activeKey = equipmentInfo.arcsoftActiveKey!!
////// GlobalData.restId = equipmentInfo.canteenId!!
//// }
//}
@@ -0,0 +1,325 @@
package com.sw.platecabinet.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arcsoft.face.ErrorInfo
import com.sw.inbound.utils.SPUtil
import com.sw.plate.App
import com.sw.plate.utils.Base64
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import com.sw.platecabinet.network.ApiClient
import com.sw.platecabinet.repository.RemoteRepositoryV2
import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
/**
* 用户viewmodel
*/
class NetViewModelV2 : ViewModel() {
companion object {
const val PAGE_SIZE = 100
}
private val faceApi: FaceApi = FaceApi()
private val repository = RemoteRepositoryV2(ApiClient.apiServiceV2)
//fun getUserFace(
// pageNo: Int,
// pageSize: Int,
// callback: (Boolean, List<UserFaceModelV2>) -> Unit
//) {
// viewModelScope.launch {
// val response = repository.getUserFaceCache(pageNum = pageNo, pageSize = pageSize)
// if (response.isSuccess()) callback(true, response.data ?: emptyList())
// else callback(false, emptyList())
// }
//}
/**
* 获取人脸数据
*/
fun getUserFaceCache(
pageNo: Int = 1,
pageSize: Int = PAGE_SIZE,
callback: (Boolean, String?) -> Unit = { _, _ -> }
) {
var currentPageNo = pageNo
viewModelScope.launch {
val response = repository.getUserFaceCache(pageNo, pageSize)
if (response.isSuccess().not()) {
callback(false, response.msg)
return@launch
}
// 获取成功一次后缓存状态
SPUtil.getInstance().put(GlobalKey.KEY_FIRST_RUN, true)
val list = response.data ?: emptyList()
if (pageNo == 1 && list.isEmpty()) {
callback(false, "暂无数据")
return@launch
}
val faceEntityList = list.mapNotNull { face ->
val featureBase64 = face.resolveFeatureStr()
if (featureBase64.isNullOrBlank()) null
else {
val featureData = base64ToByteArray(featureBase64)
if (featureData == null) null
else face.toFaceEntity()
}
}
withContext(Dispatchers.Default) {
if (currentPageNo == 1) {
//第一页数据删除本地数据库,同时重置时间戳
faceApi.clearFaceData()
faceTimestamp = 0L
}
faceApi.updateFaceData(pageNo, faceEntityList)
if (list.size >= pageSize) {
// 取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
currentPageNo++
getUserFaceCache(
pageNo = currentPageNo, callback = callback
)
return@withContext
}
if (list.isNotEmpty()) {
// 最后一页:取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
}
SpTool.lastFaceTimestamp = faceTimestamp
callback(true, "查询完成")
}
}
}
private var faceTimestamp = 0L
/**
* 激活人脸识别引擎
*/
fun activeEngine() {
Timber.d("activeEngine")
faceApi.activeEngine(
App.getContext(),
GlobalData.appId,
GlobalData.sdkKey,
GlobalData.activeKey,
object : FaceApi.ActiveCallback {
override fun onSuccess(activeCode: Int) {
Timber.d("activeEngine activeCode = $activeCode")
// ThreadUtils.launch {
viewModelScope.launch(Dispatchers.Main) {
when (activeCode) {
ErrorInfo.MOK -> {
ToastUtils.showToast("激活引擎成功")
}
ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> {
// ToastUtils.showToast("引擎已激活,无需再次激活")
}
else -> {
ToastUtils.showToast("激活引擎失败($activeCode)")
}
}
}
}
override fun onFail(e: Exception?) {
viewModelScope.launch(Dispatchers.Main) {
ToastUtils.showToast("激活引擎异常,${e?.message}")
}
}
})
}
fun getDeviceConfig(onSuccess: (DeviceConfigV2?) -> Unit, onFailure: (String?) -> Unit) {
// Timber.tag(TAG).d("getDeviceConfig")
viewModelScope.launch {
val response = repository.getDeviceConfig()
if (response.isSuccess()) {
onSuccess(response.data)
} else {
onFailure(response.msg)
}
}
}
/**
* 增量获取人脸数据(支持分页递归,取所有页中的最大时间戳)
*/
fun getFaceIncrementList(
pageNo: Int = 1,
pageSize: Int = PAGE_SIZE,
timestamp: Long,
refreshFaceList: () -> Unit
) {
var currentPageNo = pageNo
viewModelScope.launch {
val response = repository.getFaceIncrementList(
pageNum = currentPageNo.toLong(),
pageSize = pageSize.toLong(),
timestamp = timestamp
)
if (response.isSuccess().not()) {
return@launch
}
withContext(Dispatchers.IO) {
val list = response.data ?: emptyList()
updateFaceData(list)
// 取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
if (list.size >= pageSize) {
// 还有下一页,继续递归拉取
currentPageNo++
getFaceIncrementList(
pageNo = currentPageNo,
pageSize = pageSize,
timestamp = timestamp,
refreshFaceList = refreshFaceList
)
return@withContext
}
// 所有页拉取完毕,保存最终时间戳(只升不降)
val storedTimestamp = SpTool.lastFaceTimestamp
if (faceTimestamp > storedTimestamp) {
SpTool.lastFaceTimestamp = faceTimestamp
}
try {
refreshFaceList()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
private fun updateFaceData(list: List<UserFaceModelV2>) {
try {
list.forEach { model ->
if (model.faceDeleted == true) {
//删除数据
// 仅删除 userFaceId 对应的单条人脸记录,避免误删该用户名下所有特征
val userFaceId = model.userFaceId
if (!userFaceId.isNullOrEmpty()) {
faceApi.deleteByUserFaceId(userFaceId)
}
} else {
// 优先按 userFaceId 精确判重,避免特征字段不一致导致重复入库
val userFaceId = model.userFaceId
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
// 本地已存在该 userFaceId,跳过插入
return@forEach
}
// userFaceId 为空时,回退到特征比较去重(修正字段来源,保证与入库一致)
val featureStr = model.resolveFeatureStr()
val existList = faceApi.queryAllByUserName(model.userId)
val alreadyExists = existList.any { entity ->
featureStr != null && Base64.encode(entity.featureData) == featureStr
}
if (!alreadyExists) {
faceApi.insert(model.toFaceEntity())
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 添加人脸数据
*/
fun addUserFace(faceData: String, imageFile: File, block: (Boolean, UserFaceModelV2?) -> Unit) {
viewModelScope.launch {
try {
val imageResp = repository.uploadImage(imageFile)
val imageUrl = imageResp.data ?: ""
if (imageUrl.isBlank()) {
block(false, null)
return@launch
}
val faceResp = repository.addUserFace(faceData, imageUrl)
if (faceResp.isSuccess().not()) {
block(false, null)
return@launch
}
block(true, faceResp.data)
} catch (e: Exception) {
// 网络或解析异常时同样回调失败,避免 block 永不触发导致上层无反馈
e.printStackTrace()
block(false, null)
}
}
}
/**
* 取餐盘上报(P-09a):记录用户开始就餐时刻,作为后续就餐记录(P-09)的强前置。
* 失败仅回调通知,不阻断吐盘等业务主流程。
*
* @param userId 用户 id(餐盘号 = userId
* @param callback 上报结果回调:Boolean 是否成功,String? 成功为 recordNo、失败为错误信息
*/
fun platePickup(
userId: Long,
callback: (Boolean, String?) -> Unit = { _, _ -> }
) {
viewModelScope.launch {
try {
val response = repository.platePickup(userId)
if (response.isSuccess()) {
callback(true, response.data)
} else {
callback(false, response.msg)
}
} catch (e: Exception) {
e.printStackTrace()
callback(false, e.message)
}
}
}
private fun base64ToByteArray(base64: String?): ByteArray? {
base64 ?: return null
return try {
Base64.decode(base64)
} catch (e: Exception) {
null
}
}
/**
* 从人脸数据列表中取出最大的 faceUpdateTimestamp(跳过 null 值)
*/
private fun maxFaceTimestamp(list: List<UserFaceModelV2>): Long {
return list.mapNotNull { it.faceUpdateTimestamp }.maxOrNull() ?: 0L
}
}
@@ -1,201 +1,201 @@
package com.sw.platecabinet.viewmodel //package com.sw.platecabinet.viewmodel
//
import androidx.lifecycle.viewModelScope //import androidx.lifecycle.viewModelScope
import com.arcsoft.face.ErrorInfo //import com.arcsoft.face.ErrorInfo
import com.sw.inbound.utils.SPUtil //import com.sw.inbound.utils.SPUtil
import com.sw.plate.App //import com.sw.plate.App
import com.sw.plate.utils.Base64 //import com.sw.plate.utils.Base64
import com.sw.plate.utils.ToastUtils //import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.FaceApi //import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity //import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData //import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey //import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.model.DeviceConfig //import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.response.UserFaceModel //import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.utils.SpTool //import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers //import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch //import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext //import kotlinx.coroutines.withContext
import timber.log.Timber //import timber.log.Timber
import java.io.File //import java.io.File
//
/** ///**
* 用户viewmodel // * 用户viewmodel
*/ // */
class UserViewModel : BaseViewModel() { //class UserViewModel : BaseViewModel() {
//
companion object { // companion object {
const val PAGE_SIZE = 100 // const val PAGE_SIZE = 100
} // }
//
private val faceApi: FaceApi = FaceApi() // private val faceApi: FaceApi = FaceApi()
//
//
fun getUserFace( // fun getUserFace(
pageNo: Int, // pageNo: Int,
pageSize: Int, // pageSize: Int,
callback: (List<UserFaceModel>) -> Unit // callback: (List<UserFaceModel>) -> Unit
) { // ) {
launchWithLoading { // launchWithLoading {
val response = repository.getUserFaceCache(pageNum = pageNo, pageSize = pageSize) // val response = repository.getUserFaceCache(pageNum = pageNo, pageSize = pageSize)
val status = parseResponse(response) // val status = parseResponse(response)
if (status) callback(response.data ?: emptyList()) // if (status) callback(response.data ?: emptyList())
else emptyList<UserFaceModel>() // else emptyList<UserFaceModel>()
} // }
} // }
//
/** // /**
* 获取人脸数据 // * 获取人脸数据
*/ // */
fun getUserFaceCache( // fun getUserFaceCache(
pageNo: Int = 1, // pageNo: Int = 1,
pageSize: Int = PAGE_SIZE, // pageSize: Int = PAGE_SIZE,
callback: (Boolean, String?) -> Unit = { statue, msg -> } // callback: (Boolean, String?) -> Unit = { statue, msg -> }
) { // ) {
var currentPageNo = pageNo // var currentPageNo = pageNo
launchWithLoading { // launchWithLoading {
val response = repository.getUserFaceCache(pageNum = pageNo, pageSize = pageSize) // val response = repository.getUserFaceCache(pageNum = pageNo, pageSize = pageSize)
val status = parseResponse(response) // val status = parseResponse(response)
if (status.not()) { // if (status.not()) {
callback(false, response.msg) // callback(false, response.msg)
return@launchWithLoading // return@launchWithLoading
} // }
// 获取成功一次后缓存状态 // // 获取成功一次后缓存状态
SPUtil.getInstance().put(GlobalKey.KEY_FIRST_RUN, true) // SPUtil.getInstance().put(GlobalKey.KEY_FIRST_RUN, true)
val list: List<UserFaceModel> = response.data ?: emptyList() // val list: List<UserFaceModel> = response.data ?: emptyList()
if (pageNo == 1 && list.isEmpty()) { // if (pageNo == 1 && list.isEmpty()) {
callback(false, "暂无数据") // callback(false, "暂无数据")
return@launchWithLoading // return@launchWithLoading
} // }
val faceEntityList = list.map { // val faceEntityList = list.map {
FaceEntity(it.userId, null, Base64.decode(it.faceFeatureStr)).apply { // FaceEntity(it.userId, null, Base64.decode(it.faceFeatureStr)).apply {
userType = if (it.member) "1" else "2" // userType = if (it.member == true) "1" else "2"
} // }
} // }
withContext(Dispatchers.Default) { // withContext(Dispatchers.Default) {
if (currentPageNo == 1) { // if (currentPageNo == 1) {
//第一页数据删除本地数据库 // //第一页数据删除本地数据库
faceApi.clearFaceData() // faceApi.clearFaceData()
} // }
faceApi.updateFaceData(pageNo, faceEntityList) // faceApi.updateFaceData(pageNo, faceEntityList)
if (list.size >= pageSize) { // if (list.size >= pageSize) {
faceTimestamp = list.last().faceUpdateTimestamp ?: 0 // faceTimestamp = list.last().faceUpdateTimestamp ?: 0
currentPageNo++ // currentPageNo++
getUserFaceCache( // getUserFaceCache(
pageNo = currentPageNo, // pageNo = currentPageNo,
callback = callback // callback = callback
) // )
return@withContext // return@withContext
} // }
if (list.isNotEmpty()) { // if (list.isNotEmpty()) {
faceTimestamp = list.last().faceUpdateTimestamp ?: 0 // faceTimestamp = list.last().faceUpdateTimestamp ?: 0
} // }
SpTool.lastFaceTimestamp = faceTimestamp // SpTool.lastFaceTimestamp = faceTimestamp
callback(true, "查询完成") // callback(true, "查询完成")
} // }
} // }
} // }
//
private var faceTimestamp = 0L // private var faceTimestamp = 0L
//
//
/** // /**
* 激活人脸识别引擎 // * 激活人脸识别引擎
*/ // */
fun activeEngine() { // fun activeEngine() {
Timber.d("activeEngine") // Timber.d("activeEngine")
//
faceApi.activeEngine( // faceApi.activeEngine(
App.getContext(), // App.getContext(),
GlobalData.appId, // GlobalData.appId,
GlobalData.sdkKey, // GlobalData.sdkKey,
GlobalData.activeKey, // GlobalData.activeKey,
object : FaceApi.ActiveCallback { // object : FaceApi.ActiveCallback {
override fun onSuccess(activeCode: Int) { // override fun onSuccess(activeCode: Int) {
Timber.d("activeEngine activeCode = $activeCode") // Timber.d("activeEngine activeCode = $activeCode")
// ThreadUtils.launch { //// ThreadUtils.launch {
viewModelScope.launch(Dispatchers.Main) { // viewModelScope.launch(Dispatchers.Main) {
when (activeCode) { // when (activeCode) {
ErrorInfo.MOK -> { // ErrorInfo.MOK -> {
ToastUtils.showToast("激活引擎成功") // ToastUtils.showToast("激活引擎成功")
} // }
//
ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> { // ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> {
// ToastUtils.showToast("引擎已激活,无需再次激活") //// ToastUtils.showToast("引擎已激活,无需再次激活")
} // }
//
else -> { // else -> {
ToastUtils.showToast("激活引擎失败($activeCode)") // ToastUtils.showToast("激活引擎失败($activeCode)")
} // }
} // }
} // }
} // }
//
override fun onFail(e: Exception?) { // override fun onFail(e: Exception?) {
viewModelScope.launch(Dispatchers.Main) { // viewModelScope.launch(Dispatchers.Main) {
ToastUtils.showToast("激活引擎异常,${e?.message}") // ToastUtils.showToast("激活引擎异常,${e?.message}")
} // }
} // }
}) // })
} // }
//
//
fun getDeviceConfig(block: (DeviceConfig?) -> Unit) { // fun getDeviceConfig(block: (DeviceConfig?) -> Unit) {
// Timber.tag(TAG).d("getDeviceConfig") //// Timber.tag(TAG).d("getDeviceConfig")
launch { // launch {
val response = repository.getDeviceConfig() // val response = repository.getDeviceConfig()
if (parseResponse(response)) { // if (parseResponse(response)) {
block(response.data) // block(response.data)
} else { // } else {
block(null) // block(null)
} // }
} // }
} // }
//
//
/** // /**
* 获取人脸数据 // * 获取人脸数据
*/ // */
fun getFaceIncrementList( // fun getFaceIncrementList(
pageNo: Int = 1, // pageNo: Int = 1,
pageSize: Int = PAGE_SIZE, // pageSize: Int = PAGE_SIZE,
timestamp: Long, // timestamp: Long,
block: (List<UserFaceModel>) -> Unit // block: (List<UserFaceModel>) -> Unit
) { // ) {
launch { // launch {
val response = repository.getFaceIncrementList( // val response = repository.getFaceIncrementList(
pageNum = pageNo.toLong(), // pageNum = pageNo.toLong(),
pageSize = pageSize.toLong(), // pageSize = pageSize.toLong(),
timestamp = timestamp // timestamp = timestamp
) // )
if (parseResponse(response)) { // if (parseResponse(response)) {
withContext(Dispatchers.Default) { // withContext(Dispatchers.Default) {
val list: List<UserFaceModel> = response.data ?: emptyList() // val list: List<UserFaceModel> = response.data ?: emptyList()
block(list) // block(list)
} // }
} // }
} // }
} // }
//
/** // /**
* 添加人脸数据 // * 添加人脸数据
*/ // */
fun addUserFace(faceData: String, imageFile: File, block: (Boolean, UserFaceModel?) -> Unit) { // fun addUserFace(faceData: String, imageFile: File, block: (Boolean, UserFaceModel?) -> Unit) {
launch { // launch {
val imageResp = repository.uploadImage(imageFile) // val imageResp = repository.uploadImage(imageFile)
val imageUrl = imageResp.data ?: "" // val imageUrl = imageResp.data ?: ""
if (imageUrl.isBlank()) { // if (imageUrl.isBlank()) {
block(false, null) // block(false, null)
return@launch // return@launch
} // }
val faceResp = repository.addUserFace(faceData, imageUrl) // val faceResp = repository.addUserFace(faceData, imageUrl)
val status = parseResponse(faceResp) // val status = parseResponse(faceResp)
if (status.not()) { // if (status.not()) {
block(false, null) // block(false, null)
return@launch // return@launch
} // }
block(true, faceResp.data) // block(true, faceResp.data)
} // }
} // }
//
} //}
@@ -324,6 +324,54 @@
android:textColor="#FFCC99" android:textColor="#FFCC99"
android:textSize="22sp" /> android:textSize="22sp" />
<!-- ===== 人脸库 ===== -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="人脸库"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tvFaceCount"
android:layout_width="0dp"
android:layout_height="60dp"
android:layout_weight="1"
android:background="@drawable/btn_outline"
android:gravity="center"
android:text="人脸总数:--"
android:textColor="#FFCC99"
android:textSize="20sp" />
<View
android:layout_width="20dp"
android:layout_height="1dp" />
<TextView
android:id="@+id/btnRefreshFaceCount"
android:layout_width="0dp"
android:layout_height="60dp"
android:layout_weight="1"
android:background="@drawable/btn_outline"
android:gravity="center"
android:text="刷新"
android:textColor="#FFCC99"
android:textSize="20sp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="30dp" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
@@ -11,6 +11,9 @@
android:id="@+id/tvLeftDate" android:id="@+id/tvLeftDate"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minHeight="64dp"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"> app:layout_constraintBottom_toBottomOf="parent">
@@ -36,6 +39,8 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:minHeight="64dp"
android:paddingHorizontal="12dp"
android:textColor="#FFF0C8B4" android:textColor="#FFF0C8B4"
android:textSize="24sp" android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@ android {
consumerProguardFiles("consumer-rules.pro") consumerProguardFiles("consumer-rules.pro")
ndk { ndk {
abiFilters.addAll(listOf("armeabi-v7a"/*, "arm64-v8a"*/)) abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a"))
} }
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -32,7 +32,7 @@ public class FaceApi {
*/ */
public void updateFaceData(int index, List<FaceEntity> list) { public void updateFaceData(int index, List<FaceEntity> list) {
Log.d(TAG, "updateFaceData: index = " + index + ", listSize = " + list.size()); Log.d(TAG, "updateFaceData: index = " + index + ", listSize = " + list.size());
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); FaceDao faceDao = getFaceDao();
//if (index == 1) { //if (index == 1) {
// faceDao.deleteAll(); // faceDao.deleteAll();
// faceDao.resetId(); // faceDao.resetId();
@@ -41,7 +41,7 @@ public class FaceApi {
} }
public void clearFaceData() { public void clearFaceData() {
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); FaceDao faceDao = getFaceDao();
faceDao.deleteAll(); faceDao.deleteAll();
faceDao.resetId(); faceDao.resetId();
} }
@@ -88,29 +88,51 @@ public class FaceApi {
} }
public void deleteByUserName(String userName) { public void deleteByUserName(String userName) {
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); getFaceDao().deleteFaceById(userName);
faceDao.deleteFaceById(userName); }
/**
* 按 userFaceId 删除单条人脸记录
*/
public void deleteByUserFaceId(String userFaceId) {
getFaceDao().deleteFaceByUserFaceId(userFaceId);
}
/**
* 按 userFaceId 查询单条人脸记录
*/
public FaceEntity queryByUserFaceId(String userFaceId) {
if (userFaceId == null || userFaceId.isEmpty()) {
return null;
}
return getFaceDao().queryByUserFaceId(userFaceId);
} }
public Long insert(FaceEntity entity) { public Long insert(FaceEntity entity) {
if (entity == null) { if (entity == null) {
return 0L; return 0L;
} }
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); return getFaceDao().insert(entity);
return faceDao.insert(entity);
} }
public FaceEntity queryByUserName(String userName) { public FaceEntity queryByUserName(String userName) {
if (TextUtils.isEmpty(userName)) { if (TextUtils.isEmpty(userName)) {
return null; return null;
} }
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); return getFaceDao().queryByUserName(userName);
return faceDao.queryByUserName(userName);
} }
public int queryFaceCount() { public int queryFaceCount() {
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); return getFaceDao().getFaceCount();
return faceDao.getFaceCount(); }
public List<FaceEntity> queryAllByUserName(String userName) {
return getFaceDao().queryAllByUserName(userName);
}
public FaceDao getFaceDao() {
return FaceDatabase.getInstance(App.getContext()).faceDao();
} }
} }
@@ -39,7 +39,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -104,6 +103,10 @@ public class FaceHelper implements FaceListener {
* 活体检测引擎 * 活体检测引擎
*/ */
private FaceEngine flEngine; private FaceEngine flEngine;
/**
* 口罩检测引擎(5.0 独立引擎)
*/
private FaceEngine maskEngine;
private Camera.Size previewSize; private Camera.Size previewSize;
@@ -148,11 +151,6 @@ public class FaceHelper implements FaceListener {
*/ */
private boolean onlyDetectLiveness; private boolean onlyDetectLiveness;
/**
* 是否需要更新faceData
*/
private boolean needUpdateFaceData;
/** /**
* 识别的配置项 * 识别的配置项
*/ */
@@ -182,9 +180,9 @@ public class FaceHelper implements FaceListener {
} }
private FaceHelper(Builder builder) { private FaceHelper(Builder builder) {
needUpdateFaceData = builder.needUpdateFaceData;
onlyDetectLiveness = builder.onlyDetectLiveness; onlyDetectLiveness = builder.onlyDetectLiveness;
ftEngine = builder.ftEngine; ftEngine = builder.ftEngine;
maskEngine = builder.maskEngine;
trackedFaceCount = builder.trackedFaceCount; trackedFaceCount = builder.trackedFaceCount;
previewSize = builder.previewSize; previewSize = builder.previewSize;
frEngine = builder.frEngine; frEngine = builder.frEngine;
@@ -352,11 +350,11 @@ public class FaceHelper implements FaceListener {
return facePreviewInfoList; return facePreviewInfoList;
} }
} }
if (!onlyDetectLiveness) { if (!onlyDetectLiveness && maskEngine != null) {
code = ftEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList, code = maskEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList,
FaceEngine.ASF_MASK_DETECT); FaceEngine.ASF_MASK_DETECT);
if (code == ErrorInfo.MOK) { if (code == ErrorInfo.MOK) {
code = ftEngine.getMask(maskInfoList); code = maskEngine.getMask(maskInfoList);
if (code != ErrorInfo.MOK) { if (code != ErrorInfo.MOK) {
onFail(new Exception("process getMask failed,code is " + code)); onFail(new Exception("process getMask failed,code is " + code));
return facePreviewInfoList; return facePreviewInfoList;
@@ -880,18 +878,7 @@ public class FaceHelper implements FaceListener {
int fdCode = flEngine.detectFaces(nv21Data, width, height, format, faceInfoList); int fdCode = flEngine.detectFaces(nv21Data, width, height, format, faceInfoList);
boolean isFaceExists = isFaceExists(faceInfoList, faceInfo); boolean isFaceExists = isFaceExists(faceInfoList, faceInfo);
if (fdCode == ErrorInfo.MOK && isFaceExists) { if (fdCode == ErrorInfo.MOK && isFaceExists) {
if (needUpdateFaceData) {
/*
* 若IR人脸框有偏移,则需要对IR的人脸数据进行updateFaceData处理,再将处理后的FaceInfo信息传输给活体检测接口
*/
flCode = flEngine.updateFaceData(nv21Data, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21,
new ArrayList<>(Collections.singletonList(faceInfo)));
if (flCode == ErrorInfo.MOK) {
flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS); flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS);
}
} else {
flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS);
}
} else { } else {
onFail(new Exception("ir detectFaces failed fdCode:" + fdCode + ",isFaceExists:" + isFaceExists)); onFail(new Exception("ir detectFaces failed fdCode:" + fdCode + ",isFaceExists:" + isFaceExists));
} }
@@ -1098,9 +1085,9 @@ public class FaceHelper implements FaceListener {
private FaceEngine ftEngine; private FaceEngine ftEngine;
private FaceEngine frEngine; private FaceEngine frEngine;
private FaceEngine flEngine; private FaceEngine flEngine;
private FaceEngine maskEngine;
private Camera.Size previewSize; private Camera.Size previewSize;
private boolean onlyDetectLiveness; private boolean onlyDetectLiveness;
private boolean needUpdateFaceData;
private RecognizeConfiguration recognizeConfiguration; private RecognizeConfiguration recognizeConfiguration;
private RecognizeCallback recognizeCallback; private RecognizeCallback recognizeCallback;
private IDualCameraFaceInfoTransformer dualCameraFaceInfoTransformer; private IDualCameraFaceInfoTransformer dualCameraFaceInfoTransformer;
@@ -1141,6 +1128,11 @@ public class FaceHelper implements FaceListener {
return this; return this;
} }
public Builder maskEngine(FaceEngine val) {
maskEngine = val;
return this;
}
public Builder previewSize(Camera.Size val) { public Builder previewSize(Camera.Size val) {
previewSize = val; previewSize = val;
return this; return this;
@@ -1166,11 +1158,6 @@ public class FaceHelper implements FaceListener {
return this; return this;
} }
public Builder needUpdateFaceData(boolean val) {
needUpdateFaceData = val;
return this;
}
public FaceHelper build() { public FaceHelper build() {
return new FaceHelper(this); return new FaceHelper(this);
} }
@@ -9,7 +9,7 @@ import androidx.room.RoomDatabase;
import com.sw.plate.utils.arcface.facedb.dao.FaceDao; import com.sw.plate.utils.arcface.facedb.dao.FaceDao;
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity; import com.sw.plate.utils.arcface.facedb.entity.FaceEntity;
@Database(entities = {FaceEntity.class}, version = 1, exportSchema = false) @Database(entities = {FaceEntity.class}, version = 2, exportSchema = false)
public abstract class FaceDatabase extends RoomDatabase { public abstract class FaceDatabase extends RoomDatabase {
public abstract FaceDao faceDao(); public abstract FaceDao faceDao();
@@ -22,7 +22,10 @@ public abstract class FaceDatabase extends RoomDatabase {
faceDatabase = Room.databaseBuilder(context, FaceDatabase.class, faceDatabase = Room.databaseBuilder(context, FaceDatabase.class,
context.getDatabasePath("faceDB.db").getPath() context.getDatabasePath("faceDB.db").getPath()
// context.getExternalFilesDir("database") + File.separator + "faceDB.db" // context.getExternalFilesDir("database") + File.separator + "faceDB.db"
).build(); )
// 5.0 特征模型不兼容,升级时直接删除旧表重建
.fallbackToDestructiveMigration()
.build();
} }
} }
} }
@@ -55,6 +55,18 @@ public interface FaceDao {
@Query("DELETE from face WHERE user_name = :userName") @Query("DELETE from face WHERE user_name = :userName")
int deleteFaceById(String userName); int deleteFaceById(String userName);
/**
* 按 userFaceId 删除单条人脸记录
*/
@Query("DELETE from face WHERE user_face_id = :userFaceId")
int deleteFaceByUserFaceId(String userFaceId);
/**
* 按 userFaceId 查询单条人脸记录
*/
@Query("SELECT * FROM face WHERE user_face_id = :userFaceId LIMIT 1")
FaceEntity queryByUserFaceId(String userFaceId);
/** /**
* 删除所有已注册的人脸 * 删除所有已注册的人脸
* *
@@ -104,13 +116,13 @@ public interface FaceDao {
/** /**
* @return 删除临时用户人脸 * @return 删除临时用户人脸
*/ */
@Query("DELETE from face WHERE user_type = '2'") @Query("DELETE from face WHERE user_type = :userType")
int deleteTempUserFaceData(); int deleteUserFaceData(int userType);
/** /**
* 查询用户人脸数 * 查询用户人脸数
* @param userType 1-会员,2-临时用户 * @param userType 1-会员,2-临时用户
*/ */
@Query("SELECT COUNT(1) FROM face WHERE user_type = :userType") @Query("SELECT COUNT(1) FROM face WHERE user_type = :userType")
int getFaceCount(String userType); int getFaceCountByUserType(int userType);
} }
@@ -52,12 +52,39 @@ public class FaceEntity implements Parcelable {
/** /**
* 会员编号 * 会员编号
*/ */
@ColumnInfo(name = "cardNo") @ColumnInfo(name = "card_no")
private String cardNo; private String cardNo;
@Ignore @Ignore
private int trackId;//人脸追踪ID private int trackId;//人脸追踪ID
@ColumnInfo(name = "user_id")
private String userId;
@ColumnInfo(name = "user_face_id")
private String userFaceId;
@ColumnInfo(name = "member")
private boolean member;
@ColumnInfo(name = "face_update_timestamp")
private long faceUpdateTimestamp;
public FaceEntity() {
registerTime = System.currentTimeMillis();
}
@Ignore
public FaceEntity(String userName, byte[] featureData, String userType, String cardNo, String userId, String userFaceId, boolean member, long faceUpdateTimestamp) {
this.userName = userName;
this.featureData = featureData;
this.userType = userType;
this.cardNo = cardNo;
this.userId = userId;
this.userFaceId = userFaceId;
this.member = member;
this.faceUpdateTimestamp = faceUpdateTimestamp;
registerTime = System.currentTimeMillis();
}
@Ignore
public FaceEntity(String userName, String imagePath, byte[] featureData) { public FaceEntity(String userName, String imagePath, byte[] featureData) {
this.userName = userName; this.userName = userName;
this.imagePath = imagePath; this.imagePath = imagePath;
@@ -65,6 +92,7 @@ public class FaceEntity implements Parcelable {
registerTime = System.currentTimeMillis(); registerTime = System.currentTimeMillis();
} }
@Ignore
public FaceEntity(FaceEntity faceEntity) { public FaceEntity(FaceEntity faceEntity) {
this.faceId = faceEntity.faceId; this.faceId = faceEntity.faceId;
this.userName = faceEntity.userName; this.userName = faceEntity.userName;
@@ -73,9 +101,14 @@ public class FaceEntity implements Parcelable {
this.registerTime = faceEntity.registerTime; this.registerTime = faceEntity.registerTime;
this.userType = faceEntity.getUserType(); this.userType = faceEntity.getUserType();
this.cardNo = faceEntity.getCardNo(); this.cardNo = faceEntity.getCardNo();
this.userId = faceEntity.getUserId();
this.userFaceId = faceEntity.getUserFaceId();
this.member = faceEntity.isMember();
this.faceUpdateTimestamp = faceEntity.getFaceUpdateTimestamp();
} }
@Ignore
protected FaceEntity(Parcel in) { protected FaceEntity(Parcel in) {
faceId = in.readLong(); faceId = in.readLong();
registerTime = in.readLong(); registerTime = in.readLong();
@@ -84,6 +117,10 @@ public class FaceEntity implements Parcelable {
featureData = in.createByteArray(); featureData = in.createByteArray();
userType = in.readString(); userType = in.readString();
cardNo = in.readString(); cardNo = in.readString();
userId = in.readString();
userFaceId = in.readString();
member = in.readByte() != 0;
faceUpdateTimestamp = in.readLong();
} }
public static final Creator<FaceEntity> CREATOR = new Creator<FaceEntity>() { public static final Creator<FaceEntity> CREATOR = new Creator<FaceEntity>() {
@@ -162,7 +199,37 @@ public class FaceEntity implements Parcelable {
this.cardNo = cardNo; this.cardNo = cardNo;
} }
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserFaceId() {
return userFaceId;
}
public void setUserFaceId(String userFaceId) {
this.userFaceId = userFaceId;
}
public boolean isMember() {
return member;
}
public void setMember(boolean member) {
this.member = member;
}
public long getFaceUpdateTimestamp() {
return faceUpdateTimestamp;
}
public void setFaceUpdateTimestamp(long faceUpdateTimestamp) {
this.faceUpdateTimestamp = faceUpdateTimestamp;
}
@Override @Override
public int describeContents() { public int describeContents() {
@@ -178,6 +245,10 @@ public class FaceEntity implements Parcelable {
dest.writeByteArray(featureData); dest.writeByteArray(featureData);
dest.writeString(userType); dest.writeString(userType);
dest.writeString(cardNo); dest.writeString(cardNo);
dest.writeString(userId);
dest.writeString(userFaceId);
dest.writeByte((byte) (member ? 1 : 0));
dest.writeLong(faceUpdateTimestamp);
} }
@Override @Override
@@ -195,12 +266,16 @@ public class FaceEntity implements Parcelable {
TextUtils.equals(this.imagePath, that.imagePath) && TextUtils.equals(this.imagePath, that.imagePath) &&
Arrays.equals(featureData, that.featureData) && Arrays.equals(featureData, that.featureData) &&
TextUtils.equals(this.userType, that.userType) && TextUtils.equals(this.userType, that.userType) &&
TextUtils.equals(this.cardNo, that.cardNo); TextUtils.equals(this.cardNo, that.cardNo) &&
TextUtils.equals(this.userId, that.userId) &&
TextUtils.equals(this.userFaceId, that.userFaceId) &&
this.member == that.member &&
this.faceUpdateTimestamp == that.faceUpdateTimestamp;
} }
@Override @Override
public int hashCode() { public int hashCode() {
int result = Objects.hash(faceId, registerTime, userName, imagePath, userType, cardNo); int result = Objects.hash(faceId, registerTime, userName, imagePath, userType, cardNo, userId, userFaceId, member, faceUpdateTimestamp);
result = 31 * result + Arrays.hashCode(featureData); result = 31 * result + Arrays.hashCode(featureData);
return result; return result;
} }
@@ -12,7 +12,6 @@ import androidx.lifecycle.ViewModel;
import com.arcsoft.face.AgeInfo; import com.arcsoft.face.AgeInfo;
import com.arcsoft.face.ErrorInfo; import com.arcsoft.face.ErrorInfo;
import com.arcsoft.face.FaceAttributeParam;
import com.arcsoft.face.FaceEngine; import com.arcsoft.face.FaceEngine;
import com.arcsoft.face.FaceInfo; import com.arcsoft.face.FaceInfo;
import com.arcsoft.face.GenderInfo; import com.arcsoft.face.GenderInfo;
@@ -132,13 +131,14 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
private MutableLiveData<Integer> ftInitCode = new MutableLiveData<>(); private MutableLiveData<Integer> ftInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> frInitCode = new MutableLiveData<>(); private MutableLiveData<Integer> frInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> flInitCode = new MutableLiveData<>(); private MutableLiveData<Integer> flInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> maskInitCode = new MutableLiveData<>();
/** /**
* 人脸操作辅助类,推帧即可,内部会进行特征提取、识别 * 人脸操作辅助类,推帧即可,内部会进行特征提取、识别
*/ */
private FaceHelper faceHelper; private FaceHelper faceHelper;
/** /**
* VIDEO模式人脸检测引擎,用于预览帧人脸追踪及图像质量检测 * VIDEO模式人脸检测引擎,用于预览帧人脸追踪
*/ */
private FaceEngine ftEngine; private FaceEngine ftEngine;
/** /**
@@ -149,6 +149,10 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
* IMAGE模式活体检测引擎,用于预览帧人脸活体检测 * IMAGE模式活体检测引擎,用于预览帧人脸活体检测
*/ */
private FaceEngine flEngine; private FaceEngine flEngine;
/**
* IMAGE模式口罩检测引擎(5.0 独立引擎)
*/
private FaceEngine maskEngine;
private PreviewConfig previewConfig; private PreviewConfig previewConfig;
@@ -160,10 +164,6 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
private MutableLiveData<CompareResult> recognizeUserId = new MutableLiveData<>(); private MutableLiveData<CompareResult> recognizeUserId = new MutableLiveData<>();
/**
* 检测ir活体前,是否需要更新faceData
*/
private boolean needUpdateFaceData;
/** /**
* 当前活体检测的检测类型 * 当前活体检测的检测类型
*/ */
@@ -297,22 +297,22 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
.similarThreshold(ConfigUtil.getRecognizeThreshold(context)) .similarThreshold(ConfigUtil.getRecognizeThreshold(context))
.imageQualityNoMaskRecognizeThreshold(ConfigUtil.getImageQualityNoMaskRecognizeThreshold(context)) .imageQualityNoMaskRecognizeThreshold(ConfigUtil.getImageQualityNoMaskRecognizeThreshold(context))
.imageQualityMaskRecognizeThreshold(ConfigUtil.getImageQualityMaskRecognizeThreshold(context)) .imageQualityMaskRecognizeThreshold(ConfigUtil.getImageQualityMaskRecognizeThreshold(context))
.livenessParam(new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context), .livenessParam(new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context)))
ConfigUtil.getLivenessFqThreshold(context)))
.build(); .build();
int cameraOffsetX = ConfigUtil.getDualCameraHorizontalOffset(context);
int cameraOffsetY = ConfigUtil.getDualCameraVerticalOffset(context);
needUpdateFaceData = (livenessType == LivenessType.IR && (cameraOffsetX != 0 || cameraOffsetY != 0));
// 人脸追踪引擎(VIDEO模式,仅检测)
ftEngine = new FaceEngine(); ftEngine = new FaceEngine();
int ftEngineMask = FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT; int ftEngineMask = FaceEngine.ASF_FACE_DETECT;
ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, ConfigUtil.getFtOrient(context), ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, ConfigUtil.getFtOrient(context),
ConfigUtil.getRecognizeMaxDetectFaceNum(context), ftEngineMask)); ConfigUtil.getRecognizeMaxDetectFaceNum(context), ftEngineMask));
FaceAttributeParam attributeParam = new FaceAttributeParam(
ConfigUtil.getRecognizeEyeOpenThreshold(context), ConfigUtil.getRecognizeMouthCloseThreshold(context),
ConfigUtil.getRecognizeWearGlassesThreshold(context));
ftEngine.setFaceAttributeParam(attributeParam);
// 口罩检测引擎(5.0 独立引擎)
maskEngine = new FaceEngine();
maskInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT,
ConfigUtil.getRecognizeMaxDetectFaceNum(context), FaceEngine.ASF_MASK_DETECT));
// 特征提取引擎
frEngine = new FaceEngine(); frEngine = new FaceEngine();
int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION; int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION;
if (enableFaceQualityDetect) { if (enableFaceQualityDetect) {
@@ -322,16 +322,13 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
10, frEngineMask)); 10, frEngineMask));
FaceServer.getInstance().initFaceList(context, frEngine, faceCount -> loadFaceList = true, true); FaceServer.getInstance().initFaceList(context, frEngine, faceCount -> loadFaceList = true, true);
//启用活体检测时,才初始化活体引擎 // 启用活体检测时,才初始化活体引擎
if (enableLive) { if (enableLive) {
flEngine = new FaceEngine(); flEngine = new FaceEngine();
int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT)); int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT));
if (needUpdateFaceData) {
flEngineMask |= FaceEngine.ASF_UPDATE_FACEDATA;
}
flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask)); DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask));
LivenessParam livenessParam = new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context), ConfigUtil.getLivenessFqThreshold(context)); LivenessParam livenessParam = new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context));
flEngine.setLivenessParam(livenessParam); flEngine.setLivenessParam(livenessParam);
} }
@@ -356,19 +353,25 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
if (ftEngine != null) { if (ftEngine != null) {
synchronized (ftEngine) { synchronized (ftEngine) {
int ftUnInitCode = ftEngine.unInit(); int ftUnInitCode = ftEngine.unInit();
Log.i(TAG, "unInitEngine: " + ftUnInitCode); Log.i(TAG, "unInitEngine ft: " + ftUnInitCode);
}
}
if (maskEngine != null) {
synchronized (maskEngine) {
int maskUnInitCode = maskEngine.unInit();
Log.i(TAG, "unInitEngine mask: " + maskUnInitCode);
} }
} }
if (frEngine != null) { if (frEngine != null) {
synchronized (frEngine) { synchronized (frEngine) {
int frUnInitCode = frEngine.unInit(); int frUnInitCode = frEngine.unInit();
Log.i(TAG, "unInitEngine: " + frUnInitCode); Log.i(TAG, "unInitEngine fr: " + frUnInitCode);
} }
} }
if (flEngine != null) { if (flEngine != null) {
synchronized (flEngine) { synchronized (flEngine) {
int flUnInitCode = flEngine.unInit(); int flUnInitCode = flEngine.unInit();
Log.i(TAG, "unInitEngine: " + flUnInitCode); Log.i(TAG, "unInitEngine fl: " + flUnInitCode);
} }
} }
} }
@@ -469,7 +472,7 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
.ftEngine(ftEngine) .ftEngine(ftEngine)
.frEngine(frEngine) .frEngine(frEngine)
.flEngine(flEngine) .flEngine(flEngine)
.needUpdateFaceData(needUpdateFaceData) .maskEngine(maskEngine)
.frQueueSize(maxDetectFaceNum) .frQueueSize(maxDetectFaceNum)
.flQueueSize(maxDetectFaceNum) .flQueueSize(maxDetectFaceNum)
.previewSize(previewSize) .previewSize(previewSize)