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