quadqr-js 1.2.0 → 1.3.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 +29 -3
- package/README.md +34 -9
- package/SPECIFICATION.md +8 -3
- package/bin/quadqr.js +4 -2
- package/dist/esm/benchmark.js +34 -9
- package/dist/esm/quadqr.js +878 -226
- package/dist/esm/vision.js +452 -14
- package/dist/quadqr.js +1330 -242
- package/dist/quadqr.min.js +1328 -242
- package/docs/API.md +10 -4
- package/docs/CLI.md +6 -0
- package/docs/HIGH_DENSITY_MODE.md +100 -0
- package/docs/README.md +2 -1
- package/docs/TRIANGLE16.md +108 -0
- package/package.json +4 -2
- package/types/benchmark.d.ts +3 -3
- package/types/index.d.ts +29 -1
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
|
}
|
|
@@ -374,6 +399,14 @@ function alignmentTemplateValue(pattern, r, c) {
|
|
|
374
399
|
return alignmentPatternIsBlack(pattern, r, c) ? 1 : 0;
|
|
375
400
|
}
|
|
376
401
|
|
|
402
|
+
const ALIGNMENT_SUBCELL_PROBES = Object.freeze([
|
|
403
|
+
Object.freeze([0, 0]),
|
|
404
|
+
Object.freeze([-0.32, 0]),
|
|
405
|
+
Object.freeze([0.32, 0]),
|
|
406
|
+
Object.freeze([0, -0.32]),
|
|
407
|
+
Object.freeze([0, 0.32])
|
|
408
|
+
]);
|
|
409
|
+
|
|
377
410
|
function alignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
|
|
378
411
|
let matches = 0;
|
|
379
412
|
let total = 0;
|
|
@@ -391,7 +424,31 @@ function alignmentScore(binary, width, height, center, basisU, basisV, scale, pa
|
|
|
391
424
|
return total ? matches / total : 0;
|
|
392
425
|
}
|
|
393
426
|
|
|
394
|
-
function
|
|
427
|
+
function preciseAlignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
|
|
428
|
+
let matches = 0;
|
|
429
|
+
let total = 0;
|
|
430
|
+
const radius = alignmentPatternRadius(pattern);
|
|
431
|
+
for (let r = -radius; r <= radius; r++) {
|
|
432
|
+
for (let c = -radius; c <= radius; c++) {
|
|
433
|
+
const expected = alignmentTemplateValue(pattern, r, c);
|
|
434
|
+
// Centre-only alignment scoring has a broad plateau: a candidate can be
|
|
435
|
+
// wrong by almost half a module while every centre still lands in the
|
|
436
|
+
// correct solid tile. This denser score is reserved for the slow recovery
|
|
437
|
+
// path, where Triangle16 needs sub-module geometry precision.
|
|
438
|
+
for (const [du, dv] of ALIGNMENT_SUBCELL_PROBES) {
|
|
439
|
+
const x = center.x + basisU.x * (c + du) * scale + basisV.x * (r + dv) * scale;
|
|
440
|
+
const y = center.y + basisU.y * (c + du) * scale + basisV.y * (r + dv) * scale;
|
|
441
|
+
const value = sampleBinaryAt(binary, width, height, x, y);
|
|
442
|
+
if (value === null) continue;
|
|
443
|
+
total++;
|
|
444
|
+
if (value === expected) matches++;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return total ? matches / total : 0;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
395
452
|
const size = sizeForVersion(version);
|
|
396
453
|
const separation = size - 7;
|
|
397
454
|
const basisU = {
|
|
@@ -411,8 +468,12 @@ function searchAlignment(binary, width, height, triple, version) {
|
|
|
411
468
|
};
|
|
412
469
|
|
|
413
470
|
let best = { score: 0, center: predicted, scale: 1 };
|
|
414
|
-
const
|
|
471
|
+
const precise = options.preciseAlignment === true;
|
|
472
|
+
const offsets = precise
|
|
473
|
+
? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
|
|
474
|
+
: [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
|
|
415
475
|
const scales = [0.72, 0.85, 1, 1.15, 1.3];
|
|
476
|
+
const scoreAlignment = precise ? preciseAlignmentScore : alignmentScore;
|
|
416
477
|
|
|
417
478
|
for (const ou of offsets) {
|
|
418
479
|
for (const ov of offsets) {
|
|
@@ -421,7 +482,7 @@ function searchAlignment(binary, width, height, triple, version) {
|
|
|
421
482
|
y: predicted.y + basisU.y * ou + basisV.y * ov
|
|
422
483
|
};
|
|
423
484
|
for (const scale of scales) {
|
|
424
|
-
const score =
|
|
485
|
+
const score = scoreAlignment(binary, width, height, center, basisU, basisV, scale, target);
|
|
425
486
|
if (score > best.score) best = { score, center, scale };
|
|
426
487
|
}
|
|
427
488
|
}
|
|
@@ -464,6 +525,183 @@ function scoreAlignmentGrid(binary, width, height, homography, version) {
|
|
|
464
525
|
return { score, patternScores };
|
|
465
526
|
}
|
|
466
527
|
|
|
528
|
+
function localProjectiveBasis(homography, moduleX, moduleY) {
|
|
529
|
+
const center = projectPoint(homography, moduleX, moduleY);
|
|
530
|
+
const alongU = projectPoint(homography, moduleX + 1, moduleY);
|
|
531
|
+
const alongV = projectPoint(homography, moduleX, moduleY + 1);
|
|
532
|
+
return {
|
|
533
|
+
center,
|
|
534
|
+
basisU: { x: alongU.x - center.x, y: alongU.y - center.y },
|
|
535
|
+
basisV: { x: alongV.x - center.x, y: alongV.y - center.y }
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function searchProjectedAlignment(binary, width, height, homography, pattern) {
|
|
540
|
+
const moduleX = pattern.col + 0.5;
|
|
541
|
+
const moduleY = pattern.row + 0.5;
|
|
542
|
+
const local = localProjectiveBasis(homography, moduleX, moduleY);
|
|
543
|
+
const moduleScale = Math.max(
|
|
544
|
+
0.5,
|
|
545
|
+
(Math.hypot(local.basisU.x, local.basisU.y) + Math.hypot(local.basisV.x, local.basisV.y)) / 2
|
|
546
|
+
);
|
|
547
|
+
const offsets = [-1, -0.5, 0, 0.5, 1];
|
|
548
|
+
const scales = [0.88, 1, 1.12];
|
|
549
|
+
let best = {
|
|
550
|
+
score: projectedAlignmentScore(binary, width, height, homography, pattern),
|
|
551
|
+
center: local.center,
|
|
552
|
+
scale: 1,
|
|
553
|
+
offsetU: 0,
|
|
554
|
+
offsetV: 0,
|
|
555
|
+
displacementModules: 0
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
for (const offsetU of offsets) {
|
|
559
|
+
for (const offsetV of offsets) {
|
|
560
|
+
const center = {
|
|
561
|
+
x: local.center.x + local.basisU.x * offsetU + local.basisV.x * offsetV,
|
|
562
|
+
y: local.center.y + local.basisU.y * offsetU + local.basisV.y * offsetV
|
|
563
|
+
};
|
|
564
|
+
for (const scale of scales) {
|
|
565
|
+
const score = alignmentScore(
|
|
566
|
+
binary,
|
|
567
|
+
width,
|
|
568
|
+
height,
|
|
569
|
+
center,
|
|
570
|
+
local.basisU,
|
|
571
|
+
local.basisV,
|
|
572
|
+
scale,
|
|
573
|
+
pattern
|
|
574
|
+
);
|
|
575
|
+
if (score > best.score) {
|
|
576
|
+
best = {
|
|
577
|
+
score,
|
|
578
|
+
center,
|
|
579
|
+
scale,
|
|
580
|
+
offsetU,
|
|
581
|
+
offsetV,
|
|
582
|
+
displacementModules: Math.hypot(offsetU, offsetV),
|
|
583
|
+
displacementPixels: Math.hypot(offsetU, offsetV) * moduleScale
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return best;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function refineHomographyWithAlignmentGrid(
|
|
594
|
+
binary,
|
|
595
|
+
width,
|
|
596
|
+
height,
|
|
597
|
+
initialHomography,
|
|
598
|
+
version,
|
|
599
|
+
sourcePoints,
|
|
600
|
+
destinationPoints,
|
|
601
|
+
initialGrid,
|
|
602
|
+
options = {}
|
|
603
|
+
) {
|
|
604
|
+
const patterns = alignmentPatternCentersForVersion(version);
|
|
605
|
+
if (patterns.length <= 1 || options.alignmentRefinement === false) {
|
|
606
|
+
return {
|
|
607
|
+
homography: initialHomography,
|
|
608
|
+
grid: initialGrid,
|
|
609
|
+
refined: false,
|
|
610
|
+
points: []
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const primary = patterns.find((pattern) => pattern.primary);
|
|
615
|
+
const secondaries = patterns.filter((pattern) => !pattern.primary);
|
|
616
|
+
const minimumScore = options.alignmentRefinePatternThreshold ?? 0.84;
|
|
617
|
+
const maxPoints = Math.max(1, Math.min(18, Math.round(options.alignmentRefineMaxPoints ?? 12)));
|
|
618
|
+
const candidates = [];
|
|
619
|
+
|
|
620
|
+
for (const pattern of secondaries) {
|
|
621
|
+
const found = searchProjectedAlignment(binary, width, height, initialHomography, pattern);
|
|
622
|
+
if (found.score < minimumScore) continue;
|
|
623
|
+
if (found.displacementModules > (options.alignmentRefineMaxDisplacement ?? 1.45)) continue;
|
|
624
|
+
candidates.push({ pattern, ...found });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (!candidates.length) {
|
|
628
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Prefer confident references, but retain spatial distribution so dense
|
|
632
|
+
// versions use alignment information across the entire matrix rather than
|
|
633
|
+
// allowing a cluster of nearby markers to dominate the fit.
|
|
634
|
+
candidates.sort((a, b) =>
|
|
635
|
+
(b.score - a.score) ||
|
|
636
|
+
((b.pattern.row + b.pattern.col) - (a.pattern.row + a.pattern.col))
|
|
637
|
+
);
|
|
638
|
+
const selected = [];
|
|
639
|
+
for (const candidate of candidates) {
|
|
640
|
+
if (selected.length >= maxPoints) break;
|
|
641
|
+
const tooClose = selected.some((item) =>
|
|
642
|
+
Math.hypot(item.pattern.col - candidate.pattern.col, item.pattern.row - candidate.pattern.row) < 6
|
|
643
|
+
);
|
|
644
|
+
if (!tooClose || selected.length < 3) selected.push(candidate);
|
|
645
|
+
}
|
|
646
|
+
if (!selected.length) return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
647
|
+
|
|
648
|
+
const refinedSource = sourcePoints.slice();
|
|
649
|
+
const refinedDestination = destinationPoints.slice();
|
|
650
|
+
const weights = [4, 4, 4, 5];
|
|
651
|
+
const pointInfo = [];
|
|
652
|
+
|
|
653
|
+
for (const candidate of selected) {
|
|
654
|
+
refinedSource.push(candidate.center);
|
|
655
|
+
refinedDestination.push({
|
|
656
|
+
x: candidate.pattern.col + 0.5,
|
|
657
|
+
y: candidate.pattern.row + 0.5
|
|
658
|
+
});
|
|
659
|
+
// A 3x3 marker carries less evidence than the 5x5 primary and the three
|
|
660
|
+
// finder patterns, so it helps average geometry without overpowering them.
|
|
661
|
+
weights.push(0.8 + candidate.score * 1.7);
|
|
662
|
+
pointInfo.push({
|
|
663
|
+
row: candidate.pattern.row,
|
|
664
|
+
col: candidate.pattern.col,
|
|
665
|
+
score: candidate.score,
|
|
666
|
+
x: candidate.center.x,
|
|
667
|
+
y: candidate.center.y,
|
|
668
|
+
displacementModules: candidate.displacementModules
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
let refinedHomography;
|
|
673
|
+
try {
|
|
674
|
+
refinedHomography = computeHomographyLeastSquares(refinedSource, refinedDestination, weights);
|
|
675
|
+
} catch {
|
|
676
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version);
|
|
680
|
+
const primaryBefore = primary
|
|
681
|
+
? projectedAlignmentScore(binary, width, height, initialHomography, primary)
|
|
682
|
+
: 1;
|
|
683
|
+
const primaryAfter = primary
|
|
684
|
+
? projectedAlignmentScore(binary, width, height, refinedHomography, primary)
|
|
685
|
+
: 1;
|
|
686
|
+
const allowedGridDrop = options.alignmentRefineAllowedGridDrop ?? 0.006;
|
|
687
|
+
const allowedPrimaryDrop = options.alignmentRefineAllowedPrimaryDrop ?? 0.04;
|
|
688
|
+
|
|
689
|
+
if (
|
|
690
|
+
refinedGrid.score + allowedGridDrop < initialGrid.score ||
|
|
691
|
+
primaryAfter + allowedPrimaryDrop < primaryBefore
|
|
692
|
+
) {
|
|
693
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
homography: refinedHomography,
|
|
698
|
+
grid: refinedGrid,
|
|
699
|
+
refined: true,
|
|
700
|
+
points: pointInfo,
|
|
701
|
+
initialGridScore: initialGrid.score
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
467
705
|
function solveLinearSystemFloat(matrix, vector) {
|
|
468
706
|
const n = vector.length;
|
|
469
707
|
const a = matrix.map((row, index) => row.slice().concat([vector[index]]));
|
|
@@ -505,6 +743,43 @@ export function computeHomography(sourcePoints, destinationPoints) {
|
|
|
505
743
|
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
|
506
744
|
}
|
|
507
745
|
|
|
746
|
+
function computeHomographyLeastSquares(sourcePoints, destinationPoints, weights = null) {
|
|
747
|
+
// Same destination -> source convention as computeHomography(), but accepts
|
|
748
|
+
// more than four correspondences and averages locator/alignment measurement
|
|
749
|
+
// noise. This is used only as a refinement after a valid four-point projective
|
|
750
|
+
// solution already exists.
|
|
751
|
+
assert(
|
|
752
|
+
sourcePoints.length === destinationPoints.length && sourcePoints.length >= 4,
|
|
753
|
+
"At least four matching point pairs are required."
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
const normal = Array.from({ length: 8 }, () => Array(8).fill(0));
|
|
757
|
+
const rhs = Array(8).fill(0);
|
|
758
|
+
|
|
759
|
+
const addEquation = (row, value, weight) => {
|
|
760
|
+
for (let i = 0; i < 8; i++) {
|
|
761
|
+
rhs[i] += row[i] * value * weight;
|
|
762
|
+
for (let j = 0; j < 8; j++) normal[i][j] += row[i] * row[j] * weight;
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
for (let i = 0; i < sourcePoints.length; i++) {
|
|
767
|
+
const u = destinationPoints[i].x;
|
|
768
|
+
const v = destinationPoints[i].y;
|
|
769
|
+
const x = sourcePoints[i].x;
|
|
770
|
+
const y = sourcePoints[i].y;
|
|
771
|
+
const weight = Math.max(0.05, Number(weights?.[i] ?? 1));
|
|
772
|
+
addEquation([u, v, 1, 0, 0, 0, -u * x, -v * x], x, weight);
|
|
773
|
+
addEquation([0, 0, 0, u, v, 1, -u * y, -v * y], y, weight);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Tiny Tikhonov regularisation keeps near-degenerate noisy fits numerically
|
|
777
|
+
// stable without moving a normal QR-sized solution in any meaningful way.
|
|
778
|
+
for (let i = 0; i < 8; i++) normal[i][i] += 1e-9;
|
|
779
|
+
const h = solveLinearSystemFloat(normal, rhs);
|
|
780
|
+
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
|
781
|
+
}
|
|
782
|
+
|
|
508
783
|
export function projectPoint(h, u, v) {
|
|
509
784
|
const denominator = h[6] * u + h[7] * v + h[8];
|
|
510
785
|
return {
|
|
@@ -530,7 +805,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
530
805
|
for (const item of versions) {
|
|
531
806
|
const version = item.version;
|
|
532
807
|
const size = sizeForVersion(version);
|
|
533
|
-
const alignment = searchAlignment(binary, width, height, triple, version);
|
|
808
|
+
const alignment = searchAlignment(binary, width, height, triple, version, options);
|
|
534
809
|
if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
|
|
535
810
|
|
|
536
811
|
const alignmentTarget = alignment.target;
|
|
@@ -554,8 +829,34 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
554
829
|
continue;
|
|
555
830
|
}
|
|
556
831
|
|
|
557
|
-
const
|
|
558
|
-
|
|
832
|
+
const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
|
|
833
|
+
const alignmentGridThreshold = options.alignmentGridThreshold ?? 0.68;
|
|
834
|
+
const refinementFloor = Math.max(0, alignmentGridThreshold - (options.alignmentRefineCandidateMargin ?? 0.10));
|
|
835
|
+
const skipRefinementScore = options.alignmentRefineSkipScore ?? 0.985;
|
|
836
|
+
const refinement = (
|
|
837
|
+
initialAlignmentGrid.score >= refinementFloor &&
|
|
838
|
+
initialAlignmentGrid.score < skipRefinementScore
|
|
839
|
+
)
|
|
840
|
+
? refineHomographyWithAlignmentGrid(
|
|
841
|
+
binary,
|
|
842
|
+
width,
|
|
843
|
+
height,
|
|
844
|
+
homography,
|
|
845
|
+
version,
|
|
846
|
+
src,
|
|
847
|
+
dest,
|
|
848
|
+
initialAlignmentGrid,
|
|
849
|
+
options
|
|
850
|
+
)
|
|
851
|
+
: {
|
|
852
|
+
homography,
|
|
853
|
+
grid: initialAlignmentGrid,
|
|
854
|
+
refined: false,
|
|
855
|
+
points: []
|
|
856
|
+
};
|
|
857
|
+
homography = refinement.homography;
|
|
858
|
+
const alignmentGrid = refinement.grid;
|
|
859
|
+
if (alignmentGrid.score < alignmentGridThreshold) continue;
|
|
559
860
|
|
|
560
861
|
const versionPenalty = item.error / 4;
|
|
561
862
|
const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
|
|
@@ -574,6 +875,9 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
574
875
|
target: { row: alignmentTarget.row, col: alignmentTarget.col },
|
|
575
876
|
patterns: alignmentGrid.patternScores.length,
|
|
576
877
|
gridScore: alignmentGrid.score,
|
|
878
|
+
initialGridScore: refinement.initialGridScore ?? initialAlignmentGrid.score,
|
|
879
|
+
refined: refinement.refined,
|
|
880
|
+
refinementPoints: refinement.points,
|
|
577
881
|
patternScores: alignmentGrid.patternScores
|
|
578
882
|
},
|
|
579
883
|
threshold,
|
|
@@ -645,19 +949,21 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
645
949
|
const maxCandidates = options.maxCandidates ?? 8;
|
|
646
950
|
|
|
647
951
|
const evaluatePass = (pass, recovery = false) => {
|
|
648
|
-
const
|
|
649
|
-
const
|
|
952
|
+
const detector = pass.detector ?? {};
|
|
953
|
+
const finders = detectFinderCandidates(pass.binary, width, height, detector);
|
|
954
|
+
const geometryOptions = {
|
|
955
|
+
...options,
|
|
956
|
+
finderMethod: pass.finderMethod,
|
|
957
|
+
alignmentThreshold: recovery ? 0.68 : 0.72,
|
|
958
|
+
alignmentGridThreshold: recovery ? 0.64 : 0.68
|
|
959
|
+
};
|
|
960
|
+
let geometries = geometryCandidatesFromBinary(
|
|
650
961
|
pass.binary,
|
|
651
962
|
width,
|
|
652
963
|
height,
|
|
653
964
|
finders,
|
|
654
965
|
pass.threshold,
|
|
655
|
-
|
|
656
|
-
...options,
|
|
657
|
-
finderMethod: pass.finderMethod,
|
|
658
|
-
alignmentThreshold: recovery ? 0.68 : 0.72,
|
|
659
|
-
alignmentGridThreshold: recovery ? 0.64 : 0.68
|
|
660
|
-
}
|
|
966
|
+
geometryOptions
|
|
661
967
|
);
|
|
662
968
|
|
|
663
969
|
pushFinderDiagnostic(
|
|
@@ -667,6 +973,35 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
667
973
|
finders,
|
|
668
974
|
geometries
|
|
669
975
|
);
|
|
976
|
+
|
|
977
|
+
// Dense codes under projective distortion can leave two finder patterns
|
|
978
|
+
// perfectly strong while the third is stretched enough to miss the normal
|
|
979
|
+
// 1:1:3:1:1 tolerance. Only in that very specific recovery state, run one
|
|
980
|
+
// bounded looser detector and let the normal three-finder geometry checks
|
|
981
|
+
// reject false data-cell lookalikes. The clean fast path is unchanged.
|
|
982
|
+
if (!geometries.length && recovery && finders.length === 2) {
|
|
983
|
+
const recoveredFinders = recoverFinderSetFromTwo(pass.binary, width, height, finders, detector);
|
|
984
|
+
if (recoveredFinders.length > 2) {
|
|
985
|
+
const finderMethod = `${pass.finderMethod}-two-finder-recovery`;
|
|
986
|
+
geometries = geometryCandidatesFromBinary(
|
|
987
|
+
pass.binary,
|
|
988
|
+
width,
|
|
989
|
+
height,
|
|
990
|
+
recoveredFinders,
|
|
991
|
+
pass.threshold,
|
|
992
|
+
{ ...geometryOptions, finderMethod }
|
|
993
|
+
);
|
|
994
|
+
pushFinderDiagnostic(
|
|
995
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
|
|
996
|
+
finderMethod,
|
|
997
|
+
pass.threshold,
|
|
998
|
+
recoveredFinders,
|
|
999
|
+
geometries
|
|
1000
|
+
);
|
|
1001
|
+
if (geometries.length) return { finders: recoveredFinders, geometries };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
670
1005
|
return { finders, geometries };
|
|
671
1006
|
};
|
|
672
1007
|
|
|
@@ -682,6 +1017,38 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
682
1017
|
if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
|
|
683
1018
|
if (options.finderRecovery === false) return [];
|
|
684
1019
|
|
|
1020
|
+
// If the clean threshold already found exactly two strong locators, try the
|
|
1021
|
+
// bounded perspective-tolerant third-finder pass before any color processing.
|
|
1022
|
+
// This is substantially cheaper than Auto Color and targets the dense-code
|
|
1023
|
+
// projective failure mode directly.
|
|
1024
|
+
if (fast.finders.length === 2) {
|
|
1025
|
+
const recoveredFinders = recoverFinderSetFromTwo(valueInfo.binary, width, height, fast.finders, {});
|
|
1026
|
+
if (recoveredFinders.length > 2) {
|
|
1027
|
+
const finderMethod = "rgb-value-otsu-two-finder-recovery";
|
|
1028
|
+
const recoveredGeometries = geometryCandidatesFromBinary(
|
|
1029
|
+
valueInfo.binary,
|
|
1030
|
+
width,
|
|
1031
|
+
height,
|
|
1032
|
+
recoveredFinders,
|
|
1033
|
+
valueInfo.threshold,
|
|
1034
|
+
{
|
|
1035
|
+
...options,
|
|
1036
|
+
finderMethod,
|
|
1037
|
+
alignmentThreshold: 0.68,
|
|
1038
|
+
alignmentGridThreshold: 0.64
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1041
|
+
pushFinderDiagnostic(
|
|
1042
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
|
|
1043
|
+
finderMethod,
|
|
1044
|
+
valueInfo.threshold,
|
|
1045
|
+
recoveredFinders,
|
|
1046
|
+
recoveredGeometries
|
|
1047
|
+
);
|
|
1048
|
+
if (recoveredGeometries.length) return recoveredGeometries.slice(0, maxCandidates);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
685
1052
|
// Camera recovery #1: Photoshop Auto Color-style per-channel levels before
|
|
686
1053
|
// finder thresholding. Live camera frames are usually much larger than the
|
|
687
1054
|
// QR itself, so a single global histogram can be dominated by dark room/UI
|
|
@@ -1088,6 +1455,49 @@ export function samplePerspectiveMatrix(imageData, homography, size, options = {
|
|
|
1088
1455
|
return { rgbGrid };
|
|
1089
1456
|
}
|
|
1090
1457
|
|
|
1458
|
+
/**
|
|
1459
|
+
* Sample the two protected regions of every Triangle16 module.
|
|
1460
|
+
*
|
|
1461
|
+
* Triangle16 uses a fixed "/" diagonal. Samples are intentionally placed well
|
|
1462
|
+
* inside the upper-left and lower-right triangles, away from the diagonal and
|
|
1463
|
+
* module borders. This makes the decoder much less sensitive to antialiasing,
|
|
1464
|
+
* blur, resampling and small homography errors than center sampling.
|
|
1465
|
+
*/
|
|
1466
|
+
export function samplePerspectiveTriangleMatrix(imageData, homography, size, options = {}) {
|
|
1467
|
+
const triangleGrid = Array.from({ length: size }, () => new Array(size));
|
|
1468
|
+
const mode = options.sampleMode ?? "cross";
|
|
1469
|
+
const radius = options.highDensitySampleRadius ?? options.triangleSampleRadius ??
|
|
1470
|
+
(mode === "median" ? 0.075 : 0.085);
|
|
1471
|
+
const inset = Math.max(0.18, Math.min(0.36, Number(options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28)));
|
|
1472
|
+
const firstOffset = inset;
|
|
1473
|
+
const secondOffset = 1 - inset;
|
|
1474
|
+
const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
|
|
1475
|
+
const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
|
|
1476
|
+
const sampler = mode === "median" ? robustProjectedSample : averageProjectedSample;
|
|
1477
|
+
|
|
1478
|
+
for (let r = 0; r < size; r++) {
|
|
1479
|
+
for (let c = 0; c < size; c++) {
|
|
1480
|
+
triangleGrid[r][c] = {
|
|
1481
|
+
first: sampler(
|
|
1482
|
+
imageData,
|
|
1483
|
+
homography,
|
|
1484
|
+
c + firstOffset + offsetX,
|
|
1485
|
+
r + firstOffset + offsetY,
|
|
1486
|
+
radius
|
|
1487
|
+
),
|
|
1488
|
+
second: sampler(
|
|
1489
|
+
imageData,
|
|
1490
|
+
homography,
|
|
1491
|
+
c + secondOffset + offsetX,
|
|
1492
|
+
r + secondOffset + offsetY,
|
|
1493
|
+
radius
|
|
1494
|
+
)
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
return { triangleGrid };
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1091
1501
|
function meanRgb(values) {
|
|
1092
1502
|
if (!values.length) throw new Error("No calibration samples available.");
|
|
1093
1503
|
return values.reduce(
|
|
@@ -1284,6 +1694,34 @@ export function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.1
|
|
|
1284
1694
|
return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
1285
1695
|
}
|
|
1286
1696
|
|
|
1697
|
+
export function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.085, insetRatio = 0.28) {
|
|
1698
|
+
const moduleW = bounds.width / size;
|
|
1699
|
+
const moduleH = bounds.height / size;
|
|
1700
|
+
const inset = Math.max(0.18, Math.min(0.36, insetRatio));
|
|
1701
|
+
const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
|
|
1702
|
+
const triangleGrid = Array.from({ length: size }, () => new Array(size));
|
|
1703
|
+
|
|
1704
|
+
for (let r = 0; r < size; r++) {
|
|
1705
|
+
for (let c = 0; c < size; c++) {
|
|
1706
|
+
triangleGrid[r][c] = {
|
|
1707
|
+
first: sampleAverageAxis(
|
|
1708
|
+
imageData,
|
|
1709
|
+
bounds.x + (c + inset) * moduleW,
|
|
1710
|
+
bounds.y + (r + inset) * moduleH,
|
|
1711
|
+
radius
|
|
1712
|
+
),
|
|
1713
|
+
second: sampleAverageAxis(
|
|
1714
|
+
imageData,
|
|
1715
|
+
bounds.x + (c + 1 - inset) * moduleW,
|
|
1716
|
+
bounds.y + (r + 1 - inset) * moduleH,
|
|
1717
|
+
radius
|
|
1718
|
+
)
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return { triangleGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1287
1725
|
export const visionInternals = Object.freeze({
|
|
1288
1726
|
buildGray,
|
|
1289
1727
|
otsuThreshold,
|