diff --git a/app/debug.jpg b/app/debug.jpg new file mode 100644 index 0000000..70682e8 Binary files /dev/null and b/app/debug.jpg differ diff --git a/app/src/main/assets/debug.jpg b/app/src/main/assets/debug.jpg new file mode 100644 index 0000000..70682e8 Binary files /dev/null and b/app/src/main/assets/debug.jpg differ diff --git a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt index 68aa4c8..3f00b6e 100644 --- a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt +++ b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt @@ -1,7 +1,10 @@ package com.example.studentfaceregistry import android.Manifest +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.content.SharedPreferences +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.Rect import android.graphics.RectF @@ -16,6 +19,7 @@ import android.view.View import android.widget.EditText import android.widget.Button import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast @@ -27,8 +31,6 @@ import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView -import androidx.camera.view.transform.CoordinateTransform -import androidx.camera.view.transform.ImageProxyTransformFactory import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.example.studentfaceregistry.data.Student @@ -37,6 +39,7 @@ import com.example.studentfaceregistry.face.FaceMatcher import com.example.studentfaceregistry.face.FaceProcessor import com.example.studentfaceregistry.face.MatchType import com.example.studentfaceregistry.face.RecognitionResult +import com.example.studentfaceregistry.ui.FaceDebugRenderer import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.face.Face import com.example.studentfaceregistry.ui.DetectionUi @@ -46,6 +49,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream import java.util.concurrent.Executors class MainActivity : AppCompatActivity() { @@ -59,6 +64,8 @@ class MainActivity : AppCompatActivity() { private lateinit var uploadStatusText: TextView private lateinit var uploadUrlText: TextView private lateinit var pauseRecognitionButton: Button + private lateinit var debugTraceStatusText: TextView + private lateinit var debugTraceImageView: ImageView private lateinit var homePanel: View private lateinit var recognitionPanel: View private lateinit var uploadPanel: View @@ -73,10 +80,6 @@ class MainActivity : AppCompatActivity() { private val matcherPrefs: SharedPreferences by lazy { getSharedPreferences(MATCHER_PREFS_NAME, MODE_PRIVATE) } - private val imageProxyTransformFactory = ImageProxyTransformFactory().apply { - setUsingCropRect(false) - setUsingRotationDegrees(false) - } private val cameraExecutor = Executors.newSingleThreadExecutor() private var students: List = emptyList() private var recognitionEnabled = true @@ -85,6 +88,7 @@ class MainActivity : AppCompatActivity() { private var euclideanThreshold = DEFAULT_EUCLIDEAN_THRESHOLD private var cosineThreshold = DEFAULT_COSINE_THRESHOLD @Volatile private var matcher = FaceMatcher(DEFAULT_EUCLIDEAN_THRESHOLD, DEFAULT_COSINE_THRESHOLD) + @Volatile private var captureNextLiveDebugFrame = false private val recognitionHistory = mutableMapOf>() private val recognitionCache = mutableMapOf() @@ -288,6 +292,9 @@ class MainActivity : AppCompatActivity() { }) addView(matcherConfigPanel) + if (isDebugBuild()) { + addView(createDebugTracePanel()) + } val cameraFrame = FrameLayout(this@MainActivity).apply { layoutParams = LinearLayout.LayoutParams( @@ -332,6 +339,56 @@ class MainActivity : AppCompatActivity() { } } + private fun createDebugTracePanel(): View { + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(16, 12, 16, 12) + setBackgroundColor(0xFFFFFFFF.toInt()) + + val row = LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + row.addView(Button(this@MainActivity).apply { + text = "调试 debug.jpg" + setOnClickListener { runDebugTraceFromAsset() } + }) + row.addView(Button(this@MainActivity).apply { + text = "捕获实时帧" + setOnClickListener { + captureNextLiveDebugFrame = true + debugTraceStatusText.text = "等待下一帧人脸画面..." + } + }) + + debugTraceStatusText = TextView(this@MainActivity).apply { + text = "将 assets/debug.jpg 生成管线调试图" + textSize = 13f + setTextColor(0xFF475569.toInt()) + setPadding(16, 0, 0, 0) + } + row.addView(debugTraceStatusText, LinearLayout.LayoutParams(0, -2, 1f)) + addView(row) + + debugTraceImageView = ImageView(this@MainActivity).apply { + visibility = View.GONE + adjustViewBounds = true + scaleType = ImageView.ScaleType.FIT_CENTER + setBackgroundColor(0xFFF8FAFC.toInt()) + } + addView( + debugTraceImageView, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(320) + ).apply { + topMargin = dp(10) + } + ) + } + } + private fun createUploadPanel(): View { return LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -531,9 +588,14 @@ class MainActivity : AppCompatActivity() { if (shouldRefreshRecognition(face.trackingId, now)) index to face else null } val refreshedResults = mutableMapOf() + val debugBitmap = if (faces.isNotEmpty() && (facesNeedingRefresh.isNotEmpty() || isDebugBuild())) { + ImageUtils.imageProxyToBitmap(image) + } else { + null + } if (facesNeedingRefresh.isNotEmpty()) { - val bitmap = ImageUtils.imageProxyToBitmap(image) + val bitmap = requireNotNull(debugBitmap) facesNeedingRefresh.forEach { (index, face) -> val embedding = faceProcessor.embedFace(bitmap, face) val refreshed = matcher.findNearest(embedding, students) @@ -558,7 +620,9 @@ class MainActivity : AppCompatActivity() { withContext(Dispatchers.Main) { if (currentMode == AppMode.RECOGNITION) { val detections = mapDetectionsToPreview(image, rawDetections) - overlayView.update(detections) + traceFacePipeline(image, faces, debugBitmap, rawDetections, detections) + maybeSaveLiveDebugFrame(image, debugBitmap, rawDetections, detections) + overlayView.update(detections.filter { it.result.isMatched }) updateRecognitionStatus(detections) } } @@ -635,6 +699,97 @@ class MainActivity : AppCompatActivity() { uploadUrlText.text = "-" } + private fun runDebugTraceFromAsset() { + if (!isDebugBuild()) return + + val faceProcessor = ensureProcessor() ?: return + val previewWidth = previewView.width.takeIf { it > 0 } ?: 0 + val previewHeight = previewView.height.takeIf { it > 0 } ?: 0 + debugTraceStatusText.text = "正在读取 debug.jpg ..." + + lifecycleScope.launch(Dispatchers.Default) { + try { + val sourceBitmap = loadDebugBitmapFromAssets() + val steps = faceProcessor.debugTrace(sourceBitmap) + val results = steps.map { step -> + val embedding = faceProcessor.embedFace(sourceBitmap, step.face) + matcher.findNearest(embedding, students) + } + val rendered = FaceDebugRenderer.render( + source = sourceBitmap, + steps = steps, + results = results, + previewWidth = previewWidth.takeIf { it > 0 } ?: sourceBitmap.width, + previewHeight = previewHeight.takeIf { it > 0 } ?: sourceBitmap.height + ) + val outFile = File(cacheDir, "face_trace_debug.png") + FileOutputStream(outFile).use { stream -> + rendered.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + withContext(Dispatchers.Main) { + debugTraceImageView.setImageBitmap(rendered) + debugTraceImageView.visibility = View.VISIBLE + debugTraceStatusText.text = "已生成 ${outFile.absolutePath}" + } + } catch (e: Exception) { + Log.e("MainActivity", "Debug trace failed", e) + withContext(Dispatchers.Main) { + debugTraceStatusText.text = "调试图生成失败:${e.message ?: e.javaClass.simpleName}" + toast("调试图生成失败:${e.message ?: e.javaClass.simpleName}") + } + } + } + } + + private fun loadDebugBitmapFromAssets(): Bitmap { + assets.open(DEBUG_ASSET_NAME).use { stream -> + return BitmapFactory.decodeStream(stream) + ?: error("无法解码 assets/$DEBUG_ASSET_NAME") + } + } + + private fun maybeSaveLiveDebugFrame( + image: ImageProxy, + bitmap: Bitmap?, + rawDetections: List, + mappedDetections: List + ) { + if (!isDebugBuild() || !captureNextLiveDebugFrame) return + val sourceBitmap = bitmap ?: return + captureNextLiveDebugFrame = false + + val previewWidth = previewView.width.takeIf { it > 0 } ?: sourceBitmap.width + val previewHeight = previewView.height.takeIf { it > 0 } ?: sourceBitmap.height + val boxes = rawDetections.mapIndexed { index, raw -> + FaceDebugRenderer.LiveBox( + raw = Rect(raw.bounds), + mapped = RectF(mappedDetections.getOrNull(index)?.bounds ?: RectF()), + label = mappedDetections.getOrNull(index)?.label ?: raw.result.matchType.name + ) + } + val metadata = buildString { + append("proxy=").append(image.width).append('x').append(image.height) + append(" rotation=").append(image.imageInfo.rotationDegrees) + append(" cropRect=").append(image.cropRect) + append(" preview=").append(previewWidth).append('x').append(previewHeight) + append(" bitmap=").append(sourceBitmap.width).append('x').append(sourceBitmap.height) + } + val rendered = FaceDebugRenderer.renderLiveFrame( + source = sourceBitmap, + boxes = boxes, + previewWidth = previewWidth, + previewHeight = previewHeight, + metadata = metadata + ) + val outFile = File(cacheDir, "face_trace_live.png") + FileOutputStream(outFile).use { stream -> + rendered.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + debugTraceImageView.setImageBitmap(rendered) + debugTraceImageView.visibility = View.VISIBLE + debugTraceStatusText.text = "已生成 ${outFile.absolutePath}" + } + private fun ensureProcessor(): FaceProcessor? { val current = processor if (current != null) return current @@ -650,6 +805,14 @@ class MainActivity : AppCompatActivity() { return created } + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } + + private fun isDebugBuild(): Boolean { + return applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + } + private fun hasCameraPermission(): Boolean { return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED } @@ -705,18 +868,109 @@ class MainActivity : AppCompatActivity() { } private fun mapDetectionsToPreview(image: ImageProxy, rawDetections: List): List { - val target = previewView.outputTransform ?: return rawDetections.map { - DetectionUi(android.graphics.RectF(it.bounds), it.result, it.confidence) + val previewWidth = previewView.width.toFloat() + val previewHeight = previewView.height.toFloat() + if (previewWidth <= 0f || previewHeight <= 0f) { + return rawDetections.map { DetectionUi(RectF(it.bounds), it.result, it.confidence) } } - val source = imageProxyTransformFactory.getOutputTransform(image) - val transform = CoordinateTransform(source, target) + + val rotatedCropRect = rotateCropRectToDisplaySpace( + cropRect = image.cropRect, + imageWidth = image.width, + imageHeight = image.height, + rotationDegrees = image.imageInfo.rotationDegrees + ) + val scale = maxOf( + previewWidth / rotatedCropRect.width().coerceAtLeast(1), + previewHeight / rotatedCropRect.height().coerceAtLeast(1) + ) + val offsetX = (previewWidth - rotatedCropRect.width() * scale) / 2f + val offsetY = (previewHeight - rotatedCropRect.height() * scale) / 2f + return rawDetections.map { detection -> - val rect = RectF(detection.bounds) - transform.mapRect(rect) + val rect = RectF( + (detection.bounds.left - rotatedCropRect.left) * scale + offsetX, + (detection.bounds.top - rotatedCropRect.top) * scale + offsetY, + (detection.bounds.right - rotatedCropRect.left) * scale + offsetX, + (detection.bounds.bottom - rotatedCropRect.top) * scale + offsetY + ) DetectionUi(rect, detection.result, detection.confidence) } } + private fun rotateCropRectToDisplaySpace( + cropRect: Rect, + imageWidth: Int, + imageHeight: Int, + rotationDegrees: Int + ): Rect { + return when ((rotationDegrees % 360 + 360) % 360) { + 0 -> Rect(cropRect) + 90 -> Rect( + imageHeight - cropRect.bottom, + cropRect.left, + imageHeight - cropRect.top, + cropRect.right + ) + 180 -> Rect( + imageWidth - cropRect.right, + imageHeight - cropRect.bottom, + imageWidth - cropRect.left, + imageHeight - cropRect.top + ) + 270 -> Rect( + cropRect.top, + imageWidth - cropRect.right, + cropRect.bottom, + imageWidth - cropRect.left + ) + else -> Rect(cropRect) + } + } + + private fun traceFacePipeline( + image: ImageProxy, + faces: List, + bitmap: android.graphics.Bitmap?, + rawDetections: List, + mappedDetections: List + ) { + if (!isDebugBuild()) return + + val mediaImage = image.image + Log.d( + TRACE_TAG, + buildString { + append("frame proxy=").append(image.width).append('x').append(image.height) + append(", media=").append(mediaImage?.width).append('x').append(mediaImage?.height) + append(", rotation=").append(image.imageInfo.rotationDegrees) + append(", cropRect=").append(image.cropRect) + append(", preview=").append(previewView.width).append('x').append(previewView.height) + append(", previewTransform=").append(previewView.outputTransform != null) + append(", bitmap=").append(bitmap?.width).append('x').append(bitmap?.height) + append(", faces=").append(faces.size) + } + ) + + faces.forEachIndexed { index, face -> + val raw = rawDetections.getOrNull(index) + val mapped = mappedDetections.getOrNull(index) + Log.d( + TRACE_TAG, + buildString { + append("face#").append(index) + append(" track=").append(face.trackingId) + append(" box=").append(face.boundingBox) + append(" mapped=").append(mapped?.bounds) + append(" result=").append(raw?.result?.matchType) + append(" dist=").append(raw?.result?.distance) + append(" cosine=").append(raw?.result?.cosineSimilarity) + append(" conf=").append(raw?.confidence) + } + ) + } + } + private fun stabilizeRecognition(face: Face, result: RecognitionResult): RecognitionResult { val trackId = face.trackingId ?: return result val history = recognitionHistory.getOrPut(trackId) { ArrayDeque() } @@ -764,6 +1018,8 @@ class MainActivity : AppCompatActivity() { private const val DEFAULT_COSINE_THRESHOLD = 0.6f private const val RECOGNITION_REFRESH_MS = 400L private const val ANALYSIS_INTERVAL_MS = 120L + private const val TRACE_TAG = "FaceTrace" + private const val DEBUG_ASSET_NAME = "debug.jpg" private val ANALYSIS_SIZE = Size(640, 480) } } diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceDebugTrace.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceDebugTrace.kt new file mode 100644 index 0000000..4b68d64 --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceDebugTrace.kt @@ -0,0 +1,12 @@ +package com.example.studentfaceregistry.face + +import android.graphics.Bitmap +import android.graphics.Rect +import com.google.mlkit.vision.face.Face + +data class FaceDebugStep( + val face: Face, + val cropRect: Rect, + val croppedBitmap: Bitmap, + val alignedBitmap: Bitmap +) 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 643ede9..cebe93b 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt @@ -2,6 +2,7 @@ package com.example.studentfaceregistry.face import android.content.Context import android.graphics.Bitmap +import android.util.Log import com.google.ai.edge.litert.Accelerator import com.google.ai.edge.litert.CompiledModel import com.google.ai.edge.litert.TensorBuffer @@ -26,22 +27,22 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp private val outputBuffer: TensorBuffer private val inputSize: Int private val outputSize: Int + private val accelerator: Accelerator // ArcFace 标准化参数 (RGB) private val mean = floatArrayOf(127.5f, 127.5f, 127.5f) private val std = floatArrayOf(127.5f, 127.5f, 127.5f) init { - model = CompiledModel.create( - context.assets, - MODEL_ASSET_NAME, - CompiledModel.Options(setOf(Accelerator.CPU)) - ) + val (compiledModel, selectedAccelerator) = createModel(context) + model = compiledModel + accelerator = selectedAccelerator inputBuffer = model.createInputBuffers().first() outputBuffer = model.createOutputBuffers().first() val inputElements = inputBuffer.readFloat().size inputSize = inferInputSize(inputElements) outputSize = outputBuffer.readFloat().size + Log.i(TAG, "FaceEmbedder initialized with accelerator=$accelerator") } /** @@ -115,6 +116,7 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp fun getOutputSize(): Int = outputSize companion object { + private const val TAG = "FaceEmbedder" const val MODEL_ASSET_NAME = "facenet.tflite" } @@ -126,4 +128,21 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp } return size } + + private fun createModel(context: Context): Pair { + return runCatching { + CompiledModel.create( + context.assets, + MODEL_ASSET_NAME, + CompiledModel.Options(setOf(Accelerator.GPU)) + ) to Accelerator.GPU + }.getOrElse { gpuError -> + Log.w(TAG, "GPU initialization failed, falling back to CPU", gpuError) + CompiledModel.create( + context.assets, + MODEL_ASSET_NAME, + CompiledModel.Options(setOf(Accelerator.CPU)) + ) to Accelerator.CPU + } + } } 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 1710f3f..b1f9980 100644 --- a/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt @@ -1,8 +1,10 @@ package com.example.studentfaceregistry.face import android.content.Context +import android.content.pm.ApplicationInfo import android.graphics.Bitmap import android.graphics.Rect +import android.util.Log import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.face.Face import com.google.mlkit.vision.face.FaceDetection @@ -18,6 +20,7 @@ class FaceProcessor(context: Context) : AutoCloseable { private val embedder = FaceEmbedder(context) private val aligner = FaceAligner() private val preprocessor = ImagePreprocessor() + private val debugEnabled = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 // 实时检测器:快速模式,用于摄像头预览 private val realtimeDetector = FaceDetection.getClient( @@ -88,6 +91,30 @@ class FaceProcessor(context: Context) : AutoCloseable { val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE) ?: return faceBitmap val rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE) ?: return faceBitmap + if (debugEnabled) { + val leftEyeRelX = leftEye.position.x - faceLeft + val leftEyeRelY = leftEye.position.y - faceTop + val rightEyeRelX = rightEye.position.x - faceLeft + val rightEyeRelY = rightEye.position.y - faceTop + val angle = Math.atan2( + (rightEyeRelY - leftEyeRelY).toDouble(), + (rightEyeRelX - leftEyeRelX).toDouble() + ).toFloat() * 180f / Math.PI.toFloat() + Log.d( + TRACE_TAG, + buildString { + append("alignFace bitmap=").append(faceBitmap.width).append('x').append(faceBitmap.height) + append(" faceLeft=").append(faceLeft) + append(" faceTop=").append(faceTop) + append(" leftEye=(").append(leftEye.position.x).append(',').append(leftEye.position.y).append(')') + append(" rightEye=(").append(rightEye.position.x).append(',').append(rightEye.position.y).append(')') + append(" relLeft=(").append(leftEyeRelX).append(',').append(leftEyeRelY).append(')') + append(" relRight=(").append(rightEyeRelX).append(',').append(rightEyeRelY).append(')') + append(" angle=").append(angle) + } + ) + } + return aligner.align( bitmap = faceBitmap, leftEyeX = leftEye.position.x, @@ -113,6 +140,18 @@ class FaceProcessor(context: Context) : AutoCloseable { return detectFaces(InputImage.fromBitmap(bitmap, 0)) } + /** + * 调试入口:保留检测、裁剪、对齐三段中间结果,便于生成可视化报告。 + */ + suspend fun debugTrace(bitmap: Bitmap): List { + return detectFaces(bitmap).mapNotNull { face -> + val crop = safeCropRect(bitmap, face.boundingBox) ?: return@mapNotNull null + val cropped = Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height()) + val aligned = alignFaceWithLandmarks(cropped, face, crop.left, crop.top) + FaceDebugStep(face, Rect(crop), cropped, aligned) + } + } + /** * 注册用的人脸检测(高精度) */ @@ -126,8 +165,15 @@ class FaceProcessor(context: Context) : AutoCloseable { * 提取指定人脸的特征 */ fun embedFace(bitmap: Bitmap, face: Face): FloatArray { - val cropped = cropFace(bitmap, face.boundingBox) - val aligned = alignFaceWithLandmarks(cropped, face, 0, 0) + val crop = requireNotNull(safeCropRect(bitmap, face.boundingBox)) { "人脸区域超出图片边界。" } + val cropped = Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height()) + if (debugEnabled) { + Log.d( + TRACE_TAG, + "embedFace src=${bitmap.width}x${bitmap.height} box=${face.boundingBox} crop=$crop" + ) + } + val aligned = alignFaceWithLandmarks(cropped, face, crop.left, crop.top) val preprocessed = preprocessor.preprocessRealtime(aligned) return embedder.embed(preprocessed) } @@ -138,14 +184,6 @@ class FaceProcessor(context: Context) : AutoCloseable { embedder.close() } - /** - * 裁剪人脸区域 - */ - private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap { - val crop = requireNotNull(safeCropRect(bitmap, box)) { "人脸区域超出图片边界。" } - return Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height()) - } - /** * 计算安全的裁剪区域(带 padding) */ @@ -167,4 +205,8 @@ class FaceProcessor(context: Context) : AutoCloseable { return Rect(left, top, right, bottom) } + + companion object { + private const val TRACE_TAG = "FaceTrace" + } } diff --git a/app/src/main/java/com/example/studentfaceregistry/ui/FaceDebugRenderer.kt b/app/src/main/java/com/example/studentfaceregistry/ui/FaceDebugRenderer.kt new file mode 100644 index 0000000..e0eebde --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/ui/FaceDebugRenderer.kt @@ -0,0 +1,446 @@ +package com.example.studentfaceregistry.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import com.example.studentfaceregistry.face.FaceDebugStep +import com.example.studentfaceregistry.face.RecognitionResult +import kotlin.math.max +import kotlin.math.min + +object FaceDebugRenderer { + data class LiveBox( + val raw: android.graphics.Rect, + val mapped: RectF, + val label: String + ) + + fun render( + source: Bitmap, + steps: List, + results: List, + previewWidth: Int, + previewHeight: Int + ): Bitmap { + val width = 1600 + val outerPadding = 32 + val panelGap = 24 + val titleHeight = 52 + val panelWidth = width - outerPadding * 2 + val previewAspect = if (previewWidth > 0 && previewHeight > 0) { + previewWidth.toFloat() / previewHeight.toFloat() + } else { + source.width.toFloat() / source.height.toFloat() + } + val panelHeight = ((panelWidth / previewAspect).toInt()).coerceIn(360, 640) + val chosenIndex = chooseHighlightIndex(steps, results) + val cropStep = chosenIndex?.let { steps.getOrNull(it) } + val cropBitmap = cropStep?.croppedBitmap + val alignedBitmap = cropStep?.alignedBitmap + val rawPanelHeight = panelHeight + val previewPanelHeight = panelHeight + val thumbnailHeight = 320 + val totalHeight = + outerPadding + + titleHeight + rawPanelHeight + + panelGap + + titleHeight + previewPanelHeight + + panelGap + + titleHeight + thumbnailHeight + + panelGap + + titleHeight + thumbnailHeight + + panelGap + + 180 + + outerPadding + + val output = Bitmap.createBitmap(width, totalHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + canvas.drawColor(Color.WHITE) + + val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(15, 23, 42) + textSize = 38f + typeface = Typeface.DEFAULT_BOLD + } + val metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(71, 85, 105) + textSize = 24f + } + val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + color = Color.rgb(203, 213, 225) + } + val rawPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 6f + } + val previewPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 6f + color = Color.rgb(59, 130, 246) + } + val labelBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 28f + typeface = Typeface.DEFAULT_BOLD + } + + var top = outerPadding.toFloat() + drawTitle(canvas, "1. 原图 + 原始检测框", top, titlePaint, metaPaint, source, steps.size) + top += titleHeight.toFloat() + val rawRect = RectF( + outerPadding.toFloat(), + top, + (outerPadding + panelWidth).toFloat(), + (top + rawPanelHeight) + ) + drawPanelBorder(canvas, rawRect, borderPaint) + drawBitmapFitCenter(canvas, source, rawRect) + steps.forEachIndexed { index, step -> + val color = if (index == chosenIndex) Color.rgb(34, 197, 94) else Color.rgb(239, 68, 68) + rawPaint.color = color + val mapped = mapRectFitCenter(step.face.boundingBox, source.width, source.height, rawRect) + canvas.drawRect(mapped, rawPaint) + drawLabel(canvas, mapped.left, max(rawRect.top, mapped.top - 38f), "face#$index", labelBgPaint, labelPaint, color) + } + + top = rawRect.bottom + panelGap + drawTitle(canvas, "2. 映射到预览后的框", top, titlePaint, metaPaint, source, previewWidth, previewHeight) + top += titleHeight.toFloat() + val previewRect = RectF( + outerPadding.toFloat(), + top, + (outerPadding + panelWidth).toFloat(), + (top + previewPanelHeight) + ) + drawPanelBorder(canvas, previewRect, borderPaint) + drawBitmapCenterCrop(canvas, source, previewRect) + steps.forEachIndexed { index, step -> + val color = if (index == chosenIndex) Color.rgb(34, 197, 94) else Color.rgb(59, 130, 246) + previewPaint.color = color + val mapped = mapRectCenterCrop(step.face.boundingBox, source.width, source.height, previewRect) + canvas.drawRect(mapped, previewPaint) + drawLabel(canvas, mapped.left, max(previewRect.top, mapped.top - 38f), "mapped#$index", labelBgPaint, labelPaint, color) + } + + top = previewRect.bottom + panelGap + drawTitle(canvas, "3. 裁剪后的人脸", top, titlePaint, metaPaint, source, cropStep?.cropRect) + top += titleHeight.toFloat() + val cropRect = RectF( + outerPadding.toFloat(), + top, + (outerPadding + panelWidth / 2f - panelGap / 2f), + (top + thumbnailHeight) + ) + val alignRect = RectF( + cropRect.right + panelGap, + top, + (outerPadding + panelWidth).toFloat(), + (top + thumbnailHeight) + ) + drawPanelBorder(canvas, cropRect, borderPaint) + drawPanelBorder(canvas, alignRect, borderPaint) + if (cropBitmap != null) { + drawBitmapFitCenter(canvas, cropBitmap, cropRect) + } else { + drawEmpty(canvas, cropRect, "无裁剪结果") + } + if (alignedBitmap != null) { + drawBitmapFitCenter(canvas, alignedBitmap, alignRect) + } else { + drawEmpty(canvas, alignRect, "无对齐结果") + } + + top = cropRect.bottom + panelGap + val infoRect = RectF( + outerPadding.toFloat(), + top, + (outerPadding + panelWidth).toFloat(), + (top + 180) + ) + drawPanelBorder(canvas, infoRect, borderPaint) + val infoText = buildString { + append("输出目录:cache/face_trace_debug.png") + append(" 选中人脸:") + append(chosenIndex?.let { "face#$it" } ?: "无") + append(" 预览尺寸:") + append(previewWidth).append('x').append(previewHeight) + } + canvas.drawText(infoText, infoRect.left + 24f, infoRect.top + 60f, metaPaint) + canvas.drawText("图例:红=原始框 蓝=映射框 绿=选中人脸", infoRect.left + 24f, infoRect.top + 104f, metaPaint) + + cropStep?.let { + val cropText = "cropRect=${it.cropRect}" + canvas.drawText(cropText, infoRect.left + 24f, infoRect.top + 148f, metaPaint) + } + + return output + } + + fun renderLiveFrame( + source: Bitmap, + boxes: List, + previewWidth: Int, + previewHeight: Int, + metadata: String + ): Bitmap { + val width = 1600 + val outerPadding = 32 + val panelGap = 24 + val titleHeight = 52 + val panelWidth = width - outerPadding * 2 + val previewAspect = if (previewWidth > 0 && previewHeight > 0) { + previewWidth.toFloat() / previewHeight.toFloat() + } else { + source.width.toFloat() / source.height.toFloat() + } + val panelHeight = ((panelWidth / previewAspect).toInt()).coerceIn(360, 640) + val totalHeight = + outerPadding + + titleHeight + panelHeight + + panelGap + + titleHeight + panelHeight + + panelGap + + 220 + + outerPadding + + val output = Bitmap.createBitmap(width, totalHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + canvas.drawColor(Color.WHITE) + + val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(15, 23, 42) + textSize = 38f + typeface = Typeface.DEFAULT_BOLD + } + val metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(71, 85, 105) + textSize = 24f + } + val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + color = Color.rgb(203, 213, 225) + } + val rawPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 6f + color = Color.rgb(239, 68, 68) + } + val mappedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 6f + color = Color.rgb(59, 130, 246) + } + val labelBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 28f + typeface = Typeface.DEFAULT_BOLD + } + + var top = outerPadding.toFloat() + drawTitle(canvas, "1. 实时帧 + 原始检测框", top, titlePaint, metaPaint, source, boxes.size) + top += titleHeight.toFloat() + val rawPanel = RectF(outerPadding.toFloat(), top, (outerPadding + panelWidth).toFloat(), top + panelHeight) + drawPanelBorder(canvas, rawPanel, borderPaint) + drawBitmapFitCenter(canvas, source, rawPanel) + boxes.forEachIndexed { index, box -> + val raw = mapRectFitCenter(box.raw, source.width, source.height, rawPanel) + canvas.drawRect(raw, rawPaint) + drawLabel(canvas, raw.left, max(rawPanel.top, raw.top - 38f), "raw#$index", labelBgPaint, labelPaint, rawPaint.color) + } + + top = rawPanel.bottom + panelGap + drawTitle(canvas, "2. 实际映射到预览后的框", top, titlePaint, metaPaint, source, previewWidth, previewHeight) + top += titleHeight.toFloat() + val mappedPanel = RectF(outerPadding.toFloat(), top, (outerPadding + panelWidth).toFloat(), top + panelHeight) + drawPanelBorder(canvas, mappedPanel, borderPaint) + drawBitmapCenterCrop(canvas, source, mappedPanel) + boxes.forEachIndexed { index, box -> + val mapped = mapRectFromPreview(box.mapped, previewWidth, previewHeight, mappedPanel) + canvas.drawRect(mapped, mappedPaint) + drawLabel(canvas, mapped.left, max(mappedPanel.top, mapped.top - 38f), "mapped#$index", labelBgPaint, labelPaint, mappedPaint.color) + } + + top = mappedPanel.bottom + panelGap + val infoRect = RectF(outerPadding.toFloat(), top, (outerPadding + panelWidth).toFloat(), top + 220) + drawPanelBorder(canvas, infoRect, borderPaint) + canvas.drawText("输出目录:cache/face_trace_live.png", infoRect.left + 24f, infoRect.top + 52f, metaPaint) + canvas.drawText("图例:红=ML Kit 原始框 蓝=CameraX 实际映射框", infoRect.left + 24f, infoRect.top + 92f, metaPaint) + canvas.drawText(metadata, infoRect.left + 24f, infoRect.top + 132f, metaPaint) + boxes.firstOrNull()?.let { first -> + canvas.drawText("first raw=${first.raw} mapped=${first.mapped}", infoRect.left + 24f, infoRect.top + 172f, metaPaint) + } + + return output + } + + private fun chooseHighlightIndex( + steps: List, + results: List + ): Int? { + val matchedIndex = results.indexOfFirst { it.isMatched } + if (matchedIndex >= 0) return matchedIndex + if (steps.isEmpty()) return null + return steps.indices.maxByOrNull { steps[it].cropRect.width() * steps[it].cropRect.height() } + } + + private fun drawTitle( + canvas: Canvas, + title: String, + top: Float, + titlePaint: Paint, + metaPaint: Paint, + source: Bitmap, + extra: Any? = null + ) { + canvas.drawText(title, 32f, top + 36f, titlePaint) + val subtitle = when (extra) { + is Int -> "source=${source.width}x${source.height} faces=$extra" + is String -> extra + else -> "source=${source.width}x${source.height}" + } + canvas.drawText(subtitle, 32f, top + 48f + 26f, metaPaint) + } + + private fun drawTitle( + canvas: Canvas, + title: String, + top: Float, + titlePaint: Paint, + metaPaint: Paint, + source: Bitmap, + previewWidth: Int, + previewHeight: Int + ) { + canvas.drawText(title, 32f, top + 36f, titlePaint) + val subtitle = "source=${source.width}x${source.height} preview=${previewWidth}x${previewHeight}" + canvas.drawText(subtitle, 32f, top + 48f + 26f, metaPaint) + } + + private fun drawTitle( + canvas: Canvas, + title: String, + top: Float, + titlePaint: Paint, + metaPaint: Paint, + source: Bitmap, + cropRect: android.graphics.Rect? + ) { + canvas.drawText(title, 32f, top + 36f, titlePaint) + val subtitle = "source=${source.width}x${source.height} crop=${cropRect ?: "-"}" + canvas.drawText(subtitle, 32f, top + 48f + 26f, metaPaint) + } + + private fun drawPanelBorder(canvas: Canvas, rect: RectF, paint: Paint) { + canvas.drawRoundRect(rect, 16f, 16f, paint) + } + + private fun drawBitmapFitCenter(canvas: Canvas, bitmap: Bitmap, dest: RectF) { + val scale = min(dest.width() / bitmap.width, dest.height() / bitmap.height) + val drawWidth = bitmap.width * scale + val drawHeight = bitmap.height * scale + val left = dest.left + (dest.width() - drawWidth) / 2f + val top = dest.top + (dest.height() - drawHeight) / 2f + canvas.drawBitmap( + bitmap, + null, + RectF(left, top, left + drawWidth, top + drawHeight), + null + ) + } + + private fun drawBitmapCenterCrop(canvas: Canvas, bitmap: Bitmap, dest: RectF) { + val scale = max(dest.width() / bitmap.width, dest.height() / bitmap.height) + val drawWidth = bitmap.width * scale + val drawHeight = bitmap.height * scale + val left = dest.left + (dest.width() - drawWidth) / 2f + val top = dest.top + (dest.height() - drawHeight) / 2f + val saveCount = canvas.save() + canvas.clipRect(dest) + canvas.drawBitmap( + bitmap, + null, + RectF(left, top, left + drawWidth, top + drawHeight), + null + ) + canvas.restoreToCount(saveCount) + } + + private fun mapRectFitCenter(rect: android.graphics.Rect, srcWidth: Int, srcHeight: Int, dest: RectF): RectF { + val scale = min(dest.width() / srcWidth, dest.height() / srcHeight) + val drawWidth = srcWidth * scale + val drawHeight = srcHeight * scale + val left = dest.left + (dest.width() - drawWidth) / 2f + val top = dest.top + (dest.height() - drawHeight) / 2f + return RectF( + rect.left * scale + left, + rect.top * scale + top, + rect.right * scale + left, + rect.bottom * scale + top + ) + } + + private fun mapRectCenterCrop(rect: android.graphics.Rect, srcWidth: Int, srcHeight: Int, dest: RectF): RectF { + val scale = max(dest.width() / srcWidth, dest.height() / srcHeight) + val drawWidth = srcWidth * scale + val drawHeight = srcHeight * scale + val left = dest.left + (dest.width() - drawWidth) / 2f + val top = dest.top + (dest.height() - drawHeight) / 2f + return RectF( + rect.left * scale + left, + rect.top * scale + top, + rect.right * scale + left, + rect.bottom * scale + top + ) + } + + private fun mapRectFromPreview(rect: RectF, previewWidth: Int, previewHeight: Int, dest: RectF): RectF { + val safeWidth = previewWidth.takeIf { it > 0 } ?: 1 + val safeHeight = previewHeight.takeIf { it > 0 } ?: 1 + val scaleX = dest.width() / safeWidth + val scaleY = dest.height() / safeHeight + return RectF( + dest.left + rect.left * scaleX, + dest.top + rect.top * scaleY, + dest.left + rect.right * scaleX, + dest.top + rect.bottom * scaleY + ) + } + + private fun drawLabel( + canvas: Canvas, + left: Float, + top: Float, + text: String, + bgPaint: Paint, + textPaint: Paint, + color: Int + ) { + val paddingX = 12f + val paddingY = 10f + val textWidth = textPaint.measureText(text) + val rect = RectF(left, top, left + textWidth + paddingX * 2, top + 38f) + bgPaint.color = color + canvas.drawRoundRect(rect, 8f, 8f, bgPaint) + canvas.drawText(text, rect.left + paddingX, rect.bottom - paddingY, textPaint) + } + + private fun drawEmpty(canvas: Canvas, rect: RectF, text: String) { + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(148, 163, 184) + textSize = 28f + } + canvas.drawText(text, rect.left + 24f, rect.centerY(), paint) + } +}