@simasfin/smmf-sdk 0.0.0-stage → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -2
- package/android/build.gradle +51 -0
- package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.aar +0 -0
- package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.pom +52 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/id/co/onepass/expo/OnePassSignatureExpoView.kt +25 -0
- package/android/src/main/java/id/co/onepass/expo/OnepassExpoModule.kt +134 -0
- package/app.plugin.js +1 -0
- package/build/OnePassSignatureView.d.ts +19 -0
- package/build/OnePassSignatureView.d.ts.map +1 -0
- package/build/OnePassSignatureView.js +15 -0
- package/build/OnePassSignatureView.js.map +1 -0
- package/build/index.d.ts +21 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +10 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/OnePassSDK/DesignSystem.swift +139 -0
- package/ios/OnePassSDK/IconView.swift +54 -0
- package/ios/OnePassSDK/IconsaxPath.swift +216 -0
- package/ios/OnePassSDK/Liveness/FaceAnalyzer.swift +114 -0
- package/ios/OnePassSDK/Liveness/FaceLandmarkMetrics.swift +28 -0
- package/ios/OnePassSDK/Liveness/FaceSnapshotMapper.swift +37 -0
- package/ios/OnePassSDK/Liveness/LivenessGate.swift +155 -0
- package/ios/OnePassSDK/Liveness/LivenessImagePreparer.swift +44 -0
- package/ios/OnePassSDK/Liveness/LivenessViewController.swift +724 -0
- package/ios/OnePassSDK/Ocr/CoverFitCrop.swift +37 -0
- package/ios/OnePassSDK/Ocr/KtpFrameOverlayView.swift +98 -0
- package/ios/OnePassSDK/Ocr/KtpImagePreparer.swift +90 -0
- package/ios/OnePassSDK/Ocr/KtpOcrViewController.swift +703 -0
- package/ios/OnePassSDK/Ocr/KtpPhotoInspector.swift +93 -0
- package/ios/OnePassSDK/Ocr/KtpQualityGate.swift +185 -0
- package/ios/OnePassSDK/OnePass.swift +28 -0
- package/ios/OnePassSDK/Resources/hindari_pemakaian.png +0 -0
- package/ios/OnePassSDK/Resources/inter_bold.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_medium.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_regular.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_semibold.ttf +0 -0
- package/ios/OnePassSDK/Resources/ktp_illustration.webp +0 -0
- package/ios/OnePassSDK/Resources/logo_smmf.png +0 -0
- package/ios/OnePassSDK/Resources/placeholder_head.png +0 -0
- package/ios/OnePassSDK/Signature/SignaturePadView.swift +144 -0
- package/ios/OnePassSignatureExpoView.swift +22 -0
- package/ios/OnepassExpo.podspec +23 -0
- package/ios/OnepassExpoModule.swift +77 -0
- package/licenses/Iconsax-MIT.txt +26 -0
- package/licenses/Inter-OFL-1.1.txt +93 -0
- package/package.json +49 -4
- package/plugin/src/inject.js +31 -0
- package/plugin/withOnePass.js +32 -0
- package/scripts/sync-native.js +84 -0
- package/src/OnePassSignatureView.tsx +34 -0
- package/src/index.ts +44 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
|
|
3
|
+
enum IconSegment {
|
|
4
|
+
case move(CGFloat, CGFloat)
|
|
5
|
+
case line(CGFloat, CGFloat)
|
|
6
|
+
case curve(CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, CGFloat)
|
|
7
|
+
case close
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
enum IconsaxPath {
|
|
11
|
+
static let closeSquare: [IconSegment] = [
|
|
12
|
+
.move(16.190, 2.000),
|
|
13
|
+
.line(7.810, 2.000),
|
|
14
|
+
.curve(4.170, 2.000, 2.000, 4.170, 2.000, 7.810),
|
|
15
|
+
.line(2.000, 16.180),
|
|
16
|
+
.curve(2.000, 19.830, 4.170, 22.000, 7.810, 22.000),
|
|
17
|
+
.line(16.180, 22.000),
|
|
18
|
+
.curve(19.820, 22.000, 21.990, 19.830, 21.990, 16.190),
|
|
19
|
+
.line(21.990, 7.810),
|
|
20
|
+
.curve(22.000, 4.170, 19.830, 2.000, 16.190, 2.000),
|
|
21
|
+
.close,
|
|
22
|
+
.move(15.360, 14.300),
|
|
23
|
+
.curve(15.650, 14.590, 15.650, 15.070, 15.360, 15.360),
|
|
24
|
+
.curve(15.210, 15.510, 15.020, 15.580, 14.830, 15.580),
|
|
25
|
+
.curve(14.640, 15.580, 14.450, 15.510, 14.300, 15.360),
|
|
26
|
+
.line(12.000, 13.060),
|
|
27
|
+
.line(9.700, 15.360),
|
|
28
|
+
.curve(9.550, 15.510, 9.360, 15.580, 9.170, 15.580),
|
|
29
|
+
.curve(8.980, 15.580, 8.790, 15.510, 8.640, 15.360),
|
|
30
|
+
.curve(8.350, 15.066, 8.350, 14.594, 8.640, 14.300),
|
|
31
|
+
.line(10.940, 12.000),
|
|
32
|
+
.line(8.640, 9.700),
|
|
33
|
+
.curve(8.350, 9.406, 8.350, 8.934, 8.640, 8.640),
|
|
34
|
+
.curve(8.930, 8.350, 9.410, 8.350, 9.700, 8.640),
|
|
35
|
+
.line(12.000, 10.940),
|
|
36
|
+
.line(14.300, 8.640),
|
|
37
|
+
.curve(14.590, 8.350, 15.070, 8.350, 15.360, 8.640),
|
|
38
|
+
.curve(15.650, 8.930, 15.650, 9.410, 15.360, 9.700),
|
|
39
|
+
.line(13.060, 12.000),
|
|
40
|
+
.line(15.360, 14.300),
|
|
41
|
+
.close,
|
|
42
|
+
]
|
|
43
|
+
static let camera: [IconSegment] = [
|
|
44
|
+
.move(18.000, 6.000),
|
|
45
|
+
.curve(17.390, 6.000, 16.830, 5.650, 16.550, 5.110),
|
|
46
|
+
.line(15.830, 3.660),
|
|
47
|
+
.curve(15.370, 2.750, 14.170, 2.000, 13.150, 2.000),
|
|
48
|
+
.line(10.860, 2.000),
|
|
49
|
+
.curve(9.830, 2.000, 8.630, 2.750, 8.170, 3.660),
|
|
50
|
+
.line(7.450, 5.110),
|
|
51
|
+
.curve(7.170, 5.650, 6.610, 6.000, 6.000, 6.000),
|
|
52
|
+
.curve(3.830, 6.000, 2.110, 7.830, 2.250, 9.990),
|
|
53
|
+
.line(2.770, 18.250),
|
|
54
|
+
.curve(2.890, 20.310, 4.000, 22.000, 6.760, 22.000),
|
|
55
|
+
.line(17.240, 22.000),
|
|
56
|
+
.curve(20.000, 22.000, 21.100, 20.310, 21.230, 18.250),
|
|
57
|
+
.line(21.750, 9.990),
|
|
58
|
+
.curve(21.816, 8.955, 21.450, 7.939, 20.739, 7.183),
|
|
59
|
+
.curve(20.029, 6.427, 19.037, 5.999, 18.000, 6.000),
|
|
60
|
+
.close,
|
|
61
|
+
.move(10.500, 7.250),
|
|
62
|
+
.line(13.500, 7.250),
|
|
63
|
+
.curve(13.910, 7.250, 14.250, 7.590, 14.250, 8.000),
|
|
64
|
+
.curve(14.250, 8.410, 13.910, 8.750, 13.500, 8.750),
|
|
65
|
+
.line(10.500, 8.750),
|
|
66
|
+
.curve(10.090, 8.750, 9.750, 8.410, 9.750, 8.000),
|
|
67
|
+
.curve(9.750, 7.590, 10.090, 7.250, 10.500, 7.250),
|
|
68
|
+
.close,
|
|
69
|
+
.move(12.000, 18.120),
|
|
70
|
+
.curve(10.140, 18.120, 8.620, 16.610, 8.620, 14.740),
|
|
71
|
+
.curve(8.620, 12.870, 10.130, 11.360, 12.000, 11.360),
|
|
72
|
+
.curve(13.870, 11.360, 15.380, 12.870, 15.380, 14.740),
|
|
73
|
+
.curve(15.380, 16.610, 13.860, 18.120, 12.000, 18.120),
|
|
74
|
+
.close,
|
|
75
|
+
]
|
|
76
|
+
static let flash: [IconSegment] = [
|
|
77
|
+
.move(17.910, 10.720),
|
|
78
|
+
.line(14.820, 10.720),
|
|
79
|
+
.line(14.820, 3.520),
|
|
80
|
+
.curve(14.820, 1.840, 13.910, 1.500, 12.800, 2.760),
|
|
81
|
+
.line(12.000, 3.670),
|
|
82
|
+
.line(5.230, 11.370),
|
|
83
|
+
.curve(4.300, 12.420, 4.690, 13.280, 6.090, 13.280),
|
|
84
|
+
.line(9.180, 13.280),
|
|
85
|
+
.line(9.180, 20.480),
|
|
86
|
+
.curve(9.180, 22.160, 10.090, 22.500, 11.200, 21.240),
|
|
87
|
+
.line(12.000, 20.330),
|
|
88
|
+
.line(18.770, 12.630),
|
|
89
|
+
.curve(19.700, 11.580, 19.310, 10.720, 17.910, 10.720),
|
|
90
|
+
.close,
|
|
91
|
+
]
|
|
92
|
+
static let flashSlash: [IconSegment] = [
|
|
93
|
+
.move(21.770, 2.229),
|
|
94
|
+
.curve(21.470, 1.929, 20.980, 1.929, 20.680, 2.229),
|
|
95
|
+
.line(2.230, 20.689),
|
|
96
|
+
.curve(1.930, 20.989, 1.930, 21.479, 2.230, 21.779),
|
|
97
|
+
.curve(2.374, 21.922, 2.569, 22.002, 2.772, 22.000),
|
|
98
|
+
.curve(2.975, 21.998, 3.169, 21.915, 3.310, 21.769),
|
|
99
|
+
.line(21.770, 3.309),
|
|
100
|
+
.curve(22.080, 3.009, 22.080, 2.529, 21.770, 2.229),
|
|
101
|
+
.close,
|
|
102
|
+
.move(14.820, 3.520),
|
|
103
|
+
.line(14.820, 9.180),
|
|
104
|
+
.line(9.180, 14.820),
|
|
105
|
+
.line(9.180, 13.280),
|
|
106
|
+
.line(6.090, 13.280),
|
|
107
|
+
.curve(4.690, 13.280, 4.300, 12.420, 5.230, 11.370),
|
|
108
|
+
.line(12.000, 3.670),
|
|
109
|
+
.line(12.800, 2.760),
|
|
110
|
+
.curve(13.910, 1.500, 14.820, 1.840, 14.820, 3.520),
|
|
111
|
+
.close,
|
|
112
|
+
.move(18.770, 12.629),
|
|
113
|
+
.line(12.000, 20.329),
|
|
114
|
+
.line(11.200, 21.239),
|
|
115
|
+
.curve(10.090, 22.499, 9.180, 22.159, 9.180, 20.479),
|
|
116
|
+
.line(9.180, 17.819),
|
|
117
|
+
.line(16.280, 10.719),
|
|
118
|
+
.line(17.910, 10.719),
|
|
119
|
+
.curve(19.310, 10.719, 19.700, 11.579, 18.770, 12.629),
|
|
120
|
+
.close,
|
|
121
|
+
]
|
|
122
|
+
static let danger: [IconSegment] = [
|
|
123
|
+
.move(21.760, 15.920),
|
|
124
|
+
.line(15.360, 4.400),
|
|
125
|
+
.curve(14.500, 2.850, 13.310, 2.000, 12.000, 2.000),
|
|
126
|
+
.curve(10.690, 2.000, 9.500, 2.850, 8.640, 4.400),
|
|
127
|
+
.line(2.240, 15.920),
|
|
128
|
+
.curve(1.430, 17.390, 1.340, 18.800, 1.990, 19.910),
|
|
129
|
+
.curve(2.640, 21.020, 3.920, 21.630, 5.600, 21.630),
|
|
130
|
+
.line(18.400, 21.630),
|
|
131
|
+
.curve(20.080, 21.630, 21.360, 21.020, 22.010, 19.910),
|
|
132
|
+
.curve(22.660, 18.800, 22.570, 17.380, 21.760, 15.920),
|
|
133
|
+
.close,
|
|
134
|
+
.move(11.250, 9.000),
|
|
135
|
+
.curve(11.250, 8.590, 11.590, 8.250, 12.000, 8.250),
|
|
136
|
+
.curve(12.410, 8.250, 12.750, 8.590, 12.750, 9.000),
|
|
137
|
+
.line(12.750, 14.000),
|
|
138
|
+
.curve(12.750, 14.410, 12.410, 14.750, 12.000, 14.750),
|
|
139
|
+
.curve(11.590, 14.750, 11.250, 14.410, 11.250, 14.000),
|
|
140
|
+
.line(11.250, 9.000),
|
|
141
|
+
.close,
|
|
142
|
+
.move(12.710, 17.710),
|
|
143
|
+
.line(12.560, 17.830),
|
|
144
|
+
.curve(12.500, 17.870, 12.440, 17.900, 12.380, 17.920),
|
|
145
|
+
.curve(12.320, 17.950, 12.260, 17.970, 12.190, 17.980),
|
|
146
|
+
.curve(12.130, 17.990, 12.060, 18.000, 12.000, 18.000),
|
|
147
|
+
.curve(11.940, 18.000, 11.870, 17.990, 11.800, 17.980),
|
|
148
|
+
.curve(11.737, 17.969, 11.677, 17.949, 11.620, 17.920),
|
|
149
|
+
.curve(11.556, 17.898, 11.496, 17.868, 11.440, 17.830),
|
|
150
|
+
.line(11.290, 17.710),
|
|
151
|
+
.curve(11.110, 17.520, 11.000, 17.260, 11.000, 17.000),
|
|
152
|
+
.curve(11.000, 16.740, 11.110, 16.480, 11.290, 16.290),
|
|
153
|
+
.line(11.440, 16.170),
|
|
154
|
+
.curve(11.500, 16.130, 11.560, 16.100, 11.620, 16.080),
|
|
155
|
+
.curve(11.680, 16.050, 11.740, 16.030, 11.800, 16.020),
|
|
156
|
+
.curve(11.930, 15.990, 12.070, 15.990, 12.190, 16.020),
|
|
157
|
+
.curve(12.260, 16.030, 12.320, 16.050, 12.380, 16.080),
|
|
158
|
+
.curve(12.440, 16.100, 12.500, 16.130, 12.560, 16.170),
|
|
159
|
+
.line(12.710, 16.290),
|
|
160
|
+
.curve(12.890, 16.480, 13.000, 16.740, 13.000, 17.000),
|
|
161
|
+
.curve(13.000, 17.260, 12.890, 17.520, 12.710, 17.710),
|
|
162
|
+
.close,
|
|
163
|
+
]
|
|
164
|
+
static let user: [IconSegment] = [
|
|
165
|
+
.move(12.000, 12.000),
|
|
166
|
+
.curve(14.761, 12.000, 17.000, 9.761, 17.000, 7.000),
|
|
167
|
+
.curve(17.000, 4.239, 14.761, 2.000, 12.000, 2.000),
|
|
168
|
+
.curve(9.239, 2.000, 7.000, 4.239, 7.000, 7.000),
|
|
169
|
+
.curve(7.000, 9.761, 9.239, 12.000, 12.000, 12.000),
|
|
170
|
+
.close,
|
|
171
|
+
.move(12.000, 14.500),
|
|
172
|
+
.curve(6.990, 14.500, 2.910, 17.860, 2.910, 22.000),
|
|
173
|
+
.curve(2.910, 22.280, 3.130, 22.500, 3.410, 22.500),
|
|
174
|
+
.line(20.590, 22.500),
|
|
175
|
+
.curve(20.870, 22.500, 21.090, 22.280, 21.090, 22.000),
|
|
176
|
+
.curve(21.090, 17.860, 17.010, 14.500, 12.000, 14.500),
|
|
177
|
+
.close,
|
|
178
|
+
]
|
|
179
|
+
static let arrowBack: [IconSegment] = [
|
|
180
|
+
.move(20.000, 11.000),
|
|
181
|
+
.line(7.830, 11.000),
|
|
182
|
+
.line(13.420, 5.410),
|
|
183
|
+
.line(12.000, 4.000),
|
|
184
|
+
.line(4.000, 12.000),
|
|
185
|
+
.line(12.000, 20.000),
|
|
186
|
+
.line(13.410, 18.590),
|
|
187
|
+
.line(7.830, 13.000),
|
|
188
|
+
.line(20.000, 13.000),
|
|
189
|
+
.line(20.000, 11.000),
|
|
190
|
+
.close,
|
|
191
|
+
]
|
|
192
|
+
static let check: [IconSegment] = [
|
|
193
|
+
.move(9.000, 16.170),
|
|
194
|
+
.line(4.830, 12.000),
|
|
195
|
+
.line(3.410, 13.410),
|
|
196
|
+
.line(9.000, 19.000),
|
|
197
|
+
.line(21.000, 7.000),
|
|
198
|
+
.line(19.590, 5.590),
|
|
199
|
+
.close,
|
|
200
|
+
]
|
|
201
|
+
static let close: [IconSegment] = [
|
|
202
|
+
.move(19.000, 6.410),
|
|
203
|
+
.line(17.590, 5.000),
|
|
204
|
+
.line(12.000, 10.590),
|
|
205
|
+
.line(6.410, 5.000),
|
|
206
|
+
.line(5.000, 6.410),
|
|
207
|
+
.line(10.590, 12.000),
|
|
208
|
+
.line(5.000, 17.590),
|
|
209
|
+
.line(6.410, 19.000),
|
|
210
|
+
.line(12.000, 13.410),
|
|
211
|
+
.line(17.590, 19.000),
|
|
212
|
+
.line(19.000, 17.590),
|
|
213
|
+
.line(13.410, 12.000),
|
|
214
|
+
.close,
|
|
215
|
+
]
|
|
216
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import CoreVideo
|
|
3
|
+
import Foundation
|
|
4
|
+
import Vision
|
|
5
|
+
|
|
6
|
+
final class FaceAnalyzer: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
7
|
+
|
|
8
|
+
private static let earOpen: Float = 0.25
|
|
9
|
+
private static let earClosed: Float = 0.10
|
|
10
|
+
|
|
11
|
+
private let onSnapshot: (FaceSnapshot?) -> Void
|
|
12
|
+
private var busy = false
|
|
13
|
+
|
|
14
|
+
init(onSnapshot: @escaping (FaceSnapshot?) -> Void) {
|
|
15
|
+
self.onSnapshot = onSnapshot
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func captureOutput(
|
|
19
|
+
_ output: AVCaptureOutput,
|
|
20
|
+
didOutput sampleBuffer: CMSampleBuffer,
|
|
21
|
+
from connection: AVCaptureConnection
|
|
22
|
+
) {
|
|
23
|
+
guard !busy, let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
|
24
|
+
busy = true
|
|
25
|
+
let width = CVPixelBufferGetWidth(pixelBuffer)
|
|
26
|
+
let height = CVPixelBufferGetHeight(pixelBuffer)
|
|
27
|
+
let luma = averageLuma(pixelBuffer)
|
|
28
|
+
let request = VNDetectFaceLandmarksRequest { [weak self] request, _ in
|
|
29
|
+
defer { self?.busy = false }
|
|
30
|
+
guard let self else { return }
|
|
31
|
+
guard
|
|
32
|
+
let faces = request.results as? [VNFaceObservation],
|
|
33
|
+
let face = faces.max(by: { $0.boundingBox.width < $1.boundingBox.width })
|
|
34
|
+
else {
|
|
35
|
+
self.onSnapshot(nil)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
self.onSnapshot(self.snapshot(from: face, width: width, height: height, luma: luma))
|
|
39
|
+
}
|
|
40
|
+
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .leftMirrored)
|
|
41
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
42
|
+
try? handler.perform([request])
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private func snapshot(
|
|
47
|
+
from face: VNFaceObservation,
|
|
48
|
+
width: Int,
|
|
49
|
+
height: Int,
|
|
50
|
+
luma: Float
|
|
51
|
+
) -> FaceSnapshot {
|
|
52
|
+
let box = face.boundingBox
|
|
53
|
+
let pixelBox = CGRect(
|
|
54
|
+
x: box.minX * CGFloat(width),
|
|
55
|
+
y: (1 - box.maxY) * CGFloat(height),
|
|
56
|
+
width: box.width * CGFloat(width),
|
|
57
|
+
height: box.height * CGFloat(height)
|
|
58
|
+
)
|
|
59
|
+
let landmarks = face.landmarks
|
|
60
|
+
let eyeOpen = eyeOpenProbability(landmarks)
|
|
61
|
+
let mouthOpen = mouthOpenRatio(landmarks)
|
|
62
|
+
return FaceSnapshotMapper.map(
|
|
63
|
+
imageWidth: width,
|
|
64
|
+
imageHeight: height,
|
|
65
|
+
boundingBox: pixelBox,
|
|
66
|
+
leftEyeOpenProbability: eyeOpen,
|
|
67
|
+
rightEyeOpenProbability: eyeOpen,
|
|
68
|
+
mouthBottomY: mouthOpen?.mouthBottom,
|
|
69
|
+
noseBaseY: mouthOpen?.noseBase,
|
|
70
|
+
avgLuma: luma
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private func eyeOpenProbability(_ landmarks: VNFaceLandmarks2D?) -> Float? {
|
|
75
|
+
guard let landmarks else { return nil }
|
|
76
|
+
let ratios = [landmarks.leftEye, landmarks.rightEye].compactMap { region -> Float? in
|
|
77
|
+
guard let points = region?.normalizedPoints else { return nil }
|
|
78
|
+
return FaceLandmarkMetrics.aspectRatio(points: points)
|
|
79
|
+
}
|
|
80
|
+
return FaceLandmarkMetrics.eyeOpenProbability(aspectRatios: ratios)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private func mouthOpenRatio(
|
|
84
|
+
_ landmarks: VNFaceLandmarks2D?
|
|
85
|
+
) -> (mouthBottom: Float, noseBase: Float)? {
|
|
86
|
+
guard
|
|
87
|
+
let landmarks,
|
|
88
|
+
let lips = landmarks.outerLips?.pointsInImage(imageSize: CGSize(width: 1, height: 1)),
|
|
89
|
+
let nose = landmarks.nose?.pointsInImage(imageSize: CGSize(width: 1, height: 1)),
|
|
90
|
+
let mouthBottom = FaceLandmarkMetrics.lowestPoint(lips),
|
|
91
|
+
let noseBase = FaceLandmarkMetrics.lowestPoint(nose)
|
|
92
|
+
else { return nil }
|
|
93
|
+
return (mouthBottom, noseBase)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private func averageLuma(_ pixelBuffer: CVPixelBuffer) -> Float {
|
|
97
|
+
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
|
98
|
+
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
|
|
99
|
+
guard let base = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else { return 255 }
|
|
100
|
+
let bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
|
|
101
|
+
let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
|
|
102
|
+
let pointer = base.assumingMemoryBound(to: UInt8.self)
|
|
103
|
+
var sum = 0
|
|
104
|
+
var count = 0
|
|
105
|
+
var offset = 0
|
|
106
|
+
let stride = 64
|
|
107
|
+
while offset < bytesPerRow * height {
|
|
108
|
+
sum += Int(pointer[offset])
|
|
109
|
+
count += 1
|
|
110
|
+
offset += stride
|
|
111
|
+
}
|
|
112
|
+
return count == 0 ? 255 : Float(sum) / Float(count)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum FaceLandmarkMetrics {
|
|
5
|
+
static let earOpen: Float = 0.25
|
|
6
|
+
static let earClosed: Float = 0.10
|
|
7
|
+
|
|
8
|
+
static func aspectRatio(points: [CGPoint]) -> Float? {
|
|
9
|
+
guard points.count > 2 else { return nil }
|
|
10
|
+
let xs = points.map { Float($0.x) }
|
|
11
|
+
let ys = points.map { Float($0.y) }
|
|
12
|
+
let width = (xs.max() ?? 0) - (xs.min() ?? 0)
|
|
13
|
+
let height = (ys.max() ?? 0) - (ys.min() ?? 0)
|
|
14
|
+
guard width > 0 else { return nil }
|
|
15
|
+
return height / width
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static func eyeOpenProbability(aspectRatios: [Float]) -> Float? {
|
|
19
|
+
guard !aspectRatios.isEmpty else { return nil }
|
|
20
|
+
let average = aspectRatios.reduce(0, +) / Float(aspectRatios.count)
|
|
21
|
+
let normalized = (average - earClosed) / (earOpen - earClosed)
|
|
22
|
+
return min(max(normalized, 0), 1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static func lowestPoint(_ points: [CGPoint]) -> Float? {
|
|
26
|
+
points.map { Float(1 - $0.y) }.max()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
public enum FaceSnapshotMapper {
|
|
5
|
+
public static func map(
|
|
6
|
+
imageWidth: Int,
|
|
7
|
+
imageHeight: Int,
|
|
8
|
+
boundingBox: CGRect,
|
|
9
|
+
leftEyeOpenProbability: Float?,
|
|
10
|
+
rightEyeOpenProbability: Float?,
|
|
11
|
+
mouthBottomY: Float?,
|
|
12
|
+
noseBaseY: Float?,
|
|
13
|
+
avgLuma: Float?
|
|
14
|
+
) -> FaceSnapshot {
|
|
15
|
+
let eyeOpen: Float?
|
|
16
|
+
if let left = leftEyeOpenProbability, let right = rightEyeOpenProbability {
|
|
17
|
+
eyeOpen = (left + right) / 2
|
|
18
|
+
} else {
|
|
19
|
+
eyeOpen = nil
|
|
20
|
+
}
|
|
21
|
+
let faceHeight = Float(boundingBox.height)
|
|
22
|
+
let mouthOpen: Float?
|
|
23
|
+
if let mouthBottomY, let noseBaseY, faceHeight > 0 {
|
|
24
|
+
mouthOpen = (mouthBottomY - noseBaseY) / faceHeight
|
|
25
|
+
} else {
|
|
26
|
+
mouthOpen = nil
|
|
27
|
+
}
|
|
28
|
+
return FaceSnapshot(
|
|
29
|
+
centerXRatio: Float(boundingBox.midX) / Float(imageWidth),
|
|
30
|
+
centerYRatio: Float(boundingBox.midY) / Float(imageHeight),
|
|
31
|
+
widthRatio: Float(boundingBox.width) / Float(imageWidth),
|
|
32
|
+
eyeOpenProbability: eyeOpen,
|
|
33
|
+
mouthOpenRatio: mouthOpen,
|
|
34
|
+
avgLuma: avgLuma
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct FaceSnapshot: Equatable {
|
|
4
|
+
public let centerXRatio: Float
|
|
5
|
+
public let centerYRatio: Float
|
|
6
|
+
public let widthRatio: Float
|
|
7
|
+
public let eyeOpenProbability: Float?
|
|
8
|
+
public let mouthOpenRatio: Float?
|
|
9
|
+
public let avgLuma: Float?
|
|
10
|
+
|
|
11
|
+
public init(
|
|
12
|
+
centerXRatio: Float,
|
|
13
|
+
centerYRatio: Float,
|
|
14
|
+
widthRatio: Float,
|
|
15
|
+
eyeOpenProbability: Float?,
|
|
16
|
+
mouthOpenRatio: Float?,
|
|
17
|
+
avgLuma: Float? = nil
|
|
18
|
+
) {
|
|
19
|
+
self.centerXRatio = centerXRatio
|
|
20
|
+
self.centerYRatio = centerYRatio
|
|
21
|
+
self.widthRatio = widthRatio
|
|
22
|
+
self.eyeOpenProbability = eyeOpenProbability
|
|
23
|
+
self.mouthOpenRatio = mouthOpenRatio
|
|
24
|
+
self.avgLuma = avgLuma
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public enum GateStep: CaseIterable {
|
|
29
|
+
case position
|
|
30
|
+
case openMouth
|
|
31
|
+
case blink
|
|
32
|
+
case ready
|
|
33
|
+
|
|
34
|
+
public var instruction: String {
|
|
35
|
+
switch self {
|
|
36
|
+
case .position: return "Posisikan wajah di dalam area lingkaran"
|
|
37
|
+
case .openMouth: return "Buka mulut"
|
|
38
|
+
case .blink: return "Kedipkan mata"
|
|
39
|
+
case .ready: return "Foto wajah Anda"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public final class LivenessGate {
|
|
45
|
+
public static let positionHoldFrames = 10
|
|
46
|
+
public static let centerTolerance: Float = 0.15
|
|
47
|
+
public static let minWidthRatio: Float = 0.3
|
|
48
|
+
public static let maxWidthRatio: Float = 0.8
|
|
49
|
+
public static let mouthOpenFactor: Float = 1.2
|
|
50
|
+
public static let mouthOpenHoldFrames = 2
|
|
51
|
+
public static let minStepDuration: TimeInterval = 1
|
|
52
|
+
public static let eyeOpenThreshold: Float = 0.7
|
|
53
|
+
public static let eyeClosedThreshold: Float = 0.3
|
|
54
|
+
public static let lowLightLuma: Float = 60
|
|
55
|
+
|
|
56
|
+
public private(set) var step: GateStep = .position
|
|
57
|
+
|
|
58
|
+
private let clock: () -> TimeInterval
|
|
59
|
+
private var positionHoldCount = 0
|
|
60
|
+
private var closedMouthSamples: [Float] = []
|
|
61
|
+
private var mouthOpenCount = 0
|
|
62
|
+
private var sawEyesOpen = false
|
|
63
|
+
private var stepSatisfied = false
|
|
64
|
+
private var stepStartedAt: TimeInterval?
|
|
65
|
+
|
|
66
|
+
public init(clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
|
|
67
|
+
self.clock = clock
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public func process(_ face: FaceSnapshot?) {
|
|
71
|
+
if step == .ready { return }
|
|
72
|
+
let now = clock()
|
|
73
|
+
let startedAt = stepStartedAt ?? now
|
|
74
|
+
stepStartedAt = startedAt
|
|
75
|
+
if let face, !stepSatisfied { stepSatisfied = evaluate(face) }
|
|
76
|
+
if stepSatisfied, now - startedAt >= Self.minStepDuration { advance(now) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private func evaluate(_ face: FaceSnapshot) -> Bool {
|
|
80
|
+
switch step {
|
|
81
|
+
case .position:
|
|
82
|
+
let centered = isCentered(face)
|
|
83
|
+
positionHoldCount = centered ? positionHoldCount + 1 : 0
|
|
84
|
+
if centered, let mouth = face.mouthOpenRatio { closedMouthSamples.append(mouth) }
|
|
85
|
+
return positionHoldCount >= Self.positionHoldFrames
|
|
86
|
+
case .openMouth:
|
|
87
|
+
guard let mouth = face.mouthOpenRatio else { return false }
|
|
88
|
+
if closedMouthSamples.isEmpty { closedMouthSamples.append(mouth) }
|
|
89
|
+
let baseline = closedMouthSamples.sorted()[closedMouthSamples.count / 2]
|
|
90
|
+
mouthOpenCount = mouth >= baseline * Self.mouthOpenFactor ? mouthOpenCount + 1 : 0
|
|
91
|
+
return mouthOpenCount >= Self.mouthOpenHoldFrames
|
|
92
|
+
case .blink:
|
|
93
|
+
guard let eye = face.eyeOpenProbability else { return false }
|
|
94
|
+
if eye > Self.eyeOpenThreshold { sawEyesOpen = true }
|
|
95
|
+
return sawEyesOpen && eye < Self.eyeClosedThreshold
|
|
96
|
+
case .ready:
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func advance(_ now: TimeInterval) {
|
|
102
|
+
switch step {
|
|
103
|
+
case .position: step = .openMouth
|
|
104
|
+
case .openMouth: step = .blink
|
|
105
|
+
case .blink, .ready: step = .ready
|
|
106
|
+
}
|
|
107
|
+
stepSatisfied = false
|
|
108
|
+
stepStartedAt = now
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public func advanceByTimer() {
|
|
112
|
+
advance(clock())
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public func reset() {
|
|
116
|
+
step = .position
|
|
117
|
+
positionHoldCount = 0
|
|
118
|
+
closedMouthSamples.removeAll()
|
|
119
|
+
mouthOpenCount = 0
|
|
120
|
+
sawEyesOpen = false
|
|
121
|
+
stepSatisfied = false
|
|
122
|
+
stepStartedAt = nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public func eyesOpen(_ face: FaceSnapshot?) -> Bool {
|
|
126
|
+
guard let eye = face?.eyeOpenProbability else { return false }
|
|
127
|
+
return eye > Self.eyeOpenThreshold
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public func readyForPhoto(_ face: FaceSnapshot?) -> Bool {
|
|
131
|
+
guard let face else { return false }
|
|
132
|
+
return eyesOpen(face) && isCentered(face)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private func isCentered(_ face: FaceSnapshot) -> Bool {
|
|
136
|
+
abs(face.centerXRatio - 0.5) < Self.centerTolerance
|
|
137
|
+
&& abs(face.centerYRatio - 0.5) < Self.centerTolerance
|
|
138
|
+
&& face.widthRatio >= Self.minWidthRatio
|
|
139
|
+
&& face.widthRatio <= Self.maxWidthRatio
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public func feedback(for face: FaceSnapshot?) -> String? {
|
|
143
|
+
guard let face else { return "Wajah tidak terdeteksi" }
|
|
144
|
+
if let luma = face.avgLuma, luma < Self.lowLightLuma {
|
|
145
|
+
return "Cahaya kurang, cari tempat lebih terang"
|
|
146
|
+
}
|
|
147
|
+
if step != .position { return nil }
|
|
148
|
+
if face.widthRatio < Self.minWidthRatio { return "Wajah terlalu jauh" }
|
|
149
|
+
if face.widthRatio > Self.maxWidthRatio { return "Wajah terlalu dekat" }
|
|
150
|
+
let offCenter = abs(face.centerXRatio - 0.5) >= Self.centerTolerance
|
|
151
|
+
|| abs(face.centerYRatio - 0.5) >= Self.centerTolerance
|
|
152
|
+
if offCenter { return "Posisikan wajah di tengah lingkaran" }
|
|
153
|
+
return nil
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
enum LivenessImagePreparer {
|
|
4
|
+
static let maxUploadDimension: CGFloat = 1280
|
|
5
|
+
static let jpegQuality: CGFloat = 0.92
|
|
6
|
+
|
|
7
|
+
struct Prepared {
|
|
8
|
+
let image: UIImage
|
|
9
|
+
let base64: String
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static func prepareForUpload(_ image: UIImage) -> Prepared {
|
|
13
|
+
let mirrored = mirrorHorizontally(downscale(image))
|
|
14
|
+
let data = mirrored.jpegData(compressionQuality: jpegQuality) ?? Data()
|
|
15
|
+
return Prepared(image: mirrored, base64: data.base64EncodedString())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static func downscale(_ image: UIImage) -> UIImage {
|
|
19
|
+
let longestSide = max(image.size.width, image.size.height)
|
|
20
|
+
guard longestSide > maxUploadDimension else { return image }
|
|
21
|
+
let scale = maxUploadDimension / longestSide
|
|
22
|
+
let target = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
|
23
|
+
let renderer = UIGraphicsImageRenderer(size: target, format: opaqueFormat())
|
|
24
|
+
return renderer.image { _ in
|
|
25
|
+
image.draw(in: CGRect(origin: .zero, size: target))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static func mirrorHorizontally(_ image: UIImage) -> UIImage {
|
|
30
|
+
let renderer = UIGraphicsImageRenderer(size: image.size, format: opaqueFormat())
|
|
31
|
+
return renderer.image { context in
|
|
32
|
+
context.cgContext.translateBy(x: image.size.width, y: 0)
|
|
33
|
+
context.cgContext.scaleBy(x: -1, y: 1)
|
|
34
|
+
image.draw(in: CGRect(origin: .zero, size: image.size))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private static func opaqueFormat() -> UIGraphicsImageRendererFormat {
|
|
39
|
+
let format = UIGraphicsImageRendererFormat.default()
|
|
40
|
+
format.scale = 1
|
|
41
|
+
format.opaque = true
|
|
42
|
+
return format
|
|
43
|
+
}
|
|
44
|
+
}
|