@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.
Files changed (53) hide show
  1. package/README.md +84 -2
  2. package/android/build.gradle +51 -0
  3. package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.aar +0 -0
  4. package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.pom +52 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/id/co/onepass/expo/OnePassSignatureExpoView.kt +25 -0
  7. package/android/src/main/java/id/co/onepass/expo/OnepassExpoModule.kt +134 -0
  8. package/app.plugin.js +1 -0
  9. package/build/OnePassSignatureView.d.ts +19 -0
  10. package/build/OnePassSignatureView.d.ts.map +1 -0
  11. package/build/OnePassSignatureView.js +15 -0
  12. package/build/OnePassSignatureView.js.map +1 -0
  13. package/build/index.d.ts +21 -0
  14. package/build/index.d.ts.map +1 -0
  15. package/build/index.js +10 -0
  16. package/build/index.js.map +1 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/OnePassSDK/DesignSystem.swift +139 -0
  19. package/ios/OnePassSDK/IconView.swift +54 -0
  20. package/ios/OnePassSDK/IconsaxPath.swift +216 -0
  21. package/ios/OnePassSDK/Liveness/FaceAnalyzer.swift +114 -0
  22. package/ios/OnePassSDK/Liveness/FaceLandmarkMetrics.swift +28 -0
  23. package/ios/OnePassSDK/Liveness/FaceSnapshotMapper.swift +37 -0
  24. package/ios/OnePassSDK/Liveness/LivenessGate.swift +155 -0
  25. package/ios/OnePassSDK/Liveness/LivenessImagePreparer.swift +44 -0
  26. package/ios/OnePassSDK/Liveness/LivenessViewController.swift +724 -0
  27. package/ios/OnePassSDK/Ocr/CoverFitCrop.swift +37 -0
  28. package/ios/OnePassSDK/Ocr/KtpFrameOverlayView.swift +98 -0
  29. package/ios/OnePassSDK/Ocr/KtpImagePreparer.swift +90 -0
  30. package/ios/OnePassSDK/Ocr/KtpOcrViewController.swift +703 -0
  31. package/ios/OnePassSDK/Ocr/KtpPhotoInspector.swift +93 -0
  32. package/ios/OnePassSDK/Ocr/KtpQualityGate.swift +185 -0
  33. package/ios/OnePassSDK/OnePass.swift +28 -0
  34. package/ios/OnePassSDK/Resources/hindari_pemakaian.png +0 -0
  35. package/ios/OnePassSDK/Resources/inter_bold.ttf +0 -0
  36. package/ios/OnePassSDK/Resources/inter_medium.ttf +0 -0
  37. package/ios/OnePassSDK/Resources/inter_regular.ttf +0 -0
  38. package/ios/OnePassSDK/Resources/inter_semibold.ttf +0 -0
  39. package/ios/OnePassSDK/Resources/ktp_illustration.webp +0 -0
  40. package/ios/OnePassSDK/Resources/logo_smmf.png +0 -0
  41. package/ios/OnePassSDK/Resources/placeholder_head.png +0 -0
  42. package/ios/OnePassSDK/Signature/SignaturePadView.swift +144 -0
  43. package/ios/OnePassSignatureExpoView.swift +22 -0
  44. package/ios/OnepassExpo.podspec +23 -0
  45. package/ios/OnepassExpoModule.swift +77 -0
  46. package/licenses/Iconsax-MIT.txt +26 -0
  47. package/licenses/Inter-OFL-1.1.txt +93 -0
  48. package/package.json +49 -4
  49. package/plugin/src/inject.js +31 -0
  50. package/plugin/withOnePass.js +32 -0
  51. package/scripts/sync-native.js +84 -0
  52. package/src/OnePassSignatureView.tsx +34 -0
  53. 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
+ }