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,62 @@
|
|
|
1
|
+
/** A bounded physical continuity claim; equal text alone never establishes identity. */
|
|
2
|
+
export function coveredByContinuousBars(image, point, barcode) {
|
|
3
|
+
const polygon = barcode.polygon;
|
|
4
|
+
const left = [(polygon[0][0] + polygon[3][0]) / 2, (polygon[0][1] + polygon[3][1]) / 2];
|
|
5
|
+
const right = [(polygon[1][0] + polygon[2][0]) / 2, (polygon[1][1] + polygon[2][1]) / 2];
|
|
6
|
+
const dx = right[0] - left[0], dy = right[1] - left[1], width = Math.hypot(dx, dy);
|
|
7
|
+
if (width < 45)
|
|
8
|
+
return false;
|
|
9
|
+
const nx = -dy / width, ny = dx / width;
|
|
10
|
+
const along = ((point[0] - left[0]) * dx + (point[1] - left[1]) * dy) / (width * width);
|
|
11
|
+
const offset = (point[0] - left[0]) * nx + (point[1] - left[1]) * ny;
|
|
12
|
+
if (along < 0 || along > 1 || Math.abs(offset) > Math.min(128, width * 0.5))
|
|
13
|
+
return false;
|
|
14
|
+
const { data, width: iw, height: ih } = image;
|
|
15
|
+
const gray = (x, y) => {
|
|
16
|
+
x = Math.max(0, Math.min(iw - 1, x));
|
|
17
|
+
y = Math.max(0, Math.min(ih - 1, y));
|
|
18
|
+
const x0 = Math.floor(x), y0 = Math.floor(y), x1 = Math.min(iw - 1, x0 + 1), y1 = Math.min(ih - 1, y0 + 1), fx = x - x0, fy = y - y0;
|
|
19
|
+
const sample = (a, b) => {
|
|
20
|
+
const k = (b * iw + a) * 4;
|
|
21
|
+
return (77 * data[k] + 150 * data[k + 1] + 29 * data[k + 2]) / 256;
|
|
22
|
+
};
|
|
23
|
+
return ((sample(x0, y0) * (1 - fx) + sample(x1, y0) * fx) * (1 - fy) +
|
|
24
|
+
(sample(x0, y1) * (1 - fx) + sample(x1, y1) * fx) * fy);
|
|
25
|
+
};
|
|
26
|
+
const count = Math.min(256, Math.max(95, Math.round(width * 1.5)));
|
|
27
|
+
const profile = (displacement) => {
|
|
28
|
+
const values = new Float64Array(count);
|
|
29
|
+
let sum = 0, energy = 0;
|
|
30
|
+
for (let i = 0; i < count; i++) {
|
|
31
|
+
const fraction = 0.02 + (0.96 * i) / (count - 1);
|
|
32
|
+
const v = gray(left[0] + dx * fraction + nx * displacement, left[1] + dy * fraction + ny * displacement);
|
|
33
|
+
values[i] = v;
|
|
34
|
+
sum += v;
|
|
35
|
+
energy += v * v;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
values,
|
|
39
|
+
mean: sum / count,
|
|
40
|
+
deviation: Math.sqrt(Math.max(0, energy / count - (sum / count) ** 2)),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
const reference = profile(0);
|
|
44
|
+
if (reference.deviation < 5)
|
|
45
|
+
return false;
|
|
46
|
+
const agrees = (displacement) => {
|
|
47
|
+
const row = profile(displacement);
|
|
48
|
+
if (row.deviation < reference.deviation * 0.65 || row.deviation < 5)
|
|
49
|
+
return false;
|
|
50
|
+
let covariance = 0;
|
|
51
|
+
for (let i = 0; i < count; i++)
|
|
52
|
+
covariance += (reference.values[i] - reference.mean) * (row.values[i] - row.mean);
|
|
53
|
+
return covariance / (count * reference.deviation * row.deviation) > 0.8;
|
|
54
|
+
};
|
|
55
|
+
if (!agrees(offset) || !agrees(offset / 2))
|
|
56
|
+
return false;
|
|
57
|
+
const steps = Math.ceil(Math.abs(offset) * 2);
|
|
58
|
+
for (let i = 1; i < steps; i++)
|
|
59
|
+
if (!agrees((offset * i) / steps))
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Source-resolution directional texture proposals. No labels or decoded text as inputs. */
|
|
2
|
+
export function detailProposals(image: any, count?: number, tileSize?: number, sampleStep?: number): {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
score: number;
|
|
10
|
+
coherence: number;
|
|
11
|
+
energy: number;
|
|
12
|
+
angle: number;
|
|
13
|
+
}[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Source-resolution directional texture proposals. No labels or decoded text as inputs. */
|
|
2
|
+
export function detailProposals(image, count = 4, tileSize = 320, sampleStep = 2) {
|
|
3
|
+
const { data, width, height } = image;
|
|
4
|
+
const size = 24, step = sampleStep, cells = [];
|
|
5
|
+
const gray = (x, y) => {
|
|
6
|
+
const i = (y * width + x) * 4;
|
|
7
|
+
return (77 * data[i] + 150 * data[i + 1] + 29 * data[i + 2]) / 256;
|
|
8
|
+
};
|
|
9
|
+
for (let y = 1; y + size < height; y += size)
|
|
10
|
+
for (let x = 1; x + size < width; x += size) {
|
|
11
|
+
let xx = 0, yy = 0, xy = 0, sx = 0, sy = 0, n = 0;
|
|
12
|
+
for (let j = y; j < y + size; j += step)
|
|
13
|
+
for (let i = x; i < x + size; i += step) {
|
|
14
|
+
const v = gray(i, j), gx = gray(i + 1, j) - v, gy = gray(i, j + 1) - v;
|
|
15
|
+
xx += gx * gx;
|
|
16
|
+
yy += gy * gy;
|
|
17
|
+
xy += gx * gy;
|
|
18
|
+
sx += gx;
|
|
19
|
+
sy += gy;
|
|
20
|
+
n++;
|
|
21
|
+
}
|
|
22
|
+
xx = Math.max(0, xx - (sx * sx) / n);
|
|
23
|
+
yy = Math.max(0, yy - (sy * sy) / n);
|
|
24
|
+
xy -= (sx * sy) / n;
|
|
25
|
+
const energy = (xx + yy) / n, coherence = Math.sqrt((xx - yy) ** 2 + 4 * xy * xy) / (xx + yy + 1);
|
|
26
|
+
const score = Math.sqrt(energy) * coherence * coherence;
|
|
27
|
+
if (score > 5)
|
|
28
|
+
cells.push({
|
|
29
|
+
x: x + size / 2,
|
|
30
|
+
y: y + size / 2,
|
|
31
|
+
score,
|
|
32
|
+
coherence,
|
|
33
|
+
energy,
|
|
34
|
+
angle: 0.5 * Math.atan2(2 * xy, xx - yy),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
cells.sort((a, b) => b.score - a.score);
|
|
38
|
+
const selected = [];
|
|
39
|
+
for (const c of cells) {
|
|
40
|
+
if (selected.some((s) => Math.hypot(s.x - c.x, s.y - c.y) < 128))
|
|
41
|
+
continue;
|
|
42
|
+
selected.push({
|
|
43
|
+
...c,
|
|
44
|
+
left: Math.max(0, Math.min(width - tileSize, Math.round(c.x - tileSize / 2))),
|
|
45
|
+
top: Math.max(0, Math.min(height - tileSize, Math.round(c.y - tileSize / 2))),
|
|
46
|
+
width: Math.min(width, tileSize),
|
|
47
|
+
height: Math.min(height, tileSize),
|
|
48
|
+
});
|
|
49
|
+
if (selected.length === count)
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
return selected;
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Bounded source-guided hypotheses, without another localizer/shear search per crop. */
|
|
2
|
+
export function recoverDirectSeed(image: any, scanner: any, policy: any, baseline: any, budget?: number, factor?: number, sequential?: boolean, scharr?: boolean, minimumSpan?: number, maxDirections?: number): {
|
|
3
|
+
barcodes: any[];
|
|
4
|
+
additions: any[];
|
|
5
|
+
attempts: {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
w: number;
|
|
9
|
+
h: number;
|
|
10
|
+
factor: number;
|
|
11
|
+
frame: any;
|
|
12
|
+
reads: any;
|
|
13
|
+
deferredReads: any;
|
|
14
|
+
proposals: {
|
|
15
|
+
polygon: number[][];
|
|
16
|
+
score: number;
|
|
17
|
+
text: string;
|
|
18
|
+
}[];
|
|
19
|
+
unfinished: any;
|
|
20
|
+
}[];
|
|
21
|
+
proposals: {
|
|
22
|
+
polygon: number[][];
|
|
23
|
+
score: number;
|
|
24
|
+
text: string;
|
|
25
|
+
}[];
|
|
26
|
+
extraMs: number;
|
|
27
|
+
searchLimited: boolean;
|
|
28
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { makeCanvas } from "../detail-canvas.js";
|
|
2
|
+
import { detailProposals } from "./detail-proposals-rich.mjs";
|
|
3
|
+
import { sourceEvidence } from "./source-evidence.mjs";
|
|
4
|
+
import { coveredByContinuousBars } from "./continuity.mjs";
|
|
5
|
+
function refinedAngle(image, seed) {
|
|
6
|
+
const gray = (x, y) => {
|
|
7
|
+
const i = (y * image.width + x) * 4;
|
|
8
|
+
return (77 * image.data[i] + 150 * image.data[i + 1] + 29 * image.data[i + 2]) / 256;
|
|
9
|
+
};
|
|
10
|
+
let xx = 0, yy = 0, xy = 0, sx = 0, sy = 0, n = 0;
|
|
11
|
+
for (let y = Math.max(1, Math.round(seed.y) - 12); y < Math.min(image.height - 1, seed.y + 12); y++)
|
|
12
|
+
for (let x = Math.max(1, Math.round(seed.x) - 12); x < Math.min(image.width - 1, seed.x + 12); x++) {
|
|
13
|
+
const a = gray(x - 1, y - 1), b = gray(x, y - 1), c = gray(x + 1, y - 1), d = gray(x - 1, y), e = gray(x + 1, y), f = gray(x - 1, y + 1), g = gray(x, y + 1), h = gray(x + 1, y + 1);
|
|
14
|
+
const gx = (3 * (c - a) + 10 * (e - d) + 3 * (h - f)) / 16, gy = (3 * (f - a) + 10 * (g - b) + 3 * (h - c)) / 16;
|
|
15
|
+
xx += gx * gx;
|
|
16
|
+
yy += gy * gy;
|
|
17
|
+
xy += gx * gy;
|
|
18
|
+
sx += gx;
|
|
19
|
+
sy += gy;
|
|
20
|
+
n++;
|
|
21
|
+
}
|
|
22
|
+
return 0.5 * Math.atan2(2 * (xy - (sx * sy) / n), xx - (sx * sx) / n - yy + (sy * sy) / n);
|
|
23
|
+
}
|
|
24
|
+
function contains(point, q) {
|
|
25
|
+
let hit = false;
|
|
26
|
+
for (let i = 0, j = q.length - 1; i < q.length; j = i++) {
|
|
27
|
+
if (q[i][1] > point[1] !== q[j][1] > point[1] &&
|
|
28
|
+
point[0] < ((q[j][0] - q[i][0]) * (point[1] - q[i][1])) / (q[j][1] - q[i][1]) + q[i][0])
|
|
29
|
+
hit = !hit;
|
|
30
|
+
}
|
|
31
|
+
return hit;
|
|
32
|
+
}
|
|
33
|
+
function covered(image, point, barcode) {
|
|
34
|
+
return contains(point, barcode.polygon) || coveredByContinuousBars(image, point, barcode);
|
|
35
|
+
}
|
|
36
|
+
function sourceSpan(p) {
|
|
37
|
+
const dx = p[1][0] - p[0][0], dy = p[1][1] - p[0][1];
|
|
38
|
+
return (Math.abs(dx * (p[3][1] - p[0][1]) - dy * (p[3][0] - p[0][0])) /
|
|
39
|
+
Math.max(1e-9, Math.hypot(dx, dy)));
|
|
40
|
+
}
|
|
41
|
+
/** Bounded source-guided hypotheses, without another localizer/shear search per crop. */
|
|
42
|
+
export function recoverDirectSeed(image, scanner, policy, baseline, budget = 64, factor = 3, sequential = false, scharr = false, minimumSpan = 0, maxDirections = Infinity) {
|
|
43
|
+
const start = performance.now();
|
|
44
|
+
const seeds = detailProposals(image, 2, 256, 4);
|
|
45
|
+
const additions = [], attempts = [], proposals = [];
|
|
46
|
+
const tile = makeCanvas(1, 1), up = makeCanvas(1, 1);
|
|
47
|
+
const tc = tile.getContext("2d"), uc = up.getContext("2d", { willReadFrequently: true });
|
|
48
|
+
for (const seed of seeds) {
|
|
49
|
+
if (seed.score < seeds[0].score * 0.6)
|
|
50
|
+
continue;
|
|
51
|
+
if ([...baseline.scan.barcodes, ...additions].some((b) => covered(image, [seed.x, seed.y], b)))
|
|
52
|
+
continue;
|
|
53
|
+
const quads = [];
|
|
54
|
+
const initialAngle = scharr ? refinedAngle(image, seed) : seed.angle;
|
|
55
|
+
const directions = (scharr ? [0, -3, 3] : [0, -5, 5, -10, 10, -15, 15, -20, 20]).map((offset) => initialAngle + (offset * Math.PI) / 180);
|
|
56
|
+
for (const angle of directions) {
|
|
57
|
+
const c = Math.cos(angle), s = Math.sin(angle);
|
|
58
|
+
const polygon = [
|
|
59
|
+
[-96, -24],
|
|
60
|
+
[96, -24],
|
|
61
|
+
[96, 24],
|
|
62
|
+
[-96, 24],
|
|
63
|
+
].map(([u, v]) => [seed.x + u * c - v * s, seed.y + u * s + v * c]);
|
|
64
|
+
const evidence = sourceEvidence(image, polygon, true, 32);
|
|
65
|
+
if (Math.max(evidence.transitions, evidence.localTransitions) >= 32)
|
|
66
|
+
quads.push(polygon);
|
|
67
|
+
if (quads.length >= maxDirections)
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
if (!quads.length)
|
|
71
|
+
continue;
|
|
72
|
+
const size = 256, w = Math.min(size, image.width), h = Math.min(size, image.height);
|
|
73
|
+
const x = Math.max(0, Math.min(image.width - w, Math.round(seed.x - w / 2)));
|
|
74
|
+
const y = Math.max(0, Math.min(image.height - h, Math.round(seed.y - h / 2)));
|
|
75
|
+
const bytes = new Uint8ClampedArray(w * h * 4);
|
|
76
|
+
for (let row = 0; row < h; row++) {
|
|
77
|
+
const from = ((y + row) * image.width + x) * 4;
|
|
78
|
+
bytes.set(image.data.subarray(from, from + w * 4), row * w * 4);
|
|
79
|
+
}
|
|
80
|
+
tile.width = w;
|
|
81
|
+
tile.height = h;
|
|
82
|
+
tc.putImageData({ data: bytes, width: w, height: h }, 0, 0);
|
|
83
|
+
up.width = w * factor;
|
|
84
|
+
up.height = h * factor;
|
|
85
|
+
uc.imageSmoothingEnabled = true;
|
|
86
|
+
uc.drawImage(tile, 0, 0, up.width, up.height);
|
|
87
|
+
const pixels = uc.getImageData(0, 0, up.width, up.height);
|
|
88
|
+
const batches = sequential ? quads.map((q) => [q]) : [quads];
|
|
89
|
+
for (const batch of batches) {
|
|
90
|
+
const result = scanner.scan({
|
|
91
|
+
data: new Uint8Array(pixels.data.buffer),
|
|
92
|
+
width: up.width,
|
|
93
|
+
height: up.height,
|
|
94
|
+
channels: 4,
|
|
95
|
+
stride: up.width * 4,
|
|
96
|
+
}, batch.map((q) => q.map(([a, b]) => [(a - x) * factor, (b - y) * factor])), {
|
|
97
|
+
...policy,
|
|
98
|
+
maxRetryPathsPerCandidate: budget,
|
|
99
|
+
maxRetryPathsPerFrame: budget * batch.length,
|
|
100
|
+
});
|
|
101
|
+
const allReads = result.barcodes.map((b) => ({
|
|
102
|
+
...b,
|
|
103
|
+
polygon: b.polygon.map(([a, b]) => [x + a / factor, y + b / factor]),
|
|
104
|
+
}));
|
|
105
|
+
const reads = allReads.filter((b) => sourceSpan(b.polygon) >= minimumSpan);
|
|
106
|
+
const deferredReads = allReads.filter((b) => sourceSpan(b.polygon) < minimumSpan);
|
|
107
|
+
for (const read of reads) {
|
|
108
|
+
const center = read.polygon.reduce((a, p) => [a[0] + p[0] / 4, a[1] + p[1] / 4], [0, 0]);
|
|
109
|
+
if (![...baseline.scan.barcodes, ...additions].some((old) => old.text === read.text && covered(image, center, old)))
|
|
110
|
+
additions.push(read);
|
|
111
|
+
}
|
|
112
|
+
const localized = batch.map((polygon) => ({ polygon, score: seed.score, text: "" }));
|
|
113
|
+
proposals.push(...localized);
|
|
114
|
+
attempts.push({
|
|
115
|
+
x,
|
|
116
|
+
y,
|
|
117
|
+
w,
|
|
118
|
+
h,
|
|
119
|
+
factor,
|
|
120
|
+
// Raw evidence remains in crop coordinates with this explicit transform.
|
|
121
|
+
frame: result,
|
|
122
|
+
reads,
|
|
123
|
+
deferredReads,
|
|
124
|
+
proposals: localized,
|
|
125
|
+
unfinished: result.unfinished || deferredReads.length > 0,
|
|
126
|
+
});
|
|
127
|
+
if (sequential && reads.some((b) => covered(image, [seed.x, seed.y], b)))
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
barcodes: [...baseline.scan.barcodes, ...additions],
|
|
133
|
+
additions,
|
|
134
|
+
attempts,
|
|
135
|
+
proposals,
|
|
136
|
+
extraMs: performance.now() - start,
|
|
137
|
+
searchLimited: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Explicit indices reject sparse arrays; nonfinite numeric geometry reaches Rust. */
|
|
2
|
+
export function isQuadShape(value: any): boolean;
|
|
3
|
+
/** Validate before asynchronous work; returned bytes are owned by the caller. */
|
|
4
|
+
export function snapshotImage(image: any): {
|
|
5
|
+
data: Uint8Array<any>;
|
|
6
|
+
width: any;
|
|
7
|
+
height: any;
|
|
8
|
+
channels: any;
|
|
9
|
+
stride: any;
|
|
10
|
+
};
|
|
11
|
+
export function snapshotPolicy(policy: any): any;
|
|
12
|
+
/** Uncalibrated support ordering; ties preserve spatial output order. */
|
|
13
|
+
export function rankBarcodes(barcodes: any): any[];
|
|
14
|
+
export class ScannerError extends Error {
|
|
15
|
+
constructor(code: any, message: any);
|
|
16
|
+
code: any;
|
|
17
|
+
}
|
|
18
|
+
export class IndependentScanner {
|
|
19
|
+
static create(bytes: any): Promise<IndependentScanner>;
|
|
20
|
+
constructor(exports: any);
|
|
21
|
+
/** All supplied regions receive the cheap pass; successful reads do not end scanning. */
|
|
22
|
+
scan(image: any, quads: any, policy?: {}): any;
|
|
23
|
+
/** Synchronous transaction: upload once, localize, decode the same owned pixels. */
|
|
24
|
+
scanLocalized(image: any, policy?: {}, fitLimit?: number, fullFrame?: boolean, transform?: undefined): {
|
|
25
|
+
localization: any;
|
|
26
|
+
searchWindows: {
|
|
27
|
+
kind: string;
|
|
28
|
+
polygon: number[][];
|
|
29
|
+
candidateIndex: any;
|
|
30
|
+
}[];
|
|
31
|
+
scan: any;
|
|
32
|
+
localizationMs: number;
|
|
33
|
+
decodingMs: number;
|
|
34
|
+
scanMs: number;
|
|
35
|
+
};
|
|
36
|
+
/** Separate convenience; it never changes find-all work or suppresses frame evidence. */
|
|
37
|
+
best(result: any): any;
|
|
38
|
+
dispose(): void;
|
|
39
|
+
#private;
|
|
40
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
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) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "ScannerError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function integer(value, min, max, name) {
|
|
26
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
27
|
+
throw new ScannerError("invalid_input", `Invalid ${name}`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function status(code) {
|
|
31
|
+
if (code !== 0)
|
|
32
|
+
throw new ScannerError(`core_${code}`, `Independent scanner rejected operation (${code})`);
|
|
33
|
+
}
|
|
34
|
+
function parseFrame(text, count) {
|
|
35
|
+
const v = JSON.parse(text);
|
|
36
|
+
if (!v || typeof v !== "object")
|
|
37
|
+
throw new ScannerError("invalid_output", "Expected frame object");
|
|
38
|
+
const f = v;
|
|
39
|
+
if (!Array.isArray(f.candidates) ||
|
|
40
|
+
f.candidates.length !== count ||
|
|
41
|
+
!Array.isArray(f.barcodes) ||
|
|
42
|
+
typeof f.unfinished !== "boolean" ||
|
|
43
|
+
!f.reconciliation)
|
|
44
|
+
throw new ScannerError("invalid_output", "Invalid frame shape");
|
|
45
|
+
for (let i = 0; i < f.candidates.length; i++) {
|
|
46
|
+
const c = f.candidates[i];
|
|
47
|
+
if (c.candidate_index !== i ||
|
|
48
|
+
!Array.isArray(c.coverage) ||
|
|
49
|
+
c.coverage.length !== 4 ||
|
|
50
|
+
!Array.isArray(c.observations) ||
|
|
51
|
+
!Array.isArray(c.detections) ||
|
|
52
|
+
typeof c.error !== "boolean")
|
|
53
|
+
throw new ScannerError("invalid_output", "Invalid candidate shape");
|
|
54
|
+
}
|
|
55
|
+
for (const b of f.barcodes)
|
|
56
|
+
if (!/^\d{13}$/.test(b.text) ||
|
|
57
|
+
!Array.isArray(b.polygon) ||
|
|
58
|
+
b.polygon.length !== 4 ||
|
|
59
|
+
!Array.isArray(b.candidate_indices) ||
|
|
60
|
+
b.candidate_indices.some((i) => !Number.isInteger(i) || i < 0 || i >= count))
|
|
61
|
+
throw new ScannerError("invalid_output", "Invalid barcode shape");
|
|
62
|
+
return f;
|
|
63
|
+
}
|
|
64
|
+
/** Validate before asynchronous work; returned bytes are owned by the caller. */
|
|
65
|
+
export function snapshotImage(image) {
|
|
66
|
+
if (!image || typeof image !== "object")
|
|
67
|
+
throw new ScannerError("invalid_input", "Invalid image");
|
|
68
|
+
const width = integer(image.width, 1, 0xffffffff, "width"), height = integer(image.height, 1, 0xffffffff, "height");
|
|
69
|
+
if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
|
|
70
|
+
throw new ScannerError("invalid_input", "Invalid image buffer/channels");
|
|
71
|
+
const stride = integer(image.stride, width * image.channels, 0xffffffff, "stride");
|
|
72
|
+
const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, "image length");
|
|
73
|
+
if (image.data.byteLength < required)
|
|
74
|
+
throw new ScannerError("invalid_input", "Image buffer is too short");
|
|
75
|
+
return {
|
|
76
|
+
data: new Uint8Array(image.data.subarray(0, required)),
|
|
77
|
+
width,
|
|
78
|
+
height,
|
|
79
|
+
channels: image.channels,
|
|
80
|
+
stride,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function snapshotPolicy(policy) {
|
|
84
|
+
if (!policy || typeof policy !== "object")
|
|
85
|
+
throw new ScannerError("invalid_input", "Invalid policy");
|
|
86
|
+
const copy = { ...policy };
|
|
87
|
+
for (const key of [
|
|
88
|
+
"transitionCleanup",
|
|
89
|
+
"sourceIdentity",
|
|
90
|
+
"interiorNormalization",
|
|
91
|
+
"guardBias",
|
|
92
|
+
"allowSingleRow",
|
|
93
|
+
])
|
|
94
|
+
if (copy[key] !== undefined && typeof copy[key] !== "boolean")
|
|
95
|
+
throw new ScannerError("invalid_input", `Invalid ${key}`);
|
|
96
|
+
integer(copy.maxRetryPathsPerCandidate ?? 512, 0, 4096, "candidate budget");
|
|
97
|
+
integer(copy.maxRetryPathsPerFrame ?? 8192, 0, 65536, "frame budget");
|
|
98
|
+
integer(copy.maxAssociationChecks ?? 200000, 0, 2000000, "comparison budget");
|
|
99
|
+
integer(copy.maxAssociationPixels ?? 2000000, 0, 16000000, "pixel budget");
|
|
100
|
+
integer(copy.maxResults ?? 1024, 1, 4096, "result budget");
|
|
101
|
+
return copy;
|
|
102
|
+
}
|
|
103
|
+
/** Uncalibrated support ordering; ties preserve spatial output order. */
|
|
104
|
+
export function rankBarcodes(barcodes) {
|
|
105
|
+
return [...barcodes].sort((a, b) => b.support - a.support);
|
|
106
|
+
}
|
|
107
|
+
export class IndependentScanner {
|
|
108
|
+
#exports;
|
|
109
|
+
#handle;
|
|
110
|
+
constructor(exports) {
|
|
111
|
+
this.#exports = exports;
|
|
112
|
+
if (exports.regions_version() !== 1)
|
|
113
|
+
throw new ScannerError("abi_version", "Unsupported scanner ABI");
|
|
114
|
+
this.#handle = exports.regions_new();
|
|
115
|
+
if (!this.#handle)
|
|
116
|
+
throw new ScannerError("capacity", "Scanner handle capacity exhausted");
|
|
117
|
+
}
|
|
118
|
+
static async create(bytes) {
|
|
119
|
+
const module = await WebAssembly.compile(bytes);
|
|
120
|
+
const instance = await WebAssembly.instantiate(module, {});
|
|
121
|
+
const exports = instance.exports;
|
|
122
|
+
for (const name of [
|
|
123
|
+
"regions_localize",
|
|
124
|
+
"regions_version",
|
|
125
|
+
"regions_new",
|
|
126
|
+
"regions_destroy",
|
|
127
|
+
"regions_prepare",
|
|
128
|
+
"regions_input_ptr",
|
|
129
|
+
"regions_input_len",
|
|
130
|
+
"regions_quads_ptr",
|
|
131
|
+
"regions_output_ptr",
|
|
132
|
+
"regions_output_len",
|
|
133
|
+
"regions_scan",
|
|
134
|
+
]) {
|
|
135
|
+
if (typeof instance.exports[name] !== "function")
|
|
136
|
+
throw new ScannerError("abi_shape", `Missing ${name}`);
|
|
137
|
+
}
|
|
138
|
+
if (!(exports.memory instanceof WebAssembly.Memory))
|
|
139
|
+
throw new ScannerError("abi_shape", "Missing memory");
|
|
140
|
+
return new IndependentScanner(exports);
|
|
141
|
+
}
|
|
142
|
+
/** All supplied regions receive the cheap pass; successful reads do not end scanning. */
|
|
143
|
+
scan(image, quads, policy = {}) {
|
|
144
|
+
return this.#scan(image, quads, policy, false);
|
|
145
|
+
}
|
|
146
|
+
/** Synchronous transaction: upload once, localize, decode the same owned pixels. */
|
|
147
|
+
scanLocalized(image, policy = {}, fitLimit = 8, fullFrame = false, transform = undefined) {
|
|
148
|
+
const start = performance.now();
|
|
149
|
+
if (!this.#handle)
|
|
150
|
+
throw new ScannerError("disposed", "Scanner is disposed");
|
|
151
|
+
if (!image || typeof image !== "object")
|
|
152
|
+
throw new ScannerError("invalid_input", "Invalid image");
|
|
153
|
+
const width = integer(image.width, 3, 0xffffffff, "width"), height = integer(image.height, 3, 0xffffffff, "height");
|
|
154
|
+
if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
|
|
155
|
+
throw new ScannerError("invalid_input", "Invalid image buffer/channels");
|
|
156
|
+
const stride = integer(image.stride, width * image.channels, 0xffffffff, "stride");
|
|
157
|
+
const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, "image length");
|
|
158
|
+
if (image.data.byteLength < required)
|
|
159
|
+
throw new ScannerError("invalid_input", "Image buffer is too short");
|
|
160
|
+
integer(fitLimit, 0, 8, "shear limit");
|
|
161
|
+
const e = this.#exports, id = this.#handle;
|
|
162
|
+
status(e.regions_prepare(id, width, height, image.channels, stride));
|
|
163
|
+
if (e.regions_input_len(id) !== required)
|
|
164
|
+
throw new ScannerError("abi_shape", "Input allocation mismatch");
|
|
165
|
+
new Uint8Array(e.memory.buffer, e.regions_input_ptr(id), required).set(image.data.subarray(0, required));
|
|
166
|
+
status(e.regions_localize(id, fitLimit));
|
|
167
|
+
let localization = JSON.parse(new TextDecoder().decode(new Uint8Array(e.memory.buffer, e.regions_output_ptr(id), e.regions_output_len(id))));
|
|
168
|
+
if (transform)
|
|
169
|
+
localization = transform(localization);
|
|
170
|
+
if (localization.retryMask)
|
|
171
|
+
policy = { ...policy, retryMask: localization.retryMask };
|
|
172
|
+
if (!Array.isArray(localization.proposals) ||
|
|
173
|
+
localization.proposals.length > (fullFrame ? 63 : 64) ||
|
|
174
|
+
localization.proposals.some((p) => !isQuadShape(p.polygon)))
|
|
175
|
+
throw new ScannerError("abi_shape", "Invalid localization");
|
|
176
|
+
const searchWindows = fullFrame
|
|
177
|
+
? [
|
|
178
|
+
{
|
|
179
|
+
kind: "full_frame_search",
|
|
180
|
+
polygon: [
|
|
181
|
+
[0, 0],
|
|
182
|
+
[width - 1, 0],
|
|
183
|
+
[width - 1, height - 1],
|
|
184
|
+
[0, height - 1],
|
|
185
|
+
],
|
|
186
|
+
candidateIndex: localization.proposals.length,
|
|
187
|
+
},
|
|
188
|
+
]
|
|
189
|
+
: [];
|
|
190
|
+
const localizationMs = performance.now() - start, decodeStart = performance.now();
|
|
191
|
+
const scan = this.#scan(image, [...localization.proposals.map((p) => p.polygon), ...searchWindows.map((p) => p.polygon)], policy, true);
|
|
192
|
+
return {
|
|
193
|
+
localization,
|
|
194
|
+
searchWindows,
|
|
195
|
+
scan,
|
|
196
|
+
localizationMs,
|
|
197
|
+
decodingMs: performance.now() - decodeStart,
|
|
198
|
+
scanMs: performance.now() - start,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
#scan(image, quads, policy, prepared) {
|
|
202
|
+
const start = performance.now();
|
|
203
|
+
if (!this.#handle)
|
|
204
|
+
throw new ScannerError("disposed", "Scanner is disposed");
|
|
205
|
+
if (!image ||
|
|
206
|
+
typeof image !== "object" ||
|
|
207
|
+
!Array.isArray(quads) ||
|
|
208
|
+
!policy ||
|
|
209
|
+
typeof policy !== "object")
|
|
210
|
+
throw new ScannerError("invalid_input", "Invalid scan arguments");
|
|
211
|
+
for (const key of [
|
|
212
|
+
"transitionCleanup",
|
|
213
|
+
"sourceIdentity",
|
|
214
|
+
"interiorNormalization",
|
|
215
|
+
"guardBias",
|
|
216
|
+
"allowSingleRow",
|
|
217
|
+
])
|
|
218
|
+
if (policy[key] !== undefined && typeof policy[key] !== "boolean")
|
|
219
|
+
throw new ScannerError("invalid_input", `Invalid ${key}`);
|
|
220
|
+
const e = this.#exports, id = this.#handle;
|
|
221
|
+
const width = integer(image.width, 1, 0xffffffff, "width"), height = integer(image.height, 1, 0xffffffff, "height");
|
|
222
|
+
if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
|
|
223
|
+
throw new ScannerError("invalid_input", "Invalid image buffer/channels");
|
|
224
|
+
const stride = integer(image.stride, width * image.channels, 0xffffffff, "stride");
|
|
225
|
+
const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, "image length");
|
|
226
|
+
if (image.data.byteLength < required)
|
|
227
|
+
throw new ScannerError("invalid_input", "Image buffer is too short");
|
|
228
|
+
integer(quads.length, 0, 64, "candidate count");
|
|
229
|
+
for (const q of quads)
|
|
230
|
+
if (!isQuadShape(q))
|
|
231
|
+
throw new ScannerError("invalid_input", "Invalid quad shape");
|
|
232
|
+
const perCandidate = integer(policy.maxRetryPathsPerCandidate ?? 512, 0, 4096, "candidate budget");
|
|
233
|
+
const perFrame = integer(policy.maxRetryPathsPerFrame ?? 8192, 0, 65536, "frame budget");
|
|
234
|
+
const checks = integer(policy.maxAssociationChecks ?? 200000, 0, 2000000, "comparison budget");
|
|
235
|
+
const pixels = integer(policy.maxAssociationPixels ?? 2000000, 0, 16000000, "pixel budget");
|
|
236
|
+
const results = integer(policy.maxResults ?? 1024, 1, 4096, "result budget");
|
|
237
|
+
const flags = (policy.transitionCleanup ? 1 : 0) |
|
|
238
|
+
(policy.sourceIdentity ? 2 : 0) |
|
|
239
|
+
(policy.interiorNormalization ? 4 : 0) |
|
|
240
|
+
(policy.guardBias ? 8 : 0) |
|
|
241
|
+
(policy.allowSingleRow ? 16 : 0);
|
|
242
|
+
if (!prepared) {
|
|
243
|
+
status(e.regions_prepare(id, width, height, image.channels, stride));
|
|
244
|
+
if (e.regions_input_len(id) !== required)
|
|
245
|
+
throw new ScannerError("abi_shape", "Input allocation mismatch");
|
|
246
|
+
new Uint8Array(e.memory.buffer, e.regions_input_ptr(id), required).set(image.data.subarray(0, required));
|
|
247
|
+
}
|
|
248
|
+
const coordinates = new Float64Array(e.memory.buffer, e.regions_quads_ptr(id), 512);
|
|
249
|
+
quads.forEach((q, i) => q.forEach((p, j) => {
|
|
250
|
+
coordinates[i * 8 + j * 2] = p[0];
|
|
251
|
+
coordinates[i * 8 + j * 2 + 1] = p[1];
|
|
252
|
+
}));
|
|
253
|
+
const mask = policy.retryMask ?? [4294967295, 4294967295];
|
|
254
|
+
if (!Array.isArray(mask) || mask.length !== 2)
|
|
255
|
+
throw new ScannerError("invalid_input", "Invalid retry mask");
|
|
256
|
+
for (const value of mask)
|
|
257
|
+
integer(value, 0, 4294967295, "retry mask");
|
|
258
|
+
if (e.regions_scan_mask)
|
|
259
|
+
status(e.regions_scan_mask(id, quads.length, flags, perCandidate, perFrame, checks, pixels, results, mask[0], mask[1]));
|
|
260
|
+
else if (policy.retryMask)
|
|
261
|
+
throw new ScannerError("abi_shape", "Missing retry scheduling ABI");
|
|
262
|
+
else
|
|
263
|
+
status(e.regions_scan(id, quads.length, flags, perCandidate, perFrame, checks, pixels, results));
|
|
264
|
+
// Scan can grow memory. Never reuse the earlier input/coordinate views.
|
|
265
|
+
const output = new Uint8Array(e.memory.buffer, e.regions_output_ptr(id), e.regions_output_len(id)).slice();
|
|
266
|
+
const frame = parseFrame(new TextDecoder().decode(output), quads.length);
|
|
267
|
+
return { ...frame, candidateTimingsAvailable: false, elapsedMs: performance.now() - start };
|
|
268
|
+
}
|
|
269
|
+
/** Separate convenience; it never changes find-all work or suppresses frame evidence. */
|
|
270
|
+
best(result) {
|
|
271
|
+
return rankBarcodes(result.barcodes)[0];
|
|
272
|
+
}
|
|
273
|
+
dispose() {
|
|
274
|
+
if (this.#handle) {
|
|
275
|
+
const id = this.#handle;
|
|
276
|
+
this.#handle = 0;
|
|
277
|
+
status(this.#exports.regions_destroy(id));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Preserve undecoded geometry without assigning crop indices to primary proposals. */
|
|
2
|
+
export function detailRegions(found: any, recovery: any): any;
|
|
3
|
+
/** Workbench composition. Each crop keeps its own candidate-index namespace. */
|
|
4
|
+
export class DetailScanner {
|
|
5
|
+
static create(primaryBytes: any, recoveryBytes: any, directions: any): Promise<DetailScanner>;
|
|
6
|
+
constructor(primary: any, recovery: any, directions: any);
|
|
7
|
+
primary: any;
|
|
8
|
+
recovery: any;
|
|
9
|
+
directions: any;
|
|
10
|
+
scanLocalized(image: any, policy: any, fitLimit: any, fullFrame: any): any;
|
|
11
|
+
dispose(): void;
|
|
12
|
+
}
|