quadqr-js 1.4.2 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FORMAT.md +5 -5
- package/README.md +19 -8
- package/SPECIFICATION.md +12 -12
- package/dist/camera-scanner-worker.js +793 -0
- package/dist/esm/camera-scanner-worker.js +793 -0
- package/dist/esm/geometry.js +25 -10
- package/dist/esm/quadqr.js +843 -62
- package/dist/esm/vision.js +887 -122
- package/dist/esm/wasm.js +85 -12
- package/dist/quadqr.js +1803 -198
- package/dist/quadqr.min.js +2032 -274
- package/dist/wasm/quadqr-core.wasm +0 -0
- package/docs/API.md +13 -5
- package/docs/BROWSER_CDN.md +5 -5
- package/docs/CLI.md +167 -167
- package/docs/COMPRESSION.md +170 -170
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/HIGH_DENSITY_MODE.md +100 -100
- package/docs/NODE.md +159 -159
- package/docs/README.md +62 -62
- package/docs/RELIABILITY_LAB.md +64 -64
- package/docs/TRIANGLE16.md +98 -98
- package/docs/WASM.md +30 -4
- package/package.json +5 -3
package/dist/esm/vision.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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 +
|
|
250
|
+
Math.round(baseThreshold + thresholdOffset),
|
|
123
251
|
8,
|
|
124
252
|
247
|
|
125
253
|
);
|
|
126
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
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 =
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
809
|
-
//
|
|
810
|
-
weights.push(
|
|
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
|
|
952
|
-
const
|
|
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
|
|