改一个版

This commit is contained in:
2026-05-13 20:50:55 +08:00
parent 7998428587
commit 1170e092f7
8 changed files with 343 additions and 124 deletions

View File

@@ -1,63 +1,72 @@
package com.example.studentfaceregistry package com.example.studentfaceregistry
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageFormat
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.YuvImage
import android.media.Image import android.media.Image
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import java.io.ByteArrayOutputStream
import kotlin.math.min
@ExperimentalGetImage
object ImageUtils { object ImageUtils {
fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap { fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap {
val image = imageProxy.image ?: error("ImageProxy does not contain an image.") val image = imageProxy.image ?: error("ImageProxy does not contain an image.")
val nv21 = yuv420ToNv21(image) val bitmap = yuv420ToBitmap(image)
val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null) if (imageProxy.imageInfo.rotationDegrees == 0) {
val output = ByteArrayOutputStream() return bitmap
yuvImage.compressToJpeg(Rect(0, 0, image.width, image.height), 90, output) }
val bitmap = BitmapFactory.decodeByteArray(output.toByteArray(), 0, output.size())
val matrix = Matrix().apply { val matrix = Matrix().apply {
postRotate(imageProxy.imageInfo.rotationDegrees.toFloat()) postRotate(imageProxy.imageInfo.rotationDegrees.toFloat())
} }
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) 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 width = image.width
val height = image.height val height = image.height
val ySize = width * height val yPlane = image.planes[0]
val chromaSize = width * height / 4 val uPlane = image.planes[1]
val nv21 = ByteArray(ySize + chromaSize * 2) val vPlane = image.planes[2]
copyPlane(image.planes[0], width, height, nv21, 0, 1)
copyPlane(image.planes[2], width / 2, height / 2, nv21, ySize, 2) val yBuffer = yPlane.buffer
copyPlane(image.planes[1], width / 2, height / 2, nv21, ySize + 1, 2) val uBuffer = uPlane.buffer
return nv21 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)
}
} }
private fun copyPlane( return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
plane: Image.Plane, setPixels(pixels, 0, width, 0, 0, width, height)
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
}
} }
} }
} }

View File

