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