From 4e875819b689d7df17527a1ce2ebe46b839cce88 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Sun, 10 May 2026 18:38:05 +0800 Subject: [PATCH] fixed some bugs --- .../studentfaceregistry/MainActivity.kt | 261 +++++++++++++++--- .../studentfaceregistry/ui/OverlayView.kt | 23 +- 2 files changed, 233 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt index db7d082..98119d0 100644 --- a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt +++ b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt @@ -1,14 +1,19 @@ package com.example.studentfaceregistry import android.Manifest +import android.content.SharedPreferences import android.content.pm.PackageManager +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 @@ -22,6 +27,8 @@ import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView +import androidx.camera.view.transform.CoordinateTransform +import androidx.camera.view.transform.ImageProxyTransformFactory import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.example.studentfaceregistry.data.Student @@ -29,7 +36,9 @@ 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 @@ -44,6 +53,9 @@ class MainActivity : AppCompatActivity() { 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 @@ -58,12 +70,22 @@ class MainActivity : AppCompatActivity() { private var cameraProvider: ProcessCameraProvider? = null private val repository by lazy { StudentRepository(this) } - private val matcher = FaceMatcher(euclideanThreshold = 1.0f, cosineThreshold = 0.6f) + private val matcherPrefs: SharedPreferences by lazy { + getSharedPreferences(MATCHER_PREFS_NAME, MODE_PRIVATE) + } + private val imageProxyTransformFactory = ImageProxyTransformFactory().apply { + setUsingCropRect(false) + setUsingRotationDegrees(false) + } 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 permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -80,6 +102,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + loadMatcherConfig() setContentView(createContentView()) lifecycleScope.launch { @@ -197,6 +220,74 @@ class MainActivity : AppCompatActivity() { } 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() } + }) + + addView(matcherConfigPanel) + val cameraFrame = FrameLayout(this@MainActivity).apply { layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, @@ -206,6 +297,7 @@ class MainActivity : AppCompatActivity() { } 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)) @@ -229,7 +321,7 @@ class MainActivity : AppCompatActivity() { text = if (recognitionEnabled) "暂停识别" else "继续识别" recognitionStatusText.text = if (recognitionEnabled) "正在识别" else "识别已暂停" if (!recognitionEnabled) { - overlayView.update(emptyList(), 1, 1) + overlayView.update(emptyList()) } updateAnalyzerState() } @@ -320,7 +412,10 @@ class MainActivity : AppCompatActivity() { lastAnalysisAt = 0L pauseRecognitionButton.text = "暂停识别" recognitionStatusText.text = "正在准备识别服务..." - overlayView.update(emptyList(), 1, 1) + matcherConfigSummaryText.text = matcherConfigSummary() + euclideanThresholdInput.setText(formatThreshold(euclideanThreshold)) + cosineThresholdInput.setText(formatThreshold(cosineThreshold)) + overlayView.update(emptyList()) if (hasCameraPermission()) { startRecognitionSession() @@ -333,29 +428,42 @@ class MainActivity : AppCompatActivity() { if (currentMode != AppMode.RECOGNITION) return val faceProcessor = ensureProcessor() ?: return - val providerFuture = ProcessCameraProvider.getInstance(this) - providerFuture.addListener({ - val provider = providerFuture.get() - cameraProvider = provider - if (currentMode != AppMode.RECOGNITION) { + 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() + .setTargetResolution(ANALYSIS_SIZE) + .build() + .also { it.setSurfaceProvider(previewView.surfaceProvider) } + val analysis = ImageAnalysis.Builder() + .setTargetResolution(ANALYSIS_SIZE) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + val viewPort = previewView.viewPort provider.unbindAll() - return@addListener - } - - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - val analysis = ImageAnalysis.Builder() - .setTargetResolution(ANALYSIS_SIZE) - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - - provider.unbindAll() - provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) - analysisUseCase = analysis - recognitionStatusText.text = if (faceProcessor === processor) "正在识别" else "识别服务已启动" - updateAnalyzerState() - }, ContextCompat.getMainExecutor(this)) + 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() { @@ -365,7 +473,7 @@ class MainActivity : AppCompatActivity() { analysisUseCase = null cameraProvider?.unbindAll() cameraProvider = null - overlayView.update(emptyList(), 1, 1) + overlayView.update(emptyList()) processor?.close() processor = null } @@ -404,7 +512,8 @@ class MainActivity : AppCompatActivity() { if (faces.isEmpty()) { withContext(Dispatchers.Main) { if (currentMode == AppMode.RECOGNITION) { - overlayView.update(emptyList(), 1, 1) + recognitionHistory.clear() + overlayView.update(emptyList()) updateRecognitionStatus(emptyList()) } } @@ -412,19 +521,22 @@ class MainActivity : AppCompatActivity() { } val bitmap = ImageUtils.imageProxyToBitmap(image) - val detections = faces.map { face -> + val activeTrackIds = faces.mapNotNull { it.trackingId }.toSet() + val rawDetections = faces.map { face -> val embedding = faceProcessor.embedFace(bitmap, face) - val result = matcher.findNearest(embedding, students) - DetectionUi( + val result = stabilizeRecognition(face, matcher.findNearest(embedding, students)) + RawDetection( bounds = face.boundingBox, result = result, confidence = result.confidence ) } + recognitionHistory.keys.retainAll(activeTrackIds) withContext(Dispatchers.Main) { if (currentMode == AppMode.RECOGNITION) { - overlayView.update(detections, bitmap.width, bitmap.height) + val detections = mapDetectionsToPreview(image, rawDetections) + overlayView.update(detections) updateRecognitionStatus(detections) } } @@ -524,8 +636,87 @@ class MainActivity : AppCompatActivity() { 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) + .apply() + + matcherConfigSummaryText.text = matcherConfigSummary() + recognitionStatusText.text = "识别参数已更新" + toast("识别参数已保存") + } + + private fun matcherConfigSummary(): String { + return "当前参数:欧氏 <= ${formatThreshold(euclideanThreshold)},余弦 >= ${formatThreshold(cosineThreshold)}" + } + + private fun formatThreshold(value: Float): String { + return String.format(java.util.Locale.US, "%.2f", value) + } + + private fun mapDetectionsToPreview(image: ImageProxy, rawDetections: List): List { + val target = previewView.outputTransform ?: return rawDetections.map { + DetectionUi(android.graphics.RectF(it.bounds), it.result, it.confidence) + } + val source = imageProxyTransformFactory.getOutputTransform(image) + val transform = CoordinateTransform(source, target) + return rawDetections.map { detection -> + val rect = RectF(detection.bounds) + transform.mapRect(rect) + DetectionUi(rect, detection.result, detection.confidence) + } + } + + 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 best = matched.groupBy { it.student?.id }.maxByOrNull { it.value.size } ?: return result + return if (best.value.size >= 2) best.value.last() else result + } + companion object { - private const val ANALYSIS_INTERVAL_MS = 250L + 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 ANALYSIS_INTERVAL_MS = 120L private val ANALYSIS_SIZE = Size(640, 480) } } @@ -535,3 +726,9 @@ private enum class AppMode { RECOGNITION, UPLOAD } + +private data class RawDetection( + val bounds: Rect, + val result: RecognitionResult, + val confidence: Float? +) diff --git a/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt b/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt index e078893..c6cfbb2 100644 --- a/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt +++ b/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt @@ -4,7 +4,7 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint -import android.graphics.Rect +import android.graphics.RectF import android.graphics.Typeface import android.util.AttributeSet import android.view.View @@ -51,13 +51,9 @@ class OverlayView @JvmOverloads constructor( private val colorUnknown = Color.rgb(59, 130, 246) // 蓝色 - 未入库 private var detections: List = emptyList() - private var imageWidth = 1 - private var imageHeight = 1 - fun update(items: List, width: Int, height: Int) { + fun update(items: List) { detections = items - imageWidth = width.coerceAtLeast(1) - imageHeight = height.coerceAtLeast(1) invalidate() } @@ -70,7 +66,7 @@ class OverlayView @JvmOverloads constructor( } private fun drawDetection(canvas: Canvas, item: DetectionUi, index: Int) { - val rect = mapRect(item.bounds) + val rect = item.bounds // 根据识别结果设置颜色 val boxColor = getBoxColor(item.result) @@ -124,24 +120,13 @@ class OverlayView @JvmOverloads constructor( else -> colorUnknown } } - - private fun mapRect(rect: Rect): android.graphics.RectF { - val scaleX = width.toFloat() / imageWidth - val scaleY = height.toFloat() / imageHeight - return android.graphics.RectF( - width - rect.right * scaleX, - rect.top * scaleY, - width - rect.left * scaleX, - rect.bottom * scaleY - ) - } } /** * 识别 UI 数据 */ data class DetectionUi( - val bounds: Rect, + val bounds: RectF, val result: RecognitionResult, val confidence: Float? = null ) {