From 1170e092f7b58e423458db2b0f229044fd6dc302 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Wed, 13 May 2026 20:50:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E4=B8=80=E4=B8=AA=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/studentfaceregistry/ImageUtils.kt | 89 ++++---- .../studentfaceregistry/MainActivity.kt | 205 +++++++++++++++--- .../studentfaceregistry/face/FaceEmbedder.kt | 50 +++-- .../studentfaceregistry/face/FaceMatcher.kt | 16 +- .../studentfaceregistry/face/FaceProcessor.kt | 57 ++++- .../face/ImagePreprocessor.kt | 18 +- .../face/SmartEnrollment.kt | 28 ++- .../studentfaceregistry/ui/OverlayView.kt | 4 +- 8 files changed, 343 insertions(+), 124 deletions(-) diff --git a/app/src/main/java/com/example/studentfaceregistry/ImageUtils.kt b/app/src/main/java/com/example/studentfaceregistry/ImageUtils.kt index 9c98dca..0e714e3 100644 --- a/app/src/main/java/com/example/studentfaceregistry/ImageUtils.kt +++ b/app/src/main/java/com/example/studentfaceregistry/ImageUtils.kt @@ -1,63 +1,72 @@ package com.example.studentfaceregistry import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.ImageFormat import android.graphics.Matrix -import android.graphics.Rect -import android.graphics.YuvImage import android.media.Image +import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageProxy -import java.io.ByteArrayOutputStream -import kotlin.math.min +@ExperimentalGetImage object ImageUtils { fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap { val image = imageProxy.image ?: error("ImageProxy does not contain an image.") - val nv21 = yuv420ToNv21(image) - val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null) - val output = ByteArrayOutputStream() - yuvImage.compressToJpeg(Rect(0, 0, image.width, image.height), 90, output) - val bitmap = BitmapFactory.decodeByteArray(output.toByteArray(), 0, output.size()) + val bitmap = yuv420ToBitmap(image) + if (imageProxy.imageInfo.rotationDegrees == 0) { + return bitmap + } val matrix = Matrix().apply { postRotate(imageProxy.imageInfo.rotationDegrees.toFloat()) } return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) } - private fun yuv420ToNv21(image: Image): ByteArray { + private fun yuv420ToBitmap(image: Image): Bitmap { val width = image.width val height = image.height - val ySize = width * height - val chromaSize = width * height / 4 - val nv21 = ByteArray(ySize + chromaSize * 2) - copyPlane(image.planes[0], width, height, nv21, 0, 1) - copyPlane(image.planes[2], width / 2, height / 2, nv21, ySize, 2) - copyPlane(image.planes[1], width / 2, height / 2, nv21, ySize + 1, 2) - return nv21 - } + val yPlane = image.planes[0] + val uPlane = image.planes[1] + val vPlane = image.planes[2] - private fun copyPlane( - plane: Image.Plane, - width: Int, - height: Int, - output: ByteArray, - offset: Int, - outputPixelStride: Int - ) { - val buffer = plane.buffer - val row = ByteArray(plane.rowStride) - var outputIndex = offset - for (rowIndex in 0 until height) { - val bytesToRead = min(plane.rowStride, buffer.remaining()) - buffer.get(row, 0, bytesToRead) - for (colIndex in 0 until width) { - val inputIndex = colIndex * plane.pixelStride - if (inputIndex < bytesToRead && outputIndex < output.size) { - output[outputIndex] = row[inputIndex] - } - outputIndex += outputPixelStride + val yBuffer = yPlane.buffer + val uBuffer = uPlane.buffer + val vBuffer = vPlane.buffer + val pixels = IntArray(width * height) + + val yRowStride = yPlane.rowStride + val yPixelStride = yPlane.pixelStride + val uRowStride = uPlane.rowStride + val uPixelStride = uPlane.pixelStride + val vRowStride = vPlane.rowStride + val vPixelStride = vPlane.pixelStride + + var pixelIndex = 0 + for (row in 0 until height) { + val yRow = row * yRowStride + val uvRow = (row shr 1) * uRowStride + val vvRow = (row shr 1) * vRowStride + for (col in 0 until width) { + val y = (yBuffer.get(yRow + col * yPixelStride).toInt() and 0xFF) - 16 + val u = (uBuffer.get(uvRow + (col shr 1) * uPixelStride).toInt() and 0xFF) - 128 + val v = (vBuffer.get(vvRow + (col shr 1) * vPixelStride).toInt() and 0xFF) - 128 + + val y1192 = 1192 * y.coerceAtLeast(0) + var r = y1192 + 1634 * v + var g = y1192 - 833 * v - 400 * u + var b = y1192 + 2066 * u + + r = r.coerceIn(0, 262143) + g = g.coerceIn(0, 262143) + b = b.coerceIn(0, 262143) + + pixels[pixelIndex++] = -0x1000000 or + ((r shl 6) and 0x00FF0000) or + ((g shr 2) and 0x0000FF00) or + ((b shr 10) and 0x000000FF) } } + + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { + setPixels(pixels, 0, width, 0, 0, width, height) + } } } diff --git a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt index 5f4d3e6..3d1af61 100644 --- a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt +++ b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt @@ -3,6 +3,7 @@ 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 @@ -22,6 +23,7 @@ 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 @@ -46,6 +48,7 @@ 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 @@ -81,6 +84,8 @@ class MainActivity : AppCompatActivity() { @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 val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -412,6 +417,8 @@ class MainActivity : AppCompatActivity() { cosineThresholdInput.setText(formatThreshold(cosineThreshold)) recognitionHistory.clear() recognitionCache.clear() + lastVisibleDetections = emptyList() + lastVisibleDetectionsAt = 0L overlayView.update(emptyList()) if (hasCameraPermission()) { @@ -472,6 +479,8 @@ class MainActivity : AppCompatActivity() { cameraProvider = null recognitionHistory.clear() recognitionCache.clear() + lastVisibleDetections = emptyList() + lastVisibleDetectionsAt = 0L overlayView.update(emptyList()) processor?.close() processor = null @@ -511,10 +520,17 @@ class MainActivity : AppCompatActivity() { if (faces.isEmpty()) { withContext(Dispatchers.Main) { if (currentMode == AppMode.RECOGNITION) { - recognitionHistory.clear() - recognitionCache.clear() - overlayView.update(emptyList()) - updateRecognitionStatus(emptyList()) + 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 @@ -522,19 +538,15 @@ class MainActivity : AppCompatActivity() { val activeTrackIds = faces.mapNotNull { it.trackingId }.toSet() val facesNeedingRefresh = faces.mapIndexedNotNull { index, face -> - if (shouldRefreshRecognition(face.trackingId, now)) index to face else null + if (shouldRefreshRecognition(face, now)) index to face else null } val refreshedResults = mutableMapOf() if (facesNeedingRefresh.isNotEmpty()) { val bitmap = ImageUtils.imageProxyToBitmap(image) facesNeedingRefresh.forEach { (index, face) -> - val embedding = faceProcessor.embedFace(bitmap, face) - val refreshed = matcher.findNearest(embedding, students) + val refreshed = refreshRecognition(faceProcessor, bitmap, face) refreshedResults[index] = refreshed - face.trackingId?.let { trackId -> - recognitionCache[trackId] = CachedRecognition(refreshed, now) - } } } @@ -543,17 +555,18 @@ class MainActivity : AppCompatActivity() { RawDetection( bounds = face.boundingBox, result = result, - confidence = result.confidence + confidence = if (result.student != null) result.confidence else null ) } - recognitionHistory.keys.retainAll(activeTrackIds) - recognitionCache.keys.retainAll(activeTrackIds) + pruneTrackCache(now, activeTrackIds) withContext(Dispatchers.Main) { if (currentMode == AppMode.RECOGNITION) { val detections = mapDetectionsToPreview(image, rawDetections) - overlayView.update(detections.filter { it.result.isMatched }) + overlayView.update(detections) updateRecognitionStatus(detections) + lastVisibleDetections = detections + lastVisibleDetectionsAt = now } } } catch (e: Exception) { @@ -578,6 +591,8 @@ class MainActivity : AppCompatActivity() { 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()) { @@ -591,10 +606,21 @@ class MainActivity : AppCompatActivity() { val confidence = (it.confidence!! * 100).toInt() "${student.name}(${confidence}%)" } - recognitionStatusText.text = "识别到 ${matched.size}/$total 人:$names" + val remaining = total - matched.size + recognitionStatusText.text = if (remaining > 0) { + "识别到 ${matched.size}/$total 人:$names,另有 $remaining 人跟踪中" + } else { + "识别到 ${matched.size}/$total 人:$names" + } } } else if (unsure.isNotEmpty()) { recognitionStatusText.text = "疑似检测到 ${unsure.size} 人(不确定)" + } else if (tracking > 0) { + recognitionStatusText.text = if (noMatch > 0) { + "跟踪到 $total 人,识别中 $tracking 人,$noMatch 人未匹配" + } else { + "跟踪到 $total 人,识别中 $tracking 人" + } } else { recognitionStatusText.text = "检测到 $total 张未入库人脸" } @@ -633,7 +659,9 @@ class MainActivity : AppCompatActivity() { val current = processor if (current != null) return current - val created = runCatching { FaceProcessor(this) } + val created = runCatching { + FaceProcessor(this).also { it.warmUp() } + } .onFailure { val message = it.message ?: "unknown error" recognitionStatusText.text = "模型初始化失败:$message" @@ -770,14 +798,86 @@ class MainActivity : AppCompatActivity() { 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 + 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(trackId: Int?, now: Long): Boolean { - if (trackId == null) return true + private fun shouldRefreshRecognition(face: Face, now: Long): Boolean { + val trackId = face.trackingId ?: return true val cached = recognitionCache[trackId] ?: return true - return now - cached.updatedAtMs >= RECOGNITION_REFRESH_MS + 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] } + val needsEscalation = cached == null || + !cached.result.isMatched || + primary.matchType != MatchType.MATCH || + primary.confidence < cached.result.confidence - 0.05f || + hasTrackMovedSignificantly(cached.lastBounds, face.boundingBox) + + 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( @@ -786,16 +886,55 @@ class MainActivity : AppCompatActivity() { now: Long ): RecognitionResult { val trackId = face.trackingId + val previousState = trackId?.let { recognitionCache[it] } + val previousResult = previousState?.result val directResult = when { refreshedResult != null -> refreshedResult - trackId != null && recognitionCache.containsKey(trackId) -> recognitionCache.getValue(trackId).result + previousResult != null -> previousResult else -> RecognitionResult(null, Float.MAX_VALUE, 0f, MatchType.NO_DATA) } val stabilized = stabilizeRecognition(face, directResult) - if (trackId != null) { - recognitionCache[trackId] = CachedRecognition(stabilized, now) + 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 } - return stabilized } companion object { @@ -804,8 +943,15 @@ class MainActivity : AppCompatActivity() { 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 RECOGNITION_REFRESH_MS = 400L - private const val ANALYSIS_INTERVAL_MS = 120L + private const val MATCH_REFRESH_MS = 900L + private const val UNSURE_REFRESH_MS = 260L + private const val NO_MATCH_REFRESH_MS = 160L + 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(640, 480) } } @@ -824,5 +970,8 @@ private data class RawDetection( private data class CachedRecognition( val result: RecognitionResult, - val updatedAtMs: Long + val updatedAtMs: Long, + val lastSeenAtMs: Long, + val lastBounds: RectF, + val stableFrames: Int ) diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt index cebe93b..2bf4cb3 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt @@ -28,6 +28,8 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp private val inputSize: Int private val outputSize: Int private val accelerator: Accelerator + private val inputPixels: IntArray + private val inputValues: FloatArray // ArcFace 标准化参数 (RGB) private val mean = floatArrayOf(127.5f, 127.5f, 127.5f) @@ -42,6 +44,8 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp val inputElements = inputBuffer.readFloat().size inputSize = inferInputSize(inputElements) outputSize = outputBuffer.readFloat().size + inputPixels = IntArray(inputSize * inputSize) + inputValues = FloatArray(inputSize * inputSize * 3) Log.i(TAG, "FaceEmbedder initialized with accelerator=$accelerator") } @@ -55,41 +59,45 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp val resized = Bitmap.createScaledBitmap(face, inputSize, inputSize, true) // 准备输入数据 - val input = FloatArray(inputSize * inputSize * 3) - val pixels = IntArray(inputSize * inputSize) - resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize) + resized.getPixels(inputPixels, 0, inputSize, 0, 0, inputSize, inputSize) var index = 0 - for (color in pixels) { - val r = ((color shr 16) and 0xFF).toFloat() - val g = ((color shr 8) and 0xFF).toFloat() - val b = (color and 0xFF).toFloat() - - when (modelType) { - ModelType.FACENET -> { - // FaceNet 归一化:(pixel - 127.5) / 127.5 - input[index++] = (r - 127.5f) / 127.5f - input[index++] = (g - 127.5f) / 127.5f - input[index++] = (b - 127.5f) / 127.5f + when (modelType) { + ModelType.FACENET -> { + for (color in inputPixels) { + val r = ((color shr 16) and 0xFF).toFloat() + val g = ((color shr 8) and 0xFF).toFloat() + val b = (color and 0xFF).toFloat() + inputValues[index++] = (r - 127.5f) / 127.5f + inputValues[index++] = (g - 127.5f) / 127.5f + inputValues[index++] = (b - 127.5f) / 127.5f } - ModelType.ARCFACE -> { - // ArcFace 归一化:(pixel - mean) / std - // 等价于 (pixel - 127.5) / 127.5,但语义更清晰 - input[index++] = (r - mean[0]) / std[0] - input[index++] = (g - mean[1]) / std[1] - input[index++] = (b - mean[2]) / std[2] + } + ModelType.ARCFACE -> { + for (color in inputPixels) { + val r = ((color shr 16) and 0xFF).toFloat() + val g = ((color shr 8) and 0xFF).toFloat() + val b = (color and 0xFF).toFloat() + inputValues[index++] = (r - mean[0]) / std[0] + inputValues[index++] = (g - mean[1]) / std[1] + inputValues[index++] = (b - mean[2]) / std[2] } } } // 执行推理 - inputBuffer.writeFloat(input) + inputBuffer.writeFloat(inputValues) model.run(listOf(inputBuffer), listOf(outputBuffer)) // L2 归一化输出特征 return l2Normalize(outputBuffer.readFloat()) } + fun warmUp() { + val bitmap = Bitmap.createBitmap(inputSize, inputSize, Bitmap.Config.ARGB_8888) + embed(bitmap) + } + override fun close() { model.close() } diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt index 3af9c9c..5dabe41 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt @@ -44,7 +44,7 @@ class FaceMatcher( // 判断匹配类型 val matchType = determineMatchType(nearestEuclidean, nearestCosine, secondNearestEuclidean) - return if (nearest != null && matchType == MatchType.MATCH) { + return if (nearest != null && (matchType == MatchType.MATCH || matchType == MatchType.UNSURE)) { RecognitionResult(nearest, nearestEuclidean, nearestCosine, matchType) } else { RecognitionResult(null, nearestEuclidean, nearestCosine, matchType) @@ -113,21 +113,19 @@ class FaceMatcher( cosineSim: Float, secondNearestDist: Float ): MatchType { - // 同时满足欧氏距离和余弦相似度阈值 val euclideanMatch = euclideanDist <= euclideanThreshold val cosineMatch = cosineSim >= cosineThreshold - - // 检查是否是明显最优(与第二名的差距) val gap = secondNearestDist - euclideanDist - val isClearlyBest = gap > 0.3f + val isClearlyBest = gap >= 0.12f return when { - // 同时满足两个阈值,或者满足一个且明显优于其他 - (euclideanMatch && cosineMatch) || (euclideanMatch && isClearlyBest) || (cosineMatch && isClearlyBest) -> { + euclideanMatch && cosineMatch -> { MatchType.MATCH } - // 只满足一个阈值,但不明显优于其他 - euclideanMatch || cosineSim > (cosineThreshold * 0.9f) -> { + (cosineMatch && isClearlyBest) || (euclideanMatch && isClearlyBest) -> { + MatchType.MATCH + } + euclideanMatch || cosineMatch || cosineSim >= (cosineThreshold - 0.05f) -> { MatchType.UNSURE } else -> { diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt index 49c8d1e..d40400c 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt @@ -23,7 +23,7 @@ class FaceProcessor(context: Context) : AutoCloseable { private val realtimeDetector = FaceDetection.getClient( FaceDetectorOptions.Builder() .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) - .setMinFaceSize(0.08f) + .setMinFaceSize(0.06f) .enableTracking() .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL) .build() @@ -117,9 +117,10 @@ class FaceProcessor(context: Context) : AutoCloseable { * 注册用的人脸检测(高精度) */ private suspend fun detectFacesForEnrollment(bitmap: Bitmap): List { - val accurateFaces = enrollmentDetector.process(InputImage.fromBitmap(bitmap, 0)).await() + val image = InputImage.fromBitmap(bitmap, 0) + val accurateFaces = enrollmentDetector.process(image).await() if (accurateFaces.isNotEmpty()) return accurateFaces - return realtimeDetector.process(InputImage.fromBitmap(bitmap, 0)).await() + return realtimeDetector.process(image).await() } /** @@ -128,11 +129,27 @@ class FaceProcessor(context: Context) : AutoCloseable { fun embedFace(bitmap: Bitmap, face: Face): FloatArray { val crop = requireNotNull(safeCropRect(bitmap, face.boundingBox)) { "人脸区域超出图片边界。" } val cropped = Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height()) - val aligned = alignFaceWithLandmarks(cropped, face, crop.left, crop.top) + val aligned = alignFaceForRecognition(cropped, face, crop.left, crop.top) val preprocessed = preprocessor.preprocessRealtime(aligned) return embedder.embed(preprocessed) } + fun embedFaceCandidates(bitmap: Bitmap, face: Face): List { + val embeddings = mutableListOf() + for (paddingRatio in FACE_PADDING_RATIOS) { + val crop = safeCropRect(bitmap, face.boundingBox, paddingRatio) ?: continue + val cropped = Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height()) + val aligned = alignFaceForRecognition(cropped, face, crop.left, crop.top) + val preprocessed = preprocessor.preprocessRealtime(aligned) + embeddings.add(embedder.embed(preprocessed)) + } + return embeddings + } + + fun warmUp() { + embedder.warmUp() + } + override fun close() { realtimeDetector.close() enrollmentDetector.close() @@ -143,10 +160,14 @@ class FaceProcessor(context: Context) : AutoCloseable { * 计算安全的裁剪区域(带 padding) */ private fun safeCropRect(bitmap: Bitmap, box: Rect): Rect? { + return safeCropRect(bitmap, box, 0.25f) + } + + private fun safeCropRect(bitmap: Bitmap, box: Rect, paddingRatio: Float): Rect? { if (box.width() <= 0 || box.height() <= 0) return null // 增加 padding 比例,保留更多上下文信息 - val padding = (maxOf(box.width(), box.height()) * 0.25f).toInt() + val padding = (maxOf(box.width(), box.height()) * paddingRatio).toInt() val left = (box.left - padding).coerceIn(0, bitmap.width) val top = (box.top - padding).coerceIn(0, bitmap.height) @@ -160,4 +181,30 @@ class FaceProcessor(context: Context) : AutoCloseable { return Rect(left, top, right, bottom) } + + private fun alignFaceForRecognition( + faceBitmap: Bitmap, + face: Face, + faceLeft: Int, + faceTop: Int + ): Bitmap { + val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE) + val rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE) + if (leftEye != null && rightEye != null) { + return aligner.align( + bitmap = faceBitmap, + leftEyeX = leftEye.position.x, + leftEyeY = leftEye.position.y, + rightEyeX = rightEye.position.x, + rightEyeY = rightEye.position.y, + faceLeft = faceLeft, + faceTop = faceTop + ) + } + return aligner.alignWithEstimation(faceBitmap, 0, 0, face.headEulerAngleZ) + } + + companion object { + private val FACE_PADDING_RATIOS = floatArrayOf(0.18f, 0.34f) + } } diff --git a/app/src/main/java/com/example/studentfaceregistry/face/ImagePreprocessor.kt b/app/src/main/java/com/example/studentfaceregistry/face/ImagePreprocessor.kt index 8850123..c1ddccf 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/ImagePreprocessor.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/ImagePreprocessor.kt @@ -16,22 +16,22 @@ import kotlin.math.min class ImagePreprocessor { /** - * 完整的预处理流程: - * 1. 亮度/对比度增强 - * 2. 直方图均衡化(简化版) - * 3. 转换为适合模型输入的格式 + * 统一的人脸预处理流程。 + * + * 识别和注册都走同一套轻量增强,减少两条链路的特征分布差异。 */ fun preprocess(bitmap: Bitmap): Bitmap { - // 先进行亮度对比度增强 - val enhanced = enhanceBrightnessContrast(bitmap) - // 再进行直方图均衡化 - return histogramEqualize(enhanced) + return normalizeForEmbedding(bitmap) } /** - * 实时识别场景使用轻量预处理,减少每帧 CPU 开销。 + * 实时识别场景使用同一套轻量预处理,避免额外的帧间波动。 */ fun preprocessRealtime(bitmap: Bitmap): Bitmap { + return normalizeForEmbedding(bitmap) + } + + private fun normalizeForEmbedding(bitmap: Bitmap): Bitmap { return enhanceBrightnessContrast(bitmap, brightness = 0.06f, contrast = 1.08f) } diff --git a/app/src/main/java/com/example/studentfaceregistry/face/SmartEnrollment.kt b/app/src/main/java/com/example/studentfaceregistry/face/SmartEnrollment.kt index 3136af6..7328f08 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/SmartEnrollment.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/SmartEnrollment.kt @@ -79,17 +79,20 @@ class SmartEnrollment(context: Context) : AutoCloseable { faceInfo.faceArea } - // 裁剪人脸 - val croppedFace = cropFace(bitmap, largestFace.cropRect) + val embeddings = mutableListOf() + for (paddingRatio in IMAGE_PADDING_RATIOS) { + val cropRect = safeCropRect(bitmap, largestFace.face.boundingBox, paddingRatio) ?: continue + val croppedFace = cropFace(bitmap, cropRect) + val alignedFace = alignFace(croppedFace, largestFace.face, cropRect) + val preprocessedFace = preprocessor.preprocess(alignedFace) + embeddings.add(embedder.embed(preprocessedFace)) + } - // 对齐 - val alignedFace = alignFace(croppedFace, largestFace.face, largestFace.cropRect) + if (embeddings.isEmpty()) { + return EnrollmentResult.Failure("未检测到可用人脸,请换更清晰或更正面的照片。") + } - // 预处理 - val preprocessedFace = preprocessor.preprocess(alignedFace) - - // 提取特征 - val embedding = embedder.embed(preprocessedFace) + val embedding = fuseEmbeddings(embeddings) EnrollmentResult.Success(embedding, EnrollmentSourceType.IMAGE) } catch (e: Exception) { @@ -344,9 +347,13 @@ class SmartEnrollment(context: Context) : AutoCloseable { } private fun safeCropRect(bitmap: Bitmap, box: Rect): Rect? { + return safeCropRect(bitmap, box, 0.25f) + } + + private fun safeCropRect(bitmap: Bitmap, box: Rect, paddingRatio: Float): Rect? { if (box.width() <= 0 || box.height() <= 0) return null - val padding = (maxOf(box.width(), box.height()) * 0.25f).toInt() + val padding = (maxOf(box.width(), box.height()) * paddingRatio).toInt() val left = (box.left - padding).coerceIn(0, bitmap.width) val top = (box.top - padding).coerceIn(0, bitmap.height) val right = (box.right + padding).coerceIn(0, bitmap.width) @@ -422,6 +429,7 @@ class SmartEnrollment(context: Context) : AutoCloseable { companion object { private val VIDEO_EXTENSIONS = setOf(".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv", ".webm", ".3gp") private val VIDEO_MIME_TYPES = setOf("application/mp4") + private val IMAGE_PADDING_RATIOS = floatArrayOf(0.18f, 0.25f, 0.34f) } } 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 c6cfbb2..442663b 100644 --- a/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt +++ b/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt @@ -48,7 +48,7 @@ class OverlayView @JvmOverloads constructor( private val colorMatch = Color.rgb(34, 197, 94) // 绿色 - 匹配成功 private val colorUnsure = Color.rgb(234, 179, 8) // 黄色 - 不确定 private val colorNoMatch = Color.rgb(239, 68, 68) // 红色 - 未匹配 - private val colorUnknown = Color.rgb(59, 130, 246) // 蓝色 - 未入库 + private val colorUnknown = Color.rgb(59, 130, 246) // 蓝色 - 识别中 private var detections: List = emptyList() @@ -136,7 +136,7 @@ data class DetectionUi( result.isMatched -> "${result.student?.name} (${result.student?.studentNo})" result.matchType == MatchType.UNSURE -> "疑似:${result.student?.name}" result.student == null && result.distance != Float.MAX_VALUE -> "未匹配" - else -> "未入库" + else -> "识别中" } } }