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