quadqr-js 1.2.0 → 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 +29 -3
- package/README.md +34 -9
- package/SPECIFICATION.md +8 -3
- package/bin/quadqr.js +4 -2
- package/dist/esm/benchmark.js +34 -9
- package/dist/esm/quadqr.js +878 -226
- package/dist/esm/vision.js +452 -14
- package/dist/quadqr.js +1330 -242
- package/dist/quadqr.min.js +1328 -242
- package/docs/API.md +10 -4
- package/docs/CLI.md +6 -0
- package/docs/HIGH_DENSITY_MODE.md +100 -0
- package/docs/README.md +2 -1
- package/docs/TRIANGLE16.md +108 -0
- package/package.json +4 -2
- package/types/benchmark.d.ts +3 -3
- package/types/index.d.ts +29 -1
package/dist/esm/quadqr.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* QuadQR
|
|
3
3
|
*
|
|
4
|
-
* Experimental
|
|
4
|
+
* Experimental RGBW / Triangle16 matrix code written in pure JavaScript.
|
|
5
5
|
*
|
|
6
6
|
* Core format:
|
|
7
|
-
* - Red / Green / Blue / White data
|
|
8
|
-
* -
|
|
7
|
+
* - RGBW profile: Red / Green / Blue / White, exactly 2 bits per data cell
|
|
8
|
+
* - Triangle16 profile: two fixed RGBW triangle regions, 16 states / 4 bits per body data cell
|
|
9
9
|
* - GF(256) Reed-Solomon ECC over byte symbols
|
|
10
10
|
* - interleaved body ECC blocks
|
|
11
11
|
* - zero-overhead spectral-spatial cell placement
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
computeHomography,
|
|
33
33
|
projectPoint,
|
|
34
34
|
samplePerspectiveMatrix,
|
|
35
|
+
samplePerspectiveTriangleMatrix,
|
|
35
36
|
rectifyImageData,
|
|
36
37
|
sampleObservedPalette,
|
|
37
38
|
spatiallyNormalizeRgbGrid,
|
|
@@ -39,7 +40,8 @@ import {
|
|
|
39
40
|
autoToneContrastColorImageData,
|
|
40
41
|
autoColorImageData,
|
|
41
42
|
findActiveBounds,
|
|
42
|
-
sampleAxisAlignedGrid
|
|
43
|
+
sampleAxisAlignedGrid,
|
|
44
|
+
sampleAxisAlignedTriangleGrid
|
|
43
45
|
} from "./vision.js";
|
|
44
46
|
import {
|
|
45
47
|
decryptSecurePayload,
|
|
@@ -64,6 +66,12 @@ export const RENDER_MODES = Object.freeze({
|
|
|
64
66
|
PRINT: "print"
|
|
65
67
|
});
|
|
66
68
|
|
|
69
|
+
export const CELL_ENCODINGS = Object.freeze({
|
|
70
|
+
RGBW: "rgbw",
|
|
71
|
+
TRIANGLE16: "triangle16"
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
|
|
67
75
|
// Print-safe defaults deliberately use darker, more ink-tolerant primaries and
|
|
68
76
|
// a true white background. Applications can still override individual colors.
|
|
69
77
|
export const PRINT_PALETTE = Object.freeze({
|
|
@@ -121,8 +129,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
|
|
|
121
129
|
const HEADER_BYTES = 10;
|
|
122
130
|
const HEADER_RS_PARITY = 8;
|
|
123
131
|
const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
|
|
124
|
-
const
|
|
125
|
-
const
|
|
132
|
+
const RGBW_CELLS_PER_BYTE = 4;
|
|
133
|
+
const TRIANGLE16_CELLS_PER_BYTE = 2;
|
|
134
|
+
const CELLS_PER_BYTE = RGBW_CELLS_PER_BYTE; // legacy internal alias
|
|
135
|
+
const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
|
|
126
136
|
|
|
127
137
|
// Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
|
|
128
138
|
// cannot afford the normal 18-byte protected header plus a 24-byte M body
|
|
@@ -134,7 +144,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
|
|
|
134
144
|
const COMPACT_HEADER_BYTES = 4;
|
|
135
145
|
const COMPACT_HEADER_RS_PARITY = 4;
|
|
136
146
|
const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
|
|
137
|
-
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES *
|
|
147
|
+
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
|
|
138
148
|
const COMPACT_ECC_LEVELS = Object.freeze({
|
|
139
149
|
L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
|
|
140
150
|
M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
|
|
@@ -147,6 +157,7 @@ const TEXT_FLAG = 1;
|
|
|
147
157
|
const SECURE_FLAG = 1 << 3;
|
|
148
158
|
const EXTENDED_PAYLOAD_FLAG = 1 << 4;
|
|
149
159
|
const SIGNED_FLAG = 1 << 5;
|
|
160
|
+
const TRIANGLE16_FLAG = 1 << 6;
|
|
150
161
|
|
|
151
162
|
// Internal payload extension envelope. This is deliberately not a public
|
|
152
163
|
// payload mode or content-type system. It exists only when compression or
|
|
@@ -209,6 +220,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
|
|
|
209
220
|
return value;
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
function normalizeCellEncoding(value = CELL_ENCODINGS.RGBW) {
|
|
224
|
+
const key = String(value).toLowerCase();
|
|
225
|
+
assert(
|
|
226
|
+
key === CELL_ENCODINGS.RGBW || key === CELL_ENCODINGS.TRIANGLE16,
|
|
227
|
+
`Internal cell encoding must be ${CELL_ENCODINGS.RGBW} or ${CELL_ENCODINGS.TRIANGLE16}.`
|
|
228
|
+
);
|
|
229
|
+
return key;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Public API presents Triangle16 as one experimental High Density Mode toggle.
|
|
233
|
+
// cellEncoding remains an internal/backward-compatible detail so existing
|
|
234
|
+
// experimental branch symbols and renderer metadata continue to work.
|
|
235
|
+
function cellEncodingFromOptions(options = {}) {
|
|
236
|
+
if (typeof options.highDensity === "boolean") {
|
|
237
|
+
return options.highDensity ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
|
|
238
|
+
}
|
|
239
|
+
return normalizeCellEncoding(options.cellEncoding ?? CELL_ENCODINGS.RGBW);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function cellsPerByteForEncoding(cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
243
|
+
return normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16
|
|
244
|
+
? TRIANGLE16_CELLS_PER_BYTE
|
|
245
|
+
: RGBW_CELLS_PER_BYTE;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function packTriangleCell(first, second) {
|
|
249
|
+
assert(first >= 0 && first <= 3 && second >= 0 && second <= 3, "Invalid Triangle16 color.");
|
|
250
|
+
return ((first & 3) << 2) | (second & 3);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function unpackTriangleCell(value) {
|
|
254
|
+
assert(Number.isInteger(value) && value >= 0 && value <= 15, "Invalid Triangle16 data cell.");
|
|
255
|
+
return { first: (value >>> 2) & 3, second: value & 3 };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function solidTriangleCell(color) {
|
|
259
|
+
return packTriangleCell(color, color);
|
|
260
|
+
}
|
|
261
|
+
|
|
212
262
|
function concatBytes(...arrays) {
|
|
213
263
|
const total = arrays.reduce((sum, item) => sum + item.length, 0);
|
|
214
264
|
const out = new Uint8Array(total);
|
|
@@ -636,8 +686,19 @@ export function crc32(bytes) {
|
|
|
636
686
|
return (crc ^ 0xffffffff) >>> 0;
|
|
637
687
|
}
|
|
638
688
|
|
|
639
|
-
function bytesToCells(bytes) {
|
|
640
|
-
const
|
|
689
|
+
function bytesToCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
690
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
691
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
692
|
+
const out = new Array(bytes.length * TRIANGLE16_CELLS_PER_BYTE);
|
|
693
|
+
let cursor = 0;
|
|
694
|
+
for (const byte of bytes) {
|
|
695
|
+
out[cursor++] = (byte >>> 4) & 0x0f;
|
|
696
|
+
out[cursor++] = byte & 0x0f;
|
|
697
|
+
}
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const out = new Array(bytes.length * RGBW_CELLS_PER_BYTE);
|
|
641
702
|
let cursor = 0;
|
|
642
703
|
for (const byte of bytes) {
|
|
643
704
|
out[cursor++] = (byte >>> 6) & 0b11;
|
|
@@ -648,11 +709,36 @@ function bytesToCells(bytes) {
|
|
|
648
709
|
return out;
|
|
649
710
|
}
|
|
650
711
|
|
|
651
|
-
function
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
712
|
+
function bytesToProtectedHeaderCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
713
|
+
const base = bytesToCells(bytes, CELL_ENCODINGS.RGBW);
|
|
714
|
+
if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) return base;
|
|
715
|
+
// Triangle16 deliberately keeps the protected bootstrap/header as solid-color
|
|
716
|
+
// modules. This costs a few cells but makes mode detection and damaged-camera
|
|
717
|
+
// recovery substantially more reliable.
|
|
718
|
+
return base.map(solidTriangleCell);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function cellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
722
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
723
|
+
const cellsPerByte = cellsPerByteForEncoding(encoding);
|
|
724
|
+
const count = byteCount ?? Math.floor(cells.length / cellsPerByte);
|
|
725
|
+
assert(cells.length >= count * cellsPerByte, "Not enough data cells to rebuild bytes.");
|
|
726
|
+
const out = new Uint8Array(count);
|
|
727
|
+
|
|
728
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
729
|
+
for (let i = 0; i < count; i++) {
|
|
730
|
+
const offset = i * TRIANGLE16_CELLS_PER_BYTE;
|
|
731
|
+
const a = cells[offset];
|
|
732
|
+
const b = cells[offset + 1];
|
|
733
|
+
assert(Number.isInteger(a) && a >= 0 && a <= 15, "Invalid Triangle16 data cell.");
|
|
734
|
+
assert(Number.isInteger(b) && b >= 0 && b <= 15, "Invalid Triangle16 data cell.");
|
|
735
|
+
out[i] = (a << 4) | b;
|
|
736
|
+
}
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
for (let i = 0; i < count; i++) {
|
|
741
|
+
const offset = i * RGBW_CELLS_PER_BYTE;
|
|
656
742
|
const a = cells[offset];
|
|
657
743
|
const b = cells[offset + 1];
|
|
658
744
|
const c = cells[offset + 2];
|
|
@@ -665,14 +751,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
|
|
|
665
751
|
return out;
|
|
666
752
|
}
|
|
667
753
|
|
|
668
|
-
function
|
|
754
|
+
function protectedHeaderCellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
755
|
+
if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) {
|
|
756
|
+
return cellsToBytes(cells, byteCount, CELL_ENCODINGS.RGBW);
|
|
757
|
+
}
|
|
758
|
+
const colors = cells.map((value) => {
|
|
759
|
+
const pair = unpackTriangleCell(value);
|
|
760
|
+
if (pair.first !== pair.second) throw new Error("Triangle16 protected header lost solid-color integrity.");
|
|
761
|
+
return pair.first;
|
|
762
|
+
});
|
|
763
|
+
return cellsToBytes(colors, byteCount, CELL_ENCODINGS.RGBW);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function flagsFor(text, eccLevel, secure = false, extended = false, signed = false, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
669
767
|
return (text ? TEXT_FLAG : 0) |
|
|
670
768
|
(secure ? SECURE_FLAG : 0) |
|
|
671
769
|
(extended ? EXTENDED_PAYLOAD_FLAG : 0) |
|
|
672
770
|
(signed ? SIGNED_FLAG : 0) |
|
|
771
|
+
(normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
|
|
673
772
|
((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
|
|
674
773
|
}
|
|
675
774
|
|
|
775
|
+
function cellEncodingFromFlags(flags) {
|
|
776
|
+
return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
|
|
777
|
+
}
|
|
778
|
+
|
|
676
779
|
function eccFromFlags(flags) {
|
|
677
780
|
const id = (flags & ECC_MASK) >> ECC_SHIFT;
|
|
678
781
|
const name = ECC_BY_ID[id];
|
|
@@ -922,7 +1025,8 @@ function maskValue(row, col, maskId) {
|
|
|
922
1025
|
}
|
|
923
1026
|
}
|
|
924
1027
|
|
|
925
|
-
function makePaddingCells(count, seed) {
|
|
1028
|
+
function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1029
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
926
1030
|
let state = (seed >>> 0) || 0x6d2b79f5;
|
|
927
1031
|
const out = new Array(count);
|
|
928
1032
|
for (let i = 0; i < count; i++) {
|
|
@@ -930,11 +1034,27 @@ function makePaddingCells(count, seed) {
|
|
|
930
1034
|
state ^= state >>> 17;
|
|
931
1035
|
state ^= state << 5;
|
|
932
1036
|
state >>>= 0;
|
|
933
|
-
out[i] = state & 3;
|
|
1037
|
+
out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
|
|
934
1038
|
}
|
|
935
1039
|
return out;
|
|
936
1040
|
}
|
|
937
1041
|
|
|
1042
|
+
function triangleMaskValue(row, col, maskId) {
|
|
1043
|
+
const first = maskValue(row, col, maskId);
|
|
1044
|
+
const second = maskValue(row + 1, col + 2, (maskId + 1) & 3);
|
|
1045
|
+
return packTriangleCell(first, second);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function applyCellMask(value, row, col, maskId, cellEncoding, protectedHeader = false) {
|
|
1049
|
+
if (cellEncoding !== CELL_ENCODINGS.TRIANGLE16) return value ^ maskValue(row, col, maskId);
|
|
1050
|
+
if (protectedHeader) {
|
|
1051
|
+
const pair = unpackTriangleCell(value);
|
|
1052
|
+
if (pair.first !== pair.second) throw new Error("Triangle16 protected header must use solid-color cells.");
|
|
1053
|
+
return solidTriangleCell(pair.first ^ maskValue(row, col, maskId));
|
|
1054
|
+
}
|
|
1055
|
+
return value ^ triangleMaskValue(row, col, maskId);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
938
1058
|
const SPECTRAL_PERMUTATION_CACHE = new Map();
|
|
939
1059
|
|
|
940
1060
|
function spectralPermutation(length, version) {
|
|
@@ -963,7 +1083,15 @@ function spectralPermutation(length, version) {
|
|
|
963
1083
|
return permutation;
|
|
964
1084
|
}
|
|
965
1085
|
|
|
966
|
-
function applyData(
|
|
1086
|
+
function applyData(
|
|
1087
|
+
layout,
|
|
1088
|
+
rawCells,
|
|
1089
|
+
maskId,
|
|
1090
|
+
spectralInterleaving = true,
|
|
1091
|
+
cellEncoding = CELL_ENCODINGS.RGBW,
|
|
1092
|
+
protectedHeaderCells = getHeaderPlan(layout.version).codewordCells
|
|
1093
|
+
) {
|
|
1094
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
967
1095
|
const matrix = cloneMatrix(layout.matrix);
|
|
968
1096
|
const permutation = spectralInterleaving
|
|
969
1097
|
? spectralPermutation(layout.dataPositions.length, layout.version)
|
|
@@ -972,16 +1100,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
|
|
|
972
1100
|
for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
|
|
973
1101
|
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
974
1102
|
const [row, col] = layout.dataPositions[physicalIndex];
|
|
975
|
-
matrix[row][col] =
|
|
1103
|
+
matrix[row][col] = applyCellMask(
|
|
1104
|
+
rawCells[logicalIndex],
|
|
1105
|
+
row,
|
|
1106
|
+
col,
|
|
1107
|
+
maskId,
|
|
1108
|
+
encoding,
|
|
1109
|
+
encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < protectedHeaderCells
|
|
1110
|
+
);
|
|
976
1111
|
}
|
|
977
1112
|
return matrix;
|
|
978
1113
|
}
|
|
979
1114
|
|
|
980
|
-
function
|
|
1115
|
+
function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1116
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
981
1117
|
const size = matrix.length;
|
|
982
1118
|
let penalty = 0;
|
|
983
|
-
const
|
|
984
|
-
let
|
|
1119
|
+
const colorCounts = [0, 0, 0, 0];
|
|
1120
|
+
let colorCount = 0;
|
|
1121
|
+
|
|
1122
|
+
const symbolAt = (r, c) => matrix[r][c];
|
|
1123
|
+
const addColors = (value) => {
|
|
1124
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
1125
|
+
const pair = unpackTriangleCell(value);
|
|
1126
|
+
colorCounts[pair.first]++;
|
|
1127
|
+
colorCounts[pair.second]++;
|
|
1128
|
+
colorCount += 2;
|
|
1129
|
+
} else {
|
|
1130
|
+
colorCounts[value]++;
|
|
1131
|
+
colorCount++;
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
985
1134
|
|
|
986
1135
|
for (let r = 0; r < size; r++) {
|
|
987
1136
|
let previous = null;
|
|
@@ -992,9 +1141,8 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
992
1141
|
run = 0;
|
|
993
1142
|
continue;
|
|
994
1143
|
}
|
|
995
|
-
const value =
|
|
996
|
-
|
|
997
|
-
dataCount++;
|
|
1144
|
+
const value = symbolAt(r, c);
|
|
1145
|
+
addColors(value);
|
|
998
1146
|
if (value === previous) {
|
|
999
1147
|
run++;
|
|
1000
1148
|
if (run >= 4) penalty += 2;
|
|
@@ -1014,7 +1162,7 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
1014
1162
|
run = 0;
|
|
1015
1163
|
continue;
|
|
1016
1164
|
}
|
|
1017
|
-
const value =
|
|
1165
|
+
const value = symbolAt(r, c);
|
|
1018
1166
|
if (value === previous) {
|
|
1019
1167
|
run++;
|
|
1020
1168
|
if (run >= 4) penalty += 2;
|
|
@@ -1025,9 +1173,9 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
1025
1173
|
}
|
|
1026
1174
|
}
|
|
1027
1175
|
|
|
1028
|
-
if (
|
|
1029
|
-
const ideal =
|
|
1030
|
-
penalty +=
|
|
1176
|
+
if (colorCount > 0) {
|
|
1177
|
+
const ideal = colorCount / 4;
|
|
1178
|
+
penalty += colorCounts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
|
|
1031
1179
|
}
|
|
1032
1180
|
return penalty;
|
|
1033
1181
|
}
|
|
@@ -1056,7 +1204,7 @@ function deinterleaveBlocks(stream, blockLengths) {
|
|
|
1056
1204
|
return blocks;
|
|
1057
1205
|
}
|
|
1058
1206
|
|
|
1059
|
-
function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
1207
|
+
function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1060
1208
|
const ecc = getEffectiveEcc(version, eccLevel);
|
|
1061
1209
|
const bodyByteCount = payloadLength + CRC_BYTES;
|
|
1062
1210
|
const dataSymbols = bodyByteCount;
|
|
@@ -1078,25 +1226,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
|
1078
1226
|
dataBlockLengths,
|
|
1079
1227
|
codewordBlockLengths,
|
|
1080
1228
|
encodedSymbols,
|
|
1081
|
-
encodedCells: encodedSymbols *
|
|
1229
|
+
encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
|
|
1082
1230
|
};
|
|
1083
1231
|
}
|
|
1084
1232
|
|
|
1085
|
-
function streamCellCount(payloadLength, eccLevel, version) {
|
|
1086
|
-
return getHeaderPlan(version).codewordCells +
|
|
1233
|
+
function streamCellCount(payloadLength, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1234
|
+
return getHeaderPlan(version).codewordCells +
|
|
1235
|
+
getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding).encodedCells;
|
|
1087
1236
|
}
|
|
1088
1237
|
|
|
1089
|
-
function streamFitsLayout(layout, eccLevel, payloadLength, version) {
|
|
1090
|
-
return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
|
|
1238
|
+
function streamFitsLayout(layout, eccLevel, payloadLength, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1239
|
+
return streamCellCount(payloadLength, eccLevel, version, cellEncoding) <= layout.dataPositions.length;
|
|
1091
1240
|
}
|
|
1092
1241
|
|
|
1093
|
-
function getCapacityForLayout(layout, eccLevel, version) {
|
|
1094
|
-
|
|
1242
|
+
function getCapacityForLayout(layout, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1243
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1244
|
+
if (!streamFitsLayout(layout, eccLevel, 0, version, encoding)) return 0;
|
|
1095
1245
|
let low = 0;
|
|
1096
|
-
let high = Math.floor(layout.dataPositions.length /
|
|
1246
|
+
let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
|
|
1097
1247
|
while (low < high) {
|
|
1098
1248
|
const mid = Math.ceil((low + high) / 2);
|
|
1099
|
-
if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
|
|
1249
|
+
if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
|
|
1100
1250
|
else high = mid - 1;
|
|
1101
1251
|
}
|
|
1102
1252
|
return low;
|
|
@@ -1105,6 +1255,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
|
|
|
1105
1255
|
export function getVersionInfo(version, options = {}) {
|
|
1106
1256
|
validateVersion(version);
|
|
1107
1257
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
1258
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
1108
1259
|
const layout = createLayout(version);
|
|
1109
1260
|
const headerPlan = getHeaderPlan(version);
|
|
1110
1261
|
const effectiveEcc = getEffectiveEcc(version, eccLevel);
|
|
@@ -1114,8 +1265,8 @@ export function getVersionInfo(version, options = {}) {
|
|
|
1114
1265
|
eccLevel,
|
|
1115
1266
|
size: layout.size,
|
|
1116
1267
|
dataCells: layout.dataPositions.length,
|
|
1117
|
-
theoreticalBits: layout.dataPositions.length * 2,
|
|
1118
|
-
capacityBytes: getCapacityForLayout(layout, eccLevel, version),
|
|
1268
|
+
theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
|
|
1269
|
+
capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
|
|
1119
1270
|
headerCells: headerPlan.codewordCells,
|
|
1120
1271
|
headerBytes: headerPlan.headerBytes,
|
|
1121
1272
|
headerParitySymbols: headerPlan.paritySymbols,
|
|
@@ -1127,8 +1278,11 @@ export function getVersionInfo(version, options = {}) {
|
|
|
1127
1278
|
hasAlignmentMarker: true,
|
|
1128
1279
|
alignmentPatterns: layout.alignments.length,
|
|
1129
1280
|
alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
|
|
1130
|
-
bitsPerDataCell: 2,
|
|
1281
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
1282
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
1283
|
+
cellEncoding,
|
|
1131
1284
|
colors: 4,
|
|
1285
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
1132
1286
|
spectralInterleaving: true,
|
|
1133
1287
|
confidenceAwareEcc: true
|
|
1134
1288
|
};
|
|
@@ -1139,6 +1293,7 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
1139
1293
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
1140
1294
|
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
1141
1295
|
const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
1296
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
1142
1297
|
|
|
1143
1298
|
validateVersion(minVersion);
|
|
1144
1299
|
validateVersion(maxVersion);
|
|
@@ -1147,10 +1302,10 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
1147
1302
|
if (requested !== "auto") {
|
|
1148
1303
|
validateVersion(requested);
|
|
1149
1304
|
assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
|
|
1150
|
-
const info = getVersionInfo(requested, { ecc });
|
|
1305
|
+
const info = getVersionInfo(requested, { ecc, cellEncoding });
|
|
1151
1306
|
const layout = createLayout(requested);
|
|
1152
1307
|
assert(
|
|
1153
|
-
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
|
|
1308
|
+
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
|
|
1154
1309
|
`Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
|
|
1155
1310
|
);
|
|
1156
1311
|
return requested;
|
|
@@ -1158,20 +1313,20 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
1158
1313
|
|
|
1159
1314
|
for (let version = minVersion; version <= maxVersion; version++) {
|
|
1160
1315
|
const layout = createLayout(version);
|
|
1161
|
-
if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
|
|
1316
|
+
if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
|
|
1162
1317
|
}
|
|
1163
1318
|
throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
|
|
1164
1319
|
}
|
|
1165
1320
|
|
|
1166
|
-
function encodeProtectedHeader(header, version) {
|
|
1321
|
+
function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1167
1322
|
const plan = getHeaderPlan(version);
|
|
1168
1323
|
const codeword = rsEncode(Array.from(header), plan.paritySymbols);
|
|
1169
1324
|
assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
|
|
1170
|
-
return
|
|
1325
|
+
return bytesToProtectedHeaderCells(codeword, cellEncoding);
|
|
1171
1326
|
}
|
|
1172
1327
|
|
|
1173
|
-
function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
1174
|
-
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
|
|
1328
|
+
function encodeProtectedBody(bodyBytes, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1329
|
+
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version, cellEncoding);
|
|
1175
1330
|
assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
|
|
1176
1331
|
|
|
1177
1332
|
const blocks = [];
|
|
@@ -1180,7 +1335,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
|
1180
1335
|
blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
|
|
1181
1336
|
offset += length;
|
|
1182
1337
|
}
|
|
1183
|
-
return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
|
|
1338
|
+
return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
|
|
1184
1339
|
}
|
|
1185
1340
|
|
|
1186
1341
|
function finalizeMatrix(layout, rawCells, meta) {
|
|
@@ -1188,9 +1343,10 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1188
1343
|
let bestMatrix = null;
|
|
1189
1344
|
let bestPenalty = Infinity;
|
|
1190
1345
|
|
|
1346
|
+
const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
|
|
1191
1347
|
for (let maskId = 0; maskId < 4; maskId++) {
|
|
1192
|
-
const candidate = applyData(layout, rawCells, maskId, true);
|
|
1193
|
-
const penalty =
|
|
1348
|
+
const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
|
|
1349
|
+
const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
|
|
1194
1350
|
if (penalty < bestPenalty) {
|
|
1195
1351
|
bestPenalty = penalty;
|
|
1196
1352
|
bestMaskId = maskId;
|
|
@@ -1198,7 +1354,7 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1198
1354
|
}
|
|
1199
1355
|
}
|
|
1200
1356
|
|
|
1201
|
-
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
|
|
1357
|
+
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
|
|
1202
1358
|
return {
|
|
1203
1359
|
format: "QuadQR",
|
|
1204
1360
|
formatVersion: FORMAT_VERSION,
|
|
@@ -1221,7 +1377,10 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1221
1377
|
capacityBytes: info.capacityBytes,
|
|
1222
1378
|
alignmentPatterns: layout.alignments.length,
|
|
1223
1379
|
utilization: meta.meaningfulCells / layout.dataPositions.length,
|
|
1224
|
-
bitsPerDataCell: 2,
|
|
1380
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
1381
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
1382
|
+
cellEncoding,
|
|
1383
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
1225
1384
|
eccLevel: meta.eccLevel,
|
|
1226
1385
|
eccParitySymbols: meta.eccParitySymbols,
|
|
1227
1386
|
eccBlocks: meta.eccBlocks,
|
|
@@ -1242,23 +1401,26 @@ function encodePreparedBytes(input, options = {}) {
|
|
|
1242
1401
|
const secure = Boolean(options.secure);
|
|
1243
1402
|
const extended = Boolean(options.extended);
|
|
1244
1403
|
const signed = Boolean(options.signed);
|
|
1245
|
-
const
|
|
1246
|
-
const
|
|
1404
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
1405
|
+
const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed, cellEncoding);
|
|
1406
|
+
const version = chooseVersion(payload.length, { ...options, ecc: eccLevel, cellEncoding });
|
|
1247
1407
|
const layout = createLayout(version);
|
|
1248
1408
|
const header = makeHeader(payload.length, flags, version);
|
|
1249
1409
|
const crc = crc32(concatBytes(header, payload));
|
|
1250
|
-
const headerCells = encodeProtectedHeader(header, version);
|
|
1251
|
-
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
|
|
1410
|
+
const headerCells = encodeProtectedHeader(header, version, cellEncoding);
|
|
1411
|
+
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version, cellEncoding);
|
|
1252
1412
|
const meaningfulCells = headerCells.concat(bodyEncoded.cells);
|
|
1253
1413
|
assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
|
|
1254
1414
|
|
|
1255
1415
|
const padding = makePaddingCells(
|
|
1256
1416
|
layout.dataPositions.length - meaningfulCells.length,
|
|
1257
|
-
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
|
|
1417
|
+
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
|
|
1418
|
+
cellEncoding
|
|
1258
1419
|
);
|
|
1259
1420
|
|
|
1260
1421
|
return finalizeMatrix(layout, meaningfulCells.concat(padding), {
|
|
1261
1422
|
version,
|
|
1423
|
+
cellEncoding,
|
|
1262
1424
|
payloadBytes: payload.length,
|
|
1263
1425
|
sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
|
|
1264
1426
|
secure,
|
|
@@ -1499,12 +1661,14 @@ function rotate90(matrix) {
|
|
|
1499
1661
|
return out;
|
|
1500
1662
|
}
|
|
1501
1663
|
|
|
1502
|
-
function extractVisibleCells(matrix, layout) {
|
|
1664
|
+
function extractVisibleCells(matrix, layout, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1665
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1666
|
+
const maxValue = encoding === CELL_ENCODINGS.TRIANGLE16 ? 15 : 3;
|
|
1503
1667
|
const out = [];
|
|
1504
1668
|
for (const [row, col] of layout.dataPositions) {
|
|
1505
1669
|
const value = matrix[row][col];
|
|
1506
|
-
if (!Number.isInteger(value) || value < 0 || value >
|
|
1507
|
-
throw new Error(
|
|
1670
|
+
if (!Number.isInteger(value) || value < 0 || value > maxValue) {
|
|
1671
|
+
throw new Error(`Data region contains an invalid ${encoding === CELL_ENCODINGS.TRIANGLE16 ? "Triangle16" : "RGBW"} data cell.`);
|
|
1508
1672
|
}
|
|
1509
1673
|
out.push(value);
|
|
1510
1674
|
}
|
|
@@ -1518,6 +1682,34 @@ function unmaskCells(visibleCells, positions, maskId) {
|
|
|
1518
1682
|
});
|
|
1519
1683
|
}
|
|
1520
1684
|
|
|
1685
|
+
function unmaskAndRestoreLogicalCells(
|
|
1686
|
+
visibleCells,
|
|
1687
|
+
positions,
|
|
1688
|
+
maskId,
|
|
1689
|
+
version,
|
|
1690
|
+
spectralInterleaving,
|
|
1691
|
+
cellEncoding = CELL_ENCODINGS.RGBW
|
|
1692
|
+
) {
|
|
1693
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1694
|
+
const permutation = spectralInterleaving ? spectralPermutation(visibleCells.length, version) : null;
|
|
1695
|
+
const logical = new Array(visibleCells.length);
|
|
1696
|
+
const headerCells = getHeaderPlan(version).codewordCells;
|
|
1697
|
+
|
|
1698
|
+
for (let logicalIndex = 0; logicalIndex < visibleCells.length; logicalIndex++) {
|
|
1699
|
+
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
1700
|
+
const [row, col] = positions[physicalIndex];
|
|
1701
|
+
logical[logicalIndex] = applyCellMask(
|
|
1702
|
+
visibleCells[physicalIndex],
|
|
1703
|
+
row,
|
|
1704
|
+
col,
|
|
1705
|
+
maskId,
|
|
1706
|
+
encoding,
|
|
1707
|
+
encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < headerCells
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
return logical;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1521
1713
|
function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
1522
1714
|
if (!spectralInterleaving) return physicalValues.slice();
|
|
1523
1715
|
const permutation = spectralPermutation(physicalValues.length, version);
|
|
@@ -1528,20 +1720,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
|
1528
1720
|
return logical;
|
|
1529
1721
|
}
|
|
1530
1722
|
|
|
1531
|
-
function cellsToSymbolConfidences(
|
|
1723
|
+
function cellsToSymbolConfidences(
|
|
1724
|
+
confidences,
|
|
1725
|
+
byteCount,
|
|
1726
|
+
cellsPerByte = RGBW_CELLS_PER_BYTE
|
|
1727
|
+
) {
|
|
1532
1728
|
if (!confidences) return null;
|
|
1533
|
-
if (confidences.length < byteCount *
|
|
1729
|
+
if (confidences.length < byteCount * cellsPerByte) return null;
|
|
1534
1730
|
const out = new Array(byteCount);
|
|
1535
1731
|
for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
|
|
1536
|
-
const offset = symbolIndex *
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
confidences[offset + 2] ?? 1,
|
|
1543
|
-
confidences[offset + 3] ?? 1
|
|
1544
|
-
);
|
|
1732
|
+
const offset = symbolIndex * cellsPerByte;
|
|
1733
|
+
let value = 1;
|
|
1734
|
+
for (let cellIndex = 0; cellIndex < cellsPerByte; cellIndex++) {
|
|
1735
|
+
value = Math.min(value, confidences[offset + cellIndex] ?? 1);
|
|
1736
|
+
}
|
|
1737
|
+
out[symbolIndex] = value;
|
|
1545
1738
|
}
|
|
1546
1739
|
return out;
|
|
1547
1740
|
}
|
|
@@ -1578,11 +1771,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
|
|
|
1578
1771
|
}
|
|
1579
1772
|
}
|
|
1580
1773
|
|
|
1581
|
-
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
|
|
1774
|
+
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1582
1775
|
const plan = getHeaderPlan(version);
|
|
1583
1776
|
const headerCells = rawCells.slice(0, plan.codewordCells);
|
|
1584
|
-
const codeword = Array.from(
|
|
1585
|
-
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
|
|
1777
|
+
const codeword = Array.from(protectedHeaderCellsToBytes(headerCells, plan.codewordBytes, cellEncoding));
|
|
1778
|
+
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes, RGBW_CELLS_PER_BYTE);
|
|
1586
1779
|
const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
|
|
1587
1780
|
const header = new Uint8Array(decoded.data);
|
|
1588
1781
|
const parsed = parseHeader(header, version);
|
|
@@ -1597,16 +1790,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
|
|
|
1597
1790
|
};
|
|
1598
1791
|
}
|
|
1599
1792
|
|
|
1600
|
-
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
|
|
1601
|
-
const plan = getBodyRsPlan(payloadLength, eccLevel, version);
|
|
1793
|
+
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1794
|
+
const plan = getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding);
|
|
1602
1795
|
const bodyStart = getHeaderPlan(version).codewordCells;
|
|
1603
1796
|
const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
|
|
1604
1797
|
if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
|
|
1605
1798
|
|
|
1606
|
-
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
|
|
1799
|
+
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
|
|
1607
1800
|
const symbolConfidences = cellsToSymbolConfidences(
|
|
1608
1801
|
rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
|
|
1609
|
-
plan.encodedSymbols
|
|
1802
|
+
plan.encodedSymbols,
|
|
1803
|
+
cellsPerByteForEncoding(cellEncoding)
|
|
1610
1804
|
);
|
|
1611
1805
|
const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
|
|
1612
1806
|
const confidenceBlocks = symbolConfidences
|
|
@@ -1649,96 +1843,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1649
1843
|
if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
1650
1844
|
|
|
1651
1845
|
const layout = createLayout(version);
|
|
1652
|
-
const visible = extractVisibleCells(matrix, layout);
|
|
1653
|
-
const visibleConfidences = confidenceMatrix
|
|
1654
|
-
? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
|
|
1655
|
-
: null;
|
|
1656
1846
|
const errors = [];
|
|
1847
|
+
const hintedEncoding = options.cellEncodingHint != null
|
|
1848
|
+
? normalizeCellEncoding(options.cellEncodingHint)
|
|
1849
|
+
: null;
|
|
1850
|
+
const hasExtendedCells = layout.dataPositions.some(([row, col]) => (matrix[row]?.[col] ?? 0) > 3);
|
|
1851
|
+
const encodingAttempts = hintedEncoding
|
|
1852
|
+
? [hintedEncoding]
|
|
1853
|
+
: hasExtendedCells
|
|
1854
|
+
? [CELL_ENCODINGS.TRIANGLE16, CELL_ENCODINGS.RGBW]
|
|
1855
|
+
: [CELL_ENCODINGS.RGBW, CELL_ENCODINGS.TRIANGLE16];
|
|
1856
|
+
|
|
1857
|
+
for (const cellEncoding of encodingAttempts) {
|
|
1858
|
+
let visible;
|
|
1859
|
+
try {
|
|
1860
|
+
visible = extractVisibleCells(matrix, layout, cellEncoding);
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
errors.push(`${cellEncoding}: ${error.message}`);
|
|
1863
|
+
continue;
|
|
1864
|
+
}
|
|
1657
1865
|
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
for (let maskId = 0; maskId < 4; maskId++) {
|
|
1662
|
-
try {
|
|
1663
|
-
const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
|
|
1664
|
-
const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
|
|
1665
|
-
const rawConfidences = visibleConfidences
|
|
1666
|
-
? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
|
|
1667
|
-
: null;
|
|
1668
|
-
const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
|
|
1669
|
-
const header = headerDecoded.header;
|
|
1670
|
-
const flags = headerDecoded.flags;
|
|
1671
|
-
const eccLevel = eccFromFlags(flags);
|
|
1672
|
-
const payloadLength = headerDecoded.payloadLength;
|
|
1673
|
-
if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
|
|
1674
|
-
throw new Error("Declared payload exceeds matrix capacity.");
|
|
1675
|
-
}
|
|
1866
|
+
const visibleConfidences = confidenceMatrix
|
|
1867
|
+
? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
|
|
1868
|
+
: null;
|
|
1676
1869
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1870
|
+
// New symbols use spectral-spatial interleaving. Legacy order remains a
|
|
1871
|
+
// decode fallback so existing RGBW QuadQR images do not become unreadable.
|
|
1872
|
+
for (const spectralInterleaving of [true, false]) {
|
|
1873
|
+
for (let maskId = 0; maskId < 4; maskId++) {
|
|
1874
|
+
try {
|
|
1875
|
+
const raw = unmaskAndRestoreLogicalCells(
|
|
1876
|
+
visible,
|
|
1877
|
+
layout.dataPositions,
|
|
1878
|
+
maskId,
|
|
1879
|
+
version,
|
|
1880
|
+
spectralInterleaving,
|
|
1881
|
+
cellEncoding
|
|
1882
|
+
);
|
|
1883
|
+
const rawConfidences = visibleConfidences
|
|
1884
|
+
? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
|
|
1885
|
+
: null;
|
|
1886
|
+
const headerDecoded = decodeProtectedHeader(
|
|
1887
|
+
raw,
|
|
1888
|
+
version,
|
|
1889
|
+
rawConfidences,
|
|
1890
|
+
options,
|
|
1891
|
+
cellEncoding
|
|
1892
|
+
);
|
|
1893
|
+
const header = headerDecoded.header;
|
|
1894
|
+
const flags = headerDecoded.flags;
|
|
1895
|
+
const declaredCellEncoding = cellEncodingFromFlags(flags);
|
|
1896
|
+
if (declaredCellEncoding !== cellEncoding) {
|
|
1897
|
+
throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
|
|
1898
|
+
}
|
|
1899
|
+
const eccLevel = eccFromFlags(flags);
|
|
1900
|
+
const payloadLength = headerDecoded.payloadLength;
|
|
1901
|
+
if (streamCellCount(payloadLength, eccLevel, version, cellEncoding) > raw.length) {
|
|
1902
|
+
throw new Error("Declared payload exceeds matrix capacity.");
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
const bodyDecoded = decodeProtectedBody(
|
|
1906
|
+
raw,
|
|
1907
|
+
payloadLength,
|
|
1908
|
+
eccLevel,
|
|
1909
|
+
version,
|
|
1910
|
+
rawConfidences,
|
|
1911
|
+
options,
|
|
1912
|
+
cellEncoding
|
|
1913
|
+
);
|
|
1914
|
+
const body = bodyDecoded.body;
|
|
1915
|
+
const payload = body.slice(0, payloadLength);
|
|
1916
|
+
const expectedCrc = readU32be(body, payloadLength);
|
|
1917
|
+
const actualCrc = crc32(concatBytes(header, payload));
|
|
1918
|
+
if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
|
|
1919
|
+
|
|
1920
|
+
const isText = (flags & TEXT_FLAG) !== 0;
|
|
1921
|
+
const secure = (flags & SECURE_FLAG) !== 0;
|
|
1922
|
+
const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
1923
|
+
const signedFlag = (flags & SIGNED_FLAG) !== 0;
|
|
1924
|
+
const security = secure ? inspectSecureEnvelope(payload) : null;
|
|
1925
|
+
const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
|
|
1926
|
+
const applicationPayload = envelope ? envelope.payload : payload;
|
|
1927
|
+
const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
|
|
1928
|
+
const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
|
|
1929
|
+
return {
|
|
1930
|
+
ok: true,
|
|
1931
|
+
format: "QuadQR",
|
|
1932
|
+
formatVersion: FORMAT_VERSION,
|
|
1933
|
+
version,
|
|
1934
|
+
size,
|
|
1935
|
+
alignmentPatterns: layout.alignments.length,
|
|
1936
|
+
maskId,
|
|
1937
|
+
rotation,
|
|
1938
|
+
flags,
|
|
1939
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
1940
|
+
cellEncoding,
|
|
1941
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
1942
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
1943
|
+
eccLevel,
|
|
1944
|
+
eccParitySymbols: bodyDecoded.plan.paritySymbols,
|
|
1945
|
+
eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
|
|
1946
|
+
correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
|
|
1947
|
+
spectralInterleaving,
|
|
1948
|
+
confidenceAwareEcc: Boolean(rawConfidences),
|
|
1949
|
+
confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
|
|
1950
|
+
erasureSymbols,
|
|
1951
|
+
unknownErrorSymbols,
|
|
1952
|
+
correctedHeaderSymbols: headerDecoded.correctedSymbols,
|
|
1953
|
+
correctedBodySymbols: bodyDecoded.correctedSymbols,
|
|
1954
|
+
correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
|
|
1955
|
+
protectedPayload: envelope ? payload : undefined,
|
|
1956
|
+
payload: secure ? payload : applicationPayload,
|
|
1957
|
+
text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
|
|
1958
|
+
compressed: envelope?.compressed ?? false,
|
|
1959
|
+
compression: envelope?.compression ?? "none",
|
|
1960
|
+
signed: envelope?.signed ?? signedFlag,
|
|
1961
|
+
signatureVerified: envelope?.signatureVerified,
|
|
1962
|
+
signatureTrusted: envelope?.signatureTrusted,
|
|
1963
|
+
signingKeyId: envelope?.keyId ?? null,
|
|
1964
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
|
|
1965
|
+
signer: envelope?.signer ?? null,
|
|
1966
|
+
secure,
|
|
1967
|
+
encrypted: secure,
|
|
1968
|
+
decrypted: false,
|
|
1969
|
+
requiresDecryption: secure,
|
|
1970
|
+
security,
|
|
1971
|
+
crc32: actualCrc >>> 0
|
|
1972
|
+
};
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
errors.push(`${cellEncoding} ${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
|
|
1975
|
+
}
|
|
1742
1976
|
}
|
|
1743
1977
|
}
|
|
1744
1978
|
}
|
|
@@ -1855,6 +2089,80 @@ function cellRgb(cell, palette) {
|
|
|
1855
2089
|
}
|
|
1856
2090
|
|
|
1857
2091
|
|
|
2092
|
+
function inferCellEncoding(codeOrMatrix, matrix, layout = null) {
|
|
2093
|
+
if (!Array.isArray(codeOrMatrix) && codeOrMatrix?.cellEncoding) {
|
|
2094
|
+
return normalizeCellEncoding(codeOrMatrix.cellEncoding);
|
|
2095
|
+
}
|
|
2096
|
+
const activeLayout = layout ?? renderLayoutForMatrix(matrix);
|
|
2097
|
+
if (activeLayout) {
|
|
2098
|
+
for (const [row, col] of activeLayout.dataPositions) {
|
|
2099
|
+
if ((matrix[row]?.[col] ?? 0) > 3) return CELL_ENCODINGS.TRIANGLE16;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
return CELL_ENCODINGS.RGBW;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function triangleCellColors(cell) {
|
|
2106
|
+
const { first, second } = unpackTriangleCell(cell);
|
|
2107
|
+
return { first, second };
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
function drawTriangleCellCanvas(ctx, x, y, width, height, cell, palette) {
|
|
2111
|
+
const pair = triangleCellColors(cell);
|
|
2112
|
+
if (pair.first === pair.second) {
|
|
2113
|
+
ctx.fillStyle = cellColor(pair.first, palette);
|
|
2114
|
+
ctx.fillRect(x, y, width, height);
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// Fixed "/" split. first = upper-left triangle, second = lower-right.
|
|
2119
|
+
ctx.beginPath();
|
|
2120
|
+
ctx.moveTo(x, y);
|
|
2121
|
+
ctx.lineTo(x + width, y);
|
|
2122
|
+
ctx.lineTo(x, y + height);
|
|
2123
|
+
ctx.closePath();
|
|
2124
|
+
ctx.fillStyle = cellColor(pair.first, palette);
|
|
2125
|
+
ctx.fill();
|
|
2126
|
+
|
|
2127
|
+
ctx.beginPath();
|
|
2128
|
+
ctx.moveTo(x + width, y);
|
|
2129
|
+
ctx.lineTo(x + width, y + height);
|
|
2130
|
+
ctx.lineTo(x, y + height);
|
|
2131
|
+
ctx.closePath();
|
|
2132
|
+
ctx.fillStyle = cellColor(pair.second, palette);
|
|
2133
|
+
ctx.fill();
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function fillImageTriangleCell(data, imageWidth, x, y, width, height, cell, palette) {
|
|
2137
|
+
const pair = triangleCellColors(cell);
|
|
2138
|
+
const firstRgb = cellRgb(pair.first, palette);
|
|
2139
|
+
const secondRgb = cellRgb(pair.second, palette);
|
|
2140
|
+
for (let yy = 0; yy < height; yy++) {
|
|
2141
|
+
for (let xx = 0; xx < width; xx++) {
|
|
2142
|
+
const normalizedX = (xx + 0.5) / Math.max(1, width);
|
|
2143
|
+
const normalizedY = (yy + 0.5) / Math.max(1, height);
|
|
2144
|
+
const rgb = normalizedX + normalizedY <= 1 ? firstRgb : secondRgb;
|
|
2145
|
+
const p = ((y + yy) * imageWidth + (x + xx)) * 4;
|
|
2146
|
+
data[p] = rgb.r;
|
|
2147
|
+
data[p + 1] = rgb.g;
|
|
2148
|
+
data[p + 2] = rgb.b;
|
|
2149
|
+
data[p + 3] = 255;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function svgTriangleCell(x, y, size, cell, palette) {
|
|
2155
|
+
const pair = triangleCellColors(cell);
|
|
2156
|
+
if (pair.first === pair.second) return svgRect(x, y, size, size, cellColor(pair.first, palette));
|
|
2157
|
+
const first = cellColor(pair.first, palette);
|
|
2158
|
+
const second = cellColor(pair.second, palette);
|
|
2159
|
+
return [
|
|
2160
|
+
`<polygon points="${x},${y} ${x + size},${y} ${x},${y + size}" fill="${first}"/>`,
|
|
2161
|
+
`<polygon points="${x + size},${y} ${x + size},${y + size} ${x},${y + size}" fill="${second}"/>`
|
|
2162
|
+
].join("");
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
|
|
1858
2166
|
function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
|
|
1859
2167
|
const value = String(style).toLowerCase();
|
|
1860
2168
|
const allowed = Object.values(RENDER_STYLES);
|
|
@@ -2324,6 +2632,7 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
2324
2632
|
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2325
2633
|
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2326
2634
|
const layout = renderLayoutForMatrix(matrix);
|
|
2635
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
2327
2636
|
const size = matrix.length;
|
|
2328
2637
|
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
2329
2638
|
|
|
@@ -2342,6 +2651,14 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
2342
2651
|
const { x, y, width: cellWidth, height: cellHeight } = rect;
|
|
2343
2652
|
const cellSize = Math.min(cellWidth, cellHeight);
|
|
2344
2653
|
|
|
2654
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
2655
|
+
// Experimental Triangle16 always renders payload cells with exact,
|
|
2656
|
+
// hard-edged geometry. Decorative per-cell styles would contaminate the
|
|
2657
|
+
// two independent color samples and reduce scan reliability.
|
|
2658
|
+
drawTriangleCellCanvas(ctx, x, y, cellWidth, cellHeight, cell, palette);
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2345
2662
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2346
2663
|
ctx.fillStyle = cellColor(cell, palette);
|
|
2347
2664
|
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
@@ -2415,6 +2732,7 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
2415
2732
|
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2416
2733
|
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2417
2734
|
const layout = renderLayoutForMatrix(matrix);
|
|
2735
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
2418
2736
|
const size = matrix.length;
|
|
2419
2737
|
const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
|
|
2420
2738
|
const white = palette.white;
|
|
@@ -2431,7 +2749,6 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
2431
2749
|
for (let r = 0; r < size; r++) {
|
|
2432
2750
|
for (let c = 0; c < size; c++) {
|
|
2433
2751
|
const cell = matrix[r][c];
|
|
2434
|
-
const rgb = cellRgb(cell, palette);
|
|
2435
2752
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
2436
2753
|
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
2437
2754
|
const x0 = rect.x;
|
|
@@ -2440,6 +2757,13 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
2440
2757
|
const cellHeight = rect.height;
|
|
2441
2758
|
const cellSize = Math.min(cellWidth, cellHeight);
|
|
2442
2759
|
|
|
2760
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
2761
|
+
fillImageTriangleCell(data, pixelSize, x0, y0, cellWidth, cellHeight, cell, palette);
|
|
2762
|
+
continue;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
const rgb = cellRgb(cell, palette);
|
|
2766
|
+
|
|
2443
2767
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2444
2768
|
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
2445
2769
|
continue;
|
|
@@ -2501,6 +2825,7 @@ export function renderToSVG(codeOrMatrix, options = {}) {
|
|
|
2501
2825
|
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2502
2826
|
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2503
2827
|
const layout = renderLayoutForMatrix(matrix);
|
|
2828
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
2504
2829
|
const size = matrix.length;
|
|
2505
2830
|
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
2506
2831
|
const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
|
|
@@ -2511,7 +2836,12 @@ export function renderToSVG(codeOrMatrix, options = {}) {
|
|
|
2511
2836
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
2512
2837
|
const x = (c + quietZone) * moduleSize;
|
|
2513
2838
|
const y = (r + quietZone) * moduleSize;
|
|
2514
|
-
const fill = cellColor(cell, palette);
|
|
2839
|
+
const fill = cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural ? null : cellColor(cell, palette);
|
|
2840
|
+
|
|
2841
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
2842
|
+
body.push(svgTriangleCell(x, y, moduleSize, cell, palette));
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2515
2845
|
|
|
2516
2846
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2517
2847
|
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
@@ -2738,6 +3068,60 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
2738
3068
|
};
|
|
2739
3069
|
}
|
|
2740
3070
|
|
|
3071
|
+
function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layout) {
|
|
3072
|
+
const size = rgbGrid.length;
|
|
3073
|
+
const matrix = make2D(size, CELL.WHITE);
|
|
3074
|
+
const confidence = make2D(size, 1);
|
|
3075
|
+
const dataClassifier = {
|
|
3076
|
+
...classifier,
|
|
3077
|
+
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
3078
|
+
};
|
|
3079
|
+
let distanceSum = 0;
|
|
3080
|
+
let confidenceSum = 0;
|
|
3081
|
+
let minimumConfidence = 1;
|
|
3082
|
+
let lowConfidenceCells = 0;
|
|
3083
|
+
|
|
3084
|
+
for (let r = 0; r < size; r++) {
|
|
3085
|
+
for (let c = 0; c < size; c++) {
|
|
3086
|
+
const isDataCell = layout && !layout.reserved[r][c];
|
|
3087
|
+
if (!isDataCell) {
|
|
3088
|
+
const classified = classifyRgb(rgbGrid[r][c], classifier);
|
|
3089
|
+
matrix[r][c] = classified.cell;
|
|
3090
|
+
confidence[r][c] = classified.confidence;
|
|
3091
|
+
distanceSum += classified.distance;
|
|
3092
|
+
confidenceSum += classified.confidence;
|
|
3093
|
+
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
3094
|
+
if (classified.confidence < 0.4) lowConfidenceCells++;
|
|
3095
|
+
continue;
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
const samples = triangleGrid?.[r]?.[c];
|
|
3099
|
+
if (!samples) throw new Error("Triangle16 sampling grid is incomplete.");
|
|
3100
|
+
const classifyData = (rgb) => classifier.dataMode === "hue"
|
|
3101
|
+
? classifyDataHue(rgb, classifier)
|
|
3102
|
+
: classifyRgb(rgb, dataClassifier);
|
|
3103
|
+
const first = classifyData(samples.first);
|
|
3104
|
+
const second = classifyData(samples.second);
|
|
3105
|
+
const cellConfidence = Math.min(first.confidence, second.confidence);
|
|
3106
|
+
matrix[r][c] = packTriangleCell(first.cell, second.cell);
|
|
3107
|
+
confidence[r][c] = cellConfidence;
|
|
3108
|
+
distanceSum += (first.distance + second.distance) / 2;
|
|
3109
|
+
confidenceSum += cellConfidence;
|
|
3110
|
+
minimumConfidence = Math.min(minimumConfidence, cellConfidence);
|
|
3111
|
+
if (cellConfidence < 0.4) lowConfidenceCells++;
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
return {
|
|
3116
|
+
matrix,
|
|
3117
|
+
confidence,
|
|
3118
|
+
averageColorDistance: distanceSum / (size * size),
|
|
3119
|
+
averageCellConfidence: confidenceSum / (size * size),
|
|
3120
|
+
minimumCellConfidence: minimumConfidence,
|
|
3121
|
+
lowConfidenceCells
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
3124
|
+
|
|
2741
3125
|
function structuralAccuracy(matrix, layout) {
|
|
2742
3126
|
let matches = 0;
|
|
2743
3127
|
let total = 0;
|
|
@@ -2777,6 +3161,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2777
3161
|
finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
|
|
2778
3162
|
finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
|
|
2779
3163
|
finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
|
|
3164
|
+
preciseAlignment: options.preciseAlignment,
|
|
2780
3165
|
diagnostics: options._visionDiagnostics,
|
|
2781
3166
|
diagnosticLabel: options._diagnosticLabel ?? "normal"
|
|
2782
3167
|
});
|
|
@@ -2802,9 +3187,18 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2802
3187
|
let bestStructureScore = 0;
|
|
2803
3188
|
for (const profile of sampleProfiles) {
|
|
2804
3189
|
let sampled;
|
|
3190
|
+
let triangleSampled = null;
|
|
2805
3191
|
let observedPalette;
|
|
2806
3192
|
try {
|
|
2807
3193
|
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
|
|
3194
|
+
if (options.highDensitySampling !== false && options.triangle16 !== false) {
|
|
3195
|
+
triangleSampled = samplePerspectiveTriangleMatrix(
|
|
3196
|
+
imageData,
|
|
3197
|
+
geometry.homography,
|
|
3198
|
+
layout.size,
|
|
3199
|
+
profile
|
|
3200
|
+
);
|
|
3201
|
+
}
|
|
2808
3202
|
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
|
|
2809
3203
|
robust: profile.robustCalibration
|
|
2810
3204
|
});
|
|
@@ -2863,11 +3257,67 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2863
3257
|
}
|
|
2864
3258
|
};
|
|
2865
3259
|
|
|
3260
|
+
const tryTriangleAttempt = (attempt, metadata = {}) => {
|
|
3261
|
+
if (!triangleSampled?.triangleGrid) return false;
|
|
3262
|
+
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
3263
|
+
const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
|
|
3264
|
+
const classified = classifyTriangleSampledRgbGrid(
|
|
3265
|
+
sampled.rgbGrid,
|
|
3266
|
+
triangleSampled.triangleGrid,
|
|
3267
|
+
attempt.classifier,
|
|
3268
|
+
layout
|
|
3269
|
+
);
|
|
3270
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
3271
|
+
bestStructureScore = Math.max(bestStructureScore, structureScore);
|
|
3272
|
+
pushObservation(options, {
|
|
3273
|
+
version: geometry.version,
|
|
3274
|
+
matrix: classified.matrix,
|
|
3275
|
+
confidence: classified.confidence,
|
|
3276
|
+
geometry,
|
|
3277
|
+
observedPalette: activeObservedPalette,
|
|
3278
|
+
samplingMode: activeSamplingMode,
|
|
3279
|
+
colorNormalization: attempt.colorNormalization,
|
|
3280
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3281
|
+
structureScore,
|
|
3282
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3283
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3284
|
+
});
|
|
3285
|
+
|
|
3286
|
+
try {
|
|
3287
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
3288
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3289
|
+
cellConfidence: classified.confidence,
|
|
3290
|
+
cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
|
|
3291
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3292
|
+
});
|
|
3293
|
+
if (decoded.version !== geometry.version) return false;
|
|
3294
|
+
results.push({
|
|
3295
|
+
...decoded,
|
|
3296
|
+
perspectiveCorrected: true,
|
|
3297
|
+
colorCalibrated: true,
|
|
3298
|
+
colorNormalization: attempt.colorNormalization,
|
|
3299
|
+
samplingMode: activeSamplingMode,
|
|
3300
|
+
geometry,
|
|
3301
|
+
observedPalette: activeObservedPalette,
|
|
3302
|
+
averageColorDistance: classified.averageColorDistance,
|
|
3303
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3304
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3305
|
+
lowConfidenceCells: classified.lowConfidenceCells,
|
|
3306
|
+
rectified: options.includeRectified
|
|
3307
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
3308
|
+
: undefined
|
|
3309
|
+
});
|
|
3310
|
+
return true;
|
|
3311
|
+
} catch {
|
|
3312
|
+
return false;
|
|
3313
|
+
}
|
|
3314
|
+
};
|
|
3315
|
+
|
|
2866
3316
|
// Fast path: preserve the original observed-RGB classifier first, then
|
|
2867
3317
|
// try per-channel white balancing. Most clean frames stop here without
|
|
2868
3318
|
// paying for the more expensive spatial normalization fallback.
|
|
2869
3319
|
for (const attempt of paletteClassifierAttempts(observedPalette)) {
|
|
2870
|
-
if (tryAttempt(attempt, sampled.rgbGrid)) {
|
|
3320
|
+
if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
|
|
2871
3321
|
geometryDecoded = true;
|
|
2872
3322
|
break;
|
|
2873
3323
|
}
|
|
@@ -2994,17 +3444,30 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2994
3444
|
[step, -step], [step, step], [-step, -step], [-step, step]
|
|
2995
3445
|
];
|
|
2996
3446
|
const refinementCandidates = [];
|
|
3447
|
+
const triangleRefinementCandidates = [];
|
|
2997
3448
|
|
|
2998
3449
|
for (const [offsetX, offsetY] of refinementOffsets) {
|
|
2999
3450
|
let sampled;
|
|
3451
|
+
let triangleSampled = null;
|
|
3000
3452
|
let observedPalette;
|
|
3001
3453
|
try {
|
|
3002
|
-
|
|
3454
|
+
const samplingOptions = {
|
|
3003
3455
|
sampleMode: "cross",
|
|
3004
3456
|
sampleRadius: 0,
|
|
3005
3457
|
sampleOffsetX: offsetX,
|
|
3006
|
-
sampleOffsetY: offsetY
|
|
3007
|
-
|
|
3458
|
+
sampleOffsetY: offsetY,
|
|
3459
|
+
triangleSampleInset: options.highDensitySampleInset ?? options.triangleSampleInset,
|
|
3460
|
+
triangleSampleRadius: options.highDensitySampleRadius ?? options.triangleSampleRadius
|
|
3461
|
+
};
|
|
3462
|
+
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, samplingOptions);
|
|
3463
|
+
if (options.highDensitySampling !== false && options.triangle16 !== false) {
|
|
3464
|
+
triangleSampled = samplePerspectiveTriangleMatrix(
|
|
3465
|
+
imageData,
|
|
3466
|
+
geometry.homography,
|
|
3467
|
+
layout.size,
|
|
3468
|
+
samplingOptions
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
3008
3471
|
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
|
|
3009
3472
|
} catch {
|
|
3010
3473
|
continue;
|
|
@@ -3034,30 +3497,80 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3034
3497
|
|
|
3035
3498
|
// Do not spend RS work on a refinement that made the known finder /
|
|
3036
3499
|
// alignment structure worse. This keeps the recovery path bounded.
|
|
3037
|
-
if (structureScore
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3500
|
+
if (structureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
|
|
3501
|
+
refinementCandidates.push({
|
|
3502
|
+
attempt,
|
|
3503
|
+
classified,
|
|
3504
|
+
observedPalette,
|
|
3505
|
+
offsetX,
|
|
3506
|
+
offsetY,
|
|
3507
|
+
structureScore,
|
|
3508
|
+
cellEncoding: CELL_ENCODINGS.RGBW,
|
|
3509
|
+
samplingMode: "refined-center"
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
if (triangleSampled?.triangleGrid) {
|
|
3514
|
+
const triangleClassified = classifyTriangleSampledRgbGrid(
|
|
3515
|
+
sampled.rgbGrid,
|
|
3516
|
+
triangleSampled.triangleGrid,
|
|
3517
|
+
attempt.classifier,
|
|
3518
|
+
layout
|
|
3519
|
+
);
|
|
3520
|
+
const triangleStructureScore = structuralAccuracy(triangleClassified.matrix, layout);
|
|
3521
|
+
bestStructureScore = Math.max(bestStructureScore, triangleStructureScore);
|
|
3522
|
+
pushObservation(options, {
|
|
3523
|
+
version: geometry.version,
|
|
3524
|
+
matrix: triangleClassified.matrix,
|
|
3525
|
+
confidence: triangleClassified.confidence,
|
|
3526
|
+
geometry,
|
|
3527
|
+
observedPalette,
|
|
3528
|
+
samplingMode: "refined-triangle16",
|
|
3529
|
+
samplingOffset: { x: offsetX, y: offsetY },
|
|
3530
|
+
colorNormalization: attempt.colorNormalization,
|
|
3531
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3532
|
+
structureScore: triangleStructureScore,
|
|
3533
|
+
averageCellConfidence: triangleClassified.averageCellConfidence,
|
|
3534
|
+
lowConfidenceCells: triangleClassified.lowConfidenceCells
|
|
3535
|
+
});
|
|
3536
|
+
if (triangleStructureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
|
|
3537
|
+
triangleRefinementCandidates.push({
|
|
3538
|
+
attempt,
|
|
3539
|
+
classified: triangleClassified,
|
|
3540
|
+
observedPalette,
|
|
3541
|
+
offsetX,
|
|
3542
|
+
offsetY,
|
|
3543
|
+
structureScore: triangleStructureScore,
|
|
3544
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3545
|
+
samplingMode: "refined-triangle16"
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3046
3549
|
}
|
|
3047
3550
|
}
|
|
3048
3551
|
|
|
3049
|
-
|
|
3552
|
+
const sortRefinementCandidates = (items) => items.sort((a, b) =>
|
|
3050
3553
|
(b.structureScore - a.structureScore) ||
|
|
3051
3554
|
(b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
|
|
3052
3555
|
(a.classified.averageColorDistance - b.classified.averageColorDistance)
|
|
3053
3556
|
);
|
|
3557
|
+
sortRefinementCandidates(refinementCandidates);
|
|
3558
|
+
sortRefinementCandidates(triangleRefinementCandidates);
|
|
3054
3559
|
|
|
3055
3560
|
const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
|
|
3056
|
-
|
|
3561
|
+
// Keep separate per-encoding limits. A Triangle16 image can produce very
|
|
3562
|
+
// confident but meaningless centre samples, and those must not crowd out
|
|
3563
|
+
// the dual-region candidates during the final bounded recovery pass.
|
|
3564
|
+
const decodeCandidates = [
|
|
3565
|
+
...refinementCandidates.slice(0, decodeLimit),
|
|
3566
|
+
...triangleRefinementCandidates.slice(0, decodeLimit)
|
|
3567
|
+
];
|
|
3568
|
+
for (const candidate of decodeCandidates) {
|
|
3057
3569
|
try {
|
|
3058
3570
|
const decoded = decodeMatrix(candidate.classified.matrix, {
|
|
3059
3571
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3060
3572
|
cellConfidence: candidate.classified.confidence,
|
|
3573
|
+
cellEncodingHint: candidate.cellEncoding,
|
|
3061
3574
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3062
3575
|
});
|
|
3063
3576
|
if (decoded.version !== geometry.version) continue;
|
|
@@ -3068,7 +3581,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3068
3581
|
samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
|
|
3069
3582
|
colorCalibrated: true,
|
|
3070
3583
|
colorNormalization: candidate.attempt.colorNormalization,
|
|
3071
|
-
samplingMode:
|
|
3584
|
+
samplingMode: candidate.samplingMode,
|
|
3072
3585
|
geometry,
|
|
3073
3586
|
observedPalette: candidate.observedPalette,
|
|
3074
3587
|
averageColorDistance: candidate.classified.averageColorDistance,
|
|
@@ -3118,6 +3631,15 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3118
3631
|
for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
|
|
3119
3632
|
try {
|
|
3120
3633
|
const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
|
|
3634
|
+
const triangleSampled = options.highDensitySampling === false || options.triangle16 === false
|
|
3635
|
+
? null
|
|
3636
|
+
: sampleAxisAlignedTriangleGrid(
|
|
3637
|
+
imageData,
|
|
3638
|
+
bounds,
|
|
3639
|
+
size,
|
|
3640
|
+
Math.min(0.10, Math.max(0.04, radiusProfiles[profileIndex] * 0.48)),
|
|
3641
|
+
options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28
|
|
3642
|
+
);
|
|
3121
3643
|
const layout = createLayout(version);
|
|
3122
3644
|
const classifierAttempts = [];
|
|
3123
3645
|
try {
|
|
@@ -3157,45 +3679,70 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3157
3679
|
});
|
|
3158
3680
|
|
|
3159
3681
|
for (const attempt of classifierAttempts) {
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
maxErasureConfidence: options.maxErasureConfidence
|
|
3178
|
-
});
|
|
3179
|
-
candidates.push({
|
|
3180
|
-
...decoded,
|
|
3181
|
-
bounds,
|
|
3182
|
-
sampledVersion: version,
|
|
3183
|
-
moduleWidth: moduleW,
|
|
3184
|
-
moduleHeight: moduleH,
|
|
3185
|
-
perspectiveCorrected: false,
|
|
3186
|
-
colorCalibrated: attempt.calibrated,
|
|
3187
|
-
colorNormalization: attempt.colorNormalization,
|
|
3188
|
-
samplingMode: profileIndex === 0 ? "axis" : "axis-center",
|
|
3189
|
-
averageColorDistance: classified.averageColorDistance,
|
|
3190
|
-
averageCellConfidence: classified.averageCellConfidence,
|
|
3191
|
-
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3192
|
-
lowConfidenceCells: classified.lowConfidenceCells
|
|
3682
|
+
const centerGrid = attempt.rgbGrid ?? sampled.rgbGrid;
|
|
3683
|
+
const samplingMode = profileIndex === 0 ? "axis" : "axis-center";
|
|
3684
|
+
const classifiedAttempts = [{
|
|
3685
|
+
classified: classifySampledRgbGrid(centerGrid, attempt.classifier, layout),
|
|
3686
|
+
cellEncoding: CELL_ENCODINGS.RGBW,
|
|
3687
|
+
samplingMode
|
|
3688
|
+
}];
|
|
3689
|
+
if (triangleSampled?.triangleGrid && attempt.rgbGrid === sampled.rgbGrid) {
|
|
3690
|
+
classifiedAttempts.push({
|
|
3691
|
+
classified: classifyTriangleSampledRgbGrid(
|
|
3692
|
+
centerGrid,
|
|
3693
|
+
triangleSampled.triangleGrid,
|
|
3694
|
+
attempt.classifier,
|
|
3695
|
+
layout
|
|
3696
|
+
),
|
|
3697
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3698
|
+
samplingMode: `${samplingMode}-triangle16`
|
|
3193
3699
|
});
|
|
3194
|
-
accepted = true;
|
|
3195
|
-
break;
|
|
3196
|
-
} catch {
|
|
3197
|
-
// Try next classifier/profile.
|
|
3198
3700
|
}
|
|
3701
|
+
|
|
3702
|
+
for (const classifiedAttempt of classifiedAttempts) {
|
|
3703
|
+
try {
|
|
3704
|
+
const classified = classifiedAttempt.classified;
|
|
3705
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
3706
|
+
pushObservation(options, {
|
|
3707
|
+
version,
|
|
3708
|
+
matrix: classified.matrix,
|
|
3709
|
+
confidence: classified.confidence,
|
|
3710
|
+
bounds,
|
|
3711
|
+
samplingMode: classifiedAttempt.samplingMode,
|
|
3712
|
+
colorNormalization: attempt.colorNormalization,
|
|
3713
|
+
cellEncoding: classifiedAttempt.cellEncoding,
|
|
3714
|
+
structureScore,
|
|
3715
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3716
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3717
|
+
});
|
|
3718
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
3719
|
+
structureTolerance: options.structureTolerance ?? 0.12,
|
|
3720
|
+
cellConfidence: classified.confidence,
|
|
3721
|
+
cellEncodingHint: classifiedAttempt.cellEncoding,
|
|
3722
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3723
|
+
});
|
|
3724
|
+
candidates.push({
|
|
3725
|
+
...decoded,
|
|
3726
|
+
bounds,
|
|
3727
|
+
sampledVersion: version,
|
|
3728
|
+
moduleWidth: moduleW,
|
|
3729
|
+
moduleHeight: moduleH,
|
|
3730
|
+
perspectiveCorrected: false,
|
|
3731
|
+
colorCalibrated: attempt.calibrated,
|
|
3732
|
+
colorNormalization: attempt.colorNormalization,
|
|
3733
|
+
samplingMode: classifiedAttempt.samplingMode,
|
|
3734
|
+
averageColorDistance: classified.averageColorDistance,
|
|
3735
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3736
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3737
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3738
|
+
});
|
|
3739
|
+
accepted = true;
|
|
3740
|
+
break;
|
|
3741
|
+
} catch {
|
|
3742
|
+
// Try the next cell encoding/classifier/profile.
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
if (accepted) break;
|
|
3199
3746
|
}
|
|
3200
3747
|
} catch {
|
|
3201
3748
|
// Try a narrower centre sample, then next version.
|
|
@@ -3307,7 +3854,7 @@ function perspectiveStress(imageData, severity) {
|
|
|
3307
3854
|
{ x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
|
|
3308
3855
|
{ x: inset * 0.20, y: height - 1 - inset * 0.95 }
|
|
3309
3856
|
];
|
|
3310
|
-
const inverse = computeHomography(
|
|
3857
|
+
const inverse = computeHomography(source, destination);
|
|
3311
3858
|
const out = new Uint8ClampedArray(width * height * 4);
|
|
3312
3859
|
out.fill(255);
|
|
3313
3860
|
for (let y = 0; y < height; y++) {
|
|
@@ -3592,6 +4139,36 @@ export function scanImageData(imageData, options = {}) {
|
|
|
3592
4139
|
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
3593
4140
|
}
|
|
3594
4141
|
|
|
4142
|
+
// Triangle16 has half-cell color regions, so geometry that is adequate for
|
|
4143
|
+
// solid RGBW cells can still be off by a few tenths of a module and mix the
|
|
4144
|
+
// two triangle colors. If normal geometry was found but no payload decoded,
|
|
4145
|
+
// retry geometry once with finer alignment localization. This stays off the
|
|
4146
|
+
// clean/normal camera fast path.
|
|
4147
|
+
if (
|
|
4148
|
+
options.perspective !== false &&
|
|
4149
|
+
options.preciseAlignmentRecovery !== false &&
|
|
4150
|
+
!options._preciseAlignmentRecovery &&
|
|
4151
|
+
geometryCollector.length > 0
|
|
4152
|
+
) {
|
|
4153
|
+
try {
|
|
4154
|
+
const precise = tryPerspectiveScan(imageData, {
|
|
4155
|
+
...scanOptions,
|
|
4156
|
+
preciseAlignment: true,
|
|
4157
|
+
_preciseAlignmentRecovery: true,
|
|
4158
|
+
_diagnosticLabel: "precise-alignment"
|
|
4159
|
+
});
|
|
4160
|
+
if (precise) {
|
|
4161
|
+
return decorateScanResult({
|
|
4162
|
+
...precise,
|
|
4163
|
+
geometryRefined: true,
|
|
4164
|
+
recoveryMode: precise.recoveryMode ?? "precise-alignment"
|
|
4165
|
+
}, debugContext, options);
|
|
4166
|
+
}
|
|
4167
|
+
} catch {
|
|
4168
|
+
// Continue to color recovery and axis-aligned fallback.
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
|
|
3595
4172
|
// If the normal scanner could see QuadQR geometry but could not decode the
|
|
3596
4173
|
// colors, retry the same frame after a single global Auto Tone / Contrast /
|
|
3597
4174
|
// Color-style pass. This is deliberately after the normal path so clean
|
|
@@ -3995,6 +4572,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
3995
4572
|
|
|
3996
4573
|
const scanInterval = Math.max(80, options.scanInterval ?? 180);
|
|
3997
4574
|
const scratchCanvas = document.createElement("canvas");
|
|
4575
|
+
const highResolutionCanvas = document.createElement("canvas");
|
|
3998
4576
|
const multiFrameEnabled = options.multiFrame !== false;
|
|
3999
4577
|
const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
|
|
4000
4578
|
const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
|
|
@@ -4002,6 +4580,12 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4002
4580
|
const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
|
|
4003
4581
|
const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
|
|
4004
4582
|
const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
|
|
4583
|
+
const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
|
|
4584
|
+
const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
|
|
4585
|
+
const cameraHighResolutionMaxDimension = Math.max(
|
|
4586
|
+
baseCameraMaxDimension,
|
|
4587
|
+
Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
|
|
4588
|
+
);
|
|
4005
4589
|
let missStreak = 0;
|
|
4006
4590
|
let stopped = false;
|
|
4007
4591
|
let busy = false;
|
|
@@ -4049,7 +4633,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4049
4633
|
|
|
4050
4634
|
const scanNow = () => scanVideoFrame(video, {
|
|
4051
4635
|
...options,
|
|
4052
|
-
maxDimension:
|
|
4636
|
+
maxDimension: baseCameraMaxDimension,
|
|
4053
4637
|
canvas: scratchCanvas
|
|
4054
4638
|
});
|
|
4055
4639
|
|
|
@@ -4116,7 +4700,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4116
4700
|
busy = true;
|
|
4117
4701
|
frameNumber++;
|
|
4118
4702
|
const observations = [];
|
|
4119
|
-
const frameDiagnostics =
|
|
4703
|
+
const frameDiagnostics = {};
|
|
4120
4704
|
const capturedFrame = {};
|
|
4121
4705
|
const frameStarted = nowMs();
|
|
4122
4706
|
let allowAutoEnhance = false;
|
|
@@ -4144,7 +4728,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4144
4728
|
// If geometry exists, the rectified QR-only pixel enhancer is both
|
|
4145
4729
|
// stronger and much cheaper than reprocessing the whole live frame.
|
|
4146
4730
|
fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
|
|
4147
|
-
maxDimension:
|
|
4731
|
+
maxDimension: baseCameraMaxDimension,
|
|
4148
4732
|
canvas: scratchCanvas,
|
|
4149
4733
|
_capturedFrame: capturedFrame,
|
|
4150
4734
|
_observationCollector: observations,
|
|
@@ -4185,6 +4769,71 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4185
4769
|
...frameDiagnostics
|
|
4186
4770
|
});
|
|
4187
4771
|
|
|
4772
|
+
// Geometry-aware high-resolution retry. A dense QuadQR can look large
|
|
4773
|
+
// enough in the preview while each individual module has become too
|
|
4774
|
+
// small after the normal 1080 px scanner cap. If the fast pass already
|
|
4775
|
+
// sees at least two convincing finders, spend one bounded retry on a
|
|
4776
|
+
// higher-resolution copy of the visible camera ROI. Empty frames and
|
|
4777
|
+
// ordinary small codes never pay this cost.
|
|
4778
|
+
const shouldTryHighResolution = options.cameraHighResolutionRecovery !== false &&
|
|
4779
|
+
cameraHighResolutionMaxDimension > baseCameraMaxDimension &&
|
|
4780
|
+
(frameDiagnostics?.finderCount ?? 0) >= (options.cameraHighResolutionMinFinders ?? 2) &&
|
|
4781
|
+
((missStreak - 1) % cameraHighResolutionEvery === 0);
|
|
4782
|
+
if (shouldTryHighResolution) {
|
|
4783
|
+
const highResolutionObservations = [];
|
|
4784
|
+
const highResolutionDiagnostics = {};
|
|
4785
|
+
const highResolutionCapturedFrame = {};
|
|
4786
|
+
emitDiagnostic({
|
|
4787
|
+
type: "method",
|
|
4788
|
+
state: "trying",
|
|
4789
|
+
method: "high-resolution-geometry-recovery",
|
|
4790
|
+
message: `Dense geometry detected · retrying at up to ${cameraHighResolutionMaxDimension}px`,
|
|
4791
|
+
...frameDiagnostics
|
|
4792
|
+
});
|
|
4793
|
+
try {
|
|
4794
|
+
const recoveryStarted = nowMs();
|
|
4795
|
+
const recovered = scanVideoFrame(video, {
|
|
4796
|
+
...options,
|
|
4797
|
+
_diagnosticLabel: "high-resolution-geometry-recovery",
|
|
4798
|
+
finderRecovery: true,
|
|
4799
|
+
autoEnhanceRecovery: false,
|
|
4800
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
4801
|
+
maxDimension: cameraHighResolutionMaxDimension,
|
|
4802
|
+
canvas: highResolutionCanvas,
|
|
4803
|
+
_capturedFrame: highResolutionCapturedFrame,
|
|
4804
|
+
_observationCollector: highResolutionObservations,
|
|
4805
|
+
_frameDiagnostics: highResolutionDiagnostics
|
|
4806
|
+
});
|
|
4807
|
+
const recoveryElapsedMs = nowMs() - recoveryStarted;
|
|
4808
|
+
emitDiagnostic({
|
|
4809
|
+
type: "success",
|
|
4810
|
+
state: "decoded",
|
|
4811
|
+
method: "high-resolution-geometry-recovery",
|
|
4812
|
+
elapsedMs: recoveryElapsedMs,
|
|
4813
|
+
message: `High-resolution retry decoded v${recovered.version} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
4814
|
+
...(highResolutionDiagnostics ?? frameDiagnostics)
|
|
4815
|
+
});
|
|
4816
|
+
missStreak = 0;
|
|
4817
|
+
observationHistory.clear();
|
|
4818
|
+
if (emitResult({
|
|
4819
|
+
...recovered,
|
|
4820
|
+
cameraHighResolutionRecovery: true,
|
|
4821
|
+
cameraProgressiveRecovery: true,
|
|
4822
|
+
recoveryMode: recovered.recoveryMode ?? "high-resolution-geometry-recovery"
|
|
4823
|
+
}, highResolutionCapturedFrame, highResolutionDiagnostics ?? frameDiagnostics)) return;
|
|
4824
|
+
} catch (highResolutionError) {
|
|
4825
|
+
observations.push(...highResolutionObservations);
|
|
4826
|
+
emitDiagnostic({
|
|
4827
|
+
type: "method",
|
|
4828
|
+
state: "failed",
|
|
4829
|
+
method: "high-resolution-geometry-recovery",
|
|
4830
|
+
message: `High-resolution geometry retry did not decode${highResolutionDiagnostics?.finderCount != null ? ` · ${highResolutionDiagnostics.finderCount} finder(s)` : ""}`,
|
|
4831
|
+
error: highResolutionError?.message ?? String(highResolutionError),
|
|
4832
|
+
...(highResolutionDiagnostics ?? frameDiagnostics)
|
|
4833
|
+
});
|
|
4834
|
+
}
|
|
4835
|
+
}
|
|
4836
|
+
|
|
4188
4837
|
// The user's real phone-camera case is dominated by color cast before
|
|
4189
4838
|
// finder detection: Photoshop Auto Color alone makes the same live QR
|
|
4190
4839
|
// immediately detectable. Retry the exact captured frame with a cheap
|
|
@@ -4489,6 +5138,9 @@ export const internals = Object.freeze({
|
|
|
4489
5138
|
HEADER_CODEWORD_CELLS,
|
|
4490
5139
|
COMPACT_HEADER_CODEWORD_CELLS,
|
|
4491
5140
|
CELLS_PER_BYTE,
|
|
5141
|
+
RGBW_CELLS_PER_BYTE,
|
|
5142
|
+
TRIANGLE16_CELLS_PER_BYTE,
|
|
5143
|
+
TRIANGLE16_FLAG,
|
|
4492
5144
|
TEXT_FLAG,
|
|
4493
5145
|
SECURE_FLAG
|
|
4494
5146
|
});
|