tapirscan 1.0.0 → 1.2.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.
Files changed (33) hide show
  1. package/README.md +184 -105
  2. package/dist/completion-host.d.mts +40 -0
  3. package/dist/completion-host.mjs +188 -0
  4. package/dist/detail-runtime/direct-recovery.d.mts +28 -0
  5. package/dist/detail-runtime/direct-recovery.mjs +142 -0
  6. package/dist/detail-runtime/host.d.mts +40 -0
  7. package/dist/detail-runtime/host.mjs +282 -0
  8. package/dist/detail-runtime/scanner.d.mts +12 -0
  9. package/dist/detail-runtime/scanner.mjs +70 -0
  10. package/dist/detail.d.ts +3 -4
  11. package/dist/detail.js +3 -6
  12. package/dist/index.d.ts +91 -33
  13. package/dist/index.js +174 -86
  14. package/dist/multiformat/coverage.d.ts +7 -0
  15. package/dist/multiformat/coverage.js +26 -0
  16. package/dist/multiformat/formats.d.ts +4 -2
  17. package/dist/multiformat/formats.js +13 -1
  18. package/dist/multiformat/geometry.d.ts +5 -0
  19. package/dist/multiformat/geometry.js +13 -3
  20. package/dist/multiformat/linear-duplicates.d.ts +16 -0
  21. package/dist/multiformat/linear-duplicates.js +163 -0
  22. package/dist/multiformat/scanner.d.ts +11 -2
  23. package/dist/multiformat/scanner.js +127 -24
  24. package/examples/camera.html +63 -0
  25. package/examples/scan-worker.mjs +20 -0
  26. package/examples/worker-client.mjs +63 -0
  27. package/package.json +7 -6
  28. package/wasm/{high-release-20260915.wasm → high-complete-release-20260916.wasm} +0 -0
  29. package/wasm/{low-release-20260915.wasm → low-complete-release-20260916.wasm} +0 -0
  30. package/wasm/{medium-release-20260915.wasm → medium-complete-release-20260916.wasm} +0 -0
  31. package/wasm/multiformat.json +2 -1
  32. package/wasm/multiformat.wasm +0 -0
  33. package/wasm/{very-high-release-20260915.wasm → very-high-complete-release-20260916.wasm} +0 -0
@@ -35,7 +35,7 @@ export function transformFor(quad, width, height) {
35
35
  }
36
36
  return a.map((row) => row[8]);
37
37
  }
