remove debug info

This commit is contained in:
2026-05-11 18:00:52 +08:00
parent 2f83a1f610
commit 7998428587
4 changed files with 1 additions and 722 deletions

View File

@@ -1,10 +1,7 @@
package com.example.studentfaceregistry package com.example.studentfaceregistry
import android.Manifest import android.Manifest
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Rect import android.graphics.Rect
import android.graphics.RectF import android.graphics.RectF
@@ -19,7 +16,6 @@ import android.view.View
import android.widget.EditText import android.widget.EditText
import android.widget.Button import android.widget.Button
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
@@ -39,7 +35,6 @@ 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.face.MatchType
import com.example.studentfaceregistry.face.RecognitionResult import com.example.studentfaceregistry.face.RecognitionResult
import com.example.studentfaceregistry.ui.FaceDebugRenderer
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.Face import com.google.mlkit.vision.face.Face
import com.example.studentfaceregistry.ui.DetectionUi import com.example.studentfaceregistry.ui.DetectionUi
@@ -49,8 +44,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors import java.util.concurrent.Executors
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -64,8 +57,6 @@ class MainActivity : AppCompatActivity() {
private lateinit var uploadStatusText: TextView private lateinit var uploadStatusText: TextView
private lateinit var uploadUrlText: TextView private lateinit var uploadUrlText: TextView
private lateinit var pauseRecognitionButton: Button private lateinit var pauseRecognitionButton: Button
private lateinit var debugTraceStatusText: TextView
private lateinit var debugTraceImageView: ImageView
private lateinit var homePanel: View private lateinit var homePanel: View
private lateinit var recognitionPanel: View private lateinit var recognitionPanel: View
private lateinit var uploadPanel: View private lateinit var uploadPanel: View
@@ -88,7 +79,6 @@ class MainActivity : AppCompatActivity() {
private var euclideanThreshold = DEFAULT_EUCLIDEAN_THRESHOLD private var euclideanThreshold = DEFAULT_EUCLIDEAN_THRESHOLD
private var cosineThreshold = DEFAULT_COSINE_THRESHOLD private var cosineThreshold = DEFAULT_COSINE_THRESHOLD
@Volatile private var matcher = FaceMatcher(DEFAULT_EUCLIDEAN_THRESHOLD, DEFAULT_COSINE_THRESHOLD) @Volatile private var matcher = FaceMatcher(DEFAULT_EUCLIDEAN_THRESHOLD, DEFAULT_COSINE_THRESHOLD)
@Volatile private var captureNextLiveDebugFrame = false
private val recognitionHistory = mutableMapOf<Int, ArrayDeque<RecognitionResult>>() private val recognitionHistory = mutableMapOf<Int, ArrayDeque<RecognitionResult>>()
private val recognitionCache = mutableMapOf<Int, CachedRecognition>() private val recognitionCache = mutableMapOf<Int, CachedRecognition>()
@@ -292,9 +282,6 @@ class MainActivity : AppCompatActivity() {
}) })
addView(matcherConfigPanel) addView(matcherConfigPanel)
if (isDebugBuild()) {
addView(createDebugTracePanel())
}
val cameraFrame = FrameLayout(this@MainActivity).apply { val cameraFrame = FrameLayout(this@MainActivity).apply {
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
@@ -339,56 +326,6 @@ 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 { private fun createUploadPanel(): View {
return LinearLayout(this).apply { return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
@@ -588,14 +525,9 @@ class MainActivity : AppCompatActivity() {
if (shouldRefreshRecognition(face.trackingId, now)) index to face else null if (shouldRefreshRecognition(face.trackingId, now)) index to face else null
} }
val refreshedResults = mutableMapOf<Int, RecognitionResult>() val refreshedResults = mutableMapOf<Int, RecognitionResult>()
val debugBitmap = if (faces.isNotEmpty() && (facesNeedingRefresh.isNotEmpty() || isDebugBuild())) {
ImageUtils.imageProxyToBitmap(image)
} else {
null
}
if (facesNeedingRefresh.isNotEmpty()) { if (facesNeedingRefresh.isNotEmpty()) {
val bitmap = requireNotNull(debugBitmap) val bitmap = ImageUtils.imageProxyToBitmap(image)
facesNeedingRefresh.forEach { (index, face) -> facesNeedingRefresh.forEach { (index, face) ->
val embedding = faceProcessor.embedFace(bitmap, face) val embedding = faceProcessor.embedFace(bitmap, face)
val refreshed = matcher.findNearest(embedding, students) val refreshed = matcher.findNearest(embedding, students)
@@ -620,8 +552,6 @@ class MainActivity : AppCompatActivity() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (currentMode == AppMode.RECOGNITION) { if (currentMode == AppMode.RECOGNITION) {
val detections = mapDetectionsToPreview(image, rawDetections) val detections = mapDetectionsToPreview(image, rawDetections)
traceFacePipeline(image, faces, debugBitmap, rawDetections, detections)
maybeSaveLiveDebugFrame(image, debugBitmap, rawDetections, detections)
overlayView.update(detections.filter { it.result.isMatched }) overlayView.update(detections.filter { it.result.isMatched })
updateRecognitionStatus(detections) updateRecognitionStatus(detections)
} }
@@ -699,97 +629,6 @@ class MainActivity : AppCompatActivity() {
uploadUrlText.text = "-" 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<RawDetection>,
mappedDetections: List<DetectionUi>
) {
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? { private fun ensureProcessor(): FaceProcessor? {
val current = processor val current = processor
if (current != null) return current if (current != null) return current
@@ -805,14 +644,6 @@ class MainActivity : AppCompatActivity() {
return created 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 { private fun hasCameraPermission(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
} }
@@ -928,49 +759,6 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun traceFacePipeline(
image: ImageProxy,
faces: List<Face>,
bitmap: android.graphics.Bitmap?,
rawDetections: List<RawDetection>,
mappedDetections: List<DetectionUi>
) {
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 { private fun stabilizeRecognition(face: Face, result: RecognitionResult): RecognitionResult {
val trackId = face.trackingId ?: return result val trackId = face.trackingId ?: return result
val history = recognitionHistory.getOrPut(trackId) { ArrayDeque() } val history = recognitionHistory.getOrPut(trackId) { ArrayDeque() }
@@ -1018,8 +806,6 @@ class MainActivity : AppCompatActivity() {
private const val DEFAULT_COSINE_THRESHOLD = 0.6f private const val DEFAULT_COSINE_THRESHOLD = 0.6f
private const val RECOGNITION_REFRESH_MS = 400L private const val RECOGNITION_REFRESH_MS = 400L
private const val ANALYSIS_INTERVAL_MS = 120L 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) private val ANALYSIS_SIZE = Size(640, 480)
} }
} }

View File

@@ -1,12 +0,0 @@
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
)

View File

@@ -1,10 +1,8 @@
package com.example.studentfaceregistry.face package com.example.studentfaceregistry.face
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Rect import android.graphics.Rect
import android.util.Log
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.Face import com.google.mlkit.vision.face.Face
import com.google.mlkit.vision.face.FaceDetection import com.google.mlkit.vision.face.FaceDetection
@@ -20,7 +18,6 @@ class FaceProcessor(context: Context) : AutoCloseable {
private val embedder = FaceEmbedder(context) private val embedder = FaceEmbedder(context)
private val aligner = FaceAligner() private val aligner = FaceAligner()
private val preprocessor = ImagePreprocessor() private val preprocessor = ImagePreprocessor()
private val debugEnabled = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
// 实时检测器:快速模式,用于摄像头预览 // 实时检测器:快速模式,用于摄像头预览
private val realtimeDetector = FaceDetection.getClient( private val realtimeDetector = FaceDetection.getClient(
@@ -91,30 +88,6 @@ class FaceProcessor(context: Context) : AutoCloseable {
val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE) ?: return faceBitmap val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE) ?: return faceBitmap
val rightEye = face.getLandmark(FaceLandmark.RIGHT_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( return aligner.align(
bitmap = faceBitmap, bitmap = faceBitmap,
leftEyeX = leftEye.position.x, leftEyeX = leftEye.position.x,
@@ -140,18 +113,6 @@ class FaceProcessor(context: Context) : AutoCloseable {
return detectFaces(InputImage.fromBitmap(bitmap, 0)) return detectFaces(InputImage.fromBitmap(bitmap, 0))
} }
/**
* 调试入口:保留检测、裁剪、对齐三段中间结果,便于生成可视化报告。
*/
suspend fun debugTrace(bitmap: Bitmap): List<FaceDebugStep> {
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)
}
}
/** /**
* 注册用的人脸检测(高精度) * 注册用的人脸检测(高精度)
*/ */
@@ -167,12 +128,6 @@ class FaceProcessor(context: Context) : AutoCloseable {
fun embedFace(bitmap: Bitmap, face: Face): FloatArray { fun embedFace(bitmap: Bitmap, face: Face): FloatArray {
val crop = requireNotNull(safeCropRect(bitmap, face.boundingBox)) { "人脸区域超出图片边界。" } val crop = requireNotNull(safeCropRect(bitmap, face.boundingBox)) { "人脸区域超出图片边界。" }
val cropped = Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height()) 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 aligned = alignFaceWithLandmarks(cropped, face, crop.left, crop.top)
val preprocessed = preprocessor.preprocessRealtime(aligned) val preprocessed = preprocessor.preprocessRealtime(aligned)
return embedder.embed(preprocessed) return embedder.embed(preprocessed)
@@ -205,8 +160,4 @@ class FaceProcessor(context: Context) : AutoCloseable {
return Rect(left, top, right, bottom) return Rect(left, top, right, bottom)
} }
companion object {
private const val TRACE_TAG = "FaceTrace"
}
} }

View File

@@ -1,446 +0,0 @@
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<FaceDebugStep>,
results: List<RecognitionResult>,
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<LiveBox>,
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<FaceDebugStep>,
results: List<RecognitionResult>
): 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)
}
}