@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,282 @@
1
+ /**
2
+ * checkerboard-calibrate Node-RED node.
3
+ *
4
+ * A calibration step for a fixed camera rig:
5
+ * photograph a printed checkerboard of known physical pitch, measure the
6
+ * detected pixel pitch, and compare the resulting mm/px scale against a
7
+ * previously-saved baseline. Detection/measurement lives in
8
+ * lib/checkerboard.js so it can be exercised outside Node-RED.
9
+ *
10
+ * Run this once at commissioning and again after camera/mechanical
11
+ * maintenance - not per production frame.
12
+ *
13
+ * Input (msg.payload): Buffer / Uint8Array / ArrayBuffer with image bytes,
14
+ * a file path string, or an object { data | buffer | path } - a photo of
15
+ * the printed checkerboard.
16
+ * msg.save (bool): persist the freshly detected scale as the new
17
+ * baseline (their "Match New Scale" + "Save").
18
+ */
19
+
20
+ const fs = require("fs");
21
+ const fsp = fs.promises;
22
+ const inspector = require("./lib/inspector.js");
23
+ const { toShared } = require("./lib/shared.js");
24
+ const { readScaleFile, writeScaleFile } = require("./lib/scaleFile.js");
25
+
26
+ module.exports = (RED) => {
27
+ function clampInt(value, fallback, min, max) {
28
+ const n = parseInt(value, 10);
29
+ if (isNaN(n)) return fallback;
30
+ return Math.min(max, Math.max(min, n));
31
+ }
32
+
33
+ function clampFloat(value, fallback, min, max) {
34
+ const n = parseFloat(value);
35
+ if (isNaN(n)) return fallback;
36
+ return Math.min(max, Math.max(min, n));
37
+ }
38
+
39
+ // Same input caps as golden-compare.js: an unbounded buffer would be
40
+ // copied on every message for no measurement value, and an unbounded
41
+ // path read would hang or OOM on a special file like /dev/zero.
42
+ const MAX_IMAGE_BYTES = 512 * 1024 * 1024;
43
+
44
+ /**
45
+ * Read an image file through one handle: open -> fstat -> guards ->
46
+ * read. The guards are the point - see golden-compare.js's twin. A
47
+ * pathExists() then readFile() pair is a race, and an unguarded path
48
+ * read lets a flow point msg.payload at /dev/zero and hang the node.
49
+ * Returns null when the path does not exist, so callers can keep
50
+ * distinguishing "missing" from "refused".
51
+ */
52
+ async function readRegularFile(p, label) {
53
+ let fd;
54
+ try {
55
+ fd = await fsp.open(p, fs.constants.O_RDONLY);
56
+ } catch (err) {
57
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return null;
58
+ throw err;
59
+ }
60
+ try {
61
+ const stat = await fd.stat();
62
+ if ((stat.mode & fs.constants.S_IFMT) !== fs.constants.S_IFREG) {
63
+ throw new Error(
64
+ `${label} is not a regular file: "${p}" - refusing to read it`,
65
+ );
66
+ }
67
+ if (stat.size > MAX_IMAGE_BYTES) {
68
+ throw new Error(
69
+ `${label} is ${stat.size} bytes, above the ${MAX_IMAGE_BYTES}-byte cap: "${p}"`,
70
+ );
71
+ }
72
+ return await fd.readFile();
73
+ } finally {
74
+ await fd.close();
75
+ }
76
+ }
77
+
78
+ // same resolveImage contract as golden-compare.js
79
+ async function resolveImage(source, label) {
80
+ if (source == null || source === "") {
81
+ throw new Error(`${label} is empty`);
82
+ }
83
+ if (
84
+ Buffer.isBuffer(source) ||
85
+ source instanceof Uint8Array ||
86
+ source instanceof ArrayBuffer
87
+ ) {
88
+ if (source.byteLength > MAX_IMAGE_BYTES) {
89
+ throw new Error(
90
+ `${label} is ${source.byteLength} bytes, above the ${MAX_IMAGE_BYTES}-byte cap`,
91
+ );
92
+ }
93
+ return Buffer.from(source);
94
+ }
95
+ if (typeof source === "string") {
96
+ const file = await readRegularFile(source, label);
97
+ if (!file) throw new Error(`${label} does not exist on disk: "${source}"`);
98
+ return file;
99
+ }
100
+ if (typeof source === "object") {
101
+ const data = source.data || source.buffer;
102
+ if (
103
+ Buffer.isBuffer(data) ||
104
+ data instanceof Uint8Array ||
105
+ data instanceof ArrayBuffer
106
+ ) {
107
+ if (data.byteLength > MAX_IMAGE_BYTES) {
108
+ throw new Error(
109
+ `${label} is ${data.byteLength} bytes, above the ${MAX_IMAGE_BYTES}-byte cap`,
110
+ );
111
+ }
112
+ return Buffer.from(data);
113
+ }
114
+ if (typeof source.path === "string") {
115
+ const file = await readRegularFile(source.path, label);
116
+ if (file) return file;
117
+ }
118
+ throw new Error(
119
+ `${label} object must contain "data"/"buffer" or an existing "path"`,
120
+ );
121
+ }
122
+ throw new Error(`unsupported ${label} type: ${typeof source}`);
123
+ }
124
+
125
+ function fmtMs(ms) {
126
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
127
+ }
128
+
129
+ function CheckerboardCalibrateNode(config) {
130
+ RED.nodes.createNode(this, config);
131
+ const node = this;
132
+
133
+ node.targetPitchMm = clampFloat(config.targetPitchMm, 10, 0.01, 10000);
134
+ node.checkerboardCols = clampInt(config.checkerboardCols, 4, 2, 100);
135
+ node.checkerboardRows = clampInt(config.checkerboardRows, 6, 3, 100);
136
+ node.allowedErrorPercent = clampFloat(config.allowedErrorPercent, 2, 0, 100);
137
+ node.scaleFilePath = String(config.scaleFilePath || "").trim();
138
+
139
+ node.on("input", async (msg, send, done) => {
140
+ send =
141
+ send ||
142
+ function () {
143
+ node.send.apply(node, arguments);
144
+ };
145
+ const totalStart = performance.now();
146
+ try {
147
+ if (!node.scaleFilePath) {
148
+ throw new Error(
149
+ "no scale file path configured - set where the calibration baseline should be saved/read",
150
+ );
151
+ }
152
+ const cfg = {
153
+ targetPitchMm: clampFloat(
154
+ msg.targetPitchMm,
155
+ node.targetPitchMm,
156
+ 0.01,
157
+ 10000,
158
+ ),
159
+ checkerboardCols: clampInt(
160
+ msg.checkerboardCols,
161
+ node.checkerboardCols,
162
+ 2,
163
+ 100,
164
+ ),
165
+ checkerboardRows: clampInt(
166
+ msg.checkerboardRows,
167
+ node.checkerboardRows,
168
+ 3,
169
+ 100,
170
+ ),
171
+ allowedErrorPercent: clampFloat(
172
+ msg.allowedErrorPercent,
173
+ node.allowedErrorPercent,
174
+ 0,
175
+ 100,
176
+ ),
177
+ };
178
+
179
+ const buffer = await resolveImage(msg.payload, "msg.payload");
180
+
181
+ node.status({
182
+ fill: "blue",
183
+ shape: "dot",
184
+ text: "detecting checkerboard…",
185
+ });
186
+ // Off the event loop, like golden-compare: this runs at full
187
+ // sensor resolution with no downscale, which measured ~59ms of
188
+ // synchronous work on a 5520x4140 capture and more when a board
189
+ // is actually found.
190
+ const measured = (
191
+ await inspector.calibrate({ cfg, image: toShared(buffer).buffer })
192
+ ).result;
193
+
194
+ if (!measured.detected) {
195
+ msg.payload = false;
196
+ msg.result = {
197
+ checkerboardDetected: false,
198
+ reason: measured.reason,
199
+ pass: false,
200
+ };
201
+ msg.timings = { totalMs: Math.round(performance.now() - totalStart) };
202
+ send(msg);
203
+ node.status({ fill: "red", shape: "ring", text: "not detected" });
204
+ node.warn(`checkerboard-calibrate: not detected - ${measured.reason}`);
205
+ done();
206
+ return;
207
+ }
208
+
209
+ const baseline = await readScaleFile(node.scaleFilePath);
210
+ const detectedScale = measured.mmPerPixel;
211
+ const currentScale = baseline ? baseline.mmPerPixelNative : null;
212
+ const bootstrap = currentScale == null;
213
+ if (bootstrap && !msg.save) {
214
+ // Bootstrap mode is informational only - nothing is persisted
215
+ // unless the message says so. The status line reads like
216
+ // success, so say plainly that the scale was not saved, or an
217
+ // operator walks away thinking the rig is calibrated.
218
+ node.warn(
219
+ `checkerboard-calibrate: detected ${detectedScale.toFixed(5)}mm/px but nothing was saved - ` +
220
+ `send msg.save:true to persist this as the baseline`,
221
+ );
222
+ }
223
+ const deviationPercent = bootstrap
224
+ ? null
225
+ : (Math.abs(detectedScale - currentScale) / currentScale) * 100;
226
+ const pass = bootstrap || deviationPercent <= cfg.allowedErrorPercent;
227
+
228
+ let saved = false;
229
+ if (msg.save) {
230
+ await writeScaleFile(node.scaleFilePath, {
231
+ mmPerPixelNative: detectedScale,
232
+ nativeWidth: measured.width,
233
+ nativeHeight: measured.height,
234
+ calibratedAt: new Date().toISOString(),
235
+ });
236
+ saved = true;
237
+ }
238
+
239
+ msg.payload = pass;
240
+ msg.result = {
241
+ checkerboardDetected: true,
242
+ currentScale: saved ? detectedScale : currentScale,
243
+ detectedScale,
244
+ deviationPercent,
245
+ bootstrap,
246
+ pass,
247
+ saved,
248
+ pitchXPx: measured.pitchXPx,
249
+ pitchYPx: measured.pitchYPx,
250
+ nativeWidth: measured.width,
251
+ nativeHeight: measured.height,
252
+ };
253
+ msg.timings = { totalMs: Math.round(performance.now() - totalStart) };
254
+ send(msg);
255
+
256
+ const statusText = bootstrap
257
+ ? `no baseline · ${detectedScale.toFixed(5)}mm/px${saved ? " · saved" : ""}`
258
+ : `${pass ? "pass" : "fail"} · dev ${deviationPercent.toFixed(2)}%${saved ? " · saved" : ""}`;
259
+ node.status({
260
+ fill: bootstrap ? "blue" : pass ? "green" : "red",
261
+ shape: bootstrap ? "dot" : pass ? "dot" : "ring",
262
+ text: statusText,
263
+ });
264
+ node.log(
265
+ `checkerboard-calibrate: detected=${detectedScale.toFixed(6)}mm/px ` +
266
+ `current=${currentScale == null ? "none" : currentScale.toFixed(6)} ` +
267
+ `deviation=${deviationPercent == null ? "n/a" : deviationPercent.toFixed(2) + "%"} ` +
268
+ `pass=${pass} saved=${saved} total ${fmtMs(msg.timings.totalMs)}`,
269
+ );
270
+ done();
271
+ } catch (err) {
272
+ node.status({ fill: "red", shape: "ring", text: "error" });
273
+ // done(err) routes the failure through node.error exactly
274
+ // once; an explicit node.error here reported every failure
275
+ // twice (double log lines, Catch nodes firing twice)
276
+ done(err);
277
+ }
278
+ });
279
+ }
280
+
281
+ RED.nodes.registerType("checkerboard-calibrate", CheckerboardCalibrateNode);
282
+ };
@@ -0,0 +1,239 @@
1
+ [
2
+ {
3
+ "id": "lfx-note-a",
4
+ "type": "comment",
5
+ "z": "lfx-tab",
6
+ "name": "1 · TUNE ONE EDGE ─ line-finder is the setup tool, not the cropper",
7
+ "info": "`line-finder` never crops anything. It measures ONE straight edge inside\nONE region you draw, so you can get that edge solid before handing the\nnumbers to `label-crop` (section 2), which runs four of them itself.\n\nOpen the `line finder` node, load a sample frame into its canvas and\ndrag across the edge you want - the drag *direction* sets the scan\ndirection. Then hit Inject and read the preview drawn on the canvas:\n\n blue parallelogram = the search region\n green ticks = calipers that agreed and were used in the fit\n red ticks = calipers dropped as outliers\n yellow line = the fitted line\n\nTune until it is found on every frame you try, with few red ticks:\n\n * found nothing -> lower `contrastThreshold`; if only some\n calipers find it, lower\n `minCaliperFraction` too\n * found the WRONG edge -> tighten the region, do NOT raise contrast\n * soft, multi-step -> `Edge select` = `first` or `last` instead\n transition of `best`. `best` picks the strongest step,\n which flips between two similar ones from\n frame to frame; `first`/`last` are\n positional, so they stay on the same step.\n This is what makes a boundary repeatable.\n * a few red ticks -> widen the region so the edge stays inside it\n on every frame, or raise\n `outlierTolerancePx` a little\n\nThe region loaded here is this rig's *top* edge - the hardest one, a\n4-grey-level step that is never found at the default contrast of 2.\nRepeat for the other three, then move to section 2.",
8
+ "x": 300,
9
+ "y": 60,
10
+ "wires": []
11
+ },
12
+ {
13
+ "id": "lfx-inject-a",
14
+ "type": "inject",
15
+ "z": "lfx-tab",
16
+ "name": "one frame",
17
+ "props": [
18
+ {
19
+ "p": "payload"
20
+ }
21
+ ],
22
+ "repeat": "",
23
+ "crontab": "",
24
+ "once": false,
25
+ "onceDelay": 0.1,
26
+ "topic": "",
27
+ "payload": "",
28
+ "payloadType": "date",
29
+ "x": 140,
30
+ "y": 120,
31
+ "wires": [
32
+ [
33
+ "lfx-read-a"
34
+ ]
35
+ ]
36
+ },
37
+ {
38
+ "id": "lfx-read-a",
39
+ "type": "file in",
40
+ "z": "lfx-tab",
41
+ "name": "sample frame",
42
+ "filename": "/data/Inspection/sample_images/good/image_20260907_093135-748Z.jpg",
43
+ "filenameType": "str",
44
+ "format": "",
45
+ "chunk": false,
46
+ "sendError": true,
47
+ "encoding": "none",
48
+ "allProps": false,
49
+ "x": 320,
50
+ "y": 120,
51
+ "wires": [
52
+ [
53
+ "lfx-finder"
54
+ ]
55
+ ]
56
+ },
57
+ {
58
+ "id": "lfx-finder",
59
+ "type": "line-finder",
60
+ "z": "lfx-tab",
61
+ "name": "top edge",
62
+ "regionX": 300,
63
+ "regionY": 8,
64
+ "regionWidth": 2400,
65
+ "regionHeight": 90,
66
+ "regionAngleDeg": 0,
67
+ "scanDirection": "down",
68
+ "polarity": "either",
69
+ "edgeSelect": "first",
70
+ "ignoreCount": 0,
71
+ "calipers": 16,
72
+ "contrastThreshold": 0.35,
73
+ "filterHalfWidth": 2,
74
+ "outlierTolerancePx": 2.5,
75
+ "minCaliperFraction": 0.25,
76
+ "angleToleranceDeg": 10,
77
+ "minScore": 0,
78
+ "previewEnabled": true,
79
+ "previewWidth": 260,
80
+ "x": 530,
81
+ "y": 120,
82
+ "wires": [
83
+ [
84
+ "lfx-debug-a"
85
+ ]
86
+ ]
87
+ },
88
+ {
89
+ "id": "lfx-debug-a",
90
+ "type": "debug",
91
+ "z": "lfx-tab",
92
+ "name": "msg.lineFinder",
93
+ "active": true,
94
+ "tosidebar": true,
95
+ "console": false,
96
+ "tostatus": false,
97
+ "complete": "lineFinder",
98
+ "targetType": "msg",
99
+ "statusVal": "",
100
+ "statusType": "auto",
101
+ "x": 740,
102
+ "y": 120,
103
+ "wires": []
104
+ },
105
+ {
106
+ "id": "lfx-note-b",
107
+ "type": "comment",
108
+ "z": "lfx-tab",
109
+ "name": "2 · CROP ─ label-crop in calipers mode runs four line finders itself",
110
+ "info": "`label-crop` with **Boundary = calipers** runs no blob search at all. It\ngreys the frame once, runs one line finder per side over the regions in\nthe `edgeRegions` JSON, intersects the four fitted lines into a\nrectangle, then deskews and crops to it - the same rotate/crop tail the\nblob mode uses.\n\nThe scan direction per side is implied (left scans rightwards, top\nscans downwards, ...), so each side needs only its box plus whichever\nline-finder options you tuned in section 1. Every option in\n`lib/lineFinder.js` is accepted per side.\n\nCoordinates are FULL-RESOLUTION frame pixels. Calipers mode ignores\n`maxEdge` and measures at full res on purpose - a caliper looks for a\nposition, not a shape, so a 640px detection copy would coarsen every\nreading 6x.\n\nIf one edge is not found the frame is NOT cropped:\n`msg.labelCrop.reason` names the side (e.g.\n`calipers:missing-edge:top`) and `msg.labelCrop.edges` carries per-side\ndiagnostics (found / reason / score / calipers / residualPx) so you can\nsee which region to re-aim. Guessing a missing edge would silently\nmis-crop, so it refuses instead.\n\nMeasured with the regions below over this rig's 148 good frames:\n\n found on 148/148\n label width spread 1.8 px\n height spread 5.2 px\n deskew angle spread 0.14 deg\n\nand of the 14 bad frames it refuses 6 outright - the blanks, which have\nno label boundary to find.\n\nNote the lowered `minCaliperFraction`. Left, right and top are found\nby all 16 calipers on every one of the 148 frames, but the bottom edge\nis picked up by only a third of them - 5 of 16 at worst - so at the 0.5\ndefault it is reported missing on 76 of them. Lowering the fraction is\nthe right fix there, not raising contrast.",
111
+ "x": 300,
112
+ "y": 240,
113
+ "wires": []
114
+ },
115
+ {
116
+ "id": "lfx-inject-b",
117
+ "type": "inject",
118
+ "z": "lfx-tab",
119
+ "name": "one frame",
120
+ "props": [
121
+ {
122
+ "p": "payload"
123
+ }
124
+ ],
125
+ "repeat": "",
126
+ "crontab": "",
127
+ "once": false,
128
+ "onceDelay": 0.1,
129
+ "topic": "",
130
+ "payload": "",
131
+ "payloadType": "date",
132
+ "x": 140,
133
+ "y": 300,
134
+ "wires": [
135
+ [
136
+ "lfx-read-b"
137
+ ]
138
+ ]
139
+ },
140
+ {
141
+ "id": "lfx-read-b",
142
+ "type": "file in",
143
+ "z": "lfx-tab",
144
+ "name": "sample frame",
145
+ "filename": "/data/Inspection/sample_images/good/image_20260907_093135-748Z.jpg",
146
+ "filenameType": "str",
147
+ "format": "",
148
+ "chunk": false,
149
+ "sendError": true,
150
+ "encoding": "none",
151
+ "allProps": false,
152
+ "x": 320,
153
+ "y": 300,
154
+ "wires": [
155
+ [
156
+ "lfx-crop"
157
+ ]
158
+ ]
159
+ },
160
+ {
161
+ "id": "lfx-crop",
162
+ "type": "label-crop",
163
+ "z": "lfx-tab",
164
+ "name": "crop by calipers",
165
+ "boundaryMode": "calipers",
166
+ "edgeRegions": "{\n \"left\": { \"x\": 40, \"y\": 400, \"width\": 90, \"height\": 2800, \"contrastThreshold\": 1.0, \"edgeSelect\": \"last\", \"minCaliperFraction\": 0.25 },\n \"right\": { \"x\": 2900, \"y\": 400, \"width\": 90, \"height\": 2800, \"contrastThreshold\": 0.6, \"edgeSelect\": \"last\", \"minCaliperFraction\": 0.25 },\n \"top\": { \"x\": 300, \"y\": 8, \"width\": 2400, \"height\": 90, \"contrastThreshold\": 0.35, \"edgeSelect\": \"first\", \"minCaliperFraction\": 0.25 },\n \"bottom\": { \"x\": 300, \"y\": 3580, \"width\": 2400, \"height\": 90, \"contrastThreshold\": 2.5, \"edgeSelect\": \"best\", \"minCaliperFraction\": 0.2 }\n}",
167
+ "maxEdge": 640,
168
+ "polarity": "auto",
169
+ "minAreaFraction": 0.05,
170
+ "maxAreaFraction": 0.9,
171
+ "minRectangularity": 0.4,
172
+ "maxBorderContact": 0.5,
173
+ "minDominance": 1.5,
174
+ "minConfidence": 0.4,
175
+ "aspectRatio": "",
176
+ "aspectTolerance": 0.15,
177
+ "expectedSizeFraction": "",
178
+ "sizeTolerance": 0.2,
179
+ "cropMargin": 0,
180
+ "minRotateAngleDeg": 0.5,
181
+ "previewEnabled": true,
182
+ "previewWidth": 220,
183
+ "outputFormat": "jpg",
184
+ "outputQuality": 92,
185
+ "pngOptimize": false,
186
+ "x": 540,
187
+ "y": 300,
188
+ "wires": [
189
+ [
190
+ "lfx-debug-b",
191
+ "lfx-write"
192
+ ]
193
+ ]
194
+ },
195
+ {
196
+ "id": "lfx-debug-b",
197
+ "type": "debug",
198
+ "z": "lfx-tab",
199
+ "name": "msg.labelCrop",
200
+ "active": true,
201
+ "tosidebar": true,
202
+ "console": false,
203
+ "tostatus": false,
204
+ "complete": "labelCrop",
205
+ "targetType": "msg",
206
+ "statusVal": "",
207
+ "statusType": "auto",
208
+ "x": 760,
209
+ "y": 260,
210
+ "wires": []
211
+ },
212
+ {
213
+ "id": "lfx-write",
214
+ "type": "file",
215
+ "z": "lfx-tab",
216
+ "name": "/data/label-crop.jpg",
217
+ "filename": "/data/label-crop.jpg",
218
+ "filenameType": "str",
219
+ "appendNewline": false,
220
+ "createDir": true,
221
+ "overwriteFile": "true",
222
+ "encoding": "none",
223
+ "x": 790,
224
+ "y": 320,
225
+ "wires": [
226
+ []
227
+ ]
228
+ },
229
+ {
230
+ "id": "lfx-note-c",
231
+ "type": "comment",
232
+ "z": "lfx-tab",
233
+ "name": "3 · WHAT NOT TO EXPECT from cropping (measured on this rig)",
234
+ "info": "The crop above is geometrically excellent and still does not improve\nthe inspection on this label. Measured over 148 good / 14 bad frames\nagainst a Demo_Good_60 golden:\n\n no crop 148/148 good pass\n blob crop 72/148\n fixed rect 55/148\n calipers crop 28/148\n\nThe cause is not the crop quality. The recurring background-defect\nregions sit on logotype strokes and barcode bar fields - real fine\nartwork in the label's left margin. Cropping shifts\nwhich pixels land in which comparison block, which moves that artwork\nrelative to the golden and makes the mismatch worse.\n\nSo use this flow when you want a deskewed, consistently framed label\nimage - archiving, OCR, a downstream tool that wants the label alone -\nnot as a way to raise the inspection score. See ARCHITECTURE.md,\n\"What it does not solve\".",
235
+ "x": 280,
236
+ "y": 400,
237
+ "wires": []
238
+ }
239
+ ]