@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/lib/shared.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-memory allocation for the buffers the worker pool operates on.
|
|
3
|
+
*
|
|
4
|
+
* Worker threads can only touch a typed array without copying it if its
|
|
5
|
+
* backing store is a SharedArrayBuffer, and the per-frame buffers here
|
|
6
|
+
* run to tens of megabytes - copying them into and out of workers would
|
|
7
|
+
* cost more than the parallelism saves. So the large intermediates are
|
|
8
|
+
* allocated shared from the start; a typed array over a SharedArrayBuffer
|
|
9
|
+
* behaves identically to one over an ArrayBuffer for every operation in
|
|
10
|
+
* this codebase, so nothing else has to know.
|
|
11
|
+
*
|
|
12
|
+
* If SharedArrayBuffer is unavailable the allocators fall back to ordinary
|
|
13
|
+
* buffers and `isShared` reports false, which is the signal for callers to
|
|
14
|
+
* stay on the serial path rather than fail.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const HAS_SAB = typeof SharedArrayBuffer !== "undefined";
|
|
18
|
+
|
|
19
|
+
function allocU8(length) {
|
|
20
|
+
return HAS_SAB
|
|
21
|
+
? new Uint8Array(new SharedArrayBuffer(length))
|
|
22
|
+
: new Uint8Array(length);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function allocU32(length) {
|
|
26
|
+
return HAS_SAB
|
|
27
|
+
? new Uint32Array(new SharedArrayBuffer(length * 4))
|
|
28
|
+
: new Uint32Array(length);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function allocF32(length) {
|
|
32
|
+
return HAS_SAB
|
|
33
|
+
? new Float32Array(new SharedArrayBuffer(length * 4))
|
|
34
|
+
: new Float32Array(length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function allocF64(length) {
|
|
38
|
+
return HAS_SAB
|
|
39
|
+
? new Float64Array(new SharedArrayBuffer(length * 8))
|
|
40
|
+
: new Float64Array(length);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Copy into shared memory only when it is not already there. */
|
|
44
|
+
function toShared(array) {
|
|
45
|
+
if (!HAS_SAB) return array;
|
|
46
|
+
if (
|
|
47
|
+
array.buffer instanceof SharedArrayBuffer &&
|
|
48
|
+
array.byteOffset === 0 &&
|
|
49
|
+
array.byteLength === array.buffer.byteLength
|
|
50
|
+
) {
|
|
51
|
+
return array;
|
|
52
|
+
}
|
|
53
|
+
return copyToShared(array);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Copy `array`'s bytes into a fresh zero-offset SharedArrayBuffer-backed
|
|
58
|
+
* view with the same element type. Workers rebuild their side as
|
|
59
|
+
* `new Uint8Array(buffer)` from offset 0 (poolWorker.js), so a view that
|
|
60
|
+
* is a slice of a larger buffer (byteOffset > 0) must be copied or the
|
|
61
|
+
* workers would read the wrong bytes - silently.
|
|
62
|
+
*/
|
|
63
|
+
function copyToShared(array) {
|
|
64
|
+
const store = new SharedArrayBuffer(array.byteLength);
|
|
65
|
+
// Buffer's `new Buffer(SAB)` form is deprecated (DEP0005) and sharp's
|
|
66
|
+
// decodeGray hands us Buffers on every parallel frame; build those as
|
|
67
|
+
// a plain Uint8Array over the store instead. The element type only
|
|
68
|
+
// matters for the other typed arrays, which callers index as such.
|
|
69
|
+
const Ctor = array.constructor === Buffer ? Uint8Array : array.constructor;
|
|
70
|
+
const out = new Ctor(store);
|
|
71
|
+
out.set(array);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isShared(array) {
|
|
76
|
+
return HAS_SAB && array != null && array.buffer instanceof SharedArrayBuffer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
HAS_SAB,
|
|
81
|
+
allocU8,
|
|
82
|
+
allocU32,
|
|
83
|
+
allocF32,
|
|
84
|
+
allocF64,
|
|
85
|
+
toShared,
|
|
86
|
+
isShared,
|
|
87
|
+
};
|
package/lib/threshold.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreground (ink) thresholding strategies.
|
|
3
|
+
*
|
|
4
|
+
* "Foreground" is always the dark side: ink/print/marks on a lighter
|
|
5
|
+
* substrate.
|
|
6
|
+
*
|
|
7
|
+
* - "fixed" - one hand-set grey level. Fastest and perfectly
|
|
8
|
+
* repeatable, but it silently drifts out of calibration
|
|
9
|
+
* with the lighting: a slightly darker exposure turns
|
|
10
|
+
* substrate into ink and floods the background check.
|
|
11
|
+
* - "otsu" - one level per image, chosen to maximally separate the
|
|
12
|
+
* two intensity modes. Absorbs uniform exposure changes,
|
|
13
|
+
* which is the common failure on a production line where
|
|
14
|
+
* lamps age and ambient light shifts.
|
|
15
|
+
* - "sauvola" - a per-pixel level from the local mean and standard
|
|
16
|
+
* deviation. Absorbs *gradients* (one edge of the part
|
|
17
|
+
* lit brighter than the other), which a single global
|
|
18
|
+
* level cannot. In blank regions the local deviation
|
|
19
|
+
* collapses, so the threshold sinks well below the local
|
|
20
|
+
* mean and the substrate is correctly left as background
|
|
21
|
+
* - that property is why this beats a plain local-mean
|
|
22
|
+
* threshold, which speckles blank areas with noise.
|
|
23
|
+
*
|
|
24
|
+
* Otsu and Sauvola both make golden and target independently
|
|
25
|
+
* self-normalizing, which is the point: they no longer have to have been
|
|
26
|
+
* shot under identical light to be comparable.
|
|
27
|
+
*
|
|
28
|
+
* Whichever mode is used, the decision is still a hard cut, and features
|
|
29
|
+
* that sit near the level land arbitrarily on one side of it. That is not
|
|
30
|
+
* hypothetical: comparing PDF artwork against a photograph of the print,
|
|
31
|
+
* a screened tint renders light in the PDF (above its level, so not ink)
|
|
32
|
+
* and prints heavier through dot gain (below its level, so ink). The
|
|
33
|
+
* design element is identical; only the quantization differs.
|
|
34
|
+
*
|
|
35
|
+
* Worse, the level itself is not stable. Otsu re-derives it from each
|
|
36
|
+
* image's histogram, and the histogram changes with resolution: on this
|
|
37
|
+
* project's artwork the level walks from 160 at workingSize 1024 down to
|
|
38
|
+
* 145 at 3072, which is enough to flip a solid grey "RX" panel sitting at
|
|
39
|
+
* 155 from ink to background and light up a whole region of false extra
|
|
40
|
+
* ink. Neither side is wrong; the panel is simply too close to the cut
|
|
41
|
+
* for the cut to mean anything.
|
|
42
|
+
*
|
|
43
|
+
* So each threshold also reports an `ambiguous` mask - pixels within
|
|
44
|
+
* `cfg.inkMargin` grey levels of the level they were judged against, on
|
|
45
|
+
* *either* side of it. Both blemish checks drop a pixel from the evidence
|
|
46
|
+
* when either image is ambiguous there. See computeExtraInkDefect in
|
|
47
|
+
* compare.js.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
"use strict";
|
|
51
|
+
|
|
52
|
+
const { buildIntegral, blockSum } = require("./integral.js");
|
|
53
|
+
const { allocU8 } = require("./shared.js");
|
|
54
|
+
|
|
55
|
+
/** Standard histogram-based Otsu: the level maximizing between-class variance. */
|
|
56
|
+
function otsuThreshold(gray) {
|
|
57
|
+
const hist = new Uint32Array(256);
|
|
58
|
+
for (let i = 0; i < gray.length; i++) hist[gray[i]]++;
|
|
59
|
+
const total = gray.length;
|
|
60
|
+
|
|
61
|
+
let sumAll = 0;
|
|
62
|
+
for (let t = 0; t < 256; t++) sumAll += t * hist[t];
|
|
63
|
+
|
|
64
|
+
let sumB = 0;
|
|
65
|
+
let weightB = 0;
|
|
66
|
+
let maxVariance = 0;
|
|
67
|
+
let threshold = 0;
|
|
68
|
+
for (let t = 0; t < 256; t++) {
|
|
69
|
+
weightB += hist[t];
|
|
70
|
+
if (weightB === 0) continue;
|
|
71
|
+
const weightF = total - weightB;
|
|
72
|
+
if (weightF === 0) break;
|
|
73
|
+
sumB += t * hist[t];
|
|
74
|
+
const meanB = sumB / weightB;
|
|
75
|
+
const meanF = (sumAll - sumB) / weightF;
|
|
76
|
+
const variance = weightB * weightF * (meanB - meanF) * (meanB - meanF);
|
|
77
|
+
if (variance > maxVariance) {
|
|
78
|
+
maxVariance = variance;
|
|
79
|
+
threshold = t;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return threshold;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** foreground = dark pixels, against one global level. */
|
|
86
|
+
function thresholdFgFixed(gray, level) {
|
|
87
|
+
const n = gray.length;
|
|
88
|
+
const out = allocU8(n);
|
|
89
|
+
for (let i = 0; i < n; i++) out[i] = gray[i] < level ? 1 : 0;
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pixels too close to a global level for the level to have decided
|
|
95
|
+
* anything: within `margin` grey levels either side. Null when the margin
|
|
96
|
+
* is disabled, so callers can skip the test entirely rather than walk an
|
|
97
|
+
* all-zero mask.
|
|
98
|
+
*/
|
|
99
|
+
function ambiguousFixed(gray, level, margin) {
|
|
100
|
+
if (!(margin > 0)) return null;
|
|
101
|
+
const n = gray.length;
|
|
102
|
+
const out = allocU8(n);
|
|
103
|
+
const lo = level - margin;
|
|
104
|
+
const hi = level + margin;
|
|
105
|
+
for (let i = 0; i < n; i++) {
|
|
106
|
+
const v = gray[i];
|
|
107
|
+
out[i] = v >= lo && v <= hi ? 1 : 0;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Sum of squares needs more range than a Uint32 integral gives
|
|
113
|
+
// (n*255^2 overflows past ~66k pixels), and Float32's 24-bit mantissa
|
|
114
|
+
// loses the low bits that the corner-subtraction depends on - so the
|
|
115
|
+
// squares table is Float64.
|
|
116
|
+
function buildSquaredIntegral(gray, width, height) {
|
|
117
|
+
const stride = width + 1;
|
|
118
|
+
const integral = new Float64Array(stride * (height + 1));
|
|
119
|
+
for (let y = 0; y < height; y++) {
|
|
120
|
+
let rowSum = 0;
|
|
121
|
+
const srcRow = y * width;
|
|
122
|
+
const intRow = (y + 1) * stride;
|
|
123
|
+
const intPrevRow = y * stride;
|
|
124
|
+
for (let x = 0; x < width; x++) {
|
|
125
|
+
const v = gray[srcRow + x];
|
|
126
|
+
rowSum += v * v;
|
|
127
|
+
integral[intRow + x + 1] = integral[intPrevRow + x + 1] + rowSum;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { integral, stride, width, height };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Sauvola: T(x,y) = m * (1 + k*(s/R - 1)), with m/s the local mean and
|
|
135
|
+
* standard deviation over a (2r+1)^2 window and R=128 the dynamic-range
|
|
136
|
+
* normalizer. Both windows come from integral images, so cost is
|
|
137
|
+
* O(width*height) independent of the radius.
|
|
138
|
+
*/
|
|
139
|
+
function thresholdFgSauvola(gray, width, height, radius, k, margin) {
|
|
140
|
+
const sum = buildIntegral(gray, width, height);
|
|
141
|
+
const sqSum = buildSquaredIntegral(gray, width, height);
|
|
142
|
+
const out = new Uint8Array(width * height);
|
|
143
|
+
// the level is per-pixel here, so "too close to call" has to be decided
|
|
144
|
+
// inside the loop - there is no single level to compare against after
|
|
145
|
+
const ambiguous = margin > 0 ? new Uint8Array(width * height) : null;
|
|
146
|
+
const R = 128;
|
|
147
|
+
for (let y = 0; y < height; y++) {
|
|
148
|
+
const y0 = y - radius;
|
|
149
|
+
const y1 = y + radius + 1;
|
|
150
|
+
for (let x = 0; x < width; x++) {
|
|
151
|
+
const x0 = x - radius;
|
|
152
|
+
const x1 = x + radius + 1;
|
|
153
|
+
const cx0 = x0 < 0 ? 0 : x0;
|
|
154
|
+
const cy0 = y0 < 0 ? 0 : y0;
|
|
155
|
+
const cx1 = x1 > width ? width : x1;
|
|
156
|
+
const cy1 = y1 > height ? height : y1;
|
|
157
|
+
const area = (cx1 - cx0) * (cy1 - cy0);
|
|
158
|
+
if (area <= 0) continue;
|
|
159
|
+
const mean = blockSum(sum, cx0, cy0, cx1, cy1) / area;
|
|
160
|
+
const meanSq = blockSum(sqSum, cx0, cy0, cx1, cy1) / area;
|
|
161
|
+
const variance = meanSq - mean * mean;
|
|
162
|
+
const std = variance > 0 ? Math.sqrt(variance) : 0;
|
|
163
|
+
const t = mean * (1 + k * (std / R - 1));
|
|
164
|
+
const i = y * width + x;
|
|
165
|
+
const v = gray[i];
|
|
166
|
+
out[i] = v < t ? 1 : 0;
|
|
167
|
+
if (ambiguous !== null) {
|
|
168
|
+
ambiguous[i] = v >= t - margin && v <= t + margin ? 1 : 0;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { fg: out, ambiguous };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Threshold to a foreground mask under the configured mode. Returns
|
|
177
|
+
* { fg, level, ambiguous }:
|
|
178
|
+
*
|
|
179
|
+
* - `level` is the global grey level actually used (null for sauvola,
|
|
180
|
+
* which has no single level), reported for diagnostics so a drifting
|
|
181
|
+
* exposure is visible rather than merely absorbed.
|
|
182
|
+
* - `ambiguous` marks pixels within cfg.inkMargin grey levels of the
|
|
183
|
+
* level they were judged against, either side of it - too close to the
|
|
184
|
+
* cut for the cut to carry a defect claim. Null when inkMargin is 0,
|
|
185
|
+
* which restores the plain hard-threshold behaviour exactly.
|
|
186
|
+
*
|
|
187
|
+
* `fg` itself is never narrowed by the margin: alignment scores against
|
|
188
|
+
* the full mask, where an ambiguous pixel is still perfectly good
|
|
189
|
+
* evidence of where the label is. The margin only withholds it from the
|
|
190
|
+
* *defect* decision.
|
|
191
|
+
*/
|
|
192
|
+
function thresholdForeground(gray, width, height, cfg) {
|
|
193
|
+
const margin = cfg.inkMargin > 0 ? cfg.inkMargin : 0;
|
|
194
|
+
switch (cfg.thresholdMode) {
|
|
195
|
+
case "otsu": {
|
|
196
|
+
const level = otsuThreshold(gray);
|
|
197
|
+
return {
|
|
198
|
+
fg: thresholdFgFixed(gray, level),
|
|
199
|
+
level,
|
|
200
|
+
ambiguous: ambiguousFixed(gray, level, margin),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
case "sauvola": {
|
|
204
|
+
const { fg, ambiguous } = thresholdFgSauvola(
|
|
205
|
+
gray,
|
|
206
|
+
width,
|
|
207
|
+
height,
|
|
208
|
+
cfg.sauvolaRadius,
|
|
209
|
+
cfg.sauvolaK,
|
|
210
|
+
margin,
|
|
211
|
+
);
|
|
212
|
+
return { fg, level: null, ambiguous };
|
|
213
|
+
}
|
|
214
|
+
default: {
|
|
215
|
+
const level = cfg.threshold;
|
|
216
|
+
return {
|
|
217
|
+
fg: thresholdFgFixed(gray, level),
|
|
218
|
+
level,
|
|
219
|
+
ambiguous: ambiguousFixed(gray, level, margin),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
otsuThreshold,
|
|
227
|
+
thresholdFgFixed,
|
|
228
|
+
ambiguousFixed,
|
|
229
|
+
thresholdFgSauvola,
|
|
230
|
+
thresholdForeground,
|
|
231
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trained-transform file helpers.
|
|
3
|
+
*
|
|
4
|
+
* The alignment transform splits cleanly into two halves by what
|
|
5
|
+
* physically varies between frames:
|
|
6
|
+
*
|
|
7
|
+
* - magnification (mx) and stretch (my/mx) come from the camera's
|
|
8
|
+
* standoff and the press's pull on the media. Neither changes from
|
|
9
|
+
* one part to the next, so both are measured once and reused - the
|
|
10
|
+
* same reasoning as the mm/px calibration next door.
|
|
11
|
+
* - translation and rotation are where the part happens to be sitting
|
|
12
|
+
* this time, and have to be solved every frame.
|
|
13
|
+
*
|
|
14
|
+
* Reusing the first half is not primarily a speed optimization. A search
|
|
15
|
+
* that is free to re-solve magnification per frame can pick wrong, and it
|
|
16
|
+
* is likeliest to pick wrong on a badly printed label - the case the
|
|
17
|
+
* inspection exists for - because a poor print gives the search poor
|
|
18
|
+
* evidence to fit. Pinning removes that whole class of failure.
|
|
19
|
+
*
|
|
20
|
+
* A trained record is tied to the golden it was measured against and to
|
|
21
|
+
* the working size it was measured at, because the numbers are meaningless
|
|
22
|
+
* against a different golden or at a different resolution. Both are
|
|
23
|
+
* checked on load and a mismatch is rejected rather than silently applied.
|
|
24
|
+
*
|
|
25
|
+
* "The golden it was measured against" has to mean the *image*, not the
|
|
26
|
+
* way the image arrived. `goldenKey` is the caller's cheap fingerprint,
|
|
27
|
+
* and its form follows the delivery: `buf:<sha1>` for a golden sent on the
|
|
28
|
+
* message, `path:<file>:<mtime>:<size>` for one read from disk, `key:<n>`
|
|
29
|
+
* for one the flow named. Training through msg.golden and then producing
|
|
30
|
+
* frames from the configured goldenPath - the documented "train from any
|
|
31
|
+
* two images" flow - therefore compared `buf:...` against `path:...` and
|
|
32
|
+
* refused a perfectly good record on every frame, silently falling back
|
|
33
|
+
* to the full search. So a record also carries `goldenContentKey`, a hash
|
|
34
|
+
* of the golden's bytes, and the cheap keys disagreeing only means
|
|
35
|
+
* "resolve it against the content" rather than "refuse". Computing that
|
|
36
|
+
* hash means reading the golden, which is the one thing the cheap keys
|
|
37
|
+
* exist to avoid, so the caller passes a resolver that is consulted only
|
|
38
|
+
* on a cheap-key mismatch and never on the hot path.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
"use strict";
|
|
42
|
+
|
|
43
|
+
const fs = require("fs");
|
|
44
|
+
const fsp = fs.promises;
|
|
45
|
+
const path = require("path");
|
|
46
|
+
|
|
47
|
+
async function pathExists(p) {
|
|
48
|
+
try {
|
|
49
|
+
await fsp.access(p);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isFinitePositive(v) {
|
|
57
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The physical range a trained magnification can plausibly take. A
|
|
61
|
+
// standoff a few cm further out changes a scale by a few percent; nothing
|
|
62
|
+
// in this rig produces a 1000x or 1e-4x relationship between the golden's
|
|
63
|
+
// working pixels and the frame's. The bound is not about being right at
|
|
64
|
+
// the edges - a huge but finite scaleX like 1e308 flows through the
|
|
65
|
+
// pinned search into centerX = (tW - mx*gW)/2 = -Infinity and an infinite
|
|
66
|
+
// halfRange, which makes the sweep loop forever and freezes the Node-RED
|
|
67
|
+
// process. Refuse the record instead, the same way a golden/workingSize
|
|
68
|
+
// mismatch is refused, and let the caller fall back to searching.
|
|
69
|
+
const SCALE_MIN = 0.05;
|
|
70
|
+
const SCALE_MAX = 100;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a trained transform, returning null when there is nothing usable.
|
|
74
|
+
* `expect` is { goldenKey, workingSize, goldenContentKey }, where
|
|
75
|
+
* goldenContentKey is a hash of the golden's bytes or a function
|
|
76
|
+
* returning one; a record trained against a different golden or working
|
|
77
|
+
* size is refused with a reason rather than applied, since applying it
|
|
78
|
+
* would misalign every frame in a way that looks like a print fault.
|
|
79
|
+
*/
|
|
80
|
+
async function readTransformFile(filePath, expect) {
|
|
81
|
+
if (!filePath || !(await pathExists(filePath))) return null;
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(await fsp.readFile(filePath, "utf8"));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { error: `trained transform is not readable JSON: ${err.message}` };
|
|
87
|
+
}
|
|
88
|
+
if (!isFinitePositive(parsed.scaleX) || !isFinitePositive(parsed.scaleY)) {
|
|
89
|
+
return { error: "trained transform has no usable scaleX/scaleY" };
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
parsed.scaleX < SCALE_MIN ||
|
|
93
|
+
parsed.scaleX > SCALE_MAX ||
|
|
94
|
+
parsed.scaleY < SCALE_MIN ||
|
|
95
|
+
parsed.scaleY > SCALE_MAX
|
|
96
|
+
) {
|
|
97
|
+
return {
|
|
98
|
+
error:
|
|
99
|
+
`trained transform scaleX/scaleY must be within ${SCALE_MIN}..${SCALE_MAX} ` +
|
|
100
|
+
`(got ${parsed.scaleX}/${parsed.scaleY}) - retrain it`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (expect) {
|
|
104
|
+
if (
|
|
105
|
+
expect.goldenKey &&
|
|
106
|
+
parsed.goldenKey &&
|
|
107
|
+
parsed.goldenKey !== expect.goldenKey
|
|
108
|
+
) {
|
|
109
|
+
// The cheap keys record how the golden was delivered, so they
|
|
110
|
+
// disagree both when the golden really changed and when the same
|
|
111
|
+
// image simply arrived a different way. Only the content can tell
|
|
112
|
+
// those apart; resolving it costs a read, so it happens here and
|
|
113
|
+
// nowhere else.
|
|
114
|
+
const mine = await resolveContentKey(expect);
|
|
115
|
+
if (!parsed.goldenContentKey || !mine) {
|
|
116
|
+
return {
|
|
117
|
+
error:
|
|
118
|
+
`trained transform was measured against a different golden ` +
|
|
119
|
+
`(${parsed.goldenKey} vs ${expect.goldenKey}) - retrain it` +
|
|
120
|
+
(parsed.goldenContentKey
|
|
121
|
+
? ""
|
|
122
|
+
: ` (this record predates content-keyed training, so the same ` +
|
|
123
|
+
`golden delivered a different way cannot be recognised)`),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (parsed.goldenContentKey !== mine) {
|
|
127
|
+
return {
|
|
128
|
+
error:
|
|
129
|
+
`trained transform was measured against different golden content ` +
|
|
130
|
+
`(${parsed.goldenContentKey} vs ${mine}) - retrain it`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
expect.workingSize &&
|
|
136
|
+
parsed.workingSize &&
|
|
137
|
+
parsed.workingSize !== expect.workingSize
|
|
138
|
+
) {
|
|
139
|
+
return {
|
|
140
|
+
error:
|
|
141
|
+
`trained transform was measured at workingSize ${parsed.workingSize}, ` +
|
|
142
|
+
`now running at ${expect.workingSize} - retrain it`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { scaleX: parsed.scaleX, scaleY: parsed.scaleY, record: parsed };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// expect.goldenContentKey may be a string or a function returning one
|
|
150
|
+
// (sync or async); a resolver that throws - an unreadable golden, say -
|
|
151
|
+
// leaves the mismatch unresolved, which refuses the record and falls back
|
|
152
|
+
// to searching, the same as before it existed.
|
|
153
|
+
async function resolveContentKey(expect) {
|
|
154
|
+
const c = expect.goldenContentKey;
|
|
155
|
+
if (typeof c === "string") return c || null;
|
|
156
|
+
if (typeof c !== "function") return null;
|
|
157
|
+
try {
|
|
158
|
+
return (await c()) || null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function writeTransformFile(filePath, record) {
|
|
165
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
166
|
+
await fsp.writeFile(filePath, JSON.stringify(record, null, 2));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { readTransformFile, writeTransformFile };
|
package/lib/warp.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resampling the matched region of a camera frame into the golden's own
|
|
3
|
+
* frame, given the transform recovered by lib/align.js.
|
|
4
|
+
*
|
|
5
|
+
* The old code could get away with a plain windowed byte copy because the
|
|
6
|
+
* only transform it supported was an integer translation. Once
|
|
7
|
+
* magnification is in play the sampling method starts to matter a great
|
|
8
|
+
* deal, and in the wrong direction for the naive choice: when the frame
|
|
9
|
+
* resolves the part more finely than the golden does (m > 1, the usual
|
|
10
|
+
* case for a 23MP capture against a downscaled template), point-sampling
|
|
11
|
+
* one frame pixel per golden pixel throws away all but 1/m^2 of the data.
|
|
12
|
+
* On the fine text that this node exists to inspect, that aliases strokes
|
|
13
|
+
* in and out of existence and manufactures defects no printer produced.
|
|
14
|
+
*
|
|
15
|
+
* So sampling adapts to the magnification, per axis - mx and my differ
|
|
16
|
+
* whenever the press stretched the print along one axis:
|
|
17
|
+
*
|
|
18
|
+
* m > 1 - area-average the mx by my frame footprint of each golden
|
|
19
|
+
* pixel, read in O(1) from a summed-area table. This is a true
|
|
20
|
+
* box downsample, the same thing an image resizer would do,
|
|
21
|
+
* and it costs the same per output pixel regardless of m.
|
|
22
|
+
* m <= 1 - bilinear interpolation. There is no footprint to average
|
|
23
|
+
* (the frame carries less detail than the golden), so the
|
|
24
|
+
* useful thing is smooth sub-pixel placement.
|
|
25
|
+
*
|
|
26
|
+
* Under rotation the footprint is treated as its axis-aligned bounding
|
|
27
|
+
* box rather than a rotated rectangle. Across the small angles this
|
|
28
|
+
* handles, the difference is a slight extra blur at the corners of each
|
|
29
|
+
* footprint, which costs far less than the aliasing it avoids.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
"use strict";
|
|
33
|
+
|
|
34
|
+
const { allocU8 } = require("./shared.js");
|
|
35
|
+
|
|
36
|
+
const { buildIntegral64 } = require("./integral.js");
|
|
37
|
+
|
|
38
|
+
// The area-average path needs a summed-area table over the source. Built
|
|
39
|
+
// separately so a caller evaluating many candidate transforms against the
|
|
40
|
+
// same frame (the refinement loop in lib/align.js) pays for it once
|
|
41
|
+
// instead of once per candidate - rebuilding it per call would make the
|
|
42
|
+
// table, not the sampling, the dominant cost.
|
|
43
|
+
//
|
|
44
|
+
// This is the *grey* table (0-255 per pixel), so it uses the Float64
|
|
45
|
+
// accumulator: a Uint32 table wraps once the frame's canvas passes
|
|
46
|
+
// ~16.8M bright pixels, and the bottom rows of the wrap read as garbage
|
|
47
|
+
// to the area-average warp. Float64 holds every sum here exactly.
|
|
48
|
+
function buildGrayTable(src, srcW, srcH) {
|
|
49
|
+
return buildIntegral64(src, srcW, srcH);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Value of a cumulative table at a fractional corner. The table is a
|
|
53
|
+
// lattice over [0,width] x [0,height] (entry (x,y) is the sum of the
|
|
54
|
+
// pixels strictly above and left of (x,y)); bilinear interpolation
|
|
55
|
+
// between the four surrounding lattice points is what makes a box with
|
|
56
|
+
// fractional corners summable in O(1) - the standard interpolated
|
|
57
|
+
// summed-area table.
|
|
58
|
+
function satAt(table, x, y) {
|
|
59
|
+
const { integral, stride, width, height } = table;
|
|
60
|
+
if (x <= 0 || y <= 0) return 0;
|
|
61
|
+
if (x >= width) x = width;
|
|
62
|
+
if (y >= height) y = height;
|
|
63
|
+
const ix = x | 0;
|
|
64
|
+
const iy = y | 0;
|
|
65
|
+
const fx = x - ix;
|
|
66
|
+
const fy = y - iy;
|
|
67
|
+
const x1 = ix + 1 > width ? width : ix + 1;
|
|
68
|
+
const y1 = iy + 1 > height ? height : iy + 1;
|
|
69
|
+
const p00 = integral[iy * stride + ix];
|
|
70
|
+
const p10 = integral[iy * stride + x1];
|
|
71
|
+
const p01 = integral[y1 * stride + ix];
|
|
72
|
+
const p11 = integral[y1 * stride + x1];
|
|
73
|
+
return (
|
|
74
|
+
p00 + (p10 - p00) * fx + (p01 - p00) * fy + (p00 - p10 - p01 + p11) * fx * fy
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sample `src` (srcW x srcH, 8-bit) into an outW x outH canvas under
|
|
80
|
+
* target = (ox,oy) + R(theta) * diag(mx,my) * golden.
|
|
81
|
+
*
|
|
82
|
+
* Pixels mapping outside the source are filled with `fill` (default 255,
|
|
83
|
+
* i.e. blank substrate - so a template hanging off the frame edge reads
|
|
84
|
+
* as "no ink there", which the print check will flag, rather than as a
|
|
85
|
+
* black bar, which would flood the background check with a defect the
|
|
86
|
+
* part does not have).
|
|
87
|
+
*
|
|
88
|
+
* `table` is an optional prebuilt summed-area table from buildGrayTable,
|
|
89
|
+
* for callers warping the same frame many times.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* Resample rows [yLo,yHi) only. Rows are independent, so this is what the
|
|
93
|
+
* worker pool splits - and it calls this very function, so there is one
|
|
94
|
+
* implementation of the sampling rather than a copy that could drift.
|
|
95
|
+
* `sat` non-null selects the area-average path.
|
|
96
|
+
*/
|
|
97
|
+
function warpRows(
|
|
98
|
+
out,
|
|
99
|
+
src,
|
|
100
|
+
srcW,
|
|
101
|
+
srcH,
|
|
102
|
+
mx,
|
|
103
|
+
my,
|
|
104
|
+
theta,
|
|
105
|
+
ox,
|
|
106
|
+
oy,
|
|
107
|
+
outW,
|
|
108
|
+
sat,
|
|
109
|
+
yLo,
|
|
110
|
+
yHi,
|
|
111
|
+
) {
|
|
112
|
+
const cos = Math.cos(theta);
|
|
113
|
+
const sin = Math.sin(theta);
|
|
114
|
+
const xFromGx = cos * mx;
|
|
115
|
+
const yFromGx = sin * mx;
|
|
116
|
+
const xFromGy = -sin * my;
|
|
117
|
+
const yFromGy = cos * my;
|
|
118
|
+
|
|
119
|
+
if (sat !== null) {
|
|
120
|
+
// downsampling: area-average each golden pixel's footprint
|
|
121
|
+
const halfX = mx / 2;
|
|
122
|
+
const halfY = my / 2;
|
|
123
|
+
for (let y = yLo; y < yHi; y++) {
|
|
124
|
+
const baseX = ox + xFromGy * y;
|
|
125
|
+
const baseY = oy + yFromGy * y;
|
|
126
|
+
const outRow = y * outW;
|
|
127
|
+
for (let x = 0; x < outW; x++) {
|
|
128
|
+
const tx = baseX + xFromGx * x;
|
|
129
|
+
const ty = baseY + yFromGx * x;
|
|
130
|
+
// Pixel index j spans continuous [j, j+1), so the footprint
|
|
131
|
+
// centered on index tx spans [tx+0.5-half, tx+0.5+half).
|
|
132
|
+
// Dropping that half-pixel biases every footprint half a
|
|
133
|
+
// pixel up-left, which is invisible on a single image and
|
|
134
|
+
// fatal when two independently-resampled grids are compared:
|
|
135
|
+
// the best alignment stops being the true one.
|
|
136
|
+
let x0 = tx + 0.5 - halfX;
|
|
137
|
+
let y0 = ty + 0.5 - halfY;
|
|
138
|
+
let x1 = tx + 0.5 + halfX;
|
|
139
|
+
let y1 = ty + 0.5 + halfY;
|
|
140
|
+
if (x1 <= 0 || y1 <= 0 || x0 >= srcW || y0 >= srcH) continue;
|
|
141
|
+
if (x0 < 0) x0 = 0;
|
|
142
|
+
if (y0 < 0) y0 = 0;
|
|
143
|
+
if (x1 > srcW) x1 = srcW;
|
|
144
|
+
if (y1 > srcH) y1 = srcH;
|
|
145
|
+
// The corners stay fractional. Rounding them to integers was
|
|
146
|
+
// exact for an odd integer magnification, but any fractional
|
|
147
|
+
// m shifts the box half a pixel and degenerates toward point
|
|
148
|
+
// sampling (at m = 1.5, every other box rounds to a 1-px
|
|
149
|
+
// footprint) - exactly the aliasing this path exists to
|
|
150
|
+
// avoid. With the corners interpolated in the cumulative
|
|
151
|
+
// table, every box - integer or fractional m - is a true
|
|
152
|
+
// area average.
|
|
153
|
+
const area = (x1 - x0) * (y1 - y0);
|
|
154
|
+
if (area <= 0) continue;
|
|
155
|
+
const sum =
|
|
156
|
+
satAt(sat, x1, y1) -
|
|
157
|
+
satAt(sat, x0, y1) -
|
|
158
|
+
satAt(sat, x1, y0) +
|
|
159
|
+
satAt(sat, x0, y0);
|
|
160
|
+
out[outRow + x] = Math.round(sum / area);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// upsampling / same scale: bilinear
|
|
167
|
+
for (let y = yLo; y < yHi; y++) {
|
|
168
|
+
const baseX = ox + xFromGy * y;
|
|
169
|
+
const baseY = oy + yFromGy * y;
|
|
170
|
+
const outRow = y * outW;
|
|
171
|
+
for (let x = 0; x < outW; x++) {
|
|
172
|
+
const tx = baseX + xFromGx * x;
|
|
173
|
+
const ty = baseY + yFromGx * x;
|
|
174
|
+
if (tx < -1 || ty < -1 || tx > srcW || ty > srcH) continue;
|
|
175
|
+
const fx = Math.floor(tx);
|
|
176
|
+
const fy = Math.floor(ty);
|
|
177
|
+
const dx = tx - fx;
|
|
178
|
+
const dy = ty - fy;
|
|
179
|
+
const x0 = fx < 0 ? 0 : fx >= srcW ? srcW - 1 : fx;
|
|
180
|
+
const y0 = fy < 0 ? 0 : fy >= srcH ? srcH - 1 : fy;
|
|
181
|
+
const x1 = x0 + 1 >= srcW ? srcW - 1 : x0 + 1;
|
|
182
|
+
const y1 = y0 + 1 >= srcH ? srcH - 1 : y0 + 1;
|
|
183
|
+
const p00 = src[y0 * srcW + x0];
|
|
184
|
+
const p10 = src[y0 * srcW + x1];
|
|
185
|
+
const p01 = src[y1 * srcW + x0];
|
|
186
|
+
const p11 = src[y1 * srcW + x1];
|
|
187
|
+
const top = p00 + (p10 - p00) * dx;
|
|
188
|
+
const bottom = p01 + (p11 - p01) * dx;
|
|
189
|
+
out[outRow + x] = Math.round(top + (bottom - top) * dy);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function warpGray(
|
|
195
|
+
src,
|
|
196
|
+
srcW,
|
|
197
|
+
srcH,
|
|
198
|
+
mx,
|
|
199
|
+
my,
|
|
200
|
+
theta,
|
|
201
|
+
ox,
|
|
202
|
+
oy,
|
|
203
|
+
outW,
|
|
204
|
+
outH,
|
|
205
|
+
fill,
|
|
206
|
+
table,
|
|
207
|
+
) {
|
|
208
|
+
const out = allocU8(outW * outH);
|
|
209
|
+
out.fill(fill == null ? 255 : fill);
|
|
210
|
+
const sat =
|
|
211
|
+
mx > 1.001 || my > 1.001 ? table || buildIntegral64(src, srcW, srcH) : null;
|
|
212
|
+
warpRows(out, src, srcW, srcH, mx, my, theta, ox, oy, outW, sat, 0, outH);
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { warpGray, warpRows, buildGrayTable };
|