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/README.md +1068 -1057
- package/dist/esm/quadqr.js +1270 -99
- package/dist/esm/vision.js +597 -52
- package/dist/quadqr.js +1865 -153
- package/dist/quadqr.min.js +1881 -153
- package/docs/API.md +329 -309
- package/docs/BROWSER_CDN.md +142 -137
- package/docs/GETTING_STARTED.md +174 -173
- package/docs/WASM.md +67 -67
- package/package.json +95 -95
- package/types/index.d.ts +123 -1
- package/docs/PUBLISHING.md +0 -109
package/dist/esm/quadqr.js
CHANGED
|
@@ -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
|
|
1596
|
-
return
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
1652
|
-
const
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
1808
|
+
}
|
|
1694
1809
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
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
|
|
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
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
-
|
|
1750
|
-
|
|
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
|
|
1753
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
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
|
-
//
|
|
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,
|
|
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,
|
|
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(
|
|
1842
|
-
const width = Math.max(1, Math.round(
|
|
1843
|
-
const height = Math.max(1, Math.round(
|
|
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
|
-
|
|
1849
|
-
|
|
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:
|
|
1862
|
-
height: { ideal:
|
|
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, {
|
|
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
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
-
|
|
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,
|