继续优化
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Rect
|
||||
import android.media.MediaDataSource
|
||||
import android.media.MediaMetadataRetriever
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.mlkit.vision.face.Face
|
||||
import com.google.mlkit.vision.face.FaceDetection
|
||||
import com.google.mlkit.vision.face.FaceDetectorOptions
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 智能注册器
|
||||
* 支持图片和视频两种输入方式,自动选择最大人脸进行注册
|
||||
*
|
||||
* 功能特性:
|
||||
* 1. 自动识别输入类型(图片或视频)
|
||||
* 2. 自动检测并选择最大的人脸
|
||||
* 3. 视频注册时自动提取多角度帧并融合特征
|
||||
*/
|
||||
class SmartEnrollment(context: Context) {
|
||||
|
||||
// 高精度人脸检测器(用于注册)
|
||||
private val detector = FaceDetection.getClient(
|
||||
FaceDetectorOptions.Builder()
|
||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
|
||||
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
|
||||
.setMinFaceSize(0.03f)
|
||||
.build()
|
||||
)
|
||||
|
||||
private val embedder = FaceEmbedder(context, FaceEmbedder.ModelType.ARCFACE)
|
||||
private val aligner = FaceAligner()
|
||||
private val preprocessor = ImagePreprocessor()
|
||||
|
||||
/**
|
||||
* 智能注册入口
|
||||
* @param data 数据字节(图片或视频)
|
||||
* @param mimeType MIME 类型,如 "image/jpeg", "video/mp4"
|
||||
* @param fileName 文件名(用于提取学号和姓名)
|
||||
* @return 注册结果
|
||||
*/
|
||||
suspend fun enroll(
|
||||
data: ByteArray,
|
||||
mimeType: String?,
|
||||
fileName: String
|
||||
): EnrollmentResult {
|
||||
return if (isVideo(mimeType, fileName)) {
|
||||
enrollFromVideo(data)
|
||||
} else {
|
||||
enrollFromImage(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从图片注册
|
||||
*/
|
||||
private suspend fun enrollFromImage(data: ByteArray): EnrollmentResult {
|
||||
return try {
|
||||
// 解码图片
|
||||
val bitmap = decodeBitmap(data)
|
||||
|
||||
// 检测人脸并只保留可安全裁剪的人脸区域
|
||||
val faces = detectUsableFaces(bitmap)
|
||||
|
||||
if (faces.isEmpty()) {
|
||||
return EnrollmentResult.Failure("未检测到人脸,请换更清晰或更正面的照片。")
|
||||
}
|
||||
|
||||
// 选择最大的人脸
|
||||
val largestFace = faces.maxBy { faceInfo ->
|
||||
faceInfo.faceArea
|
||||
}
|
||||
|
||||
// 裁剪人脸
|
||||
val croppedFace = cropFace(bitmap, largestFace.cropRect)
|
||||
|
||||
// 对齐
|
||||
val alignedFace = alignFace(croppedFace, largestFace.face, largestFace.cropRect)
|
||||
|
||||
// 预处理
|
||||
val preprocessedFace = preprocessor.preprocess(alignedFace)
|
||||
|
||||
// 提取特征
|
||||
val embedding = embedder.embed(preprocessedFace)
|
||||
|
||||
EnrollmentResult.Success(embedding, EnrollmentSourceType.IMAGE)
|
||||
} catch (e: Exception) {
|
||||
EnrollmentResult.Failure("图片处理失败:${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从视频注册
|
||||
* @param data 视频字节
|
||||
* @param minFrames 最少帧数
|
||||
* @param maxFrames 最大帧数(用于融合)
|
||||
*/
|
||||
private suspend fun enrollFromVideo(
|
||||
data: ByteArray,
|
||||
minFrames: Int = 10,
|
||||
maxFrames: Int = 30
|
||||
): EnrollmentResult {
|
||||
return try {
|
||||
// 提取视频帧
|
||||
val frames = extractVideoFrames(data, maxFrames * 2)
|
||||
|
||||
if (frames.size < minFrames) {
|
||||
return EnrollmentResult.Failure(
|
||||
"视频有效帧数不足(检测到 ${frames.size} 帧,需要至少 $minFrames 帧)," +
|
||||
"请确保视频中人脸清晰且持续展示足够时间。"
|
||||
)
|
||||
}
|
||||
|
||||
// 对每帧选择最大人脸
|
||||
val faceFrames = frames.mapNotNull { bitmap ->
|
||||
val faces = detectUsableFaces(bitmap)
|
||||
if (faces.isEmpty()) return@mapNotNull null
|
||||
|
||||
val largestFace = faces.maxBy { faceInfo ->
|
||||
faceInfo.faceArea
|
||||
}
|
||||
|
||||
FaceFrameInfo(bitmap, largestFace.face, largestFace.cropRect, largestFace.faceArea)
|
||||
}
|
||||
|
||||
if (faceFrames.size < minFrames) {
|
||||
return EnrollmentResult.Failure(
|
||||
"视频有效人脸帧数不足(检测到 ${faceFrames.size} 帧,需要至少 $minFrames 帧)"
|
||||
)
|
||||
}
|
||||
|
||||
// 计算每帧的角度并分组
|
||||
val groupedFrames = groupFramesByAngle(faceFrames)
|
||||
|
||||
// 从每组选择质量最好的帧
|
||||
val selectedFrames = selectBestFramesFromGroups(groupedFrames, maxFrames)
|
||||
|
||||
// 提取每帧的特征
|
||||
val embeddings = selectedFrames.map { frameInfo ->
|
||||
val cropped = cropFace(frameInfo.bitmap, frameInfo.cropRect)
|
||||
val aligned = alignFace(cropped, frameInfo.face, frameInfo.cropRect)
|
||||
val preprocessed = preprocessor.preprocess(aligned)
|
||||
embedder.embed(preprocessed)
|
||||
}
|
||||
|
||||
// 融合特征
|
||||
val fusedEmbedding = fuseEmbeddings(embeddings)
|
||||
|
||||
// 计算角度覆盖
|
||||
val yawRange = calculateAngleRange(selectedFrames, { f -> yawAngle(f.face) })
|
||||
val pitchRange = calculateAngleRange(selectedFrames, { f -> pitchAngle(f.face) })
|
||||
|
||||
EnrollmentResult.Success(
|
||||
embedding = fusedEmbedding,
|
||||
sourceType = EnrollmentSourceType.VIDEO,
|
||||
frameCount = selectedFrames.size,
|
||||
yawRange = yawRange,
|
||||
pitchRange = pitchRange
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
EnrollmentResult.Failure("视频处理失败:${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取视频帧
|
||||
*/
|
||||
private fun extractVideoFrames(videoData: ByteArray, maxFrames: Int): List<Bitmap> {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
val dataSource = ByteArrayVideoDataSource(videoData)
|
||||
|
||||
return try {
|
||||
retriever.setDataSource(dataSource)
|
||||
|
||||
val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0
|
||||
val frameInterval = maxOf(100, (duration / maxFrames).toInt()) // 至少每 100ms 一帧
|
||||
|
||||
val frames = mutableListOf<Bitmap>()
|
||||
var timeUs = 0L
|
||||
|
||||
while (timeUs < duration * 1000) {
|
||||
try {
|
||||
val bitmap = retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST)
|
||||
if (bitmap != null && !bitmap.isRecycled) {
|
||||
frames.add(bitmap)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 跳过错误帧
|
||||
}
|
||||
timeUs += (frameInterval * 1000).toLong() // 转换为微秒
|
||||
}
|
||||
|
||||
frames
|
||||
} finally {
|
||||
retriever.release()
|
||||
dataSource.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算偏航角(左右转头)
|
||||
*/
|
||||
private fun yawAngle(face: Face): Float {
|
||||
val leftCheek = face.leftCheek ?: return 0f
|
||||
val rightCheek = face.rightCheek ?: return 0f
|
||||
if (!leftCheek.visible || !rightCheek.visible) return 0f
|
||||
|
||||
val dx = rightCheek.position.x - leftCheek.position.x
|
||||
val dy = rightCheek.position.y - leftCheek.position.y
|
||||
return atan2(dy.toDouble(), dx.toDouble()).toFloat() * 180f / 3.14159
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算俯仰角(上下点头)
|
||||
*/
|
||||
private fun pitchAngle(face: Face): Float {
|
||||
val nose = face.nose ?: return 0f
|
||||
val leftEye = face.leftEye ?: return 0f
|
||||
val rightEye = face.rightEye ?: return 0f
|
||||
if (!nose.visible || !leftEye.visible || !rightEye.visible) return 0f
|
||||
|
||||
val eyeCenterY = (leftEye.position.y + rightEye.position.y) / 2f
|
||||
val dx = nose.position.x - (leftEye.position.x + rightEye.position.x) / 2f
|
||||
val dy = nose.position.y - eyeCenterY
|
||||
return atan2(dy.toDouble(), dx.toDouble()).toFloat() * 180f / 3.14159
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角度分组
|
||||
*/
|
||||
private fun groupFramesByAngle(frames: List<FaceFrameInfo>): Map<AngleBucket, List<FaceFrameInfo>> {
|
||||
val buckets = mutableMapOf<AngleBucket, MutableList<FaceFrameInfo>>()
|
||||
|
||||
for (frame in frames) {
|
||||
val yaw = yawAngle(frame.face)
|
||||
val pitch = pitchAngle(frame.face)
|
||||
val bucket = AngleBucket(
|
||||
yawBucket = when {
|
||||
yaw < -30 -> -2
|
||||
yaw < -10 -> -1
|
||||
yaw < 10 -> 0
|
||||
yaw < 30 -> 1
|
||||
else -> 2
|
||||
},
|
||||
pitchBucket = when {
|
||||
pitch < -15 -> -1
|
||||
pitch < 15 -> 0
|
||||
else -> 1
|
||||
}
|
||||
)
|
||||
if (!buckets.containsKey(bucket)) buckets[bucket] = mutableListOf()
|
||||
buckets[bucket]!!.add(frame)
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
/**
|
||||
* 从每组选择最佳帧
|
||||
*/
|
||||
private fun selectBestFramesFromGroups(
|
||||
groups: Map<AngleBucket, List<FaceFrameInfo>>,
|
||||
maxFrames: Int
|
||||
): List<FaceFrameInfo> {
|
||||
val selected = mutableListOf<FaceFrameInfo>()
|
||||
|
||||
// 按组内最大人脸面积排序
|
||||
val sortedGroups = groups.entries.sortedByDescending { entry ->
|
||||
entry.value.maxOfOrNull {
|
||||
it.faceArea
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
for ((_, frames) in sortedGroups) {
|
||||
val sortedFrames = frames.sortedByDescending {
|
||||
it.faceArea
|
||||
}
|
||||
for (frame in sortedFrames) {
|
||||
if (selected.size >= maxFrames) break
|
||||
selected.add(frame)
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
/**
|
||||
* 融合多个特征向量
|
||||
*/
|
||||
private fun fuseEmbeddings(embeddings: List<FloatArray>): FloatArray {
|
||||
if (embeddings.isEmpty()) throw IllegalArgumentException("embeddings is empty")
|
||||
if (embeddings.size == 1) return embeddings[0]
|
||||
|
||||
val size = embeddings[0].size
|
||||
val sum = FloatArray(size)
|
||||
|
||||
for (embedding in embeddings) {
|
||||
for (i in 0 until size) {
|
||||
sum[i] += embedding[i]
|
||||
}
|
||||
}
|
||||
|
||||
// 平均
|
||||
for (i in 0 until size) {
|
||||
sum[i] /= embeddings.size
|
||||
}
|
||||
|
||||
// L2 归一化
|
||||
return l2Normalize(sum)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算角度范围
|
||||
*/
|
||||
private fun calculateAngleRange(
|
||||
frames: List<FaceFrameInfo>,
|
||||
angleExtractor: (FaceFrameInfo) -> Float
|
||||
): Float {
|
||||
if (frames.isEmpty()) return 0f
|
||||
val angles = frames.map(angleExtractor)
|
||||
return angles.maxOrNull()!! - angles.minOrNull()!!
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪人脸
|
||||
*/
|
||||
private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap {
|
||||
return Bitmap.createBitmap(bitmap, box.left, box.top, box.width(), box.height())
|
||||
}
|
||||
|
||||
private suspend fun detectUsableFaces(bitmap: Bitmap): List<DetectedFace> {
|
||||
return detector.process(InputImage.fromBitmap(bitmap, 0)).await()
|
||||
.mapNotNull { face ->
|
||||
val cropRect = safeCropRect(bitmap, face.boundingBox) ?: return@mapNotNull null
|
||||
val faceArea = face.boundingBox.width() * face.boundingBox.height()
|
||||
DetectedFace(face, cropRect, faceArea)
|
||||
}
|
||||
}
|
||||
|
||||
private fun safeCropRect(bitmap: Bitmap, box: Rect): Rect? {
|
||||
if (box.width() <= 0 || box.height() <= 0) return null
|
||||
|
||||
val padding = (maxOf(box.width(), box.height()) * 0.25f).toInt()
|
||||
val left = (box.left - padding).coerceIn(0, bitmap.width)
|
||||
val top = (box.top - padding).coerceIn(0, bitmap.height)
|
||||
val right = (box.right + padding).coerceIn(0, bitmap.width)
|
||||
val bottom = (box.bottom + padding).coerceIn(0, bitmap.height)
|
||||
if (right - left <= 1 || bottom - top <= 1) return null
|
||||
|
||||
return Rect(left, top, right, bottom)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对齐人脸
|
||||
*/
|
||||
private fun alignFace(bitmap: Bitmap, face: Face, cropRect: Rect): Bitmap {
|
||||
val leftEye = face.leftEye ?: return bitmap
|
||||
val rightEye = face.rightEye ?: return bitmap
|
||||
if (!leftEye.visible || !rightEye.visible) return bitmap
|
||||
|
||||
return aligner.align(
|
||||
bitmap = bitmap,
|
||||
leftEyeX = leftEye.position.x,
|
||||
leftEyeY = leftEye.position.y,
|
||||
rightEyeX = rightEye.position.x,
|
||||
rightEyeY = rightEye.position.y,
|
||||
faceLeft = cropRect.left,
|
||||
faceTop = cropRect.top
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 Bitmap
|
||||
*/
|
||||
private fun decodeBitmap(data: ByteArray): Bitmap {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
|
||||
|
||||
val maxSide = maxOf(bounds.outWidth, bounds.outHeight).coerceAtLeast(1)
|
||||
var sampleSize = 1
|
||||
while (maxSide / sampleSize > 1280) sampleSize *= 2
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
return BitmapFactory.decodeByteArray(data, 0, data.size, options)
|
||||
?: throw IllegalArgumentException("Unable to decode image")
|
||||
}
|
||||
|
||||
/**
|
||||
* L2 归一化
|
||||
*/
|
||||
private fun l2Normalize(values: FloatArray): FloatArray {
|
||||
var sum = 0f
|
||||
for (value in values) sum += value * value
|
||||
val norm = sqrt(sum.coerceAtLeast(1e-12f))
|
||||
return FloatArray(values.size) { values[it] / norm }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频
|
||||
*/
|
||||
private fun isVideo(mimeType: String?, fileName: String): Boolean {
|
||||
val lowerMimeType = mimeType?.lowercase().orEmpty()
|
||||
val lowerFileName = fileName.lowercase()
|
||||
return lowerMimeType.startsWith("video/") ||
|
||||
lowerMimeType in VIDEO_MIME_TYPES ||
|
||||
VIDEO_EXTENSIONS.any { lowerFileName.endsWith(it) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
detector.close()
|
||||
embedder.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val VIDEO_EXTENSIONS = setOf(".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv", ".webm", ".3gp")
|
||||
private val VIDEO_MIME_TYPES = setOf("application/mp4")
|
||||
}
|
||||
}
|
||||
|
||||
private data class DetectedFace(
|
||||
val face: Face,
|
||||
val cropRect: Rect,
|
||||
val faceArea: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 人脸帧信息
|
||||
*/
|
||||
private data class FaceFrameInfo(
|
||||
val bitmap: Bitmap,
|
||||
val face: Face,
|
||||
val cropRect: Rect,
|
||||
val faceArea: Int
|
||||
)
|
||||
|
||||
private class ByteArrayVideoDataSource(private val data: ByteArray) : MediaDataSource() {
|
||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||
if (position >= data.size) return -1
|
||||
val length = minOf(size, data.size - position.toInt())
|
||||
System.arraycopy(data, position.toInt(), buffer, offset, length)
|
||||
return length
|
||||
}
|
||||
|
||||
override fun getSize(): Long = data.size.toLong()
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* 角度分组
|
||||
*/
|
||||
private data class AngleBucket(val yawBucket: Int, val pitchBucket: Int)
|
||||
|
||||
/**
|
||||
* 注册结果
|
||||
*/
|
||||
sealed class EnrollmentResult {
|
||||
data class Success(
|
||||
val embedding: FloatArray,
|
||||
val sourceType: EnrollmentSourceType = EnrollmentSourceType.IMAGE,
|
||||
val frameCount: Int = 1,
|
||||
val yawRange: Float = 0f,
|
||||
val pitchRange: Float = 0f
|
||||
) : EnrollmentResult()
|
||||
|
||||
data class Failure(val reason: String) : EnrollmentResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册源类型
|
||||
*/
|
||||
enum class EnrollmentSourceType {
|
||||
IMAGE, // 图片注册
|
||||
VIDEO // 视频注册
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
package com.example.studentfaceregistry.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
import com.example.studentfaceregistry.data.StudentRepository
|
||||
import com.example.studentfaceregistry.face.FaceProcessor
|
||||
import com.example.studentfaceregistry.face.SmartEnrollment
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Inet4Address
|
||||
@@ -17,7 +16,6 @@ import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class UploadServer(
|
||||
@@ -27,10 +25,12 @@ class UploadServer(
|
||||
) : AutoCloseable {
|
||||
private var serverSocket: ServerSocket? = null
|
||||
@Volatile private var running = false
|
||||
private lateinit var smartEnrollment: SmartEnrollment
|
||||
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
smartEnrollment = SmartEnrollment(context)
|
||||
serverSocket = ServerSocket(8080)
|
||||
thread(name = "upload-server", isDaemon = true) {
|
||||
while (running) {
|
||||
@@ -52,6 +52,7 @@ class UploadServer(
|
||||
override fun close() {
|
||||
running = false
|
||||
runCatching { serverSocket?.close() }
|
||||
runCatching { smartEnrollment.close() }
|
||||
}
|
||||
|
||||
private fun handle(socket: Socket) {
|
||||
@@ -70,8 +71,7 @@ class UploadServer(
|
||||
}
|
||||
|
||||
private fun handleUpload(request: HttpRequest, output: OutputStream) {
|
||||
val processor = processorProvider()
|
||||
if (processor == null) {
|
||||
if (processorProvider() == null) {
|
||||
respondText(output, 503, "Missing facenet.tflite model", "text/plain; charset=utf-8")
|
||||
return
|
||||
}
|
||||
@@ -82,10 +82,10 @@ class UploadServer(
|
||||
|
||||
runCatching {
|
||||
val payload = JSONObject(bodyText)
|
||||
val zipBase64 = payload.optString("zipBase64", "")
|
||||
if (zipBase64.isNotBlank()) {
|
||||
val zipBytes = Base64.decode(zipBase64, Base64.DEFAULT)
|
||||
processZip(zipBytes, processor) { ok, name, error ->
|
||||
// 单文件或文件列表上传(支持图片和视频)
|
||||
val items = parseUploadItems(payload)
|
||||
items.forEachIndexed { index, item ->
|
||||
processUploadItem(item.fileName.ifBlank { "student_$index" }, item.base64, item.mimeType) { ok, name, error ->
|
||||
if (ok) {
|
||||
accepted += 1
|
||||
notices += "OK $name"
|
||||
@@ -94,19 +94,6 @@ class UploadServer(
|
||||
notices += "FAIL $name: $error"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val images = parseImages(payload)
|
||||
images.forEachIndexed { index, item ->
|
||||
processImage(item.fileName.ifBlank { "student_$index.jpg" }, item.base64, processor) { ok, name, error ->
|
||||
if (ok) {
|
||||
accepted += 1
|
||||
notices += "OK $name"
|
||||
} else {
|
||||
rejected += 1
|
||||
notices += "FAIL $name: $error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
respondText(output, 400, """{"accepted":0,"rejected":0,"message":${jsonString(it.message ?: "invalid request")}}""", "application/json; charset=utf-8")
|
||||
@@ -126,7 +113,7 @@ class UploadServer(
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>学生头像上传</title>
|
||||
<title>学生人脸注册上传</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 24px; max-width: 920px; }
|
||||
input, button { font-size: 16px; padding: 10px; margin: 8px 0; }
|
||||
@@ -135,18 +122,13 @@ class UploadServer(
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>学生头像批量上传</h2>
|
||||
<p class="hint">在电脑上选择头像,点击上传后,手机会本地生成人脸特征并保存。文件名建议使用 学号_姓名.jpg。</p>
|
||||
<h2>学生人脸注册上传</h2>
|
||||
<p class="hint">选择学生图片或视频后上传,手机会自动按文件类型注册,并优先使用画面中最大的人脸。文件名建议使用 学号_姓名.jpg 或 学号_姓名.mp4。</p>
|
||||
<div>
|
||||
<label>上传文件夹</label><br>
|
||||
<input id="folderFiles" type="file" multiple webkitdirectory directory accept="image/*"><br>
|
||||
<input id="folderFiles" type="file" multiple webkitdirectory directory accept="image/*,video/*"><br>
|
||||
<button id="uploadFolderBtn">上传文件夹</button>
|
||||
</div>
|
||||
<div>
|
||||
<label>上传压缩包</label><br>
|
||||
<input id="zipFile" type="file" accept=".zip,application/zip"><br>
|
||||
<button id="uploadZipBtn">上传 zip</button>
|
||||
</div>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
const result = document.getElementById('result');
|
||||
@@ -163,7 +145,7 @@ class UploadServer(
|
||||
const resp = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ images: [{ fileName: file.name, base64 }] })
|
||||
body: JSON.stringify({ items: [{ fileName: file.name, base64, mimeType: file.type }] })
|
||||
});
|
||||
const text = await resp.text();
|
||||
logs.push(text);
|
||||
@@ -177,21 +159,6 @@ class UploadServer(
|
||||
}
|
||||
result.textContent = `完成:成功 ${'$'}{ok},失败 ${'$'}{fail}\n\n` + logs.join('\n');
|
||||
};
|
||||
document.getElementById('uploadZipBtn').onclick = async () => {
|
||||
result.textContent = '上传中...';
|
||||
const file = document.getElementById('zipFile').files[0];
|
||||
if (!file) {
|
||||
result.textContent = '请选择 zip 文件';
|
||||
return;
|
||||
}
|
||||
const base64 = await toBase64(file);
|
||||
const resp = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ zipFileName: file.name, zipBase64: base64 })
|
||||
});
|
||||
result.textContent = await resp.text();
|
||||
};
|
||||
function toBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -207,105 +174,66 @@ class UploadServer(
|
||||
respondText(output, 200, html, "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
private fun parseImages(root: JSONObject): List<UploadedImage> {
|
||||
val array = root.optJSONArray("images") ?: org.json.JSONArray()
|
||||
private fun parseUploadItems(root: JSONObject): List<UploadItem> {
|
||||
val array = root.optJSONArray("items") ?: root.optJSONArray("images") ?: org.json.JSONArray()
|
||||
return buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val item = array.optJSONObject(index) ?: continue
|
||||
add(
|
||||
UploadedImage(
|
||||
fileName = item.optString("fileName", "student_$index.jpg"),
|
||||
base64 = item.optString("base64", "")
|
||||
UploadItem(
|
||||
fileName = item.optString("fileName", "student_$index"),
|
||||
base64 = item.optString("base64", ""),
|
||||
mimeType = item.optString("mimeType", "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processZip(
|
||||
zipBytes: ByteArray,
|
||||
processor: FaceProcessor,
|
||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
||||
) {
|
||||
ZipInputStream(ByteArrayInputStream(zipBytes)).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val entryName = entry.name
|
||||
if (!entry.isDirectory && isImageFile(entryName)) {
|
||||
val imageBytes = zip.readBytes()
|
||||
processImageBytes(entryName, imageBytes, processor, report)
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
private fun processUploadItem(fileName: String, base64: String, mimeType: String, report: (ok: Boolean, name: String, error: String?) -> Unit) {
|
||||
runCatching {
|
||||
val data = Base64.decode(base64, Base64.DEFAULT)
|
||||
processBytes(fileName, data, mimeType, report)
|
||||
}.onFailure {
|
||||
report(false, fileName, it.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processBytes(fileName: String, data: ByteArray, mimeType: String, report: (ok: Boolean, name: String, error: String?) -> Unit) {
|
||||
runCatching {
|
||||
val result = runBlocking {
|
||||
smartEnrollment.enroll(data, mimeType, fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processImage(
|
||||
fileName: String,
|
||||
base64: String,
|
||||
processor: FaceProcessor,
|
||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
||||
) {
|
||||
runCatching {
|
||||
val imageBytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
processImageBytes(fileName, imageBytes, processor, report)
|
||||
when (result) {
|
||||
is com.example.studentfaceregistry.face.EnrollmentResult.Success -> {
|
||||
val (studentNo, name) = parseStudent(fileName)
|
||||
repository.add(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
name = name,
|
||||
photoUri = "upload://$fileName",
|
||||
embedding = result.embedding
|
||||
)
|
||||
)
|
||||
val sourceInfo = if (result.sourceType == com.example.studentfaceregistry.face.EnrollmentSourceType.VIDEO) {
|
||||
"(视频${result.frameCount}帧)"
|
||||
} else ""
|
||||
report(true, fileName + sourceInfo, null)
|
||||
}
|
||||
is com.example.studentfaceregistry.face.EnrollmentResult.Failure -> {
|
||||
report(false, fileName, result.reason)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
report(false, fileName, it.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processImageBytes(
|
||||
fileName: String,
|
||||
imageBytes: ByteArray,
|
||||
processor: FaceProcessor,
|
||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
||||
) {
|
||||
runCatching {
|
||||
val bitmap = decodeBitmap(imageBytes)
|
||||
val embedding = runBlocking { processor.embedSingleFace(bitmap) }
|
||||
val (studentNo, name) = parseStudent(fileName)
|
||||
repository.add(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
name = name,
|
||||
photoUri = "upload://$fileName",
|
||||
embedding = embedding
|
||||
)
|
||||
)
|
||||
report(true, fileName, null)
|
||||
}.onFailure {
|
||||
report(false, fileName, it.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isImageFile(name: String): Boolean {
|
||||
val lower = name.lowercase()
|
||||
return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp")
|
||||
}
|
||||
|
||||
private fun decodeBitmap(imageBytes: ByteArray): android.graphics.Bitmap {
|
||||
val bounds = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, bounds)
|
||||
val maxSide = maxOf(bounds.outWidth, bounds.outHeight).coerceAtLeast(1)
|
||||
var sampleSize = 1
|
||||
while (maxSide / sampleSize > 1280) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
|
||||
}
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
||||
?: error("Unable to decode image")
|
||||
}
|
||||
|
||||
private fun parseStudent(filename: String): Pair<String, String> {
|
||||
val base = filename.substringBeforeLast(".")
|
||||
val parts = base.split(Regex("[_\\-\\s]+"), limit = 2).map { it.trim() }
|
||||
return if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) {
|
||||
return if (parts.size == 2 && parts[0].isNotEmpty() && parts[1].isNotEmpty()) {
|
||||
parts[0] to parts[1]
|
||||
} else {
|
||||
base to base
|
||||
@@ -334,7 +262,7 @@ class UploadServer(
|
||||
previous = current
|
||||
}
|
||||
val headerText = headerBytes.toString(StandardCharsets.ISO_8859_1.name())
|
||||
val lines = headerText.split("\r\n").filter { it.isNotBlank() }
|
||||
val lines = headerText.split("\r\n").filter { it.isNotEmpty() }
|
||||
val requestLine = lines.firstOrNull().orEmpty().split(" ")
|
||||
val method = requestLine.getOrNull(0).orEmpty()
|
||||
val path = requestLine.getOrNull(1).orEmpty()
|
||||
@@ -399,7 +327,8 @@ private data class HttpRequest(
|
||||
val body: ByteArray
|
||||
)
|
||||
|
||||
private data class UploadedImage(
|
||||
private data class UploadItem(
|
||||
val fileName: String,
|
||||
val base64: String
|
||||
val base64: String,
|
||||
val mimeType: String = ""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user