react-native-rectangle-doc-scanner 11.3.0 → 13.0.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.
@@ -486,32 +486,11 @@ class CameraController(
486
486
 
487
487
  if (viewWidth <= 0 || viewHeight <= 0) return null
488
488
 
489
- // The image coordinates are in camera sensor space. We need to transform them
490
- // to match how the TextureView displays the image (after rotation/scaling).
491
- val sensorOrientation = getCameraSensorOrientation()
492
- val displayRotationDegrees = when (textureView.display?.rotation ?: Surface.ROTATION_0) {
493
- Surface.ROTATION_0 -> 0
494
- Surface.ROTATION_90 -> 90
495
- Surface.ROTATION_180 -> 180
496
- Surface.ROTATION_270 -> 270
497
- else -> 0
498
- }
499
-
500
- fun rotatePoint(point: org.opencv.core.Point): org.opencv.core.Point {
501
- return if (sensorOrientation == 90) {
502
- org.opencv.core.Point(
503
- point.y,
504
- imageWidth - point.x
505
- )
506
- } else {
507
- point
508
- }
509
- }
510
-
511
- val finalWidth = if (sensorOrientation == 90) imageHeight else imageWidth
512
- val finalHeight = if (sensorOrientation == 90) imageWidth else imageHeight
489
+ // Rectangle coordinates are already in the rotated image space (effective rotation applied).
490
+ val finalWidth = imageWidth
491
+ val finalHeight = imageHeight
513
492
 
514
- // Then apply fit-center scaling
493
+ // Apply fit-center scaling to match TextureView display.
515
494
  val scaleX = viewWidth / finalWidth.toFloat()
516
495
  val scaleY = viewHeight / finalHeight.toFloat()
517
496
  val scale = scaleX.coerceAtMost(scaleY)
@@ -522,10 +501,9 @@ class CameraController(
522
501
  val offsetY = (viewHeight - scaledHeight) / 2f
523
502
 
524
503
  fun transformPoint(point: org.opencv.core.Point): org.opencv.core.Point {
525
- val rotated = rotatePoint(point)
526
504
  return org.opencv.core.Point(
527
- rotated.x * scale + offsetX,
528
- rotated.y * scale + offsetY
505
+ point.x * scale + offsetX,
506
+ point.y * scale + offsetY
529
507
  )
530
508
  }
531
509
 
@@ -536,10 +514,9 @@ class CameraController(
536
514
  transformPoint(rectangle.bottomRight)
537
515
  )
538
516
 
539
- Log.d(TAG, "[MAPPING] Sensor: ${sensorOrientation}°, Image: ${imageWidth}x${imageHeight} → Final: ${finalWidth}x${finalHeight}")
517
+ Log.d(TAG, "[MAPPING] Image: ${imageWidth}x${imageHeight} → Final: ${finalWidth}x${finalHeight}")
540
518
  Log.d(TAG, "[MAPPING] View: ${viewWidth.toInt()}x${viewHeight.toInt()}, Scale: $scale, Offset: ($offsetX, $offsetY)")
541
519
  Log.d(TAG, "[MAPPING] TL: (${rectangle.topLeft.x}, ${rectangle.topLeft.y}) → " +
542
- "Rotated: (${rotatePoint(rectangle.topLeft).x}, ${rotatePoint(rectangle.topLeft).y}) → " +
543
520
  "Final: (${result.topLeft.x}, ${result.topLeft.y})")
544
521
 
545
522
  return result
@@ -60,6 +60,7 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
60
60
  private var lastDetectedImageHeight = 0
61
61
  private var lastRectangleOnScreen: Rectangle? = null
62
62
  private var lastSmoothedRectangleOnScreen: Rectangle? = null
63
+ private val iouHistory = ArrayDeque<Rectangle>()
63
64
 
64
65
  // Coroutine scope for async operations
65
66
  private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -175,6 +176,7 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
175
176
  }
176
177
  }
177
178
 
