Skip to content

Commit c5bc8d5

Browse files
tjleingThomas Leingtylerjroach
authored
fix(liveness): use updated Liveness oval algorithm (#95)
Co-authored-by: Thomas Leing <[email protected]> Co-authored-by: tjroach <[email protected]>
1 parent d87e9d9 commit c5bc8d5

File tree

2 files changed

+152
-12
lines changed

2 files changed

+152
-12
lines changed

liveness/src/main/java/com/amplifyframework/ui/liveness/ml/FaceDetector.kt

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package com.amplifyframework.ui.liveness.ml
1717

1818
import android.content.Context
1919
import android.graphics.RectF
20+
import androidx.annotation.VisibleForTesting
2021
import com.amplifyframework.predictions.aws.models.FaceTargetMatchingParameters
2122
import com.amplifyframework.ui.liveness.R
2223
import com.amplifyframework.ui.liveness.camera.LivenessCoordinator.Companion.TARGET_HEIGHT
@@ -64,6 +65,14 @@ internal class FaceDetector(private val livenessState: LivenessState) {
6465
var mouthX = outputBoxes[0][i][10]
6566
var mouthY = outputBoxes[0][i][11]
6667

68+
// the face's right ear is actually the one on the left on screen, and vice versa.
69+
// this is the same for the eyes, but we need the ears to be correct with respect to the
70+
// bounding box for the algorithm to work.
71+
var rightEarX = outputBoxes[0][i][12]
72+
var rightEarY = outputBoxes[0][i][13]
73+
var leftEarX = outputBoxes[0][i][14]
74+
var leftEarY = outputBoxes[0][i][15]
75+
6776
xCenter = xCenter / X_SCALE * anchors[i].w + anchors[i].xCenter
6877
yCenter = yCenter / Y_SCALE * anchors[i].h + anchors[i].yCenter
6978
h = h / H_SCALE * anchors[i].h
@@ -85,13 +94,20 @@ internal class FaceDetector(private val livenessState: LivenessState) {
8594
mouthX = mouthX / X_SCALE * anchors[i].w + anchors[i].xCenter
8695
mouthY = mouthY / Y_SCALE * anchors[i].h + anchors[i].yCenter
8796

97+
leftEarX = leftEarX / X_SCALE * anchors[i].w + anchors[i].xCenter
98+
leftEarY = leftEarY / Y_SCALE * anchors[i].h + anchors[i].yCenter
99+
rightEarX = rightEarX / X_SCALE * anchors[i].w + anchors[i].xCenter
100+
rightEarY = rightEarY / Y_SCALE * anchors[i].h + anchors[i].yCenter
101+
88102
detections.add(
89103
Detection(
90104
RectF(xMin, yMin, xMax, yMax),
91105
Landmark(leftEyeX, leftEyeY),
92106
Landmark(rightEyeX, rightEyeY),
93107
Landmark(noseX, noseY),
94108
Landmark(mouthX, mouthY),
109+
Landmark(leftEarX, leftEarY),
110+
Landmark(rightEarX, rightEarY),
95111
score
96112
)
97113
)
@@ -111,6 +127,8 @@ internal class FaceDetector(private val livenessState: LivenessState) {
111127
val renormalizedDetections = mutableListOf<Detection>()
112128
weightedDetections.forEach { detection ->
113129
// Change landmark coordinates to be for actual image size instead of model input size
130+
val scaledBottom = (detection.location.bottom / Y_SCALE) * TARGET_HEIGHT
131+
114132
val scaledLeftEyeX = (detection.leftEye.x / X_SCALE) * TARGET_WIDTH
115133
val scaledLeftEyeY = (detection.leftEye.y / Y_SCALE) * TARGET_HEIGHT
116134
val scaledRightEyeX = (detection.rightEye.x / X_SCALE) * TARGET_WIDTH
@@ -119,19 +137,28 @@ internal class FaceDetector(private val livenessState: LivenessState) {
119137
val scaledNoseY = (detection.nose.y / Y_SCALE) * TARGET_HEIGHT
120138
val scaledMouthX = (detection.mouth.x / X_SCALE) * TARGET_WIDTH
121139
val scaledMouthY = (detection.mouth.y / Y_SCALE) * TARGET_HEIGHT
140+
val scaledLeftEarX = (detection.leftEar.x / X_SCALE) * TARGET_WIDTH
141+
val scaledLeftEarY = (detection.leftEar.y / Y_SCALE) * TARGET_HEIGHT
142+
val scaledRightEarX = (detection.rightEar.x / X_SCALE) * TARGET_WIDTH
143+
val scaledRightEarY = (detection.rightEar.y / Y_SCALE) * TARGET_HEIGHT
122144

123145
val scaledLeftEye = Landmark(scaledLeftEyeX, scaledLeftEyeY)
124146
val scaledRightEye = Landmark(scaledRightEyeX, scaledRightEyeY)
125147
val scaledNose = Landmark(scaledNoseX, scaledNoseY)
126148
val scaledMouth = Landmark(scaledMouthX, scaledMouthY)
149+
val scaledLeftEar = Landmark(scaledLeftEarX, scaledLeftEarY)
150+
val scaledRightEar = Landmark(scaledRightEarX, scaledRightEarY)
127151

128152
// Generate the face bounding box from the landmarks
129153
val renormalizedBoundingBox =
130154
generateBoundingBoxFromLandmarks(
155+
scaledBottom,
131156
scaledLeftEye,
132157
scaledRightEye,
133158
scaledNose,
134-
scaledMouth
159+
scaledMouth,
160+
scaledLeftEar,
161+
scaledRightEar,
135162
)
136163
renormalizedDetections.add(
137164
Detection(
@@ -140,18 +167,23 @@ internal class FaceDetector(private val livenessState: LivenessState) {
140167
scaledRightEye,
141168
scaledNose,
142169
scaledMouth,
170+
scaledLeftEar,
171+
scaledRightEar,
143172
detection.score
144173
)
145174
)
146175
}
147176
return renormalizedDetections
148177
}
149178

150-
private fun generateBoundingBoxFromLandmarks(
179+
fun generateBoundingBoxFromLandmarks(
180+
faceBottom: Float,
151181
leftEye: Landmark,
152182
rightEye: Landmark,
153183
nose: Landmark,
154-
mouth: Landmark
184+
mouth: Landmark,
185+
leftEar: Landmark,
186+
rightEar: Landmark
155187
): RectF {
156188
val pupilDistance = calculatePupilDistance(leftEye, rightEye)
157189
val faceHeight = calculateFaceHeight(leftEye, rightEye, mouth)
@@ -163,18 +195,16 @@ internal class FaceDetector(private val livenessState: LivenessState) {
163195
val eyeCenterY = (leftEye.y + rightEye.y) / 2
164196

165197
var cx = eyeCenterX
166-
var cy = eyeCenterY
167198
val ovalInfo = livenessState.faceTargetChallenge
168199
if (ovalInfo != null && eyeCenterY <= ovalInfo.targetCenterY / 2) {
169200
cx = (eyeCenterX + nose.x) / 2
170-
cy = (eyeCenterY + nose.y) / 2
171201
}
172202

173-
val left = cx - ow / 2
174-
val top = cy - oh / 2
175-
val right = left + ow
176-
val bottom = top + oh
177-
return RectF(left, top, right, bottom)
203+
val top = faceBottom - oh
204+
val left = min(cx - ow / 2, rightEar.x)
205+
val right = max(cx + ow / 2, leftEar.x)
206+
207+
return RectF(left, top, right, faceBottom)
178208
}
179209

180210
private fun generateAnchors(): List<Anchor> {
@@ -284,6 +314,8 @@ internal class FaceDetector(private val livenessState: LivenessState) {
284314
var weightedRightEye = detection.rightEye
285315
var weightedNose = detection.nose
286316
var weightedMouth = detection.mouth
317+
var weightedLeftEar = detection.leftEar
318+
var weightedRightEar = detection.rightEar
287319
if (candidates.isNotEmpty()) {
288320
var wXMin = 0.0f
289321
var wYMin = 0.0f
@@ -297,6 +329,10 @@ internal class FaceDetector(private val livenessState: LivenessState) {
297329
var wNoseY = 0.0f
298330
var wMouthX = 0.0f
299331
var wMouthY = 0.0f
332+
var wLeftEarX = 0.0f
333+
var wLeftEarY = 0.0f
334+
var wRightEarX = 0.0f
335+
var wRightEarY = 0.0f
300336
var totalScore = 0.0f
301337
candidates.forEach { candidate ->
302338
totalScore += candidate.score
@@ -305,6 +341,8 @@ internal class FaceDetector(private val livenessState: LivenessState) {
305341
val rightEye = detections[candidate.index].rightEye
306342
val nose = detections[candidate.index].nose
307343
val mouth = detections[candidate.index].mouth
344+
val leftEar = detections[candidate.index].leftEar
345+
val rightEar = detections[candidate.index].rightEar
308346

309347
wXMin += bbox.left * candidate.score
310348
wYMin += bbox.top * candidate.score
@@ -321,6 +359,11 @@ internal class FaceDetector(private val livenessState: LivenessState) {
321359

322360
wMouthX += mouth.x * candidate.score
323361
wMouthY += mouth.y * candidate.score
362+
363+
wLeftEarX += leftEar.x * candidate.score
364+
wLeftEarY += leftEar.y * candidate.score
365+
wRightEarX += rightEar.x * candidate.score
366+
wRightEarY += rightEar.y * candidate.score
324367
}
325368
weightedLocation.left = wXMin / totalScore * INPUT_SIZE_WIDTH
326369
weightedLocation.top = wYMin / totalScore * INPUT_SIZE_HEIGHT
@@ -342,6 +385,13 @@ internal class FaceDetector(private val livenessState: LivenessState) {
342385
val weightedMouthX = wMouthX / totalScore * INPUT_SIZE_WIDTH
343386
val weightedMouthY = wMouthY / totalScore * INPUT_SIZE_HEIGHT
344387
weightedMouth = Landmark(weightedMouthX, weightedMouthY)
388+
389+
val weightedLeftEarX = wLeftEarX / totalScore * INPUT_SIZE_WIDTH
390+
val weightedLeftEarY = wLeftEarY / totalScore * INPUT_SIZE_HEIGHT
391+
weightedLeftEar = Landmark(weightedLeftEarX, weightedLeftEarY)
392+
val weightedRightEarX = wRightEarX / totalScore * INPUT_SIZE_WIDTH
393+
val weightedRightEarY = wRightEarY / totalScore * INPUT_SIZE_HEIGHT
394+
weightedRightEar = Landmark(weightedRightEarX, weightedRightEarY)
345395
}
346396
remainedIndexedScores.clear()
347397
remainedIndexedScores.addAll(remained)
@@ -351,6 +401,8 @@ internal class FaceDetector(private val livenessState: LivenessState) {
351401
weightedRightEye,
352402
weightedNose,
353403
weightedMouth,
404+
weightedLeftEar,
405+
weightedRightEar,
354406
detection.score
355407
)
356408
outputLocations.add(weightedDetection)
@@ -382,6 +434,8 @@ internal class FaceDetector(private val livenessState: LivenessState) {
382434
val rightEye: Landmark,
383435
val nose: Landmark,
384436
val mouth: Landmark,
437+
val leftEar: Landmark,
438+
val rightEar: Landmark,
385439
val score: Float
386440
)
387441
private class IndexedScore(val index: Int, val score: Float)
@@ -508,11 +562,13 @@ internal class FaceDetector(private val livenessState: LivenessState) {
508562
return calibratedPupilDistance / ovalWidth
509563
}
510564

511-
private fun calculatePupilDistance(leftEye: Landmark, rightEye: Landmark): Float {
565+
@VisibleForTesting(VisibleForTesting.PRIVATE)
566+
internal fun calculatePupilDistance(leftEye: Landmark, rightEye: Landmark): Float {
512567
return sqrt((leftEye.x - rightEye.x).pow(2) + (leftEye.y - rightEye.y).pow(2))
513568
}
514569

515-
private fun calculateFaceHeight(leftEye: Landmark, rightEye: Landmark, mouth: Landmark):
570+
@VisibleForTesting(VisibleForTesting.PRIVATE)
571+
internal fun calculateFaceHeight(leftEye: Landmark, rightEye: Landmark, mouth: Landmark):
516572
Float {
517573
val eyeCenterX = (leftEye.x + rightEye.x) / 2
518574
val eyeCenterY = (leftEye.y + rightEye.y) / 2
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package com.amplifyframework.ui.liveness.ml
17+
18+
import com.amplifyframework.ui.liveness.ml.FaceDetector.Landmark
19+
import com.amplifyframework.ui.liveness.state.LivenessState
20+
import io.mockk.every
21+
import io.mockk.mockk
22+
import org.junit.Assert.assertEquals
23+
import org.junit.Before
24+
import org.junit.Test
25+
import org.junit.runner.RunWith
26+
import org.robolectric.RobolectricTestRunner
27+
28+
@RunWith(RobolectricTestRunner::class)
29+
internal class FaceDetectorTest {
30+
private lateinit var detector: FaceDetector
31+
32+
// given
33+
private val faceBottom = 0.88746625f
34+
private val leftEye = Landmark(0.668633f, 0.48738188f)
35+
private val rightEye = Landmark(0.35714725f, 0.46644497f)
36+
private val nose = Landmark(0.52836484f, 0.53194016f)
37+
private val mouth = Landmark(0.5062596f, 0.68926525f)
38+
private val leftEar = Landmark(0.78989476f, 0.5973732f)
39+
private val rightEar = Landmark(0.16585289f, 0.5668279f)
40+
private val width = 0.65490234f
41+
private val height = 0.49117205f
42+
43+
@Before
44+
fun setup() {
45+
val state = mockk<LivenessState> {
46+
every { faceTargetChallenge } returns null
47+
}
48+
detector = FaceDetector(state)
49+
}
50+
51+
@Test
52+
fun `test detected face`() {
53+
val faceDistance = FaceDetector.calculateFaceDistance(leftEye, rightEye, mouth, 1, 1)
54+
assertApprox(0.31462398f, faceDistance)
55+
val pupilDistance = FaceDetector.calculatePupilDistance(leftEye, rightEye)
56+
assertApprox(0.3121886f, pupilDistance)
57+
val faceHeight = FaceDetector.calculateFaceHeight(leftEye, rightEye, mouth)
58+
assertApprox(0.21245532f, faceHeight)
59+
}
60+
61+
@Test
62+
fun `test detected bounding box`() {
63+
// when
64+
val boundingBox = detector.generateBoundingBoxFromLandmarks(
65+
faceBottom,
66+
leftEye,
67+
rightEye,
68+
nose,
69+
mouth,
70+
leftEar,
71+
rightEar
72+
)
73+
74+
// then
75+
assertApprox(0.16585289f, boundingBox.left)
76+
assertApprox(0.07296771f, boundingBox.top)
77+
assertApprox(0.16585289f + 0.62404186f, boundingBox.right)
78+
assertApprox(0.07296771f + 0.8144985f, boundingBox.bottom)
79+
}
80+
81+
private fun assertApprox(expected: Float, actual: Float, delta: Float = 0.000001f) {
82+
assertEquals(expected, actual, delta)
83+
}
84+
}

0 commit comments

Comments
 (0)