@smileid/usesmileid_mlkit_face 12.0.0-beta01 → 12.0.0
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/CHANGELOG.md +4 -0
- package/README.md +5 -17
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/usesmileid/rn/analyzer/mlkitface/ResizingFrameStorage.kt +125 -9
- package/android/src/main/java/com/usesmileid/rn/analyzer/mlkitface/UseSmileIDAnalyzerExecutor.kt +3 -1
- package/package.json +5 -6
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -6,19 +6,13 @@ UseSmileID v12 — Google ML Kit face provider for React Native (Expo Modules).
|
|
|
6
6
|
|
|
7
7
|
## Architecture
|
|
8
8
|
|
|
9
|
-
The native side runs the entire face-scan pipeline (analyzer + validator
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
from the existing native session.
|
|
15
|
-
* A TypeScript wrapper that bridges the four contract methods
|
|
16
|
-
(`configure / analyze / reset / close`) and validates the wire payloads
|
|
17
|
-
at the channel boundary via Zod.
|
|
9
|
+
The native side runs the entire face-scan pipeline (analyzer + validator +
|
|
10
|
+
transitioner + state machine). This package contributes a Kotlin Expo Module
|
|
11
|
+
that constructs `UseSmileIDFaceSession(FaceDetectorAnalyzer())` and a TypeScript
|
|
12
|
+
wrapper that bridges the four contract methods (`configure / analyze / reset /
|
|
13
|
+
close`) with Zod-validated wire payloads.
|
|
18
14
|
|
|
19
15
|
No ML logic is reimplemented in this package — it's a pure passthrough.
|
|
20
|
-
See `improvements.md` and `react-native-expo/docs/Architecture.md` for
|
|
21
|
-
the design rationale.
|
|
22
16
|
|
|
23
17
|
## Installation
|
|
24
18
|
|
|
@@ -58,9 +52,3 @@ await useSmileIDMlkitFace.close();
|
|
|
58
52
|
```sh
|
|
59
53
|
pnpm --filter @smileid/usesmileid_mlkit_face test
|
|
60
54
|
```
|
|
61
|
-
|
|
62
|
-
JS-side tests mock `requireNativeModule` and verify the wrapper's
|
|
63
|
-
contract: method shape, boundary validation, error propagation,
|
|
64
|
-
idempotency. Native-side tests live under
|
|
65
|
-
`android/src/test/kotlin/...` (TODO when the Kotlin module is wired
|
|
66
|
-
to the real AAR).
|
package/android/build.gradle
CHANGED
|
@@ -41,7 +41,7 @@ dependencies {
|
|
|
41
41
|
// Existing Smile ID native AAR — the entire ML pipeline lives here.
|
|
42
42
|
// This Expo Module is a thin Kotlin shim that constructs the
|
|
43
43
|
// session and bridges its four methods to JS via Expo Modules.
|
|
44
|
-
implementation 'com.usesmileid:mlkit-face:12.0.0
|
|
44
|
+
implementation 'com.usesmileid:mlkit-face:12.0.0'
|
|
45
45
|
|
|
46
46
|
// VisionCamera frame-processor plugin classes. The host app provides
|
|
47
47
|
// this dependency via RN autolinking — react-native-vision-camera >= 4.7.3
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
package com.usesmileid.rn.analyzer.mlkitface
|
|
8
8
|
|
|
9
|
+
import com.usesmileid.bridge.model.BoundingBox as NativeBoundingBox
|
|
10
|
+
import com.usesmileid.bridge.model.CaptureType
|
|
11
|
+
import com.usesmileid.bridge.model.ViewportCropRegion as NativeViewportCropRegion
|
|
9
12
|
import com.usesmileid.bridge.model.YuvFrame
|
|
10
13
|
import com.usesmileid.bridge.storage.DocumentSide
|
|
11
14
|
import com.usesmileid.bridge.storage.FrameKind
|
|
@@ -16,6 +19,7 @@ import java.io.File
|
|
|
16
19
|
import java.io.IOException
|
|
17
20
|
import java.util.concurrent.ConcurrentLinkedDeque
|
|
18
21
|
import java.util.concurrent.atomic.AtomicReference
|
|
22
|
+
import kotlin.math.roundToInt
|
|
19
23
|
import kotlinx.coroutines.CoroutineScope
|
|
20
24
|
import kotlinx.coroutines.Dispatchers
|
|
21
25
|
import kotlinx.coroutines.Job
|
|
@@ -32,8 +36,15 @@ import kotlinx.coroutines.withContext
|
|
|
32
36
|
* carries no override, so the on-disk pixel size always matches the
|
|
33
37
|
* `image_width` / `image_height` values reported in job metadata.
|
|
34
38
|
*
|
|
35
|
-
* Document frames are
|
|
36
|
-
*
|
|
39
|
+
* Document frames are decoded upright, then cropped to the guide rectangle when
|
|
40
|
+
* a crop region has been published via [setCropRegion] (the on-screen frame the
|
|
41
|
+
* user aligned the document into) so the uploaded image matches the capture
|
|
42
|
+
* preview. The crop runs at full resolution first; the resulting crop is then
|
|
43
|
+
* downscaled so its longest edge is bounded at [DOCUMENT_MAX_DIMENSION] (1600
|
|
44
|
+
* px), preserving aspect ratio and never upscaling, before JPEG-encoding at the
|
|
45
|
+
* specified [documentQuality]. Cropping before the downscale keeps maximum
|
|
46
|
+
* detail in the document region — the bound applies to the crop, not the whole
|
|
47
|
+
* frame, so a tightly framed card fills the saved pixels. Document dimensions
|
|
37
48
|
* are not part of the token `sdk_config` contract.
|
|
38
49
|
*
|
|
39
50
|
* Resize and encode run on a private `Dispatchers.IO` scope; [persist] is
|
|
@@ -42,6 +53,7 @@ import kotlinx.coroutines.withContext
|
|
|
42
53
|
*/
|
|
43
54
|
internal class ResizingFrameStorage(
|
|
44
55
|
private val sessionDirectory: File,
|
|
56
|
+
private val captureType: CaptureType,
|
|
45
57
|
private val documentSide: DocumentSide = DocumentSide.Front,
|
|
46
58
|
private val selfieTargetWidth: Int = 480,
|
|
47
59
|
private val selfieTargetHeight: Int = 640,
|
|
@@ -52,10 +64,34 @@ internal class ResizingFrameStorage(
|
|
|
52
64
|
private val documentQuality: Int = 85,
|
|
53
65
|
) : FrameStorage {
|
|
54
66
|
|
|
67
|
+
companion object {
|
|
68
|
+
/**
|
|
69
|
+
* Longest-edge bound, in pixels, for the saved document JPEG. The
|
|
70
|
+
* guide-rect crop is downscaled so its larger dimension never exceeds
|
|
71
|
+
* this value, preserving aspect ratio and never upscaling.
|
|
72
|
+
*/
|
|
73
|
+
private const val DOCUMENT_MAX_DIMENSION = 1600
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Outward padding fraction applied to the guide-rect crop on each side
|
|
77
|
+
* before cropping. Fifteen percent of the box's own width/height keeps
|
|
78
|
+
* the document's corners and edges from being clipped when the card is
|
|
79
|
+
* framed tight against the on-screen guide.
|
|
80
|
+
*/
|
|
81
|
+
private const val DOCUMENT_CROP_PADDING_FRACTION = 0.15f
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
56
85
|
private val pending = ConcurrentLinkedDeque<Job>()
|
|
57
86
|
private val firstError = AtomicReference<Throwable?>(null)
|
|
58
87
|
|
|
88
|
+
@Volatile
|
|
89
|
+
private var cropRegion: NativeViewportCropRegion? = null
|
|
90
|
+
|
|
91
|
+
override fun setCropRegion(region: NativeViewportCropRegion?) {
|
|
92
|
+
cropRegion = region
|
|
93
|
+
}
|
|
94
|
+
|
|
59
95
|
override fun fileFor(kind: FrameKind, captureIndex: Int): File = File(
|
|
60
96
|
sessionDirectory,
|
|
61
97
|
when (kind) {
|
|
@@ -69,9 +105,21 @@ internal class ResizingFrameStorage(
|
|
|
69
105
|
)
|
|
70
106
|
|
|
71
107
|
override fun persist(frame: YuvFrame, file: File) {
|
|
108
|
+
launchEncode(frame, file, documentCropRegion = null)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override fun persistDocument(frame: YuvFrame, file: File, liveBoundingBox: NativeBoundingBox?) {
|
|
112
|
+
// Snapshot the crop region at dispatch time so a concurrent
|
|
113
|
+
// setCropRegion(null) between here and the coroutine body can't tear
|
|
114
|
+
// the state. liveBoundingBox is unused: the guide rectangle the user
|
|
115
|
+
// framed (cropRegion) takes precedence over the live detector box.
|
|
116
|
+
launchEncode(frame, file, documentCropRegion = cropRegion)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun launchEncode(frame: YuvFrame, file: File, documentCropRegion: NativeViewportCropRegion?) {
|
|
72
120
|
val job = scope.launch {
|
|
73
121
|
try {
|
|
74
|
-
encodeAndWrite(frame, file)
|
|
122
|
+
encodeAndWrite(frame, file, documentCropRegion)
|
|
75
123
|
} catch (throwable: Throwable) {
|
|
76
124
|
firstError.compareAndSet(null, throwable)
|
|
77
125
|
throw throwable
|
|
@@ -89,10 +137,33 @@ internal class ResizingFrameStorage(
|
|
|
89
137
|
}
|
|
90
138
|
|
|
91
139
|
override fun clear() {
|
|
92
|
-
|
|
140
|
+
// Selfie and document captures share one session directory; delete
|
|
141
|
+
// only this role's own frames.
|
|
142
|
+
sessionDirectory.listFiles()?.forEach { file ->
|
|
143
|
+
if (ownsForClear(file)) file.delete()
|
|
144
|
+
}
|
|
93
145
|
}
|
|
94
146
|
|
|
95
|
-
private
|
|
147
|
+
private fun ownsForClear(file: File): Boolean = when (captureType) {
|
|
148
|
+
CaptureType.SELFIE -> {
|
|
149
|
+
val kind = frameKindOf(file)
|
|
150
|
+
kind == FrameKind.Selfie || kind == FrameKind.Liveness
|
|
151
|
+
}
|
|
152
|
+
CaptureType.DOCUMENT -> file.name == fileFor(FrameKind.Document, captureIndex = 0).name
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun frameKindOf(file: File): FrameKind? = when {
|
|
156
|
+
file.name.startsWith("selfie") -> FrameKind.Selfie
|
|
157
|
+
file.name.startsWith("liveness") -> FrameKind.Liveness
|
|
158
|
+
file.name.startsWith("document") -> FrameKind.Document
|
|
159
|
+
else -> null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private suspend fun encodeAndWrite(
|
|
163
|
+
frame: YuvFrame,
|
|
164
|
+
file: File,
|
|
165
|
+
documentCropRegion: NativeViewportCropRegion?,
|
|
166
|
+
) {
|
|
96
167
|
withContext(Dispatchers.IO) {
|
|
97
168
|
file.parentFile?.mkdirs()
|
|
98
169
|
val name = file.name
|
|
@@ -116,16 +187,61 @@ internal class ResizingFrameStorage(
|
|
|
116
187
|
}
|
|
117
188
|
}
|
|
118
189
|
else -> {
|
|
119
|
-
// Document frames:
|
|
120
|
-
|
|
190
|
+
// Document frames: decode to the upright bitmap, crop to the guide
|
|
191
|
+
// rectangle (if a crop region is set) at full resolution, then
|
|
192
|
+
// downscale the crop so its longest edge is bounded at
|
|
193
|
+
// DOCUMENT_MAX_DIMENSION. Cropping before the downscale keeps maximum
|
|
194
|
+
// detail in the document region — the bound applies to the crop, not
|
|
195
|
+
// the whole frame, so a tightly framed card fills the saved pixels.
|
|
196
|
+
// toFrameBoundingBox returns a normalized 0–1 box in the upright
|
|
197
|
+
// frame's coordinate space, mapped to pixels by the bitmap dimensions.
|
|
198
|
+
// The box is padded outward by DOCUMENT_CROP_PADDING_FRACTION of its
|
|
199
|
+
// own size on each side so the document's corners and edges keep
|
|
200
|
+
// breathing room and are never clipped when the card is framed tight
|
|
201
|
+
// against the guide.
|
|
202
|
+
val source = frame.toArgbBitmap(jpegQuality = documentQuality)
|
|
121
203
|
?: throw IOException("toArgbBitmap returned null for ${file.name}")
|
|
204
|
+
val cropped = if (documentCropRegion != null) {
|
|
205
|
+
val box = documentCropRegion.toFrameBoundingBox(source.width, source.height)
|
|
206
|
+
val padX = box.width * DOCUMENT_CROP_PADDING_FRACTION
|
|
207
|
+
val padY = box.height * DOCUMENT_CROP_PADDING_FRACTION
|
|
208
|
+
// Clamp left/top to leave room for at least one pixel and derive
|
|
209
|
+
// right/bottom from the clamped left/top so the crop never runs past
|
|
210
|
+
// the bitmap edge. Bitmap.createBitmap throws IllegalArgumentException
|
|
211
|
+
// when x + width exceeds the source width, which is reachable if the
|
|
212
|
+
// padded box lands fully off an edge and both bounds collapse onto the
|
|
213
|
+
// same coordinate.
|
|
214
|
+
val pixelLeft = ((box.left - padX) * source.width).roundToInt().coerceIn(0, source.width - 1)
|
|
215
|
+
val pixelTop = ((box.top - padY) * source.height).roundToInt().coerceIn(0, source.height - 1)
|
|
216
|
+
val pixelRight = ((box.left + box.width + padX) * source.width).roundToInt().coerceIn(pixelLeft + 1, source.width)
|
|
217
|
+
val pixelBottom = ((box.top + box.height + padY) * source.height).roundToInt().coerceIn(pixelTop + 1, source.height)
|
|
218
|
+
val cropWidth = (pixelRight - pixelLeft).coerceAtLeast(1)
|
|
219
|
+
val cropHeight = (pixelBottom - pixelTop).coerceAtLeast(1)
|
|
220
|
+
android.graphics.Bitmap.createBitmap(source, pixelLeft, pixelTop, cropWidth, cropHeight)
|
|
221
|
+
} else {
|
|
222
|
+
source
|
|
223
|
+
}
|
|
224
|
+
// Downscale the crop so its longest edge is at most
|
|
225
|
+
// DOCUMENT_MAX_DIMENSION, preserving aspect ratio. Skip when the crop
|
|
226
|
+
// already fits so a small document is never upscaled.
|
|
227
|
+
val longest = maxOf(cropped.width, cropped.height)
|
|
228
|
+
val scaled = if (longest > DOCUMENT_MAX_DIMENSION) {
|
|
229
|
+
val scale = DOCUMENT_MAX_DIMENSION.toFloat() / longest
|
|
230
|
+
val scaledWidth = (cropped.width * scale).roundToInt().coerceAtLeast(1)
|
|
231
|
+
val scaledHeight = (cropped.height * scale).roundToInt().coerceAtLeast(1)
|
|
232
|
+
android.graphics.Bitmap.createScaledBitmap(cropped, scaledWidth, scaledHeight, true)
|
|
233
|
+
} else {
|
|
234
|
+
cropped
|
|
235
|
+
}
|
|
122
236
|
try {
|
|
123
237
|
val success = file.outputStream().use {
|
|
124
|
-
|
|
238
|
+
scaled.compress(android.graphics.Bitmap.CompressFormat.JPEG, documentQuality, it)
|
|
125
239
|
}
|
|
126
240
|
if (!success) throw IOException("Bitmap.compress(JPEG) failed for ${file.absolutePath}")
|
|
127
241
|
} finally {
|
|
128
|
-
|
|
242
|
+
if (scaled !== cropped) scaled.recycle()
|
|
243
|
+
if (cropped !== source) cropped.recycle()
|
|
244
|
+
source.recycle()
|
|
129
245
|
}
|
|
130
246
|
}
|
|
131
247
|
}
|
package/android/src/main/java/com/usesmileid/rn/analyzer/mlkitface/UseSmileIDAnalyzerExecutor.kt
CHANGED
|
@@ -8,6 +8,7 @@ package com.usesmileid.rn.analyzer.mlkitface
|
|
|
8
8
|
|
|
9
9
|
import android.content.Context
|
|
10
10
|
import com.usesmileid.bridge.model.CaptureReadyEvent as NativeCaptureReadyEvent
|
|
11
|
+
import com.usesmileid.bridge.model.CaptureType
|
|
11
12
|
import com.usesmileid.bridge.model.YuvFrame as NativeYuvFrame
|
|
12
13
|
import com.usesmileid.bridge.storage.DocumentSide
|
|
13
14
|
import com.usesmileid.bridge.storage.FrameStorage
|
|
@@ -74,7 +75,7 @@ abstract class UseSmileIDAnalyzerExecutor<
|
|
|
74
75
|
protected abstract fun createSession(config: PConfig, frameStorage: FrameStorage): Session
|
|
75
76
|
|
|
76
77
|
/**
|
|
77
|
-
* The document side this session's [
|
|
78
|
+
* The document side this session's [ResizingFrameStorage] writes, selecting
|
|
78
79
|
* the `document_front.jpg` vs `document_back.jpg` filename. Face modules
|
|
79
80
|
* keep the default ([DocumentSide.Front]); document modules override to
|
|
80
81
|
* read the side from their wire config so two-sided captures don't
|
|
@@ -260,6 +261,7 @@ abstract class UseSmileIDAnalyzerExecutor<
|
|
|
260
261
|
config: Map<*, *>?,
|
|
261
262
|
): ResizingFrameStorage = ResizingFrameStorage(
|
|
262
263
|
sessionDirectory = sessionDir,
|
|
264
|
+
captureType = CaptureType.SELFIE,
|
|
263
265
|
documentSide = documentSide,
|
|
264
266
|
selfieTargetWidth = (config?.get("selfieImageWidth") as? Number)?.toInt() ?: 480,
|
|
265
267
|
selfieTargetHeight = (config?.get("selfieImageHeight") as? Number)?.toInt() ?: 640,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smileid/usesmileid_mlkit_face",
|
|
3
|
-
"version": "12.0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "12.0.0",
|
|
4
|
+
"description": "Android MLKit face detection implementation for the UseSmileID React Native (Expo) SDK.",
|
|
5
5
|
"homepage": "https://usesmileid.com",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"CHANGELOG.md"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@smileid/usesmileid_bridge": "12.0.0
|
|
47
|
-
"@smileid/usesmileid_platform_interface": "12.0.0
|
|
46
|
+
"@smileid/usesmileid_bridge": "12.0.0",
|
|
47
|
+
"@smileid/usesmileid_platform_interface": "12.0.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"expo": ">=56.0.0",
|
|
@@ -57,8 +57,7 @@
|
|
|
57
57
|
"typescript": "^6.0.3"
|
|
58
58
|
},
|
|
59
59
|
"publishConfig": {
|
|
60
|
-
"access": "public"
|
|
61
|
-
"provenance": false
|
|
60
|
+
"access": "public"
|
|
62
61
|
},
|
|
63
62
|
"scripts": {
|
|
64
63
|
"build": "tsc -p tsconfig.json",
|