ajust rects
This commit is contained in:
BIN
app/debug.jpg
Normal file
BIN
app/debug.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1019 KiB |
BIN
app/src/main/assets/debug.jpg
Normal file
BIN
app/src/main/assets/debug.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1019 KiB |
@@ -1,7 +1,10 @@
|
|||||||
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
|
||||||
@@ -16,6 +19,7 @@ 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
|
||||||
@@ -27,8 +31,6 @@ import androidx.camera.core.ImageProxy
|
|||||||
import androidx.camera.core.Preview
|
import androidx.camera.core.Preview
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import androidx.camera.view.transform.CoordinateTransform
|
|
||||||
import androidx.camera.view.transform.ImageProxyTransformFactory
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.example.studentfaceregistry.data.Student
|
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.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
|
||||||
@@ -46,6 +49,8 @@ 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() {
|
||||||
@@ -59,6 +64,8 @@ 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
|
||||||
@@ -73,10 +80,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val matcherPrefs: SharedPreferences by lazy {
|
private val matcherPrefs: SharedPreferences by lazy {
|
||||||
getSharedPreferences(MATCHER_PREFS_NAME, MODE_PRIVATE)
|
getSharedPreferences(MATCHER_PREFS_NAME, MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
private val imageProxyTransformFactory = ImageProxyTransformFactory().apply {
|
|
||||||
setUsingCropRect(false)
|
|
||||||
setUsingRotationDegrees(false)
|
|
||||||
}
|
|
||||||
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||||
private var students: List<Student> = emptyList()
|
private var students: List<Student> = emptyList()
|
||||||
private var recognitionEnabled = true
|
private var recognitionEnabled = true
|
||||||
@@ -85,6 +88,7 @@ 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>()
|
||||||
|
|
||||||
@@ -288,6 +292,9 @@ 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(
|
||||||
@@ -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 {
|
private fun createUploadPanel(): View {
|
||||||
return LinearLayout(this).apply {
|
return LinearLayout(this).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
@@ -531,9 +588,14 @@ 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 = ImageUtils.imageProxyToBitmap(image)
|
val bitmap = requireNotNull(debugBitmap)
|
||||||
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)
|
||||||
@@ -558,7 +620,9 @@ 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)
|
||||||
overlayView.update(detections)
|
traceFacePipeline(image, faces, debugBitmap, rawDetections, detections)
|
||||||
|
maybeSaveLiveDebugFrame(image, debugBitmap, rawDetections, detections)
|
||||||
|
overlayView.update(detections.filter { it.result.isMatched })
|
||||||
updateRecognitionStatus(detections)
|
updateRecognitionStatus(detections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -635,6 +699,97 @@ 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
|
||||||
@@ -650,6 +805,14 @@ 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
|
||||||
}
|
}
|
||||||
@@ -705,18 +868,109 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mapDetectionsToPreview(image: ImageProxy, rawDetections: List<RawDetection>): List<DetectionUi> {
|
private fun mapDetectionsToPreview(image: ImageProxy, rawDetections: List<RawDetection>): List<DetectionUi> {
|
||||||
val target = previewView.outputTransform ?: return rawDetections.map {
|
val previewWidth = previewView.width.toFloat()
|
||||||
DetectionUi(android.graphics.RectF(it.bounds), it.result, it.confidence)
|
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 ->
|
return rawDetections.map { detection ->
|
||||||
val rect = RectF(detection.bounds)
|
val rect = RectF(
|
||||||
transform.mapRect(rect)
|
(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)
|
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<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() }
|
||||||
@@ -764,6 +1018,8 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -2,6 +2,7 @@ package com.example.studentfaceregistry.face
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.util.Log
|
||||||
import com.google.ai.edge.litert.Accelerator
|
import com.google.ai.edge.litert.Accelerator
|
||||||
import com.google.ai.edge.litert.CompiledModel
|
import com.google.ai.edge.litert.CompiledModel
|
||||||
import com.google.ai.edge.litert.TensorBuffer
|
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 outputBuffer: TensorBuffer
|
||||||
private val inputSize: Int
|
private val inputSize: Int
|
||||||
private val outputSize: Int
|
private val outputSize: Int
|
||||||
|
private val accelerator: Accelerator
|
||||||
|
|
||||||
// ArcFace 标准化参数 (RGB)
|
// ArcFace 标准化参数 (RGB)
|
||||||
private val mean = floatArrayOf(127.5f, 127.5f, 127.5f)
|
private val mean = floatArrayOf(127.5f, 127.5f, 127.5f)
|
||||||
private val std = floatArrayOf(127.5f, 127.5f, 127.5f)
|
private val std = floatArrayOf(127.5f, 127.5f, 127.5f)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
model = CompiledModel.create(
|
val (compiledModel, selectedAccelerator) = createModel(context)
|
||||||
context.assets,
|
model = compiledModel
|
||||||
MODEL_ASSET_NAME,
|
accelerator = selectedAccelerator
|
||||||
CompiledModel.Options(setOf(Accelerator.CPU))
|
|
||||||
)
|
|
||||||
inputBuffer = model.createInputBuffers().first()
|
inputBuffer = model.createInputBuffers().first()
|
||||||
outputBuffer = model.createOutputBuffers().first()
|
outputBuffer = model.createOutputBuffers().first()
|
||||||
val inputElements = inputBuffer.readFloat().size
|
val inputElements = inputBuffer.readFloat().size
|
||||||
inputSize = inferInputSize(inputElements)
|
inputSize = inferInputSize(inputElements)
|
||||||
outputSize = outputBuffer.readFloat().size
|
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
|
fun getOutputSize(): Int = outputSize
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "FaceEmbedder"
|
||||||
const val MODEL_ASSET_NAME = "facenet.tflite"
|
const val MODEL_ASSET_NAME = "facenet.tflite"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,4 +128,21 @@ class FaceEmbedder(context: Context, private val modelType: ModelType = ModelTyp
|
|||||||
}
|
}
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createModel(context: Context): Pair<CompiledModel, Accelerator> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
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
|
||||||
@@ -18,6 +20,7 @@ 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(
|
||||||
@@ -88,6 +91,30 @@ 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,
|
||||||
@@ -113,6 +140,18 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册用的人脸检测(高精度)
|
* 注册用的人脸检测(高精度)
|
||||||
*/
|
*/
|
||||||
@@ -126,8 +165,15 @@ class FaceProcessor(context: Context) : AutoCloseable {
|
|||||||
* 提取指定人脸的特征
|
* 提取指定人脸的特征
|
||||||
*/
|
*/
|
||||||
fun embedFace(bitmap: Bitmap, face: Face): FloatArray {
|
fun embedFace(bitmap: Bitmap, face: Face): FloatArray {
|
||||||
val cropped = cropFace(bitmap, face.boundingBox)
|
val crop = requireNotNull(safeCropRect(bitmap, face.boundingBox)) { "人脸区域超出图片边界。" }
|
||||||
val aligned = alignFaceWithLandmarks(cropped, face, 0, 0)
|
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)
|
val preprocessed = preprocessor.preprocessRealtime(aligned)
|
||||||
return embedder.embed(preprocessed)
|
return embedder.embed(preprocessed)
|
||||||
}
|
}
|
||||||
@@ -138,14 +184,6 @@ class FaceProcessor(context: Context) : AutoCloseable {
|
|||||||
embedder.close()
|
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)
|
* 计算安全的裁剪区域(带 padding)
|
||||||
*/
|
*/
|
||||||
@@ -167,4 +205,8 @@ 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user