change model with a sc model

This commit is contained in:
2026-08-07 08:05:50 +08:00
parent 1170e092f7
commit 0dd6ef9978
15 changed files with 287 additions and 21933 deletions

View File

@@ -1,4 +1,5 @@
Place a FaceNet-compatible TensorFlow Lite model here as facenet.tflite. Place a face recognition TensorFlow Lite model here as mobilefacenet.tflite
(MobileFaceNet@WebFace600K, 112x112 input, 512-d output).
Expected input: [1, 160, 160, 3]. Expected input: [1, 112, 112, 3] (float32, RGB, values normalized to [-1, 1]).
Expected output: a float embedding vector. Expected output: a 512-d L2-normalized float embedding vector.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -86,6 +86,9 @@ class MainActivity : AppCompatActivity() {
private val recognitionCache = mutableMapOf<Int, CachedRecognition>() private val recognitionCache = mutableMapOf<Int, CachedRecognition>()
private var lastVisibleDetections: List<DetectionUi> = emptyList() private var lastVisibleDetections: List<DetectionUi> = emptyList()
private var lastVisibleDetectionsAt = 0L private var lastVisibleDetectionsAt = 0L
private var perfFrameCount = 0
private var perfDetectMsSum = 0L
private var perfRefreshMsSum = 0L
private val permissionLauncher = registerForActivityResult( private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
@@ -285,6 +288,10 @@ class MainActivity : AppCompatActivity() {
text = "保存识别参数" text = "保存识别参数"
setOnClickListener { saveMatcherConfig() } setOnClickListener { saveMatcherConfig() }
}) })
matcherConfigPanel.addView(Button(this@MainActivity).apply {
text = "自动校准阈值"
setOnClickListener { applySuggestedThresholds() }
})
addView(matcherConfigPanel) addView(matcherConfigPanel)
@@ -431,7 +438,39 @@ class MainActivity : AppCompatActivity() {
private fun startRecognitionSession() { private fun startRecognitionSession() {
if (currentMode != AppMode.RECOGNITION) return if (currentMode != AppMode.RECOGNITION) return
val faceProcessor = ensureProcessor() ?: return recognitionStatusText.text = "正在加载模型..."
lifecycleScope.launch(Dispatchers.IO) {
val existing = processor
if (existing != null) {
withContext(Dispatchers.Main) {
if (currentMode == AppMode.RECOGNITION) bindRecognitionCamera(existing)
}
return@launch
}
val created = runCatching {
FaceProcessor(this@MainActivity).also { it.warmUp() }
}
withContext(Dispatchers.Main) {
if (currentMode != AppMode.RECOGNITION) {
created.getOrNull()?.close()
return@withContext
}
val faceProcessor = created.getOrNull()
if (faceProcessor == null) {
val error = created.exceptionOrNull()
val message = error?.message ?: error?.javaClass?.simpleName ?: "unknown error"
recognitionStatusText.text = "模型初始化失败:$message"
toast("模型初始化失败:$message")
return@withContext
}
processor = faceProcessor
bindRecognitionCamera(faceProcessor)
}
}
}
private fun bindRecognitionCamera(faceProcessor: FaceProcessor) {
previewView.post { previewView.post {
val providerFuture = ProcessCameraProvider.getInstance(this) val providerFuture = ProcessCameraProvider.getInstance(this)
providerFuture.addListener({ providerFuture.addListener({
@@ -515,7 +554,9 @@ class MainActivity : AppCompatActivity() {
try { try {
val mediaImage = image.image ?: return@launch val mediaImage = image.image ?: return@launch
val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees) val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
val detectStart = SystemClock.elapsedRealtime()
val faces = faceProcessor.detectFaces(inputImage) val faces = faceProcessor.detectFaces(inputImage)
val detectMs = SystemClock.elapsedRealtime() - detectStart
if (faces.isEmpty()) { if (faces.isEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -543,11 +584,27 @@ class MainActivity : AppCompatActivity() {
val refreshedResults = mutableMapOf<Int, RecognitionResult>() val refreshedResults = mutableMapOf<Int, RecognitionResult>()
if (facesNeedingRefresh.isNotEmpty()) { if (facesNeedingRefresh.isNotEmpty()) {
val refreshStart = SystemClock.elapsedRealtime()
val bitmap = ImageUtils.imageProxyToBitmap(image) val bitmap = ImageUtils.imageProxyToBitmap(image)
facesNeedingRefresh.forEach { (index, face) -> facesNeedingRefresh.forEach { (index, face) ->
val refreshed = refreshRecognition(faceProcessor, bitmap, face) val refreshed = refreshRecognition(faceProcessor, bitmap, face)
refreshedResults[index] = refreshed refreshedResults[index] = refreshed
} }
val refreshMs = SystemClock.elapsedRealtime() - refreshStart
perfFrameCount++
perfDetectMsSum += detectMs
perfRefreshMsSum += refreshMs
if (perfFrameCount >= 60) {
Log.i(
"MainActivity",
"FacePerf 检测平均=${perfDetectMsSum / perfFrameCount}ms " +
"刷新推理平均=${perfRefreshMsSum / perfFrameCount}ms " +
"人脸数=${faces.size}"
)
perfFrameCount = 0
perfDetectMsSum = 0L
perfRefreshMsSum = 0L
}
} }
val rawDetections = faces.mapIndexed { index, face -> val rawDetections = faces.mapIndexed { index, face ->
@@ -655,23 +712,6 @@ class MainActivity : AppCompatActivity() {
uploadUrlText.text = "-" uploadUrlText.text = "-"
} }
private fun ensureProcessor(): FaceProcessor? {
val current = processor
if (current != null) return current
val created = runCatching {
FaceProcessor(this).also { it.warmUp() }
}
.onFailure {
val message = it.message ?: "unknown error"
recognitionStatusText.text = "模型初始化失败:$message"
toast("模型初始化失败:$message")
}
.getOrNull()
processor = created
return created
}
private fun hasCameraPermission(): Boolean { private fun hasCameraPermission(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
} }
@@ -722,6 +762,20 @@ class MainActivity : AppCompatActivity() {
return "当前参数:欧氏 <= ${formatThreshold(euclideanThreshold)},余弦 >= ${formatThreshold(cosineThreshold)}" return "当前参数:欧氏 <= ${formatThreshold(euclideanThreshold)},余弦 >= ${formatThreshold(cosineThreshold)}"
} }
private fun applySuggestedThresholds() {
if (students.size < 3) {
toast("至少需要 3 名已注册学生才能校准阈值")
return
}
val suggested = matcher.suggestThresholds(students)
euclideanThresholdInput.setText(formatThreshold(suggested.euclidean))
cosineThresholdInput.setText(formatThreshold(suggested.cosine))
matcherConfigSummaryText.text =
"建议参数(基于 ${students.size} 名学生):欧氏 <= ${formatThreshold(suggested.euclidean)}" +
"余弦 >= ${formatThreshold(suggested.cosine)},点击“保存识别参数”生效"
toast("已填入建议阈值,确认后点击保存")
}
private fun formatThreshold(value: Float): String { private fun formatThreshold(value: Float): String {
return String.format(java.util.Locale.US, "%.2f", value) return String.format(java.util.Locale.US, "%.2f", value)
} }
@@ -860,11 +914,11 @@ class MainActivity : AppCompatActivity() {
): RecognitionResult { ): RecognitionResult {
val primary = matcher.findNearest(faceProcessor.embedFace(bitmap, face), students) val primary = matcher.findNearest(faceProcessor.embedFace(bitmap, face), students)
val cached = face.trackingId?.let { recognitionCache[it] } val cached = face.trackingId?.let { recognitionCache[it] }
// 未匹配时只做单次推理;仅在疑似匹配或人脸明显移动时才用多裁剪升级,
// 避免对陌生面孔持续做 3 次推理
val needsEscalation = cached == null || val needsEscalation = cached == null ||
!cached.result.isMatched || hasTrackMovedSignificantly(cached.lastBounds, face.boundingBox) ||
primary.matchType != MatchType.MATCH || primary.matchType != MatchType.NO_MATCH
primary.confidence < cached.result.confidence - 0.05f ||
hasTrackMovedSignificantly(cached.lastBounds, face.boundingBox)
if (!needsEscalation) { if (!needsEscalation) {
return primary return primary
@@ -944,15 +998,15 @@ class MainActivity : AppCompatActivity() {
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 MATCH_REFRESH_MS = 900L private const val MATCH_REFRESH_MS = 900L
private const val UNSURE_REFRESH_MS = 260L private const val UNSURE_REFRESH_MS = 500L
private const val NO_MATCH_REFRESH_MS = 160L private const val NO_MATCH_REFRESH_MS = 800L
private const val TRACK_STALE_MS = 900L private const val TRACK_STALE_MS = 900L
private const val TRACK_MOVE_THRESHOLD = 0.16f private const val TRACK_MOVE_THRESHOLD = 0.16f
private const val TRACK_SIZE_THRESHOLD = 0.20f private const val TRACK_SIZE_THRESHOLD = 0.20f
private const val MATCH_STABLE_CONFIDENCE = 0.82f private const val MATCH_STABLE_CONFIDENCE = 0.82f
private const val ANALYSIS_INTERVAL_MS = 80L private const val ANALYSIS_INTERVAL_MS = 80L
private const val DETECTION_GRACE_MS = 700L private const val DETECTION_GRACE_MS = 700L
private val ANALYSIS_SIZE = Size(640, 480) private val ANALYSIS_SIZE = Size(1280, 720)
} }
} }

View File

@@ -6,5 +6,6 @@ data class Student(
val name: String, val name: String,
val photoUri: String, val photoUri: String,
val embedding: FloatArray, val embedding: FloatArray,
val extraEmbeddings: List<FloatArray> = emptyList(),
val createdAt: Long = System.currentTimeMillis() val createdAt: Long = System.currentTimeMillis()
) )

View File

@@ -39,6 +39,7 @@ class StudentRepository(context: Context) {
name = obj.getString("name"), name = obj.getString("name"),
photoUri = obj.getString("photoUri"), photoUri = obj.getString("photoUri"),
embedding = decodeEmbedding(obj.getJSONArray("embedding")), embedding = decodeEmbedding(obj.getJSONArray("embedding")),
extraEmbeddings = decodeEmbeddings(obj.optJSONArray("extraEmbeddings")),
createdAt = obj.optLong("createdAt", System.currentTimeMillis()) createdAt = obj.optLong("createdAt", System.currentTimeMillis())
) )
) )
@@ -57,6 +58,10 @@ class StudentRepository(context: Context) {
put("photoUri", student.photoUri) put("photoUri", student.photoUri)
put("createdAt", student.createdAt) put("createdAt", student.createdAt)
put("embedding", JSONArray(student.embedding.toList())) put("embedding", JSONArray(student.embedding.toList()))
put(
"extraEmbeddings",
JSONArray(student.extraEmbeddings.map { JSONArray(it.toList()) })
)
} }
) )
} }
@@ -66,4 +71,13 @@ class StudentRepository(context: Context) {
private fun decodeEmbedding(array: JSONArray): FloatArray { private fun decodeEmbedding(array: JSONArray): FloatArray {
return FloatArray(array.length()) { index -> array.getDouble(index).toFloat() } return FloatArray(array.length()) { index -> array.getDouble(index).toFloat() }
} }
private fun decodeEmbeddings(array: JSONArray?): List<FloatArray> {
if (array == null) return emptyList()
return buildList {
for (index in 0 until array.length()) {
add(decodeEmbedding(array.getJSONArray(index)))
}
}
}
} }

View File

@@ -125,7 +125,7 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp
companion object { companion object {
private const val TAG = "FaceEmbedder" private const val TAG = "FaceEmbedder"
const val MODEL_ASSET_NAME = "facenet.tflite" const val MODEL_ASSET_NAME = "mobilefacenet.tflite"
} }
private fun inferInputSize(floatCount: Int): Int { private fun inferInputSize(floatCount: Int): Int {

View File

@@ -28,7 +28,13 @@ class FaceMatcher(
var secondNearestEuclidean = Float.MAX_VALUE var secondNearestEuclidean = Float.MAX_VALUE
for (student in students) { for (student in students) {
val cosineSim = cosineSimilarity(embedding, student.embedding) // 对该学生存储的所有特征(主特征 + 多角度补充特征)取最高相似度
var bestCosine = -Float.MAX_VALUE
for (candidate in student.allEmbeddings()) {
val cosineSim = cosineSimilarity(embedding, candidate)
if (cosineSim > bestCosine) bestCosine = cosineSim
}
val cosineSim = bestCosine
val euclideanDist = euclideanDistanceFromCosine(cosineSim) val euclideanDist = euclideanDistanceFromCosine(cosineSim)
if (euclideanDist < nearestEuclidean) { if (euclideanDist < nearestEuclidean) {
@@ -51,26 +57,6 @@ class FaceMatcher(
} }
} }
/**
* 查找所有可能的匹配(返回多个候选)
*/
fun findAllMatches(
embedding: FloatArray,
students: List<Student>,
topK: Int = 3
): List<MatchCandidate> {
val candidates = students.map { student ->
val cosineSim = cosineSimilarity(embedding, student.embedding)
MatchCandidate(
student = student,
euclideanDistance = euclideanDistanceFromCosine(cosineSim),
cosineSimilarity = cosineSim
)
}
return candidates.sortedBy { it.euclideanDistance }.take(topK)
}
/** /**
* 计算欧氏距离 * 计算欧氏距离
*/ */
@@ -84,6 +70,8 @@ class FaceMatcher(
return sqrt(sum) return sqrt(sum)
} }
private fun Student.allEmbeddings(): List<FloatArray> = listOf(embedding) + extraEmbeddings
/** /**
* 对已 L2 归一化向量,通过余弦相似度推导欧氏距离,避免重复遍历 * 对已 L2 归一化向量,通过余弦相似度推导欧氏距离,避免重复遍历
*/ */
@@ -136,14 +124,17 @@ class FaceMatcher(
/** /**
* 根据一组距离自适应调整阈值 * 根据一组距离自适应调整阈值
* 适用于首次导入大量数据后优化识别效果 * 基于已注册学生两两之间的(异类)距离分布给出保守建议:
* 阈值取异类距离分布的低分位,保证绝大多数异类对被正确拒绝。
* 注意:本方法没有同人距离数据,只能给出"上界"参考,
* 现场采集同人数据后可进一步放宽。
*/ */
fun suggestThresholds(students: List<Student>): SuggestedThresholds { fun suggestThresholds(students: List<Student>): SuggestedThresholds {
if (students.size < 3) { if (students.size < 3) {
return SuggestedThresholds(euclideanThreshold, cosineThreshold) return SuggestedThresholds(euclideanThreshold, cosineThreshold)
} }
// 计算所有学生两两之间的距离(采样) // 计算所有学生两两之间的距离(采样,上限 200 对
val distances = mutableListOf<Float>() val distances = mutableListOf<Float>()
val sampleSize = minOf(students.size, 50) val sampleSize = minOf(students.size, 50)
val step = maxOf(1, students.size / sampleSize) val step = maxOf(1, students.size / sampleSize)
@@ -161,13 +152,15 @@ class FaceMatcher(
distances.sort() distances.sort()
// 建议使用较小百分位作为阈值(假设同一个人多次录入的距离较小) // 使用低分位异类距离作为欧氏阈值上界:仅约 10% 的异类对会低于该距离
val p10Index = (distances.size * 0.1).toInt().coerceIn(0, distances.size - 1) val p10Index = (distances.size * 0.1).toInt().coerceIn(0, distances.size - 1)
val p20Index = (distances.size * 0.2).toInt().coerceIn(0, distances.size - 1) val p05Index = (distances.size * 0.05).toInt().coerceIn(0, distances.size - 1)
val suggestedEuclidean = distances[p10Index]
return SuggestedThresholds( return SuggestedThresholds(
euclidean = distances[p10Index], euclidean = suggestedEuclidean,
cosine = 1f - distances[p20Index] * 0.5f // 对 L2 归一化向量cos = 1 - d^2 / 2用更保守的 p05 距离推导余弦阈值
cosine = 1f - (distances[p05Index] * distances[p05Index]) / 2f
) )
} }
} }
@@ -182,15 +175,6 @@ enum class MatchType {
NO_DATA // 没有数据 NO_DATA // 没有数据
} }
/**
* 匹配候选
*/
data class MatchCandidate(
val student: Student,
val euclideanDistance: Float,
val cosineSimilarity: Float
)
/** /**
* 建议的阈值 * 建议的阈值
*/ */

View File

@@ -12,8 +12,10 @@ import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions import com.google.mlkit.vision.face.FaceDetectorOptions
import com.google.mlkit.vision.face.FaceLandmark import com.google.mlkit.vision.face.FaceLandmark
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import kotlin.math.abs
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
/** /**
@@ -79,6 +81,28 @@ class SmartEnrollment(context: Context) : AutoCloseable {
faceInfo.faceArea faceInfo.faceArea
} }
// 质量检查:人脸大小
val faceBox = largestFace.face.boundingBox
if (faceBox.width() < MIN_ENROLL_FACE_PX || faceBox.height() < MIN_ENROLL_FACE_PX) {
return EnrollmentResult.Failure(
"照片中的人脸太小(${faceBox.width()}px请换更近或更高清的照片。"
)
}
// 质量检查:角度
val yaw = yawAngle(largestFace.face)
val pitch = pitchAngle(largestFace.face)
if (abs(yaw) > MAX_ENROLL_YAW || abs(pitch) > MAX_ENROLL_PITCH) {
return EnrollmentResult.Failure(
"请使用正面人脸照片(当前左右偏转 ${yaw.roundToInt()}°、上下 ${pitch.roundToInt()}°)。"
)
}
// 质量检查:清晰度
if (!isSharpEnough(cropFace(bitmap, largestFace.cropRect))) {
return EnrollmentResult.Failure("照片模糊,请换更清晰的照片。")
}
val embeddings = mutableListOf<FloatArray>() val embeddings = mutableListOf<FloatArray>()
for (paddingRatio in IMAGE_PADDING_RATIOS) { for (paddingRatio in IMAGE_PADDING_RATIOS) {
val cropRect = safeCropRect(bitmap, largestFace.face.boundingBox, paddingRatio) ?: continue val cropRect = safeCropRect(bitmap, largestFace.face.boundingBox, paddingRatio) ?: continue
@@ -131,6 +155,9 @@ class SmartEnrollment(context: Context) : AutoCloseable {
faceInfo.faceArea faceInfo.faceArea
} }
if (largestFace.face.boundingBox.width() < MIN_ENROLL_FACE_PX) return@mapNotNull null
if (!isSharpEnough(cropFace(bitmap, largestFace.cropRect))) return@mapNotNull null
FaceFrameInfo(bitmap, largestFace.face, largestFace.cropRect, largestFace.faceArea) FaceFrameInfo(bitmap, largestFace.face, largestFace.cropRect, largestFace.faceArea)
} }
@@ -147,15 +174,15 @@ class SmartEnrollment(context: Context) : AutoCloseable {
val selectedFrames = selectBestFramesFromGroups(groupedFrames, maxFrames) val selectedFrames = selectBestFramesFromGroups(groupedFrames, maxFrames)
// 提取每帧的特征 // 提取每帧的特征
val embeddings = selectedFrames.map { frameInfo -> val frameEmbeddings = selectedFrames.map { frameInfo ->
val cropped = cropFace(frameInfo.bitmap, frameInfo.cropRect) frameInfo to embedFrame(frameInfo)
val aligned = alignFace(cropped, frameInfo.face, frameInfo.cropRect)
val preprocessed = preprocessor.preprocess(aligned)
embedder.embed(preprocessed)
} }
// 融合特征 // 按偏航角分组:正脸融合为主特征,左右侧脸融合为补充特征
val fusedEmbedding = fuseEmbeddings(embeddings) val (primaryEmbeddings, extraEmbeddings) = splitByYaw(frameEmbeddings)
val fusedEmbedding = fuseEmbeddings(primaryEmbeddings)
// splitByYaw 返回的补充特征已经是每组融合后的向量
val fusedExtras = extraEmbeddings
// 计算角度覆盖 // 计算角度覆盖
val yawRange = calculateAngleRange(selectedFrames, { f -> yawAngle(f.face) }) val yawRange = calculateAngleRange(selectedFrames, { f -> yawAngle(f.face) })
@@ -166,7 +193,8 @@ class SmartEnrollment(context: Context) : AutoCloseable {
sourceType = EnrollmentSourceType.VIDEO, sourceType = EnrollmentSourceType.VIDEO,
frameCount = selectedFrames.size, frameCount = selectedFrames.size,
yawRange = yawRange, yawRange = yawRange,
pitchRange = pitchRange pitchRange = pitchRange,
extraEmbeddings = fusedExtras
) )
} catch (e: Exception) { } catch (e: Exception) {
EnrollmentResult.Failure("视频处理失败:${e.message}") EnrollmentResult.Failure("视频处理失败:${e.message}")
@@ -337,6 +365,82 @@ class SmartEnrollment(context: Context) : AutoCloseable {
return Bitmap.createBitmap(bitmap, box.left, box.top, box.width(), box.height()) return Bitmap.createBitmap(bitmap, box.left, box.top, box.width(), box.height())
} }
/**
* 对单帧提取特征(裁剪 + 对齐 + 预处理 + 推理)
*/
private fun embedFrame(frameInfo: FaceFrameInfo): FloatArray {
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 splitByYaw(
frames: List<Pair<FaceFrameInfo, FloatArray>>
): Pair<List<FloatArray>, List<FloatArray>> {
val front = mutableListOf<FloatArray>()
val left = mutableListOf<FloatArray>()
val right = mutableListOf<FloatArray>()
for ((frameInfo, embedding) in frames) {
val yaw = yawAngle(frameInfo.face)
when {
yaw < -10f -> left.add(embedding)
yaw > 10f -> right.add(embedding)
else -> front.add(embedding)
}
}
val extras = mutableListOf<FloatArray>()
if (left.size >= 2) extras.add(fuseEmbeddings(left))
if (right.size >= 2) extras.add(fuseEmbeddings(right))
val primary = if (front.isNotEmpty()) front else frames.map { it.second }
return primary to extras
}
/**
* 基于 Laplacian 方差判断人脸区域是否清晰
*/
private fun isSharpEnough(bitmap: Bitmap, threshold: Double = BLUR_VARIANCE_THRESHOLD): Boolean {
val width = bitmap.width
val height = bitmap.height
if (width < 3 || height < 3) return false
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
var sum = 0.0
var sumSquares = 0.0
var count = 0L
for (y in 1 until height - 1) {
var index = y * width + 1
for (x in 1 until width - 1) {
val lap = 4 * luma(pixels[index]) -
luma(pixels[index - 1]) - luma(pixels[index + 1]) -
luma(pixels[index - width]) - luma(pixels[index + width])
sum += lap
sumSquares += lap * lap
count++
index++
}
}
if (count == 0L) return false
val mean = sum / count
val variance = sumSquares / count - mean * mean
return variance >= threshold
}
private fun luma(pixel: Int): Int {
val r = (pixel shr 16) and 0xFF
val g = (pixel shr 8) and 0xFF
val b = pixel and 0xFF
return (r * 299 + g * 587 + b * 114) / 1000
}
private suspend fun detectUsableFaces(bitmap: Bitmap): List<DetectedFace> { private suspend fun detectUsableFaces(bitmap: Bitmap): List<DetectedFace> {
return detector.process(InputImage.fromBitmap(bitmap, 0)).await() return detector.process(InputImage.fromBitmap(bitmap, 0)).await()
.mapNotNull { face -> .mapNotNull { face ->
@@ -430,6 +534,10 @@ class SmartEnrollment(context: Context) : AutoCloseable {
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) 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 BLUR_VARIANCE_THRESHOLD = 40.0
} }
} }
@@ -476,7 +584,8 @@ sealed class EnrollmentResult {
val sourceType: EnrollmentSourceType = EnrollmentSourceType.IMAGE, val sourceType: EnrollmentSourceType = EnrollmentSourceType.IMAGE,
val frameCount: Int = 1, val frameCount: Int = 1,
val yawRange: Float = 0f, val yawRange: Float = 0f,
val pitchRange: Float = 0f val pitchRange: Float = 0f,
val extraEmbeddings: List<FloatArray> = emptyList()
) : EnrollmentResult() { ) : EnrollmentResult() {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -489,6 +598,10 @@ sealed class EnrollmentResult {
if (pitchRange != other.pitchRange) return false if (pitchRange != other.pitchRange) return false
if (!embedding.contentEquals(other.embedding)) return false if (!embedding.contentEquals(other.embedding)) return false
if (sourceType != other.sourceType) return false if (sourceType != other.sourceType) return false
if (extraEmbeddings.size != other.extraEmbeddings.size) return false
if (extraEmbeddings.indices.any {
!extraEmbeddings[it].contentEquals(other.extraEmbeddings[it])
}) return false
return true return true
} }
@@ -499,6 +612,7 @@ sealed class EnrollmentResult {
result = 31 * result + pitchRange.hashCode() result = 31 * result + pitchRange.hashCode()
result = 31 * result + embedding.contentHashCode() result = 31 * result + embedding.contentHashCode()
result = 31 * result + sourceType.hashCode() result = 31 * result + sourceType.hashCode()
result = 31 * result + extraEmbeddings.sumOf { it.contentHashCode() }
return result return result
} }
} }

View File

@@ -1,388 +0,0 @@
package com.example.studentfaceregistry.face
import android.graphics.Bitmap
import android.graphics.Rect
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.Face
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
import com.google.mlkit.vision.face.FaceLandmark
import kotlinx.coroutines.tasks.await
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* 视频人脸注册器
* 从视频中提取多角度人脸帧,融合生成更鲁棒的特征向量
*
* 使用场景:
* - 学生录制视频时转头、点头
* - 自动选择质量好的帧
* - 融合多帧特征,提升识别准确率
*/
class VideoEnrollment(private val context: android.content.Context) : AutoCloseable {
private val detector = FaceDetection.getClient(
FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
.build()
)
private val embedder = FaceEmbedder(context, FaceEmbedder.ModelType.ARCFACE)
/**
* 从视频帧序列中提取人脸特征
* @param frames 视频帧列表Bitmap
* @param minFrames 最少需要的人脸帧数
* @param maxFrames 最多使用的人脸帧数(用于特征融合)
* @return 融合后的特征向量
*/
suspend fun enrollFromFrames(
frames: List<Bitmap>,
minFrames: Int = 5,
maxFrames: Int = 20
): VideoEnrollmentResult {
val faceFrames = mutableListOf<FaceFrame>()
// 检测所有帧中的人脸
for ((index, frame) in frames.withIndex()) {
val faces = detector.process(InputImage.fromBitmap(frame, 0)).await()
for (face in faces) {
val quality = calculateFaceQuality(face)
if (quality >= MIN_QUALITY_THRESHOLD) {
val yaw = calculateYawAngle(face)
val pitch = calculatePitchAngle(face)
faceFrames.add(
FaceFrame(
bitmap = frame,
face = face,
quality = quality,
yaw = yaw,
pitch = pitch,
frameIndex = index
)
)
}
}
}
if (faceFrames.size < minFrames) {
return VideoEnrollmentResult.Failure(
reason = "检测到 ${faceFrames.size} 帧有效人脸,需要至少 $minFrames 帧。请确保视频中人脸清晰且有多角度展示。"
)
}
// 按角度分组,确保多角度覆盖
val groupedFrames = groupFramesByAngle(faceFrames)
// 从每组选择质量最好的帧
val selectedFrames = selectBestFramesFromGroups(groupedFrames, maxFrames)
// 提取每帧的特征
val embeddings = selectedFrames.map { frame ->
extractSingleEmbedding(frame)
}
// 融合特征(平均 + L2 归一化)
val fusedEmbedding = fuseEmbeddings(embeddings)
return VideoEnrollmentResult.Success(
embedding = fusedEmbedding,
frameCount = selectedFrames.size,
totalDetected = faceFrames.size,
angleCoverage = calculateAngleCoverage(selectedFrames)
)
}
/**
* 计算人脸质量分数0-1
*/
private fun calculateFaceQuality(face: Face): Float {
var score = 1.0f
// 可见度分数
val leftEyeVisible = face.getLandmark(FaceLandmark.LEFT_EYE) != null
val rightEyeVisible = face.getLandmark(FaceLandmark.RIGHT_EYE) != null
if (!leftEyeVisible || !rightEyeVisible) score *= 0.7f
// 置信度分数
val confidence = face.trackingId?.let { 1.0f } ?: 0.9f
score *= confidence
// 人脸大小分数(越大越好)
val faceArea = face.boundingBox.width() * face.boundingBox.height()
val sizeScore = (faceArea / 100000f).coerceIn(0.5f, 1.0f)
score *= sizeScore
return score
}
/**
* 计算人脸偏航角(左右转头,-90 到 90 度)
*/
private fun calculateYawAngle(face: Face): Float {
val leftCheek = face.getLandmark(FaceLandmark.LEFT_CHEEK) ?: return 0f
val rightCheek = face.getLandmark(FaceLandmark.RIGHT_CHEEK) ?: return 0f
val dx = rightCheek.position.x - leftCheek.position.x
val dy = rightCheek.position.y - leftCheek.position.y
// 计算角度
val angle = atan2(dy.toDouble(), dx.toDouble()).toFloat() * 180f / PI.toFloat()
return angle.coerceIn(-90f, 90f)
}
/**
* 计算人脸俯仰角(上下点头,-90 到 90 度)
*/
private fun calculatePitchAngle(face: Face): Float {
val nose = face.getLandmark(FaceLandmark.NOSE_BASE) ?: return 0f
val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE) ?: return 0f
val rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE) ?: return 0f
// 眼睛中心点
val eyeCenterY = (leftEye.position.y + rightEye.position.y) / 2f
val dx = nose.position.x - (leftEye.position.x + rightEye.position.x) / 2f
val dy = nose.position.y - eyeCenterY
val angle = atan2(dy.toDouble(), dx.toDouble()).toFloat() * 180f / PI.toFloat()
return angle.coerceIn(-90f, 90f)
}
/**
* 按角度分组人脸帧
*/
private fun groupFramesByAngle(frames: List<FaceFrame>): Map<VideoAngleBucket, List<FaceFrame>> {
val buckets = mutableMapOf<VideoAngleBucket, MutableList<FaceFrame>>()
for (frame in frames) {
val bucket = getAngleBucket(frame.yaw, frame.pitch)
if (!buckets.containsKey(bucket)) {
buckets[bucket] = mutableListOf()
}
buckets[bucket]!!.add(frame)
}
return buckets
}
/**
* 获取角度分组
*/
private fun getAngleBucket(yaw: Float, pitch: Float): VideoAngleBucket {
val yawBucket = when {
yaw < -30 -> -2 // 左
yaw < -10 -> -1 // 左前
yaw < 10 -> 0 // 正
yaw < 30 -> 1 // 右前
else -> 2 // 右
}
val pitchBucket = when {
pitch < -15 -> -1 // 上
pitch < 15 -> 0 // 中
else -> 1 // 下
}
return VideoAngleBucket(yawBucket, pitchBucket)
}
/**
* 从每组中选择质量最好的帧
*/
private fun selectBestFramesFromGroups(
groups: Map<VideoAngleBucket, List<FaceFrame>>,
maxFrames: Int
): List<FaceFrame> {
val selected = mutableListOf<FaceFrame>()
// 先按组排序(组内按质量降序)
val sortedGroups = groups.entries.sortedByDescending { entry ->
entry.value.maxOfOrNull { it.quality } ?: 0f
}
for ((_, frames) in sortedGroups) {
val sortedFrames = frames.sortedByDescending { it.quality }
for (frame in sortedFrames) {
if (selected.size >= maxFrames) break
// 避免选择同一帧多次
if (!selected.any { it.frameIndex == frame.frameIndex }) {
selected.add(frame)
}
}
}
return selected
}
/**
* 提取单帧人脸特征
*/
private fun extractSingleEmbedding(frame: FaceFrame): FloatArray {
val bitmap = frame.bitmap
val face = frame.face
// 裁剪人脸
val cropped = cropFace(bitmap, face.boundingBox)
// 对齐
val aligned = alignFace(cropped, face)
// 预处理
val preprocessor = ImagePreprocessor()
val preprocessed = preprocessor.preprocess(aligned)
// 提取特征
return embedder.embed(preprocessed)
}
/**
* 融合多个特征向量
*/
private fun fuseEmbeddings(embeddings: List<FloatArray>): FloatArray {
if (embeddings.isEmpty()) throw IllegalArgumentException("embeddings is empty")
if (embeddings.size == 1) return embeddings[0]
val size = embeddings[0].size
val sum = FloatArray(size)
for (embedding in embeddings) {
for (i in 0 until size) {
sum[i] += embedding[i]
}
}
// 平均
for (i in 0 until size) {
sum[i] /= embeddings.size
}
// L2 归一化
return l2Normalize(sum)
}
/**
* 计算角度覆盖范围
*/
private fun calculateAngleCoverage(frames: List<FaceFrame>): AngleCoverage {
var minYaw = Float.MAX_VALUE
var maxYaw = -Float.MAX_VALUE
var minPitch = Float.MAX_VALUE
var maxPitch = -Float.MAX_VALUE
for (frame in frames) {
minYaw = minOf(minYaw, frame.yaw)
maxYaw = maxOf(maxYaw, frame.yaw)
minPitch = minOf(minPitch, frame.pitch)
maxPitch = maxOf(maxPitch, frame.pitch)
}
return AngleCoverage(
yawRange = maxYaw - minYaw,
pitchRange = maxPitch - minPitch,
minYaw = minYaw,
maxYaw = maxYaw,
minPitch = minPitch,
maxPitch = maxPitch
)
}
/**
* 裁剪人脸
*/
private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap {
val padding = (maxOf(box.width(), box.height()) * 0.25f).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)
val bottom = (box.bottom + padding).coerceIn(0, bitmap.height)
return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
}
/**
* 对齐人脸
*/
private fun alignFace(bitmap: Bitmap, face: Face): Bitmap {
val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE) ?: return bitmap
val rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE) ?: return bitmap
val aligner = FaceAligner()
return aligner.align(
bitmap = bitmap,
leftEyeX = leftEye.position.x,
leftEyeY = leftEye.position.y,
rightEyeX = rightEye.position.x,
rightEyeY = rightEye.position.y,
faceLeft = 0,
faceTop = 0
)
}
private fun l2Normalize(values: FloatArray): FloatArray {
var sum = 0f
for (value in values) sum += value * value
val norm = sqrt(sum.coerceAtLeast(1e-12f))
return FloatArray(values.size) { values[it] / norm }
}
override fun close() {
detector.close()
embedder.close()
}
companion object {
private const val MIN_QUALITY_THRESHOLD = 0.5f
}
}
/**
* 人脸帧信息
*/
data class FaceFrame(
val bitmap: Bitmap,
val face: Face,
val quality: Float,
val yaw: Float, // 偏航角(左右转头)
val pitch: Float, // 俯仰角(上下点头)
val frameIndex: Int
)
/**
* 角度分组
*/
data class VideoAngleBucket(val yaw: Int, val pitch: Int)
/**
* 角度覆盖范围
*/
data class AngleCoverage(
val yawRange: Float,
val pitchRange: Float,
val minYaw: Float,
val maxYaw: Float,
val minPitch: Float,
val maxPitch: Float
)
/**
* 注册结果
*/
sealed class VideoEnrollmentResult {
data class Success(
val embedding: FloatArray,
val frameCount: Int,
val totalDetected: Int,
val angleCoverage: AngleCoverage
) : VideoEnrollmentResult()
data class Failure(val reason: String) : VideoEnrollmentResult()
}

