react-native-rectangle-doc-scanner 13.1.0 → 13.3.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.
@@ -53,7 +53,9 @@ class CameraController(
53
53
 
54
54
  companion object {
55
55
  private const val TAG = "CameraController"
56
- private const val ANALYSIS_TARGET_RESOLUTION = 1920 // Max dimension for analysis
56
+ // Increased resolution for better corner detection (was causing 640x480 downscale)
57
+ private const val ANALYSIS_TARGET_WIDTH = 1280
58
+ private const val ANALYSIS_TARGET_HEIGHT = 960
57
59
  }
58
60
 
59
61
  private fun getCameraSensorOrientation(): Int {
@@ -183,11 +185,11 @@ class CameraController(
183
185
  Log.d(TAG, "[CAMERAX] SurfaceProvider set successfully")
184
186
  }
185
187
 
186
- // ImageAnalysis UseCase for document detection
188
+ // ImageAnalysis UseCase for document detection with fixed high resolution
187
189
  imageAnalyzer = ImageAnalysis.Builder()
188
190
  .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
189
- // Match preview aspect ratio to avoid square analysis frames on some devices.
190
- .setTargetAspectRatio(AspectRatio.RATIO_4_3)
191
+ // Use explicit resolution instead of aspect ratio to prevent 640x480 downscale
192
+ .setTargetResolution(Size(ANALYSIS_TARGET_WIDTH, ANALYSIS_TARGET_HEIGHT))
191
193
  .setTargetRotation(targetRotation) // Match preview rotation
192
194
  .build()
193
195
  .also {
@@ -494,10 +496,10 @@ class CameraController(
494
496
  val finalWidth = imageWidth
495
497
  val finalHeight = imageHeight
496
498
 
497
- // Apply fit-center scaling to match TextureView display.
499
+ // Apply center-crop scaling to match TextureView display.
498
500
  val scaleX = viewWidth / finalWidth.toFloat()
499
501
  val scaleY = viewHeight / finalHeight.toFloat()
500
- val scale = scaleX.coerceAtMost(scaleY)
502
+ val scale = scaleX.coerceAtLeast(scaleY)
501
503
 
502
504
  val scaledWidth = finalWidth * scale
503
505
  val scaledHeight = finalHeight * scale
@@ -583,26 +585,17 @@ class CameraController(
583
585
  bufferHeight
584
586
  }
585
587
 
586
- // Scale to fit within the view while maintaining aspect ratio (no zoom/crop)
588
+ // Scale to fill the view while maintaining aspect ratio (center-crop)
587
589
  val scaleX = viewWidth.toFloat() / rotatedBufferWidth.toFloat()
588
590
  val scaleY = viewHeight.toFloat() / rotatedBufferHeight.toFloat()
589
- val scale = scaleX.coerceAtMost(scaleY) // Use min to fit
591
+ val scale = scaleX.coerceAtLeast(scaleY) // Use max to fill
590
592
 
591
593
  Log.d(TAG, "[TRANSFORM] Rotated buffer: ${rotatedBufferWidth}x${rotatedBufferHeight}, ScaleX: $scaleX, ScaleY: $scaleY, Using: $scale")
592
594
 
593
595
  matrix.postScale(scale, scale, centerX, centerY)
594
596
 
595
- // Track the actual preview viewport within the view for clipping overlays.
596
- val scaledWidth = rotatedBufferWidth * scale
597
- val scaledHeight = rotatedBufferHeight * scale
598
- val offsetX = (viewWidth - scaledWidth) / 2f
599
- val offsetY = (viewHeight - scaledHeight) / 2f
600
- previewViewport = android.graphics.RectF(
601
- offsetX,
602
- offsetY,
603
- offsetX + scaledWidth,
604
- offsetY + scaledHeight
605
- )
597
+ // With center-crop, the preview fully covers the view bounds.
598
+ previewViewport = android.graphics.RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat())
606
599
 
607
600
  textureView.setTransform(matrix)
608
601
  Log.d(TAG, "[TRANSFORM] Transform applied successfully")
@@ -67,7 +67,8 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
67
67
 
68
68
  companion object {
69
69
  private const val TAG = "DocumentScannerView"
70
- private const val PREVIEW_ASPECT_RATIO = 3f / 4f // width:height (matches 3:4)
70
+ // Removed fixed aspect ratio to prevent squished preview on tablets
71
+ // Preview will now properly fill the view using center-crop scaling
71
72
  }
72
73
 
73
74
  override val lifecycle: Lifecycle
@@ -150,24 +151,10 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
150
151
  private fun layoutPreviewAndOverlay(viewWidth: Int, viewHeight: Int) {
151
152
  if (viewWidth <= 0 || viewHeight <= 0) return
152
153
 
153
- val targetWidth: Int
154
- val targetHeight: Int
155
- val aspectHeight = (viewWidth / PREVIEW_ASPECT_RATIO).toInt()
156
- if (aspectHeight <= viewHeight) {
157
- targetWidth = viewWidth
158
- targetHeight = aspectHeight
159
- } else {
160
- targetWidth = (viewHeight * PREVIEW_ASPECT_RATIO).toInt()
161
- targetHeight = viewHeight
162
- }
163
-
164
- val left = (viewWidth - targetWidth) / 2
165
- val top = if (targetHeight < viewHeight) 0 else (viewHeight - targetHeight) / 2
166
- val right = left + targetWidth
167
- val bottom = top + targetHeight
168
-
169
- previewView.layout(left, top, right, bottom)
170
- overlayView.layout(left, top, right, bottom)
154
+ // Fill entire view to prevent squished appearance on tablets
155
+ // CameraX will handle aspect ratio via center-crop in updateTextureViewTransform
156
+ previewView.layout(0, 0, viewWidth, viewHeight)
157
+ overlayView.layout(0, 0, viewWidth, viewHeight)
171
158
  }
172
159
 
173
160
  private fun initializeCameraWhenReady() {
@@ -270,36 +257,72 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
270
257
  }
271
258
  }
272
259
 
260
+ // Enhanced snap: Track corner movement per frame
261
+ private var consecutiveStableFrames = 0
262
+ private val requiredStableFrames = 3 // Require 3 consecutive stable frames for snap
263
+
273
264
  private fun smoothRectangle(rectangle: Rectangle?, viewWidth: Int, viewHeight: Int): Rectangle? {
274
265
  if (rectangle == null || viewWidth <= 0 || viewHeight <= 0) {
275
266
  lastSmoothedRectangleOnScreen = null
267
+ consecutiveStableFrames = 0
276
268
  return null
277
269
  }
278
270
 
279
271
  val prev = lastSmoothedRectangleOnScreen
280
272
  if (prev == null) {
281
273
  lastSmoothedRectangleOnScreen = rectangle
274
+ consecutiveStableFrames = 0
282
275
  return rectangle
283
276
  }
284
277
 
285
278
  val minDim = min(viewWidth, viewHeight).toDouble()
286
- val snapThreshold = max(12.0, minDim * 0.015)
287
- val smoothThreshold = max(24.0, minDim * 0.06)
288
- val maxCornerDistance = max(
279
+ // Tighter snap threshold for better stability
280
+ val snapThreshold = max(10.0, minDim * 0.012)
281
+ val smoothThreshold = max(20.0, minDim * 0.05)
282
+
283
+ // Calculate max corner movement
284
+ val maxCornerDist = max(
289
285
  maxCornerDistance(prev, rectangle),
290
286
  maxCornerDistance(rectangle, prev)
291
287
  )
292
288
 
289
+ // Enhanced snap logic: Check individual corner distances
290
+ val allCornersStable = areAllCornersStable(prev, rectangle, snapThreshold)
291
+
293
292
  val result = when {
294
- maxCornerDistance <= snapThreshold -> prev
295
- maxCornerDistance <= smoothThreshold -> blendRectangle(prev, rectangle, 0.35)
296
- else -> rectangle
293
+ // Snap only if all corners are stable AND we have consecutive stable frames
294
+ allCornersStable && maxCornerDist <= snapThreshold -> {
295
+ consecutiveStableFrames++
296
+ if (consecutiveStableFrames >= requiredStableFrames) {
297
+ prev // Lock to previous position
298
+ } else {
299
+ blendRectangle(prev, rectangle, 0.25) // Small blend while building stability
300
+ }
301
+ }
302
+ maxCornerDist <= smoothThreshold -> {
303
+ consecutiveStableFrames = 0
304
+ blendRectangle(prev, rectangle, 0.35)
305
+ }
306
+ else -> {
307
+ consecutiveStableFrames = 0
308
+ rectangle
309
+ }
297
310
  }
298
311
 
299
312
  lastSmoothedRectangleOnScreen = result
300
313
  return result
301
314
  }
302
315
 
316
+ /**
317
+ * Check if all 4 corners are within the snap threshold (not just max distance)
318
+ */
319
+ private fun areAllCornersStable(prev: Rectangle, current: Rectangle, threshold: Double): Boolean {
320
+ return distance(prev.topLeft, current.topLeft) <= threshold &&
321
+ distance(prev.topRight, current.topRight) <= threshold &&
322
+ distance(prev.bottomLeft, current.bottomLeft) <= threshold &&
323
+ distance(prev.bottomRight, current.bottomRight) <= threshold
324
+ }
325
+
303
326
  private fun maxCornerDistance(a: Rectangle, b: Rectangle): Double {
304
327
  return max(
305
328
  max(
@@ -436,9 +436,101 @@ class DocumentDetector {
436
436
  if (aspect < 0.45 || aspect > 2.8) {
437
437
  return false
438
438
  }
439
+
440
+ // Enhanced validation: Check corner angles (should be close to 90°)
441
+ if (!hasValidCornerAngles(rectangle)) {
442
+ return false
443
+ }
444
+
445
+ // Enhanced validation: Check edge straightness
446
+ if (!hasValidEdgeStraightness(rectangle, srcMat)) {
447
+ return false
448
+ }
449
+
450
+ // Enhanced validation: Check margin from view edges (avoid detecting screen borders)
451
+ val margin = minDim * 0.05 // 5% margin
452
+ if (rectangle.topLeft.x < margin || rectangle.topLeft.y < margin ||
453
+ rectangle.topRight.x > srcMat.cols() - margin || rectangle.topRight.y < margin ||
454
+ rectangle.bottomLeft.x < margin || rectangle.bottomLeft.y > srcMat.rows() - margin ||
455
+ rectangle.bottomRight.x > srcMat.cols() - margin || rectangle.bottomRight.y > srcMat.rows() - margin) {
456
+ return false
457
+ }
458
+
439
459
  return true
440
460
  }
441
461
 
462
+ /**
463
+ * Check if all corners have angles close to 90° (within 60°-120° range)
464
+ */
465
+ private fun hasValidCornerAngles(rectangle: Rectangle): Boolean {
466
+ fun angleAt(p1: Point, vertex: Point, p2: Point): Double {
467
+ val v1x = p1.x - vertex.x
468
+ val v1y = p1.y - vertex.y
469
+ val v2x = p2.x - vertex.x
470
+ val v2y = p2.y - vertex.y
471
+
472
+ val dot = v1x * v2x + v1y * v2y
473
+ val len1 = sqrt(v1x * v1x + v1y * v1y)
474
+ val len2 = sqrt(v2x * v2x + v2y * v2y)
475
+
476
+ if (len1 < 1.0 || len2 < 1.0) return 90.0
477
+
478
+ val cosAngle = (dot / (len1 * len2)).coerceIn(-1.0, 1.0)
479
+ return Math.toDegrees(kotlin.math.acos(cosAngle))
480
+ }
481
+
482
+ val angleTL = angleAt(rectangle.topRight, rectangle.topLeft, rectangle.bottomLeft)
483
+ val angleTR = angleAt(rectangle.topLeft, rectangle.topRight, rectangle.bottomRight)
484
+ val angleBL = angleAt(rectangle.topLeft, rectangle.bottomLeft, rectangle.bottomRight)
485
+ val angleBR = angleAt(rectangle.topRight, rectangle.bottomRight, rectangle.bottomLeft)
486
+
487
+ // All angles should be within 60°-120° (allow ±30° from 90°)
488
+ return angleTL in 60.0..120.0 && angleTR in 60.0..120.0 &&
489
+ angleBL in 60.0..120.0 && angleBR in 60.0..120.0
490
+ }
491
+
492
+ /**
493
+ * Check if edges are sufficiently straight (low deviation from fitted line)
494
+ */
495
+ private fun hasValidEdgeStraightness(rectangle: Rectangle, srcMat: Mat): Boolean {
496
+ val minDim = min(srcMat.cols(), srcMat.rows()).toDouble()
497
+ val maxDeviation = max(10.0, minDim * 0.02) // Allow 2% deviation
498
+
499
+ fun checkEdgeStraightness(p1: Point, p2: Point, p3: Point, p4: Point): Boolean {
500
+ // Check if points p1-p2 and p3-p4 form roughly parallel lines
501
+ val dx1 = p2.x - p1.x
502
+ val dy1 = p2.y - p1.y
503
+ val dx2 = p4.x - p3.x
504
+ val dy2 = p4.y - p3.y
505
+
506
+ val len1 = sqrt(dx1 * dx1 + dy1 * dy1)
507
+ val len2 = sqrt(dx2 * dx2 + dy2 * dy2)
508
+
509
+ if (len1 < 1.0 || len2 < 1.0) return true
510
+
511
+ // Normalize and compute angle difference
512
+ val dot = (dx1 * dx2 + dy1 * dy2) / (len1 * len2)
513
+ val angleDiff = Math.toDegrees(kotlin.math.acos(dot.coerceIn(-1.0, 1.0)))
514
+
515
+ // Parallel lines should have angle diff close to 0° or 180°
516
+ return angleDiff < 15.0 || angleDiff > 165.0
517
+ }
518
+
519
+ // Check top vs bottom edges
520
+ val topBottomOk = checkEdgeStraightness(
521
+ rectangle.topLeft, rectangle.topRight,
522
+ rectangle.bottomLeft, rectangle.bottomRight
523
+ )
524
+
525
+ // Check left vs right edges
526
+ val leftRightOk = checkEdgeStraightness(
527
+ rectangle.topLeft, rectangle.bottomLeft,
528
+ rectangle.topRight, rectangle.bottomRight
529
+ )
530
+
531
+ return topBottomOk && leftRightOk
532
+ }
533
+
442
534
  /**
443
535
  * Evaluate rectangle quality (matching iOS logic)
444
536
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "13.1.0",
3
+ "version": "13.3.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",