@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,875 @@
1
+ # Architecture
2
+
3
+ What this node is doing, in what order, and why each piece is shaped the
4
+ way it is. `README.md` is the operator's guide — what the settings mean
5
+ and how to use them. This is the map for changing the code.
6
+
7
+ ## The problem
8
+
9
+ Compare **the PDF artwork for a label** against **a photograph of that
10
+ label printed**, and report where the print is wrong.
11
+
12
+ Almost every difficulty follows from those two inputs sharing nothing:
13
+
14
+ | | golden | frame |
15
+ | --- | --- | --- |
16
+ | origin | vector artwork, rendered | camera capture |
17
+ | tone | synthetic pure black on pure white | continuous grey, lit unevenly |
18
+ | scale | render DPI, arbitrary | camera px/mm, unrelated |
19
+ | framing | the label, cropped | the label plus tray, table, margin |
20
+ | geometry | flat by construction | stretched by the press, bowed on a formed tray |
21
+
22
+ So there is no shared coordinate system, no shared grey level, and not
23
+ even a shared shape. Everything below exists to bridge one of those gaps.
24
+
25
+ ## Pipeline
26
+
27
+ ```mermaid
28
+ flowchart TD
29
+ subgraph prep["prepareGolden — cached, once per golden+settings"]
30
+ G1[decode to grey at workingSize] --> G2[threshold to ink mask]
31
+ G2 --> G3[dilate by backgroundTolerance]
32
+ G2 --> G4[3 density lattices<br/>coarse / medium / fine]
33
+ G2 --> G5[decimated mask for<br/>the polish objective]
34
+ end
35
+
36
+ subgraph frame["compareFrame — per frame"]
37
+ F1[decode to grey<br/>preserving aspect ratio] --> F2[threshold to ink mask]
38
+ F2 --> F3{transform pinned?}
39
+ F3 -->|no| F4[search scale, stretch,<br/>angle, translation]
40
+ F3 -->|yes| F5[search translation<br/>and angle only]
41
+ F4 --> F6[polish on pixel disagreement]
42
+ F5 --> F6
43
+ F6 --> F7[warp frame into golden's grid]
44
+ F7 --> F8[per-tile local refinement]
45
+ F8 --> F9[threshold in golden's grid]
46
+ F9 --> F10[print check:<br/>golden ink the frame lacks]
47
+ F9 --> F11[background check:<br/>frame ink the golden lacks]
48
+ F10 --> F12[block density → regions → verdict]
49
+ F11 --> F12
50
+ end
51
+
52
+ G4 -.-> F4
53
+ G4 -.-> F5
54
+ G5 -.-> F6
55
+ G3 -.-> F11
56
+ ```
57
+
58
+ Golden preparation is cached on the node and keyed by everything baked
59
+ into it: the source fingerprint, `workingSize`, `threshold`,
60
+ `thresholdMode`, `sauvolaRadius`, `sauvolaK`, `inkMargin`,
61
+ `backgroundTolerance`, `debugStages` (the golden's stage PNGs are
62
+ rendered only when it is on), the calibrated scale — including the
63
+ calibration photo's native size, which the mm conversion is expressed
64
+ against — and the raw geometry when the golden arrived as raw pixels.
65
+ Settings applied fresh per frame (`printTolerance`, `alignSearch`,
66
+ `blockSize`, the local-alignment settings…) are deliberately *not* in the
67
+ key. The `cacheKey` construction in `golden-compare.js` is the
68
+ authoritative list.
69
+
70
+ ## Modules
71
+
72
+ | file | role |
73
+ | --- | --- |
74
+ | `golden-compare.js` | Node-RED wiring: config, bounds/clamping, `msg` overrides, golden cache, transform training, logging |
75
+ | `lib/compare.js` | the pipeline above — `prepareGolden` and `compareFrame` |
76
+ | `lib/align.js` | global transform search: coarse-to-fine over scale, stretch, angle, translation |
77
+ | `lib/localAlign.js` | per-tile displacement field and its application |
78
+ | `lib/warp.js` | resample the frame into golden's grid, area-average when minifying |
79
+ | `lib/threshold.js` | fixed / Otsu / Sauvola ink levels, plus the ambiguity mask |
80
+ | `lib/dilate.js` | separable morphological dilation (van Herk/Gil-Werman) |
81
+ | `lib/integral.js` | summed-area tables — O(1) box sums; binary masks stay `Uint32`, grey tables use a `Float64` accumulator (a `Uint32` grey table wraps past ~16.8M bright pixels and silently corrupts the warp) |
82
+ | `lib/components.js` | connected components for region extraction |
83
+ | `lib/transformFile.js` | trained-transform persistence and its validity guards |
84
+ | `lib/scaleFile.js` | mm/px calibration file, shared with `checkerboard-calibrate` |
85
+ | `lib/checkerboard.js` | checkerboard detection for mm/px calibration |
86
+ | `label-crop.js` | Node-RED wiring for the label-crop node: config/clamping, engine availability gate, `msg.labelCrop` attachment, optional before/after preview publish |
87
+ | `lib/labelCrop.js` | deskew-and-crop op: decode-once, low-res Otsu mask analysis (connected component + exterior minimum-area rectangle) + brightness/Sobel boundary refinement, ROI-only rotation, native final crop; composes the native OpenCV engine |
88
+
89
+ ## The geometry model
90
+
91
+ Five degrees of freedom: `mx`, `my`, `theta`, `ox`, `oy`.
92
+
93
+ `mx` and `my` are **independent** on purpose. A press stretches print
94
+ along its media-feed axis relative to the artwork — 4.5–6% on this
95
+ project's samples — and a single isotropic scale can only split that
96
+ error, leaving every feature several pixels out toward the ends of the
97
+ long axis. On body text several pixels is the whole stroke, so ~12% of
98
+ pixels disagree and a good part fails everything.
99
+
100
+ There is no shear or perspective term, and adding one is not worth it.
101
+ Fitting the measured residual with a homography (8 DOF) removed 18% of
102
+ it; a full quadratic (12 DOF) removed 27%. What is left after the global
103
+ fit is **not a smooth field** — see local refinement below.
104
+
105
+ ### Search
106
+
107
+ Four stages over three density lattices, coarse to fine, then a polish
108
+ against real pixel disagreement.
109
+
110
+ The coarse stages rank candidates by a **density proxy** (ink per grid
111
+ cell) because it is cheap. It is also weak: it cannot separate a
112
+ correctly scaled match from one a few percent off that happens to drop
113
+ its ink in the same cells. That is why stage 2 keeps its best placement
114
+ *per scale pair* and the top `alignCandidates` of them are carried
115
+ through the fine stage and then judged on the pixel objective. Collapsing
116
+ to a single winner at stage 2 was unrecoverable — a frame could end up
117
+ 0.5% off in scale, ~10px of drift across the label, ~1000 false regions.
118
+
119
+ The polish objective runs on a **fixed 320px canvas** rather than a fixed
120
+ fraction of the golden — what it needs is enough pixels to rank
121
+ sub-percent nudges, which depends on the label, not on `workingSize`.
122
+ That coarseness is also why a pinned run can settle a pixel from where a
123
+ searched one lands.
124
+
125
+ ### Pinning
126
+
127
+ `mx`/`my` come from the camera's standoff and the press's pull. Neither
128
+ changes between parts; only where the part sits does. So they can be
129
+ measured once (**train the transform**) and reused, which halves the time
130
+ and, more importantly, removes a chance to be wrong — a search free to
131
+ re-solve magnification is likeliest to pick badly on a *badly printed*
132
+ label, which is exactly the case the inspection exists for.
133
+
134
+ The trained record is tied to its golden and working size, both checked
135
+ on load. It cannot be tied to the print run, and the stretch belongs to
136
+ the run: a new run on the same artwork needs retraining and the file
137
+ still looks valid. The alignment residual catches it, so the node warns
138
+ when it lands well above what training measured.
139
+
140
+ ## Thresholding and ambiguity
141
+
142
+ Ink is decided per image (`otsu` by default) because artwork and
143
+ photograph have no common grey level.
144
+
145
+ Whatever the mode, a hard cut mis-assigns anything sitting near it, and
146
+ both sides of this comparison have such features:
147
+
148
+ - a screened tint renders *lighter* than the level in the PDF and prints
149
+ *darker* than the level in the photo, so the same design element is
150
+ background in one and ink in the other;
151
+ - Otsu's level is not even stable across resolution — on this artwork it
152
+ walks from 160 at `workingSize` 1024 to 145 at 3072, which flips a flat
153
+ grey panel at 155 from ink to background and lights up a whole region.
154
+
155
+ So `inkMargin` marks pixels within N levels of their own level as
156
+ **ambiguous, on either side**, and both checks drop a pixel when *either*
157
+ image is ambiguous there. A defect claim is a claim about both images, so
158
+ ambiguity in either voids it. Ambiguous pixels remain full evidence for
159
+ *alignment* — the margin only withholds them from the defect decision.
160
+
161
+ ## Local refinement
162
+
163
+ After a correct global fit the good pair still has a median residual of
164
+ 0.73px, a p90 of 1.55px, and regions 4–5px out that match their golden
165
+ counterpart near-perfectly once shifted. A label on a formed tray is not
166
+ a plane; regions lift and bow independently, and no global
167
+ parametrisation describes that.
168
+
169
+ Each tile therefore takes its own offset, with four guards:
170
+
171
+ - offsets **capped**, so a tile can never slide far enough to hide a fault;
172
+ - tiles too flat to localise are **not trusted**, they are filled from neighbours;
173
+ - a match on the **edge of the search box** is refused, not clamped;
174
+ - the field is **median-filtered** — a spurious match is a lone disagreeing
175
+ tile, real substrate movement is coherent across several.
176
+
177
+ The image is resampled at **whole pixels**. Bilinear resampling was tried
178
+ and is actively harmful: interpolating at a fractional offset is a
179
+ low-pass filter, and it blurred a pen mark below the ink level, dropping
180
+ the defect ratio 82% and turning a failing part into a passing one. The
181
+ *field* is interpolated smoothly between tile centres; only the sample is
182
+ rounded.
183
+
184
+ Its value does not appear as a lower defect ratio — the dilation was
185
+ already forgiving the fringing it removes. It appears as headroom: with
186
+ refinement on, `printTolerance`/`backgroundTolerance` can run at 2/1
187
+ instead of 5/3, so a defect two to three times smaller can be gated.
188
+ **The two settings are coupled**: turning refinement off without widening
189
+ the tolerances again will fail good parts.
190
+
191
+ ## The two blemish checks
192
+
193
+ Deliberately separate, and provably disjoint:
194
+
195
+ - **print** — golden has ink the frame lacks, after dilating the frame's
196
+ ink by `printTolerance`. Missing print.
197
+ - **background** — the frame has ink the golden lacks, after dilating the
198
+ golden's ink by `backgroundTolerance`. Unwanted print, marks, smears.
199
+
200
+ They are different faults with different causes on the line, so
201
+ collapsing them would throw away the more actionable half. Position and
202
+ angle are gated separately for the same reason.
203
+
204
+ Each defect mask is summed into `blockSize` blocks; blocks at or above
205
+ `blockThreshold` are grouped into regions by connected components. The
206
+ part fails if the worst block density reaches `failThreshold` **or** the
207
+ overall defect ratio reaches `failRatio`.
208
+
209
+ This block stage sets a **floor on detectable defect size**, and it is
210
+ easy to mistake for a diff problem: a stroke one working-pixel wide
211
+ cannot fill 15% of a 16×16 block wherever it lands, so a hairline is
212
+ detected and then discarded as speck noise.
213
+
214
+ ## Input
215
+
216
+ Either side can arrive as a file path, an encoded buffer (PNG/JPEG/…), or
217
+ **raw pixels** — a descriptor `{ data, width, height, channels }`, or a
218
+ bare buffer plus `msg.rawInfo` / `msg.goldenRawInfo`.
219
+
220
+ Raw matters because there is nothing in such a buffer for `sharp` to infer
221
+ a geometry from, and PNG decode is the largest serial cost left in a frame
222
+ (~300ms on a 4096×5500 capture, and `sharp` cannot thread the inflate).
223
+ A camera SDK handing over a framebuffer skips it entirely.
224
+
225
+ It is also how the golden is meant to arrive. `pdf-to-image` in RAW mode
226
+ puts a self-describing descriptor on `msg.payload`, so wiring the artwork
227
+ in is one assignment:
228
+
229
+ ```text
230
+ pdf-to-image (format: RAW) → msg.golden = msg.payload → golden-compare
231
+ ```
232
+
233
+ Two traps, both guarded — plus a third:
234
+
235
+ - `msg.images[]` from that node is **per-page metadata only** and carries
236
+ no pixels. It is also read for the *frame* — but only when no golden
237
+ travels on the same message, because when the golden is the PDF render
238
+ `msg.images` describes that, and decoding a 23MP capture at the
239
+ artwork's dimensions would give a confident wrong answer rather than an
240
+ error. The golden side carries the same guard mirrored: `msg.images[]`
241
+ geometry is consulted only when the golden source is a bare buffer with
242
+ no geometry of its own, so a stale `RAW` message can never stamp the
243
+ frame's dimensions onto a path- or file-supplied golden.
244
+ - The golden is **fingerprinted before it is loaded**, not after. Only the
245
+ fingerprint runs on every message; the bytes are read only when the
246
+ cache misses. That ordering is the whole point — until 1.0.2 the node
247
+ resolved both images to bytes first and only then asked whether anything
248
+ had changed, so a `goldenPath` golden was read from disk in full on
249
+ every frame and discarded, and `msg.goldenKey` saved nothing at all
250
+ because the hash ran before the name was consulted.
251
+ - A *path*-string golden is keyed by path **plus the file's mtime and
252
+ size**, so an in-place overwrite re-decodes rather than serving the
253
+ stale prepared golden — and, because the same key names the trained
254
+ transform, the stale transform refuses itself with a retrain warning.
255
+ The key's *form* follows the delivery, though, so it cannot say
256
+ whether a `buf:` record and a `path:` frame are the same image; a
257
+ trained record therefore also carries `goldenContentKey`, a hash of
258
+ the golden's bytes, consulted only when the cheap keys disagree.
259
+ Reading the golden to hash it is exactly what the cheap key exists to
260
+ avoid, so it happens on a mismatch and is memoised per file version —
261
+ never on the hot path.
262
+ A cache hit costs one `open`+`fstat` and no read; the handle stays
263
+ open so a miss reads through the same handle it stat'ed, which is the
264
+ swap-between-stat-and-read race the single-handle read exists to
265
+ avoid.
266
+ - A buffer golden still has to be hashed: there is nothing else in it
267
+ that says whether it changed. `msg.goldenKey` names it instead and
268
+ skips the hash — **and with it, invalidation becomes the flow's
269
+ responsibility.** The buffer's length still goes into the cache key,
270
+ which catches the coarsest way a stale name goes wrong, but a
271
+ different render of the same size under an unchanged name will be
272
+ served from cache. That is the bargain the option offers.
273
+ - The *frame* is never fingerprinted. Nothing is cached against it, and
274
+ it used to be SHA-1'd every message for a key that was discarded —
275
+ 27ms of a 23MP framebuffer.
276
+ - A raw descriptor is validated against the buffer before `sharp` sees
277
+ it: `width × height × channels` must fit the actual bytes (and
278
+ `width × height` is capped at 64M pixels). sharp's raw path used to
279
+ read past the end of an undersized buffer rather than reporting the
280
+ numbers were wrong; libvips now catches that, but the node fails
281
+ cleanly with the real numbers instead of relying on it.
282
+
283
+ ## Resolution
284
+
285
+ `workingSize` decides what is *physically detectable*, not just how
286
+ sharp the output looks. Downscaling averages a thin mark into the
287
+ substrate around it: a ~4px pen line on a 4096×5500 capture measures grey
288
+ 20 at `workingSize` 3072 but grey **120 against a threshold of 143** at
289
+ 1024 — three quarters of the way to invisible. No downstream setting
290
+ recovers that.
291
+
292
+ The frame is decoded **preserving its own aspect ratio**, never stretched
293
+ to golden's dimensions, and brought to golden's *physical* scale rather
294
+ than its pixel dimensions. Its canvas is capped at 2.5× `workingSize` on
295
+ the long edge.
296
+
297
+ **The golden is never upscaled**, so `workingSize` is an upper bound, not
298
+ a setting: a golden smaller than it caps the whole inspection at the
299
+ golden's resolution and discards detail the camera did capture. This
300
+ project's own artwork is 1844×2656, so `workingSize` 3072 has in fact been
301
+ running at 2656 — which is why 3072 and above measured identically. The
302
+ node warns when a golden comes in short. It matters most when the golden
303
+ is a PDF render, where the pixel count is a dpi setting rather than a
304
+ property of the file: at `workingSize` 3072 a 100mm label wants roughly
305
+ 780 dpi.
306
+
307
+ ## Where the time goes
308
+
309
+ Measured on the demo pair at `workingSize` 3072, transform pinned
310
+ (milliseconds):
311
+
312
+ | stage | serial | 8 workers | notes |
313
+ | --- | ---: | ---: | --- |
314
+ | decode | 141 | 134 | sharp, already all cores; PNG inflate is serial |
315
+ | grey summed-area table | 41 | 41 | split across the pool since 1.1.3 |
316
+ | transform search | 266 | 250 | polish is sequential by nature |
317
+ | warp into golden's grid | 88 | **24** | rows |
318
+ | local refinement | 116 | **32** | tile rows, then image rows |
319
+ | threshold | 24 | **8** | rows |
320
+ | diff | 95 | **24** | dilation columns/rows, then rows |
321
+ | block density, regions, verdict | 19 | 20 | 245 when heat maps are output |
322
+ | **verdict path total** | **858** | **605** | |
323
+ | debug stages | — | — | **+690** when enabled |
324
+ | heat maps | — | — | **+223** when enabled |
325
+
326
+ The working size is not negotiable: 3072 is the only setting at which the
327
+ demo scratch survives decoding at all — 2560 and below miss it entirely,
328
+ and 2048 fails the clean part.
329
+
330
+ That table is the demo pair. On a real 4096×5500 capture against a
331
+ 1844×2656 golden the proportions differ enough to be worth stating: with
332
+ the transform pinned and 12 workers, align is ~550ms of a ~1050ms frame,
333
+ and inside align the four sweeps are only 87ms. The costs are the polish
334
+ (~215ms), the two summed-area tables (~140ms before they were split, ~60ms
335
+ after) and the warp/threshold/local-refinement group (~210ms).
336
+
337
+ ### What the polish actually costs
338
+
339
+ The polish is a hill-climb, so its rounds are sequential by construction:
340
+ each round scores one neighbourhood on the pool, picks the best, and
341
+ re-centres. Profiled on that frame it runs **15 rounds of ~9.4 candidates**,
342
+ and round cost fits **~8.8ms fixed + ~1.55ms per candidate** — so at the
343
+ default neighbourhood size most of a round is dispatch, not arithmetic.
344
+
345
+ Two things follow, both measured rather than reasoned:
346
+
347
+ - **Widening the neighbourhood does not help.** Reaching two steps per
348
+ axis instead of one takes the round count from 15 to 12 while tripling
349
+ the candidates (352ms → 567ms); three steps reaches 11 rounds for five
350
+ times the candidates (874ms). Every variant lands on the same transform.
351
+ The round count is bounded by the step schedule, not by how far a round
352
+ can see.
353
+ - **Shortening the step schedule is not safe.** `POLISH_STEPS` looks
354
+ redundant — `translationStep` collapses to `[2, 1, 1, 1, 1]`px — but the
355
+ levels differ in their angle nudge (and, unpinned, their scale nudge).
356
+ Truncating to `[0.02, 0.01]` recovers 0.5° on a part rotated 0.4° and
357
+ turns a clean part into 26 print and 106 background false regions.
358
+ Dropping only the intermediate levels is identical to the default on the
359
+ pinned path across seven fixtures, but diverges unpinned — different
360
+ recovered scale, and a different background region count on the reject.
361
+
362
+ So the lever on the polish is not a cheaper round or a shorter ladder: it
363
+ is starting closer to the answer, which is what `nativeAlignSeed` does.
364
+
365
+ ### Threading
366
+
367
+ Everything above runs in **one inspector worker**, not on Node-RED's
368
+ event loop. That is the single most important structural fact about this
369
+ node, and it is not an optimisation: a frame is ~600ms of CPU, Node-RED
370
+ has one thread, and before 1.1.0 an unpinned frame blocked it for
371
+ **1499ms** — measured — taking the editor websocket, HTTP endpoints,
372
+ MQTT keepalives and every other flow in the instance down with it.
373
+
374
+ ```text
375
+ Node-RED event loop inspector worker pool (8, nested)
376
+ --------------------- ---------------- ----------------
377
+ config, clamping, msg --> prepareGolden store --> warp / diff /
378
+ overrides, file I/O and compareFrame localAlign /
379
+ its guards, cache key, measureCheckerboard objective
380
+ status/warn/log, done(), sharp
381
+ result plumbing
382
+ ```
383
+
384
+ Worst contiguous main-thread block per frame: **1499ms -> ~14ms**, of
385
+ which ~13ms is copying a 23MP payload into shared memory, which is
386
+ inherent to handing it across a thread boundary. It does not make a frame
387
+ faster - a 600ms frame is still 600ms - it stops being 600ms of frozen
388
+ runtime.
389
+
390
+ Four things hold it together:
391
+
392
+ - **No file I/O in the inspector.** Structured clone drops an error's own
393
+ properties *and* its class, so a cloned `ENOENT` arrives with
394
+ `err.code === undefined` and `golden-compare.js` branches on exactly
395
+ that. Every path that must tell "missing" from "refused" stays on the
396
+ calling thread.
397
+ - **Buffers are re-wrapped on receipt.** Structured clone turns a Buffer
398
+ into a `Uint8Array`, and `msg.printHeatmap` is documented as a PNG
399
+ Buffer and wired into an image viewer in the demo flow. The failure is
400
+ silent, not loud: `Buffer.toString("base64")` gives `iVBORw0KGgo...`
401
+ where `Uint8Array` gives `137,80,78,71,...`. The re-wrap shares memory
402
+ rather than copying, and it has to tolerate nulls — heatmaps and
403
+ `stages` are null by default.
404
+ - **The golden store is bounded** (four entries, LRU). It outlives a
405
+ redeploy deliberately, and its key includes settings a *message* can
406
+ override, so an unbounded store would grow by ~84MB per distinct
407
+ `msg.threshold` at `workingSize` 4096.
408
+ - **Bytes are sent only when asked for.** `prepare` carries the cache key
409
+ alone; the inspector answers `needGolden` if it lacks it. A redeploy
410
+ therefore costs one round trip rather than re-reading the artwork to
411
+ ship bytes the inspector already holds.
412
+
413
+ An entry can be evicted between preparing it and using it, so `inspect`
414
+ can also answer `needGolden`. The retry that handles this **must
415
+ invalidate the node's own cache first**: the handler decides whether to
416
+ prepare on `node.goldenCache.key !== cacheKey`, so a retry that leaves it
417
+ in place re-enters a cache *hit*, never sends a prepare, and asks the
418
+ same empty inspector forever. It is capped at one retry as a backstop.
419
+
420
+ `checkerboard-calibrate` goes through the same inspector: it runs at full
421
+ sensor resolution with no downscale, ~59ms of synchronous work on a
422
+ 5520x4140 capture.
423
+
424
+ ### Parallelism
425
+
426
+ The per-pixel stages run on a persistent worker pool (`lib/pool.js`),
427
+ which takes them from 323ms to 88ms. Four properties make it safe:
428
+
429
+ Both per-frame summed-area tables are built on it too. The serial form
430
+ fuses each row's prefix sum with the column accumulation, which is the
431
+ right shape for one thread — it reads the row above as it writes, so no
432
+ two rows can run at once. Split into two passes each dimension is
433
+ independent, and the arithmetic is untouched: Uint32 addition is exact
434
+ modulo 2^32, and the Float64 table only ever holds small exact integers,
435
+ so both are byte-identical to the serial tables whatever the core count.
436
+ On a 4096x5500 frame the mask table falls out of the align bucket
437
+ (677ms → 599ms) and the grey table goes 113ms → 39ms.
438
+
439
+ - **One implementation.** The workers call the same `warpRows`,
440
+ `fieldRows`, `applyRows` and `slidingMax1D` the main thread does, rather
441
+ than a copy. A divergence would show up as a defect that appears or
442
+ disappears with core count, which would look like a flaky camera rather
443
+ than a bug — `test/parallel.test.js` asserts byte-identical output.
444
+ - **Shared memory.** The buffers are allocated on `SharedArrayBuffer`
445
+ (`lib/shared.js`), so a dispatch ships a memory handle rather than tens
446
+ of megabytes.
447
+ - **The pool persists.** Spawning eight workers costs ~60ms, which would
448
+ wipe out the ~230ms saved. It is created once per process, not per
449
+ frame, and survives a Node-RED redeploy.
450
+ - **Every stage falls back.** Small images, `workers: 1`, or a missing
451
+ `SharedArrayBuffer` all take the serial path, which stays the reference
452
+ implementation.
453
+
454
+ Two invariants hold the *concurrent* case together, and both were wrong
455
+ until 1.0.2. Node-RED never awaits a node's input handler, so two frames
456
+ overlap inside `compareFrame` as a matter of course:
457
+
458
+ - **A dispatch is settled by id, never by "the next reply".** Listeners
459
+ used to be registered per dispatch with `once`, so two dispatches queued
460
+ on one worker both fired on the first reply — and the second caller read
461
+ an output buffer that was still being written. End to end that surfaced
462
+ as two concurrent frames both reporting `transform.score = 0`, which is
463
+ not an error value: it is a perfect match, it beats every candidate, and
464
+ the part passes.
465
+ - **The pool is never torn down because a different size was asked for.**
466
+ It used to be rebuilt whenever the requested worker count changed, which
467
+ both `msg.workers` and a second differently-configured node reach — a
468
+ ~60ms respawn per frame, and a teardown that terminated workers another
469
+ frame was still waiting on. It now grows to the largest size asked for
470
+ and stays; a smaller request dispatches to a prefix.
471
+
472
+ Making the listeners permanent means their ref-counting has to become
473
+ explicit: an attached listener refs the worker's port, and the old
474
+ attach/detach per dispatch was doing that by accident. Without an
475
+ explicit `ref()`/`unref()` around the pending map emptying, an idle pool
476
+ holds the event loop open and the process never exits.
477
+
478
+ Two failure paths are handled explicitly. A worker that dies or is
479
+ terminated mid-dispatch rejects that dispatch (`runRanges` listens for
480
+ `exit` as well as `message`/`error`), so a worker crash fails the frame
481
+ fast instead of leaving it hanging; a worker that errors now leaves the
482
+ pool alone rather than shutting it down for every other in-flight
483
+ frame. And the per-worker
484
+ defect counters are sized from the actual pool size, so a pool larger
485
+ than 64 workers cannot silently drop defect counts.
486
+
487
+ What does not parallelise: the polish is a sequential descent, each step
488
+ depending on the last; and PNG inflate inside `sharp` is serial, so decode
489
+ stays ~135ms whatever the core count.
490
+
491
+ ### Still on the table
492
+
493
+ 500ms was the target and 605ms is where this lands. Closing the last
494
+ 100ms needs a change of kind:
495
+
496
+ - **coarse-to-fine inspection** — diff at low resolution, then re-inspect
497
+ only flagged neighbourhoods at full resolution. The largest remaining
498
+ win, since the whole reason for 3072 is a handful of pixels;
499
+ - **parallel summed-area table** — 41ms, two passes (rows then columns);
500
+ - **native or WASM inner loops** for the warp and the diff.
501
+
502
+ The `nativeFastAlign` branch is the intentionally non-equivalent version of
503
+ that experiment: OpenCV owns decode, unrestricted affine solve, and global
504
+ warp, so the grey table, binary table, all JS search/polish rounds, and the JS
505
+ global warp disappear. Local refinement and blemish policy remain. It measured
506
+ 663ms -> 243ms on the clean 4096x5500 PNG and 1065ms -> 443ms on the
507
+ high-compression reject, with diagnostics off. It also changes the geometry
508
+ model and resampling, so `transform.native` marks its results and it remains an
509
+ opt-in prototype. A geometry/score guard refuses native fits outside
510
+ `maxAngleDeg`, more than 3% from either trained scale, or above 0.15 mask
511
+ disagreement, then runs the trained JS path and reports `nativeFallback`.
512
+
513
+ Also taken already: the polish objective runs on a fixed 320px canvas
514
+ rather than half the golden (search 1643 → 250ms), and the tile matcher
515
+ subsamples by 3 rather than 2.
516
+
517
+ **The density sweeps (stages 1–3) are still synchronous**, and on an
518
+ unpinned frame they are the larger half of the search. So the search is
519
+ no longer *one* contiguous block on the event loop, but it is not free of
520
+ one either. `scoreCandidate` already reads a shared-backed `Uint32`
521
+ integral, so the groundwork for splitting them is in place.
522
+
523
+ ### The polish, batched (1.0.2)
524
+
525
+ The polish became a **pattern search** so its candidates could be scored
526
+ on the pool: every probe in a round is measured from one fixed centre,
527
+ which the first-improvement walk it replaced could not offer — that walk
528
+ re-derived each probe from whatever it had just accepted, which is
529
+ exactly what made its evaluations sequential.
530
+
531
+ Measured against a fixed pin, 16 cores, `workingSize` 2048:
532
+
533
+ | | before | after |
534
+ | --- | ---: | ---: |
535
+ | 8 workers, pinned — search | 220ms | 222ms |
536
+ | 8 workers, pinned — worst event-loop block | 257ms | **93ms** |
537
+ | 8 workers, unpinned — search | 1959ms | **1718ms** |
538
+ | alignment residual, pinned | 0.003978 | **0.003813** |
539
+ | alignment residual, unpinned | 0.008836 | **0.008698** |
540
+
541
+ The registration is better on every fixture measured and the contiguous
542
+ block on the pinned path drops 2.8×. Pinned search time is at **parity**,
543
+ not faster: the pattern search does roughly 2.5× the evaluations and the
544
+ pool absorbs them rather than beating them.
545
+
546
+ The pool's automatic size is capped at sixteen, not eight. Eight was
547
+ chosen against the defect scan, whose curve is flat past it; the alignment
548
+ polish disagrees, because it is ~15 *sequential* rounds of a ten-candidate
549
+ batch and each round finishes no sooner than its slowest worker. On a
550
+ 16-core host against a 4096x5500 frame, pinned, polish runs 342ms at eight
551
+ workers and 220ms at twelve, plateauing there — align 798ms → 680ms, with
552
+ bit-identical transforms and verdicts at every size. The cap is sixteen
553
+ rather than one-per-core because past the plateau the extra threads only
554
+ cost memory and contend with the rest of the instance.
555
+
556
+ Below eight workers it is slower, and that is deliberate. Pinned search
557
+ goes 223 → 282ms at four workers, 231 → 419ms at two, and 225 → 541ms
558
+ with no pool at all. A cheaper path for small hosts would mean two
559
+ polishes, and the alignment a part receives would then depend on the core
560
+ count of the machine inspecting it — the failure `lib/parallel.js` exists
561
+ to prevent. One algorithm everywhere, and the small-host cost is stated
562
+ rather than hidden.
563
+
564
+ The walk's early break did not survive the change. The walk stopped when
565
+ a whole compounding round improved nothing; the closest equivalent here
566
+ is "this step size improved nothing", but a poll moves along one axis at
567
+ a time and so runs out of single-axis improvements well before the
568
+ alignment has converged. Breaking there skipped the three finest step
569
+ sizes, and on a clean bench pair cost a 15% worse residual and a false
570
+ background region on a good part. No test in the suite can see that
571
+ distinction — both forms tie the walk on the spec fixture — so it is
572
+ measured in `bench/`, not asserted.
573
+
574
+ ## label-crop: a fast deskew-crop node on the native engine
575
+
576
+ `label-crop` solves a different problem from golden-compare's own
577
+ alignment, and the difference is why it exists as a separate node:
578
+
579
+ - golden-compare aligns the **printed artwork** to itself and reports
580
+ deviations — the print's position, angle and stretch relative to the
581
+ golden.
582
+ - `label-crop` removes the **placement** variation first: where the
583
+ physical label sits in the frame and how square it sits. A label shifted
584
+ on the tray would otherwise move the whole label relative to the golden
585
+ and flood both blemish checks with a defect the print did not make.
586
+
587
+ So the typical flow is label-crop first, golden-compare second — and
588
+ label-crop's output can be previewed or saved on its own, which the
589
+ inline alignment inside golden-compare cannot be.
590
+
591
+ ### Shape: native pixels, JS only on the small mask
592
+
593
+ The pixel work is delegated to the optional native OpenCV addon — the same
594
+ engine `lib/nativeSeed.js` uses — through its promisified `cpp-bridge`. `lib/labelCrop.js` composes five
595
+ engine calls and does everything else in JS:
596
+
597
+ 1. **decode once** — an encoded Buffer becomes a full-res raw object via
598
+ `colorConvert(buffer, RGB, raw)`; a raw object input skips this.
599
+ 2. **detection copy** — `resize` to ≤ `maxEdge` long edge, `colorConvert`
600
+ to grey. This is the only resolution the JS analysis ever sees.
601
+ 3. **Otsu** — `filter(gray, "otsu", 3, 0, raw)` runs once. `auto`
602
+ analyzes that small binary mask and its JS-inverted form, keeping the
603
+ better rectangle and reporting which polarity won.
604
+ 4. **analysis in JS** — connected components over the ≤640px mask, then
605
+ the dominant component's exterior points, convex hull, and minimum-area
606
+ rectangle. Using the exterior rather than pixel moments prevents
607
+ asymmetric printed panels from inventing label rotation. The angle is
608
+ normalised into [-45°, 45°].
609
+
610
+ Because the label is part of the bright blob, that rectangle always
611
+ *contains* the label. The blob extent is therefore an upper bound, and
612
+ the real boundary is found by snapping each side inward. Two signals
613
+ are accumulated into 1-D histograms along the rect's axes:
614
+
615
+ - **brightness** — for each column/row, the fraction of the rect's
616
+ extent that is label-tone (≥ the 98th-percentile gray for a light
617
+ label, ≤ it for a dark label). The label interior is solidly
618
+ label-tone while a bright halo or bright table patch outside it is
619
+ not, so the boundary is where a run of three reaches ~70% of the
620
+ frame's own maximum fraction (self-adapting to how much of the rect
621
+ the label actually fills). This is what separates "proper white"
622
+ from "grayish" on these photos.
623
+ - **edges** (fallback) — native Sobel (`filter(gray, "edge", 3, 1)`);
624
+ a full-length boundary line becomes one tall bin. Only trusted when
625
+ the strip between the candidate and the region side is weaker than
626
+ the label tone, so a printed barcode band inside the label is never
627
+ mistaken for its edge — but a seam/shadow on an equally-toned
628
+ surface (e.g. the label bottom on a bright table) still snaps.
629
+
630
+ A side whose region position is confirmed (label tone reaches the frame
631
+ edge — a clipped label) stays put. `refinedSides` reports which sides
632
+ moved.
633
+
634
+ After refinement an optional **size gate** compares the refined
635
+ rectangle's area (as a fraction of the frame) against
636
+ `expectedSizeFraction` within `sizeTolerance`. Because it runs after
637
+ refinement, the clipped/table-blended extents the refinement removes are
638
+ not counted — a rect that is still far off (halo included, wrong
639
+ product) becomes a `size-mismatch` miss instead of a wrong crop. The
640
+ node's edit dialog provides a **label size selector**: load any
641
+ representative photo and open the zoomable modal viewer (wheel / +− /
642
+ Fit / 100% zoom, Draw/Pan modes, draggable corner handles) to draw the
643
+ label rectangle; Apply fills `aspectRatio` and
644
+ `expectedSizeFraction` (both resolution-independent fractions), and both
645
+ act as gates (aspect inside `analyzeMask`, size in the op after
646
+ refinement).
647
+ 5. **ROI-only rotate** — the label's axis-aligned bbox (plus a small
648
+ `cropMargin` ring so the rotate never samples past the ROI) is
649
+ cropped from the full frame and rotated using the detected angle in
650
+ OpenCV's image-coordinate convention, then re-cropped to the centred
651
+ `w × h` label rect. Rotating the whole
652
+ frame instead would pay the full canvas for a small label. Sub-
653
+ `minRotateAngleDeg` angles skip the rotate and crop directly.
654
+ 6. **native final crop + encode** — the last `crop` call takes the
655
+ output format, so encoded outputs never round-trip through JS.
656
+
657
+ The engine is a **setup dependency, not a fallback**: `getBridge()`
658
+ throws when the binary is missing, and the node reports a setup error
659
+ instead of silently passing every frame through (which would look like
660
+ "no label found"). `available()` gates the node; the unit tests inject a
661
+ fake engine through `_setBridge` so the suite stays hermetic on
662
+ platforms without a binary.
663
+
664
+ ### Confidence gates: a miss is safer than a wrong crop
665
+
666
+ The mask analysis returns a confidence and refuses to crop when the
667
+ evidence is bad. Each gate has its own `reason` so a miss tells the
668
+ operator what to adjust:
669
+
670
+ | gate | default | reason |
671
+ | --- | --- | --- |
672
+ | blob area ≥ `minAreaFraction` of the frame | 0.05 | `too-small` / `no-component` |
673
+ | blob area ≤ `maxAreaFraction` of the frame | 0.9 | `too-large` |
674
+ | `rectangularity` (blob area / exterior rect area) ≥ `minRectangularity` | 0.4 | `low-rectangularity` |
675
+ | bbox edges touching the border ≤ `maxBorderContact` | 0.5 | `border-contact` |
676
+ | best blob / second blob ≥ `minDominance` | 1.5 | `ambiguous` |
677
+ | optional `aspectRatio` within `aspectTolerance` (log ratio) | — | `aspect-mismatch` |
678
+ | combined confidence ≥ `minConfidence` | 0.4 | `low-confidence` |
679
+
680
+ A miss returns the original input unchanged (`detected: false`), so a
681
+ flow keeps running while the gates are being tuned. `rectangularity` is
682
+ the one gate that must tolerate printed labels: the ink inside a label
683
+ turns into holes in the mask (both polarities hole the label, since
684
+ content is darker than the substrate), so the default is a lenient 0.4
685
+ rather than a clean-rectangle 0.9.
686
+
687
+ ### Why not the worker pool or sharp
688
+
689
+ The per-frame stages in golden-compare run on the JS worker pool because
690
+ they are JS kernels over shared memory. label-crop has no such kernels:
691
+ its per-pixel work is already native (the engine's own threading), and
692
+ its JS is a few hundred kilobytes of mask at most — a pool would only
693
+ add dispatch and copies. sharp stays the decoder/encoder for
694
+ golden-compare's own pipeline; label-crop deliberately routes its pixels
695
+ through the same engine that does the rest of the work, so there is one
696
+ codec stack for the native path.
697
+
698
+ ## line-finder: calipers over an operator-drawn region
699
+
700
+ `lib/lineFinder.js` is the odd one out in this package: pure JS, no
701
+ engine, no image decode. That is deliberate on three counts.
702
+
703
+ **It is a position measurement, not a shape search.** `label-crop`'s blob
704
+ path can afford a 640px detection copy because it is looking for *which*
705
+ region is the label. A caliper is looking for *where* an edge is, and on a
706
+ 3700px frame that copy costs a factor of six in every reading it makes. So
707
+ the caliper path runs at full resolution - affordable precisely because it
708
+ only ever touches the pixels inside the drawn region, which no whole-frame
709
+ operator can claim.
710
+
711
+ **The engine has nothing to offer here.** The work is a few thousand
712
+ bilinear samples, a box filter and a 2x2 eigenproblem. Shipping that
713
+ through the native bridge would cost more in marshalling than it saves,
714
+ and would make the module untestable on win32, where the native
715
+ bridge has no binary.
716
+
717
+ **A drawn region is the algorithm, not a convenience.** The reason the
718
+ blob search fails on the Inspection rig is not that it is badly tuned; it
719
+ is that the label's own boundary (a 4-10 grey-level step) is weaker than
720
+ the printed rules a few millimetres inside it (25-90). No global threshold
721
+ separates those, because the wanted edge is not distinguished by any
722
+ property except *where it is*. Constraining the search is the only fix
723
+ that is not a rule about this particular artwork.
724
+
725
+ Within a region:
726
+
727
+ - The (scan, line) frame is derived from the region and a scan direction,
728
+ and everything is sampled bilinearly in those axes - so a rotated region
729
+ is the same code path as an upright one.
730
+ - Each caliper averages its whole slice before differentiating. This is
731
+ what buys the sensitivity: noise falls as sqrt(rows), so a 4-level step
732
+ over 137 rows has a signal-to-noise ratio a single row could never give.
733
+ - Edge candidates are local extrema of the first derivative above a
734
+ contrast threshold, refined parabolically. `edgeSelect` and
735
+ `ignoreCount` decide which candidate wins - the operator's way of saying
736
+ "the second edge, not the first", which is how a known vignette gets
737
+ stepped past without narrowing the box.
738
+ - The fit is total least squares, because two of the four edges of an
739
+ upright label are vertical and ordinary least squares cannot represent
740
+ a vertical line. Outliers are peeled **one per pass**: a batch trim
741
+ rejects the inliers too, since one caliper on a speck tilts the first
742
+ fit far enough that every good point lands on the same side of it.
743
+
744
+ `rectFromLines` intersects four results into corners, averaging both
745
+ spans for each dimension and both horizontal edges for the angle, so no
746
+ single edge decides the deskew. `label-crop`'s calipers mode feeds that
747
+ straight into the existing rotate/crop tail - the two boundary modes share
748
+ everything from `cropToRect` onwards.
749
+
750
+ ### Why the caliper search is not OpenCV
751
+
752
+ The obvious objection to a pure-JS image operation is that it leaves
753
+ performance on the table. Measured on this rig (container, 3000x3700 frame,
754
+ medians), it does not, for two separate reasons.
755
+
756
+ **The engine cannot express a caliper.** The native cpp-bridge exports
757
+ 16 operations, and its `filter` accepts `otsu`, `blur`, `gaussian` and `edge`.
758
+ There is no reduce, no derivative along an axis, no arbitrary warp, no
759
+ sub-pixel peak. A caliper is a *banded mean profile* then its derivative, and
760
+ the only expressible route to the profile is `resize` to (depth x bands),
761
+ which would be a banded mean if resize area-averaged. It does not:
762
+
763
+ ```
764
+ resize 2800 rows -> 16, with one row at 200 on a ground of 100:
765
+ 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100
766
+ ```
767
+
768
+ It sampled and missed the row entirely. That averaging is not an
769
+ implementation detail, it is the sensitivity: the top edge on this rig is a
770
+ four-grey-level step, invisible in any single row and unambiguous across the
771
+ 175 rows a band covers. So a native caliper means new C++ in a third-party
772
+ package, not a call we are declining to make.
773
+
774
+ **The round trips are not the problem either, and neither is the search.**
775
+ For one 90x2800 region: `crop` 1.0ms, `resize` 0.44ms, `blur` 1.3ms, `edge`
776
+ 3.2ms, `rotate` 10.3ms. Cheap enough - but the whole JS search for that region
777
+ is 3.8ms, so a three-call native pipeline would start in the hole before any
778
+ JS ran. And in a raw-in flow, where the decode is paid once upstream, the four
779
+ edges cost 12.8ms against a label-crop total of 230ms.
780
+
781
+ What *was* on the table was in the JS, and it is now taken: see
782
+ `profilesAxisAligned` in `lib/lineFinder.js`. An unrotated region - every
783
+ region in the example flow, and all four edges of an upright label - has the
784
+ image's own axes, so the interpolation weights along the scan depend only on
785
+ the step index and the cross-axis pair only on the row. Resolving them once
786
+ per region instead of once per sample, and dropping the per-sample function
787
+ call, made the search **2.7x faster with byte-identical output** (34.5ms ->
788
+ 12.8ms for the four regions). The interpolation itself cannot be dropped:
789
+ the scan samples at `s + 0.5` while `sampleBilinear` puts pixel centres on
790
+ whole numbers, so every sample sits between two columns and skipping the
791
+ blend would move every measurement half a pixel.
792
+ `test/lineFinderSampling.test.js` compares the two builders value by value,
793
+ with `strictEqual` rather than a tolerance, over fractional origins, band
794
+ counts that do not divide the region, single-row bands and regions hanging
795
+ off each edge.
796
+
797
+ One genuine finding did come out of the exercise, and it points the other way:
798
+ for an *encoded* payload the engine's own decode is the single biggest cost and
799
+ sharp beats it. `colorConvert(buffer, "RGB")` takes 466ms where
800
+ `sharp(buffer).raw()` takes 296ms for bit-identical pixels, which is 32% of a
801
+ 772ms buffer-in label-crop. It is not wired up - the crop/rotate tail wants an
802
+ engine descriptor and the raw-in flows this rig uses never pay the decode -
803
+ but if a flow ever does feed label-crop a Buffer, that is where its time goes,
804
+ and the answer there is less OpenCV rather than more.
805
+
806
+ ### What it does not solve
807
+
808
+ Finding the boundary reliably is not the same as making the downstream
809
+ inspection better. On the Inspection set the calipers crop 148 of 148 good
810
+ frames (against 76 for the blob search) with the recovered height stable
811
+ to 3.5px, but feeding that crop into `golden-compare` still scores worse
812
+ than not cropping at all: the recurring background regions sit on the
813
+ artwork's own fine strokes, not on the label edge, so removing the border
814
+ does not remove them, and the extra resample makes them slightly worse.
815
+ The finder fixes boundary detection; the crop's effect on the blemish
816
+ channels is a separate question.
817
+
818
+ ## Editing the node
819
+
820
+ Two things that bite when adding a setting, both learned the hard way:
821
+
822
+ - **Node-RED does not backfill a new default into existing node
823
+ instances.** A node saved before the property existed simply has no
824
+ value for it, so a strict `RED.validators.number()` marks it *"invalid
825
+ properties"* — in every deployed flow, not just the one being worked on,
826
+ and the message points at the node rather than at what happened. All the
827
+ numeric validators in both nodes allow blank for that reason
828
+ (`RED.validators.number(true)`); the runtime clamps a missing or blank
829
+ value to the same default anyway.
830
+ - **Anything baked into `prepareGolden` must go in the golden cache key**
831
+ (`golden-compare.js`), or a changed setting will be silently ignored
832
+ until something else invalidates the cache. That includes `debugStages`
833
+ (the golden's stage PNGs are rendered only when it is on, so the flag
834
+ changes what is cached) and the calibration photo's native size (it
835
+ drives the mm conversion). Settings applied per frame must *not* be in
836
+ it.
837
+
838
+ `test/parallel.test.js` is the other thing to keep in mind: the worker
839
+ kernels call the same functions the main thread does, and the tests assert
840
+ byte-identical output. If a hot loop is refactored, refactor the shared
841
+ function rather than copying it into the worker.
842
+
843
+ ## Known limits
844
+
845
+ - **Two labels will align to a mediocre score and then disagree
846
+ everywhere**, and every number downstream will describe it as a
847
+ catastrophic print. `match.mismatchSuspected` exists for exactly this:
848
+ poor registration *and* both blemish checks saturated, which is the
849
+ shape a wrong golden makes and a defective part does not. Scale for
850
+ `match.score` on these samples — correctly paired 0.02–0.05, badly
851
+ printed ~0.10, different product ~0.18.
852
+ - A trained transform cannot detect a new print run (see Pinning); a
853
+ corrupted record — unreadable JSON, or scales outside the sane 0.05–100
854
+ range — is refused on load and the node searches unpinned instead. A
855
+ refusal is reported on the message as `result.transform.pinRefused` as
856
+ well as warned, since a silent fall back to searching otherwise looks
857
+ identical to a normal frame.
858
+ - Sub-pixel translation is not corrected — the polish objective is
859
+ decimated, and local refinement rounds to whole pixels on purpose.
860
+ - Badly printed parts localise poorly by nature; NOK_009 localises only
861
+ 433/736 tiles. That is a signal, not a malfunction.
862
+ - `workingSize` cannot exceed the golden's own resolution (see Resolution),
863
+ so raising it past that buys nothing and the node says so.
864
+ - Heat maps and debug stages are output, not inspection: ~900ms of a frame
865
+ between them, and off by default in the demo flow. The verdict does not
866
+ depend on either.
867
+ - `localAlign` and the morphological tolerances are **coupled**. The
868
+ defaults of 2/1 assume refinement is on; turning it off without widening
869
+ them again will fail good parts, because the registration error it was
870
+ absorbing comes straight back.
871
+ - `checkerboard-calibrate`'s `cols`/`rows` count **dark squares**, not
872
+ physical squares: a standard 4×6 physical board is `cols: 2, rows: 6`,
873
+ while the editor's default `4 × 6` describes an 8×6 board. A photo too
874
+ thin to measure a pitch is reported as not detected rather than as a
875
+ bogus scale.