@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/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
+ };
@@ -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 };