react-native-rectangle-doc-scanner 3.241.0 → 3.243.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/android/src/main/kotlin/com/reactnativerectangledocscanner/CameraController.kt +83 -16
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentDetector.kt +54 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerView.kt +7 -1
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/ImageProcessor.kt +2 -1
- package/package.json +1 -1
|
@@ -70,6 +70,8 @@ class CameraController(
|
|
|
70
70
|
.build()
|
|
71
71
|
)
|
|
72
72
|
private var lastRefineTimestamp = 0L
|
|
73
|
+
private var lastRectangle: Rectangle? = null
|
|
74
|
+
private var lastRectangleTimestamp = 0L
|
|
73
75
|
|
|
74
76
|
var onFrameAnalyzed: ((Rectangle?, Int, Int) -> Unit)? = null
|
|
75
77
|
|
|
@@ -213,7 +215,7 @@ class CameraController(
|
|
|
213
215
|
}
|
|
214
216
|
|
|
215
217
|
val previewSizes = streamConfigMap.getOutputSizes(SurfaceTexture::class.java)
|
|
216
|
-
previewSize = chooseBestSize(previewSizes, viewAspect, null)
|
|
218
|
+
previewSize = chooseBestSize(previewSizes, viewAspect, null, preferClosestAspect = true)
|
|
217
219
|
|
|
218
220
|
val analysisSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888)
|
|
219
221
|
analysisSize = chooseBestSize(analysisSizes, viewAspect, ANALYSIS_MAX_AREA)
|
|
@@ -390,7 +392,7 @@ class CameraController(
|
|
|
390
392
|
|
|
391
393
|
val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) image.height else image.width
|
|
392
394
|
val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) image.width else image.height
|
|
393
|
-
onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
|
|
395
|
+
onFrameAnalyzed?.invoke(smoothRectangle(rectangle), frameWidth, frameHeight)
|
|
394
396
|
}
|
|
395
397
|
.addOnFailureListener { e ->
|
|
396
398
|
Log.e(TAG, "[CAMERA2] ML Kit detection failed", e)
|
|
@@ -485,31 +487,45 @@ class CameraController(
|
|
|
485
487
|
|
|
486
488
|
val matrix = Matrix()
|
|
487
489
|
bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY())
|
|
488
|
-
matrix.setRectToRect(
|
|
490
|
+
matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL)
|
|
491
|
+
val scale = max(viewWidth / bufferWidth, viewHeight / bufferHeight)
|
|
492
|
+
matrix.postScale(scale, scale, centerX, centerY)
|
|
489
493
|
matrix.postRotate(rotation.toFloat(), centerX, centerY)
|
|
490
494
|
previewView.setTransform(matrix)
|
|
491
495
|
}
|
|
492
496
|
|
|
493
|
-
private fun chooseBestSize(
|
|
497
|
+
private fun chooseBestSize(
|
|
498
|
+
sizes: Array<Size>?,
|
|
499
|
+
targetAspect: Double,
|
|
500
|
+
maxArea: Int?,
|
|
501
|
+
preferClosestAspect: Boolean = false
|
|
502
|
+
): Size? {
|
|
494
503
|
if (sizes == null || sizes.isEmpty()) return null
|
|
495
504
|
val sorted = sizes.sortedByDescending { it.width * it.height }
|
|
496
505
|
|
|
497
|
-
val matching = sorted.filter {
|
|
498
|
-
val aspect = it.width.toDouble() / it.height.toDouble()
|
|
499
|
-
abs(aspect - targetAspect) <= ANALYSIS_ASPECT_TOLERANCE && (maxArea == null || it.width * it.height <= maxArea)
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (matching.isNotEmpty()) {
|
|
503
|
-
return matching.first()
|
|
504
|
-
}
|
|
505
|
-
|
|
506
506
|
val capped = if (maxArea != null) {
|
|
507
507
|
sorted.filter { it.width * it.height <= maxArea }
|
|
508
508
|
} else {
|
|
509
509
|
sorted
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
-
|
|
512
|
+
if (capped.isEmpty()) {
|
|
513
|
+
return sorted.first()
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (preferClosestAspect) {
|
|
517
|
+
return capped.minWithOrNull(
|
|
518
|
+
compareBy<Size> { abs(it.width.toDouble() / it.height.toDouble() - targetAspect) }
|
|
519
|
+
.thenByDescending { it.width * it.height }
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
val matching = capped.filter {
|
|
524
|
+
val aspect = it.width.toDouble() / it.height.toDouble()
|
|
525
|
+
abs(aspect - targetAspect) <= ANALYSIS_ASPECT_TOLERANCE
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return matching.firstOrNull() ?: capped.first()
|
|
513
529
|
}
|
|
514
530
|
|
|
515
531
|
private fun rotateAndMirror(bitmap: Bitmap, rotationDegrees: Int, mirror: Boolean): Bitmap {
|
|
@@ -528,7 +544,7 @@ class CameraController(
|
|
|
528
544
|
|
|
529
545
|
private fun shouldRefineWithOpenCv(): Boolean {
|
|
530
546
|
val now = System.currentTimeMillis()
|
|
531
|
-
if (now - lastRefineTimestamp <
|
|
547
|
+
if (now - lastRefineTimestamp < 150) {
|
|
532
548
|
return false
|
|
533
549
|
}
|
|
534
550
|
lastRefineTimestamp = now
|
|
@@ -538,7 +554,16 @@ class CameraController(
|
|
|
538
554
|
private fun refineWithOpenCv(image: Image, rotationDegrees: Int, mlBox: Rect): Rectangle? {
|
|
539
555
|
return try {
|
|
540
556
|
val nv21 = imageToNv21(image)
|
|
541
|
-
val
|
|
557
|
+
val uprightWidth = if (rotationDegrees == 90 || rotationDegrees == 270) image.height else image.width
|
|
558
|
+
val uprightHeight = if (rotationDegrees == 90 || rotationDegrees == 270) image.width else image.height
|
|
559
|
+
val expanded = expandRect(mlBox, uprightWidth, uprightHeight, 0.2f)
|
|
560
|
+
val openCvRect = DocumentDetector.detectRectangleInYUVWithRoi(
|
|
561
|
+
nv21,
|
|
562
|
+
image.width,
|
|
563
|
+
image.height,
|
|
564
|
+
rotationDegrees,
|
|
565
|
+
expanded
|
|
566
|
+
)
|
|
542
567
|
if (openCvRect == null) {
|
|
543
568
|
null
|
|
544
569
|
} else {
|
|
@@ -560,6 +585,48 @@ class CameraController(
|
|
|
560
585
|
)
|
|
561
586
|
}
|
|
562
587
|
|
|
588
|
+
private fun expandRect(box: Rect, maxWidth: Int, maxHeight: Int, ratio: Float): Rect {
|
|
589
|
+
val padX = (box.width() * ratio).toInt()
|
|
590
|
+
val padY = (box.height() * ratio).toInt()
|
|
591
|
+
val left = (box.left - padX).coerceAtLeast(0)
|
|
592
|
+
val top = (box.top - padY).coerceAtLeast(0)
|
|
593
|
+
val right = (box.right + padX).coerceAtMost(maxWidth)
|
|
594
|
+
val bottom = (box.bottom + padY).coerceAtMost(maxHeight)
|
|
595
|
+
return Rect(left, top, right, bottom)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private fun smoothRectangle(current: Rectangle?): Rectangle? {
|
|
599
|
+
val now = System.currentTimeMillis()
|
|
600
|
+
val last = lastRectangle
|
|
601
|
+
if (current == null) {
|
|
602
|
+
if (last != null && now - lastRectangleTimestamp < 250) {
|
|
603
|
+
return last
|
|
604
|
+
}
|
|
605
|
+
lastRectangle = null
|
|
606
|
+
return null
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
val smoothed = if (last != null && now - lastRectangleTimestamp < 500) {
|
|
610
|
+
val t = 0.35
|
|
611
|
+
Rectangle(
|
|
612
|
+
Point(lerp(last.topLeft.x, current.topLeft.x, t), lerp(last.topLeft.y, current.topLeft.y, t)),
|
|
613
|
+
Point(lerp(last.topRight.x, current.topRight.x, t), lerp(last.topRight.y, current.topRight.y, t)),
|
|
614
|
+
Point(lerp(last.bottomLeft.x, current.bottomLeft.x, t), lerp(last.bottomLeft.y, current.bottomLeft.y, t)),
|
|
615
|
+
Point(lerp(last.bottomRight.x, current.bottomRight.x, t), lerp(last.bottomRight.y, current.bottomRight.y, t))
|
|
616
|
+
)
|
|
617
|
+
} else {
|
|
618
|
+
current
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
lastRectangle = smoothed
|
|
622
|
+
lastRectangleTimestamp = now
|
|
623
|
+
return smoothed
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private fun lerp(start: Double, end: Double, t: Double): Double {
|
|
627
|
+
return start + (end - start) * t
|
|
628
|
+
}
|
|
629
|
+
|
|
563
630
|
private fun rectangleBounds(rectangle: Rectangle): Rect {
|
|
564
631
|
val left = listOf(rectangle.topLeft.x, rectangle.bottomLeft.x, rectangle.topRight.x, rectangle.bottomRight.x).minOrNull() ?: 0.0
|
|
565
632
|
val right = listOf(rectangle.topLeft.x, rectangle.bottomLeft.x, rectangle.topRight.x, rectangle.bottomRight.x).maxOrNull() ?: 0.0
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.reactnativerectangledocscanner
|
|
2
2
|
|
|
3
3
|
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.Rect
|
|
4
5
|
import android.util.Log
|
|
5
6
|
import org.opencv.android.Utils
|
|
6
7
|
import org.opencv.core.*
|
|
@@ -98,6 +99,59 @@ class DocumentDetector {
|
|
|
98
99
|
return rectangle
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Detect rectangle within a region-of-interest (ROI) in YUV image.
|
|
104
|
+
* The ROI is specified in the rotated image coordinate space.
|
|
105
|
+
*/
|
|
106
|
+
fun detectRectangleInYUVWithRoi(
|
|
107
|
+
yuvBytes: ByteArray,
|
|
108
|
+
width: Int,
|
|
109
|
+
height: Int,
|
|
110
|
+
rotation: Int,
|
|
111
|
+
roi: Rect
|
|
112
|
+
): Rectangle? {
|
|
113
|
+
val yuvMat = Mat(height + height / 2, width, CvType.CV_8UC1)
|
|
114
|
+
yuvMat.put(0, 0, yuvBytes)
|
|
115
|
+
|
|
116
|
+
val rgbMat = Mat()
|
|
117
|
+
Imgproc.cvtColor(yuvMat, rgbMat, Imgproc.COLOR_YUV2RGB_NV21)
|
|
118
|
+
|
|
119
|
+
if (rotation != 0) {
|
|
120
|
+
val rotationCode = when (rotation) {
|
|
121
|
+
90 -> Core.ROTATE_90_CLOCKWISE
|
|
122
|
+
180 -> Core.ROTATE_180
|
|
123
|
+
270 -> Core.ROTATE_90_COUNTERCLOCKWISE
|
|
124
|
+
else -> null
|
|
125
|
+
}
|
|
126
|
+
if (rotationCode != null) {
|
|
127
|
+
Core.rotate(rgbMat, rgbMat, rotationCode)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
val x = roi.left.coerceIn(0, rgbMat.cols() - 1)
|
|
132
|
+
val y = roi.top.coerceIn(0, rgbMat.rows() - 1)
|
|
133
|
+
val right = roi.right.coerceIn(x + 1, rgbMat.cols())
|
|
134
|
+
val bottom = roi.bottom.coerceIn(y + 1, rgbMat.rows())
|
|
135
|
+
val w = right - x
|
|
136
|
+
val h = bottom - y
|
|
137
|
+
val roiRect = org.opencv.core.Rect(x, y, w, h)
|
|
138
|
+
|
|
139
|
+
val roiMat = Mat(rgbMat, roiRect)
|
|
140
|
+
val rectangle = detectRectangleInMat(roiMat)
|
|
141
|
+
roiMat.release()
|
|
142
|
+
yuvMat.release()
|
|
143
|
+
rgbMat.release()
|
|
144
|
+
|
|
145
|
+
return rectangle?.let {
|
|
146
|
+
Rectangle(
|
|
147
|
+
Point(it.topLeft.x + x, it.topLeft.y + y),
|
|
148
|
+
Point(it.topRight.x + x, it.topRight.y + y),
|
|
149
|
+
Point(it.bottomLeft.x + x, it.bottomLeft.y + y),
|
|
150
|
+
Point(it.bottomRight.x + x, it.bottomRight.y + y)
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
101
155
|
/**
|
|
102
156
|
* Core detection algorithm using OpenCV
|
|
103
157
|
*/
|
|
@@ -312,7 +312,13 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
|
|
|
312
312
|
try {
|
|
313
313
|
// Detect rectangle in captured image
|
|
314
314
|
val bitmap = BitmapFactory.decodeFile(imageFile.absolutePath)
|
|
315
|
-
|
|
315
|
+
?: throw IllegalStateException("decode_failed")
|
|
316
|
+
val detectedRectangle = try {
|
|
317
|
+
DocumentDetector.detectRectangle(bitmap)
|
|
318
|
+
} catch (e: Exception) {
|
|
319
|
+
Log.w(TAG, "Rectangle detection failed, using original image", e)
|
|
320
|
+
null
|
|
321
|
+
}
|
|
316
322
|
|
|
317
323
|
// Process image with detected rectangle
|
|
318
324
|
val shouldCrop = detectedRectangle != null && stableCounter > 0
|
|
@@ -194,7 +194,8 @@ object ImageProcessor {
|
|
|
194
194
|
saturation: Float = 1f,
|
|
195
195
|
shouldCrop: Boolean = true
|
|
196
196
|
): ProcessedImage {
|
|
197
|
-
|
|
197
|
+
val initialBitmap = BitmapFactory.decodeFile(imagePath)
|
|
198
|
+
?: throw IllegalStateException("decode_failed")
|
|
198
199
|
var croppedBitmap = initialBitmap
|
|
199
200
|
|
|
200
201
|
// Apply perspective correction if rectangle detected and cropping enabled
|
package/package.json
CHANGED