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

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,15 @@ 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;
14
23
  static detectPageCorners(imageBase64: string): Point[] | undefined;
15
24
  static applyPerspectiveCorrection(imageBase64: string, corners: Point[]): string | undefined;
16
25
  /**
package/dist/index.js CHANGED
@@ -17,6 +17,35 @@ 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
+ }
20
49
  static detectPageCorners(imageBase64) {
21
50
  let src = null;
22
51
  let resized = null;
@@ -24,7 +53,7 @@ class DocumentScanner {
24
53
  let blurred = null;
25
54
  let closed = null;
26
55
  let edges = null;
27
- let kernelDims = null;
56
+ let dilated = null;
28
57
  let contoursObj = null;
29
58
  let hierarchyObj = null;
30
59
  try {
@@ -46,25 +75,41 @@ class DocumentScanner {
46
75
  // BƯỚC 1: Chuyển sang ảnh xám (Grayscale)
47
76
  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
77
  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)
78
+ // BƯỚC 2: GaussianBlur 7x7 (theo andrewdcampbell)
50
79
  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
80
  const ksize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 7, 7);
52
81
  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
82
+ // BƯỚC 3: MORPH_CLOSE (kernel RECT 9x9) nối các lỗ trống giữa cạnh viền
83
+ // Thiếu bước này nguyên nhân chính khiến contour bị đứt đoạn
84
+ const MORPH_SIZE = 9;
85
+ const morphKernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, MORPH_SIZE, MORPH_SIZE);
86
+ const morphKernel = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, morphKernelSize);
55
87
  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 MORPH_CLOSE đã dọn dẹp mặt thẻ quá mượt.
88
+ try {
89
+ react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
90
+ }
91
+ catch (_morphErr) {
92
+ closed = blurred; // Fallback nếu thiết bị không hỗ trợ
93
+ }
94
+ // BƯỚC 4: Canny (0, 84) – ngưỡng thấp theo andrewdcampbell
95
+ // 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
96
+ // threshold2=84: ngưỡng cạnh mạnh vừa phải
61
97
  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
98
  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 External sẽ hoạt động hoàn hảo khi Canny chỉ thấy 1 nét vành ngoài.
99
+ // BƯỚC 5: Dilate 3x3 nối các pixel viền lân cận bị đứt
100
+ const dilKernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 3, 3);
101
+ const dilKernel = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, dilKernelSize);
102
+ 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);
103
+ try {
104
+ react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', edges, dilated, 1 /* MORPH_DILATE */, dilKernel);
105
+ }
106
+ catch (_dilErr) {
107
+ dilated = edges; // Fallback
108
+ }
109
+ // BƯỚC 6: Tìm Contours trên ảnh đã dilate
65
110
  contoursObj = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.MatVector);
66
111
  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, 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
112
+ react_native_fast_opencv_1.OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj, 1 /* RETR_LIST */, 2 /* CHAIN_APPROX_SIMPLE */);
68
113
  const contoursJS = react_native_fast_opencv_1.OpenCV.toJSValue(contoursObj);
69
114
  const contoursArray = (contoursJS === null || contoursJS === void 0 ? void 0 : contoursJS.array) || [];
70
115
  const contoursSize = contoursArray.length;
@@ -72,45 +117,61 @@ class DocumentScanner {
72
117
  return undefined;
73
118
  }
74
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;
75
123
  for (let i = 0; i < contoursSize; i++) {
76
124
  const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contoursObj, i);
77
125
  const areaObj = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour);
78
126
  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 vặt (< 5% diện tích mặt quét)
80
- if (area > (targetWidth * targetHeight * 0.05)) {
127
+ if (area > (imgArea * MIN_AREA_RATIO)) {
81
128
  contourMetrics.push({ index: i, area, contour });
82
129
  }
83
130
  }
84
131
  contourMetrics.sort((a, b) => b.area - a.area);
85
- let largestPoly = undefined;
132
+ // Thu thập TẤT CẢ candidates hợp lệ, chọn tốt nhất (không dừng sớm)
133
+ let bestPoly = undefined;
134
+ let bestArea = 0;
135
+ let bestAngleRange = 999;
86
136
  const maxChecks = Math.min(contourMetrics.length, 5);
