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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import com.simprints.face.infra.basebiosdk.detection.FaceDetector
import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase
import com.simprints.infra.config.store.ConfigRepository
import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT
import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_CAPTURE_MAX_AREA_DEFAULT
import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_CAPTURE_MAX_ROLL_DEGREES_DEFAULT
import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_CAPTURE_MAX_YAW_DEGREES_DEFAULT
import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_CAPTURE_MIN_AREA_DEFAULT
import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckConfiguration
import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckMode
import com.simprints.infra.config.store.models.ModalitySdkType
Expand Down Expand Up @@ -57,10 +61,11 @@ internal class LiveFeedbackViewModel @Inject constructor(
private var samplesToCapture: Int = 1
private var qualityThreshold: Float = 0f

private val faceTarget = FaceTarget(
SymmetricTarget(VALID_YAW_DELTA),
SymmetricTarget(VALID_ROLL_DELTA),
0.20f..0.5f,
@Volatile
private var faceTarget = FaceTarget(
SymmetricTarget(FACE_CAPTURE_MAX_YAW_DEGREES_DEFAULT),
SymmetricTarget(FACE_CAPTURE_MAX_ROLL_DEGREES_DEFAULT),
FACE_CAPTURE_MIN_AREA_DEFAULT..FACE_CAPTURE_MAX_AREA_DEFAULT,
)
private val fallbackCaptureEventStartTime = timeHelper.now()
private var shouldSendFallbackCaptureEvent: AtomicBoolean = AtomicBoolean(true)
Expand Down Expand Up @@ -163,7 +168,13 @@ internal class LiveFeedbackViewModel @Inject constructor(
val config = configRepository.getProjectConfiguration()
spoofCheckConfig = getSpoofCheckConfiguration(config, bioSdk)
qualityThreshold = config.face?.getSdkConfiguration(bioSdk)?.qualityThreshold ?: 0f
autoCaptureImagingDurationMillis = config.experimental().faceAutoCaptureImagingDurationMillis
val experimentalConfig = config.experimental()
autoCaptureImagingDurationMillis = experimentalConfig.faceAutoCaptureImagingDurationMillis
faceTarget = FaceTarget(
SymmetricTarget(experimentalConfig.faceCaptureMaxYawDegrees),
SymmetricTarget(experimentalConfig.faceCaptureMaxRollDegrees),
experimentalConfig.faceCaptureAcceptedAreaRange,
Comment thread
meladRaouf marked this conversation as resolved.
)
}
}

Expand Down Expand Up @@ -524,11 +535,6 @@ internal class LiveFeedbackViewModel @Inject constructor(
eventReporter.addCaptureEvents(faceDetection, attemptNumber, qualityThreshold, spoofCheckConfig, isAutoCapture = isAutoCapture)
}

companion object {
private const val VALID_ROLL_DELTA = 15f
private const val VALID_YAW_DELTA = 30f
}

enum class PermissionAction {
RequestCameraPermission,
OpenAppSettings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.simprints.face.infra.basebiosdk.detection.FaceDetector
import com.simprints.face.infra.basebiosdk.detection.SpoofCheckResult
import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase
import com.simprints.infra.config.store.ConfigRepository
import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration
import com.simprints.infra.config.store.models.FaceConfiguration
import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckConfiguration
import com.simprints.infra.config.store.models.ModalitySdkType
Expand All @@ -30,6 +31,7 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Before
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -196,6 +198,35 @@ internal class LiveFeedbackViewModelTest {
}
}

@Test
fun `manual - face acceptance thresholds are read from experimental config`() = runTest {
coEvery { configRepository.getProjectConfiguration().custom } returns mapOf(
ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive(10),
ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_ROLL_DEGREES to JsonPrimitive(25),
ExperimentalProjectConfiguration.FACE_CAPTURE_MIN_AREA to JsonPrimitive(0.1f),
ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_AREA to JsonPrimitive(0.4f),
)
every { faceDetector.analyze(frame) } returnsMany listOf(
getFace(yaw = 20f), // off yaw with config, valid with defaults
getFace(roll = 20f), // valid with config, off roll with defaults
getFace(Rect(0, 0, 35, 35)), // valid with config, too far with defaults
getFace(Rect(0, 0, 70, 70)), // too close with config, valid with defaults
)
val states = collectStates()

viewModel.initAutoCapture()
viewModel.initCapture(ModalitySdkType.SIM_FACE, 2)
repeat(4) { viewModel.process(frame, frame) }

val feedbacks = states.map { it.feedback }
assertThat(feedbacks).containsExactly(
LiveFeedbackState.Feedback.NONE,
LiveFeedbackState.Feedback.LOOK_STRAIGHT,
LiveFeedbackState.Feedback.VALID,
LiveFeedbackState.Feedback.TOO_CLOSE,
)
}

@Test
fun `manual - invalid faces map to the correct feedback`() = runTest {
every { faceDetector.analyze(frame) } returnsMany listOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,53 @@
?.coerceIn(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN, FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX)
?: FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT

/**
* Maximum absolute face yaw (in degrees) accepted during face capture. Applies to all face SDKs.
*/
val faceCaptureMaxYawDegrees: Float
get() = customConfig
?.get(FACE_CAPTURE_MAX_YAW_DEGREES)
?.jsonPrimitive
?.floatOrNull
?.takeIf { it.isFinite() }
?.coerceIn(FACE_CAPTURE_MAX_ANGLE_DEGREES_MIN, FACE_CAPTURE_MAX_ANGLE_DEGREES_MAX)
Comment thread
Copilot marked this conversation as resolved.
?: FACE_CAPTURE_MAX_YAW_DEGREES_DEFAULT

/**
* Maximum absolute face roll (in degrees) accepted during face capture. Applies to all face SDKs.
*/
val faceCaptureMaxRollDegrees: Float
get() = customConfig
?.get(FACE_CAPTURE_MAX_ROLL_DEGREES)
?.jsonPrimitive
?.floatOrNull
?.takeIf { it.isFinite() }
?.coerceIn(FACE_CAPTURE_MAX_ANGLE_DEGREES_MIN, FACE_CAPTURE_MAX_ANGLE_DEGREES_MAX)
?: FACE_CAPTURE_MAX_ROLL_DEGREES_DEFAULT

/**
* Range of the relative frame area the face bounding box must occupy during face capture.
* Applies to all face SDKs. Falls back to the default range if the configured range is empty.
*/
val faceCaptureAcceptedAreaRange: ClosedFloatingPointRange<Float>
get() {
val min = customConfig
?.get(FACE_CAPTURE_MIN_AREA)
?.jsonPrimitive
?.floatOrNull
?.takeIf { it.isFinite() }
?.coerceIn(0f, 1f)
?: FACE_CAPTURE_MIN_AREA_DEFAULT
val max = customConfig
?.get(FACE_CAPTURE_MAX_AREA)
?.jsonPrimitive
?.floatOrNull
?.takeIf { it.isFinite() }
?.coerceIn(0f, 1f)
?: FACE_CAPTURE_MAX_AREA_DEFAULT
return if (min < max) min..max else FACE_CAPTURE_MIN_AREA_DEFAULT..FACE_CAPTURE_MAX_AREA_DEFAULT
}

val recordsDbMigrationFromRealmEnabled: Boolean
get() = customConfig
?.get(RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_ENABLED)
Expand Down Expand Up @@ -175,7 +222,7 @@
?.booleanOrNull
.let { it == true }

// TODO Temporary flags until configuration is added to FaceSdkConfiguration

Check warning on line 225 in infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Simprints_Android-Simprints-ID&issues=AaEgJ71_olW4Vdh1xb8t&open=AaEgJ71_olW4Vdh1xb8t&pullRequest=1848
val spoofCheckMode: SpoofCheckMode
get() = customConfig
?.get(SPOOF_CHECK_MODE)
Expand All @@ -184,7 +231,7 @@
?.let { SpoofCheckMode.fromString(it) }
?: SpoofCheckMode.DISABLED

// TODO Temporary flags until configuration is added to FaceSdkConfiguration

Check warning on line 234 in infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Simprints_Android-Simprints-ID&issues=AaEgJ71_olW4Vdh1xb8u&open=AaEgJ71_olW4Vdh1xb8u&pullRequest=1848
val spoofCheckThreshold: Float
get() = customConfig
?.get(SPOOF_CHECK_THRESHOLD)
Expand All @@ -193,7 +240,7 @@
?.coerceIn(0f, 1f)
?: SPOOF_CHECK_THRESHOLD_DEFAULT

// TODO Temporary flags until configuration is added to FaceSdkConfiguration

Check warning on line 243 in infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Simprints_Android-Simprints-ID&issues=AaEgJ71_olW4Vdh1xb8v&open=AaEgJ71_olW4Vdh1xb8v&pullRequest=1848
val spoofMaxAttempts: Int
get() = customConfig
?.get(SPOOF_CHECK_MAX_ATTEMPTS)
Expand All @@ -202,7 +249,7 @@
?.coerceAtLeast(1)
?: SPOOF_CHECK_MAX_ATTEMPTS_DEFAULT

// TODO Temporary flags until configuration is added to FaceSdkConfiguration

Check warning on line 252 in infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Simprints_Android-Simprints-ID&issues=AaEgJ71_olW4Vdh1xb8w&open=AaEgJ71_olW4Vdh1xb8w&pullRequest=1848
val spoofMaxBitmapSize: Int
get() = customConfig
?.get(SPOOF_CHECK_MAX_BITMAP_SIZE)
Expand All @@ -211,7 +258,7 @@
?.coerceAtLeast(SPOOF_CHECK_MAX_BITMAP_SIZE_MINIMUM)
?: SPOOF_CHECK_MAX_BITMAP_SIZE_DEFAULT

// TODO Temporary flags until configuration is added to FaceSdkConfiguration

Check warning on line 261 in infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Simprints_Android-Simprints-ID&issues=AaEgJ71_olW4Vdh1xb8x&open=AaEgJ71_olW4Vdh1xb8x&pullRequest=1848
val spoofMinValidationUiDurationMs: Int
get() = customConfig
?.get(SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS)
Expand All @@ -220,7 +267,7 @@
?.coerceAtLeast(0)
?: SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS_DEFAULT

// TODO Temporary flags until configuration is added to FaceSdkConfiguration

Check warning on line 270 in infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Simprints_Android-Simprints-ID&issues=AaEgJ71_olW4Vdh1xb8y&open=AaEgJ71_olW4Vdh1xb8y&pullRequest=1848
val spoofMinValidationErrorUiDurationMs: Int
get() = customConfig
?.get(SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS)
Expand All @@ -247,6 +294,17 @@
const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT = 3_000L
internal const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX = 60_000L

const val FACE_CAPTURE_MAX_YAW_DEGREES = "faceCaptureMaxYawDegrees"
const val FACE_CAPTURE_MAX_YAW_DEGREES_DEFAULT = 30f
const val FACE_CAPTURE_MAX_ROLL_DEGREES = "faceCaptureMaxRollDegrees"
const val FACE_CAPTURE_MAX_ROLL_DEGREES_DEFAULT = 15f
internal const val FACE_CAPTURE_MAX_ANGLE_DEGREES_MIN = 1f
internal const val FACE_CAPTURE_MAX_ANGLE_DEGREES_MAX = 90f
const val FACE_CAPTURE_MIN_AREA = "faceCaptureMinArea"
const val FACE_CAPTURE_MIN_AREA_DEFAULT = 0.2f
const val FACE_CAPTURE_MAX_AREA = "faceCaptureMaxArea"
const val FACE_CAPTURE_MAX_AREA_DEFAULT = 0.5f

internal const val FALLBACK_TO_COMMCARE_THRESHOLD_DAYS = "fallbackToCommCareThresholdDays"
internal const val FALLBACK_TO_COMMCARE_THRESHOLD_DAYS_DEFAULT = 5L

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,4 +450,57 @@ internal class ExperimentalProjectConfigurationTest {
assertThat(ExperimentalProjectConfiguration(config).useParentalConsentAsDefault).isEqualTo(result)
}
}

@Test
fun `face capture max yaw parsed correctly`() {
mapOf(
emptyMap<String, JsonElement>() to 30f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive(null)) to 30f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive("string")) to 30f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive("NaN")) to 30f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive("Infinity")) to 30f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive(20)) to 20f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive(22.5f)) to 22.5f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive(0)) to 1f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_YAW_DEGREES to JsonPrimitive(120)) to 90f,
).forEach { (config, result) ->
assertThat(ExperimentalProjectConfiguration(config).faceCaptureMaxYawDegrees).isEqualTo(result)
}
}

