quadqr-js 1.0.1 → 1.2.0
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/FORMAT.md +11 -0
- package/README.md +1222 -1057
- package/SPECIFICATION.md +332 -0
- package/bin/quadqr.js +102 -13
- package/dist/esm/benchmark.js +80 -0
- package/dist/esm/node.js +13 -1
- package/dist/esm/quadqr.js +2714 -182
- package/dist/esm/vision.js +597 -52
- package/dist/quadqr.js +3315 -244
- package/dist/quadqr.min.js +3322 -241
- package/docs/API.md +545 -309
- package/docs/BROWSER_CDN.md +187 -137
- package/docs/CLI.md +150 -76
- package/docs/GETTING_STARTED.md +247 -173
- package/docs/NODE.md +137 -123
- package/docs/README.md +59 -41
- package/docs/WASM.md +67 -67
- package/package.json +100 -95
- package/types/benchmark.d.ts +4 -3
- package/types/index.d.ts +238 -2
- package/types/node.d.ts +2 -0
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++;
|
|
1494
1867
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
+
}
|
|
1885
|
+
}
|
|
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,19 +2191,57 @@ 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, computeHomography, projectPoint, 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;
|
|
1662
2220
|
const MAX_VERSION = 40;
|
|
1663
2221
|
const DEFAULT_ECC_LEVEL = "M";
|
|
1664
2222
|
|
|
2223
|
+
const RENDER_MODES = Object.freeze({
|
|
2224
|
+
SCREEN: "screen",
|
|
2225
|
+
PRINT: "print"
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
// Print-safe defaults deliberately use darker, more ink-tolerant primaries and
|
|
2229
|
+
// a true white background. Applications can still override individual colors.
|
|
2230
|
+
const PRINT_PALETTE = Object.freeze({
|
|
2231
|
+
black: "#000000",
|
|
2232
|
+
white: "#ffffff",
|
|
2233
|
+
red: "#d71932",
|
|
2234
|
+
green: "#087f3e",
|
|
2235
|
+
blue: "#174ea6"
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
const COMPRESSION_MODES = Object.freeze({
|
|
2239
|
+
NONE: "none",
|
|
2240
|
+
AUTO: "auto",
|
|
2241
|
+
LZ: "lz"
|
|
2242
|
+
});
|
|
2243
|
+
const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
|
|
2244
|
+
|
|
1665
2245
|
// Data cells are the four values 0..3. Structural white deliberately shares
|
|
1666
2246
|
// value 3 with data-white because both render and scan identically.
|
|
1667
2247
|
const CELL = Object.freeze({
|
|
@@ -1726,6 +2306,22 @@ const COMPACT_ECC_LEVELS = Object.freeze({
|
|
|
1726
2306
|
const CRC_BYTES = 4;
|
|
1727
2307
|
const TEXT_FLAG = 1;
|
|
1728
2308
|
const SECURE_FLAG = 1 << 3;
|
|
2309
|
+
const EXTENDED_PAYLOAD_FLAG = 1 << 4;
|
|
2310
|
+
const SIGNED_FLAG = 1 << 5;
|
|
2311
|
+
|
|
2312
|
+
// Internal payload extension envelope. This is deliberately not a public
|
|
2313
|
+
// payload mode or content-type system. It exists only when compression or
|
|
2314
|
+
// signing needs metadata around an otherwise normal text/binary payload.
|
|
2315
|
+
const PAYLOAD_ENVELOPE_VERSION = 2;
|
|
2316
|
+
const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
|
|
2317
|
+
const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
|
|
2318
|
+
const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
|
|
2319
|
+
const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
|
|
2320
|
+
const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
|
|
2321
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
|
|
2322
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
|
|
2323
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
|
|
2324
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
|
|
1729
2325
|
const ECC_SHIFT = 1;
|
|
1730
2326
|
const ECC_MASK = 0b00000110;
|
|
1731
2327
|
|
|
@@ -1804,9 +2400,375 @@ function readU32be(bytes, offset = 0) {
|
|
|
1804
2400
|
) >>> 0;
|
|
1805
2401
|
}
|
|
1806
2402
|
|
|
2403
|
+
function u16be(value) {
|
|
2404
|
+
const v = Number(value) >>> 0;
|
|
2405
|
+
assert(v <= 0xffff, "Value does not fit in uint16.");
|
|
2406
|
+
return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function readU16be(bytes, offset = 0) {
|
|
2410
|
+
return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
function normalizeCompressionMode(value = "none") {
|
|
2414
|
+
const key = String(value).toLowerCase();
|
|
2415
|
+
assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
|
|
2416
|
+
return key;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
function asBytes(input) {
|
|
2420
|
+
if (input instanceof Uint8Array) return input;
|
|
2421
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
2422
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
2423
|
+
return new Uint8Array(input);
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
/** Portable LZSS-style compressor used by QuadQR payload compression. */
|
|
2427
|
+
function compressPayload(input) {
|
|
2428
|
+
const bytes = asBytes(input);
|
|
2429
|
+
if (bytes.length < 4) return bytes.slice();
|
|
2430
|
+
const out = [];
|
|
2431
|
+
const recent = new Map();
|
|
2432
|
+
let pos = 0;
|
|
2433
|
+
|
|
2434
|
+
const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
|
|
2435
|
+
const remember = (i) => {
|
|
2436
|
+
const key = keyAt(i);
|
|
2437
|
+
if (key < 0) return;
|
|
2438
|
+
let list = recent.get(key);
|
|
2439
|
+
if (!list) recent.set(key, list = []);
|
|
2440
|
+
list.push(i);
|
|
2441
|
+
while (list.length > 32) list.shift();
|
|
2442
|
+
while (list.length && i - list[0] > 4095) list.shift();
|
|
2443
|
+
};
|
|
2444
|
+
|
|
2445
|
+
while (pos < bytes.length) {
|
|
2446
|
+
const flagIndex = out.length;
|
|
2447
|
+
out.push(0);
|
|
2448
|
+
let flags = 0;
|
|
2449
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
2450
|
+
let bestLength = 0;
|
|
2451
|
+
let bestOffset = 0;
|
|
2452
|
+
const key = keyAt(pos);
|
|
2453
|
+
const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
|
|
2454
|
+
for (let ci = candidates.length - 1; ci >= 0; ci--) {
|
|
2455
|
+
const candidate = candidates[ci];
|
|
2456
|
+
const offset = pos - candidate;
|
|
2457
|
+
if (offset <= 0 || offset > 4095) continue;
|
|
2458
|
+
let length = 0;
|
|
2459
|
+
while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
|
|
2460
|
+
if (length >= 3 && length > bestLength) {
|
|
2461
|
+
bestLength = length;
|
|
2462
|
+
bestOffset = offset;
|
|
2463
|
+
if (length === 18) break;
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
if (bestLength >= 3) {
|
|
2468
|
+
flags |= (1 << token);
|
|
2469
|
+
const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
|
|
2470
|
+
out.push((encoded >>> 8) & 0xff, encoded & 0xff);
|
|
2471
|
+
for (let i = 0; i < bestLength; i++) remember(pos + i);
|
|
2472
|
+
pos += bestLength;
|
|
2473
|
+
} else {
|
|
2474
|
+
out.push(bytes[pos]);
|
|
2475
|
+
remember(pos);
|
|
2476
|
+
pos++;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
out[flagIndex] = flags;
|
|
2480
|
+
}
|
|
2481
|
+
return Uint8Array.from(out);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function decompressPayload(input, expectedLength = null) {
|
|
2485
|
+
const bytes = asBytes(input);
|
|
2486
|
+
const out = [];
|
|
2487
|
+
let pos = 0;
|
|
2488
|
+
while (pos < bytes.length) {
|
|
2489
|
+
const flags = bytes[pos++];
|
|
2490
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
2491
|
+
if (flags & (1 << token)) {
|
|
2492
|
+
assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
|
|
2493
|
+
const encoded = (bytes[pos++] << 8) | bytes[pos++];
|
|
2494
|
+
const offset = (encoded >>> 4) & 0x0fff;
|
|
2495
|
+
const length = (encoded & 0x0f) + 3;
|
|
2496
|
+
assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
|
|
2497
|
+
for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
|
|
2498
|
+
} else {
|
|
2499
|
+
out.push(bytes[pos++]);
|
|
2500
|
+
}
|
|
2501
|
+
if (expectedLength != null && out.length >= expectedLength) break;
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
|
|
2505
|
+
return Uint8Array.from(out);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
function prepareCompressedBody(payload, compression = "none") {
|
|
2509
|
+
const mode = normalizeCompressionMode(compression);
|
|
2510
|
+
if (mode === "none") return { body: payload, compression: "none" };
|
|
2511
|
+
const compressed = compressPayload(payload);
|
|
2512
|
+
if (mode === "auto" && compressed.length >= payload.length - 2) {
|
|
2513
|
+
return { body: payload, compression: "none" };
|
|
2514
|
+
}
|
|
2515
|
+
return { body: compressed, compression: "lz" };
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
function normalizeSigningKeyId(value) {
|
|
2519
|
+
if (value == null || value === false || value === "") return new Uint8Array(0);
|
|
2520
|
+
const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
|
|
2521
|
+
assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
|
|
2522
|
+
return bytes;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
|
|
2526
|
+
assert(keyIdLength <= 0xff, "Signing key ID is too long.");
|
|
2527
|
+
assert(publicKeyLength <= 0xff, "Signing public key is too large.");
|
|
2528
|
+
assert(signatureLength <= 0xff, "Signature is too large.");
|
|
2529
|
+
const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
|
|
2530
|
+
header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
|
|
2531
|
+
header[4] = version;
|
|
2532
|
+
header[5] = flags & 0xff;
|
|
2533
|
+
header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
|
|
2534
|
+
header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
|
|
2535
|
+
header.set(u32be(originalLength), 8);
|
|
2536
|
+
header[12] = keyIdLength;
|
|
2537
|
+
header[13] = publicKeyLength;
|
|
2538
|
+
header[14] = signatureLength;
|
|
2539
|
+
header[15] = 0;
|
|
2540
|
+
return header;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function isPayloadEnvelope(input) {
|
|
2544
|
+
const bytes = asBytes(input);
|
|
2545
|
+
return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
function packPayloadEnvelope(input, options = {}) {
|
|
2549
|
+
const payload = asBytes(input);
|
|
2550
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "none");
|
|
2551
|
+
const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
|
|
2552
|
+
const header = makePayloadEnvelopeHeader({
|
|
2553
|
+
compression: prepared.compression,
|
|
2554
|
+
flags,
|
|
2555
|
+
originalLength: payload.length
|
|
2556
|
+
});
|
|
2557
|
+
return concatBytes(header, prepared.body);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
function parsePayloadEnvelope(input) {
|
|
2561
|
+
const container = asBytes(input);
|
|
2562
|
+
assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
|
|
2563
|
+
const version = container[4];
|
|
2564
|
+
assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
|
|
2565
|
+
const flags = container[5];
|
|
2566
|
+
const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
|
|
2567
|
+
assert(compression != null, `Unknown payload compression id ${container[6]}.`);
|
|
2568
|
+
const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
|
|
2569
|
+
const originalLength = readU32be(container, 8);
|
|
2570
|
+
const metadataLength = container[12];
|
|
2571
|
+
const publicKeyLength = container[13];
|
|
2572
|
+
const signatureLength = container[14];
|
|
2573
|
+
let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
|
|
2574
|
+
const endMetadata = offset + metadataLength;
|
|
2575
|
+
const endPublicKey = endMetadata + publicKeyLength;
|
|
2576
|
+
const endSignature = endPublicKey + signatureLength;
|
|
2577
|
+
assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
|
|
2578
|
+
const metadataBytes = container.slice(offset, endMetadata);
|
|
2579
|
+
const publicKey = container.slice(endMetadata, endPublicKey);
|
|
2580
|
+
const signature = container.slice(endPublicKey, endSignature);
|
|
2581
|
+
const stored = container.slice(endSignature);
|
|
2582
|
+
const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
|
|
2583
|
+
assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
|
|
2584
|
+
const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
|
|
2585
|
+
const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
|
|
2586
|
+
|
|
2587
|
+
// v1 used byte 12 for a human-readable signer label and always embedded
|
|
2588
|
+
// the public key. v2 uses the same field for a compact key ID and makes
|
|
2589
|
+
// public-key embedding optional.
|
|
2590
|
+
const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
2591
|
+
const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
2592
|
+
const keyIdHex = version >= 2 && metadataLength
|
|
2593
|
+
? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
|
|
2594
|
+
: null;
|
|
2595
|
+
|
|
2596
|
+
return {
|
|
2597
|
+
container,
|
|
2598
|
+
version,
|
|
2599
|
+
compression,
|
|
2600
|
+
compressed: compression !== "none",
|
|
2601
|
+
originalBytes: originalLength,
|
|
2602
|
+
storedBytes: stored.length,
|
|
2603
|
+
savedBytes: originalLength - stored.length,
|
|
2604
|
+
payload,
|
|
2605
|
+
signed,
|
|
2606
|
+
signer: legacySigner,
|
|
2607
|
+
keyId,
|
|
2608
|
+
keyIdHex,
|
|
2609
|
+
publicKey,
|
|
2610
|
+
hasEmbeddedPublicKey: publicKey.length > 0,
|
|
2611
|
+
signature,
|
|
2612
|
+
signatureAlgorithm,
|
|
2613
|
+
signedBytes,
|
|
2614
|
+
signatureVerified: signed ? null : undefined,
|
|
2615
|
+
signatureTrusted: signed ? null : undefined
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
function subtleCrypto() {
|
|
2620
|
+
const subtle = globalThis.crypto?.subtle;
|
|
2621
|
+
assert(subtle, "Web Crypto is required for QuadQR signing.");
|
|
2622
|
+
return subtle;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
async function normalizeEd25519PublicKey(key) {
|
|
2626
|
+
const subtle = subtleCrypto();
|
|
2627
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
2628
|
+
if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
|
|
2629
|
+
const bytes = asBytes(key);
|
|
2630
|
+
assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
|
|
2631
|
+
return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
async function normalizeEd25519PrivateKey(key) {
|
|
2635
|
+
const subtle = subtleCrypto();
|
|
2636
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
2637
|
+
if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
|
|
2638
|
+
const bytes = asBytes(key);
|
|
2639
|
+
return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
async function deriveSigningKeyId(publicKey, bytes = 8) {
|
|
2643
|
+
const subtle = subtleCrypto();
|
|
2644
|
+
const normalized = await normalizeEd25519PublicKey(publicKey);
|
|
2645
|
+
const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
|
|
2646
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
|
|
2647
|
+
const count = Math.max(4, Math.min(16, Math.floor(bytes)));
|
|
2648
|
+
return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
async function generateSigningKeyPair() {
|
|
2652
|
+
const subtle = subtleCrypto();
|
|
2653
|
+
const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
2654
|
+
const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
|
|
2655
|
+
const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
|
|
2656
|
+
const keyId = await deriveSigningKeyId(pair.publicKey);
|
|
2657
|
+
return {
|
|
2658
|
+
privateKey: pair.privateKey,
|
|
2659
|
+
publicKey: pair.publicKey,
|
|
2660
|
+
privateKeyPkcs8,
|
|
2661
|
+
publicKeyBytes,
|
|
2662
|
+
keyId,
|
|
2663
|
+
algorithm: "Ed25519"
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
async function packSignedPayloadEnvelope(input, options = {}) {
|
|
2668
|
+
const payload = asBytes(input);
|
|
2669
|
+
assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
|
|
2670
|
+
const subtle = subtleCrypto();
|
|
2671
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
|
|
2672
|
+
const keyIdBytes = normalizeSigningKeyId(options.keyId);
|
|
2673
|
+
|
|
2674
|
+
let publicKeyBytes = new Uint8Array(0);
|
|
2675
|
+
if (options.embedPublicKey) {
|
|
2676
|
+
assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
|
|
2677
|
+
const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
|
|
2678
|
+
publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
|
|
2682
|
+
(prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
|
|
2683
|
+
(publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
|
|
2684
|
+
const header = makePayloadEnvelopeHeader({
|
|
2685
|
+
compression: prepared.compression,
|
|
2686
|
+
flags,
|
|
2687
|
+
originalLength: payload.length,
|
|
2688
|
+
keyIdLength: keyIdBytes.length,
|
|
2689
|
+
publicKeyLength: publicKeyBytes.length,
|
|
2690
|
+
signatureLength: 64,
|
|
2691
|
+
signatureAlgorithm: "Ed25519"
|
|
2692
|
+
});
|
|
2693
|
+
const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
|
|
2694
|
+
const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
|
|
2695
|
+
const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
|
|
2696
|
+
assert(signature.length === 64, "Unexpected Ed25519 signature length.");
|
|
2697
|
+
return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
function lookupTrustedSigningKey(trustedKeys, keyId) {
|
|
2701
|
+
if (!trustedKeys || !keyId) return null;
|
|
2702
|
+
if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
|
|
2703
|
+
if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
|
|
2704
|
+
return null;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
2708
|
+
const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
|
|
2709
|
+
assert(parsed.signed, "QuadQR payload is not signed.");
|
|
2710
|
+
assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
|
|
2711
|
+
const subtle = subtleCrypto();
|
|
2712
|
+
|
|
2713
|
+
let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
|
|
2714
|
+
let trustSource = trusted ? "external" : null;
|
|
2715
|
+
|
|
2716
|
+
// Legacy v1 symbols embedded the public key by default, so preserve their
|
|
2717
|
+
// historical self-verification behavior. v2 requires explicit opt-in.
|
|
2718
|
+
if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
|
|
2719
|
+
trusted = parsed.publicKey;
|
|
2720
|
+
trustSource = "embedded";
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
assert(
|
|
2724
|
+
trusted,
|
|
2725
|
+
parsed.keyId
|
|
2726
|
+
? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
|
|
2727
|
+
: "A trusted Ed25519 public key is required to verify this signed QuadQR."
|
|
2728
|
+
);
|
|
2729
|
+
|
|
2730
|
+
const publicKey = await normalizeEd25519PublicKey(trusted);
|
|
2731
|
+
if (trustSource === "external" && parsed.publicKey.length) {
|
|
2732
|
+
const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
|
|
2733
|
+
assert(
|
|
2734
|
+
trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
|
|
2735
|
+
"Embedded signing key does not match the trusted public key."
|
|
2736
|
+
);
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
|
|
2740
|
+
return {
|
|
2741
|
+
verified,
|
|
2742
|
+
trusted: verified && trustSource === "external",
|
|
2743
|
+
trustSource,
|
|
2744
|
+
keyId: parsed.keyId ?? null
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
function prepareOptionalCompressedPayload(input, compression = "none") {
|
|
2749
|
+
const payload = asBytes(input);
|
|
2750
|
+
const mode = normalizeCompressionMode(compression);
|
|
2751
|
+
if (mode === "none") return { payload, extended: false, compression: "none" };
|
|
2752
|
+
const prepared = prepareCompressedBody(payload, mode);
|
|
2753
|
+
// Auto mode is truly zero-overhead when compression does not help.
|
|
2754
|
+
if (mode === "auto" && prepared.compression === "none") {
|
|
2755
|
+
return { payload, extended: false, compression: "none" };
|
|
2756
|
+
}
|
|
2757
|
+
return {
|
|
2758
|
+
payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
|
|
2759
|
+
extended: true,
|
|
2760
|
+
compression: prepared.compression
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
|
|
1807
2764
|
let CRC_TABLE = null;
|
|
1808
2765
|
let CRC32_ACCELERATOR = null;
|
|
1809
2766
|
|
|
2767
|
+
/**
|
|
2768
|
+
* Install or clear an optional synchronous CRC-32 accelerator.
|
|
2769
|
+
* QuadQR's prebuilt WASM helper uses this hook after initWasm() succeeds.
|
|
2770
|
+
* The JavaScript implementation remains the default and fallback.
|
|
2771
|
+
*/
|
|
1810
2772
|
function installCrc32Accelerator(accelerator = null) {
|
|
1811
2773
|
assert(
|
|
1812
2774
|
accelerator == null || typeof accelerator === "function",
|
|
@@ -1863,9 +2825,11 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
|
|
|
1863
2825
|
return out;
|
|
1864
2826
|
}
|
|
1865
2827
|
|
|
1866
|
-
function flagsFor(text, eccLevel, secure = false) {
|
|
2828
|
+
function flagsFor(text, eccLevel, secure = false, extended = false, signed = false) {
|
|
1867
2829
|
return (text ? TEXT_FLAG : 0) |
|
|
1868
2830
|
(secure ? SECURE_FLAG : 0) |
|
|
2831
|
+
(extended ? EXTENDED_PAYLOAD_FLAG : 0) |
|
|
2832
|
+
(signed ? SIGNED_FLAG : 0) |
|
|
1869
2833
|
((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
|
|
1870
2834
|
}
|
|
1871
2835
|
|
|
@@ -2405,6 +3369,11 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
2405
3369
|
payloadBytes: meta.payloadBytes,
|
|
2406
3370
|
sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
|
|
2407
3371
|
secure: Boolean(meta.secure),
|
|
3372
|
+
compressed: meta.compression != null && meta.compression !== "none",
|
|
3373
|
+
compression: meta.compression ?? "none",
|
|
3374
|
+
signed: Boolean(meta.signed),
|
|
3375
|
+
signingKeyId: meta.signingKeyId ?? null,
|
|
3376
|
+
hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
|
|
2408
3377
|
requiresDecryption: Boolean(meta.secure),
|
|
2409
3378
|
security: meta.security ?? null,
|
|
2410
3379
|
meaningfulCells: meta.meaningfulCells,
|
|
@@ -2423,20 +3392,17 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
2423
3392
|
};
|
|
2424
3393
|
}
|
|
2425
3394
|
|
|
2426
|
-
function
|
|
2427
|
-
assert(typeof text === "string", "encodeText expects a string.");
|
|
2428
|
-
return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
function encodeBytes(input, options = {}) {
|
|
3395
|
+
function encodePreparedBytes(input, options = {}) {
|
|
2432
3396
|
if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
|
|
2433
3397
|
throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
|
|
2434
3398
|
}
|
|
2435
3399
|
|
|
2436
|
-
const payload =
|
|
3400
|
+
const payload = asBytes(input);
|
|
2437
3401
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
2438
3402
|
const secure = Boolean(options.secure);
|
|
2439
|
-
const
|
|
3403
|
+
const extended = Boolean(options.extended);
|
|
3404
|
+
const signed = Boolean(options.signed);
|
|
3405
|
+
const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed);
|
|
2440
3406
|
const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
|
|
2441
3407
|
const layout = createLayout(version);
|
|
2442
3408
|
const header = makeHeader(payload.length, flags, version);
|
|
@@ -2456,6 +3422,11 @@ function encodeBytes(input, options = {}) {
|
|
|
2456
3422
|
payloadBytes: payload.length,
|
|
2457
3423
|
sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
|
|
2458
3424
|
secure,
|
|
3425
|
+
extended,
|
|
3426
|
+
signed,
|
|
3427
|
+
compression: options.compressionMetadata ?? "none",
|
|
3428
|
+
signingKeyId: options.signingKeyId ?? null,
|
|
3429
|
+
hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
|
|
2459
3430
|
security: options.securityMetadata ?? null,
|
|
2460
3431
|
meaningfulCells: meaningfulCells.length,
|
|
2461
3432
|
eccLevel,
|
|
@@ -2466,43 +3437,138 @@ function encodeBytes(input, options = {}) {
|
|
|
2466
3437
|
});
|
|
2467
3438
|
}
|
|
2468
3439
|
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
3440
|
+
function encodeText(text, options = {}) {
|
|
3441
|
+
assert(typeof text === "string", "encodeText expects a string.");
|
|
3442
|
+
const source = getTextEncoder().encode(text);
|
|
3443
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
3444
|
+
return encodePreparedBytes(prepared.payload, {
|
|
3445
|
+
...options,
|
|
3446
|
+
text: true,
|
|
3447
|
+
extended: prepared.extended,
|
|
3448
|
+
sourcePayloadBytes: source.length,
|
|
3449
|
+
compressionMetadata: prepared.compression
|
|
3450
|
+
});
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
function encodeBytes(input, options = {}) {
|
|
3454
|
+
const source = asBytes(input);
|
|
3455
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
3456
|
+
return encodePreparedBytes(prepared.payload, {
|
|
3457
|
+
...options,
|
|
3458
|
+
text: Boolean(options.text),
|
|
3459
|
+
extended: prepared.extended,
|
|
3460
|
+
sourcePayloadBytes: source.length,
|
|
3461
|
+
compressionMetadata: prepared.compression
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
/** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
|
|
3466
|
+
function encodeUint8Array(input, options = {}) {
|
|
3467
|
+
return encodeBytes(asBytes(input), options);
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
/** Encode and sign a normal payload using an internal Ed25519 envelope. */
|
|
3471
|
+
async function encodeSignedBytes(input, options = {}) {
|
|
3472
|
+
const source = asBytes(input);
|
|
3473
|
+
const envelope = await packSignedPayloadEnvelope(source, options);
|
|
3474
|
+
const parsed = parsePayloadEnvelope(envelope);
|
|
3475
|
+
return encodePreparedBytes(envelope, {
|
|
3476
|
+
...options,
|
|
3477
|
+
text: Boolean(options.text),
|
|
3478
|
+
extended: true,
|
|
3479
|
+
signed: true,
|
|
3480
|
+
sourcePayloadBytes: source.length,
|
|
3481
|
+
compressionMetadata: parsed.compression,
|
|
3482
|
+
signingKeyId: parsed.keyId,
|
|
3483
|
+
hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
|
|
3484
|
+
});
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
async function encodeSignedText(text, options = {}) {
|
|
3488
|
+
assert(typeof text === "string", "encodeSignedText expects a string.");
|
|
3489
|
+
return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
/**
|
|
3493
|
+
* Encode text using the optional QuadQR Secure Payload v1 layer.
|
|
3494
|
+
* This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
|
|
3495
|
+
*/
|
|
3496
|
+
async function encodeSecureText(text, options = {}) {
|
|
3497
|
+
assert(typeof text === "string", "encodeSecureText expects a string.");
|
|
3498
|
+
return encodeSecureBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
2476
3499
|
}
|
|
2477
3500
|
|
|
2478
3501
|
/** Encode arbitrary bytes using password or raw 256-bit key security. */
|
|
2479
3502
|
async function encodeSecureBytes(input, options = {}) {
|
|
2480
|
-
const sourcePayload =
|
|
3503
|
+
const sourcePayload = asBytes(input);
|
|
2481
3504
|
const security = options.security ?? {};
|
|
2482
|
-
|
|
2483
|
-
|
|
3505
|
+
let protectedPayload = sourcePayload;
|
|
3506
|
+
let extended = false;
|
|
3507
|
+
let signed = false;
|
|
3508
|
+
let compression = "none";
|
|
3509
|
+
let signingKeyId = null;
|
|
3510
|
+
let hasEmbeddedPublicKey = false;
|
|
3511
|
+
|
|
3512
|
+
if (options.signing) {
|
|
3513
|
+
protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
|
|
3514
|
+
...options,
|
|
3515
|
+
...options.signing,
|
|
3516
|
+
compression: options.compression ?? "auto"
|
|
3517
|
+
});
|
|
3518
|
+
const parsed = parsePayloadEnvelope(protectedPayload);
|
|
3519
|
+
extended = true;
|
|
3520
|
+
signed = true;
|
|
3521
|
+
compression = parsed.compression;
|
|
3522
|
+
signingKeyId = parsed.keyId;
|
|
3523
|
+
hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
|
|
3524
|
+
} else {
|
|
3525
|
+
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
|
|
3526
|
+
protectedPayload = prepared.payload;
|
|
3527
|
+
extended = prepared.extended;
|
|
3528
|
+
compression = prepared.compression;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
const encrypted = await encryptSecurePayload(protectedPayload, security);
|
|
3532
|
+
return encodePreparedBytes(encrypted.envelope, {
|
|
2484
3533
|
...options,
|
|
2485
3534
|
text: Boolean(options.text),
|
|
2486
3535
|
secure: true,
|
|
3536
|
+
extended,
|
|
3537
|
+
signed,
|
|
2487
3538
|
sourcePayloadBytes: sourcePayload.length,
|
|
3539
|
+
compressionMetadata: compression,
|
|
3540
|
+
signingKeyId,
|
|
3541
|
+
hasEmbeddedPublicKey,
|
|
2488
3542
|
securityMetadata: encrypted.metadata
|
|
2489
3543
|
});
|
|
2490
3544
|
}
|
|
2491
3545
|
|
|
2492
3546
|
/**
|
|
2493
3547
|
* Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
|
|
2494
|
-
*
|
|
3548
|
+
* Compression and signatures are automatically unwrapped after decryption.
|
|
2495
3549
|
*/
|
|
2496
3550
|
async function decryptDecoded(result, security = {}) {
|
|
2497
3551
|
assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
|
|
2498
3552
|
const plaintext = await decryptSecurePayload(result.payload, security);
|
|
2499
3553
|
const isText = (result.flags & TEXT_FLAG) !== 0;
|
|
3554
|
+
const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
3555
|
+
const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
|
|
3556
|
+
const payload = envelope ? envelope.payload : plaintext;
|
|
2500
3557
|
return {
|
|
2501
3558
|
...result,
|
|
2502
3559
|
encryptedPayload: result.payload,
|
|
2503
3560
|
encryptedPayloadBytes: result.payload.length,
|
|
2504
|
-
|
|
2505
|
-
|
|
3561
|
+
protectedPayload: envelope ? plaintext : undefined,
|
|
3562
|
+
payload,
|
|
3563
|
+
text: isText ? getTextDecoder().decode(payload) : null,
|
|
3564
|
+
compressed: envelope?.compressed ?? false,
|
|
3565
|
+
compression: envelope?.compression ?? "none",
|
|
3566
|
+
signed: envelope?.signed ?? result.signed ?? false,
|
|
3567
|
+
signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
|
|
3568
|
+
signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
|
|
3569
|
+
signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
|
|
3570
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
|
|
3571
|
+
signer: envelope?.signer ?? result.signer ?? null,
|
|
2506
3572
|
decrypted: true,
|
|
2507
3573
|
requiresDecryption: false,
|
|
2508
3574
|
security: { ...result.security, decrypted: true }
|
|
@@ -2773,7 +3839,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
2773
3839
|
|
|
2774
3840
|
const isText = (flags & TEXT_FLAG) !== 0;
|
|
2775
3841
|
const secure = (flags & SECURE_FLAG) !== 0;
|
|
3842
|
+
const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
3843
|
+
const signedFlag = (flags & SIGNED_FLAG) !== 0;
|
|
2776
3844
|
const security = secure ? inspectSecureEnvelope(payload) : null;
|
|
3845
|
+
const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
|
|
3846
|
+
const applicationPayload = envelope ? envelope.payload : payload;
|
|
2777
3847
|
const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
|
|
2778
3848
|
const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
|
|
2779
3849
|
return {
|
|
@@ -2798,8 +3868,17 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
2798
3868
|
correctedHeaderSymbols: headerDecoded.correctedSymbols,
|
|
2799
3869
|
correctedBodySymbols: bodyDecoded.correctedSymbols,
|
|
2800
3870
|
correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
|
|
2801
|
-
payload,
|
|
2802
|
-
|
|
3871
|
+
protectedPayload: envelope ? payload : undefined,
|
|
3872
|
+
payload: secure ? payload : applicationPayload,
|
|
3873
|
+
text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
|
|
3874
|
+
compressed: envelope?.compressed ?? false,
|
|
3875
|
+
compression: envelope?.compression ?? "none",
|
|
3876
|
+
signed: envelope?.signed ?? signedFlag,
|
|
3877
|
+
signatureVerified: envelope?.signatureVerified,
|
|
3878
|
+
signatureTrusted: envelope?.signatureTrusted,
|
|
3879
|
+
signingKeyId: envelope?.keyId ?? null,
|
|
3880
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
|
|
3881
|
+
signer: envelope?.signer ?? null,
|
|
2803
3882
|
secure,
|
|
2804
3883
|
encrypted: secure,
|
|
2805
3884
|
decrypted: false,
|
|
@@ -2844,6 +3923,26 @@ function decodeMatrix(inputMatrix, options = {}) {
|
|
|
2844
3923
|
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
2845
3924
|
}
|
|
2846
3925
|
|
|
3926
|
+
/** Decode a matrix and return only its application payload bytes. */
|
|
3927
|
+
function decodeUint8Array(inputMatrix, options = {}) {
|
|
3928
|
+
return decodeMatrix(inputMatrix, options).payload;
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
/** Verify a signed decode result and return the result with verification state attached. */
|
|
3932
|
+
async function verifyDecodedSignature(result, options = {}) {
|
|
3933
|
+
assert(result?.signed, "Decode result is not signed.");
|
|
3934
|
+
const source = result.protectedPayload;
|
|
3935
|
+
assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
|
|
3936
|
+
const verification = await verifyPayloadEnvelopeSignature(source, options);
|
|
3937
|
+
return {
|
|
3938
|
+
...result,
|
|
3939
|
+
signatureVerified: verification.verified,
|
|
3940
|
+
signatureTrusted: verification.trusted,
|
|
3941
|
+
signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
|
|
3942
|
+
signatureTrustSource: verification.trustSource
|
|
3943
|
+
};
|
|
3944
|
+
}
|
|
3945
|
+
|
|
2847
3946
|
function hexToRgb(hex) {
|
|
2848
3947
|
const clean = hex.replace("#", "");
|
|
2849
3948
|
assert(clean.length === 6, `Invalid hex color ${hex}.`);
|
|
@@ -2858,6 +3957,18 @@ function resolvePalette(palette = {}) {
|
|
|
2858
3957
|
return { ...DEFAULT_PALETTE, ...palette };
|
|
2859
3958
|
}
|
|
2860
3959
|
|
|
3960
|
+
function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
|
|
3961
|
+
const value = String(mode).toLowerCase();
|
|
3962
|
+
assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
|
|
3963
|
+
return value;
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
function resolveRenderPalette(options = {}) {
|
|
3967
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
3968
|
+
const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
|
|
3969
|
+
return { ...base, ...(options.palette ?? {}) };
|
|
3970
|
+
}
|
|
3971
|
+
|
|
2861
3972
|
function paletteRgb(palette = {}) {
|
|
2862
3973
|
const p = resolvePalette(palette);
|
|
2863
3974
|
return {
|
|
@@ -3021,18 +4132,346 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
|
|
|
3021
4132
|
}
|
|
3022
4133
|
}
|
|
3023
4134
|
|
|
4135
|
+
/** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
|
|
4136
|
+
function estimateSafeLogoSize(codeOrMatrix, options = {}) {
|
|
4137
|
+
const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
|
|
4138
|
+
const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
|
|
4139
|
+
const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
|
|
4140
|
+
const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
|
|
4141
|
+
const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
|
|
4142
|
+
const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
|
|
4143
|
+
const clearPenalty = clearBackground ? 0.90 : 1;
|
|
4144
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
4145
|
+
const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
|
|
4146
|
+
const versionPenalty = code?.version === 1 ? 0.88 : 1;
|
|
4147
|
+
return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4150
|
+
/**
|
|
4151
|
+
* Empirically search for the largest logo ratio that still decodes from the
|
|
4152
|
+
* dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
|
|
4153
|
+
*/
|
|
4154
|
+
function findMaxSafeLogoSize(code, options = {}) {
|
|
4155
|
+
assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
|
|
4156
|
+
const logo = options.logo;
|
|
4157
|
+
const source = logo?.source ?? logo;
|
|
4158
|
+
assert(source?.data && source?.width && source?.height,
|
|
4159
|
+
"findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
|
|
4160
|
+
let low = Math.max(0.05, Number(options.minSize ?? 0.05));
|
|
4161
|
+
let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
|
|
4162
|
+
let best = 0;
|
|
4163
|
+
const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
|
|
4164
|
+
for (let i = 0; i < iterations; i++) {
|
|
4165
|
+
const probe = (low + high) / 2;
|
|
4166
|
+
try {
|
|
4167
|
+
const image = renderToImageData(code, {
|
|
4168
|
+
...options,
|
|
4169
|
+
logo: {
|
|
4170
|
+
...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
|
|
4171
|
+
size: probe
|
|
4172
|
+
}
|
|
4173
|
+
});
|
|
4174
|
+
const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
|
|
4175
|
+
if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
|
|
4176
|
+
best = probe;
|
|
4177
|
+
low = probe;
|
|
4178
|
+
} catch {
|
|
4179
|
+
high = probe;
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
return {
|
|
4183
|
+
safeSize: best || estimateSafeLogoSize(code, options),
|
|
4184
|
+
testedMax: Number(options.maxSize ?? 0.28),
|
|
4185
|
+
iterations,
|
|
4186
|
+
empirical: best > 0
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
function getPrintGuidance(codeOrMatrix, options = {}) {
|
|
4191
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
|
|
4192
|
+
assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
|
|
4193
|
+
const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
|
|
4194
|
+
const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
|
|
4195
|
+
const totalModules = matrix.length + quietZone * 2;
|
|
4196
|
+
const moduleSizeMm = physicalSizeMm / totalModules;
|
|
4197
|
+
const dpi = Number(options.dpi ?? 300);
|
|
4198
|
+
const modulePixels = moduleSizeMm / 25.4 * dpi;
|
|
4199
|
+
const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
|
|
4200
|
+
return {
|
|
4201
|
+
mode: "print",
|
|
4202
|
+
physicalSizeMm,
|
|
4203
|
+
quietZone,
|
|
4204
|
+
moduleSizeMm,
|
|
4205
|
+
modulePixelsAtDpi: modulePixels,
|
|
4206
|
+
dpi,
|
|
4207
|
+
recommendedMinimumModuleMm,
|
|
4208
|
+
meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
|
|
4209
|
+
recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
|
|
4210
|
+
palette: PRINT_PALETTE,
|
|
4211
|
+
recommendations: [
|
|
4212
|
+
"Use classic rendering for print unless a styled output has been physically validated.",
|
|
4213
|
+
"Keep at least a 4-module quiet zone.",
|
|
4214
|
+
`Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
|
|
4215
|
+
"Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
|
|
4216
|
+
]
|
|
4217
|
+
};
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
|
|
4221
|
+
if (!options.logo) return null;
|
|
4222
|
+
|
|
4223
|
+
const nested = (
|
|
4224
|
+
options.logo &&
|
|
4225
|
+
typeof options.logo === "object" &&
|
|
4226
|
+
Object.prototype.hasOwnProperty.call(options.logo, "source")
|
|
4227
|
+
) ? options.logo : null;
|
|
4228
|
+
const source = nested ? nested.source : options.logo;
|
|
4229
|
+
if (!source) return null;
|
|
4230
|
+
|
|
4231
|
+
const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
|
|
4232
|
+
const autoSize = String(requestedSize).toLowerCase() === "auto";
|
|
4233
|
+
const rawSize = Number(requestedSize);
|
|
4234
|
+
const sizeRatio = autoSize
|
|
4235
|
+
? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
|
|
4236
|
+
: Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
|
|
4237
|
+
const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
|
|
4238
|
+
const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
|
|
4239
|
+
const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
|
|
4240
|
+
const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
|
|
4241
|
+
const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
|
|
4242
|
+
const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
|
|
4243
|
+
|
|
4244
|
+
const codePixels = matrixSize * moduleSize;
|
|
4245
|
+
const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
|
|
4246
|
+
const logoPixels = Math.max(1, codePixels * sizeRatio);
|
|
4247
|
+
const center = canvasPixels / 2;
|
|
4248
|
+
const paddingPixels = paddingModules * moduleSize;
|
|
4249
|
+
const backgroundPixels = logoPixels + paddingPixels * 2;
|
|
4250
|
+
|
|
4251
|
+
return {
|
|
4252
|
+
source,
|
|
4253
|
+
sizeRatio,
|
|
4254
|
+
clearBackground,
|
|
4255
|
+
backgroundColor,
|
|
4256
|
+
logoX: center - logoPixels / 2,
|
|
4257
|
+
logoY: center - logoPixels / 2,
|
|
4258
|
+
logoSize: logoPixels,
|
|
4259
|
+
backgroundX: center - backgroundPixels / 2,
|
|
4260
|
+
backgroundY: center - backgroundPixels / 2,
|
|
4261
|
+
backgroundSize: backgroundPixels,
|
|
4262
|
+
backgroundRadius: radiusModules * moduleSize
|
|
4263
|
+
};
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
function canvasLogoSource(source) {
|
|
4267
|
+
if (!source || typeof source === "string") return source;
|
|
4268
|
+
if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
|
|
4269
|
+
let scratch = null;
|
|
4270
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
4271
|
+
scratch = new OffscreenCanvas(source.width, source.height);
|
|
4272
|
+
} else if (typeof document !== "undefined") {
|
|
4273
|
+
scratch = document.createElement("canvas");
|
|
4274
|
+
scratch.width = source.width;
|
|
4275
|
+
scratch.height = source.height;
|
|
4276
|
+
}
|
|
4277
|
+
if (!scratch) return source;
|
|
4278
|
+
const ctx = scratch.getContext("2d");
|
|
4279
|
+
if (!ctx) return source;
|
|
4280
|
+
const pixels = source.data instanceof Uint8ClampedArray
|
|
4281
|
+
? source.data
|
|
4282
|
+
: new Uint8ClampedArray(source.data);
|
|
4283
|
+
ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
|
|
4284
|
+
return scratch;
|
|
4285
|
+
}
|
|
4286
|
+
return source;
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
function logoSourceDimensions(source) {
|
|
4290
|
+
const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
|
|
4291
|
+
const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
|
|
4292
|
+
if (!(Number(width) > 0 && Number(height) > 0)) return null;
|
|
4293
|
+
return { width: Number(width), height: Number(height) };
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
function containRect(x, y, size, dimensions) {
|
|
4297
|
+
if (!dimensions) return { x, y, width: size, height: size };
|
|
4298
|
+
const scale = Math.min(size / dimensions.width, size / dimensions.height);
|
|
4299
|
+
const width = dimensions.width * scale;
|
|
4300
|
+
const height = dimensions.height * scale;
|
|
4301
|
+
return {
|
|
4302
|
+
x: x + (size - width) / 2,
|
|
4303
|
+
y: y + (size - height) / 2,
|
|
4304
|
+
width,
|
|
4305
|
+
height
|
|
4306
|
+
};
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
function drawLogoToCanvas(ctx, logo, palette) {
|
|
4310
|
+
if (!logo) return;
|
|
4311
|
+
if (logo.clearBackground) {
|
|
4312
|
+
ctx.fillStyle = logo.backgroundColor || palette.white;
|
|
4313
|
+
roundedRectPath(
|
|
4314
|
+
ctx,
|
|
4315
|
+
logo.backgroundX,
|
|
4316
|
+
logo.backgroundY,
|
|
4317
|
+
logo.backgroundSize,
|
|
4318
|
+
logo.backgroundSize,
|
|
4319
|
+
logo.backgroundRadius
|
|
4320
|
+
);
|
|
4321
|
+
ctx.fill();
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
const source = canvasLogoSource(logo.source);
|
|
4325
|
+
assert(
|
|
4326
|
+
source && typeof source !== "string",
|
|
4327
|
+
"renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
|
|
4328
|
+
);
|
|
4329
|
+
const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
|
|
4330
|
+
const previousSmoothing = ctx.imageSmoothingEnabled;
|
|
4331
|
+
ctx.imageSmoothingEnabled = true;
|
|
4332
|
+
ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
|
|
4333
|
+
ctx.imageSmoothingEnabled = previousSmoothing;
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4336
|
+
function blendImagePixel(data, width, x, y, rgb, alpha) {
|
|
4337
|
+
if (x < 0 || y < 0 || x >= width) return;
|
|
4338
|
+
const height = data.length / 4 / width;
|
|
4339
|
+
if (y >= height) return;
|
|
4340
|
+
const p = (y * width + x) * 4;
|
|
4341
|
+
const a = Math.max(0, Math.min(1, alpha));
|
|
4342
|
+
data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
|
|
4343
|
+
data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
|
|
4344
|
+
data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
|
|
4345
|
+
data[p + 3] = 255;
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
function drawLogoToImageData(data, width, logo) {
|
|
4349
|
+
if (!logo) return;
|
|
4350
|
+
if (logo.clearBackground) {
|
|
4351
|
+
const rgb = hexToRgb(logo.backgroundColor);
|
|
4352
|
+
fillImageRoundedRect(
|
|
4353
|
+
data,
|
|
4354
|
+
width,
|
|
4355
|
+
Math.round(logo.backgroundX),
|
|
4356
|
+
Math.round(logo.backgroundY),
|
|
4357
|
+
Math.round(logo.backgroundSize),
|
|
4358
|
+
Math.round(logo.backgroundSize),
|
|
4359
|
+
Math.round(logo.backgroundRadius),
|
|
4360
|
+
rgb
|
|
4361
|
+
);
|
|
4362
|
+
}
|
|
4363
|
+
|
|
4364
|
+
const source = logo.source;
|
|
4365
|
+
assert(
|
|
4366
|
+
source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
|
|
4367
|
+
"renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
|
|
4368
|
+
);
|
|
4369
|
+
const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
|
|
4370
|
+
width: source.width,
|
|
4371
|
+
height: source.height
|
|
4372
|
+
});
|
|
4373
|
+
const x0 = Math.round(dest.x);
|
|
4374
|
+
const y0 = Math.round(dest.y);
|
|
4375
|
+
const destWidth = Math.max(1, Math.round(dest.width));
|
|
4376
|
+
const destHeight = Math.max(1, Math.round(dest.height));
|
|
4377
|
+
const src = source.data;
|
|
4378
|
+
|
|
4379
|
+
for (let y = 0; y < destHeight; y++) {
|
|
4380
|
+
const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
|
|
4381
|
+
for (let x = 0; x < destWidth; x++) {
|
|
4382
|
+
const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
|
|
4383
|
+
const p = (sy * source.width + sx) * 4;
|
|
4384
|
+
const alpha = (src[p + 3] ?? 255) / 255;
|
|
4385
|
+
if (alpha <= 0) continue;
|
|
4386
|
+
blendImagePixel(data, width, x0 + x, y0 + y, {
|
|
4387
|
+
r: src[p],
|
|
4388
|
+
g: src[p + 1],
|
|
4389
|
+
b: src[p + 2]
|
|
4390
|
+
}, alpha);
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
function escapeXmlAttribute(value) {
|
|
4396
|
+
return String(value)
|
|
4397
|
+
.replaceAll("&", "&")
|
|
4398
|
+
.replaceAll('"', """)
|
|
4399
|
+
.replaceAll("<", "<")
|
|
4400
|
+
.replaceAll(">", ">");
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
function svgRect(x, y, width, height, fill, radius = 0) {
|
|
4404
|
+
const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
|
|
4405
|
+
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
function svgLogoHref(source) {
|
|
4409
|
+
if (typeof source === "string") return source;
|
|
4410
|
+
if (typeof source?.src === "string" && source.src) return source.src;
|
|
4411
|
+
return null;
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
function resolveRenderSizing(options, matrixSize) {
|
|
4415
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
4416
|
+
const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
|
|
4417
|
+
const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
|
|
4418
|
+
? Math.max(4, requestedQuietZone)
|
|
4419
|
+
: requestedQuietZone;
|
|
4420
|
+
const totalModules = matrixSize + quietZone * 2;
|
|
4421
|
+
const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
|
|
4422
|
+
const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
|
|
4423
|
+
|
|
4424
|
+
let pixelSize;
|
|
4425
|
+
if (hasImageSize) {
|
|
4426
|
+
const requested = Number(options.imageSize);
|
|
4427
|
+
assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
|
|
4428
|
+
pixelSize = Math.max(totalModules, Math.round(requested));
|
|
4429
|
+
} else if (hasModuleSize) {
|
|
4430
|
+
const requested = Number(options.moduleSize);
|
|
4431
|
+
assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
|
|
4432
|
+
const moduleSize = Math.max(1, Math.floor(requested));
|
|
4433
|
+
pixelSize = totalModules * moduleSize;
|
|
4434
|
+
} else {
|
|
4435
|
+
// A practical export default that is large enough for screens/social use
|
|
4436
|
+
// while keeping the API independent from the encoded matrix version.
|
|
4437
|
+
pixelSize = Math.max(totalModules, 720);
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4440
|
+
return {
|
|
4441
|
+
quietZone,
|
|
4442
|
+
totalModules,
|
|
4443
|
+
pixelSize,
|
|
4444
|
+
moduleSize: pixelSize / totalModules,
|
|
4445
|
+
mode
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
|
|
4449
|
+
function rasterModuleRect(row, col, quietZone, moduleSize) {
|
|
4450
|
+
const x0 = Math.round((col + quietZone) * moduleSize);
|
|
4451
|
+
const y0 = Math.round((row + quietZone) * moduleSize);
|
|
4452
|
+
const x1 = Math.round((col + quietZone + 1) * moduleSize);
|
|
4453
|
+
const y1 = Math.round((row + quietZone + 1) * moduleSize);
|
|
4454
|
+
return {
|
|
4455
|
+
x: x0,
|
|
4456
|
+
y: y0,
|
|
4457
|
+
width: Math.max(1, x1 - x0),
|
|
4458
|
+
height: Math.max(1, y1 - y0)
|
|
4459
|
+
};
|
|
4460
|
+
}
|
|
4461
|
+
|
|
3024
4462
|
function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
3025
4463
|
assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
|
|
3026
4464
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
3027
4465
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
3028
|
-
const
|
|
3029
|
-
const
|
|
3030
|
-
const palette =
|
|
3031
|
-
const paletteValues = paletteRgb(
|
|
3032
|
-
const
|
|
4466
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
4467
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
4468
|
+
const palette = resolveRenderPalette(options);
|
|
4469
|
+
const paletteValues = paletteRgb(palette);
|
|
4470
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
4471
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
3033
4472
|
const layout = renderLayoutForMatrix(matrix);
|
|
3034
4473
|
const size = matrix.length;
|
|
3035
|
-
const
|
|
4474
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
3036
4475
|
|
|
3037
4476
|
canvas.width = pixelSize;
|
|
3038
4477
|
canvas.height = pixelSize;
|
|
@@ -3045,12 +4484,13 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
3045
4484
|
for (let c = 0; c < size; c++) {
|
|
3046
4485
|
const cell = matrix[r][c];
|
|
3047
4486
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
3048
|
-
const
|
|
3049
|
-
const y
|
|
4487
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
4488
|
+
const { x, y, width: cellWidth, height: cellHeight } = rect;
|
|
4489
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
3050
4490
|
|
|
3051
4491
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
3052
4492
|
ctx.fillStyle = cellColor(cell, palette);
|
|
3053
|
-
ctx.fillRect(x, y,
|
|
4493
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
3054
4494
|
continue;
|
|
3055
4495
|
}
|
|
3056
4496
|
|
|
@@ -3060,28 +4500,29 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
3060
4500
|
const opacity = depthOpacity(r, c, cell);
|
|
3061
4501
|
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
3062
4502
|
ctx.fillStyle = rgbCss(base);
|
|
3063
|
-
ctx.fillRect(x, y,
|
|
4503
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
3064
4504
|
|
|
3065
|
-
if (
|
|
3066
|
-
const edge = Math.max(1, Math.floor(
|
|
4505
|
+
if (cellSize >= 6) {
|
|
4506
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
3067
4507
|
const highlight = mixRgb(base, paletteValues.white, 0.17);
|
|
3068
4508
|
const shadow = mixRgb(base, paletteValues.black, 0.12);
|
|
3069
4509
|
ctx.fillStyle = rgbCss(highlight);
|
|
3070
|
-
ctx.fillRect(x, y,
|
|
3071
|
-
ctx.fillRect(x, y, edge,
|
|
4510
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
4511
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
3072
4512
|
ctx.fillStyle = rgbCss(shadow);
|
|
3073
|
-
ctx.fillRect(x, y +
|
|
3074
|
-
ctx.fillRect(x +
|
|
4513
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
4514
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
3075
4515
|
}
|
|
3076
4516
|
continue;
|
|
3077
4517
|
}
|
|
3078
4518
|
|
|
3079
4519
|
if (style === RENDER_STYLES.SOFT) {
|
|
3080
|
-
const inset =
|
|
3081
|
-
const width =
|
|
3082
|
-
const
|
|
4520
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
4521
|
+
const width = cellWidth - inset * 2;
|
|
4522
|
+
const height = cellHeight - inset * 2;
|
|
4523
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
3083
4524
|
ctx.fillStyle = cellColor(cell, palette);
|
|
3084
|
-
roundedRectPath(ctx, x + inset, y + inset, width,
|
|
4525
|
+
roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
|
|
3085
4526
|
ctx.fill();
|
|
3086
4527
|
continue;
|
|
3087
4528
|
}
|
|
@@ -3090,37 +4531,40 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
3090
4531
|
// Keep the exact encoded color across the full module, then limit the
|
|
3091
4532
|
// recessed effect to a narrow edge band so center sampling stays intact.
|
|
3092
4533
|
ctx.fillStyle = cellColor(cell, palette);
|
|
3093
|
-
ctx.fillRect(x, y,
|
|
4534
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
3094
4535
|
if (cell === CELL.WHITE) continue;
|
|
3095
4536
|
|
|
3096
|
-
const edge = safeStyleEdge(
|
|
4537
|
+
const edge = safeStyleEdge(cellSize);
|
|
3097
4538
|
if (edge > 0) {
|
|
3098
4539
|
const fx = insetStyleColors(rgb, paletteValues);
|
|
3099
4540
|
ctx.fillStyle = rgbCss(fx.shadow);
|
|
3100
|
-
ctx.fillRect(x, y,
|
|
3101
|
-
ctx.fillRect(x, y, edge,
|
|
4541
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
4542
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
3102
4543
|
ctx.fillStyle = rgbCss(fx.highlight);
|
|
3103
|
-
ctx.fillRect(x, y +
|
|
3104
|
-
ctx.fillRect(x +
|
|
4544
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
4545
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
3105
4546
|
}
|
|
3106
4547
|
}
|
|
3107
4548
|
}
|
|
3108
4549
|
}
|
|
4550
|
+
drawLogoToCanvas(ctx, logo, palette);
|
|
3109
4551
|
return canvas;
|
|
3110
4552
|
}
|
|
3111
4553
|
|
|
3112
4554
|
function renderToImageData(codeOrMatrix, options = {}) {
|
|
3113
4555
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
3114
4556
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
3115
|
-
const
|
|
3116
|
-
const
|
|
3117
|
-
const
|
|
3118
|
-
const
|
|
4557
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
4558
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
4559
|
+
const resolvedPalette = resolveRenderPalette(options);
|
|
4560
|
+
const palette = paletteRgb(resolvedPalette);
|
|
4561
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
4562
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
3119
4563
|
const layout = renderLayoutForMatrix(matrix);
|
|
3120
4564
|
const size = matrix.length;
|
|
3121
|
-
const pixelSize = (size + quietZone * 2) * moduleSize;
|
|
3122
4565
|
const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
|
|
3123
4566
|
const white = palette.white;
|
|
4567
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
|
|
3124
4568
|
|
|
3125
4569
|
for (let i = 0; i < pixelSize * pixelSize; i++) {
|
|
3126
4570
|
const p = i * 4;
|
|
@@ -3135,26 +4579,105 @@ function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
3135
4579
|
const cell = matrix[r][c];
|
|
3136
4580
|
const rgb = cellRgb(cell, palette);
|
|
3137
4581
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
3138
|
-
const
|
|
3139
|
-
const x0 =
|
|
4582
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
4583
|
+
const x0 = rect.x;
|
|
4584
|
+
const y0 = rect.y;
|
|
4585
|
+
const cellWidth = rect.width;
|
|
4586
|
+
const cellHeight = rect.height;
|
|
4587
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
3140
4588
|
|
|
3141
4589
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
3142
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
4590
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
3143
4591
|
continue;
|
|
3144
4592
|
}
|
|
3145
4593
|
|
|
3146
4594
|
if (style === RENDER_STYLES.DEPTH) {
|
|
3147
4595
|
const opacity = depthOpacity(r, c, cell);
|
|
3148
4596
|
const base = mixRgb(white, rgb, opacity);
|
|
3149
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
3150
|
-
if (
|
|
3151
|
-
const edge = Math.max(1, Math.floor(
|
|
4597
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
|
|
4598
|
+
if (cellSize >= 6) {
|
|
4599
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
3152
4600
|
const highlight = mixRgb(base, white, 0.17);
|
|
3153
4601
|
const shadow = mixRgb(base, palette.black, 0.12);
|
|
3154
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
3155
|
-
fillImageRect(data, pixelSize, x0, y0, edge,
|
|
3156
|
-
fillImageRect(data, pixelSize, x0, y0 +
|
|
3157
|
-
fillImageRect(data, pixelSize, x0 +
|
|
4602
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
|
|
4603
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
|
|
4604
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
|
|
4605
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
|
|
4606
|
+
}
|
|
4607
|
+
continue;
|
|
4608
|
+
}
|
|
4609
|
+
|
|
4610
|
+
if (style === RENDER_STYLES.SOFT) {
|
|
4611
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
4612
|
+
const width = cellWidth - inset * 2;
|
|
4613
|
+
const height = cellHeight - inset * 2;
|
|
4614
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
4615
|
+
fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
|
|
4616
|
+
continue;
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
if (style === RENDER_STYLES.INSET) {
|
|
4620
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
4621
|
+
if (cell === CELL.WHITE) continue;
|
|
4622
|
+
const edge = safeStyleEdge(cellSize);
|
|
4623
|
+
if (edge > 0) {
|
|
4624
|
+
const fx = insetStyleColors(rgb, palette);
|
|
4625
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
|
|
4626
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
|
|
4627
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
|
|
4628
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
drawLogoToImageData(data, pixelSize, logo);
|
|
4635
|
+
|
|
4636
|
+
return { width: pixelSize, height: pixelSize, data };
|
|
4637
|
+
}
|
|
4638
|
+
|
|
4639
|
+
/** Render a QuadQR code/matrix to a standalone SVG string. */
|
|
4640
|
+
function renderToSVG(codeOrMatrix, options = {}) {
|
|
4641
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
4642
|
+
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
4643
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
4644
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
4645
|
+
const palette = resolveRenderPalette(options);
|
|
4646
|
+
const paletteValues = paletteRgb(palette);
|
|
4647
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
4648
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
4649
|
+
const layout = renderLayoutForMatrix(matrix);
|
|
4650
|
+
const size = matrix.length;
|
|
4651
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
4652
|
+
const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
|
|
4653
|
+
|
|
4654
|
+
for (let r = 0; r < size; r++) {
|
|
4655
|
+
for (let c = 0; c < size; c++) {
|
|
4656
|
+
const cell = matrix[r][c];
|
|
4657
|
+
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
4658
|
+
const x = (c + quietZone) * moduleSize;
|
|
4659
|
+
const y = (r + quietZone) * moduleSize;
|
|
4660
|
+
const fill = cellColor(cell, palette);
|
|
4661
|
+
|
|
4662
|
+
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
4663
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
4664
|
+
continue;
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
const rgb = cellRgb(cell, paletteValues);
|
|
4668
|
+
|
|
4669
|
+
if (style === RENDER_STYLES.DEPTH) {
|
|
4670
|
+
const opacity = depthOpacity(r, c, cell);
|
|
4671
|
+
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
4672
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
|
|
4673
|
+
if (moduleSize >= 6) {
|
|
4674
|
+
const edge = Math.max(1, Math.floor(moduleSize * 0.08));
|
|
4675
|
+
const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
|
|
4676
|
+
const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
|
|
4677
|
+
body.push(svgRect(x, y, moduleSize, edge, highlight));
|
|
4678
|
+
body.push(svgRect(x, y, edge, moduleSize, highlight));
|
|
4679
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
|
|
4680
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
|
|
3158
4681
|
}
|
|
3159
4682
|
continue;
|
|
3160
4683
|
}
|
|
@@ -3163,36 +4686,102 @@ function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
3163
4686
|
const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
|
|
3164
4687
|
const width = moduleSize - inset * 2;
|
|
3165
4688
|
const radius = Math.max(1, Math.floor(moduleSize * 0.20));
|
|
3166
|
-
|
|
4689
|
+
body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
|
|
3167
4690
|
continue;
|
|
3168
4691
|
}
|
|
3169
4692
|
|
|
3170
4693
|
if (style === RENDER_STYLES.INSET) {
|
|
3171
|
-
|
|
4694
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
3172
4695
|
if (cell === CELL.WHITE) continue;
|
|
3173
4696
|
const edge = safeStyleEdge(moduleSize);
|
|
3174
4697
|
if (edge > 0) {
|
|
3175
|
-
const fx = insetStyleColors(rgb,
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
4698
|
+
const fx = insetStyleColors(rgb, paletteValues);
|
|
4699
|
+
const shadow = rgbCss(fx.shadow);
|
|
4700
|
+
const highlight = rgbCss(fx.highlight);
|
|
4701
|
+
body.push(svgRect(x, y, moduleSize, edge, shadow));
|
|
4702
|
+
body.push(svgRect(x, y, edge, moduleSize, shadow));
|
|
4703
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
|
|
4704
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
|
|
3180
4705
|
}
|
|
3181
4706
|
}
|
|
3182
4707
|
}
|
|
3183
4708
|
}
|
|
3184
4709
|
|
|
3185
|
-
|
|
3186
|
-
|
|
4710
|
+
if (logo) {
|
|
4711
|
+
if (logo.clearBackground) {
|
|
4712
|
+
body.push(svgRect(
|
|
4713
|
+
logo.backgroundX,
|
|
4714
|
+
logo.backgroundY,
|
|
4715
|
+
logo.backgroundSize,
|
|
4716
|
+
logo.backgroundSize,
|
|
4717
|
+
logo.backgroundColor,
|
|
4718
|
+
logo.backgroundRadius
|
|
4719
|
+
));
|
|
4720
|
+
}
|
|
4721
|
+
const href = svgLogoHref(logo.source);
|
|
4722
|
+
assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
|
|
4723
|
+
body.push(
|
|
4724
|
+
`<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
|
|
4725
|
+
);
|
|
4726
|
+
}
|
|
3187
4727
|
|
|
3188
|
-
function classifierFromPaletteRgb(observed) {
|
|
3189
4728
|
return [
|
|
3190
|
-
|
|
3191
|
-
{
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
4729
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
4730
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
|
|
4731
|
+
...body,
|
|
4732
|
+
"</svg>"
|
|
4733
|
+
].join("\n");
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
function clampNumber(value, min, max) {
|
|
4737
|
+
return Math.max(min, Math.min(max, value));
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
function identityColorTransform(rgb) {
|
|
4741
|
+
return rgb;
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4744
|
+
function makeWhiteBalanceTransform(observed) {
|
|
4745
|
+
const black = observed.black;
|
|
4746
|
+
const white = observed.white;
|
|
4747
|
+
const ranges = {
|
|
4748
|
+
r: Math.max(32, white.r - black.r),
|
|
4749
|
+
g: Math.max(32, white.g - black.g),
|
|
4750
|
+
b: Math.max(32, white.b - black.b)
|
|
4751
|
+
};
|
|
4752
|
+
|
|
4753
|
+
return (rgb) => ({
|
|
4754
|
+
// Normalize the camera's observed black/white points per channel. This
|
|
4755
|
+
// boosts a suppressed blue channel under warm/yellow illumination without
|
|
4756
|
+
// changing the QuadQR palette or payload capacity.
|
|
4757
|
+
r: clampNumber((rgb.r - black.r) * 255 / ranges.r, -96, 384),
|
|
4758
|
+
g: clampNumber((rgb.g - black.g) * 255 / ranges.g, -96, 384),
|
|
4759
|
+
b: clampNumber((rgb.b - black.b) * 255 / ranges.b, -96, 384)
|
|
4760
|
+
});
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
function classifierFromPaletteRgb(observed, mode = "raw") {
|
|
4764
|
+
const transform = mode === "balanced" || mode === "hue"
|
|
4765
|
+
? makeWhiteBalanceTransform(observed)
|
|
4766
|
+
: identityColorTransform;
|
|
4767
|
+
const entries = [
|
|
4768
|
+
{ cell: CELL.BLACK, rgb: transform(observed.black) },
|
|
4769
|
+
{ cell: CELL.WHITE, rgb: transform(observed.white) },
|
|
4770
|
+
{ cell: CELL.RED, rgb: transform(observed.red) },
|
|
4771
|
+
{ cell: CELL.GREEN, rgb: transform(observed.green) },
|
|
4772
|
+
{ cell: CELL.BLUE, rgb: transform(observed.blue) }
|
|
3195
4773
|
];
|
|
4774
|
+
const colored = entries.filter(({ cell }) => cell === CELL.RED || cell === CELL.GREEN || cell === CELL.BLUE);
|
|
4775
|
+
const chroma = (rgb) => Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
|
|
4776
|
+
const minimumColorChroma = Math.min(...colored.map(({ rgb }) => chroma(rgb)));
|
|
4777
|
+
return {
|
|
4778
|
+
entries,
|
|
4779
|
+
transform,
|
|
4780
|
+
mode,
|
|
4781
|
+
dataMode: mode === "hue" ? "hue" : "distance",
|
|
4782
|
+
whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
|
|
4783
|
+
minimumColorChroma
|
|
4784
|
+
};
|
|
3196
4785
|
}
|
|
3197
4786
|
|
|
3198
4787
|
function colorDistanceSq(a, b) {
|
|
@@ -3203,12 +4792,13 @@ function colorDistanceSq(a, b) {
|
|
|
3203
4792
|
}
|
|
3204
4793
|
|
|
3205
4794
|
function classifyRgb(rgb, classifier) {
|
|
4795
|
+
const transformed = classifier.transform(rgb);
|
|
3206
4796
|
let best = null;
|
|
3207
4797
|
let bestDistanceSq = Infinity;
|
|
3208
4798
|
let secondDistanceSq = Infinity;
|
|
3209
4799
|
|
|
3210
|
-
for (const candidate of classifier) {
|
|
3211
|
-
const distanceSq = colorDistanceSq(
|
|
4800
|
+
for (const candidate of classifier.entries) {
|
|
4801
|
+
const distanceSq = colorDistanceSq(transformed, candidate.rgb);
|
|
3212
4802
|
if (distanceSq < bestDistanceSq) {
|
|
3213
4803
|
secondDistanceSq = bestDistanceSq;
|
|
3214
4804
|
bestDistanceSq = distanceSq;
|
|
@@ -3220,17 +4810,46 @@ function classifyRgb(rgb, classifier) {
|
|
|
3220
4810
|
|
|
3221
4811
|
const distance = Math.sqrt(bestDistanceSq);
|
|
3222
4812
|
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
4813
|
const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
|
|
3226
4814
|
return { cell: best.cell, distance, confidence };
|
|
3227
4815
|
}
|
|
3228
4816
|
|
|
4817
|
+
function classifyDataHue(rgb, classifier) {
|
|
4818
|
+
const transformed = classifier.transform(rgb);
|
|
4819
|
+
const channels = [
|
|
4820
|
+
{ cell: CELL.RED, value: transformed.r },
|
|
4821
|
+
{ cell: CELL.GREEN, value: transformed.g },
|
|
4822
|
+
{ cell: CELL.BLUE, value: transformed.b }
|
|
4823
|
+
].sort((a, b) => b.value - a.value);
|
|
4824
|
+
const maxValue = channels[0].value;
|
|
4825
|
+
const secondValue = channels[1].value;
|
|
4826
|
+
const minValue = channels[2].value;
|
|
4827
|
+
const chroma = maxValue - minValue;
|
|
4828
|
+
const whiteThreshold = Math.max(18, Math.min(58, classifier.minimumColorChroma * 0.34));
|
|
4829
|
+
|
|
4830
|
+
if (chroma <= whiteThreshold) {
|
|
4831
|
+
const confidence = clampNumber((whiteThreshold - chroma) / Math.max(whiteThreshold, 1), 0, 1);
|
|
4832
|
+
return { cell: CELL.WHITE, distance: chroma, confidence: 0.35 + confidence * 0.65 };
|
|
4833
|
+
}
|
|
4834
|
+
|
|
4835
|
+
const margin = maxValue - secondValue;
|
|
4836
|
+
const hueConfidence = clampNumber(margin / Math.max(chroma, 1), 0, 1);
|
|
4837
|
+
const saturationConfidence = clampNumber((chroma - whiteThreshold) / Math.max(classifier.minimumColorChroma - whiteThreshold, 1), 0, 1);
|
|
4838
|
+
return {
|
|
4839
|
+
cell: channels[0].cell,
|
|
4840
|
+
distance: Math.max(0, classifier.minimumColorChroma - chroma),
|
|
4841
|
+
confidence: clampNumber(0.18 + hueConfidence * 0.52 + saturationConfidence * 0.30, 0, 1)
|
|
4842
|
+
};
|
|
4843
|
+
}
|
|
4844
|
+
|
|
3229
4845
|
function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
3230
4846
|
const size = rgbGrid.length;
|
|
3231
4847
|
const matrix = make2D(size, CELL.WHITE);
|
|
3232
4848
|
const confidence = make2D(size, 1);
|
|
3233
|
-
const dataClassifier =
|
|
4849
|
+
const dataClassifier = {
|
|
4850
|
+
...classifier,
|
|
4851
|
+
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
4852
|
+
};
|
|
3234
4853
|
let distanceSum = 0;
|
|
3235
4854
|
let confidenceSum = 0;
|
|
3236
4855
|
let minimumConfidence = 1;
|
|
@@ -3241,8 +4860,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3241
4860
|
// Black is structural only. Restricting data modules to RGBW keeps a
|
|
3242
4861
|
// shadowed/blurred data sample representable so confidence-aware ECC can
|
|
3243
4862
|
// promote it to an erasure instead of aborting before RS gets a chance.
|
|
3244
|
-
const
|
|
3245
|
-
const
|
|
4863
|
+
const isDataCell = layout && !layout.reserved[r][c];
|
|
4864
|
+
const candidates = isDataCell ? dataClassifier : classifier;
|
|
4865
|
+
const classified = isDataCell && classifier.dataMode === "hue"
|
|
4866
|
+
? classifyDataHue(rgbGrid[r][c], classifier)
|
|
4867
|
+
: classifyRgb(rgbGrid[r][c], candidates);
|
|
3246
4868
|
matrix[r][c] = classified.cell;
|
|
3247
4869
|
confidence[r][c] = classified.confidence;
|
|
3248
4870
|
distanceSum += classified.distance;
|
|
@@ -3261,115 +4883,468 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3261
4883
|
};
|
|
3262
4884
|
}
|
|
3263
4885
|
|
|
4886
|
+
function structuralAccuracy(matrix, layout) {
|
|
4887
|
+
let matches = 0;
|
|
4888
|
+
let total = 0;
|
|
4889
|
+
for (let r = 0; r < layout.size; r++) {
|
|
4890
|
+
for (let c = 0; c < layout.size; c++) {
|
|
4891
|
+
if (!layout.reserved[r][c]) continue;
|
|
4892
|
+
total++;
|
|
4893
|
+
if (matrix[r][c] === layout.matrix[r][c]) matches++;
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
return total ? matches / total : 0;
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
function pushObservation(options, observation) {
|
|
4900
|
+
if (!Array.isArray(options._observationCollector)) return;
|
|
4901
|
+
options._observationCollector.push(observation);
|
|
4902
|
+
}
|
|
4903
|
+
|
|
4904
|
+
function paletteClassifierAttempts(observedPalette) {
|
|
4905
|
+
return [
|
|
4906
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
|
|
4907
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
|
|
4908
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
|
|
4909
|
+
];
|
|
4910
|
+
}
|
|
4911
|
+
|
|
3264
4912
|
function tryPerspectiveScan(imageData, options) {
|
|
3265
4913
|
const geometryCandidates = detectCodeGeometry(imageData, {
|
|
3266
4914
|
minVersion: options.minVersion ?? MIN_VERSION,
|
|
3267
4915
|
maxVersion: options.maxVersion ?? MAX_VERSION,
|
|
3268
|
-
maxCandidates: options.maxGeometryCandidates ?? 8
|
|
4916
|
+
maxCandidates: options.maxGeometryCandidates ?? 8,
|
|
4917
|
+
finderRecovery: options.finderRecovery,
|
|
4918
|
+
finderAutoColorBlackClip: options.finderAutoColorBlackClip,
|
|
4919
|
+
finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
|
|
4920
|
+
finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
|
|
4921
|
+
finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
|
|
4922
|
+
finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
|
|
4923
|
+
finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
|
|
4924
|
+
finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
|
|
4925
|
+
diagnostics: options._visionDiagnostics,
|
|
4926
|
+
diagnosticLabel: options._diagnosticLabel ?? "normal"
|
|
3269
4927
|
});
|
|
4928
|
+
if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
|
|
3270
4929
|
const results = [];
|
|
3271
4930
|
|
|
3272
4931
|
for (const geometry of geometryCandidates) {
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
maxErasureConfidence: options.maxErasureConfidence
|
|
3285
|
-
});
|
|
3286
|
-
if (decoded.version !== geometry.version) continue;
|
|
3287
|
-
|
|
3288
|
-
results.push({
|
|
3289
|
-
...decoded,
|
|
3290
|
-
perspectiveCorrected: true,
|
|
3291
|
-
colorCalibrated: true,
|
|
3292
|
-
geometry,
|
|
3293
|
-
observedPalette,
|
|
3294
|
-
averageColorDistance: classified.averageColorDistance,
|
|
3295
|
-
averageCellConfidence: classified.averageCellConfidence,
|
|
3296
|
-
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3297
|
-
lowConfidenceCells: classified.lowConfidenceCells,
|
|
3298
|
-
rectified: options.includeRectified
|
|
3299
|
-
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
3300
|
-
: undefined
|
|
4932
|
+
const layout = createLayout(geometry.version);
|
|
4933
|
+
const sampleProfiles = [{
|
|
4934
|
+
sampleMode: options.sampleMode ?? "cross",
|
|
4935
|
+
sampleRadius: options.sampleRadius ?? 0.16,
|
|
4936
|
+
robustCalibration: false
|
|
4937
|
+
}];
|
|
4938
|
+
if (options.adaptiveSampling !== false) {
|
|
4939
|
+
sampleProfiles.push({
|
|
4940
|
+
sampleMode: "median",
|
|
4941
|
+
sampleRadius: options.robustSampleRadius ?? 0.12,
|
|
4942
|
+
robustCalibration: true
|
|
3301
4943
|
});
|
|
3302
|
-
} catch {
|
|
3303
|
-
// Continue trying geometry hypotheses.
|
|
3304
4944
|
}
|
|
3305
|
-
}
|
|
3306
|
-
|
|
3307
|
-
results.sort((a, b) =>
|
|
3308
|
-
(a.correctedSymbols - b.correctedSymbols) ||
|
|
3309
|
-
(a.averageColorDistance - b.averageColorDistance) ||
|
|
3310
|
-
(b.geometry.score - a.geometry.score)
|
|
3311
|
-
);
|
|
3312
|
-
return results[0] ?? null;
|
|
3313
|
-
}
|
|
3314
|
-
|
|
3315
|
-
function tryAxisAlignedScan(imageData, options) {
|
|
3316
|
-
const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
|
|
3317
|
-
const fixedClassifier = classifierFromPaletteRgb(paletteRgb(options.palette));
|
|
3318
|
-
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
3319
|
-
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
3320
|
-
const candidates = [];
|
|
3321
|
-
|
|
3322
|
-
for (let version = minVersion; version <= maxVersion; version++) {
|
|
3323
|
-
const size = sizeForVersion(version);
|
|
3324
|
-
const moduleW = bounds.width / size;
|
|
3325
|
-
const moduleH = bounds.height / size;
|
|
3326
|
-
if (moduleW < 0.75 || moduleH < 0.75) continue;
|
|
3327
|
-
const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
|
|
3328
|
-
if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
|
|
3329
4945
|
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
4946
|
+
let geometryDecoded = false;
|
|
4947
|
+
let bestStructureScore = 0;
|
|
4948
|
+
for (const profile of sampleProfiles) {
|
|
4949
|
+
let sampled;
|
|
4950
|
+
let observedPalette;
|
|
3334
4951
|
try {
|
|
3335
|
-
|
|
3336
|
-
|
|
4952
|
+
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
|
|
4953
|
+
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
|
|
4954
|
+
robust: profile.robustCalibration
|
|
4955
|
+
});
|
|
3337
4956
|
} catch {
|
|
3338
|
-
|
|
4957
|
+
continue;
|
|
3339
4958
|
}
|
|
3340
|
-
classifierAttempts.push({ classifier: fixedClassifier, calibrated: false });
|
|
3341
4959
|
|
|
3342
|
-
|
|
3343
|
-
|
|
4960
|
+
const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
|
|
4961
|
+
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
4962
|
+
const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
|
|
4963
|
+
const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
|
|
4964
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
4965
|
+
bestStructureScore = Math.max(bestStructureScore, structureScore);
|
|
4966
|
+
pushObservation(options, {
|
|
4967
|
+
version: geometry.version,
|
|
4968
|
+
matrix: classified.matrix,
|
|
4969
|
+
confidence: classified.confidence,
|
|
4970
|
+
geometry,
|
|
4971
|
+
observedPalette: activeObservedPalette,
|
|
4972
|
+
samplingMode: activeSamplingMode,
|
|
4973
|
+
colorNormalization: attempt.colorNormalization,
|
|
4974
|
+
structureScore,
|
|
4975
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
4976
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
4977
|
+
});
|
|
4978
|
+
|
|
3344
4979
|
try {
|
|
3345
|
-
const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
|
|
3346
4980
|
const decoded = decodeMatrix(classified.matrix, {
|
|
3347
|
-
structureTolerance: options.structureTolerance ?? 0.
|
|
4981
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3348
4982
|
cellConfidence: classified.confidence,
|
|
3349
4983
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3350
4984
|
});
|
|
3351
|
-
|
|
4985
|
+
if (decoded.version !== geometry.version) return false;
|
|
4986
|
+
|
|
4987
|
+
results.push({
|
|
3352
4988
|
...decoded,
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
4989
|
+
perspectiveCorrected: true,
|
|
4990
|
+
colorCalibrated: true,
|
|
4991
|
+
colorNormalization: attempt.colorNormalization,
|
|
4992
|
+
samplingMode: activeSamplingMode,
|
|
4993
|
+
geometry,
|
|
4994
|
+
observedPalette: activeObservedPalette,
|
|
4995
|
+
autoEnhanced: metadata.autoEnhanced || undefined,
|
|
4996
|
+
recoveryMode: metadata.recoveryMode,
|
|
3359
4997
|
averageColorDistance: classified.averageColorDistance,
|
|
3360
4998
|
averageCellConfidence: classified.averageCellConfidence,
|
|
3361
4999
|
minimumCellConfidence: classified.minimumCellConfidence,
|
|
5000
|
+
lowConfidenceCells: classified.lowConfidenceCells,
|
|
5001
|
+
rectified: options.includeRectified
|
|
5002
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
5003
|
+
: undefined
|
|
5004
|
+
});
|
|
5005
|
+
return true;
|
|
5006
|
+
} catch {
|
|
5007
|
+
return false;
|
|
5008
|
+
}
|
|
5009
|
+
};
|
|
5010
|
+
|
|
5011
|
+
// Fast path: preserve the original observed-RGB classifier first, then
|
|
5012
|
+
// try per-channel white balancing. Most clean frames stop here without
|
|
5013
|
+
// paying for the more expensive spatial normalization fallback.
|
|
5014
|
+
for (const attempt of paletteClassifierAttempts(observedPalette)) {
|
|
5015
|
+
if (tryAttempt(attempt, sampled.rgbGrid)) {
|
|
5016
|
+
geometryDecoded = true;
|
|
5017
|
+
break;
|
|
5018
|
+
}
|
|
5019
|
+
}
|
|
5020
|
+
|
|
5021
|
+
if (!geometryDecoded && options.spatialColorNormalization !== false) {
|
|
5022
|
+
try {
|
|
5023
|
+
const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
|
|
5024
|
+
const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
|
|
5025
|
+
geometryDecoded = tryAttempt({
|
|
5026
|
+
classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
|
|
5027
|
+
colorNormalization: "spatial-white-balanced"
|
|
5028
|
+
}, normalizedGrid);
|
|
5029
|
+
} catch {
|
|
5030
|
+
// Continue to the recovery profile below.
|
|
5031
|
+
}
|
|
5032
|
+
}
|
|
5033
|
+
|
|
5034
|
+
// Very cheap Photoshop-like recovery on the already-sampled module grid.
|
|
5035
|
+
// Normal camera frames never reach this path. The transform approximates
|
|
5036
|
+
// Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
|
|
5037
|
+
if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
|
|
5038
|
+
try {
|
|
5039
|
+
const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
|
|
5040
|
+
blackClip: options.autoEnhanceBlackClip,
|
|
5041
|
+
whiteClip: options.autoEnhanceWhiteClip,
|
|
5042
|
+
saturation: options.autoEnhanceSaturation
|
|
5043
|
+
});
|
|
5044
|
+
const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
|
|
5045
|
+
for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
|
|
5046
|
+
const recoveredAttempt = {
|
|
5047
|
+
...attempt,
|
|
5048
|
+
colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
|
|
5049
|
+
};
|
|
5050
|
+
if (tryAttempt(recoveredAttempt, enhancedGrid, {
|
|
5051
|
+
observedPalette: enhancedPalette,
|
|
5052
|
+
autoEnhanced: true,
|
|
5053
|
+
recoveryMode: "module-grid-auto-tone-contrast-color"
|
|
5054
|
+
})) {
|
|
5055
|
+
geometryDecoded = true;
|
|
5056
|
+
break;
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
5059
|
+
} catch {
|
|
5060
|
+
// Continue to the QR-region pixel recovery below.
|
|
5061
|
+
}
|
|
5062
|
+
}
|
|
5063
|
+
|
|
5064
|
+
// Camera-specific Photoshop-style fallback. Enhancing the whole camera
|
|
5065
|
+
// frame is often ineffective because dark surroundings, browser/UI
|
|
5066
|
+
// reflections and unrelated objects skew the histograms. Once finder
|
|
5067
|
+
// geometry is known, rectify only the QuadQR region, run Auto Tone /
|
|
5068
|
+
// Contrast / Color there at pixel level, then sample modules again. This
|
|
5069
|
+
// closely matches what happens when the QR itself is corrected in an
|
|
5070
|
+
// editor before photographing it, while staying off the clean fast path.
|
|
5071
|
+
if (
|
|
5072
|
+
!geometryDecoded &&
|
|
5073
|
+
profile === sampleProfiles[sampleProfiles.length - 1] &&
|
|
5074
|
+
options.autoEnhanceRecovery !== false &&
|
|
5075
|
+
options.rectifiedAutoEnhanceRecovery !== false
|
|
5076
|
+
) {
|
|
5077
|
+
try {
|
|
5078
|
+
const recoveryModuleSize = Math.max(4, Math.min(10, Math.round(options.rectifiedRecoveryModuleSize ?? 6)));
|
|
5079
|
+
const rectifiedRegion = rectifyImageData(
|
|
5080
|
+
imageData,
|
|
5081
|
+
geometry.homography,
|
|
5082
|
+
layout.size,
|
|
5083
|
+
recoveryModuleSize
|
|
5084
|
+
);
|
|
5085
|
+
const enhancedRegion = autoToneContrastColorImageData(rectifiedRegion, {
|
|
5086
|
+
blackClip: options.rectifiedAutoEnhanceBlackClip ?? options.autoEnhanceBlackClip ?? 0.008,
|
|
5087
|
+
whiteClip: options.rectifiedAutoEnhanceWhiteClip ?? options.autoEnhanceWhiteClip ?? 0.006,
|
|
5088
|
+
saturation: options.rectifiedAutoEnhanceSaturation ?? options.autoEnhanceSaturation ?? 1.22,
|
|
5089
|
+
highlightFraction: options.rectifiedAutoEnhanceHighlightFraction ?? 0.16,
|
|
5090
|
+
targetSamples: Math.min(120000, rectifiedRegion.width * rectifiedRegion.height)
|
|
5091
|
+
});
|
|
5092
|
+
const enhancedSampled = sampleAxisAlignedGrid(
|
|
5093
|
+
enhancedRegion,
|
|
5094
|
+
{ x: 0, y: 0, width: enhancedRegion.width, height: enhancedRegion.height },
|
|
5095
|
+
layout.size,
|
|
5096
|
+
options.rectifiedRecoveryRadiusRatio ?? 0.16
|
|
5097
|
+
);
|
|
5098
|
+
const enhancedPalette = sampleObservedPalette(
|
|
5099
|
+
enhancedSampled.rgbGrid,
|
|
5100
|
+
layout.calibration,
|
|
5101
|
+
{ robust: true }
|
|
5102
|
+
);
|
|
5103
|
+
for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
|
|
5104
|
+
const recoveredAttempt = {
|
|
5105
|
+
...attempt,
|
|
5106
|
+
colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
|
|
5107
|
+
};
|
|
5108
|
+
if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
|
|
5109
|
+
observedPalette: enhancedPalette,
|
|
5110
|
+
samplingMode: "rectified-auto-enhance",
|
|
5111
|
+
autoEnhanced: true,
|
|
5112
|
+
recoveryMode: "rectified-auto-tone-contrast-color"
|
|
5113
|
+
})) {
|
|
5114
|
+
geometryDecoded = true;
|
|
5115
|
+
break;
|
|
5116
|
+
}
|
|
5117
|
+
}
|
|
5118
|
+
} catch {
|
|
5119
|
+
// Geometry refinement remains available as the final bounded fallback.
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
|
|
5123
|
+
if (geometryDecoded) break;
|
|
5124
|
+
}
|
|
5125
|
+
|
|
5126
|
+
// Slow-path geometry micro-refinement. Finder/alignment detection can be
|
|
5127
|
+
// correct while a soft-focus or smeared camera shifts the effective module
|
|
5128
|
+
// centres by a small fraction of a cell. Clean scans never reach this code.
|
|
5129
|
+
// On a strong-but-undecodable symbol, probe a tiny 3x3 offset neighbourhood
|
|
5130
|
+
// using centre-only samples and attempt decoding from the best candidates.
|
|
5131
|
+
if (
|
|
5132
|
+
!geometryDecoded &&
|
|
5133
|
+
options.geometryRefinement !== false &&
|
|
5134
|
+
bestStructureScore >= (options.refinementStructureThreshold ?? 0.90)
|
|
5135
|
+
) {
|
|
5136
|
+
const step = clampNumber(options.refinementOffset ?? 0.20, 0.05, 0.35);
|
|
5137
|
+
const refinementOffsets = [
|
|
5138
|
+
[step, 0], [-step, 0], [0, -step], [0, step],
|
|
5139
|
+
[step, -step], [step, step], [-step, -step], [-step, step]
|
|
5140
|
+
];
|
|
5141
|
+
const refinementCandidates = [];
|
|
5142
|
+
|
|
5143
|
+
for (const [offsetX, offsetY] of refinementOffsets) {
|
|
5144
|
+
let sampled;
|
|
5145
|
+
let observedPalette;
|
|
5146
|
+
try {
|
|
5147
|
+
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
|
|
5148
|
+
sampleMode: "cross",
|
|
5149
|
+
sampleRadius: 0,
|
|
5150
|
+
sampleOffsetX: offsetX,
|
|
5151
|
+
sampleOffsetY: offsetY
|
|
5152
|
+
});
|
|
5153
|
+
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
|
|
5154
|
+
} catch {
|
|
5155
|
+
continue;
|
|
5156
|
+
}
|
|
5157
|
+
|
|
5158
|
+
// Raw observed-palette classification is deliberately first here too.
|
|
5159
|
+
// It is cheap, and it handles the common case where blur biased the
|
|
5160
|
+
// geometry slightly but the camera colors themselves remain usable.
|
|
5161
|
+
const attempts = paletteClassifierAttempts(observedPalette);
|
|
5162
|
+
for (const attempt of attempts) {
|
|
5163
|
+
const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
|
|
5164
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
5165
|
+
bestStructureScore = Math.max(bestStructureScore, structureScore);
|
|
5166
|
+
pushObservation(options, {
|
|
5167
|
+
version: geometry.version,
|
|
5168
|
+
matrix: classified.matrix,
|
|
5169
|
+
confidence: classified.confidence,
|
|
5170
|
+
geometry,
|
|
5171
|
+
observedPalette,
|
|
5172
|
+
samplingMode: "refined-center",
|
|
5173
|
+
samplingOffset: { x: offsetX, y: offsetY },
|
|
5174
|
+
colorNormalization: attempt.colorNormalization,
|
|
5175
|
+
structureScore,
|
|
5176
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3362
5177
|
lowConfidenceCells: classified.lowConfidenceCells
|
|
3363
5178
|
});
|
|
3364
|
-
|
|
5179
|
+
|
|
5180
|
+
// Do not spend RS work on a refinement that made the known finder /
|
|
5181
|
+
// alignment structure worse. This keeps the recovery path bounded.
|
|
5182
|
+
if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
|
|
5183
|
+
refinementCandidates.push({
|
|
5184
|
+
attempt,
|
|
5185
|
+
classified,
|
|
5186
|
+
observedPalette,
|
|
5187
|
+
offsetX,
|
|
5188
|
+
offsetY,
|
|
5189
|
+
structureScore
|
|
5190
|
+
});
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
|
|
5194
|
+
refinementCandidates.sort((a, b) =>
|
|
5195
|
+
(b.structureScore - a.structureScore) ||
|
|
5196
|
+
(b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
|
|
5197
|
+
(a.classified.averageColorDistance - b.classified.averageColorDistance)
|
|
5198
|
+
);
|
|
5199
|
+
|
|
5200
|
+
const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
|
|
5201
|
+
for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
|
|
5202
|
+
try {
|
|
5203
|
+
const decoded = decodeMatrix(candidate.classified.matrix, {
|
|
5204
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
5205
|
+
cellConfidence: candidate.classified.confidence,
|
|
5206
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
5207
|
+
});
|
|
5208
|
+
if (decoded.version !== geometry.version) continue;
|
|
5209
|
+
results.push({
|
|
5210
|
+
...decoded,
|
|
5211
|
+
perspectiveCorrected: true,
|
|
5212
|
+
geometryRefined: true,
|
|
5213
|
+
samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
|
|
5214
|
+
colorCalibrated: true,
|
|
5215
|
+
colorNormalization: candidate.attempt.colorNormalization,
|
|
5216
|
+
samplingMode: "refined-center",
|
|
5217
|
+
geometry,
|
|
5218
|
+
observedPalette: candidate.observedPalette,
|
|
5219
|
+
averageColorDistance: candidate.classified.averageColorDistance,
|
|
5220
|
+
averageCellConfidence: candidate.classified.averageCellConfidence,
|
|
5221
|
+
minimumCellConfidence: candidate.classified.minimumCellConfidence,
|
|
5222
|
+
lowConfidenceCells: candidate.classified.lowConfidenceCells,
|
|
5223
|
+
rectified: options.includeRectified
|
|
5224
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
5225
|
+
: undefined
|
|
5226
|
+
});
|
|
5227
|
+
geometryDecoded = true;
|
|
3365
5228
|
break;
|
|
3366
5229
|
} catch {
|
|
3367
|
-
// Try next
|
|
5230
|
+
// Try the next high-scoring micro-refinement.
|
|
3368
5231
|
}
|
|
3369
5232
|
}
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5235
|
+
|
|
5236
|
+
results.sort((a, b) =>
|
|
5237
|
+
(a.correctedSymbols - b.correctedSymbols) ||
|
|
5238
|
+
(a.averageColorDistance - b.averageColorDistance) ||
|
|
5239
|
+
(b.geometry.score - a.geometry.score)
|
|
5240
|
+
);
|
|
5241
|
+
return results[0] ?? null;
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5244
|
+
function tryAxisAlignedScan(imageData, options) {
|
|
5245
|
+
const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
|
|
5246
|
+
const fixedPalette = paletteRgb(options.palette);
|
|
5247
|
+
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
5248
|
+
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
5249
|
+
const candidates = [];
|
|
5250
|
+
|
|
5251
|
+
for (let version = minVersion; version <= maxVersion; version++) {
|
|
5252
|
+
const size = sizeForVersion(version);
|
|
5253
|
+
const moduleW = bounds.width / size;
|
|
5254
|
+
const moduleH = bounds.height / size;
|
|
5255
|
+
if (moduleW < 0.75 || moduleH < 0.75) continue;
|
|
5256
|
+
const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
|
|
5257
|
+
if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
|
|
5258
|
+
|
|
5259
|
+
const radiusProfiles = [options.sampleRadius ?? 0.18];
|
|
5260
|
+
if (options.adaptiveSampling !== false) radiusProfiles.push(options.robustSampleRadius ?? 0.10);
|
|
5261
|
+
let accepted = false;
|
|
5262
|
+
|
|
5263
|
+
for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
|
|
5264
|
+
try {
|
|
5265
|
+
const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
|
|
5266
|
+
const layout = createLayout(version);
|
|
5267
|
+
const classifierAttempts = [];
|
|
5268
|
+
try {
|
|
5269
|
+
const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
|
|
5270
|
+
robust: profileIndex > 0
|
|
5271
|
+
});
|
|
5272
|
+
classifierAttempts.push(...paletteClassifierAttempts(observedPalette).map((attempt) => ({
|
|
5273
|
+
...attempt,
|
|
5274
|
+
calibrated: true,
|
|
5275
|
+
observedPalette,
|
|
5276
|
+
rgbGrid: sampled.rgbGrid
|
|
5277
|
+
})));
|
|
5278
|
+
if (options.spatialColorNormalization !== false) {
|
|
5279
|
+
try {
|
|
5280
|
+
const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
|
|
5281
|
+
const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
|
|
5282
|
+
classifierAttempts.push({
|
|
5283
|
+
classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
|
|
5284
|
+
calibrated: true,
|
|
5285
|
+
colorNormalization: "spatial-white-balanced",
|
|
5286
|
+
observedPalette,
|
|
5287
|
+
rgbGrid: normalizedGrid
|
|
5288
|
+
});
|
|
5289
|
+
} catch {
|
|
5290
|
+
// Continue with global calibration/fixed palette.
|
|
5291
|
+
}
|
|
5292
|
+
}
|
|
5293
|
+
} catch {
|
|
5294
|
+
// Fixed palette fallback below.
|
|
5295
|
+
}
|
|
5296
|
+
classifierAttempts.push({
|
|
5297
|
+
classifier: classifierFromPaletteRgb(fixedPalette, "raw"),
|
|
5298
|
+
calibrated: false,
|
|
5299
|
+
colorNormalization: "fixed-rgb",
|
|
5300
|
+
observedPalette: fixedPalette,
|
|
5301
|
+
rgbGrid: sampled.rgbGrid
|
|
5302
|
+
});
|
|
5303
|
+
|
|
5304
|
+
for (const attempt of classifierAttempts) {
|
|
5305
|
+
try {
|
|
5306
|
+
const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
|
|
5307
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
5308
|
+
pushObservation(options, {
|
|
5309
|
+
version,
|
|
5310
|
+
matrix: classified.matrix,
|
|
5311
|
+
confidence: classified.confidence,
|
|
5312
|
+
bounds,
|
|
5313
|
+
samplingMode: profileIndex === 0 ? "axis" : "axis-center",
|
|
5314
|
+
colorNormalization: attempt.colorNormalization,
|
|
5315
|
+
structureScore,
|
|
5316
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
5317
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
5318
|
+
});
|
|
5319
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
5320
|
+
structureTolerance: options.structureTolerance ?? 0.12,
|
|
5321
|
+
cellConfidence: classified.confidence,
|
|
5322
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
5323
|
+
});
|
|
5324
|
+
candidates.push({
|
|
5325
|
+
...decoded,
|
|
5326
|
+
bounds,
|
|
5327
|
+
sampledVersion: version,
|
|
5328
|
+
moduleWidth: moduleW,
|
|
5329
|
+
moduleHeight: moduleH,
|
|
5330
|
+
perspectiveCorrected: false,
|
|
5331
|
+
colorCalibrated: attempt.calibrated,
|
|
5332
|
+
colorNormalization: attempt.colorNormalization,
|
|
5333
|
+
samplingMode: profileIndex === 0 ? "axis" : "axis-center",
|
|
5334
|
+
averageColorDistance: classified.averageColorDistance,
|
|
5335
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
5336
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
5337
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
5338
|
+
});
|
|
5339
|
+
accepted = true;
|
|
5340
|
+
break;
|
|
5341
|
+
} catch {
|
|
5342
|
+
// Try next classifier/profile.
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
} catch {
|
|
5346
|
+
// Try a narrower centre sample, then next version.
|
|
5347
|
+
}
|
|
3373
5348
|
}
|
|
3374
5349
|
}
|
|
3375
5350
|
|
|
@@ -3379,6 +5354,364 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3379
5354
|
return candidates[0] ?? null;
|
|
3380
5355
|
}
|
|
3381
5356
|
|
|
5357
|
+
const STRESS_PROFILES = Object.freeze([
|
|
5358
|
+
Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
|
|
5359
|
+
Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
|
|
5360
|
+
Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
|
|
5361
|
+
Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
|
|
5362
|
+
Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
|
|
5363
|
+
Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
|
|
5364
|
+
Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
|
|
5365
|
+
Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
|
|
5366
|
+
Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
|
|
5367
|
+
]);
|
|
5368
|
+
|
|
5369
|
+
function cloneImageDataLike(imageData) {
|
|
5370
|
+
return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
|
|
5371
|
+
}
|
|
5372
|
+
|
|
5373
|
+
function sampleRgbaBilinear(imageData, x, y) {
|
|
5374
|
+
const { width, height, data } = imageData;
|
|
5375
|
+
const cx = clampNumber(x, 0, width - 1);
|
|
5376
|
+
const cy = clampNumber(y, 0, height - 1);
|
|
5377
|
+
const x0 = Math.floor(cx), y0 = Math.floor(cy);
|
|
5378
|
+
const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
|
|
5379
|
+
const tx = cx - x0, ty = cy - y0;
|
|
5380
|
+
const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
|
|
5381
|
+
const out = [0, 0, 0, 255];
|
|
5382
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
5383
|
+
const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
|
|
5384
|
+
const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
|
|
5385
|
+
out[ch] = a * (1 - ty) + b * ty;
|
|
5386
|
+
}
|
|
5387
|
+
return out;
|
|
5388
|
+
}
|
|
5389
|
+
|
|
5390
|
+
function resizeImageDataLike(imageData, width, height) {
|
|
5391
|
+
width = Math.max(1, Math.round(width));
|
|
5392
|
+
height = Math.max(1, Math.round(height));
|
|
5393
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
5394
|
+
for (let y = 0; y < height; y++) {
|
|
5395
|
+
for (let x = 0; x < width; x++) {
|
|
5396
|
+
const sx = (x + 0.5) * imageData.width / width - 0.5;
|
|
5397
|
+
const sy = (y + 0.5) * imageData.height / height - 0.5;
|
|
5398
|
+
const rgba = sampleRgbaBilinear(imageData, sx, sy);
|
|
5399
|
+
const p = (y * width + x) * 4;
|
|
5400
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
|
|
5401
|
+
}
|
|
5402
|
+
}
|
|
5403
|
+
return { width, height, data: out };
|
|
5404
|
+
}
|
|
5405
|
+
|
|
5406
|
+
function boxBlurImage(imageData, radius) {
|
|
5407
|
+
radius = Math.max(1, Math.round(radius));
|
|
5408
|
+
const { width, height, data } = imageData;
|
|
5409
|
+
const horizontal = new Float32Array(data.length);
|
|
5410
|
+
const out = new Uint8ClampedArray(data.length);
|
|
5411
|
+
for (let y = 0; y < height; y++) {
|
|
5412
|
+
for (let x = 0; x < width; x++) {
|
|
5413
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
5414
|
+
let sum = 0, count = 0;
|
|
5415
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
5416
|
+
const xx = x + dx;
|
|
5417
|
+
if (xx < 0 || xx >= width) continue;
|
|
5418
|
+
sum += data[(y * width + xx) * 4 + ch]; count++;
|
|
5419
|
+
}
|
|
5420
|
+
horizontal[(y * width + x) * 4 + ch] = sum / count;
|
|
5421
|
+
}
|
|
5422
|
+
horizontal[(y * width + x) * 4 + 3] = 255;
|
|
5423
|
+
}
|
|
5424
|
+
}
|
|
5425
|
+
for (let y = 0; y < height; y++) {
|
|
5426
|
+
for (let x = 0; x < width; x++) {
|
|
5427
|
+
const p = (y * width + x) * 4;
|
|
5428
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
5429
|
+
let sum = 0, count = 0;
|
|
5430
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
5431
|
+
const yy = y + dy;
|
|
5432
|
+
if (yy < 0 || yy >= height) continue;
|
|
5433
|
+
sum += horizontal[(yy * width + x) * 4 + ch]; count++;
|
|
5434
|
+
}
|
|
5435
|
+
out[p + ch] = sum / count;
|
|
5436
|
+
}
|
|
5437
|
+
out[p + 3] = 255;
|
|
5438
|
+
}
|
|
5439
|
+
}
|
|
5440
|
+
return { width, height, data: out };
|
|
5441
|
+
}
|
|
5442
|
+
|
|
5443
|
+
function perspectiveStress(imageData, severity) {
|
|
5444
|
+
const { width, height } = imageData;
|
|
5445
|
+
const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
|
|
5446
|
+
const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
|
|
5447
|
+
const destination = [
|
|
5448
|
+
{ x: inset * 1.15, y: inset * 0.25 },
|
|
5449
|
+
{ x: width - 1 - inset * 0.25, y: inset },
|
|
5450
|
+
{ x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
|
|
5451
|
+
{ x: inset * 0.20, y: height - 1 - inset * 0.95 }
|
|
5452
|
+
];
|
|
5453
|
+
const inverse = computeHomography(destination, source);
|
|
5454
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
5455
|
+
out.fill(255);
|
|
5456
|
+
for (let y = 0; y < height; y++) {
|
|
5457
|
+
for (let x = 0; x < width; x++) {
|
|
5458
|
+
const src = projectPoint(inverse, x, y);
|
|
5459
|
+
if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
|
|
5460
|
+
const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
|
|
5461
|
+
const p = (y * width + x) * 4;
|
|
5462
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
|
|
5463
|
+
}
|
|
5464
|
+
}
|
|
5465
|
+
return { width, height, data: out };
|
|
5466
|
+
}
|
|
5467
|
+
|
|
5468
|
+
/** Apply one deterministic camera/print-style distortion to ImageData. */
|
|
5469
|
+
function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
|
|
5470
|
+
assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
|
|
5471
|
+
const s = clampNumber(Number(severity), 0, 1);
|
|
5472
|
+
if (type === "clean") return cloneImageDataLike(imageData);
|
|
5473
|
+
if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
|
|
5474
|
+
if (type === "perspective") return perspectiveStress(imageData, s);
|
|
5475
|
+
if (type === "downscale") {
|
|
5476
|
+
const scale = Math.max(0.20, 1 - s * 0.72);
|
|
5477
|
+
const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
|
|
5478
|
+
return resizeImageDataLike(small, imageData.width, imageData.height);
|
|
5479
|
+
}
|
|
5480
|
+
|
|
5481
|
+
const out = cloneImageDataLike(imageData);
|
|
5482
|
+
const { width, height, data } = out;
|
|
5483
|
+
const quantStep = 4 + Math.round(s * 30);
|
|
5484
|
+
for (let y = 0; y < height; y++) {
|
|
5485
|
+
for (let x = 0; x < width; x++) {
|
|
5486
|
+
const p = (y * width + x) * 4;
|
|
5487
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
5488
|
+
let value = data[p + ch];
|
|
5489
|
+
if (type === "brightness-low") value *= 1 - s * 0.52;
|
|
5490
|
+
else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
|
|
5491
|
+
else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
|
|
5492
|
+
else if (type === "shadow") {
|
|
5493
|
+
const nx = x / Math.max(1, width - 1);
|
|
5494
|
+
const ny = y / Math.max(1, height - 1);
|
|
5495
|
+
const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
|
|
5496
|
+
value *= 1 - shadow * s * 0.62;
|
|
5497
|
+
} else if (type === "jpeg") {
|
|
5498
|
+
value = Math.round(value / quantStep) * quantStep;
|
|
5499
|
+
const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
|
|
5500
|
+
value += block * s * 3;
|
|
5501
|
+
} else if (type === "warm") {
|
|
5502
|
+
if (ch === 0) value *= 1 + s * 0.12;
|
|
5503
|
+
if (ch === 2) value *= 1 - s * 0.22;
|
|
5504
|
+
}
|
|
5505
|
+
data[p + ch] = clampNumber(Math.round(value), 0, 255);
|
|
5506
|
+
}
|
|
5507
|
+
data[p + 3] = 255;
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
return out;
|
|
5511
|
+
}
|
|
5512
|
+
|
|
5513
|
+
function runImageStressTest(imageData, expected = {}, options = {}) {
|
|
5514
|
+
const profiles = options.profiles ?? STRESS_PROFILES;
|
|
5515
|
+
const results = [];
|
|
5516
|
+
let passedWeight = 0;
|
|
5517
|
+
let totalWeight = 0;
|
|
5518
|
+
for (const profile of profiles) {
|
|
5519
|
+
const weight = Number(profile.weight ?? 1);
|
|
5520
|
+
totalWeight += weight;
|
|
5521
|
+
const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
|
|
5522
|
+
const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
5523
|
+
try {
|
|
5524
|
+
const decoded = scanImageData(distorted, {
|
|
5525
|
+
minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
|
|
5526
|
+
maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
|
|
5527
|
+
debug: false
|
|
5528
|
+
});
|
|
5529
|
+
const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
|
|
5530
|
+
if (matches) passedWeight += weight;
|
|
5531
|
+
results.push({
|
|
5532
|
+
id: profile.id,
|
|
5533
|
+
label: profile.label,
|
|
5534
|
+
type: profile.type,
|
|
5535
|
+
severity: profile.severity,
|
|
5536
|
+
passed: matches,
|
|
5537
|
+
confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
|
|
5538
|
+
correctedSymbols: decoded.correctedSymbols ?? 0,
|
|
5539
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
5540
|
+
image: options.includeImages ? distorted : undefined,
|
|
5541
|
+
error: matches ? null : "Decoded payload did not match expected CRC."
|
|
5542
|
+
});
|
|
5543
|
+
} catch (error) {
|
|
5544
|
+
results.push({
|
|
5545
|
+
id: profile.id,
|
|
5546
|
+
label: profile.label,
|
|
5547
|
+
type: profile.type,
|
|
5548
|
+
severity: profile.severity,
|
|
5549
|
+
passed: false,
|
|
5550
|
+
confidence: 0,
|
|
5551
|
+
correctedSymbols: null,
|
|
5552
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
5553
|
+
image: options.includeImages ? distorted : undefined,
|
|
5554
|
+
error: error.message
|
|
5555
|
+
});
|
|
5556
|
+
}
|
|
5557
|
+
}
|
|
5558
|
+
const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
|
|
5559
|
+
const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
|
|
5560
|
+
const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
|
|
5561
|
+
const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
|
|
5562
|
+
return {
|
|
5563
|
+
score,
|
|
5564
|
+
rating,
|
|
5565
|
+
passPercent,
|
|
5566
|
+
passed: results.filter((r) => r.passed).length,
|
|
5567
|
+
total: results.length,
|
|
5568
|
+
averageConfidence,
|
|
5569
|
+
results
|
|
5570
|
+
};
|
|
5571
|
+
}
|
|
5572
|
+
|
|
5573
|
+
/** Render and run the standard scanability torture suite. */
|
|
5574
|
+
function assessScanability(code, renderOptions = {}, options = {}) {
|
|
5575
|
+
assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
|
|
5576
|
+
const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
|
|
5577
|
+
const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
|
|
5578
|
+
const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
|
|
5579
|
+
const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
|
|
5580
|
+
return {
|
|
5581
|
+
...report,
|
|
5582
|
+
version: code.version,
|
|
5583
|
+
eccLevel: code.eccLevel,
|
|
5584
|
+
utilization: code.utilization,
|
|
5585
|
+
testImageSize,
|
|
5586
|
+
autoLogoSize,
|
|
5587
|
+
recommendations: [
|
|
5588
|
+
...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
|
|
5589
|
+
...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
|
|
5590
|
+
...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
|
|
5591
|
+
...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
|
|
5592
|
+
]
|
|
5593
|
+
};
|
|
5594
|
+
}
|
|
5595
|
+
|
|
5596
|
+
function paletteConfidence(observedPalette) {
|
|
5597
|
+
if (!observedPalette) return 0.5;
|
|
5598
|
+
const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
|
|
5599
|
+
if (values.length < 5) return 0.5;
|
|
5600
|
+
let minDistance = Infinity;
|
|
5601
|
+
for (let i = 0; i < values.length; i++) {
|
|
5602
|
+
for (let j = i + 1; j < values.length; j++) {
|
|
5603
|
+
const a = values[i], b = values[j];
|
|
5604
|
+
minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
return clampNumber((minDistance - 28) / 120, 0, 1);
|
|
5608
|
+
}
|
|
5609
|
+
|
|
5610
|
+
function scannerDiagnostics(result, context = {}, options = {}) {
|
|
5611
|
+
const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
|
|
5612
|
+
const correctableTotal = result && info
|
|
5613
|
+
? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
|
|
5614
|
+
: 0;
|
|
5615
|
+
const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
|
|
5616
|
+
const structureConfidence = clampNumber(
|
|
5617
|
+
result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
|
|
5618
|
+
);
|
|
5619
|
+
const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
|
|
5620
|
+
const geometryConfidence = result
|
|
5621
|
+
? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
|
|
5622
|
+
: (context.geometry?.length ? 0.45 : 0);
|
|
5623
|
+
const calibrationConfidence = result
|
|
5624
|
+
? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
|
|
5625
|
+
: 0;
|
|
5626
|
+
const confidence = result
|
|
5627
|
+
? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
|
|
5628
|
+
: 0;
|
|
5629
|
+
const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
|
|
5630
|
+
version: geometry.version,
|
|
5631
|
+
size: geometry.size,
|
|
5632
|
+
score: geometry.score,
|
|
5633
|
+
estimatedSize: geometry.estimatedSize,
|
|
5634
|
+
alignmentScore: geometry.alignmentScore,
|
|
5635
|
+
alignmentGridScore: geometry.alignmentGridScore,
|
|
5636
|
+
corners: geometry.corners ?? geometry.quad ?? null,
|
|
5637
|
+
finders: geometry.finders ?? null
|
|
5638
|
+
}));
|
|
5639
|
+
const observations = context.observations ?? [];
|
|
5640
|
+
const latest = observations.at(-1) ?? null;
|
|
5641
|
+
const stages = {
|
|
5642
|
+
finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
5643
|
+
geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
5644
|
+
sampling: observations.length > 0 || Boolean(result),
|
|
5645
|
+
colorClassification: observations.length > 0 || Boolean(result),
|
|
5646
|
+
ecc: Boolean(result),
|
|
5647
|
+
crc: Boolean(result),
|
|
5648
|
+
payload: Boolean(result)
|
|
5649
|
+
};
|
|
5650
|
+
let failedStage = null;
|
|
5651
|
+
if (!result) {
|
|
5652
|
+
failedStage = !stages.finderDetection ? "finder-detection"
|
|
5653
|
+
: !stages.sampling ? "perspective-geometry"
|
|
5654
|
+
: "color-classification-or-ecc";
|
|
5655
|
+
}
|
|
5656
|
+
return {
|
|
5657
|
+
confidence,
|
|
5658
|
+
cellConfidence,
|
|
5659
|
+
structureConfidence,
|
|
5660
|
+
geometryConfidence,
|
|
5661
|
+
calibrationConfidence,
|
|
5662
|
+
eccUtilization,
|
|
5663
|
+
correctedErrors: result?.correctedSymbols ?? 0,
|
|
5664
|
+
erasureSymbols: result?.erasureSymbols ?? 0,
|
|
5665
|
+
lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
|
|
5666
|
+
stages,
|
|
5667
|
+
failedStage,
|
|
5668
|
+
geometryCandidates,
|
|
5669
|
+
vision: context.vision ?? null,
|
|
5670
|
+
sampled: latest ? {
|
|
5671
|
+
version: latest.version,
|
|
5672
|
+
samplingMode: latest.samplingMode,
|
|
5673
|
+
colorNormalization: latest.colorNormalization,
|
|
5674
|
+
structureScore: latest.structureScore,
|
|
5675
|
+
averageCellConfidence: latest.averageCellConfidence,
|
|
5676
|
+
observedPalette: latest.observedPalette ?? null,
|
|
5677
|
+
matrix: options.debugMatrices === false ? undefined : latest.matrix,
|
|
5678
|
+
confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
|
|
5679
|
+
} : null
|
|
5680
|
+
};
|
|
5681
|
+
}
|
|
5682
|
+
|
|
5683
|
+
function decorateScanResult(result, context, options) {
|
|
5684
|
+
const diagnostics = scannerDiagnostics(result, context, options);
|
|
5685
|
+
return {
|
|
5686
|
+
...result,
|
|
5687
|
+
confidence: diagnostics.confidence,
|
|
5688
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
5689
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
5690
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
5691
|
+
correctedErrors: result.correctedSymbols ?? 0,
|
|
5692
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
5693
|
+
diagnostics: options.debug ? diagnostics : {
|
|
5694
|
+
confidence: diagnostics.confidence,
|
|
5695
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
5696
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
5697
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
5698
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
5699
|
+
correctedErrors: diagnostics.correctedErrors,
|
|
5700
|
+
erasureSymbols: diagnostics.erasureSymbols
|
|
5701
|
+
}
|
|
5702
|
+
};
|
|
5703
|
+
}
|
|
5704
|
+
|
|
5705
|
+
/** Non-throwing scanner debug API with detailed stage diagnostics. */
|
|
5706
|
+
function debugScanImageData(imageData, options = {}) {
|
|
5707
|
+
try {
|
|
5708
|
+
const result = scanImageData(imageData, { ...options, debug: true });
|
|
5709
|
+
return { ok: true, result, debug: result.diagnostics };
|
|
5710
|
+
} catch (error) {
|
|
5711
|
+
return { ok: false, error: error.message, debug: error.debug ?? null };
|
|
5712
|
+
}
|
|
5713
|
+
}
|
|
5714
|
+
|
|
3382
5715
|
function scanImageData(imageData, options = {}) {
|
|
3383
5716
|
assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
|
|
3384
5717
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
@@ -3386,19 +5719,103 @@ function scanImageData(imageData, options = {}) {
|
|
|
3386
5719
|
validateVersion(minVersion);
|
|
3387
5720
|
validateVersion(maxVersion);
|
|
3388
5721
|
|
|
5722
|
+
const geometryCollector = [];
|
|
5723
|
+
const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
|
|
5724
|
+
const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
|
|
5725
|
+
const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
|
|
5726
|
+
const scanOptions = {
|
|
5727
|
+
...options,
|
|
5728
|
+
_geometryCollector: geometryCollector,
|
|
5729
|
+
_observationCollector: observationCollector,
|
|
5730
|
+
_visionDiagnostics: visionDiagnostics
|
|
5731
|
+
};
|
|
5732
|
+
|
|
3389
5733
|
if (options.perspective !== false) {
|
|
3390
|
-
const perspective = tryPerspectiveScan(imageData,
|
|
3391
|
-
if (perspective) return perspective;
|
|
5734
|
+
const perspective = tryPerspectiveScan(imageData, scanOptions);
|
|
5735
|
+
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
5736
|
+
}
|
|
5737
|
+
|
|
5738
|
+
// If the normal scanner could see QuadQR geometry but could not decode the
|
|
5739
|
+
// colors, retry the same frame after a single global Auto Tone / Contrast /
|
|
5740
|
+
// Color-style pass. This is deliberately after the normal path so clean
|
|
5741
|
+
// camera scanning remains unchanged and fast.
|
|
5742
|
+
if (
|
|
5743
|
+
options.autoEnhanceRecovery !== false &&
|
|
5744
|
+
options.fullFrameAutoEnhanceRecovery !== false &&
|
|
5745
|
+
!options._autoEnhancedRecovery &&
|
|
5746
|
+
geometryCollector.length > 0
|
|
5747
|
+
) {
|
|
5748
|
+
try {
|
|
5749
|
+
const enhanced = autoToneContrastColorImageData(imageData, {
|
|
5750
|
+
blackClip: options.autoEnhanceBlackClip,
|
|
5751
|
+
whiteClip: options.autoEnhanceWhiteClip,
|
|
5752
|
+
saturation: options.autoEnhanceSaturation,
|
|
5753
|
+
targetSamples: options.autoEnhanceTargetSamples
|
|
5754
|
+
});
|
|
5755
|
+
const recovered = tryPerspectiveScan(enhanced, {
|
|
5756
|
+
...options,
|
|
5757
|
+
_autoEnhancedRecovery: true,
|
|
5758
|
+
autoEnhanceRecovery: false,
|
|
5759
|
+
_geometryCollector: []
|
|
5760
|
+
});
|
|
5761
|
+
if (recovered) {
|
|
5762
|
+
return decorateScanResult({
|
|
5763
|
+
...recovered,
|
|
5764
|
+
autoEnhanced: true,
|
|
5765
|
+
recoveryMode: "auto-tone-contrast-color",
|
|
5766
|
+
originalColorNormalization: recovered.colorNormalization
|
|
5767
|
+
}, debugContext, options);
|
|
5768
|
+
}
|
|
5769
|
+
} catch {
|
|
5770
|
+
// Continue to the axis-aligned fallback.
|
|
5771
|
+
}
|
|
3392
5772
|
}
|
|
3393
5773
|
|
|
3394
5774
|
if (options.axisAlignedFallback !== false) {
|
|
3395
|
-
const axis = tryAxisAlignedScan(imageData,
|
|
3396
|
-
if (axis) return axis;
|
|
5775
|
+
const axis = tryAxisAlignedScan(imageData, scanOptions);
|
|
5776
|
+
if (axis) return decorateScanResult(axis, debugContext, options);
|
|
3397
5777
|
}
|
|
3398
5778
|
|
|
3399
|
-
|
|
5779
|
+
// Static images sometimes have such low contrast that locator detection itself
|
|
5780
|
+
// fails. Permit one enhanced full retry in that case. The live camera disables
|
|
5781
|
+
// this on its first pass so an empty frame never becomes expensive.
|
|
5782
|
+
if (
|
|
5783
|
+
options.autoEnhanceRecovery !== false &&
|
|
5784
|
+
!options._autoEnhancedRecovery &&
|
|
5785
|
+
geometryCollector.length === 0 &&
|
|
5786
|
+
options.autoEnhanceWhenNoGeometry !== false
|
|
5787
|
+
) {
|
|
5788
|
+
try {
|
|
5789
|
+
const enhanced = autoToneContrastColorImageData(imageData, {
|
|
5790
|
+
blackClip: options.autoEnhanceBlackClip,
|
|
5791
|
+
whiteClip: options.autoEnhanceWhiteClip,
|
|
5792
|
+
saturation: options.autoEnhanceSaturation,
|
|
5793
|
+
targetSamples: options.autoEnhanceTargetSamples
|
|
5794
|
+
});
|
|
5795
|
+
const recoveryOptions = {
|
|
5796
|
+
...options,
|
|
5797
|
+
_autoEnhancedRecovery: true,
|
|
5798
|
+
autoEnhanceRecovery: false,
|
|
5799
|
+
_geometryCollector: []
|
|
5800
|
+
};
|
|
5801
|
+
if (options.perspective !== false) {
|
|
5802
|
+
const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
|
|
5803
|
+
if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
5804
|
+
}
|
|
5805
|
+
if (options.axisAlignedFallback !== false) {
|
|
5806
|
+
const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
|
|
5807
|
+
if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
5808
|
+
}
|
|
5809
|
+
} catch {
|
|
5810
|
+
// Fall through to the normal scan failure below.
|
|
5811
|
+
}
|
|
5812
|
+
}
|
|
5813
|
+
|
|
5814
|
+
const error = new Error(
|
|
3400
5815
|
"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
5816
|
);
|
|
5817
|
+
if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
|
|
5818
|
+
throw error;
|
|
3402
5819
|
}
|
|
3403
5820
|
|
|
3404
5821
|
async function scanFile(file, options = {}) {
|
|
@@ -3427,19 +5844,274 @@ async function scanFile(file, options = {}) {
|
|
|
3427
5844
|
return scanImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), options);
|
|
3428
5845
|
}
|
|
3429
5846
|
|
|
5847
|
+
function parseObjectPositionFraction(value, fallback = 0.5) {
|
|
5848
|
+
if (typeof value !== "string") return fallback;
|
|
5849
|
+
const token = value.trim().toLowerCase();
|
|
5850
|
+
if (token === "left" || token === "top") return 0;
|
|
5851
|
+
if (token === "right" || token === "bottom") return 1;
|
|
5852
|
+
if (token === "center") return 0.5;
|
|
5853
|
+
if (token.endsWith("%")) {
|
|
5854
|
+
const parsed = Number.parseFloat(token);
|
|
5855
|
+
if (Number.isFinite(parsed)) return clampNumber(parsed / 100, 0, 1);
|
|
5856
|
+
}
|
|
5857
|
+
return fallback;
|
|
5858
|
+
}
|
|
5859
|
+
|
|
5860
|
+
function visibleVideoSourceRect(video, options = {}) {
|
|
5861
|
+
const sourceWidth = video.videoWidth;
|
|
5862
|
+
const sourceHeight = video.videoHeight;
|
|
5863
|
+
const full = { x: 0, y: 0, width: sourceWidth, height: sourceHeight, cropped: false };
|
|
5864
|
+
if (options.videoCropMode === "full") return full;
|
|
5865
|
+
|
|
5866
|
+
let boxWidth = Number(video.clientWidth) || 0;
|
|
5867
|
+
let boxHeight = Number(video.clientHeight) || 0;
|
|
5868
|
+
if ((!boxWidth || !boxHeight) && typeof video.getBoundingClientRect === "function") {
|
|
5869
|
+
const rect = video.getBoundingClientRect();
|
|
5870
|
+
boxWidth = boxWidth || rect.width;
|
|
5871
|
+
boxHeight = boxHeight || rect.height;
|
|
5872
|
+
}
|
|
5873
|
+
if (!boxWidth || !boxHeight) return full;
|
|
5874
|
+
|
|
5875
|
+
let objectFit = options.videoObjectFit;
|
|
5876
|
+
let objectPosition = options.videoObjectPosition;
|
|
5877
|
+
if ((!objectFit || !objectPosition) && typeof getComputedStyle === "function") {
|
|
5878
|
+
try {
|
|
5879
|
+
const style = getComputedStyle(video);
|
|
5880
|
+
objectFit = objectFit || style.objectFit;
|
|
5881
|
+
objectPosition = objectPosition || style.objectPosition;
|
|
5882
|
+
} catch {
|
|
5883
|
+
// Fall back to the demo's normal cover/center behavior.
|
|
5884
|
+
}
|
|
5885
|
+
}
|
|
5886
|
+
objectFit = objectFit || "cover";
|
|
5887
|
+
if (objectFit !== "cover") return full;
|
|
5888
|
+
|
|
5889
|
+
const scale = Math.max(boxWidth / sourceWidth, boxHeight / sourceHeight);
|
|
5890
|
+
if (!Number.isFinite(scale) || scale <= 0) return full;
|
|
5891
|
+
let cropWidth = Math.min(sourceWidth, boxWidth / scale);
|
|
5892
|
+
let cropHeight = Math.min(sourceHeight, boxHeight / scale);
|
|
5893
|
+
if (cropWidth >= sourceWidth - 1 && cropHeight >= sourceHeight - 1) return full;
|
|
5894
|
+
|
|
5895
|
+
const positionTokens = String(objectPosition || "50% 50%").trim().split(/\s+/);
|
|
5896
|
+
const positionX = parseObjectPositionFraction(positionTokens[0], 0.5);
|
|
5897
|
+
const positionY = parseObjectPositionFraction(positionTokens[1] ?? positionTokens[0], 0.5);
|
|
5898
|
+
let x = (sourceWidth - cropWidth) * positionX;
|
|
5899
|
+
let y = (sourceHeight - cropHeight) * positionY;
|
|
5900
|
+
|
|
5901
|
+
// Optional tiny inset removes the soft edge of a camera preview without
|
|
5902
|
+
// changing the visible composition. Leave at zero unless explicitly set.
|
|
5903
|
+
const inset = clampNumber(options.videoCropInset ?? 0, 0, 0.18);
|
|
5904
|
+
if (inset > 0) {
|
|
5905
|
+
const dx = cropWidth * inset;
|
|
5906
|
+
const dy = cropHeight * inset;
|
|
5907
|
+
x += dx;
|
|
5908
|
+
y += dy;
|
|
5909
|
+
cropWidth -= dx * 2;
|
|
5910
|
+
cropHeight -= dy * 2;
|
|
5911
|
+
}
|
|
5912
|
+
|
|
5913
|
+
x = clampNumber(x, 0, Math.max(0, sourceWidth - cropWidth));
|
|
5914
|
+
y = clampNumber(y, 0, Math.max(0, sourceHeight - cropHeight));
|
|
5915
|
+
return { x, y, width: cropWidth, height: cropHeight, cropped: true };
|
|
5916
|
+
}
|
|
5917
|
+
|
|
5918
|
+
function cropImageDataInset(imageData, insetFraction = 0) {
|
|
5919
|
+
const inset = clampNumber(Number(insetFraction) || 0, 0, 0.32);
|
|
5920
|
+
if (inset <= 0) {
|
|
5921
|
+
return {
|
|
5922
|
+
imageData,
|
|
5923
|
+
rect: { x: 0, y: 0, width: imageData.width, height: imageData.height }
|
|
5924
|
+
};
|
|
5925
|
+
}
|
|
5926
|
+
|
|
5927
|
+
const x = Math.max(0, Math.floor(imageData.width * inset));
|
|
5928
|
+
const y = Math.max(0, Math.floor(imageData.height * inset));
|
|
5929
|
+
const width = Math.max(1, imageData.width - x * 2);
|
|
5930
|
+
const height = Math.max(1, imageData.height - y * 2);
|
|
5931
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
5932
|
+
for (let row = 0; row < height; row++) {
|
|
5933
|
+
const sourceStart = ((y + row) * imageData.width + x) * 4;
|
|
5934
|
+
const sourceEnd = sourceStart + width * 4;
|
|
5935
|
+
data.set(imageData.data.subarray(sourceStart, sourceEnd), row * width * 4);
|
|
5936
|
+
}
|
|
5937
|
+
return {
|
|
5938
|
+
imageData: { width, height, data },
|
|
5939
|
+
rect: { x, y, width, height }
|
|
5940
|
+
};
|
|
5941
|
+
}
|
|
5942
|
+
|
|
5943
|
+
function bestVisionDiagnosticPass(visionDiagnostics) {
|
|
5944
|
+
const passes = visionDiagnostics?.passes;
|
|
5945
|
+
if (!Array.isArray(passes) || !passes.length) return null;
|
|
5946
|
+
return passes.slice().sort((a, b) => {
|
|
5947
|
+
const aGeometry = a.geometries?.[0];
|
|
5948
|
+
const bGeometry = b.geometries?.[0];
|
|
5949
|
+
return (Boolean(bGeometry) - Boolean(aGeometry)) ||
|
|
5950
|
+
((b.finderCount ?? 0) - (a.finderCount ?? 0)) ||
|
|
5951
|
+
((bGeometry?.score ?? 0) - (aGeometry?.score ?? 0));
|
|
5952
|
+
})[0];
|
|
5953
|
+
}
|
|
5954
|
+
|
|
5955
|
+
function nowMs() {
|
|
5956
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
5957
|
+
? performance.now()
|
|
5958
|
+
: Date.now();
|
|
5959
|
+
}
|
|
5960
|
+
|
|
5961
|
+
function normalizeFrameDiagnostics(frameDiagnostics, source, width, height, visionDiagnostics) {
|
|
5962
|
+
if (!frameDiagnostics || typeof frameDiagnostics !== "object") return;
|
|
5963
|
+
frameDiagnostics.scanWidth = width;
|
|
5964
|
+
frameDiagnostics.scanHeight = height;
|
|
5965
|
+
frameDiagnostics.frameWidth = width;
|
|
5966
|
+
frameDiagnostics.frameHeight = height;
|
|
5967
|
+
frameDiagnostics.scanRect = { x: 0, y: 0, width, height };
|
|
5968
|
+
frameDiagnostics.sourceRect = {
|
|
5969
|
+
x: source.x,
|
|
5970
|
+
y: source.y,
|
|
5971
|
+
width: source.width,
|
|
5972
|
+
height: source.height,
|
|
5973
|
+
cropped: Boolean(source.cropped)
|
|
5974
|
+
};
|
|
5975
|
+
frameDiagnostics.vision = visionDiagnostics;
|
|
5976
|
+
const bestPass = bestVisionDiagnosticPass(visionDiagnostics);
|
|
5977
|
+
frameDiagnostics.bestPass = bestPass;
|
|
5978
|
+
frameDiagnostics.finderCount = bestPass?.finderCount ?? 0;
|
|
5979
|
+
frameDiagnostics.finders = bestPass?.finders ?? [];
|
|
5980
|
+
frameDiagnostics.finderMethod = bestPass?.finderMethod ?? null;
|
|
5981
|
+
frameDiagnostics.finderPasses = Array.isArray(visionDiagnostics?.passes)
|
|
5982
|
+
? visionDiagnostics.passes.map((pass) => ({
|
|
5983
|
+
method: pass.finderMethod ?? pass.label,
|
|
5984
|
+
finderCount: pass.finderCount ?? 0,
|
|
5985
|
+
threshold: pass.threshold,
|
|
5986
|
+
geometryCount: pass.geometries?.length ?? 0
|
|
5987
|
+
}))
|
|
5988
|
+
: [];
|
|
5989
|
+
frameDiagnostics.geometry = bestPass?.geometries?.[0] ?? null;
|
|
5990
|
+
}
|
|
5991
|
+
|
|
3430
5992
|
function scanVideoFrame(video, options = {}) {
|
|
3431
5993
|
assert(typeof document !== "undefined", "scanVideoFrame is a browser API.");
|
|
3432
5994
|
assert(video && video.videoWidth && video.videoHeight, "Video frame is not ready.");
|
|
5995
|
+
|
|
5996
|
+
// Scan what the user actually sees. On phones the video element is usually a
|
|
5997
|
+
// portrait box with object-fit: cover while the camera sensor stream is 16:9.
|
|
5998
|
+
// Previously we scanned the entire hidden sensor frame, so the QR looked big
|
|
5999
|
+
// in the guide but became much smaller after canvas downscaling. Cropping to
|
|
6000
|
+
// the visible source region both improves module resolution and reduces work.
|
|
6001
|
+
const source = visibleVideoSourceRect(video, options);
|
|
3433
6002
|
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(
|
|
6003
|
+
const scale = Math.min(1, maxDimension / Math.max(source.width, source.height));
|
|
6004
|
+
const width = Math.max(1, Math.round(source.width * scale));
|
|
6005
|
+
const height = Math.max(1, Math.round(source.height * scale));
|
|
3437
6006
|
const canvas = options.canvas ?? document.createElement("canvas");
|
|
3438
6007
|
canvas.width = width;
|
|
3439
6008
|
canvas.height = height;
|
|
3440
6009
|
const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
3441
|
-
|
|
3442
|
-
|
|
6010
|
+
// High-quality downscaling is preferable for finder geometry, but visible-ROI
|
|
6011
|
+
// cropping means many mobile frames now need little or no downscaling at all.
|
|
6012
|
+
if ("imageSmoothingEnabled" in ctx) ctx.imageSmoothingEnabled = true;
|
|
6013
|
+
if ("imageSmoothingQuality" in ctx) ctx.imageSmoothingQuality = "high";
|
|
6014
|
+
ctx.drawImage(video, source.x, source.y, source.width, source.height, 0, 0, width, height);
|
|
6015
|
+
const frameImageData = ctx.getImageData(0, 0, width, height);
|
|
6016
|
+
if (options._capturedFrame && typeof options._capturedFrame === "object") {
|
|
6017
|
+
options._capturedFrame.imageData = frameImageData;
|
|
6018
|
+
options._capturedFrame.scanWidth = width;
|
|
6019
|
+
options._capturedFrame.scanHeight = height;
|
|
6020
|
+
options._capturedFrame.source = { ...source };
|
|
6021
|
+
}
|
|
6022
|
+
const visionDiagnostics = options._frameDiagnostics ? { passes: [] } : null;
|
|
6023
|
+
try {
|
|
6024
|
+
const result = scanImageData(frameImageData, {
|
|
6025
|
+
...options,
|
|
6026
|
+
_visionDiagnostics: visionDiagnostics
|
|
6027
|
+
});
|
|
6028
|
+
normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
|
|
6029
|
+
if (!source.cropped) return result;
|
|
6030
|
+
return {
|
|
6031
|
+
...result,
|
|
6032
|
+
cameraVisibleCrop: true,
|
|
6033
|
+
cameraSourceRect: { x: source.x, y: source.y, width: source.width, height: source.height }
|
|
6034
|
+
};
|
|
6035
|
+
} catch (error) {
|
|
6036
|
+
normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
|
|
6037
|
+
throw error;
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
|
|
6041
|
+
function selectBestFrameObservation(observations) {
|
|
6042
|
+
if (!observations?.length) return null;
|
|
6043
|
+
return observations
|
|
6044
|
+
.filter((item) => item?.matrix && item.structureScore >= 0.82)
|
|
6045
|
+
.sort((a, b) =>
|
|
6046
|
+
(b.structureScore - a.structureScore) ||
|
|
6047
|
+
(b.averageCellConfidence - a.averageCellConfidence) ||
|
|
6048
|
+
((b.geometry?.score ?? 0) - (a.geometry?.score ?? 0))
|
|
6049
|
+
)[0] ?? null;
|
|
6050
|
+
}
|
|
6051
|
+
|
|
6052
|
+
function combineFrameObservations(observations) {
|
|
6053
|
+
if (!observations?.length) return null;
|
|
6054
|
+
const version = observations[0].version;
|
|
6055
|
+
const size = observations[0].matrix.length;
|
|
6056
|
+
if (!observations.every((item) => item.version === version && item.matrix.length === size)) return null;
|
|
6057
|
+
|
|
6058
|
+
const matrix = make2D(size, CELL.WHITE);
|
|
6059
|
+
const confidence = make2D(size, 0);
|
|
6060
|
+
for (let r = 0; r < size; r++) {
|
|
6061
|
+
for (let c = 0; c < size; c++) {
|
|
6062
|
+
const votes = new Map();
|
|
6063
|
+
const confidenceByCell = new Map();
|
|
6064
|
+
for (const observation of observations) {
|
|
6065
|
+
const cell = observation.matrix[r][c];
|
|
6066
|
+
const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
|
|
6067
|
+
const weight = 0.35 + sourceConfidence * 0.65;
|
|
6068
|
+
votes.set(cell, (votes.get(cell) ?? 0) + weight);
|
|
6069
|
+
const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
|
|
6070
|
+
stats.weighted += sourceConfidence * weight;
|
|
6071
|
+
stats.weight += weight;
|
|
6072
|
+
confidenceByCell.set(cell, stats);
|
|
6073
|
+
}
|
|
6074
|
+
|
|
6075
|
+
const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
|
|
6076
|
+
const [bestCell, bestWeight] = ranked[0];
|
|
6077
|
+
const secondWeight = ranked[1]?.[1] ?? 0;
|
|
6078
|
+
const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
|
|
6079
|
+
const sourceStats = confidenceByCell.get(bestCell);
|
|
6080
|
+
const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
|
|
6081
|
+
const support = totalWeight ? bestWeight / totalWeight : 0;
|
|
6082
|
+
const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
|
|
6083
|
+
const agreement = 0.5 * support + 0.5 * margin;
|
|
6084
|
+
|
|
6085
|
+
matrix[r][c] = bestCell;
|
|
6086
|
+
// Do not become overconfident just because several blurry frames agree.
|
|
6087
|
+
// Retaining source uncertainty lets Reed-Solomon treat repeated ambiguous
|
|
6088
|
+
// cells as erasures instead of hard, supposedly-certain errors.
|
|
6089
|
+
confidence[r][c] = clampNumber(sourceConfidence * 0.58 + agreement * 0.42, 0, 1);
|
|
6090
|
+
}
|
|
6091
|
+
}
|
|
6092
|
+
return { version, matrix, confidence };
|
|
6093
|
+
}
|
|
6094
|
+
|
|
6095
|
+
async function improveCameraTrack(stream) {
|
|
6096
|
+
const track = stream.getVideoTracks?.()[0];
|
|
6097
|
+
if (!track?.applyConstraints || !track.getCapabilities) return;
|
|
6098
|
+
try {
|
|
6099
|
+
const capabilities = track.getCapabilities();
|
|
6100
|
+
const advanced = {};
|
|
6101
|
+
if (Array.isArray(capabilities.focusMode) && capabilities.focusMode.includes("continuous")) {
|
|
6102
|
+
advanced.focusMode = "continuous";
|
|
6103
|
+
}
|
|
6104
|
+
if (Array.isArray(capabilities.exposureMode) && capabilities.exposureMode.includes("continuous")) {
|
|
6105
|
+
advanced.exposureMode = "continuous";
|
|
6106
|
+
}
|
|
6107
|
+
if (Array.isArray(capabilities.whiteBalanceMode) && capabilities.whiteBalanceMode.includes("continuous")) {
|
|
6108
|
+
advanced.whiteBalanceMode = "continuous";
|
|
6109
|
+
}
|
|
6110
|
+
if (Object.keys(advanced).length) await track.applyConstraints({ advanced: [advanced] });
|
|
6111
|
+
} catch {
|
|
6112
|
+
// Browsers expose different subsets of camera controls. Scanner-side
|
|
6113
|
+
// calibration remains the primary path when these hints are unsupported.
|
|
6114
|
+
}
|
|
3443
6115
|
}
|
|
3444
6116
|
|
|
3445
6117
|
async function startCameraScanner(video, options = {}) {
|
|
@@ -3451,11 +6123,13 @@ async function startCameraScanner(video, options = {}) {
|
|
|
3451
6123
|
audio: false,
|
|
3452
6124
|
video: {
|
|
3453
6125
|
facingMode: { ideal: "environment" },
|
|
3454
|
-
width: { ideal:
|
|
3455
|
-
height: { ideal:
|
|
6126
|
+
width: { ideal: 1920 },
|
|
6127
|
+
height: { ideal: 1080 },
|
|
6128
|
+
frameRate: { ideal: 30 }
|
|
3456
6129
|
}
|
|
3457
6130
|
}
|
|
3458
6131
|
);
|
|
6132
|
+
await improveCameraTrack(stream);
|
|
3459
6133
|
|
|
3460
6134
|
video.srcObject = stream;
|
|
3461
6135
|
video.setAttribute("playsinline", "");
|
|
@@ -3464,32 +6138,435 @@ async function startCameraScanner(video, options = {}) {
|
|
|
3464
6138
|
|
|
3465
6139
|
const scanInterval = Math.max(80, options.scanInterval ?? 180);
|
|
3466
6140
|
const scratchCanvas = document.createElement("canvas");
|
|
6141
|
+
const multiFrameEnabled = options.multiFrame !== false;
|
|
6142
|
+
const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
|
|
6143
|
+
const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
|
|
6144
|
+
const observationHistory = new Map();
|
|
6145
|
+
const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
|
|
6146
|
+
const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
|
|
6147
|
+
const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
|
|
6148
|
+
let missStreak = 0;
|
|
3467
6149
|
let stopped = false;
|
|
3468
6150
|
let busy = false;
|
|
3469
6151
|
let timer = null;
|
|
6152
|
+
let frameNumber = 0;
|
|
6153
|
+
|
|
6154
|
+
const diagnosticsEnabled = typeof options.onDiagnostic === "function";
|
|
6155
|
+
const emitDiagnostic = (event) => {
|
|
6156
|
+
if (!diagnosticsEnabled) return;
|
|
6157
|
+
try {
|
|
6158
|
+
options.onDiagnostic({
|
|
6159
|
+
timestamp: Date.now(),
|
|
6160
|
+
frame: frameNumber,
|
|
6161
|
+
...event
|
|
6162
|
+
});
|
|
6163
|
+
} catch {
|
|
6164
|
+
// Diagnostics are UI-only and must never interrupt scanning.
|
|
6165
|
+
}
|
|
6166
|
+
};
|
|
6167
|
+
|
|
6168
|
+
if (diagnosticsEnabled) {
|
|
6169
|
+
const track = stream.getVideoTracks?.()[0];
|
|
6170
|
+
const settings = track?.getSettings?.() ?? {};
|
|
6171
|
+
emitDiagnostic({
|
|
6172
|
+
type: "camera-ready",
|
|
6173
|
+
method: "camera",
|
|
6174
|
+
message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight}` ,
|
|
6175
|
+
camera: {
|
|
6176
|
+
width: settings.width ?? video.videoWidth,
|
|
6177
|
+
height: settings.height ?? video.videoHeight,
|
|
6178
|
+
frameRate: settings.frameRate ?? null,
|
|
6179
|
+
facingMode: settings.facingMode ?? null
|
|
6180
|
+
}
|
|
6181
|
+
});
|
|
6182
|
+
}
|
|
3470
6183
|
|
|
3471
6184
|
const stop = () => {
|
|
3472
6185
|
stopped = true;
|
|
3473
6186
|
if (timer) clearTimeout(timer);
|
|
6187
|
+
observationHistory.clear();
|
|
3474
6188
|
for (const track of stream.getTracks()) track.stop();
|
|
3475
6189
|
if (video.srcObject === stream) video.srcObject = null;
|
|
3476
6190
|
};
|
|
3477
6191
|
|
|
3478
|
-
const scanNow = () => scanVideoFrame(video, {
|
|
6192
|
+
const scanNow = () => scanVideoFrame(video, {
|
|
6193
|
+
...options,
|
|
6194
|
+
maxDimension: options.maxDimension ?? 1080,
|
|
6195
|
+
canvas: scratchCanvas
|
|
6196
|
+
});
|
|
6197
|
+
|
|
6198
|
+
const emitResult = (result, capturedFrame = null, diagnostic = null) => {
|
|
6199
|
+
const frameMeta = capturedFrame?.imageData ? {
|
|
6200
|
+
frame: frameNumber,
|
|
6201
|
+
imageData: capturedFrame.imageData,
|
|
6202
|
+
scanWidth: capturedFrame.scanWidth,
|
|
6203
|
+
scanHeight: capturedFrame.scanHeight,
|
|
6204
|
+
sourceRect: capturedFrame.source ? { ...capturedFrame.source } : null,
|
|
6205
|
+
enhancedImageData: capturedFrame.enhancedImageData ?? null,
|
|
6206
|
+
enhancedRect: capturedFrame.enhancedRect ? { ...capturedFrame.enhancedRect } : null,
|
|
6207
|
+
enhancement: capturedFrame.enhancement ? { ...capturedFrame.enhancement } : null,
|
|
6208
|
+
diagnostic
|
|
6209
|
+
} : null;
|
|
6210
|
+
options.onResult?.(result, frameMeta);
|
|
6211
|
+
options.onDecode?.(result, frameMeta);
|
|
6212
|
+
if (options.stopOnResult ?? true) {
|
|
6213
|
+
stop();
|
|
6214
|
+
return true;
|
|
6215
|
+
}
|
|
6216
|
+
return false;
|
|
6217
|
+
};
|
|
6218
|
+
|
|
6219
|
+
const tryMultiFrameDecode = (observations) => {
|
|
6220
|
+
if (!multiFrameEnabled) return null;
|
|
6221
|
+
const best = selectBestFrameObservation(observations);
|
|
6222
|
+
if (!best) return null;
|
|
6223
|
+
const history = observationHistory.get(best.version) ?? [];
|
|
6224
|
+
history.push(best);
|
|
6225
|
+
while (history.length > multiFrameWindow) history.shift();
|
|
6226
|
+
observationHistory.set(best.version, history);
|
|
6227
|
+
if (history.length < multiFrameMinFrames) return null;
|
|
6228
|
+
|
|
6229
|
+
const combined = combineFrameObservations(history);
|
|
6230
|
+
if (!combined) return null;
|
|
6231
|
+
try {
|
|
6232
|
+
const decoded = decodeMatrix(combined.matrix, {
|
|
6233
|
+
structureTolerance: options.structureTolerance ?? 0.20,
|
|
6234
|
+
cellConfidence: combined.confidence,
|
|
6235
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
6236
|
+
});
|
|
6237
|
+
if (decoded.version !== best.version) return null;
|
|
6238
|
+
return {
|
|
6239
|
+
...decoded,
|
|
6240
|
+
perspectiveCorrected: Boolean(best.geometry),
|
|
6241
|
+
colorCalibrated: true,
|
|
6242
|
+
colorNormalization: "multi-frame-vote",
|
|
6243
|
+
samplingMode: "multi-frame-vote",
|
|
6244
|
+
multiFrameCombined: history.length,
|
|
6245
|
+
geometry: best.geometry,
|
|
6246
|
+
observedPalette: best.observedPalette,
|
|
6247
|
+
averageCellConfidence: best.averageCellConfidence,
|
|
6248
|
+
lowConfidenceCells: best.lowConfidenceCells
|
|
6249
|
+
};
|
|
6250
|
+
} catch {
|
|
6251
|
+
return null;
|
|
6252
|
+
}
|
|
6253
|
+
};
|
|
3479
6254
|
|
|
3480
6255
|
const loop = async () => {
|
|
3481
6256
|
if (stopped) return;
|
|
3482
6257
|
if (!busy && video.readyState >= 2) {
|
|
3483
6258
|
busy = true;
|
|
6259
|
+
frameNumber++;
|
|
6260
|
+
const observations = [];
|
|
6261
|
+
const frameDiagnostics = diagnosticsEnabled ? {} : null;
|
|
6262
|
+
const capturedFrame = {};
|
|
6263
|
+
const frameStarted = nowMs();
|
|
6264
|
+
let allowAutoEnhance = false;
|
|
6265
|
+
let allowFinderRecovery = false;
|
|
3484
6266
|
try {
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
6267
|
+
// The first frame stays minimal. The primary finder pass itself is now
|
|
6268
|
+
// QuadQR-aware (max RGB/value channel), so it remains cheap while no
|
|
6269
|
+
// longer confusing dark blue data with structural black. After a miss,
|
|
6270
|
+
// alternate frames may bracket finder thresholds and then use the
|
|
6271
|
+
// stronger color recovery path. Empty camera frames therefore do not
|
|
6272
|
+
// pay every recovery cost on every scan tick.
|
|
6273
|
+
allowFinderRecovery = options.finderRecovery !== false &&
|
|
6274
|
+
missStreak > 0 && ((missStreak - 1) % cameraFinderRecoveryEvery === 0);
|
|
6275
|
+
allowAutoEnhance = options.autoEnhanceRecovery !== false &&
|
|
6276
|
+
missStreak > 0 && ((missStreak - 1) % cameraAutoEnhanceEvery === 0);
|
|
6277
|
+
const method = allowAutoEnhance
|
|
6278
|
+
? "progressive-color-recovery"
|
|
6279
|
+
: (allowFinderRecovery ? "finder-recovery" : "fast-scan");
|
|
6280
|
+
const result = scanVideoFrame(video, {
|
|
6281
|
+
...options,
|
|
6282
|
+
_diagnosticLabel: method,
|
|
6283
|
+
finderRecovery: allowFinderRecovery,
|
|
6284
|
+
autoEnhanceRecovery: allowAutoEnhance,
|
|
6285
|
+
autoEnhanceWhenNoGeometry: allowAutoEnhance,
|
|
6286
|
+
// If geometry exists, the rectified QR-only pixel enhancer is both
|
|
6287
|
+
// stronger and much cheaper than reprocessing the whole live frame.
|
|
6288
|
+
fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
|
|
6289
|
+
maxDimension: options.maxDimension ?? 1080,
|
|
6290
|
+
canvas: scratchCanvas,
|
|
6291
|
+
_capturedFrame: capturedFrame,
|
|
6292
|
+
_observationCollector: observations,
|
|
6293
|
+
_frameDiagnostics: frameDiagnostics
|
|
6294
|
+
});
|
|
6295
|
+
const elapsedMs = nowMs() - frameStarted;
|
|
6296
|
+
emitDiagnostic({
|
|
6297
|
+
type: "frame",
|
|
6298
|
+
state: "decoded",
|
|
6299
|
+
method: result.recoveryMode ?? result.samplingMode ?? method,
|
|
6300
|
+
elapsedMs,
|
|
6301
|
+
missStreak,
|
|
6302
|
+
...frameDiagnostics
|
|
6303
|
+
});
|
|
6304
|
+
emitDiagnostic({
|
|
6305
|
+
type: "success",
|
|
6306
|
+
state: "decoded",
|
|
6307
|
+
method: result.recoveryMode ?? result.samplingMode ?? method,
|
|
6308
|
+
elapsedMs,
|
|
6309
|
+
message: `Decoded v${result.version} · ECC ${result.eccLevel} · ${Math.round(elapsedMs)} ms`,
|
|
6310
|
+
...frameDiagnostics
|
|
6311
|
+
});
|
|
6312
|
+
missStreak = 0;
|
|
6313
|
+
observationHistory.clear();
|
|
6314
|
+
if (emitResult(result, capturedFrame, frameDiagnostics)) return;
|
|
3491
6315
|
} catch (error) {
|
|
3492
|
-
|
|
6316
|
+
missStreak++;
|
|
6317
|
+
const fastElapsedMs = nowMs() - frameStarted;
|
|
6318
|
+
emitDiagnostic({
|
|
6319
|
+
type: "frame",
|
|
6320
|
+
state: "miss",
|
|
6321
|
+
method: allowAutoEnhance
|
|
6322
|
+
? "progressive-color-recovery"
|
|
6323
|
+
: (allowFinderRecovery ? "finder-recovery" : "fast-scan"),
|
|
6324
|
+
elapsedMs: fastElapsedMs,
|
|
6325
|
+
missStreak,
|
|
6326
|
+
error: error?.message ?? String(error),
|
|
6327
|
+
...frameDiagnostics
|
|
6328
|
+
});
|
|
6329
|
+
|
|
6330
|
+
// The user's real phone-camera case is dominated by color cast before
|
|
6331
|
+
// finder detection: Photoshop Auto Color alone makes the same live QR
|
|
6332
|
+
// immediately detectable. Retry the exact captured frame with a cheap
|
|
6333
|
+
// per-channel Auto Color levels correction before any geometry-dependent
|
|
6334
|
+
// recovery. This runs only after the normal fast scan fails, and by
|
|
6335
|
+
// default only on every other missed frame after the first one.
|
|
6336
|
+
const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
|
|
6337
|
+
capturedFrame.imageData &&
|
|
6338
|
+
((missStreak - 1) % cameraAutoColorEvery === 0);
|
|
6339
|
+
if (shouldTryCameraAutoColor) {
|
|
6340
|
+
const requestedCrops = Array.isArray(options.cameraAutoColorCropInsets)
|
|
6341
|
+
? options.cameraAutoColorCropInsets
|
|
6342
|
+
: [0.08, 0.16, 0.22, 0];
|
|
6343
|
+
const cropInsets = [];
|
|
6344
|
+
for (const value of requestedCrops) {
|
|
6345
|
+
const inset = clampNumber(Number(value), 0, 0.30);
|
|
6346
|
+
if (!cropInsets.some((item) => Math.abs(item - inset) < 0.001)) cropInsets.push(inset);
|
|
6347
|
+
}
|
|
6348
|
+
const explicitAnalysisInsets = Array.isArray(options.cameraAutoColorAnalysisInsets)
|
|
6349
|
+
? options.cameraAutoColorAnalysisInsets
|
|
6350
|
+
: null;
|
|
6351
|
+
|
|
6352
|
+
emitDiagnostic({
|
|
6353
|
+
type: "method",
|
|
6354
|
+
state: "trying",
|
|
6355
|
+
method: "camera-auto-color",
|
|
6356
|
+
message: `Fast scan failed · Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
|
|
6357
|
+
...frameDiagnostics
|
|
6358
|
+
});
|
|
6359
|
+
|
|
6360
|
+
let autoColorDecoded = false;
|
|
6361
|
+
for (let profileIndex = 0; profileIndex < cropInsets.length; profileIndex++) {
|
|
6362
|
+
const cropInset = cropInsets[profileIndex];
|
|
6363
|
+
const cropped = cropImageDataInset(capturedFrame.imageData, cropInset);
|
|
6364
|
+
const defaultAnalysisInsets = [0.10, 0.08, 0.04, 0.10];
|
|
6365
|
+
const analysisInset = clampNumber(
|
|
6366
|
+
Number(explicitAnalysisInsets?.[profileIndex]
|
|
6367
|
+
?? options.cameraAutoColorAnalysisInset
|
|
6368
|
+
?? defaultAnalysisInsets[Math.min(profileIndex, defaultAnalysisInsets.length - 1)]),
|
|
6369
|
+
0,
|
|
6370
|
+
0.30
|
|
6371
|
+
);
|
|
6372
|
+
const autoColorObservations = [];
|
|
6373
|
+
const autoColorVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
|
|
6374
|
+
const autoColorFrameDiagnostics = diagnosticsEnabled ? {} : null;
|
|
6375
|
+
const cropLabel = cropInset ? `${Math.round(cropInset * 100)}pct-crop` : "full";
|
|
6376
|
+
const profileName = `camera-auto-color-${cropLabel}`;
|
|
6377
|
+
try {
|
|
6378
|
+
const recoveryStarted = nowMs();
|
|
6379
|
+
const correctedFrame = autoColorImageData(cropped.imageData, {
|
|
6380
|
+
// This is intentionally performed on a centered recovery crop.
|
|
6381
|
+
// A live preview can contain large dark borders, browser UI, a
|
|
6382
|
+
// monitor bezel or room background. Those pixels completely
|
|
6383
|
+
// change global Auto Color/Otsu statistics even though the QR
|
|
6384
|
+
// itself looks identical to a saved crop. Photoshop succeeded
|
|
6385
|
+
// because the user's edited image was effectively QR-centric.
|
|
6386
|
+
blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
|
|
6387
|
+
whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
|
|
6388
|
+
highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
|
|
6389
|
+
outputHighlight: options.cameraAutoColorOutputHighlight ?? 190,
|
|
6390
|
+
analysisInset,
|
|
6391
|
+
minimumInputRange: options.cameraAutoColorMinimumInputRange ?? 72,
|
|
6392
|
+
targetSamples: options.cameraAutoColorTargetSamples ?? 90000
|
|
6393
|
+
});
|
|
6394
|
+
const recovered = scanImageData(correctedFrame, {
|
|
6395
|
+
...options,
|
|
6396
|
+
_diagnosticLabel: profileName,
|
|
6397
|
+
_visionDiagnostics: autoColorVisionDiagnostics,
|
|
6398
|
+
finderRecovery: true,
|
|
6399
|
+
autoEnhanceRecovery: false,
|
|
6400
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
6401
|
+
_observationCollector: autoColorObservations
|
|
6402
|
+
});
|
|
6403
|
+
if (autoColorFrameDiagnostics) {
|
|
6404
|
+
normalizeFrameDiagnostics(
|
|
6405
|
+
autoColorFrameDiagnostics,
|
|
6406
|
+
capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
|
|
6407
|
+
correctedFrame.width,
|
|
6408
|
+
correctedFrame.height,
|
|
6409
|
+
autoColorVisionDiagnostics
|
|
6410
|
+
);
|
|
6411
|
+
autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
|
|
6412
|
+
autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
|
|
6413
|
+
autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
|
|
6414
|
+
autoColorFrameDiagnostics.autoColorCropInset = cropInset;
|
|
6415
|
+
autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
|
|
6416
|
+
}
|
|
6417
|
+
const recoveryElapsedMs = nowMs() - recoveryStarted;
|
|
6418
|
+
emitDiagnostic({
|
|
6419
|
+
type: "frame",
|
|
6420
|
+
state: "decoded",
|
|
6421
|
+
method: profileName,
|
|
6422
|
+
elapsedMs: recoveryElapsedMs,
|
|
6423
|
+
missStreak,
|
|
6424
|
+
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
6425
|
+
});
|
|
6426
|
+
emitDiagnostic({
|
|
6427
|
+
type: "success",
|
|
6428
|
+
state: "decoded",
|
|
6429
|
+
method: "camera-auto-color",
|
|
6430
|
+
elapsedMs: recoveryElapsedMs,
|
|
6431
|
+
message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
6432
|
+
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
6433
|
+
});
|
|
6434
|
+
capturedFrame.enhancedImageData = correctedFrame;
|
|
6435
|
+
capturedFrame.enhancedRect = { ...cropped.rect };
|
|
6436
|
+
capturedFrame.enhancement = {
|
|
6437
|
+
method: "camera-auto-color",
|
|
6438
|
+
cropInset,
|
|
6439
|
+
analysisInset
|
|
6440
|
+
};
|
|
6441
|
+
missStreak = 0;
|
|
6442
|
+
observationHistory.clear();
|
|
6443
|
+
autoColorDecoded = true;
|
|
6444
|
+
if (emitResult({
|
|
6445
|
+
...recovered,
|
|
6446
|
+
autoColorCorrected: true,
|
|
6447
|
+
cameraProgressiveRecovery: true,
|
|
6448
|
+
recoveryMode: "camera-auto-color",
|
|
6449
|
+
cameraAutoColorCropInset: cropInset,
|
|
6450
|
+
cameraAutoColorAnalysisInset: analysisInset
|
|
6451
|
+
}, capturedFrame, autoColorFrameDiagnostics ?? frameDiagnostics)) return;
|
|
6452
|
+
break;
|
|
6453
|
+
} catch (recoveryError) {
|
|
6454
|
+
observations.push(...autoColorObservations);
|
|
6455
|
+
if (autoColorFrameDiagnostics) {
|
|
6456
|
+
normalizeFrameDiagnostics(
|
|
6457
|
+
autoColorFrameDiagnostics,
|
|
6458
|
+
capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
|
|
6459
|
+
cropped.imageData.width,
|
|
6460
|
+
cropped.imageData.height,
|
|
6461
|
+
autoColorVisionDiagnostics
|
|
6462
|
+
);
|
|
6463
|
+
autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
|
|
6464
|
+
autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
|
|
6465
|
+
autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
|
|
6466
|
+
autoColorFrameDiagnostics.autoColorCropInset = cropInset;
|
|
6467
|
+
autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
|
|
6468
|
+
emitDiagnostic({
|
|
6469
|
+
type: "frame",
|
|
6470
|
+
state: "miss",
|
|
6471
|
+
method: profileName,
|
|
6472
|
+
elapsedMs: nowMs() - frameStarted,
|
|
6473
|
+
missStreak,
|
|
6474
|
+
error: recoveryError?.message ?? String(recoveryError),
|
|
6475
|
+
...autoColorFrameDiagnostics
|
|
6476
|
+
});
|
|
6477
|
+
}
|
|
6478
|
+
emitDiagnostic({
|
|
6479
|
+
type: "method",
|
|
6480
|
+
state: "failed",
|
|
6481
|
+
method: profileName,
|
|
6482
|
+
message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
|
|
6483
|
+
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
6484
|
+
});
|
|
6485
|
+
}
|
|
6486
|
+
}
|
|
6487
|
+
if (!autoColorDecoded) {
|
|
6488
|
+
emitDiagnostic({
|
|
6489
|
+
type: "method",
|
|
6490
|
+
state: "failed",
|
|
6491
|
+
method: "camera-auto-color",
|
|
6492
|
+
message: "All camera Auto Color profiles failed · continuing deeper recovery",
|
|
6493
|
+
...frameDiagnostics
|
|
6494
|
+
});
|
|
6495
|
+
}
|
|
6496
|
+
}
|
|
6497
|
+
|
|
6498
|
+
// If the fast pass already saw a convincing QuadQR structure, retry
|
|
6499
|
+
// the exact same captured ROI immediately with the stronger QR-region
|
|
6500
|
+
// color correction. Empty/non-QR frames do not pay this cost. This is
|
|
6501
|
+
// the live equivalent of: normal scan first, then Auto Tone / Contrast
|
|
6502
|
+
// / Color only when the normal decode actually needs help.
|
|
6503
|
+
if (!allowAutoEnhance && options.autoEnhanceRecovery !== false) {
|
|
6504
|
+
const strongObservation = selectBestFrameObservation(observations);
|
|
6505
|
+
if (strongObservation && scratchCanvas.width && scratchCanvas.height) {
|
|
6506
|
+
emitDiagnostic({
|
|
6507
|
+
type: "method",
|
|
6508
|
+
state: "trying",
|
|
6509
|
+
method: "qr-region-auto-enhance",
|
|
6510
|
+
message: "Fast decode failed · trying QR-region Auto Tone / Contrast / Color",
|
|
6511
|
+
...frameDiagnostics
|
|
6512
|
+
});
|
|
6513
|
+
try {
|
|
6514
|
+
const recoveryStarted = nowMs();
|
|
6515
|
+
const frameContext = scratchCanvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
6516
|
+
const capturedImageData = frameContext.getImageData(0, 0, scratchCanvas.width, scratchCanvas.height);
|
|
6517
|
+
const recoveryObservations = [];
|
|
6518
|
+
const recoveryVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
|
|
6519
|
+
const recovered = scanImageData(capturedImageData, {
|
|
6520
|
+
...options,
|
|
6521
|
+
_diagnosticLabel: "qr-region-auto-enhance",
|
|
6522
|
+
_visionDiagnostics: recoveryVisionDiagnostics,
|
|
6523
|
+
autoEnhanceRecovery: true,
|
|
6524
|
+
autoEnhanceWhenNoGeometry: false,
|
|
6525
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
6526
|
+
_observationCollector: recoveryObservations
|
|
6527
|
+
});
|
|
6528
|
+
const recoveryElapsedMs = nowMs() - recoveryStarted;
|
|
6529
|
+
emitDiagnostic({
|
|
6530
|
+
type: "success",
|
|
6531
|
+
state: "decoded",
|
|
6532
|
+
method: recovered.recoveryMode ?? recovered.samplingMode ?? "qr-region-auto-enhance",
|
|
6533
|
+
elapsedMs: recoveryElapsedMs,
|
|
6534
|
+
message: `Recovery decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
6535
|
+
...frameDiagnostics
|
|
6536
|
+
});
|
|
6537
|
+
missStreak = 0;
|
|
6538
|
+
observationHistory.clear();
|
|
6539
|
+
if (emitResult({
|
|
6540
|
+
...recovered,
|
|
6541
|
+
cameraProgressiveRecovery: true
|
|
6542
|
+
}, capturedFrame, frameDiagnostics)) return;
|
|
6543
|
+
} catch {
|
|
6544
|
+
emitDiagnostic({
|
|
6545
|
+
type: "method",
|
|
6546
|
+
state: "failed",
|
|
6547
|
+
method: "qr-region-auto-enhance",
|
|
6548
|
+
message: "QR-region color recovery did not decode · trying multi-frame ECC",
|
|
6549
|
+
...frameDiagnostics
|
|
6550
|
+
});
|
|
6551
|
+
// Keep the original observations for multi-frame voting below.
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
6554
|
+
}
|
|
6555
|
+
|
|
6556
|
+
const combined = tryMultiFrameDecode(observations);
|
|
6557
|
+
if (combined) {
|
|
6558
|
+
emitDiagnostic({
|
|
6559
|
+
type: "success",
|
|
6560
|
+
state: "decoded",
|
|
6561
|
+
method: "multi-frame-vote",
|
|
6562
|
+
message: `Multi-frame recovery decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
|
|
6563
|
+
...frameDiagnostics
|
|
6564
|
+
});
|
|
6565
|
+
missStreak = 0;
|
|
6566
|
+
if (emitResult(combined, capturedFrame, frameDiagnostics)) return;
|
|
6567
|
+
} else {
|
|
6568
|
+
options.onScanMiss?.(error);
|
|
6569
|
+
}
|
|
3493
6570
|
} finally {
|
|
3494
6571
|
busy = false;
|
|
3495
6572
|
}
|
|
@@ -3547,6 +6624,10 @@ const internals = Object.freeze({
|
|
|
3547
6624
|
decodeRsAdaptive,
|
|
3548
6625
|
encodeProtectedHeader,
|
|
3549
6626
|
decodeProtectedHeader,
|
|
6627
|
+
selectBestFrameObservation,
|
|
6628
|
+
combineFrameObservations,
|
|
6629
|
+
visibleVideoSourceRect,
|
|
6630
|
+
cropImageDataInset,
|
|
3550
6631
|
HEADER_CODEWORD_CELLS,
|
|
3551
6632
|
COMPACT_HEADER_CODEWORD_CELLS,
|
|
3552
6633
|
CELLS_PER_BYTE,
|
|
@@ -3554,7 +6635,7 @@ const internals = Object.freeze({
|
|
|
3554
6635
|
SECURE_FLAG
|
|
3555
6636
|
});
|
|
3556
6637
|
|
|
3557
|
-
return { installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, renderToCanvas, renderToImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
|
|
6638
|
+
return { compressPayload, decompressPayload, deriveSigningKeyId, generateSigningKeyPair, installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeUint8Array, encodeSignedBytes, encodeSignedText, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, decodeUint8Array, verifyDecodedSignature, estimateSafeLogoSize, findMaxSafeLogoSize, getPrintGuidance, renderToCanvas, renderToImageData, renderToSVG, applyStressDistortion, runImageStressTest, assessScanability, debugScanImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, RENDER_MODES, PRINT_PALETTE, COMPRESSION_MODES, SIGNATURE_ALGORITHMS, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, STRESS_PROFILES, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
|
|
3558
6639
|
})();
|
|
3559
6640
|
const QuadQR = __qqr_quadqr_js;
|
|
3560
6641
|
|