179
+
178
180
  private fun setupCamera() {
179
181
  try {
180
182
  Log.d(TAG, "[SETUP] Creating CameraController...")
@@ -305,23 +307,33 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
305
307
  overlayView.setRectangle(rectangleOnScreen, overlayColor)
306
308
  }
307
309
 
308
- // Update stable counter based on quality
310
+ // Update stable counter based on quality + IOU stability
309
311
  if (rectangleCoordinates == null) {
310
312
  if (stableCounter != 0) {
311
313
  Log.d(TAG, "Rectangle lost, resetting stableCounter")
312
314
  }
313
315
  stableCounter = 0
316
+ clearIouHistory()
314
317
  } else {
315
318
  when (quality) {
316
319
  RectangleQuality.GOOD -> {
317
- stableCounter = min(stableCounter + 1, detectionCountBeforeCapture)
318
- Log.d(TAG, "Good rectangle detected, stableCounter: $stableCounter/$detectionCountBeforeCapture")
320
+ val isStable = rectangleOnScreen?.let { updateIouHistory(it) } ?: false
321
+ if (isStable) {
322
+ stableCounter = min(stableCounter + 1, detectionCountBeforeCapture)
323
+ Log.d(TAG, "Good rectangle detected, stableCounter: $stableCounter/$detectionCountBeforeCapture")
324
+ } else {
325
+ if (stableCounter > 0) {
326
+ stableCounter--
327
+ }
328
+ Log.d(TAG, "Rectangle unstable (IOU), stableCounter: $stableCounter")
329
+ }
319
330
  }
320
331
  RectangleQuality.BAD_ANGLE, RectangleQuality.TOO_FAR -> {
321
332
  if (stableCounter > 0) {
322
333
  stableCounter--
323
334
  }
324
335
  Log.d(TAG, "Bad rectangle detected (type: $quality), stableCounter: $stableCounter")
336
+ clearIouHistory()
325
337
  }
326
338
  }
327
339
  }
@@ -337,6 +349,52 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
337
349
  }
338
350
  }
339
351
 
352
+ private fun updateIouHistory(rectangle: Rectangle): Boolean {
353
+ if (iouHistory.size >= 3) {
354
+ iouHistory.removeFirst()
355
+ }
356
+ iouHistory.addLast(rectangle)
357
+ if (iouHistory.size < 3) {
358
+ return false
359
+ }
360
+ val r0 = iouHistory.elementAt(0)
361
+ val r1 = iouHistory.elementAt(1)
362
+ val r2 = iouHistory.elementAt(2)
363
+ val iou01 = rectangleIou(r0, r1)
364
+ val iou12 = rectangleIou(r1, r2)
365
+ val iou02 = rectangleIou(r0, r2)
366
+ return iou01 >= 0.85 && iou12 >= 0.85 && iou02 >= 0.85
367
+ }
368
+
369
+ private fun clearIouHistory() {
370
+ iouHistory.clear()
371
+ }
372
+
373
+ private fun rectangleIou(a: Rectangle, b: Rectangle): Double {
374
+ fun bounds(r: Rectangle): DoubleArray {
375
+ val minX = min(min(r.topLeft.x, r.topRight.x), min(r.bottomLeft.x, r.bottomRight.x))
376
+ val maxX = max(max(r.topLeft.x, r.topRight.x), max(r.bottomLeft.x, r.bottomRight.x))
377
+ val minY = min(min(r.topLeft.y, r.topRight.y), min(r.bottomLeft.y, r.bottomRight.y))
378
+ val maxY = max(max(r.topLeft.y, r.topRight.y), max(r.bottomLeft.y, r.bottomRight.y))
379
+ return doubleArrayOf(minX, minY, maxX, maxY)
380
+ }
381
+
382
+ val ab = bounds(a)
383
+ val bb = bounds(b)
384
+ val interLeft = max(ab[0], bb[0])
385
+ val interTop = max(ab[1], bb[1])
386
+ val interRight = min(ab[2], bb[2])
387
+ val interBottom = min(ab[3], bb[3])
388
+ val interW = max(0.0, interRight - interLeft)
389
+ val interH = max(0.0, interBottom - interTop)
390
+ val interArea = interW * interH
391
+ val areaA = max(0.0, (ab[2] - ab[0])) * max(0.0, (ab[3] - ab[1]))
392
+ val areaB = max(0.0, (bb[2] - bb[0])) * max(0.0, (bb[3] - bb[1]))
393
+ val union = areaA + areaB - interArea
394
+ if (union <= 0.0) return 0.0
395
+ return interArea / union
396
+ }
397
+
340
398
  fun capture() {
341
399
  captureWithPromise(null)
342
400
  }
@@ -715,7 +715,7 @@ const NativeScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAU
715
715
  const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
