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
- // Simplified coordinate mapping for TextureView
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
- val scaleX = viewWidth / imageWidth.toFloat()
447
- val scaleY = viewHeight / imageHeight.toFloat()
448
- val scale = scaleX.coerceAtMost(scaleY)
449
- val scaledWidth = imageWidth * scale
450
- val scaledHeight = imageHeight * scale
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
- fun scalePoint(point: org.opencv.core.Point): org.opencv.core.Point {
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
- point.x * scale + offsetX,
457
- point.y * scale + offsetY
519
+ rotated.x * scale + offsetX,
520
+ rotated.y * scale + offsetY
458
521
  )
459
522
  }
460
523
 
461
- return Rectangle(
462
- scalePoint(rectangle.topLeft),
463
- scalePoint(rectangle.topRight),
464
- scalePoint(rectangle.bottomLeft),
465
- scalePoint(rectangle.bottomRight)
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
- // CameraX accounts for sensor orientation via targetRotation. Some tablets with landscape
507
- // sensors report Display 90 in portrait but render upside down; add a 180° fix for that case.
508
- val tabletUpsideDownFix = if (sensorOrientation == 0 && displayRotationDegrees == 90) 180 else 0
509
- val rotationDegrees = ((displayRotationDegrees + tabletUpsideDownFix) % 360).toFloat()
574
+ // For portrait apps:
575
+ // - 90° sensor (phones): buffer is landscape need 90° rotation to portrait
576
+ // - 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
- largestRectangle = refineRectangle(grayMat, orderPoints(points))
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
- largestRectangle = refineRectangle(grayMat, orderPoints(boxPoints))
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(90.0, minDim * 0.12)
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(80.0, minDim * 0.08)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "10.31.0",
3
+ "version": "10.33.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",