rn-opencv-doc-perspective-correction 1.0.17 → 1.0.19
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 +2 -0
- package/dist/index.js +109 -43
- package/package.json +1 -1
- package/src/index.ts +123 -45
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export declare class DocumentScanner {
|
|
|
20
20
|
* Tham chiếu: andrewdcampbell/OpenCV-Document-Scanner MAX_QUAD_ANGLE_RANGE=40
|
|
21
21
|
*/
|
|
22
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;
|
|
23
25
|
static detectPageCorners(imageBase64: string): Point[] | undefined;
|
|
24
26
|
static applyPerspectiveCorrection(imageBase64: string, corners: Point[]): string | undefined;
|
|
25
27
|
/**
|
package/dist/index.js
CHANGED
|
@@ -46,7 +46,17 @@ class DocumentScanner {
|
|
|
46
46
|
];
|
|
47
47
|
return Math.max(...angles) - Math.min(...angles);
|
|
48
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
|
+
}
|
|
49
58
|
static detectPageCorners(imageBase64) {
|
|
59
|
+
var _a, _b, _c;
|
|
50
60
|
let src = null;
|
|
51
61
|
let resized = null;
|
|
52
62
|
let gray = null;
|
|
@@ -85,11 +95,13 @@ class DocumentScanner {
|
|
|
85
95
|
const morphKernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, MORPH_SIZE, MORPH_SIZE);
|
|
86
96
|
const morphKernel = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, morphKernelSize);
|
|
87
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);
|
|
98
|
+
let morphCloseUsed = true;
|
|
88
99
|
try {
|
|
89
100
|
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
|
|
90
101
|
}
|
|
91
102
|
catch (_morphErr) {
|
|
92
103
|
closed = blurred; // Fallback nếu thiết bị không hỗ trợ
|
|
104
|
+
morphCloseUsed = false;
|
|
93
105
|
}
|
|
94
106
|
// BƯỚC 4: Canny (0, 84) – ngưỡng thấp theo andrewdcampbell
|
|
95
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
|
|
@@ -100,75 +112,97 @@ class DocumentScanner {
|
|
|
100
112
|
const dilKernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 3, 3);
|
|
101
113
|
const dilKernel = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, dilKernelSize);
|
|
102
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;
|
|
103
116
|
try {
|
|
104
117
|
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', edges, dilated, 1 /* MORPH_DILATE */, dilKernel);
|
|
105
118
|
}
|
|
106
119
|
catch (_dilErr) {
|
|
107
120
|
dilated = edges; // Fallback
|
|
121
|
+
dilateUsed = false;
|
|
108
122
|
}
|
|
109
|
-
// BƯỚC 6: Tìm Contours
|
|
123
|
+
// BƯỚC 6: Tìm Contours — dùng RETR_EXTERNAL (đúng như Python gốc, không phải RETR_LIST)
|
|
110
124
|
contoursObj = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.MatVector);
|
|
111
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);
|
|
112
|
-
react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj,
|
|
126
|
+
react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj, 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
113
127
|
const contoursJS = react_native_fast_opencv_1.OpenCV.toJSValue(contoursObj);
|
|
114
128
|
const contoursArray = (contoursJS === null || contoursJS === void 0 ? void 0 : contoursJS.array) || [];
|
|
115
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}`);
|
|
116
132
|
if (contoursSize === 0) {
|
|
117
|
-
|
|
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);
|
|
118
136
|
}
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
const MIN_AREA_RATIO = 0.15;
|
|
137
|
+
// Lấy top 5 contour lớn nhất (đúng như Python: cnts[:5])
|
|
138
|
+
const allContourMetrics = [];
|
|
139
|
+
let maxContourArea = 0;
|
|
123
140
|
for (let i = 0; i < contoursSize; i++) {
|
|
124
141
|
const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contoursObj, i);
|
|
125
142
|
const areaObj = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour);
|
|
126
143
|
const area = areaObj ? areaObj.value : 0;
|
|
127
|
-
if (area >
|
|
128
|
-
|
|
129
|
-
}
|
|
144
|
+
if (area > maxContourArea)
|
|
145
|
+
maxContourArea = area;
|
|
146
|
+
allContourMetrics.push({ index: i, area, contour });
|
|
130
147
|
}
|
|
131
|
-
|
|
132
|
-
|
|
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;
|
|
133
153
|
let bestPoly = undefined;
|
|
134
154
|
let bestArea = 0;
|
|
135
155
|
let bestAngleRange = 999;
|
|
136
|
-
const
|
|
137
|
-
for (let i = 0; i <
|
|
138
|
-
const metric =
|
|
156
|
+
const debugCandidates = [];
|
|
157
|
+
for (let i = 0; i < top5.length; i++) {
|
|
158
|
+
const metric = top5[i];
|
|
139
159
|
const contour = metric.contour;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
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
|
+
let foundApproxArea = 0;
|
|
167
|
+
for (let epPx = 10; epPx <= 120; epPx += 5) {
|
|
144
168
|
const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
|
|
145
|
-
react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx,
|
|
169
|
+
react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
|
|
146
170
|
const approxJS = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
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;
|
|
172
|
+
if (verts === 4) {
|
|
173
|
+
const pts = approxJS.array;
|
|
174
|
+
const sorted = this.sortCorners([...pts]);
|
|
175
|
+
const angleRange = this.getAngleRange(sorted);
|
|
176
|
+
// ⚠️ QUAN TRỌNG: Đúng như Python gốc — kiểm tra area của APPROX POLYGON (4 điểm),
|
|
177
|
+
// KHÔNG phải area của raw contour (edge mỏng). Edge contour chỉ có area nhỏ
|
|
178
|
+
// (perimeter * lineWidth) nhưng polygon 4-điểm có area = diện tích tài liệu.
|
|
179
|
+
const approxAreaObj = react_native_fast_opencv_1.OpenCV.invoke('contourArea', approx);
|
|
180
|
+
const approxArea = approxAreaObj ? ((_c = approxAreaObj.value) !== null && _c !== void 0 ? _c : 0) : 0;
|
|
181
|
+
if (angleRange < foundAngle) {
|
|
182
|
+
foundAngle = angleRange;
|
|
183
|
+
foundPts = pts;
|
|
184
|
+
foundVerts = verts;
|
|
185
|
+
foundApproxArea = approxArea;
|
|
186
|
+
}
|
|
187
|
+
// is_valid_contour(approx): approxArea > MIN_QUAD_AREA_RATIO && angleRange < 40
|
|
188
|
+
if (approxArea > imgArea * MIN_QUAD_AREA_RATIO && angleRange < 40) {
|
|
189
|
+
if (approxArea > bestArea || (approxArea === bestArea && angleRange < bestAngleRange)) {
|
|
190
|
+
bestPoly = pts;
|
|
191
|
+
bestArea = approxArea;
|
|
192
|
+
bestAngleRange = angleRange;
|
|
165
193
|
}
|
|
194
|
+
break;
|
|
166
195
|
}
|
|
167
|
-
catch (_convexErr) { }
|
|
168
196
|
}
|
|
169
197
|
}
|
|
198
|
+
debugCandidates.push(` [C${i}] rawArea=${metric.area.toFixed(0)}px²(${(metric.area / imgArea * 100).toFixed(1)}%) ` +
|
|
199
|
+
`approxArea=${foundApproxArea > 0 ? foundApproxArea.toFixed(0) + 'px²(' + ((foundApproxArea / imgArea * 100).toFixed(1)) + '%)' : 'N/A'} ` +
|
|
200
|
+
`verts=${foundVerts} angle=${foundAngle === 999 ? 'N/A' : foundAngle.toFixed(1)}° ` +
|
|
201
|
+
`valid=${foundApproxArea > imgArea * MIN_QUAD_AREA_RATIO && foundAngle < 40}`);
|
|
170
202
|
}
|
|
171
|
-
|
|
203
|
+
console.log(`[DocDetect] Candidates:\n${debugCandidates.join('\n')}`);
|
|
204
|
+
console.log(`[DocDetect] Result: bestPoly=${bestPoly ? 'found' : 'NOT FOUND'} area=${bestArea.toFixed(0)} angleRange=${bestAngleRange === 999 ? 'N/A' : bestAngleRange.toFixed(1)}°`);
|
|
205
|
+
// BƯỚC 7: Trả về góc, scale về kích thước ảnh gốc
|
|
172
206
|
if (bestPoly && bestPoly.length === 4) {
|
|
173
207
|
const actualRatio = (resized === src) ? 1.0 : ratio;
|
|
174
208
|
const originalCorners = bestPoly.map(p => ({
|
|
@@ -177,11 +211,43 @@ class DocumentScanner {
|
|
|
177
211
|
}));
|
|
178
212
|
return this.sortCorners(originalCorners);
|
|
179
213
|
}
|
|
180
|
-
|
|
214
|
+
// Giống Python gốc: nếu không tìm được → dùng toàn bộ ảnh làm fallback
|
|
215
|
+
// "If we did not find any valid contours, just use the whole image"
|
|
216
|
+
console.log(`[DocDetect] No valid quad found (minArea=${(MIN_QUAD_AREA_RATIO * 100).toFixed(0)}%, maxContour=${maxContourArea.toFixed(0)}px²). Fallback to full image.`);
|
|
217
|
+
throw new Error(`Không phát hiện được viền tài liệu.\n` +
|
|
218
|
+
`Ảnh: ${origCols}x${origRows}px | Resize: ${targetWidth}x${targetHeight}px\n` +
|
|
219
|
+
`Contours kiểm tra: ${top5.length}/${contoursSize} | MinArea: ${(MIN_QUAD_AREA_RATIO * 100).toFixed(0)}%\n` +
|
|
220
|
+
`Contour lớn nhất: ${maxContourArea.toFixed(0)}px² (${(maxContourArea / imgArea * 100).toFixed(1)}% ảnh)\n` +
|
|
221
|
+
`Chi tiết:\n${debugCandidates.join('\n')}\n` +
|
|
222
|
+
`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.`);
|
|
181
223
|
}
|
|
182
224
|
catch (e) {
|
|
183
|
-
|
|
184
|
-
|
|
225
|
+
// Trích message từ mọi dạng exception (Error object, string, native, undefined...)
|
|
226
|
+
let rawMsg;
|
|
227
|
+
if (typeof e === 'string') {
|
|
228
|
+
rawMsg = e;
|
|
229
|
+
}
|
|
230
|
+
else if (e instanceof Error) {
|
|
231
|
+
rawMsg = e.message || e.toString();
|
|
232
|
+
}
|
|
233
|
+
else if (e && typeof e === 'object') {
|
|
234
|
+
rawMsg = e.message || e.code || e.name || JSON.stringify(e);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
rawMsg = String(e !== null && e !== void 0 ? e : 'Unknown error');
|
|
238
|
+
}
|
|
239
|
+
console.error('[OpenCV] detectPageCorners exception:', rawMsg, e);
|
|
240
|
+
// Nếu đây là lỗi intentional từ chính hàm này (đã có thông tin chi tiết),
|
|
241
|
+
// chỉ wrap nhẹ để không mất ngữ cảnh.
|
|
242
|
+
// Nếu là lỗi native bất ngờ từ OpenCV, thêm context step.
|
|
243
|
+
const isIntentional = rawMsg.startsWith('Không ') || rawMsg.startsWith('[OpenCV Corner');
|
|
244
|
+
if (isIntentional) {
|
|
245
|
+
throw new Error(rawMsg);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
throw new Error(`Lỗi xử lý OpenCV không mong đợi: ${rawMsg}\n` +
|
|
249
|
+
`(Có thể do ảnh corrupt, định dạng không hỗ trợ, hoặc thiết bị thiếu bộ nhớ)`);
|
|
250
|
+
}
|
|
185
251
|
}
|
|
186
252
|
finally {
|
|
187
253
|
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.19",
|
|
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
|
@@ -59,6 +59,16 @@ export class DocumentScanner {
|
|
|
59
59
|
return Math.max(...angles) - Math.min(...angles);
|
|
60
60
|
}
|
|
61
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
|
+
|
|
62
72
|
public static detectPageCorners(imageBase64: string): Point[] | undefined {
|
|
63
73
|
let src: OpenCVMat | null = null;
|
|
64
74
|
let resized: OpenCVMat | null = null;
|
|
@@ -105,10 +115,12 @@ export class DocumentScanner {
|
|
|
105
115
|
const morphKernelSize = OpenCV.createObject(ObjectType.Size, MORPH_SIZE, MORPH_SIZE);
|
|
106
116
|
const morphKernel = OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, morphKernelSize);
|
|
107
117
|
closed = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
118
|
+
let morphCloseUsed = true;
|
|
108
119
|
try {
|
|
109
120
|
OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
|
|
110
121
|
} catch (_morphErr) {
|
|
111
122
|
closed = blurred; // Fallback nếu thiết bị không hỗ trợ
|
|
123
|
+
morphCloseUsed = false;
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
// BƯỚC 4: Canny (0, 84) – ngưỡng thấp theo andrewdcampbell
|
|
@@ -121,85 +133,116 @@ export class DocumentScanner {
|
|
|
121
133
|
const dilKernelSize = OpenCV.createObject(ObjectType.Size, 3, 3);
|
|
122
134
|
const dilKernel = OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, dilKernelSize);
|
|
123
135
|
dilated = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
136
|
+
let dilateUsed = true;
|
|
124
137
|
try {
|
|
125
138
|
OpenCV.invoke('morphologyEx', edges, dilated, 1 /* MORPH_DILATE */, dilKernel);
|
|
126
139
|
} catch (_dilErr) {
|
|
127
140
|
dilated = edges; // Fallback
|
|
141
|
+
dilateUsed = false;
|
|
128
142
|
}
|
|
129
143
|
|
|
130
|
-
// BƯỚC 6: Tìm Contours
|
|
144
|
+
// BƯỚC 6: Tìm Contours — dùng RETR_EXTERNAL (đúng như Python gốc, không phải RETR_LIST)
|
|
131
145
|
contoursObj = OpenCV.createObject(ObjectType.MatVector);
|
|
132
146
|
hierarchyObj = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
133
|
-
OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj,
|
|
147
|
+
OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj,
|
|
148
|
+
0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
134
149
|
|
|
135
150
|
const contoursJS = OpenCV.toJSValue(contoursObj);
|
|
136
151
|
const contoursArray = contoursJS?.array || [];
|
|
137
152
|
const contoursSize = contoursArray.length;
|
|
138
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
|
+
|
|
139
157
|
if (contoursSize === 0) {
|
|
140
|
-
|
|
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);
|
|
141
161
|
}
|
|
142
162
|
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const MIN_AREA_RATIO = 0.15;
|
|
163
|
+
// Lấy top 5 contour lớn nhất (đúng như Python: cnts[:5])
|
|
164
|
+
const allContourMetrics = [];
|
|
165
|
+
let maxContourArea = 0;
|
|
147
166
|
for (let i = 0; i < contoursSize; i++) {
|
|
148
167
|
const contour = OpenCV.copyObjectFromVector(contoursObj, i);
|
|
149
168
|
const areaObj = OpenCV.invoke('contourArea', contour);
|
|
150
169
|
const area = areaObj ? areaObj.value : 0;
|
|
151
|
-
if (area >
|
|
152
|
-
|
|
153
|
-
}
|
|
170
|
+
if (area > maxContourArea) maxContourArea = area;
|
|
171
|
+
allContourMetrics.push({ index: i, area, contour });
|
|
154
172
|
}
|
|
173
|
+
allContourMetrics.sort((a, b) => b.area - a.area);
|
|
174
|
+
const top5 = allContourMetrics.slice(0, 5);
|
|
175
|
+
|
|
176
|
+
console.log(`[DocDetect] top5 areas: ${top5.map(m => m.area.toFixed(0)).join(', ')} | imgArea=${imgArea.toFixed(0)}`);
|
|
155
177
|
|
|
156
|
-
|
|
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;
|
|
157
180
|
|
|
158
|
-
// Thu thập TẤT CẢ candidates hợp lệ, chọn tốt nhất (không dừng sớm)
|
|
159
181
|
let bestPoly: Point[] | undefined = undefined;
|
|
160
182
|
let bestArea = 0;
|
|
161
183
|
let bestAngleRange = 999;
|
|
184
|
+
const debugCandidates: string[] = [];
|
|
162
185
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const metric = contourMetrics[i];
|
|
186
|
+
for (let i = 0; i < top5.length; i++) {
|
|
187
|
+
const metric = top5[i];
|
|
166
188
|
const contour = metric.contour;
|
|
167
189
|
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
let foundApproxArea = 0;
|
|
170
197
|
|
|
171
|
-
|
|
172
|
-
for (let ep = 0.02; ep <= 0.1; ep += 0.01) {
|
|
198
|
+
for (let epPx = 10; epPx <= 120; epPx += 5) {
|
|
173
199
|
const approx = OpenCV.createObject(ObjectType.PointVector);
|
|
174
|
-
OpenCV.invoke('approxPolyDP', contour, approx,
|
|
200
|
+
OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
|
|
175
201
|
const approxJS = OpenCV.toJSValue(approx);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
202
|
+
const verts = approxJS?.array?.length ?? 0;
|
|
203
|
+
|
|
204
|
+
if (verts === 4) {
|
|
205
|
+
const pts = approxJS.array as Point[];
|
|
206
|
+
const sorted = this.sortCorners([...pts]);
|
|
207
|
+
const angleRange = this.getAngleRange(sorted);
|
|
208
|
+
|
|
209
|
+
// ⚠️ QUAN TRỌNG: Đúng như Python gốc — kiểm tra area của APPROX POLYGON (4 điểm),
|
|
210
|
+
// KHÔNG phải area của raw contour (edge mỏng). Edge contour chỉ có area nhỏ
|
|
211
|
+
// (perimeter * lineWidth) nhưng polygon 4-điểm có area = diện tích tài liệu.
|
|
212
|
+
const approxAreaObj = OpenCV.invoke('contourArea', approx);
|
|
213
|
+
const approxArea = approxAreaObj ? (approxAreaObj.value ?? 0) : 0;
|
|
214
|
+
|
|
215
|
+
if (angleRange < foundAngle) {
|
|
216
|
+
foundAngle = angleRange;
|
|
217
|
+
foundPts = pts;
|
|
218
|
+
foundVerts = verts;
|
|
219
|
+
foundApproxArea = approxArea;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// is_valid_contour(approx): approxArea > MIN_QUAD_AREA_RATIO && angleRange < 40
|
|
223
|
+
if (approxArea > imgArea * MIN_QUAD_AREA_RATIO && angleRange < 40) {
|
|
224
|
+
if (approxArea > bestArea || (approxArea === bestArea && angleRange < bestAngleRange)) {
|
|
225
|
+
bestPoly = pts;
|
|
226
|
+
bestArea = approxArea;
|
|
227
|
+
bestAngleRange = angleRange;
|
|
196
228
|
}
|
|
197
|
-
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
198
231
|
}
|
|
199
232
|
}
|
|
233
|
+
|
|
234
|
+
debugCandidates.push(
|
|
235
|
+
` [C${i}] rawArea=${metric.area.toFixed(0)}px²(${(metric.area/imgArea*100).toFixed(1)}%) ` +
|
|
236
|
+
`approxArea=${foundApproxArea > 0 ? foundApproxArea.toFixed(0)+'px²('+((foundApproxArea/imgArea*100).toFixed(1))+'%)' : 'N/A'} ` +
|
|
237
|
+
`verts=${foundVerts} angle=${foundAngle === 999 ? 'N/A' : foundAngle.toFixed(1)}° ` +
|
|
238
|
+
`valid=${foundApproxArea > imgArea * MIN_QUAD_AREA_RATIO && foundAngle < 40}`
|
|
239
|
+
);
|
|
200
240
|
}
|
|
201
241
|
|
|
202
|
-
|
|
242
|
+
console.log(`[DocDetect] Candidates:\n${debugCandidates.join('\n')}`);
|
|
243
|
+
console.log(`[DocDetect] Result: bestPoly=${bestPoly ? 'found' : 'NOT FOUND'} area=${bestArea.toFixed(0)} angleRange=${bestAngleRange === 999 ? 'N/A' : bestAngleRange.toFixed(1)}°`);
|
|
244
|
+
|
|
245
|
+
// BƯỚC 7: Trả về góc, scale về kích thước ảnh gốc
|
|
203
246
|
if (bestPoly && bestPoly.length === 4) {
|
|
204
247
|
const actualRatio = (resized === src) ? 1.0 : ratio;
|
|
205
248
|
const originalCorners = bestPoly.map(p => ({
|
|
@@ -208,10 +251,45 @@ export class DocumentScanner {
|
|
|
208
251
|
}));
|
|
209
252
|
return this.sortCorners(originalCorners);
|
|
210
253
|
}
|
|
211
|
-
|
|
254
|
+
|
|
255
|
+
// Giống Python gốc: nếu không tìm được → dùng toàn bộ ảnh làm fallback
|
|
256
|
+
// "If we did not find any valid contours, just use the whole image"
|
|
257
|
+
console.log(`[DocDetect] No valid quad found (minArea=${(MIN_QUAD_AREA_RATIO*100).toFixed(0)}%, maxContour=${maxContourArea.toFixed(0)}px²). Fallback to full image.`);
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Không phát hiện được viền tài liệu.\n` +
|
|
260
|
+
`Ảnh: ${origCols}x${origRows}px | Resize: ${targetWidth}x${targetHeight}px\n` +
|
|
261
|
+
`Contours kiểm tra: ${top5.length}/${contoursSize} | MinArea: ${(MIN_QUAD_AREA_RATIO*100).toFixed(0)}%\n` +
|
|
262
|
+
`Contour lớn nhất: ${maxContourArea.toFixed(0)}px² (${(maxContourArea/imgArea*100).toFixed(1)}% ảnh)\n` +
|
|
263
|
+
`Chi tiết:\n${debugCandidates.join('\n')}\n` +
|
|
264
|
+
`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.`
|
|
265
|
+
);
|
|
212
266
|
} catch (e: any) {
|
|
213
|
-
|
|
214
|
-
|
|
267
|
+
// Trích message từ mọi dạng exception (Error object, string, native, undefined...)
|
|
268
|
+
let rawMsg: string;
|
|
269
|
+
if (typeof e === 'string') {
|
|
270
|
+
rawMsg = e;
|
|
271
|
+
} else if (e instanceof Error) {
|
|
272
|
+
rawMsg = e.message || e.toString();
|
|
273
|
+
} else if (e && typeof e === 'object') {
|
|
274
|
+
rawMsg = e.message || e.code || e.name || JSON.stringify(e);
|
|
275
|
+
} else {
|
|
276
|
+
rawMsg = String(e ?? 'Unknown error');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.error('[OpenCV] detectPageCorners exception:', rawMsg, e);
|
|
280
|
+
|
|
281
|
+
// Nếu đây là lỗi intentional từ chính hàm này (đã có thông tin chi tiết),
|
|
282
|
+
// chỉ wrap nhẹ để không mất ngữ cảnh.
|
|
283
|
+
// Nếu là lỗi native bất ngờ từ OpenCV, thêm context step.
|
|
284
|
+
const isIntentional = rawMsg.startsWith('Không ') || rawMsg.startsWith('[OpenCV Corner');
|
|
285
|
+
if (isIntentional) {
|
|
286
|
+
throw new Error(rawMsg);
|
|
287
|
+
} else {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Lỗi xử lý OpenCV không mong đợi: ${rawMsg}\n` +
|
|
290
|
+
`(Có thể do ảnh corrupt, định dạng không hỗ trợ, hoặc thiết bị thiếu bộ nhớ)`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
215
293
|
} finally {
|
|
216
294
|
OpenCV.clearBuffers();
|
|
217
295
|
}
|