@Test
fun `face capture max roll parsed correctly`() {
mapOf(
emptyMap<String, JsonElement>() to 15f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_ROLL_DEGREES to JsonPrimitive(null)) to 15f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_ROLL_DEGREES to JsonPrimitive("string")) to 15f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_ROLL_DEGREES to JsonPrimitive("NaN")) to 15f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_ROLL_DEGREES to JsonPrimitive("Infinity")) to 15f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_ROLL_DEGREES to JsonPrimitive(10)) to 10f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_ROLL_DEGREES to JsonPrimitive(-5)) to 1f,
mapOf(ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_ROLL_DEGREES to JsonPrimitive(100)) to 90f,
).forEach { (config, result) ->
assertThat(ExperimentalProjectConfiguration(config).faceCaptureMaxRollDegrees).isEqualTo(result)
}
}

@Test
fun `face capture accepted area range parsed correctly`() {
val min = ExperimentalProjectConfiguration.FACE_CAPTURE_MIN_AREA
val max = ExperimentalProjectConfiguration.FACE_CAPTURE_MAX_AREA
mapOf(
emptyMap<String, JsonElement>() to 0.2f..0.5f,
mapOf(min to JsonPrimitive("string"), max to JsonPrimitive(null)) to 0.2f..0.5f,
mapOf(min to JsonPrimitive("NaN"), max to JsonPrimitive("Infinity")) to 0.2f..0.5f,
mapOf(min to JsonPrimitive(0.1f)) to 0.1f..0.5f,
mapOf(max to JsonPrimitive(0.7f)) to 0.2f..0.7f,
mapOf(min to JsonPrimitive(0.15f), max to JsonPrimitive(0.6f)) to 0.15f..0.6f,
mapOf(min to JsonPrimitive(-1), max to JsonPrimitive(2)) to 0f..1f,
// Empty or inverted range falls back to defaults
mapOf(min to JsonPrimitive(0.6f)) to 0.2f..0.5f,
mapOf(min to JsonPrimitive(0.4f), max to JsonPrimitive(0.4f)) to 0.2f..0.5f,
).forEach { (config, result) ->
assertThat(ExperimentalProjectConfiguration(config).faceCaptureAcceptedAreaRange).isEqualTo(result)
}
}
}
Loading