From 0fdc93bbbd0ca5db8ee484c1ba9c7e07b2024a95 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Fri, 2 Oct 2026 10:11:21 +0100 Subject: [PATCH] [MS-1607] Make face capture acceptance thresholds remote-configurable --- .../livefeedback/LiveFeedbackViewModel.kt | 26 +++++---- .../livefeedback/LiveFeedbackViewModelTest.kt | 31 ++++++++++ .../ExperimentalProjectConfiguration.kt | 58 +++++++++++++++++++ .../ExperimentalProjectConfigurationTest.kt | 53 +++++++++++++++++ 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt index 37da44e450..041787e52c 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt @@ -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 @@ -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) @@ -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, + ) } } @@ -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, diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt index 0d4d9ed26c..21e4f1a6a8 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt @@ -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 @@ -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 @@ -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( diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt index a832cff387..1f13c61f67 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt @@ -31,6 +31,53 @@ data class ExperimentalProjectConfiguration( ?.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) + ?: 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 + 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) @@ -247,6 +294,17 @@ data class ExperimentalProjectConfiguration( 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 diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt index e95818ef80..bd2759cfb6 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt @@ -450,4 +450,57 @@ internal class ExperimentalProjectConfigurationTest { assertThat(ExperimentalProjectConfiguration(config).useParentalConsentAsDefault).isEqualTo(result) } } + + @Test + fun `face capture max yaw parsed correctly`() { + mapOf( + emptyMap() 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() 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() 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) + } + } }