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 = 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>() private val recognitionCache = mutableMapOf() private var lastVisibleDetections: List = 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() 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) { 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): List { 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) { 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 )