diff --git a/.editorconfig b/.editorconfig index 9446997..d740210 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,9 +19,22 @@ ktlint_standard_annotation = disabled # If enabled, this rules forces any multiline assignment to the new line regardless of length ktlint_standard_multiline-expression-wrapping = disabled +# If enabled, this rules forces braces to all branches if any single one has them +ktlint_standard_when-entry-bracing = disabled + +# If enabled, this rule forces blank line between when branches if there is a single multiline branch +ktlint_standard_blank-line-between-when-conditions = disabled +ij_kotlin_line_break_after_multiline_when_entry = false + # Default allows the expression body to be a single call to a wrapper # function (e.g. `runTest{}`) without a line break ktlint_function_signature_body_expression_wrapping = default # In tests star imports are fine since there could be lots helper functions ij_kotlin_packages_to_use_import_on_demand=androidx.test.**,io.mockk.**,com.google.common.truth.** + +# Backticked function names are only used in tests and those names can be very long +ktlint_ignore_back_ticked_identifier=true + +# Unused import deletion is disabled by default due to a issue with +ktlint_standard_no-unused-imports = enabled diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc049c9..9246930 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,4 @@ jobs: java-version: '17' - name: Build with Gradle - run: ./gradlew build \ No newline at end of file + run: ./gradlew simface:build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b878cf9..905ddba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,10 +40,10 @@ jobs: run: chmod +x gradlew - name: Build libraries - run: ./gradlew build + run: ./gradlew simface:build - name: Publish to GitHub Packages env: USERNAME: ${{ github.actor }} TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./gradlew publish + run: ./gradlew simface:publish diff --git a/README.md b/README.md index f914da9..50110b2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# SimFace +# Simprints Face Biometrics SDK + +## SimFace Library An Android library for face recognition and quality assessment on edge devices. It provides face detection, embedding creation, and biometric matching capabilities. @@ -9,15 +11,17 @@ detection and [EdgeFace](https://github.com/otroshi/edgeface) for embedding (tem 2. Embedding Creation: This module is used to generate a 512-float vector representation of face images. 3. Matching and Identification: Used for verification and identification purposes. +**šŸ“š [View Full Documentation](simface/README.md)** for installation and usage examples. + ## SimQ Library -**SimQ** is a standalone Android library for comprehensive face quality assessment. It can be used independently or as part of SimFace for enhanced quality evaluation. SimQ provides a quality score from 0.0 to 1.0, with customizable weights for each metric and configurable thresholds. +**SimQ** is a standalone Android library for comprehensive face quality assessment. It can be used independently or as part of SimFace for +enhanced quality evaluation. SimQ provides a quality score from 0.0 to 1.0, with customizable weights for each metric and configurable +thresholds. **šŸ“š [View Full SimQ Documentation](simq/README.md)** for installation, usage examples, and advanced configuration options. -## Include the library into the project. - -### Option 1 (Recommended) +### Installation 1. Add the repository to your `settings.gradle.kts` under `dependencyResolutionManagement` under `respositories`: @@ -31,99 +35,11 @@ maven { } ``` -2. Import the dependencies in `build.gradle.kts`: +Import the dependencies in `build.gradle.kts`: ```kotlin implementation("com.simprints.biometrics:simface:2026.1.0") -``` - -## Implement the functionality. - -### Coroutines (Recommended) - -```kotlin -// Initialize library configuration -val simFace = SimFace() -val simFaceConfig = SimFaceConfig(context) -simFace.initialize(simFaceConfig) - -// Load a bitmap image for processing -val faceImage: Bitmap = - BitmapFactory.decodeResource(context.resources, R.drawable.royalty_free_good_face) - -lifecycleScope.launch { - val faces = simFace.detectFaceBlocking(faceImage) - val face = faces[0] - if (faces.size != 1 || face.quality < 0.6) throw Exception("Quality not sufficient") - - // Align and crop the image of the face - val alignedFace = face.alignedFaceImage(bitmap) - - // Generate an embedding from the image - val probe = simFace.getFaceDetectionProcessor().getEmbedding(alignedFace) - - // Verify the embedding against itself - val score = simFace.verificationScore(probe, probe) -} -``` - -### Callbacks - -```kotlin -// Initialize library configuration -val simFace = SimFace() -val simFaceConfig = SimFaceConfig(context) -simFace.initialize(simFaceConfig) - -// Load a bitmap image for processing -val faceImage: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.royalty_free_good_face) - -// Note that this can be better handled with callbacks or coroutines -simFace.getFaceDetectionProcessor().detectFace(faceImage, onSuccess = { faces -> - val face = faces[0] - if (faces.size != 1 || face.quality < 0.6) throw Exception("Quality not sufficient") - - // Align and crop the image of the face - val alignedFace = face.alignedFaceImage(bitmap) - - // Generate an embedding from the image - val probe = simFace.getEmbedding(alignedFace) - // Verify the embedding against itself - val score = simFace.verificationScore(probe, probe) -}) +// Or if only quality assessment is needed +implementation("com.simprints.biometrics:simq:2026.1.0") ``` - -## Workflow - -### Face Detection and Embedding - -We first initialize the library. Then we can use the -`simFace.detectFace` method to detect faces in images and evaluate their quality. -We can repeat this process multiple times until a sufficiently good face image is selected. - -Afterwards, we can use the `simFace.getEmbedding` method to obtain a vector template -from the selected image. The embedding is represented by a 512 float array. - -### Verification and Identification - -The same steps are taken to initialize the library. - -Then, the matching of two templates is carried out using the `simFace.verificationScore` method, -which returns a score in the [0, 1] range, being 1 a perfect match. - -Identification can be carried using the `simFace.identificationScore` method which returns a mapping of the -referenceVectors to the the score with respect to the probe. - -Both methods use the cosine similarity between vectors as a measure of the score. - -## System Requirements - -The library works with a minimum version of Android 6.0 (API Level 23). It has been tested and runs -smoothly on _Samsung Galaxy A03 Core_ which has the following specifications: - -- Android 11 -- 1.6GHz Octa-core -- 2GB RAM -- 8MP f/2.0 Camera -- 32GB Storage diff --git a/build.gradle.kts b/build.gradle.kts index ec0b00e..a76596a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,91 +1,6 @@ plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) - `maven-publish` + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false } -val projectGroupId = "com.simprints.biometrics" -val projectArtifactId = "simface" -val projectVersion = "2026.1.0" - -group = projectGroupId -version = projectVersion - -android { - - namespace = "$projectGroupId.$projectArtifactId" - compileSdk = 36 - - defaultConfig { - minSdk = 23 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } -} - -dependencies { - api(project(":simq")) - - // Tensorflow versions that works with Edgeface - api(libs.tensorflow.lite.support) - api(libs.tensorflow.lite.metadata) - api(libs.tensorflow.lite) - - // Face Detection and quality - api(libs.face.detection) - - // For face alignment - api(libs.ejml.simple) - - androidTestImplementation(libs.truth) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.kotlinx.coroutines.test) -} - -publishing { - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/simprints/Biometrics-SimFace") - credentials { - username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME") - password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") - } - } - } - publications { - create("ReleaseAar") { - groupId = projectGroupId - artifactId = projectArtifactId - version = projectVersion - afterEvaluate { artifact(tasks.getByName("bundleReleaseAar")) } - - pom.withXml { - val dependenciesNode = asNode().appendNode("dependencies") - - configurations.getByName("api").dependencies.map { dependency -> - dependenciesNode.appendNode("dependency").also { - it.appendNode("groupId", dependency.group) - it.appendNode("artifactId", dependency.name) - it.appendNode("version", dependency.version) - } - } - } - } - } -} diff --git a/gradle.properties b/gradle.properties index 20e2a01..61ae872 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,8 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d353ce9..baf43bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,23 +1,29 @@ [versions] -agp = "8.13.2" +agp = "9.2.1" kotlin = "2.2.21" -junitVersion = "1.3.0" -espressoCore = "3.7.0" -tensorflowLite = "2.17.0" -tensorflowLiteMetadata = "0.5.0" -tensorflowLiteSupport = "0.5.0" -faceDetection = "16.1.7" -ejmlSimple = "0.44.0" coreKtx = "1.17.0" appcompat = "1.7.1" material = "1.13.0" +composeBom = "2024.09.00" +activityCompose = "1.10.1" +lifecycleRuntimeKtx = "2.9.3" +camera = "1.3.1" +faceDetection = "16.1.7" +ejmlSimple = "0.44.0" opencv = "4.13.0" +tensorflowLite = "2.17.0" +tensorflowLiteMetadata = "0.5.0" +tensorflowLiteSupport = "0.5.0" truth = "1.4.5" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +coroutines = "1.10.2" [libraries] androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } tensorflow-lite = { module = "org.tensorflow:tensorflow-lite", version.ref = "tensorflowLite" } tensorflow-lite-metadata = { module = "org.tensorflow:tensorflow-lite-metadata", version.ref = "tensorflowLiteMetadata" } tensorflow-lite-support = { module = "org.tensorflow:tensorflow-lite-support", version.ref = "tensorflowLiteSupport" } @@ -28,8 +34,28 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version material = { group = "com.google.android.material", name = "material", version.ref = "material" } opencv = { module = "org.opencv:opencv", version.ref = "opencv" } truth = { module = "com.google.truth:truth", version.ref = "truth" } +junit = { group = "junit", name = "junit", version.ref = "junit" } + +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } + +camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camera" } +camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camera" } +camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camera" } +camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camera" } [plugins] -jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d611e67..40a6b7b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Oct 02 14:49:35 EEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts new file mode 100644 index 0000000..e05efbf --- /dev/null +++ b/sample/build.gradle.kts @@ -0,0 +1,71 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + id("org.jetbrains.kotlin.plugin.compose") +} + + +android { + namespace = "com.simprints.sample" + compileSdk = 36 + + defaultConfig { + applicationId = "com.simprints.sample" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":simface")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + // CameraX dependencies + implementation(libs.camera.core) + implementation(libs.camera.camera2) + implementation(libs.camera.lifecycle) + implementation(libs.camera.view) + + testImplementation(libs.junit) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ce65a69 --- /dev/null +++ b/sample/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/java/com/simprints/sample/di/ViewModelFactory.kt b/sample/src/main/java/com/simprints/sample/di/ViewModelFactory.kt new file mode 100644 index 0000000..b715985 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/di/ViewModelFactory.kt @@ -0,0 +1,38 @@ +package com.simprints.sample.di + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.simprints.sample.ui.screens.camera.SimFaceCameraViewModel +import com.simprints.sample.ui.screens.image.SimFaceTestImageViewModel +import com.simprints.sample.wrappers.SampleImageLoader +import com.simprints.sample.wrappers.SimFaceWrapper + +class SimFaceCameraViewModelFactory( + private val application: Application, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SimFaceCameraViewModel::class.java)) { + return SimFaceCameraViewModel( + repository = SimFaceWrapper(application.applicationContext), + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } +} + +class SimFaceTestImageDemoViewModelFactory( + private val application: Application, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SimFaceTestImageViewModel::class.java)) { + return SimFaceTestImageViewModel( + repository = SimFaceWrapper(application.applicationContext), + imageLoader = SampleImageLoader(application.resources), + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/composables/CameraCaptureSection.kt b/sample/src/main/java/com/simprints/sample/ui/composables/CameraCaptureSection.kt new file mode 100644 index 0000000..af860e2 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/composables/CameraCaptureSection.kt @@ -0,0 +1,75 @@ +package com.simprints.sample.ui.composables + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.simprints.sample.ui.models.FaceResult + +@Composable +fun CameraCaptureSection( + isBusy: Boolean, + capturedImage1: FaceResult?, + capturedImage2: FaceResult?, + onCaptureFace1: () -> Unit, + onCaptureFace2: () -> Unit, + onCompareCaptured: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "šŸ“ø Camera Capture & Compare", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onCaptureFace1, + enabled = !isBusy, + modifier = Modifier.weight(1f), + ) { + Text("Capture Face 1") + } + + Button( + onClick = onCaptureFace2, + enabled = !isBusy, + modifier = Modifier.weight(1f), + ) { + Text("Capture Face 2") + } + } + + Button( + onClick = onCompareCaptured, + enabled = !isBusy && capturedImage1 != null && capturedImage2 != null, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Compare Captured Faces") + } + } + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/composables/ComparisonResultCard.kt b/sample/src/main/java/com/simprints/sample/ui/composables/ComparisonResultCard.kt new file mode 100644 index 0000000..990ad10 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/composables/ComparisonResultCard.kt @@ -0,0 +1,28 @@ +package com.simprints.sample.ui.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun ComparisonResultCard(comparisonResult: String?) { + comparisonResult?.let { comparison -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = comparison, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + } + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/composables/DisplayFaceResult.kt b/sample/src/main/java/com/simprints/sample/ui/composables/DisplayFaceResult.kt new file mode 100644 index 0000000..352161b --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/composables/DisplayFaceResult.kt @@ -0,0 +1,56 @@ +package com.simprints.sample.ui.composables + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.simprints.sample.ui.models.FaceResult + +@Composable +fun DisplayFaceResult( + result: FaceResult, + title: String, + titleColor: Color, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = titleColor) + + result.bitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Processed face $title", + modifier = Modifier + .fillMaxWidth() + .height(250.dp), + ) + } + + Text( + text = result.message, + fontSize = 14.sp, + fontWeight = if (result.success) FontWeight.Normal else FontWeight.Bold, + color = if (result.success) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.error + }, + ) + } + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/composables/TestImagesSection.kt b/sample/src/main/java/com/simprints/sample/ui/composables/TestImagesSection.kt new file mode 100644 index 0000000..91c5ef1 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/composables/TestImagesSection.kt @@ -0,0 +1,62 @@ +package com.simprints.sample.ui.composables + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.simprints.sample.ui.models.FaceResult + +@Composable +fun TestImagesSection( + isBusy: Boolean, + result1: FaceResult?, + result2: FaceResult?, + result3: FaceResult?, + onLoadObama1: () -> Unit, + onLoadObama2: () -> Unit, + onLoadBush: () -> Unit, + onLoadLowQuality: () -> Unit, + onCompareObamaToObama: () -> Unit, + onCompareObamaToBush: () -> Unit, +) { + Text(text = "Test Images", fontSize = 18.sp, fontWeight = FontWeight.Bold) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onLoadObama1, enabled = !isBusy) { Text("Load Obama 1") } + Button(onClick = onLoadObama2, enabled = !isBusy) { Text("Load Obama 2") } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onLoadBush, enabled = !isBusy) { Text("Load Bush 1") } + Button(onClick = onLoadLowQuality, enabled = !isBusy) { Text("Load Low Quality") } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onCompareObamaToObama, + enabled = !isBusy && result1 != null && result2 != null, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), + modifier = Modifier.weight(1f), + ) { + Text("Compare Obama with Obama", textAlign = TextAlign.Center) + } + + Button( + onClick = onCompareObamaToBush, + enabled = !isBusy && result1 != null && result3 != null, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), + modifier = Modifier.weight(1f), + ) { + Text("Compare Obama with Bush", textAlign = TextAlign.Center) + } + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/models/FaceResult.kt b/sample/src/main/java/com/simprints/sample/ui/models/FaceResult.kt new file mode 100644 index 0000000..0806e73 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/models/FaceResult.kt @@ -0,0 +1,12 @@ +package com.simprints.sample.ui.models + +import android.graphics.Bitmap +import com.simprints.biometrics.simface.data.FaceDetection + +data class FaceResult( + val bitmap: Bitmap?, + val success: Boolean, + val message: String, + val faces: List, + val embedding: ByteArray? = null, +) diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/camera/CameraPreviewScreen.kt b/sample/src/main/java/com/simprints/sample/ui/screens/camera/CameraPreviewScreen.kt new file mode 100644 index 0000000..5645ddb --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/camera/CameraPreviewScreen.kt @@ -0,0 +1,428 @@ +package com.simprints.sample.ui.screens.camera + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageFormat +import android.graphics.Matrix +import android.graphics.Rect +import android.graphics.YuvImage +import android.util.Log +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.simprints.biometrics.simface.data.FaceDetection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import kotlin.coroutines.resume + +private const val ANALYSIS_IMAGE_MAX_WIDTH = 500 + +data class FaceDetectionResult( + val faces: List, + val imageWidth: Int, + val imageHeight: Int, + val rotation: Int, +) + +@Composable +fun CameraPreviewScreen( + modifier: Modifier = Modifier, + onDetectFaces: suspend (Bitmap) -> List, + onImageCaptured: (Bitmap) -> Unit, + onDismiss: () -> Unit, + isProcessing: Boolean = false, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + var faceDetectionResult by remember { mutableStateOf(null) } + var previewView by remember { mutableStateOf(null) } + var imageCapture by remember { mutableStateOf(null) } + var isCapturing by remember { mutableStateOf(false) } + var previewWidth by remember { mutableStateOf(0f) } + var previewHeight by remember { mutableStateOf(0f) } + + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + + DisposableEffect(Unit) { onDispose { cameraProviderFuture.get()?.unbindAll() } } + + Box(modifier = modifier.fillMaxSize()) { + // Camera Preview + AndroidView( + factory = { ctx -> + PreviewView(ctx).apply { + previewView = this + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + post { + previewWidth = width.toFloat() + previewHeight = height.toFloat() + } + } + }, + modifier = Modifier.fillMaxSize(), + update = { view -> + previewWidth = view.width.toFloat() + previewHeight = view.height.toFloat() + }, + ) + + // Face detection overlay + faceDetectionResult?.let { result -> + if (previewWidth > 0 && previewHeight > 0) { + Canvas(modifier = Modifier.fillMaxSize()) { + result.faces.forEach { face -> + val box = face.absoluteBoundingBox + + // Calculate scale factors based on preview size vs image size + val scaleX = previewWidth / result.imageWidth.toFloat() + val scaleY = previewHeight / result.imageHeight.toFloat() + + // Map coordinates directly + val left = box.left * scaleX + val top = box.top * scaleY + val width = box.width() * scaleX + val height = box.height() * scaleY + + // Draw bounding box + val color = + when { + face.quality >= 0.7 -> Color.Green + face.quality >= 0.5 -> Color.Yellow + else -> Color.Red + } + + drawRect( + color = color, + topLeft = Offset(left, top), + size = Size(width, height), + style = Stroke(width = 4.dp.toPx()), + ) + } + } + } + } + + // UI Overlay + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // Top info card + Card(colors = CardDefaults.cardColors(containerColor = Color.Black.copy(alpha = 0.7f))) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = if (faceDetectionResult?.faces?.isNotEmpty() == true) { + "āœ“ Face Detected" + } else { + "⚠ No Face Detected" + }, + color = if (faceDetectionResult?.faces?.isNotEmpty() == true) { + Color.Green + } else { + Color.Yellow + }, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ) + + faceDetectionResult?.faces?.firstOrNull()?.let { face -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Quality: ${"%.2f".format(face.quality)}", + color = when { + face.quality >= 0.7 -> Color.Green + face.quality >= 0.5 -> Color.Yellow + else -> Color.Red + }, + fontSize = 14.sp, + ) + Text( + text = "Yaw: ${"%.1f".format(face.yaw)}°", + color = Color.White, + fontSize = 12.sp, + ) + Text( + text = "Roll: ${"%.1f".format(face.roll)}°", + color = Color.White, + fontSize = 12.sp, + ) + } + } + } + + // Bottom controls + Box(modifier = Modifier.fillMaxWidth()) { + // Cancel button on the far left + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE57373)), + modifier = Modifier.align(Alignment.CenterStart), + ) { Text("Cancel", color = Color.White) } + + // Camera button dead center + Button( + onClick = { + if (!isCapturing && !isProcessing) { + isCapturing = true + imageCapture?.takePicture( + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(image: ImageProxy) { + CoroutineScope(Dispatchers.Default).launch { + try { + val bitmap = imageProxyToBitmap(image) + image.close() + + // Resize bitmap to 500px width for faster + // processing + val resizedBitmap = resizeBitmap(bitmap) + + withContext(Dispatchers.Main) { + onImageCaptured(resizedBitmap) + // Parent will handle the processing + // state + } + } catch (e: Exception) { + Log.e( + "CameraPreview", + "Image processing failed", + e, + ) + withContext(Dispatchers.Main) { + isCapturing = false + } + } + } + } + + override fun onError(exception: ImageCaptureException) { + Log.e("CameraPreview", "Capture failed", exception) + isCapturing = false + } + }, + ) + } + }, + enabled = !isCapturing && !isProcessing && faceDetectionResult?.faces?.isNotEmpty() == true, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF20b2d0), + disabledContainerColor = Color.Gray, + ), + modifier = Modifier + .size(80.dp) + .align(Alignment.Center), + contentPadding = PaddingValues(0.dp), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = if (isCapturing || isProcessing) "ā³" else "šŸ“·", + fontSize = 32.sp, + ) + } + } + } + } + + // Processing overlay + if (isProcessing) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + ) { + Card(colors = CardDefaults.cardColors(containerColor = Color.Black.copy(alpha = 0.8f))) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(32.dp), + ) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.size(64.dp), + ) + Text( + text = "Processing image...", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } + + LaunchedEffect(Unit) { + val cameraProvider = suspendCancellableCoroutine { continuation -> + cameraProviderFuture.addListener( + { continuation.resume(cameraProviderFuture.get()) }, + ContextCompat.getMainExecutor(context), + ) + } + + cameraProvider.unbindAll() + + val preview = Preview.Builder().build() + + val imageAnalyzer = ImageAnalysis + .Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + imageCapture = ImageCapture + .Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + + imageAnalyzer.setAnalyzer(ContextCompat.getMainExecutor(context)) { imageProxy -> + CoroutineScope(Dispatchers.Default).launch { + try { + val bitmap = imageProxyToBitmap(imageProxy) + val resizedBitmap = resizeBitmap(bitmap) + val faces = onDetectFaces(resizedBitmap) + + withContext(Dispatchers.Main) { + faceDetectionResult = + FaceDetectionResult( + faces = faces, + imageWidth = resizedBitmap.width, + imageHeight = resizedBitmap.height, + rotation = imageProxy.imageInfo.rotationDegrees, + ) + } + } catch (e: Exception) { + Log.e("CameraPreview", "Face detection error", e) + } finally { + imageProxy.close() + } + } + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + try { + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalyzer, + imageCapture, + ) + + previewView?.let { preview.setSurfaceProvider(it.surfaceProvider) } + } catch (e: Exception) { + Log.e("CameraPreview", "Camera binding failed", e) + } + } +} + +private fun imageProxyToBitmap(image: ImageProxy): Bitmap { + val planes = image.planes + + var bitmap: Bitmap + + // Check if it's JPEG format (from capture) or YUV format (from analysis) + if (planes.size == 1) { + // JPEG format - captured image + val buffer = planes[0].buffer + val bytes = ByteArray(buffer.remaining()) + buffer.get(bytes) + bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } else { + // YUV format - analysis frame + val yBuffer = planes[0].buffer + val uBuffer = planes[1].buffer + val vBuffer = planes[2].buffer + + val ySize = yBuffer.remaining() + val uSize = uBuffer.remaining() + val vSize = vBuffer.remaining() + + val nv21 = ByteArray(ySize + uSize + vSize) + + yBuffer.get(nv21, 0, ySize) + vBuffer.get(nv21, ySize, vSize) + uBuffer.get(nv21, ySize + vSize, uSize) + + val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null) + + val out = ByteArrayOutputStream() + yuvImage.compressToJpeg(Rect(0, 0, image.width, image.height), 100, out) + + val imageBytes = out.toByteArray() + bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + } + + // Rotate bitmap to correct orientation + val matrix = Matrix() + matrix.postRotate(image.imageInfo.rotationDegrees.toFloat()) + + bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + + return bitmap +} + +private fun resizeBitmap(bitmap: Bitmap): Bitmap { + val width = bitmap.width + val height = bitmap.height + + if (width <= ANALYSIS_IMAGE_MAX_WIDTH) { + return bitmap + } + + val aspectRatio = height.toFloat() / width.toFloat() + val newWidth = ANALYSIS_IMAGE_MAX_WIDTH + val newHeight = (newWidth * aspectRatio).toInt() + + return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) +} diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/camera/CameraTarget.kt b/sample/src/main/java/com/simprints/sample/ui/screens/camera/CameraTarget.kt new file mode 100644 index 0000000..03ef3ec --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/camera/CameraTarget.kt @@ -0,0 +1,6 @@ +package com.simprints.sample.ui.screens.camera + +enum class CameraTarget { + FACE_1, + FACE_2, +} diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraDemoScreen.kt b/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraDemoScreen.kt new file mode 100644 index 0000000..f4600c0 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraDemoScreen.kt @@ -0,0 +1,153 @@ +package com.simprints.sample.ui.screens.camera + +import android.Manifest +import android.app.Application +import android.content.pm.PackageManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.simprints.sample.di.SimFaceCameraViewModelFactory +import com.simprints.sample.ui.composables.CameraCaptureSection +import com.simprints.sample.ui.composables.ComparisonResultCard +import com.simprints.sample.ui.composables.DisplayFaceResult +import kotlinx.coroutines.launch + +@Composable +fun SimFaceCameraDemoScreen( + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val application = remember(context) { context.applicationContext as Application } + val viewModel = remember(application) { + ViewModelProvider( + context as ComponentActivity, + SimFaceCameraViewModelFactory(application), + )[SimFaceCameraViewModel::class.java] + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var showCameraPreview by remember { mutableStateOf(false) } + val snackbarScope = rememberCoroutineScope() + + LaunchedEffect(viewModel) { + viewModel.showSnackBarEffect.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + + BackHandler(enabled = showCameraPreview) { showCameraPreview = false } + + if (showCameraPreview) { + CameraPreviewScreen( + modifier = modifier, + onDetectFaces = viewModel::detectFacesForPreview, + isProcessing = uiState.isProcessing, + onImageCaptured = { + viewModel.processCapturedBitmap(it) + showCameraPreview = false + }, + onDismiss = { showCameraPreview = false }, + ) + return + } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED, + ) + } + + val permissionLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted -> + hasCameraPermission = isGranted + if (!isGranted) { + snackbarScope.launch { + snackbarHostState.showSnackbar("Camera permission is required to capture images") + } + } + } + + fun checkAndRequestCameraPermission(onPermissionGranted: () -> Unit) { + when { + hasCameraPermission -> onPermissionGranted() + else -> permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text(text = "SimFace Camera Demo", fontSize = 24.sp, fontWeight = FontWeight.Bold) + + CameraCaptureSection( + isBusy = uiState.isProcessing || uiState.isComparing, + capturedImage1 = uiState.capturedImage1, + capturedImage2 = uiState.capturedImage2, + onCaptureFace1 = { + checkAndRequestCameraPermission { + viewModel.setCameraTarget(CameraTarget.FACE_1) + showCameraPreview = true + } + }, + onCaptureFace2 = { + checkAndRequestCameraPermission { + viewModel.setCameraTarget(CameraTarget.FACE_2) + showCameraPreview = true + } + }, + onCompareCaptured = viewModel::compareCapturedFaces, + ) + + if (uiState.isProcessing || uiState.isComparing) { + CircularProgressIndicator() + Text( + text = if (uiState.isProcessing) "Processing image..." else "Comparing faces...", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + ComparisonResultCard(comparisonResult = uiState.comparisonResult) + + uiState.capturedImage1?.let { res -> + DisplayFaceResult(res, "Captured Face 1", MaterialTheme.colorScheme.tertiary) + } + + uiState.capturedImage2?.let { res -> + DisplayFaceResult(res, "Captured Face 2", MaterialTheme.colorScheme.tertiary) + } + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraUiState.kt b/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraUiState.kt new file mode 100644 index 0000000..f10f392 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraUiState.kt @@ -0,0 +1,12 @@ +package com.simprints.sample.ui.screens.camera + +import com.simprints.sample.ui.models.FaceResult + +data class SimFaceCameraUiState( + val cameraTarget: CameraTarget = CameraTarget.FACE_1, + val capturedImage1: FaceResult? = null, + val capturedImage2: FaceResult? = null, + val comparisonResult: String? = null, + val isProcessing: Boolean = false, + val isComparing: Boolean = false, +) diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraViewModel.kt b/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraViewModel.kt new file mode 100644 index 0000000..b046605 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/camera/SimFaceCameraViewModel.kt @@ -0,0 +1,159 @@ +package com.simprints.sample.ui.screens.camera + +import android.graphics.Bitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simprints.biometrics.simface.data.FaceDetection +import com.simprints.sample.ui.models.FaceResult +import com.simprints.sample.wrappers.SimFaceWrapper +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale + +class SimFaceCameraViewModel( + private val repository: SimFaceWrapper, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + private val _uiState = MutableStateFlow(SimFaceCameraUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _showSnackBar = MutableSharedFlow(extraBufferCapacity = 1) + val showSnackBarEffect: SharedFlow = _showSnackBar + + fun setCameraTarget(target: CameraTarget) { + _uiState.update { it.copy(cameraTarget = target) } + } + + fun processCapturedBitmap(bitmap: Bitmap) { + viewModelScope.launch { + _uiState.update { it.copy(isProcessing = true) } + val result = processImageFromBitmap(bitmap) + _uiState.update { + when (it.cameraTarget) { + CameraTarget.FACE_1 -> it.copy(capturedImage1 = result, isProcessing = false) + CameraTarget.FACE_2 -> it.copy(capturedImage2 = result, isProcessing = false) + } + } + if (!result.success) { + _showSnackBar.tryEmit("Capture error: ${result.message}") + } + } + } + + fun compareCapturedFaces() { + viewModelScope.launch { + _uiState.update { it.copy(isComparing = true) } + val comparisonResult = compareImages(uiState.value.capturedImage1, uiState.value.capturedImage2) + _uiState.update { it.copy(comparisonResult = comparisonResult, isComparing = false) } + if (comparisonResult.startsWith("⚠") || comparisonResult.startsWith("āŒ")) { + _showSnackBar.tryEmit(comparisonResult) + } + } + } + + suspend fun detectFacesForPreview(bitmap: Bitmap): List = repository.detectFaces(bitmap) + + private suspend fun processImageFromBitmap(bitmap: Bitmap): FaceResult = + withContext(ioDispatcher) { + try { + val faces = repository.detectFaces(bitmap) + if (faces.isEmpty()) { + return@withContext FaceResult( + bitmap = bitmap, + success = false, + message = "No faces detected", + faces = emptyList(), + embedding = null, + ) + } + + val face = faces[0] + val embedding = try { + repository.getEmbedding(face, bitmap) + } catch (_: Exception) { + null + } + + val message = buildString { + appendLine("āœ… Face detected!") + appendLine("Quality Score: ${"%.2f".format(Locale.US, face.quality)}") + appendLine("Number of faces: ${faces.size}") + appendLine("Bounding Box: ${face.absoluteBoundingBox}") + appendLine("Yaw: ${"%.1f".format(Locale.US, face.yaw)}°") + appendLine("Roll: ${"%.1f".format(Locale.US, face.roll)}°") + + if (face.quality >= 0.6) { + appendLine("\nšŸŽ‰ Quality is good!") + } else { + appendLine("\nāš ļø Quality could be better") + } + } + + FaceResult( + bitmap = bitmap, + success = true, + message = message, + faces = faces, + embedding = embedding, + ) + } catch (e: Exception) { + FaceResult( + bitmap = bitmap, + success = false, + message = "Error: ${e.message}", + faces = emptyList(), + embedding = null, + ) + } + } + + private suspend fun compareImages( + result1: FaceResult?, + result2: FaceResult?, + ): String = withContext(ioDispatcher) { + try { + if (result1 == null || result2 == null) { + return@withContext "āš ļø Please process both images first" + } + + val embedding1 = result1.embedding + val embedding2 = result2.embedding + + if (embedding1 == null || embedding2 == null) { + return@withContext "āš ļø Could not extract embeddings from one or both images" + } + + val score = repository.verificationScore(embedding1, embedding2) + val percentage = score * 100 + + buildString { + appendLine("šŸ” Face Matching Results") + appendLine("━━━━━━━━━━━━━━━━━━━━") + appendLine("Match Score: ${"%.2f".format(Locale.US, score)}") + appendLine("Match Probability: ${"%.2f".format(Locale.US, percentage)}%") + appendLine() + + when { + percentage >= 80 -> appendLine("āœ… Strong Match - Likely same person") + percentage >= 60 -> appendLine("āš ļø Moderate Match - Possibly same person") + else -> appendLine("āŒ No Match - Likely different persons") + } + } + } catch (e: Exception) { + "āŒ Error comparing images: ${e.message}" + } + } + + override fun onCleared() { + repository.release() + super.onCleared() + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageDemoScreen.kt b/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageDemoScreen.kt new file mode 100644 index 0000000..b9a9468 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageDemoScreen.kt @@ -0,0 +1,92 @@ +package com.simprints.sample.ui.screens.image + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.simprints.sample.di.SimFaceTestImageDemoViewModelFactory +import com.simprints.sample.ui.composables.ComparisonResultCard +import com.simprints.sample.ui.composables.DisplayFaceResult +import com.simprints.sample.ui.composables.TestImagesSection + +@Composable +fun SimFaceTestImageDemoScreen( + modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState, +) { + val context = LocalContext.current + val application = remember(context) { context.applicationContext as Application } + val viewModel = remember(application) { + ViewModelProvider( + context as ComponentActivity, + SimFaceTestImageDemoViewModelFactory(application), + )[SimFaceTestImageViewModel::class.java] + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel) { + viewModel.showSnackBarEffect.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text(text = "SimFace Test Images Demo", fontSize = 24.sp, fontWeight = FontWeight.Bold) + + TestImagesSection( + isBusy = uiState.isProcessing || uiState.isComparing, + result1 = uiState.result1, + result2 = uiState.result2, + result3 = uiState.result3, + onLoadObama1 = viewModel::loadTestImage1, + onLoadObama2 = viewModel::loadTestImage2, + onLoadBush = viewModel::loadTestImage3, + onLoadLowQuality = viewModel::loadTestImage4, + onCompareObamaToObama = viewModel::compareObamaWithObama, + onCompareObamaToBush = viewModel::compareObamaWithBush, + ) + + if (uiState.isProcessing || uiState.isComparing) { + CircularProgressIndicator() + Text( + text = if (uiState.isProcessing) "Processing image..." else "Comparing faces...", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + ComparisonResultCard(comparisonResult = uiState.comparisonResult) + + uiState.result1?.let { res -> DisplayFaceResult(res, "Obama 1", MaterialTheme.colorScheme.primary) } + uiState.result2?.let { res -> DisplayFaceResult(res, "Obama 2", MaterialTheme.colorScheme.secondary) } + uiState.result3?.let { res -> DisplayFaceResult(res, "Bush 1", MaterialTheme.colorScheme.tertiary) } + uiState.result4?.let { res -> DisplayFaceResult(res, "Low Quality", MaterialTheme.colorScheme.tertiary) } + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageUiState.kt b/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageUiState.kt new file mode 100644 index 0000000..ed7172b --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageUiState.kt @@ -0,0 +1,13 @@ +package com.simprints.sample.ui.screens.image + +import com.simprints.sample.ui.models.FaceResult + +data class SimFaceTestImageUiState( + val result1: FaceResult? = null, + val result2: FaceResult? = null, + val result3: FaceResult? = null, + val result4: FaceResult? = null, + val comparisonResult: String? = null, + val isProcessing: Boolean = false, + val isComparing: Boolean = false, +) diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageViewModel.kt b/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageViewModel.kt new file mode 100644 index 0000000..ba72fdc --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/image/SimFaceTestImageViewModel.kt @@ -0,0 +1,206 @@ +package com.simprints.sample.ui.screens.image + +import android.graphics.Bitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simprints.sample.R +import com.simprints.sample.ui.models.FaceResult +import com.simprints.sample.wrappers.SampleImageLoader +import com.simprints.sample.wrappers.SimFaceWrapper +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale + +class SimFaceTestImageViewModel( + private val repository: SimFaceWrapper, + private val imageLoader: SampleImageLoader, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + private val _uiState = MutableStateFlow(SimFaceTestImageUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _showSnackBar = MutableSharedFlow(extraBufferCapacity = 1) + val showSnackBarEffect: SharedFlow = _showSnackBar + + fun loadTestImage1() = loadTestImage(TestImageDemoSlot.OBAMA_1, R.drawable.obama1) + + fun loadTestImage2() = loadTestImage(TestImageDemoSlot.OBAMA_2, R.drawable.obama2) + + fun loadTestImage3() = loadTestImage(TestImageDemoSlot.BUSH, R.drawable.bush) + + fun loadTestImage4() = loadTestImage(TestImageDemoSlot.LOW_QUALITY, R.drawable.low_quality) + + fun compareObamaWithObama() { + compareTestImages(uiState.value.result1, uiState.value.result2) + } + + fun compareObamaWithBush() { + compareTestImages(uiState.value.result1, uiState.value.result3) + } + + private fun loadTestImage( + slot: TestImageDemoSlot, + imageRes: Int, + ) { + viewModelScope.launch { + _uiState.update { it.copy(isProcessing = true, comparisonResult = null) } + val result = processImage(imageRes) + _uiState.update { + when (slot) { + TestImageDemoSlot.OBAMA_1 -> it.copy(result1 = result, isProcessing = false) + TestImageDemoSlot.OBAMA_2 -> it.copy(result2 = result, isProcessing = false) + TestImageDemoSlot.BUSH -> it.copy(result3 = result, isProcessing = false) + TestImageDemoSlot.LOW_QUALITY -> it.copy(result4 = result, isProcessing = false) + } + } + if (!result.success) { + _showSnackBar.tryEmit("Image error: ${result.message}") + } + } + } + + private fun compareTestImages( + result1: FaceResult?, + result2: FaceResult?, + ) { + viewModelScope.launch { + _uiState.update { it.copy(isComparing = true) } + val comparisonResult = compareImages(result1, result2) + _uiState.update { it.copy(comparisonResult = comparisonResult, isComparing = false) } + if (comparisonResult.startsWith("⚠") || comparisonResult.startsWith("āŒ")) { + _showSnackBar.tryEmit(comparisonResult) + } + } + } + + private suspend fun processImage(imageRes: Int): FaceResult = withContext(ioDispatcher) { + try { + val bitmap = imageLoader.load(imageRes) ?: return@withContext FaceResult( + bitmap = null, + success = false, + message = "Could not decode test image", + faces = emptyList(), + embedding = null, + ) + processImageFromBitmap(bitmap) + } catch (e: Exception) { + FaceResult( + bitmap = null, + success = false, + message = "Error: ${e.message}", + faces = emptyList(), + embedding = null, + ) + } + } + + private suspend fun processImageFromBitmap(bitmap: Bitmap): FaceResult = withContext(ioDispatcher) { + try { + val faces = repository.detectFaces(bitmap) + if (faces.isEmpty()) { + return@withContext FaceResult( + bitmap = bitmap, + success = false, + message = "No faces detected", + faces = emptyList(), + embedding = null, + ) + } + + val face = faces[0] + val embedding = try { + repository.getEmbedding(face, bitmap) + } catch (_: Exception) { + null + } + + val message = buildString { + appendLine("āœ… Face detected!") + appendLine("Quality Score: ${"%.2f".format(Locale.US, face.quality)}") + appendLine("Number of faces: ${faces.size}") + appendLine("Bounding Box: ${face.absoluteBoundingBox}") + appendLine("Yaw: ${"%.1f".format(Locale.US, face.yaw)}°") + appendLine("Roll: ${"%.1f".format(Locale.US, face.roll)}°") + + if (face.quality >= 0.6) { + appendLine("\nšŸŽ‰ Quality is good!") + } else { + appendLine("\nāš ļø Quality could be better") + } + } + + FaceResult( + bitmap = bitmap, + success = true, + message = message, + faces = faces, + embedding = embedding, + ) + } catch (e: Exception) { + FaceResult( + bitmap = bitmap, + success = false, + message = "Error: ${e.message}", + faces = emptyList(), + embedding = null, + ) + } + } + + private suspend fun compareImages( + result1: FaceResult?, + result2: FaceResult?, + ): String = withContext(ioDispatcher) { + try { + if (result1 == null || result2 == null) { + return@withContext "āš ļø Please process both images first" + } + + val embedding1 = result1.embedding + val embedding2 = result2.embedding + + if (embedding1 == null || embedding2 == null) { + return@withContext "āš ļø Could not extract embeddings from one or both images" + } + + val score = repository.verificationScore(embedding1, embedding2) + val percentage = score * 100 + + buildString { + appendLine("šŸ” Face Matching Results") + appendLine("━━━━━━━━━━━━━━━━━━━━") + appendLine("Match Score: ${"%.2f".format(Locale.US, score)}") + appendLine("Match Probability: ${"%.2f".format(Locale.US, percentage)}%") + appendLine() + + when { + percentage >= 80 -> appendLine("āœ… Strong Match - Likely same person") + percentage >= 60 -> appendLine("āš ļø Moderate Match - Possibly same person") + else -> appendLine("āŒ No Match - Likely different persons") + } + } + } catch (e: Exception) { + "āŒ Error comparing images: ${e.message}" + } + } + + override fun onCleared() { + repository.release() + super.onCleared() + } +} + +private enum class TestImageDemoSlot { + OBAMA_1, + OBAMA_2, + BUSH, + LOW_QUALITY, +} diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/root/DemoTab.kt b/sample/src/main/java/com/simprints/sample/ui/screens/root/DemoTab.kt new file mode 100644 index 0000000..f48a73f --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/root/DemoTab.kt @@ -0,0 +1,6 @@ +package com.simprints.sample.ui.screens.root + +enum class DemoTab { + CAMERA, + TEST_IMAGES, +} diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/root/MainActivity.kt b/sample/src/main/java/com/simprints/sample/ui/screens/root/MainActivity.kt new file mode 100644 index 0000000..0d9ad97 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/root/MainActivity.kt @@ -0,0 +1,34 @@ +package com.simprints.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.simprints.sample.ui.screens.root.DemoTab +import com.simprints.sample.ui.screens.root.SimFaceDemoScreen +import com.simprints.sample.ui.theme.SimFaceTesterTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + val snackbarHostState = remember { SnackbarHostState() } + var selectedTab by remember { mutableStateOf(DemoTab.CAMERA) } + + SimFaceTesterTheme { + SimFaceDemoScreen( + selectedTab = selectedTab, + onSelectTab = { selectedTab = it }, + snackbarHostState = snackbarHostState, + ) + } + } + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/screens/root/SimFaceDemoScreen.kt b/sample/src/main/java/com/simprints/sample/ui/screens/root/SimFaceDemoScreen.kt new file mode 100644 index 0000000..3f19692 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/screens/root/SimFaceDemoScreen.kt @@ -0,0 +1,59 @@ +package com.simprints.sample.ui.screens.root + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.simprints.sample.ui.screens.camera.SimFaceCameraDemoScreen +import com.simprints.sample.ui.screens.image.SimFaceTestImageDemoScreen + +@Composable +fun SimFaceDemoScreen( + modifier: Modifier = Modifier, + selectedTab: DemoTab, + snackbarHostState: SnackbarHostState, + onSelectTab: (DemoTab) -> Unit, +) { + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = selectedTab == DemoTab.CAMERA, + onClick = { onSelectTab(DemoTab.CAMERA) }, + label = { Text("Camera") }, + icon = { Text("1") }, + ) + NavigationBarItem( + selected = selectedTab == DemoTab.TEST_IMAGES, + onClick = { onSelectTab(DemoTab.TEST_IMAGES) }, + label = { Text("Test Images") }, + icon = { Text("2") }, + ) + } + }, + ) { innerPadding -> + when (selectedTab) { + DemoTab.CAMERA -> { + SimFaceCameraDemoScreen( + modifier = Modifier.fillMaxSize().padding(innerPadding), + snackbarHostState = snackbarHostState, + ) + } + + DemoTab.TEST_IMAGES -> { + SimFaceTestImageDemoScreen( + modifier = Modifier.fillMaxSize().padding(innerPadding), + snackbarHostState = snackbarHostState, + ) + } + } + } +} diff --git a/sample/src/main/java/com/simprints/sample/ui/theme/Color.kt b/sample/src/main/java/com/simprints/sample/ui/theme/Color.kt new file mode 100644 index 0000000..768d87e --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.simprints.sample.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/sample/src/main/java/com/simprints/sample/ui/theme/Theme.kt b/sample/src/main/java/com/simprints/sample/ui/theme/Theme.kt new file mode 100644 index 0000000..9902559 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/theme/Theme.kt @@ -0,0 +1,46 @@ +package com.simprints.sample.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, +) + +@Composable +fun SimFaceTesterTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/sample/src/main/java/com/simprints/sample/ui/theme/Type.kt b/sample/src/main/java/com/simprints/sample/ui/theme/Type.kt new file mode 100644 index 0000000..3a04b01 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/ui/theme/Type.kt @@ -0,0 +1,18 @@ +package com.simprints.sample.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/sample/src/main/java/com/simprints/sample/wrappers/SampleImageLoader.kt b/sample/src/main/java/com/simprints/sample/wrappers/SampleImageLoader.kt new file mode 100644 index 0000000..27dc40c --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/wrappers/SampleImageLoader.kt @@ -0,0 +1,11 @@ +package com.simprints.sample.wrappers + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory + +class SampleImageLoader( + private val resources: Resources, +) { + fun load(imageRes: Int): Bitmap? = BitmapFactory.decodeResource(resources, imageRes) +} diff --git a/sample/src/main/java/com/simprints/sample/wrappers/SimFaceWrapper.kt b/sample/src/main/java/com/simprints/sample/wrappers/SimFaceWrapper.kt new file mode 100644 index 0000000..9e82f42 --- /dev/null +++ b/sample/src/main/java/com/simprints/sample/wrappers/SimFaceWrapper.kt @@ -0,0 +1,35 @@ +package com.simprints.sample.wrappers + +import android.content.Context +import android.graphics.Bitmap +import com.simprints.biometrics.simface.SimFace +import com.simprints.biometrics.simface.SimFaceConfig +import com.simprints.biometrics.simface.data.FaceDetection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SimFaceWrapper( + context: Context, +) { + private val simFace = SimFace().apply { initialize(SimFaceConfig(context)) } + + suspend fun detectFaces(bitmap: Bitmap): List = + withContext(Dispatchers.IO) { simFace.detectFaceBlocking(bitmap) } + + suspend fun getEmbedding( + face: FaceDetection, + sourceBitmap: Bitmap, + ): ByteArray? = withContext(Dispatchers.IO) { + val alignedFace = face.alignedFaceImage(sourceBitmap) + simFace.getEmbedding(alignedFace) + } + + suspend fun verificationScore( + embedding1: ByteArray, + embedding2: ByteArray, + ): Double = withContext(Dispatchers.IO) { simFace.verificationScore(embedding1, embedding2) } + + fun release() { + simFace.release() + } +} diff --git a/sample/src/main/res/drawable/bush.jpeg b/sample/src/main/res/drawable/bush.jpeg new file mode 100644 index 0000000..c89290d Binary files /dev/null and b/sample/src/main/res/drawable/bush.jpeg differ diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/drawable/ic_launcher_foreground.xml b/sample/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/androidTest/res/drawable/royalty_free_bad_face.jpg b/sample/src/main/res/drawable/low_quality.jpg similarity index 100% rename from src/androidTest/res/drawable/royalty_free_bad_face.jpg rename to sample/src/main/res/drawable/low_quality.jpg diff --git a/sample/src/main/res/drawable/obama1.jpg b/sample/src/main/res/drawable/obama1.jpg new file mode 100644 index 0000000..fcec7ed Binary files /dev/null and b/sample/src/main/res/drawable/obama1.jpg differ diff --git a/sample/src/main/res/drawable/obama2.jpg b/sample/src/main/res/drawable/obama2.jpg new file mode 100644 index 0000000..82abca6 Binary files /dev/null and b/sample/src/main/res/drawable/obama2.jpg differ diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher.webp b/sample/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher.webp b/sample/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp b/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/sample/src/main/res/values/colors.xml b/sample/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/sample/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml new file mode 100644 index 0000000..7978e2b --- /dev/null +++ b/sample/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SimFaceTester + \ No newline at end of file diff --git a/sample/src/main/res/values/themes.xml b/sample/src/main/res/values/themes.xml new file mode 100644 index 0000000..4872bf4 --- /dev/null +++ b/sample/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +