改一个版
This commit is contained in:
@@ -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)
|
|
||||||
copyPlane(image.planes[1], width / 2, height / 2, nv21, ySize + 1, 2)
|
|
||||||
return nv21
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyPlane(
|
val yBuffer = yPlane.buffer
|
||||||
plane: Image.Plane,
|
val uBuffer = uPlane.buffer
|
||||||
width: Int,
|
val vBuffer = vPlane.buffer
|
||||||
height: Int,
|
val pixels = IntArray(width * height)
|
||||||
output: ByteArray,
|
|
||||||
offset: Int,
|
val yRowStride = yPlane.rowStride
|
||||||
outputPixelStride: Int
|
val yPixelStride = yPlane.pixelStride
|
||||||
) {
|
val uRowStride = uPlane.rowStride
|
||||||
val buffer = plane.buffer
|
val uPixelStride = uPlane.pixelStride
|
||||||
val row = ByteArray(plane.rowStride)
|
val vRowStride = vPlane.rowStride
|
||||||
var outputIndex = offset
|
val vPixelStride = vPlane.pixelStride
|
||||||
for (rowIndex in 0 until height) {
|
|
||||||
val bytesToRead = min(plane.rowStride, buffer.remaining())
|
var pixelIndex = 0
|
||||||
buffer.get(row, 0, bytesToRead)
|
for (row in 0 until height) {
|
||||||
for (colIndex in 0 until width) {
|
val yRow = row * yRowStride
|
||||||
val inputIndex = colIndex * plane.pixelStride
|
val uvRow = (row shr 1) * uRowStride
|
||||||
if (inputIndex < bytesToRead && outputIndex < output.size) {
|
val vvRow = (row shr 1) * vRowStride
|
||||||
output[outputIndex] = row[inputIndex]
|
for (col in 0 until width) {
|
||||||
}
|
val y = (yBuffer.get(yRow + col * yPixelStride).toInt() and 0xFF) - 16
|
||||||
outputIndex += outputPixelStride
|
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.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,10 +520,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
if (faces.isEmpty()) {
|
if (faces.isEmpty()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (currentMode == AppMode.RECOGNITION) {
|
if (currentMode == AppMode.RECOGNITION) {
|
||||||
recognitionHistory.clear()
|
if (now - lastVisibleDetectionsAt <= DETECTION_GRACE_MS && lastVisibleDetections.isNotEmpty()) {
|
||||||
recognitionCache.clear()
|
overlayView.update(lastVisibleDetections)
|
||||||
overlayView.update(emptyList())
|
updateRecognitionStatus(lastVisibleDetections)
|
||||||
updateRecognitionStatus(emptyList())
|
} else {
|
||||||
|
recognitionHistory.clear()
|
||||||
|
recognitionCache.clear()
|
||||||
|
lastVisibleDetections = emptyList()
|
||||||
|
lastVisibleDetectionsAt = 0L
|
||||||
|
overlayView.update(emptyList())
|
||||||
|
updateRecognitionStatus(emptyList())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return@launch
|
return@launch
|
||||||
@@ -522,19 +538,15 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
||||||
val r = ((color shr 16) and 0xFF).toFloat()
|
ModelType.FACENET -> {
|
||||||
val g = ((color shr 8) and 0xFF).toFloat()
|
for (color in inputPixels) {
|
||||||
val b = (color and 0xFF).toFloat()
|
val r = ((color shr 16) and 0xFF).toFloat()
|
||||||
|
val g = ((color shr 8) and 0xFF).toFloat()
|
||||||
when (modelType) {
|
val b = (color and 0xFF).toFloat()
|
||||||
ModelType.FACENET -> {
|
inputValues[index++] = (r - 127.5f) / 127.5f
|
||||||
// FaceNet 归一化:(pixel - 127.5) / 127.5
|
inputValues[index++] = (g - 127.5f) / 127.5f
|
||||||
input[index++] = (r - 127.5f) / 127.5f
|
inputValues[index++] = (b - 127.5f) / 127.5f
|
||||||
input[index++] = (g - 127.5f) / 127.5f
|
|
||||||
input[index++] = (b - 127.5f) / 127.5f
|
|
||||||
}
|
}
|
||||||
ModelType.ARCFACE -> {
|
}
|
||||||
// ArcFace 归一化:(pixel - mean) / std
|
ModelType.ARCFACE -> {
|
||||||
// 等价于 (pixel - 127.5) / 127.5,但语义更清晰
|
for (color in inputPixels) {
|
||||||
input[index++] = (r - mean[0]) / std[0]
|
val r = ((color shr 16) and 0xFF).toFloat()
|
||||||
input[index++] = (g - mean[1]) / std[1]
|
val g = ((color shr 8) and 0xFF).toFloat()
|
||||||
input[index++] = (b - mean[2]) / std[2]
|
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))
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, cropRect)
|
||||||
|
val preprocessedFace = preprocessor.preprocess(alignedFace)
|
||||||
|
embeddings.add(embedder.embed(preprocessedFace))
|
||||||
|
}
|
||||||
|
|
||||||
// 对齐
|
if (embeddings.isEmpty()) {
|
||||||
val alignedFace = alignFace(croppedFace, largestFace.face, largestFace.cropRect)
|
return EnrollmentResult.Failure("未检测到可用人脸,请换更清晰或更正面的照片。")
|
||||||
|
}
|
||||||
|
|
||||||
// 预处理
|
val embedding = fuseEmbeddings(embeddings)
|
||||||
val preprocessedFace = preprocessor.preprocess(alignedFace)
|
|
||||||
|
|
||||||
// 提取特征
|
|
||||||
val embedding = embedder.embed(preprocessedFace)
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 -> "识别中"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user