rn-opencv-doc-perspective-correction 1.0.18 → 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 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,163 +55,236 @@ class DocumentScanner {
55
55
  { x: 0, y: h }, // bottom-left
56
56
  ];
57
57
  }
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) {
63
+ var _a, _b, _c;
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
+ }
58
125
  static detectPageCorners(imageBase64) {
59
- var _a, _b;
60
- let src = null;
61
- let resized = null;
62
- let gray = null;
63
- let blurred = null;
64
- let closed = null;
65
- let edges = null;
66
- let dilated = null;
67
- let contoursObj = null;
68
- let hierarchyObj = null;
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 (err) {
83
- resized = src; // Fallback an toàn
140
+ catch (_) {
141
+ // nếu resize fail, dùng src gốc ratio=1
84
142
  }
85
- // BƯỚC 1: Chuyển sang ảnh xám (Grayscale)
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
- // BƯỚC 2: GaussianBlur 7x7 (theo andrewdcampbell)
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
- // 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 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);
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;
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.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
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 (_morphErr) {
103
- closed = blurred; // Fallback nếu thiết bị không hỗ trợ
104
- morphCloseUsed = false;
189
+ catch (p1Err) {
190
+ console.log('[DocDetect] P1 failed:', p1Err);
105
191
  }
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
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);
110
- react_native_fast_opencv_1.OpenCV.invoke('Canny', closed, edges, 0, 84);
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;
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
- 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)
124
- contoursObj = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.MatVector);
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);
126
- react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj, 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
127
- const contoursJS = react_native_fast_opencv_1.OpenCV.toJSValue(contoursObj);
128
- const contoursArray = (contoursJS === null || contoursJS === void 0 ? void 0 : contoursJS.array) || [];
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}`);
132
- if (contoursSize === 0) {
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);
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
- // Lấy top 5 contour lớn nhất (đúng như Python: cnts[:5])
138
- const allContourMetrics = [];
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
- 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];
159
- const contour = metric.contour;
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) {
167
- const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
168
- react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
169
- const approxJS = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
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;
186
- }
187
- break;
236
+ // ═══════════════════════════════════════════════════════════════
237
+ // PIPELINE 3: OTSU threshold (binary toàn cục)
238
+ //
239
+ // Cho ảnh tương phản giữa tài liệu 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 };
188
262
  }
189
263
  }
190
264
  }
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}`);
194
265
  }
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) {
199
- const actualRatio = (resized === src) ? 1.0 : ratio;
200
- const originalCorners = bestPoly.map(p => ({
201
- x: Math.round(p.x * actualRatio),
202
- y: Math.round(p.y * actualRatio)
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)
203
278
  }));
204
279
  return this.sortCorners(originalCorners);
205
280
  }
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.`);
281
+ // Không pipeline nào thành công
282
+ console.log('[DocDetect] All pipelines failed');
209
283
  throw new Error(`Không phát hiện được viền tài liệu.\n` +
210
284
  `Ả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.`);
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.`);
215
288
  }
216
289
  catch (e) {
217
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.18",
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
- public static detectPageCorners(imageBase64: string): Point[] | undefined {
73
- let src: OpenCVMat | null = null;
74
- let resized: OpenCVMat | null = null;
75
- let gray: OpenCVMat | null = null;
76
- let blurred: OpenCVMat | null = null;
77
- let closed: OpenCVMat | null = null;
78
- let edges: OpenCVMat | null = null;
79
- let dilated: OpenCVMat | null = null;
80
- let contoursObj: any = null;
81
- let hierarchyObj: any = null;
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,168 +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 (err) {
100
- resized = src; // Fallback an toàn
172
+ } catch (_) {
173
+ // nếu resize fail, dùng src gốc ratio=1
101
174
  }
102
175
 
103
- // BƯỚC 1: Chuyển sang ảnh xám (Grayscale)
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
- // BƯỚC 2: GaussianBlur 7x7 (theo andrewdcampbell)
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
- // 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 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);
117
- closed = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
118
- let morphCloseUsed = true;
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.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;
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
- // 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
129
- edges = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
130
- OpenCV.invoke('Canny', closed, edges, 0, 84);
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
- 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)
145
- contoursObj = OpenCV.createObject(ObjectType.MatVector);
146
- hierarchyObj = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
147
- OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj,
148
- 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
149
-
150
- const contoursJS = OpenCV.toJSValue(contoursObj);
151
- const contoursArray = contoursJS?.array || [];
152
- const contoursSize = contoursArray.length;
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
-
157
- if (contoursSize === 0) {
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);
161
- }
162
-
163
- // Lấy top 5 contour lớn nhất (đúng như Python: cnts[:5])
164
- const allContourMetrics = [];
165
- let maxContourArea = 0;
166
- for (let i = 0; i < contoursSize; i++) {
167
- const contour = OpenCV.copyObjectFromVector(contoursObj, i);
168
- const areaObj = OpenCV.invoke('contourArea', contour);
169
- const area = areaObj ? areaObj.value : 0;
170
- if (area > maxContourArea) maxContourArea = area;
171
- allContourMetrics.push({ index: i, area, contour });
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)}`);
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
-
197
- for (let epPx = 10; epPx <= 120; epPx += 5) {
198
- const approx = OpenCV.createObject(ObjectType.PointVector);
199
- OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
200
- const approxJS = OpenCV.toJSValue(approx);
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;
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 };
212
278
  }
279
+ }
280
+ }
281
+ } catch (p2Err) {
282
+ console.log('[DocDetect] P2 failed:', p2Err);
283
+ }
213
284
 
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;
220
- }
221
- break;
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 };
222
316
  }
223
317
  }
224
318
  }
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
- );
319
+ } catch (p3Err) {
320
+ console.log('[DocDetect] P3 failed:', p3Err);
231
321
  }
232
322
 
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)}°`);
323
+ // ═══════════════════════════════════════════════════════════════
324
+ // Trả kết quả
325
+ // ═══════════════════════════════════════════════════════════════
326
+ console.log(`[DocDetect] All debug:\n${allDebug.join('\n')}`);
235
327
 
236
- // BƯỚC 7: Trả về góc, scale về kích thước ảnh gốc
237
- if (bestPoly && bestPoly.length === 4) {
238
- const actualRatio = (resized === src) ? 1.0 : ratio;
239
- const originalCorners = bestPoly.map(p => ({
240
- x: Math.round(p.x * actualRatio),
241
- 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)
242
333
  }));
243
334
  return this.sortCorners(originalCorners);
244
335
  }
245
336
 
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.`);
337
+ // Không pipeline nào thành công
338
+ console.log('[DocDetect] All pipelines failed');
249
339
  throw new Error(
250
340
  `Không phát hiện được viền tài liệu.\n` +
251
341
  `Ả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.`
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.`
256
345
  );
257
346
  } catch (e: any) {
258
347
  // Trích message từ mọi dạng exception (Error object, string, native, undefined...)