()
+
+ 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 = """
+
+
+
+
+
+ 学生头像上传
+
+
+
+ 学生头像批量上传
+ 在电脑上选择头像,点击上传后,手机会本地生成人脸特征并保存。文件名建议使用 学号_姓名.jpg。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """.trimIndent()
+ respondText(output, 200, html, "text/html; charset=utf-8")
+ }
+
+ private fun parseImages(root: JSONObject): List {
+ 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 {
+ 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()
+ .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
+)
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..19daede
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..670bfb3
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..670bfb3
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2692cc1
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ 学生人脸识别
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..f66b27e
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..be603e7
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.6.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.20" apply false
+ id("org.jetbrains.kotlin.kapt") version "2.0.20" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..6e7dff7
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+android.useAndroidX=true
+android.enableJetifier=true
+android.nonTransitiveRClass=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..df97d72
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..cfc8054
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "StudentFaceRegistry"
+include(":app")