tapirscan 1.0.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/LICENSE +21 -0
- package/README.md +241 -0
- package/THIRD_PARTY_NOTICES.md +656 -0
- package/dist/detail-20260914/continuity.d.mts +2 -0
- package/dist/detail-20260914/continuity.mjs +62 -0
- package/dist/detail-20260914/detail-proposals-rich.d.mts +13 -0
- package/dist/detail-20260914/detail-proposals-rich.mjs +53 -0
- package/dist/detail-20260914/direct-recovery.d.mts +28 -0
- package/dist/detail-20260914/direct-recovery.mjs +139 -0
- package/dist/detail-20260914/host.d.mts +40 -0
- package/dist/detail-20260914/host.mjs +280 -0
- package/dist/detail-20260914/scanner.d.mts +12 -0
- package/dist/detail-20260914/scanner.mjs +64 -0
- package/dist/detail-20260914/source-evidence.d.mts +10 -0
- package/dist/detail-20260914/source-evidence.mjs +94 -0
- package/dist/detail-canvas.d.ts +35 -0
- package/dist/detail-canvas.js +73 -0
- package/dist/detail.d.ts +19 -0
- package/dist/detail.js +87 -0
- package/dist/host.d.ts +112 -0
- package/dist/host.js +184 -0
- package/dist/host64.d.ts +112 -0
- package/dist/host64.js +184 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.js +199 -0
- package/dist/multiformat/formats.d.ts +26 -0
- package/dist/multiformat/formats.js +59 -0
- package/dist/multiformat/geometry.d.ts +32 -0
- package/dist/multiformat/geometry.js +122 -0
- package/dist/multiformat/pixels.d.ts +3 -0
- package/dist/multiformat/pixels.js +36 -0
- package/dist/multiformat/scanner.d.ts +51 -0
- package/dist/multiformat/scanner.js +258 -0
- package/dist/multiformat-host.d.ts +121 -0
- package/dist/multiformat-host.js +207 -0
- package/dist/policy.d.ts +12 -0
- package/dist/policy.js +12 -0
- package/package.json +52 -0
- package/wasm/high-release-20260915.wasm +0 -0
- package/wasm/low-release-20260915.wasm +0 -0
- package/wasm/medium-release-20260915.wasm +0 -0
- package/wasm/multiformat.json +6 -0
- package/wasm/multiformat.wasm +0 -0
- package/wasm/very-high-release-20260915.wasm +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { ReleaseDetailScanner, fitLimits } from "../detail.js";
|
|
2
|
+
import { IndependentScanner } from "../multiformat-host.js";
|
|
3
|
+
import { policy } from "../policy.js";
|
|
4
|
+
import { rectify, project, distinctReads, polygonOverlap } from "./geometry.js";
|
|
5
|
+
import { toGray } from "./pixels.js";
|
|
6
|
+
import { maskFor, resolveFormats, linearFormats as supportedLinearFormats, } from "./formats.js";
|
|
7
|
+
/** Frozen EAN13 Medium plus project-owned opt-in readers. No reference decoder. */
|
|
8
|
+
export class MediumMultiformatScanner {
|
|
9
|
+
medium;
|
|
10
|
+
mode = "medium";
|
|
11
|
+
extra;
|
|
12
|
+
handle = 0;
|
|
13
|
+
disposed = false;
|
|
14
|
+
constructor() { }
|
|
15
|
+
static async create(mediumBytes, extraBytes, mode = "medium", recoveryBytes) {
|
|
16
|
+
const scanner = new MediumMultiformatScanner();
|
|
17
|
+
try {
|
|
18
|
+
scanner.mode = mode;
|
|
19
|
+
scanner.medium =
|
|
20
|
+
mode !== "low" && recoveryBytes
|
|
21
|
+
? await ReleaseDetailScanner.create(mediumBytes, recoveryBytes, mode)
|
|
22
|
+
: await IndependentScanner.create(mediumBytes);
|
|
23
|
+
if (extraBytes) {
|
|
24
|
+
const instance = await WebAssembly.instantiate(extraBytes, {});
|
|
25
|
+
const e = instance.instance.exports;
|
|
26
|
+
for (const name of [
|
|
27
|
+
"multi_new",
|
|
28
|
+
"multi_free",
|
|
29
|
+
"multi_prepare",
|
|
30
|
+
"multi_input",
|
|
31
|
+
"multi_scan",
|
|
32
|
+
"multi_output",
|
|
33
|
+
"multi_output_len",
|
|
34
|
+
]) {
|
|
35
|
+
if (typeof e[name] !== "function")
|
|
36
|
+
throw Error(`Invalid multiformat WASM export: ${name}`);
|
|
37
|
+
}
|
|
38
|
+
if (!(e.memory instanceof WebAssembly.Memory))
|
|
39
|
+
throw Error("Invalid multiformat WASM memory.");
|
|
40
|
+
scanner.extra = e;
|
|
41
|
+
scanner.handle = e.multi_new();
|
|
42
|
+
if (!scanner.handle)
|
|
43
|
+
throw Error("Could not create additional reader session.");
|
|
44
|
+
}
|
|
45
|
+
return scanner;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
scanner.dispose();
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
scan(image, inputFormats, options = {}) {
|
|
53
|
+
if (this.disposed)
|
|
54
|
+
throw Error("Scanner is disposed.");
|
|
55
|
+
const medium = this.medium;
|
|
56
|
+
if (!medium)
|
|
57
|
+
throw Error("Medium reader has not been initialized.");
|
|
58
|
+
const formats = resolveFormats(inputFormats);
|
|
59
|
+
const eanAddOnSymbol = options.eanAddOnSymbol ?? "Ignore";
|
|
60
|
+
if (!["Ignore", "Read", "Require"].includes(eanAddOnSymbol))
|
|
61
|
+
throw Error("Unknown EAN add-on policy.");
|
|
62
|
+
const requestedStrategy = options.linearStrategy ?? "scanlines";
|
|
63
|
+
if (!["scanlines", "medium-localized"].includes(requestedStrategy))
|
|
64
|
+
throw Error("Unknown linear strategy.");
|
|
65
|
+
// Supplements extend beyond the frozen Medium crop, so they require the
|
|
66
|
+
// full-frame supplemental pass. Default EAN13 remains untouched.
|
|
67
|
+
const linearStrategy = eanAddOnSymbol === "Ignore" ? requestedStrategy : "scanlines";
|
|
68
|
+
const localized = linearStrategy === "medium-localized" &&
|
|
69
|
+
formats.some((f) => f !== "EAN13" && f !== "UPCA" && supportedLinearFormats.includes(f));
|
|
70
|
+
let proposals = [];
|
|
71
|
+
const start = performance.now();
|
|
72
|
+
const { width, height, channels, stride, data } = image;
|
|
73
|
+
if (!Number.isSafeInteger(width) ||
|
|
74
|
+
!Number.isSafeInteger(height) ||
|
|
75
|
+
width < 3 ||
|
|
76
|
+
height < 3 ||
|
|
77
|
+
width * height > 32 * 1024 * 1024 ||
|
|
78
|
+
![1, 3, 4].includes(channels) ||
|
|
79
|
+
!Number.isSafeInteger(stride) ||
|
|
80
|
+
stride < width * channels ||
|
|
81
|
+
!(data instanceof Uint8Array) ||
|
|
82
|
+
data.length < (height - 1) * stride + width * channels)
|
|
83
|
+
throw Error("Invalid image dimensions or buffer.");
|
|
84
|
+
const barcodes = [], regions = [];
|
|
85
|
+
let unfinished = false, mediumMs = 0, additionalMs = 0, preparationMs = 0, localizationMs = 0;
|
|
86
|
+
if (formats.includes("EAN13") || formats.includes("UPCA")) {
|
|
87
|
+
const begin = performance.now();
|
|
88
|
+
const found = medium.scanLocalized(image, policy, fitLimits[this.mode], true);
|
|
89
|
+
const localization = found.localization;
|
|
90
|
+
proposals = localization.proposals;
|
|
91
|
+
localizationMs = found.localizationMs;
|
|
92
|
+
for (const b of found.scan.barcodes) {
|
|
93
|
+
if (formats.includes("UPCA") && b.text.startsWith("0"))
|
|
94
|
+
barcodes.push({
|
|
95
|
+
format: "UPCA",
|
|
96
|
+
text: b.text.slice(1),
|
|
97
|
+
polygon: b.polygon,
|
|
98
|
+
support: b.support,
|
|
99
|
+
});
|
|
100
|
+
else if (formats.includes("EAN13"))
|
|
101
|
+
barcodes.push({ format: "EAN13", text: b.text, polygon: b.polygon, support: b.support });
|
|
102
|
+
}
|
|
103
|
+
const decoded = new Set(found.scan.barcodes.flatMap((b) => b.candidate_indices));
|
|
104
|
+
localization.proposals.forEach((p, i) => {
|
|
105
|
+
if (!decoded.has(i))
|
|
106
|
+
regions.push({ format: "Unknown", text: "", polygon: p.polygon, support: 0 });
|
|
107
|
+
});
|
|
108
|
+
if ("recovery" in found) {
|
|
109
|
+
const recovery = found.recovery;
|
|
110
|
+
for (const attempt of recovery.attempts) {
|
|
111
|
+
const accepted = new Set(attempt.reads.flatMap((b) => b.candidate_indices));
|
|
112
|
+
attempt.proposals.forEach((p, i) => {
|
|
113
|
+
if (!accepted.has(i))
|
|
114
|
+
regions.push({ format: "Unknown", text: "", polygon: p.polygon, support: 0 });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
unfinished =
|
|
119
|
+
found.scan.unfinished ||
|
|
120
|
+
Boolean(localization.workLimited) ||
|
|
121
|
+
(localization.omitted ?? 0) > 0;
|
|
122
|
+
mediumMs = performance.now() - begin;
|
|
123
|
+
}
|
|
124
|
+
if (localized && !formats.includes("EAN13") && !formats.includes("UPCA")) {
|
|
125
|
+
const begin = performance.now();
|
|
126
|
+
const found = medium.scanLocalized(image, policy, fitLimits[this.mode], true)
|
|
127
|
+
.localization;
|
|
128
|
+
proposals = found.proposals;
|
|
129
|
+
unfinished ||= Boolean(found.workLimited) || (found.omitted ?? 0) > 0;
|
|
130
|
+
localizationMs = performance.now() - begin;
|
|
131
|
+
}
|
|
132
|
+
// UPC-A has the same optical structure as zero-prefixed EAN13, so the
|
|
133
|
+
// frozen Medium reader handles it without a second optical search.
|
|
134
|
+
const extraFormats = formats.filter((f) => eanAddOnSymbol !== "Ignore" || (f !== "EAN13" && f !== "UPCA"));
|
|
135
|
+
if (extraFormats.length) {
|
|
136
|
+
if (!this.extra || !this.handle)
|
|
137
|
+
throw Error("Additional readers have not been loaded.");
|
|
138
|
+
const begin = performance.now();
|
|
139
|
+
const gray = toGray(image);
|
|
140
|
+
preparationMs = performance.now() - begin;
|
|
141
|
+
const matrixFormats = extraFormats.filter((f) => !supportedLinearFormats.includes(f));
|
|
142
|
+
const linearFormats = extraFormats.filter((f) => supportedLinearFormats.includes(f));
|
|
143
|
+
const run = (pixels, w, h, enabled, effort) => {
|
|
144
|
+
const start = performance.now();
|
|
145
|
+
const result = this.scanExtra(pixels, w, h, enabled, effort, eanAddOnSymbol);
|
|
146
|
+
additionalMs += performance.now() - start;
|
|
147
|
+
unfinished ||= result.unfinished;
|
|
148
|
+
return result;
|
|
149
|
+
};
|
|
150
|
+
if (!localized || !proposals.length) {
|
|
151
|
+
const result = run(gray, width, height, extraFormats, 1);
|
|
152
|
+
barcodes.push(...result.barcodes);
|
|
153
|
+
regions.push(...(result.regions ?? []));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
if (matrixFormats.length) {
|
|
157
|
+
const result = run(gray, width, height, matrixFormats, 1);
|
|
158
|
+
barcodes.push(...result.barcodes);
|
|
159
|
+
regions.push(...(result.regions ?? []));
|
|
160
|
+
}
|
|
161
|
+
for (const proposal of proposals) {
|
|
162
|
+
const start = performance.now();
|
|
163
|
+
let crop;
|
|
164
|
+
try {
|
|
165
|
+
crop = rectify(gray, width, height, proposal.polygon);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
unfinished = true;
|
|
169
|
+
regions.push({ format: "Unknown", text: "", polygon: proposal.polygon, support: 0 });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
preparationMs += performance.now() - start;
|
|
173
|
+
const result = run(crop.data, crop.width, crop.height, linearFormats, 0);
|
|
174
|
+
const reads = result.barcodes;
|
|
175
|
+
if (!reads.length)
|
|
176
|
+
regions.push({ format: "Unknown", text: "", polygon: proposal.polygon, support: 0 });
|
|
177
|
+
for (const read of [...reads, ...(result.regions ?? [])]) {
|
|
178
|
+
const p = read.polygon;
|
|
179
|
+
const mapped = (p) => project(crop.transform, p[0], p[1]);
|
|
180
|
+
read.polygon = [mapped(p[0]), mapped(p[1]), mapped(p[2]), mapped(p[3])];
|
|
181
|
+
if (read.text)
|
|
182
|
+
barcodes.push(read);
|
|
183
|
+
else
|
|
184
|
+
regions.push(read);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (extraFormats.length) {
|
|
190
|
+
for (const read of barcodes.filter((b) => b.eanAddOn)) {
|
|
191
|
+
for (const base of barcodes) {
|
|
192
|
+
if (base.format === read.format &&
|
|
193
|
+
base.text === read.text &&
|
|
194
|
+
!base.eanAddOn &&
|
|
195
|
+
polygonOverlap(base.polygon, read.polygon).smaller >= 0.65)
|
|
196
|
+
base.eanAddOn = read.eanAddOn;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (eanAddOnSymbol === "Require") {
|
|
200
|
+
for (let i = barcodes.length - 1; i >= 0; i--) {
|
|
201
|
+
const b = barcodes[i];
|
|
202
|
+
if (["EAN13", "UPCA", "EAN8", "UPCE"].includes(b.format) && !b.eanAddOn) {
|
|
203
|
+
regions.push({ ...b, text: "" });
|
|
204
|
+
barcodes.splice(i, 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const distinct = distinctReads(barcodes);
|
|
209
|
+
barcodes.splice(0, barcodes.length, ...distinct);
|
|
210
|
+
const remaining = regions.filter((region) => !barcodes.some((b) => polygonOverlap(region.polygon, b.polygon).a >= 0.65));
|
|
211
|
+
regions.splice(0, regions.length, ...distinctReads(remaining));
|
|
212
|
+
}
|
|
213
|
+
barcodes.sort((a, b) => b.support - a.support);
|
|
214
|
+
barcodes.forEach((b, i) => {
|
|
215
|
+
b.rank = i + 1;
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
barcodes,
|
|
219
|
+
eanAddOnSymbol,
|
|
220
|
+
regions: [...barcodes, ...regions],
|
|
221
|
+
formats,
|
|
222
|
+
unfinished,
|
|
223
|
+
scanMs: performance.now() - start,
|
|
224
|
+
mediumMs,
|
|
225
|
+
additionalMs,
|
|
226
|
+
preparationMs,
|
|
227
|
+
localizationMs,
|
|
228
|
+
linearStrategy,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
scanExtra(gray, width, height, formats, effort, eanAddOnSymbol) {
|
|
232
|
+
const e = this.extra;
|
|
233
|
+
if (!e || !this.handle)
|
|
234
|
+
throw Error("Additional readers have not been loaded.");
|
|
235
|
+
if (e.multi_prepare(this.handle, width, height) !== 0)
|
|
236
|
+
throw Error("Additional reader rejected image size.");
|
|
237
|
+
new Uint8Array(e.memory.buffer, e.multi_input(this.handle), width * height).set(gray);
|
|
238
|
+
const mask = maskFor(formats) |
|
|
239
|
+
(eanAddOnSymbol === "Read" ? 32768 : eanAddOnSymbol === "Require" ? 65536 : 0);
|
|
240
|
+
if (e.multi_scan(this.handle, mask, effort) !== 0)
|
|
241
|
+
throw Error("Additional scanner failed.");
|
|
242
|
+
const bytes = new Uint8Array(e.memory.buffer, e.multi_output(this.handle), e.multi_output_len(this.handle));
|
|
243
|
+
const found = JSON.parse(new TextDecoder().decode(bytes));
|
|
244
|
+
for (const b of [...found.barcodes, ...(found.regions ?? [])])
|
|
245
|
+
if (!formats.includes(b.format))
|
|
246
|
+
throw Error("Scanner returned a disabled format.");
|
|
247
|
+
return found;
|
|
248
|
+
}
|
|
249
|
+
dispose() {
|
|
250
|
+
if (this.disposed)
|
|
251
|
+
return;
|
|
252
|
+
this.medium?.dispose();
|
|
253
|
+
if (this.handle)
|
|
254
|
+
this.extra?.multi_free(this.handle);
|
|
255
|
+
this.handle = 0;
|
|
256
|
+
this.disposed = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Independent EAN-13 supplied-region scanner. Experimental; no localizer or
|
|
2
|
+
* reference decoder is imported. All result confidence remains uncalibrated. */
|
|
3
|
+
export type Point = readonly [number, number];
|
|
4
|
+
export type Quad = readonly [Point, Point, Point, Point];
|
|
5
|
+
export interface Image {
|
|
6
|
+
data: Uint8Array;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
channels: 1 | 3 | 4;
|
|
10
|
+
stride: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Policy {
|
|
13
|
+
transitionCleanup?: boolean;
|
|
14
|
+
sourceIdentity?: boolean;
|
|
15
|
+
interiorNormalization?: boolean;
|
|
16
|
+
guardBias?: boolean;
|
|
17
|
+
/** Experimental: accept one full-quiet scanline; increases false-read risk. Default false. */
|
|
18
|
+
allowSingleRow?: boolean;
|
|
19
|
+
maxRetryPathsPerCandidate?: number;
|
|
20
|
+
maxRetryPathsPerFrame?: number;
|
|
21
|
+
maxAssociationChecks?: number;
|
|
22
|
+
maxAssociationPixels?: number;
|
|
23
|
+
maxResults?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface Detection {
|
|
26
|
+
text: string;
|
|
27
|
+
polygon: Quad;
|
|
28
|
+
support: number;
|
|
29
|
+
axis: number;
|
|
30
|
+
}
|
|
31
|
+
export interface Barcode extends Detection {
|
|
32
|
+
candidate_indices: number[];
|
|
33
|
+
}
|
|
34
|
+
export interface Observation {
|
|
35
|
+
text: string;
|
|
36
|
+
axis: number;
|
|
37
|
+
fraction: number;
|
|
38
|
+
left: number;
|
|
39
|
+
right: number;
|
|
40
|
+
cost: number;
|
|
41
|
+
gap: number;
|
|
42
|
+
}
|
|
43
|
+
export interface Candidate {
|
|
44
|
+
candidate_index: number;
|
|
45
|
+
coverage: readonly (readonly [number | null, number | null])[];
|
|
46
|
+
error: boolean;
|
|
47
|
+
error_detail: 'invalid_geometry' | 'sampling_error' | null;
|
|
48
|
+
unfinished: boolean;
|
|
49
|
+
ms: number;
|
|
50
|
+
work: Record<string, number>;
|
|
51
|
+
detections: Detection[];
|
|
52
|
+
observations: Observation[];
|
|
53
|
+
}
|
|
54
|
+
export interface ScanFrame {
|
|
55
|
+
unfinished: boolean;
|
|
56
|
+
candidates: Candidate[];
|
|
57
|
+
barcodes: Barcode[];
|
|
58
|
+
reconciliation: {
|
|
59
|
+
comparisons: number;
|
|
60
|
+
merged: number;
|
|
61
|
+
ambiguous: number;
|
|
62
|
+
conflicting: number;
|
|
63
|
+
pending_observations: number;
|
|
64
|
+
truncated: boolean;
|
|
65
|
+
source_pairs: number;
|
|
66
|
+
source_matches: number;
|
|
67
|
+
source_pixels: number;
|
|
68
|
+
source_capped: number;
|
|
69
|
+
pending_coverage_checks: number;
|
|
70
|
+
pending_quarantined: number;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export interface ScanResult extends ScanFrame {
|
|
74
|
+
/** This WASM build has no internal clock. Candidate ms values are unavailable zeros. */
|
|
75
|
+
candidateTimingsAvailable: false;
|
|
76
|
+
/** Actual host wall time, including validation, preparation/copies, decode and JSON parsing. */
|
|
77
|
+
elapsedMs: number;
|
|
78
|
+
}
|
|
79
|
+
/** Explicit indices reject sparse arrays; nonfinite numeric geometry reaches Rust. */
|
|
80
|
+
export declare function isQuadShape(value: unknown): value is Quad;
|
|
81
|
+
export declare class ScannerError extends Error {
|
|
82
|
+
readonly code: string;
|
|
83
|
+
constructor(code: string, message: string);
|
|
84
|
+
}
|
|
85
|
+
/** Validate before asynchronous work; returned bytes are owned by the caller. */
|
|
86
|
+
export declare function snapshotImage(image: Image): Image;
|
|
87
|
+
export declare function snapshotPolicy(policy: Policy): Policy;
|
|
88
|
+
/** Uncalibrated support ordering; ties preserve spatial output order. */
|
|
89
|
+
export declare function rankBarcodes(barcodes: readonly Barcode[]): Barcode[];
|
|
90
|
+
export declare class IndependentScanner {
|
|
91
|
+
#private;
|
|
92
|
+
private constructor();
|
|
93
|
+
static create(bytes: BufferSource): Promise<IndependentScanner>;
|
|
94
|
+
/** Localization-only entry point for opt-in independent linear decoders. */
|
|
95
|
+
localizeOnly(image: Image, fitLimit?: number): {
|
|
96
|
+
proposals: {
|
|
97
|
+
polygon: Quad;
|
|
98
|
+
score?: number;
|
|
99
|
+
}[];
|
|
100
|
+
workLimited?: boolean;
|
|
101
|
+
omitted?: number;
|
|
102
|
+
};
|
|
103
|
+
/** All supplied regions receive the cheap pass; successful reads do not end scanning. */
|
|
104
|
+
scan(image: Image, quads: readonly Quad[], policy?: Policy): ScanResult;
|
|
105
|
+
/** Synchronous transaction: upload once, localize, decode the same owned pixels. */
|
|
106
|
+
scanLocalized(image: Image, policy?: Policy, fitLimit?: number, fullFrame?: boolean): {
|
|
107
|
+
localization: any;
|
|
108
|
+
searchWindows: {
|
|
109
|
+
kind: string;
|
|
110
|
+
polygon: number[][];
|
|
111
|
+
candidateIndex: any;
|
|
112
|
+
}[];
|
|
113
|
+
scan: ScanResult;
|
|
114
|
+
localizationMs: number;
|
|
115
|
+
decodingMs: number;
|
|
116
|
+
scanMs: number;
|
|
117
|
+
};
|
|
118
|
+
/** Separate convenience; it never changes find-all work or suppresses frame evidence. */
|
|
119
|
+
best(result: ScanFrame): Barcode | undefined;
|
|
120
|
+
dispose(): void;
|
|
121
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** Explicit indices reject sparse arrays; nonfinite numeric geometry reaches Rust. */
|
|
2
|
+
export function isQuadShape(value) {
|
|
3
|
+
if (!Array.isArray(value) || value.length !== 4)
|
|
4
|
+
return false;
|
|
5
|
+
for (let i = 0; i < 4; i++) {
|
|
6
|
+
if (!Object.hasOwn(value, i))
|
|
7
|
+
return false;
|
|
8
|
+
const point = value[i];
|
|
9
|
+
if (!Array.isArray(point) || point.length !== 2)
|
|
10
|
+
return false;
|
|
11
|
+
for (let j = 0; j < 2; j++)
|
|
12
|
+
if (!Object.hasOwn(point, j) || typeof point[j] !== 'number')
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
export class ScannerError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) { super(message); this.name = 'ScannerError'; this.code = code; }
|
|
20
|
+
}
|
|
21
|
+
function integer(value, min, max, name) {
|
|
22
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
23
|
+
throw new ScannerError('invalid_input', `Invalid ${name}`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function status(code) {
|
|
27
|
+
if (code !== 0)
|
|
28
|
+
throw new ScannerError(`core_${code}`, `Independent scanner rejected operation (${code})`);
|
|
29
|
+
}
|
|
30
|
+
function parseFrame(text, count) {
|
|
31
|
+
const v = JSON.parse(text);
|
|
32
|
+
if (!v || typeof v !== 'object')
|
|
33
|
+
throw new ScannerError('invalid_output', 'Expected frame object');
|
|
34
|
+
const f = v;
|
|
35
|
+
if (!Array.isArray(f.candidates) || f.candidates.length !== count || !Array.isArray(f.barcodes) || typeof f.unfinished !== 'boolean' || !f.reconciliation)
|
|
36
|
+
throw new ScannerError('invalid_output', 'Invalid frame shape');
|
|
37
|
+
for (let i = 0; i < f.candidates.length; i++) {
|
|
38
|
+
const c = f.candidates[i];
|
|
39
|
+
if (c.candidate_index !== i || !Array.isArray(c.coverage) || c.coverage.length !== 4 || !Array.isArray(c.observations) || !Array.isArray(c.detections) || typeof c.error !== 'boolean')
|
|
40
|
+
throw new ScannerError('invalid_output', 'Invalid candidate shape');
|
|
41
|
+
}
|
|
42
|
+
for (const b of f.barcodes)
|
|
43
|
+
if (!/^\d{13}$/.test(b.text) || !Array.isArray(b.polygon) || b.polygon.length !== 4 || !Array.isArray(b.candidate_indices) || b.candidate_indices.some(i => !Number.isInteger(i) || i < 0 || i >= count))
|
|
44
|
+
throw new ScannerError('invalid_output', 'Invalid barcode shape');
|
|
45
|
+
return f;
|
|
46
|
+
}
|
|
47
|
+
/** Validate before asynchronous work; returned bytes are owned by the caller. */
|
|
48
|
+
export function snapshotImage(image) {
|
|
49
|
+
if (!image || typeof image !== 'object')
|
|
50
|
+
throw new ScannerError('invalid_input', 'Invalid image');
|
|
51
|
+
const width = integer(image.width, 1, 0xffffffff, 'width'), height = integer(image.height, 1, 0xffffffff, 'height');
|
|
52
|
+
if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
|
|
53
|
+
throw new ScannerError('invalid_input', 'Invalid image buffer/channels');
|
|
54
|
+
const stride = integer(image.stride, width * image.channels, 0xffffffff, 'stride');
|
|
55
|
+
const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, 'image length');
|
|
56
|
+
if (image.data.byteLength < required)
|
|
57
|
+
throw new ScannerError('invalid_input', 'Image buffer is too short');
|
|
58
|
+
return { data: new Uint8Array(image.data.subarray(0, required)), width, height, channels: image.channels, stride };
|
|
59
|
+
}
|
|
60
|
+
export function snapshotPolicy(policy) {
|
|
61
|
+
if (!policy || typeof policy !== 'object')
|
|
62
|
+
throw new ScannerError('invalid_input', 'Invalid policy');
|
|
63
|
+
const copy = { ...policy };
|
|
64
|
+
for (const key of ['transitionCleanup', 'sourceIdentity', 'interiorNormalization', 'guardBias', 'allowSingleRow'])
|
|
65
|
+
if (copy[key] !== undefined && typeof copy[key] !== 'boolean')
|
|
66
|
+
throw new ScannerError('invalid_input', `Invalid ${key}`);
|
|
67
|
+
integer(copy.maxRetryPathsPerCandidate ?? 512, 0, 4096, 'candidate budget');
|
|
68
|
+
integer(copy.maxRetryPathsPerFrame ?? 8192, 0, 65536, 'frame budget');
|
|
69
|
+
integer(copy.maxAssociationChecks ?? 200000, 0, 2000000, 'comparison budget');
|
|
70
|
+
integer(copy.maxAssociationPixels ?? 2000000, 0, 16000000, 'pixel budget');
|
|
71
|
+
integer(copy.maxResults ?? 1024, 1, 4096, 'result budget');
|
|
72
|
+
return copy;
|
|
73
|
+
}
|
|
74
|
+
/** Uncalibrated support ordering; ties preserve spatial output order. */
|
|
75
|
+
export function rankBarcodes(barcodes) {
|
|
76
|
+
return [...barcodes].sort((a, b) => b.support - a.support);
|
|
77
|
+
}
|
|
78
|
+
export class IndependentScanner {
|
|
79
|
+
#exports;
|
|
80
|
+
#handle;
|
|
81
|
+
constructor(exports) {
|
|
82
|
+
this.#exports = exports;
|
|
83
|
+
if (exports.regions_version() !== 1)
|
|
84
|
+
throw new ScannerError('abi_version', 'Unsupported scanner ABI');
|
|
85
|
+
this.#handle = exports.regions_new();
|
|
86
|
+
if (!this.#handle)
|
|
87
|
+
throw new ScannerError('capacity', 'Scanner handle capacity exhausted');
|
|
88
|
+
}
|
|
89
|
+
static async create(bytes) {
|
|
90
|
+
const module = await WebAssembly.compile(bytes);
|
|
91
|
+
const instance = await WebAssembly.instantiate(module, {});
|
|
92
|
+
const exports = instance.exports;
|
|
93
|
+
for (const name of ['regions_localize', 'regions_version', 'regions_new', 'regions_destroy', 'regions_prepare', 'regions_input_ptr', 'regions_input_len', 'regions_quads_ptr', 'regions_output_ptr', 'regions_output_len', 'regions_scan']) {
|
|
94
|
+
if (typeof instance.exports[name] !== 'function')
|
|
95
|
+
throw new ScannerError('abi_shape', `Missing ${name}`);
|
|
96
|
+
}
|
|
97
|
+
if (!(exports.memory instanceof WebAssembly.Memory))
|
|
98
|
+
throw new ScannerError('abi_shape', 'Missing memory');
|
|
99
|
+
return new IndependentScanner(exports);
|
|
100
|
+
}
|
|
101
|
+
/** Localization-only entry point for opt-in independent linear decoders. */
|
|
102
|
+
localizeOnly(image, fitLimit = 8) {
|
|
103
|
+
if (!this.#handle)
|
|
104
|
+
throw new ScannerError('disposed', 'Scanner is disposed');
|
|
105
|
+
const width = integer(image.width, 3, 0xffffffff, 'width'), height = integer(image.height, 3, 0xffffffff, 'height');
|
|
106
|
+
if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
|
|
107
|
+
throw new ScannerError('invalid_input', 'Invalid image buffer/channels');
|
|
108
|
+
const stride = integer(image.stride, width * image.channels, 0xffffffff, 'stride');
|
|
109
|
+
const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, 'image length');
|
|
110
|
+
if (image.data.byteLength < required)
|
|
111
|
+
throw new ScannerError('invalid_input', 'Image buffer is too short');
|
|
112
|
+
integer(fitLimit, 0, 8, 'shear limit');
|
|
113
|
+
const e = this.#exports, id = this.#handle;
|
|
114
|
+
status(e.regions_prepare(id, width, height, image.channels, stride));
|
|
115
|
+
if (e.regions_input_len(id) !== required)
|
|
116
|
+
throw new ScannerError('abi_shape', 'Input allocation mismatch');
|
|
117
|
+
new Uint8Array(e.memory.buffer, e.regions_input_ptr(id), required).set(image.data.subarray(0, required));
|
|
118
|
+
status(e.regions_localize(id, fitLimit));
|
|
119
|
+
const localization = JSON.parse(new TextDecoder().decode(new Uint8Array(e.memory.buffer, e.regions_output_ptr(id), e.regions_output_len(id))));
|
|
120
|
+
if (!Array.isArray(localization.proposals) || localization.proposals.length > 32 || localization.proposals.some(p => !isQuadShape(p.polygon)))
|
|
121
|
+
throw new ScannerError('abi_shape', 'Invalid localization');
|
|
122
|
+
return localization;
|
|
123
|
+
}
|
|
124
|
+
/** All supplied regions receive the cheap pass; successful reads do not end scanning. */
|
|
125
|
+
scan(image, quads, policy = {}) {
|
|
126
|
+
return this.#scan(image, quads, policy, false);
|
|
127
|
+
}
|
|
128
|
+
/** Synchronous transaction: upload once, localize, decode the same owned pixels. */
|
|
129
|
+
scanLocalized(image, policy = {}, fitLimit = 8, fullFrame = false) {
|
|
130
|
+
const start = performance.now();
|
|
131
|
+
if (!this.#handle)
|
|
132
|
+
throw new ScannerError('disposed', 'Scanner is disposed');
|
|
133
|
+
if (!image || typeof image !== 'object')
|
|
134
|
+
throw new ScannerError('invalid_input', 'Invalid image');
|
|
135
|
+
const width = integer(image.width, 3, 0xffffffff, 'width'), height = integer(image.height, 3, 0xffffffff, 'height');
|
|
136
|
+
if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
|
|
137
|
+
throw new ScannerError('invalid_input', 'Invalid image buffer/channels');
|
|
138
|
+
const stride = integer(image.stride, width * image.channels, 0xffffffff, 'stride');
|
|
139
|
+
const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, 'image length');
|
|
140
|
+
if (image.data.byteLength < required)
|
|
141
|
+
throw new ScannerError('invalid_input', 'Image buffer is too short');
|
|
142
|
+
integer(fitLimit, 0, 8, 'shear limit');
|
|
143
|
+
const e = this.#exports, id = this.#handle;
|
|
144
|
+
status(e.regions_prepare(id, width, height, image.channels, stride));
|
|
145
|
+
if (e.regions_input_len(id) !== required)
|
|
146
|
+
throw new ScannerError('abi_shape', 'Input allocation mismatch');
|
|
147
|
+
new Uint8Array(e.memory.buffer, e.regions_input_ptr(id), required).set(image.data.subarray(0, required));
|
|
148
|
+
status(e.regions_localize(id, fitLimit));
|
|
149
|
+
const localization = JSON.parse(new TextDecoder().decode(new Uint8Array(e.memory.buffer, e.regions_output_ptr(id), e.regions_output_len(id))));
|
|
150
|
+
if (!Array.isArray(localization.proposals) || localization.proposals.length > 32 || localization.proposals.some((p) => !isQuadShape(p.polygon)))
|
|
151
|
+
throw new ScannerError('abi_shape', 'Invalid localization');
|
|
152
|
+
const searchWindows = fullFrame ? [{ kind: 'full_frame_search', polygon: [[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], candidateIndex: localization.proposals.length }] : [];
|
|
153
|
+
const localizationMs = performance.now() - start, decodeStart = performance.now();
|
|
154
|
+
const scan = this.#scan(image, [...localization.proposals.map((p) => p.polygon), ...searchWindows.map(p => p.polygon)], policy, true);
|
|
155
|
+
return { localization, searchWindows, scan, localizationMs, decodingMs: performance.now() - decodeStart, scanMs: performance.now() - start };
|
|
156
|
+
}
|
|
157
|
+
#scan(image, quads, policy, prepared) {
|
|
158
|
+
const start = performance.now();
|
|
159
|
+
if (!this.#handle)
|
|
160
|
+
throw new ScannerError('disposed', 'Scanner is disposed');
|
|
161
|
+
if (!image || typeof image !== 'object' || !Array.isArray(quads) || !policy || typeof policy !== 'object')
|
|
162
|
+
throw new ScannerError('invalid_input', 'Invalid scan arguments');
|
|
163
|
+
for (const key of ['transitionCleanup', 'sourceIdentity', 'interiorNormalization', 'guardBias', 'allowSingleRow'])
|
|
164
|
+
if (policy[key] !== undefined && typeof policy[key] !== 'boolean')
|
|
165
|
+
throw new ScannerError('invalid_input', `Invalid ${key}`);
|
|
166
|
+
const e = this.#exports, id = this.#handle;
|
|
167
|
+
const width = integer(image.width, 1, 0xffffffff, 'width'), height = integer(image.height, 1, 0xffffffff, 'height');
|
|
168
|
+
if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
|
|
169
|
+
throw new ScannerError('invalid_input', 'Invalid image buffer/channels');
|
|
170
|
+
const stride = integer(image.stride, width * image.channels, 0xffffffff, 'stride');
|
|
171
|
+
const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, 'image length');
|
|
172
|
+
if (image.data.byteLength < required)
|
|
173
|
+
throw new ScannerError('invalid_input', 'Image buffer is too short');
|
|
174
|
+
integer(quads.length, 0, 64, 'candidate count');
|
|
175
|
+
for (const q of quads)
|
|
176
|
+
if (!isQuadShape(q))
|
|
177
|
+
throw new ScannerError('invalid_input', 'Invalid quad shape');
|
|
178
|
+
const perCandidate = integer(policy.maxRetryPathsPerCandidate ?? 512, 0, 4096, 'candidate budget');
|
|
179
|
+
const perFrame = integer(policy.maxRetryPathsPerFrame ?? 8192, 0, 65536, 'frame budget');
|
|
180
|
+
const checks = integer(policy.maxAssociationChecks ?? 200000, 0, 2000000, 'comparison budget');
|
|
181
|
+
const pixels = integer(policy.maxAssociationPixels ?? 2000000, 0, 16000000, 'pixel budget');
|
|
182
|
+
const results = integer(policy.maxResults ?? 1024, 1, 4096, 'result budget');
|
|
183
|
+
const flags = (policy.transitionCleanup ? 1 : 0) | (policy.sourceIdentity ? 2 : 0) | (policy.interiorNormalization ? 4 : 0) | (policy.guardBias ? 8 : 0) | (policy.allowSingleRow ? 16 : 0);
|
|
184
|
+
if (!prepared) {
|
|
185
|
+
status(e.regions_prepare(id, width, height, image.channels, stride));
|
|
186
|
+
if (e.regions_input_len(id) !== required)
|
|
187
|
+
throw new ScannerError('abi_shape', 'Input allocation mismatch');
|
|
188
|
+
new Uint8Array(e.memory.buffer, e.regions_input_ptr(id), required).set(image.data.subarray(0, required));
|
|
189
|
+
}
|
|
190
|
+
const coordinates = new Float64Array(e.memory.buffer, e.regions_quads_ptr(id), 512);
|
|
191
|
+
quads.forEach((q, i) => q.forEach((p, j) => { coordinates[i * 8 + j * 2] = p[0]; coordinates[i * 8 + j * 2 + 1] = p[1]; }));
|
|
192
|
+
status(e.regions_scan(id, quads.length, flags, perCandidate, perFrame, checks, pixels, results));
|
|
193
|
+
// Scan can grow memory. Never reuse the earlier input/coordinate views.
|
|
194
|
+
const output = new Uint8Array(e.memory.buffer, e.regions_output_ptr(id), e.regions_output_len(id)).slice();
|
|
195
|
+
const frame = parseFrame(new TextDecoder().decode(output), quads.length);
|
|
196
|
+
return { ...frame, candidateTimingsAvailable: false, elapsedMs: performance.now() - start };
|
|
197
|
+
}
|
|
198
|
+
/** Separate convenience; it never changes find-all work or suppresses frame evidence. */
|
|
199
|
+
best(result) {
|
|
200
|
+
return rankBarcodes(result.barcodes)[0];
|
|
201
|
+
}
|
|
202
|
+
dispose() { if (this.#handle) {
|
|
203
|
+
const id = this.#handle;
|
|
204
|
+
this.#handle = 0;
|
|
205
|
+
status(this.#exports.regions_destroy(id));
|
|
206
|
+
} }
|
|
207
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const policy: Readonly<{
|
|
2
|
+
transitionCleanup: true;
|
|
3
|
+
sourceIdentity: true;
|
|
4
|
+
interiorNormalization: true;
|
|
5
|
+
guardBias: true;
|
|
6
|
+
allowSingleRow: false;
|
|
7
|
+
maxRetryPathsPerCandidate: 512;
|
|
8
|
+
maxRetryPathsPerFrame: 8192;
|
|
9
|
+
maxAssociationChecks: 200000;
|
|
10
|
+
maxAssociationPixels: 2000000;
|
|
11
|
+
maxResults: 1024;
|
|
12
|
+
}>;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const policy = Object.freeze({
|
|
2
|
+
transitionCleanup: true,
|
|
3
|
+
sourceIdentity: true,
|
|
4
|
+
interiorNormalization: true,
|
|
5
|
+
guardBias: true,
|
|
6
|
+
allowSingleRow: false,
|
|
7
|
+
maxRetryPathsPerCandidate: 512,
|
|
8
|
+
maxRetryPathsPerFrame: 8192,
|
|
9
|
+
maxAssociationChecks: 200000,
|
|
10
|
+
maxAssociationPixels: 2000000,
|
|
11
|
+
maxResults: 1024,
|
|
12
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tapirscan",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Orientation-aware barcode scanning with a Rust/WASM core and four effort modes",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"wasm/multiformat.json",
|
|
15
|
+
"THIRD_PARTY_NOTICES.md",
|
|
16
|
+
"wasm/low-release-20260915.wasm",
|
|
17
|
+
"wasm/medium-release-20260915.wasm",
|
|
18
|
+
"wasm/high-release-20260915.wasm",
|
|
19
|
+
"wasm/very-high-release-20260915.wasm",
|
|
20
|
+
"wasm/multiformat.wasm"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"test": "node --test test/scanner.mjs",
|
|
25
|
+
"prepack": "npm run build && node scripts/verify-package.mjs"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "5.8.3"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"barcode",
|
|
32
|
+
"ean13",
|
|
33
|
+
"scanner",
|
|
34
|
+
"wasm",
|
|
35
|
+
"computer-vision",
|
|
36
|
+
"typescript"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"author": "Florian Nick",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/kleinicke/tapirscan.git",
|
|
46
|
+
"directory": "bindings/javascript"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/kleinicke/tapirscan#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/kleinicke/tapirscan/issues"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|