@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/CHANGELOG.md ADDED
@@ -0,0 +1,524 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - **`line-finder` - find a straight edge inside a region you draw.** A row
12
+ of calipers scans across an operator-drawn region, each reports where the
13
+ brightness steps (with parabolic sub-pixel refinement), and a line is
14
+ fitted through those points by total least squares with outliers peeled
15
+ one at a time.
16
+ Pure JS over a grayscale raster in `lib/lineFinder.js` - no OpenCV engine,
17
+ and only the region's own pixels are touched, so cost follows the drawn
18
+ box rather than the frame.
19
+
20
+ It exists because a whole-frame search finds the *strongest* edge, which
21
+ is not always the wanted one. On the Inspection rig the label's own
22
+ boundary is a 4-10 grey-level step while the printed rules a few
23
+ millimetres inside it are 25-90, so the blob search locks onto the print.
24
+ A drawn region settles it by construction. Each caliper averages its whole
25
+ slice, which is what makes a 4-level step findable: invisible in one row,
26
+ unambiguous across two hundred.
27
+
28
+ Reports `{ found, reason, line, angleDeg, score, calipers, residualPx,
29
+ points }`; a miss is a normal result, not an error. The editor has a
30
+ drawing canvas where the drag direction sets the scan direction, and an
31
+ optional flow-canvas preview draws the region, every caliper hit (green
32
+ kept, red dropped) and the fitted line over the frame - misses included,
33
+ since that is when seeing the box matters most.
34
+
35
+ - **`label-crop` gains `boundaryMode: "calipers"`.** Four `edgeRegions`
36
+ (left/right/top/bottom) are fitted and intersected into the label's
37
+ corners instead of thresholding the whole frame. Runs at full resolution
38
+ rather than on the `maxEdge` detection copy - a caliper measures a
39
+ position, and a 640px copy costs a factor of six in every reading. Any
40
+ edge that is not found fails the frame, per label-crop's existing rule
41
+ that bad evidence is a miss and never a wrong crop; `metadata.edges`
42
+ carries each edge's own outcome so a region that needs re-aiming can be
43
+ identified. `boundaryMode` defaults to `"blob"`, so existing flows are
44
+ unchanged.
45
+
46
+ Measured over the 148-good Inspection set: the blob search cropped 76 of
47
+ 148 frames with output aspect swinging 0.650-0.743 (14%); calipers cropped
48
+ **148 of 148** with recovered height stable to 3.5px (0.1%) and angle to
49
+ 0.04 degrees. It also refuses the blank parts outright - there is no
50
+ boundary to find on an unprinted label - which the blemish channels
51
+ cannot see at all.
52
+
53
+ - **The caliper search is 2.7x faster, with byte-identical output.** An
54
+ unrotated region has the image's own axes, so the interpolation weights along
55
+ the scan depend only on the step index and the cross-axis pair only on the
56
+ row. `profilesAxisAligned` resolves both once per region instead of once per
57
+ sample and drops the per-sample function call, taking this rig's four edges
58
+ from 34.5ms to 12.8ms. Every region in the example flow and all four edges of
59
+ an upright label qualify; a rotated region keeps the general path.
60
+
61
+ The interpolation is *not* skipped, which would have been the easy 11x: the
62
+ scan samples at `s + 0.5` while `sampleBilinear` puts pixel centres on whole
63
+ numbers, so every sample sits between two columns and dropping the blend
64
+ would move every measurement half a pixel.
65
+ `test/lineFinderSampling.test.js` compares the two builders with
66
+ `strictEqual`, value by value, over fractional origins and sizes, band counts
67
+ that do not divide the region, single-row bands, and regions hanging off each
68
+ edge of the frame. The 148-frame sweep is unchanged: 148/148, width spread
69
+ 1.84px, height 5.17px, angle 0.14 degrees.
70
+
71
+ ARCHITECTURE.md now records why this is JS rather than OpenCV, with the
72
+ measurements: the bridge has no reduce and no derivative, and its `resize`
73
+ samples rather than area-averages - it missed a single bright row entirely
74
+ when asked to reduce 2800 rows to 16 - so it cannot build the banded mean a
75
+ caliper is made of. It also records the one finding that points the other
76
+ way: for an encoded payload, sharp decodes 1.5x faster than the engine for
77
+ bit-identical pixels.
78
+
79
+ - **`line-finder` gets a zoomable region selector.** The editor's search
80
+ region was drawn on a 360px thumbnail, which is the one scale at which the
81
+ faint boundary this node exists to find is invisible - so the region could
82
+ only ever be aimed approximately, then corrected by typing numbers. Loading
83
+ a sample now opens a modal viewer on it, zoomed onto the current region,
84
+ with wheel-to-cursor zoom, Fit/100%, and right-drag pan. Dragging on empty
85
+ space draws a new region and still takes the scan direction from the drag;
86
+ corner handles resize from the opposite corner, dragging inside moves, the
87
+ arrow keys nudge by a pixel (ten with Shift), and an amber grip rotates the
88
+ region about its centre to a tenth of a degree. Apply commits, Cancel and
89
+ Escape do not.
90
+
91
+ The overlay draws what the search will actually do rather than just a box:
92
+ the scan direction, the edge being looked for, and one line per caliper
93
+ where that band will measure - so `calipers` and the region's length stop
94
+ being abstract numbers. The footer calls out a region hanging off the frame,
95
+ which is worth knowing because a band that is not wholly inside the image is
96
+ skipped, not partially averaged. The thumbnail also draws a rotated region
97
+ as the parallelogram it is; it used to draw an upright box whatever
98
+ `regionAngleDeg` said.
99
+
100
+ Two new suites cover it. `test/editorRegionGeometry.test.js` lifts the
101
+ editor's copy of the region geometry out of the .html and runs it against
102
+ `lib/lineFinder.js`, because two copies of a rotation convention drift and
103
+ the failure is silent - the box drawn stops being the box searched.
104
+ `test/lineFinderEditor.test.js` drives the viewer itself over the small fake
105
+ DOM in `test/helpers/fakeEditorDom.js`: drag, resize, rotate, nudge, Cancel
106
+ discarding, Escape not leaking window listeners.
107
+
108
+ - **An example flow: `examples/label-crop-with-line-finder.json`.** Importable
109
+ from the Node-RED menu (Import -> Examples). Shows the two-step workflow the
110
+ calipers mode expects - tune one edge at a time in a `line-finder` node with
111
+ the preview on, then paste the four regions into `label-crop` - and carries a
112
+ set of regions measured on the Inspection rig: found on all 148 good frames,
113
+ recovered label width stable to 1.8px, height to 5.2px, deskew angle to 0.14
114
+ degrees, with 6 of the 14 bad frames refused because a blank has no boundary.
115
+ Its comment nodes hold the tuning rules, including the two that mattered
116
+ most here: `edgeSelect: "last"` on the soft left/right transition (which took
117
+ the width spread from 38px to 1.8px, since `best` flips between two similar
118
+ steps), and `minCaliperFraction: 0.2` on the bottom edge, which only about a
119
+ third of its calipers ever see. The third comment records what cropping does
120
+ *not* fix, so the flow is not mistaken for an inspection improvement.
121
+
122
+ - **`nativeFastAlign` - an aggressive OpenCV prototype, off by default.**
123
+ OpenCV now has an opt-in path that owns decode, unrestricted affine
124
+ registration, and the global warp. This bypasses both JS summed-area
125
+ tables, all density sweeps, the pixel polish, and the JS global warp.
126
+ Local tile refinement and the existing blemish policy still run. A native
127
+ failure falls back to the JS implementation, and `result.transform.native`
128
+ reports which path ran. Native fits are also rejected when they exceed
129
+ `maxAngleDeg`, drift more than 3% from either trained magnification, or
130
+ score above 0.15; `result.transform.nativeFallback` carries the reason.
131
+
132
+ This path is deliberately not verdict-identical: it permits shear, uses
133
+ OpenCV's nearest-neighbour warp, does not preserve pinned magnifications,
134
+ and scores full-resolution post-local mask disagreement. On the available
135
+ clean/reject pair it changed the clean frame from fail to pass while the
136
+ marked reject still failed with four background regions. Median warm
137
+ container measurements, 4096x5500 PNG, 12 workers, diagnostics off:
138
+ 663ms -> 243ms clean and 1065ms -> 443ms reject. Reproduce with
139
+ `bench/opencv-fast-bench.js`.
140
+
141
+ - **`nativeAlignSeed` - a prototype, off by default.** The pinned search
142
+ can start from an ORB+ECC alignment measured by the optional
143
+ optional native OpenCV engine instead of from the
144
+ staged sweeps. On a 4096x5500 frame, pinned: align 545ms -> 338ms,
145
+ search 325ms -> 125ms. `result.transform.seeded` reports whether a seed
146
+ was actually used.
147
+
148
+ The engine is not a dependency and nothing changes without it -
149
+ installing the optional native engine enables it. A seed
150
+ replaces the sweeps' *guess* only: the trained magnifications are still
151
+ the trained ones, polish still refines against real pixels, and it still
152
+ produces the score. Seeds are range-checked and discarded on anything
153
+ implausible, because the engine reports success for results that are
154
+ plainly wrong - its features-only pipeline returned scaleX 33.7 at 97
155
+ degrees on this project's own sample with success=true.
156
+
157
+ **Not ready to be the default.** Across five fixtures every verdict
158
+ matched, but the evidence behind one did not: the reject sample yields
159
+ two background regions seeded against one unseeded. Scores move in both
160
+ directions (0.040663 -> 0.040401 on a clean part, 0.040558 -> 0.040846
161
+ on a rotated one). ORB is feature matching, and the badly printed parts
162
+ this inspection exists to catch are exactly the ones with the poorest
163
+ features - that needs validating against a real reject set before this
164
+ can be trusted by default.
165
+
166
+ ## [1.1.3] - 2026-08-29
167
+
168
+ ### Changed
169
+
170
+ - **Both per-frame summed-area tables are built on the worker pool.** The
171
+ mask table the transform search reads and the grey table every
172
+ candidate warp reads were the two largest pieces of per-frame work
173
+ still running single-threaded on the main path, while the pool that had
174
+ just been widened sat idle. The serial form fuses each row's prefix sum
175
+ with the column accumulation - cache-friendly, but it reads the row
176
+ above as it writes, so no two rows can run at once. As two passes each
177
+ dimension is independent.
178
+
179
+ The arithmetic is unchanged and the tables are byte-identical to the
180
+ serial ones at any core count: Uint32 addition is exact modulo 2^32,
181
+ and the Float64 table only ever holds small exact integers, which is
182
+ why it was Float64 to begin with. `test/parallel.test.js` asserts both,
183
+ and the serial implementations remain the reference.
184
+
185
+ Measured on a 4096x5500 frame, pinned, 12 workers: align 677ms → 539ms,
186
+ grey table 113ms → 39ms, frame 889ms → 748ms. Transform, score and both
187
+ blemish verdicts identical - `ox 232.97456593793459`, `score 0.040663`,
188
+ on a clean part and on the one with a background defect.
189
+
190
+ ## [1.1.2] - 2026-08-29
191
+
192
+ ### Changed
193
+
194
+ - **The worker pool's automatic size is capped at sixteen, not eight.**
195
+ The old cap was measured against the defect scan, whose speedup curve is
196
+ flat past eight workers. The alignment polish is a different workload -
197
+ ~15 sequential rounds of a ten-candidate batch, where each round pays a
198
+ fixed dispatch cost and finishes no sooner than its slowest worker - and
199
+ it keeps improving to about twelve. Measured on a 16-core host against a
200
+ 4096x5500 frame, pinned: polish 342ms -> 220ms, align 798ms -> 680ms,
201
+ with bit-identical transforms, scores and blemish verdicts at every pool
202
+ size. Live end to end, the frame went 1.38s -> 1.14s. Hosts with nine
203
+ cores or fewer are unaffected, and an explicit `workers` setting is
204
+ still taken literally.
205
+
206
+ ## [1.1.1] - 2026-08-29
207
+
208
+ ### Fixed
209
+
210
+ - **A trained transform is tied to the golden's content, not to how the
211
+ golden was delivered.** Training through `msg.golden` and then
212
+ producing frames from the configured golden path - the documented
213
+ "train from any two images" flow - wrote a record keyed `buf:<sha1>`
214
+ and then checked it against a frame keyed
215
+ `path:<file>:<mtime>:<size>`. Identical bytes, different key form, so
216
+ the record was refused on *every* frame and the node fell back to the
217
+ full search behind a `node.warn()` that is easy to miss: the tick,
218
+ trigger, untick sequence looked like it had trained, and the search
219
+ kept running. A record now also carries `goldenContentKey`, a hash of
220
+ the golden's bytes, which is consulted only when the cheap keys
221
+ disagree - a real golden change is still refused, and the hot path
222
+ still never reads or hashes the golden. Records written before this
223
+ release have no content hash and are still refused on a key-form
224
+ mismatch; retrain once. Measured on the reproduction: 1029ms of search
225
+ per frame, unpinned, becomes 348ms pinned.
226
+
227
+ ### Added
228
+
229
+ - `result.transform.pinRefused` carries the reason whenever a trained
230
+ record was found and declined. The warning stays, but a refused pin is
231
+ otherwise invisible from the message alone - same shape, same pass or
232
+ fail, silently slower.
233
+
234
+ ## [1.1.0] - 2026-08-29
235
+
236
+ The whole image pipeline moves off Node-RED's event loop into a worker.
237
+
238
+ A frame is ~600ms of CPU and Node-RED has one thread, so an unpinned
239
+ frame blocked the runtime for **1499ms** - measured - stalling the editor
240
+ websocket, HTTP endpoints, MQTT keepalives and every other flow in the
241
+ instance. Worst contiguous main-thread block per frame is now **~14ms**,
242
+ of which ~13ms is copying a 23MP payload into shared memory.
243
+
244
+ **This does not make anything faster.** A 600ms frame is still 600ms; it
245
+ stops being 600ms of frozen runtime. Measured node-level `totalMs` is
246
+ unchanged (1397ms vs 1496ms median, ranges overlapping).
247
+
248
+ Minor rather than patch: `golden.gray` and friends already changed type
249
+ in 1.0.2, and this release changes threading behaviour that a flow can
250
+ observe.
251
+
252
+ ### Changed
253
+
254
+ - `prepareGolden`, `compareFrame` and `measureCheckerboard` all run in a
255
+ single persistent inspector worker, with the existing worker pool
256
+ nested inside it. `checkerboard-calibrate` is included because it runs
257
+ at full sensor resolution with no downscale - ~59ms of synchronous work
258
+ on a 5520x4140 capture.
259
+ - The inspector outlives a redeploy, like the worker pool, and `prepare`
260
+ carries the cache key alone: the golden's bytes cross only when the
261
+ inspector says it lacks that key. A redeploy costs one round trip
262
+ rather than a ~2.6s cold start.
263
+ - A `Uint8Array`/`ArrayBuffer` payload is now copied **once**, straight
264
+ into shared memory, rather than to a Buffer and then to shared memory.
265
+ A `Buffer` payload gains a ~12ms copy it did not pay before; the
266
+ comment in `test/fingerprint.test.js` about it not being copied has
267
+ been corrected. That copy narrows, but does not close, an old hazard:
268
+ sharp decodes asynchronously, so a flow reusing its capture buffer
269
+ could corrupt a decode in progress. The decoder no longer sees the
270
+ caller's memory at all.
271
+
272
+ ### Fixed
273
+
274
+ - Nothing user-visible. Four defects were caught in review before
275
+ release, all of them invisible to the existing suite, and each now has
276
+ a test: PNG Buffers degrading to `Uint8Array` across the boundary
277
+ (silent - a viewer renders nothing); the re-wrap throwing on the
278
+ default configuration, where heatmaps and stages are null; the
279
+ eviction retry livelocking because it re-entered a cache hit; and a
280
+ failed prepare wedging its key in the inspector for the life of the
281
+ process.
282
+
283
+ ### Known issues
284
+
285
+ - The inspector's golden store holds four entries. A flow that keeps
286
+ more than four distinct goldens genuinely in flight will re-prepare on
287
+ eviction (~280ms) rather than fail, but it will do so repeatedly.
288
+ - Frames still interleave rather than queue. That is deliberate: one
289
+ inspector serialising them would give a node's 2.3s unpinned frame
290
+ head-of-line blocking over every other node.
291
+ - A second ~13ms main-thread span per frame, alongside the payload copy,
292
+ is measured but not yet attributed. It is not GC.
293
+
294
+ ## [1.0.2] - 2026-08-29
295
+
296
+ Concurrency and per-frame waste. Two of the fixes below are for bugs that
297
+ produced a *confidently wrong answer* rather than an error, and both were
298
+ reachable on an ordinary flow.
299
+
300
+ ### Fixed
301
+
302
+ - **A dispatch could settle on another dispatch's reply.** The worker pool
303
+ registered `once("message")` per dispatch, so two dispatches queued on
304
+ one worker both fired on the first reply and the second caller read a
305
+ half-written output buffer. Node-RED never awaits a node's input
306
+ handler, so two frames overlap inside `compareFrame` as a matter of
307
+ course. End to end this surfaced as two concurrent frames both reporting
308
+ `transform.score = 0` — not an error value: a perfect match, which beats
309
+ every candidate and passes the part. Replies now carry the id of the
310
+ dispatch they answer.
311
+ - **The pool was torn down whenever a different worker count was
312
+ requested**, which both `msg.workers` and a second differently-configured
313
+ node reach. That cost a ~60ms respawn per frame and terminated workers
314
+ another in-flight frame was still waiting on, failing that frame for no
315
+ fault of its own. One pool now grows to the largest size asked for; a
316
+ smaller request dispatches to a prefix.
317
+ - A worker that errors no longer shuts down the pool for every other
318
+ in-flight frame, and a failed `postMessage` rejects its dispatch instead
319
+ of leaving it unsettled forever.
320
+ - `msg.goldenKey` did not do what it was documented to do. The hash of the
321
+ golden ran before the name was consulted, so naming a golden saved
322
+ nothing.
323
+
324
+ ### Changed
325
+
326
+ - **The golden is fingerprinted before it is loaded, not after.** Only the
327
+ fingerprint runs per message; the bytes are read only on a cache miss.
328
+ Three costs go away, all of them paid to re-learn a constant:
329
+ - `msg.payload` was SHA-1'd on every message for a cache key that was
330
+ discarded — 27ms of a 23MP framebuffer. Nothing is cached against the
331
+ frame, so it is no longer fingerprinted at all.
332
+ - a `goldenPath` golden was read from disk in full on every frame and
333
+ discarded on a cache hit. A hit now costs one `open`+`fstat`.
334
+ - a `Buffer` payload was copied before being handed to sharp — another
335
+ ~8ms at 23MP. `Uint8Array` and `ArrayBuffer` are still copied, since
336
+ those can be views onto a buffer the caller keeps writing to.
337
+ - **`msg.goldenKey` now means the flow owns invalidation.** The buffer's
338
+ length still enters the cache key, so a differently-sized render under a
339
+ stale name is caught, but a same-sized one is served from cache.
340
+ - **The alignment polish is a pattern search, scored on the worker pool.**
341
+ The first-improvement walk it replaced re-derived each probe from
342
+ whatever it had just accepted, which is what made its evaluations
343
+ sequential and unbatchable. `findTransform` is now async and accepts an
344
+ `objectiveBatch`; the scalar `objective` stays supported.
345
+ Measured against a fixed pin, 16 cores, `workingSize` 2048:
346
+
347
+ | | before | after |
348
+ | --- | ---: | ---: |
349
+ | 8 workers, pinned — search | 220ms | 222ms |
350
+ | 8 workers, pinned — worst event-loop block | 257ms | **93ms** |
351
+ | 8 workers, unpinned — search | 1959ms | **1718ms** |
352
+ | alignment residual, pinned | 0.003978 | **0.003813** |
353
+ | alignment residual, unpinned | 0.008836 | **0.008698** |
354
+
355
+ Registration is better on every fixture measured and the contiguous
356
+ event-loop block on the pinned path drops 2.8x. **Pinned search time is
357
+ at parity, not faster** — the pattern search does roughly 2.5x the
358
+ evaluations and the pool absorbs them.
359
+ - **Below eight workers the polish is slower**: pinned search 223 -> 282ms
360
+ at four workers, 231 -> 419ms at two, 225 -> 541ms with no pool. This is
361
+ the price of one algorithm rather than two — a cheaper path for small
362
+ hosts would make the alignment a part receives depend on the core count
363
+ of the machine inspecting it.
364
+ - The prepared golden's buffers are allocated in shared memory once
365
+ instead of being copied there per frame (~4ms a frame at 4.9MP). Note
366
+ for callers: `golden.gray` is now a plain `Uint8Array` rather than a
367
+ `Buffer`.
368
+
369
+ ### Added
370
+
371
+ - `bench/frame-bench.js`, reporting `searchMs` apart from `alignMs`,
372
+ alignment residual, and the worst contiguous event-loop block. `--pin`
373
+ fixes the magnification so two versions can be compared on the same
374
+ problem; without it each version pins to its own recovered scale and a
375
+ score difference says nothing.
376
+ - `test/fingerprint.test.js` — asserts the *absence* of per-frame reads
377
+ and digests, by counting them.
378
+ - `test/poolConcurrency.test.js` — two overlapping dispatches, and a
379
+ differently-sized request against a pool in use. Both were checked
380
+ against the old pool first: they pass on it until the timing is made
381
+ decisive.
382
+ - `test/goldenShared.test.js` — walks the prepared golden rather than
383
+ naming fields, so a new pixel buffer is covered without anyone
384
+ remembering.
385
+ - `test/editorDefaults.test.js` — parses the `defaults` block out of both
386
+ `.html` files and compares 38 of 39 and 5 of 6 properties against their
387
+ runtime fallbacks, including the boolean idioms and `pickMode`. Checked
388
+ against three deliberate breaks.
389
+
390
+ ### Known issues
391
+
392
+ - The density sweeps (`findTransform` stages 1-3) are still synchronous.
393
+ The transform search is no longer *one* contiguous block on the event
394
+ loop, but on an unpinned frame the sweeps remain the larger half of it.
395
+ - A small dispatch queues behind a large one on the same workers
396
+ (head-of-line blocking). Pre-existing, and unchanged here.
397
+ - `checkerboard-calibrate`'s `cols`/`rows` count **dark squares per row**,
398
+ not physical squares: a standard 4x6 physical board must be configured
399
+ as `cols: 2, rows: 6`. Unchanged, and documented in the node's help.
400
+
401
+ ## [1.0.1] - 2026-08-28
402
+
403
+ An adversarial review (five independent review lanes, verified by three
404
+ verifying agents) surfaced nineteen distinct issues; every confirmed one
405
+ is fixed below with a regression test. The suite grew from 44 to 100+
406
+ tests.
407
+
408
+ ### Fixed
409
+
410
+ - **Grey summed-area tables wrapped past 2³² on large frames.** A `Uint32`
411
+ table over the frame's greys (0–255) wraps once the image exceeds
412
+ ~16.8M bright pixels — reachable at the default working sizes on this
413
+ project's 23MP captures — silently corrupting the area-average warp and
414
+ the polish objective in the wrapped region (phantom or missed defects).
415
+ Grey tables now use a `Float64` accumulator; binary-mask tables stay
416
+ `Uint32`. The worker kernel reads the same type, so serial and parallel
417
+ stay byte-identical.
418
+ - **Fractional-magnification warp footprint.** The area-average footprint
419
+ was exact only for integer magnifications; fractional values (e.g.
420
+ m = 1.5) degenerated to 1-px point sampling. Warp boxes now read
421
+ fractional corners through bilinear interpolation of the summed-area
422
+ table.
423
+ - **`scaleLadder(min == max)` returned `[1]`.** Pinning the magnification
424
+ to a non-1.0 scale silently searched at 1.0 every frame. A degenerate
425
+ ladder now returns `[min]` (and rejects a non-positive `min` cleanly).
426
+ - **A corrupted trained-transform file could hang the flow.** `scaleX:
427
+ 1e308` passed the finite-positive check and drove the pinned search into
428
+ an infinite synchronous loop (event-loop freeze, unbounded memory).
429
+ Scales are now validated to the sane physical range 0.05–100 and refused
430
+ with a reason; `offsetsAround` treats non-finite ranges as center-only.
431
+ - **Checkerboard calibration reported `detected: true` with no pitch.**
432
+ `median([])` returned NaN for grids too thin to measure, silently
433
+ disabling the mm position gate. Such photos are now reported as not
434
+ detected with a reason.
435
+ - **`goldenRawGeometry` fell through to stale `msg.images[]`.** A golden
436
+ supplied as a path or container-carrying buffer on a message that still
437
+ carried pdf-to-image leftovers was decoded as raw pixels at the frame's
438
+ dimensions — and the garbage golden was cached and reused. `msg.images[]`
439
+ geometry is now only consulted for a bare-buffer golden, mirroring the
440
+ frame-side guard.
441
+ - **Path-string goldens were cached content-blind.** An in-place overwrite
442
+ of the artwork served the stale prepared golden (and stale trained
443
+ transform) silently. Path cache keys now carry the file's mtime and
444
+ size, so an overwrite re-decodes and the stale transform refuses itself
445
+ with a retrain warning.
446
+ - **`readScaleFile` accepted 0/negative/Infinity and swallowed corrupt
447
+ files.** Values are now validated finite-positive (upper bound 1000),
448
+ and unreadable/implausible files return an error that the node surfaces
449
+ as a warning instead of silently dropping calibration.
450
+ - **mm-per-pixel assumed the golden's native resolution equalled the
451
+ calibration photo's.** The conversion is now derived from the
452
+ calibration photo's recorded native size (with a backward-compatible
453
+ fallback), and the node warns once when the golden's native resolution
454
+ differs from the calibration photo's.
455
+ - **Both nodes double-reported failures** (`node.error(...)` followed by
456
+ `done(err)`; Node-RED routes `done(err)` through `node.error`). Errors
457
+ are now reported once.
458
+ - **A frame could hang forever if a pool worker died or was terminated
459
+ mid-dispatch** (`runRanges` settled only on `message`/`error`). An
460
+ `exit` listener now rejects the dispatch, so the frame fails fast.
461
+ - **Defect counters silently under-counted with pools larger than 64**
462
+ (the slot buffer was fixed at 64; typed-array out-of-bounds writes are
463
+ ignored). Slots are now sized from the actual pool.
464
+ - **`toShared` dropped `byteOffset`/`byteLength`** on sliced views. Offset
465
+ views are re-copied into a zero-offset `SharedArrayBuffer`.
466
+ - **Raw descriptors were never validated against buffer length.** A
467
+ descriptor whose `width × height × channels` exceeded the actual bytes
468
+ reached sharp's raw path (libvips now errors, but with a generic
469
+ message; it historically read out of bounds). The node now fails with
470
+ the real numbers, and declared sizes are capped at 64M pixels.
471
+ - **Path inputs could read unboundedly** (`/dev/zero`, FIFOs, symlinks,
472
+ multi-GB files) with a TOCTOU window. Reads now go through
473
+ `open` → `fstat` → regular-file check → 512 MB cap → read via the same
474
+ fd.
475
+ - **No input size cap before copy+hash.** Buffer inputs over 512 MB are
476
+ refused before copying or SHA-1 hashing.
477
+ - **`new Buffer(SharedArrayBuffer)` (DEP0005)** fired on every parallel
478
+ frame via `toShared`. The deprecated constructor form is gone.
479
+ - **Golden stage PNGs were rendered unconditionally** and retained on the
480
+ cached golden for the node's lifetime. They are rendered only when
481
+ `debugStages` is on (the flag is now part of the golden cache key).
482
+ - **`resolveImage` object form rejected `ArrayBuffer` data** (and
483
+ `checkerboard-calibrate`'s twin had the same gap). Accepted now.
484
+ - **`checkerboard-calibrate` used strict `RED.validators.number()`** while
485
+ `golden-compare` deliberately allows blank; switched to
486
+ `number(true)` for consistency with the no-backfill policy.
487
+ - **A vacuous test** — "the mismatch check can be turned off" used a
488
+ fixture that scored below `mismatchScore` even with the check enabled.
489
+ It now uses a genuinely different-artwork pair with a real precondition.
490
+ - **Stale comments** — a cache-key comment omitted key components and one
491
+ referenced a non-existent `fgWeak` field (it is `fgAmbiguous`).
492
+
493
+ ### Changed
494
+
495
+ - `workingSize` cache semantics: an in-place golden-file overwrite now
496
+ re-decodes (see Fixed), and `debugStages` + the calibration photo's
497
+ native size participate in the cache key.
498
+ - `checkerboard-calibrate` and `golden-compare` now share the same
499
+ validator policy (`RED.validators.number(true)`).
500
+
501
+ ### Tests
502
+
503
+ - New suites: `test/warp.test.js` (grey-table wrap, fractional footprints),
504
+ `test/checkerboard.test.js` (first-ever coverage of `lib/checkerboard.js`),
505
+ `test/scaleFile.test.js`, `test/pool.test.js` (exit handling, oversized
506
+ pools, `toShared`), `test/glue.test.js` + `test/checkerboardCalibrate.test.js`
507
+ (Node-RED glue through a fake-RED harness: `resolveImage` branches,
508
+ cache invalidation, `msg.rawInfo`, error reporting, input caps).
509
+ - Byte-identity assertions extended to `warpParallel` (with and without
510
+ the summed-area table) and `refineLocallyParallel`.
511
+
512
+ ### Known issues
513
+
514
+ - `checkerboard-calibrate`'s `cols`/`rows` count **dark squares per row**,
515
+ not physical squares: a standard 4×6 physical board must be configured
516
+ as `cols: 2, rows: 6`. The editor defaults (`4 × 6`) describe an 8×6
517
+ board and will not detect a standard 4×6 one. Counting semantics are
518
+ documented in the node's help text and intentionally unchanged.
519
+
520
+ ## [1.0.0] - 2026-05-06
521
+
522
+ Initial release: `golden-compare` (position + print/background blemish
523
+ checks against a cached golden, worker-pool accelerated) and
524
+ `checkerboard-calibrate` (mm/px calibration from a printed checkerboard).