add sources

This commit is contained in:
2026-05-06 15:42:25 +08:00
parent 5935fffd0a
commit 5e19f09ddc
28 changed files with 1136 additions and 0 deletions

40
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,40 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.studentfaceregistry"
compileSdk = 35
defaultConfig {
applicationId = "com.example.studentfaceregistry"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.activity:activity-ktx:1.9.3")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
implementation("androidx.camera:camera-view:1.4.0")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
implementation("com.google.mlkit:face-detection:16.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
implementation("org.tensorflow:tensorflow-lite:2.16.1")
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,4 @@
Place a FaceNet-compatible TensorFlow Lite model here as facenet.tflite.
Expected input: [1, 160, 160, 3].
Expected output: a float embedding vector.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,63 @@
package com.example.studentfaceregistry
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageFormat
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.YuvImage
import android.media.Image
import androidx.camera.core.ImageProxy
import java.io.ByteArrayOutputStream
import kotlin.math.min
object ImageUtils {
fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap {
val image = imageProxy.image ?: error("ImageProxy does not contain an image.")
val nv21 = yuv420ToNv21(image)
val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null)
val output = ByteArrayOutputStream()
yuvImage.compressToJpeg(Rect(0, 0, image.width, image.height), 90, output)
val bitmap = BitmapFactory.decodeByteArray(output.toByteArray(), 0, output.size())
val matrix = Matrix().apply {
postRotate(imageProxy.imageInfo.rotationDegrees.toFloat())
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
private fun yuv420ToNv21(image: Image): ByteArray {
val width = image.width
val height = image.height
val ySize = width * height
val chromaSize = width * height / 4
val nv21 = ByteArray(ySize + chromaSize * 2)
copyPlane(image.planes[0], width, height, nv21, 0, 1)
copyPlane(image.planes[2], width / 2, height / 2, nv21, ySize, 2)
copyPlane(image.planes[1], width / 2, height / 2, nv21, ySize + 1, 2)
return nv21
}
private fun copyPlane(
plane: Image.Plane,
width: Int,
height: Int,
output: ByteArray,
offset: Int,
outputPixelStride: Int
) {
val buffer = plane.buffer
val row = ByteArray(plane.rowStride)
var outputIndex = offset
for (rowIndex in 0 until height) {
val bytesToRead = min(plane.rowStride, buffer.remaining())
buffer.get(row, 0, bytesToRead)
for (colIndex in 0 until width) {
val inputIndex = colIndex * plane.pixelStride
if (inputIndex < bytesToRead && outputIndex < output.size) {
output[outputIndex] = row[inputIndex]
}
outputIndex += outputPixelStride
}
}
}
}

View File

@@ -0,0 +1,202 @@
package com.example.studentfaceregistry
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Typeface
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.example.studentfaceregistry.data.StudentRepository
import com.example.studentfaceregistry.data.Student
import com.example.studentfaceregistry.face.FaceMatcher
import com.example.studentfaceregistry.face.FaceProcessor
import com.example.studentfaceregistry.ui.DetectionUi
import com.example.studentfaceregistry.ui.OverlayView
import com.example.studentfaceregistry.upload.UploadServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
class MainActivity : AppCompatActivity() {
private lateinit var previewView: PreviewView
private lateinit var overlayView: OverlayView
private lateinit var statusText: TextView
private lateinit var countText: TextView
private var processor: FaceProcessor? = null
private var uploadServer: UploadServer? = null
private val repository by lazy { StudentRepository(this) }
private val matcher = FaceMatcher()
private val cameraExecutor = Executors.newSingleThreadExecutor()
private var students: List<Student> = emptyList()
private var recognitionEnabled = true
private var analyzing = false
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants ->
if (grants[Manifest.permission.CAMERA] == true) startCamera() else toast("需要相机权限才能识别学生身份。")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(createContentView())
processor = runCatching { FaceProcessor(this) }
.onFailure {
statusText.text = "缺少模型文件app/src/main/assets/facenet.tflite"
toast("请先放入 facenet.tflite 模型。")
}
.getOrNull()
uploadServer = UploadServer(this, repository) { processor }.also { it.start() }
lifecycleScope.launch {
repository.students.collectLatest { list ->
students = list
countText.text = "已入库 ${list.size} 名学生"
}
}
statusText.text = "电脑浏览器打开上传地址来批量导入头像"
if (hasCameraPermission()) startCamera() else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA))
}
override fun onDestroy() {
super.onDestroy()
processor?.close()
uploadServer?.close()
cameraExecutor.shutdown()
}
private fun createContentView(): View {
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(0xFFF8FAFC.toInt())
}
val header = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(32, 28, 32, 20)
}
header.addView(TextView(this).apply {
text = "学生人脸识别"
textSize = 24f
setTextColor(0xFF0F172A.toInt())
})
countText = TextView(this).apply {
text = "已入库 0 名学生"
textSize = 15f
setTextColor(0xFF475569.toInt())
}
statusText = TextView(this).apply {
text = "点击上传地址,用电脑浏览器批量上传头像"
textSize = 22f
typeface = Typeface.DEFAULT_BOLD
setTextColor(0xFF0F172A.toInt())
setPadding(0, 12, 0, 0)
}
header.addView(countText)
header.addView(statusText)
val cameraFrame = FrameLayout(this).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
}
previewView = PreviewView(this).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
}
overlayView = OverlayView(this)
cameraFrame.addView(previewView, FrameLayout.LayoutParams(-1, -1))
cameraFrame.addView(overlayView, FrameLayout.LayoutParams(-1, -1))
val controls = LinearLayout(this).apply {
gravity = Gravity.CENTER
orientation = LinearLayout.HORIZONTAL
setPadding(20, 20, 20, 28)
}
controls.addView(Button(this).apply {
text = "上传地址"
setOnClickListener {
statusText.text = uploadServer?.accessUrl() ?: "上传服务未启动"
}
})
controls.addView(Button(this).apply {
text = "暂停识别"
setOnClickListener {
recognitionEnabled = !recognitionEnabled
text = if (recognitionEnabled) "暂停识别" else "开始识别"
statusText.text = if (recognitionEnabled) "正在识别" else "识别已暂停"
}
})
root.addView(header)
root.addView(cameraFrame)
root.addView(controls)
return root
}
private fun startCamera() {
val providerFuture = ProcessCameraProvider.getInstance(this)
providerFuture.addListener({
val cameraProvider = providerFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor) { image -> analyze(image) } }
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
}, ContextCompat.getMainExecutor(this))
}
private fun analyze(image: ImageProxy) {
val faceProcessor = processor
if (!recognitionEnabled || analyzing || faceProcessor == null) {
image.close()
return
}
analyzing = true
lifecycleScope.launch {
try {
val bitmap = withContext(Dispatchers.Default) { ImageUtils.imageProxyToBitmap(image) }
val faces = faceProcessor.detectFaces(bitmap)
val detections = faces.map { face ->
val embedding = faceProcessor.embedFace(bitmap, face)
DetectionUi(face.boundingBox, matcher.findNearest(embedding, students))
}
overlayView.update(detections, bitmap.width, bitmap.height)
statusText.text = detections.firstOrNull()?.result?.student?.let {
"识别到:${it.name}${it.studentNo}"
} ?: if (detections.isEmpty()) "未检测到人脸" else "检测到未入库人脸"
} catch (_: Exception) {
statusText.text = "识别暂不可用,请确认模型文件和相机画面。"
} finally {
analyzing = false
image.close()
}
}
}
private fun hasCameraPermission(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
}
private fun toast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}

