@graciousstar/node-red-contrib-vision-tools 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +875 -0
- package/CHANGELOG.md +524 -0
- package/LICENSE +202 -0
- package/README.md +992 -0
- package/barcode-locate.html +256 -0
- package/barcode-locate.js +156 -0
- package/checkerboard-calibrate.html +167 -0
- package/checkerboard-calibrate.js +282 -0
- package/examples/label-crop-with-line-finder.json +239 -0
- package/golden-compare.html +713 -0
- package/golden-compare.js +1126 -0
- package/icons/checkerboard-calibrate.svg +8 -0
- package/icons/golden-compare.svg +6 -0
- package/label-crop.html +1178 -0
- package/label-crop.js +250 -0
- package/lib/align.js +867 -0
- package/lib/checkerboard.js +331 -0
- package/lib/compare.js +1338 -0
- package/lib/components.js +84 -0
- package/lib/dilate.js +65 -0
- package/lib/inspector.js +188 -0
- package/lib/inspectorCore.js +128 -0
- package/lib/inspectorWorker.js +40 -0
- package/lib/integral.js +76 -0
- package/lib/labelCrop.js +1461 -0
- package/lib/lineFinder.js +765 -0
- package/lib/localAlign.js +360 -0
- package/lib/locate.js +302 -0
- package/lib/nativeSeed.js +292 -0
- package/lib/parallel.js +428 -0
- package/lib/pool.js +250 -0
- package/lib/poolWorker.js +324 -0
- package/lib/scaleFile.js +83 -0
- package/lib/shared.js +87 -0
- package/lib/threshold.js +231 -0
- package/lib/transformFile.js +169 -0
- package/lib/warp.js +216 -0
- package/line-finder.html +1361 -0
- package/line-finder.js +280 -0
- package/package.json +73 -0
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).
|