react-native-rectangle-doc-scanner 0.64.0 → 0.65.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.
@@ -1,96 +1,71 @@
1
- import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
2
- import { View, TouchableOpacity, StyleSheet } from 'react-native';
3
- import { Camera, useCameraDevice, useCameraPermission, useFrameProcessor } from 'react-native-vision-camera';
4
- import { useResizePlugin } from 'vision-camera-resize-plugin';
5
- import { useRunOnJS } from 'react-native-worklets-core';
1
+ import React, {
2
+ ComponentType,
3
+ ReactNode,
4
+ useCallback,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
6
9
  import {
7
- OpenCV,
8
- ColorConversionCodes,
9
- MorphTypes,
10
- MorphShapes,
11
- RetrievalModes,
12
- ContourApproximationModes,
13
- ObjectType,
14
- } from 'react-native-fast-opencv';
10
+ LayoutChangeEvent,
11
+ StyleSheet,
12
+ TouchableOpacity,
13
+ View,
14
+ } from 'react-native';
15
+ import DocumentScanner from 'react-native-document-scanner-plugin';
15
16
  import { Overlay } from './utils/overlay';
16
- import { checkStability } from './utils/stability';
17
- import {
18
- blendQuads,
19
- isValidQuad,
20
- orderQuadPoints,
21
- quadArea,
22
- quadCenter,
23
- quadDistance,
24
- quadEdgeLengths,
25
- sanitizeQuad,
26
- weightedAverageQuad,
27
- } from './utils/quad';
28
17
  import type { Point } from './types';
29
18
 
