quadqr-js 0.7.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/FORMAT.md +390 -0
- package/LICENSE +661 -0
- package/README.md +1042 -0
- package/bin/quadqr.js +102 -0
- package/dist/benchmark.cjs +1 -0
- package/dist/benchmark.js +1 -0
- package/dist/browser.js +2 -0
- package/dist/esm/benchmark.js +193 -0
- package/dist/esm/geometry.js +149 -0
- package/dist/esm/node.js +275 -0
- package/dist/esm/quadqr.js +1962 -0
- package/dist/esm/reed-solomon.js +371 -0
- package/dist/esm/security.js +402 -0
- package/dist/esm/vision.js +752 -0
- package/dist/esm/wasm.js +98 -0
- package/dist/index.cjs +1 -0
- package/dist/index.js +2 -0
- package/dist/node.cjs +1 -0
- package/dist/node.js +1 -0
- package/dist/quadqr.js +3651 -0
- package/dist/quadqr.min.js +3593 -0
- package/dist/wasm/quadqr-core.wasm +0 -0
- package/docs/API.md +188 -0
- package/docs/BROWSER_CDN.md +83 -0
- package/docs/GETTING_STARTED.md +91 -0
- package/docs/NODE.md +92 -0
- package/docs/PUBLISHING.md +115 -0
- package/docs/README.md +26 -0
- package/docs/SECURITY.md +78 -0
- package/docs/WASM.md +36 -0
- package/package.json +95 -0
- package/types/benchmark.d.ts +7 -0
- package/types/index.d.ts +139 -0
- package/types/node.d.ts +9 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import {
|
|
2
|
+
alignmentPatternCentersForVersion,
|
|
3
|
+
alignmentPatternIsBlack,
|
|
4
|
+
alignmentPatternRadius,
|
|
5
|
+
primaryAlignmentPatternForVersion,
|
|
6
|
+
sizeForVersion
|
|
7
|
+
} from "./geometry.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Image geometry and sampling helpers for QuadQR.
|
|
11
|
+
* Pure JavaScript. No DOM dependency except callers may pass browser ImageData.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function assert(condition, message) {
|
|
15
|
+
if (!condition) throw new Error(message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clamp(value, min, max) {
|
|
19
|
+
return Math.max(min, Math.min(max, value));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pixelRgb(imageData, x, y) {
|
|
23
|
+
const ix = clamp(Math.round(x), 0, imageData.width - 1);
|
|
24
|
+
const iy = clamp(Math.round(y), 0, imageData.height - 1);
|
|
25
|
+
const index = (iy * imageData.width + ix) * 4;
|
|
26
|
+
const a = imageData.data[index + 3] / 255;
|
|
27
|
+
return {
|
|
28
|
+
r: imageData.data[index] * a + 255 * (1 - a),
|
|
29
|
+
g: imageData.data[index + 1] * a + 255 * (1 - a),
|
|
30
|
+
b: imageData.data[index + 2] * a + 255 * (1 - a)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bilinearRgb(imageData, x, y) {
|
|
35
|
+
const x0 = clamp(Math.floor(x), 0, imageData.width - 1);
|
|
36
|
+
const y0 = clamp(Math.floor(y), 0, imageData.height - 1);
|
|
37
|
+
const x1 = clamp(x0 + 1, 0, imageData.width - 1);
|
|
38
|
+
const y1 = clamp(y0 + 1, 0, imageData.height - 1);
|
|
39
|
+
const fx = clamp(x - x0, 0, 1);
|
|
40
|
+
const fy = clamp(y - y0, 0, 1);
|
|
41
|
+
|
|
42
|
+
const p00 = pixelRgb(imageData, x0, y0);
|
|
43
|
+
const p10 = pixelRgb(imageData, x1, y0);
|
|
44
|
+
const p01 = pixelRgb(imageData, x0, y1);
|
|
45
|
+
const p11 = pixelRgb(imageData, x1, y1);
|
|
46
|
+
|
|
47
|
+
const mix = (a, b, t) => a + (b - a) * t;
|
|
48
|
+
return {
|
|
49
|
+
r: mix(mix(p00.r, p10.r, fx), mix(p01.r, p11.r, fx), fy),
|
|
50
|
+
g: mix(mix(p00.g, p10.g, fx), mix(p01.g, p11.g, fx), fy),
|
|
51
|
+
b: mix(mix(p00.b, p10.b, fx), mix(p01.b, p11.b, fx), fy)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function luminance(rgb) {
|
|
56
|
+
return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildGray(imageData) {
|
|
60
|
+
const gray = new Uint8Array(imageData.width * imageData.height);
|
|
61
|
+
for (let i = 0; i < gray.length; i++) {
|
|
62
|
+
const p = i * 4;
|
|
63
|
+
const a = imageData.data[p + 3] / 255;
|
|
64
|
+
const r = imageData.data[p] * a + 255 * (1 - a);
|
|
65
|
+
const g = imageData.data[p + 1] * a + 255 * (1 - a);
|
|
66
|
+
const b = imageData.data[p + 2] * a + 255 * (1 - a);
|
|
67
|
+
gray[i] = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
|
|
68
|
+
}
|
|
69
|
+
return gray;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function otsuThreshold(gray) {
|
|
73
|
+
const histogram = new Uint32Array(256);
|
|
74
|
+
for (const value of gray) histogram[value]++;
|
|
75
|
+
const total = gray.length;
|
|
76
|
+
let sum = 0;
|
|
77
|
+
for (let i = 0; i < 256; i++) sum += i * histogram[i];
|
|
78
|
+
|
|
79
|
+
let sumBackground = 0;
|
|
80
|
+
let weightBackground = 0;
|
|
81
|
+
let maxVariance = -1;
|
|
82
|
+
let threshold = 127;
|
|
83
|
+
|
|
84
|
+
for (let t = 0; t < 256; t++) {
|
|
85
|
+
weightBackground += histogram[t];
|
|
86
|
+
if (weightBackground === 0) continue;
|
|
87
|
+
const weightForeground = total - weightBackground;
|
|
88
|
+
if (weightForeground === 0) break;
|
|
89
|
+
sumBackground += t * histogram[t];
|
|
90
|
+
const meanBackground = sumBackground / weightBackground;
|
|
91
|
+
const meanForeground = (sum - sumBackground) / weightForeground;
|
|
92
|
+
const diff = meanBackground - meanForeground;
|
|
93
|
+
const variance = weightBackground * weightForeground * diff * diff;
|
|
94
|
+
if (variance > maxVariance) {
|
|
95
|
+
maxVariance = variance;
|
|
96
|
+
threshold = t;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return threshold;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildBinary(imageData) {
|
|
104
|
+
const gray = buildGray(imageData);
|
|
105
|
+
const threshold = otsuThreshold(gray);
|
|
106
|
+
const binary = new Uint8Array(gray.length);
|
|
107
|
+
for (let i = 0; i < gray.length; i++) binary[i] = gray[i] <= threshold ? 1 : 0;
|
|
108
|
+
return { gray, binary, threshold };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runsForRow(binary, width, row) {
|
|
112
|
+
const runs = [];
|
|
113
|
+
let color = binary[row * width];
|
|
114
|
+
let start = 0;
|
|
115
|
+
for (let x = 1; x < width; x++) {
|
|
116
|
+
const next = binary[row * width + x];
|
|
117
|
+
if (next !== color) {
|
|
118
|
+
runs.push({ color, start, length: x - start });
|
|
119
|
+
color = next;
|
|
120
|
+
start = x;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
runs.push({ color, start, length: width - start });
|
|
124
|
+
return runs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function runsForColumn(binary, width, height, col) {
|
|
128
|
+
const runs = [];
|
|
129
|
+
let color = binary[col];
|
|
130
|
+
let start = 0;
|
|
131
|
+
for (let y = 1; y < height; y++) {
|
|
132
|
+
const next = binary[y * width + col];
|
|
133
|
+
if (next !== color) {
|
|
134
|
+
runs.push({ color, start, length: y - start });
|
|
135
|
+
color = next;
|
|
136
|
+
start = y;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
runs.push({ color, start, length: height - start });
|
|
140
|
+
return runs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function finderRatioScore(lengths) {
|
|
144
|
+
const total = lengths.reduce((sum, value) => sum + value, 0);
|
|
145
|
+
if (total < 7) return Infinity;
|
|
146
|
+
const module = total / 7;
|
|
147
|
+
const expected = [module, module, 3 * module, module, module];
|
|
148
|
+
let score = 0;
|
|
149
|
+
for (let i = 0; i < 5; i++) {
|
|
150
|
+
const tolerance = i === 2 ? module * 1.25 : module * 0.8;
|
|
151
|
+
const diff = Math.abs(lengths[i] - expected[i]);
|
|
152
|
+
if (diff > tolerance) return Infinity;
|
|
153
|
+
score += diff / Math.max(1, expected[i]);
|
|
154
|
+
}
|
|
155
|
+
return score;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function findWindowContainingCoordinate(runs, coordinate) {
|
|
159
|
+
for (let i = 2; i < runs.length - 2; i++) {
|
|
160
|
+
const centerRun = runs[i];
|
|
161
|
+
if (centerRun.color !== 1) continue;
|
|
162
|
+
if (coordinate < centerRun.start || coordinate >= centerRun.start + centerRun.length) continue;
|
|
163
|
+
const window = runs.slice(i - 2, i + 3);
|
|
164
|
+
if (window.map((run) => run.color).join("") !== "10101") continue;
|
|
165
|
+
const score = finderRatioScore(window.map((run) => run.length));
|
|
166
|
+
if (!Number.isFinite(score)) continue;
|
|
167
|
+
const first = window[0].start;
|
|
168
|
+
const total = window.reduce((sum, run) => sum + run.length, 0);
|
|
169
|
+
return {
|
|
170
|
+
center: first + total / 2,
|
|
171
|
+
moduleSize: total / 7,
|
|
172
|
+
score
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function crossCheckVertical(binary, width, height, x, y) {
|
|
179
|
+
const col = clamp(Math.round(x), 0, width - 1);
|
|
180
|
+
return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function crossCheckHorizontal(binary, width, height, x, y) {
|
|
184
|
+
const row = clamp(Math.round(y), 0, height - 1);
|
|
185
|
+
return findWindowContainingCoordinate(runsForRow(binary, width, row), x);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function clusterFinderCandidates(raw) {
|
|
189
|
+
const clusters = [];
|
|
190
|
+
raw.sort((a, b) => a.moduleSize - b.moduleSize);
|
|
191
|
+
|
|
192
|
+
for (const candidate of raw) {
|
|
193
|
+
let best = null;
|
|
194
|
+
let bestDistance = Infinity;
|
|
195
|
+
for (const cluster of clusters) {
|
|
196
|
+
const moduleRatio = Math.abs(cluster.moduleSize - candidate.moduleSize) /
|
|
197
|
+
Math.max(cluster.moduleSize, candidate.moduleSize);
|
|
198
|
+
if (moduleRatio > 0.45) continue;
|
|
199
|
+
const dx = cluster.x - candidate.x;
|
|
200
|
+
const dy = cluster.y - candidate.y;
|
|
201
|
+
const distance = Math.hypot(dx, dy);
|
|
202
|
+
if (distance <= Math.max(cluster.moduleSize, candidate.moduleSize) * 2.25 && distance < bestDistance) {
|
|
203
|
+
best = cluster;
|
|
204
|
+
bestDistance = distance;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!best) {
|
|
209
|
+
clusters.push({ ...candidate, confirmations: 1 });
|
|
210
|
+
} else {
|
|
211
|
+
const n = best.confirmations;
|
|
212
|
+
best.x = (best.x * n + candidate.x) / (n + 1);
|
|
213
|
+
best.y = (best.y * n + candidate.y) / (n + 1);
|
|
214
|
+
best.moduleSize = (best.moduleSize * n + candidate.moduleSize) / (n + 1);
|
|
215
|
+
best.score = (best.score * n + candidate.score) / (n + 1);
|
|
216
|
+
best.confirmations++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return clusters
|
|
221
|
+
.filter((candidate) => candidate.confirmations >= 2)
|
|
222
|
+
.sort((a, b) =>
|
|
223
|
+
(b.confirmations - a.confirmations) ||
|
|
224
|
+
(a.score - b.score) ||
|
|
225
|
+
(b.moduleSize - a.moduleSize)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function detectFinderCandidates(binary, width, height) {
|
|
230
|
+
const raw = [];
|
|
231
|
+
const rowStep = height > 1200 ? 2 : 1;
|
|
232
|
+
|
|
233
|
+
for (let y = 0; y < height; y += rowStep) {
|
|
234
|
+
const runs = runsForRow(binary, width, y);
|
|
235
|
+
for (let i = 0; i <= runs.length - 5; i++) {
|
|
236
|
+
const window = runs.slice(i, i + 5);
|
|
237
|
+
if (window.map((run) => run.color).join("") !== "10101") continue;
|
|
238
|
+
const lengths = window.map((run) => run.length);
|
|
239
|
+
const ratioScore = finderRatioScore(lengths);
|
|
240
|
+
if (!Number.isFinite(ratioScore)) continue;
|
|
241
|
+
const total = lengths.reduce((sum, value) => sum + value, 0);
|
|
242
|
+
const centerX = window[0].start + total / 2;
|
|
243
|
+
const vertical = crossCheckVertical(binary, width, height, centerX, y);
|
|
244
|
+
if (!vertical) continue;
|
|
245
|
+
const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center);
|
|
246
|
+
if (!horizontal) continue;
|
|
247
|
+
|
|
248
|
+
const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
|
|
249
|
+
const moduleSpread = Math.max(
|
|
250
|
+
Math.abs(moduleSize - total / 7),
|
|
251
|
+
Math.abs(moduleSize - vertical.moduleSize),
|
|
252
|
+
Math.abs(moduleSize - horizontal.moduleSize)
|
|
253
|
+
) / moduleSize;
|
|
254
|
+
if (moduleSpread > 0.45) continue;
|
|
255
|
+
|
|
256
|
+
raw.push({
|
|
257
|
+
x: horizontal.center,
|
|
258
|
+
y: vertical.center,
|
|
259
|
+
moduleSize,
|
|
260
|
+
score: ratioScore + vertical.score + horizontal.score
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return clusterFinderCandidates(raw);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function dot(a, b) {
|
|
269
|
+
return a.x * b.x + a.y * b.y;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sub(a, b) {
|
|
273
|
+
return { x: a.x - b.x, y: a.y - b.y };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function cross(a, b) {
|
|
277
|
+
return a.x * b.y - a.y * b.x;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function chooseFinderTriples(candidates, maxTriples = 16) {
|
|
281
|
+
const top = candidates.slice(0, Math.min(candidates.length, 14));
|
|
282
|
+
const triples = [];
|
|
283
|
+
|
|
284
|
+
for (let a = 0; a < top.length - 2; a++) {
|
|
285
|
+
for (let b = a + 1; b < top.length - 1; b++) {
|
|
286
|
+
for (let c = b + 1; c < top.length; c++) {
|
|
287
|
+
const points = [top[a], top[b], top[c]];
|
|
288
|
+
for (let corner = 0; corner < 3; corner++) {
|
|
289
|
+
const tl = points[corner];
|
|
290
|
+
const other = points.filter((_, index) => index !== corner);
|
|
291
|
+
let tr = other[0];
|
|
292
|
+
let bl = other[1];
|
|
293
|
+
let u = sub(tr, tl);
|
|
294
|
+
let v = sub(bl, tl);
|
|
295
|
+
let d1 = Math.hypot(u.x, u.y);
|
|
296
|
+
let d2 = Math.hypot(v.x, v.y);
|
|
297
|
+
if (d1 < tl.moduleSize * 10 || d2 < tl.moduleSize * 10) continue;
|
|
298
|
+
const cos = Math.abs(dot(u, v) / (d1 * d2));
|
|
299
|
+
if (cos > 0.55) continue;
|
|
300
|
+
if (cross(u, v) < 0) {
|
|
301
|
+
[tr, bl] = [bl, tr];
|
|
302
|
+
u = sub(tr, tl);
|
|
303
|
+
v = sub(bl, tl);
|
|
304
|
+
d1 = Math.hypot(u.x, u.y);
|
|
305
|
+
d2 = Math.hypot(v.x, v.y);
|
|
306
|
+
}
|
|
307
|
+
const moduleMean = (tl.moduleSize + tr.moduleSize + bl.moduleSize) / 3;
|
|
308
|
+
const moduleSpread = Math.max(
|
|
309
|
+
Math.abs(tl.moduleSize - moduleMean),
|
|
310
|
+
Math.abs(tr.moduleSize - moduleMean),
|
|
311
|
+
Math.abs(bl.moduleSize - moduleMean)
|
|
312
|
+
) / moduleMean;
|
|
313
|
+
if (moduleSpread > 0.5) continue;
|
|
314
|
+
const legRatio = Math.max(d1, d2) / Math.min(d1, d2);
|
|
315
|
+
if (legRatio > 2.1) continue;
|
|
316
|
+
const area = Math.abs(cross(u, v));
|
|
317
|
+
const confirmScore = tl.confirmations + tr.confirmations + bl.confirmations;
|
|
318
|
+
const score = area / (1 + cos * 8 + moduleSpread * 5 + Math.max(0, legRatio - 1) * 2) + confirmScore * 100;
|
|
319
|
+
triples.push({ tl, tr, bl, moduleMean, score, orthogonality: 1 - cos });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
triples.sort((a, b) => b.score - a.score);
|
|
326
|
+
return triples.slice(0, maxTriples);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function nearestVersionFromEstimate(value, minVersion, maxVersion) {
|
|
330
|
+
const candidates = [];
|
|
331
|
+
for (let version = minVersion; version <= maxVersion; version++) {
|
|
332
|
+
const size = sizeForVersion(version);
|
|
333
|
+
candidates.push({ version, error: Math.abs(size - value) });
|
|
334
|
+
}
|
|
335
|
+
candidates.sort((a, b) => a.error - b.error);
|
|
336
|
+
return candidates;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sampleBinaryAt(binary, width, height, x, y) {
|
|
340
|
+
const ix = Math.round(x);
|
|
341
|
+
const iy = Math.round(y);
|
|
342
|
+
if (ix < 0 || iy < 0 || ix >= width || iy >= height) return null;
|
|
343
|
+
return binary[iy * width + ix];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function alignmentTemplateValue(pattern, r, c) {
|
|
347
|
+
return alignmentPatternIsBlack(pattern, r, c) ? 1 : 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function alignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
|
|
351
|
+
let matches = 0;
|
|
352
|
+
let total = 0;
|
|
353
|
+
const radius = alignmentPatternRadius(pattern);
|
|
354
|
+
for (let r = -radius; r <= radius; r++) {
|
|
355
|
+
for (let c = -radius; c <= radius; c++) {
|
|
356
|
+
const x = center.x + basisU.x * c * scale + basisV.x * r * scale;
|
|
357
|
+
const y = center.y + basisU.y * c * scale + basisV.y * r * scale;
|
|
358
|
+
const value = sampleBinaryAt(binary, width, height, x, y);
|
|
359
|
+
if (value === null) continue;
|
|
360
|
+
total++;
|
|
361
|
+
if (value === alignmentTemplateValue(pattern, r, c)) matches++;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return total ? matches / total : 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function searchAlignment(binary, width, height, triple, version) {
|
|
368
|
+
const size = sizeForVersion(version);
|
|
369
|
+
const separation = size - 7;
|
|
370
|
+
const basisU = {
|
|
371
|
+
x: (triple.tr.x - triple.tl.x) / separation,
|
|
372
|
+
y: (triple.tr.y - triple.tl.y) / separation
|
|
373
|
+
};
|
|
374
|
+
const basisV = {
|
|
375
|
+
x: (triple.bl.x - triple.tl.x) / separation,
|
|
376
|
+
y: (triple.bl.y - triple.tl.y) / separation
|
|
377
|
+
};
|
|
378
|
+
const target = primaryAlignmentPatternForVersion(version);
|
|
379
|
+
const targetX = target.col + 0.5;
|
|
380
|
+
const targetY = target.row + 0.5;
|
|
381
|
+
const predicted = {
|
|
382
|
+
x: triple.tl.x + basisU.x * (targetX - 3.5) + basisV.x * (targetY - 3.5),
|
|
383
|
+
y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
let best = { score: 0, center: predicted, scale: 1 };
|
|
387
|
+
const offsets = [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
|
|
388
|
+
const scales = [0.72, 0.85, 1, 1.15, 1.3];
|
|
389
|
+
|
|
390
|
+
for (const ou of offsets) {
|
|
391
|
+
for (const ov of offsets) {
|
|
392
|
+
const center = {
|
|
393
|
+
x: predicted.x + basisU.x * ou + basisV.x * ov,
|
|
394
|
+
y: predicted.y + basisU.y * ou + basisV.y * ov
|
|
395
|
+
};
|
|
396
|
+
for (const scale of scales) {
|
|
397
|
+
const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
|
|
398
|
+
if (score > best.score) best = { score, center, scale };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return { ...best, basisU, basisV, predicted, target };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function projectedAlignmentScore(binary, width, height, homography, pattern) {
|
|
407
|
+
let matches = 0;
|
|
408
|
+
let total = 0;
|
|
409
|
+
const radius = alignmentPatternRadius(pattern);
|
|
410
|
+
for (let r = -radius; r <= radius; r++) {
|
|
411
|
+
for (let c = -radius; c <= radius; c++) {
|
|
412
|
+
const point = projectPoint(homography, pattern.col + c + 0.5, pattern.row + r + 0.5);
|
|
413
|
+
const value = sampleBinaryAt(binary, width, height, point.x, point.y);
|
|
414
|
+
if (value === null) continue;
|
|
415
|
+
total++;
|
|
416
|
+
if (value === alignmentTemplateValue(pattern, r, c)) matches++;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return total ? matches / total : 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function scoreAlignmentGrid(binary, width, height, homography, version) {
|
|
423
|
+
const patterns = alignmentPatternCentersForVersion(version);
|
|
424
|
+
if (!patterns.length) return { score: 1, patternScores: [] };
|
|
425
|
+
const patternScores = patterns.map((pattern) => ({
|
|
426
|
+
row: pattern.row,
|
|
427
|
+
col: pattern.col,
|
|
428
|
+
size: pattern.size,
|
|
429
|
+
primary: Boolean(pattern.primary),
|
|
430
|
+
score: projectedAlignmentScore(binary, width, height, homography, pattern)
|
|
431
|
+
}));
|
|
432
|
+
const totalWeight = patternScores.reduce((sum, item) => sum + (item.primary ? 2 : 1), 0);
|
|
433
|
+
const score = patternScores.reduce(
|
|
434
|
+
(sum, item) => sum + item.score * (item.primary ? 2 : 1),
|
|
435
|
+
0
|
|
436
|
+
) / totalWeight;
|
|
437
|
+
return { score, patternScores };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function solveLinearSystemFloat(matrix, vector) {
|
|
441
|
+
const n = vector.length;
|
|
442
|
+
const a = matrix.map((row, index) => row.slice().concat([vector[index]]));
|
|
443
|
+
for (let col = 0; col < n; col++) {
|
|
444
|
+
let pivot = col;
|
|
445
|
+
for (let row = col + 1; row < n; row++) {
|
|
446
|
+
if (Math.abs(a[row][col]) > Math.abs(a[pivot][col])) pivot = row;
|
|
447
|
+
}
|
|
448
|
+
if (Math.abs(a[pivot][col]) < 1e-10) throw new Error("Singular homography system.");
|
|
449
|
+
if (pivot !== col) [a[col], a[pivot]] = [a[pivot], a[col]];
|
|
450
|
+
const divisor = a[col][col];
|
|
451
|
+
for (let j = col; j <= n; j++) a[col][j] /= divisor;
|
|
452
|
+
for (let row = 0; row < n; row++) {
|
|
453
|
+
if (row === col) continue;
|
|
454
|
+
const factor = a[row][col];
|
|
455
|
+
if (Math.abs(factor) < 1e-12) continue;
|
|
456
|
+
for (let j = col; j <= n; j++) a[row][j] -= factor * a[col][j];
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return a.map((row) => row[n]);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function computeHomography(sourcePoints, destinationPoints) {
|
|
463
|
+
// Returns transform mapping destination -> source.
|
|
464
|
+
assert(sourcePoints.length === 4 && destinationPoints.length === 4, "Four point pairs are required.");
|
|
465
|
+
const A = [];
|
|
466
|
+
const b = [];
|
|
467
|
+
for (let i = 0; i < 4; i++) {
|
|
468
|
+
const u = destinationPoints[i].x;
|
|
469
|
+
const v = destinationPoints[i].y;
|
|
470
|
+
const x = sourcePoints[i].x;
|
|
471
|
+
const y = sourcePoints[i].y;
|
|
472
|
+
A.push([u, v, 1, 0, 0, 0, -u * x, -v * x]);
|
|
473
|
+
b.push(x);
|
|
474
|
+
A.push([0, 0, 0, u, v, 1, -u * y, -v * y]);
|
|
475
|
+
b.push(y);
|
|
476
|
+
}
|
|
477
|
+
const h = solveLinearSystemFloat(A, b);
|
|
478
|
+
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function projectPoint(h, u, v) {
|
|
482
|
+
const denominator = h[6] * u + h[7] * v + h[8];
|
|
483
|
+
return {
|
|
484
|
+
x: (h[0] * u + h[1] * v + h[2]) / denominator,
|
|
485
|
+
y: (h[3] * u + h[4] * v + h[5]) / denominator
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function detectCodeGeometry(imageData, options = {}) {
|
|
490
|
+
assert(imageData?.data && imageData.width && imageData.height, "Valid image data is required.");
|
|
491
|
+
const minVersion = options.minVersion ?? 1;
|
|
492
|
+
const maxVersion = options.maxVersion ?? 40;
|
|
493
|
+
const maxCandidates = options.maxCandidates ?? 8;
|
|
494
|
+
const { binary, threshold } = buildBinary(imageData);
|
|
495
|
+
const finders = detectFinderCandidates(binary, imageData.width, imageData.height);
|
|
496
|
+
if (finders.length < 3) return [];
|
|
497
|
+
|
|
498
|
+
const triples = chooseFinderTriples(finders, 20);
|
|
499
|
+
const geometries = [];
|
|
500
|
+
|
|
501
|
+
for (const triple of triples) {
|
|
502
|
+
const legH = Math.hypot(triple.tr.x - triple.tl.x, triple.tr.y - triple.tl.y);
|
|
503
|
+
const legV = Math.hypot(triple.bl.x - triple.tl.x, triple.bl.y - triple.tl.y);
|
|
504
|
+
const estimatedSize = ((legH + legV) / 2) / triple.moduleMean + 7;
|
|
505
|
+
const versions = nearestVersionFromEstimate(estimatedSize, minVersion, maxVersion).slice(0, 9);
|
|
506
|
+
|
|
507
|
+
for (const item of versions) {
|
|
508
|
+
const version = item.version;
|
|
509
|
+
const size = sizeForVersion(version);
|
|
510
|
+
const alignment = searchAlignment(binary, imageData.width, imageData.height, triple, version);
|
|
511
|
+
if (alignment.score < 0.72) continue;
|
|
512
|
+
|
|
513
|
+
const alignmentTarget = alignment.target;
|
|
514
|
+
const dest = [
|
|
515
|
+
{ x: 3.5, y: 3.5 },
|
|
516
|
+
{ x: size - 3.5, y: 3.5 },
|
|
517
|
+
{ x: 3.5, y: size - 3.5 },
|
|
518
|
+
{ x: alignmentTarget.col + 0.5, y: alignmentTarget.row + 0.5 }
|
|
519
|
+
];
|
|
520
|
+
const src = [
|
|
521
|
+
{ x: triple.tl.x, y: triple.tl.y },
|
|
522
|
+
{ x: triple.tr.x, y: triple.tr.y },
|
|
523
|
+
{ x: triple.bl.x, y: triple.bl.y },
|
|
524
|
+
alignment.center
|
|
525
|
+
];
|
|
526
|
+
|
|
527
|
+
let homography;
|
|
528
|
+
try {
|
|
529
|
+
homography = computeHomography(src, dest);
|
|
530
|
+
} catch {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const alignmentGrid = scoreAlignmentGrid(binary, imageData.width, imageData.height, homography, version);
|
|
535
|
+
if (alignmentGrid.score < 0.68) continue;
|
|
536
|
+
|
|
537
|
+
const versionPenalty = item.error / 4;
|
|
538
|
+
const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
|
|
539
|
+
const score = triple.score * (0.5 + 0.5 * alignmentConfidence) / (1 + versionPenalty * 0.08);
|
|
540
|
+
geometries.push({
|
|
541
|
+
version,
|
|
542
|
+
size,
|
|
543
|
+
sourcePoints: src,
|
|
544
|
+
destinationPoints: dest,
|
|
545
|
+
homography,
|
|
546
|
+
finders: { topLeft: triple.tl, topRight: triple.tr, bottomLeft: triple.bl },
|
|
547
|
+
alignment: {
|
|
548
|
+
center: alignment.center,
|
|
549
|
+
score: alignment.score,
|
|
550
|
+
scale: alignment.scale,
|
|
551
|
+
target: { row: alignmentTarget.row, col: alignmentTarget.col },
|
|
552
|
+
patterns: alignmentGrid.patternScores.length,
|
|
553
|
+
gridScore: alignmentGrid.score,
|
|
554
|
+
patternScores: alignmentGrid.patternScores
|
|
555
|
+
},
|
|
556
|
+
threshold,
|
|
557
|
+
estimatedSize,
|
|
558
|
+
score
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Deduplicate roughly identical geometry/version hypotheses.
|
|
564
|
+
const deduped = [];
|
|
565
|
+
geometries.sort((a, b) => b.score - a.score);
|
|
566
|
+
for (const geometry of geometries) {
|
|
567
|
+
const duplicate = deduped.some((other) =>
|
|
568
|
+
other.version === geometry.version &&
|
|
569
|
+
Math.hypot(
|
|
570
|
+
other.sourcePoints[0].x - geometry.sourcePoints[0].x,
|
|
571
|
+
other.sourcePoints[0].y - geometry.sourcePoints[0].y
|
|
572
|
+
) < Math.max(3, geometry.finders.topLeft.moduleSize * 2)
|
|
573
|
+
);
|
|
574
|
+
if (!duplicate) deduped.push(geometry);
|
|
575
|
+
if (deduped.length >= maxCandidates) break;
|
|
576
|
+
}
|
|
577
|
+
return deduped;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function averageProjectedSample(imageData, homography, moduleX, moduleY, radius = 0.16) {
|
|
581
|
+
const offsets = radius > 0
|
|
582
|
+
? [[0, 0], [-radius, 0], [radius, 0], [0, -radius], [0, radius]]
|
|
583
|
+
: [[0, 0]];
|
|
584
|
+
let r = 0;
|
|
585
|
+
let g = 0;
|
|
586
|
+
let b = 0;
|
|
587
|
+
let count = 0;
|
|
588
|
+
for (const [dx, dy] of offsets) {
|
|
589
|
+
const point = projectPoint(homography, moduleX + dx, moduleY + dy);
|
|
590
|
+
if (point.x < 0 || point.y < 0 || point.x >= imageData.width || point.y >= imageData.height) continue;
|
|
591
|
+
const rgb = bilinearRgb(imageData, point.x, point.y);
|
|
592
|
+
r += rgb.r;
|
|
593
|
+
g += rgb.g;
|
|
594
|
+
b += rgb.b;
|
|
595
|
+
count++;
|
|
596
|
+
}
|
|
597
|
+
if (!count) return { r: 255, g: 255, b: 255 };
|
|
598
|
+
return { r: r / count, g: g / count, b: b / count };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function samplePerspectiveMatrix(imageData, homography, size, options = {}) {
|
|
602
|
+
const rgbGrid = Array.from({ length: size }, () => new Array(size));
|
|
603
|
+
const radius = options.sampleRadius ?? 0.16;
|
|
604
|
+
for (let r = 0; r < size; r++) {
|
|
605
|
+
for (let c = 0; c < size; c++) {
|
|
606
|
+
rgbGrid[r][c] = averageProjectedSample(imageData, homography, c + 0.5, r + 0.5, radius);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return { rgbGrid };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function meanRgb(values) {
|
|
613
|
+
if (!values.length) throw new Error("No calibration samples available.");
|
|
614
|
+
return values.reduce(
|
|
615
|
+
(sum, rgb) => ({ r: sum.r + rgb.r, g: sum.g + rgb.g, b: sum.b + rgb.b }),
|
|
616
|
+
{ r: 0, g: 0, b: 0 }
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function averageRgb(values) {
|
|
621
|
+
const sum = meanRgb(values);
|
|
622
|
+
return { r: sum.r / values.length, g: sum.g / values.length, b: sum.b / values.length };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function pickCalibrationSamples(rgbGrid, positions, limit = 24) {
|
|
626
|
+
if (!positions?.length) return [];
|
|
627
|
+
const step = Math.max(1, Math.floor(positions.length / limit));
|
|
628
|
+
const out = [];
|
|
629
|
+
for (let i = 0; i < positions.length; i += step) {
|
|
630
|
+
const [row, col] = positions[i];
|
|
631
|
+
out.push(rgbGrid[row][col]);
|
|
632
|
+
if (out.length >= limit) break;
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function sampleObservedPalette(rgbGrid, calibration) {
|
|
638
|
+
const palette = {
|
|
639
|
+
black: averageRgb(pickCalibrationSamples(rgbGrid, calibration.black, 36)),
|
|
640
|
+
white: averageRgb(pickCalibrationSamples(rgbGrid, calibration.white, 36)),
|
|
641
|
+
red: averageRgb(pickCalibrationSamples(rgbGrid, calibration.red, 12)),
|
|
642
|
+
green: averageRgb(pickCalibrationSamples(rgbGrid, calibration.green, 12)),
|
|
643
|
+
blue: averageRgb(pickCalibrationSamples(rgbGrid, calibration.blue, 12))
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// All five observed classes must remain separated enough for reliable RGBW
|
|
647
|
+
// classification: structural black plus the four visible data states.
|
|
648
|
+
const colors = [palette.black, palette.red, palette.green, palette.blue, palette.white];
|
|
649
|
+
const distances = [];
|
|
650
|
+
for (let i = 0; i < colors.length; i++) {
|
|
651
|
+
for (let j = i + 1; j < colors.length; j++) {
|
|
652
|
+
distances.push(Math.hypot(
|
|
653
|
+
colors[i].r - colors[j].r,
|
|
654
|
+
colors[i].g - colors[j].g,
|
|
655
|
+
colors[i].b - colors[j].b
|
|
656
|
+
));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (Math.min(...distances) < 28) throw new Error("RGBW calibration references are not separable in this image.");
|
|
660
|
+
return palette;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function rectifyImageData(imageData, homography, size, moduleSize = 8) {
|
|
664
|
+
const outputSize = Math.max(1, Math.round(size * moduleSize));
|
|
665
|
+
const data = new Uint8ClampedArray(outputSize * outputSize * 4);
|
|
666
|
+
for (let y = 0; y < outputSize; y++) {
|
|
667
|
+
for (let x = 0; x < outputSize; x++) {
|
|
668
|
+
const moduleX = (x + 0.5) / moduleSize;
|
|
669
|
+
const moduleY = (y + 0.5) / moduleSize;
|
|
670
|
+
const source = projectPoint(homography, moduleX, moduleY);
|
|
671
|
+
const rgb = bilinearRgb(imageData, source.x, source.y);
|
|
672
|
+
const p = (y * outputSize + x) * 4;
|
|
673
|
+
data[p] = clamp(Math.round(rgb.r), 0, 255);
|
|
674
|
+
data[p + 1] = clamp(Math.round(rgb.g), 0, 255);
|
|
675
|
+
data[p + 2] = clamp(Math.round(rgb.b), 0, 255);
|
|
676
|
+
data[p + 3] = 255;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { width: outputSize, height: outputSize, data };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function findActiveBounds(imageData, whiteThreshold = 238) {
|
|
683
|
+
const { width, height, data } = imageData;
|
|
684
|
+
let minX = width;
|
|
685
|
+
let minY = height;
|
|
686
|
+
let maxX = -1;
|
|
687
|
+
let maxY = -1;
|
|
688
|
+
for (let y = 0; y < height; y++) {
|
|
689
|
+
for (let x = 0; x < width; x++) {
|
|
690
|
+
const i = (y * width + x) * 4;
|
|
691
|
+
const a = data[i + 3];
|
|
692
|
+
const active = a > 16 && (data[i] < whiteThreshold || data[i + 1] < whiteThreshold || data[i + 2] < whiteThreshold);
|
|
693
|
+
if (!active) continue;
|
|
694
|
+
minX = Math.min(minX, x);
|
|
695
|
+
maxX = Math.max(maxX, x);
|
|
696
|
+
minY = Math.min(minY, y);
|
|
697
|
+
maxY = Math.max(maxY, y);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (maxX < minX || maxY < minY) throw new Error("No code-like non-white area found.");
|
|
701
|
+
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function sampleAverageAxis(imageData, centerX, centerY, radius) {
|
|
705
|
+
const { width, height, data } = imageData;
|
|
706
|
+
const minX = Math.max(0, Math.floor(centerX - radius));
|
|
707
|
+
const maxX = Math.min(width - 1, Math.ceil(centerX + radius));
|
|
708
|
+
const minY = Math.max(0, Math.floor(centerY - radius));
|
|
709
|
+
const maxY = Math.min(height - 1, Math.ceil(centerY + radius));
|
|
710
|
+
let r = 0;
|
|
711
|
+
let g = 0;
|
|
712
|
+
let b = 0;
|
|
713
|
+
let count = 0;
|
|
714
|
+
for (let y = minY; y <= maxY; y++) {
|
|
715
|
+
for (let x = minX; x <= maxX; x++) {
|
|
716
|
+
const i = (y * width + x) * 4;
|
|
717
|
+
const a = data[i + 3] / 255;
|
|
718
|
+
r += data[i] * a + 255 * (1 - a);
|
|
719
|
+
g += data[i + 1] * a + 255 * (1 - a);
|
|
720
|
+
b += data[i + 2] * a + 255 * (1 - a);
|
|
721
|
+
count++;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return count ? { r: r / count, g: g / count, b: b / count } : { r: 255, g: 255, b: 255 };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.18) {
|
|
728
|
+
const moduleW = bounds.width / size;
|
|
729
|
+
const moduleH = bounds.height / size;
|
|
730
|
+
const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
|
|
731
|
+
const rgbGrid = Array.from({ length: size }, () => new Array(size));
|
|
732
|
+
for (let r = 0; r < size; r++) {
|
|
733
|
+
for (let c = 0; c < size; c++) {
|
|
734
|
+
const x = bounds.x + (c + 0.5) * moduleW;
|
|
735
|
+
const y = bounds.y + (r + 0.5) * moduleH;
|
|
736
|
+
rgbGrid[r][c] = sampleAverageAxis(imageData, x, y, radius);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export const visionInternals = Object.freeze({
|
|
743
|
+
buildGray,
|
|
744
|
+
otsuThreshold,
|
|
745
|
+
buildBinary,
|
|
746
|
+
finderRatioScore,
|
|
747
|
+
detectFinderCandidates,
|
|
748
|
+
chooseFinderTriples,
|
|
749
|
+
searchAlignment,
|
|
750
|
+
bilinearRgb,
|
|
751
|
+
luminance
|
|
752
|
+
});
|