react-native-rectangle-doc-scanner 3.241.0 → 3.242.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.
@@ -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)
@@ -490,26 +492,38 @@ class CameraController(
490
492
  previewView.setTransform(matrix)
491
493
  }
492
494
 
493
- private fun chooseBestSize(sizes: Array<Size>?, targetAspect: Double, maxArea: Int?): Size? {
495
+ private fun chooseBestSize(
496
+ sizes: Array<Size>?,
497
+ targetAspect: Double,
498
+ maxArea: Int?,
499
+ preferClosestAspect: Boolean = false
500
+ ): Size? {
494
501
  if (sizes == null || sizes.isEmpty()) return null
495
502
  val sorted = sizes.sortedByDescending { it.width * it.height }
496
503
 
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
504
  val capped = if (maxArea != null) {
507
505
  sorted.filter { it.width * it.height <= maxArea }
508
506
  } else {
509
507
  sorted
510
508
  }
511
509
 
512
- return capped.firstOrNull() ?: sorted.first()
510
+ if (capped.isEmpty()) {
511
+ return sorted.first()
512
+ }
513
+
514
+ if (preferClosestAspect) {
515
+ return capped.minWithOrNull(
516
+ compareBy<Size> { abs(it.width.toDouble() / it.height.toDouble() - targetAspect) }
517
+ .thenByDescending { it.width * it.height }
518
+ )
519
+ }
520
+
521
+ val matching = capped.filter {
522
+ val aspect = it.width.toDouble() / it.height.toDouble()
523
+ abs(aspect - targetAspect) <= ANALYSIS_ASPECT_TOLERANCE
524
+ }
525
+
526
+ return matching.firstOrNull() ?: capped.first()
513
527
  }
514
528
 
515
529
  private fun rotateAndMirror(bitmap: Bitmap, rotationDegrees: Int, mirror: Boolean): Bitmap {
@@ -528,7 +542,7 @@ class CameraController(
528
542
 
529
543
  private fun shouldRefineWithOpenCv(): Boolean {
530
544
  val now = System.currentTimeMillis()
531
- if (now - lastRefineTimestamp < 200) {
545
+ if (now - lastRefineTimestamp < 150) {
532
546
  return false
533
547
  }
534
548
  lastRefineTimestamp = now
@@ -538,7 +552,16 @@ class CameraController(
538
552
  private fun refineWithOpenCv(image: Image, rotationDegrees: Int, mlBox: Rect): Rectangle? {
539
553
  return try {
540
554
  val nv21 = imageToNv21(image)
541
- val openCvRect = DocumentDetector.detectRectangleInYUV(nv21, image.width, image.height, rotationDegrees)
555
+ val uprightWidth = if (rotationDegrees == 90 || rotationDegrees == 270) image.height else image.width
556
+ val uprightHeight = if (rotationDegrees == 90 || rotationDegrees == 270) image.width else image.height
557
+ val expanded = expandRect(mlBox, uprightWidth, uprightHeight, 0.2f)
558
+ val openCvRect = DocumentDetector.detectRectangleInYUVWithRoi(
559
+ nv21,
560
+ image.width,
561
+ image.height,
562
+ rotationDegrees,
563
+ expanded
564
+ )
542
565
  if (openCvRect == null) {
543
566
  null
544
567
  } else {
@@ -560,6 +583,48 @@ class CameraController(
560
583
  )
561
584
  }
562
585
 
586
+ private fun expandRect(box: Rect, maxWidth: Int, maxHeight: Int, ratio: Float): Rect {
587
+ val padX = (box.width() * ratio).toInt()
588
+ val padY = (box.height() * ratio).toInt()
589
+ val left = (box.left - padX).coerceAtLeast(0)
590
+ val top = (box.top - padY).coerceAtLeast(0)
591
+ val right = (box.right + padX).coerceAtMost(maxWidth)
592
+ val bottom = (box.bottom + padY).coerceAtMost(maxHeight)
593
+ return Rect(left, top, right, bottom)
594
+ }
595
+
596
+ private fun smoothRectangle(current: Rectangle?): Rectangle? {
597
+ val now = System.currentTimeMillis()
598
+ val last = lastRectangle
599
+ if (current == null) {
600
+ if (last != null && now - lastRectangleTimestamp < 250) {
601
+ return last
602
+ }
603
+ lastRectangle = null
604
+ return null
605
+ }
606
+
607
+ val smoothed = if (last != null && now - lastRectangleTimestamp < 500) {
608
+ val t = 0.35
609
+ Rectangle(
610
+ Point(lerp(last.topLeft.x, current.topLeft.x, t), lerp(last.topLeft.y, current.topLeft.y, t)),
611
+ Point(lerp(last.topRight.x, current.topRight.x, t), lerp(last.topRight.y, current.topRight.y, t)),
612
+ Point(lerp(last.bottomLeft.x, current.bottomLeft.x, t), lerp(last.bottomLeft.y, current.bottomLeft.y, t)),
613
+ Point(lerp(last.bottomRight.x, current.bottomRight.x, t), lerp(last.bottomRight.y, current.bottomRight.y, t))
614
+ )
615
+ } else {
616
+ current
617
+ }
618
+
619
+ lastRectangle = smoothed
620
+ lastRectangleTimestamp = now
621
+ return smoothed
622
+ }
623
+
624
+ private fun lerp(start: Double, end: Double, t: Double): Double {
625
+ return start + (end - start) * t
626
+ }
627
+
563
628
  private fun rectangleBounds(rectangle: Rectangle): Rect {
564
629
  val left = listOf(rectangle.topLeft.x, rectangle.bottomLeft.x, rectangle.topRight.x, rectangle.bottomRight.x).minOrNull() ?: 0.0
565
630
  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
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.241.0",
3
+ "version": "3.242.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",