@graciousstar/node-red-contrib-vision-tools 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.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Local (per-tile) alignment refinement.
3
+ *
4
+ * The global transform gets the label into place; it cannot get all of it
5
+ * into place. Measured on this project's own good pair - artwork against
6
+ * a photograph of the print, after a correctly recovered 5-DOF transform
7
+ * - the leftover displacement has a median of 0.73px, a 90th percentile
8
+ * of 1.55px, and individual regions sitting 4-5px out that match their
9
+ * golden counterpart near-perfectly once shifted.
10
+ *
11
+ * That field is not smooth, so raising the global model does not reach
12
+ * it: fitting a homography (8 DOF) to it removed 18% of the residual, and
13
+ * a full quadratic (12 DOF) only 27%. A global warp cannot pull one
14
+ * corner 5px left while leaving the two-thirds of the label that is
15
+ * already sub-pixel untouched. The physical reason is that a label on a
16
+ * formed tray is not a plane - regions lift and bow independently - and
17
+ * no global parametrisation describes that.
18
+ *
19
+ * So each tile finds its own small offset. Two properties matter for
20
+ * correctness:
21
+ *
22
+ * - The offsets are **capped** (maxOffset). Uncapped, a tile could slide
23
+ * onto neighbouring ink and quietly align a genuine fault away. The
24
+ * cap is what keeps this a registration refinement rather than a
25
+ * licence to match anything.
26
+ * - Tiles without enough contrast to localise are **not trusted**. A
27
+ * blank tile matches equally well everywhere, so its "best" offset is
28
+ * noise; it is filled from its neighbours instead.
29
+ *
30
+ * The field is median-filtered (a spurious match is a lone disagreeing
31
+ * tile, while real substrate movement is coherent across several) and
32
+ * then interpolated bilinearly between tile centres when resampling.
33
+ * Applying a piecewise-constant shift per tile would step at every tile
34
+ * boundary and manufacture a seam of false defects along each one.
35
+ */
36
+
37
+ "use strict";
38
+
39
+ const { allocU8, allocF32 } = require("./shared.js");
40
+
41
+ /**
42
+ * Whole-pixel sample with edge clamping - deliberately NOT interpolated.
43
+ *
44
+ * Bilinear resampling here was tried and is actively harmful. Interpolating
45
+ * at a fractional offset is a low-pass filter, and the features this node
46
+ * has to see are one to two pixels wide: on the demo capture it blurred
47
+ * the pen mark until it no longer crossed the ink level, dropping the
48
+ * defect ratio by 82% and turning a failing part into a passing one. It
49
+ * quietly ate real ink everywhere else too, taking a clean part's
50
+ * background ratio from 0.00009 to exactly 0.
51
+ *
52
+ * So the displacement field is interpolated smoothly and then rounded,
53
+ * and pixels are moved rather than mixed. The cost is that correction is
54
+ * quantised to whole pixels - the measured median residual is ~0.75px, so
55
+ * roughly half of it survives - but the several-pixel outliers that
56
+ * actually manufacture false regions are removed, and no contrast is lost
57
+ * doing it. Sharpness is worth more than sub-pixel here.
58
+ */
59
+ function sampleNearest(src, w, h, x, y) {
60
+ if (x < 0) x = 0;
61
+ else if (x > w - 1) x = w - 1;
62
+ if (y < 0) y = 0;
63
+ else if (y > h - 1) y = h - 1;
64
+ return src[y * w + x];
65
+ }
66
+
67
+ /**
68
+ * Mean squared difference between the golden tile and the frame shifted
69
+ * by (dx,dy). Subsampled by 2 in both axes - this is called (2r+1)^2
70
+ * times per tile and the extra precision buys nothing at this scale.
71
+ */
72
+ // Subsample the tile when matching. This runs (2r+1)^2 times per tile
73
+ // over several hundred tiles, so it is worth being stingy: going from
74
+ // every 2nd pixel to every 3rd took local refinement from ~170ms to
75
+ // ~120ms on a 1844x2656 golden with no measurable change to the field it
76
+ // produces. The tile is 96px, so even at step 3 there are ~1000 samples
77
+ // behind each offset.
78
+ const SSD_STEP = 3;
79
+
80
+ function tileSsd(golden, target, w, h, x0, y0, x1, y1, dx, dy) {
81
+ let sum = 0;
82
+ let count = 0;
83
+ for (let y = y0; y < y1; y += SSD_STEP) {
84
+ const sy = y + dy;
85
+ if (sy < 0 || sy >= h) continue;
86
+ const gRow = y * w;
87
+ const tRow = sy * w;
88
+ for (let x = x0; x < x1; x += SSD_STEP) {
89
+ const sx = x + dx;
90
+ if (sx < 0 || sx >= w) continue;
91
+ const d = golden[gRow + x] - target[tRow + sx];
92
+ sum += d * d;
93
+ count++;
94
+ }
95
+ }
96
+ return count ? sum / count : Infinity;
97
+ }
98
+
99
+ /** Standard deviation of a golden tile - its ability to localise at all. */
100
+ function tileStdDev(golden, w, x0, y0, x1, y1) {
101
+ let sum = 0;
102
+ let sumSq = 0;
103
+ let n = 0;
104
+ for (let y = y0; y < y1; y += 2) {
105
+ const row = y * w;
106
+ for (let x = x0; x < x1; x += 2) {
107
+ const v = golden[row + x];
108
+ sum += v;
109
+ sumSq += v * v;
110
+ n++;
111
+ }
112
+ }
113
+ if (n === 0) return 0;
114
+ const mean = sum / n;
115
+ const variance = sumSq / n - mean * mean;
116
+ return variance > 0 ? Math.sqrt(variance) : 0;
117
+ }
118
+
119
+ /** Sub-pixel minimum from a parabola through three SSD samples. */
120
+ function parabolic(before, at, after) {
121
+ const denom = before - 2 * at + after;
122
+ if (Math.abs(denom) < 1e-9) return 0;
123
+ const shift = (0.5 * (before - after)) / denom;
124
+ return shift > 1 || shift < -1 ? 0 : shift;
125
+ }
126
+
127
+ /**
128
+ * Per-tile displacement of the frame relative to the golden, in golden
129
+ * pixels: the golden's pixel (x,y) is found in the frame at
130
+ * (x + fx, y + fy).
131
+ */
132
+ /**
133
+ * Fill tile rows [gyLo,gyHi) of an existing field. Tile rows are
134
+ * independent, so this is the unit the worker pool splits - and it calls
135
+ * this same function, so there is one implementation rather than a copy
136
+ * that could drift from it.
137
+ */
138
+ function fieldRows(goldenGray, targetGray, width, height, opts, out, gyLo, gyHi) {
139
+ const tile = opts.tile;
140
+ const maxOffset = opts.maxOffset;
141
+ const minStdDev = opts.minStdDev;
142
+ const { fx, fy, valid, gridW } = out;
143
+
144
+ for (let gy = gyLo; gy < gyHi; gy++) {
145
+ const y0 = gy * tile;
146
+ const y1 = Math.min(height, y0 + tile);
147
+ for (let gx = 0; gx < gridW; gx++) {
148
+ const x0 = gx * tile;
149
+ const x1 = Math.min(width, x0 + tile);
150
+ const cell = gy * gridW + gx;
151
+ if (tileStdDev(goldenGray, width, x0, y0, x1, y1) < minStdDev) continue;
152
+
153
+ let bestDx = 0;
154
+ let bestDy = 0;
155
+ let bestVal = Infinity;
156
+ for (let dy = -maxOffset; dy <= maxOffset; dy++) {
157
+ for (let dx = -maxOffset; dx <= maxOffset; dx++) {
158
+ const v = tileSsd(goldenGray, targetGray, width, height, x0, y0, x1, y1, dx, dy);
159
+ if (v < bestVal) {
160
+ bestVal = v;
161
+ bestDx = dx;
162
+ bestDy = dy;
163
+ }
164
+ }
165
+ }
166
+ // a minimum sitting on the edge of the search box is not a
167
+ // minimum - the true one is outside the cap, and trusting it
168
+ // would be extrapolating past where we agreed to look
169
+ if (Math.abs(bestDx) === maxOffset || Math.abs(bestDy) === maxOffset) continue;
170
+
171
+ const sx = parabolic(
172
+ tileSsd(goldenGray, targetGray, width, height, x0, y0, x1, y1, bestDx - 1, bestDy),
173
+ bestVal,
174
+ tileSsd(goldenGray, targetGray, width, height, x0, y0, x1, y1, bestDx + 1, bestDy),
175
+ );
176
+ const sy = parabolic(
177
+ tileSsd(goldenGray, targetGray, width, height, x0, y0, x1, y1, bestDx, bestDy - 1),
178
+ bestVal,
179
+ tileSsd(goldenGray, targetGray, width, height, x0, y0, x1, y1, bestDx, bestDy + 1),
180
+ );
181
+ fx[cell] = bestDx + sx;
182
+ fy[cell] = bestDy + sy;
183
+ valid[cell] = 1;
184
+ }
185
+ }
186
+ }
187
+
188
+ function buildDisplacementField(goldenGray, targetGray, width, height, opts) {
189
+ const gridW = Math.max(1, Math.ceil(width / opts.tile));
190
+ const gridH = Math.max(1, Math.ceil(height / opts.tile));
191
+ const out = {
192
+ fx: allocF32(gridW * gridH),
193
+ fy: allocF32(gridW * gridH),
194
+ valid: allocU8(gridW * gridH),
195
+ gridW,
196
+ gridH,
197
+ };
198
+ fieldRows(goldenGray, targetGray, width, height, opts, out, 0, gridH);
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * 3x3 median over valid neighbours, which also fills the invalid tiles.
204
+ * A tile that matched something spurious disagrees with everything around
205
+ * it, while real substrate movement is coherent over several tiles - so
206
+ * the median keeps the second and discards the first. Filling blank tiles
207
+ * from their neighbours matters as much: leaving them at zero would put a
208
+ * step between a blank tile and a genuinely displaced one beside it, and
209
+ * the resampling would smear ink across that step.
210
+ */
211
+ function smoothField(field) {
212
+ const { fx, fy, valid, gridW, gridH } = field;
213
+ const outX = allocF32(fx.length);
214
+ const outY = allocF32(fy.length);
215
+ const outValid = allocU8(valid.length);
216
+ const bufX = [];
217
+ const bufY = [];
218
+ const median = (arr) => {
219
+ arr.sort((a, b) => a - b);
220
+ const mid = arr.length >> 1;
221
+ return arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2;
222
+ };
223
+
224
+ for (let gy = 0; gy < gridH; gy++) {
225
+ for (let gx = 0; gx < gridW; gx++) {
226
+ const cell = gy * gridW + gx;
227
+ bufX.length = 0;
228
+ bufY.length = 0;
229
+ for (let dy = -1; dy <= 1; dy++) {
230
+ const ny = gy + dy;
231
+ if (ny < 0 || ny >= gridH) continue;
232
+ for (let dx = -1; dx <= 1; dx++) {
233
+ const nx = gx + dx;
234
+ if (nx < 0 || nx >= gridW) continue;
235
+ const n = ny * gridW + nx;
236
+ if (!valid[n]) continue;
237
+ bufX.push(fx[n]);
238
+ bufY.push(fy[n]);
239
+ }
240
+ }
241
+ if (bufX.length === 0) continue;
242
+ outX[cell] = median(bufX);
243
+ outY[cell] = median(bufY);
244
+ outValid[cell] = 1;
245
+ }
246
+ }
247
+ return { fx: outX, fy: outY, valid: outValid, gridW, gridH };
248
+ }
249
+
250
+ /**
251
+ * Resample the frame under the displacement field: interpolated smoothly
252
+ * between tile centres, then rounded to whole pixels for the sample
253
+ * itself. See sampleNearest for why the image is never interpolated.
254
+ */
255
+ /** Resample rows [yLo,yHi) under the field. Rows are independent. */
256
+ function applyRows(out, targetGray, width, height, field, tile, yLo, yHi) {
257
+ const { fx, fy, gridW, gridH } = field;
258
+ for (let y = yLo; y < yHi; y++) {
259
+ // tile centres sit at (g + 0.5) * tile, so the field coordinate of
260
+ // an image row is y/tile - 0.5
261
+ let gyf = y / tile - 0.5;
262
+ if (gyf < 0) gyf = 0;
263
+ else if (gyf > gridH - 1) gyf = gridH - 1;
264
+ const gy0 = Math.floor(gyf);
265
+ const gy1 = gy0 + 1 < gridH ? gy0 + 1 : gy0;
266
+ const wy = gyf - gy0;
267
+ const row = y * width;
268
+ for (let x = 0; x < width; x++) {
269
+ let gxf = x / tile - 0.5;
270
+ if (gxf < 0) gxf = 0;
271
+ else if (gxf > gridW - 1) gxf = gridW - 1;
272
+ const gx0 = Math.floor(gxf);
273
+ const gx1 = gx0 + 1 < gridW ? gx0 + 1 : gx0;
274
+ const wx = gxf - gx0;
275
+
276
+ const i00 = gy0 * gridW + gx0;
277
+ const i01 = gy0 * gridW + gx1;
278
+ const i10 = gy1 * gridW + gx0;
279
+ const i11 = gy1 * gridW + gx1;
280
+ const top = fx[i00] + (fx[i01] - fx[i00]) * wx;
281
+ const bot = fx[i10] + (fx[i11] - fx[i10]) * wx;
282
+ const dx = top + (bot - top) * wy;
283
+ const topY = fy[i00] + (fy[i01] - fy[i00]) * wx;
284
+ const botY = fy[i10] + (fy[i11] - fy[i10]) * wx;
285
+ const dy = topY + (botY - topY) * wy;
286
+
287
+ out[row + x] = sampleNearest(
288
+ targetGray,
289
+ width,
290
+ height,
291
+ x + Math.round(dx),
292
+ y + Math.round(dy),
293
+ );
294
+ }
295
+ }
296
+ }
297
+
298
+ function applyField(targetGray, width, height, field, tile) {
299
+ const out = allocU8(width * height);
300
+ applyRows(out, targetGray, width, height, field, tile, 0, height);
301
+ return out;
302
+ }
303
+
304
+ /**
305
+ * Refine an already globally-aligned frame tile by tile. Returns the
306
+ * corrected grey plus what it had to move, so a caller can see whether
307
+ * the rig is drifting rather than only that it was compensated for.
308
+ */
309
+ function refineLocally(goldenGray, alignedTargetGray, width, height, cfg) {
310
+ const tile = Math.max(8, cfg.localAlignTile | 0);
311
+ const maxOffset = Math.max(1, cfg.localAlignMax | 0);
312
+ const raw = buildDisplacementField(goldenGray, alignedTargetGray, width, height, {
313
+ tile,
314
+ maxOffset,
315
+ minStdDev: cfg.localAlignMinStdDev != null ? cfg.localAlignMinStdDev : 12,
316
+ });
317
+ const field = smoothField(raw);
318
+
319
+ return {
320
+ gray: applyField(alignedTargetGray, width, height, field, tile),
321
+ field,
322
+ tile,
323
+ stats: fieldStats(field),
324
+ };
325
+ }
326
+
327
+ /** How far the field had to move things - reported so a drifting rig is
328
+ * visible rather than merely compensated for. */
329
+ function fieldStats(field) {
330
+ let localised = 0;
331
+ let sum = 0;
332
+ let max = 0;
333
+ const magnitudes = [];
334
+ for (let i = 0; i < field.valid.length; i++) {
335
+ if (!field.valid[i]) continue;
336
+ const m = Math.hypot(field.fx[i], field.fy[i]);
337
+ magnitudes.push(m);
338
+ localised++;
339
+ sum += m;
340
+ if (m > max) max = m;
341
+ }
342
+ magnitudes.sort((a, b) => a - b);
343
+ return {
344
+ tiles: field.valid.length,
345
+ localised,
346
+ meanPx: localised ? sum / localised : 0,
347
+ medianPx: magnitudes.length ? magnitudes[magnitudes.length >> 1] : 0,
348
+ maxPx: max,
349
+ };
350
+ }
351
+
352
+ module.exports = {
353
+ refineLocally,
354
+ buildDisplacementField,
355
+ fieldRows,
356
+ smoothField,
357
+ applyField,
358
+ applyRows,
359
+ fieldStats,
360
+ };
package/lib/locate.js ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Barcode location + decode, kept independent of Node-RED (plain functions
3
+ * over Buffers/plain objects) so it can be exercised from a standalone
4
+ * script without booting the runtime - same convention as
5
+ * node-red-contrib-golden-compare/lib.
6
+ *
7
+ * Uses zxing-wasm (the real zxing-cpp C++ engine as WebAssembly) rather than
8
+ * @zxing/library (the pure-JS port node-red-contrib-image-tools' Barcode
9
+ * Decoder uses) - genuine multi-symbol detection, rotation tolerance, and
10
+ * native DataMatrix support, all needed to reliably read a real multi-code
11
+ * label. Full-image scanning with it is already far more capable than the
12
+ * JS port, but on a large photo (megapixels) it still costs on the order of
13
+ * a second per scan (mostly the detector's own search over the full frame).
14
+ * If the barcode locations are known in advance - a fixed camera rig with
15
+ * labels landing in roughly the same place shot to shot, same assumption
16
+ * golden-compare's "one-time/per-maintenance calibration" already makes for
17
+ * this project - decoding small, pre-defined regions instead is much
18
+ * faster: on the 4096x5500 test photo (6 regions, warmed up), the whole
19
+ * regions pass measured ~190-235ms against ~1.4-1.8s for the same image
20
+ * scanned whole - a ~7x wall-clock win, and the gap widens with fewer/
21
+ * smaller regions since most of that 190-235ms is one fixed decode (next
22
+ * paragraph), not per-region cost (each region's own search measured
23
+ * 1-50ms).
24
+ *
25
+ * When regions are in play, the raw decode itself is restricted to the
26
+ * union bounding box of all regions (via sharp's extract-on-load, driven
27
+ * by a near-free sharp().metadata() header read to get image dimensions
28
+ * first) rather than decoding the full frame - on the 4096x5500 test
29
+ * photo this cut the dominant decode cost from ~180ms to ~139ms (a bbox of
30
+ * 1666x3677 out of the full frame), and it means the full-resolution raw
31
+ * buffer is never materialized at all on the common path where regions
32
+ * succeed. The full-frame decode only happens lazily, inside
33
+ * decodeFullImage, when the automatic fallback actually fires. Every
34
+ * region crop (for both the search itself and the preview image attached
35
+ * to each result) is sliced from that bbox-restricted raw buffer via
36
+ * sharp's raw-input mode, and all regions are searched concurrently via
37
+ * Promise.all (verified both faster - ~30ms vs ~35-65ms sequential for 6
38
+ * regions - and correctness-identical to sequential, i.e. concurrent
39
+ * zxing-wasm readBarcodes() calls don't corrupt each other's results, at
40
+ * least as tested against this project's real sample photo).
41
+ *
42
+ * Three implementation traps worth knowing about if touching this file,
43
+ * all found by benchmarking against this project's real 4096x5500 sample
44
+ * photo rather than trusting any approach to "obviously" be fast:
45
+ * - A first version re-ran `sharp(pngBuffer).extract(box)` per region
46
+ * instead - a fresh full PNG decode every time. Across 6 regions that
47
+ * alone cost ~1.1s (region timings summed to ~125ms, the wrapping loop
48
+ * took ~1220ms) - decode-once-slice-many fixed it.
49
+ * - A second version sliced each region via one `sharp(raw.data,
50
+ * {raw:...}).extract(box)` pipeline, forked into two outputs (raw +
51
+ * PNG preview) with `.clone()` run concurrently through `Promise.all`.
52
+ * Measured 400-600ms *per region* against the 90MB raw buffer - worse
53
+ * than the original bug. Two independent, *sequential* pipelines (see
54
+ * sliceRawImage) measured 2-40ms for the same crops instead. The cause
55
+ * wasn't fully pinned down (suspected libvips contention over shared
56
+ * large raw input across concurrent clones) - if tempted to
57
+ * "simplify" this back to clone()+Promise.all, re-benchmark against a
58
+ * real multi-megapixel raw buffer first, not a small test image.
59
+ * - The region-level parallelism added later (Promise.all across
60
+ * *different* regions, each with its own independent sliceRawImage
61
+ * call) is a different thing from the clone()+Promise.all trap above -
62
+ * it's independent pipelines from the start, never sharing one via
63
+ * .clone(), and was benchmarked as a genuine win. Don't conflate the
64
+ * two when reasoning about future changes here.
65
+ */
66
+
67
+ "use strict";
68
+
69
+ const sharp = require("sharp");
70
+ const { readBarcodes } = require("zxing-wasm/reader");
71
+
72
+ // Formats enabled by default. EAN8 is deliberately left out: it's short
73
+ // enough that ZXing's implementations (both the JS port and, in one test
74
+ // against this project's own sample label, zxing-wasm) have a real chance
75
+ // of matching noise in a region that has no barcode in it at all - a
76
+ // confident wrong answer, worse than "not found". Turn it on explicitly
77
+ // per-call if a real EAN-8 code is expected.
78
+ const DEFAULT_FORMATS = ["Code128", "DataMatrix", "QRCode", "EAN13", "Code39", "ITF", "PDF417"];
79
+
80
+ const WARMUP_WIDTH = 4500;
81
+ const WARMUP_HEIGHT = 6000;
82
+
83
+ // zxing-wasm's first-ever readBarcodes() call in this process pays a
84
+ // one-time cost (WASM instantiation, plus linear-memory growth to fit a
85
+ // large image) of roughly 1.5-2s that has nothing to do with the image
86
+ // content - verified by timing repeated calls, where call 1 was
87
+ // consistently ~1.8x slower than every call after it. A same-size blank
88
+ // canvas warms it up exactly as well as a real photo does. Module-level so
89
+ // it runs (and is paid for) only once per Node-RED process, however many
90
+ // barcode-locate node instances exist across however many flows.
91
+ let warmupPromise = null;
92
+ function warmUp(readerOptions) {
93
+ if (!warmupPromise) {
94
+ warmupPromise = (async () => {
95
+ const blank = await sharp({
96
+ create: {
97
+ width: WARMUP_WIDTH,
98
+ height: WARMUP_HEIGHT,
99
+ channels: 3,
100
+ background: { r: 255, g: 255, b: 255 },
101
+ },
102
+ })
103
+ .png()
104
+ .toBuffer();
105
+ try {
106
+ await readBarcodes(blank, readerOptions);
107
+ } catch (err) {
108
+ // a blank canvas decodes to nothing - expected; only the warmup
109
+ // side effect (WASM init + heap growth) matters here
110
+ }
111
+ })();
112
+ }
113
+ return warmupPromise;
114
+ }
115
+
116
+ // Decodes the source image to raw RGBA pixels. With no extractBox, decodes
117
+ // the full frame (used for the automatic fallback, and for full-frame-only
118
+ // mode). With extractBox, decodes only that region via sharp's
119
+ // extract-on-load - cheaper than decoding the full frame and slicing after,
120
+ // see the region-bbox note in the module docstring. Every crop after this
121
+ // point (for search input or preview output) slices whichever buffer this
122
+ // returns via sharp's raw-input mode, which is pure memory work - no
123
+ // re-parsing of the original PNG/JPEG per region.
124
+ async function decodeToRawImage(buffer, extractBox) {
125
+ let pipeline = sharp(buffer).ensureAlpha();
126
+ if (extractBox) pipeline = pipeline.extract(extractBox);
127
+ const { data, info } = await pipeline.raw().toBuffer({ resolveWithObject: true });
128
+ return { data, width: info.width, height: info.height, channels: info.channels };
129
+ }
130
+
131
+ function boundingBoxFromPosition(position) {
132
+ const xs = [position.topLeft.x, position.topRight.x, position.bottomRight.x, position.bottomLeft.x];
133
+ const ys = [position.topLeft.y, position.topRight.y, position.bottomRight.y, position.bottomLeft.y];
134
+ return {
135
+ minX: Math.min(...xs),
136
+ maxX: Math.max(...xs),
137
+ minY: Math.min(...ys),
138
+ maxY: Math.max(...ys),
139
+ };
140
+ }
141
+
142
+ // clamps a user-defined region to the image bounds - regions are measured
143
+ // by eye against a specific photo/rig and can drift slightly out of bounds
144
+ // (a region near an edge, or a photo that came in smaller than expected)
145
+ function clampBox(box, imageWidth, imageHeight) {
146
+ const left = Math.max(0, Math.min(Math.round(box.x), imageWidth - 1));
147
+ const top = Math.max(0, Math.min(Math.round(box.y), imageHeight - 1));
148
+ const width = Math.max(1, Math.min(Math.round(box.width), imageWidth - left));
149
+ const height = Math.max(1, Math.min(Math.round(box.height), imageHeight - top));
150
+ return { left, top, width, height };
151
+ }
152
+
153
+ // union bounding box of a set of already-clamped boxes, in the same
154
+ // {left,top,width,height} shape - used to restrict the raw decode to just
155
+ // the area regions actually cover instead of the whole frame
156
+ function unionBox(boxes) {
157
+ const left = Math.min(...boxes.map((b) => b.left));
158
+ const top = Math.min(...boxes.map((b) => b.top));
159
+ const right = Math.max(...boxes.map((b) => b.left + b.width));
160
+ const bottom = Math.max(...boxes.map((b) => b.top + b.height));
161
+ return { left, top, width: right - left, height: bottom - top };
162
+ }
163
+
164
+ // Slices a box out of the raw image, returning both an ImageData-shaped
165
+ // object (fed straight to readBarcodes - no PNG encode/decode round trip
166
+ // needed just to search it) and a PNG buffer (for the result's preview
167
+ // crop). Two independent, *sequential* pipelines from raw.data, not one
168
+ // pipeline forked with .clone() and run concurrently via Promise.all -
169
+ // that combination measured 400-600ms per crop against this raw buffer
170
+ // (90MB, 4096x5500 RGBA) vs. 2-40ms doing the same two crops sequentially
171
+ // as separate pipelines. Cause not fully pinned down (likely libvips
172
+ // contention over the same large raw source when two pipelines derived
173
+ // from it via clone() process concurrently) - the fix is empirical, not
174
+ // fully understood, so don't "simplify" this back to clone()+Promise.all
175
+ // without re-benchmarking against a large raw buffer specifically.
176
+ async function sliceRawImage(raw, box) {
177
+ const rawCrop = await sharp(raw.data, { raw: { width: raw.width, height: raw.height, channels: raw.channels } })
178
+ .extract(box)
179
+ .raw()
180
+ .toBuffer({ resolveWithObject: true });
181
+ const previewPng = await sharp(raw.data, { raw: { width: raw.width, height: raw.height, channels: raw.channels } })
182
+ .extract(box)
183
+ .png()
184
+ .toBuffer();
185
+ const imageData = {
186
+ data: new Uint8ClampedArray(rawCrop.data.buffer, rawCrop.data.byteOffset, rawCrop.data.byteLength),
187
+ width: rawCrop.info.width,
188
+ height: rawCrop.info.height,
189
+ };
190
+ return { imageData, previewPng };
191
+ }
192
+
193
+ // `raw` may be decoded from an extract-on-load restricted to a bbox rather
194
+ // than the full frame (see locateBarcodes) - `origin` carries that bbox's
195
+ // offset plus the true full-image dimensions, so region boxes can be
196
+ // clamped/reported in real image coordinates (roi) while the actual slice
197
+ // happens in raw's own, possibly-offset, coordinate space (localBox).
198
+ async function decodeRegion(raw, origin, region, readerOptions) {
199
+ const fullBox = clampBox(region, origin.imageWidth, origin.imageHeight);
200
+ const localBox = { left: fullBox.left - origin.left, top: fullBox.top - origin.top, width: fullBox.width, height: fullBox.height };
201
+ const { imageData, previewPng } = await sliceRawImage(raw, localBox);
202
+
203
+ const start = Date.now();
204
+ const found = await readBarcodes(imageData, readerOptions);
205
+ const decodeMs = Date.now() - start;
206
+
207
+ return found.map((r) => ({
208
+ text: r.text,
209
+ format: r.format,
210
+ decodeMs,
211
+ source: "region",
212
+ regionLabel: region.label || null,
213
+ roi: { x: fullBox.left, y: fullBox.top, width: fullBox.width, height: fullBox.height },
214
+ previewBuffer: previewPng,
215
+ }));
216
+ }
217
+
218
+ async function decodeFullImage(raw, readerOptions) {
219
+ const imageData = { data: new Uint8ClampedArray(raw.data.buffer, raw.data.byteOffset, raw.data.byteLength), width: raw.width, height: raw.height };
220
+
221
+ const start = Date.now();
222
+ const found = await readBarcodes(imageData, readerOptions);
223
+ const decodeMs = Date.now() - start;
224
+
225
+ const results = [];
226
+ for (const r of found) {
227
+ const bbox = boundingBoxFromPosition(r.position);
228
+ const box = clampBox(
229
+ { x: bbox.minX, y: bbox.minY, width: bbox.maxX - bbox.minX, height: bbox.maxY - bbox.minY },
230
+ raw.width,
231
+ raw.height,
232
+ );
233
+ const { previewPng } = await sliceRawImage(raw, box);
234
+ results.push({
235
+ text: r.text,
236
+ format: r.format,
237
+ decodeMs,
238
+ source: "fullImage",
239
+ regionLabel: null,
240
+ roi: { x: box.left, y: box.top, width: box.width, height: box.height },
241
+ previewBuffer: previewPng,
242
+ });
243
+ }
244
+ return results;
245
+ }
246
+
247
+ /**
248
+ * @param {Buffer} buffer - full image bytes
249
+ * @param {object} options
250
+ * @param {Array<{label?:string,x:number,y:number,width:number,height:number}>} [options.regions]
251
+ * @param {"regionsThenAuto"|"regionsOnly"|"autoOnly"} [options.mode]
252
+ * @param {object} [options.readerOptions] - passed straight through to zxing-wasm's readBarcodes
253
+ * @returns {Promise<{results: object[], timings: object, usedFullImage: boolean, imageWidth: number, imageHeight: number}>}
254
+ */
255
+ async function locateBarcodes(buffer, options = {}) {
256
+ const mode = options.mode || "regionsThenAuto";
257
+ const regions = Array.isArray(options.regions) ? options.regions : [];
258
+ const readerOptions = options.readerOptions || { formats: DEFAULT_FORMATS, tryHarder: true, tryRotate: true, maxNumberOfSymbols: 20 };
259
+
260
+ await warmUp(readerOptions);
261
+
262
+ // header-only read (~1-3ms, no pixel decode) - just enough to clamp
263
+ // regions and compute their union bbox before touching pixel data
264
+ const metadata = await sharp(buffer).metadata();
265
+ const imageWidth = metadata.width;
266
+ const imageHeight = metadata.height;
267
+
268
+ const timings = {};
269
+ let results = [];
270
+
271
+ const tryRegions = mode !== "autoOnly" && regions.length > 0;
272
+ if (tryRegions) {
273
+ const start = Date.now();
274
+ const clampedBoxes = regions.map((r) => clampBox(r, imageWidth, imageHeight));
275
+ const bbox = unionBox(clampedBoxes);
276
+ const bboxRaw = await decodeToRawImage(buffer, bbox);
277
+ const origin = { left: bbox.left, top: bbox.top, imageWidth, imageHeight };
278
+ const regionResults = await Promise.all(regions.map((region) => decodeRegion(bboxRaw, origin, region, readerOptions)));
279
+ results = results.concat(...regionResults);
280
+ timings.regionsMs = Date.now() - start;
281
+ }
282
+
283
+ const shouldFallback = mode === "autoOnly" || (mode === "regionsThenAuto" && results.length === 0);
284
+ let usedFullImage = false;
285
+ if (shouldFallback) {
286
+ usedFullImage = true;
287
+ const start = Date.now();
288
+ const raw = await decodeToRawImage(buffer);
289
+ results = results.concat(await decodeFullImage(raw, readerOptions));
290
+ timings.fullImageMs = Date.now() - start;
291
+ }
292
+
293
+ return {
294
+ results,
295
+ timings,
296
+ usedFullImage,
297
+ imageWidth,
298
+ imageHeight,
299
+ };
300
+ }
301
+
302
+ module.exports = { locateBarcodes, DEFAULT_FORMATS };