quadqr-js 1.0.2 → 1.3.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 +40 -3
- package/README.md +199 -20
- package/SPECIFICATION.md +337 -0
- package/bin/quadqr.js +105 -14
- package/dist/esm/benchmark.js +113 -8
- package/dist/esm/node.js +13 -1
- package/dist/esm/quadqr.js +2312 -299
- package/dist/esm/vision.js +452 -14
- package/dist/quadqr.js +2746 -299
- package/dist/quadqr.min.js +2736 -297
- package/docs/API.md +230 -8
- package/docs/BROWSER_CDN.md +51 -6
- package/docs/CLI.md +156 -76
- package/docs/GETTING_STARTED.md +80 -7
- package/docs/HIGH_DENSITY_MODE.md +100 -0
- package/docs/NODE.md +137 -123
- package/docs/README.md +60 -41
- package/docs/TRIANGLE16.md +108 -0
- package/docs/WASM.md +2 -2
- package/package.json +10 -3
- package/types/benchmark.d.ts +4 -3
- package/types/index.d.ts +144 -2
- package/types/node.d.ts +2 -0
package/dist/quadqr.min.js
CHANGED
|
@@ -1190,6 +1190,31 @@ function detectFinderCandidates(binary, width, height, options = {}) {
|
|
|
1190
1190
|
return clusterFinderCandidates(raw, minConfirmations);
|
|
1191
1191
|
}
|
|
1192
1192
|
|
|
1193
|
+
function recoverFinderSetFromTwo(binary, width, height, strongFinders, detector = {}) {
|
|
1194
|
+
if (strongFinders.length !== 2) return strongFinders;
|
|
1195
|
+
const moduleMean = (strongFinders[0].moduleSize + strongFinders[1].moduleSize) / 2;
|
|
1196
|
+
const loose = detectFinderCandidates(binary, width, height, {
|
|
1197
|
+
toleranceScale: Math.max(1.45, (detector.toleranceScale ?? 1) * 1.35),
|
|
1198
|
+
moduleSpreadLimit: Math.max(0.72, detector.moduleSpreadLimit ?? 0.45),
|
|
1199
|
+
minConfirmations: 1
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
const extra = loose.filter((candidate) => {
|
|
1203
|
+
const moduleRatio = Math.abs(candidate.moduleSize - moduleMean) / Math.max(candidate.moduleSize, moduleMean);
|
|
1204
|
+
if (moduleRatio > 0.62) return false;
|
|
1205
|
+
return strongFinders.every((known) =>
|
|
1206
|
+
Math.hypot(candidate.x - known.x, candidate.y - known.y) > Math.max(moduleMean, known.moduleSize) * 5
|
|
1207
|
+
);
|
|
1208
|
+
}).slice(0, 12);
|
|
1209
|
+
|
|
1210
|
+
if (!extra.length) return strongFinders;
|
|
1211
|
+
return strongFinders.concat(extra).sort((a, b) =>
|
|
1212
|
+
(b.confirmations - a.confirmations) ||
|
|
1213
|
+
(a.score - b.score) ||
|
|
1214
|
+
(b.moduleSize - a.moduleSize)
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1193
1218
|
function dot(a, b) {
|
|
1194
1219
|
return a.x * b.x + a.y * b.y;
|
|
1195
1220
|
}
|
|
@@ -1272,6 +1297,14 @@ function alignmentTemplateValue(pattern, r, c) {
|
|
|
1272
1297
|
return alignmentPatternIsBlack(pattern, r, c) ? 1 : 0;
|
|
1273
1298
|
}
|
|
1274
1299
|
|
|
1300
|
+
const ALIGNMENT_SUBCELL_PROBES = Object.freeze([
|
|
1301
|
+
Object.freeze([0, 0]),
|
|
1302
|
+
Object.freeze([-0.32, 0]),
|
|
1303
|
+
Object.freeze([0.32, 0]),
|
|
1304
|
+
Object.freeze([0, -0.32]),
|
|
1305
|
+
Object.freeze([0, 0.32])
|
|
1306
|
+
]);
|
|
1307
|
+
|
|
1275
1308
|
function alignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
|
|
1276
1309
|
let matches = 0;
|
|
1277
1310
|
let total = 0;
|
|
@@ -1289,7 +1322,31 @@ function alignmentScore(binary, width, height, center, basisU, basisV, scale, pa
|
|
|
1289
1322
|
return total ? matches / total : 0;
|
|
1290
1323
|
}
|
|
1291
1324
|
|
|
1292
|
-
function
|
|
1325
|
+
function preciseAlignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
|
|
1326
|
+
let matches = 0;
|
|
1327
|
+
let total = 0;
|
|
1328
|
+
const radius = alignmentPatternRadius(pattern);
|
|
1329
|
+
for (let r = -radius; r <= radius; r++) {
|
|
1330
|
+
for (let c = -radius; c <= radius; c++) {
|
|
1331
|
+
const expected = alignmentTemplateValue(pattern, r, c);
|
|
1332
|
+
// Centre-only alignment scoring has a broad plateau: a candidate can be
|
|
1333
|
+
// wrong by almost half a module while every centre still lands in the
|
|
1334
|
+
// correct solid tile. This denser score is reserved for the slow recovery
|
|
1335
|
+
// path, where Triangle16 needs sub-module geometry precision.
|
|
1336
|
+
for (const [du, dv] of ALIGNMENT_SUBCELL_PROBES) {
|
|
1337
|
+
const x = center.x + basisU.x * (c + du) * scale + basisV.x * (r + dv) * scale;
|
|
1338
|
+
const y = center.y + basisU.y * (c + du) * scale + basisV.y * (r + dv) * scale;
|
|
1339
|
+
const value = sampleBinaryAt(binary, width, height, x, y);
|
|
1340
|
+
if (value === null) continue;
|
|
1341
|
+
total++;
|
|
1342
|
+
if (value === expected) matches++;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return total ? matches / total : 0;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
1293
1350
|
const size = sizeForVersion(version);
|
|
1294
1351
|
const separation = size - 7;
|
|
1295
1352
|
const basisU = {
|
|
@@ -1309,8 +1366,12 @@ function searchAlignment(binary, width, height, triple, version) {
|
|
|
1309
1366
|
};
|
|
1310
1367
|
|
|
1311
1368
|
let best = { score: 0, center: predicted, scale: 1 };
|
|
1312
|
-
const
|
|
1369
|
+
const precise = options.preciseAlignment === true;
|
|
1370
|
+
const offsets = precise
|
|
1371
|
+
? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
|
|
1372
|
+
: [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
|
|
1313
1373
|
const scales = [0.72, 0.85, 1, 1.15, 1.3];
|
|
1374
|
+
const scoreAlignment = precise ? preciseAlignmentScore : alignmentScore;
|
|
1314
1375
|
|
|
1315
1376
|
for (const ou of offsets) {
|
|
1316
1377
|
for (const ov of offsets) {
|
|
@@ -1319,7 +1380,7 @@ function searchAlignment(binary, width, height, triple, version) {
|
|
|
1319
1380
|
y: predicted.y + basisU.y * ou + basisV.y * ov
|
|
1320
1381
|
};
|
|
1321
1382
|
for (const scale of scales) {
|
|
1322
|
-
const score =
|
|
1383
|
+
const score = scoreAlignment(binary, width, height, center, basisU, basisV, scale, target);
|
|
1323
1384
|
if (score > best.score) best = { score, center, scale };
|
|
1324
1385
|
}
|
|
1325
1386
|
}
|
|
@@ -1362,6 +1423,183 @@ function scoreAlignmentGrid(binary, width, height, homography, version) {
|
|
|
1362
1423
|
return { score, patternScores };
|
|
1363
1424
|
}
|
|
1364
1425
|
|
|
1426
|
+
function localProjectiveBasis(homography, moduleX, moduleY) {
|
|
1427
|
+
const center = projectPoint(homography, moduleX, moduleY);
|
|
1428
|
+
const alongU = projectPoint(homography, moduleX + 1, moduleY);
|
|
1429
|
+
const alongV = projectPoint(homography, moduleX, moduleY + 1);
|
|
1430
|
+
return {
|
|
1431
|
+
center,
|
|
1432
|
+
basisU: { x: alongU.x - center.x, y: alongU.y - center.y },
|
|
1433
|
+
basisV: { x: alongV.x - center.x, y: alongV.y - center.y }
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function searchProjectedAlignment(binary, width, height, homography, pattern) {
|
|
1438
|
+
const moduleX = pattern.col + 0.5;
|
|
1439
|
+
const moduleY = pattern.row + 0.5;
|
|
1440
|
+
const local = localProjectiveBasis(homography, moduleX, moduleY);
|
|
1441
|
+
const moduleScale = Math.max(
|
|
1442
|
+
0.5,
|
|
1443
|
+
(Math.hypot(local.basisU.x, local.basisU.y) + Math.hypot(local.basisV.x, local.basisV.y)) / 2
|
|
1444
|
+
);
|
|
1445
|
+
const offsets = [-1, -0.5, 0, 0.5, 1];
|
|
1446
|
+
const scales = [0.88, 1, 1.12];
|
|
1447
|
+
let best = {
|
|
1448
|
+
score: projectedAlignmentScore(binary, width, height, homography, pattern),
|
|
1449
|
+
center: local.center,
|
|
1450
|
+
scale: 1,
|
|
1451
|
+
offsetU: 0,
|
|
1452
|
+
offsetV: 0,
|
|
1453
|
+
displacementModules: 0
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
for (const offsetU of offsets) {
|
|
1457
|
+
for (const offsetV of offsets) {
|
|
1458
|
+
const center = {
|
|
1459
|
+
x: local.center.x + local.basisU.x * offsetU + local.basisV.x * offsetV,
|
|
1460
|
+
y: local.center.y + local.basisU.y * offsetU + local.basisV.y * offsetV
|
|
1461
|
+
};
|
|
1462
|
+
for (const scale of scales) {
|
|
1463
|
+
const score = alignmentScore(
|
|
1464
|
+
binary,
|
|
1465
|
+
width,
|
|
1466
|
+
height,
|
|
1467
|
+
center,
|
|
1468
|
+
local.basisU,
|
|
1469
|
+
local.basisV,
|
|
1470
|
+
scale,
|
|
1471
|
+
pattern
|
|
1472
|
+
);
|
|
1473
|
+
if (score > best.score) {
|
|
1474
|
+
best = {
|
|
1475
|
+
score,
|
|
1476
|
+
center,
|
|
1477
|
+
scale,
|
|
1478
|
+
offsetU,
|
|
1479
|
+
offsetV,
|
|
1480
|
+
displacementModules: Math.hypot(offsetU, offsetV),
|
|
1481
|
+
displacementPixels: Math.hypot(offsetU, offsetV) * moduleScale
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
return best;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function refineHomographyWithAlignmentGrid(
|
|
1492
|
+
binary,
|
|
1493
|
+
width,
|
|
1494
|
+
height,
|
|
1495
|
+
initialHomography,
|
|
1496
|
+
version,
|
|
1497
|
+
sourcePoints,
|
|
1498
|
+
destinationPoints,
|
|
1499
|
+
initialGrid,
|
|
1500
|
+
options = {}
|
|
1501
|
+
) {
|
|
1502
|
+
const patterns = alignmentPatternCentersForVersion(version);
|
|
1503
|
+
if (patterns.length <= 1 || options.alignmentRefinement === false) {
|
|
1504
|
+
return {
|
|
1505
|
+
homography: initialHomography,
|
|
1506
|
+
grid: initialGrid,
|
|
1507
|
+
refined: false,
|
|
1508
|
+
points: []
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const primary = patterns.find((pattern) => pattern.primary);
|
|
1513
|
+
const secondaries = patterns.filter((pattern) => !pattern.primary);
|
|
1514
|
+
const minimumScore = options.alignmentRefinePatternThreshold ?? 0.84;
|
|
1515
|
+
const maxPoints = Math.max(1, Math.min(18, Math.round(options.alignmentRefineMaxPoints ?? 12)));
|
|
1516
|
+
const candidates = [];
|
|
1517
|
+
|
|
1518
|
+
for (const pattern of secondaries) {
|
|
1519
|
+
const found = searchProjectedAlignment(binary, width, height, initialHomography, pattern);
|
|
1520
|
+
if (found.score < minimumScore) continue;
|
|
1521
|
+
if (found.displacementModules > (options.alignmentRefineMaxDisplacement ?? 1.45)) continue;
|
|
1522
|
+
candidates.push({ pattern, ...found });
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
if (!candidates.length) {
|
|
1526
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Prefer confident references, but retain spatial distribution so dense
|
|
1530
|
+
// versions use alignment information across the entire matrix rather than
|
|
1531
|
+
// allowing a cluster of nearby markers to dominate the fit.
|
|
1532
|
+
candidates.sort((a, b) =>
|
|
1533
|
+
(b.score - a.score) ||
|
|
1534
|
+
((b.pattern.row + b.pattern.col) - (a.pattern.row + a.pattern.col))
|
|
1535
|
+
);
|
|
1536
|
+
const selected = [];
|
|
1537
|
+
for (const candidate of candidates) {
|
|
1538
|
+
if (selected.length >= maxPoints) break;
|
|
1539
|
+
const tooClose = selected.some((item) =>
|
|
1540
|
+
Math.hypot(item.pattern.col - candidate.pattern.col, item.pattern.row - candidate.pattern.row) < 6
|
|
1541
|
+
);
|
|
1542
|
+
if (!tooClose || selected.length < 3) selected.push(candidate);
|
|
1543
|
+
}
|
|
1544
|
+
if (!selected.length) return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
1545
|
+
|
|
1546
|
+
const refinedSource = sourcePoints.slice();
|
|
1547
|
+
const refinedDestination = destinationPoints.slice();
|
|
1548
|
+
const weights = [4, 4, 4, 5];
|
|
1549
|
+
const pointInfo = [];
|
|
1550
|
+
|
|
1551
|
+
for (const candidate of selected) {
|
|
1552
|
+
refinedSource.push(candidate.center);
|
|
1553
|
+
refinedDestination.push({
|
|
1554
|
+
x: candidate.pattern.col + 0.5,
|
|
1555
|
+
y: candidate.pattern.row + 0.5
|
|
1556
|
+
});
|
|
1557
|
+
// A 3x3 marker carries less evidence than the 5x5 primary and the three
|
|
1558
|
+
// finder patterns, so it helps average geometry without overpowering them.
|
|
1559
|
+
weights.push(0.8 + candidate.score * 1.7);
|
|
1560
|
+
pointInfo.push({
|
|
1561
|
+
row: candidate.pattern.row,
|
|
1562
|
+
col: candidate.pattern.col,
|
|
1563
|
+
score: candidate.score,
|
|
1564
|
+
x: candidate.center.x,
|
|
1565
|
+
y: candidate.center.y,
|
|
1566
|
+
displacementModules: candidate.displacementModules
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
let refinedHomography;
|
|
1571
|
+
try {
|
|
1572
|
+
refinedHomography = computeHomographyLeastSquares(refinedSource, refinedDestination, weights);
|
|
1573
|
+
} catch {
|
|
1574
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version);
|
|
1578
|
+
const primaryBefore = primary
|
|
1579
|
+
? projectedAlignmentScore(binary, width, height, initialHomography, primary)
|
|
1580
|
+
: 1;
|
|
1581
|
+
const primaryAfter = primary
|
|
1582
|
+
? projectedAlignmentScore(binary, width, height, refinedHomography, primary)
|
|
1583
|
+
: 1;
|
|
1584
|
+
const allowedGridDrop = options.alignmentRefineAllowedGridDrop ?? 0.006;
|
|
1585
|
+
const allowedPrimaryDrop = options.alignmentRefineAllowedPrimaryDrop ?? 0.04;
|
|
1586
|
+
|
|
1587
|
+
if (
|
|
1588
|
+
refinedGrid.score + allowedGridDrop < initialGrid.score ||
|
|
1589
|
+
primaryAfter + allowedPrimaryDrop < primaryBefore
|
|
1590
|
+
) {
|
|
1591
|
+
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
return {
|
|
1595
|
+
homography: refinedHomography,
|
|
1596
|
+
grid: refinedGrid,
|
|
1597
|
+
refined: true,
|
|
1598
|
+
points: pointInfo,
|
|
1599
|
+
initialGridScore: initialGrid.score
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1365
1603
|
function solveLinearSystemFloat(matrix, vector) {
|
|
1366
1604
|
const n = vector.length;
|
|
1367
1605
|
const a = matrix.map((row, index) => row.slice().concat([vector[index]]));
|
|
@@ -1403,6 +1641,43 @@ function computeHomography(sourcePoints, destinationPoints) {
|
|
|
1403
1641
|
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
|
1404
1642
|
}
|
|
1405
1643
|
|
|
1644
|
+
function computeHomographyLeastSquares(sourcePoints, destinationPoints, weights = null) {
|
|
1645
|
+
// Same destination -> source convention as computeHomography(), but accepts
|
|
1646
|
+
// more than four correspondences and averages locator/alignment measurement
|
|
1647
|
+
// noise. This is used only as a refinement after a valid four-point projective
|
|
1648
|
+
// solution already exists.
|
|
1649
|
+
assert(
|
|
1650
|
+
sourcePoints.length === destinationPoints.length && sourcePoints.length >= 4,
|
|
1651
|
+
"At least four matching point pairs are required."
|
|
1652
|
+
);
|
|
1653
|
+
|
|
1654
|
+
const normal = Array.from({ length: 8 }, () => Array(8).fill(0));
|
|
1655
|
+
const rhs = Array(8).fill(0);
|
|
1656
|
+
|
|
1657
|
+
const addEquation = (row, value, weight) => {
|
|
1658
|
+
for (let i = 0; i < 8; i++) {
|
|
1659
|
+
rhs[i] += row[i] * value * weight;
|
|
1660
|
+
for (let j = 0; j < 8; j++) normal[i][j] += row[i] * row[j] * weight;
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
for (let i = 0; i < sourcePoints.length; i++) {
|
|
1665
|
+
const u = destinationPoints[i].x;
|
|
1666
|
+
const v = destinationPoints[i].y;
|
|
1667
|
+
const x = sourcePoints[i].x;
|
|
1668
|
+
const y = sourcePoints[i].y;
|
|
1669
|
+
const weight = Math.max(0.05, Number(weights?.[i] ?? 1));
|
|
1670
|
+
addEquation([u, v, 1, 0, 0, 0, -u * x, -v * x], x, weight);
|
|
1671
|
+
addEquation([0, 0, 0, u, v, 1, -u * y, -v * y], y, weight);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// Tiny Tikhonov regularisation keeps near-degenerate noisy fits numerically
|
|
1675
|
+
// stable without moving a normal QR-sized solution in any meaningful way.
|
|
1676
|
+
for (let i = 0; i < 8; i++) normal[i][i] += 1e-9;
|
|
1677
|
+
const h = solveLinearSystemFloat(normal, rhs);
|
|
1678
|
+
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1406
1681
|
function projectPoint(h, u, v) {
|
|
1407
1682
|
const denominator = h[6] * u + h[7] * v + h[8];
|
|
1408
1683
|
return {
|
|
@@ -1428,7 +1703,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
1428
1703
|
for (const item of versions) {
|
|
1429
1704
|
const version = item.version;
|
|
1430
1705
|
const size = sizeForVersion(version);
|
|
1431
|
-
const alignment = searchAlignment(binary, width, height, triple, version);
|
|
1706
|
+
const alignment = searchAlignment(binary, width, height, triple, version, options);
|
|
1432
1707
|
if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
|
|
1433
1708
|
|
|
1434
1709
|
const alignmentTarget = alignment.target;
|
|
@@ -1452,8 +1727,34 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
1452
1727
|
continue;
|
|
1453
1728
|
}
|
|
1454
1729
|
|
|
1455
|
-
const
|
|
1456
|
-
|
|
1730
|
+
const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
|
|
1731
|
+
const alignmentGridThreshold = options.alignmentGridThreshold ?? 0.68;
|
|
1732
|
+
const refinementFloor = Math.max(0, alignmentGridThreshold - (options.alignmentRefineCandidateMargin ?? 0.10));
|
|
1733
|
+
const skipRefinementScore = options.alignmentRefineSkipScore ?? 0.985;
|
|
1734
|
+
const refinement = (
|
|
1735
|
+
initialAlignmentGrid.score >= refinementFloor &&
|
|
1736
|
+
initialAlignmentGrid.score < skipRefinementScore
|
|
1737
|
+
)
|
|
1738
|
+
? refineHomographyWithAlignmentGrid(
|
|
1739
|
+
binary,
|
|
1740
|
+
width,
|
|
1741
|
+
height,
|
|
1742
|
+
homography,
|
|
1743
|
+
version,
|
|
1744
|
+
src,
|
|
1745
|
+
dest,
|
|
1746
|
+
initialAlignmentGrid,
|
|
1747
|
+
options
|
|
1748
|
+
)
|
|
1749
|
+
: {
|
|
1750
|
+
homography,
|
|
1751
|
+
grid: initialAlignmentGrid,
|
|
1752
|
+
refined: false,
|
|
1753
|
+
points: []
|
|
1754
|
+
};
|
|
1755
|
+
homography = refinement.homography;
|
|
1756
|
+
const alignmentGrid = refinement.grid;
|
|
1757
|
+
if (alignmentGrid.score < alignmentGridThreshold) continue;
|
|
1457
1758
|
|
|
1458
1759
|
const versionPenalty = item.error / 4;
|
|
1459
1760
|
const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
|
|
@@ -1472,6 +1773,9 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
1472
1773
|
target: { row: alignmentTarget.row, col: alignmentTarget.col },
|
|
1473
1774
|
patterns: alignmentGrid.patternScores.length,
|
|
1474
1775
|
gridScore: alignmentGrid.score,
|
|
1776
|
+
initialGridScore: refinement.initialGridScore ?? initialAlignmentGrid.score,
|
|
1777
|
+
refined: refinement.refined,
|
|
1778
|
+
refinementPoints: refinement.points,
|
|
1475
1779
|
patternScores: alignmentGrid.patternScores
|
|
1476
1780
|
},
|
|
1477
1781
|
threshold,
|
|
@@ -1543,19 +1847,21 @@ function detectCodeGeometry(imageData, options = {}) {
|
|
|
1543
1847
|
const maxCandidates = options.maxCandidates ?? 8;
|
|
1544
1848
|
|
|
1545
1849
|
const evaluatePass = (pass, recovery = false) => {
|
|
1546
|
-
const
|
|
1547
|
-
const
|
|
1850
|
+
const detector = pass.detector ?? {};
|
|
1851
|
+
const finders = detectFinderCandidates(pass.binary, width, height, detector);
|
|
1852
|
+
const geometryOptions = {
|
|
1853
|
+
...options,
|
|
1854
|
+
finderMethod: pass.finderMethod,
|
|
1855
|
+
alignmentThreshold: recovery ? 0.68 : 0.72,
|
|
1856
|
+
alignmentGridThreshold: recovery ? 0.64 : 0.68
|
|
1857
|
+
};
|
|
1858
|
+
let geometries = geometryCandidatesFromBinary(
|
|
1548
1859
|
pass.binary,
|
|
1549
1860
|
width,
|
|
1550
1861
|
height,
|
|
1551
1862
|
finders,
|
|
1552
1863
|
pass.threshold,
|
|
1553
|
-
|
|
1554
|
-
...options,
|
|
1555
|
-
finderMethod: pass.finderMethod,
|
|
1556
|
-
alignmentThreshold: recovery ? 0.68 : 0.72,
|
|
1557
|
-
alignmentGridThreshold: recovery ? 0.64 : 0.68
|
|
1558
|
-
}
|
|
1864
|
+
geometryOptions
|
|
1559
1865
|
);
|
|
1560
1866
|
|
|
1561
1867
|
pushFinderDiagnostic(
|
|
@@ -1565,6 +1871,35 @@ function detectCodeGeometry(imageData, options = {}) {
|
|
|
1565
1871
|
finders,
|
|
1566
1872
|
geometries
|
|
1567
1873
|
);
|
|
1874
|
+
|
|
1875
|
+
// Dense codes under projective distortion can leave two finder patterns
|
|
1876
|
+
// perfectly strong while the third is stretched enough to miss the normal
|
|
1877
|
+
// 1:1:3:1:1 tolerance. Only in that very specific recovery state, run one
|
|
1878
|
+
// bounded looser detector and let the normal three-finder geometry checks
|
|
1879
|
+
// reject false data-cell lookalikes. The clean fast path is unchanged.
|
|
1880
|
+
if (!geometries.length && recovery && finders.length === 2) {
|
|
1881
|
+
const recoveredFinders = recoverFinderSetFromTwo(pass.binary, width, height, finders, detector);
|
|
1882
|
+
if (recoveredFinders.length > 2) {
|
|
1883
|
+
const finderMethod = `${pass.finderMethod}-two-finder-recovery`;
|
|
1884
|
+
geometries = geometryCandidatesFromBinary(
|
|
1885
|
+
pass.binary,
|
|
1886
|
+
width,
|
|
1887
|
+
height,
|
|
1888
|
+
recoveredFinders,
|
|
1889
|
+
pass.threshold,
|
|
1890
|
+
{ ...geometryOptions, finderMethod }
|
|
1891
|
+
);
|
|
1892
|
+
pushFinderDiagnostic(
|
|
1893
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
|
|
1894
|
+
finderMethod,
|
|
1895
|
+
pass.threshold,
|
|
1896
|
+
recoveredFinders,
|
|
1897
|
+
geometries
|
|
1898
|
+
);
|
|
1899
|
+
if (geometries.length) return { finders: recoveredFinders, geometries };
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1568
1903
|
return { finders, geometries };
|
|
1569
1904
|
};
|
|
1570
1905
|
|
|
@@ -1580,6 +1915,38 @@ function detectCodeGeometry(imageData, options = {}) {
|
|
|
1580
1915
|
if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
|
|
1581
1916
|
if (options.finderRecovery === false) return [];
|
|
1582
1917
|
|
|
1918
|
+
// If the clean threshold already found exactly two strong locators, try the
|
|
1919
|
+
// bounded perspective-tolerant third-finder pass before any color processing.
|
|
1920
|
+
// This is substantially cheaper than Auto Color and targets the dense-code
|
|
1921
|
+
// projective failure mode directly.
|
|
1922
|
+
if (fast.finders.length === 2) {
|
|
1923
|
+
const recoveredFinders = recoverFinderSetFromTwo(valueInfo.binary, width, height, fast.finders, {});
|
|
1924
|
+
if (recoveredFinders.length > 2) {
|
|
1925
|
+
const finderMethod = "rgb-value-otsu-two-finder-recovery";
|
|
1926
|
+
const recoveredGeometries = geometryCandidatesFromBinary(
|
|
1927
|
+
valueInfo.binary,
|
|
1928
|
+
width,
|
|
1929
|
+
height,
|
|
1930
|
+
recoveredFinders,
|
|
1931
|
+
valueInfo.threshold,
|
|
1932
|
+
{
|
|
1933
|
+
...options,
|
|
1934
|
+
finderMethod,
|
|
1935
|
+
alignmentThreshold: 0.68,
|
|
1936
|
+
alignmentGridThreshold: 0.64
|
|
1937
|
+
}
|
|
1938
|
+
);
|
|
1939
|
+
pushFinderDiagnostic(
|
|
1940
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
|
|
1941
|
+
finderMethod,
|
|
1942
|
+
valueInfo.threshold,
|
|
1943
|
+
recoveredFinders,
|
|
1944
|
+
recoveredGeometries
|
|
1945
|
+
);
|
|
1946
|
+
if (recoveredGeometries.length) return recoveredGeometries.slice(0, maxCandidates);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1583
1950
|
// Camera recovery #1: Photoshop Auto Color-style per-channel levels before
|
|
1584
1951
|
// finder thresholding. Live camera frames are usually much larger than the
|
|
1585
1952
|
// QR itself, so a single global histogram can be dominated by dark room/UI
|
|
@@ -1983,6 +2350,49 @@ function samplePerspectiveMatrix(imageData, homography, size, options = {}) {
|
|
|
1983
2350
|
return { rgbGrid };
|
|
1984
2351
|
}
|
|
1985
2352
|
|
|
2353
|
+
/**
|
|
2354
|
+
* Sample the two protected regions of every Triangle16 module.
|
|
2355
|
+
*
|
|
2356
|
+
* Triangle16 uses a fixed "/" diagonal. Samples are intentionally placed well
|
|
2357
|
+
* inside the upper-left and lower-right triangles, away from the diagonal and
|
|
2358
|
+
* module borders. This makes the decoder much less sensitive to antialiasing,
|
|
2359
|
+
* blur, resampling and small homography errors than center sampling.
|
|
2360
|
+
*/
|
|
2361
|
+
function samplePerspectiveTriangleMatrix(imageData, homography, size, options = {}) {
|
|
2362
|
+
const triangleGrid = Array.from({ length: size }, () => new Array(size));
|
|
2363
|
+
const mode = options.sampleMode ?? "cross";
|
|
2364
|
+
const radius = options.highDensitySampleRadius ?? options.triangleSampleRadius ??
|
|
2365
|
+
(mode === "median" ? 0.075 : 0.085);
|
|
2366
|
+
const inset = Math.max(0.18, Math.min(0.36, Number(options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28)));
|
|
2367
|
+
const firstOffset = inset;
|
|
2368
|
+
const secondOffset = 1 - inset;
|
|
2369
|
+
const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
|
|
2370
|
+
const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
|
|
2371
|
+
const sampler = mode === "median" ? robustProjectedSample : averageProjectedSample;
|
|
2372
|
+
|
|
2373
|
+
for (let r = 0; r < size; r++) {
|
|
2374
|
+
for (let c = 0; c < size; c++) {
|
|
2375
|
+
triangleGrid[r][c] = {
|
|
2376
|
+
first: sampler(
|
|
2377
|
+
imageData,
|
|
2378
|
+
homography,
|
|
2379
|
+
c + firstOffset + offsetX,
|
|
2380
|
+
r + firstOffset + offsetY,
|
|
2381
|
+
radius
|
|
2382
|
+
),
|
|
2383
|
+
second: sampler(
|
|
2384
|
+
imageData,
|
|
2385
|
+
homography,
|
|
2386
|
+
c + secondOffset + offsetX,
|
|
2387
|
+
r + secondOffset + offsetY,
|
|
2388
|
+
radius
|
|
2389
|
+
)
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
return { triangleGrid };
|
|
2394
|
+
}
|
|
2395
|
+
|
|
1986
2396
|
function meanRgb(values) {
|
|
1987
2397
|
if (!values.length) throw new Error("No calibration samples available.");
|
|
1988
2398
|
return values.reduce(
|
|
@@ -2179,6 +2589,34 @@ function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.18) {
|
|
|
2179
2589
|
return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
2180
2590
|
}
|
|
2181
2591
|
|
|
2592
|
+
function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.085, insetRatio = 0.28) {
|
|
2593
|
+
const moduleW = bounds.width / size;
|
|
2594
|
+
const moduleH = bounds.height / size;
|
|
2595
|
+
const inset = Math.max(0.18, Math.min(0.36, insetRatio));
|
|
2596
|
+
const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
|
|
2597
|
+
const triangleGrid = Array.from({ length: size }, () => new Array(size));
|
|
2598
|
+
|
|
2599
|
+
for (let r = 0; r < size; r++) {
|
|
2600
|
+
for (let c = 0; c < size; c++) {
|
|
2601
|
+
triangleGrid[r][c] = {
|
|
2602
|
+
first: sampleAverageAxis(
|
|
2603
|
+
imageData,
|
|
2604
|
+
bounds.x + (c + inset) * moduleW,
|
|
2605
|
+
bounds.y + (r + inset) * moduleH,
|
|
2606
|
+
radius
|
|
2607
|
+
),
|
|
2608
|
+
second: sampleAverageAxis(
|
|
2609
|
+
imageData,
|
|
2610
|
+
bounds.x + (c + 1 - inset) * moduleW,
|
|
2611
|
+
bounds.y + (r + 1 - inset) * moduleH,
|
|
2612
|
+
radius
|
|
2613
|
+
)
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
return { triangleGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2182
2620
|
const visionInternals = Object.freeze({
|
|
2183
2621
|
buildGray,
|
|
2184
2622
|
otsuThreshold,
|
|
@@ -2191,21 +2629,21 @@ const visionInternals = Object.freeze({
|
|
|
2191
2629
|
luminance
|
|
2192
2630
|
});
|
|
2193
2631
|
|
|
2194
|
-
return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, visionInternals };
|
|
2632
|
+
return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid, visionInternals };
|
|
2195
2633
|
})();
|
|
2196
2634
|
const __qqr_quadqr_js = (function () {
|
|
2197
2635
|
const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
|
|
2198
2636
|
const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
|
|
2199
|
-
const { detectCodeGeometry, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
|
|
2637
|
+
const { detectCodeGeometry, computeHomography, projectPoint, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid } = __qqr_vision_js;
|
|
2200
2638
|
const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex } = __qqr_security_js;
|
|
2201
2639
|
/**
|
|
2202
2640
|
* QuadQR
|
|
2203
2641
|
*
|
|
2204
|
-
* Experimental
|
|
2642
|
+
* Experimental RGBW / Triangle16 matrix code written in pure JavaScript.
|
|
2205
2643
|
*
|
|
2206
2644
|
* Core format:
|
|
2207
|
-
* - Red / Green / Blue / White data
|
|
2208
|
-
* -
|
|
2645
|
+
* - RGBW profile: Red / Green / Blue / White, exactly 2 bits per data cell
|
|
2646
|
+
* - Triangle16 profile: two fixed RGBW triangle regions, 16 states / 4 bits per body data cell
|
|
2209
2647
|
* - GF(256) Reed-Solomon ECC over byte symbols
|
|
2210
2648
|
* - interleaved body ECC blocks
|
|
2211
2649
|
* - zero-overhead spectral-spatial cell placement
|
|
@@ -2220,6 +2658,33 @@ const MIN_VERSION = 1;
|
|
|
2220
2658
|
const MAX_VERSION = 40;
|
|
2221
2659
|
const DEFAULT_ECC_LEVEL = "M";
|
|
2222
2660
|
|
|
2661
|
+
const RENDER_MODES = Object.freeze({
|
|
2662
|
+
SCREEN: "screen",
|
|
2663
|
+
PRINT: "print"
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
const CELL_ENCODINGS = Object.freeze({
|
|
2667
|
+
RGBW: "rgbw",
|
|
2668
|
+
TRIANGLE16: "triangle16"
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
// Print-safe defaults deliberately use darker, more ink-tolerant primaries and
|
|
2672
|
+
// a true white background. Applications can still override individual colors.
|
|
2673
|
+
const PRINT_PALETTE = Object.freeze({
|
|
2674
|
+
black: "#000000",
|
|
2675
|
+
white: "#ffffff",
|
|
2676
|
+
red: "#d71932",
|
|
2677
|
+
green: "#087f3e",
|
|
2678
|
+
blue: "#174ea6"
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
const COMPRESSION_MODES = Object.freeze({
|
|
2682
|
+
NONE: "none",
|
|
2683
|
+
AUTO: "auto",
|
|
2684
|
+
LZ: "lz"
|
|
2685
|
+
});
|
|
2686
|
+
const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
|
|
2687
|
+
|
|
2223
2688
|
// Data cells are the four values 0..3. Structural white deliberately shares
|
|
2224
2689
|
// value 3 with data-white because both render and scan identically.
|
|
2225
2690
|
const CELL = Object.freeze({
|
|
@@ -2260,8 +2725,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
|
|
|
2260
2725
|
const HEADER_BYTES = 10;
|
|
2261
2726
|
const HEADER_RS_PARITY = 8;
|
|
2262
2727
|
const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
|
|
2263
|
-
const
|
|
2264
|
-
const
|
|
2728
|
+
const RGBW_CELLS_PER_BYTE = 4;
|
|
2729
|
+
const TRIANGLE16_CELLS_PER_BYTE = 2;
|
|
2730
|
+
const CELLS_PER_BYTE = RGBW_CELLS_PER_BYTE; // legacy internal alias
|
|
2731
|
+
const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
|
|
2265
2732
|
|
|
2266
2733
|
// Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
|
|
2267
2734
|
// cannot afford the normal 18-byte protected header plus a 24-byte M body
|
|
@@ -2273,7 +2740,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
|
|
|
2273
2740
|
const COMPACT_HEADER_BYTES = 4;
|
|
2274
2741
|
const COMPACT_HEADER_RS_PARITY = 4;
|
|
2275
2742
|
const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
|
|
2276
|
-
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES *
|
|
2743
|
+
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
|
|
2277
2744
|
const COMPACT_ECC_LEVELS = Object.freeze({
|
|
2278
2745
|
L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
|
|
2279
2746
|
M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
|
|
@@ -2284,6 +2751,23 @@ const COMPACT_ECC_LEVELS = Object.freeze({
|
|
|
2284
2751
|
const CRC_BYTES = 4;
|
|
2285
2752
|
const TEXT_FLAG = 1;
|
|
2286
2753
|
const SECURE_FLAG = 1 << 3;
|
|
2754
|
+
const EXTENDED_PAYLOAD_FLAG = 1 << 4;
|
|
2755
|
+
const SIGNED_FLAG = 1 << 5;
|
|
2756
|
+
const TRIANGLE16_FLAG = 1 << 6;
|
|
2757
|
+
|
|
2758
|
+
// Internal payload extension envelope. This is deliberately not a public
|
|
2759
|
+
// payload mode or content-type system. It exists only when compression or
|
|
2760
|
+
// signing needs metadata around an otherwise normal text/binary payload.
|
|
2761
|
+
const PAYLOAD_ENVELOPE_VERSION = 2;
|
|
2762
|
+
const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
|
|
2763
|
+
const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
|
|
2764
|
+
const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
|
|
2765
|
+
const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
|
|
2766
|
+
const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
|
|
2767
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
|
|
2768
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
|
|
2769
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
|
|
2770
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
|
|
2287
2771
|
const ECC_SHIFT = 1;
|
|
2288
2772
|
const ECC_MASK = 0b00000110;
|
|
2289
2773
|
|
|
@@ -2332,6 +2816,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
|
|
|
2332
2816
|
return value;
|
|
2333
2817
|
}
|
|
2334
2818
|
|
|
2819
|
+
function normalizeCellEncoding(value = CELL_ENCODINGS.RGBW) {
|
|
2820
|
+
const key = String(value).toLowerCase();
|
|
2821
|
+
assert(
|
|
2822
|
+
key === CELL_ENCODINGS.RGBW || key === CELL_ENCODINGS.TRIANGLE16,
|
|
2823
|
+
`Internal cell encoding must be ${CELL_ENCODINGS.RGBW} or ${CELL_ENCODINGS.TRIANGLE16}.`
|
|
2824
|
+
);
|
|
2825
|
+
return key;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// Public API presents Triangle16 as one experimental High Density Mode toggle.
|
|
2829
|
+
// cellEncoding remains an internal/backward-compatible detail so existing
|
|
2830
|
+
// experimental branch symbols and renderer metadata continue to work.
|
|
2831
|
+
function cellEncodingFromOptions(options = {}) {
|
|
2832
|
+
if (typeof options.highDensity === "boolean") {
|
|
2833
|
+
return options.highDensity ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
|
|
2834
|
+
}
|
|
2835
|
+
return normalizeCellEncoding(options.cellEncoding ?? CELL_ENCODINGS.RGBW);
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
function cellsPerByteForEncoding(cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
2839
|
+
return normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16
|
|
2840
|
+
? TRIANGLE16_CELLS_PER_BYTE
|
|
2841
|
+
: RGBW_CELLS_PER_BYTE;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function packTriangleCell(first, second) {
|
|
2845
|
+
assert(first >= 0 && first <= 3 && second >= 0 && second <= 3, "Invalid Triangle16 color.");
|
|
2846
|
+
return ((first & 3) << 2) | (second & 3);
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
function unpackTriangleCell(value) {
|
|
2850
|
+
assert(Number.isInteger(value) && value >= 0 && value <= 15, "Invalid Triangle16 data cell.");
|
|
2851
|
+
return { first: (value >>> 2) & 3, second: value & 3 };
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
function solidTriangleCell(color) {
|
|
2855
|
+
return packTriangleCell(color, color);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2335
2858
|
function concatBytes(...arrays) {
|
|
2336
2859
|
const total = arrays.reduce((sum, item) => sum + item.length, 0);
|
|
2337
2860
|
const out = new Uint8Array(total);
|
|
@@ -2362,6 +2885,367 @@ function readU32be(bytes, offset = 0) {
|
|
|
2362
2885
|
) >>> 0;
|
|
2363
2886
|
}
|
|
2364
2887
|
|
|
2888
|
+
function u16be(value) {
|
|
2889
|
+
const v = Number(value) >>> 0;
|
|
2890
|
+
assert(v <= 0xffff, "Value does not fit in uint16.");
|
|
2891
|
+
return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function readU16be(bytes, offset = 0) {
|
|
2895
|
+
return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
function normalizeCompressionMode(value = "none") {
|
|
2899
|
+
const key = String(value).toLowerCase();
|
|
2900
|
+
assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
|
|
2901
|
+
return key;
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
function asBytes(input) {
|
|
2905
|
+
if (input instanceof Uint8Array) return input;
|
|
2906
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
2907
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
2908
|
+
return new Uint8Array(input);
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/** Portable LZSS-style compressor used by QuadQR payload compression. */
|
|
2912
|
+
function compressPayload(input) {
|
|
2913
|
+
const bytes = asBytes(input);
|
|
2914
|
+
if (bytes.length < 4) return bytes.slice();
|
|
2915
|
+
const out = [];
|
|
2916
|
+
const recent = new Map();
|
|
2917
|
+
let pos = 0;
|
|
2918
|
+
|
|
2919
|
+
const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
|
|
2920
|
+
const remember = (i) => {
|
|
2921
|
+
const key = keyAt(i);
|
|
2922
|
+
if (key < 0) return;
|
|
2923
|
+
let list = recent.get(key);
|
|
2924
|
+
if (!list) recent.set(key, list = []);
|
|
2925
|
+
list.push(i);
|
|
2926
|
+
while (list.length > 32) list.shift();
|
|
2927
|
+
while (list.length && i - list[0] > 4095) list.shift();
|
|
2928
|
+
};
|
|
2929
|
+
|
|
2930
|
+
while (pos < bytes.length) {
|
|
2931
|
+
const flagIndex = out.length;
|
|
2932
|
+
out.push(0);
|
|
2933
|
+
let flags = 0;
|
|
2934
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
2935
|
+
let bestLength = 0;
|
|
2936
|
+
let bestOffset = 0;
|
|
2937
|
+
const key = keyAt(pos);
|
|
2938
|
+
const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
|
|
2939
|
+
for (let ci = candidates.length - 1; ci >= 0; ci--) {
|
|
2940
|
+
const candidate = candidates[ci];
|
|
2941
|
+
const offset = pos - candidate;
|
|
2942
|
+
if (offset <= 0 || offset > 4095) continue;
|
|
2943
|
+
let length = 0;
|
|
2944
|
+
while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
|
|
2945
|
+
if (length >= 3 && length > bestLength) {
|
|
2946
|
+
bestLength = length;
|
|
2947
|
+
bestOffset = offset;
|
|
2948
|
+
if (length === 18) break;
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
if (bestLength >= 3) {
|
|
2953
|
+
flags |= (1 << token);
|
|
2954
|
+
const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
|
|
2955
|
+
out.push((encoded >>> 8) & 0xff, encoded & 0xff);
|
|
2956
|
+
for (let i = 0; i < bestLength; i++) remember(pos + i);
|
|
2957
|
+
pos += bestLength;
|
|
2958
|
+
} else {
|
|
2959
|
+
out.push(bytes[pos]);
|
|
2960
|
+
remember(pos);
|
|
2961
|
+
pos++;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
out[flagIndex] = flags;
|
|
2965
|
+
}
|
|
2966
|
+
return Uint8Array.from(out);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
function decompressPayload(input, expectedLength = null) {
|
|
2970
|
+
const bytes = asBytes(input);
|
|
2971
|
+
const out = [];
|
|
2972
|
+
let pos = 0;
|
|
2973
|
+
while (pos < bytes.length) {
|
|
2974
|
+
const flags = bytes[pos++];
|
|
2975
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
2976
|
+
if (flags & (1 << token)) {
|
|
2977
|
+
assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
|
|
2978
|
+
const encoded = (bytes[pos++] << 8) | bytes[pos++];
|
|
2979
|
+
const offset = (encoded >>> 4) & 0x0fff;
|
|
2980
|
+
const length = (encoded & 0x0f) + 3;
|
|
2981
|
+
assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
|
|
2982
|
+
for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
|
|
2983
|
+
} else {
|
|
2984
|
+
out.push(bytes[pos++]);
|
|
2985
|
+
}
|
|
2986
|
+
if (expectedLength != null && out.length >= expectedLength) break;
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
|
|
2990
|
+
return Uint8Array.from(out);
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
function prepareCompressedBody(payload, compression = "none") {
|
|
2994
|
+
const mode = normalizeCompressionMode(compression);
|
|
2995
|
+
if (mode === "none") return { body: payload, compression: "none" };
|
|
2996
|
+
const compressed = compressPayload(payload);
|
|
2997
|
+
if (mode === "auto" && compressed.length >= payload.length - 2) {
|
|
2998
|
+
return { body: payload, compression: "none" };
|
|
2999
|
+
}
|
|
3000
|
+
return { body: compressed, compression: "lz" };
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function normalizeSigningKeyId(value) {
|
|
3004
|
+
if (value == null || value === false || value === "") return new Uint8Array(0);
|
|
3005
|
+
const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
|
|
3006
|
+
assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
|
|
3007
|
+
return bytes;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
|
|
3011
|
+
assert(keyIdLength <= 0xff, "Signing key ID is too long.");
|
|
3012
|
+
assert(publicKeyLength <= 0xff, "Signing public key is too large.");
|
|
3013
|
+
assert(signatureLength <= 0xff, "Signature is too large.");
|
|
3014
|
+
const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
|
|
3015
|
+
header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
|
|
3016
|
+
header[4] = version;
|
|
3017
|
+
header[5] = flags & 0xff;
|
|
3018
|
+
header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
|
|
3019
|
+
header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
|
|
3020
|
+
header.set(u32be(originalLength), 8);
|
|
3021
|
+
header[12] = keyIdLength;
|
|
3022
|
+
header[13] = publicKeyLength;
|
|
3023
|
+
header[14] = signatureLength;
|
|
3024
|
+
header[15] = 0;
|
|
3025
|
+
return header;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
function isPayloadEnvelope(input) {
|
|
3029
|
+
const bytes = asBytes(input);
|
|
3030
|
+
return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
function packPayloadEnvelope(input, options = {}) {
|
|
3034
|
+
const payload = asBytes(input);
|
|
3035
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "none");
|
|
3036
|
+
const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
|
|
3037
|
+
const header = makePayloadEnvelopeHeader({
|
|
3038
|
+
compression: prepared.compression,
|
|
3039
|
+
flags,
|
|
3040
|
+
originalLength: payload.length
|
|
3041
|
+
});
|
|
3042
|
+
return concatBytes(header, prepared.body);
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
function parsePayloadEnvelope(input) {
|
|
3046
|
+
const container = asBytes(input);
|
|
3047
|
+
assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
|
|
3048
|
+
const version = container[4];
|
|
3049
|
+
assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
|
|
3050
|
+
const flags = container[5];
|
|
3051
|
+
const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
|
|
3052
|
+
assert(compression != null, `Unknown payload compression id ${container[6]}.`);
|
|
3053
|
+
const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
|
|
3054
|
+
const originalLength = readU32be(container, 8);
|
|
3055
|
+
const metadataLength = container[12];
|
|
3056
|
+
const publicKeyLength = container[13];
|
|
3057
|
+
const signatureLength = container[14];
|
|
3058
|
+
let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
|
|
3059
|
+
const endMetadata = offset + metadataLength;
|
|
3060
|
+
const endPublicKey = endMetadata + publicKeyLength;
|
|
3061
|
+
const endSignature = endPublicKey + signatureLength;
|
|
3062
|
+
assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
|
|
3063
|
+
const metadataBytes = container.slice(offset, endMetadata);
|
|
3064
|
+
const publicKey = container.slice(endMetadata, endPublicKey);
|
|
3065
|
+
const signature = container.slice(endPublicKey, endSignature);
|
|
3066
|
+
const stored = container.slice(endSignature);
|
|
3067
|
+
const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
|
|
3068
|
+
assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
|
|
3069
|
+
const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
|
|
3070
|
+
const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
|
|
3071
|
+
|
|
3072
|
+
// v1 used byte 12 for a human-readable signer label and always embedded
|
|
3073
|
+
// the public key. v2 uses the same field for a compact key ID and makes
|
|
3074
|
+
// public-key embedding optional.
|
|
3075
|
+
const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
3076
|
+
const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
3077
|
+
const keyIdHex = version >= 2 && metadataLength
|
|
3078
|
+
? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
|
|
3079
|
+
: null;
|
|
3080
|
+
|
|
3081
|
+
return {
|
|
3082
|
+
container,
|
|
3083
|
+
version,
|
|
3084
|
+
compression,
|
|
3085
|
+
compressed: compression !== "none",
|
|
3086
|
+
originalBytes: originalLength,
|
|
3087
|
+
storedBytes: stored.length,
|
|
3088
|
+
savedBytes: originalLength - stored.length,
|
|
3089
|
+
payload,
|
|
3090
|
+
signed,
|
|
3091
|
+
signer: legacySigner,
|
|
3092
|
+
keyId,
|
|
3093
|
+
keyIdHex,
|
|
3094
|
+
publicKey,
|
|
3095
|
+
hasEmbeddedPublicKey: publicKey.length > 0,
|
|
3096
|
+
signature,
|
|
3097
|
+
signatureAlgorithm,
|
|
3098
|
+
signedBytes,
|
|
3099
|
+
signatureVerified: signed ? null : undefined,
|
|
3100
|
+
signatureTrusted: signed ? null : undefined
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
function subtleCrypto() {
|
|
3105
|
+
const subtle = globalThis.crypto?.subtle;
|
|
3106
|
+
assert(subtle, "Web Crypto is required for QuadQR signing.");
|
|
3107
|
+
return subtle;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
async function normalizeEd25519PublicKey(key) {
|
|
3111
|
+
const subtle = subtleCrypto();
|
|
3112
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
3113
|
+
if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
|
|
3114
|
+
const bytes = asBytes(key);
|
|
3115
|
+
assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
|
|
3116
|
+
return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
async function normalizeEd25519PrivateKey(key) {
|
|
3120
|
+
const subtle = subtleCrypto();
|
|
3121
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
3122
|
+
if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
|
|
3123
|
+
const bytes = asBytes(key);
|
|
3124
|
+
return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
async function deriveSigningKeyId(publicKey, bytes = 8) {
|
|
3128
|
+
const subtle = subtleCrypto();
|
|
3129
|
+
const normalized = await normalizeEd25519PublicKey(publicKey);
|
|
3130
|
+
const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
|
|
3131
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
|
|
3132
|
+
const count = Math.max(4, Math.min(16, Math.floor(bytes)));
|
|
3133
|
+
return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
async function generateSigningKeyPair() {
|
|
3137
|
+
const subtle = subtleCrypto();
|
|
3138
|
+
const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
3139
|
+
const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
|
|
3140
|
+
const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
|
|
3141
|
+
const keyId = await deriveSigningKeyId(pair.publicKey);
|
|
3142
|
+
return {
|
|
3143
|
+
privateKey: pair.privateKey,
|
|
3144
|
+
publicKey: pair.publicKey,
|
|
3145
|
+
privateKeyPkcs8,
|
|
3146
|
+
publicKeyBytes,
|
|
3147
|
+
keyId,
|
|
3148
|
+
algorithm: "Ed25519"
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
async function packSignedPayloadEnvelope(input, options = {}) {
|
|
3153
|
+
const payload = asBytes(input);
|
|
3154
|
+
assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
|
|
3155
|
+
const subtle = subtleCrypto();
|
|
3156
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
|
|
3157
|
+
const keyIdBytes = normalizeSigningKeyId(options.keyId);
|
|
3158
|
+
|
|
3159
|
+
let publicKeyBytes = new Uint8Array(0);
|
|
3160
|
+
if (options.embedPublicKey) {
|
|
3161
|
+
assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
|
|
3162
|
+
const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
|
|
3163
|
+
publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
|
|
3167
|
+
(prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
|
|
3168
|
+
(publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
|
|
3169
|
+
const header = makePayloadEnvelopeHeader({
|
|
3170
|
+
compression: prepared.compression,
|
|
3171
|
+
flags,
|
|
3172
|
+
originalLength: payload.length,
|
|
3173
|
+
keyIdLength: keyIdBytes.length,
|
|
3174
|
+
publicKeyLength: publicKeyBytes.length,
|
|
3175
|
+
signatureLength: 64,
|
|
3176
|
+
signatureAlgorithm: "Ed25519"
|
|
3177
|
+
});
|
|
3178
|
+
const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
|
|
3179
|
+
const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
|
|
3180
|
+
const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
|
|
3181
|
+
assert(signature.length === 64, "Unexpected Ed25519 signature length.");
|
|
3182
|
+
return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
function lookupTrustedSigningKey(trustedKeys, keyId) {
|
|
3186
|
+
if (!trustedKeys || !keyId) return null;
|
|
3187
|
+
if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
|
|
3188
|
+
if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
|
|
3189
|
+
return null;
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
3193
|
+
const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
|
|
3194
|
+
assert(parsed.signed, "QuadQR payload is not signed.");
|
|
3195
|
+
assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
|
|
3196
|
+
const subtle = subtleCrypto();
|
|
3197
|
+
|
|
3198
|
+
let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
|
|
3199
|
+
let trustSource = trusted ? "external" : null;
|
|
3200
|
+
|
|
3201
|
+
// Legacy v1 symbols embedded the public key by default, so preserve their
|
|
3202
|
+
// historical self-verification behavior. v2 requires explicit opt-in.
|
|
3203
|
+
if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
|
|
3204
|
+
trusted = parsed.publicKey;
|
|
3205
|
+
trustSource = "embedded";
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
assert(
|
|
3209
|
+
trusted,
|
|
3210
|
+
parsed.keyId
|
|
3211
|
+
? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
|
|
3212
|
+
: "A trusted Ed25519 public key is required to verify this signed QuadQR."
|
|
3213
|
+
);
|
|
3214
|
+
|
|
3215
|
+
const publicKey = await normalizeEd25519PublicKey(trusted);
|
|
3216
|
+
if (trustSource === "external" && parsed.publicKey.length) {
|
|
3217
|
+
const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
|
|
3218
|
+
assert(
|
|
3219
|
+
trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
|
|
3220
|
+
"Embedded signing key does not match the trusted public key."
|
|
3221
|
+
);
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
|
|
3225
|
+
return {
|
|
3226
|
+
verified,
|
|
3227
|
+
trusted: verified && trustSource === "external",
|
|
3228
|
+
trustSource,
|
|
3229
|
+
keyId: parsed.keyId ?? null
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
function prepareOptionalCompressedPayload(input, compression = "none") {
|
|
3234
|
+
const payload = asBytes(input);
|
|
3235
|
+
const mode = normalizeCompressionMode(compression);
|
|
3236
|
+
if (mode === "none") return { payload, extended: false, compression: "none" };
|
|
3237
|
+
const prepared = prepareCompressedBody(payload, mode);
|
|
3238
|
+
// Auto mode is truly zero-overhead when compression does not help.
|
|
3239
|
+
if (mode === "auto" && prepared.compression === "none") {
|
|
3240
|
+
return { payload, extended: false, compression: "none" };
|
|
3241
|
+
}
|
|
3242
|
+
return {
|
|
3243
|
+
payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
|
|
3244
|
+
extended: true,
|
|
3245
|
+
compression: prepared.compression
|
|
3246
|
+
};
|
|
3247
|
+
}
|
|
3248
|
+
|
|
2365
3249
|
let CRC_TABLE = null;
|
|
2366
3250
|
let CRC32_ACCELERATOR = null;
|
|
2367
3251
|
|
|
@@ -2397,8 +3281,19 @@ function crc32(bytes) {
|
|
|
2397
3281
|
return (crc ^ 0xffffffff) >>> 0;
|
|
2398
3282
|
}
|
|
2399
3283
|
|
|
2400
|
-
function bytesToCells(bytes) {
|
|
2401
|
-
const
|
|
3284
|
+
function bytesToCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3285
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
3286
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
3287
|
+
const out = new Array(bytes.length * TRIANGLE16_CELLS_PER_BYTE);
|
|
3288
|
+
let cursor = 0;
|
|
3289
|
+
for (const byte of bytes) {
|
|
3290
|
+
out[cursor++] = (byte >>> 4) & 0x0f;
|
|
3291
|
+
out[cursor++] = byte & 0x0f;
|
|
3292
|
+
}
|
|
3293
|
+
return out;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
const out = new Array(bytes.length * RGBW_CELLS_PER_BYTE);
|
|
2402
3297
|
let cursor = 0;
|
|
2403
3298
|
for (const byte of bytes) {
|
|
2404
3299
|
out[cursor++] = (byte >>> 6) & 0b11;
|
|
@@ -2409,11 +3304,36 @@ function bytesToCells(bytes) {
|
|
|
2409
3304
|
return out;
|
|
2410
3305
|
}
|
|
2411
3306
|
|
|
2412
|
-
function
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
3307
|
+
function bytesToProtectedHeaderCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3308
|
+
const base = bytesToCells(bytes, CELL_ENCODINGS.RGBW);
|
|
3309
|
+
if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) return base;
|
|
3310
|
+
// Triangle16 deliberately keeps the protected bootstrap/header as solid-color
|
|
3311
|
+
// modules. This costs a few cells but makes mode detection and damaged-camera
|
|
3312
|
+
// recovery substantially more reliable.
|
|
3313
|
+
return base.map(solidTriangleCell);
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
function cellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3317
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
3318
|
+
const cellsPerByte = cellsPerByteForEncoding(encoding);
|
|
3319
|
+
const count = byteCount ?? Math.floor(cells.length / cellsPerByte);
|
|
3320
|
+
assert(cells.length >= count * cellsPerByte, "Not enough data cells to rebuild bytes.");
|
|
3321
|
+
const out = new Uint8Array(count);
|
|
3322
|
+
|
|
3323
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
3324
|
+
for (let i = 0; i < count; i++) {
|
|
3325
|
+
const offset = i * TRIANGLE16_CELLS_PER_BYTE;
|
|
3326
|
+
const a = cells[offset];
|
|
3327
|
+
const b = cells[offset + 1];
|
|
3328
|
+
assert(Number.isInteger(a) && a >= 0 && a <= 15, "Invalid Triangle16 data cell.");
|
|
3329
|
+
assert(Number.isInteger(b) && b >= 0 && b <= 15, "Invalid Triangle16 data cell.");
|
|
3330
|
+
out[i] = (a << 4) | b;
|
|
3331
|
+
}
|
|
3332
|
+
return out;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
for (let i = 0; i < count; i++) {
|
|
3336
|
+
const offset = i * RGBW_CELLS_PER_BYTE;
|
|
2417
3337
|
const a = cells[offset];
|
|
2418
3338
|
const b = cells[offset + 1];
|
|
2419
3339
|
const c = cells[offset + 2];
|
|
@@ -2426,12 +3346,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
|
|
|
2426
3346
|
return out;
|
|
2427
3347
|
}
|
|
2428
3348
|
|
|
2429
|
-
function
|
|
3349
|
+
function protectedHeaderCellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3350
|
+
if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) {
|
|
3351
|
+
return cellsToBytes(cells, byteCount, CELL_ENCODINGS.RGBW);
|
|
3352
|
+
}
|
|
3353
|
+
const colors = cells.map((value) => {
|
|
3354
|
+
const pair = unpackTriangleCell(value);
|
|
3355
|
+
if (pair.first !== pair.second) throw new Error("Triangle16 protected header lost solid-color integrity.");
|
|
3356
|
+
return pair.first;
|
|
3357
|
+
});
|
|
3358
|
+
return cellsToBytes(colors, byteCount, CELL_ENCODINGS.RGBW);
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
function flagsFor(text, eccLevel, secure = false, extended = false, signed = false, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
2430
3362
|
return (text ? TEXT_FLAG : 0) |
|
|
2431
3363
|
(secure ? SECURE_FLAG : 0) |
|
|
3364
|
+
(extended ? EXTENDED_PAYLOAD_FLAG : 0) |
|
|
3365
|
+
(signed ? SIGNED_FLAG : 0) |
|
|
3366
|
+
(normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
|
|
2432
3367
|
((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
|
|
2433
3368
|
}
|
|
2434
3369
|
|
|
3370
|
+
function cellEncodingFromFlags(flags) {
|
|
3371
|
+
return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
2435
3374
|
function eccFromFlags(flags) {
|
|
2436
3375
|
const id = (flags & ECC_MASK) >> ECC_SHIFT;
|
|
2437
3376
|
const name = ECC_BY_ID[id];
|
|
@@ -2681,7 +3620,8 @@ function maskValue(row, col, maskId) {
|
|
|
2681
3620
|
}
|
|
2682
3621
|
}
|
|
2683
3622
|
|
|
2684
|
-
function makePaddingCells(count, seed) {
|
|
3623
|
+
function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3624
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
2685
3625
|
let state = (seed >>> 0) || 0x6d2b79f5;
|
|
2686
3626
|
const out = new Array(count);
|
|
2687
3627
|
for (let i = 0; i < count; i++) {
|
|
@@ -2689,11 +3629,27 @@ function makePaddingCells(count, seed) {
|
|
|
2689
3629
|
state ^= state >>> 17;
|
|
2690
3630
|
state ^= state << 5;
|
|
2691
3631
|
state >>>= 0;
|
|
2692
|
-
out[i] = state & 3;
|
|
3632
|
+
out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
|
|
2693
3633
|
}
|
|
2694
3634
|
return out;
|
|
2695
3635
|
}
|
|
2696
3636
|
|
|
3637
|
+
function triangleMaskValue(row, col, maskId) {
|
|
3638
|
+
const first = maskValue(row, col, maskId);
|
|
3639
|
+
const second = maskValue(row + 1, col + 2, (maskId + 1) & 3);
|
|
3640
|
+
return packTriangleCell(first, second);
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
function applyCellMask(value, row, col, maskId, cellEncoding, protectedHeader = false) {
|
|
3644
|
+
if (cellEncoding !== CELL_ENCODINGS.TRIANGLE16) return value ^ maskValue(row, col, maskId);
|
|
3645
|
+
if (protectedHeader) {
|
|
3646
|
+
const pair = unpackTriangleCell(value);
|
|
3647
|
+
if (pair.first !== pair.second) throw new Error("Triangle16 protected header must use solid-color cells.");
|
|
3648
|
+
return solidTriangleCell(pair.first ^ maskValue(row, col, maskId));
|
|
3649
|
+
}
|
|
3650
|
+
return value ^ triangleMaskValue(row, col, maskId);
|
|
3651
|
+
}
|
|
3652
|
+
|
|
2697
3653
|
const SPECTRAL_PERMUTATION_CACHE = new Map();
|
|
2698
3654
|
|
|
2699
3655
|
function spectralPermutation(length, version) {
|
|
@@ -2722,7 +3678,15 @@ function spectralPermutation(length, version) {
|
|
|
2722
3678
|
return permutation;
|
|
2723
3679
|
}
|
|
2724
3680
|
|
|
2725
|
-
function applyData(
|
|
3681
|
+
function applyData(
|
|
3682
|
+
layout,
|
|
3683
|
+
rawCells,
|
|
3684
|
+
maskId,
|
|
3685
|
+
spectralInterleaving = true,
|
|
3686
|
+
cellEncoding = CELL_ENCODINGS.RGBW,
|
|
3687
|
+
protectedHeaderCells = getHeaderPlan(layout.version).codewordCells
|
|
3688
|
+
) {
|
|
3689
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
2726
3690
|
const matrix = cloneMatrix(layout.matrix);
|
|
2727
3691
|
const permutation = spectralInterleaving
|
|
2728
3692
|
? spectralPermutation(layout.dataPositions.length, layout.version)
|
|
@@ -2731,16 +3695,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
|
|
|
2731
3695
|
for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
|
|
2732
3696
|
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
2733
3697
|
const [row, col] = layout.dataPositions[physicalIndex];
|
|
2734
|
-
matrix[row][col] =
|
|
3698
|
+
matrix[row][col] = applyCellMask(
|
|
3699
|
+
rawCells[logicalIndex],
|
|
3700
|
+
row,
|
|
3701
|
+
col,
|
|
3702
|
+
maskId,
|
|
3703
|
+
encoding,
|
|
3704
|
+
encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < protectedHeaderCells
|
|
3705
|
+
);
|
|
2735
3706
|
}
|
|
2736
3707
|
return matrix;
|
|
2737
3708
|
}
|
|
2738
3709
|
|
|
2739
|
-
function
|
|
3710
|
+
function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3711
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
2740
3712
|
const size = matrix.length;
|
|
2741
3713
|
let penalty = 0;
|
|
2742
|
-
const
|
|
2743
|
-
let
|
|
3714
|
+
const colorCounts = [0, 0, 0, 0];
|
|
3715
|
+
let colorCount = 0;
|
|
3716
|
+
|
|
3717
|
+
const symbolAt = (r, c) => matrix[r][c];
|
|
3718
|
+
const addColors = (value) => {
|
|
3719
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
3720
|
+
const pair = unpackTriangleCell(value);
|
|
3721
|
+
colorCounts[pair.first]++;
|
|
3722
|
+
colorCounts[pair.second]++;
|
|
3723
|
+
colorCount += 2;
|
|
3724
|
+
} else {
|
|
3725
|
+
colorCounts[value]++;
|
|
3726
|
+
colorCount++;
|
|
3727
|
+
}
|
|
3728
|
+
};
|
|
2744
3729
|
|
|
2745
3730
|
for (let r = 0; r < size; r++) {
|
|
2746
3731
|
let previous = null;
|
|
@@ -2751,9 +3736,8 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
2751
3736
|
run = 0;
|
|
2752
3737
|
continue;
|
|
2753
3738
|
}
|
|
2754
|
-
const value =
|
|
2755
|
-
|
|
2756
|
-
dataCount++;
|
|
3739
|
+
const value = symbolAt(r, c);
|
|
3740
|
+
addColors(value);
|
|
2757
3741
|
if (value === previous) {
|
|
2758
3742
|
run++;
|
|
2759
3743
|
if (run >= 4) penalty += 2;
|
|
@@ -2773,7 +3757,7 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
2773
3757
|
run = 0;
|
|
2774
3758
|
continue;
|
|
2775
3759
|
}
|
|
2776
|
-
const value =
|
|
3760
|
+
const value = symbolAt(r, c);
|
|
2777
3761
|
if (value === previous) {
|
|
2778
3762
|
run++;
|
|
2779
3763
|
if (run >= 4) penalty += 2;
|
|
@@ -2784,9 +3768,9 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
2784
3768
|
}
|
|
2785
3769
|
}
|
|
2786
3770
|
|
|
2787
|
-
if (
|
|
2788
|
-
const ideal =
|
|
2789
|
-
penalty +=
|
|
3771
|
+
if (colorCount > 0) {
|
|
3772
|
+
const ideal = colorCount / 4;
|
|
3773
|
+
penalty += colorCounts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
|
|
2790
3774
|
}
|
|
2791
3775
|
return penalty;
|
|
2792
3776
|
}
|
|
@@ -2815,7 +3799,7 @@ function deinterleaveBlocks(stream, blockLengths) {
|
|
|
2815
3799
|
return blocks;
|
|
2816
3800
|
}
|
|
2817
3801
|
|
|
2818
|
-
function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
3802
|
+
function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
2819
3803
|
const ecc = getEffectiveEcc(version, eccLevel);
|
|
2820
3804
|
const bodyByteCount = payloadLength + CRC_BYTES;
|
|
2821
3805
|
const dataSymbols = bodyByteCount;
|
|
@@ -2837,25 +3821,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
|
2837
3821
|
dataBlockLengths,
|
|
2838
3822
|
codewordBlockLengths,
|
|
2839
3823
|
encodedSymbols,
|
|
2840
|
-
encodedCells: encodedSymbols *
|
|
3824
|
+
encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
|
|
2841
3825
|
};
|
|
2842
3826
|
}
|
|
2843
3827
|
|
|
2844
|
-
function streamCellCount(payloadLength, eccLevel, version) {
|
|
2845
|
-
return getHeaderPlan(version).codewordCells +
|
|
3828
|
+
function streamCellCount(payloadLength, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3829
|
+
return getHeaderPlan(version).codewordCells +
|
|
3830
|
+
getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding).encodedCells;
|
|
2846
3831
|
}
|
|
2847
3832
|
|
|
2848
|
-
function streamFitsLayout(layout, eccLevel, payloadLength, version) {
|
|
2849
|
-
return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
|
|
3833
|
+
function streamFitsLayout(layout, eccLevel, payloadLength, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3834
|
+
return streamCellCount(payloadLength, eccLevel, version, cellEncoding) <= layout.dataPositions.length;
|
|
2850
3835
|
}
|
|
2851
3836
|
|
|
2852
|
-
function getCapacityForLayout(layout, eccLevel, version) {
|
|
2853
|
-
|
|
3837
|
+
function getCapacityForLayout(layout, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3838
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
3839
|
+
if (!streamFitsLayout(layout, eccLevel, 0, version, encoding)) return 0;
|
|
2854
3840
|
let low = 0;
|
|
2855
|
-
let high = Math.floor(layout.dataPositions.length /
|
|
3841
|
+
let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
|
|
2856
3842
|
while (low < high) {
|
|
2857
3843
|
const mid = Math.ceil((low + high) / 2);
|
|
2858
|
-
if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
|
|
3844
|
+
if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
|
|
2859
3845
|
else high = mid - 1;
|
|
2860
3846
|
}
|
|
2861
3847
|
return low;
|
|
@@ -2864,6 +3850,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
|
|
|
2864
3850
|
function getVersionInfo(version, options = {}) {
|
|
2865
3851
|
validateVersion(version);
|
|
2866
3852
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
3853
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
2867
3854
|
const layout = createLayout(version);
|
|
2868
3855
|
const headerPlan = getHeaderPlan(version);
|
|
2869
3856
|
const effectiveEcc = getEffectiveEcc(version, eccLevel);
|
|
@@ -2873,8 +3860,8 @@ function getVersionInfo(version, options = {}) {
|
|
|
2873
3860
|
eccLevel,
|
|
2874
3861
|
size: layout.size,
|
|
2875
3862
|
dataCells: layout.dataPositions.length,
|
|
2876
|
-
theoreticalBits: layout.dataPositions.length * 2,
|
|
2877
|
-
capacityBytes: getCapacityForLayout(layout, eccLevel, version),
|
|
3863
|
+
theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
|
|
3864
|
+
capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
|
|
2878
3865
|
headerCells: headerPlan.codewordCells,
|
|
2879
3866
|
headerBytes: headerPlan.headerBytes,
|
|
2880
3867
|
headerParitySymbols: headerPlan.paritySymbols,
|
|
@@ -2886,8 +3873,11 @@ function getVersionInfo(version, options = {}) {
|
|
|
2886
3873
|
hasAlignmentMarker: true,
|
|
2887
3874
|
alignmentPatterns: layout.alignments.length,
|
|
2888
3875
|
alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
|
|
2889
|
-
bitsPerDataCell: 2,
|
|
3876
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
3877
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
3878
|
+
cellEncoding,
|
|
2890
3879
|
colors: 4,
|
|
3880
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
2891
3881
|
spectralInterleaving: true,
|
|
2892
3882
|
confidenceAwareEcc: true
|
|
2893
3883
|
};
|
|
@@ -2898,6 +3888,7 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
2898
3888
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
2899
3889
|
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
2900
3890
|
const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
3891
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
2901
3892
|
|
|
2902
3893
|
validateVersion(minVersion);
|
|
2903
3894
|
validateVersion(maxVersion);
|
|
@@ -2906,10 +3897,10 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
2906
3897
|
if (requested !== "auto") {
|
|
2907
3898
|
validateVersion(requested);
|
|
2908
3899
|
assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
|
|
2909
|
-
const info = getVersionInfo(requested, { ecc });
|
|
3900
|
+
const info = getVersionInfo(requested, { ecc, cellEncoding });
|
|
2910
3901
|
const layout = createLayout(requested);
|
|
2911
3902
|
assert(
|
|
2912
|
-
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
|
|
3903
|
+
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
|
|
2913
3904
|
`Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
|
|
2914
3905
|
);
|
|
2915
3906
|
return requested;
|
|
@@ -2917,20 +3908,20 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
2917
3908
|
|
|
2918
3909
|
for (let version = minVersion; version <= maxVersion; version++) {
|
|
2919
3910
|
const layout = createLayout(version);
|
|
2920
|
-
if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
|
|
3911
|
+
if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
|
|
2921
3912
|
}
|
|
2922
3913
|
throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
|
|
2923
3914
|
}
|
|
2924
3915
|
|
|
2925
|
-
function encodeProtectedHeader(header, version) {
|
|
3916
|
+
function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
2926
3917
|
const plan = getHeaderPlan(version);
|
|
2927
3918
|
const codeword = rsEncode(Array.from(header), plan.paritySymbols);
|
|
2928
3919
|
assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
|
|
2929
|
-
return
|
|
3920
|
+
return bytesToProtectedHeaderCells(codeword, cellEncoding);
|
|
2930
3921
|
}
|
|
2931
3922
|
|
|
2932
|
-
function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
2933
|
-
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
|
|
3923
|
+
function encodeProtectedBody(bodyBytes, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3924
|
+
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version, cellEncoding);
|
|
2934
3925
|
assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
|
|
2935
3926
|
|
|
2936
3927
|
const blocks = [];
|
|
@@ -2939,7 +3930,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
|
2939
3930
|
blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
|
|
2940
3931
|
offset += length;
|
|
2941
3932
|
}
|
|
2942
|
-
return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
|
|
3933
|
+
return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
|
|
2943
3934
|
}
|
|
2944
3935
|
|
|
2945
3936
|
function finalizeMatrix(layout, rawCells, meta) {
|
|
@@ -2947,9 +3938,10 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
2947
3938
|
let bestMatrix = null;
|
|
2948
3939
|
let bestPenalty = Infinity;
|
|
2949
3940
|
|
|
3941
|
+
const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
|
|
2950
3942
|
for (let maskId = 0; maskId < 4; maskId++) {
|
|
2951
|
-
const candidate = applyData(layout, rawCells, maskId, true);
|
|
2952
|
-
const penalty =
|
|
3943
|
+
const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
|
|
3944
|
+
const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
|
|
2953
3945
|
if (penalty < bestPenalty) {
|
|
2954
3946
|
bestPenalty = penalty;
|
|
2955
3947
|
bestMaskId = maskId;
|
|
@@ -2957,7 +3949,7 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
2957
3949
|
}
|
|
2958
3950
|
}
|
|
2959
3951
|
|
|
2960
|
-
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
|
|
3952
|
+
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
|
|
2961
3953
|
return {
|
|
2962
3954
|
format: "QuadQR",
|
|
2963
3955
|
formatVersion: FORMAT_VERSION,
|
|
@@ -2968,6 +3960,11 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
2968
3960
|
payloadBytes: meta.payloadBytes,
|
|
2969
3961
|
sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
|
|
2970
3962
|
secure: Boolean(meta.secure),
|
|
3963
|
+
compressed: meta.compression != null && meta.compression !== "none",
|
|
3964
|
+
compression: meta.compression ?? "none",
|
|
3965
|
+
signed: Boolean(meta.signed),
|
|
3966
|
+
signingKeyId: meta.signingKeyId ?? null,
|
|
3967
|
+
hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
|
|
2971
3968
|
requiresDecryption: Boolean(meta.secure),
|
|
2972
3969
|
security: meta.security ?? null,
|
|
2973
3970
|
meaningfulCells: meta.meaningfulCells,
|
|
@@ -2975,7 +3972,10 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
2975
3972
|
capacityBytes: info.capacityBytes,
|
|
2976
3973
|
alignmentPatterns: layout.alignments.length,
|
|
2977
3974
|
utilization: meta.meaningfulCells / layout.dataPositions.length,
|
|
2978
|
-
bitsPerDataCell: 2,
|
|
3975
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
3976
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
3977
|
+
cellEncoding,
|
|
3978
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
2979
3979
|
eccLevel: meta.eccLevel,
|
|
2980
3980
|
eccParitySymbols: meta.eccParitySymbols,
|
|
2981
3981
|
eccBlocks: meta.eccBlocks,
|
|
@@ -2986,39 +3986,44 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
2986
3986
|
};
|
|
2987
3987
|
}
|
|
2988
3988
|
|
|
2989
|
-
function
|
|
2990
|
-
assert(typeof text === "string", "encodeText expects a string.");
|
|
2991
|
-
return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
2992
|
-
}
|
|
2993
|
-
|
|
2994
|
-
function encodeBytes(input, options = {}) {
|
|
3989
|
+
function encodePreparedBytes(input, options = {}) {
|
|
2995
3990
|
if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
|
|
2996
3991
|
throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
|
|
2997
3992
|
}
|
|
2998
3993
|
|
|
2999
|
-
const payload =
|
|
3994
|
+
const payload = asBytes(input);
|
|
3000
3995
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
3001
3996
|
const secure = Boolean(options.secure);
|
|
3002
|
-
const
|
|
3003
|
-
const
|
|
3997
|
+
const extended = Boolean(options.extended);
|
|
3998
|
+
const signed = Boolean(options.signed);
|
|
3999
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
4000
|
+
const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed, cellEncoding);
|
|
4001
|
+
const version = chooseVersion(payload.length, { ...options, ecc: eccLevel, cellEncoding });
|
|
3004
4002
|
const layout = createLayout(version);
|
|
3005
4003
|
const header = makeHeader(payload.length, flags, version);
|
|
3006
4004
|
const crc = crc32(concatBytes(header, payload));
|
|
3007
|
-
const headerCells = encodeProtectedHeader(header, version);
|
|
3008
|
-
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
|
|
4005
|
+
const headerCells = encodeProtectedHeader(header, version, cellEncoding);
|
|
4006
|
+
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version, cellEncoding);
|
|
3009
4007
|
const meaningfulCells = headerCells.concat(bodyEncoded.cells);
|
|
3010
4008
|
assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
|
|
3011
4009
|
|
|
3012
4010
|
const padding = makePaddingCells(
|
|
3013
4011
|
layout.dataPositions.length - meaningfulCells.length,
|
|
3014
|
-
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
|
|
4012
|
+
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
|
|
4013
|
+
cellEncoding
|
|
3015
4014
|
);
|
|
3016
4015
|
|
|
3017
4016
|
return finalizeMatrix(layout, meaningfulCells.concat(padding), {
|
|
3018
4017
|
version,
|
|
4018
|
+
cellEncoding,
|
|
3019
4019
|
payloadBytes: payload.length,
|
|
3020
4020
|
sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
|
|
3021
4021
|
secure,
|
|
4022
|
+
extended,
|
|
4023
|
+
signed,
|
|
4024
|
+
compression: options.compressionMetadata ?? "none",
|
|
4025
|
+
signingKeyId: options.signingKeyId ?? null,
|
|
4026
|
+
hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
|
|
3022
4027
|
security: options.securityMetadata ?? null,
|
|
3023
4028
|
meaningfulCells: meaningfulCells.length,
|
|
3024
4029
|
eccLevel,
|
|
@@ -3029,6 +4034,58 @@ function encodeBytes(input, options = {}) {
|
|
|
3029
4034
|
});
|
|
3030
4035
|
}
|
|
3031
4036
|
|
|
4037
|
+
function encodeText(text, options = {}) {
|
|
4038
|
+
assert(typeof text === "string", "encodeText expects a string.");
|
|
4039
|
+
const source = getTextEncoder().encode(text);
|
|
4040
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
4041
|
+
return encodePreparedBytes(prepared.payload, {
|
|
4042
|
+
...options,
|
|
4043
|
+
text: true,
|
|
4044
|
+
extended: prepared.extended,
|
|
4045
|
+
sourcePayloadBytes: source.length,
|
|
4046
|
+
compressionMetadata: prepared.compression
|
|
4047
|
+
});
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
function encodeBytes(input, options = {}) {
|
|
4051
|
+
const source = asBytes(input);
|
|
4052
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
4053
|
+
return encodePreparedBytes(prepared.payload, {
|
|
4054
|
+
...options,
|
|
4055
|
+
text: Boolean(options.text),
|
|
4056
|
+
extended: prepared.extended,
|
|
4057
|
+
sourcePayloadBytes: source.length,
|
|
4058
|
+
compressionMetadata: prepared.compression
|
|
4059
|
+
});
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
/** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
|
|
4063
|
+
function encodeUint8Array(input, options = {}) {
|
|
4064
|
+
return encodeBytes(asBytes(input), options);
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
/** Encode and sign a normal payload using an internal Ed25519 envelope. */
|
|
4068
|
+
async function encodeSignedBytes(input, options = {}) {
|
|
4069
|
+
const source = asBytes(input);
|
|
4070
|
+
const envelope = await packSignedPayloadEnvelope(source, options);
|
|
4071
|
+
const parsed = parsePayloadEnvelope(envelope);
|
|
4072
|
+
return encodePreparedBytes(envelope, {
|
|
4073
|
+
...options,
|
|
4074
|
+
text: Boolean(options.text),
|
|
4075
|
+
extended: true,
|
|
4076
|
+
signed: true,
|
|
4077
|
+
sourcePayloadBytes: source.length,
|
|
4078
|
+
compressionMetadata: parsed.compression,
|
|
4079
|
+
signingKeyId: parsed.keyId,
|
|
4080
|
+
hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
async function encodeSignedText(text, options = {}) {
|
|
4085
|
+
assert(typeof text === "string", "encodeSignedText expects a string.");
|
|
4086
|
+
return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
4087
|
+
}
|
|
4088
|
+
|
|
3032
4089
|
/**
|
|
3033
4090
|
* Encode text using the optional QuadQR Secure Payload v1 layer.
|
|
3034
4091
|
* This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
|
|
@@ -3040,32 +4097,75 @@ async function encodeSecureText(text, options = {}) {
|
|
|
3040
4097
|
|
|
3041
4098
|
/** Encode arbitrary bytes using password or raw 256-bit key security. */
|
|
3042
4099
|
async function encodeSecureBytes(input, options = {}) {
|
|
3043
|
-
const sourcePayload =
|
|
4100
|
+
const sourcePayload = asBytes(input);
|
|
3044
4101
|
const security = options.security ?? {};
|
|
3045
|
-
|
|
3046
|
-
|
|
4102
|
+
let protectedPayload = sourcePayload;
|
|
4103
|
+
let extended = false;
|
|
4104
|
+
let signed = false;
|
|
4105
|
+
let compression = "none";
|
|
4106
|
+
let signingKeyId = null;
|
|
4107
|
+
let hasEmbeddedPublicKey = false;
|
|
4108
|
+
|
|
4109
|
+
if (options.signing) {
|
|
4110
|
+
protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
|
|
4111
|
+
...options,
|
|
4112
|
+
...options.signing,
|
|
4113
|
+
compression: options.compression ?? "auto"
|
|
4114
|
+
});
|
|
4115
|
+
const parsed = parsePayloadEnvelope(protectedPayload);
|
|
4116
|
+
extended = true;
|
|
4117
|
+
signed = true;
|
|
4118
|
+
compression = parsed.compression;
|
|
4119
|
+
signingKeyId = parsed.keyId;
|
|
4120
|
+
hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
|
|
4121
|
+
} else {
|
|
4122
|
+
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
|
|
4123
|
+
protectedPayload = prepared.payload;
|
|
4124
|
+
extended = prepared.extended;
|
|
4125
|
+
compression = prepared.compression;
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
const encrypted = await encryptSecurePayload(protectedPayload, security);
|
|
4129
|
+
return encodePreparedBytes(encrypted.envelope, {
|
|
3047
4130
|
...options,
|
|
3048
4131
|
text: Boolean(options.text),
|
|
3049
4132
|
secure: true,
|
|
4133
|
+
extended,
|
|
4134
|
+
signed,
|
|
3050
4135
|
sourcePayloadBytes: sourcePayload.length,
|
|
4136
|
+
compressionMetadata: compression,
|
|
4137
|
+
signingKeyId,
|
|
4138
|
+
hasEmbeddedPublicKey,
|
|
3051
4139
|
securityMetadata: encrypted.metadata
|
|
3052
4140
|
});
|
|
3053
4141
|
}
|
|
3054
4142
|
|
|
3055
4143
|
/**
|
|
3056
4144
|
* Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
|
|
3057
|
-
*
|
|
4145
|
+
* Compression and signatures are automatically unwrapped after decryption.
|
|
3058
4146
|
*/
|
|
3059
4147
|
async function decryptDecoded(result, security = {}) {
|
|
3060
4148
|
assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
|
|
3061
4149
|
const plaintext = await decryptSecurePayload(result.payload, security);
|
|
3062
4150
|
const isText = (result.flags & TEXT_FLAG) !== 0;
|
|
4151
|
+
const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
4152
|
+
const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
|
|
4153
|
+
const payload = envelope ? envelope.payload : plaintext;
|
|
3063
4154
|
return {
|
|
3064
4155
|
...result,
|
|
3065
4156
|
encryptedPayload: result.payload,
|
|
3066
4157
|
encryptedPayloadBytes: result.payload.length,
|
|
3067
|
-
|
|
3068
|
-
|
|
4158
|
+
protectedPayload: envelope ? plaintext : undefined,
|
|
4159
|
+
payload,
|
|
4160
|
+
text: isText ? getTextDecoder().decode(payload) : null,
|
|
4161
|
+
compressed: envelope?.compressed ?? false,
|
|
4162
|
+
compression: envelope?.compression ?? "none",
|
|
4163
|
+
signed: envelope?.signed ?? result.signed ?? false,
|
|
4164
|
+
signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
|
|
4165
|
+
signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
|
|
4166
|
+
signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
|
|
4167
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
|
|
4168
|
+
signer: envelope?.signer ?? result.signer ?? null,
|
|
3069
4169
|
decrypted: true,
|
|
3070
4170
|
requiresDecryption: false,
|
|
3071
4171
|
security: { ...result.security, decrypted: true }
|
|
@@ -3145,12 +4245,14 @@ function rotate90(matrix) {
|
|
|
3145
4245
|
return out;
|
|
3146
4246
|
}
|
|
3147
4247
|
|
|
3148
|
-
function extractVisibleCells(matrix, layout) {
|
|
4248
|
+
function extractVisibleCells(matrix, layout, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
4249
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
4250
|
+
const maxValue = encoding === CELL_ENCODINGS.TRIANGLE16 ? 15 : 3;
|
|
3149
4251
|
const out = [];
|
|
3150
4252
|
for (const [row, col] of layout.dataPositions) {
|
|
3151
4253
|
const value = matrix[row][col];
|
|
3152
|
-
if (!Number.isInteger(value) || value < 0 || value >
|
|
3153
|
-
throw new Error(
|
|
4254
|
+
if (!Number.isInteger(value) || value < 0 || value > maxValue) {
|
|
4255
|
+
throw new Error(`Data region contains an invalid ${encoding === CELL_ENCODINGS.TRIANGLE16 ? "Triangle16" : "RGBW"} data cell.`);
|
|
3154
4256
|
}
|
|
3155
4257
|
out.push(value);
|
|
3156
4258
|
}
|
|
@@ -3164,6 +4266,34 @@ function unmaskCells(visibleCells, positions, maskId) {
|
|
|
3164
4266
|
});
|
|
3165
4267
|
}
|
|
3166
4268
|
|
|
4269
|
+
function unmaskAndRestoreLogicalCells(
|
|
4270
|
+
visibleCells,
|
|
4271
|
+
positions,
|
|
4272
|
+
maskId,
|
|
4273
|
+
version,
|
|
4274
|
+
spectralInterleaving,
|
|
4275
|
+
cellEncoding = CELL_ENCODINGS.RGBW
|
|
4276
|
+
) {
|
|
4277
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
4278
|
+
const permutation = spectralInterleaving ? spectralPermutation(visibleCells.length, version) : null;
|
|
4279
|
+
const logical = new Array(visibleCells.length);
|
|
4280
|
+
const headerCells = getHeaderPlan(version).codewordCells;
|
|
4281
|
+
|
|
4282
|
+
for (let logicalIndex = 0; logicalIndex < visibleCells.length; logicalIndex++) {
|
|
4283
|
+
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
4284
|
+
const [row, col] = positions[physicalIndex];
|
|
4285
|
+
logical[logicalIndex] = applyCellMask(
|
|
4286
|
+
visibleCells[physicalIndex],
|
|
4287
|
+
row,
|
|
4288
|
+
col,
|
|
4289
|
+
maskId,
|
|
4290
|
+
encoding,
|
|
4291
|
+
encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < headerCells
|
|
4292
|
+
);
|
|
4293
|
+
}
|
|
4294
|
+
return logical;
|
|
4295
|
+
}
|
|
4296
|
+
|
|
3167
4297
|
function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
3168
4298
|
if (!spectralInterleaving) return physicalValues.slice();
|
|
3169
4299
|
const permutation = spectralPermutation(physicalValues.length, version);
|
|
@@ -3174,20 +4304,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
|
3174
4304
|
return logical;
|
|
3175
4305
|
}
|
|
3176
4306
|
|
|
3177
|
-
function cellsToSymbolConfidences(
|
|
4307
|
+
function cellsToSymbolConfidences(
|
|
4308
|
+
confidences,
|
|
4309
|
+
byteCount,
|
|
4310
|
+
cellsPerByte = RGBW_CELLS_PER_BYTE
|
|
4311
|
+
) {
|
|
3178
4312
|
if (!confidences) return null;
|
|
3179
|
-
if (confidences.length < byteCount *
|
|
4313
|
+
if (confidences.length < byteCount * cellsPerByte) return null;
|
|
3180
4314
|
const out = new Array(byteCount);
|
|
3181
4315
|
for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
|
|
3182
|
-
const offset = symbolIndex *
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
confidences[offset + 2] ?? 1,
|
|
3189
|
-
confidences[offset + 3] ?? 1
|
|
3190
|
-
);
|
|
4316
|
+
const offset = symbolIndex * cellsPerByte;
|
|
4317
|
+
let value = 1;
|
|
4318
|
+
for (let cellIndex = 0; cellIndex < cellsPerByte; cellIndex++) {
|
|
4319
|
+
value = Math.min(value, confidences[offset + cellIndex] ?? 1);
|
|
4320
|
+
}
|
|
4321
|
+
out[symbolIndex] = value;
|
|
3191
4322
|
}
|
|
3192
4323
|
return out;
|
|
3193
4324
|
}
|
|
@@ -3224,11 +4355,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
|
|
|
3224
4355
|
}
|
|
3225
4356
|
}
|
|
3226
4357
|
|
|
3227
|
-
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
|
|
4358
|
+
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
3228
4359
|
const plan = getHeaderPlan(version);
|
|
3229
4360
|
const headerCells = rawCells.slice(0, plan.codewordCells);
|
|
3230
|
-
const codeword = Array.from(
|
|
3231
|
-
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
|
|
4361
|
+
const codeword = Array.from(protectedHeaderCellsToBytes(headerCells, plan.codewordBytes, cellEncoding));
|
|
4362
|
+
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes, RGBW_CELLS_PER_BYTE);
|
|
3232
4363
|
const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
|
|
3233
4364
|
const header = new Uint8Array(decoded.data);
|
|
3234
4365
|
const parsed = parseHeader(header, version);
|
|
@@ -3243,16 +4374,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
|
|
|
3243
4374
|
};
|
|
3244
4375
|
}
|
|
3245
4376
|
|
|
3246
|
-
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
|
|
3247
|
-
const plan = getBodyRsPlan(payloadLength, eccLevel, version);
|
|
4377
|
+
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
4378
|
+
const plan = getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding);
|
|
3248
4379
|
const bodyStart = getHeaderPlan(version).codewordCells;
|
|
3249
4380
|
const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
|
|
3250
4381
|
if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
|
|
3251
4382
|
|
|
3252
|
-
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
|
|
4383
|
+
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
|
|
3253
4384
|
const symbolConfidences = cellsToSymbolConfidences(
|
|
3254
4385
|
rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
|
|
3255
|
-
plan.encodedSymbols
|
|
4386
|
+
plan.encodedSymbols,
|
|
4387
|
+
cellsPerByteForEncoding(cellEncoding)
|
|
3256
4388
|
);
|
|
3257
4389
|
const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
|
|
3258
4390
|
const confidenceBlocks = symbolConfidences
|
|
@@ -3295,83 +4427,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
3295
4427
|
if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
3296
4428
|
|
|
3297
4429
|
const layout = createLayout(version);
|
|
3298
|
-
const visible = extractVisibleCells(matrix, layout);
|
|
3299
|
-
const visibleConfidences = confidenceMatrix
|
|
3300
|
-
? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
|
|
3301
|
-
: null;
|
|
3302
4430
|
const errors = [];
|
|
4431
|
+
const hintedEncoding = options.cellEncodingHint != null
|
|
4432
|
+
? normalizeCellEncoding(options.cellEncodingHint)
|
|
4433
|
+
: null;
|
|
4434
|
+
const hasExtendedCells = layout.dataPositions.some(([row, col]) => (matrix[row]?.[col] ?? 0) > 3);
|
|
4435
|
+
const encodingAttempts = hintedEncoding
|
|
4436
|
+
? [hintedEncoding]
|
|
4437
|
+
: hasExtendedCells
|
|
4438
|
+
? [CELL_ENCODINGS.TRIANGLE16, CELL_ENCODINGS.RGBW]
|
|
4439
|
+
: [CELL_ENCODINGS.RGBW, CELL_ENCODINGS.TRIANGLE16];
|
|
4440
|
+
|
|
4441
|
+
for (const cellEncoding of encodingAttempts) {
|
|
4442
|
+
let visible;
|
|
4443
|
+
try {
|
|
4444
|
+
visible = extractVisibleCells(matrix, layout, cellEncoding);
|
|
4445
|
+
} catch (error) {
|
|
4446
|
+
errors.push(`${cellEncoding}: ${error.message}`);
|
|
4447
|
+
continue;
|
|
4448
|
+
}
|
|
3303
4449
|
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
for (let maskId = 0; maskId < 4; maskId++) {
|
|
3308
|
-
try {
|
|
3309
|
-
const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
|
|
3310
|
-
const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
|
|
3311
|
-
const rawConfidences = visibleConfidences
|
|
3312
|
-
? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
|
|
3313
|
-
: null;
|
|
3314
|
-
const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
|
|
3315
|
-
const header = headerDecoded.header;
|
|
3316
|
-
const flags = headerDecoded.flags;
|
|
3317
|
-
const eccLevel = eccFromFlags(flags);
|
|
3318
|
-
const payloadLength = headerDecoded.payloadLength;
|
|
3319
|
-
if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
|
|
3320
|
-
throw new Error("Declared payload exceeds matrix capacity.");
|
|
3321
|
-
}
|
|
4450
|
+
const visibleConfidences = confidenceMatrix
|
|
4451
|
+
? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
|
|
4452
|
+
: null;
|
|
3322
4453
|
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
eccLevel
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
4454
|
+
// New symbols use spectral-spatial interleaving. Legacy order remains a
|
|
4455
|
+
// decode fallback so existing RGBW QuadQR images do not become unreadable.
|
|
4456
|
+
for (const spectralInterleaving of [true, false]) {
|
|
4457
|
+
for (let maskId = 0; maskId < 4; maskId++) {
|
|
4458
|
+
try {
|
|
4459
|
+
const raw = unmaskAndRestoreLogicalCells(
|
|
4460
|
+
visible,
|
|
4461
|
+
layout.dataPositions,
|
|
4462
|
+
maskId,
|
|
4463
|
+
version,
|
|
4464
|
+
spectralInterleaving,
|
|
4465
|
+
cellEncoding
|
|
4466
|
+
);
|
|
4467
|
+
const rawConfidences = visibleConfidences
|
|
4468
|
+
? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
|
|
4469
|
+
: null;
|
|
4470
|
+
const headerDecoded = decodeProtectedHeader(
|
|
4471
|
+
raw,
|
|
4472
|
+
version,
|
|
4473
|
+
rawConfidences,
|
|
4474
|
+
options,
|
|
4475
|
+
cellEncoding
|
|
4476
|
+
);
|
|
4477
|
+
const header = headerDecoded.header;
|
|
4478
|
+
const flags = headerDecoded.flags;
|
|
4479
|
+
const declaredCellEncoding = cellEncodingFromFlags(flags);
|
|
4480
|
+
if (declaredCellEncoding !== cellEncoding) {
|
|
4481
|
+
throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
|
|
4482
|
+
}
|
|
4483
|
+
const eccLevel = eccFromFlags(flags);
|
|
4484
|
+
const payloadLength = headerDecoded.payloadLength;
|
|
4485
|
+
if (streamCellCount(payloadLength, eccLevel, version, cellEncoding) > raw.length) {
|
|
4486
|
+
throw new Error("Declared payload exceeds matrix capacity.");
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4489
|
+
const bodyDecoded = decodeProtectedBody(
|
|
4490
|
+
raw,
|
|
4491
|
+
payloadLength,
|
|
4492
|
+
eccLevel,
|
|
4493
|
+
version,
|
|
4494
|
+
rawConfidences,
|
|
4495
|
+
options,
|
|
4496
|
+
cellEncoding
|
|
4497
|
+
);
|
|
4498
|
+
const body = bodyDecoded.body;
|
|
4499
|
+
const payload = body.slice(0, payloadLength);
|
|
4500
|
+
const expectedCrc = readU32be(body, payloadLength);
|
|
4501
|
+
const actualCrc = crc32(concatBytes(header, payload));
|
|
4502
|
+
if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
|
|
4503
|
+
|
|
4504
|
+
const isText = (flags & TEXT_FLAG) !== 0;
|
|
4505
|
+
const secure = (flags & SECURE_FLAG) !== 0;
|
|
4506
|
+
const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
4507
|
+
const signedFlag = (flags & SIGNED_FLAG) !== 0;
|
|
4508
|
+
const security = secure ? inspectSecureEnvelope(payload) : null;
|
|
4509
|
+
const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
|
|
4510
|
+
const applicationPayload = envelope ? envelope.payload : payload;
|
|
4511
|
+
const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
|
|
4512
|
+
const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
|
|
4513
|
+
return {
|
|
4514
|
+
ok: true,
|
|
4515
|
+
format: "QuadQR",
|
|
4516
|
+
formatVersion: FORMAT_VERSION,
|
|
4517
|
+
version,
|
|
4518
|
+
size,
|
|
4519
|
+
alignmentPatterns: layout.alignments.length,
|
|
4520
|
+
maskId,
|
|
4521
|
+
rotation,
|
|
4522
|
+
flags,
|
|
4523
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
4524
|
+
cellEncoding,
|
|
4525
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
4526
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
4527
|
+
eccLevel,
|
|
4528
|
+
eccParitySymbols: bodyDecoded.plan.paritySymbols,
|
|
4529
|
+
eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
|
|
4530
|
+
correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
|
|
4531
|
+
spectralInterleaving,
|
|
4532
|
+
confidenceAwareEcc: Boolean(rawConfidences),
|
|
4533
|
+
confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
|
|
4534
|
+
erasureSymbols,
|
|
4535
|
+
unknownErrorSymbols,
|
|
4536
|
+
correctedHeaderSymbols: headerDecoded.correctedSymbols,
|
|
4537
|
+
correctedBodySymbols: bodyDecoded.correctedSymbols,
|
|
4538
|
+
correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
|
|
4539
|
+
protectedPayload: envelope ? payload : undefined,
|
|
4540
|
+
payload: secure ? payload : applicationPayload,
|
|
4541
|
+
text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
|
|
4542
|
+
compressed: envelope?.compressed ?? false,
|
|
4543
|
+
compression: envelope?.compression ?? "none",
|
|
4544
|
+
signed: envelope?.signed ?? signedFlag,
|
|
4545
|
+
signatureVerified: envelope?.signatureVerified,
|
|
4546
|
+
signatureTrusted: envelope?.signatureTrusted,
|
|
4547
|
+
signingKeyId: envelope?.keyId ?? null,
|
|
4548
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
|
|
4549
|
+
signer: envelope?.signer ?? null,
|
|
4550
|
+
secure,
|
|
4551
|
+
encrypted: secure,
|
|
4552
|
+
decrypted: false,
|
|
4553
|
+
requiresDecryption: secure,
|
|
4554
|
+
security,
|
|
4555
|
+
crc32: actualCrc >>> 0
|
|
4556
|
+
};
|
|
4557
|
+
} catch (error) {
|
|
4558
|
+
errors.push(`${cellEncoding} ${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
|
|
4559
|
+
}
|
|
3375
4560
|
}
|
|
3376
4561
|
}
|
|
3377
4562
|
}
|
|
@@ -3407,6 +4592,26 @@ function decodeMatrix(inputMatrix, options = {}) {
|
|
|
3407
4592
|
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
3408
4593
|
}
|
|
3409
4594
|
|
|
4595
|
+
/** Decode a matrix and return only its application payload bytes. */
|
|
4596
|
+
function decodeUint8Array(inputMatrix, options = {}) {
|
|
4597
|
+
return decodeMatrix(inputMatrix, options).payload;
|
|
4598
|
+
}
|
|
4599
|
+
|
|
4600
|
+
/** Verify a signed decode result and return the result with verification state attached. */
|
|
4601
|
+
async function verifyDecodedSignature(result, options = {}) {
|
|
4602
|
+
assert(result?.signed, "Decode result is not signed.");
|
|
4603
|
+
const source = result.protectedPayload;
|
|
4604
|
+
assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
|
|
4605
|
+
const verification = await verifyPayloadEnvelopeSignature(source, options);
|
|
4606
|
+
return {
|
|
4607
|
+
...result,
|
|
4608
|
+
signatureVerified: verification.verified,
|
|
4609
|
+
signatureTrusted: verification.trusted,
|
|
4610
|
+
signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
|
|
4611
|
+
signatureTrustSource: verification.trustSource
|
|
4612
|
+
};
|
|
4613
|
+
}
|
|
4614
|
+
|
|
3410
4615
|
function hexToRgb(hex) {
|
|
3411
4616
|
const clean = hex.replace("#", "");
|
|
3412
4617
|
assert(clean.length === 6, `Invalid hex color ${hex}.`);
|
|
@@ -3421,6 +4626,18 @@ function resolvePalette(palette = {}) {
|
|
|
3421
4626
|
return { ...DEFAULT_PALETTE, ...palette };
|
|
3422
4627
|
}
|
|
3423
4628
|
|
|
4629
|
+
function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
|
|
4630
|
+
const value = String(mode).toLowerCase();
|
|
4631
|
+
assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
|
|
4632
|
+
return value;
|
|
4633
|
+
}
|
|
4634
|
+
|
|
4635
|
+
function resolveRenderPalette(options = {}) {
|
|
4636
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
4637
|
+
const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
|
|
4638
|
+
return { ...base, ...(options.palette ?? {}) };
|
|
4639
|
+
}
|
|
4640
|
+
|
|
3424
4641
|
function paletteRgb(palette = {}) {
|
|
3425
4642
|
const p = resolvePalette(palette);
|
|
3426
4643
|
return {
|
|
@@ -3454,6 +4671,79 @@ function cellRgb(cell, palette) {
|
|
|
3454
4671
|
}
|
|
3455
4672
|
}
|
|
3456
4673
|
|
|
4674
|
+
function inferCellEncoding(codeOrMatrix, matrix, layout = null) {
|
|
4675
|
+
if (!Array.isArray(codeOrMatrix) && codeOrMatrix?.cellEncoding) {
|
|
4676
|
+
return normalizeCellEncoding(codeOrMatrix.cellEncoding);
|
|
4677
|
+
}
|
|
4678
|
+
const activeLayout = layout ?? renderLayoutForMatrix(matrix);
|
|
4679
|
+
if (activeLayout) {
|
|
4680
|
+
for (const [row, col] of activeLayout.dataPositions) {
|
|
4681
|
+
if ((matrix[row]?.[col] ?? 0) > 3) return CELL_ENCODINGS.TRIANGLE16;
|
|
4682
|
+
}
|
|
4683
|
+
}
|
|
4684
|
+
return CELL_ENCODINGS.RGBW;
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
function triangleCellColors(cell) {
|
|
4688
|
+
const { first, second } = unpackTriangleCell(cell);
|
|
4689
|
+
return { first, second };
|
|
4690
|
+
}
|
|
4691
|
+
|
|
4692
|
+
function drawTriangleCellCanvas(ctx, x, y, width, height, cell, palette) {
|
|
4693
|
+
const pair = triangleCellColors(cell);
|
|
4694
|
+
if (pair.first === pair.second) {
|
|
4695
|
+
ctx.fillStyle = cellColor(pair.first, palette);
|
|
4696
|
+
ctx.fillRect(x, y, width, height);
|
|
4697
|
+
return;
|
|
4698
|
+
}
|
|
4699
|
+
|
|
4700
|
+
// Fixed "/" split. first = upper-left triangle, second = lower-right.
|
|
4701
|
+
ctx.beginPath();
|
|
4702
|
+
ctx.moveTo(x, y);
|
|
4703
|
+
ctx.lineTo(x + width, y);
|
|
4704
|
+
ctx.lineTo(x, y + height);
|
|
4705
|
+
ctx.closePath();
|
|
4706
|
+
ctx.fillStyle = cellColor(pair.first, palette);
|
|
4707
|
+
ctx.fill();
|
|
4708
|
+
|
|
4709
|
+
ctx.beginPath();
|
|
4710
|
+
ctx.moveTo(x + width, y);
|
|
4711
|
+
ctx.lineTo(x + width, y + height);
|
|
4712
|
+
ctx.lineTo(x, y + height);
|
|
4713
|
+
ctx.closePath();
|
|
4714
|
+
ctx.fillStyle = cellColor(pair.second, palette);
|
|
4715
|
+
ctx.fill();
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
function fillImageTriangleCell(data, imageWidth, x, y, width, height, cell, palette) {
|
|
4719
|
+
const pair = triangleCellColors(cell);
|
|
4720
|
+
const firstRgb = cellRgb(pair.first, palette);
|
|
4721
|
+
const secondRgb = cellRgb(pair.second, palette);
|
|
4722
|
+
for (let yy = 0; yy < height; yy++) {
|
|
4723
|
+
for (let xx = 0; xx < width; xx++) {
|
|
4724
|
+
const normalizedX = (xx + 0.5) / Math.max(1, width);
|
|
4725
|
+
const normalizedY = (yy + 0.5) / Math.max(1, height);
|
|
4726
|
+
const rgb = normalizedX + normalizedY <= 1 ? firstRgb : secondRgb;
|
|
4727
|
+
const p = ((y + yy) * imageWidth + (x + xx)) * 4;
|
|
4728
|
+
data[p] = rgb.r;
|
|
4729
|
+
data[p + 1] = rgb.g;
|
|
4730
|
+
data[p + 2] = rgb.b;
|
|
4731
|
+
data[p + 3] = 255;
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
function svgTriangleCell(x, y, size, cell, palette) {
|
|
4737
|
+
const pair = triangleCellColors(cell);
|
|
4738
|
+
if (pair.first === pair.second) return svgRect(x, y, size, size, cellColor(pair.first, palette));
|
|
4739
|
+
const first = cellColor(pair.first, palette);
|
|
4740
|
+
const second = cellColor(pair.second, palette);
|
|
4741
|
+
return [
|
|
4742
|
+
`<polygon points="${x},${y} ${x + size},${y} ${x},${y + size}" fill="${first}"/>`,
|
|
4743
|
+
`<polygon points="${x + size},${y} ${x + size},${y + size} ${x},${y + size}" fill="${second}"/>`
|
|
4744
|
+
].join("");
|
|
4745
|
+
}
|
|
4746
|
+
|
|
3457
4747
|
function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
|
|
3458
4748
|
const value = String(style).toLowerCase();
|
|
3459
4749
|
const allowed = Object.values(RENDER_STYLES);
|
|
@@ -3584,18 +4874,347 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
|
|
|
3584
4874
|
}
|
|
3585
4875
|
}
|
|
3586
4876
|
|
|
4877
|
+
/** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
|
|
4878
|
+
function estimateSafeLogoSize(codeOrMatrix, options = {}) {
|
|
4879
|
+
const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
|
|
4880
|
+
const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
|
|
4881
|
+
const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
|
|
4882
|
+
const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
|
|
4883
|
+
const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
|
|
4884
|
+
const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
|
|
4885
|
+
const clearPenalty = clearBackground ? 0.90 : 1;
|
|
4886
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
4887
|
+
const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
|
|
4888
|
+
const versionPenalty = code?.version === 1 ? 0.88 : 1;
|
|
4889
|
+
return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
|
|
4890
|
+
}
|
|
4891
|
+
|
|
4892
|
+
/**
|
|
4893
|
+
* Empirically search for the largest logo ratio that still decodes from the
|
|
4894
|
+
* dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
|
|
4895
|
+
*/
|
|
4896
|
+
function findMaxSafeLogoSize(code, options = {}) {
|
|
4897
|
+
assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
|
|
4898
|
+
const logo = options.logo;
|
|
4899
|
+
const source = logo?.source ?? logo;
|
|
4900
|
+
assert(source?.data && source?.width && source?.height,
|
|
4901
|
+
"findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
|
|
4902
|
+
let low = Math.max(0.05, Number(options.minSize ?? 0.05));
|
|
4903
|
+
let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
|
|
4904
|
+
let best = 0;
|
|
4905
|
+
const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
|
|
4906
|
+
for (let i = 0; i < iterations; i++) {
|
|
4907
|
+
const probe = (low + high) / 2;
|
|
4908
|
+
try {
|
|
4909
|
+
const image = renderToImageData(code, {
|
|
4910
|
+
...options,
|
|
4911
|
+
logo: {
|
|
4912
|
+
...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
|
|
4913
|
+
size: probe
|
|
4914
|
+
}
|
|
4915
|
+
});
|
|
4916
|
+
const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
|
|
4917
|
+
if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
|
|
4918
|
+
best = probe;
|
|
4919
|
+
low = probe;
|
|
4920
|
+
} catch {
|
|
4921
|
+
high = probe;
|
|
4922
|
+
}
|
|
4923
|
+
}
|
|
4924
|
+
return {
|
|
4925
|
+
safeSize: best || estimateSafeLogoSize(code, options),
|
|
4926
|
+
testedMax: Number(options.maxSize ?? 0.28),
|
|
4927
|
+
iterations,
|
|
4928
|
+
empirical: best > 0
|
|
4929
|
+
};
|
|
4930
|
+
}
|
|
4931
|
+
|
|
4932
|
+
function getPrintGuidance(codeOrMatrix, options = {}) {
|
|
4933
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
|
|
4934
|
+
assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
|
|
4935
|
+
const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
|
|
4936
|
+
const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
|
|
4937
|
+
const totalModules = matrix.length + quietZone * 2;
|
|
4938
|
+
const moduleSizeMm = physicalSizeMm / totalModules;
|
|
4939
|
+
const dpi = Number(options.dpi ?? 300);
|
|
4940
|
+
const modulePixels = moduleSizeMm / 25.4 * dpi;
|
|
4941
|
+
const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
|
|
4942
|
+
return {
|
|
4943
|
+
mode: "print",
|
|
4944
|
+
physicalSizeMm,
|
|
4945
|
+
quietZone,
|
|
4946
|
+
moduleSizeMm,
|
|
4947
|
+
modulePixelsAtDpi: modulePixels,
|
|
4948
|
+
dpi,
|
|
4949
|
+
recommendedMinimumModuleMm,
|
|
4950
|
+
meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
|
|
4951
|
+
recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
|
|
4952
|
+
palette: PRINT_PALETTE,
|
|
4953
|
+
recommendations: [
|
|
4954
|
+
"Use classic rendering for print unless a styled output has been physically validated.",
|
|
4955
|
+
"Keep at least a 4-module quiet zone.",
|
|
4956
|
+
`Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
|
|
4957
|
+
"Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
|
|
4958
|
+
]
|
|
4959
|
+
};
|
|
4960
|
+
}
|
|
4961
|
+
|
|
4962
|
+
function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
|
|
4963
|
+
if (!options.logo) return null;
|
|
4964
|
+
|
|
4965
|
+
const nested = (
|
|
4966
|
+
options.logo &&
|
|
4967
|
+
typeof options.logo === "object" &&
|
|
4968
|
+
Object.prototype.hasOwnProperty.call(options.logo, "source")
|
|
4969
|
+
) ? options.logo : null;
|
|
4970
|
+
const source = nested ? nested.source : options.logo;
|
|
4971
|
+
if (!source) return null;
|
|
4972
|
+
|
|
4973
|
+
const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
|
|
4974
|
+
const autoSize = String(requestedSize).toLowerCase() === "auto";
|
|
4975
|
+
const rawSize = Number(requestedSize);
|
|
4976
|
+
const sizeRatio = autoSize
|
|
4977
|
+
? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
|
|
4978
|
+
: Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
|
|
4979
|
+
const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
|
|
4980
|
+
const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
|
|
4981
|
+
const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
|
|
4982
|
+
const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
|
|
4983
|
+
const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
|
|
4984
|
+
const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
|
|
4985
|
+
|
|
4986
|
+
const codePixels = matrixSize * moduleSize;
|
|
4987
|
+
const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
|
|
4988
|
+
const logoPixels = Math.max(1, codePixels * sizeRatio);
|
|
4989
|
+
const center = canvasPixels / 2;
|
|
4990
|
+
const paddingPixels = paddingModules * moduleSize;
|
|
4991
|
+
const backgroundPixels = logoPixels + paddingPixels * 2;
|
|
4992
|
+
|
|
4993
|
+
return {
|
|
4994
|
+
source,
|
|
4995
|
+
sizeRatio,
|
|
4996
|
+
clearBackground,
|
|
4997
|
+
backgroundColor,
|
|
4998
|
+
logoX: center - logoPixels / 2,
|
|
4999
|
+
logoY: center - logoPixels / 2,
|
|
5000
|
+
logoSize: logoPixels,
|
|
5001
|
+
backgroundX: center - backgroundPixels / 2,
|
|
5002
|
+
backgroundY: center - backgroundPixels / 2,
|
|
5003
|
+
backgroundSize: backgroundPixels,
|
|
5004
|
+
backgroundRadius: radiusModules * moduleSize
|
|
5005
|
+
};
|
|
5006
|
+
}
|
|
5007
|
+
|
|
5008
|
+
function canvasLogoSource(source) {
|
|
5009
|
+
if (!source || typeof source === "string") return source;
|
|
5010
|
+
if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
|
|
5011
|
+
let scratch = null;
|
|
5012
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
5013
|
+
scratch = new OffscreenCanvas(source.width, source.height);
|
|
5014
|
+
} else if (typeof document !== "undefined") {
|
|
5015
|
+
scratch = document.createElement("canvas");
|
|
5016
|
+
scratch.width = source.width;
|
|
5017
|
+
scratch.height = source.height;
|
|
5018
|
+
}
|
|
5019
|
+
if (!scratch) return source;
|
|
5020
|
+
const ctx = scratch.getContext("2d");
|
|
5021
|
+
if (!ctx) return source;
|
|
5022
|
+
const pixels = source.data instanceof Uint8ClampedArray
|
|
5023
|
+
? source.data
|
|
5024
|
+
: new Uint8ClampedArray(source.data);
|
|
5025
|
+
ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
|
|
5026
|
+
return scratch;
|
|
5027
|
+
}
|
|
5028
|
+
return source;
|
|
5029
|
+
}
|
|
5030
|
+
|
|
5031
|
+
function logoSourceDimensions(source) {
|
|
5032
|
+
const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
|
|
5033
|
+
const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
|
|
5034
|
+
if (!(Number(width) > 0 && Number(height) > 0)) return null;
|
|
5035
|
+
return { width: Number(width), height: Number(height) };
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
function containRect(x, y, size, dimensions) {
|
|
5039
|
+
if (!dimensions) return { x, y, width: size, height: size };
|
|
5040
|
+
const scale = Math.min(size / dimensions.width, size / dimensions.height);
|
|
5041
|
+
const width = dimensions.width * scale;
|
|
5042
|
+
const height = dimensions.height * scale;
|
|
5043
|
+
return {
|
|
5044
|
+
x: x + (size - width) / 2,
|
|
5045
|
+
y: y + (size - height) / 2,
|
|
5046
|
+
width,
|
|
5047
|
+
height
|
|
5048
|
+
};
|
|
5049
|
+
}
|
|
5050
|
+
|
|
5051
|
+
function drawLogoToCanvas(ctx, logo, palette) {
|
|
5052
|
+
if (!logo) return;
|
|
5053
|
+
if (logo.clearBackground) {
|
|
5054
|
+
ctx.fillStyle = logo.backgroundColor || palette.white;
|
|
5055
|
+
roundedRectPath(
|
|
5056
|
+
ctx,
|
|
5057
|
+
logo.backgroundX,
|
|
5058
|
+
logo.backgroundY,
|
|
5059
|
+
logo.backgroundSize,
|
|
5060
|
+
logo.backgroundSize,
|
|
5061
|
+
logo.backgroundRadius
|
|
5062
|
+
);
|
|
5063
|
+
ctx.fill();
|
|
5064
|
+
}
|
|
5065
|
+
|
|
5066
|
+
const source = canvasLogoSource(logo.source);
|
|
5067
|
+
assert(
|
|
5068
|
+
source && typeof source !== "string",
|
|
5069
|
+
"renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
|
|
5070
|
+
);
|
|
5071
|
+
const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
|
|
5072
|
+
const previousSmoothing = ctx.imageSmoothingEnabled;
|
|
5073
|
+
ctx.imageSmoothingEnabled = true;
|
|
5074
|
+
ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
|
|
5075
|
+
ctx.imageSmoothingEnabled = previousSmoothing;
|
|
5076
|
+
}
|
|
5077
|
+
|
|
5078
|
+
function blendImagePixel(data, width, x, y, rgb, alpha) {
|
|
5079
|
+
if (x < 0 || y < 0 || x >= width) return;
|
|
5080
|
+
const height = data.length / 4 / width;
|
|
5081
|
+
if (y >= height) return;
|
|
5082
|
+
const p = (y * width + x) * 4;
|
|
5083
|
+
const a = Math.max(0, Math.min(1, alpha));
|
|
5084
|
+
data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
|
|
5085
|
+
data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
|
|
5086
|
+
data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
|
|
5087
|
+
data[p + 3] = 255;
|
|
5088
|
+
}
|
|
5089
|
+
|
|
5090
|
+
function drawLogoToImageData(data, width, logo) {
|
|
5091
|
+
if (!logo) return;
|
|
5092
|
+
if (logo.clearBackground) {
|
|
5093
|
+
const rgb = hexToRgb(logo.backgroundColor);
|
|
5094
|
+
fillImageRoundedRect(
|
|
5095
|
+
data,
|
|
5096
|
+
width,
|
|
5097
|
+
Math.round(logo.backgroundX),
|
|
5098
|
+
Math.round(logo.backgroundY),
|
|
5099
|
+
Math.round(logo.backgroundSize),
|
|
5100
|
+
Math.round(logo.backgroundSize),
|
|
5101
|
+
Math.round(logo.backgroundRadius),
|
|
5102
|
+
rgb
|
|
5103
|
+
);
|
|
5104
|
+
}
|
|
5105
|
+
|
|
5106
|
+
const source = logo.source;
|
|
5107
|
+
assert(
|
|
5108
|
+
source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
|
|
5109
|
+
"renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
|
|
5110
|
+
);
|
|
5111
|
+
const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
|
|
5112
|
+
width: source.width,
|
|
5113
|
+
height: source.height
|
|
5114
|
+
});
|
|
5115
|
+
const x0 = Math.round(dest.x);
|
|
5116
|
+
const y0 = Math.round(dest.y);
|
|
5117
|
+
const destWidth = Math.max(1, Math.round(dest.width));
|
|
5118
|
+
const destHeight = Math.max(1, Math.round(dest.height));
|
|
5119
|
+
const src = source.data;
|
|
5120
|
+
|
|
5121
|
+
for (let y = 0; y < destHeight; y++) {
|
|
5122
|
+
const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
|
|
5123
|
+
for (let x = 0; x < destWidth; x++) {
|
|
5124
|
+
const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
|
|
5125
|
+
const p = (sy * source.width + sx) * 4;
|
|
5126
|
+
const alpha = (src[p + 3] ?? 255) / 255;
|
|
5127
|
+
if (alpha <= 0) continue;
|
|
5128
|
+
blendImagePixel(data, width, x0 + x, y0 + y, {
|
|
5129
|
+
r: src[p],
|
|
5130
|
+
g: src[p + 1],
|
|
5131
|
+
b: src[p + 2]
|
|
5132
|
+
}, alpha);
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
function escapeXmlAttribute(value) {
|
|
5138
|
+
return String(value)
|
|
5139
|
+
.replaceAll("&", "&")
|
|
5140
|
+
.replaceAll('"', """)
|
|
5141
|
+
.replaceAll("<", "<")
|
|
5142
|
+
.replaceAll(">", ">");
|
|
5143
|
+
}
|
|
5144
|
+
|
|
5145
|
+
function svgRect(x, y, width, height, fill, radius = 0) {
|
|
5146
|
+
const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
|
|
5147
|
+
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
|
|
5148
|
+
}
|
|
5149
|
+
|
|
5150
|
+
function svgLogoHref(source) {
|
|
5151
|
+
if (typeof source === "string") return source;
|
|
5152
|
+
if (typeof source?.src === "string" && source.src) return source.src;
|
|
5153
|
+
return null;
|
|
5154
|
+
}
|
|
5155
|
+
|
|
5156
|
+
function resolveRenderSizing(options, matrixSize) {
|
|
5157
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
5158
|
+
const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
|
|
5159
|
+
const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
|
|
5160
|
+
? Math.max(4, requestedQuietZone)
|
|
5161
|
+
: requestedQuietZone;
|
|
5162
|
+
const totalModules = matrixSize + quietZone * 2;
|
|
5163
|
+
const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
|
|
5164
|
+
const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
|
|
5165
|
+
|
|
5166
|
+
let pixelSize;
|
|
5167
|
+
if (hasImageSize) {
|
|
5168
|
+
const requested = Number(options.imageSize);
|
|
5169
|
+
assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
|
|
5170
|
+
pixelSize = Math.max(totalModules, Math.round(requested));
|
|
5171
|
+
} else if (hasModuleSize) {
|
|
5172
|
+
const requested = Number(options.moduleSize);
|
|
5173
|
+
assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
|
|
5174
|
+
const moduleSize = Math.max(1, Math.floor(requested));
|
|
5175
|
+
pixelSize = totalModules * moduleSize;
|
|
5176
|
+
} else {
|
|
5177
|
+
// A practical export default that is large enough for screens/social use
|
|
5178
|
+
// while keeping the API independent from the encoded matrix version.
|
|
5179
|
+
pixelSize = Math.max(totalModules, 720);
|
|
5180
|
+
}
|
|
5181
|
+
|
|
5182
|
+
return {
|
|
5183
|
+
quietZone,
|
|
5184
|
+
totalModules,
|
|
5185
|
+
pixelSize,
|
|
5186
|
+
moduleSize: pixelSize / totalModules,
|
|
5187
|
+
mode
|
|
5188
|
+
};
|
|
5189
|
+
}
|
|
5190
|
+
|
|
5191
|
+
function rasterModuleRect(row, col, quietZone, moduleSize) {
|
|
5192
|
+
const x0 = Math.round((col + quietZone) * moduleSize);
|
|
5193
|
+
const y0 = Math.round((row + quietZone) * moduleSize);
|
|
5194
|
+
const x1 = Math.round((col + quietZone + 1) * moduleSize);
|
|
5195
|
+
const y1 = Math.round((row + quietZone + 1) * moduleSize);
|
|
5196
|
+
return {
|
|
5197
|
+
x: x0,
|
|
5198
|
+
y: y0,
|
|
5199
|
+
width: Math.max(1, x1 - x0),
|
|
5200
|
+
height: Math.max(1, y1 - y0)
|
|
5201
|
+
};
|
|
5202
|
+
}
|
|
5203
|
+
|
|
3587
5204
|
function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
3588
5205
|
assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
|
|
3589
5206
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
3590
5207
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
3591
|
-
const
|
|
3592
|
-
const
|
|
3593
|
-
const palette =
|
|
3594
|
-
const paletteValues = paletteRgb(
|
|
3595
|
-
const
|
|
5208
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
5209
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
5210
|
+
const palette = resolveRenderPalette(options);
|
|
5211
|
+
const paletteValues = paletteRgb(palette);
|
|
5212
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
5213
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
3596
5214
|
const layout = renderLayoutForMatrix(matrix);
|
|
5215
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
3597
5216
|
const size = matrix.length;
|
|
3598
|
-
const
|
|
5217
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
3599
5218
|
|
|
3600
5219
|
canvas.width = pixelSize;
|
|
3601
5220
|
canvas.height = pixelSize;
|
|
@@ -3608,12 +5227,21 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
3608
5227
|
for (let c = 0; c < size; c++) {
|
|
3609
5228
|
const cell = matrix[r][c];
|
|
3610
5229
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
3611
|
-
const
|
|
3612
|
-
const y
|
|
5230
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
5231
|
+
const { x, y, width: cellWidth, height: cellHeight } = rect;
|
|
5232
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
5233
|
+
|
|
5234
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
5235
|
+
// Experimental Triangle16 always renders payload cells with exact,
|
|
5236
|
+
// hard-edged geometry. Decorative per-cell styles would contaminate the
|
|
5237
|
+
// two independent color samples and reduce scan reliability.
|
|
5238
|
+
drawTriangleCellCanvas(ctx, x, y, cellWidth, cellHeight, cell, palette);
|
|
5239
|
+
continue;
|
|
5240
|
+
}
|
|
3613
5241
|
|
|
3614
5242
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
3615
5243
|
ctx.fillStyle = cellColor(cell, palette);
|
|
3616
|
-
ctx.fillRect(x, y,
|
|
5244
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
3617
5245
|
continue;
|
|
3618
5246
|
}
|
|
3619
5247
|
|
|
@@ -3623,28 +5251,29 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
3623
5251
|
const opacity = depthOpacity(r, c, cell);
|
|
3624
5252
|
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
3625
5253
|
ctx.fillStyle = rgbCss(base);
|
|
3626
|
-
ctx.fillRect(x, y,
|
|
5254
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
3627
5255
|
|
|
3628
|
-
if (
|
|
3629
|
-
const edge = Math.max(1, Math.floor(
|
|
5256
|
+
if (cellSize >= 6) {
|
|
5257
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
3630
5258
|
const highlight = mixRgb(base, paletteValues.white, 0.17);
|
|
3631
5259
|
const shadow = mixRgb(base, paletteValues.black, 0.12);
|
|
3632
5260
|
ctx.fillStyle = rgbCss(highlight);
|
|
3633
|
-
ctx.fillRect(x, y,
|
|
3634
|
-
ctx.fillRect(x, y, edge,
|
|
5261
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
5262
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
3635
5263
|
ctx.fillStyle = rgbCss(shadow);
|
|
3636
|
-
ctx.fillRect(x, y +
|
|
3637
|
-
ctx.fillRect(x +
|
|
5264
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
5265
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
3638
5266
|
}
|
|
3639
5267
|
continue;
|
|
3640
5268
|
}
|
|
3641
5269
|
|
|
3642
5270
|
if (style === RENDER_STYLES.SOFT) {
|
|
3643
|
-
const inset =
|
|
3644
|
-
const width =
|
|
3645
|
-
const
|
|
5271
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
5272
|
+
const width = cellWidth - inset * 2;
|
|
5273
|
+
const height = cellHeight - inset * 2;
|
|
5274
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
3646
5275
|
ctx.fillStyle = cellColor(cell, palette);
|
|
3647
|
-
roundedRectPath(ctx, x + inset, y + inset, width,
|
|
5276
|
+
roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
|
|
3648
5277
|
ctx.fill();
|
|
3649
5278
|
continue;
|
|
3650
5279
|
}
|
|
@@ -3653,37 +5282,41 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
3653
5282
|
// Keep the exact encoded color across the full module, then limit the
|
|
3654
5283
|
// recessed effect to a narrow edge band so center sampling stays intact.
|
|
3655
5284
|
ctx.fillStyle = cellColor(cell, palette);
|
|
3656
|
-
ctx.fillRect(x, y,
|
|
5285
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
3657
5286
|
if (cell === CELL.WHITE) continue;
|
|
3658
5287
|
|
|
3659
|
-
const edge = safeStyleEdge(
|
|
5288
|
+
const edge = safeStyleEdge(cellSize);
|
|
3660
5289
|
if (edge > 0) {
|
|
3661
5290
|
const fx = insetStyleColors(rgb, paletteValues);
|
|
3662
5291
|
ctx.fillStyle = rgbCss(fx.shadow);
|
|
3663
|
-
ctx.fillRect(x, y,
|
|
3664
|
-
ctx.fillRect(x, y, edge,
|
|
5292
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
5293
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
3665
5294
|
ctx.fillStyle = rgbCss(fx.highlight);
|
|
3666
|
-
ctx.fillRect(x, y +
|
|
3667
|
-
ctx.fillRect(x +
|
|
5295
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
5296
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
3668
5297
|
}
|
|
3669
5298
|
}
|
|
3670
5299
|
}
|
|
3671
5300
|
}
|
|
5301
|
+
drawLogoToCanvas(ctx, logo, palette);
|
|
3672
5302
|
return canvas;
|
|
3673
5303
|
}
|
|
3674
5304
|
|
|
3675
5305
|
function renderToImageData(codeOrMatrix, options = {}) {
|
|
3676
5306
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
3677
5307
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
3678
|
-
const
|
|
3679
|
-
const
|
|
3680
|
-
const
|
|
3681
|
-
const
|
|
5308
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
5309
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
5310
|
+
const resolvedPalette = resolveRenderPalette(options);
|
|
5311
|
+
const palette = paletteRgb(resolvedPalette);
|
|
5312
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
5313
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
3682
5314
|
const layout = renderLayoutForMatrix(matrix);
|
|
5315
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
3683
5316
|
const size = matrix.length;
|
|
3684
|
-
const pixelSize = (size + quietZone * 2) * moduleSize;
|
|
3685
5317
|
const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
|
|
3686
5318
|
const white = palette.white;
|
|
5319
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
|
|
3687
5320
|
|
|
3688
5321
|
for (let i = 0; i < pixelSize * pixelSize; i++) {
|
|
3689
5322
|
const p = i * 4;
|
|
@@ -3696,28 +5329,119 @@ function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
3696
5329
|
for (let r = 0; r < size; r++) {
|
|
3697
5330
|
for (let c = 0; c < size; c++) {
|
|
3698
5331
|
const cell = matrix[r][c];
|
|
3699
|
-
const rgb = cellRgb(cell, palette);
|
|
3700
5332
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
3701
|
-
const
|
|
3702
|
-
const x0 =
|
|
5333
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
5334
|
+
const x0 = rect.x;
|
|
5335
|
+
const y0 = rect.y;
|
|
5336
|
+
const cellWidth = rect.width;
|
|
5337
|
+
const cellHeight = rect.height;
|
|
5338
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
5339
|
+
|
|
5340
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
5341
|
+
fillImageTriangleCell(data, pixelSize, x0, y0, cellWidth, cellHeight, cell, palette);
|
|
5342
|
+
continue;
|
|
5343
|
+
}
|
|
5344
|
+
|
|
5345
|
+
const rgb = cellRgb(cell, palette);
|
|
3703
5346
|
|
|
3704
5347
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
3705
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
5348
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
3706
5349
|
continue;
|
|
3707
5350
|
}
|
|
3708
5351
|
|
|
3709
5352
|
if (style === RENDER_STYLES.DEPTH) {
|
|
3710
5353
|
const opacity = depthOpacity(r, c, cell);
|
|
3711
5354
|
const base = mixRgb(white, rgb, opacity);
|
|
3712
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
3713
|
-
if (
|
|
3714
|
-
const edge = Math.max(1, Math.floor(
|
|
5355
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
|
|
5356
|
+
if (cellSize >= 6) {
|
|
5357
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
3715
5358
|
const highlight = mixRgb(base, white, 0.17);
|
|
3716
5359
|
const shadow = mixRgb(base, palette.black, 0.12);
|
|
3717
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
3718
|
-
fillImageRect(data, pixelSize, x0, y0, edge,
|
|
3719
|
-
fillImageRect(data, pixelSize, x0, y0 +
|
|
3720
|
-
fillImageRect(data, pixelSize, x0 +
|
|
5360
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
|
|
5361
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
|
|
5362
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
|
|
5363
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
|
|
5364
|
+
}
|
|
5365
|
+
continue;
|
|
5366
|
+
}
|
|
5367
|
+
|
|
5368
|
+
if (style === RENDER_STYLES.SOFT) {
|
|
5369
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
5370
|
+
const width = cellWidth - inset * 2;
|
|
5371
|
+
const height = cellHeight - inset * 2;
|
|
5372
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
5373
|
+
fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
|
|
5374
|
+
continue;
|
|
5375
|
+
}
|
|
5376
|
+
|
|
5377
|
+
if (style === RENDER_STYLES.INSET) {
|
|
5378
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
5379
|
+
if (cell === CELL.WHITE) continue;
|
|
5380
|
+
const edge = safeStyleEdge(cellSize);
|
|
5381
|
+
if (edge > 0) {
|
|
5382
|
+
const fx = insetStyleColors(rgb, palette);
|
|
5383
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
|
|
5384
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
|
|
5385
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
|
|
5386
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
|
|
5387
|
+
}
|
|
5388
|
+
}
|
|
5389
|
+
}
|
|
5390
|
+
}
|
|
5391
|
+
|
|
5392
|
+
drawLogoToImageData(data, pixelSize, logo);
|
|
5393
|
+
|
|
5394
|
+
return { width: pixelSize, height: pixelSize, data };
|
|
5395
|
+
}
|
|
5396
|
+
|
|
5397
|
+
/** Render a QuadQR code/matrix to a standalone SVG string. */
|
|
5398
|
+
function renderToSVG(codeOrMatrix, options = {}) {
|
|
5399
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
5400
|
+
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
5401
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
5402
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
5403
|
+
const palette = resolveRenderPalette(options);
|
|
5404
|
+
const paletteValues = paletteRgb(palette);
|
|
5405
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
5406
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
5407
|
+
const layout = renderLayoutForMatrix(matrix);
|
|
5408
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
5409
|
+
const size = matrix.length;
|
|
5410
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
5411
|
+
const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
|
|
5412
|
+
|
|
5413
|
+
for (let r = 0; r < size; r++) {
|
|
5414
|
+
for (let c = 0; c < size; c++) {
|
|
5415
|
+
const cell = matrix[r][c];
|
|
5416
|
+
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
5417
|
+
const x = (c + quietZone) * moduleSize;
|
|
5418
|
+
const y = (r + quietZone) * moduleSize;
|
|
5419
|
+
const fill = cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural ? null : cellColor(cell, palette);
|
|
5420
|
+
|
|
5421
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
5422
|
+
body.push(svgTriangleCell(x, y, moduleSize, cell, palette));
|
|
5423
|
+
continue;
|
|
5424
|
+
}
|
|
5425
|
+
|
|
5426
|
+
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
5427
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
5428
|
+
continue;
|
|
5429
|
+
}
|
|
5430
|
+
|
|
5431
|
+
const rgb = cellRgb(cell, paletteValues);
|
|
5432
|
+
|
|
5433
|
+
if (style === RENDER_STYLES.DEPTH) {
|
|
5434
|
+
const opacity = depthOpacity(r, c, cell);
|
|
5435
|
+
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
5436
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
|
|
5437
|
+
if (moduleSize >= 6) {
|
|
5438
|
+
const edge = Math.max(1, Math.floor(moduleSize * 0.08));
|
|
5439
|
+
const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
|
|
5440
|
+
const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
|
|
5441
|
+
body.push(svgRect(x, y, moduleSize, edge, highlight));
|
|
5442
|
+
body.push(svgRect(x, y, edge, moduleSize, highlight));
|
|
5443
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
|
|
5444
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
|
|
3721
5445
|
}
|
|
3722
5446
|
continue;
|
|
3723
5447
|
}
|
|
@@ -3726,26 +5450,51 @@ function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
3726
5450
|
const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
|
|
3727
5451
|
const width = moduleSize - inset * 2;
|
|
3728
5452
|
const radius = Math.max(1, Math.floor(moduleSize * 0.20));
|
|
3729
|
-
|
|
5453
|
+
body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
|
|
3730
5454
|
continue;
|
|
3731
5455
|
}
|
|
3732
5456
|
|
|
3733
5457
|
if (style === RENDER_STYLES.INSET) {
|
|
3734
|
-
|
|
5458
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
3735
5459
|
if (cell === CELL.WHITE) continue;
|
|
3736
5460
|
const edge = safeStyleEdge(moduleSize);
|
|
3737
5461
|
if (edge > 0) {
|
|
3738
|
-
const fx = insetStyleColors(rgb,
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
5462
|
+
const fx = insetStyleColors(rgb, paletteValues);
|
|
5463
|
+
const shadow = rgbCss(fx.shadow);
|
|
5464
|
+
const highlight = rgbCss(fx.highlight);
|
|
5465
|
+
body.push(svgRect(x, y, moduleSize, edge, shadow));
|
|
5466
|
+
body.push(svgRect(x, y, edge, moduleSize, shadow));
|
|
5467
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
|
|
5468
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
|
|
3743
5469
|
}
|
|
3744
5470
|
}
|
|
3745
5471
|
}
|
|
3746
5472
|
}
|
|
3747
5473
|
|
|
3748
|
-
|
|
5474
|
+
if (logo) {
|
|
5475
|
+
if (logo.clearBackground) {
|
|
5476
|
+
body.push(svgRect(
|
|
5477
|
+
logo.backgroundX,
|
|
5478
|
+
logo.backgroundY,
|
|
5479
|
+
logo.backgroundSize,
|
|
5480
|
+
logo.backgroundSize,
|
|
5481
|
+
logo.backgroundColor,
|
|
5482
|
+
logo.backgroundRadius
|
|
5483
|
+
));
|
|
5484
|
+
}
|
|
5485
|
+
const href = svgLogoHref(logo.source);
|
|
5486
|
+
assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
|
|
5487
|
+
body.push(
|
|
5488
|
+
`<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
|
|
5489
|
+
);
|
|
5490
|
+
}
|
|
5491
|
+
|
|
5492
|
+
return [
|
|
5493
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
5494
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
|
|
5495
|
+
...body,
|
|
5496
|
+
"</svg>"
|
|
5497
|
+
].join("\n");
|
|
3749
5498
|
}
|
|
3750
5499
|
|
|
3751
5500
|
function clampNumber(value, min, max) {
|
|
@@ -3898,6 +5647,60 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3898
5647
|
};
|
|
3899
5648
|
}
|
|
3900
5649
|
|
|
5650
|
+
function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layout) {
|
|
5651
|
+
const size = rgbGrid.length;
|
|
5652
|
+
const matrix = make2D(size, CELL.WHITE);
|
|
5653
|
+
const confidence = make2D(size, 1);
|
|
5654
|
+
const dataClassifier = {
|
|
5655
|
+
...classifier,
|
|
5656
|
+
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
5657
|
+
};
|
|
5658
|
+
let distanceSum = 0;
|
|
5659
|
+
let confidenceSum = 0;
|
|
5660
|
+
let minimumConfidence = 1;
|
|
5661
|
+
let lowConfidenceCells = 0;
|
|
5662
|
+
|
|
5663
|
+
for (let r = 0; r < size; r++) {
|
|
5664
|
+
for (let c = 0; c < size; c++) {
|
|
5665
|
+
const isDataCell = layout && !layout.reserved[r][c];
|
|
5666
|
+
if (!isDataCell) {
|
|
5667
|
+
const classified = classifyRgb(rgbGrid[r][c], classifier);
|
|
5668
|
+
matrix[r][c] = classified.cell;
|
|
5669
|
+
confidence[r][c] = classified.confidence;
|
|
5670
|
+
distanceSum += classified.distance;
|
|
5671
|
+
confidenceSum += classified.confidence;
|
|
5672
|
+
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
5673
|
+
if (classified.confidence < 0.4) lowConfidenceCells++;
|
|
5674
|
+
continue;
|
|
5675
|
+
}
|
|
5676
|
+
|
|
5677
|
+
const samples = triangleGrid?.[r]?.[c];
|
|
5678
|
+
if (!samples) throw new Error("Triangle16 sampling grid is incomplete.");
|
|
5679
|
+
const classifyData = (rgb) => classifier.dataMode === "hue"
|
|
5680
|
+
? classifyDataHue(rgb, classifier)
|
|
5681
|
+
: classifyRgb(rgb, dataClassifier);
|
|
5682
|
+
const first = classifyData(samples.first);
|
|
5683
|
+
const second = classifyData(samples.second);
|
|
5684
|
+
const cellConfidence = Math.min(first.confidence, second.confidence);
|
|
5685
|
+
matrix[r][c] = packTriangleCell(first.cell, second.cell);
|
|
5686
|
+
confidence[r][c] = cellConfidence;
|
|
5687
|
+
distanceSum += (first.distance + second.distance) / 2;
|
|
5688
|
+
confidenceSum += cellConfidence;
|
|
5689
|
+
minimumConfidence = Math.min(minimumConfidence, cellConfidence);
|
|
5690
|
+
if (cellConfidence < 0.4) lowConfidenceCells++;
|
|
5691
|
+
}
|
|
5692
|
+
}
|
|
5693
|
+
|
|
5694
|
+
return {
|
|
5695
|
+
matrix,
|
|
5696
|
+
confidence,
|
|
5697
|
+
averageColorDistance: distanceSum / (size * size),
|
|
5698
|
+
averageCellConfidence: confidenceSum / (size * size),
|
|
5699
|
+
minimumCellConfidence: minimumConfidence,
|
|
5700
|
+
lowConfidenceCells
|
|
5701
|
+
};
|
|
5702
|
+
}
|
|
5703
|
+
|
|
3901
5704
|
function structuralAccuracy(matrix, layout) {
|
|
3902
5705
|
let matches = 0;
|
|
3903
5706
|
let total = 0;
|
|
@@ -3937,6 +5740,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3937
5740
|
finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
|
|
3938
5741
|
finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
|
|
3939
5742
|
finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
|
|
5743
|
+
preciseAlignment: options.preciseAlignment,
|
|
3940
5744
|
diagnostics: options._visionDiagnostics,
|
|
3941
5745
|
diagnosticLabel: options._diagnosticLabel ?? "normal"
|
|
3942
5746
|
});
|
|
@@ -3962,9 +5766,18 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3962
5766
|
let bestStructureScore = 0;
|
|
3963
5767
|
for (const profile of sampleProfiles) {
|
|
3964
5768
|
let sampled;
|
|
5769
|
+
let triangleSampled = null;
|
|
3965
5770
|
let observedPalette;
|
|
3966
5771
|
try {
|
|
3967
5772
|
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
|
|
5773
|
+
if (options.highDensitySampling !== false && options.triangle16 !== false) {
|
|
5774
|
+
triangleSampled = samplePerspectiveTriangleMatrix(
|
|
5775
|
+
imageData,
|
|
5776
|
+
geometry.homography,
|
|
5777
|
+
layout.size,
|
|
5778
|
+
profile
|
|
5779
|
+
);
|
|
5780
|
+
}
|
|
3968
5781
|
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
|
|
3969
5782
|
robust: profile.robustCalibration
|
|
3970
5783
|
});
|
|
@@ -4023,11 +5836,67 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
4023
5836
|
}
|
|
4024
5837
|
};
|
|
4025
5838
|
|
|
5839
|
+
const tryTriangleAttempt = (attempt, metadata = {}) => {
|
|
5840
|
+
if (!triangleSampled?.triangleGrid) return false;
|
|
5841
|
+
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
5842
|
+
const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
|
|
5843
|
+
const classified = classifyTriangleSampledRgbGrid(
|
|
5844
|
+
sampled.rgbGrid,
|
|
5845
|
+
triangleSampled.triangleGrid,
|
|
5846
|
+
attempt.classifier,
|
|
5847
|
+
layout
|
|
5848
|
+
);
|
|
5849
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
5850
|
+
bestStructureScore = Math.max(bestStructureScore, structureScore);
|
|
5851
|
+
pushObservation(options, {
|
|
5852
|
+
version: geometry.version,
|
|
5853
|
+
matrix: classified.matrix,
|
|
5854
|
+
confidence: classified.confidence,
|
|
5855
|
+
geometry,
|
|
5856
|
+
observedPalette: activeObservedPalette,
|
|
5857
|
+
samplingMode: activeSamplingMode,
|
|
5858
|
+
colorNormalization: attempt.colorNormalization,
|
|
5859
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
5860
|
+
structureScore,
|
|
5861
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
5862
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
5863
|
+
});
|
|
5864
|
+
|
|
5865
|
+
try {
|
|
5866
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
5867
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
5868
|
+
cellConfidence: classified.confidence,
|
|
5869
|
+
cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
|
|
5870
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
5871
|
+
});
|
|
5872
|
+
if (decoded.version !== geometry.version) return false;
|
|
5873
|
+
results.push({
|
|
5874
|
+
...decoded,
|
|
5875
|
+
perspectiveCorrected: true,
|
|
5876
|
+
colorCalibrated: true,
|
|
5877
|
+
colorNormalization: attempt.colorNormalization,
|
|
5878
|
+
samplingMode: activeSamplingMode,
|
|
5879
|
+
geometry,
|
|
5880
|
+
observedPalette: activeObservedPalette,
|
|
5881
|
+
averageColorDistance: classified.averageColorDistance,
|
|
5882
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
5883
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
5884
|
+
lowConfidenceCells: classified.lowConfidenceCells,
|
|
5885
|
+
rectified: options.includeRectified
|
|
5886
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
5887
|
+
: undefined
|
|
5888
|
+
});
|
|
5889
|
+
return true;
|
|
5890
|
+
} catch {
|
|
5891
|
+
return false;
|
|
5892
|
+
}
|
|
5893
|
+
};
|
|
5894
|
+
|
|
4026
5895
|
// Fast path: preserve the original observed-RGB classifier first, then
|
|
4027
5896
|
// try per-channel white balancing. Most clean frames stop here without
|
|
4028
5897
|
// paying for the more expensive spatial normalization fallback.
|
|
4029
5898
|
for (const attempt of paletteClassifierAttempts(observedPalette)) {
|
|
4030
|
-
if (tryAttempt(attempt, sampled.rgbGrid)) {
|
|
5899
|
+
if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
|
|
4031
5900
|
geometryDecoded = true;
|
|
4032
5901
|
break;
|
|
4033
5902
|
}
|
|
@@ -4154,17 +6023,30 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
4154
6023
|
[step, -step], [step, step], [-step, -step], [-step, step]
|
|
4155
6024
|
];
|
|
4156
6025
|
const refinementCandidates = [];
|
|
6026
|
+
const triangleRefinementCandidates = [];
|
|
4157
6027
|
|
|
4158
6028
|
for (const [offsetX, offsetY] of refinementOffsets) {
|
|
4159
6029
|
let sampled;
|
|
6030
|
+
let triangleSampled = null;
|
|
4160
6031
|
let observedPalette;
|
|
4161
6032
|
try {
|
|
4162
|
-
|
|
6033
|
+
const samplingOptions = {
|
|
4163
6034
|
sampleMode: "cross",
|
|
4164
6035
|
sampleRadius: 0,
|
|
4165
6036
|
sampleOffsetX: offsetX,
|
|
4166
|
-
sampleOffsetY: offsetY
|
|
4167
|
-
|
|
6037
|
+
sampleOffsetY: offsetY,
|
|
6038
|
+
triangleSampleInset: options.highDensitySampleInset ?? options.triangleSampleInset,
|
|
6039
|
+
triangleSampleRadius: options.highDensitySampleRadius ?? options.triangleSampleRadius
|
|
6040
|
+
};
|
|
6041
|
+
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, samplingOptions);
|
|
6042
|
+
if (options.highDensitySampling !== false && options.triangle16 !== false) {
|
|
6043
|
+
triangleSampled = samplePerspectiveTriangleMatrix(
|
|
6044
|
+
imageData,
|
|
6045
|
+
geometry.homography,
|
|
6046
|
+
layout.size,
|
|
6047
|
+
samplingOptions
|
|
6048
|
+
);
|
|
6049
|
+
}
|
|
4168
6050
|
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
|
|
4169
6051
|
} catch {
|
|
4170
6052
|
continue;
|
|
@@ -4194,30 +6076,80 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
4194
6076
|
|
|
4195
6077
|
// Do not spend RS work on a refinement that made the known finder /
|
|
4196
6078
|
// alignment structure worse. This keeps the recovery path bounded.
|
|
4197
|
-
if (structureScore
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
6079
|
+
if (structureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
|
|
6080
|
+
refinementCandidates.push({
|
|
6081
|
+
attempt,
|
|
6082
|
+
classified,
|
|
6083
|
+
observedPalette,
|
|
6084
|
+
offsetX,
|
|
6085
|
+
offsetY,
|
|
6086
|
+
structureScore,
|
|
6087
|
+
cellEncoding: CELL_ENCODINGS.RGBW,
|
|
6088
|
+
samplingMode: "refined-center"
|
|
6089
|
+
});
|
|
6090
|
+
}
|
|
6091
|
+
|
|
6092
|
+
if (triangleSampled?.triangleGrid) {
|
|
6093
|
+
const triangleClassified = classifyTriangleSampledRgbGrid(
|
|
6094
|
+
sampled.rgbGrid,
|
|
6095
|
+
triangleSampled.triangleGrid,
|
|
6096
|
+
attempt.classifier,
|
|
6097
|
+
layout
|
|
6098
|
+
);
|
|
6099
|
+
const triangleStructureScore = structuralAccuracy(triangleClassified.matrix, layout);
|
|
6100
|
+
bestStructureScore = Math.max(bestStructureScore, triangleStructureScore);
|
|
6101
|
+
pushObservation(options, {
|
|
6102
|
+
version: geometry.version,
|
|
6103
|
+
matrix: triangleClassified.matrix,
|
|
6104
|
+
confidence: triangleClassified.confidence,
|
|
6105
|
+
geometry,
|
|
6106
|
+
observedPalette,
|
|
6107
|
+
samplingMode: "refined-triangle16",
|
|
6108
|
+
samplingOffset: { x: offsetX, y: offsetY },
|
|
6109
|
+
colorNormalization: attempt.colorNormalization,
|
|
6110
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
6111
|
+
structureScore: triangleStructureScore,
|
|
6112
|
+
averageCellConfidence: triangleClassified.averageCellConfidence,
|
|
6113
|
+
lowConfidenceCells: triangleClassified.lowConfidenceCells
|
|
6114
|
+
});
|
|
6115
|
+
if (triangleStructureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
|
|
6116
|
+
triangleRefinementCandidates.push({
|
|
6117
|
+
attempt,
|
|
6118
|
+
classified: triangleClassified,
|
|
6119
|
+
observedPalette,
|
|
6120
|
+
offsetX,
|
|
6121
|
+
offsetY,
|
|
6122
|
+
structureScore: triangleStructureScore,
|
|
6123
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
6124
|
+
samplingMode: "refined-triangle16"
|
|
6125
|
+
});
|
|
6126
|
+
}
|
|
6127
|
+
}
|
|
4206
6128
|
}
|
|
4207
6129
|
}
|
|
4208
6130
|
|
|
4209
|
-
|
|
6131
|
+
const sortRefinementCandidates = (items) => items.sort((a, b) =>
|
|
4210
6132
|
(b.structureScore - a.structureScore) ||
|
|
4211
6133
|
(b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
|
|
4212
6134
|
(a.classified.averageColorDistance - b.classified.averageColorDistance)
|
|
4213
6135
|
);
|
|
6136
|
+
sortRefinementCandidates(refinementCandidates);
|
|
6137
|
+
sortRefinementCandidates(triangleRefinementCandidates);
|
|
4214
6138
|
|
|
4215
6139
|
const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
|
|
4216
|
-
|
|
6140
|
+
// Keep separate per-encoding limits. A Triangle16 image can produce very
|
|
6141
|
+
// confident but meaningless centre samples, and those must not crowd out
|
|
6142
|
+
// the dual-region candidates during the final bounded recovery pass.
|
|
6143
|
+
const decodeCandidates = [
|
|
6144
|
+
...refinementCandidates.slice(0, decodeLimit),
|
|
6145
|
+
...triangleRefinementCandidates.slice(0, decodeLimit)
|
|
6146
|
+
];
|
|
6147
|
+
for (const candidate of decodeCandidates) {
|
|
4217
6148
|
try {
|
|
4218
6149
|
const decoded = decodeMatrix(candidate.classified.matrix, {
|
|
4219
6150
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
4220
6151
|
cellConfidence: candidate.classified.confidence,
|
|
6152
|
+
cellEncodingHint: candidate.cellEncoding,
|
|
4221
6153
|
maxErasureConfidence: options.maxErasureConfidence
|
|
4222
6154
|
});
|
|
4223
6155
|
if (decoded.version !== geometry.version) continue;
|
|
@@ -4228,7 +6160,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
4228
6160
|
samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
|
|
4229
6161
|
colorCalibrated: true,
|
|
4230
6162
|
colorNormalization: candidate.attempt.colorNormalization,
|
|
4231
|
-
samplingMode:
|
|
6163
|
+
samplingMode: candidate.samplingMode,
|
|
4232
6164
|
geometry,
|
|
4233
6165
|
observedPalette: candidate.observedPalette,
|
|
4234
6166
|
averageColorDistance: candidate.classified.averageColorDistance,
|
|
@@ -4278,6 +6210,15 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
4278
6210
|
for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
|
|
4279
6211
|
try {
|
|
4280
6212
|
const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
|
|
6213
|
+
const triangleSampled = options.highDensitySampling === false || options.triangle16 === false
|
|
6214
|
+
? null
|
|
6215
|
+
: sampleAxisAlignedTriangleGrid(
|
|
6216
|
+
imageData,
|
|
6217
|
+
bounds,
|
|
6218
|
+
size,
|
|
6219
|
+
Math.min(0.10, Math.max(0.04, radiusProfiles[profileIndex] * 0.48)),
|
|
6220
|
+
options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28
|
|
6221
|
+
);
|
|
4281
6222
|
const layout = createLayout(version);
|
|
4282
6223
|
const classifierAttempts = [];
|
|
4283
6224
|
try {
|
|
@@ -4317,45 +6258,70 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
4317
6258
|
});
|
|
4318
6259
|
|
|
4319
6260
|
for (const attempt of classifierAttempts) {
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
maxErasureConfidence: options.maxErasureConfidence
|
|
6261
|
+
const centerGrid = attempt.rgbGrid ?? sampled.rgbGrid;
|
|
6262
|
+
const samplingMode = profileIndex === 0 ? "axis" : "axis-center";
|
|
6263
|
+
const classifiedAttempts = [{
|
|
6264
|
+
classified: classifySampledRgbGrid(centerGrid, attempt.classifier, layout),
|
|
6265
|
+
cellEncoding: CELL_ENCODINGS.RGBW,
|
|
6266
|
+
samplingMode
|
|
6267
|
+
}];
|
|
6268
|
+
if (triangleSampled?.triangleGrid && attempt.rgbGrid === sampled.rgbGrid) {
|
|
6269
|
+
classifiedAttempts.push({
|
|
6270
|
+
classified: classifyTriangleSampledRgbGrid(
|
|
6271
|
+
centerGrid,
|
|
6272
|
+
triangleSampled.triangleGrid,
|
|
6273
|
+
attempt.classifier,
|
|
6274
|
+
layout
|
|
6275
|
+
),
|
|
6276
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
6277
|
+
samplingMode: `${samplingMode}-triangle16`
|
|
4338
6278
|
});
|
|
4339
|
-
candidates.push({
|
|
4340
|
-
...decoded,
|
|
4341
|
-
bounds,
|
|
4342
|
-
sampledVersion: version,
|
|
4343
|
-
moduleWidth: moduleW,
|
|
4344
|
-
moduleHeight: moduleH,
|
|
4345
|
-
perspectiveCorrected: false,
|
|
4346
|
-
colorCalibrated: attempt.calibrated,
|
|
4347
|
-
colorNormalization: attempt.colorNormalization,
|
|
4348
|
-
samplingMode: profileIndex === 0 ? "axis" : "axis-center",
|
|
4349
|
-
averageColorDistance: classified.averageColorDistance,
|
|
4350
|
-
averageCellConfidence: classified.averageCellConfidence,
|
|
4351
|
-
minimumCellConfidence: classified.minimumCellConfidence,
|
|
4352
|
-
lowConfidenceCells: classified.lowConfidenceCells
|
|
4353
|
-
});
|
|
4354
|
-
accepted = true;
|
|
4355
|
-
break;
|
|
4356
|
-
} catch {
|
|
4357
|
-
// Try next classifier/profile.
|
|
4358
6279
|
}
|
|
6280
|
+
|
|
6281
|
+
for (const classifiedAttempt of classifiedAttempts) {
|
|
6282
|
+
try {
|
|
6283
|
+
const classified = classifiedAttempt.classified;
|
|
6284
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
6285
|
+
pushObservation(options, {
|
|
6286
|
+
version,
|
|
6287
|
+
matrix: classified.matrix,
|
|
6288
|
+
confidence: classified.confidence,
|
|
6289
|
+
bounds,
|
|
6290
|
+
samplingMode: classifiedAttempt.samplingMode,
|
|
6291
|
+
colorNormalization: attempt.colorNormalization,
|
|
6292
|
+
cellEncoding: classifiedAttempt.cellEncoding,
|
|
6293
|
+
structureScore,
|
|
6294
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
6295
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
6296
|
+
});
|
|
6297
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
6298
|
+
structureTolerance: options.structureTolerance ?? 0.12,
|
|
6299
|
+
cellConfidence: classified.confidence,
|
|
6300
|
+
cellEncodingHint: classifiedAttempt.cellEncoding,
|
|
6301
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
6302
|
+
});
|
|
6303
|
+
candidates.push({
|
|
6304
|
+
...decoded,
|
|
6305
|
+
bounds,
|
|
6306
|
+
sampledVersion: version,
|
|
6307
|
+
moduleWidth: moduleW,
|
|
6308
|
+
moduleHeight: moduleH,
|
|
6309
|
+
perspectiveCorrected: false,
|
|
6310
|
+
colorCalibrated: attempt.calibrated,
|
|
6311
|
+
colorNormalization: attempt.colorNormalization,
|
|
6312
|
+
samplingMode: classifiedAttempt.samplingMode,
|
|
6313
|
+
averageColorDistance: classified.averageColorDistance,
|
|
6314
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
6315
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
6316
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
6317
|
+
});
|
|
6318
|
+
accepted = true;
|
|
6319
|
+
break;
|
|
6320
|
+
} catch {
|
|
6321
|
+
// Try the next cell encoding/classifier/profile.
|
|
6322
|
+
}
|
|
6323
|
+
}
|
|
6324
|
+
if (accepted) break;
|
|
4359
6325
|
}
|
|
4360
6326
|
} catch {
|
|
4361
6327
|
// Try a narrower centre sample, then next version.
|
|
@@ -4369,6 +6335,364 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
4369
6335
|
return candidates[0] ?? null;
|
|
4370
6336
|
}
|
|
4371
6337
|
|
|
6338
|
+
const STRESS_PROFILES = Object.freeze([
|
|
6339
|
+
Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
|
|
6340
|
+
Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
|
|
6341
|
+
Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
|
|
6342
|
+
Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
|
|
6343
|
+
Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
|
|
6344
|
+
Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
|
|
6345
|
+
Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
|
|
6346
|
+
Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
|
|
6347
|
+
Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
|
|
6348
|
+
]);
|
|
6349
|
+
|
|
6350
|
+
function cloneImageDataLike(imageData) {
|
|
6351
|
+
return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
|
|
6352
|
+
}
|
|
6353
|
+
|
|
6354
|
+
function sampleRgbaBilinear(imageData, x, y) {
|
|
6355
|
+
const { width, height, data } = imageData;
|
|
6356
|
+
const cx = clampNumber(x, 0, width - 1);
|
|
6357
|
+
const cy = clampNumber(y, 0, height - 1);
|
|
6358
|
+
const x0 = Math.floor(cx), y0 = Math.floor(cy);
|
|
6359
|
+
const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
|
|
6360
|
+
const tx = cx - x0, ty = cy - y0;
|
|
6361
|
+
const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
|
|
6362
|
+
const out = [0, 0, 0, 255];
|
|
6363
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
6364
|
+
const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
|
|
6365
|
+
const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
|
|
6366
|
+
out[ch] = a * (1 - ty) + b * ty;
|
|
6367
|
+
}
|
|
6368
|
+
return out;
|
|
6369
|
+
}
|
|
6370
|
+
|
|
6371
|
+
function resizeImageDataLike(imageData, width, height) {
|
|
6372
|
+
width = Math.max(1, Math.round(width));
|
|
6373
|
+
height = Math.max(1, Math.round(height));
|
|
6374
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
6375
|
+
for (let y = 0; y < height; y++) {
|
|
6376
|
+
for (let x = 0; x < width; x++) {
|
|
6377
|
+
const sx = (x + 0.5) * imageData.width / width - 0.5;
|
|
6378
|
+
const sy = (y + 0.5) * imageData.height / height - 0.5;
|
|
6379
|
+
const rgba = sampleRgbaBilinear(imageData, sx, sy);
|
|
6380
|
+
const p = (y * width + x) * 4;
|
|
6381
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
|
|
6382
|
+
}
|
|
6383
|
+
}
|
|
6384
|
+
return { width, height, data: out };
|
|
6385
|
+
}
|
|
6386
|
+
|
|
6387
|
+
function boxBlurImage(imageData, radius) {
|
|
6388
|
+
radius = Math.max(1, Math.round(radius));
|
|
6389
|
+
const { width, height, data } = imageData;
|
|
6390
|
+
const horizontal = new Float32Array(data.length);
|
|
6391
|
+
const out = new Uint8ClampedArray(data.length);
|
|
6392
|
+
for (let y = 0; y < height; y++) {
|
|
6393
|
+
for (let x = 0; x < width; x++) {
|
|
6394
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
6395
|
+
let sum = 0, count = 0;
|
|
6396
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
6397
|
+
const xx = x + dx;
|
|
6398
|
+
if (xx < 0 || xx >= width) continue;
|
|
6399
|
+
sum += data[(y * width + xx) * 4 + ch]; count++;
|
|
6400
|
+
}
|
|
6401
|
+
horizontal[(y * width + x) * 4 + ch] = sum / count;
|
|
6402
|
+
}
|
|
6403
|
+
horizontal[(y * width + x) * 4 + 3] = 255;
|
|
6404
|
+
}
|
|
6405
|
+
}
|
|
6406
|
+
for (let y = 0; y < height; y++) {
|
|
6407
|
+
for (let x = 0; x < width; x++) {
|
|
6408
|
+
const p = (y * width + x) * 4;
|
|
6409
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
6410
|
+
let sum = 0, count = 0;
|
|
6411
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
6412
|
+
const yy = y + dy;
|
|
6413
|
+
if (yy < 0 || yy >= height) continue;
|
|
6414
|
+
sum += horizontal[(yy * width + x) * 4 + ch]; count++;
|
|
6415
|
+
}
|
|
6416
|
+
out[p + ch] = sum / count;
|
|
6417
|
+
}
|
|
6418
|
+
out[p + 3] = 255;
|
|
6419
|
+
}
|
|
6420
|
+
}
|
|
6421
|
+
return { width, height, data: out };
|
|
6422
|
+
}
|
|
6423
|
+
|
|
6424
|
+
function perspectiveStress(imageData, severity) {
|
|
6425
|
+
const { width, height } = imageData;
|
|
6426
|
+
const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
|
|
6427
|
+
const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
|
|
6428
|
+
const destination = [
|
|
6429
|
+
{ x: inset * 1.15, y: inset * 0.25 },
|
|
6430
|
+
{ x: width - 1 - inset * 0.25, y: inset },
|
|
6431
|
+
{ x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
|
|
6432
|
+
{ x: inset * 0.20, y: height - 1 - inset * 0.95 }
|
|
6433
|
+
];
|
|
6434
|
+
const inverse = computeHomography(source, destination);
|
|
6435
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
6436
|
+
out.fill(255);
|
|
6437
|
+
for (let y = 0; y < height; y++) {
|
|
6438
|
+
for (let x = 0; x < width; x++) {
|
|
6439
|
+
const src = projectPoint(inverse, x, y);
|
|
6440
|
+
if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
|
|
6441
|
+
const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
|
|
6442
|
+
const p = (y * width + x) * 4;
|
|
6443
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
|
|
6444
|
+
}
|
|
6445
|
+
}
|
|
6446
|
+
return { width, height, data: out };
|
|
6447
|
+
}
|
|
6448
|
+
|
|
6449
|
+
/** Apply one deterministic camera/print-style distortion to ImageData. */
|
|
6450
|
+
function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
|
|
6451
|
+
assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
|
|
6452
|
+
const s = clampNumber(Number(severity), 0, 1);
|
|
6453
|
+
if (type === "clean") return cloneImageDataLike(imageData);
|
|
6454
|
+
if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
|
|
6455
|
+
if (type === "perspective") return perspectiveStress(imageData, s);
|
|
6456
|
+
if (type === "downscale") {
|
|
6457
|
+
const scale = Math.max(0.20, 1 - s * 0.72);
|
|
6458
|
+
const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
|
|
6459
|
+
return resizeImageDataLike(small, imageData.width, imageData.height);
|
|
6460
|
+
}
|
|
6461
|
+
|
|
6462
|
+
const out = cloneImageDataLike(imageData);
|
|
6463
|
+
const { width, height, data } = out;
|
|
6464
|
+
const quantStep = 4 + Math.round(s * 30);
|
|
6465
|
+
for (let y = 0; y < height; y++) {
|
|
6466
|
+
for (let x = 0; x < width; x++) {
|
|
6467
|
+
const p = (y * width + x) * 4;
|
|
6468
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
6469
|
+
let value = data[p + ch];
|
|
6470
|
+
if (type === "brightness-low") value *= 1 - s * 0.52;
|
|
6471
|
+
else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
|
|
6472
|
+
else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
|
|
6473
|
+
else if (type === "shadow") {
|
|
6474
|
+
const nx = x / Math.max(1, width - 1);
|
|
6475
|
+
const ny = y / Math.max(1, height - 1);
|
|
6476
|
+
const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
|
|
6477
|
+
value *= 1 - shadow * s * 0.62;
|
|
6478
|
+
} else if (type === "jpeg") {
|
|
6479
|
+
value = Math.round(value / quantStep) * quantStep;
|
|
6480
|
+
const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
|
|
6481
|
+
value += block * s * 3;
|
|
6482
|
+
} else if (type === "warm") {
|
|
6483
|
+
if (ch === 0) value *= 1 + s * 0.12;
|
|
6484
|
+
if (ch === 2) value *= 1 - s * 0.22;
|
|
6485
|
+
}
|
|
6486
|
+
data[p + ch] = clampNumber(Math.round(value), 0, 255);
|
|
6487
|
+
}
|
|
6488
|
+
data[p + 3] = 255;
|
|
6489
|
+
}
|
|
6490
|
+
}
|
|
6491
|
+
return out;
|
|
6492
|
+
}
|
|
6493
|
+
|
|
6494
|
+
function runImageStressTest(imageData, expected = {}, options = {}) {
|
|
6495
|
+
const profiles = options.profiles ?? STRESS_PROFILES;
|
|
6496
|
+
const results = [];
|
|
6497
|
+
let passedWeight = 0;
|
|
6498
|
+
let totalWeight = 0;
|
|
6499
|
+
for (const profile of profiles) {
|
|
6500
|
+
const weight = Number(profile.weight ?? 1);
|
|
6501
|
+
totalWeight += weight;
|
|
6502
|
+
const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
|
|
6503
|
+
const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
6504
|
+
try {
|
|
6505
|
+
const decoded = scanImageData(distorted, {
|
|
6506
|
+
minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
|
|
6507
|
+
maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
|
|
6508
|
+
debug: false
|
|
6509
|
+
});
|
|
6510
|
+
const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
|
|
6511
|
+
if (matches) passedWeight += weight;
|
|
6512
|
+
results.push({
|
|
6513
|
+
id: profile.id,
|
|
6514
|
+
label: profile.label,
|
|
6515
|
+
type: profile.type,
|
|
6516
|
+
severity: profile.severity,
|
|
6517
|
+
passed: matches,
|
|
6518
|
+
confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
|
|
6519
|
+
correctedSymbols: decoded.correctedSymbols ?? 0,
|
|
6520
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
6521
|
+
image: options.includeImages ? distorted : undefined,
|
|
6522
|
+
error: matches ? null : "Decoded payload did not match expected CRC."
|
|
6523
|
+
});
|
|
6524
|
+
} catch (error) {
|
|
6525
|
+
results.push({
|
|
6526
|
+
id: profile.id,
|
|
6527
|
+
label: profile.label,
|
|
6528
|
+
type: profile.type,
|
|
6529
|
+
severity: profile.severity,
|
|
6530
|
+
passed: false,
|
|
6531
|
+
confidence: 0,
|
|
6532
|
+
correctedSymbols: null,
|
|
6533
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
6534
|
+
image: options.includeImages ? distorted : undefined,
|
|
6535
|
+
error: error.message
|
|
6536
|
+
});
|
|
6537
|
+
}
|
|
6538
|
+
}
|
|
6539
|
+
const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
|
|
6540
|
+
const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
|
|
6541
|
+
const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
|
|
6542
|
+
const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
|
|
6543
|
+
return {
|
|
6544
|
+
score,
|
|
6545
|
+
rating,
|
|
6546
|
+
passPercent,
|
|
6547
|
+
passed: results.filter((r) => r.passed).length,
|
|
6548
|
+
total: results.length,
|
|
6549
|
+
averageConfidence,
|
|
6550
|
+
results
|
|
6551
|
+
};
|
|
6552
|
+
}
|
|
6553
|
+
|
|
6554
|
+
/** Render and run the standard scanability torture suite. */
|
|
6555
|
+
function assessScanability(code, renderOptions = {}, options = {}) {
|
|
6556
|
+
assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
|
|
6557
|
+
const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
|
|
6558
|
+
const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
|
|
6559
|
+
const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
|
|
6560
|
+
const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
|
|
6561
|
+
return {
|
|
6562
|
+
...report,
|
|
6563
|
+
version: code.version,
|
|
6564
|
+
eccLevel: code.eccLevel,
|
|
6565
|
+
utilization: code.utilization,
|
|
6566
|
+
testImageSize,
|
|
6567
|
+
autoLogoSize,
|
|
6568
|
+
recommendations: [
|
|
6569
|
+
...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
|
|
6570
|
+
...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
|
|
6571
|
+
...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
|
|
6572
|
+
...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
|
|
6573
|
+
]
|
|
6574
|
+
};
|
|
6575
|
+
}
|
|
6576
|
+
|
|
6577
|
+
function paletteConfidence(observedPalette) {
|
|
6578
|
+
if (!observedPalette) return 0.5;
|
|
6579
|
+
const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
|
|
6580
|
+
if (values.length < 5) return 0.5;
|
|
6581
|
+
let minDistance = Infinity;
|
|
6582
|
+
for (let i = 0; i < values.length; i++) {
|
|
6583
|
+
for (let j = i + 1; j < values.length; j++) {
|
|
6584
|
+
const a = values[i], b = values[j];
|
|
6585
|
+
minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
|
|
6586
|
+
}
|
|
6587
|
+
}
|
|
6588
|
+
return clampNumber((minDistance - 28) / 120, 0, 1);
|
|
6589
|
+
}
|
|
6590
|
+
|
|
6591
|
+
function scannerDiagnostics(result, context = {}, options = {}) {
|
|
6592
|
+
const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
|
|
6593
|
+
const correctableTotal = result && info
|
|
6594
|
+
? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
|
|
6595
|
+
: 0;
|
|
6596
|
+
const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
|
|
6597
|
+
const structureConfidence = clampNumber(
|
|
6598
|
+
result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
|
|
6599
|
+
);
|
|
6600
|
+
const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
|
|
6601
|
+
const geometryConfidence = result
|
|
6602
|
+
? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
|
|
6603
|
+
: (context.geometry?.length ? 0.45 : 0);
|
|
6604
|
+
const calibrationConfidence = result
|
|
6605
|
+
? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
|
|
6606
|
+
: 0;
|
|
6607
|
+
const confidence = result
|
|
6608
|
+
? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
|
|
6609
|
+
: 0;
|
|
6610
|
+
const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
|
|
6611
|
+
version: geometry.version,
|
|
6612
|
+
size: geometry.size,
|
|
6613
|
+
score: geometry.score,
|
|
6614
|
+
estimatedSize: geometry.estimatedSize,
|
|
6615
|
+
alignmentScore: geometry.alignmentScore,
|
|
6616
|
+
alignmentGridScore: geometry.alignmentGridScore,
|
|
6617
|
+
corners: geometry.corners ?? geometry.quad ?? null,
|
|
6618
|
+
finders: geometry.finders ?? null
|
|
6619
|
+
}));
|
|
6620
|
+
const observations = context.observations ?? [];
|
|
6621
|
+
const latest = observations.at(-1) ?? null;
|
|
6622
|
+
const stages = {
|
|
6623
|
+
finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
6624
|
+
geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
6625
|
+
sampling: observations.length > 0 || Boolean(result),
|
|
6626
|
+
colorClassification: observations.length > 0 || Boolean(result),
|
|
6627
|
+
ecc: Boolean(result),
|
|
6628
|
+
crc: Boolean(result),
|
|
6629
|
+
payload: Boolean(result)
|
|
6630
|
+
};
|
|
6631
|
+
let failedStage = null;
|
|
6632
|
+
if (!result) {
|
|
6633
|
+
failedStage = !stages.finderDetection ? "finder-detection"
|
|
6634
|
+
: !stages.sampling ? "perspective-geometry"
|
|
6635
|
+
: "color-classification-or-ecc";
|
|
6636
|
+
}
|
|
6637
|
+
return {
|
|
6638
|
+
confidence,
|
|
6639
|
+
cellConfidence,
|
|
6640
|
+
structureConfidence,
|
|
6641
|
+
geometryConfidence,
|
|
6642
|
+
calibrationConfidence,
|
|
6643
|
+
eccUtilization,
|
|
6644
|
+
correctedErrors: result?.correctedSymbols ?? 0,
|
|
6645
|
+
erasureSymbols: result?.erasureSymbols ?? 0,
|
|
6646
|
+
lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
|
|
6647
|
+
stages,
|
|
6648
|
+
failedStage,
|
|
6649
|
+
geometryCandidates,
|
|
6650
|
+
vision: context.vision ?? null,
|
|
6651
|
+
sampled: latest ? {
|
|
6652
|
+
version: latest.version,
|
|
6653
|
+
samplingMode: latest.samplingMode,
|
|
6654
|
+
colorNormalization: latest.colorNormalization,
|
|
6655
|
+
structureScore: latest.structureScore,
|
|
6656
|
+
averageCellConfidence: latest.averageCellConfidence,
|
|
6657
|
+
observedPalette: latest.observedPalette ?? null,
|
|
6658
|
+
matrix: options.debugMatrices === false ? undefined : latest.matrix,
|
|
6659
|
+
confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
|
|
6660
|
+
} : null
|
|
6661
|
+
};
|
|
6662
|
+
}
|
|
6663
|
+
|
|
6664
|
+
function decorateScanResult(result, context, options) {
|
|
6665
|
+
const diagnostics = scannerDiagnostics(result, context, options);
|
|
6666
|
+
return {
|
|
6667
|
+
...result,
|
|
6668
|
+
confidence: diagnostics.confidence,
|
|
6669
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
6670
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
6671
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
6672
|
+
correctedErrors: result.correctedSymbols ?? 0,
|
|
6673
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
6674
|
+
diagnostics: options.debug ? diagnostics : {
|
|
6675
|
+
confidence: diagnostics.confidence,
|
|
6676
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
6677
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
6678
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
6679
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
6680
|
+
correctedErrors: diagnostics.correctedErrors,
|
|
6681
|
+
erasureSymbols: diagnostics.erasureSymbols
|
|
6682
|
+
}
|
|
6683
|
+
};
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
/** Non-throwing scanner debug API with detailed stage diagnostics. */
|
|
6687
|
+
function debugScanImageData(imageData, options = {}) {
|
|
6688
|
+
try {
|
|
6689
|
+
const result = scanImageData(imageData, { ...options, debug: true });
|
|
6690
|
+
return { ok: true, result, debug: result.diagnostics };
|
|
6691
|
+
} catch (error) {
|
|
6692
|
+
return { ok: false, error: error.message, debug: error.debug ?? null };
|
|
6693
|
+
}
|
|
6694
|
+
}
|
|
6695
|
+
|
|
4372
6696
|
function scanImageData(imageData, options = {}) {
|
|
4373
6697
|
assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
|
|
4374
6698
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
@@ -4377,11 +6701,49 @@ function scanImageData(imageData, options = {}) {
|
|
|
4377
6701
|
validateVersion(maxVersion);
|
|
4378
6702
|
|
|
4379
6703
|
const geometryCollector = [];
|
|
4380
|
-
const
|
|
6704
|
+
const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
|
|
6705
|
+
const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
|
|
6706
|
+
const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
|
|
6707
|
+
const scanOptions = {
|
|
6708
|
+
...options,
|
|
6709
|
+
_geometryCollector: geometryCollector,
|
|
6710
|
+
_observationCollector: observationCollector,
|
|
6711
|
+
_visionDiagnostics: visionDiagnostics
|
|
6712
|
+
};
|
|
4381
6713
|
|
|
4382
6714
|
if (options.perspective !== false) {
|
|
4383
6715
|
const perspective = tryPerspectiveScan(imageData, scanOptions);
|
|
4384
|
-
if (perspective) return perspective;
|
|
6716
|
+
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
6717
|
+
}
|
|
6718
|
+
|
|
6719
|
+
// Triangle16 has half-cell color regions, so geometry that is adequate for
|
|
6720
|
+
// solid RGBW cells can still be off by a few tenths of a module and mix the
|
|
6721
|
+
// two triangle colors. If normal geometry was found but no payload decoded,
|
|
6722
|
+
// retry geometry once with finer alignment localization. This stays off the
|
|
6723
|
+
// clean/normal camera fast path.
|
|
6724
|
+
if (
|
|
6725
|
+
options.perspective !== false &&
|
|
6726
|
+
options.preciseAlignmentRecovery !== false &&
|
|
6727
|
+
!options._preciseAlignmentRecovery &&
|
|
6728
|
+
geometryCollector.length > 0
|
|
6729
|
+
) {
|
|
6730
|
+
try {
|
|
6731
|
+
const precise = tryPerspectiveScan(imageData, {
|
|
6732
|
+
...scanOptions,
|
|
6733
|
+
preciseAlignment: true,
|
|
6734
|
+
_preciseAlignmentRecovery: true,
|
|
6735
|
+
_diagnosticLabel: "precise-alignment"
|
|
6736
|
+
});
|
|
6737
|
+
if (precise) {
|
|
6738
|
+
return decorateScanResult({
|
|
6739
|
+
...precise,
|
|
6740
|
+
geometryRefined: true,
|
|
6741
|
+
recoveryMode: precise.recoveryMode ?? "precise-alignment"
|
|
6742
|
+
}, debugContext, options);
|
|
6743
|
+
}
|
|
6744
|
+
} catch {
|
|
6745
|
+
// Continue to color recovery and axis-aligned fallback.
|
|
6746
|
+
}
|
|
4385
6747
|
}
|
|
4386
6748
|
|
|
4387
6749
|
// If the normal scanner could see QuadQR geometry but could not decode the
|
|
@@ -4408,12 +6770,12 @@ function scanImageData(imageData, options = {}) {
|
|
|
4408
6770
|
_geometryCollector: []
|
|
4409
6771
|
});
|
|
4410
6772
|
if (recovered) {
|
|
4411
|
-
return {
|
|
6773
|
+
return decorateScanResult({
|
|
4412
6774
|
...recovered,
|
|
4413
6775
|
autoEnhanced: true,
|
|
4414
6776
|
recoveryMode: "auto-tone-contrast-color",
|
|
4415
6777
|
originalColorNormalization: recovered.colorNormalization
|
|
4416
|
-
};
|
|
6778
|
+
}, debugContext, options);
|
|
4417
6779
|
}
|
|
4418
6780
|
} catch {
|
|
4419
6781
|
// Continue to the axis-aligned fallback.
|
|
@@ -4422,7 +6784,7 @@ function scanImageData(imageData, options = {}) {
|
|
|
4422
6784
|
|
|
4423
6785
|
if (options.axisAlignedFallback !== false) {
|
|
4424
6786
|
const axis = tryAxisAlignedScan(imageData, scanOptions);
|
|
4425
|
-
if (axis) return axis;
|
|
6787
|
+
if (axis) return decorateScanResult(axis, debugContext, options);
|
|
4426
6788
|
}
|
|
4427
6789
|
|
|
4428
6790
|
// Static images sometimes have such low contrast that locator detection itself
|
|
@@ -4449,20 +6811,22 @@ function scanImageData(imageData, options = {}) {
|
|
|
4449
6811
|
};
|
|
4450
6812
|
if (options.perspective !== false) {
|
|
4451
6813
|
const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
|
|
4452
|
-
if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
|
|
6814
|
+
if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
4453
6815
|
}
|
|
4454
6816
|
if (options.axisAlignedFallback !== false) {
|
|
4455
6817
|
const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
|
|
4456
|
-
if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
|
|
6818
|
+
if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
4457
6819
|
}
|
|
4458
6820
|
} catch {
|
|
4459
6821
|
// Fall through to the normal scan failure below.
|
|
4460
6822
|
}
|
|
4461
6823
|
}
|
|
4462
6824
|
|
|
4463
|
-
|
|
6825
|
+
const error = new Error(
|
|
4464
6826
|
"No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
|
|
4465
6827
|
);
|
|
6828
|
+
if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
|
|
6829
|
+
throw error;
|
|
4466
6830
|
}
|
|
4467
6831
|
|
|
4468
6832
|
async function scanFile(file, options = {}) {
|
|
@@ -4785,6 +7149,7 @@ async function startCameraScanner(video, options = {}) {
|
|
|
4785
7149
|
|
|
4786
7150
|
const scanInterval = Math.max(80, options.scanInterval ?? 180);
|
|
4787
7151
|
const scratchCanvas = document.createElement("canvas");
|
|
7152
|
+
const highResolutionCanvas = document.createElement("canvas");
|
|
4788
7153
|
const multiFrameEnabled = options.multiFrame !== false;
|
|
4789
7154
|
const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
|
|
4790
7155
|
const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
|
|
@@ -4792,6 +7157,12 @@ async function startCameraScanner(video, options = {}) {
|
|
|
4792
7157
|
const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
|
|
4793
7158
|
const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
|
|
4794
7159
|
const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
|
|
7160
|
+
const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
|
|
7161
|
+
const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
|
|
7162
|
+
const cameraHighResolutionMaxDimension = Math.max(
|
|
7163
|
+
baseCameraMaxDimension,
|
|
7164
|
+
Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
|
|
7165
|
+
);
|
|
4795
7166
|
let missStreak = 0;
|
|
4796
7167
|
let stopped = false;
|
|
4797
7168
|
let busy = false;
|
|
@@ -4838,7 +7209,7 @@ async function startCameraScanner(video, options = {}) {
|
|
|
4838
7209
|
|
|
4839
7210
|
const scanNow = () => scanVideoFrame(video, {
|
|
4840
7211
|
...options,
|
|
4841
|
-
maxDimension:
|
|
7212
|
+
maxDimension: baseCameraMaxDimension,
|
|
4842
7213
|
canvas: scratchCanvas
|
|
4843
7214
|
});
|
|
4844
7215
|
|
|
@@ -4905,7 +7276,7 @@ async function startCameraScanner(video, options = {}) {
|
|
|
4905
7276
|
busy = true;
|
|
4906
7277
|
frameNumber++;
|
|
4907
7278
|
const observations = [];
|
|
4908
|
-
const frameDiagnostics =
|
|
7279
|
+
const frameDiagnostics = {};
|
|
4909
7280
|
const capturedFrame = {};
|
|
4910
7281
|
const frameStarted = nowMs();
|
|
4911
7282
|
let allowAutoEnhance = false;
|
|
@@ -4933,7 +7304,7 @@ async function startCameraScanner(video, options = {}) {
|
|
|
4933
7304
|
// If geometry exists, the rectified QR-only pixel enhancer is both
|
|
4934
7305
|
// stronger and much cheaper than reprocessing the whole live frame.
|
|
4935
7306
|
fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
|
|
4936
|
-
maxDimension:
|
|
7307
|
+
maxDimension: baseCameraMaxDimension,
|
|
4937
7308
|
canvas: scratchCanvas,
|
|
4938
7309
|
_capturedFrame: capturedFrame,
|
|
4939
7310
|
_observationCollector: observations,
|
|
@@ -4974,6 +7345,71 @@ async function startCameraScanner(video, options = {}) {
|
|
|
4974
7345
|
...frameDiagnostics
|
|
4975
7346
|
});
|
|
4976
7347
|
|
|
7348
|
+
// Geometry-aware high-resolution retry. A dense QuadQR can look large
|
|
7349
|
+
// enough in the preview while each individual module has become too
|
|
7350
|
+
// small after the normal 1080 px scanner cap. If the fast pass already
|
|
7351
|
+
// sees at least two convincing finders, spend one bounded retry on a
|
|
7352
|
+
// higher-resolution copy of the visible camera ROI. Empty frames and
|
|
7353
|
+
// ordinary small codes never pay this cost.
|
|
7354
|
+
const shouldTryHighResolution = options.cameraHighResolutionRecovery !== false &&
|
|
7355
|
+
cameraHighResolutionMaxDimension > baseCameraMaxDimension &&
|
|
7356
|
+
(frameDiagnostics?.finderCount ?? 0) >= (options.cameraHighResolutionMinFinders ?? 2) &&
|
|
7357
|
+
((missStreak - 1) % cameraHighResolutionEvery === 0);
|
|
7358
|
+
if (shouldTryHighResolution) {
|
|
7359
|
+
const highResolutionObservations = [];
|
|
7360
|
+
const highResolutionDiagnostics = {};
|
|
7361
|
+
const highResolutionCapturedFrame = {};
|
|
7362
|
+
emitDiagnostic({
|
|
7363
|
+
type: "method",
|
|
7364
|
+
state: "trying",
|
|
7365
|
+
method: "high-resolution-geometry-recovery",
|
|
7366
|
+
message: `Dense geometry detected · retrying at up to ${cameraHighResolutionMaxDimension}px`,
|
|
7367
|
+
...frameDiagnostics
|
|
7368
|
+
});
|
|
7369
|
+
try {
|
|
7370
|
+
const recoveryStarted = nowMs();
|
|
7371
|
+
const recovered = scanVideoFrame(video, {
|
|
7372
|
+
...options,
|
|
7373
|
+
_diagnosticLabel: "high-resolution-geometry-recovery",
|
|
7374
|
+
finderRecovery: true,
|
|
7375
|
+
autoEnhanceRecovery: false,
|
|
7376
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
7377
|
+
maxDimension: cameraHighResolutionMaxDimension,
|
|
7378
|
+
canvas: highResolutionCanvas,
|
|
7379
|
+
_capturedFrame: highResolutionCapturedFrame,
|
|
7380
|
+
_observationCollector: highResolutionObservations,
|
|
7381
|
+
_frameDiagnostics: highResolutionDiagnostics
|
|
7382
|
+
});
|
|
7383
|
+
const recoveryElapsedMs = nowMs() - recoveryStarted;
|
|
7384
|
+
emitDiagnostic({
|
|
7385
|
+
type: "success",
|
|
7386
|
+
state: "decoded",
|
|
7387
|
+
method: "high-resolution-geometry-recovery",
|
|
7388
|
+
elapsedMs: recoveryElapsedMs,
|
|
7389
|
+
message: `High-resolution retry decoded v${recovered.version} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
7390
|
+
...(highResolutionDiagnostics ?? frameDiagnostics)
|
|
7391
|
+
});
|
|
7392
|
+
missStreak = 0;
|
|
7393
|
+
observationHistory.clear();
|
|
7394
|
+
if (emitResult({
|
|
7395
|
+
...recovered,
|
|
7396
|
+
cameraHighResolutionRecovery: true,
|
|
7397
|
+
cameraProgressiveRecovery: true,
|
|
7398
|
+
recoveryMode: recovered.recoveryMode ?? "high-resolution-geometry-recovery"
|
|
7399
|
+
}, highResolutionCapturedFrame, highResolutionDiagnostics ?? frameDiagnostics)) return;
|
|
7400
|
+
} catch (highResolutionError) {
|
|
7401
|
+
observations.push(...highResolutionObservations);
|
|
7402
|
+
emitDiagnostic({
|
|
7403
|
+
type: "method",
|
|
7404
|
+
state: "failed",
|
|
7405
|
+
method: "high-resolution-geometry-recovery",
|
|
7406
|
+
message: `High-resolution geometry retry did not decode${highResolutionDiagnostics?.finderCount != null ? ` · ${highResolutionDiagnostics.finderCount} finder(s)` : ""}`,
|
|
7407
|
+
error: highResolutionError?.message ?? String(highResolutionError),
|
|
7408
|
+
...(highResolutionDiagnostics ?? frameDiagnostics)
|
|
7409
|
+
});
|
|
7410
|
+
}
|
|
7411
|
+
}
|
|
7412
|
+
|
|
4977
7413
|
// The user's real phone-camera case is dominated by color cast before
|
|
4978
7414
|
// finder detection: Photoshop Auto Color alone makes the same live QR
|
|
4979
7415
|
// immediately detectable. Retry the exact captured frame with a cheap
|
|
@@ -5278,11 +7714,14 @@ const internals = Object.freeze({
|
|
|
5278
7714
|
HEADER_CODEWORD_CELLS,
|
|
5279
7715
|
COMPACT_HEADER_CODEWORD_CELLS,
|
|
5280
7716
|
CELLS_PER_BYTE,
|
|
7717
|
+
RGBW_CELLS_PER_BYTE,
|
|
7718
|
+
TRIANGLE16_CELLS_PER_BYTE,
|
|
7719
|
+
TRIANGLE16_FLAG,
|
|
5281
7720
|
TEXT_FLAG,
|
|
5282
7721
|
SECURE_FLAG
|
|
5283
7722
|
});
|
|
5284
7723
|
|
|
5285
|
-
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 };
|
|
7724
|
+
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, CELL_ENCODINGS, 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 };
|
|
5286
7725
|
})();
|
|
5287
7726
|
const QuadQR = __qqr_quadqr_js;
|
|
5288
7727
|
|