View File

@@ -226,7 +226,8 @@ class UploadServer(
studentNo = studentNo, studentNo = studentNo,
name = name, name = name,
photoUri = "upload://$fileName", photoUri = "upload://$fileName",
embedding = result.embedding embedding = result.embedding,
extraEmbeddings = result.extraEmbeddings
) )
) )
val sourceInfo = if (result.sourceType == com.example.studentfaceregistry.face.EnrollmentSourceType.VIDEO) { val sourceInfo = if (result.sourceType == com.example.studentfaceregistry.face.EnrollmentSourceType.VIDEO) {

View File

@@ -1,134 +1,62 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
ArcFace ONNX to TFLite 转换脚本 ONNX 人脸模型转 TFLite 脚本(基于 onnx2tf
使用方法: 环境要求(推荐使用已有 onnx2tf 的 conda 环境):
1. 先安装依赖pip install onnx onnxruntime tensorflow conda activate base # 已安装 onnx2tf + ai-edge-litert
2. 运行转换python convert_model.py 或 pip install onnx2tf ai-edge-litert onnx onnxruntime
用法:
python convert_model.py <input.onnx> <output.tflite>
示例:
python convert_model.py backup_models/buffalo_sc/w600k_mbf.onnx app/app/src/main/assets/mobilefacenet.tflite
""" """
import os import os
import numpy as np import shutil
import subprocess import subprocess
import sys import sys
import tempfile
def install_requirements():
"""安装必要的包"""
packages = ['onnx', 'onnxruntime', 'tensorflow', 'tf2onnx', 'onnx-tf']
print("检查并安装依赖包...")
for pkg in packages:
try:
__import__(pkg.replace('-', '_'))
print(f"{pkg} 已安装")
except ImportError:
print(f" 安装 {pkg}...")
subprocess.check_call([sys.executable, '-m', 'pip', 'install', pkg, '-q'])
print("依赖包安装完成!\n")
def download_model(): def main() -> int:
"""下载 ArcFace 模型""" if len(sys.argv) != 3:
model_url = "https://drive.google.com/uc?export=download&id=1gnt6P3jaiwfevV4hreWHPu0Mive5VRyP" print(__doc__)
output_path = "/Users/liushuming/projects/app/app/src/main/assets/arcface_ir50_glint360k.onnx" return 1
print("下载 ArcFace 模型 (IResNet-50, Glint360K 数据集)...") onnx_path = sys.argv[1]
print(f"目标路径:{output_path}") tflite_path = sys.argv[2]
import urllib.request if not os.path.isfile(onnx_path):
urllib.request.urlretrieve(model_url, output_path) print(f"错误:找不到输入模型 {onnx_path}")
return 1
size_mb = os.path.getsize(output_path) / 1024 / 1024
print(f"下载完成!模型大小:{size_mb:.2f} MB\n")
return output_path
def convert_onnx_to_tflite(onnx_path, output_path):
"""将 ONNX 模型转换为 TFLite"""
print(f"转换 ONNX -> TFLite...")
print(f"输入:{onnx_path}")
print(f"输出:{output_path}")
work_dir = tempfile.mkdtemp(prefix="onnx2tf_")
try: try:
import onnx print(f"转换 {onnx_path} -> {tflite_path}")
from onnx_tf.backend import prepare subprocess.check_call(
[sys.executable, "-m", "onnx2tf", "-i", onnx_path, "-o", work_dir]
)
# 加载 ONNX 模型 base = os.path.splitext(os.path.basename(onnx_path))[0]
onnx_model = onnx.load(onnx_path) converted = os.path.join(work_dir, f"{base}_float32.tflite")
onnx.checker.check_model(onnx_model) if not os.path.isfile(converted):
candidates = [
f for f in os.listdir(work_dir) if f.endswith("_float32.tflite")
]
if not candidates:
print("错误onnx2tf 未生成 float32 tflite请查看上方日志")
return 1
converted = os.path.join(work_dir, candidates[0])
# 打印模型信息 os.makedirs(os.path.dirname(tflite_path) or ".", exist_ok=True)
inputs = onnx_model.graph.input shutil.copyfile(converted, tflite_path)
outputs = onnx_model.graph.output print(f"完成:{tflite_path}{os.path.getsize(tflite_path) / 1024 / 1024:.2f} MB")
print(f"\n模型输入:{inputs[0].name}, 形状:{[d.dim_value for d in inputs[0].type.tensor_type.shape.dim]}") return 0
print(f"模型输出:{outputs[0].name}, 形状:{[d.dim_value for d in outputs[0].type.tensor_type.shape.dim]}") finally:
shutil.rmtree(work_dir, ignore_errors=True)
# 转换为 TensorFlow
print("\n转换为 TensorFlow 格式...")
tf_backend = prepare(onnx_model)
tf_graph = tf_backend.tf_graph
# 保存为 SavedModel
import tensorflow as tf
saved_model_dir = output_path.replace('.tflite', '_saved_model')
# 清理已存在的目录
import shutil
if os.path.exists(saved_model_dir):
shutil.rmtree(saved_model_dir)
# 保存 graph
with tf.Graph().as_default() as graph:
tf.import_graph_def(tf_graph, name="")
tf.saved_model.save(tf.keras.models.Model(inputs=graph.get_tensor_by_name('input:0'),
outputs=graph.get_tensor_by_name('output:0')),
saved_model_dir)
# 转换为 TFLite
print("转换为 TFLite 格式...")
converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_model = converter.convert()
with open(output_path, 'wb') as f:
f.write(tflite_model)
# 清理临时文件
shutil.rmtree(saved_model_dir)
size_mb = os.path.getsize(output_path) / 1024 / 1024
print(f"\n✓ 转换完成!")
print(f" TFLite 模型:{output_path}")
print(f" 模型大小:{size_mb:.2f} MB")
return True
except ImportError as e:
print(f"\n错误:缺少依赖包 - {e}")
print("请运行pip install onnx-tf tensorflow")
return False
except Exception as e:
print(f"\n转换错误:{e}")
return False
def main():
print("=" * 60)
print("ArcFace 模型下载和转换工具")
print("=" * 60)
# 安装依赖
install_requirements()
# 下载模型
onnx_path = download_model()
# 转换为 TFLite
output_path = "/Users/liushuming/projects/app/app/src/main/assets/facenet.tflite"
convert_onnx_to_tflite(onnx_path, output_path)
print("\n" + "=" * 60)
print("完成!")
print("模型已保存到app/src/main/assets/facenet.tflite")
print("=" * 60)
if __name__ == "__main__": if __name__ == "__main__":
main() sys.exit(main())