@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/line-finder.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * line-finder: find a straight edge inside a search region the operator
3
+ * drew.
4
+ *
5
+ * Input (msg.payload): an encoded image Buffer or a raw
6
+ * { data, width, height, channels } object - the same image shapes
7
+ * golden-compare and label-crop accept.
8
+ *
9
+ * Output: msg.payload passes through untouched (this is a measuring
10
+ * tool, not a filter) and msg.lineFinder carries the result:
11
+ *
12
+ * { found, reason, line: { x, y, dx, dy, p0, p1 }, angleDeg, score,
13
+ * calipers: { total, found, used }, residualPx, points, region,
14
+ * timings: { totalMs } }
15
+ *
16
+ * A miss is a normal outcome, not an error: `found` is false and
17
+ * `reason` says which gate stopped it. Genuine setup problems - an
18
+ * unusable payload, a region off the image - are errors.
19
+ *
20
+ * Unlike label-crop this needs no OpenCV engine. lib/lineFinder.js is
21
+ * pure JS over a grayscale raster and only touches the region's own
22
+ * pixels, so the cost scales with the box the operator drew rather than
23
+ * with the frame. Decoding an *encoded* payload does need sharp, which
24
+ * is already a hard dependency of this package.
25
+ */
26
+
27
+ const { performance } = require("node:perf_hooks");
28
+
29
+ module.exports = (RED) => {
30
+ const {
31
+ findLine,
32
+ regionCorners,
33
+ SCAN_DIRECTIONS,
34
+ POLARITIES,
35
+ EDGE_SELECTS,
36
+ } = require("./lib/lineFinder.js");
37
+
38
+ const BOUNDS = {
39
+ calipers: [1, 512],
40
+ contrastThreshold: [0, 255],
41
+ filterHalfWidth: [0, 64],
42
+ ignoreCount: [0, 64],
43
+ outlierTolerancePx: [0.1, 1000],
44
+ minCaliperFraction: [0.05, 1],
45
+ angleToleranceDeg: [0, 90],
46
+ minScore: [0, 1],
47
+ previewWidth: [80, 600],
48
+ };
49
+
50
+ function clampInt(value, fallback, [min, max]) {
51
+ const n = Number.parseInt(value, 10);
52
+ if (Number.isNaN(n)) return fallback;
53
+ return Math.min(max, Math.max(min, n));
54
+ }
55
+
56
+ function clampFloat(value, fallback, [min, max]) {
57
+ const n = Number.parseFloat(value);
58
+ if (Number.isNaN(n)) return fallback;
59
+ return Math.min(max, Math.max(min, n));
60
+ }
61
+
62
+ function pickMode(value, fallback, allowed) {
63
+ return allowed.includes(value) ? value : fallback;
64
+ }
65
+
66
+ /**
67
+ * Grayscale raster from whatever the flow handed us.
68
+ *
69
+ * A raw descriptor is used in place: the caller already paid for the
70
+ * pixels and the finder only reads them. Only an encoded buffer goes
71
+ * through sharp, and then only once.
72
+ */
73
+ async function toGray(payload) {
74
+ if (payload && payload.data && payload.width && payload.height) {
75
+ const channels = payload.channels || 1;
76
+ const data =
77
+ payload.data instanceof Uint8Array
78
+ ? payload.data
79
+ : new Uint8Array(payload.data.buffer, payload.data.byteOffset, payload.data.byteLength);
80
+ if (channels === 1) {
81
+ return { gray: data, width: payload.width, height: payload.height };
82
+ }
83
+ // Rec. 601 luma, matching what sharp's .grayscale() produces, so a
84
+ // region tuned on an encoded sample behaves the same on raw input.
85
+ const n = payload.width * payload.height;
86
+ const gray = new Uint8Array(n);
87
+ for (let i = 0; i < n; i++) {
88
+ const p = i * channels;
89
+ gray[i] =
90
+ (data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29 + 128) >> 8;
91
+ }
92
+ return { gray, width: payload.width, height: payload.height };
93
+ }
94
+ if (Buffer.isBuffer(payload)) {
95
+ const sharp = require("sharp");
96
+ const { data, info } = await sharp(payload)
97
+ .grayscale()
98
+ .raw()
99
+ .toBuffer({ resolveWithObject: true });
100
+ return {
101
+ gray: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
102
+ width: info.width,
103
+ height: info.height,
104
+ };
105
+ }
106
+ throw new Error(
107
+ "line-finder: msg.payload must be an encoded image Buffer or a raw " +
108
+ "{ data, width, height, channels } image object",
109
+ );
110
+ }
111
+
112
+ /**
113
+ * A small JPEG of the frame, plus the geometry needed to draw the
114
+ * search region, the caliper hits and the fitted line over it.
115
+ *
116
+ * Tuning a caliper region is the one job where a number is not enough:
117
+ * "score 0.4" does not say whether the box is aimed at the wrong edge,
118
+ * clipped by the frame, or straddling two steps - and the picture says
119
+ * all three at a glance. So the overlay carries the *dropped* caliper
120
+ * points too, not only the surviving fit.
121
+ *
122
+ * Coordinates are published in image pixels and scaled in the editor,
123
+ * so the payload does not have to be re-sent when the preview is
124
+ * resized, and a rotated region draws as the parallelogram it is.
125
+ */
126
+ async function buildPreview(payload, result, region, cfg, width) {
127
+ const sharp = require("sharp");
128
+ const pipeline =
129
+ payload && payload.data && payload.width
130
+ ? sharp(
131
+ Buffer.isBuffer(payload.data) ? payload.data : Buffer.from(payload.data),
132
+ {
133
+ raw: {
134
+ width: payload.width,
135
+ height: payload.height,
136
+ channels: payload.channels || 1,
137
+ },
138
+ },
139
+ )
140
+ : sharp(payload);
141
+ const jpeg = await pipeline
142
+ .resize({ width, withoutEnlargement: true })
143
+ .jpeg({ quality: 70 })
144
+ .toBuffer();
145
+ return {
146
+ image: jpeg.toString("base64"),
147
+ mimeType: "jpeg",
148
+ previewWidth: width,
149
+ imageWidth: result.imageWidth,
150
+ imageHeight: result.imageHeight,
151
+ region: regionCorners(region, cfg.scanDirection),
152
+ scanDirection: cfg.scanDirection,
153
+ found: result.found,
154
+ reason: result.reason,
155
+ score: result.score,
156
+ angleDeg: result.angleDeg,
157
+ calipers: result.calipers,
158
+ residualPx: result.residualPx,
159
+ line: result.found ? { p0: result.line.p0, p1: result.line.p1 } : null,
160
+ // rounded: this crosses the websocket on every frame, and a
161
+ // tenth of a pixel is well past what a thumbnail can show
162
+ points: result.points.map((p) => ({
163
+ x: Math.round(p.x * 10) / 10,
164
+ y: Math.round(p.y * 10) / 10,
165
+ used: p.used,
166
+ })),
167
+ };
168
+ }
169
+
170
+ function LineFinderNode(config) {
171
+ RED.nodes.createNode(this, config);
172
+
173
+ const region = {
174
+ x: clampFloat(config.regionX, 0, [-1e6, 1e6]),
175
+ y: clampFloat(config.regionY, 0, [-1e6, 1e6]),
176
+ width: clampFloat(config.regionWidth, 100, [1, 1e6]),
177
+ height: clampFloat(config.regionHeight, 100, [1, 1e6]),
178
+ angleDeg: clampFloat(config.regionAngleDeg, 0, [-180, 180]),
179
+ };
180
+
181
+ const defaults = {
182
+ scanDirection: pickMode(config.scanDirection, "right", SCAN_DIRECTIONS),
183
+ polarity: pickMode(config.polarity, "either", POLARITIES),
184
+ edgeSelect: pickMode(config.edgeSelect, "best", EDGE_SELECTS),
185
+ calipers: clampInt(config.calipers, 16, BOUNDS.calipers),
186
+ contrastThreshold: clampFloat(config.contrastThreshold, 2, BOUNDS.contrastThreshold),
187
+ filterHalfWidth: clampInt(config.filterHalfWidth, 2, BOUNDS.filterHalfWidth),
188
+ ignoreCount: clampInt(config.ignoreCount, 0, BOUNDS.ignoreCount),
189
+ outlierTolerancePx: clampFloat(config.outlierTolerancePx, 2.5, BOUNDS.outlierTolerancePx),
190
+ minCaliperFraction: clampFloat(config.minCaliperFraction, 0.5, BOUNDS.minCaliperFraction),
191
+ // blank means "do not check the angle at all", which is the
192
+ // documented off value in lib/lineFinder.js
193
+ angleToleranceDeg:
194
+ config.angleToleranceDeg === "" || config.angleToleranceDeg == null
195
+ ? null
196
+ : clampFloat(config.angleToleranceDeg, 10, BOUNDS.angleToleranceDeg),
197
+ };
198
+
199
+ // A found line that nothing agrees on is worse than a clean miss, so
200
+ // the score gate is applied here rather than left to the flow.
201
+ const minScore = clampFloat(config.minScore, 0, BOUNDS.minScore);
202
+ const overrideKeys = Object.keys(defaults);
203
+
204
+ const configuredPreviewEnabled = !!config.previewEnabled;
205
+ const configuredPreviewWidth = clampInt(
206
+ config.previewWidth,
207
+ 260,
208
+ BOUNDS.previewWidth,
209
+ );
210
+
211
+ this.on("input", async (msg, send, done) => {
212
+ const started = performance.now();
213
+ try {
214
+ const cfg = { ...defaults };
215
+ for (const key of overrideKeys) {
216
+ if (msg[key] !== undefined) cfg[key] = msg[key];
217
+ }
218
+ // msg.region wins wholesale when supplied, so one configured node
219
+ // can be re-aimed per message (four of them driven from a list,
220
+ // for instance) without four copies of the node.
221
+ const useRegion =
222
+ msg.region && typeof msg.region === "object"
223
+ ? { ...region, ...msg.region }
224
+ : region;
225
+
226
+ const { gray, width, height } = await toGray(msg.payload);
227
+ const result = findLine(gray, width, height, useRegion, cfg);
228
+ result.region = useRegion;
229
+ result.imageWidth = width;
230
+ result.imageHeight = height;
231
+ result.timings = { totalMs: performance.now() - started };
232
+
233
+ if (result.found && result.score < minScore) {
234
+ result.found = false;
235
+ result.reason = "below-min-score";
236
+ }
237
+
238
+ const previewEnabled =
239
+ msg.previewEnabled == null
240
+ ? configuredPreviewEnabled
241
+ : !!msg.previewEnabled;
242
+ if (previewEnabled && RED.comms && typeof RED.comms.publish === "function") {
243
+ try {
244
+ const data = await buildPreview(
245
+ msg.payload,
246
+ result,
247
+ useRegion,
248
+ cfg,
249
+ clampInt(msg.previewWidth, configuredPreviewWidth, BOUNDS.previewWidth),
250
+ );
251
+ RED.comms.publish("line-finder-preview", { id: this.id, ...data });
252
+ } catch (previewError) {
253
+ // a preview is a diagnostic, never a reason to fail a frame
254
+ this.warn(`line-finder preview: ${previewError.message}`);
255
+ }
256
+ } else if (RED.comms && typeof RED.comms.publish === "function") {
257
+ RED.comms.publish("line-finder-preview", { id: this.id, clear: true });
258
+ }
259
+
260
+ msg.lineFinder = result;
261
+ this.status({
262
+ fill: result.found ? "green" : "yellow",
263
+ shape: result.found ? "dot" : "ring",
264
+ text: result.found
265
+ ? `${result.angleDeg.toFixed(2)}° · ${result.calipers.used}/${result.calipers.total} · score ${result.score.toFixed(2)}`
266
+ : `not found (${result.reason})`,
267
+ });
268
+ send(msg);
269
+ done();
270
+ } catch (err) {
271
+ this.status({ fill: "red", shape: "ring", text: "error" });
272
+ // done(err) is Node-RED's single failure path; it routes to
273
+ // node.error without a second report here.
274
+ done(err);
275
+ }
276
+ });
277
+ }
278
+
279
+ RED.nodes.registerType("line-finder", LineFinderNode);
280
+ };
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@graciousstar/node-red-contrib-vision-tools",
3
+ "version": "1.0.0",
4
+ "description": "Machine-vision nodes for Node-RED on a fixed camera rig: golden-template AOI with position and blemish tolerances, mm/px calibration from a checkerboard, deskew-and-crop of a physical label, caliper edge and line finding over a drawn region, and barcode location and decoding restricted to known regions.",
5
+ "license": "Apache-2.0",
6
+ "author": "GraciousStar",
7
+ "keywords": [
8
+ "node-red",
9
+ "image",
10
+ "vision",
11
+ "machine-vision",
12
+ "qc",
13
+ "aoi",
14
+ "inspection",
15
+ "golden-template",
16
+ "calibration",
17
+ "checkerboard",
18
+ "caliper",
19
+ "edge-detection",
20
+ "line-finder",
21
+ "barcode",
22
+ "datamatrix",
23
+ "qr",
24
+ "code128",
25
+ "sharp"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/GraciousGpal/node-red-contrib-vision-tools.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/GraciousGpal/node-red-contrib-vision-tools/issues"
33
+ },
34
+ "homepage": "https://github.com/GraciousGpal/node-red-contrib-vision-tools#readme",
35
+ "node-red": {
36
+ "version": ">=2.0.0",
37
+ "nodes": {
38
+ "golden-compare": "golden-compare.js",
39
+ "checkerboard-calibrate": "checkerboard-calibrate.js",
40
+ "label-crop": "label-crop.js",
41
+ "line-finder": "line-finder.js",
42
+ "barcode-locate": "barcode-locate.js"
43
+ }
44
+ },
45
+ "scripts": {
46
+ "test": "node --test"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "dependencies": {
52
+ "sharp": "^0.35.3",
53
+ "zxing-wasm": "^3.1.3"
54
+ },
55
+ "optionalDependencies": {
56
+ "@rosepetal/node-red-contrib-image-tools": "^1.6.4"
57
+ },
58
+ "allowScripts": {
59
+ "sharp@0.35.3": true,
60
+ "@rosepetal/node-red-contrib-image-tools@1.6.4": true
61
+ },
62
+ "files": [
63
+ "*.js",
64
+ "*.html",
65
+ "lib/",
66
+ "icons/",
67
+ "examples/",
68
+ "README.md",
69
+ "ARCHITECTURE.md",
70
+ "CHANGELOG.md",
71
+ "LICENSE"
72
+ ]
73
+ }