rn-opencv-doc-perspective-correction 1.0.19 → 1.0.20
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 +5 -0
- package/dist/index.js +199 -134
- package/package.json +1 -1
- package/src/index.ts +234 -154
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export declare class DocumentScanner {
|
|
|
22
22
|
private static getAngleRange;
|
|
23
23
|
/** Fallback: 4 góc của toàn bộ ảnh (như Python gốc khi không tìm được contour) */
|
|
24
24
|
private static buildFullImageCorners;
|
|
25
|
+
/**
|
|
26
|
+
* Tìm tứ giác tốt nhất từ một MatVector contours.
|
|
27
|
+
* Trả về { poly, area, angleRange } hoặc null nếu không tìm được.
|
|
28
|
+
*/
|
|
29
|
+
private static findBestQuad;
|
|
25
30
|
static detectPageCorners(imageBase64: string): Point[] | undefined;
|
|
26
31
|
static applyPerspectiveCorrection(imageBase64: string, corners: Point[]): string | undefined;
|
|
27
32
|
/**
|
package/dist/index.js
CHANGED
|
@@ -55,171 +55,236 @@ class DocumentScanner {
|
|
|
55
55
|
{ x: 0, y: h }, // bottom-left
|
|
56
56
|
];
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Tìm tứ giác tốt nhất từ một MatVector contours.
|
|
60
|
+
* Trả về { poly, area, angleRange } hoặc null nếu không tìm được.
|
|
61
|
+
*/
|
|
62
|
+
static findBestQuad(contoursObj, contoursSize, imgArea, minAreaRatio, pipelineName) {
|
|
59
63
|
var _a, _b, _c;
|
|
60
|
-
|
|
61
|
-
let
|
|
62
|
-
let
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
const allMetrics = [];
|
|
65
|
+
let maxRawArea = 0;
|
|
66
|
+
for (let i = 0; i < contoursSize; i++) {
|
|
67
|
+
const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contoursObj, i);
|
|
68
|
+
const areaObj = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour);
|
|
69
|
+
const area = areaObj ? areaObj.value : 0;
|
|
70
|
+
if (area > maxRawArea)
|
|
71
|
+
maxRawArea = area;
|
|
72
|
+
allMetrics.push({ area, contour });
|
|
73
|
+
}
|
|
74
|
+
allMetrics.sort((a, b) => b.area - a.area);
|
|
75
|
+
const top = allMetrics.slice(0, 5);
|
|
76
|
+
let bestPoly;
|
|
77
|
+
let bestArea = 0;
|
|
78
|
+
let bestAngle = 999;
|
|
79
|
+
const debug = [];
|
|
80
|
+
for (let i = 0; i < top.length; i++) {
|
|
81
|
+
const m = top[i];
|
|
82
|
+
// Thử nhiều epsilon (10→120px) để approxPolyDP ra đúng 4 đỉnh
|
|
83
|
+
let fPts = null;
|
|
84
|
+
let fVerts = 0;
|
|
85
|
+
let fAngle = 999;
|
|
86
|
+
let fApproxArea = 0;
|
|
87
|
+
for (let ep = 10; ep <= 120; ep += 5) {
|
|
88
|
+
const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
|
|
89
|
+
react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', m.contour, approx, ep, true);
|
|
90
|
+
const approxJS = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
|
|
91
|
+
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;
|
|
92
|
+
if (verts === 4) {
|
|
93
|
+
const pts = approxJS.array;
|
|
94
|
+
const sorted = this.sortCorners([...pts]);
|
|
95
|
+
const angleRange = this.getAngleRange(sorted);
|
|
96
|
+
// Lấy diện tích polygon 4-điểm (đúng như Python: contourArea(approx))
|
|
97
|
+
const aObj = react_native_fast_opencv_1.OpenCV.invoke('contourArea', approx);
|
|
98
|
+
const approxArea = aObj ? ((_c = aObj.value) !== null && _c !== void 0 ? _c : 0) : 0;
|
|
99
|
+
if (angleRange < fAngle) {
|
|
100
|
+
fAngle = angleRange;
|
|
101
|
+
fPts = pts;
|
|
102
|
+
fVerts = verts;
|
|
103
|
+
fApproxArea = approxArea;
|
|
104
|
+
}
|
|
105
|
+
if (approxArea > imgArea * minAreaRatio && angleRange < 40) {
|
|
106
|
+
if (approxArea > bestArea || (approxArea === bestArea && angleRange < bestAngle)) {
|
|
107
|
+
bestPoly = pts;
|
|
108
|
+
bestArea = approxArea;
|
|
109
|
+
bestAngle = angleRange;
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
debug.push(` [${pipelineName}:C${i}] raw=${m.area.toFixed(0)}(${(m.area / imgArea * 100).toFixed(1)}%) ` +
|
|
116
|
+
`approx=${fApproxArea > 0 ? fApproxArea.toFixed(0) + '(' + ((fApproxArea / imgArea * 100).toFixed(1)) + '%)' : '-'} ` +
|
|
117
|
+
`v=${fVerts} a=${fAngle === 999 ? '-' : fAngle.toFixed(1)}° ` +
|
|
118
|
+
`ok=${fApproxArea > imgArea * minAreaRatio && fAngle < 40}`);
|
|
119
|
+
}
|
|
120
|
+
if (bestPoly && bestPoly.length === 4) {
|
|
121
|
+
return { poly: bestPoly, area: bestArea, angleRange: bestAngle, debug };
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
static detectPageCorners(imageBase64) {
|
|
126
|
+
var _a, _b, _c, _d, _e, _f;
|
|
69
127
|
try {
|
|
70
|
-
src = react_native_fast_opencv_1.OpenCV.base64ToMat(imageBase64);
|
|
128
|
+
const src = react_native_fast_opencv_1.OpenCV.base64ToMat(imageBase64);
|
|
71
129
|
const jsSrc = react_native_fast_opencv_1.OpenCV.toJSValue(src);
|
|
72
130
|
const origCols = jsSrc.cols || 1000;
|
|
73
131
|
const origRows = jsSrc.rows || 1000;
|
|
74
132
|
const targetHeight = 500.0;
|
|
75
133
|
const ratio = origRows / targetHeight;
|
|
76
134
|
const targetWidth = Math.max(1, Math.round(origCols / ratio));
|
|
77
|
-
resized = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
135
|
+
const resized = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
78
136
|
const dstSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, targetWidth, targetHeight);
|
|
79
137
|
try {
|
|
80
138
|
react_native_fast_opencv_1.OpenCV.invoke('resize', src, resized, dstSize, 0, 0, 1 /* INTER_LINEAR */);
|
|
81
139
|
}
|
|
82
|
-
catch (
|
|
83
|
-
|
|
140
|
+
catch (_) {
|
|
141
|
+
// nếu resize fail, dùng src gốc — ratio=1
|
|
84
142
|
}
|
|
85
|
-
//
|
|
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);
|
|
143
|
+
// Grayscale + Blur
|
|
144
|
+
const 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);
|
|
87
145
|
react_native_fast_opencv_1.OpenCV.invoke('cvtColor', resized, gray, react_native_fast_opencv_1.ColorConversionCodes.COLOR_BGR2GRAY);
|
|
88
|
-
|
|
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);
|
|
146
|
+
const 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);
|
|
90
147
|
const ksize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 7, 7);
|
|
91
148
|
react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', gray, blurred, ksize, 0);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
149
|
+
const imgArea = targetWidth * targetHeight;
|
|
150
|
+
const MIN_AREA = 0.15; // 15% — khoan dung hơn Python gốc (25%)
|
|
151
|
+
const allDebug = [];
|
|
152
|
+
let winner = null;
|
|
153
|
+
// ═══════════════════════════════════════════════════════════════
|
|
154
|
+
// PIPELINE 1: Adaptive Threshold → contour KÍN (method chính)
|
|
155
|
+
//
|
|
156
|
+
// Adaptive threshold tạo ảnh binary phân biệt foreground/background.
|
|
157
|
+
// findContours trên binary image cho contour KÍN → area = diện tích thật.
|
|
158
|
+
// Đây thay thế LSD line detection (không có trong react-native-fast-opencv).
|
|
159
|
+
// ═══════════════════════════════════════════════════════════════
|
|
99
160
|
try {
|
|
100
|
-
react_native_fast_opencv_1.OpenCV.
|
|
161
|
+
const adaptive = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
162
|
+
// ADAPTIVE_THRESH_GAUSSIAN_C = 1, THRESH_BINARY_INV = 1
|
|
163
|
+
// blockSize=15 (must be odd), C=10
|
|
164
|
+
react_native_fast_opencv_1.OpenCV.invoke('adaptiveThreshold', blurred, adaptive, 255, 1 /* GAUSSIAN_C */, 1 /* BINARY_INV */, 15, 10);
|
|
165
|
+
// MORPH_CLOSE để nối vùng tài liệu lại — kernel lớn 15x15
|
|
166
|
+
const closeKSz = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 15, 15);
|
|
167
|
+
const closeK = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* RECT */, closeKSz);
|
|
168
|
+
const adaptClosed = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
169
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', adaptive, adaptClosed, 3 /* MORPH_CLOSE */, closeK);
|
|
170
|
+
const cnt1 = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.MatVector);
|
|
171
|
+
const hier1 = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
172
|
+
react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', adaptClosed, cnt1, hier1, 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
173
|
+
const js1 = react_native_fast_opencv_1.OpenCV.toJSValue(cnt1);
|
|
174
|
+
const sz1 = (_b = (_a = js1 === null || js1 === void 0 ? void 0 : js1.array) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
|
|
175
|
+
console.log(`[DocDetect] P1(AdaptThresh): ${sz1} contours`);
|
|
176
|
+
if (sz1 > 0) {
|
|
177
|
+
const r1 = this.findBestQuad(cnt1, sz1, imgArea, MIN_AREA, 'P1');
|
|
178
|
+
if (r1) {
|
|
179
|
+
allDebug.push(...r1.debug);
|
|
180
|
+
if (!winner || r1.area > winner.area) {
|
|
181
|
+
winner = { poly: r1.poly, area: r1.area, angleRange: r1.angleRange };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (r1 === null) {
|
|
185
|
+
// Ghi debug dù không tìm được
|
|
186
|
+
}
|
|
187
|
+
}
|
|
101
188
|
}
|
|
102
|
-
catch (
|
|
103
|
-
|
|
104
|
-
morphCloseUsed = false;
|
|
189
|
+
catch (p1Err) {
|
|
190
|
+
console.log('[DocDetect] P1 failed:', p1Err);
|
|
105
191
|
}
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
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;
|
|
192
|
+
// ═══════════════════════════════════════════════════════════════
|
|
193
|
+
// PIPELINE 2: Canny (giống Python gốc andrewdcampbell)
|
|
194
|
+
//
|
|
195
|
+
// MORPH_CLOSE trên grayscale → Canny → dilate mạnh → contours.
|
|
196
|
+
// Dilate 7x7 x2 iterations để NỐI edge fragments thành contour kín.
|
|
197
|
+
// ═══════════════════════════════════════════════════════════════
|
|
116
198
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
199
|
+
// MORPH_CLOSE 9x9 trên grayscale (đúng Python)
|
|
200
|
+
const morphSz = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 9, 9);
|
|
201
|
+
const morphK = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* RECT */, morphSz);
|
|
202
|
+
const morphed = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
203
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', blurred, morphed, 3 /* MORPH_CLOSE */, morphK);
|
|
204
|
+
// Canny (0, 84)
|
|
205
|
+
const cannyOut = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
206
|
+
react_native_fast_opencv_1.OpenCV.invoke('Canny', morphed, cannyOut, 0, 84);
|
|
207
|
+
// Dilate MẠNH: kernel 7x7 — nối edge fragments lại thành contour kín
|
|
208
|
+
const dilSz = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 7, 7);
|
|
209
|
+
const dilK = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* RECT */, dilSz);
|
|
210
|
+
const dilOut = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
211
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', cannyOut, dilOut, 1 /* MORPH_DILATE */, dilK);
|
|
212
|
+
// MORPH_CLOSE lần nữa trên edge image để đóng gap
|
|
213
|
+
const closeSz2 = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 11, 11);
|
|
214
|
+
const closeK2 = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* RECT */, closeSz2);
|
|
215
|
+
const closedEdge = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
216
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', dilOut, closedEdge, 3 /* MORPH_CLOSE */, closeK2);
|
|
217
|
+
const cnt2 = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.MatVector);
|
|
218
|
+
const hier2 = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
219
|
+
react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', closedEdge, cnt2, hier2, 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
220
|
+
const js2 = react_native_fast_opencv_1.OpenCV.toJSValue(cnt2);
|
|
221
|
+
const sz2 = (_d = (_c = js2 === null || js2 === void 0 ? void 0 : js2.array) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0;
|
|
222
|
+
console.log(`[DocDetect] P2(CannyStrong): ${sz2} contours`);
|
|
223
|
+
if (sz2 > 0) {
|
|
224
|
+
const r2 = this.findBestQuad(cnt2, sz2, imgArea, MIN_AREA, 'P2');
|
|
225
|
+
if (r2) {
|
|
226
|
+
allDebug.push(...r2.debug);
|
|
227
|
+
if (!winner || r2.area > winner.area) {
|
|
228
|
+
winner = { poly: r2.poly, area: r2.area, angleRange: r2.angleRange };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
136
232
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
let maxContourArea = 0;
|
|
140
|
-
for (let i = 0; i < contoursSize; i++) {
|
|
141
|
-
const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contoursObj, i);
|
|
142
|
-
const areaObj = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour);
|
|
143
|
-
const area = areaObj ? areaObj.value : 0;
|
|
144
|
-
if (area > maxContourArea)
|
|
145
|
-
maxContourArea = area;
|
|
146
|
-
allContourMetrics.push({ index: i, area, contour });
|
|
233
|
+
catch (p2Err) {
|
|
234
|
+
console.log('[DocDetect] P2 failed:', p2Err);
|
|
147
235
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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;
|
|
193
|
-
}
|
|
194
|
-
break;
|
|
236
|
+
// ═══════════════════════════════════════════════════════════════
|
|
237
|
+
// PIPELINE 3: OTSU threshold (binary toàn cục)
|
|
238
|
+
//
|
|
239
|
+
// Cho ảnh có tương phản rõ giữa tài liệu và nền.
|
|
240
|
+
// ═══════════════════════════════════════════════════════════════
|
|
241
|
+
try {
|
|
242
|
+
const otsu = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
243
|
+
// threshold type = THRESH_BINARY_INV + THRESH_OTSU = 1 + 8 = 9
|
|
244
|
+
react_native_fast_opencv_1.OpenCV.invoke('threshold', blurred, otsu, 0, 255, 9);
|
|
245
|
+
// MORPH_CLOSE lớn để nối vùng
|
|
246
|
+
const oCloseSz = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 15, 15);
|
|
247
|
+
const oCloseK = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* RECT */, oCloseSz);
|
|
248
|
+
const otsuClosed = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
249
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', otsu, otsuClosed, 3 /* MORPH_CLOSE */, oCloseK);
|
|
250
|
+
const cnt3 = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.MatVector);
|
|
251
|
+
const hier3 = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
|
|
252
|
+
react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', otsuClosed, cnt3, hier3, 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
253
|
+
const js3 = react_native_fast_opencv_1.OpenCV.toJSValue(cnt3);
|
|
254
|
+
const sz3 = (_f = (_e = js3 === null || js3 === void 0 ? void 0 : js3.array) === null || _e === void 0 ? void 0 : _e.length) !== null && _f !== void 0 ? _f : 0;
|
|
255
|
+
console.log(`[DocDetect] P3(OTSU): ${sz3} contours`);
|
|
256
|
+
if (sz3 > 0) {
|
|
257
|
+
const r3 = this.findBestQuad(cnt3, sz3, imgArea, MIN_AREA, 'P3');
|
|
258
|
+
if (r3) {
|
|
259
|
+
allDebug.push(...r3.debug);
|
|
260
|
+
if (!winner || r3.area > winner.area) {
|
|
261
|
+
winner = { poly: r3.poly, area: r3.area, angleRange: r3.angleRange };
|
|
195
262
|
}
|
|
196
263
|
}
|
|
197
264
|
}
|
|
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}`);
|
|
202
265
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
266
|
+
catch (p3Err) {
|
|
267
|
+
console.log('[DocDetect] P3 failed:', p3Err);
|
|
268
|
+
}
|
|
269
|
+
// ═══════════════════════════════════════════════════════════════
|
|
270
|
+
// Trả kết quả
|
|
271
|
+
// ═══════════════════════════════════════════════════════════════
|
|
272
|
+
console.log(`[DocDetect] All debug:\n${allDebug.join('\n')}`);
|
|
273
|
+
if (winner) {
|
|
274
|
+
console.log(`[DocDetect] ✓ Winner: area=${winner.area.toFixed(0)}(${(winner.area / imgArea * 100).toFixed(1)}%) angle=${winner.angleRange.toFixed(1)}°`);
|
|
275
|
+
const originalCorners = winner.poly.map(p => ({
|
|
276
|
+
x: Math.round(p.x * ratio),
|
|
277
|
+
y: Math.round(p.y * ratio)
|
|
211
278
|
}));
|
|
212
279
|
return this.sortCorners(originalCorners);
|
|
213
280
|
}
|
|
214
|
-
//
|
|
215
|
-
|
|
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.`);
|
|
281
|
+
// Không pipeline nào thành công
|
|
282
|
+
console.log('[DocDetect] ✗ All pipelines failed');
|
|
217
283
|
throw new Error(`Không phát hiện được viền tài liệu.\n` +
|
|
218
284
|
`Ảnh: ${origCols}x${origRows}px | Resize: ${targetWidth}x${targetHeight}px\n` +
|
|
219
|
-
`
|
|
220
|
-
`
|
|
221
|
-
`
|
|
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.`);
|
|
285
|
+
`MinArea: ${(MIN_AREA * 100).toFixed(0)}%\n` +
|
|
286
|
+
`Chi tiết:\n${allDebug.join('\n')}\n` +
|
|
287
|
+
`Gợi ý: Hãy đảm bảo tài liệu chiếm ≥15% khung hình, viền rõ ràng, nền tương phản.`);
|
|
223
288
|
}
|
|
224
289
|
catch (e) {
|
|
225
290
|
// Trích message từ mọi dạng exception (Error object, string, native, undefined...)
|
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.20",
|
|
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
|
@@ -69,20 +69,94 @@ export class DocumentScanner {
|
|
|
69
69
|
];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Tìm tứ giác tốt nhất từ một MatVector contours.
|
|
74
|
+
* Trả về { poly, area, angleRange } hoặc null nếu không tìm được.
|
|
75
|
+
*/
|
|
76
|
+
private static findBestQuad(
|
|
77
|
+
contoursObj: any,
|
|
78
|
+
contoursSize: number,
|
|
79
|
+
imgArea: number,
|
|
80
|
+
minAreaRatio: number,
|
|
81
|
+
pipelineName: string,
|
|
82
|
+
): { poly: Point[]; area: number; angleRange: number; debug: string[] } | null {
|
|
83
|
+
const allMetrics: { area: number; contour: any }[] = [];
|
|
84
|
+
let maxRawArea = 0;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < contoursSize; i++) {
|
|
87
|
+
const contour = OpenCV.copyObjectFromVector(contoursObj, i);
|
|
88
|
+
const areaObj = OpenCV.invoke('contourArea', contour);
|
|
89
|
+
const area = areaObj ? areaObj.value : 0;
|
|
90
|
+
if (area > maxRawArea) maxRawArea = area;
|
|
91
|
+
allMetrics.push({ area, contour });
|
|
92
|
+
}
|
|
93
|
+
allMetrics.sort((a, b) => b.area - a.area);
|
|
94
|
+
const top = allMetrics.slice(0, 5);
|
|
95
|
+
|
|
96
|
+
let bestPoly: Point[] | undefined;
|
|
97
|
+
let bestArea = 0;
|
|
98
|
+
let bestAngle = 999;
|
|
99
|
+
const debug: string[] = [];
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < top.length; i++) {
|
|
102
|
+
const m = top[i];
|
|
103
|
+
|
|
104
|
+
// Thử nhiều epsilon (10→120px) để approxPolyDP ra đúng 4 đỉnh
|
|
105
|
+
let fPts: Point[] | null = null;
|
|
106
|
+
let fVerts = 0;
|
|
107
|
+
let fAngle = 999;
|
|
108
|
+
let fApproxArea = 0;
|
|
109
|
+
|
|
110
|
+
for (let ep = 10; ep <= 120; ep += 5) {
|
|
111
|
+
const approx = OpenCV.createObject(ObjectType.PointVector);
|
|
112
|
+
OpenCV.invoke('approxPolyDP', m.contour, approx, ep, true);
|
|
113
|
+
const approxJS = OpenCV.toJSValue(approx);
|
|
114
|
+
const verts = approxJS?.array?.length ?? 0;
|
|
115
|
+
|
|
116
|
+
if (verts === 4) {
|
|
117
|
+
const pts = approxJS.array as Point[];
|
|
118
|
+
const sorted = this.sortCorners([...pts]);
|
|
119
|
+
const angleRange = this.getAngleRange(sorted);
|
|
120
|
+
|
|
121
|
+
// Lấy diện tích polygon 4-điểm (đúng như Python: contourArea(approx))
|
|
122
|
+
const aObj = OpenCV.invoke('contourArea', approx);
|
|
123
|
+
const approxArea = aObj ? (aObj.value ?? 0) : 0;
|
|
124
|
+
|
|
125
|
+
if (angleRange < fAngle) {
|
|
126
|
+
fAngle = angleRange;
|
|
127
|
+
fPts = pts;
|
|
128
|
+
fVerts = verts;
|
|
129
|
+
fApproxArea = approxArea;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (approxArea > imgArea * minAreaRatio && angleRange < 40) {
|
|
133
|
+
if (approxArea > bestArea || (approxArea === bestArea && angleRange < bestAngle)) {
|
|
134
|
+
bestPoly = pts;
|
|
135
|
+
bestArea = approxArea;
|
|
136
|
+
bestAngle = angleRange;
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
debug.push(
|
|
144
|
+
` [${pipelineName}:C${i}] raw=${m.area.toFixed(0)}(${(m.area/imgArea*100).toFixed(1)}%) ` +
|
|
145
|
+
`approx=${fApproxArea > 0 ? fApproxArea.toFixed(0)+'('+((fApproxArea/imgArea*100).toFixed(1))+'%)' : '-'} ` +
|
|
146
|
+
`v=${fVerts} a=${fAngle === 999 ? '-' : fAngle.toFixed(1)}° ` +
|
|
147
|
+
`ok=${fApproxArea > imgArea * minAreaRatio && fAngle < 40}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (bestPoly && bestPoly.length === 4) {
|
|
152
|
+
return { poly: bestPoly, area: bestArea, angleRange: bestAngle, debug };
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
82
156
|
|
|
157
|
+
public static detectPageCorners(imageBase64: string): Point[] | undefined {
|
|
83
158
|
try {
|
|
84
|
-
src = OpenCV.base64ToMat(imageBase64);
|
|
85
|
-
|
|
159
|
+
const src = OpenCV.base64ToMat(imageBase64);
|
|
86
160
|
const jsSrc = OpenCV.toJSValue(src);
|
|
87
161
|
const origCols = jsSrc.cols || 1000;
|
|
88
162
|
const origRows = jsSrc.rows || 1000;
|
|
@@ -91,177 +165,183 @@ export class DocumentScanner {
|
|
|
91
165
|
const ratio = origRows / targetHeight;
|
|
92
166
|
const targetWidth = Math.max(1, Math.round(origCols / ratio));
|
|
93
167
|
|
|
94
|
-
resized = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
168
|
+
const resized = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
95
169
|
const dstSize = OpenCV.createObject(ObjectType.Size, targetWidth, targetHeight);
|
|
96
|
-
|
|
97
170
|
try {
|
|
98
171
|
OpenCV.invoke('resize', src, resized, dstSize, 0, 0, 1 /* INTER_LINEAR */);
|
|
99
|
-
} catch (
|
|
100
|
-
|
|
172
|
+
} catch (_) {
|
|
173
|
+
// nếu resize fail, dùng src gốc — ratio=1
|
|
101
174
|
}
|
|
102
175
|
|
|
103
|
-
//
|
|
104
|
-
gray = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
176
|
+
// Grayscale + Blur
|
|
177
|
+
const gray = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
105
178
|
OpenCV.invoke('cvtColor', resized, gray, ColorConversionCodes.COLOR_BGR2GRAY);
|
|
106
179
|
|
|
107
|
-
|
|
108
|
-
blurred = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
180
|
+
const blurred = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
109
181
|
const ksize = OpenCV.createObject(ObjectType.Size, 7, 7);
|
|
110
182
|
OpenCV.invoke('GaussianBlur', gray, blurred, ksize, 0);
|
|
111
183
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
184
|
+
const imgArea = targetWidth * targetHeight;
|
|
185
|
+
const MIN_AREA = 0.15; // 15% — khoan dung hơn Python gốc (25%)
|
|
186
|
+
|
|
187
|
+
const allDebug: string[] = [];
|
|
188
|
+
let winner: { poly: Point[]; area: number; angleRange: number } | null = null;
|
|
189
|
+
|
|
190
|
+
// ═══════════════════════════════════════════════════════════════
|
|
191
|
+
// PIPELINE 1: Adaptive Threshold → contour KÍN (method chính)
|
|
192
|
+
//
|
|
193
|
+
// Adaptive threshold tạo ảnh binary phân biệt foreground/background.
|
|
194
|
+
// findContours trên binary image cho contour KÍN → area = diện tích thật.
|
|
195
|
+
// Đây thay thế LSD line detection (không có trong react-native-fast-opencv).
|
|
196
|
+
// ═══════════════════════════════════════════════════════════════
|
|
119
197
|
try {
|
|
120
|
-
OpenCV.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
198
|
+
const adaptive = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
199
|
+
// ADAPTIVE_THRESH_GAUSSIAN_C = 1, THRESH_BINARY_INV = 1
|
|
200
|
+
// blockSize=15 (must be odd), C=10
|
|
201
|
+
OpenCV.invoke('adaptiveThreshold', blurred, adaptive,
|
|
202
|
+
255, 1 /* GAUSSIAN_C */, 1 /* BINARY_INV */, 15, 10);
|
|
203
|
+
|
|
204
|
+
// MORPH_CLOSE để nối vùng tài liệu lại — kernel lớn 15x15
|
|
205
|
+
const closeKSz = OpenCV.createObject(ObjectType.Size, 15, 15);
|
|
206
|
+
const closeK = OpenCV.invoke('getStructuringElement', 0 /* RECT */, closeKSz);
|
|
207
|
+
const adaptClosed = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
208
|
+
OpenCV.invoke('morphologyEx', adaptive, adaptClosed, 3 /* MORPH_CLOSE */, closeK);
|
|
209
|
+
|
|
210
|
+
const cnt1 = OpenCV.createObject(ObjectType.MatVector);
|
|
211
|
+
const hier1 = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
212
|
+
OpenCV.invoke('findContoursWithHierarchy', adaptClosed, cnt1, hier1,
|
|
213
|
+
0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
214
|
+
|
|
215
|
+
const js1 = OpenCV.toJSValue(cnt1);
|
|
216
|
+
const sz1 = js1?.array?.length ?? 0;
|
|
217
|
+
console.log(`[DocDetect] P1(AdaptThresh): ${sz1} contours`);
|
|
218
|
+
|
|
219
|
+
if (sz1 > 0) {
|
|
220
|
+
const r1 = this.findBestQuad(cnt1, sz1, imgArea, MIN_AREA, 'P1');
|
|
221
|
+
if (r1) {
|
|
222
|
+
allDebug.push(...r1.debug);
|
|
223
|
+
if (!winner || r1.area > winner.area) {
|
|
224
|
+
winner = { poly: r1.poly, area: r1.area, angleRange: r1.angleRange };
|
|
225
|
+
}
|
|
226
|
+
} else if (r1 === null) {
|
|
227
|
+
// Ghi debug dù không tìm được
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (p1Err) {
|
|
231
|
+
console.log('[DocDetect] P1 failed:', p1Err);
|
|
124
232
|
}
|
|
125
233
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
234
|
+
// ═══════════════════════════════════════════════════════════════
|
|
235
|
+
// PIPELINE 2: Canny (giống Python gốc andrewdcampbell)
|
|
236
|
+
//
|
|
237
|
+
// MORPH_CLOSE trên grayscale → Canny → dilate mạnh → contours.
|
|
238
|
+
// Dilate 7x7 x2 iterations để NỐI edge fragments thành contour kín.
|
|
239
|
+
// ═══════════════════════════════════════════════════════════════
|
|
137
240
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
console.log(`[DocDetect] top5 areas: ${top5.map(m => m.area.toFixed(0)).join(', ')} | imgArea=${imgArea.toFixed(0)}`);
|
|
177
|
-
|
|
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;
|
|
180
|
-
|
|
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];
|
|
188
|
-
const contour = metric.contour;
|
|
189
|
-
|
|
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;
|
|
197
|
-
|
|
198
|
-
for (let epPx = 10; epPx <= 120; epPx += 5) {
|
|
199
|
-
const approx = OpenCV.createObject(ObjectType.PointVector);
|
|
200
|
-
OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
|
|
201
|
-
const approxJS = OpenCV.toJSValue(approx);
|
|
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;
|
|
241
|
+
// MORPH_CLOSE 9x9 trên grayscale (đúng Python)
|
|
242
|
+
const morphSz = OpenCV.createObject(ObjectType.Size, 9, 9);
|
|
243
|
+
const morphK = OpenCV.invoke('getStructuringElement', 0 /* RECT */, morphSz);
|
|
244
|
+
const morphed = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
245
|
+
OpenCV.invoke('morphologyEx', blurred, morphed, 3 /* MORPH_CLOSE */, morphK);
|
|
246
|
+
|
|
247
|
+
// Canny (0, 84)
|
|
248
|
+
const cannyOut = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
249
|
+
OpenCV.invoke('Canny', morphed, cannyOut, 0, 84);
|
|
250
|
+
|
|
251
|
+
// Dilate MẠNH: kernel 7x7 — nối edge fragments lại thành contour kín
|
|
252
|
+
const dilSz = OpenCV.createObject(ObjectType.Size, 7, 7);
|
|
253
|
+
const dilK = OpenCV.invoke('getStructuringElement', 0 /* RECT */, dilSz);
|
|
254
|
+
const dilOut = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
255
|
+
OpenCV.invoke('morphologyEx', cannyOut, dilOut, 1 /* MORPH_DILATE */, dilK);
|
|
256
|
+
|
|
257
|
+
// MORPH_CLOSE lần nữa trên edge image để đóng gap
|
|
258
|
+
const closeSz2 = OpenCV.createObject(ObjectType.Size, 11, 11);
|
|
259
|
+
const closeK2 = OpenCV.invoke('getStructuringElement', 0 /* RECT */, closeSz2);
|
|
260
|
+
const closedEdge = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
261
|
+
OpenCV.invoke('morphologyEx', dilOut, closedEdge, 3 /* MORPH_CLOSE */, closeK2);
|
|
262
|
+
|
|
263
|
+
const cnt2 = OpenCV.createObject(ObjectType.MatVector);
|
|
264
|
+
const hier2 = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
265
|
+
OpenCV.invoke('findContoursWithHierarchy', closedEdge, cnt2, hier2,
|
|
266
|
+
0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
267
|
+
|
|
268
|
+
const js2 = OpenCV.toJSValue(cnt2);
|
|
269
|
+
const sz2 = js2?.array?.length ?? 0;
|
|
270
|
+
console.log(`[DocDetect] P2(CannyStrong): ${sz2} contours`);
|
|
271
|
+
|
|
272
|
+
if (sz2 > 0) {
|
|
273
|
+
const r2 = this.findBestQuad(cnt2, sz2, imgArea, MIN_AREA, 'P2');
|
|
274
|
+
if (r2) {
|
|
275
|
+
allDebug.push(...r2.debug);
|
|
276
|
+
if (!winner || r2.area > winner.area) {
|
|
277
|
+
winner = { poly: r2.poly, area: r2.area, angleRange: r2.angleRange };
|
|
220
278
|
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} catch (p2Err) {
|
|
282
|
+
console.log('[DocDetect] P2 failed:', p2Err);
|
|
283
|
+
}
|
|
221
284
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
285
|
+
// ═══════════════════════════════════════════════════════════════
|
|
286
|
+
// PIPELINE 3: OTSU threshold (binary toàn cục)
|
|
287
|
+
//
|
|
288
|
+
// Cho ảnh có tương phản rõ giữa tài liệu và nền.
|
|
289
|
+
// ═══════════════════════════════════════════════════════════════
|
|
290
|
+
try {
|
|
291
|
+
const otsu = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
292
|
+
// threshold type = THRESH_BINARY_INV + THRESH_OTSU = 1 + 8 = 9
|
|
293
|
+
OpenCV.invoke('threshold', blurred, otsu, 0, 255, 9);
|
|
294
|
+
|
|
295
|
+
// MORPH_CLOSE lớn để nối vùng
|
|
296
|
+
const oCloseSz = OpenCV.createObject(ObjectType.Size, 15, 15);
|
|
297
|
+
const oCloseK = OpenCV.invoke('getStructuringElement', 0 /* RECT */, oCloseSz);
|
|
298
|
+
const otsuClosed = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
299
|
+
OpenCV.invoke('morphologyEx', otsu, otsuClosed, 3 /* MORPH_CLOSE */, oCloseK);
|
|
300
|
+
|
|
301
|
+
const cnt3 = OpenCV.createObject(ObjectType.MatVector);
|
|
302
|
+
const hier3 = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
|
|
303
|
+
OpenCV.invoke('findContoursWithHierarchy', otsuClosed, cnt3, hier3,
|
|
304
|
+
0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
|
|
305
|
+
|
|
306
|
+
const js3 = OpenCV.toJSValue(cnt3);
|
|
307
|
+
const sz3 = js3?.array?.length ?? 0;
|
|
308
|
+
console.log(`[DocDetect] P3(OTSU): ${sz3} contours`);
|
|
309
|
+
|
|
310
|
+
if (sz3 > 0) {
|
|
311
|
+
const r3 = this.findBestQuad(cnt3, sz3, imgArea, MIN_AREA, 'P3');
|
|
312
|
+
if (r3) {
|
|
313
|
+
allDebug.push(...r3.debug);
|
|
314
|
+
if (!winner || r3.area > winner.area) {
|
|
315
|
+
winner = { poly: r3.poly, area: r3.area, angleRange: r3.angleRange };
|
|
230
316
|
}
|
|
231
317
|
}
|
|
232
318
|
}
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
);
|
|
319
|
+
} catch (p3Err) {
|
|
320
|
+
console.log('[DocDetect] P3 failed:', p3Err);
|
|
240
321
|
}
|
|
241
322
|
|
|
242
|
-
|
|
243
|
-
|
|
323
|
+
// ═══════════════════════════════════════════════════════════════
|
|
324
|
+
// Trả kết quả
|
|
325
|
+
// ═══════════════════════════════════════════════════════════════
|
|
326
|
+
console.log(`[DocDetect] All debug:\n${allDebug.join('\n')}`);
|
|
244
327
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
y: Math.round(p.y * actualRatio)
|
|
328
|
+
if (winner) {
|
|
329
|
+
console.log(`[DocDetect] ✓ Winner: area=${winner.area.toFixed(0)}(${(winner.area/imgArea*100).toFixed(1)}%) angle=${winner.angleRange.toFixed(1)}°`);
|
|
330
|
+
const originalCorners = winner.poly.map(p => ({
|
|
331
|
+
x: Math.round(p.x * ratio),
|
|
332
|
+
y: Math.round(p.y * ratio)
|
|
251
333
|
}));
|
|
252
334
|
return this.sortCorners(originalCorners);
|
|
253
335
|
}
|
|
254
336
|
|
|
255
|
-
//
|
|
256
|
-
|
|
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.`);
|
|
337
|
+
// Không pipeline nào thành công
|
|
338
|
+
console.log('[DocDetect] ✗ All pipelines failed');
|
|
258
339
|
throw new Error(
|
|
259
340
|
`Không phát hiện được viền tài liệu.\n` +
|
|
260
341
|
`Ảnh: ${origCols}x${origRows}px | Resize: ${targetWidth}x${targetHeight}px\n` +
|
|
261
|
-
`
|
|
262
|
-
`
|
|
263
|
-
`
|
|
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.`
|
|
342
|
+
`MinArea: ${(MIN_AREA*100).toFixed(0)}%\n` +
|
|
343
|
+
`Chi tiết:\n${allDebug.join('\n')}\n` +
|
|
344
|
+
`Gợi ý: Hãy đảm bảo tài liệu chiếm ≥15% khung hình, viền rõ ràng, nền tương phản.`
|
|
265
345
|
);
|
|
266
346
|
} catch (e: any) {
|
|
267
347
|
// Trích message từ mọi dạng exception (Error object, string, native, undefined...)
|