继续优化
This commit is contained in:
65
AGENTS.md
Normal file
65
AGENTS.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||||
|
|
||||||
|
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||||
|
|
||||||
|
## 1. Think Before Coding
|
||||||
|
|
||||||
|
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||||
|
|
||||||
|
Before implementing:
|
||||||
|
- State your assumptions explicitly.
|
||||||
|
- If multiple interpretations exist, present them - don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
## 2. Simplicity First
|
||||||
|
|
||||||
|
**Minimum code that solves the problem. Nothing speculative.**
|
||||||
|
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
- If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||||
|
|
||||||
|
## 3. Surgical Changes
|
||||||
|
|
||||||
|
**Touch only what you must. Clean up only your own mess.**
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it - don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
|
## 4. Goal-Driven Execution
|
||||||
|
|
||||||
|
**Define success criteria. Loop until verified.**
|
||||||
|
|
||||||
|
Transform tasks into verifiable goals:
|
||||||
|
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||||
|
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||||
|
- "Refactor X" → "Ensure tests pass before and after"
|
||||||
|
|
||||||
|
For multi-step tasks, state a brief plan:
|
||||||
|
```
|
||||||
|
1. [Step] → verify: [check]
|
||||||
|
2. [Step] → verify: [check]
|
||||||
|
3. [Step] → verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||||
@@ -28,13 +28,12 @@ http://192.168.1.23:8080
|
|||||||
|
|
||||||
让电脑和手机连接同一个 Wi-Fi,然后在电脑浏览器打开这个地址。网页里可以一次选择多张学生头像,点击上传后,手机会本地检测人脸、生成 embedding 并写入本地数据库。
|
让电脑和手机连接同一个 Wi-Fi,然后在电脑浏览器打开这个地址。网页里可以一次选择多张学生头像,点击上传后,手机会本地检测人脸、生成 embedding 并写入本地数据库。
|
||||||
|
|
||||||
网页同时支持两种批量导入方式:
|
网页支持按文件夹批量导入:
|
||||||
|
|
||||||
- 选择一个文件夹,自动上传文件夹里的所有图片
|
- 选择一个文件夹,自动上传文件夹里的图片或视频
|
||||||
- 上传一个 `.zip` 压缩包,手机端自动解压后入库
|
|
||||||
|
|
||||||
文件夹选择在 Chrome / Edge 这类支持目录选择的浏览器里效果最好。
|
文件夹选择在 Chrome / Edge 这类支持目录选择的浏览器里效果最好。
|
||||||
大批量导入时优先选择文件夹上传;网页会逐张发送图片,手机端内存压力更小。
|
网页会逐个发送文件,手机端内存压力更小。
|
||||||
|
|
||||||
## 头像命名规则
|
## 头像命名规则
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
package com.example.studentfaceregistry.upload
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import com.example.studentfaceregistry.data.Student
|
import com.example.studentfaceregistry.data.Student
|
||||||
import com.example.studentfaceregistry.data.StudentRepository
|
import com.example.studentfaceregistry.data.StudentRepository
|
||||||
import com.example.studentfaceregistry.face.FaceProcessor
|
import com.example.studentfaceregistry.face.FaceProcessor
|
||||||
|
import com.example.studentfaceregistry.face.SmartEnrollment
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
@@ -17,7 +16,6 @@ import java.net.NetworkInterface
|
|||||||
import java.net.ServerSocket
|
import java.net.ServerSocket
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
class UploadServer(
|
class UploadServer(
|
||||||
@@ -27,10 +25,12 @@ class UploadServer(
|
|||||||
) : AutoCloseable {
|
) : AutoCloseable {
|
||||||
private var serverSocket: ServerSocket? = null
|
private var serverSocket: ServerSocket? = null
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
|
private lateinit var smartEnrollment: SmartEnrollment
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
running = true
|
||||||
|
smartEnrollment = SmartEnrollment(context)
|
||||||
serverSocket = ServerSocket(8080)
|
serverSocket = ServerSocket(8080)
|
||||||
thread(name = "upload-server", isDaemon = true) {
|
thread(name = "upload-server", isDaemon = true) {
|
||||||
while (running) {
|
while (running) {
|
||||||
@@ -52,6 +52,7 @@ class UploadServer(
|
|||||||
override fun close() {
|
override fun close() {
|
||||||
running = false
|
running = false
|
||||||
runCatching { serverSocket?.close() }
|
runCatching { serverSocket?.close() }
|
||||||
|
runCatching { smartEnrollment.close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handle(socket: Socket) {
|
private fun handle(socket: Socket) {
|
||||||
@@ -70,8 +71,7 @@ class UploadServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleUpload(request: HttpRequest, output: OutputStream) {
|
private fun handleUpload(request: HttpRequest, output: OutputStream) {
|
||||||
val processor = processorProvider()
|
if (processorProvider() == null) {
|
||||||
if (processor == null) {
|
|
||||||
respondText(output, 503, "Missing facenet.tflite model", "text/plain; charset=utf-8")
|
respondText(output, 503, "Missing facenet.tflite model", "text/plain; charset=utf-8")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -82,10 +82,10 @@ class UploadServer(
|
|||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val payload = JSONObject(bodyText)
|
val payload = JSONObject(bodyText)
|
||||||
val zipBase64 = payload.optString("zipBase64", "")
|
// 单文件或文件列表上传(支持图片和视频)
|
||||||
if (zipBase64.isNotBlank()) {
|
val items = parseUploadItems(payload)
|
||||||
val zipBytes = Base64.decode(zipBase64, Base64.DEFAULT)
|
items.forEachIndexed { index, item ->
|
||||||
processZip(zipBytes, processor) { ok, name, error ->
|
processUploadItem(item.fileName.ifBlank { "student_$index" }, item.base64, item.mimeType) { ok, name, error ->
|
||||||
if (ok) {
|
if (ok) {
|
||||||
accepted += 1
|
accepted += 1
|
||||||
notices += "OK $name"
|
notices += "OK $name"
|
||||||
@@ -94,19 +94,6 @@ class UploadServer(
|
|||||||
notices += "FAIL $name: $error"
|
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 {
|
}.onFailure {
|
||||||
respondText(output, 400, """{"accepted":0,"rejected":0,"message":${jsonString(it.message ?: "invalid request")}}""", "application/json; charset=utf-8")
|
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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>学生头像上传</title>
|
<title>学生人脸注册上传</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 24px; max-width: 920px; }
|
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 24px; max-width: 920px; }
|
||||||
input, button { font-size: 16px; padding: 10px; margin: 8px 0; }
|
input, button { font-size: 16px; padding: 10px; margin: 8px 0; }
|
||||||
@@ -135,18 +122,13 @@ class UploadServer(
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>学生头像批量上传</h2>
|
<h2>学生人脸注册上传</h2>
|
||||||
<p class="hint">在电脑上选择头像,点击上传后,手机会本地生成人脸特征并保存。文件名建议使用 学号_姓名.jpg。</p>
|
<p class="hint">选择学生图片或视频后上传,手机会自动按文件类型注册,并优先使用画面中最大的人脸。文件名建议使用 学号_姓名.jpg 或 学号_姓名.mp4。</p>
|
||||||
<div>
|
<div>
|
||||||
<label>上传文件夹</label><br>
|
<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>
|
<button id="uploadFolderBtn">上传文件夹</button>
|
||||||
</div>
|
</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>
|
<pre id="result"></pre>
|
||||||
<script>
|
<script>
|
||||||
const result = document.getElementById('result');
|
const result = document.getElementById('result');
|
||||||
@@ -163,7 +145,7 @@ class UploadServer(
|
|||||||
const resp = await fetch('/upload', {
|
const resp = await fetch('/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const text = await resp.text();
|
||||||
logs.push(text);
|
logs.push(text);
|
||||||
@@ -177,21 +159,6 @@ class UploadServer(
|
|||||||
}
|
}
|
||||||
result.textContent = `完成:成功 ${'$'}{ok},失败 ${'$'}{fail}\n\n` + logs.join('\n');
|
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) {
|
function toBase64(file) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -207,105 +174,66 @@ class UploadServer(
|
|||||||
respondText(output, 200, html, "text/html; charset=utf-8")
|
respondText(output, 200, html, "text/html; charset=utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseImages(root: JSONObject): List<UploadedImage> {
|
private fun parseUploadItems(root: JSONObject): List<UploadItem> {
|
||||||
val array = root.optJSONArray("images") ?: org.json.JSONArray()
|
val array = root.optJSONArray("items") ?: root.optJSONArray("images") ?: org.json.JSONArray()
|
||||||
return buildList {
|
return buildList {
|
||||||
for (index in 0 until array.length()) {
|
for (index in 0 until array.length()) {
|
||||||
val item = array.optJSONObject(index) ?: continue
|
val item = array.optJSONObject(index) ?: continue
|
||||||
add(
|
add(
|
||||||
UploadedImage(
|
UploadItem(
|
||||||
fileName = item.optString("fileName", "student_$index.jpg"),
|
fileName = item.optString("fileName", "student_$index"),
|
||||||
base64 = item.optString("base64", "")
|
base64 = item.optString("base64", ""),
|
||||||
|
mimeType = item.optString("mimeType", "")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processZip(
|
private fun processUploadItem(fileName: String, base64: String, mimeType: String, report: (ok: Boolean, name: String, error: String?) -> Unit) {
|
||||||
zipBytes: ByteArray,
|
runCatching {
|
||||||
processor: FaceProcessor,
|
val data = Base64.decode(base64, Base64.DEFAULT)
|
||||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
processBytes(fileName, data, mimeType, report)
|
||||||
) {
|
}.onFailure {
|
||||||
ZipInputStream(ByteArrayInputStream(zipBytes)).use { zip ->
|
report(false, fileName, it.message)
|
||||||
var entry = zip.nextEntry
|
}
|
||||||
while (entry != null) {
|
}
|
||||||
val entryName = entry.name
|
|
||||||
if (!entry.isDirectory && isImageFile(entryName)) {
|
private fun processBytes(fileName: String, data: ByteArray, mimeType: String, report: (ok: Boolean, name: String, error: String?) -> Unit) {
|
||||||
val imageBytes = zip.readBytes()
|
runCatching {
|
||||||
processImageBytes(entryName, imageBytes, processor, report)
|
val result = runBlocking {
|
||||||
}
|
smartEnrollment.enroll(data, mimeType, fileName)
|
||||||
zip.closeEntry()
|
|
||||||
entry = zip.nextEntry
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processImage(
|
when (result) {
|
||||||
fileName: String,
|
is com.example.studentfaceregistry.face.EnrollmentResult.Success -> {
|
||||||
base64: String,
|
val (studentNo, name) = parseStudent(fileName)
|
||||||
processor: FaceProcessor,
|
repository.add(
|
||||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
Student(
|
||||||
) {
|
studentNo = studentNo,
|
||||||
runCatching {
|
name = name,
|
||||||
val imageBytes = Base64.decode(base64, Base64.DEFAULT)
|
photoUri = "upload://$fileName",
|
||||||
processImageBytes(fileName, imageBytes, processor, report)
|
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 {
|
}.onFailure {
|
||||||
report(false, fileName, it.message)
|
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> {
|
private fun parseStudent(filename: String): Pair<String, String> {
|
||||||
val base = filename.substringBeforeLast(".")
|
val base = filename.substringBeforeLast(".")
|
||||||
val parts = base.split(Regex("[_\\-\\s]+"), limit = 2).map { it.trim() }
|
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]
|
parts[0] to parts[1]
|
||||||
} else {
|
} else {
|
||||||
base to base
|
base to base
|
||||||
@@ -334,7 +262,7 @@ class UploadServer(
|
|||||||
previous = current
|
previous = current
|
||||||
}
|
}
|
||||||
val headerText = headerBytes.toString(StandardCharsets.ISO_8859_1.name())
|
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 requestLine = lines.firstOrNull().orEmpty().split(" ")
|
||||||
val method = requestLine.getOrNull(0).orEmpty()
|
val method = requestLine.getOrNull(0).orEmpty()
|
||||||
val path = requestLine.getOrNull(1).orEmpty()
|
val path = requestLine.getOrNull(1).orEmpty()
|
||||||
@@ -399,7 +327,8 @@ private data class HttpRequest(
|
|||||||
val body: ByteArray
|
val body: ByteArray
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class UploadedImage(
|
private data class UploadItem(
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
val base64: String
|
val base64: String,
|
||||||
|
val mimeType: String = ""
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user