diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..6ab5407 --- /dev/null +++ b/.claude/CLAUDE.md @@ -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. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..dc917ae --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt index 7a929a3..1abb770 100644 --- a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt +++ b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt @@ -23,8 +23,10 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.example.studentfaceregistry.data.StudentRepository import com.example.studentfaceregistry.data.Student +import com.example.studentfaceregistry.face.FaceEmbedder import com.example.studentfaceregistry.face.FaceMatcher import com.example.studentfaceregistry.face.FaceProcessor +import com.example.studentfaceregistry.face.MatchType import com.example.studentfaceregistry.ui.DetectionUi import com.example.studentfaceregistry.ui.OverlayView import com.example.studentfaceregistry.upload.UploadServer @@ -43,7 +45,8 @@ class MainActivity : AppCompatActivity() { private var uploadServer: UploadServer? = null private val repository by lazy { StudentRepository(this) } - private val matcher = FaceMatcher() + // ArcFace 推荐阈值 + private val matcher = FaceMatcher(euclideanThreshold = 1.0f, cosineThreshold = 0.6f) private val cameraExecutor = Executors.newSingleThreadExecutor() private var students: List = emptyList() private var recognitionEnabled = true @@ -52,7 +55,8 @@ class MainActivity : AppCompatActivity() { private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { grants -> - if (grants[Manifest.permission.CAMERA] == true) startCamera() else toast("需要相机权限才能识别学生身份。") + if (grants[Manifest.permission.CAMERA] == true) startCamera() + else toast("需要相机权限才能识别学生身份。") } override fun onCreate(savedInstanceState: Bundle?) { @@ -89,6 +93,7 @@ class MainActivity : AppCompatActivity() { orientation = LinearLayout.VERTICAL setBackgroundColor(0xFFF8FAFC.toInt()) } + val header = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding(32, 28, 32, 20) @@ -175,14 +180,22 @@ class MainActivity : AppCompatActivity() { try { val bitmap = withContext(Dispatchers.Default) { ImageUtils.imageProxyToBitmap(image) } val faces = faceProcessor.detectFaces(bitmap) + + // 同时识别所有人脸 val detections = faces.map { face -> val embedding = faceProcessor.embedFace(bitmap, face) - DetectionUi(face.boundingBox, matcher.findNearest(embedding, students)) + val result = matcher.findNearest(embedding, students) + DetectionUi( + bounds = face.boundingBox, + result = result, + confidence = result.confidence + ) } + overlayView.update(detections, bitmap.width, bitmap.height) - statusText.text = detections.firstOrNull()?.result?.student?.let { - "识别到:${it.name}(${it.studentNo})" - } ?: if (detections.isEmpty()) "未检测到人脸" else "检测到未入库人脸" + + // 显示所有识别结果 + updateStatusText(detections) } catch (_: Exception) { statusText.text = "识别暂不可用,请确认模型文件和相机画面。" } finally { @@ -192,6 +205,40 @@ class MainActivity : AppCompatActivity() { } } + private fun updateStatusText(detections: List) { + if (detections.isEmpty()) { + statusText.text = "未检测到人脸" + return + } + + // 统计识别结果 + val matched = detections.filter { it.result.isMatched } + val unsure = detections.filter { it.result.matchType == MatchType.UNSURE } + val unmatched = detections.filter { !it.result.isMatched && it.result.matchType != MatchType.UNSURE } + + val total = detections.size + val matchedCount = matched.size + + if (matchedCount > 0) { + if (matchedCount == 1 && total == 1) { + val student = matched[0].result.student!! + val confidence = (matched[0].confidence!! * 100).toInt() + statusText.text = "识别到:${student.name}(${student.studentNo})置信度${confidence}%" + } else { + val names = matched.joinToString(", ") { + val s = it.result.student!! + val c = (it.confidence!! * 100).toInt() + "${s.name}(${c}%)" + } + statusText.text = "识别到 $matchedCount/$total 人:$names" + } + } else if (unsure.isNotEmpty()) { + statusText.text = "疑似检测到 ${unsure.size} 人(不确定)" + } else { + statusText.text = "检测到 $total 张未入库人脸" + } + } + private fun hasCameraPermission(): Boolean { return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED } diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceAligner.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceAligner.kt new file mode 100644 index 0000000..0f91d1b --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceAligner.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt index 8f795db..6150858 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt @@ -7,38 +7,102 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.sqrt -class FaceEmbedder(context: Context) : AutoCloseable { +/** + * 人脸特征提取器 + * 支持 FaceNet 和 ArcFace 模型 + * + * ArcFace 模型推荐: + * - 下载地址:https://github.com/onnela亭亭/ArcFace_TFLite + * - 或使用 InsightFace 的 ArcFace 模型 + */ +class FaceEmbedder(context: Context, private val modelType: ModelType = ModelType.ARCFACE) : AutoCloseable { + enum class ModelType { + FACENET, // 归一化:(pixel - 127.5) / 127.5 + ARCFACE // 归一化:pixel / 255.0,然后 (x - mean) / std + } + private val interpreter: Interpreter - private val inputSize = 160 + private val inputSize: Int private val outputSize: Int + // ArcFace 标准化参数 (RGB) + private val mean = floatArrayOf(127.5f, 127.5f, 127.5f) + private val std = floatArrayOf(127.5f, 127.5f, 127.5f) + init { val modelBytes = context.assets.open("facenet.tflite").use { input -> input.readBytes() } - interpreter = Interpreter(ByteBuffer.allocateDirect(modelBytes.size).apply { - order(ByteOrder.nativeOrder()) - put(modelBytes) - rewind() - }) - outputSize = interpreter.getOutputTensor(0).shape().last() + + val options = Interpreter.Options().apply { + setNumThreads(4) + // 如果设备支持,可以启用 GPU delegate + // val gpuDelegate = GpuDelegate() + // addDelegate(gpuDelegate) + } + + interpreter = Interpreter( + ByteBuffer.allocateDirect(modelBytes.size).apply { + order(ByteOrder.nativeOrder()) + put(modelBytes) + rewind() + }, + options + ) + + val inputShape = interpreter.getInputTensor(0).shape() + inputSize = inputShape[1] // 通常是 112 或 160 + + val outputShape = interpreter.getOutputTensor(0).shape() + outputSize = outputShape.last() } + /** + * 提取人脸特征 + * @param face 人脸图片(已裁剪和对齐) + * @return L2 归一化的特征向量 + */ fun embed(face: Bitmap): FloatArray { + // 缩放到模型输入尺寸 val resized = Bitmap.createScaledBitmap(face, inputSize, inputSize, true) + + // 准备输入数据 val input = ByteBuffer .allocateDirect(inputSize * inputSize * 3 * Float.SIZE_BYTES) .order(ByteOrder.nativeOrder()) + val pixels = IntArray(inputSize * inputSize) resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize) - pixels.forEach { color -> - input.putFloat((((color shr 16) and 0xFF) - 127.5f) / 128f) - input.putFloat((((color shr 8) and 0xFF) - 127.5f) / 128f) - input.putFloat(((color and 0xFF) - 127.5f) / 128f) + + for (color in pixels) { + val r = ((color shr 16) and 0xFF).toFloat() + val g = ((color shr 8) and 0xFF).toFloat() + val b = (color and 0xFF).toFloat() + + when (modelType) { + ModelType.FACENET -> { + // FaceNet 归一化:(pixel - 127.5) / 127.5 + input.putFloat((r - 127.5f) / 127.5f) + input.putFloat((g - 127.5f) / 127.5f) + input.putFloat((b - 127.5f) / 127.5f) + } + ModelType.ARCFACE -> { + // ArcFace 归一化:(pixel - mean) / std + // 等价于 (pixel - 127.5) / 127.5,但语义更清晰 + input.putFloat((r - mean[0]) / std[0]) + input.putFloat((g - mean[1]) / std[1]) + input.putFloat((b - mean[2]) / std[2]) + } + } } + input.rewind() + + // 执行推理 val output = Array(1) { FloatArray(outputSize) } interpreter.run(input, output) + + // L2 归一化输出特征 return l2Normalize(output[0]) } @@ -46,10 +110,24 @@ class FaceEmbedder(context: Context) : AutoCloseable { interpreter.close() } + /** + * L2 归一化 + */ private fun l2Normalize(values: FloatArray): FloatArray { var sum = 0f - values.forEach { sum += it * it } + for (value in values) { + sum += value * value + } 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 } diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt index 8a62fb6..d079565 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt @@ -1,32 +1,195 @@ package com.example.studentfaceregistry.face import com.example.studentfaceregistry.data.Student +import kotlin.math acos import kotlin.math.sqrt -class FaceMatcher(private val threshold: Float = 0.95f) { +/** + * 人脸比对器 + * 支持多种相似度计算方式和自适应阈值 + */ +class FaceMatcher( + private val euclideanThreshold: Float = 1.0f, + private val cosineThreshold: Float = 0.6f +) { + /** + * 查找最匹配的学生(使用余弦相似度) + * @param embedding 待识别的人脸特征 + * @param students 已注册的学生列表 + * @return 识别结果 + */ fun findNearest(embedding: FloatArray, students: List): RecognitionResult { + if (students.isEmpty()) { + return RecognitionResult(null, Float.MAX_VALUE, 0f, MatchType.NO_DATA) + } + var nearest: Student? = null - var nearestDistance = Float.MAX_VALUE - students.forEach { student -> - val distance = euclidean(embedding, student.embedding) - if (distance < nearestDistance) { + var nearestEuclidean = Float.MAX_VALUE + var nearestCosine = -Float.MAX_VALUE + var secondNearestEuclidean = Float.MAX_VALUE + + for (student in students) { + val euclideanDist = euclideanDistance(embedding, student.embedding) + val cosineSim = cosineSimilarity(embedding, student.embedding) + + if (euclideanDist < nearestEuclidean) { + secondNearestEuclidean = nearestEuclidean nearest = student - nearestDistance = distance + 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 { - RecognitionResult(null, nearestDistance) + RecognitionResult(null, nearestEuclidean, nearestCosine, matchType) } } - private fun euclidean(a: FloatArray, b: FloatArray): Float { + /** + * 查找所有可能的匹配(返回多个候选) + */ + fun findAllMatches( + embedding: FloatArray, + students: List, + topK: Int = 3 + ): List { + val candidates = students.map { student -> + MatchCandidate( + student = student, + euclideanDistance = euclideanDistance(embedding, student.embedding), + cosineSimilarity = cosineSimilarity(embedding, student.embedding) + ) + } + + return candidates.sortedBy { it.euclideanDistance }.take(topK) + } + + /** + * 计算欧氏距离 + */ + private fun euclideanDistance(a: FloatArray, b: FloatArray): Float { var sum = 0f - for (index in 0 until minOf(a.size, b.size)) { - val diff = a[index] - b[index] + val len = minOf(a.size, b.size) + for (i in 0 until len) { + val diff = a[i] - b[i] sum += diff * diff } return sqrt(sum) } + + /** + * 计算余弦相似度 + * 对于已 L2 归一化的向量,余弦相似度等于点积 + */ + private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float { + var dotProduct = 0f + val len = minOf(a.size, b.size) + for (i in 0 until len) { + dotProduct += a[i] * b[i] + } + // 限制在 [-1, 1] 范围内,避免浮点误差 + return dotProduct.coerceIn(-1f, 1f) + } + + /** + * 判断匹配类型 + */ + private fun determineMatchType( + euclideanDist: Float, + cosineSim: Float, + secondNearestDist: Float + ): MatchType { + // 同时满足欧氏距离和余弦相似度阈值 + val euclideanMatch = euclideanDist <= euclideanThreshold + val cosineMatch = cosineSim >= cosineThreshold + + // 检查是否是明显最优(与第二名的差距) + val gap = secondNearestDist - euclideanDist + val isClearlyBest = gap > 0.3f + + return when { + // 同时满足两个阈值,或者满足一个且明显优于其他 + (euclideanMatch && cosineMatch) || (euclideanMatch && isClearlyBest) || (cosineMatch && isClearlyBest) -> { + MatchType.MATCH + } + // 只满足一个阈值,但不明显优于其他 + euclideanMatch || cosineSim > (cosineThreshold * 0.9f) -> { + MatchType.UNSURE + } + else -> { + MatchType.NO_MATCH + } + } + } + + /** + * 根据一组距离自适应调整阈值 + * 适用于首次导入大量数据后优化识别效果 + */ + fun suggestThresholds(students: List): SuggestedThresholds { + if (students.size < 3) { + return SuggestedThresholds(euclideanThreshold, cosineThreshold) + } + + // 计算所有学生两两之间的距离(采样) + val distances = mutableListOf() + 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 +) diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt index bcf464e..7d2fa94 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt @@ -9,44 +9,156 @@ import com.google.mlkit.vision.face.FaceDetection import com.google.mlkit.vision.face.FaceDetectorOptions import kotlinx.coroutines.tasks.await +/** + * 人脸处理器 + * 负责人脸检测、对齐、预处理和特征提取 + */ class FaceProcessor(context: Context) : AutoCloseable { private val embedder = FaceEmbedder(context) - private val detector = FaceDetection.getClient( + private val aligner = FaceAligner() + private val preprocessor = ImagePreprocessor() + + // 实时检测器:快速模式,用于摄像头预览 + private val realtimeDetector = FaceDetection.getClient( FaceDetectorOptions.Builder() .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) .setMinFaceSize(0.08f) .enableTracking() + .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL) .build() ) + // 注册检测器:高精度模式,启用关键点检测 + private val enrollmentDetector = FaceDetection.getClient( + FaceDetectorOptions.Builder() + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) + .setMinFaceSize(0.04f) + .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL) + .build() + ) + + /** + * 从图片中提取单张人脸的特征(用于注册/上传) + * @param bitmap 输入图片 + * @return L2 归一化的特征向量 + */ suspend fun embedSingleFace(bitmap: Bitmap): FloatArray { - val faces = detectFaces(bitmap) - require(faces.isNotEmpty()) { "图片中未检测到人脸。" } - val largestFace = faces.maxBy { face -> - face.boundingBox.width() * face.boundingBox.height() + val faces = detectFacesForEnrollment(bitmap) + .mapNotNull { face -> + val safeBox = safeCropRect(bitmap, face.boundingBox) + if (safeBox != null) face to safeBox else null + } + + require(faces.isNotEmpty()) { "图片中未检测到可用人脸,请换更清晰或更正面的照片。" } + + // 选择面积最大的人脸 + val (largestFace, faceBox) = faces.maxBy { (_, box) -> + box.width() * box.height() } - 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 { - return detector.process(InputImage.fromBitmap(bitmap, 0)).await() + return realtimeDetector.process(InputImage.fromBitmap(bitmap, 0)).await() } + /** + * 注册用的人脸检测(高精度) + */ + private suspend fun detectFacesForEnrollment(bitmap: Bitmap): List { + val accurateFaces = enrollmentDetector.process(InputImage.fromBitmap(bitmap, 0)).await() + if (accurateFaces.isNotEmpty()) return accurateFaces + return realtimeDetector.process(InputImage.fromBitmap(bitmap, 0)).await() + } + + /** + * 提取指定人脸的特征 + */ fun embedFace(bitmap: Bitmap, face: Face): FloatArray { - return embedder.embed(cropFace(bitmap, face.boundingBox)) + val cropped = cropFace(bitmap, face.boundingBox) + val aligned = alignFaceWithLandmarks(cropped, face, 0, 0) + val preprocessed = preprocessor.preprocess(aligned) + return embedder.embed(preprocessed) } override fun close() { - detector.close() + realtimeDetector.close() + enrollmentDetector.close() embedder.close() } + /** + * 裁剪人脸区域 + */ private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap { - val padding = (maxOf(box.width(), box.height()) * 0.18f).toInt() - val left = (box.left - padding).coerceAtLeast(0) - val top = (box.top - padding).coerceAtLeast(0) - val right = (box.right + padding).coerceAtMost(bitmap.width) - val bottom = (box.bottom + padding).coerceAtMost(bitmap.height) - return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top) + val crop = requireNotNull(safeCropRect(bitmap, box)) { "人脸区域超出图片边界。" } + return Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height()) + } + + /** + * 计算安全的裁剪区域(带 padding) + */ + private fun safeCropRect(bitmap: Bitmap, box: Rect): Rect? { + if (box.width() <= 0 || box.height() <= 0) return null + + // 增加 padding 比例,保留更多上下文信息 + val padding = (maxOf(box.width(), box.height()) * 0.25f).toInt() + + val left = (box.left - padding).coerceIn(0, bitmap.width) + val top = (box.top - padding).coerceIn(0, bitmap.height) + val right = (box.right + padding).coerceIn(0, bitmap.width) + val bottom = (box.bottom + padding).coerceIn(0, bitmap.height) + + val width = right - left + val height = bottom - top + + if (width <= 1 || height <= 1) return null + + return Rect(left, top, right, bottom) } } diff --git a/app/src/main/java/com/example/studentfaceregistry/face/ImagePreprocessor.kt b/app/src/main/java/com/example/studentfaceregistry/face/ImagePreprocessor.kt new file mode 100644 index 0000000..7f5b1a5 --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/face/ImagePreprocessor.kt @@ -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 + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/face/RecognitionResult.kt b/app/src/main/java/com/example/studentfaceregistry/face/RecognitionResult.kt index ddea5d6..8680135 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/RecognitionResult.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/RecognitionResult.kt @@ -2,7 +2,29 @@ package com.example.studentfaceregistry.face import com.example.studentfaceregistry.data.Student +/** + * 人脸识别结果 + */ data class RecognitionResult( val student: Student?, - val distance: Float -) + val distance: Float, + val cosineSimilarity: Float = 0f, + val matchType: MatchType = MatchType.NO_MATCH +) { + /** + * 是否是匹配状态 + */ + val isMatched: Boolean get() = student != null && matchType == MatchType.MATCH + + /** + * 匹配置信度(0-1,越大越可信) + */ + val confidence: Float + get() { + if (student == null) return 0f + // 结合余弦相似度和欧氏距离计算置信度 + // 对于 L2 归一化的 512 维向量,欧氏距离通常在 0-2 之间 + val distanceScore = (1f - (distance / 2f)).coerceIn(0f, 1f) + return ((cosineSimilarity + 1f) / 2f * 0.6f + distanceScore * 0.4f).coerceIn(0f, 1f) + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/face/VideoEnrollment.kt b/app/src/main/java/com/example/studentfaceregistry/face/VideoEnrollment.kt new file mode 100644 index 0000000..9906826 --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/face/VideoEnrollment.kt @@ -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, + minFrames: Int = 5, + maxFrames: Int = 20 + ): EnrollmentResult { + val faceFrames = mutableListOf() + + // 检测所有帧中的人脸 + 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): Map> { + val buckets = mutableMapOf>() + + 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>, + maxFrames: Int + ): List { + val selected = mutableListOf() + + // 先按组排序(组内按质量降序) + 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 { + 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): 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() +} diff --git a/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt b/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt index 40be80c..cbe3753 100644 --- a/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt +++ b/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt @@ -5,28 +5,54 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Rect +import android.graphics.RippleDrawable +import android.graphics.Typeface +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RoundRectShape import android.util.AttributeSet import android.view.View +import com.example.studentfaceregistry.face.MatchType import com.example.studentfaceregistry.face.RecognitionResult +/** + * 识别叠加视图 + * 在摄像头预览上绘制识别框和标签 + */ class OverlayView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { + + // 识别框画笔 private val boxPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(37, 99, 235) style = Paint.Style.STROKE - strokeWidth = 5f + strokeWidth = 6f } + + // 标签背景画笔 private val labelBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.argb(220, 15, 23, 42) style = Paint.Style.FILL } + + // 标签文字画笔 private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE - textSize = 52f - typeface = android.graphics.Typeface.DEFAULT_BOLD + textSize = 48f + typeface = Typeface.DEFAULT_BOLD } + + // 置信度文字画笔 + private val confidencePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 32f + } + + // 颜色定义 + private val colorMatch = Color.rgb(34, 197, 94) // 绿色 - 匹配成功 + private val colorUnsure = Color.rgb(234, 179, 8) // 黄色 - 不确定 + private val colorNoMatch = Color.rgb(239, 68, 68) // 红色 - 未匹配 + private val colorUnknown = Color.rgb(59, 130, 246) // 蓝色 - 未入库 + private var detections: List = emptyList() private var imageWidth = 1 private var imageHeight = 1 @@ -40,14 +66,65 @@ class OverlayView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - detections.forEach { item -> - val rect = mapRect(item.bounds) - canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, boxPaint) - val label = item.label - val labelWidth = labelPaint.measureText(label) - val top = (rect.top - 68f).coerceAtLeast(0f) - canvas.drawRect(rect.left, top, rect.left + labelWidth + 32f, top + 64f, labelBackgroundPaint) - canvas.drawText(label, rect.left + 16f, top + 48f, labelPaint) + + detections.forEachIndexed { index, item -> + drawDetection(canvas, item, index) + } + } + + private fun drawDetection(canvas: Canvas, item: DetectionUi, index: Int) { + val rect = mapRect(item.bounds) + + // 根据识别结果设置颜色 + val boxColor = getBoxColor(item.result) + boxPaint.color = boxColor + + // 绘制识别框 + canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, boxPaint) + + // 绘制标签背景 + val label = item.label + val labelWidth = labelPaint.measureText(label) + val labelHeight = 60f + val padding = 12f + + // 标签在框上方 + val labelLeft = rect.left + padding + val labelTop = (rect.top - labelHeight - padding).coerceAtLeast(padding) + val labelRight = labelLeft + labelWidth + padding * 2 + val labelBottom = labelTop + labelHeight + + // 标签背景颜色与框颜色一致 + labelBackgroundPaint.color = boxColor + canvas.drawRoundRect(labelLeft, labelTop, labelRight, labelBottom, 8f, labelBackgroundPaint) + + // 绘制标签文字 + canvas.drawText(label, labelLeft + padding, labelTop + 38f, labelPaint) + + // 如果有置信度信息,显示在标签右侧 + if (item.confidence != null) { + val confidenceText = "${(item.confidence * 100).toInt()}%" + val confidenceWidth = confidencePaint.measureText(confidenceText) + val confLeft = labelRight + 8f + val confTop = labelTop + 8f + val confRight = confLeft + confidenceWidth + 16f + val confBottom = confTop + 44f + + // 置信度背景(半透明黑色) + labelBackgroundPaint.color = Color.argb(180, 0, 0, 0) + canvas.drawRoundRect(confLeft, confTop, confRight, confBottom, 6f, labelBackgroundPaint) + + // 置信度文字 + canvas.drawText(confidenceText, confLeft + 8f, confTop + 30f, confidencePaint) + } + } + + private fun getBoxColor(result: RecognitionResult): Int { + return when { + result.isMatched -> colorMatch + result.matchType == MatchType.UNSURE -> colorUnsure + result.student == null && result.distance != Float.MAX_VALUE -> colorNoMatch + else -> colorUnknown } } @@ -63,11 +140,21 @@ class OverlayView @JvmOverloads constructor( } } +/** + * 识别 UI 数据 + */ data class DetectionUi( val bounds: Rect, - val result: RecognitionResult + val result: RecognitionResult, + val confidence: Float? = null ) { val label: String - get() = result.student?.let { "${it.name} ${it.studentNo}" } - ?: if (result.distance == Float.MAX_VALUE) "未入库" else "未匹配 %.2f".format(result.distance) + get() { + return when { + result.isMatched -> "${result.student?.name} (${result.student?.studentNo})" + result.matchType == MatchType.UNSURE -> "疑似:${result.student?.name}" + result.student == null && result.distance != Float.MAX_VALUE -> "未匹配" + else -> "未入库" + } + } } diff --git a/app/src/main/java/com/example/studentfaceregistry/upload/UploadServer.kt b/app/src/main/java/com/example/studentfaceregistry/upload/UploadServer.kt index 381aeaf..1d47f19 100644 --- a/app/src/main/java/com/example/studentfaceregistry/upload/UploadServer.kt +++ b/app/src/main/java/com/example/studentfaceregistry/upload/UploadServer.kt @@ -296,7 +296,7 @@ class UploadServer( } val options = BitmapFactory.Options().apply { inSampleSize = sampleSize - inPreferredConfig = android.graphics.Bitmap.Config.RGB_565 + inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888 } return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options) ?: error("Unable to decode image")