@@ -3,6 +3,7 @@ package com.example.studentfaceregistry
import android.Manifest import android.Manifest
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Rect import android.graphics.Rect
import android.graphics.RectF import android.graphics.RectF
import android.graphics.Typeface import android.graphics.Typeface
@@ -22,6 +23,7 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview import androidx.camera.core.Preview
@@ -46,6 +48,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ExperimentalGetImage
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var countText: TextView private lateinit var countText: TextView
private lateinit var previewView: PreviewView private lateinit var previewView: PreviewView
@@ -81,6 +84,8 @@ class MainActivity : AppCompatActivity() {
@Volatile private var matcher = FaceMatcher(DEFAULT_EUCLIDEAN_THRESHOLD, DEFAULT_COSINE_THRESHOLD) @Volatile private var matcher = FaceMatcher(DEFAULT_EUCLIDEAN_THRESHOLD, DEFAULT_COSINE_THRESHOLD)
private val recognitionHistory = mutableMapOf<Int, ArrayDeque<RecognitionResult>>() private val recognitionHistory = mutableMapOf<Int, ArrayDeque<RecognitionResult>>()
private val recognitionCache = mutableMapOf<Int, CachedRecognition>() private val recognitionCache = mutableMapOf<Int, CachedRecognition>()
private var lastVisibleDetections: List<DetectionUi> = emptyList()
private var lastVisibleDetectionsAt = 0L
private val permissionLauncher = registerForActivityResult( private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
@@ -412,6 +417,8 @@ class MainActivity : AppCompatActivity() {
cosineThresholdInput.setText(formatThreshold(cosineThreshold)) cosineThresholdInput.setText(formatThreshold(cosineThreshold))
recognitionHistory.clear() recognitionHistory.clear()
recognitionCache.clear() recognitionCache.clear()
lastVisibleDetections = emptyList()
lastVisibleDetectionsAt = 0L
overlayView.update(emptyList()) overlayView.update(emptyList())
if (hasCameraPermission()) { if (hasCameraPermission()) {
@@ -472,6 +479,8 @@ class MainActivity : AppCompatActivity() {
cameraProvider = null cameraProvider = null
recognitionHistory.clear() recognitionHistory.clear()
recognitionCache.clear() recognitionCache.clear()
lastVisibleDetections = emptyList()
lastVisibleDetectionsAt = 0L
overlayView.update(emptyList()) overlayView.update(emptyList())
processor?.close() processor?.close()
processor = null processor = null
@@ -511,30 +520,33 @@ class MainActivity : AppCompatActivity() {
if (faces.isEmpty()) { if (faces.isEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (currentMode == AppMode.RECOGNITION) { if (currentMode == AppMode.RECOGNITION) {
if (now - lastVisibleDetectionsAt <= DETECTION_GRACE_MS && lastVisibleDetections.isNotEmpty()) {
overlayView.update(lastVisibleDetections)
updateRecognitionStatus(lastVisibleDetections)
} else {
recognitionHistory.clear() recognitionHistory.clear()
recognitionCache.clear() recognitionCache.clear()
lastVisibleDetections = emptyList()
lastVisibleDetectionsAt = 0L
overlayView.update(emptyList()) overlayView.update(emptyList())
updateRecognitionStatus(emptyList()) updateRecognitionStatus(emptyList())
} }
} }
}
return@launch return@launch
} }
val activeTrackIds = faces.mapNotNull { it.trackingId }.toSet() val activeTrackIds = faces.mapNotNull { it.trackingId }.toSet()
val facesNeedingRefresh = faces.mapIndexedNotNull { index, face -> 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<Int, RecognitionResult>() val refreshedResults = mutableMapOf<Int, RecognitionResult>()
if (facesNeedingRefresh.isNotEmpty()) { if (facesNeedingRefresh.isNotEmpty()) {
val bitmap = ImageUtils.imageProxyToBitmap(image) val bitmap = ImageUtils.imageProxyToBitmap(image)
facesNeedingRefresh.forEach { (index, face) -> facesNeedingRefresh.forEach { (index, face) ->
val embedding = faceProcessor.embedFace(bitmap, face) val refreshed = refreshRecognition(faceProcessor, bitmap, face)
val refreshed = matcher.findNearest(embedding, students)
refreshedResults[index] = refreshed refreshedResults[index] = refreshed
face.trackingId?.let { trackId ->
recognitionCache[trackId] = CachedRecognition(refreshed, now)
}
} }
} }
@@ -543,17 +555,18 @@ class MainActivity : AppCompatActivity() {
RawDetection( RawDetection(
bounds = face.boundingBox, bounds = face.boundingBox,
result = result, result = result,
confidence = result.confidence confidence = if (result.student != null) result.confidence else null
) )
} }
recognitionHistory.keys.retainAll(activeTrackIds) pruneTrackCache(now, activeTrackIds)
recognitionCache.keys.retainAll(activeTrackIds)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (currentMode == AppMode.RECOGNITION) { if (currentMode == AppMode.RECOGNITION) {
val detections = mapDetectionsToPreview(image, rawDetections) val detections = mapDetectionsToPreview(image, rawDetections)
overlayView.update(detections.filter { it.result.isMatched }) overlayView.update(detections)
updateRecognitionStatus(detections) updateRecognitionStatus(detections)
lastVisibleDetections = detections
lastVisibleDetectionsAt = now
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -578,6 +591,8 @@ class MainActivity : AppCompatActivity() {
val matched = detections.filter { it.result.isMatched } val matched = detections.filter { it.result.isMatched }
val unsure = detections.filter { it.result.matchType == MatchType.UNSURE } 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 val total = detections.size
if (matched.isNotEmpty()) { if (matched.isNotEmpty()) {
@@ -591,10 +606,21 @@ class MainActivity : AppCompatActivity() {
val confidence = (it.confidence!! * 100).toInt() val confidence = (it.confidence!! * 100).toInt()
"${student.name}(${confidence}%)" "${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()) { } else if (unsure.isNotEmpty()) {
recognitionStatusText.text = "疑似检测到 ${unsure.size} 人(不确定)" recognitionStatusText.text = "疑似检测到 ${unsure.size} 人(不确定)"
} else if (tracking > 0) {
recognitionStatusText.text = if (noMatch > 0) {
"跟踪到 $total 人,识别中 $tracking 人,$noMatch 人未匹配"
} else {
"跟踪到 $total 人,识别中 $tracking"
}
} else { } else {
recognitionStatusText.text = "检测到 $total 张未入库人脸" recognitionStatusText.text = "检测到 $total 张未入库人脸"
} }
@@ -633,7 +659,9 @@ class MainActivity : AppCompatActivity() {
val current = processor val current = processor
if (current != null) return current if (current != null) return current
val created = runCatching { FaceProcessor(this) } val created = runCatching {
FaceProcessor(this).also { it.warmUp() }
}
.onFailure { .onFailure {
val message = it.message ?: "unknown error" val message = it.message ?: "unknown error"
recognitionStatusText.text = "模型初始化失败:$message" recognitionStatusText.text = "模型初始化失败:$message"
@@ -770,14 +798,86 @@ class MainActivity : AppCompatActivity() {
val matched = history.filter { it.isMatched } val matched = history.filter { it.isMatched }
if (matched.isEmpty()) return result if (matched.isEmpty()) return result
val best = matched.groupBy { it.student?.id }.maxByOrNull { it.value.size } ?: return result val grouped = matched.groupBy { it.student?.id }
return if (best.value.size >= 2) best.value.last() else result 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 { private fun shouldRefreshRecognition(face: Face, now: Long): Boolean {
if (trackId == null) return true val trackId = face.trackingId ?: return true
val cached = recognitionCache[trackId] ?: 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<Int>) {
recognitionCache.entries.removeAll { (trackId, state) ->
trackId !in activeTrackIds && now - state.lastSeenAtMs > TRACK_STALE_MS
}
recognitionHistory.entries.removeAll { (trackId, _) ->
trackId !in recognitionCache
}
}
private fun refreshRecognition(
faceProcessor: FaceProcessor,
bitmap: Bitmap,
face: Face
): RecognitionResult {
val primary = matcher.findNearest(faceProcessor.embedFace(bitmap, face), students)
val cached = face.trackingId?.let { recognitionCache[it] }
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( private fun resolveRecognitionResult(
@@ -786,16 +886,55 @@ class MainActivity : AppCompatActivity() {
now: Long now: Long
): RecognitionResult { ): RecognitionResult {
val trackId = face.trackingId val trackId = face.trackingId
val previousState = trackId?.let { recognitionCache[it] }
val previousResult = previousState?.result
val directResult = when { val directResult = when {
refreshedResult != null -> refreshedResult 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) else -> RecognitionResult(null, Float.MAX_VALUE, 0f, MatchType.NO_DATA)
} }
val stabilized = stabilizeRecognition(face, directResult) val stabilized = stabilizeRecognition(face, directResult)
if (trackId != null) { val finalResult = when {
recognitionCache[trackId] = CachedRecognition(stabilized, now) 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 { companion object {
@@ -804,8 +943,15 @@ class MainActivity : AppCompatActivity() {
private const val KEY_COSINE_THRESHOLD = "cosine_threshold" private const val KEY_COSINE_THRESHOLD = "cosine_threshold"
private const val DEFAULT_EUCLIDEAN_THRESHOLD = 1.0f private const val DEFAULT_EUCLIDEAN_THRESHOLD = 1.0f
private const val DEFAULT_COSINE_THRESHOLD = 0.6f private const val DEFAULT_COSINE_THRESHOLD = 0.6f
private const val RECOGNITION_REFRESH_MS = 400L private const val MATCH_REFRESH_MS = 900L
private const val ANALYSIS_INTERVAL_MS = 120L 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) private val ANALYSIS_SIZE = Size(640, 480)
} }
} }
@@ -824,5 +970,8 @@ private data class RawDetection(
private data class CachedRecognition( private data class CachedRecognition(
val result: RecognitionResult, val result: RecognitionResult,
val updatedAtMs: Long val updatedAtMs: Long,
val lastSeenAtMs: Long,
val lastBounds: RectF,
val stableFrames: Int
) )

View File

@@ -28,6 +28,8 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp
private val inputSize: Int private val inputSize: Int
private val outputSize: Int private val outputSize: Int
private val accelerator: Accelerator private val accelerator: Accelerator
private val inputPixels: IntArray
private val inputValues: FloatArray
// ArcFace 标准化参数 (RGB) // ArcFace 标准化参数 (RGB)
private val mean = floatArrayOf(127.5f, 127.5f, 127.5f) 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 val inputElements = inputBuffer.readFloat().size
inputSize = inferInputSize(inputElements) inputSize = inferInputSize(inputElements)
outputSize = outputBuffer.readFloat().size outputSize = outputBuffer.readFloat().size
inputPixels = IntArray(inputSize * inputSize)
inputValues = FloatArray(inputSize * inputSize * 3)
Log.i(TAG, "FaceEmbedder initialized with accelerator=$accelerator") 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 resized = Bitmap.createScaledBitmap(face, inputSize, inputSize, true)
// 准备输入数据 // 准备输入数据
val input = FloatArray(inputSize * inputSize * 3) resized.getPixels(inputPixels, 0, inputSize, 0, 0, inputSize, inputSize)
val pixels = IntArray(inputSize * inputSize)
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
var index = 0 var index = 0
for (color in pixels) { when (modelType) {
ModelType.FACENET -> {
for (color in inputPixels) {
val r = ((color shr 16) and 0xFF).toFloat() val r = ((color shr 16) and 0xFF).toFloat()
val g = ((color shr 8) and 0xFF).toFloat() val g = ((color shr 8) and 0xFF).toFloat()
val b = (color and 0xFF).toFloat() val b = (color and 0xFF).toFloat()
inputValues[index++] = (r - 127.5f) / 127.5f
when (modelType) { inputValues[index++] = (g - 127.5f) / 127.5f
ModelType.FACENET -> { inputValues[index++] = (b - 127.5f) / 127.5f
// 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
} }
ModelType.ARCFACE -> { ModelType.ARCFACE -> {
// ArcFace 归一化:(pixel - mean) / std for (color in inputPixels) {
// 等价于 (pixel - 127.5) / 127.5,但语义更清晰 val r = ((color shr 16) and 0xFF).toFloat()
input[index++] = (r - mean[0]) / std[0] val g = ((color shr 8) and 0xFF).toFloat()
input[index++] = (g - mean[1]) / std[1] val b = (color and 0xFF).toFloat()
input[index++] = (b - mean[2]) / std[2] 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)) model.run(listOf(inputBuffer), listOf(outputBuffer))
// L2 归一化输出特征 // L2 归一化输出特征
return l2Normalize(outputBuffer.readFloat()) return l2Normalize(outputBuffer.readFloat())
} }
fun warmUp() {
val bitmap = Bitmap.createBitmap(inputSize, inputSize, Bitmap.Config.ARGB_8888)
embed(bitmap)
}
override fun close() { override fun close() {
model.close() model.close()
} }

View File

@@ -44,7 +44,7 @@ class FaceMatcher(
// 判断匹配类型 // 判断匹配类型
val matchType = determineMatchType(nearestEuclidean, nearestCosine, secondNearestEuclidean) 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) RecognitionResult(nearest, nearestEuclidean, nearestCosine, matchType)
} else { } else {
RecognitionResult(null, nearestEuclidean, nearestCosine, matchType) RecognitionResult(null, nearestEuclidean, nearestCosine, matchType)
@@ -113,21 +113,19 @@ class FaceMatcher(
cosineSim: Float, cosineSim: Float,
secondNearestDist: Float secondNearestDist: Float
): MatchType { ): MatchType {
// 同时满足欧氏距离和余弦相似度阈值
val euclideanMatch = euclideanDist <= euclideanThreshold val euclideanMatch = euclideanDist <= euclideanThreshold
val cosineMatch = cosineSim >= cosineThreshold val cosineMatch = cosineSim >= cosineThreshold
// 检查是否是明显最优(与第二名的差距)
val gap = secondNearestDist - euclideanDist val gap = secondNearestDist - euclideanDist
val isClearlyBest = gap > 0.3f val isClearlyBest = gap >= 0.12f
return when { return when {
// 同时满足两个阈值,或者满足一个且明显优于其他 euclideanMatch && cosineMatch -> {
(euclideanMatch && cosineMatch) || (euclideanMatch && isClearlyBest) || (cosineMatch && isClearlyBest) -> {
MatchType.MATCH MatchType.MATCH
} }
// 只满足一个阈值,但不明显优于其他 (cosineMatch && isClearlyBest) || (euclideanMatch && isClearlyBest) -> {
euclideanMatch || cosineSim > (cosineThreshold * 0.9f) -> { MatchType.MATCH
}
euclideanMatch || cosineMatch || cosineSim >= (cosineThreshold - 0.05f) -> {
MatchType.UNSURE MatchType.UNSURE
} }
else -> { else -> {

View File

@@ -23,7 +23,7 @@ class FaceProcessor(context: Context) : AutoCloseable {
private val realtimeDetector = FaceDetection.getClient( private val realtimeDetector = FaceDetection.getClient(
FaceDetectorOptions.Builder() FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
.setMinFaceSize(0.08f) .setMinFaceSize(0.06f)
.enableTracking() .enableTracking()
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL) .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
.build() .build()
@@ -117,9 +117,10 @@ class FaceProcessor(context: Context) : AutoCloseable {
* 注册用的人脸检测(高精度) * 注册用的人脸检测(高精度)
*/ */
private suspend fun detectFacesForEnrollment(bitmap: Bitmap): List<Face> { private suspend fun detectFacesForEnrollment(bitmap: Bitmap): List<Face> {
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 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 { fun embedFace(bitmap: Bitmap, face: Face): FloatArray {
val crop = requireNotNull(safeCropRect(bitmap, face.boundingBox)) { "人脸区域超出图片边界。" } val crop = requireNotNull(safeCropRect(bitmap, face.boundingBox)) { "人脸区域超出图片边界。" }
val cropped = Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height()) 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) val preprocessed = preprocessor.preprocessRealtime(aligned)
return embedder.embed(preprocessed) return embedder.embed(preprocessed)
} }
fun embedFaceCandidates(bitmap: Bitmap, face: Face): List<FloatArray> {
val embeddings = mutableListOf<FloatArray>()
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() { override fun close() {
realtimeDetector.close() realtimeDetector.close()
enrollmentDetector.close() enrollmentDetector.close()
@@ -143,10 +160,14 @@ class FaceProcessor(context: Context) : AutoCloseable {
* 计算安全的裁剪区域(带 padding * 计算安全的裁剪区域(带 padding
*/ */
private fun safeCropRect(bitmap: Bitmap, box: Rect): Rect? { 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 if (box.width() <= 0 || box.height() <= 0) return null
// 增加 padding 比例,保留更多上下文信息 // 增加 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 left = (box.left - padding).coerceIn(0, bitmap.width)
val top = (box.top - padding).coerceIn(0, bitmap.height) val top = (box.top - padding).coerceIn(0, bitmap.height)
@@ -160,4 +181,30 @@ class FaceProcessor(context: Context) : AutoCloseable {
return Rect(left, top, right, bottom) 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)
}
} }

View File

@@ -16,22 +16,22 @@ import kotlin.math.min
class ImagePreprocessor { class ImagePreprocessor {
/** /**
* 完整的预处理流程 * 统一的人脸预处理流程
* 1. 亮度/对比度增强 *
* 2. 直方图均衡化(简化版) * 识别和注册都走同一套轻量增强,减少两条链路的特征分布差异。
* 3. 转换为适合模型输入的格式
*/ */
fun preprocess(bitmap: Bitmap): Bitmap { fun preprocess(bitmap: Bitmap): Bitmap {
// 先进行亮度对比度增强 return normalizeForEmbedding(bitmap)
val enhanced = enhanceBrightnessContrast(bitmap)
// 再进行直方图均衡化
return histogramEqualize(enhanced)
} }
/** /**
* 实时识别场景使用轻量预处理,减少每帧 CPU 开销 * 实时识别场景使用同一套轻量预处理,避免额外的帧间波动
*/ */
fun preprocessRealtime(bitmap: Bitmap): Bitmap { fun preprocessRealtime(bitmap: Bitmap): Bitmap {
return normalizeForEmbedding(bitmap)
}
private fun normalizeForEmbedding(bitmap: Bitmap): Bitmap {
return enhanceBrightnessContrast(bitmap, brightness = 0.06f, contrast = 1.08f) return enhanceBrightnessContrast(bitmap, brightness = 0.06f, contrast = 1.08f)
} }

View File

@@ -79,17 +79,20 @@ class SmartEnrollment(context: Context) : AutoCloseable {
faceInfo.faceArea faceInfo.faceArea
} }
// 裁剪人脸 val embeddings = mutableListOf<FloatArray>()
val croppedFace = cropFace(bitmap, largestFace.cropRect) 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, largestFace.cropRect) val alignedFace = alignFace(croppedFace, largestFace.face, cropRect)
// 预处理
val preprocessedFace = preprocessor.preprocess(alignedFace) val preprocessedFace = preprocessor.preprocess(alignedFace)
embeddings.add(embedder.embed(preprocessedFace))
}
// 提取特征 if (embeddings.isEmpty()) {
val embedding = embedder.embed(preprocessedFace) return EnrollmentResult.Failure("未检测到可用人脸,请换更清晰或更正面的照片。")
}
val embedding = fuseEmbeddings(embeddings)
EnrollmentResult.Success(embedding, EnrollmentSourceType.IMAGE) EnrollmentResult.Success(embedding, EnrollmentSourceType.IMAGE)
} catch (e: Exception) { } catch (e: Exception) {
@@ -344,9 +347,13 @@ class SmartEnrollment(context: Context) : AutoCloseable {
} }
private fun safeCropRect(bitmap: Bitmap, box: Rect): Rect? { 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 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 left = (box.left - padding).coerceIn(0, bitmap.width)
val top = (box.top - padding).coerceIn(0, bitmap.height) val top = (box.top - padding).coerceIn(0, bitmap.height)
val right = (box.right + padding).coerceIn(0, bitmap.width) val right = (box.right + padding).coerceIn(0, bitmap.width)
@@ -422,6 +429,7 @@ class SmartEnrollment(context: Context) : AutoCloseable {
companion object { companion object {
private val VIDEO_EXTENSIONS = setOf(".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv", ".webm", ".3gp") private val VIDEO_EXTENSIONS = setOf(".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv", ".webm", ".3gp")
private val VIDEO_MIME_TYPES = setOf("application/mp4") private val VIDEO_MIME_TYPES = setOf("application/mp4")
private val IMAGE_PADDING_RATIOS = floatArrayOf(0.18f, 0.25f, 0.34f)
} }
} }

View File

@@ -48,7 +48,7 @@ class OverlayView @JvmOverloads constructor(
private val colorMatch = Color.rgb(34, 197, 94) // 绿色 - 匹配成功 private val colorMatch = Color.rgb(34, 197, 94) // 绿色 - 匹配成功
private val colorUnsure = Color.rgb(234, 179, 8) // 黄色 - 不确定 private val colorUnsure = Color.rgb(234, 179, 8) // 黄色 - 不确定
private val colorNoMatch = Color.rgb(239, 68, 68) // 红色 - 未匹配 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<DetectionUi> = emptyList() private var detections: List<DetectionUi> = emptyList()
@@ -136,7 +136,7 @@ data class DetectionUi(
result.isMatched -> "${result.student?.name} (${result.student?.studentNo})" result.isMatched -> "${result.student?.name} (${result.student?.studentNo})"
result.matchType == MatchType.UNSURE -> "疑似:${result.student?.name}" result.matchType == MatchType.UNSURE -> "疑似:${result.student?.name}"
result.student == null && result.distance != Float.MAX_VALUE -> "未匹配" result.student == null && result.distance != Float.MAX_VALUE -> "未匹配"
else -> "未入库" else -> "识别中"
} }
} }
} }