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.
@@ -0,0 +1,793 @@
1
+ import {
2
+ CELL_ENCODINGS,
3
+ decodeMatrix,
4
+ scanImageData,
5
+ internals
6
+ } from "./esm/quadqr.js";
7
+ import { autoColorImageData } from "./esm/vision.js";
8
+ import { initWasm } from "./esm/wasm.js";
9
+
10
+ const {
11
+ combineFrameObservations,
12
+ cropImageDataInset,
13
+ observationDataAgreement,
14
+ selectBestFrameObservation
15
+ } = internals;
16
+
17
+ let scanOptions = {};
18
+ let missStreak = 0;
19
+ let cameraGeometryHint = null;
20
+ let cameraGeometryHintMisses = 0;
21
+ let observationHistory = new Map();
22
+ let wasmState = null;
23
+ const frameCanvasPool = new Map();
24
+
25
+ function nowMs() {
26
+ return typeof performance !== "undefined" && typeof performance.now === "function"
27
+ ? performance.now()
28
+ : Date.now();
29
+ }
30
+
31
+ function clampNumber(value, min, max) {
32
+ return Math.max(min, Math.min(max, value));
33
+ }
34
+
35
+ function serializeError(error) {
36
+ return {
37
+ name: error?.name || "Error",
38
+ message: error?.message || String(error),
39
+ stack: error?.stack || "",
40
+ debug: error?.debug ?? null
41
+ };
42
+ }
43
+
44
+ function bestVisionDiagnosticPass(visionDiagnostics) {
45
+ const passes = visionDiagnostics?.passes;
46
+ if (!Array.isArray(passes) || !passes.length) return null;
47
+ return passes.slice().sort((a, b) => {
48
+ const aGeometry = a.geometries?.[0];
49
+ const bGeometry = b.geometries?.[0];
50
+ return (Boolean(bGeometry) - Boolean(aGeometry)) ||
51
+ ((b.finderCount ?? 0) - (a.finderCount ?? 0)) ||
52
+ ((bGeometry?.score ?? 0) - (aGeometry?.score ?? 0));
53
+ })[0];
54
+ }
55
+
56
+ function normalizeFrameDiagnostics(frameDiagnostics, source, width, height, visionDiagnostics) {
57
+ if (!frameDiagnostics || typeof frameDiagnostics !== "object") return;
58
+ frameDiagnostics.scanWidth = width;
59
+ frameDiagnostics.scanHeight = height;
60
+ frameDiagnostics.frameWidth = width;
61
+ frameDiagnostics.frameHeight = height;
62
+ frameDiagnostics.scanRect = { x: 0, y: 0, width, height };
63
+ frameDiagnostics.sourceRect = {
64
+ x: source.x,
65
+ y: source.y,
66
+ width: source.width,
67
+ height: source.height,
68
+ cropped: Boolean(source.cropped)
69
+ };
70
+ frameDiagnostics.vision = visionDiagnostics;
71
+ const bestPass = bestVisionDiagnosticPass(visionDiagnostics);
72
+ frameDiagnostics.bestPass = bestPass;
73
+ frameDiagnostics.finderCount = bestPass?.finderCount ?? 0;
74
+ frameDiagnostics.finders = bestPass?.finders ?? [];
75
+ frameDiagnostics.finderMethod = bestPass?.finderMethod ?? null;
76
+ frameDiagnostics.finderPasses = Array.isArray(visionDiagnostics?.passes)
77
+ ? visionDiagnostics.passes.map((pass) => ({
78
+ method: pass.finderMethod ?? pass.label,
79
+ finderCount: pass.finderCount ?? 0,
80
+ threshold: pass.threshold,
81
+ geometryCount: pass.geometries?.length ?? 0
82
+ }))
83
+ : [];
84
+ frameDiagnostics.geometry = bestPass?.geometries?.[0] ?? null;
85
+ }
86
+
87
+ function attachGeometryDiagnostic(frameDiagnostics, result) {
88
+ if (!frameDiagnostics || !result?.geometry || frameDiagnostics.geometry) return;
89
+ const geometry = result.geometry;
90
+ frameDiagnostics.geometry = geometry;
91
+ frameDiagnostics.geometryReused = Boolean(result.geometryReused);
92
+ if (geometry.finders) {
93
+ const reusedFinders = [
94
+ geometry.finders.topLeft,
95
+ geometry.finders.topRight,
96
+ geometry.finders.bottomLeft
97
+ ].filter(Boolean);
98
+ frameDiagnostics.finders = reusedFinders;
99
+ frameDiagnostics.finderCount = reusedFinders.length;
100
+ if (result.geometryReused) frameDiagnostics.finderMethod = "geometry-reuse";
101
+ }
102
+ }
103
+
104
+ function scaleGeometryForFrame(geometry, fromFrame, toFrame) {
105
+ if (!geometry?.homography || !fromFrame?.scanWidth || !toFrame?.scanWidth) return null;
106
+ const sx = toFrame.scanWidth / fromFrame.scanWidth;
107
+ const sy = toFrame.scanHeight / fromFrame.scanHeight;
108
+ if (!Number.isFinite(sx) || !Number.isFinite(sy) || sx <= 0 || sy <= 0) return null;
109
+ const moduleScale = Math.sqrt(sx * sy);
110
+ const scalePoint = (point) => point ? { ...point, x: point.x * sx, y: point.y * sy } : point;
111
+ const scaleFinder = (finder) => finder ? {
112
+ ...finder,
113
+ x: finder.x * sx,
114
+ y: finder.y * sy,
115
+ moduleSize: finder.moduleSize * moduleScale
116
+ } : finder;
117
+ const h = geometry.homography;
118
+ const scaled = {
119
+ ...geometry,
120
+ homography: [
121
+ h[0] * sx, h[1] * sx, h[2] * sx,
122
+ h[3] * sy, h[4] * sy, h[5] * sy,
123
+ h[6], h[7], h[8]
124
+ ],
125
+ sourcePoints: Array.isArray(geometry.sourcePoints)
126
+ ? geometry.sourcePoints.map(scalePoint)
127
+ : geometry.sourcePoints,
128
+ finders: geometry.finders ? {
129
+ topLeft: scaleFinder(geometry.finders.topLeft),
130
+ topRight: scaleFinder(geometry.finders.topRight),
131
+ bottomLeft: scaleFinder(geometry.finders.bottomLeft)
132
+ } : geometry.finders
133
+ };
134
+ if (geometry.alignment) {
135
+ scaled.alignment = {
136
+ ...geometry.alignment,
137
+ center: scalePoint(geometry.alignment.center),
138
+ predicted: scalePoint(geometry.alignment.predicted)
139
+ };
140
+ }
141
+ return scaled;
142
+ }
143
+
144
+ function updateCameraGeometryHint(frameDiagnostics, result = null) {
145
+ if (scanOptions.cameraGeometryReuse === false) {
146
+ cameraGeometryHint = null;
147
+ return;
148
+ }
149
+ const geometry = result?.geometry ?? frameDiagnostics?.geometry ?? null;
150
+ if (geometry?.homography && Number.isInteger(geometry.version)) {
151
+ cameraGeometryHint = geometry;
152
+ cameraGeometryHintMisses = 0;
153
+ return;
154
+ }
155
+ if (cameraGeometryHint) {
156
+ cameraGeometryHintMisses++;
157
+ const maxMisses = Math.max(1, Math.round(scanOptions.cameraGeometryReuseMaxMisses ?? 5));
158
+ if (cameraGeometryHintMisses >= maxMisses) {
159
+ cameraGeometryHint = null;
160
+ cameraGeometryHintMisses = 0;
161
+ }
162
+ }
163
+ }
164
+
165
+ function tryMultiFrameDecode(observations) {
166
+ if (scanOptions.multiFrame === false) return null;
167
+ const multiFrameWindow = Math.max(2, Math.min(8, Math.round(scanOptions.multiFrameWindow ?? 4)));
168
+ const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(scanOptions.multiFrameMinFrames ?? 2)));
169
+ const best = selectBestFrameObservation(observations);
170
+ if (!best) return null;
171
+ const trackKey = `${best.version}:${best.cellEncoding ?? "auto"}`;
172
+ let history = observationHistory.get(trackKey) ?? [];
173
+
174
+ if (history.length) {
175
+ const agreement = observationDataAgreement(history[history.length - 1], best);
176
+ const minimumAgreement = best.cellEncoding === CELL_ENCODINGS.TRIANGLE16
177
+ ? (scanOptions.multiFrameMinAgreementHighDensity ?? 0.58)
178
+ : (scanOptions.multiFrameMinAgreement ?? 0.62);
179
+ if (agreement > 0 && agreement < minimumAgreement) history = [];
180
+ }
181
+
182
+ history.push(best);
183
+ while (history.length > multiFrameWindow) history.shift();
184
+ observationHistory.set(trackKey, history);
185
+ if (history.length < multiFrameMinFrames) return null;
186
+
187
+ const combined = combineFrameObservations(history);
188
+ if (!combined) return null;
189
+ try {
190
+ const decoded = decodeMatrix(combined.matrix, {
191
+ structureTolerance: scanOptions.structureTolerance ?? 0.20,
192
+ cellConfidence: combined.confidence,
193
+ cellAlternatives: combined.alternatives,
194
+ cellEncodingHint: best.cellEncoding ?? undefined,
195
+ maxErasureConfidence: scanOptions.maxErasureConfidence,
196
+ softDecoding: scanOptions.softDecoding
197
+ });
198
+ if (decoded.version !== best.version) return null;
199
+ return {
200
+ ...decoded,
201
+ perspectiveCorrected: Boolean(best.geometry),
202
+ colorCalibrated: true,
203
+ colorNormalization: "multi-frame-confidence-fusion",
204
+ samplingMode: "multi-frame-confidence-fusion",
205
+ multiFrameCombined: history.length,
206
+ multiFrameAgreement: combined.frameAgreement,
207
+ multiFrameMode: "confidence-fusion",
208
+ geometry: best.geometry,
209
+ observedPalette: best.observedPalette,
210
+ averageCellConfidence: best.averageCellConfidence,
211
+ lowConfidenceCells: best.lowConfidenceCells
212
+ };
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function pooledFrameCanvas(cap, width, height) {
219
+ let entry = frameCanvasPool.get(cap);
220
+ if (!entry) {
221
+ const canvas = new OffscreenCanvas(width, height);
222
+ const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
223
+ entry = { canvas, ctx };
224
+ frameCanvasPool.set(cap, entry);
225
+ } else if (entry.canvas.width !== width || entry.canvas.height !== height) {
226
+ entry.canvas.width = width;
227
+ entry.canvas.height = height;
228
+ }
229
+ return entry;
230
+ }
231
+
232
+ function makeCanvasFrameProvider(bitmap, source) {
233
+ // Cache ImageData per resolution for the current camera frame, and reuse the
234
+ // underlying OffscreenCanvas/context across frames. This avoids allocating
235
+ // fresh canvases at camera frame rate without changing any scanner stage.
236
+ const frames = new Map();
237
+ return (maxDimension) => {
238
+ const cap = Math.max(1, Math.round(maxDimension));
239
+ if (frames.has(cap)) return frames.get(cap);
240
+ const scale = Math.min(1, cap / Math.max(source.width, source.height));
241
+ const width = Math.max(1, Math.round(source.width * scale));
242
+ const height = Math.max(1, Math.round(source.height * scale));
243
+ const { ctx } = pooledFrameCanvas(cap, width, height);
244
+ if ("imageSmoothingEnabled" in ctx) ctx.imageSmoothingEnabled = true;
245
+ if ("imageSmoothingQuality" in ctx) ctx.imageSmoothingQuality = "high";
246
+ ctx.drawImage(bitmap, source.x, source.y, source.width, source.height, 0, 0, width, height);
247
+ const imageData = ctx.getImageData(0, 0, width, height);
248
+ const frame = { imageData, scanWidth: width, scanHeight: height, source: { ...source } };
249
+ frames.set(cap, frame);
250
+ return frame;
251
+ };
252
+ }
253
+
254
+ function scanCapturedFrame(frame, options, observations, frameDiagnostics) {
255
+ const visionDiagnostics = frameDiagnostics ? { passes: [] } : null;
256
+ try {
257
+ let result = scanImageData(frame.imageData, {
258
+ ...options,
259
+ _visionDiagnostics: visionDiagnostics,
260
+ _observationCollector: observations
261
+ });
262
+ normalizeFrameDiagnostics(frameDiagnostics, frame.source, frame.scanWidth, frame.scanHeight, visionDiagnostics);
263
+ attachGeometryDiagnostic(frameDiagnostics, result);
264
+ if (frame.source.cropped) {
265
+ result = {
266
+ ...result,
267
+ cameraVisibleCrop: true,
268
+ cameraSourceRect: {
269
+ x: frame.source.x,
270
+ y: frame.source.y,
271
+ width: frame.source.width,
272
+ height: frame.source.height
273
+ }
274
+ };
275
+ }
276
+ return result;
277
+ } catch (error) {
278
+ normalizeFrameDiagnostics(frameDiagnostics, frame.source, frame.scanWidth, frame.scanHeight, visionDiagnostics);
279
+ throw error;
280
+ }
281
+ }
282
+
283
+ function makeFrameMeta(frame, diagnostic, enhancement = null) {
284
+ return {
285
+ imageData: frame.imageData,
286
+ scanWidth: frame.scanWidth,
287
+ scanHeight: frame.scanHeight,
288
+ sourceRect: frame.source ? { ...frame.source } : null,
289
+ enhancedImageData: enhancement?.imageData ?? null,
290
+ enhancedRect: enhancement?.rect ? { ...enhancement.rect } : null,
291
+ enhancement: enhancement?.meta ? { ...enhancement.meta } : null,
292
+ diagnostic
293
+ };
294
+ }
295
+
296
+ function transferableBuffers(frameMeta) {
297
+ const buffers = [];
298
+ const base = frameMeta?.imageData?.data?.buffer;
299
+ const enhanced = frameMeta?.enhancedImageData?.data?.buffer;
300
+ if (base instanceof ArrayBuffer) buffers.push(base);
301
+ if (enhanced instanceof ArrayBuffer && enhanced !== base) buffers.push(enhanced);
302
+ return buffers;
303
+ }
304
+
305
+ function pushDiagnostic(events, event) {
306
+ events.push(event);
307
+ }
308
+
309
+ function processFrame(bitmap, source, frameNumber) {
310
+ const events = [];
311
+ const observations = [];
312
+ const frameDiagnostics = {};
313
+ const frameStarted = nowMs();
314
+ const baseCameraMaxDimension = Math.max(480, Math.round(scanOptions.maxDimension ?? 640));
315
+ const cameraHighResolutionMaxDimension = Math.max(
316
+ baseCameraMaxDimension,
317
+ Math.round(scanOptions.cameraHighResolutionMaxDimension ?? 960)
318
+ );
319
+ const cameraAutoColorEvery = Math.max(1, Math.round(scanOptions.cameraAutoColorEvery ?? 1));
320
+ const cameraAutoEnhanceEvery = Math.max(1, Math.round(scanOptions.cameraAutoEnhanceEvery ?? 2));
321
+ const cameraFinderRecoveryEvery = Math.max(1, Math.round(scanOptions.cameraFinderRecoveryEvery ?? 2));
322
+ const cameraHighResolutionEvery = Math.max(1, Math.round(scanOptions.cameraHighResolutionEvery ?? 2));
323
+ const getFrame = makeCanvasFrameProvider(bitmap, source);
324
+ const baseFrame = getFrame(baseCameraMaxDimension);
325
+
326
+ let allowFinderRecovery = false;
327
+ let allowAutoEnhance = false;
328
+ try {
329
+ allowFinderRecovery = scanOptions.finderRecovery !== false &&
330
+ missStreak > 0 && ((missStreak - 1) % cameraFinderRecoveryEvery === 0);
331
+ allowAutoEnhance = scanOptions.autoEnhanceRecovery !== false &&
332
+ missStreak > 0 && ((missStreak - 1) % cameraAutoEnhanceEvery === 0);
333
+ const method = allowAutoEnhance
334
+ ? "progressive-color-recovery"
335
+ : (allowFinderRecovery ? "finder-recovery" : "fast-scan");
336
+
337
+ const result = scanCapturedFrame(baseFrame, {
338
+ ...scanOptions,
339
+ _diagnosticLabel: method,
340
+ finderRecovery: allowFinderRecovery,
341
+ autoEnhanceRecovery: allowAutoEnhance,
342
+ autoEnhanceWhenNoGeometry: allowAutoEnhance,
343
+ fullFrameAutoEnhanceRecovery: scanOptions.fullFrameAutoEnhanceRecovery ?? false,
344
+ _geometryHints: scanOptions.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint]
345
+ }, observations, frameDiagnostics);
346
+
347
+ const elapsedMs = nowMs() - frameStarted;
348
+ updateCameraGeometryHint(frameDiagnostics, result);
349
+ pushDiagnostic(events, {
350
+ type: "frame",
351
+ state: "decoded",
352
+ method: result.recoveryMode ?? result.samplingMode ?? method,
353
+ elapsedMs,
354
+ missStreak,
355
+ ...frameDiagnostics
356
+ });
357
+ pushDiagnostic(events, {
358
+ type: "success",
359
+ state: "decoded",
360
+ method: result.recoveryMode ?? result.samplingMode ?? method,
361
+ elapsedMs,
362
+ message: `Decoded v${result.version} · ECC ${result.eccLevel} · ${Math.round(elapsedMs)} ms`,
363
+ ...frameDiagnostics
364
+ });
365
+ missStreak = 0;
366
+ observationHistory.clear();
367
+ return {
368
+ ok: true,
369
+ result,
370
+ frameMeta: makeFrameMeta(baseFrame, frameDiagnostics),
371
+ diagnostics: events,
372
+ elapsedMs,
373
+ missStreak
374
+ };
375
+ } catch (error) {
376
+ missStreak++;
377
+ updateCameraGeometryHint(frameDiagnostics);
378
+ const fastElapsedMs = nowMs() - frameStarted;
379
+ pushDiagnostic(events, {
380
+ type: "frame",
381
+ state: "miss",
382
+ method: allowAutoEnhance
383
+ ? "progressive-color-recovery"
384
+ : (allowFinderRecovery ? "finder-recovery" : "fast-scan"),
385
+ elapsedMs: fastElapsedMs,
386
+ missStreak,
387
+ error: error?.message ?? String(error),
388
+ ...frameDiagnostics
389
+ });
390
+
391
+ // The continuous camera engine can dedicate one worker to fresh-frame
392
+ // acquisition. In fast mode we intentionally stop here after the normal
393
+ // detector/decode attempt. A second worker runs the complete recovery
394
+ // stack in parallel, so Auto Color, high-resolution perspective recovery,
395
+ // multi-frame fusion, and damaged-code recovery can never block the next
396
+ // fresh camera frame. This changes scheduling only, not scanner capability.
397
+ if (scanOptions.cameraPipelineMode === "fast") {
398
+ return {
399
+ ok: false,
400
+ error: serializeError(error),
401
+ diagnostics: events,
402
+ elapsedMs: nowMs() - frameStarted,
403
+ missStreak,
404
+ fastPipeline: true
405
+ };
406
+ }
407
+
408
+ const shouldTryHighResolution = scanOptions.cameraHighResolutionRecovery !== false &&
409
+ cameraHighResolutionMaxDimension > baseCameraMaxDimension &&
410
+ (frameDiagnostics?.finderCount ?? 0) >= (scanOptions.cameraHighResolutionMinFinders ?? 2) &&
411
+ ((missStreak - 1) % cameraHighResolutionEvery === 0);
412
+ if (shouldTryHighResolution) {
413
+ const highResolutionObservations = [];
414
+ const highResolutionDiagnostics = {};
415
+ pushDiagnostic(events, {
416
+ type: "method",
417
+ state: "trying",
418
+ method: "high-resolution-geometry-recovery",
419
+ message: `Finder geometry detected · refining detail at up to ${cameraHighResolutionMaxDimension}px`,
420
+ ...frameDiagnostics
421
+ });
422
+ try {
423
+ const recoveryStarted = nowMs();
424
+ const highResolutionFrame = getFrame(cameraHighResolutionMaxDimension);
425
+ const scaledGeometry = scaleGeometryForFrame(frameDiagnostics?.geometry, baseFrame, highResolutionFrame);
426
+ let recovered = null;
427
+
428
+ // If the low-resolution locator already established projective geometry,
429
+ // do not make the detailed frame rediscover the eyes. Scale the
430
+ // homography and sample/decode immediately. If that fast reuse misses,
431
+ // fall through to the complete high-detail detector/recovery path.
432
+ if (scaledGeometry) {
433
+ try {
434
+ recovered = scanCapturedFrame(highResolutionFrame, {
435
+ ...scanOptions,
436
+ _diagnosticLabel: "high-resolution-geometry-reuse",
437
+ _geometryHints: [scaledGeometry],
438
+ _geometryHintOnly: true,
439
+ finderRecovery: false,
440
+ autoEnhanceRecovery: false,
441
+ fullFrameAutoEnhanceRecovery: false
442
+ }, highResolutionObservations, highResolutionDiagnostics);
443
+ } catch {
444
+ recovered = null;
445
+ }
446
+ }
447
+
448
+ if (!recovered) {
449
+ recovered = scanCapturedFrame(highResolutionFrame, {
450
+ ...scanOptions,
451
+ _diagnosticLabel: "high-resolution-geometry-recovery",
452
+ finderRecovery: true,
453
+ autoEnhanceRecovery: false,
454
+ fullFrameAutoEnhanceRecovery: false
455
+ }, highResolutionObservations, highResolutionDiagnostics);
456
+ }
457
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
458
+ pushDiagnostic(events, {
459
+ type: "success",
460
+ state: "decoded",
461
+ method: recovered.geometryReused ? "high-resolution-geometry-reuse" : "high-resolution-geometry-recovery",
462
+ elapsedMs: recoveryElapsedMs,
463
+ message: `Detail retry decoded v${recovered.version} · ${Math.round(recoveryElapsedMs)} ms`,
464
+ ...highResolutionDiagnostics
465
+ });
466
+ missStreak = 0;
467
+ observationHistory.clear();
468
+ return {
469
+ ok: true,
470
+ result: {
471
+ ...recovered,
472
+ cameraHighResolutionRecovery: true,
473
+ cameraProgressiveRecovery: true,
474
+ recoveryMode: recovered.recoveryMode ?? (recovered.geometryReused ? "high-resolution-geometry-reuse" : "high-resolution-geometry-recovery")
475
+ },
476
+ frameMeta: makeFrameMeta(highResolutionFrame, highResolutionDiagnostics),
477
+ diagnostics: events,
478
+ elapsedMs: nowMs() - frameStarted,
479
+ missStreak
480
+ };
481
+ } catch (highResolutionError) {
482
+ observations.push(...highResolutionObservations);
483
+ pushDiagnostic(events, {
484
+ type: "method",
485
+ state: "failed",
486
+ method: "high-resolution-geometry-recovery",
487
+ message: `High-detail geometry retry did not decode${highResolutionDiagnostics?.finderCount != null ? ` · ${highResolutionDiagnostics.finderCount} finder(s)` : ""}`,
488
+ error: highResolutionError?.message ?? String(highResolutionError),
489
+ ...(highResolutionDiagnostics ?? frameDiagnostics)
490
+ });
491
+ }
492
+ }
493
+
494
+ const shouldTryCameraAutoColor = scanOptions.cameraAutoColorRecovery !== false &&
495
+ baseFrame.imageData &&
496
+ ((missStreak - 1) % cameraAutoColorEvery === 0);
497
+ if (shouldTryCameraAutoColor) {
498
+ const requestedCrops = Array.isArray(scanOptions.cameraAutoColorCropInsets)
499
+ ? scanOptions.cameraAutoColorCropInsets
500
+ : [0.08, 0.16, 0.22, 0];
501
+ const cropInsets = [];
502
+ for (const value of requestedCrops) {
503
+ const inset = clampNumber(Number(value), 0, 0.30);
504
+ if (!cropInsets.some((item) => Math.abs(item - inset) < 0.001)) cropInsets.push(inset);
505
+ }
506
+ const explicitAnalysisInsets = Array.isArray(scanOptions.cameraAutoColorAnalysisInsets)
507
+ ? scanOptions.cameraAutoColorAnalysisInsets
508
+ : null;
509
+
510
+ pushDiagnostic(events, {
511
+ type: "method",
512
+ state: "trying",
513
+ method: "camera-auto-color",
514
+ message: `Fast scan failed · QuadQR Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
515
+ ...frameDiagnostics
516
+ });
517
+
518
+ for (let profileIndex = 0; profileIndex < cropInsets.length; profileIndex++) {
519
+ const cropInset = cropInsets[profileIndex];
520
+ const cropped = cropImageDataInset(baseFrame.imageData, cropInset);
521
+ const defaultAnalysisInsets = [0.10, 0.08, 0.04, 0.10];
522
+ const analysisInset = clampNumber(
523
+ Number(explicitAnalysisInsets?.[profileIndex]
524
+ ?? scanOptions.cameraAutoColorAnalysisInset
525
+ ?? defaultAnalysisInsets[Math.min(profileIndex, defaultAnalysisInsets.length - 1)]),
526
+ 0,
527
+ 0.30
528
+ );
529
+ const autoColorObservations = [];
530
+ const autoColorVisionDiagnostics = { passes: [] };
531
+ const autoColorFrameDiagnostics = {};
532
+ const cropLabel = cropInset ? `${Math.round(cropInset * 100)}pct-crop` : "full";
533
+ const profileName = `camera-auto-color-${cropLabel}`;
534
+ try {
535
+ const recoveryStarted = nowMs();
536
+ const correctedFrame = autoColorImageData(cropped.imageData, {
537
+ blackClip: scanOptions.cameraAutoColorBlackClip ?? 0.0001,
538
+ whiteClip: scanOptions.cameraAutoColorWhiteClip ?? 0.004,
539
+ highlightPercentile: scanOptions.cameraAutoColorHighlightPercentile ?? 0.95,
540
+ outputHighlight: scanOptions.cameraAutoColorOutputHighlight ?? 190,
541
+ analysisInset,
542
+ minimumInputRange: scanOptions.cameraAutoColorMinimumInputRange ?? 72,
543
+ targetSamples: scanOptions.cameraAutoColorTargetSamples ?? 90000
544
+ });
545
+ const recovered = scanImageData(correctedFrame, {
546
+ ...scanOptions,
547
+ _diagnosticLabel: profileName,
548
+ _visionDiagnostics: autoColorVisionDiagnostics,
549
+ finderRecovery: true,
550
+ autoEnhanceRecovery: false,
551
+ fullFrameAutoEnhanceRecovery: false,
552
+ _observationCollector: autoColorObservations
553
+ });
554
+ normalizeFrameDiagnostics(
555
+ autoColorFrameDiagnostics,
556
+ baseFrame.source,
557
+ correctedFrame.width,
558
+ correctedFrame.height,
559
+ autoColorVisionDiagnostics
560
+ );
561
+ autoColorFrameDiagnostics.frameWidth = baseFrame.scanWidth;
562
+ autoColorFrameDiagnostics.frameHeight = baseFrame.scanHeight;
563
+ autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
564
+ autoColorFrameDiagnostics.autoColorCropInset = cropInset;
565
+ autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
566
+ attachGeometryDiagnostic(autoColorFrameDiagnostics, recovered);
567
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
568
+ pushDiagnostic(events, {
569
+ type: "frame",
570
+ state: "decoded",
571
+ method: profileName,
572
+ elapsedMs: recoveryElapsedMs,
573
+ missStreak,
574
+ ...autoColorFrameDiagnostics
575
+ });
576
+ pushDiagnostic(events, {
577
+ type: "success",
578
+ state: "decoded",
579
+ method: "camera-auto-color",
580
+ elapsedMs: recoveryElapsedMs,
581
+ message: `QuadQR Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
582
+ ...autoColorFrameDiagnostics
583
+ });
584
+ missStreak = 0;
585
+ observationHistory.clear();
586
+ return {
587
+ ok: true,
588
+ result: {
589
+ ...recovered,
590
+ autoColorCorrected: true,
591
+ cameraProgressiveRecovery: true,
592
+ recoveryMode: "camera-auto-color",
593
+ cameraAutoColorCropInset: cropInset,
594
+ cameraAutoColorAnalysisInset: analysisInset
595
+ },
596
+ frameMeta: makeFrameMeta(baseFrame, autoColorFrameDiagnostics, {
597
+ imageData: correctedFrame,
598
+ rect: cropped.rect,
599
+ meta: { method: "camera-auto-color", cropInset, analysisInset }
600
+ }),
601
+ diagnostics: events,
602
+ elapsedMs: nowMs() - frameStarted,
603
+ missStreak
604
+ };
605
+ } catch (recoveryError) {
606
+ observations.push(...autoColorObservations);
607
+ normalizeFrameDiagnostics(
608
+ autoColorFrameDiagnostics,
609
+ baseFrame.source,
610
+ cropped.imageData.width,
611
+ cropped.imageData.height,
612
+ autoColorVisionDiagnostics
613
+ );
614
+ autoColorFrameDiagnostics.frameWidth = baseFrame.scanWidth;
615
+ autoColorFrameDiagnostics.frameHeight = baseFrame.scanHeight;
616
+ autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
617
+ autoColorFrameDiagnostics.autoColorCropInset = cropInset;
618
+ autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
619
+ pushDiagnostic(events, {
620
+ type: "frame",
621
+ state: "miss",
622
+ method: profileName,
623
+ elapsedMs: nowMs() - frameStarted,
624
+ missStreak,
625
+ error: recoveryError?.message ?? String(recoveryError),
626
+ ...autoColorFrameDiagnostics
627
+ });
628
+ pushDiagnostic(events, {
629
+ type: "method",
630
+ state: "failed",
631
+ method: profileName,
632
+ message: `QuadQR Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
633
+ ...autoColorFrameDiagnostics
634
+ });
635
+ }
636
+ }
637
+ pushDiagnostic(events, {
638
+ type: "method",
639
+ state: "failed",
640
+ method: "camera-auto-color",
641
+ message: "All camera QuadQR Auto Color profiles failed · continuing deeper recovery",
642
+ ...frameDiagnostics
643
+ });
644
+ }
645
+
646
+ if (!allowAutoEnhance && scanOptions.autoEnhanceRecovery !== false) {
647
+ const strongObservation = selectBestFrameObservation(observations);
648
+ if (strongObservation) {
649
+ pushDiagnostic(events, {
650
+ type: "method",
651
+ state: "trying",
652
+ method: "qr-region-auto-enhance",
653
+ message: "Fast decode failed · trying QR-region Auto Tone / Contrast / Color",
654
+ ...frameDiagnostics
655
+ });
656
+ try {
657
+ const recoveryStarted = nowMs();
658
+ const recoveryObservations = [];
659
+ const recoveryVisionDiagnostics = { passes: [] };
660
+ const recovered = scanImageData(baseFrame.imageData, {
661
+ ...scanOptions,
662
+ _diagnosticLabel: "qr-region-auto-enhance",
663
+ _visionDiagnostics: recoveryVisionDiagnostics,
664
+ autoEnhanceRecovery: true,
665
+ autoEnhanceWhenNoGeometry: false,
666
+ fullFrameAutoEnhanceRecovery: false,
667
+ _observationCollector: recoveryObservations
668
+ });
669
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
670
+ pushDiagnostic(events, {
671
+ type: "success",
672
+ state: "decoded",
673
+ method: recovered.recoveryMode ?? recovered.samplingMode ?? "qr-region-auto-enhance",
674
+ elapsedMs: recoveryElapsedMs,
675
+ message: `Recovery decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
676
+ ...frameDiagnostics
677
+ });
678
+ missStreak = 0;
679
+ observationHistory.clear();
680
+ return {
681
+ ok: true,
682
+ result: { ...recovered, cameraProgressiveRecovery: true },
683
+ frameMeta: makeFrameMeta(baseFrame, frameDiagnostics),
684
+ diagnostics: events,
685
+ elapsedMs: nowMs() - frameStarted,
686
+ missStreak
687
+ };
688
+ } catch {
689
+ pushDiagnostic(events, {
690
+ type: "method",
691
+ state: "failed",
692
+ method: "qr-region-auto-enhance",
693
+ message: "QR-region color recovery did not decode · trying multi-frame ECC",
694
+ ...frameDiagnostics
695
+ });
696
+ }
697
+ }
698
+ }
699
+
700
+ const combined = tryMultiFrameDecode(observations);
701
+ if (combined) {
702
+ pushDiagnostic(events, {
703
+ type: "success",
704
+ state: "decoded",
705
+ method: "multi-frame-confidence-fusion",
706
+ message: `Multi-frame confidence fusion decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
707
+ ...frameDiagnostics
708
+ });
709
+ missStreak = 0;
710
+ return {
711
+ ok: true,
712
+ result: combined,
713
+ frameMeta: makeFrameMeta(baseFrame, frameDiagnostics),
714
+ diagnostics: events,
715
+ elapsedMs: nowMs() - frameStarted,
716
+ missStreak
717
+ };
718
+ }
719
+
720
+ return {
721
+ ok: false,
722
+ error: serializeError(error),
723
+ diagnostics: events,
724
+ elapsedMs: nowMs() - frameStarted,
725
+ missStreak
726
+ };
727
+ } finally {
728
+ try { bitmap.close(); } catch {}
729
+ }
730
+ }
731
+
732
+ async function initialize(options) {
733
+ scanOptions = options ?? {};
734
+ missStreak = 0;
735
+ cameraGeometryHint = null;
736
+ cameraGeometryHintMisses = 0;
737
+ observationHistory = new Map();
738
+ try {
739
+ wasmState = await initWasm();
740
+ } catch {
741
+ wasmState = null;
742
+ }
743
+ return {
744
+ worker: true,
745
+ wasm: wasmState,
746
+ offscreenCanvas: typeof OffscreenCanvas === "function"
747
+ };
748
+ }
749
+
750
+ self.addEventListener("message", async (event) => {
751
+ const message = event.data ?? {};
752
+ const id = message.id;
753
+ if (!id) return;
754
+ try {
755
+ if (message.type === "init") {
756
+ const result = await initialize(message.options ?? {});
757
+ self.postMessage({ id, ok: true, type: "init", result });
758
+ return;
759
+ }
760
+ if (message.type === "reset") {
761
+ missStreak = 0;
762
+ cameraGeometryHint = null;
763
+ cameraGeometryHintMisses = 0;
764
+ observationHistory.clear();
765
+ self.postMessage({ id, ok: true, type: "reset" });
766
+ return;
767
+ }
768
+ if (message.type === "scan" || message.type === "scan-full") {
769
+ if (!(message.bitmap instanceof ImageBitmap)) throw new Error("Camera worker expected an ImageBitmap frame.");
770
+ if (typeof OffscreenCanvas !== "function") throw new Error("OffscreenCanvas is unavailable in the camera worker.");
771
+ const previousOptions = scanOptions;
772
+ if (message.type === "scan-full") {
773
+ // Safety fallback for browsers that allow one module worker but reject
774
+ // creation of the second recovery worker. Run the exact full pipeline
775
+ // in the existing worker rather than silently losing recovery power.
776
+ scanOptions = { ...previousOptions, ...(message.options ?? {}), cameraPipelineMode: "full" };
777
+ }
778
+ let result;
779
+ try {
780
+ result = processFrame(message.bitmap, message.source, message.frame ?? 0);
781
+ } finally {
782
+ scanOptions = previousOptions;
783
+ }
784
+ const transfers = result.ok ? transferableBuffers(result.frameMeta) : [];
785
+ self.postMessage({ id, ok: true, type: message.type, result }, transfers);
786
+ return;
787
+ }
788
+ throw new Error(`Unknown camera worker message: ${message.type}`);
789
+ } catch (error) {
790
+ try { message.bitmap?.close?.(); } catch {}
791
+ self.postMessage({ id, ok: false, type: message.type, error: serializeError(error) });
792
+ }
793
+ });