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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -11,6 +11,17 @@ export type OcrMethodHint = 'G' | 'S';
11
11
  export declare class DocumentScanner {
12
12
  private static getDistance;
13
13
  private static sortCorners;
14
+ /**
15
+ * Tính góc (độ) giữa vector (p2→p1) và vector (p2→p3).
16
+ */
17
+ private static getAngleBetween;
18
+ /**
19
+ * Dải góc nội tứ giác (max - min). Nếu > 40° → quá méo, không phải tài liệu.
20
+ * Tham chiếu: andrewdcampbell/OpenCV-Document-Scanner MAX_QUAD_ANGLE_RANGE=40
21
+ */
22
+ private static getAngleRange;
23
+ /** Fallback: 4 góc của toàn bộ ảnh (như Python gốc khi không tìm được contour) */
24
+ private static buildFullImageCorners;
14
25
  static detectPageCorners(imageBase64: string): Point[] | undefined;
15
26
  static applyPerspectiveCorrection(imageBase64: string, corners: Point[]): string | undefined;
16
27
  /**
package/dist/index.js CHANGED
@@ -17,14 +17,53 @@ class DocumentScanner {
17
17
  return angleA - angleB;
18
18
  });
19
19
  }
20
+ /**
21
+ * Tính góc (độ) giữa vector (p2→p1) và vector (p2→p3).
22
+ */
23
+ static getAngleBetween(p1, p2, p3) {
24
+ const v1x = p1.x - p2.x, v1y = p1.y - p2.y;
25
+ const v2x = p3.x - p2.x, v2y = p3.y - p2.y;
26
+ const dot = v1x * v2x + v1y * v2y;
27
+ const mag1 = Math.sqrt(v1x * v1x + v1y * v1y);
28
+ const mag2 = Math.sqrt(v2x * v2x + v2y * v2y);
29
+ if (mag1 === 0 || mag2 === 0)
30
+ return 0;
31
+ return Math.acos(Math.max(-1, Math.min(1, dot / (mag1 * mag2)))) * (180 / Math.PI);
32
+ }
33
+ /**
34
+ * Dải góc nội tứ giác (max - min). Nếu > 40° → quá méo, không phải tài liệu.
35
+ * Tham chiếu: andrewdcampbell/OpenCV-Document-Scanner MAX_QUAD_ANGLE_RANGE=40
36
+ */
37
+ static getAngleRange(quad) {
38
+ if (quad.length !== 4)
39
+ return 999;
40
+ const [tl, tr, br, bl] = quad;
41
+ const angles = [
42
+ this.getAngleBetween(bl, tl, tr),
43
+ this.getAngleBetween(tl, tr, br),
44
+ this.getAngleBetween(tr, br, bl),
45
+ this.getAngleBetween(br, bl, tl),
46
+ ];
47
+ return Math.max(...angles) - Math.min(...angles);
48
+ }
49
+ /** Fallback: 4 góc của toàn bộ ảnh (như Python gốc khi không tìm được contour) */
50
+ static buildFullImageCorners(w, h) {
51
+ return [
52
+ { x: 0, y: 0 }, // top-left
53
+ { x: w, y: 0 }, // top-right
54
+ { x: w, y: h }, // bottom-right
55
+ { x: 0, y: h }, // bottom-left
56
+ ];
57
+ }
20
58
  static detectPageCorners(imageBase64) {
59
+ var _a, _b;
21
60
  let src = null;
22
61
  let resized = null;
23
62
  let gray = null;
24
63
  let blurred = null;
25
64
  let closed = null;
26
65
  let edges = null;
27
- let kernelDims = null;
66
+ let dilated = null;
28
67
  let contoursObj = null;
29
68
  let hierarchyObj = null;
30
69
  try {
@@ -46,90 +85,161 @@ class DocumentScanner {
46
85
  // BƯỚC 1: Chuyển sang ảnh xám (Grayscale)
47
86
  gray = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
48
87
  react_native_fast_opencv_1.OpenCV.invoke('cvtColor', resized, gray, react_native_fast_opencv_1.ColorConversionCodes.COLOR_BGR2GRAY);
49
- // BƯỚC 2: Làm mờ để loại bỏ nhiễu hạt (Gaussian Blur 7x7)
88
+ // BƯỚC 2: GaussianBlur 7x7 (theo andrewdcampbell)
50
89
  blurred = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
51
90
  const ksize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 7, 7);
52
91
  react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', gray, blurred, ksize, 0);
53
- // BƯỚC 3: Dùng hình thái học lấp các chữ/chi tiết trên CCCD bằng MORPH_CLOSE
54
- // Giúp mảng CCCD thành khối liền (mặt chữ lặn đi), Canny sẽ bắt vòng viền chuẩn xác
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);
55
97
  closed = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
56
- const kernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 9, 9);
57
- kernelDims = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, kernelSize);
58
- react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, kernelDims);
59
- // BƯỚC 4: Dò tìm cạnh viền Canny
60
- // Không cần Canny-Auto nữa vì MORPH_CLOSE đã dọn dẹp mặt thẻ quá mượt.
98
+ let morphCloseUsed = true;
99
+ try {
100
+ react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
101
+ }
102
+ catch (_morphErr) {
103
+ closed = blurred; // Fallback nếu thiết bị không hỗ trợ
104
+ morphCloseUsed = false;
105
+ }
106
+ // BƯỚC 4: Canny (0, 84) – ngưỡng thấp theo andrewdcampbell
107
+ // threshold1=0: chấp nhận TẤT CẢ cạnh yếu nếu liên kết với cạnh mạnh
108
+ // threshold2=84: ngưỡng cạnh mạnh vừa phải
61
109
  edges = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
