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.
@@ -1,568 +0,0 @@
1
- package com.reactnativerectangledocscanner
2
-
3
- import android.Manifest
4
- import android.content.Context
5
- import android.content.pm.PackageManager
6
- import android.graphics.Bitmap
7
- import android.graphics.BitmapFactory
8
- import android.graphics.Color
9
- import android.media.Image
10
- import android.util.AttributeSet
11
- import android.util.Log
12
- import android.util.Size as AndroidSize
13
- import android.widget.FrameLayout
14
- import androidx.camera.core.*
15
- import androidx.camera.lifecycle.ProcessCameraProvider
16
- import androidx.camera.view.PreviewView
17
- import androidx.concurrent.futures.await
18
- import androidx.core.content.ContextCompat
19
- import androidx.lifecycle.LifecycleOwner
20
- import com.facebook.react.bridge.Arguments
21
- import com.facebook.react.bridge.Promise
22
- import com.facebook.react.bridge.ReactContext
23
- import com.facebook.react.bridge.WritableMap
24
- import com.facebook.react.uimanager.events.RCTEventEmitter
25
- import kotlinx.coroutines.CoroutineScope
26
- import kotlinx.coroutines.Dispatchers
27
- import kotlinx.coroutines.Job
28
- import kotlinx.coroutines.launch
29
- import kotlinx.coroutines.withContext
30
- import org.opencv.android.OpenCVLoader
31
- import org.opencv.core.CvType
32
- import org.opencv.core.Mat
33
- import org.opencv.core.MatOfPoint
34
- import org.opencv.core.MatOfPoint2f
35
- import org.opencv.core.Point
36
- import org.opencv.core.Size as MatSize
37
- import org.opencv.imgproc.Imgproc
38
- import org.opencv.photo.Photo
39
- import java.io.File
40
- import java.nio.ByteBuffer
41
- import java.text.SimpleDateFormat
42
- import java.util.Date
43
- import java.util.Locale
44
- import java.util.concurrent.ExecutorService
45
- import java.util.concurrent.Executors
46
- import kotlin.math.abs
47
- import kotlin.math.hypot
48
- import kotlin.math.max
49
-
50
- @androidx.camera.core.ExperimentalGetImage
51
- class RNRDocScannerView @JvmOverloads constructor(
52
- context: Context,
53
- attrs: AttributeSet? = null,
54
- ) : FrameLayout(context, attrs) {
55
-
56
- var detectionCountBeforeCapture: Int = 8
57
- var autoCapture: Boolean = true
58
- var enableTorch: Boolean = false
59
- set(value) {
60
- field = value
61
- updateTorchMode(value)
62
- }
63
- var quality: Int = 90
64
- var useBase64: Boolean = false
65
-
66
- private val previewView: PreviewView = PreviewView(context)
67
- private var cameraProvider: ProcessCameraProvider? = null
68
- private var camera: Camera? = null
69
- private var imageCapture: ImageCapture? = null
70
- private var imageAnalysis: ImageAnalysis? = null
71
- private var cameraExecutor: ExecutorService? = null
72
- private val scope = CoroutineScope(Dispatchers.Main + Job())
73
-
74
- private var currentStableCounter: Int = 0
75
- private var lastQuad: QuadPoints? = null
76
- private var lastFrameSize: AndroidSize? = null
77
- private var missedDetections: Int = 0
78
- private val maxMissedDetections = 4
79
- private var capturePromise: Promise? = null
80
- private var captureInFlight: Boolean = false
81
-
82
- init {
83
- setBackgroundColor(Color.BLACK)
84
- addView(
85
- previewView,
86
- LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
87
- )
88
-
89
- if (!OpenCVLoader.initDebug()) {
90
- Log.w(TAG, "Failed to initialise OpenCV - detection will not run.")
91
- }
92
-
93
- initializeCamera()
94
- }
95
-
96
- private fun initializeCamera() {
97
- if (!hasCameraPermission()) {
98
- Log.w(TAG, "Camera permission missing. Detection will not start.")
99
- return
100
- }
101
-
102
- cameraExecutor = Executors.newSingleThreadExecutor()
103
- val providerFuture = ProcessCameraProvider.getInstance(context)
104
- providerFuture.addListener(
105
- {
106
- scope.launch {
107
- try {
108
- cameraProvider = providerFuture.await()
109
- bindCameraUseCases()
110
- } catch (error: Exception) {
111
- Log.e(TAG, "Failed to initialise camera", error)
112
- }
113
- }
114
- },
115
- ContextCompat.getMainExecutor(context),
116
- )
117
- }
118
-
119
- private fun hasCameraPermission(): Boolean {
120
- return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
121
- }
122
-
123
- private fun bindCameraUseCases() {
124
- val provider = cameraProvider ?: return
125
- val lifecycleOwner = context as? LifecycleOwner
126
- if (lifecycleOwner == null) {
127
- Log.w(TAG, "Context is not a LifecycleOwner; cannot bind camera use cases.")
128
- return
129
- }
130
- provider.unbindAll()
131
-
132
- val preview = Preview.Builder()
133
- .setTargetAspectRatio(AspectRatio.RATIO_4_3)
134
- .setTargetRotation(previewView.display.rotation)
135
- .build()
136
- .also { it.setSurfaceProvider(previewView.surfaceProvider) }
137
-
138
- imageCapture = ImageCapture.Builder()
139
- .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
140
- .setTargetAspectRatio(AspectRatio.RATIO_4_3)
141
- .setTargetRotation(previewView.display.rotation)
142
- .build()
143
-
144
- imageAnalysis = ImageAnalysis.Builder()
145
- .setTargetAspectRatio(AspectRatio.RATIO_4_3)
146
- .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
147
- .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
148
- .build()
149
- .also { analysis ->
150
- analysis.setAnalyzer(cameraExecutor!!) { imageProxy ->
151
- try {
152
- processFrame(imageProxy)
153
- } catch (error: Exception) {
154
- Log.e(TAG, "Frame processing error", error)
155
- imageProxy.close()
156
- }
157
- }
158
- }
159
-
160
- val selector = CameraSelector.Builder()
161
- .requireLensFacing(CameraSelector.LENS_FACING_BACK)
162
- .build()
163
-
164
- camera = provider.bindToLifecycle(
165
- lifecycleOwner,
166
- selector,
167
- preview,
168
- imageCapture,
169
- imageAnalysis,
170
- )
171
-
172
- updateTorchMode(enableTorch)
173
- }
174
-
175
- private fun updateTorchMode(enabled: Boolean) {
176
- camera?.cameraControl?.enableTorch(enabled)
177
- }
178
-
179
- private fun processFrame(imageProxy: ImageProxy) {
180
- val mediaImage = imageProxy.image
181
- if (mediaImage == null) {
182
- imageProxy.close()
183
- return
184
- }
185
-
186
- val frameSize = AndroidSize(imageProxy.width, imageProxy.height)
187
- lastFrameSize = frameSize
188
-
189
- val mat = yuvToMat(mediaImage, imageProxy.imageInfo.rotationDegrees)
190
- val detectedQuad = detectDocument(mat, frameSize)
191
-
192
- imageProxy.close()
193
-
194
- scope.launch {
195
- emitDetectionResult(detectedQuad, frameSize)
196
- if (autoCapture && detectedQuad != null && currentStableCounter >= detectionCountBeforeCapture && !captureInFlight) {
197
- triggerAutoCapture()
198
- }
199
- }
200
- }
201
-
202
- private fun emitDetectionResult(quad: QuadPoints?, frameSize: AndroidSize) {
203
- val reactContext = context as? ReactContext ?: return
204
-
205
- val effectiveQuad: QuadPoints? = when {
206
- quad != null -> {
207
- missedDetections = 0
208
- lastQuad = quad
209
- quad
210
- }
211
- lastQuad != null && missedDetections < maxMissedDetections -> {
212
- missedDetections += 1
213
- lastQuad
214
- }
215
- else -> {
216
- missedDetections = 0
217
- lastQuad = null
218
- null
219
- }
220
- }
221
-
222
- val sizeForEvent = if (effectiveQuad === quad) frameSize else lastFrameSize ?: frameSize
223
-
224
- val event: WritableMap = Arguments.createMap().apply {
225
- if (effectiveQuad != null) {
226
- val quadMap = Arguments.createMap().apply {
227
- putMap("topLeft", effectiveQuad.topLeft.toWritable())
228
- putMap("topRight", effectiveQuad.topRight.toWritable())
229
- putMap("bottomRight", effectiveQuad.bottomRight.toWritable())
230
- putMap("bottomLeft", effectiveQuad.bottomLeft.toWritable())
231
- }
232
- putMap("rectangleCoordinates", quadMap)
233
- currentStableCounter = (currentStableCounter + 1).coerceAtMost(detectionCountBeforeCapture)
234
- } else {
235
- putNull("rectangleCoordinates")
236
- currentStableCounter = 0
237
- }
238
- putInt("stableCounter", currentStableCounter)
239
- putDouble("frameWidth", sizeForEvent.width.toDouble())
240
- putDouble("frameHeight", sizeForEvent.height.toDouble())
241
- }
242
-
243
- reactContext
244
- .getJSModule(RCTEventEmitter::class.java)
245
- ?.receiveEvent(id, "onRectangleDetect", event)
246
- }
247
-
248
- private fun triggerAutoCapture() {
249
- startCapture(null)
250
- }
251
-
252
- fun capture(promise: Promise) {
253
- startCapture(promise)
254
- }
255
-
256
- private fun startCapture(promise: Promise?) {
257
- if (captureInFlight) {
258
- promise?.reject("capture_in_progress", "A capture request is already running.")
259
- return
260
- }
261
-
262
- val imageCapture = this.imageCapture
263
- if (imageCapture == null) {
264
- promise?.reject("capture_unavailable", "Image capture is not initialised yet.")
265
- return
266
- }
267
-
268
- val outputDir = context.cacheDir
269
- val photoFile = File(
270
- outputDir,
271
- "docscan-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.US).format(Date())}.jpg",
272
- )
273
-
274
- val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
275
-
276
- captureInFlight = true
277
- pendingPromise = promise
278
-
279
- imageCapture.takePicture(
280
- outputOptions,
281
- cameraExecutor ?: Executors.newSingleThreadExecutor(),
282
- object : ImageCapture.OnImageSavedCallback {
283
- override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
284
- scope.launch {
285
- handleCaptureSuccess(photoFile)
286
- }
287
- }
288
-
289
- override fun onError(exception: ImageCaptureException) {
290
- scope.launch {
291
- handleCaptureFailure(exception)
292
- }
293
- }
294
- },
295
- )
296
- }
297
-
298
- private suspend fun handleCaptureSuccess(file: File) {
299
- withContext(Dispatchers.IO) {
300
- try {
301
- val bitmap = BitmapFactory.decodeFile(file.absolutePath)
302
- val width = bitmap.width
303
- val height = bitmap.height
304
-
305
- val frameSize = lastFrameSize
306
- val quadForCapture = if (lastQuad != null && frameSize != null) {
307
- val scaleX = width.toDouble() / frameSize.width.toDouble()
308
- val scaleY = height.toDouble() / frameSize.height.toDouble()
309
- lastQuad!!.scaled(scaleX, scaleY)
310
- } else {
311
- null
312
- }
313
-
314
- val croppedPath = if (quadForCapture != null) {
315
- cropAndSave(bitmap, quadForCapture, file.parentFile ?: context.cacheDir)
316
- } else {
317
- file.absolutePath
318
- }
319
-
320
- val event = Arguments.createMap().apply {
321
- putString("initialImage", "file://${file.absolutePath}")
322
- putString("croppedImage", "file://$croppedPath")
323
- putDouble("width", width.toDouble())
324
- putDouble("height", height.toDouble())
325
- }
326
-
327
- withContext(Dispatchers.Main) {
328
- emitPictureTaken(event)
329
- pendingPromise?.resolve(event)
330
- resetAfterCapture()
331
- }
332
- } catch (error: Exception) {
333
- bitmap.recycle()
334
-
335
- withContext(Dispatchers.Main) {
336
- handleCaptureFailure(error)
337
- }
338
- }
339
- }
340
- }
341
-
342
- private fun handleCaptureFailure(error: Exception) {
343
- pendingPromise?.reject(error)
344
- resetAfterCapture()
345
- }
346
-
347
- private fun resetAfterCapture() {
348
- captureInFlight = false
349
- pendingPromise = null
350
- currentStableCounter = 0
351
- }
352
-
353
- private fun emitPictureTaken(payload: WritableMap) {
354
- val reactContext = context as? ReactContext ?: return
355
- reactContext
356
- .getJSModule(RCTEventEmitter::class.java)
357
- ?.receiveEvent(id, "onPictureTaken", payload)
358
- }
359
-
360
- fun reset() {
361
- currentStableCounter = 0
362
- lastQuad = null
363
- }
364
-
365
- override fun onDetachedFromWindow() {
366
- super.onDetachedFromWindow()
367
- cameraExecutor?.shutdown()
368
- cameraExecutor = null
369
- cameraProvider?.unbindAll()
370
- }
371
-
372
- // region Detection helpers
373
-
374
- private fun yuvToMat(image: Image, rotationDegrees: Int): Mat {
375
- val bufferY = image.planes[0].buffer.toByteArray()
376
- val bufferU = image.planes[1].buffer.toByteArray()
377
- val bufferV = image.planes[2].buffer.toByteArray()
378
-
379
- val yuvBytes = ByteArray(bufferY.size + bufferU.size + bufferV.size)
380
- bufferY.copyInto(yuvBytes, 0)
381
- bufferV.copyInto(yuvBytes, bufferY.size)
382
- bufferU.copyInto(yuvBytes, bufferY.size + bufferV.size)
383
-
384
- val yuvMat = Mat(image.height + image.height / 2, image.width, CvType.CV_8UC1)
385
- yuvMat.put(0, 0, yuvBytes)
386
-
387
- val bgrMat = Mat()
388
- Imgproc.cvtColor(yuvMat, bgrMat, Imgproc.COLOR_YUV2BGR_NV21, 3)
389
- yuvMat.release()
390
-
391
- val rotatedMat = Mat()
392
- when (rotationDegrees) {
393
- 90 -> Core.rotate(bgrMat, rotatedMat, Core.ROTATE_90_CLOCKWISE)
394
- 180 -> Core.rotate(bgrMat, rotatedMat, Core.ROTATE_180)
395
- 270 -> Core.rotate(bgrMat, rotatedMat, Core.ROTATE_90_COUNTERCLOCKWISE)
396
- else -> bgrMat.copyTo(rotatedMat)
397
- }
398
- bgrMat.release()
399
- return rotatedMat
400
- }
401
-
402
- private fun detectDocument(mat: Mat, frameSize: AndroidSize): QuadPoints? {
403
- if (mat.empty()) {
404
- mat.release()
405
- return null
406
- }
407
-
408
- val gray = Mat()
409
- Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY)
410
-
411
- // Improve contrast for low-light or glossy surfaces
412
- val clahe = Photo.createCLAHE(2.0, MatSize(8.0, 8.0))
413
- val enhanced = Mat()
414
- clahe.apply(gray, enhanced)
415
- clahe.collectGarbage()
416
-
417
- val blurred = Mat()
418
- Imgproc.GaussianBlur(enhanced, blurred, MatSize(5.0, 5.0), 0.0)
419
-
420
- val edges = Mat()
421
- Imgproc.Canny(blurred, edges, 40.0, 140.0)
422
-
423
- val morphKernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, MatSize(5.0, 5.0))
424
- Imgproc.morphologyEx(edges, edges, Imgproc.MORPH_CLOSE, morphKernel)
425
-
426
- val contours = ArrayList<MatOfPoint>()
427
- val hierarchy = Mat()
428
- Imgproc.findContours(edges, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
429
-
430
- var bestQuad: QuadPoints? = null
431
- var maxArea = 0.0
432
- val frameArea = frameSize.width * frameSize.height.toDouble()
433
-
434
- val approxCurve = MatOfPoint2f()
435
- for (contour in contours) {
436
- val contour2f = MatOfPoint2f(*contour.toArray())
437
- val perimeter = Imgproc.arcLength(contour2f, true)
438
- Imgproc.approxPolyDP(contour2f, approxCurve, 0.02 * perimeter, true)
439
-
440
- val points = approxCurve.toArray()
441
- if (points.size != 4) {
442
- contour.release()
443
- contour2f.release()
444
- continue
445
- }
446
-
447
- val area = abs(Imgproc.contourArea(approxCurve))
448
- if (area < frameArea * 0.05 || area > frameArea * 0.98) {
449
- contour.release()
450
- contour2f.release()
451
- continue
452
- }
453
-
454
- if (area > maxArea && Imgproc.isContourConvex(approxCurve)) {
455
- val ordered = orderPoints(points)
456
- bestQuad = QuadPoints(
457
- topLeft = ordered[0],
458
- topRight = ordered[1],
459
- bottomRight = ordered[2],
460
- bottomLeft = ordered[3],
461
- )
462
- maxArea = area
463
- }
464
-
465
- contour.release()
466
- contour2f.release()
467
- }
468
-
469
- gray.release()
470
- enhanced.release()
471
- blurred.release()
472
- edges.release()
473
- morphKernel.release()
474
- hierarchy.release()
475
- approxCurve.release()
476
- mat.release()
477
-
478
- return bestQuad
479
- }
480
-
481
- private fun orderPoints(points: Array<Point>): Array<Point> {
482
- val sorted = points.sortedBy { it.x + it.y }
483
- val tl = sorted.first()
484
- val br = sorted.last()
485
- val remaining = points.filter { it != tl && it != br }
486
- val (tr, bl) =
487
- if (remaining[0].x > remaining[1].x) remaining[0] to remaining[1] else remaining[1] to remaining[0]
488
- return arrayOf(tl, tr, br, bl)
489
- }
490
-
491
- // endregion
492
-
493
- private fun cropAndSave(bitmap: Bitmap, quad: QuadPoints, outputDir: File): String {
494
- val srcMat = Mat()
495
- org.opencv.android.Utils.bitmapToMat(bitmap, srcMat)
496
-
497
- val ordered = quad.toArray()
498
- val widthA = hypot(ordered[2].x - ordered[3].x, ordered[2].y - ordered[3].y)
499
- val widthB = hypot(ordered[1].x - ordered[0].x, ordered[1].y - ordered[0].y)
500
- val heightA = hypot(ordered[1].x - ordered[2].x, ordered[1].y - ordered[2].y)
501
- val heightB = hypot(ordered[0].x - ordered[3].x, ordered[0].y - ordered[3].y)
502
-
503
- val maxWidth = max(widthA, widthB).toInt().coerceAtLeast(1)
504
- val maxHeight = max(heightA, heightB).toInt().coerceAtLeast(1)
505
-
506
- val srcPoints = MatOfPoint2f(*ordered)
507
- val dstPoints = MatOfPoint2f(
508
- Point(0.0, 0.0),
509
- Point(maxWidth - 1.0, 0.0),
510
- Point(maxWidth - 1.0, maxHeight - 1.0),
511
- Point(0.0, maxHeight - 1.0),
512
- )
513
-
514
- val transform = Imgproc.getPerspectiveTransform(srcPoints, dstPoints)
515
- val warped = Mat(MatSize(maxWidth.toDouble(), maxHeight.toDouble()), srcMat.type())
516
- Imgproc.warpPerspective(srcMat, warped, transform, warped.size())
517
-
518
- val croppedBitmap = Bitmap.createBitmap(maxWidth, maxHeight, Bitmap.Config.ARGB_8888)
519
- org.opencv.android.Utils.matToBitmap(warped, croppedBitmap)
520
-
521
- val outputFile = File(
522
- outputDir,
523
- "docscan-cropped-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.US).format(Date())}.jpg",
524
- )
525
- outputFile.outputStream().use { stream ->
526
- croppedBitmap.compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(10, 100), stream)
527
- }
528
-
529
- srcMat.release()
530
- warped.release()
531
- transform.release()
532
- srcPoints.release()
533
- dstPoints.release()
534
-
535
- return outputFile.absolutePath
536
- }
537
-
538
- private fun Point.toWritable(): WritableMap = Arguments.createMap().apply {
539
- putDouble("x", x)
540
- putDouble("y", y)
541
- }
542
-
543
- private fun ByteBuffer.toByteArray(): ByteArray {
544
- val bytes = ByteArray(remaining())
545
- get(bytes)
546
- rewind()
547
- return bytes
548
- }
549
-
550
- companion object {
551
- private const val TAG = "RNRDocScanner"
552
- }
553
- }
554
-
555
- data class QuadPoints(
556
- val topLeft: Point,
557
- val topRight: Point,
558
- val bottomRight: Point,
559
- val bottomLeft: Point,
560
- ) {
561
- fun toArray(): Array<Point> = arrayOf(topLeft, topRight, bottomRight, bottomLeft)
562
- fun scaled(scaleX: Double, scaleY: Double): QuadPoints = QuadPoints(
563
- topLeft = Point(topLeft.x * scaleX, topLeft.y * scaleY),
564
- topRight = Point(topRight.x * scaleX, topRight.y * scaleY),
565
- bottomRight = Point(bottomRight.x * scaleX, bottomRight.y * scaleY),
566
- bottomLeft = Point(bottomLeft.x * scaleX, bottomLeft.y * scaleY),
567
- )
568
- }
@@ -1,50 +0,0 @@
1
- package com.reactnativerectangledocscanner
2
-
3
- import com.facebook.react.bridge.ReactApplicationContext
4
- import com.facebook.react.bridge.ReactContext
5
- import com.facebook.react.uimanager.SimpleViewManager
6
- import com.facebook.react.uimanager.ThemedReactContext
7
- import com.facebook.react.uimanager.annotations.ReactProp
8
-
9
- class RNRDocScannerViewManager(
10
- private val reactContext: ReactApplicationContext,
11
- ) : SimpleViewManager<RNRDocScannerView>() {
12
-
13
- override fun getName() = "RNRDocScannerView"
14
-
15
- override fun createViewInstance(reactContext: ThemedReactContext): RNRDocScannerView {
16
- return RNRDocScannerView(reactContext)
17
- }
18
-
19
- override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
20
- return mutableMapOf(
21
- "onRectangleDetect" to mapOf("registrationName" to "onRectangleDetect"),
22
- "onPictureTaken" to mapOf("registrationName" to "onPictureTaken"),
23
- )
24
- }
25
-
26
- @ReactProp(name = "detectionCountBeforeCapture", defaultInt = 8)
27
- fun setDetectionCountBeforeCapture(view: RNRDocScannerView, value: Int) {
28
- view.detectionCountBeforeCapture = value
29
- }
30
-
31
- @ReactProp(name = "autoCapture", defaultBoolean = true)
32
- fun setAutoCapture(view: RNRDocScannerView, value: Boolean) {
33
- view.autoCapture = value
34
- }
35
-
36
- @ReactProp(name = "enableTorch", defaultBoolean = false)
37
- fun setEnableTorch(view: RNRDocScannerView, value: Boolean) {
38
- view.enableTorch = value
39
- }
40
-
41
- @ReactProp(name = "quality", defaultInt = 90)
42
- fun setQuality(view: RNRDocScannerView, value: Int) {
43
- view.quality = value
44
- }
45
-
46
- @ReactProp(name = "useBase64", defaultBoolean = false)
47
- fun setUseBase64(view: RNRDocScannerView, value: Boolean) {
48
- view.useBase64 = value
49
- }
50
- }