@gueshbzh-perso/scanner 0.8.0 → 0.10.0
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/README.md +15 -2
- package/dist/core.d.ts +12 -4
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +24 -11
- package/dist/core.js.map +1 -1
- package/dist/engine.d.ts +13 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +24 -4
- package/dist/engine.js.map +1 -1
- package/dist/native.d.ts +43 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +107 -0
- package/dist/native.js.map +1 -0
- package/dist/overlay.js +2 -1
- package/dist/overlay.js.map +1 -1
- package/dist/reader.d.ts +40 -1
- package/dist/reader.d.ts.map +1 -1
- package/dist/reader.js +186 -30
- package/dist/reader.js.map +1 -1
- package/dist/types.d.ts +19 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/wasm/scanner_wasm.d.ts +20 -0
- package/dist/wasm/scanner_wasm.js +53 -0
- package/dist/wasm/scanner_wasm_bg.wasm +0 -0
- package/dist/wasm/scanner_wasm_bg.wasm.d.ts +2 -0
- package/package.json +1 -1
- package/src/core.ts +24 -11
- package/src/engine.ts +43 -5
- package/src/native.ts +140 -0
- package/src/overlay.ts +2 -1
- package/src/reader.ts +205 -34
- package/src/types.ts +19 -2
package/src/core.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// - `SessionCore`: the session. Cheap calls (`plan`, `commit`), main thread.
|
|
7
7
|
// - `DecoderCore`: the expensive decoding, one per Web Worker.
|
|
8
8
|
// The inline engine (no Worker support) uses both on the main thread.
|
|
9
|
+
// Codes read by the browser's native `BarcodeDetector` enter the session with
|
|
10
|
+
// `SessionCore.commitExternal` (see `native.ts`).
|
|
9
11
|
import init, { WasmFrameDecoder, WasmScanner, WasmScanSession, version } from './wasm/scanner_wasm.js';
|
|
10
12
|
import type { FramePlan, FrameReport, PixelFormat, ScanResult, WasmScanConfig } from './wasm/scanner_wasm.js';
|
|
11
13
|
import type { Detection, EngineOptions } from './types.js';
|
|
@@ -72,6 +74,12 @@ export function toDetection(r: ScanResult): Detection {
|
|
|
72
74
|
return d;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
function toScanResult(d: Detection): ScanResult {
|
|
78
|
+
const r: ScanResult = { text: d.text, format: d.format, points: d.points, timestampMs: d.timestamp };
|
|
79
|
+
if (d.rawBytes) r.rawBytes = d.rawBytes;
|
|
80
|
+
return r;
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
export interface FrameOutcome {
|
|
76
84
|
/** New codes, or `null`. */
|
|
77
85
|
results: Detection[] | null;
|
|
@@ -89,9 +97,14 @@ export class SessionCore {
|
|
|
89
97
|
this.session = new WasmScanSession(streamConfig(options));
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Strategy plan of the next frame, for a `DecoderCore` configured the same
|
|
102
|
+
* way. `fastPath: false` when the native `BarcodeDetector` already searched
|
|
103
|
+
* the frame: the decoder then runs the fallback strategy only.
|
|
104
|
+
*/
|
|
105
|
+
plan(fastPath = true): FramePlan {
|
|
106
|
+
const session = this.active();
|
|
107
|
+
return fastPath ? session.plan() : session.planFallbackOnly();
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
/** Record a decoded frame; returns the new codes and the live ones. */
|
|
@@ -102,16 +115,16 @@ export class SessionCore {
|
|
|
102
115
|
return { results, live: results ?? session.lastDetections.map(toDetection), decodeMs: report.elapsedMs };
|
|
103
116
|
}
|
|
104
117
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Record codes read by the native `BarcodeDetector`: same format filter,
|
|
120
|
+
* confirmation and de-duplication as decoded frames. Points are kept as
|
|
121
|
+
* given (video pixels).
|
|
122
|
+
*/
|
|
123
|
+
commitExternal(codes: Detection[], elapsedMs: number): FrameOutcome {
|
|
107
124
|
const session = this.active();
|
|
108
|
-
|
|
109
|
-
session.frameBuffer(pixels.length).set(pixels);
|
|
110
|
-
const t0 = performance.now();
|
|
111
|
-
const fresh = session.processFrameBuffer(width, height, format);
|
|
112
|
-
const decodeMs = performance.now() - t0;
|
|
125
|
+
const fresh = session.commitExternal(codes.map(toScanResult), elapsedMs);
|
|
113
126
|
const results = fresh ? fresh.map(toDetection) : null;
|
|
114
|
-
return { results, live: results ?? session.lastDetections.map(toDetection), decodeMs };
|
|
127
|
+
return { results, live: results ?? session.lastDetections.map(toDetection), decodeMs: elapsedMs };
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
reset(): void {
|
package/src/engine.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// with many slow cores (Galaxy A14: decode ~125 ms) the pool multiplies the
|
|
10
10
|
// frame rate.
|
|
11
11
|
import { DecoderCore, SessionCore, compileWasm, defaultWasmUrl, loadWasm } from './core.js';
|
|
12
|
-
import type { FramePlan, FrameReport } from './core.js';
|
|
12
|
+
import type { FrameOutcome, FramePlan, FrameReport } from './core.js';
|
|
13
13
|
import type { WorkerRequest, WorkerResponse } from './protocol.js';
|
|
14
14
|
import type { PixelFormat } from './wasm/scanner_wasm.js';
|
|
15
15
|
import type { AssetOptions, Detection, EngineOptions } from './types.js';
|
|
@@ -28,8 +28,24 @@ export interface Engine {
|
|
|
28
28
|
/** Frames that can be decoded at the same time (number of workers). */
|
|
29
29
|
readonly concurrency: number;
|
|
30
30
|
configure(options: EngineOptions): Promise<void>;
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Decode a camera frame. `buffer` is transferred and returned in the reply.
|
|
33
|
+
* `fastPath: false` runs the fallback strategy only, for a frame the
|
|
34
|
+
* native `BarcodeDetector` already searched without finding anything.
|
|
35
|
+
*/
|
|
36
|
+
processFrame(
|
|
37
|
+
buffer: ArrayBuffer,
|
|
38
|
+
width: number,
|
|
39
|
+
height: number,
|
|
40
|
+
format: PixelFormat,
|
|
41
|
+
fastPath?: boolean,
|
|
42
|
+
): Promise<FrameReply>;
|
|
43
|
+
/**
|
|
44
|
+
* Record codes read by the native `BarcodeDetector` in a frame: same
|
|
45
|
+
* format filter, multi-frame confirmation and de-duplication as decoded
|
|
46
|
+
* frames. Points are returned as given.
|
|
47
|
+
*/
|
|
48
|
+
commitExternal(codes: Detection[], elapsedMs: number): Promise<FrameOutcome>;
|
|
33
49
|
/** One-shot scan of a still image, running every strategy. */
|
|
34
50
|
scanImage(
|
|
35
51
|
buffer: ArrayBuffer,
|
|
@@ -186,10 +202,11 @@ class WorkerPoolEngine implements Engine {
|
|
|
186
202
|
width: number,
|
|
187
203
|
height: number,
|
|
188
204
|
format: PixelFormat,
|
|
205
|
+
fastPath = true,
|
|
189
206
|
): Promise<FrameReply> {
|
|
190
207
|
if (!this.configured) throw new Error('Engine not configured');
|
|
191
208
|
const generation = this.generation;
|
|
192
|
-
const plan = this.session.plan();
|
|
209
|
+
const plan = this.session.plan(fastPath);
|
|
193
210
|
let res: OkResponse;
|
|
194
211
|
try {
|
|
195
212
|
res = await this.idleWorker().ok({ type: 'decode', buffer, width, height, format, plan }, [buffer]);
|
|
@@ -205,6 +222,11 @@ class WorkerPoolEngine implements Engine {
|
|
|
205
222
|
return { ...this.session.commit(res.report), buffer: returned };
|
|
206
223
|
}
|
|
207
224
|
|
|
225
|
+
async commitExternal(codes: Detection[], elapsedMs: number) {
|
|
226
|
+
if (!this.configured) throw new Error('Engine not configured');
|
|
227
|
+
return this.session.commitExternal(codes, elapsedMs);
|
|
228
|
+
}
|
|
229
|
+
|
|
208
230
|
async scanImage(
|
|
209
231
|
buffer: ArrayBuffer,
|
|
210
232
|
width: number,
|
|
@@ -265,7 +287,10 @@ class InlineEngine implements Engine {
|
|
|
265
287
|
constructor(readonly version: string) {}
|
|
266
288
|
|
|
267
289
|
async configure(options: EngineOptions) {
|
|
290
|
+
// Same configuration for both: a plan is an index in the decoder's list
|
|
291
|
+
// of fallback strategies (as with workers).
|
|
268
292
|
this.session.configure(options);
|
|
293
|
+
this.decoder.configure(options);
|
|
269
294
|
}
|
|
270
295
|
|
|
271
296
|
async processFrame(
|
|
@@ -273,8 +298,21 @@ class InlineEngine implements Engine {
|
|
|
273
298
|
width: number,
|
|
274
299
|
height: number,
|
|
275
300
|
format: PixelFormat,
|
|
301
|
+
fastPath = true,
|
|
276
302
|
): Promise<FrameReply> {
|
|
277
|
-
|
|
303
|
+
const plan = this.session.plan(fastPath);
|
|
304
|
+
let report: FrameReport;
|
|
305
|
+
try {
|
|
306
|
+
report = this.decoder.decode(new Uint8Array(buffer), width, height, format, plan);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
this.session.commit(emptyReport(plan));
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
return { ...this.session.commit(report), buffer };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async commitExternal(codes: Detection[], elapsedMs: number) {
|
|
315
|
+
return this.session.commitExternal(codes, elapsedMs);
|
|
278
316
|
}
|
|
279
317
|
|
|
280
318
|
async scanImage(
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The browser's native barcode decoder (`BarcodeDetector`, Shape Detection API).
|
|
2
|
+
//
|
|
3
|
+
// Available in Chrome on Android (Google Play Services), macOS (Apple Vision)
|
|
4
|
+
// and ChromeOS; not in Safari, Firefox, nor Chrome on Windows / Linux. Where it
|
|
5
|
+
// exists it decodes outside the page's threads, so it can replace the engine's
|
|
6
|
+
// **fast path**: the engine then only runs its fallback strategies (Linear,
|
|
7
|
+
// CLAHE, Sauvola, inverted…) on the frames the native decoder found nothing
|
|
8
|
+
// in. The native decoder has no such preprocessing, and those strategies are
|
|
9
|
+
// what reads the hard codes (laser-engraved tags, labels in shadow).
|
|
10
|
+
import type { Rect } from './grabber.js';
|
|
11
|
+
import type { BarcodeFormat, Detection, Point } from './types.js';
|
|
12
|
+
|
|
13
|
+
// The API is not in TypeScript's `lib.dom` yet: minimal local declarations.
|
|
14
|
+
|
|
15
|
+
/** A code found by `BarcodeDetector.detect()`. */
|
|
16
|
+
export interface NativeBarcode {
|
|
17
|
+
rawValue: string;
|
|
18
|
+
format: string;
|
|
19
|
+
/** Corners in pixels of the source (for a `<video>`: `videoWidth` space). */
|
|
20
|
+
cornerPoints: readonly Point[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface BarcodeDetectorInstance {
|
|
24
|
+
detect(source: CanvasImageSource): Promise<NativeBarcode[]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The `BarcodeDetector` constructor. */
|
|
28
|
+
export interface BarcodeDetectorClass {
|
|
29
|
+
new (options?: { formats?: string[] }): BarcodeDetectorInstance;
|
|
30
|
+
getSupportedFormats(): Promise<string[]>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Formats of this SDK, all spelled like the native ones (`qr_code`, `ean_13`…)
|
|
35
|
+
* except `micro_qr`, which the native API does not have: when it is asked
|
|
36
|
+
* for, it is still read by the engine's fallback strategies (`Hard` decodes
|
|
37
|
+
* the raw frame with every format), only less often than by a fast path.
|
|
38
|
+
*/
|
|
39
|
+
const NATIVE_FORMATS: readonly BarcodeFormat[] = [
|
|
40
|
+
'qr_code',
|
|
41
|
+
'data_matrix',
|
|
42
|
+
'aztec',
|
|
43
|
+
'pdf417',
|
|
44
|
+
'code_128',
|
|
45
|
+
'code_39',
|
|
46
|
+
'code_93',
|
|
47
|
+
'ean_13',
|
|
48
|
+
'ean_8',
|
|
49
|
+
'upc_a',
|
|
50
|
+
'upc_e',
|
|
51
|
+
'itf',
|
|
52
|
+
'codabar',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** Native decoder serving one reader configuration. */
|
|
56
|
+
export class NativeDetector {
|
|
57
|
+
private constructor(
|
|
58
|
+
private detector: BarcodeDetectorInstance,
|
|
59
|
+
/** Formats the detector looks for. */
|
|
60
|
+
readonly formats: readonly BarcodeFormat[],
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A detector for `formats` (default: all), or `undefined` when the native
|
|
65
|
+
* decoder cannot replace the engine's fast path: API absent, or one of the
|
|
66
|
+
* requested formats not supported natively (codes of that format would
|
|
67
|
+
* then only be read by the slower fallbacks).
|
|
68
|
+
*/
|
|
69
|
+
static async create(
|
|
70
|
+
formats: readonly BarcodeFormat[] | undefined,
|
|
71
|
+
api: BarcodeDetectorClass | undefined = nativeApi(),
|
|
72
|
+
): Promise<NativeDetector | undefined> {
|
|
73
|
+
if (!api) return undefined;
|
|
74
|
+
let supported: string[];
|
|
75
|
+
try {
|
|
76
|
+
supported = await api.getSupportedFormats();
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
// On Android the list is empty until Google Play Services has downloaded
|
|
81
|
+
// its barcode module (first use on the device, in the background): the
|
|
82
|
+
// detector is unavailable *for now*, the engine does the whole job.
|
|
83
|
+
const wanted = (formats?.length ? formats : NATIVE_FORMATS).filter((f) => f !== 'micro_qr');
|
|
84
|
+
if (wanted.length === 0 || !wanted.every((f) => supported.includes(f))) return undefined;
|
|
85
|
+
try {
|
|
86
|
+
return new NativeDetector(new api({ formats: [...wanted] }), wanted);
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Codes in `source` whose center lies in `rect` (the region of interest, in
|
|
94
|
+
* source pixels), as detections in source pixels. Throws when the native
|
|
95
|
+
* decoder fails.
|
|
96
|
+
*/
|
|
97
|
+
async detect(source: CanvasImageSource, rect: Rect): Promise<Detection[]> {
|
|
98
|
+
const found = await this.detector.detect(source);
|
|
99
|
+
const timestamp = Date.now();
|
|
100
|
+
return found.flatMap((code) => {
|
|
101
|
+
const format = code.format as BarcodeFormat;
|
|
102
|
+
if (!this.formats.includes(format) || code.cornerPoints.length === 0) return [];
|
|
103
|
+
if (!contains(rect, center(code.cornerPoints))) return [];
|
|
104
|
+
const points = code.cornerPoints.map((p) => ({ x: p.x, y: p.y }));
|
|
105
|
+
return [{ text: code.rawValue, format, points, timestamp }];
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The browser's own `BarcodeDetector`, if any. Feature detection only (no
|
|
112
|
+
* list of browsers or devices): any browser that ships the API is used.
|
|
113
|
+
*
|
|
114
|
+
* Polyfills are rejected: packages such as `barcode-detector` install a
|
|
115
|
+
* `BarcodeDetector` that runs ZXing in WebAssembly on the main thread,
|
|
116
|
+
* slower than this SDK's own engine in its workers. A built-in class
|
|
117
|
+
* stringifies as `function BarcodeDetector() { [native code] }`.
|
|
118
|
+
*/
|
|
119
|
+
function nativeApi(): BarcodeDetectorClass | undefined {
|
|
120
|
+
const api: unknown = Reflect.get(globalThis, 'BarcodeDetector');
|
|
121
|
+
if (typeof api !== 'function' || !isBuiltIn(api)) return undefined;
|
|
122
|
+
return api as BarcodeDetectorClass;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `true` for a function implemented by the browser, not in JavaScript. */
|
|
126
|
+
export function isBuiltIn(fn: object): boolean {
|
|
127
|
+
return /\{\s*\[native code\]\s*\}\s*$/.test(Function.prototype.toString.call(fn));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function center(points: readonly Point[]): Point {
|
|
131
|
+
const n = points.length;
|
|
132
|
+
return {
|
|
133
|
+
x: points.reduce((s, p) => s + p.x, 0) / n,
|
|
134
|
+
y: points.reduce((s, p) => s + p.y, 0) / n,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function contains(r: Rect, p: Point): boolean {
|
|
139
|
+
return p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
|
|
140
|
+
}
|
package/src/overlay.ts
CHANGED
|
@@ -181,7 +181,8 @@ function drawPolygon(ctx: CanvasRenderingContext2D, pts: Point[]): void {
|
|
|
181
181
|
if (!first) return;
|
|
182
182
|
ctx.beginPath();
|
|
183
183
|
if (pts.length === 2 && second) {
|
|
184
|
-
//
|
|
184
|
+
// Fallback for a 1D code whose box could not be measured: the two ends
|
|
185
|
+
// of the scan line, drawn as a thick bar.
|
|
185
186
|
ctx.save();
|
|
186
187
|
ctx.lineWidth = 10;
|
|
187
188
|
ctx.globalAlpha *= 0.6;
|
package/src/reader.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// BarcodeReader: camera preview + frame pipeline + engine, wired together.
|
|
2
2
|
import { capabilities, listCameras, openCamera, rememberCamera, setTorch, setZoom } from './camera.js';
|
|
3
3
|
import { pickEngineOptions } from './core.js';
|
|
4
|
-
import { createEngine, type Engine } from './engine.js';
|
|
4
|
+
import { createEngine, type Engine, type FrameReply } from './engine.js';
|
|
5
5
|
import { Feedback } from './feedback.js';
|
|
6
6
|
import {
|
|
7
7
|
clampRegion,
|
|
@@ -11,22 +11,36 @@ import {
|
|
|
11
11
|
regionToVideoRect,
|
|
12
12
|
videoToElement,
|
|
13
13
|
} from './geometry.js';
|
|
14
|
-
import { CanvasGrabber, createGrabber, type FrameGrabber } from './grabber.js';
|
|
14
|
+
import { CanvasGrabber, createGrabber, type FrameGrabber, type Rect } from './grabber.js';
|
|
15
|
+
import type { FrameOutcome } from './core.js';
|
|
16
|
+
import { NativeDetector } from './native.js';
|
|
15
17
|
import { Preview } from './overlay.js';
|
|
16
18
|
import type { Detection, EngineOptions, ReaderEvents, ReaderOptions, ReaderStats, Region } from './types.js';
|
|
17
19
|
|
|
18
20
|
const DEFAULT_REGION: Region = { x: 0.1, y: 0.2, width: 0.8, height: 0.6 };
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* With the native detector, delay between two engine frames while the
|
|
24
|
+
* native decoder finds nothing (~3 fallbacks per second). A code that only
|
|
25
|
+
* the fallbacks read is still found within a couple of seconds (the
|
|
26
|
+
* strategies share CPU time, the cheap Linear one runs most often), while
|
|
27
|
+
* the workers stay idle most of the time. After a frame where the engine saw
|
|
28
|
+
* a code, the next one is immediate: a 1D code needs a second read.
|
|
29
|
+
*/
|
|
30
|
+
const FALLBACK_INTERVAL_MS = 300;
|
|
31
|
+
|
|
20
32
|
/** A frame read from the video, waiting for (or being decoded by) the engine. */
|
|
21
33
|
interface GrabbedFrame {
|
|
22
34
|
buffer: ArrayBuffer;
|
|
23
35
|
width: number;
|
|
24
36
|
height: number;
|
|
25
37
|
/** Region of the video the frame was cropped from (to map points back). */
|
|
26
|
-
rect:
|
|
38
|
+
rect: Rect;
|
|
27
39
|
grabMs: number;
|
|
28
40
|
/** Grab order: decodes finish out of order when several workers run. */
|
|
29
41
|
seq: number;
|
|
42
|
+
/** Run the engine's fast path (`false`: the native detector already searched this frame). */
|
|
43
|
+
fastPath: boolean;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
type Listener<T> = (payload: T) => void;
|
|
@@ -55,8 +69,20 @@ export class BarcodeReader {
|
|
|
55
69
|
private engineOptions: EngineOptions;
|
|
56
70
|
private running = false;
|
|
57
71
|
private paused = false;
|
|
58
|
-
/** A grab (GPU readback)
|
|
72
|
+
/** A frame is being acquired: grab (GPU readback) and native detection. */
|
|
59
73
|
private grabbing = false;
|
|
74
|
+
/** Native `BarcodeDetector` used as the fast path, when enabled and available. */
|
|
75
|
+
private native: NativeDetector | undefined;
|
|
76
|
+
/** Bumped when the native detector is replaced: results of the old one are dropped. */
|
|
77
|
+
private nativeGeneration = 0;
|
|
78
|
+
/** The native detector failed once: not used again by this reader. */
|
|
79
|
+
private nativeFailed = false;
|
|
80
|
+
/** Native detector active: a fallback-only frame is being grabbed or decoded. */
|
|
81
|
+
private fallbackInFlight = false;
|
|
82
|
+
/** Native detector active: earliest time of the next fallback-only frame. */
|
|
83
|
+
private nextFallbackAt = 0;
|
|
84
|
+
/** Which decoder drew the current highlights: each one only clears its own. */
|
|
85
|
+
private highlightSource: 'native' | 'engine' | null = null;
|
|
60
86
|
/** Frames being decoded (at most `engine.concurrency`). */
|
|
61
87
|
private decoding = 0;
|
|
62
88
|
/** Sequence number of the last grabbed frame, and of the last one drawn. */
|
|
@@ -77,13 +103,15 @@ export class BarcodeReader {
|
|
|
77
103
|
private torchOn = false;
|
|
78
104
|
private buffers: ArrayBuffer[] = [];
|
|
79
105
|
private frameHandle = 0;
|
|
80
|
-
private perf = { frames: 0, since: 0, fps: 0, decodeMs: 0, grabMs: 0, w: 0, h: 0 };
|
|
106
|
+
private perf = { frames: 0, since: 0, fps: 0, decodeMs: 0, grabMs: 0, nativeMs: 0, w: 0, h: 0 };
|
|
81
107
|
|
|
82
108
|
private constructor(
|
|
83
109
|
private engine: Engine,
|
|
84
110
|
private options: ReaderOptions,
|
|
85
111
|
host: HTMLElement,
|
|
112
|
+
native: NativeDetector | undefined,
|
|
86
113
|
) {
|
|
114
|
+
this.native = native;
|
|
87
115
|
this.region = clampRegion(options.regionOfInterest ?? DEFAULT_REGION);
|
|
88
116
|
this.engineOptions = pickEngineOptions(options);
|
|
89
117
|
this.preview = new Preview(host, options.overlay);
|
|
@@ -101,8 +129,10 @@ export class BarcodeReader {
|
|
|
101
129
|
typeof options.host === 'string' ? document.querySelector<HTMLElement>(options.host) : options.host;
|
|
102
130
|
if (!host) throw new Error(`BarcodeReader: host element not found (${String(options.host)})`);
|
|
103
131
|
const engine = await createEngine(options);
|
|
104
|
-
|
|
105
|
-
|
|
132
|
+
const engineOptions = pickEngineOptions(options);
|
|
133
|
+
await engine.configure(engineOptions);
|
|
134
|
+
const native = await createNative(options, engineOptions);
|
|
135
|
+
return new BarcodeReader(engine, options, host, native);
|
|
106
136
|
}
|
|
107
137
|
|
|
108
138
|
/** Video input devices (labels are empty until permission is granted). */
|
|
@@ -134,6 +164,8 @@ export class BarcodeReader {
|
|
|
134
164
|
backend: this.grabber.kind,
|
|
135
165
|
worker: this.engine.inWorker,
|
|
136
166
|
workers: this.engine.concurrency,
|
|
167
|
+
native: this.native !== undefined,
|
|
168
|
+
nativeMs: this.perf.nativeMs,
|
|
137
169
|
};
|
|
138
170
|
}
|
|
139
171
|
|
|
@@ -282,7 +314,11 @@ export class BarcodeReader {
|
|
|
282
314
|
async configure(options: EngineOptions): Promise<void> {
|
|
283
315
|
this.assertAlive();
|
|
284
316
|
this.engineOptions = { ...this.engineOptions, ...options };
|
|
317
|
+
// Detections in flight were made for the old formats: dropped.
|
|
318
|
+
const generation = ++this.nativeGeneration;
|
|
285
319
|
await this.engine.configure(this.engineOptions);
|
|
320
|
+
const native = this.nativeFailed ? undefined : await createNative(this.options, this.engineOptions);
|
|
321
|
+
if (generation === this.nativeGeneration) this.native = native;
|
|
286
322
|
}
|
|
287
323
|
|
|
288
324
|
/** Allow `text` to be reported again right away. */
|
|
@@ -352,13 +388,14 @@ export class BarcodeReader {
|
|
|
352
388
|
* Back-pressure: at most one grab in flight and one frame waiting for a
|
|
353
389
|
* free worker. While a frame waits, no new grab starts, so frames are
|
|
354
390
|
* skipped, never queued, and the waiting frame is at most one decode old.
|
|
391
|
+
* The native detection is part of the grab stage, so the same rule keeps
|
|
392
|
+
* at most one `detect()` in flight.
|
|
355
393
|
*/
|
|
356
394
|
private tick(): void {
|
|
357
395
|
if (!this.running) return;
|
|
358
396
|
if (!this.grabbing && !this.pending && !this.paused && this.preview.video.videoWidth > 0) {
|
|
359
397
|
this.grabbing = true;
|
|
360
|
-
this.
|
|
361
|
-
.then((frame) => this.enqueue(frame))
|
|
398
|
+
this.acquire()
|
|
362
399
|
.catch((err) => this.handleFrameError(err))
|
|
363
400
|
.finally(() => (this.grabbing = false));
|
|
364
401
|
}
|
|
@@ -366,11 +403,84 @@ export class BarcodeReader {
|
|
|
366
403
|
this.schedule();
|
|
367
404
|
}
|
|
368
405
|
|
|
369
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Grab stage. With the native `BarcodeDetector`, the frame is searched
|
|
408
|
+
* natively first, straight from the `<video>` (Chrome reads it outside the
|
|
409
|
+
* page's threads): no grab of our own. Codes it finds are committed right
|
|
410
|
+
* away. When it finds nothing, the engine gets a frame **from time to
|
|
411
|
+
* time** (`FALLBACK_INTERVAL_MS`), without its fast path, for one fallback
|
|
412
|
+
* strategy: the safety net for what the native decoder has no
|
|
413
|
+
* preprocessing for (laser engraving, light-on-dark codes, `micro_qr`…).
|
|
414
|
+
* Grabbing and decoding every frame as well would double the reads of the
|
|
415
|
+
* frame and keep the workers busy on frames the native decoder handles,
|
|
416
|
+
* costing battery and CPU it also needs.
|
|
417
|
+
*/
|
|
418
|
+
private async acquire(): Promise<void> {
|
|
419
|
+
const native = this.native;
|
|
420
|
+
const rect = this.videoRect();
|
|
421
|
+
if (!native) {
|
|
422
|
+
this.enqueue(await this.grabFrame(rect, true));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const generation = this.nativeGeneration;
|
|
426
|
+
const t0 = performance.now();
|
|
427
|
+
let codes: Detection[];
|
|
428
|
+
try {
|
|
429
|
+
codes = await native.detect(this.preview.video, rect);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
this.disableNative(err);
|
|
432
|
+
this.enqueue(await this.grabFrame(rect, true));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const nativeMs = performance.now() - t0;
|
|
436
|
+
// Replaced by `configure()` meanwhile: results made for the old formats.
|
|
437
|
+
if (generation !== this.nativeGeneration || !this.running || this.paused) return;
|
|
438
|
+
|
|
439
|
+
if (codes.length === 0 && this.fallbackDue()) {
|
|
440
|
+
this.fallbackInFlight = true;
|
|
441
|
+
this.updatePerf({ nativeMs, counted: false });
|
|
442
|
+
try {
|
|
443
|
+
this.enqueue(await this.grabFrame(rect, false));
|
|
444
|
+
} catch (err) {
|
|
445
|
+
this.fallbackInFlight = false;
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const outcome = await this.engine.commitExternal(codes, nativeMs);
|
|
451
|
+
if (generation !== this.nativeGeneration || !this.running || this.paused) return;
|
|
452
|
+
this.failures = 0;
|
|
453
|
+
this.updatePerf({ nativeMs });
|
|
454
|
+
// Native points are already in video pixels.
|
|
455
|
+
this.present(outcome, ++this.grabbed, 'native', (d) => d);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The engine may decode a frame for its fallbacks (native detector active). */
|
|
459
|
+
private fallbackDue(): boolean {
|
|
460
|
+
return !this.fallbackInFlight && performance.now() >= this.nextFallbackAt;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The native decoder threw: decode with the engine only from now on. */
|
|
464
|
+
private disableNative(err: unknown): void {
|
|
465
|
+
if (this.nativeFailed) return;
|
|
466
|
+
// Like the WebGL → Canvas 2D fallback: the reader keeps working, so this
|
|
467
|
+
// is a warning, not an `error` event.
|
|
468
|
+
console.warn('[scanner] native BarcodeDetector failed, decoding with the engine only:', err);
|
|
469
|
+
this.nativeFailed = true;
|
|
470
|
+
this.native = undefined;
|
|
471
|
+
this.nativeGeneration++;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The region of interest, in video pixels. */
|
|
475
|
+
private videoRect(): Rect {
|
|
370
476
|
const video = this.preview.video;
|
|
371
477
|
const ew = this.preview.width || video.videoWidth;
|
|
372
478
|
const eh = this.preview.height || video.videoHeight;
|
|
373
|
-
|
|
479
|
+
return regionToVideoRect(this.region, video.videoWidth, video.videoHeight, ew, eh);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private async grabFrame(rect: Rect, fastPath: boolean): Promise<GrabbedFrame> {
|
|
483
|
+
const video = this.preview.video;
|
|
374
484
|
const { width, height } = outputSize(rect, this.options.processing?.maxDimension ?? 1024);
|
|
375
485
|
|
|
376
486
|
const t0 = performance.now();
|
|
@@ -381,12 +491,21 @@ export class BarcodeReader {
|
|
|
381
491
|
this.buffers.push(buffer);
|
|
382
492
|
throw new GrabError(err);
|
|
383
493
|
}
|
|
384
|
-
return {
|
|
494
|
+
return {
|
|
495
|
+
buffer,
|
|
496
|
+
width,
|
|
497
|
+
height,
|
|
498
|
+
rect,
|
|
499
|
+
grabMs: performance.now() - t0,
|
|
500
|
+
seq: ++this.grabbed,
|
|
501
|
+
fastPath,
|
|
502
|
+
};
|
|
385
503
|
}
|
|
386
504
|
|
|
387
505
|
private enqueue(frame: GrabbedFrame): void {
|
|
388
506
|
if (!this.running || this.paused) {
|
|
389
507
|
this.buffers.push(frame.buffer);
|
|
508
|
+
if (!frame.fastPath) this.fallbackInFlight = false;
|
|
390
509
|
} else if (this.decoding >= this.engine.concurrency) {
|
|
391
510
|
this.pending = frame;
|
|
392
511
|
} else {
|
|
@@ -408,31 +527,58 @@ export class BarcodeReader {
|
|
|
408
527
|
|
|
409
528
|
private async decodeFrame(frame: GrabbedFrame): Promise<void> {
|
|
410
529
|
const { width, height, rect } = frame;
|
|
411
|
-
|
|
530
|
+
let reply: FrameReply;
|
|
531
|
+
try {
|
|
532
|
+
reply = await this.engine.processFrame(frame.buffer, width, height, 'luma', frame.fastPath);
|
|
533
|
+
} finally {
|
|
534
|
+
if (!frame.fastPath) {
|
|
535
|
+
this.fallbackInFlight = false;
|
|
536
|
+
this.nextFallbackAt = performance.now() + FALLBACK_INTERVAL_MS;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
412
539
|
this.buffers.push(reply.buffer);
|
|
413
540
|
if (!this.running) return;
|
|
414
541
|
this.failures = 0;
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
542
|
+
if (!frame.fastPath && reply.live.length > 0) this.nextFallbackAt = 0;
|
|
543
|
+
|
|
544
|
+
this.updatePerf({ grab: frame, decodeMs: reply.decodeMs });
|
|
545
|
+
// Fallback-only frames (native detector active) are decoded one at a
|
|
546
|
+
// time, so they cannot finish out of order: numbered when they finish,
|
|
547
|
+
// after the native frames that ran meanwhile.
|
|
548
|
+
const seq = frame.fastPath ? frame.seq : ++this.grabbed;
|
|
549
|
+
this.present(reply, seq, 'engine', (d) => ({
|
|
422
550
|
...d,
|
|
423
551
|
points: decoderToVideo(d.points, rect, width, height),
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Draw the live codes of frame `seq` and report the new ones, mapped to video pixels by `toVideo`. */
|
|
556
|
+
private present(
|
|
557
|
+
outcome: Pick<FrameOutcome, 'results' | 'live'>,
|
|
558
|
+
seq: number,
|
|
559
|
+
source: 'native' | 'engine',
|
|
560
|
+
toVideo: (d: Detection) => Detection,
|
|
561
|
+
): void {
|
|
562
|
+
// An older frame finishing after a newer one must not move the highlights
|
|
563
|
+
// back. With the native detector, a decoder that found nothing does not
|
|
564
|
+
// erase what the other one drew: the native decoder would otherwise
|
|
565
|
+
// erase, ~30 times per second, a code only the fallbacks can read.
|
|
566
|
+
const owns = outcome.live.length > 0 || this.highlightSource === source || !this.highlightSource;
|
|
567
|
+
if (seq > this.drawn && owns) {
|
|
568
|
+
this.drawn = seq;
|
|
569
|
+
this.highlightSource = outcome.live.length > 0 ? source : null;
|
|
570
|
+
const video = this.preview.video;
|
|
571
|
+
const vw = video.videoWidth;
|
|
572
|
+
const vh = video.videoHeight;
|
|
429
573
|
const layout = coverLayout(vw, vh, this.preview.width || vw, this.preview.height || vh);
|
|
430
|
-
this.preview.setDetections(
|
|
574
|
+
this.preview.setDetections(
|
|
575
|
+
outcome.live.map((d) => toVideo(d).points.map((p) => videoToElement(p, layout))),
|
|
576
|
+
);
|
|
431
577
|
}
|
|
432
578
|
|
|
433
|
-
if (
|
|
579
|
+
if (outcome.results && outcome.results.length > 0) {
|
|
434
580
|
this.feedback.play();
|
|
435
|
-
this.emit('detected',
|
|
581
|
+
this.emit('detected', outcome.results.map(toVideo));
|
|
436
582
|
}
|
|
437
583
|
}
|
|
438
584
|
|
|
@@ -461,13 +607,26 @@ export class BarcodeReader {
|
|
|
461
607
|
return new ArrayBuffer(size);
|
|
462
608
|
}
|
|
463
609
|
|
|
464
|
-
|
|
610
|
+
/**
|
|
611
|
+
* Account for a processed frame. `counted: false` for a native miss handed
|
|
612
|
+
* to the engine: that frame is counted once, when the engine is done.
|
|
613
|
+
*/
|
|
614
|
+
private updatePerf(s: {
|
|
615
|
+
grab?: GrabbedFrame;
|
|
616
|
+
decodeMs?: number;
|
|
617
|
+
nativeMs?: number;
|
|
618
|
+
counted?: boolean;
|
|
619
|
+
}): void {
|
|
465
620
|
const p = this.perf;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
621
|
+
const ema = (avg: number, v: number) => (avg ? avg * 0.9 + v * 0.1 : v);
|
|
622
|
+
if (s.counted !== false) p.frames++;
|
|
623
|
+
if (s.grab) {
|
|
624
|
+
p.grabMs = ema(p.grabMs, s.grab.grabMs);
|
|
625
|
+
p.w = s.grab.width;
|
|
626
|
+
p.h = s.grab.height;
|
|
627
|
+
}
|
|
628
|
+
if (s.decodeMs !== undefined) p.decodeMs = ema(p.decodeMs, s.decodeMs);
|
|
629
|
+
if (s.nativeMs !== undefined) p.nativeMs = ema(p.nativeMs, s.nativeMs);
|
|
471
630
|
const now = performance.now();
|
|
472
631
|
if (now - p.since >= 1000) {
|
|
473
632
|
p.fps = (p.frames * 1000) / (now - p.since);
|
|
@@ -518,6 +677,18 @@ class GrabError extends Error {
|
|
|
518
677
|
}
|
|
519
678
|
}
|
|
520
679
|
|
|
680
|
+
/** The native detector unless `processing.nativeDetector` is `'off'`, when it can serve `engineOptions`. */
|
|
681
|
+
function createNative(
|
|
682
|
+
options: ReaderOptions,
|
|
683
|
+
engineOptions: EngineOptions,
|
|
684
|
+
): Promise<NativeDetector | undefined> {
|
|
685
|
+
// On by default: on a Galaxy A14 (Chrome, Play Services) it read codes very
|
|
686
|
+
// well whatever the orientation of the phone, where the engine's fast pass
|
|
687
|
+
// only reads 1D codes roughly aligned with the image rows.
|
|
688
|
+
if (options.processing?.nativeDetector === 'off') return Promise.resolve(undefined);
|
|
689
|
+
return NativeDetector.create(engineOptions.formats);
|
|
690
|
+
}
|
|
691
|
+
|
|
521
692
|
function waitForVideo(video: HTMLVideoElement): Promise<void> {
|
|
522
693
|
if (video.readyState >= 2 && video.videoWidth > 0) return Promise.resolve();
|
|
523
694
|
return new Promise((resolve, reject) => {
|