@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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Connected-component labeling over a full-resolution binary mask.
3
+ *
4
+ * Unlike findRegions() in compare.js (which flood-fills a coarse block
5
+ * density grid for the heat map), this walks the raw pixel mask directly
6
+ * and tracks a running centroid per blob - needed to measure checkerboard
7
+ * square centroids precisely for lib/checkerboard.js.
8
+ */
9
+
10
+ "use strict";
11
+
12
+ /**
13
+ * 4-connected flood fill. Returns one entry per blob:
14
+ * { cx, cy, area, x0, y0, x1, y1 } - cx/cy are the centroid (mean x/y),
15
+ * x0/y0/x1/y1 is the half-open bounding box.
16
+ */
17
+ function connectedComponents(mask, width, height, opts) {
18
+ const minArea = (opts && opts.minArea) || 0;
19
+ const maxArea = (opts && opts.maxArea) || Infinity;
20
+ const n = width * height;
21
+ const visited = new Uint8Array(n);
22
+ const stack = new Int32Array(n);
23
+ const blobs = [];
24
+
25
+ for (let start = 0; start < n; start++) {
26
+ if (!mask[start] || visited[start]) continue;
27
+ let stackLen = 0;
28
+ visited[start] = 1;
29
+ stack[stackLen++] = start;
30
+
31
+ let minX = width;
32
+ let minY = height;
33
+ let maxX = -1;
34
+ let maxY = -1;
35
+ let sumX = 0;
36
+ let sumY = 0;
37
+ let area = 0;
38
+
39
+ while (stackLen > 0) {
40
+ const idx = stack[--stackLen];
41
+ const x = idx % width;
42
+ const y = (idx / width) | 0;
43
+ if (x < minX) minX = x;
44
+ if (x > maxX) maxX = x;
45
+ if (y < minY) minY = y;
46
+ if (y > maxY) maxY = y;
47
+ sumX += x;
48
+ sumY += y;
49
+ area++;
50
+
51
+ if (x > 0 && mask[idx - 1] && !visited[idx - 1]) {
52
+ visited[idx - 1] = 1;
53
+ stack[stackLen++] = idx - 1;
54
+ }
55
+ if (x < width - 1 && mask[idx + 1] && !visited[idx + 1]) {
56
+ visited[idx + 1] = 1;
57
+ stack[stackLen++] = idx + 1;
58
+ }
59
+ if (y > 0 && mask[idx - width] && !visited[idx - width]) {
60
+ visited[idx - width] = 1;
61
+ stack[stackLen++] = idx - width;
62
+ }
63
+ if (y < height - 1 && mask[idx + width] && !visited[idx + width]) {
64
+ visited[idx + width] = 1;
65
+ stack[stackLen++] = idx + width;
66
+ }
67
+ }
68
+
69
+ if (area < minArea || area > maxArea) continue;
70
+ blobs.push({
71
+ cx: sumX / area,
72
+ cy: sumY / area,
73
+ area,
74
+ x0: minX,
75
+ y0: minY,
76
+ x1: maxX + 1,
77
+ y1: maxY + 1,
78
+ });
79
+ }
80
+
81
+ return blobs;
82
+ }
83
+
84
+ module.exports = { connectedComponents };
package/lib/dilate.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Separable morphological dilation with a square structuring element,
3
+ * via the sliding-window-maximum (monotonic deque) algorithm.
4
+ *
5
+ * Cost is O(width*height) regardless of the radius - a naive dilate that
6
+ * scans a (2r+1)x(2r+1) window per pixel would cost O(width*height*r^2),
7
+ * which gets slow fast as the tolerance radius grows. Two 1D passes (rows,
8
+ * then columns) give the same result as a single square-window 2D max
9
+ * filter because max is separable over a rectangular structuring element.
10
+ */
11
+
12
+ "use strict";
13
+
14
+ const { allocU8 } = require("./shared.js");
15
+
16
+ // dst[j] = max(src[offset + max(0,j-r)*stride .. offset + min(n-1,j+r)*stride])
17
+ // dq is caller-provided scratch (Int32Array, length >= n) to avoid
18
+ // reallocating per row/column.
19
+ function slidingMax1D(src, offset, stride, n, r, dst, dstOffset, dstStride, dq) {
20
+ let dqLen = 0;
21
+ let dqStart = 0;
22
+ let added = -1;
23
+ for (let j = 0; j < n; j++) {
24
+ const rightIdx = j + r < n - 1 ? j + r : n - 1;
25
+ while (added < rightIdx) {
26
+ added++;
27
+ const v = src[offset + added * stride];
28
+ while (dqLen > dqStart && src[offset + dq[dqLen - 1] * stride] <= v)
29
+ dqLen--;
30
+ dq[dqLen++] = added;
31
+ }
32
+ const leftIdx = j - r > 0 ? j - r : 0;
33
+ while (dq[dqStart] < leftIdx) dqStart++;
34
+ dst[dstOffset + j * dstStride] = src[offset + dq[dqStart] * stride];
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Dilate a binary/grayscale Uint8Array (row-major, width x height) by
40
+ * `radius` px. Returns a new Uint8Array; a radius <= 0 returns a copy.
41
+ */
42
+ function dilate(src, width, height, radius) {
43
+ // shared-backed so the worker pool can read it without a copy
44
+ const out = allocU8(width * height);
45
+ if (radius <= 0) {
46
+ out.set(src);
47
+ return out;
48
+ }
49
+ const scratchLen = width > height ? width : height;
50
+ const dq = new Int32Array(scratchLen);
51
+ const tmp = allocU8(width * height);
52
+
53
+ // horizontal pass: src -> tmp (contiguous rows, stride 1)
54
+ for (let y = 0; y < height; y++) {
55
+ const rowOffset = y * width;
56
+ slidingMax1D(src, rowOffset, 1, width, radius, tmp, rowOffset, 1, dq);
57
+ }
58
+ // vertical pass: tmp -> out (columns, stride = width)
59
+ for (let x = 0; x < width; x++) {
60
+ slidingMax1D(tmp, x, width, height, radius, out, x, width, dq);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ module.exports = { dilate, slidingMax1D };
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Main-thread client for the inspection pipeline.
3
+ *
4
+ * Why this exists: `compareFrame` is ~600ms of CPU, and Node-RED has one
5
+ * thread. Before this, an unpinned frame blocked the runtime's event loop
6
+ * for 1499ms - measured - which stalls every other flow in the instance,
7
+ * the editor websocket, HTTP endpoints and MQTT keepalives along with it.
8
+ * Moving the pipeline into a worker takes the main thread's share of a
9
+ * frame to roughly 12ms.
10
+ *
11
+ * It does not make a frame faster. A 600ms frame is still 600ms; it stops
12
+ * being 600ms of frozen runtime.
13
+ *
14
+ * One inspector per process, spawned on first use and never torn down -
15
+ * the same decision lib/pool.js makes, for the same reason: a respawn
16
+ * costs a worker start plus a ~280ms re-prepare of the golden, which a
17
+ * redeploy would otherwise pay on its first frame. It is unref()'d, so an
18
+ * idle inspector never holds the process open.
19
+ *
20
+ * The nested worker pool lives inside the inspector, not here.
21
+ */
22
+
23
+ "use strict";
24
+
25
+ const path = require("node:path");
26
+ const { HAS_SAB } = require("./shared.js");
27
+ const core = require("./inspectorCore.js");
28
+
29
+ const WORKER_PATH = path.join(__dirname, "inspectorWorker.js");
30
+
31
+ let Worker = null;
32
+ try {
33
+ ({ Worker } = require("node:worker_threads"));
34
+ } catch {
35
+ Worker = null;
36
+ }
37
+
38
+ /**
39
+ * Inline means calling the same core functions on this thread instead of
40
+ * in a worker. It is a fallback, not a second implementation - the
41
+ * verdict cannot differ, because it is the same code. What *does* differ
42
+ * is everything around the call: inline keeps real Buffers and the
43
+ * original Error objects, where the worker path gets structured clones of
44
+ * both. That is why the node-level tests run in both modes.
45
+ */
46
+ const INLINE =
47
+ process.env.GOLDEN_COMPARE_INLINE === "1" || !HAS_SAB || Worker === null;
48
+
49
+ let worker = null;
50
+ let pending = null;
51
+ let nextId = 1;
52
+
53
+ function spawn() {
54
+ const w = new Worker(WORKER_PATH);
55
+ const waiting = new Map();
56
+
57
+ const rejectAll = (err) => {
58
+ if (waiting.size === 0) return;
59
+ const entries = Array.from(waiting.values());
60
+ waiting.clear();
61
+ w.unref();
62
+ for (const entry of entries) entry.reject(err);
63
+ };
64
+
65
+ w.on("message", (msg) => {
66
+ const entry = msg == null ? undefined : waiting.get(msg.id);
67
+ if (!entry) return; // a reply for a request already rejected
68
+ waiting.delete(msg.id);
69
+ if (waiting.size === 0) w.unref();
70
+ if (msg.error) {
71
+ const err = new Error(msg.error.message);
72
+ err.name = msg.error.name || "Error";
73
+ if (msg.error.stack) err.stack = msg.error.stack;
74
+ entry.reject(err);
75
+ } else {
76
+ entry.resolve(msg);
77
+ }
78
+ });
79
+ const drop = (err) => {
80
+ if (worker === w) {
81
+ worker = null;
82
+ pending = null;
83
+ }
84
+ rejectAll(err);
85
+ };
86
+ w.on("error", (err) => {
87
+ drop(err);
88
+ w.terminate();
89
+ });
90
+ w.on("exit", (code) =>
91
+ drop(new Error(`inspector exited (code ${code}) before answering`)),
92
+ );
93
+ // idle: the listeners above would otherwise ref the port and keep the
94
+ // process alive - the same trap lib/pool.js documents
95
+ w.unref();
96
+ worker = w;
97
+ pending = waiting;
98
+ return w;
99
+ }
100
+
101
+ function send(op, message) {
102
+ const w = worker || spawn();
103
+ const waiting = pending;
104
+ return new Promise((resolve, reject) => {
105
+ const id = nextId++;
106
+ if (waiting.size === 0) w.ref();
107
+ waiting.set(id, { resolve, reject });
108
+ try {
109
+ w.postMessage({ ...message, id, op });
110
+ } catch (err) {
111
+ waiting.delete(id);
112
+ if (waiting.size === 0) w.unref();
113
+ reject(err);
114
+ }
115
+ });
116
+ }
117
+
118
+ const call = (op, message) =>
119
+ INLINE ? core[op](message) : send(op, message);
120
+
121
+ /**
122
+ * Structured clone turns a Buffer into a plain Uint8Array, and the
123
+ * difference is not academic: msg.printHeatmap is documented as a PNG
124
+ * Buffer and wired straight into an image viewer in the demo flow, and
125
+ *
126
+ * Buffer.toString("base64") -> "AQID+g=="
127
+ * Uint8Array.toString("base64") -> "1,2,3,250"
128
+ *
129
+ * so the output would be silently wrong rather than an error. Re-wrapping
130
+ * shares the same memory; it does not copy.
131
+ *
132
+ * The null checks matter as much as the wrap: heatmaps are null unless
133
+ * asked for, and `stages` is null unless debugStages is on - which is the
134
+ * default, so an unguarded version would fail on the first frame of a
135
+ * default-configured flow.
136
+ */
137
+ function asBuffer(value) {
138
+ if (value == null) return value;
139
+ if (Buffer.isBuffer(value)) return value;
140
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
141
+ }
142
+
143
+ function rewrapResult(result) {
144
+ if (!result) return result;
145
+ if (result.printBlemish) {
146
+ result.printBlemish.heatmap = asBuffer(result.printBlemish.heatmap);
147
+ }
148
+ if (result.backgroundBlemish) {
149
+ result.backgroundBlemish.heatmap = asBuffer(
150
+ result.backgroundBlemish.heatmap,
151
+ );
152
+ }
153
+ if (result.stages) {
154
+ for (const key of Object.keys(result.stages)) {
155
+ result.stages[key] = asBuffer(result.stages[key]);
156
+ }
157
+ }
158
+ return result;
159
+ }
160
+
161
+ /** Ensure a prepared golden exists for this key. Pass `golden` only after
162
+ * a first call has answered `needGolden` - the bytes are expensive to
163
+ * produce and the store usually already has them. */
164
+ async function prepare({ cacheKey, cfg, golden }) {
165
+ return call("prepare", { cacheKey, cfg, golden });
166
+ }
167
+
168
+ async function inspect({ cacheKey, cfg, frame }) {
169
+ const reply = await call("inspect", { cacheKey, cfg, frame });
170
+ if (reply.needGolden) return reply;
171
+ return { ...reply, result: rewrapResult(reply.result) };
172
+ }
173
+
174
+ async function calibrate({ cfg, image }) {
175
+ return call("calibrate", { cfg, image });
176
+ }
177
+
178
+ /** Tests only. The inspector is process-wide and deliberately survives a
179
+ * redeploy. */
180
+ function shutdown() {
181
+ const w = worker;
182
+ worker = null;
183
+ pending = null;
184
+ if (w) w.terminate();
185
+ core.clear();
186
+ }
187
+
188
+ module.exports = { prepare, inspect, calibrate, shutdown, INLINE };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The inspection pipeline behind one small request/response surface,
3
+ * plus the prepared-golden store it needs.
4
+ *
5
+ * This module is called two ways: directly, on the Node-RED thread, when
6
+ * worker threads or SharedArrayBuffer are unavailable; and from inside
7
+ * `lib/inspectorWorker.js`, which is where it runs in production. Both
8
+ * paths call these same functions, so a verdict cannot depend on which
9
+ * one ran - the same discipline `lib/parallel.js` uses for its kernels,
10
+ * and for the same reason.
11
+ *
12
+ * It deliberately does no file I/O. Structured clone drops an error's
13
+ * own properties - a cloned ENOENT arrives with `err.code === undefined`,
14
+ * and `golden-compare.js` branches on exactly that - so every path that
15
+ * needs to tell "missing" from "refused" stays on the calling thread.
16
+ */
17
+
18
+ "use strict";
19
+
20
+ const { prepareGolden, compareFrame } = require("./compare.js");
21
+ const { measureCheckerboard } = require("./checkerboard.js");
22
+
23
+ /**
24
+ * How many prepared goldens to keep.
25
+ *
26
+ * There has to be a bound. The store lives for the life of the process
27
+ * (like the worker pool, and for the same reason - re-preparing costs
28
+ * ~280ms), and it is keyed by everything baked into the golden, which
29
+ * includes settings a *message* can override: threshold, thresholdMode,
30
+ * sauvolaRadius, sauvolaK, inkMargin, backgroundTolerance, debugStages.
31
+ * A flow sweeping msg.threshold would otherwise add an entry per frame,
32
+ * each holding five masks - around 84MB at workingSize 4096.
33
+ *
34
+ * Four covers the cases that are actually concurrent: a couple of nodes
35
+ * with different goldens, plus a settings change being tuned.
36
+ */
37
+ const MAX_GOLDENS = 4;
38
+
39
+ // cacheKey -> { promise }, in insertion order, so the first key is the
40
+ // least recently used.
41
+ const goldens = new Map();
42
+
43
+ function touch(cacheKey, entry) {
44
+ goldens.delete(cacheKey);
45
+ goldens.set(cacheKey, entry);
46
+ while (goldens.size > MAX_GOLDENS) {
47
+ goldens.delete(goldens.keys().next().value);
48
+ }
49
+ }
50
+
51
+ /** A SharedArrayBuffer handle back to a Buffer view over the same bytes.
52
+ * `Buffer.from(view)` would copy the whole frame again. */
53
+ function view(handle) {
54
+ return handle == null ? null : Buffer.from(handle, 0, handle.byteLength);
55
+ }
56
+
57
+ /**
58
+ * Everything the calling thread still needs to know about a prepared
59
+ * golden: the two warnings it raises, the trained-transform record, and
60
+ * the threshold level. mmPerWorkingPx is not read on that side today -
61
+ * it is here so the reply is a complete description of the golden rather
62
+ * than a list of current callers, which is the thing that rots.
63
+ */
64
+ function goldenMeta(golden) {
65
+ return {
66
+ nativeWidth: golden.nativeWidth,
67
+ nativeHeight: golden.nativeHeight,
68
+ width: golden.width,
69
+ height: golden.height,
70
+ thresholdLevel: golden.thresholdLevel,
71
+ mmPerWorkingPx: golden.mmPerWorkingPx,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Make sure the store holds a golden for `cacheKey`.
77
+ *
78
+ * Answers `{ needGolden: true }` rather than throwing when it does not
79
+ * have the key and was not given the bytes, so the caller can send them
80
+ * only when they are actually needed - a golden the store already holds
81
+ * must not be re-read from disk just to be discarded as a duplicate.
82
+ */
83
+ async function prepare({ cacheKey, cfg, golden }) {
84
+ let entry = goldens.get(cacheKey);
85
+ if (!entry) {
86
+ if (!golden) return { needGolden: true };
87
+ entry = {};
88
+ entry.promise = prepareGolden(view(golden), cfg).catch((err) => {
89
+ // Drop the entry so the next message retries, instead of every
90
+ // later frame inheriting one transient failure for the life of
91
+ // the process. golden-compare.js does the same for its own
92
+ // cache; both are needed, since they are two caches.
93
+ if (goldens.get(cacheKey) === entry) goldens.delete(cacheKey);
94
+ throw err;
95
+ });
96
+ goldens.set(cacheKey, entry);
97
+ }
98
+ touch(cacheKey, entry);
99
+ return { goldenMeta: goldenMeta(await entry.promise) };
100
+ }
101
+
102
+ /**
103
+ * Compare one frame. `{ needGolden: true }` here means the golden was
104
+ * evicted between prepare and inspect; the caller re-prepares and retries
105
+ * exactly once.
106
+ */
107
+ async function inspect({ cacheKey, cfg, frame }) {
108
+ const entry = goldens.get(cacheKey);
109
+ if (!entry) return { needGolden: true };
110
+ touch(cacheKey, entry);
111
+ const golden = await entry.promise;
112
+ const result = await compareFrame(view(frame), golden, cfg);
113
+ return { result, goldenMeta: goldenMeta(golden) };
114
+ }
115
+
116
+ /** checkerboard-calibrate's measurement, which is otherwise ~59ms of
117
+ * synchronous work on the event loop at full sensor resolution. */
118
+ async function calibrate({ cfg, image }) {
119
+ return { result: await measureCheckerboard(view(image), cfg) };
120
+ }
121
+
122
+ /** Testing seam: the store is process-wide and deliberately survives a
123
+ * redeploy, so only tests should ever clear it. */
124
+ function clear() {
125
+ goldens.clear();
126
+ }
127
+
128
+ module.exports = { prepare, inspect, calibrate, clear, MAX_GOLDENS };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Worker side of the inspector: a thin messaging wrapper around
3
+ * lib/inspectorCore.js.
4
+ *
5
+ * Nothing but plumbing belongs here. The core is what runs on the
6
+ * Node-RED thread when worker threads are unavailable, so any logic that
7
+ * crept in here would be logic the inline path does not have.
8
+ *
9
+ * Replies carry the id of the request they answer, for the reason
10
+ * lib/pool.js documents at length: settling on "the next reply" lets two
11
+ * concurrent requests take each other's answers.
12
+ */
13
+
14
+ "use strict";
15
+
16
+ const { parentPort } = require("node:worker_threads");
17
+ const core = require("./inspectorCore.js");
18
+
19
+ parentPort.on("message", async (msg) => {
20
+ const { id, op } = msg;
21
+ try {
22
+ const handler = core[op];
23
+ if (typeof handler !== "function") {
24
+ throw new Error(`unknown inspector op: ${op}`);
25
+ }
26
+ parentPort.postMessage({ id, ...(await handler(msg)) });
27
+ } catch (err) {
28
+ // name and stack are carried explicitly: structured clone keeps
29
+ // neither an Error's own properties nor its class, so anything the
30
+ // caller wants to see has to be named here.
31
+ parentPort.postMessage({
32
+ id,
33
+ error: {
34
+ message: err && err.message ? err.message : String(err),
35
+ name: err && err.name ? err.name : "Error",
36
+ stack: err && err.stack ? err.stack : undefined,
37
+ },
38
+ });
39
+ }
40
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Summed-area table (integral image) over a binary/grayscale Uint8Array,
3
+ * so a rectangular block sum is an O(1) lookup instead of an O(block area)
4
+ * scan - used to turn the per-pixel defect mask into a heat-map grid
5
+ * without re-walking every pixel per block.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const { allocU32, allocF64 } = require("./shared.js");
11
+
12
+ function buildIntegral(src, width, height) {
13
+ const stride = width + 1;
14
+ // shared-backed: the warp kernel reads this from worker threads
15
+ const integral = allocU32(stride * (height + 1));
16
+ for (let y = 0; y < height; y++) {
17
+ let rowSum = 0;
18
+ const srcRow = y * width;
19
+ const intRow = (y + 1) * stride;
20
+ const intPrevRow = y * stride;
21
+ for (let x = 0; x < width; x++) {
22
+ rowSum += src[srcRow + x];
23
+ integral[intRow + x + 1] = integral[intPrevRow + x + 1] + rowSum;
24
+ }
25
+ }
26
+ return { integral, stride, width, height };
27
+ }
28
+
29
+ /**
30
+ * Wider-accumulator twin of buildIntegral, for summing *grey* values
31
+ * (0-255) rather than binary 0/1 masks.
32
+ *
33
+ * A Uint32 table wraps at 2^32 / 255 ~= 16.8M pixels. The frame's working
34
+ * canvas is capped at 2.5x workingSize on the long edge, which at
35
+ * workingSize 3072 is roughly 7680x5720 - the bottom rows of a Uint32
36
+ * table there wrap silently and the area-average warp reads garbage
37
+ * (phantom or missed defects). Float64 holds every integer sum in this
38
+ * codebase exactly (well past 2^53), so a grey table built this way is
39
+ * exact regardless of canvas size. The binary-mask tables must stay
40
+ * Uint32: they are what scoreCandidate/blockSum read inline, and the
41
+ * Float64 form would cost 2x the memory for no benefit.
42
+ */
43
+ function buildIntegral64(src, width, height) {
44
+ const stride = width + 1;
45
+ // shared-backed: the warp kernel reads this from worker threads
46
+ const integral = allocF64(stride * (height + 1));
47
+ for (let y = 0; y < height; y++) {
48
+ let rowSum = 0;
49
+ const srcRow = y * width;
50
+ const intRow = (y + 1) * stride;
51
+ const intPrevRow = y * stride;
52
+ for (let x = 0; x < width; x++) {
53
+ rowSum += src[srcRow + x];
54
+ integral[intRow + x + 1] = integral[intPrevRow + x + 1] + rowSum;
55
+ }
56
+ }
57
+ return { integral, stride, width, height };
58
+ }
59
+
60
+ // Sum over the half-open rectangle [x0,x1) x [y0,y1), clamped to bounds.
61
+ function blockSum(table, x0, y0, x1, y1) {
62
+ const { integral, stride, width, height } = table;
63
+ const cx0 = x0 < 0 ? 0 : x0 > width ? width : x0;
64
+ const cy0 = y0 < 0 ? 0 : y0 > height ? height : y0;
65
+ const cx1 = x1 < 0 ? 0 : x1 > width ? width : x1;
66
+ const cy1 = y1 < 0 ? 0 : y1 > height ? height : y1;
67
+ if (cx1 <= cx0 || cy1 <= cy0) return 0;
68
+ return (
69
+ integral[cy1 * stride + cx1] -
70
+ integral[cy0 * stride + cx1] -
71
+ integral[cy1 * stride + cx0] +
72
+ integral[cy0 * stride + cx0]
73
+ );
74
+ }
75
+
76
+ module.exports = { buildIntegral, buildIntegral64, blockSum };