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 output: a float embedding vector.
|
||||
Expected input: [1, 112, 112, 3] (float32, RGB, values normalized to [-1, 1]).
|
||||
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 var lastVisibleDetections: List<DetectionUi> = emptyList()
|
||||
private var lastVisibleDetectionsAt = 0L
|
||||
private var perfFrameCount = 0
|
||||
private var perfDetectMsSum = 0L
|
||||
private var perfRefreshMsSum = 0L
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
@@ -285,6 +288,10 @@ class MainActivity : AppCompatActivity() {
|
||||
text = "保存识别参数"
|
||||
setOnClickListener { saveMatcherConfig() }
|
||||
})
|
||||
matcherConfigPanel.addView(Button(this@MainActivity).apply {
|
||||
text = "自动校准阈值"
|
||||
setOnClickListener { applySuggestedThresholds() }
|
||||
})
|
||||
|
||||
addView(matcherConfigPanel)
|
||||
|
||||
@@ -431,7 +438,39 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun startRecognitionSession() {
|
||||
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 {
|
||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||
providerFuture.addListener({
|
||||
@@ -515,7 +554,9 @@ class MainActivity : AppCompatActivity() {
|
||||
try {
|
||||
val mediaImage = image.image ?: return@launch
|
||||
val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
|
||||
val detectStart = SystemClock.elapsedRealtime()
|
||||
val faces = faceProcessor.detectFaces(inputImage)
|
||||
val detectMs = SystemClock.elapsedRealtime() - detectStart
|
||||
|
||||
if (faces.isEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -543,11 +584,27 @@ class MainActivity : AppCompatActivity() {
|
||||
val refreshedResults = mutableMapOf<Int, RecognitionResult>()
|
||||
|
||||
if (facesNeedingRefresh.isNotEmpty()) {
|
||||
val refreshStart = SystemClock.elapsedRealtime()
|
||||
val bitmap = ImageUtils.imageProxyToBitmap(image)
|
||||
facesNeedingRefresh.forEach { (index, face) ->
|
||||
val refreshed = refreshRecognition(faceProcessor, bitmap, face)
|
||||
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 ->
|
||||
@@ -655,23 +712,6 @@ class MainActivity : AppCompatActivity() {
|
||||
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 {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
@@ -722,6 +762,20 @@ class MainActivity : AppCompatActivity() {
|
||||
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 {
|
||||
return String.format(java.util.Locale.US, "%.2f", value)
|
||||
}
|
||||
@@ -860,11 +914,11 @@ class MainActivity : AppCompatActivity() {
|
||||
): RecognitionResult {
|
||||
val primary = matcher.findNearest(faceProcessor.embedFace(bitmap, face), students)
|
||||
val cached = face.trackingId?.let { recognitionCache[it] }
|
||||
// 未匹配时只做单次推理;仅在疑似匹配或人脸明显移动时才用多裁剪升级,
|
||||
// 避免对陌生面孔持续做 3 次推理
|
||||
val needsEscalation = cached == null ||
|
||||
!cached.result.isMatched ||
|
||||
primary.matchType != MatchType.MATCH ||
|
||||
primary.confidence < cached.result.confidence - 0.05f ||
|
||||
hasTrackMovedSignificantly(cached.lastBounds, face.boundingBox)
|
||||
hasTrackMovedSignificantly(cached.lastBounds, face.boundingBox) ||
|
||||
primary.matchType != MatchType.NO_MATCH
|
||||
|
||||
if (!needsEscalation) {
|
||||
return primary
|
||||
@@ -944,15 +998,15 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val DEFAULT_EUCLIDEAN_THRESHOLD = 1.0f
|
||||
private const val DEFAULT_COSINE_THRESHOLD = 0.6f
|
||||
private const val MATCH_REFRESH_MS = 900L
|
||||
private const val UNSURE_REFRESH_MS = 260L
|
||||
private const val NO_MATCH_REFRESH_MS = 160L
|
||||
private const val UNSURE_REFRESH_MS = 500L
|
||||
private const val NO_MATCH_REFRESH_MS = 800L
|
||||
private const val TRACK_STALE_MS = 900L
|
||||
private const val TRACK_MOVE_THRESHOLD = 0.16f
|
||||
private const val TRACK_SIZE_THRESHOLD = 0.20f
|
||||
private const val MATCH_STABLE_CONFIDENCE = 0.82f
|
||||
private const val ANALYSIS_INTERVAL_MS = 80L
|
||||
private const val DETECTION_GRACE_MS = 700L
|
||||
private val ANALYSIS_SIZE = Size(640, 480)
|
||||
private val ANALYSIS_SIZE = Size(1280, 720)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,6 @@ data class Student(
|
||||
val name: String,
|
||||
val photoUri: String,
|
||||
val embedding: FloatArray,
|
||||
val extraEmbeddings: List<FloatArray> = emptyList(),
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ class StudentRepository(context: Context) {
|
||||
name = obj.getString("name"),
|
||||
photoUri = obj.getString("photoUri"),
|
||||
embedding = decodeEmbedding(obj.getJSONArray("embedding")),
|
||||
extraEmbeddings = decodeEmbeddings(obj.optJSONArray("extraEmbeddings")),
|
||||
createdAt = obj.optLong("createdAt", System.currentTimeMillis())
|
||||
)
|
||||
)
|
||||
@@ -57,6 +58,10 @@ class StudentRepository(context: Context) {
|
||||
put("photoUri", student.photoUri)
|
||||
put("createdAt", student.createdAt)
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -28,7 +28,13 @@ class FaceMatcher(
|
||||
var secondNearestEuclidean = Float.MAX_VALUE
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun Student.allEmbeddings(): List<FloatArray> = listOf(embedding) + extraEmbeddings
|
||||
|
||||
/**
|
||||
* 对已 L2 归一化向量,通过余弦相似度推导欧氏距离,避免重复遍历
|
||||
*/
|
||||
@@ -136,14 +124,17 @@ class FaceMatcher(
|
||||
|
||||
/**
|
||||
* 根据一组距离自适应调整阈值
|
||||
* 适用于首次导入大量数据后优化识别效果
|
||||
* 基于已注册学生两两之间的(异类)距离分布给出保守建议:
|
||||
* 阈值取异类距离分布的低分位,保证绝大多数异类对被正确拒绝。
|
||||
* 注意:本方法没有同人距离数据,只能给出"上界"参考,
|
||||
* 现场采集同人数据后可进一步放宽。
|
||||
*/
|
||||
fun suggestThresholds(students: List<Student>): SuggestedThresholds {
|
||||
if (students.size < 3) {
|
||||
return SuggestedThresholds(euclideanThreshold, cosineThreshold)
|
||||
}
|
||||
|
||||
// 计算所有学生两两之间的距离(采样)
|
||||
// 计算所有学生两两之间的距离(采样,上限 200 对)
|
||||
val distances = mutableListOf<Float>()
|
||||
val sampleSize = minOf(students.size, 50)
|
||||
val step = maxOf(1, students.size / sampleSize)
|
||||
@@ -161,13 +152,15 @@ class FaceMatcher(
|
||||
|
||||
distances.sort()
|
||||
|
||||
// 建议使用较小百分位作为阈值(假设同一个人多次录入的距离较小)
|
||||
// 使用低分位异类距离作为欧氏阈值上界:仅约 10% 的异类对会低于该距离
|
||||
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(
|
||||
euclidean = distances[p10Index],
|
||||
cosine = 1f - distances[p20Index] * 0.5f
|
||||
euclidean = suggestedEuclidean,
|
||||
// 对 L2 归一化向量:cos = 1 - d^2 / 2,用更保守的 p05 距离推导余弦阈值
|
||||
cosine = 1f - (distances[p05Index] * distances[p05Index]) / 2f
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -182,15 +175,6 @@ enum class MatchType {
|
||||
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.FaceLandmark
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
@@ -79,6 +81,28 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
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>()
|
||||
for (paddingRatio in IMAGE_PADDING_RATIOS) {
|
||||
val cropRect = safeCropRect(bitmap, largestFace.face.boundingBox, paddingRatio) ?: continue
|
||||
@@ -131,6 +155,9 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -147,15 +174,15 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
val selectedFrames = selectBestFramesFromGroups(groupedFrames, maxFrames)
|
||||
|
||||
// 提取每帧的特征
|
||||
val embeddings = selectedFrames.map { frameInfo ->
|
||||
val cropped = cropFace(frameInfo.bitmap, frameInfo.cropRect)
|
||||
val aligned = alignFace(cropped, frameInfo.face, frameInfo.cropRect)
|
||||
val preprocessed = preprocessor.preprocess(aligned)
|
||||
embedder.embed(preprocessed)
|
||||
val frameEmbeddings = selectedFrames.map { frameInfo ->
|
||||
frameInfo to embedFrame(frameInfo)
|
||||
}
|
||||
|
||||
// 融合特征
|
||||
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) })
|
||||
@@ -166,7 +193,8 @@ class SmartEnrollment(context: Context) : AutoCloseable {
|
||||
sourceType = EnrollmentSourceType.VIDEO,
|
||||
frameCount = selectedFrames.size,
|
||||
yawRange = yawRange,
|
||||
pitchRange = pitchRange
|
||||
pitchRange = pitchRange,
|
||||
extraEmbeddings = fusedExtras
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
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())
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单帧提取特征(裁剪 + 对齐 + 预处理 + 推理)
|
||||
*/
|
||||
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> {
|
||||
return detector.process(InputImage.fromBitmap(bitmap, 0)).await()
|
||||
.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_MIME_TYPES = setOf("application/mp4")
|
||||
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 frameCount: Int = 1,
|
||||
val yawRange: Float = 0f,
|
||||
val pitchRange: Float = 0f
|
||||
val pitchRange: Float = 0f,
|
||||
val extraEmbeddings: List<FloatArray> = emptyList()
|
||||
) : EnrollmentResult() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -489,6 +598,10 @@ sealed class EnrollmentResult {
|
||||
if (pitchRange != other.pitchRange) return false
|
||||
if (!embedding.contentEquals(other.embedding)) 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
|
||||
}
|
||||
@@ -499,6 +612,7 @@ sealed class EnrollmentResult {
|
||||
result = 31 * result + pitchRange.hashCode()
|
||||
result = 31 * result + embedding.contentHashCode()
|
||||
result = 31 * result + sourceType.hashCode()
|
||||
result = 31 * result + extraEmbeddings.sumOf { it.contentHashCode() }
|
||||
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,
|
||||
name = name,
|
||||
photoUri = "upload://$fileName",
|
||||
embedding = result.embedding
|
||||
embedding = result.embedding,
|
||||
extraEmbeddings = result.extraEmbeddings
|
||||
)
|
||||
)
|
||||
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
|
||||
"""
|
||||
ArcFace ONNX to TFLite 转换脚本
|
||||
ONNX 人脸模型转 TFLite 脚本(基于 onnx2tf)
|
||||
|
||||
使用方法:
|
||||
1. 先安装依赖:pip install onnx onnxruntime tensorflow
|
||||
2. 运行转换:python convert_model.py
|
||||
环境要求(推荐使用已有 onnx2tf 的 conda 环境):
|
||||
conda activate base # 已安装 onnx2tf + ai-edge-litert
|
||||
或 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 numpy as np
|
||||
import shutil
|
||||
import subprocess
|
||||
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():
|
||||
"""下载 ArcFace 模型"""
|
||||
model_url = "https://drive.google.com/uc?export=download&id=1gnt6P3jaiwfevV4hreWHPu0Mive5VRyP"
|
||||
output_path = "/Users/liushuming/projects/app/app/src/main/assets/arcface_ir50_glint360k.onnx"
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
print("下载 ArcFace 模型 (IResNet-50, Glint360K 数据集)...")
|
||||
print(f"目标路径:{output_path}")
|
||||
onnx_path = sys.argv[1]
|
||||
tflite_path = sys.argv[2]
|
||||
|
||||
import urllib.request
|
||||
urllib.request.urlretrieve(model_url, output_path)
|
||||
|
||||
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}")
|
||||
if not os.path.isfile(onnx_path):
|
||||
print(f"错误:找不到输入模型 {onnx_path}")
|
||||
return 1
|
||||
|
||||
work_dir = tempfile.mkdtemp(prefix="onnx2tf_")
|
||||
try:
|
||||
import onnx
|
||||
from onnx_tf.backend import prepare
|
||||
print(f"转换 {onnx_path} -> {tflite_path}")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "onnx2tf", "-i", onnx_path, "-o", work_dir]
|
||||
)
|
||||
|
||||
# 加载 ONNX 模型
|
||||
onnx_model = onnx.load(onnx_path)
|
||||
onnx.checker.check_model(onnx_model)
|
||||
base = os.path.splitext(os.path.basename(onnx_path))[0]
|
||||
converted = os.path.join(work_dir, f"{base}_float32.tflite")
|
||||
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])
|
||||
|
||||
# 打印模型信息
|
||||
inputs = onnx_model.graph.input
|
||||
outputs = onnx_model.graph.output
|
||||
print(f"\n模型输入:{inputs[0].name}, 形状:{[d.dim_value for d in inputs[0].type.tensor_type.shape.dim]}")
|
||||
print(f"模型输出:{outputs[0].name}, 形状:{[d.dim_value for d in outputs[0].type.tensor_type.shape.dim]}")
|
||||
os.makedirs(os.path.dirname(tflite_path) or ".", exist_ok=True)
|
||||
shutil.copyfile(converted, tflite_path)
|
||||
print(f"完成:{tflite_path}({os.path.getsize(tflite_path) / 1024 / 1024:.2f} MB)")
|
||||
return 0
|
||||
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__":
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
||||
Reference in New Issue
Block a user