react-native-rectangle-doc-scanner 1.13.0 → 1.14.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/README.md +49 -120
- package/dist/DocScanner.d.ts +6 -8
- package/dist/DocScanner.js +55 -108
- package/package.json +4 -2
- package/src/DocScanner.tsx +121 -241
- package/src/external.d.ts +28 -12
- package/android/build.gradle +0 -55
- package/android/consumer-rules.pro +0 -1
- package/android/proguard-rules.pro +0 -1
- package/android/src/main/java/com/reactnativerectangledocscanner/RNRDocScannerModule.kt +0 -37
- package/android/src/main/java/com/reactnativerectangledocscanner/RNRDocScannerPackage.kt +0 -16
- package/android/src/main/java/com/reactnativerectangledocscanner/RNRDocScannerView.kt +0 -568
- package/android/src/main/java/com/reactnativerectangledocscanner/RNRDocScannerViewManager.kt +0 -50
- package/docs/native-module-architecture.md +0 -178
- package/ios/RNRDocScannerModule.swift +0 -49
- package/ios/RNRDocScannerView.swift +0 -694
- package/ios/RNRDocScannerViewManager.m +0 -22
- package/ios/RNRDocScannerViewManager.swift +0 -47
- package/react-native-rectangle-doc-scanner.podspec +0 -22
- package/src/utils/overlay.tsx +0 -208
- package/src/utils/quad.ts +0 -181
- package/src/utils/stability.ts +0 -32
|
@@ -1,694 +0,0 @@
|
|
|
1
|
-
import AVFoundation
|
|
2
|
-
import CoreImage
|
|
3
|
-
import Foundation
|
|
4
|
-
import React
|
|
5
|
-
import UIKit
|
|
6
|
-
import Vision
|
|
7
|
-
|
|
8
|
-
@objc(RNRDocScannerView)
|
|
9
|
-
class RNRDocScannerView: UIView, AVCaptureVideoDataOutputSampleBufferDelegate, AVCapturePhotoCaptureDelegate {
|
|
10
|
-
@objc var detectionCountBeforeCapture: NSNumber = 8
|
|
11
|
-
@objc var autoCapture: Bool = true
|
|
12
|
-
@objc var enableTorch: Bool = false {
|
|
13
|
-
didSet {
|
|
14
|
-
updateTorchMode()
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
@objc var quality: NSNumber = 90
|
|
18
|
-
@objc var useBase64: Bool = false
|
|
19
|
-
|
|
20
|
-
@objc var onRectangleDetect: RCTDirectEventBlock?
|
|
21
|
-
@objc var onPictureTaken: RCTDirectEventBlock?
|
|
22
|
-
|
|
23
|
-
private let session = AVCaptureSession()
|
|
24
|
-
private let sessionQueue = DispatchQueue(label: "com.reactnative.rectangledocscanner.session")
|
|
25
|
-
private let analysisQueue = DispatchQueue(label: "com.reactnative.rectangledocscanner.analysis")
|
|
26
|
-
private let ciContext = CIContext()
|
|
27
|
-
|
|
28
|
-
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
29
|
-
private let videoOutput = AVCaptureVideoDataOutput()
|
|
30
|
-
private let photoOutput = AVCapturePhotoOutput()
|
|
31
|
-
private var smoothedOverlayPoints: [CGPoint]?
|
|
32
|
-
private let outlineLayer = CAShapeLayer()
|
|
33
|
-
private let gridLayer = CAShapeLayer()
|
|
34
|
-
|
|
35
|
-
private var currentStableCounter: Int = 0
|
|
36
|
-
private var isProcessingFrame = false
|
|
37
|
-
private var isCaptureInFlight = false
|
|
38
|
-
private var lastObservation: VNRectangleObservation?
|
|
39
|
-
private var missedDetectionFrames: Int = 0
|
|
40
|
-
private let maxMissedDetections = 1
|
|
41
|
-
private var lastFrameSize: CGSize = .zero
|
|
42
|
-
private var photoCaptureCompletion: ((Result<RNRDocScannerCaptureResult, Error>) -> Void)?
|
|
43
|
-
|
|
44
|
-
override init(frame: CGRect) {
|
|
45
|
-
super.init(frame: frame)
|
|
46
|
-
commonInit()
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
required init?(coder: NSCoder) {
|
|
50
|
-
super.init(coder: coder)
|
|
51
|
-
commonInit()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
private func commonInit() {
|
|
55
|
-
backgroundColor = .black
|
|
56
|
-
configurePreviewLayer()
|
|
57
|
-
configureOverlayLayers()
|
|
58
|
-
configureSession()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
private func configurePreviewLayer() {
|
|
62
|
-
let layer = AVCaptureVideoPreviewLayer(session: session)
|
|
63
|
-
layer.videoGravity = .resizeAspectFill
|
|
64
|
-
self.layer.insertSublayer(layer, at: 0)
|
|
65
|
-
previewLayer = layer
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
private func configureOverlayLayers() {
|
|
69
|
-
outlineLayer.strokeColor = UIColor(red: 0.18, green: 0.6, blue: 0.95, alpha: 1.0).cgColor
|
|
70
|
-
outlineLayer.fillColor = UIColor(red: 0.18, green: 0.6, blue: 0.95, alpha: 0.2).cgColor
|
|
71
|
-
outlineLayer.lineWidth = 4
|
|
72
|
-
outlineLayer.lineJoin = .round
|
|
73
|
-
outlineLayer.isHidden = true
|
|
74
|
-
layer.addSublayer(outlineLayer)
|
|
75
|
-
|
|
76
|
-
gridLayer.strokeColor = UIColor(red: 0.18, green: 0.6, blue: 0.95, alpha: 0.35).cgColor
|
|
77
|
-
gridLayer.fillColor = UIColor.clear.cgColor
|
|
78
|
-
gridLayer.lineWidth = 1.5
|
|
79
|
-
gridLayer.lineJoin = .round
|
|
80
|
-
gridLayer.isHidden = true
|
|
81
|
-
gridLayer.zPosition = outlineLayer.zPosition + 1
|
|
82
|
-
layer.addSublayer(gridLayer)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
private func configureSession() {
|
|
86
|
-
sessionQueue.async { [weak self] in
|
|
87
|
-
guard let self else { return }
|
|
88
|
-
|
|
89
|
-
session.beginConfiguration()
|
|
90
|
-
session.sessionPreset = .high
|
|
91
|
-
|
|
92
|
-
defer {
|
|
93
|
-
session.commitConfiguration()
|
|
94
|
-
if !session.isRunning {
|
|
95
|
-
session.startRunning()
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
guard
|
|
100
|
-
let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
|
101
|
-
let videoInput = try? AVCaptureDeviceInput(device: videoDevice),
|
|
102
|
-
session.canAddInput(videoInput)
|
|
103
|
-
else {
|
|
104
|
-
NSLog("[RNRDocScanner] Unable to create AVCaptureDeviceInput")
|
|
105
|
-
return
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
session.addInput(videoInput)
|
|
109
|
-
|
|
110
|
-
if session.canAddOutput(photoOutput) {
|
|
111
|
-
photoOutput.isHighResolutionCaptureEnabled = true
|
|
112
|
-
session.addOutput(photoOutput)
|
|
113
|
-
if let connection = photoOutput.connection(with: .video), connection.isVideoOrientationSupported {
|
|
114
|
-
connection.videoOrientation = .portrait
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
videoOutput.videoSettings = [
|
|
119
|
-
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
|
120
|
-
]
|
|
121
|
-
videoOutput.alwaysDiscardsLateVideoFrames = true
|
|
122
|
-
videoOutput.setSampleBufferDelegate(self, queue: analysisQueue)
|
|
123
|
-
|
|
124
|
-
if session.canAddOutput(videoOutput) {
|
|
125
|
-
session.addOutput(videoOutput)
|
|
126
|
-
if let connection = videoOutput.connection(with: .video), connection.isVideoOrientationSupported {
|
|
127
|
-
connection.videoOrientation = .portrait
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
override func layoutSubviews() {
|
|
134
|
-
super.layoutSubviews()
|
|
135
|
-
previewLayer?.frame = bounds
|
|
136
|
-
if let connection = previewLayer?.connection, connection.isVideoOrientationSupported {
|
|
137
|
-
connection.videoOrientation = .portrait
|
|
138
|
-
}
|
|
139
|
-
outlineLayer.frame = bounds
|
|
140
|
-
gridLayer.frame = bounds
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private func updateTorchMode() {
|
|
144
|
-
sessionQueue.async { [weak self] in
|
|
145
|
-
guard
|
|
146
|
-
let self,
|
|
147
|
-
let device = self.videoDevice(for: .back),
|
|
148
|
-
device.hasTorch
|
|
149
|
-
else {
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
do {
|
|
154
|
-
try device.lockForConfiguration()
|
|
155
|
-
device.torchMode = self.enableTorch ? .on : .off
|
|
156
|
-
device.unlockForConfiguration()
|
|
157
|
-
} catch {
|
|
158
|
-
NSLog("[RNRDocScanner] Failed to update torch mode: \(error)")
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
private func videoDevice(for position: AVCaptureDevice.Position) -> AVCaptureDevice? {
|
|
164
|
-
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
|
|
165
|
-
return device
|
|
166
|
-
}
|
|
167
|
-
return AVCaptureDevice.devices(for: .video).first(where: { $0.position == position })
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// MARK: - Detection
|
|
171
|
-
|
|
172
|
-
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
|
173
|
-
if isProcessingFrame {
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
|
177
|
-
return
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
isProcessingFrame = true
|
|
181
|
-
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
|
182
|
-
let frameSize = CGSize(width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
|
183
|
-
lastFrameSize = frameSize
|
|
184
|
-
let orientation = currentExifOrientation()
|
|
185
|
-
|
|
186
|
-
defer {
|
|
187
|
-
CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
|
|
188
|
-
isProcessingFrame = false
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
let requestHandler: VNRequestCompletionHandler = { [weak self] request, error in
|
|
192
|
-
guard let self = self else { return }
|
|
193
|
-
|
|
194
|
-
if let error = error {
|
|
195
|
-
NSLog("[RNRDocScanner] detection error: \(error)")
|
|
196
|
-
self.lastObservation = nil
|
|
197
|
-
self.handleDetectedRectangle(nil, frameSize: frameSize)
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
guard let observations = request.results as? [VNRectangleObservation], !observations.isEmpty else {
|
|
202
|
-
self.lastObservation = nil
|
|
203
|
-
self.handleDetectedRectangle(nil, frameSize: frameSize)
|
|
204
|
-
return
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
let filtered = observations.filter { $0.confidence >= 0.55 }
|
|
208
|
-
let candidates = filtered.isEmpty ? observations : filtered
|
|
209
|
-
let weighted: [VNRectangleObservation] = candidates.sorted { (lhs: VNRectangleObservation, rhs: VNRectangleObservation) -> Bool in
|
|
210
|
-
let lhsScore: CGFloat = CGFloat(lhs.confidence) * lhs.boundingBox.area
|
|
211
|
-
let rhsScore: CGFloat = CGFloat(rhs.confidence) * rhs.boundingBox.area
|
|
212
|
-
return lhsScore > rhsScore
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
guard let best = weighted.first else {
|
|
216
|
-
self.lastObservation = nil
|
|
217
|
-
self.handleDetectedRectangle(nil, frameSize: frameSize)
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
self.lastObservation = best
|
|
222
|
-
self.missedDetectionFrames = 0
|
|
223
|
-
self.handleDetectedRectangle(best, frameSize: frameSize)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
let request = VNDetectRectanglesRequest(completionHandler: requestHandler)
|
|
227
|
-
|
|
228
|
-
request.maximumObservations = 3
|
|
229
|
-
request.minimumConfidence = 0.65
|
|
230
|
-
request.minimumAspectRatio = 0.12
|
|
231
|
-
request.maximumAspectRatio = 1.9
|
|
232
|
-
request.minimumSize = 0.05
|
|
233
|
-
if #available(iOS 13.0, *) {
|
|
234
|
-
request.quadratureTolerance = 18
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
var processedImage = CIImage(cvPixelBuffer: pixelBuffer)
|
|
238
|
-
processedImage = processedImage.applyingFilter("CIColorControls", parameters: [
|
|
239
|
-
kCIInputContrastKey: 1.35,
|
|
240
|
-
kCIInputBrightnessKey: 0.02,
|
|
241
|
-
kCIInputSaturationKey: 1.05,
|
|
242
|
-
])
|
|
243
|
-
processedImage = processedImage.applyingFilter("CISharpenLuminance", parameters: [kCIInputSharpnessKey: 0.5])
|
|
244
|
-
|
|
245
|
-
let handler = VNImageRequestHandler(ciImage: processedImage, orientation: orientation, options: [:])
|
|
246
|
-
do {
|
|
247
|
-
try handler.perform([request])
|
|
248
|
-
} catch {
|
|
249
|
-
NSLog("[RNRDocScanner] Failed to run Vision request: \(error)")
|
|
250
|
-
lastObservation = nil
|
|
251
|
-
handleDetectedRectangle(nil, frameSize: frameSize)
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
func handleDetectedRectangle(_ rectangle: VNRectangleObservation?, frameSize: CGSize) {
|
|
256
|
-
guard let onRectangleDetect else { return }
|
|
257
|
-
|
|
258
|
-
let effectiveObservation: VNRectangleObservation?
|
|
259
|
-
if let rect = rectangle {
|
|
260
|
-
effectiveObservation = rect
|
|
261
|
-
lastObservation = rect
|
|
262
|
-
missedDetectionFrames = 0
|
|
263
|
-
} else if missedDetectionFrames < maxMissedDetections, let cached = lastObservation {
|
|
264
|
-
missedDetectionFrames += 1
|
|
265
|
-
effectiveObservation = cached
|
|
266
|
-
} else {
|
|
267
|
-
lastObservation = nil
|
|
268
|
-
missedDetectionFrames = 0
|
|
269
|
-
smoothedOverlayPoints = nil
|
|
270
|
-
effectiveObservation = nil
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
let overlayStableThreshold = max(2, Int(truncating: detectionCountBeforeCapture) / 2)
|
|
274
|
-
let payload: [String: Any?]
|
|
275
|
-
|
|
276
|
-
if let observation = effectiveObservation {
|
|
277
|
-
let points = [
|
|
278
|
-
pointForOverlay(from: observation.topLeft, frameSize: frameSize),
|
|
279
|
-
pointForOverlay(from: observation.topRight, frameSize: frameSize),
|
|
280
|
-
pointForOverlay(from: observation.bottomRight, frameSize: frameSize),
|
|
281
|
-
pointForOverlay(from: observation.bottomLeft, frameSize: frameSize),
|
|
282
|
-
]
|
|
283
|
-
|
|
284
|
-
currentStableCounter = min(currentStableCounter + 1, Int(truncating: detectionCountBeforeCapture))
|
|
285
|
-
|
|
286
|
-
let normalizedArea = observation.boundingBox.width * observation.boundingBox.height
|
|
287
|
-
let meetsArea = normalizedArea >= 0.06 && normalizedArea <= 0.95
|
|
288
|
-
let meetsConfidence = observation.confidence >= 0.65
|
|
289
|
-
let shouldDisplayOverlay = currentStableCounter >= overlayStableThreshold && meetsArea && meetsConfidence
|
|
290
|
-
updateNativeOverlay(with: shouldDisplayOverlay ? observation : nil)
|
|
291
|
-
|
|
292
|
-
payload = [
|
|
293
|
-
"rectangleCoordinates": shouldDisplayOverlay ? [
|
|
294
|
-
"topLeft": ["x": points[0].x, "y": points[0].y],
|
|
295
|
-
"topRight": ["x": points[1].x, "y": points[1].y],
|
|
296
|
-
"bottomRight": ["x": points[2].x, "y": points[2].y],
|
|
297
|
-
"bottomLeft": ["x": points[3].x, "y": points[3].y],
|
|
298
|
-
] : NSNull(),
|
|
299
|
-
"stableCounter": currentStableCounter,
|
|
300
|
-
"frameWidth": frameSize.width,
|
|
301
|
-
"frameHeight": frameSize.height,
|
|
302
|
-
]
|
|
303
|
-
} else {
|
|
304
|
-
currentStableCounter = 0
|
|
305
|
-
updateNativeOverlay(with: nil)
|
|
306
|
-
payload = [
|
|
307
|
-
"rectangleCoordinates": NSNull(),
|
|
308
|
-
"stableCounter": currentStableCounter,
|
|
309
|
-
"frameWidth": frameSize.width,
|
|
310
|
-
"frameHeight": frameSize.height,
|
|
311
|
-
]
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
DispatchQueue.main.async {
|
|
315
|
-
onRectangleDetect(payload.compactMapValues { $0 })
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
private func pointForOverlay(from normalizedPoint: CGPoint, frameSize: CGSize) -> CGPoint {
|
|
320
|
-
CGPoint(x: normalizedPoint.x * frameSize.width, y: (1 - normalizedPoint.y) * frameSize.height)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private func updateNativeOverlay(with observation: VNRectangleObservation?) {
|
|
324
|
-
DispatchQueue.main.async {
|
|
325
|
-
guard let observation else {
|
|
326
|
-
self.outlineLayer.path = nil
|
|
327
|
-
self.gridLayer.path = nil
|
|
328
|
-
self.outlineLayer.isHidden = true
|
|
329
|
-
self.gridLayer.isHidden = true
|
|
330
|
-
self.smoothedOverlayPoints = nil
|
|
331
|
-
return
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
guard let previewLayer = self.previewLayer else {
|
|
335
|
-
return
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
let rawPoints = [
|
|
339
|
-
self.convertToLayerPoint(observation.topLeft, previewLayer: previewLayer),
|
|
340
|
-
self.convertToLayerPoint(observation.topRight, previewLayer: previewLayer),
|
|
341
|
-
self.convertToLayerPoint(observation.bottomRight, previewLayer: previewLayer),
|
|
342
|
-
self.convertToLayerPoint(observation.bottomLeft, previewLayer: previewLayer),
|
|
343
|
-
]
|
|
344
|
-
|
|
345
|
-
let orderedPoints = self.orderPoints(rawPoints)
|
|
346
|
-
|
|
347
|
-
let points: [CGPoint]
|
|
348
|
-
if let previous = self.smoothedOverlayPoints, previous.count == 4 {
|
|
349
|
-
points = zip(previous, orderedPoints).map { prev, next in
|
|
350
|
-
CGPoint(x: prev.x * 0.7 + next.x * 0.3, y: prev.y * 0.7 + next.y * 0.3)
|
|
351
|
-
}
|
|
352
|
-
} else {
|
|
353
|
-
points = orderedPoints
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
self.smoothedOverlayPoints = points
|
|
357
|
-
|
|
358
|
-
let outline = UIBezierPath()
|
|
359
|
-
outline.move(to: points[0])
|
|
360
|
-
outline.addLine(to: points[1])
|
|
361
|
-
outline.addLine(to: points[2])
|
|
362
|
-
outline.addLine(to: points[3])
|
|
363
|
-
outline.close()
|
|
364
|
-
|
|
365
|
-
self.outlineLayer.path = outline.cgPath
|
|
366
|
-
self.outlineLayer.isHidden = false
|
|
367
|
-
|
|
368
|
-
let gridPath = UIBezierPath()
|
|
369
|
-
let steps: [CGFloat] = [1.0 / 3.0, 2.0 / 3.0]
|
|
370
|
-
|
|
371
|
-
for step in steps {
|
|
372
|
-
let startVertical = self.interpolate(points[0], points[1], t: step)
|
|
373
|
-
let endVertical = self.interpolate(points[3], points[2], t: step)
|
|
374
|
-
gridPath.move(to: startVertical)
|
|
375
|
-
gridPath.addLine(to: endVertical)
|
|
376
|
-
|
|
377
|
-
let startHorizontal = self.interpolate(points[0], points[3], t: step)
|
|
378
|
-
let endHorizontal = self.interpolate(points[1], points[2], t: step)
|
|
379
|
-
gridPath.move(to: startHorizontal)
|
|
380
|
-
gridPath.addLine(to: endHorizontal)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
self.gridLayer.path = gridPath.cgPath
|
|
384
|
-
self.gridLayer.isHidden = false
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
private func convertToLayerPoint(_ normalizedPoint: CGPoint, previewLayer: AVCaptureVideoPreviewLayer) -> CGPoint {
|
|
389
|
-
let devicePoint = CGPoint(x: normalizedPoint.x, y: 1 - normalizedPoint.y)
|
|
390
|
-
return previewLayer.layerPointConverted(fromCaptureDevicePoint: devicePoint)
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
private func interpolate(_ start: CGPoint, _ end: CGPoint, t: CGFloat) -> CGPoint {
|
|
394
|
-
CGPoint(x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t)
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
private func orderPoints(_ points: [CGPoint]) -> [CGPoint] {
|
|
398
|
-
guard points.count == 4 else { return points }
|
|
399
|
-
|
|
400
|
-
var topLeft = points[0]
|
|
401
|
-
var topRight = points[0]
|
|
402
|
-
var bottomRight = points[0]
|
|
403
|
-
var bottomLeft = points[0]
|
|
404
|
-
|
|
405
|
-
var minSum = CGFloat.greatestFiniteMagnitude
|
|
406
|
-
var maxSum = -CGFloat.greatestFiniteMagnitude
|
|
407
|
-
var minDiff = CGFloat.greatestFiniteMagnitude
|
|
408
|
-
var maxDiff = -CGFloat.greatestFiniteMagnitude
|
|
409
|
-
|
|
410
|
-
for point in points {
|
|
411
|
-
let sum = point.x + point.y
|
|
412
|
-
if sum < minSum {
|
|
413
|
-
minSum = sum
|
|
414
|
-
topLeft = point
|
|
415
|
-
}
|
|
416
|
-
if sum > maxSum {
|
|
417
|
-
maxSum = sum
|
|
418
|
-
bottomRight = point
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
let diff = point.x - point.y
|
|
422
|
-
if diff < minDiff {
|
|
423
|
-
minDiff = diff
|
|
424
|
-
bottomLeft = point
|
|
425
|
-
}
|
|
426
|
-
if diff > maxDiff {
|
|
427
|
-
maxDiff = diff
|
|
428
|
-
topRight = point
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
var ordered = [topLeft, topRight, bottomRight, bottomLeft]
|
|
433
|
-
if cross(ordered[0], ordered[1], ordered[2]) < 0 {
|
|
434
|
-
ordered = [topLeft, bottomLeft, bottomRight, topRight]
|
|
435
|
-
}
|
|
436
|
-
return ordered
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
private func cross(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint) -> CGFloat {
|
|
440
|
-
let abx = b.x - a.x
|
|
441
|
-
let aby = b.y - a.y
|
|
442
|
-
let acx = c.x - a.x
|
|
443
|
-
let acy = c.y - a.y
|
|
444
|
-
return abx * acy - aby * acx
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// MARK: - Capture
|
|
448
|
-
|
|
449
|
-
func capture(completion: @escaping (Result<RNRDocScannerCaptureResult, Error>) -> Void) {
|
|
450
|
-
sessionQueue.async { [weak self] in
|
|
451
|
-
guard let self else { return }
|
|
452
|
-
|
|
453
|
-
if isCaptureInFlight {
|
|
454
|
-
completion(.failure(RNRDocScannerError.captureInProgress))
|
|
455
|
-
return
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
guard photoOutput.connection(with: .video) != nil else {
|
|
459
|
-
completion(.failure(RNRDocScannerError.captureUnavailable))
|
|
460
|
-
return
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
isCaptureInFlight = true
|
|
464
|
-
photoCaptureCompletion = completion
|
|
465
|
-
|
|
466
|
-
let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
|
467
|
-
settings.isHighResolutionPhotoEnabled = photoOutput.isHighResolutionCaptureEnabled
|
|
468
|
-
if photoOutput.supportedFlashModes.contains(.on) {
|
|
469
|
-
settings.flashMode = enableTorch ? .on : .off
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
photoOutput.capturePhoto(with: settings, delegate: self)
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
func resetStability() {
|
|
477
|
-
currentStableCounter = 0
|
|
478
|
-
lastObservation = nil
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// MARK: - AVCapturePhotoCaptureDelegate
|
|
482
|
-
|
|
483
|
-
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
|
484
|
-
guard let completion = photoCaptureCompletion else {
|
|
485
|
-
isCaptureInFlight = false
|
|
486
|
-
return
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if let error {
|
|
490
|
-
finishCapture(result: .failure(error))
|
|
491
|
-
return
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
guard let data = photo.fileDataRepresentation() else {
|
|
495
|
-
finishCapture(result: .failure(RNRDocScannerError.imageCreationFailed))
|
|
496
|
-
return
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
let dimensions = photoDimensions(photo: photo)
|
|
500
|
-
do {
|
|
501
|
-
let original = try serializeImageData(data, suffix: "original")
|
|
502
|
-
let croppedString: String?
|
|
503
|
-
|
|
504
|
-
if let croppedData = generateCroppedImage(from: data) {
|
|
505
|
-
croppedString = try serializeImageData(croppedData, suffix: "cropped").string
|
|
506
|
-
} else {
|
|
507
|
-
croppedString = original.string
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
let result = RNRDocScannerCaptureResult(
|
|
511
|
-
croppedImage: croppedString,
|
|
512
|
-
originalImage: original.string,
|
|
513
|
-
width: dimensions.width,
|
|
514
|
-
height: dimensions.height
|
|
515
|
-
)
|
|
516
|
-
|
|
517
|
-
finishCapture(result: .success(result))
|
|
518
|
-
} catch {
|
|
519
|
-
finishCapture(result: .failure(error))
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
func photoOutput(_ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, error: Error?) {
|
|
524
|
-
if let error, isCaptureInFlight {
|
|
525
|
-
finishCapture(result: .failure(error))
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
private func finishCapture(result: Result<RNRDocScannerCaptureResult, Error>) {
|
|
530
|
-
let completion = photoCaptureCompletion
|
|
531
|
-
photoCaptureCompletion = nil
|
|
532
|
-
isCaptureInFlight = false
|
|
533
|
-
|
|
534
|
-
DispatchQueue.main.async {
|
|
535
|
-
switch result {
|
|
536
|
-
case let .success(payload):
|
|
537
|
-
completion?(.success(payload))
|
|
538
|
-
self.emitPictureTaken(payload)
|
|
539
|
-
case let .failure(error):
|
|
540
|
-
completion?(.failure(error))
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
private func emitPictureTaken(_ result: RNRDocScannerCaptureResult) {
|
|
546
|
-
guard let onPictureTaken else { return }
|
|
547
|
-
let payload: [String: Any] = [
|
|
548
|
-
"croppedImage": result.croppedImage ?? NSNull(),
|
|
549
|
-
"initialImage": result.originalImage,
|
|
550
|
-
"width": result.width,
|
|
551
|
-
"height": result.height,
|
|
552
|
-
]
|
|
553
|
-
onPictureTaken(payload)
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// MARK: - Helpers
|
|
557
|
-
|
|
558
|
-
private func currentExifOrientation() -> CGImagePropertyOrientation {
|
|
559
|
-
switch UIDevice.current.orientation {
|
|
560
|
-
case .landscapeLeft:
|
|
561
|
-
return .up
|
|
562
|
-
case .landscapeRight:
|
|
563
|
-
return .down
|
|
564
|
-
case .portraitUpsideDown:
|
|
565
|
-
return .left
|
|
566
|
-
default:
|
|
567
|
-
return .right
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
private func photoDimensions(photo: AVCapturePhoto) -> CGSize {
|
|
572
|
-
if let pixelBuffer = photo.pixelBuffer {
|
|
573
|
-
return CGSize(width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
let width = photo.metadata[kCGImagePropertyPixelWidth as String] as? Int ?? Int(lastFrameSize.width)
|
|
577
|
-
let height = photo.metadata[kCGImagePropertyPixelHeight as String] as? Int ?? Int(lastFrameSize.height)
|
|
578
|
-
return CGSize(width: CGFloat(width), height: CGFloat(height))
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
private func serializeImageData(_ data: Data, suffix: String) throws -> (string: String, url: URL?) {
|
|
582
|
-
let filename = "docscan-\(UUID().uuidString)-\(suffix).jpg"
|
|
583
|
-
let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
584
|
-
do {
|
|
585
|
-
try data.write(to: url, options: .atomic)
|
|
586
|
-
} catch {
|
|
587
|
-
throw RNRDocScannerError.fileWriteFailed
|
|
588
|
-
}
|
|
589
|
-
return (url.absoluteString, url)
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
private func generateCroppedImage(from data: Data) -> Data? {
|
|
593
|
-
guard let ciImage = CIImage(data: data) else {
|
|
594
|
-
return nil
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
var observation: VNRectangleObservation? = nil
|
|
598
|
-
let request = VNDetectRectanglesRequest { request, _ in
|
|
599
|
-
observation = (request.results as? [VNRectangleObservation])?.first
|
|
600
|
-
}
|
|
601
|
-
request.maximumObservations = 1
|
|
602
|
-
request.minimumConfidence = 0.6
|
|
603
|
-
|
|
604
|
-
let handler = VNImageRequestHandler(ciImage: ciImage, options: [:])
|
|
605
|
-
try? handler.perform([request])
|
|
606
|
-
|
|
607
|
-
guard let targetObservation = observation ?? lastObservation else {
|
|
608
|
-
return nil
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
let size = ciImage.extent.size
|
|
612
|
-
let topLeft = normalizedPoint(targetObservation.topLeft, in: size, flipY: false)
|
|
613
|
-
let topRight = normalizedPoint(targetObservation.topRight, in: size, flipY: false)
|
|
614
|
-
let bottomLeft = normalizedPoint(targetObservation.bottomLeft, in: size, flipY: false)
|
|
615
|
-
let bottomRight = normalizedPoint(targetObservation.bottomRight, in: size, flipY: false)
|
|
616
|
-
|
|
617
|
-
guard let filter = CIFilter(name: "CIPerspectiveCorrection") else {
|
|
618
|
-
return nil
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
filter.setValue(ciImage, forKey: kCIInputImageKey)
|
|
622
|
-
filter.setValue(CIVector(cgPoint: topLeft), forKey: "inputTopLeft")
|
|
623
|
-
filter.setValue(CIVector(cgPoint: topRight), forKey: "inputTopRight")
|
|
624
|
-
filter.setValue(CIVector(cgPoint: bottomLeft), forKey: "inputBottomLeft")
|
|
625
|
-
filter.setValue(CIVector(cgPoint: bottomRight), forKey: "inputBottomRight")
|
|
626
|
-
|
|
627
|
-
guard let corrected = filter.outputImage else {
|
|
628
|
-
return nil
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
guard let cgImage = ciContext.createCGImage(corrected, from: corrected.extent) else {
|
|
632
|
-
return nil
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
let cropped = UIImage(cgImage: cgImage)
|
|
636
|
-
return cropped.jpegData(compressionQuality: CGFloat(max(0.05, min(1.0, quality.doubleValue / 100.0))))
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
private func normalizedPoint(_ point: CGPoint, in size: CGSize, flipY: Bool) -> CGPoint {
|
|
640
|
-
let yValue = flipY ? (1 - point.y) : point.y
|
|
641
|
-
return CGPoint(x: point.x * size.width, y: yValue * size.height)
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
struct RNRDocScannerCaptureResult {
|
|
646
|
-
let croppedImage: String?
|
|
647
|
-
let originalImage: String
|
|
648
|
-
let width: CGFloat
|
|
649
|
-
let height: CGFloat
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
enum RNRDocScannerError: Error {
|
|
653
|
-
case captureInProgress
|
|
654
|
-
case captureUnavailable
|
|
655
|
-
case imageCreationFailed
|
|
656
|
-
case fileWriteFailed
|
|
657
|
-
case viewNotFound
|
|
658
|
-
|
|
659
|
-
var code: String {
|
|
660
|
-
switch self {
|
|
661
|
-
case .captureInProgress:
|
|
662
|
-
return "capture_in_progress"
|
|
663
|
-
case .captureUnavailable:
|
|
664
|
-
return "capture_unavailable"
|
|
665
|
-
case .imageCreationFailed:
|
|
666
|
-
return "image_creation_failed"
|
|
667
|
-
case .fileWriteFailed:
|
|
668
|
-
return "file_write_failed"
|
|
669
|
-
case .viewNotFound:
|
|
670
|
-
return "view_not_found"
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
var message: String {
|
|
675
|
-
switch self {
|
|
676
|
-
case .captureInProgress:
|
|
677
|
-
return "A capture request is already in flight."
|
|
678
|
-
case .captureUnavailable:
|
|
679
|
-
return "Photo output is not configured yet."
|
|
680
|
-
case .imageCreationFailed:
|
|
681
|
-
return "Unable to create image data from capture."
|
|
682
|
-
case .fileWriteFailed:
|
|
683
|
-
return "Failed to persist captured image to disk."
|
|
684
|
-
case .viewNotFound:
|
|
685
|
-
return "Unable to locate the native DocScanner view."
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
private extension CGRect {
|
|
691
|
-
var area: CGFloat {
|
|
692
|
-
width * height
|
|
693
|
-
}
|
|
694
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
#import <React/RCTBridge.h>
|
|
2
|
-
#import <React/RCTUIManager.h>
|
|
3
|
-
#import <React/RCTViewManager.h>
|
|
4
|
-
|
|
5
|
-
// Swift bridging header not needed with @objc annotations in Swift files
|
|
6
|
-
// #import "react-native-rectangle-doc-scanner-Swift.h"
|
|
7
|
-
|
|
8
|
-
@interface RCT_EXTERN_MODULE(RNRDocScannerViewManager, RCTViewManager)
|
|
9
|
-
RCT_EXPORT_VIEW_PROPERTY(detectionCountBeforeCapture, NSNumber)
|
|
10
|
-
RCT_EXPORT_VIEW_PROPERTY(autoCapture, BOOL)
|
|
11
|
-
RCT_EXPORT_VIEW_PROPERTY(enableTorch, BOOL)
|
|
12
|
-
RCT_EXPORT_VIEW_PROPERTY(quality, NSNumber)
|
|
13
|
-
RCT_EXPORT_VIEW_PROPERTY(useBase64, BOOL)
|
|
14
|
-
RCT_EXPORT_VIEW_PROPERTY(onRectangleDetect, RCTDirectEventBlock)
|
|
15
|
-
RCT_EXPORT_VIEW_PROPERTY(onPictureTaken, RCTDirectEventBlock)
|
|
16
|
-
|
|
17
|
-
RCT_EXTERN_METHOD(capture:(nonnull NSNumber *)reactTag
|
|
18
|
-
resolver:(RCTPromiseResolveBlock)resolve
|
|
19
|
-
rejecter:(RCTPromiseRejectBlock)reject)
|
|
20
|
-
|
|
21
|
-
RCT_EXTERN_METHOD(reset:(nonnull NSNumber *)reactTag)
|
|
22
|
-
@end
|