quadqr-js 1.3.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 +8 -6
- package/README.md +68 -43
- package/SPECIFICATION.md +23 -8
- package/bin/quadqr.js +4 -2
- package/dist/esm/benchmark.js +37 -6
- package/dist/esm/brotli.js +2210 -0
- package/dist/esm/deflate.js +381 -0
- package/dist/esm/quadqr.js +1053 -121
- package/dist/esm/security.js +19 -0
- package/dist/esm/vision.js +244 -66
- package/dist/quadqr.js +4085 -370
- package/dist/quadqr.min.js +3799 -366
- package/docs/API.md +42 -11
- package/docs/BROWSER_CDN.md +9 -5
- package/docs/CLI.md +13 -2
- package/docs/COMPRESSION.md +170 -0
- package/docs/GETTING_STARTED.md +12 -2
- package/docs/HIGH_DENSITY_MODE.md +2 -2
- package/docs/NODE.md +22 -0
- package/docs/README.md +7 -5
- package/docs/RELIABILITY_LAB.md +64 -0
- package/docs/TRIANGLE16.md +7 -17
- package/docs/WASM.md +1 -1
- package/package.json +9 -10
- package/types/benchmark.d.ts +0 -8
- package/types/index.d.ts +0 -403
- package/types/node.d.ts +0 -11
package/dist/esm/quadqr.js
CHANGED
|
@@ -53,8 +53,23 @@ import {
|
|
|
53
53
|
DEFAULT_PBKDF2_ITERATIONS,
|
|
54
54
|
generateRaw256Key,
|
|
55
55
|
normalizeRaw256Key,
|
|
56
|
-
bytesToHex
|
|
56
|
+
bytesToHex,
|
|
57
|
+
estimateSecureEnvelopeOverhead
|
|
57
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";
|
|
58
73
|
|
|
59
74
|
export const FORMAT_VERSION = 5;
|
|
60
75
|
export const MIN_VERSION = 1;
|
|
@@ -85,7 +100,30 @@ export const PRINT_PALETTE = Object.freeze({
|
|
|
85
100
|
export const COMPRESSION_MODES = Object.freeze({
|
|
86
101
|
NONE: "none",
|
|
87
102
|
AUTO: "auto",
|
|
88
|
-
|
|
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
|
+
})
|
|
89
127
|
});
|
|
90
128
|
export const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
|
|
91
129
|
|
|
@@ -162,14 +200,14 @@ const TRIANGLE16_FLAG = 1 << 6;
|
|
|
162
200
|
// Internal payload extension envelope. This is deliberately not a public
|
|
163
201
|
// payload mode or content-type system. It exists only when compression or
|
|
164
202
|
// signing needs metadata around an otherwise normal text/binary payload.
|
|
165
|
-
const PAYLOAD_ENVELOPE_VERSION =
|
|
203
|
+
const PAYLOAD_ENVELOPE_VERSION = 3;
|
|
166
204
|
const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
|
|
167
205
|
const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
|
|
168
206
|
const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
|
|
169
207
|
const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
|
|
170
208
|
const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
|
|
171
|
-
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
|
|
172
|
-
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" });
|
|
173
211
|
const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
|
|
174
212
|
const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
|
|
175
213
|
const ECC_SHIFT = 1;
|
|
@@ -301,10 +339,36 @@ function readU16be(bytes, offset = 0) {
|
|
|
301
339
|
|
|
302
340
|
function normalizeCompressionMode(value = "none") {
|
|
303
341
|
const key = String(value).toLowerCase();
|
|
304
|
-
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.");
|
|
305
343
|
return key;
|
|
306
344
|
}
|
|
307
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
|
+
|
|
308
372
|
function asBytes(input) {
|
|
309
373
|
if (input instanceof Uint8Array) return input;
|
|
310
374
|
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
@@ -312,10 +376,27 @@ function asBytes(input) {
|
|
|
312
376
|
return new Uint8Array(input);
|
|
313
377
|
}
|
|
314
378
|
|
|
315
|
-
/** Portable LZSS-style compressor used by QuadQR payload compression.
|
|
316
|
-
|
|
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 = {}) {
|
|
317
385
|
const bytes = asBytes(input);
|
|
318
|
-
|
|
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
|
+
|
|
319
400
|
const out = [];
|
|
320
401
|
const recent = new Map();
|
|
321
402
|
let pos = 0;
|
|
@@ -327,38 +408,50 @@ export function compressPayload(input) {
|
|
|
327
408
|
let list = recent.get(key);
|
|
328
409
|
if (!list) recent.set(key, list = []);
|
|
329
410
|
list.push(i);
|
|
330
|
-
while (list.length >
|
|
411
|
+
while (list.length > historyLimit) list.shift();
|
|
331
412
|
while (list.length && i - list[0] > 4095) list.shift();
|
|
332
413
|
};
|
|
333
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
|
+
|
|
334
435
|
while (pos < bytes.length) {
|
|
335
436
|
const flagIndex = out.length;
|
|
336
437
|
out.push(0);
|
|
337
438
|
let flags = 0;
|
|
338
439
|
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
339
|
-
let
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
if (offset <= 0 || offset > 4095) continue;
|
|
347
|
-
let length = 0;
|
|
348
|
-
while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
|
|
349
|
-
if (length >= 3 && length > bestLength) {
|
|
350
|
-
bestLength = length;
|
|
351
|
-
bestOffset = offset;
|
|
352
|
-
if (length === 18) break;
|
|
353
|
-
}
|
|
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 };
|
|
354
447
|
}
|
|
355
448
|
|
|
356
|
-
if (
|
|
449
|
+
if (best.length >= 3) {
|
|
357
450
|
flags |= (1 << token);
|
|
358
|
-
const encoded = ((
|
|
451
|
+
const encoded = ((best.offset & 0x0fff) << 4) | ((best.length - 3) & 0x0f);
|
|
359
452
|
out.push((encoded >>> 8) & 0xff, encoded & 0xff);
|
|
360
|
-
for (let i = 0; i <
|
|
361
|
-
pos +=
|
|
453
|
+
for (let i = 0; i < best.length; i++) remember(pos + i);
|
|
454
|
+
pos += best.length;
|
|
362
455
|
} else {
|
|
363
456
|
out.push(bytes[pos]);
|
|
364
457
|
remember(pos);
|
|
@@ -394,14 +487,210 @@ export function decompressPayload(input, expectedLength = null) {
|
|
|
394
487
|
return Uint8Array.from(out);
|
|
395
488
|
}
|
|
396
489
|
|
|
397
|
-
|
|
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 = {}) {
|
|
398
673
|
const mode = normalizeCompressionMode(compression);
|
|
399
|
-
if (mode === "none") return {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
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" };
|
|
403
682
|
}
|
|
404
|
-
|
|
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" };
|
|
405
694
|
}
|
|
406
695
|
|
|
407
696
|
|
|
@@ -435,9 +724,7 @@ function isPayloadEnvelope(input) {
|
|
|
435
724
|
return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
|
|
436
725
|
}
|
|
437
726
|
|
|
438
|
-
function
|
|
439
|
-
const payload = asBytes(input);
|
|
440
|
-
const prepared = prepareCompressedBody(payload, options.compression ?? "none");
|
|
727
|
+
function packPreparedPayloadEnvelope(payload, prepared) {
|
|
441
728
|
const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
|
|
442
729
|
const header = makePayloadEnvelopeHeader({
|
|
443
730
|
compression: prepared.compression,
|
|
@@ -447,11 +734,17 @@ function packPayloadEnvelope(input, options = {}) {
|
|
|
447
734
|
return concatBytes(header, prepared.body);
|
|
448
735
|
}
|
|
449
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
|
+
|
|
450
743
|
function parsePayloadEnvelope(input) {
|
|
451
744
|
const container = asBytes(input);
|
|
452
745
|
assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
|
|
453
746
|
const version = container[4];
|
|
454
|
-
assert(version
|
|
747
|
+
assert(version >= 1 && version <= PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
|
|
455
748
|
const flags = container[5];
|
|
456
749
|
const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
|
|
457
750
|
assert(compression != null, `Unknown payload compression id ${container[6]}.`);
|
|
@@ -469,7 +762,13 @@ function parsePayloadEnvelope(input) {
|
|
|
469
762
|
const publicKey = container.slice(endMetadata, endPublicKey);
|
|
470
763
|
const signature = container.slice(endPublicKey, endSignature);
|
|
471
764
|
const stored = container.slice(endSignature);
|
|
472
|
-
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;
|
|
473
772
|
assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
|
|
474
773
|
const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
|
|
475
774
|
const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
|
|
@@ -558,7 +857,6 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
558
857
|
const payload = asBytes(input);
|
|
559
858
|
assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
|
|
560
859
|
const subtle = subtleCrypto();
|
|
561
|
-
const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
|
|
562
860
|
const keyIdBytes = normalizeSigningKeyId(options.keyId);
|
|
563
861
|
|
|
564
862
|
let publicKeyBytes = new Uint8Array(0);
|
|
@@ -568,6 +866,14 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
568
866
|
publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
|
|
569
867
|
}
|
|
570
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
|
+
|
|
571
877
|
const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
|
|
572
878
|
(prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
|
|
573
879
|
(publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
|
|
@@ -584,7 +890,13 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
584
890
|
const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
|
|
585
891
|
const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
|
|
586
892
|
assert(signature.length === 64, "Unexpected Ed25519 signature length.");
|
|
587
|
-
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
|
+
};
|
|
588
900
|
}
|
|
589
901
|
|
|
590
902
|
function lookupTrustedSigningKey(trustedKeys, keyId) {
|
|
@@ -604,7 +916,7 @@ async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
|
604
916
|
let trustSource = trusted ? "external" : null;
|
|
605
917
|
|
|
606
918
|
// Legacy v1 symbols embedded the public key by default, so preserve their
|
|
607
|
-
// historical self-verification behavior. v2
|
|
919
|
+
// historical self-verification behavior. v2+ require explicit opt-in.
|
|
608
920
|
if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
|
|
609
921
|
trusted = parsed.publicKey;
|
|
610
922
|
trustSource = "embedded";
|
|
@@ -635,19 +947,31 @@ async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
|
635
947
|
};
|
|
636
948
|
}
|
|
637
949
|
|
|
638
|
-
function prepareOptionalCompressedPayload(input, compression = "none") {
|
|
950
|
+
function prepareOptionalCompressedPayload(input, compression = "none", options = {}) {
|
|
639
951
|
const payload = asBytes(input);
|
|
640
952
|
const mode = normalizeCompressionMode(compression);
|
|
641
|
-
if (mode === "none")
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
};
|
|
646
967
|
}
|
|
647
968
|
return {
|
|
648
|
-
payload:
|
|
969
|
+
payload: packPreparedPayloadEnvelope(payload, prepared),
|
|
649
970
|
extended: true,
|
|
650
|
-
compression: prepared.compression
|
|
971
|
+
compression: prepared.compression,
|
|
972
|
+
compressionLevel: prepared.compressionLevel ?? null,
|
|
973
|
+
compressionStrategy: prepared.compressionStrategy ?? mode,
|
|
974
|
+
smartCompression: prepared.smartCompression ?? null
|
|
651
975
|
};
|
|
652
976
|
}
|
|
653
977
|
|
|
@@ -1367,6 +1691,9 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1367
1691
|
secure: Boolean(meta.secure),
|
|
1368
1692
|
compressed: meta.compression != null && meta.compression !== "none",
|
|
1369
1693
|
compression: meta.compression ?? "none",
|
|
1694
|
+
compressionLevel: meta.compressionLevel ?? null,
|
|
1695
|
+
compressionStrategy: meta.compressionStrategy ?? null,
|
|
1696
|
+
smartCompression: meta.smartCompression ?? null,
|
|
1370
1697
|
signed: Boolean(meta.signed),
|
|
1371
1698
|
signingKeyId: meta.signingKeyId ?? null,
|
|
1372
1699
|
hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
|
|
@@ -1427,6 +1754,9 @@ function encodePreparedBytes(input, options = {}) {
|
|
|
1427
1754
|
extended,
|
|
1428
1755
|
signed,
|
|
1429
1756
|
compression: options.compressionMetadata ?? "none",
|
|
1757
|
+
compressionLevel: options.compressionLevelMetadata ?? null,
|
|
1758
|
+
compressionStrategy: options.compressionStrategyMetadata ?? null,
|
|
1759
|
+
smartCompression: options.smartCompressionMetadata ?? null,
|
|
1430
1760
|
signingKeyId: options.signingKeyId ?? null,
|
|
1431
1761
|
hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
|
|
1432
1762
|
security: options.securityMetadata ?? null,
|
|
@@ -1442,25 +1772,31 @@ function encodePreparedBytes(input, options = {}) {
|
|
|
1442
1772
|
export function encodeText(text, options = {}) {
|
|
1443
1773
|
assert(typeof text === "string", "encodeText expects a string.");
|
|
1444
1774
|
const source = getTextEncoder().encode(text);
|
|
1445
|
-
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1775
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none", options);
|
|
1446
1776
|
return encodePreparedBytes(prepared.payload, {
|
|
1447
1777
|
...options,
|
|
1448
1778
|
text: true,
|
|
1449
1779
|
extended: prepared.extended,
|
|
1450
1780
|
sourcePayloadBytes: source.length,
|
|
1451
|
-
compressionMetadata: prepared.compression
|
|
1781
|
+
compressionMetadata: prepared.compression,
|
|
1782
|
+
compressionLevelMetadata: prepared.compressionLevel,
|
|
1783
|
+
compressionStrategyMetadata: prepared.compressionStrategy,
|
|
1784
|
+
smartCompressionMetadata: prepared.smartCompression
|
|
1452
1785
|
});
|
|
1453
1786
|
}
|
|
1454
1787
|
|
|
1455
1788
|
export function encodeBytes(input, options = {}) {
|
|
1456
1789
|
const source = asBytes(input);
|
|
1457
|
-
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1790
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none", options);
|
|
1458
1791
|
return encodePreparedBytes(prepared.payload, {
|
|
1459
1792
|
...options,
|
|
1460
1793
|
text: Boolean(options.text),
|
|
1461
1794
|
extended: prepared.extended,
|
|
1462
1795
|
sourcePayloadBytes: source.length,
|
|
1463
|
-
compressionMetadata: prepared.compression
|
|
1796
|
+
compressionMetadata: prepared.compression,
|
|
1797
|
+
compressionLevelMetadata: prepared.compressionLevel,
|
|
1798
|
+
compressionStrategyMetadata: prepared.compressionStrategy,
|
|
1799
|
+
smartCompressionMetadata: prepared.smartCompression
|
|
1464
1800
|
});
|
|
1465
1801
|
}
|
|
1466
1802
|
|
|
@@ -1472,15 +1808,18 @@ export function encodeUint8Array(input, options = {}) {
|
|
|
1472
1808
|
/** Encode and sign a normal payload using an internal Ed25519 envelope. */
|
|
1473
1809
|
export async function encodeSignedBytes(input, options = {}) {
|
|
1474
1810
|
const source = asBytes(input);
|
|
1475
|
-
const
|
|
1476
|
-
const parsed = parsePayloadEnvelope(envelope);
|
|
1477
|
-
return encodePreparedBytes(envelope, {
|
|
1811
|
+
const packed = await packSignedPayloadEnvelope(source, options);
|
|
1812
|
+
const parsed = parsePayloadEnvelope(packed.envelope);
|
|
1813
|
+
return encodePreparedBytes(packed.envelope, {
|
|
1478
1814
|
...options,
|
|
1479
1815
|
text: Boolean(options.text),
|
|
1480
1816
|
extended: true,
|
|
1481
1817
|
signed: true,
|
|
1482
1818
|
sourcePayloadBytes: source.length,
|
|
1483
1819
|
compressionMetadata: parsed.compression,
|
|
1820
|
+
compressionLevelMetadata: packed.compressionLevel,
|
|
1821
|
+
compressionStrategyMetadata: packed.compressionStrategy,
|
|
1822
|
+
smartCompressionMetadata: packed.smartCompression,
|
|
1484
1823
|
signingKeyId: parsed.keyId,
|
|
1485
1824
|
hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
|
|
1486
1825
|
});
|
|
@@ -1505,30 +1844,47 @@ export async function encodeSecureText(text, options = {}) {
|
|
|
1505
1844
|
export async function encodeSecureBytes(input, options = {}) {
|
|
1506
1845
|
const sourcePayload = asBytes(input);
|
|
1507
1846
|
const security = options.security ?? {};
|
|
1847
|
+
const secureOuterFixedBytes = estimateSecureEnvelopeOverhead(security);
|
|
1508
1848
|
let protectedPayload = sourcePayload;
|
|
1509
1849
|
let extended = false;
|
|
1510
1850
|
let signed = false;
|
|
1511
1851
|
let compression = "none";
|
|
1852
|
+
let compressionLevel = null;
|
|
1853
|
+
let compressionStrategy = null;
|
|
1854
|
+
let smartCompression = null;
|
|
1512
1855
|
let signingKeyId = null;
|
|
1513
1856
|
let hasEmbeddedPublicKey = false;
|
|
1514
1857
|
|
|
1515
1858
|
if (options.signing) {
|
|
1516
|
-
|
|
1859
|
+
const packed = await packSignedPayloadEnvelope(sourcePayload, {
|
|
1517
1860
|
...options,
|
|
1518
1861
|
...options.signing,
|
|
1519
|
-
compression: options.compression ?? "auto"
|
|
1862
|
+
compression: options.compression ?? "auto",
|
|
1863
|
+
outerFixedBytes: secureOuterFixedBytes,
|
|
1864
|
+
versionOptions: options
|
|
1520
1865
|
});
|
|
1866
|
+
protectedPayload = packed.envelope;
|
|
1521
1867
|
const parsed = parsePayloadEnvelope(protectedPayload);
|
|
1522
1868
|
extended = true;
|
|
1523
1869
|
signed = true;
|
|
1524
1870
|
compression = parsed.compression;
|
|
1871
|
+
compressionLevel = packed.compressionLevel;
|
|
1872
|
+
compressionStrategy = packed.compressionStrategy;
|
|
1873
|
+
smartCompression = packed.smartCompression;
|
|
1525
1874
|
signingKeyId = parsed.keyId;
|
|
1526
1875
|
hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
|
|
1527
1876
|
} else {
|
|
1528
|
-
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none"
|
|
1877
|
+
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none", {
|
|
1878
|
+
...options,
|
|
1879
|
+
outerFixedBytes: secureOuterFixedBytes,
|
|
1880
|
+
versionOptions: options
|
|
1881
|
+
});
|
|
1529
1882
|
protectedPayload = prepared.payload;
|
|
1530
1883
|
extended = prepared.extended;
|
|
1531
1884
|
compression = prepared.compression;
|
|
1885
|
+
compressionLevel = prepared.compressionLevel;
|
|
1886
|
+
compressionStrategy = prepared.compressionStrategy;
|
|
1887
|
+
smartCompression = prepared.smartCompression;
|
|
1532
1888
|
}
|
|
1533
1889
|
|
|
1534
1890
|
const encrypted = await encryptSecurePayload(protectedPayload, security);
|
|
@@ -1540,6 +1896,9 @@ export async function encodeSecureBytes(input, options = {}) {
|
|
|
1540
1896
|
signed,
|
|
1541
1897
|
sourcePayloadBytes: sourcePayload.length,
|
|
1542
1898
|
compressionMetadata: compression,
|
|
1899
|
+
compressionLevelMetadata: compressionLevel,
|
|
1900
|
+
compressionStrategyMetadata: compressionStrategy,
|
|
1901
|
+
smartCompressionMetadata: smartCompression,
|
|
1543
1902
|
signingKeyId,
|
|
1544
1903
|
hasEmbeddedPublicKey,
|
|
1545
1904
|
securityMetadata: encrypted.metadata
|
|
@@ -1980,10 +2339,68 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1980
2339
|
throw new Error(`QuadQR decode failed. ${errors.join(" | ")}`);
|
|
1981
2340
|
}
|
|
1982
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
|
+
|
|
1983
2399
|
export function decodeMatrix(inputMatrix, options = {}) {
|
|
1984
2400
|
assert(Array.isArray(inputMatrix) && inputMatrix.length > 0, "Matrix is required.");
|
|
1985
2401
|
let matrix = cloneMatrix(inputMatrix);
|
|
1986
2402
|
let confidenceMatrix = options.cellConfidence ? cloneMatrix(options.cellConfidence) : null;
|
|
2403
|
+
let alternativeMatrix = options.cellAlternatives ? cloneMatrix(options.cellAlternatives) : null;
|
|
1987
2404
|
const errors = [];
|
|
1988
2405
|
const tolerance = options.structureTolerance ?? 0;
|
|
1989
2406
|
|
|
@@ -1993,16 +2410,38 @@ export function decodeMatrix(inputMatrix, options = {}) {
|
|
|
1993
2410
|
"cellConfidence must be a square matrix matching the QuadQR matrix."
|
|
1994
2411
|
);
|
|
1995
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
|
+
}
|
|
1996
2419
|
|
|
1997
2420
|
for (let rotationIndex = 0; rotationIndex < 4; rotationIndex++) {
|
|
1998
2421
|
const degrees = rotationIndex * 90;
|
|
1999
2422
|
try {
|
|
2000
|
-
|
|
2423
|
+
const decoded = decodeCanonical(matrix, degrees, tolerance, confidenceMatrix, options);
|
|
2424
|
+
return {
|
|
2425
|
+
spectrumEccVersion: 2,
|
|
2426
|
+
softDecoded: false,
|
|
2427
|
+
softSubstitutions: 0,
|
|
2428
|
+
...decoded
|
|
2429
|
+
};
|
|
2001
2430
|
} catch (error) {
|
|
2002
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;
|
|
2003
2441
|
}
|
|
2004
2442
|
matrix = rotate90(matrix);
|
|
2005
2443
|
if (confidenceMatrix) confidenceMatrix = rotate90(confidenceMatrix);
|
|
2444
|
+
if (alternativeMatrix) alternativeMatrix = rotate90(alternativeMatrix);
|
|
2006
2445
|
}
|
|
2007
2446
|
|
|
2008
2447
|
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
@@ -2944,11 +3383,96 @@ function makeWhiteBalanceTransform(observed) {
|
|
|
2944
3383
|
});
|
|
2945
3384
|
}
|
|
2946
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
|
+
|
|
2947
3463
|
function classifierFromPaletteRgb(observed, mode = "raw") {
|
|
2948
|
-
const
|
|
3464
|
+
const affine = mode === "affine" ? makeAffineCalibrationTransform(observed) : null;
|
|
3465
|
+
const transform = affine?.transform ?? (mode === "balanced" || mode === "hue"
|
|
2949
3466
|
? makeWhiteBalanceTransform(observed)
|
|
2950
|
-
: identityColorTransform;
|
|
2951
|
-
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 ?? [
|
|
2952
3476
|
{ cell: CELL.BLACK, rgb: transform(observed.black) },
|
|
2953
3477
|
{ cell: CELL.WHITE, rgb: transform(observed.white) },
|
|
2954
3478
|
{ cell: CELL.RED, rgb: transform(observed.red) },
|
|
@@ -2962,6 +3486,8 @@ function classifierFromPaletteRgb(observed, mode = "raw") {
|
|
|
2962
3486
|
entries,
|
|
2963
3487
|
transform,
|
|
2964
3488
|
mode,
|
|
3489
|
+
calibrationModel: affine ? "affine-3x4" : (mode === "balanced" || mode === "hue" ? "black-white-balance" : "observed-palette"),
|
|
3490
|
+
calibrationError: affine?.rmsError ?? null,
|
|
2965
3491
|
dataMode: mode === "hue" ? "hue" : "distance",
|
|
2966
3492
|
whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
|
|
2967
3493
|
minimumColorChroma
|
|
@@ -2975,27 +3501,30 @@ function colorDistanceSq(a, b) {
|
|
|
2975
3501
|
return dr * dr + dg * dg + db * db;
|
|
2976
3502
|
}
|
|
2977
3503
|
|
|
2978
|
-
function
|
|
3504
|
+
function rankRgbCandidates(rgb, classifier) {
|
|
2979
3505
|
const transformed = classifier.transform(rgb);
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
bestDistanceSq = distanceSq;
|
|
2989
|
-
best = candidate;
|
|
2990
|
-
} else if (distanceSq < secondDistanceSq) {
|
|
2991
|
-
secondDistanceSq = distanceSq;
|
|
2992
|
-
}
|
|
2993
|
-
}
|
|
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
|
+
}
|
|
2994
3514
|
|
|
2995
|
-
|
|
2996
|
-
const
|
|
2997
|
-
const
|
|
2998
|
-
|
|
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
|
+
};
|
|
2999
3528
|
}
|
|
3000
3529
|
|
|
3001
3530
|
|
|
@@ -3031,6 +3560,7 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3031
3560
|
const size = rgbGrid.length;
|
|
3032
3561
|
const matrix = make2D(size, CELL.WHITE);
|
|
3033
3562
|
const confidence = make2D(size, 1);
|
|
3563
|
+
const alternatives = make2D(size, null);
|
|
3034
3564
|
const dataClassifier = {
|
|
3035
3565
|
...classifier,
|
|
3036
3566
|
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
@@ -3050,8 +3580,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3050
3580
|
const classified = isDataCell && classifier.dataMode === "hue"
|
|
3051
3581
|
? classifyDataHue(rgbGrid[r][c], classifier)
|
|
3052
3582
|
: classifyRgb(rgbGrid[r][c], candidates);
|
|
3583
|
+
const ranked = rankRgbCandidates(rgbGrid[r][c], candidates);
|
|
3584
|
+
const alternate = ranked.find((candidate) => candidate.cell !== classified.cell);
|
|
3053
3585
|
matrix[r][c] = classified.cell;
|
|
3054
3586
|
confidence[r][c] = classified.confidence;
|
|
3587
|
+
alternatives[r][c] = alternate?.cell ?? null;
|
|
3055
3588
|
distanceSum += classified.distance;
|
|
3056
3589
|
confidenceSum += classified.confidence;
|
|
3057
3590
|
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
@@ -3061,6 +3594,7 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3061
3594
|
return {
|
|
3062
3595
|
matrix,
|
|
3063
3596
|
confidence,
|
|
3597
|
+
alternatives,
|
|
3064
3598
|
averageColorDistance: distanceSum / (size * size),
|
|
3065
3599
|
averageCellConfidence: confidenceSum / (size * size),
|
|
3066
3600
|
minimumCellConfidence: minimumConfidence,
|
|
@@ -3072,6 +3606,7 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
|
|
|
3072
3606
|
const size = rgbGrid.length;
|
|
3073
3607
|
const matrix = make2D(size, CELL.WHITE);
|
|
3074
3608
|
const confidence = make2D(size, 1);
|
|
3609
|
+
const alternatives = make2D(size, null);
|
|
3075
3610
|
const dataClassifier = {
|
|
3076
3611
|
...classifier,
|
|
3077
3612
|
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
@@ -3088,6 +3623,7 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
|
|
|
3088
3623
|
const classified = classifyRgb(rgbGrid[r][c], classifier);
|
|
3089
3624
|
matrix[r][c] = classified.cell;
|
|
3090
3625
|
confidence[r][c] = classified.confidence;
|
|
3626
|
+
alternatives[r][c] = classified.alternativeCell ?? null;
|
|
3091
3627
|
distanceSum += classified.distance;
|
|
3092
3628
|
confidenceSum += classified.confidence;
|
|
3093
3629
|
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
@@ -3102,19 +3638,35 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
|
|
|
3102
3638
|
: classifyRgb(rgb, dataClassifier);
|
|
3103
3639
|
const first = classifyData(samples.first);
|
|
3104
3640
|
const second = classifyData(samples.second);
|
|
3105
|
-
const
|
|
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);
|
|
3106
3649
|
matrix[r][c] = packTriangleCell(first.cell, second.cell);
|
|
3107
|
-
|
|
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);
|
|
3108
3659
|
distanceSum += (first.distance + second.distance) / 2;
|
|
3109
|
-
confidenceSum +=
|
|
3110
|
-
minimumConfidence = Math.min(minimumConfidence,
|
|
3111
|
-
if (
|
|
3660
|
+
confidenceSum += confidence[r][c];
|
|
3661
|
+
minimumConfidence = Math.min(minimumConfidence, confidence[r][c]);
|
|
3662
|
+
if (confidence[r][c] < 0.4) lowConfidenceCells++;
|
|
3112
3663
|
}
|
|
3113
3664
|
}
|
|
3114
3665
|
|
|
3115
3666
|
return {
|
|
3116
3667
|
matrix,
|
|
3117
3668
|
confidence,
|
|
3669
|
+
alternatives,
|
|
3118
3670
|
averageColorDistance: distanceSum / (size * size),
|
|
3119
3671
|
averageCellConfidence: confidenceSum / (size * size),
|
|
3120
3672
|
minimumCellConfidence: minimumConfidence,
|
|
@@ -3144,6 +3696,7 @@ function paletteClassifierAttempts(observedPalette) {
|
|
|
3144
3696
|
return [
|
|
3145
3697
|
{ classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
|
|
3146
3698
|
{ classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
|
|
3699
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "affine"), colorNormalization: "affine-calibrated" },
|
|
3147
3700
|
{ classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
|
|
3148
3701
|
];
|
|
3149
3702
|
}
|
|
@@ -3216,6 +3769,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3216
3769
|
version: geometry.version,
|
|
3217
3770
|
matrix: classified.matrix,
|
|
3218
3771
|
confidence: classified.confidence,
|
|
3772
|
+
alternatives: classified.alternatives,
|
|
3219
3773
|
geometry,
|
|
3220
3774
|
observedPalette: activeObservedPalette,
|
|
3221
3775
|
samplingMode: activeSamplingMode,
|
|
@@ -3229,6 +3783,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3229
3783
|
const decoded = decodeMatrix(classified.matrix, {
|
|
3230
3784
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3231
3785
|
cellConfidence: classified.confidence,
|
|
3786
|
+
cellAlternatives: classified.alternatives,
|
|
3232
3787
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3233
3788
|
});
|
|
3234
3789
|
if (decoded.version !== geometry.version) return false;
|
|
@@ -3273,6 +3828,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3273
3828
|
version: geometry.version,
|
|
3274
3829
|
matrix: classified.matrix,
|
|
3275
3830
|
confidence: classified.confidence,
|
|
3831
|
+
alternatives: classified.alternatives,
|
|
3276
3832
|
geometry,
|
|
3277
3833
|
observedPalette: activeObservedPalette,
|
|
3278
3834
|
samplingMode: activeSamplingMode,
|
|
@@ -3287,6 +3843,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3287
3843
|
const decoded = decodeMatrix(classified.matrix, {
|
|
3288
3844
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3289
3845
|
cellConfidence: classified.confidence,
|
|
3846
|
+
cellAlternatives: classified.alternatives,
|
|
3290
3847
|
cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
|
|
3291
3848
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3292
3849
|
});
|
|
@@ -3336,9 +3893,9 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3336
3893
|
}
|
|
3337
3894
|
}
|
|
3338
3895
|
|
|
3339
|
-
//
|
|
3340
|
-
// Normal camera frames never reach this path. The transform
|
|
3341
|
-
// 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).
|
|
3342
3899
|
if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
|
|
3343
3900
|
try {
|
|
3344
3901
|
const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
|
|
@@ -3366,7 +3923,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3366
3923
|
}
|
|
3367
3924
|
}
|
|
3368
3925
|
|
|
3369
|
-
// Camera-specific
|
|
3926
|
+
// Camera-specific QuadQR color-recovery fallback. Enhancing the whole camera
|
|
3370
3927
|
// frame is often ineffective because dark surroundings, browser/UI
|
|
3371
3928
|
// reflections and unrelated objects skew the histograms. Once finder
|
|
3372
3929
|
// geometry is known, rectify only the QuadQR region, run Auto Tone /
|
|
@@ -3485,6 +4042,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3485
4042
|
version: geometry.version,
|
|
3486
4043
|
matrix: classified.matrix,
|
|
3487
4044
|
confidence: classified.confidence,
|
|
4045
|
+
alternatives: classified.alternatives,
|
|
3488
4046
|
geometry,
|
|
3489
4047
|
observedPalette,
|
|
3490
4048
|
samplingMode: "refined-center",
|
|
@@ -3523,6 +4081,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3523
4081
|
version: geometry.version,
|
|
3524
4082
|
matrix: triangleClassified.matrix,
|
|
3525
4083
|
confidence: triangleClassified.confidence,
|
|
4084
|
+
alternatives: triangleClassified.alternatives,
|
|
3526
4085
|
geometry,
|
|
3527
4086
|
observedPalette,
|
|
3528
4087
|
samplingMode: "refined-triangle16",
|
|
@@ -3570,6 +4129,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3570
4129
|
const decoded = decodeMatrix(candidate.classified.matrix, {
|
|
3571
4130
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3572
4131
|
cellConfidence: candidate.classified.confidence,
|
|
4132
|
+
cellAlternatives: candidate.classified.alternatives,
|
|
3573
4133
|
cellEncodingHint: candidate.cellEncoding,
|
|
3574
4134
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3575
4135
|
});
|
|
@@ -3707,6 +4267,7 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3707
4267
|
version,
|
|
3708
4268
|
matrix: classified.matrix,
|
|
3709
4269
|
confidence: classified.confidence,
|
|
4270
|
+
alternatives: classified.alternatives,
|
|
3710
4271
|
bounds,
|
|
3711
4272
|
samplingMode: classifiedAttempt.samplingMode,
|
|
3712
4273
|
colorNormalization: attempt.colorNormalization,
|
|
@@ -3718,6 +4279,7 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3718
4279
|
const decoded = decodeMatrix(classified.matrix, {
|
|
3719
4280
|
structureTolerance: options.structureTolerance ?? 0.12,
|
|
3720
4281
|
cellConfidence: classified.confidence,
|
|
4282
|
+
cellAlternatives: classified.alternatives,
|
|
3721
4283
|
cellEncodingHint: classifiedAttempt.cellEncoding,
|
|
3722
4284
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3723
4285
|
});
|
|
@@ -3770,6 +4332,31 @@ export const STRESS_PROFILES = Object.freeze([
|
|
|
3770
4332
|
Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
|
|
3771
4333
|
]);
|
|
3772
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
|
+
|
|
3773
4360
|
function cloneImageDataLike(imageData) {
|
|
3774
4361
|
return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
|
|
3775
4362
|
}
|
|
@@ -3869,13 +4456,151 @@ function perspectiveStress(imageData, severity) {
|
|
|
3869
4456
|
return { width, height, data: out };
|
|
3870
4457
|
}
|
|
3871
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
|
+
|
|
3872
4588
|
/** Apply one deterministic camera/print-style distortion to ImageData. */
|
|
3873
4589
|
export function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
|
|
3874
4590
|
assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
|
|
3875
4591
|
const s = clampNumber(Number(severity), 0, 1);
|
|
3876
4592
|
if (type === "clean") return cloneImageDataLike(imageData);
|
|
3877
4593
|
if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
|
|
4594
|
+
if (type === "motion-blur") return motionBlurImage(imageData, s, options);
|
|
3878
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
|
+
}
|
|
3879
4604
|
if (type === "downscale") {
|
|
3880
4605
|
const scale = Math.max(0.20, 1 - s * 0.72);
|
|
3881
4606
|
const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
|
|
@@ -3904,7 +4629,22 @@ export function applyStressDistortion(imageData, type, severity = 0.5, options =
|
|
|
3904
4629
|
value += block * s * 3;
|
|
3905
4630
|
} else if (type === "warm") {
|
|
3906
4631
|
if (ch === 0) value *= 1 + s * 0.12;
|
|
4632
|
+
if (ch === 1) value *= 1 + s * 0.03;
|
|
3907
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);
|
|
3908
4648
|
}
|
|
3909
4649
|
data[p + ch] = clampNumber(Math.round(value), 0, 255);
|
|
3910
4650
|
}
|
|
@@ -3974,6 +4714,112 @@ export function runImageStressTest(imageData, expected = {}, options = {}) {
|
|
|
3974
4714
|
};
|
|
3975
4715
|
}
|
|
3976
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
|
+
|
|
3977
4823
|
/** Render and run the standard scanability torture suite. */
|
|
3978
4824
|
export function assessScanability(code, renderOptions = {}, options = {}) {
|
|
3979
4825
|
assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
|
|
@@ -4483,47 +5329,111 @@ function selectBestFrameObservation(observations) {
|
|
|
4483
5329
|
)[0] ?? null;
|
|
4484
5330
|
}
|
|
4485
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
|
+
|
|
4486
5348
|
function combineFrameObservations(observations) {
|
|
4487
5349
|
if (!observations?.length) return null;
|
|
4488
5350
|
const version = observations[0].version;
|
|
5351
|
+
const cellEncoding = observations[0].cellEncoding ?? null;
|
|
4489
5352
|
const size = observations[0].matrix.length;
|
|
4490
|
-
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;
|
|
4491
5358
|
|
|
4492
5359
|
const matrix = make2D(size, CELL.WHITE);
|
|
4493
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
|
+
|
|
4494
5374
|
for (let r = 0; r < size; r++) {
|
|
4495
5375
|
for (let c = 0; c < size; c++) {
|
|
4496
5376
|
const votes = new Map();
|
|
4497
5377
|
const confidenceByCell = new Map();
|
|
4498
|
-
for (
|
|
5378
|
+
for (let index = 0; index < observations.length; index++) {
|
|
5379
|
+
const observation = observations[index];
|
|
4499
5380
|
const cell = observation.matrix[r][c];
|
|
4500
5381
|
const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
|
|
4501
|
-
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;
|
|
4502
5392
|
votes.set(cell, (votes.get(cell) ?? 0) + weight);
|
|
4503
5393
|
const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
|
|
4504
5394
|
stats.weighted += sourceConfidence * weight;
|
|
4505
5395
|
stats.weight += weight;
|
|
4506
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
|
+
}
|
|
4507
5407
|
}
|
|
4508
5408
|
|
|
4509
5409
|
const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
|
|
4510
5410
|
const [bestCell, bestWeight] = ranked[0];
|
|
4511
|
-
const secondWeight = ranked[1]
|
|
5411
|
+
const [secondCell, secondWeight] = ranked[1] ?? [null, 0];
|
|
4512
5412
|
const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
|
|
4513
5413
|
const sourceStats = confidenceByCell.get(bestCell);
|
|
4514
5414
|
const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
|
|
4515
5415
|
const support = totalWeight ? bestWeight / totalWeight : 0;
|
|
4516
5416
|
const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
|
|
4517
|
-
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
|
+
);
|
|
4518
5423
|
|
|
4519
5424
|
matrix[r][c] = bestCell;
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
// cells as erasures instead of hard, supposedly-certain errors.
|
|
4523
|
-
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;
|
|
4524
5427
|
}
|
|
4525
5428
|
}
|
|
4526
|
-
return {
|
|
5429
|
+
return {
|
|
5430
|
+
version,
|
|
5431
|
+
cellEncoding,
|
|
5432
|
+
matrix,
|
|
5433
|
+
confidence,
|
|
5434
|
+
alternatives,
|
|
5435
|
+
frameAgreement: agreementCount ? agreementSum / agreementCount : 1
|
|
5436
|
+
};
|
|
4527
5437
|
}
|
|
4528
5438
|
|
|
4529
5439
|
async function improveCameraTrack(stream) {
|
|
@@ -4662,10 +5572,25 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4662
5572
|
if (!multiFrameEnabled) return null;
|
|
4663
5573
|
const best = selectBestFrameObservation(observations);
|
|
4664
5574
|
if (!best) return null;
|
|
4665
|
-
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
|
+
|
|
4666
5591
|
history.push(best);
|
|
4667
5592
|
while (history.length > multiFrameWindow) history.shift();
|
|
4668
|
-
observationHistory.set(
|
|
5593
|
+
observationHistory.set(trackKey, history);
|
|
4669
5594
|
if (history.length < multiFrameMinFrames) return null;
|
|
4670
5595
|
|
|
4671
5596
|
const combined = combineFrameObservations(history);
|
|
@@ -4674,16 +5599,21 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4674
5599
|
const decoded = decodeMatrix(combined.matrix, {
|
|
4675
5600
|
structureTolerance: options.structureTolerance ?? 0.20,
|
|
4676
5601
|
cellConfidence: combined.confidence,
|
|
4677
|
-
|
|
5602
|
+
cellAlternatives: combined.alternatives,
|
|
5603
|
+
cellEncodingHint: best.cellEncoding ?? undefined,
|
|
5604
|
+
maxErasureConfidence: options.maxErasureConfidence,
|
|
5605
|
+
softDecoding: options.softDecoding
|
|
4678
5606
|
});
|
|
4679
5607
|
if (decoded.version !== best.version) return null;
|
|
4680
5608
|
return {
|
|
4681
5609
|
...decoded,
|
|
4682
5610
|
perspectiveCorrected: Boolean(best.geometry),
|
|
4683
5611
|
colorCalibrated: true,
|
|
4684
|
-
colorNormalization: "multi-frame-
|
|
4685
|
-
samplingMode: "multi-frame-
|
|
5612
|
+
colorNormalization: "multi-frame-confidence-fusion",
|
|
5613
|
+
samplingMode: "multi-frame-confidence-fusion",
|
|
4686
5614
|
multiFrameCombined: history.length,
|
|
5615
|
+
multiFrameAgreement: combined.frameAgreement,
|
|
5616
|
+
multiFrameMode: "confidence-fusion",
|
|
4687
5617
|
geometry: best.geometry,
|
|
4688
5618
|
observedPalette: best.observedPalette,
|
|
4689
5619
|
averageCellConfidence: best.averageCellConfidence,
|
|
@@ -4834,10 +5764,9 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4834
5764
|
}
|
|
4835
5765
|
}
|
|
4836
5766
|
|
|
4837
|
-
//
|
|
4838
|
-
//
|
|
4839
|
-
//
|
|
4840
|
-
// per-channel Auto Color levels correction before any geometry-dependent
|
|
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
|
|
4841
5770
|
// recovery. This runs only after the normal fast scan fails, and by
|
|
4842
5771
|
// default only on every other missed frame after the first one.
|
|
4843
5772
|
const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
|
|
@@ -4860,7 +5789,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4860
5789
|
type: "method",
|
|
4861
5790
|
state: "trying",
|
|
4862
5791
|
method: "camera-auto-color",
|
|
4863
|
-
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(" → ")})`,
|
|
4864
5793
|
...frameDiagnostics
|
|
4865
5794
|
});
|
|
4866
5795
|
|
|
@@ -4887,9 +5816,9 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4887
5816
|
// This is intentionally performed on a centered recovery crop.
|
|
4888
5817
|
// A live preview can contain large dark borders, browser UI, a
|
|
4889
5818
|
// monitor bezel or room background. Those pixels completely
|
|
4890
|
-
// change global Auto Color/Otsu statistics even though the QR
|
|
4891
|
-
// itself looks identical to a saved crop.
|
|
4892
|
-
//
|
|
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.
|
|
4893
5822
|
blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
|
|
4894
5823
|
whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
|
|
4895
5824
|
highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
|
|
@@ -4935,7 +5864,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4935
5864
|
state: "decoded",
|
|
4936
5865
|
method: "camera-auto-color",
|
|
4937
5866
|
elapsedMs: recoveryElapsedMs,
|
|
4938
|
-
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`,
|
|
4939
5868
|
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4940
5869
|
});
|
|
4941
5870
|
capturedFrame.enhancedImageData = correctedFrame;
|
|
@@ -4986,7 +5915,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4986
5915
|
type: "method",
|
|
4987
5916
|
state: "failed",
|
|
4988
5917
|
method: profileName,
|
|
4989
|
-
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)` : ""}`,
|
|
4990
5919
|
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4991
5920
|
});
|
|
4992
5921
|
}
|
|
@@ -4996,7 +5925,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4996
5925
|
type: "method",
|
|
4997
5926
|
state: "failed",
|
|
4998
5927
|
method: "camera-auto-color",
|
|
4999
|
-
message: "All camera Auto Color profiles failed · continuing deeper recovery",
|
|
5928
|
+
message: "All camera QuadQR Auto Color profiles failed · continuing deeper recovery",
|
|
5000
5929
|
...frameDiagnostics
|
|
5001
5930
|
});
|
|
5002
5931
|
}
|
|
@@ -5065,8 +5994,8 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
5065
5994
|
emitDiagnostic({
|
|
5066
5995
|
type: "success",
|
|
5067
5996
|
state: "decoded",
|
|
5068
|
-
method: "multi-frame-
|
|
5069
|
-
message: `Multi-frame
|
|
5997
|
+
method: "multi-frame-confidence-fusion",
|
|
5998
|
+
message: `Multi-frame confidence fusion decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
|
|
5070
5999
|
...frameDiagnostics
|
|
5071
6000
|
});
|
|
5072
6001
|
missStreak = 0;
|
|
@@ -5129,6 +6058,9 @@ export const internals = Object.freeze({
|
|
|
5129
6058
|
restoreLogicalOrder,
|
|
5130
6059
|
cellsToSymbolConfidences,
|
|
5131
6060
|
decodeRsAdaptive,
|
|
6061
|
+
classifierFromPaletteRgb,
|
|
6062
|
+
classifyRgb,
|
|
6063
|
+
observationDataAgreement,
|
|
5132
6064
|
encodeProtectedHeader,
|
|
5133
6065
|
decodeProtectedHeader,
|
|
5134
6066
|
selectBestFrameObservation,
|