diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..823be3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +.idea/ + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore +keystore.properties + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..956c004 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +/release \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1df7e7d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,134 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile +import java.util.Locale +import java.util.Properties + +val packageName = "ru.solrudev.facerecognizer" + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.ksp) + alias(dagger.plugins.hilt.plugin) + alias(androidx.plugins.navigation.safeargs) + alias(libs.plugins.android.cachefix) +} + +kotlin { + jvmToolchain(17) +} + +base { + archivesName.set(packageName) +} + +android { + compileSdk = 34 + buildToolsVersion = "34.0.0" + namespace = packageName + + defaultConfig { + applicationId = packageName + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.0.1" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + + val releaseSigningConfig by signingConfigs.registering { + initWith(signingConfigs["debug"]) + val keystorePropertiesFile = rootProject.file("keystore.properties") + if (keystorePropertiesFile.exists()) { + val keystoreProperties = Properties().apply { + keystorePropertiesFile.inputStream().use(::load) + } + keyAlias = keystoreProperties["keyAlias"] as? String + keyPassword = keystoreProperties["keyPassword"] as? String + storeFile = keystoreProperties["storeFile"]?.let(::file) + storePassword = keystoreProperties["storePassword"] as? String + enableV3Signing = true + enableV4Signing = true + } + } + + buildTypes { + named("debug") { + multiDexEnabled = true + } + named("release") { + isMinifyEnabled = true + isShrinkResources = true + signingConfig = releaseSigningConfig.get() + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + splits { + abi { + isEnable = true + reset() + //noinspection ChromeOsAbiSupport + include("arm64-v8a") + isUniversalApk = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + mlModelBinding = true + viewBinding = true + } +} + +tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.add("-Xjvm-default=all") + } +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +androidComponents { + onVariants(selector().all()) { variant -> + afterEvaluate { + val variantName = variant.name.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } + tasks.getByName("ksp${variantName}Kotlin") { + setSource(tasks.getByName("generate${variantName}MlModelClass").outputs) + } + } + } +} + +dependencies { + ksp(dagger.bundles.hilt.compilers) + + implementation(dagger.hilt.android) + implementation(androidx.activity.ktx) + implementation(androidx.fragment.ktx) + implementation(androidx.bundles.navigation) + implementation(androidx.bundles.camera) + implementation(libs.materialcomponents) + implementation(libs.kotlinx.coroutines) + implementation(libs.viewbindingpropertydelegate) + implementation(libs.insetter) + implementation(libs.mlkit.facedetection) + implementation(libs.bundles.tensorflow) + + debugImplementation(androidx.multidex) + + testImplementation(libs.kotlin.test) + testImplementation(libs.bundles.junit) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(androidx.test.ext.junit) + androidTestImplementation(androidx.espresso.core) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ac3de17 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,6 @@ +-keepattributes LineNumberTable,SourceFile +-renamesourcefileattribute Source + +#### Miscellaneous +-keep,allowobfuscation,allowshrinking class org.tensorflow.lite.gpu.GpuDelegateFactory$Options$GpuBackend +-keep,allowobfuscation,allowshrinking class org.tensorflow.lite.gpu.GpuDelegateFactory$Options \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..52c02cb --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceDetector.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceDetector.kt new file mode 100644 index 0000000..c586ade --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceDetector.kt @@ -0,0 +1,56 @@ +@file:OptIn(TransformExperimental::class) + +package ru.solrudev.facerecognizer + +import android.graphics.Bitmap +import android.graphics.RectF +import androidx.annotation.OptIn +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageProxy +import androidx.camera.view.TransformExperimental +import androidx.camera.view.transform.CoordinateTransform +import com.google.mlkit.vision.common.InputImage +import ru.solrudev.facerecognizer.util.await +import ru.solrudev.facerecognizer.util.crop +import ru.solrudev.facerecognizer.util.rotateCounterclockwise +import javax.inject.Inject +import com.google.mlkit.vision.face.FaceDetector as MlKitVisionFaceDetector + +interface FaceDetector : AutoCloseable { + suspend fun detect( + image: ImageProxy, + rotationDegrees: Int, + previewTransform: CoordinateTransform + ): List +} + +class MlKitFaceDetector @Inject constructor(private val faceDetector: MlKitVisionFaceDetector) : FaceDetector { + + @OptIn(ExperimentalGetImage::class) + override suspend fun detect( + image: ImageProxy, + rotationDegrees: Int, + previewTransform: CoordinateTransform + ): List { + val inputImage = InputImage.fromMediaImage(image.image!!, rotationDegrees) + return faceDetector + .process(inputImage) + .await() + .mapNotNull { face -> + val rotatedFaceBoundingBox = face.boundingBox + val faceBoundingBox = rotatedFaceBoundingBox.rotateCounterclockwise( + image.width, image.height, rotationDegrees + ) + val faceBitmap = image.toBitmap().crop(faceBoundingBox, rotationDegrees) ?: return@mapNotNull null + previewTransform.mapRect(faceBoundingBox) + val faceId = face.trackingId ?: error("Face tracking is disabled") + DetectedFace(faceId, faceBitmap, faceBoundingBox) + } + } + + override fun close() { + faceDetector.close() + } +} + +data class DetectedFace(val id: Int, val bitmap: Bitmap, val boundingBox: RectF) \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceEmbeddingsRepository.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceEmbeddingsRepository.kt new file mode 100644 index 0000000..c62b57f --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceEmbeddingsRepository.kt @@ -0,0 +1,40 @@ +package ru.solrudev.facerecognizer + +import javax.inject.Inject +import javax.inject.Singleton + +interface FaceEmbeddingsRepository { + suspend fun addFaceEmbeddings(faceEmbeddings: FaceEmbeddings) + suspend fun getAllFaceEmbeddings(): List +} + +@Singleton +class InMemoryFaceEmbeddingsRepository @Inject constructor() : FaceEmbeddingsRepository { + + private val faceEmbeddingsList = mutableListOf() + + override suspend fun addFaceEmbeddings(faceEmbeddings: FaceEmbeddings) { + faceEmbeddingsList += faceEmbeddings + } + + override suspend fun getAllFaceEmbeddings(): List { + return faceEmbeddingsList + } +} + +data class FaceEmbeddings(val name: String, val embeddings: FloatArray) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as FaceEmbeddings + if (name != other.name) return false + return embeddings.contentEquals(other.embeddings) + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + embeddings.contentHashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognition.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognition.kt new file mode 100644 index 0000000..4a5250b --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognition.kt @@ -0,0 +1,6 @@ +package ru.solrudev.facerecognizer + +import android.graphics.Bitmap +import android.graphics.Rect + +data class FaceRecognition(val face: RecognizedFace, val boundingBox: Rect, val bitmap: Bitmap) \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognitionCoroutineScope.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognitionCoroutineScope.kt new file mode 100644 index 0000000..5da0b53 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognitionCoroutineScope.kt @@ -0,0 +1,12 @@ +package ru.solrudev.facerecognizer + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import ru.solrudev.facerecognizer.di.DefaultDispatcher +import javax.inject.Inject + +interface FaceRecognitionCoroutineScope : CoroutineScope + +class FaceRecognitionCoroutineScopeImpl @Inject constructor( + @DefaultDispatcher dispatcher: CoroutineDispatcher +) : FaceRecognitionCoroutineScope, CoroutineScope by CoroutineScope(dispatcher) \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognitionProcessor.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognitionProcessor.kt new file mode 100644 index 0000000..7e56a30 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognitionProcessor.kt @@ -0,0 +1,81 @@ +@file:OptIn(TransformExperimental::class) + +package ru.solrudev.facerecognizer + +import androidx.annotation.OptIn +import androidx.camera.core.ImageProxy +import androidx.camera.view.TransformExperimental +import androidx.camera.view.transform.CoordinateTransform +import androidx.core.graphics.toRect +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +interface FaceRecognitionProcessor : AutoCloseable { + val results: Flow> + fun processFrame(image: ImageProxy, rotationDegrees: Int, previewTransform: CoordinateTransform) +} + +class FaceRecognitionProcessorImpl @Inject constructor( + private val faceDetector: FaceDetector, + private val faceRecognizer: FaceRecognizer, + private val coroutineScope: FaceRecognitionCoroutineScope +) : FaceRecognitionProcessor { + + private val detectedFaces = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val recognizedFaces = detectedFaces + .transform { detectedFaces -> + emit(recognizeFaces(detectedFaces)) + delay(500.milliseconds) + } + .onStart { emit(emptyList()) } + + override val results = combine(detectedFaces, recognizedFaces) { detectedFaces, recognizedFaces -> + detectedFaces.map { detectedFace -> + val recognizedFace = recognizedFaces + .firstOrNull { it.id == detectedFace.id } + ?.recognizedFace ?: RecognizedFace() + FaceRecognition(recognizedFace, detectedFace.boundingBox.toRect(), detectedFace.bitmap) + } + } + + override fun processFrame(image: ImageProxy, rotationDegrees: Int, previewTransform: CoordinateTransform) { + coroutineScope.launch { + val faces = image.use { imageProxy -> + faceDetector.detect(imageProxy, rotationDegrees, previewTransform) + } + detectedFaces.emit(faces) + } + } + + override fun close() { + faceDetector.close() + faceRecognizer.close() + coroutineScope.cancel() + } + + private suspend inline fun recognizeFaces(detectedFaces: List) = coroutineScope { + detectedFaces + .map { face -> + async { RecognizedFaceWithId(face.id, faceRecognizer.recognize(face.bitmap)) } + } + .awaitAll() + } +} + +private data class RecognizedFaceWithId(val id: Int, val recognizedFace: RecognizedFace) \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognizer.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognizer.kt new file mode 100644 index 0000000..4f51279 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/FaceRecognizer.kt @@ -0,0 +1,104 @@ +package ru.solrudev.facerecognizer + +import android.graphics.Bitmap +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runInterruptible +import org.tensorflow.lite.support.common.ops.NormalizeOp +import org.tensorflow.lite.support.image.ImageProcessor +import org.tensorflow.lite.support.image.TensorImage +import org.tensorflow.lite.support.image.ops.ResizeOp +import ru.solrudev.facerecognizer.di.DefaultDispatcher +import ru.solrudev.facerecognizer.ml.MobileFaceNet +import javax.inject.Inject +import kotlin.coroutines.coroutineContext +import kotlin.math.sqrt + +private const val FACE_DISTANCE_THRESHOLD = 1f + +interface FaceRecognizer : AutoCloseable { + suspend fun recognize(bitmap: Bitmap): RecognizedFace +} + +class TfLiteFaceRecognizer @Inject constructor( + private val faceRecognizer: MobileFaceNet, + private val faceEmbeddingsRepository: FaceEmbeddingsRepository, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher +) : FaceRecognizer { + + private val facePreprocessor = ImageProcessor.Builder() + .add(ResizeOp(112, 112, ResizeOp.ResizeMethod.BILINEAR)) + .add(NormalizeOp(128f, 128f)) + .build() + + override suspend fun recognize(bitmap: Bitmap): RecognizedFace { + val faceTensorImage = TensorImage.fromBitmap(bitmap) + val embeddings = runInterruptible(defaultDispatcher) { + val tensorImage = facePreprocessor.process(faceTensorImage) + val outputs = faceRecognizer.process(tensorImage.tensorBuffer) + outputs.outputFeature0AsTensorBuffer.floatArray + } + return findNearestRegisteredFace(embeddings) + } + + override fun close() { + faceRecognizer.close() + } + + private suspend inline fun findNearestRegisteredFace(embeddings: FloatArray) = coroutineScope { + val knownEmbeddings = faceEmbeddingsRepository.getAllFaceEmbeddings() + if (knownEmbeddings.isEmpty()) { + return@coroutineScope RecognizedFace(embeddings = embeddings) + } + val nearestFace = knownEmbeddings + .map { faceEmbeddings -> + async(defaultDispatcher) { + val distance = euclideanDistance(embeddings, faceEmbeddings.embeddings) + RecognizedFace(faceEmbeddings.name, distance, embeddings) + } + } + .awaitAll() + .minBy { it.distance } + if (nearestFace.distance > FACE_DISTANCE_THRESHOLD) { + return@coroutineScope RecognizedFace(embeddings = embeddings) + } + return@coroutineScope nearestFace + } + + private suspend inline fun euclideanDistance(embeddings1: FloatArray, embeddings2: FloatArray) = sqrt( + embeddings1.foldIndexed(initial = 0f) { index, acc, value -> + coroutineContext.ensureActive() + val diff = value - embeddings2[index] + acc + (diff * diff) + } + ) +} + +data class RecognizedFace( + val name: String = "", + val distance: Float = 0f, + val embeddings: FloatArray = floatArrayOf() +) { + + val isUnknown: Boolean + get() = name == "" && distance == 0f + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as RecognizedFace + if (name != other.name) return false + if (distance != other.distance) return false + return embeddings.contentEquals(other.embeddings) + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + distance.hashCode() + result = 31 * result + embeddings.contentHashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/MainApplication.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/MainApplication.kt new file mode 100644 index 0000000..0861c9d --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/MainApplication.kt @@ -0,0 +1,7 @@ +package ru.solrudev.facerecognizer + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MainApplication : Application() \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/di/CoroutineDispatcherModule.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/CoroutineDispatcherModule.kt new file mode 100644 index 0000000..7ed9ecc --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/CoroutineDispatcherModule.kt @@ -0,0 +1,21 @@ +package ru.solrudev.facerecognizer.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +@InstallIn(SingletonComponent::class) +@Module +object CoroutineDispatcherModule { + + @Provides + @DefaultDispatcher + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Provides + @MainDispatcher + fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/di/CoroutineDispatcherQualifiers.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/CoroutineDispatcherQualifiers.kt new file mode 100644 index 0000000..fdafdab --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/CoroutineDispatcherQualifiers.kt @@ -0,0 +1,11 @@ +package ru.solrudev.facerecognizer.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class DefaultDispatcher + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class MainDispatcher \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceDetectorBindModule.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceDetectorBindModule.kt new file mode 100644 index 0000000..b39cbd9 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceDetectorBindModule.kt @@ -0,0 +1,16 @@ +package ru.solrudev.facerecognizer.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ru.solrudev.facerecognizer.FaceDetector +import ru.solrudev.facerecognizer.MlKitFaceDetector + +@InstallIn(SingletonComponent::class) +@Module +interface FaceDetectorBindModule { + + @Binds + fun bindFaceDetector(faceDetector: MlKitFaceDetector): FaceDetector +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceDetectorModule.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceDetectorModule.kt new file mode 100644 index 0000000..935e590 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceDetectorModule.kt @@ -0,0 +1,30 @@ +package ru.solrudev.facerecognizer.di + +import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetector +import com.google.mlkit.vision.face.FaceDetectorOptions +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor + +@InstallIn(SingletonComponent::class) +@Module(includes = [FaceDetectorBindModule::class]) +object FaceDetectorModule { + + @Provides + fun provideFaceDetector(@DefaultDispatcher defaultDispatcher: CoroutineDispatcher): FaceDetector { + val detectorOptions = FaceDetectorOptions.Builder() + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) + .setContourMode(FaceDetectorOptions.CONTOUR_MODE_NONE) + .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) + .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) + .setMinFaceSize(0.5f) + .enableTracking() + .setExecutor(defaultDispatcher.asExecutor()) + .build() + return FaceDetection.getClient(detectorOptions) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceRecognitionBindModule.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceRecognitionBindModule.kt new file mode 100644 index 0000000..d70866d --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceRecognitionBindModule.kt @@ -0,0 +1,28 @@ +package ru.solrudev.facerecognizer.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ru.solrudev.facerecognizer.FaceRecognitionCoroutineScope +import ru.solrudev.facerecognizer.FaceRecognitionCoroutineScopeImpl +import ru.solrudev.facerecognizer.FaceRecognitionProcessor +import ru.solrudev.facerecognizer.FaceRecognitionProcessorImpl +import ru.solrudev.facerecognizer.FaceRecognizer +import ru.solrudev.facerecognizer.TfLiteFaceRecognizer + +@InstallIn(SingletonComponent::class) +@Module +interface FaceRecognitionBindModule { + + @Binds + fun bindFaceRecognizer(faceRecognizer: TfLiteFaceRecognizer): FaceRecognizer + + @Binds + fun bindFaceRecognitionProcessor(faceRecognitionProcessor: FaceRecognitionProcessorImpl): FaceRecognitionProcessor + + @Binds + fun bindFaceRecognitionCoroutineScope( + faceRecognitionCoroutineScope: FaceRecognitionCoroutineScopeImpl + ): FaceRecognitionCoroutineScope +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceRecognitionModule.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceRecognitionModule.kt new file mode 100644 index 0000000..7453a1e --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/FaceRecognitionModule.kt @@ -0,0 +1,30 @@ +package ru.solrudev.facerecognizer.di + +import android.content.Context +import android.os.Build +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.tensorflow.lite.gpu.CompatibilityList +import org.tensorflow.lite.support.model.Model +import ru.solrudev.facerecognizer.ml.MobileFaceNet + +@InstallIn(SingletonComponent::class) +@Module(includes = [FaceRecognitionBindModule::class]) +object FaceRecognitionModule { + + @Provides + fun provideMobileFaceNet(@ApplicationContext context: Context): MobileFaceNet { + val recognizerOptionsBuilder = Model.Options.Builder() + .setNumThreads(2) + if (CompatibilityList().isDelegateSupportedOnThisDevice && Build.VERSION.SDK_INT < 28) { + recognizerOptionsBuilder.setDevice(Model.Device.GPU) + } + if (Build.VERSION.SDK_INT >= 28) { + recognizerOptionsBuilder.setDevice(Model.Device.NNAPI) + } + return MobileFaceNet.newInstance(context, recognizerOptionsBuilder.build()) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/di/RepositoryBindModule.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/RepositoryBindModule.kt new file mode 100644 index 0000000..748da96 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/di/RepositoryBindModule.kt @@ -0,0 +1,16 @@ +package ru.solrudev.facerecognizer.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ru.solrudev.facerecognizer.FaceEmbeddingsRepository +import ru.solrudev.facerecognizer.InMemoryFaceEmbeddingsRepository + +@InstallIn(SingletonComponent::class) +@Module +interface RepositoryBindModule { + + @Binds + fun bindFaceEmbeddingsRepository(faceEmbeddingsRepository: InMemoryFaceEmbeddingsRepository): FaceEmbeddingsRepository +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/DialogUiState.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/DialogUiState.kt new file mode 100644 index 0000000..d080eda --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/DialogUiState.kt @@ -0,0 +1,6 @@ +package ru.solrudev.facerecognizer.ui + +data class DialogUiState(val isVisible: Boolean = false, val dialog: T? = null) { + val shouldShow: Boolean + get() = !isVisible && dialog != null +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/MainActivity.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/MainActivity.kt new file mode 100644 index 0000000..3315329 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/MainActivity.kt @@ -0,0 +1,54 @@ +package ru.solrudev.facerecognizer.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupWithNavController +import by.kirich1409.viewbindingdelegate.viewBinding +import dagger.hilt.android.AndroidEntryPoint +import dev.chrisbanes.insetter.applyInsetter +import ru.solrudev.facerecognizer.R +import ru.solrudev.facerecognizer.databinding.MainNavHostBinding + +@AndroidEntryPoint +class MainActivity : AppCompatActivity(R.layout.main_nav_host) { + + private val binding by viewBinding(MainNavHostBinding::bind, R.id.container_nav_host) + private val topLevelDestinations = setOf(R.id.face_capture_fragment) + private val appBarConfiguration = AppBarConfiguration(topLevelDestinations) + + private val navController: NavController + get() = binding.contentNavHost.getFragment().navController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + with(binding) { + setContentView(root) + applyInsets() + toolbarNavHost.setupWithNavController(navController, appBarConfiguration) + } + } + + private fun MainNavHostBinding.applyInsets() { + appBarLayoutNavHost.applyInsetter { + type(statusBars = true) { + padding() + } + type(navigationBars = true) { + margin(horizontal = true) + } + type(displayCutout = true) { + padding(left = true, top = true, right = true) + } + } + contentNavHost.applyInsetter { + type(navigationBars = true) { + margin(horizontal = true) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/CameraLens.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/CameraLens.kt new file mode 100644 index 0000000..b936fcf --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/CameraLens.kt @@ -0,0 +1,17 @@ +package ru.solrudev.facerecognizer.ui.facecapture + +import androidx.camera.core.CameraSelector + +enum class CameraLens { + FRONT, BACK +} + +fun CameraLens.switch() = when (this) { + CameraLens.BACK -> CameraLens.FRONT + CameraLens.FRONT -> CameraLens.BACK +} + +fun CameraLens.toCameraSelectorLens() = when (this) { + CameraLens.FRONT -> CameraSelector.LENS_FACING_FRONT + CameraLens.BACK -> CameraSelector.LENS_FACING_BACK +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureFragment.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureFragment.kt new file mode 100644 index 0000000..b9b0d7a --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureFragment.kt @@ -0,0 +1,158 @@ +package ru.solrudev.facerecognizer.ui.facecapture + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT +import android.os.Bundle +import android.view.View +import androidx.annotation.OptIn +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.TransformExperimental +import androidx.core.content.ContextCompat +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import by.kirich1409.viewbindingdelegate.viewBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import ru.solrudev.facerecognizer.R +import ru.solrudev.facerecognizer.databinding.DialogAddFaceBinding +import ru.solrudev.facerecognizer.databinding.FragmentCaptureFaceBinding +import ru.solrudev.facerecognizer.di.DefaultDispatcher +import ru.solrudev.facerecognizer.util.checkOrRequestCameraPermission +import ru.solrudev.facerecognizer.util.isCameraPermissionGranted +import ru.solrudev.facerecognizer.util.launchRepeatOnViewLifecycle +import ru.solrudev.facerecognizer.util.registerForCameraPermissionResult +import ru.solrudev.facerecognizer.util.setString +import ru.solrudev.facerecognizer.util.showWithLifecycle +import java.util.concurrent.Executor +import javax.inject.Inject + +@AndroidEntryPoint +class FaceCaptureFragment : Fragment(R.layout.fragment_capture_face) { + + private val binding by viewBinding(FragmentCaptureFaceBinding::bind) + private val viewModel: FaceCaptureViewModel by viewModels() + private val requestPermissionLauncher = registerForCameraPermissionResult { startCamera() } + private var faceRecognitionAnalyzer: FaceRecognitionAnalyzer? = null + private lateinit var cameraExecutor: Executor + + @Inject + lateinit var faceRecognitionAnalyzerFactory: FaceRecognitionAnalyzerFactory + + @Inject + @DefaultDispatcher + lateinit var defaultDispatcher: CoroutineDispatcher + + @SuppressLint("SourceLockedOrientationActivity") + override fun onAttach(context: Context) { + super.onAttach(context) + cameraExecutor = defaultDispatcher.asExecutor() + requireActivity().requestedOrientation = SCREEN_ORIENTATION_PORTRAIT + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + checkOrRequestCameraPermission(requestPermissionLauncher) + binding.buttonCaptureFaceAdd.setOnClickListener { + viewModel.showAddFaceDialog() + } + binding.buttonCaptureFaceCameraLens.setOnClickListener { + viewModel.switchCameraLens() + } + viewLifecycleOwner.lifecycle.addObserver(viewModel) + startRender() + } + + override fun onDestroy() { + faceRecognitionAnalyzer?.close() + super.onDestroy() + } + + private fun startRender() = launchRepeatOnViewLifecycle { + renderAddFaceDialog() + renderCameraLens() + } + + private fun CoroutineScope.renderAddFaceDialog() { + viewModel.uiState + .distinctUntilChangedBy { it.addFaceDialog } + .filter { it.addFaceDialog.shouldShow } + .onEach { showAddFaceDialog(it.addFaceDialog.dialog!!) } + .launchIn(this) + } + + private fun CoroutineScope.renderCameraLens() { + viewModel.uiState + .distinctUntilChangedBy { it.cameraLens } + .filter { isCameraPermissionGranted() } + .onEach { startCamera(it.cameraLens.toCameraSelectorLens()) } + .launchIn(this) + } + + private fun FaceRecognitionAnalyzer.startResultsRender() = launchRepeatOnViewLifecycle { + results.collect { results -> + viewModel.onFaceRecognitionResults(results) + binding.overlayFaceCaptureResults.drawResults(results) + } + } + + @OptIn(TransformExperimental::class) + private fun startCamera(cameraLens: Int = viewModel.uiState.value.cameraLens.toCameraSelectorLens()) { + val faceRecognitionAnalyzer = faceRecognitionAnalyzerFactory.create( + previewOutputTransformProvider = { binding.previewViewFaceCapture.outputTransform!! } + ) + this.faceRecognitionAnalyzer = faceRecognitionAnalyzer + faceRecognitionAnalyzer.startResultsRender() + val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) + cameraProviderFuture.addListener({ + if (binding.previewViewFaceCapture.display == null) { + return@addListener + } + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder() + .build() + .also { it.setSurfaceProvider(binding.previewViewFaceCapture.surfaceProvider) } + val imageAnalysis = ImageAnalysis.Builder() + .setTargetRotation(binding.previewViewFaceCapture.display.rotation) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { it.setAnalyzer(cameraExecutor, faceRecognitionAnalyzer) } + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(cameraLens) + .build() + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis) + }, ContextCompat.getMainExecutor(requireContext())) + } + + private fun showAddFaceDialog(dialog: AddFaceDialog) { + val dialogBinding = DialogAddFaceBinding.inflate(layoutInflater).apply { + imageViewAddFacePhoto.setImageBitmap(dialog.bitmap) + editTextAddFaceName.setString(dialog.name) + editTextAddFaceName.addTextChangedListener(onTextChanged = { text, _, _, _ -> + viewModel.onAddedNameChanged(text) + }) + } + MaterialAlertDialogBuilder(requireContext()) + .setView(dialogBinding.root) + .setPositiveButton(R.string.add) { _, _ -> + val name = dialogBinding.editTextAddFaceName.text.toString() + viewModel.onFaceAdded(name) + } + .setOnDismissListener { + viewModel.onAddFaceDialogDismissed() + } + .showWithLifecycle(viewLifecycleOwner.lifecycle) + viewModel.onAddFaceDialogShowed() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureUiState.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureUiState.kt new file mode 100644 index 0000000..b5aa491 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureUiState.kt @@ -0,0 +1,11 @@ +package ru.solrudev.facerecognizer.ui.facecapture + +import android.graphics.Bitmap +import ru.solrudev.facerecognizer.ui.DialogUiState + +data class FaceCaptureUiState( + val cameraLens: CameraLens = CameraLens.FRONT, + val addFaceDialog: DialogUiState = DialogUiState() +) + +data class AddFaceDialog(val name: String = "", val bitmap: Bitmap?) \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureViewModel.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureViewModel.kt new file mode 100644 index 0000000..8d5ab65 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceCaptureViewModel.kt @@ -0,0 +1,72 @@ +package ru.solrudev.facerecognizer.ui.facecapture + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.solrudev.facerecognizer.FaceEmbeddings +import ru.solrudev.facerecognizer.FaceEmbeddingsRepository +import ru.solrudev.facerecognizer.FaceRecognition +import ru.solrudev.facerecognizer.ui.DialogUiState +import javax.inject.Inject + +@HiltViewModel +class FaceCaptureViewModel @Inject constructor( + private val faceEmbeddingsRepository: FaceEmbeddingsRepository +) : ViewModel(), DefaultLifecycleObserver { + + private var lastResult: FaceRecognition? = null + private val _uiState = MutableStateFlow(FaceCaptureUiState()) + val uiState = _uiState.asStateFlow() + + override fun onStop(owner: LifecycleOwner) = _uiState.update { + val addFaceDialog = it.addFaceDialog.copy(isVisible = false) + it.copy(addFaceDialog = addFaceDialog) + } + + fun onAddedNameChanged(name: CharSequence?) = _uiState.update { + val addFaceDialog = it.addFaceDialog.dialog?.copy(name = name?.toString().orEmpty()) + it.copy(addFaceDialog = it.addFaceDialog.copy(dialog = addFaceDialog)) + } + + fun showAddFaceDialog() { + if (lastResult == null) { + return + } + _uiState.update { + val addFaceDialog = AddFaceDialog(bitmap = lastResult?.bitmap) + it.copy(addFaceDialog = it.addFaceDialog.copy(dialog = addFaceDialog)) + } + } + + fun onAddFaceDialogShowed() = _uiState.update { + val addFaceDialog = it.addFaceDialog.copy(isVisible = true) + it.copy(addFaceDialog = addFaceDialog) + } + + fun onFaceAdded(name: String) = viewModelScope.launch { + val faceEmbeddings = FaceEmbeddings(name, lastResult?.face?.embeddings ?: floatArrayOf()) + faceEmbeddingsRepository.addFaceEmbeddings(faceEmbeddings) + } + + fun onAddFaceDialogDismissed() = _uiState.update { + it.copy(addFaceDialog = DialogUiState()) + } + + fun switchCameraLens() = _uiState.update { + val cameraLens = it.cameraLens.switch() + it.copy(cameraLens = cameraLens) + } + + fun onFaceRecognitionResults(results: List) { + val result = results.firstOrNull() + if (_uiState.value.addFaceDialog.dialog == null) { + lastResult = result + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceRecognitionAnalyzer.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceRecognitionAnalyzer.kt new file mode 100644 index 0000000..8cc9a39 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceRecognitionAnalyzer.kt @@ -0,0 +1,57 @@ +package ru.solrudev.facerecognizer.ui.facecapture + +import android.util.Size +import androidx.annotation.OptIn +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.view.TransformExperimental +import androidx.camera.view.transform.CoordinateTransform +import androidx.camera.view.transform.ImageProxyTransformFactory +import androidx.camera.view.transform.OutputTransform +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import ru.solrudev.facerecognizer.FaceRecognitionProcessor +import ru.solrudev.facerecognizer.di.MainDispatcher + +@OptIn(TransformExperimental::class) +class FaceRecognitionAnalyzer @AssistedInject constructor( + private val faceRecognitionProcessor: FaceRecognitionProcessor, + @MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @Assisted private val previewOutputTransformProvider: () -> OutputTransform, +) : ImageAnalysis.Analyzer, AutoCloseable { + + val results = faceRecognitionProcessor.results + + @Volatile + private var isClosed = false + + private val previewOutputTransform by lazy { + runBlocking(mainDispatcher) { + previewOutputTransformProvider() + } + } + + private val imageProxyTransformFactory = ImageProxyTransformFactory() + private val size = Size(480, 360) + + override fun analyze(image: ImageProxy) { + if (isClosed) { + return + } + val sourceTransform = imageProxyTransformFactory.getOutputTransform(image) + val rotationDegrees = image.imageInfo.rotationDegrees + val previewTransform = CoordinateTransform(sourceTransform, previewOutputTransform) + faceRecognitionProcessor.processFrame(image, rotationDegrees, previewTransform) + } + + override fun getDefaultTargetResolution(): Size { + return size + } + + override fun close() { + isClosed = true + faceRecognitionProcessor.close() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceRecognitionAnalyzerFactory.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceRecognitionAnalyzerFactory.kt new file mode 100644 index 0000000..0b9cccb --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/facecapture/FaceRecognitionAnalyzerFactory.kt @@ -0,0 +1,12 @@ +package ru.solrudev.facerecognizer.ui.facecapture + +import androidx.annotation.OptIn +import androidx.camera.view.TransformExperimental +import androidx.camera.view.transform.OutputTransform +import dagger.assisted.AssistedFactory + +@AssistedFactory +interface FaceRecognitionAnalyzerFactory { + @OptIn(TransformExperimental::class) + fun create(previewOutputTransformProvider: () -> OutputTransform): FaceRecognitionAnalyzer +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/widget/OverlayView.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/widget/OverlayView.kt new file mode 100644 index 0000000..3de2fc9 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/ui/widget/OverlayView.kt @@ -0,0 +1,99 @@ +package ru.solrudev.facerecognizer.ui.widget + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.graphics.withClip +import ru.solrudev.facerecognizer.FaceRecognition +import ru.solrudev.facerecognizer.R +import ru.solrudev.facerecognizer.util.roundedPath + +private const val BOUNDING_BOX_TEXT_PADDING = 16 +private const val BOUNDING_BOX_CORNER_SIZE = 50f +private const val BOUNDING_BOX_TEXT_MARGIN = 25f + +class OverlayView(context: Context, attrs: AttributeSet) : View(context, attrs) { + + private var results = listOf() + private val boxPaint = Paint() + private val textBackgroundPaint = Paint() + private val textPaint = Paint().apply { isAntiAlias = true } + private val bounds = Rect() + + init { + initPaints() + } + + fun drawResults(detectionResults: List) { + results = detectionResults + invalidate() + } + + fun clear() { + textPaint.reset() + textBackgroundPaint.reset() + boxPaint.reset() + invalidate() + initPaints() + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + for (result in results) { + val roundedBoundingBox = result.boundingBox.roundedPath(BOUNDING_BOX_CORNER_SIZE) + if (result.face.isUnknown) { + canvas.drawPath(roundedBoundingBox, boxPaint) + return + } + canvas.drawFaceRecognition(result, roundedBoundingBox) + } + } + + private fun Canvas.drawFaceRecognition(faceRecognition: FaceRecognition, roundedBoundingBox: Path) { + val text = "${faceRecognition.face.name} ${String.format("%.2f", faceRecognition.face.distance)}" + textBackgroundPaint.getTextBounds(text, 0, text.length, bounds) + val textWidth = bounds.width() + val textHeight = bounds.height() + val top = faceRecognition.boundingBox.top.toFloat() + val left = faceRecognition.boundingBox.left.toFloat() + withClip(roundedBoundingBox) { + drawRect( + left, top, + left + textWidth + BOUNDING_BOX_TEXT_PADDING + BOUNDING_BOX_TEXT_MARGIN, + top + textHeight + BOUNDING_BOX_TEXT_PADDING + BOUNDING_BOX_TEXT_MARGIN, + textBackgroundPaint + ) + drawText( + text, + left + BOUNDING_BOX_TEXT_MARGIN, + top + textHeight + BOUNDING_BOX_TEXT_MARGIN, + textPaint + ) + } + drawPath(roundedBoundingBox, boxPaint) + } + + private fun initPaints() { + textBackgroundPaint.apply { + color = Color.BLACK + style = Paint.Style.FILL + textSize = 50f + } + textPaint.apply { + color = Color.WHITE + style = Paint.Style.FILL + textSize = 50f + } + boxPaint.apply { + color = ContextCompat.getColor(context, R.color.bounding_box_color) + strokeWidth = 8f + style = Paint.Style.STROKE + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/util/BitmapUtil.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/BitmapUtil.kt new file mode 100644 index 0000000..c1bf6a0 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/BitmapUtil.kt @@ -0,0 +1,25 @@ +package ru.solrudev.facerecognizer.util + +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.RectF +import androidx.core.graphics.toRect + +fun Bitmap.crop(rect: RectF, rotationDegrees: Int): Bitmap? { + val cropRect = rect.toRect() + val x = cropRect.left.coerceAtLeast(0) + val y = cropRect.top.coerceAtLeast(0) + val width = cropRect.width() + val height = cropRect.height() + val croppedWidth = if (x + width > this.width) this.width - x else width + val croppedHeight = if (y + height > this.height) this.height - y else height + if (croppedWidth <= 0 || croppedHeight <= 0) { + return null + } + val needToRotate = rotationDegrees % 360 != 0 + if (needToRotate) { + val rotationMatrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } + return Bitmap.createBitmap(this, x, y, croppedWidth, croppedHeight, rotationMatrix, true) + } + return Bitmap.createBitmap(this, x, y, croppedWidth, croppedHeight) +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/util/DialogUtil.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/DialogUtil.kt new file mode 100644 index 0000000..d6237c1 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/DialogUtil.kt @@ -0,0 +1,61 @@ +package ru.solrudev.facerecognizer.util + +import android.app.Dialog +import android.content.DialogInterface.OnDismissListener +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner + +/** + * Displays the dialog. When [dismissEvent] or [ON_DESTROY] occurs in the provided lifecycle, dialog is dismissed. + * Previously set [OnDismissListener] won't be invoked. + * @param lifecycle a [Lifecycle] to be observed. + * @param dismissEvent [Lifecycle.Event] on which dialog will be dismissed. + */ +fun Dialog.showWithLifecycle(lifecycle: Lifecycle, dismissEvent: Lifecycle.Event = ON_STOP) { + val dialogHolder = LifecycleAwareDialogHolder(this, dismissEvent) + lifecycle.addObserver(dialogHolder) + show() +} + +/** + * Creates an [AlertDialog] and displays it. When [dismissEvent] or [ON_DESTROY] occurs in the provided lifecycle, + * dialog is dismissed. + * Previously set [OnDismissListener] won't be invoked. + * @param lifecycle a [Lifecycle] to be observed. + * @param dismissEvent [Lifecycle.Event] on which dialog will be dismissed. + */ +fun AlertDialog.Builder.showWithLifecycle(lifecycle: Lifecycle, dismissEvent: Lifecycle.Event = ON_STOP) { + create().showWithLifecycle(lifecycle, dismissEvent) +} + +/** + * Lifecycle-aware wrapper for a dialog. When [dismissEvent] or [ON_DESTROY] occurs, dialog is dismissed. + * Previously set [OnDismissListener] won't be invoked. + * @param dialog a [Dialog] which should be dismissed on [dismissEvent]. + * @param dismissEvent [Lifecycle.Event] on which dialog will be dismissed. + */ +private class LifecycleAwareDialogHolder( + private var dialog: Dialog?, + private val dismissEvent: Lifecycle.Event +) : LifecycleEventObserver { + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == dismissEvent || event == ON_DESTROY) { + dialog?.dismissWithoutSideEffects() + dialog = null + source.lifecycle.removeObserver(this) + } + } +} + +/** + * Removes previously set [OnDismissListener] and dismisses the dialog. + */ +private fun Dialog.dismissWithoutSideEffects() { + setOnDismissListener(null) + dismiss() +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/util/EditTextUtil.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/EditTextUtil.kt new file mode 100644 index 0000000..cbf50c2 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/EditTextUtil.kt @@ -0,0 +1,10 @@ +package ru.solrudev.facerecognizer.util + +import android.widget.EditText + +fun EditText.setString(string: String) { + setText(string) + text?.let { text -> + setSelection(text.length) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/util/FragmentUtil.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/FragmentUtil.kt new file mode 100644 index 0000000..86e8015 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/FragmentUtil.kt @@ -0,0 +1,50 @@ +package ru.solrudev.facerecognizer.util + +import android.Manifest +import android.content.pm.PackageManager +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import ru.solrudev.facerecognizer.R + +const val CAMERA_PERMISSION = Manifest.permission.CAMERA + +inline fun Fragment.registerForCameraPermissionResult(crossinline onGranted: () -> Unit) = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + onGranted() + } else { + Toast.makeText( + requireContext(), + R.string.camera_permission_not_granted, + Toast.LENGTH_SHORT + ).show() + findNavController().popBackStack() + } + } + +fun Fragment.checkOrRequestCameraPermission(launcher: ActivityResultLauncher) { + if (!isCameraPermissionGranted()) { + launcher.launch(CAMERA_PERMISSION) + } +} + +fun Fragment.isCameraPermissionGranted() = ContextCompat.checkSelfPermission( + requireContext(), CAMERA_PERMISSION +) == PackageManager.PERMISSION_GRANTED + +inline fun Fragment.launchRepeatOnViewLifecycle( + crossinline action: suspend CoroutineScope.() -> Unit +) = viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + action() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/util/RectUtil.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/RectUtil.kt new file mode 100644 index 0000000..d425884 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/RectUtil.kt @@ -0,0 +1,53 @@ +package ru.solrudev.facerecognizer.util + +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +import androidx.core.graphics.toRectF +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.android.material.shape.ShapeAppearancePathProvider + +fun Rect.rotateCounterclockwise(containerWidth: Int, containerHeight: Int, rotationDegrees: Int): RectF { + if (rotationDegrees % 360 == 0) { + return toRectF() + } + var left = left + var top = top + var right = right + var bottom = bottom + val normalizedDegrees = (rotationDegrees % 360 + 360) % 360 + val previousLeft = left + val previousTop = top + val previousRight = right + val previousBottom = bottom + when (normalizedDegrees) { + 90 -> { + left = previousTop + top = containerHeight - previousRight + right = previousBottom + bottom = containerHeight - previousLeft + } + + 180 -> { + left = containerWidth - previousRight + top = containerHeight - previousBottom + right = containerWidth - previousLeft + bottom = containerHeight - previousTop + } + + 270 -> { + left = containerWidth - previousBottom + top = previousLeft + right = containerWidth - previousTop + bottom = previousRight + } + } + return RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat()) +} + +fun Rect.roundedPath(cornerSize: Float): Path { + val roundPath = Path() + val appearanceModel = ShapeAppearanceModel().withCornerSize(cornerSize) + ShapeAppearancePathProvider().calculatePath(appearanceModel, 1f, toRectF(), roundPath) + return roundPath +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/solrudev/facerecognizer/util/TaskUtil.kt b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/TaskUtil.kt new file mode 100644 index 0000000..26b05f1 --- /dev/null +++ b/app/src/main/kotlin/ru/solrudev/facerecognizer/util/TaskUtil.kt @@ -0,0 +1,12 @@ +package ru.solrudev.facerecognizer.util + +import com.google.android.gms.tasks.Task +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +suspend fun Task.await(): TResult = suspendCancellableCoroutine { continuation -> + addOnCanceledListener { continuation.cancel() } + addOnSuccessListener { continuation.resume(it) } + addOnFailureListener { continuation.resumeWithException(it) } +} \ No newline at end of file diff --git a/app/src/main/ml/mobile_face_net.tflite b/app/src/main/ml/mobile_face_net.tflite new file mode 100644 index 0000000..dcba8d1 Binary files /dev/null and b/app/src/main/ml/mobile_face_net.tflite differ diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..a7fab1d --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_camera_switch.xml b/app/src/main/res/drawable/ic_camera_switch.xml new file mode 100644 index 0000000..47968bd --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_switch.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0f97952 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_add_face.xml b/app/src/main/res/layout/dialog_add_face.xml new file mode 100644 index 0000000..65c9a5c --- /dev/null +++ b/app/src/main/res/layout/dialog_add_face.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_capture_face.xml b/app/src/main/res/layout/fragment_capture_face.xml new file mode 100644 index 0000000..87ce252 --- /dev/null +++ b/app/src/main/res/layout/fragment_capture_face.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_nav_host.xml b/app/src/main/res/layout/main_nav_host.xml new file mode 100644 index 0000000..eb8dc76 --- /dev/null +++ b/app/src/main/res/layout/main_nav_host.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml new file mode 100644 index 0000000..408aa68 --- /dev/null +++ b/app/src/main/res/menu/main_menu.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file 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..ef49c99 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file 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..ef49c99 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..97d3a66 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3f29aac Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..a8bcffa Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6f1ee8b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..ecef718 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b7dafa7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..eb74449 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..80fc78e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..fca5af1 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..928ab43 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml new file mode 100644 index 0000000..3e2e7f9 --- /dev/null +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night-v21/themes.xml b/app/src/main/res/values-night-v21/themes.xml new file mode 100644 index 0000000..a6ff0e8 --- /dev/null +++ b/app/src/main/res/values-night-v21/themes.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..fe6b3f5 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,3 @@ +tasks.register("clean").configure { + delete(rootProject.layout.buildDirectory) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..fe12d0b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml new file mode 100644 index 0000000..a772a01 --- /dev/null +++ b/gradle/androidx.versions.toml @@ -0,0 +1,23 @@ +[versions] +navigation = "2.7.7" +camera = "1.3.2" + +[libraries] +activity-ktx = { module = "androidx.activity:activity-ktx", version = "1.8.2" } +fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.6.2" } +navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } +navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } +multidex = { module = "androidx.multidex:multidex", version = "2.0.1" } +test-ext-junit = { module = "androidx.test.ext:junit", version = "1.1.5" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.5.1" } +camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } +camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "camera" } +camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } +camera-view = { module = "androidx.camera:camera-view", version.ref = "camera" } + +[bundles] +navigation = ["navigation-fragment-ktx", "navigation-ui-ktx"] +camera = ["camera-camera2", "camera-extensions", "camera-lifecycle", "camera-view"] + +[plugins] +navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "navigation" } \ No newline at end of file diff --git a/gradle/dagger.versions.toml b/gradle/dagger.versions.toml new file mode 100644 index 0000000..364f712 --- /dev/null +++ b/gradle/dagger.versions.toml @@ -0,0 +1,14 @@ +[versions] +dagger = "2.51.1" +hilt = "1.2.0" + +[libraries] +dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger" } + +[bundles] +hilt-compilers = ["dagger-hilt-compiler", "androidx-hilt-compiler"] + +[plugins] +hilt-plugin = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..e55c9c2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,34 @@ +[versions] +android-gradleplugin = "8.3.1" +kotlin = "1.9.23" +kotlin-ksp = "1.9.23-1.0.19" +kotlinx-coroutines = "1.8.0" +tensorflow-lite = "0.4.4" +tensorflow-gpu = "2.15.0" + +[libraries] +materialcomponents = { module = "com.google.android.material:material", version = "1.11.0" } +viewbindingpropertydelegate = { module = "com.github.kirich1409:viewbindingpropertydelegate-noreflection", version = "1.5.9" } +kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +junit-platform-commons = { module = "org.junit.platform:junit-platform-commons", version = "1.10.1" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version = "5.10.1" } +mlkit-facedetection = { module = "com.google.mlkit:face-detection", version = "16.1.6" } +tensorflow-taskvision = { module = "org.tensorflow:tensorflow-lite-task-vision", version = "0.4.4" } +tensorflow-gpudelegateplugin = { module = "org.tensorflow:tensorflow-lite-gpu-delegate-plugin", version.ref = "tensorflow-lite" } +tensorflow-gpu = { module = "org.tensorflow:tensorflow-lite-gpu", version.ref = "tensorflow-gpu" } +tensorflow-gpu-api = { module = "org.tensorflow:tensorflow-lite-gpu-api", version.ref = "tensorflow-gpu" } +tensorflow-metadata = { module = "org.tensorflow:tensorflow-lite-metadata", version.ref = "tensorflow-lite" } +tensorflow-support = { module = "org.tensorflow:tensorflow-lite-support", version.ref = "tensorflow-lite" } +insetter = { module = "dev.chrisbanes.insetter:insetter", version = "0.6.1" } + +[bundles] +junit = ["junit-platform-commons", "junit-jupiter"] +tensorflow = ["tensorflow-taskvision", "tensorflow-gpudelegateplugin", "tensorflow-gpu", "tensorflow-gpu-api", "tensorflow-metadata", "tensorflow-support"] + +[plugins] +android-application = { id = "com.android.application", version.ref = "android-gradleplugin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlin-ksp" } +android-cachefix = { id = "org.gradle.android.cache-fix", version = "3.0" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /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.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b19020a --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,47 @@ +rootProject.name = "FaceRecognizer" +include(":app") + +enableFeaturePreview("STABLE_CONFIGURATION_CACHE") + +pluginManagement { + repositories { + google { + content { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + includeGroup("com.google.testing.platform") + } + } + gradlePluginPortal() + mavenCentral() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + + repositories { + google { + content { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } + + versionCatalogs { + register("androidx") { + from(files("gradle/androidx.versions.toml")) + } + register("dagger") { + from(files("gradle/dagger.versions.toml")) + } + } +} \ No newline at end of file