quadqr-js 1.2.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FORMAT.md +37 -9
- package/README.md +98 -48
- package/SPECIFICATION.md +30 -10
- package/bin/quadqr.js +6 -2
- package/dist/esm/benchmark.js +71 -15
- package/dist/esm/brotli.js +2210 -0
- package/dist/esm/deflate.js +381 -0
- package/dist/esm/quadqr.js +1927 -343
- package/dist/esm/security.js +19 -0
- package/dist/esm/vision.js +660 -44
- package/dist/quadqr.js +5292 -489
- package/dist/quadqr.min.js +4987 -468
- package/docs/API.md +49 -12
- package/docs/BROWSER_CDN.md +9 -5
- package/docs/CLI.md +19 -2
- package/docs/COMPRESSION.md +170 -0
- package/docs/GETTING_STARTED.md +12 -2
- package/docs/HIGH_DENSITY_MODE.md +100 -0
- package/docs/NODE.md +22 -0
- package/docs/README.md +8 -5
- package/docs/RELIABILITY_LAB.md +64 -0
- package/docs/TRIANGLE16.md +98 -0
- package/docs/WASM.md +1 -1
- package/package.json +11 -10
- package/types/benchmark.d.ts +0 -8
- package/types/index.d.ts +0 -375
- package/types/node.d.ts +0 -11
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,
|
|
@@ -51,8 +53,23 @@ import {
|
|
|
51
53
|
DEFAULT_PBKDF2_ITERATIONS,
|
|
52
54
|
generateRaw256Key,
|
|
53
55
|
normalizeRaw256Key,
|
|
54
|
-
bytesToHex
|
|
56
|
+
bytesToHex,
|
|
57
|
+
estimateSecureEnvelopeOverhead
|
|
55
58
|
} from "./security.js";
|
|
59
|
+
import {
|
|
60
|
+
compressDeflatePayload as compressDeflateBytes,
|
|
61
|
+
decompressDeflatePayload as decompressDeflateBytes,
|
|
62
|
+
DEFAULT_DEFLATE_LEVEL,
|
|
63
|
+
DEFLATE_LEVEL_MIN,
|
|
64
|
+
DEFLATE_LEVEL_MAX
|
|
65
|
+
} from "./deflate.js";
|
|
66
|
+
import {
|
|
67
|
+
compressBrotliPayload as compressBrotliBytes,
|
|
68
|
+
decompressBrotliPayload as decompressBrotliBytes,
|
|
69
|
+
DEFAULT_BROTLI_QUALITY,
|
|
70
|
+
BROTLI_QUALITY_MIN,
|
|
71
|
+
BROTLI_QUALITY_MAX
|
|
72
|
+
} from "./brotli.js";
|
|
56
73
|
|
|
57
74
|
export const FORMAT_VERSION = 5;
|
|
58
75
|
export const MIN_VERSION = 1;
|
|
@@ -64,6 +81,12 @@ export const RENDER_MODES = Object.freeze({
|
|
|
64
81
|
PRINT: "print"
|
|
65
82
|
});
|
|
66
83
|
|
|
84
|
+
export const CELL_ENCODINGS = Object.freeze({
|
|
85
|
+
RGBW: "rgbw",
|
|
86
|
+
TRIANGLE16: "triangle16"
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
|
|
67
90
|
// Print-safe defaults deliberately use darker, more ink-tolerant primaries and
|
|
68
91
|
// a true white background. Applications can still override individual colors.
|
|
69
92
|
export const PRINT_PALETTE = Object.freeze({
|
|
@@ -77,7 +100,30 @@ export const PRINT_PALETTE = Object.freeze({
|
|
|
77
100
|
export const COMPRESSION_MODES = Object.freeze({
|
|
78
101
|
NONE: "none",
|
|
79
102
|
AUTO: "auto",
|
|
80
|
-
|
|
103
|
+
SMART: "smart",
|
|
104
|
+
LZ: "lz",
|
|
105
|
+
DEFLATE: "deflate",
|
|
106
|
+
BROTLI: "brotli"
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const LZ_LEVEL_MIN = 1;
|
|
110
|
+
export const LZ_LEVEL_MAX = 9;
|
|
111
|
+
export const DEFAULT_LZ_LEVEL = 6;
|
|
112
|
+
|
|
113
|
+
export const COMPRESSION_LEVELS = Object.freeze({
|
|
114
|
+
lz: Object.freeze({ min: LZ_LEVEL_MIN, max: LZ_LEVEL_MAX, default: DEFAULT_LZ_LEVEL }),
|
|
115
|
+
deflate: Object.freeze({ min: DEFLATE_LEVEL_MIN, max: DEFLATE_LEVEL_MAX, default: DEFAULT_DEFLATE_LEVEL }),
|
|
116
|
+
brotli: Object.freeze({ min: BROTLI_QUALITY_MIN, max: BROTLI_QUALITY_MAX, default: DEFAULT_BROTLI_QUALITY }),
|
|
117
|
+
auto: Object.freeze({ lz: DEFAULT_LZ_LEVEL, deflate: DEFAULT_DEFLATE_LEVEL, brotli: 6 }),
|
|
118
|
+
smart: Object.freeze({
|
|
119
|
+
initialLz: DEFAULT_LZ_LEVEL,
|
|
120
|
+
initialDeflate: DEFAULT_DEFLATE_LEVEL,
|
|
121
|
+
initialBrotli: 6,
|
|
122
|
+
strongDeflate: 8,
|
|
123
|
+
strongBrotli: 9,
|
|
124
|
+
maximumDeflate: 9,
|
|
125
|
+
maximumBrotli: 11
|
|
126
|
+
})
|
|
81
127
|
});
|
|
82
128
|
export const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
|
|
83
129
|
|
|
@@ -121,8 +167,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
|
|
|
121
167
|
const HEADER_BYTES = 10;
|
|
122
168
|
const HEADER_RS_PARITY = 8;
|
|
123
169
|
const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
|
|
124
|
-
const
|
|
125
|
-
const
|
|
170
|
+
const RGBW_CELLS_PER_BYTE = 4;
|
|
171
|
+
const TRIANGLE16_CELLS_PER_BYTE = 2;
|
|
172
|
+
const CELLS_PER_BYTE = RGBW_CELLS_PER_BYTE; // legacy internal alias
|
|
173
|
+
const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
|
|
126
174
|
|
|
127
175
|
// Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
|
|
128
176
|
// cannot afford the normal 18-byte protected header plus a 24-byte M body
|
|
@@ -134,7 +182,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
|
|
|
134
182
|
const COMPACT_HEADER_BYTES = 4;
|
|
135
183
|
const COMPACT_HEADER_RS_PARITY = 4;
|
|
136
184
|
const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
|
|
137
|
-
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES *
|
|
185
|
+
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
|
|
138
186
|
const COMPACT_ECC_LEVELS = Object.freeze({
|
|
139
187
|
L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
|
|
140
188
|
M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
|
|
@@ -147,18 +195,19 @@ const TEXT_FLAG = 1;
|
|
|
147
195
|
const SECURE_FLAG = 1 << 3;
|
|
148
196
|
const EXTENDED_PAYLOAD_FLAG = 1 << 4;
|
|
149
197
|
const SIGNED_FLAG = 1 << 5;
|
|
198
|
+
const TRIANGLE16_FLAG = 1 << 6;
|
|
150
199
|
|
|
151
200
|
// Internal payload extension envelope. This is deliberately not a public
|
|
152
201
|
// payload mode or content-type system. It exists only when compression or
|
|
153
202
|
// signing needs metadata around an otherwise normal text/binary payload.
|
|
154
|
-
const PAYLOAD_ENVELOPE_VERSION =
|
|
203
|
+
const PAYLOAD_ENVELOPE_VERSION = 3;
|
|
155
204
|
const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
|
|
156
205
|
const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
|
|
157
206
|
const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
|
|
158
207
|
const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
|
|
159
208
|
const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
|
|
160
|
-
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
|
|
161
|
-
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
|
|
209
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1, deflate: 2, brotli: 3 });
|
|
210
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz", 2: "deflate", 3: "brotli" });
|
|
162
211
|
const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
|
|
163
212
|
const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
|
|
164
213
|
const ECC_SHIFT = 1;
|
|
@@ -209,6 +258,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
|
|
|
209
258
|
return value;
|
|
210
259
|
}
|
|
211
260
|
|
|
261
|
+
function normalizeCellEncoding(value = CELL_ENCODINGS.RGBW) {
|
|
262
|
+
const key = String(value).toLowerCase();
|
|
263
|
+
assert(
|
|
264
|
+
key === CELL_ENCODINGS.RGBW || key === CELL_ENCODINGS.TRIANGLE16,
|
|
265
|
+
`Internal cell encoding must be ${CELL_ENCODINGS.RGBW} or ${CELL_ENCODINGS.TRIANGLE16}.`
|
|
266
|
+
);
|
|
267
|
+
return key;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Public API presents Triangle16 as one experimental High Density Mode toggle.
|
|
271
|
+
// cellEncoding remains an internal/backward-compatible detail so existing
|
|
272
|
+
// experimental branch symbols and renderer metadata continue to work.
|
|
273
|
+
function cellEncodingFromOptions(options = {}) {
|
|
274
|
+
if (typeof options.highDensity === "boolean") {
|
|
275
|
+
return options.highDensity ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
|
|
276
|
+
}
|
|
277
|
+
return normalizeCellEncoding(options.cellEncoding ?? CELL_ENCODINGS.RGBW);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function cellsPerByteForEncoding(cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
281
|
+
return normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16
|
|
282
|
+
? TRIANGLE16_CELLS_PER_BYTE
|
|
283
|
+
: RGBW_CELLS_PER_BYTE;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function packTriangleCell(first, second) {
|
|
287
|
+
assert(first >= 0 && first <= 3 && second >= 0 && second <= 3, "Invalid Triangle16 color.");
|
|
288
|
+
return ((first & 3) << 2) | (second & 3);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function unpackTriangleCell(value) {
|
|
292
|
+
assert(Number.isInteger(value) && value >= 0 && value <= 15, "Invalid Triangle16 data cell.");
|
|
293
|
+
return { first: (value >>> 2) & 3, second: value & 3 };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function solidTriangleCell(color) {
|
|
297
|
+
return packTriangleCell(color, color);
|
|
298
|
+
}
|
|
299
|
+
|
|
212
300
|
function concatBytes(...arrays) {
|
|
213
301
|
const total = arrays.reduce((sum, item) => sum + item.length, 0);
|
|
214
302
|
const out = new Uint8Array(total);
|
|
@@ -251,10 +339,36 @@ function readU16be(bytes, offset = 0) {
|
|
|
251
339
|
|
|
252
340
|
function normalizeCompressionMode(value = "none") {
|
|
253
341
|
const key = String(value).toLowerCase();
|
|
254
|
-
assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or
|
|
342
|
+
assert(["none", "auto", "smart", "lz", "deflate", "brotli"].includes(key), "compression must be none, auto, smart, lz, deflate, or brotli.");
|
|
255
343
|
return key;
|
|
256
344
|
}
|
|
257
345
|
|
|
346
|
+
function normalizeExplicitCompressionLevel(mode, options = {}) {
|
|
347
|
+
const generic = options.compressionLevel;
|
|
348
|
+
if (mode === "lz") {
|
|
349
|
+
const value = Number(generic ?? options.lzLevel ?? DEFAULT_LZ_LEVEL);
|
|
350
|
+
assert(Number.isInteger(value) && value >= LZ_LEVEL_MIN && value <= LZ_LEVEL_MAX, `LZ compressionLevel must be ${LZ_LEVEL_MIN}..${LZ_LEVEL_MAX}.`);
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
if (mode === "deflate") {
|
|
354
|
+
const value = Number(generic ?? options.deflateLevel ?? DEFAULT_DEFLATE_LEVEL);
|
|
355
|
+
assert(Number.isInteger(value) && value >= DEFLATE_LEVEL_MIN && value <= DEFLATE_LEVEL_MAX, `DEFLATE compressionLevel must be ${DEFLATE_LEVEL_MIN}..${DEFLATE_LEVEL_MAX}.`);
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
if (mode === "brotli") {
|
|
359
|
+
const value = Number(generic ?? options.brotliQuality ?? DEFAULT_BROTLI_QUALITY);
|
|
360
|
+
assert(Number.isInteger(value) && value >= BROTLI_QUALITY_MIN && value <= BROTLI_QUALITY_MAX, `Brotli compressionLevel must be ${BROTLI_QUALITY_MIN}..${BROTLI_QUALITY_MAX}.`);
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
if (generic != null || options.lzLevel != null || options.deflateLevel != null || options.brotliQuality != null) {
|
|
364
|
+
assert(
|
|
365
|
+
mode === "auto" || mode === "smart",
|
|
366
|
+
"compressionLevel is only meaningful with compression: lz, compression: deflate, or compression: brotli."
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
258
372
|
function asBytes(input) {
|
|
259
373
|
if (input instanceof Uint8Array) return input;
|
|
260
374
|
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
@@ -262,10 +376,27 @@ function asBytes(input) {
|
|
|
262
376
|
return new Uint8Array(input);
|
|
263
377
|
}
|
|
264
378
|
|
|
265
|
-
/** Portable LZSS-style compressor used by QuadQR payload compression.
|
|
266
|
-
|
|
379
|
+
/** Portable LZSS-style compressor used by QuadQR payload compression.
|
|
380
|
+
* Levels 1..9 only change encoder search effort. The wire format is unchanged,
|
|
381
|
+
* so every level is decoded by the same legacy LZ decoder. Level 6 preserves
|
|
382
|
+
* the historical QuadQR search depth and is the default.
|
|
383
|
+
*/
|
|
384
|
+
export function compressPayload(input, options = {}) {
|
|
267
385
|
const bytes = asBytes(input);
|
|
268
|
-
|
|
386
|
+
const requestedLevel = typeof options === "number"
|
|
387
|
+
? options
|
|
388
|
+
: (options.level ?? options.compressionLevel ?? DEFAULT_LZ_LEVEL);
|
|
389
|
+
const level = Number(requestedLevel);
|
|
390
|
+
assert(Number.isInteger(level) && level >= LZ_LEVEL_MIN && level <= LZ_LEVEL_MAX, `LZ level must be ${LZ_LEVEL_MIN}..${LZ_LEVEL_MAX}.`);
|
|
391
|
+
|
|
392
|
+
// Level 6 deliberately matches the original compressor's 32-candidate
|
|
393
|
+
// history exactly. Higher levels walk deeper chains and add bounded lazy
|
|
394
|
+
// lookahead, while lower levels reduce CPU work for faster encoding.
|
|
395
|
+
const candidateDepth = [0, 4, 8, 12, 16, 24, 32, 48, 64, 96][level];
|
|
396
|
+
const lazyDepth = [0, 0, 0, 0, 0, 0, 0, 12, 24, 48][level];
|
|
397
|
+
const lazyGain = level >= 9 ? 0 : level >= 7 ? 1 : 2;
|
|
398
|
+
const historyLimit = Math.max(candidateDepth, lazyDepth, 4);
|
|
399
|
+
|
|
269
400
|
const out = [];
|
|
270
401
|
const recent = new Map();
|
|
271
402
|
let pos = 0;
|
|
@@ -277,38 +408,50 @@ export function compressPayload(input) {
|
|
|
277
408
|
let list = recent.get(key);
|
|
278
409
|
if (!list) recent.set(key, list = []);
|
|
279
410
|
list.push(i);
|
|
280
|
-
while (list.length >
|
|
411
|
+
while (list.length > historyLimit) list.shift();
|
|
281
412
|
while (list.length && i - list[0] > 4095) list.shift();
|
|
282
413
|
};
|
|
283
414
|
|
|
415
|
+
const findBest = (position, maxCandidates) => {
|
|
416
|
+
let bestLength = 0;
|
|
417
|
+
let bestOffset = 0;
|
|
418
|
+
const key = keyAt(position);
|
|
419
|
+
const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
|
|
420
|
+
for (let ci = candidates.length - 1, checked = 0; ci >= 0 && checked < maxCandidates; ci--, checked++) {
|
|
421
|
+
const candidate = candidates[ci];
|
|
422
|
+
const offset = position - candidate;
|
|
423
|
+
if (offset <= 0 || offset > 4095) continue;
|
|
424
|
+
let length = 0;
|
|
425
|
+
while (length < 18 && position + length < bytes.length && bytes[candidate + length] === bytes[position + length]) length++;
|
|
426
|
+
if (length >= 3 && length > bestLength) {
|
|
427
|
+
bestLength = length;
|
|
428
|
+
bestOffset = offset;
|
|
429
|
+
if (length === 18) break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return { length: bestLength, offset: bestOffset };
|
|
433
|
+
};
|
|
434
|
+
|
|
284
435
|
while (pos < bytes.length) {
|
|
285
436
|
const flagIndex = out.length;
|
|
286
437
|
out.push(0);
|
|
287
438
|
let flags = 0;
|
|
288
439
|
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
289
|
-
let
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
if (offset <= 0 || offset > 4095) continue;
|
|
297
|
-
let length = 0;
|
|
298
|
-
while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
|
|
299
|
-
if (length >= 3 && length > bestLength) {
|
|
300
|
-
bestLength = length;
|
|
301
|
-
bestOffset = offset;
|
|
302
|
-
if (length === 18) break;
|
|
303
|
-
}
|
|
440
|
+
let best = findBest(pos, candidateDepth);
|
|
441
|
+
|
|
442
|
+
// Strong levels may emit one literal when the next byte begins a
|
|
443
|
+
// meaningfully longer match. This changes encoder effort only.
|
|
444
|
+
if (best.length >= 3 && lazyDepth > 0 && pos + 1 < bytes.length) {
|
|
445
|
+
const next = findBest(pos + 1, lazyDepth);
|
|
446
|
+
if (next.length > best.length + lazyGain) best = { length: 0, offset: 0 };
|
|
304
447
|
}
|
|
305
448
|
|
|
306
|
-
if (
|
|
449
|
+
if (best.length >= 3) {
|
|
307
450
|
flags |= (1 << token);
|
|
308
|
-
const encoded = ((
|
|
451
|
+
const encoded = ((best.offset & 0x0fff) << 4) | ((best.length - 3) & 0x0f);
|
|
309
452
|
out.push((encoded >>> 8) & 0xff, encoded & 0xff);
|
|
310
|
-
for (let i = 0; i <
|
|
311
|
-
pos +=
|
|
453
|
+
for (let i = 0; i < best.length; i++) remember(pos + i);
|
|
454
|
+
pos += best.length;
|
|
312
455
|
} else {
|
|
313
456
|
out.push(bytes[pos]);
|
|
314
457
|
remember(pos);
|
|
@@ -344,14 +487,210 @@ export function decompressPayload(input, expectedLength = null) {
|
|
|
344
487
|
return Uint8Array.from(out);
|
|
345
488
|
}
|
|
346
489
|
|
|
347
|
-
|
|
490
|
+
/** Raw RFC 1951 fixed-Huffman DEFLATE helper used by Compression 3.0. */
|
|
491
|
+
export function compressDeflatePayload(input, options = {}) {
|
|
492
|
+
return compressDeflateBytes(asBytes(input), options);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Restore a raw DEFLATE payload produced by compressDeflatePayload(). */
|
|
496
|
+
export function decompressDeflatePayload(input, expectedLength = null) {
|
|
497
|
+
return decompressDeflateBytes(asBytes(input), expectedLength);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Portable Brotli helper used by Compression 3.0 in browsers and Node.js. */
|
|
501
|
+
export function compressBrotliPayload(input, options = {}) {
|
|
502
|
+
return compressBrotliBytes(asBytes(input), options);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Restore a Brotli payload produced by compressBrotliPayload(). */
|
|
506
|
+
export function decompressBrotliPayload(input, expectedLength = null) {
|
|
507
|
+
return decompressBrotliBytes(asBytes(input), expectedLength);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function makeCompressionCandidate(payload, compression, compressionLevel = null) {
|
|
511
|
+
if (compression === "none") return { body: payload, compression: "none", compressionLevel: null };
|
|
512
|
+
if (compression === "lz") {
|
|
513
|
+
return {
|
|
514
|
+
body: compressPayload(payload, { level: compressionLevel ?? DEFAULT_LZ_LEVEL }),
|
|
515
|
+
compression: "lz",
|
|
516
|
+
compressionLevel: compressionLevel ?? DEFAULT_LZ_LEVEL
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (compression === "deflate") {
|
|
520
|
+
return {
|
|
521
|
+
body: compressDeflatePayload(payload, { level: compressionLevel ?? DEFAULT_DEFLATE_LEVEL }),
|
|
522
|
+
compression: "deflate",
|
|
523
|
+
compressionLevel: compressionLevel ?? DEFAULT_DEFLATE_LEVEL
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
if (compression === "brotli") {
|
|
527
|
+
return {
|
|
528
|
+
body: compressBrotliPayload(payload, { quality: compressionLevel ?? DEFAULT_BROTLI_QUALITY }),
|
|
529
|
+
compression: "brotli",
|
|
530
|
+
compressionLevel: compressionLevel ?? DEFAULT_BROTLI_QUALITY
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
throw new Error(`Unsupported compression candidate ${compression}.`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function candidateFinalPayloadLength(candidate, payload, options = {}) {
|
|
537
|
+
const outerFixedBytes = Math.max(0, Number(options.outerFixedBytes ?? 0) || 0);
|
|
538
|
+
if (options.envelopeAlreadyRequired) {
|
|
539
|
+
const envelopeFixedBytes = Math.max(
|
|
540
|
+
PAYLOAD_ENVELOPE_HEADER_BYTES,
|
|
541
|
+
Number(options.envelopeFixedBytes ?? PAYLOAD_ENVELOPE_HEADER_BYTES) || PAYLOAD_ENVELOPE_HEADER_BYTES
|
|
542
|
+
);
|
|
543
|
+
return outerFixedBytes + envelopeFixedBytes + candidate.body.length;
|
|
544
|
+
}
|
|
545
|
+
const innerBytes = candidate.compression === "none"
|
|
546
|
+
? candidate.body.length
|
|
547
|
+
: PAYLOAD_ENVELOPE_HEADER_BYTES + candidate.body.length;
|
|
548
|
+
return outerFixedBytes + innerBytes;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function compareCompressionCandidates(a, b) {
|
|
552
|
+
if (a.finalLength !== b.finalLength) return a.finalLength - b.finalLength;
|
|
553
|
+
// Prefer the cheaper decoder when storage is exactly tied.
|
|
554
|
+
const rank = { none: 0, lz: 1, deflate: 2, brotli: 3 };
|
|
555
|
+
return (rank[a.compression] ?? 9) - (rank[b.compression] ?? 9);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function annotateCompressionCandidate(candidate, payload, options = {}) {
|
|
559
|
+
return { ...candidate, finalLength: candidateFinalPayloadLength(candidate, payload, options) };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function safeChooseCompressionVersion(payloadLength, versionOptions = {}) {
|
|
563
|
+
try {
|
|
564
|
+
return chooseVersion(payloadLength, versionOptions);
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function smartCompressionTarget(candidate, versionOptions = {}) {
|
|
571
|
+
const requested = versionOptions.version ?? "auto";
|
|
572
|
+
const minVersion = versionOptions.minVersion ?? MIN_VERSION;
|
|
573
|
+
const maxVersion = versionOptions.maxVersion ?? MAX_VERSION;
|
|
574
|
+
|
|
575
|
+
// A fixed version cannot become physically smaller, but Smart may still
|
|
576
|
+
// spend extra CPU when stronger compression is needed to make that exact
|
|
577
|
+
// requested version fit.
|
|
578
|
+
if (requested !== "auto") {
|
|
579
|
+
validateVersion(requested);
|
|
580
|
+
const info = getVersionInfo(requested, versionOptions);
|
|
581
|
+
if (candidate.finalLength <= info.capacityBytes) return null;
|
|
582
|
+
const gapBytes = candidate.finalLength - info.capacityBytes;
|
|
583
|
+
return {
|
|
584
|
+
currentVersion: null,
|
|
585
|
+
targetVersion: requested,
|
|
586
|
+
targetCapacityBytes: info.capacityBytes,
|
|
587
|
+
gapBytes,
|
|
588
|
+
gapRatio: candidate.finalLength > 0 ? gapBytes / candidate.finalLength : 0
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const currentVersion = safeChooseCompressionVersion(candidate.finalLength, versionOptions);
|
|
593
|
+
const targetVersion = currentVersion == null ? maxVersion : currentVersion - 1;
|
|
594
|
+
if (targetVersion < minVersion) return null;
|
|
595
|
+
const info = getVersionInfo(targetVersion, versionOptions);
|
|
596
|
+
const gapBytes = Math.max(0, candidate.finalLength - info.capacityBytes);
|
|
597
|
+
return {
|
|
598
|
+
currentVersion,
|
|
599
|
+
targetVersion,
|
|
600
|
+
targetCapacityBytes: info.capacityBytes,
|
|
601
|
+
gapBytes,
|
|
602
|
+
gapRatio: candidate.finalLength > 0 ? gapBytes / candidate.finalLength : 0
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function compressionCandidates(payload, options = {}) {
|
|
607
|
+
return [
|
|
608
|
+
makeCompressionCandidate(payload, "none"),
|
|
609
|
+
makeCompressionCandidate(payload, "lz", DEFAULT_LZ_LEVEL),
|
|
610
|
+
makeCompressionCandidate(payload, "deflate", DEFAULT_DEFLATE_LEVEL),
|
|
611
|
+
makeCompressionCandidate(payload, "brotli", 6)
|
|
612
|
+
].map((candidate) => annotateCompressionCandidate(candidate, payload, options));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function chooseBestCompressionCandidate(candidates) {
|
|
616
|
+
return candidates.slice().sort(compareCompressionCandidates)[0];
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function prepareSmartCompressedBody(payload, options = {}) {
|
|
620
|
+
const versionOptions = options.versionOptions ?? {};
|
|
621
|
+
const tried = [];
|
|
622
|
+
const seen = new Set();
|
|
623
|
+
const add = (compression, level = null) => {
|
|
624
|
+
const key = `${compression}:${level ?? ""}`;
|
|
625
|
+
if (seen.has(key)) return null;
|
|
626
|
+
seen.add(key);
|
|
627
|
+
const candidate = annotateCompressionCandidate(makeCompressionCandidate(payload, compression, level), payload, options);
|
|
628
|
+
tried.push(candidate);
|
|
629
|
+
return candidate;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
add("none");
|
|
633
|
+
add("lz", DEFAULT_LZ_LEVEL);
|
|
634
|
+
add("deflate", DEFAULT_DEFLATE_LEVEL);
|
|
635
|
+
add("brotli", 6);
|
|
636
|
+
let best = chooseBestCompressionCandidate(tried);
|
|
637
|
+
const initialVersion = safeChooseCompressionVersion(best.finalLength, versionOptions);
|
|
638
|
+
|
|
639
|
+
// Smart is intentionally CPU-heavy, but it still avoids maximum-quality
|
|
640
|
+
// passes when the next smaller matrix is far outside realistic reach.
|
|
641
|
+
let target = smartCompressionTarget(best, versionOptions);
|
|
642
|
+
if (target && (target.gapRatio <= 0.30 || target.gapBytes <= 192)) {
|
|
643
|
+
add("deflate", 8);
|
|
644
|
+
add("brotli", 9);
|
|
645
|
+
best = chooseBestCompressionCandidate(tried);
|
|
646
|
+
target = smartCompressionTarget(best, versionOptions);
|
|
647
|
+
|
|
648
|
+
// Maximum passes are reserved for a genuinely close version boundary, or
|
|
649
|
+
// when the strong pass already crossed one boundary and another is close.
|
|
650
|
+
if (target && (target.gapRatio <= 0.16 || target.gapBytes <= 96)) {
|
|
651
|
+
add("deflate", 9);
|
|
652
|
+
add("brotli", 11);
|
|
653
|
+
best = chooseBestCompressionCandidate(tried);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const finalVersion = safeChooseCompressionVersion(best.finalLength, versionOptions);
|
|
658
|
+
return {
|
|
659
|
+
...best,
|
|
660
|
+
compressionStrategy: "smart",
|
|
661
|
+
smartCompression: {
|
|
662
|
+
cpuHeavy: true,
|
|
663
|
+
initialVersion,
|
|
664
|
+
finalVersion,
|
|
665
|
+
levelsTried: tried
|
|
666
|
+
.filter((candidate) => candidate.compression === "deflate" || candidate.compression === "brotli")
|
|
667
|
+
.map((candidate) => ({ algorithm: candidate.compression, level: candidate.compressionLevel, bytes: candidate.body.length, finalBytes: candidate.finalLength }))
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function prepareCompressedBody(payload, compression = "none", options = {}) {
|
|
348
673
|
const mode = normalizeCompressionMode(compression);
|
|
349
|
-
if (mode === "none") return {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
return {
|
|
674
|
+
if (mode === "none") return { ...makeCompressionCandidate(payload, "none"), compressionStrategy: "none" };
|
|
675
|
+
if (mode === "lz") {
|
|
676
|
+
const level = normalizeExplicitCompressionLevel(mode, options);
|
|
677
|
+
return { ...makeCompressionCandidate(payload, "lz", level), compressionStrategy: "explicit" };
|
|
678
|
+
}
|
|
679
|
+
if (mode === "deflate") {
|
|
680
|
+
const level = normalizeExplicitCompressionLevel(mode, options);
|
|
681
|
+
return { ...makeCompressionCandidate(payload, "deflate", level), compressionStrategy: "explicit" };
|
|
353
682
|
}
|
|
354
|
-
|
|
683
|
+
if (mode === "brotli") {
|
|
684
|
+
const level = normalizeExplicitCompressionLevel(mode, options);
|
|
685
|
+
return { ...makeCompressionCandidate(payload, "brotli", level), compressionStrategy: "explicit" };
|
|
686
|
+
}
|
|
687
|
+
if (mode === "smart") return prepareSmartCompressedBody(payload, options);
|
|
688
|
+
|
|
689
|
+
// Auto deliberately performs one balanced pass per codec. It is the fast
|
|
690
|
+
// default; Smart is the opt-in mode that spends extra CPU near QR-version
|
|
691
|
+
// boundaries.
|
|
692
|
+
const best = chooseBestCompressionCandidate(compressionCandidates(payload, options));
|
|
693
|
+
return { ...best, compressionStrategy: "auto" };
|
|
355
694
|
}
|
|
356
695
|
|
|
357
696
|
|
|
@@ -385,9 +724,7 @@ function isPayloadEnvelope(input) {
|
|
|
385
724
|
return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
|
|
386
725
|
}
|
|
387
726
|
|
|
388
|
-
function
|
|
389
|
-
const payload = asBytes(input);
|
|
390
|
-
const prepared = prepareCompressedBody(payload, options.compression ?? "none");
|
|
727
|
+
function packPreparedPayloadEnvelope(payload, prepared) {
|
|
391
728
|
const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
|
|
392
729
|
const header = makePayloadEnvelopeHeader({
|
|
393
730
|
compression: prepared.compression,
|
|
@@ -397,11 +734,17 @@ function packPayloadEnvelope(input, options = {}) {
|
|
|
397
734
|
return concatBytes(header, prepared.body);
|
|
398
735
|
}
|
|
399
736
|
|
|
737
|
+
function packPayloadEnvelope(input, options = {}) {
|
|
738
|
+
const payload = asBytes(input);
|
|
739
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "none", options);
|
|
740
|
+
return packPreparedPayloadEnvelope(payload, prepared);
|
|
741
|
+
}
|
|
742
|
+
|
|
400
743
|
function parsePayloadEnvelope(input) {
|
|
401
744
|
const container = asBytes(input);
|
|
402
745
|
assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
|
|
403
746
|
const version = container[4];
|
|
404
|
-
assert(version
|
|
747
|
+
assert(version >= 1 && version <= PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
|
|
405
748
|
const flags = container[5];
|
|
406
749
|
const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
|
|
407
750
|
assert(compression != null, `Unknown payload compression id ${container[6]}.`);
|
|
@@ -419,7 +762,13 @@ function parsePayloadEnvelope(input) {
|
|
|
419
762
|
const publicKey = container.slice(endMetadata, endPublicKey);
|
|
420
763
|
const signature = container.slice(endPublicKey, endSignature);
|
|
421
764
|
const stored = container.slice(endSignature);
|
|
422
|
-
const payload = compression === "lz"
|
|
765
|
+
const payload = compression === "lz"
|
|
766
|
+
? decompressPayload(stored, originalLength)
|
|
767
|
+
: compression === "deflate"
|
|
768
|
+
? decompressDeflatePayload(stored, originalLength)
|
|
769
|
+
: compression === "brotli"
|
|
770
|
+
? decompressBrotliPayload(stored, originalLength)
|
|
771
|
+
: stored;
|
|
423
772
|
assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
|
|
424
773
|
const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
|
|
425
774
|
const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
|
|
@@ -508,7 +857,6 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
508
857
|
const payload = asBytes(input);
|
|
509
858
|
assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
|
|
510
859
|
const subtle = subtleCrypto();
|
|
511
|
-
const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
|
|
512
860
|
const keyIdBytes = normalizeSigningKeyId(options.keyId);
|
|
513
861
|
|
|
514
862
|
let publicKeyBytes = new Uint8Array(0);
|
|
@@ -518,6 +866,14 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
518
866
|
publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
|
|
519
867
|
}
|
|
520
868
|
|
|
869
|
+
const signedEnvelopeFixedBytes = PAYLOAD_ENVELOPE_HEADER_BYTES + keyIdBytes.length + publicKeyBytes.length + 64;
|
|
870
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "auto", {
|
|
871
|
+
...options,
|
|
872
|
+
envelopeAlreadyRequired: true,
|
|
873
|
+
envelopeFixedBytes: signedEnvelopeFixedBytes,
|
|
874
|
+
versionOptions: options.versionOptions ?? options
|
|
875
|
+
});
|
|
876
|
+
|
|
521
877
|
const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
|
|
522
878
|
(prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
|
|
523
879
|
(publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
|
|
@@ -534,7 +890,13 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
534
890
|
const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
|
|
535
891
|
const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
|
|
536
892
|
assert(signature.length === 64, "Unexpected Ed25519 signature length.");
|
|
537
|
-
return
|
|
893
|
+
return {
|
|
894
|
+
envelope: concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body),
|
|
895
|
+
compression: prepared.compression,
|
|
896
|
+
compressionLevel: prepared.compressionLevel ?? null,
|
|
897
|
+
compressionStrategy: prepared.compressionStrategy ?? null,
|
|
898
|
+
smartCompression: prepared.smartCompression ?? null
|
|
899
|
+
};
|
|
538
900
|
}
|
|
539
901
|
|
|
540
902
|
function lookupTrustedSigningKey(trustedKeys, keyId) {
|
|
@@ -554,7 +916,7 @@ async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
|
554
916
|
let trustSource = trusted ? "external" : null;
|
|
555
917
|
|
|
556
918
|
// Legacy v1 symbols embedded the public key by default, so preserve their
|
|
557
|
-
// historical self-verification behavior. v2
|
|
919
|
+
// historical self-verification behavior. v2+ require explicit opt-in.
|
|
558
920
|
if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
|
|
559
921
|
trusted = parsed.publicKey;
|
|
560
922
|
trustSource = "embedded";
|
|
@@ -585,19 +947,31 @@ async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
|
585
947
|
};
|
|
586
948
|
}
|
|
587
949
|
|
|
588
|
-
function prepareOptionalCompressedPayload(input, compression = "none") {
|
|
950
|
+
function prepareOptionalCompressedPayload(input, compression = "none", options = {}) {
|
|
589
951
|
const payload = asBytes(input);
|
|
590
952
|
const mode = normalizeCompressionMode(compression);
|
|
591
|
-
if (mode === "none")
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
953
|
+
if (mode === "none") {
|
|
954
|
+
return { payload, extended: false, compression: "none", compressionLevel: null, compressionStrategy: "none", smartCompression: null };
|
|
955
|
+
}
|
|
956
|
+
const prepared = prepareCompressedBody(payload, mode, { ...options, versionOptions: options.versionOptions ?? options });
|
|
957
|
+
// Auto and Smart are truly zero-overhead when compression does not help.
|
|
958
|
+
if ((mode === "auto" || mode === "smart") && prepared.compression === "none") {
|
|
959
|
+
return {
|
|
960
|
+
payload,
|
|
961
|
+
extended: false,
|
|
962
|
+
compression: "none",
|
|
963
|
+
compressionLevel: null,
|
|
964
|
+
compressionStrategy: prepared.compressionStrategy,
|
|
965
|
+
smartCompression: prepared.smartCompression ?? null
|
|
966
|
+
};
|
|
596
967
|
}
|
|
597
968
|
return {
|
|
598
|
-
payload:
|
|
969
|
+
payload: packPreparedPayloadEnvelope(payload, prepared),
|
|
599
970
|
extended: true,
|
|
600
|
-
compression: prepared.compression
|
|
971
|
+
compression: prepared.compression,
|
|
972
|
+
compressionLevel: prepared.compressionLevel ?? null,
|
|
973
|
+
compressionStrategy: prepared.compressionStrategy ?? mode,
|
|
974
|
+
smartCompression: prepared.smartCompression ?? null
|
|
601
975
|
};
|
|
602
976
|
}
|
|
603
977
|
|
|
@@ -636,8 +1010,19 @@ export function crc32(bytes) {
|
|
|
636
1010
|
return (crc ^ 0xffffffff) >>> 0;
|
|
637
1011
|
}
|
|
638
1012
|
|
|
639
|
-
function bytesToCells(bytes) {
|
|
640
|
-
const
|
|
1013
|
+
function bytesToCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1014
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1015
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
1016
|
+
const out = new Array(bytes.length * TRIANGLE16_CELLS_PER_BYTE);
|
|
1017
|
+
let cursor = 0;
|
|
1018
|
+
for (const byte of bytes) {
|
|
1019
|
+
out[cursor++] = (byte >>> 4) & 0x0f;
|
|
1020
|
+
out[cursor++] = byte & 0x0f;
|
|
1021
|
+
}
|
|
1022
|
+
return out;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const out = new Array(bytes.length * RGBW_CELLS_PER_BYTE);
|
|
641
1026
|
let cursor = 0;
|
|
642
1027
|
for (const byte of bytes) {
|
|
643
1028
|
out[cursor++] = (byte >>> 6) & 0b11;
|
|
@@ -648,11 +1033,36 @@ function bytesToCells(bytes) {
|
|
|
648
1033
|
return out;
|
|
649
1034
|
}
|
|
650
1035
|
|
|
651
|
-
function
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1036
|
+
function bytesToProtectedHeaderCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1037
|
+
const base = bytesToCells(bytes, CELL_ENCODINGS.RGBW);
|
|
1038
|
+
if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) return base;
|
|
1039
|
+
// Triangle16 deliberately keeps the protected bootstrap/header as solid-color
|
|
1040
|
+
// modules. This costs a few cells but makes mode detection and damaged-camera
|
|
1041
|
+
// recovery substantially more reliable.
|
|
1042
|
+
return base.map(solidTriangleCell);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function cellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1046
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1047
|
+
const cellsPerByte = cellsPerByteForEncoding(encoding);
|
|
1048
|
+
const count = byteCount ?? Math.floor(cells.length / cellsPerByte);
|
|
1049
|
+
assert(cells.length >= count * cellsPerByte, "Not enough data cells to rebuild bytes.");
|
|
1050
|
+
const out = new Uint8Array(count);
|
|
1051
|
+
|
|
1052
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
1053
|
+
for (let i = 0; i < count; i++) {
|
|
1054
|
+
const offset = i * TRIANGLE16_CELLS_PER_BYTE;
|
|
1055
|
+
const a = cells[offset];
|
|
1056
|
+
const b = cells[offset + 1];
|
|
1057
|
+
assert(Number.isInteger(a) && a >= 0 && a <= 15, "Invalid Triangle16 data cell.");
|
|
1058
|
+
assert(Number.isInteger(b) && b >= 0 && b <= 15, "Invalid Triangle16 data cell.");
|
|
1059
|
+
out[i] = (a << 4) | b;
|
|
1060
|
+
}
|
|
1061
|
+
return out;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
for (let i = 0; i < count; i++) {
|
|
1065
|
+
const offset = i * RGBW_CELLS_PER_BYTE;
|
|
656
1066
|
const a = cells[offset];
|
|
657
1067
|
const b = cells[offset + 1];
|
|
658
1068
|
const c = cells[offset + 2];
|
|
@@ -665,14 +1075,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
|
|
|
665
1075
|
return out;
|
|
666
1076
|
}
|
|
667
1077
|
|
|
668
|
-
function
|
|
1078
|
+
function protectedHeaderCellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1079
|
+
if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) {
|
|
1080
|
+
return cellsToBytes(cells, byteCount, CELL_ENCODINGS.RGBW);
|
|
1081
|
+
}
|
|
1082
|
+
const colors = cells.map((value) => {
|
|
1083
|
+
const pair = unpackTriangleCell(value);
|
|
1084
|
+
if (pair.first !== pair.second) throw new Error("Triangle16 protected header lost solid-color integrity.");
|
|
1085
|
+
return pair.first;
|
|
1086
|
+
});
|
|
1087
|
+
return cellsToBytes(colors, byteCount, CELL_ENCODINGS.RGBW);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function flagsFor(text, eccLevel, secure = false, extended = false, signed = false, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
669
1091
|
return (text ? TEXT_FLAG : 0) |
|
|
670
1092
|
(secure ? SECURE_FLAG : 0) |
|
|
671
1093
|
(extended ? EXTENDED_PAYLOAD_FLAG : 0) |
|
|
672
1094
|
(signed ? SIGNED_FLAG : 0) |
|
|
1095
|
+
(normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
|
|
673
1096
|
((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
|
|
674
1097
|
}
|
|
675
1098
|
|
|
1099
|
+
function cellEncodingFromFlags(flags) {
|
|
1100
|
+
return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
676
1103
|
function eccFromFlags(flags) {
|
|
677
1104
|
const id = (flags & ECC_MASK) >> ECC_SHIFT;
|
|
678
1105
|
const name = ECC_BY_ID[id];
|
|
@@ -922,7 +1349,8 @@ function maskValue(row, col, maskId) {
|
|
|
922
1349
|
}
|
|
923
1350
|
}
|
|
924
1351
|
|
|
925
|
-
function makePaddingCells(count, seed) {
|
|
1352
|
+
function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1353
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
926
1354
|
let state = (seed >>> 0) || 0x6d2b79f5;
|
|
927
1355
|
const out = new Array(count);
|
|
928
1356
|
for (let i = 0; i < count; i++) {
|
|
@@ -930,11 +1358,27 @@ function makePaddingCells(count, seed) {
|
|
|
930
1358
|
state ^= state >>> 17;
|
|
931
1359
|
state ^= state << 5;
|
|
932
1360
|
state >>>= 0;
|
|
933
|
-
out[i] = state & 3;
|
|
1361
|
+
out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
|
|
934
1362
|
}
|
|
935
1363
|
return out;
|
|
936
1364
|
}
|
|
937
1365
|
|
|
1366
|
+
function triangleMaskValue(row, col, maskId) {
|
|
1367
|
+
const first = maskValue(row, col, maskId);
|
|
1368
|
+
const second = maskValue(row + 1, col + 2, (maskId + 1) & 3);
|
|
1369
|
+
return packTriangleCell(first, second);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function applyCellMask(value, row, col, maskId, cellEncoding, protectedHeader = false) {
|
|
1373
|
+
if (cellEncoding !== CELL_ENCODINGS.TRIANGLE16) return value ^ maskValue(row, col, maskId);
|
|
1374
|
+
if (protectedHeader) {
|
|
1375
|
+
const pair = unpackTriangleCell(value);
|
|
1376
|
+
if (pair.first !== pair.second) throw new Error("Triangle16 protected header must use solid-color cells.");
|
|
1377
|
+
return solidTriangleCell(pair.first ^ maskValue(row, col, maskId));
|
|
1378
|
+
}
|
|
1379
|
+
return value ^ triangleMaskValue(row, col, maskId);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
938
1382
|
const SPECTRAL_PERMUTATION_CACHE = new Map();
|
|
939
1383
|
|
|
940
1384
|
function spectralPermutation(length, version) {
|
|
@@ -963,7 +1407,15 @@ function spectralPermutation(length, version) {
|
|
|
963
1407
|
return permutation;
|
|
964
1408
|
}
|
|
965
1409
|
|
|
966
|
-
function applyData(
|
|
1410
|
+
function applyData(
|
|
1411
|
+
layout,
|
|
1412
|
+
rawCells,
|
|
1413
|
+
maskId,
|
|
1414
|
+
spectralInterleaving = true,
|
|
1415
|
+
cellEncoding = CELL_ENCODINGS.RGBW,
|
|
1416
|
+
protectedHeaderCells = getHeaderPlan(layout.version).codewordCells
|
|
1417
|
+
) {
|
|
1418
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
967
1419
|
const matrix = cloneMatrix(layout.matrix);
|
|
968
1420
|
const permutation = spectralInterleaving
|
|
969
1421
|
? spectralPermutation(layout.dataPositions.length, layout.version)
|
|
@@ -972,16 +1424,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
|
|
|
972
1424
|
for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
|
|
973
1425
|
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
974
1426
|
const [row, col] = layout.dataPositions[physicalIndex];
|
|
975
|
-
matrix[row][col] =
|
|
1427
|
+
matrix[row][col] = applyCellMask(
|
|
1428
|
+
rawCells[logicalIndex],
|
|
1429
|
+
row,
|
|
1430
|
+
col,
|
|
1431
|
+
maskId,
|
|
1432
|
+
encoding,
|
|
1433
|
+
encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < protectedHeaderCells
|
|
1434
|
+
);
|
|
976
1435
|
}
|
|
977
1436
|
return matrix;
|
|
978
1437
|
}
|
|
979
1438
|
|
|
980
|
-
function
|
|
1439
|
+
function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1440
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
981
1441
|
const size = matrix.length;
|
|
982
1442
|
let penalty = 0;
|
|
983
|
-
const
|
|
984
|
-
let
|
|
1443
|
+
const colorCounts = [0, 0, 0, 0];
|
|
1444
|
+
let colorCount = 0;
|
|
1445
|
+
|
|
1446
|
+
const symbolAt = (r, c) => matrix[r][c];
|
|
1447
|
+
const addColors = (value) => {
|
|
1448
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
1449
|
+
const pair = unpackTriangleCell(value);
|
|
1450
|
+
colorCounts[pair.first]++;
|
|
1451
|
+
colorCounts[pair.second]++;
|
|
1452
|
+
colorCount += 2;
|
|
1453
|
+
} else {
|
|
1454
|
+
colorCounts[value]++;
|
|
1455
|
+
colorCount++;
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
985
1458
|
|
|
986
1459
|
for (let r = 0; r < size; r++) {
|
|
987
1460
|
let previous = null;
|
|
@@ -992,9 +1465,8 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
992
1465
|
run = 0;
|
|
993
1466
|
continue;
|
|
994
1467
|
}
|
|
995
|
-
const value =
|
|
996
|
-
|
|
997
|
-
dataCount++;
|
|
1468
|
+
const value = symbolAt(r, c);
|
|
1469
|
+
addColors(value);
|
|
998
1470
|
if (value === previous) {
|
|
999
1471
|
run++;
|
|
1000
1472
|
if (run >= 4) penalty += 2;
|
|
@@ -1014,7 +1486,7 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
1014
1486
|
run = 0;
|
|
1015
1487
|
continue;
|
|
1016
1488
|
}
|
|
1017
|
-
const value =
|
|
1489
|
+
const value = symbolAt(r, c);
|
|
1018
1490
|
if (value === previous) {
|
|
1019
1491
|
run++;
|
|
1020
1492
|
if (run >= 4) penalty += 2;
|
|
@@ -1025,9 +1497,9 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
1025
1497
|
}
|
|
1026
1498
|
}
|
|
1027
1499
|
|
|
1028
|
-
if (
|
|
1029
|
-
const ideal =
|
|
1030
|
-
penalty +=
|
|
1500
|
+
if (colorCount > 0) {
|
|
1501
|
+
const ideal = colorCount / 4;
|
|
1502
|
+
penalty += colorCounts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
|
|
1031
1503
|
}
|
|
1032
1504
|
return penalty;
|
|
1033
1505
|
}
|
|
@@ -1056,7 +1528,7 @@ function deinterleaveBlocks(stream, blockLengths) {
|
|
|
1056
1528
|
return blocks;
|
|
1057
1529
|
}
|
|
1058
1530
|
|
|
1059
|
-
function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
1531
|
+
function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1060
1532
|
const ecc = getEffectiveEcc(version, eccLevel);
|
|
1061
1533
|
const bodyByteCount = payloadLength + CRC_BYTES;
|
|
1062
1534
|
const dataSymbols = bodyByteCount;
|
|
@@ -1078,25 +1550,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
|
1078
1550
|
dataBlockLengths,
|
|
1079
1551
|
codewordBlockLengths,
|
|
1080
1552
|
encodedSymbols,
|
|
1081
|
-
encodedCells: encodedSymbols *
|
|
1553
|
+
encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
|
|
1082
1554
|
};
|
|
1083
1555
|
}
|
|
1084
1556
|
|
|
1085
|
-
function streamCellCount(payloadLength, eccLevel, version) {
|
|
1086
|
-
return getHeaderPlan(version).codewordCells +
|
|
1557
|
+
function streamCellCount(payloadLength, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1558
|
+
return getHeaderPlan(version).codewordCells +
|
|
1559
|
+
getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding).encodedCells;
|
|
1087
1560
|
}
|
|
1088
1561
|
|
|
1089
|
-
function streamFitsLayout(layout, eccLevel, payloadLength, version) {
|
|
1090
|
-
return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
|
|
1562
|
+
function streamFitsLayout(layout, eccLevel, payloadLength, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1563
|
+
return streamCellCount(payloadLength, eccLevel, version, cellEncoding) <= layout.dataPositions.length;
|
|
1091
1564
|
}
|
|
1092
1565
|
|
|
1093
|
-
function getCapacityForLayout(layout, eccLevel, version) {
|
|
1094
|
-
|
|
1566
|
+
function getCapacityForLayout(layout, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1567
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1568
|
+
if (!streamFitsLayout(layout, eccLevel, 0, version, encoding)) return 0;
|
|
1095
1569
|
let low = 0;
|
|
1096
|
-
let high = Math.floor(layout.dataPositions.length /
|
|
1570
|
+
let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
|
|
1097
1571
|
while (low < high) {
|
|
1098
1572
|
const mid = Math.ceil((low + high) / 2);
|
|
1099
|
-
if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
|
|
1573
|
+
if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
|
|
1100
1574
|
else high = mid - 1;
|
|
1101
1575
|
}
|
|
1102
1576
|
return low;
|
|
@@ -1105,6 +1579,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
|
|
|
1105
1579
|
export function getVersionInfo(version, options = {}) {
|
|
1106
1580
|
validateVersion(version);
|
|
1107
1581
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
1582
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
1108
1583
|
const layout = createLayout(version);
|
|
1109
1584
|
const headerPlan = getHeaderPlan(version);
|
|
1110
1585
|
const effectiveEcc = getEffectiveEcc(version, eccLevel);
|
|
@@ -1114,8 +1589,8 @@ export function getVersionInfo(version, options = {}) {
|
|
|
1114
1589
|
eccLevel,
|
|
1115
1590
|
size: layout.size,
|
|
1116
1591
|
dataCells: layout.dataPositions.length,
|
|
1117
|
-
theoreticalBits: layout.dataPositions.length * 2,
|
|
1118
|
-
capacityBytes: getCapacityForLayout(layout, eccLevel, version),
|
|
1592
|
+
theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
|
|
1593
|
+
capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
|
|
1119
1594
|
headerCells: headerPlan.codewordCells,
|
|
1120
1595
|
headerBytes: headerPlan.headerBytes,
|
|
1121
1596
|
headerParitySymbols: headerPlan.paritySymbols,
|
|
@@ -1127,8 +1602,11 @@ export function getVersionInfo(version, options = {}) {
|
|
|
1127
1602
|
hasAlignmentMarker: true,
|
|
1128
1603
|
alignmentPatterns: layout.alignments.length,
|
|
1129
1604
|
alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
|
|
1130
|
-
bitsPerDataCell: 2,
|
|
1605
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
1606
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
1607
|
+
cellEncoding,
|
|
1131
1608
|
colors: 4,
|
|
1609
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
1132
1610
|
spectralInterleaving: true,
|
|
1133
1611
|
confidenceAwareEcc: true
|
|
1134
1612
|
};
|
|
@@ -1139,6 +1617,7 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
1139
1617
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
1140
1618
|
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
1141
1619
|
const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
1620
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
1142
1621
|
|
|
1143
1622
|
validateVersion(minVersion);
|
|
1144
1623
|
validateVersion(maxVersion);
|
|
@@ -1147,10 +1626,10 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
1147
1626
|
if (requested !== "auto") {
|
|
1148
1627
|
validateVersion(requested);
|
|
1149
1628
|
assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
|
|
1150
|
-
const info = getVersionInfo(requested, { ecc });
|
|
1629
|
+
const info = getVersionInfo(requested, { ecc, cellEncoding });
|
|
1151
1630
|
const layout = createLayout(requested);
|
|
1152
1631
|
assert(
|
|
1153
|
-
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
|
|
1632
|
+
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
|
|
1154
1633
|
`Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
|
|
1155
1634
|
);
|
|
1156
1635
|
return requested;
|
|
@@ -1158,20 +1637,20 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
1158
1637
|
|
|
1159
1638
|
for (let version = minVersion; version <= maxVersion; version++) {
|
|
1160
1639
|
const layout = createLayout(version);
|
|
1161
|
-
if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
|
|
1640
|
+
if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
|
|
1162
1641
|
}
|
|
1163
1642
|
throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
|
|
1164
1643
|
}
|
|
1165
1644
|
|
|
1166
|
-
function encodeProtectedHeader(header, version) {
|
|
1645
|
+
function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1167
1646
|
const plan = getHeaderPlan(version);
|
|
1168
1647
|
const codeword = rsEncode(Array.from(header), plan.paritySymbols);
|
|
1169
1648
|
assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
|
|
1170
|
-
return
|
|
1649
|
+
return bytesToProtectedHeaderCells(codeword, cellEncoding);
|
|
1171
1650
|
}
|
|
1172
1651
|
|
|
1173
|
-
function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
1174
|
-
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
|
|
1652
|
+
function encodeProtectedBody(bodyBytes, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1653
|
+
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version, cellEncoding);
|
|
1175
1654
|
assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
|
|
1176
1655
|
|
|
1177
1656
|
const blocks = [];
|
|
@@ -1180,7 +1659,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
|
1180
1659
|
blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
|
|
1181
1660
|
offset += length;
|
|
1182
1661
|
}
|
|
1183
|
-
return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
|
|
1662
|
+
return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
|
|
1184
1663
|
}
|
|
1185
1664
|
|
|
1186
1665
|
function finalizeMatrix(layout, rawCells, meta) {
|
|
@@ -1188,9 +1667,10 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1188
1667
|
let bestMatrix = null;
|
|
1189
1668
|
let bestPenalty = Infinity;
|
|
1190
1669
|
|
|
1670
|
+
const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
|
|
1191
1671
|
for (let maskId = 0; maskId < 4; maskId++) {
|
|
1192
|
-
const candidate = applyData(layout, rawCells, maskId, true);
|
|
1193
|
-
const penalty =
|
|
1672
|
+
const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
|
|
1673
|
+
const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
|
|
1194
1674
|
if (penalty < bestPenalty) {
|
|
1195
1675
|
bestPenalty = penalty;
|
|
1196
1676
|
bestMaskId = maskId;
|
|
@@ -1198,7 +1678,7 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1198
1678
|
}
|
|
1199
1679
|
}
|
|
1200
1680
|
|
|
1201
|
-
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
|
|
1681
|
+
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
|
|
1202
1682
|
return {
|
|
1203
1683
|
format: "QuadQR",
|
|
1204
1684
|
formatVersion: FORMAT_VERSION,
|
|
@@ -1211,6 +1691,9 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1211
1691
|
secure: Boolean(meta.secure),
|
|
1212
1692
|
compressed: meta.compression != null && meta.compression !== "none",
|
|
1213
1693
|
compression: meta.compression ?? "none",
|
|
1694
|
+
compressionLevel: meta.compressionLevel ?? null,
|
|
1695
|
+
compressionStrategy: meta.compressionStrategy ?? null,
|
|
1696
|
+
smartCompression: meta.smartCompression ?? null,
|
|
1214
1697
|
signed: Boolean(meta.signed),
|
|
1215
1698
|
signingKeyId: meta.signingKeyId ?? null,
|
|
1216
1699
|
hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
|
|
@@ -1221,7 +1704,10 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1221
1704
|
capacityBytes: info.capacityBytes,
|
|
1222
1705
|
alignmentPatterns: layout.alignments.length,
|
|
1223
1706
|
utilization: meta.meaningfulCells / layout.dataPositions.length,
|
|
1224
|
-
bitsPerDataCell: 2,
|
|
1707
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
1708
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
1709
|
+
cellEncoding,
|
|
1710
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
1225
1711
|
eccLevel: meta.eccLevel,
|
|
1226
1712
|
eccParitySymbols: meta.eccParitySymbols,
|
|
1227
1713
|
eccBlocks: meta.eccBlocks,
|
|
@@ -1242,29 +1728,35 @@ function encodePreparedBytes(input, options = {}) {
|
|
|
1242
1728
|
const secure = Boolean(options.secure);
|
|
1243
1729
|
const extended = Boolean(options.extended);
|
|
1244
1730
|
const signed = Boolean(options.signed);
|
|
1245
|
-
const
|
|
1246
|
-
const
|
|
1731
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
1732
|
+
const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed, cellEncoding);
|
|
1733
|
+
const version = chooseVersion(payload.length, { ...options, ecc: eccLevel, cellEncoding });
|
|
1247
1734
|
const layout = createLayout(version);
|
|
1248
1735
|
const header = makeHeader(payload.length, flags, version);
|
|
1249
1736
|
const crc = crc32(concatBytes(header, payload));
|
|
1250
|
-
const headerCells = encodeProtectedHeader(header, version);
|
|
1251
|
-
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
|
|
1737
|
+
const headerCells = encodeProtectedHeader(header, version, cellEncoding);
|
|
1738
|
+
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version, cellEncoding);
|
|
1252
1739
|
const meaningfulCells = headerCells.concat(bodyEncoded.cells);
|
|
1253
1740
|
assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
|
|
1254
1741
|
|
|
1255
1742
|
const padding = makePaddingCells(
|
|
1256
1743
|
layout.dataPositions.length - meaningfulCells.length,
|
|
1257
|
-
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
|
|
1744
|
+
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
|
|
1745
|
+
cellEncoding
|
|
1258
1746
|
);
|
|
1259
1747
|
|
|
1260
1748
|
return finalizeMatrix(layout, meaningfulCells.concat(padding), {
|
|
1261
1749
|
version,
|
|
1750
|
+
cellEncoding,
|
|
1262
1751
|
payloadBytes: payload.length,
|
|
1263
1752
|
sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
|
|
1264
1753
|
secure,
|
|
1265
1754
|
extended,
|
|
1266
1755
|
signed,
|
|
1267
1756
|
compression: options.compressionMetadata ?? "none",
|
|
1757
|
+
compressionLevel: options.compressionLevelMetadata ?? null,
|
|
1758
|
+
compressionStrategy: options.compressionStrategyMetadata ?? null,
|
|
1759
|
+
smartCompression: options.smartCompressionMetadata ?? null,
|
|
1268
1760
|
signingKeyId: options.signingKeyId ?? null,
|
|
1269
1761
|
hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
|
|
1270
1762
|
security: options.securityMetadata ?? null,
|
|
@@ -1280,25 +1772,31 @@ function encodePreparedBytes(input, options = {}) {
|
|
|
1280
1772
|
export function encodeText(text, options = {}) {
|
|
1281
1773
|
assert(typeof text === "string", "encodeText expects a string.");
|
|
1282
1774
|
const source = getTextEncoder().encode(text);
|
|
1283
|
-
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1775
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none", options);
|
|
1284
1776
|
return encodePreparedBytes(prepared.payload, {
|
|
1285
1777
|
...options,
|
|
1286
1778
|
text: true,
|
|
1287
1779
|
extended: prepared.extended,
|
|
1288
1780
|
sourcePayloadBytes: source.length,
|
|
1289
|
-
compressionMetadata: prepared.compression
|
|
1781
|
+
compressionMetadata: prepared.compression,
|
|
1782
|
+
compressionLevelMetadata: prepared.compressionLevel,
|
|
1783
|
+
compressionStrategyMetadata: prepared.compressionStrategy,
|
|
1784
|
+
smartCompressionMetadata: prepared.smartCompression
|
|
1290
1785
|
});
|
|
1291
1786
|
}
|
|
1292
1787
|
|
|
1293
1788
|
export function encodeBytes(input, options = {}) {
|
|
1294
1789
|
const source = asBytes(input);
|
|
1295
|
-
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1790
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none", options);
|
|
1296
1791
|
return encodePreparedBytes(prepared.payload, {
|
|
1297
1792
|
...options,
|
|
1298
1793
|
text: Boolean(options.text),
|
|
1299
1794
|
extended: prepared.extended,
|
|
1300
1795
|
sourcePayloadBytes: source.length,
|
|
1301
|
-
compressionMetadata: prepared.compression
|
|
1796
|
+
compressionMetadata: prepared.compression,
|
|
1797
|
+
compressionLevelMetadata: prepared.compressionLevel,
|
|
1798
|
+
compressionStrategyMetadata: prepared.compressionStrategy,
|
|
1799
|
+
smartCompressionMetadata: prepared.smartCompression
|
|
1302
1800
|
});
|
|
1303
1801
|
}
|
|
1304
1802
|
|
|
@@ -1310,15 +1808,18 @@ export function encodeUint8Array(input, options = {}) {
|
|
|
1310
1808
|
/** Encode and sign a normal payload using an internal Ed25519 envelope. */
|
|
1311
1809
|
export async function encodeSignedBytes(input, options = {}) {
|
|
1312
1810
|
const source = asBytes(input);
|
|
1313
|
-
const
|
|
1314
|
-
const parsed = parsePayloadEnvelope(envelope);
|
|
1315
|
-
return encodePreparedBytes(envelope, {
|
|
1811
|
+
const packed = await packSignedPayloadEnvelope(source, options);
|
|
1812
|
+
const parsed = parsePayloadEnvelope(packed.envelope);
|
|
1813
|
+
return encodePreparedBytes(packed.envelope, {
|
|
1316
1814
|
...options,
|
|
1317
1815
|
text: Boolean(options.text),
|
|
1318
1816
|
extended: true,
|
|
1319
1817
|
signed: true,
|
|
1320
1818
|
sourcePayloadBytes: source.length,
|
|
1321
1819
|
compressionMetadata: parsed.compression,
|
|
1820
|
+
compressionLevelMetadata: packed.compressionLevel,
|
|
1821
|
+
compressionStrategyMetadata: packed.compressionStrategy,
|
|
1822
|
+
smartCompressionMetadata: packed.smartCompression,
|
|
1322
1823
|
signingKeyId: parsed.keyId,
|
|
1323
1824
|
hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
|
|
1324
1825
|
});
|
|
@@ -1343,30 +1844,47 @@ export async function encodeSecureText(text, options = {}) {
|
|
|
1343
1844
|
export async function encodeSecureBytes(input, options = {}) {
|
|
1344
1845
|
const sourcePayload = asBytes(input);
|
|
1345
1846
|
const security = options.security ?? {};
|
|
1847
|
+
const secureOuterFixedBytes = estimateSecureEnvelopeOverhead(security);
|
|
1346
1848
|
let protectedPayload = sourcePayload;
|
|
1347
1849
|
let extended = false;
|
|
1348
1850
|
let signed = false;
|
|
1349
1851
|
let compression = "none";
|
|
1852
|
+
let compressionLevel = null;
|
|
1853
|
+
let compressionStrategy = null;
|
|
1854
|
+
let smartCompression = null;
|
|
1350
1855
|
let signingKeyId = null;
|
|
1351
1856
|
let hasEmbeddedPublicKey = false;
|
|
1352
1857
|
|
|
1353
1858
|
if (options.signing) {
|
|
1354
|
-
|
|
1859
|
+
const packed = await packSignedPayloadEnvelope(sourcePayload, {
|
|
1355
1860
|
...options,
|
|
1356
1861
|
...options.signing,
|
|
1357
|
-
compression: options.compression ?? "auto"
|
|
1862
|
+
compression: options.compression ?? "auto",
|
|
1863
|
+
outerFixedBytes: secureOuterFixedBytes,
|
|
1864
|
+
versionOptions: options
|
|
1358
1865
|
});
|
|
1866
|
+
protectedPayload = packed.envelope;
|
|
1359
1867
|
const parsed = parsePayloadEnvelope(protectedPayload);
|
|
1360
1868
|
extended = true;
|
|
1361
1869
|
signed = true;
|
|
1362
1870
|
compression = parsed.compression;
|
|
1871
|
+
compressionLevel = packed.compressionLevel;
|
|
1872
|
+
compressionStrategy = packed.compressionStrategy;
|
|
1873
|
+
smartCompression = packed.smartCompression;
|
|
1363
1874
|
signingKeyId = parsed.keyId;
|
|
1364
1875
|
hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
|
|
1365
1876
|
} else {
|
|
1366
|
-
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none"
|
|
1877
|
+
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none", {
|
|
1878
|
+
...options,
|
|
1879
|
+
outerFixedBytes: secureOuterFixedBytes,
|
|
1880
|
+
versionOptions: options
|
|
1881
|
+
});
|
|
1367
1882
|
protectedPayload = prepared.payload;
|
|
1368
1883
|
extended = prepared.extended;
|
|
1369
1884
|
compression = prepared.compression;
|
|
1885
|
+
compressionLevel = prepared.compressionLevel;
|
|
1886
|
+
compressionStrategy = prepared.compressionStrategy;
|
|
1887
|
+
smartCompression = prepared.smartCompression;
|
|
1370
1888
|
}
|
|
1371
1889
|
|
|
1372
1890
|
const encrypted = await encryptSecurePayload(protectedPayload, security);
|
|
@@ -1378,6 +1896,9 @@ export async function encodeSecureBytes(input, options = {}) {
|
|
|
1378
1896
|
signed,
|
|
1379
1897
|
sourcePayloadBytes: sourcePayload.length,
|
|
1380
1898
|
compressionMetadata: compression,
|
|
1899
|
+
compressionLevelMetadata: compressionLevel,
|
|
1900
|
+
compressionStrategyMetadata: compressionStrategy,
|
|
1901
|
+
smartCompressionMetadata: smartCompression,
|
|
1381
1902
|
signingKeyId,
|
|
1382
1903
|
hasEmbeddedPublicKey,
|
|
1383
1904
|
securityMetadata: encrypted.metadata
|
|
@@ -1499,12 +2020,14 @@ function rotate90(matrix) {
|
|
|
1499
2020
|
return out;
|
|
1500
2021
|
}
|
|
1501
2022
|
|
|
1502
|
-
function extractVisibleCells(matrix, layout) {
|
|
2023
|
+
function extractVisibleCells(matrix, layout, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
2024
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
2025
|
+
const maxValue = encoding === CELL_ENCODINGS.TRIANGLE16 ? 15 : 3;
|
|
1503
2026
|
const out = [];
|
|
1504
2027
|
for (const [row, col] of layout.dataPositions) {
|
|
1505
2028
|
const value = matrix[row][col];
|
|
1506
|
-
if (!Number.isInteger(value) || value < 0 || value >
|
|
1507
|
-
throw new Error(
|
|
2029
|
+
if (!Number.isInteger(value) || value < 0 || value > maxValue) {
|
|
2030
|
+
throw new Error(`Data region contains an invalid ${encoding === CELL_ENCODINGS.TRIANGLE16 ? "Triangle16" : "RGBW"} data cell.`);
|
|
1508
2031
|
}
|
|
1509
2032
|
out.push(value);
|
|
1510
2033
|
}
|
|
@@ -1518,6 +2041,34 @@ function unmaskCells(visibleCells, positions, maskId) {
|
|
|
1518
2041
|
});
|
|
1519
2042
|
}
|
|
1520
2043
|
|
|
2044
|
+
function unmaskAndRestoreLogicalCells(
|
|
2045
|
+
visibleCells,
|
|
2046
|
+
positions,
|
|
2047
|
+
maskId,
|
|
2048
|
+
version,
|
|
2049
|
+
spectralInterleaving,
|
|
2050
|
+
cellEncoding = CELL_ENCODINGS.RGBW
|
|
2051
|
+
) {
|
|
2052
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
2053
|
+
const permutation = spectralInterleaving ? spectralPermutation(visibleCells.length, version) : null;
|
|
2054
|
+
const logical = new Array(visibleCells.length);
|
|
2055
|
+
const headerCells = getHeaderPlan(version).codewordCells;
|
|
2056
|
+
|
|
2057
|
+
for (let logicalIndex = 0; logicalIndex < visibleCells.length; logicalIndex++) {
|
|
2058
|
+
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
2059
|
+
const [row, col] = positions[physicalIndex];
|
|
2060
|
+
logical[logicalIndex] = applyCellMask(
|
|
2061
|
+
visibleCells[physicalIndex],
|
|
2062
|
+
row,
|
|
2063
|
+
col,
|
|
2064
|
+
maskId,
|
|
2065
|
+
encoding,
|
|
2066
|
+
encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < headerCells
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
return logical;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1521
2072
|
function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
1522
2073
|
if (!spectralInterleaving) return physicalValues.slice();
|
|
1523
2074
|
const permutation = spectralPermutation(physicalValues.length, version);
|
|
@@ -1528,20 +2079,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
|
1528
2079
|
return logical;
|
|
1529
2080
|
}
|
|
1530
2081
|
|
|
1531
|
-
function cellsToSymbolConfidences(
|
|
2082
|
+
function cellsToSymbolConfidences(
|
|
2083
|
+
confidences,
|
|
2084
|
+
byteCount,
|
|
2085
|
+
cellsPerByte = RGBW_CELLS_PER_BYTE
|
|
2086
|
+
) {
|
|
1532
2087
|
if (!confidences) return null;
|
|
1533
|
-
if (confidences.length < byteCount *
|
|
2088
|
+
if (confidences.length < byteCount * cellsPerByte) return null;
|
|
1534
2089
|
const out = new Array(byteCount);
|
|
1535
2090
|
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
|
-
);
|
|
2091
|
+
const offset = symbolIndex * cellsPerByte;
|
|
2092
|
+
let value = 1;
|
|
2093
|
+
for (let cellIndex = 0; cellIndex < cellsPerByte; cellIndex++) {
|
|
2094
|
+
value = Math.min(value, confidences[offset + cellIndex] ?? 1);
|
|
2095
|
+
}
|
|
2096
|
+
out[symbolIndex] = value;
|
|
1545
2097
|
}
|
|
1546
2098
|
return out;
|
|
1547
2099
|
}
|
|
@@ -1578,11 +2130,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
|
|
|
1578
2130
|
}
|
|
1579
2131
|
}
|
|
1580
2132
|
|
|
1581
|
-
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
|
|
2133
|
+
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1582
2134
|
const plan = getHeaderPlan(version);
|
|
1583
2135
|
const headerCells = rawCells.slice(0, plan.codewordCells);
|
|
1584
|
-
const codeword = Array.from(
|
|
1585
|
-
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
|
|
2136
|
+
const codeword = Array.from(protectedHeaderCellsToBytes(headerCells, plan.codewordBytes, cellEncoding));
|
|
2137
|
+
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes, RGBW_CELLS_PER_BYTE);
|
|
1586
2138
|
const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
|
|
1587
2139
|
const header = new Uint8Array(decoded.data);
|
|
1588
2140
|
const parsed = parseHeader(header, version);
|
|
@@ -1597,16 +2149,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
|
|
|
1597
2149
|
};
|
|
1598
2150
|
}
|
|
1599
2151
|
|
|
1600
|
-
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
|
|
1601
|
-
const plan = getBodyRsPlan(payloadLength, eccLevel, version);
|
|
2152
|
+
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
2153
|
+
const plan = getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding);
|
|
1602
2154
|
const bodyStart = getHeaderPlan(version).codewordCells;
|
|
1603
2155
|
const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
|
|
1604
2156
|
if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
|
|
1605
2157
|
|
|
1606
|
-
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
|
|
2158
|
+
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
|
|
1607
2159
|
const symbolConfidences = cellsToSymbolConfidences(
|
|
1608
2160
|
rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
|
|
1609
|
-
plan.encodedSymbols
|
|
2161
|
+
plan.encodedSymbols,
|
|
2162
|
+
cellsPerByteForEncoding(cellEncoding)
|
|
1610
2163
|
);
|
|
1611
2164
|
const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
|
|
1612
2165
|
const confidenceBlocks = symbolConfidences
|
|
@@ -1649,96 +2202,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1649
2202
|
if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
1650
2203
|
|
|
1651
2204
|
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
2205
|
const errors = [];
|
|
2206
|
+
const hintedEncoding = options.cellEncodingHint != null
|
|
2207
|
+
? normalizeCellEncoding(options.cellEncodingHint)
|
|
2208
|
+
: null;
|
|
2209
|
+
const hasExtendedCells = layout.dataPositions.some(([row, col]) => (matrix[row]?.[col] ?? 0) > 3);
|
|
2210
|
+
const encodingAttempts = hintedEncoding
|
|
2211
|
+
? [hintedEncoding]
|
|
2212
|
+
: hasExtendedCells
|
|
2213
|
+
? [CELL_ENCODINGS.TRIANGLE16, CELL_ENCODINGS.RGBW]
|
|
2214
|
+
: [CELL_ENCODINGS.RGBW, CELL_ENCODINGS.TRIANGLE16];
|
|
2215
|
+
|
|
2216
|
+
for (const cellEncoding of encodingAttempts) {
|
|
2217
|
+
let visible;
|
|
2218
|
+
try {
|
|
2219
|
+
visible = extractVisibleCells(matrix, layout, cellEncoding);
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
errors.push(`${cellEncoding}: ${error.message}`);
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
1657
2224
|
|
|
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
|
-
}
|
|
2225
|
+
const visibleConfidences = confidenceMatrix
|
|
2226
|
+
? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
|
|
2227
|
+
: null;
|
|
1676
2228
|
|
|
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
|
-
|
|
2229
|
+
// New symbols use spectral-spatial interleaving. Legacy order remains a
|
|
2230
|
+
// decode fallback so existing RGBW QuadQR images do not become unreadable.
|
|
2231
|
+
for (const spectralInterleaving of [true, false]) {
|
|
2232
|
+
for (let maskId = 0; maskId < 4; maskId++) {
|
|
2233
|
+
try {
|
|
2234
|
+
const raw = unmaskAndRestoreLogicalCells(
|
|
2235
|
+
visible,
|
|
2236
|
+
layout.dataPositions,
|
|
2237
|
+
maskId,
|
|
2238
|
+
version,
|
|
2239
|
+
spectralInterleaving,
|
|
2240
|
+
cellEncoding
|
|
2241
|
+
);
|
|
2242
|
+
const rawConfidences = visibleConfidences
|
|
2243
|
+
? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
|
|
2244
|
+
: null;
|
|
2245
|
+
const headerDecoded = decodeProtectedHeader(
|
|
2246
|
+
raw,
|
|
2247
|
+
version,
|
|
2248
|
+
rawConfidences,
|
|
2249
|
+
options,
|
|
2250
|
+
cellEncoding
|
|
2251
|
+
);
|
|
2252
|
+
const header = headerDecoded.header;
|
|
2253
|
+
const flags = headerDecoded.flags;
|
|
2254
|
+
const declaredCellEncoding = cellEncodingFromFlags(flags);
|
|
2255
|
+
if (declaredCellEncoding !== cellEncoding) {
|
|
2256
|
+
throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
|
|
2257
|
+
}
|
|
2258
|
+
const eccLevel = eccFromFlags(flags);
|
|
2259
|
+
const payloadLength = headerDecoded.payloadLength;
|
|
2260
|
+
if (streamCellCount(payloadLength, eccLevel, version, cellEncoding) > raw.length) {
|
|
2261
|
+
throw new Error("Declared payload exceeds matrix capacity.");
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
const bodyDecoded = decodeProtectedBody(
|
|
2265
|
+
raw,
|
|
2266
|
+
payloadLength,
|
|
2267
|
+
eccLevel,
|
|
2268
|
+
version,
|
|
2269
|
+
rawConfidences,
|
|
2270
|
+
options,
|
|
2271
|
+
cellEncoding
|
|
2272
|
+
);
|
|
2273
|
+
const body = bodyDecoded.body;
|
|
2274
|
+
const payload = body.slice(0, payloadLength);
|
|
2275
|
+
const expectedCrc = readU32be(body, payloadLength);
|
|
2276
|
+
const actualCrc = crc32(concatBytes(header, payload));
|
|
2277
|
+
if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
|
|
2278
|
+
|
|
2279
|
+
const isText = (flags & TEXT_FLAG) !== 0;
|
|
2280
|
+
const secure = (flags & SECURE_FLAG) !== 0;
|
|
2281
|
+
const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
2282
|
+
const signedFlag = (flags & SIGNED_FLAG) !== 0;
|
|
2283
|
+
const security = secure ? inspectSecureEnvelope(payload) : null;
|
|
2284
|
+
const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
|
|
2285
|
+
const applicationPayload = envelope ? envelope.payload : payload;
|
|
2286
|
+
const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
|
|
2287
|
+
const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
|
|
2288
|
+
return {
|
|
2289
|
+
ok: true,
|
|
2290
|
+
format: "QuadQR",
|
|
2291
|
+
formatVersion: FORMAT_VERSION,
|
|
2292
|
+
version,
|
|
2293
|
+
size,
|
|
2294
|
+
alignmentPatterns: layout.alignments.length,
|
|
2295
|
+
maskId,
|
|
2296
|
+
rotation,
|
|
2297
|
+
flags,
|
|
2298
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
2299
|
+
cellEncoding,
|
|
2300
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
2301
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
2302
|
+
eccLevel,
|
|
2303
|
+
eccParitySymbols: bodyDecoded.plan.paritySymbols,
|
|
2304
|
+
eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
|
|
2305
|
+
correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
|
|
2306
|
+
spectralInterleaving,
|
|
2307
|
+
confidenceAwareEcc: Boolean(rawConfidences),
|
|
2308
|
+
confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
|
|
2309
|
+
erasureSymbols,
|
|
2310
|
+
unknownErrorSymbols,
|
|
2311
|
+
correctedHeaderSymbols: headerDecoded.correctedSymbols,
|
|
2312
|
+
correctedBodySymbols: bodyDecoded.correctedSymbols,
|
|
2313
|
+
correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
|
|
2314
|
+
protectedPayload: envelope ? payload : undefined,
|
|
2315
|
+
payload: secure ? payload : applicationPayload,
|
|
2316
|
+
text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
|
|
2317
|
+
compressed: envelope?.compressed ?? false,
|
|
2318
|
+
compression: envelope?.compression ?? "none",
|
|
2319
|
+
signed: envelope?.signed ?? signedFlag,
|
|
2320
|
+
signatureVerified: envelope?.signatureVerified,
|
|
2321
|
+
signatureTrusted: envelope?.signatureTrusted,
|
|
2322
|
+
signingKeyId: envelope?.keyId ?? null,
|
|
2323
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
|
|
2324
|
+
signer: envelope?.signer ?? null,
|
|
2325
|
+
secure,
|
|
2326
|
+
encrypted: secure,
|
|
2327
|
+
decrypted: false,
|
|
2328
|
+
requiresDecryption: secure,
|
|
2329
|
+
security,
|
|
2330
|
+
crc32: actualCrc >>> 0
|
|
2331
|
+
};
|
|
2332
|
+
} catch (error) {
|
|
2333
|
+
errors.push(`${cellEncoding} ${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
|
|
2334
|
+
}
|
|
1742
2335
|
}
|
|
1743
2336
|
}
|
|
1744
2337
|
}
|
|
@@ -1746,10 +2339,68 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1746
2339
|
throw new Error(`QuadQR decode failed. ${errors.join(" | ")}`);
|
|
1747
2340
|
}
|
|
1748
2341
|
|
|
2342
|
+
function trySoftMatrixDecode(matrix, alternatives, confidenceMatrix, degrees, tolerance, options = {}) {
|
|
2343
|
+
if (!alternatives || !confidenceMatrix || options.softDecoding === false) return null;
|
|
2344
|
+
const version = versionFromSize(matrix.length);
|
|
2345
|
+
if (!version) return null;
|
|
2346
|
+
const layout = createLayout(version);
|
|
2347
|
+
const threshold = clampNumber(options.softDecodeConfidence ?? 0.72, 0.05, 0.95);
|
|
2348
|
+
const maxCells = Math.max(2, Math.min(16, Math.round(options.softDecodeMaxCells ?? 10)));
|
|
2349
|
+
const pairCells = Math.max(2, Math.min(maxCells, Math.round(options.softDecodePairCells ?? 6)));
|
|
2350
|
+
const ranked = [];
|
|
2351
|
+
|
|
2352
|
+
for (const [row, col] of layout.dataPositions) {
|
|
2353
|
+
const alternative = alternatives[row]?.[col];
|
|
2354
|
+
const confidence = confidenceMatrix[row]?.[col] ?? 1;
|
|
2355
|
+
if (!Number.isInteger(alternative) || alternative === matrix[row][col] || confidence > threshold) continue;
|
|
2356
|
+
ranked.push({ row, col, alternative, confidence });
|
|
2357
|
+
}
|
|
2358
|
+
ranked.sort((a, b) => (a.confidence - b.confidence) || (a.row - b.row) || (a.col - b.col));
|
|
2359
|
+
const candidates = ranked.slice(0, maxCells);
|
|
2360
|
+
let attempts = 0;
|
|
2361
|
+
|
|
2362
|
+
const attempt = (changes) => {
|
|
2363
|
+
const trial = cloneMatrix(matrix);
|
|
2364
|
+
for (const change of changes) trial[change.row][change.col] = change.alternative;
|
|
2365
|
+
attempts++;
|
|
2366
|
+
try {
|
|
2367
|
+
const decoded = decodeCanonical(trial, degrees, tolerance, confidenceMatrix, {
|
|
2368
|
+
...options,
|
|
2369
|
+
softDecoding: false
|
|
2370
|
+
});
|
|
2371
|
+
return {
|
|
2372
|
+
...decoded,
|
|
2373
|
+
spectrumEccVersion: 2,
|
|
2374
|
+
softDecoded: true,
|
|
2375
|
+
softSubstitutions: changes.length,
|
|
2376
|
+
softDecodeAttempts: attempts,
|
|
2377
|
+
softDecodeCellsConsidered: candidates.length
|
|
2378
|
+
};
|
|
2379
|
+
} catch {
|
|
2380
|
+
return null;
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
|
|
2384
|
+
for (const candidate of candidates) {
|
|
2385
|
+
const decoded = attempt([candidate]);
|
|
2386
|
+
if (decoded) return decoded;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const pairCandidates = candidates.slice(0, pairCells);
|
|
2390
|
+
for (let i = 0; i < pairCandidates.length; i++) {
|
|
2391
|
+
for (let j = i + 1; j < pairCandidates.length; j++) {
|
|
2392
|
+
const decoded = attempt([pairCandidates[i], pairCandidates[j]]);
|
|
2393
|
+
if (decoded) return decoded;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
return null;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
1749
2399
|
export function decodeMatrix(inputMatrix, options = {}) {
|
|
1750
2400
|
assert(Array.isArray(inputMatrix) && inputMatrix.length > 0, "Matrix is required.");
|
|
1751
2401
|
let matrix = cloneMatrix(inputMatrix);
|
|
1752
2402
|
let confidenceMatrix = options.cellConfidence ? cloneMatrix(options.cellConfidence) : null;
|
|
2403
|
+
let alternativeMatrix = options.cellAlternatives ? cloneMatrix(options.cellAlternatives) : null;
|
|
1753
2404
|
const errors = [];
|
|
1754
2405
|
const tolerance = options.structureTolerance ?? 0;
|
|
1755
2406
|
|
|
@@ -1759,16 +2410,38 @@ export function decodeMatrix(inputMatrix, options = {}) {
|
|
|
1759
2410
|
"cellConfidence must be a square matrix matching the QuadQR matrix."
|
|
1760
2411
|
);
|
|
1761
2412
|
}
|
|
2413
|
+
if (alternativeMatrix) {
|
|
2414
|
+
assert(
|
|
2415
|
+
alternativeMatrix.length === matrix.length && alternativeMatrix.every((row) => row.length === matrix.length),
|
|
2416
|
+
"cellAlternatives must be a square matrix matching the QuadQR matrix."
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
1762
2419
|
|
|
1763
2420
|
for (let rotationIndex = 0; rotationIndex < 4; rotationIndex++) {
|
|
1764
2421
|
const degrees = rotationIndex * 90;
|
|
1765
2422
|
try {
|
|
1766
|
-
|
|
2423
|
+
const decoded = decodeCanonical(matrix, degrees, tolerance, confidenceMatrix, options);
|
|
2424
|
+
return {
|
|
2425
|
+
spectrumEccVersion: 2,
|
|
2426
|
+
softDecoded: false,
|
|
2427
|
+
softSubstitutions: 0,
|
|
2428
|
+
...decoded
|
|
2429
|
+
};
|
|
1767
2430
|
} catch (error) {
|
|
1768
2431
|
errors.push(`${degrees}°: ${error.message}`);
|
|
2432
|
+
const softDecoded = trySoftMatrixDecode(
|
|
2433
|
+
matrix,
|
|
2434
|
+
alternativeMatrix,
|
|
2435
|
+
confidenceMatrix,
|
|
2436
|
+
degrees,
|
|
2437
|
+
tolerance,
|
|
2438
|
+
options
|
|
2439
|
+
);
|
|
2440
|
+
if (softDecoded) return softDecoded;
|
|
1769
2441
|
}
|
|
1770
2442
|
matrix = rotate90(matrix);
|
|
1771
2443
|
if (confidenceMatrix) confidenceMatrix = rotate90(confidenceMatrix);
|
|
2444
|
+
if (alternativeMatrix) alternativeMatrix = rotate90(alternativeMatrix);
|
|
1772
2445
|
}
|
|
1773
2446
|
|
|
1774
2447
|
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
@@ -1855,6 +2528,80 @@ function cellRgb(cell, palette) {
|
|
|
1855
2528
|
}
|
|
1856
2529
|
|
|
1857
2530
|
|
|
2531
|
+
function inferCellEncoding(codeOrMatrix, matrix, layout = null) {
|
|
2532
|
+
if (!Array.isArray(codeOrMatrix) && codeOrMatrix?.cellEncoding) {
|
|
2533
|
+
return normalizeCellEncoding(codeOrMatrix.cellEncoding);
|
|
2534
|
+
}
|
|
2535
|
+
const activeLayout = layout ?? renderLayoutForMatrix(matrix);
|
|
2536
|
+
if (activeLayout) {
|
|
2537
|
+
for (const [row, col] of activeLayout.dataPositions) {
|
|
2538
|
+
if ((matrix[row]?.[col] ?? 0) > 3) return CELL_ENCODINGS.TRIANGLE16;
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
return CELL_ENCODINGS.RGBW;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function triangleCellColors(cell) {
|
|
2545
|
+
const { first, second } = unpackTriangleCell(cell);
|
|
2546
|
+
return { first, second };
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function drawTriangleCellCanvas(ctx, x, y, width, height, cell, palette) {
|
|
2550
|
+
const pair = triangleCellColors(cell);
|
|
2551
|
+
if (pair.first === pair.second) {
|
|
2552
|
+
ctx.fillStyle = cellColor(pair.first, palette);
|
|
2553
|
+
ctx.fillRect(x, y, width, height);
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// Fixed "/" split. first = upper-left triangle, second = lower-right.
|
|
2558
|
+
ctx.beginPath();
|
|
2559
|
+
ctx.moveTo(x, y);
|
|
2560
|
+
ctx.lineTo(x + width, y);
|
|
2561
|
+
ctx.lineTo(x, y + height);
|
|
2562
|
+
ctx.closePath();
|
|
2563
|
+
ctx.fillStyle = cellColor(pair.first, palette);
|
|
2564
|
+
ctx.fill();
|
|
2565
|
+
|
|
2566
|
+
ctx.beginPath();
|
|
2567
|
+
ctx.moveTo(x + width, y);
|
|
2568
|
+
ctx.lineTo(x + width, y + height);
|
|
2569
|
+
ctx.lineTo(x, y + height);
|
|
2570
|
+
ctx.closePath();
|
|
2571
|
+
ctx.fillStyle = cellColor(pair.second, palette);
|
|
2572
|
+
ctx.fill();
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
function fillImageTriangleCell(data, imageWidth, x, y, width, height, cell, palette) {
|
|
2576
|
+
const pair = triangleCellColors(cell);
|
|
2577
|
+
const firstRgb = cellRgb(pair.first, palette);
|
|
2578
|
+
const secondRgb = cellRgb(pair.second, palette);
|
|
2579
|
+
for (let yy = 0; yy < height; yy++) {
|
|
2580
|
+
for (let xx = 0; xx < width; xx++) {
|
|
2581
|
+
const normalizedX = (xx + 0.5) / Math.max(1, width);
|
|
2582
|
+
const normalizedY = (yy + 0.5) / Math.max(1, height);
|
|
2583
|
+
const rgb = normalizedX + normalizedY <= 1 ? firstRgb : secondRgb;
|
|
2584
|
+
const p = ((y + yy) * imageWidth + (x + xx)) * 4;
|
|
2585
|
+
data[p] = rgb.r;
|
|
2586
|
+
data[p + 1] = rgb.g;
|
|
2587
|
+
data[p + 2] = rgb.b;
|
|
2588
|
+
data[p + 3] = 255;
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function svgTriangleCell(x, y, size, cell, palette) {
|
|
2594
|
+
const pair = triangleCellColors(cell);
|
|
2595
|
+
if (pair.first === pair.second) return svgRect(x, y, size, size, cellColor(pair.first, palette));
|
|
2596
|
+
const first = cellColor(pair.first, palette);
|
|
2597
|
+
const second = cellColor(pair.second, palette);
|
|
2598
|
+
return [
|
|
2599
|
+
`<polygon points="${x},${y} ${x + size},${y} ${x},${y + size}" fill="${first}"/>`,
|
|
2600
|
+
`<polygon points="${x + size},${y} ${x + size},${y + size} ${x},${y + size}" fill="${second}"/>`
|
|
2601
|
+
].join("");
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
|
|
1858
2605
|
function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
|
|
1859
2606
|
const value = String(style).toLowerCase();
|
|
1860
2607
|
const allowed = Object.values(RENDER_STYLES);
|
|
@@ -2324,6 +3071,7 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
2324
3071
|
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2325
3072
|
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2326
3073
|
const layout = renderLayoutForMatrix(matrix);
|
|
3074
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
2327
3075
|
const size = matrix.length;
|
|
2328
3076
|
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
2329
3077
|
|
|
@@ -2342,6 +3090,14 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
2342
3090
|
const { x, y, width: cellWidth, height: cellHeight } = rect;
|
|
2343
3091
|
const cellSize = Math.min(cellWidth, cellHeight);
|
|
2344
3092
|
|
|
3093
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
3094
|
+
// Experimental Triangle16 always renders payload cells with exact,
|
|
3095
|
+
// hard-edged geometry. Decorative per-cell styles would contaminate the
|
|
3096
|
+
// two independent color samples and reduce scan reliability.
|
|
3097
|
+
drawTriangleCellCanvas(ctx, x, y, cellWidth, cellHeight, cell, palette);
|
|
3098
|
+
continue;
|
|
3099
|
+
}
|
|
3100
|
+
|
|
2345
3101
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2346
3102
|
ctx.fillStyle = cellColor(cell, palette);
|
|
2347
3103
|
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
@@ -2415,6 +3171,7 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
2415
3171
|
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2416
3172
|
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2417
3173
|
const layout = renderLayoutForMatrix(matrix);
|
|
3174
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
2418
3175
|
const size = matrix.length;
|
|
2419
3176
|
const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
|
|
2420
3177
|
const white = palette.white;
|
|
@@ -2431,7 +3188,6 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
2431
3188
|
for (let r = 0; r < size; r++) {
|
|
2432
3189
|
for (let c = 0; c < size; c++) {
|
|
2433
3190
|
const cell = matrix[r][c];
|
|
2434
|
-
const rgb = cellRgb(cell, palette);
|
|
2435
3191
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
2436
3192
|
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
2437
3193
|
const x0 = rect.x;
|
|
@@ -2440,6 +3196,13 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
2440
3196
|
const cellHeight = rect.height;
|
|
2441
3197
|
const cellSize = Math.min(cellWidth, cellHeight);
|
|
2442
3198
|
|
|
3199
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
3200
|
+
fillImageTriangleCell(data, pixelSize, x0, y0, cellWidth, cellHeight, cell, palette);
|
|
3201
|
+
continue;
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
const rgb = cellRgb(cell, palette);
|
|
3205
|
+
|
|
2443
3206
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2444
3207
|
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
2445
3208
|
continue;
|
|
@@ -2501,6 +3264,7 @@ export function renderToSVG(codeOrMatrix, options = {}) {
|
|
|
2501
3264
|
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2502
3265
|
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2503
3266
|
const layout = renderLayoutForMatrix(matrix);
|
|
3267
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
2504
3268
|
const size = matrix.length;
|
|
2505
3269
|
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
2506
3270
|
const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
|
|
@@ -2511,7 +3275,12 @@ export function renderToSVG(codeOrMatrix, options = {}) {
|
|
|
2511
3275
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
2512
3276
|
const x = (c + quietZone) * moduleSize;
|
|
2513
3277
|
const y = (r + quietZone) * moduleSize;
|
|
2514
|
-
const fill = cellColor(cell, palette);
|
|
3278
|
+
const fill = cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural ? null : cellColor(cell, palette);
|
|
3279
|
+
|
|
3280
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
3281
|
+
body.push(svgTriangleCell(x, y, moduleSize, cell, palette));
|
|
3282
|
+
continue;
|
|
3283
|
+
}
|
|
2515
3284
|
|
|
2516
3285
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2517
3286
|
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
@@ -2614,11 +3383,96 @@ function makeWhiteBalanceTransform(observed) {
|
|
|
2614
3383
|
});
|
|
2615
3384
|
}
|
|
2616
3385
|
|
|
3386
|
+
function solveLinearSystem(matrix, vector) {
|
|
3387
|
+
const n = vector.length;
|
|
3388
|
+
const a = matrix.map((row, index) => row.slice().concat(vector[index]));
|
|
3389
|
+
for (let col = 0; col < n; col++) {
|
|
3390
|
+
let pivot = col;
|
|
3391
|
+
for (let row = col + 1; row < n; row++) {
|
|
3392
|
+
if (Math.abs(a[row][col]) > Math.abs(a[pivot][col])) pivot = row;
|
|
3393
|
+
}
|
|
3394
|
+
if (Math.abs(a[pivot][col]) < 1e-9) return null;
|
|
3395
|
+
[a[col], a[pivot]] = [a[pivot], a[col]];
|
|
3396
|
+
const divisor = a[col][col];
|
|
3397
|
+
for (let j = col; j <= n; j++) a[col][j] /= divisor;
|
|
3398
|
+
for (let row = 0; row < n; row++) {
|
|
3399
|
+
if (row === col) continue;
|
|
3400
|
+
const factor = a[row][col];
|
|
3401
|
+
if (Math.abs(factor) < 1e-12) continue;
|
|
3402
|
+
for (let j = col; j <= n; j++) a[row][j] -= factor * a[col][j];
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
return a.map((row) => row[n]);
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
function makeAffineCalibrationTransform(observed) {
|
|
3409
|
+
const ideal = {
|
|
3410
|
+
black: { r: 0, g: 0, b: 0 },
|
|
3411
|
+
white: { r: 255, g: 255, b: 255 },
|
|
3412
|
+
red: { r: 255, g: 0, b: 0 },
|
|
3413
|
+
green: { r: 0, g: 255, b: 0 },
|
|
3414
|
+
blue: { r: 0, g: 0, b: 255 }
|
|
3415
|
+
};
|
|
3416
|
+
const keys = ["black", "white", "red", "green", "blue"];
|
|
3417
|
+
const rows = keys.map((key) => {
|
|
3418
|
+
const rgb = observed[key];
|
|
3419
|
+
return [rgb.r, rgb.g, rgb.b, 1];
|
|
3420
|
+
});
|
|
3421
|
+
const normal = Array.from({ length: 4 }, () => Array(4).fill(0));
|
|
3422
|
+
for (const row of rows) {
|
|
3423
|
+
for (let i = 0; i < 4; i++) {
|
|
3424
|
+
for (let j = 0; j < 4; j++) normal[i][j] += row[i] * row[j];
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
// A tiny ridge term keeps the transform stable if the photographed palette
|
|
3428
|
+
// becomes nearly singular under severe clipping or monochromatic lighting.
|
|
3429
|
+
const ridge = 1e-4;
|
|
3430
|
+
for (let i = 0; i < 4; i++) normal[i][i] += ridge;
|
|
3431
|
+
|
|
3432
|
+
const coefficients = {};
|
|
3433
|
+
for (const channel of ["r", "g", "b"]) {
|
|
3434
|
+
const rhs = Array(4).fill(0);
|
|
3435
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
3436
|
+
const x = rows[rowIndex];
|
|
3437
|
+
const y = ideal[keys[rowIndex]][channel];
|
|
3438
|
+
for (let i = 0; i < 4; i++) rhs[i] += x[i] * y;
|
|
3439
|
+
}
|
|
3440
|
+
coefficients[channel] = solveLinearSystem(normal, rhs);
|
|
3441
|
+
if (!coefficients[channel]) return null;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
const transform = (rgb) => {
|
|
3445
|
+
const x = [rgb.r, rgb.g, rgb.b, 1];
|
|
3446
|
+
const mapChannel = (channel) => coefficients[channel].reduce((sum, value, index) => sum + value * x[index], 0);
|
|
3447
|
+
return {
|
|
3448
|
+
r: clampNumber(mapChannel("r"), -96, 384),
|
|
3449
|
+
g: clampNumber(mapChannel("g"), -96, 384),
|
|
3450
|
+
b: clampNumber(mapChannel("b"), -96, 384)
|
|
3451
|
+
};
|
|
3452
|
+
};
|
|
3453
|
+
|
|
3454
|
+
let errorSq = 0;
|
|
3455
|
+
for (const key of keys) {
|
|
3456
|
+
const mapped = transform(observed[key]);
|
|
3457
|
+
const target = ideal[key];
|
|
3458
|
+
errorSq += colorDistanceSq(mapped, target);
|
|
3459
|
+
}
|
|
3460
|
+
return { transform, ideal, rmsError: Math.sqrt(errorSq / (keys.length * 3)) };
|
|
3461
|
+
}
|
|
3462
|
+
|
|
2617
3463
|
function classifierFromPaletteRgb(observed, mode = "raw") {
|
|
2618
|
-
const
|
|
3464
|
+
const affine = mode === "affine" ? makeAffineCalibrationTransform(observed) : null;
|
|
3465
|
+
const transform = affine?.transform ?? (mode === "balanced" || mode === "hue"
|
|
2619
3466
|
? makeWhiteBalanceTransform(observed)
|
|
2620
|
-
: identityColorTransform;
|
|
2621
|
-
const
|
|
3467
|
+
: identityColorTransform);
|
|
3468
|
+
const idealEntries = affine ? [
|
|
3469
|
+
{ cell: CELL.BLACK, rgb: affine.ideal.black },
|
|
3470
|
+
{ cell: CELL.WHITE, rgb: affine.ideal.white },
|
|
3471
|
+
{ cell: CELL.RED, rgb: affine.ideal.red },
|
|
3472
|
+
{ cell: CELL.GREEN, rgb: affine.ideal.green },
|
|
3473
|
+
{ cell: CELL.BLUE, rgb: affine.ideal.blue }
|
|
3474
|
+
] : null;
|
|
3475
|
+
const entries = idealEntries ?? [
|
|
2622
3476
|
{ cell: CELL.BLACK, rgb: transform(observed.black) },
|
|
2623
3477
|
{ cell: CELL.WHITE, rgb: transform(observed.white) },
|
|
2624
3478
|
{ cell: CELL.RED, rgb: transform(observed.red) },
|
|
@@ -2632,6 +3486,8 @@ function classifierFromPaletteRgb(observed, mode = "raw") {
|
|
|
2632
3486
|
entries,
|
|
2633
3487
|
transform,
|
|
2634
3488
|
mode,
|
|
3489
|
+
calibrationModel: affine ? "affine-3x4" : (mode === "balanced" || mode === "hue" ? "black-white-balance" : "observed-palette"),
|
|
3490
|
+
calibrationError: affine?.rmsError ?? null,
|
|
2635
3491
|
dataMode: mode === "hue" ? "hue" : "distance",
|
|
2636
3492
|
whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
|
|
2637
3493
|
minimumColorChroma
|
|
@@ -2645,27 +3501,30 @@ function colorDistanceSq(a, b) {
|
|
|
2645
3501
|
return dr * dr + dg * dg + db * db;
|
|
2646
3502
|
}
|
|
2647
3503
|
|
|
2648
|
-
function
|
|
3504
|
+
function rankRgbCandidates(rgb, classifier) {
|
|
2649
3505
|
const transformed = classifier.transform(rgb);
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
3506
|
+
return classifier.entries
|
|
3507
|
+
.map((candidate) => ({
|
|
3508
|
+
cell: candidate.cell,
|
|
3509
|
+
rgb: candidate.rgb,
|
|
3510
|
+
distance: Math.sqrt(colorDistanceSq(transformed, candidate.rgb))
|
|
3511
|
+
}))
|
|
3512
|
+
.sort((a, b) => (a.distance - b.distance) || (a.cell - b.cell));
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
function classifyRgb(rgb, classifier) {
|
|
3516
|
+
const ranked = rankRgbCandidates(rgb, classifier);
|
|
3517
|
+
const best = ranked[0];
|
|
3518
|
+
const second = ranked[1] ?? { cell: best.cell, distance: best.distance + 1 };
|
|
3519
|
+
const confidence = Math.max(0, Math.min(1, (second.distance - best.distance) / Math.max(second.distance, 1e-6)));
|
|
3520
|
+
return {
|
|
3521
|
+
cell: best.cell,
|
|
3522
|
+
distance: best.distance,
|
|
3523
|
+
confidence,
|
|
3524
|
+
alternativeCell: second.cell,
|
|
3525
|
+
alternativeDistance: second.distance,
|
|
3526
|
+
ambiguity: 1 - confidence
|
|
3527
|
+
};
|
|
2669
3528
|
}
|
|
2670
3529
|
|
|
2671
3530
|
|
|
@@ -2701,6 +3560,7 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
2701
3560
|
const size = rgbGrid.length;
|
|
2702
3561
|
const matrix = make2D(size, CELL.WHITE);
|
|
2703
3562
|
const confidence = make2D(size, 1);
|
|
3563
|
+
const alternatives = make2D(size, null);
|
|
2704
3564
|
const dataClassifier = {
|
|
2705
3565
|
...classifier,
|
|
2706
3566
|
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
@@ -2720,8 +3580,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
2720
3580
|
const classified = isDataCell && classifier.dataMode === "hue"
|
|
2721
3581
|
? classifyDataHue(rgbGrid[r][c], classifier)
|
|
2722
3582
|
: classifyRgb(rgbGrid[r][c], candidates);
|
|
3583
|
+
const ranked = rankRgbCandidates(rgbGrid[r][c], candidates);
|
|
3584
|
+
const alternate = ranked.find((candidate) => candidate.cell !== classified.cell);
|
|
2723
3585
|
matrix[r][c] = classified.cell;
|
|
2724
3586
|
confidence[r][c] = classified.confidence;
|
|
3587
|
+
alternatives[r][c] = alternate?.cell ?? null;
|
|
2725
3588
|
distanceSum += classified.distance;
|
|
2726
3589
|
confidenceSum += classified.confidence;
|
|
2727
3590
|
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
@@ -2731,6 +3594,79 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
2731
3594
|
return {
|
|
2732
3595
|
matrix,
|
|
2733
3596
|
confidence,
|
|
3597
|
+
alternatives,
|
|
3598
|
+
averageColorDistance: distanceSum / (size * size),
|
|
3599
|
+
averageCellConfidence: confidenceSum / (size * size),
|
|
3600
|
+
minimumCellConfidence: minimumConfidence,
|
|
3601
|
+
lowConfidenceCells
|
|
3602
|
+
};
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layout) {
|
|
3606
|
+
const size = rgbGrid.length;
|
|
3607
|
+
const matrix = make2D(size, CELL.WHITE);
|
|
3608
|
+
const confidence = make2D(size, 1);
|
|
3609
|
+
const alternatives = make2D(size, null);
|
|
3610
|
+
const dataClassifier = {
|
|
3611
|
+
...classifier,
|
|
3612
|
+
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
3613
|
+
};
|
|
3614
|
+
let distanceSum = 0;
|
|
3615
|
+
let confidenceSum = 0;
|
|
3616
|
+
let minimumConfidence = 1;
|
|
3617
|
+
let lowConfidenceCells = 0;
|
|
3618
|
+
|
|
3619
|
+
for (let r = 0; r < size; r++) {
|
|
3620
|
+
for (let c = 0; c < size; c++) {
|
|
3621
|
+
const isDataCell = layout && !layout.reserved[r][c];
|
|
3622
|
+
if (!isDataCell) {
|
|
3623
|
+
const classified = classifyRgb(rgbGrid[r][c], classifier);
|
|
3624
|
+
matrix[r][c] = classified.cell;
|
|
3625
|
+
confidence[r][c] = classified.confidence;
|
|
3626
|
+
alternatives[r][c] = classified.alternativeCell ?? null;
|
|
3627
|
+
distanceSum += classified.distance;
|
|
3628
|
+
confidenceSum += classified.confidence;
|
|
3629
|
+
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
3630
|
+
if (classified.confidence < 0.4) lowConfidenceCells++;
|
|
3631
|
+
continue;
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
const samples = triangleGrid?.[r]?.[c];
|
|
3635
|
+
if (!samples) throw new Error("Triangle16 sampling grid is incomplete.");
|
|
3636
|
+
const classifyData = (rgb) => classifier.dataMode === "hue"
|
|
3637
|
+
? classifyDataHue(rgb, classifier)
|
|
3638
|
+
: classifyRgb(rgb, dataClassifier);
|
|
3639
|
+
const first = classifyData(samples.first);
|
|
3640
|
+
const second = classifyData(samples.second);
|
|
3641
|
+
const firstRanked = rankRgbCandidates(samples.first, dataClassifier);
|
|
3642
|
+
const secondRanked = rankRgbCandidates(samples.second, dataClassifier);
|
|
3643
|
+
const firstAlt = firstRanked.find((candidate) => candidate.cell !== first.cell)?.cell ?? first.cell;
|
|
3644
|
+
const secondAlt = secondRanked.find((candidate) => candidate.cell !== second.cell)?.cell ?? second.cell;
|
|
3645
|
+
const firstSpread = Number(samples.firstSpread ?? 0);
|
|
3646
|
+
const secondSpread = Number(samples.secondSpread ?? 0);
|
|
3647
|
+
const stability = clampNumber(1 - Math.max(firstSpread, secondSpread) / 110, 0.35, 1);
|
|
3648
|
+
const cellConfidence = Math.min(first.confidence, second.confidence) * (0.68 + stability * 0.32);
|
|
3649
|
+
matrix[r][c] = packTriangleCell(first.cell, second.cell);
|
|
3650
|
+
// Prefer changing the region with the weaker classification. If both are
|
|
3651
|
+
// similarly uncertain, changing the more spatially unstable region first
|
|
3652
|
+
// gives Spectrum ECC 2.0 a better second hypothesis.
|
|
3653
|
+
const firstRisk = (1 - first.confidence) + firstSpread / 255;
|
|
3654
|
+
const secondRisk = (1 - second.confidence) + secondSpread / 255;
|
|
3655
|
+
alternatives[r][c] = firstRisk >= secondRisk
|
|
3656
|
+
? packTriangleCell(firstAlt, second.cell)
|
|
3657
|
+
: packTriangleCell(first.cell, secondAlt);
|
|
3658
|
+
confidence[r][c] = clampNumber(cellConfidence, 0, 1);
|
|
3659
|
+
distanceSum += (first.distance + second.distance) / 2;
|
|
3660
|
+
confidenceSum += confidence[r][c];
|
|
3661
|
+
minimumConfidence = Math.min(minimumConfidence, confidence[r][c]);
|
|
3662
|
+
if (confidence[r][c] < 0.4) lowConfidenceCells++;
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
return {
|
|
3667
|
+
matrix,
|
|
3668
|
+
confidence,
|
|
3669
|
+
alternatives,
|
|
2734
3670
|
averageColorDistance: distanceSum / (size * size),
|
|
2735
3671
|
averageCellConfidence: confidenceSum / (size * size),
|
|
2736
3672
|
minimumCellConfidence: minimumConfidence,
|
|
@@ -2760,6 +3696,7 @@ function paletteClassifierAttempts(observedPalette) {
|
|
|
2760
3696
|
return [
|
|
2761
3697
|
{ classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
|
|
2762
3698
|
{ classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
|
|
3699
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "affine"), colorNormalization: "affine-calibrated" },
|
|
2763
3700
|
{ classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
|
|
2764
3701
|
];
|
|
2765
3702
|
}
|
|
@@ -2777,6 +3714,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2777
3714
|
finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
|
|
2778
3715
|
finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
|
|
2779
3716
|
finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
|
|
3717
|
+
preciseAlignment: options.preciseAlignment,
|
|
2780
3718
|
diagnostics: options._visionDiagnostics,
|
|
2781
3719
|
diagnosticLabel: options._diagnosticLabel ?? "normal"
|
|
2782
3720
|
});
|
|
@@ -2802,9 +3740,18 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2802
3740
|
let bestStructureScore = 0;
|
|
2803
3741
|
for (const profile of sampleProfiles) {
|
|
2804
3742
|
let sampled;
|
|
3743
|
+
let triangleSampled = null;
|
|
2805
3744
|
let observedPalette;
|
|
2806
3745
|
try {
|
|
2807
3746
|
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
|
|
3747
|
+
if (options.highDensitySampling !== false && options.triangle16 !== false) {
|
|
3748
|
+
triangleSampled = samplePerspectiveTriangleMatrix(
|
|
3749
|
+
imageData,
|
|
3750
|
+
geometry.homography,
|
|
3751
|
+
layout.size,
|
|
3752
|
+
profile
|
|
3753
|
+
);
|
|
3754
|
+
}
|
|
2808
3755
|
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
|
|
2809
3756
|
robust: profile.robustCalibration
|
|
2810
3757
|
});
|
|
@@ -2822,6 +3769,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2822
3769
|
version: geometry.version,
|
|
2823
3770
|
matrix: classified.matrix,
|
|
2824
3771
|
confidence: classified.confidence,
|
|
3772
|
+
alternatives: classified.alternatives,
|
|
2825
3773
|
geometry,
|
|
2826
3774
|
observedPalette: activeObservedPalette,
|
|
2827
3775
|
samplingMode: activeSamplingMode,
|
|
@@ -2835,6 +3783,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2835
3783
|
const decoded = decodeMatrix(classified.matrix, {
|
|
2836
3784
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
2837
3785
|
cellConfidence: classified.confidence,
|
|
3786
|
+
cellAlternatives: classified.alternatives,
|
|
2838
3787
|
maxErasureConfidence: options.maxErasureConfidence
|
|
2839
3788
|
});
|
|
2840
3789
|
if (decoded.version !== geometry.version) return false;
|
|
@@ -2863,11 +3812,69 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2863
3812
|
}
|
|
2864
3813
|
};
|
|
2865
3814
|
|
|
3815
|
+
const tryTriangleAttempt = (attempt, metadata = {}) => {
|
|
3816
|
+
if (!triangleSampled?.triangleGrid) return false;
|
|
3817
|
+
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
3818
|
+
const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
|
|
3819
|
+
const classified = classifyTriangleSampledRgbGrid(
|
|
3820
|
+
sampled.rgbGrid,
|
|
3821
|
+
triangleSampled.triangleGrid,
|
|
3822
|
+
attempt.classifier,
|
|
3823
|
+
layout
|
|
3824
|
+
);
|
|
3825
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
3826
|
+
bestStructureScore = Math.max(bestStructureScore, structureScore);
|
|
3827
|
+
pushObservation(options, {
|
|
3828
|
+
version: geometry.version,
|
|
3829
|
+
matrix: classified.matrix,
|
|
3830
|
+
confidence: classified.confidence,
|
|
3831
|
+
alternatives: classified.alternatives,
|
|
3832
|
+
geometry,
|
|
3833
|
+
observedPalette: activeObservedPalette,
|
|
3834
|
+
samplingMode: activeSamplingMode,
|
|
3835
|
+
colorNormalization: attempt.colorNormalization,
|
|
3836
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3837
|
+
structureScore,
|
|
3838
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3839
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3840
|
+
});
|
|
3841
|
+
|
|
3842
|
+
try {
|
|
3843
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
3844
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3845
|
+
cellConfidence: classified.confidence,
|
|
3846
|
+
cellAlternatives: classified.alternatives,
|
|
3847
|
+
cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
|
|
3848
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3849
|
+
});
|
|
3850
|
+
if (decoded.version !== geometry.version) return false;
|
|
3851
|
+
results.push({
|
|
3852
|
+
...decoded,
|
|
3853
|
+
perspectiveCorrected: true,
|
|
3854
|
+
colorCalibrated: true,
|
|
3855
|
+
colorNormalization: attempt.colorNormalization,
|
|
3856
|
+
samplingMode: activeSamplingMode,
|
|
3857
|
+
geometry,
|
|
3858
|
+
observedPalette: activeObservedPalette,
|
|
3859
|
+
averageColorDistance: classified.averageColorDistance,
|
|
3860
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3861
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3862
|
+
lowConfidenceCells: classified.lowConfidenceCells,
|
|
3863
|
+
rectified: options.includeRectified
|
|
3864
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
3865
|
+
: undefined
|
|
3866
|
+
});
|
|
3867
|
+
return true;
|
|
3868
|
+
} catch {
|
|
3869
|
+
return false;
|
|
3870
|
+
}
|
|
3871
|
+
};
|
|
3872
|
+
|
|
2866
3873
|
// Fast path: preserve the original observed-RGB classifier first, then
|
|
2867
3874
|
// try per-channel white balancing. Most clean frames stop here without
|
|
2868
3875
|
// paying for the more expensive spatial normalization fallback.
|
|
2869
3876
|
for (const attempt of paletteClassifierAttempts(observedPalette)) {
|
|
2870
|
-
if (tryAttempt(attempt, sampled.rgbGrid)) {
|
|
3877
|
+
if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
|
|
2871
3878
|
geometryDecoded = true;
|
|
2872
3879
|
break;
|
|
2873
3880
|
}
|
|
@@ -2886,9 +3893,9 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2886
3893
|
}
|
|
2887
3894
|
}
|
|
2888
3895
|
|
|
2889
|
-
//
|
|
2890
|
-
// Normal camera frames never reach this path. The transform
|
|
2891
|
-
// Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
|
|
3896
|
+
// Lightweight QuadQR color recovery on the already-sampled module grid.
|
|
3897
|
+
// Normal camera frames never reach this path. The transform combines
|
|
3898
|
+
// QuadQR Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
|
|
2892
3899
|
if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
|
|
2893
3900
|
try {
|
|
2894
3901
|
const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
|
|
@@ -2916,7 +3923,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2916
3923
|
}
|
|
2917
3924
|
}
|
|
2918
3925
|
|
|
2919
|
-
// Camera-specific
|
|
3926
|
+
// Camera-specific QuadQR color-recovery fallback. Enhancing the whole camera
|
|
2920
3927
|
// frame is often ineffective because dark surroundings, browser/UI
|
|
2921
3928
|
// reflections and unrelated objects skew the histograms. Once finder
|
|
2922
3929
|
// geometry is known, rectify only the QuadQR region, run Auto Tone /
|
|
@@ -2994,17 +4001,30 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2994
4001
|
[step, -step], [step, step], [-step, -step], [-step, step]
|
|
2995
4002
|
];
|
|
2996
4003
|
const refinementCandidates = [];
|
|
4004
|
+
const triangleRefinementCandidates = [];
|
|
2997
4005
|
|
|
2998
4006
|
for (const [offsetX, offsetY] of refinementOffsets) {
|
|
2999
4007
|
let sampled;
|
|
4008
|
+
let triangleSampled = null;
|
|
3000
4009
|
let observedPalette;
|
|
3001
4010
|
try {
|
|
3002
|
-
|
|
4011
|
+
const samplingOptions = {
|
|
3003
4012
|
sampleMode: "cross",
|
|
3004
4013
|
sampleRadius: 0,
|
|
3005
4014
|
sampleOffsetX: offsetX,
|
|
3006
|
-
sampleOffsetY: offsetY
|
|
3007
|
-
|
|
4015
|
+
sampleOffsetY: offsetY,
|
|
4016
|
+
triangleSampleInset: options.highDensitySampleInset ?? options.triangleSampleInset,
|
|
4017
|
+
triangleSampleRadius: options.highDensitySampleRadius ?? options.triangleSampleRadius
|
|
4018
|
+
};
|
|
4019
|
+
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, samplingOptions);
|
|
4020
|
+
if (options.highDensitySampling !== false && options.triangle16 !== false) {
|
|
4021
|
+
triangleSampled = samplePerspectiveTriangleMatrix(
|
|
4022
|
+
imageData,
|
|
4023
|
+
geometry.homography,
|
|
4024
|
+
layout.size,
|
|
4025
|
+
samplingOptions
|
|
4026
|
+
);
|
|
4027
|
+
}
|
|
3008
4028
|
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
|
|
3009
4029
|
} catch {
|
|
3010
4030
|
continue;
|
|
@@ -3022,6 +4042,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3022
4042
|
version: geometry.version,
|
|
3023
4043
|
matrix: classified.matrix,
|
|
3024
4044
|
confidence: classified.confidence,
|
|
4045
|
+
alternatives: classified.alternatives,
|
|
3025
4046
|
geometry,
|
|
3026
4047
|
observedPalette,
|
|
3027
4048
|
samplingMode: "refined-center",
|
|
@@ -3034,30 +4055,82 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3034
4055
|
|
|
3035
4056
|
// Do not spend RS work on a refinement that made the known finder /
|
|
3036
4057
|
// alignment structure worse. This keeps the recovery path bounded.
|
|
3037
|
-
if (structureScore
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
4058
|
+
if (structureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
|
|
4059
|
+
refinementCandidates.push({
|
|
4060
|
+
attempt,
|
|
4061
|
+
classified,
|
|
4062
|
+
observedPalette,
|
|
4063
|
+
offsetX,
|
|
4064
|
+
offsetY,
|
|
4065
|
+
structureScore,
|
|
4066
|
+
cellEncoding: CELL_ENCODINGS.RGBW,
|
|
4067
|
+
samplingMode: "refined-center"
|
|
4068
|
+
});
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4071
|
+
if (triangleSampled?.triangleGrid) {
|
|
4072
|
+
const triangleClassified = classifyTriangleSampledRgbGrid(
|
|
4073
|
+
sampled.rgbGrid,
|
|
4074
|
+
triangleSampled.triangleGrid,
|
|
4075
|
+
attempt.classifier,
|
|
4076
|
+
layout
|
|
4077
|
+
);
|
|
4078
|
+
const triangleStructureScore = structuralAccuracy(triangleClassified.matrix, layout);
|
|
4079
|
+
bestStructureScore = Math.max(bestStructureScore, triangleStructureScore);
|
|
4080
|
+
pushObservation(options, {
|
|
4081
|
+
version: geometry.version,
|
|
4082
|
+
matrix: triangleClassified.matrix,
|
|
4083
|
+
confidence: triangleClassified.confidence,
|
|
4084
|
+
alternatives: triangleClassified.alternatives,
|
|
4085
|
+
geometry,
|
|
4086
|
+
observedPalette,
|
|
4087
|
+
samplingMode: "refined-triangle16",
|
|
4088
|
+
samplingOffset: { x: offsetX, y: offsetY },
|
|
4089
|
+
colorNormalization: attempt.colorNormalization,
|
|
4090
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
4091
|
+
structureScore: triangleStructureScore,
|
|
4092
|
+
averageCellConfidence: triangleClassified.averageCellConfidence,
|
|
4093
|
+
lowConfidenceCells: triangleClassified.lowConfidenceCells
|
|
4094
|
+
});
|
|
4095
|
+
if (triangleStructureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
|
|
4096
|
+
triangleRefinementCandidates.push({
|
|
4097
|
+
attempt,
|
|
4098
|
+
classified: triangleClassified,
|
|
4099
|
+
observedPalette,
|
|
4100
|
+
offsetX,
|
|
4101
|
+
offsetY,
|
|
4102
|
+
structureScore: triangleStructureScore,
|
|
4103
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
4104
|
+
samplingMode: "refined-triangle16"
|
|
4105
|
+
});
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
3046
4108
|
}
|
|
3047
4109
|
}
|
|
3048
4110
|
|
|
3049
|
-
|
|
4111
|
+
const sortRefinementCandidates = (items) => items.sort((a, b) =>
|
|
3050
4112
|
(b.structureScore - a.structureScore) ||
|
|
3051
4113
|
(b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
|
|
3052
4114
|
(a.classified.averageColorDistance - b.classified.averageColorDistance)
|
|
3053
4115
|
);
|
|
4116
|
+
sortRefinementCandidates(refinementCandidates);
|
|
4117
|
+
sortRefinementCandidates(triangleRefinementCandidates);
|
|
3054
4118
|
|
|
3055
4119
|
const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
|
|
3056
|
-
|
|
4120
|
+
// Keep separate per-encoding limits. A Triangle16 image can produce very
|
|
4121
|
+
// confident but meaningless centre samples, and those must not crowd out
|
|
4122
|
+
// the dual-region candidates during the final bounded recovery pass.
|
|
4123
|
+
const decodeCandidates = [
|
|
4124
|
+
...refinementCandidates.slice(0, decodeLimit),
|
|
4125
|
+
...triangleRefinementCandidates.slice(0, decodeLimit)
|
|
4126
|
+
];
|
|
4127
|
+
for (const candidate of decodeCandidates) {
|
|
3057
4128
|
try {
|
|
3058
4129
|
const decoded = decodeMatrix(candidate.classified.matrix, {
|
|
3059
4130
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3060
4131
|
cellConfidence: candidate.classified.confidence,
|
|
4132
|
+
cellAlternatives: candidate.classified.alternatives,
|
|
4133
|
+
cellEncodingHint: candidate.cellEncoding,
|
|
3061
4134
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3062
4135
|
});
|
|
3063
4136
|
if (decoded.version !== geometry.version) continue;
|
|
@@ -3068,7 +4141,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3068
4141
|
samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
|
|
3069
4142
|
colorCalibrated: true,
|
|
3070
4143
|
colorNormalization: candidate.attempt.colorNormalization,
|
|
3071
|
-
samplingMode:
|
|
4144
|
+
samplingMode: candidate.samplingMode,
|
|
3072
4145
|
geometry,
|
|
3073
4146
|
observedPalette: candidate.observedPalette,
|
|
3074
4147
|
averageColorDistance: candidate.classified.averageColorDistance,
|
|
@@ -3118,6 +4191,15 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3118
4191
|
for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
|
|
3119
4192
|
try {
|
|
3120
4193
|
const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
|
|
4194
|
+
const triangleSampled = options.highDensitySampling === false || options.triangle16 === false
|
|
4195
|
+
? null
|
|
4196
|
+
: sampleAxisAlignedTriangleGrid(
|
|
4197
|
+
imageData,
|
|
4198
|
+
bounds,
|
|
4199
|
+
size,
|
|
4200
|
+
Math.min(0.10, Math.max(0.04, radiusProfiles[profileIndex] * 0.48)),
|
|
4201
|
+
options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28
|
|
4202
|
+
);
|
|
3121
4203
|
const layout = createLayout(version);
|
|
3122
4204
|
const classifierAttempts = [];
|
|
3123
4205
|
try {
|
|
@@ -3157,45 +4239,72 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3157
4239
|
});
|
|
3158
4240
|
|
|
3159
4241
|
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
|
|
4242
|
+
const centerGrid = attempt.rgbGrid ?? sampled.rgbGrid;
|
|
4243
|
+
const samplingMode = profileIndex === 0 ? "axis" : "axis-center";
|
|
4244
|
+
const classifiedAttempts = [{
|
|
4245
|
+
classified: classifySampledRgbGrid(centerGrid, attempt.classifier, layout),
|
|
4246
|
+
cellEncoding: CELL_ENCODINGS.RGBW,
|
|
4247
|
+
samplingMode
|
|
4248
|
+
}];
|
|
4249
|
+
if (triangleSampled?.triangleGrid && attempt.rgbGrid === sampled.rgbGrid) {
|
|
4250
|
+
classifiedAttempts.push({
|
|
4251
|
+
classified: classifyTriangleSampledRgbGrid(
|
|
4252
|
+
centerGrid,
|
|
4253
|
+
triangleSampled.triangleGrid,
|
|
4254
|
+
attempt.classifier,
|
|
4255
|
+
layout
|
|
4256
|
+
),
|
|
4257
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
4258
|
+
samplingMode: `${samplingMode}-triangle16`
|
|
3193
4259
|
});
|
|
3194
|
-
accepted = true;
|
|
3195
|
-
break;
|
|
3196
|
-
} catch {
|
|
3197
|
-
// Try next classifier/profile.
|
|
3198
4260
|
}
|
|
4261
|
+
|
|
4262
|
+
for (const classifiedAttempt of classifiedAttempts) {
|
|
4263
|
+
try {
|
|
4264
|
+
const classified = classifiedAttempt.classified;
|
|
4265
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
4266
|
+
pushObservation(options, {
|
|
4267
|
+
version,
|
|
4268
|
+
matrix: classified.matrix,
|
|
4269
|
+
confidence: classified.confidence,
|
|
4270
|
+
alternatives: classified.alternatives,
|
|
4271
|
+
bounds,
|
|
4272
|
+
samplingMode: classifiedAttempt.samplingMode,
|
|
4273
|
+
colorNormalization: attempt.colorNormalization,
|
|
4274
|
+
cellEncoding: classifiedAttempt.cellEncoding,
|
|
4275
|
+
structureScore,
|
|
4276
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
4277
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
4278
|
+
});
|
|
4279
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
4280
|
+
structureTolerance: options.structureTolerance ?? 0.12,
|
|
4281
|
+
cellConfidence: classified.confidence,
|
|
4282
|
+
cellAlternatives: classified.alternatives,
|
|
4283
|
+
cellEncodingHint: classifiedAttempt.cellEncoding,
|
|
4284
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
4285
|
+
});
|
|
4286
|
+
candidates.push({
|
|
4287
|
+
...decoded,
|
|
4288
|
+
bounds,
|
|
4289
|
+
sampledVersion: version,
|
|
4290
|
+
moduleWidth: moduleW,
|
|
4291
|
+
moduleHeight: moduleH,
|
|
4292
|
+
perspectiveCorrected: false,
|
|
4293
|
+
colorCalibrated: attempt.calibrated,
|
|
4294
|
+
colorNormalization: attempt.colorNormalization,
|
|
4295
|
+
samplingMode: classifiedAttempt.samplingMode,
|
|
4296
|
+
averageColorDistance: classified.averageColorDistance,
|
|
4297
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
4298
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
4299
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
4300
|
+
});
|
|
4301
|
+
accepted = true;
|
|
4302
|
+
break;
|
|
4303
|
+
} catch {
|
|
4304
|
+
// Try the next cell encoding/classifier/profile.
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
if (accepted) break;
|
|
3199
4308
|
}
|
|
3200
4309
|
} catch {
|
|
3201
4310
|
// Try a narrower centre sample, then next version.
|
|
@@ -3223,6 +4332,31 @@ export const STRESS_PROFILES = Object.freeze([
|
|
|
3223
4332
|
Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
|
|
3224
4333
|
]);
|
|
3225
4334
|
|
|
4335
|
+
|
|
4336
|
+
export const RELIABILITY_PROFILES = Object.freeze([
|
|
4337
|
+
Object.freeze({ id: "clean", label: "Clean reference", category: "Baseline", type: "clean", severity: 0, weight: 2, suite: "quick" }),
|
|
4338
|
+
Object.freeze({ id: "blur", label: "Lens blur", category: "Optics", type: "blur", severity: 0.40, weight: 1, suite: "quick" }),
|
|
4339
|
+
Object.freeze({ id: "motion", label: "Motion blur", category: "Optics", type: "motion-blur", severity: 0.42, weight: 1, suite: "full" }),
|
|
4340
|
+
Object.freeze({ id: "dark", label: "Low light", category: "Exposure", type: "brightness-low", severity: 0.50, weight: 1, suite: "quick" }),
|
|
4341
|
+
Object.freeze({ id: "bright", label: "High exposure", category: "Exposure", type: "brightness-high", severity: 0.38, weight: 1, suite: "full" }),
|
|
4342
|
+
Object.freeze({ id: "shadow", label: "Gradient shadow", category: "Lighting", type: "shadow", severity: 0.58, weight: 1.2, suite: "quick" }),
|
|
4343
|
+
Object.freeze({ id: "glare", label: "Specular glare", category: "Lighting", type: "glare", severity: 0.34, weight: 1.1, suite: "full" }),
|
|
4344
|
+
Object.freeze({ id: "warm", label: "Warm color cast", category: "Color", type: "warm", severity: 0.62, weight: 1, suite: "quick" }),
|
|
4345
|
+
Object.freeze({ id: "cool", label: "Cool color cast", category: "Color", type: "cool", severity: 0.60, weight: 1, suite: "full" }),
|
|
4346
|
+
Object.freeze({ id: "contrast", label: "Contrast loss", category: "Color", type: "contrast-loss", severity: 0.46, weight: 1, suite: "full" }),
|
|
4347
|
+
Object.freeze({ id: "noise", label: "Sensor noise", category: "Sensor", type: "noise", severity: 0.42, weight: 0.9, suite: "full" }),
|
|
4348
|
+
Object.freeze({ id: "jpeg", label: "JPEG-like damage", category: "Resampling", type: "jpeg", severity: 0.52, weight: 0.8, suite: "full" }),
|
|
4349
|
+
Object.freeze({ id: "downscale", label: "Aggressive downscale", category: "Resampling", type: "downscale", severity: 0.55, weight: 1, suite: "quick" }),
|
|
4350
|
+
Object.freeze({ id: "perspective-2d", label: "Projective skew", category: "Perspective", type: "perspective", severity: 0.52, weight: 1.3, suite: "quick" }),
|
|
4351
|
+
Object.freeze({ id: "yaw-35", label: "3D yaw 35°", category: "Perspective", type: "perspective-3d", severity: 0.55, yawDegrees: 35, pitchDegrees: 0, rollDegrees: 0, weight: 1.4, suite: "quick" }),
|
|
4352
|
+
Object.freeze({ id: "pitch-30", label: "3D pitch 30°", category: "Perspective", type: "perspective-3d", severity: 0.50, yawDegrees: 0, pitchDegrees: 30, rollDegrees: 0, weight: 1.3, suite: "full" }),
|
|
4353
|
+
Object.freeze({ id: "z-rotation-55", label: "Z rotation 55°", category: "Perspective", type: "perspective-3d", severity: 0.45, yawDegrees: 0, pitchDegrees: 0, rollDegrees: 55, weight: 1.1, suite: "quick" }),
|
|
4354
|
+
Object.freeze({ id: "combined-3d", label: "Combined 3D tilt", category: "Perspective", type: "perspective-3d", severity: 0.62, yawDegrees: 30, pitchDegrees: 15, rollDegrees: 18, weight: 1.5, suite: "full" }),
|
|
4355
|
+
Object.freeze({ id: "yaw-55", label: "Extreme yaw 55°", category: "Extreme perspective", type: "perspective-3d", severity: 0.82, yawDegrees: 55, pitchDegrees: 0, rollDegrees: 0, weight: 1.5, suite: "extreme" }),
|
|
4356
|
+
Object.freeze({ id: "pitch-40", label: "Extreme pitch 40°", category: "Extreme perspective", type: "perspective-3d", severity: 0.78, yawDegrees: 0, pitchDegrees: 40, rollDegrees: 0, weight: 1.4, suite: "extreme" }),
|
|
4357
|
+
Object.freeze({ id: "z-rotation-75", label: "Z rotation 75°", category: "Extreme perspective", type: "perspective-3d", severity: 0.72, yawDegrees: 0, pitchDegrees: 0, rollDegrees: 75, weight: 1.1, suite: "extreme" })
|
|
4358
|
+
]);
|
|
4359
|
+
|
|
3226
4360
|
function cloneImageDataLike(imageData) {
|
|
3227
4361
|
return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
|
|
3228
4362
|
}
|
|
@@ -3307,7 +4441,7 @@ function perspectiveStress(imageData, severity) {
|
|
|
3307
4441
|
{ x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
|
|
3308
4442
|
{ x: inset * 0.20, y: height - 1 - inset * 0.95 }
|
|
3309
4443
|
];
|
|
3310
|
-
const inverse = computeHomography(
|
|
4444
|
+
const inverse = computeHomography(source, destination);
|
|
3311
4445
|
const out = new Uint8ClampedArray(width * height * 4);
|
|
3312
4446
|
out.fill(255);
|
|
3313
4447
|
for (let y = 0; y < height; y++) {
|
|
@@ -3322,13 +4456,151 @@ function perspectiveStress(imageData, severity) {
|
|
|
3322
4456
|
return { width, height, data: out };
|
|
3323
4457
|
}
|
|
3324
4458
|
|
|
4459
|
+
|
|
4460
|
+
function fillWhiteRgba(data) {
|
|
4461
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
4462
|
+
data[i] = 255;
|
|
4463
|
+
data[i + 1] = 255;
|
|
4464
|
+
data[i + 2] = 255;
|
|
4465
|
+
data[i + 3] = 255;
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4469
|
+
function projectPlaneQuad(width, height, options = {}) {
|
|
4470
|
+
const pitch = Number(options.pitchDegrees ?? options.pitch ?? 0) * Math.PI / 180;
|
|
4471
|
+
const yaw = Number(options.yawDegrees ?? options.yaw ?? 0) * Math.PI / 180;
|
|
4472
|
+
const roll = Number(options.rollDegrees ?? options.roll ?? 0) * Math.PI / 180;
|
|
4473
|
+
const cameraDistance = Math.max(1.8, Number(options.cameraDistance ?? 3.0));
|
|
4474
|
+
const fill = clampNumber(Number(options.fill ?? 0.84), 0.45, 0.94);
|
|
4475
|
+
const aspect = width / Math.max(1, height);
|
|
4476
|
+
// Point order intentionally follows TL, TR, BL, BR to match the
|
|
4477
|
+
// destination->source homography convention used throughout the scanner.
|
|
4478
|
+
const corners = [
|
|
4479
|
+
{ x: -aspect, y: -1, z: 0 },
|
|
4480
|
+
{ x: aspect, y: -1, z: 0 },
|
|
4481
|
+
{ x: -aspect, y: 1, z: 0 },
|
|
4482
|
+
{ x: aspect, y: 1, z: 0 }
|
|
4483
|
+
];
|
|
4484
|
+
const cp = Math.cos(pitch), sp = Math.sin(pitch);
|
|
4485
|
+
const cy = Math.cos(yaw), sy = Math.sin(yaw);
|
|
4486
|
+
const cr = Math.cos(roll), sr = Math.sin(roll);
|
|
4487
|
+
const projected = corners.map((point) => {
|
|
4488
|
+
// Yaw around Y.
|
|
4489
|
+
const x1 = point.x * cy + point.z * sy;
|
|
4490
|
+
const z1 = -point.x * sy + point.z * cy;
|
|
4491
|
+
const y1 = point.y;
|
|
4492
|
+
// Pitch around X.
|
|
4493
|
+
const y2 = y1 * cp - z1 * sp;
|
|
4494
|
+
const z2 = y1 * sp + z1 * cp;
|
|
4495
|
+
const x2 = x1;
|
|
4496
|
+
// Roll around Z, i.e. in-plane camera/code rotation.
|
|
4497
|
+
const x3 = x2 * cr - y2 * sr;
|
|
4498
|
+
const y3 = x2 * sr + y2 * cr;
|
|
4499
|
+
const depth = Math.max(0.35, cameraDistance + z2);
|
|
4500
|
+
const perspective = cameraDistance / depth;
|
|
4501
|
+
return { x: x3 * perspective, y: y3 * perspective };
|
|
4502
|
+
});
|
|
4503
|
+
const minX = Math.min(...projected.map((point) => point.x));
|
|
4504
|
+
const maxX = Math.max(...projected.map((point) => point.x));
|
|
4505
|
+
const minY = Math.min(...projected.map((point) => point.y));
|
|
4506
|
+
const maxY = Math.max(...projected.map((point) => point.y));
|
|
4507
|
+
const projectedWidth = Math.max(0.001, maxX - minX);
|
|
4508
|
+
const projectedHeight = Math.max(0.001, maxY - minY);
|
|
4509
|
+
const scale = Math.min(width * fill / projectedWidth, height * fill / projectedHeight);
|
|
4510
|
+
const centerX = (minX + maxX) / 2;
|
|
4511
|
+
const centerY = (minY + maxY) / 2;
|
|
4512
|
+
return projected.map((point) => ({
|
|
4513
|
+
x: width / 2 + (point.x - centerX) * scale,
|
|
4514
|
+
y: height / 2 + (point.y - centerY) * scale
|
|
4515
|
+
}));
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
function warpToQuad(imageData, destination) {
|
|
4519
|
+
const { width, height } = imageData;
|
|
4520
|
+
const source = [
|
|
4521
|
+
{ x: 0, y: 0 },
|
|
4522
|
+
{ x: width - 1, y: 0 },
|
|
4523
|
+
{ x: 0, y: height - 1 },
|
|
4524
|
+
{ x: width - 1, y: height - 1 }
|
|
4525
|
+
];
|
|
4526
|
+
const inverse = computeHomography(source, destination);
|
|
4527
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
4528
|
+
fillWhiteRgba(out);
|
|
4529
|
+
for (let y = 0; y < height; y++) {
|
|
4530
|
+
for (let x = 0; x < width; x++) {
|
|
4531
|
+
const src = projectPoint(inverse, x, y);
|
|
4532
|
+
if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
|
|
4533
|
+
const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
|
|
4534
|
+
const p = (y * width + x) * 4;
|
|
4535
|
+
out[p] = rgba[0];
|
|
4536
|
+
out[p + 1] = rgba[1];
|
|
4537
|
+
out[p + 2] = rgba[2];
|
|
4538
|
+
out[p + 3] = 255;
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
return { width, height, data: out };
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
function perspective3dStress(imageData, severity, options = {}) {
|
|
4545
|
+
const s = clampNumber(Number(severity), 0, 1);
|
|
4546
|
+
const destination = projectPlaneQuad(imageData.width, imageData.height, {
|
|
4547
|
+
pitchDegrees: options.pitchDegrees ?? (8 + s * 28),
|
|
4548
|
+
yawDegrees: options.yawDegrees ?? (12 + s * 43),
|
|
4549
|
+
rollDegrees: options.rollDegrees ?? (s * 18),
|
|
4550
|
+
cameraDistance: options.cameraDistance ?? 3,
|
|
4551
|
+
fill: options.fill ?? (0.88 - s * 0.06)
|
|
4552
|
+
});
|
|
4553
|
+
return warpToQuad(imageData, destination);
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
function motionBlurImage(imageData, severity, options = {}) {
|
|
4557
|
+
const radius = Math.max(1, Math.round(Number(options.radius ?? (2 + severity * 5))));
|
|
4558
|
+
const slope = Number(options.slope ?? 0.35);
|
|
4559
|
+
const { width, height } = imageData;
|
|
4560
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
4561
|
+
for (let y = 0; y < height; y++) {
|
|
4562
|
+
for (let x = 0; x < width; x++) {
|
|
4563
|
+
const p = (y * width + x) * 4;
|
|
4564
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
4565
|
+
let sum = 0;
|
|
4566
|
+
let count = 0;
|
|
4567
|
+
for (let k = -radius; k <= radius; k++) {
|
|
4568
|
+
const sx = x + k;
|
|
4569
|
+
const sy = y + Math.round(k * slope);
|
|
4570
|
+
if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue;
|
|
4571
|
+
sum += imageData.data[(sy * width + sx) * 4 + ch];
|
|
4572
|
+
count++;
|
|
4573
|
+
}
|
|
4574
|
+
out[p + ch] = count ? sum / count : imageData.data[p + ch];
|
|
4575
|
+
}
|
|
4576
|
+
out[p + 3] = 255;
|
|
4577
|
+
}
|
|
4578
|
+
}
|
|
4579
|
+
return { width, height, data: out };
|
|
4580
|
+
}
|
|
4581
|
+
|
|
4582
|
+
function deterministicNoise(x, y, channel) {
|
|
4583
|
+
let value = Math.imul((x + 1) ^ (channel * 374761393), 668265263) ^ Math.imul(y + 11, 2246822519);
|
|
4584
|
+
value = Math.imul(value ^ (value >>> 13), 1274126177);
|
|
4585
|
+
return ((value ^ (value >>> 16)) >>> 0) / 0xffffffff * 2 - 1;
|
|
4586
|
+
}
|
|
4587
|
+
|
|
3325
4588
|
/** Apply one deterministic camera/print-style distortion to ImageData. */
|
|
3326
4589
|
export function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
|
|
3327
4590
|
assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
|
|
3328
4591
|
const s = clampNumber(Number(severity), 0, 1);
|
|
3329
4592
|
if (type === "clean") return cloneImageDataLike(imageData);
|
|
3330
4593
|
if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
|
|
4594
|
+
if (type === "motion-blur") return motionBlurImage(imageData, s, options);
|
|
3331
4595
|
if (type === "perspective") return perspectiveStress(imageData, s);
|
|
4596
|
+
if (type === "perspective-3d" || type === "rotate-z") {
|
|
4597
|
+
return perspective3dStress(imageData, s, {
|
|
4598
|
+
...options,
|
|
4599
|
+
pitchDegrees: type === "rotate-z" ? 0 : options.pitchDegrees,
|
|
4600
|
+
yawDegrees: type === "rotate-z" ? 0 : options.yawDegrees,
|
|
4601
|
+
rollDegrees: type === "rotate-z" ? (options.rollDegrees ?? 20 + s * 70) : options.rollDegrees
|
|
4602
|
+
});
|
|
4603
|
+
}
|
|
3332
4604
|
if (type === "downscale") {
|
|
3333
4605
|
const scale = Math.max(0.20, 1 - s * 0.72);
|
|
3334
4606
|
const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
|
|
@@ -3357,7 +4629,22 @@ export function applyStressDistortion(imageData, type, severity = 0.5, options =
|
|
|
3357
4629
|
value += block * s * 3;
|
|
3358
4630
|
} else if (type === "warm") {
|
|
3359
4631
|
if (ch === 0) value *= 1 + s * 0.12;
|
|
4632
|
+
if (ch === 1) value *= 1 + s * 0.03;
|
|
3360
4633
|
if (ch === 2) value *= 1 - s * 0.22;
|
|
4634
|
+
} else if (type === "cool") {
|
|
4635
|
+
if (ch === 0) value *= 1 - s * 0.20;
|
|
4636
|
+
if (ch === 1) value *= 1 + s * 0.02;
|
|
4637
|
+
if (ch === 2) value *= 1 + s * 0.14;
|
|
4638
|
+
} else if (type === "noise") {
|
|
4639
|
+
value += deterministicNoise(x, y, ch) * (5 + s * 34);
|
|
4640
|
+
} else if (type === "glare") {
|
|
4641
|
+
const nx = (x / Math.max(1, width - 1) - 0.68) / 0.28;
|
|
4642
|
+
const ny = (y / Math.max(1, height - 1) - 0.30) / 0.20;
|
|
4643
|
+
const falloff = Math.exp(-(nx * nx + ny * ny) * 2.2);
|
|
4644
|
+
value += (255 - value) * falloff * s * 0.82;
|
|
4645
|
+
} else if (type === "gamma") {
|
|
4646
|
+
const gamma = 1 + (s - 0.5) * 1.2;
|
|
4647
|
+
value = 255 * Math.pow(value / 255, gamma);
|
|
3361
4648
|
}
|
|
3362
4649
|
data[p + ch] = clampNumber(Math.round(value), 0, 255);
|
|
3363
4650
|
}
|
|
@@ -3427,6 +4714,112 @@ export function runImageStressTest(imageData, expected = {}, options = {}) {
|
|
|
3427
4714
|
};
|
|
3428
4715
|
}
|
|
3429
4716
|
|
|
4717
|
+
|
|
4718
|
+
function reliabilityProfilesForSuite(suite = "full") {
|
|
4719
|
+
const rank = { quick: 0, full: 1, extreme: 2 };
|
|
4720
|
+
const requested = rank[suite] ?? rank.full;
|
|
4721
|
+
return RELIABILITY_PROFILES.filter((profile) => (rank[profile.suite] ?? 1) <= requested);
|
|
4722
|
+
}
|
|
4723
|
+
|
|
4724
|
+
/** Run the broader Reliability Lab suite and return category-level scores. */
|
|
4725
|
+
export function runReliabilityLab(imageData, expected = {}, options = {}) {
|
|
4726
|
+
const suite = options.suite ?? "full";
|
|
4727
|
+
const profiles = options.profiles ?? reliabilityProfilesForSuite(suite);
|
|
4728
|
+
const report = runImageStressTest(imageData, expected, { ...options, profiles });
|
|
4729
|
+
const categoryMap = new Map();
|
|
4730
|
+
for (const result of report.results) {
|
|
4731
|
+
const profile = profiles.find((item) => item.id === result.id) ?? {};
|
|
4732
|
+
const category = profile.category ?? "Other";
|
|
4733
|
+
const weight = Number(profile.weight ?? 1);
|
|
4734
|
+
if (!categoryMap.has(category)) categoryMap.set(category, { category, passedWeight: 0, totalWeight: 0, passed: 0, total: 0 });
|
|
4735
|
+
const entry = categoryMap.get(category);
|
|
4736
|
+
entry.totalWeight += weight;
|
|
4737
|
+
entry.total++;
|
|
4738
|
+
if (result.passed) {
|
|
4739
|
+
entry.passedWeight += weight;
|
|
4740
|
+
entry.passed++;
|
|
4741
|
+
}
|
|
4742
|
+
result.category = category;
|
|
4743
|
+
result.pitchDegrees = profile.pitchDegrees ?? null;
|
|
4744
|
+
result.yawDegrees = profile.yawDegrees ?? null;
|
|
4745
|
+
result.rollDegrees = profile.rollDegrees ?? null;
|
|
4746
|
+
}
|
|
4747
|
+
const categories = [...categoryMap.values()].map((entry) => ({
|
|
4748
|
+
category: entry.category,
|
|
4749
|
+
passed: entry.passed,
|
|
4750
|
+
total: entry.total,
|
|
4751
|
+
score: entry.totalWeight ? entry.passedWeight / entry.totalWeight * 100 : 0
|
|
4752
|
+
})).sort((a, b) => a.score - b.score || a.category.localeCompare(b.category));
|
|
4753
|
+
return {
|
|
4754
|
+
...report,
|
|
4755
|
+
suite,
|
|
4756
|
+
categories,
|
|
4757
|
+
weakestCategory: categories[0] ?? null
|
|
4758
|
+
};
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4761
|
+
/** Sweep one 3D perspective axis to measure the largest passing angle. */
|
|
4762
|
+
export function runPerspectiveSweep(imageData, expected = {}, options = {}) {
|
|
4763
|
+
const axis = options.axis ?? "yaw";
|
|
4764
|
+
const defaultAngles = axis === "roll"
|
|
4765
|
+
? [0, 25, 45, 60, 75]
|
|
4766
|
+
: [0, 15, 25, 35, 45, 55];
|
|
4767
|
+
const angles = (options.angles ?? defaultAngles).map(Number).filter(Number.isFinite);
|
|
4768
|
+
const results = [];
|
|
4769
|
+
let maxPassedAngle = null;
|
|
4770
|
+
for (const angle of angles) {
|
|
4771
|
+
const transform = {
|
|
4772
|
+
pitchDegrees: Number(options.pitchDegrees ?? 0),
|
|
4773
|
+
yawDegrees: Number(options.yawDegrees ?? 0),
|
|
4774
|
+
rollDegrees: Number(options.rollDegrees ?? 0),
|
|
4775
|
+
fill: options.fill ?? 0.84,
|
|
4776
|
+
cameraDistance: options.cameraDistance ?? 3
|
|
4777
|
+
};
|
|
4778
|
+
if (axis === "pitch") transform.pitchDegrees = angle;
|
|
4779
|
+
else if (axis === "roll" || axis === "z") transform.rollDegrees = angle;
|
|
4780
|
+
else transform.yawDegrees = angle;
|
|
4781
|
+
const distorted = applyStressDistortion(imageData, "perspective-3d", 0.5, transform);
|
|
4782
|
+
const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
4783
|
+
try {
|
|
4784
|
+
const decoded = scanImageData(distorted, {
|
|
4785
|
+
minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
|
|
4786
|
+
maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
|
|
4787
|
+
debug: false
|
|
4788
|
+
});
|
|
4789
|
+
const passed = expected.crc32 == null || decoded.crc32 === expected.crc32;
|
|
4790
|
+
if (passed) maxPassedAngle = Math.max(maxPassedAngle ?? angle, angle);
|
|
4791
|
+
results.push({
|
|
4792
|
+
angle,
|
|
4793
|
+
axis,
|
|
4794
|
+
passed,
|
|
4795
|
+
confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
|
|
4796
|
+
correctedSymbols: decoded.correctedSymbols ?? 0,
|
|
4797
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
4798
|
+
image: options.includeImages ? distorted : undefined,
|
|
4799
|
+
error: passed ? null : "Decoded payload did not match expected CRC."
|
|
4800
|
+
});
|
|
4801
|
+
} catch (error) {
|
|
4802
|
+
results.push({
|
|
4803
|
+
angle,
|
|
4804
|
+
axis,
|
|
4805
|
+
passed: false,
|
|
4806
|
+
confidence: 0,
|
|
4807
|
+
correctedSymbols: null,
|
|
4808
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
4809
|
+
image: options.includeImages ? distorted : undefined,
|
|
4810
|
+
error: error.message
|
|
4811
|
+
});
|
|
4812
|
+
}
|
|
4813
|
+
}
|
|
4814
|
+
return {
|
|
4815
|
+
axis,
|
|
4816
|
+
maxPassedAngle,
|
|
4817
|
+
passed: results.filter((item) => item.passed).length,
|
|
4818
|
+
total: results.length,
|
|
4819
|
+
results
|
|
4820
|
+
};
|
|
4821
|
+
}
|
|
4822
|
+
|
|
3430
4823
|
/** Render and run the standard scanability torture suite. */
|
|
3431
4824
|
export function assessScanability(code, renderOptions = {}, options = {}) {
|
|
3432
4825
|
assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
|
|
@@ -3592,6 +4985,36 @@ export function scanImageData(imageData, options = {}) {
|
|
|
3592
4985
|
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
3593
4986
|
}
|
|
3594
4987
|
|
|
4988
|
+
// Triangle16 has half-cell color regions, so geometry that is adequate for
|
|
4989
|
+
// solid RGBW cells can still be off by a few tenths of a module and mix the
|
|
4990
|
+
// two triangle colors. If normal geometry was found but no payload decoded,
|
|
4991
|
+
// retry geometry once with finer alignment localization. This stays off the
|
|
4992
|
+
// clean/normal camera fast path.
|
|
4993
|
+
if (
|
|
4994
|
+
options.perspective !== false &&
|
|
4995
|
+
options.preciseAlignmentRecovery !== false &&
|
|
4996
|
+
!options._preciseAlignmentRecovery &&
|
|
4997
|
+
geometryCollector.length > 0
|
|
4998
|
+
) {
|
|
4999
|
+
try {
|
|
5000
|
+
const precise = tryPerspectiveScan(imageData, {
|
|
5001
|
+
...scanOptions,
|
|
5002
|
+
preciseAlignment: true,
|
|
5003
|
+
_preciseAlignmentRecovery: true,
|
|
5004
|
+
_diagnosticLabel: "precise-alignment"
|
|
5005
|
+
});
|
|
5006
|
+
if (precise) {
|
|
5007
|
+
return decorateScanResult({
|
|
5008
|
+
...precise,
|
|
5009
|
+
geometryRefined: true,
|
|
5010
|
+
recoveryMode: precise.recoveryMode ?? "precise-alignment"
|
|
5011
|
+
}, debugContext, options);
|
|
5012
|
+
}
|
|
5013
|
+
} catch {
|
|
5014
|
+
// Continue to color recovery and axis-aligned fallback.
|
|
5015
|
+
}
|
|
5016
|
+
}
|
|
5017
|
+
|
|
3595
5018
|
// If the normal scanner could see QuadQR geometry but could not decode the
|
|
3596
5019
|
// colors, retry the same frame after a single global Auto Tone / Contrast /
|
|
3597
5020
|
// Color-style pass. This is deliberately after the normal path so clean
|
|
@@ -3906,47 +5329,111 @@ function selectBestFrameObservation(observations) {
|
|
|
3906
5329
|
)[0] ?? null;
|
|
3907
5330
|
}
|
|
3908
5331
|
|
|
5332
|
+
function observationDataAgreement(a, b) {
|
|
5333
|
+
if (!a?.matrix || !b?.matrix || a.version !== b.version || a.matrix.length !== b.matrix.length) return 0;
|
|
5334
|
+
if ((a.cellEncoding ?? null) !== (b.cellEncoding ?? null)) return 0;
|
|
5335
|
+
const layout = createLayout(a.version);
|
|
5336
|
+
let agreed = 0;
|
|
5337
|
+
let compared = 0;
|
|
5338
|
+
for (const [row, col] of layout.dataPositions) {
|
|
5339
|
+
const ca = a.confidence?.[row]?.[col] ?? 0;
|
|
5340
|
+
const cb = b.confidence?.[row]?.[col] ?? 0;
|
|
5341
|
+
if (Math.min(ca, cb) < 0.48) continue;
|
|
5342
|
+
compared++;
|
|
5343
|
+
if (a.matrix[row][col] === b.matrix[row][col]) agreed++;
|
|
5344
|
+
}
|
|
5345
|
+
return compared ? agreed / compared : 0;
|
|
5346
|
+
}
|
|
5347
|
+
|
|
3909
5348
|
function combineFrameObservations(observations) {
|
|
3910
5349
|
if (!observations?.length) return null;
|
|
3911
5350
|
const version = observations[0].version;
|
|
5351
|
+
const cellEncoding = observations[0].cellEncoding ?? null;
|
|
3912
5352
|
const size = observations[0].matrix.length;
|
|
3913
|
-
if (!observations.every((item) =>
|
|
5353
|
+
if (!observations.every((item) =>
|
|
5354
|
+
item.version === version &&
|
|
5355
|
+
(item.cellEncoding ?? null) === cellEncoding &&
|
|
5356
|
+
item.matrix.length === size
|
|
5357
|
+
)) return null;
|
|
3914
5358
|
|
|
3915
5359
|
const matrix = make2D(size, CELL.WHITE);
|
|
3916
5360
|
const confidence = make2D(size, 0);
|
|
5361
|
+
const alternatives = make2D(size, null);
|
|
5362
|
+
const frameCount = observations.length;
|
|
5363
|
+
const latest = observations[frameCount - 1];
|
|
5364
|
+
let agreementSum = 0;
|
|
5365
|
+
let agreementCount = 0;
|
|
5366
|
+
for (let i = 0; i < frameCount - 1; i++) {
|
|
5367
|
+
const agreement = observationDataAgreement(observations[i], latest);
|
|
5368
|
+
if (agreement > 0) {
|
|
5369
|
+
agreementSum += agreement;
|
|
5370
|
+
agreementCount++;
|
|
5371
|
+
}
|
|
5372
|
+
}
|
|
5373
|
+
|
|
3917
5374
|
for (let r = 0; r < size; r++) {
|
|
3918
5375
|
for (let c = 0; c < size; c++) {
|
|
3919
5376
|
const votes = new Map();
|
|
3920
5377
|
const confidenceByCell = new Map();
|
|
3921
|
-
for (
|
|
5378
|
+
for (let index = 0; index < observations.length; index++) {
|
|
5379
|
+
const observation = observations[index];
|
|
3922
5380
|
const cell = observation.matrix[r][c];
|
|
3923
5381
|
const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
|
|
3924
|
-
const
|
|
5382
|
+
const age = observations.length - 1 - index;
|
|
5383
|
+
const recency = Math.pow(0.88, age);
|
|
5384
|
+
const frameQuality = clampNumber(
|
|
5385
|
+
0.45 +
|
|
5386
|
+
(observation.structureScore ?? 0.82) * 0.30 +
|
|
5387
|
+
(observation.averageCellConfidence ?? 0.5) * 0.25,
|
|
5388
|
+
0.45,
|
|
5389
|
+
1
|
|
5390
|
+
);
|
|
5391
|
+
const weight = (0.25 + sourceConfidence * 0.75) * recency * frameQuality;
|
|
3925
5392
|
votes.set(cell, (votes.get(cell) ?? 0) + weight);
|
|
3926
5393
|
const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
|
|
3927
5394
|
stats.weighted += sourceConfidence * weight;
|
|
3928
5395
|
stats.weight += weight;
|
|
3929
5396
|
confidenceByCell.set(cell, stats);
|
|
5397
|
+
|
|
5398
|
+
// The per-frame second hypothesis also contributes weak evidence. It
|
|
5399
|
+
// never outranks a strong primary vote by itself, but it preserves a
|
|
5400
|
+
// plausible alternative for Spectrum ECC 2.0 when several frames are
|
|
5401
|
+
// individually ambiguous in the same region.
|
|
5402
|
+
const alternative = observation.alternatives?.[r]?.[c];
|
|
5403
|
+
if (Number.isInteger(alternative) && alternative !== cell) {
|
|
5404
|
+
const altWeight = weight * (1 - sourceConfidence) * 0.42;
|
|
5405
|
+
votes.set(alternative, (votes.get(alternative) ?? 0) + altWeight);
|
|
5406
|
+
}
|
|
3930
5407
|
}
|
|
3931
5408
|
|
|
3932
5409
|
const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
|
|
3933
5410
|
const [bestCell, bestWeight] = ranked[0];
|
|
3934
|
-
const secondWeight = ranked[1]
|
|
5411
|
+
const [secondCell, secondWeight] = ranked[1] ?? [null, 0];
|
|
3935
5412
|
const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
|
|
3936
5413
|
const sourceStats = confidenceByCell.get(bestCell);
|
|
3937
5414
|
const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
|
|
3938
5415
|
const support = totalWeight ? bestWeight / totalWeight : 0;
|
|
3939
5416
|
const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
|
|
3940
|
-
const
|
|
5417
|
+
const temporalBoost = clampNumber((frameCount - 1) / 5, 0, 0.18);
|
|
5418
|
+
const fusedConfidence = clampNumber(
|
|
5419
|
+
sourceConfidence * 0.50 + support * 0.30 + margin * 0.20 + temporalBoost,
|
|
5420
|
+
0,
|
|
5421
|
+
1
|
|
5422
|
+
);
|
|
3941
5423
|
|
|
3942
5424
|
matrix[r][c] = bestCell;
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
// cells as erasures instead of hard, supposedly-certain errors.
|
|
3946
|
-
confidence[r][c] = clampNumber(sourceConfidence * 0.58 + agreement * 0.42, 0, 1);
|
|
5425
|
+
confidence[r][c] = fusedConfidence;
|
|
5426
|
+
alternatives[r][c] = Number.isInteger(secondCell) && secondCell !== bestCell ? secondCell : null;
|
|
3947
5427
|
}
|
|
3948
5428
|
}
|
|
3949
|
-
return {
|
|
5429
|
+
return {
|
|
5430
|
+
version,
|
|
5431
|
+
cellEncoding,
|
|
5432
|
+
matrix,
|
|
5433
|
+
confidence,
|
|
5434
|
+
alternatives,
|
|
5435
|
+
frameAgreement: agreementCount ? agreementSum / agreementCount : 1
|
|
5436
|
+
};
|
|
3950
5437
|
}
|
|
3951
5438
|
|
|
3952
5439
|
async function improveCameraTrack(stream) {
|
|
@@ -3995,6 +5482,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
3995
5482
|
|
|
3996
5483
|
const scanInterval = Math.max(80, options.scanInterval ?? 180);
|
|
3997
5484
|
const scratchCanvas = document.createElement("canvas");
|
|
5485
|
+
const highResolutionCanvas = document.createElement("canvas");
|
|
3998
5486
|
const multiFrameEnabled = options.multiFrame !== false;
|
|
3999
5487
|
const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
|
|
4000
5488
|
const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
|
|
@@ -4002,6 +5490,12 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4002
5490
|
const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
|
|
4003
5491
|
const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
|
|
4004
5492
|
const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
|
|
5493
|
+
const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
|
|
5494
|
+
const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
|
|
5495
|
+
const cameraHighResolutionMaxDimension = Math.max(
|
|
5496
|
+
baseCameraMaxDimension,
|
|
5497
|
+
Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
|
|
5498
|
+
);
|
|
4005
5499
|
let missStreak = 0;
|
|
4006
5500
|
let stopped = false;
|
|
4007
5501
|
let busy = false;
|
|
@@ -4049,7 +5543,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4049
5543
|
|
|
4050
5544
|
const scanNow = () => scanVideoFrame(video, {
|
|
4051
5545
|
...options,
|
|
4052
|
-
maxDimension:
|
|
5546
|
+
maxDimension: baseCameraMaxDimension,
|
|
4053
5547
|
canvas: scratchCanvas
|
|
4054
5548
|
});
|
|
4055
5549
|
|
|
@@ -4078,10 +5572,25 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4078
5572
|
if (!multiFrameEnabled) return null;
|
|
4079
5573
|
const best = selectBestFrameObservation(observations);
|
|
4080
5574
|
if (!best) return null;
|
|
4081
|
-
const
|
|
5575
|
+
const trackKey = `${best.version}:${best.cellEncoding ?? "auto"}`;
|
|
5576
|
+
let history = observationHistory.get(trackKey) ?? [];
|
|
5577
|
+
|
|
5578
|
+
// Only fuse observations that appear to describe the same payload. Finder
|
|
5579
|
+
// structure alone is not enough because two different QuadQR symbols of the
|
|
5580
|
+
// same version would otherwise contaminate each other's history. High-
|
|
5581
|
+
// confidence data-cell agreement gives us a cheap identity check without
|
|
5582
|
+
// needing the header to decode first.
|
|
5583
|
+
if (history.length) {
|
|
5584
|
+
const agreement = observationDataAgreement(history[history.length - 1], best);
|
|
5585
|
+
const minimumAgreement = best.cellEncoding === CELL_ENCODINGS.TRIANGLE16
|
|
5586
|
+
? (options.multiFrameMinAgreementHighDensity ?? 0.58)
|
|
5587
|
+
: (options.multiFrameMinAgreement ?? 0.62);
|
|
5588
|
+
if (agreement > 0 && agreement < minimumAgreement) history = [];
|
|
5589
|
+
}
|
|
5590
|
+
|
|
4082
5591
|
history.push(best);
|
|
4083
5592
|
while (history.length > multiFrameWindow) history.shift();
|
|
4084
|
-
observationHistory.set(
|
|
5593
|
+
observationHistory.set(trackKey, history);
|
|
4085
5594
|
if (history.length < multiFrameMinFrames) return null;
|
|
4086
5595
|
|
|
4087
5596
|
const combined = combineFrameObservations(history);
|
|
@@ -4090,16 +5599,21 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4090
5599
|
const decoded = decodeMatrix(combined.matrix, {
|
|
4091
5600
|
structureTolerance: options.structureTolerance ?? 0.20,
|
|
4092
5601
|
cellConfidence: combined.confidence,
|
|
4093
|
-
|
|
5602
|
+
cellAlternatives: combined.alternatives,
|
|
5603
|
+
cellEncodingHint: best.cellEncoding ?? undefined,
|
|
5604
|
+
maxErasureConfidence: options.maxErasureConfidence,
|
|
5605
|
+
softDecoding: options.softDecoding
|
|
4094
5606
|
});
|
|
4095
5607
|
if (decoded.version !== best.version) return null;
|
|
4096
5608
|
return {
|
|
4097
5609
|
...decoded,
|
|
4098
5610
|
perspectiveCorrected: Boolean(best.geometry),
|
|
4099
5611
|
colorCalibrated: true,
|
|
4100
|
-
colorNormalization: "multi-frame-
|
|
4101
|
-
samplingMode: "multi-frame-
|
|
5612
|
+
colorNormalization: "multi-frame-confidence-fusion",
|
|
5613
|
+
samplingMode: "multi-frame-confidence-fusion",
|
|
4102
5614
|
multiFrameCombined: history.length,
|
|
5615
|
+
multiFrameAgreement: combined.frameAgreement,
|
|
5616
|
+
multiFrameMode: "confidence-fusion",
|
|
4103
5617
|
geometry: best.geometry,
|
|
4104
5618
|
observedPalette: best.observedPalette,
|
|
4105
5619
|
averageCellConfidence: best.averageCellConfidence,
|
|
@@ -4116,7 +5630,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4116
5630
|
busy = true;
|
|
4117
5631
|
frameNumber++;
|
|
4118
5632
|
const observations = [];
|
|
4119
|
-
const frameDiagnostics =
|
|
5633
|
+
const frameDiagnostics = {};
|
|
4120
5634
|
const capturedFrame = {};
|
|
4121
5635
|
const frameStarted = nowMs();
|
|
4122
5636
|
let allowAutoEnhance = false;
|
|
@@ -4144,7 +5658,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4144
5658
|
// If geometry exists, the rectified QR-only pixel enhancer is both
|
|
4145
5659
|
// stronger and much cheaper than reprocessing the whole live frame.
|
|
4146
5660
|
fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
|
|
4147
|
-
maxDimension:
|
|
5661
|
+
maxDimension: baseCameraMaxDimension,
|
|
4148
5662
|
canvas: scratchCanvas,
|
|
4149
5663
|
_capturedFrame: capturedFrame,
|
|
4150
5664
|
_observationCollector: observations,
|
|
@@ -4185,10 +5699,74 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4185
5699
|
...frameDiagnostics
|
|
4186
5700
|
});
|
|
4187
5701
|
|
|
4188
|
-
//
|
|
4189
|
-
//
|
|
4190
|
-
//
|
|
4191
|
-
//
|
|
5702
|
+
// Geometry-aware high-resolution retry. A dense QuadQR can look large
|
|
5703
|
+
// enough in the preview while each individual module has become too
|
|
5704
|
+
// small after the normal 1080 px scanner cap. If the fast pass already
|
|
5705
|
+
// sees at least two convincing finders, spend one bounded retry on a
|
|
5706
|
+
// higher-resolution copy of the visible camera ROI. Empty frames and
|
|
5707
|
+
// ordinary small codes never pay this cost.
|
|
5708
|
+
const shouldTryHighResolution = options.cameraHighResolutionRecovery !== false &&
|
|
5709
|
+
cameraHighResolutionMaxDimension > baseCameraMaxDimension &&
|
|
5710
|
+
(frameDiagnostics?.finderCount ?? 0) >= (options.cameraHighResolutionMinFinders ?? 2) &&
|
|
5711
|
+
((missStreak - 1) % cameraHighResolutionEvery === 0);
|
|
5712
|
+
if (shouldTryHighResolution) {
|
|
5713
|
+
const highResolutionObservations = [];
|
|
5714
|
+
const highResolutionDiagnostics = {};
|
|
5715
|
+
const highResolutionCapturedFrame = {};
|
|
5716
|
+
emitDiagnostic({
|
|
5717
|
+
type: "method",
|
|
5718
|
+
state: "trying",
|
|
5719
|
+
method: "high-resolution-geometry-recovery",
|
|
5720
|
+
message: `Dense geometry detected · retrying at up to ${cameraHighResolutionMaxDimension}px`,
|
|
5721
|
+
...frameDiagnostics
|
|
5722
|
+
});
|
|
5723
|
+
try {
|
|
5724
|
+
const recoveryStarted = nowMs();
|
|
5725
|
+
const recovered = scanVideoFrame(video, {
|
|
5726
|
+
...options,
|
|
5727
|
+
_diagnosticLabel: "high-resolution-geometry-recovery",
|
|
5728
|
+
finderRecovery: true,
|
|
5729
|
+
autoEnhanceRecovery: false,
|
|
5730
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
5731
|
+
maxDimension: cameraHighResolutionMaxDimension,
|
|
5732
|
+
canvas: highResolutionCanvas,
|
|
5733
|
+
_capturedFrame: highResolutionCapturedFrame,
|
|
5734
|
+
_observationCollector: highResolutionObservations,
|
|
5735
|
+
_frameDiagnostics: highResolutionDiagnostics
|
|
5736
|
+
});
|
|
5737
|
+
const recoveryElapsedMs = nowMs() - recoveryStarted;
|
|
5738
|
+
emitDiagnostic({
|
|
5739
|
+
type: "success",
|
|
5740
|
+
state: "decoded",
|
|
5741
|
+
method: "high-resolution-geometry-recovery",
|
|
5742
|
+
elapsedMs: recoveryElapsedMs,
|
|
5743
|
+
message: `High-resolution retry decoded v${recovered.version} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
5744
|
+
...(highResolutionDiagnostics ?? frameDiagnostics)
|
|
5745
|
+
});
|
|
5746
|
+
missStreak = 0;
|
|
5747
|
+
observationHistory.clear();
|
|
5748
|
+
if (emitResult({
|
|
5749
|
+
...recovered,
|
|
5750
|
+
cameraHighResolutionRecovery: true,
|
|
5751
|
+
cameraProgressiveRecovery: true,
|
|
5752
|
+
recoveryMode: recovered.recoveryMode ?? "high-resolution-geometry-recovery"
|
|
5753
|
+
}, highResolutionCapturedFrame, highResolutionDiagnostics ?? frameDiagnostics)) return;
|
|
5754
|
+
} catch (highResolutionError) {
|
|
5755
|
+
observations.push(...highResolutionObservations);
|
|
5756
|
+
emitDiagnostic({
|
|
5757
|
+
type: "method",
|
|
5758
|
+
state: "failed",
|
|
5759
|
+
method: "high-resolution-geometry-recovery",
|
|
5760
|
+
message: `High-resolution geometry retry did not decode${highResolutionDiagnostics?.finderCount != null ? ` · ${highResolutionDiagnostics.finderCount} finder(s)` : ""}`,
|
|
5761
|
+
error: highResolutionError?.message ?? String(highResolutionError),
|
|
5762
|
+
...(highResolutionDiagnostics ?? frameDiagnostics)
|
|
5763
|
+
});
|
|
5764
|
+
}
|
|
5765
|
+
}
|
|
5766
|
+
|
|
5767
|
+
// Strong color casts can hide finder structure before normal geometry
|
|
5768
|
+
// recovery begins. QuadQR Auto Color retries the exact captured frame with a cheap
|
|
5769
|
+
// per-channel QuadQR Auto Color levels correction before any geometry-dependent
|
|
4192
5770
|
// recovery. This runs only after the normal fast scan fails, and by
|
|
4193
5771
|
// default only on every other missed frame after the first one.
|
|
4194
5772
|
const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
|
|
@@ -4211,7 +5789,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4211
5789
|
type: "method",
|
|
4212
5790
|
state: "trying",
|
|
4213
5791
|
method: "camera-auto-color",
|
|
4214
|
-
message: `Fast scan failed · Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
|
|
5792
|
+
message: `Fast scan failed · QuadQR Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
|
|
4215
5793
|
...frameDiagnostics
|
|
4216
5794
|
});
|
|
4217
5795
|
|
|
@@ -4238,9 +5816,9 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4238
5816
|
// This is intentionally performed on a centered recovery crop.
|
|
4239
5817
|
// A live preview can contain large dark borders, browser UI, a
|
|
4240
5818
|
// monitor bezel or room background. Those pixels completely
|
|
4241
|
-
// change global Auto Color/Otsu statistics even though the QR
|
|
4242
|
-
// itself looks identical to a saved crop.
|
|
4243
|
-
//
|
|
5819
|
+
// change global QuadQR Auto Color/Otsu statistics even though the QR
|
|
5820
|
+
// itself looks identical to a saved crop. QuadQR recovery stays
|
|
5821
|
+
// QR-centric so surrounding scene pixels do not dominate the correction.
|
|
4244
5822
|
blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
|
|
4245
5823
|
whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
|
|
4246
5824
|
highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
|
|
@@ -4286,7 +5864,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4286
5864
|
state: "decoded",
|
|
4287
5865
|
method: "camera-auto-color",
|
|
4288
5866
|
elapsedMs: recoveryElapsedMs,
|
|
4289
|
-
message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
5867
|
+
message: `QuadQR Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
4290
5868
|
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4291
5869
|
});
|
|
4292
5870
|
capturedFrame.enhancedImageData = correctedFrame;
|
|
@@ -4337,7 +5915,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4337
5915
|
type: "method",
|
|
4338
5916
|
state: "failed",
|
|
4339
5917
|
method: profileName,
|
|
4340
|
-
message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
|
|
5918
|
+
message: `QuadQR Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
|
|
4341
5919
|
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4342
5920
|
});
|
|
4343
5921
|
}
|
|
@@ -4347,7 +5925,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4347
5925
|
type: "method",
|
|
4348
5926
|
state: "failed",
|
|
4349
5927
|
method: "camera-auto-color",
|
|
4350
|
-
message: "All camera Auto Color profiles failed · continuing deeper recovery",
|
|
5928
|
+
message: "All camera QuadQR Auto Color profiles failed · continuing deeper recovery",
|
|
4351
5929
|
...frameDiagnostics
|
|
4352
5930
|
});
|
|
4353
5931
|
}
|
|
@@ -4416,8 +5994,8 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4416
5994
|
emitDiagnostic({
|
|
4417
5995
|
type: "success",
|
|
4418
5996
|
state: "decoded",
|
|
4419
|
-
method: "multi-frame-
|
|
4420
|
-
message: `Multi-frame
|
|
5997
|
+
method: "multi-frame-confidence-fusion",
|
|
5998
|
+
message: `Multi-frame confidence fusion decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
|
|
4421
5999
|
...frameDiagnostics
|
|
4422
6000
|
});
|
|
4423
6001
|
missStreak = 0;
|
|
@@ -4480,6 +6058,9 @@ export const internals = Object.freeze({
|
|
|
4480
6058
|
restoreLogicalOrder,
|
|
4481
6059
|
cellsToSymbolConfidences,
|
|
4482
6060
|
decodeRsAdaptive,
|
|
6061
|
+
classifierFromPaletteRgb,
|
|
6062
|
+
classifyRgb,
|
|
6063
|
+
observationDataAgreement,
|
|
4483
6064
|
encodeProtectedHeader,
|
|
4484
6065
|
decodeProtectedHeader,
|
|
4485
6066
|
selectBestFrameObservation,
|
|
@@ -4489,6 +6070,9 @@ export const internals = Object.freeze({
|
|
|
4489
6070
|
HEADER_CODEWORD_CELLS,
|
|
4490
6071
|
COMPACT_HEADER_CODEWORD_CELLS,
|
|
4491
6072
|
CELLS_PER_BYTE,
|
|
6073
|
+
RGBW_CELLS_PER_BYTE,
|
|
6074
|
+
TRIANGLE16_CELLS_PER_BYTE,
|
|
6075
|
+
TRIANGLE16_FLAG,
|
|
4492
6076
|
TEXT_FLAG,
|
|
4493
6077
|
SECURE_FLAG
|
|
4494
6078
|
});
|