quadqr-js 1.4.2 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FORMAT.md +5 -5
- package/README.md +19 -8
- package/SPECIFICATION.md +12 -12
- package/dist/camera-scanner-worker.js +793 -0
- package/dist/esm/camera-scanner-worker.js +793 -0
- package/dist/esm/geometry.js +25 -10
- package/dist/esm/quadqr.js +843 -62
- package/dist/esm/vision.js +887 -122
- package/dist/esm/wasm.js +85 -12
- package/dist/quadqr.js +1803 -198
- package/dist/quadqr.min.js +2032 -274
- package/dist/wasm/quadqr-core.wasm +0 -0
- package/docs/API.md +13 -5
- package/docs/BROWSER_CDN.md +5 -5
- package/docs/CLI.md +167 -167
- package/docs/COMPRESSION.md +170 -170
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/HIGH_DENSITY_MODE.md +100 -100
- package/docs/NODE.md +159 -159
- package/docs/README.md +62 -62
- package/docs/RELIABILITY_LAB.md +64 -64
- package/docs/TRIANGLE16.md +98 -98
- package/docs/WASM.md +30 -4
- package/package.json +5 -3
package/dist/quadqr.js
CHANGED
|
@@ -8,8 +8,9 @@ const __qqr_geometry_js = (function () {
|
|
|
8
8
|
*
|
|
9
9
|
* Versions 2..40 use the same alignment-pattern center schedule as standard
|
|
10
10
|
* QR Code matrices. QuadQR remains its own symbology, but reuses that proven
|
|
11
|
-
* spatial distribution for alignment references. QuadQR uses
|
|
12
|
-
*
|
|
11
|
+
* spatial distribution for alignment references. QuadQR format v6 uses the same
|
|
12
|
+
* proven 5x5 nested alignment marker shape at every scheduled alignment center.
|
|
13
|
+
* Legacy format v5 used one 5x5 primary marker plus compact 3x3 secondaries.
|
|
13
14
|
* Version 1 keeps the legacy QuadQR bottom-right bootstrap marker because standard QR v1 has no
|
|
14
15
|
* alignment pattern and the QuadQR camera scanner needs a fourth projective
|
|
15
16
|
* reference point.
|
|
@@ -18,6 +19,14 @@ const __qqr_geometry_js = (function () {
|
|
|
18
19
|
const MIN_GEOMETRY_VERSION = 1;
|
|
19
20
|
const MAX_GEOMETRY_VERSION = 40;
|
|
20
21
|
|
|
22
|
+
const ALIGNMENT_PROFILE_STANDARD_5 = "standard5";
|
|
23
|
+
const ALIGNMENT_PROFILE_LEGACY_3 = "compact3";
|
|
24
|
+
|
|
25
|
+
function normalizeAlignmentProfile(profile = ALIGNMENT_PROFILE_STANDARD_5) {
|
|
26
|
+
if (profile === ALIGNMENT_PROFILE_STANDARD_5 || profile === ALIGNMENT_PROFILE_LEGACY_3) return profile;
|
|
27
|
+
throw new Error(`Unsupported alignment profile ${profile}.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
// Index by version. Entry 0 is unused. Values are module indices of alignment
|
|
22
31
|
// pattern centers, matching the standard QR Code version layout for v2..v40.
|
|
23
32
|
const ALIGNMENT_PATTERN_AXES = Object.freeze([
|
|
@@ -84,9 +93,11 @@ function versionFromSize(size) {
|
|
|
84
93
|
: null;
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
function alignmentPatternCentersForVersion(version) {
|
|
96
|
+
function alignmentPatternCentersForVersion(version, options = {}) {
|
|
88
97
|
assertVersion(version);
|
|
89
98
|
const size = sizeForVersion(version);
|
|
99
|
+
const requestedProfile = typeof options === "string" ? options : options.profile;
|
|
100
|
+
const profile = normalizeAlignmentProfile(requestedProfile ?? ALIGNMENT_PROFILE_STANDARD_5);
|
|
90
101
|
|
|
91
102
|
if (version === 1) {
|
|
92
103
|
const center = size - 4;
|
|
@@ -96,7 +107,8 @@ function alignmentPatternCentersForVersion(version) {
|
|
|
96
107
|
size: 5,
|
|
97
108
|
primary: true,
|
|
98
109
|
bootstrap: true,
|
|
99
|
-
separator: true
|
|
110
|
+
separator: true,
|
|
111
|
+
profile
|
|
100
112
|
}];
|
|
101
113
|
}
|
|
102
114
|
|
|
@@ -118,10 +130,13 @@ function alignmentPatternCentersForVersion(version) {
|
|
|
118
130
|
centers.push({
|
|
119
131
|
row,
|
|
120
132
|
col,
|
|
121
|
-
|
|
133
|
+
// Format v6 uses a real QR-style 5x5 black/white/black alignment eye
|
|
134
|
+
// everywhere. Legacy v5 kept compact 3x3 secondaries to save cells.
|
|
135
|
+
size: profile === ALIGNMENT_PROFILE_LEGACY_3 && !primary ? 3 : 5,
|
|
122
136
|
primary,
|
|
123
137
|
bootstrap: false,
|
|
124
|
-
separator: false
|
|
138
|
+
separator: false,
|
|
139
|
+
profile
|
|
125
140
|
});
|
|
126
141
|
}
|
|
127
142
|
}
|
|
@@ -129,8 +144,8 @@ function alignmentPatternCentersForVersion(version) {
|
|
|
129
144
|
return centers;
|
|
130
145
|
}
|
|
131
146
|
|
|
132
|
-
function primaryAlignmentPatternForVersion(version) {
|
|
133
|
-
const centers = alignmentPatternCentersForVersion(version);
|
|
147
|
+
function primaryAlignmentPatternForVersion(version, options = {}) {
|
|
148
|
+
const centers = alignmentPatternCentersForVersion(version, options);
|
|
134
149
|
return centers.find((pattern) => pattern.primary) ?? centers[centers.length - 1];
|
|
135
150
|
}
|
|
136
151
|
|
|
@@ -143,17 +158,17 @@ function alignmentPatternIsBlack(pattern, rowOffset, colOffset) {
|
|
|
143
158
|
if (Math.abs(rowOffset) > radius || Math.abs(colOffset) > radius) return null;
|
|
144
159
|
|
|
145
160
|
if (pattern.size === 3) {
|
|
146
|
-
//
|
|
161
|
+
// Legacy format-v5 secondary marker: black 3x3 ring with a white center.
|
|
147
162
|
return rowOffset !== 0 || colOffset !== 0;
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
//
|
|
165
|
+
// QR-style 5x5 marker: black outer ring, white inner ring, black center.
|
|
151
166
|
const outer = Math.abs(rowOffset) === 2 || Math.abs(colOffset) === 2;
|
|
152
167
|
const center = rowOffset === 0 && colOffset === 0;
|
|
153
168
|
return outer || center;
|
|
154
169
|
}
|
|
155
170
|
|
|
156
|
-
return { sizeForVersion, versionFromSize, alignmentPatternCentersForVersion, primaryAlignmentPatternForVersion, alignmentPatternRadius, alignmentPatternIsBlack, MIN_GEOMETRY_VERSION, MAX_GEOMETRY_VERSION, ALIGNMENT_PATTERN_AXES };
|
|
171
|
+
return { normalizeAlignmentProfile, sizeForVersion, versionFromSize, alignmentPatternCentersForVersion, primaryAlignmentPatternForVersion, alignmentPatternRadius, alignmentPatternIsBlack, MIN_GEOMETRY_VERSION, MAX_GEOMETRY_VERSION, ALIGNMENT_PROFILE_STANDARD_5, ALIGNMENT_PROFILE_LEGACY_3, ALIGNMENT_PATTERN_AXES };
|
|
157
172
|
})();
|
|
158
173
|
const __qqr_reed_solomon_js = (function () {
|
|
159
174
|
/**
|
|
@@ -956,10 +971,10 @@ const securityInternals = Object.freeze({
|
|
|
956
971
|
return { bytesToHex, hexToBytes, normalizeRaw256Key, generateRaw256Key, estimateSecureEnvelopeOverhead, inspectSecureEnvelope, encryptSecurePayload, decryptSecurePayload, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, MIN_PBKDF2_ITERATIONS, MAX_PBKDF2_ITERATIONS, SECURITY_MODES, SECURITY_ALGORITHMS, securityInternals };
|
|
957
972
|
})();
|
|
958
973
|
const __qqr_vision_js = (function () {
|
|
959
|
-
const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, primaryAlignmentPatternForVersion, sizeForVersion } = __qqr_geometry_js;
|
|
974
|
+
const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, primaryAlignmentPatternForVersion, ALIGNMENT_PROFILE_STANDARD_5, ALIGNMENT_PROFILE_LEGACY_3, sizeForVersion } = __qqr_geometry_js;
|
|
960
975
|
/**
|
|
961
976
|
* Image geometry and sampling helpers for QuadQR.
|
|
962
|
-
*
|
|
977
|
+
* JavaScript fallback with optional WASM hot-loop acceleration. No DOM dependency except callers may pass browser ImageData.
|
|
963
978
|
*/
|
|
964
979
|
|
|
965
980
|
function assert(condition, message) {
|
|
@@ -970,6 +985,20 @@ function clamp(value, min, max) {
|
|
|
970
985
|
return Math.max(min, Math.min(max, value));
|
|
971
986
|
}
|
|
972
987
|
|
|
988
|
+
let visionAccelerator = null;
|
|
989
|
+
|
|
990
|
+
// Repeated recovery stages often inspect the exact same captured ImageData.
|
|
991
|
+
// Cache immutable grayscale/binary preprocessing by ImageData identity so the
|
|
992
|
+
// stronger scanner can reuse work instead of rebuilding millions of pixels.
|
|
993
|
+
// WeakMap keeps camera frames collectible as soon as the scan finishes.
|
|
994
|
+
const binaryPreprocessCache = new WeakMap();
|
|
995
|
+
const autoColorGrayCache = new WeakMap();
|
|
996
|
+
|
|
997
|
+
/** Install or remove optional scanner hot-loop acceleration. Internal WASM wiring hook. */
|
|
998
|
+
function installVisionAccelerator(accelerator = null) {
|
|
999
|
+
visionAccelerator = accelerator && typeof accelerator.buildBinary === "function" ? accelerator : null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
973
1002
|
function pixelRgb(imageData, x, y) {
|
|
974
1003
|
const ix = clamp(Math.round(x), 0, imageData.width - 1);
|
|
975
1004
|
const iy = clamp(Math.round(y), 0, imageData.height - 1);
|
|
@@ -1065,54 +1094,140 @@ function binaryAtThreshold(gray, threshold) {
|
|
|
1065
1094
|
return binary;
|
|
1066
1095
|
}
|
|
1067
1096
|
|
|
1097
|
+
function hybridLocalBinary(gray, width, height) {
|
|
1098
|
+
// QR-specific local thresholding inspired by the same block strategy used by
|
|
1099
|
+
// ZXing's HybridBinarizer. It is intentionally a locator fallback, not a
|
|
1100
|
+
// replacement for QuadQR's color correction. 8x8 blocks + smoothed local
|
|
1101
|
+
// black points make finder rings survive shadows and screen gradients that
|
|
1102
|
+
// defeat one global threshold.
|
|
1103
|
+
const BLOCK = 8;
|
|
1104
|
+
const MIN_DIMENSION = 40;
|
|
1105
|
+
const MIN_DYNAMIC_RANGE = 24;
|
|
1106
|
+
if (width < MIN_DIMENSION || height < MIN_DIMENSION) return null;
|
|
1107
|
+
|
|
1108
|
+
const subWidth = Math.ceil(width / BLOCK);
|
|
1109
|
+
const subHeight = Math.ceil(height / BLOCK);
|
|
1110
|
+
const blackPoints = new Uint16Array(subWidth * subHeight);
|
|
1111
|
+
const maxXOffset = Math.max(0, width - BLOCK);
|
|
1112
|
+
const maxYOffset = Math.max(0, height - BLOCK);
|
|
1113
|
+
|
|
1114
|
+
for (let by = 0; by < subHeight; by++) {
|
|
1115
|
+
const y0 = Math.min(by * BLOCK, maxYOffset);
|
|
1116
|
+
for (let bx = 0; bx < subWidth; bx++) {
|
|
1117
|
+
const x0 = Math.min(bx * BLOCK, maxXOffset);
|
|
1118
|
+
let sum = 0;
|
|
1119
|
+
let min = 255;
|
|
1120
|
+
let max = 0;
|
|
1121
|
+
for (let yy = 0; yy < BLOCK; yy++) {
|
|
1122
|
+
const row = (y0 + yy) * width + x0;
|
|
1123
|
+
for (let xx = 0; xx < BLOCK; xx++) {
|
|
1124
|
+
const value = gray[row + xx];
|
|
1125
|
+
sum += value;
|
|
1126
|
+
if (value < min) min = value;
|
|
1127
|
+
if (value > max) max = value;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
let average = sum >> 6;
|
|
1131
|
+
if (max - min <= MIN_DYNAMIC_RANGE) {
|
|
1132
|
+
average = min >> 1;
|
|
1133
|
+
if (by > 0 && bx > 0) {
|
|
1134
|
+
const above = blackPoints[(by - 1) * subWidth + bx];
|
|
1135
|
+
const left = blackPoints[by * subWidth + bx - 1];
|
|
1136
|
+
const diag = blackPoints[(by - 1) * subWidth + bx - 1];
|
|
1137
|
+
const neighbor = (above + 2 * left + diag) >> 2;
|
|
1138
|
+
if (min < neighbor) average = neighbor;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
blackPoints[by * subWidth + bx] = average;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const binary = new Uint8Array(width * height);
|
|
1146
|
+
for (let by = 0; by < subHeight; by++) {
|
|
1147
|
+
const y0 = Math.min(by * BLOCK, maxYOffset);
|
|
1148
|
+
const centerY = clamp(by, 2, Math.max(2, subHeight - 3));
|
|
1149
|
+
for (let bx = 0; bx < subWidth; bx++) {
|
|
1150
|
+
const x0 = Math.min(bx * BLOCK, maxXOffset);
|
|
1151
|
+
const centerX = clamp(bx, 2, Math.max(2, subWidth - 3));
|
|
1152
|
+
let sum = 0;
|
|
1153
|
+
let count = 0;
|
|
1154
|
+
for (let oy = -2; oy <= 2; oy++) {
|
|
1155
|
+
const py = clamp(centerY + oy, 0, subHeight - 1);
|
|
1156
|
+
for (let ox = -2; ox <= 2; ox++) {
|
|
1157
|
+
const px = clamp(centerX + ox, 0, subWidth - 1);
|
|
1158
|
+
sum += blackPoints[py * subWidth + px];
|
|
1159
|
+
count++;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
const threshold = Math.round(sum / Math.max(1, count));
|
|
1163
|
+
for (let yy = 0; yy < BLOCK; yy++) {
|
|
1164
|
+
const y = y0 + yy;
|
|
1165
|
+
if (y >= height) break;
|
|
1166
|
+
const row = y * width;
|
|
1167
|
+
for (let xx = 0; xx < BLOCK; xx++) {
|
|
1168
|
+
const x = x0 + xx;
|
|
1169
|
+
if (x >= width) break;
|
|
1170
|
+
if (gray[row + x] <= threshold) binary[row + x] = 1;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return binary;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1068
1178
|
function buildBinary(imageData, options = {}) {
|
|
1069
1179
|
const grayMode = options.grayMode ?? "luminance";
|
|
1180
|
+
const thresholdOffset = Math.round(Number(options.thresholdOffset) || 0);
|
|
1181
|
+
const canCache = imageData && typeof imageData === "object" && options.cache !== false;
|
|
1182
|
+
const cacheKey = `${grayMode}:${thresholdOffset}`;
|
|
1183
|
+
if (canCache) {
|
|
1184
|
+
const cached = binaryPreprocessCache.get(imageData)?.get(cacheKey);
|
|
1185
|
+
if (cached) return cached;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
let result;
|
|
1189
|
+
if (visionAccelerator) {
|
|
1190
|
+
try {
|
|
1191
|
+
const accelerated = visionAccelerator.buildBinary(imageData, {
|
|
1192
|
+
grayMode,
|
|
1193
|
+
thresholdOffset
|
|
1194
|
+
});
|
|
1195
|
+
if (accelerated?.gray && accelerated?.binary) {
|
|
1196
|
+
result = accelerated;
|
|
1197
|
+
if (canCache) {
|
|
1198
|
+
let entries = binaryPreprocessCache.get(imageData);
|
|
1199
|
+
if (!entries) binaryPreprocessCache.set(imageData, entries = new Map());
|
|
1200
|
+
entries.set(cacheKey, result);
|
|
1201
|
+
}
|
|
1202
|
+
return result;
|
|
1203
|
+
}
|
|
1204
|
+
} catch {
|
|
1205
|
+
// A WASM/runtime failure must never make scanning unavailable. Fall back
|
|
1206
|
+
// to the exact JavaScript implementation for this and future calls.
|
|
1207
|
+
visionAccelerator = null;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1070
1211
|
const gray = buildGray(imageData, grayMode);
|
|
1071
1212
|
const baseThreshold = otsuThreshold(gray);
|
|
1072
1213
|
const threshold = clamp(
|
|
1073
|
-
Math.round(baseThreshold +
|
|
1214
|
+
Math.round(baseThreshold + thresholdOffset),
|
|
1074
1215
|
8,
|
|
1075
1216
|
247
|
|
1076
1217
|
);
|
|
1077
|
-
|
|
1218
|
+
result = {
|
|
1078
1219
|
gray,
|
|
1079
1220
|
binary: binaryAtThreshold(gray, threshold),
|
|
1080
1221
|
threshold,
|
|
1081
1222
|
baseThreshold,
|
|
1082
1223
|
grayMode
|
|
1083
1224
|
};
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
let color = binary[row * width];
|
|
1089
|
-
let start = 0;
|
|
1090
|
-
for (let x = 1; x < width; x++) {
|
|
1091
|
-
const next = binary[row * width + x];
|
|
1092
|
-
if (next !== color) {
|
|
1093
|
-
runs.push({ color, start, length: x - start });
|
|
1094
|
-
color = next;
|
|
1095
|
-
start = x;
|
|
1096
|
-
}
|
|
1225
|
+
if (canCache) {
|
|
1226
|
+
let entries = binaryPreprocessCache.get(imageData);
|
|
1227
|
+
if (!entries) binaryPreprocessCache.set(imageData, entries = new Map());
|
|
1228
|
+
entries.set(cacheKey, result);
|
|
1097
1229
|
}
|
|
1098
|
-
|
|
1099
|
-
return runs;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
function runsForColumn(binary, width, height, col) {
|
|
1103
|
-
const runs = [];
|
|
1104
|
-
let color = binary[col];
|
|
1105
|
-
let start = 0;
|
|
1106
|
-
for (let y = 1; y < height; y++) {
|
|
1107
|
-
const next = binary[y * width + col];
|
|
1108
|
-
if (next !== color) {
|
|
1109
|
-
runs.push({ color, start, length: y - start });
|
|
1110
|
-
color = next;
|
|
1111
|
-
start = y;
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
runs.push({ color, start, length: height - start });
|
|
1115
|
-
return runs;
|
|
1230
|
+
return result;
|
|
1116
1231
|
}
|
|
1117
1232
|
|
|
1118
1233
|
function finderRatioScore(lengths, toleranceScale = 1) {
|
|
@@ -1130,34 +1245,85 @@ function finderRatioScore(lengths, toleranceScale = 1) {
|
|
|
1130
1245
|
return score;
|
|
1131
1246
|
}
|
|
1132
1247
|
|
|
1133
|
-
function
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
const
|
|
1160
|
-
|
|
1248
|
+
function finderCenterFromEnd(stateCount, end) {
|
|
1249
|
+
return end - stateCount[4] - stateCount[3] - stateCount[2] / 2;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function directCrossCheckVertical(binary, width, height, startY, centerX, maxCount, originalTotal, toleranceScale = 1) {
|
|
1253
|
+
const x = clamp(Math.round(centerX), 0, width - 1);
|
|
1254
|
+
const state = [0, 0, 0, 0, 0];
|
|
1255
|
+
let y = clamp(Math.round(startY), 0, height - 1);
|
|
1256
|
+
|
|
1257
|
+
while (y >= 0 && binary[y * width + x]) { state[2]++; y--; }
|
|
1258
|
+
if (y < 0) return null;
|
|
1259
|
+
while (y >= 0 && !binary[y * width + x] && state[1] <= maxCount) { state[1]++; y--; }
|
|
1260
|
+
if (y < 0 || state[1] > maxCount) return null;
|
|
1261
|
+
while (y >= 0 && binary[y * width + x] && state[0] <= maxCount) { state[0]++; y--; }
|
|
1262
|
+
if (state[0] > maxCount) return null;
|
|
1263
|
+
|
|
1264
|
+
y = clamp(Math.round(startY), 0, height - 1) + 1;
|
|
1265
|
+
while (y < height && binary[y * width + x]) { state[2]++; y++; }
|
|
1266
|
+
if (y === height) return null;
|
|
1267
|
+
while (y < height && !binary[y * width + x] && state[3] < maxCount) { state[3]++; y++; }
|
|
1268
|
+
if (y === height || state[3] >= maxCount) return null;
|
|
1269
|
+
while (y < height && binary[y * width + x] && state[4] < maxCount) { state[4]++; y++; }
|
|
1270
|
+
if (state[4] >= maxCount) return null;
|
|
1271
|
+
|
|
1272
|
+
const total = state.reduce((sum, value) => sum + value, 0);
|
|
1273
|
+
if (Math.abs(total - originalTotal) > originalTotal * 0.40) return null;
|
|
1274
|
+
const score = finderRatioScore(state, toleranceScale);
|
|
1275
|
+
if (!Number.isFinite(score)) return null;
|
|
1276
|
+
return { center: finderCenterFromEnd(state, y), moduleSize: total / 7, score };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function directCrossCheckHorizontal(binary, width, height, startX, centerY, maxCount, originalTotal, toleranceScale = 1) {
|
|
1280
|
+
const y = clamp(Math.round(centerY), 0, height - 1);
|
|
1281
|
+
const row = y * width;
|
|
1282
|
+
const state = [0, 0, 0, 0, 0];
|
|
1283
|
+
let x = clamp(Math.round(startX), 0, width - 1);
|
|
1284
|
+
|
|
1285
|
+
while (x >= 0 && binary[row + x]) { state[2]++; x--; }
|
|
1286
|
+
if (x < 0) return null;
|
|
1287
|
+
while (x >= 0 && !binary[row + x] && state[1] <= maxCount) { state[1]++; x--; }
|
|
1288
|
+
if (x < 0 || state[1] > maxCount) return null;
|
|
1289
|
+
while (x >= 0 && binary[row + x] && state[0] <= maxCount) { state[0]++; x--; }
|
|
1290
|
+
if (state[0] > maxCount) return null;
|
|
1291
|
+
|
|
1292
|
+
x = clamp(Math.round(startX), 0, width - 1) + 1;
|
|
1293
|
+
while (x < width && binary[row + x]) { state[2]++; x++; }
|
|
1294
|
+
if (x === width) return null;
|
|
1295
|
+
while (x < width && !binary[row + x] && state[3] < maxCount) { state[3]++; x++; }
|
|
1296
|
+
if (x === width || state[3] >= maxCount) return null;
|
|
1297
|
+
while (x < width && binary[row + x] && state[4] < maxCount) { state[4]++; x++; }
|
|
1298
|
+
if (state[4] >= maxCount) return null;
|
|
1299
|
+
|
|
1300
|
+
const total = state.reduce((sum, value) => sum + value, 0);
|
|
1301
|
+
if (Math.abs(total - originalTotal) > originalTotal * 0.25) return null;
|
|
1302
|
+
const score = finderRatioScore(state, toleranceScale);
|
|
1303
|
+
if (!Number.isFinite(score)) return null;
|
|
1304
|
+
return { center: finderCenterFromEnd(state, x), moduleSize: total / 7, score };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function directCrossCheckDiagonal(binary, width, height, centerX, centerY, toleranceScale = 1) {
|
|
1308
|
+
const cx = clamp(Math.round(centerX), 0, width - 1);
|
|
1309
|
+
const cy = clamp(Math.round(centerY), 0, height - 1);
|
|
1310
|
+
const state = [0, 0, 0, 0, 0];
|
|
1311
|
+
let step = 0;
|
|
1312
|
+
while (cx - step >= 0 && cy - step >= 0 && binary[(cy - step) * width + (cx - step)]) { state[2]++; step++; }
|
|
1313
|
+
if (!state[2]) return false;
|
|
1314
|
+
while (cx - step >= 0 && cy - step >= 0 && !binary[(cy - step) * width + (cx - step)]) { state[1]++; step++; }
|
|
1315
|
+
if (!state[1]) return false;
|
|
1316
|
+
while (cx - step >= 0 && cy - step >= 0 && binary[(cy - step) * width + (cx - step)]) { state[0]++; step++; }
|
|
1317
|
+
if (!state[0]) return false;
|
|
1318
|
+
|
|
1319
|
+
step = 1;
|
|
1320
|
+
while (cx + step < width && cy + step < height && binary[(cy + step) * width + (cx + step)]) { state[2]++; step++; }
|
|
1321
|
+
while (cx + step < width && cy + step < height && !binary[(cy + step) * width + (cx + step)]) { state[3]++; step++; }
|
|
1322
|
+
if (!state[3]) return false;
|
|
1323
|
+
while (cx + step < width && cy + step < height && binary[(cy + step) * width + (cx + step)]) { state[4]++; step++; }
|
|
1324
|
+
if (!state[4]) return false;
|
|
1325
|
+
|
|
1326
|
+
return Number.isFinite(finderRatioScore(state, toleranceScale * 1.35));
|
|
1161
1327
|
}
|
|
1162
1328
|
|
|
1163
1329
|
function clusterFinderCandidates(raw, minConfirmations = 2) {
|
|
@@ -1202,47 +1368,180 @@ function clusterFinderCandidates(raw, minConfirmations = 2) {
|
|
|
1202
1368
|
}
|
|
1203
1369
|
|
|
1204
1370
|
function detectFinderCandidates(binary, width, height, options = {}) {
|
|
1371
|
+
// Finder acquisition follows the same broad strategy that makes mature QR
|
|
1372
|
+
// readers feel immediate: scan a subset of rows for 1:1:3:1:1, then confirm
|
|
1373
|
+
// each hit directly across the orthogonal axes. Unlike the previous scanner,
|
|
1374
|
+
// this does not allocate complete run arrays for every row and column.
|
|
1205
1375
|
const raw = [];
|
|
1206
|
-
const rowStep = height > 1200 ? 2 : 1;
|
|
1207
1376
|
const toleranceScale = options.toleranceScale ?? 1;
|
|
1208
1377
|
const moduleSpreadLimit = options.moduleSpreadLimit ?? 0.45;
|
|
1209
1378
|
const minConfirmations = options.minConfirmations ?? 2;
|
|
1379
|
+
const rowStep = Math.max(1, Math.round(options.rowStep ?? (height > 900 ? 2 : 1)));
|
|
1380
|
+
const diagonalCheck = options.diagonalCheck === true;
|
|
1381
|
+
const maxRawCandidates = Math.max(24, Math.round(options.maxRawCandidates ?? 160));
|
|
1382
|
+
|
|
1383
|
+
const handlePossibleCenter = (stateCount, y, endX) => {
|
|
1384
|
+
const ratioScore = finderRatioScore(stateCount, toleranceScale);
|
|
1385
|
+
if (!Number.isFinite(ratioScore)) return false;
|
|
1386
|
+
const total = stateCount.reduce((sum, value) => sum + value, 0);
|
|
1387
|
+
const centerX = finderCenterFromEnd(stateCount, endX);
|
|
1388
|
+
const maxCount = Math.max(2, Math.ceil(stateCount[2] * 1.25));
|
|
1389
|
+
const vertical = directCrossCheckVertical(binary, width, height, y, centerX, maxCount, total, toleranceScale);
|
|
1390
|
+
if (!vertical) return false;
|
|
1391
|
+
const horizontal = directCrossCheckHorizontal(binary, width, height, centerX, vertical.center, maxCount, total, toleranceScale);
|
|
1392
|
+
if (!horizontal) return false;
|
|
1393
|
+
if (diagonalCheck && !directCrossCheckDiagonal(binary, width, height, horizontal.center, vertical.center, toleranceScale)) return false;
|
|
1394
|
+
|
|
1395
|
+
const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
|
|
1396
|
+
const moduleSpread = Math.max(
|
|
1397
|
+
Math.abs(moduleSize - total / 7),
|
|
1398
|
+
Math.abs(moduleSize - vertical.moduleSize),
|
|
1399
|
+
Math.abs(moduleSize - horizontal.moduleSize)
|
|
1400
|
+
) / Math.max(0.01, moduleSize);
|
|
1401
|
+
if (moduleSpread > moduleSpreadLimit) return false;
|
|
1402
|
+
|
|
1403
|
+
raw.push({
|
|
1404
|
+
x: horizontal.center,
|
|
1405
|
+
y: vertical.center,
|
|
1406
|
+
moduleSize,
|
|
1407
|
+
score: ratioScore + vertical.score + horizontal.score
|
|
1408
|
+
});
|
|
1409
|
+
return true;
|
|
1410
|
+
};
|
|
1210
1411
|
|
|
1211
|
-
for (let y =
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
score: ratioScore + vertical.score + horizontal.score
|
|
1239
|
-
});
|
|
1412
|
+
for (let y = rowStep - 1; y < height; y += rowStep) {
|
|
1413
|
+
const state = [0, 0, 0, 0, 0];
|
|
1414
|
+
let currentState = 0;
|
|
1415
|
+
const row = y * width;
|
|
1416
|
+
|
|
1417
|
+
for (let x = 0; x < width; x++) {
|
|
1418
|
+
if (binary[row + x]) {
|
|
1419
|
+
if ((currentState & 1) === 1) currentState++;
|
|
1420
|
+
state[currentState]++;
|
|
1421
|
+
} else {
|
|
1422
|
+
if ((currentState & 1) === 0) {
|
|
1423
|
+
if (currentState === 4) {
|
|
1424
|
+
handlePossibleCenter(state, y, x);
|
|
1425
|
+
state[0] = state[2];
|
|
1426
|
+
state[1] = state[3];
|
|
1427
|
+
state[2] = state[4];
|
|
1428
|
+
state[3] = 1;
|
|
1429
|
+
state[4] = 0;
|
|
1430
|
+
currentState = 3;
|
|
1431
|
+
} else {
|
|
1432
|
+
currentState++;
|
|
1433
|
+
state[currentState]++;
|
|
1434
|
+
}
|
|
1435
|
+
} else {
|
|
1436
|
+
state[currentState]++;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1240
1439
|
}
|
|
1440
|
+
if (currentState === 4) handlePossibleCenter(state, y, width);
|
|
1441
|
+
if (raw.length >= maxRawCandidates) break;
|
|
1241
1442
|
}
|
|
1242
1443
|
|
|
1243
1444
|
return clusterFinderCandidates(raw, minConfirmations);
|
|
1244
1445
|
}
|
|
1245
1446
|
|
|
1447
|
+
function detectFinderCandidatesByComponents(binary, width, height, options = {}) {
|
|
1448
|
+
const visited = new Uint8Array(width * height);
|
|
1449
|
+
const stack = [];
|
|
1450
|
+
const candidates = [];
|
|
1451
|
+
const minArea = Math.max(16, Math.round(options.componentMinArea ?? 20));
|
|
1452
|
+
const minSpan = Math.max(5, Math.round(options.componentMinSpan ?? 7));
|
|
1453
|
+
const maxComponents = Math.max(64, Math.round(options.componentMaxCount ?? 600));
|
|
1454
|
+
let componentsSeen = 0;
|
|
1455
|
+
|
|
1456
|
+
const templateScore = (minX, minY, maxX, maxY) => {
|
|
1457
|
+
const spanX = maxX - minX + 1;
|
|
1458
|
+
const spanY = maxY - minY + 1;
|
|
1459
|
+
let matches = 0;
|
|
1460
|
+
let total = 0;
|
|
1461
|
+
for (let r = 0; r < 7; r++) {
|
|
1462
|
+
for (let c = 0; c < 7; c++) {
|
|
1463
|
+
const x = minX + ((c + 0.5) / 7) * spanX;
|
|
1464
|
+
const y = minY + ((r + 0.5) / 7) * spanY;
|
|
1465
|
+
const actual = sampleBinaryAt(binary, width, height, x, y);
|
|
1466
|
+
if (actual == null) continue;
|
|
1467
|
+
const expected = r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4);
|
|
1468
|
+
total++;
|
|
1469
|
+
if (Boolean(actual) === expected) matches++;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
return total ? matches / total : 0;
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1475
|
+
for (let index = 0; index < binary.length; index++) {
|
|
1476
|
+
if (!binary[index] || visited[index]) continue;
|
|
1477
|
+
componentsSeen++;
|
|
1478
|
+
if (componentsSeen > maxComponents) break;
|
|
1479
|
+
visited[index] = 1;
|
|
1480
|
+
stack.length = 0;
|
|
1481
|
+
stack.push(index);
|
|
1482
|
+
let area = 0;
|
|
1483
|
+
let minX = width;
|
|
1484
|
+
let minY = height;
|
|
1485
|
+
let maxX = -1;
|
|
1486
|
+
let maxY = -1;
|
|
1487
|
+
|
|
1488
|
+
while (stack.length) {
|
|
1489
|
+
const current = stack.pop();
|
|
1490
|
+
const y = Math.floor(current / width);
|
|
1491
|
+
const x = current - y * width;
|
|
1492
|
+
area++;
|
|
1493
|
+
if (x < minX) minX = x;
|
|
1494
|
+
if (x > maxX) maxX = x;
|
|
1495
|
+
if (y < minY) minY = y;
|
|
1496
|
+
if (y > maxY) maxY = y;
|
|
1497
|
+
|
|
1498
|
+
if (x > 0) {
|
|
1499
|
+
const next = current - 1;
|
|
1500
|
+
if (binary[next] && !visited[next]) { visited[next] = 1; stack.push(next); }
|
|
1501
|
+
}
|
|
1502
|
+
if (x + 1 < width) {
|
|
1503
|
+
const next = current + 1;
|
|
1504
|
+
if (binary[next] && !visited[next]) { visited[next] = 1; stack.push(next); }
|
|
1505
|
+
}
|
|
1506
|
+
if (y > 0) {
|
|
1507
|
+
const next = current - width;
|
|
1508
|
+
if (binary[next] && !visited[next]) { visited[next] = 1; stack.push(next); }
|
|
1509
|
+
}
|
|
1510
|
+
if (y + 1 < height) {
|
|
1511
|
+
const next = current + width;
|
|
1512
|
+
if (binary[next] && !visited[next]) { visited[next] = 1; stack.push(next); }
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const spanX = maxX - minX + 1;
|
|
1517
|
+
const spanY = maxY - minY + 1;
|
|
1518
|
+
if (area < minArea || spanX < minSpan || spanY < minSpan) continue;
|
|
1519
|
+
const aspect = spanX / spanY;
|
|
1520
|
+
if (aspect < 0.18 || aspect > 5.5) continue;
|
|
1521
|
+
const density = area / (spanX * spanY);
|
|
1522
|
+
if (density < 0.10 || density > 0.78) continue;
|
|
1523
|
+
const centerX = (minX + maxX) / 2;
|
|
1524
|
+
const centerY = (minY + maxY) / 2;
|
|
1525
|
+
if (sampleBinaryAt(binary, width, height, centerX, centerY) !== 1) continue;
|
|
1526
|
+
const score = templateScore(minX, minY, maxX, maxY);
|
|
1527
|
+
if (score < (options.componentTemplateThreshold ?? 0.63)) continue;
|
|
1528
|
+
|
|
1529
|
+
candidates.push({
|
|
1530
|
+
x: centerX,
|
|
1531
|
+
y: centerY,
|
|
1532
|
+
moduleSize: Math.max(0.75, Math.sqrt(area / 24)),
|
|
1533
|
+
score: (1 - score) * 3,
|
|
1534
|
+
componentScore: score,
|
|
1535
|
+
componentArea: area
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
return candidates.sort((a, b) =>
|
|
1540
|
+
(b.componentScore - a.componentScore) ||
|
|
1541
|
+
(b.componentArea - a.componentArea)
|
|
1542
|
+
).slice(0, Math.max(3, Math.round(options.componentMaxCandidates ?? 12)));
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1246
1545
|
function recoverFinderSetFromTwo(binary, width, height, strongFinders, detector = {}) {
|
|
1247
1546
|
if (strongFinders.length !== 2) return strongFinders;
|
|
1248
1547
|
const moduleMean = (strongFinders[0].moduleSize + strongFinders[1].moduleSize) / 2;
|
|
@@ -1348,6 +1647,61 @@ function chooseFinderTriples(candidates, maxTriples = 16, options = {}) {
|
|
|
1348
1647
|
return triples.slice(0, maxTriples);
|
|
1349
1648
|
}
|
|
1350
1649
|
|
|
1650
|
+
function rayDistanceToImageBoundary(width, height, x, y, dx, dy) {
|
|
1651
|
+
let limit = Infinity;
|
|
1652
|
+
if (dx > 1e-9) limit = Math.min(limit, (width - 1 - x) / dx);
|
|
1653
|
+
else if (dx < -1e-9) limit = Math.min(limit, (0 - x) / dx);
|
|
1654
|
+
if (dy > 1e-9) limit = Math.min(limit, (height - 1 - y) / dy);
|
|
1655
|
+
else if (dy < -1e-9) limit = Math.min(limit, (0 - y) / dy);
|
|
1656
|
+
return Number.isFinite(limit) ? Math.max(0, limit) : 0;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function finderHalfRunDistance(binary, width, height, center, direction) {
|
|
1660
|
+
const length = Math.hypot(direction.x, direction.y);
|
|
1661
|
+
if (length < 1e-6) return NaN;
|
|
1662
|
+
const dx = direction.x / length;
|
|
1663
|
+
const dy = direction.y / length;
|
|
1664
|
+
const maxDistance = rayDistanceToImageBoundary(width, height, center.x, center.y, dx, dy);
|
|
1665
|
+
let previous = sampleBinaryAt(binary, width, height, center.x, center.y);
|
|
1666
|
+
if (previous !== 1) return NaN;
|
|
1667
|
+
let transitions = 0;
|
|
1668
|
+
let lastDistance = 0;
|
|
1669
|
+
// Half-pixel stepping keeps the measured boundary stable at small module
|
|
1670
|
+
// sizes without becoming expensive. A finder needs only three transitions:
|
|
1671
|
+
// center black -> white ring -> black ring -> outside white.
|
|
1672
|
+
for (let distance = 0.5; distance <= maxDistance; distance += 0.5) {
|
|
1673
|
+
const value = sampleBinaryAt(binary, width, height, center.x + dx * distance, center.y + dy * distance);
|
|
1674
|
+
if (value == null) break;
|
|
1675
|
+
if (value !== previous) {
|
|
1676
|
+
transitions++;
|
|
1677
|
+
previous = value;
|
|
1678
|
+
if (transitions === 3) return distance;
|
|
1679
|
+
}
|
|
1680
|
+
lastDistance = distance;
|
|
1681
|
+
if (distance > 80 && transitions === 0) break;
|
|
1682
|
+
}
|
|
1683
|
+
return transitions >= 2 ? lastDistance : NaN;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function directionalFinderModuleSize(binary, width, height, pattern, otherPattern) {
|
|
1687
|
+
const direction = { x: otherPattern.x - pattern.x, y: otherPattern.y - pattern.y };
|
|
1688
|
+
const forward = finderHalfRunDistance(binary, width, height, pattern, direction);
|
|
1689
|
+
const backward = finderHalfRunDistance(binary, width, height, pattern, { x: -direction.x, y: -direction.y });
|
|
1690
|
+
if (!Number.isFinite(forward) && !Number.isFinite(backward)) return NaN;
|
|
1691
|
+
if (!Number.isFinite(forward)) return (backward * 2) / 7;
|
|
1692
|
+
if (!Number.isFinite(backward)) return (forward * 2) / 7;
|
|
1693
|
+
return (forward + backward) / 7;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function moduleSizeBetweenFinders(binary, width, height, a, b) {
|
|
1697
|
+
const fromA = directionalFinderModuleSize(binary, width, height, a, b);
|
|
1698
|
+
const fromB = directionalFinderModuleSize(binary, width, height, b, a);
|
|
1699
|
+
if (Number.isFinite(fromA) && Number.isFinite(fromB)) return (fromA + fromB) / 2;
|
|
1700
|
+
if (Number.isFinite(fromA)) return fromA;
|
|
1701
|
+
if (Number.isFinite(fromB)) return fromB;
|
|
1702
|
+
return (a.moduleSize + b.moduleSize) / 2;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1351
1705
|
function nearestVersionFromEstimate(value, minVersion, maxVersion) {
|
|
1352
1706
|
const candidates = [];
|
|
1353
1707
|
for (let version = minVersion; version <= maxVersion; version++) {
|
|
@@ -1418,6 +1772,112 @@ function preciseAlignmentScore(binary, width, height, center, basisU, basisV, sc
|
|
|
1418
1772
|
return total ? matches / total : 0;
|
|
1419
1773
|
}
|
|
1420
1774
|
|
|
1775
|
+
// Fast 5x5 nested-eye probe used before the heavier alignment template search.
|
|
1776
|
+
// A standard 5x5 alignment pattern has a particularly strong signature along
|
|
1777
|
+
// its centre axes: black / white / black / white / black. We also probe the
|
|
1778
|
+
// inner and outer diagonals so random payload modules are much less likely to
|
|
1779
|
+
// impersonate an alignment eye. This is deliberately tiny: at most 17 binary
|
|
1780
|
+
// samples per candidate instead of a full 25/125-sample template evaluation.
|
|
1781
|
+
const FAST_ALIGNMENT_PROBES = Object.freeze([
|
|
1782
|
+
Object.freeze([0, 0, 1, 2.2]),
|
|
1783
|
+
Object.freeze([-1, 0, 0, 1.4]), Object.freeze([1, 0, 0, 1.4]),
|
|
1784
|
+
Object.freeze([0, -1, 0, 1.4]), Object.freeze([0, 1, 0, 1.4]),
|
|
1785
|
+
Object.freeze([-2, 0, 1, 1.2]), Object.freeze([2, 0, 1, 1.2]),
|
|
1786
|
+
Object.freeze([0, -2, 1, 1.2]), Object.freeze([0, 2, 1, 1.2]),
|
|
1787
|
+
Object.freeze([-1, -1, 0, 0.9]), Object.freeze([1, -1, 0, 0.9]),
|
|
1788
|
+
Object.freeze([-1, 1, 0, 0.9]), Object.freeze([1, 1, 0, 0.9]),
|
|
1789
|
+
Object.freeze([-2, -2, 1, 0.8]), Object.freeze([2, -2, 1, 0.8]),
|
|
1790
|
+
Object.freeze([-2, 2, 1, 0.8]), Object.freeze([2, 2, 1, 0.8])
|
|
1791
|
+
]);
|
|
1792
|
+
|
|
1793
|
+
function fastNestedAlignmentScore(binary, width, height, center, basisU, basisV, scale) {
|
|
1794
|
+
let matched = 0;
|
|
1795
|
+
let total = 0;
|
|
1796
|
+
for (const [c, r, expected, weight] of FAST_ALIGNMENT_PROBES) {
|
|
1797
|
+
const x = center.x + basisU.x * c * scale + basisV.x * r * scale;
|
|
1798
|
+
const y = center.y + basisU.y * c * scale + basisV.y * r * scale;
|
|
1799
|
+
const value = sampleBinaryAt(binary, width, height, x, y);
|
|
1800
|
+
if (value === null) continue;
|
|
1801
|
+
total += weight;
|
|
1802
|
+
if (value === expected) matched += weight;
|
|
1803
|
+
}
|
|
1804
|
+
return total ? matched / total : 0;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function searchNestedAlignmentFast(binary, width, height, predicted, basisU, basisV, target, options = {}) {
|
|
1808
|
+
if (target?.size !== 5 || options.fastAlignmentLocator === false) return null;
|
|
1809
|
+
|
|
1810
|
+
const inferredScale = clamp(Number(options.inferredTargetScale ?? 1), 0.5, 2.1);
|
|
1811
|
+
const radius = clamp(Number(options.radius ?? 5), 2.5, 18);
|
|
1812
|
+
const coarseStep = radius >= 14 ? 2 : radius >= 9 ? 1.5 : 1;
|
|
1813
|
+
const scales = [...new Set([
|
|
1814
|
+
Number((inferredScale * 0.78).toFixed(3)),
|
|
1815
|
+
Number((inferredScale * 0.90).toFixed(3)),
|
|
1816
|
+
Number(inferredScale.toFixed(3)),
|
|
1817
|
+
Number((inferredScale * 1.12).toFixed(3)),
|
|
1818
|
+
Number((inferredScale * 1.28).toFixed(3)),
|
|
1819
|
+
0.75, 1, 1.25
|
|
1820
|
+
])].filter((value) => value >= 0.48 && value <= 2.1);
|
|
1821
|
+
|
|
1822
|
+
const candidates = [];
|
|
1823
|
+
for (let offsetV = -radius; offsetV <= radius + 0.001; offsetV += coarseStep) {
|
|
1824
|
+
for (let offsetU = -radius; offsetU <= radius + 0.001; offsetU += coarseStep) {
|
|
1825
|
+
const center = {
|
|
1826
|
+
x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
|
|
1827
|
+
y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
|
|
1828
|
+
};
|
|
1829
|
+
let bestScore = 0;
|
|
1830
|
+
let bestScale = 1;
|
|
1831
|
+
for (const scale of scales) {
|
|
1832
|
+
const score = fastNestedAlignmentScore(binary, width, height, center, basisU, basisV, scale);
|
|
1833
|
+
if (score > bestScore) {
|
|
1834
|
+
bestScore = score;
|
|
1835
|
+
bestScale = scale;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
// Keeping only plausible nested eyes makes the fine phase effectively
|
|
1839
|
+
// constant-time even on very dense symbols.
|
|
1840
|
+
if (bestScore >= (options.fastAlignmentSeedThreshold ?? 0.73)) {
|
|
1841
|
+
candidates.push({ score: bestScore, center, scale: bestScale, offsetU, offsetV });
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
if (!candidates.length) return null;
|
|
1847
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
1848
|
+
const fineSeeds = candidates.slice(0, Math.max(1, Math.round(options.fastAlignmentFineSeeds ?? 5)));
|
|
1849
|
+
const fineOffsets = [-0.75, -0.35, 0, 0.35, 0.75];
|
|
1850
|
+
let best = null;
|
|
1851
|
+
|
|
1852
|
+
for (const seed of fineSeeds) {
|
|
1853
|
+
const fineScales = [...new Set([
|
|
1854
|
+
Number((seed.scale * 0.90).toFixed(3)),
|
|
1855
|
+
Number((seed.scale * 0.96).toFixed(3)),
|
|
1856
|
+
Number(seed.scale.toFixed(3)),
|
|
1857
|
+
Number((seed.scale * 1.05).toFixed(3)),
|
|
1858
|
+
Number((seed.scale * 1.12).toFixed(3))
|
|
1859
|
+
])].filter((value) => value >= 0.46 && value <= 2.15);
|
|
1860
|
+
for (const du of fineOffsets) {
|
|
1861
|
+
for (const dv of fineOffsets) {
|
|
1862
|
+
const offsetU = seed.offsetU + du;
|
|
1863
|
+
const offsetV = seed.offsetV + dv;
|
|
1864
|
+
const center = {
|
|
1865
|
+
x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
|
|
1866
|
+
y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
|
|
1867
|
+
};
|
|
1868
|
+
for (const scale of fineScales) {
|
|
1869
|
+
const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
|
|
1870
|
+
if (!best || score > best.score) {
|
|
1871
|
+
best = { score, center, scale, offsetU, offsetV, broadSearch: radius > 4, fastNested: true };
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
return best;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1421
1881
|
function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
1422
1882
|
const size = sizeForVersion(version);
|
|
1423
1883
|
const separation = size - 7;
|
|
@@ -1429,7 +1889,7 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
|
1429
1889
|
x: (triple.bl.x - triple.tl.x) / separation,
|
|
1430
1890
|
y: (triple.bl.y - triple.tl.y) / separation
|
|
1431
1891
|
};
|
|
1432
|
-
const target = primaryAlignmentPatternForVersion(version);
|
|
1892
|
+
const target = primaryAlignmentPatternForVersion(version, { profile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5 });
|
|
1433
1893
|
const targetX = target.col + 0.5;
|
|
1434
1894
|
const targetY = target.row + 0.5;
|
|
1435
1895
|
const predicted = {
|
|
@@ -1437,11 +1897,6 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
|
1437
1897
|
y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
|
|
1438
1898
|
};
|
|
1439
1899
|
|
|
1440
|
-
const precise = options.preciseAlignment === true;
|
|
1441
|
-
const localScoreFn = precise ? preciseAlignmentScore : alignmentScore;
|
|
1442
|
-
const localOffsets = precise
|
|
1443
|
-
? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
|
|
1444
|
-
: [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
|
|
1445
1900
|
const finderSizes = [triple.tl.moduleSize, triple.tr.moduleSize, triple.bl.moduleSize];
|
|
1446
1901
|
const finderMin = Math.max(0.01, Math.min(...finderSizes));
|
|
1447
1902
|
const finderMax = Math.max(...finderSizes);
|
|
@@ -1461,11 +1916,62 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
|
1461
1916
|
Number((inferredTargetScale * 1.12).toFixed(3))
|
|
1462
1917
|
])].filter((scale) => scale >= 0.52 && scale <= 1.9);
|
|
1463
1918
|
|
|
1919
|
+
const cornerSkew = Math.max(0, 1 - (triple.orthogonality ?? 1));
|
|
1920
|
+
const projectiveSignal = Math.max(
|
|
1921
|
+
Math.max(0, finderScaleRatio - 1),
|
|
1922
|
+
Math.max(0, (triple.moduleSpread ?? 0) * 1.25),
|
|
1923
|
+
cornerSkew * 0.75
|
|
1924
|
+
);
|
|
1925
|
+
|
|
1926
|
+
// Try the nested-eye locator first. It searches a projective-sized region
|
|
1927
|
+
// using only a handful of binary samples per location and therefore remains
|
|
1928
|
+
// cheap even when the affine prediction is several modules off.
|
|
1929
|
+
const fastRadius = clamp(
|
|
1930
|
+
Number(options.fastAlignmentRadius ?? (3 + projectiveSignal * 13)),
|
|
1931
|
+
3,
|
|
1932
|
+
options.fastAlignmentMaxRadius ?? 16
|
|
1933
|
+
);
|
|
1934
|
+
const fastNested = searchNestedAlignmentFast(
|
|
1935
|
+
binary,
|
|
1936
|
+
width,
|
|
1937
|
+
height,
|
|
1938
|
+
predicted,
|
|
1939
|
+
basisU,
|
|
1940
|
+
basisV,
|
|
1941
|
+
target,
|
|
1942
|
+
{
|
|
1943
|
+
...options,
|
|
1944
|
+
inferredTargetScale,
|
|
1945
|
+
radius: fastRadius
|
|
1946
|
+
}
|
|
1947
|
+
);
|
|
1948
|
+
|
|
1949
|
+
const fastAccept = options.fastAlignmentAccept ?? (options.preciseAlignment ? 0.88 : 0.80);
|
|
1950
|
+
if (fastNested && fastNested.score >= fastAccept) {
|
|
1951
|
+
return {
|
|
1952
|
+
...fastNested,
|
|
1953
|
+
basisU,
|
|
1954
|
+
basisV,
|
|
1955
|
+
predicted,
|
|
1956
|
+
target,
|
|
1957
|
+
finderScaleRatio,
|
|
1958
|
+
projectiveSignal
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
const precise = options.preciseAlignment === true;
|
|
1963
|
+
const localScoreFn = precise ? preciseAlignmentScore : alignmentScore;
|
|
1964
|
+
const localOffsets = precise
|
|
1965
|
+
? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
|
|
1966
|
+
: [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
|
|
1967
|
+
|
|
1464
1968
|
const centerAt = (offsetU, offsetV) => ({
|
|
1465
1969
|
x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
|
|
1466
1970
|
y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
|
|
1467
1971
|
});
|
|
1468
|
-
let best =
|
|
1972
|
+
let best = fastNested && fastNested.score > 0
|
|
1973
|
+
? fastNested
|
|
1974
|
+
: { score: 0, center: predicted, scale: 1, offsetU: 0, offsetV: 0, broadSearch: false };
|
|
1469
1975
|
|
|
1470
1976
|
const evaluateIntoBest = (offsetU, offsetV, scales, scoreFn, broadSearch = false) => {
|
|
1471
1977
|
const center = centerAt(offsetU, offsetV);
|
|
@@ -1485,12 +1991,6 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
|
|
|
1485
1991
|
// prediction even though all three finder patterns remain strong. Use a
|
|
1486
1992
|
// bounded coarse-to-fine search only when the locator geometry signals that
|
|
1487
1993
|
// projective foreshortening is present.
|
|
1488
|
-
const cornerSkew = Math.max(0, 1 - (triple.orthogonality ?? 1));
|
|
1489
|
-
const projectiveSignal = Math.max(
|
|
1490
|
-
Math.max(0, finderScaleRatio - 1),
|
|
1491
|
-
Math.max(0, (triple.moduleSpread ?? 0) * 1.25),
|
|
1492
|
-
cornerSkew * 0.75
|
|
1493
|
-
);
|
|
1494
1994
|
const broadEnabled = options.perspectiveAlignmentRecovery !== false && (
|
|
1495
1995
|
options.perspectiveRecovery === true ||
|
|
1496
1996
|
projectiveSignal >= (options.perspectiveAlignmentSignalThreshold ?? 0.32)
|
|
@@ -1607,8 +2107,8 @@ function projectedAlignmentScore(binary, width, height, homography, pattern) {
|
|
|
1607
2107
|
return total ? matches / total : 0;
|
|
1608
2108
|
}
|
|
1609
2109
|
|
|
1610
|
-
function scoreAlignmentGrid(binary, width, height, homography, version) {
|
|
1611
|
-
const patterns = alignmentPatternCentersForVersion(version);
|
|
2110
|
+
function scoreAlignmentGrid(binary, width, height, homography, version, options = {}) {
|
|
2111
|
+
const patterns = alignmentPatternCentersForVersion(version, { profile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5 });
|
|
1612
2112
|
if (!patterns.length) return { score: 1, patternScores: [] };
|
|
1613
2113
|
const patternScores = patterns.map((pattern) => ({
|
|
1614
2114
|
row: pattern.row,
|
|
@@ -1701,7 +2201,7 @@ function refineHomographyWithAlignmentGrid(
|
|
|
1701
2201
|
initialGrid,
|
|
1702
2202
|
options = {}
|
|
1703
2203
|
) {
|
|
1704
|
-
const patterns = alignmentPatternCentersForVersion(version);
|
|
2204
|
+
const patterns = alignmentPatternCentersForVersion(version, { profile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5 });
|
|
1705
2205
|
if (patterns.length <= 1 || options.alignmentRefinement === false) {
|
|
1706
2206
|
return {
|
|
1707
2207
|
homography: initialHomography,
|
|
@@ -1756,9 +2256,9 @@ function refineHomographyWithAlignmentGrid(
|
|
|
1756
2256
|
x: candidate.pattern.col + 0.5,
|
|
1757
2257
|
y: candidate.pattern.row + 0.5
|
|
1758
2258
|
});
|
|
1759
|
-
//
|
|
1760
|
-
//
|
|
1761
|
-
weights.push(
|
|
2259
|
+
// Distributed 5x5 alignment eyes now carry stronger evidence while remaining
|
|
2260
|
+
// below the three primary finder patterns in the projective fit.
|
|
2261
|
+
weights.push(1.15 + candidate.score * 1.95);
|
|
1762
2262
|
pointInfo.push({
|
|
1763
2263
|
row: candidate.pattern.row,
|
|
1764
2264
|
col: candidate.pattern.col,
|
|
@@ -1776,7 +2276,7 @@ function refineHomographyWithAlignmentGrid(
|
|
|
1776
2276
|
return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
|
|
1777
2277
|
}
|
|
1778
2278
|
|
|
1779
|
-
const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version);
|
|
2279
|
+
const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version, options);
|
|
1780
2280
|
const primaryBefore = primary
|
|
1781
2281
|
? projectedAlignmentScore(binary, width, height, initialHomography, primary)
|
|
1782
2282
|
: 1;
|
|
@@ -1899,12 +2399,118 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
1899
2399
|
for (const triple of triples) {
|
|
1900
2400
|
const legH = Math.hypot(triple.tr.x - triple.tl.x, triple.tr.y - triple.tl.y);
|
|
1901
2401
|
const legV = Math.hypot(triple.bl.x - triple.tl.x, triple.bl.y - triple.tl.y);
|
|
1902
|
-
const
|
|
1903
|
-
const
|
|
2402
|
+
const moduleH = moduleSizeBetweenFinders(binary, width, height, triple.tl, triple.tr);
|
|
2403
|
+
const moduleV = moduleSizeBetweenFinders(binary, width, height, triple.tl, triple.bl);
|
|
2404
|
+
const estimatedSizeH = legH / Math.max(0.5, moduleH) + 7;
|
|
2405
|
+
const estimatedSizeV = legV / Math.max(0.5, moduleV) + 7;
|
|
2406
|
+
const estimateSpread = Math.abs(estimatedSizeH - estimatedSizeV);
|
|
2407
|
+
const fallbackEstimate = ((legH + legV) / 2) / triple.moduleMean + 7;
|
|
2408
|
+
const estimatedSize = Number.isFinite(estimatedSizeH) && Number.isFinite(estimatedSizeV)
|
|
2409
|
+
? (estimatedSizeH + estimatedSizeV) / 2
|
|
2410
|
+
: fallbackEstimate;
|
|
2411
|
+
// Wrong finder triples often produce wildly different horizontal/vertical
|
|
2412
|
+
// module counts. Reject only the most implausible sets here; full projective
|
|
2413
|
+
// recovery remains intentionally permissive for real extreme camera angles.
|
|
2414
|
+
const maxEstimateSpread = options.maxDimensionEstimateSpread ?? (options.perspectiveRecovery ? 18 : 10);
|
|
2415
|
+
if (estimateSpread > maxEstimateSpread) continue;
|
|
2416
|
+
const versions = nearestVersionFromEstimate(estimatedSize, minVersion, maxVersion).slice(0, Math.max(1, Math.round(options.versionSearchLimit ?? 9)));
|
|
1904
2417
|
|
|
1905
2418
|
for (const item of versions) {
|
|
1906
2419
|
const version = item.version;
|
|
1907
2420
|
const size = sizeForVersion(version);
|
|
2421
|
+
|
|
2422
|
+
// Near-front-facing symbols do not need to wait for a fourth locator.
|
|
2423
|
+
// With three reliable finder centres we already have an affine mapping.
|
|
2424
|
+
// Validate that cheap mapping against the distributed 5x5 alignment grid;
|
|
2425
|
+
// when it agrees strongly, return it immediately and let payload decoding
|
|
2426
|
+
// provide the final integrity check. Strongly projective symbols skip this
|
|
2427
|
+
// shortcut and continue into the full alignment/homography path below.
|
|
2428
|
+
const affineProjectiveSignal = Math.max(
|
|
2429
|
+
Math.max(0, triple.moduleSpread ?? 0),
|
|
2430
|
+
Math.max(0, 1 - (triple.orthogonality ?? 1)) * 0.85,
|
|
2431
|
+
Math.max(0, (triple.legRatio ?? 1) - 1) * 0.12
|
|
2432
|
+
);
|
|
2433
|
+
const allowAffineFastPath = options.finderAffineFastPath !== false &&
|
|
2434
|
+
options.preciseAlignment !== true &&
|
|
2435
|
+
options.perspectiveRecovery !== true &&
|
|
2436
|
+
item.error <= (options.finderAffineVersionError ?? 2.6) &&
|
|
2437
|
+
affineProjectiveSignal <= (options.finderAffineMaxProjectiveSignal ?? 0.24);
|
|
2438
|
+
|
|
2439
|
+
if (allowAffineFastPath) {
|
|
2440
|
+
const separation = size - 7;
|
|
2441
|
+
const basisU = {
|
|
2442
|
+
x: (triple.tr.x - triple.tl.x) / separation,
|
|
2443
|
+
y: (triple.tr.y - triple.tl.y) / separation
|
|
2444
|
+
};
|
|
2445
|
+
const basisV = {
|
|
2446
|
+
x: (triple.bl.x - triple.tl.x) / separation,
|
|
2447
|
+
y: (triple.bl.y - triple.tl.y) / separation
|
|
2448
|
+
};
|
|
2449
|
+
const target = primaryAlignmentPatternForVersion(version, {
|
|
2450
|
+
profile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5
|
|
2451
|
+
});
|
|
2452
|
+
const targetX = target.col + 0.5;
|
|
2453
|
+
const targetY = target.row + 0.5;
|
|
2454
|
+
const predictedAlignment = {
|
|
2455
|
+
x: triple.tl.x + basisU.x * (targetX - 3.5) + basisV.x * (targetY - 3.5),
|
|
2456
|
+
y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
|
|
2457
|
+
};
|
|
2458
|
+
const affineDest = [
|
|
2459
|
+
{ x: 3.5, y: 3.5 },
|
|
2460
|
+
{ x: size - 3.5, y: 3.5 },
|
|
2461
|
+
{ x: 3.5, y: size - 3.5 },
|
|
2462
|
+
{ x: targetX, y: targetY }
|
|
2463
|
+
];
|
|
2464
|
+
const affineSrc = [
|
|
2465
|
+
{ x: triple.tl.x, y: triple.tl.y },
|
|
2466
|
+
{ x: triple.tr.x, y: triple.tr.y },
|
|
2467
|
+
{ x: triple.bl.x, y: triple.bl.y },
|
|
2468
|
+
predictedAlignment
|
|
2469
|
+
];
|
|
2470
|
+
try {
|
|
2471
|
+
const affineHomography = computeHomography(affineSrc, affineDest);
|
|
2472
|
+
const affineGrid = scoreAlignmentGrid(binary, width, height, affineHomography, version, options);
|
|
2473
|
+
if (affineGrid.score >= (options.finderAffineGridThreshold ?? 0.84)) {
|
|
2474
|
+
const versionPenalty = item.error / 4;
|
|
2475
|
+
geometries.push({
|
|
2476
|
+
version,
|
|
2477
|
+
size,
|
|
2478
|
+
sourcePoints: affineSrc,
|
|
2479
|
+
destinationPoints: affineDest,
|
|
2480
|
+
homography: affineHomography,
|
|
2481
|
+
finders: { topLeft: triple.tl, topRight: triple.tr, bottomLeft: triple.bl },
|
|
2482
|
+
alignment: {
|
|
2483
|
+
center: predictedAlignment,
|
|
2484
|
+
predicted: predictedAlignment,
|
|
2485
|
+
score: affineGrid.score,
|
|
2486
|
+
scale: 1,
|
|
2487
|
+
target: { row: target.row, col: target.col },
|
|
2488
|
+
patterns: affineGrid.patternScores.length,
|
|
2489
|
+
gridScore: affineGrid.score,
|
|
2490
|
+
initialGridScore: affineGrid.score,
|
|
2491
|
+
refined: false,
|
|
2492
|
+
refinementPoints: [],
|
|
2493
|
+
patternScores: affineGrid.patternScores,
|
|
2494
|
+
method: "finder-affine"
|
|
2495
|
+
},
|
|
2496
|
+
threshold,
|
|
2497
|
+
finderMethod: `${options.finderMethod ?? "finder"}-affine`,
|
|
2498
|
+
alignmentProfile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
|
|
2499
|
+
estimatedSize,
|
|
2500
|
+
estimatedSizeH,
|
|
2501
|
+
estimatedSizeV,
|
|
2502
|
+
directionalModuleSizeH: moduleH,
|
|
2503
|
+
directionalModuleSizeV: moduleV,
|
|
2504
|
+
score: triple.score * (0.58 + 0.42 * affineGrid.score) / (1 + versionPenalty * 0.08),
|
|
2505
|
+
finderAffineFastPath: true
|
|
2506
|
+
});
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
} catch {
|
|
2510
|
+
// Full alignment search below remains the authoritative path.
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
|
|
1908
2514
|
const alignment = searchAlignment(binary, width, height, triple, version, options);
|
|
1909
2515
|
if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
|
|
1910
2516
|
|
|
@@ -1929,7 +2535,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
1929
2535
|
continue;
|
|
1930
2536
|
}
|
|
1931
2537
|
|
|
1932
|
-
const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
|
|
2538
|
+
const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version, options);
|
|
1933
2539
|
const alignmentGridThreshold = options.alignmentGridThreshold ?? 0.68;
|
|
1934
2540
|
const refinementFloor = Math.max(0, alignmentGridThreshold - (options.alignmentRefineCandidateMargin ?? 0.10));
|
|
1935
2541
|
const skipRefinementScore = options.alignmentRefineSkipScore ?? 0.985;
|
|
@@ -1982,7 +2588,12 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
|
|
|
1982
2588
|
},
|
|
1983
2589
|
threshold,
|
|
1984
2590
|
finderMethod: options.finderMethod,
|
|
2591
|
+
alignmentProfile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
|
|
1985
2592
|
estimatedSize,
|
|
2593
|
+
estimatedSizeH,
|
|
2594
|
+
estimatedSizeV,
|
|
2595
|
+
directionalModuleSizeH: moduleH,
|
|
2596
|
+
directionalModuleSizeV: moduleV,
|
|
1986
2597
|
score
|
|
1987
2598
|
});
|
|
1988
2599
|
}
|
|
@@ -2031,6 +2642,7 @@ function pushFinderDiagnostic(options, finderMethod, threshold, finders, geometr
|
|
|
2031
2642
|
alignmentScore: geometry.alignment.score,
|
|
2032
2643
|
alignmentGridScore: geometry.alignment.gridScore,
|
|
2033
2644
|
finderMethod: geometry.finderMethod,
|
|
2645
|
+
alignmentProfile: geometry.alignmentProfile,
|
|
2034
2646
|
finders: {
|
|
2035
2647
|
topLeft: { ...geometry.finders.topLeft },
|
|
2036
2648
|
topRight: { ...geometry.finders.topRight },
|
|
@@ -2056,7 +2668,10 @@ function detectCodeGeometry(imageData, options = {}) {
|
|
|
2056
2668
|
finderMethod: pass.finderMethod,
|
|
2057
2669
|
perspectiveRecovery: recovery || options.perspectiveRecovery === true,
|
|
2058
2670
|
alignmentThreshold: recovery ? 0.68 : 0.72,
|
|
2059
|
-
alignmentGridThreshold: recovery ? 0.64 : 0.68
|
|
2671
|
+
alignmentGridThreshold: recovery ? 0.64 : 0.68,
|
|
2672
|
+
versionSearchLimit: recovery
|
|
2673
|
+
? (options.recoveryVersionSearchLimit ?? 9)
|
|
2674
|
+
: (options.fastVersionSearchLimit ?? 4)
|
|
2060
2675
|
};
|
|
2061
2676
|
let geometries = geometryCandidatesFromBinary(
|
|
2062
2677
|
pass.binary,
|
|
@@ -2064,9 +2679,38 @@ function detectCodeGeometry(imageData, options = {}) {
|
|
|
2064
2679
|
height,
|
|
2065
2680
|
finders,
|
|
2066
2681
|
pass.threshold,
|
|
2067
|
-
geometryOptions
|
|
2682
|
+
{ ...geometryOptions, alignmentProfile: ALIGNMENT_PROFILE_STANDARD_5 }
|
|
2068
2683
|
);
|
|
2069
2684
|
|
|
2685
|
+
// New format-v6 symbols use 5x5 alignment eyes. Only if that geometry does
|
|
2686
|
+
// not validate do we spend a compatibility attempt on format-v5's compact
|
|
2687
|
+
// 3x3 secondary markers. This keeps the modern path fast.
|
|
2688
|
+
if (!geometries.length && options.legacyAlignmentRecovery !== false && finders.length >= 3) {
|
|
2689
|
+
const legacyMethod = `${pass.finderMethod}-legacy-align`;
|
|
2690
|
+
const legacyOptions = {
|
|
2691
|
+
...geometryOptions,
|
|
2692
|
+
finderMethod: legacyMethod,
|
|
2693
|
+
alignmentProfile: ALIGNMENT_PROFILE_LEGACY_3
|
|
2694
|
+
};
|
|
2695
|
+
geometries = geometryCandidatesFromBinary(
|
|
2696
|
+
pass.binary,
|
|
2697
|
+
width,
|
|
2698
|
+
height,
|
|
2699
|
+
finders,
|
|
2700
|
+
pass.threshold,
|
|
2701
|
+
legacyOptions
|
|
2702
|
+
);
|
|
2703
|
+
if (geometries.length) {
|
|
2704
|
+
pushFinderDiagnostic(
|
|
2705
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "normal"}-legacy-align` },
|
|
2706
|
+
legacyMethod,
|
|
2707
|
+
pass.threshold,
|
|
2708
|
+
finders,
|
|
2709
|
+
geometries
|
|
2710
|
+
);
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2070
2714
|
pushFinderDiagnostic(
|
|
2071
2715
|
{ ...options, width, height },
|
|
2072
2716
|
pass.finderMethod,
|
|
@@ -2113,9 +2757,123 @@ function detectCodeGeometry(imageData, options = {}) {
|
|
|
2113
2757
|
finderMethod: "rgb-value-otsu",
|
|
2114
2758
|
binary: valueInfo.binary,
|
|
2115
2759
|
threshold: valueInfo.threshold,
|
|
2116
|
-
detector: {
|
|
2760
|
+
detector: {
|
|
2761
|
+
toleranceScale: options.finderToleranceScale ?? 1.0,
|
|
2762
|
+
moduleSpreadLimit: options.finderModuleSpreadLimit ?? 0.45,
|
|
2763
|
+
minConfirmations: options.finderMinConfirmations ?? 2,
|
|
2764
|
+
diagonalCheck: false
|
|
2765
|
+
}
|
|
2117
2766
|
}, false);
|
|
2767
|
+
const fastEstimateMatched = fast.geometries.some((geometry) =>
|
|
2768
|
+
Math.abs(sizeForVersion(geometry.version) - geometry.estimatedSize) <= (options.fastDimensionAcceptance ?? 2.0)
|
|
2769
|
+
);
|
|
2770
|
+
if (fast.geometries.length && fastEstimateMatched) {
|
|
2771
|
+
return fast.geometries.slice(0, maxCandidates);
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
// Seeing three finder patterns should immediately unlock perspective-aware
|
|
2775
|
+
// geometry. The previous first pass used stricter near-front-facing triple
|
|
2776
|
+
// limits, so a code could visibly have all three eyes yet still fall through
|
|
2777
|
+
// into color recovery. Reuse the exact same binary/finders with broader
|
|
2778
|
+
// projective geometry before doing any additional pixel processing.
|
|
2779
|
+
if (fast.finders.length >= 3) {
|
|
2780
|
+
const finderMethod = "rgb-value-otsu-projective";
|
|
2781
|
+
const projectiveGeometries = geometryCandidatesFromBinary(
|
|
2782
|
+
valueInfo.binary,
|
|
2783
|
+
width,
|
|
2784
|
+
height,
|
|
2785
|
+
fast.finders,
|
|
2786
|
+
valueInfo.threshold,
|
|
2787
|
+
{
|
|
2788
|
+
...options,
|
|
2789
|
+
finderMethod,
|
|
2790
|
+
perspectiveRecovery: true,
|
|
2791
|
+
alignmentThreshold: 0.60,
|
|
2792
|
+
alignmentGridThreshold: 0.58,
|
|
2793
|
+
versionSearchLimit: options.fastPerspectiveVersionSearchLimit ?? 4,
|
|
2794
|
+
perspectiveAlignmentFineSeeds: options.fastPerspectiveAlignmentFineSeeds ?? 3,
|
|
2795
|
+
perspectiveAlignmentMaxRadius: options.fastPerspectiveAlignmentMaxRadius ?? 12
|
|
2796
|
+
}
|
|
2797
|
+
);
|
|
2798
|
+
pushFinderDiagnostic(
|
|
2799
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "normal"}-projective` },
|
|
2800
|
+
finderMethod,
|
|
2801
|
+
valueInfo.threshold,
|
|
2802
|
+
fast.finders,
|
|
2803
|
+
projectiveGeometries
|
|
2804
|
+
);
|
|
2805
|
+
if (projectiveGeometries.length) return projectiveGeometries.slice(0, maxCandidates);
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// Keep the original strict geometry as a fallback if the projective pass did
|
|
2809
|
+
// not produce anything better. This preserves historical behavior while
|
|
2810
|
+
// avoiding an early wrong-version lock-in when the directional size estimate
|
|
2811
|
+
// clearly points at another version.
|
|
2118
2812
|
if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
|
|
2813
|
+
|
|
2814
|
+
// A local block threshold is part of the normal locator path, not the heavy
|
|
2815
|
+
// color/damage recovery stack. It is especially effective when one finder is
|
|
2816
|
+
// under a shadow or a phone-screen gradient while the other two are bright.
|
|
2817
|
+
// At camera locator resolution this costs far less than escalating to a
|
|
2818
|
+
// high-resolution scan or running full-frame Auto Color.
|
|
2819
|
+
let localBinary = null;
|
|
2820
|
+
if (options.localFinderThreshold !== false && fast.finders.length < 3) {
|
|
2821
|
+
localBinary = hybridLocalBinary(valueInfo.gray, width, height);
|
|
2822
|
+
if (localBinary) {
|
|
2823
|
+
const local = evaluatePass({
|
|
2824
|
+
finderMethod: "rgb-value-hybrid-local",
|
|
2825
|
+
binary: localBinary,
|
|
2826
|
+
threshold: null,
|
|
2827
|
+
detector: {
|
|
2828
|
+
toleranceScale: 1.10,
|
|
2829
|
+
moduleSpreadLimit: 0.58,
|
|
2830
|
+
diagonalCheck: false
|
|
2831
|
+
}
|
|
2832
|
+
}, true);
|
|
2833
|
+
if (local.geometries.length) return local.geometries.slice(0, maxCandidates);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
if (options.componentFinderFallback !== false && fast.finders.length < 3) {
|
|
2838
|
+
for (const [binary, method] of [
|
|
2839
|
+
[valueInfo.binary, "rgb-value-component"],
|
|
2840
|
+
[localBinary, "rgb-value-hybrid-component"]
|
|
2841
|
+
]) {
|
|
2842
|
+
if (!binary) continue;
|
|
2843
|
+
const componentFinders = detectFinderCandidatesByComponents(binary, width, height, options);
|
|
2844
|
+
const merged = clusterFinderCandidates([
|
|
2845
|
+
...fast.finders.map((finder) => ({ ...finder })),
|
|
2846
|
+
...componentFinders
|
|
2847
|
+
], 1);
|
|
2848
|
+
if (merged.length < 3) continue;
|
|
2849
|
+
const componentGeometries = geometryCandidatesFromBinary(
|
|
2850
|
+
binary,
|
|
2851
|
+
width,
|
|
2852
|
+
height,
|
|
2853
|
+
merged,
|
|
2854
|
+
method.includes("hybrid") ? null : valueInfo.threshold,
|
|
2855
|
+
{
|
|
2856
|
+
...options,
|
|
2857
|
+
finderMethod: method,
|
|
2858
|
+
perspectiveRecovery: true,
|
|
2859
|
+
alignmentThreshold: 0.60,
|
|
2860
|
+
alignmentGridThreshold: 0.58,
|
|
2861
|
+
versionSearchLimit: options.fastPerspectiveVersionSearchLimit ?? 4,
|
|
2862
|
+
perspectiveAlignmentFineSeeds: options.fastPerspectiveAlignmentFineSeeds ?? 3,
|
|
2863
|
+
perspectiveAlignmentMaxRadius: options.fastPerspectiveAlignmentMaxRadius ?? 12
|
|
2864
|
+
}
|
|
2865
|
+
);
|
|
2866
|
+
pushFinderDiagnostic(
|
|
2867
|
+
{ ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "normal"}-component` },
|
|
2868
|
+
method,
|
|
2869
|
+
method.includes("hybrid") ? null : valueInfo.threshold,
|
|
2870
|
+
merged,
|
|
2871
|
+
componentGeometries
|
|
2872
|
+
);
|
|
2873
|
+
if (componentGeometries.length) return componentGeometries.slice(0, maxCandidates);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2119
2877
|
if (options.finderRecovery === false) return [];
|
|
2120
2878
|
|
|
2121
2879
|
// If the clean threshold already found exactly two strong locators, try the
|
|
@@ -2390,6 +3148,21 @@ function autoColorImageData(imageData, options = {}) {
|
|
|
2390
3148
|
}
|
|
2391
3149
|
|
|
2392
3150
|
function buildAutoColorValueGray(imageData, options = {}) {
|
|
3151
|
+
const canCache = imageData && typeof imageData === "object" && options.cache !== false;
|
|
3152
|
+
const cacheKey = [
|
|
3153
|
+
options.blackClip ?? 0.0001,
|
|
3154
|
+
options.whiteClip ?? "default",
|
|
3155
|
+
options.highlightPercentile ?? 0.95,
|
|
3156
|
+
options.outputHighlight ?? 190,
|
|
3157
|
+
options.analysisInset ?? 0,
|
|
3158
|
+
options.minimumInputRange ?? 72,
|
|
3159
|
+
options.targetSamples ?? "default"
|
|
3160
|
+
].join(":");
|
|
3161
|
+
if (canCache) {
|
|
3162
|
+
const cached = autoColorGrayCache.get(imageData)?.get(cacheKey);
|
|
3163
|
+
if (cached) return cached;
|
|
3164
|
+
}
|
|
3165
|
+
|
|
2393
3166
|
const levels = buildAutoColorLevels(imageData, options);
|
|
2394
3167
|
const gray = new Uint8Array(imageData.width * imageData.height);
|
|
2395
3168
|
for (let i = 0; i < gray.length; i++) {
|
|
@@ -2404,6 +3177,11 @@ function buildAutoColorValueGray(imageData, options = {}) {
|
|
|
2404
3177
|
levels.mapChannel(b, 2)
|
|
2405
3178
|
));
|
|
2406
3179
|
}
|
|
3180
|
+
if (canCache) {
|
|
3181
|
+
let entries = autoColorGrayCache.get(imageData);
|
|
3182
|
+
if (!entries) autoColorGrayCache.set(imageData, entries = new Map());
|
|
3183
|
+
entries.set(cacheKey, gray);
|
|
3184
|
+
}
|
|
2407
3185
|
return gray;
|
|
2408
3186
|
}
|
|
2409
3187
|
|
|
@@ -2863,7 +3641,7 @@ const visionInternals = Object.freeze({
|
|
|
2863
3641
|
luminance
|
|
2864
3642
|
});
|
|
2865
3643
|
|
|
2866
|
-
return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid, visionInternals };
|
|
3644
|
+
return { installVisionAccelerator, buildBinary, detectFinderCandidates, chooseFinderTriples, searchAlignment, computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid, visionInternals };
|
|
2867
3645
|
})();
|
|
2868
3646
|
const __qqr_deflate_js = (function () {
|
|
2869
3647
|
/**
|
|
@@ -5466,7 +6244,7 @@ return { compressBrotliPayload, decompressBrotliPayload, BROTLI_QUALITY_MIN, BRO
|
|
|
5466
6244
|
})();
|
|
5467
6245
|
const __qqr_quadqr_js = (function () {
|
|
5468
6246
|
const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
|
|
5469
|
-
const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
|
|
6247
|
+
const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, ALIGNMENT_PROFILE_STANDARD_5, ALIGNMENT_PROFILE_LEGACY_3, sizeForVersion, versionFromSize } = __qqr_geometry_js;
|
|
5470
6248
|
const { detectCodeGeometry, computeHomography, projectPoint, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid } = __qqr_vision_js;
|
|
5471
6249
|
const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex, estimateSecureEnvelopeOverhead } = __qqr_security_js;
|
|
5472
6250
|
const { compressDeflatePayload: compressDeflateBytes, decompressDeflatePayload: decompressDeflateBytes, DEFAULT_DEFLATE_LEVEL, DEFLATE_LEVEL_MIN, DEFLATE_LEVEL_MAX } = __qqr_deflate_js;
|
|
@@ -5488,7 +6266,8 @@ const { compressBrotliPayload: compressBrotliBytes, decompressBrotliPayload: dec
|
|
|
5488
6266
|
* - camera color calibration and image scanning
|
|
5489
6267
|
*/
|
|
5490
6268
|
|
|
5491
|
-
const FORMAT_VERSION =
|
|
6269
|
+
const FORMAT_VERSION = 6;
|
|
6270
|
+
const LEGACY_FORMAT_VERSION = 5;
|
|
5492
6271
|
const MIN_VERSION = 1;
|
|
5493
6272
|
const MAX_VERSION = 40;
|
|
5494
6273
|
const DEFAULT_ECC_LEVEL = "M";
|
|
@@ -6550,7 +7329,7 @@ function getEffectiveEcc(version, eccLevel) {
|
|
|
6550
7329
|
return version === COMPACT_VERSION ? COMPACT_ECC_LEVELS[normalized] : ECC_LEVELS[normalized];
|
|
6551
7330
|
}
|
|
6552
7331
|
|
|
6553
|
-
function makeHeader(payloadLength, flags, version) {
|
|
7332
|
+
function makeHeader(payloadLength, flags, version, formatVersion = FORMAT_VERSION) {
|
|
6554
7333
|
assert(payloadLength >= 0 && payloadLength <= 0xffffffff, "Payload is too large.");
|
|
6555
7334
|
|
|
6556
7335
|
if (version === COMPACT_VERSION) {
|
|
@@ -6565,7 +7344,7 @@ function makeHeader(payloadLength, flags, version) {
|
|
|
6565
7344
|
|
|
6566
7345
|
const header = new Uint8Array(HEADER_BYTES);
|
|
6567
7346
|
header.set(MAGIC, 0);
|
|
6568
|
-
header[4] =
|
|
7347
|
+
header[4] = formatVersion;
|
|
6569
7348
|
header[5] = flags & 0xff;
|
|
6570
7349
|
header.set(u32be(payloadLength), 6);
|
|
6571
7350
|
return header;
|
|
@@ -6587,14 +7366,22 @@ function parseHeader(header, version) {
|
|
|
6587
7366
|
return { flags: header[1], payloadLength: header[2] };
|
|
6588
7367
|
}
|
|
6589
7368
|
|
|
6590
|
-
|
|
7369
|
+
const formatVersion = header[4];
|
|
7370
|
+
if (!magicMatches(header) || (formatVersion !== FORMAT_VERSION && formatVersion !== LEGACY_FORMAT_VERSION)) {
|
|
6591
7371
|
throw new Error("QuadQR magic/version mismatch.");
|
|
6592
7372
|
}
|
|
6593
|
-
return { flags: header[5], payloadLength: readU32be(header, 6) };
|
|
7373
|
+
return { formatVersion, flags: header[5], payloadLength: readU32be(header, 6) };
|
|
6594
7374
|
}
|
|
6595
7375
|
|
|
6596
|
-
function
|
|
7376
|
+
function alignmentProfileForFormat(formatVersion = FORMAT_VERSION) {
|
|
7377
|
+
return formatVersion === LEGACY_FORMAT_VERSION
|
|
7378
|
+
? ALIGNMENT_PROFILE_LEGACY_3
|
|
7379
|
+
: ALIGNMENT_PROFILE_STANDARD_5;
|
|
7380
|
+
}
|
|
7381
|
+
|
|
7382
|
+
function createLayout(version, options = {}) {
|
|
6597
7383
|
validateVersion(version);
|
|
7384
|
+
const alignmentProfile = options.alignmentProfile ?? alignmentProfileForFormat(options.formatVersion ?? FORMAT_VERSION);
|
|
6598
7385
|
const size = sizeForVersion(version);
|
|
6599
7386
|
const matrix = make2D(size, CELL.WHITE);
|
|
6600
7387
|
const reserved = make2D(size, false);
|
|
@@ -6722,7 +7509,7 @@ function createLayout(version) {
|
|
|
6722
7509
|
reserveAndSet(row, 6, row % 2 === 0 ? CELL.BLACK : CELL.WHITE);
|
|
6723
7510
|
}
|
|
6724
7511
|
|
|
6725
|
-
const alignments = alignmentPatternCentersForVersion(version).map(drawAlignmentPattern);
|
|
7512
|
+
const alignments = alignmentPatternCentersForVersion(version, { profile: alignmentProfile }).map(drawAlignmentPattern);
|
|
6726
7513
|
const alignment = alignments[alignments.length - 1];
|
|
6727
7514
|
const calibrationStrip = drawCalibrationStrip();
|
|
6728
7515
|
|
|
@@ -6746,6 +7533,7 @@ function createLayout(version) {
|
|
|
6746
7533
|
return {
|
|
6747
7534
|
version,
|
|
6748
7535
|
size,
|
|
7536
|
+
alignmentProfile,
|
|
6749
7537
|
matrix,
|
|
6750
7538
|
reserved,
|
|
6751
7539
|
dataPositions,
|
|
@@ -7386,8 +8174,8 @@ function alignmentPatternMismatchRatio(matrix, pattern) {
|
|
|
7386
8174
|
return mismatches / total;
|
|
7387
8175
|
}
|
|
7388
8176
|
|
|
7389
|
-
function alignmentGridMismatchRatio(matrix, version) {
|
|
7390
|
-
const patterns = alignmentPatternCentersForVersion(version);
|
|
8177
|
+
function alignmentGridMismatchRatio(matrix, version, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
|
|
8178
|
+
const patterns = alignmentPatternCentersForVersion(version, { profile: alignmentProfile });
|
|
7391
8179
|
let weightedMismatch = 0;
|
|
7392
8180
|
let totalWeight = 0;
|
|
7393
8181
|
for (const pattern of patterns) {
|
|
@@ -7399,7 +8187,7 @@ function alignmentGridMismatchRatio(matrix, version) {
|
|
|
7399
8187
|
return totalWeight ? weightedMismatch / totalWeight : 0;
|
|
7400
8188
|
}
|
|
7401
8189
|
|
|
7402
|
-
function validateStructure(matrix, tolerance = 0) {
|
|
8190
|
+
function validateStructure(matrix, tolerance = 0, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
|
|
7403
8191
|
const size = matrix.length;
|
|
7404
8192
|
if (size < 21 || matrix.some((row) => row.length !== size)) return false;
|
|
7405
8193
|
const version = versionFromSize(size);
|
|
@@ -7412,11 +8200,11 @@ function validateStructure(matrix, tolerance = 0) {
|
|
|
7412
8200
|
if (finderRatios.some((ratio) => ratio > tolerance)) return false;
|
|
7413
8201
|
|
|
7414
8202
|
const alignmentTolerance = Math.max(tolerance, 0.12);
|
|
7415
|
-
const primary = alignmentPatternCentersForVersion(version).at(-1);
|
|
8203
|
+
const primary = alignmentPatternCentersForVersion(version, { profile: alignmentProfile }).at(-1);
|
|
7416
8204
|
if (primary && alignmentPatternMismatchRatio(matrix, primary) > alignmentTolerance) {
|
|
7417
8205
|
return false;
|
|
7418
8206
|
}
|
|
7419
|
-
if (alignmentGridMismatchRatio(matrix, version) > alignmentTolerance) return false;
|
|
8207
|
+
if (alignmentGridMismatchRatio(matrix, version, alignmentProfile) > alignmentTolerance) return false;
|
|
7420
8208
|
return true;
|
|
7421
8209
|
}
|
|
7422
8210
|
|
|
@@ -7604,13 +8392,13 @@ function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConf
|
|
|
7604
8392
|
};
|
|
7605
8393
|
}
|
|
7606
8394
|
|
|
7607
|
-
function
|
|
8395
|
+
function decodeCanonicalWithProfile(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
|
|
7608
8396
|
const size = matrix.length;
|
|
7609
8397
|
const version = versionFromSize(size);
|
|
7610
8398
|
if (!version) throw new Error(`Unsupported matrix size ${size}.`);
|
|
7611
|
-
if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
8399
|
+
if (!validateStructure(matrix, tolerance, alignmentProfile)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
7612
8400
|
|
|
7613
|
-
const layout = createLayout(version);
|
|
8401
|
+
const layout = createLayout(version, { alignmentProfile });
|
|
7614
8402
|
const errors = [];
|
|
7615
8403
|
const hintedEncoding = options.cellEncodingHint != null
|
|
7616
8404
|
? normalizeCellEncoding(options.cellEncodingHint)
|
|
@@ -7660,6 +8448,10 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
7660
8448
|
);
|
|
7661
8449
|
const header = headerDecoded.header;
|
|
7662
8450
|
const flags = headerDecoded.flags;
|
|
8451
|
+
const decodedFormatVersion = headerDecoded.formatVersion ?? FORMAT_VERSION;
|
|
8452
|
+
if (version >= 7 && alignmentProfileForFormat(decodedFormatVersion) !== alignmentProfile) {
|
|
8453
|
+
throw new Error(`Format v${decodedFormatVersion} uses a different alignment profile.`);
|
|
8454
|
+
}
|
|
7663
8455
|
const declaredCellEncoding = cellEncodingFromFlags(flags);
|
|
7664
8456
|
if (declaredCellEncoding !== cellEncoding) {
|
|
7665
8457
|
throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
|
|
@@ -7697,10 +8489,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
7697
8489
|
return {
|
|
7698
8490
|
ok: true,
|
|
7699
8491
|
format: "QuadQR",
|
|
7700
|
-
formatVersion:
|
|
8492
|
+
formatVersion: decodedFormatVersion,
|
|
7701
8493
|
version,
|
|
7702
8494
|
size,
|
|
7703
8495
|
alignmentPatterns: layout.alignments.length,
|
|
8496
|
+
alignmentProfile,
|
|
7704
8497
|
maskId,
|
|
7705
8498
|
rotation,
|
|
7706
8499
|
flags,
|
|
@@ -7748,17 +8541,42 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
7748
8541
|
throw new Error(`QuadQR decode failed. ${errors.join(" | ")}`);
|
|
7749
8542
|
}
|
|
7750
8543
|
|
|
8544
|
+
function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}) {
|
|
8545
|
+
const hinted = options.alignmentProfileHint;
|
|
8546
|
+
const profiles = hinted
|
|
8547
|
+
? [hinted]
|
|
8548
|
+
: [ALIGNMENT_PROFILE_STANDARD_5, ALIGNMENT_PROFILE_LEGACY_3];
|
|
8549
|
+
const errors = [];
|
|
8550
|
+
for (const alignmentProfile of profiles) {
|
|
8551
|
+
try {
|
|
8552
|
+
return decodeCanonicalWithProfile(matrix, rotation, tolerance, confidenceMatrix, options, alignmentProfile);
|
|
8553
|
+
} catch (error) {
|
|
8554
|
+
errors.push(`${alignmentProfile}: ${error.message}`);
|
|
8555
|
+
}
|
|
8556
|
+
}
|
|
8557
|
+
throw new Error(`QuadQR decode failed for all alignment profiles. ${errors.join(" | ")}`);
|
|
8558
|
+
}
|
|
8559
|
+
|
|
7751
8560
|
function trySoftMatrixDecode(matrix, alternatives, confidenceMatrix, degrees, tolerance, options = {}) {
|
|
7752
8561
|
if (!alternatives || !confidenceMatrix || options.softDecoding === false) return null;
|
|
7753
8562
|
const version = versionFromSize(matrix.length);
|
|
7754
8563
|
if (!version) return null;
|
|
7755
8564
|
const layout = createLayout(version);
|
|
8565
|
+
const legacyLayout = createLayout(version, { alignmentProfile: ALIGNMENT_PROFILE_LEGACY_3 });
|
|
8566
|
+
const softPositions = [];
|
|
8567
|
+
const seenSoftPositions = new Set();
|
|
8568
|
+
for (const [row, col] of [...layout.dataPositions, ...legacyLayout.dataPositions]) {
|
|
8569
|
+
const key = `${row},${col}`;
|
|
8570
|
+
if (seenSoftPositions.has(key)) continue;
|
|
8571
|
+
seenSoftPositions.add(key);
|
|
8572
|
+
softPositions.push([row, col]);
|
|
8573
|
+
}
|
|
7756
8574
|
const threshold = clampNumber(options.softDecodeConfidence ?? 0.72, 0.05, 0.95);
|
|
7757
8575
|
const maxCells = Math.max(2, Math.min(16, Math.round(options.softDecodeMaxCells ?? 10)));
|
|
7758
8576
|
const pairCells = Math.max(2, Math.min(maxCells, Math.round(options.softDecodePairCells ?? 6)));
|
|
7759
8577
|
const ranked = [];
|
|
7760
8578
|
|
|
7761
|
-
for (const [row, col] of
|
|
8579
|
+
for (const [row, col] of softPositions) {
|
|
7762
8580
|
const alternative = alternatives[row]?.[col];
|
|
7763
8581
|
const confidence = confidenceMatrix[row]?.[col] ?? 1;
|
|
7764
8582
|
if (!Number.isInteger(alternative) || alternative === matrix[row][col] || confidence > threshold) continue;
|
|
@@ -9111,27 +9929,31 @@ function paletteClassifierAttempts(observedPalette) {
|
|
|
9111
9929
|
}
|
|
9112
9930
|
|
|
9113
9931
|
function tryPerspectiveScan(imageData, options) {
|
|
9114
|
-
const geometryCandidates =
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
9129
|
-
|
|
9932
|
+
const geometryCandidates = Array.isArray(options._geometryCandidatesOverride) && options._geometryCandidatesOverride.length
|
|
9933
|
+
? options._geometryCandidatesOverride
|
|
9934
|
+
: detectCodeGeometry(imageData, {
|
|
9935
|
+
minVersion: options.minVersion ?? MIN_VERSION,
|
|
9936
|
+
maxVersion: options.maxVersion ?? MAX_VERSION,
|
|
9937
|
+
maxCandidates: options.maxGeometryCandidates ?? 8,
|
|
9938
|
+
finderRecovery: options.finderRecovery,
|
|
9939
|
+
finderAutoColorBlackClip: options.finderAutoColorBlackClip,
|
|
9940
|
+
finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
|
|
9941
|
+
finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
|
|
9942
|
+
finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
|
|
9943
|
+
finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
|
|
9944
|
+
finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
|
|
9945
|
+
finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
|
|
9946
|
+
preciseAlignment: options.preciseAlignment,
|
|
9947
|
+
diagnostics: options._visionDiagnostics,
|
|
9948
|
+
diagnosticLabel: options._diagnosticLabel ?? "normal"
|
|
9949
|
+
});
|
|
9130
9950
|
if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
|
|
9131
9951
|
const results = [];
|
|
9132
9952
|
|
|
9133
9953
|
for (const geometry of geometryCandidates) {
|
|
9134
|
-
const layout = createLayout(geometry.version
|
|
9954
|
+
const layout = createLayout(geometry.version, {
|
|
9955
|
+
alignmentProfile: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5
|
|
9956
|
+
});
|
|
9135
9957
|
const sampleProfiles = [{
|
|
9136
9958
|
sampleMode: options.sampleMode ?? "cross",
|
|
9137
9959
|
sampleRadius: options.sampleRadius ?? 0.16,
|
|
@@ -9168,7 +9990,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9168
9990
|
continue;
|
|
9169
9991
|
}
|
|
9170
9992
|
|
|
9171
|
-
const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
|
|
9993
|
+
const tryAttempt = (attempt, rgbGrid, metadata = {}, allowSoftDecoding = options.softDecoding !== false) => {
|
|
9172
9994
|
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
9173
9995
|
const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
|
|
9174
9996
|
const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
|
|
@@ -9193,6 +10015,9 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9193
10015
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
9194
10016
|
cellConfidence: classified.confidence,
|
|
9195
10017
|
cellAlternatives: classified.alternatives,
|
|
10018
|
+
cellEncodingHint: CELL_ENCODINGS.RGBW,
|
|
10019
|
+
alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
|
|
10020
|
+
softDecoding: allowSoftDecoding,
|
|
9196
10021
|
maxErasureConfidence: options.maxErasureConfidence
|
|
9197
10022
|
});
|
|
9198
10023
|
if (decoded.version !== geometry.version) return false;
|
|
@@ -9221,7 +10046,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9221
10046
|
}
|
|
9222
10047
|
};
|
|
9223
10048
|
|
|
9224
|
-
const tryTriangleAttempt = (attempt, metadata = {}) => {
|
|
10049
|
+
const tryTriangleAttempt = (attempt, metadata = {}, allowSoftDecoding = options.softDecoding !== false) => {
|
|
9225
10050
|
if (!triangleSampled?.triangleGrid) return false;
|
|
9226
10051
|
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
9227
10052
|
const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
|
|
@@ -9254,6 +10079,8 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9254
10079
|
cellConfidence: classified.confidence,
|
|
9255
10080
|
cellAlternatives: classified.alternatives,
|
|
9256
10081
|
cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
|
|
10082
|
+
alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
|
|
10083
|
+
softDecoding: allowSoftDecoding,
|
|
9257
10084
|
maxErasureConfidence: options.maxErasureConfidence
|
|
9258
10085
|
});
|
|
9259
10086
|
if (decoded.version !== geometry.version) return false;
|
|
@@ -9282,21 +10109,38 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9282
10109
|
// Fast path: preserve the original observed-RGB classifier first, then
|
|
9283
10110
|
// try per-channel white balancing. Most clean frames stop here without
|
|
9284
10111
|
// paying for the more expensive spatial normalization fallback.
|
|
9285
|
-
|
|
9286
|
-
|
|
10112
|
+
const basePaletteAttempts = paletteClassifierAttempts(observedPalette);
|
|
10113
|
+
for (const attempt of basePaletteAttempts) {
|
|
10114
|
+
if (tryAttempt(attempt, sampled.rgbGrid, {}, false) || tryTriangleAttempt(attempt, {}, false)) {
|
|
9287
10115
|
geometryDecoded = true;
|
|
9288
10116
|
break;
|
|
9289
10117
|
}
|
|
9290
10118
|
}
|
|
10119
|
+
// Spectrum ECC soft decoding is intentionally deferred until every cheap
|
|
10120
|
+
// hard classifier has had a chance. This preserves the exact recovery
|
|
10121
|
+
// candidates while avoiding expensive second-hypothesis RS searches when
|
|
10122
|
+
// a later normal color model can decode the frame immediately.
|
|
10123
|
+
if (!geometryDecoded && options.softDecoding !== false) {
|
|
10124
|
+
for (const attempt of basePaletteAttempts) {
|
|
10125
|
+
if (tryAttempt(attempt, sampled.rgbGrid, {}, true) || tryTriangleAttempt(attempt, {}, true)) {
|
|
10126
|
+
geometryDecoded = true;
|
|
10127
|
+
break;
|
|
10128
|
+
}
|
|
10129
|
+
}
|
|
10130
|
+
}
|
|
9291
10131
|
|
|
9292
10132
|
if (!geometryDecoded && options.spatialColorNormalization !== false) {
|
|
9293
10133
|
try {
|
|
9294
10134
|
const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
|
|
9295
10135
|
const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
|
|
9296
|
-
|
|
10136
|
+
const spatialAttempt = {
|
|
9297
10137
|
classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
|
|
9298
10138
|
colorNormalization: "spatial-white-balanced"
|
|
9299
|
-
}
|
|
10139
|
+
};
|
|
10140
|
+
geometryDecoded = tryAttempt(spatialAttempt, normalizedGrid, {}, false);
|
|
10141
|
+
if (!geometryDecoded && options.softDecoding !== false) {
|
|
10142
|
+
geometryDecoded = tryAttempt(spatialAttempt, normalizedGrid, {}, true);
|
|
10143
|
+
}
|
|
9300
10144
|
} catch {
|
|
9301
10145
|
// Continue to the recovery profile below.
|
|
9302
10146
|
}
|
|
@@ -9313,20 +10157,32 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9313
10157
|
saturation: options.autoEnhanceSaturation
|
|
9314
10158
|
});
|
|
9315
10159
|
const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
10160
|
+
const enhancedAttempts = paletteClassifierAttempts(enhancedPalette).map((attempt) => ({
|
|
10161
|
+
...attempt,
|
|
10162
|
+
colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
|
|
10163
|
+
}));
|
|
10164
|
+
for (const recoveredAttempt of enhancedAttempts) {
|
|
9321
10165
|
if (tryAttempt(recoveredAttempt, enhancedGrid, {
|
|
9322
10166
|
observedPalette: enhancedPalette,
|
|
9323
10167
|
autoEnhanced: true,
|
|
9324
10168
|
recoveryMode: "module-grid-auto-tone-contrast-color"
|
|
9325
|
-
})) {
|
|
10169
|
+
}, false)) {
|
|
9326
10170
|
geometryDecoded = true;
|
|
9327
10171
|
break;
|
|
9328
10172
|
}
|
|
9329
10173
|
}
|
|
10174
|
+
if (!geometryDecoded && options.softDecoding !== false) {
|
|
10175
|
+
for (const recoveredAttempt of enhancedAttempts) {
|
|
10176
|
+
if (tryAttempt(recoveredAttempt, enhancedGrid, {
|
|
10177
|
+
observedPalette: enhancedPalette,
|
|
10178
|
+
autoEnhanced: true,
|
|
10179
|
+
recoveryMode: "module-grid-auto-tone-contrast-color"
|
|
10180
|
+
}, true)) {
|
|
10181
|
+
geometryDecoded = true;
|
|
10182
|
+
break;
|
|
10183
|
+
}
|
|
10184
|
+
}
|
|
10185
|
+
}
|
|
9330
10186
|
} catch {
|
|
9331
10187
|
// Continue to the QR-region pixel recovery below.
|
|
9332
10188
|
}
|
|
@@ -9371,21 +10227,34 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9371
10227
|
layout.calibration,
|
|
9372
10228
|
{ robust: true }
|
|
9373
10229
|
);
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
|
|
10230
|
+
const rectifiedAttempts = paletteClassifierAttempts(enhancedPalette).map((attempt) => ({
|
|
10231
|
+
...attempt,
|
|
10232
|
+
colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
|
|
10233
|
+
}));
|
|
10234
|
+
for (const recoveredAttempt of rectifiedAttempts) {
|
|
9379
10235
|
if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
|
|
9380
10236
|
observedPalette: enhancedPalette,
|
|
9381
10237
|
samplingMode: "rectified-auto-enhance",
|
|
9382
10238
|
autoEnhanced: true,
|
|
9383
10239
|
recoveryMode: "rectified-auto-tone-contrast-color"
|
|
9384
|
-
})) {
|
|
10240
|
+
}, false)) {
|
|
9385
10241
|
geometryDecoded = true;
|
|
9386
10242
|
break;
|
|
9387
10243
|
}
|
|
9388
10244
|
}
|
|
10245
|
+
if (!geometryDecoded && options.softDecoding !== false) {
|
|
10246
|
+
for (const recoveredAttempt of rectifiedAttempts) {
|
|
10247
|
+
if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
|
|
10248
|
+
observedPalette: enhancedPalette,
|
|
10249
|
+
samplingMode: "rectified-auto-enhance",
|
|
10250
|
+
autoEnhanced: true,
|
|
10251
|
+
recoveryMode: "rectified-auto-tone-contrast-color"
|
|
10252
|
+
}, true)) {
|
|
10253
|
+
geometryDecoded = true;
|
|
10254
|
+
break;
|
|
10255
|
+
}
|
|
10256
|
+
}
|
|
10257
|
+
}
|
|
9389
10258
|
} catch {
|
|
9390
10259
|
// Geometry refinement remains available as the final bounded fallback.
|
|
9391
10260
|
}
|
|
@@ -9394,6 +10263,15 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9394
10263
|
if (geometryDecoded) break;
|
|
9395
10264
|
}
|
|
9396
10265
|
|
|
10266
|
+
// A decoded QuadQR has already passed structural validation, Spectrum ECC,
|
|
10267
|
+
// and the payload CRC. Continuing through every lower-ranked geometry after
|
|
10268
|
+
// that point used to spend most of the camera scan time proving the same
|
|
10269
|
+
// frame again. Return the first authenticated decode immediately. An opt-in
|
|
10270
|
+
// diagnostic mode can still collect every successful geometry if needed.
|
|
10271
|
+
if (geometryDecoded && options.collectAllGeometryResults !== true) {
|
|
10272
|
+
return results[results.length - 1] ?? null;
|
|
10273
|
+
}
|
|
10274
|
+
|
|
9397
10275
|
// Slow-path geometry micro-refinement. Finder/alignment detection can be
|
|
9398
10276
|
// correct while a soft-focus or smeared camera shifts the effective module
|
|
9399
10277
|
// centres by a small fraction of a cell. Clean scans never reach this code.
|
|
@@ -9540,6 +10418,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9540
10418
|
cellConfidence: candidate.classified.confidence,
|
|
9541
10419
|
cellAlternatives: candidate.classified.alternatives,
|
|
9542
10420
|
cellEncodingHint: candidate.cellEncoding,
|
|
10421
|
+
alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
|
|
9543
10422
|
maxErasureConfidence: options.maxErasureConfidence
|
|
9544
10423
|
});
|
|
9545
10424
|
if (decoded.version !== geometry.version) continue;
|
|
@@ -9568,6 +10447,11 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
9568
10447
|
}
|
|
9569
10448
|
}
|
|
9570
10449
|
}
|
|
10450
|
+
|
|
10451
|
+
|
|
10452
|
+
if (geometryDecoded && options.collectAllGeometryResults !== true) {
|
|
10453
|
+
return results[results.length - 1] ?? null;
|
|
10454
|
+
}
|
|
9571
10455
|
}
|
|
9572
10456
|
|
|
9573
10457
|
results.sort((a, b) =>
|
|
@@ -10390,6 +11274,38 @@ function scanImageData(imageData, options = {}) {
|
|
|
10390
11274
|
};
|
|
10391
11275
|
|
|
10392
11276
|
if (options.perspective !== false) {
|
|
11277
|
+
const geometryHints = Array.isArray(options._geometryHints)
|
|
11278
|
+
? options._geometryHints.filter((item) => item?.homography && Number.isInteger(item.version)).slice(0, 2)
|
|
11279
|
+
: [];
|
|
11280
|
+
if (geometryHints.length) {
|
|
11281
|
+
try {
|
|
11282
|
+
const hinted = tryPerspectiveScan(imageData, {
|
|
11283
|
+
...scanOptions,
|
|
11284
|
+
_diagnosticLabel: options._diagnosticLabel ? `${options._diagnosticLabel}-geometry-reuse` : "geometry-reuse",
|
|
11285
|
+
_geometryCandidatesOverride: geometryHints,
|
|
11286
|
+
_geometryCollector: [],
|
|
11287
|
+
_observationCollector: [],
|
|
11288
|
+
adaptiveSampling: options._geometryReuseAdaptiveSampling ?? false,
|
|
11289
|
+
geometryRefinement: false
|
|
11290
|
+
});
|
|
11291
|
+
if (hinted) {
|
|
11292
|
+
geometryCollector.push(hinted.geometry ?? geometryHints[0]);
|
|
11293
|
+
return decorateScanResult({
|
|
11294
|
+
...hinted,
|
|
11295
|
+
geometryReused: true,
|
|
11296
|
+
recoveryMode: hinted.recoveryMode ?? "geometry-reuse"
|
|
11297
|
+
}, debugContext, options);
|
|
11298
|
+
}
|
|
11299
|
+
} catch {
|
|
11300
|
+
// A stale geometry hint is expected when the camera/code moves. The
|
|
11301
|
+
// camera worker can request a hint-only attempt when it is reusing
|
|
11302
|
+
// low-resolution locator geometry on a higher-detail frame.
|
|
11303
|
+
}
|
|
11304
|
+
if (options._geometryHintOnly === true) {
|
|
11305
|
+
throw new Error("Geometry hint did not decode this frame.");
|
|
11306
|
+
}
|
|
11307
|
+
}
|
|
11308
|
+
|
|
10393
11309
|
const perspective = tryPerspectiveScan(imageData, scanOptions);
|
|
10394
11310
|
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
10395
11311
|
}
|
|
@@ -10715,6 +11631,21 @@ function scanVideoFrame(video, options = {}) {
|
|
|
10715
11631
|
_visionDiagnostics: visionDiagnostics
|
|
10716
11632
|
});
|
|
10717
11633
|
normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
|
|
11634
|
+
if (options._frameDiagnostics && result?.geometry && !options._frameDiagnostics.geometry) {
|
|
11635
|
+
const geometry = result.geometry;
|
|
11636
|
+
options._frameDiagnostics.geometry = geometry;
|
|
11637
|
+
options._frameDiagnostics.geometryReused = Boolean(result.geometryReused);
|
|
11638
|
+
if (geometry.finders) {
|
|
11639
|
+
const reusedFinders = [
|
|
11640
|
+
geometry.finders.topLeft,
|
|
11641
|
+
geometry.finders.topRight,
|
|
11642
|
+
geometry.finders.bottomLeft
|
|
11643
|
+
].filter(Boolean);
|
|
11644
|
+
options._frameDiagnostics.finders = reusedFinders;
|
|
11645
|
+
options._frameDiagnostics.finderCount = reusedFinders.length;
|
|
11646
|
+
if (result.geometryReused) options._frameDiagnostics.finderMethod = "geometry-reuse";
|
|
11647
|
+
}
|
|
11648
|
+
}
|
|
10718
11649
|
if (!source.cropped) return result;
|
|
10719
11650
|
return {
|
|
10720
11651
|
...result,
|
|
@@ -10867,7 +11798,7 @@ async function improveCameraTrack(stream) {
|
|
|
10867
11798
|
}
|
|
10868
11799
|
}
|
|
10869
11800
|
|
|
10870
|
-
async function
|
|
11801
|
+
async function startCameraScannerMainThread(video, options = {}) {
|
|
10871
11802
|
assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
|
|
10872
11803
|
assert(video, "A video element is required.");
|
|
10873
11804
|
|
|
@@ -10876,8 +11807,8 @@ async function startCameraScanner(video, options = {}) {
|
|
|
10876
11807
|
audio: false,
|
|
10877
11808
|
video: {
|
|
10878
11809
|
facingMode: { ideal: "environment" },
|
|
10879
|
-
width: { ideal:
|
|
10880
|
-
height: { ideal:
|
|
11810
|
+
width: { ideal: 1280 },
|
|
11811
|
+
height: { ideal: 720 },
|
|
10881
11812
|
frameRate: { ideal: 30 }
|
|
10882
11813
|
}
|
|
10883
11814
|
}
|
|
@@ -10889,7 +11820,11 @@ async function startCameraScanner(video, options = {}) {
|
|
|
10889
11820
|
video.muted = true;
|
|
10890
11821
|
await video.play();
|
|
10891
11822
|
|
|
10892
|
-
|
|
11823
|
+
// Keep scanning responsive without queueing stale camera frames. The old
|
|
11824
|
+
// 180 ms post-scan timeout made effective cadence equal scan time + 180 ms.
|
|
11825
|
+
// The scheduler below measures from scan start and uses real video frames
|
|
11826
|
+
// when requestVideoFrameCallback() is available.
|
|
11827
|
+
const scanInterval = Math.max(24, Number(options.scanInterval ?? 80));
|
|
10893
11828
|
const scratchCanvas = document.createElement("canvas");
|
|
10894
11829
|
const highResolutionCanvas = document.createElement("canvas");
|
|
10895
11830
|
const multiFrameEnabled = options.multiFrame !== false;
|
|
@@ -10900,16 +11835,23 @@ async function startCameraScanner(video, options = {}) {
|
|
|
10900
11835
|
const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
|
|
10901
11836
|
const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
|
|
10902
11837
|
const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
|
|
10903
|
-
const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ??
|
|
11838
|
+
const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 640));
|
|
10904
11839
|
const cameraHighResolutionMaxDimension = Math.max(
|
|
10905
11840
|
baseCameraMaxDimension,
|
|
10906
|
-
Math.round(options.cameraHighResolutionMaxDimension ??
|
|
11841
|
+
Math.round(options.cameraHighResolutionMaxDimension ?? 960)
|
|
10907
11842
|
);
|
|
10908
11843
|
let missStreak = 0;
|
|
10909
11844
|
let stopped = false;
|
|
10910
11845
|
let busy = false;
|
|
10911
11846
|
let timer = null;
|
|
11847
|
+
let frameCallbackId = null;
|
|
11848
|
+
let lastScanStartedAt = -Infinity;
|
|
10912
11849
|
let frameNumber = 0;
|
|
11850
|
+
let cameraGeometryHint = null;
|
|
11851
|
+
let cameraGeometryHintMisses = 0;
|
|
11852
|
+
const cameraGeometryReuseMaxMisses = Math.max(1, Math.round(options.cameraGeometryReuseMaxMisses ?? 5));
|
|
11853
|
+
const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
|
|
11854
|
+
typeof video.requestVideoFrameCallback === "function";
|
|
10913
11855
|
|
|
10914
11856
|
const diagnosticsEnabled = typeof options.onDiagnostic === "function";
|
|
10915
11857
|
const emitDiagnostic = (event) => {
|
|
@@ -10945,6 +11887,12 @@ async function startCameraScanner(video, options = {}) {
|
|
|
10945
11887
|
const stop = () => {
|
|
10946
11888
|
stopped = true;
|
|
10947
11889
|
if (timer) clearTimeout(timer);
|
|
11890
|
+
if (frameCallbackId != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
11891
|
+
try { video.cancelVideoFrameCallback(frameCallbackId); } catch {}
|
|
11892
|
+
}
|
|
11893
|
+
timer = null;
|
|
11894
|
+
frameCallbackId = null;
|
|
11895
|
+
cameraGeometryHint = null;
|
|
10948
11896
|
observationHistory.clear();
|
|
10949
11897
|
for (const track of stream.getTracks()) track.stop();
|
|
10950
11898
|
if (video.srcObject === stream) video.srcObject = null;
|
|
@@ -10953,9 +11901,30 @@ async function startCameraScanner(video, options = {}) {
|
|
|
10953
11901
|
const scanNow = () => scanVideoFrame(video, {
|
|
10954
11902
|
...options,
|
|
10955
11903
|
maxDimension: baseCameraMaxDimension,
|
|
10956
|
-
canvas: scratchCanvas
|
|
11904
|
+
canvas: scratchCanvas,
|
|
11905
|
+
_geometryHints: options.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint]
|
|
10957
11906
|
});
|
|
10958
11907
|
|
|
11908
|
+
const updateCameraGeometryHint = (frameDiagnostics, result = null) => {
|
|
11909
|
+
if (options.cameraGeometryReuse === false) {
|
|
11910
|
+
cameraGeometryHint = null;
|
|
11911
|
+
return;
|
|
11912
|
+
}
|
|
11913
|
+
const geometry = result?.geometry ?? frameDiagnostics?.geometry ?? null;
|
|
11914
|
+
if (geometry?.homography && Number.isInteger(geometry.version)) {
|
|
11915
|
+
cameraGeometryHint = geometry;
|
|
11916
|
+
cameraGeometryHintMisses = 0;
|
|
11917
|
+
return;
|
|
11918
|
+
}
|
|
11919
|
+
if (cameraGeometryHint) {
|
|
11920
|
+
cameraGeometryHintMisses++;
|
|
11921
|
+
if (cameraGeometryHintMisses >= cameraGeometryReuseMaxMisses) {
|
|
11922
|
+
cameraGeometryHint = null;
|
|
11923
|
+
cameraGeometryHintMisses = 0;
|
|
11924
|
+
}
|
|
11925
|
+
}
|
|
11926
|
+
};
|
|
11927
|
+
|
|
10959
11928
|
const emitResult = (result, capturedFrame = null, diagnostic = null) => {
|
|
10960
11929
|
const frameMeta = capturedFrame?.imageData ? {
|
|
10961
11930
|
frame: frameNumber,
|
|
@@ -11033,6 +12002,33 @@ async function startCameraScanner(video, options = {}) {
|
|
|
11033
12002
|
}
|
|
11034
12003
|
};
|
|
11035
12004
|
|
|
12005
|
+
const scheduleNextScan = () => {
|
|
12006
|
+
if (stopped) return;
|
|
12007
|
+
const runWhenDue = () => {
|
|
12008
|
+
frameCallbackId = null;
|
|
12009
|
+
if (stopped) return;
|
|
12010
|
+
const remaining = scanInterval - (nowMs() - lastScanStartedAt);
|
|
12011
|
+
if (remaining > 1) {
|
|
12012
|
+
timer = setTimeout(() => {
|
|
12013
|
+
timer = null;
|
|
12014
|
+
scheduleNextScan();
|
|
12015
|
+
}, remaining);
|
|
12016
|
+
return;
|
|
12017
|
+
}
|
|
12018
|
+
void loop();
|
|
12019
|
+
};
|
|
12020
|
+
|
|
12021
|
+
if (useVideoFrameCallback) {
|
|
12022
|
+
frameCallbackId = video.requestVideoFrameCallback(runWhenDue);
|
|
12023
|
+
} else {
|
|
12024
|
+
const remaining = Math.max(0, scanInterval - (nowMs() - lastScanStartedAt));
|
|
12025
|
+
timer = setTimeout(() => {
|
|
12026
|
+
timer = null;
|
|
12027
|
+
runWhenDue();
|
|
12028
|
+
}, remaining);
|
|
12029
|
+
}
|
|
12030
|
+
};
|
|
12031
|
+
|
|
11036
12032
|
const loop = async () => {
|
|
11037
12033
|
if (stopped) return;
|
|
11038
12034
|
if (!busy && video.readyState >= 2) {
|
|
@@ -11042,6 +12038,7 @@ async function startCameraScanner(video, options = {}) {
|
|
|
11042
12038
|
const frameDiagnostics = {};
|
|
11043
12039
|
const capturedFrame = {};
|
|
11044
12040
|
const frameStarted = nowMs();
|
|
12041
|
+
lastScanStartedAt = frameStarted;
|
|
11045
12042
|
let allowAutoEnhance = false;
|
|
11046
12043
|
let allowFinderRecovery = false;
|
|
11047
12044
|
try {
|
|
@@ -11069,11 +12066,13 @@ async function startCameraScanner(video, options = {}) {
|
|
|
11069
12066
|
fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
|
|
11070
12067
|
maxDimension: baseCameraMaxDimension,
|
|
11071
12068
|
canvas: scratchCanvas,
|
|
12069
|
+
_geometryHints: options.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint],
|
|
11072
12070
|
_capturedFrame: capturedFrame,
|
|
11073
12071
|
_observationCollector: observations,
|
|
11074
12072
|
_frameDiagnostics: frameDiagnostics
|
|
11075
12073
|
});
|
|
11076
12074
|
const elapsedMs = nowMs() - frameStarted;
|
|
12075
|
+
updateCameraGeometryHint(frameDiagnostics, result);
|
|
11077
12076
|
emitDiagnostic({
|
|
11078
12077
|
type: "frame",
|
|
11079
12078
|
state: "decoded",
|
|
@@ -11095,6 +12094,7 @@ async function startCameraScanner(video, options = {}) {
|
|
|
11095
12094
|
if (emitResult(result, capturedFrame, frameDiagnostics)) return;
|
|
11096
12095
|
} catch (error) {
|
|
11097
12096
|
missStreak++;
|
|
12097
|
+
updateCameraGeometryHint(frameDiagnostics);
|
|
11098
12098
|
const fastElapsedMs = nowMs() - frameStarted;
|
|
11099
12099
|
emitDiagnostic({
|
|
11100
12100
|
type: "frame",
|
|
@@ -11110,7 +12110,7 @@ async function startCameraScanner(video, options = {}) {
|
|
|
11110
12110
|
|
|
11111
12111
|
// Geometry-aware high-resolution retry. A dense QuadQR can look large
|
|
11112
12112
|
// enough in the preview while each individual module has become too
|
|
11113
|
-
// small after the normal
|
|
12113
|
+
// small after the normal 640 px scanner cap. If the fast pass already
|
|
11114
12114
|
// sees at least two convincing finders, spend one bounded retry on a
|
|
11115
12115
|
// higher-resolution copy of the visible camera ROI. Empty frames and
|
|
11116
12116
|
// ordinary small codes never pay this cost.
|
|
@@ -11416,13 +12416,570 @@ async function startCameraScanner(video, options = {}) {
|
|
|
11416
12416
|
busy = false;
|
|
11417
12417
|
}
|
|
11418
12418
|
}
|
|
11419
|
-
|
|
12419
|
+
scheduleNextScan();
|
|
11420
12420
|
};
|
|
11421
12421
|
|
|
11422
|
-
|
|
12422
|
+
scheduleNextScan();
|
|
11423
12423
|
return { stream, stop, scanNow, video };
|
|
11424
12424
|
}
|
|
11425
12425
|
|
|
12426
|
+
|
|
12427
|
+
function cameraWorkerSupported(options = {}) {
|
|
12428
|
+
if (options.cameraWorker === false) return false;
|
|
12429
|
+
return typeof Worker === "function" &&
|
|
12430
|
+
typeof createImageBitmap === "function" &&
|
|
12431
|
+
typeof OffscreenCanvas === "function";
|
|
12432
|
+
}
|
|
12433
|
+
|
|
12434
|
+
function serializableCameraWorkerOptions(options = {}) {
|
|
12435
|
+
const skip = new Set([
|
|
12436
|
+
"canvas",
|
|
12437
|
+
"constraints",
|
|
12438
|
+
"onDecode",
|
|
12439
|
+
"onDiagnostic",
|
|
12440
|
+
"onResult",
|
|
12441
|
+
"onScanMiss",
|
|
12442
|
+
"cameraWorkerUrl"
|
|
12443
|
+
]);
|
|
12444
|
+
const out = {};
|
|
12445
|
+
for (const [key, value] of Object.entries(options)) {
|
|
12446
|
+
if (skip.has(key) || typeof value === "function" || value == null) continue;
|
|
12447
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
12448
|
+
out[key] = value;
|
|
12449
|
+
continue;
|
|
12450
|
+
}
|
|
12451
|
+
if (Array.isArray(value)) {
|
|
12452
|
+
out[key] = value.map((item) => {
|
|
12453
|
+
if (item && typeof item === "object") return { ...item };
|
|
12454
|
+
return item;
|
|
12455
|
+
});
|
|
12456
|
+
continue;
|
|
12457
|
+
}
|
|
12458
|
+
if (Object.getPrototypeOf(value) === Object.prototype) out[key] = { ...value };
|
|
12459
|
+
}
|
|
12460
|
+
return out;
|
|
12461
|
+
}
|
|
12462
|
+
|
|
12463
|
+
function resolveCameraWorkerUrl(options = {}) {
|
|
12464
|
+
if (options.cameraWorkerUrl) {
|
|
12465
|
+
return new URL(
|
|
12466
|
+
options.cameraWorkerUrl,
|
|
12467
|
+
typeof document !== "undefined" ? document.baseURI : (__qqrScriptSrc || (typeof location !== 'undefined' ? location.href : ''))
|
|
12468
|
+
);
|
|
12469
|
+
}
|
|
12470
|
+
return new URL("./camera-scanner-worker.js", (__qqrScriptSrc || (typeof location !== 'undefined' ? location.href : '')));
|
|
12471
|
+
}
|
|
12472
|
+
|
|
12473
|
+
async function initializeCameraWorker(options = {}) {
|
|
12474
|
+
const worker = new Worker(resolveCameraWorkerUrl(options), {
|
|
12475
|
+
type: "module",
|
|
12476
|
+
name: "quadqr-camera-scanner"
|
|
12477
|
+
});
|
|
12478
|
+
let sequence = 0;
|
|
12479
|
+
const pending = new Map();
|
|
12480
|
+
let fatalError = null;
|
|
12481
|
+
|
|
12482
|
+
const rejectPending = (error) => {
|
|
12483
|
+
fatalError = error instanceof Error ? error : new Error(String(error));
|
|
12484
|
+
for (const { reject } of pending.values()) reject(fatalError);
|
|
12485
|
+
pending.clear();
|
|
12486
|
+
};
|
|
12487
|
+
|
|
12488
|
+
worker.addEventListener("message", (event) => {
|
|
12489
|
+
const message = event.data ?? {};
|
|
12490
|
+
const entry = pending.get(message.id);
|
|
12491
|
+
if (!entry) return;
|
|
12492
|
+
pending.delete(message.id);
|
|
12493
|
+
if (message.ok) entry.resolve(message.result);
|
|
12494
|
+
else {
|
|
12495
|
+
const error = new Error(message.error?.message ?? "QuadQR camera worker failed.");
|
|
12496
|
+
error.name = message.error?.name ?? "Error";
|
|
12497
|
+
error.stack = message.error?.stack ?? error.stack;
|
|
12498
|
+
error.debug = message.error?.debug ?? null;
|
|
12499
|
+
entry.reject(error);
|
|
12500
|
+
}
|
|
12501
|
+
});
|
|
12502
|
+
worker.addEventListener("error", (event) => {
|
|
12503
|
+
rejectPending(new Error(event?.message || "QuadQR camera worker crashed."));
|
|
12504
|
+
});
|
|
12505
|
+
worker.addEventListener("messageerror", () => {
|
|
12506
|
+
rejectPending(new Error("QuadQR camera worker returned an unreadable message."));
|
|
12507
|
+
});
|
|
12508
|
+
|
|
12509
|
+
const request = (type, payload = {}, transfer = []) => {
|
|
12510
|
+
if (fatalError) return Promise.reject(fatalError);
|
|
12511
|
+
const id = `qqr-camera-${++sequence}`;
|
|
12512
|
+
return new Promise((resolve, reject) => {
|
|
12513
|
+
pending.set(id, { resolve, reject });
|
|
12514
|
+
try {
|
|
12515
|
+
worker.postMessage({ id, type, ...payload }, transfer);
|
|
12516
|
+
} catch (error) {
|
|
12517
|
+
pending.delete(id);
|
|
12518
|
+
reject(error);
|
|
12519
|
+
}
|
|
12520
|
+
});
|
|
12521
|
+
};
|
|
12522
|
+
|
|
12523
|
+
try {
|
|
12524
|
+
const state = await request("init", { options: serializableCameraWorkerOptions(options) });
|
|
12525
|
+
if (!state?.offscreenCanvas) throw new Error("OffscreenCanvas is unavailable in the QuadQR camera worker.");
|
|
12526
|
+
return {
|
|
12527
|
+
worker,
|
|
12528
|
+
state,
|
|
12529
|
+
request,
|
|
12530
|
+
terminate() {
|
|
12531
|
+
rejectPending(new Error("QuadQR camera worker stopped."));
|
|
12532
|
+
worker.terminate();
|
|
12533
|
+
}
|
|
12534
|
+
};
|
|
12535
|
+
} catch (error) {
|
|
12536
|
+
worker.terminate();
|
|
12537
|
+
throw error;
|
|
12538
|
+
}
|
|
12539
|
+
}
|
|
12540
|
+
|
|
12541
|
+
async function captureCameraBitmap(video, source, maxDimension) {
|
|
12542
|
+
const cap = Math.max(1, Math.round(maxDimension));
|
|
12543
|
+
const videoWidth = Math.max(1, Math.round(video.videoWidth || source.width || 1));
|
|
12544
|
+
const videoHeight = Math.max(1, Math.round(video.videoHeight || source.height || 1));
|
|
12545
|
+
const sx = clampNumber(Math.round(source.x || 0), 0, Math.max(0, videoWidth - 1));
|
|
12546
|
+
const sy = clampNumber(Math.round(source.y || 0), 0, Math.max(0, videoHeight - 1));
|
|
12547
|
+
const sw = Math.max(1, Math.min(videoWidth - sx, Math.round(source.width || videoWidth)));
|
|
12548
|
+
const sh = Math.max(1, Math.min(videoHeight - sy, Math.round(source.height || videoHeight)));
|
|
12549
|
+
const scale = Math.min(1, cap / Math.max(sw, sh));
|
|
12550
|
+
const width = Math.max(1, Math.round(sw * scale));
|
|
12551
|
+
const height = Math.max(1, Math.round(sh * scale));
|
|
12552
|
+
|
|
12553
|
+
// Crop and resize before crossing the worker boundary. Previously every
|
|
12554
|
+
// 640px scan transferred a full 1080p camera bitmap and only then shrank it
|
|
12555
|
+
// inside OffscreenCanvas. On phones that creates unnecessary GPU/memory
|
|
12556
|
+
// pressure and can delay fresh-frame acquisition even though decoding is
|
|
12557
|
+
// off-thread. The fallback preserves compatibility with browsers that do not
|
|
12558
|
+
// implement the resize overload for video-backed ImageBitmap creation.
|
|
12559
|
+
try {
|
|
12560
|
+
const bitmap = await createImageBitmap(video, sx, sy, sw, sh, {
|
|
12561
|
+
resizeWidth: width,
|
|
12562
|
+
resizeHeight: height,
|
|
12563
|
+
resizeQuality: "medium"
|
|
12564
|
+
});
|
|
12565
|
+
return {
|
|
12566
|
+
bitmap,
|
|
12567
|
+
source: { x: 0, y: 0, width: bitmap.width, height: bitmap.height, cropped: Boolean(source.cropped) },
|
|
12568
|
+
originalSource: { ...source },
|
|
12569
|
+
preScaled: true
|
|
12570
|
+
};
|
|
12571
|
+
} catch {
|
|
12572
|
+
const bitmap = await createImageBitmap(video);
|
|
12573
|
+
return {
|
|
12574
|
+
bitmap,
|
|
12575
|
+
source: { ...source },
|
|
12576
|
+
originalSource: { ...source },
|
|
12577
|
+
preScaled: false
|
|
12578
|
+
};
|
|
12579
|
+
}
|
|
12580
|
+
}
|
|
12581
|
+
|
|
12582
|
+
function maximumFinderCount(workerResult) {
|
|
12583
|
+
let count = 0;
|
|
12584
|
+
for (const diagnostic of workerResult?.diagnostics ?? []) {
|
|
12585
|
+
count = Math.max(
|
|
12586
|
+
count,
|
|
12587
|
+
Number(diagnostic?.finderCount) || 0,
|
|
12588
|
+
Number(diagnostic?.bestPass?.finderCount) || 0
|
|
12589
|
+
);
|
|
12590
|
+
for (const pass of diagnostic?.finderPasses ?? []) {
|
|
12591
|
+
count = Math.max(count, Number(pass?.finderCount) || 0);
|
|
12592
|
+
}
|
|
12593
|
+
}
|
|
12594
|
+
return count;
|
|
12595
|
+
}
|
|
12596
|
+
|
|
12597
|
+
async function startCameraScannerWorker(video, options = {}) {
|
|
12598
|
+
assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
|
|
12599
|
+
assert(video, "A video element is required.");
|
|
12600
|
+
|
|
12601
|
+
// Keep fresh-frame scanning independent from the expensive damaged/color
|
|
12602
|
+
// recovery pipeline. Finder detection itself remains JavaScript; WASM is
|
|
12603
|
+
// only an optional grayscale/binary + CRC accelerator beneath both workers.
|
|
12604
|
+
// The fast worker can therefore inspect the newest frame even while the
|
|
12605
|
+
// recovery worker is spending hundreds of milliseconds on an older difficult
|
|
12606
|
+
// frame. No recovery method is removed.
|
|
12607
|
+
const fastWorkerOptions = {
|
|
12608
|
+
...options,
|
|
12609
|
+
cameraPipelineMode: "fast",
|
|
12610
|
+
cameraHighResolutionRecovery: false,
|
|
12611
|
+
cameraAutoColorRecovery: false,
|
|
12612
|
+
autoEnhanceRecovery: false,
|
|
12613
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
12614
|
+
multiFrame: false
|
|
12615
|
+
};
|
|
12616
|
+
const fastWorkerClient = await initializeCameraWorker(fastWorkerOptions);
|
|
12617
|
+
|
|
12618
|
+
let stream = null;
|
|
12619
|
+
try {
|
|
12620
|
+
stream = await navigator.mediaDevices.getUserMedia(
|
|
12621
|
+
options.constraints ?? {
|
|
12622
|
+
audio: false,
|
|
12623
|
+
video: {
|
|
12624
|
+
facingMode: { ideal: "environment" },
|
|
12625
|
+
width: { ideal: 1280 },
|
|
12626
|
+
height: { ideal: 720 },
|
|
12627
|
+
frameRate: { ideal: 30 }
|
|
12628
|
+
}
|
|
12629
|
+
}
|
|
12630
|
+
);
|
|
12631
|
+
await improveCameraTrack(stream);
|
|
12632
|
+
|
|
12633
|
+
video.srcObject = stream;
|
|
12634
|
+
video.setAttribute("playsinline", "");
|
|
12635
|
+
video.muted = true;
|
|
12636
|
+
await video.play();
|
|
12637
|
+
} catch (error) {
|
|
12638
|
+
fastWorkerClient.terminate();
|
|
12639
|
+
for (const track of stream?.getTracks?.() ?? []) track.stop();
|
|
12640
|
+
if (video.srcObject === stream) video.srcObject = null;
|
|
12641
|
+
throw error;
|
|
12642
|
+
}
|
|
12643
|
+
|
|
12644
|
+
const scanInterval = Math.max(24, Number(options.scanInterval ?? 33));
|
|
12645
|
+
const fastCaptureMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 640));
|
|
12646
|
+
const recoveryCaptureMaxDimension = Math.max(
|
|
12647
|
+
fastCaptureMaxDimension,
|
|
12648
|
+
Math.round(options.cameraHighResolutionMaxDimension ?? 960)
|
|
12649
|
+
);
|
|
12650
|
+
const recoveryStrongFinderInterval = Math.max(80, Number(options.cameraRecoveryStrongFinderInterval ?? 120));
|
|
12651
|
+
const recoveryWeakFinderInterval = Math.max(recoveryStrongFinderInterval, Number(options.cameraRecoveryWeakFinderInterval ?? 260));
|
|
12652
|
+
const recoveryNoFinderInterval = Math.max(recoveryWeakFinderInterval, Number(options.cameraRecoveryNoFinderInterval ?? 850));
|
|
12653
|
+
const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
|
|
12654
|
+
typeof video.requestVideoFrameCallback === "function";
|
|
12655
|
+
|
|
12656
|
+
let stopped = false;
|
|
12657
|
+
let busy = false;
|
|
12658
|
+
let recoveryBusy = false;
|
|
12659
|
+
let recoveryWorkerClient = null;
|
|
12660
|
+
let recoveryWorkerPromise = null;
|
|
12661
|
+
let recoveryWorkerFailed = false;
|
|
12662
|
+
let timer = null;
|
|
12663
|
+
let frameCallbackId = null;
|
|
12664
|
+
let lastScanStartedAt = -Infinity;
|
|
12665
|
+
let lastRecoveryStartedAt = -Infinity;
|
|
12666
|
+
let frameNumber = 0;
|
|
12667
|
+
let requestToken = 0;
|
|
12668
|
+
let recoveryToken = 0;
|
|
12669
|
+
|
|
12670
|
+
const diagnosticsEnabled = typeof options.onDiagnostic === "function";
|
|
12671
|
+
const emitDiagnostic = (event) => {
|
|
12672
|
+
if (!diagnosticsEnabled) return;
|
|
12673
|
+
try {
|
|
12674
|
+
options.onDiagnostic({
|
|
12675
|
+
timestamp: Date.now(),
|
|
12676
|
+
frame: event?.frame ?? frameNumber,
|
|
12677
|
+
cameraWorker: true,
|
|
12678
|
+
...event
|
|
12679
|
+
});
|
|
12680
|
+
} catch {
|
|
12681
|
+
// Diagnostics are UI-only and must never interrupt scanning.
|
|
12682
|
+
}
|
|
12683
|
+
};
|
|
12684
|
+
|
|
12685
|
+
const ensureRecoveryWorker = async () => {
|
|
12686
|
+
if (recoveryWorkerClient) return recoveryWorkerClient;
|
|
12687
|
+
if (recoveryWorkerFailed) return null;
|
|
12688
|
+
if (!recoveryWorkerPromise) {
|
|
12689
|
+
recoveryWorkerPromise = initializeCameraWorker({ ...options, cameraPipelineMode: "full" })
|
|
12690
|
+
.then((client) => {
|
|
12691
|
+
if (stopped) {
|
|
12692
|
+
client.terminate();
|
|
12693
|
+
return null;
|
|
12694
|
+
}
|
|
12695
|
+
recoveryWorkerClient = client;
|
|
12696
|
+
return client;
|
|
12697
|
+
})
|
|
12698
|
+
.catch((error) => {
|
|
12699
|
+
recoveryWorkerFailed = true;
|
|
12700
|
+
emitDiagnostic({
|
|
12701
|
+
type: "recovery-worker-error",
|
|
12702
|
+
state: "fallback",
|
|
12703
|
+
method: "camera-fast-worker",
|
|
12704
|
+
message: `Parallel recovery worker unavailable · fast scanner remains active (${error?.message ?? String(error)})`
|
|
12705
|
+
});
|
|
12706
|
+
return null;
|
|
12707
|
+
})
|
|
12708
|
+
.finally(() => {
|
|
12709
|
+
recoveryWorkerPromise = null;
|
|
12710
|
+
});
|
|
12711
|
+
}
|
|
12712
|
+
return recoveryWorkerPromise;
|
|
12713
|
+
};
|
|
12714
|
+
|
|
12715
|
+
const track = stream.getVideoTracks?.()[0];
|
|
12716
|
+
const settings = track?.getSettings?.() ?? {};
|
|
12717
|
+
emitDiagnostic({
|
|
12718
|
+
type: "camera-ready",
|
|
12719
|
+
method: "camera-dual-worker",
|
|
12720
|
+
message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + parallel recovery`,
|
|
12721
|
+
camera: {
|
|
12722
|
+
width: settings.width ?? video.videoWidth,
|
|
12723
|
+
height: settings.height ?? video.videoHeight,
|
|
12724
|
+
frameRate: settings.frameRate ?? null,
|
|
12725
|
+
facingMode: settings.facingMode ?? null
|
|
12726
|
+
},
|
|
12727
|
+
worker: fastWorkerClient.state,
|
|
12728
|
+
scanMaxDimension: fastCaptureMaxDimension,
|
|
12729
|
+
recoveryMaxDimension: recoveryCaptureMaxDimension
|
|
12730
|
+
});
|
|
12731
|
+
|
|
12732
|
+
const stop = () => {
|
|
12733
|
+
if (stopped) return;
|
|
12734
|
+
stopped = true;
|
|
12735
|
+
requestToken++;
|
|
12736
|
+
recoveryToken++;
|
|
12737
|
+
if (timer) clearTimeout(timer);
|
|
12738
|
+
if (frameCallbackId != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
12739
|
+
try { video.cancelVideoFrameCallback(frameCallbackId); } catch {}
|
|
12740
|
+
}
|
|
12741
|
+
timer = null;
|
|
12742
|
+
frameCallbackId = null;
|
|
12743
|
+
fastWorkerClient.terminate();
|
|
12744
|
+
if (recoveryWorkerClient) {
|
|
12745
|
+
recoveryWorkerClient.terminate();
|
|
12746
|
+
recoveryWorkerClient = null;
|
|
12747
|
+
} else if (recoveryWorkerPromise) {
|
|
12748
|
+
void recoveryWorkerPromise.then((client) => client?.terminate()).catch(() => {});
|
|
12749
|
+
}
|
|
12750
|
+
for (const cameraTrack of stream.getTracks()) cameraTrack.stop();
|
|
12751
|
+
if (video.srcObject === stream) video.srcObject = null;
|
|
12752
|
+
};
|
|
12753
|
+
|
|
12754
|
+
// Preserve the historical immediate/manual helper as a synchronous scan of
|
|
12755
|
+
// the current video element. The continuous scanner itself stays off-thread.
|
|
12756
|
+
const scanNow = () => scanVideoFrame(video, {
|
|
12757
|
+
...options,
|
|
12758
|
+
maxDimension: fastCaptureMaxDimension
|
|
12759
|
+
});
|
|
12760
|
+
|
|
12761
|
+
const emitResult = (result, frameMeta = null) => {
|
|
12762
|
+
if (stopped) return true;
|
|
12763
|
+
const normalizedMeta = frameMeta
|
|
12764
|
+
? { frame: frameMeta.frame ?? frameNumber, ...frameMeta }
|
|
12765
|
+
: null;
|
|
12766
|
+
options.onResult?.(result, normalizedMeta);
|
|
12767
|
+
options.onDecode?.(result, normalizedMeta);
|
|
12768
|
+
if (options.stopOnResult ?? true) {
|
|
12769
|
+
stop();
|
|
12770
|
+
return true;
|
|
12771
|
+
}
|
|
12772
|
+
return false;
|
|
12773
|
+
};
|
|
12774
|
+
|
|
12775
|
+
const runRecovery = async (triggerFrame, finderCount) => {
|
|
12776
|
+
if (stopped || recoveryBusy) return;
|
|
12777
|
+
recoveryBusy = true;
|
|
12778
|
+
lastRecoveryStartedAt = nowMs();
|
|
12779
|
+
const token = ++recoveryToken;
|
|
12780
|
+
let bitmap = null;
|
|
12781
|
+
try {
|
|
12782
|
+
const client = await ensureRecoveryWorker();
|
|
12783
|
+
if (stopped || token !== recoveryToken) return;
|
|
12784
|
+
const recoveryClient = client ?? fastWorkerClient;
|
|
12785
|
+
const singleWorkerFallback = !client;
|
|
12786
|
+
|
|
12787
|
+
// Capture only after the recovery execution path is ready. This guarantees
|
|
12788
|
+
// the expensive path receives a fresh frame rather than a bitmap that sat
|
|
12789
|
+
// in memory while a recovery worker was starting.
|
|
12790
|
+
const visibleSource = visibleVideoSourceRect(video, options);
|
|
12791
|
+
const captured = await captureCameraBitmap(video, visibleSource, recoveryCaptureMaxDimension);
|
|
12792
|
+
bitmap = captured.bitmap;
|
|
12793
|
+
if (stopped || token !== recoveryToken) {
|
|
12794
|
+
bitmap.close?.();
|
|
12795
|
+
return;
|
|
12796
|
+
}
|
|
12797
|
+
|
|
12798
|
+
emitDiagnostic({
|
|
12799
|
+
type: "recovery-dispatch",
|
|
12800
|
+
state: "trying",
|
|
12801
|
+
method: "parallel-full-recovery",
|
|
12802
|
+
frame: triggerFrame,
|
|
12803
|
+
finderCount,
|
|
12804
|
+
message: finderCount > 0
|
|
12805
|
+
? `Fast frame saw ${finderCount} finder${finderCount === 1 ? "" : "s"} · full recovery running in parallel`
|
|
12806
|
+
: "Periodic full recovery running in parallel while fast fresh-frame scanning continues"
|
|
12807
|
+
});
|
|
12808
|
+
|
|
12809
|
+
const recoveryPayload = { bitmap, source: captured.source, frame: triggerFrame };
|
|
12810
|
+
if (singleWorkerFallback) recoveryPayload.options = serializableCameraWorkerOptions(options);
|
|
12811
|
+
const workerResult = await recoveryClient.request(
|
|
12812
|
+
singleWorkerFallback ? "scan-full" : "scan",
|
|
12813
|
+
recoveryPayload,
|
|
12814
|
+
[bitmap]
|
|
12815
|
+
);
|
|
12816
|
+
bitmap = null;
|
|
12817
|
+
if (stopped || token !== recoveryToken) return;
|
|
12818
|
+
|
|
12819
|
+
for (const diagnostic of workerResult?.diagnostics ?? []) {
|
|
12820
|
+
emitDiagnostic({
|
|
12821
|
+
...diagnostic,
|
|
12822
|
+
recoveryWorker: !singleWorkerFallback,
|
|
12823
|
+
singleWorkerRecoveryFallback: singleWorkerFallback,
|
|
12824
|
+
frame: triggerFrame
|
|
12825
|
+
});
|
|
12826
|
+
}
|
|
12827
|
+
if (workerResult?.ok) {
|
|
12828
|
+
emitResult(workerResult.result, { ...workerResult.frameMeta, frame: triggerFrame });
|
|
12829
|
+
}
|
|
12830
|
+
} catch (error) {
|
|
12831
|
+
try { bitmap?.close?.(); } catch {}
|
|
12832
|
+
if (!stopped) {
|
|
12833
|
+
emitDiagnostic({
|
|
12834
|
+
type: "recovery-worker-error",
|
|
12835
|
+
state: "error",
|
|
12836
|
+
method: "parallel-full-recovery",
|
|
12837
|
+
frame: triggerFrame,
|
|
12838
|
+
message: error?.message ?? String(error)
|
|
12839
|
+
});
|
|
12840
|
+
}
|
|
12841
|
+
} finally {
|
|
12842
|
+
recoveryBusy = false;
|
|
12843
|
+
}
|
|
12844
|
+
};
|
|
12845
|
+
|
|
12846
|
+
const maybeDispatchRecovery = (workerResult, triggerFrame) => {
|
|
12847
|
+
if (stopped || recoveryBusy) return;
|
|
12848
|
+
const finderCount = maximumFinderCount(workerResult);
|
|
12849
|
+
const elapsed = nowMs() - lastRecoveryStartedAt;
|
|
12850
|
+
const minimumInterval = finderCount >= 2
|
|
12851
|
+
? recoveryStrongFinderInterval
|
|
12852
|
+
: finderCount === 1
|
|
12853
|
+
? recoveryWeakFinderInterval
|
|
12854
|
+
: recoveryNoFinderInterval;
|
|
12855
|
+
if (elapsed >= minimumInterval) void runRecovery(triggerFrame, finderCount);
|
|
12856
|
+
};
|
|
12857
|
+
|
|
12858
|
+
const scheduleNextScan = () => {
|
|
12859
|
+
if (stopped) return;
|
|
12860
|
+
const runWhenDue = () => {
|
|
12861
|
+
frameCallbackId = null;
|
|
12862
|
+
if (stopped) return;
|
|
12863
|
+
const remaining = scanInterval - (nowMs() - lastScanStartedAt);
|
|
12864
|
+
if (remaining > 1) {
|
|
12865
|
+
timer = setTimeout(() => {
|
|
12866
|
+
timer = null;
|
|
12867
|
+
scheduleNextScan();
|
|
12868
|
+
}, remaining);
|
|
12869
|
+
return;
|
|
12870
|
+
}
|
|
12871
|
+
void loop();
|
|
12872
|
+
};
|
|
12873
|
+
|
|
12874
|
+
if (useVideoFrameCallback) {
|
|
12875
|
+
frameCallbackId = video.requestVideoFrameCallback(runWhenDue);
|
|
12876
|
+
} else {
|
|
12877
|
+
const remaining = Math.max(0, scanInterval - (nowMs() - lastScanStartedAt));
|
|
12878
|
+
timer = setTimeout(() => {
|
|
12879
|
+
timer = null;
|
|
12880
|
+
runWhenDue();
|
|
12881
|
+
}, remaining);
|
|
12882
|
+
}
|
|
12883
|
+
};
|
|
12884
|
+
|
|
12885
|
+
const loop = async () => {
|
|
12886
|
+
if (stopped) return;
|
|
12887
|
+
if (!busy && video.readyState >= 2) {
|
|
12888
|
+
busy = true;
|
|
12889
|
+
frameNumber++;
|
|
12890
|
+
const currentFrame = frameNumber;
|
|
12891
|
+
lastScanStartedAt = nowMs();
|
|
12892
|
+
const token = ++requestToken;
|
|
12893
|
+
let bitmap = null;
|
|
12894
|
+
try {
|
|
12895
|
+
const visibleSource = visibleVideoSourceRect(video, options);
|
|
12896
|
+
const captured = await captureCameraBitmap(video, visibleSource, fastCaptureMaxDimension);
|
|
12897
|
+
bitmap = captured.bitmap;
|
|
12898
|
+
if (stopped || token !== requestToken) {
|
|
12899
|
+
bitmap.close?.();
|
|
12900
|
+
return;
|
|
12901
|
+
}
|
|
12902
|
+
const workerResult = await fastWorkerClient.request(
|
|
12903
|
+
"scan",
|
|
12904
|
+
{ bitmap, source: captured.source, frame: currentFrame },
|
|
12905
|
+
[bitmap]
|
|
12906
|
+
);
|
|
12907
|
+
bitmap = null;
|
|
12908
|
+
if (stopped || token !== requestToken) return;
|
|
12909
|
+
|
|
12910
|
+
for (const diagnostic of workerResult?.diagnostics ?? []) {
|
|
12911
|
+
emitDiagnostic({ ...diagnostic, fastWorker: true, frame: currentFrame });
|
|
12912
|
+
}
|
|
12913
|
+
if (workerResult?.ok) {
|
|
12914
|
+
if (emitResult(workerResult.result, { ...workerResult.frameMeta, frame: currentFrame })) return;
|
|
12915
|
+
} else {
|
|
12916
|
+
// Full perspective/color/damage recovery runs independently. Do not
|
|
12917
|
+
// await it here: the next camera callback must remain free to inspect
|
|
12918
|
+
// a newer frame immediately.
|
|
12919
|
+
maybeDispatchRecovery(workerResult, currentFrame);
|
|
12920
|
+
const error = new Error(workerResult?.error?.message ?? "Unable to decode QuadQR frame.");
|
|
12921
|
+
error.name = workerResult?.error?.name ?? "Error";
|
|
12922
|
+
error.debug = workerResult?.error?.debug ?? null;
|
|
12923
|
+
options.onScanMiss?.(error);
|
|
12924
|
+
}
|
|
12925
|
+
} catch (error) {
|
|
12926
|
+
try { bitmap?.close?.(); } catch {}
|
|
12927
|
+
if (!stopped) {
|
|
12928
|
+
emitDiagnostic({
|
|
12929
|
+
type: "worker-error",
|
|
12930
|
+
state: "error",
|
|
12931
|
+
method: "camera-fast-worker",
|
|
12932
|
+
frame: currentFrame,
|
|
12933
|
+
message: error?.message ?? String(error)
|
|
12934
|
+
});
|
|
12935
|
+
options.onScanMiss?.(error);
|
|
12936
|
+
}
|
|
12937
|
+
} finally {
|
|
12938
|
+
busy = false;
|
|
12939
|
+
}
|
|
12940
|
+
}
|
|
12941
|
+
scheduleNextScan();
|
|
12942
|
+
};
|
|
12943
|
+
|
|
12944
|
+
scheduleNextScan();
|
|
12945
|
+
return {
|
|
12946
|
+
stream,
|
|
12947
|
+
stop,
|
|
12948
|
+
scanNow,
|
|
12949
|
+
video,
|
|
12950
|
+
worker: true,
|
|
12951
|
+
workerMode: "dual-pipeline",
|
|
12952
|
+
workerState: fastWorkerClient.state
|
|
12953
|
+
};
|
|
12954
|
+
}
|
|
12955
|
+
|
|
12956
|
+
/**
|
|
12957
|
+
* Start continuous camera scanning. Modern browsers use a dedicated module
|
|
12958
|
+
* worker by default so the complete recovery pipeline can remain enabled
|
|
12959
|
+
* without blocking rendering/input. Unsupported/CSP-restricted environments
|
|
12960
|
+
* automatically fall back to the original main-thread scanner.
|
|
12961
|
+
*/
|
|
12962
|
+
async function startCameraScanner(video, options = {}) {
|
|
12963
|
+
if (cameraWorkerSupported(options)) {
|
|
12964
|
+
try {
|
|
12965
|
+
return await startCameraScannerWorker(video, options);
|
|
12966
|
+
} catch (error) {
|
|
12967
|
+
if (options.cameraWorkerRequired) throw error;
|
|
12968
|
+
try {
|
|
12969
|
+
options.onDiagnostic?.({
|
|
12970
|
+
timestamp: Date.now(),
|
|
12971
|
+
frame: 0,
|
|
12972
|
+
type: "worker-fallback",
|
|
12973
|
+
state: "fallback",
|
|
12974
|
+
method: "camera-main-thread",
|
|
12975
|
+
message: `Background scanner unavailable · using main-thread fallback (${error?.message ?? String(error)})`
|
|
12976
|
+
});
|
|
12977
|
+
} catch {}
|
|
12978
|
+
}
|
|
12979
|
+
}
|
|
12980
|
+
return startCameraScannerMainThread(video, options);
|
|
12981
|
+
}
|
|
12982
|
+
|
|
11426
12983
|
function rectifyDetectedCode(imageData, options = {}) {
|
|
11427
12984
|
const candidates = detectCodeGeometry(imageData, {
|
|
11428
12985
|
minVersion: options.minVersion ?? MIN_VERSION,
|
|
@@ -11486,7 +13043,7 @@ const internals = Object.freeze({
|
|
|
11486
13043
|
SECURE_FLAG
|
|
11487
13044
|
});
|
|
11488
13045
|
|
|
11489
|
-
return { compressPayload, decompressPayload, compressDeflatePayload, decompressDeflatePayload, compressBrotliPayload, decompressBrotliPayload, deriveSigningKeyId, generateSigningKeyPair, installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeUint8Array, encodeSignedBytes, encodeSignedText, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, decodeUint8Array, verifyDecodedSignature, estimateSafeLogoSize, findMaxSafeLogoSize, getPrintGuidance, renderToCanvas, renderToImageData, renderToSVG, applyStressDistortion, runImageStressTest, runReliabilityLab, runPerspectiveSweep, assessScanability, debugScanImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, RENDER_MODES, CELL_ENCODINGS, PRINT_PALETTE, COMPRESSION_MODES, LZ_LEVEL_MIN, LZ_LEVEL_MAX, DEFAULT_LZ_LEVEL, COMPRESSION_LEVELS, SIGNATURE_ALGORITHMS, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, STRESS_PROFILES, RELIABILITY_PROFILES, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
|
|
13046
|
+
return { compressPayload, decompressPayload, compressDeflatePayload, decompressDeflatePayload, compressBrotliPayload, decompressBrotliPayload, deriveSigningKeyId, generateSigningKeyPair, installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeUint8Array, encodeSignedBytes, encodeSignedText, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, decodeUint8Array, verifyDecodedSignature, estimateSafeLogoSize, findMaxSafeLogoSize, getPrintGuidance, renderToCanvas, renderToImageData, renderToSVG, applyStressDistortion, runImageStressTest, runReliabilityLab, runPerspectiveSweep, assessScanability, debugScanImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, LEGACY_FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, RENDER_MODES, CELL_ENCODINGS, PRINT_PALETTE, COMPRESSION_MODES, LZ_LEVEL_MIN, LZ_LEVEL_MAX, DEFAULT_LZ_LEVEL, COMPRESSION_LEVELS, SIGNATURE_ALGORITHMS, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, STRESS_PROFILES, RELIABILITY_PROFILES, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
|
|
11490
13047
|
})();
|
|
11491
13048
|
const QuadQR = __qqr_quadqr_js;
|
|
11492
13049
|
|
|
@@ -11507,19 +13064,67 @@ QuadQR.initWasm = async function initWasm(options = {}) {
|
|
|
11507
13064
|
const instance = result.instance;
|
|
11508
13065
|
const memory = instance.exports.memory;
|
|
11509
13066
|
const crc32Bytes = instance.exports.crc32_bytes;
|
|
13067
|
+
const buildBinaryRgba = instance.exports.build_binary_rgba;
|
|
11510
13068
|
const heapExport = instance.exports.__heap_base;
|
|
11511
13069
|
const heapBase = Number((heapExport && heapExport.value) || heapExport || 65536);
|
|
11512
13070
|
if (!(memory instanceof WebAssembly.Memory) || typeof crc32Bytes !== "function") {
|
|
11513
13071
|
throw new Error("QuadQR WASM exports are invalid.");
|
|
11514
13072
|
}
|
|
13073
|
+
const ensureMemory = function (required) {
|
|
13074
|
+
if (required > memory.buffer.byteLength) memory.grow(Math.ceil((required - memory.buffer.byteLength) / 65536));
|
|
13075
|
+
};
|
|
11515
13076
|
QuadQR.installCrc32Accelerator(function (input) {
|
|
11516
|
-
const data = input instanceof Uint8Array
|
|
13077
|
+
const data = input instanceof Uint8Array
|
|
13078
|
+
? input
|
|
13079
|
+
: ArrayBuffer.isView(input)
|
|
13080
|
+
? new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
|
13081
|
+
: new Uint8Array(input);
|
|
11517
13082
|
const required = heapBase + data.length;
|
|
11518
|
-
|
|
13083
|
+
ensureMemory(required);
|
|
11519
13084
|
new Uint8Array(memory.buffer, heapBase, data.length).set(data);
|
|
11520
13085
|
return crc32Bytes(heapBase, data.length) >>> 0;
|
|
11521
13086
|
});
|
|
11522
|
-
|
|
13087
|
+
const accelerators = ["crc32"];
|
|
13088
|
+
if (typeof buildBinaryRgba === "function") {
|
|
13089
|
+
const align16 = function (value) { return (value + 15) & ~15; };
|
|
13090
|
+
__qqr_vision_js.installVisionAccelerator({
|
|
13091
|
+
buildBinary: function (imageData, scanOptions = {}) {
|
|
13092
|
+
const width = Number(imageData && imageData.width) || 0;
|
|
13093
|
+
const height = Number(imageData && imageData.height) || 0;
|
|
13094
|
+
const pixels = width * height;
|
|
13095
|
+
if (!pixels || !imageData || !imageData.data || imageData.data.length < pixels * 4) {
|
|
13096
|
+
throw new Error("Valid ImageData is required for WASM preprocessing.");
|
|
13097
|
+
}
|
|
13098
|
+
const raw = imageData.data;
|
|
13099
|
+
const rgba = raw instanceof Uint8Array
|
|
13100
|
+
? raw
|
|
13101
|
+
: new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
13102
|
+
const rgbaBytes = pixels * 4;
|
|
13103
|
+
const inputPtr = align16(heapBase);
|
|
13104
|
+
const grayPtr = align16(inputPtr + rgbaBytes);
|
|
13105
|
+
const binaryPtr = align16(grayPtr + pixels);
|
|
13106
|
+
ensureMemory(binaryPtr + pixels);
|
|
13107
|
+
new Uint8Array(memory.buffer, inputPtr, rgbaBytes).set(rgba.subarray(0, rgbaBytes));
|
|
13108
|
+
const mode = scanOptions.grayMode === "value" ? 0 : 1;
|
|
13109
|
+
const offset = Math.round(Number(scanOptions.thresholdOffset) || 0);
|
|
13110
|
+
const packed = buildBinaryRgba(inputPtr, pixels, mode, offset, grayPtr, binaryPtr) >>> 0;
|
|
13111
|
+
const gray = new Uint8Array(pixels);
|
|
13112
|
+
const binary = new Uint8Array(pixels);
|
|
13113
|
+
gray.set(new Uint8Array(memory.buffer, grayPtr, pixels));
|
|
13114
|
+
binary.set(new Uint8Array(memory.buffer, binaryPtr, pixels));
|
|
13115
|
+
return {
|
|
13116
|
+
gray: gray,
|
|
13117
|
+
binary: binary,
|
|
13118
|
+
threshold: packed & 255,
|
|
13119
|
+
baseThreshold: (packed >>> 8) & 255,
|
|
13120
|
+
grayMode: scanOptions.grayMode || "luminance",
|
|
13121
|
+
accelerated: "wasm"
|
|
13122
|
+
};
|
|
13123
|
+
}
|
|
13124
|
+
});
|
|
13125
|
+
accelerators.push("scanner-preprocess");
|
|
13126
|
+
}
|
|
13127
|
+
return Object.freeze({ enabled: true, module: "quadqr-core", accelerators: Object.freeze(accelerators), bytes: bytes.length });
|
|
11523
13128
|
};
|
|
11524
13129
|
global.QuadQR = QuadQR;
|
|
11525
13130
|
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
|