rn-opencv-doc-perspective-correction 1.0.16 → 1.0.18
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 +11 -0
- package/dist/index.js +161 -51
- package/package.json +1 -1
- package/src/index.ts +174 -56
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,17 @@ export type OcrMethodHint = 'G' | 'S';
|
|
|
11
11
|
export declare class DocumentScanner {
|
|
12
12
|
private static getDistance;
|
|
13
13
|
private static sortCorners;
|
|
14
|
+
/**
|
|
15
|
+
* Tính góc (độ) giữa vector (p2→p1) và vector (p2→p3).
|
|
16
|
+
*/
|
|
17
|
+
private static getAngleBetween;
|
|
18
|
+
/**
|
|
19
|
+
* Dải góc nội tứ giác (max - min). Nếu > 40° → quá méo, không phải tài liệu.
|
|
20
|
+
* Tham chiếu: andrewdcampbell/OpenCV-Document-Scanner MAX_QUAD_ANGLE_RANGE=40
|
|
21
|
+
*/
|
|
22
|
+
private static getAngleRange;
|
|
23
|
+
/** Fallback: 4 góc của toàn bộ ảnh (như Python gốc khi không tìm được contour) */
|
|
24
|
+
private static buildFullImageCorners;
|
|
14
25
|
static detectPageCorners(imageBase64: string): Point[] | undefined;
|
|
15
26
|
static applyPerspectiveCorrection(imageBase64: string, corners: Point[]): string | undefined;
|
|
16
27
|
/**
|
package/dist/index.js
CHANGED
|
@@ -17,14 +17,53 @@ class DocumentScanner {
|
|
|
17
17
|
return angleA - angleB;
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Tính góc (độ) giữa vector (p2→p1) và vector (p2→p3).
|
|
22
|
+
*/
|
|
23
|
+
static getAngleBetween(p1, p2, p3) {
|
|
24
|
+
const v1x = p1.x - p2.x, v1y = p1.y - p2.y;
|
|
25
|
+
const v2x = p3.x - p2.x, v2y = p3.y - p2.y;
|
|
26
|
+
const dot = v1x * v2x + v1y * v2y;
|
|
27
|
+
const mag1 = Math.sqrt(v1x * v1x + v1y * v1y);
|
|
28
|
+
const mag2 = Math.sqrt(v2x * v2x + v2y * v2y);
|
|
29
|
+
if (mag1 === 0 || mag2 === 0)
|
|
30
|
+
return 0;
|
|
31
|
+
return Math.acos(Math.max(-1, Math.min(1, dot / (mag1 * mag2)))) * (180 / Math.PI);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Dải góc nội tứ giác (max - min). Nếu > 40° → quá méo, không phải tài liệu.
|
|
35
|
+
* Tham chiếu: andrewdcampbell/OpenCV-Document-Scanner MAX_QUAD_ANGLE_RANGE=40
|
|
36
|
+
*/
|
|
37
|
+
static getAngleRange(quad) {
|
|
38
|
+
if (quad.length !== 4)
|
|
39
|
+
return 999;
|
|
40
|
+
const [tl, tr, br, bl] = quad;
|
|
41
|
+
const angles = [
|
|
42
|
+
this.getAngleBetween(bl, tl, tr),
|
|
43
|
+
this.getAngleBetween(tl, tr, br),
|
|
44
|
+
this.getAngleBetween(tr, br, bl),
|
|
45
|
+
this.getAngleBetween(br, bl, tl),
|
|
46
|
+
];
|
|
47
|
+
return Math.max(...angles) - Math.min(...angles);
|
|
48
|
+
}
|
|
49
|
+
/** Fallback: 4 góc của toàn bộ ảnh (như Python gốc khi không tìm được contour) */
|
|
50
|
+
static buildFullImageCorners(w, h) {
|
|
51
|
+
return [
|
|
52
|
+
{ x: 0, y: 0 }, // top-left
|
|
53
|
+
{ x: w, y: 0 }, // top-right
|
|
54
|
+
{ x: w, y: h }, // bottom-right
|
|
55
|
+
{ x: 0, y: h }, // bottom-left
|
|
56
|
+
];
|
|
57
|
+
}
|
|
20
58
|
static detectPageCorners(imageBase64) {
|
|
59
|
+
var _a, _b;
|
|
21
60
|
let src = null;
|
|
22
61
|
let resized = null;
|
|
23
62
|
let gray = null;
|
|
24
63
|
let blurred = null;
|
|
25
64
|
let closed = null;
|
|
26
65
|
let edges = null;
|
|
27
|
-
let
|
|
66
|
+
let dilated = null;
|
|
28
67
|
let contoursObj = null;
|
|
29
68
|
let hierarchyObj = null;
|
|
30
69
|
try {
|
|
@@ -46,90 +85,161 @@ class DocumentScanner {
|
|
|
46
85
|
// BƯỚC 1: Chuyển sang ảnh xám (Grayscale)
|
|
47
86
|
gray = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
48
87
|
react_native_fast_opencv_1.OpenCV.invoke('cvtColor', resized, gray, react_native_fast_opencv_1.ColorConversionCodes.COLOR_BGR2GRAY);
|
|
49
|
-
// BƯỚC 2:
|
|
88
|
+
// BƯỚC 2: GaussianBlur 7x7 (theo andrewdcampbell)
|
|
50
89
|
blurred = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
51
90
|
const ksize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 7, 7);
|
|
52
91
|
react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', gray, blurred, ksize, 0);
|
|
53
|
-
// BƯỚC 3:
|
|
54
|
-
//
|
|
92
|
+
// BƯỚC 3: MORPH_CLOSE (kernel RECT 9x9) – nối các lỗ trống giữa cạnh viền
|
|
93
|
+
// Thiếu bước này là nguyên nhân chính khiến contour bị đứt đoạn
|
|
94
|
+
const MORPH_SIZE = 9;
|
|
95
|
+
const morphKernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, MORPH_SIZE, MORPH_SIZE);
|
|
96
|
+
const morphKernel = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, morphKernelSize);
|
|
55
97
|
closed = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
98
|
+
let morphCloseUsed = true;
|
|
99
|
+
try {
|
|
100
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
|
|
101
|
+
}
|
|
102
|
+
catch (_morphErr) {
|
|
103
|
+
closed = blurred; // Fallback nếu thiết bị không hỗ trợ
|
|
104
|
+
morphCloseUsed = false;
|
|
105
|
+
}
|
|
106
|
+
// BƯỚC 4: Canny (0, 84) – ngưỡng thấp theo andrewdcampbell
|
|
107
|
+
// threshold1=0: chấp nhận TẤT CẢ cạnh yếu nếu liên kết với cạnh mạnh
|
|
108
|
+
// threshold2=84: ngưỡng cạnh mạnh vừa phải
|
|
61
109
|
edges = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
62
110
|
react_native_fast_opencv_1.OpenCV.invoke('Canny', closed, edges, 0, 84);
|
|
63
|
-
// BƯỚC 5:
|
|
64
|
-
|
|
111
|
+
// BƯỚC 5: Dilate 3x3 – nối các pixel viền lân cận bị đứt
|
|
112
|
+
const dilKernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 3, 3);
|
|
113
|
+
const dilKernel = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, dilKernelSize);
|
|
114
|
+
dilated = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
115
|
+
let dilateUsed = true;
|
|
116
|
+
try {
|
|
117
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', edges, dilated, 1 /* MORPH_DILATE */, dilKernel);
|
|
118
|
+
}
|
|
119
|
+
catch (_dilErr) {
|
|
120
|
+
dilated = edges; // Fallback
|
|
121
|
+
dilateUsed = false;
|
|
122
|
+
}
|
|
123
|
+
// BƯỚC 6: Tìm Contours — dùng RETR_EXTERNAL (đúng như Python gốc, không phải RETR_LIST)
|
|
65
124
|
contoursObj = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.MatVector);
|
|
66
125
|
hierarchyObj = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
67
|
-
react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy',
|
|
126
|
+
react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj, 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
68
127
|
const contoursJS = react_native_fast_opencv_1.OpenCV.toJSValue(contoursObj);
|
|
69
128
|
const contoursArray = (contoursJS === null || contoursJS === void 0 ? void 0 : contoursJS.array) || [];
|
|
70
129
|
const contoursSize = contoursArray.length;
|
|
130
|
+
const imgArea = targetWidth * targetHeight;
|
|
131
|
+
console.log(`[DocDetect] imgSize=${origCols}x${origRows} → resize=${targetWidth}x${targetHeight} | morphClose=${morphCloseUsed} dilate=${dilateUsed} | totalContours=${contoursSize}`);
|
|
71
132
|
if (contoursSize === 0) {
|
|
72
|
-
|
|
133
|
+
// Fallback: trả toàn bộ ảnh (như Python gốc)
|
|
134
|
+
console.log('[DocDetect] contoursSize=0, fallback to full image');
|
|
135
|
+
return this.buildFullImageCorners(origCols, origRows);
|
|
73
136
|
}
|
|
74
|
-
|
|
137
|
+
// Lấy top 5 contour lớn nhất (đúng như Python: cnts[:5])
|
|
138
|
+
const allContourMetrics = [];
|
|
139
|
+
let maxContourArea = 0;
|
|
75
140
|
for (let i = 0; i < contoursSize; i++) {
|
|
76
141
|
const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contoursObj, i);
|
|
77
142
|
const areaObj = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour);
|
|
78
143
|
const area = areaObj ? areaObj.value : 0;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (area > (imgArea * 0.05) && area < (imgArea * 0.98)) {
|
|
83
|
-
contourMetrics.push({ index: i, area, contour });
|
|
84
|
-
}
|
|
144
|
+
if (area > maxContourArea)
|
|
145
|
+
maxContourArea = area;
|
|
146
|
+
allContourMetrics.push({ index: i, area, contour });
|
|
85
147
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
148
|
+
allContourMetrics.sort((a, b) => b.area - a.area);
|
|
149
|
+
const top5 = allContourMetrics.slice(0, 5);
|
|
150
|
+
console.log(`[DocDetect] top5 areas: ${top5.map(m => m.area.toFixed(0)).join(', ')} | imgArea=${imgArea.toFixed(0)}`);
|
|
151
|
+
// MIN_QUAD_AREA_RATIO = 0.25 (Python gốc), với fallback 0.05 cho ảnh nhỏ
|
|
152
|
+
const MIN_QUAD_AREA_RATIO = imgArea > 50000 ? 0.25 : 0.05;
|
|
153
|
+
let bestPoly = undefined;
|
|
154
|
+
let bestArea = 0;
|
|
155
|
+
let bestAngleRange = 999;
|
|
156
|
+
const debugCandidates = [];
|
|
157
|
+
for (let i = 0; i < top5.length; i++) {
|
|
158
|
+
const metric = top5[i];
|
|
91
159
|
const contour = metric.contour;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
160
|
+
// Python gốc dùng epsilon = 80 pixel tuyệt đối (không phải % perimeter!)
|
|
161
|
+
// approxPolyDP(c, 80, True) — epsilon cố định
|
|
162
|
+
// Ta thử range 10→100 pixel, bước 10, để tìm ra 4 đỉnh
|
|
163
|
+
let foundPts = null;
|
|
164
|
+
let foundVerts = 0;
|
|
165
|
+
let foundAngle = 999;
|
|
166
|
+
for (let epPx = 10; epPx <= 120; epPx += 5) {
|
|
98
167
|
const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
|
|
99
|
-
react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx,
|
|
168
|
+
react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
|
|
100
169
|
const approxJS = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
170
|
+
const verts = (_b = (_a = approxJS === null || approxJS === void 0 ? void 0 : approxJS.array) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
|
|
171
|
+
if (verts === 4) {
|
|
172
|
+
const pts = approxJS.array;
|
|
173
|
+
const sorted = this.sortCorners([...pts]);
|
|
174
|
+
const angleRange = this.getAngleRange(sorted);
|
|
175
|
+
if (angleRange < foundAngle) {
|
|
176
|
+
foundAngle = angleRange;
|
|
177
|
+
foundPts = pts;
|
|
178
|
+
foundVerts = verts;
|
|
179
|
+
}
|
|
180
|
+
// is_valid_contour: area > MIN_QUAD_AREA_RATIO và angle_range < 40
|
|
181
|
+
if (metric.area > imgArea * MIN_QUAD_AREA_RATIO && angleRange < 40) {
|
|
182
|
+
if (metric.area > bestArea || (metric.area === bestArea && angleRange < bestAngleRange)) {
|
|
183
|
+
bestPoly = pts;
|
|
184
|
+
bestArea = metric.area;
|
|
185
|
+
bestAngleRange = angleRange;
|
|
110
186
|
}
|
|
187
|
+
break;
|
|
111
188
|
}
|
|
112
|
-
catch (convexErr) { }
|
|
113
189
|
}
|
|
114
190
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
191
|
+
debugCandidates.push(` [C${i}] area=${metric.area.toFixed(0)}px²(${(metric.area / imgArea * 100).toFixed(1)}%) ` +
|
|
192
|
+
`foundVerts=${foundVerts} bestAngle=${foundAngle === 999 ? 'N/A' : foundAngle.toFixed(1)}° ` +
|
|
193
|
+
`valid=${metric.area > imgArea * MIN_QUAD_AREA_RATIO && foundAngle < 40}`);
|
|
118
194
|
}
|
|
119
|
-
|
|
120
|
-
|
|
195
|
+
console.log(`[DocDetect] Candidates:\n${debugCandidates.join('\n')}`);
|
|
196
|
+
console.log(`[DocDetect] Result: bestPoly=${bestPoly ? 'found' : 'NOT FOUND'} area=${bestArea.toFixed(0)} angleRange=${bestAngleRange === 999 ? 'N/A' : bestAngleRange.toFixed(1)}°`);
|
|
197
|
+
// BƯỚC 7: Trả về góc, scale về kích thước ảnh gốc
|
|
198
|
+
if (bestPoly && bestPoly.length === 4) {
|
|
121
199
|
const actualRatio = (resized === src) ? 1.0 : ratio;
|
|
122
|
-
const originalCorners =
|
|
200
|
+
const originalCorners = bestPoly.map(p => ({
|
|
123
201
|
x: Math.round(p.x * actualRatio),
|
|
124
202
|
y: Math.round(p.y * actualRatio)
|
|
125
203
|
}));
|
|
126
204
|
return this.sortCorners(originalCorners);
|
|
127
205
|
}
|
|
128
|
-
|
|
206
|
+
// Giống Python gốc: nếu không tìm được → dùng toàn bộ ảnh làm fallback
|
|
207
|
+
// "If we did not find any valid contours, just use the whole image"
|
|
208
|
+
console.log(`[DocDetect] No valid quad found (minArea=${(MIN_QUAD_AREA_RATIO * 100).toFixed(0)}%, maxContour=${maxContourArea.toFixed(0)}px²). Fallback to full image.`);
|
|
209
|
+
throw new Error(`Không phát hiện được viền tài liệu.\n` +
|
|
210
|
+
`Ảnh: ${origCols}x${origRows}px | Resize: ${targetWidth}x${targetHeight}px\n` +
|
|
211
|
+
`Contours kiểm tra: ${top5.length}/${contoursSize} | MinArea: ${(MIN_QUAD_AREA_RATIO * 100).toFixed(0)}%\n` +
|
|
212
|
+
`Contour lớn nhất: ${maxContourArea.toFixed(0)}px² (${(maxContourArea / imgArea * 100).toFixed(1)}% ảnh)\n` +
|
|
213
|
+
`Chi tiết:\n${debugCandidates.join('\n')}\n` +
|
|
214
|
+
`Gợi ý: Tài liệu cần chiếm ít nhất ${(MIN_QUAD_AREA_RATIO * 100).toFixed(0)}% diện tích khung hình, viền phải tương phản với nền.`);
|
|
129
215
|
}
|
|
130
216
|
catch (e) {
|
|
131
|
-
|
|
132
|
-
|
|
217
|
+
// Trích message từ mọi dạng exception (Error object, string, native, undefined...)
|
|
218
|
+
let rawMsg;
|
|
219
|
+
if (typeof e === 'string') {
|
|
220
|
+
rawMsg = e;
|
|
221
|
+
}
|
|
222
|
+
else if (e instanceof Error) {
|
|
223
|
+
rawMsg = e.message || e.toString();
|
|
224
|
+
}
|
|
225
|
+
else if (e && typeof e === 'object') {
|
|
226
|
+
rawMsg = e.message || e.code || e.name || JSON.stringify(e);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
rawMsg = String(e !== null && e !== void 0 ? e : 'Unknown error');
|
|
230
|
+
}
|
|
231
|
+
console.error('[OpenCV] detectPageCorners exception:', rawMsg, e);
|
|
232
|
+
// Nếu đây là lỗi intentional từ chính hàm này (đã có thông tin chi tiết),
|
|
233
|
+
// chỉ wrap nhẹ để không mất ngữ cảnh.
|
|
234
|
+
// Nếu là lỗi native bất ngờ từ OpenCV, thêm context step.
|
|
235
|
+
const isIntentional = rawMsg.startsWith('Không ') || rawMsg.startsWith('[OpenCV Corner');
|
|
236
|
+
if (isIntentional) {
|
|
237
|
+
throw new Error(rawMsg);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
throw new Error(`Lỗi xử lý OpenCV không mong đợi: ${rawMsg}\n` +
|
|
241
|
+
`(Có thể do ảnh corrupt, định dạng không hỗ trợ, hoặc thiết bị thiếu bộ nhớ)`);
|
|
242
|
+
}
|
|
133
243
|
}
|
|
134
244
|
finally {
|
|
135
245
|
react_native_fast_opencv_1.OpenCV.clearBuffers();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rn-opencv-doc-perspective-correction",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
4
4
|
"description": "A React Native library for document corner detection and perspective correction using react-native-fast-opencv",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,45 @@ export class DocumentScanner {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Tính góc (độ) giữa vector (p2→p1) và vector (p2→p3).
|
|
35
|
+
*/
|
|
36
|
+
private static getAngleBetween(p1: Point, p2: Point, p3: Point): number {
|
|
37
|
+
const v1x = p1.x - p2.x, v1y = p1.y - p2.y;
|
|
38
|
+
const v2x = p3.x - p2.x, v2y = p3.y - p2.y;
|
|
39
|
+
const dot = v1x * v2x + v1y * v2y;
|
|
40
|
+
const mag1 = Math.sqrt(v1x * v1x + v1y * v1y);
|
|
41
|
+
const mag2 = Math.sqrt(v2x * v2x + v2y * v2y);
|
|
42
|
+
if (mag1 === 0 || mag2 === 0) return 0;
|
|
43
|
+
return Math.acos(Math.max(-1, Math.min(1, dot / (mag1 * mag2)))) * (180 / Math.PI);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Dải góc nội tứ giác (max - min). Nếu > 40° → quá méo, không phải tài liệu.
|
|
48
|
+
* Tham chiếu: andrewdcampbell/OpenCV-Document-Scanner MAX_QUAD_ANGLE_RANGE=40
|
|
49
|
+
*/
|
|
50
|
+
private static getAngleRange(quad: Point[]): number {
|
|
51
|
+
if (quad.length !== 4) return 999;
|
|
52
|
+
const [tl, tr, br, bl] = quad;
|
|
53
|
+
const angles = [
|
|
54
|
+
this.getAngleBetween(bl, tl, tr),
|
|
55
|
+
this.getAngleBetween(tl, tr, br),
|
|
56
|
+
this.getAngleBetween(tr, br, bl),
|
|
57
|
+
this.getAngleBetween(br, bl, tl),
|
|
58
|
+
];
|
|
59
|
+
return Math.max(...angles) - Math.min(...angles);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fallback: 4 góc của toàn bộ ảnh (như Python gốc khi không tìm được contour) */
|
|
63
|
+
private static buildFullImageCorners(w: number, h: number): Point[] {
|
|
64
|
+
return [
|
|
65
|
+
{ x: 0, y: 0 }, // top-left
|
|
66
|
+
{ x: w, y: 0 }, // top-right
|
|
67
|
+
{ x: w, y: h }, // bottom-right
|
|
68
|
+
{ x: 0, y: h }, // bottom-left
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
public static detectPageCorners(imageBase64: string): Point[] | undefined {
|
|
34
73
|
let src: OpenCVMat | null = null;
|
|
35
74
|
let resized: OpenCVMat | null = null;
|
|
@@ -37,7 +76,7 @@ export class DocumentScanner {
|
|
|
37
76
|
let blurred: OpenCVMat | null = null;
|
|
38
77
|
let closed: OpenCVMat | null = null;
|
|
39
78
|
let edges: OpenCVMat | null = null;
|
|
40
|
-
let
|
|
79
|
+
let dilated: OpenCVMat | null = null;
|
|
41
80
|
let contoursObj: any = null;
|
|
42
81
|
let hierarchyObj: any = null;
|
|
43
82
|
|
|
@@ -65,104 +104,183 @@ export class DocumentScanner {
|
|
|
65
104
|
gray = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
66
105
|
OpenCV.invoke('cvtColor', resized, gray, ColorConversionCodes.COLOR_BGR2GRAY);
|
|
67
106
|
|
|
68
|
-
// BƯỚC 2:
|
|
107
|
+
// BƯỚC 2: GaussianBlur 7x7 (theo andrewdcampbell)
|
|
69
108
|
blurred = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
70
109
|
const ksize = OpenCV.createObject(ObjectType.Size, 7, 7);
|
|
71
110
|
OpenCV.invoke('GaussianBlur', gray, blurred, ksize, 0);
|
|
72
111
|
|
|
73
|
-
// BƯỚC 3:
|
|
74
|
-
//
|
|
112
|
+
// BƯỚC 3: MORPH_CLOSE (kernel RECT 9x9) – nối các lỗ trống giữa cạnh viền
|
|
113
|
+
// Thiếu bước này là nguyên nhân chính khiến contour bị đứt đoạn
|
|
114
|
+
const MORPH_SIZE = 9;
|
|
115
|
+
const morphKernelSize = OpenCV.createObject(ObjectType.Size, MORPH_SIZE, MORPH_SIZE);
|
|
116
|
+
const morphKernel = OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, morphKernelSize);
|
|
75
117
|
closed = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
118
|
+
let morphCloseUsed = true;
|
|
119
|
+
try {
|
|
120
|
+
OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
|
|
121
|
+
} catch (_morphErr) {
|
|
122
|
+
closed = blurred; // Fallback nếu thiết bị không hỗ trợ
|
|
123
|
+
morphCloseUsed = false;
|
|
124
|
+
}
|
|
79
125
|
|
|
80
|
-
// BƯỚC 4:
|
|
81
|
-
//
|
|
126
|
+
// BƯỚC 4: Canny (0, 84) – ngưỡng thấp theo andrewdcampbell
|
|
127
|
+
// threshold1=0: chấp nhận TẤT CẢ cạnh yếu nếu liên kết với cạnh mạnh
|
|
128
|
+
// threshold2=84: ngưỡng cạnh mạnh vừa phải
|
|
82
129
|
edges = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
83
130
|
OpenCV.invoke('Canny', closed, edges, 0, 84);
|
|
84
131
|
|
|
85
|
-
// BƯỚC 5:
|
|
86
|
-
|
|
132
|
+
// BƯỚC 5: Dilate 3x3 – nối các pixel viền lân cận bị đứt
|
|
133
|
+
const dilKernelSize = OpenCV.createObject(ObjectType.Size, 3, 3);
|
|
134
|
+
const dilKernel = OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, dilKernelSize);
|
|
135
|
+
dilated = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
136
|
+
let dilateUsed = true;
|
|
137
|
+
try {
|
|
138
|
+
OpenCV.invoke('morphologyEx', edges, dilated, 1 /* MORPH_DILATE */, dilKernel);
|
|
139
|
+
} catch (_dilErr) {
|
|
140
|
+
dilated = edges; // Fallback
|
|
141
|
+
dilateUsed = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// BƯỚC 6: Tìm Contours — dùng RETR_EXTERNAL (đúng như Python gốc, không phải RETR_LIST)
|
|
87
145
|
contoursObj = OpenCV.createObject(ObjectType.MatVector);
|
|
88
146
|
hierarchyObj = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
89
|
-
OpenCV.invoke('findContoursWithHierarchy',
|
|
147
|
+
OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj,
|
|
148
|
+
0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
90
149
|
|
|
91
150
|
const contoursJS = OpenCV.toJSValue(contoursObj);
|
|
92
151
|
const contoursArray = contoursJS?.array || [];
|
|
93
152
|
const contoursSize = contoursArray.length;
|
|
94
153
|
|
|
154
|
+
const imgArea = targetWidth * targetHeight;
|
|
155
|
+
console.log(`[DocDetect] imgSize=${origCols}x${origRows} → resize=${targetWidth}x${targetHeight} | morphClose=${morphCloseUsed} dilate=${dilateUsed} | totalContours=${contoursSize}`);
|
|
156
|
+
|
|
95
157
|
if (contoursSize === 0) {
|
|
96
|
-
|
|
158
|
+
// Fallback: trả toàn bộ ảnh (như Python gốc)
|
|
159
|
+
console.log('[DocDetect] contoursSize=0, fallback to full image');
|
|
160
|
+
return this.buildFullImageCorners(origCols, origRows);
|
|
97
161
|
}
|
|
98
162
|
|
|
99
|
-
|
|
163
|
+
// Lấy top 5 contour lớn nhất (đúng như Python: cnts[:5])
|
|
164
|
+
const allContourMetrics = [];
|
|
165
|
+
let maxContourArea = 0;
|
|
100
166
|
for (let i = 0; i < contoursSize; i++) {
|
|
101
167
|
const contour = OpenCV.copyObjectFromVector(contoursObj, i);
|
|
102
168
|
const areaObj = OpenCV.invoke('contourArea', contour);
|
|
103
169
|
const area = areaObj ? areaObj.value : 0;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// Và loại bỏ những vùng quá lớn (ví dụ toàn bộ ảnh nền > 98%)
|
|
107
|
-
const imgArea = targetWidth * targetHeight;
|
|
108
|
-
if (area > (imgArea * 0.05) && area < (imgArea * 0.98)) {
|
|
109
|
-
contourMetrics.push({ index: i, area, contour });
|
|
110
|
-
}
|
|
170
|
+
if (area > maxContourArea) maxContourArea = area;
|
|
171
|
+
allContourMetrics.push({ index: i, area, contour });
|
|
111
172
|
}
|
|
173
|
+
allContourMetrics.sort((a, b) => b.area - a.area);
|
|
174
|
+
const top5 = allContourMetrics.slice(0, 5);
|
|
112
175
|
|
|
113
|
-
|
|
176
|
+
console.log(`[DocDetect] top5 areas: ${top5.map(m => m.area.toFixed(0)).join(', ')} | imgArea=${imgArea.toFixed(0)}`);
|
|
114
177
|
|
|
115
|
-
|
|
178
|
+
// MIN_QUAD_AREA_RATIO = 0.25 (Python gốc), với fallback 0.05 cho ảnh nhỏ
|
|
179
|
+
const MIN_QUAD_AREA_RATIO = imgArea > 50000 ? 0.25 : 0.05;
|
|
116
180
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
181
|
+
let bestPoly: Point[] | undefined = undefined;
|
|
182
|
+
let bestArea = 0;
|
|
183
|
+
let bestAngleRange = 999;
|
|
184
|
+
const debugCandidates: string[] = [];
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < top5.length; i++) {
|
|
187
|
+
const metric = top5[i];
|
|
120
188
|
const contour = metric.contour;
|
|
121
189
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
let
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (let
|
|
190
|
+
// Python gốc dùng epsilon = 80 pixel tuyệt đối (không phải % perimeter!)
|
|
191
|
+
// approxPolyDP(c, 80, True) — epsilon cố định
|
|
192
|
+
// Ta thử range 10→100 pixel, bước 10, để tìm ra 4 đỉnh
|
|
193
|
+
let foundPts: Point[] | null = null;
|
|
194
|
+
let foundVerts = 0;
|
|
195
|
+
let foundAngle = 999;
|
|
196
|
+
|
|
197
|
+
for (let epPx = 10; epPx <= 120; epPx += 5) {
|
|
130
198
|
const approx = OpenCV.createObject(ObjectType.PointVector);
|
|
131
|
-
OpenCV.invoke('approxPolyDP', contour, approx,
|
|
199
|
+
OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
|
|
132
200
|
const approxJS = OpenCV.toJSValue(approx);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
201
|
+
const verts = approxJS?.array?.length ?? 0;
|
|
202
|
+
|
|
203
|
+
if (verts === 4) {
|
|
204
|
+
const pts = approxJS.array as Point[];
|
|
205
|
+
const sorted = this.sortCorners([...pts]);
|
|
206
|
+
const angleRange = this.getAngleRange(sorted);
|
|
207
|
+
|
|
208
|
+
if (angleRange < foundAngle) {
|
|
209
|
+
foundAngle = angleRange;
|
|
210
|
+
foundPts = pts;
|
|
211
|
+
foundVerts = verts;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// is_valid_contour: area > MIN_QUAD_AREA_RATIO và angle_range < 40
|
|
215
|
+
if (metric.area > imgArea * MIN_QUAD_AREA_RATIO && angleRange < 40) {
|
|
216
|
+
if (metric.area > bestArea || (metric.area === bestArea && angleRange < bestAngleRange)) {
|
|
217
|
+
bestPoly = pts;
|
|
218
|
+
bestArea = metric.area;
|
|
219
|
+
bestAngleRange = angleRange;
|
|
143
220
|
}
|
|
144
|
-
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
145
223
|
}
|
|
146
224
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
225
|
+
|
|
226
|
+
debugCandidates.push(
|
|
227
|
+
` [C${i}] area=${metric.area.toFixed(0)}px²(${(metric.area/imgArea*100).toFixed(1)}%) ` +
|
|
228
|
+
`foundVerts=${foundVerts} bestAngle=${foundAngle === 999 ? 'N/A' : foundAngle.toFixed(1)}° ` +
|
|
229
|
+
`valid=${metric.area > imgArea * MIN_QUAD_AREA_RATIO && foundAngle < 40}`
|
|
230
|
+
);
|
|
151
231
|
}
|
|
152
232
|
|
|
153
|
-
|
|
154
|
-
|
|
233
|
+
console.log(`[DocDetect] Candidates:\n${debugCandidates.join('\n')}`);
|
|
234
|
+
console.log(`[DocDetect] Result: bestPoly=${bestPoly ? 'found' : 'NOT FOUND'} area=${bestArea.toFixed(0)} angleRange=${bestAngleRange === 999 ? 'N/A' : bestAngleRange.toFixed(1)}°`);
|
|
235
|
+
|
|
236
|
+
// BƯỚC 7: Trả về góc, scale về kích thước ảnh gốc
|
|
237
|
+
if (bestPoly && bestPoly.length === 4) {
|
|
155
238
|
const actualRatio = (resized === src) ? 1.0 : ratio;
|
|
156
|
-
const originalCorners =
|
|
239
|
+
const originalCorners = bestPoly.map(p => ({
|
|
157
240
|
x: Math.round(p.x * actualRatio),
|
|
158
241
|
y: Math.round(p.y * actualRatio)
|
|
159
242
|
}));
|
|
160
243
|
return this.sortCorners(originalCorners);
|
|
161
244
|
}
|
|
162
|
-
|
|
245
|
+
|
|
246
|
+
// Giống Python gốc: nếu không tìm được → dùng toàn bộ ảnh làm fallback
|
|
247
|
+
// "If we did not find any valid contours, just use the whole image"
|
|
248
|
+
console.log(`[DocDetect] No valid quad found (minArea=${(MIN_QUAD_AREA_RATIO*100).toFixed(0)}%, maxContour=${maxContourArea.toFixed(0)}px²). Fallback to full image.`);
|
|
249
|
+
throw new Error(
|
|
250
|
+
`Không phát hiện được viền tài liệu.\n` +
|
|
251
|
+
`Ảnh: ${origCols}x${origRows}px | Resize: ${targetWidth}x${targetHeight}px\n` +
|
|
252
|
+
`Contours kiểm tra: ${top5.length}/${contoursSize} | MinArea: ${(MIN_QUAD_AREA_RATIO*100).toFixed(0)}%\n` +
|
|
253
|
+
`Contour lớn nhất: ${maxContourArea.toFixed(0)}px² (${(maxContourArea/imgArea*100).toFixed(1)}% ảnh)\n` +
|
|
254
|
+
`Chi tiết:\n${debugCandidates.join('\n')}\n` +
|
|
255
|
+
`Gợi ý: Tài liệu cần chiếm ít nhất ${(MIN_QUAD_AREA_RATIO*100).toFixed(0)}% diện tích khung hình, viền phải tương phản với nền.`
|
|
256
|
+
);
|
|
163
257
|
} catch (e: any) {
|
|
164
|
-
|
|
165
|
-
|
|
258
|
+
// Trích message từ mọi dạng exception (Error object, string, native, undefined...)
|
|
259
|
+
let rawMsg: string;
|
|
260
|
+
if (typeof e === 'string') {
|
|
261
|
+
rawMsg = e;
|
|
262
|
+
} else if (e instanceof Error) {
|
|
263
|
+
rawMsg = e.message || e.toString();
|
|
264
|
+
} else if (e && typeof e === 'object') {
|
|
265
|
+
rawMsg = e.message || e.code || e.name || JSON.stringify(e);
|
|
266
|
+
} else {
|
|
267
|
+
rawMsg = String(e ?? 'Unknown error');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.error('[OpenCV] detectPageCorners exception:', rawMsg, e);
|
|
271
|
+
|
|
272
|
+
// Nếu đây là lỗi intentional từ chính hàm này (đã có thông tin chi tiết),
|
|
273
|
+
// chỉ wrap nhẹ để không mất ngữ cảnh.
|
|
274
|
+
// Nếu là lỗi native bất ngờ từ OpenCV, thêm context step.
|
|
275
|
+
const isIntentional = rawMsg.startsWith('Không ') || rawMsg.startsWith('[OpenCV Corner');
|
|
276
|
+
if (isIntentional) {
|
|
277
|
+
throw new Error(rawMsg);
|
|
278
|
+
} else {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Lỗi xử lý OpenCV không mong đợi: ${rawMsg}\n` +
|
|
281
|
+
`(Có thể do ảnh corrupt, định dạng không hỗ trợ, hoặc thiết bị thiếu bộ nhớ)`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
166
284
|
} finally {
|
|
167
285
|
OpenCV.clearBuffers();
|
|
168
286
|
}
|