quadqr-js 1.0.1 → 1.0.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.
@@ -954,7 +954,7 @@ function luminance(rgb) {
954
954
  return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
955
955
  }
956
956
 
957
- function buildGray(imageData) {
957
+ function buildGray(imageData, mode = "luminance") {
958
958
  const gray = new Uint8Array(imageData.width * imageData.height);
959
959
  for (let i = 0; i < gray.length; i++) {
960
960
  const p = i * 4;
@@ -962,7 +962,15 @@ function buildGray(imageData) {
962
962
  const r = imageData.data[p] * a + 255 * (1 - a);
963
963
  const g = imageData.data[p + 1] * a + 255 * (1 - a);
964
964
  const b = imageData.data[p + 2] * a + 255 * (1 - a);
965
- gray[i] = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
965
+
966
+ // Finder detection is special for QuadQR. Saturated red/green/blue data
967
+ // cells can look very dark in normal luminance, especially blue under a
968
+ // warm phone camera. The HSV "value" channel keeps any cell with one
969
+ // strong RGB component bright while structural black stays dark in all
970
+ // channels. That makes the black/white finder rings far easier to isolate.
971
+ gray[i] = Math.round(mode === "value"
972
+ ? Math.max(r, g, b)
973
+ : 0.2126 * r + 0.7152 * g + 0.0722 * b);
966
974
  }
967
975
  return gray;
968
976
  }
@@ -998,12 +1006,28 @@ function otsuThreshold(gray) {
998
1006
  return threshold;
999
1007
  }
1000
1008
 
1001
- function buildBinary(imageData) {
1002
- const gray = buildGray(imageData);
1003
- const threshold = otsuThreshold(gray);
1009
+ function binaryAtThreshold(gray, threshold) {
1004
1010
  const binary = new Uint8Array(gray.length);
1005
1011
  for (let i = 0; i < gray.length; i++) binary[i] = gray[i] <= threshold ? 1 : 0;
1006
- return { gray, binary, threshold };
1012
+ return binary;
1013
+ }
1014
+
1015
+ function buildBinary(imageData, options = {}) {
1016
+ const grayMode = options.grayMode ?? "luminance";
1017
+ const gray = buildGray(imageData, grayMode);
1018
+ const baseThreshold = otsuThreshold(gray);
1019
+ const threshold = clamp(
1020
+ Math.round(baseThreshold + (options.thresholdOffset ?? 0)),
1021
+ 8,
1022
+ 247
1023
+ );
1024
+ return {
1025
+ gray,
1026
+ binary: binaryAtThreshold(gray, threshold),
1027
+ threshold,
1028
+ baseThreshold,
1029
+ grayMode
1030
+ };
1007
1031
  }
1008
1032
 
1009
1033
  function runsForRow(binary, width, row) {
@@ -1038,14 +1062,14 @@ function runsForColumn(binary, width, height, col) {
1038
1062
  return runs;
1039
1063
  }
1040
1064
 
1041
- function finderRatioScore(lengths) {
1065
+ function finderRatioScore(lengths, toleranceScale = 1) {
1042
1066
  const total = lengths.reduce((sum, value) => sum + value, 0);
1043
1067
  if (total < 7) return Infinity;
1044
1068
  const module = total / 7;
1045
1069
  const expected = [module, module, 3 * module, module, module];
1046
1070
  let score = 0;
1047
1071
  for (let i = 0; i < 5; i++) {
1048
- const tolerance = i === 2 ? module * 1.25 : module * 0.8;
1072
+ const tolerance = (i === 2 ? module * 1.25 : module * 0.8) * toleranceScale;
1049
1073
  const diff = Math.abs(lengths[i] - expected[i]);
1050
1074
  if (diff > tolerance) return Infinity;
1051
1075
  score += diff / Math.max(1, expected[i]);
@@ -1053,14 +1077,14 @@ function finderRatioScore(lengths) {
1053
1077
  return score;
1054
1078
  }
1055
1079
 
1056
- function findWindowContainingCoordinate(runs, coordinate) {
1080
+ function findWindowContainingCoordinate(runs, coordinate, toleranceScale = 1) {
1057
1081
  for (let i = 2; i < runs.length - 2; i++) {
1058
1082
  const centerRun = runs[i];
1059
1083
  if (centerRun.color !== 1) continue;
1060
1084
  if (coordinate < centerRun.start || coordinate >= centerRun.start + centerRun.length) continue;
1061
1085
  const window = runs.slice(i - 2, i + 3);
1062
1086
  if (window.map((run) => run.color).join("") !== "10101") continue;
1063
- const score = finderRatioScore(window.map((run) => run.length));
1087
+ const score = finderRatioScore(window.map((run) => run.length), toleranceScale);
1064
1088
  if (!Number.isFinite(score)) continue;
1065
1089
  const first = window[0].start;
1066
1090
  const total = window.reduce((sum, run) => sum + run.length, 0);
@@ -1073,17 +1097,17 @@ function findWindowContainingCoordinate(runs, coordinate) {
1073
1097
  return null;
1074
1098
  }
1075
1099
 
1076
- function crossCheckVertical(binary, width, height, x, y) {
1100
+ function crossCheckVertical(binary, width, height, x, y, toleranceScale = 1) {
1077
1101
  const col = clamp(Math.round(x), 0, width - 1);
1078
- return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y);
1102
+ return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y, toleranceScale);
1079
1103
  }
1080
1104
 
1081
- function crossCheckHorizontal(binary, width, height, x, y) {
1105
+ function crossCheckHorizontal(binary, width, height, x, y, toleranceScale = 1) {
1082
1106
  const row = clamp(Math.round(y), 0, height - 1);
1083
- return findWindowContainingCoordinate(runsForRow(binary, width, row), x);
1107
+ return findWindowContainingCoordinate(runsForRow(binary, width, row), x, toleranceScale);
1084
1108
  }
1085
1109
 
1086
- function clusterFinderCandidates(raw) {
1110
+ function clusterFinderCandidates(raw, minConfirmations = 2) {
1087
1111
  const clusters = [];
1088
1112
  raw.sort((a, b) => a.moduleSize - b.moduleSize);
1089
1113
 
@@ -1116,7 +1140,7 @@ function clusterFinderCandidates(raw) {
1116
1140
  }
1117
1141
 
1118
1142
  return clusters
1119
- .filter((candidate) => candidate.confirmations >= 2)
1143
+ .filter((candidate) => candidate.confirmations >= minConfirmations)
1120
1144
  .sort((a, b) =>
1121
1145
  (b.confirmations - a.confirmations) ||
1122
1146
  (a.score - b.score) ||
@@ -1124,9 +1148,12 @@ function clusterFinderCandidates(raw) {
1124
1148
  );
1125
1149
  }
1126
1150
 
1127
- function detectFinderCandidates(binary, width, height) {
1151
+ function detectFinderCandidates(binary, width, height, options = {}) {
1128
1152
  const raw = [];
1129
1153
  const rowStep = height > 1200 ? 2 : 1;
1154
+ const toleranceScale = options.toleranceScale ?? 1;
1155
+ const moduleSpreadLimit = options.moduleSpreadLimit ?? 0.45;
1156
+ const minConfirmations = options.minConfirmations ?? 2;
1130
1157
 
1131
1158
  for (let y = 0; y < height; y += rowStep) {
1132
1159
  const runs = runsForRow(binary, width, y);
@@ -1134,13 +1161,13 @@ function detectFinderCandidates(binary, width, height) {
1134
1161
  const window = runs.slice(i, i + 5);
1135
1162
  if (window.map((run) => run.color).join("") !== "10101") continue;
1136
1163
  const lengths = window.map((run) => run.length);
1137
- const ratioScore = finderRatioScore(lengths);
1164
+ const ratioScore = finderRatioScore(lengths, toleranceScale);
1138
1165
  if (!Number.isFinite(ratioScore)) continue;
1139
1166
  const total = lengths.reduce((sum, value) => sum + value, 0);
1140
1167
  const centerX = window[0].start + total / 2;
1141
- const vertical = crossCheckVertical(binary, width, height, centerX, y);
1168
+ const vertical = crossCheckVertical(binary, width, height, centerX, y, toleranceScale);
1142
1169
  if (!vertical) continue;
1143
- const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center);
1170
+ const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center, toleranceScale);
1144
1171
  if (!horizontal) continue;
1145
1172
 
1146
1173
  const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
@@ -1149,7 +1176,7 @@ function detectFinderCandidates(binary, width, height) {
1149
1176
  Math.abs(moduleSize - vertical.moduleSize),
1150
1177
  Math.abs(moduleSize - horizontal.moduleSize)
1151
1178
  ) / moduleSize;
1152
- if (moduleSpread > 0.45) continue;
1179
+ if (moduleSpread > moduleSpreadLimit) continue;
1153
1180
 
1154
1181
  raw.push({
1155
1182
  x: horizontal.center,
@@ -1160,7 +1187,7 @@ function detectFinderCandidates(binary, width, height) {
1160
1187
  }
1161
1188
  }
1162
1189
 
1163
- return clusterFinderCandidates(raw);
1190
+ return clusterFinderCandidates(raw, minConfirmations);
1164
1191
  }
1165
1192
 
1166
1193
  function dot(a, b) {
@@ -1384,15 +1411,11 @@ function projectPoint(h, u, v) {
1384
1411
  };
1385
1412
  }
1386
1413
 
1387
- function detectCodeGeometry(imageData, options = {}) {
1388
- assert(imageData?.data && imageData.width && imageData.height, "Valid image data is required.");
1414
+ function geometryCandidatesFromBinary(binary, width, height, finders, threshold, options = {}) {
1415
+ if (finders.length < 3) return [];
1389
1416
  const minVersion = options.minVersion ?? 1;
1390
1417
  const maxVersion = options.maxVersion ?? 40;
1391
1418
  const maxCandidates = options.maxCandidates ?? 8;
1392
- const { binary, threshold } = buildBinary(imageData);
1393
- const finders = detectFinderCandidates(binary, imageData.width, imageData.height);
1394
- if (finders.length < 3) return [];
1395
-
1396
1419
  const triples = chooseFinderTriples(finders, 20);
1397
1420
  const geometries = [];
1398
1421
 
@@ -1405,8 +1428,8 @@ function detectCodeGeometry(imageData, options = {}) {
1405
1428
  for (const item of versions) {
1406
1429
  const version = item.version;
1407
1430
  const size = sizeForVersion(version);
1408
- const alignment = searchAlignment(binary, imageData.width, imageData.height, triple, version);
1409
- if (alignment.score < 0.72) continue;
1431
+ const alignment = searchAlignment(binary, width, height, triple, version);
1432
+ if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
1410
1433
 
1411
1434
  const alignmentTarget = alignment.target;
1412
1435
  const dest = [
@@ -1429,8 +1452,8 @@ function detectCodeGeometry(imageData, options = {}) {
1429
1452
  continue;
1430
1453
  }
1431
1454
 
1432
- const alignmentGrid = scoreAlignmentGrid(binary, imageData.width, imageData.height, homography, version);
1433
- if (alignmentGrid.score < 0.68) continue;
1455
+ const alignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
1456
+ if (alignmentGrid.score < (options.alignmentGridThreshold ?? 0.68)) continue;
1434
1457
 
1435
1458
  const versionPenalty = item.error / 4;
1436
1459
  const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
@@ -1452,13 +1475,13 @@ function detectCodeGeometry(imageData, options = {}) {
1452
1475
  patternScores: alignmentGrid.patternScores
1453
1476
  },
1454
1477
  threshold,
1478
+ finderMethod: options.finderMethod,
1455
1479
  estimatedSize,
1456
1480
  score
1457
1481
  });
1458
1482
  }
1459
1483
  }
1460
1484
 
1461
- // Deduplicate roughly identical geometry/version hypotheses.
1462
1485
  const deduped = [];
1463
1486
  geometries.sort((a, b) => b.score - a.score);
1464
1487
  for (const geometry of geometries) {
@@ -1475,33 +1498,486 @@ function detectCodeGeometry(imageData, options = {}) {
1475
1498
  return deduped;
1476
1499
  }
1477
1500
 
1501
+ function pushFinderDiagnostic(options, finderMethod, threshold, finders, geometries) {
1502
+ if (!options.diagnostics || typeof options.diagnostics !== "object") return;
1503
+ const passes = Array.isArray(options.diagnostics.passes)
1504
+ ? options.diagnostics.passes
1505
+ : (options.diagnostics.passes = []);
1506
+ passes.push({
1507
+ label: options.diagnosticLabel ?? "perspective",
1508
+ finderMethod,
1509
+ width: options.width,
1510
+ height: options.height,
1511
+ threshold,
1512
+ finderCount: finders.length,
1513
+ finders: finders.map((finder) => ({
1514
+ x: finder.x,
1515
+ y: finder.y,
1516
+ moduleSize: finder.moduleSize,
1517
+ confirmations: finder.confirmations,
1518
+ score: finder.score
1519
+ })),
1520
+ geometries: geometries.map((geometry) => ({
1521
+ version: geometry.version,
1522
+ size: geometry.size,
1523
+ score: geometry.score,
1524
+ estimatedSize: geometry.estimatedSize,
1525
+ alignmentScore: geometry.alignment.score,
1526
+ alignmentGridScore: geometry.alignment.gridScore,
1527
+ finderMethod: geometry.finderMethod,
1528
+ finders: {
1529
+ topLeft: { ...geometry.finders.topLeft },
1530
+ topRight: { ...geometry.finders.topRight },
1531
+ bottomLeft: { ...geometry.finders.bottomLeft }
1532
+ },
1533
+ alignmentCenter: { ...geometry.alignment.center },
1534
+ sourcePoints: geometry.sourcePoints.map((point) => ({ ...point }))
1535
+ }))
1536
+ });
1537
+ }
1538
+
1539
+ function detectCodeGeometry(imageData, options = {}) {
1540
+ assert(imageData?.data && imageData.width && imageData.height, "Valid image data is required.");
1541
+ const width = imageData.width;
1542
+ const height = imageData.height;
1543
+ const maxCandidates = options.maxCandidates ?? 8;
1544
+
1545
+ const evaluatePass = (pass, recovery = false) => {
1546
+ const finders = detectFinderCandidates(pass.binary, width, height, pass.detector ?? {});
1547
+ const geometries = geometryCandidatesFromBinary(
1548
+ pass.binary,
1549
+ width,
1550
+ height,
1551
+ finders,
1552
+ pass.threshold,
1553
+ {
1554
+ ...options,
1555
+ finderMethod: pass.finderMethod,
1556
+ alignmentThreshold: recovery ? 0.68 : 0.72,
1557
+ alignmentGridThreshold: recovery ? 0.64 : 0.68
1558
+ }
1559
+ );
1560
+
1561
+ pushFinderDiagnostic(
1562
+ { ...options, width, height },
1563
+ pass.finderMethod,
1564
+ pass.threshold,
1565
+ finders,
1566
+ geometries
1567
+ );
1568
+ return { finders, geometries };
1569
+ };
1570
+
1571
+ // Fast path stays exactly one grayscale + finder pass. No Auto Color, extra
1572
+ // thresholding, or luminance image is computed when a normal frame works.
1573
+ const valueInfo = buildBinary(imageData, { grayMode: "value" });
1574
+ const fast = evaluatePass({
1575
+ finderMethod: "rgb-value-otsu",
1576
+ binary: valueInfo.binary,
1577
+ threshold: valueInfo.threshold,
1578
+ detector: {}
1579
+ }, false);
1580
+ if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
1581
+ if (options.finderRecovery === false) return [];
1582
+
1583
+ // Camera recovery #1: Photoshop Auto Color-style per-channel levels before
1584
+ // finder thresholding. Live camera frames are usually much larger than the
1585
+ // QR itself, so a single global histogram can be dominated by dark room/UI
1586
+ // pixels around the guide. Photoshop looked strong in the user's cropped
1587
+ // sample because its statistics were effectively code-centric. We emulate
1588
+ // that by trying a few center-weighted analysis windows, while still applying
1589
+ // each correction to the full frame so finder coordinates never move.
1590
+ const requestedInsets = Array.isArray(options.finderAutoColorAnalysisInsets)
1591
+ ? options.finderAutoColorAnalysisInsets
1592
+ : (Number.isFinite(options.finderAutoColorAnalysisInset)
1593
+ ? [options.finderAutoColorAnalysisInset]
1594
+ : [0.10, 0.20, 0.04]);
1595
+ const autoColorInsets = [];
1596
+ for (const value of requestedInsets) {
1597
+ const inset = clamp(Number(value), 0, 0.30);
1598
+ if (!autoColorInsets.some((item) => Math.abs(item - inset) < 0.001)) autoColorInsets.push(inset);
1599
+ }
1600
+
1601
+ for (const analysisInset of autoColorInsets) {
1602
+ try {
1603
+ const autoColorGray = buildAutoColorValueGray(imageData, {
1604
+ blackClip: options.finderAutoColorBlackClip ?? 0.0001,
1605
+ whiteClip: options.finderAutoColorWhiteClip,
1606
+ highlightPercentile: options.finderAutoColorHighlightPercentile ?? 0.95,
1607
+ outputHighlight: options.finderAutoColorOutputHighlight ?? 190,
1608
+ analysisInset,
1609
+ minimumInputRange: options.finderAutoColorMinimumInputRange ?? 72,
1610
+ targetSamples: options.finderAutoColorTargetSamples
1611
+ });
1612
+ const base = otsuThreshold(autoColorGray);
1613
+ const insetLabel = String(Math.round(analysisInset * 100)).padStart(2, "0");
1614
+ const thresholds = [
1615
+ { suffix: "otsu", value: base },
1616
+ { suffix: "high", value: clamp(base + 12, 8, 247) }
1617
+ ];
1618
+ for (const thresholdInfo of thresholds) {
1619
+ const autoColor = evaluatePass({
1620
+ finderMethod: `auto-color-center${insetLabel}-${thresholdInfo.suffix}`,
1621
+ binary: binaryAtThreshold(autoColorGray, thresholdInfo.value),
1622
+ threshold: thresholdInfo.value,
1623
+ detector: { toleranceScale: 1.22, moduleSpreadLimit: 0.60 }
1624
+ }, true);
1625
+ if (autoColor.geometries.length) return autoColor.geometries.slice(0, maxCandidates);
1626
+ }
1627
+ } catch {
1628
+ // Try the next center weighting, then continue with raw threshold bracketing.
1629
+ }
1630
+ }
1631
+
1632
+ // Camera recovery #2: bracket the raw value-channel threshold, then retain
1633
+ // the legacy luminance pass for unusual captures. These are only built after
1634
+ // both the normal and Auto Color finder passes fail.
1635
+ const highThreshold = clamp(valueInfo.baseThreshold + 18, 8, 247);
1636
+ const lowThreshold = clamp(valueInfo.baseThreshold - 14, 8, 247);
1637
+ const recoveryPasses = [];
1638
+ if (highThreshold !== valueInfo.threshold) {
1639
+ recoveryPasses.push({
1640
+ finderMethod: "rgb-value-high-threshold",
1641
+ binary: binaryAtThreshold(valueInfo.gray, highThreshold),
1642
+ threshold: highThreshold,
1643
+ detector: { toleranceScale: 1.18, moduleSpreadLimit: 0.56 }
1644
+ });
1645
+ }
1646
+ if (lowThreshold !== valueInfo.threshold) {
1647
+ recoveryPasses.push({
1648
+ finderMethod: "rgb-value-low-threshold",
1649
+ binary: binaryAtThreshold(valueInfo.gray, lowThreshold),
1650
+ threshold: lowThreshold,
1651
+ detector: { toleranceScale: 1.14, moduleSpreadLimit: 0.54 }
1652
+ });
1653
+ }
1654
+ const lumaInfo = buildBinary(imageData, { grayMode: "luminance" });
1655
+ recoveryPasses.push({
1656
+ finderMethod: "luminance-otsu",
1657
+ binary: lumaInfo.binary,
1658
+ threshold: lumaInfo.threshold,
1659
+ detector: { toleranceScale: 1.10, moduleSpreadLimit: 0.52 }
1660
+ });
1661
+
1662
+ for (const pass of recoveryPasses) {
1663
+ const recovery = evaluatePass(pass, true);
1664
+ if (recovery.geometries.length) return recovery.geometries.slice(0, maxCandidates);
1665
+ }
1666
+
1667
+ return [];
1668
+ }
1669
+
1670
+ function median(values) {
1671
+ if (!values.length) return 0;
1672
+ const sorted = values.slice().sort((a, b) => a - b);
1673
+ const middle = Math.floor(sorted.length / 2);
1674
+ return sorted.length % 2
1675
+ ? sorted[middle]
1676
+ : (sorted[middle - 1] + sorted[middle]) / 2;
1677
+ }
1678
+
1679
+ function robustRgb(values) {
1680
+ if (!values.length) throw new Error("No RGB samples available.");
1681
+ return {
1682
+ r: median(values.map((rgb) => rgb.r)),
1683
+ g: median(values.map((rgb) => rgb.g)),
1684
+ b: median(values.map((rgb) => rgb.b))
1685
+ };
1686
+ }
1687
+
1478
1688
  function averageProjectedSample(imageData, homography, moduleX, moduleY, radius = 0.16) {
1479
1689
  const offsets = radius > 0
1480
1690
  ? [[0, 0], [-radius, 0], [radius, 0], [0, -radius], [0, radius]]
1481
1691
  : [[0, 0]];
1482
- let r = 0;
1483
- let g = 0;
1484
- let b = 0;
1485
- let count = 0;
1692
+ const values = [];
1486
1693
  for (const [dx, dy] of offsets) {
1487
1694
  const point = projectPoint(homography, moduleX + dx, moduleY + dy);
1488
1695
  if (point.x < 0 || point.y < 0 || point.x >= imageData.width || point.y >= imageData.height) continue;
1489
- const rgb = bilinearRgb(imageData, point.x, point.y);
1490
- r += rgb.r;
1491
- g += rgb.g;
1492
- b += rgb.b;
1493
- count++;
1696
+ values.push(bilinearRgb(imageData, point.x, point.y));
1697
+ }
1698
+ if (!values.length) return { r: 255, g: 255, b: 255 };
1699
+ return averageRgb(values);
1700
+ }
1701
+
1702
+ function robustProjectedSample(imageData, homography, moduleX, moduleY, radius = 0.12) {
1703
+ // Fallback for dirty/soft-focus camera frames. A compact 3x3 patch stays
1704
+ // away from module edges and median aggregation rejects glare/speckles.
1705
+ const offsets = radius > 0 ? [-radius, 0, radius] : [0];
1706
+ const values = [];
1707
+ for (const dy of offsets) {
1708
+ for (const dx of offsets) {
1709
+ const point = projectPoint(homography, moduleX + dx, moduleY + dy);
1710
+ if (point.x < 0 || point.y < 0 || point.x >= imageData.width || point.y >= imageData.height) continue;
1711
+ values.push(bilinearRgb(imageData, point.x, point.y));
1712
+ }
1713
+ }
1714
+ return values.length ? robustRgb(values) : { r: 255, g: 255, b: 255 };
1715
+ }
1716
+
1717
+ function histogramPercentile(histogram, total, fraction) {
1718
+ if (!total) return 0;
1719
+ const target = Math.max(1, Math.ceil(total * clamp(fraction, 0, 1)));
1720
+ let seen = 0;
1721
+ for (let i = 0; i < histogram.length; i++) {
1722
+ seen += histogram[i];
1723
+ if (seen >= target) return i;
1724
+ }
1725
+ return histogram.length - 1;
1726
+ }
1727
+
1728
+ function buildAutoColorLevels(imageData, options = {}) {
1729
+ const analysisInset = clamp(options.analysisInset ?? 0, 0, 0.30);
1730
+ const x0 = Math.floor(imageData.width * analysisInset);
1731
+ const y0 = Math.floor(imageData.height * analysisInset);
1732
+ const x1 = Math.max(x0 + 1, Math.ceil(imageData.width * (1 - analysisInset)));
1733
+ const y1 = Math.max(y0 + 1, Math.ceil(imageData.height * (1 - analysisInset)));
1734
+ const analysisWidth = Math.max(1, x1 - x0);
1735
+ const analysisHeight = Math.max(1, y1 - y0);
1736
+ const pixelCount = analysisWidth * analysisHeight;
1737
+ const targetSamples = Math.max(4000, Math.round(options.targetSamples ?? 90000));
1738
+ const step = Math.max(1, Math.floor(pixelCount / targetSamples));
1739
+ const histograms = [new Uint32Array(256), new Uint32Array(256), new Uint32Array(256)];
1740
+ let samples = 0;
1741
+
1742
+ // Camera recovery deliberately supports analysing only the central part of
1743
+ // the visible frame. A phone preview often contains very dark UI/screen
1744
+ // edges outside the code; letting those pixels define the black point makes
1745
+ // an otherwise useful Auto Color pass far too weak.
1746
+ for (let index = 0; index < pixelCount; index += step) {
1747
+ const x = x0 + (index % analysisWidth);
1748
+ const y = y0 + Math.floor(index / analysisWidth);
1749
+ const p = (y * imageData.width + x) * 4;
1750
+ const a = imageData.data[p + 3] / 255;
1751
+ const rgb = [
1752
+ imageData.data[p] * a + 255 * (1 - a),
1753
+ imageData.data[p + 1] * a + 255 * (1 - a),
1754
+ imageData.data[p + 2] * a + 255 * (1 - a)
1755
+ ];
1756
+ for (let channel = 0; channel < 3; channel++) {
1757
+ histograms[channel][clamp(Math.round(rgb[channel]), 0, 255)]++;
1758
+ }
1759
+ samples++;
1760
+ }
1761
+
1762
+ const blackClip = clamp(options.blackClip ?? 0.004, 0, 0.06);
1763
+ const whiteClip = clamp(options.whiteClip ?? 0.004, 0, 0.06);
1764
+ const lows = histograms.map((histogram) => histogramPercentile(histogram, samples, blackClip));
1765
+
1766
+ let references;
1767
+ let highs;
1768
+ const outputHighlight = Number(options.outputHighlight);
1769
+ if (Number.isFinite(outputHighlight)) {
1770
+ // Strong Photoshop-like camera mode. Photoshop Auto Color on the supplied
1771
+ // warm camera sample does not stretch the brightest observed paper/white
1772
+ // cells all the way to 255. Instead it anchors the per-channel shadow
1773
+ // points close to black while keeping the observed highlight around a
1774
+ // neutral mid-high value. That produces much darker structural black and
1775
+ // much stronger RGB separation without washing the whole code out.
1776
+ const highlightPercentile = clamp(options.highlightPercentile ?? 0.95, 0.70, 0.9999);
1777
+ const target = clamp(outputHighlight, 96, 250);
1778
+ references = histograms.map((histogram) => histogramPercentile(histogram, samples, highlightPercentile));
1779
+ const minimumInputRange = Math.max(32, Math.round(options.minimumInputRange ?? 72));
1780
+ highs = references.map((reference, channel) => {
1781
+ const observedRange = Math.max(minimumInputRange, reference - lows[channel]);
1782
+ return lows[channel] + observedRange * 255 / target;
1783
+ });
1784
+ } else {
1785
+ references = histograms.map((histogram) => histogramPercentile(histogram, samples, 1 - whiteClip));
1786
+ highs = references.slice();
1787
+ for (let channel = 0; channel < 3; channel++) {
1788
+ highs[channel] = Math.max(lows[channel] + 24, highs[channel]);
1789
+ }
1790
+ }
1791
+
1792
+ const mapChannel = (value, channel) => clamp(
1793
+ (value - lows[channel]) * 255 / Math.max(1, highs[channel] - lows[channel]),
1794
+ 0,
1795
+ 255
1796
+ );
1797
+
1798
+ return { lows, highs, references, mapChannel };
1799
+ }
1800
+
1801
+ function autoColorImageData(imageData, options = {}) {
1802
+ const levels = buildAutoColorLevels(imageData, options);
1803
+ const pixelCount = imageData.width * imageData.height;
1804
+ const data = new Uint8ClampedArray(imageData.data.length);
1805
+ for (let index = 0; index < pixelCount; index++) {
1806
+ const p = index * 4;
1807
+ const a = imageData.data[p + 3] / 255;
1808
+ const r = imageData.data[p] * a + 255 * (1 - a);
1809
+ const g = imageData.data[p + 1] * a + 255 * (1 - a);
1810
+ const b = imageData.data[p + 2] * a + 255 * (1 - a);
1811
+ data[p] = levels.mapChannel(r, 0);
1812
+ data[p + 1] = levels.mapChannel(g, 1);
1813
+ data[p + 2] = levels.mapChannel(b, 2);
1814
+ data[p + 3] = 255;
1815
+ }
1816
+ return { width: imageData.width, height: imageData.height, data };
1817
+ }
1818
+
1819
+ function buildAutoColorValueGray(imageData, options = {}) {
1820
+ const levels = buildAutoColorLevels(imageData, options);
1821
+ const gray = new Uint8Array(imageData.width * imageData.height);
1822
+ for (let i = 0; i < gray.length; i++) {
1823
+ const p = i * 4;
1824
+ const a = imageData.data[p + 3] / 255;
1825
+ const r = imageData.data[p] * a + 255 * (1 - a);
1826
+ const g = imageData.data[p + 1] * a + 255 * (1 - a);
1827
+ const b = imageData.data[p + 2] * a + 255 * (1 - a);
1828
+ gray[i] = Math.round(Math.max(
1829
+ levels.mapChannel(r, 0),
1830
+ levels.mapChannel(g, 1),
1831
+ levels.mapChannel(b, 2)
1832
+ ));
1833
+ }
1834
+ return gray;
1835
+ }
1836
+
1837
+ function buildAutoToneContrastColorTransform(samples, options = {}) {
1838
+ if (!samples?.length) return (rgb) => rgb;
1839
+ const blackClip = clamp(options.blackClip ?? 0.006, 0, 0.08);
1840
+ const whiteClip = clamp(options.whiteClip ?? 0.004, 0, 0.08);
1841
+ const highlightFraction = clamp(options.highlightFraction ?? 0.14, 0.04, 0.35);
1842
+ const saturation = clamp(options.saturation ?? 1.12, 1, 1.5);
1843
+
1844
+ // Auto Color-style neutralization: use the brightest portion of the frame as
1845
+ // a likely white reference. This is especially effective on warm/yellow
1846
+ // phone-camera frames where the blue channel is suppressed.
1847
+ const luminanceHistogram = new Uint32Array(256);
1848
+ for (const rgb of samples) {
1849
+ const y = clamp(Math.round(luminance(rgb)), 0, 255);
1850
+ luminanceHistogram[y]++;
1851
+ }
1852
+ const highlightThreshold = histogramPercentile(
1853
+ luminanceHistogram,
1854
+ samples.length,
1855
+ 1 - highlightFraction
1856
+ );
1857
+ let highlightR = 0;
1858
+ let highlightG = 0;
1859
+ let highlightB = 0;
1860
+ let highlightCount = 0;
1861
+ for (const rgb of samples) {
1862
+ if (luminance(rgb) < highlightThreshold) continue;
1863
+ highlightR += rgb.r;
1864
+ highlightG += rgb.g;
1865
+ highlightB += rgb.b;
1866
+ highlightCount++;
1867
+ }
1868
+ const highlightMean = highlightCount
1869
+ ? { r: highlightR / highlightCount, g: highlightG / highlightCount, b: highlightB / highlightCount }
1870
+ : { r: 255, g: 255, b: 255 };
1871
+ const neutralTarget = (highlightMean.r + highlightMean.g + highlightMean.b) / 3;
1872
+ const gains = {
1873
+ r: clamp(neutralTarget / Math.max(24, highlightMean.r), 0.72, 1.42),
1874
+ g: clamp(neutralTarget / Math.max(24, highlightMean.g), 0.72, 1.42),
1875
+ b: clamp(neutralTarget / Math.max(24, highlightMean.b), 0.72, 1.42)
1876
+ };
1877
+
1878
+ // Auto Tone-style per-channel levels after the neutralization above.
1879
+ const histograms = [new Uint32Array(256), new Uint32Array(256), new Uint32Array(256)];
1880
+ for (const rgb of samples) {
1881
+ const corrected = [rgb.r * gains.r, rgb.g * gains.g, rgb.b * gains.b];
1882
+ for (let channel = 0; channel < 3; channel++) {
1883
+ histograms[channel][clamp(Math.round(corrected[channel]), 0, 255)]++;
1884
+ }
1494
1885
  }
1495
- if (!count) return { r: 255, g: 255, b: 255 };
1496
- return { r: r / count, g: g / count, b: b / count };
1886
+ const lows = histograms.map((histogram) => histogramPercentile(histogram, samples.length, blackClip));
1887
+ const highs = histograms.map((histogram) => histogramPercentile(histogram, samples.length, 1 - whiteClip));
1888
+ for (let channel = 0; channel < 3; channel++) {
1889
+ highs[channel] = Math.max(lows[channel] + 20, highs[channel]);
1890
+ }
1891
+
1892
+ const tone = (value, channel) => clamp(
1893
+ (value * [gains.r, gains.g, gains.b][channel] - lows[channel]) * 255 / (highs[channel] - lows[channel]),
1894
+ 0,
1895
+ 255
1896
+ );
1897
+
1898
+ // Auto Contrast-style common tonal expansion is calculated after the
1899
+ // per-channel levels so it does not undo white-balance correction.
1900
+ const toneLuminanceHistogram = new Uint32Array(256);
1901
+ for (const rgb of samples) {
1902
+ const corrected = { r: tone(rgb.r, 0), g: tone(rgb.g, 1), b: tone(rgb.b, 2) };
1903
+ toneLuminanceHistogram[clamp(Math.round(luminance(corrected)), 0, 255)]++;
1904
+ }
1905
+ const contrastLow = histogramPercentile(toneLuminanceHistogram, samples.length, blackClip);
1906
+ const contrastHigh = Math.max(
1907
+ contrastLow + 28,
1908
+ histogramPercentile(toneLuminanceHistogram, samples.length, 1 - whiteClip)
1909
+ );
1910
+ const contrast = (value) => clamp((value - contrastLow) * 255 / (contrastHigh - contrastLow), 0, 255);
1911
+
1912
+ return (rgb) => {
1913
+ let r = contrast(tone(rgb.r, 0));
1914
+ let g = contrast(tone(rgb.g, 1));
1915
+ let b = contrast(tone(rgb.b, 2));
1916
+
1917
+ // A small saturation recovery restores separation lost to lens haze and
1918
+ // bilinear camera scaling. It is intentionally conservative because this
1919
+ // path runs only after the normal scanner has failed.
1920
+ const y = luminance({ r, g, b });
1921
+ r = clamp(y + (r - y) * saturation, 0, 255);
1922
+ g = clamp(y + (g - y) * saturation, 0, 255);
1923
+ b = clamp(y + (b - y) * saturation, 0, 255);
1924
+ return { r, g, b };
1925
+ };
1926
+ }
1927
+
1928
+ function autoToneContrastColorRgbGrid(rgbGrid, options = {}) {
1929
+ const samples = [];
1930
+ for (const row of rgbGrid) for (const rgb of row) samples.push(rgb);
1931
+ const transform = buildAutoToneContrastColorTransform(samples, options);
1932
+ return rgbGrid.map((row) => row.map((rgb) => transform(rgb)));
1933
+ }
1934
+
1935
+ function autoToneContrastColorImageData(imageData, options = {}) {
1936
+ const pixelCount = imageData.width * imageData.height;
1937
+ const targetSamples = Math.max(8000, Math.round(options.targetSamples ?? 160000));
1938
+ const step = Math.max(1, Math.floor(pixelCount / targetSamples));
1939
+ const samples = [];
1940
+ for (let index = 0; index < pixelCount; index += step) {
1941
+ const p = index * 4;
1942
+ const a = imageData.data[p + 3] / 255;
1943
+ samples.push({
1944
+ r: imageData.data[p] * a + 255 * (1 - a),
1945
+ g: imageData.data[p + 1] * a + 255 * (1 - a),
1946
+ b: imageData.data[p + 2] * a + 255 * (1 - a)
1947
+ });
1948
+ }
1949
+
1950
+ const transform = buildAutoToneContrastColorTransform(samples, options);
1951
+ const data = new Uint8ClampedArray(imageData.data.length);
1952
+ for (let index = 0; index < pixelCount; index++) {
1953
+ const p = index * 4;
1954
+ const a = imageData.data[p + 3] / 255;
1955
+ const corrected = transform({
1956
+ r: imageData.data[p] * a + 255 * (1 - a),
1957
+ g: imageData.data[p + 1] * a + 255 * (1 - a),
1958
+ b: imageData.data[p + 2] * a + 255 * (1 - a)
1959
+ });
1960
+ data[p] = corrected.r;
1961
+ data[p + 1] = corrected.g;
1962
+ data[p + 2] = corrected.b;
1963
+ data[p + 3] = 255;
1964
+ }
1965
+ return { width: imageData.width, height: imageData.height, data };
1497
1966
  }
1498
1967
 
1499
1968
  function samplePerspectiveMatrix(imageData, homography, size, options = {}) {
1500
1969
  const rgbGrid = Array.from({ length: size }, () => new Array(size));
1501
- const radius = options.sampleRadius ?? 0.16;
1970
+ const mode = options.sampleMode ?? "cross";
1971
+ const radius = options.sampleRadius ?? (mode === "median" ? 0.12 : 0.16);
1972
+ const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
1973
+ const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
1502
1974
  for (let r = 0; r < size; r++) {
1503
1975
  for (let c = 0; c < size; c++) {
1504
- rgbGrid[r][c] = averageProjectedSample(imageData, homography, c + 0.5, r + 0.5, radius);
1976
+ const moduleX = c + 0.5 + offsetX;
1977
+ const moduleY = r + 0.5 + offsetY;
1978
+ rgbGrid[r][c] = mode === "median"
1979
+ ? robustProjectedSample(imageData, homography, moduleX, moduleY, radius)
1980
+ : averageProjectedSample(imageData, homography, moduleX, moduleY, radius);
1505
1981
  }
1506
1982
  }
1507
1983
  return { rgbGrid };
@@ -1532,13 +2008,14 @@ function pickCalibrationSamples(rgbGrid, positions, limit = 24) {
1532
2008
  return out;
1533
2009
  }
1534
2010
 
1535
- function sampleObservedPalette(rgbGrid, calibration) {
2011
+ function sampleObservedPalette(rgbGrid, calibration, options = {}) {
2012
+ const aggregate = options.robust ? robustRgb : averageRgb;
1536
2013
  const palette = {
1537
- black: averageRgb(pickCalibrationSamples(rgbGrid, calibration.black, 36)),
1538
- white: averageRgb(pickCalibrationSamples(rgbGrid, calibration.white, 36)),
1539
- red: averageRgb(pickCalibrationSamples(rgbGrid, calibration.red, 12)),
1540
- green: averageRgb(pickCalibrationSamples(rgbGrid, calibration.green, 12)),
1541
- blue: averageRgb(pickCalibrationSamples(rgbGrid, calibration.blue, 12))
2014
+ black: aggregate(pickCalibrationSamples(rgbGrid, calibration.black, 36)),
2015
+ white: aggregate(pickCalibrationSamples(rgbGrid, calibration.white, 36)),
2016
+ red: aggregate(pickCalibrationSamples(rgbGrid, calibration.red, 12)),
2017
+ green: aggregate(pickCalibrationSamples(rgbGrid, calibration.green, 12)),
2018
+ blue: aggregate(pickCalibrationSamples(rgbGrid, calibration.blue, 12))
1542
2019
  };
1543
2020
 
1544
2021
  // All five observed classes must remain separated enough for reliable RGBW
@@ -1558,6 +2035,71 @@ function sampleObservedPalette(rgbGrid, calibration) {
1558
2035
  return palette;
1559
2036
  }
1560
2037
 
2038
+ function calibrationReferencePoints(rgbGrid, positions, limit = 64) {
2039
+ if (!positions?.length) return [];
2040
+ const step = Math.max(1, Math.floor(positions.length / limit));
2041
+ const refs = [];
2042
+ for (let i = 0; i < positions.length; i += step) {
2043
+ const [row, col] = positions[i];
2044
+ refs.push({ row, col, rgb: rgbGrid[row][col] });
2045
+ if (refs.length >= limit) break;
2046
+ }
2047
+ return refs;
2048
+ }
2049
+
2050
+ function estimateLocalReference(refs, row, col) {
2051
+ if (!refs.length) return { r: 0, g: 0, b: 0 };
2052
+ let wr = 0;
2053
+ let wg = 0;
2054
+ let wb = 0;
2055
+ let total = 0;
2056
+ for (const ref of refs) {
2057
+ const dr = row - ref.row;
2058
+ const dc = col - ref.col;
2059
+ const distanceSq = dr * dr + dc * dc;
2060
+ if (distanceSq < 1e-9) return ref.rgb;
2061
+ // Smooth inverse-distance weighting. The +4 prevents one slightly blurred
2062
+ // structural cell from dominating the local estimate.
2063
+ const weight = 1 / (distanceSq + 4);
2064
+ wr += ref.rgb.r * weight;
2065
+ wg += ref.rgb.g * weight;
2066
+ wb += ref.rgb.b * weight;
2067
+ total += weight;
2068
+ }
2069
+ return {
2070
+ r: wr / total,
2071
+ g: wg / total,
2072
+ b: wb / total
2073
+ };
2074
+ }
2075
+
2076
+ function spatiallyNormalizeRgbGrid(rgbGrid, calibration, options = {}) {
2077
+ const size = rgbGrid.length;
2078
+ const limit = options.referenceLimit ?? 64;
2079
+ const blackRefs = calibrationReferencePoints(rgbGrid, calibration.black, limit);
2080
+ const whiteRefs = calibrationReferencePoints(rgbGrid, calibration.white, limit);
2081
+ if (!blackRefs.length || !whiteRefs.length) throw new Error("Black/white references are required for spatial color normalization.");
2082
+
2083
+ const normalized = Array.from({ length: size }, () => new Array(size));
2084
+ for (let row = 0; row < size; row++) {
2085
+ for (let col = 0; col < size; col++) {
2086
+ const rgb = rgbGrid[row][col];
2087
+ const black = estimateLocalReference(blackRefs, row, col);
2088
+ const white = estimateLocalReference(whiteRefs, row, col);
2089
+ const normalizeChannel = (value, low, high) => {
2090
+ const range = Math.max(28, high - low);
2091
+ return clamp((value - low) * 255 / range, -96, 384);
2092
+ };
2093
+ normalized[row][col] = {
2094
+ r: normalizeChannel(rgb.r, black.r, white.r),
2095
+ g: normalizeChannel(rgb.g, black.g, white.g),
2096
+ b: normalizeChannel(rgb.b, black.b, white.b)
2097
+ };
2098
+ }
2099
+ }
2100
+ return normalized;
2101
+ }
2102
+
1561
2103
  function rectifyImageData(imageData, homography, size, moduleSize = 8) {
1562
2104
  const outputSize = Math.max(1, Math.round(size * moduleSize));
1563
2105
  const data = new Uint8ClampedArray(outputSize * outputSize * 4);
@@ -1649,13 +2191,29 @@ const visionInternals = Object.freeze({
1649
2191
  luminance
1650
2192
  });
1651
2193
 
1652
- return { computeHomography, projectPoint, detectCodeGeometry, samplePerspectiveMatrix, sampleObservedPalette, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, visionInternals };
2194
+ return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, visionInternals };
1653
2195
  })();
1654
2196
  const __qqr_quadqr_js = (function () {
1655
2197
  const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
1656
2198
  const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
1657
- const { detectCodeGeometry, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
2199
+ const { detectCodeGeometry, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
1658
2200
  const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex } = __qqr_security_js;
2201
+ /**
2202
+ * QuadQR
2203
+ *
2204
+ * Experimental four-state RGBW matrix code written in pure JavaScript.
2205
+ *
2206
+ * Core format:
2207
+ * - Red / Green / Blue / White data cells
2208
+ * - exactly 2 bits per data cell
2209
+ * - GF(256) Reed-Solomon ECC over byte symbols
2210
+ * - interleaved body ECC blocks
2211
+ * - zero-overhead spectral-spatial cell placement
2212
+ * - confidence-aware Reed-Solomon error/erasure recovery
2213
+ * - protected bootstrap/header
2214
+ * - version-dependent distributed alignment patterns for perspective recovery
2215
+ * - camera color calibration and image scanning
2216
+ */
1659
2217
 
1660
2218
  const FORMAT_VERSION = 5;
1661
2219
  const MIN_VERSION = 1;
@@ -1807,6 +2365,11 @@ function readU32be(bytes, offset = 0) {
1807
2365
  let CRC_TABLE = null;
1808
2366
  let CRC32_ACCELERATOR = null;
1809
2367
 
2368
+ /**
2369
+ * Install or clear an optional synchronous CRC-32 accelerator.
2370
+ * QuadQR's prebuilt WASM helper uses this hook after initWasm() succeeds.
2371
+ * The JavaScript implementation remains the default and fallback.
2372
+ */
1810
2373
  function installCrc32Accelerator(accelerator = null) {
1811
2374
  assert(
1812
2375
  accelerator == null || typeof accelerator === "function",
@@ -3185,14 +3748,55 @@ function renderToImageData(codeOrMatrix, options = {}) {
3185
3748
  return { width: pixelSize, height: pixelSize, data };
3186
3749
  }
3187
3750
 
3188
- function classifierFromPaletteRgb(observed) {
3189
- return [
3190
- { cell: CELL.BLACK, rgb: observed.black },
3191
- { cell: CELL.WHITE, rgb: observed.white },
3192
- { cell: CELL.RED, rgb: observed.red },
3193
- { cell: CELL.GREEN, rgb: observed.green },
3194
- { cell: CELL.BLUE, rgb: observed.blue }
3751
+ function clampNumber(value, min, max) {
3752
+ return Math.max(min, Math.min(max, value));
3753
+ }
3754
+
3755
+ function identityColorTransform(rgb) {
3756
+ return rgb;
3757
+ }
3758
+
3759
+ function makeWhiteBalanceTransform(observed) {
3760
+ const black = observed.black;
3761
+ const white = observed.white;
3762
+ const ranges = {
3763
+ r: Math.max(32, white.r - black.r),
3764
+ g: Math.max(32, white.g - black.g),
3765
+ b: Math.max(32, white.b - black.b)
3766
+ };
3767
+
3768
+ return (rgb) => ({
3769
+ // Normalize the camera's observed black/white points per channel. This
3770
+ // boosts a suppressed blue channel under warm/yellow illumination without
3771
+ // changing the QuadQR palette or payload capacity.
3772
+ r: clampNumber((rgb.r - black.r) * 255 / ranges.r, -96, 384),
3773
+ g: clampNumber((rgb.g - black.g) * 255 / ranges.g, -96, 384),
3774
+ b: clampNumber((rgb.b - black.b) * 255 / ranges.b, -96, 384)
3775
+ });
3776
+ }
3777
+
3778
+ function classifierFromPaletteRgb(observed, mode = "raw") {
3779
+ const transform = mode === "balanced" || mode === "hue"
3780
+ ? makeWhiteBalanceTransform(observed)
3781
+ : identityColorTransform;
3782
+ const entries = [
3783
+ { cell: CELL.BLACK, rgb: transform(observed.black) },
3784
+ { cell: CELL.WHITE, rgb: transform(observed.white) },
3785
+ { cell: CELL.RED, rgb: transform(observed.red) },
3786
+ { cell: CELL.GREEN, rgb: transform(observed.green) },
3787
+ { cell: CELL.BLUE, rgb: transform(observed.blue) }
3195
3788
  ];
3789
+ const colored = entries.filter(({ cell }) => cell === CELL.RED || cell === CELL.GREEN || cell === CELL.BLUE);
3790
+ const chroma = (rgb) => Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
3791
+ const minimumColorChroma = Math.min(...colored.map(({ rgb }) => chroma(rgb)));
3792
+ return {
3793
+ entries,
3794
+ transform,
3795
+ mode,
3796
+ dataMode: mode === "hue" ? "hue" : "distance",
3797
+ whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
3798
+ minimumColorChroma
3799
+ };
3196
3800
  }
3197
3801
 
3198
3802
  function colorDistanceSq(a, b) {
@@ -3203,12 +3807,13 @@ function colorDistanceSq(a, b) {
3203
3807
  }
3204
3808
 
3205
3809
  function classifyRgb(rgb, classifier) {
3810
+ const transformed = classifier.transform(rgb);
3206
3811
  let best = null;
3207
3812
  let bestDistanceSq = Infinity;
3208
3813
  let secondDistanceSq = Infinity;
3209
3814
 
3210
- for (const candidate of classifier) {
3211
- const distanceSq = colorDistanceSq(rgb, candidate.rgb);
3815
+ for (const candidate of classifier.entries) {
3816
+ const distanceSq = colorDistanceSq(transformed, candidate.rgb);
3212
3817
  if (distanceSq < bestDistanceSq) {
3213
3818
  secondDistanceSq = bestDistanceSq;
3214
3819
  bestDistanceSq = distanceSq;
@@ -3220,17 +3825,46 @@ function classifyRgb(rgb, classifier) {
3220
3825
 
3221
3826
  const distance = Math.sqrt(bestDistanceSq);
3222
3827
  const secondDistance = Number.isFinite(secondDistanceSq) ? Math.sqrt(secondDistanceSq) : distance + 1;
3223
- // Confidence is the normalized separation between the nearest and second
3224
- // nearest calibrated palette states. 1 means unambiguous, 0 means tied.
3225
3828
  const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
3226
3829
  return { cell: best.cell, distance, confidence };
3227
3830
  }
3228
3831
 
3832
+ function classifyDataHue(rgb, classifier) {
3833
+ const transformed = classifier.transform(rgb);
3834
+ const channels = [
3835
+ { cell: CELL.RED, value: transformed.r },
3836
+ { cell: CELL.GREEN, value: transformed.g },
3837
+ { cell: CELL.BLUE, value: transformed.b }
3838
+ ].sort((a, b) => b.value - a.value);
3839
+ const maxValue = channels[0].value;
3840
+ const secondValue = channels[1].value;
3841
+ const minValue = channels[2].value;
3842
+ const chroma = maxValue - minValue;
3843
+ const whiteThreshold = Math.max(18, Math.min(58, classifier.minimumColorChroma * 0.34));
3844
+
3845
+ if (chroma <= whiteThreshold) {
3846
+ const confidence = clampNumber((whiteThreshold - chroma) / Math.max(whiteThreshold, 1), 0, 1);
3847
+ return { cell: CELL.WHITE, distance: chroma, confidence: 0.35 + confidence * 0.65 };
3848
+ }
3849
+
3850
+ const margin = maxValue - secondValue;
3851
+ const hueConfidence = clampNumber(margin / Math.max(chroma, 1), 0, 1);
3852
+ const saturationConfidence = clampNumber((chroma - whiteThreshold) / Math.max(classifier.minimumColorChroma - whiteThreshold, 1), 0, 1);
3853
+ return {
3854
+ cell: channels[0].cell,
3855
+ distance: Math.max(0, classifier.minimumColorChroma - chroma),
3856
+ confidence: clampNumber(0.18 + hueConfidence * 0.52 + saturationConfidence * 0.30, 0, 1)
3857
+ };
3858
+ }
3859
+
3229
3860
  function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3230
3861
  const size = rgbGrid.length;
3231
3862
  const matrix = make2D(size, CELL.WHITE);
3232
3863
  const confidence = make2D(size, 1);
3233
- const dataClassifier = classifier.filter(({ cell }) => cell !== CELL.BLACK);
3864
+ const dataClassifier = {
3865
+ ...classifier,
3866
+ entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
3867
+ };
3234
3868
  let distanceSum = 0;
3235
3869
  let confidenceSum = 0;
3236
3870
  let minimumConfidence = 1;
@@ -3241,8 +3875,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3241
3875
  // Black is structural only. Restricting data modules to RGBW keeps a
3242
3876
  // shadowed/blurred data sample representable so confidence-aware ECC can
3243
3877
  // promote it to an erasure instead of aborting before RS gets a chance.
3244
- const candidates = layout && !layout.reserved[r][c] ? dataClassifier : classifier;
3245
- const classified = classifyRgb(rgbGrid[r][c], candidates);
3878
+ const isDataCell = layout && !layout.reserved[r][c];
3879
+ const candidates = isDataCell ? dataClassifier : classifier;
3880
+ const classified = isDataCell && classifier.dataMode === "hue"
3881
+ ? classifyDataHue(rgbGrid[r][c], classifier)
3882
+ : classifyRgb(rgbGrid[r][c], candidates);
3246
3883
  matrix[r][c] = classified.cell;
3247
3884
  confidence[r][c] = classified.confidence;
3248
3885
  distanceSum += classified.distance;
@@ -3261,46 +3898,353 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3261
3898
  };
3262
3899
  }
3263
3900
 
3901
+ function structuralAccuracy(matrix, layout) {
3902
+ let matches = 0;
3903
+ let total = 0;
3904
+ for (let r = 0; r < layout.size; r++) {
3905
+ for (let c = 0; c < layout.size; c++) {
3906
+ if (!layout.reserved[r][c]) continue;
3907
+ total++;
3908
+ if (matrix[r][c] === layout.matrix[r][c]) matches++;
3909
+ }
3910
+ }
3911
+ return total ? matches / total : 0;
3912
+ }
3913
+
3914
+ function pushObservation(options, observation) {
3915
+ if (!Array.isArray(options._observationCollector)) return;
3916
+ options._observationCollector.push(observation);
3917
+ }
3918
+
3919
+ function paletteClassifierAttempts(observedPalette) {
3920
+ return [
3921
+ { classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
3922
+ { classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
3923
+ { classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
3924
+ ];
3925
+ }
3926
+
3264
3927
  function tryPerspectiveScan(imageData, options) {
3265
3928
  const geometryCandidates = detectCodeGeometry(imageData, {
3266
3929
  minVersion: options.minVersion ?? MIN_VERSION,
3267
3930
  maxVersion: options.maxVersion ?? MAX_VERSION,
3268
- maxCandidates: options.maxGeometryCandidates ?? 8
3931
+ maxCandidates: options.maxGeometryCandidates ?? 8,
3932
+ finderRecovery: options.finderRecovery,
3933
+ finderAutoColorBlackClip: options.finderAutoColorBlackClip,
3934
+ finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
3935
+ finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
3936
+ finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
3937
+ finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
3938
+ finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
3939
+ finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
3940
+ diagnostics: options._visionDiagnostics,
3941
+ diagnosticLabel: options._diagnosticLabel ?? "normal"
3269
3942
  });
3943
+ if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
3270
3944
  const results = [];
3271
3945
 
3272
3946
  for (const geometry of geometryCandidates) {
3273
- try {
3274
- const layout = createLayout(geometry.version);
3275
- const sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
3276
- sampleRadius: options.sampleRadius ?? 0.16
3277
- });
3278
- const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
3279
- const classifier = classifierFromPaletteRgb(observedPalette);
3280
- const classified = classifySampledRgbGrid(sampled.rgbGrid, classifier, layout);
3281
- const decoded = decodeMatrix(classified.matrix, {
3282
- structureTolerance: options.structureTolerance ?? 0.18,
3283
- cellConfidence: classified.confidence,
3284
- maxErasureConfidence: options.maxErasureConfidence
3947
+ const layout = createLayout(geometry.version);
3948
+ const sampleProfiles = [{
3949
+ sampleMode: options.sampleMode ?? "cross",
3950
+ sampleRadius: options.sampleRadius ?? 0.16,
3951
+ robustCalibration: false
3952
+ }];
3953
+ if (options.adaptiveSampling !== false) {
3954
+ sampleProfiles.push({
3955
+ sampleMode: "median",
3956
+ sampleRadius: options.robustSampleRadius ?? 0.12,
3957
+ robustCalibration: true
3285
3958
  });
3286
- if (decoded.version !== geometry.version) continue;
3959
+ }
3287
3960
 
3288
- results.push({
3289
- ...decoded,
3290
- perspectiveCorrected: true,
3291
- colorCalibrated: true,
3292
- geometry,
3293
- observedPalette,
3294
- averageColorDistance: classified.averageColorDistance,
3295
- averageCellConfidence: classified.averageCellConfidence,
3296
- minimumCellConfidence: classified.minimumCellConfidence,
3297
- lowConfidenceCells: classified.lowConfidenceCells,
3298
- rectified: options.includeRectified
3299
- ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
3300
- : undefined
3301
- });
3302
- } catch {
3303
- // Continue trying geometry hypotheses.
3961
+ let geometryDecoded = false;
3962
+ let bestStructureScore = 0;
3963
+ for (const profile of sampleProfiles) {
3964
+ let sampled;
3965
+ let observedPalette;
3966
+ try {
3967
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
3968
+ observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
3969
+ robust: profile.robustCalibration
3970
+ });
3971
+ } catch {
3972
+ continue;
3973
+ }
3974
+
3975
+ const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
3976
+ const activeObservedPalette = metadata.observedPalette ?? observedPalette;
3977
+ const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
3978
+ const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
3979
+ const structureScore = structuralAccuracy(classified.matrix, layout);
3980
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
3981
+ pushObservation(options, {
3982
+ version: geometry.version,
3983
+ matrix: classified.matrix,
3984
+ confidence: classified.confidence,
3985
+ geometry,
3986
+ observedPalette: activeObservedPalette,
3987
+ samplingMode: activeSamplingMode,
3988
+ colorNormalization: attempt.colorNormalization,
3989
+ structureScore,
3990
+ averageCellConfidence: classified.averageCellConfidence,
3991
+ lowConfidenceCells: classified.lowConfidenceCells
3992
+ });
3993
+
3994
+ try {
3995
+ const decoded = decodeMatrix(classified.matrix, {
3996
+ structureTolerance: options.structureTolerance ?? 0.18,
3997
+ cellConfidence: classified.confidence,
3998
+ maxErasureConfidence: options.maxErasureConfidence
3999
+ });
4000
+ if (decoded.version !== geometry.version) return false;
4001
+
4002
+ results.push({
4003
+ ...decoded,
4004
+ perspectiveCorrected: true,
4005
+ colorCalibrated: true,
4006
+ colorNormalization: attempt.colorNormalization,
4007
+ samplingMode: activeSamplingMode,
4008
+ geometry,
4009
+ observedPalette: activeObservedPalette,
4010
+ autoEnhanced: metadata.autoEnhanced || undefined,
4011
+ recoveryMode: metadata.recoveryMode,
4012
+ averageColorDistance: classified.averageColorDistance,
4013
+ averageCellConfidence: classified.averageCellConfidence,
4014
+ minimumCellConfidence: classified.minimumCellConfidence,
4015
+ lowConfidenceCells: classified.lowConfidenceCells,
4016
+ rectified: options.includeRectified
4017
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
4018
+ : undefined
4019
+ });
4020
+ return true;
4021
+ } catch {
4022
+ return false;
4023
+ }
4024
+ };
4025
+
4026
+ // Fast path: preserve the original observed-RGB classifier first, then
4027
+ // try per-channel white balancing. Most clean frames stop here without
4028
+ // paying for the more expensive spatial normalization fallback.
4029
+ for (const attempt of paletteClassifierAttempts(observedPalette)) {
4030
+ if (tryAttempt(attempt, sampled.rgbGrid)) {
4031
+ geometryDecoded = true;
4032
+ break;
4033
+ }
4034
+ }
4035
+
4036
+ if (!geometryDecoded && options.spatialColorNormalization !== false) {
4037
+ try {
4038
+ const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
4039
+ const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
4040
+ geometryDecoded = tryAttempt({
4041
+ classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
4042
+ colorNormalization: "spatial-white-balanced"
4043
+ }, normalizedGrid);
4044
+ } catch {
4045
+ // Continue to the recovery profile below.
4046
+ }
4047
+ }
4048
+
4049
+ // Very cheap Photoshop-like recovery on the already-sampled module grid.
4050
+ // Normal camera frames never reach this path. The transform approximates
4051
+ // Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
4052
+ if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
4053
+ try {
4054
+ const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
4055
+ blackClip: options.autoEnhanceBlackClip,
4056
+ whiteClip: options.autoEnhanceWhiteClip,
4057
+ saturation: options.autoEnhanceSaturation
4058
+ });
4059
+ const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
4060
+ for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
4061
+ const recoveredAttempt = {
4062
+ ...attempt,
4063
+ colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
4064
+ };
4065
+ if (tryAttempt(recoveredAttempt, enhancedGrid, {
4066
+ observedPalette: enhancedPalette,
4067
+ autoEnhanced: true,
4068
+ recoveryMode: "module-grid-auto-tone-contrast-color"
4069
+ })) {
4070
+ geometryDecoded = true;
4071
+ break;
4072
+ }
4073
+ }
4074
+ } catch {
4075
+ // Continue to the QR-region pixel recovery below.
4076
+ }
4077
+ }
4078
+
4079
+ // Camera-specific Photoshop-style fallback. Enhancing the whole camera
4080
+ // frame is often ineffective because dark surroundings, browser/UI
4081
+ // reflections and unrelated objects skew the histograms. Once finder
4082
+ // geometry is known, rectify only the QuadQR region, run Auto Tone /
4083
+ // Contrast / Color there at pixel level, then sample modules again. This
4084
+ // closely matches what happens when the QR itself is corrected in an
4085
+ // editor before photographing it, while staying off the clean fast path.
4086
+ if (
4087
+ !geometryDecoded &&
4088
+ profile === sampleProfiles[sampleProfiles.length - 1] &&
4089
+ options.autoEnhanceRecovery !== false &&
4090
+ options.rectifiedAutoEnhanceRecovery !== false
4091
+ ) {
4092
+ try {
4093
+ const recoveryModuleSize = Math.max(4, Math.min(10, Math.round(options.rectifiedRecoveryModuleSize ?? 6)));
4094
+ const rectifiedRegion = rectifyImageData(
4095
+ imageData,
4096
+ geometry.homography,
4097
+ layout.size,
4098
+ recoveryModuleSize
4099
+ );
4100
+ const enhancedRegion = autoToneContrastColorImageData(rectifiedRegion, {
4101
+ blackClip: options.rectifiedAutoEnhanceBlackClip ?? options.autoEnhanceBlackClip ?? 0.008,
4102
+ whiteClip: options.rectifiedAutoEnhanceWhiteClip ?? options.autoEnhanceWhiteClip ?? 0.006,
4103
+ saturation: options.rectifiedAutoEnhanceSaturation ?? options.autoEnhanceSaturation ?? 1.22,
4104
+ highlightFraction: options.rectifiedAutoEnhanceHighlightFraction ?? 0.16,
4105
+ targetSamples: Math.min(120000, rectifiedRegion.width * rectifiedRegion.height)
4106
+ });
4107
+ const enhancedSampled = sampleAxisAlignedGrid(
4108
+ enhancedRegion,
4109
+ { x: 0, y: 0, width: enhancedRegion.width, height: enhancedRegion.height },
4110
+ layout.size,
4111
+ options.rectifiedRecoveryRadiusRatio ?? 0.16
4112
+ );
4113
+ const enhancedPalette = sampleObservedPalette(
4114
+ enhancedSampled.rgbGrid,
4115
+ layout.calibration,
4116
+ { robust: true }
4117
+ );
4118
+ for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
4119
+ const recoveredAttempt = {
4120
+ ...attempt,
4121
+ colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
4122
+ };
4123
+ if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
4124
+ observedPalette: enhancedPalette,
4125
+ samplingMode: "rectified-auto-enhance",
4126
+ autoEnhanced: true,
4127
+ recoveryMode: "rectified-auto-tone-contrast-color"
4128
+ })) {
4129
+ geometryDecoded = true;
4130
+ break;
4131
+ }
4132
+ }
4133
+ } catch {
4134
+ // Geometry refinement remains available as the final bounded fallback.
4135
+ }
4136
+ }
4137
+
4138
+ if (geometryDecoded) break;
4139
+ }
4140
+
4141
+ // Slow-path geometry micro-refinement. Finder/alignment detection can be
4142
+ // correct while a soft-focus or smeared camera shifts the effective module
4143
+ // centres by a small fraction of a cell. Clean scans never reach this code.
4144
+ // On a strong-but-undecodable symbol, probe a tiny 3x3 offset neighbourhood
4145
+ // using centre-only samples and attempt decoding from the best candidates.
4146
+ if (
4147
+ !geometryDecoded &&
4148
+ options.geometryRefinement !== false &&
4149
+ bestStructureScore >= (options.refinementStructureThreshold ?? 0.90)
4150
+ ) {
4151
+ const step = clampNumber(options.refinementOffset ?? 0.20, 0.05, 0.35);
4152
+ const refinementOffsets = [
4153
+ [step, 0], [-step, 0], [0, -step], [0, step],
4154
+ [step, -step], [step, step], [-step, -step], [-step, step]
4155
+ ];
4156
+ const refinementCandidates = [];
4157
+
4158
+ for (const [offsetX, offsetY] of refinementOffsets) {
4159
+ let sampled;
4160
+ let observedPalette;
4161
+ try {
4162
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
4163
+ sampleMode: "cross",
4164
+ sampleRadius: 0,
4165
+ sampleOffsetX: offsetX,
4166
+ sampleOffsetY: offsetY
4167
+ });
4168
+ observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
4169
+ } catch {
4170
+ continue;
4171
+ }
4172
+
4173
+ // Raw observed-palette classification is deliberately first here too.
4174
+ // It is cheap, and it handles the common case where blur biased the
4175
+ // geometry slightly but the camera colors themselves remain usable.
4176
+ const attempts = paletteClassifierAttempts(observedPalette);
4177
+ for (const attempt of attempts) {
4178
+ const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
4179
+ const structureScore = structuralAccuracy(classified.matrix, layout);
4180
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
4181
+ pushObservation(options, {
4182
+ version: geometry.version,
4183
+ matrix: classified.matrix,
4184
+ confidence: classified.confidence,
4185
+ geometry,
4186
+ observedPalette,
4187
+ samplingMode: "refined-center",
4188
+ samplingOffset: { x: offsetX, y: offsetY },
4189
+ colorNormalization: attempt.colorNormalization,
4190
+ structureScore,
4191
+ averageCellConfidence: classified.averageCellConfidence,
4192
+ lowConfidenceCells: classified.lowConfidenceCells
4193
+ });
4194
+
4195
+ // Do not spend RS work on a refinement that made the known finder /
4196
+ // alignment structure worse. This keeps the recovery path bounded.
4197
+ if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
4198
+ refinementCandidates.push({
4199
+ attempt,
4200
+ classified,
4201
+ observedPalette,
4202
+ offsetX,
4203
+ offsetY,
4204
+ structureScore
4205
+ });
4206
+ }
4207
+ }
4208
+
4209
+ refinementCandidates.sort((a, b) =>
4210
+ (b.structureScore - a.structureScore) ||
4211
+ (b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
4212
+ (a.classified.averageColorDistance - b.classified.averageColorDistance)
4213
+ );
4214
+
4215
+ const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
4216
+ for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
4217
+ try {
4218
+ const decoded = decodeMatrix(candidate.classified.matrix, {
4219
+ structureTolerance: options.structureTolerance ?? 0.18,
4220
+ cellConfidence: candidate.classified.confidence,
4221
+ maxErasureConfidence: options.maxErasureConfidence
4222
+ });
4223
+ if (decoded.version !== geometry.version) continue;
4224
+ results.push({
4225
+ ...decoded,
4226
+ perspectiveCorrected: true,
4227
+ geometryRefined: true,
4228
+ samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
4229
+ colorCalibrated: true,
4230
+ colorNormalization: candidate.attempt.colorNormalization,
4231
+ samplingMode: "refined-center",
4232
+ geometry,
4233
+ observedPalette: candidate.observedPalette,
4234
+ averageColorDistance: candidate.classified.averageColorDistance,
4235
+ averageCellConfidence: candidate.classified.averageCellConfidence,
4236
+ minimumCellConfidence: candidate.classified.minimumCellConfidence,
4237
+ lowConfidenceCells: candidate.classified.lowConfidenceCells,
4238
+ rectified: options.includeRectified
4239
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
4240
+ : undefined
4241
+ });
4242
+ geometryDecoded = true;
4243
+ break;
4244
+ } catch {
4245
+ // Try the next high-scoring micro-refinement.
4246
+ }
4247
+ }
3304
4248
  }
3305
4249
  }
3306
4250
 
@@ -3314,7 +4258,7 @@ function tryPerspectiveScan(imageData, options) {
3314
4258
 
3315
4259
  function tryAxisAlignedScan(imageData, options) {
3316
4260
  const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
3317
- const fixedClassifier = classifierFromPaletteRgb(paletteRgb(options.palette));
4261
+ const fixedPalette = paletteRgb(options.palette);
3318
4262
  const minVersion = options.minVersion ?? MIN_VERSION;
3319
4263
  const maxVersion = options.maxVersion ?? MAX_VERSION;
3320
4264
  const candidates = [];
@@ -3327,49 +4271,95 @@ function tryAxisAlignedScan(imageData, options) {
3327
4271
  const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
3328
4272
  if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
3329
4273
 
3330
- try {
3331
- const sampled = sampleAxisAlignedGrid(imageData, bounds, size, options.sampleRadius ?? 0.18);
3332
- const classifierAttempts = [];
3333
- const layout = createLayout(version);
3334
- try {
3335
- const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
3336
- classifierAttempts.push({ classifier: classifierFromPaletteRgb(observedPalette), calibrated: true });
3337
- } catch {
3338
- // Fixed palette fallback below.
3339
- }
3340
- classifierAttempts.push({ classifier: fixedClassifier, calibrated: false });
4274
+ const radiusProfiles = [options.sampleRadius ?? 0.18];
4275
+ if (options.adaptiveSampling !== false) radiusProfiles.push(options.robustSampleRadius ?? 0.10);
4276
+ let accepted = false;
3341
4277
 
3342
- let accepted = false;
3343
- for (const attempt of classifierAttempts) {
4278
+ for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
4279
+ try {
4280
+ const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
4281
+ const layout = createLayout(version);
4282
+ const classifierAttempts = [];
3344
4283
  try {
3345
- const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
3346
- const decoded = decodeMatrix(classified.matrix, {
3347
- structureTolerance: options.structureTolerance ?? 0.12,
3348
- cellConfidence: classified.confidence,
3349
- maxErasureConfidence: options.maxErasureConfidence
3350
- });
3351
- candidates.push({
3352
- ...decoded,
3353
- bounds,
3354
- sampledVersion: version,
3355
- moduleWidth: moduleW,
3356
- moduleHeight: moduleH,
3357
- perspectiveCorrected: false,
3358
- colorCalibrated: attempt.calibrated,
3359
- averageColorDistance: classified.averageColorDistance,
3360
- averageCellConfidence: classified.averageCellConfidence,
3361
- minimumCellConfidence: classified.minimumCellConfidence,
3362
- lowConfidenceCells: classified.lowConfidenceCells
4284
+ const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
4285
+ robust: profileIndex > 0
3363
4286
  });
3364
- accepted = true;
3365
- break;
4287
+ classifierAttempts.push(...paletteClassifierAttempts(observedPalette).map((attempt) => ({
4288
+ ...attempt,
4289
+ calibrated: true,
4290
+ observedPalette,
4291
+ rgbGrid: sampled.rgbGrid
4292
+ })));
4293
+ if (options.spatialColorNormalization !== false) {
4294
+ try {
4295
+ const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
4296
+ const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
4297
+ classifierAttempts.push({
4298
+ classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
4299
+ calibrated: true,
4300
+ colorNormalization: "spatial-white-balanced",
4301
+ observedPalette,
4302
+ rgbGrid: normalizedGrid
4303
+ });
4304
+ } catch {
4305
+ // Continue with global calibration/fixed palette.
4306
+ }
4307
+ }
3366
4308
  } catch {
3367
- // Try next classifier.
4309
+ // Fixed palette fallback below.
4310
+ }
4311
+ classifierAttempts.push({
4312
+ classifier: classifierFromPaletteRgb(fixedPalette, "raw"),
4313
+ calibrated: false,
4314
+ colorNormalization: "fixed-rgb",
4315
+ observedPalette: fixedPalette,
4316
+ rgbGrid: sampled.rgbGrid
4317
+ });
4318
+
4319
+ for (const attempt of classifierAttempts) {
4320
+ try {
4321
+ const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
4322
+ const structureScore = structuralAccuracy(classified.matrix, layout);
4323
+ pushObservation(options, {
4324
+ version,
4325
+ matrix: classified.matrix,
4326
+ confidence: classified.confidence,
4327
+ bounds,
4328
+ samplingMode: profileIndex === 0 ? "axis" : "axis-center",
4329
+ colorNormalization: attempt.colorNormalization,
4330
+ structureScore,
4331
+ averageCellConfidence: classified.averageCellConfidence,
4332
+ lowConfidenceCells: classified.lowConfidenceCells
4333
+ });
4334
+ const decoded = decodeMatrix(classified.matrix, {
4335
+ structureTolerance: options.structureTolerance ?? 0.12,
4336
+ cellConfidence: classified.confidence,
4337
+ maxErasureConfidence: options.maxErasureConfidence
4338
+ });
4339
+ candidates.push({
4340
+ ...decoded,
4341
+ bounds,
4342
+ sampledVersion: version,
4343
+ moduleWidth: moduleW,
4344
+ moduleHeight: moduleH,
4345
+ perspectiveCorrected: false,
4346
+ colorCalibrated: attempt.calibrated,
4347
+ colorNormalization: attempt.colorNormalization,
4348
+ samplingMode: profileIndex === 0 ? "axis" : "axis-center",
4349
+ averageColorDistance: classified.averageColorDistance,
4350
+ averageCellConfidence: classified.averageCellConfidence,
4351
+ minimumCellConfidence: classified.minimumCellConfidence,
4352
+ lowConfidenceCells: classified.lowConfidenceCells
4353
+ });
4354
+ accepted = true;
4355
+ break;
4356
+ } catch {
4357
+ // Try next classifier/profile.
4358
+ }
3368
4359
  }
4360
+ } catch {
4361
+ // Try a narrower centre sample, then next version.
3369
4362
  }
3370
- if (!accepted) throw new Error("Axis-aligned candidate did not decode.");
3371
- } catch {
3372
- // Try next version.
3373
4363
  }
3374
4364
  }
3375
4365
 
@@ -3386,16 +4376,90 @@ function scanImageData(imageData, options = {}) {
3386
4376
  validateVersion(minVersion);
3387
4377
  validateVersion(maxVersion);
3388
4378
 
4379
+ const geometryCollector = [];
4380
+ const scanOptions = { ...options, _geometryCollector: geometryCollector };
4381
+
3389
4382
  if (options.perspective !== false) {
3390
- const perspective = tryPerspectiveScan(imageData, options);
4383
+ const perspective = tryPerspectiveScan(imageData, scanOptions);
3391
4384
  if (perspective) return perspective;
3392
4385
  }
3393
4386
 
4387
+ // If the normal scanner could see QuadQR geometry but could not decode the
4388
+ // colors, retry the same frame after a single global Auto Tone / Contrast /
4389
+ // Color-style pass. This is deliberately after the normal path so clean
4390
+ // camera scanning remains unchanged and fast.
4391
+ if (
4392
+ options.autoEnhanceRecovery !== false &&
4393
+ options.fullFrameAutoEnhanceRecovery !== false &&
4394
+ !options._autoEnhancedRecovery &&
4395
+ geometryCollector.length > 0
4396
+ ) {
4397
+ try {
4398
+ const enhanced = autoToneContrastColorImageData(imageData, {
4399
+ blackClip: options.autoEnhanceBlackClip,
4400
+ whiteClip: options.autoEnhanceWhiteClip,
4401
+ saturation: options.autoEnhanceSaturation,
4402
+ targetSamples: options.autoEnhanceTargetSamples
4403
+ });
4404
+ const recovered = tryPerspectiveScan(enhanced, {
4405
+ ...options,
4406
+ _autoEnhancedRecovery: true,
4407
+ autoEnhanceRecovery: false,
4408
+ _geometryCollector: []
4409
+ });
4410
+ if (recovered) {
4411
+ return {
4412
+ ...recovered,
4413
+ autoEnhanced: true,
4414
+ recoveryMode: "auto-tone-contrast-color",
4415
+ originalColorNormalization: recovered.colorNormalization
4416
+ };
4417
+ }
4418
+ } catch {
4419
+ // Continue to the axis-aligned fallback.
4420
+ }
4421
+ }
4422
+
3394
4423
  if (options.axisAlignedFallback !== false) {
3395
- const axis = tryAxisAlignedScan(imageData, options);
4424
+ const axis = tryAxisAlignedScan(imageData, scanOptions);
3396
4425
  if (axis) return axis;
3397
4426
  }
3398
4427
 
4428
+ // Static images sometimes have such low contrast that locator detection itself
4429
+ // fails. Permit one enhanced full retry in that case. The live camera disables
4430
+ // this on its first pass so an empty frame never becomes expensive.
4431
+ if (
4432
+ options.autoEnhanceRecovery !== false &&
4433
+ !options._autoEnhancedRecovery &&
4434
+ geometryCollector.length === 0 &&
4435
+ options.autoEnhanceWhenNoGeometry !== false
4436
+ ) {
4437
+ try {
4438
+ const enhanced = autoToneContrastColorImageData(imageData, {
4439
+ blackClip: options.autoEnhanceBlackClip,
4440
+ whiteClip: options.autoEnhanceWhiteClip,
4441
+ saturation: options.autoEnhanceSaturation,
4442
+ targetSamples: options.autoEnhanceTargetSamples
4443
+ });
4444
+ const recoveryOptions = {
4445
+ ...options,
4446
+ _autoEnhancedRecovery: true,
4447
+ autoEnhanceRecovery: false,
4448
+ _geometryCollector: []
4449
+ };
4450
+ if (options.perspective !== false) {
4451
+ const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
4452
+ if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
4453
+ }
4454
+ if (options.axisAlignedFallback !== false) {
4455
+ const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
4456
+ if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
4457
+ }
4458
+ } catch {
4459
+ // Fall through to the normal scan failure below.
4460
+ }
4461
+ }
4462
+
3399
4463
  throw new Error(
3400
4464
  "No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
3401
4465
  );
@@ -3427,19 +4491,274 @@ async function scanFile(file, options = {}) {
3427
4491
  return scanImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), options);
3428
4492
  }
3429
4493
 
4494
+ function parseObjectPositionFraction(value, fallback = 0.5) {
4495
+ if (typeof value !== "string") return fallback;
4496
+ const token = value.trim().toLowerCase();
4497
+ if (token === "left" || token === "top") return 0;
4498
+ if (token === "right" || token === "bottom") return 1;
4499
+ if (token === "center") return 0.5;
4500
+ if (token.endsWith("%")) {
4501
+ const parsed = Number.parseFloat(token);
4502
+ if (Number.isFinite(parsed)) return clampNumber(parsed / 100, 0, 1);
4503
+ }
4504
+ return fallback;
4505
+ }
4506
+
4507
+ function visibleVideoSourceRect(video, options = {}) {
4508
+ const sourceWidth = video.videoWidth;
4509
+ const sourceHeight = video.videoHeight;
4510
+ const full = { x: 0, y: 0, width: sourceWidth, height: sourceHeight, cropped: false };
4511
+ if (options.videoCropMode === "full") return full;
4512
+
4513
+ let boxWidth = Number(video.clientWidth) || 0;
4514
+ let boxHeight = Number(video.clientHeight) || 0;
4515
+ if ((!boxWidth || !boxHeight) && typeof video.getBoundingClientRect === "function") {
4516
+ const rect = video.getBoundingClientRect();
4517
+ boxWidth = boxWidth || rect.width;
4518
+ boxHeight = boxHeight || rect.height;
4519
+ }
4520
+ if (!boxWidth || !boxHeight) return full;
4521
+
4522
+ let objectFit = options.videoObjectFit;
4523
+ let objectPosition = options.videoObjectPosition;
4524
+ if ((!objectFit || !objectPosition) && typeof getComputedStyle === "function") {
4525
+ try {
4526
+ const style = getComputedStyle(video);
4527
+ objectFit = objectFit || style.objectFit;
4528
+ objectPosition = objectPosition || style.objectPosition;
4529
+ } catch {
4530
+ // Fall back to the demo's normal cover/center behavior.
4531
+ }
4532
+ }
4533
+ objectFit = objectFit || "cover";
4534
+ if (objectFit !== "cover") return full;
4535
+
4536
+ const scale = Math.max(boxWidth / sourceWidth, boxHeight / sourceHeight);
4537
+ if (!Number.isFinite(scale) || scale <= 0) return full;
4538
+ let cropWidth = Math.min(sourceWidth, boxWidth / scale);
4539
+ let cropHeight = Math.min(sourceHeight, boxHeight / scale);
4540
+ if (cropWidth >= sourceWidth - 1 && cropHeight >= sourceHeight - 1) return full;
4541
+
4542
+ const positionTokens = String(objectPosition || "50% 50%").trim().split(/\s+/);
4543
+ const positionX = parseObjectPositionFraction(positionTokens[0], 0.5);
4544
+ const positionY = parseObjectPositionFraction(positionTokens[1] ?? positionTokens[0], 0.5);
4545
+ let x = (sourceWidth - cropWidth) * positionX;
4546
+ let y = (sourceHeight - cropHeight) * positionY;
4547
+
4548
+ // Optional tiny inset removes the soft edge of a camera preview without
4549
+ // changing the visible composition. Leave at zero unless explicitly set.
4550
+ const inset = clampNumber(options.videoCropInset ?? 0, 0, 0.18);
4551
+ if (inset > 0) {
4552
+ const dx = cropWidth * inset;
4553
+ const dy = cropHeight * inset;
4554
+ x += dx;
4555
+ y += dy;
4556
+ cropWidth -= dx * 2;
4557
+ cropHeight -= dy * 2;
4558
+ }
4559
+
4560
+ x = clampNumber(x, 0, Math.max(0, sourceWidth - cropWidth));
4561
+ y = clampNumber(y, 0, Math.max(0, sourceHeight - cropHeight));
4562
+ return { x, y, width: cropWidth, height: cropHeight, cropped: true };
4563
+ }
4564
+
4565
+ function cropImageDataInset(imageData, insetFraction = 0) {
4566
+ const inset = clampNumber(Number(insetFraction) || 0, 0, 0.32);
4567
+ if (inset <= 0) {
4568
+ return {
4569
+ imageData,
4570
+ rect: { x: 0, y: 0, width: imageData.width, height: imageData.height }
4571
+ };
4572
+ }
4573
+
4574
+ const x = Math.max(0, Math.floor(imageData.width * inset));
4575
+ const y = Math.max(0, Math.floor(imageData.height * inset));
4576
+ const width = Math.max(1, imageData.width - x * 2);
4577
+ const height = Math.max(1, imageData.height - y * 2);
4578
+ const data = new Uint8ClampedArray(width * height * 4);
4579
+ for (let row = 0; row < height; row++) {
4580
+ const sourceStart = ((y + row) * imageData.width + x) * 4;
4581
+ const sourceEnd = sourceStart + width * 4;
4582
+ data.set(imageData.data.subarray(sourceStart, sourceEnd), row * width * 4);
4583
+ }
4584
+ return {
4585
+ imageData: { width, height, data },
4586
+ rect: { x, y, width, height }
4587
+ };
4588
+ }
4589
+
4590
+ function bestVisionDiagnosticPass(visionDiagnostics) {
4591
+ const passes = visionDiagnostics?.passes;
4592
+ if (!Array.isArray(passes) || !passes.length) return null;
4593
+ return passes.slice().sort((a, b) => {
4594
+ const aGeometry = a.geometries?.[0];
4595
+ const bGeometry = b.geometries?.[0];
4596
+ return (Boolean(bGeometry) - Boolean(aGeometry)) ||
4597
+ ((b.finderCount ?? 0) - (a.finderCount ?? 0)) ||
4598
+ ((bGeometry?.score ?? 0) - (aGeometry?.score ?? 0));
4599
+ })[0];
4600
+ }
4601
+
4602
+ function nowMs() {
4603
+ return typeof performance !== "undefined" && typeof performance.now === "function"
4604
+ ? performance.now()
4605
+ : Date.now();
4606
+ }
4607
+
4608
+ function normalizeFrameDiagnostics(frameDiagnostics, source, width, height, visionDiagnostics) {
4609
+ if (!frameDiagnostics || typeof frameDiagnostics !== "object") return;
4610
+ frameDiagnostics.scanWidth = width;
4611
+ frameDiagnostics.scanHeight = height;
4612
+ frameDiagnostics.frameWidth = width;
4613
+ frameDiagnostics.frameHeight = height;
4614
+ frameDiagnostics.scanRect = { x: 0, y: 0, width, height };
4615
+ frameDiagnostics.sourceRect = {
4616
+ x: source.x,
4617
+ y: source.y,
4618
+ width: source.width,
4619
+ height: source.height,
4620
+ cropped: Boolean(source.cropped)
4621
+ };
4622
+ frameDiagnostics.vision = visionDiagnostics;
4623
+ const bestPass = bestVisionDiagnosticPass(visionDiagnostics);
4624
+ frameDiagnostics.bestPass = bestPass;
4625
+ frameDiagnostics.finderCount = bestPass?.finderCount ?? 0;
4626
+ frameDiagnostics.finders = bestPass?.finders ?? [];
4627
+ frameDiagnostics.finderMethod = bestPass?.finderMethod ?? null;
4628
+ frameDiagnostics.finderPasses = Array.isArray(visionDiagnostics?.passes)
4629
+ ? visionDiagnostics.passes.map((pass) => ({
4630
+ method: pass.finderMethod ?? pass.label,
4631
+ finderCount: pass.finderCount ?? 0,
4632
+ threshold: pass.threshold,
4633
+ geometryCount: pass.geometries?.length ?? 0
4634
+ }))
4635
+ : [];
4636
+ frameDiagnostics.geometry = bestPass?.geometries?.[0] ?? null;
4637
+ }
4638
+
3430
4639
  function scanVideoFrame(video, options = {}) {
3431
4640
  assert(typeof document !== "undefined", "scanVideoFrame is a browser API.");
3432
4641
  assert(video && video.videoWidth && video.videoHeight, "Video frame is not ready.");
4642
+
4643
+ // Scan what the user actually sees. On phones the video element is usually a
4644
+ // portrait box with object-fit: cover while the camera sensor stream is 16:9.
4645
+ // Previously we scanned the entire hidden sensor frame, so the QR looked big
4646
+ // in the guide but became much smaller after canvas downscaling. Cropping to
4647
+ // the visible source region both improves module resolution and reduces work.
4648
+ const source = visibleVideoSourceRect(video, options);
3433
4649
  const maxDimension = options.maxDimension ?? 960;
3434
- const scale = Math.min(1, maxDimension / Math.max(video.videoWidth, video.videoHeight));
3435
- const width = Math.max(1, Math.round(video.videoWidth * scale));
3436
- const height = Math.max(1, Math.round(video.videoHeight * scale));
4650
+ const scale = Math.min(1, maxDimension / Math.max(source.width, source.height));
4651
+ const width = Math.max(1, Math.round(source.width * scale));
4652
+ const height = Math.max(1, Math.round(source.height * scale));
3437
4653
  const canvas = options.canvas ?? document.createElement("canvas");
3438
4654
  canvas.width = width;
3439
4655
  canvas.height = height;
3440
4656
  const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
3441
- ctx.drawImage(video, 0, 0, width, height);
3442
- return scanImageData(ctx.getImageData(0, 0, width, height), options);
4657
+ // High-quality downscaling is preferable for finder geometry, but visible-ROI
4658
+ // cropping means many mobile frames now need little or no downscaling at all.
4659
+ if ("imageSmoothingEnabled" in ctx) ctx.imageSmoothingEnabled = true;
4660
+ if ("imageSmoothingQuality" in ctx) ctx.imageSmoothingQuality = "high";
4661
+ ctx.drawImage(video, source.x, source.y, source.width, source.height, 0, 0, width, height);
4662
+ const frameImageData = ctx.getImageData(0, 0, width, height);
4663
+ if (options._capturedFrame && typeof options._capturedFrame === "object") {
4664
+ options._capturedFrame.imageData = frameImageData;
4665
+ options._capturedFrame.scanWidth = width;
4666
+ options._capturedFrame.scanHeight = height;
4667
+ options._capturedFrame.source = { ...source };
4668
+ }
4669
+ const visionDiagnostics = options._frameDiagnostics ? { passes: [] } : null;
4670
+ try {
4671
+ const result = scanImageData(frameImageData, {
4672
+ ...options,
4673
+ _visionDiagnostics: visionDiagnostics
4674
+ });
4675
+ normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
4676
+ if (!source.cropped) return result;
4677
+ return {
4678
+ ...result,
4679
+ cameraVisibleCrop: true,
4680
+ cameraSourceRect: { x: source.x, y: source.y, width: source.width, height: source.height }
4681
+ };
4682
+ } catch (error) {
4683
+ normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
4684
+ throw error;
4685
+ }
4686
+ }
4687
+
4688
+ function selectBestFrameObservation(observations) {
4689
+ if (!observations?.length) return null;
4690
+ return observations
4691
+ .filter((item) => item?.matrix && item.structureScore >= 0.82)
4692
+ .sort((a, b) =>
4693
+ (b.structureScore - a.structureScore) ||
4694
+ (b.averageCellConfidence - a.averageCellConfidence) ||
4695
+ ((b.geometry?.score ?? 0) - (a.geometry?.score ?? 0))
4696
+ )[0] ?? null;
4697
+ }
4698
+
4699
+ function combineFrameObservations(observations) {
4700
+ if (!observations?.length) return null;
4701
+ const version = observations[0].version;
4702
+ const size = observations[0].matrix.length;
4703
+ if (!observations.every((item) => item.version === version && item.matrix.length === size)) return null;
4704
+
4705
+ const matrix = make2D(size, CELL.WHITE);
4706
+ const confidence = make2D(size, 0);
4707
+ for (let r = 0; r < size; r++) {
4708
+ for (let c = 0; c < size; c++) {
4709
+ const votes = new Map();
4710
+ const confidenceByCell = new Map();
4711
+ for (const observation of observations) {
4712
+ const cell = observation.matrix[r][c];
4713
+ const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
4714
+ const weight = 0.35 + sourceConfidence * 0.65;
4715
+ votes.set(cell, (votes.get(cell) ?? 0) + weight);
4716
+ const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
4717
+ stats.weighted += sourceConfidence * weight;
4718
+ stats.weight += weight;
4719
+ confidenceByCell.set(cell, stats);
4720
+ }
4721
+
4722
+ const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
4723
+ const [bestCell, bestWeight] = ranked[0];
4724
+ const secondWeight = ranked[1]?.[1] ?? 0;
4725
+ const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
4726
+ const sourceStats = confidenceByCell.get(bestCell);
4727
+ const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
4728
+ const support = totalWeight ? bestWeight / totalWeight : 0;
4729
+ const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
4730
+ const agreement = 0.5 * support + 0.5 * margin;
4731
+
4732
+ matrix[r][c] = bestCell;
4733
+ // Do not become overconfident just because several blurry frames agree.
4734
+ // Retaining source uncertainty lets Reed-Solomon treat repeated ambiguous
4735
+ // cells as erasures instead of hard, supposedly-certain errors.
4736
+ confidence[r][c] = clampNumber(sourceConfidence * 0.58 + agreement * 0.42, 0, 1);
4737
+ }
4738
+ }
4739
+ return { version, matrix, confidence };
4740
+ }
4741
+
4742
+ async function improveCameraTrack(stream) {
4743
+ const track = stream.getVideoTracks?.()[0];
4744
+ if (!track?.applyConstraints || !track.getCapabilities) return;
4745
+ try {
4746
+ const capabilities = track.getCapabilities();
4747
+ const advanced = {};
4748
+ if (Array.isArray(capabilities.focusMode) && capabilities.focusMode.includes("continuous")) {
4749
+ advanced.focusMode = "continuous";
4750
+ }
4751
+ if (Array.isArray(capabilities.exposureMode) && capabilities.exposureMode.includes("continuous")) {
4752
+ advanced.exposureMode = "continuous";
4753
+ }
4754
+ if (Array.isArray(capabilities.whiteBalanceMode) && capabilities.whiteBalanceMode.includes("continuous")) {
4755
+ advanced.whiteBalanceMode = "continuous";
4756
+ }
4757
+ if (Object.keys(advanced).length) await track.applyConstraints({ advanced: [advanced] });
4758
+ } catch {
4759
+ // Browsers expose different subsets of camera controls. Scanner-side
4760
+ // calibration remains the primary path when these hints are unsupported.
4761
+ }
3443
4762
  }
3444
4763
 
3445
4764
  async function startCameraScanner(video, options = {}) {
@@ -3451,11 +4770,13 @@ async function startCameraScanner(video, options = {}) {
3451
4770
  audio: false,
3452
4771
  video: {
3453
4772
  facingMode: { ideal: "environment" },
3454
- width: { ideal: 1280 },
3455
- height: { ideal: 720 }
4773
+ width: { ideal: 1920 },
4774
+ height: { ideal: 1080 },
4775
+ frameRate: { ideal: 30 }
3456
4776
  }
3457
4777
  }
3458
4778
  );
4779
+ await improveCameraTrack(stream);
3459
4780
 
3460
4781
  video.srcObject = stream;
3461
4782
  video.setAttribute("playsinline", "");
@@ -3464,32 +4785,435 @@ async function startCameraScanner(video, options = {}) {
3464
4785
 
3465
4786
  const scanInterval = Math.max(80, options.scanInterval ?? 180);
3466
4787
  const scratchCanvas = document.createElement("canvas");
4788
+ const multiFrameEnabled = options.multiFrame !== false;
4789
+ const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
4790
+ const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
4791
+ const observationHistory = new Map();
4792
+ const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
4793
+ const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
4794
+ const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
4795
+ let missStreak = 0;
3467
4796
  let stopped = false;
3468
4797
  let busy = false;
3469
4798
  let timer = null;
4799
+ let frameNumber = 0;
4800
+
4801
+ const diagnosticsEnabled = typeof options.onDiagnostic === "function";
4802
+ const emitDiagnostic = (event) => {
4803
+ if (!diagnosticsEnabled) return;
4804
+ try {
4805
+ options.onDiagnostic({
4806
+ timestamp: Date.now(),
4807
+ frame: frameNumber,
4808
+ ...event
4809
+ });
4810
+ } catch {
4811
+ // Diagnostics are UI-only and must never interrupt scanning.
4812
+ }
4813
+ };
4814
+
4815
+ if (diagnosticsEnabled) {
4816
+ const track = stream.getVideoTracks?.()[0];
4817
+ const settings = track?.getSettings?.() ?? {};
4818
+ emitDiagnostic({
4819
+ type: "camera-ready",
4820
+ method: "camera",
4821
+ message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight}` ,
4822
+ camera: {
4823
+ width: settings.width ?? video.videoWidth,
4824
+ height: settings.height ?? video.videoHeight,
4825
+ frameRate: settings.frameRate ?? null,
4826
+ facingMode: settings.facingMode ?? null
4827
+ }
4828
+ });
4829
+ }
3470
4830
 
3471
4831
  const stop = () => {
3472
4832
  stopped = true;
3473
4833
  if (timer) clearTimeout(timer);
4834
+ observationHistory.clear();
3474
4835
  for (const track of stream.getTracks()) track.stop();
3475
4836
  if (video.srcObject === stream) video.srcObject = null;
3476
4837
  };
3477
4838
 
3478
- const scanNow = () => scanVideoFrame(video, { ...options, canvas: scratchCanvas });
4839
+ const scanNow = () => scanVideoFrame(video, {
4840
+ ...options,
4841
+ maxDimension: options.maxDimension ?? 1080,
4842
+ canvas: scratchCanvas
4843
+ });
4844
+
4845
+ const emitResult = (result, capturedFrame = null, diagnostic = null) => {
4846
+ const frameMeta = capturedFrame?.imageData ? {
4847
+ frame: frameNumber,
4848
+ imageData: capturedFrame.imageData,
4849
+ scanWidth: capturedFrame.scanWidth,
4850
+ scanHeight: capturedFrame.scanHeight,
4851
+ sourceRect: capturedFrame.source ? { ...capturedFrame.source } : null,
4852
+ enhancedImageData: capturedFrame.enhancedImageData ?? null,
4853
+ enhancedRect: capturedFrame.enhancedRect ? { ...capturedFrame.enhancedRect } : null,
4854
+ enhancement: capturedFrame.enhancement ? { ...capturedFrame.enhancement } : null,
4855
+ diagnostic
4856
+ } : null;
4857
+ options.onResult?.(result, frameMeta);
4858
+ options.onDecode?.(result, frameMeta);
4859
+ if (options.stopOnResult ?? true) {
4860
+ stop();
4861
+ return true;
4862
+ }
4863
+ return false;
4864
+ };
4865
+
4866
+ const tryMultiFrameDecode = (observations) => {
4867
+ if (!multiFrameEnabled) return null;
4868
+ const best = selectBestFrameObservation(observations);
4869
+ if (!best) return null;
4870
+ const history = observationHistory.get(best.version) ?? [];
4871
+ history.push(best);
4872
+ while (history.length > multiFrameWindow) history.shift();
4873
+ observationHistory.set(best.version, history);
4874
+ if (history.length < multiFrameMinFrames) return null;
4875
+
4876
+ const combined = combineFrameObservations(history);
4877
+ if (!combined) return null;
4878
+ try {
4879
+ const decoded = decodeMatrix(combined.matrix, {
4880
+ structureTolerance: options.structureTolerance ?? 0.20,
4881
+ cellConfidence: combined.confidence,
4882
+ maxErasureConfidence: options.maxErasureConfidence
4883
+ });
4884
+ if (decoded.version !== best.version) return null;
4885
+ return {
4886
+ ...decoded,
4887
+ perspectiveCorrected: Boolean(best.geometry),
4888
+ colorCalibrated: true,
4889
+ colorNormalization: "multi-frame-vote",
4890
+ samplingMode: "multi-frame-vote",
4891
+ multiFrameCombined: history.length,
4892
+ geometry: best.geometry,
4893
+ observedPalette: best.observedPalette,
4894
+ averageCellConfidence: best.averageCellConfidence,
4895
+ lowConfidenceCells: best.lowConfidenceCells
4896
+ };
4897
+ } catch {
4898
+ return null;
4899
+ }
4900
+ };
3479
4901
 
3480
4902
  const loop = async () => {
3481
4903
  if (stopped) return;
3482
4904
  if (!busy && video.readyState >= 2) {
3483
4905
  busy = true;
4906
+ frameNumber++;
4907
+ const observations = [];
4908
+ const frameDiagnostics = diagnosticsEnabled ? {} : null;
4909
+ const capturedFrame = {};
4910
+ const frameStarted = nowMs();
4911
+ let allowAutoEnhance = false;
4912
+ let allowFinderRecovery = false;
3484
4913
  try {
3485
- const result = scanNow();
3486
- options.onResult?.(result);
3487
- if (options.stopOnResult ?? true) {
3488
- stop();
3489
- return;
3490
- }
4914
+ // The first frame stays minimal. The primary finder pass itself is now
4915
+ // QuadQR-aware (max RGB/value channel), so it remains cheap while no
4916
+ // longer confusing dark blue data with structural black. After a miss,
4917
+ // alternate frames may bracket finder thresholds and then use the
4918
+ // stronger color recovery path. Empty camera frames therefore do not
4919
+ // pay every recovery cost on every scan tick.
4920
+ allowFinderRecovery = options.finderRecovery !== false &&
4921
+ missStreak > 0 && ((missStreak - 1) % cameraFinderRecoveryEvery === 0);
4922
+ allowAutoEnhance = options.autoEnhanceRecovery !== false &&
4923
+ missStreak > 0 && ((missStreak - 1) % cameraAutoEnhanceEvery === 0);
4924
+ const method = allowAutoEnhance
4925
+ ? "progressive-color-recovery"
4926
+ : (allowFinderRecovery ? "finder-recovery" : "fast-scan");
4927
+ const result = scanVideoFrame(video, {
4928
+ ...options,
4929
+ _diagnosticLabel: method,
4930
+ finderRecovery: allowFinderRecovery,
4931
+ autoEnhanceRecovery: allowAutoEnhance,
4932
+ autoEnhanceWhenNoGeometry: allowAutoEnhance,
4933
+ // If geometry exists, the rectified QR-only pixel enhancer is both
4934
+ // stronger and much cheaper than reprocessing the whole live frame.
4935
+ fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
4936
+ maxDimension: options.maxDimension ?? 1080,
4937
+ canvas: scratchCanvas,
4938
+ _capturedFrame: capturedFrame,
4939
+ _observationCollector: observations,
4940
+ _frameDiagnostics: frameDiagnostics
4941
+ });
4942
+ const elapsedMs = nowMs() - frameStarted;
4943
+ emitDiagnostic({
4944
+ type: "frame",
4945
+ state: "decoded",
4946
+ method: result.recoveryMode ?? result.samplingMode ?? method,
4947
+ elapsedMs,
4948
+ missStreak,
4949
+ ...frameDiagnostics
4950
+ });
4951
+ emitDiagnostic({
4952
+ type: "success",
4953
+ state: "decoded",
4954
+ method: result.recoveryMode ?? result.samplingMode ?? method,
4955
+ elapsedMs,
4956
+ message: `Decoded v${result.version} · ECC ${result.eccLevel} · ${Math.round(elapsedMs)} ms`,
4957
+ ...frameDiagnostics
4958
+ });
4959
+ missStreak = 0;
4960
+ observationHistory.clear();
4961
+ if (emitResult(result, capturedFrame, frameDiagnostics)) return;
3491
4962
  } catch (error) {
3492
- options.onScanMiss?.(error);
4963
+ missStreak++;
4964
+ const fastElapsedMs = nowMs() - frameStarted;
4965
+ emitDiagnostic({
4966
+ type: "frame",
4967
+ state: "miss",
4968
+ method: allowAutoEnhance
4969
+ ? "progressive-color-recovery"
4970
+ : (allowFinderRecovery ? "finder-recovery" : "fast-scan"),
4971
+ elapsedMs: fastElapsedMs,
4972
+ missStreak,
4973
+ error: error?.message ?? String(error),
4974
+ ...frameDiagnostics
4975
+ });
4976
+
4977
+ // The user's real phone-camera case is dominated by color cast before
4978
+ // finder detection: Photoshop Auto Color alone makes the same live QR
4979
+ // immediately detectable. Retry the exact captured frame with a cheap
4980
+ // per-channel Auto Color levels correction before any geometry-dependent
4981
+ // recovery. This runs only after the normal fast scan fails, and by
4982
+ // default only on every other missed frame after the first one.
4983
+ const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
4984
+ capturedFrame.imageData &&
4985
+ ((missStreak - 1) % cameraAutoColorEvery === 0);
4986
+ if (shouldTryCameraAutoColor) {
4987
+ const requestedCrops = Array.isArray(options.cameraAutoColorCropInsets)
4988
+ ? options.cameraAutoColorCropInsets
4989
+ : [0.08, 0.16, 0.22, 0];
4990
+ const cropInsets = [];
4991
+ for (const value of requestedCrops) {
4992
+ const inset = clampNumber(Number(value), 0, 0.30);
4993
+ if (!cropInsets.some((item) => Math.abs(item - inset) < 0.001)) cropInsets.push(inset);
4994
+ }
4995
+ const explicitAnalysisInsets = Array.isArray(options.cameraAutoColorAnalysisInsets)
4996
+ ? options.cameraAutoColorAnalysisInsets
4997
+ : null;
4998
+
4999
+ emitDiagnostic({
5000
+ type: "method",
5001
+ state: "trying",
5002
+ method: "camera-auto-color",
5003
+ message: `Fast scan failed · Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
5004
+ ...frameDiagnostics
5005
+ });
5006
+
5007
+ let autoColorDecoded = false;
5008
+ for (let profileIndex = 0; profileIndex < cropInsets.length; profileIndex++) {
5009
+ const cropInset = cropInsets[profileIndex];
5010
+ const cropped = cropImageDataInset(capturedFrame.imageData, cropInset);
5011
+ const defaultAnalysisInsets = [0.10, 0.08, 0.04, 0.10];
5012
+ const analysisInset = clampNumber(
5013
+ Number(explicitAnalysisInsets?.[profileIndex]
5014
+ ?? options.cameraAutoColorAnalysisInset
5015
+ ?? defaultAnalysisInsets[Math.min(profileIndex, defaultAnalysisInsets.length - 1)]),
5016
+ 0,
5017
+ 0.30
5018
+ );
5019
+ const autoColorObservations = [];
5020
+ const autoColorVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
5021
+ const autoColorFrameDiagnostics = diagnosticsEnabled ? {} : null;
5022
+ const cropLabel = cropInset ? `${Math.round(cropInset * 100)}pct-crop` : "full";
5023
+ const profileName = `camera-auto-color-${cropLabel}`;
5024
+ try {
5025
+ const recoveryStarted = nowMs();
5026
+ const correctedFrame = autoColorImageData(cropped.imageData, {
5027
+ // This is intentionally performed on a centered recovery crop.
5028
+ // A live preview can contain large dark borders, browser UI, a
5029
+ // monitor bezel or room background. Those pixels completely
5030
+ // change global Auto Color/Otsu statistics even though the QR
5031
+ // itself looks identical to a saved crop. Photoshop succeeded
5032
+ // because the user's edited image was effectively QR-centric.
5033
+ blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
5034
+ whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
5035
+ highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
5036
+ outputHighlight: options.cameraAutoColorOutputHighlight ?? 190,
5037
+ analysisInset,
5038
+ minimumInputRange: options.cameraAutoColorMinimumInputRange ?? 72,
5039
+ targetSamples: options.cameraAutoColorTargetSamples ?? 90000
5040
+ });
5041
+ const recovered = scanImageData(correctedFrame, {
5042
+ ...options,
5043
+ _diagnosticLabel: profileName,
5044
+ _visionDiagnostics: autoColorVisionDiagnostics,
5045
+ finderRecovery: true,
5046
+ autoEnhanceRecovery: false,
5047
+ fullFrameAutoEnhanceRecovery: false,
5048
+ _observationCollector: autoColorObservations
5049
+ });
5050
+ if (autoColorFrameDiagnostics) {
5051
+ normalizeFrameDiagnostics(
5052
+ autoColorFrameDiagnostics,
5053
+ capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
5054
+ correctedFrame.width,
5055
+ correctedFrame.height,
5056
+ autoColorVisionDiagnostics
5057
+ );
5058
+ autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
5059
+ autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
5060
+ autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
5061
+ autoColorFrameDiagnostics.autoColorCropInset = cropInset;
5062
+ autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
5063
+ }
5064
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
5065
+ emitDiagnostic({
5066
+ type: "frame",
5067
+ state: "decoded",
5068
+ method: profileName,
5069
+ elapsedMs: recoveryElapsedMs,
5070
+ missStreak,
5071
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
5072
+ });
5073
+ emitDiagnostic({
5074
+ type: "success",
5075
+ state: "decoded",
5076
+ method: "camera-auto-color",
5077
+ elapsedMs: recoveryElapsedMs,
5078
+ message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
5079
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
5080
+ });
5081
+ capturedFrame.enhancedImageData = correctedFrame;
5082
+ capturedFrame.enhancedRect = { ...cropped.rect };
5083
+ capturedFrame.enhancement = {
5084
+ method: "camera-auto-color",
5085
+ cropInset,
5086
+ analysisInset
5087
+ };
5088
+ missStreak = 0;
5089
+ observationHistory.clear();
5090
+ autoColorDecoded = true;
5091
+ if (emitResult({
5092
+ ...recovered,
5093
+ autoColorCorrected: true,
5094
+ cameraProgressiveRecovery: true,
5095
+ recoveryMode: "camera-auto-color",
5096
+ cameraAutoColorCropInset: cropInset,
5097
+ cameraAutoColorAnalysisInset: analysisInset
5098
+ }, capturedFrame, autoColorFrameDiagnostics ?? frameDiagnostics)) return;
5099
+ break;
5100
+ } catch (recoveryError) {
5101
+ observations.push(...autoColorObservations);
5102
+ if (autoColorFrameDiagnostics) {
5103
+ normalizeFrameDiagnostics(
5104
+ autoColorFrameDiagnostics,
5105
+ capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
5106
+ cropped.imageData.width,
5107
+ cropped.imageData.height,
5108
+ autoColorVisionDiagnostics
5109
+ );
5110
+ autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
5111
+ autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
5112
+ autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
5113
+ autoColorFrameDiagnostics.autoColorCropInset = cropInset;
5114
+ autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
5115
+ emitDiagnostic({
5116
+ type: "frame",
5117
+ state: "miss",
5118
+ method: profileName,
5119
+ elapsedMs: nowMs() - frameStarted,
5120
+ missStreak,
5121
+ error: recoveryError?.message ?? String(recoveryError),
5122
+ ...autoColorFrameDiagnostics
5123
+ });
5124
+ }
5125
+ emitDiagnostic({
5126
+ type: "method",
5127
+ state: "failed",
5128
+ method: profileName,
5129
+ message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
5130
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
5131
+ });
5132
+ }
5133
+ }
5134
+ if (!autoColorDecoded) {
5135
+ emitDiagnostic({
5136
+ type: "method",
5137
+ state: "failed",
5138
+ method: "camera-auto-color",
5139
+ message: "All camera Auto Color profiles failed · continuing deeper recovery",
5140
+ ...frameDiagnostics
5141
+ });
5142
+ }
5143
+ }
5144
+
5145
+ // If the fast pass already saw a convincing QuadQR structure, retry
5146
+ // the exact same captured ROI immediately with the stronger QR-region
5147
+ // color correction. Empty/non-QR frames do not pay this cost. This is
5148
+ // the live equivalent of: normal scan first, then Auto Tone / Contrast
5149
+ // / Color only when the normal decode actually needs help.
5150
+ if (!allowAutoEnhance && options.autoEnhanceRecovery !== false) {
5151
+ const strongObservation = selectBestFrameObservation(observations);
5152
+ if (strongObservation && scratchCanvas.width && scratchCanvas.height) {
5153
+ emitDiagnostic({
5154
+ type: "method",
5155
+ state: "trying",
5156
+ method: "qr-region-auto-enhance",
5157
+ message: "Fast decode failed · trying QR-region Auto Tone / Contrast / Color",
5158
+ ...frameDiagnostics
5159
+ });
5160
+ try {
5161
+ const recoveryStarted = nowMs();
5162
+ const frameContext = scratchCanvas.getContext("2d", { alpha: false, willReadFrequently: true });
5163
+ const capturedImageData = frameContext.getImageData(0, 0, scratchCanvas.width, scratchCanvas.height);
5164
+ const recoveryObservations = [];
5165
+ const recoveryVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
5166
+ const recovered = scanImageData(capturedImageData, {
5167
+ ...options,
5168
+ _diagnosticLabel: "qr-region-auto-enhance",
5169
+ _visionDiagnostics: recoveryVisionDiagnostics,
5170
+ autoEnhanceRecovery: true,
5171
+ autoEnhanceWhenNoGeometry: false,
5172
+ fullFrameAutoEnhanceRecovery: false,
5173
+ _observationCollector: recoveryObservations
5174
+ });
5175
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
5176
+ emitDiagnostic({
5177
+ type: "success",
5178
+ state: "decoded",
5179
+ method: recovered.recoveryMode ?? recovered.samplingMode ?? "qr-region-auto-enhance",
5180
+ elapsedMs: recoveryElapsedMs,
5181
+ message: `Recovery decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
5182
+ ...frameDiagnostics
5183
+ });
5184
+ missStreak = 0;
5185
+ observationHistory.clear();
5186
+ if (emitResult({
5187
+ ...recovered,
5188
+ cameraProgressiveRecovery: true
5189
+ }, capturedFrame, frameDiagnostics)) return;
5190
+ } catch {
5191
+ emitDiagnostic({
5192
+ type: "method",
5193
+ state: "failed",
5194
+ method: "qr-region-auto-enhance",
5195
+ message: "QR-region color recovery did not decode · trying multi-frame ECC",
5196
+ ...frameDiagnostics
5197
+ });
5198
+ // Keep the original observations for multi-frame voting below.
5199
+ }
5200
+ }
5201
+ }
5202
+
5203
+ const combined = tryMultiFrameDecode(observations);
5204
+ if (combined) {
5205
+ emitDiagnostic({
5206
+ type: "success",
5207
+ state: "decoded",
5208
+ method: "multi-frame-vote",
5209
+ message: `Multi-frame recovery decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
5210
+ ...frameDiagnostics
5211
+ });
5212
+ missStreak = 0;
5213
+ if (emitResult(combined, capturedFrame, frameDiagnostics)) return;
5214
+ } else {
5215
+ options.onScanMiss?.(error);
5216
+ }
3493
5217
  } finally {
3494
5218
  busy = false;
3495
5219
  }
@@ -3547,6 +5271,10 @@ const internals = Object.freeze({
3547
5271
  decodeRsAdaptive,
3548
5272
  encodeProtectedHeader,
3549
5273
  decodeProtectedHeader,
5274
+ selectBestFrameObservation,
5275
+ combineFrameObservations,
5276
+ visibleVideoSourceRect,
5277
+ cropImageDataInset,
3550
5278
  HEADER_CODEWORD_CELLS,
3551
5279
  COMPACT_HEADER_CODEWORD_CELLS,
3552
5280
  CELLS_PER_BYTE,