react-native-rectangle-doc-scanner 10.31.0 → 10.33.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.
|
@@ -433,37 +433,105 @@ class CameraController(
|
|
|
433
433
|
Log.d(TAG, "[CAMERAX] Transform refresh requested - handled automatically")
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
//
|
|
436
|
+
// Coordinate mapping aligned with TextureView transform
|
|
437
437
|
fun mapRectangleToView(rectangle: Rectangle?, imageWidth: Int, imageHeight: Int): Rectangle? {
|
|
438
438
|
if (rectangle == null || imageWidth <= 0 || imageHeight <= 0) return null
|
|
439
439
|
|
|
440
|
-
// Fit-center scaling to avoid zoom/crop and keep mapping aligned with preview.
|
|
441
440
|
val viewWidth = textureView.width.toFloat()
|
|
442
441
|
val viewHeight = textureView.height.toFloat()
|
|
443
442
|
|
|
444
443
|
if (viewWidth <= 0 || viewHeight <= 0) return null
|
|
445
444
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
445
|
+
// The image coordinates are in camera sensor space. We need to transform them
|
|
446
|
+
// to match how the TextureView displays the image (after rotation/scaling).
|
|
447
|
+
|
|
448
|
+
// CameraX provides images in sensor orientation. For a 90° sensor (most phones),
|
|
449
|
+
// the image is rotated 90° relative to natural portrait. We must rotate coordinates
|
|
450
|
+
// to match the final display orientation.
|
|
451
|
+
val sensorOrientation = getCameraSensorOrientation()
|
|
452
|
+
val displayRotationDegrees = when (textureView.display?.rotation ?: Surface.ROTATION_0) {
|
|
453
|
+
Surface.ROTATION_0 -> 0
|
|
454
|
+
Surface.ROTATION_90 -> 90
|
|
455
|
+
Surface.ROTATION_180 -> 180
|
|
456
|
+
Surface.ROTATION_270 -> 270
|
|
457
|
+
else -> 0
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Calculate the rotation needed to display the image correctly in portrait mode.
|
|
461
|
+
// This must match the rotation applied in updateTextureViewTransform.
|
|
462
|
+
val rotationDegrees = when {
|
|
463
|
+
// Tablet with landscape sensor in portrait: add 180° fix for upside-down
|
|
464
|
+
sensorOrientation == 0 && displayRotationDegrees == 90 -> 270f
|
|
465
|
+
// Phone with 90° sensor in portrait: rotate 90° to match
|
|
466
|
+
sensorOrientation == 90 && displayRotationDegrees == 0 -> 90f
|
|
467
|
+
// Default: use display rotation
|
|
468
|
+
else -> displayRotationDegrees.toFloat()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
Log.d(TAG, "[MAPPING] Image: ${imageWidth}x${imageHeight}, Sensor: ${sensorOrientation}°, " +
|
|
472
|
+
"Display: ${displayRotationDegrees}°, Final rotation: ${rotationDegrees}°")
|
|
473
|
+
|
|
474
|
+
// Apply rotation to coordinates to match display orientation
|
|
475
|
+
fun rotatePoint(point: org.opencv.core.Point): org.opencv.core.Point {
|
|
476
|
+
return when (rotationDegrees.toInt()) {
|
|
477
|
+
90 -> org.opencv.core.Point(
|
|
478
|
+
imageHeight - point.y,
|
|
479
|
+
point.x
|
|
480
|
+
)
|
|
481
|
+
180 -> org.opencv.core.Point(
|
|
482
|
+
imageWidth - point.x,
|
|
483
|
+
imageHeight - point.y
|
|
484
|
+
)
|
|
485
|
+
270 -> org.opencv.core.Point(
|
|
486
|
+
point.y,
|
|
487
|
+
imageWidth - point.x
|
|
488
|
+
)
|
|
489
|
+
else -> point // 0 degrees, no rotation
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Determine dimensions after rotation
|
|
494
|
+
val rotatedImageWidth = if (rotationDegrees == 90f || rotationDegrees == 270f) {
|
|
495
|
+
imageHeight
|
|
496
|
+
} else {
|
|
497
|
+
imageWidth
|
|
498
|
+
}
|
|
499
|
+
val rotatedImageHeight = if (rotationDegrees == 90f || rotationDegrees == 270f) {
|
|
500
|
+
imageWidth
|
|
501
|
+
} else {
|
|
502
|
+
imageHeight
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Calculate scaling to fit the rotated image into the view (matching transform)
|
|
506
|
+
val scaleX = viewWidth / rotatedImageWidth.toFloat()
|
|
507
|
+
val scaleY = viewHeight / rotatedImageHeight.toFloat()
|
|
508
|
+
val scale = scaleX.coerceAtMost(scaleY) // Fit (preserve aspect ratio)
|
|
509
|
+
|
|
510
|
+
val scaledWidth = rotatedImageWidth * scale
|
|
511
|
+
val scaledHeight = rotatedImageHeight * scale
|
|
451
512
|
val offsetX = (viewWidth - scaledWidth) / 2f
|
|
452
513
|
val offsetY = (viewHeight - scaledHeight) / 2f
|
|
453
514
|
|
|
454
|
-
|
|
515
|
+
// Transform coordinates: rotate first, then scale and center
|
|
516
|
+
fun transformPoint(point: org.opencv.core.Point): org.opencv.core.Point {
|
|
517
|
+
val rotated = rotatePoint(point)
|
|
455
518
|
return org.opencv.core.Point(
|
|
456
|
-
|
|
457
|
-
|
|
519
|
+
rotated.x * scale + offsetX,
|
|
520
|
+
rotated.y * scale + offsetY
|
|
458
521
|
)
|
|
459
522
|
}
|
|
460
523
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
524
|
+
val result = Rectangle(
|
|
525
|
+
transformPoint(rectangle.topLeft),
|
|
526
|
+
transformPoint(rectangle.topRight),
|
|
527
|
+
transformPoint(rectangle.bottomLeft),
|
|
528
|
+
transformPoint(rectangle.bottomRight)
|
|
466
529
|
)
|
|
530
|
+
|
|
531
|
+
Log.d(TAG, "[MAPPING] Original TL: (${rectangle.topLeft.x}, ${rectangle.topLeft.y}) → " +
|
|
532
|
+
"Transformed: (${result.topLeft.x}, ${result.topLeft.y})")
|
|
533
|
+
|
|
534
|
+
return result
|
|
467
535
|
}
|
|
468
536
|
|
|
469
537
|
fun getPreviewViewport(): android.graphics.RectF? {
|
|
@@ -503,10 +571,17 @@ class CameraController(
|
|
|
503
571
|
val centerY = viewHeight / 2f
|
|
504
572
|
|
|
505
573
|
// Calculate rotation from buffer to display coordinates.
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
val rotationDegrees =
|
|
574
|
+
// For portrait apps:
|
|
575
|
+
// - 90° sensor (phones): buffer is landscape → need 90° rotation to portrait
|
|
576
|
+
// - 0° sensor (tablets): buffer is portrait → need displayRotation adjustment
|
|
577
|
+
val rotationDegrees = when {
|
|
578
|
+
// Tablet with landscape sensor in portrait: add 180° fix for upside-down
|
|
579
|
+
sensorOrientation == 0 && displayRotationDegrees == 90 -> 270f
|
|
580
|
+
// Phone with 90° sensor in portrait: rotate 90° to match
|
|
581
|
+
sensorOrientation == 90 && displayRotationDegrees == 0 -> 90f
|
|
582
|
+
// Default: use display rotation
|
|
583
|
+
else -> displayRotationDegrees.toFloat()
|
|
584
|
+
}
|
|
510
585
|
|
|
511
586
|
Log.d(TAG, "[TRANSFORM] Applying rotation: ${rotationDegrees}°")
|
|
512
587
|
|
|
@@ -285,15 +285,24 @@ class DocumentDetector {
|
|
|
285
285
|
|
|
286
286
|
if (quad.total() == 4L && Imgproc.isContourConvex(MatOfPoint(*quad.toArray()))) {
|
|
287
287
|
val points = quad.toArray()
|
|
288
|
+
val ordered = orderPoints(points)
|
|
288
289
|
val rect = Imgproc.minAreaRect(MatOfPoint2f(*points))
|
|
289
290
|
val rectArea = rect.size.area()
|
|
290
291
|
val rectangularity = if (rectArea > 1.0) contourArea / rectArea else 0.0
|
|
291
|
-
if (rectangularity >= 0.6) {
|
|
292
|
+
if (rectangularity >= 0.6 && isCandidateValid(ordered, srcMat)) {
|
|
292
293
|
debugStats.candidates += 1
|
|
293
294
|
val score = contourArea * rectangularity
|
|
294
295
|
if (score > bestScore) {
|
|
296
|
+
val width = distance(ordered.topLeft, ordered.topRight)
|
|
297
|
+
val height = distance(ordered.topLeft, ordered.bottomLeft)
|
|
298
|
+
val aspect = if (height > 0.0) width / height else 0.0
|
|
295
299
|
bestScore = score
|
|
296
|
-
|
|
300
|
+
debugStats.bestArea = contourArea
|
|
301
|
+
debugStats.bestRectangularity = rectangularity
|
|
302
|
+
debugStats.bestWidth = width
|
|
303
|
+
debugStats.bestHeight = height
|
|
304
|
+
debugStats.bestAspect = aspect
|
|
305
|
+
largestRectangle = refineRectangle(grayMat, ordered)
|
|
297
306
|
}
|
|
298
307
|
}
|
|
299
308
|
} else {
|
|
@@ -308,10 +317,22 @@ class DocumentDetector {
|
|
|
308
317
|
debugStats.candidates += 1
|
|
309
318
|
val boxPoints = Array(4) { Point() }
|
|
310
319
|
rotated.points(boxPoints)
|
|
320
|
+
val ordered = orderPoints(boxPoints)
|
|
321
|
+
if (!isCandidateValid(ordered, srcMat)) {
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
311
324
|
val score = contourArea * rectangularity
|
|
312
325
|
if (score > bestScore) {
|
|
326
|
+
val width = distance(ordered.topLeft, ordered.topRight)
|
|
327
|
+
val height = distance(ordered.topLeft, ordered.bottomLeft)
|
|
328
|
+
val aspect = if (height > 0.0) width / height else 0.0
|
|
313
329
|
bestScore = score
|
|
314
|
-
|
|
330
|
+
debugStats.bestArea = contourArea
|
|
331
|
+
debugStats.bestRectangularity = rectangularity
|
|
332
|
+
debugStats.bestWidth = width
|
|
333
|
+
debugStats.bestHeight = height
|
|
334
|
+
debugStats.bestAspect = aspect
|
|
335
|
+
largestRectangle = refineRectangle(grayMat, ordered)
|
|
315
336
|
}
|
|
316
337
|
}
|
|
317
338
|
}
|
|
@@ -356,6 +377,11 @@ class DocumentDetector {
|
|
|
356
377
|
"[DEBUG] cannyLow=$cannyLow cannyHigh=$cannyHigh " +
|
|
357
378
|
"contours=${debugStats.contours} candidates=${debugStats.candidates} " +
|
|
358
379
|
"bestScore=${String.format("%.1f", debugStats.bestScore)} " +
|
|
380
|
+
"bestArea=${String.format("%.1f", debugStats.bestArea)} " +
|
|
381
|
+
"bestRect=${String.format("%.2f", debugStats.bestRectangularity)} " +
|
|
382
|
+
"bestW=${String.format("%.1f", debugStats.bestWidth)} " +
|
|
383
|
+
"bestH=${String.format("%.1f", debugStats.bestHeight)} " +
|
|
384
|
+
"bestAspect=${String.format("%.2f", debugStats.bestAspect)} " +
|
|
359
385
|
"hasRect=${rectangle != null}"
|
|
360
386
|
)
|
|
361
387
|
}
|
|
@@ -374,7 +400,12 @@ class DocumentDetector {
|
|
|
374
400
|
private data class DebugStats(
|
|
375
401
|
var contours: Int = 0,
|
|
376
402
|
var candidates: Int = 0,
|
|
377
|
-
var bestScore: Double = 0.0
|
|
403
|
+
var bestScore: Double = 0.0,
|
|
404
|
+
var bestArea: Double = 0.0,
|
|
405
|
+
var bestRectangularity: Double = 0.0,
|
|
406
|
+
var bestWidth: Double = 0.0,
|
|
407
|
+
var bestHeight: Double = 0.0,
|
|
408
|
+
var bestAspect: Double = 0.0
|
|
378
409
|
)
|
|
379
410
|
|
|
380
411
|
/**
|
|
@@ -393,6 +424,21 @@ class DocumentDetector {
|
|
|
393
424
|
return Rectangle(topLeft, topRight, bottomLeft, bottomRight)
|
|
394
425
|
}
|
|
395
426
|
|
|
427
|
+
private fun isCandidateValid(rectangle: Rectangle, srcMat: Mat): Boolean {
|
|
428
|
+
val width = distance(rectangle.topLeft, rectangle.topRight)
|
|
429
|
+
val height = distance(rectangle.topLeft, rectangle.bottomLeft)
|
|
430
|
+
val minDim = min(srcMat.cols(), srcMat.rows()).toDouble()
|
|
431
|
+
val minEdge = max(60.0, minDim * 0.08)
|
|
432
|
+
if (width < minEdge || height < minEdge) {
|
|
433
|
+
return false
|
|
434
|
+
}
|
|
435
|
+
val aspect = if (height > 0) width / height else 0.0
|
|
436
|
+
if (aspect < 0.45 || aspect > 2.8) {
|
|
437
|
+
return false
|
|
438
|
+
}
|
|
439
|
+
return true
|
|
440
|
+
}
|
|
441
|
+
|
|
396
442
|
/**
|
|
397
443
|
* Evaluate rectangle quality (matching iOS logic)
|
|
398
444
|
*/
|
|
@@ -437,7 +483,7 @@ class DocumentDetector {
|
|
|
437
483
|
}
|
|
438
484
|
|
|
439
485
|
val minDim = kotlin.math.min(viewWidth.toDouble(), viewHeight.toDouble())
|
|
440
|
-
val angleThreshold = max(
|
|
486
|
+
val angleThreshold = max(140.0, minDim * 0.18)
|
|
441
487
|
|
|
442
488
|
val topYDiff = abs(rectangle.topRight.y - rectangle.topLeft.y)
|
|
443
489
|
val bottomYDiff = abs(rectangle.bottomLeft.y - rectangle.bottomRight.y)
|
|
@@ -448,7 +494,7 @@ class DocumentDetector {
|
|
|
448
494
|
return RectangleQuality.BAD_ANGLE
|
|
449
495
|
}
|
|
450
496
|
|
|
451
|
-
val margin = max(
|
|
497
|
+
val margin = max(50.0, minDim * 0.05)
|
|
452
498
|
if (rectangle.topLeft.y > margin ||
|
|
453
499
|
rectangle.topRight.y > margin ||
|
|
454
500
|
rectangle.bottomLeft.y < (viewHeight - margin) ||
|
package/package.json
CHANGED