62
110
  react_native_fast_opencv_1.OpenCV.invoke('Canny', closed, edges, 0, 84);
63
- // BƯỚC 5: Tìm khối đa giác liên kết (Contours)
64
- // Lệnh bắt RETR_LIST (1) để tóm được tài liệu nằm bên trong các viền nền lớn
111
+ // BƯỚC 5: Dilate 3x3 nối các pixel viền lân cận bị đứt
112
+ const dilKernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 3, 3);
113
+ const dilKernel = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, dilKernelSize);
114
+ dilated = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
115
+ let dilateUsed = true;
116
+ try {
117
+ react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', edges, dilated, 1 /* MORPH_DILATE */, dilKernel);
118
+ }
119
+ catch (_dilErr) {
120
+ dilated = edges; // Fallback
121
+ dilateUsed = false;
122
+ }
123
+ // BƯỚC 6: Tìm Contours — dùng RETR_EXTERNAL (đúng như Python gốc, không phải RETR_LIST)
65
124
  contoursObj = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.MatVector);
66
125
  hierarchyObj = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat, 0, 0, react_native_fast_opencv_1.DataTypes.CV_8U);
67
- react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', edges, 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 */);
68
127
  const contoursJS = react_native_fast_opencv_1.OpenCV.toJSValue(contoursObj);
69
128
  const contoursArray = (contoursJS === null || contoursJS === void 0 ? void 0 : contoursJS.array) || [];
70
129
  const contoursSize = contoursArray.length;
130
+ const imgArea = targetWidth * targetHeight;
131
+ console.log(`[DocDetect] imgSize=${origCols}x${origRows} → resize=${targetWidth}x${targetHeight} | morphClose=${morphCloseUsed} dilate=${dilateUsed} | totalContours=${contoursSize}`);
71
132
  if (contoursSize === 0) {
72
- 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);
73
136
  }
74
- let contourMetrics = [];
137
+ // Lấy top 5 contour lớn nhất (đúng như Python: cnts[:5])
138
+ const allContourMetrics = [];
139
+ let maxContourArea = 0;
75
140
  for (let i = 0; i < contoursSize; i++) {
76
141
  const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contoursObj, i);
77
142
  const areaObj = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour);
78
143
  const area = areaObj ? areaObj.value : 0;
