@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,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
+ };