react-native-rectangle-doc-scanner 0.71.0 → 1.1.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.
@@ -35,6 +35,7 @@ import org.opencv.core.MatOfPoint2f
35
35
  import org.opencv.core.Point
36
36
  import org.opencv.core.Size as MatSize
37
37
  import org.opencv.imgproc.Imgproc
38
+ import org.opencv.photo.Photo
38
39
  import java.io.File
39
40
  import java.nio.ByteBuffer
40
41
  import java.text.SimpleDateFormat
@@ -73,6 +74,8 @@ class RNRDocScannerView @JvmOverloads constructor(
73
74
  private var currentStableCounter: Int = 0
74
75
  private var lastQuad: QuadPoints? = null
75
76
  private var lastFrameSize: AndroidSize? = null
77
+ private var missedDetections: Int = 0
78
+ private val maxMissedDetections = 4
76
79
  private var capturePromise: Promise? = null
77
80
  private var captureInFlight: Boolean = false
78
81
 
@@ -198,25 +201,43 @@ class RNRDocScannerView @JvmOverloads constructor(
198
201
 
199
202
  private fun emitDetectionResult(quad: QuadPoints?, frameSize: AndroidSize) {
200
203
  val reactContext = context as? ReactContext ?: return
204
+
205
+ val effectiveQuad: QuadPoints? = when {
206
+ quad != null -> {
207
+ missedDetections = 0
208
+ lastQuad = quad
209
+ quad
210
+ }
211
+ lastQuad != null && missedDetections < maxMissedDetections -> {
212
+ missedDetections += 1
213
+ lastQuad
214
+ }
215
+ else -> {
216
+ missedDetections = 0
217
+ lastQuad = null
218
+ null
219
+ }
220
+ }
221
+
222
+ val sizeForEvent = if (effectiveQuad === quad) frameSize else lastFrameSize ?: frameSize
223
+
201
224
  val event: WritableMap = Arguments.createMap().apply {
202
- if (quad != null) {
225
+ if (effectiveQuad != null) {
203
226
  val quadMap = Arguments.createMap().apply {
204
- putMap("topLeft", quad.topLeft.toWritable())
205
- putMap("topRight", quad.topRight.toWritable())
206
- putMap("bottomRight", quad.bottomRight.toWritable())
207
- putMap("bottomLeft", quad.bottomLeft.toWritable())
227
+ putMap("topLeft", effectiveQuad.topLeft.toWritable())
228
+ putMap("topRight", effectiveQuad.topRight.toWritable())
229
+ putMap("bottomRight", effectiveQuad.bottomRight.toWritable())
230
+ putMap("bottomLeft", effectiveQuad.bottomLeft.toWritable())
208
231
  }
209
232
  putMap("rectangleCoordinates", quadMap)
210
233
  currentStableCounter = (currentStableCounter + 1).coerceAtMost(detectionCountBeforeCapture)
211
- lastQuad = quad
212
234
  } else {
213
235
  putNull("rectangleCoordinates")
214
236
  currentStableCounter = 0
215
- lastQuad = null
216
237
  }
217
238
  putInt("stableCounter", currentStableCounter)
218
- putDouble("frameWidth", frameSize.width.toDouble())
219
- putDouble("frameHeight", frameSize.height.toDouble())
239
+ putDouble("frameWidth", sizeForEvent.width.toDouble())
240
+ putDouble("frameHeight", sizeForEvent.height.toDouble())
220
241
  }
221
242
 
222
243
  reactContext
@@ -387,11 +408,20 @@ class RNRDocScannerView @JvmOverloads constructor(
387
408
  val gray = Mat()
388
409
  Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY)
389
410
 
411
+ // Improve contrast for low-light or glossy surfaces
412
+ val clahe = Photo.createCLAHE(2.0, MatSize(8.0, 8.0))
413
+ val enhanced = Mat()
414
+ clahe.apply(gray, enhanced)
415
+ clahe.collectGarbage()
416
+
390
417
  val blurred = Mat()
391
- Imgproc.GaussianBlur(gray, blurred, MatSize(5.0, 5.0), 0.0)
418
+ Imgproc.GaussianBlur(enhanced, blurred, MatSize(5.0, 5.0), 0.0)
392
419
 
393
420
  val edges = Mat()
394
- Imgproc.Canny(blurred, edges, 50.0, 150.0)
421
+ Imgproc.Canny(blurred, edges, 40.0, 140.0)
422
+
423
+ val morphKernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, MatSize(5.0, 5.0))
424
+ Imgproc.morphologyEx(edges, edges, Imgproc.MORPH_CLOSE, morphKernel)
395
425
 
396
426
  val contours = ArrayList<MatOfPoint>()
397
427
  val hierarchy = Mat()
@@ -415,7 +445,7 @@ class RNRDocScannerView @JvmOverloads constructor(
415
445
  }
416
446
 
417
447
  val area = abs(Imgproc.contourArea(approxCurve))
418
- if (area < frameArea * 0.10 || area > frameArea * 0.95) {
448
+ if (area < frameArea * 0.05 || area > frameArea * 0.98) {
419
449
  contour.release()
420
450
  contour2f.release()
421
451
  continue
@@ -437,8 +467,10 @@ class RNRDocScannerView @JvmOverloads constructor(
437
467
  }
438
468
 
439
469
  gray.release()
470
+ enhanced.release()
440
471
  blurred.release()
441
472
  edges.release()
473
+ morphKernel.release()
442
474
  hierarchy.release()
443
475
  approxCurve.release()
444
476
  mat.release()
@@ -62,6 +62,15 @@ const buildPath = (points) => {
62
62
  path.close();
63
63
  return path;
64
64
  };
65
+ const orderQuad = (points) => {
66
+ if (points.length !== 4) {
67
+ return points;
68
+ }
69
+ const sorted = [...points].sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
70
+ const top = sorted.slice(0, 2).sort((a, b) => a.x - b.x);
71
+ const bottom = sorted.slice(2, 4).sort((a, b) => a.x - b.x);
72
+ return [top[0], top[1], bottom[1], bottom[0]];
73
+ };
65
74
  const Overlay = ({ quad, color = '#e7a649', frameSize, showGrid = true, gridColor = 'rgba(231, 166, 73, 0.35)', gridLineWidth = 2, }) => {
66
75
  const { width: screenWidth, height: screenHeight } = (0, react_native_1.useWindowDimensions)();
67
76
  const fillColor = (0, react_1.useMemo)(() => withAlpha(color, 0.2), [color]);
@@ -106,10 +115,11 @@ const Overlay = ({ quad, color = '#e7a649', frameSize, showGrid = true, gridColo
106
115
  if (!transformedQuad) {
107
116
  return { outlinePath: null, gridPaths: [] };
108
117
  }
109
- const skPath = buildPath(transformedQuad);
118
+ const normalizedQuad = orderQuad(transformedQuad);
119
+ const skPath = buildPath(normalizedQuad);
110
120
  const grid = [];
111
121
  if (showGrid) {
112
- const [topLeft, topRight, bottomRight, bottomLeft] = transformedQuad;
122
+ const [topLeft, topRight, bottomRight, bottomLeft] = normalizedQuad;
113
123
  const steps = [1 / 3, 2 / 3];
114
124
  steps.forEach((t) => {
115
125
  const start = lerp(topLeft, topRight, t);
@@ -33,6 +33,8 @@ class RNRDocScannerView: UIView, AVCaptureVideoDataOutputSampleBufferDelegate, A
33
33
  private var isProcessingFrame = false
34
34
  private var isCaptureInFlight = false
35
35
  private var lastObservation: VNRectangleObservation?
36
+ private var missedDetectionFrames: Int = 0
37
+ private let maxMissedDetections = 4
36
38
  private var lastFrameSize: CGSize = .zero
37
39
  private var photoCaptureCompletion: ((Result<RNRDocScannerCaptureResult, Error>) -> Void)?
38
40
 
@@ -87,6 +89,9 @@ class RNRDocScannerView: UIView, AVCaptureVideoDataOutputSampleBufferDelegate, A
87
89
  if session.canAddOutput(photoOutput) {
88
90
  photoOutput.isHighResolutionCaptureEnabled = true
89
91
  session.addOutput(photoOutput)
92
+ if let connection = photoOutput.connection(with: .video), connection.isVideoOrientationSupported {
93
+ connection.videoOrientation = .portrait
94
+ }
90
95
  }
91
96
 
92
97
  videoOutput.videoSettings = [
@@ -97,6 +102,9 @@ class RNRDocScannerView: UIView, AVCaptureVideoDataOutputSampleBufferDelegate, A
97
102
 
98
103
  if session.canAddOutput(videoOutput) {
99
104
  session.addOutput(videoOutput)
105
+ if let connection = videoOutput.connection(with: .video), connection.isVideoOrientationSupported {
106
+ connection.videoOrientation = .portrait
107
+ }
100
108
  }
101
109
  }
102
110
  }
@@ -104,6 +112,9 @@ class RNRDocScannerView: UIView, AVCaptureVideoDataOutputSampleBufferDelegate, A
104
112
  override func layoutSubviews() {
105
113
  super.layoutSubviews()
106
114
  previewLayer?.frame = bounds
115
+ if let connection = previewLayer?.connection, connection.isVideoOrientationSupported {
116
+ connection.videoOrientation = .portrait
117
+ }
107
118
  }
108
119
 
109
120
  private func updateTorchMode() {
@@ -164,21 +175,27 @@ class RNRDocScannerView: UIView, AVCaptureVideoDataOutputSampleBufferDelegate, A
164
175
  return
165
176
  }
166
177
 
167
- guard let observation = (request.results as? [VNRectangleObservation])?.first else {
168
- self.lastObservation = nil
169
- self.handleDetectedRectangle(nil, frameSize: frameSize)
170
- return
171
- }
178
+ guard let observations = request.results as? [VNRectangleObservation], let observation = observations.max(by: { lhs, rhs in
179
+ lhs.boundingBox.width * lhs.boundingBox.height < rhs.boundingBox.width * rhs.boundingBox.height
180
+ }) else {
181
+ self.lastObservation = nil
182
+ self.handleDetectedRectangle(nil, frameSize: frameSize)
183
+ return
184
+ }
172
185
 
173
- self.lastObservation = observation
174
- self.handleDetectedRectangle(observation, frameSize: frameSize)
175
- }
186
+ self.lastObservation = observation
187
+ self.missedDetectionFrames = 0
188
+ self.handleDetectedRectangle(observation, frameSize: frameSize)
189
+ }
176
190
 
177
- request.maximumObservations = 1
178
- request.minimumConfidence = 0.6
179
- request.minimumAspectRatio = 0.3
180
- request.maximumAspectRatio = 1.0
181
- request.minimumSize = 0.15
191
+ request.maximumObservations = 1
192
+ request.minimumConfidence = 0.5
193
+ request.minimumAspectRatio = 0.15
194
+ request.maximumAspectRatio = 1.75
195
+ request.minimumSize = 0.08
196
+ if #available(iOS 13.0, *) {
197
+ request.quadratureTolerance = 45
198
+ }
182
199
 
183
200
  let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: orientation, options: [:])
184
201
  do {
@@ -193,13 +210,27 @@ class RNRDocScannerView: UIView, AVCaptureVideoDataOutputSampleBufferDelegate, A
193
210
  func handleDetectedRectangle(_ rectangle: VNRectangleObservation?, frameSize: CGSize) {
194
211
  guard let onRectangleDetect else { return }
195
212
 
213
+ let effectiveObservation: VNRectangleObservation?
214
+ if let rect = rectangle {
215
+ effectiveObservation = rect
216
+ lastObservation = rect
217
+ missedDetectionFrames = 0
218
+ } else if missedDetectionFrames < maxMissedDetections, let cached = lastObservation {
219
+ missedDetectionFrames += 1
220
+ effectiveObservation = cached
221
+ } else {
222
+ lastObservation = nil
223
+ missedDetectionFrames = 0
224
+ effectiveObservation = nil
225
+ }
226
+
196
227
  let payload: [String: Any?]
197
- if let rectangle {
228
+ if let observation = effectiveObservation {
198
229
  let points = [
199
- pointForOverlay(from: rectangle.topLeft, frameSize: frameSize),
200
- pointForOverlay(from: rectangle.topRight, frameSize: frameSize),
201
- pointForOverlay(from: rectangle.bottomRight, frameSize: frameSize),
202
- pointForOverlay(from: rectangle.bottomLeft, frameSize: frameSize),
230
+ pointForOverlay(from: observation.topLeft, frameSize: frameSize),
231
+ pointForOverlay(from: observation.topRight, frameSize: frameSize),
232
+ pointForOverlay(from: observation.bottomRight, frameSize: frameSize),
233
+ pointForOverlay(from: observation.bottomLeft, frameSize: frameSize),
203
234
  ]
204
235
 
205
236
  currentStableCounter = min(currentStableCounter + 1, Int(truncating: detectionCountBeforeCapture))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "0.71.0",
3
+ "version": "1.1.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -47,6 +47,18 @@ const buildPath = (points: Point[]) => {
47
47
  return path;
48
48
  };
49
49
 
50
+ const orderQuad = (points: Point[]): Point[] => {
51
+ if (points.length !== 4) {
52
+ return points;
53
+ }
54
+
55
+ const sorted = [...points].sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
56
+ const top = sorted.slice(0, 2).sort((a, b) => a.x - b.x);
57
+ const bottom = sorted.slice(2, 4).sort((a, b) => a.x - b.x);
58
+
59
+ return [top[0], top[1], bottom[1], bottom[0]];
60
+ };
61
+
50
62
  export const Overlay: React.FC<OverlayProps> = ({
51
63
  quad,
52
64
  color = '#e7a649',
@@ -105,11 +117,12 @@ export const Overlay: React.FC<OverlayProps> = ({
105
117
  return { outlinePath: null, gridPaths: [] };
106
118
  }
107
119
 
108
- const skPath = buildPath(transformedQuad);
120
+ const normalizedQuad = orderQuad(transformedQuad);
121
+ const skPath = buildPath(normalizedQuad);
109
122
  const grid: ReturnType<typeof Skia.Path.Make>[] = [];
110
123
 
111
124
  if (showGrid) {
112
- const [topLeft, topRight, bottomRight, bottomLeft] = transformedQuad;
125
+ const [topLeft, topRight, bottomRight, bottomLeft] = normalizedQuad;
113
126
  const steps = [1 / 3, 2 / 3];
114
127
 
115
128
  steps.forEach((t) => {