@andresxid/xid_camera_ocr_sdk_react 0.1.0
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/CHANGELOG.md +24 -0
- package/README.md +223 -0
- package/dist/LICENSE-OpenCV.txt +202 -0
- package/dist/NOTICE-OpenCV.txt +43 -0
- package/dist/cv-worker.js +670 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +459 -0
- package/dist/index.d.ts +459 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/opencv.js +51 -0
- package/package.json +79 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// src/core/geometry.ts
|
|
2
|
+
var ID1_RATIO = 85.6 / 53.98;
|
|
3
|
+
var TD3_RATIO = 125 / 88;
|
|
4
|
+
var TYPE_BOUNDARY_RATIO = 1.503;
|
|
5
|
+
var HINT_TOLERANCE = 0.05;
|
|
6
|
+
var CAPTURABLE_ASPECT_MIN = 1.25;
|
|
7
|
+
var CAPTURABLE_ASPECT_MAX = 1.75;
|
|
8
|
+
var ASPECT_SCORE_FALLOFF = 0.25;
|
|
9
|
+
function orderCorners(points) {
|
|
10
|
+
if (points.length !== 4) throw new Error(`orderCorners expects 4 points, got ${points.length}`);
|
|
11
|
+
const cx = (points[0].x + points[1].x + points[2].x + points[3].x) / 4;
|
|
12
|
+
const cy = (points[0].y + points[1].y + points[2].y + points[3].y) / 4;
|
|
13
|
+
const cycle = [...points].sort(
|
|
14
|
+
(a, b) => Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx)
|
|
15
|
+
);
|
|
16
|
+
let start = 0;
|
|
17
|
+
for (let i = 1; i < 4; i++) {
|
|
18
|
+
const p = cycle[i];
|
|
19
|
+
const s = cycle[start];
|
|
20
|
+
if (p.x + p.y < s.x + s.y || p.x + p.y === s.x + s.y && p.y < s.y) start = i;
|
|
21
|
+
}
|
|
22
|
+
return [0, 1, 2, 3].map((k) => cycle[(start + k) % 4]);
|
|
23
|
+
}
|
|
24
|
+
function polygonArea(points) {
|
|
25
|
+
let sum = 0;
|
|
26
|
+
for (let i = 0; i < points.length; i++) {
|
|
27
|
+
const a = points[i];
|
|
28
|
+
const b = points[(i + 1) % points.length];
|
|
29
|
+
sum += a.x * b.y - b.x * a.y;
|
|
30
|
+
}
|
|
31
|
+
return Math.abs(sum) / 2;
|
|
32
|
+
}
|
|
33
|
+
function sideLengths(quad) {
|
|
34
|
+
const [tl, tr, br, bl] = quad;
|
|
35
|
+
return {
|
|
36
|
+
top: Math.hypot(tr.x - tl.x, tr.y - tl.y),
|
|
37
|
+
right: Math.hypot(br.x - tr.x, br.y - tr.y),
|
|
38
|
+
bottom: Math.hypot(br.x - bl.x, br.y - bl.y),
|
|
39
|
+
left: Math.hypot(bl.x - tl.x, bl.y - tl.y)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function interiorAngles(quad) {
|
|
43
|
+
const out = [0, 0, 0, 0];
|
|
44
|
+
for (let i = 0; i < 4; i++) {
|
|
45
|
+
const prev = quad[(i + 3) % 4];
|
|
46
|
+
const cur = quad[i];
|
|
47
|
+
const next = quad[(i + 1) % 4];
|
|
48
|
+
const ax = prev.x - cur.x;
|
|
49
|
+
const ay = prev.y - cur.y;
|
|
50
|
+
const bx = next.x - cur.x;
|
|
51
|
+
const by = next.y - cur.y;
|
|
52
|
+
const la = Math.hypot(ax, ay);
|
|
53
|
+
const lb = Math.hypot(bx, by);
|
|
54
|
+
if (la === 0 || lb === 0) {
|
|
55
|
+
out[i] = 0;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const cos = Math.min(1, Math.max(-1, (ax * bx + ay * by) / (la * lb)));
|
|
59
|
+
out[i] = Math.acos(cos) * 180 / Math.PI;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function isValidQuad(quad, minAngle = 70, maxAngle = 110) {
|
|
64
|
+
const s = sideLengths(quad);
|
|
65
|
+
if (s.top <= 0 || s.right <= 0 || s.bottom <= 0 || s.left <= 0) return false;
|
|
66
|
+
if (!isConvex(quad) || polygonArea(quad) <= 0) return false;
|
|
67
|
+
return interiorAngles(quad).every((a) => a >= minAngle && a <= maxAngle);
|
|
68
|
+
}
|
|
69
|
+
function isConvex(quad) {
|
|
70
|
+
let sign = 0;
|
|
71
|
+
for (let i = 0; i < 4; i++) {
|
|
72
|
+
const a = quad[i];
|
|
73
|
+
const b = quad[(i + 1) % 4];
|
|
74
|
+
const c = quad[(i + 2) % 4];
|
|
75
|
+
const cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
|
|
76
|
+
if (cross === 0) return false;
|
|
77
|
+
const s = Math.sign(cross);
|
|
78
|
+
if (sign === 0) sign = s;
|
|
79
|
+
else if (s !== sign) return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
function rectifiedAspect(quad) {
|
|
84
|
+
const s = sideLengths(quad);
|
|
85
|
+
const w = (s.top + s.bottom) / 2;
|
|
86
|
+
const h = (s.left + s.right) / 2;
|
|
87
|
+
const short = Math.min(w, h);
|
|
88
|
+
if (short <= 0) return 0;
|
|
89
|
+
return Math.max(w, h) / short;
|
|
90
|
+
}
|
|
91
|
+
function aspectScore(aspect) {
|
|
92
|
+
if (!Number.isFinite(aspect)) return 0;
|
|
93
|
+
const d = Math.min(Math.abs(aspect - ID1_RATIO), Math.abs(aspect - TD3_RATIO));
|
|
94
|
+
return Math.max(0, 1 - d / ASPECT_SCORE_FALLOFF);
|
|
95
|
+
}
|
|
96
|
+
function scoreCandidate(c) {
|
|
97
|
+
return 0.5 * c.areaRatio + 0.3 * c.rectangularity + 0.2 * aspectScore(c.aspect);
|
|
98
|
+
}
|
|
99
|
+
function typeHintFromAspect(aspect) {
|
|
100
|
+
const nominal = aspect >= TYPE_BOUNDARY_RATIO ? ID1_RATIO : TD3_RATIO;
|
|
101
|
+
if (Math.abs(aspect - nominal) / nominal <= HINT_TOLERANCE) {
|
|
102
|
+
return nominal === ID1_RATIO ? "INE" : "PASSPORT_MX";
|
|
103
|
+
}
|
|
104
|
+
return "OTHER";
|
|
105
|
+
}
|
|
106
|
+
function isCapturableAspect(aspect) {
|
|
107
|
+
return aspect >= CAPTURABLE_ASPECT_MIN && aspect <= CAPTURABLE_ASPECT_MAX;
|
|
108
|
+
}
|
|
109
|
+
function canonicalRatioFor(type, measuredAspect) {
|
|
110
|
+
if (type === "INE") return ID1_RATIO;
|
|
111
|
+
if (type === "PASSPORT_MX") return TD3_RATIO;
|
|
112
|
+
return measuredAspect;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/core/quality.ts
|
|
116
|
+
var QUALITY_THRESHOLDS = {
|
|
117
|
+
brightnessMin: 0.18,
|
|
118
|
+
brightnessMax: 0.9,
|
|
119
|
+
blurMin: 60,
|
|
120
|
+
glareMax: 0.04,
|
|
121
|
+
fillMin: 0.25,
|
|
122
|
+
glarePixel: 248
|
|
123
|
+
};
|
|
124
|
+
function quadRowSpans(quad, width, height) {
|
|
125
|
+
const spans = new Int32Array(height * 2);
|
|
126
|
+
for (let y = 0; y < height; y++) {
|
|
127
|
+
const yc = y + 0.5;
|
|
128
|
+
let xMin = Infinity;
|
|
129
|
+
let xMax = -Infinity;
|
|
130
|
+
for (let i = 0; i < 4; i++) {
|
|
131
|
+
const a = quad[i];
|
|
132
|
+
const b = quad[(i + 1) % 4];
|
|
133
|
+
const lo = Math.min(a.y, b.y);
|
|
134
|
+
const hi = Math.max(a.y, b.y);
|
|
135
|
+
if (yc < lo || yc > hi || hi === lo) continue;
|
|
136
|
+
const x = a.x + (yc - a.y) * (b.x - a.x) / (b.y - a.y);
|
|
137
|
+
if (x < xMin) xMin = x;
|
|
138
|
+
if (x > xMax) xMax = x;
|
|
139
|
+
}
|
|
140
|
+
if (xMin === Infinity) {
|
|
141
|
+
spans[y * 2] = 0;
|
|
142
|
+
spans[y * 2 + 1] = 0;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
spans[y * 2] = Math.max(0, Math.ceil(xMin - 0.5));
|
|
146
|
+
spans[y * 2 + 1] = Math.min(width, Math.floor(xMax + 0.5));
|
|
147
|
+
}
|
|
148
|
+
return spans;
|
|
149
|
+
}
|
|
150
|
+
function measureQuality(gray, quad) {
|
|
151
|
+
const { data, width: W, height: H } = gray;
|
|
152
|
+
const spans = quadRowSpans(quad, W, H);
|
|
153
|
+
const glarePixel = QUALITY_THRESHOLDS.glarePixel;
|
|
154
|
+
let sum = 0;
|
|
155
|
+
let glare = 0;
|
|
156
|
+
let count = 0;
|
|
157
|
+
let lapSum = 0;
|
|
158
|
+
let lapSq = 0;
|
|
159
|
+
let lapCount = 0;
|
|
160
|
+
for (let y = 0; y < H; y++) {
|
|
161
|
+
const xs = spans[y * 2];
|
|
162
|
+
const xe = spans[y * 2 + 1];
|
|
163
|
+
if (xs >= xe) continue;
|
|
164
|
+
let lx = 0;
|
|
165
|
+
let rx = 0;
|
|
166
|
+
if (y > 0 && y < H - 1) {
|
|
167
|
+
lx = Math.max(xs, spans[(y - 1) * 2], spans[(y + 1) * 2]) + 1;
|
|
168
|
+
rx = Math.min(xe, spans[(y - 1) * 2 + 1], spans[(y + 1) * 2 + 1]) - 1;
|
|
169
|
+
}
|
|
170
|
+
const row = y * W;
|
|
171
|
+
for (let x = xs; x < xe; x++) {
|
|
172
|
+
const i = row + x;
|
|
173
|
+
const v = data[i];
|
|
174
|
+
sum += v;
|
|
175
|
+
if (v >= glarePixel) glare++;
|
|
176
|
+
count++;
|
|
177
|
+
if (x >= lx && x < rx) {
|
|
178
|
+
const r = 4 * v - data[i - W] - data[i + W] - data[i - 1] - data[i + 1];
|
|
179
|
+
lapSum += r;
|
|
180
|
+
lapSq += r * r;
|
|
181
|
+
lapCount++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const brightness = count > 0 ? sum / count / 255 : 0;
|
|
186
|
+
const glareRatio = count > 0 ? glare / count : 0;
|
|
187
|
+
const blurScore = lapCount > 0 ? Math.max(0, lapSq / lapCount - (lapSum / lapCount) ** 2) : 0;
|
|
188
|
+
const frameArea = W * H;
|
|
189
|
+
const docFillRatio = frameArea > 0 ? polygonArea(quad) / frameArea : 0;
|
|
190
|
+
const ok = brightness >= QUALITY_THRESHOLDS.brightnessMin && brightness <= QUALITY_THRESHOLDS.brightnessMax && blurScore >= QUALITY_THRESHOLDS.blurMin && glareRatio <= QUALITY_THRESHOLDS.glareMax && docFillRatio >= QUALITY_THRESHOLDS.fillMin;
|
|
191
|
+
return { brightness, blurScore, glareRatio, docFillRatio, ok };
|
|
192
|
+
}
|
|
193
|
+
function frameMeanBrightness(gray) {
|
|
194
|
+
const { data } = gray;
|
|
195
|
+
if (data.length === 0) return 0;
|
|
196
|
+
let sum = 0;
|
|
197
|
+
for (let i = 0; i < data.length; i++) sum += data[i];
|
|
198
|
+
return sum / data.length / 255;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/core/cv-detection.ts
|
|
202
|
+
var MIN_AREA_RATIO = 0.08;
|
|
203
|
+
var MAX_AREA_RATIO = 0.95;
|
|
204
|
+
var APPROX_EPSILON = 0.02;
|
|
205
|
+
var BORDER_PADDING = 8;
|
|
206
|
+
var CLAHE_CLIP = 2;
|
|
207
|
+
var CLAHE_TILE = 8;
|
|
208
|
+
var CANNY_SIGMA = 0.33;
|
|
209
|
+
var DILATE_ITERATIONS = 2;
|
|
210
|
+
var BORDER_TOUCH_PX = 2;
|
|
211
|
+
var MAX_BORDER_CORNERS = 1;
|
|
212
|
+
var BORDER_TOUCH_LIMIT = BORDER_TOUCH_PX + DILATE_ITERATIONS;
|
|
213
|
+
var NESTED_AREA_FACTOR = 2;
|
|
214
|
+
var NESTED_MARGIN_PX = 2 * (DILATE_ITERATIONS + 1);
|
|
215
|
+
var POLARITY_MIN_DIFF = 10;
|
|
216
|
+
var POLARITY_RING_SCALE = 1.1;
|
|
217
|
+
var QUALITY_INSET_PX = DILATE_ITERATIONS + 1;
|
|
218
|
+
var MAX_REDUCIBLE_VERTICES = 6;
|
|
219
|
+
var REDUCED_MIN_FILL = 0.92;
|
|
220
|
+
var REDUCED_MAX_FILL = 1 / REDUCED_MIN_FILL;
|
|
221
|
+
var INDIRECT_SCORE_FACTOR = 0.85;
|
|
222
|
+
var PRIOR_WEIGHT = 2;
|
|
223
|
+
var PRIOR_D_CAP = 0.05;
|
|
224
|
+
var COARSE_SCALE = 2;
|
|
225
|
+
var MIN_COARSE_SIDE = 64;
|
|
226
|
+
function empty(frameBrightness) {
|
|
227
|
+
return { detected: false, documentType: null, aspectRatio: null, corners: null, boundingBox: null, quality: null, frameBrightness, score: null };
|
|
228
|
+
}
|
|
229
|
+
function detectDocumentCV(cv, imageData, _acceptedTypes, opts = {}) {
|
|
230
|
+
const frame = { brightness: 0 };
|
|
231
|
+
try {
|
|
232
|
+
return detectInner(cv, imageData, frame, opts.prior ?? null);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn("[xid-camera-ocr] detectDocumentCV failed:", err);
|
|
235
|
+
return empty(frame.brightness);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function detectInner(cv, imageData, frame, prior) {
|
|
239
|
+
const { width: W, height: H } = imageData;
|
|
240
|
+
const mats = [];
|
|
241
|
+
const own = (m) => {
|
|
242
|
+
mats.push(m);
|
|
243
|
+
return m;
|
|
244
|
+
};
|
|
245
|
+
try {
|
|
246
|
+
const src = own(cv.matFromImageData(imageData));
|
|
247
|
+
const gray = own(new cv.Mat());
|
|
248
|
+
const small = own(new cv.Mat());
|
|
249
|
+
const ws = {
|
|
250
|
+
equalized: own(new cv.Mat()),
|
|
251
|
+
filtered: own(new cv.Mat()),
|
|
252
|
+
padded: own(new cv.Mat()),
|
|
253
|
+
edges: own(new cv.Mat()),
|
|
254
|
+
dilated: own(new cv.Mat()),
|
|
255
|
+
kernel: own(cv.Mat.ones(3, 3, cv.CV_8U)),
|
|
256
|
+
contours: own(new cv.MatVector()),
|
|
257
|
+
hierarchy: own(new cv.Mat()),
|
|
258
|
+
hull: own(new cv.Mat()),
|
|
259
|
+
approx: own(new cv.Mat()),
|
|
260
|
+
clahe: own(new cv.CLAHE(CLAHE_CLIP, new cv.Size(CLAHE_TILE, CLAHE_TILE)))
|
|
261
|
+
};
|
|
262
|
+
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
|
|
263
|
+
frame.brightness = frameMeanBrightness(grayView(gray, W, H));
|
|
264
|
+
const cut = [];
|
|
265
|
+
let best = scanScale(cv, gray, ws, W, H, cut, prior);
|
|
266
|
+
let lowres = false;
|
|
267
|
+
const sw = Math.round(W / COARSE_SCALE), sh = Math.round(H / COARSE_SCALE);
|
|
268
|
+
if (!best && sw >= MIN_COARSE_SIDE && sh >= MIN_COARSE_SIDE) {
|
|
269
|
+
cv.resize(gray, small, new cv.Size(sw, sh), 0, 0, cv.INTER_AREA);
|
|
270
|
+
best = scanScale(cv, small, ws, W, H, cut, prior);
|
|
271
|
+
lowres = best !== null;
|
|
272
|
+
}
|
|
273
|
+
if (best && isNestedInCut(best, cut, grayView(gray, W, H))) best = null;
|
|
274
|
+
if (!best) return empty(frame.brightness);
|
|
275
|
+
const provenance = lowres ? "lowres" : best.reduced ? "reduced" : "direct";
|
|
276
|
+
const xs = best.quad.map((p) => p.x);
|
|
277
|
+
const ys = best.quad.map((p) => p.y);
|
|
278
|
+
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
|
|
279
|
+
return {
|
|
280
|
+
detected: true,
|
|
281
|
+
documentType: typeHintFromAspect(best.aspect),
|
|
282
|
+
aspectRatio: best.aspect,
|
|
283
|
+
corners: best.quad,
|
|
284
|
+
boundingBox: { x: minX / W, y: minY / H, width: (maxX - minX) / W, height: (maxY - minY) / H },
|
|
285
|
+
// Fresh view: any allocation since cvtColor may have grown (and detached) the heap.
|
|
286
|
+
// The inset is in pass pixels: a half-resolution contour sits twice as far (in
|
|
287
|
+
// full-frame pixels) outside the edge.
|
|
288
|
+
quality: measureQuality(grayView(gray, W, H), insetQuad(best.quad, QUALITY_INSET_PX * best.scale)),
|
|
289
|
+
frameBrightness: frame.brightness,
|
|
290
|
+
score: provenance === "direct" ? best.score : best.score * INDIRECT_SCORE_FACTOR,
|
|
291
|
+
provenance
|
|
292
|
+
};
|
|
293
|
+
} finally {
|
|
294
|
+
for (const m of mats) m.delete();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function scanScale(cv, gray, ws, W, H, cut, prior) {
|
|
298
|
+
const w = gray.cols, h = gray.rows;
|
|
299
|
+
const sx = W / w, sy = H / h;
|
|
300
|
+
const passArea = w * h;
|
|
301
|
+
const up = (p) => ({ x: p.x * sx, y: p.y * sy });
|
|
302
|
+
const { hull, approx, contours } = ws;
|
|
303
|
+
ws.clahe.apply(gray, ws.equalized);
|
|
304
|
+
cv.bilateralFilter(ws.equalized, ws.filtered, 9, 75, 75, cv.BORDER_DEFAULT);
|
|
305
|
+
cv.copyMakeBorder(ws.filtered, ws.padded, BORDER_PADDING, BORDER_PADDING, BORDER_PADDING, BORDER_PADDING, cv.BORDER_CONSTANT, new cv.Scalar(0));
|
|
306
|
+
const median = medianOf(ws.padded.data);
|
|
307
|
+
cv.Canny(ws.padded, ws.edges, Math.max(10, (1 - CANNY_SIGMA) * median), Math.min(255, (1 + CANNY_SIGMA) * median));
|
|
308
|
+
cv.dilate(ws.edges, ws.dilated, ws.kernel, new cv.Point(-1, -1), DILATE_ITERATIONS);
|
|
309
|
+
cv.findContours(ws.dilated, contours, ws.hierarchy, cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE);
|
|
310
|
+
let best = null;
|
|
311
|
+
for (let i = 0; i < contours.size(); i++) {
|
|
312
|
+
const c = contours.get(i);
|
|
313
|
+
try {
|
|
314
|
+
const box = cv.boundingRect(c);
|
|
315
|
+
if (box.width * box.height < passArea * MIN_AREA_RATIO) continue;
|
|
316
|
+
cv.convexHull(c, hull, false, true);
|
|
317
|
+
const area = cv.contourArea(hull);
|
|
318
|
+
if (area < passArea * MIN_AREA_RATIO || area > passArea * MAX_AREA_RATIO) continue;
|
|
319
|
+
cv.approxPolyDP(hull, approx, APPROX_EPSILON * cv.arcLength(hull, true), true);
|
|
320
|
+
if (approx.rows < 4 || approx.rows > MAX_REDUCIBLE_VERTICES || !cv.isContourConvex(approx)) continue;
|
|
321
|
+
const reduced = approx.rows !== 4;
|
|
322
|
+
let pts;
|
|
323
|
+
if (!reduced) {
|
|
324
|
+
pts = matPoints(approx, w, h);
|
|
325
|
+
} else {
|
|
326
|
+
const quad4 = reduceToQuad(rawPoints(approx));
|
|
327
|
+
if (!quad4) continue;
|
|
328
|
+
pts = quad4.map((p) => clampPoint(p, w, h));
|
|
329
|
+
const fill = area / polygonArea(pts);
|
|
330
|
+
if (!(fill >= REDUCED_MIN_FILL && fill <= REDUCED_MAX_FILL)) continue;
|
|
331
|
+
}
|
|
332
|
+
const fullArea = area * sx * sy;
|
|
333
|
+
const full = pts.map(up);
|
|
334
|
+
const borderLimit = BORDER_TOUCH_LIMIT * sx;
|
|
335
|
+
if (borderCorners(full, W, H, borderLimit) > MAX_BORDER_CORNERS) {
|
|
336
|
+
if (!spansFrame(full, W, H, borderLimit)) cut.push({ pts: full, area: fullArea });
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const quad = orderCorners(full);
|
|
340
|
+
if (!isValidQuad(quad)) continue;
|
|
341
|
+
const rect = cv.minAreaRect(reduced ? hull : approx);
|
|
342
|
+
const rectArea = rect.size.width * rect.size.height;
|
|
343
|
+
const rectangularity = rectArea > 0 ? Math.min(1, area / rectArea) : 0;
|
|
344
|
+
const aspect = rectifiedAspect(quad);
|
|
345
|
+
if (!isCapturableAspect(aspect)) continue;
|
|
346
|
+
const score = scoreCandidate({ areaRatio: area / passArea, rectangularity, aspect });
|
|
347
|
+
const rank = prior ? score - priorPenalty(quad, prior, W, H) : score;
|
|
348
|
+
if (!best || rank > best.rank) best = { quad, score, rank, aspect, area: fullArea, scale: sx, reduced };
|
|
349
|
+
} finally {
|
|
350
|
+
c.delete();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return best;
|
|
354
|
+
}
|
|
355
|
+
function priorPenalty(quad, prior, W, H) {
|
|
356
|
+
let best = Infinity;
|
|
357
|
+
for (let shift = 0; shift < 4; shift++) {
|
|
358
|
+
let maxMove = 0;
|
|
359
|
+
for (let i = 0; i < 4; i++) {
|
|
360
|
+
const p = quad[(i + shift) % 4];
|
|
361
|
+
maxMove = Math.max(maxMove, Math.hypot(p.x - prior[i].x, p.y - prior[i].y));
|
|
362
|
+
}
|
|
363
|
+
best = Math.min(best, maxMove);
|
|
364
|
+
}
|
|
365
|
+
return PRIOR_WEIGHT * Math.min(best / Math.hypot(W, H), PRIOR_D_CAP);
|
|
366
|
+
}
|
|
367
|
+
function grayView(gray, width, height) {
|
|
368
|
+
return { data: gray.data, width, height };
|
|
369
|
+
}
|
|
370
|
+
function rawPoints(mat) {
|
|
371
|
+
const d = mat.data32S;
|
|
372
|
+
const pts = [];
|
|
373
|
+
for (let k = 0; k < mat.rows; k++) pts.push({ x: d[k * 2] - BORDER_PADDING, y: d[k * 2 + 1] - BORDER_PADDING });
|
|
374
|
+
return pts;
|
|
375
|
+
}
|
|
376
|
+
function clampPoint(p, W, H) {
|
|
377
|
+
return { x: Math.min(W, Math.max(0, p.x)), y: Math.min(H, Math.max(0, p.y)) };
|
|
378
|
+
}
|
|
379
|
+
function matPoints(mat, W, H) {
|
|
380
|
+
return rawPoints(mat).map((p) => clampPoint(p, W, H));
|
|
381
|
+
}
|
|
382
|
+
function reduceToQuad(poly) {
|
|
383
|
+
let pts = poly;
|
|
384
|
+
while (pts.length > 4) {
|
|
385
|
+
const n = pts.length;
|
|
386
|
+
let bestIdx = -1, bestAdded = Infinity, bestX = null;
|
|
387
|
+
for (let i = 0; i < n; i++) {
|
|
388
|
+
const a0 = pts[(i + n - 1) % n], a1 = pts[i], b1 = pts[(i + 1) % n], b0 = pts[(i + 2) % n];
|
|
389
|
+
const x = forwardIntersection(a0, a1, b0, b1);
|
|
390
|
+
if (!x) continue;
|
|
391
|
+
const added = Math.abs((x.x - a1.x) * (b1.y - a1.y) - (x.y - a1.y) * (b1.x - a1.x)) / 2;
|
|
392
|
+
if (added < bestAdded) {
|
|
393
|
+
bestAdded = added;
|
|
394
|
+
bestIdx = i;
|
|
395
|
+
bestX = x;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (bestIdx < 0 || !bestX) return null;
|
|
399
|
+
const next = [];
|
|
400
|
+
for (let k = 0; k < n; k++) {
|
|
401
|
+
if (k === bestIdx) next.push(bestX);
|
|
402
|
+
else if (k !== (bestIdx + 1) % n) next.push(pts[k]);
|
|
403
|
+
}
|
|
404
|
+
pts = next;
|
|
405
|
+
}
|
|
406
|
+
return pts;
|
|
407
|
+
}
|
|
408
|
+
function forwardIntersection(a0, a1, b0, b1) {
|
|
409
|
+
const dax = a1.x - a0.x, day = a1.y - a0.y, dbx = b1.x - b0.x, dby = b1.y - b0.y;
|
|
410
|
+
const den = dax * dby - day * dbx;
|
|
411
|
+
if (Math.abs(den) < 1e-9) return null;
|
|
412
|
+
const t = ((b0.x - a0.x) * dby - (b0.y - a0.y) * dbx) / den;
|
|
413
|
+
const u = ((b0.x - a0.x) * day - (b0.y - a0.y) * dax) / den;
|
|
414
|
+
if (t < 1 || u < 1) return null;
|
|
415
|
+
return { x: a0.x + t * dax, y: a0.y + t * day };
|
|
416
|
+
}
|
|
417
|
+
function spansFrame(pts, W, H, limit) {
|
|
418
|
+
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
|
419
|
+
return Math.min(...xs) <= limit && Math.max(...xs) >= W - limit || Math.min(...ys) <= limit && Math.max(...ys) >= H - limit;
|
|
420
|
+
}
|
|
421
|
+
function isNestedInCut(best, cut, gray) {
|
|
422
|
+
const margin = NESTED_MARGIN_PX * best.scale;
|
|
423
|
+
const nested = cut.some((o) => o.area >= NESTED_AREA_FACTOR * best.area && best.quad.every((p) => insideConvex(o.pts, p, margin)));
|
|
424
|
+
if (!nested) return false;
|
|
425
|
+
if (typeHintFromAspect(best.aspect) === "OTHER") return true;
|
|
426
|
+
const { inner, ring } = innerAndRingMeans(gray, insetQuad(best.quad, QUALITY_INSET_PX * best.scale), best.quad);
|
|
427
|
+
return inner < ring - POLARITY_MIN_DIFF;
|
|
428
|
+
}
|
|
429
|
+
function innerAndRingMeans(gray, inner, quad) {
|
|
430
|
+
const { data, width: W, height: H } = gray;
|
|
431
|
+
const cx = (quad[0].x + quad[1].x + quad[2].x + quad[3].x) / 4;
|
|
432
|
+
const cy = (quad[0].y + quad[1].y + quad[2].y + quad[3].y) / 4;
|
|
433
|
+
const outer = quad.map((p) => ({ x: cx + (p.x - cx) * POLARITY_RING_SCALE, y: cy + (p.y - cy) * POLARITY_RING_SCALE }));
|
|
434
|
+
const so = quadRowSpans(outer, W, H), sq = quadRowSpans(quad, W, H), si = quadRowSpans(inner, W, H);
|
|
435
|
+
let sumIn = 0, nIn = 0, sumRing = 0, nRing = 0;
|
|
436
|
+
for (let y = 0; y < H; y++) {
|
|
437
|
+
const xs = so[y * 2], xe = so[y * 2 + 1];
|
|
438
|
+
if (xs >= xe) continue;
|
|
439
|
+
const qs = sq[y * 2], qe = sq[y * 2 + 1], is = si[y * 2], ie = si[y * 2 + 1];
|
|
440
|
+
const row = y * W;
|
|
441
|
+
for (let x = xs; x < xe; x++) {
|
|
442
|
+
const v = data[row + x];
|
|
443
|
+
if (x >= is && x < ie) {
|
|
444
|
+
sumIn += v;
|
|
445
|
+
nIn++;
|
|
446
|
+
} else if (x < qs || x >= qe) {
|
|
447
|
+
sumRing += v;
|
|
448
|
+
nRing++;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return { inner: nIn > 0 ? sumIn / nIn : 0, ring: nRing > 0 ? sumRing / nRing : 0 };
|
|
453
|
+
}
|
|
454
|
+
function insideConvex(poly, p, margin) {
|
|
455
|
+
let twiceArea = 0;
|
|
456
|
+
for (let i = 0; i < poly.length; i++) {
|
|
457
|
+
const a = poly[i], b = poly[(i + 1) % poly.length];
|
|
458
|
+
twiceArea += a.x * b.y - b.x * a.y;
|
|
459
|
+
}
|
|
460
|
+
const winding = Math.sign(twiceArea);
|
|
461
|
+
if (winding === 0) return false;
|
|
462
|
+
for (let i = 0; i < poly.length; i++) {
|
|
463
|
+
const a = poly[i], b = poly[(i + 1) % poly.length];
|
|
464
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
465
|
+
if (len === 0) continue;
|
|
466
|
+
const dist = winding * ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) / len;
|
|
467
|
+
if (dist < margin) return false;
|
|
468
|
+
}
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
function insetQuad(quad, d) {
|
|
472
|
+
return quad.map((p, i) => {
|
|
473
|
+
const prev = quad[(i + 3) % 4], next = quad[(i + 1) % 4];
|
|
474
|
+
const ux = prev.x - p.x, uy = prev.y - p.y, vx = next.x - p.x, vy = next.y - p.y;
|
|
475
|
+
const lu = Math.hypot(ux, uy), lv = Math.hypot(vx, vy);
|
|
476
|
+
const bx = ux / lu + vx / lv, by = uy / lu + vy / lv;
|
|
477
|
+
const lb = Math.hypot(bx, by);
|
|
478
|
+
const sinHalf = Math.sin(Math.acos(Math.min(1, Math.max(-1, (ux * vx + uy * vy) / (lu * lv)))) / 2);
|
|
479
|
+
const k = d / sinHalf / lb;
|
|
480
|
+
return { x: p.x + bx * k, y: p.y + by * k };
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
function borderCorners(pts, W, H, limit) {
|
|
484
|
+
let n = 0;
|
|
485
|
+
for (const p of pts) {
|
|
486
|
+
if (p.x <= limit || p.x >= W - limit || p.y <= limit || p.y >= H - limit) n++;
|
|
487
|
+
}
|
|
488
|
+
return n;
|
|
489
|
+
}
|
|
490
|
+
function medianOf(data) {
|
|
491
|
+
const hist = new Uint32Array(256);
|
|
492
|
+
for (let i = 0; i < data.length; i++) hist[data[i]]++;
|
|
493
|
+
let acc = 0;
|
|
494
|
+
for (let v = 0; v < 256; v++) {
|
|
495
|
+
acc += hist[v];
|
|
496
|
+
if (acc >= data.length / 2) return v;
|
|
497
|
+
}
|
|
498
|
+
return 128;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/core/cv-warp.ts
|
|
502
|
+
var DEFAULT_WARP_LONG_SIDE = 1024;
|
|
503
|
+
function warpDocumentImageData(cv, source, corners, docType, longSide = DEFAULT_WARP_LONG_SIDE) {
|
|
504
|
+
const ratio = clampAspect(canonicalRatioFor(docType, rectifiedAspect(corners)));
|
|
505
|
+
const shortSide = Math.max(1, Math.round(longSide / ratio));
|
|
506
|
+
const s = sideLengths(corners);
|
|
507
|
+
const portrait = s.top + s.bottom < s.left + s.right;
|
|
508
|
+
const fixedLandscape = docType === "INE" || docType === "PASSPORT_MX";
|
|
509
|
+
const q = portrait && fixedLandscape ? [corners[3], corners[0], corners[1], corners[2]] : corners;
|
|
510
|
+
const landscapeOut = !portrait || fixedLandscape;
|
|
511
|
+
const outW = landscapeOut ? longSide : shortSide;
|
|
512
|
+
const outH = landscapeOut ? shortSide : longSide;
|
|
513
|
+
let src = null;
|
|
514
|
+
let dst = null;
|
|
515
|
+
let srcPts = null;
|
|
516
|
+
let dstPts = null;
|
|
517
|
+
let M = null;
|
|
518
|
+
let rgba = null;
|
|
519
|
+
try {
|
|
520
|
+
src = cv.matFromImageData(source);
|
|
521
|
+
dst = new cv.Mat();
|
|
522
|
+
srcPts = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
|
523
|
+
q[0].x,
|
|
524
|
+
q[0].y,
|
|
525
|
+
q[1].x,
|
|
526
|
+
q[1].y,
|
|
527
|
+
q[2].x,
|
|
528
|
+
q[2].y,
|
|
529
|
+
q[3].x,
|
|
530
|
+
q[3].y
|
|
531
|
+
]);
|
|
532
|
+
dstPts = cv.matFromArray(4, 1, cv.CV_32FC2, [0, 0, outW, 0, outW, outH, 0, outH]);
|
|
533
|
+
M = cv.getPerspectiveTransform(srcPts, dstPts);
|
|
534
|
+
cv.warpPerspective(
|
|
535
|
+
src,
|
|
536
|
+
dst,
|
|
537
|
+
M,
|
|
538
|
+
new cv.Size(outW, outH),
|
|
539
|
+
cv.INTER_LINEAR,
|
|
540
|
+
cv.BORDER_CONSTANT,
|
|
541
|
+
new cv.Scalar(0, 0, 0, 255)
|
|
542
|
+
);
|
|
543
|
+
if (dst.channels() !== 4) {
|
|
544
|
+
rgba = new cv.Mat();
|
|
545
|
+
cv.cvtColor(dst, rgba, cv.COLOR_BGR2RGBA);
|
|
546
|
+
}
|
|
547
|
+
return makeImageData(new Uint8ClampedArray((rgba ?? dst).data), outW, outH);
|
|
548
|
+
} finally {
|
|
549
|
+
for (const m of [rgba, M, dstPts, srcPts, dst, src]) m?.delete();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function clampAspect(ratio) {
|
|
553
|
+
if (!Number.isFinite(ratio)) return CAPTURABLE_ASPECT_MIN;
|
|
554
|
+
return Math.min(CAPTURABLE_ASPECT_MAX, Math.max(CAPTURABLE_ASPECT_MIN, ratio));
|
|
555
|
+
}
|
|
556
|
+
function makeImageData(data, width, height) {
|
|
557
|
+
if (typeof globalThis.ImageData === "function") return new ImageData(data, width, height);
|
|
558
|
+
return { data, width, height, colorSpace: "srgb" };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/core/worker-protocol.ts
|
|
562
|
+
var OPENCV_READY_TIMEOUT_MS = 3e4;
|
|
563
|
+
function isQuad(v) {
|
|
564
|
+
return Array.isArray(v) && v.length === 4 && v.every((p) => !!p && typeof p === "object" && Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/core/cv-worker.ts
|
|
568
|
+
var dynamicImport = (url) => import(
|
|
569
|
+
/* @vite-ignore */
|
|
570
|
+
/* webpackIgnore: true */
|
|
571
|
+
url
|
|
572
|
+
);
|
|
573
|
+
var errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
574
|
+
function isCv(m) {
|
|
575
|
+
return !!m && (typeof m === "object" || typeof m === "function") && typeof m.Mat === "function";
|
|
576
|
+
}
|
|
577
|
+
function stripThen(m) {
|
|
578
|
+
if (m && (typeof m === "object" || typeof m === "function") && !(m instanceof Promise) && typeof m.then === "function") {
|
|
579
|
+
delete m.then;
|
|
580
|
+
if (typeof m.then === "function") {
|
|
581
|
+
Object.defineProperty(m, "then", { value: void 0, configurable: true, writable: true });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function transferableBuffer(data) {
|
|
586
|
+
if (data.byteOffset === 0 && data.byteLength === data.buffer.byteLength && data.buffer instanceof ArrayBuffer) {
|
|
587
|
+
return data.buffer;
|
|
588
|
+
}
|
|
589
|
+
return data.slice().buffer;
|
|
590
|
+
}
|
|
591
|
+
async function loadOpenCV(url, opts = {}) {
|
|
592
|
+
const { importer = dynamicImport, timeoutMs = OPENCV_READY_TIMEOUT_MS, pollMs = 25 } = opts;
|
|
593
|
+
let timedOut = false;
|
|
594
|
+
let timer;
|
|
595
|
+
const timeout = new Promise((_, reject) => {
|
|
596
|
+
timer = setTimeout(() => {
|
|
597
|
+
timedOut = true;
|
|
598
|
+
reject(new Error(`OpenCV from ${url} was not ready within ${timeoutMs} ms`));
|
|
599
|
+
}, timeoutMs);
|
|
600
|
+
});
|
|
601
|
+
const resolveModule = async () => {
|
|
602
|
+
let mod;
|
|
603
|
+
try {
|
|
604
|
+
mod = await importer(url);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
throw new Error(`Failed to load OpenCV from ${url}: ${errorMessage(err)}`);
|
|
607
|
+
}
|
|
608
|
+
const ns = mod ?? {};
|
|
609
|
+
let m = ns.default ?? globalThis.cv ?? mod;
|
|
610
|
+
if (!isCv(m) && typeof m === "function") m = m();
|
|
611
|
+
if (m instanceof Promise) m = await m;
|
|
612
|
+
stripThen(m);
|
|
613
|
+
const ready = m?.ready;
|
|
614
|
+
if (!isCv(m) && ready instanceof Promise) await ready.then(() => void 0);
|
|
615
|
+
while (!isCv(m)) {
|
|
616
|
+
if (timedOut) throw new Error("timed out");
|
|
617
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
618
|
+
}
|
|
619
|
+
return m;
|
|
620
|
+
};
|
|
621
|
+
try {
|
|
622
|
+
return await Promise.race([resolveModule(), timeout]);
|
|
623
|
+
} finally {
|
|
624
|
+
clearTimeout(timer);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function installCvWorker(scope, deps = {}) {
|
|
628
|
+
const detect = deps.detect ?? ((cv2, img, opts) => detectDocumentCV(cv2, img, void 0, opts));
|
|
629
|
+
const warp = deps.warp ?? ((cv2, img, corners, docType, longSide) => warpDocumentImageData(cv2, img, corners, docType, longSide));
|
|
630
|
+
let cv = null;
|
|
631
|
+
let initStarted = false;
|
|
632
|
+
scope.addEventListener("message", async (e) => {
|
|
633
|
+
const msg = e.data;
|
|
634
|
+
if (!msg || typeof msg !== "object") return;
|
|
635
|
+
if (msg.type === "init") {
|
|
636
|
+
if (initStarted) return;
|
|
637
|
+
initStarted = true;
|
|
638
|
+
try {
|
|
639
|
+
cv = await loadOpenCV(msg.opencvUrl, deps);
|
|
640
|
+
scope.postMessage({ type: "ready" });
|
|
641
|
+
} catch (err) {
|
|
642
|
+
scope.postMessage({ type: "init-error", error: errorMessage(err) });
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (msg.type !== "analyze" && msg.type !== "warp") return;
|
|
647
|
+
if (!cv) {
|
|
648
|
+
scope.postMessage({ type: "error", id: msg.id, error: "OpenCV not ready" });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const img = new ImageData(new Uint8ClampedArray(msg.buffer), msg.width, msg.height);
|
|
653
|
+
if (msg.type === "analyze") {
|
|
654
|
+
const opts = isQuad(msg.prior) ? { prior: msg.prior } : {};
|
|
655
|
+
scope.postMessage({ type: "analyze-result", id: msg.id, result: detect(cv, img, opts) });
|
|
656
|
+
} else {
|
|
657
|
+
const out = warp(cv, img, msg.corners, msg.docType, msg.longSide);
|
|
658
|
+
const buffer = transferableBuffer(out.data);
|
|
659
|
+
scope.postMessage({ type: "warp-result", id: msg.id, width: out.width, height: out.height, buffer }, [buffer]);
|
|
660
|
+
}
|
|
661
|
+
} catch (err) {
|
|
662
|
+
scope.postMessage({ type: "error", id: msg.id, error: errorMessage(err) });
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
if (typeof self !== "undefined" && typeof globalThis.document === "undefined") {
|
|
667
|
+
installCvWorker(self);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export { OPENCV_READY_TIMEOUT_MS, installCvWorker, loadOpenCV, transferableBuffer };
|