fixed a bug
This commit is contained in:
@@ -8,10 +8,10 @@ import androidx.camera.core.ImageProxy
|
||||
|
||||
@ExperimentalGetImage
|
||||
object ImageUtils {
|
||||
fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap {
|
||||
fun imageProxyToBitmap(imageProxy: ImageProxy, applyRotation: Boolean = true): Bitmap {
|
||||
val image = imageProxy.image ?: error("ImageProxy does not contain an image.")
|
||||
val bitmap = yuv420ToBitmap(image)
|
||||
if (imageProxy.imageInfo.rotationDegrees == 0) {
|
||||
if (!applyRotation || imageProxy.imageInfo.rotationDegrees == 0) {
|
||||
return bitmap
|
||||
}
|
||||
val matrix = Matrix().apply {
|
||||
|
||||
@@ -42,6 +42,7 @@ import com.example.studentfaceregistry.face.MatchType
|
||||
import com.example.studentfaceregistry.face.RecognitionResult
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.mlkit.vision.face.Face
|
||||
import com.google.mlkit.vision.face.FaceLandmark
|
||||
import com.example.studentfaceregistry.ui.DetectionUi
|
||||
import com.example.studentfaceregistry.ui.OverlayView
|
||||
import com.example.studentfaceregistry.upload.UploadServer
|
||||
@@ -115,6 +116,20 @@ class MainActivity : AppCompatActivity() {
|
||||
repository.students.collectLatest { list ->
|
||||
students = list
|
||||
countText.text = getString(R.string.registered_student_count, list.size)
|
||||
if (list.isNotEmpty()) {
|
||||
val first = list.first()
|
||||
val earliest = list.minOfOrNull { it.createdAt } ?: 0L
|
||||
val latest = list.maxOfOrNull { it.createdAt } ?: 0L
|
||||
Log.i(
|
||||
TAG,
|
||||
"已加载学生 ${list.size} 人," +
|
||||
"特征维度=${first.embedding.size}," +
|
||||
"注册时间范围=${fmtTime(earliest)} ~ ${fmtTime(latest)}," +
|
||||
"特征范数=${String.format(java.util.Locale.US, "%.3f", embeddingNorm(first.embedding))}"
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "已加载学生 0 人(请先注册)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,8 +604,18 @@ class MainActivity : AppCompatActivity() {
|
||||
if (facesNeedingRefresh.isNotEmpty()) {
|
||||
val refreshStart = SystemClock.elapsedRealtime()
|
||||
val bitmap = ImageUtils.imageProxyToBitmap(image)
|
||||
var unrotatedBitmap: Bitmap? = null
|
||||
facesNeedingRefresh.forEach { (index, face) ->
|
||||
val refreshed = refreshRecognition(faceProcessor, bitmap, face)
|
||||
var refreshed = refreshRecognition(faceProcessor, bitmap, face)
|
||||
if (refreshed.matchType == MatchType.NO_MATCH && image.imageInfo.rotationDegrees != 0) {
|
||||
val fallbackBitmap = unrotatedBitmap ?: ImageUtils
|
||||
.imageProxyToBitmap(image, applyRotation = false)
|
||||
.also { unrotatedBitmap = it }
|
||||
val fallback = refreshRecognition(faceProcessor, fallbackBitmap, face)
|
||||
if (recognitionScore(fallback) > recognitionScore(refreshed)) {
|
||||
refreshed = fallback
|
||||
}
|
||||
}
|
||||
refreshedResults[index] = refreshed
|
||||
}
|
||||
val refreshMs = SystemClock.elapsedRealtime() - refreshStart
|
||||
@@ -941,6 +966,13 @@ class MainActivity : AppCompatActivity() {
|
||||
bitmap: Bitmap,
|
||||
face: Face
|
||||
): RecognitionResult {
|
||||
val landmarkCount = listOf(
|
||||
face.getLandmark(FaceLandmark.LEFT_EYE),
|
||||
face.getLandmark(FaceLandmark.RIGHT_EYE),
|
||||
face.getLandmark(FaceLandmark.NOSE_BASE),
|
||||
face.getLandmark(FaceLandmark.MOUTH_LEFT),
|
||||
face.getLandmark(FaceLandmark.MOUTH_RIGHT)
|
||||
).count { it != null }
|
||||
val primary = matcher.findNearest(faceProcessor.embedFace(bitmap, face), students)
|
||||
val cached = face.trackingId?.let { recognitionCache[it] }
|
||||
// 未匹配时只做单次推理;仅在疑似匹配或人脸明显移动时才用多裁剪升级,
|
||||
@@ -950,6 +982,7 @@ class MainActivity : AppCompatActivity() {
|
||||
primary.matchType != MatchType.NO_MATCH
|
||||
|
||||
if (!needsEscalation) {
|
||||
logRecognitionDebug("仅主候选", landmarkCount, primary)
|
||||
return primary
|
||||
}
|
||||
|
||||
@@ -960,9 +993,39 @@ class MainActivity : AppCompatActivity() {
|
||||
best = result
|
||||
}
|
||||
}
|
||||
logRecognitionDebug("含升级候选", landmarkCount, primary, best)
|
||||
return best
|
||||
}
|
||||
|
||||
private fun logRecognitionDebug(
|
||||
stage: String,
|
||||
landmarkCount: Int,
|
||||
primary: RecognitionResult,
|
||||
best: RecognitionResult = primary
|
||||
) {
|
||||
val primaryName = primary.student?.let { "${it.name}(${it.studentNo})" } ?: "无"
|
||||
val bestName = best.student?.let { "${it.name}(${it.studentNo})" } ?: "无"
|
||||
Log.i(
|
||||
TAG,
|
||||
String.format(
|
||||
java.util.Locale.US,
|
||||
"识别 debug: 阶段=%s 关键点=%d/5 主候选=%s(余弦=%.3f 欧氏=%.3f %s) " +
|
||||
"最优=%s(余弦=%.3f 欧氏=%.3f %s) 学生=%d",
|
||||
stage,
|
||||
landmarkCount,
|
||||
primaryName,
|
||||
primary.cosineSimilarity,
|
||||
primary.distance,
|
||||
primary.matchType,
|
||||
bestName,
|
||||
best.cosineSimilarity,
|
||||
best.distance,
|
||||
best.matchType,
|
||||
students.size
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveRecognitionResult(
|
||||
face: Face,
|
||||
refreshedResult: RecognitionResult?,
|
||||
@@ -1020,7 +1083,21 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun embeddingNorm(embedding: FloatArray): Double {
|
||||
var sum = 0.0
|
||||
for (value in embedding) {
|
||||
sum += value.toDouble() * value.toDouble()
|
||||
}
|
||||
return kotlin.math.sqrt(sum)
|
||||
}
|
||||
|
||||
private fun fmtTime(timestamp: Long): String {
|
||||
return java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.US)
|
||||
.format(java.util.Date(timestamp))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val MATCHER_PREFS_NAME = "face_matcher_config"
|
||||
private const val KEY_EUCLIDEAN_THRESHOLD = "euclidean_threshold"
|
||||
private const val KEY_COSINE_THRESHOLD = "cosine_threshold"
|
||||
|
||||
@@ -13,7 +13,15 @@ class StudentRepository(context: Context) {
|
||||
val students: StateFlow<List<Student>> = _students
|
||||
|
||||
fun add(student: Student) {
|
||||
val next = _students.value.toMutableList().apply { add(student) }
|
||||
val studentToStore = if (student.id == 0L) {
|
||||
student.copy(id = stableId(student.studentNo))
|
||||
} else {
|
||||
student
|
||||
}
|
||||
val next = _students.value
|
||||
.filterNot { it.studentNo == studentToStore.studentNo }
|
||||
.toMutableList()
|
||||
.apply { add(studentToStore) }
|
||||
.sortedByDescending { it.createdAt }
|
||||
_students.value = next
|
||||
save(next)
|
||||
@@ -32,10 +40,11 @@ class StudentRepository(context: Context) {
|
||||
return buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(index)
|
||||
val studentNo = obj.getString("studentNo")
|
||||
add(
|
||||
Student(
|
||||
id = obj.optLong("id", index.toLong()),
|
||||
studentNo = obj.getString("studentNo"),
|
||||
id = obj.optLong("id").takeIf { it != 0L } ?: stableId(studentNo),
|
||||
studentNo = studentNo,
|
||||
name = obj.getString("name"),
|
||||
photoUri = obj.getString("photoUri"),
|
||||
embedding = decodeEmbedding(obj.getJSONArray("embedding")),
|
||||
@@ -80,4 +89,8 @@ class StudentRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableId(studentNo: String): Long {
|
||||
return (studentNo.hashCode().toLong() and 0xffffffffL).coerceAtLeast(1L)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 人脸对齐器
|
||||
@@ -34,10 +33,18 @@ class FaceAligner {
|
||||
faceTop: Int
|
||||
): Bitmap {
|
||||
// 计算眼睛在裁剪后图片中的相对位置
|
||||
val leftEyeRelX = leftEyeX - faceLeft
|
||||
val leftEyeRelY = leftEyeY - faceTop
|
||||
val rightEyeRelX = rightEyeX - faceLeft
|
||||
val rightEyeRelY = rightEyeY - faceTop
|
||||
var leftEyeRelX = leftEyeX - faceLeft
|
||||
var leftEyeRelY = leftEyeY - faceTop
|
||||
var rightEyeRelX = rightEyeX - faceLeft
|
||||
var rightEyeRelY = rightEyeY - faceTop
|
||||
if (leftEyeRelX > rightEyeRelX) {
|
||||
val oldLeftX = leftEyeRelX
|
||||
val oldLeftY = leftEyeRelY
|
||||
leftEyeRelX = rightEyeRelX
|
||||
leftEyeRelY = rightEyeRelY
|
||||
rightEyeRelX = oldLeftX
|
||||
rightEyeRelY = oldLeftY
|
||||
}
|
||||
|
||||
// 计算两眼连线的旋转角度(目标是让连线水平)
|
||||
val angle = calculateRotationAngle(leftEyeRelX, leftEyeRelY, rightEyeRelX, rightEyeRelY)
|
||||
@@ -58,6 +65,40 @@ class FaceAligner {
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
|
||||
fun alignFivePoint(
|
||||
bitmap: Bitmap,
|
||||
leftEyeX: Float,
|
||||
leftEyeY: Float,
|
||||
rightEyeX: Float,
|
||||
rightEyeY: Float,
|
||||
noseX: Float,
|
||||
noseY: Float,
|
||||
leftMouthX: Float,
|
||||
leftMouthY: Float,
|
||||
rightMouthX: Float,
|
||||
rightMouthY: Float,
|
||||
outputSize: Int = ARC_FACE_SIZE
|
||||
): Bitmap {
|
||||
val (imageLeftEye, imageRightEye) = sortedByX(leftEyeX, leftEyeY, rightEyeX, rightEyeY)
|
||||
val (imageLeftMouth, imageRightMouth) = sortedByX(leftMouthX, leftMouthY, rightMouthX, rightMouthY)
|
||||
val src = floatArrayOf(
|
||||
imageLeftEye.x, imageLeftEye.y,
|
||||
imageRightEye.x, imageRightEye.y,
|
||||
noseX, noseY,
|
||||
imageLeftMouth.x, imageLeftMouth.y,
|
||||
imageRightMouth.x, imageRightMouth.y
|
||||
)
|
||||
val dst = arcFaceTemplate(outputSize)
|
||||
val matrix = similarityTransform(src, dst)
|
||||
val result = Bitmap.createBitmap(outputSize, outputSize, Bitmap.Config.ARGB_8888)
|
||||
Canvas(result).drawBitmap(
|
||||
bitmap,
|
||||
matrix,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两眼连线的旋转角度
|
||||
*/
|
||||
@@ -67,6 +108,63 @@ class FaceAligner {
|
||||
return Math.atan2(dy.toDouble(), dx.toDouble()).toFloat() * 180f / Math.PI.toFloat()
|
||||
}
|
||||
|
||||
private fun sortedByX(x1: Float, y1: Float, x2: Float, y2: Float): Pair<Point, Point> {
|
||||
val first = Point(x1, y1)
|
||||
val second = Point(x2, y2)
|
||||
return if (x1 <= x2) first to second else second to first
|
||||
}
|
||||
|
||||
private fun arcFaceTemplate(outputSize: Int): FloatArray {
|
||||
val scale = outputSize / ARC_FACE_SIZE.toFloat()
|
||||
return FloatArray(ARC_FACE_TEMPLATE.size) { index -> ARC_FACE_TEMPLATE[index] * scale }
|
||||
}
|
||||
|
||||
private fun similarityTransform(src: FloatArray, dst: FloatArray): Matrix {
|
||||
val count = src.size / 2
|
||||
var srcCenterX = 0f
|
||||
var srcCenterY = 0f
|
||||
var dstCenterX = 0f
|
||||
var dstCenterY = 0f
|
||||
for (index in 0 until count) {
|
||||
srcCenterX += src[index * 2]
|
||||
srcCenterY += src[index * 2 + 1]
|
||||
dstCenterX += dst[index * 2]
|
||||
dstCenterY += dst[index * 2 + 1]
|
||||
}
|
||||
srcCenterX /= count
|
||||
srcCenterY /= count
|
||||
dstCenterX /= count
|
||||
dstCenterY /= count
|
||||
|
||||
var numeratorA = 0f
|
||||
var numeratorB = 0f
|
||||
var denominator = 0f
|
||||
for (index in 0 until count) {
|
||||
val srcX = src[index * 2] - srcCenterX
|
||||
val srcY = src[index * 2 + 1] - srcCenterY
|
||||
val dstX = dst[index * 2] - dstCenterX
|
||||
val dstY = dst[index * 2 + 1] - dstCenterY
|
||||
numeratorA += srcX * dstX + srcY * dstY
|
||||
numeratorB += srcX * dstY - srcY * dstX
|
||||
denominator += srcX * srcX + srcY * srcY
|
||||
}
|
||||
|
||||
val a = numeratorA / denominator.coerceAtLeast(1e-6f)
|
||||
val b = numeratorB / denominator.coerceAtLeast(1e-6f)
|
||||
val translateX = dstCenterX - a * srcCenterX + b * srcCenterY
|
||||
val translateY = dstCenterY - b * srcCenterX - a * srcCenterY
|
||||
|
||||
return Matrix().apply {
|
||||
setValues(
|
||||
floatArrayOf(
|
||||
a, -b, translateX,
|
||||
b, a, translateY,
|
||||
0f, 0f, 1f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单版本:仅根据估计的眼睛位置进行对齐
|
||||
* 适用于没有启用关键点检测的场景
|
||||
@@ -94,4 +192,17 @@ class FaceAligner {
|
||||
align(bitmap, leftEyeX, eyeY, rightEyeX, eyeY, faceLeft, faceTop)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Point(val x: Float, val y: Float)
|
||||
|
||||
companion object {
|
||||
private const val ARC_FACE_SIZE = 112
|
||||
private val ARC_FACE_TEMPLATE = floatArrayOf(
|
||||
38.2946f, 51.6963f,
|
||||
73.5318f, 51.5014f,
|
||||
56.0252f, 71.7366f,
|
||||
41.5493f, 92.3655f,
|
||||
70.7299f, 92.2041f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp
|
||||
private val accelerator: Accelerator
|
||||
private val inputPixels: IntArray
|
||||
private val inputValues: FloatArray
|
||||
private var debugEmbedCount = 0
|
||||
|
||||
// ArcFace 标准化参数 (RGB)
|
||||
private val mean = floatArrayOf(127.5f, 127.5f, 127.5f)
|
||||
@@ -85,12 +86,57 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp
|
||||
}
|
||||
}
|
||||
|
||||
debugEmbedCount++
|
||||
val logInput = debugEmbedCount <= 30 || debugEmbedCount % 100 == 0
|
||||
if (logInput) {
|
||||
var sum = 0f
|
||||
var sumSq = 0f
|
||||
for (value in inputValues) {
|
||||
sum += value
|
||||
sumSq += value * value
|
||||
}
|
||||
val mean = sum / inputValues.size
|
||||
val variance = (sumSq / inputValues.size - mean * mean).coerceAtLeast(0f)
|
||||
Log.i(
|
||||
TAG,
|
||||
String.format(
|
||||
java.util.Locale.US,
|
||||
"embed debug #%d 输入均值=%.3f 输入标准差=%.3f",
|
||||
debugEmbedCount,
|
||||
mean,
|
||||
sqrt(variance)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 执行推理
|
||||
inputBuffer.writeFloat(inputValues)
|
||||
model.run(listOf(inputBuffer), listOf(outputBuffer))
|
||||
|
||||
val rawOutput = outputBuffer.readFloat()
|
||||
if (logInput) {
|
||||
var sum = 0f
|
||||
var sumSq = 0f
|
||||
for (value in rawOutput) {
|
||||
sum += value
|
||||
sumSq += value * value
|
||||
}
|
||||
val mean = sum / rawOutput.size
|
||||
val variance = (sumSq / rawOutput.size - mean * mean).coerceAtLeast(0f)
|
||||
Log.i(
|
||||
TAG,
|
||||
String.format(
|
||||
java.util.Locale.US,
|
||||
"embed debug #%d 输出均值=%.3f 输出标准差=%.3f",
|
||||
debugEmbedCount,
|
||||
mean,
|
||||
sqrt(variance)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// L2 归一化输出特征
|
||||
return l2Normalize(outputBuffer.readFloat())
|
||||
return l2Normalize(rawOutput)
|
||||
}
|
||||
|
||||
fun warmUp() {
|
||||
@@ -138,19 +184,10 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp
|
||||
}
|
||||
|
||||
private fun createModel(context: Context): Pair<CompiledModel, Accelerator> {
|
||||
return runCatching {
|
||||
CompiledModel.create(
|
||||
context.assets,
|
||||
MODEL_ASSET_NAME,
|
||||
CompiledModel.Options(setOf(Accelerator.GPU))
|
||||
) to Accelerator.GPU
|
||||
}.getOrElse { gpuError ->
|
||||
Log.w(TAG, "GPU initialization failed, falling back to CPU", gpuError)
|
||||
CompiledModel.create(
|
||||
return CompiledModel.create(
|
||||
context.assets,
|
||||
MODEL_ASSET_NAME,
|
||||
CompiledModel.Options(setOf(Accelerator.CPU))
|
||||
) to Accelerator.CPU
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ class FaceProcessor(context: Context) : AutoCloseable {
|
||||
box.width() * box.height()
|
||||
}
|
||||
|
||||
val alignedFace = alignFaceFromOriginal(bitmap, largestFace) ?: run {
|
||||
// 裁剪人脸
|
||||
val croppedFace = Bitmap.createBitmap(
|
||||
bitmap,
|
||||
@@ -67,7 +68,8 @@ class FaceProcessor(context: Context) : AutoCloseable {
|
||||
)
|
||||
|
||||
// 使用关键点进行对齐
|
||||
val alignedFace = alignFaceWithLandmarks(croppedFace, largestFace, faceBox.left, faceBox.top)
|
||||
alignFaceWithLandmarks(croppedFace, largestFace, faceBox.left, faceBox.top)
|
||||
}
|
||||
|
||||
// 图像预处理
|
||||
val preprocessedFace = preprocessor.preprocess(alignedFace)
|
||||
@@ -127,6 +129,12 @@ class FaceProcessor(context: Context) : AutoCloseable {
|
||||
* 提取指定人脸的特征
|
||||
*/
|
||||
fun embedFace(bitmap: Bitmap, face: Face): FloatArray {
|
||||
val alignedFromLandmarks = alignFaceFromOriginal(bitmap, face)
|
||||
if (alignedFromLandmarks != null) {
|
||||
val preprocessed = preprocessor.preprocessRealtime(alignedFromLandmarks)
|
||||
return embedder.embed(preprocessed)
|
||||
}
|
||||
|
||||
val crop = requireNotNull(safeCropRect(bitmap, face.boundingBox)) { "人脸区域超出图片边界。" }
|
||||
val cropped = Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height())
|
||||
val aligned = alignFaceForRecognition(cropped, face, crop.left, crop.top)
|
||||
@@ -136,6 +144,9 @@ class FaceProcessor(context: Context) : AutoCloseable {
|
||||
|
||||
fun embedFaceCandidates(bitmap: Bitmap, face: Face): List<FloatArray> {
|
||||
val embeddings = mutableListOf<FloatArray>()
|
||||
alignFaceFromOriginal(bitmap, face)?.let { aligned ->
|
||||
embeddings.add(embedder.embed(preprocessor.preprocessRealtime(aligned)))
|
||||
}
|
||||
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())
|
||||
@@ -146,6 +157,28 @@ class FaceProcessor(context: Context) : AutoCloseable {
|
||||
return embeddings
|
||||
}
|
||||
|
||||
private fun alignFaceFromOriginal(bitmap: Bitmap, face: Face): Bitmap? {
|
||||
val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE) ?: return null
|
||||
val rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE) ?: return null
|
||||
val nose = face.getLandmark(FaceLandmark.NOSE_BASE) ?: return null
|
||||
val leftMouth = face.getLandmark(FaceLandmark.MOUTH_LEFT) ?: return null
|
||||
val rightMouth = face.getLandmark(FaceLandmark.MOUTH_RIGHT) ?: return null
|
||||
|
||||
return aligner.alignFivePoint(
|
||||
bitmap = bitmap,
|
||||
leftEyeX = leftEye.position.x,
|
||||
leftEyeY = leftEye.position.y,
|
||||
rightEyeX = rightEye.position.x,
|
||||
rightEyeY = rightEye.position.y,
|
||||
noseX = nose.position.x,
|
||||
noseY = nose.position.y,
|
||||
leftMouthX = leftMouth.position.x,
|
||||
leftMouthY = leftMouth.position.y,
|
||||
rightMouthX = rightMouth.position.x,
|
||||
rightMouthY = rightMouth.position.y
|
||||
)
|
||||
}
|
||||
|
||||
fun warmUp() {
|
||||
embedder.warmUp()
|
||||
}
|
||||
|
||||
@@ -101,20 +101,23 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
return EnrollmentResult.Failure("照片模糊,请换更清晰的照片。")
|
||||
}
|
||||
|
||||
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 alignedFromLandmarks = alignFaceFromOriginal(bitmap, largestFace.face)
|
||||
val cropEmbeddings = cropEmbeddingCandidates(bitmap, largestFace.face)
|
||||
if (alignedFromLandmarks != null) {
|
||||
val preprocessedFace = preprocessor.preprocess(alignedFromLandmarks)
|
||||
val embedding = embedder.embed(preprocessedFace)
|
||||
return EnrollmentResult.Success(
|
||||
embedding,
|
||||
EnrollmentSourceType.IMAGE,
|
||||
extraEmbeddings = cropEmbeddings
|
||||
)
|
||||
}
|
||||
|
||||
if (embeddings.isEmpty()) {
|
||||
if (cropEmbeddings.isEmpty()) {
|
||||
return EnrollmentResult.Failure("未检测到可用人脸,请换更清晰或更正面的照片。")
|
||||
}
|
||||
|
||||
val embedding = fuseEmbeddings(embeddings)
|
||||
val embedding = fuseEmbeddings(cropEmbeddings)
|
||||
|
||||
EnrollmentResult.Success(embedding, EnrollmentSourceType.IMAGE)
|
||||
} catch (e: Exception) {
|
||||
@@ -355,12 +358,30 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
* 对单帧提取特征(裁剪 + 对齐 + 预处理 + 推理)
|
||||
*/
|
||||
private fun embedFrame(frameInfo: FaceFrameInfo): FloatArray {
|
||||
val alignedFromLandmarks = alignFaceFromOriginal(frameInfo.bitmap, frameInfo.face)
|
||||
if (alignedFromLandmarks != null) {
|
||||
val preprocessed = preprocessor.preprocess(alignedFromLandmarks)
|
||||
return embedder.embed(preprocessed)
|
||||
}
|
||||
|
||||
val cropped = cropFace(frameInfo.bitmap, frameInfo.cropRect)
|
||||
val aligned = alignFace(cropped, frameInfo.face, frameInfo.cropRect)
|
||||
val preprocessed = preprocessor.preprocess(aligned)
|
||||
return embedder.embed(preprocessed)
|
||||
}
|
||||
|
||||
private fun cropEmbeddingCandidates(bitmap: Bitmap, face: Face): List<FloatArray> {
|
||||
return buildList {
|
||||
for (paddingRatio in IMAGE_PADDING_RATIOS) {
|
||||
val cropRect = safeCropRect(bitmap, face.boundingBox, paddingRatio) ?: continue
|
||||
val croppedFace = cropFace(bitmap, cropRect)
|
||||
val alignedFace = alignFace(croppedFace, face, cropRect)
|
||||
val preprocessedFace = preprocessor.preprocess(alignedFace)
|
||||
add(embedder.embed(preprocessedFace))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按偏航角将帧特征分组:正脸(主特征)、左右侧脸(补充特征)
|
||||
*/
|
||||
@@ -471,6 +492,28 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
)
|
||||
}
|
||||
|
||||
private fun alignFaceFromOriginal(bitmap: Bitmap, face: Face): Bitmap? {
|
||||
val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE) ?: return null
|
||||
val rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE) ?: return null
|
||||
val nose = face.getLandmark(FaceLandmark.NOSE_BASE) ?: return null
|
||||
val leftMouth = face.getLandmark(FaceLandmark.MOUTH_LEFT) ?: return null
|
||||
val rightMouth = face.getLandmark(FaceLandmark.MOUTH_RIGHT) ?: return null
|
||||
|
||||
return aligner.alignFivePoint(
|
||||
bitmap = bitmap,
|
||||
leftEyeX = leftEye.position.x,
|
||||
leftEyeY = leftEye.position.y,
|
||||
rightEyeX = rightEye.position.x,
|
||||
rightEyeY = rightEye.position.y,
|
||||
noseX = nose.position.x,
|
||||
noseY = nose.position.y,
|
||||
leftMouthX = leftMouth.position.x,
|
||||
leftMouthY = leftMouth.position.y,
|
||||
rightMouthX = rightMouth.position.x,
|
||||
rightMouthY = rightMouth.position.y
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 Bitmap
|
||||
*/
|
||||
@@ -522,7 +565,7 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
private val IMAGE_PADDING_RATIOS = floatArrayOf(0.18f, 0.25f, 0.34f)
|
||||
private const val MIN_ENROLL_FACE_PX = 96
|
||||
private const val MAX_ENROLL_YAW = 30f
|
||||
private const val MAX_ENROLL_PITCH = 15f
|
||||
private const val MAX_ENROLL_PITCH = 25f
|
||||
private const val BLUR_VARIANCE_THRESHOLD = 40.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.example.studentfaceregistry.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
import com.example.studentfaceregistry.data.StudentRepository
|
||||
import com.example.studentfaceregistry.face.SmartEnrollment
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -58,6 +60,8 @@ class UploadServer(
|
||||
private fun handle(socket: Socket) {
|
||||
thread(name = "upload-request", isDaemon = true) {
|
||||
socket.use { client ->
|
||||
client.soTimeout = SOCKET_READ_TIMEOUT_MS
|
||||
runCatching {
|
||||
val input = BufferedInputStream(client.getInputStream())
|
||||
val output = client.getOutputStream()
|
||||
val request = readHttpRequest(input)
|
||||
@@ -66,6 +70,9 @@ class UploadServer(
|
||||
request.method == "POST" && request.path == "/upload" -> handleUpload(request, output)
|
||||
else -> respondText(output, 404, "Not found", "text/plain; charset=utf-8")
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Upload request failed", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,21 +140,31 @@ class UploadServer(
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
const result = document.getElementById('result');
|
||||
document.getElementById('uploadFolderBtn').onclick = async () => {
|
||||
const uploadButton = document.getElementById('uploadFolderBtn');
|
||||
const IMAGE_REQUEST_TIMEOUT_MS = 90000;
|
||||
const VIDEO_REQUEST_TIMEOUT_MS = 210000;
|
||||
uploadButton.onclick = async () => {
|
||||
result.textContent = '上传中...';
|
||||
const files = document.getElementById('folderFiles').files;
|
||||
if (!files.length) {
|
||||
result.textContent = '请选择文件后再上传。';
|
||||
return;
|
||||
}
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
const logs = [];
|
||||
uploadButton.disabled = true;
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
result.textContent = `上传中 ${'$'}{i + 1}/${'$'}{files.length}: ${'$'}{file.name}`;
|
||||
result.textContent = progressText('上传中', i + 1, files.length, file.name, ok, fail, logs);
|
||||
try {
|
||||
const base64 = await toBase64(file);
|
||||
const resp = await fetch('/upload', {
|
||||
const resp = await fetchWithTimeout('/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: [{ fileName: file.name, base64, mimeType: file.type }] })
|
||||
});
|
||||
}, uploadTimeoutMs(file));
|
||||
const text = await resp.text();
|
||||
logs.push(text);
|
||||
try {
|
||||
@@ -157,9 +174,44 @@ class UploadServer(
|
||||
} catch (e) {
|
||||
fail += 1;
|
||||
}
|
||||
} catch (e) {
|
||||
fail += 1;
|
||||
const reason = e.name === 'AbortError'
|
||||
? '处理超时,已跳过该文件。'
|
||||
: (e.message || String(e));
|
||||
logs.push(JSON.stringify({
|
||||
accepted: 0,
|
||||
rejected: 1,
|
||||
message: 'FAIL ' + file.name + ': ' + reason
|
||||
}));
|
||||
}
|
||||
result.textContent = progressText('上传中', i + 1, files.length, file.name, ok, fail, logs);
|
||||
}
|
||||
} finally {
|
||||
uploadButton.disabled = false;
|
||||
}
|
||||
result.textContent = `完成:成功 ${'$'}{ok},失败 ${'$'}{fail}\n\n` + logs.join('\n');
|
||||
};
|
||||
function progressText(state, current, total, fileName, ok, fail, logs) {
|
||||
const recent = logs.slice(-8).join('\n');
|
||||
const summary = `${'$'}{state} ${'$'}{current}/${'$'}{total}: ${'$'}{fileName}\n成功 ${'$'}{ok},失败 ${'$'}{fail}`;
|
||||
return recent ? summary + '\n\n最近结果:\n' + recent : summary;
|
||||
}
|
||||
async function fetchWithTimeout(url, options, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
function uploadTimeoutMs(file) {
|
||||
return isVideo(file) ? VIDEO_REQUEST_TIMEOUT_MS : IMAGE_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
function isVideo(file) {
|
||||
return file.type.startsWith('video/') || /\.(mp4|avi|mov|wmv|flv|mkv|webm|3gp)$/i.test(file.name);
|
||||
}
|
||||
function toBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -213,10 +265,17 @@ class UploadServer(
|
||||
enrollment: SmartEnrollment,
|
||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
||||
) {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val timeoutMs = enrollmentTimeoutMs(mimeType, fileName)
|
||||
Log.i(TAG, "Enrollment started: $fileName (${data.size} bytes)")
|
||||
runCatching {
|
||||
val result = runBlocking {
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
enrollment.enroll(data, mimeType, fileName)
|
||||
}
|
||||
} ?: com.example.studentfaceregistry.face.EnrollmentResult.Failure(
|
||||
"处理超时(超过 ${timeoutMs / 1000} 秒),已跳过该文件。"
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is com.example.studentfaceregistry.face.EnrollmentResult.Success -> {
|
||||
@@ -240,8 +299,21 @@ class UploadServer(
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Enrollment failed: $fileName", it)
|
||||
report(false, fileName, it.message)
|
||||
}
|
||||
val elapsedMs = System.currentTimeMillis() - startedAt
|
||||
Log.i(TAG, "Enrollment finished: $fileName in ${elapsedMs}ms")
|
||||
}
|
||||
|
||||
private fun enrollmentTimeoutMs(mimeType: String, fileName: String): Long {
|
||||
val lowerMimeType = mimeType.lowercase()
|
||||
val lowerFileName = fileName.lowercase()
|
||||
return if (lowerMimeType.startsWith("video/") || VIDEO_EXTENSIONS.any { lowerFileName.endsWith(it) }) {
|
||||
VIDEO_ENROLLMENT_TIMEOUT_MS
|
||||
} else {
|
||||
IMAGE_ENROLLMENT_TIMEOUT_MS
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -329,6 +401,7 @@ class UploadServer(
|
||||
|
||||
private fun statusText(code: Int): String = when (code) {
|
||||
200 -> "OK"
|
||||
400 -> "Bad Request"
|
||||
404 -> "Not Found"
|
||||
503 -> "Service Unavailable"
|
||||
else -> "OK"
|
||||
@@ -343,6 +416,14 @@ class UploadServer(
|
||||
}
|
||||
|
||||
private fun jsonString(value: String): String = JSONObject.quote(value)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UploadServer"
|
||||
private const val SOCKET_READ_TIMEOUT_MS = 120_000
|
||||
private const val IMAGE_ENROLLMENT_TIMEOUT_MS = 45_000L
|
||||
private const val VIDEO_ENROLLMENT_TIMEOUT_MS = 180_000L
|
||||
private val VIDEO_EXTENSIONS = setOf(".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv", ".webm", ".3gp")
|
||||
}
|
||||
}
|
||||
|
||||
private data class HttpRequest(
|
||||
|
||||
Reference in New Issue
Block a user