38
- export function rectify(gray, width, height, quad) {
38
+ export function rectificationPlan(quad) {
39
39
  const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
40
40
  const w = Math.max(8, Math.ceil(Math.max(distance(quad[0], quad[1]), distance(quad[3], quad[2]))));
41
41
  const h = Math.max(8, Math.ceil(Math.max(distance(quad[0], quad[3]), distance(quad[1], quad[2]))));
@@ -54,10 +54,19 @@ export function rectify(gray, width, height, quad) {
54
54
  base[7] / divisor,
55
55
  ];
56
56
  const paddedWidth = w + 2 * pad, paddedHeight = h + 2 * pad;
57
+ return { width: paddedWidth, height: paddedHeight, transform: t };
58
+ }
59
+ export function rectify(gray, width, height, quad) {
60
+ const { width: paddedWidth, height: paddedHeight, transform: t } = rectificationPlan(quad);
57
61
  const data = new Uint8Array(paddedWidth * paddedHeight);
58
- for (let y = 0; y < paddedHeight; y++)
62
+ for (let y = 0; y < paddedHeight; y++) {
63
+ const cy = y + 0.5;
64
+ const yX = t[1] * cy, yY = t[4] * cy, yZ = t[7] * cy;
59
65
  for (let x = 0; x < paddedWidth; x++) {
60
- const [xx, yy] = project(t, x + 0.5, y + 0.5);
66
+ const cx = x + 0.5;
67
+ const z = t[6] * cx + yZ + 1;
68
+ const xx = (t[0] * cx + yX + t[2]) / z;
69
+ const yy = (t[3] * cx + yY + t[5]) / z;
61
70
  if (xx < 0 || yy < 0 || xx > width - 1 || yy > height - 1) {
62
71
  data[y * paddedWidth + x] = 255;
63
72
  continue;
@@ -67,6 +76,7 @@ export function rectify(gray, width, height, quad) {
67
76
  data[y * paddedWidth + x] = Math.round((gray[y0 * width + x0] * (1 - fx) + gray[y0 * width + x1] * fx) * (1 - fy) +
68
77
  (gray[y1 * width + x0] * (1 - fx) + gray[y1 * width + x1] * fx) * fy);
69
78
  }
79
+ }
70
80
  return { data, width: paddedWidth, height: paddedHeight, transform: t };
71
81
  }
72
82
  /** Convex intersection, including opposite winding and projective quadrilaterals. */
@@ -0,0 +1,16 @@
1
+ import type { Image, Quad } from "../host.js";
2
+ type Read = {
3
+ text: string;
4
+ format: string;
5
+ polygon: Quad;
6
+ support: number;
7
+ eanAddOn?: string;
8
+ gs1?: boolean;
9
+ readerInitialization?: boolean;
10
+ };
11
+ /** Only merge equal-value, aligned bands when source pixels connect their bars.
12
+ * This does not change search coverage or retry budgets. Exhausting the small
13
+ * evidence budget leaves detections separate, never drops an unchecked read.
14
+ */
15
+ export declare function mergeLinearDuplicates<T extends Read>(reads: readonly T[], image: Image): T[];
16
+ export {};
@@ -0,0 +1,163 @@
1
+ import { polygonOverlap } from "./geometry.js";
2
+ const formats = new Set(["EAN13", "UPCA", "EAN8", "UPCE", "Code128", "Code39", "ITF"]);
3
+ function bitCount(value) {
4
+ let bits = value - ((value >>> 1) & 0x55555555);
5
+ bits = (bits & 0x33333333) + ((bits >>> 2) & 0x33333333);
6
+ return (((bits + (bits >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
7
+ }
8
+ const midpoint = (a, b) => [
9
+ (a[0] + b[0]) / 2,
10
+ (a[1] + b[1]) / 2,
11
+ ];
12
+ const line = (q) => [midpoint(q[0], q[3]), midpoint(q[1], q[2])];
13
+ /** Only merge equal-value, aligned bands when source pixels connect their bars.
14
+ * This does not change search coverage or retry budgets. Exhausting the small
15
+ * evidence budget leaves detections separate, never drops an unchecked read.
16
+ */
17
+ export function mergeLinearDuplicates(reads, image) {
18
+ if (reads.length < 2)
19
+ return [...reads];
20
+ const seen = new Set();
21
+ let repeated = false;
22
+ for (const read of reads) {
23
+ if (!read.text || !formats.has(read.format))
24
+ continue;
25
+ const key = `${read.format}:${read.text}`;
26
+ if (seen.has(key)) {
27
+ repeated = true;
28
+ break;
29
+ }
30
+ seen.add(key);
31
+ }
32
+ if (!repeated)
33
+ return [...reads];
34
+ let remaining = 32768;
35
+ const result = [];
36
+ let values;
37
+ const profile = (left, right) => {
38
+ if (remaining < 64)
39
+ return undefined;
40
+ remaining -= 64;
41
+ values ??= new Float64Array(64);
42
+ let lo = 255, hi = 0;
43
+ for (let i = 0; i < 64; i++) {
44
+ const f = (i + 0.5) / 64;
45
+ const x = Math.round(left[0] + (right[0] - left[0]) * f);
46
+ const y = Math.round(left[1] + (right[1] - left[1]) * f);
47
+ if (x < 0 || y < 0 || x >= image.width || y >= image.height)
48
+ return undefined;
49
+ const p = y * image.stride + x * image.channels;
50
+ const value = image.channels === 1
51
+ ? image.data[p]
52
+ : (77 * image.data[p] + 150 * image.data[p + 1] + 29 * image.data[p + 2]) / 256;
53
+ values[i] = value;
54
+ lo = Math.min(lo, value);
55
+ hi = Math.max(hi, value);
56
+ }
57
+ if (hi - lo < 24)
58
+ return undefined;
59
+ const threshold = (lo + hi) / 2;
60
+ let first = 0, second = 0;
61
+ for (let i = 0; i < 32; i++) {
62
+ if (values[i] < threshold)
63
+ first |= 1 << i;
64
+ if (values[i + 32] < threshold)
65
+ second |= 1 << i;
66
+ }
67
+ return [first, second];
68
+ };
69
+ const connected = (a, inputB) => {
70
+ if (!a.every((p) => p.every(Number.isFinite)) || !inputB.every((p) => p.every(Number.isFinite)))
71
+ return undefined;
72
+ const al = line(a), bl = line(inputB);
73
+ const ax = al[1][0] - al[0][0], ay = al[1][1] - al[0][1];
74
+ let bx = bl[1][0] - bl[0][0], by = bl[1][1] - bl[0][1];
75
+ const aw = Math.hypot(ax, ay), bw = Math.hypot(bx, by);
76
+ if (aw < 24 || bw / aw < 0.9 || bw / aw > 1.1)
77
+ return undefined;
78
+ let b = inputB;
79
+ if (ax * bx + ay * by < 0) {
80
+ b = [inputB[2], inputB[3], inputB[0], inputB[1]];
81
+ bx = -bx;
82
+ by = -by;
83
+ }
84
+ if ((ax * bx + ay * by) / (aw * bw) < 0.996)
85
+ return undefined;
86
+ const ac = midpoint(al[0], al[1]), bline = line(b), bc = midpoint(bline[0], bline[1]);
87
+ const dx = bc[0] - ac[0], dy = bc[1] - ac[1];
88
+ if (Math.abs(dx * ax + dy * ay) / aw > aw * 0.06)
89
+ return undefined;
90
+ // Bound endpoint movement too: rotation/perspective can move an edge farther
91
+ // than the centers. Every interpolated sample then moves at most one pixel.
92
+ const distance = Math.max(Math.hypot(bline[0][0] - al[0][0], bline[0][1] - al[0][1]), Math.hypot(bline[1][0] - al[1][0], bline[1][1] - al[1][1]));
93
+ const steps = Math.ceil(distance);
94
+ if (steps < 1 || steps > 384 || (steps + 2) * 64 > remaining)
95
+ return undefined;
96
+ const reference = profile(al[0], al[1]);
97
+ if (!reference)
98
+ return undefined;
99
+ let dark0 = reference[0], dark1 = reference[1];
100
+ let light0 = ~dark0, light1 = ~dark1;
101
+ const minimumDark = Math.max(4, Math.ceil((bitCount(dark0) + bitCount(dark1)) / 4));
102
+ const minimumLight = Math.max(4, Math.ceil((bitCount(light0) + bitCount(light1)) / 4));
103
+ for (let step = 1; step <= steps; step++) {
104
+ const f = step / steps;
105
+ const l = [
106
+ al[0][0] + (bline[0][0] - al[0][0]) * f,
107
+ al[0][1] + (bline[0][1] - al[0][1]) * f,
108
+ ];
109
+ const r = [
110
+ al[1][0] + (bline[1][0] - al[1][0]) * f,
111
+ al[1][1] + (bline[1][1] - al[1][1]) * f,
112
+ ];
113
+ const sample = profile(l, r);
114
+ if (!sample)
115
+ return undefined;
116
+ const disagreement = bitCount(reference[0] ^ sample[0]) + bitCount(reference[1] ^ sample[1]);
117
+ if (disagreement > 12)
118
+ return undefined;
119
+ dark0 &= sample[0];
120
+ dark1 &= sample[1];
121
+ light0 &= ~sample[0];
122
+ light1 &= ~sample[1];
123
+ // A skewed separator may cross columns at different heights. Require
124
+ // individual bars AND spaces to survive the entire bridge, not just rows.
125
+ if (bitCount(dark0) + bitCount(dark1) < minimumDark ||
126
+ bitCount(light0) + bitCount(light1) < minimumLight)
127
+ return undefined;
128
+ }
129
+ const along = (p) => (-ay * p[0] + ax * p[1]) / aw;
130
+ const top = along(midpoint(a[0], a[1])) < along(midpoint(b[0], b[1])) ? a : b;
131
+ const bottom = along(midpoint(a[2], a[3])) > along(midpoint(b[2], b[3])) ? a : b;
132
+ return [top[0], top[1], bottom[2], bottom[3]];
133
+ };
134
+ for (const read of [...reads].sort((a, b) => b.support - a.support)) {
135
+ if (!formats.has(read.format) || !read.text || remaining < 192) {
136
+ result.push(read);
137
+ continue;
138
+ }
139
+ let merged = false;
140
+ for (let i = 0; i < result.length; i++) {
141
+ const other = result[i];
142
+ if (read.format !== other.format ||
143
+ read.text !== other.text ||
144
+ read.eanAddOn !== other.eanAddOn ||
145
+ Boolean(read.gs1) !== Boolean(other.gs1) ||
146
+ Boolean(read.readerInitialization) !== Boolean(other.readerInitialization))
147
+ continue;
148
+ if (polygonOverlap(other.polygon, read.polygon).smaller >= 0.65) {
149
+ merged = true;
150
+ break;
151
+ }
152
+ const polygon = connected(other.polygon, read.polygon);
153
+ if (!polygon)
154
+ continue;
155
+ result[i] = { ...other, polygon }; // Keep strongest support; observations may overlap.
156
+ merged = true;
157
+ break;
158
+ }
159
+ if (!merged)
160
+ result.push(read);
161
+ }
162
+ return result;
163
+ }
@@ -1,7 +1,9 @@
1
- import { type Image, type Quad } from "../multiformat-host.js";
1
+ import { ReleaseDetailScanner } from "../detail.js";
2
+ import { IndependentScanner, type Image, type Quad } from "../completion-host.mjs";
2
3
  import type { Mode } from "../index.js";
3
4
  import { type Format } from "./formats.js";
4
5
  export interface Barcode {
6
+ bytes?: number[];
5
7
  format: Format | "Unknown";
6
8
  text: string;
7
9
  polygon: Quad;
@@ -21,6 +23,7 @@ export interface Barcode {
21
23
  }
22
24
  export type EanAddOnSymbol = "Ignore" | "Read" | "Require";
23
25
  export interface Frame {
26
+ primary?: ReturnType<ReleaseDetailScanner["scanLocalized"]> | ReturnType<IndependentScanner["scanLocalized"]>;
24
27
  eanAddOnSymbol: EanAddOnSymbol;
25
28
  barcodes: Barcode[];
26
29
  regions: Barcode[];
@@ -39,13 +42,19 @@ export declare class MediumMultiformatScanner {
39
42
  private mode;
40
43
  private extra?;
41
44
  private handle;
45
+ private rgba;
46
+ private crop;
47
+ private effort;
48
+ private qrEffort;
42
49
  private disposed;
43
50
  private constructor();
44
- static create(mediumBytes: ArrayBuffer, extraBytes?: ArrayBuffer, mode?: Mode, recoveryBytes?: ArrayBuffer): Promise<MediumMultiformatScanner>;
51
+ static create(mediumBytes: ArrayBuffer | undefined, extraBytes?: ArrayBuffer, mode?: Mode, recoveryBytes?: ArrayBuffer): Promise<MediumMultiformatScanner>;
45
52
  scan(image: Image, inputFormats?: readonly string[], options?: {
46
53
  linearStrategy?: "scanlines" | "medium-localized";
47
54
  eanAddOnSymbol?: EanAddOnSymbol;
55
+ finishCandidates?: boolean;
48
56
  }): Frame;
49
57
  private scanExtra;
58
+ private scanPrepared;
50
59
  dispose(): void;
51
60
  }
@@ -1,25 +1,38 @@
1
+ import { mergeLinearDuplicates } from "./linear-duplicates.js";
1
2
  import { ReleaseDetailScanner, fitLimits } from "../detail.js";
2
- import { IndependentScanner } from "../multiformat-host.js";
3
+ import { IndependentScanner } from "../completion-host.mjs";
3
4
  import { policy } from "../policy.js";
4
- import { rectify, project, distinctReads, polygonOverlap } from "./geometry.js";
5
+ import { rectify, rectificationPlan, project, distinctReads, polygonOverlap } from "./geometry.js";
5
6
  import { toGray } from "./pixels.js";
6
7
  import { maskFor, resolveFormats, linearFormats as supportedLinearFormats, } from "./formats.js";
8
+ function scanGray(image) {
9
+ return image.channels === 1 && image.stride === image.width
10
+ ? image.data.subarray(0, image.width * image.height)
11
+ : toGray(image);
12
+ }
7
13
  /** Frozen EAN13 Medium plus project-owned opt-in readers. No reference decoder. */
8
14
  export class MediumMultiformatScanner {
9
15
  medium;
10
16
  mode = "medium";
11
17
  extra;
12
18
  handle = 0;
19
+ rgba = false;
20
+ crop = false;
21
+ effort = 1;
22
+ qrEffort = 1;
13
23
  disposed = false;
14
24
  constructor() { }
15
25
  static async create(mediumBytes, extraBytes, mode = "medium", recoveryBytes) {
16
26
  const scanner = new MediumMultiformatScanner();
17
27
  try {
18
28
  scanner.mode = mode;
19
- scanner.medium =
20
- mode !== "low" && recoveryBytes
21
- ? await ReleaseDetailScanner.create(mediumBytes, recoveryBytes, mode)
22
- : await IndependentScanner.create(mediumBytes);
29
+ scanner.effort = { low: 0, medium: 1, high: 2, "very-high": 2 }[mode];
30
+ scanner.qrEffort = { low: 0, medium: 1, high: 2, "very-high": 3 }[mode];
31
+ if (mediumBytes)
32
+ scanner.medium =
33
+ mode !== "low" && recoveryBytes
34
+ ? await ReleaseDetailScanner.create(mediumBytes, recoveryBytes, mode)
35
+ : await IndependentScanner.create(mediumBytes);
23
36
  if (extraBytes) {
24
37
  const instance = await WebAssembly.instantiate(extraBytes, {});
25
38
  const e = instance.instance.exports;
@@ -37,6 +50,14 @@ export class MediumMultiformatScanner {
37
50
  }
38
51
  if (!(e.memory instanceof WebAssembly.Memory))
39
52
  throw Error("Invalid multiformat WASM memory.");
53
+ scanner.rgba =
54
+ typeof e.multi_capabilities === "function" &&
55
+ (e.multi_capabilities() & 4) !== 0 &&
56
+ [e.multi_prepare_rgba, e.multi_input_rgba, e.multi_scan_rgba].every((f) => typeof f === "function");
57
+ scanner.crop =
58
+ typeof e.multi_capabilities === "function" &&
59
+ (e.multi_capabilities() & 16) !== 0 &&
60
+ [e.multi_capture_source, e.multi_crop_transform, e.multi_crop].every((f) => typeof f === "function");
40
61
  scanner.extra = e;
41
62
  scanner.handle = e.multi_new();
42
63
  if (!scanner.handle)
@@ -53,8 +74,7 @@ export class MediumMultiformatScanner {
53
74
  if (this.disposed)
54
75
  throw Error("Scanner is disposed.");
55
76
  const medium = this.medium;
56
- if (!medium)
57
- throw Error("Medium reader has not been initialized.");
77
+ const scanPolicy = { ...policy, finishCandidates: options.finishCandidates ?? false };
58
78
  const formats = resolveFormats(inputFormats);
59
79
  const eanAddOnSymbol = options.eanAddOnSymbol ?? "Ignore";
60
80
  if (!["Ignore", "Read", "Require"].includes(eanAddOnSymbol))
@@ -82,10 +102,43 @@ export class MediumMultiformatScanner {
82
102
  data.length < (height - 1) * stride + width * channels)
83
103
  throw Error("Invalid image dimensions or buffer.");
84
104
  const barcodes = [], regions = [];
105
+ let primary;
85
106
  let unfinished = false, mediumMs = 0, additionalMs = 0, preparationMs = 0, localizationMs = 0;
107
+ const extraFormats = formats.filter((f) => eanAddOnSymbol !== "Ignore" || (f !== "EAN13" && f !== "UPCA"));
108
+ const conservative = medium instanceof ReleaseDetailScanner &&
109
+ !localized &&
110
+ eanAddOnSymbol === "Ignore" &&
111
+ formats.some((f) => f === "EAN13" || f === "UPCA") &&
112
+ extraFormats.some((f) => supportedLinearFormats.includes(f));
113
+ let preparedGray;
114
+ let preLinear;
115
+ const coverage = [];
116
+ if (conservative) {
117
+ const prepareStart = performance.now();
118
+ preparedGray = scanGray(image);
119
+ preparationMs += performance.now() - prepareStart;
120
+ const extraStart = performance.now();
121
+ preLinear = this.scanExtra(preparedGray, width, height, extraFormats.filter((f) => supportedLinearFormats.includes(f)), this.effort, eanAddOnSymbol);
122
+ additionalMs += performance.now() - extraStart;
123
+ for (const read of preLinear.barcodes) {
124
+ const error = read.error ?? Infinity;
125
+ const checked = ["EAN8", "UPCE", "Code128"].includes(read.format) && read.support >= 3 && error <= 0.08;
126
+ const unchecked = ["Code39", "ITF"].includes(read.format) &&
127
+ read.support >= 8 &&
128
+ error <= 0.035 &&
129
+ read.text.length >= 8;
130
+ if (checked || unchecked)
131
+ coverage.push(read.polygon);
132
+ }
133
+ }
86
134
  if (formats.includes("EAN13") || formats.includes("UPCA")) {
135
+ if (!medium)
136
+ throw Error("EAN13 engine has not been initialized.");
87
137
  const begin = performance.now();
88
- const found = medium.scanLocalized(image, policy, fitLimits[this.mode], true);
138
+ const found = medium instanceof ReleaseDetailScanner
139
+ ? medium.scanLocalized(image, scanPolicy, fitLimits[this.mode], true, coverage)
140
+ : medium.scanLocalized(image, scanPolicy, fitLimits[this.mode], true);
141
+ primary = found;
89
142
  const localization = found.localization;
90
143
  proposals = localization.proposals;
91
144
  localizationMs = found.localizationMs;
@@ -122,8 +175,10 @@ export class MediumMultiformatScanner {
122
175
  mediumMs = performance.now() - begin;
123
176
  }
124
177
  if (localized && !formats.includes("EAN13") && !formats.includes("UPCA")) {
178
+ if (!medium)
179
+ throw Error("EAN13 localization engine has not been initialized.");
125
180
  const begin = performance.now();
126
- const found = medium.scanLocalized(image, policy, fitLimits[this.mode], true)
181
+ const found = medium.scanLocalized(image, scanPolicy, fitLimits[this.mode], true)
127
182
  .localization;
128
183
  proposals = found.proposals;
129
184
  unfinished ||= Boolean(found.workLimited) || (found.omitted ?? 0) > 0;
@@ -131,38 +186,70 @@ export class MediumMultiformatScanner {
131
186
  }
132
187
  // UPC-A has the same optical structure as zero-prefixed EAN13, so the
133
188
  // frozen Medium reader handles it without a second optical search.
134
- const extraFormats = formats.filter((f) => eanAddOnSymbol !== "Ignore" || (f !== "EAN13" && f !== "UPCA"));
135
189
  if (extraFormats.length) {
136
190
  if (!this.extra || !this.handle)
137
191
  throw Error("Additional readers have not been loaded.");
138
192
  const begin = performance.now();
139
- const gray = toGray(image);
140
- preparationMs = performance.now() - begin;
193
+ const rgba = this.rgba &&
194
+ !localized &&
195
+ formats.length === 1 &&
196
+ formats[0] === "QRCode" &&
197
+ channels === 4 &&
198
+ stride === width * 4;
199
+ const gray = preparedGray ?? (rgba ? data.subarray(0, width * height * 4) : scanGray(image));
200
+ preparationMs += performance.now() - begin;
141
201
  const matrixFormats = extraFormats.filter((f) => !supportedLinearFormats.includes(f));
142
202
  const linearFormats = extraFormats.filter((f) => supportedLinearFormats.includes(f));
143
203
  const run = (pixels, w, h, enabled, effort) => {
144
204
  const start = performance.now();
145
- const result = this.scanExtra(pixels, w, h, enabled, effort, eanAddOnSymbol);
205
+ const result = this.scanExtra(pixels, w, h, enabled, effort, eanAddOnSymbol, rgba);
146
206
  additionalMs += performance.now() - start;
147
207
  unfinished ||= result.unfinished;
148
208
  return result;
149
209
  };
150
210
  if (!localized || !proposals.length) {
151
- const result = run(gray, width, height, extraFormats, 1);
152
- barcodes.push(...result.barcodes);
153
- regions.push(...(result.regions ?? []));
211
+ for (const [enabled, effort] of [
212
+ [linearFormats, this.effort],
213
+ [matrixFormats, matrixFormats.includes("QRCode") ? this.qrEffort : 1],
214
+ ]) {
215
+ if (!enabled.length)
216
+ continue;
217
+ const result = enabled === linearFormats && preLinear
218
+ ? preLinear
219
+ : run(gray, width, height, enabled, effort);
220
+ unfinished ||= result.unfinished;
221
+ barcodes.push(...result.barcodes);
222
+ regions.push(...(result.regions ?? []));
223
+ }
154
224
  }
155
225
  else {
156
226
  if (matrixFormats.length) {
157
- const result = run(gray, width, height, matrixFormats, 1);
227
+ const result = run(gray, width, height, matrixFormats, matrixFormats.includes("QRCode") ? this.qrEffort : 1);
158
228
  barcodes.push(...result.barcodes);
159
229
  regions.push(...(result.regions ?? []));
160
230
  }
231
+ if (this.crop) {
232
+ this.extra.multi_prepare(this.handle, width, height);
233
+ new Uint8Array(this.extra.memory.buffer, this.extra.multi_input(this.handle), width * height).set(gray);
234
+ if (this.extra.multi_capture_source(this.handle) !== 0)
235
+ throw Error("Could not retain source image.");
236
+ }
161
237
  for (const proposal of proposals) {
162
238
  const start = performance.now();
163
239
  let crop;
240
+ let pixels;
164
241
  try {
165
- crop = rectify(gray, width, height, proposal.polygon);
242
+ if (this.crop) {
243
+ crop = rectificationPlan(proposal.polygon);
244
+ new Float64Array(this.extra.memory.buffer, this.extra.multi_crop_transform(this.handle), 8).set(crop.transform);
245
+ if (this.extra.multi_crop(this.handle, crop.width, crop.height) !== 0)
246
+ throw Error("Could not sample barcode crop.");
247
+ }
248
+ else {
249
+ const sampled = rectify(gray, width, height, proposal.polygon);
250
+ crop = sampled;
251
+ pixels = sampled.data;
252
+ }
166
253
  }
167
254
  catch {
168
255
  unfinished = true;
@@ -170,7 +257,14 @@ export class MediumMultiformatScanner {
170
257
  continue;
171
258
  }
172
259
  preparationMs += performance.now() - start;
173
- const result = run(crop.data, crop.width, crop.height, linearFormats, 0);
260
+ const decodeStart = performance.now();
261
+ const result = pixels
262
+ ? run(pixels, crop.width, crop.height, linearFormats, 0)
263
+ : this.scanPrepared(linearFormats, 0, eanAddOnSymbol);
264
+ if (!pixels) {
265
+ additionalMs += performance.now() - decodeStart;
266
+ unfinished ||= result.unfinished;
267
+ }
174
268
  const reads = result.barcodes;
175
269
  if (!reads.length)
176
270
  regions.push({ format: "Unknown", text: "", polygon: proposal.polygon, support: 0 });
@@ -210,11 +304,14 @@ export class MediumMultiformatScanner {
210
304
  const remaining = regions.filter((region) => !barcodes.some((b) => polygonOverlap(region.polygon, b.polygon).a >= 0.65));
211
305
  regions.splice(0, regions.length, ...distinctReads(remaining));
212
306
  }
307
+ const mergedLinear = mergeLinearDuplicates(barcodes, image);
308
+ barcodes.splice(0, barcodes.length, ...mergedLinear);
213
309
  barcodes.sort((a, b) => b.support - a.support);
214
310
  barcodes.forEach((b, i) => {
215
311
  b.rank = i + 1;
216
312
  });
217
313
  return {
314
+ primary,
218
315
  barcodes,
219
316
  eanAddOnSymbol,
220
317
  regions: [...barcodes, ...regions],
@@ -228,16 +325,22 @@ export class MediumMultiformatScanner {
228
325
  linearStrategy,
229
326
  };
230
327
  }
231
- scanExtra(gray, width, height, formats, effort, eanAddOnSymbol) {
328
+ scanExtra(gray, width, height, formats, effort, eanAddOnSymbol, rgba = false) {
232
329
  const e = this.extra;
233
330
  if (!e || !this.handle)
234
331
  throw Error("Additional readers have not been loaded.");
235
- if (e.multi_prepare(this.handle, width, height) !== 0)
332
+ if ((rgba ? e.multi_prepare_rgba : e.multi_prepare)(this.handle, width, height) !== 0)
236
333
  throw Error("Additional reader rejected image size.");
237
- new Uint8Array(e.memory.buffer, e.multi_input(this.handle), width * height).set(gray);
334
+ new Uint8Array(e.memory.buffer, (rgba ? e.multi_input_rgba : e.multi_input)(this.handle), width * height * (rgba ? 4 : 1)).set(gray);
335
+ return this.scanPrepared(formats, effort, eanAddOnSymbol, rgba);
336
+ }
337
+ scanPrepared(formats, effort, eanAddOnSymbol, rgba = false) {
338
+ const e = this.extra;
339
+ if (!e || !this.handle)
340
+ throw Error("Additional readers have not been loaded.");
238
341
  const mask = maskFor(formats) |
239
342
  (eanAddOnSymbol === "Read" ? 32768 : eanAddOnSymbol === "Require" ? 65536 : 0);
240
- if (e.multi_scan(this.handle, mask, effort) !== 0)
343
+ if ((rgba ? e.multi_scan_rgba : e.multi_scan)(this.handle, mask, effort) !== 0)
241
344
  throw Error("Additional scanner failed.");
242
345
  const bytes = new Uint8Array(e.memory.buffer, e.multi_output(this.handle), e.multi_output_len(this.handle));
243
346
  const found = JSON.parse(new TextDecoder().decode(bytes));
@@ -0,0 +1,63 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <meta charset="utf-8" />
4
+ <title>Tapirscan worker example</title>
5
+ <button id="start">Start camera</button>
6
+ <button id="stop" disabled>Stop</button>
7
+ <video autoplay muted playsinline style="max-width: 100%"></video>
8
+ <pre role="status"></pre>
9
+ <script type="module">
10
+ import { createScannerWorker } from "./worker-client.mjs";
11
+ const video = document.querySelector("video");
12
+ const status = document.querySelector("pre");
13
+ const start = document.querySelector("#start");
14
+ const stop = document.querySelector("#stop");
15
+ const canvas = document.createElement("canvas");
16
+ const context = canvas.getContext("2d", { willReadFrequently: true });
17
+ let running = false;
18
+ let scanner;
19
+ let stream;
20
+ function shutdown() {
21
+ running = false;
22
+ scanner?.dispose();
23
+ scanner = undefined;
24
+ stream?.getTracks().forEach((track) => track.stop());
25
+ stream = undefined;
26
+ video.srcObject = null;
27
+ }
28
+ start.onclick = async () => {
29
+ start.disabled = true;
30
+ running = true;
31
+ try {
32
+ scanner = await createScannerWorker({ formats: ["EAN13", "QRCode"] });
33
+ if (!running) return;
34
+ stream = await navigator.mediaDevices.getUserMedia({
35
+ video: { facingMode: "environment" },
36
+ });
37
+ if (!running) return;
38
+ video.srcObject = stream;
39
+ await video.play();
40
+ stop.disabled = false;
41
+ while (running) {
42
+ canvas.width = video.videoWidth;
43
+ canvas.height = video.videoHeight;
44
+ context.drawImage(video, 0, 0);
45
+ const result = await scanner.scan(
46
+ context.getImageData(0, 0, canvas.width, canvas.height),
47
+ );
48
+ status.textContent = JSON.stringify(result.values);
49
+ // Backpressure: capture only after the previous scan has completed.
50
+ await new Promise(requestAnimationFrame);
51
+ }
52
+ } catch (error) {
53
+ if (running) status.textContent = error.message;
54
+ } finally {
55
+ shutdown();
56
+ start.disabled = false;
57
+ stop.disabled = true;
58
+ }
59
+ };
60
+ stop.onclick = shutdown;
61
+ window.addEventListener("pagehide", shutdown);
62
+ </script>
63
+ </html>
@@ -0,0 +1,20 @@
1
+ // Serve this directory alongside ../dist and ../wasm, or adapt the import for your bundler.
2
+ import { Scanner } from "../dist/index.js";
3
+
4
+ let scanner;
5
+ self.onmessage = async ({ data: { id, type, options, image } }) => {
6
+ try {
7
+ if (type === "init") {
8
+ if (scanner) throw Error("Scanner already initialized");
9
+ scanner = await Scanner.create(options);
10
+ self.postMessage({ id });
11
+ } else if (type === "scan") {
12
+ if (!scanner) throw Error("Scanner not initialized");
13
+ self.postMessage({ id, result: scanner.scan(image, options) });
14
+ } else {
15
+ throw Error(`Unknown worker request: ${type}`);
16
+ }
17
+ } catch (error) {
18
+ self.postMessage({ id, error: { message: error.message, code: error.code } });
19
+ }
20
+ };
@@ -0,0 +1,63 @@
1
+ /** Initialize once. scan() transfers ownership of the supplied pixel buffer.
2
+ * Await each scan before capturing the next frame; dispose() stops the worker.
3
+ * Structured cloning yields independent mutable results on the receiving side.
4
+ */
5
+ export async function createScannerWorker(options = {}) {
6
+ const worker = new Worker(new URL("./scan-worker.mjs", import.meta.url), { type: "module" });
7
+ let pending;
8
+ let nextId = 0;
9
+ let closed = false;
10
+ function dispose(reason = Error("Scanner worker disposed")) {
11
+ if (closed) return;
12
+ closed = true;
13
+ worker.terminate();
14
+ pending?.reject(reason);
15
+ pending = undefined;
16
+ }
17
+ worker.onerror = (event) => dispose(Error(event.message || "Scanner worker failed"));
18
+ worker.onmessageerror = () => dispose(Error("Could not deserialize scanner result"));
19
+ worker.onmessage = ({ data }) => {
20
+ if (!pending || pending.id !== data.id) return;
21
+ const { resolve, reject } = pending;
22
+ pending = undefined;
23
+ if (data.error) reject(Object.assign(Error(data.error.message), { code: data.error.code }));
24
+ else resolve(data.result);
25
+ };
26
+ function request(type, message, transfer = []) {
27
+ if (closed) return Promise.reject(Error("Scanner worker disposed"));
28
+ if (pending)
29
+ return Promise.reject(Error("Await the previous scan before sending another frame"));
30
+ return new Promise((resolve, reject) => {
31
+ const id = nextId++;
32
+ pending = { id, resolve, reject };
33
+ try {
34
+ worker.postMessage({ id, type, ...message }, transfer);
35
+ } catch (error) {
36
+ pending = undefined;
37
+ reject(error);
38
+ }
39
+ });
40
+ }
41
+ try {
42
+ await request("init", { options });
43
+ } catch (error) {
44
+ dispose(error);
45
+ throw error;
46
+ }
47
+ return {
48
+ scan(image, options = {}) {
49
+ // Send explicit storage settings; ImageData's prototype is not required in the worker.
50
+ const pixels = {
51
+ data: image.data,
52
+ width: image.width,
53
+ height: image.height,
54
+ channels: image.channels ?? 4,
55
+ stride: image.stride,
56
+ };
57
+ return request("scan", { image: pixels, options }, [image.data.buffer]);
58
+ },
59
+ dispose() {
60
+ dispose();
61
+ },
62
+ };
63
+ }