quadqr-js 1.2.0 → 1.4.2

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