@gueshbzh-perso/scanner 0.9.0 → 0.11.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/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 and `processing.engineFallback`, 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: ReturnType<typeof regionToVideoRect>;
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) is in progress. */
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,26 @@ 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 = {
107
+ frames: 0,
108
+ engineFrames: 0,
109
+ since: 0,
110
+ fps: 0,
111
+ engineFps: 0,
112
+ decodeMs: 0,
113
+ grabMs: 0,
114
+ nativeMs: 0,
115
+ w: 0,
116
+ h: 0,
117
+ };
81
118
 
82
119
  private constructor(
83
120
  private engine: Engine,
84
121
  private options: ReaderOptions,
85
122
  host: HTMLElement,
123
+ native: NativeDetector | undefined,
86
124
  ) {
125
+ this.native = native;
87
126
  this.region = clampRegion(options.regionOfInterest ?? DEFAULT_REGION);
88
127
  this.engineOptions = pickEngineOptions(options);
89
128
  this.preview = new Preview(host, options.overlay);
@@ -101,8 +140,10 @@ export class BarcodeReader {
101
140
  typeof options.host === 'string' ? document.querySelector<HTMLElement>(options.host) : options.host;
102
141
  if (!host) throw new Error(`BarcodeReader: host element not found (${String(options.host)})`);
103
142
  const engine = await createEngine(options);
104
- await engine.configure(pickEngineOptions(options));
105
- return new BarcodeReader(engine, options, host);
143
+ const engineOptions = pickEngineOptions(options);
144
+ await engine.configure(engineOptions);
145
+ const native = await createNative(options, engineOptions);
146
+ return new BarcodeReader(engine, options, host, native);
106
147
  }
107
148
 
108
149
  /** Video input devices (labels are empty until permission is granted). */
@@ -134,6 +175,9 @@ export class BarcodeReader {
134
175
  backend: this.grabber.kind,
135
176
  worker: this.engine.inWorker,
136
177
  workers: this.engine.concurrency,
178
+ native: this.native !== undefined,
179
+ nativeMs: this.perf.nativeMs,
180
+ engineFps: this.perf.engineFps,
137
181
  };
138
182
  }
139
183
 
