@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,1126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* golden-compare Node-RED node.
|
|
3
|
+
*
|
|
4
|
+
* Golden-template AOI: a position check (measured translation vs. a tolerance band, not
|
|
5
|
+
* silently corrected away) plus two independent blemish checks (print =
|
|
6
|
+
* missing ink, background = unwanted ink), each with their own
|
|
7
|
+
* tolerance. See README.md for the algorithm; the actual pixel-crunching
|
|
8
|
+
* lives in lib/compare.js so it can be exercised outside Node-RED.
|
|
9
|
+
*
|
|
10
|
+
* Input (msg.payload): Buffer / Uint8Array / ArrayBuffer with image bytes,
|
|
11
|
+
* a file path string, or an object { data | buffer | path }.
|
|
12
|
+
* Optional per-message overrides: msg.golden (path/Buffer - swaps and
|
|
13
|
+
* re-caches the golden reference), msg.threshold, msg.inkMargin,
|
|
14
|
+
* msg.printTolerance,
|
|
15
|
+
* msg.backgroundTolerance, msg.alignSearch, msg.positionToleranceXMm,
|
|
16
|
+
* msg.positionToleranceYMm, msg.positionToleranceXPx,
|
|
17
|
+
* msg.positionToleranceYPx, msg.blockSize, msg.blockThreshold,
|
|
18
|
+
* msg.failThreshold, msg.failRatio, msg.outputPrintHeatmap,
|
|
19
|
+
* msg.outputBackgroundHeatmap, msg.debugStages.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const fsp = fs.promises;
|
|
24
|
+
const crypto = require("crypto");
|
|
25
|
+
const inspector = require("./lib/inspector.js");
|
|
26
|
+
const { toShared } = require("./lib/shared.js");
|
|
27
|
+
const { readScaleFile } = require("./lib/scaleFile.js");
|
|
28
|
+
const {
|
|
29
|
+
readTransformFile,
|
|
30
|
+
writeTransformFile,
|
|
31
|
+
} = require("./lib/transformFile.js");
|
|
32
|
+
|
|
33
|
+
module.exports = (RED) => {
|
|
34
|
+
const BOUNDS = {
|
|
35
|
+
workingSize: [64, 4096],
|
|
36
|
+
threshold: [0, 255],
|
|
37
|
+
tolerance: [0, 50],
|
|
38
|
+
alignSearch: [0, 200],
|
|
39
|
+
blockSize: [4, 256],
|
|
40
|
+
positionPx: [0, 1000],
|
|
41
|
+
positionMm: [0, 1000],
|
|
42
|
+
angleDeg: [0, 30],
|
|
43
|
+
aspect: [0, 0.5],
|
|
44
|
+
aspectSteps: [1, 21],
|
|
45
|
+
angleSteps: [1, 21],
|
|
46
|
+
scale: [0.1, 10],
|
|
47
|
+
scaleSteps: [1, 61],
|
|
48
|
+
sauvolaRadius: [2, 200],
|
|
49
|
+
sauvolaK: [0.01, 1],
|
|
50
|
+
inkMargin: [0, 128],
|
|
51
|
+
alignCandidates: [1, 16],
|
|
52
|
+
localAlignTile: [16, 512],
|
|
53
|
+
localAlignMax: [1, 16],
|
|
54
|
+
workers: [0, 64],
|
|
55
|
+
mismatchScore: [0, 1],
|
|
56
|
+
};
|
|
57
|
+
const THRESHOLD_MODES = ["fixed", "otsu", "sauvola"];
|
|
58
|
+
|
|
59
|
+
function pickMode(value, fallback) {
|
|
60
|
+
return THRESHOLD_MODES.includes(value) ? value : fallback;
|
|
61
|
+
}
|
|
62
|
+
const UNIT_BOUNDS = [0, 1];
|
|
63
|
+
|
|
64
|
+
function clampInt(value, fallback, [min, max]) {
|
|
65
|
+
const n = parseInt(value, 10);
|
|
66
|
+
if (isNaN(n)) return fallback;
|
|
67
|
+
return Math.min(max, Math.max(min, n));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clampFloat(value, fallback, [min, max]) {
|
|
71
|
+
const n = parseFloat(value);
|
|
72
|
+
if (isNaN(n)) return fallback;
|
|
73
|
+
return Math.min(max, Math.max(min, n));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fmtMs(ms) {
|
|
77
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Image inputs are capped before anything copies, hashes, or reads
|
|
81
|
+
// them: an unbounded buffer would be copied and SHA-1'd on every
|
|
82
|
+
// message for no inspection value, and an unbounded path read would
|
|
83
|
+
// hang or OOM on a special file like /dev/zero. 512MB is far past the
|
|
84
|
+
// largest capture this pipeline is meant for (a 23MP framebuffer is
|
|
85
|
+
// ~90MB).
|
|
86
|
+
const MAX_IMAGE_BYTES = 512 * 1024 * 1024;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read an image file through one handle: open -> fstat -> guards ->
|
|
90
|
+
* read. The guards are the point. A pathExists() then readFile() pair
|
|
91
|
+
* is a race (the file can be swapped between the two), and an
|
|
92
|
+
* unguarded path read lets a flow point msg.golden at /dev/zero and
|
|
93
|
+
* hang the node on an endless read. Returns null when the path does
|
|
94
|
+
* not exist, so callers can keep distinguishing "missing" from
|
|
95
|
+
* "refused".
|
|
96
|
+
*/
|
|
97
|
+
async function openRegularFile(p, label) {
|
|
98
|
+
let fd;
|
|
99
|
+
try {
|
|
100
|
+
fd = await fsp.open(p, fs.constants.O_RDONLY);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return null;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const stat = await fd.stat();
|
|
107
|
+
if ((stat.mode & fs.constants.S_IFMT) !== fs.constants.S_IFREG) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${label} is not a regular file: "${p}" - refusing to read it`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (stat.size > MAX_IMAGE_BYTES) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${label} is ${stat.size} bytes, above the ${MAX_IMAGE_BYTES}-byte cap: "${p}"`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return { handle: fd, mtimeMs: stat.mtimeMs, size: stat.size };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
await fd.close();
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** open -> fstat -> guards -> read -> close, for callers that want the
|
|
125
|
+
* bytes outright rather than a handle to fingerprint from. */
|
|
126
|
+
async function readRegularFile(p, label) {
|
|
127
|
+
const open = await openRegularFile(p, label);
|
|
128
|
+
if (!open) return null;
|
|
129
|
+
try {
|
|
130
|
+
return {
|
|
131
|
+
buffer: await open.handle.readFile(),
|
|
132
|
+
mtimeMs: open.mtimeMs,
|
|
133
|
+
size: open.size,
|
|
134
|
+
};
|
|
135
|
+
} finally {
|
|
136
|
+
await open.handle.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A raw descriptor that cannot fit in the buffer it travels with
|
|
141
|
+
* would be decoded past the end by sharp (libvips's generic 'memory
|
|
142
|
+
* area too small') - refuse with the actual numbers before sharp is
|
|
143
|
+
* involved. */
|
|
144
|
+
function assertRawFits(buffer, raw, label) {
|
|
145
|
+
if (!raw) return;
|
|
146
|
+
const need = raw.width * raw.height * raw.channels;
|
|
147
|
+
if (buffer.byteLength < need) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`${label} raw descriptor ${raw.width}x${raw.height}x${raw.channels} needs ` +
|
|
150
|
+
`${need} bytes but the buffer holds ${buffer.byteLength}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The byte-carrying forms, in one place - the top-level buffer and the
|
|
156
|
+
* `data`/`buffer` member of an object descriptor are validated
|
|
157
|
+
* identically. */
|
|
158
|
+
function bytesOf(source) {
|
|
159
|
+
const data =
|
|
160
|
+
Buffer.isBuffer(source) ||
|
|
161
|
+
source instanceof Uint8Array ||
|
|
162
|
+
source instanceof ArrayBuffer
|
|
163
|
+
? source
|
|
164
|
+
: source && typeof source === "object"
|
|
165
|
+
? source.data || source.buffer
|
|
166
|
+
: null;
|
|
167
|
+
if (
|
|
168
|
+
Buffer.isBuffer(data) ||
|
|
169
|
+
data instanceof Uint8Array ||
|
|
170
|
+
data instanceof ArrayBuffer
|
|
171
|
+
) {
|
|
172
|
+
return data;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One copy of `data` into shared memory, as a Buffer view over it, so
|
|
178
|
+
* handing it to the inspector later is a handle rather than a second
|
|
179
|
+
* copy. Falls back to an ordinary Buffer where SharedArrayBuffer is
|
|
180
|
+
* unavailable, which is also where the inspector runs inline anyway. */
|
|
181
|
+
function sharedCopy(data) {
|
|
182
|
+
const src =
|
|
183
|
+
data instanceof ArrayBuffer
|
|
184
|
+
? new Uint8Array(data)
|
|
185
|
+
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
186
|
+
if (typeof SharedArrayBuffer === "undefined") return Buffer.from(src);
|
|
187
|
+
const store = new SharedArrayBuffer(src.byteLength);
|
|
188
|
+
new Uint8Array(store).set(src);
|
|
189
|
+
return Buffer.from(store, 0, src.byteLength);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertUnderCap(data, label) {
|
|
193
|
+
if (data.byteLength > MAX_IMAGE_BYTES) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${label} is ${data.byteLength} bytes, above the ${MAX_IMAGE_BYTES}-byte cap`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Load an image source (Buffer/path/object) to { buffer, raw }.
|
|
202
|
+
*
|
|
203
|
+
* Every guard the node has ever applied lives here: the size cap, the
|
|
204
|
+
* regular-file check, the distinct missing-file errors for a path string
|
|
205
|
+
* versus a path inside an object, and the raw-descriptor length check.
|
|
206
|
+
*
|
|
207
|
+
* `prefetched` is an already-open handle from fingerprintImage, so a
|
|
208
|
+
* path golden that has just been stat'ed for its cache key is read
|
|
209
|
+
* through the *same* handle rather than reopened - the file cannot be
|
|
210
|
+
* swapped between the two, which is the race the single-handle
|
|
211
|
+
* open/fstat/read was written to avoid in the first place.
|
|
212
|
+
*/
|
|
213
|
+
async function loadImage(source, label, prefetched) {
|
|
214
|
+
if (source == null || source === "") {
|
|
215
|
+
throw new Error(`${label} is empty`);
|
|
216
|
+
}
|
|
217
|
+
const data = bytesOf(source);
|
|
218
|
+
if (data) {
|
|
219
|
+
assertUnderCap(data, label);
|
|
220
|
+
// A real Buffer is already exactly what sharp wants, so it is not
|
|
221
|
+
// copied here. Uint8Array and ArrayBuffer must be - those can be
|
|
222
|
+
// views onto a larger buffer the caller keeps writing to - and when
|
|
223
|
+
// they are, the copy goes *straight into shared memory*. The frame
|
|
224
|
+
// has to end up there anyway to reach the inspector, and doing it
|
|
225
|
+
// in two steps (Buffer.from, then toShared) copied a 68MB
|
|
226
|
+
// framebuffer twice: 10.3ms + 12.6ms, both on the event loop.
|
|
227
|
+
const buffer = Buffer.isBuffer(data) ? data : sharedCopy(data);
|
|
228
|
+
const raw = rawGeometry(source);
|
|
229
|
+
assertRawFits(buffer, raw, label);
|
|
230
|
+
return { buffer, raw };
|
|
231
|
+
}
|
|
232
|
+
if (typeof source === "string") {
|
|
233
|
+
if (prefetched) return { buffer: await prefetched.handle.readFile() };
|
|
234
|
+
const file = await readRegularFile(source, label);
|
|
235
|
+
if (!file) {
|
|
236
|
+
throw new Error(`${label} does not exist on disk: "${source}"`);
|
|
237
|
+
}
|
|
238
|
+
return { buffer: file.buffer };
|
|
239
|
+
}
|
|
240
|
+
if (typeof source === "object") {
|
|
241
|
+
if (typeof source.path === "string") {
|
|
242
|
+
if (prefetched) return { buffer: await prefetched.handle.readFile() };
|
|
243
|
+
const file = await readRegularFile(source.path, label);
|
|
244
|
+
if (file) return { buffer: file.buffer };
|
|
245
|
+
}
|
|
246
|
+
throw new Error(
|
|
247
|
+
`${label} object must contain "data"/"buffer" or an existing "path"`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`unsupported ${label} type: ${typeof source}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A cheap, stable fingerprint of an image source, *without* reading or
|
|
255
|
+
* hashing its bytes wherever that can be avoided. This is the half of the
|
|
256
|
+
* old resolveImage that has to run on every message; loadImage is the
|
|
257
|
+
* half that only has to run when the golden cache misses.
|
|
258
|
+
*
|
|
259
|
+
* Costs, per form:
|
|
260
|
+
* - a path is fingerprinted by mtime and size, so overwriting the golden
|
|
261
|
+
* in place still re-prepares the cache (and refuses the stale trained
|
|
262
|
+
* transform measured against the old bytes). The handle stays open for
|
|
263
|
+
* loadImage, so a hit costs one open+fstat and no read at all - it used
|
|
264
|
+
* to read the whole artwork on every frame and throw it away.
|
|
265
|
+
* - a named key (msg.goldenKey) skips the SHA-1 entirely. That is what
|
|
266
|
+
* the option was always documented to do and never actually did: the
|
|
267
|
+
* hash ran inside resolveImage before the name was consulted.
|
|
268
|
+
* - an unnamed buffer still has to be hashed. There is nothing else in
|
|
269
|
+
* it that says whether it changed.
|
|
270
|
+
*
|
|
271
|
+
* The caller must always call close(), hit or miss.
|
|
272
|
+
*/
|
|
273
|
+
async function fingerprintImage(source, label, namedKey) {
|
|
274
|
+
if (source == null || source === "") {
|
|
275
|
+
throw new Error(`${label} is empty`);
|
|
276
|
+
}
|
|
277
|
+
const named = namedKey ? `key:${namedKey}` : null;
|
|
278
|
+
const data = bytesOf(source);
|
|
279
|
+
if (data) {
|
|
280
|
+
assertUnderCap(data, label);
|
|
281
|
+
return {
|
|
282
|
+
key:
|
|
283
|
+
named ||
|
|
284
|
+
`buf:${crypto.createHash("sha1").update(Buffer.isBuffer(data) ? data : Buffer.from(data)).digest("hex")}`,
|
|
285
|
+
// cheap, and the only thing a named key can be cross-checked
|
|
286
|
+
// against without reading the bytes it deliberately ignores
|
|
287
|
+
byteLength: data.byteLength,
|
|
288
|
+
close: async () => {},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const p =
|
|
292
|
+
typeof source === "string"
|
|
293
|
+
? source
|
|
294
|
+
: source && typeof source === "object" && typeof source.path === "string"
|
|
295
|
+
? source.path
|
|
296
|
+
: null;
|
|
297
|
+
if (p !== null) {
|
|
298
|
+
// Stat even under a named key. It costs ~0.02ms, it keeps a deleted
|
|
299
|
+
// or swapped-for-a-directory golden an error rather than a silently
|
|
300
|
+
// reused cache entry, and only the *read* was ever expensive.
|
|
301
|
+
const open = await openRegularFile(p, label);
|
|
302
|
+
if (!open) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
typeof source === "string"
|
|
305
|
+
? `${label} does not exist on disk: "${p}"`
|
|
306
|
+
: `${label} object must contain "data"/"buffer" or an existing "path"`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
key: named || `path:${p}:${open.mtimeMs}:${open.size}`,
|
|
311
|
+
handle: open.handle,
|
|
312
|
+
close: () => open.handle.close(),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (typeof source === "object") {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`${label} object must contain "data"/"buffer" or an existing "path"`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
throw new Error(`unsupported ${label} type: ${typeof source}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Geometry for pixels that arrive with no container around them.
|
|
325
|
+
* Undefined unless all three are present and sane - a partial
|
|
326
|
+
* descriptor is worse than none, because sharp would read past the end
|
|
327
|
+
* of the buffer rather than tell you the numbers are wrong.
|
|
328
|
+
*/
|
|
329
|
+
function rawGeometry(source) {
|
|
330
|
+
if (!source || typeof source !== "object") return undefined;
|
|
331
|
+
const width = Number(source.width);
|
|
332
|
+
const height = Number(source.height);
|
|
333
|
+
const channels = Number(source.channels);
|
|
334
|
+
if (!Number.isInteger(width) || width <= 0) return undefined;
|
|
335
|
+
if (!Number.isInteger(height) || height <= 0) return undefined;
|
|
336
|
+
if (!Number.isInteger(channels) || channels < 1 || channels > 4)
|
|
337
|
+
return undefined;
|
|
338
|
+
// A descriptor past this is a mistake, not a sensor: 64M pixels at
|
|
339
|
+
// 4 channels is 256MB, far beyond anything this inspection runs.
|
|
340
|
+
// Capping here keeps such a descriptor from reaching sharp's raw
|
|
341
|
+
// path, where the length mismatch would surface only as libvips's
|
|
342
|
+
// generic 'memory area too small' error.
|
|
343
|
+
if (width * height > 64 * 1024 * 1024) return undefined;
|
|
344
|
+
return { width, height, channels };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** pdf-to-image's convention: raw bytes on the payload, geometry on
|
|
348
|
+
* msg.images[] (raw pixels have no container to carry it). */
|
|
349
|
+
function rawGeometryFromImages(msg) {
|
|
350
|
+
if (!msg || String(msg.format).toUpperCase() !== "RAW") return undefined;
|
|
351
|
+
const images = Array.isArray(msg.images) ? msg.images : null;
|
|
352
|
+
if (!images || images.length === 0) return undefined;
|
|
353
|
+
const match =
|
|
354
|
+
(msg.page != null && images.find((i) => i && i.page === msg.page)) ||
|
|
355
|
+
images[0];
|
|
356
|
+
return rawGeometry(match);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Raw geometry for the frame under inspection.
|
|
361
|
+
*
|
|
362
|
+
* `msg.images[]` is only consulted when no golden travels on the same
|
|
363
|
+
* message. When the golden is the PDF render - the intended setup -
|
|
364
|
+
* msg.images describes *that*, not the camera frame, and silently
|
|
365
|
+
* decoding a 23MP capture at the artwork's dimensions would produce a
|
|
366
|
+
* confident, wrong answer rather than an error. Say `msg.rawInfo` when
|
|
367
|
+
* both are raw on one message.
|
|
368
|
+
*/
|
|
369
|
+
function targetRawGeometry(msg, source) {
|
|
370
|
+
const direct = rawGeometry(source);
|
|
371
|
+
if (direct) return direct;
|
|
372
|
+
if (!msg) return undefined;
|
|
373
|
+
const explicit = rawGeometry(msg.rawInfo);
|
|
374
|
+
if (explicit) return explicit;
|
|
375
|
+
if (msg.golden != null) return undefined;
|
|
376
|
+
return rawGeometryFromImages(msg);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Raw geometry for the golden. Beyond the self-describing object form,
|
|
381
|
+
* `msg.goldenRawInfo` states it outright, and a bare buffer from
|
|
382
|
+
* pdf-to-image is read from msg.images[] - which is what wiring that
|
|
383
|
+
* node straight into this one produces.
|
|
384
|
+
*
|
|
385
|
+
* msg.images[] is only consulted for that one case. A path-string or
|
|
386
|
+
* object golden carries its own geometry, and a message still holding a
|
|
387
|
+
* pdf-to-image render's leftovers (msg.format === "RAW" + msg.images[])
|
|
388
|
+
* must not stamp that geometry onto a file golden: the file's bytes
|
|
389
|
+
* would be decoded as raw pixels at the frame's dimensions, and the
|
|
390
|
+
* garbage golden cached under cfg.raw for every later frame.
|
|
391
|
+
*/
|
|
392
|
+
function goldenRawGeometry(msg, source) {
|
|
393
|
+
const direct = rawGeometry(source);
|
|
394
|
+
if (direct) return direct;
|
|
395
|
+
if (!msg) return undefined;
|
|
396
|
+
const explicit = rawGeometry(msg.goldenRawInfo);
|
|
397
|
+
if (explicit) return explicit;
|
|
398
|
+
if (
|
|
399
|
+
Buffer.isBuffer(source) ||
|
|
400
|
+
source instanceof Uint8Array ||
|
|
401
|
+
source instanceof ArrayBuffer
|
|
402
|
+
) {
|
|
403
|
+
return rawGeometryFromImages(msg);
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function GoldenCompareNode(config) {
|
|
409
|
+
RED.nodes.createNode(this, config);
|
|
410
|
+
const node = this;
|
|
411
|
+
|
|
412
|
+
node.goldenPath = String(config.goldenPath || "").trim();
|
|
413
|
+
node.workingSize = clampInt(config.workingSize, 1024, BOUNDS.workingSize);
|
|
414
|
+
node.threshold = clampInt(config.threshold, 128, BOUNDS.threshold);
|
|
415
|
+
// otsu by default, not a fixed level: the golden is normally PDF
|
|
416
|
+
// artwork - synthetic pure black on pure white - and the frame is a
|
|
417
|
+
// photograph. There is no single grey level that is correct for
|
|
418
|
+
// both, and no reason to make the operator discover that.
|
|
419
|
+
node.thresholdMode = pickMode(config.thresholdMode, "otsu");
|
|
420
|
+
node.sauvolaRadius = clampInt(config.sauvolaRadius, 24, BOUNDS.sauvolaRadius);
|
|
421
|
+
node.sauvolaK = clampFloat(config.sauvolaK, 0.2, BOUNDS.sauvolaK);
|
|
422
|
+
node.inkMargin = clampInt(config.inkMargin, 8, BOUNDS.inkMargin);
|
|
423
|
+
// Wide by default: on a frame that is already at golden's scale the
|
|
424
|
+
// extra rungs cost almost nothing (there is barely any margin to
|
|
425
|
+
// sweep), while a narrow default would silently fail every
|
|
426
|
+
// artwork-as-golden setup - the case the search exists for.
|
|
427
|
+
node.scaleSearchMin = clampFloat(config.scaleSearchMin, 0.6, BOUNDS.scale);
|
|
428
|
+
node.scaleSearchMax = clampFloat(config.scaleSearchMax, 2.5, BOUNDS.scale);
|
|
429
|
+
node.scaleSearchSteps = clampInt(
|
|
430
|
+
config.scaleSearchSteps,
|
|
431
|
+
19,
|
|
432
|
+
BOUNDS.scaleSteps,
|
|
433
|
+
);
|
|
434
|
+
// Presses stretch print along the media-feed axis relative to the
|
|
435
|
+
// artwork - measured at 5-6% on this project's own samples. Left
|
|
436
|
+
// unsearched it is not a small error: it puts every feature several
|
|
437
|
+
// pixels out toward the ends of the long axis and fails a good part
|
|
438
|
+
// on both blemish checks.
|
|
439
|
+
node.alignCandidates = clampInt(
|
|
440
|
+
config.alignCandidates,
|
|
441
|
+
5,
|
|
442
|
+
BOUNDS.alignCandidates,
|
|
443
|
+
);
|
|
444
|
+
// 0 = auto (one per core, capped at 8, leaving one for the event
|
|
445
|
+
// loop); 1 disables the pool and keeps every stage on this thread
|
|
446
|
+
node.workers = clampInt(config.workers, 0, BOUNDS.workers);
|
|
447
|
+
// 0 disables the "this is a different label" check entirely
|
|
448
|
+
node.mismatchScore = clampFloat(
|
|
449
|
+
config.mismatchScore,
|
|
450
|
+
0.15,
|
|
451
|
+
BOUNDS.mismatchScore,
|
|
452
|
+
);
|
|
453
|
+
node.localAlign = config.localAlign !== false;
|
|
454
|
+
node.localAlignTile = clampInt(
|
|
455
|
+
config.localAlignTile,
|
|
456
|
+
96,
|
|
457
|
+
BOUNDS.localAlignTile,
|
|
458
|
+
);
|
|
459
|
+
node.localAlignMax = clampInt(config.localAlignMax, 3, BOUNDS.localAlignMax);
|
|
460
|
+
node.maxAspect = clampFloat(config.maxAspect, 0.06, BOUNDS.aspect);
|
|
461
|
+
node.aspectSteps = clampInt(config.aspectSteps, 7, BOUNDS.aspectSteps);
|
|
462
|
+
node.maxAngleDeg = clampFloat(config.maxAngleDeg, 2, BOUNDS.angleDeg);
|
|
463
|
+
node.angleSteps = clampInt(config.angleSteps, 5, BOUNDS.angleSteps);
|
|
464
|
+
node.positionToleranceAngleDeg = clampFloat(
|
|
465
|
+
config.positionToleranceAngleDeg,
|
|
466
|
+
1,
|
|
467
|
+
BOUNDS.angleDeg,
|
|
468
|
+
);
|
|
469
|
+
// tight because localAlign is on by default: the dilation no longer
|
|
470
|
+
// has to absorb registration error, only genuine edge variation
|
|
471
|
+
node.printTolerance = clampInt(config.printTolerance, 2, BOUNDS.tolerance);
|
|
472
|
+
node.backgroundTolerance = clampInt(
|
|
473
|
+
config.backgroundTolerance,
|
|
474
|
+
1,
|
|
475
|
+
BOUNDS.tolerance,
|
|
476
|
+
);
|
|
477
|
+
node.alignSearch = clampInt(config.alignSearch, 16, BOUNDS.alignSearch);
|
|
478
|
+
node.positionToleranceXMm = clampFloat(
|
|
479
|
+
config.positionToleranceXMm,
|
|
480
|
+
2,
|
|
481
|
+
BOUNDS.positionMm,
|
|
482
|
+
);
|
|
483
|
+
node.positionToleranceYMm = clampFloat(
|
|
484
|
+
config.positionToleranceYMm,
|
|
485
|
+
2,
|
|
486
|
+
BOUNDS.positionMm,
|
|
487
|
+
);
|
|
488
|
+
node.positionToleranceXPx = clampInt(
|
|
489
|
+
config.positionToleranceXPx,
|
|
490
|
+
16,
|
|
491
|
+
BOUNDS.positionPx,
|
|
492
|
+
);
|
|
493
|
+
node.positionToleranceYPx = clampInt(
|
|
494
|
+
config.positionToleranceYPx,
|
|
495
|
+
16,
|
|
496
|
+
BOUNDS.positionPx,
|
|
497
|
+
);
|
|
498
|
+
node.blockSize = clampInt(config.blockSize, 16, BOUNDS.blockSize);
|
|
499
|
+
node.blockThreshold = clampFloat(config.blockThreshold, 0.15, UNIT_BOUNDS);
|
|
500
|
+
node.failThreshold = clampFloat(config.failThreshold, 0.3, UNIT_BOUNDS);
|
|
501
|
+
node.failRatio = clampFloat(config.failRatio, 0.002, UNIT_BOUNDS);
|
|
502
|
+
node.outputPrintHeatmap = config.outputPrintHeatmap !== false;
|
|
503
|
+
node.outputBackgroundHeatmap = config.outputBackgroundHeatmap !== false;
|
|
504
|
+
node.debugStages = !!config.debugStages;
|
|
505
|
+
node.scaleFilePath = String(config.scaleFilePath || "").trim();
|
|
506
|
+
node.transformFilePath = String(config.transformFilePath || "").trim();
|
|
507
|
+
node.trainTransform = !!config.trainTransform;
|
|
508
|
+
// PROTOTYPES, default off - see lib/nativeSeed.js
|
|
509
|
+
node.nativeAlignSeed = !!config.nativeAlignSeed;
|
|
510
|
+
node.nativeFastAlign = !!config.nativeFastAlign;
|
|
511
|
+
|
|
512
|
+
// { key, promise } - cached prepared golden, keyed by fingerprintImage()'s
|
|
513
|
+
// fingerprint plus every setting baked into the cached object
|
|
514
|
+
// (workingSize, threshold, thresholdMode, sauvolaRadius, sauvolaK,
|
|
515
|
+
// inkMargin, backgroundTolerance, debugStages, mmPerPixelNative, the
|
|
516
|
+
// calibration photo's size, and the raw geometry - the cacheKey
|
|
517
|
+
// construction below is the authoritative list), so a changed
|
|
518
|
+
// msg.golden, a re-pointed goldenPath, a fresh calibration save, or
|
|
519
|
+
// a flipped baked setting all trigger exactly one re-prepare, shared
|
|
520
|
+
// by any messages that arrive while it's in flight.
|
|
521
|
+
node.goldenCache = null;
|
|
522
|
+
|
|
523
|
+
node.on("input", async (msg, send, done) => {
|
|
524
|
+
send =
|
|
525
|
+
send ||
|
|
526
|
+
function () {
|
|
527
|
+
node.send.apply(node, arguments);
|
|
528
|
+
};
|
|
529
|
+
const totalStart = performance.now();
|
|
530
|
+
try {
|
|
531
|
+
const goldenSource = msg.golden == null ? node.goldenPath : msg.golden;
|
|
532
|
+
if (!goldenSource) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
"no golden reference configured - set the node's Golden image path or send msg.golden",
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
const scale = await readScaleFile(node.scaleFilePath);
|
|
538
|
+
if (scale && scale.error) {
|
|
539
|
+
// a corrupt or implausible calibration file silently downgrades
|
|
540
|
+
// the whole inspection to pixel tolerances, which is the wrong
|
|
541
|
+
// thing to do without saying so - warn once per path, then run
|
|
542
|
+
// uncalibrated rather than erroring every frame
|
|
543
|
+
if (node.warnedAboutScale !== node.scaleFilePath) {
|
|
544
|
+
node.warnedAboutScale = node.scaleFilePath;
|
|
545
|
+
node.warn(scale.error);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const scaleOk = scale != null && !scale.error;
|
|
549
|
+
const cfg = {
|
|
550
|
+
workingSize: node.workingSize,
|
|
551
|
+
threshold: clampInt(msg.threshold, node.threshold, BOUNDS.threshold),
|
|
552
|
+
thresholdMode: pickMode(msg.thresholdMode, node.thresholdMode),
|
|
553
|
+
sauvolaRadius: clampInt(
|
|
554
|
+
msg.sauvolaRadius,
|
|
555
|
+
node.sauvolaRadius,
|
|
556
|
+
BOUNDS.sauvolaRadius,
|
|
557
|
+
),
|
|
558
|
+
sauvolaK: clampFloat(msg.sauvolaK, node.sauvolaK, BOUNDS.sauvolaK),
|
|
559
|
+
inkMargin: clampInt(msg.inkMargin, node.inkMargin, BOUNDS.inkMargin),
|
|
560
|
+
scaleSearchMin: clampFloat(
|
|
561
|
+
msg.scaleSearchMin,
|
|
562
|
+
node.scaleSearchMin,
|
|
563
|
+
BOUNDS.scale,
|
|
564
|
+
),
|
|
565
|
+
scaleSearchMax: clampFloat(
|
|
566
|
+
msg.scaleSearchMax,
|
|
567
|
+
node.scaleSearchMax,
|
|
568
|
+
BOUNDS.scale,
|
|
569
|
+
),
|
|
570
|
+
scaleSearchSteps: clampInt(
|
|
571
|
+
msg.scaleSearchSteps,
|
|
572
|
+
node.scaleSearchSteps,
|
|
573
|
+
BOUNDS.scaleSteps,
|
|
574
|
+
),
|
|
575
|
+
alignCandidates: clampInt(
|
|
576
|
+
msg.alignCandidates,
|
|
577
|
+
node.alignCandidates,
|
|
578
|
+
BOUNDS.alignCandidates,
|
|
579
|
+
),
|
|
580
|
+
workers: clampInt(msg.workers, node.workers, BOUNDS.workers),
|
|
581
|
+
mismatchScore: clampFloat(
|
|
582
|
+
msg.mismatchScore,
|
|
583
|
+
node.mismatchScore,
|
|
584
|
+
BOUNDS.mismatchScore,
|
|
585
|
+
),
|
|
586
|
+
localAlign: msg.localAlign == null ? node.localAlign : !!msg.localAlign,
|
|
587
|
+
localAlignTile: clampInt(
|
|
588
|
+
msg.localAlignTile,
|
|
589
|
+
node.localAlignTile,
|
|
590
|
+
BOUNDS.localAlignTile,
|
|
591
|
+
),
|
|
592
|
+
localAlignMax: clampInt(
|
|
593
|
+
msg.localAlignMax,
|
|
594
|
+
node.localAlignMax,
|
|
595
|
+
BOUNDS.localAlignMax,
|
|
596
|
+
),
|
|
597
|
+
maxAspect: clampFloat(msg.maxAspect, node.maxAspect, BOUNDS.aspect),
|
|
598
|
+
aspectSteps: clampInt(
|
|
599
|
+
msg.aspectSteps,
|
|
600
|
+
node.aspectSteps,
|
|
601
|
+
BOUNDS.aspectSteps,
|
|
602
|
+
),
|
|
603
|
+
maxAngleDeg: clampFloat(
|
|
604
|
+
msg.maxAngleDeg,
|
|
605
|
+
node.maxAngleDeg,
|
|
606
|
+
BOUNDS.angleDeg,
|
|
607
|
+
),
|
|
608
|
+
angleSteps: clampInt(msg.angleSteps, node.angleSteps, BOUNDS.angleSteps),
|
|
609
|
+
positionToleranceAngleDeg: clampFloat(
|
|
610
|
+
msg.positionToleranceAngleDeg,
|
|
611
|
+
node.positionToleranceAngleDeg,
|
|
612
|
+
BOUNDS.angleDeg,
|
|
613
|
+
),
|
|
614
|
+
printTolerance: clampInt(
|
|
615
|
+
msg.printTolerance,
|
|
616
|
+
node.printTolerance,
|
|
617
|
+
BOUNDS.tolerance,
|
|
618
|
+
),
|
|
619
|
+
backgroundTolerance: clampInt(
|
|
620
|
+
msg.backgroundTolerance,
|
|
621
|
+
node.backgroundTolerance,
|
|
622
|
+
BOUNDS.tolerance,
|
|
623
|
+
),
|
|
624
|
+
alignSearch: clampInt(
|
|
625
|
+
msg.alignSearch,
|
|
626
|
+
node.alignSearch,
|
|
627
|
+
BOUNDS.alignSearch,
|
|
628
|
+
),
|
|
629
|
+
positionToleranceXMm: clampFloat(
|
|
630
|
+
msg.positionToleranceXMm,
|
|
631
|
+
node.positionToleranceXMm,
|
|
632
|
+
BOUNDS.positionMm,
|
|
633
|
+
),
|
|
634
|
+
positionToleranceYMm: clampFloat(
|
|
635
|
+
msg.positionToleranceYMm,
|
|
636
|
+
node.positionToleranceYMm,
|
|
637
|
+
BOUNDS.positionMm,
|
|
638
|
+
),
|
|
639
|
+
positionToleranceXPx: clampInt(
|
|
640
|
+
msg.positionToleranceXPx,
|
|
641
|
+
node.positionToleranceXPx,
|
|
642
|
+
BOUNDS.positionPx,
|
|
643
|
+
),
|
|
644
|
+
positionToleranceYPx: clampInt(
|
|
645
|
+
msg.positionToleranceYPx,
|
|
646
|
+
node.positionToleranceYPx,
|
|
647
|
+
BOUNDS.positionPx,
|
|
648
|
+
),
|
|
649
|
+
blockSize: clampInt(msg.blockSize, node.blockSize, BOUNDS.blockSize),
|
|
650
|
+
blockThreshold: clampFloat(
|
|
651
|
+
msg.blockThreshold,
|
|
652
|
+
node.blockThreshold,
|
|
653
|
+
UNIT_BOUNDS,
|
|
654
|
+
),
|
|
655
|
+
failThreshold: clampFloat(
|
|
656
|
+
msg.failThreshold,
|
|
657
|
+
node.failThreshold,
|
|
658
|
+
UNIT_BOUNDS,
|
|
659
|
+
),
|
|
660
|
+
failRatio: clampFloat(msg.failRatio, node.failRatio, UNIT_BOUNDS),
|
|
661
|
+
outputPrintHeatmap:
|
|
662
|
+
msg.outputPrintHeatmap == null
|
|
663
|
+
? node.outputPrintHeatmap
|
|
664
|
+
: !!msg.outputPrintHeatmap,
|
|
665
|
+
outputBackgroundHeatmap:
|
|
666
|
+
msg.outputBackgroundHeatmap == null
|
|
667
|
+
? node.outputBackgroundHeatmap
|
|
668
|
+
: !!msg.outputBackgroundHeatmap,
|
|
669
|
+
debugStages:
|
|
670
|
+
msg.debugStages == null ? node.debugStages : !!msg.debugStages,
|
|
671
|
+
// PROTOTYPE. Seeds the pinned search from a native ORB+ECC
|
|
672
|
+
// alignment instead of the staged sweeps, when the optional
|
|
673
|
+
// @rosepetal/node-red-contrib-image-tools engine is
|
|
674
|
+
// installed. Silently inert without it, and the sweeps stay
|
|
675
|
+
// the fallback for a seed that fails its range check.
|
|
676
|
+
nativeAlignSeed:
|
|
677
|
+
msg.nativeAlignSeed == null
|
|
678
|
+
? node.nativeAlignSeed
|
|
679
|
+
: !!msg.nativeAlignSeed,
|
|
680
|
+
// Aggressive prototype: OpenCV owns affine solve + global warp.
|
|
681
|
+
// It intentionally may produce different inspection results.
|
|
682
|
+
nativeFastAlign:
|
|
683
|
+
msg.nativeFastAlign == null
|
|
684
|
+
? node.nativeFastAlign
|
|
685
|
+
: Boolean(msg.nativeFastAlign),
|
|
686
|
+
mmPerPixelNative: scaleOk ? scale.mmPerPixelNative : null,
|
|
687
|
+
// the calibration photo's own native size, so prepareGolden can
|
|
688
|
+
// convert the mm/px scale across a golden rendered at a
|
|
689
|
+
// different resolution than the calibration was taken at
|
|
690
|
+
calibrationNativeWidth: scaleOk ? scale.nativeWidth : null,
|
|
691
|
+
calibrationNativeHeight: scaleOk ? scale.nativeHeight : null,
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
// Fingerprint first, load only on a miss. The golden's bytes are
|
|
695
|
+
// the most expensive thing this node can touch, and on the hot
|
|
696
|
+
// path they have not changed - re-reading the artwork file, or
|
|
697
|
+
// re-hashing a 12MB render, is time spent re-learning a constant.
|
|
698
|
+
// msg.goldenKey names the golden instead, which is what it was
|
|
699
|
+
// always documented to do.
|
|
700
|
+
const named =
|
|
701
|
+
typeof msg.goldenKey === "string" && msg.goldenKey !== ""
|
|
702
|
+
? msg.goldenKey
|
|
703
|
+
: null;
|
|
704
|
+
// Everything the inspector needs to hold a prepared golden for
|
|
705
|
+
// this key. Re-runnable, fingerprint and all: the inspector's
|
|
706
|
+
// store is bounded, so an entry can be evicted between preparing
|
|
707
|
+
// it and using it, and the retry below re-runs this.
|
|
708
|
+
let goldenKey;
|
|
709
|
+
let goldenMeta;
|
|
710
|
+
let cacheKey;
|
|
711
|
+
const ensureGolden = async () => {
|
|
712
|
+
const fingerprint = await fingerprintImage(
|
|
713
|
+
goldenSource,
|
|
714
|
+
"golden reference",
|
|
715
|
+
named,
|
|
716
|
+
);
|
|
717
|
+
// the same string that ties a trained transform to its golden, so
|
|
718
|
+
// its format is persisted and must not drift - see the cache key
|
|
719
|
+
// note below
|
|
720
|
+
goldenKey = fingerprint.key;
|
|
721
|
+
try {
|
|
722
|
+
// only a golden sent on the message can be raw; one loaded from
|
|
723
|
+
// goldenPath is a file, and files carry their own geometry
|
|
724
|
+
cfg.raw =
|
|
725
|
+
msg.golden == null
|
|
726
|
+
? undefined
|
|
727
|
+
: goldenRawGeometry(msg, msg.golden);
|
|
728
|
+
// only settings actually baked into the cached golden object
|
|
729
|
+
// need to invalidate it - printTolerance/alignSearch/etc are
|
|
730
|
+
// applied fresh per frame in compareFrame.
|
|
731
|
+
cacheKey = [
|
|
732
|
+
fingerprint.key,
|
|
733
|
+
cfg.workingSize,
|
|
734
|
+
cfg.threshold,
|
|
735
|
+
cfg.thresholdMode,
|
|
736
|
+
cfg.sauvolaRadius,
|
|
737
|
+
cfg.sauvolaK,
|
|
738
|
+
// golden.fgAmbiguous is baked in at prepare time
|
|
739
|
+
cfg.inkMargin,
|
|
740
|
+
cfg.backgroundTolerance,
|
|
741
|
+
// whether the golden's debug-stage PNGs were baked in
|
|
742
|
+
cfg.debugStages,
|
|
743
|
+
cfg.mmPerPixelNative,
|
|
744
|
+
// the calibration photo's size, which the mm/px conversion is
|
|
745
|
+
// expressed against
|
|
746
|
+
cfg.calibrationNativeWidth == null
|
|
747
|
+
? ""
|
|
748
|
+
: `${cfg.calibrationNativeWidth}x${cfg.calibrationNativeHeight}`,
|
|
749
|
+
// raw geometry changes how the same bytes decode
|
|
750
|
+
cfg.raw
|
|
751
|
+
? `${cfg.raw.width}x${cfg.raw.height}x${cfg.raw.channels}`
|
|
752
|
+
: "",
|
|
753
|
+
// A named key is the flow's assertion that the bytes did not
|
|
754
|
+
// change, and it is deliberately trusted. The length is free
|
|
755
|
+
// to check and catches the coarsest way that assertion can be
|
|
756
|
+
// wrong (a different render under a stale name); it is in the
|
|
757
|
+
// cache key only, never in `goldenKey` itself, which is
|
|
758
|
+
// persisted in trained-transform files and must keep its
|
|
759
|
+
// format.
|
|
760
|
+
named && fingerprint.byteLength != null
|
|
761
|
+
? `len:${fingerprint.byteLength}`
|
|
762
|
+
: "",
|
|
763
|
+
].join("|");
|
|
764
|
+
|
|
765
|
+
if (!node.goldenCache || node.goldenCache.key !== cacheKey) {
|
|
766
|
+
node.status({
|
|
767
|
+
fill: "blue",
|
|
768
|
+
shape: "dot",
|
|
769
|
+
text: "preparing golden…",
|
|
770
|
+
});
|
|
771
|
+
// Ask before sending. The inspector usually already holds
|
|
772
|
+
// this golden - it outlives a redeploy - and reading the
|
|
773
|
+
// artwork off disk only to have it recognised as a duplicate
|
|
774
|
+
// key is the cost this handshake exists to avoid.
|
|
775
|
+
const promise = (async () => {
|
|
776
|
+
let reply = await inspector.prepare({ cacheKey, cfg });
|
|
777
|
+
if (reply.needGolden) {
|
|
778
|
+
const { buffer: goldenBuf } = await loadImage(
|
|
779
|
+
goldenSource,
|
|
780
|
+
"golden reference",
|
|
781
|
+
fingerprint,
|
|
782
|
+
);
|
|
783
|
+
// the object form is checked inside loadImage; the
|
|
784
|
+
// msg.goldenRawInfo / msg.images[] forms arrive
|
|
785
|
+
// separately, with the buffer known here
|
|
786
|
+
assertRawFits(goldenBuf, cfg.raw, "golden reference");
|
|
787
|
+
reply = await inspector.prepare({
|
|
788
|
+
cacheKey,
|
|
789
|
+
cfg,
|
|
790
|
+
golden: toShared(goldenBuf).buffer,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return reply.goldenMeta;
|
|
794
|
+
})().catch((err) => {
|
|
795
|
+
// let the next message retry instead of being stuck on a
|
|
796
|
+
// permanently-rejected cache entry
|
|
797
|
+
if (node.goldenCache && node.goldenCache.key === cacheKey) {
|
|
798
|
+
node.goldenCache = null;
|
|
799
|
+
}
|
|
800
|
+
throw err;
|
|
801
|
+
});
|
|
802
|
+
node.goldenCache = { key: cacheKey, promise };
|
|
803
|
+
}
|
|
804
|
+
goldenMeta = await node.goldenCache.promise;
|
|
805
|
+
} finally {
|
|
806
|
+
await fingerprint.close();
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
await ensureGolden();
|
|
810
|
+
|
|
811
|
+
// The golden's content identity, for tying a trained transform to
|
|
812
|
+
// the image rather than to how the image was delivered. Lazy and
|
|
813
|
+
// memoised on the node: it is needed when training, and otherwise
|
|
814
|
+
// only to settle a cheap-key mismatch that would refuse a good
|
|
815
|
+
// record on every frame. A buffer golden is already keyed by its
|
|
816
|
+
// own SHA-1, so that case costs nothing; a path golden is read and
|
|
817
|
+
// hashed once per file version, never per frame. Raw geometry
|
|
818
|
+
// rides along because the same bytes decode into a different image
|
|
819
|
+
// under a different width/height/channels.
|
|
820
|
+
const goldenContentKey = async () => {
|
|
821
|
+
const suffix = cfg.raw
|
|
822
|
+
? `:${cfg.raw.width}x${cfg.raw.height}x${cfg.raw.channels}`
|
|
823
|
+
: "";
|
|
824
|
+
if (goldenKey.startsWith("buf:")) {
|
|
825
|
+
return `sha1:${goldenKey.slice(4)}${suffix}`;
|
|
826
|
+
}
|
|
827
|
+
// the suffix is part of the memo key too: a named golden
|
|
828
|
+
// (msg.goldenKey) keeps its name across a change of raw
|
|
829
|
+
// geometry, and the same bytes are a different image then
|
|
830
|
+
const memo = node.goldenContentKey;
|
|
831
|
+
const memoKey = `${goldenKey}${suffix}`;
|
|
832
|
+
if (memo && memo.key === memoKey) return memo.contentKey;
|
|
833
|
+
const { buffer } = await loadImage(goldenSource, "golden reference");
|
|
834
|
+
const contentKey = `sha1:${crypto
|
|
835
|
+
.createHash("sha1")
|
|
836
|
+
.update(buffer)
|
|
837
|
+
.digest("hex")}${suffix}`;
|
|
838
|
+
node.goldenContentKey = { key: memoKey, contentKey };
|
|
839
|
+
return contentKey;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// The golden is never upscaled, so a source smaller than
|
|
843
|
+
// workingSize silently caps the whole inspection: the frame is
|
|
844
|
+
// brought down to the golden's scale, and detail the camera
|
|
845
|
+
// did capture is thrown away before anything looks at it. Easy
|
|
846
|
+
// to walk into when the golden is a PDF render, where the pixel
|
|
847
|
+
// count is a dpi setting rather than a property of the file.
|
|
848
|
+
const goldenLongEdge = Math.max(
|
|
849
|
+
goldenMeta.nativeWidth || 0,
|
|
850
|
+
goldenMeta.nativeHeight || 0,
|
|
851
|
+
);
|
|
852
|
+
if (
|
|
853
|
+
goldenLongEdge > 0 &&
|
|
854
|
+
goldenLongEdge < cfg.workingSize &&
|
|
855
|
+
node.warnedAboutKey !== cacheKey
|
|
856
|
+
) {
|
|
857
|
+
node.warnedAboutKey = cacheKey;
|
|
858
|
+
node.warn(
|
|
859
|
+
`golden is ${goldenMeta.nativeWidth}x${goldenMeta.nativeHeight}, smaller than ` +
|
|
860
|
+
`workingSize ${cfg.workingSize} - the inspection runs at the golden's ` +
|
|
861
|
+
`resolution, not the frame's. Render it at a higher dpi, or lower ` +
|
|
862
|
+
`workingSize to match.`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// The calibration measures mm/px on the calibration photo's own
|
|
867
|
+
// native resolution; the formula converts across a golden rendered
|
|
868
|
+
// at a different one, so the mm numbers stay right - but an
|
|
869
|
+
// operator who calibrated on a 4096-wide capture and then feeds
|
|
870
|
+
// 1844-wide artwork should hear that the two framings differ.
|
|
871
|
+
// Keyed on its own flag so it cannot crowd out the golden-too-small
|
|
872
|
+
// warning for the same golden.
|
|
873
|
+
if (
|
|
874
|
+
cfg.mmPerPixelNative != null &&
|
|
875
|
+
cfg.calibrationNativeWidth != null &&
|
|
876
|
+
(goldenMeta.nativeWidth !== cfg.calibrationNativeWidth ||
|
|
877
|
+
goldenMeta.nativeHeight !== cfg.calibrationNativeHeight) &&
|
|
878
|
+
node.warnedAboutGoldenRes !== cacheKey
|
|
879
|
+
) {
|
|
880
|
+
node.warnedAboutGoldenRes = cacheKey;
|
|
881
|
+
node.warn(
|
|
882
|
+
`golden is ${goldenMeta.nativeWidth}x${goldenMeta.nativeHeight}, but the calibration ` +
|
|
883
|
+
`photo was ${cfg.calibrationNativeWidth}x${cfg.calibrationNativeHeight} - the ` +
|
|
884
|
+
`mm/px scale is converted across that resolution difference, so mm ` +
|
|
885
|
+
`tolerances stay correct; calibrate from a photo at the golden's ` +
|
|
886
|
+
`resolution if the conversion surprises you`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// No fingerprint for the frame: it is different every time, so
|
|
891
|
+
// nothing is cached against it and the key was computed and
|
|
892
|
+
// thrown away. That was a SHA-1 of the whole payload on every
|
|
893
|
+
// message - 27ms of a 23MP framebuffer, for nothing.
|
|
894
|
+
const { buffer: targetBuf, raw: targetRawFromSource } = await loadImage(
|
|
895
|
+
msg.payload,
|
|
896
|
+
"msg.payload",
|
|
897
|
+
);
|
|
898
|
+
cfg.targetRaw = targetRawGeometry(msg, msg.payload) || targetRawFromSource;
|
|
899
|
+
assertRawFits(targetBuf, cfg.targetRaw, "msg.payload");
|
|
900
|
+
// Copied into shared memory once, here, so the inspector gets a
|
|
901
|
+
// handle rather than tens of megabytes: ~12ms for a 23MP raw
|
|
902
|
+
// framebuffer against the ~600ms of frozen event loop it buys.
|
|
903
|
+
//
|
|
904
|
+
// It also narrows - but does not close - an old hazard: sharp
|
|
905
|
+
// decodes asynchronously from whatever buffer it was given, so
|
|
906
|
+
// a flow reusing its capture buffer could corrupt a decode
|
|
907
|
+
// already in progress. After this copy the decoder never sees
|
|
908
|
+
// the caller's memory, so mutation *during* the decode is no
|
|
909
|
+
// longer possible; mutation between send() and this line still
|
|
910
|
+
// is, and always was.
|
|
911
|
+
const frame = toShared(targetBuf).buffer;
|
|
912
|
+
|
|
913
|
+
// Training measures the rig's magnification and the press's
|
|
914
|
+
// stretch from this one frame and writes them down; every
|
|
915
|
+
// later frame reuses them instead of re-deriving a constant.
|
|
916
|
+
// Send msg.golden alongside msg.payload to train from any two
|
|
917
|
+
// images without disturbing the node's configured golden.
|
|
918
|
+
const training =
|
|
919
|
+
msg.trainTransform == null ? node.trainTransform : !!msg.trainTransform;
|
|
920
|
+
let trainedScore = null;
|
|
921
|
+
let pinRefused = null;
|
|
922
|
+
if (!training && node.transformFilePath) {
|
|
923
|
+
const trained = await readTransformFile(node.transformFilePath, {
|
|
924
|
+
goldenKey,
|
|
925
|
+
goldenContentKey,
|
|
926
|
+
workingSize: cfg.workingSize,
|
|
927
|
+
});
|
|
928
|
+
if (trained && trained.error) {
|
|
929
|
+
// carry on searching rather than aligning to numbers
|
|
930
|
+
// known to be wrong - a stale pin looks like a print
|
|
931
|
+
// fault across the whole frame, which is the most
|
|
932
|
+
// expensive way to be wrong here
|
|
933
|
+
pinRefused = trained.error;
|
|
934
|
+
node.warn(`${trained.error}; searching for the transform instead`);
|
|
935
|
+
} else if (trained) {
|
|
936
|
+
cfg.pinnedScale = { mx: trained.scaleX, my: trained.scaleY };
|
|
937
|
+
trainedScore = trained.record.alignScore;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
node.status({
|
|
942
|
+
fill: "blue",
|
|
943
|
+
shape: "dot",
|
|
944
|
+
text: training ? "training transform…" : "comparing…",
|
|
945
|
+
});
|
|
946
|
+
// The inspector's golden store is bounded, so the entry
|
|
947
|
+
// prepared moments ago can be evicted before this frame uses it
|
|
948
|
+
// - by another node with a different golden, or a message that
|
|
949
|
+
// re-keyed on its own threshold. Re-prepare and try once more.
|
|
950
|
+
//
|
|
951
|
+
// Invalidating the cache first is what makes the retry
|
|
952
|
+
// terminate: ensureGolden decides on `node.goldenCache.key !==
|
|
953
|
+
// cacheKey`, so retrying without clearing it would re-enter a
|
|
954
|
+
// cache *hit*, never send a prepare, and ask the same empty
|
|
955
|
+
// inspector again forever.
|
|
956
|
+
let reply = await inspector.inspect({ cacheKey, cfg, frame });
|
|
957
|
+
if (reply.needGolden) {
|
|
958
|
+
if (node.goldenCache && node.goldenCache.key === cacheKey) {
|
|
959
|
+
node.goldenCache = null;
|
|
960
|
+
}
|
|
961
|
+
await ensureGolden();
|
|
962
|
+
reply = await inspector.inspect({ cacheKey, cfg, frame });
|
|
963
|
+
if (reply.needGolden) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
"the inspector lost the prepared golden twice in a row - " +
|
|
966
|
+
"the golden store is too small for the number of goldens in flight",
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const result = reply.result;
|
|
971
|
+
|
|
972
|
+
// A pinned transform cannot notice that the press has changed.
|
|
973
|
+
// The stretch is a property of the print run, not of the
|
|
974
|
+
// golden, so a new run on the same artwork needs retraining
|
|
975
|
+
// and no file check can see it coming - the golden matches.
|
|
976
|
+
// What does show it is the alignment residual: it jumps well
|
|
977
|
+
// clear of what training measured. Say so rather than
|
|
978
|
+
// reporting a frame-wide print fault.
|
|
979
|
+
if (
|
|
980
|
+
trainedScore != null &&
|
|
981
|
+
result.transform.score > trainedScore * 1.5 + 0.005
|
|
982
|
+
) {
|
|
983
|
+
node.warn(
|
|
984
|
+
`alignment residual ${result.transform.score.toFixed(4)} is well above the ` +
|
|
985
|
+
`${trainedScore.toFixed(4)} measured at training - the trained transform ` +
|
|
986
|
+
`probably no longer fits this print run; retrain it`,
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (training) {
|
|
991
|
+
const record = {
|
|
992
|
+
scaleX: result.transform.scaleX,
|
|
993
|
+
scaleY: result.transform.scaleY,
|
|
994
|
+
stretchPercent: result.transform.stretchPercent,
|
|
995
|
+
angleDeg: result.transform.angleDeg,
|
|
996
|
+
alignScore: result.transform.score,
|
|
997
|
+
goldenKey,
|
|
998
|
+
// what the record is really tied to: the golden's bytes, so
|
|
999
|
+
// the same image still matches when it arrives by a different
|
|
1000
|
+
// route than the one it was trained through
|
|
1001
|
+
goldenContentKey: await goldenContentKey(),
|
|
1002
|
+
workingSize: cfg.workingSize,
|
|
1003
|
+
goldenWidth: goldenMeta.width,
|
|
1004
|
+
goldenHeight: goldenMeta.height,
|
|
1005
|
+
trainedAt: new Date().toISOString(),
|
|
1006
|
+
};
|
|
1007
|
+
if (!node.transformFilePath) {
|
|
1008
|
+
throw new Error(
|
|
1009
|
+
"training needs a Trained transform path to write to - set one on the node",
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
await writeTransformFile(node.transformFilePath, record);
|
|
1013
|
+
msg.trainedTransform = record;
|
|
1014
|
+
node.log(
|
|
1015
|
+
`trained transform: scaleX=${record.scaleX.toFixed(5)} ` +
|
|
1016
|
+
`scaleY=${record.scaleY.toFixed(5)} stretch=${record.stretchPercent.toFixed(2)}% ` +
|
|
1017
|
+
`alignScore=${record.alignScore.toFixed(4)} -> ${node.transformFilePath}`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
msg.payload = result.pass;
|
|
1022
|
+
msg.result = {
|
|
1023
|
+
pass: result.pass,
|
|
1024
|
+
position: result.position,
|
|
1025
|
+
// a node.warn() is easy to miss in the sidebar, and a refused
|
|
1026
|
+
// pin is otherwise invisible from the message: same shape,
|
|
1027
|
+
// silently slower, transform.pinned quietly false
|
|
1028
|
+
transform: pinRefused
|
|
1029
|
+
? { ...result.transform, pinRefused }
|
|
1030
|
+
: result.transform,
|
|
1031
|
+
// registration grade, and whether this looks like the wrong
|
|
1032
|
+
// golden rather than a bad part - see gradeMatch
|
|
1033
|
+
match: result.match,
|
|
1034
|
+
thresholds: result.thresholds,
|
|
1035
|
+
localAlign: result.localAlign,
|
|
1036
|
+
printBlemish: {
|
|
1037
|
+
pass: result.printBlemish.pass,
|
|
1038
|
+
defectRatio: result.printBlemish.defectRatio,
|
|
1039
|
+
regions: result.printBlemish.regions,
|
|
1040
|
+
},
|
|
1041
|
+
backgroundBlemish: {
|
|
1042
|
+
pass: result.backgroundBlemish.pass,
|
|
1043
|
+
defectRatio: result.backgroundBlemish.defectRatio,
|
|
1044
|
+
regions: result.backgroundBlemish.regions,
|
|
1045
|
+
},
|
|
1046
|
+
};
|
|
1047
|
+
msg.timings = {
|
|
1048
|
+
decodeMs: Math.round(result.timings.decodeMs),
|
|
1049
|
+
alignMs: Math.round(result.timings.alignMs),
|
|
1050
|
+
diffMs: Math.round(result.timings.diffMs),
|
|
1051
|
+
heatmapMs: Math.round(result.timings.heatmapMs),
|
|
1052
|
+
stagesMs: Math.round(result.timings.stagesMs),
|
|
1053
|
+
totalMs: Math.round(performance.now() - totalStart),
|
|
1054
|
+
};
|
|
1055
|
+
if (result.printBlemish.heatmap) {
|
|
1056
|
+
msg.printHeatmap = result.printBlemish.heatmap;
|
|
1057
|
+
} else {
|
|
1058
|
+
delete msg.printHeatmap;
|
|
1059
|
+
}
|
|
1060
|
+
if (result.backgroundBlemish.heatmap) {
|
|
1061
|
+
msg.backgroundHeatmap = result.backgroundBlemish.heatmap;
|
|
1062
|
+
} else {
|
|
1063
|
+
delete msg.backgroundHeatmap;
|
|
1064
|
+
}
|
|
1065
|
+
if (result.stages) {
|
|
1066
|
+
msg.stages = result.stages;
|
|
1067
|
+
} else {
|
|
1068
|
+
delete msg.stages;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
send(msg);
|
|
1072
|
+
|
|
1073
|
+
// Said before the pass/fail line, because it changes what that
|
|
1074
|
+
// line means: every number below it is a comparison against
|
|
1075
|
+
// something that is not this label.
|
|
1076
|
+
if (result.match.mismatchSuspected) {
|
|
1077
|
+
node.warn(`golden-compare: ${result.match.reason}`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const failedParts = [];
|
|
1081
|
+
if (!result.position.pass) failedParts.push("position");
|
|
1082
|
+
if (!result.printBlemish.pass) failedParts.push("print");
|
|
1083
|
+
if (!result.backgroundBlemish.pass) failedParts.push("background");
|
|
1084
|
+
node.status({
|
|
1085
|
+
fill: result.pass ? "green" : "red",
|
|
1086
|
+
shape: result.pass ? "dot" : "ring",
|
|
1087
|
+
text: result.match.mismatchSuspected
|
|
1088
|
+
? `different label? · align ${result.match.score.toFixed(3)}`
|
|
1089
|
+
: result.pass
|
|
1090
|
+
? `pass · align ${result.match.score.toFixed(3)} · ${msg.timings.totalMs}ms`
|
|
1091
|
+
: `fail (${failedParts.join("+")}) · align ${result.match.score.toFixed(3)} · ${msg.timings.totalMs}ms`,
|
|
1092
|
+
});
|
|
1093
|
+
const pos = result.position;
|
|
1094
|
+
const posStr =
|
|
1095
|
+
(pos.dxMm == null
|
|
1096
|
+
? `dx=${pos.dxPx}px dy=${pos.dyPx}px`
|
|
1097
|
+
: `dx=${pos.dxMm.toFixed(2)}mm dy=${pos.dyMm.toFixed(2)}mm`) +
|
|
1098
|
+
` angle=${pos.angleDeg.toFixed(2)}deg scale=${pos.scale.toFixed(3)}` +
|
|
1099
|
+
` stretch=${pos.stretchPercent.toFixed(2)}%` +
|
|
1100
|
+
(result.transform.pinned ? " pinned" : "");
|
|
1101
|
+
const matchStr = `align(${result.match.grade} ${result.match.score.toFixed(4)}${
|
|
1102
|
+
result.match.mismatchSuspected ? " DIFFERENT-LABEL?" : ""
|
|
1103
|
+
}) `;
|
|
1104
|
+
node.log(
|
|
1105
|
+
`golden-compare: ${result.pass ? "PASS" : "FAIL"} [${failedParts.join("+") || "none"}] ` +
|
|
1106
|
+
matchStr +
|
|
1107
|
+
`position(${pos.pass ? "ok" : "FAIL"} ${posStr}) ` +
|
|
1108
|
+
`print(${result.printBlemish.pass ? "ok" : "FAIL"} ratio=${result.printBlemish.defectRatio.toFixed(5)} regions=${result.printBlemish.regions.length}) ` +
|
|
1109
|
+
`background(${result.backgroundBlemish.pass ? "ok" : "FAIL"} ratio=${result.backgroundBlemish.defectRatio.toFixed(5)} regions=${result.backgroundBlemish.regions.length}) | ` +
|
|
1110
|
+
`decode ${fmtMs(result.timings.decodeMs)}, align ${fmtMs(result.timings.alignMs)}, ` +
|
|
1111
|
+
`diff ${fmtMs(result.timings.diffMs)}, heatmap ${fmtMs(result.timings.heatmapMs)}, ` +
|
|
1112
|
+
`stages ${fmtMs(result.timings.stagesMs)}, total ${fmtMs(msg.timings.totalMs)}`,
|
|
1113
|
+
);
|
|
1114
|
+
done();
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
node.status({ fill: "red", shape: "ring", text: "error" });
|
|
1117
|
+
// done(err) routes the failure through node.error exactly
|
|
1118
|
+
// once; an explicit node.error here reported every failure
|
|
1119
|
+
// twice (double log lines, Catch nodes firing twice)
|
|
1120
|
+
done(err);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
RED.nodes.registerType("golden-compare", GoldenCompareNode);
|
|
1126
|
+
};
|