rn-opencv-doc-perspective-correction 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts 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;
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,89 @@ 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 trên ảnh đã dilate
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, 1 /* RETR_LIST */, 2 /* CHAIN_APPROX_SIMPLE */);
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
- return undefined;
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
- let contourMetrics = [];
120
- const imgArea = targetWidth * targetHeight;
121
- // Diện tích tối thiểu 15% (gốc andrewdcampbell dùng 25%, ta dùng 15% linh hoạt hơn)
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 > (imgArea * MIN_AREA_RATIO)) {
128
- contourMetrics.push({ index: i, area, contour });
129
- }
144
+ if (area > maxContourArea)
145
+ maxContourArea = area;
146
+ allContourMetrics.push({ index: i, area, contour });
130
147
  }
131
- contourMetrics.sort((a, b) => b.area - a.area);
132
- // Thu thập TẤT CẢ candidates hợp lệ, chọn tốt nhất (không dừng sớm)
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 maxChecks = Math.min(contourMetrics.length, 5);
137
- for (let i = 0; i < maxChecks; i++) {
138
- const metric = contourMetrics[i];
156
+ const debugCandidates = [];
157
+ for (let i = 0; i < top5.length; i++) {
158
+ const metric = top5[i];
139
159
  const contour = metric.contour;
140
- const periObj = react_native_fast_opencv_1.OpenCV.invoke('arcLength', contour, true);
141
- const peri = periObj ? (periObj.value || 0) : 0;
142
- // Vòng lặp epsilon (2%→10%, bước 1%) tìm tứ giác 4 đỉnh
143
- for (let ep = 0.02; ep <= 0.1; ep += 0.01) {
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) {
144
167
  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, ep * peri, true);
168
+ react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
146
169
  const approxJS = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
147
- if (approxJS && approxJS.array && approxJS.array.length === 4) {
148
- try {
149
- const isConvex = react_native_fast_opencv_1.OpenCV.invoke('isContourConvex', approx);
150
- const convexValue = (typeof isConvex === 'object' && isConvex !== null) ? isConvex.value : isConvex;
151
- if (convexValue === false)
152
- continue;
153
- const pts = approxJS.array;
154
- const sorted = this.sortCorners([...pts]);
155
- const angleRange = this.getAngleRange(sorted);
156
- // Validate: dải góc nội < 40° (loại hình thoi/bình hành méo)
157
- if (angleRange < 40) {
158
- // Ưu tiên: lớn nhất + vuông nhất
159
- if (metric.area > bestArea || (metric.area === bestArea && angleRange < bestAngleRange)) {
160
- bestPoly = pts;
161
- bestArea = metric.area;
162
- bestAngleRange = angleRange;
163
- }
164
- break; // Đã tìm quad tốt cho contour này, sang contour tiếp
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;
165
186
  }
187
+ break;
166
188
  }
167
- catch (_convexErr) { }
168
189
  }
169
190
  }
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}`);
170
194
  }
171
- // BƯỚC 7: Trả về góc, phóng trả tỷ lệ kích thước gốc
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
172
198
  if (bestPoly && bestPoly.length === 4) {
173
199
  const actualRatio = (resized === src) ? 1.0 : ratio;
174
200
  const originalCorners = bestPoly.map(p => ({
@@ -177,11 +203,43 @@ class DocumentScanner {
177
203
  }));
178
204
  return this.sortCorners(originalCorners);
179
205
  }
180
- return undefined;
206
+ // Giống Python gốc: nếu không tìm được → dùng toàn bộ ảnh làm fallback
207
+ // "If we did not find any valid contours, just use the whole image"
208
+ console.log(`[DocDetect] No valid quad found (minArea=${(MIN_QUAD_AREA_RATIO * 100).toFixed(0)}%, maxContour=${maxContourArea.toFixed(0)}px²). Fallback to full image.`);
209
+ throw new Error(`Không phát hiện được viền tài liệu.\n` +
210
+ `Ảnh: ${origCols}x${origRows}px | Resize: ${targetWidth}x${targetHeight}px\n` +
211
+ `Contours kiểm tra: ${top5.length}/${contoursSize} | MinArea: ${(MIN_QUAD_AREA_RATIO * 100).toFixed(0)}%\n` +
212
+ `Contour lớn nhất: ${maxContourArea.toFixed(0)}px² (${(maxContourArea / imgArea * 100).toFixed(1)}% ảnh)\n` +
213
+ `Chi tiết:\n${debugCandidates.join('\n')}\n` +
214
+ `Gợi ý: Tài liệu cần chiếm ít nhất ${(MIN_QUAD_AREA_RATIO * 100).toFixed(0)}% diện tích khung hình, viền phải tương phản với nền.`);
181
215
  }
182
216
  catch (e) {
183
- console.error('[OpenCV] Lỗi vòng quét nhận diện mép tài liệu:', e);
184
- throw new Error(`[OpenCV Corner Detection Error]: ${e.message}`);
217
+ // Trích message từ mọi dạng exception (Error object, string, native, undefined...)
218
+ let rawMsg;
219
+ if (typeof e === 'string') {
220
+ rawMsg = e;
221
+ }
222
+ else if (e instanceof Error) {
223
+ rawMsg = e.message || e.toString();
224
+ }
225
+ else if (e && typeof e === 'object') {
226
+ rawMsg = e.message || e.code || e.name || JSON.stringify(e);
227
+ }
228
+ else {
229
+ rawMsg = String(e !== null && e !== void 0 ? e : 'Unknown error');
230
+ }
231
+ console.error('[OpenCV] detectPageCorners exception:', rawMsg, e);
232
+ // Nếu đây là lỗi intentional từ chính hàm này (đã có thông tin chi tiết),
233
+ // chỉ wrap nhẹ để không mất ngữ cảnh.
234
+ // Nếu là lỗi native bất ngờ từ OpenCV, thêm context step.
235
+ const isIntentional = rawMsg.startsWith('Không ') || rawMsg.startsWith('[OpenCV Corner');
236
+ if (isIntentional) {
237
+ throw new Error(rawMsg);
238
+ }
239
+ else {
240
+ throw new Error(`Lỗi xử lý OpenCV không mong đợi: ${rawMsg}\n` +
241
+ `(Có thể do ảnh corrupt, định dạng không hỗ trợ, hoặc thiết bị thiếu bộ nhớ)`);
242
+ }
185
243
  }
186
244
  finally {
187
245
  react_native_fast_opencv_1.OpenCV.clearBuffers();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-opencv-doc-perspective-correction",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "A React Native library for document corner detection and perspective correction using react-native-fast-opencv",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -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,107 @@ 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 trên ảnh đã dilate
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, 1 /* RETR_LIST */, 2 /* CHAIN_APPROX_SIMPLE */);
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
- return undefined;
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
- let contourMetrics = [];
144
- const imgArea = targetWidth * targetHeight;
145
- // Diện tích tối thiểu 15% (gốc andrewdcampbell dùng 25%, ta dùng 15% linh hoạt hơn)
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 > (imgArea * MIN_AREA_RATIO)) {
152
- contourMetrics.push({ index: i, area, contour });
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
- contourMetrics.sort((a, b) => b.area - a.area);
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
- const maxChecks = Math.min(contourMetrics.length, 5);
164
- for (let i = 0; i < maxChecks; i++) {
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
- const periObj = OpenCV.invoke('arcLength', contour, true);
169
- const peri = periObj ? (periObj.value || 0) : 0;
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;
170
196
 
171
- // Vòng lặp epsilon (2%→10%, bước 1%) tìm tứ giác 4 đỉnh
172
- for (let ep = 0.02; ep <= 0.1; ep += 0.01) {
197
+ for (let epPx = 10; epPx <= 120; epPx += 5) {
173
198
  const approx = OpenCV.createObject(ObjectType.PointVector);
174
- OpenCV.invoke('approxPolyDP', contour, approx, ep * peri, true);
199
+ OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
175
200
  const approxJS = OpenCV.toJSValue(approx);
176
-
177
- if (approxJS && approxJS.array && approxJS.array.length === 4) {
178
- try {
179
- const isConvex = OpenCV.invoke('isContourConvex', approx);
180
- const convexValue = (typeof isConvex === 'object' && isConvex !== null) ? isConvex.value : isConvex;
181
- if (convexValue === false) continue;
182
-
183
- const pts = approxJS.array as Point[];
184
- const sorted = this.sortCorners([...pts]);
185
- const angleRange = this.getAngleRange(sorted);
186
-
187
- // Validate: dải góc nội < 40° (loại hình thoi/bình hành méo)
188
- if (angleRange < 40) {
189
- // Ưu tiên: lớn nhất + vuông nhất
190
- if (metric.area > bestArea || (metric.area === bestArea && angleRange < bestAngleRange)) {
191
- bestPoly = pts;
192
- bestArea = metric.area;
193
- bestAngleRange = angleRange;
194
- }
195
- break; // Đã tìm quad tốt cho contour này, sang contour tiếp
201
+ const verts = approxJS?.array?.length ?? 0;
202
+
203
+ if (verts === 4) {
204
+ const pts = approxJS.array as Point[];
205
+ const sorted = this.sortCorners([...pts]);
206
+ const angleRange = this.getAngleRange(sorted);
207
+
208
+ if (angleRange < foundAngle) {
209
+ foundAngle = angleRange;
210
+ foundPts = pts;
211
+ foundVerts = verts;
212
+ }
213
+
214
+ // is_valid_contour: area > MIN_QUAD_AREA_RATIO 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;
196
220
  }
197
- } catch (_convexErr) {}
221
+ break;
222
+ }
198
223
  }
199
224
  }
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
+ );
200
231
  }
201
232
 
202
- // BƯỚC 7: Trả về góc, phóng trả tỷ lệ kích thước gốc
233
+ console.log(`[DocDetect] Candidates:\n${debugCandidates.join('\n')}`);
234
+ console.log(`[DocDetect] Result: bestPoly=${bestPoly ? 'found' : 'NOT FOUND'} area=${bestArea.toFixed(0)} angleRange=${bestAngleRange === 999 ? 'N/A' : bestAngleRange.toFixed(1)}°`);
235
+
236
+ // BƯỚC 7: Trả về góc, scale về kích thước ảnh gốc
203
237
  if (bestPoly && bestPoly.length === 4) {
204
238
  const actualRatio = (resized === src) ? 1.0 : ratio;
205
239
  const originalCorners = bestPoly.map(p => ({
@@ -208,10 +242,45 @@ export class DocumentScanner {
208
242
  }));
209
243
  return this.sortCorners(originalCorners);
210
244
  }
211
- return undefined;
245
+
246
+ // Giống Python gốc: nếu không tìm được → dùng toàn bộ ảnh làm fallback
247
+ // "If we did not find any valid contours, just use the whole image"
248
+ console.log(`[DocDetect] No valid quad found (minArea=${(MIN_QUAD_AREA_RATIO*100).toFixed(0)}%, maxContour=${maxContourArea.toFixed(0)}px²). Fallback to full image.`);
249
+ throw new Error(
250
+ `Không phát hiện được viền tài liệu.\n` +
251
+ `Ảnh: ${origCols}x${origRows}px | Resize: ${targetWidth}x${targetHeight}px\n` +
252
+ `Contours kiểm tra: ${top5.length}/${contoursSize} | MinArea: ${(MIN_QUAD_AREA_RATIO*100).toFixed(0)}%\n` +
253
+ `Contour lớn nhất: ${maxContourArea.toFixed(0)}px² (${(maxContourArea/imgArea*100).toFixed(1)}% ảnh)\n` +
254
+ `Chi tiết:\n${debugCandidates.join('\n')}\n` +
255
+ `Gợi ý: Tài liệu cần chiếm ít nhất ${(MIN_QUAD_AREA_RATIO*100).toFixed(0)}% diện tích khung hình, viền phải tương phản với nền.`
256
+ );
212
257
  } catch (e: any) {
213
- console.error('[OpenCV] Lỗi vòng quét nhận diện mép tài liệu:', e);
214
- throw new Error(`[OpenCV Corner Detection Error]: ${e.message}`);
258
+ // Trích message từ mọi dạng exception (Error object, string, native, undefined...)
259
+ let rawMsg: string;
260
+ if (typeof e === 'string') {
261
+ rawMsg = e;
262
+ } else if (e instanceof Error) {
263
+ rawMsg = e.message || e.toString();
264
+ } else if (e && typeof e === 'object') {
265
+ rawMsg = e.message || e.code || e.name || JSON.stringify(e);
266
+ } else {
267
+ rawMsg = String(e ?? 'Unknown error');
268
+ }
269
+
270
+ console.error('[OpenCV] detectPageCorners exception:', rawMsg, e);
271
+
272
+ // Nếu đây là lỗi intentional từ chính hàm này (đã có thông tin chi tiết),
273
+ // chỉ wrap nhẹ để không mất ngữ cảnh.
274
+ // Nếu là lỗi native bất ngờ từ OpenCV, thêm context step.
275
+ const isIntentional = rawMsg.startsWith('Không ') || rawMsg.startsWith('[OpenCV Corner');
276
+ if (isIntentional) {
277
+ throw new Error(rawMsg);
278
+ } else {
279
+ throw new Error(
280
+ `Lỗi xử lý OpenCV không mong đợi: ${rawMsg}\n` +
281
+ `(Có thể do ảnh corrupt, định dạng không hỗ trợ, hoặc thiết bị thiếu bộ nhớ)`
282
+ );
283
+ }
215
284
  } finally {
216
285
  OpenCV.clearBuffers();
217
286
  }