View File

@@ -0,0 +1,10 @@
package com.example.studentfaceregistry.data
data class Student(
val id: Long = 0,
val studentNo: String,
val name: String,
val photoUri: String,
val embedding: FloatArray,
val createdAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,69 @@
package com.example.studentfaceregistry.data
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
class StudentRepository(context: Context) {
private val storageFile = File(context.filesDir, "students.json")
private val _students = MutableStateFlow(load())
val students: StateFlow<List<Student>> = _students
fun add(student: Student) {
val next = _students.value.toMutableList().apply { add(student) }
.sortedByDescending { it.createdAt }
_students.value = next
save(next)
}
fun clear() {
_students.value = emptyList()
save(emptyList())
}
private fun load(): List<Student> {
if (!storageFile.exists()) return emptyList()
val raw = storageFile.readText()
if (raw.isBlank()) return emptyList()
val array = JSONArray(raw)
return buildList {
for (index in 0 until array.length()) {
val obj = array.getJSONObject(index)
add(
Student(
id = obj.optLong("id", index.toLong()),
studentNo = obj.getString("studentNo"),
name = obj.getString("name"),
photoUri = obj.getString("photoUri"),
embedding = decodeEmbedding(obj.getJSONArray("embedding")),
createdAt = obj.optLong("createdAt", System.currentTimeMillis())
)
)
}
}
}
private fun save(students: List<Student>) {
val array = JSONArray()
students.forEach { student ->
array.put(
JSONObject().apply {
put("id", student.id)
put("studentNo", student.studentNo)
put("name", student.name)
put("photoUri", student.photoUri)
put("createdAt", student.createdAt)
put("embedding", JSONArray(student.embedding.toList()))
}
)
}
storageFile.writeText(array.toString())
}
private fun decodeEmbedding(array: JSONArray): FloatArray {
return FloatArray(array.length()) { index -> array.getDouble(index).toFloat() }
}
}

View File

@@ -0,0 +1,55 @@
package com.example.studentfaceregistry.face
import android.content.Context
import android.graphics.Bitmap
import org.tensorflow.lite.Interpreter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.sqrt
class FaceEmbedder(context: Context) : AutoCloseable {
private val interpreter: Interpreter
private val inputSize = 160
private val outputSize: Int
init {
val modelBytes = context.assets.open("facenet.tflite").use { input ->
input.readBytes()
}
interpreter = Interpreter(ByteBuffer.allocateDirect(modelBytes.size).apply {
order(ByteOrder.nativeOrder())
put(modelBytes)
rewind()
})
outputSize = interpreter.getOutputTensor(0).shape().last()
}
fun embed(face: Bitmap): FloatArray {
val resized = Bitmap.createScaledBitmap(face, inputSize, inputSize, true)
val input = ByteBuffer
.allocateDirect(inputSize * inputSize * 3 * Float.SIZE_BYTES)
.order(ByteOrder.nativeOrder())
val pixels = IntArray(inputSize * inputSize)
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
pixels.forEach { color ->
input.putFloat((((color shr 16) and 0xFF) - 127.5f) / 128f)
input.putFloat((((color shr 8) and 0xFF) - 127.5f) / 128f)
input.putFloat(((color and 0xFF) - 127.5f) / 128f)
}
input.rewind()
val output = Array(1) { FloatArray(outputSize) }
interpreter.run(input, output)
return l2Normalize(output[0])
}
override fun close() {
interpreter.close()
}
private fun l2Normalize(values: FloatArray): FloatArray {
var sum = 0f
values.forEach { sum += it * it }
val norm = sqrt(sum.coerceAtLeast(1e-12f))
return FloatArray(values.size) { index -> values[index] / norm }
}
}

View File

@@ -0,0 +1,32 @@
package com.example.studentfaceregistry.face
import com.example.studentfaceregistry.data.Student
import kotlin.math.sqrt
class FaceMatcher(private val threshold: Float = 0.95f) {
fun findNearest(embedding: FloatArray, students: List<Student>): RecognitionResult {
var nearest: Student? = null
var nearestDistance = Float.MAX_VALUE
students.forEach { student ->
val distance = euclidean(embedding, student.embedding)
if (distance < nearestDistance) {
nearest = student
nearestDistance = distance
}
}
return if (nearest != null && nearestDistance <= threshold) {
RecognitionResult(nearest, nearestDistance)
} else {
RecognitionResult(null, nearestDistance)
}
}
private fun euclidean(a: FloatArray, b: FloatArray): Float {
var sum = 0f
for (index in 0 until minOf(a.size, b.size)) {
val diff = a[index] - b[index]
sum += diff * diff
}
return sqrt(sum)
}
}

View File

@@ -0,0 +1,52 @@
package com.example.studentfaceregistry.face
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Rect
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
class FaceProcessor(context: Context) : AutoCloseable {
private val embedder = FaceEmbedder(context)
private val detector = FaceDetection.getClient(
FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
.setMinFaceSize(0.08f)
.enableTracking()
.build()
)
suspend fun embedSingleFace(bitmap: Bitmap): FloatArray {
val faces = detectFaces(bitmap)
require(faces.isNotEmpty()) { "图片中未检测到人脸。" }
val largestFace = faces.maxBy { face ->
face.boundingBox.width() * face.boundingBox.height()
}
return embedFace(bitmap, largestFace)
}
suspend fun detectFaces(bitmap: Bitmap): List<Face> {
return detector.process(InputImage.fromBitmap(bitmap, 0)).await()
}
fun embedFace(bitmap: Bitmap, face: Face): FloatArray {
return embedder.embed(cropFace(bitmap, face.boundingBox))
}
override fun close() {
detector.close()
embedder.close()
}
private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap {
val padding = (maxOf(box.width(), box.height()) * 0.18f).toInt()
val left = (box.left - padding).coerceAtLeast(0)
val top = (box.top - padding).coerceAtLeast(0)
val right = (box.right + padding).coerceAtMost(bitmap.width)
val bottom = (box.bottom + padding).coerceAtMost(bitmap.height)
return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
}
}

View File

@@ -0,0 +1,8 @@
package com.example.studentfaceregistry.face
import com.example.studentfaceregistry.data.Student
data class RecognitionResult(
val student: Student?,
val distance: Float
)

View File

@@ -0,0 +1,73 @@
package com.example.studentfaceregistry.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import com.example.studentfaceregistry.face.RecognitionResult
class OverlayView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val boxPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(37, 99, 235)
style = Paint.Style.STROKE
strokeWidth = 5f
}
private val labelBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(220, 15, 23, 42)
style = Paint.Style.FILL
}
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 52f
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
private var detections: List<DetectionUi> = emptyList()
private var imageWidth = 1
private var imageHeight = 1
fun update(items: List<DetectionUi>, width: Int, height: Int) {
detections = items
imageWidth = width.coerceAtLeast(1)
imageHeight = height.coerceAtLeast(1)
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
detections.forEach { item ->
val rect = mapRect(item.bounds)
canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, boxPaint)
val label = item.label
val labelWidth = labelPaint.measureText(label)
val top = (rect.top - 68f).coerceAtLeast(0f)
canvas.drawRect(rect.left, top, rect.left + labelWidth + 32f, top + 64f, labelBackgroundPaint)
canvas.drawText(label, rect.left + 16f, top + 48f, labelPaint)
}
}
private fun mapRect(rect: Rect): android.graphics.RectF {
val scaleX = width.toFloat() / imageWidth
val scaleY = height.toFloat() / imageHeight
return android.graphics.RectF(
width - rect.right * scaleX,
rect.top * scaleY,
width - rect.left * scaleX,
rect.bottom * scaleY
)
}
}
data class DetectionUi(
val bounds: Rect,
val result: RecognitionResult
) {
val label: String
get() = result.student?.let { "${it.name} ${it.studentNo}" }
?: if (result.distance == Float.MAX_VALUE) "未入库" else "未匹配 %.2f".format(result.distance)
}

