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.
@@ -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(bufferRect, viewRect, Matrix.ScaleToFit.FILL)
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(sizes: Array<Size>?, targetAspect: Double, maxArea: Int?): Size? {
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
- return capped.firstOrNull() ?: sorted.first()
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 < 200) {
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 openCvRect = DocumentDetector.detectRectangleInYUV(nv21, image.width, image.height, rotationDegrees)
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
- val detectedRectangle = DocumentDetector.detectRectangle(bitmap)
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
- var initialBitmap = BitmapFactory.decodeFile(imagePath)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.241.0",
3
+ "version": "3.243.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",