79
- // Mở rộng bộ lọc diện tích: Loại bỏ đi vùng nhiễu vụn (< 5% diện tích mặt quét)
80
- // loại bỏ những vùng quá lớn (ví dụ toàn bộ ảnh nền > 98%)
81
- const imgArea = targetWidth * targetHeight;
82
- if (area > (imgArea * 0.05) && area < (imgArea * 0.98)) {
83
- contourMetrics.push({ index: i, area, contour });
84
- }
144
+ if (area > maxContourArea)
145
+ maxContourArea = area;
146
+ allContourMetrics.push({ index: i, area, contour });
85
147
  }
86
- contourMetrics.sort((a, b) => b.area - a.area);
87
- let largestPoly = undefined;
88
- const maxChecks = Math.min(contourMetrics.length, 5);
89
- for (let i = 0; i < maxChecks; i++) {
90
- const metric = contourMetrics[i];
148
+ allContourMetrics.sort((a, b) => b.area - a.area);
149
+ const top5 = allContourMetrics.slice(0, 5);
150
+ console.log(`[DocDetect] top5 areas: ${top5.map(m => m.area.toFixed(0)).join(', ')} | imgArea=${imgArea.toFixed(0)}`);
151
+ // MIN_QUAD_AREA_RATIO = 0.25 (Python gốc), với fallback 0.05 cho ảnh nhỏ
152
+ const MIN_QUAD_AREA_RATIO = imgArea > 50000 ? 0.25 : 0.05;
153
+ let bestPoly = undefined;
154
+ let bestArea = 0;
155
+ let bestAngleRange = 999;
156
+ const debugCandidates = [];
157
+ for (let i = 0; i < top5.length; i++) {
158
+ const metric = top5[i];
91
159
  const contour = metric.contour;
92
- const periObj = react_native_fast_opencv_1.OpenCV.invoke('arcLength', contour, true);
93
- const peri = periObj ? (periObj.value || 0) : 0;
94
- let found4 = false;
95
- // Vòng lặp tăng dần độ dung sai Epsilon (từ 2% -> 10% chu vi)
96
- // Giúp bắt gọn 4 đỉnh kể cả khi đường mép giấy tờ bị nhăn nheo, lởm chởm
97
- for (let ep = 0.02; ep <= 0.1; ep += 0.02) {
160
+ // Python gốc dùng epsilon = 80 pixel tuyệt đối (không phải % perimeter!)
161
+ // approxPolyDP(c, 80, True) epsilon cố định
162
+ // Ta thử range 10→100 pixel, bước 10, để tìm ra 4 đỉnh
163
+ let foundPts = null;
164
+ let foundVerts = 0;
165
+ let foundAngle = 999;
166
+ for (let epPx = 10; epPx <= 120; epPx += 5) {
98
167
  const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
99
- react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx, ep * peri, true);
168
+ react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
100
169
  const approxJS = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
101
- // Nếu nội suy thành công ra đúng 4 đỉnh
102
- if (approxJS && approxJS.array && approxJS.array.length === 4) {
103
- try {
104
- const isConvex = react_native_fast_opencv_1.OpenCV.invoke('isContourConvex', approx);
105
- const convexValue = (typeof isConvex === 'object' && isConvex !== null) ? isConvex.value : isConvex;
106
- if (convexValue !== false) {
107
- largestPoly = approxJS.array;
108
- found4 = true;
109
- break;
170
+ const verts = (_b = (_a = approxJS === null || approxJS === void 0 ? void 0 : approxJS.array) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
171
+ if (verts === 4) {
172
+ const pts = approxJS.array;
173
+ const sorted = this.sortCorners([...pts]);
174
+ const angleRange = this.getAngleRange(sorted);
175
+ if (angleRange < foundAngle) {
176
+ foundAngle = angleRange;
177
+ foundPts = pts;
178
+ foundVerts = verts;
179
+ }
180
+ // is_valid_contour: area > MIN_QUAD_AREA_RATIO và angle_range < 40
181
+ if (metric.area > imgArea * MIN_QUAD_AREA_RATIO && angleRange < 40) {
182
+ if (metric.area > bestArea || (metric.area === bestArea && angleRange < bestAngleRange)) {
183
+ bestPoly = pts;
184
+ bestArea = metric.area;
185
+ bestAngleRange = angleRange;
110
186
  }
187
+ break;
111
188
  }
112
- catch (convexErr) { }
113
189
  }
114
190
  }
115
- if (found4) {
116
- break;
117
- }
191
+ debugCandidates.push(` [C${i}] area=${metric.area.toFixed(0)}px²(${(metric.area / imgArea * 100).toFixed(1)}%) ` +
192
+ `foundVerts=${foundVerts} bestAngle=${foundAngle === 999 ? 'N/A' : foundAngle.toFixed(1)}° ` +
193
+ `valid=${metric.area > imgArea * MIN_QUAD_AREA_RATIO && foundAngle < 40}`);
118
194
  }
119
- // BƯỚC 6: Trả về góc và phóng trả lại tỷ lệ kích thước cũ
120
- if (largestPoly && largestPoly.length === 4) {
195
+ console.log(`[DocDetect] Candidates:\n${debugCandidates.join('\n')}`);
196
+ console.log(`[DocDetect] Result: bestPoly=${bestPoly ? 'found' : 'NOT FOUND'} area=${bestArea.toFixed(0)} angleRange=${bestAngleRange === 999 ? 'N/A' : bestAngleRange.toFixed(1)}°`);
197
+ // BƯỚC 7: Trả về góc, scale về kích thước ảnh gốc
198
+ if (bestPoly && bestPoly.length === 4) {
121
199
  const actualRatio = (resized === src) ? 1.0 : ratio;
122
- const originalCorners = largestPoly.map(p => ({
200
+ const originalCorners = bestPoly.map(p => ({
123
201
  x: Math.round(p.x * actualRatio),
124
202
  y: Math.round(p.y * actualRatio)
125
203
  }));
126
204
  return this.sortCorners(originalCorners);
127
205
  }
128
- 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.`);
129
215
  }
130
216
  catch (e) {
131
- console.error('[OpenCV] Lỗi vòng quét nhận diện mép tài liệu:', e);
132
- 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
+ }
133
243
  }
134
244
  finally {
135
245
  react_native_fast_opencv_1.OpenCV.clearBuffers();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-opencv-doc-perspective-correction",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "A React Native library for document corner detection and perspective correction using react-native-fast-opencv",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -30,6 +30,45 @@ export class DocumentScanner {
30
30
  });
31
31
  }
32
32
 
33
+ /**
34
+ * Tính góc (độ) giữa vector (p2→p1) và vector (p2→p3).
35
+ */
36
+ private static getAngleBetween(p1: Point, p2: Point, p3: Point): number {
37
+ const v1x = p1.x - p2.x, v1y = p1.y - p2.y;
38
+ const v2x = p3.x - p2.x, v2y = p3.y - p2.y;
39
+ const dot = v1x * v2x + v1y * v2y;
40
+ const mag1 = Math.sqrt(v1x * v1x + v1y * v1y);
41
+ const mag2 = Math.sqrt(v2x * v2x + v2y * v2y);
42
+ if (mag1 === 0 || mag2 === 0) return 0;
43
+ return Math.acos(Math.max(-1, Math.min(1, dot / (mag1 * mag2)))) * (180 / Math.PI);
44
+ }
45
+
46
+ /**
47
+ * Dải góc nội tứ giác (max - min). Nếu > 40° → quá méo, không phải tài liệu.
48
+ * Tham chiếu: andrewdcampbell/OpenCV-Document-Scanner MAX_QUAD_ANGLE_RANGE=40
49
+ */
50
+ private static getAngleRange(quad: Point[]): number {
51
+ if (quad.length !== 4) return 999;
52
+ const [tl, tr, br, bl] = quad;
53
+ const angles = [
54
+ this.getAngleBetween(bl, tl, tr),
55
+ this.getAngleBetween(tl, tr, br),
56
+ this.getAngleBetween(tr, br, bl),
57
+ this.getAngleBetween(br, bl, tl),
58
+ ];
59
+ return Math.max(...angles) - Math.min(...angles);
60
+ }
61
+
62
+ /** Fallback: 4 góc của toàn bộ ảnh (như Python gốc khi không tìm được contour) */
63
+ private static buildFullImageCorners(w: number, h: number): Point[] {
64
+ return [
65
+ { x: 0, y: 0 }, // top-left
66
+ { x: w, y: 0 }, // top-right
67
+ { x: w, y: h }, // bottom-right
68
+ { x: 0, y: h }, // bottom-left
69
+ ];
70
+ }
71
+
33
72
  public static detectPageCorners(imageBase64: string): Point[] | undefined {
34
73
  let src: OpenCVMat | null = null;
35
74
  let resized: OpenCVMat | null = null;
@@ -37,7 +76,7 @@ export class DocumentScanner {
37
76
  let blurred: OpenCVMat | null = null;
38
77
  let closed: OpenCVMat | null = null;
39
78
  let edges: OpenCVMat | null = null;
40
- let kernelDims: any = null;
79
+ let dilated: OpenCVMat | null = null;
41
80
  let contoursObj: any = null;
42
81
  let hierarchyObj: any = null;
43
82
 
@@ -65,104 +104,183 @@ export class DocumentScanner {
65
104
  gray = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
66
105
  OpenCV.invoke('cvtColor', resized, gray, ColorConversionCodes.COLOR_BGR2GRAY);
67
106
 
68
- // BƯỚC 2: Làm mờ để loại bỏ nhiễu hạt (Gaussian Blur 7x7)
107
+ // BƯỚC 2: GaussianBlur 7x7 (theo andrewdcampbell)
69
108
  blurred = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
70
109
  const ksize = OpenCV.createObject(ObjectType.Size, 7, 7);
71
110
  OpenCV.invoke('GaussianBlur', gray, blurred, ksize, 0);
72
111
 
73
- // BƯỚC 3: Dùng hình thái học lấp các chữ/chi tiết trên CCCD bằng MORPH_CLOSE
74
- // Giúp mảng CCCD thành khối liền (mặt chữ lặn đi), Canny sẽ bắt vòng viền chuẩn xác
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);
75
117
  closed = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
76
- const kernelSize = OpenCV.createObject(ObjectType.Size, 9, 9);
77
- kernelDims = OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, kernelSize);
78
- OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, kernelDims);
118
+ let morphCloseUsed = true;
119
+ try {
120
+ OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
121
+ } catch (_morphErr) {
122
+ closed = blurred; // Fallback nếu thiết bị không hỗ trợ
123
+ morphCloseUsed = false;
124
+ }
79
125
 
80
- // BƯỚC 4: tìm cạnh viền Canny
81
- // Không cần Canny-Auto nữa MORPH_CLOSE đã dọn dẹp mặt thẻ quá mượt.
126
+ // BƯỚC 4: Canny (0, 84) ngưỡng thấp theo andrewdcampbell
127
+ // threshold1=0: chấp nhận TẤT CẢ cạnh yếu nếu liên kết với cạnh mạnh
128
+ // threshold2=84: ngưỡng cạnh mạnh vừa phải
82
129
  edges = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
83
130
  OpenCV.invoke('Canny', closed, edges, 0, 84);
84
131
 
85
- // BƯỚC 5: Tìm khối đa giác liên kết (Contours)
86
- // Lệnh bắt RETR_LIST (1) để tóm được tài liệu nằm bên trong các viền nền lớn
132
+ // BƯỚC 5: Dilate 3x3 nối các pixel viền lân cận bị đứt
133
+ const dilKernelSize = OpenCV.createObject(ObjectType.Size, 3, 3);
134
+ const dilKernel = OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, dilKernelSize);
135
+ dilated = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
136
+ let dilateUsed = true;
137
+ try {
138
+ OpenCV.invoke('morphologyEx', edges, dilated, 1 /* MORPH_DILATE */, dilKernel);
139
+ } catch (_dilErr) {
140
+ dilated = edges; // Fallback
141
+ dilateUsed = false;
142
+ }
143
+
144
+ // BƯỚC 6: Tìm Contours — dùng RETR_EXTERNAL (đúng như Python gốc, không phải RETR_LIST)
87
145
  contoursObj = OpenCV.createObject(ObjectType.MatVector);
88
146
  hierarchyObj = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
89
- OpenCV.invoke('findContoursWithHierarchy', edges, contoursObj, hierarchyObj, 1 /* RETR_LIST */, 2 /* CHAIN_APPROX_SIMPLE */);
147
+ OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj,
148
+ 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
90
149
 
91
150
  const contoursJS = OpenCV.toJSValue(contoursObj);
92
151
  const contoursArray = contoursJS?.array || [];
93
152
  const contoursSize = contoursArray.length;
94
153
 
154
+ const imgArea = targetWidth * targetHeight;
155
+ console.log(`[DocDetect] imgSize=${origCols}x${origRows} → resize=${targetWidth}x${targetHeight} | morphClose=${morphCloseUsed} dilate=${dilateUsed} | totalContours=${contoursSize}`);
156
+
95
157
  if (contoursSize === 0) {
96
- 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);
97
161
  }
98
162
 
99
- let contourMetrics = [];
163
+ // Lấy top 5 contour lớn nhất (đúng như Python: cnts[:5])
164
+ const allContourMetrics = [];
165
+ let maxContourArea = 0;
100
166
  for (let i = 0; i < contoursSize; i++) {
101
167
  const contour = OpenCV.copyObjectFromVector(contoursObj, i);
102
168
  const areaObj = OpenCV.invoke('contourArea', contour);
103
169
  const area = areaObj ? areaObj.value : 0;
104
-
105
- // Mở rộng bộ lọc diện tích: Loại bỏ đi vùng nhiễu vụn (< 5% diện tích mặt quét)
106
- // Và loại bỏ những vùng quá lớn (ví dụ toàn bộ ảnh nền > 98%)
107
- const imgArea = targetWidth * targetHeight;
108
- if (area > (imgArea * 0.05) && area < (imgArea * 0.98)) {
109
- contourMetrics.push({ index: i, area, contour });
110
- }
170
+ if (area > maxContourArea) maxContourArea = area;
171
+ allContourMetrics.push({ index: i, area, contour });
111
172
  }
173
+ allContourMetrics.sort((a, b) => b.area - a.area);
174
+ const top5 = allContourMetrics.slice(0, 5);
112
175
 
113
- contourMetrics.sort((a, b) => b.area - a.area);
176
+ console.log(`[DocDetect] top5 areas: ${top5.map(m => m.area.toFixed(0)).join(', ')} | imgArea=${imgArea.toFixed(0)}`);
114
177
 
115
- let largestPoly: Point[] | undefined = undefined;
178
+ // MIN_QUAD_AREA_RATIO = 0.25 (Python gốc), với fallback 0.05 cho ảnh nhỏ
179
+ const MIN_QUAD_AREA_RATIO = imgArea > 50000 ? 0.25 : 0.05;
116
180
 
117
- const maxChecks = Math.min(contourMetrics.length, 5);
118
- for (let i = 0; i < maxChecks; i++) {
119
- const metric = contourMetrics[i];
181
+ let bestPoly: Point[] | undefined = undefined;
182
+ let bestArea = 0;
183
+ let bestAngleRange = 999;
184
+ const debugCandidates: string[] = [];
185
+
186
+ for (let i = 0; i < top5.length; i++) {
187
+ const metric = top5[i];
120
188
  const contour = metric.contour;
121
189
 
122
- const periObj = OpenCV.invoke('arcLength', contour, true);
123
- const peri = periObj ? (periObj.value || 0) : 0;
124
-
125
- let found4 = false;
126
-
127
- // Vòng lặp tăng dần độ dung sai Epsilon (từ 2% -> 10% chu vi)
128
- // Giúp bắt gọn 4 đỉnh kể cả khi đường mép giấy tờ bị nhăn nheo, lởm chởm
129
- for (let ep = 0.02; ep <= 0.1; ep += 0.02) {
190
+ // Python gốc dùng epsilon = 80 pixel tuyệt đối (không phải % perimeter!)
191
+ // approxPolyDP(c, 80, True) epsilon cố định
192
+ // Ta thử range 10→100 pixel, bước 10, để tìm ra 4 đỉnh
193
+ let foundPts: Point[] | null = null;
194
+ let foundVerts = 0;
195
+ let foundAngle = 999;
196
+
197
+ for (let epPx = 10; epPx <= 120; epPx += 5) {
130
198
  const approx = OpenCV.createObject(ObjectType.PointVector);
131
- OpenCV.invoke('approxPolyDP', contour, approx, ep * peri, true);
199
+ OpenCV.invoke('approxPolyDP', contour, approx, epPx, true);
132
200
  const approxJS = OpenCV.toJSValue(approx);
133
-
134
- // Nếu nội suy thành công ra đúng 4 đỉnh
135
- if (approxJS && approxJS.array && approxJS.array.length === 4) {
136
- try {
137
- const isConvex = OpenCV.invoke('isContourConvex', approx);
138
- const convexValue = (typeof isConvex === 'object' && isConvex !== null) ? isConvex.value : isConvex;
139
- if (convexValue !== false) {
140
- largestPoly = approxJS.array as Point[];
141
- found4 = true;
142
- break;
201
+ const verts = approxJS?.array?.length ?? 0;
202
+
203
+ if (verts === 4) {
204
+ const pts = approxJS.array as Point[];
205
+ const sorted = this.sortCorners([...pts]);
206
+ const angleRange = this.getAngleRange(sorted);
207
+
208
+ if (angleRange < foundAngle) {
209
+ foundAngle = angleRange;
210
+ foundPts = pts;
211
+ foundVerts = verts;
212
+ }
213
+
214
+ // is_valid_contour: area > MIN_QUAD_AREA_RATIO và angle_range < 40
215
+ if (metric.area > imgArea * MIN_QUAD_AREA_RATIO && angleRange < 40) {
216
+ if (metric.area > bestArea || (metric.area === bestArea && angleRange < bestAngleRange)) {
217
+ bestPoly = pts;
218
+ bestArea = metric.area;
219
+ bestAngleRange = angleRange;
143
220
  }
144
- } catch (convexErr) {}
221
+ break;
222
+ }
145
223
  }
146
224
  }
147
-
148
- if (found4) {
149
- break;
150
- }
225
+
226
+ debugCandidates.push(
227
+ ` [C${i}] area=${metric.area.toFixed(0)}px²(${(metric.area/imgArea*100).toFixed(1)}%) ` +
228
+ `foundVerts=${foundVerts} bestAngle=${foundAngle === 999 ? 'N/A' : foundAngle.toFixed(1)}° ` +
229
+ `valid=${metric.area > imgArea * MIN_QUAD_AREA_RATIO && foundAngle < 40}`
230
+ );
151
231
  }
152
232
 
153
- // BƯỚC 6: Trả về góc và phóng trả lại tỷ lệ kích thước cũ
154
- if (largestPoly && largestPoly.length === 4) {
233
+ console.log(`[DocDetect] Candidates:\n${debugCandidates.join('\n')}`);
234
+ console.log(`[DocDetect] Result: bestPoly=${bestPoly ? 'found' : 'NOT FOUND'} area=${bestArea.toFixed(0)} angleRange=${bestAngleRange === 999 ? 'N/A' : bestAngleRange.toFixed(1)}°`);
235
+
236
+ // BƯỚC 7: Trả về góc, scale về kích thước ảnh gốc
237
+ if (bestPoly && bestPoly.length === 4) {
155
238
  const actualRatio = (resized === src) ? 1.0 : ratio;
156
- const originalCorners = largestPoly.map(p => ({
239
+ const originalCorners = bestPoly.map(p => ({
157
240
  x: Math.round(p.x * actualRatio),
158
241
  y: Math.round(p.y * actualRatio)
159
242
  }));
160
243
  return this.sortCorners(originalCorners);
161
244
  }
162
- 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
+ );
163
257
  } catch (e: any) {
164
- console.error('[OpenCV] Lỗi vòng quét nhận diện mép tài liệu:', e);
165
- 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
+ }
166
284
  } finally {
167
285
  OpenCV.clearBuffers();
168
286
  }