react-native-rectangle-doc-scanner 13.2.0 → 13.4.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.
- package/android/src/camera2/kotlin/com/reactnativerectangledocscanner/CameraController.kt +6 -4
- package/android/src/camera2/kotlin/com/reactnativerectangledocscanner/DocumentScannerView.kt +61 -17
- package/android/src/common/kotlin/com/reactnativerectangledocscanner/DocumentDetector.kt +95 -0
- package/package.json +1 -1
|
@@ -53,7 +53,9 @@ class CameraController(
|
|
|
53
53
|
|
|
54
54
|
companion object {
|
|
55
55
|
private const val TAG = "CameraController"
|
|
56
|
-
|
|
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
|
-
//
|
|
190
|
-
.
|
|
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 {
|
package/android/src/camera2/kotlin/com/reactnativerectangledocscanner/DocumentScannerView.kt
CHANGED
|
@@ -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
|
-
|
|
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,18 +151,25 @@ 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
|
-
//
|
|
154
|
-
val
|
|
155
|
-
val targetHeight = (viewWidth / PREVIEW_ASPECT_RATIO).toInt()
|
|
154
|
+
// Check if device is tablet (screen width >= 600dp)
|
|
155
|
+
val isTablet = (viewWidth / resources.displayMetrics.density) >= 600
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
if (isTablet) {
|
|
158
|
+
// Tablet: Fill entire view to prevent squished appearance
|
|
159
|
+
previewView.layout(0, 0, viewWidth, viewHeight)
|
|
160
|
+
overlayView.layout(0, 0, viewWidth, viewHeight)
|
|
161
|
+
} else {
|
|
162
|
+
// Phone: Use 4:3 aspect ratio with letterboxing
|
|
163
|
+
val targetAspectRatio = 3f / 4f // width/height
|
|
164
|
+
val targetWidth = viewWidth
|
|
165
|
+
val targetHeight = (viewWidth / targetAspectRatio).toInt()
|
|
166
|
+
|
|
167
|
+
val top = (viewHeight - targetHeight) / 2
|
|
168
|
+
val bottom = top + targetHeight
|
|
162
169
|
|
|
163
|
-
|
|
164
|
-
|
|
170
|
+
previewView.layout(0, top, viewWidth, bottom)
|
|
171
|
+
overlayView.layout(0, top, viewWidth, bottom)
|
|
172
|
+
}
|
|
165
173
|
}
|
|
166
174
|
|
|
167
175
|
private fun initializeCameraWhenReady() {
|
|
@@ -264,36 +272,72 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
|
|
|
264
272
|
}
|
|
265
273
|
}
|
|
266
274
|
|
|
275
|
+
// Enhanced snap: Track corner movement per frame
|
|
276
|
+
private var consecutiveStableFrames = 0
|
|
277
|
+
private val requiredStableFrames = 3 // Require 3 consecutive stable frames for snap
|
|
278
|
+
|
|
267
279
|
private fun smoothRectangle(rectangle: Rectangle?, viewWidth: Int, viewHeight: Int): Rectangle? {
|
|
268
280
|
if (rectangle == null || viewWidth <= 0 || viewHeight <= 0) {
|
|
269
281
|
lastSmoothedRectangleOnScreen = null
|
|
282
|
+
consecutiveStableFrames = 0
|
|
270
283
|
return null
|
|
271
284
|
}
|
|
272
285
|
|
|
273
286
|
val prev = lastSmoothedRectangleOnScreen
|
|
274
287
|
if (prev == null) {
|
|
275
288
|
lastSmoothedRectangleOnScreen = rectangle
|
|
289
|
+
consecutiveStableFrames = 0
|
|
276
290
|
return rectangle
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
val minDim = min(viewWidth, viewHeight).toDouble()
|
|
280
|
-
|
|
281
|
-
val
|
|
282
|
-
val
|
|
294
|
+
// Tighter snap threshold for better stability
|
|
295
|
+
val snapThreshold = max(10.0, minDim * 0.012)
|
|
296
|
+
val smoothThreshold = max(20.0, minDim * 0.05)
|
|
297
|
+
|
|
298
|
+
// Calculate max corner movement
|
|
299
|
+
val maxCornerDist = max(
|
|
283
300
|
maxCornerDistance(prev, rectangle),
|
|
284
301
|
maxCornerDistance(rectangle, prev)
|
|
285
302
|
)
|
|
286
303
|
|
|
304
|
+
// Enhanced snap logic: Check individual corner distances
|
|
305
|
+
val allCornersStable = areAllCornersStable(prev, rectangle, snapThreshold)
|
|
306
|
+
|
|
287
307
|
val result = when {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
308
|
+
// Snap only if all corners are stable AND we have consecutive stable frames
|
|
309
|
+
allCornersStable && maxCornerDist <= snapThreshold -> {
|
|
310
|
+
consecutiveStableFrames++
|
|
311
|
+
if (consecutiveStableFrames >= requiredStableFrames) {
|
|
312
|
+
prev // Lock to previous position
|
|
313
|
+
} else {
|
|
314
|
+
blendRectangle(prev, rectangle, 0.25) // Small blend while building stability
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
maxCornerDist <= smoothThreshold -> {
|
|
318
|
+
consecutiveStableFrames = 0
|
|
319
|
+
blendRectangle(prev, rectangle, 0.35)
|
|
320
|
+
}
|
|
321
|
+
else -> {
|
|
322
|
+
consecutiveStableFrames = 0
|
|
323
|
+
rectangle
|
|
324
|
+
}
|
|
291
325
|
}
|
|
292
326
|
|
|
293
327
|
lastSmoothedRectangleOnScreen = result
|
|
294
328
|
return result
|
|
295
329
|
}
|
|
296
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Check if all 4 corners are within the snap threshold (not just max distance)
|
|
333
|
+
*/
|
|
334
|
+
private fun areAllCornersStable(prev: Rectangle, current: Rectangle, threshold: Double): Boolean {
|
|
335
|
+
return distance(prev.topLeft, current.topLeft) <= threshold &&
|
|
336
|
+
distance(prev.topRight, current.topRight) <= threshold &&
|
|
337
|
+
distance(prev.bottomLeft, current.bottomLeft) <= threshold &&
|
|
338
|
+
distance(prev.bottomRight, current.bottomRight) <= threshold
|
|
339
|
+
}
|
|
340
|
+
|
|
297
341
|
private fun maxCornerDistance(a: Rectangle, b: Rectangle): Double {
|
|
298
342
|
return max(
|
|
299
343
|
max(
|
|
@@ -436,9 +436,104 @@ 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
|
+
// Made more lenient: 50°-130° (was 60°-120°)
|
|
442
|
+
if (!hasValidCornerAngles(rectangle)) {
|
|
443
|
+
return false
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Enhanced validation: Check edge straightness
|
|
447
|
+
// Made more lenient: 20° tolerance (was 15°)
|
|
448
|
+
if (!hasValidEdgeStraightness(rectangle, srcMat)) {
|
|
449
|
+
return false
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Enhanced validation: Check margin from view edges (avoid detecting screen borders)
|
|
453
|
+
// Made more lenient: 3% margin (was 5%)
|
|
454
|
+
val margin = minDim * 0.03
|
|
455
|
+
if (rectangle.topLeft.x < margin || rectangle.topLeft.y < margin ||
|
|
456
|
+
rectangle.topRight.x > srcMat.cols() - margin || rectangle.topRight.y < margin ||
|
|
457
|
+
rectangle.bottomLeft.x < margin || rectangle.bottomLeft.y > srcMat.rows() - margin ||
|
|
458
|
+
rectangle.bottomRight.x > srcMat.cols() - margin || rectangle.bottomRight.y > srcMat.rows() - margin) {
|
|
459
|
+
return false
|
|
460
|
+
}
|
|
461
|
+
|
|
439
462
|
return true
|
|
440
463
|
}
|
|
441
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Check if all corners have angles close to 90° (within 60°-120° range)
|
|
467
|
+
*/
|
|
468
|
+
private fun hasValidCornerAngles(rectangle: Rectangle): Boolean {
|
|
469
|
+
fun angleAt(p1: Point, vertex: Point, p2: Point): Double {
|
|
470
|
+
val v1x = p1.x - vertex.x
|
|
471
|
+
val v1y = p1.y - vertex.y
|
|
472
|
+
val v2x = p2.x - vertex.x
|
|
473
|
+
val v2y = p2.y - vertex.y
|
|
474
|
+
|
|
475
|
+
val dot = v1x * v2x + v1y * v2y
|
|
476
|
+
val len1 = sqrt(v1x * v1x + v1y * v1y)
|
|
477
|
+
val len2 = sqrt(v2x * v2x + v2y * v2y)
|
|
478
|
+
|
|
479
|
+
if (len1 < 1.0 || len2 < 1.0) return 90.0
|
|
480
|
+
|
|
481
|
+
val cosAngle = (dot / (len1 * len2)).coerceIn(-1.0, 1.0)
|
|
482
|
+
return Math.toDegrees(kotlin.math.acos(cosAngle))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
val angleTL = angleAt(rectangle.topRight, rectangle.topLeft, rectangle.bottomLeft)
|
|
486
|
+
val angleTR = angleAt(rectangle.topLeft, rectangle.topRight, rectangle.bottomRight)
|
|
487
|
+
val angleBL = angleAt(rectangle.topLeft, rectangle.bottomLeft, rectangle.bottomRight)
|
|
488
|
+
val angleBR = angleAt(rectangle.topRight, rectangle.bottomRight, rectangle.bottomLeft)
|
|
489
|
+
|
|
490
|
+
// All angles should be within 50°-130° (allow ±40° from 90°, more lenient)
|
|
491
|
+
return angleTL in 50.0..130.0 && angleTR in 50.0..130.0 &&
|
|
492
|
+
angleBL in 50.0..130.0 && angleBR in 50.0..130.0
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Check if edges are sufficiently straight (low deviation from fitted line)
|
|
497
|
+
*/
|
|
498
|
+
private fun hasValidEdgeStraightness(rectangle: Rectangle, srcMat: Mat): Boolean {
|
|
499
|
+
val minDim = min(srcMat.cols(), srcMat.rows()).toDouble()
|
|
500
|
+
val maxDeviation = max(10.0, minDim * 0.02) // Allow 2% deviation
|
|
501
|
+
|
|
502
|
+
fun checkEdgeStraightness(p1: Point, p2: Point, p3: Point, p4: Point): Boolean {
|
|
503
|
+
// Check if points p1-p2 and p3-p4 form roughly parallel lines
|
|
504
|
+
val dx1 = p2.x - p1.x
|
|
505
|
+
val dy1 = p2.y - p1.y
|
|
506
|
+
val dx2 = p4.x - p3.x
|
|
507
|
+
val dy2 = p4.y - p3.y
|
|
508
|
+
|
|
509
|
+
val len1 = sqrt(dx1 * dx1 + dy1 * dy1)
|
|
510
|
+
val len2 = sqrt(dx2 * dx2 + dy2 * dy2)
|
|
511
|
+
|
|
512
|
+
if (len1 < 1.0 || len2 < 1.0) return true
|
|
513
|
+
|
|
514
|
+
// Normalize and compute angle difference
|
|
515
|
+
val dot = (dx1 * dx2 + dy1 * dy2) / (len1 * len2)
|
|
516
|
+
val angleDiff = Math.toDegrees(kotlin.math.acos(dot.coerceIn(-1.0, 1.0)))
|
|
517
|
+
|
|
518
|
+
// Parallel lines should have angle diff close to 0° or 180° (more lenient: 20° tolerance)
|
|
519
|
+
return angleDiff < 20.0 || angleDiff > 160.0
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Check top vs bottom edges
|
|
523
|
+
val topBottomOk = checkEdgeStraightness(
|
|
524
|
+
rectangle.topLeft, rectangle.topRight,
|
|
525
|
+
rectangle.bottomLeft, rectangle.bottomRight
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
// Check left vs right edges
|
|
529
|
+
val leftRightOk = checkEdgeStraightness(
|
|
530
|
+
rectangle.topLeft, rectangle.bottomLeft,
|
|
531
|
+
rectangle.topRight, rectangle.bottomRight
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
return topBottomOk && leftRightOk
|
|
535
|
+
}
|
|
536
|
+
|
|
442
537
|
/**
|
|
443
538
|
* Evaluate rectangle quality (matching iOS logic)
|
|
444
539
|
*/
|