react-native-rectangle-doc-scanner 10.27.0 → 10.29.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.
@@ -33,6 +33,7 @@ class CameraController(
33
33
  private var preview: Preview? = null
34
34
  private var imageAnalyzer: ImageAnalysis? = null
35
35
  private var imageCapture: ImageCapture? = null
36
+ private var previewViewport: android.graphics.RectF? = null
36
37
 
37
38
  private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
38
39
 
@@ -358,8 +359,8 @@ class CameraController(
358
359
  if (mlBox != null) {
359
360
  val frameWidth = if (rotation == 90 || rotation == 270) height else width
360
361
  val frameHeight = if (rotation == 90 || rotation == 270) width else height
361
- val padX = (mlBox.width() * 0.15f).toInt().coerceAtLeast(24)
362
- val padY = (mlBox.height() * 0.15f).toInt().coerceAtLeast(24)
362
+ val padX = (mlBox.width() * 0.25f).toInt().coerceAtLeast(32)
363
+ val padY = (mlBox.height() * 0.25f).toInt().coerceAtLeast(32)
363
364
  val roi = android.graphics.Rect(
364
365
  (mlBox.left - padX).coerceAtLeast(0),
365
366
  (mlBox.top - padY).coerceAtLeast(0),
@@ -466,13 +467,10 @@ class CameraController(
466
467
  }
467
468
 
468
469
  fun getPreviewViewport(): android.graphics.RectF? {
469
- // With TextureView, the viewport is simply the view bounds
470
- val width = textureView.width.toFloat()
471
- val height = textureView.height.toFloat()
472
-
473
- if (width <= 0 || height <= 0) return null
474
-
475
- return android.graphics.RectF(0f, 0f, width, height)
470
+ val viewWidth = textureView.width.toFloat()
471
+ val viewHeight = textureView.height.toFloat()
472
+ if (viewWidth <= 0 || viewHeight <= 0) return null
473
+ return previewViewport ?: android.graphics.RectF(0f, 0f, viewWidth, viewHeight)
476
474
  }
477
475
 
478
476
  private fun updateTextureViewTransform(bufferWidth: Int, bufferHeight: Int) {
@@ -537,6 +535,18 @@ class CameraController(
537
535
 
538
536
  matrix.postScale(scale, scale, centerX, centerY)
539
537
 
538
+ // Track the actual preview viewport within the view for clipping overlays.
539
+ val scaledWidth = rotatedBufferWidth * scale
540
+ val scaledHeight = rotatedBufferHeight * scale
541
+ val offsetX = (viewWidth - scaledWidth) / 2f
542
+ val offsetY = (viewHeight - scaledHeight) / 2f
543
+ previewViewport = android.graphics.RectF(
544
+ offsetX,
545
+ offsetY,
546
+ offsetX + scaledWidth,
547
+ offsetY + scaledHeight
548
+ )
549
+
540
550
  textureView.setTransform(matrix)
541
551
  Log.d(TAG, "[TRANSFORM] Transform applied successfully")
542
552
  }
@@ -36,6 +36,7 @@ enum class RectangleQuality {
36
36
  class DocumentDetector {
37
37
  companion object {
38
38
  private const val TAG = "DocumentDetector"
39
+ private var debugFrameCounter = 0
39
40
 
40
41
  init {
41
42
  try {
@@ -161,6 +162,7 @@ class DocumentDetector {
161
162
  val cannyMat = Mat()
162
163
  val morphMat = Mat()
163
164
  val threshMat = Mat()
165
+ val debugStats = DebugStats()
164
166
 
165
167
  try {
166
168
  // Convert to grayscale
@@ -207,8 +209,8 @@ class DocumentDetector {
207
209
 
208
210
  val median = computeMedian(blurredMat)
209
211
  val sigma = 0.33
210
- val cannyLow = max(40.0, (1.0 - sigma) * median)
211
- val cannyHigh = max(120.0, (1.0 + sigma) * median)
212
+ val cannyLow = max(25.0, (1.0 - sigma) * median)
213
+ val cannyHigh = max(80.0, (1.0 + sigma) * median)
212
214
 
213
215
  // Apply Canny edge detection with adaptive thresholds for better corner detection.
214
216
  Imgproc.Canny(blurredMat, cannyMat, cannyLow, cannyHigh)
@@ -256,7 +258,9 @@ class DocumentDetector {
256
258
 
257
259
  var largestRectangle: Rectangle? = null
258
260
  var bestScore = 0.0
259
- val minArea = max(800.0, (srcMat.rows() * srcMat.cols()) * 0.001)
261
+ val minArea = max(450.0, (srcMat.rows() * srcMat.cols()) * 0.0007)
262
+
263
+ debugStats.contours = contours.size
260
264
 
261
265
  for (contour in contours) {
262
266
  val contourArea = Imgproc.contourArea(contour)
@@ -284,6 +288,7 @@ class DocumentDetector {
284
288
  val rectArea = rect.size.area()
285
289
  val rectangularity = if (rectArea > 1.0) contourArea / rectArea else 0.0
286
290
  if (rectangularity >= 0.6) {
291
+ debugStats.candidates += 1
287
292
  val score = contourArea * rectangularity
288
293
  if (score > bestScore) {
289
294
  bestScore = score
@@ -299,6 +304,7 @@ class DocumentDetector {
299
304
  if (rectArea > 1.0) {
300
305
  val rectangularity = contourArea / rectArea
301
306
  if (rectangularity >= 0.6) {
307
+ debugStats.candidates += 1
302
308
  val boxPoints = Array(4) { Point() }
303
309
  rotated.points(boxPoints)
304
310
  val score = contourArea * rectangularity
@@ -317,6 +323,7 @@ class DocumentDetector {
317
323
 
318
324
  hierarchy.release()
319
325
  contours.forEach { it.release() }
326
+ debugStats.bestScore = bestScore
320
327
  return largestRectangle
321
328
  }
322
329
 
@@ -340,6 +347,19 @@ class DocumentDetector {
340
347
  rectangle = findLargestRectangle(morphMat)
341
348
  }
342
349
 
350
+ if (BuildConfig.DEBUG) {
351
+ debugFrameCounter = (debugFrameCounter + 1) % 15
352
+ if (debugFrameCounter == 0) {
353
+ Log.d(
354
+ TAG,
355
+ "[DEBUG] cannyLow=$cannyLow cannyHigh=$cannyHigh " +
356
+ "contours=${debugStats.contours} candidates=${debugStats.candidates} " +
357
+ "bestScore=${String.format(\"%.1f\", debugStats.bestScore)} " +
358
+ "hasRect=${rectangle != null}"
359
+ )
360
+ }
361
+ }
362
+
343
363
  return rectangle
344
364
  } finally {
345
365
  grayMat.release()
@@ -350,6 +370,12 @@ class DocumentDetector {
350
370
  }
351
371
  }
352
372
 
373
+ private data class DebugStats(
374
+ var contours: Int = 0,
375
+ var candidates: Int = 0,
376
+ var bestScore: Double = 0.0
377
+ )
378
+
353
379
  /**
354
380
  * Order points in consistent order: topLeft, topRight, bottomLeft, bottomRight
355
381
  */
@@ -410,7 +436,7 @@ class DocumentDetector {
410
436
  }
411
437
 
412
438
  val minDim = kotlin.math.min(viewWidth.toDouble(), viewHeight.toDouble())
413
- val angleThreshold = max(60.0, minDim * 0.08)
439
+ val angleThreshold = max(90.0, minDim * 0.12)
414
440
 
415
441
  val topYDiff = abs(rectangle.topRight.y - rectangle.topLeft.y)
416
442
  val bottomYDiff = abs(rectangle.bottomLeft.y - rectangle.bottomRight.y)
@@ -421,7 +447,7 @@ class DocumentDetector {
421
447
  return RectangleQuality.BAD_ANGLE
422
448
  }
423
449
 
424
- val margin = max(120.0, minDim * 0.12)
450
+ val margin = max(80.0, minDim * 0.08)
425
451
  if (rectangle.topLeft.y > margin ||
426
452
  rectangle.topRight.y > margin ||
427
453
  rectangle.bottomLeft.y < (viewHeight - margin) ||
@@ -129,6 +129,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
129
129
  const [isGalleryOpen, setIsGalleryOpen] = (0, react_1.useState)(false);
130
130
  const [rectangleDetected, setRectangleDetected] = (0, react_1.useState)(false);
131
131
  const [rectangleHint, setRectangleHint] = (0, react_1.useState)(false);
132
+ const [captureReady, setCaptureReady] = (0, react_1.useState)(false);
132
133
  const [flashEnabled, setFlashEnabled] = (0, react_1.useState)(false);
133
134
  const [rotationDegrees, setRotationDegrees] = (0, react_1.useState)(0);
134
135
  const [capturedPhotos, setCapturedPhotos] = (0, react_1.useState)([]);
@@ -140,6 +141,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
140
141
  const captureInProgressRef = (0, react_1.useRef)(false);
141
142
  const rectangleCaptureTimeoutRef = (0, react_1.useRef)(null);
142
143
  const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
144
+ const captureReadyTimeoutRef = (0, react_1.useRef)(null);
143
145
  const isBusinessMode = type === 'business';
144
146
  const resetScannerView = (0, react_1.useCallback)((options) => {
145
147
  setProcessing(false);
@@ -147,6 +149,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
147
149
  setRotationDegrees(0);
148
150
  setRectangleDetected(false);
149
151
  setRectangleHint(false);
152
+ setCaptureReady(false);
150
153
  captureModeRef.current = null;
151
154
  captureInProgressRef.current = false;
152
155
  if (rectangleCaptureTimeoutRef.current) {
@@ -157,6 +160,10 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
157
160
  clearTimeout(rectangleHintTimeoutRef.current);
158
161
  rectangleHintTimeoutRef.current = null;
159
162
  }
163
+ if (captureReadyTimeoutRef.current) {
164
+ clearTimeout(captureReadyTimeoutRef.current);
165
+ captureReadyTimeoutRef.current = null;
166
+ }
160
167
  if (docScannerRef.current?.reset) {
161
168
  docScannerRef.current.reset();
162
169
  }
@@ -360,9 +367,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
360
367
  hasRef: hasScanner,
361
368
  rectangleDetected,
362
369
  rectangleHint,
370
+ captureReady,
363
371
  currentCaptureMode: captureModeRef.current,
364
372
  captureInProgress: captureInProgressRef.current,
365
373
  });
374
+ if (react_native_1.Platform.OS === 'android' && !captureReady) {
375
+ console.log('[FullDocScanner] Capture not ready yet, skipping');
376
+ return;
377
+ }
366
378
  if (processing) {
367
379
  console.log('[FullDocScanner] Already processing, skipping manual capture');
368
380
  return;
@@ -408,7 +420,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
408
420
  emitError(error, 'Failed to capture image. Please try again.');
409
421
  }
410
422
  });
411
- }, [processing, rectangleDetected, rectangleHint, emitError]);
423
+ }, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
412
424
  const handleGalleryPick = (0, react_1.useCallback)(async () => {
413
425
  console.log('[FullDocScanner] handleGalleryPick called');
414
426
  if (processing || isGalleryOpen) {
@@ -527,7 +539,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
527
539
  const stableCounter = event.stableCounter ?? 0;
528
540
  const rectangleCoordinates = event.rectangleOnScreen ?? event.rectangleCoordinates;
529
541
  const hasRectangle = Boolean(rectangleCoordinates);
530
- const captureReady = hasRectangle && (react_native_1.Platform.OS === 'android' || (event.lastDetectionType === 0 && stableCounter >= 1));
542
+ const isStableForCapture = hasRectangle && (react_native_1.Platform.OS === 'android' || (event.lastDetectionType === 0 && stableCounter >= 1));
531
543
  const scheduleClear = (ref, clearFn) => {
532
544
  if (ref.current) {
533
545
  clearTimeout(ref.current);
@@ -540,6 +552,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
540
552
  if (hasRectangle) {
541
553
  scheduleClear(rectangleHintTimeoutRef, () => setRectangleHint(false));
542
554
  setRectangleHint(true);
555
+ if (react_native_1.Platform.OS === 'android') {
556
+ if (!captureReadyTimeoutRef.current) {
557
+ captureReadyTimeoutRef.current = setTimeout(() => {
558
+ setCaptureReady(true);
559
+ captureReadyTimeoutRef.current = null;
560
+ }, 1000);
561
+ }
562
+ }
543
563
  }
544
564
  else {
545
565
  if (rectangleHintTimeoutRef.current) {
@@ -547,8 +567,15 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
547
567
  rectangleHintTimeoutRef.current = null;
548
568
  }
549
569
  setRectangleHint(false);
570
+ if (react_native_1.Platform.OS === 'android') {
571
+ if (captureReadyTimeoutRef.current) {
572
+ clearTimeout(captureReadyTimeoutRef.current);
573
+ captureReadyTimeoutRef.current = null;
574
+ }
575
+ setCaptureReady(false);
576
+ }
550
577
  }
551
- if (captureReady) {
578
+ if (isStableForCapture) {
552
579
  scheduleClear(rectangleCaptureTimeoutRef, () => {
553
580
  setRectangleDetected(false);
554
581
  });
@@ -570,6 +597,9 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
570
597
  if (rectangleHintTimeoutRef.current) {
571
598
  clearTimeout(rectangleHintTimeoutRef.current);
572
599
  }
600
+ if (captureReadyTimeoutRef.current) {
601
+ clearTimeout(captureReadyTimeoutRef.current);
602
+ }
573
603
  }, []);
574
604
  const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
575
605
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
@@ -642,10 +672,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
642
672
  enableGallery && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.iconButton, processing && styles.buttonDisabled], onPress: handleGalleryPick, disabled: processing, accessibilityLabel: mergedStrings.galleryButton, accessibilityRole: "button" },
643
673
  react_1.default.createElement(react_native_1.View, { style: styles.iconContainer },
644
674
  react_1.default.createElement(react_native_1.Text, { style: styles.iconText }, "\uD83D\uDDBC\uFE0F")))),
645
- react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.shutterButton, processing && styles.buttonDisabled], onPress: triggerManualCapture, disabled: processing, accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
675
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
676
+ styles.shutterButton,
677
+ processing && styles.buttonDisabled,
678
+ react_native_1.Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
679
+ ], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
646
680
  react_1.default.createElement(react_native_1.View, { style: [
647
681
  styles.shutterInner,
648
- rectangleHint && { backgroundColor: overlayColor }
682
+ (react_native_1.Platform.OS === 'android' ? captureReady : rectangleHint) && { backgroundColor: overlayColor }
649
683
  ] })),
650
684
  react_1.default.createElement(react_native_1.View, { style: styles.rightButtonsPlaceholder }))))),
651
685
  processing && (react_1.default.createElement(react_native_1.View, { style: styles.processingOverlay },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "10.27.0",
3
+ "version": "10.29.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -192,6 +192,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
192
192
  const [isGalleryOpen, setIsGalleryOpen] = useState(false);
193
193
  const [rectangleDetected, setRectangleDetected] = useState(false);
194
194
  const [rectangleHint, setRectangleHint] = useState(false);
195
+ const [captureReady, setCaptureReady] = useState(false);
195
196
  const [flashEnabled, setFlashEnabled] = useState(false);
196
197
  const [rotationDegrees, setRotationDegrees] = useState(0);
197
198
  const [capturedPhotos, setCapturedPhotos] = useState<FullDocScannerResult[]>([]);
@@ -203,6 +204,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
203
204
  const captureInProgressRef = useRef(false);
204
205
  const rectangleCaptureTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
205
206
  const rectangleHintTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
207
+ const captureReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
206
208
 
207
209
  const isBusinessMode = type === 'business';
208
210
 
@@ -213,6 +215,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
213
215
  setRotationDegrees(0);
214
216
  setRectangleDetected(false);
215
217
  setRectangleHint(false);
218
+ setCaptureReady(false);
216
219
  captureModeRef.current = null;
217
220
  captureInProgressRef.current = false;
218
221
 
@@ -225,6 +228,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
225
228
  clearTimeout(rectangleHintTimeoutRef.current);
226
229
  rectangleHintTimeoutRef.current = null;
227
230
  }
231
+ if (captureReadyTimeoutRef.current) {
232
+ clearTimeout(captureReadyTimeoutRef.current);
233
+ captureReadyTimeoutRef.current = null;
234
+ }
228
235
 
229
236
  if (docScannerRef.current?.reset) {
230
237
  docScannerRef.current.reset();
@@ -505,10 +512,16 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
505
512
  hasRef: hasScanner,
506
513
  rectangleDetected,
507
514
  rectangleHint,
515
+ captureReady,
508
516
  currentCaptureMode: captureModeRef.current,
509
517
  captureInProgress: captureInProgressRef.current,
510
518
  });
511
519
 
520
+ if (Platform.OS === 'android' && !captureReady) {
521
+ console.log('[FullDocScanner] Capture not ready yet, skipping');
522
+ return;
523
+ }
524
+
512
525
  if (processing) {
513
526
  console.log('[FullDocScanner] Already processing, skipping manual capture');
514
527
  return;
@@ -567,7 +580,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
567
580
  );
568
581
  }
569
582
  });
570
- }, [processing, rectangleDetected, rectangleHint, emitError]);
583
+ }, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
571
584
 
572
585
  const handleGalleryPick = useCallback(async () => {
573
586
  console.log('[FullDocScanner] handleGalleryPick called');
@@ -719,7 +732,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
719
732
  const stableCounter = event.stableCounter ?? 0;
720
733
  const rectangleCoordinates = event.rectangleOnScreen ?? event.rectangleCoordinates;
721
734
  const hasRectangle = Boolean(rectangleCoordinates);
722
- const captureReady = hasRectangle && (Platform.OS === 'android' || (event.lastDetectionType === 0 && stableCounter >= 1));
735
+ const isStableForCapture =
736
+ hasRectangle && (Platform.OS === 'android' || (event.lastDetectionType === 0 && stableCounter >= 1));
723
737
 
724
738
  const scheduleClear = (
725
739
  ref: React.MutableRefObject<ReturnType<typeof setTimeout> | null>,
@@ -737,15 +751,30 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
737
751
  if (hasRectangle) {
738
752
  scheduleClear(rectangleHintTimeoutRef, () => setRectangleHint(false));
739
753
  setRectangleHint(true);
754
+ if (Platform.OS === 'android') {
755
+ if (!captureReadyTimeoutRef.current) {
756
+ captureReadyTimeoutRef.current = setTimeout(() => {
757
+ setCaptureReady(true);
758
+ captureReadyTimeoutRef.current = null;
759
+ }, 1000);
760
+ }
761
+ }
740
762
  } else {
741
763
  if (rectangleHintTimeoutRef.current) {
742
764
  clearTimeout(rectangleHintTimeoutRef.current);
743
765
  rectangleHintTimeoutRef.current = null;
744
766
  }
745
767
  setRectangleHint(false);
768
+ if (Platform.OS === 'android') {
769
+ if (captureReadyTimeoutRef.current) {
770
+ clearTimeout(captureReadyTimeoutRef.current);
771
+ captureReadyTimeoutRef.current = null;
772
+ }
773
+ setCaptureReady(false);
774
+ }
746
775
  }
747
776
 
748
- if (captureReady) {
777
+ if (isStableForCapture) {
749
778
  scheduleClear(rectangleCaptureTimeoutRef, () => {
750
779
  setRectangleDetected(false);
751
780
  });
@@ -768,6 +797,9 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
768
797
  if (rectangleHintTimeoutRef.current) {
769
798
  clearTimeout(rectangleHintTimeoutRef.current);
770
799
  }
800
+ if (captureReadyTimeoutRef.current) {
801
+ clearTimeout(captureReadyTimeoutRef.current);
802
+ }
771
803
  },
772
804
  [],
773
805
  );
@@ -1000,15 +1032,19 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
1000
1032
  </TouchableOpacity>
1001
1033
  )}
1002
1034
  <TouchableOpacity
1003
- style={[styles.shutterButton, processing && styles.buttonDisabled]}
1035
+ style={[
1036
+ styles.shutterButton,
1037
+ processing && styles.buttonDisabled,
1038
+ Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
1039
+ ]}
1004
1040
  onPress={triggerManualCapture}
1005
- disabled={processing}
1041
+ disabled={processing || (Platform.OS === 'android' && !captureReady)}
1006
1042
  accessibilityLabel={mergedStrings.manualHint}
1007
1043
  accessibilityRole="button"
1008
1044
  >
1009
1045
  <View style={[
1010
1046
  styles.shutterInner,
1011
- rectangleHint && { backgroundColor: overlayColor }
1047
+ (Platform.OS === 'android' ? captureReady : rectangleHint) && { backgroundColor: overlayColor }
1012
1048
  ]} />
1013
1049
  </TouchableOpacity>
1014
1050
  <View style={styles.rightButtonsPlaceholder} />