@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,1178 @@
1
+ <script type="text/html" data-template-name="label-crop">
2
+ <div class="form-row">
3
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name" />
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-input-boundaryMode"
8
+ ><i class="fa fa-object-ungroup"></i> Boundary</label
9
+ >
10
+ <select id="node-input-boundaryMode" style="width:180px;">
11
+ <option value="blob">blob (whole-frame threshold)</option>
12
+ <option value="calipers">calipers (four drawn regions)</option>
13
+ </select>
14
+ </div>
15
+
16
+ <div class="form-row" id="label-crop-regions-row">
17
+ <label for="node-input-edgeRegions"
18
+ ><i class="fa fa-minus"></i> Edge regions</label
19
+ >
20
+ <textarea
21
+ id="node-input-edgeRegions"
22
+ rows="8"
23
+ style="width:70%; font-family:monospace; font-size:12px;"
24
+ placeholder='{"left":{"x":55,"y":400,"width":60,"height":2800},...}'
25
+ ></textarea>
26
+ <div style="margin-left:105px; margin-top:4px; color:#999; font-size:12px;">
27
+ One region per edge, in <b>full-resolution frame pixels</b>. Tune each
28
+ one with a <code>line-finder</code> node - it has a drawing canvas and
29
+ reports the score - then paste the four here.
30
+ <span id="label-crop-regions-status" style="font-family:monospace;"></span>
31
+ </div>
32
+ </div>
33
+
34
+ <div class="form-row">
35
+ <label for="node-input-maxEdge"
36
+ ><i class="fa fa-arrows-alt"></i> Detection size</label
37
+ >
38
+ <input
39
+ type="number"
40
+ id="node-input-maxEdge"
41
+ min="64"
42
+ max="4096"
43
+ style="width:90px;"
44
+ />
45
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
46
+ >long edge, px - label finding runs on this copy, the crop does not</span
47
+ >
48
+ </div>
49
+ <div class="form-row">
50
+ <label for="node-input-polarity"
51
+ ><i class="fa fa-adjust"></i> Label polarity</label
52
+ >
53
+ <select id="node-input-polarity" style="width:130px;">
54
+ <option value="auto">auto</option>
55
+ <option value="light">light label</option>
56
+ <option value="dark">dark label</option>
57
+ </select>
58
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
59
+ >auto tries both Otsu polarities and keeps the better rectangle</span
60
+ >
61
+ </div>
62
+ <div class="form-row">
63
+ <label for="node-input-minAreaFraction"
64
+ ><i class="fa fa-percent"></i> Min area fraction</label
65
+ >
66
+ <input
67
+ type="number"
68
+ id="node-input-minAreaFraction"
69
+ min="0.001"
70
+ max="0.9"
71
+ step="0.01"
72
+ style="width:90px;"
73
+ />
74
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
75
+ >label must cover at least this fraction of the frame</span
76
+ >
77
+ </div>
78
+ <div class="form-row">
79
+ <label for="node-input-maxAreaFraction"
80
+ ><i class="fa fa-percent"></i> Max area fraction</label
81
+ >
82
+ <input
83
+ type="number"
84
+ id="node-input-maxAreaFraction"
85
+ min="0.01"
86
+ max="0.999"
87
+ step="0.01"
88
+ style="width:90px;"
89
+ />
90
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
91
+ >rejects foreground regions that implausibly fill the frame</span
92
+ >
93
+ </div>
94
+ <div class="form-row">
95
+ <label for="node-input-minRectangularity"
96
+ ><i class="fa fa-square-o"></i> Min rectangularity</label
97
+ >
98
+ <input
99
+ type="number"
100
+ id="node-input-minRectangularity"
101
+ min="0.05"
102
+ max="1"
103
+ step="0.05"
104
+ style="width:90px;"
105
+ />
106
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
107
+ >blob area / its exterior rectangle area; printed labels pass at
108
+ ~0.4+</span
109
+ >
110
+ </div>
111
+ <div class="form-row">
112
+ <label for="node-input-maxBorderContact"
113
+ ><i class="fa fa-border"></i> Max border contact</label
114
+ >
115
+ <input
116
+ type="number"
117
+ id="node-input-maxBorderContact"
118
+ min="0"
119
+ max="1"
120
+ step="0.1"
121
+ style="width:90px;"
122
+ />
123
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
124
+ >0.5 permits clipping at two opposite frame edges; 1 means all edges</span
125
+ >
126
+ </div>
127
+ <div class="form-row">
128
+ <label for="node-input-minDominance"
129
+ ><i class="fa fa-bullseye"></i> Min dominance</label
130
+ >
131
+ <input
132
+ type="number"
133
+ id="node-input-minDominance"
134
+ min="1.01"
135
+ max="100"
136
+ step="0.1"
137
+ style="width:90px;"
138
+ />
139
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
140
+ >best blob vs second-best area ratio; rejects two competing labels</span
141
+ >
142
+ </div>
143
+ <div class="form-row">
144
+ <label for="node-input-minConfidence"
145
+ ><i class="fa fa-check-circle-o"></i> Min confidence</label
146
+ >
147
+ <input
148
+ type="number"
149
+ id="node-input-minConfidence"
150
+ min="0.01"
151
+ max="1"
152
+ step="0.05"
153
+ style="width:90px;"
154
+ />
155
+ </div>
156
+ <div class="form-row">
157
+ <label for="node-input-aspectRatio"
158
+ ><i class="fa fa-arrows-h"></i> Expected aspect ratio</label
159
+ >
160
+ <input
161
+ type="number"
162
+ id="node-input-aspectRatio"
163
+ min="0.05"
164
+ max="20"
165
+ step="0.05"
166
+ style="width:90px;"
167
+ />
168
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
169
+ >label w/h in the deskewed frame; blank = any</span
170
+ >
171
+ </div>
172
+ <div class="form-row">
173
+ <label for="node-input-aspectTolerance"
174
+ ><i class="fa fa-arrows-h"></i> Aspect tolerance</label
175
+ >
176
+ <input
177
+ type="number"
178
+ id="node-input-aspectTolerance"
179
+ min="0.01"
180
+ max="1"
181
+ step="0.01"
182
+ style="width:90px;"
183
+ />
184
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
185
+ >log-ratio tolerance around the expected aspect</span
186
+ >
187
+ </div>
188
+ <div class="form-row">
189
+ <label for="node-input-expectedSizeFraction"
190
+ ><i class="fa fa-arrows-alt"></i> Expected label size</label
191
+ >
192
+ <input
193
+ type="number"
194
+ id="node-input-expectedSizeFraction"
195
+ min="0.001"
196
+ max="0.99"
197
+ step="0.01"
198
+ style="width:90px;"
199
+ />
200
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
201
+ >fraction of the frame the label covers; blank = no size gate. Draw it
202
+ below on a representative sample.</span
203
+ >
204
+ </div>
205
+ <div class="form-row">
206
+ <label for="node-input-sizeTolerance"
207
+ ><i class="fa fa-arrows-alt"></i> Size tolerance</label
208
+ >
209
+ <input
210
+ type="number"
211
+ id="node-input-sizeTolerance"
212
+ min="0.01"
213
+ max="1"
214
+ step="0.01"
215
+ style="width:90px;"
216
+ />
217
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
218
+ >log-ratio tolerance around the expected size</span
219
+ >
220
+ </div>
221
+ <div class="form-row" id="label-size-canvas-row">
222
+ <label style="vertical-align:top;"
223
+ ><i class="fa fa-crop"></i> Label size selector</label
224
+ >
225
+ <div style="display:inline-block; vertical-align:top;">
226
+ <canvas
227
+ id="label-crop-size-canvas"
228
+ width="320"
229
+ height="240"
230
+ title="Click to open the zoom viewer"
231
+ style="border:1px solid #888; background:#222; cursor:pointer; touch-action:none;"
232
+ ></canvas>
233
+ <div style="margin-top:4px;">
234
+ <button type="button" id="label-crop-load-image">
235
+ Load sample image
236
+ </button>
237
+ <input
238
+ type="file"
239
+ id="label-crop-image-file"
240
+ accept="image/*"
241
+ style="display:none;"
242
+ />
243
+ <button type="button" id="label-crop-open-viewer">Open viewer</button>
244
+ <button type="button" id="label-crop-clear-size">Clear</button>
245
+ </div>
246
+ <div
247
+ id="label-crop-size-readout"
248
+ style="color:#999; font-size:11px;"
249
+ ></div>
250
+ <div style="color:#999; font-size:11px; margin-top:2px;">
251
+ Load a representative photo (any size), then open the viewer to zoom in
252
+ and drag over the label: the drawn rectangle fills the aspect and size
253
+ fields above.
254
+ </div>
255
+ </div>
256
+ </div>
257
+ <div class="form-row">
258
+ <label for="node-input-cropMargin"
259
+ ><i class="fa fa-expand"></i> ROI margin</label
260
+ >
261
+ <input
262
+ type="number"
263
+ id="node-input-cropMargin"
264
+ min="0"
265
+ max="0.25"
266
+ step="0.01"
267
+ style="width:90px;"
268
+ />
269
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
270
+ >extra ring around the label before rotation, fraction of its long
271
+ edge</span
272
+ >
273
+ </div>
274
+ <div class="form-row">
275
+ <label for="node-input-minRotateAngleDeg"
276
+ ><i class="fa fa-repeat"></i> Min deskew angle</label
277
+ >
278
+ <input
279
+ type="number"
280
+ id="node-input-minRotateAngleDeg"
281
+ min="0"
282
+ max="10"
283
+ step="0.1"
284
+ style="width:90px;"
285
+ />
286
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
287
+ >below this, rotation is skipped and the label is cropped directly</span
288
+ >
289
+ </div>
290
+ <div class="form-row">
291
+ <label for="node-input-previewEnabled"
292
+ ><i class="fa fa-eye"></i> Preview</label
293
+ >
294
+ <input
295
+ type="checkbox"
296
+ id="node-input-previewEnabled"
297
+ style="display:inline-block; width:auto; vertical-align:baseline;"
298
+ />
299
+ <label for="node-input-previewEnabled" style="width:auto; margin-left:5px;"
300
+ >Show before and after on the flow canvas</label
301
+ >
302
+ </div>
303
+ <div class="form-row" id="label-crop-preview-width-row">
304
+ <label for="node-input-previewWidth"
305
+ ><i class="fa fa-arrows-h"></i> Preview width</label
306
+ >
307
+ <input
308
+ type="number"
309
+ id="node-input-previewWidth"
310
+ min="80"
311
+ max="600"
312
+ step="10"
313
+ style="width:90px;"
314
+ />
315
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
316
+ >pixels per image; preview encoding runs only when enabled</span
317
+ >
318
+ </div>
319
+ <div class="form-row">
320
+ <label for="node-input-outputFormat"
321
+ ><i class="fa fa-file-image-o"></i> Output format</label
322
+ >
323
+ <select id="node-input-outputFormat" style="width:130px;">
324
+ <option value="raw">raw</option>
325
+ <option value="jpg">jpeg</option>
326
+ <option value="png">png</option>
327
+ <option value="webp">webp</option>
328
+ </select>
329
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
330
+ >raw is a {data,width,height,channels} object; encoded is a Buffer</span
331
+ >
332
+ </div>
333
+ <div class="form-row">
334
+ <label for="node-input-outputQuality"
335
+ ><i class="fa fa-tasks"></i> Quality</label
336
+ >
337
+ <input
338
+ type="number"
339
+ id="node-input-outputQuality"
340
+ min="1"
341
+ max="100"
342
+ step="1"
343
+ style="width:90px;"
344
+ />
345
+ </div>
346
+ <div class="form-row">
347
+ <label for="node-input-pngOptimize"
348
+ ><i class="fa fa-compress"></i> Optimise PNG</label
349
+ >
350
+ <input type="checkbox" id="node-input-pngOptimize" style="width:auto;" />
351
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
352
+ >slower PNG compression; only matters for png output</span
353
+ >
354
+ </div>
355
+ </script>
356
+
357
+ <script type="text/html" data-help-name="label-crop">
358
+ <p>
359
+ Detects the physical label boundary on a low-resolution copy, then uses the
360
+ native OpenCV engine to deskew and tightly crop the label. It
361
+ performs rotation only, not perspective correction.
362
+ </p>
363
+ <h3>Input</h3>
364
+ <p>
365
+ <code>msg.payload</code> is an encoded image Buffer or a uint8 raw image
366
+ object with <code>data</code>, <code>width</code>, <code>height</code>, and
367
+ <code>channels</code>. Configuration fields may be overridden by same-named
368
+ <code>msg</code> properties.
369
+ </p>
370
+ <h3>Output</h3>
371
+ <p>
372
+ On success, <code>msg.payload</code> is the cropped label and
373
+ <code>msg.labelCrop</code> contains corners, angle, confidence, crop
374
+ geometry, and timings. A normal detection miss passes the original payload
375
+ through with <code>msg.labelCrop.detected</code> set to false. A missing
376
+ native engine is reported as an error.
377
+ </p>
378
+ <p>
379
+ Enable <i>Preview</i> to show labelled before/after images beside the node
380
+ on the flow canvas. Preview JPEG generation is diagnostic work and adds
381
+ latency; it does not alter <code>msg.payload</code>. Click the preview to
382
+ hide it.
383
+ </p>
384
+ </script>
385
+
386
+ <script type="text/javascript">
387
+ RED.nodes.registerType("label-crop", {
388
+ category: "vision",
389
+ color: "#a6bbcf",
390
+ defaults: {
391
+ name: { value: "" },
392
+ boundaryMode: { value: "blob" },
393
+ edgeRegions: { value: "" },
394
+ // number(true) = blank allowed, for the same reason as the
395
+ // golden-compare node: Node-RED does not backfill new defaults into
396
+ // saved node instances.
397
+ maxEdge: { value: 640, validate: RED.validators.number(true) },
398
+ polarity: { value: "auto" },
399
+ minAreaFraction: { value: 0.05, validate: RED.validators.number(true) },
400
+ maxAreaFraction: { value: 0.9, validate: RED.validators.number(true) },
401
+ minRectangularity: { value: 0.4, validate: RED.validators.number(true) },
402
+ maxBorderContact: { value: 0.5, validate: RED.validators.number(true) },
403
+ minDominance: { value: 1.5, validate: RED.validators.number(true) },
404
+ minConfidence: { value: 0.4, validate: RED.validators.number(true) },
405
+ aspectRatio: { value: "" },
406
+ aspectTolerance: { value: 0.15, validate: RED.validators.number(true) },
407
+ expectedSizeFraction: { value: "" },
408
+ sizeTolerance: { value: 0.2, validate: RED.validators.number(true) },
409
+ cropMargin: { value: 0.02, validate: RED.validators.number(true) },
410
+ minRotateAngleDeg: { value: 0.5, validate: RED.validators.number(true) },
411
+ previewEnabled: { value: false },
412
+ previewWidth: { value: 220, validate: RED.validators.number(true) },
413
+ outputFormat: { value: "raw" },
414
+ outputQuality: { value: 90, validate: RED.validators.number(true) },
415
+ pngOptimize: { value: false },
416
+ },
417
+ inputs: 1,
418
+ outputs: 1,
419
+ icon: "arrow-in.png",
420
+ oneditprepare: function () {
421
+ // ---- boundary mode: the blob gates and the caliper regions are
422
+ // mutually exclusive, so only ever show the set that is in use.
423
+ const boundaryField = document.getElementById("node-input-boundaryMode");
424
+ const regionsRow = document.getElementById("label-crop-regions-row");
425
+ const regionsField = document.getElementById("node-input-edgeRegions");
426
+ const regionsStatus = document.getElementById("label-crop-regions-status");
427
+ // every row that only means something to the blob search
428
+ const BLOB_ONLY = [
429
+ "polarity",
430
+ "minAreaFraction",
431
+ "maxAreaFraction",
432
+ "minRectangularity",
433
+ "maxBorderContact",
434
+ "minDominance",
435
+ "minConfidence",
436
+ "aspectRatio",
437
+ "aspectTolerance",
438
+ "expectedSizeFraction",
439
+ "sizeTolerance",
440
+ "maxEdge",
441
+ ];
442
+ const validateRegions = function () {
443
+ const raw = (regionsField.value || "").trim();
444
+ if (!raw) {
445
+ regionsStatus.textContent = "";
446
+ return;
447
+ }
448
+ try {
449
+ const parsed = JSON.parse(raw);
450
+ const missing = ["left", "right", "top", "bottom"].filter(function (k) {
451
+ return !parsed || !parsed[k];
452
+ });
453
+ regionsStatus.style.color = missing.length ? "#c62828" : "#2e7d32";
454
+ regionsStatus.textContent = missing.length
455
+ ? " missing: " + missing.join(", ")
456
+ : " all four edges configured";
457
+ } catch (err) {
458
+ regionsStatus.style.color = "#c62828";
459
+ regionsStatus.textContent = " not valid JSON: " + err.message;
460
+ }
461
+ };
462
+ const updateBoundaryVisibility = function () {
463
+ const calipers = boundaryField.value === "calipers";
464
+ regionsRow.style.display = calipers ? "" : "none";
465
+ for (const id of BLOB_ONLY) {
466
+ const el = document.getElementById("node-input-" + id);
467
+ const row = el && el.closest(".form-row");
468
+ if (row) row.style.display = calipers ? "none" : "";
469
+ }
470
+ const sizeRow = document.getElementById("label-size-canvas-row");
471
+ if (sizeRow) sizeRow.style.display = calipers ? "none" : "";
472
+ if (calipers) validateRegions();
473
+ };
474
+ boundaryField.addEventListener("change", updateBoundaryVisibility);
475
+ regionsField.addEventListener("change", validateRegions);
476
+ regionsField.addEventListener("blur", validateRegions);
477
+ updateBoundaryVisibility();
478
+
479
+ const checkbox = $("#node-input-previewEnabled");
480
+ const widthRow = $("#label-crop-preview-width-row");
481
+ const updatePreviewVisibility = function () {
482
+ widthRow.toggle(checkbox.is(":checked"));
483
+ };
484
+ checkbox.on("change", updatePreviewVisibility);
485
+ updatePreviewVisibility();
486
+
487
+ // ---- label size selector: draw the label on a loaded sample -------
488
+ // The thumbnail below is only a preview; drawing and fine-tuning happen
489
+ // in a zoomable modal viewer (openViewer).
490
+ const canvas = document.getElementById("label-crop-size-canvas");
491
+ const readout = document.getElementById("label-crop-size-readout");
492
+ const fileInput = document.getElementById("label-crop-image-file");
493
+ const ctx = canvas.getContext("2d");
494
+ let image = null; // HTMLImageElement
495
+ let imageW = 0;
496
+ let imageH = 0;
497
+ let rect = null; // {x,y,w,h} in image pixels
498
+ const MIN_RECT = 6; // image pixels below which a rect is ignored
499
+
500
+ const clampField = function (fieldId, value, min, max) {
501
+ $("#" + fieldId).val(Math.min(max, Math.max(min, value)).toFixed(3));
502
+ };
503
+
504
+ const applyRectToFields = function () {
505
+ if (!rect || rect.w < MIN_RECT || rect.h < MIN_RECT) return;
506
+ clampField("node-input-aspectRatio", rect.w / rect.h, 0.05, 20);
507
+ clampField(
508
+ "node-input-expectedSizeFraction",
509
+ (rect.w * rect.h) / (imageW * imageH),
510
+ 0.001,
511
+ 0.99,
512
+ );
513
+ };
514
+
515
+ const updateReadout = function () {
516
+ if (!image) {
517
+ readout.textContent = "";
518
+ return;
519
+ }
520
+ if (!rect || rect.w < MIN_RECT || rect.h < MIN_RECT) {
521
+ readout.textContent =
522
+ imageW +
523
+ " × " +
524
+ imageH +
525
+ " px loaded — open the viewer to draw the label";
526
+ return;
527
+ }
528
+ const fraction = (rect.w * rect.h) / (imageW * imageH);
529
+ readout.textContent =
530
+ Math.round(rect.w) +
531
+ " × " +
532
+ Math.round(rect.h) +
533
+ " px · covers " +
534
+ (fraction * 100).toFixed(1) +
535
+ "% of the frame · w/h " +
536
+ (rect.w / rect.h).toFixed(3);
537
+ };
538
+
539
+ const drawThumb = function () {
540
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
541
+ if (!image) {
542
+ ctx.fillStyle = "#333";
543
+ ctx.font = "12px sans-serif";
544
+ ctx.textAlign = "center";
545
+ ctx.fillText("Load a sample image, then", canvas.width / 2, 112);
546
+ ctx.fillText("click here for the zoom viewer", canvas.width / 2, 130);
547
+ ctx.textAlign = "left";
548
+ return;
549
+ }
550
+ const s = Math.min(canvas.width / imageW, canvas.height / imageH);
551
+ const dw = imageW * s;
552
+ const dh = imageH * s;
553
+ const dx = (canvas.width - dw) / 2;
554
+ const dy = (canvas.height - dh) / 2;
555
+ ctx.drawImage(image, dx, dy, dw, dh);
556
+ if (rect && rect.w >= MIN_RECT && rect.h >= MIN_RECT) {
557
+ ctx.fillStyle = "rgba(79, 195, 247, 0.15)";
558
+ ctx.fillRect(
559
+ dx + rect.x * s,
560
+ dy + rect.y * s,
561
+ rect.w * s,
562
+ rect.h * s,
563
+ );
564
+ ctx.strokeStyle = "#4fc3f7";
565
+ ctx.lineWidth = 2;
566
+ ctx.strokeRect(
567
+ dx + rect.x * s,
568
+ dy + rect.y * s,
569
+ rect.w * s,
570
+ rect.h * s,
571
+ );
572
+ }
573
+ };
574
+
575
+ // ------------------- zoomable modal viewer ------------------------
576
+ let viewer = null;
577
+
578
+ const openViewer = function () {
579
+ if (!image) {
580
+ readout.textContent = "Load a sample image first.";
581
+ return;
582
+ }
583
+ if (viewer) return;
584
+
585
+ const dpr = window.devicePixelRatio || 1;
586
+ const overlay = document.createElement("div");
587
+ overlay.style.cssText =
588
+ "position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,0.78);" +
589
+ "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
590
+ "font:12px/1.4 sans-serif;color:#ddd;";
591
+
592
+ const mkButton = function (text, title) {
593
+ const b = document.createElement("button");
594
+ b.type = "button";
595
+ b.textContent = text;
596
+ b.title = title || "";
597
+ b.style.cssText =
598
+ "background:#4a4d54;color:#eee;border:1px solid #666;border-radius:3px;" +
599
+ "padding:3px 9px;cursor:pointer;font:12px sans-serif;";
600
+ return b;
601
+ };
602
+
603
+ const header = document.createElement("div");
604
+ header.style.cssText =
605
+ "display:flex;align-items:center;gap:6px;width:92vw;max-width:1200px;" +
606
+ "padding:6px 8px;background:#33353a;border:1px solid #555;border-bottom:none;" +
607
+ "border-radius:6px 6px 0 0;box-sizing:border-box;flex:0 0 auto;";
608
+
609
+ const btnZoomOut = mkButton(
610
+ "−",
611
+ "Zoom out (mouse wheel zooms to cursor)",
612
+ );
613
+ const btnZoomIn = mkButton("+", "Zoom in");
614
+ const btnFit = mkButton("Fit", "Fit the image into the window");
615
+ const btn100 = mkButton("100%", "Actual pixel size");
616
+ const btnMode = mkButton(
617
+ "Mode: Draw",
618
+ "Drag to draw the label rectangle; right-drag pans",
619
+ );
620
+ const imgInfo = document.createElement("span");
621
+ imgInfo.style.color = "#999";
622
+ const btnClose = mkButton("✕", "Close without applying");
623
+ btnClose.style.marginLeft = "auto";
624
+ header.append(
625
+ btnZoomOut,
626
+ btnZoomIn,
627
+ btnFit,
628
+ btn100,
629
+ btnMode,
630
+ imgInfo,
631
+ btnClose,
632
+ );
633
+
634
+ const body = document.createElement("div");
635
+ body.style.cssText =
636
+ "width:92vw;max-width:1200px;height:76vh;background:#232325;" +
637
+ "border:1px solid #555;position:relative;overflow:hidden;box-sizing:border-box;";
638
+
639
+ const viewCanvas = document.createElement("canvas");
640
+ viewCanvas.style.cssText =
641
+ "position:absolute;inset:0;width:100%;height:100%;display:block;" +
642
+ "touch-action:none;cursor:crosshair;";
643
+ body.appendChild(viewCanvas);
644
+ const vctx = viewCanvas.getContext("2d");
645
+
646
+ const footer = document.createElement("div");
647
+ footer.style.cssText =
648
+ "width:92vw;max-width:1200px;padding:6px 8px;background:#33353a;border:1px solid #555;" +
649
+ "border-top:none;border-radius:0 0 6px 6px;box-sizing:border-box;flex:0 0 auto;" +
650
+ "display:flex;align-items:center;gap:10px;";
651
+ const footReadout = document.createElement("span");
652
+ footReadout.style.color = "#bbb";
653
+ const btnApply = mkButton(
654
+ "Apply",
655
+ "Fill the aspect and size fields from this rectangle",
656
+ );
657
+ btnApply.style.background = "#3574f0";
658
+ btnApply.style.borderColor = "#3574f0";
659
+ btnApply.style.marginLeft = "auto";
660
+ const btnCancel = mkButton("Cancel", "Close without applying");
661
+ footer.append(footReadout, btnApply, btnCancel);
662
+
663
+ overlay.append(header, body, footer);
664
+ document.body.appendChild(overlay);
665
+ viewer = overlay;
666
+
667
+ // view state: view.scale/ox/oy map image space -> canvas (CSS px)
668
+ const view = { scale: 1, ox: 0, oy: 0 };
669
+ let mode = "draw"; // 'draw' | 'pan'
670
+ const clamp = function (v, lo, hi) {
671
+ return Math.min(hi, Math.max(lo, v));
672
+ };
673
+
674
+ const toImage = function (ev) {
675
+ const b = viewCanvas.getBoundingClientRect();
676
+ return {
677
+ x: (ev.clientX - b.left - view.ox) / view.scale,
678
+ y: (ev.clientY - b.top - view.oy) / view.scale,
679
+ };
680
+ };
681
+ const toScreen = function (x, y) {
682
+ return { x: view.ox + x * view.scale, y: view.oy + y * view.scale };
683
+ };
684
+ const fitView = function () {
685
+ const w = body.clientWidth;
686
+ const h = body.clientHeight;
687
+ view.scale = Math.min(w / imageW, h / imageH, 1) * 0.96;
688
+ view.ox = (w - imageW * view.scale) / 2;
689
+ view.oy = (h - imageH * view.scale) / 2;
690
+ };
691
+ const zoomAt = function (cx, cy, factor) {
692
+ const ix = (cx - view.ox) / view.scale;
693
+ const iy = (cy - view.oy) / view.scale;
694
+ view.scale = clamp(view.scale * factor, 0.02, 32);
695
+ view.ox = cx - ix * view.scale;
696
+ view.oy = cy - iy * view.scale;
697
+ };
698
+
699
+ const normalizedRect = function (x0, y0, x1, y1) {
700
+ let x = Math.min(x0, x1);
701
+ let y = Math.min(y0, y1);
702
+ let w = clamp(Math.abs(x1 - x0), 0, imageW);
703
+ let h = clamp(Math.abs(y1 - y0), 0, imageH);
704
+ x = clamp(x, 0, imageW - w);
705
+ y = clamp(y, 0, imageH - h);
706
+ return { x, y, w, h };
707
+ };
708
+ const clampRectToImage = function (r) {
709
+ return {
710
+ x: clamp(r.x, 0, Math.max(0, imageW - r.w)),
711
+ y: clamp(r.y, 0, Math.max(0, imageH - r.h)),
712
+ w: r.w,
713
+ h: r.h,
714
+ };
715
+ };
716
+ const resizeByHandle = function (handle, r, px, py) {
717
+ const nr = { x: r.x, y: r.y, w: r.w, h: r.h };
718
+ if (handle === 0) {
719
+ nr.x = clamp(
720
+ Math.min(px, r.x + r.w - MIN_RECT),
721
+ 0,
722
+ r.x + r.w - MIN_RECT,
723
+ );
724
+ nr.w = r.x + r.w - nr.x;
725
+ nr.y = clamp(
726
+ Math.min(py, r.y + r.h - MIN_RECT),
727
+ 0,
728
+ r.y + r.h - MIN_RECT,
729
+ );
730
+ nr.h = r.y + r.h - nr.y;
731
+ } else if (handle === 1) {
732
+ nr.w = clamp(px - r.x, MIN_RECT, imageW - r.x);
733
+ nr.y = clamp(
734
+ Math.min(py, r.y + r.h - MIN_RECT),
735
+ 0,
736
+ r.y + r.h - MIN_RECT,
737
+ );
738
+ nr.h = r.y + r.h - nr.y;
739
+ } else if (handle === 2) {
740
+ nr.x = clamp(
741
+ Math.min(px, r.x + r.w - MIN_RECT),
742
+ 0,
743
+ r.x + r.w - MIN_RECT,
744
+ );
745
+ nr.w = r.x + r.w - nr.x;
746
+ nr.h = clamp(py - r.y, MIN_RECT, imageH - r.y);
747
+ } else {
748
+ nr.w = clamp(px - r.x, MIN_RECT, imageW - r.x);
749
+ nr.h = clamp(py - r.y, MIN_RECT, imageH - r.y);
750
+ }
751
+ return nr;
752
+ };
753
+ const hitHandle = function (sx, sy) {
754
+ if (!rect || rect.w < MIN_RECT || rect.h < MIN_RECT) return -1;
755
+ const corners = [
756
+ [rect.x, rect.y],
757
+ [rect.x + rect.w, rect.y],
758
+ [rect.x, rect.y + rect.h],
759
+ [rect.x + rect.w, rect.y + rect.h],
760
+ ];
761
+ const TOL = 12;
762
+ for (let i = 0; i < corners.length; i++) {
763
+ const s = toScreen(corners[i][0], corners[i][1]);
764
+ if (Math.abs(s.x - sx) <= TOL && Math.abs(s.y - sy) <= TOL)
765
+ return i;
766
+ }
767
+ return -1;
768
+ };
769
+
770
+ let boardPattern = null;
771
+ const drawView = function () {
772
+ const w = body.clientWidth;
773
+ const h = body.clientHeight;
774
+ vctx.clearRect(0, 0, w, h);
775
+ if (!boardPattern) {
776
+ const p = document.createElement("canvas");
777
+ p.width = 28;
778
+ p.height = 28;
779
+ const pc = p.getContext("2d");
780
+ pc.fillStyle = "#26262a";
781
+ pc.fillRect(0, 0, 28, 28);
782
+ pc.fillStyle = "#2d2d32";
783
+ pc.fillRect(0, 0, 14, 14);
784
+ pc.fillRect(14, 14, 14, 14);
785
+ boardPattern = vctx.createPattern(p, "repeat");
786
+ }
787
+ vctx.fillStyle = boardPattern;
788
+ vctx.fillRect(0, 0, w, h);
789
+
790
+ vctx.save();
791
+ vctx.translate(view.ox, view.oy);
792
+ vctx.scale(view.scale, view.scale);
793
+ vctx.imageSmoothingEnabled = true;
794
+ vctx.imageSmoothingQuality = "high";
795
+ vctx.drawImage(image, 0, 0, imageW, imageH);
796
+ if (rect && rect.w >= MIN_RECT && rect.h >= MIN_RECT) {
797
+ vctx.lineJoin = "round";
798
+ vctx.fillStyle = "rgba(79, 195, 247, 0.12)";
799
+ vctx.fillRect(rect.x, rect.y, rect.w, rect.h);
800
+ vctx.lineWidth = 4 / view.scale;
801
+ vctx.strokeStyle = "rgba(0, 0, 0, 0.85)";
802
+ vctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
803
+ vctx.lineWidth = 2 / view.scale;
804
+ vctx.strokeStyle = "#4fc3f7";
805
+ vctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
806
+ const hs = 10 / view.scale;
807
+ const corners = [
808
+ [rect.x, rect.y],
809
+ [rect.x + rect.w, rect.y],
810
+ [rect.x, rect.y + rect.h],
811
+ [rect.x + rect.w, rect.y + rect.h],
812
+ ];
813
+ for (let i = 0; i < corners.length; i++) {
814
+ vctx.fillStyle = "#ffffff";
815
+ vctx.fillRect(
816
+ corners[i][0] - hs / 2,
817
+ corners[i][1] - hs / 2,
818
+ hs,
819
+ hs,
820
+ );
821
+ vctx.strokeStyle = "#4fc3f7";
822
+ vctx.lineWidth = 1.5 / view.scale;
823
+ vctx.strokeRect(
824
+ corners[i][0] - hs / 2,
825
+ corners[i][1] - hs / 2,
826
+ hs,
827
+ hs,
828
+ );
829
+ }
830
+ }
831
+ vctx.restore();
832
+ };
833
+
834
+ let drag = null;
835
+ viewCanvas.addEventListener("pointerdown", function (ev) {
836
+ if (ev.button !== 0 && ev.button !== 2) return;
837
+ viewCanvas.setPointerCapture(ev.pointerId);
838
+ const b = viewCanvas.getBoundingClientRect();
839
+ const sx = ev.clientX - b.left;
840
+ const sy = ev.clientY - b.top;
841
+ const p = toImage(ev);
842
+ const handle = hitHandle(sx, sy);
843
+ if (ev.button === 2 || mode === "pan") {
844
+ drag = { type: "pan", sx, sy, ox: view.ox, oy: view.oy };
845
+ } else if (handle >= 0) {
846
+ drag = { type: "handle", handle, startRect: { ...rect } };
847
+ } else if (
848
+ rect &&
849
+ rect.w >= MIN_RECT &&
850
+ rect.h >= MIN_RECT &&
851
+ p.x >= rect.x &&
852
+ p.x <= rect.x + rect.w &&
853
+ p.y >= rect.y &&
854
+ p.y <= rect.y + rect.h
855
+ ) {
856
+ drag = { type: "move", ix: p.x, iy: p.y, startRect: { ...rect } };
857
+ } else {
858
+ drag = { type: "rect", ix: p.x, iy: p.y, prevRect: rect };
859
+ }
860
+ });
861
+ viewCanvas.addEventListener("pointermove", function (ev) {
862
+ if (!drag) return;
863
+ const b = viewCanvas.getBoundingClientRect();
864
+ const sx = ev.clientX - b.left;
865
+ const sy = ev.clientY - b.top;
866
+ const p = toImage(ev);
867
+ if (drag.type === "pan") {
868
+ view.ox = drag.ox + (sx - drag.sx);
869
+ view.oy = drag.oy + (sy - drag.sy);
870
+ } else if (drag.type === "rect") {
871
+ rect = normalizedRect(drag.ix, drag.iy, p.x, p.y);
872
+ } else if (drag.type === "move") {
873
+ rect = clampRectToImage({
874
+ x: drag.startRect.x + (p.x - drag.ix),
875
+ y: drag.startRect.y + (p.y - drag.iy),
876
+ w: drag.startRect.w,
877
+ h: drag.startRect.h,
878
+ });
879
+ } else if (drag.type === "handle") {
880
+ rect = resizeByHandle(drag.handle, drag.startRect, p.x, p.y);
881
+ }
882
+ drawView();
883
+ updateFootReadout();
884
+ });
885
+ const endDrag = function () {
886
+ if (!drag) return;
887
+ if (
888
+ drag.type === "rect" &&
889
+ rect &&
890
+ (rect.w < MIN_RECT || rect.h < MIN_RECT)
891
+ ) {
892
+ rect = drag.prevRect;
893
+ }
894
+ drag = null;
895
+ drawView();
896
+ updateFootReadout();
897
+ };
898
+ viewCanvas.addEventListener("pointerup", endDrag);
899
+ viewCanvas.addEventListener("pointercancel", endDrag);
900
+ viewCanvas.addEventListener("contextmenu", function (ev) {
901
+ ev.preventDefault();
902
+ });
903
+ viewCanvas.addEventListener(
904
+ "wheel",
905
+ function (ev) {
906
+ ev.preventDefault();
907
+ const b = viewCanvas.getBoundingClientRect();
908
+ zoomAt(
909
+ ev.clientX - b.left,
910
+ ev.clientY - b.top,
911
+ ev.deltaY < 0 ? 1.25 : 0.8,
912
+ );
913
+ drawView();
914
+ updateFootReadout();
915
+ },
916
+ { passive: false },
917
+ );
918
+
919
+ const updateFootReadout = function () {
920
+ imgInfo.textContent = imageW + " × " + imageH + " px";
921
+ let txt = "zoom " + Math.round(view.scale * 100) + "%";
922
+ if (rect && rect.w >= MIN_RECT && rect.h >= MIN_RECT) {
923
+ const fraction = (rect.w * rect.h) / (imageW * imageH);
924
+ txt +=
925
+ " · rect " +
926
+ Math.round(rect.x) +
927
+ "," +
928
+ Math.round(rect.y) +
929
+ " " +
930
+ Math.round(rect.w) +
931
+ "×" +
932
+ Math.round(rect.h) +
933
+ " px · covers " +
934
+ (fraction * 100).toFixed(1) +
935
+ "% of frame · w/h " +
936
+ (rect.w / rect.h).toFixed(3);
937
+ } else {
938
+ txt += " · drag to draw the label rectangle";
939
+ }
940
+ footReadout.textContent = txt;
941
+ };
942
+
943
+ const setViewSize = function () {
944
+ viewCanvas.width = Math.round(body.clientWidth * dpr);
945
+ viewCanvas.height = Math.round(body.clientHeight * dpr);
946
+ vctx.setTransform(dpr, 0, 0, dpr, 0, 0);
947
+ drawView();
948
+ };
949
+
950
+ const closeViewer = function () {
951
+ if (!viewer) return;
952
+ window.removeEventListener("keydown", onKeyDown, true);
953
+ window.removeEventListener("resize", onResize);
954
+ overlay.remove();
955
+ viewer = null;
956
+ };
957
+ const onKeyDown = function (ev) {
958
+ if (ev.key === "Escape") {
959
+ ev.preventDefault();
960
+ ev.stopPropagation();
961
+ closeViewer();
962
+ } else if (
963
+ ev.key === "Enter" &&
964
+ !(
965
+ ev.target &&
966
+ ev.target instanceof Element &&
967
+ overlay.contains(ev.target)
968
+ )
969
+ ) {
970
+ // keep Enter from saving the whole edit dialog while the modal is open
971
+ ev.preventDefault();
972
+ ev.stopPropagation();
973
+ }
974
+ };
975
+ const onResize = function () {
976
+ setViewSize();
977
+ };
978
+
979
+ btnZoomIn.addEventListener("click", function () {
980
+ zoomAt(body.clientWidth / 2, body.clientHeight / 2, 1.5);
981
+ drawView();
982
+ updateFootReadout();
983
+ });
984
+ btnZoomOut.addEventListener("click", function () {
985
+ zoomAt(body.clientWidth / 2, body.clientHeight / 2, 1 / 1.5);
986
+ drawView();
987
+ updateFootReadout();
988
+ });
989
+ btnFit.addEventListener("click", function () {
990
+ fitView();
991
+ drawView();
992
+ updateFootReadout();
993
+ });
994
+ btn100.addEventListener("click", function () {
995
+ view.scale = 1;
996
+ view.ox = (body.clientWidth - imageW) / 2;
997
+ view.oy = (body.clientHeight - imageH) / 2;
998
+ drawView();
999
+ updateFootReadout();
1000
+ });
1001
+ btnMode.addEventListener("click", function () {
1002
+ mode = mode === "draw" ? "pan" : "draw";
1003
+ btnMode.textContent = "Mode: " + (mode === "draw" ? "Draw" : "Pan");
1004
+ btnMode.title =
1005
+ mode === "draw"
1006
+ ? "Drag to draw the label rectangle; right-drag pans"
1007
+ : "Drag to pan the image; switch back to Draw to edit the rectangle";
1008
+ viewCanvas.style.cursor = mode === "draw" ? "crosshair" : "grab";
1009
+ });
1010
+ btnClose.addEventListener("click", closeViewer);
1011
+ btnCancel.addEventListener("click", closeViewer);
1012
+ btnApply.addEventListener("click", function () {
1013
+ if (rect && rect.w >= MIN_RECT && rect.h >= MIN_RECT) {
1014
+ applyRectToFields();
1015
+ updateReadout();
1016
+ drawThumb();
1017
+ }
1018
+ closeViewer();
1019
+ });
1020
+
1021
+ window.addEventListener("keydown", onKeyDown, true);
1022
+ window.addEventListener("resize", onResize);
1023
+ overlay.addEventListener(
1024
+ "wheel",
1025
+ function (ev) {
1026
+ ev.preventDefault();
1027
+ },
1028
+ { passive: false },
1029
+ );
1030
+
1031
+ fitView();
1032
+ setViewSize();
1033
+ updateFootReadout();
1034
+ updateReadout();
1035
+ drawThumb();
1036
+ btnApply.focus();
1037
+ };
1038
+
1039
+ document
1040
+ .getElementById("label-crop-load-image")
1041
+ .addEventListener("click", function () {
1042
+ fileInput.click();
1043
+ });
1044
+ document
1045
+ .getElementById("label-crop-open-viewer")
1046
+ .addEventListener("click", openViewer);
1047
+ canvas.addEventListener("click", openViewer);
1048
+ fileInput.addEventListener("change", function () {
1049
+ const file = fileInput.files && fileInput.files[0];
1050
+ if (!file) return;
1051
+ const url = URL.createObjectURL(file);
1052
+ const img = new Image();
1053
+ img.onload = function () {
1054
+ image = img;
1055
+ imageW = img.naturalWidth;
1056
+ imageH = img.naturalHeight;
1057
+ rect = null;
1058
+ URL.revokeObjectURL(url);
1059
+ updateReadout();
1060
+ drawThumb();
1061
+ };
1062
+ img.onerror = function () {
1063
+ URL.revokeObjectURL(url);
1064
+ readout.textContent = "Could not load that image.";
1065
+ };
1066
+ img.src = url;
1067
+ });
1068
+ document
1069
+ .getElementById("label-crop-clear-size")
1070
+ .addEventListener("click", function () {
1071
+ rect = null;
1072
+ image = null;
1073
+ imageW = 0;
1074
+ imageH = 0;
1075
+ $("#node-input-aspectRatio").val("");
1076
+ $("#node-input-expectedSizeFraction").val("");
1077
+ updateReadout();
1078
+ drawThumb();
1079
+ });
1080
+ updateReadout();
1081
+ drawThumb();
1082
+ },
1083
+ label: function () {
1084
+ return this.name || "label crop";
1085
+ },
1086
+ paletteLabel: function () {
1087
+ return "label crop";
1088
+ },
1089
+ });
1090
+
1091
+ if (!window.labelCropPreviewRendererInitialized) {
1092
+ window.labelCropPreviewRendererInitialized = true;
1093
+
1094
+ const removePreview = function (nodeId) {
1095
+ const existing = document.getElementById(
1096
+ "label-crop-preview-container-" + nodeId,
1097
+ );
1098
+ if (existing) existing.remove();
1099
+ };
1100
+
1101
+ RED.comms.subscribe("label-crop-preview", function (_event, data) {
1102
+ if (!data || !data.id) return;
1103
+ if (data.clear || !data.before || !data.after) {
1104
+ removePreview(data.id);
1105
+ return;
1106
+ }
1107
+
1108
+ const nodeElement = document.getElementById(data.id);
1109
+ if (!nodeElement) return;
1110
+ removePreview(data.id);
1111
+
1112
+ const width = Number(data.previewWidth) || 220;
1113
+ const container = document.createElementNS(
1114
+ "http://www.w3.org/2000/svg",
1115
+ "foreignObject",
1116
+ );
1117
+ container.id = "label-crop-preview-container-" + data.id;
1118
+ container.setAttribute("x", "70");
1119
+ container.setAttribute("y", "5");
1120
+ container.setAttribute("width", String(width * 2 + 30));
1121
+ container.setAttribute("height", String(width * 2 + 45));
1122
+ container.style.overflow = "visible";
1123
+
1124
+ const body = document.createElementNS(
1125
+ "http://www.w3.org/1999/xhtml",
1126
+ "div",
1127
+ );
1128
+ body.style.display = "flex";
1129
+ body.style.gap = "6px";
1130
+ body.style.padding = "5px";
1131
+ body.style.background = "rgba(32, 32, 32, 0.9)";
1132
+ body.style.borderRadius = "4px";
1133
+ body.style.color = "white";
1134
+ body.style.font = "11px sans-serif";
1135
+ body.style.cursor = "pointer";
1136
+ body.title = "Before / after label crop (click to hide)";
1137
+ body.onclick = function () {
1138
+ removePreview(data.id);
1139
+ };
1140
+
1141
+ const addImage = function (caption, base64) {
1142
+ const panel = document.createElementNS(
1143
+ "http://www.w3.org/1999/xhtml",
1144
+ "div",
1145
+ );
1146
+ panel.style.width = width + "px";
1147
+ panel.style.flex = "0 0 auto";
1148
+
1149
+ const label = document.createElementNS(
1150
+ "http://www.w3.org/1999/xhtml",
1151
+ "div",
1152
+ );
1153
+ label.textContent = caption;
1154
+ label.style.paddingBottom = "3px";
1155
+ label.style.textAlign = "center";
1156
+
1157
+ const image = document.createElementNS(
1158
+ "http://www.w3.org/1999/xhtml",
1159
+ "img",
1160
+ );
1161
+ image.src = `data:image/${data.mimeType || "jpeg"};base64,${base64}`;
1162
+ image.alt = caption;
1163
+ image.style.display = "block";
1164
+ image.style.width = "100%";
1165
+ image.style.height = "auto";
1166
+
1167
+ panel.appendChild(label);
1168
+ panel.appendChild(image);
1169
+ body.appendChild(panel);
1170
+ };
1171
+
1172
+ addImage("Before", data.before);
1173
+ addImage("After", data.after);
1174
+ container.appendChild(body);
1175
+ nodeElement.appendChild(container);
1176
+ });
1177
+ }
1178
+ </script>