quadqr-js 1.3.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FORMAT.md +8 -6
- package/README.md +68 -43
- package/SPECIFICATION.md +23 -8
- package/bin/quadqr.js +4 -2
- package/dist/esm/benchmark.js +37 -6
- package/dist/esm/brotli.js +2210 -0
- package/dist/esm/deflate.js +381 -0
- package/dist/esm/quadqr.js +1053 -121
- package/dist/esm/security.js +19 -0
- package/dist/esm/vision.js +244 -66
- package/dist/quadqr.js +4085 -370
- package/dist/quadqr.min.js +3799 -366
- package/docs/API.md +42 -11
- package/docs/BROWSER_CDN.md +9 -5
- package/docs/CLI.md +13 -2
- package/docs/COMPRESSION.md +170 -0
- package/docs/GETTING_STARTED.md +12 -2
- package/docs/HIGH_DENSITY_MODE.md +2 -2
- package/docs/NODE.md +22 -0
- package/docs/README.md +7 -5
- package/docs/RELIABILITY_LAB.md +64 -0
- package/docs/TRIANGLE16.md +7 -17
- package/docs/WASM.md +1 -1
- package/package.json +9 -10
- package/types/benchmark.d.ts +0 -8
- package/types/index.d.ts +0 -403
- package/types/node.d.ts +0 -11
package/dist/esm/security.js
CHANGED
|
@@ -125,6 +125,25 @@ export function generateRaw256Key() {
|
|
|
125
125
|
return key;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Return the fixed number of bytes added around a plaintext by Secure Payload
|
|
130
|
+
* v1 for the supplied mode. AES-GCM ciphertext length matches plaintext length,
|
|
131
|
+
* so this lets higher layers reason about final QuadQR version boundaries
|
|
132
|
+
* before encryption runs.
|
|
133
|
+
*/
|
|
134
|
+
export function estimateSecureEnvelopeOverhead(security = {}) {
|
|
135
|
+
const mode = normalizeMode(security.mode);
|
|
136
|
+
let keyIdLength = 0;
|
|
137
|
+
if (mode === SECURITY_MODES.PASSWORD) {
|
|
138
|
+
if (security.keyId != null && security.keyId !== false) keyIdLength = encodeKeyId(security.keyId).bytes.length;
|
|
139
|
+
return FIXED_HEADER_BYTES + SALT_BYTES + NONCE_BYTES + TAG_BYTES + keyIdLength;
|
|
140
|
+
}
|
|
141
|
+
if (security.keyId === false) keyIdLength = 0;
|
|
142
|
+
else if (security.keyId != null) keyIdLength = encodeKeyId(security.keyId).bytes.length;
|
|
143
|
+
else keyIdLength = AUTO_KEY_ID_BYTES;
|
|
144
|
+
return FIXED_HEADER_BYTES + NONCE_BYTES + TAG_BYTES + keyIdLength;
|
|
145
|
+
}
|
|
146
|
+
|
|
128
147
|
function randomBytes(length) {
|
|
129
148
|
const bytes = new Uint8Array(length);
|
|
130
149
|
cryptoApi().getRandomValues(bytes);
|
package/dist/esm/vision.js
CHANGED
|
@@ -329,9 +329,15 @@ function cross(a, b) {
|
|
|
329
329
|
return a.x * b.y - a.y * b.x;
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
-
function chooseFinderTriples(candidates, maxTriples = 16) {
|
|
333
|
-
const
|
|
332
|
+
function chooseFinderTriples(candidates, maxTriples = 16, options = {}) {
|
|
333
|
+
const perspectiveRecovery = options.perspectiveRecovery === true;
|
|
334
|
+
const topLimit = perspectiveRecovery ? 18 : 14;
|
|
335
|
+
const top = candidates.slice(0, Math.min(candidates.length, topLimit));
|
|
334
336
|
const triples = [];
|
|
337
|
+
const maxCornerCos = options.maxCornerCos ?? (perspectiveRecovery ? 0.84 : 0.55);
|
|
338
|
+
const maxModuleSpread = options.maxFinderModuleSpread ?? (perspectiveRecovery ? 0.78 : 0.5);
|
|
339
|
+
const maxLegRatio = options.maxFinderLegRatio ?? (perspectiveRecovery ? 3.6 : 2.1);
|
|
340
|
+
const minLegModules = options.minFinderLegModules ?? (perspectiveRecovery ? 7.5 : 10);
|
|
335
341
|
|
|
336
342
|
for (let a = 0; a < top.length - 2; a++) {
|
|
337
343
|
for (let b = a + 1; b < top.length - 1; b++) {
|
|
@@ -346,9 +352,9 @@ function chooseFinderTriples(candidates, maxTriples = 16) {
|
|
|
346
352
|
let v = sub(bl, tl);
|
|
347
353
|
let d1 = Math.hypot(u.x, u.y);
|
|
348
354
|
let d2 = Math.hypot(v.x, v.y);
|
|
349
|
-
if (d1 < tl.moduleSize *
|
|
355
|
+
if (d1 < tl.moduleSize * minLegModules || d2 < tl.moduleSize * minLegModules) continue;
|
|
350
356
|
const cos = Math.abs(dot(u, v) / (d1 * d2));
|
|
351
|
-
if (cos >
|
|
357
|
+
if (cos > maxCornerCos) continue;
|
|
352
358
|
if (cross(u, v) < 0) {
|
|
353
359
|
[tr, bl] = [bl, tr];
|
|
354
360
|
u = sub(tr, tl);
|
|
@@ -362,13 +368,26 @@ function chooseFinderTriples(candidates, maxTriples = 16) {
|
|
|
362
368
|
Math.abs(tr.moduleSize - moduleMean),
|
|
363
369
|
Math.abs(bl.moduleSize - moduleMean)
|
|
364
370
|
) / moduleMean;
|
|
365
|
-
if (moduleSpread >
|
|
371
|
+
if (moduleSpread > maxModuleSpread) continue;
|
|
366
372
|
const legRatio = Math.max(d1, d2) / Math.min(d1, d2);
|
|
367
|
-
if (legRatio >
|
|
373
|
+
if (legRatio > maxLegRatio) continue;
|
|
368
374
|
const area = Math.abs(cross(u, v));
|
|
369
375
|
const confirmScore = tl.confirmations + tr.confirmations + bl.confirmations;
|
|
370
|
-
const
|
|
371
|
-
|
|
376
|
+
const recoveryPenalty = perspectiveRecovery
|
|
377
|
+
? 1 + Math.max(0, cos - 0.55) * 7 + Math.max(0, moduleSpread - 0.5) * 5 + Math.max(0, legRatio - 2.1) * 2
|
|
378
|
+
: 1;
|
|
379
|
+
const score = area / ((1 + cos * 8 + moduleSpread * 5 + Math.max(0, legRatio - 1) * 2) * recoveryPenalty) + confirmScore * 100;
|
|
380
|
+
triples.push({
|
|
381
|
+
tl,
|
|
382
|
+
tr,
|
|
383
|
+
bl,
|
|
384
|
+
moduleMean,
|
|
385
|
+
moduleSpread,
|
|
386
|
+
legRatio,
|
|
387
|
+
score,
|
|
388
|
+
orthogonality: 1 - cos,
|
|
389
|
+
perspectiveRecovery
|
|
390
|
+
});
|
|
372
391
|
}
|
|
373
392
|
}
|
|
374
393
|
}
|
|
@@ -467,28 +486,158 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
|
467
486
|
y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
|
|
468
487
|
};
|
|
469
488
|
|
|
470
|
-
let best = { score: 0, center: predicted, scale: 1 };
|
|
471
489
|
const precise = options.preciseAlignment === true;
|
|
472
|
-
const
|
|
490
|
+
const localScoreFn = precise ? preciseAlignmentScore : alignmentScore;
|
|
491
|
+
const localOffsets = precise
|
|
473
492
|
? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
|
|
474
493
|
: [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
|
|
475
|
-
const
|
|
476
|
-
const
|
|
494
|
+
const finderSizes = [triple.tl.moduleSize, triple.tr.moduleSize, triple.bl.moduleSize];
|
|
495
|
+
const finderMin = Math.max(0.01, Math.min(...finderSizes));
|
|
496
|
+
const finderMax = Math.max(...finderSizes);
|
|
497
|
+
const finderScaleRatio = finderMax / finderMin;
|
|
498
|
+
const inferredTargetScale = clamp(
|
|
499
|
+
Math.sqrt(
|
|
500
|
+
Math.max(0.2, triple.tr.moduleSize / Math.max(0.01, triple.tl.moduleSize)) *
|
|
501
|
+
Math.max(0.2, triple.bl.moduleSize / Math.max(0.01, triple.tl.moduleSize))
|
|
502
|
+
),
|
|
503
|
+
0.58,
|
|
504
|
+
1.75
|
|
505
|
+
);
|
|
506
|
+
const localScales = [...new Set([
|
|
507
|
+
0.72, 0.85, 1, 1.15, 1.3,
|
|
508
|
+
Number(inferredTargetScale.toFixed(3)),
|
|
509
|
+
Number((inferredTargetScale * 0.88).toFixed(3)),
|
|
510
|
+
Number((inferredTargetScale * 1.12).toFixed(3))
|
|
511
|
+
])].filter((scale) => scale >= 0.52 && scale <= 1.9);
|
|
512
|
+
|
|
513
|
+
const centerAt = (offsetU, offsetV) => ({
|
|
514
|
+
x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
|
|
515
|
+
y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
|
|
516
|
+
});
|
|
517
|
+
let best = { score: 0, center: predicted, scale: 1, offsetU: 0, offsetV: 0, broadSearch: false };
|
|
477
518
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
519
|
+
const evaluateIntoBest = (offsetU, offsetV, scales, scoreFn, broadSearch = false) => {
|
|
520
|
+
const center = centerAt(offsetU, offsetV);
|
|
521
|
+
for (const scale of scales) {
|
|
522
|
+
const score = scoreFn(binary, width, height, center, basisU, basisV, scale, target);
|
|
523
|
+
if (score > best.score) best = { score, center, scale, offsetU, offsetV, broadSearch };
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
for (const ou of localOffsets) {
|
|
528
|
+
for (const ov of localOffsets) evaluateIntoBest(ou, ov, localScales, localScoreFn, false);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// A three-finder affine extrapolation becomes increasingly wrong as the
|
|
532
|
+
// symbol tilts away from the camera. Under strong yaw/pitch the primary
|
|
533
|
+
// bottom-right alignment marker can move 8-15 modules away from the affine
|
|
534
|
+
// prediction even though all three finder patterns remain strong. Use a
|
|
535
|
+
// bounded coarse-to-fine search only when the locator geometry signals that
|
|
536
|
+
// projective foreshortening is present.
|
|
537
|
+
const cornerSkew = Math.max(0, 1 - (triple.orthogonality ?? 1));
|
|
538
|
+
const projectiveSignal = Math.max(
|
|
539
|
+
Math.max(0, finderScaleRatio - 1),
|
|
540
|
+
Math.max(0, (triple.moduleSpread ?? 0) * 1.25),
|
|
541
|
+
cornerSkew * 0.75
|
|
542
|
+
);
|
|
543
|
+
const broadEnabled = options.perspectiveAlignmentRecovery !== false && (
|
|
544
|
+
options.perspectiveRecovery === true ||
|
|
545
|
+
projectiveSignal >= (options.perspectiveAlignmentSignalThreshold ?? 0.32)
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
if (broadEnabled && best.score < (options.perspectiveAlignmentEarlyAccept ?? 0.78)) {
|
|
549
|
+
const radius = clamp(
|
|
550
|
+
Number(options.perspectiveAlignmentRadius ?? (3.5 + projectiveSignal * 17)),
|
|
551
|
+
4,
|
|
552
|
+
options.perspectiveAlignmentMaxRadius ?? 17
|
|
553
|
+
);
|
|
554
|
+
const coarseStep = radius > 12 ? 3 : radius > 8 ? 2.5 : 2;
|
|
555
|
+
const coarseScales = [...new Set([
|
|
556
|
+
Number((inferredTargetScale * 0.78).toFixed(3)),
|
|
557
|
+
Number((inferredTargetScale * 0.92).toFixed(3)),
|
|
558
|
+
Number(inferredTargetScale.toFixed(3)),
|
|
559
|
+
Number((inferredTargetScale * 1.10).toFixed(3)),
|
|
560
|
+
Number((inferredTargetScale * 1.28).toFixed(3)),
|
|
561
|
+
0.68, 0.82, 1, 1.18, 1.38, 1.58
|
|
562
|
+
])].filter((scale) => scale >= 0.48 && scale <= 2.05);
|
|
563
|
+
const coarseOffsets = [0];
|
|
564
|
+
for (let offset = coarseStep; offset <= radius + 0.001; offset += coarseStep) {
|
|
565
|
+
coarseOffsets.push(offset, -offset);
|
|
566
|
+
}
|
|
567
|
+
if (!coarseOffsets.some((value) => Math.abs(Math.abs(value) - radius) < 0.35)) {
|
|
568
|
+
coarseOffsets.push(radius, -radius);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const seeds = [];
|
|
572
|
+
for (const ou of coarseOffsets) {
|
|
573
|
+
for (const ov of coarseOffsets) {
|
|
574
|
+
if (Math.abs(ou) <= 2.75 && Math.abs(ov) <= 2.75) continue;
|
|
575
|
+
const center = centerAt(ou, ov);
|
|
576
|
+
let seedScore = 0;
|
|
577
|
+
let seedScale = 1;
|
|
578
|
+
// Keep the coarse pass cheap. Fine refinement below uses the denser
|
|
579
|
+
// sub-cell alignment score to reject data-cell lookalikes.
|
|
580
|
+
for (const scale of coarseScales) {
|
|
581
|
+
const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
|
|
582
|
+
if (score > seedScore) {
|
|
583
|
+
seedScore = score;
|
|
584
|
+
seedScale = scale;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
seeds.push({ score: seedScore, scale: seedScale, offsetU: ou, offsetV: ov });
|
|
487
588
|
}
|
|
488
589
|
}
|
|
590
|
+
seeds.sort((a, b) => b.score - a.score);
|
|
591
|
+
|
|
592
|
+
let broadBest = null;
|
|
593
|
+
const fineSeeds = seeds.slice(0, options.perspectiveAlignmentFineSeeds ?? 6);
|
|
594
|
+
const fineOffsets = [-1.25, -0.75, -0.4, 0, 0.4, 0.75, 1.25];
|
|
595
|
+
for (const seed of fineSeeds) {
|
|
596
|
+
const fineScales = [...new Set([
|
|
597
|
+
Number((seed.scale * 0.86).toFixed(3)),
|
|
598
|
+
Number((seed.scale * 0.94).toFixed(3)),
|
|
599
|
+
Number(seed.scale.toFixed(3)),
|
|
600
|
+
Number((seed.scale * 1.06).toFixed(3)),
|
|
601
|
+
Number((seed.scale * 1.16).toFixed(3)),
|
|
602
|
+
Number(inferredTargetScale.toFixed(3)),
|
|
603
|
+
1.0, 1.15, 1.3
|
|
604
|
+
])].filter((scale) => scale >= 0.46 && scale <= 2.1);
|
|
605
|
+
for (const du of fineOffsets) {
|
|
606
|
+
for (const dv of fineOffsets) {
|
|
607
|
+
const offsetU = seed.offsetU + du;
|
|
608
|
+
const offsetV = seed.offsetV + dv;
|
|
609
|
+
const center = centerAt(offsetU, offsetV);
|
|
610
|
+
for (const scale of fineScales) {
|
|
611
|
+
const score = preciseAlignmentScore(binary, width, height, center, basisU, basisV, scale, target);
|
|
612
|
+
if (!broadBest || score > broadBest.score) {
|
|
613
|
+
broadBest = { score, center, scale, offsetU, offsetV, broadSearch: true };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The precise score probes each alignment module at several sub-cell
|
|
621
|
+
// positions, making it substantially harder for random payload cells to
|
|
622
|
+
// impersonate the primary reference. Prefer a strong projective candidate
|
|
623
|
+
// even if the cheap centre-only local score happened to be slightly higher.
|
|
624
|
+
if (broadBest && (
|
|
625
|
+
broadBest.score >= (options.perspectiveAlignmentPreciseThreshold ?? 0.80) ||
|
|
626
|
+
broadBest.score > best.score + 0.08
|
|
627
|
+
)) {
|
|
628
|
+
best = broadBest;
|
|
629
|
+
}
|
|
489
630
|
}
|
|
490
631
|
|
|
491
|
-
return {
|
|
632
|
+
return {
|
|
633
|
+
...best,
|
|
634
|
+
basisU,
|
|
635
|
+
basisV,
|
|
636
|
+
predicted,
|
|
637
|
+
target,
|
|
638
|
+
finderScaleRatio,
|
|
639
|
+
projectiveSignal
|
|
640
|
+
};
|
|
492
641
|
}
|
|
493
642
|
|
|
494
643
|
function projectedAlignmentScore(binary, width, height, homography, pattern) {
|
|
@@ -793,7 +942,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
793
942
|
const minVersion = options.minVersion ?? 1;
|
|
794
943
|
const maxVersion = options.maxVersion ?? 40;
|
|
795
944
|
const maxCandidates = options.maxCandidates ?? 8;
|
|
796
|
-
const triples = chooseFinderTriples(finders, 20);
|
|
945
|
+
const triples = chooseFinderTriples(finders, options.perspectiveRecovery ? 30 : 20, options);
|
|
797
946
|
const geometries = [];
|
|
798
947
|
|
|
799
948
|
for (const triple of triples) {
|
|
@@ -954,6 +1103,7 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
954
1103
|
const geometryOptions = {
|
|
955
1104
|
...options,
|
|
956
1105
|
finderMethod: pass.finderMethod,
|
|
1106
|
+
perspectiveRecovery: recovery || options.perspectiveRecovery === true,
|
|
957
1107
|
alignmentThreshold: recovery ? 0.68 : 0.72,
|
|
958
1108
|
alignmentGridThreshold: recovery ? 0.64 : 0.68
|
|
959
1109
|
};
|
|
@@ -1005,7 +1155,7 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
1005
1155
|
return { finders, geometries };
|
|
1006
1156
|
};
|
|
1007
1157
|
|
|
1008
|
-
// Fast path stays exactly one grayscale + finder pass. No Auto Color, extra
|
|
1158
|
+
// Fast path stays exactly one grayscale + finder pass. No QuadQR Auto Color, extra
|
|
1009
1159
|
// thresholding, or luminance image is computed when a normal frame works.
|
|
1010
1160
|
const valueInfo = buildBinary(imageData, { grayMode: "value" });
|
|
1011
1161
|
const fast = evaluatePass({
|
|
@@ -1019,7 +1169,7 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
1019
1169
|
|
|
1020
1170
|
// If the clean threshold already found exactly two strong locators, try the
|
|
1021
1171
|
// bounded perspective-tolerant third-finder pass before any color processing.
|
|
1022
|
-
// This is substantially cheaper than Auto Color and targets the dense-code
|
|
1172
|
+
// This is substantially cheaper than QuadQR Auto Color and targets the dense-code
|
|
1023
1173
|
// projective failure mode directly.
|
|
1024
1174
|
if (fast.finders.length === 2) {
|
|
1025
1175
|
const recoveredFinders = recoverFinderSetFromTwo(valueInfo.binary, width, height, fast.finders, {});
|
|
@@ -1049,12 +1199,12 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
1049
1199
|
}
|
|
1050
1200
|
}
|
|
1051
1201
|
|
|
1052
|
-
// Camera recovery #1:
|
|
1202
|
+
// Camera recovery #1: QuadQR Auto Color per-channel levels before
|
|
1053
1203
|
// finder thresholding. Live camera frames are usually much larger than the
|
|
1054
1204
|
// QR itself, so a single global histogram can be dominated by dark room/UI
|
|
1055
|
-
// pixels around the guide.
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1205
|
+
// pixels around the guide. QuadQR keeps recovery deliberately code-centric
|
|
1206
|
+
// by using center-weighted analysis windows so surrounding scene pixels do not
|
|
1207
|
+
// dominate the correction, while still applying
|
|
1058
1208
|
// each correction to the full frame so finder coordinates never move.
|
|
1059
1209
|
const requestedInsets = Array.isArray(options.finderAutoColorAnalysisInsets)
|
|
1060
1210
|
? options.finderAutoColorAnalysisInsets
|
|
@@ -1100,7 +1250,7 @@ export function detectCodeGeometry(imageData, options = {}) {
|
|
|
1100
1250
|
|
|
1101
1251
|
// Camera recovery #2: bracket the raw value-channel threshold, then retain
|
|
1102
1252
|
// the legacy luminance pass for unusual captures. These are only built after
|
|
1103
|
-
// both the normal and Auto Color finder passes fail.
|
|
1253
|
+
// both the normal and QuadQR Auto Color finder passes fail.
|
|
1104
1254
|
const highThreshold = clamp(valueInfo.baseThreshold + 18, 8, 247);
|
|
1105
1255
|
const lowThreshold = clamp(valueInfo.baseThreshold - 14, 8, 247);
|
|
1106
1256
|
const recoveryPasses = [];
|
|
@@ -1214,7 +1364,7 @@ function buildAutoColorLevels(imageData, options = {}) {
|
|
|
1214
1364
|
// Camera recovery deliberately supports analysing only the central part of
|
|
1215
1365
|
// the visible frame. A phone preview often contains very dark UI/screen
|
|
1216
1366
|
// edges outside the code; letting those pixels define the black point makes
|
|
1217
|
-
// an otherwise useful Auto Color pass far too weak.
|
|
1367
|
+
// an otherwise useful QuadQR Auto Color pass far too weak.
|
|
1218
1368
|
for (let index = 0; index < pixelCount; index += step) {
|
|
1219
1369
|
const x = x0 + (index % analysisWidth);
|
|
1220
1370
|
const y = y0 + Math.floor(index / analysisWidth);
|
|
@@ -1239,8 +1389,8 @@ function buildAutoColorLevels(imageData, options = {}) {
|
|
|
1239
1389
|
let highs;
|
|
1240
1390
|
const outputHighlight = Number(options.outputHighlight);
|
|
1241
1391
|
if (Number.isFinite(outputHighlight)) {
|
|
1242
|
-
// Strong
|
|
1243
|
-
//
|
|
1392
|
+
// Strong QuadQR camera color-recovery mode. QuadQR Auto Color does not
|
|
1393
|
+
// stretch the brightest observed paper/white
|
|
1244
1394
|
// cells all the way to 255. Instead it anchors the per-channel shadow
|
|
1245
1395
|
// points close to black while keeping the observed highlight around a
|
|
1246
1396
|
// neutral mid-high value. That produces much darker structural black and
|
|
@@ -1313,7 +1463,7 @@ function buildAutoToneContrastColorTransform(samples, options = {}) {
|
|
|
1313
1463
|
const highlightFraction = clamp(options.highlightFraction ?? 0.14, 0.04, 0.35);
|
|
1314
1464
|
const saturation = clamp(options.saturation ?? 1.12, 1, 1.5);
|
|
1315
1465
|
|
|
1316
|
-
// Auto Color-style neutralization: use the brightest portion of the frame as
|
|
1466
|
+
// QuadQR Auto Color-style neutralization: use the brightest portion of the frame as
|
|
1317
1467
|
// a likely white reference. This is especially effective on warm/yellow
|
|
1318
1468
|
// phone-camera frames where the blue channel is suppressed.
|
|
1319
1469
|
const luminanceHistogram = new Uint32Array(256);
|
|
@@ -1463,35 +1613,54 @@ export function samplePerspectiveMatrix(imageData, homography, size, options = {
|
|
|
1463
1613
|
* module borders. This makes the decoder much less sensitive to antialiasing,
|
|
1464
1614
|
* blur, resampling and small homography errors than center sampling.
|
|
1465
1615
|
*/
|
|
1616
|
+
function rgbSampleSpread(values, center) {
|
|
1617
|
+
if (!values.length) return 0;
|
|
1618
|
+
return values.reduce((sum, rgb) => sum + Math.hypot(
|
|
1619
|
+
rgb.r - center.r,
|
|
1620
|
+
rgb.g - center.g,
|
|
1621
|
+
rgb.b - center.b
|
|
1622
|
+
), 0) / values.length;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1466
1625
|
export function samplePerspectiveTriangleMatrix(imageData, homography, size, options = {}) {
|
|
1467
1626
|
const triangleGrid = Array.from({ length: size }, () => new Array(size));
|
|
1468
1627
|
const mode = options.sampleMode ?? "cross";
|
|
1469
1628
|
const radius = options.highDensitySampleRadius ?? options.triangleSampleRadius ??
|
|
1470
|
-
(mode === "median" ? 0.
|
|
1471
|
-
const inset = Math.max(0.18, Math.min(0.
|
|
1472
|
-
const firstOffset = inset;
|
|
1473
|
-
const secondOffset = 1 - inset;
|
|
1629
|
+
(mode === "median" ? 0.055 : 0.065);
|
|
1630
|
+
const inset = Math.max(0.18, Math.min(0.34, Number(options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.27)));
|
|
1474
1631
|
const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
|
|
1475
1632
|
const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
|
|
1476
1633
|
const sampler = mode === "median" ? robustProjectedSample : averageProjectedSample;
|
|
1634
|
+
const side = Math.max(0.18, inset - 0.06);
|
|
1635
|
+
const middle = Math.min(0.52, 0.50 - inset * 0.04);
|
|
1636
|
+
const firstAnchors = [
|
|
1637
|
+
[inset, inset],
|
|
1638
|
+
[side, middle],
|
|
1639
|
+
[middle, side]
|
|
1640
|
+
];
|
|
1641
|
+
const secondAnchors = firstAnchors.map(([x, y]) => [1 - x, 1 - y]);
|
|
1642
|
+
|
|
1643
|
+
const sampleRegion = (c, r, anchors) => {
|
|
1644
|
+
const values = anchors.map(([x, y]) => sampler(
|
|
1645
|
+
imageData,
|
|
1646
|
+
homography,
|
|
1647
|
+
c + x + offsetX,
|
|
1648
|
+
r + y + offsetY,
|
|
1649
|
+
radius
|
|
1650
|
+
));
|
|
1651
|
+
const rgb = robustRgb(values);
|
|
1652
|
+
return { rgb, spread: rgbSampleSpread(values, rgb) };
|
|
1653
|
+
};
|
|
1477
1654
|
|
|
1478
1655
|
for (let r = 0; r < size; r++) {
|
|
1479
1656
|
for (let c = 0; c < size; c++) {
|
|
1657
|
+
const first = sampleRegion(c, r, firstAnchors);
|
|
1658
|
+
const second = sampleRegion(c, r, secondAnchors);
|
|
1480
1659
|
triangleGrid[r][c] = {
|
|
1481
|
-
first:
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
r + firstOffset + offsetY,
|
|
1486
|
-
radius
|
|
1487
|
-
),
|
|
1488
|
-
second: sampler(
|
|
1489
|
-
imageData,
|
|
1490
|
-
homography,
|
|
1491
|
-
c + secondOffset + offsetX,
|
|
1492
|
-
r + secondOffset + offsetY,
|
|
1493
|
-
radius
|
|
1494
|
-
)
|
|
1660
|
+
first: first.rgb,
|
|
1661
|
+
second: second.rgb,
|
|
1662
|
+
firstSpread: first.spread,
|
|
1663
|
+
secondSpread: second.spread
|
|
1495
1664
|
};
|
|
1496
1665
|
}
|
|
1497
1666
|
}
|
|
@@ -1694,28 +1863,37 @@ export function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.1
|
|
|
1694
1863
|
return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
1695
1864
|
}
|
|
1696
1865
|
|
|
1697
|
-
export function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.
|
|
1866
|
+
export function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.065, insetRatio = 0.27) {
|
|
1698
1867
|
const moduleW = bounds.width / size;
|
|
1699
1868
|
const moduleH = bounds.height / size;
|
|
1700
|
-
const inset = Math.max(0.18, Math.min(0.
|
|
1869
|
+
const inset = Math.max(0.18, Math.min(0.34, insetRatio));
|
|
1701
1870
|
const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
|
|
1702
1871
|
const triangleGrid = Array.from({ length: size }, () => new Array(size));
|
|
1872
|
+
const side = Math.max(0.18, inset - 0.06);
|
|
1873
|
+
const middle = Math.min(0.52, 0.50 - inset * 0.04);
|
|
1874
|
+
const firstAnchors = [[inset, inset], [side, middle], [middle, side]];
|
|
1875
|
+
const secondAnchors = firstAnchors.map(([x, y]) => [1 - x, 1 - y]);
|
|
1876
|
+
|
|
1877
|
+
const sampleRegion = (r, c, anchors) => {
|
|
1878
|
+
const values = anchors.map(([x, y]) => sampleAverageAxis(
|
|
1879
|
+
imageData,
|
|
1880
|
+
bounds.x + (c + x) * moduleW,
|
|
1881
|
+
bounds.y + (r + y) * moduleH,
|
|
1882
|
+
radius
|
|
1883
|
+
));
|
|
1884
|
+
const rgb = robustRgb(values);
|
|
1885
|
+
return { rgb, spread: rgbSampleSpread(values, rgb) };
|
|
1886
|
+
};
|
|
1703
1887
|
|
|
1704
1888
|
for (let r = 0; r < size; r++) {
|
|
1705
1889
|
for (let c = 0; c < size; c++) {
|
|
1890
|
+
const first = sampleRegion(r, c, firstAnchors);
|
|
1891
|
+
const second = sampleRegion(r, c, secondAnchors);
|
|
1706
1892
|
triangleGrid[r][c] = {
|
|
1707
|
-
first:
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
radius
|
|
1712
|
-
),
|
|
1713
|
-
second: sampleAverageAxis(
|
|
1714
|
-
imageData,
|
|
1715
|
-
bounds.x + (c + 1 - inset) * moduleW,
|
|
1716
|
-
bounds.y + (r + 1 - inset) * moduleH,
|
|
1717
|
-
radius
|
|
1718
|
-
)
|
|
1893
|
+
first: first.rgb,
|
|
1894
|
+
second: second.rgb,
|
|
1895
|
+
firstSpread: first.spread,
|
|
1896
|
+
secondSpread: second.spread
|
|
1719
1897
|
};
|
|
1720
1898
|
}
|
|
1721
1899
|
}
|