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