1061 lines
42 KiB
Kotlin
1061 lines
42 KiB
Kotlin
package com.example.studentfaceregistry
|
|
|
|
import android.Manifest
|
|
import android.content.SharedPreferences
|
|
import android.content.pm.PackageManager
|
|
import android.graphics.Bitmap
|
|
import android.graphics.Rect
|
|
import android.graphics.RectF
|
|
import android.graphics.Typeface
|
|
import android.os.Bundle
|
|
import android.os.SystemClock
|
|
import android.text.InputType
|
|
import android.util.Size
|
|
import android.util.Log
|
|
import android.view.Gravity
|
|
import android.view.View
|
|
import android.widget.EditText
|
|
import android.widget.Button
|
|
import android.widget.FrameLayout
|
|
import android.widget.LinearLayout
|
|
import android.widget.TextView
|
|
import android.widget.Toast
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.camera.core.CameraSelector
|
|
import androidx.camera.core.ExperimentalGetImage
|
|
import androidx.camera.core.ImageAnalysis
|
|
import androidx.camera.core.ImageProxy
|
|
import androidx.camera.core.Preview
|
|
import androidx.camera.core.resolutionselector.ResolutionSelector
|
|
import androidx.camera.core.resolutionselector.ResolutionStrategy
|
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
import androidx.camera.view.PreviewView
|
|
import androidx.core.content.edit
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.lifecycle.lifecycleScope
|
|
import com.example.studentfaceregistry.data.Student
|
|
import com.example.studentfaceregistry.data.StudentRepository
|
|
import com.example.studentfaceregistry.face.FaceMatcher
|
|
import com.example.studentfaceregistry.face.FaceProcessor
|
|
import com.example.studentfaceregistry.face.MatchType
|
|
import com.example.studentfaceregistry.face.RecognitionResult
|
|
import com.google.mlkit.vision.common.InputImage
|
|
import com.google.mlkit.vision.face.Face
|
|
import com.example.studentfaceregistry.ui.DetectionUi
|
|
import com.example.studentfaceregistry.ui.OverlayView
|
|
import com.example.studentfaceregistry.upload.UploadServer
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import java.util.concurrent.Executors
|
|
|
|
@ExperimentalGetImage
|
|
class MainActivity : AppCompatActivity() {
|
|
private lateinit var countText: TextView
|
|
private lateinit var previewView: PreviewView
|
|
private lateinit var overlayView: OverlayView
|
|
private lateinit var recognitionStatusText: TextView
|
|
private lateinit var matcherConfigSummaryText: TextView
|
|
private lateinit var euclideanThresholdInput: EditText
|
|
private lateinit var cosineThresholdInput: EditText
|
|
private lateinit var uploadStatusText: TextView
|
|
private lateinit var uploadUrlText: TextView
|
|
private lateinit var pauseRecognitionButton: Button
|
|
private lateinit var homePanel: View
|
|
private lateinit var recognitionPanel: View
|
|
private lateinit var uploadPanel: View
|
|
|
|
private var currentMode = AppMode.HOME
|
|
private var processor: FaceProcessor? = null
|
|
private var uploadServer: UploadServer? = null
|
|
private var analysisUseCase: ImageAnalysis? = null
|
|
private var cameraProvider: ProcessCameraProvider? = null
|
|
|
|
private val repository by lazy { StudentRepository(this) }
|
|
private val matcherPrefs: SharedPreferences by lazy {
|
|
getSharedPreferences(MATCHER_PREFS_NAME, MODE_PRIVATE)
|
|
}
|
|
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
|
private var students: List<Student> = emptyList()
|
|
private var recognitionEnabled = true
|
|
private var analyzing = false
|
|
private var lastAnalysisAt = 0L
|
|
private var euclideanThreshold = DEFAULT_EUCLIDEAN_THRESHOLD
|
|
private var cosineThreshold = DEFAULT_COSINE_THRESHOLD
|
|
@Volatile private var matcher = FaceMatcher(DEFAULT_EUCLIDEAN_THRESHOLD, DEFAULT_COSINE_THRESHOLD)
|
|
private val recognitionHistory = mutableMapOf<Int, ArrayDeque<RecognitionResult>>()
|
|
private val recognitionCache = mutableMapOf<Int, CachedRecognition>()
|
|
private var lastVisibleDetections: List<DetectionUi> = emptyList()
|
|
private var lastVisibleDetectionsAt = 0L
|
|
private var perfFrameCount = 0
|
|
private var perfDetectMsSum = 0L
|
|
private var perfRefreshMsSum = 0L
|
|
|
|
private val permissionLauncher = registerForActivityResult(
|
|
ActivityResultContracts.RequestMultiplePermissions()
|
|
) { grants ->
|
|
if (grants[Manifest.permission.CAMERA] == true) {
|
|
if (currentMode == AppMode.RECOGNITION) {
|
|
startRecognitionSession()
|
|
}
|
|
} else if (currentMode == AppMode.RECOGNITION) {
|
|
recognitionStatusText.setText(R.string.camera_permission_required)
|
|
toast(getString(R.string.camera_permission_required))
|
|
}
|
|
}
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
loadMatcherConfig()
|
|
setContentView(createContentView())
|
|
|
|
lifecycleScope.launch {
|
|
repository.students.collectLatest { list ->
|
|
students = list
|
|
countText.text = getString(R.string.registered_student_count, list.size)
|
|
}
|
|
}
|
|
|
|
switchMode(AppMode.HOME)
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
super.onDestroy()
|
|
stopRecognitionSession()
|
|
stopUploadSession()
|
|
cameraExecutor.shutdown()
|
|
}
|
|
|
|
private fun createContentView(): View {
|
|
val root = LinearLayout(this).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
setBackgroundColor(0xFFF8FAFC.toInt())
|
|
}
|
|
|
|
val header = LinearLayout(this).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
setPadding(32, 28, 32, 20)
|
|
}
|
|
header.addView(TextView(this).apply {
|
|
text = "学生人脸识别"
|
|
textSize = 24f
|
|
setTextColor(0xFF0F172A.toInt())
|
|
})
|
|
countText = TextView(this).apply {
|
|
text = "已入库 0 名学生"
|
|
textSize = 15f
|
|
setTextColor(0xFF475569.toInt())
|
|
}
|
|
header.addView(countText)
|
|
|
|
val content = FrameLayout(this).apply {
|
|
layoutParams = LinearLayout.LayoutParams(
|
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
0,
|
|
1f
|
|
)
|
|
}
|
|
|
|
homePanel = createHomePanel()
|
|
recognitionPanel = createRecognitionPanel()
|
|
uploadPanel = createUploadPanel()
|
|
homePanel.visibility = View.VISIBLE
|
|
recognitionPanel.visibility = View.GONE
|
|
uploadPanel.visibility = View.GONE
|
|
|
|
content.addView(homePanel, FrameLayout.LayoutParams(-1, -1))
|
|
content.addView(recognitionPanel, FrameLayout.LayoutParams(-1, -1))
|
|
content.addView(uploadPanel, FrameLayout.LayoutParams(-1, -1))
|
|
|
|
root.addView(header)
|
|
root.addView(content)
|
|
return root
|
|
}
|
|
|
|
private fun createHomePanel(): View {
|
|
return LinearLayout(this).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
gravity = Gravity.CENTER
|
|
setPadding(40, 40, 40, 40)
|
|
|
|
addView(TextView(this@MainActivity).apply {
|
|
text = "请选择要进入的功能"
|
|
textSize = 26f
|
|
typeface = Typeface.DEFAULT_BOLD
|
|
setTextColor(0xFF0F172A.toInt())
|
|
gravity = Gravity.CENTER
|
|
})
|
|
|
|
addView(TextView(this@MainActivity).apply {
|
|
text = "识别和注册上传分开运行,减少同时占用相机、模型和本地服务带来的卡顿。"
|
|
textSize = 16f
|
|
setTextColor(0xFF475569.toInt())
|
|
gravity = Gravity.CENTER
|
|
setPadding(0, 20, 0, 36)
|
|
})
|
|
|
|
addView(Button(this@MainActivity).apply {
|
|
text = "进入识别"
|
|
textSize = 20f
|
|
setPadding(36, 24, 36, 24)
|
|
setOnClickListener { switchMode(AppMode.RECOGNITION) }
|
|
})
|
|
|
|
addView(Button(this@MainActivity).apply {
|
|
text = "进入注册上传"
|
|
textSize = 20f
|
|
setPadding(36, 24, 36, 24)
|
|
setOnClickListener { switchMode(AppMode.UPLOAD) }
|
|
})
|
|
}
|
|
}
|
|
|
|
private fun createRecognitionPanel(): View {
|
|
return LinearLayout(this).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
setPadding(24, 16, 24, 24)
|
|
|
|
recognitionStatusText = TextView(this@MainActivity).apply {
|
|
text = "准备进入识别模式"
|
|
textSize = 22f
|
|
typeface = Typeface.DEFAULT_BOLD
|
|
setTextColor(0xFF0F172A.toInt())
|
|
setPadding(8, 8, 8, 20)
|
|
}
|
|
addView(recognitionStatusText)
|
|
|
|
val matcherConfigPanel = LinearLayout(this@MainActivity).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
setPadding(16, 16, 16, 16)
|
|
setBackgroundColor(0xFFF1F5F9.toInt())
|
|
}
|
|
|
|
matcherConfigSummaryText = TextView(this@MainActivity).apply {
|
|
textSize = 14f
|
|
setTextColor(0xFF475569.toInt())
|
|
text = matcherConfigSummary()
|
|
setPadding(0, 0, 0, 12)
|
|
}
|
|
matcherConfigPanel.addView(matcherConfigSummaryText)
|
|
|
|
val thresholdRow = LinearLayout(this@MainActivity).apply {
|
|
orientation = LinearLayout.HORIZONTAL
|
|
}
|
|
|
|
val euclideanColumn = LinearLayout(this@MainActivity).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
|
setPadding(0, 0, 12, 0)
|
|
}
|
|
euclideanColumn.addView(TextView(this@MainActivity).apply {
|
|
text = "欧氏阈值"
|
|
textSize = 13f
|
|
setTextColor(0xFF334155.toInt())
|
|
})
|
|
euclideanThresholdInput = EditText(this@MainActivity).apply {
|
|
setText(formatThreshold(euclideanThreshold))
|
|
hint = "0.00"
|
|
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
|
setTextColor(0xFF0F172A.toInt())
|
|
setBackgroundColor(0xFFFFFFFF.toInt())
|
|
setPadding(20, 18, 20, 18)
|
|
}
|
|
euclideanColumn.addView(euclideanThresholdInput)
|
|
|
|
val cosineColumn = LinearLayout(this@MainActivity).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
|
}
|
|
cosineColumn.addView(TextView(this@MainActivity).apply {
|
|
text = "余弦阈值"
|
|
textSize = 13f
|
|
setTextColor(0xFF334155.toInt())
|
|
})
|
|
cosineThresholdInput = EditText(this@MainActivity).apply {
|
|
setText(formatThreshold(cosineThreshold))
|
|
hint = "0.00"
|
|
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED
|
|
setTextColor(0xFF0F172A.toInt())
|
|
setBackgroundColor(0xFFFFFFFF.toInt())
|
|
setPadding(20, 18, 20, 18)
|
|
}
|
|
cosineColumn.addView(cosineThresholdInput)
|
|
|
|
thresholdRow.addView(euclideanColumn)
|
|
thresholdRow.addView(cosineColumn)
|
|
matcherConfigPanel.addView(thresholdRow)
|
|
|
|
matcherConfigPanel.addView(Button(this@MainActivity).apply {
|
|
text = "保存识别参数"
|
|
setOnClickListener { saveMatcherConfig() }
|
|
})
|
|
matcherConfigPanel.addView(Button(this@MainActivity).apply {
|
|
text = "自动校准阈值"
|
|
setOnClickListener { applySuggestedThresholds() }
|
|
})
|
|
|
|
addView(matcherConfigPanel)
|
|
|
|
val cameraFrame = FrameLayout(this@MainActivity).apply {
|
|
layoutParams = LinearLayout.LayoutParams(
|
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
0,
|
|
1f
|
|
)
|
|
}
|
|
previewView = PreviewView(this@MainActivity).apply {
|
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
|
}
|
|
overlayView = OverlayView(this@MainActivity)
|
|
cameraFrame.addView(previewView, FrameLayout.LayoutParams(-1, -1))
|
|
cameraFrame.addView(overlayView, FrameLayout.LayoutParams(-1, -1))
|
|
addView(cameraFrame)
|
|
|
|
addView(LinearLayout(this@MainActivity).apply {
|
|
gravity = Gravity.CENTER
|
|
orientation = LinearLayout.HORIZONTAL
|
|
setPadding(12, 20, 12, 8)
|
|
|
|
addView(Button(this@MainActivity).apply {
|
|
text = "返回首页"
|
|
setOnClickListener { switchMode(AppMode.HOME) }
|
|
})
|
|
|
|
pauseRecognitionButton = Button(this@MainActivity).apply {
|
|
text = "暂停识别"
|
|
setOnClickListener {
|
|
recognitionEnabled = !recognitionEnabled
|
|
text = if (recognitionEnabled) "暂停识别" else "继续识别"
|
|
recognitionStatusText.text = if (recognitionEnabled) "正在识别" else "识别已暂停"
|
|
if (!recognitionEnabled) {
|
|
overlayView.update(emptyList())
|
|
}
|
|
updateAnalyzerState()
|
|
}
|
|
}
|
|
addView(pauseRecognitionButton)
|
|
})
|
|
}
|
|
}
|
|
|
|
private fun createUploadPanel(): View {
|
|
return LinearLayout(this).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
gravity = Gravity.CENTER_HORIZONTAL
|
|
setPadding(40, 48, 40, 48)
|
|
|
|
uploadStatusText = TextView(this@MainActivity).apply {
|
|
text = "准备进入注册上传模式"
|
|
textSize = 24f
|
|
typeface = Typeface.DEFAULT_BOLD
|
|
setTextColor(0xFF0F172A.toInt())
|
|
gravity = Gravity.CENTER
|
|
}
|
|
addView(uploadStatusText)
|
|
|
|
addView(TextView(this@MainActivity).apply {
|
|
setText(R.string.upload_instructions)
|
|
textSize = 16f
|
|
setTextColor(0xFF475569.toInt())
|
|
gravity = Gravity.CENTER
|
|
setPadding(0, 16, 0, 20)
|
|
})
|
|
|
|
uploadUrlText = TextView(this@MainActivity).apply {
|
|
text = "-"
|
|
textSize = 20f
|
|
typeface = Typeface.MONOSPACE
|
|
setTextColor(0xFF0F172A.toInt())
|
|
gravity = Gravity.CENTER
|
|
setPadding(24, 20, 24, 20)
|
|
setTextIsSelectable(true)
|
|
setBackgroundColor(0xFFE2E8F0.toInt())
|
|
}
|
|
addView(uploadUrlText)
|
|
|
|
addView(LinearLayout(this@MainActivity).apply {
|
|
gravity = Gravity.CENTER
|
|
orientation = LinearLayout.HORIZONTAL
|
|
setPadding(12, 28, 12, 8)
|
|
|
|
addView(Button(this@MainActivity).apply {
|
|
text = "刷新地址"
|
|
setOnClickListener { startUploadSession() }
|
|
})
|
|
|
|
addView(Button(this@MainActivity).apply {
|
|
text = "返回首页"
|
|
setOnClickListener { switchMode(AppMode.HOME) }
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
private fun switchMode(mode: AppMode) {
|
|
if (currentMode == mode) return
|
|
|
|
val previousMode = currentMode
|
|
currentMode = mode
|
|
homePanel.visibility = if (mode == AppMode.HOME) View.VISIBLE else View.GONE
|
|
recognitionPanel.visibility = if (mode == AppMode.RECOGNITION) View.VISIBLE else View.GONE
|
|
uploadPanel.visibility = if (mode == AppMode.UPLOAD) View.VISIBLE else View.GONE
|
|
|
|
when (previousMode) {
|
|
AppMode.RECOGNITION -> stopRecognitionSession()
|
|
AppMode.UPLOAD -> stopUploadSession()
|
|
AppMode.HOME -> Unit
|
|
}
|
|
|
|
when (mode) {
|
|
AppMode.HOME -> Unit
|
|
AppMode.RECOGNITION -> enterRecognitionMode()
|
|
AppMode.UPLOAD -> enterUploadMode()
|
|
}
|
|
}
|
|
|
|
private fun enterRecognitionMode() {
|
|
recognitionEnabled = true
|
|
analyzing = false
|
|
lastAnalysisAt = 0L
|
|
pauseRecognitionButton.text = "暂停识别"
|
|
recognitionStatusText.text = "正在准备识别服务..."
|
|
matcherConfigSummaryText.text = matcherConfigSummary()
|
|
euclideanThresholdInput.setText(formatThreshold(euclideanThreshold))
|
|
cosineThresholdInput.setText(formatThreshold(cosineThreshold))
|
|
recognitionHistory.clear()
|
|
recognitionCache.clear()
|
|
lastVisibleDetections = emptyList()
|
|
lastVisibleDetectionsAt = 0L
|
|
overlayView.update(emptyList())
|
|
|
|
if (hasCameraPermission()) {
|
|
startRecognitionSession()
|
|
} else {
|
|
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA))
|
|
}
|
|
}
|
|
|
|
private fun startRecognitionSession() {
|
|
if (currentMode != AppMode.RECOGNITION) return
|
|
|
|
recognitionStatusText.text = "正在加载模型..."
|
|
lifecycleScope.launch(Dispatchers.IO) {
|
|
val existing = processor
|
|
if (existing != null) {
|
|
withContext(Dispatchers.Main) {
|
|
if (currentMode == AppMode.RECOGNITION) bindRecognitionCamera(existing)
|
|
}
|
|
return@launch
|
|
}
|
|
|
|
val created = runCatching {
|
|
FaceProcessor(this@MainActivity).also { it.warmUp() }
|
|
}
|
|
withContext(Dispatchers.Main) {
|
|
if (currentMode != AppMode.RECOGNITION) {
|
|
created.getOrNull()?.close()
|
|
return@withContext
|
|
}
|
|
val faceProcessor = created.getOrNull()
|
|
if (faceProcessor == null) {
|
|
val error = created.exceptionOrNull()
|
|
val message = error?.message ?: error?.javaClass?.simpleName ?: "unknown error"
|
|
recognitionStatusText.text = getString(R.string.model_init_failed, message)
|
|
toast(getString(R.string.model_init_failed, message))
|
|
return@withContext
|
|
}
|
|
processor = faceProcessor
|
|
bindRecognitionCamera(faceProcessor)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun bindRecognitionCamera(faceProcessor: FaceProcessor) {
|
|
previewView.post {
|
|
val providerFuture = ProcessCameraProvider.getInstance(this)
|
|
providerFuture.addListener({
|
|
val provider = providerFuture.get()
|
|
cameraProvider = provider
|
|
if (currentMode != AppMode.RECOGNITION) {
|
|
provider.unbindAll()
|
|
return@addListener
|
|
}
|
|
|
|
val preview = Preview.Builder()
|
|
.setResolutionSelector(analysisResolutionSelector())
|
|
.build()
|
|
.also { it.surfaceProvider = previewView.surfaceProvider }
|
|
val analysis = ImageAnalysis.Builder()
|
|
.setResolutionSelector(analysisResolutionSelector())
|
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
.build()
|
|
|
|
val viewPort = previewView.viewPort
|
|
provider.unbindAll()
|
|
if (viewPort != null) {
|
|
val group = androidx.camera.core.UseCaseGroup.Builder()
|
|
.setViewPort(viewPort)
|
|
.addUseCase(preview)
|
|
.addUseCase(analysis)
|
|
.build()
|
|
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, group)
|
|
} else {
|
|
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
|
}
|
|
analysisUseCase = analysis
|
|
recognitionStatusText.text = if (faceProcessor === processor) "正在识别" else "识别服务已启动"
|
|
updateAnalyzerState()
|
|
}, ContextCompat.getMainExecutor(this))
|
|
}
|
|
}
|
|
|
|
private fun stopRecognitionSession() {
|
|
recognitionEnabled = false
|
|
analyzing = false
|
|
analysisUseCase?.clearAnalyzer()
|
|
analysisUseCase = null
|
|
cameraProvider?.unbindAll()
|
|
cameraProvider = null
|
|
recognitionHistory.clear()
|
|
recognitionCache.clear()
|
|
lastVisibleDetections = emptyList()
|
|
lastVisibleDetectionsAt = 0L
|
|
overlayView.update(emptyList())
|
|
processor?.close()
|
|
processor = null
|
|
}
|
|
|
|
private fun updateAnalyzerState() {
|
|
val analysis = analysisUseCase ?: return
|
|
if (currentMode == AppMode.RECOGNITION && recognitionEnabled && processor != null) {
|
|
analysis.setAnalyzer(cameraExecutor) { image -> analyze(image) }
|
|
} else {
|
|
analysis.clearAnalyzer()
|
|
}
|
|
}
|
|
|
|
private fun analyze(image: ImageProxy) {
|
|
val faceProcessor = processor
|
|
val now = SystemClock.elapsedRealtime()
|
|
if (
|
|
currentMode != AppMode.RECOGNITION ||
|
|
!recognitionEnabled ||
|
|
analyzing ||
|
|
faceProcessor == null ||
|
|
now - lastAnalysisAt < ANALYSIS_INTERVAL_MS
|
|
) {
|
|
image.close()
|
|
return
|
|
}
|
|
|
|
analyzing = true
|
|
lastAnalysisAt = now
|
|
lifecycleScope.launch(Dispatchers.Default) {
|
|
try {
|
|
val mediaImage = image.image ?: return@launch
|
|
val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
|
|
val detectStart = SystemClock.elapsedRealtime()
|
|
val faces = faceProcessor.detectFaces(inputImage)
|
|
val detectMs = SystemClock.elapsedRealtime() - detectStart
|
|
|
|
if (faces.isEmpty()) {
|
|
withContext(Dispatchers.Main) {
|
|
if (currentMode == AppMode.RECOGNITION) {
|
|
if (now - lastVisibleDetectionsAt <= DETECTION_GRACE_MS && lastVisibleDetections.isNotEmpty()) {
|
|
overlayView.update(lastVisibleDetections)
|
|
updateRecognitionStatus(lastVisibleDetections)
|
|
} else {
|
|
recognitionHistory.clear()
|
|
recognitionCache.clear()
|
|
lastVisibleDetections = emptyList()
|
|
lastVisibleDetectionsAt = 0L
|
|
overlayView.update(emptyList())
|
|
updateRecognitionStatus(emptyList())
|
|
}
|
|
}
|
|
}
|
|
return@launch
|
|
}
|
|
|
|
val activeTrackIds = faces.mapNotNull { it.trackingId }.toSet()
|
|
val facesNeedingRefresh = faces.mapIndexedNotNull { index, face ->
|
|
if (shouldRefreshRecognition(face, now)) index to face else null
|
|
}
|
|
val refreshedResults = mutableMapOf<Int, RecognitionResult>()
|
|
|
|
if (facesNeedingRefresh.isNotEmpty()) {
|
|
val refreshStart = SystemClock.elapsedRealtime()
|
|
val bitmap = ImageUtils.imageProxyToBitmap(image)
|
|
facesNeedingRefresh.forEach { (index, face) ->
|
|
val refreshed = refreshRecognition(faceProcessor, bitmap, face)
|
|
refreshedResults[index] = refreshed
|
|
}
|
|
val refreshMs = SystemClock.elapsedRealtime() - refreshStart
|
|
perfFrameCount++
|
|
perfDetectMsSum += detectMs
|
|
perfRefreshMsSum += refreshMs
|
|
if (perfFrameCount >= 60) {
|
|
Log.i(
|
|
"MainActivity",
|
|
"FacePerf 检测平均=${perfDetectMsSum / perfFrameCount}ms " +
|
|
"刷新推理平均=${perfRefreshMsSum / perfFrameCount}ms " +
|
|
"人脸数=${faces.size}"
|
|
)
|
|
perfFrameCount = 0
|
|
perfDetectMsSum = 0L
|
|
perfRefreshMsSum = 0L
|
|
}
|
|
}
|
|
|
|
val rawDetections = faces.mapIndexed { index, face ->
|
|
val result = resolveRecognitionResult(face, refreshedResults[index], now)
|
|
RawDetection(
|
|
bounds = face.boundingBox,
|
|
result = result,
|
|
confidence = if (result.student != null) result.confidence else null
|
|
)
|
|
}
|
|
pruneTrackCache(now, activeTrackIds)
|
|
|
|
withContext(Dispatchers.Main) {
|
|
if (currentMode == AppMode.RECOGNITION) {
|
|
val detections = mapDetectionsToPreview(image, rawDetections)
|
|
overlayView.update(detections)
|
|
updateRecognitionStatus(detections)
|
|
lastVisibleDetections = detections
|
|
lastVisibleDetectionsAt = now
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e("MainActivity", "Recognition failed", e)
|
|
withContext(Dispatchers.Main) {
|
|
if (currentMode == AppMode.RECOGNITION) {
|
|
recognitionStatusText.text = getString(
|
|
R.string.recognition_failed,
|
|
e.message ?: e.javaClass.simpleName
|
|
)
|
|
}
|
|
}
|
|
} finally {
|
|
analyzing = false
|
|
image.close()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun updateRecognitionStatus(detections: List<DetectionUi>) {
|
|
if (detections.isEmpty()) {
|
|
recognitionStatusText.setText(R.string.no_face_detected)
|
|
return
|
|
}
|
|
|
|
val matched = detections.filter { it.result.isMatched }
|
|
val unsure = detections.filter { it.result.matchType == MatchType.UNSURE }
|
|
val tracking = detections.count { it.result.matchType == MatchType.NO_DATA }
|
|
val noMatch = detections.count { it.result.matchType == MatchType.NO_MATCH }
|
|
val total = detections.size
|
|
|
|
if (matched.isNotEmpty()) {
|
|
if (matched.size == 1 && total == 1) {
|
|
val student = matched[0].result.student!!
|
|
val confidence = (matched[0].confidence!! * 100).toInt()
|
|
recognitionStatusText.text = getString(
|
|
R.string.single_student_recognized,
|
|
student.name,
|
|
student.studentNo,
|
|
confidence
|
|
)
|
|
} else {
|
|
val names = matched.joinToString(", ") {
|
|
val student = it.result.student!!
|
|
val confidence = (it.confidence!! * 100).toInt()
|
|
getString(R.string.student_confidence_item, student.name, confidence)
|
|
}
|
|
val remaining = total - matched.size
|
|
recognitionStatusText.text = if (remaining > 0) {
|
|
getString(R.string.multi_student_recognized_with_tracking, matched.size, total, names, remaining)
|
|
} else {
|
|
getString(R.string.multi_student_recognized, matched.size, total, names)
|
|
}
|
|
}
|
|
} else if (unsure.isNotEmpty()) {
|
|
recognitionStatusText.text = getString(R.string.unsure_faces_detected, unsure.size)
|
|
} else if (tracking > 0) {
|
|
recognitionStatusText.text = if (noMatch > 0) {
|
|
getString(R.string.tracking_with_no_match, total, tracking, noMatch)
|
|
} else {
|
|
getString(R.string.tracking_faces, total, tracking)
|
|
}
|
|
} else {
|
|
recognitionStatusText.text = getString(R.string.unregistered_faces_detected, total)
|
|
}
|
|
}
|
|
|
|
private fun enterUploadMode() {
|
|
uploadStatusText.text = "正在准备注册上传服务..."
|
|
startUploadSession()
|
|
}
|
|
|
|
private fun startUploadSession() {
|
|
if (currentMode != AppMode.UPLOAD) return
|
|
|
|
val server = uploadServer ?: UploadServer(this, repository).also {
|
|
uploadServer = it
|
|
}
|
|
runCatching {
|
|
server.start()
|
|
uploadStatusText.text = "请在电脑浏览器打开以下地址"
|
|
uploadUrlText.text = server.accessUrl()
|
|
}.onFailure {
|
|
val message = it.message ?: "unknown error"
|
|
uploadStatusText.text = getString(R.string.upload_service_start_failed, message)
|
|
uploadUrlText.text = "-"
|
|
}
|
|
}
|
|
|
|
private fun stopUploadSession() {
|
|
uploadServer?.close()
|
|
uploadServer = null
|
|
uploadStatusText.text = "注册上传服务已停止"
|
|
uploadUrlText.text = "-"
|
|
}
|
|
|
|
private fun hasCameraPermission(): Boolean {
|
|
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
|
}
|
|
|
|
private fun toast(message: String) {
|
|
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
|
}
|
|
|
|
private fun loadMatcherConfig() {
|
|
euclideanThreshold = matcherPrefs.getFloat(KEY_EUCLIDEAN_THRESHOLD, DEFAULT_EUCLIDEAN_THRESHOLD)
|
|
cosineThreshold = matcherPrefs.getFloat(KEY_COSINE_THRESHOLD, DEFAULT_COSINE_THRESHOLD)
|
|
matcher = FaceMatcher(euclideanThreshold, cosineThreshold)
|
|
}
|
|
|
|
private fun saveMatcherConfig() {
|
|
val parsedEuclidean = euclideanThresholdInput.text?.toString()?.trim()?.toFloatOrNull()
|
|
val parsedCosine = cosineThresholdInput.text?.toString()?.trim()?.toFloatOrNull()
|
|
|
|
if (parsedEuclidean == null || parsedCosine == null) {
|
|
toast("请输入有效的数字")
|
|
return
|
|
}
|
|
|
|
if (parsedEuclidean !in 0f..2f) {
|
|
toast("欧氏阈值建议在 0 到 2 之间")
|
|
return
|
|
}
|
|
|
|
if (parsedCosine !in -1f..1f) {
|
|
toast("余弦阈值建议在 -1 到 1 之间")
|
|
return
|
|
}
|
|
|
|
euclideanThreshold = parsedEuclidean
|
|
cosineThreshold = parsedCosine
|
|
matcher = FaceMatcher(euclideanThreshold, cosineThreshold)
|
|
matcherPrefs.edit {
|
|
putFloat(KEY_EUCLIDEAN_THRESHOLD, euclideanThreshold)
|
|
putFloat(KEY_COSINE_THRESHOLD, cosineThreshold)
|
|
}
|
|
|
|
matcherConfigSummaryText.text = matcherConfigSummary()
|
|
recognitionStatusText.text = "识别参数已更新"
|
|
toast("识别参数已保存")
|
|
}
|
|
|
|
private fun matcherConfigSummary(): String {
|
|
return getString(
|
|
R.string.matcher_config_summary,
|
|
formatThreshold(euclideanThreshold),
|
|
formatThreshold(cosineThreshold)
|
|
)
|
|
}
|
|
|
|
private fun applySuggestedThresholds() {
|
|
if (students.size < 3) {
|
|
toast("至少需要 3 名已注册学生才能校准阈值")
|
|
return
|
|
}
|
|
val suggested = matcher.suggestThresholds(students)
|
|
euclideanThresholdInput.setText(formatThreshold(suggested.euclidean))
|
|
cosineThresholdInput.setText(formatThreshold(suggested.cosine))
|
|
matcherConfigSummaryText.text = getString(
|
|
R.string.suggested_matcher_config_summary,
|
|
students.size,
|
|
formatThreshold(suggested.euclidean),
|
|
formatThreshold(suggested.cosine)
|
|
)
|
|
toast("已填入建议阈值,确认后点击保存")
|
|
}
|
|
|
|
private fun formatThreshold(value: Float): String {
|
|
return String.format(java.util.Locale.US, "%.2f", value)
|
|
}
|
|
|
|
private fun analysisResolutionSelector(): ResolutionSelector {
|
|
return ResolutionSelector.Builder()
|
|
.setResolutionStrategy(
|
|
ResolutionStrategy(
|
|
ANALYSIS_SIZE,
|
|
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
|
|
)
|
|
)
|
|
.build()
|
|
}
|
|
|
|
private fun mapDetectionsToPreview(image: ImageProxy, rawDetections: List<RawDetection>): List<DetectionUi> {
|
|
val previewWidth = previewView.width.toFloat()
|
|
val previewHeight = previewView.height.toFloat()
|
|
if (previewWidth <= 0f || previewHeight <= 0f) {
|
|
return rawDetections.map { DetectionUi(RectF(it.bounds), it.result, it.confidence) }
|
|
}
|
|
|
|
val rotatedCropRect = rotateCropRectToDisplaySpace(
|
|
cropRect = image.cropRect,
|
|
imageWidth = image.width,
|
|
imageHeight = image.height,
|
|
rotationDegrees = image.imageInfo.rotationDegrees
|
|
)
|
|
val scale = maxOf(
|
|
previewWidth / rotatedCropRect.width().coerceAtLeast(1),
|
|
previewHeight / rotatedCropRect.height().coerceAtLeast(1)
|
|
)
|
|
val offsetX = (previewWidth - rotatedCropRect.width() * scale) / 2f
|
|
val offsetY = (previewHeight - rotatedCropRect.height() * scale) / 2f
|
|
|
|
return rawDetections.map { detection ->
|
|
val rect = RectF(
|
|
(detection.bounds.left - rotatedCropRect.left) * scale + offsetX,
|
|
(detection.bounds.top - rotatedCropRect.top) * scale + offsetY,
|
|
(detection.bounds.right - rotatedCropRect.left) * scale + offsetX,
|
|
(detection.bounds.bottom - rotatedCropRect.top) * scale + offsetY
|
|
)
|
|
DetectionUi(rect, detection.result, detection.confidence)
|
|
}
|
|
}
|
|
|
|
private fun rotateCropRectToDisplaySpace(
|
|
cropRect: Rect,
|
|
imageWidth: Int,
|
|
imageHeight: Int,
|
|
rotationDegrees: Int
|
|
): Rect {
|
|
return when ((rotationDegrees % 360 + 360) % 360) {
|
|
0 -> Rect(cropRect)
|
|
90 -> Rect(
|
|
imageHeight - cropRect.bottom,
|
|
cropRect.left,
|
|
imageHeight - cropRect.top,
|
|
cropRect.right
|
|
)
|
|
180 -> Rect(
|
|
imageWidth - cropRect.right,
|
|
imageHeight - cropRect.bottom,
|
|
imageWidth - cropRect.left,
|
|
imageHeight - cropRect.top
|
|
)
|
|
270 -> Rect(
|
|
cropRect.top,
|
|
imageWidth - cropRect.right,
|
|
cropRect.bottom,
|
|
imageWidth - cropRect.left
|
|
)
|
|
else -> Rect(cropRect)
|
|
}
|
|
}
|
|
|
|
private fun stabilizeRecognition(face: Face, result: RecognitionResult): RecognitionResult {
|
|
val trackId = face.trackingId ?: return result
|
|
val history = recognitionHistory.getOrPut(trackId) { ArrayDeque() }
|
|
history.addLast(result)
|
|
while (history.size > 5) {
|
|
history.removeFirst()
|
|
}
|
|
|
|
val matched = history.filter { it.isMatched }
|
|
if (matched.isEmpty()) return result
|
|
|
|
val grouped = matched.groupBy { it.student?.id }
|
|
val best = grouped.maxByOrNull { entry ->
|
|
entry.value.sumOf { it.confidence.toDouble() }
|
|
} ?: return result
|
|
val bestResults = best.value
|
|
val bestScore = bestResults.sumOf { it.confidence.toDouble() }.toFloat()
|
|
val bestLatest = bestResults.maxByOrNull { it.confidence } ?: return result
|
|
val secondScore = grouped
|
|
.filterKeys { it != best.key }
|
|
.maxOfOrNull { entry -> entry.value.sumOf { it.confidence.toDouble() }.toFloat() }
|
|
?: 0f
|
|
|
|
return when {
|
|
bestResults.size >= 2 -> bestLatest
|
|
bestLatest.confidence >= 0.92f && secondScore == 0f -> bestLatest
|
|
bestScore >= 1.25f && (bestScore - secondScore) >= 0.25f -> bestLatest
|
|
else -> result
|
|
}
|
|
}
|
|
|
|
private fun shouldRefreshRecognition(face: Face, now: Long): Boolean {
|
|
val trackId = face.trackingId ?: return true
|
|
val cached = recognitionCache[trackId] ?: return true
|
|
if (now - cached.lastSeenAtMs > TRACK_STALE_MS) return true
|
|
if (hasTrackMovedSignificantly(cached.lastBounds, face.boundingBox)) return true
|
|
|
|
val refreshInterval = when {
|
|
cached.result.isMatched && cached.result.confidence >= MATCH_STABLE_CONFIDENCE -> MATCH_REFRESH_MS
|
|
cached.result.matchType == MatchType.UNSURE -> UNSURE_REFRESH_MS
|
|
cached.result.matchType == MatchType.NO_MATCH -> NO_MATCH_REFRESH_MS
|
|
else -> MATCH_REFRESH_MS
|
|
}
|
|
return now - cached.updatedAtMs >= refreshInterval
|
|
}
|
|
|
|
private fun hasTrackMovedSignificantly(previousBounds: RectF, currentBounds: Rect): Boolean {
|
|
val current = RectF(currentBounds)
|
|
val widthBase = maxOf(previousBounds.width(), current.width(), 1f)
|
|
val heightBase = maxOf(previousBounds.height(), current.height(), 1f)
|
|
val centerDx = kotlin.math.abs(previousBounds.centerX() - current.centerX()) / widthBase
|
|
val centerDy = kotlin.math.abs(previousBounds.centerY() - current.centerY()) / heightBase
|
|
val sizeDiff = kotlin.math.abs(previousBounds.width() - current.width()) / widthBase
|
|
val heightDiff = kotlin.math.abs(previousBounds.height() - current.height()) / heightBase
|
|
return centerDx > TRACK_MOVE_THRESHOLD || centerDy > TRACK_MOVE_THRESHOLD || sizeDiff > TRACK_SIZE_THRESHOLD || heightDiff > TRACK_SIZE_THRESHOLD
|
|
}
|
|
|
|
private fun pruneTrackCache(now: Long, activeTrackIds: Set<Int>) {
|
|
recognitionCache.entries.removeAll { (trackId, state) ->
|
|
trackId !in activeTrackIds && now - state.lastSeenAtMs > TRACK_STALE_MS
|
|
}
|
|
recognitionHistory.entries.removeAll { (trackId, _) ->
|
|
trackId !in recognitionCache
|
|
}
|
|
}
|
|
|
|
private fun refreshRecognition(
|
|
faceProcessor: FaceProcessor,
|
|
bitmap: Bitmap,
|
|
face: Face
|
|
): RecognitionResult {
|
|
val primary = matcher.findNearest(faceProcessor.embedFace(bitmap, face), students)
|
|
val cached = face.trackingId?.let { recognitionCache[it] }
|
|
// 未匹配时只做单次推理;仅在疑似匹配或人脸明显移动时才用多裁剪升级,
|
|
// 避免对陌生面孔持续做 3 次推理
|
|
val needsEscalation = cached == null ||
|
|
hasTrackMovedSignificantly(cached.lastBounds, face.boundingBox) ||
|
|
primary.matchType != MatchType.NO_MATCH
|
|
|
|
if (!needsEscalation) {
|
|
return primary
|
|
}
|
|
|
|
var best = primary
|
|
for (embedding in faceProcessor.embedFaceCandidates(bitmap, face)) {
|
|
val result = matcher.findNearest(embedding, students)
|
|
if (recognitionScore(result) > recognitionScore(best)) {
|
|
best = result
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
private fun resolveRecognitionResult(
|
|
face: Face,
|
|
refreshedResult: RecognitionResult?,
|
|
now: Long
|
|
): RecognitionResult {
|
|
val trackId = face.trackingId
|
|
val previousState = trackId?.let { recognitionCache[it] }
|
|
val previousResult = previousState?.result
|
|
val directResult = when {
|
|
refreshedResult != null -> refreshedResult
|
|
previousResult != null -> previousResult
|
|
else -> RecognitionResult(null, Float.MAX_VALUE, 0f, MatchType.NO_DATA)
|
|
}
|
|
val stabilized = stabilizeRecognition(face, directResult)
|
|
val finalResult = when {
|
|
previousResult?.isMatched == true && !stabilized.isMatched -> previousResult
|
|
previousResult?.isMatched == true &&
|
|
stabilized.isMatched &&
|
|
previousResult.student?.id != stabilized.student?.id &&
|
|
stabilized.confidence < previousResult.confidence + 0.12f -> previousResult
|
|
previousResult?.isMatched == true &&
|
|
stabilized.isMatched &&
|
|
previousResult.student?.id == stabilized.student?.id &&
|
|
stabilized.confidence + 0.08f < previousResult.confidence -> previousResult
|
|
else -> stabilized
|
|
}
|
|
if (trackId != null) {
|
|
val stableFrames = when {
|
|
previousState?.result?.student?.id == finalResult.student?.id && finalResult.isMatched ->
|
|
(previousState?.stableFrames ?: 0) + 1
|
|
finalResult.isMatched -> 1
|
|
else -> 0
|
|
}
|
|
recognitionCache[trackId] = CachedRecognition(
|
|
result = finalResult,
|
|
updatedAtMs = if (refreshedResult != null) now else previousState?.updatedAtMs ?: now,
|
|
lastSeenAtMs = now,
|
|
lastBounds = RectF(face.boundingBox),
|
|
stableFrames = stableFrames
|
|
)
|
|
}
|
|
return finalResult
|
|
}
|
|
|
|
private fun recognitionScore(result: RecognitionResult): Float {
|
|
return matchRank(result.matchType) * 10f + result.confidence + result.cosineSimilarity * 0.1f
|
|
}
|
|
|
|
private fun matchRank(matchType: MatchType): Int {
|
|
return when (matchType) {
|
|
MatchType.MATCH -> 3
|
|
MatchType.UNSURE -> 2
|
|
MatchType.NO_MATCH -> 1
|
|
MatchType.NO_DATA -> 0
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private const val MATCHER_PREFS_NAME = "face_matcher_config"
|
|
private const val KEY_EUCLIDEAN_THRESHOLD = "euclidean_threshold"
|
|
private const val KEY_COSINE_THRESHOLD = "cosine_threshold"
|
|
private const val DEFAULT_EUCLIDEAN_THRESHOLD = 1.0f
|
|
private const val DEFAULT_COSINE_THRESHOLD = 0.6f
|
|
private const val MATCH_REFRESH_MS = 900L
|
|
private const val UNSURE_REFRESH_MS = 500L
|
|
private const val NO_MATCH_REFRESH_MS = 800L
|
|
private const val TRACK_STALE_MS = 900L
|
|
private const val TRACK_MOVE_THRESHOLD = 0.16f
|
|
private const val TRACK_SIZE_THRESHOLD = 0.20f
|
|
private const val MATCH_STABLE_CONFIDENCE = 0.82f
|
|
private const val ANALYSIS_INTERVAL_MS = 80L
|
|
private const val DETECTION_GRACE_MS = 700L
|
|
private val ANALYSIS_SIZE = Size(1280, 720)
|
|
}
|
|
}
|
|
|
|
private enum class AppMode {
|
|
HOME,
|
|
RECOGNITION,
|
|
UPLOAD
|
|
}
|
|
|
|
private data class RawDetection(
|
|
val bounds: Rect,
|
|
val result: RecognitionResult,
|
|
val confidence: Float?
|
|
)
|
|
|
|
private data class CachedRecognition(
|
|
val result: RecognitionResult,
|
|
val updatedAtMs: Long,
|
|
val lastSeenAtMs: Long,
|
|
val lastBounds: RectF,
|
|
val stableFrames: Int
|
|
)
|