改进识别架构为insightface
This commit is contained in:
65
.claude/CLAUDE.md
Normal file
65
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(archiver:*)",
|
||||
"Bash(mdfind:*)",
|
||||
"Bash(open:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(./gradlew :app:compileDebugKotlin --no-daemon)",
|
||||
"Bash(pip3 show tensorflow tensorflow-lite-transformers)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,10 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.studentfaceregistry.data.StudentRepository
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
import com.example.studentfaceregistry.face.FaceEmbedder
|
||||
import com.example.studentfaceregistry.face.FaceMatcher
|
||||
import com.example.studentfaceregistry.face.FaceProcessor
|
||||
import com.example.studentfaceregistry.face.MatchType
|
||||
import com.example.studentfaceregistry.ui.DetectionUi
|
||||
import com.example.studentfaceregistry.ui.OverlayView
|
||||
import com.example.studentfaceregistry.upload.UploadServer
|
||||
@@ -43,7 +45,8 @@ class MainActivity : AppCompatActivity() {
|
||||
private var uploadServer: UploadServer? = null
|
||||
|
||||
private val repository by lazy { StudentRepository(this) }
|
||||
private val matcher = FaceMatcher()
|
||||
// ArcFace 推荐阈值
|
||||
private val matcher = FaceMatcher(euclideanThreshold = 1.0f, cosineThreshold = 0.6f)
|
||||
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
private var students: List<Student> = emptyList()
|
||||
private var recognitionEnabled = true
|
||||
@@ -52,7 +55,8 @@ class MainActivity : AppCompatActivity() {
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { grants ->
|
||||
if (grants[Manifest.permission.CAMERA] == true) startCamera() else toast("需要相机权限才能识别学生身份。")
|
||||
if (grants[Manifest.permission.CAMERA] == true) startCamera()
|
||||
else toast("需要相机权限才能识别学生身份。")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -89,6 +93,7 @@ class MainActivity : AppCompatActivity() {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(0xFFF8FAFC.toInt())
|
||||
}
|
||||
|
||||
val header = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(32, 28, 32, 20)
|
||||
@@ -175,14 +180,22 @@ class MainActivity : AppCompatActivity() {
|
||||
try {
|
||||
val bitmap = withContext(Dispatchers.Default) { ImageUtils.imageProxyToBitmap(image) }
|
||||
val faces = faceProcessor.detectFaces(bitmap)
|
||||
|
||||
// 同时识别所有人脸
|
||||
val detections = faces.map { face ->
|
||||
val embedding = faceProcessor.embedFace(bitmap, face)
|
||||
DetectionUi(face.boundingBox, matcher.findNearest(embedding, students))
|
||||
val result = matcher.findNearest(embedding, students)
|
||||
DetectionUi(
|
||||
bounds = face.boundingBox,
|
||||
result = result,
|
||||
confidence = result.confidence
|
||||
)
|
||||
}
|
||||
|
||||
overlayView.update(detections, bitmap.width, bitmap.height)
|
||||
statusText.text = detections.firstOrNull()?.result?.student?.let {
|
||||
"识别到:${it.name}(${it.studentNo})"
|
||||
} ?: if (detections.isEmpty()) "未检测到人脸" else "检测到未入库人脸"
|
||||
|
||||
// 显示所有识别结果
|
||||
updateStatusText(detections)
|
||||
} catch (_: Exception) {
|
||||
statusText.text = "识别暂不可用,请确认模型文件和相机画面。"
|
||||
} finally {
|
||||
@@ -192,6 +205,40 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatusText(detections: List<DetectionUi>) {
|
||||
if (detections.isEmpty()) {
|
||||
statusText.text = "未检测到人脸"
|
||||
return
|
||||
}
|
||||
|
||||
// 统计识别结果
|
||||
val matched = detections.filter { it.result.isMatched }
|
||||
val unsure = detections.filter { it.result.matchType == MatchType.UNSURE }
|
||||
val unmatched = detections.filter { !it.result.isMatched && it.result.matchType != MatchType.UNSURE }
|
||||
|
||||
val total = detections.size
|
||||
val matchedCount = matched.size
|
||||
|
||||
if (matchedCount > 0) {
|
||||
if (matchedCount == 1 && total == 1) {
|
||||
val student = matched[0].result.student!!
|
||||
val confidence = (matched[0].confidence!! * 100).toInt()
|
||||
statusText.text = "识别到:${student.name}(${student.studentNo})置信度${confidence}%"
|
||||
} else {
|
||||
val names = matched.joinToString(", ") {
|
||||
val s = it.result.student!!
|
||||
val c = (it.confidence!! * 100).toInt()
|
||||
"${s.name}(${c}%)"
|
||||
}
|
||||
statusText.text = "识别到 $matchedCount/$total 人:$names"
|
||||
}
|
||||
} else if (unsure.isNotEmpty()) {
|
||||
statusText.text = "疑似检测到 ${unsure.size} 人(不确定)"
|
||||
} else {
|
||||
statusText.text = "检测到 $total 张未入库人脸"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCameraPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 人脸对齐器
|
||||
* 通过人脸关键点(左右眼)进行旋转对齐,提升识别准确率
|
||||
*/
|
||||
class FaceAligner {
|
||||
|
||||
/**
|
||||
* 根据左右眼位置对齐人脸
|
||||
* @param bitmap 原始人脸图片(已裁剪)
|
||||
* @param leftEyeX 左眼 X 坐标(相对于原始图片)
|
||||
* @param leftEyeY 左眼 Y 坐标(相对于原始图片)
|
||||
* @param rightEyeX 右眼 X 坐标(相对于原始图片)
|
||||
* @param rightEyeY 右眼 Y 坐标(相对于原始图片)
|
||||
* @param faceLeft 人脸矩形左边(相对于原始图片)
|
||||
* @param faceTop 人脸矩形上边(相对于原始图片)
|
||||
* @return 对齐后的人脸图片
|
||||
*/
|
||||
fun align(
|
||||
bitmap: Bitmap,
|
||||
leftEyeX: Float,
|
||||
leftEyeY: Float,
|
||||
rightEyeX: Float,
|
||||
rightEyeY: Float,
|
||||
faceLeft: Int,
|
||||
faceTop: Int
|
||||
): Bitmap {
|
||||
// 计算眼睛在裁剪后图片中的相对位置
|
||||
val leftEyeRelX = leftEyeX - faceLeft
|
||||
val leftEyeRelY = leftEyeY - faceTop
|
||||
val rightEyeRelX = rightEyeX - faceLeft
|
||||
val rightEyeRelY = rightEyeY - faceTop
|
||||
|
||||
// 计算两眼连线的旋转角度(目标是让连线水平)
|
||||
val angle = calculateRotationAngle(leftEyeRelX, leftEyeRelY, rightEyeRelX, rightEyeRelY)
|
||||
|
||||
// 如果角度很小(小于 2 度),直接返回原图
|
||||
if (angle.absoluteValue < 2f) {
|
||||
return bitmap
|
||||
}
|
||||
|
||||
// 计算旋转中心(两眼中心点)
|
||||
val centerX = bitmap.width / 2f
|
||||
val centerY = bitmap.height / 2f
|
||||
|
||||
// 创建旋转矩阵
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(-angle, centerX, centerY)
|
||||
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两眼连线的旋转角度
|
||||
*/
|
||||
private fun calculateRotationAngle(x1: Float, y1: Float, x2: Float, y2: Float): Float {
|
||||
val dx = x2 - x1
|
||||
val dy = y2 - y1
|
||||
return Math.atan2(dy.toDouble(), dx.toDouble()).toFloat() * 180f / Math.PI.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单版本:仅根据估计的眼睛位置进行对齐
|
||||
* 适用于没有启用关键点检测的场景
|
||||
*/
|
||||
fun alignWithEstimation(bitmap: Bitmap, faceLeft: Int, faceTop: Int, headAngle: Float? = null): Bitmap {
|
||||
// 估计眼睛位置(基于人脸矩形的经验比例)
|
||||
val faceWidth = bitmap.width
|
||||
val faceHeight = bitmap.height
|
||||
|
||||
// 眼睛通常位于人脸上部约 35-40% 的位置
|
||||
val eyeY = faceHeight * 0.38f
|
||||
|
||||
// 左右眼大约在人脸宽度的 35% 和 65% 位置
|
||||
val leftEyeX = faceWidth * 0.35f
|
||||
val rightEyeX = faceWidth * 0.65f
|
||||
|
||||
// 如果有头部角度信息,使用它来调整
|
||||
return if (headAngle != null && headAngle.absoluteValue > 2f) {
|
||||
val centerX = faceWidth / 2f
|
||||
val centerY = faceHeight / 2f
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(-headAngle, centerX, centerY)
|
||||
Bitmap.createBitmap(bitmap, 0, 0, faceWidth, faceHeight, matrix, true)
|
||||
} else {
|
||||
align(bitmap, leftEyeX, eyeY, rightEyeX, eyeY, faceLeft, faceTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,38 +7,102 @@ import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class FaceEmbedder(context: Context) : AutoCloseable {
|
||||
/**
|
||||
* 人脸特征提取器
|
||||
* 支持 FaceNet 和 ArcFace 模型
|
||||
*
|
||||
* ArcFace 模型推荐:
|
||||
* - 下载地址:https://github.com/onnela亭亭/ArcFace_TFLite
|
||||
* - 或使用 InsightFace 的 ArcFace 模型
|
||||
*/
|
||||
class FaceEmbedder(context: Context, private val modelType: ModelType = ModelType.ARCFACE) : AutoCloseable {
|
||||
enum class ModelType {
|
||||
FACENET, // 归一化:(pixel - 127.5) / 127.5
|
||||
ARCFACE // 归一化:pixel / 255.0,然后 (x - mean) / std
|
||||
}
|
||||
|
||||
private val interpreter: Interpreter
|
||||
private val inputSize = 160
|
||||
private val inputSize: Int
|
||||
private val outputSize: Int
|
||||
|
||||
// ArcFace 标准化参数 (RGB)
|
||||
private val mean = floatArrayOf(127.5f, 127.5f, 127.5f)
|
||||
private val std = floatArrayOf(127.5f, 127.5f, 127.5f)
|
||||
|
||||
init {
|
||||
val modelBytes = context.assets.open("facenet.tflite").use { input ->
|
||||
input.readBytes()
|
||||
}
|
||||
interpreter = Interpreter(ByteBuffer.allocateDirect(modelBytes.size).apply {
|
||||
|
||||
val options = Interpreter.Options().apply {
|
||||
setNumThreads(4)
|
||||
// 如果设备支持,可以启用 GPU delegate
|
||||
// val gpuDelegate = GpuDelegate()
|
||||
// addDelegate(gpuDelegate)
|
||||
}
|
||||
|
||||
interpreter = Interpreter(
|
||||
ByteBuffer.allocateDirect(modelBytes.size).apply {
|
||||
order(ByteOrder.nativeOrder())
|
||||
put(modelBytes)
|
||||
rewind()
|
||||
})
|
||||
outputSize = interpreter.getOutputTensor(0).shape().last()
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
val inputShape = interpreter.getInputTensor(0).shape()
|
||||
inputSize = inputShape[1] // 通常是 112 或 160
|
||||
|
||||
val outputShape = interpreter.getOutputTensor(0).shape()
|
||||
outputSize = outputShape.last()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取人脸特征
|
||||
* @param face 人脸图片(已裁剪和对齐)
|
||||
* @return L2 归一化的特征向量
|
||||
*/
|
||||
fun embed(face: Bitmap): FloatArray {
|
||||
// 缩放到模型输入尺寸
|
||||
val resized = Bitmap.createScaledBitmap(face, inputSize, inputSize, true)
|
||||
|
||||
// 准备输入数据
|
||||
val input = ByteBuffer
|
||||
.allocateDirect(inputSize * inputSize * 3 * Float.SIZE_BYTES)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
|
||||
val pixels = IntArray(inputSize * inputSize)
|
||||
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
|
||||
pixels.forEach { color ->
|
||||
input.putFloat((((color shr 16) and 0xFF) - 127.5f) / 128f)
|
||||
input.putFloat((((color shr 8) and 0xFF) - 127.5f) / 128f)
|
||||
input.putFloat(((color and 0xFF) - 127.5f) / 128f)
|
||||
|
||||
for (color in pixels) {
|
||||
val r = ((color shr 16) and 0xFF).toFloat()
|
||||
val g = ((color shr 8) and 0xFF).toFloat()
|
||||
val b = (color and 0xFF).toFloat()
|
||||
|
||||
when (modelType) {
|
||||
ModelType.FACENET -> {
|
||||
// FaceNet 归一化:(pixel - 127.5) / 127.5
|
||||
input.putFloat((r - 127.5f) / 127.5f)
|
||||
input.putFloat((g - 127.5f) / 127.5f)
|
||||
input.putFloat((b - 127.5f) / 127.5f)
|
||||
}
|
||||
ModelType.ARCFACE -> {
|
||||
// ArcFace 归一化:(pixel - mean) / std
|
||||
// 等价于 (pixel - 127.5) / 127.5,但语义更清晰
|
||||
input.putFloat((r - mean[0]) / std[0])
|
||||
input.putFloat((g - mean[1]) / std[1])
|
||||
input.putFloat((b - mean[2]) / std[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input.rewind()
|
||||
|
||||
// 执行推理
|
||||
val output = Array(1) { FloatArray(outputSize) }
|
||||
interpreter.run(input, output)
|
||||
|
||||
// L2 归一化输出特征
|
||||
return l2Normalize(output[0])
|
||||
}
|
||||
|
||||
@@ -46,10 +110,24 @@ class FaceEmbedder(context: Context) : AutoCloseable {
|
||||
interpreter.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* L2 归一化
|
||||
*/
|
||||
private fun l2Normalize(values: FloatArray): FloatArray {
|
||||
var sum = 0f
|
||||
values.forEach { sum += it * it }
|
||||
val norm = sqrt(sum.coerceAtLeast(1e-12f))
|
||||
return FloatArray(values.size) { index -> values[index] / norm }
|
||||
for (value in values) {
|
||||
sum += value * value
|
||||
}
|
||||
val norm = sqrt(sum.coerceAtLeast(1e-12f))
|
||||
val result = FloatArray(values.size)
|
||||
for (i in values.indices) {
|
||||
result[i] = values[i] / norm
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特征向量维度
|
||||
*/
|
||||
fun getOutputSize(): Int = outputSize
|
||||
}
|
||||
|
||||
@@ -1,32 +1,195 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
import kotlin.math acos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class FaceMatcher(private val threshold: Float = 0.95f) {
|
||||
/**
|
||||
* 人脸比对器
|
||||
* 支持多种相似度计算方式和自适应阈值
|
||||
*/
|
||||
class FaceMatcher(
|
||||
private val euclideanThreshold: Float = 1.0f,
|
||||
private val cosineThreshold: Float = 0.6f
|
||||
) {
|
||||
/**
|
||||
* 查找最匹配的学生(使用余弦相似度)
|
||||
* @param embedding 待识别的人脸特征
|
||||
* @param students 已注册的学生列表
|
||||
* @return 识别结果
|
||||
*/
|
||||
fun findNearest(embedding: FloatArray, students: List<Student>): RecognitionResult {
|
||||
if (students.isEmpty()) {
|
||||
return RecognitionResult(null, Float.MAX_VALUE, 0f, MatchType.NO_DATA)
|
||||
}
|
||||
|
||||
var nearest: Student? = null
|
||||
var nearestDistance = Float.MAX_VALUE
|
||||
students.forEach { student ->
|
||||
val distance = euclidean(embedding, student.embedding)
|
||||
if (distance < nearestDistance) {
|
||||
var nearestEuclidean = Float.MAX_VALUE
|
||||
var nearestCosine = -Float.MAX_VALUE
|
||||
var secondNearestEuclidean = Float.MAX_VALUE
|
||||
|
||||
for (student in students) {
|
||||
val euclideanDist = euclideanDistance(embedding, student.embedding)
|
||||
val cosineSim = cosineSimilarity(embedding, student.embedding)
|
||||
|
||||
if (euclideanDist < nearestEuclidean) {
|
||||
secondNearestEuclidean = nearestEuclidean
|
||||
nearest = student
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
return if (nearest != null && nearestDistance <= threshold) {
|
||||
RecognitionResult(nearest, nearestDistance)
|
||||
} else {
|
||||
RecognitionResult(null, nearestDistance)
|
||||
nearestEuclidean = euclideanDist
|
||||
nearestCosine = cosineSim
|
||||
} else if (euclideanDist < secondNearestEuclidean) {
|
||||
secondNearestEuclidean = euclideanDist
|
||||
}
|
||||
}
|
||||
|
||||
private fun euclidean(a: FloatArray, b: FloatArray): Float {
|
||||
// 判断匹配类型
|
||||
val matchType = determineMatchType(nearestEuclidean, nearestCosine, secondNearestEuclidean)
|
||||
|
||||
return if (nearest != null && matchType == MatchType.MATCH) {
|
||||
RecognitionResult(nearest, nearestEuclidean, nearestCosine, matchType)
|
||||
} else {
|
||||
RecognitionResult(null, nearestEuclidean, nearestCosine, matchType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找所有可能的匹配(返回多个候选)
|
||||
*/
|
||||
fun findAllMatches(
|
||||
embedding: FloatArray,
|
||||
students: List<Student>,
|
||||
topK: Int = 3
|
||||
): List<MatchCandidate> {
|
||||
val candidates = students.map { student ->
|
||||
MatchCandidate(
|
||||
student = student,
|
||||
euclideanDistance = euclideanDistance(embedding, student.embedding),
|
||||
cosineSimilarity = cosineSimilarity(embedding, student.embedding)
|
||||
)
|
||||
}
|
||||
|
||||
return candidates.sortedBy { it.euclideanDistance }.take(topK)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算欧氏距离
|
||||
*/
|
||||
private fun euclideanDistance(a: FloatArray, b: FloatArray): Float {
|
||||
var sum = 0f
|
||||
for (index in 0 until minOf(a.size, b.size)) {
|
||||
val diff = a[index] - b[index]
|
||||
val len = minOf(a.size, b.size)
|
||||
for (i in 0 until len) {
|
||||
val diff = a[i] - b[i]
|
||||
sum += diff * diff
|
||||
}
|
||||
return sqrt(sum)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算余弦相似度
|
||||
* 对于已 L2 归一化的向量,余弦相似度等于点积
|
||||
*/
|
||||
private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float {
|
||||
var dotProduct = 0f
|
||||
val len = minOf(a.size, b.size)
|
||||
for (i in 0 until len) {
|
||||
dotProduct += a[i] * b[i]
|
||||
}
|
||||
// 限制在 [-1, 1] 范围内,避免浮点误差
|
||||
return dotProduct.coerceIn(-1f, 1f)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断匹配类型
|
||||
*/
|
||||
private fun determineMatchType(
|
||||
euclideanDist: Float,
|
||||
cosineSim: Float,
|
||||
secondNearestDist: Float
|
||||
): MatchType {
|
||||
// 同时满足欧氏距离和余弦相似度阈值
|
||||
val euclideanMatch = euclideanDist <= euclideanThreshold
|
||||
val cosineMatch = cosineSim >= cosineThreshold
|
||||
|
||||
// 检查是否是明显最优(与第二名的差距)
|
||||
val gap = secondNearestDist - euclideanDist
|
||||
val isClearlyBest = gap > 0.3f
|
||||
|
||||
return when {
|
||||
// 同时满足两个阈值,或者满足一个且明显优于其他
|
||||
(euclideanMatch && cosineMatch) || (euclideanMatch && isClearlyBest) || (cosineMatch && isClearlyBest) -> {
|
||||
MatchType.MATCH
|
||||
}
|
||||
// 只满足一个阈值,但不明显优于其他
|
||||
euclideanMatch || cosineSim > (cosineThreshold * 0.9f) -> {
|
||||
MatchType.UNSURE
|
||||
}
|
||||
else -> {
|
||||
MatchType.NO_MATCH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据一组距离自适应调整阈值
|
||||
* 适用于首次导入大量数据后优化识别效果
|
||||
*/
|
||||
fun suggestThresholds(students: List<Student>): SuggestedThresholds {
|
||||
if (students.size < 3) {
|
||||
return SuggestedThresholds(euclideanThreshold, cosineThreshold)
|
||||
}
|
||||
|
||||
// 计算所有学生两两之间的距离(采样)
|
||||
val distances = mutableListOf<Float>()
|
||||
val sampleSize = minOf(students.size, 50)
|
||||
val step = maxOf(1, students.size / sampleSize)
|
||||
|
||||
for (i in students.indices step step) {
|
||||
for (j in (i + 1) until students.size step step) {
|
||||
distances.add(euclideanDistance(students[i].embedding, students[j].embedding))
|
||||
}
|
||||
if (distances.size >= 200) break
|
||||
}
|
||||
|
||||
if (distances.isEmpty()) {
|
||||
return SuggestedThresholds(euclideanThreshold, cosineThreshold)
|
||||
}
|
||||
|
||||
distances.sort()
|
||||
|
||||
// 建议使用较小百分位作为阈值(假设同一个人多次录入的距离较小)
|
||||
val p10Index = (distances.size * 0.1).toInt().coerceIn(0, distances.size - 1)
|
||||
val p20Index = (distances.size * 0.2).toInt().coerceIn(0, distances.size - 1)
|
||||
|
||||
return SuggestedThresholds(
|
||||
euclidean = distances[p10Index],
|
||||
cosine = 1f - distances[p20Index] * 0.5f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配类型
|
||||
*/
|
||||
enum class MatchType {
|
||||
MATCH, // 明确匹配
|
||||
UNSURE, // 可能匹配,但不确定
|
||||
NO_MATCH, // 不匹配
|
||||
NO_DATA // 没有数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配候选
|
||||
*/
|
||||
data class MatchCandidate(
|
||||
val student: Student,
|
||||
val euclideanDistance: Float,
|
||||
val cosineSimilarity: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* 建议的阈值
|
||||
*/
|
||||
data class SuggestedThresholds(
|
||||
val euclidean: Float,
|
||||
val cosine: Float
|
||||
)
|
||||
|
||||
@@ -9,44 +9,156 @@ import com.google.mlkit.vision.face.FaceDetection
|
||||
import com.google.mlkit.vision.face.FaceDetectorOptions
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
/**
|
||||
* 人脸处理器
|
||||
* 负责人脸检测、对齐、预处理和特征提取
|
||||
*/
|
||||
class FaceProcessor(context: Context) : AutoCloseable {
|
||||
private val embedder = FaceEmbedder(context)
|
||||
private val detector = FaceDetection.getClient(
|
||||
private val aligner = FaceAligner()
|
||||
private val preprocessor = ImagePreprocessor()
|
||||
|
||||
// 实时检测器:快速模式,用于摄像头预览
|
||||
private val realtimeDetector = FaceDetection.getClient(
|
||||
FaceDetectorOptions.Builder()
|
||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
|
||||
.setMinFaceSize(0.08f)
|
||||
.enableTracking()
|
||||
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
|
||||
.build()
|
||||
)
|
||||
|
||||
// 注册检测器:高精度模式,启用关键点检测
|
||||
private val enrollmentDetector = FaceDetection.getClient(
|
||||
FaceDetectorOptions.Builder()
|
||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
|
||||
.setMinFaceSize(0.04f)
|
||||
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
|
||||
.build()
|
||||
)
|
||||
|
||||
/**
|
||||
* 从图片中提取单张人脸的特征(用于注册/上传)
|
||||
* @param bitmap 输入图片
|
||||
* @return L2 归一化的特征向量
|
||||
*/
|
||||
suspend fun embedSingleFace(bitmap: Bitmap): FloatArray {
|
||||
val faces = detectFaces(bitmap)
|
||||
require(faces.isNotEmpty()) { "图片中未检测到人脸。" }
|
||||
val largestFace = faces.maxBy { face ->
|
||||
face.boundingBox.width() * face.boundingBox.height()
|
||||
}
|
||||
return embedFace(bitmap, largestFace)
|
||||
val faces = detectFacesForEnrollment(bitmap)
|
||||
.mapNotNull { face ->
|
||||
val safeBox = safeCropRect(bitmap, face.boundingBox)
|
||||
if (safeBox != null) face to safeBox else null
|
||||
}
|
||||
|
||||
require(faces.isNotEmpty()) { "图片中未检测到可用人脸,请换更清晰或更正面的照片。" }
|
||||
|
||||
// 选择面积最大的人脸
|
||||
val (largestFace, faceBox) = faces.maxBy { (_, box) ->
|
||||
box.width() * box.height()
|
||||
}
|
||||
|
||||
// 裁剪人脸
|
||||
val croppedFace = Bitmap.createBitmap(
|
||||
bitmap,
|
||||
faceBox.left,
|
||||
faceBox.top,
|
||||
faceBox.width(),
|
||||
faceBox.height()
|
||||
)
|
||||
|
||||
// 使用关键点进行对齐
|
||||
val alignedFace = alignFaceWithLandmarks(croppedFace, largestFace, faceBox.left, faceBox.top)
|
||||
|
||||
// 图像预处理
|
||||
val preprocessedFace = preprocessor.preprocess(alignedFace)
|
||||
|
||||
// 提取特征
|
||||
return embedder.embed(preprocessedFace)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用关键点对齐人脸
|
||||
*/
|
||||
private fun alignFaceWithLandmarks(
|
||||
faceBitmap: Bitmap,
|
||||
face: Face,
|
||||
faceLeft: Int,
|
||||
faceTop: Int
|
||||
): Bitmap {
|
||||
val leftEye = face.leftEye ?: return faceBitmap
|
||||
val rightEye = face.rightEye ?: return faceBitmap
|
||||
|
||||
if (!leftEye.visible || !rightEye.visible) return faceBitmap
|
||||
|
||||
return aligner.align(
|
||||
bitmap = faceBitmap,
|
||||
leftEyeX = leftEye.position.x,
|
||||
leftEyeY = leftEye.position.y,
|
||||
rightEyeX = rightEye.position.x,
|
||||
rightEyeY = rightEye.position.y,
|
||||
faceLeft = faceLeft,
|
||||
faceTop = faceTop
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时检测人脸(用于摄像头预览)
|
||||
*/
|
||||
suspend fun detectFaces(bitmap: Bitmap): List<Face> {
|
||||
return detector.process(InputImage.fromBitmap(bitmap, 0)).await()
|
||||
return realtimeDetector.process(InputImage.fromBitmap(bitmap, 0)).await()
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册用的人脸检测(高精度)
|
||||
*/
|
||||
private suspend fun detectFacesForEnrollment(bitmap: Bitmap): List<Face> {
|
||||
val accurateFaces = enrollmentDetector.process(InputImage.fromBitmap(bitmap, 0)).await()
|
||||
if (accurateFaces.isNotEmpty()) return accurateFaces
|
||||
return realtimeDetector.process(InputImage.fromBitmap(bitmap, 0)).await()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取指定人脸的特征
|
||||
*/
|
||||
fun embedFace(bitmap: Bitmap, face: Face): FloatArray {
|
||||
return embedder.embed(cropFace(bitmap, face.boundingBox))
|
||||
val cropped = cropFace(bitmap, face.boundingBox)
|
||||
val aligned = alignFaceWithLandmarks(cropped, face, 0, 0)
|
||||
val preprocessed = preprocessor.preprocess(aligned)
|
||||
return embedder.embed(preprocessed)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
detector.close()
|
||||
realtimeDetector.close()
|
||||
enrollmentDetector.close()
|
||||
embedder.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪人脸区域
|
||||
*/
|
||||
private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap {
|
||||
val padding = (maxOf(box.width(), box.height()) * 0.18f).toInt()
|
||||
val left = (box.left - padding).coerceAtLeast(0)
|
||||
val top = (box.top - padding).coerceAtLeast(0)
|
||||
val right = (box.right + padding).coerceAtMost(bitmap.width)
|
||||
val bottom = (box.bottom + padding).coerceAtMost(bitmap.height)
|
||||
return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
|
||||
val crop = requireNotNull(safeCropRect(bitmap, box)) { "人脸区域超出图片边界。" }
|
||||
return Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height())
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算安全的裁剪区域(带 padding)
|
||||
*/
|
||||
private fun safeCropRect(bitmap: Bitmap, box: Rect): Rect? {
|
||||
if (box.width() <= 0 || box.height() <= 0) return null
|
||||
|
||||
// 增加 padding 比例,保留更多上下文信息
|
||||
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)
|
||||
|
||||
val width = right - left
|
||||
val height = bottom - top
|
||||
|
||||
if (width <= 1 || height <= 1) return null
|
||||
|
||||
return Rect(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Paint
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 图像预处理器
|
||||
* 提供多种图像增强功能,提升人脸识别准确率
|
||||
*/
|
||||
class ImagePreprocessor {
|
||||
|
||||
/**
|
||||
* 完整的预处理流程:
|
||||
* 1. 亮度/对比度增强
|
||||
* 2. 直方图均衡化(简化版)
|
||||
* 3. 转换为适合模型输入的格式
|
||||
*/
|
||||
fun preprocess(bitmap: Bitmap): Bitmap {
|
||||
// 先进行亮度对比度增强
|
||||
val enhanced = enhanceBrightnessContrast(bitmap)
|
||||
// 再进行直方图均衡化
|
||||
return histogramEqualize(enhanced)
|
||||
}
|
||||
|
||||
/**
|
||||
* 亮度对比度增强
|
||||
* @param brightness 亮度调整 (-1 到 1,0 为不调整)
|
||||
* @param contrast 对比度调整 (0 到 2,1 为不调整)
|
||||
*/
|
||||
fun enhanceBrightnessContrast(
|
||||
bitmap: Bitmap,
|
||||
brightness: Float = 0.1f,
|
||||
contrast: Float = 1.15f
|
||||
): Bitmap {
|
||||
val colorMatrix = ColorMatrix().apply {
|
||||
set(floatArrayOf(
|
||||
contrast, 0f, 0f, 0f, brightness * 255f,
|
||||
0f, contrast, 0f, 0f, brightness * 255f,
|
||||
0f, 0f, contrast, 0f, brightness * 255f,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
))
|
||||
}
|
||||
|
||||
val result = Bitmap.createBitmap(bitmap.width, bitmap.height, bitmap.config ?: Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
val paint = Paint().apply {
|
||||
colorFilter = ColorMatrixColorFilter(colorMatrix)
|
||||
}
|
||||
canvas.drawBitmap(bitmap, 0f, 0f, paint)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 直方图均衡化(简化版,对 RGB 各通道分别处理)
|
||||
*/
|
||||
fun histogramEqualize(bitmap: Bitmap): Bitmap {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val pixels = IntArray(width * height)
|
||||
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
|
||||
// 对每个通道进行均衡化
|
||||
val lookupR = buildHistogramLookup(pixels, { color -> (color shr 16) and 0xFF })
|
||||
val lookupG = buildHistogramLookup(pixels, { color -> (color shr 8) and 0xFF })
|
||||
val lookupB = buildHistogramLookup(pixels, { color -> color and 0xFF })
|
||||
|
||||
// 应用查找表
|
||||
for (i in pixels.indices) {
|
||||
val color = pixels[i]
|
||||
val r = lookupR[(color shr 16) and 0xFF]
|
||||
val g = lookupG[(color shr 8) and 0xFF]
|
||||
val b = lookupB[color and 0xFF]
|
||||
pixels[i] = -0x1000000 or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
val result = Bitmap.createBitmap(width, height, bitmap.config ?: Bitmap.Config.ARGB_8888)
|
||||
result.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建直方图均衡化的查找表
|
||||
*/
|
||||
private fun buildHistogramLookup(pixels: IntArray, channelExtractor: (Int) -> Int): IntArray {
|
||||
val histogram = IntArray(256)
|
||||
val total = pixels.size
|
||||
|
||||
// 统计直方图
|
||||
for (pixel in pixels) {
|
||||
histogram[channelExtractor(pixel)]++
|
||||
}
|
||||
|
||||
// 计算累积分布函数
|
||||
val cumulative = IntArray(256)
|
||||
var sum = 0
|
||||
for (i in 0..255) {
|
||||
sum += histogram[i]
|
||||
cumulative[i] = sum
|
||||
}
|
||||
|
||||
// 构建查找表
|
||||
val lookup = IntArray(256)
|
||||
val minNonZero = (0..255).firstOrNull { histogram[it] > 0 } ?: 0
|
||||
val minCumulative = cumulative[minNonZero]
|
||||
|
||||
for (i in 0..255) {
|
||||
lookup[i] = (((cumulative[i] - minCumulative) * 255f) / (total - minCumulative)).toInt()
|
||||
.coerceIn(0, 255)
|
||||
}
|
||||
|
||||
return lookup
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应亮度调整:根据图片平均亮度自动调整
|
||||
*/
|
||||
fun adaptiveBrightness(bitmap: Bitmap): Bitmap {
|
||||
val avgBrightness = calculateAverageBrightness(bitmap)
|
||||
|
||||
// 目标亮度约为 128(中间值)
|
||||
val adjustment = (128f - avgBrightness) / 255f * 0.5f
|
||||
|
||||
return if (abs(adjustment) > 0.02f) {
|
||||
enhanceBrightnessContrast(bitmap, brightness = adjustment, contrast = 1f)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算图片平均亮度
|
||||
*/
|
||||
private fun calculateAverageBrightness(bitmap: Bitmap): Float {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val pixels = IntArray(width * height)
|
||||
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
|
||||
var totalBrightness = 0f
|
||||
for (pixel in pixels) {
|
||||
// 使用人眼对 RGB 的敏感度权重计算亮度
|
||||
val r = (pixel shr 16) and 0xFF
|
||||
val g = (pixel shr 8) and 0xFF
|
||||
val b = pixel and 0xFF
|
||||
totalBrightness += 0.299f * r + 0.587f * g + 0.114f * b
|
||||
}
|
||||
return totalBrightness / pixels.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 边缘柔和裁剪:在裁剪区域边缘添加渐变遮罩
|
||||
*/
|
||||
fun applySoftEdge(bitmap: Bitmap, edgeWidth: Int? = null): Bitmap {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val edge = edgeWidth ?: min(width, height) / 8
|
||||
|
||||
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val pixels = IntArray(width * height)
|
||||
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
val i = y * width + x
|
||||
val pixel = pixels[i]
|
||||
|
||||
// 计算到边缘的距离
|
||||
val distToLeft = x
|
||||
val distToRight = width - 1 - x
|
||||
val distToTop = y
|
||||
val distToBottom = height - 1 - y
|
||||
val minDist = minOf(distToLeft, distToRight, distToTop, distToBottom)
|
||||
|
||||
if (minDist < edge) {
|
||||
// 应用渐变透明度
|
||||
val alpha = (minDist.toFloat() / edge * 0.7f + 0.3f * 255).toInt()
|
||||
pixels[i] = (pixel and 0x00FFFFFF) or (alpha shl 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,29 @@ package com.example.studentfaceregistry.face
|
||||
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
|
||||
/**
|
||||
* 人脸识别结果
|
||||
*/
|
||||
data class RecognitionResult(
|
||||
val student: Student?,
|
||||
val distance: Float
|
||||
)
|
||||
val distance: Float,
|
||||
val cosineSimilarity: Float = 0f,
|
||||
val matchType: MatchType = MatchType.NO_MATCH
|
||||
) {
|
||||
/**
|
||||
* 是否是匹配状态
|
||||
*/
|
||||
val isMatched: Boolean get() = student != null && matchType == MatchType.MATCH
|
||||
|
||||
/**
|
||||
* 匹配置信度(0-1,越大越可信)
|
||||
*/
|
||||
val confidence: Float
|
||||
get() {
|
||||
if (student == null) return 0f
|
||||
// 结合余弦相似度和欧氏距离计算置信度
|
||||
// 对于 L2 归一化的 512 维向量,欧氏距离通常在 0-2 之间
|
||||
val distanceScore = (1f - (distance / 2f)).coerceIn(0f, 1f)
|
||||
return ((cosineSimilarity + 1f) / 2f * 0.6f + distanceScore * 0.4f).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
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 kotlinx.coroutines.tasks.await
|
||||
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) {
|
||||
|
||||
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
|
||||
): EnrollmentResult {
|
||||
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 EnrollmentResult.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 EnrollmentResult.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.leftEye?.visible ?: false
|
||||
val rightEyeVisible = face.rightEye?.visible ?: false
|
||||
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.leftCheek ?: return 0f
|
||||
val rightCheek = face.rightCheek ?: return 0f
|
||||
|
||||
if (!leftCheek.visible || !rightCheek.visible) 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 / 3.14159
|
||||
|
||||
return angle.coerceIn(-90f, 90f)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算人脸俯仰角(上下点头,-90 到 90 度)
|
||||
*/
|
||||
private fun calculatePitchAngle(face: Face): Float {
|
||||
val nose = face.nose ?: return 0f
|
||||
val leftEye = face.leftEye ?: return 0f
|
||||
val rightEye = face.rightEye ?: return 0f
|
||||
|
||||
if (!nose.visible || !leftEye.visible || !rightEye.visible) 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 / 3.14159
|
||||
|
||||
return angle.coerceIn(-90f, 90f)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角度分组人脸帧
|
||||
*/
|
||||
private fun groupFramesByAngle(frames: List<FaceFrame>): Map<AngleBucket, List<FaceFrame>> {
|
||||
val buckets = mutableMapOf<AngleBucket, 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): AngleBucket {
|
||||
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 AngleBucket(yawBucket, pitchBucket)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从每组中选择质量最好的帧
|
||||
*/
|
||||
private fun selectBestFramesFromGroups(
|
||||
groups: Map<AngleBucket, 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.leftEye ?: return bitmap
|
||||
val rightEye = face.rightEye ?: return bitmap
|
||||
|
||||
if (!leftEye.visible || !rightEye.visible) 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 AngleBucket(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 EnrollmentResult {
|
||||
data class Success(
|
||||
val embedding: FloatArray,
|
||||
val frameCount: Int,
|
||||
val totalDetected: Int,
|
||||
val angleCoverage: AngleCoverage
|
||||
) : EnrollmentResult()
|
||||
|
||||
data class Failure(val reason: String) : EnrollmentResult()
|
||||
}
|
||||
@@ -5,28 +5,54 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RippleDrawable
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.ShapeDrawable
|
||||
import android.graphics.drawable.shapes.RoundRectShape
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import com.example.studentfaceregistry.face.MatchType
|
||||
import com.example.studentfaceregistry.face.RecognitionResult
|
||||
|
||||
/**
|
||||
* 识别叠加视图
|
||||
* 在摄像头预览上绘制识别框和标签
|
||||
*/
|
||||
class OverlayView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
|
||||
// 识别框画笔
|
||||
private val boxPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.rgb(37, 99, 235)
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 5f
|
||||
strokeWidth = 6f
|
||||
}
|
||||
|
||||
// 标签背景画笔
|
||||
private val labelBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(220, 15, 23, 42)
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
// 标签文字画笔
|
||||
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 52f
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
textSize = 48f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
|
||||
// 置信度文字画笔
|
||||
private val confidencePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 32f
|
||||
}
|
||||
|
||||
// 颜色定义
|
||||
private val colorMatch = Color.rgb(34, 197, 94) // 绿色 - 匹配成功
|
||||
private val colorUnsure = Color.rgb(234, 179, 8) // 黄色 - 不确定
|
||||
private val colorNoMatch = Color.rgb(239, 68, 68) // 红色 - 未匹配
|
||||
private val colorUnknown = Color.rgb(59, 130, 246) // 蓝色 - 未入库
|
||||
|
||||
private var detections: List<DetectionUi> = emptyList()
|
||||
private var imageWidth = 1
|
||||
private var imageHeight = 1
|
||||
@@ -40,14 +66,65 @@ class OverlayView @JvmOverloads constructor(
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
detections.forEach { item ->
|
||||
|
||||
detections.forEachIndexed { index, item ->
|
||||
drawDetection(canvas, item, index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawDetection(canvas: Canvas, item: DetectionUi, index: Int) {
|
||||
val rect = mapRect(item.bounds)
|
||||
|
||||
// 根据识别结果设置颜色
|
||||
val boxColor = getBoxColor(item.result)
|
||||
boxPaint.color = boxColor
|
||||
|
||||
// 绘制识别框
|
||||
canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, boxPaint)
|
||||
|
||||
// 绘制标签背景
|
||||
val label = item.label
|
||||
val labelWidth = labelPaint.measureText(label)
|
||||
val top = (rect.top - 68f).coerceAtLeast(0f)
|
||||
canvas.drawRect(rect.left, top, rect.left + labelWidth + 32f, top + 64f, labelBackgroundPaint)
|
||||
canvas.drawText(label, rect.left + 16f, top + 48f, labelPaint)
|
||||
val labelHeight = 60f
|
||||
val padding = 12f
|
||||
|
||||
// 标签在框上方
|
||||
val labelLeft = rect.left + padding
|
||||
val labelTop = (rect.top - labelHeight - padding).coerceAtLeast(padding)
|
||||
val labelRight = labelLeft + labelWidth + padding * 2
|
||||
val labelBottom = labelTop + labelHeight
|
||||
|
||||
// 标签背景颜色与框颜色一致
|
||||
labelBackgroundPaint.color = boxColor
|
||||
canvas.drawRoundRect(labelLeft, labelTop, labelRight, labelBottom, 8f, labelBackgroundPaint)
|
||||
|
||||
// 绘制标签文字
|
||||
canvas.drawText(label, labelLeft + padding, labelTop + 38f, labelPaint)
|
||||
|
||||
// 如果有置信度信息,显示在标签右侧
|
||||
if (item.confidence != null) {
|
||||
val confidenceText = "${(item.confidence * 100).toInt()}%"
|
||||
val confidenceWidth = confidencePaint.measureText(confidenceText)
|
||||
val confLeft = labelRight + 8f
|
||||
val confTop = labelTop + 8f
|
||||
val confRight = confLeft + confidenceWidth + 16f
|
||||
val confBottom = confTop + 44f
|
||||
|
||||
// 置信度背景(半透明黑色)
|
||||
labelBackgroundPaint.color = Color.argb(180, 0, 0, 0)
|
||||
canvas.drawRoundRect(confLeft, confTop, confRight, confBottom, 6f, labelBackgroundPaint)
|
||||
|
||||
// 置信度文字
|
||||
canvas.drawText(confidenceText, confLeft + 8f, confTop + 30f, confidencePaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBoxColor(result: RecognitionResult): Int {
|
||||
return when {
|
||||
result.isMatched -> colorMatch
|
||||
result.matchType == MatchType.UNSURE -> colorUnsure
|
||||
result.student == null && result.distance != Float.MAX_VALUE -> colorNoMatch
|
||||
else -> colorUnknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,11 +140,21 @@ class OverlayView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别 UI 数据
|
||||
*/
|
||||
data class DetectionUi(
|
||||
val bounds: Rect,
|
||||
val result: RecognitionResult
|
||||
val result: RecognitionResult,
|
||||
val confidence: Float? = null
|
||||
) {
|
||||
val label: String
|
||||
get() = result.student?.let { "${it.name} ${it.studentNo}" }
|
||||
?: if (result.distance == Float.MAX_VALUE) "未入库" else "未匹配 %.2f".format(result.distance)
|
||||
get() {
|
||||
return when {
|
||||
result.isMatched -> "${result.student?.name} (${result.student?.studentNo})"
|
||||
result.matchType == MatchType.UNSURE -> "疑似:${result.student?.name}"
|
||||
result.student == null && result.distance != Float.MAX_VALUE -> "未匹配"
|
||||
else -> "未入库"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ class UploadServer(
|
||||
}
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.RGB_565
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
|
||||
}
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
||||
?: error("Unable to decode image")
|
||||
|
||||
Reference in New Issue
Block a user