@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/README.md ADDED
@@ -0,0 +1,992 @@
1
+ # @graciousstar/node-red-contrib-vision-tools
2
+
3
+ Machine-vision nodes for Node-RED, built around one assumption: the camera
4
+ is fixed. Framing, scale and lighting hold from shot to shot, so the
5
+ expensive parts of inspection — where the part is, how many millimetres a
6
+ pixel covers, where the label edge falls — can be measured once at
7
+ commissioning and only checked afterwards.
8
+
9
+ For how the pipeline is put together and why each piece is shaped the way
10
+ it is, see [ARCHITECTURE.md](ARCHITECTURE.md). This file is the operator's
11
+ guide: what the settings do and how to use them. Version history is in
12
+ [CHANGELOG.md](CHANGELOG.md).
13
+
14
+ ## The nodes
15
+
16
+ - **`golden-compare`** — compares a camera capture against a cached
17
+ "golden" reference image: a **position** check (measured shift and angle
18
+ vs. tolerance bands — a real position defect fails on its own, it isn't
19
+ silently corrected away) plus two independent **blemish** checks —
20
+ print (missing ink) and background (unwanted ink) — each with its own
21
+ tolerance. Alignment recovers independent x/y magnification and
22
+ rotation as well as translation, then refines what is left per tile, so
23
+ the golden can be the label's PDF artwork rather than a capture off the
24
+ same camera — including raw pixels handed straight over by
25
+ `pdf-to-image`. The per-pixel stages run on a worker pool.
26
+ - **`checkerboard-calibrate`** — photograph a printed checkerboard of
27
+ known pitch, measure the pixel pitch, and save or compare the resulting
28
+ mm/px scale. Run once at commissioning and again after camera or
29
+ mechanical maintenance, not per frame — the whole point of a fixed
30
+ camera rig is that this doesn't need re-running per part.
31
+ - **`label-crop`** — deskews and crops a physical label out of a frame,
32
+ either by thresholding the whole frame and taking the dominant blob or,
33
+ when the label's own boundary is fainter than its printed artwork, by
34
+ intersecting four caliper-measured edges.
35
+ - **`line-finder`** — finds one straight edge inside a region you draw. A
36
+ row of calipers scans across it, each reports where the brightness
37
+ steps, and a line is fitted through those points with outliers dropped.
38
+ Pure JS, no native engine, and only the pixels inside the region are
39
+ touched.
40
+ - **`barcode-locate`** — finds and decodes 1D and 2D barcodes, optionally
41
+ restricted to pre-defined pixel regions with a whole-image fallback.
42
+
43
+ Built for speed — a ~23MP camera frame decodes, aligns, diffs and
44
+ heat-maps in well under a second: around 0.45s against a same-scale
45
+ golden, and roughly double that when magnification and stretch both have
46
+ to be searched over a wide range (see Notes).
47
+
48
+ ## Install
49
+
50
+ From the Node-RED palette manager, search for
51
+ `@graciousstar/node-red-contrib-vision-tools`, or from your Node-RED user
52
+ directory (`~/.node-red`):
53
+
54
+ ```bash
55
+ npm i @graciousstar/node-red-contrib-vision-tools
56
+ ```
57
+
58
+ Node 18 or newer. `sharp` and `zxing-wasm` are required and ship prebuilt
59
+ binaries for the usual platforms.
60
+
61
+ `label-crop` and two optional `golden-compare` acceleration paths need a
62
+ native OpenCV addon, which is an **optional** dependency: the package
63
+ installs and every other node works without it, and `label-crop` reports a
64
+ clear setup error rather than failing a part. Prebuilt binaries for that
65
+ engine cover Linux x64/arm64, Alpine x64 and macOS arm64 — there is no
66
+ win32 build, which is why the test suite never touches it.
67
+
68
+ ## How `golden-compare` works
69
+
70
+ 1. **Golden reference** (`goldenPath`, or `msg.golden`) is decoded, grayscaled,
71
+ downscaled to `workingSize` and binarized **once** and cached — not
72
+ redone per frame, and neither are the density lattices the alignment
73
+ search compares against. Its foreground mask is dilated by
74
+ `backgroundTolerance` px to create the background-check's tolerance band.
75
+
76
+ Binarizing uses `thresholdMode`: `otsu` (the default — one level per
77
+ image, chosen to separate the two intensity modes, which absorbs
78
+ uniform exposure change), `fixed` (one hand-set level — fastest and
79
+ perfectly repeatable, but it drifts out of calibration as the lighting
80
+ does), or `sauvola` (a per-pixel level from the local mean and standard
81
+ deviation — also absorbs lighting *gradients* across the part). Otsu
82
+ and Sauvola make golden and frame independently self-normalizing. That
83
+ is the default for a reason: the golden is normally PDF artwork —
84
+ synthetic pure black on pure white — and the frame is a photograph, and
85
+ no single grey level is correct for both.
86
+
87
+ Whichever mode is chosen the decision is still a hard cut, so
88
+ `inkMargin` (grey levels, default 8) marks a pixel **ambiguous** when
89
+ it lies within that many levels of the level it was judged against, on
90
+ *either* side, and both blemish checks drop a pixel from the evidence
91
+ when either image is ambiguous there. A defect claim is a claim about
92
+ both images — "ink here, none there" — so ambiguity on either side
93
+ voids it.
94
+
95
+ This is not a fudge factor; it fixes two specific, reproducible
96
+ failures against artwork. **On the frame side**, a screened tint
97
+ renders lighter than the level in the PDF and dot gain prints it
98
+ darker than the level in the photograph, so the identical design
99
+ element binarizes to background on one side and ink on the other — on
100
+ this project's pair, two false regions at densities 0.30 and 0.23 from
101
+ a 9-level swing. **On the golden side**, and this one only appears
102
+ above `workingSize` 1024, Otsu's level is not stable: it is re-derived
103
+ from each image's histogram, and on this artwork it walks from 160 at
104
+ 1024 down to 145 at 3072. A flat "RX" panel sitting at grey 155 flips
105
+ from ink to background partway up that range while the print
106
+ reproduces it at 84 — solid ink — lighting up a whole 144×304 region
107
+ at density 1.000 on a good part. Only the two-sided test catches that,
108
+ because the artwork pixel there is *background*.
109
+
110
+ Ambiguous pixels remain full evidence for *alignment*; the margin only
111
+ withholds them from the defect claim. Set it to 0 for plain
112
+ hard-threshold behaviour.
113
+ 2. Each incoming frame is decoded **preserving its own aspect ratio** — a
114
+ raw camera capture often includes background/margin beyond golden's own
115
+ framing, and stretching it to golden's exact dimensions would distort
116
+ it non-uniformly rather than crop it. The resize target is golden's
117
+ *physical scale*, not its pixel dimensions: with a calibrated
118
+ `scaleFilePath`, that's an exact mm/px conversion; without one, the
119
+ frame inherits golden's own realized native→working scale — the
120
+ same-rig assumption that one native pixel spans the same distance in
121
+ both images (see `computeTargetWorkingSize` in `lib/compare.js`). Both
122
+ sides go through one decode path at explicitly computed dimensions, so
123
+ a frame compared against its own golden is bit-identical and scores an
124
+ exactly-zero defect ratio.
125
+ 3. The frame is binarized and `lib/align.js` searches for the transform
126
+ — magnifications `mx`/`my`, rotation `θ`, translation `(ox,oy)` — that
127
+ best places the golden template within it. The two magnifications are
128
+ **independent**, which is what makes an artwork golden usable at all:
129
+ a press stretches print along its media-feed axis relative to the
130
+ artwork (5–6% on this project's own sample pairs), and a single
131
+ isotropic scale can only split that error, leaving every feature
132
+ several pixels out toward the ends of the long axis. On body text
133
+ several pixels is the whole stroke, so ~12% of pixels disagree and both
134
+ blemish checks fail a good part. Searching `mx` and `my` separately
135
+ takes the same pairs to ~0.06%. Scoring is
136
+ mean absolute ink-density difference over a lattice of cells, each read
137
+ in O(1) from a summed-area table, so one candidate costs a few thousand
138
+ operations no matter how large either image is. The search is
139
+ coarse-to-fine: a wide sweep of the scale ladder over the whole frame, a
140
+ joint scale+angle refine, a fine translation refine, and finally a
141
+ **polish** that optimizes actual pixel disagreement instead of the
142
+ coarse density proxy — the two stop agreeing at sub-pixel scale, and it
143
+ is the pixels that the blemish checks go on to measure. The matched
144
+ region is then resampled into golden's grid by `lib/warp.js`,
145
+ area-averaging when the frame out-resolves the golden so fine text is
146
+ downsampled rather than aliased.
147
+
148
+ `alignSearch` adds translation slack beyond the pure size difference.
149
+ Set `scaleSearchMin`/`Max` both to 1 to pin the magnification,
150
+ `maxAspect` to 0 to assume both axes share a scale, and `maxAngleDeg`
151
+ to 0 to disable the rotation search — worth doing for any of them if
152
+ the rig is fixtured tightly enough that searching is only a chance to
153
+ be wrong.
154
+ 4. **Position check**: the recovered placement is compared against
155
+ tolerance bands around *nominal* — centered in the available margin and
156
+ square to the frame. (There's no separate "nominal" to capture, unlike
157
+ their system's trained nominal from label-edge geometry; this reduces to
158
+ a tolerance around exactly `(0,0)` when target and golden are already
159
+ the same size.) Offsets in mm if `scaleFilePath` points at a calibration
160
+ baseline (see below), else in px. Rotation is gated separately, in
161
+ degrees, against `positionToleranceAngleDeg`: a part that is offset and
162
+ a part that is skewed are different faults with different causes, so
163
+ collapsing them into one number would throw away the more actionable
164
+ half.
165
+ 5. **Blemish checks**, both against the *matched/cropped* target:
166
+ - *print* — golden has ink the target is missing, even after dilating
167
+ the target's ink by `printTolerance` px
168
+ - *background* — target has ink the golden never has, even after
169
+ dilating the golden's ink by `backgroundTolerance` px
170
+
171
+ These are provably disjoint per pixel (a pixel that's target-foreground
172
+ can't simultaneously fail the "target lacks ink" test), so the two
173
+ checks never double-count the same defect. Each diff is block-summed
174
+ into a density grid (`blockSize` px) via a summed-area table,
175
+ thresholded (`blockThreshold`), and flood-filled into defect bounding
176
+ boxes; each fails independently if any region exceeds `failThreshold`
177
+ or its overall defect ratio exceeds `failRatio`.
178
+ 6. Overall `pass = position.pass && printBlemish.pass && backgroundBlemish.pass`.
179
+
180
+ Decode/resize uses [`sharp`](https://sharp.pixelplumbing.com) (native,
181
+ libvips) — the images here run 20+ MP, and a pure-JS decoder was too slow
182
+ for the "very fast" requirement this node was built for. Everything after
183
+ decode (threshold, dilation, alignment search, diff, block-sum) is plain
184
+ typed-array math with no further native dependencies — binary masks stay
185
+ `Uint8Array`/`Uint32Array`, while the grey summed-area tables use a
186
+ `Float64Array` accumulator so they cannot wrap on large frames (a `Uint32`
187
+ grey table wraps past ~16.8M bright pixels and silently corrupts the
188
+ area-average warp):
189
+ dilation is a separable sliding-window max filter (van Herk/Gil-Werman,
190
+ `lib/dilate.js`), so cost stays O(width×height) regardless of the
191
+ configured radius, and every density and area-average read in the
192
+ alignment search and the warp is an O(1) summed-area lookup
193
+ (`lib/integral.js`) rather than a re-scan.
194
+
195
+ ### Input
196
+
197
+ `msg.payload` — camera frame as a `Buffer` (any format `sharp` can decode:
198
+ PNG/JPEG/etc.) or a file path string.
199
+
200
+ Per-message overrides are listed under **Speed** below. `msg.golden`
201
+ (path or Buffer) swaps and re-caches the golden reference if it differs
202
+ from what's cached.
203
+
204
+ ### Loading the golden from a PDF
205
+
206
+ The golden is normally the label's artwork, so `pdf-to-image` can hand it
207
+ over directly. Set that node's **format** to `RAW` and wire one change
208
+ node:
209
+
210
+ ```text
211
+ msg.golden = msg.payload
212
+ ```
213
+
214
+ In RAW mode `msg.payload` is already
215
+ `{ data, width, height, channels, colorSpace, dtype }` — raw pixels carry
216
+ no container to hold a geometry, so the node hands it over alongside the
217
+ bytes, and that is exactly the shape this node accepts. No PNG encode, no
218
+ PNG decode, no temporary file. `PNG` output works too and needs no
219
+ geometry, at the cost of a round trip through the encoder.
220
+
221
+ Note `msg.images[]` is *per-page metadata only* — page, width, height,
222
+ channels, path — and carries no pixels. It is the wrong thing to reach
223
+ for here. When `msg.golden` is a path or a buffer that carries its own
224
+ container, `msg.images[]` geometry is ignored entirely — a stale `RAW`
225
+ message left over from an earlier step must not stamp the frame's
226
+ dimensions onto a file golden (the same guard the frame side has always
227
+ had).
228
+
229
+ Two things to know:
230
+
231
+ - **Get the rotation right first.** Artwork is often laid out at a quarter
232
+ turn to how the camera sees the label, and the alignment search covers
233
+ ±2°, not 90° — there is no finding the way back from a wrong
234
+ orientation. Set `rotation` on the pdf-to-image node. Measured on this
235
+ project's PDF: 270 places the golden within 56px of nominal, 90 lands it
236
+ 1013px out. **A comparison that fails everywhere with a large `dx`/`dy`
237
+ is this until proven otherwise** — it is the cheapest thing to rule out
238
+ and the least obvious.
239
+ - **Render at enough dpi.** The golden is never upscaled, so a render
240
+ whose long edge is below `workingSize` caps the whole inspection at the
241
+ golden's resolution and discards detail the camera did capture. At
242
+ `workingSize` 3072 a 100mm label wants roughly 780 dpi. The node warns
243
+ when the golden comes in short — including for a PNG golden, which is
244
+ worth checking: this project's own 1844x2656 artwork means a
245
+ `workingSize` above 2656 buys nothing.
246
+ - **Give it `msg.goldenKey`.** A buffer golden is keyed by a hash of its
247
+ bytes, and hashing a ~12MB raw render on every message is real time
248
+ spent learning something that did not change. `msg.goldenKey` names it
249
+ instead — a path, a revision, an mtime — and the hash is genuinely
250
+ skipped. (Before 1.0.2 it was not: the hash ran before the name was
251
+ consulted, so the option saved nothing.)
252
+
253
+ In exchange, **invalidation becomes yours**. The buffer's length is
254
+ still checked, so a differently-sized render under a stale name is
255
+ caught, but a *same-sized* one is served from cache. Change the key
256
+ whenever the artwork changes.
257
+
258
+ A golden loaded from a path needs no key: it is fingerprinted by mtime
259
+ and size, which costs a stat and no read.
260
+ - **If the frame is raw too, say `msg.rawInfo`.** `msg.images[]` is only
261
+ read for the frame when no golden travels on the same message, because
262
+ when the golden *is* the PDF render, `msg.images` describes that and
263
+ decoding a 23MP capture at the artwork's dimensions would give a
264
+ confident wrong answer instead of an error.
265
+
266
+ A camera SDK handing over a framebuffer works the same way: either send
267
+ `msg.payload = { data, width, height, channels }`, or send the bare buffer
268
+ with `msg.rawInfo = { width, height, channels }`. On a 4096x5500 frame
269
+ that removes ~300ms of PNG decode — the largest serial cost left in an
270
+ inspection.
271
+
272
+ Raw descriptors are validated before `sharp` sees them: the buffer must
273
+ actually be `width × height × channels` bytes (a mismatch is a clear
274
+ error naming the real numbers, not a native crash), declared sizes are
275
+ capped, and any image input over 512 MB is refused before it is copied
276
+ or hashed.
277
+
278
+ ### Grading, and telling a bad part from the wrong golden
279
+
280
+ `msg.result.match` reports how well the two images registered, separately
281
+ from whether the part is any good — a part can register perfectly and be
282
+ defective, and a flawless part can look ruined because the golden is
283
+ wrong.
284
+
285
+ ```js
286
+ match: { score, grade, mismatchSuspected, reason }
287
+ ```
288
+
289
+ `score` is the alignment residual, `grade` is `good` (< 0.06) / `marginal`
290
+ / `poor` (≥ `mismatchScore`). Measured here: a correctly paired label
291
+ registers at **0.02–0.05**, a badly printed one at about **0.10**, and
292
+ another product's artwork at about **0.18**.
293
+
294
+ `mismatchSuspected` is the one worth wiring to an operator. Comparing one
295
+ product's artwork against another's photograph reports **1145 print
296
+ regions and a 6% defect ratio** — which reads as a spectacularly bad
297
+ print, when the real answer is "wrong golden". The two are distinguished
298
+ by the *shape* of the disagreement rather than its size: a defective part
299
+ disagrees in one direction and in places, two different labels disagree in
300
+ both directions and everywhere. So the claim needs poor registration
301
+ **and** both blemish checks saturated. That pairing is what keeps a
302
+ genuinely bad part out of it — the worst real one here registers at 0.10
303
+ but its two ratios are 0.006 and 0.011, lopsided and an order of magnitude
304
+ low. `mismatchScore: 0` disables it.
305
+
306
+ ### Speed
307
+
308
+ A 4096×5500 PNG frame against a 1844×2656 golden, pinned, heat maps on,
309
+ 12 workers, is ~1.05s in this project's container. What moves that,
310
+ largest first:
311
+
312
+ | lever | effect |
313
+ | --- | --- |
314
+ | `debugStages` | **+690ms** when on. Pure diagnostics |
315
+ | heat maps | **+~300ms** when on. Display output; the verdict does not use them |
316
+ | trained transform | **halves** the alignment, and accuracy is the reason to pin, not speed |
317
+ | `workers` | the per-pixel stages and both summed-area tables run on the pool. 0 picks one per core up to **16**; on a 16-core host, 8 → 12 workers took align 798ms → 680ms |
318
+ | `nativeFastAlign` | aggressive OpenCV prototype: 663ms → **243ms** on the clean PNG and 1065ms → **443ms** on the high-compression reject. See below |
319
+ | `nativeAlignSeed` | conservative prototype, off by default: align 545ms → 338ms. See **Native alignment seed** below |
320
+ | raw input instead of PNG | **−~75ms** on this frame (decode 143ms → 70ms for mono). The saving is the *decode*, so it scales with the PNG's compressed size rather than its pixel count — a 13.7MB PNG of the same dimensions decodes in ~560ms |
321
+ | the host's power profile | **~2.2×** on everything. Measured on a laptop that had dropped to battery mode mid-session: align 2020ms → 913ms on mains, no code or setting changed |
322
+
323
+ Docker is **not** on that list, and an earlier version of this table was
324
+ wrong to put it there. Running this project's own `bench/frame-bench.js`
325
+ inside the container reproduces the committed `bench/baseline.json` from
326
+ the host within ~10% (pinned search 236ms vs 222ms, unpinned align 2075ms
327
+ vs 2219ms). What looked like a container tax was the power profile above.
328
+
329
+ `workingSize` is the one that changes *what is detectable* rather than
330
+ just how long it takes — see the note on it above before lowering it.
331
+
332
+ Per-message overrides:
333
+ `msg.threshold`, `msg.thresholdMode`, `msg.sauvolaRadius`, `msg.sauvolaK`,
334
+ `msg.inkMargin`, `msg.printTolerance`, `msg.backgroundTolerance`,
335
+ `msg.alignSearch`, `msg.scaleSearchMin`/`Max`/`Steps`, `msg.maxAspect`,
336
+ `msg.aspectSteps`, `msg.maxAngleDeg`, `msg.angleSteps`,
337
+ `msg.alignCandidates`, `msg.localAlign`, `msg.localAlignTile`,
338
+ `msg.localAlignMax`, `msg.workers`, `msg.mismatchScore`,
339
+ `msg.trainTransform`, `msg.nativeAlignSeed`, `msg.nativeFastAlign`,
340
+ `msg.positionToleranceXMm`/`YMm`/`XPx`/`YPx`/`AngleDeg`, `msg.blockSize`,
341
+ `msg.blockThreshold`, `msg.failThreshold`, `msg.failRatio`,
342
+ `msg.outputPrintHeatmap`, `msg.outputBackgroundHeatmap`,
343
+ `msg.debugStages`.
344
+
345
+ Raw geometry rides alongside the image rather than as a setting:
346
+ `msg.rawInfo` for the frame, `msg.goldenRawInfo` for the golden, and
347
+ `msg.goldenKey` to name the golden for the cache. See **Input** above.
348
+
349
+ ### OpenCV fast alignment (aggressive prototype)
350
+
351
+ Tick **Experimental OpenCV fast alignment** or send
352
+ `msg.nativeFastAlign = true` to move the complete affine solve and global
353
+ warp into OpenCV. This path also decodes through OpenCV. It bypasses both JS
354
+ summed-area tables, the density sweeps, pixel polish, and the final global
355
+ warp; local tile refinement and the blemish policy still run afterward.
356
+ `result.transform.native` reports which path produced the frame.
357
+
358
+ The native result is accepted only when it stays inside `maxAngleDeg`, remains
359
+ within 3% of each trained magnification, and its full-resolution disagreement
360
+ score is at most 0.15. Otherwise the same frame automatically falls back to
361
+ the trained JS alignment and `result.transform.nativeFallback` explains why.
362
+ This prevents OpenCV from explaining small artwork differences as large
363
+ scale/stretch changes.
364
+
365
+ This prototype is intentionally **not result-compatible** with the JS path.
366
+ OpenCV fits an unrestricted affine transform (including shear), uses nearest
367
+ neighbour for its warp, does not preserve the trained magnifications, and its
368
+ reported alignment score is full-resolution post-local mask disagreement
369
+ rather than the JS 320px polish objective. On the two available matching
370
+ fixtures it changed the clean image from fail to pass and still failed the
371
+ marked reject (four background regions).
372
+
373
+ Measured in the project container, 4096×5500 PNG, 12 workers, heat maps and
374
+ debug stages off, median of three warm frames:
375
+
376
+ | frame | JS | OpenCV full | speed-up |
377
+ | --- | ---: | ---: | ---: |
378
+ | clean | 663ms | **243ms** | 2.7× |
379
+ | high-compression reject | 1065ms | **443ms** | 2.4× |
380
+
381
+ Run `node bench/opencv-fast-bench.js [golden.png] [frame.png] [iterations]`
382
+ to compare the JS, conservative seed, and full OpenCV paths on another set.
383
+ A missing or failed native engine falls back to JS.
384
+
385
+ ### Native alignment seed (prototype, off by default)
386
+
387
+ `nativeAlignSeed` starts the pinned search from an ORB+ECC alignment
388
+ measured by the optional native OpenCV
389
+ engine, instead of from the staged sweeps. On a 4096×5500 frame that took
390
+ align from 545ms to 338ms, and the search itself from 325ms to 125ms.
391
+
392
+ The engine is **not a dependency**. Without it the flag is inert and the
393
+ node behaves exactly as it does today, bit for bit —
394
+ `test/nativeSeed.test.js` asserts that. Installing the optional native
395
+ OpenCV engine enables it; it ships published
396
+ prebuilt binaries for Linux x64/arm64, Alpine x64, and macOS arm64.
397
+
398
+ A seed replaces the sweeps' **guess**, never their verdict. The trained
399
+ magnifications stay the trained ones — a seed is not allowed to reopen the
400
+ constant that pinning exists to fix — so only the angle and the placement
401
+ come from it, and the polish still refines against real pixels and still
402
+ produces the score. Every seed is range-checked against the same physical
403
+ bounds `lib/transformFile.js` applies and discarded on failure in favour
404
+ of the sweeps, because the engine reports `success: true` for results that
405
+ are plainly wrong: its features-only pipeline returned scaleX 33.7 at 97°
406
+ on this project's own sample. `result.transform.seeded` reports whether a
407
+ seed was actually used.
408
+
409
+ **Why it is not the default.** Across five fixtures every verdict matched,
410
+ but the evidence behind one did not: the reject sample resolves into two
411
+ background regions seeded and one unseeded, and the alignment residual
412
+ moves in both directions (0.040663 → 0.040401 on a clean part, 0.040558 →
413
+ 0.040846 on a rotated one). ORB is feature matching, and a badly printed
414
+ label — the case this inspection exists for — is exactly where features
415
+ are poorest. Validate against a real set of rejects for your golden before
416
+ trusting it.
417
+
418
+ ### Output
419
+
420
+ - `msg.payload` — `true`/`false` overall pass
421
+ - `msg.result` —
422
+ `{ pass, position: { dxPx, dyPx, dxMm, dyMm, angleDeg, anglePass, scale, scaleX, scaleY, stretchPercent, pass }, transform: { pinned, native, nativeFallback?, seeded, pinRefused?, scaleX, scaleY, scale, stretchPercent, angleDeg, ox, oy, score }, match: { score, grade, mismatchSuspected, reason }, thresholds: { golden, target }, localAlign: { tiles, localised, meanPx, medianPx, maxPx }, printBlemish: { pass, defectRatio, regions: [{x,y,w,h,density,avgDensity,cells}] }, backgroundBlemish: { pass, defectRatio, regions } }`
423
+ (region coordinates in the working-resolution image, same size as the
424
+ heat maps — not the original camera resolution). `transform` is the raw
425
+ recovered placement in frame-canvas pixels (`pinned` reports whether a
426
+ trained transform was used, `seeded` whether that transform started from
427
+ a native ORB+ECC seed rather than the staged sweeps — see **Native
428
+ alignment seed** below — and `pinRefused` is present with the reason
429
+ when there was a trained record the node declined to use — it also
430
+ warns, but a warning is easy to miss and the fallback to a full search
431
+ is otherwise invisible from the message); `thresholds` reports the grey level each
432
+ side actually used, so exposure drift is visible rather than merely
433
+ absorbed; `match` is the registration grade described under **Grading**
434
+ below; `localAlign` is the per-tile refinement statistics (`tiles` =
435
+ tiles examined, `localised` = tiles that found a trusted offset,
436
+ `meanPx`/`medianPx`/`maxPx` = the recovered displacement magnitude in
437
+ working px) and is `null` when refinement is off.
438
+
439
+ `stretchPercent` — how far the two axis magnifications differ — is
440
+ reported but deliberately **not** gated. Some stretch is just what the
441
+ press does, and its normal value depends on media and machine, so any
442
+ default threshold would be a guess that fails good parts. It is worth
443
+ trending, though: a stretch that moves is a press drifting.
444
+ - `msg.printHeatmap` / `msg.backgroundHeatmap` — PNG `Buffer` overlays
445
+ (only if the matching `outputPrintHeatmap`/`outputBackgroundHeatmap` is on)
446
+ - `msg.timings` — `{ decodeMs, alignMs, diffMs, heatmapMs, stagesMs, totalMs }`
447
+ - `msg.stages` — only if `debugStages` is on: PNG `Buffer`s for each
448
+ pipeline step (`goldenGray`, `goldenFg`, `goldenFgDilatedBackground`,
449
+ `targetGray`, `targetFg`, `targetGrayAligned`, `targetFgAligned`,
450
+ `targetFgDilatedPrint`, `printDefect`, `backgroundDefect`) — for
451
+ diagnosing *why* a comparison is failing rather than just that it did.
452
+ `targetGray`/`targetFg` are the *full* pre-warp frame canvas (generally a
453
+ different size than golden — useful for seeing where the match landed);
454
+ everything from `targetGrayAligned` on is golden-sized.
455
+ `targetGrayAligned` is the most useful single image when a result looks
456
+ wrong: put next to `goldenGray` it shows immediately whether the
457
+ transform found the part or something else. Golden-side stages are cheap
458
+ (cached, computed once); target-side stages add real per-frame cost, so
459
+ leave this off outside debugging.
460
+ - `node.status()` — green dot `pass · align <score> · Nms` / red ring
461
+ `fail (position+print+background, whichever failed) · align <score> ·
462
+ Nms`, or `different label? · align <score>` when `mismatchSuspected`.
463
+ The node's log line additionally carries the recovered angle and
464
+ magnification, which is usually the first thing worth looking at when a
465
+ whole batch starts failing at once.
466
+
467
+ ## How `checkerboard-calibrate` works
468
+
469
+ 1. Decode the checkerboard photo at native resolution (no downscale — best
470
+ measurement precision), Otsu-threshold it (checkerboards are strongly
471
+ bimodal, so no manual threshold tuning needed), and connected-component
472
+ label the dark squares (`lib/components.js`).
473
+ 2. Arrange the blob centroids into a `checkerboardRows × checkerboardCols`
474
+ grid and measure the median pixel pitch between adjacent same-colour
475
+ squares, in both axes (`lib/checkerboard.js`). Centroid/pitch-based
476
+ only — no sub-pixel corner refinement, no lens-distortion or
477
+ perspective correction, consistent with `golden-compare`'s
478
+ translation-only scope.
479
+ 3. `mm/px = targetPitchMm / measured pitch`. Compared against the baseline
480
+ saved in `scaleFilePath` (`deviationPercent`, `pass` if within
481
+ `allowedErrorPercent`). With no baseline yet, the result is
482
+ informational only (`bootstrap: true`) — nothing to deviate from. A
483
+ photo too thin to measure a pitch is reported as **not detected** (with
484
+ a reason) rather than as a bogus scale.
485
+
486
+ Grid size counts **dark squares**, not physical squares:
487
+ `checkerboardCols` is the number of dark squares per row and
488
+ `checkerboardRows` the number of rows. A standard 4×6 physical board has
489
+ 2 dark squares per row, so it is `cols: 2, rows: 6` — the editor
490
+ defaults (`4 × 6`) in fact describe an 8×6 board.
491
+ 4. `msg.save: true` persists the freshly detected scale as the new
492
+ baseline (`{ mmPerPixelNative, nativeWidth, nativeHeight, calibratedAt }`),
493
+ read by `golden-compare`, which rescales it from the calibration
494
+ photo's *own* native resolution to whatever `workingSize` is in use —
495
+ mm/px is a property of the physical rig, not any one image's
496
+ resolution. If the golden's native resolution differs from the
497
+ calibration photo's (e.g. a PDF render at a different dpi), the node
498
+ warns once; the mm conversion itself stays exact either way.
499
+
500
+ ### Input
501
+
502
+ `msg.payload` — a photo of the printed checkerboard (Buffer or path).
503
+ `msg.save` (bool) — persist the freshly detected scale as the new
504
+ baseline. Optional per-message overrides: `msg.targetPitchMm`,
505
+ `msg.checkerboardCols`, `msg.checkerboardRows`, `msg.allowedErrorPercent`.
506
+
507
+ ### Output
508
+
509
+ - `msg.payload` — `true`/`false` pass
510
+ - `msg.result` —
511
+ `{ checkerboardDetected, currentScale, detectedScale, deviationPercent, bootstrap, pass, saved, pitchXPx, pitchYPx, nativeWidth, nativeHeight }`
512
+ (scales in mm/px)
513
+ - `msg.timings` — `{ totalMs }`
514
+
515
+ ## How `label-crop` works
516
+
517
+ `label-crop` deskews and tightly crops a physical label out of a camera
518
+ frame, so the rest of a flow sees the label straight and centred even when
519
+ the part sits at an angle or off-centre. It is the companion to
520
+ golden-compare's own alignment: label-crop removes the *placement*
521
+ variation (where the label is in the frame), and golden-compare then
522
+ measures the *print* (the artwork relative to itself). It is a separate
523
+ node so the cropped frame can be previewed, saved, or fed to other
524
+ inspection steps.
525
+
526
+ **Engine.** All pixel work (decode, resize, Otsu, rotate, crop, final
527
+ encoding) runs in the optional native OpenCV addon
528
+ — the same optional engine `nativeAlignSeed` uses. That package is now a
529
+ dependency; it ships published prebuilt binaries for Linux x64/arm64,
530
+ Alpine x64, and macOS arm64. If the binary is missing the node reports a
531
+ **setup error** on every message rather than silently passing frames
532
+ through, because a missing engine would otherwise look like "no label
533
+ found".
534
+
535
+ **Detection.** The frame is decoded once to a raw object, then downscaled
536
+ to `maxEdge` (640px long edge by default). OpenCV applies Otsu once; in
537
+ `auto` mode the small binary mask and its JS-inverted form cover both
538
+ polarities (dark-on-light and light-on-dark), and the better rectangle wins. The JS side only ever sees this small
539
+ mask: it finds connected components, takes the dominant rectangle-like
540
+ one, traces its exterior, and fits the minimum-area rectangle around the
541
+ convex boundary. Interior print holes therefore cannot skew the label angle.
542
+
543
+ Because the label is part of the bright blob, that rectangle always *contains*
544
+ the label. A boundary pass then snaps each side inward to the label's real
545
+ edge, so a label that is clipped by the frame or blends into a similarly-bright
546
+ table crops to its true boundary instead of the whole bright region. The
547
+ primary signal is the **brightness step** — the fraction of the rect that is
548
+ label-tone (the "proper white" versus the grayish table, or the inverse for a
549
+ dark label) — with the native Sobel **edge** accumulator as fallback for a
550
+ seam/shadow boundary on an equally-toned surface. A Sobel line is only trusted
551
+ when the strip between it and the region side is dimmer than the label itself,
552
+ so a printed barcode band inside the label is never mistaken for its edge.
553
+ Clipped sides (label tone reaching the frame edge) stay put. `refinedSides`
554
+ lists which sides moved.
555
+
556
+ Confidence gates turn bad evidence into a **miss**, never a wrong crop:
557
+ the blob must fall between `minAreaFraction` and `maxAreaFraction` of the
558
+ frame, fill at least `minRectangularity` of its exterior rectangle, and not
559
+ exceed `maxBorderContact`. The 0.5 border default permits a label clipped at
560
+ two opposite image edges while rejecting a component covering all four.
561
+ The candidate must also be at least `minDominance` times the second-best blob
562
+ and, optionally, match `aspectRatio` within `aspectTolerance` and cover
563
+ `expectedSizeFraction` of the frame within `sizeTolerance`. The combined
564
+ confidence must reach `minConfidence`. `auto` polarity reports which side won.
565
+
566
+ The **label size selector** in the node's edit dialog makes the size gate
567
+ visual: load any representative photo and open the **viewer** — a zoomable
568
+ modal (wheel / +/− / Fit / 100% zoom, Draw/Pan modes) that shows the image
569
+ large, so the drawn rectangle and its corner handles are clearly visible
570
+ while you fine-tune it. Apply copies the rectangle into
571
+ `aspectRatio` and `expectedSizeFraction` automatically
572
+ (resolution-independent: the fraction is relative to that image). The size
573
+ gate is applied **after** boundary refinement, so the clipped or
574
+ table-blended extents the refinement removes are not counted — a badly
575
+ detected rect (halo included, or the wrong product) becomes a clean
576
+ `size-mismatch` miss instead of a wrong crop.
577
+
578
+ **Deskew.** The label's axis-aligned bounding box (plus a small `cropMargin`
579
+ ring, so the rotate never samples past the ROI) is cropped from the full
580
+ frame and rotated using the detected angle in OpenCV's image-coordinate
581
+ convention — only the ROI is ever rotated, never the whole frame. In the rotated canvas the label rect is
582
+ axis-aligned, so the final crop is the centred `w × h` rectangle: exactly
583
+ tight to the label. No perspective correction; sub-`minRotateAngleDeg`
584
+ angles skip the rotate entirely. The final crop is encoded natively in
585
+ the chosen `outputFormat` (raw object by default, or jpg/png/webp).
586
+
587
+ ### Input
588
+
589
+ `msg.payload` — an encoded image Buffer (JPEG/PNG/…) or a raw
590
+ `{ data, width, height, channels }` object (the same shapes
591
+ `golden-compare` accepts). Per-message overrides: `msg.maxEdge`,
592
+ `msg.polarity`, `msg.minAreaFraction`, `msg.maxAreaFraction`,
593
+ `msg.minRectangularity`, `msg.maxBorderContact`, `msg.minDominance`, `msg.minConfidence`,
594
+ `msg.aspectRatio`, `msg.aspectTolerance`, `msg.expectedSizeFraction`,
595
+ `msg.sizeTolerance`, `msg.cropMargin`, `msg.minRotateAngleDeg`,
596
+ `msg.previewEnabled`, `msg.previewWidth`, `msg.outputFormat`,
597
+ `msg.outputQuality`.
598
+
599
+ ### Output
600
+
601
+ - `msg.payload` — the deskewed tight crop (raw object by default,
602
+ encoded Buffer otherwise). On a **miss** the original payload passes
603
+ through unchanged, so downstream nodes keep working while the
604
+ detection is being tuned.
605
+ - `msg.labelCrop` —
606
+ `{ detected, reason, polarity, angleDeg, center, corners, width,
607
+ height, confidence, areaFraction, rectangularity, dominance,
608
+ borderContact, refinedSides, smallSize, scale, crop, timings }` —
609
+ corners are in the **original frame's** pixel coordinates; `timings`
610
+ breaks the run into `decodeMs` / `detectCopyMs` / `maskMs` /
611
+ `analysisMs` / `edgeMs` / `refineMs` / `rotateMs` / `cropMs` /
612
+ `totalMs` plus per-op engine timings. When preview is enabled,
613
+ `previewMs` records its additional diagnostic work.
614
+
615
+ Enable **Preview** to render labelled **Before** and **After** JPEG thumbnails
616
+ beside the node on the flow canvas. `previewWidth` controls each thumbnail's
617
+ width. Previewing does not change `msg.payload`, is off by default, and adds an
618
+ extra resize/encode (plus another decode when the original input is encoded).
619
+ Click the preview to hide it.
620
+
621
+ `bench/label-crop-bench.js` renders a synthetic 6000×4000 (24MP) frame
622
+ (dark tray, rotated light label with bars) and reports raw, JPEG and PNG
623
+ p50/p95 timings.
624
+ The 100–500ms/frame target is reported there, not asserted in the test
625
+ suite — wall-clock numbers move with the machine. The engine currently decodes
626
+ encoded Buffers synchronously while constructing its native worker, so raw
627
+ camera frames are preferable when Node-RED event-loop latency matters.
628
+
629
+ ## How `line-finder` works
630
+
631
+ Find one straight edge inside a region you draw.
632
+
633
+ Use it when a whole-frame search finds the **wrong** edge - which happens
634
+ whenever the strongest contrast near the boundary you want belongs to
635
+ something else: printed artwork a few millimetres inside a label edge, a
636
+ frame vignette outboard of it, a conveyor rail. A drawn region settles it
637
+ by construction, because nothing outside the box can win.
638
+
639
+ Per region:
640
+
641
+ 1. Sample it in its own (scan, line) axes with bilinear interpolation, so
642
+ a rotated region needs no extra code path.
643
+ 2. Split the line axis into `calipers` bands and average each band across
644
+ its full width. The averaging is the trick: a 4 grey-level step under 3
645
+ levels of sensor noise is invisible in one row and obvious across two
646
+ hundred.
647
+ 3. Smooth, differentiate, and take the extremum matching the configured
648
+ polarity, with parabolic sub-pixel refinement - a half-pixel bias over
649
+ a 3000px frame is a millimetre of error.
650
+ 4. Fit by total least squares (ordinary least squares cannot represent a
651
+ vertical line, and two edges of an upright label are vertical), peeling
652
+ the single worst outlier per pass.
653
+
654
+ `msg.payload` passes through untouched; the result lands on
655
+ `msg.lineFinder` as `{ found, reason, line, angleDeg, score, calipers,
656
+ residualPx, points }`. A miss is a normal outcome - only an unusable
657
+ payload is an error.
658
+
659
+ No OpenCV engine is needed, and only the region's own pixels are read, so
660
+ the cost follows the box you drew rather than the frame size.
661
+
662
+ `line-finder` needs no OpenCV engine, and that is a measured choice rather
663
+ than a shortcut: the bridge has no reduce and no derivative, and its `resize`
664
+ samples instead of area-averaging, so it cannot build the banded mean profile
665
+ a caliper is made of. The search is also 2.7x faster than it was, by resolving
666
+ the interpolation weights once per region instead of once per sample for any
667
+ unrotated region. ARCHITECTURE.md, "Why the caliper search is not OpenCV", has
668
+ the numbers.
669
+
670
+ ### Aiming the region
671
+
672
+ The editor's region selector is a zoom viewer, not a thumbnail: at
673
+ thumbnail scale the boundary this node exists to find is not visible at
674
+ all. Load a sample and it opens on it, zoomed onto the current region,
675
+ drawing the scan direction, the edge being looked for, and one line per
676
+ caliper where that band will measure.
677
+
678
+ Wheel zooms to the cursor, `Fit`/`100%` jump, right-drag pans. Dragging
679
+ on empty space draws a new region and takes the scan direction from the
680
+ drag; corner handles resize from the opposite corner, dragging inside
681
+ moves, the arrow keys nudge by a pixel (ten with Shift), and the amber
682
+ grip rotates about the centre to a tenth of a degree. `Apply` writes the
683
+ fields, `Cancel` and Escape do not. The footer warns when the box hangs
684
+ off the frame, which matters more than it looks: a caliper band that is
685
+ not wholly inside the image is skipped, so a box half over the edge
686
+ silently loses calipers rather than reading a partial average.
687
+
688
+ The editor carries its own copy of the region geometry, since it has to
689
+ draw exactly what the runtime will scan. `test/editorRegionGeometry.test.js`
690
+ lifts that copy out of the .html and runs it against `lib/lineFinder.js`
691
+ so the two cannot drift, and `test/lineFinderEditor.test.js` drives the
692
+ viewer itself over a small fake DOM.
693
+
694
+ ### Seeing what it did
695
+
696
+ `previewEnabled` draws the result on the flow canvas: the search region as
697
+ configured, every caliper hit (green kept, red dropped by the outlier
698
+ trim), and the fitted line, with angle, caliper count, score and residual.
699
+ Misses preview too - a score of 0.4 does not tell you whether the box is
700
+ aimed at the wrong edge, clipped by the frame, or straddling two steps,
701
+ and the picture tells you all three. It re-encodes the frame per message,
702
+ so it is for tuning, not production.
703
+
704
+ ### Four of them make a rectangle
705
+
706
+ `label-crop`'s `boundaryMode: "calipers"` takes four `edgeRegions` and
707
+ intersects the fitted lines into the label's corners. On the Inspection
708
+ sample set the whole-frame blob search cropped 76 of 148 good frames with
709
+ the output aspect swinging 14%; calipers cropped 148 of 148 with the
710
+ recovered height stable to 3.5px and the angle to 0.04 degrees.
711
+
712
+ Tuning notes:
713
+
714
+ - Start with *Contrast* at 2 and lower it until the edge is found. If it
715
+ finds the **wrong** edge instead, tighten the region rather than raising
716
+ the threshold.
717
+ - *Select* = `first` is deterministic when two steps of similar strength
718
+ sit close together; `best` can alternate between them frame to frame.
719
+ - *Edges to skip* steps past a known structure - a frame vignette, say -
720
+ without narrowing the box.
721
+ - A soft, multi-step boundary (a gradual vignette rather than a clean
722
+ edge) is the hard case: the position is repeatable to a pixel or two,
723
+ but *which* step of the transition wins may not be. Widen the region
724
+ and use `first`.
725
+
726
+ ### An example flow
727
+
728
+ `examples/label-crop-with-line-finder.json` shows the whole workflow —
729
+ import it from the Node-RED menu under **Import → Examples →
730
+ @graciousstar/node-red-contrib-vision-tools**. It has two branches: a single
731
+ `line-finder` with the preview switched on, for tuning one edge at a time,
732
+ and a `label-crop` in calipers mode carrying all four tuned regions,
733
+ writing the deskewed crop to `/data/label-crop.jpg`. Each section's comment
734
+ node holds the tuning rules and what the numbers mean.
735
+
736
+ The regions it ships were measured over the 148 good Inspection frames:
737
+ found on all 148, recovered label width stable to 1.8px, height to 5.2px,
738
+ deskew angle to 0.14 degrees — and 6 of the 14 bad frames refused outright,
739
+ those being the blanks, which have no label boundary to find.
740
+
741
+ Two things in it are worth copying to another rig:
742
+
743
+ - `edgeSelect: "last"` on the left and right edges. Both are a soft
744
+ multi-step transition, so `best` flips between two similar steps from
745
+ frame to frame; taking a *positional* step instead pulled the width
746
+ spread from 38px down to 1.8px.
747
+ - `minCaliperFraction: 0.2` on the bottom edge. Only about a third of its
748
+ calipers ever see it — 5 of 16 at worst — so at the 0.5 default
749
+ `label-crop` reports it missing on 76 of the 148 frames. Lowering the
750
+ fraction is the fix there, not raising contrast.
751
+
752
+ ## How `barcode-locate` works
753
+
754
+ Finds and decodes barcodes (1D and 2D) via
755
+ [`zxing-wasm`](https://github.com/Sec-ant/zxing-wasm), a WebAssembly build
756
+ of the `zxing-cpp` engine — genuine multi-symbol detection, rotation
757
+ tolerance and native DataMatrix support.
758
+
759
+ ### Why regions
760
+
761
+ Scanning a whole multi-megapixel photo costs on the order of a second,
762
+ almost all of it the detector's own search over the full frame. On a fixed
763
+ rig — the same assumption the rest of this package makes — barcodes land
764
+ in roughly the same place shot to shot, so telling the node where to look
765
+ turns that into a handful of single-digit-millisecond crops: measured
766
+ ~2–15ms per region against ~1.4s for the same image scanned whole, on a
767
+ 4096×5500 photo.
768
+
769
+ Mode **"Regions, then full image if nothing found"** (the default) keeps
770
+ the whole-image scan as a safety net — a repositioned label, a mis-measured
771
+ region — without paying for it on every normal run. `"Regions only"` and
772
+ `"Full image only"` are also available.
773
+
774
+ ### Input
775
+
776
+ `msg.payload` — a Buffer/Uint8Array/ArrayBuffer, a file path string, or an
777
+ object with `data`/`buffer`/`path`. Optional per-message overrides:
778
+ `msg.regions`, `msg.mode`.
779
+
780
+ ### Output
781
+
782
+ One message per barcode found, in the order regions were scanned (then the
783
+ full-image fallback, if it ran): `msg.text`, `msg.format`, `msg.roi`,
784
+ `msg.regionLabel`, `msg.source` (`"region"` or `"fullImage"`),
785
+ `msg.decodeMs`, `msg.timings`, and `msg.payload` set to a preview crop of
786
+ that barcode's region. If nothing is found at all, one message with
787
+ `msg.text = null`.
788
+
789
+ ### Notes
790
+
791
+ - **EAN-8 is off by default.** It is short enough that ZXing
792
+ implementations have a real chance of matching noise in a barcode-free
793
+ crop as a confident *wrong* result, which is worse than "not found".
794
+ Enable it only where an EAN-8 code is actually expected.
795
+ - **`tryHarder` and `tryRotate`** are both on by default and both matter
796
+ for recall — see the in-editor help.
797
+ - The first decode after a redeploy pays zxing-wasm's one-time WASM warmup.
798
+ `lib/locate.js` absorbs that so it never lands on a user-visible message.
799
+
800
+ ## Notes
801
+
802
+ - `goldenPath`/`scaleFilePath` are paths **inside the container** — use
803
+ `/data` for anything that should survive a rebuild.
804
+ - The working canvas the frame is decoded onto is sized by the calibrated
805
+ mm/px if there is one, and otherwise by the same-rig assumption (one
806
+ native pixel spans the same distance in both images). That sizing no
807
+ longer has to be *right*, because the magnification search absorbs the
808
+ error — which is what lets an artwork golden work at all. The two are
809
+ not substitutes, though: the search recovers whatever scale it needs to
810
+ compare the images, while calibration is what tells you a pixel's worth
811
+ in millimetres. Only the latter makes the position numbers physical, so
812
+ calibrate if the position tolerance is specified in mm.
813
+ - What survives on a good part, once the geometry is right, is genuine
814
+ artwork-versus-print difference rather than misalignment: dot gain and
815
+ focus shift stroke weight slightly. Most of it is threshold-straddling
816
+ rather than real, which is what `inkMargin` is for (above); widen
817
+ `printTolerance`/`backgroundTolerance` for whatever is left, rather than
818
+ loosening the alignment.
819
+ - **The global transform places the label; it cannot place all of it.**
820
+ After a correctly recovered 5-DOF fit on this project's good pair, the
821
+ leftover displacement still has a median of 0.73px, a 90th percentile
822
+ of 1.55px, and individual regions sitting 4-5px out that match their
823
+ golden counterpart near-perfectly once shifted. Raising the global
824
+ model does not reach that: fitting a homography (8 DOF) to the measured
825
+ field removed 18% of it and a full quadratic (12 DOF) only 27%, because
826
+ no global warp can pull one corner 5px while leaving the two thirds of
827
+ the label that is already sub-pixel alone. A label on a formed tray is
828
+ not a plane.
829
+
830
+ So **Refine alignment per tile** (`localAlign`, on by default) lets each
831
+ tile take up its own offset, capped at `localAlignMax` (3px) so a tile
832
+ can never slide far enough to hide a fault, and skipping tiles too flat
833
+ to localise.
834
+
835
+ Its value does not show up as a lower defect ratio, which is what makes
836
+ it easy to dismiss: at loose tolerances the dilation was already
837
+ forgiving the fringing this removes. It shows up as **headroom**. On the
838
+ demo pair, `printTolerance`/`backgroundTolerance` of 2/1 fails the clean
839
+ part without refinement (a false region at density 0.250) and passes it
840
+ with (zero regions), while the marked capture still fails either way.
841
+ That is why the tolerance defaults are 2/1 rather than the 5/3 they had
842
+ to be before - roughly a two to threefold improvement in the size of
843
+ defect that can be gated. **If you turn `localAlign` off, widen them
844
+ again**, or the registration error it was absorbing will fail good
845
+ parts. Look at the `targetFgAligned` stage against `goldenFg` to see the
846
+ difference directly: body text goes from doubled to solid.
847
+ - **Train the transform once instead of re-deriving it every frame.** The
848
+ alignment splits by what physically varies: magnification and press
849
+ stretch come from the camera's standoff and the press's pull on the
850
+ media and do not change between parts, while translation and rotation
851
+ are where this part happens to be sitting. Tick **Train the transform**
852
+ with a **Trained transform** path set (or send `msg.trainTransform`),
853
+ and the node measures `scaleX`/`scaleY` from that frame, writes them
854
+ down, and pins them on every later frame — solving only position and
855
+ angle. To train from any two images rather than the configured golden,
856
+ send `msg.golden` alongside `msg.payload`.
857
+
858
+ It roughly halves the time (2.0–2.7x on this project's captures), but
859
+ the reason to do it is accuracy, not speed: a search free to re-solve
860
+ magnification per frame can pick wrong, and it is likeliest to do so on
861
+ a badly printed label — precisely the case the inspection exists for,
862
+ because poor print gives the search poor evidence. One such capture
863
+ went from 945 regions to 25 once its transform was pinned.
864
+
865
+ Three things to know. A trained record is tied to its golden and
866
+ working size, and both are checked on load; a mismatch is refused with
867
+ a reason rather than silently applied. "Its golden" means the image,
868
+ not the route the image took: the record stores a hash of the golden's
869
+ bytes alongside the cheap cache key, so training through `msg.golden`
870
+ and then producing frames from the configured **Golden image path**
871
+ reuses the record instead of refusing it every frame. (Before 1.1.1 it
872
+ refused, warned, and searched — the flow the paragraph above tells you
873
+ to use.) Records trained before 1.1.1 have no content hash; retrain
874
+ once. Scales outside a sane physical range (0.05–100) or an unreadable
875
+ file are refused the same way — the node searches unpinned rather than
876
+ applying nonsense (a corrupted record can no longer hang the flow). But the **stretch belongs to the
877
+ print run, not to the golden**, so a new run on the same artwork needs
878
+ retraining and no file check can see that coming — on this project's own
879
+ samples two captures from a different run want 5.9% where the rest want
880
+ 4.5%, and forcing the wrong one on them produced ~1000 false regions.
881
+ What does catch it is the alignment residual jumping clear of what
882
+ training measured, and the node warns when it does.
883
+
884
+ Pinning also costs a little defect sensitivity: the polish objective is
885
+ computed on a decimated canvas, so it cannot resolve a single full-res
886
+ pixel of translation, and a pinned run can settle a pixel from where the
887
+ searched run lands. On the demo capture the marked defect still fails
888
+ the part, but as one region at density 0.156 rather than five at 0.172.
889
+ - **The coarse search can pick a wrong scale, and used to be unable to
890
+ take it back.** Stages 1-3 rank candidates by a density proxy - ink per
891
+ grid cell - which cannot separate a correctly scaled match from one a
892
+ few percent off that happens to drop its ink in the same cells. Whatever
893
+ it picked, every later stage searched a narrow band around that pick.
894
+ The symptom is a whole frame failing at roughly 0.5% residual scale
895
+ error: about 10px of drift across the label, which on 1-2px strokes is
896
+ total disagreement, so hundreds of regions light up on a part whose real
897
+ faults are two small blemishes. `alignCandidates` (default 5) keeps that
898
+ many scale hypotheses alive through the fine stage and then picks
899
+ between them on **real pixel disagreement** rather than the proxy. On
900
+ this project's samples one capture went from 315 regions to 10 with no
901
+ change to any pair that was already aligning. Set it to 1 for the old
902
+ greedy behaviour.
903
+ - `transform.score` is the alignment residual and is worth logging: on
904
+ these samples anything at or below ~0.05 tracks a handful of regions,
905
+ while ~0.10 tracks a thousand. A frame that fails everywhere with a high
906
+ score is misaligned, not defective - check that before believing the
907
+ blemish numbers, and check that the golden is the right *product* (two
908
+ different labels will happily align to a mediocre score and then
909
+ disagree everywhere).
910
+ - **`workingSize` decides what defects are physically detectable, and it
911
+ is the setting to reach for first when something real is being missed.**
912
+ It is not merely a speed/quality dial: downscaling averages a thin mark
913
+ into the substrate around it. A ~4px pen line on a 4096×5500 capture
914
+ measures grey 20 (essentially black) at `workingSize` 3072, but grey
915
+ **120 against a threshold of 143** at 1024 — 22 levels of contrast,
916
+ three quarters of the way to invisible. No downstream setting recovers
917
+ that; the evidence is gone before the threshold runs. Symptom to
918
+ recognise: the defect shows up in the raw `backgroundDefect` debug stage
919
+ as a scatter of specks rather than a stroke.
920
+ - Once the mark survives decoding it still has to form a *region*, and a
921
+ ~1.6px-wide stroke covers only ~14% of a 16×16 block — just under the
922
+ 0.15 `blockThreshold`. Halving `blockSize` to 8 is what lets a hairline
923
+ raise a region at all.
924
+ - Worked recipe for catching a hairline against PDF artwork, validated on
925
+ this project's samples: `workingSize` 3072, `inkMargin` 64, `blockSize`
926
+ 8, `failThreshold` 0.1. That gives a clean good part **0 background
927
+ regions** and a good part with one added pen line **5 regions at density
928
+ 0.172**, while all fifteen known-defective captures stay saturated
929
+ (density 1.000, 600+ regions, defect ratios above 0.05) — so the wide
930
+ margin costs nothing on real faults. It costs time: roughly 2–3.4s per
931
+ frame against 1.6s at `workingSize` 1024.
932
+ - `golden-compare`'s golden reference is only re-decoded when its source
933
+ (for a file: the path **plus the file's mtime and size**, so an
934
+ in-place overwrite of the artwork re-decodes; for a buffer: its hash,
935
+ or `msg.goldenKey`), `workingSize`/`threshold`/`thresholdMode`/
936
+ `sauvolaRadius`/`sauvolaK`/`inkMargin`/`backgroundTolerance`/
937
+ `debugStages`, the calibrated scale (including the calibration photo's
938
+ native resolution), or raw geometry changes —
939
+ `printTolerance`/`alignSearch`/etc. are applied fresh per frame and
940
+ don't need a re-decode.
941
+ - Turn off `outputPrintHeatmap`/`outputBackgroundHeatmap` for maximum
942
+ throughput if only the pass/fail decision and region lists are needed.
943
+ - `sharp` ships prebuilt binaries for Alpine/musl, so no libvips
944
+ source-compile is needed in this project's Docker build stage.
945
+
946
+ ## Tests
947
+
948
+ `npm test` (Node 18+, no test framework needed — `node --test`), 290
949
+ tests. Fixtures are generated with `sharp` rather than read from
950
+ `data/sample_images`, so the suite runs anywhere; the real QC photos are
951
+ gitignored. Coverage spans the lib pipeline (`compare`, `align`, `warp`,
952
+ `localAlign`, `checkerboard`, `threshold`), the worker pool (byte-identity
953
+ against serial, mid-flight termination, oversized pools, and two
954
+ overlapping dispatches), and the Node-RED glue itself —
955
+ `golden-compare.js` and `checkerboard-calibrate.js` are exercised through
956
+ a fake-RED harness (`test/glue.test.js`,
957
+ `test/checkerboardCalibrate.test.js`) so image resolution, cache
958
+ invalidation, `msg.rawInfo` handling and calibration files are tested
959
+ without a running Node-RED.
960
+
961
+ Three of the suites assert something other than a value:
962
+
963
+ - `test/fingerprint.test.js` counts digests and file reads, because the
964
+ property is that the work does not happen at all — "it is fast now" is
965
+ not a thing a test can hold onto.
966
+ - `test/parallel.test.js` asserts the pooled stages are byte-identical to
967
+ their serial twins. A divergence would look like a flaky camera rather
968
+ than a bug.
969
+ - `test/editorDefaults.test.js` parses the `defaults` block out of each
970
+ `.html` file and compares every entry against its runtime fallback.
971
+ Node-RED does not backfill a new default into existing node instances,
972
+ so a disagreement only shows up in flows nobody is editing.
973
+ - `test/editorRegionGeometry.test.js` lifts the region geometry out of
974
+ `line-finder.html` and runs it against `lib/lineFinder.js`. The editor
975
+ needs its own copy to draw what the runtime will scan, and two copies
976
+ of a rotation convention drift silently — the box drawn stops being the
977
+ box searched.
978
+ - `test/lineFinderSampling.test.js` compares the two profile builders
979
+ with `strictEqual` rather than a tolerance. The fast one exists only
980
+ for speed, so the only acceptable difference is none.
981
+
982
+ `bench/frame-bench.js` is a stopwatch rather than a test and is not picked
983
+ up by `node --test`. Pass `--pin mx,my` when comparing two versions:
984
+ without it each version pins to the magnification it recovered itself, and
985
+ the two are then not solving the same problem.
986
+
987
+ ## Licence
988
+
989
+ Apache-2.0 — see [LICENSE](LICENSE).
990
+
991
+ `sharp`, `zxing-wasm` and the optional native OpenCV engine are separate
992
+ packages under their own licences.