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),