@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/label-crop.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * label-crop: deskew-and-crop a physical label out of a camera frame.
3
+ *
4
+ * Input (msg.payload): an encoded image Buffer or a raw
5
+ * { data, width, height, channels } object - the same image shapes the
6
+ * golden-compare node accepts.
7
+ *
8
+ * Output: on a successful detection, msg.payload is replaced with the
9
+ * deskewed, tightly cropped label (raw by default, or jpg/png/webp) and
10
+ * msg.labelCrop carries the detection metadata. On a normal detection
11
+ * miss the original payload passes through unchanged with
12
+ * msg.labelCrop.detected === false, so a flow downstream keeps working
13
+ * while the detection is getting tuned.
14
+ *
15
+ * The heavy pixel work runs in the @rosepetal/node-red-contrib-image-tools
16
+ * native OpenCV engine (decoded once, low-res Otsu, ROI-only rotation,
17
+ * native final crop); see lib/labelCrop.js. If that engine is not
18
+ * installed or cannot be loaded, this is a **setup error** (done(err)),
19
+ * never a silent pass-through - otherwise a missing binary would look
20
+ * like "no label found" on every frame.
21
+ */
22
+
23
+ const { performance } = require("node:perf_hooks");
24
+
25
+ module.exports = (RED) => {
26
+ const { labelCrop, available, getBridge } = require("./lib/labelCrop.js");
27
+
28
+ const POLARITIES = ["auto", "light", "dark"];
29
+ const BOUNDARY_MODES = ["blob", "calipers"];
30
+ const OUTPUT_FORMATS = ["raw", "jpg", "png", "webp"];
31
+ const BOUNDS = {
32
+ maxEdge: [64, 4096],
33
+ minAreaFraction: [0.001, 0.9],
34
+ maxAreaFraction: [0.01, 0.999],
35
+ minRectangularity: [0.05, 1],
36
+ maxBorderContact: [0, 1],
37
+ minDominance: [1.01, 100],
38
+ minConfidence: [0.01, 1],
39
+ aspectRatio: [0.05, 20],
40
+ aspectTolerance: [0.01, 1],
41
+ expectedSizeFraction: [0.001, 0.99],
42
+ sizeTolerance: [0.01, 1],
43
+ cropMargin: [0, 0.25],
44
+ minRotateAngleDeg: [0, 10],
45
+ outputQuality: [1, 100],
46
+ previewWidth: [80, 600],
47
+ };
48
+
49
+ function clampInt(value, fallback, [min, max]) {
50
+ const n = parseInt(value, 10);
51
+ if (Number.isNaN(n)) return fallback;
52
+ return Math.min(max, Math.max(min, n));
53
+ }
54
+
55
+ function clampFloat(value, fallback, [min, max]) {
56
+ const n = parseFloat(value);
57
+ if (Number.isNaN(n)) return fallback;
58
+ return Math.min(max, Math.max(min, n));
59
+ }
60
+
61
+ function pickMode(value, fallback, allowed) {
62
+ return allowed.includes(value) ? value : fallback;
63
+ }
64
+
65
+ function asBoolean(value, fallback = false) {
66
+ if (value == null || value === "") return fallback;
67
+ return value === true || value === "true";
68
+ }
69
+
70
+ async function previewJpeg(image, width) {
71
+ const result = await getBridge().resize(
72
+ image,
73
+ "num",
74
+ width,
75
+ "num",
76
+ 0,
77
+ "jpg",
78
+ 75,
79
+ false,
80
+ );
81
+ if (!Buffer.isBuffer(result.image)) {
82
+ throw new Error("preview resize did not return a JPEG Buffer");
83
+ }
84
+ return result.image.toString("base64");
85
+ }
86
+
87
+ async function publishPreview(node, beforeImage, afterImage, width) {
88
+ if (!RED.comms || typeof RED.comms.publish !== "function") return;
89
+ const before = await previewJpeg(beforeImage, width);
90
+ const after =
91
+ afterImage === beforeImage ? before : await previewJpeg(afterImage, width);
92
+ RED.comms.publish("label-crop-preview", {
93
+ id: node.id,
94
+ before,
95
+ after,
96
+ mimeType: "jpeg",
97
+ previewWidth: width,
98
+ });
99
+ }
100
+
101
+ function LabelCropNode(config) {
102
+ RED.nodes.createNode(this, config);
103
+
104
+ // The four search regions are stored as JSON in the editor because
105
+ // they are a nested structure, not a scalar; a parse failure is a
106
+ // configuration error worth surfacing at deploy rather than on the
107
+ // first frame.
108
+ let configuredRegions = null;
109
+ if (config.edgeRegions && String(config.edgeRegions).trim()) {
110
+ try {
111
+ configuredRegions = JSON.parse(config.edgeRegions);
112
+ } catch (err) {
113
+ this.error(`label-crop: edgeRegions is not valid JSON - ${err.message}`);
114
+ }
115
+ }
116
+
117
+ const defaults = {
118
+ boundaryMode: pickMode(config.boundaryMode, "blob", BOUNDARY_MODES),
119
+ edgeRegions: configuredRegions,
120
+ maxEdge: clampInt(config.maxEdge, 640, BOUNDS.maxEdge),
121
+ polarity: pickMode(config.polarity, "auto", POLARITIES),
122
+ minAreaFraction: clampFloat(
123
+ config.minAreaFraction,
124
+ 0.05,
125
+ BOUNDS.minAreaFraction,
126
+ ),
127
+ maxAreaFraction: clampFloat(
128
+ config.maxAreaFraction,
129
+ 0.9,
130
+ BOUNDS.maxAreaFraction,
131
+ ),
132
+ minRectangularity: clampFloat(
133
+ config.minRectangularity,
134
+ 0.4,
135
+ BOUNDS.minRectangularity,
136
+ ),
137
+ maxBorderContact: clampFloat(
138
+ config.maxBorderContact,
139
+ 0.5,
140
+ BOUNDS.maxBorderContact,
141
+ ),
142
+ minDominance: clampFloat(config.minDominance, 1.5, BOUNDS.minDominance),
143
+ minConfidence: clampFloat(config.minConfidence, 0.4, BOUNDS.minConfidence),
144
+ aspectRatio:
145
+ config.aspectRatio === "" || config.aspectRatio == null
146
+ ? null
147
+ : clampFloat(config.aspectRatio, 1, BOUNDS.aspectRatio),
148
+ aspectTolerance: clampFloat(
149
+ config.aspectTolerance,
150
+ 0.15,
151
+ BOUNDS.aspectTolerance,
152
+ ),
153
+ expectedSizeFraction:
154
+ config.expectedSizeFraction === "" || config.expectedSizeFraction == null
155
+ ? null
156
+ : clampFloat(
157
+ config.expectedSizeFraction,
158
+ 0.5,
159
+ BOUNDS.expectedSizeFraction,
160
+ ),
161
+ sizeTolerance: clampFloat(config.sizeTolerance, 0.2, BOUNDS.sizeTolerance),
162
+ cropMargin: clampFloat(config.cropMargin, 0.02, BOUNDS.cropMargin),
163
+ minRotateAngleDeg: clampFloat(
164
+ config.minRotateAngleDeg,
165
+ 0.5,
166
+ BOUNDS.minRotateAngleDeg,
167
+ ),
168
+ outputFormat: pickMode(config.outputFormat, "raw", OUTPUT_FORMATS),
169
+ outputQuality: clampInt(config.outputQuality, 90, BOUNDS.outputQuality),
170
+ pngOptimize: !!config.pngOptimize,
171
+ };
172
+
173
+ const overrideKeys = Object.keys(defaults);
174
+ const configuredPreviewEnabled = !!config.previewEnabled;
175
+ const configuredPreviewWidth = clampInt(
176
+ config.previewWidth,
177
+ 220,
178
+ BOUNDS.previewWidth,
179
+ );
180
+
181
+ this.on("input", async (msg, send, done) => {
182
+ try {
183
+ if (!available()) {
184
+ throw new Error(
185
+ "label-crop: OpenCV engine unavailable - install " +
186
+ "@rosepetal/node-red-contrib-image-tools (prebuilt binaries ship " +
187
+ "for Linux x64/arm64, Alpine x64, and macOS arm64)",
188
+ );
189
+ }
190
+ const options = { ...defaults };
191
+ for (const key of overrideKeys) {
192
+ if (msg[key] !== undefined && msg[key] !== null && msg[key] !== "") {
193
+ options[key] = msg[key];
194
+ }
195
+ }
196
+ const originalImage = msg.payload;
197
+ const res = await labelCrop(originalImage, options);
198
+ const previewEnabled = asBoolean(
199
+ msg.previewEnabled,
200
+ configuredPreviewEnabled,
201
+ );
202
+ const previewWidth = clampInt(
203
+ msg.previewWidth,
204
+ configuredPreviewWidth,
205
+ BOUNDS.previewWidth,
206
+ );
207
+ if (previewEnabled) {
208
+ const previewStarted = performance.now();
209
+ try {
210
+ await publishPreview(this, originalImage, res.image, previewWidth);
211
+ res.metadata.timings.previewMs = performance.now() - previewStarted;
212
+ } catch (previewError) {
213
+ this.warn(`label-crop preview: ${previewError.message}`);
214
+ }
215
+ } else if (RED.comms && typeof RED.comms.publish === "function") {
216
+ RED.comms.publish("label-crop-preview", { id: this.id, clear: true });
217
+ }
218
+ msg.payload = res.image;
219
+ msg.labelCrop = res.metadata;
220
+ this.status({
221
+ fill: res.detected ? "green" : "yellow",
222
+ shape: res.detected ? "dot" : "ring",
223
+ text: res.detected
224
+ ? `deskewed ${res.metadata.width}×${res.metadata.height}`
225
+ : `not detected (${res.metadata.reason})`,
226
+ });
227
+ // In calipers mode a miss names the edge that failed, and that
228
+ // is nearly always a region that needs re-aiming rather than a
229
+ // bad part - so say so once per distinct reason, not per frame.
230
+ if (!res.detected && options.boundaryMode === "calipers") {
231
+ if (this.lastCaliperWarning !== res.metadata.reason) {
232
+ this.lastCaliperWarning = res.metadata.reason;
233
+ this.warn(`label-crop: ${res.metadata.reason}`);
234
+ }
235
+ } else if (res.detected) {
236
+ this.lastCaliperWarning = null;
237
+ }
238
+ send(msg);
239
+ done();
240
+ } catch (err) {
241
+ this.status({ fill: "red", shape: "ring", text: "error" });
242
+ // done(err) is Node-RED's single failure path; it routes to
243
+ // node.error without a second report here.
244
+ done(err);
245
+ }
246
+ });
247
+ }
248
+
249
+ RED.nodes.registerType("label-crop", LabelCropNode);
250
+ };