Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63944db6fb |
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.resource
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class FaceThumbnailDecoderInstrumentedTest {
|
||||||
|
private lateinit var testDirectory: File
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
testDirectory = File(context.cacheDir, "face_thumbnail_test").apply {
|
||||||
|
deleteRecursively()
|
||||||
|
mkdirs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
testDirectory.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun largeImagesAreBoundedAndAllExifOrientationsAreHandled() {
|
||||||
|
val orientations = listOf(
|
||||||
|
ExifInterface.ORIENTATION_NORMAL to false,
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL to false,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 to false,
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL to false,
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE to true,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 to true,
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE to true,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 to true,
|
||||||
|
)
|
||||||
|
val decoder = FaceThumbnailDecoder()
|
||||||
|
|
||||||
|
orientations.forEachIndexed { index, (orientation, swapsDimensions) ->
|
||||||
|
val imageFile = File(testDirectory, "orientation_$index.jpg")
|
||||||
|
createLargeJpeg(imageFile)
|
||||||
|
ExifInterface(imageFile).apply {
|
||||||
|
setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
|
||||||
|
saveAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
|
val decoded = decoder.decode(imageFile.absolutePath)
|
||||||
|
|
||||||
|
assertNotNull(decoded)
|
||||||
|
decoded!!
|
||||||
|
assertFalse(decoded.isRecycled)
|
||||||
|
assertTrue(maxOf(decoded.width, decoded.height) <= FaceThumbnailDecoder.MAX_PREVIEW_EDGE_PX)
|
||||||
|
if (swapsDimensions) {
|
||||||
|
assertEquals(360, decoded.width)
|
||||||
|
assertEquals(720, decoded.height)
|
||||||
|
} else {
|
||||||
|
assertEquals(720, decoded.width)
|
||||||
|
assertEquals(360, decoded.height)
|
||||||
|
}
|
||||||
|
decoded.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun captureFilesAreCreatedInOwnedDirectoryAndDeletedIdempotently() {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val fileStore = FaceCaptureFileStore(context)
|
||||||
|
val captureFile = fileStore.createCaptureFile()
|
||||||
|
|
||||||
|
assertTrue(captureFile.exists())
|
||||||
|
assertEquals(FaceCaptureFileStore.CAPTURE_DIRECTORY, captureFile.parentFile?.name)
|
||||||
|
|
||||||
|
fileStore.delete(captureFile)
|
||||||
|
fileStore.delete(captureFile)
|
||||||
|
assertFalse(captureFile.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createLargeJpeg(file: File) {
|
||||||
|
val bitmap = Bitmap.createBitmap(1600, 800, Bitmap.Config.ARGB_8888)
|
||||||
|
Canvas(bitmap).drawColor(Color.MAGENTA)
|
||||||
|
file.outputStream().use { output ->
|
||||||
|
assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output))
|
||||||
|
}
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import com.luck.picture.lib.basic.PictureSelectorSupporterActivity
|
|||||||
import com.luck.picture.lib.basic.PictureSelectorTransparentActivity
|
import com.luck.picture.lib.basic.PictureSelectorTransparentActivity
|
||||||
import com.yzx.kiosk.datastore.AppState
|
import com.yzx.kiosk.datastore.AppState
|
||||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||||
|
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
|
||||||
import com.yzx.kiosk.utils.LogUtils
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
import com.yzx.kiosk.utils.MMKVUtils
|
import com.yzx.kiosk.utils.MMKVUtils
|
||||||
import com.yzx.kiosk.utils.NavigationBarUtil
|
import com.yzx.kiosk.utils.NavigationBarUtil
|
||||||
@@ -42,6 +43,9 @@ class App : Application() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var appStoreDataSource: AppStoreDataSource
|
lateinit var appStoreDataSource: AppStoreDataSource
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var faceCaptureFileStore: FaceCaptureFileStore
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
@@ -59,6 +63,16 @@ class App : Application() {
|
|||||||
LogUtils.init(BuildConfig.DEBUG)
|
LogUtils.init(BuildConfig.DEBUG)
|
||||||
MMKVUtils.init(this)
|
MMKVUtils.init(this)
|
||||||
|
|
||||||
|
runCatching { faceCaptureFileStore.cleanupOrphans() }
|
||||||
|
.onSuccess { deletedFaceCaptures ->
|
||||||
|
if (deletedFaceCaptures > 0) {
|
||||||
|
LogUtils.i("App", "Cleaned $deletedFaceCaptures orphaned face capture file(s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
LogUtils.e("App", "Failed to clean orphaned face captures: ${error.message}")
|
||||||
|
}
|
||||||
|
|
||||||
appState.initialize()
|
appState.initialize()
|
||||||
|
|
||||||
initCoil()
|
initCoil()
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ object NetworkModule {
|
|||||||
private const val CLIENT_DEFAULT = "defaultOkHttpClient"
|
private const val CLIENT_DEFAULT = "defaultOkHttpClient"
|
||||||
const val CLIENT_UPLOAD = "uploadOkHttpClient"
|
const val CLIENT_UPLOAD = "uploadOkHttpClient"
|
||||||
const val CLIENT_DOWNLOAD = "downloadOkHttpClient"
|
const val CLIENT_DOWNLOAD = "downloadOkHttpClient"
|
||||||
|
const val CLIENT_FACE = "faceOkHttpClient"
|
||||||
const val RETROFIT_DEFAULT = "defaultRetrofit"
|
const val RETROFIT_DEFAULT = "defaultRetrofit"
|
||||||
const val RETROFIT_UPLOAD = "uploadRetrofit"
|
const val RETROFIT_UPLOAD = "uploadRetrofit"
|
||||||
|
const val RETROFIT_FACE = "faceRetrofit"
|
||||||
const val TRANSFRTVIEWMODEL = "transferViewModel"
|
const val TRANSFRTVIEWMODEL = "transferViewModel"
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@@ -104,6 +106,17 @@ object NetworkModule {
|
|||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@Named(CLIENT_FACE)
|
||||||
|
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.apply {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
private fun buildRetrofit(
|
private fun buildRetrofit(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
okHttpClient: OkHttpClient,
|
okHttpClient: OkHttpClient,
|
||||||
@@ -131,4 +144,13 @@ object NetworkModule {
|
|||||||
gson: Gson,
|
gson: Gson,
|
||||||
@Named(BASE_URL) baseUrl: String
|
@Named(BASE_URL) baseUrl: String
|
||||||
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
|
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@Named(RETROFIT_FACE)
|
||||||
|
fun provideFaceRetrofit(
|
||||||
|
@Named(CLIENT_FACE) okHttpClient: OkHttpClient,
|
||||||
|
gson: Gson,
|
||||||
|
@Named(BASE_URL) baseUrl: String,
|
||||||
|
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import com.yzx.kiosk.audio.AudioPlayService
|
import com.yzx.kiosk.audio.AudioPlayService
|
||||||
import com.yzx.kiosk.network.service.NetworkService
|
import com.yzx.kiosk.network.service.NetworkService
|
||||||
import com.yzx.kiosk.network.service.UploadService
|
import com.yzx.kiosk.network.service.UploadService
|
||||||
|
import com.yzx.kiosk.network.service.FaceSearchService
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -11,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT
|
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT
|
||||||
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD
|
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD
|
||||||
|
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_FACE
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import javax.inject.Named
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -31,6 +33,12 @@ object ServiceModule {
|
|||||||
@Named(RETROFIT_UPLOAD) retrofit: Retrofit
|
@Named(RETROFIT_UPLOAD) retrofit: Retrofit
|
||||||
): UploadService = retrofit.create(UploadService::class.java)
|
): UploadService = retrofit.create(UploadService::class.java)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideFaceSearchService(
|
||||||
|
@Named(RETROFIT_FACE) retrofit: Retrofit,
|
||||||
|
): FaceSearchService = retrofit.create(FaceSearchService::class.java)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideAudioPlayService(
|
fun provideAudioPlayService(
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import retrofit2.http.Header
|
|||||||
import retrofit2.http.Multipart
|
import retrofit2.http.Multipart
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Part
|
import retrofit2.http.Part
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Url
|
||||||
|
|
||||||
interface FaceSearchService {
|
interface FaceSearchService {
|
||||||
@Multipart
|
@Multipart
|
||||||
@POST("/{sn}/api/search")
|
@POST
|
||||||
suspend fun searchFace(
|
suspend fun searchFace(
|
||||||
@Path("sn") sn: String,
|
@Url url: String,
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@Part image: MultipartBody.Part,
|
@Part image: MultipartBody.Part,
|
||||||
@Part("threshold") threshold: RequestBody?,
|
@Part("threshold") threshold: RequestBody?,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.yzx.kiosk.network.service
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
|
internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String {
|
||||||
|
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
|
||||||
|
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
|
||||||
|
?: throw IllegalArgumentException("Invalid face search base URL")
|
||||||
|
|
||||||
|
return parsedBaseUrl.newBuilder()
|
||||||
|
// Preserve the existing /{sn}/api/search endpoint semantics even if configuration
|
||||||
|
// accidentally contains a path or query string.
|
||||||
|
.encodedPath("/")
|
||||||
|
.query(null)
|
||||||
|
.fragment(null)
|
||||||
|
.addPathSegment(deviceSn)
|
||||||
|
.addPathSegment("api")
|
||||||
|
.addPathSegment("search")
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.resource
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
internal class CaptureGate {
|
||||||
|
private val nextToken = AtomicLong(0L)
|
||||||
|
private val activeToken = AtomicLong(NO_TOKEN)
|
||||||
|
|
||||||
|
fun tryAcquire(): Long? {
|
||||||
|
val token = nextToken.incrementAndGet()
|
||||||
|
return if (activeToken.compareAndSet(NO_TOKEN, token)) token else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release(token: Long): Boolean = activeToken.compareAndSet(token, NO_TOKEN)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NO_TOKEN = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.resource
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Owns the short-lived files created for face-search requests. */
|
||||||
|
@Singleton
|
||||||
|
class FaceCaptureFileStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
fun createCaptureFile(): File {
|
||||||
|
val directory = File(context.cacheDir, CAPTURE_DIRECTORY)
|
||||||
|
check(directory.exists() || directory.mkdirs()) {
|
||||||
|
"Unable to create face capture cache directory"
|
||||||
|
}
|
||||||
|
return File.createTempFile(CAPTURE_PREFIX, CAPTURE_SUFFIX, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(file: File?) {
|
||||||
|
if (file == null || !file.exists()) return
|
||||||
|
if (!file.delete()) {
|
||||||
|
LogUtils.e(TAG, "Failed to delete face capture file: ${file.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A capture cannot remain in use across a process restart, so every owned file is orphaned
|
||||||
|
* when the application starts. Legacy root-cache captures are removed during migration too.
|
||||||
|
*/
|
||||||
|
fun cleanupOrphans(): Int = cleanupOrphans(context.cacheDir)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FaceCaptureFileStore"
|
||||||
|
internal const val CAPTURE_DIRECTORY = "face_recognition"
|
||||||
|
internal const val CAPTURE_PREFIX = "capture_"
|
||||||
|
internal const val CAPTURE_SUFFIX = ".jpg"
|
||||||
|
internal const val LEGACY_PREFIX = "IMG_"
|
||||||
|
|
||||||
|
internal fun cleanupOrphans(cacheDirectory: File): Int {
|
||||||
|
var deletedCount = 0
|
||||||
|
|
||||||
|
val captureDirectory = File(cacheDirectory, CAPTURE_DIRECTORY)
|
||||||
|
captureDirectory.listFiles()?.forEach { file ->
|
||||||
|
if (file.deleteRecursively()) deletedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheDirectory.listFiles()?.forEach { file ->
|
||||||
|
if (
|
||||||
|
file.isFile &&
|
||||||
|
file.name.startsWith(LEGACY_PREFIX) &&
|
||||||
|
file.name.endsWith(CAPTURE_SUFFIX, ignoreCase = true) &&
|
||||||
|
file.delete()
|
||||||
|
) {
|
||||||
|
deletedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.resource
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class FaceThumbnailDecoder @Inject constructor() {
|
||||||
|
fun decode(imagePath: String): Bitmap? {
|
||||||
|
var ownedBitmap: Bitmap? = null
|
||||||
|
return try {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(imagePath, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
inSampleSize = calculateInSampleSize(
|
||||||
|
bounds.outWidth,
|
||||||
|
bounds.outHeight,
|
||||||
|
MAX_PREVIEW_EDGE_PX,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ownedBitmap = BitmapFactory.decodeFile(imagePath, options) ?: return null
|
||||||
|
|
||||||
|
val orientation = ExifInterface(imagePath).getAttributeInt(
|
||||||
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL,
|
||||||
|
)
|
||||||
|
val oriented = transformForExif(ownedBitmap, orientation)
|
||||||
|
if (oriented !== ownedBitmap) {
|
||||||
|
ownedBitmap.recycle()
|
||||||
|
ownedBitmap = oriented
|
||||||
|
}
|
||||||
|
|
||||||
|
val scaled = scaleDown(ownedBitmap, MAX_PREVIEW_EDGE_PX)
|
||||||
|
if (scaled !== ownedBitmap) {
|
||||||
|
ownedBitmap.recycle()
|
||||||
|
}
|
||||||
|
ownedBitmap = null
|
||||||
|
scaled
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ownedBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||||
|
LogUtils.e(TAG, "Failed to decode face preview: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transformForExif(source: Bitmap, orientation: Int): Bitmap {
|
||||||
|
val matrix = Matrix()
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1f, -1f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
matrix.setRotate(90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
matrix.setRotate(270f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(270f)
|
||||||
|
else -> return source
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleDown(source: Bitmap, maxEdgePx: Int): Bitmap {
|
||||||
|
val currentMaxEdge = maxOf(source.width, source.height)
|
||||||
|
if (currentMaxEdge <= maxEdgePx) return source
|
||||||
|
|
||||||
|
val scale = maxEdgePx.toFloat() / currentMaxEdge
|
||||||
|
val targetWidth = (source.width * scale).toInt().coerceAtLeast(1)
|
||||||
|
val targetHeight = (source.height * scale).toInt().coerceAtLeast(1)
|
||||||
|
return source.scale(targetWidth, targetHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FaceThumbnailDecoder"
|
||||||
|
const val MAX_PREVIEW_EDGE_PX = 720
|
||||||
|
|
||||||
|
internal fun calculateInSampleSize(width: Int, height: Int, maxEdgePx: Int): Int {
|
||||||
|
if (width <= 0 || height <= 0 || maxEdgePx <= 0) return 1
|
||||||
|
var sampleSize = 1
|
||||||
|
while (maxOf(width / (sampleSize * 2), height / (sampleSize * 2)) > maxEdgePx) {
|
||||||
|
sampleSize *= 2
|
||||||
|
}
|
||||||
|
return sampleSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@ package com.yzx.kiosk.ui.face.view
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import androidx.camera.core.*
|
import androidx.camera.core.*
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
@@ -39,9 +37,6 @@ import com.yzx.kiosk.component.scaffold.AppScaffold
|
|||||||
import com.yzx.kiosk.ui.common.view.FullScreenMode
|
import com.yzx.kiosk.ui.common.view.FullScreenMode
|
||||||
import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel
|
import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel
|
||||||
import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus
|
import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus
|
||||||
import java.util.concurrent.Executor
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -56,6 +51,7 @@ fun FaceRecognitionScreen(
|
|||||||
val recognitionStatus by viewModel.recognitionStatus.collectAsState()
|
val recognitionStatus by viewModel.recognitionStatus.collectAsState()
|
||||||
val capturedImage by viewModel.capturedImage.collectAsState()
|
val capturedImage by viewModel.capturedImage.collectAsState()
|
||||||
val isRecognizing by viewModel.isRecognizing.collectAsState()
|
val isRecognizing by viewModel.isRecognizing.collectAsState()
|
||||||
|
val isCaptureInProgress by viewModel.isCaptureInProgress.collectAsState()
|
||||||
|
|
||||||
// 相机权限
|
// 相机权限
|
||||||
val permissionsState = rememberMultiplePermissionsState(
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
@@ -103,6 +99,9 @@ fun FaceRecognitionScreen(
|
|||||||
onImageCaptureReady = { imageCapture ->
|
onImageCaptureReady = { imageCapture ->
|
||||||
viewModel.setImageCapture(imageCapture)
|
viewModel.setImageCapture(imageCapture)
|
||||||
},
|
},
|
||||||
|
onImageCaptureReleased = { imageCapture ->
|
||||||
|
viewModel.clearImageCapture(imageCapture)
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -201,7 +200,7 @@ fun FaceRecognitionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
|
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
|
||||||
if (!isRecognizing) {
|
if (!isRecognizing && !isCaptureInProgress) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (recognitionStatus == RecognitionStatus.FAILED) {
|
if (recognitionStatus == RecognitionStatus.FAILED) {
|
||||||
@@ -309,50 +308,62 @@ fun FaceRecognitionScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
fun CameraPreview(
|
fun CameraPreview(
|
||||||
onImageCaptureReady: (ImageCapture) -> Unit,
|
onImageCaptureReady: (ImageCapture) -> Unit,
|
||||||
|
onImageCaptureReleased: (ImageCapture) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val currentOnImageCaptureReady by rememberUpdatedState(onImageCaptureReady)
|
||||||
|
val currentOnImageCaptureReleased by rememberUpdatedState(onImageCaptureReleased)
|
||||||
|
|
||||||
|
val previewView = remember(context) { PreviewView(context) }
|
||||||
|
val preview = remember { Preview.Builder().build() }
|
||||||
|
val imageCapture = remember {
|
||||||
|
ImageCapture.Builder()
|
||||||
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { previewView },
|
||||||
val previewView = PreviewView(ctx)
|
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
|
||||||
|
|
||||||
cameraProviderFuture.addListener({
|
|
||||||
val cameraProvider = cameraProviderFuture.get()
|
|
||||||
|
|
||||||
// 创建预览
|
|
||||||
val preview = Preview.Builder().build().also {
|
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建图像捕获
|
|
||||||
val imageCapture = ImageCapture.Builder()
|
|
||||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// 通知ViewModel ImageCapture已准备好
|
|
||||||
onImageCaptureReady(imageCapture)
|
|
||||||
|
|
||||||
// 绑定到生命周期
|
|
||||||
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
|
|
||||||
try {
|
|
||||||
cameraProvider.unbindAll()
|
|
||||||
cameraProvider.bindToLifecycle(
|
|
||||||
lifecycleOwner,
|
|
||||||
cameraSelector,
|
|
||||||
preview,
|
|
||||||
imageCapture
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}, ContextCompat.getMainExecutor(ctx))
|
|
||||||
|
|
||||||
previewView
|
|
||||||
},
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DisposableEffect(context, lifecycleOwner, preview, imageCapture) {
|
||||||
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||||
|
val executor = ContextCompat.getMainExecutor(context)
|
||||||
|
var cameraProvider: ProcessCameraProvider? = null
|
||||||
|
var disposed = false
|
||||||
|
|
||||||
|
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
|
|
||||||
|
cameraProviderFuture.addListener({
|
||||||
|
try {
|
||||||
|
val resolvedProvider = cameraProviderFuture.get()
|
||||||
|
if (disposed) {
|
||||||
|
resolvedProvider.unbind(preview, imageCapture)
|
||||||
|
return@addListener
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraProvider = resolvedProvider
|
||||||
|
resolvedProvider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
CameraSelector.DEFAULT_FRONT_CAMERA,
|
||||||
|
preview,
|
||||||
|
imageCapture,
|
||||||
|
)
|
||||||
|
currentOnImageCaptureReady(imageCapture)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}, executor)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
disposed = true
|
||||||
|
currentOnImageCaptureReleased(imageCapture)
|
||||||
|
preview.setSurfaceProvider(null)
|
||||||
|
cameraProvider?.unbind(preview, imageCapture)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package com.yzx.kiosk.ui.face.viewmodel
|
package com.yzx.kiosk.ui.face.viewmodel
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Matrix
|
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
import androidx.exifinterface.media.ExifInterface
|
|
||||||
import androidx.camera.core.ImageCaptureException
|
import androidx.camera.core.ImageCaptureException
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -16,12 +13,15 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
||||||
import com.yzx.kiosk.network.service.FaceSearchService
|
import com.yzx.kiosk.network.service.FaceSearchService
|
||||||
|
import com.yzx.kiosk.network.service.buildFaceSearchUrl
|
||||||
import com.yzx.kiosk.App
|
import com.yzx.kiosk.App
|
||||||
import com.yzx.kiosk.BuildConfig
|
import com.yzx.kiosk.BuildConfig
|
||||||
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
|
||||||
import com.yzx.kiosk.navigation.AppNavigator
|
import com.yzx.kiosk.navigation.AppNavigator
|
||||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||||
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
|
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
|
||||||
|
import com.yzx.kiosk.ui.face.resource.CaptureGate
|
||||||
|
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
|
||||||
|
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
|
||||||
import com.yzx.kiosk.utils.LogUtils
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
import com.yzx.kiosk.utils.ToastUtils
|
import com.yzx.kiosk.utils.ToastUtils
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
@@ -36,12 +36,8 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -59,7 +55,10 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
navigator: AppNavigator,
|
navigator: AppNavigator,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
private val appStoreDataSource: AppStoreDataSource,
|
private val appStoreDataSource: AppStoreDataSource,
|
||||||
private val localAudioPlayService: LocalAudioPlayService
|
private val localAudioPlayService: LocalAudioPlayService,
|
||||||
|
private val faceSearchService: FaceSearchService,
|
||||||
|
private val faceCaptureFileStore: FaceCaptureFileStore,
|
||||||
|
private val faceThumbnailDecoder: FaceThumbnailDecoder,
|
||||||
) : BaseViewModel(
|
) : BaseViewModel(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
appState = appState
|
appState = appState
|
||||||
@@ -91,9 +90,16 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
private val _isRecognizing = MutableStateFlow(false)
|
private val _isRecognizing = MutableStateFlow(false)
|
||||||
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
|
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
|
||||||
|
|
||||||
|
private val _isCaptureInProgress = MutableStateFlow(false)
|
||||||
|
val isCaptureInProgress: StateFlow<Boolean> = _isCaptureInProgress.asStateFlow()
|
||||||
|
|
||||||
// 倒计时Job
|
// 倒计时Job
|
||||||
private var countdownJob: kotlinx.coroutines.Job? = null
|
private var countdownJob: kotlinx.coroutines.Job? = null
|
||||||
private var autoCaptureJob: kotlinx.coroutines.Job? = null
|
private var autoCaptureJob: kotlinx.coroutines.Job? = null
|
||||||
|
private val captureGate = CaptureGate()
|
||||||
|
private var activeCaptureToken: Long? = null
|
||||||
|
private var activeCaptureFile: File? = null
|
||||||
|
private var isCleared = false
|
||||||
|
|
||||||
// ImageCapture实例
|
// ImageCapture实例
|
||||||
private var imageCapture: ImageCapture? = null
|
private var imageCapture: ImageCapture? = null
|
||||||
@@ -109,6 +115,15 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
this.imageCapture = imageCapture
|
this.imageCapture = imageCapture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearImageCapture(imageCapture: ImageCapture) {
|
||||||
|
if (this.imageCapture === imageCapture) {
|
||||||
|
this.imageCapture = null
|
||||||
|
activeCaptureToken?.let { captureToken ->
|
||||||
|
finishCaptureFile(activeCaptureFile, captureToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开始倒计时
|
* 开始倒计时
|
||||||
*/
|
*/
|
||||||
@@ -120,7 +135,7 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
_countdown.value = _countdown.value - 1
|
_countdown.value = _countdown.value - 1
|
||||||
}
|
}
|
||||||
// 倒计时结束,自动返回首页
|
// 倒计时结束,自动返回首页
|
||||||
toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME)
|
closeAllExcept(AppRoutes.HOME)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +169,12 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
* 手动拍照
|
* 手动拍照
|
||||||
*/
|
*/
|
||||||
fun takePhoto() {
|
fun takePhoto() {
|
||||||
|
if (_isRecognizing.value) return
|
||||||
|
val captureToken = captureGate.tryAcquire() ?: return
|
||||||
|
|
||||||
|
activeCaptureToken = captureToken
|
||||||
|
_isCaptureInProgress.value = true
|
||||||
|
|
||||||
// 如果正在自动拍照倒计时,先取消
|
// 如果正在自动拍照倒计时,先取消
|
||||||
if (_captureCountdown.value > 0) {
|
if (_captureCountdown.value > 0) {
|
||||||
cancelAutoCapture()
|
cancelAutoCapture()
|
||||||
@@ -167,80 +188,124 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val capture = imageCapture ?: run {
|
val capture = imageCapture ?: run {
|
||||||
|
releaseCaptureGate(captureToken)
|
||||||
ToastUtils.show("相机未准备好")
|
ToastUtils.show("相机未准备好")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// 创建输出文件
|
// 创建输出文件
|
||||||
val photoFile = withContext(Dispatchers.IO) {
|
var photoFile: File? = null
|
||||||
File.createTempFile(
|
try {
|
||||||
"IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}",
|
photoFile = withContext(Dispatchers.IO) {
|
||||||
".jpg",
|
faceCaptureFileStore.createCaptureFile()
|
||||||
App.instance.cacheDir
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建输出选项
|
|
||||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
|
||||||
|
|
||||||
// 拍照
|
|
||||||
capture.takePicture(
|
|
||||||
outputOptions,
|
|
||||||
ContextCompat.getMainExecutor(App.instance),
|
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
|
||||||
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
|
||||||
// 加载图片为Bitmap,并处理EXIF方向
|
|
||||||
val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath)
|
|
||||||
_capturedImage.value = bitmap
|
|
||||||
// 直接调用搜索接口
|
|
||||||
searchFaceDirectly(photoFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(exception: ImageCaptureException) {
|
|
||||||
LogUtils.e(TAG, "拍照失败: ${exception.message}")
|
|
||||||
ToastUtils.show("拍照失败")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
if (
|
||||||
|
isCleared ||
|
||||||
|
imageCapture !== capture ||
|
||||||
|
activeCaptureToken != captureToken
|
||||||
|
) {
|
||||||
|
faceCaptureFileStore.delete(photoFile)
|
||||||
|
releaseCaptureGate(captureToken)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
activeCaptureFile = photoFile
|
||||||
|
|
||||||
|
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
||||||
|
capture.takePicture(
|
||||||
|
outputOptions,
|
||||||
|
ContextCompat.getMainExecutor(App.instance),
|
||||||
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
|
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
||||||
|
if (
|
||||||
|
isCleared ||
|
||||||
|
imageCapture !== capture ||
|
||||||
|
activeCaptureToken != captureToken
|
||||||
|
) {
|
||||||
|
finishCaptureFile(photoFile, captureToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recognizeSavedPhoto(photoFile, captureToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
LogUtils.e(TAG, "拍照失败: ${exception.message}")
|
||||||
|
finishCaptureFile(photoFile, captureToken)
|
||||||
|
if (!isCleared && imageCapture === capture) {
|
||||||
|
ToastUtils.show("拍照失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
finishCaptureFile(photoFile, captureToken)
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtils.e(TAG, "创建人脸拍照文件失败: ${e.message}")
|
||||||
|
finishCaptureFile(photoFile, captureToken)
|
||||||
|
ToastUtils.show("拍照失败")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun recognizeSavedPhoto(photoFile: File, captureToken: Long) {
|
||||||
* 直接调用人脸搜索接口
|
|
||||||
*/
|
|
||||||
private fun searchFaceDirectly(photoFile: File) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isRecognizing.value = true
|
_isRecognizing.value = true
|
||||||
_recognitionStatus.value = RecognitionStatus.RECOGNIZING
|
_recognitionStatus.value = RecognitionStatus.RECOGNIZING
|
||||||
// 播放识别中音频
|
releaseCaptureGate(captureToken)
|
||||||
localAudioPlayService.playByRoute("face_recognition_recognizing")
|
|
||||||
// 播放识别中音频
|
|
||||||
localAudioPlayService.playByRoute("face_recognition_recognizing")
|
localAudioPlayService.playByRoute("face_recognition_recognizing")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 直接调用人脸识别接口
|
_capturedImage.value = withContext(Dispatchers.IO) {
|
||||||
|
faceThumbnailDecoder.decode(photoFile.absolutePath)
|
||||||
|
}
|
||||||
searchFace(photoFile)
|
searchFace(photoFile)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
// 页面跳转或 ViewModel 销毁时的正常协程取消,不向用户报错
|
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtils.e(TAG, "处理失败: ${e.message}")
|
LogUtils.e(TAG, "处理失败: ${e.message}")
|
||||||
ToastUtils.show("处理失败: ${e.message}")
|
ToastUtils.show("处理失败: ${e.message}")
|
||||||
_isRecognizing.value = false
|
_isRecognizing.value = false
|
||||||
_recognitionStatus.value = RecognitionStatus.FAILED
|
_recognitionStatus.value = RecognitionStatus.FAILED
|
||||||
|
} finally {
|
||||||
|
finishCaptureFile(photoFile, captureToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun releaseCaptureGate(captureToken: Long) {
|
||||||
|
if (captureGate.release(captureToken)) {
|
||||||
|
if (activeCaptureToken == captureToken) {
|
||||||
|
activeCaptureToken = null
|
||||||
|
}
|
||||||
|
_isCaptureInProgress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishCaptureFile(photoFile: File?, captureToken: Long) {
|
||||||
|
faceCaptureFileStore.delete(photoFile)
|
||||||
|
if (activeCaptureFile === photoFile) {
|
||||||
|
activeCaptureFile = null
|
||||||
|
}
|
||||||
|
releaseCaptureGate(captureToken)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 搜索人脸
|
* 搜索人脸
|
||||||
*/
|
*/
|
||||||
private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) {
|
private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl()
|
val faceSearchBaseUrl = if (appStoreDataSource.getUseLan()) {
|
||||||
val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken()
|
appStoreDataSource.getBindBoxLanUrl()
|
||||||
|
} else {
|
||||||
|
appStoreDataSource.getBindBoxUrl()
|
||||||
|
}
|
||||||
|
val faceSearchToken = appStoreDataSource.getBindBoxApiToken()
|
||||||
|
val requestUrl = buildFaceSearchUrl(
|
||||||
|
baseUrl = faceSearchBaseUrl,
|
||||||
|
deviceSn = appStoreDataSource.getBindBoxSn(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
// 创建MultipartBody
|
// 创建MultipartBody
|
||||||
@@ -254,13 +319,10 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
// index_date 参数:当前日期,格式 YYYYMMDD
|
// index_date 参数:当前日期,格式 YYYYMMDD
|
||||||
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
|
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
|
||||||
val indexDate = dateFormat.format(Date())
|
val indexDate = dateFormat.format(Date())
|
||||||
val indexDateBody = indexDate.toRequestBody("text/plain".toMediaType())
|
|
||||||
|
|
||||||
// Authorization header
|
// Authorization header
|
||||||
val authorization = "Bearer $FACE_SEARCH_TOKEN"
|
val authorization = "Bearer $faceSearchToken"
|
||||||
|
|
||||||
// 打印请求信息
|
// 打印请求信息
|
||||||
val requestUrl = "$FACE_SEARCH_BASE_URL/api/search"
|
|
||||||
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
|
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
|
||||||
LogUtils.d(TAG, "URL: $requestUrl")
|
LogUtils.d(TAG, "URL: $requestUrl")
|
||||||
LogUtils.d(TAG, "Headers:")
|
LogUtils.d(TAG, "Headers:")
|
||||||
@@ -271,27 +333,9 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
LogUtils.d(TAG, "index_date: $indexDate")
|
LogUtils.d(TAG, "index_date: $indexDate")
|
||||||
LogUtils.d(TAG, "==========================================")
|
LogUtils.d(TAG, "==========================================")
|
||||||
|
|
||||||
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
|
|
||||||
val client = OkHttpClient.Builder()
|
|
||||||
.apply {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// 创建Retrofit实例
|
|
||||||
val retrofit = Retrofit.Builder()
|
|
||||||
.baseUrl("$FACE_SEARCH_BASE_URL/")
|
|
||||||
.client(client)
|
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val service = retrofit.create(FaceSearchService::class.java)
|
|
||||||
|
|
||||||
// 调用接口
|
// 调用接口
|
||||||
val response = service.searchFace(
|
val response = faceSearchService.searchFace(
|
||||||
sn = appStoreDataSource.getBindBoxSn(),
|
url = requestUrl,
|
||||||
authorization = authorization,
|
authorization = authorization,
|
||||||
image = imagePart,
|
image = imagePart,
|
||||||
threshold = thresholdBody,
|
threshold = thresholdBody,
|
||||||
@@ -369,84 +413,6 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载Bitmap并处理EXIF方向信息
|
|
||||||
* 解决不同设备拍照后图片方向不正确的问题
|
|
||||||
*/
|
|
||||||
private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? {
|
|
||||||
return try {
|
|
||||||
// 先读取EXIF方向信息
|
|
||||||
val exif = ExifInterface(imagePath)
|
|
||||||
val orientation = exif.getAttributeInt(
|
|
||||||
ExifInterface.TAG_ORIENTATION,
|
|
||||||
ExifInterface.ORIENTATION_NORMAL
|
|
||||||
)
|
|
||||||
|
|
||||||
// 加载原始Bitmap
|
|
||||||
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null
|
|
||||||
|
|
||||||
// 根据EXIF方向旋转Bitmap
|
|
||||||
val rotationDegrees = when (orientation) {
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
|
||||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
|
||||||
// 水平翻转
|
|
||||||
val matrix = Matrix()
|
|
||||||
matrix.setScale(-1f, 1f)
|
|
||||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
|
||||||
// 垂直翻转
|
|
||||||
val matrix = Matrix()
|
|
||||||
matrix.setScale(1f, -1f)
|
|
||||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
|
||||||
// 转置(旋转90度+水平翻转)
|
|
||||||
val matrix = Matrix()
|
|
||||||
matrix.setRotate(90f)
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
|
||||||
// 横向(旋转270度+水平翻转)
|
|
||||||
val matrix = Matrix()
|
|
||||||
matrix.setRotate(270f)
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
||||||
}
|
|
||||||
else -> 0f // 不需要旋转
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果需要旋转
|
|
||||||
if (rotationDegrees != 0f) {
|
|
||||||
val matrix = Matrix()
|
|
||||||
matrix.postRotate(rotationDegrees)
|
|
||||||
val rotatedBitmap = Bitmap.createBitmap(
|
|
||||||
bitmap,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
bitmap.width,
|
|
||||||
bitmap.height,
|
|
||||||
matrix,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
// 回收原始bitmap
|
|
||||||
if (rotatedBitmap != bitmap) {
|
|
||||||
bitmap.recycle()
|
|
||||||
}
|
|
||||||
rotatedBitmap
|
|
||||||
} else {
|
|
||||||
bitmap
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtils.e(TAG, "加载图片并处理EXIF方向失败: ${e.message}")
|
|
||||||
// 如果处理失败,返回原始Bitmap
|
|
||||||
BitmapFactory.decodeFile(imagePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun privatePolicy() {
|
fun privatePolicy() {
|
||||||
val args = mapOf(
|
val args = mapOf(
|
||||||
AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html",
|
AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html",
|
||||||
@@ -476,9 +442,15 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
isCleared = true
|
||||||
countdownJob?.cancel()
|
countdownJob?.cancel()
|
||||||
autoCaptureJob?.cancel()
|
autoCaptureJob?.cancel()
|
||||||
|
imageCapture = null
|
||||||
|
_capturedImage.value = null
|
||||||
|
faceCaptureFileStore.delete(activeCaptureFile)
|
||||||
|
activeCaptureFile = null
|
||||||
|
activeCaptureToken?.let(::releaseCaptureGate)
|
||||||
|
super.onCleared()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.yzx.kiosk.network.service
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
|
||||||
|
class FaceSearchUrlTest {
|
||||||
|
@Test
|
||||||
|
fun `url uses configured host and safely encoded sn`() {
|
||||||
|
assertEquals(
|
||||||
|
"http://192.168.1.10/BOX%2F01/api/search",
|
||||||
|
buildFaceSearchUrl("http://192.168.1.10/old/path?unused=true", "BOX/01"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid configuration is rejected`() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
buildFaceSearchUrl("not-a-url", "BOX-1")
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
buildFaceSearchUrl("http://192.168.1.10", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retrofit accepts multipart post with dynamic url`() {
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl("https://example.test/")
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.validateEagerly(true)
|
||||||
|
.build()
|
||||||
|
.create(FaceSearchService::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.resource
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
|
class FaceCaptureFileStoreTest {
|
||||||
|
private lateinit var cacheDirectory: File
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
cacheDirectory = Files.createTempDirectory("face-capture-test").toFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
cacheDirectory.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cleanup removes owned and legacy captures only`() {
|
||||||
|
val captureDirectory = File(
|
||||||
|
cacheDirectory,
|
||||||
|
FaceCaptureFileStore.CAPTURE_DIRECTORY,
|
||||||
|
).apply { mkdirs() }
|
||||||
|
val ownedCapture = File(captureDirectory, "capture_1.jpg").apply { writeText("face") }
|
||||||
|
val legacyCapture = File(cacheDirectory, "IMG_20260825.jpg").apply { writeText("face") }
|
||||||
|
val unrelatedJpeg = File(cacheDirectory, "holiday.jpg").apply { writeText("keep") }
|
||||||
|
val similarLegacyFile = File(cacheDirectory, "IMG_notes.txt").apply { writeText("keep") }
|
||||||
|
|
||||||
|
val deleted = FaceCaptureFileStore.cleanupOrphans(cacheDirectory)
|
||||||
|
|
||||||
|
assertEquals(2, deleted)
|
||||||
|
assertFalse(ownedCapture.exists())
|
||||||
|
assertFalse(legacyCapture.exists())
|
||||||
|
assertTrue(unrelatedJpeg.exists())
|
||||||
|
assertTrue(similarLegacyFile.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cleanup is idempotent`() {
|
||||||
|
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
|
||||||
|
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.resource
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FaceResourceHelpersTest {
|
||||||
|
@Test
|
||||||
|
fun `sample size bounds large source before final scale`() {
|
||||||
|
assertEquals(4, FaceThumbnailDecoder.calculateInSampleSize(4000, 3000, 720))
|
||||||
|
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(1280, 720, 720))
|
||||||
|
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(0, 0, 720))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `capture gate permits only one active capture`() {
|
||||||
|
val gate = CaptureGate()
|
||||||
|
|
||||||
|
val firstToken = gate.tryAcquire()
|
||||||
|
assertTrue(firstToken != null)
|
||||||
|
assertEquals(null, gate.tryAcquire())
|
||||||
|
|
||||||
|
assertTrue(gate.release(firstToken!!))
|
||||||
|
val secondToken = gate.tryAcquire()
|
||||||
|
assertTrue(secondToken != null)
|
||||||
|
|
||||||
|
assertFalse(gate.release(firstToken))
|
||||||
|
assertEquals(null, gate.tryAcquire())
|
||||||
|
assertTrue(gate.release(secondToken!!))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user