quadqr-js 1.0.1 → 1.2.0
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 +11 -0
- package/README.md +1222 -1057
- package/SPECIFICATION.md +332 -0
- package/bin/quadqr.js +102 -13
- package/dist/esm/benchmark.js +80 -0
- package/dist/esm/node.js +13 -1
- package/dist/esm/quadqr.js +2714 -182
- package/dist/esm/vision.js +597 -52
- package/dist/quadqr.js +3315 -244
- package/dist/quadqr.min.js +3322 -241
- package/docs/API.md +545 -309
- package/docs/BROWSER_CDN.md +187 -137
- package/docs/CLI.md +150 -76
- package/docs/GETTING_STARTED.md +247 -173
- package/docs/NODE.md +137 -123
- package/docs/README.md +59 -41
- package/docs/WASM.md +67 -67
- package/package.json +100 -95
- package/types/benchmark.d.ts +4 -3
- package/types/index.d.ts +238 -2
- package/types/node.d.ts +2 -0
package/dist/esm/quadqr.js
CHANGED
|
@@ -29,9 +29,15 @@ import {
|
|
|
29
29
|
} from "./geometry.js";
|
|
30
30
|
import {
|
|
31
31
|
detectCodeGeometry,
|
|
32
|
+
computeHomography,
|
|
33
|
+
projectPoint,
|
|
32
34
|
samplePerspectiveMatrix,
|
|
33
35
|
rectifyImageData,
|
|
34
36
|
sampleObservedPalette,
|
|
37
|
+
spatiallyNormalizeRgbGrid,
|
|
38
|
+
autoToneContrastColorRgbGrid,
|
|
39
|
+
autoToneContrastColorImageData,
|
|
40
|
+
autoColorImageData,
|
|
35
41
|
findActiveBounds,
|
|
36
42
|
sampleAxisAlignedGrid
|
|
37
43
|
} from "./vision.js";
|
|
@@ -53,6 +59,28 @@ export const MIN_VERSION = 1;
|
|
|
53
59
|
export const MAX_VERSION = 40;
|
|
54
60
|
export const DEFAULT_ECC_LEVEL = "M";
|
|
55
61
|
|
|
62
|
+
export const RENDER_MODES = Object.freeze({
|
|
63
|
+
SCREEN: "screen",
|
|
64
|
+
PRINT: "print"
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Print-safe defaults deliberately use darker, more ink-tolerant primaries and
|
|
68
|
+
// a true white background. Applications can still override individual colors.
|
|
69
|
+
export const PRINT_PALETTE = Object.freeze({
|
|
70
|
+
black: "#000000",
|
|
71
|
+
white: "#ffffff",
|
|
72
|
+
red: "#d71932",
|
|
73
|
+
green: "#087f3e",
|
|
74
|
+
blue: "#174ea6"
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const COMPRESSION_MODES = Object.freeze({
|
|
78
|
+
NONE: "none",
|
|
79
|
+
AUTO: "auto",
|
|
80
|
+
LZ: "lz"
|
|
81
|
+
});
|
|
82
|
+
export const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
|
|
83
|
+
|
|
56
84
|
// Data cells are the four values 0..3. Structural white deliberately shares
|
|
57
85
|
// value 3 with data-white because both render and scan identically.
|
|
58
86
|
export const CELL = Object.freeze({
|
|
@@ -117,6 +145,22 @@ const COMPACT_ECC_LEVELS = Object.freeze({
|
|
|
117
145
|
const CRC_BYTES = 4;
|
|
118
146
|
const TEXT_FLAG = 1;
|
|
119
147
|
const SECURE_FLAG = 1 << 3;
|
|
148
|
+
const EXTENDED_PAYLOAD_FLAG = 1 << 4;
|
|
149
|
+
const SIGNED_FLAG = 1 << 5;
|
|
150
|
+
|
|
151
|
+
// Internal payload extension envelope. This is deliberately not a public
|
|
152
|
+
// payload mode or content-type system. It exists only when compression or
|
|
153
|
+
// signing needs metadata around an otherwise normal text/binary payload.
|
|
154
|
+
const PAYLOAD_ENVELOPE_VERSION = 2;
|
|
155
|
+
const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
|
|
156
|
+
const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
|
|
157
|
+
const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
|
|
158
|
+
const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
|
|
159
|
+
const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
|
|
160
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
|
|
161
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
|
|
162
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
|
|
163
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
|
|
120
164
|
const ECC_SHIFT = 1;
|
|
121
165
|
const ECC_MASK = 0b00000110;
|
|
122
166
|
|
|
@@ -195,6 +239,368 @@ function readU32be(bytes, offset = 0) {
|
|
|
195
239
|
) >>> 0;
|
|
196
240
|
}
|
|
197
241
|
|
|
242
|
+
function u16be(value) {
|
|
243
|
+
const v = Number(value) >>> 0;
|
|
244
|
+
assert(v <= 0xffff, "Value does not fit in uint16.");
|
|
245
|
+
return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readU16be(bytes, offset = 0) {
|
|
249
|
+
return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeCompressionMode(value = "none") {
|
|
253
|
+
const key = String(value).toLowerCase();
|
|
254
|
+
assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
|
|
255
|
+
return key;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function asBytes(input) {
|
|
259
|
+
if (input instanceof Uint8Array) return input;
|
|
260
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
261
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
262
|
+
return new Uint8Array(input);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Portable LZSS-style compressor used by QuadQR payload compression. */
|
|
266
|
+
export function compressPayload(input) {
|
|
267
|
+
const bytes = asBytes(input);
|
|
268
|
+
if (bytes.length < 4) return bytes.slice();
|
|
269
|
+
const out = [];
|
|
270
|
+
const recent = new Map();
|
|
271
|
+
let pos = 0;
|
|
272
|
+
|
|
273
|
+
const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
|
|
274
|
+
const remember = (i) => {
|
|
275
|
+
const key = keyAt(i);
|
|
276
|
+
if (key < 0) return;
|
|
277
|
+
let list = recent.get(key);
|
|
278
|
+
if (!list) recent.set(key, list = []);
|
|
279
|
+
list.push(i);
|
|
280
|
+
while (list.length > 32) list.shift();
|
|
281
|
+
while (list.length && i - list[0] > 4095) list.shift();
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
while (pos < bytes.length) {
|
|
285
|
+
const flagIndex = out.length;
|
|
286
|
+
out.push(0);
|
|
287
|
+
let flags = 0;
|
|
288
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
289
|
+
let bestLength = 0;
|
|
290
|
+
let bestOffset = 0;
|
|
291
|
+
const key = keyAt(pos);
|
|
292
|
+
const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
|
|
293
|
+
for (let ci = candidates.length - 1; ci >= 0; ci--) {
|
|
294
|
+
const candidate = candidates[ci];
|
|
295
|
+
const offset = pos - candidate;
|
|
296
|
+
if (offset <= 0 || offset > 4095) continue;
|
|
297
|
+
let length = 0;
|
|
298
|
+
while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
|
|
299
|
+
if (length >= 3 && length > bestLength) {
|
|
300
|
+
bestLength = length;
|
|
301
|
+
bestOffset = offset;
|
|
302
|
+
if (length === 18) break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (bestLength >= 3) {
|
|
307
|
+
flags |= (1 << token);
|
|
308
|
+
const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
|
|
309
|
+
out.push((encoded >>> 8) & 0xff, encoded & 0xff);
|
|
310
|
+
for (let i = 0; i < bestLength; i++) remember(pos + i);
|
|
311
|
+
pos += bestLength;
|
|
312
|
+
} else {
|
|
313
|
+
out.push(bytes[pos]);
|
|
314
|
+
remember(pos);
|
|
315
|
+
pos++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
out[flagIndex] = flags;
|
|
319
|
+
}
|
|
320
|
+
return Uint8Array.from(out);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function decompressPayload(input, expectedLength = null) {
|
|
324
|
+
const bytes = asBytes(input);
|
|
325
|
+
const out = [];
|
|
326
|
+
let pos = 0;
|
|
327
|
+
while (pos < bytes.length) {
|
|
328
|
+
const flags = bytes[pos++];
|
|
329
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
330
|
+
if (flags & (1 << token)) {
|
|
331
|
+
assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
|
|
332
|
+
const encoded = (bytes[pos++] << 8) | bytes[pos++];
|
|
333
|
+
const offset = (encoded >>> 4) & 0x0fff;
|
|
334
|
+
const length = (encoded & 0x0f) + 3;
|
|
335
|
+
assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
|
|
336
|
+
for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
|
|
337
|
+
} else {
|
|
338
|
+
out.push(bytes[pos++]);
|
|
339
|
+
}
|
|
340
|
+
if (expectedLength != null && out.length >= expectedLength) break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
|
|
344
|
+
return Uint8Array.from(out);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function prepareCompressedBody(payload, compression = "none") {
|
|
348
|
+
const mode = normalizeCompressionMode(compression);
|
|
349
|
+
if (mode === "none") return { body: payload, compression: "none" };
|
|
350
|
+
const compressed = compressPayload(payload);
|
|
351
|
+
if (mode === "auto" && compressed.length >= payload.length - 2) {
|
|
352
|
+
return { body: payload, compression: "none" };
|
|
353
|
+
}
|
|
354
|
+
return { body: compressed, compression: "lz" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
function normalizeSigningKeyId(value) {
|
|
359
|
+
if (value == null || value === false || value === "") return new Uint8Array(0);
|
|
360
|
+
const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
|
|
361
|
+
assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
|
|
362
|
+
return bytes;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
|
|
366
|
+
assert(keyIdLength <= 0xff, "Signing key ID is too long.");
|
|
367
|
+
assert(publicKeyLength <= 0xff, "Signing public key is too large.");
|
|
368
|
+
assert(signatureLength <= 0xff, "Signature is too large.");
|
|
369
|
+
const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
|
|
370
|
+
header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
|
|
371
|
+
header[4] = version;
|
|
372
|
+
header[5] = flags & 0xff;
|
|
373
|
+
header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
|
|
374
|
+
header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
|
|
375
|
+
header.set(u32be(originalLength), 8);
|
|
376
|
+
header[12] = keyIdLength;
|
|
377
|
+
header[13] = publicKeyLength;
|
|
378
|
+
header[14] = signatureLength;
|
|
379
|
+
header[15] = 0;
|
|
380
|
+
return header;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isPayloadEnvelope(input) {
|
|
384
|
+
const bytes = asBytes(input);
|
|
385
|
+
return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function packPayloadEnvelope(input, options = {}) {
|
|
389
|
+
const payload = asBytes(input);
|
|
390
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "none");
|
|
391
|
+
const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
|
|
392
|
+
const header = makePayloadEnvelopeHeader({
|
|
393
|
+
compression: prepared.compression,
|
|
394
|
+
flags,
|
|
395
|
+
originalLength: payload.length
|
|
396
|
+
});
|
|
397
|
+
return concatBytes(header, prepared.body);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function parsePayloadEnvelope(input) {
|
|
401
|
+
const container = asBytes(input);
|
|
402
|
+
assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
|
|
403
|
+
const version = container[4];
|
|
404
|
+
assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
|
|
405
|
+
const flags = container[5];
|
|
406
|
+
const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
|
|
407
|
+
assert(compression != null, `Unknown payload compression id ${container[6]}.`);
|
|
408
|
+
const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
|
|
409
|
+
const originalLength = readU32be(container, 8);
|
|
410
|
+
const metadataLength = container[12];
|
|
411
|
+
const publicKeyLength = container[13];
|
|
412
|
+
const signatureLength = container[14];
|
|
413
|
+
let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
|
|
414
|
+
const endMetadata = offset + metadataLength;
|
|
415
|
+
const endPublicKey = endMetadata + publicKeyLength;
|
|
416
|
+
const endSignature = endPublicKey + signatureLength;
|
|
417
|
+
assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
|
|
418
|
+
const metadataBytes = container.slice(offset, endMetadata);
|
|
419
|
+
const publicKey = container.slice(endMetadata, endPublicKey);
|
|
420
|
+
const signature = container.slice(endPublicKey, endSignature);
|
|
421
|
+
const stored = container.slice(endSignature);
|
|
422
|
+
const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
|
|
423
|
+
assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
|
|
424
|
+
const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
|
|
425
|
+
const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
|
|
426
|
+
|
|
427
|
+
// v1 used byte 12 for a human-readable signer label and always embedded
|
|
428
|
+
// the public key. v2 uses the same field for a compact key ID and makes
|
|
429
|
+
// public-key embedding optional.
|
|
430
|
+
const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
431
|
+
const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
432
|
+
const keyIdHex = version >= 2 && metadataLength
|
|
433
|
+
? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
|
|
434
|
+
: null;
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
container,
|
|
438
|
+
version,
|
|
439
|
+
compression,
|
|
440
|
+
compressed: compression !== "none",
|
|
441
|
+
originalBytes: originalLength,
|
|
442
|
+
storedBytes: stored.length,
|
|
443
|
+
savedBytes: originalLength - stored.length,
|
|
444
|
+
payload,
|
|
445
|
+
signed,
|
|
446
|
+
signer: legacySigner,
|
|
447
|
+
keyId,
|
|
448
|
+
keyIdHex,
|
|
449
|
+
publicKey,
|
|
450
|
+
hasEmbeddedPublicKey: publicKey.length > 0,
|
|
451
|
+
signature,
|
|
452
|
+
signatureAlgorithm,
|
|
453
|
+
signedBytes,
|
|
454
|
+
signatureVerified: signed ? null : undefined,
|
|
455
|
+
signatureTrusted: signed ? null : undefined
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function subtleCrypto() {
|
|
460
|
+
const subtle = globalThis.crypto?.subtle;
|
|
461
|
+
assert(subtle, "Web Crypto is required for QuadQR signing.");
|
|
462
|
+
return subtle;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function normalizeEd25519PublicKey(key) {
|
|
466
|
+
const subtle = subtleCrypto();
|
|
467
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
468
|
+
if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
|
|
469
|
+
const bytes = asBytes(key);
|
|
470
|
+
assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
|
|
471
|
+
return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function normalizeEd25519PrivateKey(key) {
|
|
475
|
+
const subtle = subtleCrypto();
|
|
476
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
477
|
+
if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
|
|
478
|
+
const bytes = asBytes(key);
|
|
479
|
+
return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function deriveSigningKeyId(publicKey, bytes = 8) {
|
|
483
|
+
const subtle = subtleCrypto();
|
|
484
|
+
const normalized = await normalizeEd25519PublicKey(publicKey);
|
|
485
|
+
const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
|
|
486
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
|
|
487
|
+
const count = Math.max(4, Math.min(16, Math.floor(bytes)));
|
|
488
|
+
return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function generateSigningKeyPair() {
|
|
492
|
+
const subtle = subtleCrypto();
|
|
493
|
+
const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
494
|
+
const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
|
|
495
|
+
const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
|
|
496
|
+
const keyId = await deriveSigningKeyId(pair.publicKey);
|
|
497
|
+
return {
|
|
498
|
+
privateKey: pair.privateKey,
|
|
499
|
+
publicKey: pair.publicKey,
|
|
500
|
+
privateKeyPkcs8,
|
|
501
|
+
publicKeyBytes,
|
|
502
|
+
keyId,
|
|
503
|
+
algorithm: "Ed25519"
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function packSignedPayloadEnvelope(input, options = {}) {
|
|
508
|
+
const payload = asBytes(input);
|
|
509
|
+
assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
|
|
510
|
+
const subtle = subtleCrypto();
|
|
511
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
|
|
512
|
+
const keyIdBytes = normalizeSigningKeyId(options.keyId);
|
|
513
|
+
|
|
514
|
+
let publicKeyBytes = new Uint8Array(0);
|
|
515
|
+
if (options.embedPublicKey) {
|
|
516
|
+
assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
|
|
517
|
+
const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
|
|
518
|
+
publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
|
|
522
|
+
(prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
|
|
523
|
+
(publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
|
|
524
|
+
const header = makePayloadEnvelopeHeader({
|
|
525
|
+
compression: prepared.compression,
|
|
526
|
+
flags,
|
|
527
|
+
originalLength: payload.length,
|
|
528
|
+
keyIdLength: keyIdBytes.length,
|
|
529
|
+
publicKeyLength: publicKeyBytes.length,
|
|
530
|
+
signatureLength: 64,
|
|
531
|
+
signatureAlgorithm: "Ed25519"
|
|
532
|
+
});
|
|
533
|
+
const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
|
|
534
|
+
const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
|
|
535
|
+
const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
|
|
536
|
+
assert(signature.length === 64, "Unexpected Ed25519 signature length.");
|
|
537
|
+
return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function lookupTrustedSigningKey(trustedKeys, keyId) {
|
|
541
|
+
if (!trustedKeys || !keyId) return null;
|
|
542
|
+
if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
|
|
543
|
+
if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
548
|
+
const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
|
|
549
|
+
assert(parsed.signed, "QuadQR payload is not signed.");
|
|
550
|
+
assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
|
|
551
|
+
const subtle = subtleCrypto();
|
|
552
|
+
|
|
553
|
+
let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
|
|
554
|
+
let trustSource = trusted ? "external" : null;
|
|
555
|
+
|
|
556
|
+
// Legacy v1 symbols embedded the public key by default, so preserve their
|
|
557
|
+
// historical self-verification behavior. v2 requires explicit opt-in.
|
|
558
|
+
if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
|
|
559
|
+
trusted = parsed.publicKey;
|
|
560
|
+
trustSource = "embedded";
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
assert(
|
|
564
|
+
trusted,
|
|
565
|
+
parsed.keyId
|
|
566
|
+
? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
|
|
567
|
+
: "A trusted Ed25519 public key is required to verify this signed QuadQR."
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const publicKey = await normalizeEd25519PublicKey(trusted);
|
|
571
|
+
if (trustSource === "external" && parsed.publicKey.length) {
|
|
572
|
+
const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
|
|
573
|
+
assert(
|
|
574
|
+
trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
|
|
575
|
+
"Embedded signing key does not match the trusted public key."
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
|
|
580
|
+
return {
|
|
581
|
+
verified,
|
|
582
|
+
trusted: verified && trustSource === "external",
|
|
583
|
+
trustSource,
|
|
584
|
+
keyId: parsed.keyId ?? null
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function prepareOptionalCompressedPayload(input, compression = "none") {
|
|
589
|
+
const payload = asBytes(input);
|
|
590
|
+
const mode = normalizeCompressionMode(compression);
|
|
591
|
+
if (mode === "none") return { payload, extended: false, compression: "none" };
|
|
592
|
+
const prepared = prepareCompressedBody(payload, mode);
|
|
593
|
+
// Auto mode is truly zero-overhead when compression does not help.
|
|
594
|
+
if (mode === "auto" && prepared.compression === "none") {
|
|
595
|
+
return { payload, extended: false, compression: "none" };
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
|
|
599
|
+
extended: true,
|
|
600
|
+
compression: prepared.compression
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
198
604
|
let CRC_TABLE = null;
|
|
199
605
|
let CRC32_ACCELERATOR = null;
|
|
200
606
|
|
|
@@ -259,9 +665,11 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
|
|
|
259
665
|
return out;
|
|
260
666
|
}
|
|
261
667
|
|
|
262
|
-
function flagsFor(text, eccLevel, secure = false) {
|
|
668
|
+
function flagsFor(text, eccLevel, secure = false, extended = false, signed = false) {
|
|
263
669
|
return (text ? TEXT_FLAG : 0) |
|
|
264
670
|
(secure ? SECURE_FLAG : 0) |
|
|
671
|
+
(extended ? EXTENDED_PAYLOAD_FLAG : 0) |
|
|
672
|
+
(signed ? SIGNED_FLAG : 0) |
|
|
265
673
|
((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
|
|
266
674
|
}
|
|
267
675
|
|
|
@@ -801,6 +1209,11 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
801
1209
|
payloadBytes: meta.payloadBytes,
|
|
802
1210
|
sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
|
|
803
1211
|
secure: Boolean(meta.secure),
|
|
1212
|
+
compressed: meta.compression != null && meta.compression !== "none",
|
|
1213
|
+
compression: meta.compression ?? "none",
|
|
1214
|
+
signed: Boolean(meta.signed),
|
|
1215
|
+
signingKeyId: meta.signingKeyId ?? null,
|
|
1216
|
+
hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
|
|
804
1217
|
requiresDecryption: Boolean(meta.secure),
|
|
805
1218
|
security: meta.security ?? null,
|
|
806
1219
|
meaningfulCells: meta.meaningfulCells,
|
|
@@ -819,20 +1232,17 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
819
1232
|
};
|
|
820
1233
|
}
|
|
821
1234
|
|
|
822
|
-
|
|
823
|
-
assert(typeof text === "string", "encodeText expects a string.");
|
|
824
|
-
return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
export function encodeBytes(input, options = {}) {
|
|
1235
|
+
function encodePreparedBytes(input, options = {}) {
|
|
828
1236
|
if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
|
|
829
1237
|
throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
|
|
830
1238
|
}
|
|
831
1239
|
|
|
832
|
-
const payload =
|
|
1240
|
+
const payload = asBytes(input);
|
|
833
1241
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
834
1242
|
const secure = Boolean(options.secure);
|
|
835
|
-
const
|
|
1243
|
+
const extended = Boolean(options.extended);
|
|
1244
|
+
const signed = Boolean(options.signed);
|
|
1245
|
+
const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed);
|
|
836
1246
|
const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
|
|
837
1247
|
const layout = createLayout(version);
|
|
838
1248
|
const header = makeHeader(payload.length, flags, version);
|
|
@@ -852,6 +1262,11 @@ export function encodeBytes(input, options = {}) {
|
|
|
852
1262
|
payloadBytes: payload.length,
|
|
853
1263
|
sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
|
|
854
1264
|
secure,
|
|
1265
|
+
extended,
|
|
1266
|
+
signed,
|
|
1267
|
+
compression: options.compressionMetadata ?? "none",
|
|
1268
|
+
signingKeyId: options.signingKeyId ?? null,
|
|
1269
|
+
hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
|
|
855
1270
|
security: options.securityMetadata ?? null,
|
|
856
1271
|
meaningfulCells: meaningfulCells.length,
|
|
857
1272
|
eccLevel,
|
|
@@ -862,6 +1277,59 @@ export function encodeBytes(input, options = {}) {
|
|
|
862
1277
|
});
|
|
863
1278
|
}
|
|
864
1279
|
|
|
1280
|
+
export function encodeText(text, options = {}) {
|
|
1281
|
+
assert(typeof text === "string", "encodeText expects a string.");
|
|
1282
|
+
const source = getTextEncoder().encode(text);
|
|
1283
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1284
|
+
return encodePreparedBytes(prepared.payload, {
|
|
1285
|
+
...options,
|
|
1286
|
+
text: true,
|
|
1287
|
+
extended: prepared.extended,
|
|
1288
|
+
sourcePayloadBytes: source.length,
|
|
1289
|
+
compressionMetadata: prepared.compression
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
export function encodeBytes(input, options = {}) {
|
|
1294
|
+
const source = asBytes(input);
|
|
1295
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1296
|
+
return encodePreparedBytes(prepared.payload, {
|
|
1297
|
+
...options,
|
|
1298
|
+
text: Boolean(options.text),
|
|
1299
|
+
extended: prepared.extended,
|
|
1300
|
+
sourcePayloadBytes: source.length,
|
|
1301
|
+
compressionMetadata: prepared.compression
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
|
|
1306
|
+
export function encodeUint8Array(input, options = {}) {
|
|
1307
|
+
return encodeBytes(asBytes(input), options);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Encode and sign a normal payload using an internal Ed25519 envelope. */
|
|
1311
|
+
export async function encodeSignedBytes(input, options = {}) {
|
|
1312
|
+
const source = asBytes(input);
|
|
1313
|
+
const envelope = await packSignedPayloadEnvelope(source, options);
|
|
1314
|
+
const parsed = parsePayloadEnvelope(envelope);
|
|
1315
|
+
return encodePreparedBytes(envelope, {
|
|
1316
|
+
...options,
|
|
1317
|
+
text: Boolean(options.text),
|
|
1318
|
+
extended: true,
|
|
1319
|
+
signed: true,
|
|
1320
|
+
sourcePayloadBytes: source.length,
|
|
1321
|
+
compressionMetadata: parsed.compression,
|
|
1322
|
+
signingKeyId: parsed.keyId,
|
|
1323
|
+
hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
export async function encodeSignedText(text, options = {}) {
|
|
1328
|
+
assert(typeof text === "string", "encodeSignedText expects a string.");
|
|
1329
|
+
return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
|
|
865
1333
|
/**
|
|
866
1334
|
* Encode text using the optional QuadQR Secure Payload v1 layer.
|
|
867
1335
|
* This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
|
|
@@ -873,32 +1341,75 @@ export async function encodeSecureText(text, options = {}) {
|
|
|
873
1341
|
|
|
874
1342
|
/** Encode arbitrary bytes using password or raw 256-bit key security. */
|
|
875
1343
|
export async function encodeSecureBytes(input, options = {}) {
|
|
876
|
-
const sourcePayload =
|
|
1344
|
+
const sourcePayload = asBytes(input);
|
|
877
1345
|
const security = options.security ?? {};
|
|
878
|
-
|
|
879
|
-
|
|
1346
|
+
let protectedPayload = sourcePayload;
|
|
1347
|
+
let extended = false;
|
|
1348
|
+
let signed = false;
|
|
1349
|
+
let compression = "none";
|
|
1350
|
+
let signingKeyId = null;
|
|
1351
|
+
let hasEmbeddedPublicKey = false;
|
|
1352
|
+
|
|
1353
|
+
if (options.signing) {
|
|
1354
|
+
protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
|
|
1355
|
+
...options,
|
|
1356
|
+
...options.signing,
|
|
1357
|
+
compression: options.compression ?? "auto"
|
|
1358
|
+
});
|
|
1359
|
+
const parsed = parsePayloadEnvelope(protectedPayload);
|
|
1360
|
+
extended = true;
|
|
1361
|
+
signed = true;
|
|
1362
|
+
compression = parsed.compression;
|
|
1363
|
+
signingKeyId = parsed.keyId;
|
|
1364
|
+
hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
|
|
1365
|
+
} else {
|
|
1366
|
+
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
|
|
1367
|
+
protectedPayload = prepared.payload;
|
|
1368
|
+
extended = prepared.extended;
|
|
1369
|
+
compression = prepared.compression;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const encrypted = await encryptSecurePayload(protectedPayload, security);
|
|
1373
|
+
return encodePreparedBytes(encrypted.envelope, {
|
|
880
1374
|
...options,
|
|
881
1375
|
text: Boolean(options.text),
|
|
882
1376
|
secure: true,
|
|
1377
|
+
extended,
|
|
1378
|
+
signed,
|
|
883
1379
|
sourcePayloadBytes: sourcePayload.length,
|
|
1380
|
+
compressionMetadata: compression,
|
|
1381
|
+
signingKeyId,
|
|
1382
|
+
hasEmbeddedPublicKey,
|
|
884
1383
|
securityMetadata: encrypted.metadata
|
|
885
1384
|
});
|
|
886
1385
|
}
|
|
887
1386
|
|
|
888
1387
|
/**
|
|
889
1388
|
* Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
|
|
890
|
-
*
|
|
1389
|
+
* Compression and signatures are automatically unwrapped after decryption.
|
|
891
1390
|
*/
|
|
892
1391
|
export async function decryptDecoded(result, security = {}) {
|
|
893
1392
|
assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
|
|
894
1393
|
const plaintext = await decryptSecurePayload(result.payload, security);
|
|
895
1394
|
const isText = (result.flags & TEXT_FLAG) !== 0;
|
|
1395
|
+
const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
1396
|
+
const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
|
|
1397
|
+
const payload = envelope ? envelope.payload : plaintext;
|
|
896
1398
|
return {
|
|
897
1399
|
...result,
|
|
898
1400
|
encryptedPayload: result.payload,
|
|
899
1401
|
encryptedPayloadBytes: result.payload.length,
|
|
900
|
-
|
|
901
|
-
|
|
1402
|
+
protectedPayload: envelope ? plaintext : undefined,
|
|
1403
|
+
payload,
|
|
1404
|
+
text: isText ? getTextDecoder().decode(payload) : null,
|
|
1405
|
+
compressed: envelope?.compressed ?? false,
|
|
1406
|
+
compression: envelope?.compression ?? "none",
|
|
1407
|
+
signed: envelope?.signed ?? result.signed ?? false,
|
|
1408
|
+
signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
|
|
1409
|
+
signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
|
|
1410
|
+
signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
|
|
1411
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
|
|
1412
|
+
signer: envelope?.signer ?? result.signer ?? null,
|
|
902
1413
|
decrypted: true,
|
|
903
1414
|
requiresDecryption: false,
|
|
904
1415
|
security: { ...result.security, decrypted: true }
|
|
@@ -1179,7 +1690,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1179
1690
|
|
|
1180
1691
|
const isText = (flags & TEXT_FLAG) !== 0;
|
|
1181
1692
|
const secure = (flags & SECURE_FLAG) !== 0;
|
|
1693
|
+
const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
1694
|
+
const signedFlag = (flags & SIGNED_FLAG) !== 0;
|
|
1182
1695
|
const security = secure ? inspectSecureEnvelope(payload) : null;
|
|
1696
|
+
const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
|
|
1697
|
+
const applicationPayload = envelope ? envelope.payload : payload;
|
|
1183
1698
|
const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
|
|
1184
1699
|
const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
|
|
1185
1700
|
return {
|
|
@@ -1204,8 +1719,17 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1204
1719
|
correctedHeaderSymbols: headerDecoded.correctedSymbols,
|
|
1205
1720
|
correctedBodySymbols: bodyDecoded.correctedSymbols,
|
|
1206
1721
|
correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
|
|
1207
|
-
payload,
|
|
1208
|
-
|
|
1722
|
+
protectedPayload: envelope ? payload : undefined,
|
|
1723
|
+
payload: secure ? payload : applicationPayload,
|
|
1724
|
+
text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
|
|
1725
|
+
compressed: envelope?.compressed ?? false,
|
|
1726
|
+
compression: envelope?.compression ?? "none",
|
|
1727
|
+
signed: envelope?.signed ?? signedFlag,
|
|
1728
|
+
signatureVerified: envelope?.signatureVerified,
|
|
1729
|
+
signatureTrusted: envelope?.signatureTrusted,
|
|
1730
|
+
signingKeyId: envelope?.keyId ?? null,
|
|
1731
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
|
|
1732
|
+
signer: envelope?.signer ?? null,
|
|
1209
1733
|
secure,
|
|
1210
1734
|
encrypted: secure,
|
|
1211
1735
|
decrypted: false,
|
|
@@ -1250,6 +1774,27 @@ export function decodeMatrix(inputMatrix, options = {}) {
|
|
|
1250
1774
|
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
1251
1775
|
}
|
|
1252
1776
|
|
|
1777
|
+
|
|
1778
|
+
/** Decode a matrix and return only its application payload bytes. */
|
|
1779
|
+
export function decodeUint8Array(inputMatrix, options = {}) {
|
|
1780
|
+
return decodeMatrix(inputMatrix, options).payload;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/** Verify a signed decode result and return the result with verification state attached. */
|
|
1784
|
+
export async function verifyDecodedSignature(result, options = {}) {
|
|
1785
|
+
assert(result?.signed, "Decode result is not signed.");
|
|
1786
|
+
const source = result.protectedPayload;
|
|
1787
|
+
assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
|
|
1788
|
+
const verification = await verifyPayloadEnvelopeSignature(source, options);
|
|
1789
|
+
return {
|
|
1790
|
+
...result,
|
|
1791
|
+
signatureVerified: verification.verified,
|
|
1792
|
+
signatureTrusted: verification.trusted,
|
|
1793
|
+
signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
|
|
1794
|
+
signatureTrustSource: verification.trustSource
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1253
1798
|
function hexToRgb(hex) {
|
|
1254
1799
|
const clean = hex.replace("#", "");
|
|
1255
1800
|
assert(clean.length === 6, `Invalid hex color ${hex}.`);
|
|
@@ -1264,6 +1809,18 @@ function resolvePalette(palette = {}) {
|
|
|
1264
1809
|
return { ...DEFAULT_PALETTE, ...palette };
|
|
1265
1810
|
}
|
|
1266
1811
|
|
|
1812
|
+
function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
|
|
1813
|
+
const value = String(mode).toLowerCase();
|
|
1814
|
+
assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
|
|
1815
|
+
return value;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function resolveRenderPalette(options = {}) {
|
|
1819
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
1820
|
+
const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
|
|
1821
|
+
return { ...base, ...(options.palette ?? {}) };
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1267
1824
|
function paletteRgb(palette = {}) {
|
|
1268
1825
|
const p = resolvePalette(palette);
|
|
1269
1826
|
return {
|
|
@@ -1428,18 +1985,347 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
|
|
|
1428
1985
|
}
|
|
1429
1986
|
}
|
|
1430
1987
|
|
|
1988
|
+
|
|
1989
|
+
/** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
|
|
1990
|
+
export function estimateSafeLogoSize(codeOrMatrix, options = {}) {
|
|
1991
|
+
const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
|
|
1992
|
+
const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
|
|
1993
|
+
const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
|
|
1994
|
+
const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
|
|
1995
|
+
const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
|
|
1996
|
+
const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
|
|
1997
|
+
const clearPenalty = clearBackground ? 0.90 : 1;
|
|
1998
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
1999
|
+
const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
|
|
2000
|
+
const versionPenalty = code?.version === 1 ? 0.88 : 1;
|
|
2001
|
+
return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/**
|
|
2005
|
+
* Empirically search for the largest logo ratio that still decodes from the
|
|
2006
|
+
* dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
|
|
2007
|
+
*/
|
|
2008
|
+
export function findMaxSafeLogoSize(code, options = {}) {
|
|
2009
|
+
assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
|
|
2010
|
+
const logo = options.logo;
|
|
2011
|
+
const source = logo?.source ?? logo;
|
|
2012
|
+
assert(source?.data && source?.width && source?.height,
|
|
2013
|
+
"findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
|
|
2014
|
+
let low = Math.max(0.05, Number(options.minSize ?? 0.05));
|
|
2015
|
+
let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
|
|
2016
|
+
let best = 0;
|
|
2017
|
+
const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
|
|
2018
|
+
for (let i = 0; i < iterations; i++) {
|
|
2019
|
+
const probe = (low + high) / 2;
|
|
2020
|
+
try {
|
|
2021
|
+
const image = renderToImageData(code, {
|
|
2022
|
+
...options,
|
|
2023
|
+
logo: {
|
|
2024
|
+
...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
|
|
2025
|
+
size: probe
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
|
|
2029
|
+
if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
|
|
2030
|
+
best = probe;
|
|
2031
|
+
low = probe;
|
|
2032
|
+
} catch {
|
|
2033
|
+
high = probe;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
return {
|
|
2037
|
+
safeSize: best || estimateSafeLogoSize(code, options),
|
|
2038
|
+
testedMax: Number(options.maxSize ?? 0.28),
|
|
2039
|
+
iterations,
|
|
2040
|
+
empirical: best > 0
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
export function getPrintGuidance(codeOrMatrix, options = {}) {
|
|
2045
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
|
|
2046
|
+
assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
|
|
2047
|
+
const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
|
|
2048
|
+
const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
|
|
2049
|
+
const totalModules = matrix.length + quietZone * 2;
|
|
2050
|
+
const moduleSizeMm = physicalSizeMm / totalModules;
|
|
2051
|
+
const dpi = Number(options.dpi ?? 300);
|
|
2052
|
+
const modulePixels = moduleSizeMm / 25.4 * dpi;
|
|
2053
|
+
const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
|
|
2054
|
+
return {
|
|
2055
|
+
mode: "print",
|
|
2056
|
+
physicalSizeMm,
|
|
2057
|
+
quietZone,
|
|
2058
|
+
moduleSizeMm,
|
|
2059
|
+
modulePixelsAtDpi: modulePixels,
|
|
2060
|
+
dpi,
|
|
2061
|
+
recommendedMinimumModuleMm,
|
|
2062
|
+
meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
|
|
2063
|
+
recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
|
|
2064
|
+
palette: PRINT_PALETTE,
|
|
2065
|
+
recommendations: [
|
|
2066
|
+
"Use classic rendering for print unless a styled output has been physically validated.",
|
|
2067
|
+
"Keep at least a 4-module quiet zone.",
|
|
2068
|
+
`Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
|
|
2069
|
+
"Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
|
|
2070
|
+
]
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
|
|
2075
|
+
if (!options.logo) return null;
|
|
2076
|
+
|
|
2077
|
+
const nested = (
|
|
2078
|
+
options.logo &&
|
|
2079
|
+
typeof options.logo === "object" &&
|
|
2080
|
+
Object.prototype.hasOwnProperty.call(options.logo, "source")
|
|
2081
|
+
) ? options.logo : null;
|
|
2082
|
+
const source = nested ? nested.source : options.logo;
|
|
2083
|
+
if (!source) return null;
|
|
2084
|
+
|
|
2085
|
+
const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
|
|
2086
|
+
const autoSize = String(requestedSize).toLowerCase() === "auto";
|
|
2087
|
+
const rawSize = Number(requestedSize);
|
|
2088
|
+
const sizeRatio = autoSize
|
|
2089
|
+
? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
|
|
2090
|
+
: Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
|
|
2091
|
+
const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
|
|
2092
|
+
const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
|
|
2093
|
+
const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
|
|
2094
|
+
const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
|
|
2095
|
+
const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
|
|
2096
|
+
const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
|
|
2097
|
+
|
|
2098
|
+
const codePixels = matrixSize * moduleSize;
|
|
2099
|
+
const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
|
|
2100
|
+
const logoPixels = Math.max(1, codePixels * sizeRatio);
|
|
2101
|
+
const center = canvasPixels / 2;
|
|
2102
|
+
const paddingPixels = paddingModules * moduleSize;
|
|
2103
|
+
const backgroundPixels = logoPixels + paddingPixels * 2;
|
|
2104
|
+
|
|
2105
|
+
return {
|
|
2106
|
+
source,
|
|
2107
|
+
sizeRatio,
|
|
2108
|
+
clearBackground,
|
|
2109
|
+
backgroundColor,
|
|
2110
|
+
logoX: center - logoPixels / 2,
|
|
2111
|
+
logoY: center - logoPixels / 2,
|
|
2112
|
+
logoSize: logoPixels,
|
|
2113
|
+
backgroundX: center - backgroundPixels / 2,
|
|
2114
|
+
backgroundY: center - backgroundPixels / 2,
|
|
2115
|
+
backgroundSize: backgroundPixels,
|
|
2116
|
+
backgroundRadius: radiusModules * moduleSize
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function canvasLogoSource(source) {
|
|
2121
|
+
if (!source || typeof source === "string") return source;
|
|
2122
|
+
if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
|
|
2123
|
+
let scratch = null;
|
|
2124
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
2125
|
+
scratch = new OffscreenCanvas(source.width, source.height);
|
|
2126
|
+
} else if (typeof document !== "undefined") {
|
|
2127
|
+
scratch = document.createElement("canvas");
|
|
2128
|
+
scratch.width = source.width;
|
|
2129
|
+
scratch.height = source.height;
|
|
2130
|
+
}
|
|
2131
|
+
if (!scratch) return source;
|
|
2132
|
+
const ctx = scratch.getContext("2d");
|
|
2133
|
+
if (!ctx) return source;
|
|
2134
|
+
const pixels = source.data instanceof Uint8ClampedArray
|
|
2135
|
+
? source.data
|
|
2136
|
+
: new Uint8ClampedArray(source.data);
|
|
2137
|
+
ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
|
|
2138
|
+
return scratch;
|
|
2139
|
+
}
|
|
2140
|
+
return source;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function logoSourceDimensions(source) {
|
|
2144
|
+
const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
|
|
2145
|
+
const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
|
|
2146
|
+
if (!(Number(width) > 0 && Number(height) > 0)) return null;
|
|
2147
|
+
return { width: Number(width), height: Number(height) };
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function containRect(x, y, size, dimensions) {
|
|
2151
|
+
if (!dimensions) return { x, y, width: size, height: size };
|
|
2152
|
+
const scale = Math.min(size / dimensions.width, size / dimensions.height);
|
|
2153
|
+
const width = dimensions.width * scale;
|
|
2154
|
+
const height = dimensions.height * scale;
|
|
2155
|
+
return {
|
|
2156
|
+
x: x + (size - width) / 2,
|
|
2157
|
+
y: y + (size - height) / 2,
|
|
2158
|
+
width,
|
|
2159
|
+
height
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function drawLogoToCanvas(ctx, logo, palette) {
|
|
2164
|
+
if (!logo) return;
|
|
2165
|
+
if (logo.clearBackground) {
|
|
2166
|
+
ctx.fillStyle = logo.backgroundColor || palette.white;
|
|
2167
|
+
roundedRectPath(
|
|
2168
|
+
ctx,
|
|
2169
|
+
logo.backgroundX,
|
|
2170
|
+
logo.backgroundY,
|
|
2171
|
+
logo.backgroundSize,
|
|
2172
|
+
logo.backgroundSize,
|
|
2173
|
+
logo.backgroundRadius
|
|
2174
|
+
);
|
|
2175
|
+
ctx.fill();
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
const source = canvasLogoSource(logo.source);
|
|
2179
|
+
assert(
|
|
2180
|
+
source && typeof source !== "string",
|
|
2181
|
+
"renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
|
|
2182
|
+
);
|
|
2183
|
+
const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
|
|
2184
|
+
const previousSmoothing = ctx.imageSmoothingEnabled;
|
|
2185
|
+
ctx.imageSmoothingEnabled = true;
|
|
2186
|
+
ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
|
|
2187
|
+
ctx.imageSmoothingEnabled = previousSmoothing;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function blendImagePixel(data, width, x, y, rgb, alpha) {
|
|
2191
|
+
if (x < 0 || y < 0 || x >= width) return;
|
|
2192
|
+
const height = data.length / 4 / width;
|
|
2193
|
+
if (y >= height) return;
|
|
2194
|
+
const p = (y * width + x) * 4;
|
|
2195
|
+
const a = Math.max(0, Math.min(1, alpha));
|
|
2196
|
+
data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
|
|
2197
|
+
data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
|
|
2198
|
+
data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
|
|
2199
|
+
data[p + 3] = 255;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function drawLogoToImageData(data, width, logo) {
|
|
2203
|
+
if (!logo) return;
|
|
2204
|
+
if (logo.clearBackground) {
|
|
2205
|
+
const rgb = hexToRgb(logo.backgroundColor);
|
|
2206
|
+
fillImageRoundedRect(
|
|
2207
|
+
data,
|
|
2208
|
+
width,
|
|
2209
|
+
Math.round(logo.backgroundX),
|
|
2210
|
+
Math.round(logo.backgroundY),
|
|
2211
|
+
Math.round(logo.backgroundSize),
|
|
2212
|
+
Math.round(logo.backgroundSize),
|
|
2213
|
+
Math.round(logo.backgroundRadius),
|
|
2214
|
+
rgb
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
const source = logo.source;
|
|
2219
|
+
assert(
|
|
2220
|
+
source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
|
|
2221
|
+
"renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
|
|
2222
|
+
);
|
|
2223
|
+
const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
|
|
2224
|
+
width: source.width,
|
|
2225
|
+
height: source.height
|
|
2226
|
+
});
|
|
2227
|
+
const x0 = Math.round(dest.x);
|
|
2228
|
+
const y0 = Math.round(dest.y);
|
|
2229
|
+
const destWidth = Math.max(1, Math.round(dest.width));
|
|
2230
|
+
const destHeight = Math.max(1, Math.round(dest.height));
|
|
2231
|
+
const src = source.data;
|
|
2232
|
+
|
|
2233
|
+
for (let y = 0; y < destHeight; y++) {
|
|
2234
|
+
const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
|
|
2235
|
+
for (let x = 0; x < destWidth; x++) {
|
|
2236
|
+
const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
|
|
2237
|
+
const p = (sy * source.width + sx) * 4;
|
|
2238
|
+
const alpha = (src[p + 3] ?? 255) / 255;
|
|
2239
|
+
if (alpha <= 0) continue;
|
|
2240
|
+
blendImagePixel(data, width, x0 + x, y0 + y, {
|
|
2241
|
+
r: src[p],
|
|
2242
|
+
g: src[p + 1],
|
|
2243
|
+
b: src[p + 2]
|
|
2244
|
+
}, alpha);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function escapeXmlAttribute(value) {
|
|
2250
|
+
return String(value)
|
|
2251
|
+
.replaceAll("&", "&")
|
|
2252
|
+
.replaceAll('"', """)
|
|
2253
|
+
.replaceAll("<", "<")
|
|
2254
|
+
.replaceAll(">", ">");
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function svgRect(x, y, width, height, fill, radius = 0) {
|
|
2258
|
+
const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
|
|
2259
|
+
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function svgLogoHref(source) {
|
|
2263
|
+
if (typeof source === "string") return source;
|
|
2264
|
+
if (typeof source?.src === "string" && source.src) return source.src;
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
function resolveRenderSizing(options, matrixSize) {
|
|
2269
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2270
|
+
const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
|
|
2271
|
+
const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
|
|
2272
|
+
? Math.max(4, requestedQuietZone)
|
|
2273
|
+
: requestedQuietZone;
|
|
2274
|
+
const totalModules = matrixSize + quietZone * 2;
|
|
2275
|
+
const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
|
|
2276
|
+
const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
|
|
2277
|
+
|
|
2278
|
+
let pixelSize;
|
|
2279
|
+
if (hasImageSize) {
|
|
2280
|
+
const requested = Number(options.imageSize);
|
|
2281
|
+
assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
|
|
2282
|
+
pixelSize = Math.max(totalModules, Math.round(requested));
|
|
2283
|
+
} else if (hasModuleSize) {
|
|
2284
|
+
const requested = Number(options.moduleSize);
|
|
2285
|
+
assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
|
|
2286
|
+
const moduleSize = Math.max(1, Math.floor(requested));
|
|
2287
|
+
pixelSize = totalModules * moduleSize;
|
|
2288
|
+
} else {
|
|
2289
|
+
// A practical export default that is large enough for screens/social use
|
|
2290
|
+
// while keeping the API independent from the encoded matrix version.
|
|
2291
|
+
pixelSize = Math.max(totalModules, 720);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
return {
|
|
2295
|
+
quietZone,
|
|
2296
|
+
totalModules,
|
|
2297
|
+
pixelSize,
|
|
2298
|
+
moduleSize: pixelSize / totalModules,
|
|
2299
|
+
mode
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function rasterModuleRect(row, col, quietZone, moduleSize) {
|
|
2304
|
+
const x0 = Math.round((col + quietZone) * moduleSize);
|
|
2305
|
+
const y0 = Math.round((row + quietZone) * moduleSize);
|
|
2306
|
+
const x1 = Math.round((col + quietZone + 1) * moduleSize);
|
|
2307
|
+
const y1 = Math.round((row + quietZone + 1) * moduleSize);
|
|
2308
|
+
return {
|
|
2309
|
+
x: x0,
|
|
2310
|
+
y: y0,
|
|
2311
|
+
width: Math.max(1, x1 - x0),
|
|
2312
|
+
height: Math.max(1, y1 - y0)
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
|
|
1431
2316
|
export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
1432
2317
|
assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
|
|
1433
2318
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
1434
2319
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
1435
|
-
const
|
|
1436
|
-
const
|
|
1437
|
-
const palette =
|
|
1438
|
-
const paletteValues = paletteRgb(
|
|
1439
|
-
const
|
|
2320
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2321
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2322
|
+
const palette = resolveRenderPalette(options);
|
|
2323
|
+
const paletteValues = paletteRgb(palette);
|
|
2324
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2325
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
1440
2326
|
const layout = renderLayoutForMatrix(matrix);
|
|
1441
2327
|
const size = matrix.length;
|
|
1442
|
-
const
|
|
2328
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
1443
2329
|
|
|
1444
2330
|
canvas.width = pixelSize;
|
|
1445
2331
|
canvas.height = pixelSize;
|
|
@@ -1452,12 +2338,13 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
1452
2338
|
for (let c = 0; c < size; c++) {
|
|
1453
2339
|
const cell = matrix[r][c];
|
|
1454
2340
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
1455
|
-
const
|
|
1456
|
-
const y
|
|
2341
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
2342
|
+
const { x, y, width: cellWidth, height: cellHeight } = rect;
|
|
2343
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
1457
2344
|
|
|
1458
2345
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
1459
2346
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1460
|
-
ctx.fillRect(x, y,
|
|
2347
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1461
2348
|
continue;
|
|
1462
2349
|
}
|
|
1463
2350
|
|
|
@@ -1467,28 +2354,29 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
1467
2354
|
const opacity = depthOpacity(r, c, cell);
|
|
1468
2355
|
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
1469
2356
|
ctx.fillStyle = rgbCss(base);
|
|
1470
|
-
ctx.fillRect(x, y,
|
|
2357
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1471
2358
|
|
|
1472
|
-
if (
|
|
1473
|
-
const edge = Math.max(1, Math.floor(
|
|
2359
|
+
if (cellSize >= 6) {
|
|
2360
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
1474
2361
|
const highlight = mixRgb(base, paletteValues.white, 0.17);
|
|
1475
2362
|
const shadow = mixRgb(base, paletteValues.black, 0.12);
|
|
1476
2363
|
ctx.fillStyle = rgbCss(highlight);
|
|
1477
|
-
ctx.fillRect(x, y,
|
|
1478
|
-
ctx.fillRect(x, y, edge,
|
|
2364
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
2365
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
1479
2366
|
ctx.fillStyle = rgbCss(shadow);
|
|
1480
|
-
ctx.fillRect(x, y +
|
|
1481
|
-
ctx.fillRect(x +
|
|
2367
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
2368
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
1482
2369
|
}
|
|
1483
2370
|
continue;
|
|
1484
2371
|
}
|
|
1485
2372
|
|
|
1486
2373
|
if (style === RENDER_STYLES.SOFT) {
|
|
1487
|
-
const inset =
|
|
1488
|
-
const width =
|
|
1489
|
-
const
|
|
2374
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
2375
|
+
const width = cellWidth - inset * 2;
|
|
2376
|
+
const height = cellHeight - inset * 2;
|
|
2377
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
1490
2378
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1491
|
-
roundedRectPath(ctx, x + inset, y + inset, width,
|
|
2379
|
+
roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
|
|
1492
2380
|
ctx.fill();
|
|
1493
2381
|
continue;
|
|
1494
2382
|
}
|
|
@@ -1497,37 +2385,40 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
1497
2385
|
// Keep the exact encoded color across the full module, then limit the
|
|
1498
2386
|
// recessed effect to a narrow edge band so center sampling stays intact.
|
|
1499
2387
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1500
|
-
ctx.fillRect(x, y,
|
|
2388
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1501
2389
|
if (cell === CELL.WHITE) continue;
|
|
1502
2390
|
|
|
1503
|
-
const edge = safeStyleEdge(
|
|
2391
|
+
const edge = safeStyleEdge(cellSize);
|
|
1504
2392
|
if (edge > 0) {
|
|
1505
2393
|
const fx = insetStyleColors(rgb, paletteValues);
|
|
1506
2394
|
ctx.fillStyle = rgbCss(fx.shadow);
|
|
1507
|
-
ctx.fillRect(x, y,
|
|
1508
|
-
ctx.fillRect(x, y, edge,
|
|
2395
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
2396
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
1509
2397
|
ctx.fillStyle = rgbCss(fx.highlight);
|
|
1510
|
-
ctx.fillRect(x, y +
|
|
1511
|
-
ctx.fillRect(x +
|
|
2398
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
2399
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
1512
2400
|
}
|
|
1513
2401
|
}
|
|
1514
2402
|
}
|
|
1515
2403
|
}
|
|
2404
|
+
drawLogoToCanvas(ctx, logo, palette);
|
|
1516
2405
|
return canvas;
|
|
1517
2406
|
}
|
|
1518
2407
|
|
|
1519
2408
|
export function renderToImageData(codeOrMatrix, options = {}) {
|
|
1520
2409
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
1521
2410
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
1522
|
-
const
|
|
1523
|
-
const
|
|
1524
|
-
const
|
|
1525
|
-
const
|
|
2411
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2412
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2413
|
+
const resolvedPalette = resolveRenderPalette(options);
|
|
2414
|
+
const palette = paletteRgb(resolvedPalette);
|
|
2415
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2416
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
1526
2417
|
const layout = renderLayoutForMatrix(matrix);
|
|
1527
2418
|
const size = matrix.length;
|
|
1528
|
-
const pixelSize = (size + quietZone * 2) * moduleSize;
|
|
1529
2419
|
const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
|
|
1530
2420
|
const white = palette.white;
|
|
2421
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
|
|
1531
2422
|
|
|
1532
2423
|
for (let i = 0; i < pixelSize * pixelSize; i++) {
|
|
1533
2424
|
const p = i * 4;
|
|
@@ -1542,80 +2433,226 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
1542
2433
|
const cell = matrix[r][c];
|
|
1543
2434
|
const rgb = cellRgb(cell, palette);
|
|
1544
2435
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
1545
|
-
const
|
|
1546
|
-
const x0 =
|
|
2436
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
2437
|
+
const x0 = rect.x;
|
|
2438
|
+
const y0 = rect.y;
|
|
2439
|
+
const cellWidth = rect.width;
|
|
2440
|
+
const cellHeight = rect.height;
|
|
2441
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
1547
2442
|
|
|
1548
2443
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
1549
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
2444
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
1550
2445
|
continue;
|
|
1551
2446
|
}
|
|
1552
2447
|
|
|
1553
2448
|
if (style === RENDER_STYLES.DEPTH) {
|
|
1554
2449
|
const opacity = depthOpacity(r, c, cell);
|
|
1555
2450
|
const base = mixRgb(white, rgb, opacity);
|
|
1556
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
1557
|
-
if (
|
|
1558
|
-
const edge = Math.max(1, Math.floor(
|
|
2451
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
|
|
2452
|
+
if (cellSize >= 6) {
|
|
2453
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
1559
2454
|
const highlight = mixRgb(base, white, 0.17);
|
|
1560
2455
|
const shadow = mixRgb(base, palette.black, 0.12);
|
|
1561
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
1562
|
-
fillImageRect(data, pixelSize, x0, y0, edge,
|
|
1563
|
-
fillImageRect(data, pixelSize, x0, y0 +
|
|
1564
|
-
fillImageRect(data, pixelSize, x0 +
|
|
2456
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
|
|
2457
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
|
|
2458
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
|
|
2459
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
|
|
1565
2460
|
}
|
|
1566
2461
|
continue;
|
|
1567
2462
|
}
|
|
1568
2463
|
|
|
1569
2464
|
if (style === RENDER_STYLES.SOFT) {
|
|
1570
|
-
const inset =
|
|
1571
|
-
const width =
|
|
1572
|
-
const
|
|
1573
|
-
|
|
2465
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
2466
|
+
const width = cellWidth - inset * 2;
|
|
2467
|
+
const height = cellHeight - inset * 2;
|
|
2468
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
2469
|
+
fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
|
|
1574
2470
|
continue;
|
|
1575
2471
|
}
|
|
1576
2472
|
|
|
1577
2473
|
if (style === RENDER_STYLES.INSET) {
|
|
1578
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
2474
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
1579
2475
|
if (cell === CELL.WHITE) continue;
|
|
1580
|
-
const edge = safeStyleEdge(
|
|
2476
|
+
const edge = safeStyleEdge(cellSize);
|
|
1581
2477
|
if (edge > 0) {
|
|
1582
2478
|
const fx = insetStyleColors(rgb, palette);
|
|
1583
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
1584
|
-
fillImageRect(data, pixelSize, x0, y0, edge,
|
|
1585
|
-
fillImageRect(data, pixelSize, x0, y0 +
|
|
1586
|
-
fillImageRect(data, pixelSize, x0 +
|
|
2479
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
|
|
2480
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
|
|
2481
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
|
|
2482
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
|
|
1587
2483
|
}
|
|
1588
2484
|
}
|
|
1589
2485
|
}
|
|
1590
2486
|
}
|
|
1591
2487
|
|
|
2488
|
+
drawLogoToImageData(data, pixelSize, logo);
|
|
2489
|
+
|
|
1592
2490
|
return { width: pixelSize, height: pixelSize, data };
|
|
1593
2491
|
}
|
|
1594
2492
|
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
2493
|
+
/** Render a QuadQR code/matrix to a standalone SVG string. */
|
|
2494
|
+
export function renderToSVG(codeOrMatrix, options = {}) {
|
|
2495
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
2496
|
+
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
2497
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2498
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2499
|
+
const palette = resolveRenderPalette(options);
|
|
2500
|
+
const paletteValues = paletteRgb(palette);
|
|
2501
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2502
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2503
|
+
const layout = renderLayoutForMatrix(matrix);
|
|
2504
|
+
const size = matrix.length;
|
|
2505
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
2506
|
+
const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
|
|
1604
2507
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
2508
|
+
for (let r = 0; r < size; r++) {
|
|
2509
|
+
for (let c = 0; c < size; c++) {
|
|
2510
|
+
const cell = matrix[r][c];
|
|
2511
|
+
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
2512
|
+
const x = (c + quietZone) * moduleSize;
|
|
2513
|
+
const y = (r + quietZone) * moduleSize;
|
|
2514
|
+
const fill = cellColor(cell, palette);
|
|
1611
2515
|
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
2516
|
+
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2517
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
1616
2520
|
|
|
1617
|
-
|
|
1618
|
-
|
|
2521
|
+
const rgb = cellRgb(cell, paletteValues);
|
|
2522
|
+
|
|
2523
|
+
if (style === RENDER_STYLES.DEPTH) {
|
|
2524
|
+
const opacity = depthOpacity(r, c, cell);
|
|
2525
|
+
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
2526
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
|
|
2527
|
+
if (moduleSize >= 6) {
|
|
2528
|
+
const edge = Math.max(1, Math.floor(moduleSize * 0.08));
|
|
2529
|
+
const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
|
|
2530
|
+
const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
|
|
2531
|
+
body.push(svgRect(x, y, moduleSize, edge, highlight));
|
|
2532
|
+
body.push(svgRect(x, y, edge, moduleSize, highlight));
|
|
2533
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
|
|
2534
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
|
|
2535
|
+
}
|
|
2536
|
+
continue;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
if (style === RENDER_STYLES.SOFT) {
|
|
2540
|
+
const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
|
|
2541
|
+
const width = moduleSize - inset * 2;
|
|
2542
|
+
const radius = Math.max(1, Math.floor(moduleSize * 0.20));
|
|
2543
|
+
body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
|
|
2544
|
+
continue;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
if (style === RENDER_STYLES.INSET) {
|
|
2548
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
2549
|
+
if (cell === CELL.WHITE) continue;
|
|
2550
|
+
const edge = safeStyleEdge(moduleSize);
|
|
2551
|
+
if (edge > 0) {
|
|
2552
|
+
const fx = insetStyleColors(rgb, paletteValues);
|
|
2553
|
+
const shadow = rgbCss(fx.shadow);
|
|
2554
|
+
const highlight = rgbCss(fx.highlight);
|
|
2555
|
+
body.push(svgRect(x, y, moduleSize, edge, shadow));
|
|
2556
|
+
body.push(svgRect(x, y, edge, moduleSize, shadow));
|
|
2557
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
|
|
2558
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
if (logo) {
|
|
2565
|
+
if (logo.clearBackground) {
|
|
2566
|
+
body.push(svgRect(
|
|
2567
|
+
logo.backgroundX,
|
|
2568
|
+
logo.backgroundY,
|
|
2569
|
+
logo.backgroundSize,
|
|
2570
|
+
logo.backgroundSize,
|
|
2571
|
+
logo.backgroundColor,
|
|
2572
|
+
logo.backgroundRadius
|
|
2573
|
+
));
|
|
2574
|
+
}
|
|
2575
|
+
const href = svgLogoHref(logo.source);
|
|
2576
|
+
assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
|
|
2577
|
+
body.push(
|
|
2578
|
+
`<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
|
|
2579
|
+
);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
return [
|
|
2583
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
2584
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
|
|
2585
|
+
...body,
|
|
2586
|
+
"</svg>"
|
|
2587
|
+
].join("\n");
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function clampNumber(value, min, max) {
|
|
2591
|
+
return Math.max(min, Math.min(max, value));
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
function identityColorTransform(rgb) {
|
|
2595
|
+
return rgb;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function makeWhiteBalanceTransform(observed) {
|
|
2599
|
+
const black = observed.black;
|
|
2600
|
+
const white = observed.white;
|
|
2601
|
+
const ranges = {
|
|
2602
|
+
r: Math.max(32, white.r - black.r),
|
|
2603
|
+
g: Math.max(32, white.g - black.g),
|
|
2604
|
+
b: Math.max(32, white.b - black.b)
|
|
2605
|
+
};
|
|
2606
|
+
|
|
2607
|
+
return (rgb) => ({
|
|
2608
|
+
// Normalize the camera's observed black/white points per channel. This
|
|
2609
|
+
// boosts a suppressed blue channel under warm/yellow illumination without
|
|
2610
|
+
// changing the QuadQR palette or payload capacity.
|
|
2611
|
+
r: clampNumber((rgb.r - black.r) * 255 / ranges.r, -96, 384),
|
|
2612
|
+
g: clampNumber((rgb.g - black.g) * 255 / ranges.g, -96, 384),
|
|
2613
|
+
b: clampNumber((rgb.b - black.b) * 255 / ranges.b, -96, 384)
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function classifierFromPaletteRgb(observed, mode = "raw") {
|
|
2618
|
+
const transform = mode === "balanced" || mode === "hue"
|
|
2619
|
+
? makeWhiteBalanceTransform(observed)
|
|
2620
|
+
: identityColorTransform;
|
|
2621
|
+
const entries = [
|
|
2622
|
+
{ cell: CELL.BLACK, rgb: transform(observed.black) },
|
|
2623
|
+
{ cell: CELL.WHITE, rgb: transform(observed.white) },
|
|
2624
|
+
{ cell: CELL.RED, rgb: transform(observed.red) },
|
|
2625
|
+
{ cell: CELL.GREEN, rgb: transform(observed.green) },
|
|
2626
|
+
{ cell: CELL.BLUE, rgb: transform(observed.blue) }
|
|
2627
|
+
];
|
|
2628
|
+
const colored = entries.filter(({ cell }) => cell === CELL.RED || cell === CELL.GREEN || cell === CELL.BLUE);
|
|
2629
|
+
const chroma = (rgb) => Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
|
|
2630
|
+
const minimumColorChroma = Math.min(...colored.map(({ rgb }) => chroma(rgb)));
|
|
2631
|
+
return {
|
|
2632
|
+
entries,
|
|
2633
|
+
transform,
|
|
2634
|
+
mode,
|
|
2635
|
+
dataMode: mode === "hue" ? "hue" : "distance",
|
|
2636
|
+
whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
|
|
2637
|
+
minimumColorChroma
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function colorDistanceSq(a, b) {
|
|
2642
|
+
const dr = a.r - b.r;
|
|
2643
|
+
const dg = a.g - b.g;
|
|
2644
|
+
const db = a.b - b.b;
|
|
2645
|
+
return dr * dr + dg * dg + db * db;
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
function classifyRgb(rgb, classifier) {
|
|
2649
|
+
const transformed = classifier.transform(rgb);
|
|
2650
|
+
let best = null;
|
|
2651
|
+
let bestDistanceSq = Infinity;
|
|
2652
|
+
let secondDistanceSq = Infinity;
|
|
2653
|
+
|
|
2654
|
+
for (const candidate of classifier.entries) {
|
|
2655
|
+
const distanceSq = colorDistanceSq(transformed, candidate.rgb);
|
|
1619
2656
|
if (distanceSq < bestDistanceSq) {
|
|
1620
2657
|
secondDistanceSq = bestDistanceSq;
|
|
1621
2658
|
bestDistanceSq = distanceSq;
|
|
@@ -1627,17 +2664,47 @@ function classifyRgb(rgb, classifier) {
|
|
|
1627
2664
|
|
|
1628
2665
|
const distance = Math.sqrt(bestDistanceSq);
|
|
1629
2666
|
const secondDistance = Number.isFinite(secondDistanceSq) ? Math.sqrt(secondDistanceSq) : distance + 1;
|
|
1630
|
-
// Confidence is the normalized separation between the nearest and second
|
|
1631
|
-
// nearest calibrated palette states. 1 means unambiguous, 0 means tied.
|
|
1632
2667
|
const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
|
|
1633
2668
|
return { cell: best.cell, distance, confidence };
|
|
1634
2669
|
}
|
|
1635
2670
|
|
|
2671
|
+
|
|
2672
|
+
function classifyDataHue(rgb, classifier) {
|
|
2673
|
+
const transformed = classifier.transform(rgb);
|
|
2674
|
+
const channels = [
|
|
2675
|
+
{ cell: CELL.RED, value: transformed.r },
|
|
2676
|
+
{ cell: CELL.GREEN, value: transformed.g },
|
|
2677
|
+
{ cell: CELL.BLUE, value: transformed.b }
|
|
2678
|
+
].sort((a, b) => b.value - a.value);
|
|
2679
|
+
const maxValue = channels[0].value;
|
|
2680
|
+
const secondValue = channels[1].value;
|
|
2681
|
+
const minValue = channels[2].value;
|
|
2682
|
+
const chroma = maxValue - minValue;
|
|
2683
|
+
const whiteThreshold = Math.max(18, Math.min(58, classifier.minimumColorChroma * 0.34));
|
|
2684
|
+
|
|
2685
|
+
if (chroma <= whiteThreshold) {
|
|
2686
|
+
const confidence = clampNumber((whiteThreshold - chroma) / Math.max(whiteThreshold, 1), 0, 1);
|
|
2687
|
+
return { cell: CELL.WHITE, distance: chroma, confidence: 0.35 + confidence * 0.65 };
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
const margin = maxValue - secondValue;
|
|
2691
|
+
const hueConfidence = clampNumber(margin / Math.max(chroma, 1), 0, 1);
|
|
2692
|
+
const saturationConfidence = clampNumber((chroma - whiteThreshold) / Math.max(classifier.minimumColorChroma - whiteThreshold, 1), 0, 1);
|
|
2693
|
+
return {
|
|
2694
|
+
cell: channels[0].cell,
|
|
2695
|
+
distance: Math.max(0, classifier.minimumColorChroma - chroma),
|
|
2696
|
+
confidence: clampNumber(0.18 + hueConfidence * 0.52 + saturationConfidence * 0.30, 0, 1)
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
|
|
1636
2700
|
function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
1637
2701
|
const size = rgbGrid.length;
|
|
1638
2702
|
const matrix = make2D(size, CELL.WHITE);
|
|
1639
2703
|
const confidence = make2D(size, 1);
|
|
1640
|
-
const dataClassifier =
|
|
2704
|
+
const dataClassifier = {
|
|
2705
|
+
...classifier,
|
|
2706
|
+
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
2707
|
+
};
|
|
1641
2708
|
let distanceSum = 0;
|
|
1642
2709
|
let confidenceSum = 0;
|
|
1643
2710
|
let minimumConfidence = 1;
|
|
@@ -1648,8 +2715,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
1648
2715
|
// Black is structural only. Restricting data modules to RGBW keeps a
|
|
1649
2716
|
// shadowed/blurred data sample representable so confidence-aware ECC can
|
|
1650
2717
|
// promote it to an erasure instead of aborting before RS gets a chance.
|
|
1651
|
-
const
|
|
1652
|
-
const
|
|
2718
|
+
const isDataCell = layout && !layout.reserved[r][c];
|
|
2719
|
+
const candidates = isDataCell ? dataClassifier : classifier;
|
|
2720
|
+
const classified = isDataCell && classifier.dataMode === "hue"
|
|
2721
|
+
? classifyDataHue(rgbGrid[r][c], classifier)
|
|
2722
|
+
: classifyRgb(rgbGrid[r][c], candidates);
|
|
1653
2723
|
matrix[r][c] = classified.cell;
|
|
1654
2724
|
confidence[r][c] = classified.confidence;
|
|
1655
2725
|
distanceSum += classified.distance;
|
|
@@ -1668,46 +2738,353 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
1668
2738
|
};
|
|
1669
2739
|
}
|
|
1670
2740
|
|
|
2741
|
+
function structuralAccuracy(matrix, layout) {
|
|
2742
|
+
let matches = 0;
|
|
2743
|
+
let total = 0;
|
|
2744
|
+
for (let r = 0; r < layout.size; r++) {
|
|
2745
|
+
for (let c = 0; c < layout.size; c++) {
|
|
2746
|
+
if (!layout.reserved[r][c]) continue;
|
|
2747
|
+
total++;
|
|
2748
|
+
if (matrix[r][c] === layout.matrix[r][c]) matches++;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
return total ? matches / total : 0;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
function pushObservation(options, observation) {
|
|
2755
|
+
if (!Array.isArray(options._observationCollector)) return;
|
|
2756
|
+
options._observationCollector.push(observation);
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
function paletteClassifierAttempts(observedPalette) {
|
|
2760
|
+
return [
|
|
2761
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
|
|
2762
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
|
|
2763
|
+
{ classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
|
|
2764
|
+
];
|
|
2765
|
+
}
|
|
2766
|
+
|
|
1671
2767
|
function tryPerspectiveScan(imageData, options) {
|
|
1672
2768
|
const geometryCandidates = detectCodeGeometry(imageData, {
|
|
1673
2769
|
minVersion: options.minVersion ?? MIN_VERSION,
|
|
1674
2770
|
maxVersion: options.maxVersion ?? MAX_VERSION,
|
|
1675
|
-
maxCandidates: options.maxGeometryCandidates ?? 8
|
|
2771
|
+
maxCandidates: options.maxGeometryCandidates ?? 8,
|
|
2772
|
+
finderRecovery: options.finderRecovery,
|
|
2773
|
+
finderAutoColorBlackClip: options.finderAutoColorBlackClip,
|
|
2774
|
+
finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
|
|
2775
|
+
finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
|
|
2776
|
+
finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
|
|
2777
|
+
finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
|
|
2778
|
+
finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
|
|
2779
|
+
finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
|
|
2780
|
+
diagnostics: options._visionDiagnostics,
|
|
2781
|
+
diagnosticLabel: options._diagnosticLabel ?? "normal"
|
|
1676
2782
|
});
|
|
2783
|
+
if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
|
|
1677
2784
|
const results = [];
|
|
1678
2785
|
|
|
1679
2786
|
for (const geometry of geometryCandidates) {
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
2787
|
+
const layout = createLayout(geometry.version);
|
|
2788
|
+
const sampleProfiles = [{
|
|
2789
|
+
sampleMode: options.sampleMode ?? "cross",
|
|
2790
|
+
sampleRadius: options.sampleRadius ?? 0.16,
|
|
2791
|
+
robustCalibration: false
|
|
2792
|
+
}];
|
|
2793
|
+
if (options.adaptiveSampling !== false) {
|
|
2794
|
+
sampleProfiles.push({
|
|
2795
|
+
sampleMode: "median",
|
|
2796
|
+
sampleRadius: options.robustSampleRadius ?? 0.12,
|
|
2797
|
+
robustCalibration: true
|
|
1684
2798
|
});
|
|
1685
|
-
|
|
1686
|
-
const classifier = classifierFromPaletteRgb(observedPalette);
|
|
1687
|
-
const classified = classifySampledRgbGrid(sampled.rgbGrid, classifier, layout);
|
|
1688
|
-
const decoded = decodeMatrix(classified.matrix, {
|
|
1689
|
-
structureTolerance: options.structureTolerance ?? 0.18,
|
|
1690
|
-
cellConfidence: classified.confidence,
|
|
1691
|
-
maxErasureConfidence: options.maxErasureConfidence
|
|
1692
|
-
});
|
|
1693
|
-
if (decoded.version !== geometry.version) continue;
|
|
2799
|
+
}
|
|
1694
2800
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
2801
|
+
let geometryDecoded = false;
|
|
2802
|
+
let bestStructureScore = 0;
|
|
2803
|
+
for (const profile of sampleProfiles) {
|
|
2804
|
+
let sampled;
|
|
2805
|
+
let observedPalette;
|
|
2806
|
+
try {
|
|
2807
|
+
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
|
|
2808
|
+
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
|
|
2809
|
+
robust: profile.robustCalibration
|
|
2810
|
+
});
|
|
2811
|
+
} catch {
|
|
2812
|
+
continue;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
|
|
2816
|
+
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
2817
|
+
const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
|
|
2818
|
+
const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
|
|
2819
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
2820
|
+
bestStructureScore = Math.max(bestStructureScore, structureScore);
|
|
2821
|
+
pushObservation(options, {
|
|
2822
|
+
version: geometry.version,
|
|
2823
|
+
matrix: classified.matrix,
|
|
2824
|
+
confidence: classified.confidence,
|
|
2825
|
+
geometry,
|
|
2826
|
+
observedPalette: activeObservedPalette,
|
|
2827
|
+
samplingMode: activeSamplingMode,
|
|
2828
|
+
colorNormalization: attempt.colorNormalization,
|
|
2829
|
+
structureScore,
|
|
2830
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
2831
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
2832
|
+
});
|
|
2833
|
+
|
|
2834
|
+
try {
|
|
2835
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
2836
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
2837
|
+
cellConfidence: classified.confidence,
|
|
2838
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
2839
|
+
});
|
|
2840
|
+
if (decoded.version !== geometry.version) return false;
|
|
2841
|
+
|
|
2842
|
+
results.push({
|
|
2843
|
+
...decoded,
|
|
2844
|
+
perspectiveCorrected: true,
|
|
2845
|
+
colorCalibrated: true,
|
|
2846
|
+
colorNormalization: attempt.colorNormalization,
|
|
2847
|
+
samplingMode: activeSamplingMode,
|
|
2848
|
+
geometry,
|
|
2849
|
+
observedPalette: activeObservedPalette,
|
|
2850
|
+
autoEnhanced: metadata.autoEnhanced || undefined,
|
|
2851
|
+
recoveryMode: metadata.recoveryMode,
|
|
2852
|
+
averageColorDistance: classified.averageColorDistance,
|
|
2853
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
2854
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
2855
|
+
lowConfidenceCells: classified.lowConfidenceCells,
|
|
2856
|
+
rectified: options.includeRectified
|
|
2857
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
2858
|
+
: undefined
|
|
2859
|
+
});
|
|
2860
|
+
return true;
|
|
2861
|
+
} catch {
|
|
2862
|
+
return false;
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
|
|
2866
|
+
// Fast path: preserve the original observed-RGB classifier first, then
|
|
2867
|
+
// try per-channel white balancing. Most clean frames stop here without
|
|
2868
|
+
// paying for the more expensive spatial normalization fallback.
|
|
2869
|
+
for (const attempt of paletteClassifierAttempts(observedPalette)) {
|
|
2870
|
+
if (tryAttempt(attempt, sampled.rgbGrid)) {
|
|
2871
|
+
geometryDecoded = true;
|
|
2872
|
+
break;
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
if (!geometryDecoded && options.spatialColorNormalization !== false) {
|
|
2877
|
+
try {
|
|
2878
|
+
const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
|
|
2879
|
+
const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
|
|
2880
|
+
geometryDecoded = tryAttempt({
|
|
2881
|
+
classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
|
|
2882
|
+
colorNormalization: "spatial-white-balanced"
|
|
2883
|
+
}, normalizedGrid);
|
|
2884
|
+
} catch {
|
|
2885
|
+
// Continue to the recovery profile below.
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// Very cheap Photoshop-like recovery on the already-sampled module grid.
|
|
2890
|
+
// Normal camera frames never reach this path. The transform approximates
|
|
2891
|
+
// Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
|
|
2892
|
+
if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
|
|
2893
|
+
try {
|
|
2894
|
+
const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
|
|
2895
|
+
blackClip: options.autoEnhanceBlackClip,
|
|
2896
|
+
whiteClip: options.autoEnhanceWhiteClip,
|
|
2897
|
+
saturation: options.autoEnhanceSaturation
|
|
2898
|
+
});
|
|
2899
|
+
const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
|
|
2900
|
+
for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
|
|
2901
|
+
const recoveredAttempt = {
|
|
2902
|
+
...attempt,
|
|
2903
|
+
colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
|
|
2904
|
+
};
|
|
2905
|
+
if (tryAttempt(recoveredAttempt, enhancedGrid, {
|
|
2906
|
+
observedPalette: enhancedPalette,
|
|
2907
|
+
autoEnhanced: true,
|
|
2908
|
+
recoveryMode: "module-grid-auto-tone-contrast-color"
|
|
2909
|
+
})) {
|
|
2910
|
+
geometryDecoded = true;
|
|
2911
|
+
break;
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
} catch {
|
|
2915
|
+
// Continue to the QR-region pixel recovery below.
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
// Camera-specific Photoshop-style fallback. Enhancing the whole camera
|
|
2920
|
+
// frame is often ineffective because dark surroundings, browser/UI
|
|
2921
|
+
// reflections and unrelated objects skew the histograms. Once finder
|
|
2922
|
+
// geometry is known, rectify only the QuadQR region, run Auto Tone /
|
|
2923
|
+
// Contrast / Color there at pixel level, then sample modules again. This
|
|
2924
|
+
// closely matches what happens when the QR itself is corrected in an
|
|
2925
|
+
// editor before photographing it, while staying off the clean fast path.
|
|
2926
|
+
if (
|
|
2927
|
+
!geometryDecoded &&
|
|
2928
|
+
profile === sampleProfiles[sampleProfiles.length - 1] &&
|
|
2929
|
+
options.autoEnhanceRecovery !== false &&
|
|
2930
|
+
options.rectifiedAutoEnhanceRecovery !== false
|
|
2931
|
+
) {
|
|
2932
|
+
try {
|
|
2933
|
+
const recoveryModuleSize = Math.max(4, Math.min(10, Math.round(options.rectifiedRecoveryModuleSize ?? 6)));
|
|
2934
|
+
const rectifiedRegion = rectifyImageData(
|
|
2935
|
+
imageData,
|
|
2936
|
+
geometry.homography,
|
|
2937
|
+
layout.size,
|
|
2938
|
+
recoveryModuleSize
|
|
2939
|
+
);
|
|
2940
|
+
const enhancedRegion = autoToneContrastColorImageData(rectifiedRegion, {
|
|
2941
|
+
blackClip: options.rectifiedAutoEnhanceBlackClip ?? options.autoEnhanceBlackClip ?? 0.008,
|
|
2942
|
+
whiteClip: options.rectifiedAutoEnhanceWhiteClip ?? options.autoEnhanceWhiteClip ?? 0.006,
|
|
2943
|
+
saturation: options.rectifiedAutoEnhanceSaturation ?? options.autoEnhanceSaturation ?? 1.22,
|
|
2944
|
+
highlightFraction: options.rectifiedAutoEnhanceHighlightFraction ?? 0.16,
|
|
2945
|
+
targetSamples: Math.min(120000, rectifiedRegion.width * rectifiedRegion.height)
|
|
2946
|
+
});
|
|
2947
|
+
const enhancedSampled = sampleAxisAlignedGrid(
|
|
2948
|
+
enhancedRegion,
|
|
2949
|
+
{ x: 0, y: 0, width: enhancedRegion.width, height: enhancedRegion.height },
|
|
2950
|
+
layout.size,
|
|
2951
|
+
options.rectifiedRecoveryRadiusRatio ?? 0.16
|
|
2952
|
+
);
|
|
2953
|
+
const enhancedPalette = sampleObservedPalette(
|
|
2954
|
+
enhancedSampled.rgbGrid,
|
|
2955
|
+
layout.calibration,
|
|
2956
|
+
{ robust: true }
|
|
2957
|
+
);
|
|
2958
|
+
for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
|
|
2959
|
+
const recoveredAttempt = {
|
|
2960
|
+
...attempt,
|
|
2961
|
+
colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
|
|
2962
|
+
};
|
|
2963
|
+
if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
|
|
2964
|
+
observedPalette: enhancedPalette,
|
|
2965
|
+
samplingMode: "rectified-auto-enhance",
|
|
2966
|
+
autoEnhanced: true,
|
|
2967
|
+
recoveryMode: "rectified-auto-tone-contrast-color"
|
|
2968
|
+
})) {
|
|
2969
|
+
geometryDecoded = true;
|
|
2970
|
+
break;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
} catch {
|
|
2974
|
+
// Geometry refinement remains available as the final bounded fallback.
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
if (geometryDecoded) break;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
// Slow-path geometry micro-refinement. Finder/alignment detection can be
|
|
2982
|
+
// correct while a soft-focus or smeared camera shifts the effective module
|
|
2983
|
+
// centres by a small fraction of a cell. Clean scans never reach this code.
|
|
2984
|
+
// On a strong-but-undecodable symbol, probe a tiny 3x3 offset neighbourhood
|
|
2985
|
+
// using centre-only samples and attempt decoding from the best candidates.
|
|
2986
|
+
if (
|
|
2987
|
+
!geometryDecoded &&
|
|
2988
|
+
options.geometryRefinement !== false &&
|
|
2989
|
+
bestStructureScore >= (options.refinementStructureThreshold ?? 0.90)
|
|
2990
|
+
) {
|
|
2991
|
+
const step = clampNumber(options.refinementOffset ?? 0.20, 0.05, 0.35);
|
|
2992
|
+
const refinementOffsets = [
|
|
2993
|
+
[step, 0], [-step, 0], [0, -step], [0, step],
|
|
2994
|
+
[step, -step], [step, step], [-step, -step], [-step, step]
|
|
2995
|
+
];
|
|
2996
|
+
const refinementCandidates = [];
|
|
2997
|
+
|
|
2998
|
+
for (const [offsetX, offsetY] of refinementOffsets) {
|
|
2999
|
+
let sampled;
|
|
3000
|
+
let observedPalette;
|
|
3001
|
+
try {
|
|
3002
|
+
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
|
|
3003
|
+
sampleMode: "cross",
|
|
3004
|
+
sampleRadius: 0,
|
|
3005
|
+
sampleOffsetX: offsetX,
|
|
3006
|
+
sampleOffsetY: offsetY
|
|
3007
|
+
});
|
|
3008
|
+
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
|
|
3009
|
+
} catch {
|
|
3010
|
+
continue;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// Raw observed-palette classification is deliberately first here too.
|
|
3014
|
+
// It is cheap, and it handles the common case where blur biased the
|
|
3015
|
+
// geometry slightly but the camera colors themselves remain usable.
|
|
3016
|
+
const attempts = paletteClassifierAttempts(observedPalette);
|
|
3017
|
+
for (const attempt of attempts) {
|
|
3018
|
+
const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
|
|
3019
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
3020
|
+
bestStructureScore = Math.max(bestStructureScore, structureScore);
|
|
3021
|
+
pushObservation(options, {
|
|
3022
|
+
version: geometry.version,
|
|
3023
|
+
matrix: classified.matrix,
|
|
3024
|
+
confidence: classified.confidence,
|
|
3025
|
+
geometry,
|
|
3026
|
+
observedPalette,
|
|
3027
|
+
samplingMode: "refined-center",
|
|
3028
|
+
samplingOffset: { x: offsetX, y: offsetY },
|
|
3029
|
+
colorNormalization: attempt.colorNormalization,
|
|
3030
|
+
structureScore,
|
|
3031
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3032
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3033
|
+
});
|
|
3034
|
+
|
|
3035
|
+
// Do not spend RS work on a refinement that made the known finder /
|
|
3036
|
+
// alignment structure worse. This keeps the recovery path bounded.
|
|
3037
|
+
if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
|
|
3038
|
+
refinementCandidates.push({
|
|
3039
|
+
attempt,
|
|
3040
|
+
classified,
|
|
3041
|
+
observedPalette,
|
|
3042
|
+
offsetX,
|
|
3043
|
+
offsetY,
|
|
3044
|
+
structureScore
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
refinementCandidates.sort((a, b) =>
|
|
3050
|
+
(b.structureScore - a.structureScore) ||
|
|
3051
|
+
(b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
|
|
3052
|
+
(a.classified.averageColorDistance - b.classified.averageColorDistance)
|
|
3053
|
+
);
|
|
3054
|
+
|
|
3055
|
+
const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
|
|
3056
|
+
for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
|
|
3057
|
+
try {
|
|
3058
|
+
const decoded = decodeMatrix(candidate.classified.matrix, {
|
|
3059
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3060
|
+
cellConfidence: candidate.classified.confidence,
|
|
3061
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3062
|
+
});
|
|
3063
|
+
if (decoded.version !== geometry.version) continue;
|
|
3064
|
+
results.push({
|
|
3065
|
+
...decoded,
|
|
3066
|
+
perspectiveCorrected: true,
|
|
3067
|
+
geometryRefined: true,
|
|
3068
|
+
samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
|
|
3069
|
+
colorCalibrated: true,
|
|
3070
|
+
colorNormalization: candidate.attempt.colorNormalization,
|
|
3071
|
+
samplingMode: "refined-center",
|
|
3072
|
+
geometry,
|
|
3073
|
+
observedPalette: candidate.observedPalette,
|
|
3074
|
+
averageColorDistance: candidate.classified.averageColorDistance,
|
|
3075
|
+
averageCellConfidence: candidate.classified.averageCellConfidence,
|
|
3076
|
+
minimumCellConfidence: candidate.classified.minimumCellConfidence,
|
|
3077
|
+
lowConfidenceCells: candidate.classified.lowConfidenceCells,
|
|
3078
|
+
rectified: options.includeRectified
|
|
3079
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
3080
|
+
: undefined
|
|
3081
|
+
});
|
|
3082
|
+
geometryDecoded = true;
|
|
3083
|
+
break;
|
|
3084
|
+
} catch {
|
|
3085
|
+
// Try the next high-scoring micro-refinement.
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
1711
3088
|
}
|
|
1712
3089
|
}
|
|
1713
3090
|
|
|
@@ -1721,7 +3098,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
1721
3098
|
|
|
1722
3099
|
function tryAxisAlignedScan(imageData, options) {
|
|
1723
3100
|
const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
|
|
1724
|
-
const
|
|
3101
|
+
const fixedPalette = paletteRgb(options.palette);
|
|
1725
3102
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
1726
3103
|
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
1727
3104
|
const candidates = [];
|
|
@@ -1734,49 +3111,95 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
1734
3111
|
const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
|
|
1735
3112
|
if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
|
|
1736
3113
|
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
const layout = createLayout(version);
|
|
1741
|
-
try {
|
|
1742
|
-
const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
|
|
1743
|
-
classifierAttempts.push({ classifier: classifierFromPaletteRgb(observedPalette), calibrated: true });
|
|
1744
|
-
} catch {
|
|
1745
|
-
// Fixed palette fallback below.
|
|
1746
|
-
}
|
|
1747
|
-
classifierAttempts.push({ classifier: fixedClassifier, calibrated: false });
|
|
3114
|
+
const radiusProfiles = [options.sampleRadius ?? 0.18];
|
|
3115
|
+
if (options.adaptiveSampling !== false) radiusProfiles.push(options.robustSampleRadius ?? 0.10);
|
|
3116
|
+
let accepted = false;
|
|
1748
3117
|
|
|
1749
|
-
|
|
1750
|
-
|
|
3118
|
+
for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
|
|
3119
|
+
try {
|
|
3120
|
+
const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
|
|
3121
|
+
const layout = createLayout(version);
|
|
3122
|
+
const classifierAttempts = [];
|
|
1751
3123
|
try {
|
|
1752
|
-
const
|
|
1753
|
-
|
|
1754
|
-
structureTolerance: options.structureTolerance ?? 0.12,
|
|
1755
|
-
cellConfidence: classified.confidence,
|
|
1756
|
-
maxErasureConfidence: options.maxErasureConfidence
|
|
1757
|
-
});
|
|
1758
|
-
candidates.push({
|
|
1759
|
-
...decoded,
|
|
1760
|
-
bounds,
|
|
1761
|
-
sampledVersion: version,
|
|
1762
|
-
moduleWidth: moduleW,
|
|
1763
|
-
moduleHeight: moduleH,
|
|
1764
|
-
perspectiveCorrected: false,
|
|
1765
|
-
colorCalibrated: attempt.calibrated,
|
|
1766
|
-
averageColorDistance: classified.averageColorDistance,
|
|
1767
|
-
averageCellConfidence: classified.averageCellConfidence,
|
|
1768
|
-
minimumCellConfidence: classified.minimumCellConfidence,
|
|
1769
|
-
lowConfidenceCells: classified.lowConfidenceCells
|
|
3124
|
+
const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
|
|
3125
|
+
robust: profileIndex > 0
|
|
1770
3126
|
});
|
|
1771
|
-
|
|
1772
|
-
|
|
3127
|
+
classifierAttempts.push(...paletteClassifierAttempts(observedPalette).map((attempt) => ({
|
|
3128
|
+
...attempt,
|
|
3129
|
+
calibrated: true,
|
|
3130
|
+
observedPalette,
|
|
3131
|
+
rgbGrid: sampled.rgbGrid
|
|
3132
|
+
})));
|
|
3133
|
+
if (options.spatialColorNormalization !== false) {
|
|
3134
|
+
try {
|
|
3135
|
+
const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
|
|
3136
|
+
const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
|
|
3137
|
+
classifierAttempts.push({
|
|
3138
|
+
classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
|
|
3139
|
+
calibrated: true,
|
|
3140
|
+
colorNormalization: "spatial-white-balanced",
|
|
3141
|
+
observedPalette,
|
|
3142
|
+
rgbGrid: normalizedGrid
|
|
3143
|
+
});
|
|
3144
|
+
} catch {
|
|
3145
|
+
// Continue with global calibration/fixed palette.
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
1773
3148
|
} catch {
|
|
1774
|
-
//
|
|
3149
|
+
// Fixed palette fallback below.
|
|
1775
3150
|
}
|
|
3151
|
+
classifierAttempts.push({
|
|
3152
|
+
classifier: classifierFromPaletteRgb(fixedPalette, "raw"),
|
|
3153
|
+
calibrated: false,
|
|
3154
|
+
colorNormalization: "fixed-rgb",
|
|
3155
|
+
observedPalette: fixedPalette,
|
|
3156
|
+
rgbGrid: sampled.rgbGrid
|
|
3157
|
+
});
|
|
3158
|
+
|
|
3159
|
+
for (const attempt of classifierAttempts) {
|
|
3160
|
+
try {
|
|
3161
|
+
const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
|
|
3162
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
3163
|
+
pushObservation(options, {
|
|
3164
|
+
version,
|
|
3165
|
+
matrix: classified.matrix,
|
|
3166
|
+
confidence: classified.confidence,
|
|
3167
|
+
bounds,
|
|
3168
|
+
samplingMode: profileIndex === 0 ? "axis" : "axis-center",
|
|
3169
|
+
colorNormalization: attempt.colorNormalization,
|
|
3170
|
+
structureScore,
|
|
3171
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3172
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3173
|
+
});
|
|
3174
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
3175
|
+
structureTolerance: options.structureTolerance ?? 0.12,
|
|
3176
|
+
cellConfidence: classified.confidence,
|
|
3177
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3178
|
+
});
|
|
3179
|
+
candidates.push({
|
|
3180
|
+
...decoded,
|
|
3181
|
+
bounds,
|
|
3182
|
+
sampledVersion: version,
|
|
3183
|
+
moduleWidth: moduleW,
|
|
3184
|
+
moduleHeight: moduleH,
|
|
3185
|
+
perspectiveCorrected: false,
|
|
3186
|
+
colorCalibrated: attempt.calibrated,
|
|
3187
|
+
colorNormalization: attempt.colorNormalization,
|
|
3188
|
+
samplingMode: profileIndex === 0 ? "axis" : "axis-center",
|
|
3189
|
+
averageColorDistance: classified.averageColorDistance,
|
|
3190
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3191
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3192
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3193
|
+
});
|
|
3194
|
+
accepted = true;
|
|
3195
|
+
break;
|
|
3196
|
+
} catch {
|
|
3197
|
+
// Try next classifier/profile.
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
} catch {
|
|
3201
|
+
// Try a narrower centre sample, then next version.
|
|
1776
3202
|
}
|
|
1777
|
-
if (!accepted) throw new Error("Axis-aligned candidate did not decode.");
|
|
1778
|
-
} catch {
|
|
1779
|
-
// Try next version.
|
|
1780
3203
|
}
|
|
1781
3204
|
}
|
|
1782
3205
|
|
|
@@ -1786,6 +3209,366 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
1786
3209
|
return candidates[0] ?? null;
|
|
1787
3210
|
}
|
|
1788
3211
|
|
|
3212
|
+
|
|
3213
|
+
|
|
3214
|
+
export const STRESS_PROFILES = Object.freeze([
|
|
3215
|
+
Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
|
|
3216
|
+
Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
|
|
3217
|
+
Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
|
|
3218
|
+
Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
|
|
3219
|
+
Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
|
|
3220
|
+
Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
|
|
3221
|
+
Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
|
|
3222
|
+
Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
|
|
3223
|
+
Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
|
|
3224
|
+
]);
|
|
3225
|
+
|
|
3226
|
+
function cloneImageDataLike(imageData) {
|
|
3227
|
+
return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
function sampleRgbaBilinear(imageData, x, y) {
|
|
3231
|
+
const { width, height, data } = imageData;
|
|
3232
|
+
const cx = clampNumber(x, 0, width - 1);
|
|
3233
|
+
const cy = clampNumber(y, 0, height - 1);
|
|
3234
|
+
const x0 = Math.floor(cx), y0 = Math.floor(cy);
|
|
3235
|
+
const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
|
|
3236
|
+
const tx = cx - x0, ty = cy - y0;
|
|
3237
|
+
const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
|
|
3238
|
+
const out = [0, 0, 0, 255];
|
|
3239
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
3240
|
+
const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
|
|
3241
|
+
const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
|
|
3242
|
+
out[ch] = a * (1 - ty) + b * ty;
|
|
3243
|
+
}
|
|
3244
|
+
return out;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
function resizeImageDataLike(imageData, width, height) {
|
|
3248
|
+
width = Math.max(1, Math.round(width));
|
|
3249
|
+
height = Math.max(1, Math.round(height));
|
|
3250
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
3251
|
+
for (let y = 0; y < height; y++) {
|
|
3252
|
+
for (let x = 0; x < width; x++) {
|
|
3253
|
+
const sx = (x + 0.5) * imageData.width / width - 0.5;
|
|
3254
|
+
const sy = (y + 0.5) * imageData.height / height - 0.5;
|
|
3255
|
+
const rgba = sampleRgbaBilinear(imageData, sx, sy);
|
|
3256
|
+
const p = (y * width + x) * 4;
|
|
3257
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
return { width, height, data: out };
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
function boxBlurImage(imageData, radius) {
|
|
3264
|
+
radius = Math.max(1, Math.round(radius));
|
|
3265
|
+
const { width, height, data } = imageData;
|
|
3266
|
+
const horizontal = new Float32Array(data.length);
|
|
3267
|
+
const out = new Uint8ClampedArray(data.length);
|
|
3268
|
+
for (let y = 0; y < height; y++) {
|
|
3269
|
+
for (let x = 0; x < width; x++) {
|
|
3270
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3271
|
+
let sum = 0, count = 0;
|
|
3272
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
3273
|
+
const xx = x + dx;
|
|
3274
|
+
if (xx < 0 || xx >= width) continue;
|
|
3275
|
+
sum += data[(y * width + xx) * 4 + ch]; count++;
|
|
3276
|
+
}
|
|
3277
|
+
horizontal[(y * width + x) * 4 + ch] = sum / count;
|
|
3278
|
+
}
|
|
3279
|
+
horizontal[(y * width + x) * 4 + 3] = 255;
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
for (let y = 0; y < height; y++) {
|
|
3283
|
+
for (let x = 0; x < width; x++) {
|
|
3284
|
+
const p = (y * width + x) * 4;
|
|
3285
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3286
|
+
let sum = 0, count = 0;
|
|
3287
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
3288
|
+
const yy = y + dy;
|
|
3289
|
+
if (yy < 0 || yy >= height) continue;
|
|
3290
|
+
sum += horizontal[(yy * width + x) * 4 + ch]; count++;
|
|
3291
|
+
}
|
|
3292
|
+
out[p + ch] = sum / count;
|
|
3293
|
+
}
|
|
3294
|
+
out[p + 3] = 255;
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
return { width, height, data: out };
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
function perspectiveStress(imageData, severity) {
|
|
3301
|
+
const { width, height } = imageData;
|
|
3302
|
+
const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
|
|
3303
|
+
const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
|
|
3304
|
+
const destination = [
|
|
3305
|
+
{ x: inset * 1.15, y: inset * 0.25 },
|
|
3306
|
+
{ x: width - 1 - inset * 0.25, y: inset },
|
|
3307
|
+
{ x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
|
|
3308
|
+
{ x: inset * 0.20, y: height - 1 - inset * 0.95 }
|
|
3309
|
+
];
|
|
3310
|
+
const inverse = computeHomography(destination, source);
|
|
3311
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
3312
|
+
out.fill(255);
|
|
3313
|
+
for (let y = 0; y < height; y++) {
|
|
3314
|
+
for (let x = 0; x < width; x++) {
|
|
3315
|
+
const src = projectPoint(inverse, x, y);
|
|
3316
|
+
if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
|
|
3317
|
+
const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
|
|
3318
|
+
const p = (y * width + x) * 4;
|
|
3319
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
return { width, height, data: out };
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
/** Apply one deterministic camera/print-style distortion to ImageData. */
|
|
3326
|
+
export function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
|
|
3327
|
+
assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
|
|
3328
|
+
const s = clampNumber(Number(severity), 0, 1);
|
|
3329
|
+
if (type === "clean") return cloneImageDataLike(imageData);
|
|
3330
|
+
if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
|
|
3331
|
+
if (type === "perspective") return perspectiveStress(imageData, s);
|
|
3332
|
+
if (type === "downscale") {
|
|
3333
|
+
const scale = Math.max(0.20, 1 - s * 0.72);
|
|
3334
|
+
const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
|
|
3335
|
+
return resizeImageDataLike(small, imageData.width, imageData.height);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
const out = cloneImageDataLike(imageData);
|
|
3339
|
+
const { width, height, data } = out;
|
|
3340
|
+
const quantStep = 4 + Math.round(s * 30);
|
|
3341
|
+
for (let y = 0; y < height; y++) {
|
|
3342
|
+
for (let x = 0; x < width; x++) {
|
|
3343
|
+
const p = (y * width + x) * 4;
|
|
3344
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3345
|
+
let value = data[p + ch];
|
|
3346
|
+
if (type === "brightness-low") value *= 1 - s * 0.52;
|
|
3347
|
+
else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
|
|
3348
|
+
else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
|
|
3349
|
+
else if (type === "shadow") {
|
|
3350
|
+
const nx = x / Math.max(1, width - 1);
|
|
3351
|
+
const ny = y / Math.max(1, height - 1);
|
|
3352
|
+
const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
|
|
3353
|
+
value *= 1 - shadow * s * 0.62;
|
|
3354
|
+
} else if (type === "jpeg") {
|
|
3355
|
+
value = Math.round(value / quantStep) * quantStep;
|
|
3356
|
+
const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
|
|
3357
|
+
value += block * s * 3;
|
|
3358
|
+
} else if (type === "warm") {
|
|
3359
|
+
if (ch === 0) value *= 1 + s * 0.12;
|
|
3360
|
+
if (ch === 2) value *= 1 - s * 0.22;
|
|
3361
|
+
}
|
|
3362
|
+
data[p + ch] = clampNumber(Math.round(value), 0, 255);
|
|
3363
|
+
}
|
|
3364
|
+
data[p + 3] = 255;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
return out;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
export function runImageStressTest(imageData, expected = {}, options = {}) {
|
|
3371
|
+
const profiles = options.profiles ?? STRESS_PROFILES;
|
|
3372
|
+
const results = [];
|
|
3373
|
+
let passedWeight = 0;
|
|
3374
|
+
let totalWeight = 0;
|
|
3375
|
+
for (const profile of profiles) {
|
|
3376
|
+
const weight = Number(profile.weight ?? 1);
|
|
3377
|
+
totalWeight += weight;
|
|
3378
|
+
const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
|
|
3379
|
+
const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
3380
|
+
try {
|
|
3381
|
+
const decoded = scanImageData(distorted, {
|
|
3382
|
+
minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
|
|
3383
|
+
maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
|
|
3384
|
+
debug: false
|
|
3385
|
+
});
|
|
3386
|
+
const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
|
|
3387
|
+
if (matches) passedWeight += weight;
|
|
3388
|
+
results.push({
|
|
3389
|
+
id: profile.id,
|
|
3390
|
+
label: profile.label,
|
|
3391
|
+
type: profile.type,
|
|
3392
|
+
severity: profile.severity,
|
|
3393
|
+
passed: matches,
|
|
3394
|
+
confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
|
|
3395
|
+
correctedSymbols: decoded.correctedSymbols ?? 0,
|
|
3396
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
3397
|
+
image: options.includeImages ? distorted : undefined,
|
|
3398
|
+
error: matches ? null : "Decoded payload did not match expected CRC."
|
|
3399
|
+
});
|
|
3400
|
+
} catch (error) {
|
|
3401
|
+
results.push({
|
|
3402
|
+
id: profile.id,
|
|
3403
|
+
label: profile.label,
|
|
3404
|
+
type: profile.type,
|
|
3405
|
+
severity: profile.severity,
|
|
3406
|
+
passed: false,
|
|
3407
|
+
confidence: 0,
|
|
3408
|
+
correctedSymbols: null,
|
|
3409
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
3410
|
+
image: options.includeImages ? distorted : undefined,
|
|
3411
|
+
error: error.message
|
|
3412
|
+
});
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
|
|
3416
|
+
const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
|
|
3417
|
+
const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
|
|
3418
|
+
const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
|
|
3419
|
+
return {
|
|
3420
|
+
score,
|
|
3421
|
+
rating,
|
|
3422
|
+
passPercent,
|
|
3423
|
+
passed: results.filter((r) => r.passed).length,
|
|
3424
|
+
total: results.length,
|
|
3425
|
+
averageConfidence,
|
|
3426
|
+
results
|
|
3427
|
+
};
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
/** Render and run the standard scanability torture suite. */
|
|
3431
|
+
export function assessScanability(code, renderOptions = {}, options = {}) {
|
|
3432
|
+
assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
|
|
3433
|
+
const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
|
|
3434
|
+
const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
|
|
3435
|
+
const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
|
|
3436
|
+
const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
|
|
3437
|
+
return {
|
|
3438
|
+
...report,
|
|
3439
|
+
version: code.version,
|
|
3440
|
+
eccLevel: code.eccLevel,
|
|
3441
|
+
utilization: code.utilization,
|
|
3442
|
+
testImageSize,
|
|
3443
|
+
autoLogoSize,
|
|
3444
|
+
recommendations: [
|
|
3445
|
+
...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
|
|
3446
|
+
...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
|
|
3447
|
+
...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
|
|
3448
|
+
...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
|
|
3449
|
+
]
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
function paletteConfidence(observedPalette) {
|
|
3454
|
+
if (!observedPalette) return 0.5;
|
|
3455
|
+
const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
|
|
3456
|
+
if (values.length < 5) return 0.5;
|
|
3457
|
+
let minDistance = Infinity;
|
|
3458
|
+
for (let i = 0; i < values.length; i++) {
|
|
3459
|
+
for (let j = i + 1; j < values.length; j++) {
|
|
3460
|
+
const a = values[i], b = values[j];
|
|
3461
|
+
minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
return clampNumber((minDistance - 28) / 120, 0, 1);
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
function scannerDiagnostics(result, context = {}, options = {}) {
|
|
3468
|
+
const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
|
|
3469
|
+
const correctableTotal = result && info
|
|
3470
|
+
? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
|
|
3471
|
+
: 0;
|
|
3472
|
+
const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
|
|
3473
|
+
const structureConfidence = clampNumber(
|
|
3474
|
+
result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
|
|
3475
|
+
);
|
|
3476
|
+
const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
|
|
3477
|
+
const geometryConfidence = result
|
|
3478
|
+
? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
|
|
3479
|
+
: (context.geometry?.length ? 0.45 : 0);
|
|
3480
|
+
const calibrationConfidence = result
|
|
3481
|
+
? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
|
|
3482
|
+
: 0;
|
|
3483
|
+
const confidence = result
|
|
3484
|
+
? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
|
|
3485
|
+
: 0;
|
|
3486
|
+
const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
|
|
3487
|
+
version: geometry.version,
|
|
3488
|
+
size: geometry.size,
|
|
3489
|
+
score: geometry.score,
|
|
3490
|
+
estimatedSize: geometry.estimatedSize,
|
|
3491
|
+
alignmentScore: geometry.alignmentScore,
|
|
3492
|
+
alignmentGridScore: geometry.alignmentGridScore,
|
|
3493
|
+
corners: geometry.corners ?? geometry.quad ?? null,
|
|
3494
|
+
finders: geometry.finders ?? null
|
|
3495
|
+
}));
|
|
3496
|
+
const observations = context.observations ?? [];
|
|
3497
|
+
const latest = observations.at(-1) ?? null;
|
|
3498
|
+
const stages = {
|
|
3499
|
+
finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
3500
|
+
geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
3501
|
+
sampling: observations.length > 0 || Boolean(result),
|
|
3502
|
+
colorClassification: observations.length > 0 || Boolean(result),
|
|
3503
|
+
ecc: Boolean(result),
|
|
3504
|
+
crc: Boolean(result),
|
|
3505
|
+
payload: Boolean(result)
|
|
3506
|
+
};
|
|
3507
|
+
let failedStage = null;
|
|
3508
|
+
if (!result) {
|
|
3509
|
+
failedStage = !stages.finderDetection ? "finder-detection"
|
|
3510
|
+
: !stages.sampling ? "perspective-geometry"
|
|
3511
|
+
: "color-classification-or-ecc";
|
|
3512
|
+
}
|
|
3513
|
+
return {
|
|
3514
|
+
confidence,
|
|
3515
|
+
cellConfidence,
|
|
3516
|
+
structureConfidence,
|
|
3517
|
+
geometryConfidence,
|
|
3518
|
+
calibrationConfidence,
|
|
3519
|
+
eccUtilization,
|
|
3520
|
+
correctedErrors: result?.correctedSymbols ?? 0,
|
|
3521
|
+
erasureSymbols: result?.erasureSymbols ?? 0,
|
|
3522
|
+
lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
|
|
3523
|
+
stages,
|
|
3524
|
+
failedStage,
|
|
3525
|
+
geometryCandidates,
|
|
3526
|
+
vision: context.vision ?? null,
|
|
3527
|
+
sampled: latest ? {
|
|
3528
|
+
version: latest.version,
|
|
3529
|
+
samplingMode: latest.samplingMode,
|
|
3530
|
+
colorNormalization: latest.colorNormalization,
|
|
3531
|
+
structureScore: latest.structureScore,
|
|
3532
|
+
averageCellConfidence: latest.averageCellConfidence,
|
|
3533
|
+
observedPalette: latest.observedPalette ?? null,
|
|
3534
|
+
matrix: options.debugMatrices === false ? undefined : latest.matrix,
|
|
3535
|
+
confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
|
|
3536
|
+
} : null
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
function decorateScanResult(result, context, options) {
|
|
3541
|
+
const diagnostics = scannerDiagnostics(result, context, options);
|
|
3542
|
+
return {
|
|
3543
|
+
...result,
|
|
3544
|
+
confidence: diagnostics.confidence,
|
|
3545
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
3546
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
3547
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
3548
|
+
correctedErrors: result.correctedSymbols ?? 0,
|
|
3549
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
3550
|
+
diagnostics: options.debug ? diagnostics : {
|
|
3551
|
+
confidence: diagnostics.confidence,
|
|
3552
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
3553
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
3554
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
3555
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
3556
|
+
correctedErrors: diagnostics.correctedErrors,
|
|
3557
|
+
erasureSymbols: diagnostics.erasureSymbols
|
|
3558
|
+
}
|
|
3559
|
+
};
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
/** Non-throwing scanner debug API with detailed stage diagnostics. */
|
|
3563
|
+
export function debugScanImageData(imageData, options = {}) {
|
|
3564
|
+
try {
|
|
3565
|
+
const result = scanImageData(imageData, { ...options, debug: true });
|
|
3566
|
+
return { ok: true, result, debug: result.diagnostics };
|
|
3567
|
+
} catch (error) {
|
|
3568
|
+
return { ok: false, error: error.message, debug: error.debug ?? null };
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
1789
3572
|
export function scanImageData(imageData, options = {}) {
|
|
1790
3573
|
assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
|
|
1791
3574
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
@@ -1793,19 +3576,103 @@ export function scanImageData(imageData, options = {}) {
|
|
|
1793
3576
|
validateVersion(minVersion);
|
|
1794
3577
|
validateVersion(maxVersion);
|
|
1795
3578
|
|
|
3579
|
+
const geometryCollector = [];
|
|
3580
|
+
const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
|
|
3581
|
+
const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
|
|
3582
|
+
const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
|
|
3583
|
+
const scanOptions = {
|
|
3584
|
+
...options,
|
|
3585
|
+
_geometryCollector: geometryCollector,
|
|
3586
|
+
_observationCollector: observationCollector,
|
|
3587
|
+
_visionDiagnostics: visionDiagnostics
|
|
3588
|
+
};
|
|
3589
|
+
|
|
1796
3590
|
if (options.perspective !== false) {
|
|
1797
|
-
const perspective = tryPerspectiveScan(imageData,
|
|
1798
|
-
if (perspective) return perspective;
|
|
3591
|
+
const perspective = tryPerspectiveScan(imageData, scanOptions);
|
|
3592
|
+
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
// If the normal scanner could see QuadQR geometry but could not decode the
|
|
3596
|
+
// colors, retry the same frame after a single global Auto Tone / Contrast /
|
|
3597
|
+
// Color-style pass. This is deliberately after the normal path so clean
|
|
3598
|
+
// camera scanning remains unchanged and fast.
|
|
3599
|
+
if (
|
|
3600
|
+
options.autoEnhanceRecovery !== false &&
|
|
3601
|
+
options.fullFrameAutoEnhanceRecovery !== false &&
|
|
3602
|
+
!options._autoEnhancedRecovery &&
|
|
3603
|
+
geometryCollector.length > 0
|
|
3604
|
+
) {
|
|
3605
|
+
try {
|
|
3606
|
+
const enhanced = autoToneContrastColorImageData(imageData, {
|
|
3607
|
+
blackClip: options.autoEnhanceBlackClip,
|
|
3608
|
+
whiteClip: options.autoEnhanceWhiteClip,
|
|
3609
|
+
saturation: options.autoEnhanceSaturation,
|
|
3610
|
+
targetSamples: options.autoEnhanceTargetSamples
|
|
3611
|
+
});
|
|
3612
|
+
const recovered = tryPerspectiveScan(enhanced, {
|
|
3613
|
+
...options,
|
|
3614
|
+
_autoEnhancedRecovery: true,
|
|
3615
|
+
autoEnhanceRecovery: false,
|
|
3616
|
+
_geometryCollector: []
|
|
3617
|
+
});
|
|
3618
|
+
if (recovered) {
|
|
3619
|
+
return decorateScanResult({
|
|
3620
|
+
...recovered,
|
|
3621
|
+
autoEnhanced: true,
|
|
3622
|
+
recoveryMode: "auto-tone-contrast-color",
|
|
3623
|
+
originalColorNormalization: recovered.colorNormalization
|
|
3624
|
+
}, debugContext, options);
|
|
3625
|
+
}
|
|
3626
|
+
} catch {
|
|
3627
|
+
// Continue to the axis-aligned fallback.
|
|
3628
|
+
}
|
|
1799
3629
|
}
|
|
1800
3630
|
|
|
1801
3631
|
if (options.axisAlignedFallback !== false) {
|
|
1802
|
-
const axis = tryAxisAlignedScan(imageData,
|
|
1803
|
-
if (axis) return axis;
|
|
3632
|
+
const axis = tryAxisAlignedScan(imageData, scanOptions);
|
|
3633
|
+
if (axis) return decorateScanResult(axis, debugContext, options);
|
|
1804
3634
|
}
|
|
1805
3635
|
|
|
1806
|
-
|
|
3636
|
+
// Static images sometimes have such low contrast that locator detection itself
|
|
3637
|
+
// fails. Permit one enhanced full retry in that case. The live camera disables
|
|
3638
|
+
// this on its first pass so an empty frame never becomes expensive.
|
|
3639
|
+
if (
|
|
3640
|
+
options.autoEnhanceRecovery !== false &&
|
|
3641
|
+
!options._autoEnhancedRecovery &&
|
|
3642
|
+
geometryCollector.length === 0 &&
|
|
3643
|
+
options.autoEnhanceWhenNoGeometry !== false
|
|
3644
|
+
) {
|
|
3645
|
+
try {
|
|
3646
|
+
const enhanced = autoToneContrastColorImageData(imageData, {
|
|
3647
|
+
blackClip: options.autoEnhanceBlackClip,
|
|
3648
|
+
whiteClip: options.autoEnhanceWhiteClip,
|
|
3649
|
+
saturation: options.autoEnhanceSaturation,
|
|
3650
|
+
targetSamples: options.autoEnhanceTargetSamples
|
|
3651
|
+
});
|
|
3652
|
+
const recoveryOptions = {
|
|
3653
|
+
...options,
|
|
3654
|
+
_autoEnhancedRecovery: true,
|
|
3655
|
+
autoEnhanceRecovery: false,
|
|
3656
|
+
_geometryCollector: []
|
|
3657
|
+
};
|
|
3658
|
+
if (options.perspective !== false) {
|
|
3659
|
+
const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
|
|
3660
|
+
if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
3661
|
+
}
|
|
3662
|
+
if (options.axisAlignedFallback !== false) {
|
|
3663
|
+
const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
|
|
3664
|
+
if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
3665
|
+
}
|
|
3666
|
+
} catch {
|
|
3667
|
+
// Fall through to the normal scan failure below.
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
const error = new Error(
|
|
1807
3672
|
"No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
|
|
1808
3673
|
);
|
|
3674
|
+
if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
|
|
3675
|
+
throw error;
|
|
1809
3676
|
}
|
|
1810
3677
|
|
|
1811
3678
|
export async function scanFile(file, options = {}) {
|
|
@@ -1834,19 +3701,274 @@ export async function scanFile(file, options = {}) {
|
|
|
1834
3701
|
return scanImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), options);
|
|
1835
3702
|
}
|
|
1836
3703
|
|
|
3704
|
+
function parseObjectPositionFraction(value, fallback = 0.5) {
|
|
3705
|
+
if (typeof value !== "string") return fallback;
|
|
3706
|
+
const token = value.trim().toLowerCase();
|
|
3707
|
+
if (token === "left" || token === "top") return 0;
|
|
3708
|
+
if (token === "right" || token === "bottom") return 1;
|
|
3709
|
+
if (token === "center") return 0.5;
|
|
3710
|
+
if (token.endsWith("%")) {
|
|
3711
|
+
const parsed = Number.parseFloat(token);
|
|
3712
|
+
if (Number.isFinite(parsed)) return clampNumber(parsed / 100, 0, 1);
|
|
3713
|
+
}
|
|
3714
|
+
return fallback;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
function visibleVideoSourceRect(video, options = {}) {
|
|
3718
|
+
const sourceWidth = video.videoWidth;
|
|
3719
|
+
const sourceHeight = video.videoHeight;
|
|
3720
|
+
const full = { x: 0, y: 0, width: sourceWidth, height: sourceHeight, cropped: false };
|
|
3721
|
+
if (options.videoCropMode === "full") return full;
|
|
3722
|
+
|
|
3723
|
+
let boxWidth = Number(video.clientWidth) || 0;
|
|
3724
|
+
let boxHeight = Number(video.clientHeight) || 0;
|
|
3725
|
+
if ((!boxWidth || !boxHeight) && typeof video.getBoundingClientRect === "function") {
|
|
3726
|
+
const rect = video.getBoundingClientRect();
|
|
3727
|
+
boxWidth = boxWidth || rect.width;
|
|
3728
|
+
boxHeight = boxHeight || rect.height;
|
|
3729
|
+
}
|
|
3730
|
+
if (!boxWidth || !boxHeight) return full;
|
|
3731
|
+
|
|
3732
|
+
let objectFit = options.videoObjectFit;
|
|
3733
|
+
let objectPosition = options.videoObjectPosition;
|
|
3734
|
+
if ((!objectFit || !objectPosition) && typeof getComputedStyle === "function") {
|
|
3735
|
+
try {
|
|
3736
|
+
const style = getComputedStyle(video);
|
|
3737
|
+
objectFit = objectFit || style.objectFit;
|
|
3738
|
+
objectPosition = objectPosition || style.objectPosition;
|
|
3739
|
+
} catch {
|
|
3740
|
+
// Fall back to the demo's normal cover/center behavior.
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
objectFit = objectFit || "cover";
|
|
3744
|
+
if (objectFit !== "cover") return full;
|
|
3745
|
+
|
|
3746
|
+
const scale = Math.max(boxWidth / sourceWidth, boxHeight / sourceHeight);
|
|
3747
|
+
if (!Number.isFinite(scale) || scale <= 0) return full;
|
|
3748
|
+
let cropWidth = Math.min(sourceWidth, boxWidth / scale);
|
|
3749
|
+
let cropHeight = Math.min(sourceHeight, boxHeight / scale);
|
|
3750
|
+
if (cropWidth >= sourceWidth - 1 && cropHeight >= sourceHeight - 1) return full;
|
|
3751
|
+
|
|
3752
|
+
const positionTokens = String(objectPosition || "50% 50%").trim().split(/\s+/);
|
|
3753
|
+
const positionX = parseObjectPositionFraction(positionTokens[0], 0.5);
|
|
3754
|
+
const positionY = parseObjectPositionFraction(positionTokens[1] ?? positionTokens[0], 0.5);
|
|
3755
|
+
let x = (sourceWidth - cropWidth) * positionX;
|
|
3756
|
+
let y = (sourceHeight - cropHeight) * positionY;
|
|
3757
|
+
|
|
3758
|
+
// Optional tiny inset removes the soft edge of a camera preview without
|
|
3759
|
+
// changing the visible composition. Leave at zero unless explicitly set.
|
|
3760
|
+
const inset = clampNumber(options.videoCropInset ?? 0, 0, 0.18);
|
|
3761
|
+
if (inset > 0) {
|
|
3762
|
+
const dx = cropWidth * inset;
|
|
3763
|
+
const dy = cropHeight * inset;
|
|
3764
|
+
x += dx;
|
|
3765
|
+
y += dy;
|
|
3766
|
+
cropWidth -= dx * 2;
|
|
3767
|
+
cropHeight -= dy * 2;
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
x = clampNumber(x, 0, Math.max(0, sourceWidth - cropWidth));
|
|
3771
|
+
y = clampNumber(y, 0, Math.max(0, sourceHeight - cropHeight));
|
|
3772
|
+
return { x, y, width: cropWidth, height: cropHeight, cropped: true };
|
|
3773
|
+
}
|
|
3774
|
+
|
|
3775
|
+
function cropImageDataInset(imageData, insetFraction = 0) {
|
|
3776
|
+
const inset = clampNumber(Number(insetFraction) || 0, 0, 0.32);
|
|
3777
|
+
if (inset <= 0) {
|
|
3778
|
+
return {
|
|
3779
|
+
imageData,
|
|
3780
|
+
rect: { x: 0, y: 0, width: imageData.width, height: imageData.height }
|
|
3781
|
+
};
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
const x = Math.max(0, Math.floor(imageData.width * inset));
|
|
3785
|
+
const y = Math.max(0, Math.floor(imageData.height * inset));
|
|
3786
|
+
const width = Math.max(1, imageData.width - x * 2);
|
|
3787
|
+
const height = Math.max(1, imageData.height - y * 2);
|
|
3788
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
3789
|
+
for (let row = 0; row < height; row++) {
|
|
3790
|
+
const sourceStart = ((y + row) * imageData.width + x) * 4;
|
|
3791
|
+
const sourceEnd = sourceStart + width * 4;
|
|
3792
|
+
data.set(imageData.data.subarray(sourceStart, sourceEnd), row * width * 4);
|
|
3793
|
+
}
|
|
3794
|
+
return {
|
|
3795
|
+
imageData: { width, height, data },
|
|
3796
|
+
rect: { x, y, width, height }
|
|
3797
|
+
};
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
function bestVisionDiagnosticPass(visionDiagnostics) {
|
|
3801
|
+
const passes = visionDiagnostics?.passes;
|
|
3802
|
+
if (!Array.isArray(passes) || !passes.length) return null;
|
|
3803
|
+
return passes.slice().sort((a, b) => {
|
|
3804
|
+
const aGeometry = a.geometries?.[0];
|
|
3805
|
+
const bGeometry = b.geometries?.[0];
|
|
3806
|
+
return (Boolean(bGeometry) - Boolean(aGeometry)) ||
|
|
3807
|
+
((b.finderCount ?? 0) - (a.finderCount ?? 0)) ||
|
|
3808
|
+
((bGeometry?.score ?? 0) - (aGeometry?.score ?? 0));
|
|
3809
|
+
})[0];
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
function nowMs() {
|
|
3813
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
3814
|
+
? performance.now()
|
|
3815
|
+
: Date.now();
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
function normalizeFrameDiagnostics(frameDiagnostics, source, width, height, visionDiagnostics) {
|
|
3819
|
+
if (!frameDiagnostics || typeof frameDiagnostics !== "object") return;
|
|
3820
|
+
frameDiagnostics.scanWidth = width;
|
|
3821
|
+
frameDiagnostics.scanHeight = height;
|
|
3822
|
+
frameDiagnostics.frameWidth = width;
|
|
3823
|
+
frameDiagnostics.frameHeight = height;
|
|
3824
|
+
frameDiagnostics.scanRect = { x: 0, y: 0, width, height };
|
|
3825
|
+
frameDiagnostics.sourceRect = {
|
|
3826
|
+
x: source.x,
|
|
3827
|
+
y: source.y,
|
|
3828
|
+
width: source.width,
|
|
3829
|
+
height: source.height,
|
|
3830
|
+
cropped: Boolean(source.cropped)
|
|
3831
|
+
};
|
|
3832
|
+
frameDiagnostics.vision = visionDiagnostics;
|
|
3833
|
+
const bestPass = bestVisionDiagnosticPass(visionDiagnostics);
|
|
3834
|
+
frameDiagnostics.bestPass = bestPass;
|
|
3835
|
+
frameDiagnostics.finderCount = bestPass?.finderCount ?? 0;
|
|
3836
|
+
frameDiagnostics.finders = bestPass?.finders ?? [];
|
|
3837
|
+
frameDiagnostics.finderMethod = bestPass?.finderMethod ?? null;
|
|
3838
|
+
frameDiagnostics.finderPasses = Array.isArray(visionDiagnostics?.passes)
|
|
3839
|
+
? visionDiagnostics.passes.map((pass) => ({
|
|
3840
|
+
method: pass.finderMethod ?? pass.label,
|
|
3841
|
+
finderCount: pass.finderCount ?? 0,
|
|
3842
|
+
threshold: pass.threshold,
|
|
3843
|
+
geometryCount: pass.geometries?.length ?? 0
|
|
3844
|
+
}))
|
|
3845
|
+
: [];
|
|
3846
|
+
frameDiagnostics.geometry = bestPass?.geometries?.[0] ?? null;
|
|
3847
|
+
}
|
|
3848
|
+
|
|
1837
3849
|
export function scanVideoFrame(video, options = {}) {
|
|
1838
3850
|
assert(typeof document !== "undefined", "scanVideoFrame is a browser API.");
|
|
1839
3851
|
assert(video && video.videoWidth && video.videoHeight, "Video frame is not ready.");
|
|
3852
|
+
|
|
3853
|
+
// Scan what the user actually sees. On phones the video element is usually a
|
|
3854
|
+
// portrait box with object-fit: cover while the camera sensor stream is 16:9.
|
|
3855
|
+
// Previously we scanned the entire hidden sensor frame, so the QR looked big
|
|
3856
|
+
// in the guide but became much smaller after canvas downscaling. Cropping to
|
|
3857
|
+
// the visible source region both improves module resolution and reduces work.
|
|
3858
|
+
const source = visibleVideoSourceRect(video, options);
|
|
1840
3859
|
const maxDimension = options.maxDimension ?? 960;
|
|
1841
|
-
const scale = Math.min(1, maxDimension / Math.max(
|
|
1842
|
-
const width = Math.max(1, Math.round(
|
|
1843
|
-
const height = Math.max(1, Math.round(
|
|
3860
|
+
const scale = Math.min(1, maxDimension / Math.max(source.width, source.height));
|
|
3861
|
+
const width = Math.max(1, Math.round(source.width * scale));
|
|
3862
|
+
const height = Math.max(1, Math.round(source.height * scale));
|
|
1844
3863
|
const canvas = options.canvas ?? document.createElement("canvas");
|
|
1845
3864
|
canvas.width = width;
|
|
1846
3865
|
canvas.height = height;
|
|
1847
3866
|
const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
1848
|
-
|
|
1849
|
-
|
|
3867
|
+
// High-quality downscaling is preferable for finder geometry, but visible-ROI
|
|
3868
|
+
// cropping means many mobile frames now need little or no downscaling at all.
|
|
3869
|
+
if ("imageSmoothingEnabled" in ctx) ctx.imageSmoothingEnabled = true;
|
|
3870
|
+
if ("imageSmoothingQuality" in ctx) ctx.imageSmoothingQuality = "high";
|
|
3871
|
+
ctx.drawImage(video, source.x, source.y, source.width, source.height, 0, 0, width, height);
|
|
3872
|
+
const frameImageData = ctx.getImageData(0, 0, width, height);
|
|
3873
|
+
if (options._capturedFrame && typeof options._capturedFrame === "object") {
|
|
3874
|
+
options._capturedFrame.imageData = frameImageData;
|
|
3875
|
+
options._capturedFrame.scanWidth = width;
|
|
3876
|
+
options._capturedFrame.scanHeight = height;
|
|
3877
|
+
options._capturedFrame.source = { ...source };
|
|
3878
|
+
}
|
|
3879
|
+
const visionDiagnostics = options._frameDiagnostics ? { passes: [] } : null;
|
|
3880
|
+
try {
|
|
3881
|
+
const result = scanImageData(frameImageData, {
|
|
3882
|
+
...options,
|
|
3883
|
+
_visionDiagnostics: visionDiagnostics
|
|
3884
|
+
});
|
|
3885
|
+
normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
|
|
3886
|
+
if (!source.cropped) return result;
|
|
3887
|
+
return {
|
|
3888
|
+
...result,
|
|
3889
|
+
cameraVisibleCrop: true,
|
|
3890
|
+
cameraSourceRect: { x: source.x, y: source.y, width: source.width, height: source.height }
|
|
3891
|
+
};
|
|
3892
|
+
} catch (error) {
|
|
3893
|
+
normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
|
|
3894
|
+
throw error;
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
function selectBestFrameObservation(observations) {
|
|
3899
|
+
if (!observations?.length) return null;
|
|
3900
|
+
return observations
|
|
3901
|
+
.filter((item) => item?.matrix && item.structureScore >= 0.82)
|
|
3902
|
+
.sort((a, b) =>
|
|
3903
|
+
(b.structureScore - a.structureScore) ||
|
|
3904
|
+
(b.averageCellConfidence - a.averageCellConfidence) ||
|
|
3905
|
+
((b.geometry?.score ?? 0) - (a.geometry?.score ?? 0))
|
|
3906
|
+
)[0] ?? null;
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
function combineFrameObservations(observations) {
|
|
3910
|
+
if (!observations?.length) return null;
|
|
3911
|
+
const version = observations[0].version;
|
|
3912
|
+
const size = observations[0].matrix.length;
|
|
3913
|
+
if (!observations.every((item) => item.version === version && item.matrix.length === size)) return null;
|
|
3914
|
+
|
|
3915
|
+
const matrix = make2D(size, CELL.WHITE);
|
|
3916
|
+
const confidence = make2D(size, 0);
|
|
3917
|
+
for (let r = 0; r < size; r++) {
|
|
3918
|
+
for (let c = 0; c < size; c++) {
|
|
3919
|
+
const votes = new Map();
|
|
3920
|
+
const confidenceByCell = new Map();
|
|
3921
|
+
for (const observation of observations) {
|
|
3922
|
+
const cell = observation.matrix[r][c];
|
|
3923
|
+
const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
|
|
3924
|
+
const weight = 0.35 + sourceConfidence * 0.65;
|
|
3925
|
+
votes.set(cell, (votes.get(cell) ?? 0) + weight);
|
|
3926
|
+
const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
|
|
3927
|
+
stats.weighted += sourceConfidence * weight;
|
|
3928
|
+
stats.weight += weight;
|
|
3929
|
+
confidenceByCell.set(cell, stats);
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
|
|
3933
|
+
const [bestCell, bestWeight] = ranked[0];
|
|
3934
|
+
const secondWeight = ranked[1]?.[1] ?? 0;
|
|
3935
|
+
const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
|
|
3936
|
+
const sourceStats = confidenceByCell.get(bestCell);
|
|
3937
|
+
const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
|
|
3938
|
+
const support = totalWeight ? bestWeight / totalWeight : 0;
|
|
3939
|
+
const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
|
|
3940
|
+
const agreement = 0.5 * support + 0.5 * margin;
|
|
3941
|
+
|
|
3942
|
+
matrix[r][c] = bestCell;
|
|
3943
|
+
// Do not become overconfident just because several blurry frames agree.
|
|
3944
|
+
// Retaining source uncertainty lets Reed-Solomon treat repeated ambiguous
|
|
3945
|
+
// cells as erasures instead of hard, supposedly-certain errors.
|
|
3946
|
+
confidence[r][c] = clampNumber(sourceConfidence * 0.58 + agreement * 0.42, 0, 1);
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
return { version, matrix, confidence };
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
async function improveCameraTrack(stream) {
|
|
3953
|
+
const track = stream.getVideoTracks?.()[0];
|
|
3954
|
+
if (!track?.applyConstraints || !track.getCapabilities) return;
|
|
3955
|
+
try {
|
|
3956
|
+
const capabilities = track.getCapabilities();
|
|
3957
|
+
const advanced = {};
|
|
3958
|
+
if (Array.isArray(capabilities.focusMode) && capabilities.focusMode.includes("continuous")) {
|
|
3959
|
+
advanced.focusMode = "continuous";
|
|
3960
|
+
}
|
|
3961
|
+
if (Array.isArray(capabilities.exposureMode) && capabilities.exposureMode.includes("continuous")) {
|
|
3962
|
+
advanced.exposureMode = "continuous";
|
|
3963
|
+
}
|
|
3964
|
+
if (Array.isArray(capabilities.whiteBalanceMode) && capabilities.whiteBalanceMode.includes("continuous")) {
|
|
3965
|
+
advanced.whiteBalanceMode = "continuous";
|
|
3966
|
+
}
|
|
3967
|
+
if (Object.keys(advanced).length) await track.applyConstraints({ advanced: [advanced] });
|
|
3968
|
+
} catch {
|
|
3969
|
+
// Browsers expose different subsets of camera controls. Scanner-side
|
|
3970
|
+
// calibration remains the primary path when these hints are unsupported.
|
|
3971
|
+
}
|
|
1850
3972
|
}
|
|
1851
3973
|
|
|
1852
3974
|
export async function startCameraScanner(video, options = {}) {
|
|
@@ -1858,11 +3980,13 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
1858
3980
|
audio: false,
|
|
1859
3981
|
video: {
|
|
1860
3982
|
facingMode: { ideal: "environment" },
|
|
1861
|
-
width: { ideal:
|
|
1862
|
-
height: { ideal:
|
|
3983
|
+
width: { ideal: 1920 },
|
|
3984
|
+
height: { ideal: 1080 },
|
|
3985
|
+
frameRate: { ideal: 30 }
|
|
1863
3986
|
}
|
|
1864
3987
|
}
|
|
1865
3988
|
);
|
|
3989
|
+
await improveCameraTrack(stream);
|
|
1866
3990
|
|
|
1867
3991
|
video.srcObject = stream;
|
|
1868
3992
|
video.setAttribute("playsinline", "");
|
|
@@ -1871,32 +3995,436 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
1871
3995
|
|
|
1872
3996
|
const scanInterval = Math.max(80, options.scanInterval ?? 180);
|
|
1873
3997
|
const scratchCanvas = document.createElement("canvas");
|
|
3998
|
+
const multiFrameEnabled = options.multiFrame !== false;
|
|
3999
|
+
const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
|
|
4000
|
+
const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
|
|
4001
|
+
const observationHistory = new Map();
|
|
4002
|
+
const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
|
|
4003
|
+
const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
|
|
4004
|
+
const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
|
|
4005
|
+
let missStreak = 0;
|
|
1874
4006
|
let stopped = false;
|
|
1875
4007
|
let busy = false;
|
|
1876
4008
|
let timer = null;
|
|
4009
|
+
let frameNumber = 0;
|
|
4010
|
+
|
|
4011
|
+
const diagnosticsEnabled = typeof options.onDiagnostic === "function";
|
|
4012
|
+
const emitDiagnostic = (event) => {
|
|
4013
|
+
if (!diagnosticsEnabled) return;
|
|
4014
|
+
try {
|
|
4015
|
+
options.onDiagnostic({
|
|
4016
|
+
timestamp: Date.now(),
|
|
4017
|
+
frame: frameNumber,
|
|
4018
|
+
...event
|
|
4019
|
+
});
|
|
4020
|
+
} catch {
|
|
4021
|
+
// Diagnostics are UI-only and must never interrupt scanning.
|
|
4022
|
+
}
|
|
4023
|
+
};
|
|
4024
|
+
|
|
4025
|
+
|
|
4026
|
+
if (diagnosticsEnabled) {
|
|
4027
|
+
const track = stream.getVideoTracks?.()[0];
|
|
4028
|
+
const settings = track?.getSettings?.() ?? {};
|
|
4029
|
+
emitDiagnostic({
|
|
4030
|
+
type: "camera-ready",
|
|
4031
|
+
method: "camera",
|
|
4032
|
+
message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight}` ,
|
|
4033
|
+
camera: {
|
|
4034
|
+
width: settings.width ?? video.videoWidth,
|
|
4035
|
+
height: settings.height ?? video.videoHeight,
|
|
4036
|
+
frameRate: settings.frameRate ?? null,
|
|
4037
|
+
facingMode: settings.facingMode ?? null
|
|
4038
|
+
}
|
|
4039
|
+
});
|
|
4040
|
+
}
|
|
1877
4041
|
|
|
1878
4042
|
const stop = () => {
|
|
1879
4043
|
stopped = true;
|
|
1880
4044
|
if (timer) clearTimeout(timer);
|
|
4045
|
+
observationHistory.clear();
|
|
1881
4046
|
for (const track of stream.getTracks()) track.stop();
|
|
1882
4047
|
if (video.srcObject === stream) video.srcObject = null;
|
|
1883
4048
|
};
|
|
1884
4049
|
|
|
1885
|
-
const scanNow = () => scanVideoFrame(video, {
|
|
4050
|
+
const scanNow = () => scanVideoFrame(video, {
|
|
4051
|
+
...options,
|
|
4052
|
+
maxDimension: options.maxDimension ?? 1080,
|
|
4053
|
+
canvas: scratchCanvas
|
|
4054
|
+
});
|
|
4055
|
+
|
|
4056
|
+
const emitResult = (result, capturedFrame = null, diagnostic = null) => {
|
|
4057
|
+
const frameMeta = capturedFrame?.imageData ? {
|
|
4058
|
+
frame: frameNumber,
|
|
4059
|
+
imageData: capturedFrame.imageData,
|
|
4060
|
+
scanWidth: capturedFrame.scanWidth,
|
|
4061
|
+
scanHeight: capturedFrame.scanHeight,
|
|
4062
|
+
sourceRect: capturedFrame.source ? { ...capturedFrame.source } : null,
|
|
4063
|
+
enhancedImageData: capturedFrame.enhancedImageData ?? null,
|
|
4064
|
+
enhancedRect: capturedFrame.enhancedRect ? { ...capturedFrame.enhancedRect } : null,
|
|
4065
|
+
enhancement: capturedFrame.enhancement ? { ...capturedFrame.enhancement } : null,
|
|
4066
|
+
diagnostic
|
|
4067
|
+
} : null;
|
|
4068
|
+
options.onResult?.(result, frameMeta);
|
|
4069
|
+
options.onDecode?.(result, frameMeta);
|
|
4070
|
+
if (options.stopOnResult ?? true) {
|
|
4071
|
+
stop();
|
|
4072
|
+
return true;
|
|
4073
|
+
}
|
|
4074
|
+
return false;
|
|
4075
|
+
};
|
|
4076
|
+
|
|
4077
|
+
const tryMultiFrameDecode = (observations) => {
|
|
4078
|
+
if (!multiFrameEnabled) return null;
|
|
4079
|
+
const best = selectBestFrameObservation(observations);
|
|
4080
|
+
if (!best) return null;
|
|
4081
|
+
const history = observationHistory.get(best.version) ?? [];
|
|
4082
|
+
history.push(best);
|
|
4083
|
+
while (history.length > multiFrameWindow) history.shift();
|
|
4084
|
+
observationHistory.set(best.version, history);
|
|
4085
|
+
if (history.length < multiFrameMinFrames) return null;
|
|
4086
|
+
|
|
4087
|
+
const combined = combineFrameObservations(history);
|
|
4088
|
+
if (!combined) return null;
|
|
4089
|
+
try {
|
|
4090
|
+
const decoded = decodeMatrix(combined.matrix, {
|
|
4091
|
+
structureTolerance: options.structureTolerance ?? 0.20,
|
|
4092
|
+
cellConfidence: combined.confidence,
|
|
4093
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
4094
|
+
});
|
|
4095
|
+
if (decoded.version !== best.version) return null;
|
|
4096
|
+
return {
|
|
4097
|
+
...decoded,
|
|
4098
|
+
perspectiveCorrected: Boolean(best.geometry),
|
|
4099
|
+
colorCalibrated: true,
|
|
4100
|
+
colorNormalization: "multi-frame-vote",
|
|
4101
|
+
samplingMode: "multi-frame-vote",
|
|
4102
|
+
multiFrameCombined: history.length,
|
|
4103
|
+
geometry: best.geometry,
|
|
4104
|
+
observedPalette: best.observedPalette,
|
|
4105
|
+
averageCellConfidence: best.averageCellConfidence,
|
|
4106
|
+
lowConfidenceCells: best.lowConfidenceCells
|
|
4107
|
+
};
|
|
4108
|
+
} catch {
|
|
4109
|
+
return null;
|
|
4110
|
+
}
|
|
4111
|
+
};
|
|
1886
4112
|
|
|
1887
4113
|
const loop = async () => {
|
|
1888
4114
|
if (stopped) return;
|
|
1889
4115
|
if (!busy && video.readyState >= 2) {
|
|
1890
4116
|
busy = true;
|
|
4117
|
+
frameNumber++;
|
|
4118
|
+
const observations = [];
|
|
4119
|
+
const frameDiagnostics = diagnosticsEnabled ? {} : null;
|
|
4120
|
+
const capturedFrame = {};
|
|
4121
|
+
const frameStarted = nowMs();
|
|
4122
|
+
let allowAutoEnhance = false;
|
|
4123
|
+
let allowFinderRecovery = false;
|
|
1891
4124
|
try {
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
4125
|
+
// The first frame stays minimal. The primary finder pass itself is now
|
|
4126
|
+
// QuadQR-aware (max RGB/value channel), so it remains cheap while no
|
|
4127
|
+
// longer confusing dark blue data with structural black. After a miss,
|
|
4128
|
+
// alternate frames may bracket finder thresholds and then use the
|
|
4129
|
+
// stronger color recovery path. Empty camera frames therefore do not
|
|
4130
|
+
// pay every recovery cost on every scan tick.
|
|
4131
|
+
allowFinderRecovery = options.finderRecovery !== false &&
|
|
4132
|
+
missStreak > 0 && ((missStreak - 1) % cameraFinderRecoveryEvery === 0);
|
|
4133
|
+
allowAutoEnhance = options.autoEnhanceRecovery !== false &&
|
|
4134
|
+
missStreak > 0 && ((missStreak - 1) % cameraAutoEnhanceEvery === 0);
|
|
4135
|
+
const method = allowAutoEnhance
|
|
4136
|
+
? "progressive-color-recovery"
|
|
4137
|
+
: (allowFinderRecovery ? "finder-recovery" : "fast-scan");
|
|
4138
|
+
const result = scanVideoFrame(video, {
|
|
4139
|
+
...options,
|
|
4140
|
+
_diagnosticLabel: method,
|
|
4141
|
+
finderRecovery: allowFinderRecovery,
|
|
4142
|
+
autoEnhanceRecovery: allowAutoEnhance,
|
|
4143
|
+
autoEnhanceWhenNoGeometry: allowAutoEnhance,
|
|
4144
|
+
// If geometry exists, the rectified QR-only pixel enhancer is both
|
|
4145
|
+
// stronger and much cheaper than reprocessing the whole live frame.
|
|
4146
|
+
fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
|
|
4147
|
+
maxDimension: options.maxDimension ?? 1080,
|
|
4148
|
+
canvas: scratchCanvas,
|
|
4149
|
+
_capturedFrame: capturedFrame,
|
|
4150
|
+
_observationCollector: observations,
|
|
4151
|
+
_frameDiagnostics: frameDiagnostics
|
|
4152
|
+
});
|
|
4153
|
+
const elapsedMs = nowMs() - frameStarted;
|
|
4154
|
+
emitDiagnostic({
|
|
4155
|
+
type: "frame",
|
|
4156
|
+
state: "decoded",
|
|
4157
|
+
method: result.recoveryMode ?? result.samplingMode ?? method,
|
|
4158
|
+
elapsedMs,
|
|
4159
|
+
missStreak,
|
|
4160
|
+
...frameDiagnostics
|
|
4161
|
+
});
|
|
4162
|
+
emitDiagnostic({
|
|
4163
|
+
type: "success",
|
|
4164
|
+
state: "decoded",
|
|
4165
|
+
method: result.recoveryMode ?? result.samplingMode ?? method,
|
|
4166
|
+
elapsedMs,
|
|
4167
|
+
message: `Decoded v${result.version} · ECC ${result.eccLevel} · ${Math.round(elapsedMs)} ms`,
|
|
4168
|
+
...frameDiagnostics
|
|
4169
|
+
});
|
|
4170
|
+
missStreak = 0;
|
|
4171
|
+
observationHistory.clear();
|
|
4172
|
+
if (emitResult(result, capturedFrame, frameDiagnostics)) return;
|
|
1898
4173
|
} catch (error) {
|
|
1899
|
-
|
|
4174
|
+
missStreak++;
|
|
4175
|
+
const fastElapsedMs = nowMs() - frameStarted;
|
|
4176
|
+
emitDiagnostic({
|
|
4177
|
+
type: "frame",
|
|
4178
|
+
state: "miss",
|
|
4179
|
+
method: allowAutoEnhance
|
|
4180
|
+
? "progressive-color-recovery"
|
|
4181
|
+
: (allowFinderRecovery ? "finder-recovery" : "fast-scan"),
|
|
4182
|
+
elapsedMs: fastElapsedMs,
|
|
4183
|
+
missStreak,
|
|
4184
|
+
error: error?.message ?? String(error),
|
|
4185
|
+
...frameDiagnostics
|
|
4186
|
+
});
|
|
4187
|
+
|
|
4188
|
+
// The user's real phone-camera case is dominated by color cast before
|
|
4189
|
+
// finder detection: Photoshop Auto Color alone makes the same live QR
|
|
4190
|
+
// immediately detectable. Retry the exact captured frame with a cheap
|
|
4191
|
+
// per-channel Auto Color levels correction before any geometry-dependent
|
|
4192
|
+
// recovery. This runs only after the normal fast scan fails, and by
|
|
4193
|
+
// default only on every other missed frame after the first one.
|
|
4194
|
+
const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
|
|
4195
|
+
capturedFrame.imageData &&
|
|
4196
|
+
((missStreak - 1) % cameraAutoColorEvery === 0);
|
|
4197
|
+
if (shouldTryCameraAutoColor) {
|
|
4198
|
+
const requestedCrops = Array.isArray(options.cameraAutoColorCropInsets)
|
|
4199
|
+
? options.cameraAutoColorCropInsets
|
|
4200
|
+
: [0.08, 0.16, 0.22, 0];
|
|
4201
|
+
const cropInsets = [];
|
|
4202
|
+
for (const value of requestedCrops) {
|
|
4203
|
+
const inset = clampNumber(Number(value), 0, 0.30);
|
|
4204
|
+
if (!cropInsets.some((item) => Math.abs(item - inset) < 0.001)) cropInsets.push(inset);
|
|
4205
|
+
}
|
|
4206
|
+
const explicitAnalysisInsets = Array.isArray(options.cameraAutoColorAnalysisInsets)
|
|
4207
|
+
? options.cameraAutoColorAnalysisInsets
|
|
4208
|
+
: null;
|
|
4209
|
+
|
|
4210
|
+
emitDiagnostic({
|
|
4211
|
+
type: "method",
|
|
4212
|
+
state: "trying",
|
|
4213
|
+
method: "camera-auto-color",
|
|
4214
|
+
message: `Fast scan failed · Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
|
|
4215
|
+
...frameDiagnostics
|
|
4216
|
+
});
|
|
4217
|
+
|
|
4218
|
+
let autoColorDecoded = false;
|
|
4219
|
+
for (let profileIndex = 0; profileIndex < cropInsets.length; profileIndex++) {
|
|
4220
|
+
const cropInset = cropInsets[profileIndex];
|
|
4221
|
+
const cropped = cropImageDataInset(capturedFrame.imageData, cropInset);
|
|
4222
|
+
const defaultAnalysisInsets = [0.10, 0.08, 0.04, 0.10];
|
|
4223
|
+
const analysisInset = clampNumber(
|
|
4224
|
+
Number(explicitAnalysisInsets?.[profileIndex]
|
|
4225
|
+
?? options.cameraAutoColorAnalysisInset
|
|
4226
|
+
?? defaultAnalysisInsets[Math.min(profileIndex, defaultAnalysisInsets.length - 1)]),
|
|
4227
|
+
0,
|
|
4228
|
+
0.30
|
|
4229
|
+
);
|
|
4230
|
+
const autoColorObservations = [];
|
|
4231
|
+
const autoColorVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
|
|
4232
|
+
const autoColorFrameDiagnostics = diagnosticsEnabled ? {} : null;
|
|
4233
|
+
const cropLabel = cropInset ? `${Math.round(cropInset * 100)}pct-crop` : "full";
|
|
4234
|
+
const profileName = `camera-auto-color-${cropLabel}`;
|
|
4235
|
+
try {
|
|
4236
|
+
const recoveryStarted = nowMs();
|
|
4237
|
+
const correctedFrame = autoColorImageData(cropped.imageData, {
|
|
4238
|
+
// This is intentionally performed on a centered recovery crop.
|
|
4239
|
+
// A live preview can contain large dark borders, browser UI, a
|
|
4240
|
+
// monitor bezel or room background. Those pixels completely
|
|
4241
|
+
// change global Auto Color/Otsu statistics even though the QR
|
|
4242
|
+
// itself looks identical to a saved crop. Photoshop succeeded
|
|
4243
|
+
// because the user's edited image was effectively QR-centric.
|
|
4244
|
+
blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
|
|
4245
|
+
whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
|
|
4246
|
+
highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
|
|
4247
|
+
outputHighlight: options.cameraAutoColorOutputHighlight ?? 190,
|
|
4248
|
+
analysisInset,
|
|
4249
|
+
minimumInputRange: options.cameraAutoColorMinimumInputRange ?? 72,
|
|
4250
|
+
targetSamples: options.cameraAutoColorTargetSamples ?? 90000
|
|
4251
|
+
});
|
|
4252
|
+
const recovered = scanImageData(correctedFrame, {
|
|
4253
|
+
...options,
|
|
4254
|
+
_diagnosticLabel: profileName,
|
|
4255
|
+
_visionDiagnostics: autoColorVisionDiagnostics,
|
|
4256
|
+
finderRecovery: true,
|
|
4257
|
+
autoEnhanceRecovery: false,
|
|
4258
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
4259
|
+
_observationCollector: autoColorObservations
|
|
4260
|
+
});
|
|
4261
|
+
if (autoColorFrameDiagnostics) {
|
|
4262
|
+
normalizeFrameDiagnostics(
|
|
4263
|
+
autoColorFrameDiagnostics,
|
|
4264
|
+
capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
|
|
4265
|
+
correctedFrame.width,
|
|
4266
|
+
correctedFrame.height,
|
|
4267
|
+
autoColorVisionDiagnostics
|
|
4268
|
+
);
|
|
4269
|
+
autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
|
|
4270
|
+
autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
|
|
4271
|
+
autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
|
|
4272
|
+
autoColorFrameDiagnostics.autoColorCropInset = cropInset;
|
|
4273
|
+
autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
|
|
4274
|
+
}
|
|
4275
|
+
const recoveryElapsedMs = nowMs() - recoveryStarted;
|
|
4276
|
+
emitDiagnostic({
|
|
4277
|
+
type: "frame",
|
|
4278
|
+
state: "decoded",
|
|
4279
|
+
method: profileName,
|
|
4280
|
+
elapsedMs: recoveryElapsedMs,
|
|
4281
|
+
missStreak,
|
|
4282
|
+
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4283
|
+
});
|
|
4284
|
+
emitDiagnostic({
|
|
4285
|
+
type: "success",
|
|
4286
|
+
state: "decoded",
|
|
4287
|
+
method: "camera-auto-color",
|
|
4288
|
+
elapsedMs: recoveryElapsedMs,
|
|
4289
|
+
message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
4290
|
+
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4291
|
+
});
|
|
4292
|
+
capturedFrame.enhancedImageData = correctedFrame;
|
|
4293
|
+
capturedFrame.enhancedRect = { ...cropped.rect };
|
|
4294
|
+
capturedFrame.enhancement = {
|
|
4295
|
+
method: "camera-auto-color",
|
|
4296
|
+
cropInset,
|
|
4297
|
+
analysisInset
|
|
4298
|
+
};
|
|
4299
|
+
missStreak = 0;
|
|
4300
|
+
observationHistory.clear();
|
|
4301
|
+
autoColorDecoded = true;
|
|
4302
|
+
if (emitResult({
|
|
4303
|
+
...recovered,
|
|
4304
|
+
autoColorCorrected: true,
|
|
4305
|
+
cameraProgressiveRecovery: true,
|
|
4306
|
+
recoveryMode: "camera-auto-color",
|
|
4307
|
+
cameraAutoColorCropInset: cropInset,
|
|
4308
|
+
cameraAutoColorAnalysisInset: analysisInset
|
|
4309
|
+
}, capturedFrame, autoColorFrameDiagnostics ?? frameDiagnostics)) return;
|
|
4310
|
+
break;
|
|
4311
|
+
} catch (recoveryError) {
|
|
4312
|
+
observations.push(...autoColorObservations);
|
|
4313
|
+
if (autoColorFrameDiagnostics) {
|
|
4314
|
+
normalizeFrameDiagnostics(
|
|
4315
|
+
autoColorFrameDiagnostics,
|
|
4316
|
+
capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
|
|
4317
|
+
cropped.imageData.width,
|
|
4318
|
+
cropped.imageData.height,
|
|
4319
|
+
autoColorVisionDiagnostics
|
|
4320
|
+
);
|
|
4321
|
+
autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
|
|
4322
|
+
autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
|
|
4323
|
+
autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
|
|
4324
|
+
autoColorFrameDiagnostics.autoColorCropInset = cropInset;
|
|
4325
|
+
autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
|
|
4326
|
+
emitDiagnostic({
|
|
4327
|
+
type: "frame",
|
|
4328
|
+
state: "miss",
|
|
4329
|
+
method: profileName,
|
|
4330
|
+
elapsedMs: nowMs() - frameStarted,
|
|
4331
|
+
missStreak,
|
|
4332
|
+
error: recoveryError?.message ?? String(recoveryError),
|
|
4333
|
+
...autoColorFrameDiagnostics
|
|
4334
|
+
});
|
|
4335
|
+
}
|
|
4336
|
+
emitDiagnostic({
|
|
4337
|
+
type: "method",
|
|
4338
|
+
state: "failed",
|
|
4339
|
+
method: profileName,
|
|
4340
|
+
message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
|
|
4341
|
+
...(autoColorFrameDiagnostics ?? frameDiagnostics)
|
|
4342
|
+
});
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
if (!autoColorDecoded) {
|
|
4346
|
+
emitDiagnostic({
|
|
4347
|
+
type: "method",
|
|
4348
|
+
state: "failed",
|
|
4349
|
+
method: "camera-auto-color",
|
|
4350
|
+
message: "All camera Auto Color profiles failed · continuing deeper recovery",
|
|
4351
|
+
...frameDiagnostics
|
|
4352
|
+
});
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
// If the fast pass already saw a convincing QuadQR structure, retry
|
|
4357
|
+
// the exact same captured ROI immediately with the stronger QR-region
|
|
4358
|
+
// color correction. Empty/non-QR frames do not pay this cost. This is
|
|
4359
|
+
// the live equivalent of: normal scan first, then Auto Tone / Contrast
|
|
4360
|
+
// / Color only when the normal decode actually needs help.
|
|
4361
|
+
if (!allowAutoEnhance && options.autoEnhanceRecovery !== false) {
|
|
4362
|
+
const strongObservation = selectBestFrameObservation(observations);
|
|
4363
|
+
if (strongObservation && scratchCanvas.width && scratchCanvas.height) {
|
|
4364
|
+
emitDiagnostic({
|
|
4365
|
+
type: "method",
|
|
4366
|
+
state: "trying",
|
|
4367
|
+
method: "qr-region-auto-enhance",
|
|
4368
|
+
message: "Fast decode failed · trying QR-region Auto Tone / Contrast / Color",
|
|
4369
|
+
...frameDiagnostics
|
|
4370
|
+
});
|
|
4371
|
+
try {
|
|
4372
|
+
const recoveryStarted = nowMs();
|
|
4373
|
+
const frameContext = scratchCanvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
4374
|
+
const capturedImageData = frameContext.getImageData(0, 0, scratchCanvas.width, scratchCanvas.height);
|
|
4375
|
+
const recoveryObservations = [];
|
|
4376
|
+
const recoveryVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
|
|
4377
|
+
const recovered = scanImageData(capturedImageData, {
|
|
4378
|
+
...options,
|
|
4379
|
+
_diagnosticLabel: "qr-region-auto-enhance",
|
|
4380
|
+
_visionDiagnostics: recoveryVisionDiagnostics,
|
|
4381
|
+
autoEnhanceRecovery: true,
|
|
4382
|
+
autoEnhanceWhenNoGeometry: false,
|
|
4383
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
4384
|
+
_observationCollector: recoveryObservations
|
|
4385
|
+
});
|
|
4386
|
+
const recoveryElapsedMs = nowMs() - recoveryStarted;
|
|
4387
|
+
emitDiagnostic({
|
|
4388
|
+
type: "success",
|
|
4389
|
+
state: "decoded",
|
|
4390
|
+
method: recovered.recoveryMode ?? recovered.samplingMode ?? "qr-region-auto-enhance",
|
|
4391
|
+
elapsedMs: recoveryElapsedMs,
|
|
4392
|
+
message: `Recovery decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
4393
|
+
...frameDiagnostics
|
|
4394
|
+
});
|
|
4395
|
+
missStreak = 0;
|
|
4396
|
+
observationHistory.clear();
|
|
4397
|
+
if (emitResult({
|
|
4398
|
+
...recovered,
|
|
4399
|
+
cameraProgressiveRecovery: true
|
|
4400
|
+
}, capturedFrame, frameDiagnostics)) return;
|
|
4401
|
+
} catch {
|
|
4402
|
+
emitDiagnostic({
|
|
4403
|
+
type: "method",
|
|
4404
|
+
state: "failed",
|
|
4405
|
+
method: "qr-region-auto-enhance",
|
|
4406
|
+
message: "QR-region color recovery did not decode · trying multi-frame ECC",
|
|
4407
|
+
...frameDiagnostics
|
|
4408
|
+
});
|
|
4409
|
+
// Keep the original observations for multi-frame voting below.
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
const combined = tryMultiFrameDecode(observations);
|
|
4415
|
+
if (combined) {
|
|
4416
|
+
emitDiagnostic({
|
|
4417
|
+
type: "success",
|
|
4418
|
+
state: "decoded",
|
|
4419
|
+
method: "multi-frame-vote",
|
|
4420
|
+
message: `Multi-frame recovery decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
|
|
4421
|
+
...frameDiagnostics
|
|
4422
|
+
});
|
|
4423
|
+
missStreak = 0;
|
|
4424
|
+
if (emitResult(combined, capturedFrame, frameDiagnostics)) return;
|
|
4425
|
+
} else {
|
|
4426
|
+
options.onScanMiss?.(error);
|
|
4427
|
+
}
|
|
1900
4428
|
} finally {
|
|
1901
4429
|
busy = false;
|
|
1902
4430
|
}
|
|
@@ -1954,6 +4482,10 @@ export const internals = Object.freeze({
|
|
|
1954
4482
|
decodeRsAdaptive,
|
|
1955
4483
|
encodeProtectedHeader,
|
|
1956
4484
|
decodeProtectedHeader,
|
|
4485
|
+
selectBestFrameObservation,
|
|
4486
|
+
combineFrameObservations,
|
|
4487
|
+
visibleVideoSourceRect,
|
|
4488
|
+
cropImageDataInset,
|
|
1957
4489
|
HEADER_CODEWORD_CELLS,
|
|
1958
4490
|
COMPACT_HEADER_CODEWORD_CELLS,
|
|
1959
4491
|
CELLS_PER_BYTE,
|