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.
@@ -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 searchAlignment(binary, width, height, triple, version) {
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 offsets = [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
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 = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
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 alignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
558
- if (alignmentGrid.score < (options.alignmentGridThreshold ?? 0.68)) continue;
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 finders = detectFinderCandidates(pass.binary, width, height, pass.detector ?? {});
649
- const geometries = geometryCandidatesFromBinary(
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,