fixed some bugs
This commit is contained in:
@@ -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<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 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<RawDetection>): List<DetectionUi> {
|
||||
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?
|
||||
)
|
||||
|
||||
@@ -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<DetectionUi> = emptyList()
|
||||
private var imageWidth = 1
|
||||
private var imageHeight = 1
|
||||
|
||||
fun update(items: List<DetectionUi>, width: Int, height: Int) {
|
||||
fun update(items: List<DetectionUi>) {
|
||||
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
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user