change model with a sc model
This commit is contained in:
@@ -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.
BIN
app/src/main/assets/mobilefacenet.tflite
Normal file
BIN
app/src/main/assets/mobilefacenet.tflite
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 建议的阈值
|
* 建议的阈值
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
158
convert_model.py
158
convert_model.py
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user