改一个版

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
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]
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)
}
}
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
}
return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, width, 0, 0, width, height)
}
}
}

View File

@@ -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,30 +520,33 @@ class MainActivity : AppCompatActivity() {
if (faces.isEmpty()) {
withContext(Dispatchers.Main) {
if (currentMode == AppMode.RECOGNITION) {
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
}
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
)

View File

@@ -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) {
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()
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
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]
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()
}

View File

@@ -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 -> {

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -79,17 +79,20 @@ class SmartEnrollment(context: Context) : AutoCloseable {
faceInfo.faceArea
}
// 裁剪人脸
val croppedFace = cropFace(bitmap, largestFace.cropRect)
// 对齐
val alignedFace = alignFace(croppedFace, largestFace.face, 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 embedding = embedder.embed(preprocessedFace)
if (embeddings.isEmpty()) {
return EnrollmentResult.Failure("未检测到可用人脸,请换更清晰或更正面的照片。")
}
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)
}
}

View File

@@ -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 -> "识别中"
}
}
}