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.
@@ -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 top = candidates.slice(0, Math.min(candidates.length, 14));
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 * 10 || d2 < tl.moduleSize * 10) continue;
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 > 0.55) continue;
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 > 0.5) continue;
371
+ if (moduleSpread > maxModuleSpread) continue;
341
372
  const legRatio = Math.max(d1, d2) / Math.min(d1, d2);
342
- if (legRatio > 2.1) continue;
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 score = area / (1 + cos * 8 + moduleSpread * 5 + Math.max(0, legRatio - 1) * 2) + confirmScore * 100;
346
- triples.push({ tl, tr, bl, moduleMean, score, orthogonality: 1 - cos });
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 searchAlignment(binary, width, height, triple, version) {
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
- let best = { score: 0, center: predicted, scale: 1 };
414
- const offsets = [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
415
- const scales = [0.72, 0.85, 1, 1.15, 1.3];
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
- for (const ou of offsets) {
418
- for (const ov of offsets) {
419
- const center = {
420
- x: predicted.x + basisU.x * ou + basisV.x * ov,
421
- y: predicted.y + basisU.y * ou + basisV.y * ov
422
- };
423
- for (const scale of scales) {
424
- const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
425
- if (score > best.score) best = { score, center, scale };
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 { ...best, basisU, basisV, predicted, target };
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 alignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
558
- if (alignmentGrid.score < (options.alignmentGridThreshold ?? 0.68)) continue;
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 finders = detectFinderCandidates(pass.binary, width, height, pass.detector ?? {});
649
- const geometries = geometryCandidatesFromBinary(
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
- // Camera recovery #1: Photoshop Auto Color-style per-channel levels before
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. Photoshop looked strong in the user's cropped
689
- // sample because its statistics were effectively code-centric. We emulate
690
- // that by trying a few center-weighted analysis windows, while still applying
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 Photoshop-like camera mode. Photoshop Auto Color on the supplied
876
- // warm camera sample does not stretch the brightest observed paper/white
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,