30
- const isConvexQuadrilateral = (points: Point[]) => {
31
- 'worklet';
32
- try {
33
- if (points.length !== 4) {
34
- return false;
35
- }
36
-
37
- // Validate all points have valid x and y
38
- for (const p of points) {
39
- if (typeof p.x !== 'number' || typeof p.y !== 'number' ||
40
- !isFinite(p.x) || !isFinite(p.y)) {
41
- return false;
42
- }
43
- }
44
-
45
- let previous = 0;
46
-
47
- for (let i = 0; i < 4; i++) {
48
- const p0 = points[i];
49
- const p1 = points[(i + 1) % 4];
50
- const p2 = points[(i + 2) % 4];
51
- const cross = (p1.x - p0.x) * (p2.y - p1.y) - (p1.y - p0.y) * (p2.x - p1.x);
19
+ type NativeRectangle = {
20
+ topLeft: Point;
21
+ topRight: Point;
22
+ bottomRight: Point;
23
+ bottomLeft: Point;
24
+ };
52
25
 
53
- // Relax the collinearity check - allow very small cross products
54
- if (Math.abs(cross) < 0.1) {
55
- return false;
56
- }
26
+ type NativeRectangleEvent = {
27
+ rectangleCoordinates?: NativeRectangle | null;
28
+ stableCounter?: number;
29
+ };
57
30
 
58
- if (i === 0) {
59
- previous = cross;
60
- } else if (previous * cross < 0) {
61
- return false;
62
- }
63
- }
31
+ type NativeCaptureResult = {
32
+ croppedImage?: string;
33
+ initialImage?: string;
34
+ width?: number;
35
+ height?: number;
36
+ };
64
37
 
65
- return true;
66
- } catch (err) {
67
- return false;
68
- }
38
+ type DocumentScannerHandle = {
39
+ capture: () => Promise<NativeCaptureResult>;
69
40
  };
70
41
 
71
- type CameraRef = {
72
- takePhoto: (options: { qualityPrioritization: 'balanced' | 'quality' | 'speed' }) => Promise<{
73
- path: string;
74
- }>;
42
+ type NativeDocumentScannerProps = {
43
+ style?: object;
44
+ overlayColor?: string;
45
+ detectionCountBeforeCapture?: number;
46
+ enableTorch?: boolean;
47
+ hideControls?: boolean;
48
+ useBase64?: boolean;
49
+ quality?: number;
50
+ onRectangleDetect?: (event: NativeRectangleEvent) => void;
51
+ onPictureTaken?: (event: NativeCaptureResult) => void;
75
52
  };
76
53
 
77
- type CameraOverrides = Omit<React.ComponentProps<typeof Camera>, 'style' | 'ref' | 'frameProcessor'>;
54
+ const NativeDocumentScanner = DocumentScanner as unknown as ComponentType<
55
+ NativeDocumentScannerProps & { ref?: React.Ref<DocumentScannerHandle> }
56
+ >;
78
57
 
79
58
  /**
80
- * Configuration for detection quality and behavior
59
+ * Detection configuration is no longer used now that the native
60
+ * implementation handles edge detection. Keeping it for backwards
61
+ * compatibility with existing consumer code.
81
62
  */
82
63
  export interface DetectionConfig {
83
- /** Processing resolution width (default: 1280) - higher = more accurate but slower */
84
64
  processingWidth?: number;
85
- /** Canny edge detection lower threshold (default: 40) */
86
65
  cannyLowThreshold?: number;
87
- /** Canny edge detection upper threshold (default: 120) */
88
66
  cannyHighThreshold?: number;
89
- /** Snap distance in pixels for corner locking (default: 8) */
90
67
  snapDistance?: number;
91
- /** Max frames to hold anchor when detection fails (default: 20) */
92
68
  maxAnchorMisses?: number;
93
- /** Maximum center movement allowed while maintaining lock (default: 200px) */
94
69
  maxCenterDelta?: number;
95
70
  }
96
71
 
@@ -99,491 +74,129 @@ interface Props {
99
74
  overlayColor?: string;
100
75
  autoCapture?: boolean;
101
76
  minStableFrames?: number;
102
- cameraProps?: CameraOverrides;
77
+ enableTorch?: boolean;
78
+ quality?: number;
79
+ useBase64?: boolean;
103
80
  children?: ReactNode;
104
- /** Advanced detection configuration */
81
+ showGrid?: boolean;
82
+ gridColor?: string;
83
+ gridLineWidth?: number;
105
84
  detectionConfig?: DetectionConfig;
106
85
  }
107
86
 
87
+ const DEFAULT_OVERLAY_COLOR = '#e7a649';
88
+ const GRID_COLOR_FALLBACK = 'rgba(231, 166, 73, 0.35)';
89
+
108
90
  export const DocScanner: React.FC<Props> = ({
109
91
  onCapture,
110
- overlayColor = '#e7a649',
92
+ overlayColor = DEFAULT_OVERLAY_COLOR,
111
93
  autoCapture = true,
112
94
  minStableFrames = 8,
113
- cameraProps,
95
+ enableTorch = false,
96
+ quality,
97
+ useBase64 = false,
114
98
  children,
115
- detectionConfig = {},
99
+ showGrid = true,
100
+ gridColor,
101
+ gridLineWidth = 2,
116
102
  }) => {
117
- const device = useCameraDevice('back');
118
- const { hasPermission, requestPermission } = useCameraPermission();
119
- const { resize } = useResizePlugin();
120
- const camera = useRef<CameraRef | null>(null);
121
- const handleCameraRef = useCallback((ref: CameraRef | null) => {
122
- camera.current = ref;
123
- }, []);
103
+ const scannerRef = useRef<DocumentScannerHandle | null>(null);
104
+ const capturingRef = useRef(false);
124
105
  const [quad, setQuad] = useState<Point[] | null>(null);
125
- const [stable, setStable] = useState(0);
126
-
127
- useEffect(() => {
128
- requestPermission();
129
- }, [requestPermission]);
130
-
131
- const lastQuadRef = useRef<Point[] | null>(null);
132
- const smoothingBufferRef = useRef<Point[][]>([]);
133
- const anchorQuadRef = useRef<Point[] | null>(null);
134
- const anchorMissesRef = useRef(0);
135
- const anchorConfidenceRef = useRef(0);
136
- const lastMeasurementRef = useRef<Point[] | null>(null);
137
- const frameSizeRef = useRef<{ width: number; height: number } | null>(null);
138
-
139
- // Detection parameters - configurable via props with sensible defaults
140
- const PROCESSING_WIDTH = detectionConfig.processingWidth ?? 1280;
141
- const CANNY_LOW = detectionConfig.cannyLowThreshold ?? 40;
142
- const CANNY_HIGH = detectionConfig.cannyHighThreshold ?? 120;
143
- const SNAP_DISTANCE = detectionConfig.snapDistance ?? 8;
144
- const MAX_ANCHOR_MISSES = detectionConfig.maxAnchorMisses ?? 20;
145
- const REJECT_CENTER_DELTA = detectionConfig.maxCenterDelta ?? 200;
106
+ const [frameSize, setFrameSize] = useState<{ width: number; height: number } | null>(null);
146
107
 
147
- // Fixed parameters for algorithm stability
148
- const MAX_HISTORY = 5;
149
- const SNAP_CENTER_DISTANCE = 18;
150
- const BLEND_DISTANCE = 80;
151
- const MAX_CENTER_DELTA = 120;
152
- const MAX_AREA_SHIFT = 0.55;
153
- const HISTORY_RESET_DISTANCE = 90;
154
- const MIN_AREA_RATIO = 0.0002;
155
- const MAX_AREA_RATIO = 0.9;
156
- const MIN_EDGE_RATIO = 0.015;
157
- const MIN_CONFIDENCE_TO_HOLD = 2;
158
- const MAX_ANCHOR_CONFIDENCE = 30;
108
+ const effectiveGridColor = useMemo(
109
+ () => gridColor ?? GRID_COLOR_FALLBACK,
110
+ [gridColor],
111
+ );
159
112
 
160
- const updateQuad = useRunOnJS((value: Point[] | null) => {
161
- if (__DEV__) {
162
- console.log('[DocScanner] quad', value);
113
+ const handleLayout = useCallback((event: LayoutChangeEvent) => {
114
+ const { width, height } = event.nativeEvent.layout;
115
+ if (width > 0 && height > 0) {
116
+ setFrameSize({ width, height });
163
117
  }
118
+ }, []);
164
119
 
165
- const fallbackToAnchor = (resetHistory: boolean) => {
166
- if (resetHistory) {
167
- smoothingBufferRef.current = [];
168
- lastMeasurementRef.current = null;
169
- }
170
-
171
- const anchor = anchorQuadRef.current;
172
- const anchorConfidence = anchorConfidenceRef.current;
173
-
174
- if (anchor && anchorConfidence >= MIN_CONFIDENCE_TO_HOLD) {
175
- anchorMissesRef.current += 1;
176
-
177
- if (anchorMissesRef.current <= MAX_ANCHOR_MISSES) {
178
- anchorConfidenceRef.current = Math.max(1, anchorConfidence - 1);
179
- lastQuadRef.current = anchor;
180
- setQuad(anchor);
181
- return true;
182
- }
183
- }
120
+ const handleRectangleDetect = useCallback((event: NativeRectangleEvent) => {
121
+ const coordinates = event?.rectangleCoordinates;
184
122
 
185
- anchorMissesRef.current = 0;
186
- anchorConfidenceRef.current = 0;
187
- anchorQuadRef.current = null;
188
- lastQuadRef.current = null;
123
+ if (!coordinates) {
189
124
  setQuad(null);
190
- return false;
191
- };
192
-
193
- if (!isValidQuad(value)) {
194
- const handled = fallbackToAnchor(false);
195
- if (handled) {
196
- return;
197
- }
198
125
  return;
199
126
  }
200
127
 
201
- anchorMissesRef.current = 0;
202
-
203
- const ordered = orderQuadPoints(value);
204
- const sanitized = sanitizeQuad(ordered);
205
-
206
- const frameSize = frameSizeRef.current;
207
- const frameArea = frameSize ? frameSize.width * frameSize.height : null;
208
- const area = quadArea(sanitized);
209
- const edges = quadEdgeLengths(sanitized);
210
- const minEdge = Math.min(...edges);
211
- const maxEdge = Math.max(...edges);
212
- const aspectRatio = maxEdge > 0 ? maxEdge / Math.max(minEdge, 1) : 0;
128
+ const nextQuad: Point[] = [
129
+ coordinates.topLeft,
130
+ coordinates.topRight,
131
+ coordinates.bottomRight,
132
+ coordinates.bottomLeft,
133
+ ];
213
134
 
214
- const minEdgeThreshold = frameSize
215
- ? Math.max(14, Math.min(frameSize.width, frameSize.height) * MIN_EDGE_RATIO)
216
- : 14;
217
-
218
- const areaTooSmall = frameArea ? area < frameArea * MIN_AREA_RATIO : area === 0;
219
- const areaTooLarge = frameArea ? area > frameArea * MAX_AREA_RATIO : false;
220
- const edgesTooShort = minEdge < minEdgeThreshold;
221
- const aspectTooExtreme = aspectRatio > 7;
222
-
223
- if (areaTooSmall || areaTooLarge || edgesTooShort || aspectTooExtreme) {
224
- const handled = fallbackToAnchor(true);
225
- if (handled) {
226
- return;
227
- }
228
- return;
229
- }
230
-
231
- const lastMeasurement = lastMeasurementRef.current;
232
- const shouldResetHistory =
233
- lastMeasurement && quadDistance(lastMeasurement, sanitized) > HISTORY_RESET_DISTANCE;
234
-
235
- const existingHistory = shouldResetHistory ? [] : smoothingBufferRef.current;
236
- const nextHistory = existingHistory.length >= MAX_HISTORY
237
- ? [...existingHistory.slice(existingHistory.length - (MAX_HISTORY - 1)), sanitized]
238
- : [...existingHistory, sanitized];
239
-
240
- const hasHistory = nextHistory.length >= 2;
241
- let candidate = hasHistory ? weightedAverageQuad(nextHistory) : sanitized;
242
-
243
- const anchor = anchorQuadRef.current;
244
- if (anchor && isValidQuad(anchor)) {
245
- const delta = quadDistance(candidate, anchor);
246
- const anchorCenter = quadCenter(anchor);
247
- const candidateCenter = quadCenter(candidate);
248
- const anchorArea = quadArea(anchor);
249
- const candidateArea = quadArea(candidate);
250
- const centerDelta = Math.hypot(candidateCenter.x - anchorCenter.x, candidateCenter.y - anchorCenter.y);
251
- const areaShift = anchorArea > 0 ? Math.abs(anchorArea - candidateArea) / anchorArea : 0;
135
+ setQuad(nextQuad);
136
+ }, []);
252
137
 
253
- if (centerDelta >= REJECT_CENTER_DELTA || areaShift > 1.2) {
254
- smoothingBufferRef.current = [sanitized];
255
- lastMeasurementRef.current = sanitized;
256
- anchorQuadRef.current = candidate;
257
- anchorConfidenceRef.current = 1;
258
- anchorMissesRef.current = 0;
259
- lastQuadRef.current = candidate;
260
- setQuad(candidate);
261
- return;
262
- }
138
+ const handlePictureTaken = useCallback(
139
+ (event: NativeCaptureResult) => {
140
+ capturingRef.current = false;
263
141
 
264
- if (delta <= SNAP_DISTANCE && centerDelta <= SNAP_CENTER_DISTANCE && areaShift <= 0.08) {
265
- candidate = anchor;
266
- smoothingBufferRef.current = nextHistory;
267
- lastMeasurementRef.current = sanitized;
268
- anchorConfidenceRef.current = Math.min(anchorConfidenceRef.current + 1, MAX_ANCHOR_CONFIDENCE);
269
- } else if (delta <= BLEND_DISTANCE && centerDelta <= MAX_CENTER_DELTA && areaShift <= MAX_AREA_SHIFT) {
270
- const normalizedDelta = Math.min(1, delta / BLEND_DISTANCE);
271
- const adaptiveAlpha = 0.25 + normalizedDelta * 0.45; // 0.25..0.7 range
272
- candidate = blendQuads(anchor, candidate, adaptiveAlpha);
273
- smoothingBufferRef.current = nextHistory;
274
- lastMeasurementRef.current = sanitized;
275
- anchorConfidenceRef.current = Math.min(anchorConfidenceRef.current + 1, MAX_ANCHOR_CONFIDENCE);
276
- } else {
277
- const handled = fallbackToAnchor(true);
278
- if (handled) {
279
- return;
280
- }
142
+ const path = event?.croppedImage ?? event?.initialImage;
143
+ if (!path) {
281
144
  return;
282
145
  }
283
- } else {
284
- smoothingBufferRef.current = nextHistory;
285
- lastMeasurementRef.current = sanitized;
286
- anchorConfidenceRef.current = Math.min(anchorConfidenceRef.current + 1, MAX_ANCHOR_CONFIDENCE);
287
- }
288
-
289
- candidate = orderQuadPoints(candidate);
290
- anchorQuadRef.current = candidate;
291
- lastQuadRef.current = candidate;
292
- setQuad(candidate);
293
- anchorMissesRef.current = 0;
294
- }, []);
295
-
296
- const reportError = useRunOnJS((step: string, error: unknown) => {
297
- const message = error instanceof Error ? error.message : `${error}`;
298
- console.warn(`[DocScanner] frame error at ${step}: ${message}`);
299
- }, []);
300
146
 
301
- const reportStage = useRunOnJS((_stage: string) => {
302
- // Disabled for performance
303
- }, []);
304
-
305
- const [frameSize, setFrameSize] = useState<{ width: number; height: number } | null>(null);
306
- const updateFrameSize = useRunOnJS((width: number, height: number) => {
307
- frameSizeRef.current = { width, height };
308
- setFrameSize({ width, height });
309
- }, []);
310
-
311
- const frameProcessor = useFrameProcessor((frame) => {
312
- 'worklet';
313
-
314
- let step = 'start';
315
-
316
- try {
317
- // Report frame size for coordinate transformation
318
- updateFrameSize(frame.width, frame.height);
147
+ const width = event?.width ?? frameSize?.width ?? 0;
148
+ const height = event?.height ?? frameSize?.height ?? 0;
319
149
 
320
- // Use configurable resolution for accuracy vs performance balance
321
- const ratio = PROCESSING_WIDTH / frame.width;
322
- const width = Math.floor(frame.width * ratio);
323
- const height = Math.floor(frame.height * ratio);
324
- step = 'resize';
325
- reportStage(step);
326
- const resized = resize(frame, {
327
- dataType: 'uint8',
328
- pixelFormat: 'bgr',
329
- scale: { width: width, height: height },
150
+ onCapture?.({
151
+ path,
152
+ quad,
153
+ width,
154
+ height,
330
155
  });
156
+ },
157
+ [frameSize, onCapture, quad],
158
+ );
331
159
 
332
- step = 'frameBufferToMat';
333
- reportStage(step);
334
- let mat = OpenCV.frameBufferToMat(height, width, 3, resized);
335
-
336
- step = 'cvtColor';
337
- reportStage(step);
338
- OpenCV.invoke('cvtColor', mat, mat, ColorConversionCodes.COLOR_BGR2GRAY);
339
-
340
- // Enhanced morphological operations for noise reduction
341
- const morphologyKernel = OpenCV.createObject(ObjectType.Size, 7, 7);
342
- step = 'getStructuringElement';
343
- reportStage(step);
344
- const element = OpenCV.invoke('getStructuringElement', MorphShapes.MORPH_RECT, morphologyKernel);
345
- step = 'morphologyEx';
346
- reportStage(step);
347
- // MORPH_CLOSE to fill small holes in edges
348
- OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_CLOSE, element);
349
- // MORPH_OPEN to remove small noise
350
- OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_OPEN, element);
351
-
352
- // Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
353
- step = 'bilateralFilter';
354
- reportStage(step);
355
- try {
356
- const tempMat = OpenCV.createObject(ObjectType.Mat);
357
- OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
358
- mat = tempMat;
359
- } catch (error) {
360
- if (__DEV__) {
361
- console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
362
- }
363
- step = 'gaussianBlurFallback';
364
- reportStage(step);
365
- const blurKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
366
- OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
367
- }
368
-
369
- step = 'Canny';
370
- reportStage(step);
371
- // Configurable Canny parameters for adaptive edge detection
372
- OpenCV.invoke('Canny', mat, mat, CANNY_LOW, CANNY_HIGH);
373
-
374
- step = 'createContours';
375
- reportStage(step);
376
- const contours = OpenCV.createObject(ObjectType.PointVectorOfVectors);
377
- OpenCV.invoke('findContours', mat, contours, RetrievalModes.RETR_EXTERNAL, ContourApproximationModes.CHAIN_APPROX_SIMPLE);
378
-
379
- let best: Point[] | null = null;
380
- let maxArea = 0;
381
- const frameArea = width * height;
382
-
383
- step = 'toJSValue';
384
- reportStage(step);
385
- const contourVector = OpenCV.toJSValue(contours);
386
- const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
387
-
388
- for (let i = 0; i < contourArray.length; i += 1) {
389
- step = `contour_${i}_copy`;
390
- reportStage(step);
391
- const contour = OpenCV.copyObjectFromVector(contours, i);
392
-
393
- // Compute absolute area first
394
- step = `contour_${i}_area_abs`;
395
- reportStage(step);
396
- const { value: area } = OpenCV.invoke('contourArea', contour, false);
397
-
398
- // Skip extremely small contours, but keep threshold very low to allow distant documents
399
- if (typeof area !== 'number' || !isFinite(area)) {
400
- continue;
401
- }
402
-
403
- if (area < 50) {
404
- continue;
405
- }
406
-
407
- step = `contour_${i}_area`; // ratio stage
408
- reportStage(step);
409
- const areaRatio = area / frameArea;
410
-
411
- if (__DEV__) {
412
- console.log('[DocScanner] area', area, 'ratio', areaRatio);
413
- }
414
-
415
- // Skip if area ratio is too small or too large
416
- if (areaRatio < 0.0002 || areaRatio > 0.99) {
417
- continue;
418
- }
419
-
420
- // Try to use convex hull for better corner detection
421
- let contourToUse = contour;
422
- try {
423
- step = `contour_${i}_convexHull`;
424
- reportStage(step);
425
- const hull = OpenCV.createObject(ObjectType.PointVector);
426
- OpenCV.invoke('convexHull', contour, hull, false, true);
427
- contourToUse = hull;
428
- } catch (err) {
429
- // If convexHull fails, use original contour
430
- if (__DEV__) {
431
- console.warn('[DocScanner] convexHull failed, using original contour');
432
- }
433
- }
434
-
435
- step = `contour_${i}_arcLength`;
436
- reportStage(step);
437
- const { value: perimeter } = OpenCV.invoke('arcLength', contourToUse, true);
438
- const approx = OpenCV.createObject(ObjectType.PointVector);
439
-
440
- let approxArray: Array<{ x: number; y: number }> = [];
441
-
442
- // Try more epsilon values from 0.1% to 10% for difficult shapes
443
- const epsilonValues = [
444
- 0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.007, 0.008, 0.009,
445
- 0.01, 0.012, 0.015, 0.018, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1
446
- ];
447
-
448
- for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
449
- const epsilon = epsilonValues[attempt] * perimeter;
450
- step = `contour_${i}_approxPolyDP_attempt_${attempt}`;
451
- reportStage(step);
452
- OpenCV.invoke('approxPolyDP', contourToUse, approx, epsilon, true);
453
-
454
- step = `contour_${i}_toJS_attempt_${attempt}`;
455
- reportStage(step);
456
- const approxValue = OpenCV.toJSValue(approx);
457
- const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
458
-
459
- if (__DEV__) {
460
- console.log('[DocScanner] approx length', candidate.length, 'epsilon', epsilon);
461
- }
462
-
463
- if (candidate.length === 4) {
464
- approxArray = candidate as Array<{ x: number; y: number }>;
465
- break;
466
- }
467
- }
468
-
469
- // Only proceed if we found exactly 4 corners
470
- if (approxArray.length !== 4) {
471
- continue;
472
- }
473
-
474
- step = `contour_${i}_convex`;
475
- reportStage(step);
476
-
477
- // Validate points before processing
478
- const isValidPoint = (pt: { x: number; y: number }) => {
479
- return typeof pt.x === 'number' && typeof pt.y === 'number' &&
480
- !isNaN(pt.x) && !isNaN(pt.y) &&
481
- isFinite(pt.x) && isFinite(pt.y);
482
- };
483
-
484
- if (!approxArray.every(isValidPoint)) {
485
- if (__DEV__) {
486
- console.warn('[DocScanner] invalid points in approxArray', approxArray);
487
- }
488
- continue;
489
- }
490
-
491
- const points: Point[] = approxArray.map((pt: { x: number; y: number }) => ({
492
- x: pt.x / ratio,
493
- y: pt.y / ratio,
494
- }));
495
-
496
- // Verify the quadrilateral is convex (valid document shape)
497
- try {
498
- if (!isConvexQuadrilateral(points)) {
499
- if (__DEV__) {
500
- console.log('[DocScanner] not convex, skipping:', points);
501
- }
502
- continue;
503
- }
504
- } catch (err) {
505
- if (__DEV__) {
506
- console.warn('[DocScanner] convex check error:', err, 'points:', points);
507
- }
508
- continue;
509
- }
510
-
511
- if (area > maxArea) {
512
- best = points;
513
- maxArea = area;
514
- }
515
- }
516
-
517
- step = 'clearBuffers';
518
- reportStage(step);
519
- OpenCV.clearBuffers();
520
- step = 'updateQuad';
521
- reportStage(step);
522
- updateQuad(best);
523
- } catch (error) {
524
- reportError(step, error);
160
+ const handleManualCapture = useCallback(() => {
161
+ if (autoCapture || capturingRef.current || !scannerRef.current) {
162
+ return;
525
163
  }
526
- }, [resize, reportError, updateQuad]);
527
-
528
- useEffect(() => {
529
- const s = checkStability(quad);
530
- setStable(s);
531
- }, [quad]);
532
-
533
- useEffect(() => {
534
- const capture = async () => {
535
- if (autoCapture && quad && stable >= minStableFrames && camera.current && frameSize) {
536
- const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
537
- onCapture?.({
538
- path: photo.path,
539
- quad,
540
- width: frameSize.width,
541
- height: frameSize.height,
542
- });
543
- setStable(0);
544
- }
545
- };
546
164
 
547
- capture();
548
- }, [autoCapture, minStableFrames, onCapture, quad, stable, frameSize]);
549
-
550
- const { device: overrideDevice, ...cameraRestProps } = cameraProps ?? {};
551
- const resolvedDevice = overrideDevice ?? device;
552
-
553
- if (!resolvedDevice || !hasPermission) {
554
- return null;
555
- }
165
+ capturingRef.current = true;
166
+ scannerRef.current
167
+ .capture()
168
+ .catch((error) => {
169
+ console.warn('[DocScanner] manual capture failed', error);
170
+ capturingRef.current = false;
171
+ });
172
+ }, [autoCapture]);
556
173
 
557
174
  return (
558
- <View style={{ flex: 1 }}>
559
- <Camera
560
- ref={handleCameraRef}
561
- style={StyleSheet.absoluteFillObject}
562
- device={resolvedDevice}
563
- isActive
564
- photo
565
- frameProcessor={frameProcessor}
566
- frameProcessorFps={15}
567
- {...cameraRestProps}
175
+ <View style={styles.container} onLayout={handleLayout}>
176
+ <NativeDocumentScanner
177
+ ref={(instance) => {
178
+ scannerRef.current = instance as DocumentScannerHandle | null;
179
+ }}
180
+ style={StyleSheet.absoluteFill}
181
+ overlayColor="transparent"
182
+ detectionCountBeforeCapture={autoCapture ? minStableFrames : 10000}
183
+ enableTorch={enableTorch}
184
+ hideControls
185
+ useBase64={useBase64}
186
+ quality={quality}
187
+ onRectangleDetect={handleRectangleDetect}
188
+ onPictureTaken={handlePictureTaken}
189
+ />
190
+ <Overlay
191
+ quad={quad}
192
+ color={overlayColor}
193
+ frameSize={frameSize}
194
+ showGrid={showGrid}
195
+ gridColor={effectiveGridColor}
196
+ gridLineWidth={gridLineWidth}
568
197
  />
569
- <Overlay quad={quad} color={overlayColor} frameSize={frameSize} />
570
198
  {!autoCapture && (
571
- <TouchableOpacity
572
- style={styles.button}
573
- onPress={async () => {
574
- if (!camera.current || !frameSize) {
575
- return;
576
- }
577
-
578
- const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
579
- onCapture?.({
580
- path: photo.path,
581
- quad,
582
- width: frameSize.width,
583
- height: frameSize.height,
584
- });
585
- }}
586
- />
199
+ <TouchableOpacity style={styles.button} onPress={handleManualCapture} />
587
200
  )}
588
201
  {children}
589
202
  </View>
@@ -591,6 +204,9 @@ export const DocScanner: React.FC<Props> = ({
591
204
  };
592
205
 
593
206
  const styles = StyleSheet.create({
207
+ container: {
208
+ flex: 1,
209
+ },
594
210
  button: {
595
211
  position: 'absolute',
596
212
  bottom: 40,