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