diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaf64c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +.gradle/ +.idea/ +local.properties +build/ +*/build/ +*.iml +app/src/main/assets/facenet.tflite + +# Android / Gradle generated outputs +.cxx/ +.externalNativeBuild/ +captures/ +*.apk +*.aab +*.ap_ +*.dex + +# Java / Kotlin +*.class +*.jar +*.war +*.ear + +# OS files +.DS_Store +Thumbs.db + +# Local signing files +*.jks +*.keystore +keystore.properties diff --git a/README.md b/README.md index cbc26da..baba205 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ http://192.168.1.23:8080 - 上传一个 `.zip` 压缩包,手机端自动解压后入库 文件夹选择在 Chrome / Edge 这类支持目录选择的浏览器里效果最好。 +大批量导入时优先选择文件夹上传;网页会逐张发送图片,手机端内存压力更小。 ## 头像命名规则 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..699e52e --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..feac2fb --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/README.md b/app/src/main/assets/README.md new file mode 100644 index 0000000..a2f0c2e --- /dev/null +++ b/app/src/main/assets/README.md @@ -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. diff --git a/app/src/main/assets/facenet_512.tflite b/app/src/main/assets/facenet_512.tflite new file mode 100644 index 0000000..8e48044 Binary files /dev/null and b/app/src/main/assets/facenet_512.tflite differ diff --git a/app/src/main/assets/facenet_512_int_quantized.tflite b/app/src/main/assets/facenet_512_int_quantized.tflite new file mode 100644 index 0000000..d933171 Binary files /dev/null and b/app/src/main/assets/facenet_512_int_quantized.tflite differ diff --git a/app/src/main/assets/facenet_int_quantized.tflite b/app/src/main/assets/facenet_int_quantized.tflite new file mode 100644 index 0000000..efa25d5 Binary files /dev/null and b/app/src/main/assets/facenet_int_quantized.tflite differ diff --git a/app/src/main/assets/mask_detector.tflite b/app/src/main/assets/mask_detector.tflite new file mode 100644 index 0000000..b5687cc Binary files /dev/null and b/app/src/main/assets/mask_detector.tflite differ diff --git a/app/src/main/java/com/example/studentfaceregistry/ImageUtils.kt b/app/src/main/java/com/example/studentfaceregistry/ImageUtils.kt new file mode 100644 index 0000000..9c98dca --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/ImageUtils.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt new file mode 100644 index 0000000..7a929a3 --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/MainActivity.kt @@ -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 = 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() + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/data/Student.kt b/app/src/main/java/com/example/studentfaceregistry/data/Student.kt new file mode 100644 index 0000000..72e8865 --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/data/Student.kt @@ -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() +) diff --git a/app/src/main/java/com/example/studentfaceregistry/data/StudentRepository.kt b/app/src/main/java/com/example/studentfaceregistry/data/StudentRepository.kt new file mode 100644 index 0000000..991d578 --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/data/StudentRepository.kt @@ -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> = _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 { + 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) { + 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() } + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt new file mode 100644 index 0000000..8f795db --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceEmbedder.kt @@ -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 } + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt new file mode 100644 index 0000000..8a62fb6 --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceMatcher.kt @@ -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): 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) + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt b/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt new file mode 100644 index 0000000..bcf464e --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/face/FaceProcessor.kt @@ -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 { + 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) + } +} diff --git a/app/src/main/java/com/example/studentfaceregistry/face/RecognitionResult.kt b/app/src/main/java/com/example/studentfaceregistry/face/RecognitionResult.kt new file mode 100644 index 0000000..ddea5d6 --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/face/RecognitionResult.kt @@ -0,0 +1,8 @@ +package com.example.studentfaceregistry.face + +import com.example.studentfaceregistry.data.Student + +data class RecognitionResult( + val student: Student?, + val distance: Float +) diff --git a/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt b/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt new file mode 100644 index 0000000..40be80c --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/ui/OverlayView.kt @@ -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 = emptyList() + private var imageWidth = 1 + private var imageHeight = 1 + + fun update(items: List, 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) +} diff --git a/app/src/main/java/com/example/studentfaceregistry/upload/UploadServer.kt b/app/src/main/java/com/example/studentfaceregistry/upload/UploadServer.kt new file mode 100644 index 0000000..381aeaf --- /dev/null +++ b/app/src/main/java/com/example/studentfaceregistry/upload/UploadServer.kt @@ -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() + + 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")