@smileid/usesmileid_huawei_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 CHANGED
@@ -1,3 +1,7 @@
1
+ ## 12.0.0
2
+
3
+ First stable release of the Smile ID v12 SDK. Provides the Android Huawei HMS face detection module for `usesmileid` on GMS-free devices.
4
+
1
5
  ## 12.0.0-beta01
2
6
 
3
7
  First public beta release. Huawei HMS face detection for the Smile ID React Native (Expo) SDK v12.
package/README.md CHANGED
@@ -15,8 +15,7 @@ HMS-backed analyzer in the `com.usesmileid:huawei-face` AAR, and a
15
15
  TS wrapper that bridges `configure / analyze / reset / close` with
16
16
  Zod-validated wire payloads.
17
17
 
18
- No ML logic is reimplemented in this package. See
19
- `react-native-expo/docs/Architecture.md`.
18
+ No ML logic is reimplemented in this package — it's a pure passthrough.
20
19
 
21
20
  ## Installation
22
21
 
@@ -42,7 +42,7 @@ dependencies {
42
42
  // Targets Huawei AppGallery / no-GMS devices via HMS ML kit. The
43
43
  // Expo Module is a thin Kotlin shim that constructs the session
44
44
  // and bridges its four methods to JS via Expo Modules.
45
- implementation 'com.usesmileid:huawei-face:12.0.0-beta01'
45
+ implementation 'com.usesmileid:huawei-face:12.0.0'
46
46
 
47
47
  // VisionCamera frame-processor plugin classes. The host app provides
48
48
  // 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.huaweiface
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 not resized — they are written at the analyzer's native
36
- * frame resolution using the specified [documentQuality]. Document dimensions
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
- sessionDirectory.listFiles()?.forEach { it.delete() }
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 suspend fun encodeAndWrite(frame: YuvFrame, file: File) {
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: encode at native resolution, quality only.
120
- val bitmap = frame.toArgbBitmap(jpegQuality = documentQuality)
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
- bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, documentQuality, it)
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
- bitmap.recycle()
242
+ if (scaled !== cropped) scaled.recycle()
243
+ if (cropped !== source) cropped.recycle()
244
+ source.recycle()
129
245
  }
130
246
  }
131
247
  }
@@ -8,6 +8,7 @@ package com.usesmileid.rn.analyzer.huaweiface
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 [JpegFrameStorage] writes, selecting
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
@@ -261,6 +262,7 @@ abstract class UseSmileIDAnalyzerExecutor<
261
262
  config: Map<*, *>?,
262
263
  ): ResizingFrameStorage = ResizingFrameStorage(
263
264
  sessionDirectory = sessionDir,
265
+ captureType = CaptureType.SELFIE,
264
266
  documentSide = documentSide,
265
267
  selfieTargetWidth = (config?.get("selfieImageWidth") as? Number)?.toInt() ?: 480,
266
268
  selfieTargetHeight = (config?.get("selfieImageHeight") as? Number)?.toInt() ?: 640,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@smileid/usesmileid_huawei_face",
3
- "version": "12.0.0-beta01",
4
- "description": "UseSmileID v12 — Huawei HMS face provider (Android-only, no-GMS devices). Thin Expo Module shim around the existing native AAR `com.smileidentity:huawei-face`.",
3
+ "version": "12.0.0",
4
+ "description": "Huawei HMS 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-beta01",
47
- "@smileid/usesmileid_platform_interface": "12.0.0-beta01"
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",