View File

@@ -0,0 +1,405 @@
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 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
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(
private val context: Context,
private val repository: StudentRepository,
private val processorProvider: () -> FaceProcessor?
) : AutoCloseable {
private var serverSocket: ServerSocket? = null
@Volatile private var running = false
fun start() {
if (running) return
running = true
serverSocket = ServerSocket(8080)
thread(name = "upload-server", isDaemon = true) {
while (running) {
val socket = try {
serverSocket?.accept()
} catch (_: Exception) {
null
} ?: break
handle(socket)
}
}
}
fun accessUrl(): String {
val ip = currentIpAddress() ?: "127.0.0.1"
return "http://$ip:8080"
}
override fun close() {
running = false
runCatching { serverSocket?.close() }
}
private fun handle(socket: Socket) {
thread(name = "upload-request", isDaemon = true) {
socket.use { client ->
val input = BufferedInputStream(client.getInputStream())
val output = client.getOutputStream()
val request = readHttpRequest(input)
when {
request.method == "GET" && request.path == "/" -> respondHtml(output)
request.method == "POST" && request.path == "/upload" -> handleUpload(request, output)
else -> respondText(output, 404, "Not found", "text/plain; charset=utf-8")
}
}
}
}
private fun handleUpload(request: HttpRequest, output: OutputStream) {
val processor = processorProvider()
if (processor == null) {
respondText(output, 503, "Missing facenet.tflite model", "text/plain; charset=utf-8")
return
}
val bodyText = String(request.body, StandardCharsets.UTF_8)
var accepted = 0
var rejected = 0
val notices = mutableListOf<String>()
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 ->
if (ok) {
accepted += 1
notices += "OK $name"
} else {
rejected += 1
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")
return
}
val result = """
{"accepted":$accepted,"rejected":$rejected,"message":${jsonString(notices.joinToString("\n"))}}
""".trimIndent()
respondText(output, 200, result, "application/json; charset=utf-8")
}
private fun respondHtml(output: OutputStream) {
val html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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; }
.hint { color: #555; line-height: 1.6; }
pre { background: #f4f4f5; padding: 12px; white-space: pre-wrap; }
</style>
</head>
<body>
<h2>学生头像批量上传</h2>
<p class="hint">在电脑上选择头像,点击上传后,手机会本地生成人脸特征并保存。文件名建议使用 学号_姓名.jpg。</p>
<div>
<label>上传文件夹</label><br>
<input id="folderFiles" type="file" multiple webkitdirectory directory accept="image/*"><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');
document.getElementById('uploadFolderBtn').onclick = async () => {
result.textContent = '上传中...';
const files = document.getElementById('folderFiles').files;
let ok = 0;
let fail = 0;
const logs = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
result.textContent = `上传中 ${'$'}{i + 1}/${'$'}{files.length}: ${'$'}{file.name}`;
const base64 = await toBase64(file);
const resp = await fetch('/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ images: [{ fileName: file.name, base64 }] })
});
const text = await resp.text();
logs.push(text);
try {
const parsed = JSON.parse(text);
ok += parsed.accepted || 0;
fail += parsed.rejected || 0;
} catch (e) {
fail += 1;
}
}
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();
reader.onload = () => resolve(String(reader.result).split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
</script>
</body>
</html>
""".trimIndent()
respondText(output, 200, html, "text/html; charset=utf-8")
}
private fun parseImages(root: JSONObject): List<UploadedImage> {
val array = 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", "")
)
)
}
}
}
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 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)
}.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.RGB_565
}
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()) {
parts[0] to parts[1]
} else {
base to base
}
}
private fun readHttpRequest(input: BufferedInputStream): HttpRequest {
val headerBytes = ByteArrayOutputStream()
var previous = -1
var current = -1
while (true) {
current = input.read()
if (current == -1) break
headerBytes.write(current)
if (previous == '\r'.code && current == '\n'.code) {
val bytes = headerBytes.toByteArray()
if (bytes.size >= 4 &&
bytes[bytes.size - 4] == '\r'.code.toByte() &&
bytes[bytes.size - 3] == '\n'.code.toByte() &&
bytes[bytes.size - 2] == '\r'.code.toByte() &&
bytes[bytes.size - 1] == '\n'.code.toByte()
) {
break
}
}
previous = current
}
val headerText = headerBytes.toString(StandardCharsets.ISO_8859_1.name())
val lines = headerText.split("\r\n").filter { it.isNotBlank() }
val requestLine = lines.firstOrNull().orEmpty().split(" ")
val method = requestLine.getOrNull(0).orEmpty()
val path = requestLine.getOrNull(1).orEmpty()
val contentLength = lines.firstOrNull { it.startsWith("Content-Length:", true) }
?.substringAfter(":")?.trim()?.toIntOrNull() ?: 0
val body = readExactly(input, contentLength)
return HttpRequest(method, path, body)
}
private fun readExactly(input: BufferedInputStream, length: Int): ByteArray {
val output = ByteArrayOutputStream(length)
var remaining = length
val buffer = ByteArray(8192)
while (remaining > 0) {
val read = input.read(buffer, 0, minOf(buffer.size, remaining))
if (read <= 0) break
output.write(buffer, 0, read)
remaining -= read
}
return output.toByteArray()
}
private fun respondText(output: OutputStream, code: Int, text: String, contentType: String) {
val bytes = text.toByteArray(StandardCharsets.UTF_8)
val head = buildString {
append("HTTP/1.1 ")
append(code)
append(" ")
append(statusText(code))
append("\r\nContent-Type: ")
append(contentType)
append("\r\nContent-Length: ")
append(bytes.size)
append("\r\nConnection: close\r\n\r\n")
}.toByteArray(StandardCharsets.ISO_8859_1)
output.write(head)
output.write(bytes)
output.flush()
}
private fun statusText(code: Int): String = when (code) {
200 -> "OK"
404 -> "Not Found"
503 -> "Service Unavailable"
else -> "OK"
}
private fun currentIpAddress(): String? {
return NetworkInterface.getNetworkInterfaces().toList()
.flatMap { it.inetAddresses.toList() }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
}
private fun jsonString(value: String): String = JSONObject.quote(value)
}
private data class HttpRequest(
val method: String,
val path: String,
val body: ByteArray
)
private data class UploadedImage(
val fileName: String,
val base64: String
)

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1D4ED8" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_background" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_background" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">学生人脸识别</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:colorAccent">#2563EB</item>
</style>
</resources>