改进识别架构为insightface

This commit is contained in:
2026-05-09 13:49:35 +08:00
parent 5e19f09ddc
commit 8bdf0440ae
12 changed files with 1333 additions and 66 deletions

65
.claude/CLAUDE.md Normal file
View 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.

View 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)"
]
}
}

View File

@@ -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
} }

View File

@@ -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)
}
}
}

View File

@@ -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 {
order(ByteOrder.nativeOrder()) val options = Interpreter.Options().apply {
put(modelBytes) setNumThreads(4)
rewind() // 如果设备支持,可以启用 GPU delegate
}) // val gpuDelegate = GpuDelegate()
outputSize = interpreter.getOutputTensor(0).shape().last() // addDelegate(gpuDelegate)
}
interpreter = Interpreter(
ByteBuffer.allocateDirect(modelBytes.size).apply {
order(ByteOrder.nativeOrder())
put(modelBytes)
rewind()
},
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) {
sum += value * value
}
val norm = sqrt(sum.coerceAtLeast(1e-12f)) val norm = sqrt(sum.coerceAtLeast(1e-12f))
return FloatArray(values.size) { index -> values[index] / norm } val result = FloatArray(values.size)
for (i in values.indices) {
result[i] = values[i] / norm
}
return result
} }
/**
* 获取特征向量维度
*/
fun getOutputSize(): Int = outputSize
} }

View File

@@ -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) {
secondNearestEuclidean = euclideanDist
} }
} }
return if (nearest != null && nearestDistance <= threshold) {
RecognitionResult(nearest, nearestDistance) // 判断匹配类型
val matchType = determineMatchType(nearestEuclidean, nearestCosine, secondNearestEuclidean)
return if (nearest != null && matchType == MatchType.MATCH) {
RecognitionResult(nearest, nearestEuclidean, nearestCosine, matchType)
} else { } else {
RecognitionResult(null, nearestDistance) RecognitionResult(null, nearestEuclidean, nearestCosine, matchType)
} }
} }
private fun euclidean(a: FloatArray, b: FloatArray): Float { /**
* 查找所有可能的匹配(返回多个候选)
*/
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
)

View File

@@ -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
}
require(faces.isNotEmpty()) { "图片中未检测到可用人脸,请换更清晰或更正面的照片。" }
// 选择面积最大的人脸
val (largestFace, faceBox) = faces.maxBy { (_, box) ->
box.width() * box.height()
} }
return embedFace(bitmap, largestFace)
// 裁剪人脸
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)
} }
} }

View File

@@ -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 到 10 为不调整)
* @param contrast 对比度调整 (0 到 21 为不调整)
*/
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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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 ->
val rect = mapRect(item.bounds) detections.forEachIndexed { index, item ->
canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, boxPaint) drawDetection(canvas, item, index)
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) private fun drawDetection(canvas: Canvas, item: DetectionUi, index: Int) {
canvas.drawText(label, rect.left + 16f, top + 48f, labelPaint) 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 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( 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 -> "未入库"
}
}
} }

View File

@@ -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")