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.
@@ -32,6 +32,10 @@ import {
32
32
  samplePerspectiveMatrix,
33
33
  rectifyImageData,
34
34
  sampleObservedPalette,
35
+ spatiallyNormalizeRgbGrid,
36
+ autoToneContrastColorRgbGrid,
37
+ autoToneContrastColorImageData,
38
+ autoColorImageData,
35
39
  findActiveBounds,
36
40
  sampleAxisAlignedGrid
37
41
  } from "./vision.js";
@@ -1592,14 +1596,55 @@ export function renderToImageData(codeOrMatrix, options = {}) {
1592
1596
  return { width: pixelSize, height: pixelSize, data };
1593
1597
  }
1594
1598
 
1595
- function classifierFromPaletteRgb(observed) {
1596
- return [
1597
- { cell: CELL.BLACK, rgb: observed.black },
1598
- { cell: CELL.WHITE, rgb: observed.white },
1599
- { cell: CELL.RED, rgb: observed.red },
1600
- { cell: CELL.GREEN, rgb: observed.green },
1601
- { cell: CELL.BLUE, rgb: observed.blue }
1599
+ function clampNumber(value, min, max) {
1600
+ return Math.max(min, Math.min(max, value));
1601
+ }
1602
+
1603
+ function identityColorTransform(rgb) {
1604
+ return rgb;
1605
+ }
1606
+
1607
+ function makeWhiteBalanceTransform(observed) {
1608
+ const black = observed.black;
1609
+ const white = observed.white;
1610
+ const ranges = {
1611
+ r: Math.max(32, white.r - black.r),
1612
+ g: Math.max(32, white.g - black.g),
1613
+ b: Math.max(32, white.b - black.b)
1614
+ };
1615
+
1616
+ return (rgb) => ({
1617
+ // Normalize the camera's observed black/white points per channel. This
1618
+ // boosts a suppressed blue channel under warm/yellow illumination without
1619
+ // changing the QuadQR palette or payload capacity.
1620
+ r: clampNumber((rgb.r - black.r) * 255 / ranges.r, -96, 384),
1621
+ g: clampNumber((rgb.g - black.g) * 255 / ranges.g, -96, 384),
1622
+ b: clampNumber((rgb.b - black.b) * 255 / ranges.b, -96, 384)
1623
+ });
1624
+ }
1625
+
1626
+ function classifierFromPaletteRgb(observed, mode = "raw") {
1627
+ const transform = mode === "balanced" || mode === "hue"
1628
+ ? makeWhiteBalanceTransform(observed)
1629
+ : identityColorTransform;
1630
+ const entries = [
1631
+ { cell: CELL.BLACK, rgb: transform(observed.black) },
1632
+ { cell: CELL.WHITE, rgb: transform(observed.white) },
1633
+ { cell: CELL.RED, rgb: transform(observed.red) },
1634
+ { cell: CELL.GREEN, rgb: transform(observed.green) },
1635
+ { cell: CELL.BLUE, rgb: transform(observed.blue) }
1602
1636
  ];
1637
+ const colored = entries.filter(({ cell }) => cell === CELL.RED || cell === CELL.GREEN || cell === CELL.BLUE);
1638
+ const chroma = (rgb) => Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
1639
+ const minimumColorChroma = Math.min(...colored.map(({ rgb }) => chroma(rgb)));
1640
+ return {
1641
+ entries,
1642
+ transform,
1643
+ mode,
1644
+ dataMode: mode === "hue" ? "hue" : "distance",
1645
+ whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
1646
+ minimumColorChroma
1647
+ };
1603
1648
  }
1604
1649
 
1605
1650
  function colorDistanceSq(a, b) {
@@ -1610,12 +1655,13 @@ function colorDistanceSq(a, b) {
1610
1655
  }
1611
1656
 
1612
1657
  function classifyRgb(rgb, classifier) {
1658
+ const transformed = classifier.transform(rgb);
1613
1659
  let best = null;
1614
1660
  let bestDistanceSq = Infinity;
1615
1661
  let secondDistanceSq = Infinity;
1616
1662
 
1617
- for (const candidate of classifier) {
1618
- const distanceSq = colorDistanceSq(rgb, candidate.rgb);
1663
+ for (const candidate of classifier.entries) {
1664
+ const distanceSq = colorDistanceSq(transformed, candidate.rgb);
1619
1665
  if (distanceSq < bestDistanceSq) {
1620
1666
  secondDistanceSq = bestDistanceSq;
1621
1667
  bestDistanceSq = distanceSq;
@@ -1627,17 +1673,47 @@ function classifyRgb(rgb, classifier) {
1627
1673
 
1628
1674
  const distance = Math.sqrt(bestDistanceSq);
1629
1675
  const secondDistance = Number.isFinite(secondDistanceSq) ? Math.sqrt(secondDistanceSq) : distance + 1;
1630
- // Confidence is the normalized separation between the nearest and second
1631
- // nearest calibrated palette states. 1 means unambiguous, 0 means tied.
1632
1676
  const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
1633
1677
  return { cell: best.cell, distance, confidence };
1634
1678
  }
1635
1679
 
1680
+
1681
+ function classifyDataHue(rgb, classifier) {
1682
+ const transformed = classifier.transform(rgb);
1683
+ const channels = [
1684
+ { cell: CELL.RED, value: transformed.r },
1685
+ { cell: CELL.GREEN, value: transformed.g },
1686
+ { cell: CELL.BLUE, value: transformed.b }
1687
+ ].sort((a, b) => b.value - a.value);
1688
+ const maxValue = channels[0].value;
1689
+ const secondValue = channels[1].value;
1690
+ const minValue = channels[2].value;
1691
+ const chroma = maxValue - minValue;
1692
+ const whiteThreshold = Math.max(18, Math.min(58, classifier.minimumColorChroma * 0.34));
1693
+
1694
+ if (chroma <= whiteThreshold) {
1695
+ const confidence = clampNumber((whiteThreshold - chroma) / Math.max(whiteThreshold, 1), 0, 1);
1696
+ return { cell: CELL.WHITE, distance: chroma, confidence: 0.35 + confidence * 0.65 };
1697
+ }
1698
+
1699
+ const margin = maxValue - secondValue;
1700
+ const hueConfidence = clampNumber(margin / Math.max(chroma, 1), 0, 1);
1701
+ const saturationConfidence = clampNumber((chroma - whiteThreshold) / Math.max(classifier.minimumColorChroma - whiteThreshold, 1), 0, 1);
1702
+ return {
1703
+ cell: channels[0].cell,
1704
+ distance: Math.max(0, classifier.minimumColorChroma - chroma),
1705
+ confidence: clampNumber(0.18 + hueConfidence * 0.52 + saturationConfidence * 0.30, 0, 1)
1706
+ };
1707
+ }
1708
+
1636
1709
  function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
1637
1710
  const size = rgbGrid.length;
1638
1711
  const matrix = make2D(size, CELL.WHITE);
1639
1712
  const confidence = make2D(size, 1);
1640
- const dataClassifier = classifier.filter(({ cell }) => cell !== CELL.BLACK);
1713
+ const dataClassifier = {
1714
+ ...classifier,
1715
+ entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
1716
+ };
1641
1717
  let distanceSum = 0;
1642
1718
  let confidenceSum = 0;
1643
1719
  let minimumConfidence = 1;
@@ -1648,8 +1724,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
1648
1724
  // Black is structural only. Restricting data modules to RGBW keeps a
1649
1725
  // shadowed/blurred data sample representable so confidence-aware ECC can
1650
1726
  // promote it to an erasure instead of aborting before RS gets a chance.
1651
- const candidates = layout && !layout.reserved[r][c] ? dataClassifier : classifier;
1652
- const classified = classifyRgb(rgbGrid[r][c], candidates);
1727
+ const isDataCell = layout && !layout.reserved[r][c];
1728
+ const candidates = isDataCell ? dataClassifier : classifier;
1729
+ const classified = isDataCell && classifier.dataMode === "hue"
1730
+ ? classifyDataHue(rgbGrid[r][c], classifier)
1731
+ : classifyRgb(rgbGrid[r][c], candidates);
1653
1732
  matrix[r][c] = classified.cell;
1654
1733
  confidence[r][c] = classified.confidence;
1655
1734
  distanceSum += classified.distance;
@@ -1668,46 +1747,353 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
1668
1747
  };
1669
1748
  }
1670
1749
 
1750
+ function structuralAccuracy(matrix, layout) {
1751
+ let matches = 0;
1752
+ let total = 0;
1753
+ for (let r = 0; r < layout.size; r++) {
1754
+ for (let c = 0; c < layout.size; c++) {
1755
+ if (!layout.reserved[r][c]) continue;
1756
+ total++;
1757
+ if (matrix[r][c] === layout.matrix[r][c]) matches++;
1758
+ }
1759
+ }
1760
+ return total ? matches / total : 0;
1761
+ }
1762
+
1763
+ function pushObservation(options, observation) {
1764
+ if (!Array.isArray(options._observationCollector)) return;
1765
+ options._observationCollector.push(observation);
1766
+ }
1767
+
1768
+ function paletteClassifierAttempts(observedPalette) {
1769
+ return [
1770
+ { classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
1771
+ { classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
1772
+ { classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
1773
+ ];
1774
+ }
1775
+
1671
1776
  function tryPerspectiveScan(imageData, options) {
1672
1777
  const geometryCandidates = detectCodeGeometry(imageData, {
1673
1778
  minVersion: options.minVersion ?? MIN_VERSION,
1674
1779
  maxVersion: options.maxVersion ?? MAX_VERSION,
1675
- maxCandidates: options.maxGeometryCandidates ?? 8
1780
+ maxCandidates: options.maxGeometryCandidates ?? 8,
1781
+ finderRecovery: options.finderRecovery,
1782
+ finderAutoColorBlackClip: options.finderAutoColorBlackClip,
1783
+ finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
1784
+ finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
1785
+ finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
1786
+ finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
1787
+ finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
1788
+ finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
1789
+ diagnostics: options._visionDiagnostics,
1790
+ diagnosticLabel: options._diagnosticLabel ?? "normal"
1676
1791
  });
1792
+ if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
1677
1793
  const results = [];
1678
1794
 
1679
1795
  for (const geometry of geometryCandidates) {
1680
- try {
1681
- const layout = createLayout(geometry.version);
1682
- const sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
1683
- sampleRadius: options.sampleRadius ?? 0.16
1684
- });
1685
- const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
1686
- const classifier = classifierFromPaletteRgb(observedPalette);
1687
- const classified = classifySampledRgbGrid(sampled.rgbGrid, classifier, layout);
1688
- const decoded = decodeMatrix(classified.matrix, {
1689
- structureTolerance: options.structureTolerance ?? 0.18,
1690
- cellConfidence: classified.confidence,
1691
- maxErasureConfidence: options.maxErasureConfidence
1796
+ const layout = createLayout(geometry.version);
1797
+ const sampleProfiles = [{
1798
+ sampleMode: options.sampleMode ?? "cross",
1799
+ sampleRadius: options.sampleRadius ?? 0.16,
1800
+ robustCalibration: false
1801
+ }];
1802
+ if (options.adaptiveSampling !== false) {
1803
+ sampleProfiles.push({
1804
+ sampleMode: "median",
1805
+ sampleRadius: options.robustSampleRadius ?? 0.12,
1806
+ robustCalibration: true
1692
1807
  });
1693
- if (decoded.version !== geometry.version) continue;
1808
+ }
1694
1809
 
1695
- results.push({
1696
- ...decoded,
1697
- perspectiveCorrected: true,
1698
- colorCalibrated: true,
1699
- geometry,
1700
- observedPalette,
1701
- averageColorDistance: classified.averageColorDistance,
1702
- averageCellConfidence: classified.averageCellConfidence,
1703
- minimumCellConfidence: classified.minimumCellConfidence,
1704
- lowConfidenceCells: classified.lowConfidenceCells,
1705
- rectified: options.includeRectified
1706
- ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
1707
- : undefined
1708
- });
1709
- } catch {
1710
- // Continue trying geometry hypotheses.
1810
+ let geometryDecoded = false;
1811
+ let bestStructureScore = 0;
1812
+ for (const profile of sampleProfiles) {
1813
+ let sampled;
1814
+ let observedPalette;
1815
+ try {
1816
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
1817
+ observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
1818
+ robust: profile.robustCalibration
1819
+ });
1820
+ } catch {
1821
+ continue;
1822
+ }
1823
+
1824
+ const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
1825
+ const activeObservedPalette = metadata.observedPalette ?? observedPalette;
1826
+ const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
1827
+ const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
1828
+ const structureScore = structuralAccuracy(classified.matrix, layout);
1829
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
1830
+ pushObservation(options, {
1831
+ version: geometry.version,
1832
+ matrix: classified.matrix,
1833
+ confidence: classified.confidence,
1834
+ geometry,
1835
+ observedPalette: activeObservedPalette,
1836
+ samplingMode: activeSamplingMode,
1837
+ colorNormalization: attempt.colorNormalization,
1838
+ structureScore,
1839
+ averageCellConfidence: classified.averageCellConfidence,
1840
+ lowConfidenceCells: classified.lowConfidenceCells
1841
+ });
1842
+
1843
+ try {
1844
+ const decoded = decodeMatrix(classified.matrix, {
1845
+ structureTolerance: options.structureTolerance ?? 0.18,
1846
+ cellConfidence: classified.confidence,
1847
+ maxErasureConfidence: options.maxErasureConfidence
1848
+ });
1849
+ if (decoded.version !== geometry.version) return false;
1850
+
1851
+ results.push({
1852
+ ...decoded,
1853
+ perspectiveCorrected: true,
1854
+ colorCalibrated: true,
1855
+ colorNormalization: attempt.colorNormalization,
1856
+ samplingMode: activeSamplingMode,
1857
+ geometry,
1858
+ observedPalette: activeObservedPalette,
1859
+ autoEnhanced: metadata.autoEnhanced || undefined,
1860
+ recoveryMode: metadata.recoveryMode,
1861
+ averageColorDistance: classified.averageColorDistance,
1862
+ averageCellConfidence: classified.averageCellConfidence,
1863
+ minimumCellConfidence: classified.minimumCellConfidence,
1864
+ lowConfidenceCells: classified.lowConfidenceCells,
1865
+ rectified: options.includeRectified
1866
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
1867
+ : undefined
1868
+ });
1869
+ return true;
1870
+ } catch {
1871
+ return false;
1872
+ }
1873
+ };
1874
+
1875
+ // Fast path: preserve the original observed-RGB classifier first, then
1876
+ // try per-channel white balancing. Most clean frames stop here without
1877
+ // paying for the more expensive spatial normalization fallback.
1878
+ for (const attempt of paletteClassifierAttempts(observedPalette)) {
1879
+ if (tryAttempt(attempt, sampled.rgbGrid)) {
1880
+ geometryDecoded = true;
1881
+ break;
1882
+ }
1883
+ }
1884
+
1885
+ if (!geometryDecoded && options.spatialColorNormalization !== false) {
1886
+ try {
1887
+ const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
1888
+ const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
1889
+ geometryDecoded = tryAttempt({
1890
+ classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
1891
+ colorNormalization: "spatial-white-balanced"
1892
+ }, normalizedGrid);
1893
+ } catch {
1894
+ // Continue to the recovery profile below.
1895
+ }
1896
+ }
1897
+
1898
+ // Very cheap Photoshop-like recovery on the already-sampled module grid.
1899
+ // Normal camera frames never reach this path. The transform approximates
1900
+ // Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
1901
+ if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
1902
+ try {
1903
+ const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
1904
+ blackClip: options.autoEnhanceBlackClip,
1905
+ whiteClip: options.autoEnhanceWhiteClip,
1906
+ saturation: options.autoEnhanceSaturation
1907
+ });
1908
+ const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
1909
+ for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
1910
+ const recoveredAttempt = {
1911
+ ...attempt,
1912
+ colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
1913
+ };
1914
+ if (tryAttempt(recoveredAttempt, enhancedGrid, {
1915
+ observedPalette: enhancedPalette,
1916
+ autoEnhanced: true,
1917
+ recoveryMode: "module-grid-auto-tone-contrast-color"
1918
+ })) {
1919
+ geometryDecoded = true;
1920
+ break;
1921
+ }
1922
+ }
1923
+ } catch {
1924
+ // Continue to the QR-region pixel recovery below.
1925
+ }
1926
+ }
1927
+
1928
+ // Camera-specific Photoshop-style fallback. Enhancing the whole camera
1929
+ // frame is often ineffective because dark surroundings, browser/UI
1930
+ // reflections and unrelated objects skew the histograms. Once finder
1931
+ // geometry is known, rectify only the QuadQR region, run Auto Tone /
1932
+ // Contrast / Color there at pixel level, then sample modules again. This
1933
+ // closely matches what happens when the QR itself is corrected in an
1934
+ // editor before photographing it, while staying off the clean fast path.
1935
+ if (
1936
+ !geometryDecoded &&
1937
+ profile === sampleProfiles[sampleProfiles.length - 1] &&
1938
+ options.autoEnhanceRecovery !== false &&
1939
+ options.rectifiedAutoEnhanceRecovery !== false
1940
+ ) {
1941
+ try {
1942
+ const recoveryModuleSize = Math.max(4, Math.min(10, Math.round(options.rectifiedRecoveryModuleSize ?? 6)));
1943
+ const rectifiedRegion = rectifyImageData(
1944
+ imageData,
1945
+ geometry.homography,
1946
+ layout.size,
1947
+ recoveryModuleSize
1948
+ );
1949
+ const enhancedRegion = autoToneContrastColorImageData(rectifiedRegion, {
1950
+ blackClip: options.rectifiedAutoEnhanceBlackClip ?? options.autoEnhanceBlackClip ?? 0.008,
1951
+ whiteClip: options.rectifiedAutoEnhanceWhiteClip ?? options.autoEnhanceWhiteClip ?? 0.006,
1952
+ saturation: options.rectifiedAutoEnhanceSaturation ?? options.autoEnhanceSaturation ?? 1.22,
1953
+ highlightFraction: options.rectifiedAutoEnhanceHighlightFraction ?? 0.16,
1954
+ targetSamples: Math.min(120000, rectifiedRegion.width * rectifiedRegion.height)
1955
+ });
1956
+ const enhancedSampled = sampleAxisAlignedGrid(
1957
+ enhancedRegion,
1958
+ { x: 0, y: 0, width: enhancedRegion.width, height: enhancedRegion.height },
1959
+ layout.size,
1960
+ options.rectifiedRecoveryRadiusRatio ?? 0.16
1961
+ );
1962
+ const enhancedPalette = sampleObservedPalette(
1963
+ enhancedSampled.rgbGrid,
1964
+ layout.calibration,
1965
+ { robust: true }
1966
+ );
1967
+ for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
1968
+ const recoveredAttempt = {
1969
+ ...attempt,
1970
+ colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
1971
+ };
1972
+ if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
1973
+ observedPalette: enhancedPalette,
1974
+ samplingMode: "rectified-auto-enhance",
1975
+ autoEnhanced: true,
1976
+ recoveryMode: "rectified-auto-tone-contrast-color"
1977
+ })) {
1978
+ geometryDecoded = true;
1979
+ break;
1980
+ }
1981
+ }
1982
+ } catch {
1983
+ // Geometry refinement remains available as the final bounded fallback.
1984
+ }
1985
+ }
1986
+
1987
+ if (geometryDecoded) break;
1988
+ }
1989
+
1990
+ // Slow-path geometry micro-refinement. Finder/alignment detection can be
1991
+ // correct while a soft-focus or smeared camera shifts the effective module
1992
+ // centres by a small fraction of a cell. Clean scans never reach this code.
1993
+ // On a strong-but-undecodable symbol, probe a tiny 3x3 offset neighbourhood
1994
+ // using centre-only samples and attempt decoding from the best candidates.
1995
+ if (
1996
+ !geometryDecoded &&
1997
+ options.geometryRefinement !== false &&
1998
+ bestStructureScore >= (options.refinementStructureThreshold ?? 0.90)
1999
+ ) {
2000
+ const step = clampNumber(options.refinementOffset ?? 0.20, 0.05, 0.35);
2001
+ const refinementOffsets = [
2002
+ [step, 0], [-step, 0], [0, -step], [0, step],
2003
+ [step, -step], [step, step], [-step, -step], [-step, step]
2004
+ ];
2005
+ const refinementCandidates = [];
2006
+
2007
+ for (const [offsetX, offsetY] of refinementOffsets) {
2008
+ let sampled;
2009
+ let observedPalette;
2010
+ try {
2011
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
2012
+ sampleMode: "cross",
2013
+ sampleRadius: 0,
2014
+ sampleOffsetX: offsetX,
2015
+ sampleOffsetY: offsetY
2016
+ });
2017
+ observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
2018
+ } catch {
2019
+ continue;
2020
+ }
2021
+
2022
+ // Raw observed-palette classification is deliberately first here too.
2023
+ // It is cheap, and it handles the common case where blur biased the
2024
+ // geometry slightly but the camera colors themselves remain usable.
2025
+ const attempts = paletteClassifierAttempts(observedPalette);
2026
+ for (const attempt of attempts) {
2027
+ const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
2028
+ const structureScore = structuralAccuracy(classified.matrix, layout);
2029
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
2030
+ pushObservation(options, {
2031
+ version: geometry.version,
2032
+ matrix: classified.matrix,
2033
+ confidence: classified.confidence,
2034
+ geometry,
2035
+ observedPalette,
2036
+ samplingMode: "refined-center",
2037
+ samplingOffset: { x: offsetX, y: offsetY },
2038
+ colorNormalization: attempt.colorNormalization,
2039
+ structureScore,
2040
+ averageCellConfidence: classified.averageCellConfidence,
2041
+ lowConfidenceCells: classified.lowConfidenceCells
2042
+ });
2043
+
2044
+ // Do not spend RS work on a refinement that made the known finder /
2045
+ // alignment structure worse. This keeps the recovery path bounded.
2046
+ if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
2047
+ refinementCandidates.push({
2048
+ attempt,
2049
+ classified,
2050
+ observedPalette,
2051
+ offsetX,
2052
+ offsetY,
2053
+ structureScore
2054
+ });
2055
+ }
2056
+ }
2057
+
2058
+ refinementCandidates.sort((a, b) =>
2059
+ (b.structureScore - a.structureScore) ||
2060
+ (b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
2061
+ (a.classified.averageColorDistance - b.classified.averageColorDistance)
2062
+ );
2063
+
2064
+ const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
2065
+ for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
2066
+ try {
2067
+ const decoded = decodeMatrix(candidate.classified.matrix, {
2068
+ structureTolerance: options.structureTolerance ?? 0.18,
2069
+ cellConfidence: candidate.classified.confidence,
2070
+ maxErasureConfidence: options.maxErasureConfidence
2071
+ });
2072
+ if (decoded.version !== geometry.version) continue;
2073
+ results.push({
2074
+ ...decoded,
2075
+ perspectiveCorrected: true,
2076
+ geometryRefined: true,
2077
+ samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
2078
+ colorCalibrated: true,
2079
+ colorNormalization: candidate.attempt.colorNormalization,
2080
+ samplingMode: "refined-center",
2081
+ geometry,
2082
+ observedPalette: candidate.observedPalette,
2083
+ averageColorDistance: candidate.classified.averageColorDistance,
2084
+ averageCellConfidence: candidate.classified.averageCellConfidence,
2085
+ minimumCellConfidence: candidate.classified.minimumCellConfidence,
2086
+ lowConfidenceCells: candidate.classified.lowConfidenceCells,
2087
+ rectified: options.includeRectified
2088
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
2089
+ : undefined
2090
+ });
2091
+ geometryDecoded = true;
2092
+ break;
2093
+ } catch {
2094
+ // Try the next high-scoring micro-refinement.
2095
+ }
2096
+ }
1711
2097
  }
