quadqr-js 1.3.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FORMAT.md +13 -11
- package/README.md +84 -48
- package/SPECIFICATION.md +34 -19
- package/bin/quadqr.js +4 -2
- package/dist/camera-scanner-worker.js +793 -0
- package/dist/esm/benchmark.js +37 -6
- package/dist/esm/brotli.js +2210 -0
- package/dist/esm/camera-scanner-worker.js +793 -0
- package/dist/esm/deflate.js +381 -0
- package/dist/esm/geometry.js +25 -10
- package/dist/esm/quadqr.js +1904 -191
- package/dist/esm/security.js +19 -0
- package/dist/esm/vision.js +1118 -175
- package/dist/esm/wasm.js +85 -12
- package/dist/quadqr.js +5728 -408
- package/dist/quadqr.min.js +5621 -430
- package/dist/wasm/quadqr-core.wasm +0 -0
- package/docs/API.md +54 -15
- package/docs/BROWSER_CDN.md +10 -6
- package/docs/CLI.md +167 -156
- package/docs/COMPRESSION.md +170 -0
- package/docs/GETTING_STARTED.md +12 -2
- package/docs/HIGH_DENSITY_MODE.md +100 -100
- package/docs/NODE.md +159 -137
- package/docs/README.md +62 -60
- package/docs/RELIABILITY_LAB.md +64 -0
- package/docs/TRIANGLE16.md +98 -108
- package/docs/WASM.md +30 -4
- package/package.json +12 -11
- package/types/benchmark.d.ts +0 -8
- package/types/index.d.ts +0 -403
- package/types/node.d.ts +0 -11
package/dist/esm/quadqr.js
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
alignmentPatternCentersForVersion,
|
|
25
25
|
alignmentPatternIsBlack,
|
|
26
26
|
alignmentPatternRadius,
|
|
27
|
+
ALIGNMENT_PROFILE_STANDARD_5,
|
|
28
|
+
ALIGNMENT_PROFILE_LEGACY_3,
|
|
27
29
|
sizeForVersion,
|
|
28
30
|
versionFromSize
|
|
29
31
|
} from "./geometry.js";
|
|
@@ -53,10 +55,26 @@ import {
|
|
|
53
55
|
DEFAULT_PBKDF2_ITERATIONS,
|
|
54
56
|
generateRaw256Key,
|
|
55
57
|
normalizeRaw256Key,
|
|
56
|
-
bytesToHex
|
|
58
|
+
bytesToHex,
|
|
59
|
+
estimateSecureEnvelopeOverhead
|
|
57
60
|
} from "./security.js";
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
import {
|
|
62
|
+
compressDeflatePayload as compressDeflateBytes,
|
|
63
|
+
decompressDeflatePayload as decompressDeflateBytes,
|
|
64
|
+
DEFAULT_DEFLATE_LEVEL,
|
|
65
|
+
DEFLATE_LEVEL_MIN,
|
|
66
|
+
DEFLATE_LEVEL_MAX
|
|
67
|
+
} from "./deflate.js";
|
|
68
|
+
import {
|
|
69
|
+
compressBrotliPayload as compressBrotliBytes,
|
|
70
|
+
decompressBrotliPayload as decompressBrotliBytes,
|
|
71
|
+
DEFAULT_BROTLI_QUALITY,
|
|
72
|
+
BROTLI_QUALITY_MIN,
|
|
73
|
+
BROTLI_QUALITY_MAX
|
|
74
|
+
} from "./brotli.js";
|
|
75
|
+
|
|
76
|
+
export const FORMAT_VERSION = 6;
|
|
77
|
+
export const LEGACY_FORMAT_VERSION = 5;
|
|
60
78
|
export const MIN_VERSION = 1;
|
|
61
79
|
export const MAX_VERSION = 40;
|
|
62
80
|
export const DEFAULT_ECC_LEVEL = "M";
|
|
@@ -85,7 +103,30 @@ export const PRINT_PALETTE = Object.freeze({
|
|
|
85
103
|
export const COMPRESSION_MODES = Object.freeze({
|
|
86
104
|
NONE: "none",
|
|
87
105
|
AUTO: "auto",
|
|
88
|
-
|
|
106
|
+
SMART: "smart",
|
|
107
|
+
LZ: "lz",
|
|
108
|
+
DEFLATE: "deflate",
|
|
109
|
+
BROTLI: "brotli"
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export const LZ_LEVEL_MIN = 1;
|
|
113
|
+
export const LZ_LEVEL_MAX = 9;
|
|
114
|
+
export const DEFAULT_LZ_LEVEL = 6;
|
|
115
|
+
|
|
116
|
+
export const COMPRESSION_LEVELS = Object.freeze({
|
|
117
|
+
lz: Object.freeze({ min: LZ_LEVEL_MIN, max: LZ_LEVEL_MAX, default: DEFAULT_LZ_LEVEL }),
|
|
118
|
+
deflate: Object.freeze({ min: DEFLATE_LEVEL_MIN, max: DEFLATE_LEVEL_MAX, default: DEFAULT_DEFLATE_LEVEL }),
|
|
119
|
+
brotli: Object.freeze({ min: BROTLI_QUALITY_MIN, max: BROTLI_QUALITY_MAX, default: DEFAULT_BROTLI_QUALITY }),
|
|
120
|
+
auto: Object.freeze({ lz: DEFAULT_LZ_LEVEL, deflate: DEFAULT_DEFLATE_LEVEL, brotli: 6 }),
|
|
121
|
+
smart: Object.freeze({
|
|
122
|
+
initialLz: DEFAULT_LZ_LEVEL,
|
|
123
|
+
initialDeflate: DEFAULT_DEFLATE_LEVEL,
|
|
124
|
+
initialBrotli: 6,
|
|
125
|
+
strongDeflate: 8,
|
|
126
|
+
strongBrotli: 9,
|
|
127
|
+
maximumDeflate: 9,
|
|
128
|
+
maximumBrotli: 11
|
|
129
|
+
})
|
|
89
130
|
});
|
|
90
131
|
export const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
|
|
91
132
|
|
|
@@ -162,14 +203,14 @@ const TRIANGLE16_FLAG = 1 << 6;
|
|
|
162
203
|
// Internal payload extension envelope. This is deliberately not a public
|
|
163
204
|
// payload mode or content-type system. It exists only when compression or
|
|
164
205
|
// signing needs metadata around an otherwise normal text/binary payload.
|
|
165
|
-
const PAYLOAD_ENVELOPE_VERSION =
|
|
206
|
+
const PAYLOAD_ENVELOPE_VERSION = 3;
|
|
166
207
|
const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
|
|
167
208
|
const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
|
|
168
209
|
const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
|
|
169
210
|
const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
|
|
170
211
|
const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
|
|
171
|
-
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
|
|
172
|
-
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
|
|
212
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1, deflate: 2, brotli: 3 });
|
|
213
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz", 2: "deflate", 3: "brotli" });
|
|
173
214
|
const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
|
|
174
215
|
const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
|
|
175
216
|
const ECC_SHIFT = 1;
|
|
@@ -301,10 +342,36 @@ function readU16be(bytes, offset = 0) {
|
|
|
301
342
|
|
|
302
343
|
function normalizeCompressionMode(value = "none") {
|
|
303
344
|
const key = String(value).toLowerCase();
|
|
304
|
-
assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or
|
|
345
|
+
assert(["none", "auto", "smart", "lz", "deflate", "brotli"].includes(key), "compression must be none, auto, smart, lz, deflate, or brotli.");
|
|
305
346
|
return key;
|
|
306
347
|
}
|
|
307
348
|
|
|
349
|
+
function normalizeExplicitCompressionLevel(mode, options = {}) {
|
|
350
|
+
const generic = options.compressionLevel;
|
|
351
|
+
if (mode === "lz") {
|
|
352
|
+
const value = Number(generic ?? options.lzLevel ?? DEFAULT_LZ_LEVEL);
|
|
353
|
+
assert(Number.isInteger(value) && value >= LZ_LEVEL_MIN && value <= LZ_LEVEL_MAX, `LZ compressionLevel must be ${LZ_LEVEL_MIN}..${LZ_LEVEL_MAX}.`);
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
if (mode === "deflate") {
|
|
357
|
+
const value = Number(generic ?? options.deflateLevel ?? DEFAULT_DEFLATE_LEVEL);
|
|
358
|
+
assert(Number.isInteger(value) && value >= DEFLATE_LEVEL_MIN && value <= DEFLATE_LEVEL_MAX, `DEFLATE compressionLevel must be ${DEFLATE_LEVEL_MIN}..${DEFLATE_LEVEL_MAX}.`);
|
|
359
|
+
return value;
|
|
360
|
+
}
|
|
361
|
+
if (mode === "brotli") {
|
|
362
|
+
const value = Number(generic ?? options.brotliQuality ?? DEFAULT_BROTLI_QUALITY);
|
|
363
|
+
assert(Number.isInteger(value) && value >= BROTLI_QUALITY_MIN && value <= BROTLI_QUALITY_MAX, `Brotli compressionLevel must be ${BROTLI_QUALITY_MIN}..${BROTLI_QUALITY_MAX}.`);
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
if (generic != null || options.lzLevel != null || options.deflateLevel != null || options.brotliQuality != null) {
|
|
367
|
+
assert(
|
|
368
|
+
mode === "auto" || mode === "smart",
|
|
369
|
+
"compressionLevel is only meaningful with compression: lz, compression: deflate, or compression: brotli."
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
308
375
|
function asBytes(input) {
|
|
309
376
|
if (input instanceof Uint8Array) return input;
|
|
310
377
|
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
@@ -312,10 +379,27 @@ function asBytes(input) {
|
|
|
312
379
|
return new Uint8Array(input);
|
|
313
380
|
}
|
|
314
381
|
|
|
315
|
-
/** Portable LZSS-style compressor used by QuadQR payload compression.
|
|
316
|
-
|
|
382
|
+
/** Portable LZSS-style compressor used by QuadQR payload compression.
|
|
383
|
+
* Levels 1..9 only change encoder search effort. The wire format is unchanged,
|
|
384
|
+
* so every level is decoded by the same legacy LZ decoder. Level 6 preserves
|
|
385
|
+
* the historical QuadQR search depth and is the default.
|
|
386
|
+
*/
|
|
387
|
+
export function compressPayload(input, options = {}) {
|
|
317
388
|
const bytes = asBytes(input);
|
|
318
|
-
|
|
389
|
+
const requestedLevel = typeof options === "number"
|
|
390
|
+
? options
|
|
391
|
+
: (options.level ?? options.compressionLevel ?? DEFAULT_LZ_LEVEL);
|
|
392
|
+
const level = Number(requestedLevel);
|
|
393
|
+
assert(Number.isInteger(level) && level >= LZ_LEVEL_MIN && level <= LZ_LEVEL_MAX, `LZ level must be ${LZ_LEVEL_MIN}..${LZ_LEVEL_MAX}.`);
|
|
394
|
+
|
|
395
|
+
// Level 6 deliberately matches the original compressor's 32-candidate
|
|
396
|
+
// history exactly. Higher levels walk deeper chains and add bounded lazy
|
|
397
|
+
// lookahead, while lower levels reduce CPU work for faster encoding.
|
|
398
|
+
const candidateDepth = [0, 4, 8, 12, 16, 24, 32, 48, 64, 96][level];
|
|
399
|
+
const lazyDepth = [0, 0, 0, 0, 0, 0, 0, 12, 24, 48][level];
|
|
400
|
+
const lazyGain = level >= 9 ? 0 : level >= 7 ? 1 : 2;
|
|
401
|
+
const historyLimit = Math.max(candidateDepth, lazyDepth, 4);
|
|
402
|
+
|
|
319
403
|
const out = [];
|
|
320
404
|
const recent = new Map();
|
|
321
405
|
let pos = 0;
|
|
@@ -327,38 +411,50 @@ export function compressPayload(input) {
|
|
|
327
411
|
let list = recent.get(key);
|
|
328
412
|
if (!list) recent.set(key, list = []);
|
|
329
413
|
list.push(i);
|
|
330
|
-
while (list.length >
|
|
414
|
+
while (list.length > historyLimit) list.shift();
|
|
331
415
|
while (list.length && i - list[0] > 4095) list.shift();
|
|
332
416
|
};
|
|
333
417
|
|
|
418
|
+
const findBest = (position, maxCandidates) => {
|
|
419
|
+
let bestLength = 0;
|
|
420
|
+
let bestOffset = 0;
|
|
421
|
+
const key = keyAt(position);
|
|
422
|
+
const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
|
|
423
|
+
for (let ci = candidates.length - 1, checked = 0; ci >= 0 && checked < maxCandidates; ci--, checked++) {
|
|
424
|
+
const candidate = candidates[ci];
|
|
425
|
+
const offset = position - candidate;
|
|
426
|
+
if (offset <= 0 || offset > 4095) continue;
|
|
427
|
+
let length = 0;
|
|
428
|
+
while (length < 18 && position + length < bytes.length && bytes[candidate + length] === bytes[position + length]) length++;
|
|
429
|
+
if (length >= 3 && length > bestLength) {
|
|
430
|
+
bestLength = length;
|
|
431
|
+
bestOffset = offset;
|
|
432
|
+
if (length === 18) break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { length: bestLength, offset: bestOffset };
|
|
436
|
+
};
|
|
437
|
+
|
|
334
438
|
while (pos < bytes.length) {
|
|
335
439
|
const flagIndex = out.length;
|
|
336
440
|
out.push(0);
|
|
337
441
|
let flags = 0;
|
|
338
442
|
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
339
|
-
let
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
if (offset <= 0 || offset > 4095) continue;
|
|
347
|
-
let length = 0;
|
|
348
|
-
while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
|
|
349
|
-
if (length >= 3 && length > bestLength) {
|
|
350
|
-
bestLength = length;
|
|
351
|
-
bestOffset = offset;
|
|
352
|
-
if (length === 18) break;
|
|
353
|
-
}
|
|
443
|
+
let best = findBest(pos, candidateDepth);
|
|
444
|
+
|
|
445
|
+
// Strong levels may emit one literal when the next byte begins a
|
|
446
|
+
// meaningfully longer match. This changes encoder effort only.
|
|
447
|
+
if (best.length >= 3 && lazyDepth > 0 && pos + 1 < bytes.length) {
|
|
448
|
+
const next = findBest(pos + 1, lazyDepth);
|
|
449
|
+
if (next.length > best.length + lazyGain) best = { length: 0, offset: 0 };
|
|
354
450
|
}
|
|
355
451
|
|
|
356
|
-
if (
|
|
452
|
+
if (best.length >= 3) {
|
|
357
453
|
flags |= (1 << token);
|
|
358
|
-
const encoded = ((
|
|
454
|
+
const encoded = ((best.offset & 0x0fff) << 4) | ((best.length - 3) & 0x0f);
|
|
359
455
|
out.push((encoded >>> 8) & 0xff, encoded & 0xff);
|
|
360
|
-
for (let i = 0; i <
|
|
361
|
-
pos +=
|
|
456
|
+
for (let i = 0; i < best.length; i++) remember(pos + i);
|
|
457
|
+
pos += best.length;
|
|
362
458
|
} else {
|
|
363
459
|
out.push(bytes[pos]);
|
|
364
460
|
remember(pos);
|
|
@@ -394,14 +490,210 @@ export function decompressPayload(input, expectedLength = null) {
|
|
|
394
490
|
return Uint8Array.from(out);
|
|
395
491
|
}
|
|
396
492
|
|
|
397
|
-
|
|
493
|
+
/** Raw RFC 1951 fixed-Huffman DEFLATE helper used by Compression 3.0. */
|
|
494
|
+
export function compressDeflatePayload(input, options = {}) {
|
|
495
|
+
return compressDeflateBytes(asBytes(input), options);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Restore a raw DEFLATE payload produced by compressDeflatePayload(). */
|
|
499
|
+
export function decompressDeflatePayload(input, expectedLength = null) {
|
|
500
|
+
return decompressDeflateBytes(asBytes(input), expectedLength);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Portable Brotli helper used by Compression 3.0 in browsers and Node.js. */
|
|
504
|
+
export function compressBrotliPayload(input, options = {}) {
|
|
505
|
+
return compressBrotliBytes(asBytes(input), options);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Restore a Brotli payload produced by compressBrotliPayload(). */
|
|
509
|
+
export function decompressBrotliPayload(input, expectedLength = null) {
|
|
510
|
+
return decompressBrotliBytes(asBytes(input), expectedLength);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function makeCompressionCandidate(payload, compression, compressionLevel = null) {
|
|
514
|
+
if (compression === "none") return { body: payload, compression: "none", compressionLevel: null };
|
|
515
|
+
if (compression === "lz") {
|
|
516
|
+
return {
|
|
517
|
+
body: compressPayload(payload, { level: compressionLevel ?? DEFAULT_LZ_LEVEL }),
|
|
518
|
+
compression: "lz",
|
|
519
|
+
compressionLevel: compressionLevel ?? DEFAULT_LZ_LEVEL
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (compression === "deflate") {
|
|
523
|
+
return {
|
|
524
|
+
body: compressDeflatePayload(payload, { level: compressionLevel ?? DEFAULT_DEFLATE_LEVEL }),
|
|
525
|
+
compression: "deflate",
|
|
526
|
+
compressionLevel: compressionLevel ?? DEFAULT_DEFLATE_LEVEL
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (compression === "brotli") {
|
|
530
|
+
return {
|
|
531
|
+
body: compressBrotliPayload(payload, { quality: compressionLevel ?? DEFAULT_BROTLI_QUALITY }),
|
|
532
|
+
compression: "brotli",
|
|
533
|
+
compressionLevel: compressionLevel ?? DEFAULT_BROTLI_QUALITY
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
throw new Error(`Unsupported compression candidate ${compression}.`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function candidateFinalPayloadLength(candidate, payload, options = {}) {
|
|
540
|
+
const outerFixedBytes = Math.max(0, Number(options.outerFixedBytes ?? 0) || 0);
|
|
541
|
+
if (options.envelopeAlreadyRequired) {
|
|
542
|
+
const envelopeFixedBytes = Math.max(
|
|
543
|
+
PAYLOAD_ENVELOPE_HEADER_BYTES,
|
|
544
|
+
Number(options.envelopeFixedBytes ?? PAYLOAD_ENVELOPE_HEADER_BYTES) || PAYLOAD_ENVELOPE_HEADER_BYTES
|
|
545
|
+
);
|
|
546
|
+
return outerFixedBytes + envelopeFixedBytes + candidate.body.length;
|
|
547
|
+
}
|
|
548
|
+
const innerBytes = candidate.compression === "none"
|
|
549
|
+
? candidate.body.length
|
|
550
|
+
: PAYLOAD_ENVELOPE_HEADER_BYTES + candidate.body.length;
|
|
551
|
+
return outerFixedBytes + innerBytes;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function compareCompressionCandidates(a, b) {
|
|
555
|
+
if (a.finalLength !== b.finalLength) return a.finalLength - b.finalLength;
|
|
556
|
+
// Prefer the cheaper decoder when storage is exactly tied.
|
|
557
|
+
const rank = { none: 0, lz: 1, deflate: 2, brotli: 3 };
|
|
558
|
+
return (rank[a.compression] ?? 9) - (rank[b.compression] ?? 9);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function annotateCompressionCandidate(candidate, payload, options = {}) {
|
|
562
|
+
return { ...candidate, finalLength: candidateFinalPayloadLength(candidate, payload, options) };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function safeChooseCompressionVersion(payloadLength, versionOptions = {}) {
|
|
566
|
+
try {
|
|
567
|
+
return chooseVersion(payloadLength, versionOptions);
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function smartCompressionTarget(candidate, versionOptions = {}) {
|
|
574
|
+
const requested = versionOptions.version ?? "auto";
|
|
575
|
+
const minVersion = versionOptions.minVersion ?? MIN_VERSION;
|
|
576
|
+
const maxVersion = versionOptions.maxVersion ?? MAX_VERSION;
|
|
577
|
+
|
|
578
|
+
// A fixed version cannot become physically smaller, but Smart may still
|
|
579
|
+
// spend extra CPU when stronger compression is needed to make that exact
|
|
580
|
+
// requested version fit.
|
|
581
|
+
if (requested !== "auto") {
|
|
582
|
+
validateVersion(requested);
|
|
583
|
+
const info = getVersionInfo(requested, versionOptions);
|
|
584
|
+
if (candidate.finalLength <= info.capacityBytes) return null;
|
|
585
|
+
const gapBytes = candidate.finalLength - info.capacityBytes;
|
|
586
|
+
return {
|
|
587
|
+
currentVersion: null,
|
|
588
|
+
targetVersion: requested,
|
|
589
|
+
targetCapacityBytes: info.capacityBytes,
|
|
590
|
+
gapBytes,
|
|
591
|
+
gapRatio: candidate.finalLength > 0 ? gapBytes / candidate.finalLength : 0
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const currentVersion = safeChooseCompressionVersion(candidate.finalLength, versionOptions);
|
|
596
|
+
const targetVersion = currentVersion == null ? maxVersion : currentVersion - 1;
|
|
597
|
+
if (targetVersion < minVersion) return null;
|
|
598
|
+
const info = getVersionInfo(targetVersion, versionOptions);
|
|
599
|
+
const gapBytes = Math.max(0, candidate.finalLength - info.capacityBytes);
|
|
600
|
+
return {
|
|
601
|
+
currentVersion,
|
|
602
|
+
targetVersion,
|
|
603
|
+
targetCapacityBytes: info.capacityBytes,
|
|
604
|
+
gapBytes,
|
|
605
|
+
gapRatio: candidate.finalLength > 0 ? gapBytes / candidate.finalLength : 0
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function compressionCandidates(payload, options = {}) {
|
|
610
|
+
return [
|
|
611
|
+
makeCompressionCandidate(payload, "none"),
|
|
612
|
+
makeCompressionCandidate(payload, "lz", DEFAULT_LZ_LEVEL),
|
|
613
|
+
makeCompressionCandidate(payload, "deflate", DEFAULT_DEFLATE_LEVEL),
|
|
614
|
+
makeCompressionCandidate(payload, "brotli", 6)
|
|
615
|
+
].map((candidate) => annotateCompressionCandidate(candidate, payload, options));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function chooseBestCompressionCandidate(candidates) {
|
|
619
|
+
return candidates.slice().sort(compareCompressionCandidates)[0];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function prepareSmartCompressedBody(payload, options = {}) {
|
|
623
|
+
const versionOptions = options.versionOptions ?? {};
|
|
624
|
+
const tried = [];
|
|
625
|
+
const seen = new Set();
|
|
626
|
+
const add = (compression, level = null) => {
|
|
627
|
+
const key = `${compression}:${level ?? ""}`;
|
|
628
|
+
if (seen.has(key)) return null;
|
|
629
|
+
seen.add(key);
|
|
630
|
+
const candidate = annotateCompressionCandidate(makeCompressionCandidate(payload, compression, level), payload, options);
|
|
631
|
+
tried.push(candidate);
|
|
632
|
+
return candidate;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
add("none");
|
|
636
|
+
add("lz", DEFAULT_LZ_LEVEL);
|
|
637
|
+
add("deflate", DEFAULT_DEFLATE_LEVEL);
|
|
638
|
+
add("brotli", 6);
|
|
639
|
+
let best = chooseBestCompressionCandidate(tried);
|
|
640
|
+
const initialVersion = safeChooseCompressionVersion(best.finalLength, versionOptions);
|
|
641
|
+
|
|
642
|
+
// Smart is intentionally CPU-heavy, but it still avoids maximum-quality
|
|
643
|
+
// passes when the next smaller matrix is far outside realistic reach.
|
|
644
|
+
let target = smartCompressionTarget(best, versionOptions);
|
|
645
|
+
if (target && (target.gapRatio <= 0.30 || target.gapBytes <= 192)) {
|
|
646
|
+
add("deflate", 8);
|
|
647
|
+
add("brotli", 9);
|
|
648
|
+
best = chooseBestCompressionCandidate(tried);
|
|
649
|
+
target = smartCompressionTarget(best, versionOptions);
|
|
650
|
+
|
|
651
|
+
// Maximum passes are reserved for a genuinely close version boundary, or
|
|
652
|
+
// when the strong pass already crossed one boundary and another is close.
|
|
653
|
+
if (target && (target.gapRatio <= 0.16 || target.gapBytes <= 96)) {
|
|
654
|
+
add("deflate", 9);
|
|
655
|
+
add("brotli", 11);
|
|
656
|
+
best = chooseBestCompressionCandidate(tried);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const finalVersion = safeChooseCompressionVersion(best.finalLength, versionOptions);
|
|
661
|
+
return {
|
|
662
|
+
...best,
|
|
663
|
+
compressionStrategy: "smart",
|
|
664
|
+
smartCompression: {
|
|
665
|
+
cpuHeavy: true,
|
|
666
|
+
initialVersion,
|
|
667
|
+
finalVersion,
|
|
668
|
+
levelsTried: tried
|
|
669
|
+
.filter((candidate) => candidate.compression === "deflate" || candidate.compression === "brotli")
|
|
670
|
+
.map((candidate) => ({ algorithm: candidate.compression, level: candidate.compressionLevel, bytes: candidate.body.length, finalBytes: candidate.finalLength }))
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function prepareCompressedBody(payload, compression = "none", options = {}) {
|
|
398
676
|
const mode = normalizeCompressionMode(compression);
|
|
399
|
-
if (mode === "none") return {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
return {
|
|
677
|
+
if (mode === "none") return { ...makeCompressionCandidate(payload, "none"), compressionStrategy: "none" };
|
|
678
|
+
if (mode === "lz") {
|
|
679
|
+
const level = normalizeExplicitCompressionLevel(mode, options);
|
|
680
|
+
return { ...makeCompressionCandidate(payload, "lz", level), compressionStrategy: "explicit" };
|
|
681
|
+
}
|
|
682
|
+
if (mode === "deflate") {
|
|
683
|
+
const level = normalizeExplicitCompressionLevel(mode, options);
|
|
684
|
+
return { ...makeCompressionCandidate(payload, "deflate", level), compressionStrategy: "explicit" };
|
|
685
|
+
}
|
|
686
|
+
if (mode === "brotli") {
|
|
687
|
+
const level = normalizeExplicitCompressionLevel(mode, options);
|
|
688
|
+
return { ...makeCompressionCandidate(payload, "brotli", level), compressionStrategy: "explicit" };
|
|
403
689
|
}
|
|
404
|
-
|
|
690
|
+
if (mode === "smart") return prepareSmartCompressedBody(payload, options);
|
|
691
|
+
|
|
692
|
+
// Auto deliberately performs one balanced pass per codec. It is the fast
|
|
693
|
+
// default; Smart is the opt-in mode that spends extra CPU near QR-version
|
|
694
|
+
// boundaries.
|
|
695
|
+
const best = chooseBestCompressionCandidate(compressionCandidates(payload, options));
|
|
696
|
+
return { ...best, compressionStrategy: "auto" };
|
|
405
697
|
}
|
|
406
698
|
|
|
407
699
|
|
|
@@ -435,9 +727,7 @@ function isPayloadEnvelope(input) {
|
|
|
435
727
|
return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
|
|
436
728
|
}
|
|
437
729
|
|
|
438
|
-
function
|
|
439
|
-
const payload = asBytes(input);
|
|
440
|
-
const prepared = prepareCompressedBody(payload, options.compression ?? "none");
|
|
730
|
+
function packPreparedPayloadEnvelope(payload, prepared) {
|
|
441
731
|
const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
|
|
442
732
|
const header = makePayloadEnvelopeHeader({
|
|
443
733
|
compression: prepared.compression,
|
|
@@ -447,11 +737,17 @@ function packPayloadEnvelope(input, options = {}) {
|
|
|
447
737
|
return concatBytes(header, prepared.body);
|
|
448
738
|
}
|
|
449
739
|
|
|
740
|
+
function packPayloadEnvelope(input, options = {}) {
|
|
741
|
+
const payload = asBytes(input);
|
|
742
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "none", options);
|
|
743
|
+
return packPreparedPayloadEnvelope(payload, prepared);
|
|
744
|
+
}
|
|
745
|
+
|
|
450
746
|
function parsePayloadEnvelope(input) {
|
|
451
747
|
const container = asBytes(input);
|
|
452
748
|
assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
|
|
453
749
|
const version = container[4];
|
|
454
|
-
assert(version
|
|
750
|
+
assert(version >= 1 && version <= PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
|
|
455
751
|
const flags = container[5];
|
|
456
752
|
const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
|
|
457
753
|
assert(compression != null, `Unknown payload compression id ${container[6]}.`);
|
|
@@ -469,7 +765,13 @@ function parsePayloadEnvelope(input) {
|
|
|
469
765
|
const publicKey = container.slice(endMetadata, endPublicKey);
|
|
470
766
|
const signature = container.slice(endPublicKey, endSignature);
|
|
471
767
|
const stored = container.slice(endSignature);
|
|
472
|
-
const payload = compression === "lz"
|
|
768
|
+
const payload = compression === "lz"
|
|
769
|
+
? decompressPayload(stored, originalLength)
|
|
770
|
+
: compression === "deflate"
|
|
771
|
+
? decompressDeflatePayload(stored, originalLength)
|
|
772
|
+
: compression === "brotli"
|
|
773
|
+
? decompressBrotliPayload(stored, originalLength)
|
|
774
|
+
: stored;
|
|
473
775
|
assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
|
|
474
776
|
const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
|
|
475
777
|
const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
|
|
@@ -558,7 +860,6 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
558
860
|
const payload = asBytes(input);
|
|
559
861
|
assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
|
|
560
862
|
const subtle = subtleCrypto();
|
|
561
|
-
const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
|
|
562
863
|
const keyIdBytes = normalizeSigningKeyId(options.keyId);
|
|
563
864
|
|
|
564
865
|
let publicKeyBytes = new Uint8Array(0);
|
|
@@ -568,6 +869,14 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
568
869
|
publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
|
|
569
870
|
}
|
|
570
871
|
|
|
872
|
+
const signedEnvelopeFixedBytes = PAYLOAD_ENVELOPE_HEADER_BYTES + keyIdBytes.length + publicKeyBytes.length + 64;
|
|
873
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "auto", {
|
|
874
|
+
...options,
|
|
875
|
+
envelopeAlreadyRequired: true,
|
|
876
|
+
envelopeFixedBytes: signedEnvelopeFixedBytes,
|
|
877
|
+
versionOptions: options.versionOptions ?? options
|
|
878
|
+
});
|
|
879
|
+
|
|
571
880
|
const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
|
|
572
881
|
(prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
|
|
573
882
|
(publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
|
|
@@ -584,7 +893,13 @@ async function packSignedPayloadEnvelope(input, options = {}) {
|
|
|
584
893
|
const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
|
|
585
894
|
const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
|
|
586
895
|
assert(signature.length === 64, "Unexpected Ed25519 signature length.");
|
|
587
|
-
return
|
|
896
|
+
return {
|
|
897
|
+
envelope: concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body),
|
|
898
|
+
compression: prepared.compression,
|
|
899
|
+
compressionLevel: prepared.compressionLevel ?? null,
|
|
900
|
+
compressionStrategy: prepared.compressionStrategy ?? null,
|
|
901
|
+
smartCompression: prepared.smartCompression ?? null
|
|
902
|
+
};
|
|
588
903
|
}
|
|
589
904
|
|
|
590
905
|
function lookupTrustedSigningKey(trustedKeys, keyId) {
|
|
@@ -604,7 +919,7 @@ async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
|
604
919
|
let trustSource = trusted ? "external" : null;
|
|
605
920
|
|
|
606
921
|
// Legacy v1 symbols embedded the public key by default, so preserve their
|
|
607
|
-
// historical self-verification behavior. v2
|
|
922
|
+
// historical self-verification behavior. v2+ require explicit opt-in.
|
|
608
923
|
if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
|
|
609
924
|
trusted = parsed.publicKey;
|
|
610
925
|
trustSource = "embedded";
|
|
@@ -635,19 +950,31 @@ async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
|
635
950
|
};
|
|
636
951
|
}
|
|
637
952
|
|
|
638
|
-
function prepareOptionalCompressedPayload(input, compression = "none") {
|
|
953
|
+
function prepareOptionalCompressedPayload(input, compression = "none", options = {}) {
|
|
639
954
|
const payload = asBytes(input);
|
|
640
955
|
const mode = normalizeCompressionMode(compression);
|
|
641
|
-
if (mode === "none")
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
956
|
+
if (mode === "none") {
|
|
957
|
+
return { payload, extended: false, compression: "none", compressionLevel: null, compressionStrategy: "none", smartCompression: null };
|
|
958
|
+
}
|
|
959
|
+
const prepared = prepareCompressedBody(payload, mode, { ...options, versionOptions: options.versionOptions ?? options });
|
|
960
|
+
// Auto and Smart are truly zero-overhead when compression does not help.
|
|
961
|
+
if ((mode === "auto" || mode === "smart") && prepared.compression === "none") {
|
|
962
|
+
return {
|
|
963
|
+
payload,
|
|
964
|
+
extended: false,
|
|
965
|
+
compression: "none",
|
|
966
|
+
compressionLevel: null,
|
|
967
|
+
compressionStrategy: prepared.compressionStrategy,
|
|
968
|
+
smartCompression: prepared.smartCompression ?? null
|
|
969
|
+
};
|
|
646
970
|
}
|
|
647
971
|
return {
|
|
648
|
-
payload:
|
|
972
|
+
payload: packPreparedPayloadEnvelope(payload, prepared),
|
|
649
973
|
extended: true,
|
|
650
|
-
compression: prepared.compression
|
|
974
|
+
compression: prepared.compression,
|
|
975
|
+
compressionLevel: prepared.compressionLevel ?? null,
|
|
976
|
+
compressionStrategy: prepared.compressionStrategy ?? mode,
|
|
977
|
+
smartCompression: prepared.smartCompression ?? null
|
|
651
978
|
};
|
|
652
979
|
}
|
|
653
980
|
|
|
@@ -809,7 +1136,7 @@ function getEffectiveEcc(version, eccLevel) {
|
|
|
809
1136
|
return version === COMPACT_VERSION ? COMPACT_ECC_LEVELS[normalized] : ECC_LEVELS[normalized];
|
|
810
1137
|
}
|
|
811
1138
|
|
|
812
|
-
function makeHeader(payloadLength, flags, version) {
|
|
1139
|
+
function makeHeader(payloadLength, flags, version, formatVersion = FORMAT_VERSION) {
|
|
813
1140
|
assert(payloadLength >= 0 && payloadLength <= 0xffffffff, "Payload is too large.");
|
|
814
1141
|
|
|
815
1142
|
if (version === COMPACT_VERSION) {
|
|
@@ -824,7 +1151,7 @@ function makeHeader(payloadLength, flags, version) {
|
|
|
824
1151
|
|
|
825
1152
|
const header = new Uint8Array(HEADER_BYTES);
|
|
826
1153
|
header.set(MAGIC, 0);
|
|
827
|
-
header[4] =
|
|
1154
|
+
header[4] = formatVersion;
|
|
828
1155
|
header[5] = flags & 0xff;
|
|
829
1156
|
header.set(u32be(payloadLength), 6);
|
|
830
1157
|
return header;
|
|
@@ -846,14 +1173,22 @@ function parseHeader(header, version) {
|
|
|
846
1173
|
return { flags: header[1], payloadLength: header[2] };
|
|
847
1174
|
}
|
|
848
1175
|
|
|
849
|
-
|
|
1176
|
+
const formatVersion = header[4];
|
|
1177
|
+
if (!magicMatches(header) || (formatVersion !== FORMAT_VERSION && formatVersion !== LEGACY_FORMAT_VERSION)) {
|
|
850
1178
|
throw new Error("QuadQR magic/version mismatch.");
|
|
851
1179
|
}
|
|
852
|
-
return { flags: header[5], payloadLength: readU32be(header, 6) };
|
|
1180
|
+
return { formatVersion, flags: header[5], payloadLength: readU32be(header, 6) };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function alignmentProfileForFormat(formatVersion = FORMAT_VERSION) {
|
|
1184
|
+
return formatVersion === LEGACY_FORMAT_VERSION
|
|
1185
|
+
? ALIGNMENT_PROFILE_LEGACY_3
|
|
1186
|
+
: ALIGNMENT_PROFILE_STANDARD_5;
|
|
853
1187
|
}
|
|
854
1188
|
|
|
855
|
-
function createLayout(version) {
|
|
1189
|
+
function createLayout(version, options = {}) {
|
|
856
1190
|
validateVersion(version);
|
|
1191
|
+
const alignmentProfile = options.alignmentProfile ?? alignmentProfileForFormat(options.formatVersion ?? FORMAT_VERSION);
|
|
857
1192
|
const size = sizeForVersion(version);
|
|
858
1193
|
const matrix = make2D(size, CELL.WHITE);
|
|
859
1194
|
const reserved = make2D(size, false);
|
|
@@ -981,7 +1316,7 @@ function createLayout(version) {
|
|
|
981
1316
|
reserveAndSet(row, 6, row % 2 === 0 ? CELL.BLACK : CELL.WHITE);
|
|
982
1317
|
}
|
|
983
1318
|
|
|
984
|
-
const alignments = alignmentPatternCentersForVersion(version).map(drawAlignmentPattern);
|
|
1319
|
+
const alignments = alignmentPatternCentersForVersion(version, { profile: alignmentProfile }).map(drawAlignmentPattern);
|
|
985
1320
|
const alignment = alignments[alignments.length - 1];
|
|
986
1321
|
const calibrationStrip = drawCalibrationStrip();
|
|
987
1322
|
|
|
@@ -1005,6 +1340,7 @@ function createLayout(version) {
|
|
|
1005
1340
|
return {
|
|
1006
1341
|
version,
|
|
1007
1342
|
size,
|
|
1343
|
+
alignmentProfile,
|
|
1008
1344
|
matrix,
|
|
1009
1345
|
reserved,
|
|
1010
1346
|
dataPositions,
|
|
@@ -1367,6 +1703,9 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
1367
1703
|
secure: Boolean(meta.secure),
|
|
1368
1704
|
compressed: meta.compression != null && meta.compression !== "none",
|
|
1369
1705
|
compression: meta.compression ?? "none",
|
|
1706
|
+
compressionLevel: meta.compressionLevel ?? null,
|
|
1707
|
+
compressionStrategy: meta.compressionStrategy ?? null,
|
|
1708
|
+
smartCompression: meta.smartCompression ?? null,
|
|
1370
1709
|
signed: Boolean(meta.signed),
|
|
1371
1710
|
signingKeyId: meta.signingKeyId ?? null,
|
|
1372
1711
|
hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
|
|
@@ -1427,6 +1766,9 @@ function encodePreparedBytes(input, options = {}) {
|
|
|
1427
1766
|
extended,
|
|
1428
1767
|
signed,
|
|
1429
1768
|
compression: options.compressionMetadata ?? "none",
|
|
1769
|
+
compressionLevel: options.compressionLevelMetadata ?? null,
|
|
1770
|
+
compressionStrategy: options.compressionStrategyMetadata ?? null,
|
|
1771
|
+
smartCompression: options.smartCompressionMetadata ?? null,
|
|
1430
1772
|
signingKeyId: options.signingKeyId ?? null,
|
|
1431
1773
|
hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
|
|
1432
1774
|
security: options.securityMetadata ?? null,
|
|
@@ -1442,25 +1784,31 @@ function encodePreparedBytes(input, options = {}) {
|
|
|
1442
1784
|
export function encodeText(text, options = {}) {
|
|
1443
1785
|
assert(typeof text === "string", "encodeText expects a string.");
|
|
1444
1786
|
const source = getTextEncoder().encode(text);
|
|
1445
|
-
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1787
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none", options);
|
|
1446
1788
|
return encodePreparedBytes(prepared.payload, {
|
|
1447
1789
|
...options,
|
|
1448
1790
|
text: true,
|
|
1449
1791
|
extended: prepared.extended,
|
|
1450
1792
|
sourcePayloadBytes: source.length,
|
|
1451
|
-
compressionMetadata: prepared.compression
|
|
1793
|
+
compressionMetadata: prepared.compression,
|
|
1794
|
+
compressionLevelMetadata: prepared.compressionLevel,
|
|
1795
|
+
compressionStrategyMetadata: prepared.compressionStrategy,
|
|
1796
|
+
smartCompressionMetadata: prepared.smartCompression
|
|
1452
1797
|
});
|
|
1453
1798
|
}
|
|
1454
1799
|
|
|
1455
1800
|
export function encodeBytes(input, options = {}) {
|
|
1456
1801
|
const source = asBytes(input);
|
|
1457
|
-
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1802
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none", options);
|
|
1458
1803
|
return encodePreparedBytes(prepared.payload, {
|
|
1459
1804
|
...options,
|
|
1460
1805
|
text: Boolean(options.text),
|
|
1461
1806
|
extended: prepared.extended,
|
|
1462
1807
|
sourcePayloadBytes: source.length,
|
|
1463
|
-
compressionMetadata: prepared.compression
|
|
1808
|
+
compressionMetadata: prepared.compression,
|
|
1809
|
+
compressionLevelMetadata: prepared.compressionLevel,
|
|
1810
|
+
compressionStrategyMetadata: prepared.compressionStrategy,
|
|
1811
|
+
smartCompressionMetadata: prepared.smartCompression
|
|
1464
1812
|
});
|
|
1465
1813
|
}
|
|
1466
1814
|
|
|
@@ -1472,15 +1820,18 @@ export function encodeUint8Array(input, options = {}) {
|
|
|
1472
1820
|
/** Encode and sign a normal payload using an internal Ed25519 envelope. */
|
|
1473
1821
|
export async function encodeSignedBytes(input, options = {}) {
|
|
1474
1822
|
const source = asBytes(input);
|
|
1475
|
-
const
|
|
1476
|
-
const parsed = parsePayloadEnvelope(envelope);
|
|
1477
|
-
return encodePreparedBytes(envelope, {
|
|
1823
|
+
const packed = await packSignedPayloadEnvelope(source, options);
|
|
1824
|
+
const parsed = parsePayloadEnvelope(packed.envelope);
|
|
1825
|
+
return encodePreparedBytes(packed.envelope, {
|
|
1478
1826
|
...options,
|
|
1479
1827
|
text: Boolean(options.text),
|
|
1480
1828
|
extended: true,
|
|
1481
1829
|
signed: true,
|
|
1482
1830
|
sourcePayloadBytes: source.length,
|
|
1483
1831
|
compressionMetadata: parsed.compression,
|
|
1832
|
+
compressionLevelMetadata: packed.compressionLevel,
|
|
1833
|
+
compressionStrategyMetadata: packed.compressionStrategy,
|
|
1834
|
+
smartCompressionMetadata: packed.smartCompression,
|
|
1484
1835
|
signingKeyId: parsed.keyId,
|
|
1485
1836
|
hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
|
|
1486
1837
|
});
|
|
@@ -1505,30 +1856,47 @@ export async function encodeSecureText(text, options = {}) {
|
|
|
1505
1856
|
export async function encodeSecureBytes(input, options = {}) {
|
|
1506
1857
|
const sourcePayload = asBytes(input);
|
|
1507
1858
|
const security = options.security ?? {};
|
|
1859
|
+
const secureOuterFixedBytes = estimateSecureEnvelopeOverhead(security);
|
|
1508
1860
|
let protectedPayload = sourcePayload;
|
|
1509
1861
|
let extended = false;
|
|
1510
1862
|
let signed = false;
|
|
1511
1863
|
let compression = "none";
|
|
1864
|
+
let compressionLevel = null;
|
|
1865
|
+
let compressionStrategy = null;
|
|
1866
|
+
let smartCompression = null;
|
|
1512
1867
|
let signingKeyId = null;
|
|
1513
1868
|
let hasEmbeddedPublicKey = false;
|
|
1514
1869
|
|
|
1515
1870
|
if (options.signing) {
|
|
1516
|
-
|
|
1871
|
+
const packed = await packSignedPayloadEnvelope(sourcePayload, {
|
|
1517
1872
|
...options,
|
|
1518
1873
|
...options.signing,
|
|
1519
|
-
compression: options.compression ?? "auto"
|
|
1874
|
+
compression: options.compression ?? "auto",
|
|
1875
|
+
outerFixedBytes: secureOuterFixedBytes,
|
|
1876
|
+
versionOptions: options
|
|
1520
1877
|
});
|
|
1878
|
+
protectedPayload = packed.envelope;
|
|
1521
1879
|
const parsed = parsePayloadEnvelope(protectedPayload);
|
|
1522
1880
|
extended = true;
|
|
1523
1881
|
signed = true;
|
|
1524
1882
|
compression = parsed.compression;
|
|
1883
|
+
compressionLevel = packed.compressionLevel;
|
|
1884
|
+
compressionStrategy = packed.compressionStrategy;
|
|
1885
|
+
smartCompression = packed.smartCompression;
|
|
1525
1886
|
signingKeyId = parsed.keyId;
|
|
1526
1887
|
hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
|
|
1527
1888
|
} else {
|
|
1528
|
-
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none"
|
|
1889
|
+
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none", {
|
|
1890
|
+
...options,
|
|
1891
|
+
outerFixedBytes: secureOuterFixedBytes,
|
|
1892
|
+
versionOptions: options
|
|
1893
|
+
});
|
|
1529
1894
|
protectedPayload = prepared.payload;
|
|
1530
1895
|
extended = prepared.extended;
|
|
1531
1896
|
compression = prepared.compression;
|
|
1897
|
+
compressionLevel = prepared.compressionLevel;
|
|
1898
|
+
compressionStrategy = prepared.compressionStrategy;
|
|
1899
|
+
smartCompression = prepared.smartCompression;
|
|
1532
1900
|
}
|
|
1533
1901
|
|
|
1534
1902
|
const encrypted = await encryptSecurePayload(protectedPayload, security);
|
|
@@ -1540,6 +1908,9 @@ export async function encodeSecureBytes(input, options = {}) {
|
|
|
1540
1908
|
signed,
|
|
1541
1909
|
sourcePayloadBytes: sourcePayload.length,
|
|
1542
1910
|
compressionMetadata: compression,
|
|
1911
|
+
compressionLevelMetadata: compressionLevel,
|
|
1912
|
+
compressionStrategyMetadata: compressionStrategy,
|
|
1913
|
+
smartCompressionMetadata: smartCompression,
|
|
1543
1914
|
signingKeyId,
|
|
1544
1915
|
hasEmbeddedPublicKey,
|
|
1545
1916
|
securityMetadata: encrypted.metadata
|
|
@@ -1618,8 +1989,8 @@ function alignmentPatternMismatchRatio(matrix, pattern) {
|
|
|
1618
1989
|
return mismatches / total;
|
|
1619
1990
|
}
|
|
1620
1991
|
|
|
1621
|
-
function alignmentGridMismatchRatio(matrix, version) {
|
|
1622
|
-
const patterns = alignmentPatternCentersForVersion(version);
|
|
1992
|
+
function alignmentGridMismatchRatio(matrix, version, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
|
|
1993
|
+
const patterns = alignmentPatternCentersForVersion(version, { profile: alignmentProfile });
|
|
1623
1994
|
let weightedMismatch = 0;
|
|
1624
1995
|
let totalWeight = 0;
|
|
1625
1996
|
for (const pattern of patterns) {
|
|
@@ -1631,7 +2002,7 @@ function alignmentGridMismatchRatio(matrix, version) {
|
|
|
1631
2002
|
return totalWeight ? weightedMismatch / totalWeight : 0;
|
|
1632
2003
|
}
|
|
1633
2004
|
|
|
1634
|
-
function validateStructure(matrix, tolerance = 0) {
|
|
2005
|
+
function validateStructure(matrix, tolerance = 0, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
|
|
1635
2006
|
const size = matrix.length;
|
|
1636
2007
|
if (size < 21 || matrix.some((row) => row.length !== size)) return false;
|
|
1637
2008
|
const version = versionFromSize(size);
|
|
@@ -1644,11 +2015,11 @@ function validateStructure(matrix, tolerance = 0) {
|
|
|
1644
2015
|
if (finderRatios.some((ratio) => ratio > tolerance)) return false;
|
|
1645
2016
|
|
|
1646
2017
|
const alignmentTolerance = Math.max(tolerance, 0.12);
|
|
1647
|
-
const primary = alignmentPatternCentersForVersion(version).at(-1);
|
|
2018
|
+
const primary = alignmentPatternCentersForVersion(version, { profile: alignmentProfile }).at(-1);
|
|
1648
2019
|
if (primary && alignmentPatternMismatchRatio(matrix, primary) > alignmentTolerance) {
|
|
1649
2020
|
return false;
|
|
1650
2021
|
}
|
|
1651
|
-
if (alignmentGridMismatchRatio(matrix, version) > alignmentTolerance) return false;
|
|
2022
|
+
if (alignmentGridMismatchRatio(matrix, version, alignmentProfile) > alignmentTolerance) return false;
|
|
1652
2023
|
return true;
|
|
1653
2024
|
}
|
|
1654
2025
|
|
|
@@ -1836,13 +2207,13 @@ function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConf
|
|
|
1836
2207
|
};
|
|
1837
2208
|
}
|
|
1838
2209
|
|
|
1839
|
-
function
|
|
2210
|
+
function decodeCanonicalWithProfile(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
|
|
1840
2211
|
const size = matrix.length;
|
|
1841
2212
|
const version = versionFromSize(size);
|
|
1842
2213
|
if (!version) throw new Error(`Unsupported matrix size ${size}.`);
|
|
1843
|
-
if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
2214
|
+
if (!validateStructure(matrix, tolerance, alignmentProfile)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
1844
2215
|
|
|
1845
|
-
const layout = createLayout(version);
|
|
2216
|
+
const layout = createLayout(version, { alignmentProfile });
|
|
1846
2217
|
const errors = [];
|
|
1847
2218
|
const hintedEncoding = options.cellEncodingHint != null
|
|
1848
2219
|
? normalizeCellEncoding(options.cellEncodingHint)
|
|
@@ -1892,6 +2263,10 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1892
2263
|
);
|
|
1893
2264
|
const header = headerDecoded.header;
|
|
1894
2265
|
const flags = headerDecoded.flags;
|
|
2266
|
+
const decodedFormatVersion = headerDecoded.formatVersion ?? FORMAT_VERSION;
|
|
2267
|
+
if (version >= 7 && alignmentProfileForFormat(decodedFormatVersion) !== alignmentProfile) {
|
|
2268
|
+
throw new Error(`Format v${decodedFormatVersion} uses a different alignment profile.`);
|
|
2269
|
+
}
|
|
1895
2270
|
const declaredCellEncoding = cellEncodingFromFlags(flags);
|
|
1896
2271
|
if (declaredCellEncoding !== cellEncoding) {
|
|
1897
2272
|
throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
|
|
@@ -1929,10 +2304,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1929
2304
|
return {
|
|
1930
2305
|
ok: true,
|
|
1931
2306
|
format: "QuadQR",
|
|
1932
|
-
formatVersion:
|
|
2307
|
+
formatVersion: decodedFormatVersion,
|
|
1933
2308
|
version,
|
|
1934
2309
|
size,
|
|
1935
2310
|
alignmentPatterns: layout.alignments.length,
|
|
2311
|
+
alignmentProfile,
|
|
1936
2312
|
maskId,
|
|
1937
2313
|
rotation,
|
|
1938
2314
|
flags,
|
|
@@ -1980,10 +2356,93 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1980
2356
|
throw new Error(`QuadQR decode failed. ${errors.join(" | ")}`);
|
|
1981
2357
|
}
|
|
1982
2358
|
|
|
2359
|
+
function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}) {
|
|
2360
|
+
const hinted = options.alignmentProfileHint;
|
|
2361
|
+
const profiles = hinted
|
|
2362
|
+
? [hinted]
|
|
2363
|
+
: [ALIGNMENT_PROFILE_STANDARD_5, ALIGNMENT_PROFILE_LEGACY_3];
|
|
2364
|
+
const errors = [];
|
|
2365
|
+
for (const alignmentProfile of profiles) {
|
|
2366
|
+
try {
|
|
2367
|
+
return decodeCanonicalWithProfile(matrix, rotation, tolerance, confidenceMatrix, options, alignmentProfile);
|
|
2368
|
+
} catch (error) {
|
|
2369
|
+
errors.push(`${alignmentProfile}: ${error.message}`);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
throw new Error(`QuadQR decode failed for all alignment profiles. ${errors.join(" | ")}`);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
function trySoftMatrixDecode(matrix, alternatives, confidenceMatrix, degrees, tolerance, options = {}) {
|
|
2376
|
+
if (!alternatives || !confidenceMatrix || options.softDecoding === false) return null;
|
|
2377
|
+
const version = versionFromSize(matrix.length);
|
|
2378
|
+
if (!version) return null;
|
|
2379
|
+
const layout = createLayout(version);
|
|
2380
|
+
const legacyLayout = createLayout(version, { alignmentProfile: ALIGNMENT_PROFILE_LEGACY_3 });
|
|
2381
|
+
const softPositions = [];
|
|
2382
|
+
const seenSoftPositions = new Set();
|
|
2383
|
+
for (const [row, col] of [...layout.dataPositions, ...legacyLayout.dataPositions]) {
|
|
2384
|
+
const key = `${row},${col}`;
|
|
2385
|
+
if (seenSoftPositions.has(key)) continue;
|
|
2386
|
+
seenSoftPositions.add(key);
|
|
2387
|
+
softPositions.push([row, col]);
|
|
2388
|
+
}
|
|
2389
|
+
const threshold = clampNumber(options.softDecodeConfidence ?? 0.72, 0.05, 0.95);
|
|
2390
|
+
const maxCells = Math.max(2, Math.min(16, Math.round(options.softDecodeMaxCells ?? 10)));
|
|
2391
|
+
const pairCells = Math.max(2, Math.min(maxCells, Math.round(options.softDecodePairCells ?? 6)));
|
|
2392
|
+
const ranked = [];
|
|
2393
|
+
|
|
2394
|
+
for (const [row, col] of softPositions) {
|
|
2395
|
+
const alternative = alternatives[row]?.[col];
|
|
2396
|
+
const confidence = confidenceMatrix[row]?.[col] ?? 1;
|
|
2397
|
+
if (!Number.isInteger(alternative) || alternative === matrix[row][col] || confidence > threshold) continue;
|
|
2398
|
+
ranked.push({ row, col, alternative, confidence });
|
|
2399
|
+
}
|
|
2400
|
+
ranked.sort((a, b) => (a.confidence - b.confidence) || (a.row - b.row) || (a.col - b.col));
|
|
2401
|
+
const candidates = ranked.slice(0, maxCells);
|
|
2402
|
+
let attempts = 0;
|
|
2403
|
+
|
|
2404
|
+
const attempt = (changes) => {
|
|
2405
|
+
const trial = cloneMatrix(matrix);
|
|
2406
|
+
for (const change of changes) trial[change.row][change.col] = change.alternative;
|
|
2407
|
+
attempts++;
|
|
2408
|
+
try {
|
|
2409
|
+
const decoded = decodeCanonical(trial, degrees, tolerance, confidenceMatrix, {
|
|
2410
|
+
...options,
|
|
2411
|
+
softDecoding: false
|
|
2412
|
+
});
|
|
2413
|
+
return {
|
|
2414
|
+
...decoded,
|
|
2415
|
+
spectrumEccVersion: 2,
|
|
2416
|
+
softDecoded: true,
|
|
2417
|
+
softSubstitutions: changes.length,
|
|
2418
|
+
softDecodeAttempts: attempts,
|
|
2419
|
+
softDecodeCellsConsidered: candidates.length
|
|
2420
|
+
};
|
|
2421
|
+
} catch {
|
|
2422
|
+
return null;
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
2425
|
+
|
|
2426
|
+
for (const candidate of candidates) {
|
|
2427
|
+
const decoded = attempt([candidate]);
|
|
2428
|
+
if (decoded) return decoded;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
const pairCandidates = candidates.slice(0, pairCells);
|
|
2432
|
+
for (let i = 0; i < pairCandidates.length; i++) {
|
|
2433
|
+
for (let j = i + 1; j < pairCandidates.length; j++) {
|
|
2434
|
+
const decoded = attempt([pairCandidates[i], pairCandidates[j]]);
|
|
2435
|
+
if (decoded) return decoded;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
return null;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
1983
2441
|
export function decodeMatrix(inputMatrix, options = {}) {
|
|
1984
2442
|
assert(Array.isArray(inputMatrix) && inputMatrix.length > 0, "Matrix is required.");
|
|
1985
2443
|
let matrix = cloneMatrix(inputMatrix);
|
|
1986
2444
|
let confidenceMatrix = options.cellConfidence ? cloneMatrix(options.cellConfidence) : null;
|
|
2445
|
+
let alternativeMatrix = options.cellAlternatives ? cloneMatrix(options.cellAlternatives) : null;
|
|
1987
2446
|
const errors = [];
|
|
1988
2447
|
const tolerance = options.structureTolerance ?? 0;
|
|
1989
2448
|
|
|
@@ -1993,16 +2452,38 @@ export function decodeMatrix(inputMatrix, options = {}) {
|
|
|
1993
2452
|
"cellConfidence must be a square matrix matching the QuadQR matrix."
|
|
1994
2453
|
);
|
|
1995
2454
|
}
|
|
2455
|
+
if (alternativeMatrix) {
|
|
2456
|
+
assert(
|
|
2457
|
+
alternativeMatrix.length === matrix.length && alternativeMatrix.every((row) => row.length === matrix.length),
|
|
2458
|
+
"cellAlternatives must be a square matrix matching the QuadQR matrix."
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
1996
2461
|
|
|
1997
2462
|
for (let rotationIndex = 0; rotationIndex < 4; rotationIndex++) {
|
|
1998
2463
|
const degrees = rotationIndex * 90;
|
|
1999
2464
|
try {
|
|
2000
|
-
|
|
2465
|
+
const decoded = decodeCanonical(matrix, degrees, tolerance, confidenceMatrix, options);
|
|
2466
|
+
return {
|
|
2467
|
+
spectrumEccVersion: 2,
|
|
2468
|
+
softDecoded: false,
|
|
2469
|
+
softSubstitutions: 0,
|
|
2470
|
+
...decoded
|
|
2471
|
+
};
|
|
2001
2472
|
} catch (error) {
|
|
2002
2473
|
errors.push(`${degrees}°: ${error.message}`);
|
|
2474
|
+
const softDecoded = trySoftMatrixDecode(
|
|
2475
|
+
matrix,
|
|
2476
|
+
alternativeMatrix,
|
|
2477
|
+
confidenceMatrix,
|
|
2478
|
+
degrees,
|
|
2479
|
+
tolerance,
|
|
2480
|
+
options
|
|
2481
|
+
);
|
|
2482
|
+
if (softDecoded) return softDecoded;
|
|
2003
2483
|
}
|
|
2004
2484
|
matrix = rotate90(matrix);
|
|
2005
2485
|
if (confidenceMatrix) confidenceMatrix = rotate90(confidenceMatrix);
|
|
2486
|
+
if (alternativeMatrix) alternativeMatrix = rotate90(alternativeMatrix);
|
|
2006
2487
|
}
|
|
2007
2488
|
|
|
2008
2489
|
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
@@ -2944,11 +3425,96 @@ function makeWhiteBalanceTransform(observed) {
|
|
|
2944
3425
|
});
|
|
2945
3426
|
}
|
|
2946
3427
|
|
|
3428
|
+
function solveLinearSystem(matrix, vector) {
|
|
3429
|
+
const n = vector.length;
|
|
3430
|
+
const a = matrix.map((row, index) => row.slice().concat(vector[index]));
|
|
3431
|
+
for (let col = 0; col < n; col++) {
|
|
3432
|
+
let pivot = col;
|
|
3433
|
+
for (let row = col + 1; row < n; row++) {
|
|
3434
|
+
if (Math.abs(a[row][col]) > Math.abs(a[pivot][col])) pivot = row;
|
|
3435
|
+
}
|
|
3436
|
+
if (Math.abs(a[pivot][col]) < 1e-9) return null;
|
|
3437
|
+
[a[col], a[pivot]] = [a[pivot], a[col]];
|
|
3438
|
+
const divisor = a[col][col];
|
|
3439
|
+
for (let j = col; j <= n; j++) a[col][j] /= divisor;
|
|
3440
|
+
for (let row = 0; row < n; row++) {
|
|
3441
|
+
if (row === col) continue;
|
|
3442
|
+
const factor = a[row][col];
|
|
3443
|
+
if (Math.abs(factor) < 1e-12) continue;
|
|
3444
|
+
for (let j = col; j <= n; j++) a[row][j] -= factor * a[col][j];
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
return a.map((row) => row[n]);
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
function makeAffineCalibrationTransform(observed) {
|
|
3451
|
+
const ideal = {
|
|
3452
|
+
black: { r: 0, g: 0, b: 0 },
|
|
3453
|
+
white: { r: 255, g: 255, b: 255 },
|
|
3454
|
+
red: { r: 255, g: 0, b: 0 },
|
|
3455
|
+
green: { r: 0, g: 255, b: 0 },
|
|
3456
|
+
blue: { r: 0, g: 0, b: 255 }
|
|
3457
|
+
};
|
|
3458
|
+
const keys = ["black", "white", "red", "green", "blue"];
|
|
3459
|
+
const rows = keys.map((key) => {
|
|
3460
|
+
const rgb = observed[key];
|
|
3461
|
+
return [rgb.r, rgb.g, rgb.b, 1];
|
|
3462
|
+
});
|
|
3463
|
+
const normal = Array.from({ length: 4 }, () => Array(4).fill(0));
|
|
3464
|
+
for (const row of rows) {
|
|
3465
|
+
for (let i = 0; i < 4; i++) {
|
|
3466
|
+
for (let j = 0; j < 4; j++) normal[i][j] += row[i] * row[j];
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
// A tiny ridge term keeps the transform stable if the photographed palette
|
|
3470
|
+
// becomes nearly singular under severe clipping or monochromatic lighting.
|
|
3471
|
+
const ridge = 1e-4;
|
|
3472
|
+
for (let i = 0; i < 4; i++) normal[i][i] += ridge;
|
|
3473
|
+
|
|
3474
|
+
const coefficients = {};
|
|
3475
|
+
for (const channel of ["r", "g", "b"]) {
|
|
3476
|
+
const rhs = Array(4).fill(0);
|
|
3477
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
3478
|
+
const x = rows[rowIndex];
|
|
3479
|
+
const y = ideal[keys[rowIndex]][channel];
|
|
3480
|
+
for (let i = 0; i < 4; i++) rhs[i] += x[i] * y;
|
|
3481
|
+
}
|
|
3482
|
+
coefficients[channel] = solveLinearSystem(normal, rhs);
|
|
3483
|
+
if (!coefficients[channel]) return null;
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
const transform = (rgb) => {
|
|
3487
|
+
const x = [rgb.r, rgb.g, rgb.b, 1];
|
|
3488
|
+
const mapChannel = (channel) => coefficients[channel].reduce((sum, value, index) => sum + value * x[index], 0);
|
|
3489
|
+
return {
|
|
3490
|
+
r: clampNumber(mapChannel("r"), -96, 384),
|
|
3491
|
+
g: clampNumber(mapChannel("g"), -96, 384),
|
|
3492
|
+
b: clampNumber(mapChannel("b"), -96, 384)
|
|
3493
|
+
};
|
|
3494
|
+
};
|
|
3495
|
+
|
|
3496
|
+
let errorSq = 0;
|
|
3497
|
+
for (const key of keys) {
|
|
3498
|
+
const mapped = transform(observed[key]);
|
|
3499
|
+
const target = ideal[key];
|
|
3500
|
+
errorSq += colorDistanceSq(mapped, target);
|
|
3501
|
+
}
|
|
3502
|
+
return { transform, ideal, rmsError: Math.sqrt(errorSq / (keys.length * 3)) };
|
|
3503
|
+
}
|
|
3504
|
+
|
|
2947
3505
|
function classifierFromPaletteRgb(observed, mode = "raw") {
|
|
2948
|
-
const
|
|
3506
|
+
const affine = mode === "affine" ? makeAffineCalibrationTransform(observed) : null;
|
|
3507
|
+
const transform = affine?.transform ?? (mode === "balanced" || mode === "hue"
|
|
2949
3508
|
? makeWhiteBalanceTransform(observed)
|
|
2950
|
-
: identityColorTransform;
|
|
2951
|
-
const
|
|
3509
|
+
: identityColorTransform);
|
|
3510
|
+
const idealEntries = affine ? [
|
|
3511
|
+
{ cell: CELL.BLACK, rgb: affine.ideal.black },
|
|
3512
|
+
{ cell: CELL.WHITE, rgb: affine.ideal.white },
|
|
3513
|
+
{ cell: CELL.RED, rgb: affine.ideal.red },
|
|
3514
|
+
{ cell: CELL.GREEN, rgb: affine.ideal.green },
|
|
3515
|
+
{ cell: CELL.BLUE, rgb: affine.ideal.blue }
|
|
3516
|
+
] : null;
|
|
3517
|
+
const entries = idealEntries ?? [
|
|
2952
3518
|
{ cell: CELL.BLACK, rgb: transform(observed.black) },
|
|
2953
3519
|
{ cell: CELL.WHITE, rgb: transform(observed.white) },
|
|
2954
3520
|
{ cell: CELL.RED, rgb: transform(observed.red) },
|
|
@@ -2962,6 +3528,8 @@ function classifierFromPaletteRgb(observed, mode = "raw") {
|
|
|
2962
3528
|
entries,
|
|
2963
3529
|
transform,
|
|
2964
3530
|
mode,
|
|
3531
|
+
calibrationModel: affine ? "affine-3x4" : (mode === "balanced" || mode === "hue" ? "black-white-balance" : "observed-palette"),
|
|
3532
|
+
calibrationError: affine?.rmsError ?? null,
|
|
2965
3533
|
dataMode: mode === "hue" ? "hue" : "distance",
|
|
2966
3534
|
whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
|
|
2967
3535
|
minimumColorChroma
|
|
@@ -2975,27 +3543,30 @@ function colorDistanceSq(a, b) {
|
|
|
2975
3543
|
return dr * dr + dg * dg + db * db;
|
|
2976
3544
|
}
|
|
2977
3545
|
|
|
2978
|
-
function
|
|
3546
|
+
function rankRgbCandidates(rgb, classifier) {
|
|
2979
3547
|
const transformed = classifier.transform(rgb);
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
bestDistanceSq = distanceSq;
|
|
2989
|
-
best = candidate;
|
|
2990
|
-
} else if (distanceSq < secondDistanceSq) {
|
|
2991
|
-
secondDistanceSq = distanceSq;
|
|
2992
|
-
}
|
|
2993
|
-
}
|
|
3548
|
+
return classifier.entries
|
|
3549
|
+
.map((candidate) => ({
|
|
3550
|
+
cell: candidate.cell,
|
|
3551
|
+
rgb: candidate.rgb,
|
|
3552
|
+
distance: Math.sqrt(colorDistanceSq(transformed, candidate.rgb))
|
|
3553
|
+
}))
|
|
3554
|
+
.sort((a, b) => (a.distance - b.distance) || (a.cell - b.cell));
|
|
3555
|
+
}
|
|
2994
3556
|
|
|
2995
|
-
|
|
2996
|
-
const
|
|
2997
|
-
const
|
|
2998
|
-
|
|
3557
|
+
function classifyRgb(rgb, classifier) {
|
|
3558
|
+
const ranked = rankRgbCandidates(rgb, classifier);
|
|
3559
|
+
const best = ranked[0];
|
|
3560
|
+
const second = ranked[1] ?? { cell: best.cell, distance: best.distance + 1 };
|
|
3561
|
+
const confidence = Math.max(0, Math.min(1, (second.distance - best.distance) / Math.max(second.distance, 1e-6)));
|
|
3562
|
+
return {
|
|
3563
|
+
cell: best.cell,
|
|
3564
|
+
distance: best.distance,
|
|
3565
|
+
confidence,
|
|
3566
|
+
alternativeCell: second.cell,
|
|
3567
|
+
alternativeDistance: second.distance,
|
|
3568
|
+
ambiguity: 1 - confidence
|
|
3569
|
+
};
|
|
2999
3570
|
}
|
|
3000
3571
|
|
|
3001
3572
|
|
|
@@ -3031,6 +3602,7 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3031
3602
|
const size = rgbGrid.length;
|
|
3032
3603
|
const matrix = make2D(size, CELL.WHITE);
|
|
3033
3604
|
const confidence = make2D(size, 1);
|
|
3605
|
+
const alternatives = make2D(size, null);
|
|
3034
3606
|
const dataClassifier = {
|
|
3035
3607
|
...classifier,
|
|
3036
3608
|
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
@@ -3050,8 +3622,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3050
3622
|
const classified = isDataCell && classifier.dataMode === "hue"
|
|
3051
3623
|
? classifyDataHue(rgbGrid[r][c], classifier)
|
|
3052
3624
|
: classifyRgb(rgbGrid[r][c], candidates);
|
|
3625
|
+
const ranked = rankRgbCandidates(rgbGrid[r][c], candidates);
|
|
3626
|
+
const alternate = ranked.find((candidate) => candidate.cell !== classified.cell);
|
|
3053
3627
|
matrix[r][c] = classified.cell;
|
|
3054
3628
|
confidence[r][c] = classified.confidence;
|
|
3629
|
+
alternatives[r][c] = alternate?.cell ?? null;
|
|
3055
3630
|
distanceSum += classified.distance;
|
|
3056
3631
|
confidenceSum += classified.confidence;
|
|
3057
3632
|
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
@@ -3061,6 +3636,7 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
3061
3636
|
return {
|
|
3062
3637
|
matrix,
|
|
3063
3638
|
confidence,
|
|
3639
|
+
alternatives,
|
|
3064
3640
|
averageColorDistance: distanceSum / (size * size),
|
|
3065
3641
|
averageCellConfidence: confidenceSum / (size * size),
|
|
3066
3642
|
minimumCellConfidence: minimumConfidence,
|
|
@@ -3072,6 +3648,7 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
|
|
|
3072
3648
|
const size = rgbGrid.length;
|
|
3073
3649
|
const matrix = make2D(size, CELL.WHITE);
|
|
3074
3650
|
const confidence = make2D(size, 1);
|
|
3651
|
+
const alternatives = make2D(size, null);
|
|
3075
3652
|
const dataClassifier = {
|
|
3076
3653
|
...classifier,
|
|
3077
3654
|
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
@@ -3088,6 +3665,7 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
|
|
|
3088
3665
|
const classified = classifyRgb(rgbGrid[r][c], classifier);
|
|
3089
3666
|
matrix[r][c] = classified.cell;
|
|
3090
3667
|
confidence[r][c] = classified.confidence;
|
|
3668
|
+
alternatives[r][c] = classified.alternativeCell ?? null;
|
|
3091
3669
|
distanceSum += classified.distance;
|
|
3092
3670
|
confidenceSum += classified.confidence;
|
|
3093
3671
|
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
@@ -3102,19 +3680,35 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
|
|
|
3102
3680
|
: classifyRgb(rgb, dataClassifier);
|
|
3103
3681
|
const first = classifyData(samples.first);
|
|
3104
3682
|
const second = classifyData(samples.second);
|
|
3105
|
-
const
|
|
3683
|
+
const firstRanked = rankRgbCandidates(samples.first, dataClassifier);
|
|
3684
|
+
const secondRanked = rankRgbCandidates(samples.second, dataClassifier);
|
|
3685
|
+
const firstAlt = firstRanked.find((candidate) => candidate.cell !== first.cell)?.cell ?? first.cell;
|
|
3686
|
+
const secondAlt = secondRanked.find((candidate) => candidate.cell !== second.cell)?.cell ?? second.cell;
|
|
3687
|
+
const firstSpread = Number(samples.firstSpread ?? 0);
|
|
3688
|
+
const secondSpread = Number(samples.secondSpread ?? 0);
|
|
3689
|
+
const stability = clampNumber(1 - Math.max(firstSpread, secondSpread) / 110, 0.35, 1);
|
|
3690
|
+
const cellConfidence = Math.min(first.confidence, second.confidence) * (0.68 + stability * 0.32);
|
|
3106
3691
|
matrix[r][c] = packTriangleCell(first.cell, second.cell);
|
|
3107
|
-
|
|
3692
|
+
// Prefer changing the region with the weaker classification. If both are
|
|
3693
|
+
// similarly uncertain, changing the more spatially unstable region first
|
|
3694
|
+
// gives Spectrum ECC 2.0 a better second hypothesis.
|
|
3695
|
+
const firstRisk = (1 - first.confidence) + firstSpread / 255;
|
|
3696
|
+
const secondRisk = (1 - second.confidence) + secondSpread / 255;
|
|
3697
|
+
alternatives[r][c] = firstRisk >= secondRisk
|
|
3698
|
+
? packTriangleCell(firstAlt, second.cell)
|
|
3699
|
+
: packTriangleCell(first.cell, secondAlt);
|
|
3700
|
+
confidence[r][c] = clampNumber(cellConfidence, 0, 1);
|
|
3108
3701
|
distanceSum += (first.distance + second.distance) / 2;
|
|
3109
|
-
confidenceSum +=
|
|
3110
|
-
minimumConfidence = Math.min(minimumConfidence,
|
|
3111
|
-
if (
|
|
3702
|
+
confidenceSum += confidence[r][c];
|
|
3703
|
+
minimumConfidence = Math.min(minimumConfidence, confidence[r][c]);
|
|
3704
|
+
if (confidence[r][c] < 0.4) lowConfidenceCells++;
|
|
3112
3705
|
}
|
|
3113
3706
|
}
|
|
3114
3707
|
|
|
3115
3708
|
return {
|
|
3116
3709
|
matrix,
|
|
3117
3710
|
confidence,
|
|
3711
|
+
alternatives,
|
|
3118
3712
|
averageColorDistance: distanceSum / (size * size),
|
|
3119
3713
|
averageCellConfidence: confidenceSum / (size * size),
|
|
3120
3714
|
minimumCellConfidence: minimumConfidence,
|
|
@@ -3144,32 +3738,37 @@ function paletteClassifierAttempts(observedPalette) {
|
|
|
3144
3738
|
return [
|
|
3145
3739
|
{ classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
|
|
3146
3740
|
{ classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
|
|
3741
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "affine"), colorNormalization: "affine-calibrated" },
|
|
3147
3742
|
{ classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
|
|
3148
3743
|
];
|
|
3149
3744
|
}
|
|
3150
3745
|
|
|
3151
3746
|
function tryPerspectiveScan(imageData, options) {
|
|
3152
|
-
const geometryCandidates =
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3747
|
+
const geometryCandidates = Array.isArray(options._geometryCandidatesOverride) && options._geometryCandidatesOverride.length
|
|
3748
|
+
? options._geometryCandidatesOverride
|
|
3749
|
+
: detectCodeGeometry(imageData, {
|
|
3750
|
+
minVersion: options.minVersion ?? MIN_VERSION,
|
|
3751
|
+
maxVersion: options.maxVersion ?? MAX_VERSION,
|
|
3752
|
+
maxCandidates: options.maxGeometryCandidates ?? 8,
|
|
3753
|
+
finderRecovery: options.finderRecovery,
|
|
3754
|
+
finderAutoColorBlackClip: options.finderAutoColorBlackClip,
|
|
3755
|
+
finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
|
|
3756
|
+
finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
|
|
3757
|
+
finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
|
|
3758
|
+
finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
|
|
3759
|
+
finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
|
|
3760
|
+
finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
|
|
3761
|
+
preciseAlignment: options.preciseAlignment,
|
|
3762
|
+
diagnostics: options._visionDiagnostics,
|
|
3763
|
+
diagnosticLabel: options._diagnosticLabel ?? "normal"
|
|
3764
|
+
});
|
|
3168
3765
|
if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
|
|
3169
3766
|
const results = [];
|
|
3170
3767
|
|
|
3171
3768
|
for (const geometry of geometryCandidates) {
|
|
3172
|
-
const layout = createLayout(geometry.version
|
|
3769
|
+
const layout = createLayout(geometry.version, {
|
|
3770
|
+
alignmentProfile: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5
|
|
3771
|
+
});
|
|
3173
3772
|
const sampleProfiles = [{
|
|
3174
3773
|
sampleMode: options.sampleMode ?? "cross",
|
|
3175
3774
|
sampleRadius: options.sampleRadius ?? 0.16,
|
|
@@ -3206,7 +3805,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3206
3805
|
continue;
|
|
3207
3806
|
}
|
|
3208
3807
|
|
|
3209
|
-
const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
|
|
3808
|
+
const tryAttempt = (attempt, rgbGrid, metadata = {}, allowSoftDecoding = options.softDecoding !== false) => {
|
|
3210
3809
|
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
3211
3810
|
const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
|
|
3212
3811
|
const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
|
|
@@ -3216,6 +3815,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3216
3815
|
version: geometry.version,
|
|
3217
3816
|
matrix: classified.matrix,
|
|
3218
3817
|
confidence: classified.confidence,
|
|
3818
|
+
alternatives: classified.alternatives,
|
|
3219
3819
|
geometry,
|
|
3220
3820
|
observedPalette: activeObservedPalette,
|
|
3221
3821
|
samplingMode: activeSamplingMode,
|
|
@@ -3229,6 +3829,10 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3229
3829
|
const decoded = decodeMatrix(classified.matrix, {
|
|
3230
3830
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3231
3831
|
cellConfidence: classified.confidence,
|
|
3832
|
+
cellAlternatives: classified.alternatives,
|
|
3833
|
+
cellEncodingHint: CELL_ENCODINGS.RGBW,
|
|
3834
|
+
alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
|
|
3835
|
+
softDecoding: allowSoftDecoding,
|
|
3232
3836
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3233
3837
|
});
|
|
3234
3838
|
if (decoded.version !== geometry.version) return false;
|
|
@@ -3257,7 +3861,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3257
3861
|
}
|
|
3258
3862
|
};
|
|
3259
3863
|
|
|
3260
|
-
const tryTriangleAttempt = (attempt, metadata = {}) => {
|
|
3864
|
+
const tryTriangleAttempt = (attempt, metadata = {}, allowSoftDecoding = options.softDecoding !== false) => {
|
|
3261
3865
|
if (!triangleSampled?.triangleGrid) return false;
|
|
3262
3866
|
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
3263
3867
|
const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
|
|
@@ -3273,6 +3877,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3273
3877
|
version: geometry.version,
|
|
3274
3878
|
matrix: classified.matrix,
|
|
3275
3879
|
confidence: classified.confidence,
|
|
3880
|
+
alternatives: classified.alternatives,
|
|
3276
3881
|
geometry,
|
|
3277
3882
|
observedPalette: activeObservedPalette,
|
|
3278
3883
|
samplingMode: activeSamplingMode,
|
|
@@ -3287,7 +3892,10 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3287
3892
|
const decoded = decodeMatrix(classified.matrix, {
|
|
3288
3893
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3289
3894
|
cellConfidence: classified.confidence,
|
|
3895
|
+
cellAlternatives: classified.alternatives,
|
|
3290
3896
|
cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
|
|
3897
|
+
alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
|
|
3898
|
+
softDecoding: allowSoftDecoding,
|
|
3291
3899
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3292
3900
|
});
|
|
3293
3901
|
if (decoded.version !== geometry.version) return false;
|
|
@@ -3316,29 +3924,46 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3316
3924
|
// Fast path: preserve the original observed-RGB classifier first, then
|
|
3317
3925
|
// try per-channel white balancing. Most clean frames stop here without
|
|
3318
3926
|
// paying for the more expensive spatial normalization fallback.
|
|
3319
|
-
|
|
3320
|
-
|
|
3927
|
+
const basePaletteAttempts = paletteClassifierAttempts(observedPalette);
|
|
3928
|
+
for (const attempt of basePaletteAttempts) {
|
|
3929
|
+
if (tryAttempt(attempt, sampled.rgbGrid, {}, false) || tryTriangleAttempt(attempt, {}, false)) {
|
|
3321
3930
|
geometryDecoded = true;
|
|
3322
3931
|
break;
|
|
3323
3932
|
}
|
|
3324
3933
|
}
|
|
3934
|
+
// Spectrum ECC soft decoding is intentionally deferred until every cheap
|
|
3935
|
+
// hard classifier has had a chance. This preserves the exact recovery
|
|
3936
|
+
// candidates while avoiding expensive second-hypothesis RS searches when
|
|
3937
|
+
// a later normal color model can decode the frame immediately.
|
|
3938
|
+
if (!geometryDecoded && options.softDecoding !== false) {
|
|
3939
|
+
for (const attempt of basePaletteAttempts) {
|
|
3940
|
+
if (tryAttempt(attempt, sampled.rgbGrid, {}, true) || tryTriangleAttempt(attempt, {}, true)) {
|
|
3941
|
+
geometryDecoded = true;
|
|
3942
|
+
break;
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3325
3946
|
|
|
3326
3947
|
if (!geometryDecoded && options.spatialColorNormalization !== false) {
|
|
3327
3948
|
try {
|
|
3328
3949
|
const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
|
|
3329
3950
|
const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
|
|
3330
|
-
|
|
3951
|
+
const spatialAttempt = {
|
|
3331
3952
|
classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
|
|
3332
3953
|
colorNormalization: "spatial-white-balanced"
|
|
3333
|
-
}
|
|
3954
|
+
};
|
|
3955
|
+
geometryDecoded = tryAttempt(spatialAttempt, normalizedGrid, {}, false);
|
|
3956
|
+
if (!geometryDecoded && options.softDecoding !== false) {
|
|
3957
|
+
geometryDecoded = tryAttempt(spatialAttempt, normalizedGrid, {}, true);
|
|
3958
|
+
}
|
|
3334
3959
|
} catch {
|
|
3335
3960
|
// Continue to the recovery profile below.
|
|
3336
3961
|
}
|
|
3337
3962
|
}
|
|
3338
3963
|
|
|
3339
|
-
//
|
|
3340
|
-
// Normal camera frames never reach this path. The transform
|
|
3341
|
-
// Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
|
|
3964
|
+
// Lightweight QuadQR color recovery on the already-sampled module grid.
|
|
3965
|
+
// Normal camera frames never reach this path. The transform combines
|
|
3966
|
+
// QuadQR Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
|
|
3342
3967
|
if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
|
|
3343
3968
|
try {
|
|
3344
3969
|
const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
|
|
@@ -3347,26 +3972,38 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3347
3972
|
saturation: options.autoEnhanceSaturation
|
|
3348
3973
|
});
|
|
3349
3974
|
const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3975
|
+
const enhancedAttempts = paletteClassifierAttempts(enhancedPalette).map((attempt) => ({
|
|
3976
|
+
...attempt,
|
|
3977
|
+
colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
|
|
3978
|
+
}));
|
|
3979
|
+
for (const recoveredAttempt of enhancedAttempts) {
|
|
3355
3980
|
if (tryAttempt(recoveredAttempt, enhancedGrid, {
|
|
3356
3981
|
observedPalette: enhancedPalette,
|
|
3357
3982
|
autoEnhanced: true,
|
|
3358
3983
|
recoveryMode: "module-grid-auto-tone-contrast-color"
|
|
3359
|
-
})) {
|
|
3984
|
+
}, false)) {
|
|
3360
3985
|
geometryDecoded = true;
|
|
3361
3986
|
break;
|
|
3362
3987
|
}
|
|
3363
3988
|
}
|
|
3989
|
+
if (!geometryDecoded && options.softDecoding !== false) {
|
|
3990
|
+
for (const recoveredAttempt of enhancedAttempts) {
|
|
3991
|
+
if (tryAttempt(recoveredAttempt, enhancedGrid, {
|
|
3992
|
+
observedPalette: enhancedPalette,
|
|
3993
|
+
autoEnhanced: true,
|
|
3994
|
+
recoveryMode: "module-grid-auto-tone-contrast-color"
|
|
3995
|
+
}, true)) {
|
|
3996
|
+
geometryDecoded = true;
|
|
3997
|
+
break;
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
3364
4001
|
} catch {
|
|
3365
4002
|
// Continue to the QR-region pixel recovery below.
|
|
3366
4003
|
}
|
|
3367
4004
|
}
|
|
3368
4005
|
|
|
3369
|
-
// Camera-specific
|
|
4006
|
+
// Camera-specific QuadQR color-recovery fallback. Enhancing the whole camera
|
|
3370
4007
|
// frame is often ineffective because dark surroundings, browser/UI
|
|
3371
4008
|
// reflections and unrelated objects skew the histograms. Once finder
|
|
3372
4009
|
// geometry is known, rectify only the QuadQR region, run Auto Tone /
|
|
@@ -3405,21 +4042,34 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3405
4042
|
layout.calibration,
|
|
3406
4043
|
{ robust: true }
|
|
3407
4044
|
);
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
4045
|
+
const rectifiedAttempts = paletteClassifierAttempts(enhancedPalette).map((attempt) => ({
|
|
4046
|
+
...attempt,
|
|
4047
|
+
colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
|
|
4048
|
+
}));
|
|
4049
|
+
for (const recoveredAttempt of rectifiedAttempts) {
|
|
3413
4050
|
if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
|
|
3414
4051
|
observedPalette: enhancedPalette,
|
|
3415
4052
|
samplingMode: "rectified-auto-enhance",
|
|
3416
4053
|
autoEnhanced: true,
|
|
3417
4054
|
recoveryMode: "rectified-auto-tone-contrast-color"
|
|
3418
|
-
})) {
|
|
4055
|
+
}, false)) {
|
|
3419
4056
|
geometryDecoded = true;
|
|
3420
4057
|
break;
|
|
3421
4058
|
}
|
|
3422
4059
|
}
|
|
4060
|
+
if (!geometryDecoded && options.softDecoding !== false) {
|
|
4061
|
+
for (const recoveredAttempt of rectifiedAttempts) {
|
|
4062
|
+
if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
|
|
4063
|
+
observedPalette: enhancedPalette,
|
|
4064
|
+
samplingMode: "rectified-auto-enhance",
|
|
4065
|
+
autoEnhanced: true,
|
|
4066
|
+
recoveryMode: "rectified-auto-tone-contrast-color"
|
|
4067
|
+
}, true)) {
|
|
4068
|
+
geometryDecoded = true;
|
|
4069
|
+
break;
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
3423
4073
|
} catch {
|
|
3424
4074
|
// Geometry refinement remains available as the final bounded fallback.
|
|
3425
4075
|
}
|
|
@@ -3428,6 +4078,15 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3428
4078
|
if (geometryDecoded) break;
|
|
3429
4079
|
}
|
|
3430
4080
|
|
|
4081
|
+
// A decoded QuadQR has already passed structural validation, Spectrum ECC,
|
|
4082
|
+
// and the payload CRC. Continuing through every lower-ranked geometry after
|
|
4083
|
+
// that point used to spend most of the camera scan time proving the same
|
|
4084
|
+
// frame again. Return the first authenticated decode immediately. An opt-in
|
|
4085
|
+
// diagnostic mode can still collect every successful geometry if needed.
|
|
4086
|
+
if (geometryDecoded && options.collectAllGeometryResults !== true) {
|
|
4087
|
+
return results[results.length - 1] ?? null;
|
|
4088
|
+
}
|
|
4089
|
+
|
|
3431
4090
|
// Slow-path geometry micro-refinement. Finder/alignment detection can be
|
|
3432
4091
|
// correct while a soft-focus or smeared camera shifts the effective module
|
|
3433
4092
|
// centres by a small fraction of a cell. Clean scans never reach this code.
|
|
@@ -3485,6 +4144,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3485
4144
|
version: geometry.version,
|
|
3486
4145
|
matrix: classified.matrix,
|
|
3487
4146
|
confidence: classified.confidence,
|
|
4147
|
+
alternatives: classified.alternatives,
|
|
3488
4148
|
geometry,
|
|
3489
4149
|
observedPalette,
|
|
3490
4150
|
samplingMode: "refined-center",
|
|
@@ -3523,6 +4183,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3523
4183
|
version: geometry.version,
|
|
3524
4184
|
matrix: triangleClassified.matrix,
|
|
3525
4185
|
confidence: triangleClassified.confidence,
|
|
4186
|
+
alternatives: triangleClassified.alternatives,
|
|
3526
4187
|
geometry,
|
|
3527
4188
|
observedPalette,
|
|
3528
4189
|
samplingMode: "refined-triangle16",
|
|
@@ -3570,7 +4231,9 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3570
4231
|
const decoded = decodeMatrix(candidate.classified.matrix, {
|
|
3571
4232
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3572
4233
|
cellConfidence: candidate.classified.confidence,
|
|
4234
|
+
cellAlternatives: candidate.classified.alternatives,
|
|
3573
4235
|
cellEncodingHint: candidate.cellEncoding,
|
|
4236
|
+
alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
|
|
3574
4237
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3575
4238
|
});
|
|
3576
4239
|
if (decoded.version !== geometry.version) continue;
|
|
@@ -3599,6 +4262,11 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
3599
4262
|
}
|
|
3600
4263
|
}
|
|
3601
4264
|
}
|
|
4265
|
+
|
|
4266
|
+
|
|
4267
|
+
if (geometryDecoded && options.collectAllGeometryResults !== true) {
|
|
4268
|
+
return results[results.length - 1] ?? null;
|
|
4269
|
+
}
|
|
3602
4270
|
}
|
|
3603
4271
|
|
|
3604
4272
|
results.sort((a, b) =>
|
|
@@ -3707,6 +4375,7 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3707
4375
|
version,
|
|
3708
4376
|
matrix: classified.matrix,
|
|
3709
4377
|
confidence: classified.confidence,
|
|
4378
|
+
alternatives: classified.alternatives,
|
|
3710
4379
|
bounds,
|
|
3711
4380
|
samplingMode: classifiedAttempt.samplingMode,
|
|
3712
4381
|
colorNormalization: attempt.colorNormalization,
|
|
@@ -3718,6 +4387,7 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
3718
4387
|
const decoded = decodeMatrix(classified.matrix, {
|
|
3719
4388
|
structureTolerance: options.structureTolerance ?? 0.12,
|
|
3720
4389
|
cellConfidence: classified.confidence,
|
|
4390
|
+
cellAlternatives: classified.alternatives,
|
|
3721
4391
|
cellEncodingHint: classifiedAttempt.cellEncoding,
|
|
3722
4392
|
maxErasureConfidence: options.maxErasureConfidence
|
|
3723
4393
|
});
|
|
@@ -3770,6 +4440,31 @@ export const STRESS_PROFILES = Object.freeze([
|
|
|
3770
4440
|
Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
|
|
3771
4441
|
]);
|
|
3772
4442
|
|
|
4443
|
+
|
|
4444
|
+
export const RELIABILITY_PROFILES = Object.freeze([
|
|
4445
|
+
Object.freeze({ id: "clean", label: "Clean reference", category: "Baseline", type: "clean", severity: 0, weight: 2, suite: "quick" }),
|
|
4446
|
+
Object.freeze({ id: "blur", label: "Lens blur", category: "Optics", type: "blur", severity: 0.40, weight: 1, suite: "quick" }),
|
|
4447
|
+
Object.freeze({ id: "motion", label: "Motion blur", category: "Optics", type: "motion-blur", severity: 0.42, weight: 1, suite: "full" }),
|
|
4448
|
+
Object.freeze({ id: "dark", label: "Low light", category: "Exposure", type: "brightness-low", severity: 0.50, weight: 1, suite: "quick" }),
|
|
4449
|
+
Object.freeze({ id: "bright", label: "High exposure", category: "Exposure", type: "brightness-high", severity: 0.38, weight: 1, suite: "full" }),
|
|
4450
|
+
Object.freeze({ id: "shadow", label: "Gradient shadow", category: "Lighting", type: "shadow", severity: 0.58, weight: 1.2, suite: "quick" }),
|
|
4451
|
+
Object.freeze({ id: "glare", label: "Specular glare", category: "Lighting", type: "glare", severity: 0.34, weight: 1.1, suite: "full" }),
|
|
4452
|
+
Object.freeze({ id: "warm", label: "Warm color cast", category: "Color", type: "warm", severity: 0.62, weight: 1, suite: "quick" }),
|
|
4453
|
+
Object.freeze({ id: "cool", label: "Cool color cast", category: "Color", type: "cool", severity: 0.60, weight: 1, suite: "full" }),
|
|
4454
|
+
Object.freeze({ id: "contrast", label: "Contrast loss", category: "Color", type: "contrast-loss", severity: 0.46, weight: 1, suite: "full" }),
|
|
4455
|
+
Object.freeze({ id: "noise", label: "Sensor noise", category: "Sensor", type: "noise", severity: 0.42, weight: 0.9, suite: "full" }),
|
|
4456
|
+
Object.freeze({ id: "jpeg", label: "JPEG-like damage", category: "Resampling", type: "jpeg", severity: 0.52, weight: 0.8, suite: "full" }),
|
|
4457
|
+
Object.freeze({ id: "downscale", label: "Aggressive downscale", category: "Resampling", type: "downscale", severity: 0.55, weight: 1, suite: "quick" }),
|
|
4458
|
+
Object.freeze({ id: "perspective-2d", label: "Projective skew", category: "Perspective", type: "perspective", severity: 0.52, weight: 1.3, suite: "quick" }),
|
|
4459
|
+
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" }),
|
|
4460
|
+
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" }),
|
|
4461
|
+
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" }),
|
|
4462
|
+
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" }),
|
|
4463
|
+
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" }),
|
|
4464
|
+
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" }),
|
|
4465
|
+
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" })
|
|
4466
|
+
]);
|
|
4467
|
+
|
|
3773
4468
|
function cloneImageDataLike(imageData) {
|
|
3774
4469
|
return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
|
|
3775
4470
|
}
|
|
@@ -3869,13 +4564,151 @@ function perspectiveStress(imageData, severity) {
|
|
|
3869
4564
|
return { width, height, data: out };
|
|
3870
4565
|
}
|
|
3871
4566
|
|
|
4567
|
+
|
|
4568
|
+
function fillWhiteRgba(data) {
|
|
4569
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
4570
|
+
data[i] = 255;
|
|
4571
|
+
data[i + 1] = 255;
|
|
4572
|
+
data[i + 2] = 255;
|
|
4573
|
+
data[i + 3] = 255;
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
function projectPlaneQuad(width, height, options = {}) {
|
|
4578
|
+
const pitch = Number(options.pitchDegrees ?? options.pitch ?? 0) * Math.PI / 180;
|
|
4579
|
+
const yaw = Number(options.yawDegrees ?? options.yaw ?? 0) * Math.PI / 180;
|
|
4580
|
+
const roll = Number(options.rollDegrees ?? options.roll ?? 0) * Math.PI / 180;
|
|
4581
|
+
const cameraDistance = Math.max(1.8, Number(options.cameraDistance ?? 3.0));
|
|
4582
|
+
const fill = clampNumber(Number(options.fill ?? 0.84), 0.45, 0.94);
|
|
4583
|
+
const aspect = width / Math.max(1, height);
|
|
4584
|
+
// Point order intentionally follows TL, TR, BL, BR to match the
|
|
4585
|
+
// destination->source homography convention used throughout the scanner.
|
|
4586
|
+
const corners = [
|
|
4587
|
+
{ x: -aspect, y: -1, z: 0 },
|
|
4588
|
+
{ x: aspect, y: -1, z: 0 },
|
|
4589
|
+
{ x: -aspect, y: 1, z: 0 },
|
|
4590
|
+
{ x: aspect, y: 1, z: 0 }
|
|
4591
|
+
];
|
|
4592
|
+
const cp = Math.cos(pitch), sp = Math.sin(pitch);
|
|
4593
|
+
const cy = Math.cos(yaw), sy = Math.sin(yaw);
|
|
4594
|
+
const cr = Math.cos(roll), sr = Math.sin(roll);
|
|
4595
|
+
const projected = corners.map((point) => {
|
|
4596
|
+
// Yaw around Y.
|
|
4597
|
+
const x1 = point.x * cy + point.z * sy;
|
|
4598
|
+
const z1 = -point.x * sy + point.z * cy;
|
|
4599
|
+
const y1 = point.y;
|
|
4600
|
+
// Pitch around X.
|
|
4601
|
+
const y2 = y1 * cp - z1 * sp;
|
|
4602
|
+
const z2 = y1 * sp + z1 * cp;
|
|
4603
|
+
const x2 = x1;
|
|
4604
|
+
// Roll around Z, i.e. in-plane camera/code rotation.
|
|
4605
|
+
const x3 = x2 * cr - y2 * sr;
|
|
4606
|
+
const y3 = x2 * sr + y2 * cr;
|
|
4607
|
+
const depth = Math.max(0.35, cameraDistance + z2);
|
|
4608
|
+
const perspective = cameraDistance / depth;
|
|
4609
|
+
return { x: x3 * perspective, y: y3 * perspective };
|
|
4610
|
+
});
|
|
4611
|
+
const minX = Math.min(...projected.map((point) => point.x));
|
|
4612
|
+
const maxX = Math.max(...projected.map((point) => point.x));
|
|
4613
|
+
const minY = Math.min(...projected.map((point) => point.y));
|
|
4614
|
+
const maxY = Math.max(...projected.map((point) => point.y));
|
|
4615
|
+
const projectedWidth = Math.max(0.001, maxX - minX);
|
|
4616
|
+
const projectedHeight = Math.max(0.001, maxY - minY);
|
|
4617
|
+
const scale = Math.min(width * fill / projectedWidth, height * fill / projectedHeight);
|
|
4618
|
+
const centerX = (minX + maxX) / 2;
|
|
4619
|
+
const centerY = (minY + maxY) / 2;
|
|
4620
|
+
return projected.map((point) => ({
|
|
4621
|
+
x: width / 2 + (point.x - centerX) * scale,
|
|
4622
|
+
y: height / 2 + (point.y - centerY) * scale
|
|
4623
|
+
}));
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4626
|
+
function warpToQuad(imageData, destination) {
|
|
4627
|
+
const { width, height } = imageData;
|
|
4628
|
+
const source = [
|
|
4629
|
+
{ x: 0, y: 0 },
|
|
4630
|
+
{ x: width - 1, y: 0 },
|
|
4631
|
+
{ x: 0, y: height - 1 },
|
|
4632
|
+
{ x: width - 1, y: height - 1 }
|
|
4633
|
+
];
|
|
4634
|
+
const inverse = computeHomography(source, destination);
|
|
4635
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
4636
|
+
fillWhiteRgba(out);
|
|
4637
|
+
for (let y = 0; y < height; y++) {
|
|
4638
|
+
for (let x = 0; x < width; x++) {
|
|
4639
|
+
const src = projectPoint(inverse, x, y);
|
|
4640
|
+
if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
|
|
4641
|
+
const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
|
|
4642
|
+
const p = (y * width + x) * 4;
|
|
4643
|
+
out[p] = rgba[0];
|
|
4644
|
+
out[p + 1] = rgba[1];
|
|
4645
|
+
out[p + 2] = rgba[2];
|
|
4646
|
+
out[p + 3] = 255;
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
return { width, height, data: out };
|
|
4650
|
+
}
|
|
4651
|
+
|
|
4652
|
+
function perspective3dStress(imageData, severity, options = {}) {
|
|
4653
|
+
const s = clampNumber(Number(severity), 0, 1);
|
|
4654
|
+
const destination = projectPlaneQuad(imageData.width, imageData.height, {
|
|
4655
|
+
pitchDegrees: options.pitchDegrees ?? (8 + s * 28),
|
|
4656
|
+
yawDegrees: options.yawDegrees ?? (12 + s * 43),
|
|
4657
|
+
rollDegrees: options.rollDegrees ?? (s * 18),
|
|
4658
|
+
cameraDistance: options.cameraDistance ?? 3,
|
|
4659
|
+
fill: options.fill ?? (0.88 - s * 0.06)
|
|
4660
|
+
});
|
|
4661
|
+
return warpToQuad(imageData, destination);
|
|
4662
|
+
}
|
|
4663
|
+
|
|
4664
|
+
function motionBlurImage(imageData, severity, options = {}) {
|
|
4665
|
+
const radius = Math.max(1, Math.round(Number(options.radius ?? (2 + severity * 5))));
|
|
4666
|
+
const slope = Number(options.slope ?? 0.35);
|
|
4667
|
+
const { width, height } = imageData;
|
|
4668
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
4669
|
+
for (let y = 0; y < height; y++) {
|
|
4670
|
+
for (let x = 0; x < width; x++) {
|
|
4671
|
+
const p = (y * width + x) * 4;
|
|
4672
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
4673
|
+
let sum = 0;
|
|
4674
|
+
let count = 0;
|
|
4675
|
+
for (let k = -radius; k <= radius; k++) {
|
|
4676
|
+
const sx = x + k;
|
|
4677
|
+
const sy = y + Math.round(k * slope);
|
|
4678
|
+
if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue;
|
|
4679
|
+
sum += imageData.data[(sy * width + sx) * 4 + ch];
|
|
4680
|
+
count++;
|
|
4681
|
+
}
|
|
4682
|
+
out[p + ch] = count ? sum / count : imageData.data[p + ch];
|
|
4683
|
+
}
|
|
4684
|
+
out[p + 3] = 255;
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
return { width, height, data: out };
|
|
4688
|
+
}
|
|
4689
|
+
|
|
4690
|
+
function deterministicNoise(x, y, channel) {
|
|
4691
|
+
let value = Math.imul((x + 1) ^ (channel * 374761393), 668265263) ^ Math.imul(y + 11, 2246822519);
|
|
4692
|
+
value = Math.imul(value ^ (value >>> 13), 1274126177);
|
|
4693
|
+
return ((value ^ (value >>> 16)) >>> 0) / 0xffffffff * 2 - 1;
|
|
4694
|
+
}
|
|
4695
|
+
|
|
3872
4696
|
/** Apply one deterministic camera/print-style distortion to ImageData. */
|
|
3873
4697
|
export function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
|
|
3874
4698
|
assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
|
|
3875
4699
|
const s = clampNumber(Number(severity), 0, 1);
|
|
3876
4700
|
if (type === "clean") return cloneImageDataLike(imageData);
|
|
3877
4701
|
if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
|
|
4702
|
+
if (type === "motion-blur") return motionBlurImage(imageData, s, options);
|
|
3878
4703
|
if (type === "perspective") return perspectiveStress(imageData, s);
|
|
4704
|
+
if (type === "perspective-3d" || type === "rotate-z") {
|
|
4705
|
+
return perspective3dStress(imageData, s, {
|
|
4706
|
+
...options,
|
|
4707
|
+
pitchDegrees: type === "rotate-z" ? 0 : options.pitchDegrees,
|
|
4708
|
+
yawDegrees: type === "rotate-z" ? 0 : options.yawDegrees,
|
|
4709
|
+
rollDegrees: type === "rotate-z" ? (options.rollDegrees ?? 20 + s * 70) : options.rollDegrees
|
|
4710
|
+
});
|
|
4711
|
+
}
|
|
3879
4712
|
if (type === "downscale") {
|
|
3880
4713
|
const scale = Math.max(0.20, 1 - s * 0.72);
|
|
3881
4714
|
const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
|
|
@@ -3904,7 +4737,22 @@ export function applyStressDistortion(imageData, type, severity = 0.5, options =
|
|
|
3904
4737
|
value += block * s * 3;
|
|
3905
4738
|
} else if (type === "warm") {
|
|
3906
4739
|
if (ch === 0) value *= 1 + s * 0.12;
|
|
4740
|
+
if (ch === 1) value *= 1 + s * 0.03;
|
|
3907
4741
|
if (ch === 2) value *= 1 - s * 0.22;
|
|
4742
|
+
} else if (type === "cool") {
|
|
4743
|
+
if (ch === 0) value *= 1 - s * 0.20;
|
|
4744
|
+
if (ch === 1) value *= 1 + s * 0.02;
|
|
4745
|
+
if (ch === 2) value *= 1 + s * 0.14;
|
|
4746
|
+
} else if (type === "noise") {
|
|
4747
|
+
value += deterministicNoise(x, y, ch) * (5 + s * 34);
|
|
4748
|
+
} else if (type === "glare") {
|
|
4749
|
+
const nx = (x / Math.max(1, width - 1) - 0.68) / 0.28;
|
|
4750
|
+
const ny = (y / Math.max(1, height - 1) - 0.30) / 0.20;
|
|
4751
|
+
const falloff = Math.exp(-(nx * nx + ny * ny) * 2.2);
|
|
4752
|
+
value += (255 - value) * falloff * s * 0.82;
|
|
4753
|
+
} else if (type === "gamma") {
|
|
4754
|
+
const gamma = 1 + (s - 0.5) * 1.2;
|
|
4755
|
+
value = 255 * Math.pow(value / 255, gamma);
|
|
3908
4756
|
}
|
|
3909
4757
|
data[p + ch] = clampNumber(Math.round(value), 0, 255);
|
|
3910
4758
|
}
|
|
@@ -3974,16 +4822,122 @@ export function runImageStressTest(imageData, expected = {}, options = {}) {
|
|
|
3974
4822
|
};
|
|
3975
4823
|
}
|
|
3976
4824
|
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
const
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
4825
|
+
|
|
4826
|
+
function reliabilityProfilesForSuite(suite = "full") {
|
|
4827
|
+
const rank = { quick: 0, full: 1, extreme: 2 };
|
|
4828
|
+
const requested = rank[suite] ?? rank.full;
|
|
4829
|
+
return RELIABILITY_PROFILES.filter((profile) => (rank[profile.suite] ?? 1) <= requested);
|
|
4830
|
+
}
|
|
4831
|
+
|
|
4832
|
+
/** Run the broader Reliability Lab suite and return category-level scores. */
|
|
4833
|
+
export function runReliabilityLab(imageData, expected = {}, options = {}) {
|
|
4834
|
+
const suite = options.suite ?? "full";
|
|
4835
|
+
const profiles = options.profiles ?? reliabilityProfilesForSuite(suite);
|
|
4836
|
+
const report = runImageStressTest(imageData, expected, { ...options, profiles });
|
|
4837
|
+
const categoryMap = new Map();
|
|
4838
|
+
for (const result of report.results) {
|
|
4839
|
+
const profile = profiles.find((item) => item.id === result.id) ?? {};
|
|
4840
|
+
const category = profile.category ?? "Other";
|
|
4841
|
+
const weight = Number(profile.weight ?? 1);
|
|
4842
|
+
if (!categoryMap.has(category)) categoryMap.set(category, { category, passedWeight: 0, totalWeight: 0, passed: 0, total: 0 });
|
|
4843
|
+
const entry = categoryMap.get(category);
|
|
4844
|
+
entry.totalWeight += weight;
|
|
4845
|
+
entry.total++;
|
|
4846
|
+
if (result.passed) {
|
|
4847
|
+
entry.passedWeight += weight;
|
|
4848
|
+
entry.passed++;
|
|
4849
|
+
}
|
|
4850
|
+
result.category = category;
|
|
4851
|
+
result.pitchDegrees = profile.pitchDegrees ?? null;
|
|
4852
|
+
result.yawDegrees = profile.yawDegrees ?? null;
|
|
4853
|
+
result.rollDegrees = profile.rollDegrees ?? null;
|
|
4854
|
+
}
|
|
4855
|
+
const categories = [...categoryMap.values()].map((entry) => ({
|
|
4856
|
+
category: entry.category,
|
|
4857
|
+
passed: entry.passed,
|
|
4858
|
+
total: entry.total,
|
|
4859
|
+
score: entry.totalWeight ? entry.passedWeight / entry.totalWeight * 100 : 0
|
|
4860
|
+
})).sort((a, b) => a.score - b.score || a.category.localeCompare(b.category));
|
|
3984
4861
|
return {
|
|
3985
4862
|
...report,
|
|
3986
|
-
|
|
4863
|
+
suite,
|
|
4864
|
+
categories,
|
|
4865
|
+
weakestCategory: categories[0] ?? null
|
|
4866
|
+
};
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
/** Sweep one 3D perspective axis to measure the largest passing angle. */
|
|
4870
|
+
export function runPerspectiveSweep(imageData, expected = {}, options = {}) {
|
|
4871
|
+
const axis = options.axis ?? "yaw";
|
|
4872
|
+
const defaultAngles = axis === "roll"
|
|
4873
|
+
? [0, 25, 45, 60, 75]
|
|
4874
|
+
: [0, 15, 25, 35, 45, 55];
|
|
4875
|
+
const angles = (options.angles ?? defaultAngles).map(Number).filter(Number.isFinite);
|
|
4876
|
+
const results = [];
|
|
4877
|
+
let maxPassedAngle = null;
|
|
4878
|
+
for (const angle of angles) {
|
|
4879
|
+
const transform = {
|
|
4880
|
+
pitchDegrees: Number(options.pitchDegrees ?? 0),
|
|
4881
|
+
yawDegrees: Number(options.yawDegrees ?? 0),
|
|
4882
|
+
rollDegrees: Number(options.rollDegrees ?? 0),
|
|
4883
|
+
fill: options.fill ?? 0.84,
|
|
4884
|
+
cameraDistance: options.cameraDistance ?? 3
|
|
4885
|
+
};
|
|
4886
|
+
if (axis === "pitch") transform.pitchDegrees = angle;
|
|
4887
|
+
else if (axis === "roll" || axis === "z") transform.rollDegrees = angle;
|
|
4888
|
+
else transform.yawDegrees = angle;
|
|
4889
|
+
const distorted = applyStressDistortion(imageData, "perspective-3d", 0.5, transform);
|
|
4890
|
+
const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
4891
|
+
try {
|
|
4892
|
+
const decoded = scanImageData(distorted, {
|
|
4893
|
+
minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
|
|
4894
|
+
maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
|
|
4895
|
+
debug: false
|
|
4896
|
+
});
|
|
4897
|
+
const passed = expected.crc32 == null || decoded.crc32 === expected.crc32;
|
|
4898
|
+
if (passed) maxPassedAngle = Math.max(maxPassedAngle ?? angle, angle);
|
|
4899
|
+
results.push({
|
|
4900
|
+
angle,
|
|
4901
|
+
axis,
|
|
4902
|
+
passed,
|
|
4903
|
+
confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
|
|
4904
|
+
correctedSymbols: decoded.correctedSymbols ?? 0,
|
|
4905
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
4906
|
+
image: options.includeImages ? distorted : undefined,
|
|
4907
|
+
error: passed ? null : "Decoded payload did not match expected CRC."
|
|
4908
|
+
});
|
|
4909
|
+
} catch (error) {
|
|
4910
|
+
results.push({
|
|
4911
|
+
angle,
|
|
4912
|
+
axis,
|
|
4913
|
+
passed: false,
|
|
4914
|
+
confidence: 0,
|
|
4915
|
+
correctedSymbols: null,
|
|
4916
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
4917
|
+
image: options.includeImages ? distorted : undefined,
|
|
4918
|
+
error: error.message
|
|
4919
|
+
});
|
|
4920
|
+
}
|
|
4921
|
+
}
|
|
4922
|
+
return {
|
|
4923
|
+
axis,
|
|
4924
|
+
maxPassedAngle,
|
|
4925
|
+
passed: results.filter((item) => item.passed).length,
|
|
4926
|
+
total: results.length,
|
|
4927
|
+
results
|
|
4928
|
+
};
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
/** Render and run the standard scanability torture suite. */
|
|
4932
|
+
export function assessScanability(code, renderOptions = {}, options = {}) {
|
|
4933
|
+
assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
|
|
4934
|
+
const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
|
|
4935
|
+
const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
|
|
4936
|
+
const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
|
|
4937
|
+
const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
|
|
4938
|
+
return {
|
|
4939
|
+
...report,
|
|
4940
|
+
version: code.version,
|
|
3987
4941
|
eccLevel: code.eccLevel,
|
|
3988
4942
|
utilization: code.utilization,
|
|
3989
4943
|
testImageSize,
|
|
@@ -4135,6 +5089,38 @@ export function scanImageData(imageData, options = {}) {
|
|
|
4135
5089
|
};
|
|
4136
5090
|
|
|
4137
5091
|
if (options.perspective !== false) {
|
|
5092
|
+
const geometryHints = Array.isArray(options._geometryHints)
|
|
5093
|
+
? options._geometryHints.filter((item) => item?.homography && Number.isInteger(item.version)).slice(0, 2)
|
|
5094
|
+
: [];
|
|
5095
|
+
if (geometryHints.length) {
|
|
5096
|
+
try {
|
|
5097
|
+
const hinted = tryPerspectiveScan(imageData, {
|
|
5098
|
+
...scanOptions,
|
|
5099
|
+
_diagnosticLabel: options._diagnosticLabel ? `${options._diagnosticLabel}-geometry-reuse` : "geometry-reuse",
|
|
5100
|
+
_geometryCandidatesOverride: geometryHints,
|
|
5101
|
+
_geometryCollector: [],
|
|
5102
|
+
_observationCollector: [],
|
|
5103
|
+
adaptiveSampling: options._geometryReuseAdaptiveSampling ?? false,
|
|
5104
|
+
geometryRefinement: false
|
|
5105
|
+
});
|
|
5106
|
+
if (hinted) {
|
|
5107
|
+
geometryCollector.push(hinted.geometry ?? geometryHints[0]);
|
|
5108
|
+
return decorateScanResult({
|
|
5109
|
+
...hinted,
|
|
5110
|
+
geometryReused: true,
|
|
5111
|
+
recoveryMode: hinted.recoveryMode ?? "geometry-reuse"
|
|
5112
|
+
}, debugContext, options);
|
|
5113
|
+
}
|
|
5114
|
+
} catch {
|
|
5115
|
+
// A stale geometry hint is expected when the camera/code moves. The
|
|
5116
|
+
// camera worker can request a hint-only attempt when it is reusing
|
|
5117
|
+
// low-resolution locator geometry on a higher-detail frame.
|
|
5118
|
+
}
|
|
5119
|
+
if (options._geometryHintOnly === true) {
|
|
5120
|
+
throw new Error("Geometry hint did not decode this frame.");
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
|
|
4138
5124
|
const perspective = tryPerspectiveScan(imageData, scanOptions);
|
|
4139
5125
|
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
4140
5126
|
}
|
|
@@ -4460,6 +5446,21 @@ export function scanVideoFrame(video, options = {}) {
|
|
|
4460
5446
|
_visionDiagnostics: visionDiagnostics
|
|
4461
5447
|
});
|
|
4462
5448
|
normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
|
|
5449
|
+
if (options._frameDiagnostics && result?.geometry && !options._frameDiagnostics.geometry) {
|
|
5450
|
+
const geometry = result.geometry;
|
|
5451
|
+
options._frameDiagnostics.geometry = geometry;
|
|
5452
|
+
options._frameDiagnostics.geometryReused = Boolean(result.geometryReused);
|
|
5453
|
+
if (geometry.finders) {
|
|
5454
|
+
const reusedFinders = [
|
|
5455
|
+
geometry.finders.topLeft,
|
|
5456
|
+
geometry.finders.topRight,
|
|
5457
|
+
geometry.finders.bottomLeft
|
|
5458
|
+
].filter(Boolean);
|
|
5459
|
+
options._frameDiagnostics.finders = reusedFinders;
|
|
5460
|
+
options._frameDiagnostics.finderCount = reusedFinders.length;
|
|
5461
|
+
if (result.geometryReused) options._frameDiagnostics.finderMethod = "geometry-reuse";
|
|
5462
|
+
}
|
|
5463
|
+
}
|
|
4463
5464
|
if (!source.cropped) return result;
|
|
4464
5465
|
return {
|
|
4465
5466
|
...result,
|
|
@@ -4483,47 +5484,111 @@ function selectBestFrameObservation(observations) {
|
|
|
4483
5484
|
)[0] ?? null;
|
|
4484
5485
|
}
|
|
4485
5486
|
|
|
5487
|
+
function observationDataAgreement(a, b) {
|
|
5488
|
+
if (!a?.matrix || !b?.matrix || a.version !== b.version || a.matrix.length !== b.matrix.length) return 0;
|
|
5489
|
+
if ((a.cellEncoding ?? null) !== (b.cellEncoding ?? null)) return 0;
|
|
5490
|
+
const layout = createLayout(a.version);
|
|
5491
|
+
let agreed = 0;
|
|
5492
|
+
let compared = 0;
|
|
5493
|
+
for (const [row, col] of layout.dataPositions) {
|
|
5494
|
+
const ca = a.confidence?.[row]?.[col] ?? 0;
|
|
5495
|
+
const cb = b.confidence?.[row]?.[col] ?? 0;
|
|
5496
|
+
if (Math.min(ca, cb) < 0.48) continue;
|
|
5497
|
+
compared++;
|
|
5498
|
+
if (a.matrix[row][col] === b.matrix[row][col]) agreed++;
|
|
5499
|
+
}
|
|
5500
|
+
return compared ? agreed / compared : 0;
|
|
5501
|
+
}
|
|
5502
|
+
|
|
4486
5503
|
function combineFrameObservations(observations) {
|
|
4487
5504
|
if (!observations?.length) return null;
|
|
4488
5505
|
const version = observations[0].version;
|
|
5506
|
+
const cellEncoding = observations[0].cellEncoding ?? null;
|
|
4489
5507
|
const size = observations[0].matrix.length;
|
|
4490
|
-
if (!observations.every((item) =>
|
|
5508
|
+
if (!observations.every((item) =>
|
|
5509
|
+
item.version === version &&
|
|
5510
|
+
(item.cellEncoding ?? null) === cellEncoding &&
|
|
5511
|
+
item.matrix.length === size
|
|
5512
|
+
)) return null;
|
|
4491
5513
|
|
|
4492
5514
|
const matrix = make2D(size, CELL.WHITE);
|
|
4493
5515
|
const confidence = make2D(size, 0);
|
|
5516
|
+
const alternatives = make2D(size, null);
|
|
5517
|
+
const frameCount = observations.length;
|
|
5518
|
+
const latest = observations[frameCount - 1];
|
|
5519
|
+
let agreementSum = 0;
|
|
5520
|
+
let agreementCount = 0;
|
|
5521
|
+
for (let i = 0; i < frameCount - 1; i++) {
|
|
5522
|
+
const agreement = observationDataAgreement(observations[i], latest);
|
|
5523
|
+
if (agreement > 0) {
|
|
5524
|
+
agreementSum += agreement;
|
|
5525
|
+
agreementCount++;
|
|
5526
|
+
}
|
|
5527
|
+
}
|
|
5528
|
+
|
|
4494
5529
|
for (let r = 0; r < size; r++) {
|
|
4495
5530
|
for (let c = 0; c < size; c++) {
|
|
4496
5531
|
const votes = new Map();
|
|
4497
5532
|
const confidenceByCell = new Map();
|
|
4498
|
-
for (
|
|
5533
|
+
for (let index = 0; index < observations.length; index++) {
|
|
5534
|
+
const observation = observations[index];
|
|
4499
5535
|
const cell = observation.matrix[r][c];
|
|
4500
5536
|
const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
|
|
4501
|
-
const
|
|
5537
|
+
const age = observations.length - 1 - index;
|
|
5538
|
+
const recency = Math.pow(0.88, age);
|
|
5539
|
+
const frameQuality = clampNumber(
|
|
5540
|
+
0.45 +
|
|
5541
|
+
(observation.structureScore ?? 0.82) * 0.30 +
|
|
5542
|
+
(observation.averageCellConfidence ?? 0.5) * 0.25,
|
|
5543
|
+
0.45,
|
|
5544
|
+
1
|
|
5545
|
+
);
|
|
5546
|
+
const weight = (0.25 + sourceConfidence * 0.75) * recency * frameQuality;
|
|
4502
5547
|
votes.set(cell, (votes.get(cell) ?? 0) + weight);
|
|
4503
5548
|
const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
|
|
4504
5549
|
stats.weighted += sourceConfidence * weight;
|
|
4505
5550
|
stats.weight += weight;
|
|
4506
5551
|
confidenceByCell.set(cell, stats);
|
|
5552
|
+
|
|
5553
|
+
// The per-frame second hypothesis also contributes weak evidence. It
|
|
5554
|
+
// never outranks a strong primary vote by itself, but it preserves a
|
|
5555
|
+
// plausible alternative for Spectrum ECC 2.0 when several frames are
|
|
5556
|
+
// individually ambiguous in the same region.
|
|
5557
|
+
const alternative = observation.alternatives?.[r]?.[c];
|
|
5558
|
+
if (Number.isInteger(alternative) && alternative !== cell) {
|
|
5559
|
+
const altWeight = weight * (1 - sourceConfidence) * 0.42;
|
|
5560
|
+
votes.set(alternative, (votes.get(alternative) ?? 0) + altWeight);
|
|
5561
|
+
}
|
|
4507
5562
|
}
|
|
4508
5563
|
|
|
4509
5564
|
const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
|
|
4510
5565
|
const [bestCell, bestWeight] = ranked[0];
|
|
4511
|
-
const secondWeight = ranked[1]
|
|
5566
|
+
const [secondCell, secondWeight] = ranked[1] ?? [null, 0];
|
|
4512
5567
|
const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
|
|
4513
5568
|
const sourceStats = confidenceByCell.get(bestCell);
|
|
4514
5569
|
const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
|
|
4515
5570
|
const support = totalWeight ? bestWeight / totalWeight : 0;
|
|
4516
5571
|
const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
|
|
4517
|
-
const
|
|
5572
|
+
const temporalBoost = clampNumber((frameCount - 1) / 5, 0, 0.18);
|
|
5573
|
+
const fusedConfidence = clampNumber(
|
|
5574
|
+
sourceConfidence * 0.50 + support * 0.30 + margin * 0.20 + temporalBoost,
|
|
5575
|
+
0,
|
|
5576
|
+
1
|
|
5577
|
+
);
|
|
4518
5578
|
|
|
4519
5579
|
matrix[r][c] = bestCell;
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
// cells as erasures instead of hard, supposedly-certain errors.
|
|
4523
|
-
confidence[r][c] = clampNumber(sourceConfidence * 0.58 + agreement * 0.42, 0, 1);
|
|
5580
|
+
confidence[r][c] = fusedConfidence;
|
|
5581
|
+
alternatives[r][c] = Number.isInteger(secondCell) && secondCell !== bestCell ? secondCell : null;
|
|
4524
5582
|
}
|
|
4525
5583
|
}
|
|
4526
|
-
return {
|
|
5584
|
+
return {
|
|
5585
|
+
version,
|
|
5586
|
+
cellEncoding,
|
|
5587
|
+
matrix,
|
|
5588
|
+
confidence,
|
|
5589
|
+
alternatives,
|
|
5590
|
+
frameAgreement: agreementCount ? agreementSum / agreementCount : 1
|
|
5591
|
+
};
|
|
4527
5592
|
}
|
|
4528
5593
|
|
|
4529
5594
|
async function improveCameraTrack(stream) {
|
|
@@ -4548,7 +5613,7 @@ async function improveCameraTrack(stream) {
|
|
|
4548
5613
|
}
|
|
4549
5614
|
}
|
|
4550
5615
|
|
|
4551
|
-
|
|
5616
|
+
async function startCameraScannerMainThread(video, options = {}) {
|
|
4552
5617
|
assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
|
|
4553
5618
|
assert(video, "A video element is required.");
|
|
4554
5619
|
|
|
@@ -4557,8 +5622,8 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4557
5622
|
audio: false,
|
|
4558
5623
|
video: {
|
|
4559
5624
|
facingMode: { ideal: "environment" },
|
|
4560
|
-
width: { ideal:
|
|
4561
|
-
height: { ideal:
|
|
5625
|
+
width: { ideal: 1280 },
|
|
5626
|
+
height: { ideal: 720 },
|
|
4562
5627
|
frameRate: { ideal: 30 }
|
|
4563
5628
|
}
|
|
4564
5629
|
}
|
|
@@ -4570,7 +5635,11 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4570
5635
|
video.muted = true;
|
|
4571
5636
|
await video.play();
|
|
4572
5637
|
|
|
4573
|
-
|
|
5638
|
+
// Keep scanning responsive without queueing stale camera frames. The old
|
|
5639
|
+
// 180 ms post-scan timeout made effective cadence equal scan time + 180 ms.
|
|
5640
|
+
// The scheduler below measures from scan start and uses real video frames
|
|
5641
|
+
// when requestVideoFrameCallback() is available.
|
|
5642
|
+
const scanInterval = Math.max(24, Number(options.scanInterval ?? 80));
|
|
4574
5643
|
const scratchCanvas = document.createElement("canvas");
|
|
4575
5644
|
const highResolutionCanvas = document.createElement("canvas");
|
|
4576
5645
|
const multiFrameEnabled = options.multiFrame !== false;
|
|
@@ -4581,16 +5650,23 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4581
5650
|
const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
|
|
4582
5651
|
const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
|
|
4583
5652
|
const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
|
|
4584
|
-
const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ??
|
|
5653
|
+
const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 640));
|
|
4585
5654
|
const cameraHighResolutionMaxDimension = Math.max(
|
|
4586
5655
|
baseCameraMaxDimension,
|
|
4587
|
-
Math.round(options.cameraHighResolutionMaxDimension ??
|
|
5656
|
+
Math.round(options.cameraHighResolutionMaxDimension ?? 960)
|
|
4588
5657
|
);
|
|
4589
5658
|
let missStreak = 0;
|
|
4590
5659
|
let stopped = false;
|
|
4591
5660
|
let busy = false;
|
|
4592
5661
|
let timer = null;
|
|
5662
|
+
let frameCallbackId = null;
|
|
5663
|
+
let lastScanStartedAt = -Infinity;
|
|
4593
5664
|
let frameNumber = 0;
|
|
5665
|
+
let cameraGeometryHint = null;
|
|
5666
|
+
let cameraGeometryHintMisses = 0;
|
|
5667
|
+
const cameraGeometryReuseMaxMisses = Math.max(1, Math.round(options.cameraGeometryReuseMaxMisses ?? 5));
|
|
5668
|
+
const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
|
|
5669
|
+
typeof video.requestVideoFrameCallback === "function";
|
|
4594
5670
|
|
|
4595
5671
|
const diagnosticsEnabled = typeof options.onDiagnostic === "function";
|
|
4596
5672
|
const emitDiagnostic = (event) => {
|
|
@@ -4626,6 +5702,12 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4626
5702
|
const stop = () => {
|
|
4627
5703
|
stopped = true;
|
|
4628
5704
|
if (timer) clearTimeout(timer);
|
|
5705
|
+
if (frameCallbackId != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
5706
|
+
try { video.cancelVideoFrameCallback(frameCallbackId); } catch {}
|
|
5707
|
+
}
|
|
5708
|
+
timer = null;
|
|
5709
|
+
frameCallbackId = null;
|
|
5710
|
+
cameraGeometryHint = null;
|
|
4629
5711
|
observationHistory.clear();
|
|
4630
5712
|
for (const track of stream.getTracks()) track.stop();
|
|
4631
5713
|
if (video.srcObject === stream) video.srcObject = null;
|
|
@@ -4634,9 +5716,30 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4634
5716
|
const scanNow = () => scanVideoFrame(video, {
|
|
4635
5717
|
...options,
|
|
4636
5718
|
maxDimension: baseCameraMaxDimension,
|
|
4637
|
-
canvas: scratchCanvas
|
|
5719
|
+
canvas: scratchCanvas,
|
|
5720
|
+
_geometryHints: options.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint]
|
|
4638
5721
|
});
|
|
4639
5722
|
|
|
5723
|
+
const updateCameraGeometryHint = (frameDiagnostics, result = null) => {
|
|
5724
|
+
if (options.cameraGeometryReuse === false) {
|
|
5725
|
+
cameraGeometryHint = null;
|
|
5726
|
+
return;
|
|
5727
|
+
}
|
|
5728
|
+
const geometry = result?.geometry ?? frameDiagnostics?.geometry ?? null;
|
|
5729
|
+
if (geometry?.homography && Number.isInteger(geometry.version)) {
|
|
5730
|
+
cameraGeometryHint = geometry;
|
|
5731
|
+
cameraGeometryHintMisses = 0;
|
|
5732
|
+
return;
|
|
5733
|
+
}
|
|
5734
|
+
if (cameraGeometryHint) {
|
|
5735
|
+
cameraGeometryHintMisses++;
|
|
5736
|
+
if (cameraGeometryHintMisses >= cameraGeometryReuseMaxMisses) {
|
|
5737
|
+
cameraGeometryHint = null;
|
|
5738
|
+
cameraGeometryHintMisses = 0;
|
|
5739
|
+
}
|
|
5740
|
+
}
|
|
5741
|
+
};
|
|
5742
|
+
|
|
4640
5743
|
const emitResult = (result, capturedFrame = null, diagnostic = null) => {
|
|
4641
5744
|
const frameMeta = capturedFrame?.imageData ? {
|
|
4642
5745
|
frame: frameNumber,
|
|
@@ -4662,10 +5765,25 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4662
5765
|
if (!multiFrameEnabled) return null;
|
|
4663
5766
|
const best = selectBestFrameObservation(observations);
|
|
4664
5767
|
if (!best) return null;
|
|
4665
|
-
const
|
|
5768
|
+
const trackKey = `${best.version}:${best.cellEncoding ?? "auto"}`;
|
|
5769
|
+
let history = observationHistory.get(trackKey) ?? [];
|
|
5770
|
+
|
|
5771
|
+
// Only fuse observations that appear to describe the same payload. Finder
|
|
5772
|
+
// structure alone is not enough because two different QuadQR symbols of the
|
|
5773
|
+
// same version would otherwise contaminate each other's history. High-
|
|
5774
|
+
// confidence data-cell agreement gives us a cheap identity check without
|
|
5775
|
+
// needing the header to decode first.
|
|
5776
|
+
if (history.length) {
|
|
5777
|
+
const agreement = observationDataAgreement(history[history.length - 1], best);
|
|
5778
|
+
const minimumAgreement = best.cellEncoding === CELL_ENCODINGS.TRIANGLE16
|
|
5779
|
+
? (options.multiFrameMinAgreementHighDensity ?? 0.58)
|
|
5780
|
+
: (options.multiFrameMinAgreement ?? 0.62);
|
|
5781
|
+
if (agreement > 0 && agreement < minimumAgreement) history = [];
|
|
5782
|
+
}
|
|
5783
|
+
|
|
4666
5784
|
history.push(best);
|
|
4667
5785
|
while (history.length > multiFrameWindow) history.shift();
|
|
4668
|
-
observationHistory.set(
|
|
5786
|
+
observationHistory.set(trackKey, history);
|
|
4669
5787
|
if (history.length < multiFrameMinFrames) return null;
|
|
4670
5788
|
|
|
4671
5789
|
const combined = combineFrameObservations(history);
|
|
@@ -4674,16 +5792,21 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4674
5792
|
const decoded = decodeMatrix(combined.matrix, {
|
|
4675
5793
|
structureTolerance: options.structureTolerance ?? 0.20,
|
|
4676
5794
|
cellConfidence: combined.confidence,
|
|
4677
|
-
|
|
5795
|
+
cellAlternatives: combined.alternatives,
|
|
5796
|
+
cellEncodingHint: best.cellEncoding ?? undefined,
|
|
5797
|
+
maxErasureConfidence: options.maxErasureConfidence,
|
|
5798
|
+
softDecoding: options.softDecoding
|
|
4678
5799
|
});
|
|
4679
5800
|
if (decoded.version !== best.version) return null;
|
|
4680
5801
|
return {
|
|
4681
5802
|
...decoded,
|
|
4682
5803
|
perspectiveCorrected: Boolean(best.geometry),
|
|
4683
5804
|
colorCalibrated: true,
|
|
4684
|
-
colorNormalization: "multi-frame-
|
|
4685
|
-
samplingMode: "multi-frame-
|
|
5805
|
+
colorNormalization: "multi-frame-confidence-fusion",
|
|
5806
|
+
samplingMode: "multi-frame-confidence-fusion",
|
|
4686
5807
|
multiFrameCombined: history.length,
|
|
5808
|
+
multiFrameAgreement: combined.frameAgreement,
|
|
5809
|
+
multiFrameMode: "confidence-fusion",
|
|
4687
5810
|
geometry: best.geometry,
|
|
4688
5811
|
observedPalette: best.observedPalette,
|
|
4689
5812
|
averageCellConfidence: best.averageCellConfidence,
|
|
@@ -4694,6 +5817,33 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4694
5817
|
}
|
|
4695
5818
|
};
|
|
4696
5819
|
|
|
5820
|
+
const scheduleNextScan = () => {
|
|
5821
|
+
if (stopped) return;
|
|
5822
|
+
const runWhenDue = () => {
|
|
5823
|
+
frameCallbackId = null;
|
|
5824
|
+
if (stopped) return;
|
|
5825
|
+
const remaining = scanInterval - (nowMs() - lastScanStartedAt);
|
|
5826
|
+
if (remaining > 1) {
|
|
5827
|
+
timer = setTimeout(() => {
|
|
5828
|
+
timer = null;
|
|
5829
|
+
scheduleNextScan();
|
|
5830
|
+
}, remaining);
|
|
5831
|
+
return;
|
|
5832
|
+
}
|
|
5833
|
+
void loop();
|
|
5834
|
+
};
|
|
5835
|
+
|
|
5836
|
+
if (useVideoFrameCallback) {
|
|
5837
|
+
frameCallbackId = video.requestVideoFrameCallback(runWhenDue);
|
|
5838
|
+
} else {
|
|
5839
|
+
const remaining = Math.max(0, scanInterval - (nowMs() - lastScanStartedAt));
|
|
5840
|
+
timer = setTimeout(() => {
|
|
5841
|
+
timer = null;
|
|
5842
|
+
runWhenDue();
|
|
5843
|
+
}, remaining);
|
|
5844
|
+
}
|
|
5845
|
+
};
|
|
5846
|
+
|
|
4697
5847
|
const loop = async () => {
|
|
4698
5848
|
if (stopped) return;
|
|
4699
5849
|
if (!busy && video.readyState >= 2) {
|
|
@@ -4703,6 +5853,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4703
5853
|
const frameDiagnostics = {};
|
|
4704
5854
|
const capturedFrame = {};
|
|
4705
5855
|
const frameStarted = nowMs();
|
|
5856
|
+
lastScanStartedAt = frameStarted;
|
|
4706
5857
|
let allowAutoEnhance = false;
|
|
4707
5858
|
let allowFinderRecovery = false;
|
|
4708
5859
|
try {
|
|
@@ -4730,11 +5881,13 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4730
5881
|
fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
|
|
4731
5882
|
maxDimension: baseCameraMaxDimension,
|
|
4732
5883
|
canvas: scratchCanvas,
|
|
5884
|
+
_geometryHints: options.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint],
|
|
4733
5885
|
_capturedFrame: capturedFrame,
|
|
4734
5886
|
_observationCollector: observations,
|
|
4735
5887
|
_frameDiagnostics: frameDiagnostics
|
|
4736
5888
|
});
|
|
4737
5889
|
const elapsedMs = nowMs() - frameStarted;
|
|
5890
|
+
updateCameraGeometryHint(frameDiagnostics, result);
|
|
4738
5891
|
emitDiagnostic({
|
|
4739
5892
|
type: "frame",
|
|
4740
5893
|
state: "decoded",
|
|
@@ -4756,6 +5909,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4756
5909
|
if (emitResult(result, capturedFrame, frameDiagnostics)) return;
|
|
4757
5910
|
} catch (error) {
|
|
4758
5911
|
missStreak++;
|
|
5912
|
+
updateCameraGeometryHint(frameDiagnostics);
|
|
4759
5913
|
const fastElapsedMs = nowMs() - frameStarted;
|
|
4760
5914
|
emitDiagnostic({
|
|
4761
5915
|
type: "frame",
|
|
@@ -4771,7 +5925,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4771
5925
|
|
|
4772
5926
|
// Geometry-aware high-resolution retry. A dense QuadQR can look large
|
|
4773
5927
|
// enough in the preview while each individual module has become too
|
|
4774
|
-
// small after the normal
|
|
5928
|
+
// small after the normal 640 px scanner cap. If the fast pass already
|
|
4775
5929
|
// sees at least two convincing finders, spend one bounded retry on a
|
|
4776
5930
|
// higher-resolution copy of the visible camera ROI. Empty frames and
|
|
4777
5931
|
// ordinary small codes never pay this cost.
|
|
@@ -4834,10 +5988,9 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4834
5988
|
}
|
|
4835
5989
|
}
|
|
4836
5990
|
|
|
4837
|
-
//
|
|
4838
|
-
//
|
|
4839
|
-
//
|
|
4840
|
-
// per-channel Auto Color levels correction before any geometry-dependent
|
|
5991
|
+
// Strong color casts can hide finder structure before normal geometry
|
|
5992
|
+
// recovery begins. QuadQR Auto Color retries the exact captured frame with a cheap
|
|
5993
|
+
// per-channel QuadQR Auto Color levels correction before any geometry-dependent
|
|
4841
5994
|
// recovery. This runs only after the normal fast scan fails, and by
|
|
4842
5995
|
// default only on every other missed frame after the first one.
|
|
4843
5996
|
const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
|
|
@@ -4860,7 +6013,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4860
6013
|
type: "method",
|
|
4861
6014
|
state: "trying",
|
|
4862
6015
|
method: "camera-auto-color",
|
|
4863
|
-
message: `Fast scan failed · Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
|
|
6016
|
+
message: `Fast scan failed · QuadQR Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
|
|
4864
6017
|
...frameDiagnostics
|
|
4865
6018
|
});
|
|
4866
6019
|
|
|
@@ -4887,9 +6040,9 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4887
6040
|
// This is intentionally performed on a centered recovery crop.
|
|
4888
6041
|
// A live preview can contain large dark borders, browser UI, a
|
|
4889
6042
|
// monitor bezel or room background. Those pixels completely
|
|
4890
|
-
// change global Auto Color/Otsu statistics even though the QR
|
|
4891
|
-
// itself looks identical to a saved crop.
|
|
4892
|
-
//
|
|
6043
|
+
// change global QuadQR Auto Color/Otsu statistics even though the QR
|
|
6044
|
+
// itself looks identical to a saved crop. QuadQR recovery stays
|
|
6045
|
+
// QR-centric so surrounding scene pixels do not dominate the correction.
|
|
4893
6046
|
blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
|
|
4894
6047
|
whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
|
|
4895
6048
|
highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
|
|
@@ -4935,7 +6088,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4935
6088
|
state: "decoded",
|
|
4936
6089
|
method: "camera-auto-color",
|
|
4937
6090
|
elapsedMs: recoveryElapsedMs,
|
|
4938
|
-
message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
6091
|
+
message: `QuadQR Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
4939
6092
|
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4940
6093
|
});
|
|
4941
6094
|
capturedFrame.enhancedImageData = correctedFrame;
|
|
@@ -4986,7 +6139,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4986
6139
|
type: "method",
|
|
4987
6140
|
state: "failed",
|
|
4988
6141
|
method: profileName,
|
|
4989
|
-
message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
|
|
6142
|
+
message: `QuadQR Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
|
|
4990
6143
|
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4991
6144
|
});
|
|
4992
6145
|
}
|
|
@@ -4996,7 +6149,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
4996
6149
|
type: "method",
|
|
4997
6150
|
state: "failed",
|
|
4998
6151
|
method: "camera-auto-color",
|
|
4999
|
-
message: "All camera Auto Color profiles failed · continuing deeper recovery",
|
|
6152
|
+
message: "All camera QuadQR Auto Color profiles failed · continuing deeper recovery",
|
|
5000
6153
|
...frameDiagnostics
|
|
5001
6154
|
});
|
|
5002
6155
|
}
|
|
@@ -5065,8 +6218,8 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
5065
6218
|
emitDiagnostic({
|
|
5066
6219
|
type: "success",
|
|
5067
6220
|
state: "decoded",
|
|
5068
|
-
method: "multi-frame-
|
|
5069
|
-
message: `Multi-frame
|
|
6221
|
+
method: "multi-frame-confidence-fusion",
|
|
6222
|
+
message: `Multi-frame confidence fusion decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
|
|
5070
6223
|
...frameDiagnostics
|
|
5071
6224
|
});
|
|
5072
6225
|
missStreak = 0;
|
|
@@ -5078,13 +6231,570 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
5078
6231
|
busy = false;
|
|
5079
6232
|
}
|
|
5080
6233
|
}
|
|
5081
|
-
|
|
6234
|
+
scheduleNextScan();
|
|
5082
6235
|
};
|
|
5083
6236
|
|
|
5084
|
-
|
|
6237
|
+
scheduleNextScan();
|
|
5085
6238
|
return { stream, stop, scanNow, video };
|
|
5086
6239
|
}
|
|
5087
6240
|
|
|
6241
|
+
|
|
6242
|
+
function cameraWorkerSupported(options = {}) {
|
|
6243
|
+
if (options.cameraWorker === false) return false;
|
|
6244
|
+
return typeof Worker === "function" &&
|
|
6245
|
+
typeof createImageBitmap === "function" &&
|
|
6246
|
+
typeof OffscreenCanvas === "function";
|
|
6247
|
+
}
|
|
6248
|
+
|
|
6249
|
+
function serializableCameraWorkerOptions(options = {}) {
|
|
6250
|
+
const skip = new Set([
|
|
6251
|
+
"canvas",
|
|
6252
|
+
"constraints",
|
|
6253
|
+
"onDecode",
|
|
6254
|
+
"onDiagnostic",
|
|
6255
|
+
"onResult",
|
|
6256
|
+
"onScanMiss",
|
|
6257
|
+
"cameraWorkerUrl"
|
|
6258
|
+
]);
|
|
6259
|
+
const out = {};
|
|
6260
|
+
for (const [key, value] of Object.entries(options)) {
|
|
6261
|
+
if (skip.has(key) || typeof value === "function" || value == null) continue;
|
|
6262
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
6263
|
+
out[key] = value;
|
|
6264
|
+
continue;
|
|
6265
|
+
}
|
|
6266
|
+
if (Array.isArray(value)) {
|
|
6267
|
+
out[key] = value.map((item) => {
|
|
6268
|
+
if (item && typeof item === "object") return { ...item };
|
|
6269
|
+
return item;
|
|
6270
|
+
});
|
|
6271
|
+
continue;
|
|
6272
|
+
}
|
|
6273
|
+
if (Object.getPrototypeOf(value) === Object.prototype) out[key] = { ...value };
|
|
6274
|
+
}
|
|
6275
|
+
return out;
|
|
6276
|
+
}
|
|
6277
|
+
|
|
6278
|
+
function resolveCameraWorkerUrl(options = {}) {
|
|
6279
|
+
if (options.cameraWorkerUrl) {
|
|
6280
|
+
return new URL(
|
|
6281
|
+
options.cameraWorkerUrl,
|
|
6282
|
+
typeof document !== "undefined" ? document.baseURI : import.meta.url
|
|
6283
|
+
);
|
|
6284
|
+
}
|
|
6285
|
+
return new URL("./camera-scanner-worker.js", import.meta.url);
|
|
6286
|
+
}
|
|
6287
|
+
|
|
6288
|
+
async function initializeCameraWorker(options = {}) {
|
|
6289
|
+
const worker = new Worker(resolveCameraWorkerUrl(options), {
|
|
6290
|
+
type: "module",
|
|
6291
|
+
name: "quadqr-camera-scanner"
|
|
6292
|
+
});
|
|
6293
|
+
let sequence = 0;
|
|
6294
|
+
const pending = new Map();
|
|
6295
|
+
let fatalError = null;
|
|
6296
|
+
|
|
6297
|
+
const rejectPending = (error) => {
|
|
6298
|
+
fatalError = error instanceof Error ? error : new Error(String(error));
|
|
6299
|
+
for (const { reject } of pending.values()) reject(fatalError);
|
|
6300
|
+
pending.clear();
|
|
6301
|
+
};
|
|
6302
|
+
|
|
6303
|
+
worker.addEventListener("message", (event) => {
|
|
6304
|
+
const message = event.data ?? {};
|
|
6305
|
+
const entry = pending.get(message.id);
|
|
6306
|
+
if (!entry) return;
|
|
6307
|
+
pending.delete(message.id);
|
|
6308
|
+
if (message.ok) entry.resolve(message.result);
|
|
6309
|
+
else {
|
|
6310
|
+
const error = new Error(message.error?.message ?? "QuadQR camera worker failed.");
|
|
6311
|
+
error.name = message.error?.name ?? "Error";
|
|
6312
|
+
error.stack = message.error?.stack ?? error.stack;
|
|
6313
|
+
error.debug = message.error?.debug ?? null;
|
|
6314
|
+
entry.reject(error);
|
|
6315
|
+
}
|
|
6316
|
+
});
|
|
6317
|
+
worker.addEventListener("error", (event) => {
|
|
6318
|
+
rejectPending(new Error(event?.message || "QuadQR camera worker crashed."));
|
|
6319
|
+
});
|
|
6320
|
+
worker.addEventListener("messageerror", () => {
|
|
6321
|
+
rejectPending(new Error("QuadQR camera worker returned an unreadable message."));
|
|
6322
|
+
});
|
|
6323
|
+
|
|
6324
|
+
const request = (type, payload = {}, transfer = []) => {
|
|
6325
|
+
if (fatalError) return Promise.reject(fatalError);
|
|
6326
|
+
const id = `qqr-camera-${++sequence}`;
|
|
6327
|
+
return new Promise((resolve, reject) => {
|
|
6328
|
+
pending.set(id, { resolve, reject });
|
|
6329
|
+
try {
|
|
6330
|
+
worker.postMessage({ id, type, ...payload }, transfer);
|
|
6331
|
+
} catch (error) {
|
|
6332
|
+
pending.delete(id);
|
|
6333
|
+
reject(error);
|
|
6334
|
+
}
|
|
6335
|
+
});
|
|
6336
|
+
};
|
|
6337
|
+
|
|
6338
|
+
try {
|
|
6339
|
+
const state = await request("init", { options: serializableCameraWorkerOptions(options) });
|
|
6340
|
+
if (!state?.offscreenCanvas) throw new Error("OffscreenCanvas is unavailable in the QuadQR camera worker.");
|
|
6341
|
+
return {
|
|
6342
|
+
worker,
|
|
6343
|
+
state,
|
|
6344
|
+
request,
|
|
6345
|
+
terminate() {
|
|
6346
|
+
rejectPending(new Error("QuadQR camera worker stopped."));
|
|
6347
|
+
worker.terminate();
|
|
6348
|
+
}
|
|
6349
|
+
};
|
|
6350
|
+
} catch (error) {
|
|
6351
|
+
worker.terminate();
|
|
6352
|
+
throw error;
|
|
6353
|
+
}
|
|
6354
|
+
}
|
|
6355
|
+
|
|
6356
|
+
async function captureCameraBitmap(video, source, maxDimension) {
|
|
6357
|
+
const cap = Math.max(1, Math.round(maxDimension));
|
|
6358
|
+
const videoWidth = Math.max(1, Math.round(video.videoWidth || source.width || 1));
|
|
6359
|
+
const videoHeight = Math.max(1, Math.round(video.videoHeight || source.height || 1));
|
|
6360
|
+
const sx = clampNumber(Math.round(source.x || 0), 0, Math.max(0, videoWidth - 1));
|
|
6361
|
+
const sy = clampNumber(Math.round(source.y || 0), 0, Math.max(0, videoHeight - 1));
|
|
6362
|
+
const sw = Math.max(1, Math.min(videoWidth - sx, Math.round(source.width || videoWidth)));
|
|
6363
|
+
const sh = Math.max(1, Math.min(videoHeight - sy, Math.round(source.height || videoHeight)));
|
|
6364
|
+
const scale = Math.min(1, cap / Math.max(sw, sh));
|
|
6365
|
+
const width = Math.max(1, Math.round(sw * scale));
|
|
6366
|
+
const height = Math.max(1, Math.round(sh * scale));
|
|
6367
|
+
|
|
6368
|
+
// Crop and resize before crossing the worker boundary. Previously every
|
|
6369
|
+
// 640px scan transferred a full 1080p camera bitmap and only then shrank it
|
|
6370
|
+
// inside OffscreenCanvas. On phones that creates unnecessary GPU/memory
|
|
6371
|
+
// pressure and can delay fresh-frame acquisition even though decoding is
|
|
6372
|
+
// off-thread. The fallback preserves compatibility with browsers that do not
|
|
6373
|
+
// implement the resize overload for video-backed ImageBitmap creation.
|
|
6374
|
+
try {
|
|
6375
|
+
const bitmap = await createImageBitmap(video, sx, sy, sw, sh, {
|
|
6376
|
+
resizeWidth: width,
|
|
6377
|
+
resizeHeight: height,
|
|
6378
|
+
resizeQuality: "medium"
|
|
6379
|
+
});
|
|
6380
|
+
return {
|
|
6381
|
+
bitmap,
|
|
6382
|
+
source: { x: 0, y: 0, width: bitmap.width, height: bitmap.height, cropped: Boolean(source.cropped) },
|
|
6383
|
+
originalSource: { ...source },
|
|
6384
|
+
preScaled: true
|
|
6385
|
+
};
|
|
6386
|
+
} catch {
|
|
6387
|
+
const bitmap = await createImageBitmap(video);
|
|
6388
|
+
return {
|
|
6389
|
+
bitmap,
|
|
6390
|
+
source: { ...source },
|
|
6391
|
+
originalSource: { ...source },
|
|
6392
|
+
preScaled: false
|
|
6393
|
+
};
|
|
6394
|
+
}
|
|
6395
|
+
}
|
|
6396
|
+
|
|
6397
|
+
function maximumFinderCount(workerResult) {
|
|
6398
|
+
let count = 0;
|
|
6399
|
+
for (const diagnostic of workerResult?.diagnostics ?? []) {
|
|
6400
|
+
count = Math.max(
|
|
6401
|
+
count,
|
|
6402
|
+
Number(diagnostic?.finderCount) || 0,
|
|
6403
|
+
Number(diagnostic?.bestPass?.finderCount) || 0
|
|
6404
|
+
);
|
|
6405
|
+
for (const pass of diagnostic?.finderPasses ?? []) {
|
|
6406
|
+
count = Math.max(count, Number(pass?.finderCount) || 0);
|
|
6407
|
+
}
|
|
6408
|
+
}
|
|
6409
|
+
return count;
|
|
6410
|
+
}
|
|
6411
|
+
|
|
6412
|
+
async function startCameraScannerWorker(video, options = {}) {
|
|
6413
|
+
assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
|
|
6414
|
+
assert(video, "A video element is required.");
|
|
6415
|
+
|
|
6416
|
+
// Keep fresh-frame scanning independent from the expensive damaged/color
|
|
6417
|
+
// recovery pipeline. Finder detection itself remains JavaScript; WASM is
|
|
6418
|
+
// only an optional grayscale/binary + CRC accelerator beneath both workers.
|
|
6419
|
+
// The fast worker can therefore inspect the newest frame even while the
|
|
6420
|
+
// recovery worker is spending hundreds of milliseconds on an older difficult
|
|
6421
|
+
// frame. No recovery method is removed.
|
|
6422
|
+
const fastWorkerOptions = {
|
|
6423
|
+
...options,
|
|
6424
|
+
cameraPipelineMode: "fast",
|
|
6425
|
+
cameraHighResolutionRecovery: false,
|
|
6426
|
+
cameraAutoColorRecovery: false,
|
|
6427
|
+
autoEnhanceRecovery: false,
|
|
6428
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
6429
|
+
multiFrame: false
|
|
6430
|
+
};
|
|
6431
|
+
const fastWorkerClient = await initializeCameraWorker(fastWorkerOptions);
|
|
6432
|
+
|
|
6433
|
+
let stream = null;
|
|
6434
|
+
try {
|
|
6435
|
+
stream = await navigator.mediaDevices.getUserMedia(
|
|
6436
|
+
options.constraints ?? {
|
|
6437
|
+
audio: false,
|
|
6438
|
+
video: {
|
|
6439
|
+
facingMode: { ideal: "environment" },
|
|
6440
|
+
width: { ideal: 1280 },
|
|
6441
|
+
height: { ideal: 720 },
|
|
6442
|
+
frameRate: { ideal: 30 }
|
|
6443
|
+
}
|
|
6444
|
+
}
|
|
6445
|
+
);
|
|
6446
|
+
await improveCameraTrack(stream);
|
|
6447
|
+
|
|
6448
|
+
video.srcObject = stream;
|
|
6449
|
+
video.setAttribute("playsinline", "");
|
|
6450
|
+
video.muted = true;
|
|
6451
|
+
await video.play();
|
|
6452
|
+
} catch (error) {
|
|
6453
|
+
fastWorkerClient.terminate();
|
|
6454
|
+
for (const track of stream?.getTracks?.() ?? []) track.stop();
|
|
6455
|
+
if (video.srcObject === stream) video.srcObject = null;
|
|
6456
|
+
throw error;
|
|
6457
|
+
}
|
|
6458
|
+
|
|
6459
|
+
const scanInterval = Math.max(24, Number(options.scanInterval ?? 33));
|
|
6460
|
+
const fastCaptureMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 640));
|
|
6461
|
+
const recoveryCaptureMaxDimension = Math.max(
|
|
6462
|
+
fastCaptureMaxDimension,
|
|
6463
|
+
Math.round(options.cameraHighResolutionMaxDimension ?? 960)
|
|
6464
|
+
);
|
|
6465
|
+
const recoveryStrongFinderInterval = Math.max(80, Number(options.cameraRecoveryStrongFinderInterval ?? 120));
|
|
6466
|
+
const recoveryWeakFinderInterval = Math.max(recoveryStrongFinderInterval, Number(options.cameraRecoveryWeakFinderInterval ?? 260));
|
|
6467
|
+
const recoveryNoFinderInterval = Math.max(recoveryWeakFinderInterval, Number(options.cameraRecoveryNoFinderInterval ?? 850));
|
|
6468
|
+
const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
|
|
6469
|
+
typeof video.requestVideoFrameCallback === "function";
|
|
6470
|
+
|
|
6471
|
+
let stopped = false;
|
|
6472
|
+
let busy = false;
|
|
6473
|
+
let recoveryBusy = false;
|
|
6474
|
+
let recoveryWorkerClient = null;
|
|
6475
|
+
let recoveryWorkerPromise = null;
|
|
6476
|
+
let recoveryWorkerFailed = false;
|
|
6477
|
+
let timer = null;
|
|
6478
|
+
let frameCallbackId = null;
|
|
6479
|
+
let lastScanStartedAt = -Infinity;
|
|
6480
|
+
let lastRecoveryStartedAt = -Infinity;
|
|
6481
|
+
let frameNumber = 0;
|
|
6482
|
+
let requestToken = 0;
|
|
6483
|
+
let recoveryToken = 0;
|
|
6484
|
+
|
|
6485
|
+
const diagnosticsEnabled = typeof options.onDiagnostic === "function";
|
|
6486
|
+
const emitDiagnostic = (event) => {
|
|
6487
|
+
if (!diagnosticsEnabled) return;
|
|
6488
|
+
try {
|
|
6489
|
+
options.onDiagnostic({
|
|
6490
|
+
timestamp: Date.now(),
|
|
6491
|
+
frame: event?.frame ?? frameNumber,
|
|
6492
|
+
cameraWorker: true,
|
|
6493
|
+
...event
|
|
6494
|
+
});
|
|
6495
|
+
} catch {
|
|
6496
|
+
// Diagnostics are UI-only and must never interrupt scanning.
|
|
6497
|
+
}
|
|
6498
|
+
};
|
|
6499
|
+
|
|
6500
|
+
const ensureRecoveryWorker = async () => {
|
|
6501
|
+
if (recoveryWorkerClient) return recoveryWorkerClient;
|
|
6502
|
+
if (recoveryWorkerFailed) return null;
|
|
6503
|
+
if (!recoveryWorkerPromise) {
|
|
6504
|
+
recoveryWorkerPromise = initializeCameraWorker({ ...options, cameraPipelineMode: "full" })
|
|
6505
|
+
.then((client) => {
|
|
6506
|
+
if (stopped) {
|
|
6507
|
+
client.terminate();
|
|
6508
|
+
return null;
|
|
6509
|
+
}
|
|
6510
|
+
recoveryWorkerClient = client;
|
|
6511
|
+
return client;
|
|
6512
|
+
})
|
|
6513
|
+
.catch((error) => {
|
|
6514
|
+
recoveryWorkerFailed = true;
|
|
6515
|
+
emitDiagnostic({
|
|
6516
|
+
type: "recovery-worker-error",
|
|
6517
|
+
state: "fallback",
|
|
6518
|
+
method: "camera-fast-worker",
|
|
6519
|
+
message: `Parallel recovery worker unavailable · fast scanner remains active (${error?.message ?? String(error)})`
|
|
6520
|
+
});
|
|
6521
|
+
return null;
|
|
6522
|
+
})
|
|
6523
|
+
.finally(() => {
|
|
6524
|
+
recoveryWorkerPromise = null;
|
|
6525
|
+
});
|
|
6526
|
+
}
|
|
6527
|
+
return recoveryWorkerPromise;
|
|
6528
|
+
};
|
|
6529
|
+
|
|
6530
|
+
const track = stream.getVideoTracks?.()[0];
|
|
6531
|
+
const settings = track?.getSettings?.() ?? {};
|
|
6532
|
+
emitDiagnostic({
|
|
6533
|
+
type: "camera-ready",
|
|
6534
|
+
method: "camera-dual-worker",
|
|
6535
|
+
message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + parallel recovery`,
|
|
6536
|
+
camera: {
|
|
6537
|
+
width: settings.width ?? video.videoWidth,
|
|
6538
|
+
height: settings.height ?? video.videoHeight,
|
|
6539
|
+
frameRate: settings.frameRate ?? null,
|
|
6540
|
+
facingMode: settings.facingMode ?? null
|
|
6541
|
+
},
|
|
6542
|
+
worker: fastWorkerClient.state,
|
|
6543
|
+
scanMaxDimension: fastCaptureMaxDimension,
|
|
6544
|
+
recoveryMaxDimension: recoveryCaptureMaxDimension
|
|
6545
|
+
});
|
|
6546
|
+
|
|
6547
|
+
const stop = () => {
|
|
6548
|
+
if (stopped) return;
|
|
6549
|
+
stopped = true;
|
|
6550
|
+
requestToken++;
|
|
6551
|
+
recoveryToken++;
|
|
6552
|
+
if (timer) clearTimeout(timer);
|
|
6553
|
+
if (frameCallbackId != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
6554
|
+
try { video.cancelVideoFrameCallback(frameCallbackId); } catch {}
|
|
6555
|
+
}
|
|
6556
|
+
timer = null;
|
|
6557
|
+
frameCallbackId = null;
|
|
6558
|
+
fastWorkerClient.terminate();
|
|
6559
|
+
if (recoveryWorkerClient) {
|
|
6560
|
+
recoveryWorkerClient.terminate();
|
|
6561
|
+
recoveryWorkerClient = null;
|
|
6562
|
+
} else if (recoveryWorkerPromise) {
|
|
6563
|
+
void recoveryWorkerPromise.then((client) => client?.terminate()).catch(() => {});
|
|
6564
|
+
}
|
|
6565
|
+
for (const cameraTrack of stream.getTracks()) cameraTrack.stop();
|
|
6566
|
+
if (video.srcObject === stream) video.srcObject = null;
|
|
6567
|
+
};
|
|
6568
|
+
|
|
6569
|
+
// Preserve the historical immediate/manual helper as a synchronous scan of
|
|
6570
|
+
// the current video element. The continuous scanner itself stays off-thread.
|
|
6571
|
+
const scanNow = () => scanVideoFrame(video, {
|
|
6572
|
+
...options,
|
|
6573
|
+
maxDimension: fastCaptureMaxDimension
|
|
6574
|
+
});
|
|
6575
|
+
|
|
6576
|
+
const emitResult = (result, frameMeta = null) => {
|
|
6577
|
+
if (stopped) return true;
|
|
6578
|
+
const normalizedMeta = frameMeta
|
|
6579
|
+
? { frame: frameMeta.frame ?? frameNumber, ...frameMeta }
|
|
6580
|
+
: null;
|
|
6581
|
+
options.onResult?.(result, normalizedMeta);
|
|
6582
|
+
options.onDecode?.(result, normalizedMeta);
|
|
6583
|
+
if (options.stopOnResult ?? true) {
|
|
6584
|
+
stop();
|
|
6585
|
+
return true;
|
|
6586
|
+
}
|
|
6587
|
+
return false;
|
|
6588
|
+
};
|
|
6589
|
+
|
|
6590
|
+
const runRecovery = async (triggerFrame, finderCount) => {
|
|
6591
|
+
if (stopped || recoveryBusy) return;
|
|
6592
|
+
recoveryBusy = true;
|
|
6593
|
+
lastRecoveryStartedAt = nowMs();
|
|
6594
|
+
const token = ++recoveryToken;
|
|
6595
|
+
let bitmap = null;
|
|
6596
|
+
try {
|
|
6597
|
+
const client = await ensureRecoveryWorker();
|
|
6598
|
+
if (stopped || token !== recoveryToken) return;
|
|
6599
|
+
const recoveryClient = client ?? fastWorkerClient;
|
|
6600
|
+
const singleWorkerFallback = !client;
|
|
6601
|
+
|
|
6602
|
+
// Capture only after the recovery execution path is ready. This guarantees
|
|
6603
|
+
// the expensive path receives a fresh frame rather than a bitmap that sat
|
|
6604
|
+
// in memory while a recovery worker was starting.
|
|
6605
|
+
const visibleSource = visibleVideoSourceRect(video, options);
|
|
6606
|
+
const captured = await captureCameraBitmap(video, visibleSource, recoveryCaptureMaxDimension);
|
|
6607
|
+
bitmap = captured.bitmap;
|
|
6608
|
+
if (stopped || token !== recoveryToken) {
|
|
6609
|
+
bitmap.close?.();
|
|
6610
|
+
return;
|
|
6611
|
+
}
|
|
6612
|
+
|
|
6613
|
+
emitDiagnostic({
|
|
6614
|
+
type: "recovery-dispatch",
|
|
6615
|
+
state: "trying",
|
|
6616
|
+
method: "parallel-full-recovery",
|
|
6617
|
+
frame: triggerFrame,
|
|
6618
|
+
finderCount,
|
|
6619
|
+
message: finderCount > 0
|
|
6620
|
+
? `Fast frame saw ${finderCount} finder${finderCount === 1 ? "" : "s"} · full recovery running in parallel`
|
|
6621
|
+
: "Periodic full recovery running in parallel while fast fresh-frame scanning continues"
|
|
6622
|
+
});
|
|
6623
|
+
|
|
6624
|
+
const recoveryPayload = { bitmap, source: captured.source, frame: triggerFrame };
|
|
6625
|
+
if (singleWorkerFallback) recoveryPayload.options = serializableCameraWorkerOptions(options);
|
|
6626
|
+
const workerResult = await recoveryClient.request(
|
|
6627
|
+
singleWorkerFallback ? "scan-full" : "scan",
|
|
6628
|
+
recoveryPayload,
|
|
6629
|
+
[bitmap]
|
|
6630
|
+
);
|
|
6631
|
+
bitmap = null;
|
|
6632
|
+
if (stopped || token !== recoveryToken) return;
|
|
6633
|
+
|
|
6634
|
+
for (const diagnostic of workerResult?.diagnostics ?? []) {
|
|
6635
|
+
emitDiagnostic({
|
|
6636
|
+
...diagnostic,
|
|
6637
|
+
recoveryWorker: !singleWorkerFallback,
|
|
6638
|
+
singleWorkerRecoveryFallback: singleWorkerFallback,
|
|
6639
|
+
frame: triggerFrame
|
|
6640
|
+
});
|
|
6641
|
+
}
|
|
6642
|
+
if (workerResult?.ok) {
|
|
6643
|
+
emitResult(workerResult.result, { ...workerResult.frameMeta, frame: triggerFrame });
|
|
6644
|
+
}
|
|
6645
|
+
} catch (error) {
|
|
6646
|
+
try { bitmap?.close?.(); } catch {}
|
|
6647
|
+
if (!stopped) {
|
|
6648
|
+
emitDiagnostic({
|
|
6649
|
+
type: "recovery-worker-error",
|
|
6650
|
+
state: "error",
|
|
6651
|
+
method: "parallel-full-recovery",
|
|
6652
|
+
frame: triggerFrame,
|
|
6653
|
+
message: error?.message ?? String(error)
|
|
6654
|
+
});
|
|
6655
|
+
}
|
|
6656
|
+
} finally {
|
|
6657
|
+
recoveryBusy = false;
|
|
6658
|
+
}
|
|
6659
|
+
};
|
|
6660
|
+
|
|
6661
|
+
const maybeDispatchRecovery = (workerResult, triggerFrame) => {
|
|
6662
|
+
if (stopped || recoveryBusy) return;
|
|
6663
|
+
const finderCount = maximumFinderCount(workerResult);
|
|
6664
|
+
const elapsed = nowMs() - lastRecoveryStartedAt;
|
|
6665
|
+
const minimumInterval = finderCount >= 2
|
|
6666
|
+
? recoveryStrongFinderInterval
|
|
6667
|
+
: finderCount === 1
|
|
6668
|
+
? recoveryWeakFinderInterval
|
|
6669
|
+
: recoveryNoFinderInterval;
|
|
6670
|
+
if (elapsed >= minimumInterval) void runRecovery(triggerFrame, finderCount);
|
|
6671
|
+
};
|
|
6672
|
+
|
|
6673
|
+
const scheduleNextScan = () => {
|
|
6674
|
+
if (stopped) return;
|
|
6675
|
+
const runWhenDue = () => {
|
|
6676
|
+
frameCallbackId = null;
|
|
6677
|
+
if (stopped) return;
|
|
6678
|
+
const remaining = scanInterval - (nowMs() - lastScanStartedAt);
|
|
6679
|
+
if (remaining > 1) {
|
|
6680
|
+
timer = setTimeout(() => {
|
|
6681
|
+
timer = null;
|
|
6682
|
+
scheduleNextScan();
|
|
6683
|
+
}, remaining);
|
|
6684
|
+
return;
|
|
6685
|
+
}
|
|
6686
|
+
void loop();
|
|
6687
|
+
};
|
|
6688
|
+
|
|
6689
|
+
if (useVideoFrameCallback) {
|
|
6690
|
+
frameCallbackId = video.requestVideoFrameCallback(runWhenDue);
|
|
6691
|
+
} else {
|
|
6692
|
+
const remaining = Math.max(0, scanInterval - (nowMs() - lastScanStartedAt));
|
|
6693
|
+
timer = setTimeout(() => {
|
|
6694
|
+
timer = null;
|
|
6695
|
+
runWhenDue();
|
|
6696
|
+
}, remaining);
|
|
6697
|
+
}
|
|
6698
|
+
};
|
|
6699
|
+
|
|
6700
|
+
const loop = async () => {
|
|
6701
|
+
if (stopped) return;
|
|
6702
|
+
if (!busy && video.readyState >= 2) {
|
|
6703
|
+
busy = true;
|
|
6704
|
+
frameNumber++;
|
|
6705
|
+
const currentFrame = frameNumber;
|
|
6706
|
+
lastScanStartedAt = nowMs();
|
|
6707
|
+
const token = ++requestToken;
|
|
6708
|
+
let bitmap = null;
|
|
6709
|
+
try {
|
|
6710
|
+
const visibleSource = visibleVideoSourceRect(video, options);
|
|
6711
|
+
const captured = await captureCameraBitmap(video, visibleSource, fastCaptureMaxDimension);
|
|
6712
|
+
bitmap = captured.bitmap;
|
|
6713
|
+
if (stopped || token !== requestToken) {
|
|
6714
|
+
bitmap.close?.();
|
|
6715
|
+
return;
|
|
6716
|
+
}
|
|
6717
|
+
const workerResult = await fastWorkerClient.request(
|
|
6718
|
+
"scan",
|
|
6719
|
+
{ bitmap, source: captured.source, frame: currentFrame },
|
|
6720
|
+
[bitmap]
|
|
6721
|
+
);
|
|
6722
|
+
bitmap = null;
|
|
6723
|
+
if (stopped || token !== requestToken) return;
|
|
6724
|
+
|
|
6725
|
+
for (const diagnostic of workerResult?.diagnostics ?? []) {
|
|
6726
|
+
emitDiagnostic({ ...diagnostic, fastWorker: true, frame: currentFrame });
|
|
6727
|
+
}
|
|
6728
|
+
if (workerResult?.ok) {
|
|
6729
|
+
if (emitResult(workerResult.result, { ...workerResult.frameMeta, frame: currentFrame })) return;
|
|
6730
|
+
} else {
|
|
6731
|
+
// Full perspective/color/damage recovery runs independently. Do not
|
|
6732
|
+
// await it here: the next camera callback must remain free to inspect
|
|
6733
|
+
// a newer frame immediately.
|
|
6734
|
+
maybeDispatchRecovery(workerResult, currentFrame);
|
|
6735
|
+
const error = new Error(workerResult?.error?.message ?? "Unable to decode QuadQR frame.");
|
|
6736
|
+
error.name = workerResult?.error?.name ?? "Error";
|
|
6737
|
+
error.debug = workerResult?.error?.debug ?? null;
|
|
6738
|
+
options.onScanMiss?.(error);
|
|
6739
|
+
}
|
|
6740
|
+
} catch (error) {
|
|
6741
|
+
try { bitmap?.close?.(); } catch {}
|
|
6742
|
+
if (!stopped) {
|
|
6743
|
+
emitDiagnostic({
|
|
6744
|
+
type: "worker-error",
|
|
6745
|
+
state: "error",
|
|
6746
|
+
method: "camera-fast-worker",
|
|
6747
|
+
frame: currentFrame,
|
|
6748
|
+
message: error?.message ?? String(error)
|
|
6749
|
+
});
|
|
6750
|
+
options.onScanMiss?.(error);
|
|
6751
|
+
}
|
|
6752
|
+
} finally {
|
|
6753
|
+
busy = false;
|
|
6754
|
+
}
|
|
6755
|
+
}
|
|
6756
|
+
scheduleNextScan();
|
|
6757
|
+
};
|
|
6758
|
+
|
|
6759
|
+
scheduleNextScan();
|
|
6760
|
+
return {
|
|
6761
|
+
stream,
|
|
6762
|
+
stop,
|
|
6763
|
+
scanNow,
|
|
6764
|
+
video,
|
|
6765
|
+
worker: true,
|
|
6766
|
+
workerMode: "dual-pipeline",
|
|
6767
|
+
workerState: fastWorkerClient.state
|
|
6768
|
+
};
|
|
6769
|
+
}
|
|
6770
|
+
|
|
6771
|
+
/**
|
|
6772
|
+
* Start continuous camera scanning. Modern browsers use a dedicated module
|
|
6773
|
+
* worker by default so the complete recovery pipeline can remain enabled
|
|
6774
|
+
* without blocking rendering/input. Unsupported/CSP-restricted environments
|
|
6775
|
+
* automatically fall back to the original main-thread scanner.
|
|
6776
|
+
*/
|
|
6777
|
+
export async function startCameraScanner(video, options = {}) {
|
|
6778
|
+
if (cameraWorkerSupported(options)) {
|
|
6779
|
+
try {
|
|
6780
|
+
return await startCameraScannerWorker(video, options);
|
|
6781
|
+
} catch (error) {
|
|
6782
|
+
if (options.cameraWorkerRequired) throw error;
|
|
6783
|
+
try {
|
|
6784
|
+
options.onDiagnostic?.({
|
|
6785
|
+
timestamp: Date.now(),
|
|
6786
|
+
frame: 0,
|
|
6787
|
+
type: "worker-fallback",
|
|
6788
|
+
state: "fallback",
|
|
6789
|
+
method: "camera-main-thread",
|
|
6790
|
+
message: `Background scanner unavailable · using main-thread fallback (${error?.message ?? String(error)})`
|
|
6791
|
+
});
|
|
6792
|
+
} catch {}
|
|
6793
|
+
}
|
|
6794
|
+
}
|
|
6795
|
+
return startCameraScannerMainThread(video, options);
|
|
6796
|
+
}
|
|
6797
|
+
|
|
5088
6798
|
export function rectifyDetectedCode(imageData, options = {}) {
|
|
5089
6799
|
const candidates = detectCodeGeometry(imageData, {
|
|
5090
6800
|
minVersion: options.minVersion ?? MIN_VERSION,
|
|
@@ -5129,6 +6839,9 @@ export const internals = Object.freeze({
|
|
|
5129
6839
|
restoreLogicalOrder,
|
|
5130
6840
|
cellsToSymbolConfidences,
|
|
5131
6841
|
decodeRsAdaptive,
|
|
6842
|
+
classifierFromPaletteRgb,
|
|
6843
|
+
classifyRgb,
|
|
6844
|
+
observationDataAgreement,
|
|
5132
6845
|
encodeProtectedHeader,
|
|
5133
6846
|
decodeProtectedHeader,
|
|
5134
6847
|
selectBestFrameObservation,
|