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.
@@ -3,12 +3,14 @@ import {
3
3
  alignmentPatternIsBlack,
4
4
  alignmentPatternRadius,
5
5
  primaryAlignmentPatternForVersion,
6
+ ALIGNMENT_PROFILE_STANDARD_5,
7
+ ALIGNMENT_PROFILE_LEGACY_3,
6
8
  sizeForVersion
7
9
  } from "./geometry.js";
8
10
 
9
11
  /**
10
12
  * Image geometry and sampling helpers for QuadQR.
11
- * Pure JavaScript. No DOM dependency except callers may pass browser ImageData.
13
+ * JavaScript fallback with optional WASM hot-loop acceleration. No DOM dependency except callers may pass browser ImageData.
12
14
  */
13
15
 
14
16
  function assert(condition, message) {
@@ -19,6 +21,20 @@ function clamp(value, min, max) {
19
21
  return Math.max(min, Math.min(max, value));
20
22
  }
21
23
 
24
+ let visionAccelerator = null;
25
+
26
+ // Repeated recovery stages often inspect the exact same captured ImageData.
27
+ // Cache immutable grayscale/binary preprocessing by ImageData identity so the
28
+ // stronger scanner can reuse work instead of rebuilding millions of pixels.
29
+ // WeakMap keeps camera frames collectible as soon as the scan finishes.
30
+ const binaryPreprocessCache = new WeakMap();
31
+ const autoColorGrayCache = new WeakMap();
32
+
33
+ /** Install or remove optional scanner hot-loop acceleration. Internal WASM wiring hook. */
34
+ export function installVisionAccelerator(accelerator = null) {
35
+ visionAccelerator = accelerator && typeof accelerator.buildBinary === "function" ? accelerator : null;
36
+ }
37
+
22
38
  function pixelRgb(imageData, x, y) {
23
39
  const ix = clamp(Math.round(x), 0, imageData.width - 1);
24
40
  const iy = clamp(Math.round(y), 0, imageData.height - 1);
@@ -114,54 +130,140 @@ function binaryAtThreshold(gray, threshold) {
114
130
  return binary;
115
131
  }
116
132
 
117
- function buildBinary(imageData, options = {}) {
133
+ function hybridLocalBinary(gray, width, height) {
134
+ // QR-specific local thresholding inspired by the same block strategy used by
135
+ // ZXing's HybridBinarizer. It is intentionally a locator fallback, not a
136
+ // replacement for QuadQR's color correction. 8x8 blocks + smoothed local
137
+ // black points make finder rings survive shadows and screen gradients that
138
+ // defeat one global threshold.
139
+ const BLOCK = 8;
140
+ const MIN_DIMENSION = 40;
141
+ const MIN_DYNAMIC_RANGE = 24;
142
+ if (width < MIN_DIMENSION || height < MIN_DIMENSION) return null;
143
+
144
+ const subWidth = Math.ceil(width / BLOCK);
145
+ const subHeight = Math.ceil(height / BLOCK);
146
+ const blackPoints = new Uint16Array(subWidth * subHeight);
147
+ const maxXOffset = Math.max(0, width - BLOCK);
148
+ const maxYOffset = Math.max(0, height - BLOCK);
149
+
150
+ for (let by = 0; by < subHeight; by++) {
151
+ const y0 = Math.min(by * BLOCK, maxYOffset);
152
+ for (let bx = 0; bx < subWidth; bx++) {
153
+ const x0 = Math.min(bx * BLOCK, maxXOffset);
154
+ let sum = 0;
155
+ let min = 255;
156
+ let max = 0;
157
+ for (let yy = 0; yy < BLOCK; yy++) {
158
+ const row = (y0 + yy) * width + x0;
159
+ for (let xx = 0; xx < BLOCK; xx++) {
160
+ const value = gray[row + xx];
161
+ sum += value;
162
+ if (value < min) min = value;
163
+ if (value > max) max = value;
164
+ }
165
+ }
166
+ let average = sum >> 6;
167
+ if (max - min <= MIN_DYNAMIC_RANGE) {
168
+ average = min >> 1;
169
+ if (by > 0 && bx > 0) {
170
+ const above = blackPoints[(by - 1) * subWidth + bx];
171
+ const left = blackPoints[by * subWidth + bx - 1];
172
+ const diag = blackPoints[(by - 1) * subWidth + bx - 1];
173
+ const neighbor = (above + 2 * left + diag) >> 2;
174
+ if (min < neighbor) average = neighbor;
175
+ }
176
+ }
177
+ blackPoints[by * subWidth + bx] = average;
178
+ }
179
+ }
180
+
181
+ const binary = new Uint8Array(width * height);
182
+ for (let by = 0; by < subHeight; by++) {
183
+ const y0 = Math.min(by * BLOCK, maxYOffset);
184
+ const centerY = clamp(by, 2, Math.max(2, subHeight - 3));
185
+ for (let bx = 0; bx < subWidth; bx++) {
186
+ const x0 = Math.min(bx * BLOCK, maxXOffset);
187
+ const centerX = clamp(bx, 2, Math.max(2, subWidth - 3));
188
+ let sum = 0;
189
+ let count = 0;
190
+ for (let oy = -2; oy <= 2; oy++) {
191
+ const py = clamp(centerY + oy, 0, subHeight - 1);
192
+ for (let ox = -2; ox <= 2; ox++) {
193
+ const px = clamp(centerX + ox, 0, subWidth - 1);
194
+ sum += blackPoints[py * subWidth + px];
195
+ count++;
196
+ }
197
+ }
198
+ const threshold = Math.round(sum / Math.max(1, count));
199
+ for (let yy = 0; yy < BLOCK; yy++) {
200
+ const y = y0 + yy;
201
+ if (y >= height) break;
202
+ const row = y * width;
203
+ for (let xx = 0; xx < BLOCK; xx++) {
204
+ const x = x0 + xx;
205
+ if (x >= width) break;
206
+ if (gray[row + x] <= threshold) binary[row + x] = 1;
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return binary;
212
+ }
213
+
214
+ export function buildBinary(imageData, options = {}) {
118
215
  const grayMode = options.grayMode ?? "luminance";
216
+ const thresholdOffset = Math.round(Number(options.thresholdOffset) || 0);
217
+ const canCache = imageData && typeof imageData === "object" && options.cache !== false;
218
+ const cacheKey = `${grayMode}:${thresholdOffset}`;
219
+ if (canCache) {
220
+ const cached = binaryPreprocessCache.get(imageData)?.get(cacheKey);
221
+ if (cached) return cached;
222
+ }
223
+
224
+ let result;
225
+ if (visionAccelerator) {
226
+ try {
227
+ const accelerated = visionAccelerator.buildBinary(imageData, {
228
+ grayMode,
229
+ thresholdOffset
230
+ });
231
+ if (accelerated?.gray && accelerated?.binary) {
232
+ result = accelerated;
233
+ if (canCache) {
234
+ let entries = binaryPreprocessCache.get(imageData);
235
+ if (!entries) binaryPreprocessCache.set(imageData, entries = new Map());
236
+ entries.set(cacheKey, result);
237
+ }
238
+ return result;
239
+ }
240
+ } catch {
241
+ // A WASM/runtime failure must never make scanning unavailable. Fall back
242
+ // to the exact JavaScript implementation for this and future calls.
243
+ visionAccelerator = null;
244
+ }
245
+ }
246
+
119
247
  const gray = buildGray(imageData, grayMode);
120
248
  const baseThreshold = otsuThreshold(gray);
121
249
  const threshold = clamp(
122
- Math.round(baseThreshold + (options.thresholdOffset ?? 0)),
250
+ Math.round(baseThreshold + thresholdOffset),
123
251
  8,
124
252
  247
125
253
  );
126
- return {
254
+ result = {
127
255
  gray,
128
256
  binary: binaryAtThreshold(gray, threshold),
129
257
  threshold,
130
258
  baseThreshold,
131
259
  grayMode
132
260
  };
133
- }
134
-
135
- function runsForRow(binary, width, row) {
136
- const runs = [];
137
- let color = binary[row * width];
138
- let start = 0;
139
- for (let x = 1; x < width; x++) {
140
- const next = binary[row * width + x];
141
- if (next !== color) {
142
- runs.push({ color, start, length: x - start });
143
- color = next;
144
- start = x;
145
- }
146
- }
147
- runs.push({ color, start, length: width - start });
148
- return runs;
149
- }
150
-
151
- function runsForColumn(binary, width, height, col) {
152
- const runs = [];
153
- let color = binary[col];
154
- let start = 0;
155
- for (let y = 1; y < height; y++) {
156
- const next = binary[y * width + col];
157
- if (next !== color) {
158
- runs.push({ color, start, length: y - start });
159
- color = next;
160
- start = y;
161
- }
261
+ if (canCache) {
262
+ let entries = binaryPreprocessCache.get(imageData);
263
+ if (!entries) binaryPreprocessCache.set(imageData, entries = new Map());
264
+ entries.set(cacheKey, result);
162
265
  }
163
- runs.push({ color, start, length: height - start });
164
- return runs;
266
+ return result;
165
267
  }
166
268
 
167
269
  function finderRatioScore(lengths, toleranceScale = 1) {
@@ -179,34 +281,85 @@ function finderRatioScore(lengths, toleranceScale = 1) {
179
281
  return score;
180
282
  }
181
283
 
182
- function findWindowContainingCoordinate(runs, coordinate, toleranceScale = 1) {
183
- for (let i = 2; i < runs.length - 2; i++) {
184
- const centerRun = runs[i];
185
- if (centerRun.color !== 1) continue;
186
- if (coordinate < centerRun.start || coordinate >= centerRun.start + centerRun.length) continue;
187
- const window = runs.slice(i - 2, i + 3);
188
- if (window.map((run) => run.color).join("") !== "10101") continue;
189
- const score = finderRatioScore(window.map((run) => run.length), toleranceScale);
190
- if (!Number.isFinite(score)) continue;
191
- const first = window[0].start;
192
- const total = window.reduce((sum, run) => sum + run.length, 0);
193
- return {
194
- center: first + total / 2,
195
- moduleSize: total / 7,
196
- score
197
- };
198
- }
199
- return null;
200
- }
201
-
202
- function crossCheckVertical(binary, width, height, x, y, toleranceScale = 1) {
203
- const col = clamp(Math.round(x), 0, width - 1);
204
- return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y, toleranceScale);
205
- }
206
-
207
- function crossCheckHorizontal(binary, width, height, x, y, toleranceScale = 1) {
208
- const row = clamp(Math.round(y), 0, height - 1);
209
- return findWindowContainingCoordinate(runsForRow(binary, width, row), x, toleranceScale);
284
+ function finderCenterFromEnd(stateCount, end) {
285
+ return end - stateCount[4] - stateCount[3] - stateCount[2] / 2;
286
+ }
287
+
288
+ function directCrossCheckVertical(binary, width, height, startY, centerX, maxCount, originalTotal, toleranceScale = 1) {
289
+ const x = clamp(Math.round(centerX), 0, width - 1);
290
+ const state = [0, 0, 0, 0, 0];
291
+ let y = clamp(Math.round(startY), 0, height - 1);
292
+
293
+ while (y >= 0 && binary[y * width + x]) { state[2]++; y--; }
294
+ if (y < 0) return null;
295
+ while (y >= 0 && !binary[y * width + x] && state[1] <= maxCount) { state[1]++; y--; }
296
+ if (y < 0 || state[1] > maxCount) return null;
297
+ while (y >= 0 && binary[y * width + x] && state[0] <= maxCount) { state[0]++; y--; }
298
+ if (state[0] > maxCount) return null;
299
+
300
+ y = clamp(Math.round(startY), 0, height - 1) + 1;
301
+ while (y < height && binary[y * width + x]) { state[2]++; y++; }
302
+ if (y === height) return null;
303
+ while (y < height && !binary[y * width + x] && state[3] < maxCount) { state[3]++; y++; }
304
+ if (y === height || state[3] >= maxCount) return null;
305
+ while (y < height && binary[y * width + x] && state[4] < maxCount) { state[4]++; y++; }
306
+ if (state[4] >= maxCount) return null;
307
+
308
+ const total = state.reduce((sum, value) => sum + value, 0);
309
+ if (Math.abs(total - originalTotal) > originalTotal * 0.40) return null;
310
+ const score = finderRatioScore(state, toleranceScale);
311
+ if (!Number.isFinite(score)) return null;
312
+ return { center: finderCenterFromEnd(state, y), moduleSize: total / 7, score };
313
+ }
314
+
315
+ function directCrossCheckHorizontal(binary, width, height, startX, centerY, maxCount, originalTotal, toleranceScale = 1) {
316
+ const y = clamp(Math.round(centerY), 0, height - 1);
317
+ const row = y * width;
318
+ const state = [0, 0, 0, 0, 0];
319
+ let x = clamp(Math.round(startX), 0, width - 1);
320
+
321
+ while (x >= 0 && binary[row + x]) { state[2]++; x--; }
322
+ if (x < 0) return null;
323
+ while (x >= 0 && !binary[row + x] && state[1] <= maxCount) { state[1]++; x--; }
324
+ if (x < 0 || state[1] > maxCount) return null;
325
+ while (x >= 0 && binary[row + x] && state[0] <= maxCount) { state[0]++; x--; }
326
+ if (state[0] > maxCount) return null;
327
+
328
+ x = clamp(Math.round(startX), 0, width - 1) + 1;
329
+ while (x < width && binary[row + x]) { state[2]++; x++; }
330
+ if (x === width) return null;
331
+ while (x < width && !binary[row + x] && state[3] < maxCount) { state[3]++; x++; }
332
+ if (x === width || state[3] >= maxCount) return null;
333
+ while (x < width && binary[row + x] && state[4] < maxCount) { state[4]++; x++; }
334
+ if (state[4] >= maxCount) return null;
335
+
336
+ const total = state.reduce((sum, value) => sum + value, 0);
337
+ if (Math.abs(total - originalTotal) > originalTotal * 0.25) return null;
338
+ const score = finderRatioScore(state, toleranceScale);
339
+ if (!Number.isFinite(score)) return null;
340
+ return { center: finderCenterFromEnd(state, x), moduleSize: total / 7, score };
341
+ }
342
+
343
+ function directCrossCheckDiagonal(binary, width, height, centerX, centerY, toleranceScale = 1) {
344
+ const cx = clamp(Math.round(centerX), 0, width - 1);
345
+ const cy = clamp(Math.round(centerY), 0, height - 1);
346
+ const state = [0, 0, 0, 0, 0];
347
+ let step = 0;
348
+ while (cx - step >= 0 && cy - step >= 0 && binary[(cy - step) * width + (cx - step)]) { state[2]++; step++; }
349
+ if (!state[2]) return false;
350
+ while (cx - step >= 0 && cy - step >= 0 && !binary[(cy - step) * width + (cx - step)]) { state[1]++; step++; }
351
+ if (!state[1]) return false;
352
+ while (cx - step >= 0 && cy - step >= 0 && binary[(cy - step) * width + (cx - step)]) { state[0]++; step++; }
353
+ if (!state[0]) return false;
354
+
355
+ step = 1;
356
+ while (cx + step < width && cy + step < height && binary[(cy + step) * width + (cx + step)]) { state[2]++; step++; }
357
+ while (cx + step < width && cy + step < height && !binary[(cy + step) * width + (cx + step)]) { state[3]++; step++; }
358
+ if (!state[3]) return false;
359
+ while (cx + step < width && cy + step < height && binary[(cy + step) * width + (cx + step)]) { state[4]++; step++; }
360
+ if (!state[4]) return false;
361
+
362
+ return Number.isFinite(finderRatioScore(state, toleranceScale * 1.35));
210
363
  }
211
364
 
212
365
  function clusterFinderCandidates(raw, minConfirmations = 2) {
@@ -250,48 +403,181 @@ function clusterFinderCandidates(raw, minConfirmations = 2) {
250
403
  );
251
404
  }
252
405
 
253
- function detectFinderCandidates(binary, width, height, options = {}) {
406
+ export function detectFinderCandidates(binary, width, height, options = {}) {
407
+ // Finder acquisition follows the same broad strategy that makes mature QR
408
+ // readers feel immediate: scan a subset of rows for 1:1:3:1:1, then confirm
409
+ // each hit directly across the orthogonal axes. Unlike the previous scanner,
410
+ // this does not allocate complete run arrays for every row and column.
254
411
  const raw = [];
255
- const rowStep = height > 1200 ? 2 : 1;
256
412
  const toleranceScale = options.toleranceScale ?? 1;
257
413
  const moduleSpreadLimit = options.moduleSpreadLimit ?? 0.45;
258
414
  const minConfirmations = options.minConfirmations ?? 2;
415
+ const rowStep = Math.max(1, Math.round(options.rowStep ?? (height > 900 ? 2 : 1)));
416
+ const diagonalCheck = options.diagonalCheck === true;
417
+ const maxRawCandidates = Math.max(24, Math.round(options.maxRawCandidates ?? 160));
418
+
419
+ const handlePossibleCenter = (stateCount, y, endX) => {
420
+ const ratioScore = finderRatioScore(stateCount, toleranceScale);
421
+ if (!Number.isFinite(ratioScore)) return false;
422
+ const total = stateCount.reduce((sum, value) => sum + value, 0);
423
+ const centerX = finderCenterFromEnd(stateCount, endX);
424
+ const maxCount = Math.max(2, Math.ceil(stateCount[2] * 1.25));
425
+ const vertical = directCrossCheckVertical(binary, width, height, y, centerX, maxCount, total, toleranceScale);
426
+ if (!vertical) return false;
427
+ const horizontal = directCrossCheckHorizontal(binary, width, height, centerX, vertical.center, maxCount, total, toleranceScale);
428
+ if (!horizontal) return false;
429
+ if (diagonalCheck && !directCrossCheckDiagonal(binary, width, height, horizontal.center, vertical.center, toleranceScale)) return false;
430
+
431
+ const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
432
+ const moduleSpread = Math.max(
433
+ Math.abs(moduleSize - total / 7),
434
+ Math.abs(moduleSize - vertical.moduleSize),
435
+ Math.abs(moduleSize - horizontal.moduleSize)
436
+ ) / Math.max(0.01, moduleSize);
437
+ if (moduleSpread > moduleSpreadLimit) return false;
438
+
439
+ raw.push({
440
+ x: horizontal.center,
441
+ y: vertical.center,
442
+ moduleSize,
443
+ score: ratioScore + vertical.score + horizontal.score
444
+ });
445
+ return true;
446
+ };
259
447
 
260
- for (let y = 0; y < height; y += rowStep) {
261
- const runs = runsForRow(binary, width, y);
262
- for (let i = 0; i <= runs.length - 5; i++) {
263
- const window = runs.slice(i, i + 5);
264
- if (window.map((run) => run.color).join("") !== "10101") continue;
265
- const lengths = window.map((run) => run.length);
266
- const ratioScore = finderRatioScore(lengths, toleranceScale);
267
- if (!Number.isFinite(ratioScore)) continue;
268
- const total = lengths.reduce((sum, value) => sum + value, 0);
269
- const centerX = window[0].start + total / 2;
270
- const vertical = crossCheckVertical(binary, width, height, centerX, y, toleranceScale);
271
- if (!vertical) continue;
272
- const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center, toleranceScale);
273
- if (!horizontal) continue;
274
-
275
- const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
276
- const moduleSpread = Math.max(
277
- Math.abs(moduleSize - total / 7),
278
- Math.abs(moduleSize - vertical.moduleSize),
279
- Math.abs(moduleSize - horizontal.moduleSize)
280
- ) / moduleSize;
281
- if (moduleSpread > moduleSpreadLimit) continue;
282
-
283
- raw.push({
284
- x: horizontal.center,
285
- y: vertical.center,
286
- moduleSize,
287
- score: ratioScore + vertical.score + horizontal.score
288
- });
448
+ for (let y = rowStep - 1; y < height; y += rowStep) {
449
+ const state = [0, 0, 0, 0, 0];
450
+ let currentState = 0;
451
+ const row = y * width;
452
+
453
+ for (let x = 0; x < width; x++) {
454
+ if (binary[row + x]) {
455
+ if ((currentState & 1) === 1) currentState++;
456
+ state[currentState]++;
457
+ } else {
458
+ if ((currentState & 1) === 0) {
459
+ if (currentState === 4) {
460
+ handlePossibleCenter(state, y, x);
461
+ state[0] = state[2];
462
+ state[1] = state[3];
463
+ state[2] = state[4];
464
+ state[3] = 1;
465
+ state[4] = 0;
466
+ currentState = 3;
467
+ } else {
468
+ currentState++;
469
+ state[currentState]++;
470
+ }
471
+ } else {
472
+ state[currentState]++;
473
+ }
474
+ }
289
475
  }
476
+ if (currentState === 4) handlePossibleCenter(state, y, width);
477
+ if (raw.length >= maxRawCandidates) break;
290
478
  }
291
479
 
292
480
  return clusterFinderCandidates(raw, minConfirmations);
293
481
  }
294
482
 
483
+ function detectFinderCandidatesByComponents(binary, width, height, options = {}) {
484
+ const visited = new Uint8Array(width * height);
485
+ const stack = [];
486
+ const candidates = [];
487
+ const minArea = Math.max(16, Math.round(options.componentMinArea ?? 20));
488
+ const minSpan = Math.max(5, Math.round(options.componentMinSpan ?? 7));
489
+ const maxComponents = Math.max(64, Math.round(options.componentMaxCount ?? 600));
490
+ let componentsSeen = 0;
491
+
492
+ const templateScore = (minX, minY, maxX, maxY) => {
493
+ const spanX = maxX - minX + 1;
494
+ const spanY = maxY - minY + 1;
495
+ let matches = 0;
496
+ let total = 0;
497
+ for (let r = 0; r < 7; r++) {
498
+ for (let c = 0; c < 7; c++) {
499
+ const x = minX + ((c + 0.5) / 7) * spanX;
500
+ const y = minY + ((r + 0.5) / 7) * spanY;
501
+ const actual = sampleBinaryAt(binary, width, height, x, y);
502
+ if (actual == null) continue;
503
+ const expected = r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4);
504
+ total++;
505
+ if (Boolean(actual) === expected) matches++;
506
+ }
507
+ }
508
+ return total ? matches / total : 0;
509
+ };
510
+
511
+ for (let index = 0; index < binary.length; index++) {
512
+ if (!binary[index] || visited[index]) continue;
513
+ componentsSeen++;
514
+ if (componentsSeen > maxComponents) break;
515
+ visited[index] = 1;
516
+ stack.length = 0;
517
+ stack.push(index);
518
+ let area = 0;
519
+ let minX = width;
520
+ let minY = height;
521
+ let maxX = -1;
522
+ let maxY = -1;
523
+
524
+ while (stack.length) {
525
+ const current = stack.pop();
526
+ const y = Math.floor(current / width);
527
+ const x = current - y * width;
528
+ area++;
529
+ if (x < minX) minX = x;
530
+ if (x > maxX) maxX = x;
531
+ if (y < minY) minY = y;
532
+ if (y > maxY) maxY = y;
533
+
534
+ if (x > 0) {
535
+ const next = current - 1;
536
+ if (binary[next] && !visited[next]) { visited[next] = 1; stack.push(next); }
537
+ }
538
+ if (x + 1 < width) {
539
+ const next = current + 1;
540
+ if (binary[next] && !visited[next]) { visited[next] = 1; stack.push(next); }
541
+ }
542
+ if (y > 0) {
543
+ const next = current - width;
544
+ if (binary[next] && !visited[next]) { visited[next] = 1; stack.push(next); }
545
+ }
546
+ if (y + 1 < height) {
547
+ const next = current + width;
548
+ if (binary[next] && !visited[next]) { visited[next] = 1; stack.push(next); }
549
+ }
550
+ }
551
+
552
+ const spanX = maxX - minX + 1;
553
+ const spanY = maxY - minY + 1;
554
+ if (area < minArea || spanX < minSpan || spanY < minSpan) continue;
555
+ const aspect = spanX / spanY;
556
+ if (aspect < 0.18 || aspect > 5.5) continue;
557
+ const density = area / (spanX * spanY);
558
+ if (density < 0.10 || density > 0.78) continue;
559
+ const centerX = (minX + maxX) / 2;
560
+ const centerY = (minY + maxY) / 2;
561
+ if (sampleBinaryAt(binary, width, height, centerX, centerY) !== 1) continue;
562
+ const score = templateScore(minX, minY, maxX, maxY);
563
+ if (score < (options.componentTemplateThreshold ?? 0.63)) continue;
564
+
565
+ candidates.push({
566
+ x: centerX,
567
+ y: centerY,
568
+ moduleSize: Math.max(0.75, Math.sqrt(area / 24)),
569
+ score: (1 - score) * 3,
570
+ componentScore: score,
571
+ componentArea: area
572
+ });
573
+ }
574
+
575
+ return candidates.sort((a, b) =>
576
+ (b.componentScore - a.componentScore) ||
577
+ (b.componentArea - a.componentArea)
578
+ ).slice(0, Math.max(3, Math.round(options.componentMaxCandidates ?? 12)));
579
+ }
580
+
295
581
  function recoverFinderSetFromTwo(binary, width, height, strongFinders, detector = {}) {
296
582
  if (strongFinders.length !== 2) return strongFinders;
297
583
  const moduleMean = (strongFinders[0].moduleSize + strongFinders[1].moduleSize) / 2;
@@ -329,7 +615,7 @@ function cross(a, b) {
329
615
  return a.x * b.y - a.y * b.x;
330
616
  }
331
617
 
332
- function chooseFinderTriples(candidates, maxTriples = 16, options = {}) {
618
+ export function chooseFinderTriples(candidates, maxTriples = 16, options = {}) {
333
619
  const perspectiveRecovery = options.perspectiveRecovery === true;
334
620
  const topLimit = perspectiveRecovery ? 18 : 14;
335
621
  const top = candidates.slice(0, Math.min(candidates.length, topLimit));
@@ -397,6 +683,61 @@ function chooseFinderTriples(candidates, maxTriples = 16, options = {}) {
397
683
  return triples.slice(0, maxTriples);
398
684
  }
399
685
 
686
+ function rayDistanceToImageBoundary(width, height, x, y, dx, dy) {
687
+ let limit = Infinity;
688
+ if (dx > 1e-9) limit = Math.min(limit, (width - 1 - x) / dx);
689
+ else if (dx < -1e-9) limit = Math.min(limit, (0 - x) / dx);
690
+ if (dy > 1e-9) limit = Math.min(limit, (height - 1 - y) / dy);
691
+ else if (dy < -1e-9) limit = Math.min(limit, (0 - y) / dy);
692
+ return Number.isFinite(limit) ? Math.max(0, limit) : 0;
693
+ }
694
+
695
+ function finderHalfRunDistance(binary, width, height, center, direction) {
696
+ const length = Math.hypot(direction.x, direction.y);
697
+ if (length < 1e-6) return NaN;
698
+ const dx = direction.x / length;
699
+ const dy = direction.y / length;
700
+ const maxDistance = rayDistanceToImageBoundary(width, height, center.x, center.y, dx, dy);
701
+ let previous = sampleBinaryAt(binary, width, height, center.x, center.y);
702
+ if (previous !== 1) return NaN;
703
+ let transitions = 0;
704
+ let lastDistance = 0;
705
+ // Half-pixel stepping keeps the measured boundary stable at small module
706
+ // sizes without becoming expensive. A finder needs only three transitions:
707
+ // center black -> white ring -> black ring -> outside white.
708
+ for (let distance = 0.5; distance <= maxDistance; distance += 0.5) {
709
+ const value = sampleBinaryAt(binary, width, height, center.x + dx * distance, center.y + dy * distance);
710
+ if (value == null) break;
711
+ if (value !== previous) {
712
+ transitions++;
713
+ previous = value;
714
+ if (transitions === 3) return distance;
715
+ }
716
+ lastDistance = distance;
717
+ if (distance > 80 && transitions === 0) break;
718
+ }
719
+ return transitions >= 2 ? lastDistance : NaN;
720
+ }
721
+
722
+ function directionalFinderModuleSize(binary, width, height, pattern, otherPattern) {
723
+ const direction = { x: otherPattern.x - pattern.x, y: otherPattern.y - pattern.y };
724
+ const forward = finderHalfRunDistance(binary, width, height, pattern, direction);
725
+ const backward = finderHalfRunDistance(binary, width, height, pattern, { x: -direction.x, y: -direction.y });
726
+ if (!Number.isFinite(forward) && !Number.isFinite(backward)) return NaN;
727
+ if (!Number.isFinite(forward)) return (backward * 2) / 7;
728
+ if (!Number.isFinite(backward)) return (forward * 2) / 7;
729
+ return (forward + backward) / 7;
730
+ }
731
+
732
+ function moduleSizeBetweenFinders(binary, width, height, a, b) {
733
+ const fromA = directionalFinderModuleSize(binary, width, height, a, b);
734
+ const fromB = directionalFinderModuleSize(binary, width, height, b, a);
735
+ if (Number.isFinite(fromA) && Number.isFinite(fromB)) return (fromA + fromB) / 2;
736
+ if (Number.isFinite(fromA)) return fromA;
737
+ if (Number.isFinite(fromB)) return fromB;
738
+ return (a.moduleSize + b.moduleSize) / 2;
739
+ }
740
+
400
741
  function nearestVersionFromEstimate(value, minVersion, maxVersion) {
401
742
  const candidates = [];
402
743
  for (let version = minVersion; version <= maxVersion; version++) {
@@ -467,7 +808,113 @@ function preciseAlignmentScore(binary, width, height, center, basisU, basisV, sc
467
808
  return total ? matches / total : 0;
468
809
  }
469
810
 
470
- function searchAlignment(binary, width, height, triple, version, options = {}) {
811
+ // Fast 5x5 nested-eye probe used before the heavier alignment template search.
812
+ // A standard 5x5 alignment pattern has a particularly strong signature along
813
+ // its centre axes: black / white / black / white / black. We also probe the
814
+ // inner and outer diagonals so random payload modules are much less likely to
815
+ // impersonate an alignment eye. This is deliberately tiny: at most 17 binary
816
+ // samples per candidate instead of a full 25/125-sample template evaluation.
817
+ const FAST_ALIGNMENT_PROBES = Object.freeze([
818
+ Object.freeze([0, 0, 1, 2.2]),
819
+ Object.freeze([-1, 0, 0, 1.4]), Object.freeze([1, 0, 0, 1.4]),
820
+ Object.freeze([0, -1, 0, 1.4]), Object.freeze([0, 1, 0, 1.4]),
821
+ Object.freeze([-2, 0, 1, 1.2]), Object.freeze([2, 0, 1, 1.2]),
822
+ Object.freeze([0, -2, 1, 1.2]), Object.freeze([0, 2, 1, 1.2]),
823
+ Object.freeze([-1, -1, 0, 0.9]), Object.freeze([1, -1, 0, 0.9]),
824
+ Object.freeze([-1, 1, 0, 0.9]), Object.freeze([1, 1, 0, 0.9]),
825
+ Object.freeze([-2, -2, 1, 0.8]), Object.freeze([2, -2, 1, 0.8]),
826
+ Object.freeze([-2, 2, 1, 0.8]), Object.freeze([2, 2, 1, 0.8])
827
+ ]);
828
+
829
+ function fastNestedAlignmentScore(binary, width, height, center, basisU, basisV, scale) {
830
+ let matched = 0;
831
+ let total = 0;
832
+ for (const [c, r, expected, weight] of FAST_ALIGNMENT_PROBES) {
833
+ const x = center.x + basisU.x * c * scale + basisV.x * r * scale;
834
+ const y = center.y + basisU.y * c * scale + basisV.y * r * scale;
835
+ const value = sampleBinaryAt(binary, width, height, x, y);
836
+ if (value === null) continue;
837
+ total += weight;
838
+ if (value === expected) matched += weight;
839
+ }
840
+ return total ? matched / total : 0;
841
+ }
842
+
843
+ function searchNestedAlignmentFast(binary, width, height, predicted, basisU, basisV, target, options = {}) {
844
+ if (target?.size !== 5 || options.fastAlignmentLocator === false) return null;
845
+
846
+ const inferredScale = clamp(Number(options.inferredTargetScale ?? 1), 0.5, 2.1);
847
+ const radius = clamp(Number(options.radius ?? 5), 2.5, 18);
848
+ const coarseStep = radius >= 14 ? 2 : radius >= 9 ? 1.5 : 1;
849
+ const scales = [...new Set([
850
+ Number((inferredScale * 0.78).toFixed(3)),
851
+ Number((inferredScale * 0.90).toFixed(3)),
852
+ Number(inferredScale.toFixed(3)),
853
+ Number((inferredScale * 1.12).toFixed(3)),
854
+ Number((inferredScale * 1.28).toFixed(3)),
855
+ 0.75, 1, 1.25
856
+ ])].filter((value) => value >= 0.48 && value <= 2.1);
857
+
858
+ const candidates = [];
859
+ for (let offsetV = -radius; offsetV <= radius + 0.001; offsetV += coarseStep) {
860
+ for (let offsetU = -radius; offsetU <= radius + 0.001; offsetU += coarseStep) {
861
+ const center = {
862
+ x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
863
+ y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
864
+ };
865
+ let bestScore = 0;
866
+ let bestScale = 1;
867
+ for (const scale of scales) {
868
+ const score = fastNestedAlignmentScore(binary, width, height, center, basisU, basisV, scale);
869
+ if (score > bestScore) {
870
+ bestScore = score;
871
+ bestScale = scale;
872
+ }
873
+ }
874
+ // Keeping only plausible nested eyes makes the fine phase effectively
875
+ // constant-time even on very dense symbols.
876
+ if (bestScore >= (options.fastAlignmentSeedThreshold ?? 0.73)) {
877
+ candidates.push({ score: bestScore, center, scale: bestScale, offsetU, offsetV });
878
+ }
879
+ }
880
+ }
881
+
882
+ if (!candidates.length) return null;
883
+ candidates.sort((a, b) => b.score - a.score);
884
+ const fineSeeds = candidates.slice(0, Math.max(1, Math.round(options.fastAlignmentFineSeeds ?? 5)));
885
+ const fineOffsets = [-0.75, -0.35, 0, 0.35, 0.75];
886
+ let best = null;
887
+
888
+ for (const seed of fineSeeds) {
889
+ const fineScales = [...new Set([
890
+ Number((seed.scale * 0.90).toFixed(3)),
891
+ Number((seed.scale * 0.96).toFixed(3)),
892
+ Number(seed.scale.toFixed(3)),
893
+ Number((seed.scale * 1.05).toFixed(3)),
894
+ Number((seed.scale * 1.12).toFixed(3))
895
+ ])].filter((value) => value >= 0.46 && value <= 2.15);
896
+ for (const du of fineOffsets) {
897
+ for (const dv of fineOffsets) {
898
+ const offsetU = seed.offsetU + du;
899
+ const offsetV = seed.offsetV + dv;
900
+ const center = {
901
+ x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
902
+ y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
903
+ };
904
+ for (const scale of fineScales) {
905
+ const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
906
+ if (!best || score > best.score) {
907
+ best = { score, center, scale, offsetU, offsetV, broadSearch: radius > 4, fastNested: true };
908
+ }
909
+ }
910
+ }
911
+ }
912
+ }
913
+
914
+ return best;
915
+ }
916
+
917
+ export function searchAlignment(binary, width, height, triple, version, options = {}) {
471
918
  const size = sizeForVersion(version);
472
919
  const separation = size - 7;
473
920
  const basisU = {
@@ -478,7 +925,7 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
478
925
  x: (triple.bl.x - triple.tl.x) / separation,
479
926
  y: (triple.bl.y - triple.tl.y) / separation
480
927
  };
481
- const target = primaryAlignmentPatternForVersion(version);
928
+ const target = primaryAlignmentPatternForVersion(version, { profile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5 });
482
929
  const targetX = target.col + 0.5;
483
930
  const targetY = target.row + 0.5;
484
931
  const predicted = {
@@ -486,11 +933,6 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
486
933
  y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
487
934
  };
488
935
 
489
- const precise = options.preciseAlignment === true;
490
- const localScoreFn = precise ? preciseAlignmentScore : alignmentScore;
491
- const localOffsets = precise
492
- ? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
493
- : [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
494
936
  const finderSizes = [triple.tl.moduleSize, triple.tr.moduleSize, triple.bl.moduleSize];
495
937
  const finderMin = Math.max(0.01, Math.min(...finderSizes));
496
938
  const finderMax = Math.max(...finderSizes);
@@ -510,11 +952,62 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
510
952
  Number((inferredTargetScale * 1.12).toFixed(3))
511
953
  ])].filter((scale) => scale >= 0.52 && scale <= 1.9);
512
954
 
955
+ const cornerSkew = Math.max(0, 1 - (triple.orthogonality ?? 1));
956
+ const projectiveSignal = Math.max(
957
+ Math.max(0, finderScaleRatio - 1),
958
+ Math.max(0, (triple.moduleSpread ?? 0) * 1.25),
959
+ cornerSkew * 0.75
960
+ );
961
+
962
+ // Try the nested-eye locator first. It searches a projective-sized region
963
+ // using only a handful of binary samples per location and therefore remains
964
+ // cheap even when the affine prediction is several modules off.
965
+ const fastRadius = clamp(
966
+ Number(options.fastAlignmentRadius ?? (3 + projectiveSignal * 13)),
967
+ 3,
968
+ options.fastAlignmentMaxRadius ?? 16
969
+ );
970
+ const fastNested = searchNestedAlignmentFast(
971
+ binary,
972
+ width,
973
+ height,
974
+ predicted,
975
+ basisU,
976
+ basisV,
977
+ target,
978
+ {
979
+ ...options,
980
+ inferredTargetScale,
981
+ radius: fastRadius
982
+ }
983
+ );
984
+
985
+ const fastAccept = options.fastAlignmentAccept ?? (options.preciseAlignment ? 0.88 : 0.80);
986
+ if (fastNested && fastNested.score >= fastAccept) {
987
+ return {
988
+ ...fastNested,
989
+ basisU,
990
+ basisV,
991
+ predicted,
992
+ target,
993
+ finderScaleRatio,
994
+ projectiveSignal
995
+ };
996
+ }
997
+
998
+ const precise = options.preciseAlignment === true;
999
+ const localScoreFn = precise ? preciseAlignmentScore : alignmentScore;
1000
+ const localOffsets = precise
1001
+ ? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
1002
+ : [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
1003
+
513
1004
  const centerAt = (offsetU, offsetV) => ({
514
1005
  x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
515
1006
  y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
516
1007
  });
517
- let best = { score: 0, center: predicted, scale: 1, offsetU: 0, offsetV: 0, broadSearch: false };
1008
+ let best = fastNested && fastNested.score > 0
1009
+ ? fastNested
1010
+ : { score: 0, center: predicted, scale: 1, offsetU: 0, offsetV: 0, broadSearch: false };
518
1011
 
519
1012
  const evaluateIntoBest = (offsetU, offsetV, scales, scoreFn, broadSearch = false) => {
520
1013
  const center = centerAt(offsetU, offsetV);
@@ -534,12 +1027,6 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
534
1027
  // prediction even though all three finder patterns remain strong. Use a
535
1028
  // bounded coarse-to-fine search only when the locator geometry signals that
536
1029
  // projective foreshortening is present.
537
- const cornerSkew = Math.max(0, 1 - (triple.orthogonality ?? 1));
538
- const projectiveSignal = Math.max(
539
- Math.max(0, finderScaleRatio - 1),
540
- Math.max(0, (triple.moduleSpread ?? 0) * 1.25),
541
- cornerSkew * 0.75
542
- );
543
1030
  const broadEnabled = options.perspectiveAlignmentRecovery !== false && (
544
1031
  options.perspectiveRecovery === true ||
545
1032
  projectiveSignal >= (options.perspectiveAlignmentSignalThreshold ?? 0.32)
@@ -656,8 +1143,8 @@ function projectedAlignmentScore(binary, width, height, homography, pattern) {
656
1143
  return total ? matches / total : 0;
657
1144
  }
658
1145
 
659
- function scoreAlignmentGrid(binary, width, height, homography, version) {
660
- const patterns = alignmentPatternCentersForVersion(version);
1146
+ function scoreAlignmentGrid(binary, width, height, homography, version, options = {}) {
1147
+ const patterns = alignmentPatternCentersForVersion(version, { profile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5 });
661
1148
  if (!patterns.length) return { score: 1, patternScores: [] };
662
1149
  const patternScores = patterns.map((pattern) => ({
663
1150
  row: pattern.row,
@@ -750,7 +1237,7 @@ function refineHomographyWithAlignmentGrid(
750
1237
  initialGrid,
751
1238
  options = {}
752
1239
  ) {
753
- const patterns = alignmentPatternCentersForVersion(version);
1240
+ const patterns = alignmentPatternCentersForVersion(version, { profile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5 });
754
1241
  if (patterns.length <= 1 || options.alignmentRefinement === false) {
755
1242
  return {
756
1243
  homography: initialHomography,
@@ -805,9 +1292,9 @@ function refineHomographyWithAlignmentGrid(
805
1292
  x: candidate.pattern.col + 0.5,
806
1293
  y: candidate.pattern.row + 0.5
807
1294
  });
808
- // A 3x3 marker carries less evidence than the 5x5 primary and the three
809
- // finder patterns, so it helps average geometry without overpowering them.
810
- weights.push(0.8 + candidate.score * 1.7);
1295
+ // Distributed 5x5 alignment eyes now carry stronger evidence while remaining
1296
+ // below the three primary finder patterns in the projective fit.
1297
+ weights.push(1.15 + candidate.score * 1.95);
811
1298
  pointInfo.push({
812
1299
  row: candidate.pattern.row,
813
1300
  col: candidate.pattern.col,
@@ -825,7 +1312,7 @@ function refineHomographyWithAlignmentGrid(
825
1312
  return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
826
1313
  }
827
1314
 
828
- const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version);
1315
+ const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version, options);
829
1316
  const primaryBefore = primary
830
1317
  ? projectedAlignmentScore(binary, width, height, initialHomography, primary)
831
1318
  : 1;
@@ -948,12 +1435,118 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
948
1435
  for (const triple of triples) {
949
1436
  const legH = Math.hypot(triple.tr.x - triple.tl.x, triple.tr.y - triple.tl.y);
950
1437
  const legV = Math.hypot(triple.bl.x - triple.tl.x, triple.bl.y - triple.tl.y);
951
- const estimatedSize = ((legH + legV) / 2) / triple.moduleMean + 7;
952
- const versions = nearestVersionFromEstimate(estimatedSize, minVersion, maxVersion).slice(0, 9);
1438
+ const moduleH = moduleSizeBetweenFinders(binary, width, height, triple.tl, triple.tr);
1439
+ const moduleV = moduleSizeBetweenFinders(binary, width, height, triple.tl, triple.bl);
1440
+ const estimatedSizeH = legH / Math.max(0.5, moduleH) + 7;
1441
+ const estimatedSizeV = legV / Math.max(0.5, moduleV) + 7;
1442
+ const estimateSpread = Math.abs(estimatedSizeH - estimatedSizeV);
1443
+ const fallbackEstimate = ((legH + legV) / 2) / triple.moduleMean + 7;
1444
+ const estimatedSize = Number.isFinite(estimatedSizeH) && Number.isFinite(estimatedSizeV)
1445
+ ? (estimatedSizeH + estimatedSizeV) / 2
1446
+ : fallbackEstimate;
1447
+ // Wrong finder triples often produce wildly different horizontal/vertical
1448
+ // module counts. Reject only the most implausible sets here; full projective
1449
+ // recovery remains intentionally permissive for real extreme camera angles.
1450
+ const maxEstimateSpread = options.maxDimensionEstimateSpread ?? (options.perspectiveRecovery ? 18 : 10);
1451
+ if (estimateSpread > maxEstimateSpread) continue;
1452
+ const versions = nearestVersionFromEstimate(estimatedSize, minVersion, maxVersion).slice(0, Math.max(1, Math.round(options.versionSearchLimit ?? 9)));
953
1453
 
954
1454
  for (const item of versions) {
955
1455
  const version = item.version;
956
1456
  const size = sizeForVersion(version);
1457
+
1458
+ // Near-front-facing symbols do not need to wait for a fourth locator.
1459
+ // With three reliable finder centres we already have an affine mapping.
1460
+ // Validate that cheap mapping against the distributed 5x5 alignment grid;
1461
+ // when it agrees strongly, return it immediately and let payload decoding
1462
+ // provide the final integrity check. Strongly projective symbols skip this
1463
+ // shortcut and continue into the full alignment/homography path below.
1464
+ const affineProjectiveSignal = Math.max(
1465
+ Math.max(0, triple.moduleSpread ?? 0),
1466
+ Math.max(0, 1 - (triple.orthogonality ?? 1)) * 0.85,
1467
+ Math.max(0, (triple.legRatio ?? 1) - 1) * 0.12
1468
+ );
1469
+ const allowAffineFastPath = options.finderAffineFastPath !== false &&
1470
+ options.preciseAlignment !== true &&
1471
+ options.perspectiveRecovery !== true &&
1472
+ item.error <= (options.finderAffineVersionError ?? 2.6) &&
1473
+ affineProjectiveSignal <= (options.finderAffineMaxProjectiveSignal ?? 0.24);
1474
+
1475
+ if (allowAffineFastPath) {
1476
+ const separation = size - 7;
1477
+ const basisU = {
1478
+ x: (triple.tr.x - triple.tl.x) / separation,
1479
+ y: (triple.tr.y - triple.tl.y) / separation
1480
+ };
1481
+ const basisV = {
1482
+ x: (triple.bl.x - triple.tl.x) / separation,
1483
+ y: (triple.bl.y - triple.tl.y) / separation
1484
+ };
1485
+ const target = primaryAlignmentPatternForVersion(version, {
1486
+ profile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5
1487
+ });
1488
+ const targetX = target.col + 0.5;
1489
+ const targetY = target.row + 0.5;
1490
+ const predictedAlignment = {
1491
+ x: triple.tl.x + basisU.x * (targetX - 3.5) + basisV.x * (targetY - 3.5),
1492
+ y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
1493
+ };
1494
+ const affineDest = [
1495
+ { x: 3.5, y: 3.5 },
1496
+ { x: size - 3.5, y: 3.5 },
1497
+ { x: 3.5, y: size - 3.5 },
1498
+ { x: targetX, y: targetY }
1499
+ ];
1500
+ const affineSrc = [
1501
+ { x: triple.tl.x, y: triple.tl.y },
1502
+ { x: triple.tr.x, y: triple.tr.y },
1503
+ { x: triple.bl.x, y: triple.bl.y },
1504
+ predictedAlignment
1505
+ ];
1506
+ try {
1507
+ const affineHomography = computeHomography(affineSrc, affineDest);
1508
+ const affineGrid = scoreAlignmentGrid(binary, width, height, affineHomography, version, options);
1509
+ if (affineGrid.score >= (options.finderAffineGridThreshold ?? 0.84)) {
1510
+ const versionPenalty = item.error / 4;
1511
+ geometries.push({
1512
+ version,
1513
+ size,
1514
+ sourcePoints: affineSrc,
1515
+ destinationPoints: affineDest,
1516
+ homography: affineHomography,
1517
+ finders: { topLeft: triple.tl, topRight: triple.tr, bottomLeft: triple.bl },
1518
+ alignment: {
1519
+ center: predictedAlignment,
1520
+ predicted: predictedAlignment,
1521
+ score: affineGrid.score,
1522
+ scale: 1,
1523
+ target: { row: target.row, col: target.col },
1524
+ patterns: affineGrid.patternScores.length,
1525
+ gridScore: affineGrid.score,
1526
+ initialGridScore: affineGrid.score,
1527
+ refined: false,
1528
+ refinementPoints: [],
1529
+ patternScores: affineGrid.patternScores,
1530
+ method: "finder-affine"
1531
+ },
1532
+ threshold,
1533
+ finderMethod: `${options.finderMethod ?? "finder"}-affine`,
1534
+ alignmentProfile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
1535
+ estimatedSize,
1536
+ estimatedSizeH,
1537
+ estimatedSizeV,
1538
+ directionalModuleSizeH: moduleH,
1539
+ directionalModuleSizeV: moduleV,
1540
+ score: triple.score * (0.58 + 0.42 * affineGrid.score) / (1 + versionPenalty * 0.08),
1541
+ finderAffineFastPath: true
1542
+ });
1543
+ continue;
1544
+ }
1545
+ } catch {
1546
+ // Full alignment search below remains the authoritative path.
1547
+ }
1548
+ }
1549
+
957
1550
  const alignment = searchAlignment(binary, width, height, triple, version, options);
958
1551
  if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
959
1552
 
@@ -978,7 +1571,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
978
1571
  continue;
979
1572
  }
980
1573
 
981
- const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
1574
+ const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version, options);
982
1575
  const alignmentGridThreshold = options.alignmentGridThreshold ?? 0.68;
983
1576
  const refinementFloor = Math.max(0, alignmentGridThreshold - (options.alignmentRefineCandidateMargin ?? 0.10));
984
1577
  const skipRefinementScore = options.alignmentRefineSkipScore ?? 0.985;
@@ -1031,7 +1624,12 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
1031
1624
  },
1032
1625
  threshold,
1033
1626
  finderMethod: options.finderMethod,
1627
+ alignmentProfile: options.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
1034
1628
  estimatedSize,
1629
+ estimatedSizeH,
1630
+ estimatedSizeV,
1631
+ directionalModuleSizeH: moduleH,
1632
+ directionalModuleSizeV: moduleV,
1035
1633
  score
1036
1634
  });
1037
1635
  }
@@ -1080,6 +1678,7 @@ function pushFinderDiagnostic(options, finderMethod, threshold, finders, geometr
1080
1678
  alignmentScore: geometry.alignment.score,
1081
1679
  alignmentGridScore: geometry.alignment.gridScore,
1082
1680
  finderMethod: geometry.finderMethod,
1681
+ alignmentProfile: geometry.alignmentProfile,
1083
1682
  finders: {
1084
1683
  topLeft: { ...geometry.finders.topLeft },
1085
1684
  topRight: { ...geometry.finders.topRight },
@@ -1105,7 +1704,10 @@ export function detectCodeGeometry(imageData, options = {}) {
1105
1704
  finderMethod: pass.finderMethod,
1106
1705
  perspectiveRecovery: recovery || options.perspectiveRecovery === true,
1107
1706
  alignmentThreshold: recovery ? 0.68 : 0.72,
1108
- alignmentGridThreshold: recovery ? 0.64 : 0.68
1707
+ alignmentGridThreshold: recovery ? 0.64 : 0.68,
1708
+ versionSearchLimit: recovery
1709
+ ? (options.recoveryVersionSearchLimit ?? 9)
1710
+ : (options.fastVersionSearchLimit ?? 4)
1109
1711
  };
1110
1712
  let geometries = geometryCandidatesFromBinary(
1111
1713
  pass.binary,
@@ -1113,9 +1715,38 @@ export function detectCodeGeometry(imageData, options = {}) {
1113
1715
  height,
1114
1716
  finders,
1115
1717
  pass.threshold,
1116
- geometryOptions
1718
+ { ...geometryOptions, alignmentProfile: ALIGNMENT_PROFILE_STANDARD_5 }
1117
1719
  );
1118
1720
 
1721
+ // New format-v6 symbols use 5x5 alignment eyes. Only if that geometry does
1722
+ // not validate do we spend a compatibility attempt on format-v5's compact
1723
+ // 3x3 secondary markers. This keeps the modern path fast.
1724
+ if (!geometries.length && options.legacyAlignmentRecovery !== false && finders.length >= 3) {
1725
+ const legacyMethod = `${pass.finderMethod}-legacy-align`;
1726
+ const legacyOptions = {
1727
+ ...geometryOptions,
1728
+ finderMethod: legacyMethod,
1729
+ alignmentProfile: ALIGNMENT_PROFILE_LEGACY_3
1730
+ };
1731
+ geometries = geometryCandidatesFromBinary(
1732
+ pass.binary,
1733
+ width,
1734
+ height,
1735
+ finders,
1736
+ pass.threshold,
1737
+ legacyOptions
1738
+ );
1739
+ if (geometries.length) {
1740
+ pushFinderDiagnostic(
1741
+ { ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "normal"}-legacy-align` },
1742
+ legacyMethod,
1743
+ pass.threshold,
1744
+ finders,
1745
+ geometries
1746
+ );
1747
+ }
1748
+ }
1749
+
1119
1750
  pushFinderDiagnostic(
1120
1751
  { ...options, width, height },
1121
1752
  pass.finderMethod,
@@ -1162,9 +1793,123 @@ export function detectCodeGeometry(imageData, options = {}) {
1162
1793
  finderMethod: "rgb-value-otsu",
1163
1794
  binary: valueInfo.binary,
1164
1795
  threshold: valueInfo.threshold,
1165
- detector: {}
1796
+ detector: {
1797
+ toleranceScale: options.finderToleranceScale ?? 1.0,
1798
+ moduleSpreadLimit: options.finderModuleSpreadLimit ?? 0.45,
1799
+ minConfirmations: options.finderMinConfirmations ?? 2,
1800
+ diagonalCheck: false
1801
+ }
1166
1802
  }, false);
1803
+ const fastEstimateMatched = fast.geometries.some((geometry) =>
1804
+ Math.abs(sizeForVersion(geometry.version) - geometry.estimatedSize) <= (options.fastDimensionAcceptance ?? 2.0)
1805
+ );
1806
+ if (fast.geometries.length && fastEstimateMatched) {
1807
+ return fast.geometries.slice(0, maxCandidates);
1808
+ }
1809
+
1810
+ // Seeing three finder patterns should immediately unlock perspective-aware
1811
+ // geometry. The previous first pass used stricter near-front-facing triple
1812
+ // limits, so a code could visibly have all three eyes yet still fall through
1813
+ // into color recovery. Reuse the exact same binary/finders with broader
1814
+ // projective geometry before doing any additional pixel processing.
1815
+ if (fast.finders.length >= 3) {
1816
+ const finderMethod = "rgb-value-otsu-projective";
1817
+ const projectiveGeometries = geometryCandidatesFromBinary(
1818
+ valueInfo.binary,
1819
+ width,
1820
+ height,
1821
+ fast.finders,
1822
+ valueInfo.threshold,
1823
+ {
1824
+ ...options,
1825
+ finderMethod,
1826
+ perspectiveRecovery: true,
1827
+ alignmentThreshold: 0.60,
1828
+ alignmentGridThreshold: 0.58,
1829
+ versionSearchLimit: options.fastPerspectiveVersionSearchLimit ?? 4,
1830
+ perspectiveAlignmentFineSeeds: options.fastPerspectiveAlignmentFineSeeds ?? 3,
1831
+ perspectiveAlignmentMaxRadius: options.fastPerspectiveAlignmentMaxRadius ?? 12
1832
+ }
1833
+ );
1834
+ pushFinderDiagnostic(
1835
+ { ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "normal"}-projective` },
1836
+ finderMethod,
1837
+ valueInfo.threshold,
1838
+ fast.finders,
1839
+ projectiveGeometries
1840
+ );
1841
+ if (projectiveGeometries.length) return projectiveGeometries.slice(0, maxCandidates);
1842
+ }
1843
+
1844
+ // Keep the original strict geometry as a fallback if the projective pass did
1845
+ // not produce anything better. This preserves historical behavior while
1846
+ // avoiding an early wrong-version lock-in when the directional size estimate
1847
+ // clearly points at another version.
1167
1848
  if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
1849
+
1850
+ // A local block threshold is part of the normal locator path, not the heavy
1851
+ // color/damage recovery stack. It is especially effective when one finder is
1852
+ // under a shadow or a phone-screen gradient while the other two are bright.
1853
+ // At camera locator resolution this costs far less than escalating to a
1854
+ // high-resolution scan or running full-frame Auto Color.
1855
+ let localBinary = null;
1856
+ if (options.localFinderThreshold !== false && fast.finders.length < 3) {
1857
+ localBinary = hybridLocalBinary(valueInfo.gray, width, height);
1858
+ if (localBinary) {
1859
+ const local = evaluatePass({
1860
+ finderMethod: "rgb-value-hybrid-local",
1861
+ binary: localBinary,
1862
+ threshold: null,
1863
+ detector: {
1864
+ toleranceScale: 1.10,
1865
+ moduleSpreadLimit: 0.58,
1866
+ diagonalCheck: false
1867
+ }
1868
+ }, true);
1869
+ if (local.geometries.length) return local.geometries.slice(0, maxCandidates);
1870
+ }
1871
+ }
1872
+
1873
+ if (options.componentFinderFallback !== false && fast.finders.length < 3) {
1874
+ for (const [binary, method] of [
1875
+ [valueInfo.binary, "rgb-value-component"],
1876
+ [localBinary, "rgb-value-hybrid-component"]
1877
+ ]) {
1878
+ if (!binary) continue;
1879
+ const componentFinders = detectFinderCandidatesByComponents(binary, width, height, options);
1880
+ const merged = clusterFinderCandidates([
1881
+ ...fast.finders.map((finder) => ({ ...finder })),
1882
+ ...componentFinders
1883
+ ], 1);
1884
+ if (merged.length < 3) continue;
1885
+ const componentGeometries = geometryCandidatesFromBinary(
1886
+ binary,
1887
+ width,
1888
+ height,
1889
+ merged,
1890
+ method.includes("hybrid") ? null : valueInfo.threshold,
1891
+ {
1892
+ ...options,
1893
+ finderMethod: method,
1894
+ perspectiveRecovery: true,
1895
+ alignmentThreshold: 0.60,
1896
+ alignmentGridThreshold: 0.58,
1897
+ versionSearchLimit: options.fastPerspectiveVersionSearchLimit ?? 4,
1898
+ perspectiveAlignmentFineSeeds: options.fastPerspectiveAlignmentFineSeeds ?? 3,
1899
+ perspectiveAlignmentMaxRadius: options.fastPerspectiveAlignmentMaxRadius ?? 12
1900
+ }
1901
+ );
1902
+ pushFinderDiagnostic(
1903
+ { ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "normal"}-component` },
1904
+ method,
1905
+ method.includes("hybrid") ? null : valueInfo.threshold,
1906
+ merged,
1907
+ componentGeometries
1908
+ );
1909
+ if (componentGeometries.length) return componentGeometries.slice(0, maxCandidates);
1910
+ }
1911
+ }
1912
+
1168
1913
  if (options.finderRecovery === false) return [];
1169
1914
 
1170
1915
  // If the clean threshold already found exactly two strong locators, try the
@@ -1439,6 +2184,21 @@ export function autoColorImageData(imageData, options = {}) {
1439
2184
  }
1440
2185
 
1441
2186
  function buildAutoColorValueGray(imageData, options = {}) {
2187
+ const canCache = imageData && typeof imageData === "object" && options.cache !== false;
2188
+ const cacheKey = [
2189
+ options.blackClip ?? 0.0001,
2190
+ options.whiteClip ?? "default",
2191
+ options.highlightPercentile ?? 0.95,
2192
+ options.outputHighlight ?? 190,
2193
+ options.analysisInset ?? 0,
2194
+ options.minimumInputRange ?? 72,
2195
+ options.targetSamples ?? "default"
2196
+ ].join(":");
2197
+ if (canCache) {
2198
+ const cached = autoColorGrayCache.get(imageData)?.get(cacheKey);
2199
+ if (cached) return cached;
2200
+ }
2201
+
1442
2202
  const levels = buildAutoColorLevels(imageData, options);
1443
2203
  const gray = new Uint8Array(imageData.width * imageData.height);
1444
2204
  for (let i = 0; i < gray.length; i++) {
@@ -1453,6 +2213,11 @@ function buildAutoColorValueGray(imageData, options = {}) {
1453
2213
  levels.mapChannel(b, 2)
1454
2214
  ));
1455
2215
  }
2216
+ if (canCache) {
2217
+ let entries = autoColorGrayCache.get(imageData);
2218
+ if (!entries) autoColorGrayCache.set(imageData, entries = new Map());
2219
+ entries.set(cacheKey, gray);
2220
+ }
1456
2221
  return gray;
1457
2222
  }
1458
2223