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