改进识别架构为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 androidx.lifecycle.lifecycleScope
|
||||||
import com.example.studentfaceregistry.data.StudentRepository
|
import com.example.studentfaceregistry.data.StudentRepository
|
||||||
import com.example.studentfaceregistry.data.Student
|
import com.example.studentfaceregistry.data.Student
|
||||||
|
import com.example.studentfaceregistry.face.FaceEmbedder
|
||||||
import com.example.studentfaceregistry.face.FaceMatcher
|
import com.example.studentfaceregistry.face.FaceMatcher
|
||||||
import com.example.studentfaceregistry.face.FaceProcessor
|
import com.example.studentfaceregistry.face.FaceProcessor
|
||||||
|
import com.example.studentfaceregistry.face.MatchType
|
||||||
import com.example.studentfaceregistry.ui.DetectionUi
|
import com.example.studentfaceregistry.ui.DetectionUi
|
||||||
import com.example.studentfaceregistry.ui.OverlayView
|
import com.example.studentfaceregistry.ui.OverlayView
|
||||||
import com.example.studentfaceregistry.upload.UploadServer
|
import com.example.studentfaceregistry.upload.UploadServer
|
||||||
@@ -43,7 +45,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private var uploadServer: UploadServer? = null
|
private var uploadServer: UploadServer? = null
|
||||||
|
|
||||||
private val repository by lazy { StudentRepository(this) }
|
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 val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||||
private var students: List<Student> = emptyList()
|
private var students: List<Student> = emptyList()
|
||||||
private var recognitionEnabled = true
|
private var recognitionEnabled = true
|
||||||
@@ -52,7 +55,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val permissionLauncher = registerForActivityResult(
|
private val permissionLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
) { grants ->
|
) { grants ->
|
||||||
if (grants[Manifest.permission.CAMERA] == true) startCamera() else toast("需要相机权限才能识别学生身份。")
|
if (grants[Manifest.permission.CAMERA] == true) startCamera()
|
||||||
|
else toast("需要相机权限才能识别学生身份。")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -89,6 +93,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
setBackgroundColor(0xFFF8FAFC.toInt())
|
setBackgroundColor(0xFFF8FAFC.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
val header = LinearLayout(this).apply {
|
val header = LinearLayout(this).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
setPadding(32, 28, 32, 20)
|
setPadding(32, 28, 32, 20)
|
||||||
@@ -175,14 +180,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
try {
|
try {
|
||||||
val bitmap = withContext(Dispatchers.Default) { ImageUtils.imageProxyToBitmap(image) }
|
val bitmap = withContext(Dispatchers.Default) { ImageUtils.imageProxyToBitmap(image) }
|
||||||
val faces = faceProcessor.detectFaces(bitmap)
|
val faces = faceProcessor.detectFaces(bitmap)
|
||||||
|
|
||||||
|
// 同时识别所有人脸
|
||||||
val detections = faces.map { face ->
|
val detections = faces.map { face ->
|
||||||
val embedding = faceProcessor.embedFace(bitmap, 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)
|
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) {
|
} catch (_: Exception) {
|
||||||
statusText.text = "识别暂不可用,请确认模型文件和相机画面。"
|
statusText.text = "识别暂不可用,请确认模型文件和相机画面。"
|
||||||
} finally {
|
} 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 {
|
private fun hasCameraPermission(): Boolean {
|
||||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
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 java.nio.ByteOrder
|
||||||
import kotlin.math.sqrt
|
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 interpreter: Interpreter
|
||||||
private val inputSize = 160
|
private val inputSize: Int
|
||||||
private val outputSize: 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 {
|
init {
|
||||||
val modelBytes = context.assets.open("facenet.tflite").use { input ->
|
val modelBytes = context.assets.open("facenet.tflite").use { input ->
|
||||||
input.readBytes()
|
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())
|
order(ByteOrder.nativeOrder())
|
||||||
put(modelBytes)
|
put(modelBytes)
|
||||||
rewind()
|
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 {
|
fun embed(face: Bitmap): FloatArray {
|
||||||
|
// 缩放到模型输入尺寸
|
||||||
val resized = Bitmap.createScaledBitmap(face, inputSize, inputSize, true)
|
val resized = Bitmap.createScaledBitmap(face, inputSize, inputSize, true)
|
||||||
|
|
||||||
|
// 准备输入数据
|
||||||
val input = ByteBuffer
|
val input = ByteBuffer
|
||||||
.allocateDirect(inputSize * inputSize * 3 * Float.SIZE_BYTES)
|
.allocateDirect(inputSize * inputSize * 3 * Float.SIZE_BYTES)
|
||||||
.order(ByteOrder.nativeOrder())
|
.order(ByteOrder.nativeOrder())
|
||||||
|
|
||||||
val pixels = IntArray(inputSize * inputSize)
|
val pixels = IntArray(inputSize * inputSize)
|
||||||
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
|
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
|
||||||
pixels.forEach { color ->
|
|
||||||
input.putFloat((((color shr 16) and 0xFF) - 127.5f) / 128f)
|
for (color in pixels) {
|
||||||
input.putFloat((((color shr 8) and 0xFF) - 127.5f) / 128f)
|
val r = ((color shr 16) and 0xFF).toFloat()
|
||||||
input.putFloat(((color and 0xFF) - 127.5f) / 128f)
|
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()
|
input.rewind()
|
||||||
|
|
||||||
|
// 执行推理
|
||||||
val output = Array(1) { FloatArray(outputSize) }
|
val output = Array(1) { FloatArray(outputSize) }
|
||||||
interpreter.run(input, output)
|
interpreter.run(input, output)
|
||||||
|
|
||||||
|
// L2 归一化输出特征
|
||||||
return l2Normalize(output[0])
|
return l2Normalize(output[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +110,24 @@ class FaceEmbedder(context: Context) : AutoCloseable {
|
|||||||
interpreter.close()
|
interpreter.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L2 归一化
|
||||||
|
*/
|
||||||
private fun l2Normalize(values: FloatArray): FloatArray {
|
private fun l2Normalize(values: FloatArray): FloatArray {
|
||||||
var sum = 0f
|
var sum = 0f
|
||||||
values.forEach { sum += it * it }
|
for (value in values) {
|
||||||
val norm = sqrt(sum.coerceAtLeast(1e-12f))
|
sum += value * value
|
||||||
return FloatArray(values.size) { index -> values[index] / norm }
|
|
||||||
}
|
}
|
||||||
|
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
|
package com.example.studentfaceregistry.face
|
||||||
|
|
||||||
import com.example.studentfaceregistry.data.Student
|
import com.example.studentfaceregistry.data.Student
|
||||||
|
import kotlin.math acos
|
||||||
import kotlin.math.sqrt
|
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 {
|
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 nearest: Student? = null
|
||||||
var nearestDistance = Float.MAX_VALUE
|
var nearestEuclidean = Float.MAX_VALUE
|
||||||
students.forEach { student ->
|
var nearestCosine = -Float.MAX_VALUE
|
||||||
val distance = euclidean(embedding, student.embedding)
|
var secondNearestEuclidean = Float.MAX_VALUE
|
||||||
if (distance < nearestDistance) {
|
|
||||||
|
for (student in students) {
|
||||||
|
val euclideanDist = euclideanDistance(embedding, student.embedding)
|
||||||
|
val cosineSim = cosineSimilarity(embedding, student.embedding)
|
||||||
|
|
||||||
|
if (euclideanDist < nearestEuclidean) {
|
||||||
|
secondNearestEuclidean = nearestEuclidean
|
||||||
nearest = student
|
nearest = student
|
||||||
nearestDistance = distance
|
nearestEuclidean = euclideanDist
|
||||||
}
|
nearestCosine = cosineSim
|
||||||
}
|
} else if (euclideanDist < secondNearestEuclidean) {
|
||||||
return if (nearest != null && nearestDistance <= threshold) {
|
secondNearestEuclidean = euclideanDist
|
||||||
RecognitionResult(nearest, nearestDistance)
|
|
||||||
} else {
|
|
||||||
RecognitionResult(null, nearestDistance)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
var sum = 0f
|
||||||
for (index in 0 until minOf(a.size, b.size)) {
|
val len = minOf(a.size, b.size)
|
||||||
val diff = a[index] - b[index]
|
for (i in 0 until len) {
|
||||||
|
val diff = a[i] - b[i]
|
||||||
sum += diff * diff
|
sum += diff * diff
|
||||||
}
|
}
|
||||||
return sqrt(sum)
|
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 com.google.mlkit.vision.face.FaceDetectorOptions
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸处理器
|
||||||
|
* 负责人脸检测、对齐、预处理和特征提取
|
||||||
|
*/
|
||||||
class FaceProcessor(context: Context) : AutoCloseable {
|
class FaceProcessor(context: Context) : AutoCloseable {
|
||||||
private val embedder = FaceEmbedder(context)
|
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()
|
FaceDetectorOptions.Builder()
|
||||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
|
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
|
||||||
.setMinFaceSize(0.08f)
|
.setMinFaceSize(0.08f)
|
||||||
.enableTracking()
|
.enableTracking()
|
||||||
|
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
|
||||||
.build()
|
.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 {
|
suspend fun embedSingleFace(bitmap: Bitmap): FloatArray {
|
||||||
val faces = detectFaces(bitmap)
|
val faces = detectFacesForEnrollment(bitmap)
|
||||||
require(faces.isNotEmpty()) { "图片中未检测到人脸。" }
|
.mapNotNull { face ->
|
||||||
val largestFace = faces.maxBy { face ->
|
val safeBox = safeCropRect(bitmap, face.boundingBox)
|
||||||
face.boundingBox.width() * face.boundingBox.height()
|
if (safeBox != null) face to safeBox else null
|
||||||
}
|
|
||||||
return embedFace(bitmap, largestFace)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
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 {
|
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() {
|
override fun close() {
|
||||||
detector.close()
|
realtimeDetector.close()
|
||||||
|
enrollmentDetector.close()
|
||||||
embedder.close()
|
embedder.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裁剪人脸区域
|
||||||
|
*/
|
||||||
private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap {
|
private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap {
|
||||||
val padding = (maxOf(box.width(), box.height()) * 0.18f).toInt()
|
val crop = requireNotNull(safeCropRect(bitmap, box)) { "人脸区域超出图片边界。" }
|
||||||
val left = (box.left - padding).coerceAtLeast(0)
|
return Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height())
|
||||||
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)
|
* 计算安全的裁剪区域(带 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
|
import com.example.studentfaceregistry.data.Student
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸识别结果
|
||||||
|
*/
|
||||||
data class RecognitionResult(
|
data class RecognitionResult(
|
||||||
val student: Student?,
|
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.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Rect
|
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.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import com.example.studentfaceregistry.face.MatchType
|
||||||
import com.example.studentfaceregistry.face.RecognitionResult
|
import com.example.studentfaceregistry.face.RecognitionResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别叠加视图
|
||||||
|
* 在摄像头预览上绘制识别框和标签
|
||||||
|
*/
|
||||||
class OverlayView @JvmOverloads constructor(
|
class OverlayView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null
|
attrs: AttributeSet? = null
|
||||||
) : View(context, attrs) {
|
) : View(context, attrs) {
|
||||||
|
|
||||||
|
// 识别框画笔
|
||||||
private val boxPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val boxPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
color = Color.rgb(37, 99, 235)
|
|
||||||
style = Paint.Style.STROKE
|
style = Paint.Style.STROKE
|
||||||
strokeWidth = 5f
|
strokeWidth = 6f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签背景画笔
|
||||||
private val labelBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val labelBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
color = Color.argb(220, 15, 23, 42)
|
|
||||||
style = Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签文字画笔
|
||||||
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
color = Color.WHITE
|
color = Color.WHITE
|
||||||
textSize = 52f
|
textSize = 48f
|
||||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
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 detections: List<DetectionUi> = emptyList()
|
||||||
private var imageWidth = 1
|
private var imageWidth = 1
|
||||||
private var imageHeight = 1
|
private var imageHeight = 1
|
||||||
@@ -40,14 +66,65 @@ class OverlayView @JvmOverloads constructor(
|
|||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
override fun onDraw(canvas: Canvas) {
|
||||||
super.onDraw(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 rect = mapRect(item.bounds)
|
||||||
|
|
||||||
|
// 根据识别结果设置颜色
|
||||||
|
val boxColor = getBoxColor(item.result)
|
||||||
|
boxPaint.color = boxColor
|
||||||
|
|
||||||
|
// 绘制识别框
|
||||||
canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, boxPaint)
|
canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, boxPaint)
|
||||||
|
|
||||||
|
// 绘制标签背景
|
||||||
val label = item.label
|
val label = item.label
|
||||||
val labelWidth = labelPaint.measureText(label)
|
val labelWidth = labelPaint.measureText(label)
|
||||||
val top = (rect.top - 68f).coerceAtLeast(0f)
|
val labelHeight = 60f
|
||||||
canvas.drawRect(rect.left, top, rect.left + labelWidth + 32f, top + 64f, labelBackgroundPaint)
|
val padding = 12f
|
||||||
canvas.drawText(label, rect.left + 16f, top + 48f, labelPaint)
|
|
||||||
|
// 标签在框上方
|
||||||
|
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(
|
data class DetectionUi(
|
||||||
val bounds: Rect,
|
val bounds: Rect,
|
||||||
val result: RecognitionResult
|
val result: RecognitionResult,
|
||||||
|
val confidence: Float? = null
|
||||||
) {
|
) {
|
||||||
val label: String
|
val label: String
|
||||||
get() = result.student?.let { "${it.name} ${it.studentNo}" }
|
get() {
|
||||||
?: if (result.distance == Float.MAX_VALUE) "未入库" else "未匹配 %.2f".format(result.distance)
|
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 {
|
val options = BitmapFactory.Options().apply {
|
||||||
inSampleSize = sampleSize
|
inSampleSize = sampleSize
|
||||||
inPreferredConfig = android.graphics.Bitmap.Config.RGB_565
|
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
|
||||||
}
|
}
|
||||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
||||||
?: error("Unable to decode image")
|
?: error("Unable to decode image")
|
||||||
|
|||||||
Reference in New Issue
Block a user