1712
2098
  }
1713
2099
 
@@ -1721,7 +2107,7 @@ function tryPerspectiveScan(imageData, options) {
1721
2107
 
1722
2108
  function tryAxisAlignedScan(imageData, options) {
1723
2109
  const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
1724
- const fixedClassifier = classifierFromPaletteRgb(paletteRgb(options.palette));
2110
+ const fixedPalette = paletteRgb(options.palette);
1725
2111
  const minVersion = options.minVersion ?? MIN_VERSION;
1726
2112
  const maxVersion = options.maxVersion ?? MAX_VERSION;
1727
2113
  const candidates = [];
@@ -1734,49 +2120,95 @@ function tryAxisAlignedScan(imageData, options) {
1734
2120
  const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
1735
2121
  if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
1736
2122
 
1737
- try {
1738
- const sampled = sampleAxisAlignedGrid(imageData, bounds, size, options.sampleRadius ?? 0.18);
1739
- const classifierAttempts = [];
1740
- const layout = createLayout(version);
1741
- try {
1742
- const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
1743
- classifierAttempts.push({ classifier: classifierFromPaletteRgb(observedPalette), calibrated: true });
1744
- } catch {
1745
- // Fixed palette fallback below.
1746
- }
1747
- classifierAttempts.push({ classifier: fixedClassifier, calibrated: false });
2123
+ const radiusProfiles = [options.sampleRadius ?? 0.18];
2124
+ if (options.adaptiveSampling !== false) radiusProfiles.push(options.robustSampleRadius ?? 0.10);
2125
+ let accepted = false;
1748
2126
 
1749
- let accepted = false;
1750
- for (const attempt of classifierAttempts) {
2127
+ for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
2128
+ try {
2129
+ const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
2130
+ const layout = createLayout(version);
2131
+ const classifierAttempts = [];
1751
2132
  try {
1752
- const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
1753
- const decoded = decodeMatrix(classified.matrix, {
1754
- structureTolerance: options.structureTolerance ?? 0.12,
1755
- cellConfidence: classified.confidence,
1756
- maxErasureConfidence: options.maxErasureConfidence
1757
- });
1758
- candidates.push({
1759
- ...decoded,
1760
- bounds,
1761
- sampledVersion: version,
1762
- moduleWidth: moduleW,
1763
- moduleHeight: moduleH,
1764
- perspectiveCorrected: false,
1765
- colorCalibrated: attempt.calibrated,
1766
- averageColorDistance: classified.averageColorDistance,
1767
- averageCellConfidence: classified.averageCellConfidence,
1768
- minimumCellConfidence: classified.minimumCellConfidence,
1769
- lowConfidenceCells: classified.lowConfidenceCells
2133
+ const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
2134
+ robust: profileIndex > 0
1770
2135
  });
1771
- accepted = true;
1772
- break;
2136
+ classifierAttempts.push(...paletteClassifierAttempts(observedPalette).map((attempt) => ({
2137
+ ...attempt,
2138
+ calibrated: true,
2139
+ observedPalette,
2140
+ rgbGrid: sampled.rgbGrid
2141
+ })));
2142
+ if (options.spatialColorNormalization !== false) {
2143
+ try {
2144
+ const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
2145
+ const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
2146
+ classifierAttempts.push({
2147
+ classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
2148
+ calibrated: true,
2149
+ colorNormalization: "spatial-white-balanced",
2150
+ observedPalette,
2151
+ rgbGrid: normalizedGrid
2152
+ });
2153
+ } catch {
2154
+ // Continue with global calibration/fixed palette.
2155
+ }
2156
+ }
1773
2157
  } catch {
1774
- // Try next classifier.
2158
+ // Fixed palette fallback below.
2159
+ }
2160
+ classifierAttempts.push({
2161
+ classifier: classifierFromPaletteRgb(fixedPalette, "raw"),
2162
+ calibrated: false,
2163
+ colorNormalization: "fixed-rgb",
2164
+ observedPalette: fixedPalette,
2165
+ rgbGrid: sampled.rgbGrid
2166
+ });
2167
+
2168
+ for (const attempt of classifierAttempts) {
2169
+ try {
2170
+ const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
2171
+ const structureScore = structuralAccuracy(classified.matrix, layout);
2172
+ pushObservation(options, {
2173
+ version,
2174
+ matrix: classified.matrix,
2175
+ confidence: classified.confidence,
2176
+ bounds,
2177
+ samplingMode: profileIndex === 0 ? "axis" : "axis-center",
2178
+ colorNormalization: attempt.colorNormalization,
2179
+ structureScore,
2180
+ averageCellConfidence: classified.averageCellConfidence,
2181
+ lowConfidenceCells: classified.lowConfidenceCells
2182
+ });
2183
+ const decoded = decodeMatrix(classified.matrix, {
2184
+ structureTolerance: options.structureTolerance ?? 0.12,
2185
+ cellConfidence: classified.confidence,
2186
+ maxErasureConfidence: options.maxErasureConfidence
2187
+ });
2188
+ candidates.push({
2189
+ ...decoded,
2190
+ bounds,
2191
+ sampledVersion: version,
2192
+ moduleWidth: moduleW,
2193
+ moduleHeight: moduleH,
2194
+ perspectiveCorrected: false,
2195
+ colorCalibrated: attempt.calibrated,
2196
+ colorNormalization: attempt.colorNormalization,
2197
+ samplingMode: profileIndex === 0 ? "axis" : "axis-center",
2198
+ averageColorDistance: classified.averageColorDistance,
2199
+ averageCellConfidence: classified.averageCellConfidence,
2200
+ minimumCellConfidence: classified.minimumCellConfidence,
2201
+ lowConfidenceCells: classified.lowConfidenceCells
2202
+ });
2203
+ accepted = true;
2204
+ break;
2205
+ } catch {
2206
+ // Try next classifier/profile.
2207
+ }
1775
2208
  }
2209
+ } catch {
2210
+ // Try a narrower centre sample, then next version.
1776
2211
  }
1777
- if (!accepted) throw new Error("Axis-aligned candidate did not decode.");
1778
- } catch {
1779
- // Try next version.
1780
2212
  }
1781
2213
  }
1782
2214
 
@@ -1793,16 +2225,90 @@ export function scanImageData(imageData, options = {}) {
1793
2225
  validateVersion(minVersion);
1794
2226
  validateVersion(maxVersion);
1795
2227
 
2228
+ const geometryCollector = [];
2229
+ const scanOptions = { ...options, _geometryCollector: geometryCollector };
2230
+
1796
2231
  if (options.perspective !== false) {
1797
- const perspective = tryPerspectiveScan(imageData, options);
2232
+ const perspective = tryPerspectiveScan(imageData, scanOptions);
1798
2233
  if (perspective) return perspective;
1799
2234
  }
1800
2235
 
2236
+ // If the normal scanner could see QuadQR geometry but could not decode the
2237
+ // colors, retry the same frame after a single global Auto Tone / Contrast /
2238
+ // Color-style pass. This is deliberately after the normal path so clean
2239
+ // camera scanning remains unchanged and fast.
2240
+ if (
2241
+ options.autoEnhanceRecovery !== false &&
2242
+ options.fullFrameAutoEnhanceRecovery !== false &&
2243
+ !options._autoEnhancedRecovery &&
2244
+ geometryCollector.length > 0
2245
+ ) {
2246
+ try {
2247
+ const enhanced = autoToneContrastColorImageData(imageData, {
2248
+ blackClip: options.autoEnhanceBlackClip,
2249
+ whiteClip: options.autoEnhanceWhiteClip,
2250
+ saturation: options.autoEnhanceSaturation,
2251
+ targetSamples: options.autoEnhanceTargetSamples
2252
+ });
2253
+ const recovered = tryPerspectiveScan(enhanced, {
2254
+ ...options,
2255
+ _autoEnhancedRecovery: true,
2256
+ autoEnhanceRecovery: false,
2257
+ _geometryCollector: []
2258
+ });
2259
+ if (recovered) {
2260
+ return {
2261
+ ...recovered,
2262
+ autoEnhanced: true,
2263
+ recoveryMode: "auto-tone-contrast-color",
2264
+ originalColorNormalization: recovered.colorNormalization
2265
+ };
2266
+ }
2267
+ } catch {
2268
+ // Continue to the axis-aligned fallback.
2269
+ }
2270
+ }
2271
+
1801
2272
  if (options.axisAlignedFallback !== false) {
1802
- const axis = tryAxisAlignedScan(imageData, options);
2273
+ const axis = tryAxisAlignedScan(imageData, scanOptions);
1803
2274
  if (axis) return axis;
1804
2275
  }
1805
2276
 
2277
+ // Static images sometimes have such low contrast that locator detection itself
2278
+ // fails. Permit one enhanced full retry in that case. The live camera disables
2279
+ // this on its first pass so an empty frame never becomes expensive.
2280
+ if (
2281
+ options.autoEnhanceRecovery !== false &&
2282
+ !options._autoEnhancedRecovery &&
2283
+ geometryCollector.length === 0 &&
2284
+ options.autoEnhanceWhenNoGeometry !== false
2285
+ ) {
2286
+ try {
2287
+ const enhanced = autoToneContrastColorImageData(imageData, {
2288
+ blackClip: options.autoEnhanceBlackClip,
2289
+ whiteClip: options.autoEnhanceWhiteClip,
2290
+ saturation: options.autoEnhanceSaturation,
2291
+ targetSamples: options.autoEnhanceTargetSamples
2292
+ });
2293
+ const recoveryOptions = {
2294
+ ...options,
2295
+ _autoEnhancedRecovery: true,
2296
+ autoEnhanceRecovery: false,
2297
+ _geometryCollector: []
2298
+ };
2299
+ if (options.perspective !== false) {
2300
+ const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
2301
+ if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
2302
+ }
2303
+ if (options.axisAlignedFallback !== false) {
2304
+ const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
2305
+ if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
2306
+ }
2307
+ } catch {
2308
+ // Fall through to the normal scan failure below.
2309
+ }
2310
+ }
2311
+
1806
2312
  throw new Error(
1807
2313
  "No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
1808
2314
  );
@@ -1834,19 +2340,274 @@ export async function scanFile(file, options = {}) {
1834
2340
  return scanImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), options);
1835
2341
  }
1836
2342
 
2343
+ function parseObjectPositionFraction(value, fallback = 0.5) {
2344
+ if (typeof value !== "string") return fallback;
2345
+ const token = value.trim().toLowerCase();
2346
+ if (token === "left" || token === "top") return 0;
2347
+ if (token === "right" || token === "bottom") return 1;
2348
+ if (token === "center") return 0.5;
2349
+ if (token.endsWith("%")) {
2350
+ const parsed = Number.parseFloat(token);
2351
+ if (Number.isFinite(parsed)) return clampNumber(parsed / 100, 0, 1);
2352
+ }
2353
+ return fallback;
2354
+ }
2355
+
2356
+ function visibleVideoSourceRect(video, options = {}) {
2357
+ const sourceWidth = video.videoWidth;
2358
+ const sourceHeight = video.videoHeight;
2359
+ const full = { x: 0, y: 0, width: sourceWidth, height: sourceHeight, cropped: false };
2360
+ if (options.videoCropMode === "full") return full;
2361
+
2362
+ let boxWidth = Number(video.clientWidth) || 0;
2363
+ let boxHeight = Number(video.clientHeight) || 0;
2364
+ if ((!boxWidth || !boxHeight) && typeof video.getBoundingClientRect === "function") {
2365
+ const rect = video.getBoundingClientRect();
2366
+ boxWidth = boxWidth || rect.width;
2367
+ boxHeight = boxHeight || rect.height;
2368
+ }
2369
+ if (!boxWidth || !boxHeight) return full;
2370
+
2371
+ let objectFit = options.videoObjectFit;
2372
+ let objectPosition = options.videoObjectPosition;
2373
+ if ((!objectFit || !objectPosition) && typeof getComputedStyle === "function") {
2374
+ try {
2375
+ const style = getComputedStyle(video);
2376
+ objectFit = objectFit || style.objectFit;
2377
+ objectPosition = objectPosition || style.objectPosition;
2378
+ } catch {
2379
+ // Fall back to the demo's normal cover/center behavior.
2380
+ }
2381
+ }
2382
+ objectFit = objectFit || "cover";
2383
+ if (objectFit !== "cover") return full;
2384
+
2385
+ const scale = Math.max(boxWidth / sourceWidth, boxHeight / sourceHeight);
2386
+ if (!Number.isFinite(scale) || scale <= 0) return full;
2387
+ let cropWidth = Math.min(sourceWidth, boxWidth / scale);
2388
+ let cropHeight = Math.min(sourceHeight, boxHeight / scale);
2389
+ if (cropWidth >= sourceWidth - 1 && cropHeight >= sourceHeight - 1) return full;
2390
+
2391
+ const positionTokens = String(objectPosition || "50% 50%").trim().split(/\s+/);
2392
+ const positionX = parseObjectPositionFraction(positionTokens[0], 0.5);
2393
+ const positionY = parseObjectPositionFraction(positionTokens[1] ?? positionTokens[0], 0.5);
2394
+ let x = (sourceWidth - cropWidth) * positionX;
2395
+ let y = (sourceHeight - cropHeight) * positionY;
2396
+
2397
+ // Optional tiny inset removes the soft edge of a camera preview without
2398
+ // changing the visible composition. Leave at zero unless explicitly set.
2399
+ const inset = clampNumber(options.videoCropInset ?? 0, 0, 0.18);
2400
+ if (inset > 0) {
2401
+ const dx = cropWidth * inset;
2402
+ const dy = cropHeight * inset;
2403
+ x += dx;
2404
+ y += dy;
2405
+ cropWidth -= dx * 2;
2406
+ cropHeight -= dy * 2;
2407
+ }
2408
+
2409
+ x = clampNumber(x, 0, Math.max(0, sourceWidth - cropWidth));
2410
+ y = clampNumber(y, 0, Math.max(0, sourceHeight - cropHeight));
2411
+ return { x, y, width: cropWidth, height: cropHeight, cropped: true };
2412
+ }
2413
+
2414
+ function cropImageDataInset(imageData, insetFraction = 0) {
2415
+ const inset = clampNumber(Number(insetFraction) || 0, 0, 0.32);
2416
+ if (inset <= 0) {
2417
+ return {
2418
+ imageData,
2419
+ rect: { x: 0, y: 0, width: imageData.width, height: imageData.height }
2420
+ };
2421
+ }
2422
+
2423
+ const x = Math.max(0, Math.floor(imageData.width * inset));
2424
+ const y = Math.max(0, Math.floor(imageData.height * inset));
2425
+ const width = Math.max(1, imageData.width - x * 2);
2426
+ const height = Math.max(1, imageData.height - y * 2);
2427
+ const data = new Uint8ClampedArray(width * height * 4);
2428
+ for (let row = 0; row < height; row++) {
2429
+ const sourceStart = ((y + row) * imageData.width + x) * 4;
2430
+ const sourceEnd = sourceStart + width * 4;
2431
+ data.set(imageData.data.subarray(sourceStart, sourceEnd), row * width * 4);
2432
+ }
2433
+ return {
2434
+ imageData: { width, height, data },
2435
+ rect: { x, y, width, height }
2436
+ };
2437
+ }
2438
+
2439
+ function bestVisionDiagnosticPass(visionDiagnostics) {
2440
+ const passes = visionDiagnostics?.passes;
2441
+ if (!Array.isArray(passes) || !passes.length) return null;
2442
+ return passes.slice().sort((a, b) => {
2443
+ const aGeometry = a.geometries?.[0];
2444
+ const bGeometry = b.geometries?.[0];
2445
+ return (Boolean(bGeometry) - Boolean(aGeometry)) ||
2446
+ ((b.finderCount ?? 0) - (a.finderCount ?? 0)) ||
2447
+ ((bGeometry?.score ?? 0) - (aGeometry?.score ?? 0));
2448
+ })[0];
2449
+ }
2450
+
2451
+ function nowMs() {
2452
+ return typeof performance !== "undefined" && typeof performance.now === "function"
2453
+ ? performance.now()
2454
+ : Date.now();
2455
+ }
2456
+
2457
+ function normalizeFrameDiagnostics(frameDiagnostics, source, width, height, visionDiagnostics) {
2458
+ if (!frameDiagnostics || typeof frameDiagnostics !== "object") return;
2459
+ frameDiagnostics.scanWidth = width;
2460
+ frameDiagnostics.scanHeight = height;
2461
+ frameDiagnostics.frameWidth = width;
2462
+ frameDiagnostics.frameHeight = height;
2463
+ frameDiagnostics.scanRect = { x: 0, y: 0, width, height };
2464
+ frameDiagnostics.sourceRect = {
2465
+ x: source.x,
2466
+ y: source.y,
2467
+ width: source.width,
2468
+ height: source.height,
2469
+ cropped: Boolean(source.cropped)
2470
+ };
2471
+ frameDiagnostics.vision = visionDiagnostics;
2472
+ const bestPass = bestVisionDiagnosticPass(visionDiagnostics);
2473
+ frameDiagnostics.bestPass = bestPass;
2474
+ frameDiagnostics.finderCount = bestPass?.finderCount ?? 0;
2475
+ frameDiagnostics.finders = bestPass?.finders ?? [];
2476
+ frameDiagnostics.finderMethod = bestPass?.finderMethod ?? null;
2477
+ frameDiagnostics.finderPasses = Array.isArray(visionDiagnostics?.passes)
2478
+ ? visionDiagnostics.passes.map((pass) => ({
2479
+ method: pass.finderMethod ?? pass.label,
2480
+ finderCount: pass.finderCount ?? 0,
2481
+ threshold: pass.threshold,
2482
+ geometryCount: pass.geometries?.length ?? 0
2483
+ }))
2484
+ : [];
2485
+ frameDiagnostics.geometry = bestPass?.geometries?.[0] ?? null;
2486
+ }
2487
+
1837
2488
  export function scanVideoFrame(video, options = {}) {
1838
2489
  assert(typeof document !== "undefined", "scanVideoFrame is a browser API.");
1839
2490
  assert(video && video.videoWidth && video.videoHeight, "Video frame is not ready.");
2491
+
2492
+ // Scan what the user actually sees. On phones the video element is usually a
2493
+ // portrait box with object-fit: cover while the camera sensor stream is 16:9.
2494
+ // Previously we scanned the entire hidden sensor frame, so the QR looked big
2495
+ // in the guide but became much smaller after canvas downscaling. Cropping to
2496
+ // the visible source region both improves module resolution and reduces work.
2497
+ const source = visibleVideoSourceRect(video, options);
1840
2498
  const maxDimension = options.maxDimension ?? 960;
1841
- const scale = Math.min(1, maxDimension / Math.max(video.videoWidth, video.videoHeight));
1842
- const width = Math.max(1, Math.round(video.videoWidth * scale));
1843
- const height = Math.max(1, Math.round(video.videoHeight * scale));
2499
+ const scale = Math.min(1, maxDimension / Math.max(source.width, source.height));
2500
+ const width = Math.max(1, Math.round(source.width * scale));
2501
+ const height = Math.max(1, Math.round(source.height * scale));
1844
2502
  const canvas = options.canvas ?? document.createElement("canvas");
1845
2503
  canvas.width = width;
1846
2504
  canvas.height = height;
1847
2505
  const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
1848
- ctx.drawImage(video, 0, 0, width, height);
1849
- return scanImageData(ctx.getImageData(0, 0, width, height), options);
2506
+ // High-quality downscaling is preferable for finder geometry, but visible-ROI
2507
+ // cropping means many mobile frames now need little or no downscaling at all.
2508
+ if ("imageSmoothingEnabled" in ctx) ctx.imageSmoothingEnabled = true;
2509
+ if ("imageSmoothingQuality" in ctx) ctx.imageSmoothingQuality = "high";
2510
+ ctx.drawImage(video, source.x, source.y, source.width, source.height, 0, 0, width, height);
2511
+ const frameImageData = ctx.getImageData(0, 0, width, height);
2512
+ if (options._capturedFrame && typeof options._capturedFrame === "object") {
2513
+ options._capturedFrame.imageData = frameImageData;
2514
+ options._capturedFrame.scanWidth = width;
2515
+ options._capturedFrame.scanHeight = height;
2516
+ options._capturedFrame.source = { ...source };
2517
+ }
2518
+ const visionDiagnostics = options._frameDiagnostics ? { passes: [] } : null;
2519
+ try {
2520
+ const result = scanImageData(frameImageData, {
2521
+ ...options,
2522
+ _visionDiagnostics: visionDiagnostics
2523
+ });
2524
+ normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
2525
+ if (!source.cropped) return result;
2526
+ return {
2527
+ ...result,
2528
+ cameraVisibleCrop: true,
2529
+ cameraSourceRect: { x: source.x, y: source.y, width: source.width, height: source.height }
2530
+ };
2531
+ } catch (error) {
2532
+ normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
2533
+ throw error;
2534
+ }
2535
+ }
2536
+
2537
+ function selectBestFrameObservation(observations) {
2538
+ if (!observations?.length) return null;
2539
+ return observations
2540
+ .filter((item) => item?.matrix && item.structureScore >= 0.82)
2541
+ .sort((a, b) =>
2542
+ (b.structureScore - a.structureScore) ||
2543
+ (b.averageCellConfidence - a.averageCellConfidence) ||
2544
+ ((b.geometry?.score ?? 0) - (a.geometry?.score ?? 0))
2545
+ )[0] ?? null;
2546
+ }
2547
+
2548
+ function combineFrameObservations(observations) {
2549
+ if (!observations?.length) return null;
2550
+ const version = observations[0].version;
2551
+ const size = observations[0].matrix.length;
2552
+ if (!observations.every((item) => item.version === version && item.matrix.length === size)) return null;
2553
+
2554
+ const matrix = make2D(size, CELL.WHITE);
2555
+ const confidence = make2D(size, 0);
2556
+ for (let r = 0; r < size; r++) {
2557
+ for (let c = 0; c < size; c++) {
2558
+ const votes = new Map();
2559
+ const confidenceByCell = new Map();
2560
+ for (const observation of observations) {
2561
+ const cell = observation.matrix[r][c];
2562
+ const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
2563
+ const weight = 0.35 + sourceConfidence * 0.65;
2564
+ votes.set(cell, (votes.get(cell) ?? 0) + weight);
2565
+ const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
2566
+ stats.weighted += sourceConfidence * weight;
2567
+ stats.weight += weight;
2568
+ confidenceByCell.set(cell, stats);
2569
+ }
2570
+
2571
+ const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
2572
+ const [bestCell, bestWeight] = ranked[0];
2573
+ const secondWeight = ranked[1]?.[1] ?? 0;
2574
+ const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
2575
+ const sourceStats = confidenceByCell.get(bestCell);
2576
+ const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
2577
+ const support = totalWeight ? bestWeight / totalWeight : 0;
2578
+ const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
2579
+ const agreement = 0.5 * support + 0.5 * margin;
2580
+
2581
+ matrix[r][c] = bestCell;
2582
+ // Do not become overconfident just because several blurry frames agree.
2583
+ // Retaining source uncertainty lets Reed-Solomon treat repeated ambiguous
2584
+ // cells as erasures instead of hard, supposedly-certain errors.
2585
+ confidence[r][c] = clampNumber(sourceConfidence * 0.58 + agreement * 0.42, 0, 1);
2586
+ }
2587
+ }
2588
+ return { version, matrix, confidence };
2589
+ }
2590
+
2591
+ async function improveCameraTrack(stream) {
2592
+ const track = stream.getVideoTracks?.()[0];
2593
+ if (!track?.applyConstraints || !track.getCapabilities) return;
2594
+ try {
2595
+ const capabilities = track.getCapabilities();
2596
+ const advanced = {};
2597
+ if (Array.isArray(capabilities.focusMode) && capabilities.focusMode.includes("continuous")) {
2598
+ advanced.focusMode = "continuous";
2599
+ }
2600
+ if (Array.isArray(capabilities.exposureMode) && capabilities.exposureMode.includes("continuous")) {
2601
+ advanced.exposureMode = "continuous";
2602
+ }
2603
+ if (Array.isArray(capabilities.whiteBalanceMode) && capabilities.whiteBalanceMode.includes("continuous")) {
2604
+ advanced.whiteBalanceMode = "continuous";
2605
+ }
2606
+ if (Object.keys(advanced).length) await track.applyConstraints({ advanced: [advanced] });
2607
+ } catch {
2608
+ // Browsers expose different subsets of camera controls. Scanner-side
2609
+ // calibration remains the primary path when these hints are unsupported.
2610
+ }
1850
2611
  }
1851
2612
 
1852
2613
  export async function startCameraScanner(video, options = {}) {
@@ -1858,11 +2619,13 @@ export async function startCameraScanner(video, options = {}) {
1858
2619
  audio: false,
1859
2620
  video: {
1860
2621
  facingMode: { ideal: "environment" },
1861
- width: { ideal: 1280 },
1862
- height: { ideal: 720 }
2622
+ width: { ideal: 1920 },
2623
+ height: { ideal: 1080 },
2624
+ frameRate: { ideal: 30 }
1863
2625
  }
1864
2626
  }
1865
2627
  );
2628
+ await improveCameraTrack(stream);
1866
2629
 
1867
2630
  video.srcObject = stream;
1868
2631
  video.setAttribute("playsinline", "");
@@ -1871,32 +2634,436 @@ export async function startCameraScanner(video, options = {}) {
1871
2634
 
1872
2635
  const scanInterval = Math.max(80, options.scanInterval ?? 180);
1873
2636
  const scratchCanvas = document.createElement("canvas");
2637
+ const multiFrameEnabled = options.multiFrame !== false;
2638
+ const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
2639
+ const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
2640
+ const observationHistory = new Map();
2641
+ const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
2642
+ const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
2643
+ const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
2644
+ let missStreak = 0;
1874
2645
  let stopped = false;
1875
2646
  let busy = false;
1876
2647
  let timer = null;
2648
+ let frameNumber = 0;
2649
+
2650
+ const diagnosticsEnabled = typeof options.onDiagnostic === "function";
2651
+ const emitDiagnostic = (event) => {
2652
+ if (!diagnosticsEnabled) return;
2653
+ try {
2654
+ options.onDiagnostic({
2655
+ timestamp: Date.now(),
2656
+ frame: frameNumber,
2657
+ ...event
2658
+ });
2659
+ } catch {
2660
+ // Diagnostics are UI-only and must never interrupt scanning.
2661
+ }
2662
+ };
2663
+
2664
+
2665
+ if (diagnosticsEnabled) {
2666
+ const track = stream.getVideoTracks?.()[0];
2667
+ const settings = track?.getSettings?.() ?? {};
2668
+ emitDiagnostic({
2669
+ type: "camera-ready",
2670
+ method: "camera",
2671
+ message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight}` ,
2672
+ camera: {
2673
+ width: settings.width ?? video.videoWidth,
2674
+ height: settings.height ?? video.videoHeight,
2675
+ frameRate: settings.frameRate ?? null,
2676
+ facingMode: settings.facingMode ?? null
2677
+ }
2678
+ });
2679
+ }
1877
2680
 
1878
2681
  const stop = () => {
1879
2682
  stopped = true;
1880
2683
  if (timer) clearTimeout(timer);
2684
+ observationHistory.clear();
1881
2685
  for (const track of stream.getTracks()) track.stop();
1882
2686
  if (video.srcObject === stream) video.srcObject = null;
1883
2687
  };
1884
2688
 
1885
- const scanNow = () => scanVideoFrame(video, { ...options, canvas: scratchCanvas });
2689
+ const scanNow = () => scanVideoFrame(video, {
2690
+ ...options,
2691
+ maxDimension: options.maxDimension ?? 1080,
2692
+ canvas: scratchCanvas
2693
+ });
2694
+
2695
+ const emitResult = (result, capturedFrame = null, diagnostic = null) => {
2696
+ const frameMeta = capturedFrame?.imageData ? {
2697
+ frame: frameNumber,
2698
+ imageData: capturedFrame.imageData,
2699
+ scanWidth: capturedFrame.scanWidth,
2700
+ scanHeight: capturedFrame.scanHeight,
2701
+ sourceRect: capturedFrame.source ? { ...capturedFrame.source } : null,
2702
+ enhancedImageData: capturedFrame.enhancedImageData ?? null,
2703
+ enhancedRect: capturedFrame.enhancedRect ? { ...capturedFrame.enhancedRect } : null,
2704
+ enhancement: capturedFrame.enhancement ? { ...capturedFrame.enhancement } : null,
2705
+ diagnostic
2706
+ } : null;
2707
+ options.onResult?.(result, frameMeta);
2708
+ options.onDecode?.(result, frameMeta);
2709
+ if (options.stopOnResult ?? true) {
2710
+ stop();
2711
+ return true;
2712
+ }
2713
+ return false;
2714
+ };
2715
+
2716
+ const tryMultiFrameDecode = (observations) => {
2717
+ if (!multiFrameEnabled) return null;
2718
+ const best = selectBestFrameObservation(observations);
2719
+ if (!best) return null;
2720
+ const history = observationHistory.get(best.version) ?? [];
2721
+ history.push(best);
2722
+ while (history.length > multiFrameWindow) history.shift();
2723
+ observationHistory.set(best.version, history);
2724
+ if (history.length < multiFrameMinFrames) return null;
2725
+
2726
+ const combined = combineFrameObservations(history);
2727
+ if (!combined) return null;
2728
+ try {
2729
+ const decoded = decodeMatrix(combined.matrix, {
2730
+ structureTolerance: options.structureTolerance ?? 0.20,
2731
+ cellConfidence: combined.confidence,
2732
+ maxErasureConfidence: options.maxErasureConfidence
2733
+ });
2734
+ if (decoded.version !== best.version) return null;
2735
+ return {
2736
+ ...decoded,
2737
+ perspectiveCorrected: Boolean(best.geometry),
2738
+ colorCalibrated: true,
2739
+ colorNormalization: "multi-frame-vote",
2740
+ samplingMode: "multi-frame-vote",
2741
+ multiFrameCombined: history.length,
2742
+ geometry: best.geometry,
2743
+ observedPalette: best.observedPalette,
2744
+ averageCellConfidence: best.averageCellConfidence,
2745
+ lowConfidenceCells: best.lowConfidenceCells
2746
+ };
2747
+ } catch {
2748
+ return null;
2749
+ }
2750
+ };
1886
2751
 
1887
2752
  const loop = async () => {
1888
2753
  if (stopped) return;
1889
2754
  if (!busy && video.readyState >= 2) {
1890
2755
  busy = true;
2756
+ frameNumber++;
2757
+ const observations = [];
2758
+ const frameDiagnostics = diagnosticsEnabled ? {} : null;
2759
+ const capturedFrame = {};
2760
+ const frameStarted = nowMs();
2761
+ let allowAutoEnhance = false;
2762
+ let allowFinderRecovery = false;
1891
2763
  try {
1892
- const result = scanNow();
1893
- options.onResult?.(result);
1894
- if (options.stopOnResult ?? true) {
1895
- stop();
1896
- return;
1897
- }
2764
+ // The first frame stays minimal. The primary finder pass itself is now
2765
+ // QuadQR-aware (max RGB/value channel), so it remains cheap while no
2766
+ // longer confusing dark blue data with structural black. After a miss,
2767
+ // alternate frames may bracket finder thresholds and then use the
2768
+ // stronger color recovery path. Empty camera frames therefore do not
2769
+ // pay every recovery cost on every scan tick.
2770
+ allowFinderRecovery = options.finderRecovery !== false &&
2771
+ missStreak > 0 && ((missStreak - 1) % cameraFinderRecoveryEvery === 0);
2772
+ allowAutoEnhance = options.autoEnhanceRecovery !== false &&
2773
+ missStreak > 0 && ((missStreak - 1) % cameraAutoEnhanceEvery === 0);
2774
+ const method = allowAutoEnhance
2775
+ ? "progressive-color-recovery"
2776
+ : (allowFinderRecovery ? "finder-recovery" : "fast-scan");
2777
+ const result = scanVideoFrame(video, {
2778
+ ...options,
2779
+ _diagnosticLabel: method,
2780
+ finderRecovery: allowFinderRecovery,
2781
+ autoEnhanceRecovery: allowAutoEnhance,
2782
+ autoEnhanceWhenNoGeometry: allowAutoEnhance,
2783
+ // If geometry exists, the rectified QR-only pixel enhancer is both
2784
+ // stronger and much cheaper than reprocessing the whole live frame.
2785
+ fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
2786
+ maxDimension: options.maxDimension ?? 1080,
2787
+ canvas: scratchCanvas,
2788
+ _capturedFrame: capturedFrame,
2789
+ _observationCollector: observations,
2790
+ _frameDiagnostics: frameDiagnostics
2791
+ });
2792
+ const elapsedMs = nowMs() - frameStarted;
2793
+ emitDiagnostic({
2794
+ type: "frame",
2795
+ state: "decoded",
2796
+ method: result.recoveryMode ?? result.samplingMode ?? method,
2797
+ elapsedMs,
2798
+ missStreak,
2799
+ ...frameDiagnostics
2800
+ });
2801
+ emitDiagnostic({
2802
+ type: "success",
2803
+ state: "decoded",
2804
+ method: result.recoveryMode ?? result.samplingMode ?? method,
2805
+ elapsedMs,
2806
+ message: `Decoded v${result.version} · ECC ${result.eccLevel} · ${Math.round(elapsedMs)} ms`,
2807
+ ...frameDiagnostics
2808
+ });
2809
+ missStreak = 0;
2810
+ observationHistory.clear();
2811
+ if (emitResult(result, capturedFrame, frameDiagnostics)) return;
1898
2812
  } catch (error) {
1899
- options.onScanMiss?.(error);
2813
+ missStreak++;
2814
+ const fastElapsedMs = nowMs() - frameStarted;
2815
+ emitDiagnostic({
2816
+ type: "frame",
2817
+ state: "miss",
2818
+ method: allowAutoEnhance
2819
+ ? "progressive-color-recovery"
2820
+ : (allowFinderRecovery ? "finder-recovery" : "fast-scan"),
2821
+ elapsedMs: fastElapsedMs,
2822
+ missStreak,
2823
+ error: error?.message ?? String(error),
2824
+ ...frameDiagnostics
2825
+ });
2826
+
2827
+ // The user's real phone-camera case is dominated by color cast before
2828
+ // finder detection: Photoshop Auto Color alone makes the same live QR
2829
+ // immediately detectable. Retry the exact captured frame with a cheap
2830
+ // per-channel Auto Color levels correction before any geometry-dependent
2831
+ // recovery. This runs only after the normal fast scan fails, and by
2832
+ // default only on every other missed frame after the first one.
2833
+ const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
2834
+ capturedFrame.imageData &&
2835
+ ((missStreak - 1) % cameraAutoColorEvery === 0);
2836
+ if (shouldTryCameraAutoColor) {
2837
+ const requestedCrops = Array.isArray(options.cameraAutoColorCropInsets)
2838
+ ? options.cameraAutoColorCropInsets
2839
+ : [0.08, 0.16, 0.22, 0];
2840
+ const cropInsets = [];
2841
+ for (const value of requestedCrops) {
2842
+ const inset = clampNumber(Number(value), 0, 0.30);
2843
+ if (!cropInsets.some((item) => Math.abs(item - inset) < 0.001)) cropInsets.push(inset);
2844
+ }
2845
+ const explicitAnalysisInsets = Array.isArray(options.cameraAutoColorAnalysisInsets)
2846
+ ? options.cameraAutoColorAnalysisInsets
2847
+ : null;
2848
+
2849
+ emitDiagnostic({
2850
+ type: "method",
2851
+ state: "trying",
2852
+ method: "camera-auto-color",
2853
+ message: `Fast scan failed · Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
2854
+ ...frameDiagnostics
2855
+ });
2856
+
2857
+ let autoColorDecoded = false;
2858
+ for (let profileIndex = 0; profileIndex < cropInsets.length; profileIndex++) {
2859
+ const cropInset = cropInsets[profileIndex];
2860
+ const cropped = cropImageDataInset(capturedFrame.imageData, cropInset);
2861
+ const defaultAnalysisInsets = [0.10, 0.08, 0.04, 0.10];
2862
+ const analysisInset = clampNumber(
2863
+ Number(explicitAnalysisInsets?.[profileIndex]
2864
+ ?? options.cameraAutoColorAnalysisInset
2865
+ ?? defaultAnalysisInsets[Math.min(profileIndex, defaultAnalysisInsets.length - 1)]),
2866
+ 0,
2867
+ 0.30
2868
+ );
2869
+ const autoColorObservations = [];
2870
+ const autoColorVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
2871
+ const autoColorFrameDiagnostics = diagnosticsEnabled ? {} : null;
2872
+ const cropLabel = cropInset ? `${Math.round(cropInset * 100)}pct-crop` : "full";
2873
+ const profileName = `camera-auto-color-${cropLabel}`;
2874
+ try {
2875
+ const recoveryStarted = nowMs();
2876
+ const correctedFrame = autoColorImageData(cropped.imageData, {
2877
+ // This is intentionally performed on a centered recovery crop.
2878
+ // A live preview can contain large dark borders, browser UI, a
2879
+ // monitor bezel or room background. Those pixels completely
2880
+ // change global Auto Color/Otsu statistics even though the QR
2881
+ // itself looks identical to a saved crop. Photoshop succeeded
2882
+ // because the user's edited image was effectively QR-centric.
2883
+ blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
2884
+ whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
2885
+ highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
2886
+ outputHighlight: options.cameraAutoColorOutputHighlight ?? 190,
2887
+ analysisInset,
2888
+ minimumInputRange: options.cameraAutoColorMinimumInputRange ?? 72,
2889
+ targetSamples: options.cameraAutoColorTargetSamples ?? 90000
2890
+ });
2891
+ const recovered = scanImageData(correctedFrame, {
2892
+ ...options,
2893
+ _diagnosticLabel: profileName,
2894
+ _visionDiagnostics: autoColorVisionDiagnostics,
2895
+ finderRecovery: true,
2896
+ autoEnhanceRecovery: false,
2897
+ fullFrameAutoEnhanceRecovery: false,
2898
+ _observationCollector: autoColorObservations
2899
+ });
2900
+ if (autoColorFrameDiagnostics) {
2901
+ normalizeFrameDiagnostics(
2902
+ autoColorFrameDiagnostics,
2903
+ capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
2904
+ correctedFrame.width,
2905
+ correctedFrame.height,
2906
+ autoColorVisionDiagnostics
2907
+ );
2908
+ autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
2909
+ autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
2910
+ autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
2911
+ autoColorFrameDiagnostics.autoColorCropInset = cropInset;
2912
+ autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
2913
+ }
2914
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
2915
+ emitDiagnostic({
2916
+ type: "frame",
2917
+ state: "decoded",
2918
+ method: profileName,
2919
+ elapsedMs: recoveryElapsedMs,
2920
+ missStreak,
2921
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
2922
+ });
2923
+ emitDiagnostic({
2924
+ type: "success",
2925
+ state: "decoded",
2926
+ method: "camera-auto-color",
2927
+ elapsedMs: recoveryElapsedMs,
2928
+ message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
2929
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
2930
+ });
2931
+ capturedFrame.enhancedImageData = correctedFrame;
2932
+ capturedFrame.enhancedRect = { ...cropped.rect };
2933
+ capturedFrame.enhancement = {
2934
+ method: "camera-auto-color",
2935
+ cropInset,
2936
+ analysisInset
2937
+ };
2938
+ missStreak = 0;
2939
+ observationHistory.clear();
2940
+ autoColorDecoded = true;
2941
+ if (emitResult({
2942
+ ...recovered,
2943
+ autoColorCorrected: true,
2944
+ cameraProgressiveRecovery: true,
2945
+ recoveryMode: "camera-auto-color",
2946
+ cameraAutoColorCropInset: cropInset,
2947
+ cameraAutoColorAnalysisInset: analysisInset
2948
+ }, capturedFrame, autoColorFrameDiagnostics ?? frameDiagnostics)) return;
2949
+ break;
2950
+ } catch (recoveryError) {
2951
+ observations.push(...autoColorObservations);
2952
+ if (autoColorFrameDiagnostics) {
2953
+ normalizeFrameDiagnostics(
2954
+ autoColorFrameDiagnostics,
2955
+ capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
2956
+ cropped.imageData.width,
2957
+ cropped.imageData.height,
2958
+ autoColorVisionDiagnostics
2959
+ );
2960
+ autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
2961
+ autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
2962
+ autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
2963
+ autoColorFrameDiagnostics.autoColorCropInset = cropInset;
2964
+ autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
2965
+ emitDiagnostic({
2966
+ type: "frame",
2967
+ state: "miss",
2968
+ method: profileName,
2969
+ elapsedMs: nowMs() - frameStarted,
2970
+ missStreak,
2971
+ error: recoveryError?.message ?? String(recoveryError),
2972
+ ...autoColorFrameDiagnostics
2973
+ });
2974
+ }
2975
+ emitDiagnostic({
2976
+ type: "method",
2977
+ state: "failed",
2978
+ method: profileName,
2979
+ message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
2980
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
2981
+ });
2982
+ }
2983
+ }
2984
+ if (!autoColorDecoded) {
2985
+ emitDiagnostic({
2986
+ type: "method",
2987
+ state: "failed",
2988
+ method: "camera-auto-color",
2989
+ message: "All camera Auto Color profiles failed · continuing deeper recovery",
2990
+ ...frameDiagnostics
2991
+ });
2992
+ }
2993
+ }
2994
+
2995
+ // If the fast pass already saw a convincing QuadQR structure, retry
2996
+ // the exact same captured ROI immediately with the stronger QR-region
2997
+ // color correction. Empty/non-QR frames do not pay this cost. This is
2998
+ // the live equivalent of: normal scan first, then Auto Tone / Contrast
2999
+ // / Color only when the normal decode actually needs help.
3000
+ if (!allowAutoEnhance && options.autoEnhanceRecovery !== false) {
3001
+ const strongObservation = selectBestFrameObservation(observations);
3002
+ if (strongObservation && scratchCanvas.width && scratchCanvas.height) {
3003
+ emitDiagnostic({
3004
+ type: "method",
3005
+ state: "trying",
3006
+ method: "qr-region-auto-enhance",
3007
+ message: "Fast decode failed · trying QR-region Auto Tone / Contrast / Color",
3008
+ ...frameDiagnostics
3009
+ });
3010
+ try {
3011
+ const recoveryStarted = nowMs();
3012
+ const frameContext = scratchCanvas.getContext("2d", { alpha: false, willReadFrequently: true });
3013
+ const capturedImageData = frameContext.getImageData(0, 0, scratchCanvas.width, scratchCanvas.height);
3014
+ const recoveryObservations = [];
3015
+ const recoveryVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
3016
+ const recovered = scanImageData(capturedImageData, {
3017
+ ...options,
3018
+ _diagnosticLabel: "qr-region-auto-enhance",
3019
+ _visionDiagnostics: recoveryVisionDiagnostics,
3020
+ autoEnhanceRecovery: true,
3021
+ autoEnhanceWhenNoGeometry: false,
3022
+ fullFrameAutoEnhanceRecovery: false,
3023
+ _observationCollector: recoveryObservations
3024
+ });
3025
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
3026
+ emitDiagnostic({
3027
+ type: "success",
3028
+ state: "decoded",
3029
+ method: recovered.recoveryMode ?? recovered.samplingMode ?? "qr-region-auto-enhance",
3030
+ elapsedMs: recoveryElapsedMs,
3031
+ message: `Recovery decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
3032
+ ...frameDiagnostics
3033
+ });
3034
+ missStreak = 0;
3035
+ observationHistory.clear();
3036
+ if (emitResult({
3037
+ ...recovered,
3038
+ cameraProgressiveRecovery: true
3039
+ }, capturedFrame, frameDiagnostics)) return;
3040
+ } catch {
3041
+ emitDiagnostic({
3042
+ type: "method",
3043
+ state: "failed",
3044
+ method: "qr-region-auto-enhance",
3045
+ message: "QR-region color recovery did not decode · trying multi-frame ECC",
3046
+ ...frameDiagnostics
3047
+ });
3048
+ // Keep the original observations for multi-frame voting below.
3049
+ }
3050
+ }
3051
+ }
3052
+
3053
+ const combined = tryMultiFrameDecode(observations);
3054
+ if (combined) {
3055
+ emitDiagnostic({
3056
+ type: "success",
3057
+ state: "decoded",
3058
+ method: "multi-frame-vote",
3059
+ message: `Multi-frame recovery decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
3060
+ ...frameDiagnostics
3061
+ });
3062
+ missStreak = 0;
3063
+ if (emitResult(combined, capturedFrame, frameDiagnostics)) return;
3064
+ } else {
3065
+ options.onScanMiss?.(error);
3066
+ }
1900
3067
  } finally {
1901
3068
  busy = false;
1902
3069
  }
@@ -1954,6 +3121,10 @@ export const internals = Object.freeze({
1954
3121
  decodeRsAdaptive,
1955
3122
  encodeProtectedHeader,
1956
3123
  decodeProtectedHeader,
3124
+ selectBestFrameObservation,
3125
+ combineFrameObservations,
3126
+ visibleVideoSourceRect,
3127
+ cropImageDataInset,
1957
3128
  HEADER_CODEWORD_CELLS,
1958
3129
  COMPACT_HEADER_CODEWORD_CELLS,
1959
3130
  CELLS_PER_BYTE,