@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/ARCHITECTURE.md
ADDED
|
@@ -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.
|