Files
student_recognize/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt
2026-08-07 11:02:08 +08:00

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
)