quadqr-js 1.2.0 → 1.4.2
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 +37 -9
- package/README.md +98 -48
- package/SPECIFICATION.md +30 -10
- package/bin/quadqr.js +6 -2
- package/dist/esm/benchmark.js +71 -15
- package/dist/esm/brotli.js +2210 -0
- package/dist/esm/deflate.js +381 -0
- package/dist/esm/quadqr.js +1927 -343
- package/dist/esm/security.js +19 -0
- package/dist/esm/vision.js +660 -44
- package/dist/quadqr.js +5292 -489
- package/dist/quadqr.min.js +4987 -468
- package/docs/API.md +49 -12
- package/docs/BROWSER_CDN.md +9 -5
- package/docs/CLI.md +19 -2
- package/docs/COMPRESSION.md +170 -0
- package/docs/GETTING_STARTED.md +12 -2
- package/docs/HIGH_DENSITY_MODE.md +100 -0
- package/docs/NODE.md +22 -0
- package/docs/README.md +8 -5
- package/docs/RELIABILITY_LAB.md +64 -0
- package/docs/TRIANGLE16.md +98 -0
- package/docs/WASM.md +1 -1
- package/package.json +11 -10
- package/types/benchmark.d.ts +0 -8
- package/types/index.d.ts +0 -375
- package/types/node.d.ts +0 -11
package/dist/esm/vision.js
CHANGED
|
@@ -292,6 +292,31 @@ function detectFinderCandidates(binary, width, height, options = {}) {
|
|
|
292
292
|
return clusterFinderCandidates(raw, minConfirmations);
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
function recoverFinderSetFromTwo(binary, width, height, strongFinders, detector = {}) {
|
|
296
|
+
if (strongFinders.length !== 2) return strongFinders;
|
|
297
|
+
const moduleMean = (strongFinders[0].moduleSize + strongFinders[1].moduleSize) / 2;
|
|
298
|
+
const loose = detectFinderCandidates(binary, width, height, {
|
|
299
|
+
toleranceScale: Math.max(1.45, (detector.toleranceScale ?? 1) * 1.35),
|
|
300
|
+
moduleSpreadLimit: Math.max(0.72, detector.moduleSpreadLimit ?? 0.45),
|
|
301
|
+
minConfirmations: 1
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const extra = loose.filter((candidate) => {
|
|
305
|
+
const moduleRatio = Math.abs(candidate.moduleSize - moduleMean) / Math.max(candidate.moduleSize, moduleMean);
|
|
306
|
+
if (moduleRatio > 0.62) return false;
|
|
307
|
+
return strongFinders.every((known) =>
|
|
308
|
+
Math.hypot(candidate.x - known.x, candidate.y - known.y) > Math.max(moduleMean, known.moduleSize) * 5
|
|
309
|
+
);
|
|
310
|
+
}).slice(0, 12);
|
|
311
|
+
|
|
312
|
+
if (!extra.length) return strongFinders;
|
|
313
|
+
return strongFinders.concat(extra).sort((a, b) =>
|
|
314
|
+
(b.confirmations - a.confirmations) ||
|
|
315
|
+
(a.score - b.score) ||
|
|
316
|
+
(b.moduleSize - a.moduleSize)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
295
320
|
function dot(a, b) {
|
|
296
321
|
return a.x * b.x + a.y * b.y;
|
|
297
322
|
}
|
|
@@ -304,9 +329,15 @@ function cross(a, b) {
|
|
|
304
329
|
return a.x * b.y - a.y * b.x;
|
|
305
330
|
}
|
|
306
331
|
|
|
307
|
-
function chooseFinderTriples(candidates, maxTriples = 16) {
|
|
308
|
-
const
|
|
332
|
+
function chooseFinderTriples(candidates, maxTriples = 16, options = {}) {
|
|
333
|
+
const perspectiveRecovery = options.perspectiveRecovery === true;
|
|
334
|
+
const topLimit = perspectiveRecovery ? 18 : 14;
|
|
335
|
+
const top = candidates.slice(0, Math.min(candidates.length, topLimit));
|
|
309
336
|
const triples = [];
|
|
337
|
+
const maxCornerCos = options.maxCornerCos ?? (perspectiveRecovery ? 0.84 : 0.55);
|
|
338
|
+
const maxModuleSpread = options.maxFinderModuleSpread ?? (perspectiveRecovery ? 0.78 : 0.5);
|
|
339
|
+
const maxLegRatio = options.maxFinderLegRatio ?? (perspectiveRecovery ? 3.6 : 2.1);
|
|
340
|
+
const minLegModules = options.minFinderLegModules ?? (perspectiveRecovery ? 7.5 : 10);
|
|
310
341
|
|
|
311
342
|
for (let a = 0; a < top.length - 2; a++) {
|
|
312
343
|
for (let b = a + 1; b < top.length - 1; b++) {
|
|
@@ -321,9 +352,9 @@ function chooseFinderTriples(candidates, maxTriples = 16) {
|
|
|
321
352
|
let v = sub(bl, tl);
|
|
322
353
|
let d1 = Math.hypot(u.x, u.y);
|
|
323
354
|
let d2 = Math.hypot(v.x, v.y);
|
|
324
|
-
if (d1 < tl.moduleSize *
|
|
355
|
+
if (d1 < tl.moduleSize * minLegModules || d2 < tl.moduleSize * minLegModules) continue;
|
|
325
356
|
const cos = Math.abs(dot(u, v) / (d1 * d2));
|
|
326
|
-
if (cos >
|
|
357
|
+
if (cos > maxCornerCos) continue;
|
|
327
358
|
if (cross(u, v) < 0) {
|
|
328
359
|
[tr, bl] = [bl, tr];
|
|
329
360
|
u = sub(tr, tl);
|
|
@@ -337,13 +368,26 @@ function chooseFinderTriples(candidates, maxTriples = 16) {
|
|
|
337
368
|
Math.abs(tr.moduleSize - moduleMean),
|
|
338
369
|
Math.abs(bl.moduleSize - moduleMean)
|
|
339
370
|
) / moduleMean;
|
|
340
|
-
if (moduleSpread >
|
|
371
|
+
if (moduleSpread > maxModuleSpread) continue;
|
|
341
372
|
const legRatio = Math.max(d1, d2) / Math.min(d1, d2);
|
|
342
|
-
if (legRatio >
|
|
373
|
+
if (legRatio > maxLegRatio) continue;
|
|
343
374
|
const area = Math.abs(cross(u, v));
|
|
344
375
|
const confirmScore = tl.confirmations + tr.confirmations + bl.confirmations;
|
|
345
|
-
const
|
|
346
|
-
|
|
376
|
+
const recoveryPenalty = perspectiveRecovery
|
|
377
|
+
? 1 + Math.max(0, cos - 0.55) * 7 + Math.max(0, moduleSpread - 0.5) * 5 + Math.max(0, legRatio - 2.1) * 2
|
|
378
|
+
: 1;
|
|
379
|
+
const score = area / ((1 + cos * 8 + moduleSpread * 5 + Math.max(0, legRatio - 1) * 2) * recoveryPenalty) + confirmScore * 100;
|
|
380
|
+
triples.push({
|
|
381
|
+
tl,
|
|
382
|
+
tr,
|
|
383
|
+
bl,
|
|
384
|
+
moduleMean,
|
|
385
|
+
moduleSpread,
|
|
386
|
+
legRatio,
|
|
387
|
+
score,
|
|
388
|
+
orthogonality: 1 - cos,
|
|
389
|
+
perspectiveRecovery
|
|
390
|
+
});
|
|
347
391
|
}
|
|
348
392
|
}
|
|
349
393
|
}
|
|
@@ -374,6 +418,14 @@ function alignmentTemplateValue(pattern, r, c) {
|
|
|
374
418
|
return alignmentPatternIsBlack(pattern, r, c) ? 1 : 0;
|
|
375
419
|
}
|
|
376
420
|
|
|
421
|
+
const ALIGNMENT_SUBCELL_PROBES = Object.freeze([
|
|
422
|
+
Object.freeze([0, 0]),
|
|
423
|
+
Object.freeze([-0.32, 0]),
|
|
424
|
+
Object.freeze([0.32, 0]),
|
|
425
|
+
Object.freeze([0, -0.32]),
|
|
426
|
+
Object.freeze([0, 0.32])
|
|
427
|
+
]);
|
|
428
|
+
|
|
377
429
|
function alignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
|
|
378
430
|
let matches = 0;
|
|
379
431
|
let total = 0;
|
|
@@ -391,7 +443,31 @@ function alignmentScore(binary, width, height, center, basisU, basisV, scale, pa
|
|
|
391
443
|
return total ? matches / total : 0;
|
|
392
444
|
}
|
|
393
445
|
|
|
394
|
-
function
|
|
446
|
+
function preciseAlignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
|
|
447
|
+
let matches = 0;
|
|
448
|
+
let total = 0;
|
|
449
|
+
const radius = alignmentPatternRadius(pattern);
|
|
450
|
+
for (let r = -radius; r <= radius; r++) {
|
|
451
|
+
for (let c = -radius; c <= radius; c++) {
|
|
452
|
+
const expected = alignmentTemplateValue(pattern, r, c);
|
|
453
|
+
// Centre-only alignment scoring has a broad plateau: a candidate can be
|
|
454
|
+
// wrong by almost half a module while every centre still lands in the
|
|
455
|
+
// correct solid tile. This denser score is reserved for the slow recovery
|
|
456
|
+
// path, where Triangle16 needs sub-module geometry precision.
|
|
457
|
+
for (const [du, dv] of ALIGNMENT_SUBCELL_PROBES) {
|
|
458
|
+
const x = center.x + basisU.x * (c + du) * scale + basisV.x * (r + dv) * scale;
|
|
459
|
+
const y = center.y + basisU.y * (c + du) * scale + basisV.y * (r + dv) * scale;
|
|
460
|
+
const value = sampleBinaryAt(binary, width, height, x, y);
|
|
461
|
+
if (value === null) continue;
|
|
462
|
+
total++;
|
|
463
|
+
if (value === expected) matches++;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return total ? matches / total : 0;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
395
471
|
const size = sizeForVersion(version);
|
|
396
472
|
const separation = size - 7;
|
|
397
473
|
const basisU = {
|
|
@@ -410,24 +486,158 @@ function searchAlignment(binary, width, height, triple, version) {
|
|
|
410
486
|
y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
|
|
411
487
|
};
|
|
412
488
|
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
const
|
|
489
|
+
const precise = options.preciseAlignment === true;
|
|
490
|
+
const localScoreFn = precise ? preciseAlignmentScore : alignmentScore;
|
|
491
|
+
const localOffsets = precise
|
|
492
|
+
? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
|
|
493
|
+
: [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
|
|
494
|
+
const finderSizes = [triple.tl.moduleSize, triple.tr.moduleSize, triple.bl.moduleSize];
|
|
495
|
+
const finderMin = Math.max(0.01, Math.min(...finderSizes));
|
|
496
|
+
const finderMax = Math.max(...finderSizes);
|
|
497
|
+
const finderScaleRatio = finderMax / finderMin;
|
|
498
|
+
const inferredTargetScale = clamp(
|
|
499
|
+
Math.sqrt(
|
|
500
|
+
Math.max(0.2, triple.tr.moduleSize / Math.max(0.01, triple.tl.moduleSize)) *
|
|
501
|
+
Math.max(0.2, triple.bl.moduleSize / Math.max(0.01, triple.tl.moduleSize))
|
|
502
|
+
),
|
|
503
|
+
0.58,
|
|
504
|
+
1.75
|
|
505
|
+
);
|
|
506
|
+
const localScales = [...new Set([
|
|
507
|
+
0.72, 0.85, 1, 1.15, 1.3,
|
|
508
|
+
Number(inferredTargetScale.toFixed(3)),
|
|
509
|
+
Number((inferredTargetScale * 0.88).toFixed(3)),
|
|
510
|
+
Number((inferredTargetScale * 1.12).toFixed(3))
|
|
511
|
+
])].filter((scale) => scale >= 0.52 && scale <= 1.9);
|
|
512
|
+
|
|
513
|
+
const centerAt = (offsetU, offsetV) => ({
|
|
514
|
+
x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
|
|
515
|
+
y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
|
|
516
|
+
});
|
|
517
|
+
let best = { score: 0, center: predicted, scale: 1, offsetU: 0, offsetV: 0, broadSearch: false };
|
|
416
518
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
519
|
+
const evaluateIntoBest = (offsetU, offsetV, scales, scoreFn, broadSearch = false) => {
|
|
520
|
+
const center = centerAt(offsetU, offsetV);
|
|
521
|
+
for (const scale of scales) {
|
|
522
|
+
const score = scoreFn(binary, width, height, center, basisU, basisV, scale, target);
|
|
523
|
+
if (score > best.score) best = { score, center, scale, offsetU, offsetV, broadSearch };
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
for (const ou of localOffsets) {
|
|
528
|
+
for (const ov of localOffsets) evaluateIntoBest(ou, ov, localScales, localScoreFn, false);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// A three-finder affine extrapolation becomes increasingly wrong as the
|
|
532
|
+
// symbol tilts away from the camera. Under strong yaw/pitch the primary
|
|
533
|
+
// bottom-right alignment marker can move 8-15 modules away from the affine
|
|
534
|
+
// prediction even though all three finder patterns remain strong. Use a
|
|
535
|
+
// bounded coarse-to-fine search only when the locator geometry signals that
|
|
536
|
+
// projective foreshortening is present.
|
|
537
|
+
const cornerSkew = Math.max(0, 1 - (triple.orthogonality ?? 1));
|
|
538
|
+
const projectiveSignal = Math.max(
|
|
539
|
+
Math.max(0, finderScaleRatio - 1),
|
|
540
|
+
Math.max(0, (triple.moduleSpread ?? 0) * 1.25),
|
|
541
|
+
cornerSkew * 0.75
|
|
542
|
+
);
|
|
543
|
+
const broadEnabled = options.perspectiveAlignmentRecovery !== false && (
|
|
544
|
+
options.perspectiveRecovery === true ||
|
|
545
|
+
projectiveSignal >= (options.perspectiveAlignmentSignalThreshold ?? 0.32)
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
if (broadEnabled && best.score < (options.perspectiveAlignmentEarlyAccept ?? 0.78)) {
|
|
549
|
+
const radius = clamp(
|
|
550
|
+
Number(options.perspectiveAlignmentRadius ?? (3.5 + projectiveSignal * 17)),
|
|
551
|
+
4,
|
|
552
|
+
options.perspectiveAlignmentMaxRadius ?? 17
|
|
553
|
+
);
|
|
554
|
+
const coarseStep = radius > 12 ? 3 : radius > 8 ? 2.5 : 2;
|
|
555
|
+
const coarseScales = [...new Set([
|
|
556
|
+
Number((inferredTargetScale * 0.78).toFixed(3)),
|
|
557
|
+
Number((inferredTargetScale * 0.92).toFixed(3)),
|
|
558
|
+
Number(inferredTargetScale.toFixed(3)),
|
|
559
|
+
Number((inferredTargetScale * 1.10).toFixed(3)),
|
|
560
|
+
Number((inferredTargetScale * 1.28).toFixed(3)),
|
|
561
|
+
0.68, 0.82, 1, 1.18, 1.38, 1.58
|
|
562
|
+
])].filter((scale) => scale >= 0.48 && scale <= 2.05);
|
|
563
|
+
const coarseOffsets = [0];
|
|
564
|
+
for (let offset = coarseStep; offset <= radius + 0.001; offset += coarseStep) {
|
|
565
|
+
coarseOffsets.push(offset, -offset);
|
|
566
|
+
}
|
|
567
|
+
if (!coarseOffsets.some((value) => Math.abs(Math.abs(value) - radius) < 0.35)) {
|
|
568
|
+
coarseOffsets.push(radius, -radius);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const seeds = [];
|
|
572
|
+
for (const ou of coarseOffsets) {
|
|
573
|
+
for (const ov of coarseOffsets) {
|
|
574
|
+
if (Math.abs(ou) <= 2.75 && Math.abs(ov) <= 2.75) continue;
|
|
575
|
+
const center = centerAt(ou, ov);
|
|
576
|
+
let seedScore = 0;
|
|
577
|
+
let seedScale = 1;
|
|
578
|
+
// Keep the coarse pass cheap. Fine refinement below uses the denser
|
|
579
|
+
// sub-cell alignment score to reject data-cell lookalikes.
|
|
580
|
+
for (const scale of coarseScales) {
|
|
581
|
+
const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
|
|
582
|
+
if (score > seedScore) {
|
|
583
|
+
seedScore = score;
|
|
584
|
+
seedScale = scale;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
seeds.push({ score: seedScore, scale: seedScale, offsetU: ou, offsetV: ov });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
seeds.sort((a, b) => b.score - a.score);
|
|
591
|
+
|
|
592
|
+
let broadBest = null;
|
|
593
|
+
const fineSeeds = seeds.slice(0, options.perspectiveAlignmentFineSeeds ?? 6);
|
|
594
|
+
const fineOffsets = [-1.25, -0.75, -0.4, 0, 0.4, 0.75, 1.25];
|
|
595
|
+
for (const seed of fineSeeds) {
|
|
596
|
+
const fineScales = [...new Set([
|
|
597
|
+
Number((seed.scale * 0.86).toFixed(3)),
|
|
598
|
+
Number((seed.scale * 0.94).toFixed(3)),
|
|
599
|
+
Number(seed.scale.toFixed(3)),
|
|
600
|
+
Number((seed.scale * 1.06).toFixed(3)),
|
|
601
|
+
Number((seed.scale * 1.16).toFixed(3)),
|
|
602
|
+
Number(inferredTargetScale.toFixed(3)),
|
|
603
|
+
1.0, 1.15, 1.3
|
|
604
|
+
])].filter((scale) => scale >= 0.46 && scale <= 2.1);
|
|
605
|
+
for (const du of fineOffsets) {
|
|
606
|
+
for (const dv of fineOffsets) {
|
|
607
|
+
const offsetU = seed.offsetU + du;
|
|
608
|
+
const offsetV = seed.offsetV + dv;
|
|
609
|
+
const center = centerAt(offsetU, offsetV);
|
|
610
|
+
for (const scale of fineScales) {
|
|
611
|
+
const score = preciseAlignmentScore(binary, width, height, center, basisU, basisV, scale, target);
|
|
612
|
+
if (!broadBest || score > broadBest.score) {
|
|
613
|
+
broadBest = { score, center, scale, offsetU, offsetV, broadSearch: true };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
426
617
|
}
|
|
427
618
|
}
|
|
619
|
+
|
|
620
|
+
// The precise score probes each alignment module at several sub-cell
|
|
621
|
+
// positions, making it substantially harder for random payload cells to
|
|
622
|
+
// impersonate the primary reference. Prefer a strong projective candidate
|
|
623
|
+
// even if the cheap centre-only local score happened to be slightly higher.
|
|
624
|
+
if (broadBest && (
|
|
625
|
+
broadBest.score >= (options.perspectiveAlignmentPreciseThreshold ?? 0.80) ||
|
|
626
|
+
broadBest.score > best.score + 0.08
|
|
627
|
+
)) {
|
|
628
|
+
best = broadBest;
|
|
629
|
+
}
|
|
428
630
|
}
|
|
429
631
|
|
|
430
|
-
return {
|
|
632
|
+
return {
|
|
633
|
+
...best,
|
|
634
|
+
basisU,
|
|
635
|
+
basisV,
|
|
636
|
+
predicted,
|
|
637
|
+
target,
|
|
638
|
+
finderScaleRatio,
|
|
639
|
+
projectiveSignal
|
|
640
|
+
};
|
|
431
641
|
}
|
|
432
642
|
|
|
433
643
|
function projectedAlignmentScore(binary, width, height, homography, pattern) {
|
|
@@ -464,6 +674,183 @@ function scoreAlignmentGrid(binary, width, height, homography, version) {
|
|
|
464
674
|
return { score, patternScores };
|
|
465
675
|
}
|
|
466
676
|
|
|
677
|
+
function localProjectiveBasis(homography, moduleX, moduleY) {
|
|
678
|
+
const center = projectPoint(homography, moduleX, moduleY);
|
|
679
|
+
const alongU = projectPoint(homography, moduleX + 1, moduleY);
|
|
680
|
+
const alongV = projectPoint(homography, moduleX, moduleY + 1);
|
|
681
|
+
return {
|
|
682
|
+
center,
|
|
683
|
+
basisU: { x: alongU.x - center.x, y: alongU.y - center.y },
|
|
684
|
+
basisV: { x: alongV.x - center.x, y: alongV.y - center.y }
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function searchProjectedAlignment(binary, width, height, homography, pattern) {
|
|
689
|
+
const moduleX = pattern.col + 0.5;
|
|
690
|
+
const moduleY = pattern.row + 0.5;
|
|
691
|
+
const local = localProjectiveBasis(homography, moduleX, moduleY);
|
|
692
|
+
const moduleScale = Math.max(
|
|
693
|
+
0.5,
|
|
694
|
+
(Math.hypot(local.basisU.x, local.basisU.y) + Math.hypot(local.basisV.x, local.basisV.y)) / 2
|
|
695
|
+
);
|
|
696
|
+
const offsets = [-1, -0.5, 0, 0.5, 1];
|
|
697
|
+
const scales = [0.88, 1, 1.12];
|
|
698
|
+
let best = {
|
|
699
|
+
score: projectedAlignmentScore(binary, width, height, homography, pattern),
|
|
700
|
+
center: local.center,
|
|
701
|
+
scale: 1,
|
|
702
|
+
offsetU: 0,
|
|
703
|
+
offsetV: 0,
|
|
704
|
+
displacementModules: 0
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
for (const offsetU of offsets) {
|
|
708
|
+
for (const offsetV of offsets) {
|
|
709
|
+
const center = {
|
|
710
|
+
x: local.center.x + local.basisU.x * offsetU + local.basisV.x * offsetV,
|
|
711
|
+
y: local.center.y + local.basisU.y * offsetU + local.basisV.y * offsetV
|
|
712
|
+
};
|
|
713
|
+
for (const scale of scales) {
|
|
714
|
+
const score = alignmentScore(
|
|
715
|
+
binary,
|
|
716
|
+
width,
|
|
717
|
+
height,
|
|
718
|
+
center,
|
|
719
|
+
local.basisU,
|
|
720
|
+
local.basisV,
|
|
721
|
+
scale,
|
|
722
|
+
pattern
|
|
723
|
+
);
|
|
724
|
+
if (score > best.score) {
|
|
725
|
+
best = {
|
|
726
|
+
score,
|
|
727
|
+
center,
|
|
728
|
+
scale,
|
|
729
|
+
offsetU,
|
|
730
|
+
offsetV,
|
|
731
|
+
displacementModules: Math.hypot(offsetU, offsetV),
|
|
732
|
+
displacementPixels: Math.hypot(offsetU, offsetV) * moduleScale
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return best;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function refineHomographyWithAlignmentGrid(
|
|
743
|
+
binary,
|
|
744
|
+
width,
|
|
745
|
+
height,
|
|
746
|
+
initialHomography,
|
|
747
|
+
version,
|
|
748
|
+
sourcePoints,
|
|
749
|
+
destinationPoints,
|
|
750
|
+
initialGrid,
|
|
751
|
+
options = {}
|
|
752
|
+
) {
|
|
753
|
+
const patterns = alignmentPatternCentersForVersion(version);
|
|
754
|
+
if (patterns.length <= 1 || options.alignmentRefinement === false) {
|
|
755
|
+
return {
|
|
756
|
+
homography: initialHomography,
|
|
757
|
+
grid: initialGrid,
|
|
758
|
+
refined: false,
|
|
759
|
+
points: []
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const primary = patterns.find((pattern) => pattern.primary);
|
|
764
|
+
const secondaries = patterns.filter((pattern) => !pattern.primary);
|
|
765
|
+
const minimumScore = options.alignmentRefinePatternThreshold ?? 0.84;
|
|
766
|
+
const maxPoints = Math.max(1, Math.min(18, Math.round(options.alignmentRefineMaxPoints ?? 12)));
|
|
767
|
+
const candidates = [];
|
|
768
|
+
|
|
769
|
+
for (const pattern of secondaries) {
|
|
770
|
+
const found = searchProjectedAlignment(binary, width, height, initialHomography, pattern);
|
|
771
|
+
if (found.score < minimumScore) continue;
|
|
772
|
+
if (found.displacementModules > (options.alignmentRefineMaxDisplacement ?? 1.45)) continue;
|
|
773
|
+
candidates.push({ pattern, ...found });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (!candidates.length) {
|
|
777
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Prefer confident references, but retain spatial distribution so dense
|
|
781
|
+
// versions use alignment information across the entire matrix rather than
|
|
782
|
+
// allowing a cluster of nearby markers to dominate the fit.
|
|
783
|
+
candidates.sort((a, b) =>
|
|
784
|
+
(b.score - a.score) ||
|
|
785
|
+
((b.pattern.row + b.pattern.col) - (a.pattern.row + a.pattern.col))
|
|
786
|
+
);
|
|
787
|
+
const selected = [];
|
|
788
|
+
for (const candidate of candidates) {
|
|
789
|
+
if (selected.length >= maxPoints) break;
|
|
790
|
+
const tooClose = selected.some((item) =>
|
|
791
|
+
Math.hypot(item.pattern.col - candidate.pattern.col, item.pattern.row - candidate.pattern.row) < 6
|
|
792
|
+
);
|
|
793
|
+
if (!tooClose || selected.length < 3) selected.push(candidate);
|
|
794
|
+
}
|
|
795
|
+
if (!selected.length) return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
796
|
+
|
|
797
|
+
const refinedSource = sourcePoints.slice();
|
|
798
|
+
const refinedDestination = destinationPoints.slice();
|
|
799
|
+
const weights = [4, 4, 4, 5];
|
|
800
|
+
const pointInfo = [];
|
|
801
|
+
|
|
802
|
+
for (const candidate of selected) {
|
|
803
|
+
refinedSource.push(candidate.center);
|
|
804
|
+
refinedDestination.push({
|
|
805
|
+
x: candidate.pattern.col + 0.5,
|
|
806
|
+
y: candidate.pattern.row + 0.5
|
|
807
|
+
});
|
|
808
|
+
// A 3x3 marker carries less evidence than the 5x5 primary and the three
|
|
809
|
+
// finder patterns, so it helps average geometry without overpowering them.
|
|
810
|
+
weights.push(0.8 + candidate.score * 1.7);
|
|
811
|
+
pointInfo.push({
|
|
812
|
+
row: candidate.pattern.row,
|
|
813
|
+
col: candidate.pattern.col,
|
|
814
|
+
score: candidate.score,
|
|
815
|
+
x: candidate.center.x,
|
|
816
|
+
y: candidate.center.y,
|
|
817
|
+
displacementModules: candidate.displacementModules
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
let refinedHomography;
|
|
822
|
+
try {
|
|
823
|
+
refinedHomography = computeHomographyLeastSquares(refinedSource, refinedDestination, weights);
|
|
824
|
+
} catch {
|
|
825
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version);
|
|
829
|
+
const primaryBefore = primary
|
|
830
|
+
? projectedAlignmentScore(binary, width, height, initialHomography, primary)
|
|
831
|
+
: 1;
|
|
832
|
+
const primaryAfter = primary
|
|
833
|
+
? projectedAlignmentScore(binary, width, height, refinedHomography, primary)
|
|
834
|
+
: 1;
|
|
835
|
+
const allowedGridDrop = options.alignmentRefineAllowedGridDrop ?? 0.006;
|
|
836
|
+
const allowedPrimaryDrop = options.alignmentRefineAllowedPrimaryDrop ?? 0.04;
|
|
837
|
+
|
|
838
|
+
if (
|
|
839
|
+
refinedGrid.score + allowedGridDrop < initialGrid.score ||
|
|
840
|
+
primaryAfter + allowedPrimaryDrop < primaryBefore
|
|
841
|
+
) {
|
|
842
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
homography: refinedHomography,
|
|
847
|
+
grid: refinedGrid,
|
|
848
|
+
refined: true,
|
|
849
|
+
points: pointInfo,
|
|
850
|
+
initialGridScore: initialGrid.score
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
467
854
|
function solveLinearSystemFloat(matrix, vector) {
|
|
468
855
|
const n = vector.length;
|
|
469
856
|
const a = matrix.map((row, index) => row.slice().concat([vector[index]]));
|
|
@@ -505,6 +892,43 @@ export function computeHomography(sourcePoints, destinationPoints) {
|
|
|
505
892
|
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
|
506
893
|
}
|
|
507
894
|
|
|
895
|
+
function computeHomographyLeastSquares(sourcePoints, destinationPoints, weights = null) {
|
|
896
|
+
// Same destination -> source convention as computeHomography(), but accepts
|
|
897
|
+
// more than four correspondences and averages locator/alignment measurement
|
|
898
|
+
// noise. This is used only as a refinement after a valid four-point projective
|
|
899
|
+
// solution already exists.
|
|
900
|
+
assert(
|
|
901
|
+
sourcePoints.length === destinationPoints.length && sourcePoints.length >= 4,
|
|
902
|
+
"At least four matching point pairs are required."
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
const normal = Array.from({ length: 8 }, () => Array(8).fill(0));
|
|
906
|
+
const rhs = Array(8).fill(0);
|
|
907
|
+
|
|
908
|
+
const addEquation = (row, value, weight) => {
|
|
909
|
+
for (let i = 0; i < 8; i++) {
|
|
910
|
+
rhs[i] += row[i] * value * weight;
|
|
911
|
+
for (let j = 0; j < 8; j++) normal[i][j] += row[i] * row[j] * weight;
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
for (let i = 0; i < sourcePoints.length; i++) {
|
|
916
|
+
const u = destinationPoints[i].x;
|
|
917
|
+
const v = destinationPoints[i].y;
|
|
918
|
+
const x = sourcePoints[i].x;
|
|
919
|
+
const y = sourcePoints[i].y;
|
|
920
|
+
const weight = Math.max(0.05, Number(weights?.[i] ?? 1));
|
|
921
|
+
addEquation([u, v, 1, 0, 0, 0, -u * x, -v * x], x, weight);
|
|
922
|
+
addEquation([0, 0, 0, u, v, 1, -u * y, -v * y], y, weight);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Tiny Tikhonov regularisation keeps near-degenerate noisy fits numerically
|
|
926
|
+
// stable without moving a normal QR-sized solution in any meaningful way.
|
|
927
|
+
for (let i = 0; i < 8; i++) normal[i][i] += 1e-9;
|
|
928
|
+
const h = solveLinearSystemFloat(normal, rhs);
|
|
929
|
+
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
|
930
|
+
}
|
|
931
|
+
|
|
508
932
|
export function projectPoint(h, u, v) {
|
|
509
933
|
const denominator = h[6] * u + h[7] * v + h[8];
|
|
510
934
|
return {
|
|
@@ -518,7 +942,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
518
942
|
const minVersion = options.minVersion ?? 1;
|
|
519
943
|
const maxVersion = options.maxVersion ?? 40;
|
|
520
944
|
const maxCandidates = options.maxCandidates ?? 8;
|
|
521
|
-
const triples = chooseFinderTriples(finders, 20);
|
|
945
|
+
const triples = chooseFinderTriples(finders, options.perspectiveRecovery ? 30 : 20, options);
|
|
522
946
|
const geometries = [];
|
|
523
947
|
|
|
524
948
|
for (const triple of triples) {
|
|
@@ -530,7 +954,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
530
954
|
for (const item of versions) {
|
|
531
955
|
const version = item.version;
|
|
532
956
|
const size = sizeForVersion(version);
|
|
533
|
-
const alignment = searchAlignment(binary, width, height, triple, version);
|
|
957
|
+
const alignment = searchAlignment(binary, width, height, triple, version, options);
|
|
534
958
|
if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
|
|
535
959
|
|
|
536
960
|
const alignmentTarget = alignment.target;
|
|
@@ -554,8 +978,34 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
554
978
|
continue;
|
|
555
979
|
}
|
|
556
980
|
|
|
557
|
-
const
|
|
558
|
-
|
|
981
|
+
const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
|
|
982
|
+
const alignmentGridThreshold = options.alignmentGridThreshold ?? 0.68;
|
|
983
|
+
const refinementFloor = Math.max(0, alignmentGridThreshold - (options.alignmentRefineCandidateMargin ?? 0.10));
|
|
984
|
+
const skipRefinementScore = options.alignmentRefineSkipScore ?? 0.985;
|
|
985
|
+
const refinement = (
|
|
986
|
+
initialAlignmentGrid.score >= refinementFloor &&
|
|
987
|
+
initialAlignmentGrid.score < skipRefinementScore
|
|
988
|
+
)
|
|
989
|
+
? refineHomographyWithAlignmentGrid(
|
|
990
|
+
binary,
|
|
991
|
+
width,
|
|
992
|
+
height,
|
|
993
|
+
homography,
|
|
994
|
+
version,
|
|
995
|
+
src,
|
|
996
|
+
dest,
|
|
997
|
+
initialAlignmentGrid,
|
|
998
|
+
options
|
|
999
|
+
)
|
|
1000
|
+
: {
|
|
1001
|
+
homography,
|
|
1002
|
+
grid: initialAlignmentGrid,
|
|
1003
|
+
refined: false,
|
|
1004
|
+
points: []
|
|
1005
|
+
};
|
|
1006
|
+
homography = refinement.homography;
|
|
1007
|
+
const alignmentGrid = refinement.grid;
|
|
1008
|
+
if (alignmentGrid.score < alignmentGridThreshold) continue;
|
|
559
1009
|
|
|
560
1010
|
const versionPenalty = item.error / 4;
|
|
561
1011
|
const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
|
|
@@ -574,6 +1024,9 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
574
1024
|
target: { row: alignmentTarget.row, col: alignmentTarget.col },
|
|
575
1025
|
patterns: alignmentGrid.patternScores.length,
|
|
576
1026
|
gridScore: alignmentGrid.score,
|
|
1027
|
+
initialGridScore: refinement.initialGridScore ?? initialAlignmentGrid.score,
|
|
1028
|
+
refined: refinement.refined,
|
|
1029
|
+
refinementPoints: refinement.points,
|
|
577
1030
|
patternScores: alignmentGrid.patternScores
|
|
578
1031
|
},
|
|
579
1032
|
threshold,
|
|
@@ -645,19 +1098,22 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
645
1098
|
const maxCandidates = options.maxCandidates ?? 8;
|
|
646
1099
|
|
|
647
1100
|
const evaluatePass = (pass, recovery = false) => {
|
|
648
|
-
const
|
|
649
|
-
const
|
|
1101
|
+
const detector = pass.detector ?? {};
|
|
1102
|
+
const finders = detectFinderCandidates(pass.binary, width, height, detector);
|
|
1103
|
+
const geometryOptions = {
|
|
1104
|
+
...options,
|
|
1105
|
+
finderMethod: pass.finderMethod,
|
|
1106
|
+
perspectiveRecovery: recovery || options.perspectiveRecovery === true,
|
|
1107
|
+
alignmentThreshold: recovery ? 0.68 : 0.72,
|
|
1108
|
+
alignmentGridThreshold: recovery ? 0.64 : 0.68
|
|
1109
|
+
};
|
|
1110
|
+
let geometries = geometryCandidatesFromBinary(
|
|
650
1111
|
pass.binary,
|
|
651
1112
|
width,
|
|
652
1113
|
height,
|
|
653
1114
|
finders,
|
|
654
1115
|
pass.threshold,
|
|
655
|
-
|
|
656
|
-
...options,
|
|
657
|
-
finderMethod: pass.finderMethod,
|
|
658
|
-
alignmentThreshold: recovery ? 0.68 : 0.72,
|
|
659
|
-
alignmentGridThreshold: recovery ? 0.64 : 0.68
|
|
660
|
-
}
|
|
1116
|
+
geometryOptions
|
|
661
1117
|
);
|
|
662
1118
|
|
|
663
1119
|
pushFinderDiagnostic(
|
|
@@ -667,10 +1123,39 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
667
1123
|
finders,
|
|
668
1124
|
geometries
|
|
669
1125
|
);
|
|
1126
|
+
|
|
1127
|
+
// Dense codes under projective distortion can leave two finder patterns
|
|
1128
|
+
// perfectly strong while the third is stretched enough to miss the normal
|
|
1129
|
+
// 1:1:3:1:1 tolerance. Only in that very specific recovery state, run one
|
|
1130
|
+
// bounded looser detector and let the normal three-finder geometry checks
|
|
1131
|
+
// reject false data-cell lookalikes. The clean fast path is unchanged.
|
|
1132
|
+
if (!geometries.length && recovery && finders.length === 2) {
|
|
1133
|
+
const recoveredFinders = recoverFinderSetFromTwo(pass.binary, width, height, finders, detector);
|
|
1134
|
+
if (recoveredFinders.length > 2) {
|
|
1135
|
+
const finderMethod = `${pass.finderMethod}-two-finder-recovery`;
|
|
1136
|
+
geometries = geometryCandidatesFromBinary(
|
|
1137
|
+
pass.binary,
|
|
1138
|
+
width,
|
|
1139
|
+
height,
|
|
1140
|
+
recoveredFinders,
|
|
1141
|
+
pass.threshold,
|
|
1142
|
+
{ ...geometryOptions, finderMethod }
|
|
1143
|
+
);
|
|
1144
|
+
pushFinderDiagnostic(
|
|
1145
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
|
|
1146
|
+
finderMethod,
|
|
1147
|
+
pass.threshold,
|
|
1148
|
+
recoveredFinders,
|
|
1149
|
+
geometries
|
|
1150
|
+
);
|
|
1151
|
+
if (geometries.length) return { finders: recoveredFinders, geometries };
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
670
1155
|
return { finders, geometries };
|
|
671
1156
|
};
|
|
672
1157
|
|
|
673
|
-
// Fast path stays exactly one grayscale + finder pass. No Auto Color, extra
|
|
1158
|
+
// Fast path stays exactly one grayscale + finder pass. No QuadQR Auto Color, extra
|
|
674
1159
|
// thresholding, or luminance image is computed when a normal frame works.
|
|
675
1160
|
const valueInfo = buildBinary(imageData, { grayMode: "value" });
|
|
676
1161
|
const fast = evaluatePass({
|
|
@@ -682,12 +1167,44 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
682
1167
|
if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
|
|
683
1168
|
if (options.finderRecovery === false) return [];
|
|
684
1169
|
|
|
685
|
-
//
|
|
1170
|
+
// If the clean threshold already found exactly two strong locators, try the
|
|
1171
|
+
// bounded perspective-tolerant third-finder pass before any color processing.
|
|
1172
|
+
// This is substantially cheaper than QuadQR Auto Color and targets the dense-code
|
|
1173
|
+
// projective failure mode directly.
|
|
1174
|
+
if (fast.finders.length === 2) {
|
|
1175
|
+
const recoveredFinders = recoverFinderSetFromTwo(valueInfo.binary, width, height, fast.finders, {});
|
|
1176
|
+
if (recoveredFinders.length > 2) {
|
|
1177
|
+
const finderMethod = "rgb-value-otsu-two-finder-recovery";
|
|
1178
|
+
const recoveredGeometries = geometryCandidatesFromBinary(
|
|
1179
|
+
valueInfo.binary,
|
|
1180
|
+
width,
|
|
1181
|
+
height,
|
|
1182
|
+
recoveredFinders,
|
|
1183
|
+
valueInfo.threshold,
|
|
1184
|
+
{
|
|
1185
|
+
...options,
|
|
1186
|
+
finderMethod,
|
|
1187
|
+
alignmentThreshold: 0.68,
|
|
1188
|
+
alignmentGridThreshold: 0.64
|
|
1189
|
+
}
|
|
1190
|
+
);
|
|
1191
|
+
pushFinderDiagnostic(
|
|
1192
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
|
|
1193
|
+
finderMethod,
|
|
1194
|
+
valueInfo.threshold,
|
|
1195
|
+
recoveredFinders,
|
|
1196
|
+
recoveredGeometries
|
|
1197
|
+
);
|
|
1198
|
+
if (recoveredGeometries.length) return recoveredGeometries.slice(0, maxCandidates);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Camera recovery #1: QuadQR Auto Color per-channel levels before
|
|
686
1203
|
// finder thresholding. Live camera frames are usually much larger than the
|
|
687
1204
|
// QR itself, so a single global histogram can be dominated by dark room/UI
|
|
688
|
-
// pixels around the guide.
|
|
689
|
-
//
|
|
690
|
-
//
|
|
1205
|
+
// pixels around the guide. QuadQR keeps recovery deliberately code-centric
|
|
1206
|
+
// by using center-weighted analysis windows so surrounding scene pixels do not
|
|
1207
|
+
// dominate the correction, while still applying
|
|
691
1208
|
// each correction to the full frame so finder coordinates never move.
|
|
692
1209
|
const requestedInsets = Array.isArray(options.finderAutoColorAnalysisInsets)
|
|
693
1210
|
? options.finderAutoColorAnalysisInsets
|
|
@@ -733,7 +1250,7 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
733
1250
|
|
|
734
1251
|
// Camera recovery #2: bracket the raw value-channel threshold, then retain
|
|
735
1252
|
// the legacy luminance pass for unusual captures. These are only built after
|
|
736
|
-
// both the normal and Auto Color finder passes fail.
|
|
1253
|
+
// both the normal and QuadQR Auto Color finder passes fail.
|
|
737
1254
|
const highThreshold = clamp(valueInfo.baseThreshold + 18, 8, 247);
|
|
738
1255
|
const lowThreshold = clamp(valueInfo.baseThreshold - 14, 8, 247);
|
|
739
1256
|
const recoveryPasses = [];
|
|
@@ -847,7 +1364,7 @@ function buildAutoColorLevels(imageData, options = {}) {
|
|
|
847
1364
|
// Camera recovery deliberately supports analysing only the central part of
|
|
848
1365
|
// the visible frame. A phone preview often contains very dark UI/screen
|
|
849
1366
|
// edges outside the code; letting those pixels define the black point makes
|
|
850
|
-
// an otherwise useful Auto Color pass far too weak.
|
|
1367
|
+
// an otherwise useful QuadQR Auto Color pass far too weak.
|
|
851
1368
|
for (let index = 0; index < pixelCount; index += step) {
|
|
852
1369
|
const x = x0 + (index % analysisWidth);
|
|
853
1370
|
const y = y0 + Math.floor(index / analysisWidth);
|
|
@@ -872,8 +1389,8 @@ function buildAutoColorLevels(imageData, options = {}) {
|
|
|
872
1389
|
let highs;
|
|
873
1390
|
const outputHighlight = Number(options.outputHighlight);
|
|
874
1391
|
if (Number.isFinite(outputHighlight)) {
|
|
875
|
-
// Strong
|
|
876
|
-
//
|
|
1392
|
+
// Strong QuadQR camera color-recovery mode. QuadQR Auto Color does not
|
|
1393
|
+
// stretch the brightest observed paper/white
|
|
877
1394
|
// cells all the way to 255. Instead it anchors the per-channel shadow
|
|
878
1395
|
// points close to black while keeping the observed highlight around a
|
|
879
1396
|
// neutral mid-high value. That produces much darker structural black and
|
|
@@ -946,7 +1463,7 @@ function buildAutoToneContrastColorTransform(samples, options = {}) {
|
|
|
946
1463
|
const highlightFraction = clamp(options.highlightFraction ?? 0.14, 0.04, 0.35);
|
|
947
1464
|
const saturation = clamp(options.saturation ?? 1.12, 1, 1.5);
|
|
948
1465
|
|
|
949
|
-
// Auto Color-style neutralization: use the brightest portion of the frame as
|
|
1466
|
+
// QuadQR Auto Color-style neutralization: use the brightest portion of the frame as
|
|
950
1467
|
// a likely white reference. This is especially effective on warm/yellow
|
|
951
1468
|
// phone-camera frames where the blue channel is suppressed.
|
|
952
1469
|
const luminanceHistogram = new Uint32Array(256);
|
|
@@ -1088,6 +1605,68 @@ export function samplePerspectiveMatrix(imageData, homography, size, options = {
|
|
|
1088
1605
|
return { rgbGrid };
|
|
1089
1606
|
}
|
|
1090
1607
|
|
|
1608
|
+
/**
|
|
1609
|
+
* Sample the two protected regions of every Triangle16 module.
|
|
1610
|
+
*
|
|
1611
|
+
* Triangle16 uses a fixed "/" diagonal. Samples are intentionally placed well
|
|
1612
|
+
* inside the upper-left and lower-right triangles, away from the diagonal and
|
|
1613
|
+
* module borders. This makes the decoder much less sensitive to antialiasing,
|
|
1614
|
+
* blur, resampling and small homography errors than center sampling.
|
|
1615
|
+
*/
|
|
1616
|
+
function rgbSampleSpread(values, center) {
|
|
1617
|
+
if (!values.length) return 0;
|
|
1618
|
+
return values.reduce((sum, rgb) => sum + Math.hypot(
|
|
1619
|
+
rgb.r - center.r,
|
|
1620
|
+
rgb.g - center.g,
|
|
1621
|
+
rgb.b - center.b
|
|
1622
|
+
), 0) / values.length;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
export function samplePerspectiveTriangleMatrix(imageData, homography, size, options = {}) {
|
|
1626
|
+
const triangleGrid = Array.from({ length: size }, () => new Array(size));
|
|
1627
|
+
const mode = options.sampleMode ?? "cross";
|
|
1628
|
+
const radius = options.highDensitySampleRadius ?? options.triangleSampleRadius ??
|
|
1629
|
+
(mode === "median" ? 0.055 : 0.065);
|
|
1630
|
+
const inset = Math.max(0.18, Math.min(0.34, Number(options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.27)));
|
|
1631
|
+
const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
|
|
1632
|
+
const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
|
|
1633
|
+
const sampler = mode === "median" ? robustProjectedSample : averageProjectedSample;
|
|
1634
|
+
const side = Math.max(0.18, inset - 0.06);
|
|
1635
|
+
const middle = Math.min(0.52, 0.50 - inset * 0.04);
|
|
1636
|
+
const firstAnchors = [
|
|
1637
|
+
[inset, inset],
|
|
1638
|
+
[side, middle],
|
|
1639
|
+
[middle, side]
|
|
1640
|
+
];
|
|
1641
|
+
const secondAnchors = firstAnchors.map(([x, y]) => [1 - x, 1 - y]);
|
|
1642
|
+
|
|
1643
|
+
const sampleRegion = (c, r, anchors) => {
|
|
1644
|
+
const values = anchors.map(([x, y]) => sampler(
|
|
1645
|
+
imageData,
|
|
1646
|
+
homography,
|
|
1647
|
+
c + x + offsetX,
|
|
1648
|
+
r + y + offsetY,
|
|
1649
|
+
radius
|
|
1650
|
+
));
|
|
1651
|
+
const rgb = robustRgb(values);
|
|
1652
|
+
return { rgb, spread: rgbSampleSpread(values, rgb) };
|
|
1653
|
+
};
|
|
1654
|
+
|
|
1655
|
+
for (let r = 0; r < size; r++) {
|
|
1656
|
+
for (let c = 0; c < size; c++) {
|
|
1657
|
+
const first = sampleRegion(c, r, firstAnchors);
|
|
1658
|
+
const second = sampleRegion(c, r, secondAnchors);
|
|
1659
|
+
triangleGrid[r][c] = {
|
|
1660
|
+
first: first.rgb,
|
|
1661
|
+
second: second.rgb,
|
|
1662
|
+
firstSpread: first.spread,
|
|
1663
|
+
secondSpread: second.spread
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return { triangleGrid };
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1091
1670
|
function meanRgb(values) {
|
|
1092
1671
|
if (!values.length) throw new Error("No calibration samples available.");
|
|
1093
1672
|
return values.reduce(
|
|
@@ -1284,6 +1863,43 @@ export function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.1
|
|
|
1284
1863
|
return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
1285
1864
|
}
|
|
1286
1865
|
|
|
1866
|
+
export function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.065, insetRatio = 0.27) {
|
|
1867
|
+
const moduleW = bounds.width / size;
|
|
1868
|
+
const moduleH = bounds.height / size;
|
|
1869
|
+
const inset = Math.max(0.18, Math.min(0.34, insetRatio));
|
|
1870
|
+
const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
|
|
1871
|
+
const triangleGrid = Array.from({ length: size }, () => new Array(size));
|
|
1872
|
+
const side = Math.max(0.18, inset - 0.06);
|
|
1873
|
+
const middle = Math.min(0.52, 0.50 - inset * 0.04);
|
|
1874
|
+
const firstAnchors = [[inset, inset], [side, middle], [middle, side]];
|
|
1875
|
+
const secondAnchors = firstAnchors.map(([x, y]) => [1 - x, 1 - y]);
|
|
1876
|
+
|
|
1877
|
+
const sampleRegion = (r, c, anchors) => {
|
|
1878
|
+
const values = anchors.map(([x, y]) => sampleAverageAxis(
|
|
1879
|
+
imageData,
|
|
1880
|
+
bounds.x + (c + x) * moduleW,
|
|
1881
|
+
bounds.y + (r + y) * moduleH,
|
|
1882
|
+
radius
|
|
1883
|
+
));
|
|
1884
|
+
const rgb = robustRgb(values);
|
|
1885
|
+
return { rgb, spread: rgbSampleSpread(values, rgb) };
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
for (let r = 0; r < size; r++) {
|
|
1889
|
+
for (let c = 0; c < size; c++) {
|
|
1890
|
+
const first = sampleRegion(r, c, firstAnchors);
|
|
1891
|
+
const second = sampleRegion(r, c, secondAnchors);
|
|
1892
|
+
triangleGrid[r][c] = {
|
|
1893
|
+
first: first.rgb,
|
|
1894
|
+
second: second.rgb,
|
|
1895
|
+
firstSpread: first.spread,
|
|
1896
|
+
secondSpread: second.spread
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
return { triangleGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1287
1903
|
export const visionInternals = Object.freeze({
|
|
1288
1904
|
buildGray,
|
|
1289
1905
|
otsuThreshold,
|