716
716
  const detectionThreshold = autoCapture ? minStableFrames : 99999;
717
717
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
718
- react_1.default.createElement(react_native_document_scanner_1.default, { ref: scannerRef, style: styles.scanner, detectionCountBeforeCapture: detectionThreshold, overlayColor: overlayColor, enableTorch: enableTorch, quality: normalizedQuality, useBase64: useBase64, manualOnly: false, detectionConfig: detectionConfig, onPictureTaken: handlePictureTaken, onError: handleError, onRectangleDetect: handleRectangleDetect }),
718
+ react_1.default.createElement(react_native_document_scanner_1.default, { ref: scannerRef, style: styles.scanner, detectionCountBeforeCapture: detectionThreshold, overlayColor: overlayColor, enableTorch: enableTorch, quality: normalizedQuality, useBase64: useBase64, manualOnly: react_native_1.Platform.OS === 'android', detectionConfig: detectionConfig, onPictureTaken: handlePictureTaken, onError: handleError, onRectangleDetect: handleRectangleDetect }),
719
719
  showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon, clipRect: react_native_1.Platform.OS === 'android' ? null : (detectedRectangle?.previewViewport ?? null) })),
720
720
  showManualCaptureButton && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture })),
721
721
  children));
@@ -725,16 +725,11 @@ exports.DocScanner = (0, react_1.forwardRef)((props, ref) => {
725
725
  if (react_native_1.Platform.OS !== 'android') {
726
726
  return;
727
727
  }
728
- if (hasVisionCamera) {
729
- console.log('[DocScanner] Using VisionCamera pipeline');
730
- }
731
- else {
732
- console.warn('[DocScanner] VisionCamera pipeline unavailable, falling back to native view.', {
733
- hasVisionCameraModule: Boolean(visionCameraModule),
734
- hasReanimated: Boolean(reanimatedModule),
735
- });
736
- }
728
+ console.log('[DocScanner] Using native CameraX pipeline on Android');
737
729
  }, []);
730
+ if (react_native_1.Platform.OS === 'android') {
731
+ return react_1.default.createElement(NativeScanner, { ref: ref, ...props });
732
+ }
738
733
  if (hasVisionCamera) {
739
734
  return react_1.default.createElement(VisionCameraScanner, { ref: ref, ...props });
740
735
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "11.3.0",
3
+ "version": "13.0.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -994,7 +994,7 @@ const NativeScanner = forwardRef<DocScannerHandle, Props>(
994
994
  : detectedRectangle?.rectangleOnScreen ?? detectedRectangle?.rectangleCoordinates ?? null;
995
995
  const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
996
996
 
997
- const detectionThreshold = autoCapture ? minStableFrames : 99999;
997
+ const detectionThreshold = autoCapture ? minStableFrames : 99999;
998
998
 
999
999
  return (
1000
1000
  <View style={styles.container}>
@@ -1006,7 +1006,7 @@ const NativeScanner = forwardRef<DocScannerHandle, Props>(
1006
1006
  enableTorch={enableTorch}
1007
1007
  quality={normalizedQuality}
1008
1008
  useBase64={useBase64}
1009
- manualOnly={false}
1009
+ manualOnly={Platform.OS === 'android'}
1010
1010
  detectionConfig={detectionConfig}
1011
1011
  onPictureTaken={handlePictureTaken}
1012
1012
  onError={handleError}
@@ -1035,16 +1035,13 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>((props, ref) => {
1035
1035
  if (Platform.OS !== 'android') {
1036
1036
  return;
1037
1037
  }
1038
- if (hasVisionCamera) {
1039
- console.log('[DocScanner] Using VisionCamera pipeline');
1040
- } else {
1041
- console.warn('[DocScanner] VisionCamera pipeline unavailable, falling back to native view.', {
1042
- hasVisionCameraModule: Boolean(visionCameraModule),
1043
- hasReanimated: Boolean(reanimatedModule),
1044
- });
1045
- }
1038
+ console.log('[DocScanner] Using native CameraX pipeline on Android');
1046
1039
  }, []);
1047
1040
 
1041
+ if (Platform.OS === 'android') {
1042
+ return <NativeScanner ref={ref} {...props} />;
1043
+ }
1044
+
1048
1045
  if (hasVisionCamera) {
1049
1046
  return <VisionCameraScanner ref={ref} {...props} />;
1050
1047
  }