87
137
  for (let i = 0; i < maxChecks; i++) {
88
138
  const metric = contourMetrics[i];
89
139
  const contour = metric.contour;
90
140
  const periObj = react_native_fast_opencv_1.OpenCV.invoke('arcLength', contour, true);
91
141
  const peri = periObj ? (periObj.value || 0) : 0;
92
- const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
93
- // Nội suy (approx) giảm bớt gấp khúc với độ dung sai epsilon 0.02 chu vi
94
- react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx, 0.02 * peri, true);
95
- const approxJS = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
96
- // Nếu quy chiếu thành công ra đúng 4 đỉnh
97
- if (approxJS && approxJS.array && approxJS.array.length === 4) {
98
- try {
99
- const isConvex = react_native_fast_opencv_1.OpenCV.invoke('isContourConvex', approx);
100
- const convexValue = (typeof isConvex === 'object' && isConvex !== null) ? isConvex.value : isConvex;
101
- if (convexValue === false) {
102
- continue;
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) {
144
+ 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);
146
+ 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
165
+ }
103
166
  }
167
+ catch (_convexErr) { }
104
168
  }
105
- catch (convexErr) { }
106
- largestPoly = approxJS.array;
107
- break;
108
169
  }
109
170
  }
110
- // BƯỚC 6: Trả về góc phóng trả lại tỷ lệ kích thước
111
- if (largestPoly && largestPoly.length === 4) {
171
+ // BƯỚC 7: Trả về góc, phóng trả tỷ lệ kích thước gốc
172
+ if (bestPoly && bestPoly.length === 4) {
112
173
  const actualRatio = (resized === src) ? 1.0 : ratio;
113
- const originalCorners = largestPoly.map(p => ({
174
+ const originalCorners = bestPoly.map(p => ({
114
175
  x: Math.round(p.x * actualRatio),
115
176
  y: Math.round(p.y * actualRatio)
116
177
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-opencv-doc-perspective-correction",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
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,35 @@ 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
+
33
62
  public static detectPageCorners(imageBase64: string): Point[] | undefined {
34
63
  let src: OpenCVMat | null = null;
35
64
  let resized: OpenCVMat | null = null;
@@ -37,7 +66,7 @@ export class DocumentScanner {
37
66
  let blurred: OpenCVMat | null = null;
38
67
  let closed: OpenCVMat | null = null;
39
68
  let edges: OpenCVMat | null = null;
40
- let kernelDims: any = null;
69
+ let dilated: OpenCVMat | null = null;
41
70
  let contoursObj: any = null;
42
71
  let hierarchyObj: any = null;
43
72
 
@@ -65,28 +94,43 @@ export class DocumentScanner {
65
94
  gray = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
66
95
  OpenCV.invoke('cvtColor', resized, gray, ColorConversionCodes.COLOR_BGR2GRAY);
67
96
 
68
- // BƯỚC 2: Làm mờ để loại bỏ nhiễu hạt (Gaussian Blur 7x7)
97
+ // BƯỚC 2: GaussianBlur 7x7 (theo andrewdcampbell)
69
98
  blurred = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
70
99
  const ksize = OpenCV.createObject(ObjectType.Size, 7, 7);
71
100
  OpenCV.invoke('GaussianBlur', gray, blurred, ksize, 0);
72
101
 
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
102
+ // BƯỚC 3: MORPH_CLOSE (kernel RECT 9x9) nối các lỗ trống giữa cạnh viền
103
+ // Thiếu bước này nguyên nhân chính khiến contour bị đứt đoạn
104
+ const MORPH_SIZE = 9;
105
+ const morphKernelSize = OpenCV.createObject(ObjectType.Size, MORPH_SIZE, MORPH_SIZE);
106
+ const morphKernel = OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, morphKernelSize);
75
107
  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);
108
+ try {
109
+ OpenCV.invoke('morphologyEx', blurred, closed, 3 /* MORPH_CLOSE */, morphKernel);
110
+ } catch (_morphErr) {
111
+ closed = blurred; // Fallback nếu thiết bị không hỗ trợ
112
+ }
79
113
 
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.
114
+ // BƯỚC 4: Canny (0, 84) ngưỡng thấp theo andrewdcampbell
115
+ // 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
116
+ // threshold2=84: ngưỡng cạnh mạnh vừa phải
82
117
  edges = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
83
118
  OpenCV.invoke('Canny', closed, edges, 0, 84);
84
119
 
85
- // BƯỚC 5: Tìm khối đa giác liên kết (Contours)
86
- // Lệnh bắt External sẽ hoạt động hoàn hảo khi Canny chỉ thấy 1 nét vành ngoài.
120
+ // BƯỚC 5: Dilate 3x3 nối các pixel viền lân cận bị đứt
121
+ const dilKernelSize = OpenCV.createObject(ObjectType.Size, 3, 3);
122
+ const dilKernel = OpenCV.invoke('getStructuringElement', 0 /* MORPH_RECT */, dilKernelSize);
123
+ dilated = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
124
+ try {
125
+ OpenCV.invoke('morphologyEx', edges, dilated, 1 /* MORPH_DILATE */, dilKernel);
126
+ } catch (_dilErr) {
127
+ dilated = edges; // Fallback
128
+ }
129
+
130
+ // BƯỚC 6: Tìm Contours trên ảnh đã dilate
87
131
  contoursObj = OpenCV.createObject(ObjectType.MatVector);
88
132
  hierarchyObj = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
89
- OpenCV.invoke('findContoursWithHierarchy', edges, contoursObj, hierarchyObj, 0 /* RETR_EXTERNAL */, 2 /* CHAIN_APPROX_SIMPLE */);
133
+ OpenCV.invoke('findContoursWithHierarchy', dilated, contoursObj, hierarchyObj, 1 /* RETR_LIST */, 2 /* CHAIN_APPROX_SIMPLE */);
90
134
 
91
135
  const contoursJS = OpenCV.toJSValue(contoursObj);
92
136
  const contoursArray = contoursJS?.array || [];
@@ -97,20 +141,24 @@ export class DocumentScanner {
97
141
  }
98
142
 
99
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;
100
147
  for (let i = 0; i < contoursSize; i++) {
101
148
  const contour = OpenCV.copyObjectFromVector(contoursObj, i);
102
149
  const areaObj = OpenCV.invoke('contourArea', contour);
103
150
  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 vặt (< 5% diện tích mặt quét)
106
- if (area > (targetWidth * targetHeight * 0.05)) {
151
+ if (area > (imgArea * MIN_AREA_RATIO)) {
107
152
  contourMetrics.push({ index: i, area, contour });
108
153
  }
109
154
  }
110
155
 
111
156
  contourMetrics.sort((a, b) => b.area - a.area);
112
157
 
113
- let largestPoly: Point[] | undefined = undefined;
158
+ // Thu thập TẤT CẢ candidates hợp lệ, chọn tốt nhất (không dừng sớm)
159
+ let bestPoly: Point[] | undefined = undefined;
160
+ let bestArea = 0;
161
+ let bestAngleRange = 999;
114
162
 
115
163
  const maxChecks = Math.min(contourMetrics.length, 5);
116
164
  for (let i = 0; i < maxChecks; i++) {
@@ -119,33 +167,42 @@ export class DocumentScanner {
119
167
 
120
168
  const periObj = OpenCV.invoke('arcLength', contour, true);
121
169
  const peri = periObj ? (periObj.value || 0) : 0;
122
- const approx = OpenCV.createObject(ObjectType.PointVector);
123
-
124
- // Nội suy (approx) giảm bớt gấp khúc với độ dung sai epsilon 0.02 chu vi
125
- OpenCV.invoke('approxPolyDP', contour, approx, 0.02 * peri, true);
126
170
 
127
- const approxJS = OpenCV.toJSValue(approx);
128
-
129
- // Nếu quy chiếu thành công ra đúng 4 đỉnh
130
- if (approxJS && approxJS.array && approxJS.array.length === 4) {
131
-
132
- try {
133
- const isConvex = OpenCV.invoke('isContourConvex', approx);
134
- const convexValue = (typeof isConvex === 'object' && isConvex !== null) ? isConvex.value : isConvex;
135
- if (convexValue === false) {
136
- continue;
137
- }
138
- } catch (convexErr) {}
139
-
140
- largestPoly = approxJS.array as Point[];
141
- break;
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) {
173
+ const approx = OpenCV.createObject(ObjectType.PointVector);
174
+ OpenCV.invoke('approxPolyDP', contour, approx, ep * peri, true);
175
+ 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
196
+ }
197
+ } catch (_convexErr) {}
198
+ }
142
199
  }
143
200
  }
144
201
 
145
- // BƯỚC 6: Trả về góc phóng trả lại tỷ lệ kích thước
146
- if (largestPoly && largestPoly.length === 4) {
202
+ // BƯỚC 7: Trả về góc, phóng trả tỷ lệ kích thước gốc
203
+ if (bestPoly && bestPoly.length === 4) {
147
204
  const actualRatio = (resized === src) ? 1.0 : ratio;
148
- const originalCorners = largestPoly.map(p => ({
205
+ const originalCorners = bestPoly.map(p => ({
149
206
  x: Math.round(p.x * actualRatio),
150
207
  y: Math.round(p.y * actualRatio)
151
208
  }));