Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -35,6 +38,7 @@ android {
}

dependencies {
api(project(":simq"))

// Tensorflow versions that works with Edgeface
api(libs.tensorflow.lite.support)
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ dependencyResolutionManagement {

rootProject.name = "Biometrics-SimFace"
include(":Biometrics-SimFace")
include(":simq")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a suspicion that this removes the "simface" from the project; both should be included.

I would suggest moving all of the simFace-related stuff into a subfolder first, tho.

1 change: 1 addition & 0 deletions simq/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
52 changes: 52 additions & 0 deletions simq/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Empty file added simq/consumer-rules.pro
Empty file.
21 changes: 21 additions & 0 deletions simq/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -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
111 changes: 111 additions & 0 deletions simq/src/androidTest/java/com/simprints/simq/AlignmentAnalysisTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
90 changes: 90 additions & 0 deletions simq/src/androidTest/java/com/simprints/simq/BitmapExtTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
44 changes: 44 additions & 0 deletions simq/src/androidTest/java/com/simprints/simq/QualityWeightsTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading