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/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 one 5x5 primary
12
- * alignment reference at the bottom-right and compact 3x3 secondary markers.
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
- size: primary ? 5 : 3,
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
- // Compact secondary marker: black 3x3 ring with a white center.
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
- // Primary 5x5 marker: black outer ring, white inner ring, black center.
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
- * Pure JavaScript. No DOM dependency except callers may pass browser ImageData.
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 + (options.thresholdOffset ?? 0)),
1214
+ Math.round(baseThreshold + thresholdOffset),
1074
1215
  8,
1075
1216
  247
1076
1217
  );
1077
- return {
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
- function runsForRow(binary, width, row) {
1087
- const runs = [];
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
- runs.push({ color, start, length: width - start });
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 findWindowContainingCoordinate(runs, coordinate, toleranceScale = 1) {
1134
- for (let i = 2; i < runs.length - 2; i++) {
1135
- const centerRun = runs[i];
1136
- if (centerRun.color !== 1) continue;
1137
- if (coordinate < centerRun.start || coordinate >= centerRun.start + centerRun.length) continue;
1138
- const window = runs.slice(i - 2, i + 3);
1139
- if (window.map((run) => run.color).join("") !== "10101") continue;
1140
- const score = finderRatioScore(window.map((run) => run.length), toleranceScale);
1141
- if (!Number.isFinite(score)) continue;
1142
- const first = window[0].start;
1143
- const total = window.reduce((sum, run) => sum + run.length, 0);
1144
- return {
1145
- center: first + total / 2,
1146
- moduleSize: total / 7,
1147
- score
1148
- };
1149
- }
1150
- return null;
1151
- }
1152
-
1153
- function crossCheckVertical(binary, width, height, x, y, toleranceScale = 1) {
1154
- const col = clamp(Math.round(x), 0, width - 1);
1155
- return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y, toleranceScale);
1156
- }
1157
-
1158
- function crossCheckHorizontal(binary, width, height, x, y, toleranceScale = 1) {
1159
- const row = clamp(Math.round(y), 0, height - 1);
1160
- return findWindowContainingCoordinate(runsForRow(binary, width, row), x, toleranceScale);
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 = 0; y < height; y += rowStep) {
1212
- const runs = runsForRow(binary, width, y);
1213
- for (let i = 0; i <= runs.length - 5; i++) {
1214
- const window = runs.slice(i, i + 5);
1215
- if (window.map((run) => run.color).join("") !== "10101") continue;
1216
- const lengths = window.map((run) => run.length);
1217
- const ratioScore = finderRatioScore(lengths, toleranceScale);
1218
- if (!Number.isFinite(ratioScore)) continue;
1219
- const total = lengths.reduce((sum, value) => sum + value, 0);
1220
- const centerX = window[0].start + total / 2;
1221
- const vertical = crossCheckVertical(binary, width, height, centerX, y, toleranceScale);
1222
- if (!vertical) continue;
1223
- const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center, toleranceScale);
1224
- if (!horizontal) continue;
1225
-
1226
- const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
1227
- const moduleSpread = Math.max(
1228
- Math.abs(moduleSize - total / 7),
1229
- Math.abs(moduleSize - vertical.moduleSize),
1230
- Math.abs(moduleSize - horizontal.moduleSize)
1231
- ) / moduleSize;
1232
- if (moduleSpread > moduleSpreadLimit) continue;
1233
-
1234
- raw.push({
1235
- x: horizontal.center,
1236
- y: vertical.center,
1237
- moduleSize,
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 = { score: 0, center: predicted, scale: 1, offsetU: 0, offsetV: 0, broadSearch: false };
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
- // A 3x3 marker carries less evidence than the 5x5 primary and the three
1760
- // finder patterns, so it helps average geometry without overpowering them.
1761
- weights.push(0.8 + candidate.score * 1.7);
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 estimatedSize = ((legH + legV) / 2) / triple.moduleMean + 7;
1903
- const versions = nearestVersionFromEstimate(estimatedSize, minVersion, maxVersion).slice(0, 9);
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 = 5;
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] = FORMAT_VERSION;
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
- if (!magicMatches(header) || header[4] !== FORMAT_VERSION) {
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 createLayout(version) {
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 decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}) {
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: FORMAT_VERSION,
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 layout.dataPositions) {
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 = detectCodeGeometry(imageData, {
9115
- minVersion: options.minVersion ?? MIN_VERSION,
9116
- maxVersion: options.maxVersion ?? MAX_VERSION,
9117
- maxCandidates: options.maxGeometryCandidates ?? 8,
9118
- finderRecovery: options.finderRecovery,
9119
- finderAutoColorBlackClip: options.finderAutoColorBlackClip,
9120
- finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
9121
- finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
9122
- finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
9123
- finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
9124
- finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
9125
- finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
9126
- preciseAlignment: options.preciseAlignment,
9127
- diagnostics: options._visionDiagnostics,
9128
- diagnosticLabel: options._diagnosticLabel ?? "normal"
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
- for (const attempt of paletteClassifierAttempts(observedPalette)) {
9286
- if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
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
- geometryDecoded = tryAttempt({
10136
+ const spatialAttempt = {
9297
10137
  classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
9298
10138
  colorNormalization: "spatial-white-balanced"
9299
- }, normalizedGrid);
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
- for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
9317
- const recoveredAttempt = {
9318
- ...attempt,
9319
- colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
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
- for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
9375
- const recoveredAttempt = {
9376
- ...attempt,
9377
- colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
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 startCameraScanner(video, options = {}) {
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: 1920 },
10880
- height: { ideal: 1080 },
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
- const scanInterval = Math.max(80, options.scanInterval ?? 180);
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 ?? 1080));
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 ?? 1600)
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 1080 px scanner cap. If the fast pass already
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
- timer = setTimeout(loop, scanInterval);
12419
+ scheduleNextScan();
11420
12420
  };
11421
12421
 
11422
- timer = setTimeout(loop, 0);
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 ? input : new Uint8Array(input);
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
- if (required > memory.buffer.byteLength) memory.grow(Math.ceil((required - memory.buffer.byteLength) / 65536));
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
- return Object.freeze({ enabled: true, module: "quadqr-core", accelerators: Object.freeze(["crc32"]), bytes: bytes.length });
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);