diff --git a/build.gradle.kts b/build.gradle.kts
index 08d3e95..ca425ae 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,7 +6,10 @@ plugins {
val projectGroupId = "com.simprints.biometrics"
val projectArtifactId = "simface"
-val projectVersion = "2025.3.1"
+val projectVersion = "2025.3.2"
+
+group = projectGroupId
+version = projectVersion
android {
@@ -35,6 +38,7 @@ android {
}
dependencies {
+ api(project(":simq"))
// Tensorflow versions that works with Edgeface
api(libs.tensorflow.lite.support)
@@ -47,6 +51,7 @@ dependencies {
// For face alignment
api(libs.ejml.simple)
+ androidTestImplementation(libs.truth)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.kotlinx.coroutines.test)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 893e34d..c82e9b7 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,11 @@ tensorflowLiteMetadata = "0.5.0"
tensorflowLiteSupport = "0.5.0"
faceDetection = "16.1.7"
ejmlSimple = "0.44.0"
+coreKtx = "1.17.0"
+appcompat = "1.6.1"
+material = "1.10.0"
+opencv = "4.10.0"
+truth = "1.4.5"
[libraries]
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
@@ -18,6 +23,11 @@ tensorflow-lite-metadata = { module = "org.tensorflow:tensorflow-lite-metadata",
tensorflow-lite-support = { module = "org.tensorflow:tensorflow-lite-support", version.ref = "tensorflowLiteSupport" }
face-detection = { module = "com.google.mlkit:face-detection", version.ref = "faceDetection" }
ejml-simple = { module = "org.ejml:ejml-simple", version.ref = "ejmlSimple" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+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" }
[plugins]
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 48f3186..cd8e076 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -22,3 +22,4 @@ dependencyResolutionManagement {
rootProject.name = "Biometrics-SimFace"
include(":Biometrics-SimFace")
+include(":simq")
diff --git a/simq/.gitignore b/simq/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/simq/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/simq/build.gradle.kts b/simq/build.gradle.kts
new file mode 100644
index 0000000..942f493
--- /dev/null
+++ b/simq/build.gradle.kts
@@ -0,0 +1,52 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.jetbrains.kotlin.android)
+}
+
+android {
+ namespace = "com.simprints.simq"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 23
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ api(libs.opencv)
+
+ androidTestImplementation(libs.truth)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
+}
diff --git a/simq/consumer-rules.pro b/simq/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/simq/proguard-rules.pro b/simq/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/simq/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/simq/src/androidTest/java/com/simprints/simq/AlignmentAnalysisTest.kt b/simq/src/androidTest/java/com/simprints/simq/AlignmentAnalysisTest.kt
new file mode 100644
index 0000000..225edf0
--- /dev/null
+++ b/simq/src/androidTest/java/com/simprints/simq/AlignmentAnalysisTest.kt
@@ -0,0 +1,111 @@
+package com.simprints.simq
+
+import com.google.common.truth.Truth.assertThat
+import com.simprints.simq.analysis.AlignmentAnalysis
+import org.junit.Test
+
+class AlignmentAnalysisTest {
+ private val maxAngle = 20.0
+ private val maxIndividualAngle = 25.0
+
+ @Test
+ fun perfectAlignmentReturnsScoreOf1() {
+ val score =
+ AlignmentAnalysis.calculateScore(
+ pitch = 0.0,
+ yaw = 0.0,
+ roll = 0.0,
+ maxAngle = maxAngle,
+ maxIndividualAngle = maxIndividualAngle,
+ )
+ assertThat(score).isWithin(0.001).of(1.0)
+ }
+
+ @Test
+ fun angleAtHalfMaxThresholdReturnsScoreOf0_5() {
+ val halfAngle = maxAngle / 2
+ val score =
+ AlignmentAnalysis.calculateScore(
+ pitch = halfAngle,
+ yaw = halfAngle,
+ roll = halfAngle,
+ maxAngle = maxAngle,
+ maxIndividualAngle = maxIndividualAngle,
+ )
+ assertThat(score).isWithin(0.001).of(0.5)
+ }
+
+ @Test
+ fun negativeAnglesAreTreatedAsAbsoluteValues() {
+ val score1 =
+ AlignmentAnalysis.calculateScore(
+ pitch = -10.0,
+ yaw = -10.0,
+ roll = -10.0,
+ maxAngle = maxAngle,
+ maxIndividualAngle = maxIndividualAngle,
+ )
+
+ val score2 =
+ AlignmentAnalysis.calculateScore(
+ pitch = 10.0,
+ yaw = 10.0,
+ roll = 10.0,
+ maxAngle = maxAngle,
+ maxIndividualAngle = maxIndividualAngle,
+ )
+
+ assertThat(score1).isWithin(0.001).of(score2)
+ }
+
+ @Test
+ fun anySingleAngleExceedingMaxIndividualAngleRejectsEntireScore() {
+ // Test pitch rejection
+ val pitchScore =
+ AlignmentAnalysis.calculateScore(
+ pitch = 26.0,
+ yaw = 0.0,
+ roll = 0.0,
+ maxAngle = maxAngle,
+ maxIndividualAngle = maxIndividualAngle,
+ )
+ assertThat(pitchScore).isWithin(0.001).of(0.0)
+
+ // Test yaw rejection
+ val yawScore =
+ AlignmentAnalysis.calculateScore(
+ pitch = 0.0,
+ yaw = 26.0,
+ roll = 0.0,
+ maxAngle = maxAngle,
+ maxIndividualAngle = maxIndividualAngle,
+ )
+ assertThat(yawScore).isWithin(0.001).of(0.0)
+
+ // Test roll rejection
+ val rollScore =
+ AlignmentAnalysis.calculateScore(
+ pitch = 0.0,
+ yaw = 0.0,
+ roll = 26.0,
+ maxAngle = maxAngle,
+ maxIndividualAngle = maxIndividualAngle,
+ )
+ assertThat(rollScore).isWithin(0.001).of(0.0)
+ }
+
+ @Test
+ fun mixedAnglesCalculateAverageScoreCorrectly() {
+ // Perfect pitch, half-max yaw, max roll
+ val score =
+ AlignmentAnalysis.calculateScore(
+ pitch = 0.0, // Score: 1.0
+ yaw = 10.0, // Score: 0.5 (10/20)
+ roll = 20.0, // Score: 0.0 (20/20)
+ maxAngle = maxAngle,
+ maxIndividualAngle = maxIndividualAngle,
+ )
+ // Average: (1.0 + 0.5 + 0.0) / 3 = 0.5
+ assertThat(score).isWithin(0.001).of(0.5)
+ }
+}
diff --git a/simq/src/androidTest/java/com/simprints/simq/BitmapExtTest.kt b/simq/src/androidTest/java/com/simprints/simq/BitmapExtTest.kt
new file mode 100644
index 0000000..b8e72ad
--- /dev/null
+++ b/simq/src/androidTest/java/com/simprints/simq/BitmapExtTest.kt
@@ -0,0 +1,90 @@
+package com.simprints.simq
+
+import android.graphics.Bitmap
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import com.simprints.simq.utils.centerCrop
+import com.simprints.simq.utils.resizeToArea
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BitmapExtTest {
+ @Test
+ fun centerCropBitmapWithNoDisplacementCropsCenter() {
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
+ val cropped =
+ bitmap.centerCrop(
+ centerCrop = 0.5f,
+ horizontalDisplacement = 0.0f,
+ verticalDisplacement = 0.0f,
+ )
+
+ assertThat(cropped.width).isEqualTo(50)
+ assertThat(cropped.height).isEqualTo(50)
+ }
+
+ @Test
+ fun centerCropBitmapWithFullCropReturnsSameSize() {
+ val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
+ val cropped =
+ bitmap.centerCrop(
+ centerCrop = 1.0f,
+ horizontalDisplacement = 0.0f,
+ verticalDisplacement = 0.0f,
+ )
+
+ assertThat(cropped.width).isEqualTo(100)
+ assertThat(cropped.height).isEqualTo(100)
+ }
+
+ @Test
+ fun resizeBitmapMaintainsAspectRatioForSquareBitmap() {
+ val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888)
+ val resized = bitmap.resizeToArea(targetArea = 65536.0)
+
+ assertThat(resized.width).isEqualTo(256)
+ assertThat(resized.height).isEqualTo(256)
+ }
+
+ @Test
+ fun resizeBitmapMaintainsAspectRatioForRectangularBitmap() {
+ val bitmap = Bitmap.createBitmap(800, 400, Bitmap.Config.ARGB_8888)
+ val resized = bitmap.resizeToArea(targetArea = 65536.0)
+
+ val aspectRatio = resized.width.toDouble() / resized.height.toDouble()
+ assertThat(aspectRatio).isWithin(0.01).of(2.0)
+
+ val area = resized.width * resized.height
+ assertThat(area.toDouble()).isWithin(1000.0).of(65536.0)
+ }
+
+ @Test
+ fun resizeBitmapScalesDownLargeBitmap() {
+ val bitmap = Bitmap.createBitmap(1024, 1024, Bitmap.Config.ARGB_8888)
+ val resized = bitmap.resizeToArea(targetArea = 65536.0)
+
+ assertThat(resized.width).isLessThan(bitmap.width)
+ assertThat(resized.height).isLessThan(bitmap.height)
+ }
+
+ @Test
+ fun resizeBitmapScalesUpSmallBitmap() {
+ val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888)
+ val resized = bitmap.resizeToArea(targetArea = 65536.0)
+
+ assertThat(resized.width).isGreaterThan(bitmap.width)
+ assertThat(resized.height).isGreaterThan(bitmap.height)
+ }
+
+ @Test
+ fun resizeBitmapHandlesDifferentTargetAreas() {
+ val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888)
+
+ val smallResize = bitmap.resizeToArea(targetArea = 16384.0)
+ val largeResize = bitmap.resizeToArea(targetArea = 262144.0)
+
+ assertThat(smallResize.width).isLessThan(largeResize.width)
+ assertThat(smallResize.height).isLessThan(largeResize.height)
+ }
+}
diff --git a/simq/src/androidTest/java/com/simprints/simq/QualityWeightsTest.kt b/simq/src/androidTest/java/com/simprints/simq/QualityWeightsTest.kt
new file mode 100644
index 0000000..b336242
--- /dev/null
+++ b/simq/src/androidTest/java/com/simprints/simq/QualityWeightsTest.kt
@@ -0,0 +1,44 @@
+package com.simprints.simq
+
+import android.graphics.Bitmap
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class QualityWeightsTest {
+ private fun createTestBitmap(
+ width: Int = 256,
+ height: Int = 256,
+ ): Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+
+ @Test
+ fun calculateFaceQualityWithAllWeightsMaximizingOneMetric() {
+ val bitmap = createTestBitmap()
+
+ // Only alignment weight
+ val alignmentOnlySimQ =
+ SimQ(
+ faceWeights =
+ QualityWeights(
+ alignment = 1.0,
+ blur = 0.0,
+ brightness = 0.0,
+ contrast = 0.0,
+ eyeOpenness = 0.0,
+ ),
+ )
+
+ val alignmentOnly =
+ alignmentOnlySimQ.calculateFaceQuality(
+ bitmap = bitmap,
+ pitch = 0.0,
+ yaw = 0.0,
+ roll = 0.0,
+ )
+
+ assertThat(alignmentOnly).isEqualTo(1f)
+ }
+}
diff --git a/simq/src/androidTest/java/com/simprints/simq/ScoringFunctionsTest.kt b/simq/src/androidTest/java/com/simprints/simq/ScoringFunctionsTest.kt
new file mode 100644
index 0000000..d48b0cb
--- /dev/null
+++ b/simq/src/androidTest/java/com/simprints/simq/ScoringFunctionsTest.kt
@@ -0,0 +1,153 @@
+package com.simprints.simq
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import com.simprints.simq.utils.ScoringFunctions
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ScoringFunctionsTest {
+ @Test
+ fun rampScoreReturns0WhenBelowMinimum() {
+ val score = ScoringFunctions.rampScore(x = 10.0, min = 20.0, max = 100.0)
+ assertThat(score).isWithin(0.001).of(0.0)
+ }
+
+ @Test
+ fun rampScoreReturns1WhenAboveMaximum() {
+ val score = ScoringFunctions.rampScore(x = 150.0, min = 20.0, max = 100.0)
+ assertThat(score).isWithin(0.001).of(1.0)
+ }
+
+ @Test
+ fun rampScoreReturns0_5AtMidpoint() {
+ val score = ScoringFunctions.rampScore(x = 60.0, min = 20.0, max = 100.0)
+ assertThat(score).isWithin(0.001).of(0.5)
+ }
+
+ @Test
+ fun rampScoreInterpolatesLinearly() {
+ val score = ScoringFunctions.rampScore(x = 30.0, min = 20.0, max = 120.0)
+ // (30 - 20) / (120 - 20) = 10/100 = 0.1
+ assertThat(score).isWithin(0.001).of(0.1)
+ }
+
+ @Test
+ fun plateauScoreReturns1InOptimalRange() {
+ val score1 =
+ ScoringFunctions.plateauScore(
+ x = 100.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+ assertThat(score1).isWithin(0.001).of(1.0)
+
+ val score2 =
+ ScoringFunctions.plateauScore(
+ x = 80.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+ assertThat(score2).isWithin(0.001).of(1.0)
+
+ val score3 =
+ ScoringFunctions.plateauScore(
+ x = 150.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+ assertThat(score3).isWithin(0.001).of(1.0)
+ }
+
+ @Test
+ fun plateauScoreDecreasesOutsideOptimalRange() {
+ // Below center
+ val scoreLow =
+ ScoringFunctions.plateauScore(
+ x = 50.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+ assertThat(scoreLow).isLessThan(1.0)
+ assertThat(scoreLow).isGreaterThan(0.0)
+
+ // Above center
+ val scoreHigh =
+ ScoringFunctions.plateauScore(
+ x = 170.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+ assertThat(scoreHigh).isLessThan(1.0)
+ assertThat(scoreHigh).isGreaterThan(0.0)
+ }
+
+ @Test
+ fun plateauScoreIsClampedBetween0And1() {
+ val scoreVeryLow =
+ ScoringFunctions.plateauScore(
+ x = 0.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+ assertThat(scoreVeryLow).isAtLeast(0.0)
+ assertThat(scoreVeryLow).isAtMost(1.0)
+
+ val scoreVeryHigh =
+ ScoringFunctions.plateauScore(
+ x = 300.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+ assertThat(scoreVeryHigh).isAtLeast(0.0)
+ assertThat(scoreVeryHigh).isAtMost(1.0)
+ }
+
+ @Test
+ fun plateauScoreIsSymmetricAroundOptimalRange() {
+ val scoreLow =
+ ScoringFunctions.plateauScore(
+ x = 50.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+
+ val scoreHigh =
+ ScoringFunctions.plateauScore(
+ x = 180.0,
+ centerLow = 80.0,
+ centerHigh = 150.0,
+ edgeLow = 30.0,
+ edgeHigh = 190.0,
+ steepness = 0.3,
+ )
+
+ // Should be approximately equal due to symmetry
+ assertThat(scoreHigh).isWithin(0.1).of(scoreLow)
+ }
+}
diff --git a/simq/src/androidTest/java/com/simprints/simq/SimQTest.kt b/simq/src/androidTest/java/com/simprints/simq/SimQTest.kt
new file mode 100644
index 0000000..6da3dd6
--- /dev/null
+++ b/simq/src/androidTest/java/com/simprints/simq/SimQTest.kt
@@ -0,0 +1,271 @@
+package com.simprints.simq
+
+import android.graphics.Bitmap
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.collect.Range
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SimQTest {
+ private lateinit var simQ: SimQ
+
+ @Before
+ fun setUp() {
+ simQ = SimQ()
+ }
+
+ private fun createTestBitmap(
+ width: Int = 256,
+ height: Int = 256,
+ ): Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+
+ @Test
+ fun calculateFaceQualityReturnsValueBetween0And1() {
+ val bitmap = createTestBitmap()
+ val quality = simQ.calculateFaceQuality(bitmap)
+
+ assertThat(quality).isAtLeast(0.0f)
+ assertThat(quality).isAtMost(1.0f)
+ }
+
+ @Test
+ fun calculateFaceQualityWithPerfectAlignmentReturnsHigherScore() {
+ val bitmap = createTestBitmap()
+
+ val perfectScore =
+ simQ.calculateFaceQuality(
+ bitmap = bitmap,
+ pitch = 0.0,
+ yaw = 0.0,
+ roll = 0.0,
+ )
+
+ val poorAlignmentScore =
+ simQ.calculateFaceQuality(
+ bitmap = bitmap,
+ pitch = 20.0,
+ yaw = 20.0,
+ roll = 20.0,
+ )
+
+ assertThat(perfectScore).isAtLeast(poorAlignmentScore)
+ }
+
+ @Test
+ fun calculateFaceQualityWithExtremeAnglesReturnsLowScore() {
+ val bitmap = createTestBitmap()
+
+ val quality =
+ simQ.calculateFaceQuality(
+ bitmap = bitmap,
+ pitch = 30.0,
+ yaw = 0.0,
+ roll = 0.0,
+ )
+
+ assertThat(quality).isLessThan(0.5f)
+ }
+
+ @Test
+ fun calculateFaceQualityWithEyeOpennessIncludesItInCalculation() {
+ val bitmap = createTestBitmap()
+ val simQWithEyeWeight =
+ SimQ(
+ faceWeights =
+ QualityWeights(
+ alignment = 0.3,
+ blur = 0.2,
+ brightness = 0.2,
+ contrast = 0.1,
+ eyeOpenness = 0.2,
+ ),
+ )
+
+ val openEyesScore =
+ simQWithEyeWeight.calculateFaceQuality(
+ bitmap = bitmap,
+ leftEyeOpenness = 1.0,
+ rightEyeOpenness = 1.0,
+ )
+
+ val closedEyesScore =
+ simQWithEyeWeight.calculateFaceQuality(
+ bitmap = bitmap,
+ leftEyeOpenness = 0.0,
+ rightEyeOpenness = 0.0,
+ )
+
+ assertThat(openEyesScore).isAtLeast(closedEyesScore)
+ }
+
+ @Test
+ fun calculateFaceQualityWithoutEyeOpennessIgnoresEyeWeight() {
+ val bitmap = createTestBitmap()
+ val simQWithEyeWeight = SimQ(faceWeights = QualityWeights(eyeOpenness = 0.2))
+
+ val qualityNoEyes =
+ simQWithEyeWeight.calculateFaceQuality(
+ bitmap = bitmap,
+ leftEyeOpenness = null,
+ rightEyeOpenness = null,
+ )
+
+ assertThat(qualityNoEyes).isAtLeast(0.0f)
+ assertThat(qualityNoEyes).isAtMost(1.0f)
+ }
+
+ @Test
+ fun calculateFaceQualityWithOnlyLeftEyeOpennessProvided() {
+ val bitmap = createTestBitmap()
+ val simQWithEyeWeight = SimQ(faceWeights = QualityWeights(eyeOpenness = 0.2))
+
+ val quality =
+ simQWithEyeWeight.calculateFaceQuality(
+ bitmap = bitmap,
+ leftEyeOpenness = 1.0,
+ rightEyeOpenness = null,
+ )
+
+ assertThat(quality).isIn(Range.closed(0.0f, 1.0f))
+ }
+
+ @Test
+ fun calculateFaceQualityWithCustomWeightsAffectsResult() {
+ val bitmap = createTestBitmap()
+ val alignmentWeightedSimQ =
+ SimQ(
+ faceWeights =
+ QualityWeights(
+ alignment = 0.9,
+ blur = 0.025,
+ brightness = 0.025,
+ contrast = 0.025,
+ eyeOpenness = 0.025,
+ ),
+ )
+ val otherWeightedSimQ =
+ SimQ(
+ faceWeights =
+ QualityWeights(
+ alignment = 0.025,
+ blur = 0.325,
+ brightness = 0.325,
+ contrast = 0.325,
+ eyeOpenness = 0.0,
+ ),
+ )
+
+ val alignmentWeighted =
+ alignmentWeightedSimQ.calculateFaceQuality(
+ bitmap = bitmap,
+ pitch = 20.0,
+ yaw = 20.0,
+ roll = 20.0,
+ )
+
+ val otherWeighted =
+ otherWeightedSimQ.calculateFaceQuality(
+ bitmap = bitmap,
+ pitch = 20.0,
+ yaw = 20.0,
+ roll = 20.0,
+ )
+
+ assertThat(alignmentWeighted).isLessThan(otherWeighted)
+ }
+
+ @Test
+ fun calculateFaceQualityWithCustomParametersAffectsThresholds() {
+ val bitmap = createTestBitmap()
+ val strictSimQ =
+ SimQ(
+ faceParameters =
+ QualityParameters(
+ maxAlignmentAngle = 10.0,
+ maxIndividualAngle = 20.0,
+ ),
+ )
+ val lenientSimQ =
+ SimQ(
+ faceParameters =
+ QualityParameters(
+ maxAlignmentAngle = 30.0,
+ maxIndividualAngle = 40.0,
+ ),
+ )
+
+ val strictQuality =
+ strictSimQ.calculateFaceQuality(
+ bitmap = bitmap,
+ pitch = 15.0,
+ yaw = 15.0,
+ roll = 15.0,
+ )
+
+ val lenientQuality =
+ lenientSimQ.calculateFaceQuality(
+ bitmap = bitmap,
+ pitch = 15.0,
+ yaw = 15.0,
+ roll = 15.0,
+ )
+
+ assertThat(lenientQuality).isAtLeast(strictQuality)
+ }
+
+ @Test
+ fun calculateFaceQualityHandlesDifferentBitmapSizes() {
+ val smallBitmap = createTestBitmap(64, 64)
+ val mediumBitmap = createTestBitmap(256, 256)
+ val largeBitmap = createTestBitmap(1024, 1024)
+
+ val smallQuality = simQ.calculateFaceQuality(smallBitmap)
+ val mediumQuality = simQ.calculateFaceQuality(mediumBitmap)
+ val largeQuality = simQ.calculateFaceQuality(largeBitmap)
+
+ assertThat(smallQuality).isIn(Range.closed(0.0f, 1.0f))
+ assertThat(mediumQuality).isIn(Range.closed(0.0f, 1.0f))
+ assertThat(largeQuality).isIn(Range.closed(0.0f, 1.0f))
+ }
+
+ @Test
+ fun calculateFaceQualityWithDefaultParametersReturnsReasonableScore() {
+ val bitmap = createTestBitmap()
+ val quality = simQ.calculateFaceQuality(bitmap)
+
+ assertThat(quality).isIn(Range.closed(0.0f, 1.0f))
+ }
+
+ @Test
+ fun calculateFaceQualityIsConsistentWithSameInputs() {
+ val bitmap = createTestBitmap()
+
+ val quality1 = simQ.calculateFaceQuality(bitmap, pitch = 10.0, yaw = 5.0, roll = -3.0)
+ val quality2 = simQ.calculateFaceQuality(bitmap, pitch = 10.0, yaw = 5.0, roll = -3.0)
+
+ assertThat(quality1).isWithin(0.001f).of(quality2)
+ }
+
+ @Test
+ fun calculateFaceQualityWithZeroWeightsReturnsZeroOrHandledGracefully() {
+ val bitmap = createTestBitmap()
+ val zeroWeightsSimQ =
+ SimQ(
+ faceWeights =
+ QualityWeights(
+ alignment = 0.0,
+ blur = 0.0,
+ brightness = 0.0,
+ contrast = 0.0,
+ eyeOpenness = 0.0,
+ ),
+ )
+
+ val quality = zeroWeightsSimQ.calculateFaceQuality(bitmap = bitmap)
+
+ assertThat(quality).isIn(Range.closed(0.0f, 1.0f))
+ }
+}
diff --git a/simq/src/main/AndroidManifest.xml b/simq/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/simq/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/simq/src/main/java/com/simprints/simq/QualityParameters.kt b/simq/src/main/java/com/simprints/simq/QualityParameters.kt
new file mode 100644
index 0000000..50a6282
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/QualityParameters.kt
@@ -0,0 +1,23 @@
+package com.simprints.simq
+
+data class QualityParameters(
+ // Alignment thresholds
+ val maxAlignmentAngle: Double = 20.0,
+ val maxIndividualAngle: Double = 25.0,
+ // Blur thresholds (Laplacian variance)
+ val minBlur: Double = 50_000.0,
+ val maxBlur: Double = 100_000.0,
+ // Brightness thresholds (0-255)
+ val minBrightness: Double = 30.0,
+ val optimalBrightnessLow: Double = 80.0,
+ val optimalBrightnessHigh: Double = 150.0,
+ val maxBrightness: Double = 190.0,
+ val brightnessSteepness: Double = 0.3,
+ // Contrast thresholds (std dev)
+ val minContrast: Double = 30.0,
+ val maxContrast: Double = 47.0,
+) {
+ companion object {
+ val DEFAULT = QualityParameters()
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/QualityWeights.kt b/simq/src/main/java/com/simprints/simq/QualityWeights.kt
new file mode 100644
index 0000000..8db36b0
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/QualityWeights.kt
@@ -0,0 +1,17 @@
+package com.simprints.simq
+
+/**
+ * Default quality weights for face assessment. These control how much each metric contributes to
+ * the final score.
+ */
+data class QualityWeights(
+ val alignment: Double = 0.28,
+ val blur: Double = 0.3,
+ val brightness: Double = 0.3,
+ val contrast: Double = 0.1,
+ val eyeOpenness: Double = 0.02,
+) {
+ companion object {
+ val DEFAULT = QualityWeights()
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/SimQ.kt b/simq/src/main/java/com/simprints/simq/SimQ.kt
new file mode 100644
index 0000000..c6e04f1
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/SimQ.kt
@@ -0,0 +1,126 @@
+package com.simprints.simq
+
+import android.graphics.Bitmap
+import com.simprints.simq.analysis.AlignmentAnalysis
+import com.simprints.simq.analysis.BlurAnalysis
+import com.simprints.simq.analysis.BrightnessAnalysis
+import com.simprints.simq.analysis.ContrastAnalysis
+import com.simprints.simq.utils.OpenCVLoader
+import com.simprints.simq.utils.centerCrop
+import com.simprints.simq.utils.resizeToArea
+
+class SimQ(
+ private val faceWeights: QualityWeights = QualityWeights.DEFAULT,
+ private val faceParameters: QualityParameters = QualityParameters.DEFAULT,
+) {
+ init {
+ OpenCVLoader.init()
+ }
+
+ /**
+ * Calculates face quality score (0.0 - 1.0).
+ *
+ * @param bitmap The cropped face bitmap
+ * @param pitch Face pitch angle in degrees (head nod, default: 0.0)
+ * @param yaw Face yaw angle in degrees (head rotation, default: 0.0)
+ * @param roll Face roll angle in degrees (head tilt, default: 0.0)
+ * @param leftEyeOpenness Left eye openness probability (0.0-1.0, optional)
+ * @param rightEyeOpenness Right eye openness probability (0.0-1.0, optional)
+ * @param centerCrop Fraction of the bitmap to use for quality assessment (default: 0.5)
+ * @param horizontalDisplacement Horizontal displacement for center crop (default: 0.0)
+ * @param verticalDisplacement Vertical displacement for center crop (default: 0.0)
+ * @return Quality score between 0.0 and 1.0, or 0.0 if calculation fails
+ */
+ fun calculateFaceQuality(
+ bitmap: Bitmap,
+ pitch: Double = 0.0,
+ yaw: Double = 0.0,
+ roll: Double = 0.0,
+ leftEyeOpenness: Double? = null,
+ rightEyeOpenness: Double? = null,
+ centerCrop: Float = 0.5f,
+ horizontalDisplacement: Float = 0.0f,
+ verticalDisplacement: Float = 0.0f,
+ ): Float = try {
+ // Resize bitmap to target area (256x256 = 65536)
+ val resizedBitmap = bitmap.resizeToArea(65536.0)
+
+ // Crop the bitmap
+ val croppedBitmap =
+ resizedBitmap.centerCrop(
+ centerCrop,
+ horizontalDisplacement,
+ verticalDisplacement,
+ )
+
+ var totalScore = 0.0
+ val totalWeight =
+ faceWeights.alignment +
+ faceWeights.blur +
+ faceWeights.brightness +
+ faceWeights.contrast +
+ (
+ if (leftEyeOpenness != null && rightEyeOpenness != null) {
+ faceWeights.eyeOpenness
+ } else {
+ 0.0
+ }
+ )
+
+ val alignmentScore =
+ AlignmentAnalysis.calculateScore(
+ pitch,
+ yaw,
+ roll,
+ faceParameters.maxAlignmentAngle,
+ faceParameters.maxIndividualAngle,
+ )
+ totalScore += faceWeights.alignment * alignmentScore
+
+ val blurScore =
+ BlurAnalysis.calculateScore(
+ croppedBitmap,
+ faceParameters.minBlur,
+ faceParameters.maxBlur,
+ )
+ totalScore += faceWeights.blur * blurScore
+
+ val brightnessScore =
+ BrightnessAnalysis.calculateScore(
+ croppedBitmap,
+ faceParameters.minBrightness,
+ faceParameters.optimalBrightnessLow,
+ faceParameters.optimalBrightnessHigh,
+ faceParameters.maxBrightness,
+ faceParameters.brightnessSteepness,
+ )
+ totalScore += faceWeights.brightness * brightnessScore
+
+ val contrastScore =
+ ContrastAnalysis.calculateScore(
+ croppedBitmap,
+ faceParameters.minContrast,
+ faceParameters.maxContrast,
+ )
+ totalScore += faceWeights.contrast * contrastScore
+
+ if (leftEyeOpenness != null && rightEyeOpenness != null) {
+ val eyeScore = (leftEyeOpenness + rightEyeOpenness) / 2.0
+ totalScore += faceWeights.eyeOpenness * eyeScore
+ }
+
+ // Clean up
+ if (croppedBitmap != bitmap && croppedBitmap != resizedBitmap) {
+ croppedBitmap.recycle()
+ }
+ if (resizedBitmap != bitmap) {
+ resizedBitmap.recycle()
+ }
+
+ // Normalize and clamp to 0-1 range
+ val finalScore = if (totalWeight > 0) totalScore / totalWeight else 0.0
+ finalScore.coerceIn(0.0, 1.0).toFloat()
+ } catch (e: Exception) {
+ 0.0f
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/analysis/AlignmentAnalysis.kt b/simq/src/main/java/com/simprints/simq/analysis/AlignmentAnalysis.kt
new file mode 100644
index 0000000..f7cc8ac
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/analysis/AlignmentAnalysis.kt
@@ -0,0 +1,37 @@
+package com.simprints.simq.analysis
+
+import kotlin.math.absoluteValue
+
+internal object AlignmentAnalysis {
+ /**
+ * Calculates alignment score based on pitch, yaw, and roll angles.
+ *
+ * @param pitch Face pitch angle in degrees (head nod)
+ * @param yaw Face yaw angle in degrees (head rotation)
+ * @param roll Face roll angle in degrees (head tilt)
+ * @param maxAngle Maximum acceptable angle for quality scoring
+ * @param maxIndividualAngle Absolute maximum angle before rejection
+ * @return Alignment score between 0.0 and 1.0
+ */
+ fun calculateScore(
+ pitch: Double,
+ yaw: Double,
+ roll: Double,
+ maxAngle: Double,
+ maxIndividualAngle: Double,
+ ): Double {
+ // Reject if any angle is too extreme
+ if (pitch.absoluteValue > maxIndividualAngle ||
+ yaw.absoluteValue > maxIndividualAngle ||
+ roll.absoluteValue > maxIndividualAngle
+ ) {
+ return 0.0
+ }
+
+ val pitchScore = maxOf(0.0, 1.0 - (pitch.absoluteValue / maxAngle))
+ val yawScore = maxOf(0.0, 1.0 - (yaw.absoluteValue / maxAngle))
+ val rollScore = maxOf(0.0, 1.0 - (roll.absoluteValue / maxAngle))
+
+ return (pitchScore + yawScore + rollScore) / 3.0
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/analysis/BlurAnalysis.kt b/simq/src/main/java/com/simprints/simq/analysis/BlurAnalysis.kt
new file mode 100644
index 0000000..1560398
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/analysis/BlurAnalysis.kt
@@ -0,0 +1,29 @@
+package com.simprints.simq.analysis
+
+import android.graphics.Bitmap
+import com.simprints.simq.utils.ImageAnalyzer
+import com.simprints.simq.utils.OpenCVImageAnalyzer
+import com.simprints.simq.utils.ScoringFunctions
+
+internal object BlurAnalysis {
+ private var imageAnalyzer: ImageAnalyzer = OpenCVImageAnalyzer()
+
+ /**
+ * Calculates blur score using Laplacian variance.
+ *
+ * @param bitmap The face image to analyze
+ * @param minBlur Minimum acceptable blur threshold
+ * @param maxBlur Maximum blur threshold for optimal score
+ * @return Blur score between 0.0 and 1.0
+ */
+ fun calculateScore(
+ bitmap: Bitmap,
+ minBlur: Double,
+ maxBlur: Double,
+ ): Double = try {
+ val laplacianVariance = imageAnalyzer.calculateLaplacianVariance(bitmap)
+ ScoringFunctions.rampScore(laplacianVariance, minBlur, maxBlur)
+ } catch (e: Exception) {
+ 1.0 // Default to good score if OpenCV not available
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/analysis/BrightnessAnalysis.kt b/simq/src/main/java/com/simprints/simq/analysis/BrightnessAnalysis.kt
new file mode 100644
index 0000000..429da96
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/analysis/BrightnessAnalysis.kt
@@ -0,0 +1,42 @@
+package com.simprints.simq.analysis
+
+import android.graphics.Bitmap
+import com.simprints.simq.utils.ImageAnalyzer
+import com.simprints.simq.utils.OpenCVImageAnalyzer
+import com.simprints.simq.utils.ScoringFunctions
+
+internal object BrightnessAnalysis {
+ private var imageAnalyzer: ImageAnalyzer = OpenCVImageAnalyzer()
+
+ /**
+ * Calculates brightness score using plateau function.
+ *
+ * @param bitmap The face image to analyze
+ * @param edgeLow Lower edge threshold (minimum acceptable brightness)
+ * @param centerLow Lower center threshold (start of optimal range)
+ * @param centerHigh Upper center threshold (end of optimal range)
+ * @param edgeHigh Upper edge threshold (maximum acceptable brightness)
+ * @param steepness Steepness of the sigmoid falloff
+ * @return Brightness score between 0.0 and 1.0
+ */
+ fun calculateScore(
+ bitmap: Bitmap,
+ edgeLow: Double,
+ centerLow: Double,
+ centerHigh: Double,
+ edgeHigh: Double,
+ steepness: Double,
+ ): Double = try {
+ val brightness = imageAnalyzer.calculateBrightness(bitmap)
+ ScoringFunctions.plateauScore(
+ brightness,
+ centerLow,
+ centerHigh,
+ edgeLow,
+ edgeHigh,
+ steepness,
+ )
+ } catch (e: Exception) {
+ 1.0
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/analysis/ContrastAnalysis.kt b/simq/src/main/java/com/simprints/simq/analysis/ContrastAnalysis.kt
new file mode 100644
index 0000000..6a1ee50
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/analysis/ContrastAnalysis.kt
@@ -0,0 +1,29 @@
+package com.simprints.simq.analysis
+
+import android.graphics.Bitmap
+import com.simprints.simq.utils.ImageAnalyzer
+import com.simprints.simq.utils.OpenCVImageAnalyzer
+import com.simprints.simq.utils.ScoringFunctions
+
+internal object ContrastAnalysis {
+ private var imageAnalyzer: ImageAnalyzer = OpenCVImageAnalyzer()
+
+ /**
+ * Calculates contrast score using standard deviation.
+ *
+ * @param bitmap The face image to analyze
+ * @param minContrast Minimum acceptable contrast threshold
+ * @param maxContrast Maximum contrast threshold for optimal score
+ * @return Contrast score between 0.0 and 1.0
+ */
+ fun calculateScore(
+ bitmap: Bitmap,
+ minContrast: Double,
+ maxContrast: Double,
+ ): Double = try {
+ val contrast = imageAnalyzer.calculateContrast(bitmap)
+ ScoringFunctions.rampScore(contrast, minContrast, maxContrast)
+ } catch (e: Exception) {
+ 1.0 // Default to good score if OpenCV not available
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/utils/BitmapExt.kt b/simq/src/main/java/com/simprints/simq/utils/BitmapExt.kt
new file mode 100644
index 0000000..2d318f6
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/utils/BitmapExt.kt
@@ -0,0 +1,43 @@
+package com.simprints.simq.utils
+
+import android.graphics.Bitmap
+import kotlin.math.roundToInt
+import kotlin.math.sqrt
+
+/**
+ * Crops bitmap to center region with optional displacement.
+ *
+ * @param centerCrop Fraction of the bitmap to use (0.0-1.0)
+ * @param horizontalDisplacement Horizontal displacement factor (-1.0 to 1.0)
+ * @param verticalDisplacement Vertical displacement factor (-1.0 to 1.0)
+ * @return Cropped bitmap
+ */
+internal fun Bitmap.centerCrop(
+ centerCrop: Float,
+ horizontalDisplacement: Float = 0f,
+ verticalDisplacement: Float = 0f,
+): Bitmap {
+ val hAbsDisplacement = (width * horizontalDisplacement).toInt()
+ val vAbsDisplacement = (height * verticalDisplacement).toInt()
+
+ val cropWidth = (width * centerCrop).toInt()
+ val cropHeight = (height * centerCrop).toInt()
+ val startX = hAbsDisplacement + (width - cropWidth) / 2
+ val startY = vAbsDisplacement + (height - cropHeight) / 2
+
+ return Bitmap.createBitmap(this, startX, startY, cropWidth, cropHeight)
+}
+
+/**
+ * Resizes bitmap to a target area while maintaining aspect ratio.
+ *
+ * @param targetArea Target area in pixels (default: 65536 = 256x256)
+ * @return Resized bitmap
+ */
+internal fun Bitmap.resizeToArea(targetArea: Double = 65536.0): Bitmap {
+ val aspectRatio = width.toFloat() / height.toFloat()
+ val newHeight = sqrt(targetArea / aspectRatio)
+ val newWidth = aspectRatio * newHeight
+
+ return Bitmap.createScaledBitmap(this, newWidth.roundToInt(), newHeight.roundToInt(), true)
+}
diff --git a/simq/src/main/java/com/simprints/simq/utils/ImageAnalyzer.kt b/simq/src/main/java/com/simprints/simq/utils/ImageAnalyzer.kt
new file mode 100644
index 0000000..ab1afa1
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/utils/ImageAnalyzer.kt
@@ -0,0 +1,103 @@
+package com.simprints.simq.utils
+
+import android.graphics.Bitmap
+import org.opencv.android.Utils
+import org.opencv.core.Core
+import org.opencv.core.CvType
+import org.opencv.core.Mat
+import org.opencv.core.MatOfDouble
+import org.opencv.imgproc.Imgproc
+import kotlin.math.pow
+
+/**
+ * Interface for image quality analysis operations. Provides high-level methods for analyzing image
+ * properties like brightness, blur, and contrast.
+ */
+interface ImageAnalyzer {
+ /**
+ * Calculates the mean brightness value of a bitmap image.
+ *
+ * @param bitmap The input bitmap image
+ * @return Mean brightness value (0-255)
+ */
+ fun calculateBrightness(bitmap: Bitmap): Double
+
+ /**
+ * Calculates the Laplacian variance to measure image sharpness/blur. Higher values indicate
+ * sharper images, lower values indicate more blur.
+ *
+ * @param bitmap The input bitmap image
+ * @param kernelSize Kernel size for the Laplacian operator (default: 5)
+ * @return Laplacian variance value
+ */
+ fun calculateLaplacianVariance(
+ bitmap: Bitmap,
+ kernelSize: Int = 5,
+ ): Double
+
+ /**
+ * Calculates the standard deviation to measure image contrast. Higher values indicate higher
+ * contrast.
+ *
+ * @param bitmap The input bitmap image
+ * @return Standard deviation value representing contrast
+ */
+ fun calculateContrast(bitmap: Bitmap): Double
+}
+
+internal class OpenCVImageAnalyzer : ImageAnalyzer {
+ override fun calculateBrightness(bitmap: Bitmap): Double {
+ val mat = Mat()
+ val gray = Mat()
+ try {
+ Utils.bitmapToMat(bitmap, mat)
+ Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY)
+ return Core.mean(gray).`val`[0]
+ } finally {
+ mat.release()
+ gray.release()
+ }
+ }
+
+ override fun calculateLaplacianVariance(
+ bitmap: Bitmap,
+ kernelSize: Int,
+ ): Double {
+ val mat = Mat()
+ val gray = Mat()
+ val laplacian = Mat()
+ val mean = MatOfDouble()
+ val stddev = MatOfDouble()
+ try {
+ Utils.bitmapToMat(bitmap, mat)
+ Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY)
+ Imgproc.Laplacian(gray, laplacian, CvType.CV_64F, kernelSize)
+ Core.meanStdDev(laplacian, mean, stddev)
+ return stddev.toArray()[0].pow(2.0)
+ } finally {
+ mat.release()
+ gray.release()
+ laplacian.release()
+ mean.release()
+ stddev.release()
+ }
+ }
+
+ override fun calculateContrast(bitmap: Bitmap): Double {
+ val mat = Mat()
+ val gray = Mat()
+ val mean = MatOfDouble()
+ val stddev = MatOfDouble()
+ try {
+ Utils.bitmapToMat(bitmap, mat)
+ Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY)
+ Core.meanStdDev(gray, mean, stddev)
+ return stddev.toArray()[0]
+ } finally {
+ mat.release()
+ gray.release()
+ mean.release()
+ stddev.release()
+ }
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/utils/OpenCVLoader.kt b/simq/src/main/java/com/simprints/simq/utils/OpenCVLoader.kt
new file mode 100644
index 0000000..34b418f
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/utils/OpenCVLoader.kt
@@ -0,0 +1,20 @@
+package com.simprints.simq.utils
+
+import android.util.Log
+import org.opencv.android.OpenCVLoader as AndroidOpenCVLoader
+
+/**
+ * Singleton object to handle OpenCV library loading. Ensures the native library is loaded only once
+ * during the application lifecycle.
+ */
+internal object OpenCVLoader {
+ private const val TAG = "OpenCV"
+
+ fun init() {
+ if (!AndroidOpenCVLoader.initLocal()) {
+ Log.e(TAG, "OpenCV not loaded!")
+ } else {
+ Log.d(TAG, "OpenCV loaded successfully!")
+ }
+ }
+}
diff --git a/simq/src/main/java/com/simprints/simq/utils/ScoringFunctions.kt b/simq/src/main/java/com/simprints/simq/utils/ScoringFunctions.kt
new file mode 100644
index 0000000..6b199f6
--- /dev/null
+++ b/simq/src/main/java/com/simprints/simq/utils/ScoringFunctions.kt
@@ -0,0 +1,47 @@
+package com.simprints.simq.utils
+
+import kotlin.math.exp
+
+internal object ScoringFunctions {
+ /**
+ * Ramp scoring function: linear interpolation between min and max.
+ *
+ * @param x The input value to score
+ * @param min Minimum threshold (scores 0.0 below this)
+ * @param max Maximum threshold (scores 1.0 above this)
+ * @return Score between 0.0 and 1.0
+ */
+ fun rampScore(
+ x: Double,
+ min: Double,
+ max: Double,
+ ): Double = when {
+ x < min -> 0.0
+ x > max -> 1.0
+ else -> (x - min) / (max - min)
+ }
+
+ /**
+ * Plateau scoring function: optimal range with smooth sigmoid falloff.
+ *
+ * @param x The input value to score
+ * @param centerLow Lower bound of optimal range
+ * @param centerHigh Upper bound of optimal range
+ * @param edgeLow Lower edge threshold
+ * @param edgeHigh Upper edge threshold
+ * @param steepness Steepness of sigmoid falloff
+ * @return Score between 0.0 and 1.0
+ */
+ fun plateauScore(
+ x: Double,
+ centerLow: Double,
+ centerHigh: Double,
+ edgeLow: Double,
+ edgeHigh: Double,
+ steepness: Double,
+ ): Double = when {
+ x in centerLow..centerHigh -> 1.0
+ x < centerLow -> 1.0 / (1.0 + exp(-steepness * (x - edgeLow)))
+ else -> 1.0 / (1.0 + exp(steepness * (x - edgeHigh)))
+ }.coerceIn(0.0, 1.0)
+}
diff --git a/src/androidTest/java/com/simprints/biometrics/simface/detection/FaceAlignTest.kt b/src/androidTest/java/com/simprints/biometrics/simface/detection/FaceAlignTest.kt
index a2aa060..9f746a8 100644
--- a/src/androidTest/java/com/simprints/biometrics/simface/detection/FaceAlignTest.kt
+++ b/src/androidTest/java/com/simprints/biometrics/simface/detection/FaceAlignTest.kt
@@ -5,6 +5,7 @@ import android.graphics.Bitmap
import android.graphics.Rect
import androidx.test.core.app.*
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
import com.simprints.biometrics.loadBitmapFromTestResources
import com.simprints.biometrics.simface.Constants
import com.simprints.biometrics.simface.SimFace
@@ -13,7 +14,6 @@ import com.simprints.biometrics.simface.data.FaceDetection
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.After
-import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -42,8 +42,8 @@ class FaceAlignTest {
val croppedBitmap = cropAlignFace(bitmap, boundingBox)
- assertTrue(boundingBox.width() == croppedBitmap.width)
- assertTrue(boundingBox.height() == croppedBitmap.height)
+ assertThat(croppedBitmap.width).isEqualTo(boundingBox.width())
+ assertThat(croppedBitmap.height).isEqualTo(boundingBox.height())
}
@Test(expected = IllegalArgumentException::class)
@@ -71,19 +71,15 @@ class FaceAlignTest {
})
val faces = resultDeferred.await()
- assertTrue(faces.isNotEmpty())
+ assertThat(faces).isNotEmpty()
val face = faces[0]
val warpedAlignedImage =
face.landmarks?.let { warpAlignFace(bitmap, it) }
- assertTrue(warpedAlignedImage != null)
+ assertThat(warpedAlignedImage).isNotNull()
- if (warpedAlignedImage != null) {
- assertTrue(warpedAlignedImage.width == Constants.IMAGE_SIZE)
- }
- if (warpedAlignedImage != null) {
- assertTrue(warpedAlignedImage.height == Constants.IMAGE_SIZE)
- }
+ assertThat(warpedAlignedImage!!.width).isEqualTo(Constants.IMAGE_SIZE)
+ assertThat(warpedAlignedImage.height).isEqualTo(Constants.IMAGE_SIZE)
}
}
diff --git a/src/androidTest/java/com/simprints/biometrics/simface/detection/FaceDetectionProcessorTest.kt b/src/androidTest/java/com/simprints/biometrics/simface/detection/FaceDetectionProcessorTest.kt
index da0f188..7175886 100644
--- a/src/androidTest/java/com/simprints/biometrics/simface/detection/FaceDetectionProcessorTest.kt
+++ b/src/androidTest/java/com/simprints/biometrics/simface/detection/FaceDetectionProcessorTest.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Bitmap
import androidx.test.core.app.*
import androidx.test.ext.junit.runners.*
+import com.google.common.truth.Truth.assertThat
import com.simprints.biometrics.loadBitmapFromTestResources
import com.simprints.biometrics.simface.SimFace
import com.simprints.biometrics.simface.SimFaceConfig
@@ -11,8 +12,6 @@ import com.simprints.biometrics.simface.data.FaceDetection
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.After
-import org.junit.Assert
-import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -46,9 +45,9 @@ class FaceDetectionProcessorTest {
})
val faces = resultDeferred.await()
- assertTrue(faces.isNotEmpty())
+ assertThat(faces).isNotEmpty()
val face = faces[0]
- assertTrue(face.quality > 0.5)
+ assertThat(face.quality).isGreaterThan(0.5f)
}
@Test
@@ -63,9 +62,9 @@ class FaceDetectionProcessorTest {
})
val faces = resultDeferred.await()
- assertTrue(faces.isNotEmpty())
+ assertThat(faces).isNotEmpty()
val face = faces[0]
- assertTrue(face.quality < 0.5)
+ assertThat(face.quality).isLessThan(0.5f)
}
@Test
@@ -80,7 +79,7 @@ class FaceDetectionProcessorTest {
})
val faces = resultDeferred.await()
- assertTrue(faces.isEmpty())
+ assertThat(faces).isEmpty()
}
@Test
@@ -95,7 +94,7 @@ class FaceDetectionProcessorTest {
})
val faces = resultDeferred.await()
- assertTrue(faces.size == 5)
+ assertThat(faces).hasSize(5)
}
@Test
@@ -103,9 +102,9 @@ class FaceDetectionProcessorTest {
val bitmap: Bitmap = context.loadBitmapFromTestResources("royalty_free_good_face")
val faces = simFace.detectFaceBlocking(bitmap)
- assertTrue(faces.isNotEmpty())
+ assertThat(faces).isNotEmpty()
val face = faces[0]
- assertTrue(face.quality > 0.5)
+ assertThat(face.quality).isGreaterThan(0.5f)
}
@Test
@@ -113,9 +112,9 @@ class FaceDetectionProcessorTest {
val bitmap: Bitmap = context.loadBitmapFromTestResources("royalty_free_bad_face")
val faces = simFace.detectFaceBlocking(bitmap)
- assertTrue(faces.isNotEmpty())
+ assertThat(faces).isNotEmpty()
val face = faces[0]
- assertTrue(face.quality < 0.5)
+ assertThat(face.quality).isLessThan(0.5f)
}
@Test
@@ -123,7 +122,7 @@ class FaceDetectionProcessorTest {
val bitmap: Bitmap = context.loadBitmapFromTestResources("royalty_free_flower")
val faces = simFace.detectFaceBlocking(bitmap)
- assertTrue(faces.isEmpty())
+ assertThat(faces).isEmpty()
}
@Test
@@ -131,6 +130,6 @@ class FaceDetectionProcessorTest {
val bitmap: Bitmap = context.loadBitmapFromTestResources("royalty_free_multiple_faces")
val faces = simFace.detectFaceBlocking(bitmap)
- assertTrue(faces.size == 5)
+ assertThat(faces).hasSize(5)
}
}
diff --git a/src/androidTest/java/com/simprints/biometrics/simface/embedding/CustomModelTest.kt b/src/androidTest/java/com/simprints/biometrics/simface/embedding/CustomModelTest.kt
index e4cf692..b06eaf8 100644
--- a/src/androidTest/java/com/simprints/biometrics/simface/embedding/CustomModelTest.kt
+++ b/src/androidTest/java/com/simprints/biometrics/simface/embedding/CustomModelTest.kt
@@ -3,22 +3,22 @@ package com.simprints.biometrics.simface.embedding
import android.content.Context
import android.graphics.Bitmap
import androidx.test.core.app.*
+import com.google.common.truth.Truth.assertThat
import com.simprints.biometrics.loadBitmapFromTestResources
import com.simprints.biometrics.openTestModelFile
import com.simprints.biometrics.simface.SimFaceConfig
import com.simprints.biometrics.simface.Utils
import kotlinx.coroutines.test.runTest
import org.junit.After
-import org.junit.Assert.assertArrayEquals
import org.junit.Before
import org.junit.Test
/**
* This test class makes it trivially easy to run tests with new model files:
- * 1. Add the new model file to the anrdroidTest/res/raw folder
- * 2. Create a new test method
- * 3. Provide the model file name to `openTestModelFile()`
- * 4. Do the testing
+ * 1. Add the new model file to the anrdroidTest/res/raw folder
+ * 2. Create a new test method
+ * 3. Provide the model file name to `openTestModelFile()`
+ * 4. Do the testing
*/
class CustomModelTest {
private lateinit var context: Context
@@ -32,31 +32,34 @@ class CustomModelTest {
@After
fun cleanup() {
- modelManager.close()
+ if (::modelManager.isInitialized) {
+ modelManager.close()
+ }
}
@Test
fun test_processes_face_with_custom_model() = runTest {
val testModelFile = context.openTestModelFile()
- modelManager = MLModelManager(
- SimFaceConfig(
- context,
- customModel = SimFaceConfig.CustomModel(
- file = testModelFile,
- templateVersion = "TEST_1",
+ modelManager =
+ MLModelManager(
+ SimFaceConfig(
+ context,
+ customModel =
+ SimFaceConfig.CustomModel(
+ file = testModelFile,
+ templateVersion = "TEST_1",
+ ),
),
- ),
- )
+ )
embeddingProcessor = TensorFlowEmbeddingProcessor(modelManager)
val bitmap: Bitmap = context.loadBitmapFromTestResources("royalty_free_good_face")
val resultFloat = getFaceEmbeddingFromBitmap(bitmap)
- assertArrayEquals(GOOD_FACE_EMBEDDING, resultFloat, 0.1F)
+ assertThat(resultFloat).usingTolerance(0.1).containsExactly(GOOD_FACE_EMBEDDING).inOrder()
}
- private fun getFaceEmbeddingFromBitmap(bitmap: Bitmap): FloatArray = embeddingProcessor
- .getEmbedding(bitmap)
- .let { Utils.byteArrayToFloatArray(it) }
+ private fun getFaceEmbeddingFromBitmap(bitmap: Bitmap): FloatArray =
+ embeddingProcessor.getEmbedding(bitmap).let { Utils.byteArrayToFloatArray(it) }
}
diff --git a/src/androidTest/java/com/simprints/biometrics/simface/embedding/EmbeddingProcessorTest.kt b/src/androidTest/java/com/simprints/biometrics/simface/embedding/EmbeddingProcessorTest.kt
index 3e31502..b64c642 100644
--- a/src/androidTest/java/com/simprints/biometrics/simface/embedding/EmbeddingProcessorTest.kt
+++ b/src/androidTest/java/com/simprints/biometrics/simface/embedding/EmbeddingProcessorTest.kt
@@ -4,12 +4,11 @@ import android.content.Context
import android.graphics.Bitmap
import androidx.test.core.app.*
import androidx.test.ext.junit.runners.*
+import com.google.common.truth.Truth.assertThat
import com.simprints.biometrics.loadBitmapFromTestResources
import com.simprints.biometrics.simface.SimFaceConfig
import com.simprints.biometrics.simface.Utils
import org.junit.After
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -38,10 +37,11 @@ class EmbeddingProcessorTest {
val result = embeddingProcessor.getEmbedding(bitmap)
val resultFloat = Utils.byteArrayToFloatArray(result)
- assertTrue(Utils.byteArrayToFloatArray(result).size == 512)
+ assertThat(resultFloat.size).isEqualTo(512)
- // Verify results
- assertArrayEquals(GOOD_FACE_EMBEDDING, resultFloat, 0.1F)
+ assertThat(resultFloat)
+ .usingTolerance(0.1)
+ .containsExactly(GOOD_FACE_EMBEDDING)
}
@Test
@@ -52,7 +52,7 @@ class EmbeddingProcessorTest {
val embedding1 = embeddingProcessor.getEmbedding(bitmap1)
val embedding2 = embeddingProcessor.getEmbedding(bitmap2)
- assertTrue(!embedding1.contentEquals(embedding2)) // Embeddings should be different
+ assertThat(embedding1).isNotEqualTo(embedding2)
}
@Test
@@ -62,6 +62,6 @@ class EmbeddingProcessorTest {
val embedding1 = embeddingProcessor.getEmbedding(bitmap)
val embedding2 = embeddingProcessor.getEmbedding(bitmap)
- assertArrayEquals(embedding1, embedding2) // Embeddings should be identical
+ assertThat(embedding1).isEqualTo(embedding2)
}
}
diff --git a/src/androidTest/java/com/simprints/biometrics/simface/matcher/IdentificationTest.kt b/src/androidTest/java/com/simprints/biometrics/simface/matcher/IdentificationTest.kt
index cd2b1c4..7e43337 100644
--- a/src/androidTest/java/com/simprints/biometrics/simface/matcher/IdentificationTest.kt
+++ b/src/androidTest/java/com/simprints/biometrics/simface/matcher/IdentificationTest.kt
@@ -1,8 +1,8 @@
package com.simprints.biometrics.simface.matcher
import androidx.test.ext.junit.runners.*
+import com.google.common.truth.Truth.assertThat
import com.simprints.biometrics.simface.Utils.floatArrayToByteArray
-import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -30,15 +30,15 @@ class IdentificationTest {
val sortedDistances = sortedScores.map { it.second }
// Closest match (identical vector) should have a score of 1
- assertEquals(1.0, sortedDistances[0], 0.0001)
+ assertThat(sortedDistances[0]).isWithin(0.0001).of(1.0)
// 45-degree vector (second closest) should have a score of around 0.85355
- assertEquals(0.85355, sortedDistances[1], 0.0001)
+ assertThat(sortedDistances[1]).isWithin(0.0001).of(0.85355)
// Orthogonal vector (further away) should have a score of 0.5
- assertEquals(0.5, sortedDistances[2], 0.0001)
+ assertThat(sortedDistances[2]).isWithin(0.0001).of(0.5)
// Opposite vector (furthest away) should have a score of 0.0
- assertEquals(0.0, sortedDistances[3], 0.0001)
+ assertThat(sortedDistances[3]).isWithin(0.0001).of(0.0)
}
}
diff --git a/src/androidTest/java/com/simprints/biometrics/simface/matcher/VerificationTest.kt b/src/androidTest/java/com/simprints/biometrics/simface/matcher/VerificationTest.kt
index 12adf5a..d8ff641 100644
--- a/src/androidTest/java/com/simprints/biometrics/simface/matcher/VerificationTest.kt
+++ b/src/androidTest/java/com/simprints/biometrics/simface/matcher/VerificationTest.kt
@@ -1,9 +1,8 @@
package com.simprints.biometrics.simface.matcher
import androidx.test.ext.junit.runners.*
+import com.google.common.truth.Truth.assertThat
import com.simprints.biometrics.simface.Utils.floatArrayToByteArray
-import org.junit.Assert
-import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -24,7 +23,7 @@ class VerificationTest {
val distance = matchProcessor.verificationScore(array1, array2)
- assertEquals(1.0, distance, 0.0001)
+ assertThat(distance).isWithin(0.0001).of(1.0)
}
@Test
@@ -34,7 +33,7 @@ class VerificationTest {
val distance = matchProcessor.verificationScore(array1, array2)
- assertEquals(0.5, distance, 0.0001)
+ assertThat(distance).isWithin(0.0001).of(0.5)
}
@Test
@@ -44,7 +43,7 @@ class VerificationTest {
val distance = matchProcessor.verificationScore(array1, array2)
- assertEquals(0.0, distance, 0.0001)
+ assertThat(distance).isWithin(0.0001).of(0.0)
}
@Test
@@ -54,6 +53,7 @@ class VerificationTest {
val distance = matchProcessor.verificationScore(array1, array2)
- Assert.assertTrue(distance > 0.0 && distance < 1.0)
+ assertThat(distance).isGreaterThan(0.0)
+ assertThat(distance).isLessThan(1.0)
}
}
diff --git a/src/androidTest/res/drawable/royalty_free_bad_face.jpg b/src/androidTest/res/drawable/royalty_free_bad_face.jpg
index 37b9842..97e29ab 100644
Binary files a/src/androidTest/res/drawable/royalty_free_bad_face.jpg and b/src/androidTest/res/drawable/royalty_free_bad_face.jpg differ
diff --git a/src/main/java/com/simprints/biometrics/simface/SimFace.kt b/src/main/java/com/simprints/biometrics/simface/SimFace.kt
index 0e932c5..5f49e51 100644
--- a/src/main/java/com/simprints/biometrics/simface/SimFace.kt
+++ b/src/main/java/com/simprints/biometrics/simface/SimFace.kt
@@ -11,6 +11,7 @@ import com.simprints.biometrics.simface.embedding.MLModelManager
import com.simprints.biometrics.simface.embedding.TensorFlowEmbeddingProcessor
import com.simprints.biometrics.simface.matcher.CosineDistanceMatchProcessor
import com.simprints.biometrics.simface.matcher.MatchProcessor
+import com.simprints.simq.SimQ
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import com.google.mlkit.vision.face.FaceDetection as MlKitFaceDetection
@@ -22,41 +23,53 @@ class SimFace {
private lateinit var modelManager: MLModelManager
private lateinit var embeddingProcessor: EmbeddingProcessor
private lateinit var matchProcessor: MatchProcessor
-
+ private lateinit var qualityProcessor: SimQ
private lateinit var faceDetector: FaceDetector
private lateinit var faceDetectionProcessor: FaceDetectionProcessor
- /**
- * Load the ML model into memory and prepare other resources for work.
- */
+ /** Load the ML model into memory and prepare other resources for work. */
fun initialize(config: SimFaceConfig): Unit = initLock.withLock {
try {
// Initialize the model manager with the given config
modelManager = MLModelManager(config)
+ // Initialize SimQ quality processor with optional custom weights and parameters
+ qualityProcessor =
+ when {
+ config.qualityWeights != null && config.qualityParameters != null ->
+ SimQ(config.qualityWeights, config.qualityParameters)
+ config.qualityWeights != null ->
+ SimQ(faceWeights = config.qualityWeights)
+ config.qualityParameters != null ->
+ SimQ(faceParameters = config.qualityParameters)
+ else -> SimQ()
+ }
+
// Initialize processors
embeddingProcessor = TensorFlowEmbeddingProcessor(modelManager)
matchProcessor = CosineDistanceMatchProcessor()
// Configure and load MLKit face detection model
- val realTimeOpts = FaceDetectorOptions
- .Builder()
- .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL)
- .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
- .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
- .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
- .setMinFaceSize(0.20f)
- .build()
+ val realTimeOpts =
+ FaceDetectorOptions
+ .Builder()
+ .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL)
+ .setPerformanceMode(
+ FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE,
+ ).setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
+ .setClassificationMode(
+ FaceDetectorOptions.CLASSIFICATION_MODE_ALL,
+ ).setMinFaceSize(0.20f)
+ .build()
faceDetector = MlKitFaceDetection.getClient(realTimeOpts)
- faceDetectionProcessor = MlKitFaceDetectionProcessor(faceDetector)
+ faceDetectionProcessor =
+ MlKitFaceDetectionProcessor(faceDetector, qualityProcessor)
} catch (e: Exception) {
throw RuntimeException("Failed to initialize SimFaceFacade: ${e.message}", e)
}
}
- /**
- * Releases used resources and ML model.
- */
+ /** Releases used resources and ML model. */
fun release() = initLock.withLock {
try {
if (this::modelManager.isInitialized) {
@@ -70,14 +83,12 @@ class SimFace {
}
}
- /**
- * Returns the version of the templates generated by the underlying ML model.
- */
+ /** Returns the version of the templates generated by the underlying ML model. */
fun getTemplateVersion(): String = modelManager.templateVersion
/**
- * Asynchronously processes the image and finds a face on the provided image and returns
- * its metadata (quality, bounding box, landmarks, etc.) or error in a set of callbacks.
+ * Asynchronously processes the image and finds a face on the provided image and returns its
+ * metadata (quality, bounding box, landmarks, etc.) or error in a set of callbacks.
*/
fun detectFace(
image: Bitmap,
@@ -92,8 +103,8 @@ class SimFace {
}
/**
- * Synchronously processes the image and finds all faces on the provided image and returns
- * their metadata (quality, bounding box, landmarks, etc.) or error in a set of callbacks.
+ * Synchronously processes the image and finds all faces on the provided image and returns their
+ * metadata (quality, bounding box, landmarks, etc.) or error in a set of callbacks.
*/
suspend fun detectFaceBlocking(image: Bitmap): List {
if (!this::faceDetectionProcessor.isInitialized) {
@@ -102,9 +113,7 @@ class SimFace {
return faceDetectionProcessor.detectFaceBlocking(image)
}
- /**
- * Extracts the biometric template from the provided face image.
- */
+ /** Extracts the biometric template from the provided face image. */
fun getEmbedding(faceImage: Bitmap): ByteArray {
if (!this::embeddingProcessor.isInitialized) {
throw IllegalStateException("SimFace.initialize() should be called first")
@@ -112,9 +121,7 @@ class SimFace {
return embeddingProcessor.getEmbedding(faceImage)
}
- /**
- * Compares the probe against the provided reference.
- */
+ /** Compares the probe against the provided reference. */
fun verificationScore(
probe: ByteArray,
matchReference: ByteArray,
@@ -126,8 +133,8 @@ class SimFace {
}
/**
- * Compares the probe against the provided list of references and returns the scores
- * for each reference in a descending order.
+ * Compares the probe against the provided list of references and returns the scores for each
+ * reference in a descending order.
*/
fun identificationScore(
probe: ByteArray,
diff --git a/src/main/java/com/simprints/biometrics/simface/SimFaceConfig.kt b/src/main/java/com/simprints/biometrics/simface/SimFaceConfig.kt
index 6eeac9f..4395849 100644
--- a/src/main/java/com/simprints/biometrics/simface/SimFaceConfig.kt
+++ b/src/main/java/com/simprints/biometrics/simface/SimFaceConfig.kt
@@ -1,15 +1,28 @@
package com.simprints.biometrics.simface
import android.content.Context
+import com.simprints.simq.QualityParameters
+import com.simprints.simq.QualityWeights
import java.io.File
data class SimFaceConfig(
val applicationContext: Context,
/**
- * Custom model file to use instead of the bundled one. If not set, the bundled model will be used.
- * The custom model's inputs and outputs vectors must much the default SimFace model.
+ * Custom model file to use instead of the bundled one. If not set, the bundled model will
+ * be used. The custom model's inputs and outputs vectors must match the default SimFace
+ * model.
*/
val customModel: CustomModel? = null,
+ /**
+ * Custom quality weights for face quality assessment. If not set, default weights will be
+ * used.
+ */
+ val qualityWeights: QualityWeights? = null,
+ /**
+ * Custom quality parameters for face quality assessment. If not set, default parameters
+ * will be used.
+ */
+ val qualityParameters: QualityParameters? = null,
) {
data class CustomModel(
val file: File,
diff --git a/src/main/java/com/simprints/biometrics/simface/detection/MlKitFaceDetectionProcessor.kt b/src/main/java/com/simprints/biometrics/simface/detection/MlKitFaceDetectionProcessor.kt
index 99fc493..f9eacb6 100644
--- a/src/main/java/com/simprints/biometrics/simface/detection/MlKitFaceDetectionProcessor.kt
+++ b/src/main/java/com/simprints/biometrics/simface/detection/MlKitFaceDetectionProcessor.kt
@@ -10,13 +10,14 @@ import com.simprints.biometrics.simface.Utils.clampToBounds
import com.simprints.biometrics.simface.data.FaceDetection
import com.simprints.biometrics.simface.data.FacialLandmarks
import com.simprints.biometrics.simface.data.Point2D
+import com.simprints.simq.SimQ
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
-import kotlin.math.absoluteValue
internal class MlKitFaceDetectionProcessor(
private val faceDetector: FaceDetector,
+ private val qualityProcessor: SimQ,
) : FaceDetectionProcessor {
override fun detectFace(
image: Bitmap,
@@ -31,26 +32,25 @@ internal class MlKitFaceDetectionProcessor(
.addOnSuccessListener { faces ->
val faceDetections = mutableListOf()
faces?.forEach { face ->
- val faceDetection = FaceDetection(
- sourceWidth = image.width,
- sourceHeight = image.height,
- absoluteBoundingBox = face.boundingBox.clampToBounds(
- image.width,
- image.height,
- ),
- yaw = face.headEulerAngleY,
- roll = face.headEulerAngleZ,
- landmarks = buildLandmarks(face),
- quality = calculateFaceQuality(face, image.width, image.height),
- )
+ val faceDetection =
+ FaceDetection(
+ sourceWidth = image.width,
+ sourceHeight = image.height,
+ absoluteBoundingBox =
+ face.boundingBox.clampToBounds(
+ image.width,
+ image.height,
+ ),
+ yaw = face.headEulerAngleY,
+ roll = face.headEulerAngleZ,
+ landmarks = buildLandmarks(face),
+ quality = calculateFaceQuality(face, image),
+ )
faceDetections.add(faceDetection)
}
onSuccess(faceDetections)
- }.addOnFailureListener { exception ->
- onFailure(exception)
- }.addOnCompleteListener {
- onCompleted()
- }
+ }.addOnFailureListener { exception -> onFailure(exception) }
+ .addOnCompleteListener { onCompleted() }
}
override suspend fun detectFaceBlocking(image: Bitmap): List {
@@ -62,18 +62,20 @@ internal class MlKitFaceDetectionProcessor(
.addOnSuccessListener { faces ->
val faceDetections = mutableListOf()
faces?.forEach { face ->
- val faceDetection = FaceDetection(
- sourceWidth = image.width,
- sourceHeight = image.height,
- absoluteBoundingBox = face.boundingBox.clampToBounds(
- image.width,
- image.height,
- ),
- yaw = face.headEulerAngleY,
- roll = face.headEulerAngleZ,
- quality = calculateFaceQuality(face, image.width, image.height),
- landmarks = buildLandmarks(face),
- )
+ val faceDetection =
+ FaceDetection(
+ sourceWidth = image.width,
+ sourceHeight = image.height,
+ absoluteBoundingBox =
+ face.boundingBox.clampToBounds(
+ image.width,
+ image.height,
+ ),
+ yaw = face.headEulerAngleY,
+ roll = face.headEulerAngleZ,
+ quality = calculateFaceQuality(face, image),
+ landmarks = buildLandmarks(face),
+ )
faceDetections.add(faceDetection)
}
continuation.resume(faceDetections)
@@ -85,74 +87,60 @@ internal class MlKitFaceDetectionProcessor(
private fun calculateFaceQuality(
face: Face,
- imageWidth: Int,
- imageHeight: Int,
- ): Float {
- return try {
- var score = 0.0
-
- // These should add to 1.0
- val faceRotationWeight = 0.3
- val faceTiltWeight = 0.05
- val faceNodWeight = 0.05
- val faceSizeWeight = 0.3
- val eyeOpennessWeight = 0.3
-
- // Face Rotation Score
- score += faceRotationWeight * (1.0 - (face.headEulerAngleY.absoluteValue / 90.0))
-
- // Face Tilt Score
- score += faceTiltWeight * (1.0 - (face.headEulerAngleZ.absoluteValue / 90.0))
-
- // Face Nod Score
- score += faceNodWeight * (1.0 - (face.headEulerAngleX.absoluteValue / 90.0))
-
- // Face Size Relative to Image Size
- val faceArea = face.boundingBox.width() * face.boundingBox.height()
- val imageArea = imageWidth * imageHeight
- score += faceSizeWeight * (faceArea.toDouble() / imageArea)
-
- // Eye Openness Score
- score += eyeOpennessWeight * calculateEyeOpennessScore(face)
-
- // TODO: Blur Detection
- // TODO: Brightness and Contrast Score
-
- // Just in case limit to 0-1 range
- return score.coerceIn(0.0, 1.0).toFloat()
- } catch (e: Exception) {
- println("Error calculating face quality: ${e.message}")
- 0.0f
- }
- }
-
- private fun calculateEyeOpennessScore(face: Face): Double {
- val leftEyeScore = face.leftEyeOpenProbability ?: return 0.0
- val rightEyeScore = face.rightEyeOpenProbability ?: return 0.0
-
- return (leftEyeScore + rightEyeScore) / 2.0
+ image: Bitmap,
+ ): Float = try {
+ val boundingBox = face.boundingBox.clampToBounds(image.width, image.height)
+ val faceBitmap =
+ Bitmap.createBitmap(
+ image,
+ boundingBox.left,
+ boundingBox.top,
+ boundingBox.width(),
+ boundingBox.height(),
+ )
+
+ val qualityScore =
+ qualityProcessor.calculateFaceQuality(
+ bitmap = faceBitmap,
+ pitch = face.headEulerAngleX.toDouble(),
+ yaw = face.headEulerAngleY.toDouble(),
+ roll = face.headEulerAngleZ.toDouble(),
+ leftEyeOpenness = face.leftEyeOpenProbability?.toDouble(),
+ rightEyeOpenness = face.rightEyeOpenProbability?.toDouble(),
+ )
+
+ faceBitmap.recycle()
+
+ qualityScore
+ } catch (e: Exception) {
+ 0.0f
}
private fun buildLandmarks(face: Face): FacialLandmarks? {
- val leftEye = face.getLandmark(FaceLandmark.LEFT_EYE)?.position
- ?: face.getContour(FaceContour.LEFT_EYE)?.points?.getOrNull(4)
- ?: return null
-
- val rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE)?.position
- ?: face.getContour(FaceContour.RIGHT_EYE)?.points?.getOrNull(4)
- ?: return null
-
- val nose = face.getLandmark(FaceLandmark.NOSE_BASE)?.position
- ?: face.getContour(FaceContour.NOSE_BRIDGE)?.points?.lastOrNull()
- ?: return null
-
- val mouthLeft = face.getLandmark(FaceLandmark.MOUTH_LEFT)?.position
- ?: face.getContour(FaceContour.LOWER_LIP_BOTTOM)?.points?.lastOrNull()
- ?: return null
-
- val mouthRight = face.getLandmark(FaceLandmark.MOUTH_RIGHT)?.position
- ?: face.getContour(FaceContour.LOWER_LIP_BOTTOM)?.points?.firstOrNull()
- ?: return null
+ val leftEye =
+ face.getLandmark(FaceLandmark.LEFT_EYE)?.position
+ ?: face.getContour(FaceContour.LEFT_EYE)?.points?.getOrNull(4)
+ ?: return null
+
+ val rightEye =
+ face.getLandmark(FaceLandmark.RIGHT_EYE)?.position
+ ?: face.getContour(FaceContour.RIGHT_EYE)?.points?.getOrNull(4)
+ ?: return null
+
+ val nose =
+ face.getLandmark(FaceLandmark.NOSE_BASE)?.position
+ ?: face.getContour(FaceContour.NOSE_BRIDGE)?.points?.lastOrNull()
+ ?: return null
+
+ val mouthLeft =
+ face.getLandmark(FaceLandmark.MOUTH_LEFT)?.position
+ ?: face.getContour(FaceContour.LOWER_LIP_BOTTOM)?.points?.lastOrNull()
+ ?: return null
+
+ val mouthRight =
+ face.getLandmark(FaceLandmark.MOUTH_RIGHT)?.position
+ ?: face.getContour(FaceContour.LOWER_LIP_BOTTOM)?.points?.firstOrNull()
+ ?: return null
return FacialLandmarks(
Point2D(leftEye.x, leftEye.y),