react-native-rectangle-doc-scanner 0.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.
- package/README.md +46 -0
- package/babel.config.js +7 -0
- package/dist/DocScanner.d.ts +13 -0
- package/dist/DocScanner.js +143 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +17 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/dist/utils/overlay.d.ts +8 -0
- package/dist/utils/overlay.js +52 -0
- package/dist/utils/stability.d.ts +2 -0
- package/dist/utils/stability.js +27 -0
- package/package.json +25 -0
- package/src/DocScanner.tsx +172 -0
- package/src/external.d.ts +109 -0
- package/src/index.ts +1 -0
- package/src/types.ts +1 -0
- package/src/utils/overlay.tsx +28 -0
- package/src/utils/stability.ts +34 -0
- package/tsconfig.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# React Native Rectangle Doc Scanner
|
|
2
|
+
|
|
3
|
+
VisionCamera + Fast-OpenCV powered document scanner template built for React Native. You can install it as a reusable module, extend the detection pipeline, and publish to npm out of the box.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- Real-time quad detection using `react-native-fast-opencv`
|
|
7
|
+
- Frame processor worklet executed on the UI thread via `react-native-vision-camera`
|
|
8
|
+
- Resize plugin to keep frame processing fast on lower-end devices
|
|
9
|
+
- Skia overlay for visualizing detected document contours
|
|
10
|
+
- Stability tracker for auto-capture once the document is steady
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
Install the template as a package and make sure the peer dependencies already exist in your app:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
yarn add react-native-rectangle-doc-scanner
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
```tsx
|
|
21
|
+
import React from 'react';
|
|
22
|
+
import { View } from 'react-native';
|
|
23
|
+
import { DocScanner } from 'react-native-rectangle-doc-scanner';
|
|
24
|
+
|
|
25
|
+
export const ScanScreen = () => (
|
|
26
|
+
<View style={{ flex: 1 }}>
|
|
27
|
+
<DocScanner
|
|
28
|
+
onCapture={({ path, quad }) => {
|
|
29
|
+
console.log('Document captured at', path, quad);
|
|
30
|
+
}}
|
|
31
|
+
overlayColor="#ff8800"
|
|
32
|
+
autoCapture
|
|
33
|
+
minStableFrames={8}
|
|
34
|
+
/>
|
|
35
|
+
</View>
|
|
36
|
+
);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Build
|
|
40
|
+
```sh
|
|
41
|
+
yarn build
|
|
42
|
+
```
|
|
43
|
+
Generates the `dist/` output via TypeScript.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
MIT
|
package/babel.config.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Point } from './types';
|
|
3
|
+
interface Props {
|
|
4
|
+
onCapture?: (photo: {
|
|
5
|
+
path: string;
|
|
6
|
+
quad: Point[] | null;
|
|
7
|
+
}) => void;
|
|
8
|
+
overlayColor?: string;
|
|
9
|
+
autoCapture?: boolean;
|
|
10
|
+
minStableFrames?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare const DocScanner: React.FC<Props>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DocScanner = void 0;
|
|
37
|
+
const react_1 = __importStar(require("react"));
|
|
38
|
+
const react_native_1 = require("react-native");
|
|
39
|
+
const react_native_vision_camera_1 = require("react-native-vision-camera");
|
|
40
|
+
const vision_camera_resize_plugin_1 = require("vision-camera-resize-plugin");
|
|
41
|
+
const react_native_reanimated_1 = require("react-native-reanimated");
|
|
42
|
+
const react_native_fast_opencv_1 = require("react-native-fast-opencv");
|
|
43
|
+
const overlay_1 = require("./utils/overlay");
|
|
44
|
+
const stability_1 = require("./utils/stability");
|
|
45
|
+
const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, minStableFrames = 8, }) => {
|
|
46
|
+
const device = (0, react_native_vision_camera_1.useCameraDevice)('back');
|
|
47
|
+
const { hasPermission, requestPermission } = (0, react_native_vision_camera_1.useCameraPermission)();
|
|
48
|
+
const { resize } = (0, vision_camera_resize_plugin_1.useResizePlugin)();
|
|
49
|
+
const camera = (0, react_1.useRef)(null);
|
|
50
|
+
const handleCameraRef = (0, react_1.useCallback)((ref) => {
|
|
51
|
+
camera.current = ref;
|
|
52
|
+
}, []);
|
|
53
|
+
const [quad, setQuad] = (0, react_1.useState)(null);
|
|
54
|
+
const [stable, setStable] = (0, react_1.useState)(0);
|
|
55
|
+
(0, react_1.useEffect)(() => {
|
|
56
|
+
requestPermission();
|
|
57
|
+
}, [requestPermission]);
|
|
58
|
+
const frameProcessor = (0, react_native_vision_camera_1.useFrameProcessor)((frame) => {
|
|
59
|
+
'worklet';
|
|
60
|
+
const ratio = 480 / frame.width;
|
|
61
|
+
const w = Math.floor(frame.width * ratio);
|
|
62
|
+
const h = Math.floor(frame.height * ratio);
|
|
63
|
+
const resized = resize(frame, {
|
|
64
|
+
dataType: 'uint8',
|
|
65
|
+
pixelFormat: 'bgr',
|
|
66
|
+
scale: { width: w, height: h },
|
|
67
|
+
});
|
|
68
|
+
const mat = react_native_fast_opencv_1.OpenCV.frameBufferToMat(h, w, 3, resized);
|
|
69
|
+
react_native_fast_opencv_1.OpenCV.invoke('cvtColor', mat, mat, react_native_fast_opencv_1.ColorConversionCodes.COLOR_BGR2GRAY);
|
|
70
|
+
const kernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 4, 4);
|
|
71
|
+
const element = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', react_native_fast_opencv_1.MorphShapes.MORPH_RECT, kernel);
|
|
72
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', mat, mat, react_native_fast_opencv_1.MorphTypes.MORPH_OPEN, element);
|
|
73
|
+
react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', mat, mat, kernel, 0);
|
|
74
|
+
react_native_fast_opencv_1.OpenCV.invoke('Canny', mat, mat, 75, 100);
|
|
75
|
+
const contours = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVectorOfVectors);
|
|
76
|
+
react_native_fast_opencv_1.OpenCV.invoke('findContours', mat, contours, react_native_fast_opencv_1.RetrievalModes.RETR_LIST, react_native_fast_opencv_1.ContourApproximationModes.CHAIN_APPROX_SIMPLE);
|
|
77
|
+
let best = null;
|
|
78
|
+
let maxArea = 0;
|
|
79
|
+
const arr = react_native_fast_opencv_1.OpenCV.toJSValue(contours).array;
|
|
80
|
+
for (let i = 0; i < arr.length; i++) {
|
|
81
|
+
const c = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contours, i);
|
|
82
|
+
const { value: area } = react_native_fast_opencv_1.OpenCV.invoke('contourArea', c, false);
|
|
83
|
+
if (area < w * h * 0.1) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const { value: peri } = react_native_fast_opencv_1.OpenCV.invoke('arcLength', c, true);
|
|
87
|
+
const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
|
|
88
|
+
react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', c, approx, 0.02 * peri, true);
|
|
89
|
+
const size = react_native_fast_opencv_1.OpenCV.invokeWithOutParam('size', approx);
|
|
90
|
+
const { value: convex } = react_native_fast_opencv_1.OpenCV.invoke('isContourConvex', approx);
|
|
91
|
+
if (convex && size === 4 && area > maxArea) {
|
|
92
|
+
const pts = [];
|
|
93
|
+
for (let j = 0; j < 4; j++) {
|
|
94
|
+
const p = react_native_fast_opencv_1.OpenCV.invoke('atPoint', approx, j, 0);
|
|
95
|
+
pts.push({ x: p.x / ratio, y: p.y / ratio });
|
|
96
|
+
}
|
|
97
|
+
best = pts;
|
|
98
|
+
maxArea = area;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
react_native_fast_opencv_1.OpenCV.clearBuffers();
|
|
102
|
+
(0, react_native_reanimated_1.runOnJS)(setQuad)(best);
|
|
103
|
+
}, [resize]);
|
|
104
|
+
(0, react_1.useEffect)(() => {
|
|
105
|
+
const s = (0, stability_1.checkStability)(quad);
|
|
106
|
+
setStable(s);
|
|
107
|
+
}, [quad]);
|
|
108
|
+
(0, react_1.useEffect)(() => {
|
|
109
|
+
const capture = async () => {
|
|
110
|
+
if (autoCapture && quad && stable >= minStableFrames && camera.current) {
|
|
111
|
+
const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
|
|
112
|
+
onCapture?.({ path: photo.path, quad });
|
|
113
|
+
setStable(0);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
capture();
|
|
117
|
+
}, [autoCapture, minStableFrames, onCapture, quad, stable]);
|
|
118
|
+
if (!device || !hasPermission) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return (react_1.default.createElement(react_native_1.View, { style: { flex: 1 } },
|
|
122
|
+
react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: handleCameraRef, style: react_native_1.StyleSheet.absoluteFillObject, device: device, isActive: true, photo: true, frameProcessor: frameProcessor, frameProcessorFps: 15 }),
|
|
123
|
+
react_1.default.createElement(overlay_1.Overlay, { quad: quad, color: overlayColor }),
|
|
124
|
+
!autoCapture && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: async () => {
|
|
125
|
+
if (!camera.current) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
|
|
129
|
+
onCapture?.({ path: photo.path, quad });
|
|
130
|
+
} }))));
|
|
131
|
+
};
|
|
132
|
+
exports.DocScanner = DocScanner;
|
|
133
|
+
const styles = react_native_1.StyleSheet.create({
|
|
134
|
+
button: {
|
|
135
|
+
position: 'absolute',
|
|
136
|
+
bottom: 40,
|
|
137
|
+
alignSelf: 'center',
|
|
138
|
+
width: 70,
|
|
139
|
+
height: 70,
|
|
140
|
+
borderRadius: 35,
|
|
141
|
+
backgroundColor: '#fff',
|
|
142
|
+
},
|
|
143
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DocScanner';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./DocScanner"), exports);
|
package/dist/types.d.ts
ADDED
package/dist/types.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.Overlay = void 0;
|
|
37
|
+
const react_1 = __importStar(require("react"));
|
|
38
|
+
const react_native_skia_1 = require("@shopify/react-native-skia");
|
|
39
|
+
const Overlay = ({ quad, color = '#e7a649' }) => {
|
|
40
|
+
const path = (0, react_1.useMemo)(() => {
|
|
41
|
+
if (!quad) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const skPath = react_native_skia_1.Skia.Path.Make();
|
|
45
|
+
skPath.moveTo(quad[0].x, quad[0].y);
|
|
46
|
+
quad.slice(1).forEach((p) => skPath.lineTo(p.x, p.y));
|
|
47
|
+
skPath.close();
|
|
48
|
+
return skPath;
|
|
49
|
+
}, [quad]);
|
|
50
|
+
return (react_1.default.createElement(react_native_skia_1.Canvas, { style: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 } }, path && react_1.default.createElement(react_native_skia_1.Path, { path: path, color: color, style: "stroke", strokeWidth: 4 })));
|
|
51
|
+
};
|
|
52
|
+
exports.Overlay = Overlay;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkStability = checkStability;
|
|
4
|
+
let last = null;
|
|
5
|
+
let stable = 0;
|
|
6
|
+
function checkStability(current) {
|
|
7
|
+
if (!current) {
|
|
8
|
+
stable = 0;
|
|
9
|
+
last = null;
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
if (!last) {
|
|
13
|
+
last = current;
|
|
14
|
+
stable = 1;
|
|
15
|
+
return stable;
|
|
16
|
+
}
|
|
17
|
+
const diff = Math.hypot(avg(current.map((p) => p.x)) - avg(last.map((p) => p.x)), avg(current.map((p) => p.y)) - avg(last.map((p) => p.y)));
|
|
18
|
+
if (diff < 10) {
|
|
19
|
+
stable++;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
stable = 0;
|
|
23
|
+
}
|
|
24
|
+
last = current;
|
|
25
|
+
return stable;
|
|
26
|
+
}
|
|
27
|
+
const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-rectangle-doc-scanner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"prepare": "yarn build"
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"@shopify/react-native-skia": "*",
|
|
12
|
+
"react": "*",
|
|
13
|
+
"react-native": "*",
|
|
14
|
+
"react-native-fast-opencv": "*",
|
|
15
|
+
"react-native-reanimated": "*",
|
|
16
|
+
"react-native-vision-camera": "*",
|
|
17
|
+
"react-native-worklets-core": "*",
|
|
18
|
+
"vision-camera-resize-plugin": "*"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/react": "^18.2.41",
|
|
22
|
+
"@types/react-native": "0.73.0",
|
|
23
|
+
"typescript": "^5.3.3"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React, { 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 { runOnJS } from 'react-native-reanimated';
|
|
6
|
+
import {
|
|
7
|
+
OpenCV,
|
|
8
|
+
ColorConversionCodes,
|
|
9
|
+
MorphTypes,
|
|
10
|
+
MorphShapes,
|
|
11
|
+
RetrievalModes,
|
|
12
|
+
ContourApproximationModes,
|
|
13
|
+
ObjectType,
|
|
14
|
+
} from 'react-native-fast-opencv';
|
|
15
|
+
import { Overlay } from './utils/overlay';
|
|
16
|
+
import { checkStability } from './utils/stability';
|
|
17
|
+
import type { Point } from './types';
|
|
18
|
+
|
|
19
|
+
type CameraRef = {
|
|
20
|
+
takePhoto: (options: { qualityPrioritization: 'balanced' | 'quality' | 'speed' }) => Promise<{
|
|
21
|
+
path: string;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
onCapture?: (photo: { path: string; quad: Point[] | null }) => void;
|
|
27
|
+
overlayColor?: string;
|
|
28
|
+
autoCapture?: boolean;
|
|
29
|
+
minStableFrames?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DocScanner: React.FC<Props> = ({
|
|
33
|
+
onCapture,
|
|
34
|
+
overlayColor = '#e7a649',
|
|
35
|
+
autoCapture = true,
|
|
36
|
+
minStableFrames = 8,
|
|
37
|
+
}) => {
|
|
38
|
+
const device = useCameraDevice('back');
|
|
39
|
+
const { hasPermission, requestPermission } = useCameraPermission();
|
|
40
|
+
const { resize } = useResizePlugin();
|
|
41
|
+
const camera = useRef<CameraRef | null>(null);
|
|
42
|
+
const handleCameraRef = useCallback((ref: CameraRef | null) => {
|
|
43
|
+
camera.current = ref;
|
|
44
|
+
}, []);
|
|
45
|
+
const [quad, setQuad] = useState<Point[] | null>(null);
|
|
46
|
+
const [stable, setStable] = useState(0);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
requestPermission();
|
|
50
|
+
}, [requestPermission]);
|
|
51
|
+
|
|
52
|
+
const frameProcessor = useFrameProcessor((frame) => {
|
|
53
|
+
'worklet';
|
|
54
|
+
|
|
55
|
+
const ratio = 480 / frame.width;
|
|
56
|
+
const w = Math.floor(frame.width * ratio);
|
|
57
|
+
const h = Math.floor(frame.height * ratio);
|
|
58
|
+
const resized = resize(frame, {
|
|
59
|
+
dataType: 'uint8',
|
|
60
|
+
pixelFormat: 'bgr',
|
|
61
|
+
scale: { width: w, height: h },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const mat = OpenCV.frameBufferToMat(h, w, 3, resized);
|
|
65
|
+
|
|
66
|
+
OpenCV.invoke('cvtColor', mat, mat, ColorConversionCodes.COLOR_BGR2GRAY);
|
|
67
|
+
|
|
68
|
+
const kernel = OpenCV.createObject(ObjectType.Size, 4, 4);
|
|
69
|
+
const element = OpenCV.invoke('getStructuringElement', MorphShapes.MORPH_RECT, kernel);
|
|
70
|
+
OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_OPEN, element);
|
|
71
|
+
|
|
72
|
+
OpenCV.invoke('GaussianBlur', mat, mat, kernel, 0);
|
|
73
|
+
OpenCV.invoke('Canny', mat, mat, 75, 100);
|
|
74
|
+
|
|
75
|
+
const contours = OpenCV.createObject(ObjectType.PointVectorOfVectors);
|
|
76
|
+
OpenCV.invoke('findContours', mat, contours, RetrievalModes.RETR_LIST, ContourApproximationModes.CHAIN_APPROX_SIMPLE);
|
|
77
|
+
|
|
78
|
+
let best: Point[] | null = null;
|
|
79
|
+
let maxArea = 0;
|
|
80
|
+
|
|
81
|
+
const arr = OpenCV.toJSValue(contours).array;
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < arr.length; i++) {
|
|
84
|
+
const c = OpenCV.copyObjectFromVector(contours, i);
|
|
85
|
+
const { value: area } = OpenCV.invoke('contourArea', c, false);
|
|
86
|
+
|
|
87
|
+
if (area < w * h * 0.1) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { value: peri } = OpenCV.invoke('arcLength', c, true);
|
|
92
|
+
const approx = OpenCV.createObject(ObjectType.PointVector);
|
|
93
|
+
OpenCV.invoke('approxPolyDP', c, approx, 0.02 * peri, true);
|
|
94
|
+
const size = OpenCV.invokeWithOutParam('size', approx);
|
|
95
|
+
const { value: convex } = OpenCV.invoke('isContourConvex', approx);
|
|
96
|
+
|
|
97
|
+
if (convex && size === 4 && area > maxArea) {
|
|
98
|
+
const pts: Point[] = [];
|
|
99
|
+
for (let j = 0; j < 4; j++) {
|
|
100
|
+
const p = OpenCV.invoke('atPoint', approx, j, 0);
|
|
101
|
+
pts.push({ x: p.x / ratio, y: p.y / ratio });
|
|
102
|
+
}
|
|
103
|
+
best = pts;
|
|
104
|
+
maxArea = area;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
OpenCV.clearBuffers();
|
|
109
|
+
runOnJS(setQuad)(best);
|
|
110
|
+
}, [resize]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const s = checkStability(quad);
|
|
114
|
+
setStable(s);
|
|
115
|
+
}, [quad]);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const capture = async () => {
|
|
119
|
+
if (autoCapture && quad && stable >= minStableFrames && camera.current) {
|
|
120
|
+
const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
|
|
121
|
+
onCapture?.({ path: photo.path, quad });
|
|
122
|
+
setStable(0);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
capture();
|
|
127
|
+
}, [autoCapture, minStableFrames, onCapture, quad, stable]);
|
|
128
|
+
|
|
129
|
+
if (!device || !hasPermission) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<View style={{ flex: 1 }}>
|
|
135
|
+
<Camera
|
|
136
|
+
ref={handleCameraRef}
|
|
137
|
+
style={StyleSheet.absoluteFillObject}
|
|
138
|
+
device={device}
|
|
139
|
+
isActive
|
|
140
|
+
photo
|
|
141
|
+
frameProcessor={frameProcessor}
|
|
142
|
+
frameProcessorFps={15}
|
|
143
|
+
/>
|
|
144
|
+
<Overlay quad={quad} color={overlayColor} />
|
|
145
|
+
{!autoCapture && (
|
|
146
|
+
<TouchableOpacity
|
|
147
|
+
style={styles.button}
|
|
148
|
+
onPress={async () => {
|
|
149
|
+
if (!camera.current) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
|
|
154
|
+
onCapture?.({ path: photo.path, quad });
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</View>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const styles = StyleSheet.create({
|
|
163
|
+
button: {
|
|
164
|
+
position: 'absolute',
|
|
165
|
+
bottom: 40,
|
|
166
|
+
alignSelf: 'center',
|
|
167
|
+
width: 70,
|
|
168
|
+
height: 70,
|
|
169
|
+
borderRadius: 35,
|
|
170
|
+
backgroundColor: '#fff',
|
|
171
|
+
},
|
|
172
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
declare module 'react-native-vision-camera' {
|
|
2
|
+
import type { ComponentType } from 'react';
|
|
3
|
+
import type { ViewStyle } from 'react-native';
|
|
4
|
+
|
|
5
|
+
export type CameraDevice = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
} | null;
|
|
9
|
+
|
|
10
|
+
export type Frame = {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type TakePhotoOptions = {
|
|
16
|
+
qualityPrioritization?: 'balanced' | 'quality' | 'speed';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type CameraRef = {
|
|
20
|
+
takePhoto: (options?: TakePhotoOptions) => Promise<{
|
|
21
|
+
path: string;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CameraProps = {
|
|
26
|
+
ref?: (value: CameraRef | null) => void;
|
|
27
|
+
style?: ViewStyle;
|
|
28
|
+
device: CameraDevice;
|
|
29
|
+
isActive?: boolean;
|
|
30
|
+
photo?: boolean;
|
|
31
|
+
frameProcessor?: (frame: Frame) => void;
|
|
32
|
+
frameProcessorFps?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const Camera: ComponentType<CameraProps>;
|
|
36
|
+
export function useCameraDevice(position?: 'back' | 'front'): CameraDevice;
|
|
37
|
+
export function useCameraPermission(): {
|
|
38
|
+
hasPermission: boolean;
|
|
39
|
+
requestPermission: () => Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
export function useFrameProcessor(
|
|
42
|
+
processor: (frame: Frame) => void,
|
|
43
|
+
deps?: ReadonlyArray<unknown>
|
|
44
|
+
): (frame: Frame) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
declare module 'react-native-reanimated' {
|
|
48
|
+
export function runOnJS<T extends (...args: any[]) => any>(fn: T): T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
declare module 'vision-camera-resize-plugin' {
|
|
52
|
+
import type { Frame } from 'react-native-vision-camera';
|
|
53
|
+
|
|
54
|
+
type ResizeOptions = {
|
|
55
|
+
dataType: 'uint8';
|
|
56
|
+
pixelFormat: 'bgr';
|
|
57
|
+
scale: {
|
|
58
|
+
width: number;
|
|
59
|
+
height: number;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function useResizePlugin(): {
|
|
64
|
+
resize: (frame: Frame, options: ResizeOptions) => ArrayBuffer;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
declare module 'react-native-fast-opencv' {
|
|
69
|
+
export const OpenCV: any;
|
|
70
|
+
export const ColorConversionCodes: any;
|
|
71
|
+
export const MorphTypes: any;
|
|
72
|
+
export const MorphShapes: any;
|
|
73
|
+
export const RetrievalModes: any;
|
|
74
|
+
export const ContourApproximationModes: any;
|
|
75
|
+
export const ObjectType: any;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
declare module '@shopify/react-native-skia' {
|
|
79
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
80
|
+
import type { ViewStyle } from 'react-native';
|
|
81
|
+
|
|
82
|
+
export type SkPath = {
|
|
83
|
+
moveTo: (x: number, y: number) => void;
|
|
84
|
+
lineTo: (x: number, y: number) => void;
|
|
85
|
+
close: () => void;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const Skia: {
|
|
89
|
+
Path: {
|
|
90
|
+
Make: () => SkPath;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type CanvasProps = {
|
|
95
|
+
style?: ViewStyle;
|
|
96
|
+
children?: ReactNode;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const Canvas: ComponentType<CanvasProps>;
|
|
100
|
+
|
|
101
|
+
export type PathProps = {
|
|
102
|
+
path: SkPath;
|
|
103
|
+
style?: 'stroke' | 'fill';
|
|
104
|
+
strokeWidth?: number;
|
|
105
|
+
color?: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const Path: ComponentType<PathProps>;
|
|
109
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DocScanner';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type Point = { x: number; y: number };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { Canvas, Path, Skia } from '@shopify/react-native-skia';
|
|
3
|
+
import type { Point } from '../types';
|
|
4
|
+
|
|
5
|
+
type OverlayProps = {
|
|
6
|
+
quad: Point[] | null;
|
|
7
|
+
color?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const Overlay: React.FC<OverlayProps> = ({ quad, color = '#e7a649' }) => {
|
|
11
|
+
const path = useMemo(() => {
|
|
12
|
+
if (!quad) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const skPath = Skia.Path.Make();
|
|
17
|
+
skPath.moveTo(quad[0].x, quad[0].y);
|
|
18
|
+
quad.slice(1).forEach((p) => skPath.lineTo(p.x, p.y));
|
|
19
|
+
skPath.close();
|
|
20
|
+
return skPath;
|
|
21
|
+
}, [quad]);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Canvas style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
|
25
|
+
{path && <Path path={path} color={color} style="stroke" strokeWidth={4} />}
|
|
26
|
+
</Canvas>
|
|
27
|
+
);
|
|
28
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Point } from '../types';
|
|
2
|
+
|
|
3
|
+
let last: Point[] | null = null;
|
|
4
|
+
let stable = 0;
|
|
5
|
+
|
|
6
|
+
export function checkStability(current: Point[] | null): number {
|
|
7
|
+
if (!current) {
|
|
8
|
+
stable = 0;
|
|
9
|
+
last = null;
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!last) {
|
|
14
|
+
last = current;
|
|
15
|
+
stable = 1;
|
|
16
|
+
return stable;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const diff = Math.hypot(
|
|
20
|
+
avg(current.map((p) => p.x)) - avg(last.map((p) => p.x)),
|
|
21
|
+
avg(current.map((p) => p.y)) - avg(last.map((p) => p.y))
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (diff < 10) {
|
|
25
|
+
stable++;
|
|
26
|
+
} else {
|
|
27
|
+
stable = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
last = current;
|
|
31
|
+
return stable;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const avg = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length;
|
package/tsconfig.json
ADDED