@@ -282,7 +326,11 @@ export class BarcodeReader {
282
326
  async configure(options: EngineOptions): Promise<void> {
283
327
  this.assertAlive();
284
328
  this.engineOptions = { ...this.engineOptions, ...options };
329
+ // Detections in flight were made for the old formats: dropped.
330
+ const generation = ++this.nativeGeneration;
285
331
  await this.engine.configure(this.engineOptions);
332
+ const native = this.nativeFailed ? undefined : await createNative(this.options, this.engineOptions);
333
+ if (generation === this.nativeGeneration) this.native = native;
286
334
  }
287
335
 
288
336
  /** Allow `text` to be reported again right away. */
@@ -352,13 +400,14 @@ export class BarcodeReader {
352
400
  * Back-pressure: at most one grab in flight and one frame waiting for a
353
401
  * free worker. While a frame waits, no new grab starts, so frames are
354
402
  * skipped, never queued, and the waiting frame is at most one decode old.
403
+ * The native detection is part of the grab stage, so the same rule keeps
404
+ * at most one `detect()` in flight.
355
405
  */
356
406
  private tick(): void {
357
407
  if (!this.running) return;
358
408
  if (!this.grabbing && !this.pending && !this.paused && this.preview.video.videoWidth > 0) {
359
409
  this.grabbing = true;
360
- this.grabFrame()
361
- .then((frame) => this.enqueue(frame))
410
+ this.acquire()
362
411
  .catch((err) => this.handleFrameError(err))
363
412
  .finally(() => (this.grabbing = false));
364
413
  }
@@ -366,11 +415,87 @@ export class BarcodeReader {
366
415
  this.schedule();
367
416
  }
368
417
 
369
- private async grabFrame(): Promise<GrabbedFrame> {
418
+ /**
419
+ * Grab stage. With the native `BarcodeDetector`, the frame is searched
420
+ * natively first, straight from the `<video>` (Chrome reads it outside the
421
+ * page's threads): no grab of our own. Codes it finds are committed right
422
+ * away. When it finds nothing and `processing.engineFallback` is on, the
423
+ * engine gets a frame **from time to time** (`FALLBACK_INTERVAL_MS`),
424
+ * without its fast path, for one fallback strategy: a safety net for what
425
+ * the native decoder has no preprocessing for (laser engraving,
426
+ * light-on-dark codes, `micro_qr`…). Off by default (choice of the project
427
+ * after testing on a Galaxy A14, where the native decoder read codes very
428
+ * well): the net mostly costs CPU and battery.
429
+ * Grabbing and decoding every frame as well would double the reads of the
430
+ * frame and keep the workers busy on frames the native decoder handles,
431
+ * costing battery and CPU it also needs.
432
+ */
433
+ private async acquire(): Promise<void> {
434
+ const native = this.native;
435
+ const rect = this.videoRect();
436
+ if (!native) {
437
+ this.enqueue(await this.grabFrame(rect, true));
438
+ return;
439
+ }
440
+ const generation = this.nativeGeneration;
441
+ const t0 = performance.now();
442
+ let codes: Detection[];
443
+ try {
444
+ codes = await native.detect(this.preview.video, rect);
445
+ } catch (err) {
446
+ this.disableNative(err);
447
+ this.enqueue(await this.grabFrame(rect, true));
448
+ return;
449
+ }
450
+ const nativeMs = performance.now() - t0;
451
+ // Replaced by `configure()` meanwhile: results made for the old formats.
452
+ if (generation !== this.nativeGeneration || !this.running || this.paused) return;
453
+
454
+ if (codes.length === 0 && this.options.processing?.engineFallback === true && this.fallbackDue()) {
455
+ this.fallbackInFlight = true;
456
+ this.updatePerf({ nativeMs, counted: false });
457
+ try {
458
+ this.enqueue(await this.grabFrame(rect, false));
459
+ } catch (err) {
460
+ this.fallbackInFlight = false;
461
+ throw err;
462
+ }
463
+ return;
464
+ }
465
+ const outcome = await this.engine.commitExternal(codes, nativeMs);
466
+ if (generation !== this.nativeGeneration || !this.running || this.paused) return;
467
+ this.failures = 0;
468
+ this.updatePerf({ nativeMs });
469
+ // Native points are already in video pixels.
470
+ this.present(outcome, ++this.grabbed, 'native', (d) => d);
471
+ }
472
+
473
+ /** The engine may decode a frame for its fallbacks (native detector active). */
474
+ private fallbackDue(): boolean {
475
+ return !this.fallbackInFlight && performance.now() >= this.nextFallbackAt;
476
+ }
477
+
478
+ /** The native decoder threw: decode with the engine only from now on. */
479
+ private disableNative(err: unknown): void {
480
+ if (this.nativeFailed) return;
481
+ // Like the WebGL → Canvas 2D fallback: the reader keeps working, so this
482
+ // is a warning, not an `error` event.
483
+ console.warn('[scanner] native BarcodeDetector failed, decoding with the engine only:', err);
484
+ this.nativeFailed = true;
485
+ this.native = undefined;
486
+ this.nativeGeneration++;
487
+ }
488
+
489
+ /** The region of interest, in video pixels. */
490
+ private videoRect(): Rect {
370
491
  const video = this.preview.video;
371
492
  const ew = this.preview.width || video.videoWidth;
372
493
  const eh = this.preview.height || video.videoHeight;
373
- const rect = regionToVideoRect(this.region, video.videoWidth, video.videoHeight, ew, eh);
494
+ return regionToVideoRect(this.region, video.videoWidth, video.videoHeight, ew, eh);
495
+ }
496
+
497
+ private async grabFrame(rect: Rect, fastPath: boolean): Promise<GrabbedFrame> {
498
+ const video = this.preview.video;
374
499
  const { width, height } = outputSize(rect, this.options.processing?.maxDimension ?? 1024);
375
500
 
376
501
  const t0 = performance.now();
@@ -381,12 +506,21 @@ export class BarcodeReader {
381
506
  this.buffers.push(buffer);
382
507
  throw new GrabError(err);
383
508
  }
384
- return { buffer, width, height, rect, grabMs: performance.now() - t0, seq: ++this.grabbed };
509
+ return {
510
+ buffer,
511
+ width,
512
+ height,
513
+ rect,
514
+ grabMs: performance.now() - t0,
515
+ seq: ++this.grabbed,
516
+ fastPath,
517
+ };
385
518
  }
386
519
 
387
520
  private enqueue(frame: GrabbedFrame): void {
388
521
  if (!this.running || this.paused) {
389
522
  this.buffers.push(frame.buffer);
523
+ if (!frame.fastPath) this.fallbackInFlight = false;
390
524
  } else if (this.decoding >= this.engine.concurrency) {
391
525
  this.pending = frame;
392
526
  } else {
@@ -408,31 +542,58 @@ export class BarcodeReader {
408
542
 
409
543
  private async decodeFrame(frame: GrabbedFrame): Promise<void> {
410
544
  const { width, height, rect } = frame;
411
- const reply = await this.engine.processFrame(frame.buffer, width, height, 'luma');
545
+ let reply: FrameReply;
546
+ try {
547
+ reply = await this.engine.processFrame(frame.buffer, width, height, 'luma', frame.fastPath);
548
+ } finally {
549
+ if (!frame.fastPath) {
550
+ this.fallbackInFlight = false;
551
+ this.nextFallbackAt = performance.now() + FALLBACK_INTERVAL_MS;
552
+ }
553
+ }
412
554
  this.buffers.push(reply.buffer);
413
555
  if (!this.running) return;
414
556
  this.failures = 0;
415
-
416
- this.updatePerf(frame.grabMs, reply.decodeMs, width, height);
417
-
418
- const video = this.preview.video;
419
- const vw = video.videoWidth;
420
- const vh = video.videoHeight;
421
- const toVideo = (d: Detection): Detection => ({
557
+ if (!frame.fastPath && reply.live.length > 0) this.nextFallbackAt = 0;
558
+
559
+ this.updatePerf({ grab: frame, decodeMs: reply.decodeMs });
560
+ // Fallback-only frames (native detector active) are decoded one at a
561
+ // time, so they cannot finish out of order: numbered when they finish,
562
+ // after the native frames that ran meanwhile.
563
+ const seq = frame.fastPath ? frame.seq : ++this.grabbed;
564
+ this.present(reply, seq, 'engine', (d) => ({
422
565
  ...d,
423
566
  points: decoderToVideo(d.points, rect, width, height),
424
- });
425
- // An older frame finishing after a newer one must not move the highlights back.
426
- if (frame.seq > this.drawn) {
427
- this.drawn = frame.seq;
428
- const live = reply.live.map(toVideo);
567
+ }));
568
+ }
569
+
570
+ /** Draw the live codes of frame `seq` and report the new ones, mapped to video pixels by `toVideo`. */
571
+ private present(
572
+ outcome: Pick<FrameOutcome, 'results' | 'live'>,
573
+ seq: number,
574
+ source: 'native' | 'engine',
575
+ toVideo: (d: Detection) => Detection,
576
+ ): void {
577
+ // An older frame finishing after a newer one must not move the highlights
578
+ // back. With the native detector, a decoder that found nothing does not
579
+ // erase what the other one drew: the native decoder would otherwise
580
+ // erase, ~30 times per second, a code only the fallbacks can read.
581
+ const owns = outcome.live.length > 0 || this.highlightSource === source || !this.highlightSource;
582
+ if (seq > this.drawn && owns) {
583
+ this.drawn = seq;
584
+ this.highlightSource = outcome.live.length > 0 ? source : null;
585
+ const video = this.preview.video;
586
+ const vw = video.videoWidth;
587
+ const vh = video.videoHeight;
429
588
  const layout = coverLayout(vw, vh, this.preview.width || vw, this.preview.height || vh);
430
- this.preview.setDetections(live.map((d) => d.points.map((p) => videoToElement(p, layout))));
589
+ this.preview.setDetections(
590
+ outcome.live.map((d) => toVideo(d).points.map((p) => videoToElement(p, layout))),
591
+ );
431
592
  }
432
593
 
433
- if (reply.results && reply.results.length > 0) {
594
+ if (outcome.results && outcome.results.length > 0) {
434
595
  this.feedback.play();
435
- this.emit('detected', reply.results.map(toVideo));
596
+ this.emit('detected', outcome.results.map(toVideo));
436
597
  }
437
598
  }
438
599
 
@@ -461,17 +622,41 @@ export class BarcodeReader {
461
622
  return new ArrayBuffer(size);
462
623
  }
463
624
 
464
- private updatePerf(grabMs: number, decodeMs: number, w: number, h: number): void {
625
+ /**
626
+ * Account for a processed frame. `counted: false` for a native miss handed
627
+ * to the engine: that frame is counted once, when the engine is done.
628
+ */
629
+ private updatePerf(s: {
630
+ grab?: GrabbedFrame;
631
+ decodeMs?: number;
632
+ nativeMs?: number;
633
+ counted?: boolean;
634
+ }): void {
465
635
  const p = this.perf;
466
- p.frames++;
467
- p.grabMs = p.grabMs ? p.grabMs * 0.9 + grabMs * 0.1 : grabMs;
468
- p.decodeMs = p.decodeMs ? p.decodeMs * 0.9 + decodeMs * 0.1 : decodeMs;
469
- p.w = w;
470
- p.h = h;
636
+ const ema = (avg: number, v: number) => (avg ? avg * 0.9 + v * 0.1 : v);
637
+ if (s.counted !== false) p.frames++;
638
+ if (s.grab) {
639
+ p.grabMs = ema(p.grabMs, s.grab.grabMs);
640
+ p.w = s.grab.width;
641
+ p.h = s.grab.height;
642
+ }
643
+ if (s.decodeMs !== undefined) {
644
+ p.decodeMs = ema(p.decodeMs, s.decodeMs);
645
+ p.engineFrames++;
646
+ }
647
+ if (s.nativeMs !== undefined) p.nativeMs = ema(p.nativeMs, s.nativeMs);
471
648
  const now = performance.now();
472
649
  if (now - p.since >= 1000) {
473
650
  p.fps = (p.frames * 1000) / (now - p.since);
651
+ p.engineFps = (p.engineFrames * 1000) / (now - p.since);
652
+ // With the native detector the engine may decode nothing for long
653
+ // stretches: its averages would show stale values, as if it were busy.
654
+ if (p.engineFrames === 0) {
655
+ p.decodeMs = 0;
656
+ p.grabMs = 0;
657
+ }
474
658
  p.frames = 0;
659
+ p.engineFrames = 0;
475
660
  p.since = now;
476
661
  }
477
662
  }
@@ -518,6 +703,18 @@ class GrabError extends Error {
518
703
  }
519
704
  }
520
705
 
706
+ /** The native detector unless `processing.nativeDetector` is `'off'`, when it can serve `engineOptions`. */
707
+ function createNative(
708
+ options: ReaderOptions,
709
+ engineOptions: EngineOptions,
710
+ ): Promise<NativeDetector | undefined> {
711
+ // On by default: on a Galaxy A14 (Chrome, Play Services) it read codes very
712
+ // well whatever the orientation of the phone, where the engine's fast pass
713
+ // only reads 1D codes roughly aligned with the image rows.
714
+ if (options.processing?.nativeDetector === 'off') return Promise.resolve(undefined);
715
+ return NativeDetector.create(engineOptions.formats, undefined, options.processing?.engineFallback === true);
716
+ }
717
+
521
718
  function waitForVideo(video: HTMLVideoElement): Promise<void> {
522
719
  if (video.readyState >= 2 && video.videoWidth > 0) return Promise.resolve();
523
720
  return new Promise((resolve, reject) => {
package/src/types.ts CHANGED
@@ -118,6 +118,26 @@ export interface ProcessingOptions {
118
118
  maxDimension?: number;
119
119
  /** Frame pipeline. `'auto'` uses WebGL2 and falls back to Canvas 2D. Default: `'auto'`. */
120
120
  backend?: 'auto' | 'webgl' | 'canvas';
121
+ /**
122
+ * Use the browser's native `BarcodeDetector` (Chrome on Android, macOS and
123
+ * ChromeOS) as the fast pass, when it supports every requested format
124
+ * (`micro_qr` excepted). The engine's fallback strategies keep running on
125
+ * the frames it finds nothing in: hard codes (laser engraving, shadows…)
126
+ * are still read. Codes get the same confirmation and de-duplication.
127
+ * It reads codes whatever their orientation. Elsewhere (Safari, Firefox,
128
+ * Chrome on Windows / Linux), or if it fails, the engine does everything.
129
+ * `'off'`: always the engine alone. Default: `'auto'`.
130
+ */
131
+ nativeDetector?: 'auto' | 'off';
132
+ /**
133
+ * With the native detector: while it finds nothing, also hand a frame to
134
+ * the engine's fallback strategies from time to time (~3 per second), a
135
+ * safety net for codes the native decoder may miss (laser engraving,
136
+ * shadows, light-on-dark codes, `micro_qr`). Costs a little CPU and
137
+ * battery. Without it, `micro_qr` listed in `formats` keeps the engine
138
+ * instead of the native detector. Default: `false`.
139
+ */
140
+ engineFallback?: boolean;
121
141
  }
122
142
 
123
143
  export interface ReaderOptions extends EngineOptions, AssetOptions {
@@ -139,9 +159,9 @@ export interface ReaderOptions extends EngineOptions, AssetOptions {
139
159
  export interface ReaderStats {
140
160
  /** Frames decoded per second. */
141
161
  fps: number;
142
- /** Average decode time in the engine, in ms. */
162
+ /** Average decode time in the engine, in ms (`0` when it decoded nothing in the last second). */
143
163
  decodeMs: number;
144
- /** Average GPU/canvas frame preparation time, in ms. */
164
+ /** Average GPU/canvas frame preparation time, in ms (`0` when nothing was grabbed in the last second). */
145
165
  grabMs: number;
146
166
  /** Size of the image handed to the decoder. */
147
167
  frameWidth: number;
@@ -152,6 +172,15 @@ export interface ReaderStats {
152
172
  worker: boolean;
153
173
  /** Frames decoded in parallel (number of workers, `1` on the main thread). */
154
174
  workers: number;
175
+ /** `true` when the native `BarcodeDetector` does the fast pass (see `processing.nativeDetector`). */
176
+ native: boolean;
177
+ /** Average native detection time, in ms (`0` when not used). */
178
+ nativeMs: number;
179
+ /**
180
+ * Frames decoded by the engine per second. With the native detector: `0`
181
+ * while it reads everything, a few per second with `engineFallback`.
182
+ */
183
+ engineFps: number;
155
184
  }
156
185
 
157
186
  export interface ReaderEvents {