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.
@@ -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
- LZ: "lz"
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 = 2;
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 lz.");
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
- export function compressPayload(input) {
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
- if (bytes.length < 4) return bytes.slice();
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 > 32) list.shift();
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 bestLength = 0;
340
- let bestOffset = 0;
341
- const key = keyAt(pos);
342
- const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
343
- for (let ci = candidates.length - 1; ci >= 0; ci--) {
344
- const candidate = candidates[ci];
345
- const offset = pos - candidate;
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 (bestLength >= 3) {
449
+ if (best.length >= 3) {
357
450
  flags |= (1 << token);
358
- const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
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 < bestLength; i++) remember(pos + i);
361
- pos += bestLength;
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
- function prepareCompressedBody(payload, compression = "none") {
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 { body: payload, compression: "none" };
400
- const compressed = compressPayload(payload);
401
- if (mode === "auto" && compressed.length >= payload.length - 2) {
402
- return { body: payload, compression: "none" };
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
- return { body: compressed, compression: "lz" };
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 packPayloadEnvelope(input, options = {}) {
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 === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${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" ? decompressPayload(stored, originalLength) : stored;
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 concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
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 requires explicit opt-in.
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") return { payload, extended: false, compression: "none" };
642
- const prepared = prepareCompressedBody(payload, mode);
643
- // Auto mode is truly zero-overhead when compression does not help.
644
- if (mode === "auto" && prepared.compression === "none") {
645
- return { payload, extended: false, compression: "none" };
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: packPayloadEnvelope(payload, { compression: prepared.compression }),
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 envelope = await packSignedPayloadEnvelope(source, options);
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
- protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
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
- return decodeCanonical(matrix, degrees, tolerance, confidenceMatrix, options);
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 transform = mode === "balanced" || mode === "hue"
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 entries = [
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 classifyRgb(rgb, classifier) {
3504
+ function rankRgbCandidates(rgb, classifier) {
2979
3505
  const transformed = classifier.transform(rgb);
2980
- let best = null;
2981
- let bestDistanceSq = Infinity;
2982
- let secondDistanceSq = Infinity;
2983
-
2984
- for (const candidate of classifier.entries) {
2985
- const distanceSq = colorDistanceSq(transformed, candidate.rgb);
2986
- if (distanceSq < bestDistanceSq) {
2987
- secondDistanceSq = bestDistanceSq;
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
- const distance = Math.sqrt(bestDistanceSq);
2996
- const secondDistance = Number.isFinite(secondDistanceSq) ? Math.sqrt(secondDistanceSq) : distance + 1;
2997
- const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
2998
- return { cell: best.cell, distance, confidence };
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 cellConfidence = Math.min(first.confidence, second.confidence);
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
- confidence[r][c] = cellConfidence;
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 += cellConfidence;
3110
- minimumConfidence = Math.min(minimumConfidence, cellConfidence);
3111
- if (cellConfidence < 0.4) lowConfidenceCells++;
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
- // Very cheap Photoshop-like recovery on the already-sampled module grid.
3340
- // Normal camera frames never reach this path. The transform approximates
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 Photoshop-style fallback. Enhancing the whole camera
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) => item.version === version && item.matrix.length === size)) return null;
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 (const observation of observations) {
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 weight = 0.35 + sourceConfidence * 0.65;
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]?.[1] ?? 0;
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 agreement = 0.5 * support + 0.5 * margin;
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
- // Do not become overconfident just because several blurry frames agree.
4521
- // Retaining source uncertainty lets Reed-Solomon treat repeated ambiguous
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 { version, matrix, confidence };
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 history = observationHistory.get(best.version) ?? [];
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(best.version, history);
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
- maxErasureConfidence: options.maxErasureConfidence
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-vote",
4685
- samplingMode: "multi-frame-vote",
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
- // The user's real phone-camera case is dominated by color cast before
4838
- // finder detection: Photoshop Auto Color alone makes the same live QR
4839
- // immediately detectable. Retry the exact captured frame with a cheap
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. Photoshop succeeded
4892
- // because the user's edited image was effectively QR-centric.
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-vote",
5069
- message: `Multi-frame recovery decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
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,