@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.
- package/ARCHITECTURE.md +875 -0
- package/CHANGELOG.md +524 -0
- package/LICENSE +202 -0
- package/README.md +992 -0
- package/barcode-locate.html +256 -0
- package/barcode-locate.js +156 -0
- package/checkerboard-calibrate.html +167 -0
- package/checkerboard-calibrate.js +282 -0
- package/examples/label-crop-with-line-finder.json +239 -0
- package/golden-compare.html +713 -0
- package/golden-compare.js +1126 -0
- package/icons/checkerboard-calibrate.svg +8 -0
- package/icons/golden-compare.svg +6 -0
- package/label-crop.html +1178 -0
- package/label-crop.js +250 -0
- package/lib/align.js +867 -0
- package/lib/checkerboard.js +331 -0
- package/lib/compare.js +1338 -0
- package/lib/components.js +84 -0
- package/lib/dilate.js +65 -0
- package/lib/inspector.js +188 -0
- package/lib/inspectorCore.js +128 -0
- package/lib/inspectorWorker.js +40 -0
- package/lib/integral.js +76 -0
- package/lib/labelCrop.js +1461 -0
- package/lib/lineFinder.js +765 -0
- package/lib/localAlign.js +360 -0
- package/lib/locate.js +302 -0
- package/lib/nativeSeed.js +292 -0
- package/lib/parallel.js +428 -0
- package/lib/pool.js +250 -0
- package/lib/poolWorker.js +324 -0
- package/lib/scaleFile.js +83 -0
- package/lib/shared.js +87 -0
- package/lib/threshold.js +231 -0
- package/lib/transformFile.js +169 -0
- package/lib/warp.js +216 -0
- package/line-finder.html +1361 -0
- package/line-finder.js +280 -0
- package/package.json +73 -0
|
@@ -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 };
|