改一个版
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Int, ArrayDeque<RecognitionResult>>()
|
||||
private val recognitionCache = mutableMapOf<Int, CachedRecognition>()
|
||||
private var lastVisibleDetections: List<DetectionUi> = 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<Int, RecognitionResult>()
|
||||
|
||||
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<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(
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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<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
|
||||
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<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() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,17 +79,20 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
faceInfo.faceArea
|
||||
}
|
||||
|
||||
// 裁剪人脸
|
||||
val croppedFace = cropFace(bitmap, largestFace.cropRect)
|
||||
val embeddings = mutableListOf<FloatArray>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DetectionUi> = 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 -> "识别中"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user