react-native-rectangle-doc-scanner 0.55.0 → 0.57.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/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/utils/documentDetection.d.ts +23 -0
- package/dist/utils/documentDetection.js +217 -0
- package/package.json +4 -3
- package/src/index.ts +1 -0
- package/src/utils/documentDetection.ts +278 -0
package/dist/index.d.ts
CHANGED
|
@@ -5,3 +5,4 @@ export type { FullDocScannerResult, FullDocScannerProps, FullDocScannerStrings,
|
|
|
5
5
|
export type { Point, Quad, Rectangle, CapturedDocument } from './types';
|
|
6
6
|
export type { DetectionConfig } from './DocScanner';
|
|
7
7
|
export { quadToRectangle, rectangleToQuad, scaleCoordinates, scaleRectangle, } from './utils/coordinate';
|
|
8
|
+
export { DocumentDetector } from './utils/documentDetection';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = exports.FullDocScanner = exports.CropEditor = exports.DocScanner = void 0;
|
|
3
|
+
exports.DocumentDetector = exports.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = exports.FullDocScanner = exports.CropEditor = exports.DocScanner = void 0;
|
|
4
4
|
// Main components
|
|
5
5
|
var DocScanner_1 = require("./DocScanner");
|
|
6
6
|
Object.defineProperty(exports, "DocScanner", { enumerable: true, get: function () { return DocScanner_1.DocScanner; } });
|
|
@@ -14,3 +14,5 @@ Object.defineProperty(exports, "quadToRectangle", { enumerable: true, get: funct
|
|
|
14
14
|
Object.defineProperty(exports, "rectangleToQuad", { enumerable: true, get: function () { return coordinate_1.rectangleToQuad; } });
|
|
15
15
|
Object.defineProperty(exports, "scaleCoordinates", { enumerable: true, get: function () { return coordinate_1.scaleCoordinates; } });
|
|
16
16
|
Object.defineProperty(exports, "scaleRectangle", { enumerable: true, get: function () { return coordinate_1.scaleRectangle; } });
|
|
17
|
+
var documentDetection_1 = require("./utils/documentDetection");
|
|
18
|
+
Object.defineProperty(exports, "DocumentDetector", { enumerable: true, get: function () { return documentDetection_1.DocumentDetector; } });
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Point } from '../types';
|
|
2
|
+
type Size = {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
};
|
|
6
|
+
type Quad = [Point, Point, Point, Point];
|
|
7
|
+
/**
|
|
8
|
+
* Provides document detection utilities using react-native-fast-opencv.
|
|
9
|
+
*/
|
|
10
|
+
export declare class DocumentDetector {
|
|
11
|
+
private static initialized;
|
|
12
|
+
/** Initialize OpenCV runtime once */
|
|
13
|
+
static initialize(): Promise<void>;
|
|
14
|
+
/** Find document contours and return the largest quadrilateral */
|
|
15
|
+
static findDocumentContours(imagePath: string): Promise<Quad | null>;
|
|
16
|
+
/** Apply a perspective transform using detected corners */
|
|
17
|
+
static perspectiveTransform(imagePath: string, corners: Quad, outputSize?: Size): Promise<string | null>;
|
|
18
|
+
/** Detect document and apply normalization */
|
|
19
|
+
static detectAndNormalize(imagePath: string, outputSize?: Size): Promise<string | null>;
|
|
20
|
+
/** Only detect document corners without transforming */
|
|
21
|
+
static getDocumentBounds(imagePath: string): Promise<Quad | null>;
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DocumentDetector = void 0;
|
|
4
|
+
const react_native_fast_opencv_1 = require("react-native-fast-opencv");
|
|
5
|
+
const OUTPUT_SIZE = { width: 800, height: 600 };
|
|
6
|
+
const MIN_AREA = 1000;
|
|
7
|
+
const GAUSSIAN_KERNEL = { width: 5, height: 5 };
|
|
8
|
+
const MORPH_KERNEL = { width: 3, height: 3 };
|
|
9
|
+
const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
|
|
10
|
+
const THRESH_BINARY = 0;
|
|
11
|
+
const safeRelease = (mat) => {
|
|
12
|
+
if (mat && typeof mat.release === 'function') {
|
|
13
|
+
mat.release();
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const normalizePoint = (value) => {
|
|
17
|
+
if (!value) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value) && value.length >= 2) {
|
|
21
|
+
const [x, y] = value;
|
|
22
|
+
const px = Number(x);
|
|
23
|
+
const py = Number(y);
|
|
24
|
+
return Number.isFinite(px) && Number.isFinite(py) ? { x: px, y: py } : null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'object') {
|
|
27
|
+
const maybePoint = value;
|
|
28
|
+
const px = Number(maybePoint.x);
|
|
29
|
+
const py = Number(maybePoint.y);
|
|
30
|
+
return Number.isFinite(px) && Number.isFinite(py) ? { x: px, y: py } : null;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
const toPointArray = (value) => {
|
|
35
|
+
if (!value) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
const points = value.map(normalizePoint).filter((point) => point !== null);
|
|
40
|
+
return points.length === value.length ? points : null;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === 'object') {
|
|
43
|
+
const mat = value;
|
|
44
|
+
const data = mat.data32F ?? mat.data64F ?? mat.data32S;
|
|
45
|
+
if (!data || data.length < 8) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const points = [];
|
|
49
|
+
for (let i = 0; i + 1 < data.length; i += 2) {
|
|
50
|
+
const x = data[i];
|
|
51
|
+
const y = data[i + 1];
|
|
52
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
53
|
+
points.push({ x, y });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return points.length >= 4 ? points.slice(0, 4) : null;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
const ensureQuad = (points) => {
|
|
61
|
+
if (!points || points.length < 4) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const quad = [points[0], points[1], points[2], points[3]];
|
|
65
|
+
for (const point of quad) {
|
|
66
|
+
if (typeof point.x !== 'number' || typeof point.y !== 'number') {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return quad;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Provides document detection utilities using react-native-fast-opencv.
|
|
74
|
+
*/
|
|
75
|
+
class DocumentDetector {
|
|
76
|
+
static initialized = false;
|
|
77
|
+
/** Initialize OpenCV runtime once */
|
|
78
|
+
static async initialize() {
|
|
79
|
+
if (!DocumentDetector.initialized) {
|
|
80
|
+
await react_native_fast_opencv_1.OpenCV.initialize();
|
|
81
|
+
DocumentDetector.initialized = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Find document contours and return the largest quadrilateral */
|
|
85
|
+
static async findDocumentContours(imagePath) {
|
|
86
|
+
await DocumentDetector.initialize();
|
|
87
|
+
let image;
|
|
88
|
+
let gray;
|
|
89
|
+
let blurred;
|
|
90
|
+
let thresh;
|
|
91
|
+
let morphed;
|
|
92
|
+
let kernel;
|
|
93
|
+
try {
|
|
94
|
+
image = react_native_fast_opencv_1.OpenCV.imread(imagePath);
|
|
95
|
+
gray = react_native_fast_opencv_1.OpenCV.cvtColor(image, react_native_fast_opencv_1.ColorConversionCodes.COLOR_BGR2GRAY);
|
|
96
|
+
blurred = react_native_fast_opencv_1.OpenCV.GaussianBlur(gray, GAUSSIAN_KERNEL, 0);
|
|
97
|
+
thresh = react_native_fast_opencv_1.OpenCV.adaptiveThreshold(blurred, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, 11, 2);
|
|
98
|
+
kernel = react_native_fast_opencv_1.OpenCV.getStructuringElement(react_native_fast_opencv_1.MorphShapes.MORPH_RECT, MORPH_KERNEL);
|
|
99
|
+
morphed = react_native_fast_opencv_1.OpenCV.morphologyEx(thresh, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, kernel);
|
|
100
|
+
const contours = react_native_fast_opencv_1.OpenCV.findContours(morphed, react_native_fast_opencv_1.RetrievalModes.RETR_EXTERNAL, react_native_fast_opencv_1.ContourApproximationModes.CHAIN_APPROX_SIMPLE);
|
|
101
|
+
let largestQuad = null;
|
|
102
|
+
let maxArea = 0;
|
|
103
|
+
for (const contour of contours) {
|
|
104
|
+
const area = react_native_fast_opencv_1.OpenCV.contourArea(contour);
|
|
105
|
+
if (area <= maxArea || area <= MIN_AREA) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const perimeter = react_native_fast_opencv_1.OpenCV.arcLength(contour, true);
|
|
109
|
+
const approx = react_native_fast_opencv_1.OpenCV.approxPolyDP(contour, 0.02 * perimeter, true);
|
|
110
|
+
const points = ensureQuad(toPointArray(approx));
|
|
111
|
+
safeRelease(approx);
|
|
112
|
+
if (!points) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
maxArea = area;
|
|
116
|
+
largestQuad = points;
|
|
117
|
+
}
|
|
118
|
+
return largestQuad;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (__DEV__) {
|
|
122
|
+
console.error('[DocumentDetector] findDocumentContours error', error);
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
safeRelease(kernel);
|
|
128
|
+
safeRelease(morphed);
|
|
129
|
+
safeRelease(thresh);
|
|
130
|
+
safeRelease(blurred);
|
|
131
|
+
safeRelease(gray);
|
|
132
|
+
safeRelease(image);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Apply a perspective transform using detected corners */
|
|
136
|
+
static async perspectiveTransform(imagePath, corners, outputSize = OUTPUT_SIZE) {
|
|
137
|
+
await DocumentDetector.initialize();
|
|
138
|
+
let image;
|
|
139
|
+
let srcPoints;
|
|
140
|
+
let dstPoints;
|
|
141
|
+
let transformMatrix;
|
|
142
|
+
let warped;
|
|
143
|
+
try {
|
|
144
|
+
image = react_native_fast_opencv_1.OpenCV.imread(imagePath);
|
|
145
|
+
srcPoints = react_native_fast_opencv_1.OpenCV.matFromArray(4, 1, react_native_fast_opencv_1.OpenCV.CV_32FC2, [
|
|
146
|
+
corners[0].x,
|
|
147
|
+
corners[0].y,
|
|
148
|
+
corners[1].x,
|
|
149
|
+
corners[1].y,
|
|
150
|
+
corners[2].x,
|
|
151
|
+
corners[2].y,
|
|
152
|
+
corners[3].x,
|
|
153
|
+
corners[3].y,
|
|
154
|
+
]);
|
|
155
|
+
dstPoints = react_native_fast_opencv_1.OpenCV.matFromArray(4, 1, react_native_fast_opencv_1.OpenCV.CV_32FC2, [
|
|
156
|
+
0,
|
|
157
|
+
0,
|
|
158
|
+
outputSize.width,
|
|
159
|
+
0,
|
|
160
|
+
outputSize.width,
|
|
161
|
+
outputSize.height,
|
|
162
|
+
0,
|
|
163
|
+
outputSize.height,
|
|
164
|
+
]);
|
|
165
|
+
transformMatrix = react_native_fast_opencv_1.OpenCV.getPerspectiveTransform(srcPoints, dstPoints);
|
|
166
|
+
warped = react_native_fast_opencv_1.OpenCV.warpPerspective(image, transformMatrix, outputSize);
|
|
167
|
+
const outputPath = imagePath.replace(/\.jpg$/i, '_normalized.jpg');
|
|
168
|
+
react_native_fast_opencv_1.OpenCV.imwrite(outputPath, warped);
|
|
169
|
+
return outputPath;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (__DEV__) {
|
|
173
|
+
console.error('[DocumentDetector] perspectiveTransform error', error);
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
safeRelease(warped);
|
|
179
|
+
safeRelease(transformMatrix);
|
|
180
|
+
safeRelease(dstPoints);
|
|
181
|
+
safeRelease(srcPoints);
|
|
182
|
+
safeRelease(image);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Detect document and apply normalization */
|
|
186
|
+
static async detectAndNormalize(imagePath, outputSize) {
|
|
187
|
+
try {
|
|
188
|
+
const corners = await DocumentDetector.findDocumentContours(imagePath);
|
|
189
|
+
if (!corners) {
|
|
190
|
+
if (__DEV__) {
|
|
191
|
+
console.log('[DocumentDetector] No document detected');
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return DocumentDetector.perspectiveTransform(imagePath, corners, outputSize ?? OUTPUT_SIZE);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (__DEV__) {
|
|
199
|
+
console.error('[DocumentDetector] detectAndNormalize error', error);
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Only detect document corners without transforming */
|
|
205
|
+
static async getDocumentBounds(imagePath) {
|
|
206
|
+
try {
|
|
207
|
+
return DocumentDetector.findDocumentContours(imagePath);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (__DEV__) {
|
|
211
|
+
console.error('[DocumentDetector] getDocumentBounds error', error);
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
exports.DocumentDetector = DocumentDetector;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-rectangle-doc-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"repository": {
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"@shopify/react-native-skia": "*",
|
|
20
20
|
"react": "*",
|
|
21
21
|
"react-native": "*",
|
|
22
|
+
"react-native-fast-opencv": "*",
|
|
23
|
+
"react-native-perspective-image-cropper": "*",
|
|
22
24
|
"react-native-reanimated": "*",
|
|
23
25
|
"react-native-vision-camera": "*",
|
|
24
26
|
"react-native-worklets-core": "*",
|
|
@@ -30,8 +32,7 @@
|
|
|
30
32
|
"typescript": "^5.3.3"
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
|
-
"react-native-
|
|
34
|
-
"react-native-perspective-image-cropper": "^0.4.4",
|
|
35
|
+
"react-native-rectangle-doc-scanner": "^0.55.0",
|
|
35
36
|
"react-native-worklets-core": "^1.6.2"
|
|
36
37
|
}
|
|
37
38
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OpenCV,
|
|
3
|
+
ColorConversionCodes,
|
|
4
|
+
MorphShapes,
|
|
5
|
+
MorphTypes,
|
|
6
|
+
RetrievalModes,
|
|
7
|
+
ContourApproximationModes,
|
|
8
|
+
} from 'react-native-fast-opencv';
|
|
9
|
+
import type { Point } from '../types';
|
|
10
|
+
|
|
11
|
+
type MatLike = { release?: () => void } | null | undefined;
|
|
12
|
+
|
|
13
|
+
type Size = { width: number; height: number };
|
|
14
|
+
|
|
15
|
+
type Quad = [Point, Point, Point, Point];
|
|
16
|
+
|
|
17
|
+
const OUTPUT_SIZE: Size = { width: 800, height: 600 };
|
|
18
|
+
const MIN_AREA = 1000;
|
|
19
|
+
const GAUSSIAN_KERNEL: Size = { width: 5, height: 5 };
|
|
20
|
+
const MORPH_KERNEL: Size = { width: 3, height: 3 };
|
|
21
|
+
const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
|
|
22
|
+
const THRESH_BINARY = 0;
|
|
23
|
+
|
|
24
|
+
const safeRelease = (mat: MatLike) => {
|
|
25
|
+
if (mat && typeof mat.release === 'function') {
|
|
26
|
+
mat.release();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const normalizePoint = (value: unknown): Point | null => {
|
|
31
|
+
if (!value) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (Array.isArray(value) && value.length >= 2) {
|
|
36
|
+
const [x, y] = value;
|
|
37
|
+
const px = Number(x);
|
|
38
|
+
const py = Number(y);
|
|
39
|
+
return Number.isFinite(px) && Number.isFinite(py) ? { x: px, y: py } : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof value === 'object') {
|
|
43
|
+
const maybePoint = value as { x?: unknown; y?: unknown };
|
|
44
|
+
const px = Number(maybePoint.x);
|
|
45
|
+
const py = Number(maybePoint.y);
|
|
46
|
+
return Number.isFinite(px) && Number.isFinite(py) ? { x: px, y: py } : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const toPointArray = (value: unknown): Point[] | null => {
|
|
53
|
+
if (!value) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
const points = value.map(normalizePoint).filter((point): point is Point => point !== null);
|
|
59
|
+
return points.length === value.length ? points : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof value === 'object') {
|
|
63
|
+
const mat = value as { data32F?: number[]; data64F?: number[]; data32S?: number[] };
|
|
64
|
+
const data = mat.data32F ?? mat.data64F ?? mat.data32S;
|
|
65
|
+
if (!data || data.length < 8) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const points: Point[] = [];
|
|
70
|
+
for (let i = 0; i + 1 < data.length; i += 2) {
|
|
71
|
+
const x = data[i];
|
|
72
|
+
const y = data[i + 1];
|
|
73
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
74
|
+
points.push({ x, y });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return points.length >= 4 ? points.slice(0, 4) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const ensureQuad = (points: Point[] | null): Quad | null => {
|
|
85
|
+
if (!points || points.length < 4) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const quad: Quad = [points[0], points[1], points[2], points[3]];
|
|
90
|
+
for (const point of quad) {
|
|
91
|
+
if (typeof point.x !== 'number' || typeof point.y !== 'number') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return quad;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Provides document detection utilities using react-native-fast-opencv.
|
|
101
|
+
*/
|
|
102
|
+
export class DocumentDetector {
|
|
103
|
+
private static initialized = false;
|
|
104
|
+
|
|
105
|
+
/** Initialize OpenCV runtime once */
|
|
106
|
+
static async initialize(): Promise<void> {
|
|
107
|
+
if (!DocumentDetector.initialized) {
|
|
108
|
+
await OpenCV.initialize();
|
|
109
|
+
DocumentDetector.initialized = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Find document contours and return the largest quadrilateral */
|
|
114
|
+
static async findDocumentContours(imagePath: string): Promise<Quad | null> {
|
|
115
|
+
await DocumentDetector.initialize();
|
|
116
|
+
|
|
117
|
+
let image: MatLike;
|
|
118
|
+
let gray: MatLike;
|
|
119
|
+
let blurred: MatLike;
|
|
120
|
+
let thresh: MatLike;
|
|
121
|
+
let morphed: MatLike;
|
|
122
|
+
let kernel: MatLike;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
image = OpenCV.imread(imagePath);
|
|
126
|
+
gray = OpenCV.cvtColor(image, ColorConversionCodes.COLOR_BGR2GRAY);
|
|
127
|
+
blurred = OpenCV.GaussianBlur(gray, GAUSSIAN_KERNEL, 0);
|
|
128
|
+
|
|
129
|
+
thresh = OpenCV.adaptiveThreshold(
|
|
130
|
+
blurred,
|
|
131
|
+
255,
|
|
132
|
+
ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
133
|
+
THRESH_BINARY,
|
|
134
|
+
11,
|
|
135
|
+
2,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
kernel = OpenCV.getStructuringElement(MorphShapes.MORPH_RECT, MORPH_KERNEL);
|
|
139
|
+
morphed = OpenCV.morphologyEx(thresh, MorphTypes.MORPH_CLOSE, kernel);
|
|
140
|
+
|
|
141
|
+
const contours = OpenCV.findContours(
|
|
142
|
+
morphed,
|
|
143
|
+
RetrievalModes.RETR_EXTERNAL,
|
|
144
|
+
ContourApproximationModes.CHAIN_APPROX_SIMPLE,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
let largestQuad: Quad | null = null;
|
|
148
|
+
let maxArea = 0;
|
|
149
|
+
|
|
150
|
+
for (const contour of contours) {
|
|
151
|
+
const area = OpenCV.contourArea(contour);
|
|
152
|
+
if (area <= maxArea || area <= MIN_AREA) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const perimeter = OpenCV.arcLength(contour, true);
|
|
157
|
+
const approx = OpenCV.approxPolyDP(contour, 0.02 * perimeter, true);
|
|
158
|
+
const points = ensureQuad(toPointArray(approx));
|
|
159
|
+
|
|
160
|
+
safeRelease(approx as MatLike);
|
|
161
|
+
|
|
162
|
+
if (!points) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
maxArea = area;
|
|
167
|
+
largestQuad = points;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return largestQuad;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (__DEV__) {
|
|
173
|
+
console.error('[DocumentDetector] findDocumentContours error', error);
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
} finally {
|
|
177
|
+
safeRelease(kernel);
|
|
178
|
+
safeRelease(morphed);
|
|
179
|
+
safeRelease(thresh);
|
|
180
|
+
safeRelease(blurred);
|
|
181
|
+
safeRelease(gray);
|
|
182
|
+
safeRelease(image);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Apply a perspective transform using detected corners */
|
|
187
|
+
static async perspectiveTransform(
|
|
188
|
+
imagePath: string,
|
|
189
|
+
corners: Quad,
|
|
190
|
+
outputSize: Size = OUTPUT_SIZE,
|
|
191
|
+
): Promise<string | null> {
|
|
192
|
+
await DocumentDetector.initialize();
|
|
193
|
+
|
|
194
|
+
let image: MatLike;
|
|
195
|
+
let srcPoints: MatLike;
|
|
196
|
+
let dstPoints: MatLike;
|
|
197
|
+
let transformMatrix: MatLike;
|
|
198
|
+
let warped: MatLike;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
image = OpenCV.imread(imagePath);
|
|
202
|
+
|
|
203
|
+
srcPoints = OpenCV.matFromArray(4, 1, OpenCV.CV_32FC2, [
|
|
204
|
+
corners[0].x,
|
|
205
|
+
corners[0].y,
|
|
206
|
+
corners[1].x,
|
|
207
|
+
corners[1].y,
|
|
208
|
+
corners[2].x,
|
|
209
|
+
corners[2].y,
|
|
210
|
+
corners[3].x,
|
|
211
|
+
corners[3].y,
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
dstPoints = OpenCV.matFromArray(4, 1, OpenCV.CV_32FC2, [
|
|
215
|
+
0,
|
|
216
|
+
0,
|
|
217
|
+
outputSize.width,
|
|
218
|
+
0,
|
|
219
|
+
outputSize.width,
|
|
220
|
+
outputSize.height,
|
|
221
|
+
0,
|
|
222
|
+
outputSize.height,
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
transformMatrix = OpenCV.getPerspectiveTransform(srcPoints, dstPoints);
|
|
226
|
+
|
|
227
|
+
warped = OpenCV.warpPerspective(image, transformMatrix, outputSize);
|
|
228
|
+
|
|
229
|
+
const outputPath = imagePath.replace(/\.jpg$/i, '_normalized.jpg');
|
|
230
|
+
OpenCV.imwrite(outputPath, warped);
|
|
231
|
+
|
|
232
|
+
return outputPath;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (__DEV__) {
|
|
235
|
+
console.error('[DocumentDetector] perspectiveTransform error', error);
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
} finally {
|
|
239
|
+
safeRelease(warped);
|
|
240
|
+
safeRelease(transformMatrix);
|
|
241
|
+
safeRelease(dstPoints);
|
|
242
|
+
safeRelease(srcPoints);
|
|
243
|
+
safeRelease(image);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Detect document and apply normalization */
|
|
248
|
+
static async detectAndNormalize(imagePath: string, outputSize?: Size): Promise<string | null> {
|
|
249
|
+
try {
|
|
250
|
+
const corners = await DocumentDetector.findDocumentContours(imagePath);
|
|
251
|
+
if (!corners) {
|
|
252
|
+
if (__DEV__) {
|
|
253
|
+
console.log('[DocumentDetector] No document detected');
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return DocumentDetector.perspectiveTransform(imagePath, corners, outputSize ?? OUTPUT_SIZE);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (__DEV__) {
|
|
261
|
+
console.error('[DocumentDetector] detectAndNormalize error', error);
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Only detect document corners without transforming */
|
|
268
|
+
static async getDocumentBounds(imagePath: string): Promise<Quad | null> {
|
|
269
|
+
try {
|
|
270
|
+
return DocumentDetector.findDocumentContours(imagePath);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (__DEV__) {
|
|
273
|
+
console.error('[DocumentDetector] getDocumentBounds error', error);
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|