@smileid/usesmileid_vision_document 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 +0 -1
- package/ios/UseSmileIDVisionDocumentModule/ResizingFrameStorage.swift +136 -31
- package/ios/UseSmileIDVisionDocumentModule/UseSmileIDVisionDocumentFrameProcessorPlugin.swift +65 -13
- package/ios/UseSmileIDVisionDocumentModule/UseSmileIDVisionDocumentModule.swift +12 -17
- package/ios/UseSmileIDVisionDocumentModule/UseSmileIDVisionDocumentSessionHolder.swift +24 -0
- package/package.json +5 -6
- package/plugin/build/index.js +3 -3
- package/plugin/build/index.js.map +1 -1
- package/plugin/src/index.ts +3 -3
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -13,7 +13,6 @@ and a TS wrapper that bridges the four contract methods (`configure /
|
|
|
13
13
|
analyze / reset / close`) with Zod-validated wire payloads.
|
|
14
14
|
|
|
15
15
|
No ML logic is reimplemented in this package — it's a pure passthrough.
|
|
16
|
-
See `react-native-expo/docs/Architecture.md`.
|
|
17
16
|
|
|
18
17
|
## Installation
|
|
19
18
|
|
|
@@ -1,34 +1,30 @@
|
|
|
1
|
-
// MIRRORED FILE — duplicated
|
|
2
|
-
// (usesmileid_vision_face / usesmileid_vision_document)
|
|
3
|
-
//
|
|
4
|
-
// Canonical location:
|
|
5
|
-
// usesmileid_vision_face/ios/UseSmileIDVisionFaceModule/ResizingFrameStorage.swift
|
|
6
|
-
// Mirror location:
|
|
7
|
-
// usesmileid_vision_document/ios/UseSmileIDVisionDocumentModule/ResizingFrameStorage.swift
|
|
8
|
-
//
|
|
9
|
-
// Any edit here MUST be applied to the other location in the same commit.
|
|
1
|
+
// MIRRORED FILE — duplicated in both RN Expo iOS vision modules
|
|
2
|
+
// (usesmileid_vision_face / usesmileid_vision_document); the per-module pod
|
|
3
|
+
// targets can't share sources. Apply any edit to BOTH copies in the same commit.
|
|
10
4
|
|
|
11
5
|
import Foundation
|
|
12
6
|
import UIKit
|
|
13
7
|
import UseSmileIDBridge
|
|
14
8
|
|
|
15
|
-
/// `FrameStorage`
|
|
16
|
-
///
|
|
17
|
-
///
|
|
18
|
-
///
|
|
19
|
-
///
|
|
20
|
-
///
|
|
21
|
-
///
|
|
22
|
-
///
|
|
23
|
-
/// Document frames are not resized — they are written at the analyzer's
|
|
24
|
-
/// native frame resolution using the specified `documentQuality`. Document
|
|
25
|
-
/// dimensions are not part of the token `sdk_config` contract.
|
|
26
|
-
///
|
|
27
|
-
/// Encode and write run on background tasks; `persist` is fire-and-forget so
|
|
28
|
-
/// the analyzer keeps running at frame rate. `flush()` awaits all in-flight
|
|
29
|
-
/// writes before upload proceeds.
|
|
9
|
+
/// `FrameStorage` that center-crops + scales selfie/liveness JPEGs to the
|
|
10
|
+
/// token-resolved target size (falling back to 480×640, matching the
|
|
11
|
+
/// `image_width`/`image_height` job metadata), and downscales document frames
|
|
12
|
+
/// so their longest edge is bounded at `documentMaxDimension` (no upscaling).
|
|
13
|
+
/// When a crop region is set, document frames are first cropped to the on-screen
|
|
14
|
+
/// guide so the saved capture matches what the user framed (`setCropRegion(nil)`
|
|
15
|
+
/// clears it; selfie/liveness are never guide-cropped). Encode + write run on
|
|
16
|
+
/// background tasks; `persist` is fire-and-forget, `flush()` awaits all writes.
|
|
30
17
|
final class ResizingFrameStorage: FrameStorage {
|
|
18
|
+
/// Longest-edge pixel bound applied to document frames before encoding.
|
|
19
|
+
private static let documentMaxDimension = 1600
|
|
20
|
+
/// Outward padding fraction applied to the guide-rect crop on each side
|
|
21
|
+
/// before cropping. Fifteen percent of the box's own width/height keeps the
|
|
22
|
+
/// document's corners and edges from being clipped when the card is framed
|
|
23
|
+
/// tight against the on-screen guide.
|
|
24
|
+
private static let documentCropPaddingFraction: CGFloat = 0.15
|
|
25
|
+
|
|
31
26
|
private let sessionDirectory: URL
|
|
27
|
+
private let captureType: CaptureType
|
|
32
28
|
private let documentSide: DocumentSide
|
|
33
29
|
private let selfieTargetWidth: Int
|
|
34
30
|
private let selfieTargetHeight: Int
|
|
@@ -38,9 +34,16 @@ final class ResizingFrameStorage: FrameStorage {
|
|
|
38
34
|
private let livenessQuality: CGFloat
|
|
39
35
|
private let documentQuality: CGFloat
|
|
40
36
|
private let writer: Writer
|
|
37
|
+
// The frame-processor plugin calls `setCropRegion` on the VisionCamera FP
|
|
38
|
+
// thread; `persist` reads the value on that same serialised scan path before
|
|
39
|
+
// handing it (by value) to the background encode task. The Swift value is a
|
|
40
|
+
// `Sendable` struct captured by value into `Writer.enqueue`, so no background
|
|
41
|
+
// task ever observes a torn read of this property.
|
|
42
|
+
nonisolated(unsafe) private var cropRegion: ViewportCropRegion?
|
|
41
43
|
|
|
42
44
|
init(
|
|
43
45
|
sessionDirectory: URL,
|
|
46
|
+
captureType: CaptureType,
|
|
44
47
|
documentSide: DocumentSide = .front,
|
|
45
48
|
selfieTargetWidth: Int = 480,
|
|
46
49
|
selfieTargetHeight: Int = 640,
|
|
@@ -51,6 +54,7 @@ final class ResizingFrameStorage: FrameStorage {
|
|
|
51
54
|
documentQuality: CGFloat = 0.85
|
|
52
55
|
) {
|
|
53
56
|
self.sessionDirectory = sessionDirectory
|
|
57
|
+
self.captureType = captureType
|
|
54
58
|
self.documentSide = documentSide
|
|
55
59
|
self.selfieTargetWidth = selfieTargetWidth
|
|
56
60
|
self.selfieTargetHeight = selfieTargetHeight
|
|
@@ -62,6 +66,13 @@ final class ResizingFrameStorage: FrameStorage {
|
|
|
62
66
|
self.writer = Writer()
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/// Set the on-screen document guide rectangle so document frames are cropped
|
|
70
|
+
/// to exactly what the user saw. Pass `nil` to clear the crop and save the
|
|
71
|
+
/// full upright frame. Selfie and liveness frames are unaffected.
|
|
72
|
+
func setCropRegion(_ region: ViewportCropRegion?) {
|
|
73
|
+
cropRegion = region
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
func url(for kind: FrameKind, captureIndex: Int) -> URL {
|
|
66
77
|
let name: String
|
|
67
78
|
switch kind {
|
|
@@ -84,20 +95,37 @@ final class ResizingFrameStorage: FrameStorage {
|
|
|
84
95
|
func persist(image: UIImage, to url: URL) {
|
|
85
96
|
let name = url.lastPathComponent
|
|
86
97
|
let resize: (Int, Int)?
|
|
98
|
+
let maxDimension: Int?
|
|
87
99
|
let quality: CGFloat
|
|
100
|
+
let documentCropRegion: ViewportCropRegion?
|
|
88
101
|
if name.hasPrefix("selfie") {
|
|
89
102
|
resize = (selfieTargetWidth, selfieTargetHeight)
|
|
103
|
+
maxDimension = nil
|
|
90
104
|
quality = selfieQuality
|
|
105
|
+
documentCropRegion = nil
|
|
91
106
|
} else if name.hasPrefix("liveness") {
|
|
92
107
|
resize = (livenessTargetWidth, livenessTargetHeight)
|
|
108
|
+
maxDimension = nil
|
|
93
109
|
quality = livenessQuality
|
|
110
|
+
documentCropRegion = nil
|
|
94
111
|
} else {
|
|
95
|
-
// Document frames:
|
|
112
|
+
// Document frames: optionally crop to the guide overlay, then downscale
|
|
113
|
+
// so the longest edge is bounded at documentMaxDimension; frames already
|
|
114
|
+
// within the bound are written as-is (no upscaling).
|
|
96
115
|
resize = nil
|
|
116
|
+
maxDimension = Self.documentMaxDimension
|
|
97
117
|
quality = documentQuality
|
|
118
|
+
documentCropRegion = cropRegion
|
|
98
119
|
}
|
|
99
120
|
Task {
|
|
100
|
-
await writer.enqueue(
|
|
121
|
+
await writer.enqueue(
|
|
122
|
+
image: image,
|
|
123
|
+
to: url,
|
|
124
|
+
resize: resize,
|
|
125
|
+
maxDimension: maxDimension,
|
|
126
|
+
quality: quality,
|
|
127
|
+
documentCropRegion: documentCropRegion
|
|
128
|
+
)
|
|
101
129
|
}
|
|
102
130
|
}
|
|
103
131
|
|
|
@@ -106,25 +134,53 @@ final class ResizingFrameStorage: FrameStorage {
|
|
|
106
134
|
}
|
|
107
135
|
|
|
108
136
|
func clear() {
|
|
137
|
+
// Selfie and document captures share one session directory; delete
|
|
138
|
+
// only this role's own frames.
|
|
109
139
|
let fileManager = FileManager.default
|
|
110
140
|
let contents = (try? fileManager.contentsOfDirectory(
|
|
111
141
|
at: sessionDirectory,
|
|
112
142
|
includingPropertiesForKeys: nil
|
|
113
143
|
)) ?? []
|
|
114
|
-
for fileURL in contents {
|
|
144
|
+
for fileURL in contents where ownsForClear(fileURL) {
|
|
115
145
|
try? fileManager.removeItem(at: fileURL)
|
|
116
146
|
}
|
|
117
147
|
}
|
|
118
148
|
|
|
149
|
+
private func ownsForClear(_ fileURL: URL) -> Bool {
|
|
150
|
+
let name = fileURL.lastPathComponent
|
|
151
|
+
switch captureType {
|
|
152
|
+
case .selfie:
|
|
153
|
+
return name.hasPrefix("selfie") || name.hasPrefix("liveness")
|
|
154
|
+
case .document:
|
|
155
|
+
return name == url(for: .document, captureIndex: 0).lastPathComponent
|
|
156
|
+
@unknown default:
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
119
161
|
/// Serialises writes through an actor so concurrent `persist` calls for
|
|
120
162
|
/// the same URL don't race and `flush()` can join every outstanding task
|
|
121
163
|
/// without external synchronisation.
|
|
122
164
|
private actor Writer {
|
|
123
165
|
private var inFlight: [Task<Void, Error>] = []
|
|
124
166
|
|
|
125
|
-
func enqueue(
|
|
167
|
+
func enqueue(
|
|
168
|
+
image: UIImage,
|
|
169
|
+
to url: URL,
|
|
170
|
+
resize: (Int, Int)?,
|
|
171
|
+
maxDimension: Int?,
|
|
172
|
+
quality: CGFloat,
|
|
173
|
+
documentCropRegion: ViewportCropRegion?
|
|
174
|
+
) {
|
|
126
175
|
let task = Task<Void, Error>.detached(priority: .utility) {
|
|
127
|
-
try Self.encodeAndWrite(
|
|
176
|
+
try Self.encodeAndWrite(
|
|
177
|
+
image: image,
|
|
178
|
+
to: url,
|
|
179
|
+
resize: resize,
|
|
180
|
+
maxDimension: maxDimension,
|
|
181
|
+
quality: quality,
|
|
182
|
+
documentCropRegion: documentCropRegion
|
|
183
|
+
)
|
|
128
184
|
}
|
|
129
185
|
inFlight.append(task)
|
|
130
186
|
}
|
|
@@ -149,7 +205,9 @@ final class ResizingFrameStorage: FrameStorage {
|
|
|
149
205
|
image: UIImage,
|
|
150
206
|
to url: URL,
|
|
151
207
|
resize: (Int, Int)?,
|
|
152
|
-
|
|
208
|
+
maxDimension: Int?,
|
|
209
|
+
quality: CGFloat,
|
|
210
|
+
documentCropRegion: ViewportCropRegion?
|
|
153
211
|
) throws {
|
|
154
212
|
try FileManager.default.createDirectory(
|
|
155
213
|
at: url.deletingLastPathComponent(),
|
|
@@ -162,9 +220,56 @@ final class ResizingFrameStorage: FrameStorage {
|
|
|
162
220
|
let upright = image.normalizingOrientation()
|
|
163
221
|
let data: Data?
|
|
164
222
|
if let (width, height) = resize, let cgImage = upright.cgImage {
|
|
165
|
-
// Center-crop and scale to the token-resolved target size
|
|
223
|
+
// Center-crop and scale to the token-resolved target size
|
|
224
|
+
// (selfie / liveness path).
|
|
166
225
|
let cropped = cgImage.centerCroppedExact(width: width, height: height)
|
|
167
226
|
data = cropped.flatMap { UIImage(cgImage: $0).jpegData(compressionQuality: quality) }
|
|
227
|
+
} else if let maxDimension, var cgImage = upright.cgImage {
|
|
228
|
+
// Document path: optionally crop to the on-screen guide overlay, then
|
|
229
|
+
// downscale so the longest pixel edge is bounded at maxDimension.
|
|
230
|
+
// Never upscale frames already within the bound.
|
|
231
|
+
//
|
|
232
|
+
// Geometric correctness of the crop depends on `upright` being
|
|
233
|
+
// pixel-upright (orientation baked above); `toFrameBoundingBox`
|
|
234
|
+
// maps viewport coordinates onto an upright frame.
|
|
235
|
+
if let region = documentCropRegion {
|
|
236
|
+
let pixelWidth = cgImage.width
|
|
237
|
+
let pixelHeight = cgImage.height
|
|
238
|
+
let box = region.toFrameBoundingBox(frameWidth: pixelWidth, frameHeight: pixelHeight)
|
|
239
|
+
// Pad the guide rect outward by documentCropPaddingFraction of its
|
|
240
|
+
// own size on each side so the document's corners and edges keep
|
|
241
|
+
// breathing room and are never clipped when framed tight. Intersect
|
|
242
|
+
// with the image bounds so a padded edge never runs off-frame.
|
|
243
|
+
let padX = CGFloat(box.width) * ResizingFrameStorage.documentCropPaddingFraction
|
|
244
|
+
let padY = CGFloat(box.height) * ResizingFrameStorage.documentCropPaddingFraction
|
|
245
|
+
let cropRect = CGRect(
|
|
246
|
+
x: (CGFloat(box.left) - padX) * CGFloat(pixelWidth),
|
|
247
|
+
y: (CGFloat(box.top) - padY) * CGFloat(pixelHeight),
|
|
248
|
+
width: (CGFloat(box.width) + padX * 2) * CGFloat(pixelWidth),
|
|
249
|
+
height: (CGFloat(box.height) + padY * 2) * CGFloat(pixelHeight)
|
|
250
|
+
).intersection(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight))
|
|
251
|
+
if let cropped = cgImage.cropping(to: cropRect) {
|
|
252
|
+
cgImage = cropped
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
let pixelWidth = cgImage.width
|
|
256
|
+
let pixelHeight = cgImage.height
|
|
257
|
+
let longest = max(pixelWidth, pixelHeight)
|
|
258
|
+
if longest > maxDimension {
|
|
259
|
+
let scale = CGFloat(maxDimension) / CGFloat(longest)
|
|
260
|
+
let newSize = CGSize(
|
|
261
|
+
width: (CGFloat(pixelWidth) * scale).rounded(),
|
|
262
|
+
height: (CGFloat(pixelHeight) * scale).rounded()
|
|
263
|
+
)
|
|
264
|
+
let format = UIGraphicsImageRendererFormat()
|
|
265
|
+
format.scale = 1
|
|
266
|
+
let scaled = UIGraphicsImageRenderer(size: newSize, format: format).image { _ in
|
|
267
|
+
UIImage(cgImage: cgImage).draw(in: CGRect(origin: .zero, size: newSize))
|
|
268
|
+
}
|
|
269
|
+
data = scaled.jpegData(compressionQuality: quality)
|
|
270
|
+
} else {
|
|
271
|
+
data = UIImage(cgImage: cgImage).jpegData(compressionQuality: quality)
|
|
272
|
+
}
|
|
168
273
|
} else {
|
|
169
274
|
data = upright.jpegData(compressionQuality: quality)
|
|
170
275
|
}
|
package/ios/UseSmileIDVisionDocumentModule/UseSmileIDVisionDocumentFrameProcessorPlugin.swift
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import CoreMedia
|
|
2
2
|
import CoreImage
|
|
3
3
|
import UIKit
|
|
4
|
+
import UseSmileIDBridge
|
|
4
5
|
import VisionCamera
|
|
5
6
|
|
|
6
|
-
/// VisionCamera frame processor
|
|
7
|
-
///
|
|
8
|
-
///
|
|
9
|
-
///
|
|
10
|
-
///
|
|
11
|
-
/// All session ops are serialised through `UseSmileIDVisionDocumentSessionHolder.operationQueue`.
|
|
12
|
-
/// `CIContext` is created once per instance — per-frame creation causes GPU pipeline re-compilation.
|
|
7
|
+
/// VisionCamera frame processor that feeds camera frames into the active
|
|
8
|
+
/// `UseSmileIDDocumentSession` without crossing the JS thread. Session ops
|
|
9
|
+
/// serialise through `UseSmileIDVisionDocumentSessionHolder.operationQueue`;
|
|
10
|
+
/// the `CIContext` is reused across frames (per-frame creation re-compiles the
|
|
11
|
+
/// GPU pipeline).
|
|
13
12
|
@objc(UseSmileIDVisionDocumentFrameProcessorPlugin)
|
|
14
13
|
public class UseSmileIDVisionDocumentFrameProcessorPlugin: FrameProcessorPlugin {
|
|
15
14
|
|
|
@@ -29,14 +28,21 @@ public class UseSmileIDVisionDocumentFrameProcessorPlugin: FrameProcessorPlugin
|
|
|
29
28
|
return ["error": "frame_conversion_failed"]
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
// Bake rotation + mirror
|
|
33
|
-
//
|
|
34
|
-
//
|
|
31
|
+
// Bake rotation + mirror into the pixels (CIImage.oriented) rather than an
|
|
32
|
+
// EXIF flag — some consumers ignore the flag. `frame.orientation` only
|
|
33
|
+
// uprights to the sensor-natural (portrait) axis; the document screen
|
|
34
|
+
// force-locks the interface, so we additionally rotate onto that LOCKED
|
|
35
|
+
// INTERFACE axis so the saved JPEG matches the preview and the guide-rect
|
|
36
|
+
// crop. `viewportOrientation` is the interface orientation in degrees
|
|
37
|
+
// (0/90/270); each maps to a device-verified rotation in
|
|
38
|
+
// `rotatedOntoViewportAxis` — including the portrait (0°) half-turn.
|
|
35
39
|
let orientationFlag = frame.isMirrored
|
|
36
40
|
? frame.orientation.composedWithHorizontalMirror()
|
|
37
41
|
: frame.orientation
|
|
42
|
+
let viewportOrientation = (arguments?["viewportOrientation"] as? NSNumber)?.intValue ?? 0
|
|
38
43
|
let orientedImage = CIImage(cvPixelBuffer: pixelBuffer)
|
|
39
44
|
.oriented(CGImagePropertyOrientation(orientationFlag))
|
|
45
|
+
.rotatedOntoViewportAxis(degrees: viewportOrientation)
|
|
40
46
|
|
|
41
47
|
guard let cgImage = ciContext.createCGImage(orientedImage, from: orientedImage.extent) else {
|
|
42
48
|
return ["error": "frame_conversion_failed"]
|
|
@@ -45,6 +51,13 @@ public class UseSmileIDVisionDocumentFrameProcessorPlugin: FrameProcessorPlugin
|
|
|
45
51
|
let rotation = (arguments?["rotation"] as? NSNumber)?.intValue ?? 0
|
|
46
52
|
let uiImage = UIImage(cgImage: cgImage)
|
|
47
53
|
|
|
54
|
+
// Forward the on-screen document guide rectangle (if JS supplied it) so the
|
|
55
|
+
// saved capture is cropped to exactly what the user framed. Set before the
|
|
56
|
+
// scan so the storage holds the region when this frame's `persist` runs.
|
|
57
|
+
// Both calls serialise through the holder's operation queue, so the region
|
|
58
|
+
// is stable for the duration of this frame's scan.
|
|
59
|
+
UseSmileIDVisionDocumentSessionHolder.shared.setCropRegion(cropRegionFrom(arguments))
|
|
60
|
+
|
|
48
61
|
guard let result = UseSmileIDVisionDocumentSessionHolder.shared.scanAndDrain(
|
|
49
62
|
image: uiImage,
|
|
50
63
|
rotation: rotation
|
|
@@ -54,13 +67,31 @@ public class UseSmileIDVisionDocumentFrameProcessorPlugin: FrameProcessorPlugin
|
|
|
54
67
|
|
|
55
68
|
return result
|
|
56
69
|
}
|
|
70
|
+
|
|
71
|
+
/// Build the `ViewportCropRegion` from the flat numeric crop args the JS
|
|
72
|
+
/// worklet supplies. Returns `nil` when `cropWidth <= 0` (the "no region"
|
|
73
|
+
/// signal for selfie / before the viewport is measured). Flat primitives
|
|
74
|
+
/// because frame-processor arguments don't reliably accept nested objects.
|
|
75
|
+
private func cropRegionFrom(_ arguments: [AnyHashable: Any]?) -> ViewportCropRegion? {
|
|
76
|
+
guard let width = (arguments?["cropWidth"] as? NSNumber)?.floatValue, width > 0 else {
|
|
77
|
+
return nil
|
|
78
|
+
}
|
|
79
|
+
return ViewportCropRegion(
|
|
80
|
+
left: (arguments?["cropLeft"] as? NSNumber)?.floatValue ?? 0,
|
|
81
|
+
top: (arguments?["cropTop"] as? NSNumber)?.floatValue ?? 0,
|
|
82
|
+
width: width,
|
|
83
|
+
height: (arguments?["cropHeight"] as? NSNumber)?.floatValue ?? 0,
|
|
84
|
+
viewportWidthPx: (arguments?["cropViewportWidth"] as? NSNumber)?.intValue ?? 0,
|
|
85
|
+
viewportHeightPx: (arguments?["cropViewportHeight"] as? NSNumber)?.intValue ?? 0,
|
|
86
|
+
contentScale: .crop
|
|
87
|
+
)
|
|
88
|
+
}
|
|
57
89
|
}
|
|
58
90
|
|
|
59
91
|
|
|
60
92
|
private extension UIImage.Orientation {
|
|
61
|
-
///
|
|
62
|
-
/// undoes the flip
|
|
63
|
-
/// directly with their rotation counterparts (.left ↔ .leftMirrored).
|
|
93
|
+
/// Fold the buffer's horizontal mirror into this orientation so rendering
|
|
94
|
+
/// undoes the flip with the rotation (.left ↔ .leftMirrored, etc.).
|
|
64
95
|
func composedWithHorizontalMirror() -> UIImage.Orientation {
|
|
65
96
|
switch self {
|
|
66
97
|
case .up: return .upMirrored
|
|
@@ -77,6 +108,27 @@ private extension UIImage.Orientation {
|
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
|
|
111
|
+
private extension CIImage {
|
|
112
|
+
/// Rotate the sensor-natural (portrait) image onto the locked interface
|
|
113
|
+
/// (viewport) axis so the guide-rect crop lands on the right pixels and the
|
|
114
|
+
/// preview matches the capture. `degrees` is the interface orientation
|
|
115
|
+
/// (0/90/270). Directions are device-verified on iPhone — the opposite
|
|
116
|
+
/// assignment lands the document upside down.
|
|
117
|
+
func rotatedOntoViewportAxis(degrees: Int) -> CIImage {
|
|
118
|
+
let normalized = ((degrees % 360) + 360) % 360
|
|
119
|
+
switch normalized {
|
|
120
|
+
// Portrait document (0° interface): the rear camera's sensor-natural upright
|
|
121
|
+
// sits 180° from the portrait interface — the phone is held flat over the
|
|
122
|
+
// document — so the saved portrait document needs a half-turn. The guide
|
|
123
|
+
// rect is centred, so the half-turn leaves the crop aligned.
|
|
124
|
+
case 0: return oriented(.down)
|
|
125
|
+
case 90: return oriented(.left)
|
|
126
|
+
case 270: return oriented(.right)
|
|
127
|
+
default: return self
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
80
132
|
private extension CGImagePropertyOrientation {
|
|
81
133
|
/// Case-for-case translation from `UIImage.Orientation` — the two
|
|
82
134
|
/// enums share semantics but not raw values, so a direct cast would
|
|
@@ -4,22 +4,11 @@ import UseSmileIDBridge
|
|
|
4
4
|
import UseSmileIDVisionDocument
|
|
5
5
|
import VisionCamera
|
|
6
6
|
|
|
7
|
-
/// Expo Module bridging the
|
|
8
|
-
/// `
|
|
9
|
-
/// `
|
|
10
|
-
///
|
|
11
|
-
///
|
|
12
|
-
/// pipeline. This module adapts the four contract methods (`configure /
|
|
13
|
-
/// reset / flush / close`) and publishes the active session to
|
|
14
|
-
/// `UseSmileIDVisionDocumentSessionHolder` so the frame processor plugin
|
|
15
|
-
/// can feed camera frames directly without crossing the JS thread.
|
|
16
|
-
///
|
|
17
|
-
/// All session operations (configure, reset, flush, close, per-frame scan)
|
|
18
|
-
/// are serialised through `UseSmileIDVisionDocumentSessionHolder.shared`,
|
|
19
|
-
/// which owns the single `operationQueue` serialisation point.
|
|
20
|
-
///
|
|
21
|
-
/// Lifecycle: Expo instantiates exactly one `Module` per app process;
|
|
22
|
-
/// `session` is owned for the module's lifetime.
|
|
7
|
+
/// Expo Module bridging the native `UseSmileIDDocumentSession` (from the
|
|
8
|
+
/// `UseSmileIDVisionDocument` Swift Package) to JS. No ML logic here — it
|
|
9
|
+
/// adapts `configure / reset / flush / close` and publishes the session to
|
|
10
|
+
/// `UseSmileIDVisionDocumentSessionHolder` (which serialises every op on one
|
|
11
|
+
/// `operationQueue`) so the frame processor feeds frames without the JS thread.
|
|
23
12
|
public class UseSmileIDVisionDocumentModule: Module {
|
|
24
13
|
private lazy var analyzer: DocumentDetectorAnalyzer = DocumentDetectorAnalyzer.Factory().newInstance()!
|
|
25
14
|
|
|
@@ -54,6 +43,7 @@ public class UseSmileIDVisionDocumentModule: Module {
|
|
|
54
43
|
.map { CGFloat($0) / 100.0 } ?? 0.85
|
|
55
44
|
let storage = ResizingFrameStorage(
|
|
56
45
|
sessionDirectory: Self.freshSessionDirectory(),
|
|
46
|
+
captureType: .document,
|
|
57
47
|
documentSide: documentSide,
|
|
58
48
|
documentQuality: documentQuality
|
|
59
49
|
)
|
|
@@ -69,6 +59,9 @@ public class UseSmileIDVisionDocumentModule: Module {
|
|
|
69
59
|
// Publish the session through the holder's operation queue so the
|
|
70
60
|
// write is serialised with any concurrent scan on the FP thread.
|
|
71
61
|
UseSmileIDVisionDocumentSessionHolder.shared.setSession(newSession)
|
|
62
|
+
// Publish the storage too so the frame-processor plugin can forward the
|
|
63
|
+
// document guide-rectangle crop region to it per frame.
|
|
64
|
+
UseSmileIDVisionDocumentSessionHolder.shared.setStorage(storage)
|
|
72
65
|
}
|
|
73
66
|
|
|
74
67
|
AsyncFunction("reset") {
|
|
@@ -90,11 +83,13 @@ public class UseSmileIDVisionDocumentModule: Module {
|
|
|
90
83
|
|
|
91
84
|
AsyncFunction("close") {
|
|
92
85
|
UseSmileIDVisionDocumentSessionHolder.shared.setSession(nil)
|
|
86
|
+
UseSmileIDVisionDocumentSessionHolder.shared.setStorage(nil)
|
|
93
87
|
self.session = nil
|
|
94
88
|
}
|
|
95
89
|
|
|
96
90
|
OnDestroy {
|
|
97
91
|
UseSmileIDVisionDocumentSessionHolder.shared.setSession(nil)
|
|
92
|
+
UseSmileIDVisionDocumentSessionHolder.shared.setStorage(nil)
|
|
98
93
|
self.session = nil
|
|
99
94
|
}
|
|
100
95
|
}
|
|
@@ -125,7 +120,7 @@ private extension Dictionary where Key == String, Value == Any? {
|
|
|
125
120
|
documentDetectorConfig: DocumentDetectorConfig(
|
|
126
121
|
timeout: (self["timeout"] as? NSNumber)?.intValue ?? 15_000,
|
|
127
122
|
iouThreshold: (self["iouThreshold"] as? NSNumber)?.floatValue ?? 0.85,
|
|
128
|
-
timeRequired: (self["timeRequired"] as? NSNumber)?.intValue ??
|
|
123
|
+
timeRequired: (self["timeRequired"] as? NSNumber)?.intValue ?? 1_000,
|
|
129
124
|
minCoverageThreshold: (self["minCoverageThreshold"] as? NSNumber)?.floatValue ?? 0.18,
|
|
130
125
|
maxCoverageThreshold: (self["maxCoverageThreshold"] as? NSNumber)?.floatValue ?? 0.78,
|
|
131
126
|
centerToleranceX: (self["centerToleranceX"] as? NSNumber)?.floatValue ?? 0.05,
|
|
@@ -19,6 +19,12 @@ final class UseSmileIDVisionDocumentSessionHolder {
|
|
|
19
19
|
|
|
20
20
|
private var session: UseSmileIDDocumentSession?
|
|
21
21
|
|
|
22
|
+
/// The active session's frame storage. Published alongside `session` so the
|
|
23
|
+
/// frame-processor plugin can forward the document guide-rectangle crop
|
|
24
|
+
/// region (via `ResizingFrameStorage.setCropRegion`) without reaching through
|
|
25
|
+
/// the session. Read on the FP scan path; nulled when the session closes.
|
|
26
|
+
private var storage: ResizingFrameStorage?
|
|
27
|
+
|
|
22
28
|
private init() {}
|
|
23
29
|
|
|
24
30
|
/// Replace or clear the active session. Called from the Expo module's
|
|
@@ -29,6 +35,24 @@ final class UseSmileIDVisionDocumentSessionHolder {
|
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/// Publish (or clear) the active session's frame storage. Serialised on the
|
|
39
|
+
/// same `operationQueue` as `setSession` so the write is ordered with any
|
|
40
|
+
/// concurrent scan reading it on the FP thread.
|
|
41
|
+
func setStorage(_ newStorage: ResizingFrameStorage?) {
|
|
42
|
+
operationQueue.sync {
|
|
43
|
+
storage = newStorage
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Forward the on-screen document guide rectangle to the active storage so
|
|
48
|
+
/// the saved capture is cropped to exactly what the user framed. Pass `nil`
|
|
49
|
+
/// to clear the crop. No-op when no session/storage is configured.
|
|
50
|
+
func setCropRegion(_ region: ViewportCropRegion?) {
|
|
51
|
+
operationQueue.sync {
|
|
52
|
+
storage?.setCropRegion(region)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
32
56
|
/// Run `session.reset()` serialised with any in-flight scan. Called from
|
|
33
57
|
/// the Expo module's `reset` AsyncFunction.
|
|
34
58
|
func reset() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smileid/usesmileid_vision_document",
|
|
3
|
-
"version": "12.0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "12.0.0",
|
|
4
|
+
"description": "Apple Vision document detection implementation for the UseSmileID React Native (Expo) SDK.",
|
|
5
5
|
"homepage": "https://usesmileid.com",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"CHANGELOG.md"
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@smileid/usesmileid_bridge": "12.0.0
|
|
52
|
-
"@smileid/usesmileid_platform_interface": "12.0.0
|
|
51
|
+
"@smileid/usesmileid_bridge": "12.0.0",
|
|
52
|
+
"@smileid/usesmileid_platform_interface": "12.0.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"expo": ">=56.0.0",
|
|
@@ -64,8 +64,7 @@
|
|
|
64
64
|
"typescript": "^6.0.3"
|
|
65
65
|
},
|
|
66
66
|
"publishConfig": {
|
|
67
|
-
"access": "public"
|
|
68
|
-
"provenance": false
|
|
67
|
+
"access": "public"
|
|
69
68
|
},
|
|
70
69
|
"scripts": {
|
|
71
70
|
"build": "pnpm run build:lib && pnpm run build:plugin",
|
package/plugin/build/index.js
CHANGED
|
@@ -51,15 +51,15 @@ const fs = __importStar(require("fs"));
|
|
|
51
51
|
const path = __importStar(require("path"));
|
|
52
52
|
const podfile_1 = require("./lib/podfile");
|
|
53
53
|
const spm_1 = require("./lib/spm");
|
|
54
|
-
// Pinned to the exact `12.0.0
|
|
54
|
+
// Pinned to the exact `12.0.0` release tag — `from: 12.0.0`
|
|
55
55
|
// would not resolve because `ios-spm` publishes pre-release tags (e.g.
|
|
56
|
-
// `12.0.0
|
|
56
|
+
// `12.0.0`), and SPM excludes pre-release tags from open-ended
|
|
57
57
|
// major-version requirements.
|
|
58
58
|
const SMILE_ID_PACKAGE = {
|
|
59
59
|
url: "https://github.com/smileidentity/ios-spm",
|
|
60
60
|
requirement: {
|
|
61
61
|
kind: "exactVersion",
|
|
62
|
-
version: "12.0.0
|
|
62
|
+
version: "12.0.0",
|
|
63
63
|
},
|
|
64
64
|
};
|
|
65
65
|
const POD_TARGET = "UseSmileIDVisionDocumentExpo";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,iEAAiE;AACjE,0DAA0D;AAC1D,qEAAqE;AACrE,2CAA2C;AAC3C,mEAAmE;AACnE,wEAAwE;AACxE,2EAA2E;AAC3E,EAAE;AACF,oDAAoD;AACpD,oEAAoE;AACpE,sEAAsE;AACtE,oEAAoE;AACpE,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAE9B,yDAI8B;AAC9B,uCAAyB;AACzB,2CAA6B;AAE7B,2CAAsD;AACtD,mCAAuD;AAEvD,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,iEAAiE;AACjE,0DAA0D;AAC1D,qEAAqE;AACrE,2CAA2C;AAC3C,mEAAmE;AACnE,wEAAwE;AACxE,2EAA2E;AAC3E,EAAE;AACF,oDAAoD;AACpD,oEAAoE;AACpE,sEAAsE;AACtE,oEAAoE;AACpE,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAE9B,yDAI8B;AAC9B,uCAAyB;AACzB,2CAA6B;AAE7B,2CAAsD;AACtD,mCAAuD;AAEvD,4DAA4D;AAC5D,uEAAuE;AACvE,+DAA+D;AAC/D,8BAA8B;AAC9B,MAAM,gBAAgB,GAAe;IACnC,GAAG,EAAE,0CAA0C;IAC/C,WAAW,EAAE;QACX,IAAI,EAAE,cAAc;QACpB,OAAO,EAAE,QAAQ;KAClB;CACF,CAAC;AAEF,MAAM,UAAU,GAAG,8BAA8B,CAAC;AAClD,MAAM,YAAY,GAAG,CAAC,0BAA0B,EAAE,kBAAkB,CAAU,CAAC;AAC/E,MAAM,cAAc,GAAG,4BAA4B,CAAC;AAEpD,MAAM,qBAAqB,GAAiB,CAAC,MAAM,EAAE,EAAE;IACrD,mEAAmE;IACnE,mEAAmE;IACnE,wBAAwB;IACxB,MAAM,GAAG,IAAA,iCAAgB,EAAC,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE;QAC3C,MAAM,SAAS,GAAG,MAAM,CAAC,UAAU,CAAC,WAAW,CAAC;QAChD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CACb,8FAA8F,CAC/F,CAAC;QACJ,CAAC;QACD,IAAA,oBAAc,EAAC,MAAM,CAAC,UAAU,EAAE,gBAAgB,EAAE;YAClD,UAAU,EAAE,SAAS;YACrB,QAAQ,EAAE,CAAC,GAAG,YAAY,CAAC;SAC5B,CAAC,CAAC;QACH,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC,CAAC;IAEH,mEAAmE;IACnE,iEAAiE;IACjE,+DAA+D;IAC/D,oEAAoE;IACpE,MAAM,GAAG,IAAA,iCAAgB,EAAC,MAAM,EAAE;QAChC,KAAK;QACL,CAAC,MAAM,EAAE,EAAE;YACT,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAC3B,MAAM,CAAC,UAAU,CAAC,mBAAmB,EACrC,SAAS,CACV,CAAC;YACF,MAAM,QAAQ,GAAG,EAAE,CAAC,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;YACvD,MAAM,IAAI,GAAG,IAAA,+BAAqB,EAAC,QAAQ,EAAE;gBAC3C,MAAM,EAAE,cAAc;gBACtB,OAAO,EAAE,gBAAgB;gBACzB,SAAS,EAAE,UAAU;gBACrB,QAAQ,EAAE,CAAC,GAAG,YAAY,CAAC;aAC5B,CAAC,CAAC;YACH,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,EAAE,CAAC,aAAa,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;YACtC,CAAC;YACD,OAAO,MAAM,CAAC;QAChB,CAAC;KACF,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAChB,CAAC,CAAC;AAEF,kBAAe,qBAAqB,CAAC"}
|
package/plugin/src/index.ts
CHANGED
|
@@ -23,15 +23,15 @@ import * as path from "path";
|
|
|
23
23
|
import { injectPodfileSpmBlock } from "./lib/podfile";
|
|
24
24
|
import { linkSpmPackage, SpmPackage } from "./lib/spm";
|
|
25
25
|
|
|
26
|
-
// Pinned to the exact `12.0.0
|
|
26
|
+
// Pinned to the exact `12.0.0` release tag — `from: 12.0.0`
|
|
27
27
|
// would not resolve because `ios-spm` publishes pre-release tags (e.g.
|
|
28
|
-
// `12.0.0
|
|
28
|
+
// `12.0.0`), and SPM excludes pre-release tags from open-ended
|
|
29
29
|
// major-version requirements.
|
|
30
30
|
const SMILE_ID_PACKAGE: SpmPackage = {
|
|
31
31
|
url: "https://github.com/smileidentity/ios-spm",
|
|
32
32
|
requirement: {
|
|
33
33
|
kind: "exactVersion",
|
|
34
|
-
version: "12.0.0
|
|
34
|
+
version: "12.0.0",
|
|
35
35
|
},
|
|
36
36
|
};
|
|
37
37
|
|