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