rn-opencv-doc-perspective-correction 1.0.16 → 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 RETR_LIST (1) để tóm được tài liệu nằm bên trong các viền nền lớn
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, 1 /* RETR_LIST */, 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,54 +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 (< 5% diện tích mặt quét)
80
- // Và 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)) {
127
+ if (area > (imgArea * MIN_AREA_RATIO)) {
83
128
  contourMetrics.push({ index: i, area, contour });
84
129
  }
85
130
  }
86
131
  contourMetrics.sort((a, b) => b.area - a.area);
87
- 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;
88
136
  const maxChecks = Math.min(contourMetrics.length, 5);
89
137
  for (let i = 0; i < maxChecks; i++) {
90
138
  const metric = contourMetrics[i];
91
139
  const contour = metric.contour;
92
140
  const periObj = react_native_fast_opencv_1.OpenCV.invoke('arcLength', contour, true);
93
141
  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) {
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) {
98
144
  const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
99
145
  react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contour, approx, ep * peri, true);
100
146
  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
147
  if (approxJS && approxJS.array && approxJS.array.length === 4) {
103
148
  try {
104
149
  const isConvex = react_native_fast_opencv_1.OpenCV.invoke('isContourConvex', approx);
105
150
  const convexValue = (typeof isConvex === 'object' && isConvex !== null) ? isConvex.value : isConvex;
106
- if (convexValue !== false) {
107
- largestPoly = approxJS.array;
108
- found4 = true;
109
- break;
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
110
165
  }
111
166
  }
112
- catch (convexErr) { }
167
+ catch (_convexErr) { }
113
168
  }
114
169
  }
115
- if (found4) {
116
- break;
117
- }
118
170
  }
119
- // BƯỚC 6: Trả về góc phóng trả lại tỷ lệ kích thước
120
- 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) {
121
173
  const actualRatio = (resized === src) ? 1.0 : ratio;
122
- const originalCorners = largestPoly.map(p => ({
174
+ const originalCorners = bestPoly.map(p => ({
123
175
  x: Math.round(p.x * actualRatio),
124
176
  y: Math.round(p.y * actualRatio)
125
177
  }));
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.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 RETR_LIST (1) để tóm được tài liệu nằm bên trong các viền nền lớn
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, 1 /* RETR_LIST */, 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,22 +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 (< 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)) {
151
+ if (area > (imgArea * MIN_AREA_RATIO)) {
109
152
  contourMetrics.push({ index: i, area, contour });
110
153
  }
111
154
  }
112
155
 
113
156
  contourMetrics.sort((a, b) => b.area - a.area);
114
157
 
115
- 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;
116
162
 
117
163
  const maxChecks = Math.min(contourMetrics.length, 5);
118
164
  for (let i = 0; i < maxChecks; i++) {
@@ -121,39 +167,42 @@ export class DocumentScanner {
121
167
 
122
168
  const periObj = OpenCV.invoke('arcLength', contour, true);
123
169
  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) {
170
+
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) {
130
173
  const approx = OpenCV.createObject(ObjectType.PointVector);
131
174
  OpenCV.invoke('approxPolyDP', contour, approx, ep * peri, true);
132
175
  const approxJS = OpenCV.toJSValue(approx);
133
176
 
134
- // Nếu nội suy thành công ra đúng 4 đỉnh
135
177
  if (approxJS && approxJS.array && approxJS.array.length === 4) {
136
178
  try {
137
179
  const isConvex = OpenCV.invoke('isContourConvex', approx);
138
180
  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;
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
143
196
  }
144
- } catch (convexErr) {}
197
+ } catch (_convexErr) {}
145
198
  }
146
199
  }
147
-
148
- if (found4) {
149
- break;
150
- }
151
200
  }
152
201
 
153
- // BƯỚC 6: Trả về góc phóng trả lại tỷ lệ kích thước
154
- 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) {
155
204
  const actualRatio = (resized === src) ? 1.0 : ratio;
156
- const originalCorners = largestPoly.map(p => ({
205
+ const originalCorners = bestPoly.map(p => ({
157
206
  x: Math.round(p.x * actualRatio),
158
207
  y: Math.round(p.y * actualRatio)
159
208
  }));