@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,1361 @@
1
+ <script type="text/html" data-template-name="line-finder">
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
+
7
+ <div class="form-row">
8
+ <label><i class="fa fa-crop"></i> Search region</label>
9
+ <div style="margin-left:105px;">
10
+ <canvas
11
+ id="line-finder-canvas"
12
+ width="360"
13
+ height="240"
14
+ style="border:1px solid #ccc; border-radius:3px; cursor:pointer; background:#fafafa;"
15
+ title="Click to open the zoom viewer"
16
+ ></canvas>
17
+ <div style="margin-top:6px;">
18
+ <input type="file" id="line-finder-file" accept="image/*" style="width:210px;" />
19
+ <button
20
+ type="button"
21
+ id="line-finder-open-viewer"
22
+ class="red-ui-button red-ui-button-small"
23
+ title="Zoom in and drag the region over the edge"
24
+ >
25
+ <i class="fa fa-search-plus"></i> Viewer
26
+ </button>
27
+ <button type="button" id="line-finder-clear" class="red-ui-button red-ui-button-small">
28
+ Reset
29
+ </button>
30
+ </div>
31
+ <div
32
+ id="line-finder-readout"
33
+ style="margin-top:6px; color:#888; font-size:12px; font-family:monospace;"
34
+ >
35
+ no region
36
+ </div>
37
+ <div style="margin-top:4px; color:#999; font-size:12px;">
38
+ Load a representative photo, then open the viewer to zoom in and drag
39
+ <b>across</b> the edge, in the direction the calipers should travel.
40
+ The orange arrow is that direction, the dashed green line is the edge
41
+ being looked for, and the pale lines are the calipers themselves.
42
+ Corner handles resize, the amber grip rotates, arrow keys nudge by a
43
+ pixel.
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="form-row">
49
+ <label for="node-input-regionX"><i class="fa fa-arrows"></i> Region</label>
50
+ <input type="number" id="node-input-regionX" style="width:70px;" placeholder="x" />
51
+ <input type="number" id="node-input-regionY" style="width:70px;" placeholder="y" />
52
+ <input type="number" id="node-input-regionWidth" style="width:70px;" placeholder="w" />
53
+ <input type="number" id="node-input-regionHeight" style="width:70px;" placeholder="h" />
54
+ <span class="node-input-hint" style="margin-left:8px; color:#999;">x y w h</span>
55
+ </div>
56
+
57
+ <div class="form-row">
58
+ <label for="node-input-regionAngleDeg"><i class="fa fa-rotate-right"></i> Region angle</label>
59
+ <input type="number" id="node-input-regionAngleDeg" step="0.1" style="width:80px;" />
60
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
61
+ >degrees clockwise about the region's centre</span
62
+ >
63
+ </div>
64
+
65
+ <div class="form-row">
66
+ <label for="node-input-scanDirection"><i class="fa fa-long-arrow-right"></i> Scan</label>
67
+ <select id="node-input-scanDirection" style="width:130px;">
68
+ <option value="right">left to right</option>
69
+ <option value="left">right to left</option>
70
+ <option value="down">top to bottom</option>
71
+ <option value="up">bottom to top</option>
72
+ </select>
73
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
74
+ >the edge found is perpendicular to this</span
75
+ >
76
+ </div>
77
+
78
+ <div class="form-row">
79
+ <label for="node-input-polarity"><i class="fa fa-adjust"></i> Polarity</label>
80
+ <select id="node-input-polarity" style="width:130px;">
81
+ <option value="either">either</option>
82
+ <option value="darkToLight">dark to light</option>
83
+ <option value="lightToDark">light to dark</option>
84
+ </select>
85
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
86
+ >as seen travelling along the scan direction</span
87
+ >
88
+ </div>
89
+
90
+ <div class="form-row">
91
+ <label for="node-input-edgeSelect"><i class="fa fa-crosshairs"></i> Select</label>
92
+ <select id="node-input-edgeSelect" style="width:130px;">
93
+ <option value="best">strongest edge</option>
94
+ <option value="first">first edge</option>
95
+ <option value="last">last edge</option>
96
+ </select>
97
+ <input type="number" id="node-input-ignoreCount" min="0" style="width:60px; margin-left:10px;" />
98
+ <span class="node-input-hint" style="margin-left:8px; color:#999;"
99
+ >edges to skip first</span
100
+ >
101
+ </div>
102
+
103
+ <div class="form-row">
104
+ <label for="node-input-calipers"><i class="fa fa-bars"></i> Calipers</label>
105
+ <input type="number" id="node-input-calipers" min="1" max="512" style="width:70px;" />
106
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
107
+ >scans across the region; each averages its own slice</span
108
+ >
109
+ </div>
110
+
111
+ <div class="form-row">
112
+ <label for="node-input-contrastThreshold"><i class="fa fa-signal"></i> Contrast</label>
113
+ <input type="number" id="node-input-contrastThreshold" min="0" step="0.1" style="width:70px;" />
114
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
115
+ >grey levels per pixel of travel; lower finds fainter edges</span
116
+ >
117
+ </div>
118
+
119
+ <div class="form-row">
120
+ <label for="node-input-filterHalfWidth"><i class="fa fa-filter"></i> Smoothing</label>
121
+ <input type="number" id="node-input-filterHalfWidth" min="0" max="64" style="width:70px;" />
122
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
123
+ >box half-width in scan pixels</span
124
+ >
125
+ </div>
126
+
127
+ <div class="form-row">
128
+ <label for="node-input-outlierTolerancePx"><i class="fa fa-ban"></i> Outlier</label>
129
+ <input type="number" id="node-input-outlierTolerancePx" min="0.1" step="0.1" style="width:70px;" />
130
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
131
+ >px from the fitted line before a caliper is dropped</span
132
+ >
133
+ </div>
134
+
135
+ <div class="form-row">
136
+ <label for="node-input-minCaliperFraction"><i class="fa fa-check"></i> Min calipers</label>
137
+ <input type="number" id="node-input-minCaliperFraction" min="0.05" max="1" step="0.05" style="width:70px;" />
138
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
139
+ >fraction that must survive the fit</span
140
+ >
141
+ </div>
142
+
143
+ <div class="form-row">
144
+ <label for="node-input-angleToleranceDeg"><i class="fa fa-rotate-left"></i> Angle tol.</label>
145
+ <input type="number" id="node-input-angleToleranceDeg" min="0" max="90" step="0.5" style="width:70px;" />
146
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
147
+ >degrees from the region's own orientation; blank to skip</span
148
+ >
149
+ </div>
150
+
151
+ <div class="form-row">
152
+ <label for="node-input-previewEnabled"
153
+ ><i class="fa fa-eye"></i> Preview</label
154
+ >
155
+ <input
156
+ type="checkbox"
157
+ id="node-input-previewEnabled"
158
+ style="width:auto; vertical-align:top;"
159
+ />
160
+ <label for="node-input-previewEnabled" style="width:auto; margin-left:5px;"
161
+ >Draw the region, calipers and fitted line on the flow canvas</label
162
+ >
163
+ </div>
164
+
165
+ <div class="form-row" id="line-finder-preview-width-row">
166
+ <label for="node-input-previewWidth"
167
+ ><i class="fa fa-arrows-h"></i> Preview width</label
168
+ >
169
+ <input type="number" id="node-input-previewWidth" min="80" max="600" style="width:80px;" />
170
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
171
+ >px; diagnostic only - it re-encodes the frame on every message</span
172
+ >
173
+ </div>
174
+
175
+ <div class="form-row">
176
+ <label for="node-input-minScore"><i class="fa fa-star-o"></i> Min score</label>
177
+ <input type="number" id="node-input-minScore" min="0" max="1" step="0.05" style="width:70px;" />
178
+ <span class="node-input-hint" style="margin-left:10px; color:#999;"
179
+ >below this the line is reported as not found</span
180
+ >
181
+ </div>
182
+ </script>
183
+
184
+ <script type="text/html" data-help-name="line-finder">
185
+ <p>
186
+ Find one straight edge inside a region you draw. A row of
187
+ <b>calipers</b> scans across the region, each one reports where the
188
+ brightness steps, and a line is fitted through those points with
189
+ outliers dropped.
190
+ </p>
191
+ <p>
192
+ Use it when a whole-frame search finds the <i>wrong</i> edge. That
193
+ happens whenever the strongest contrast near the boundary you want
194
+ belongs to something else - printed artwork a few millimetres inside
195
+ a label edge, a frame vignette outboard of it, a conveyor rail. A
196
+ drawn region settles it by construction: nothing outside the box can
197
+ win.
198
+ </p>
199
+
200
+ <h3>Drawing the region</h3>
201
+ <p>
202
+ Load a representative photo and the <b>zoom viewer</b> opens on it -
203
+ click the thumbnail or <i>Viewer</i> to reopen it. Drag
204
+ <b>across</b> the edge, in the direction you want the calipers to
205
+ travel. The box should straddle the edge with room for it to move
206
+ frame to frame, and should <b>exclude</b> any stronger edge you do
207
+ not want. Width along the edge buys noise rejection: each caliper
208
+ averages its whole slice, so a step of two or three grey levels is
209
+ perfectly findable over a few hundred rows even though it is
210
+ invisible in any single one.
211
+ </p>
212
+ <p>
213
+ The viewer is where a region actually gets aimed, because at
214
+ thumbnail scale a two-grey-level boundary is not visible at all. It
215
+ opens zoomed onto the current region and shows what the search will
216
+ do: the <b>orange arrow</b> is the scan direction, the <b>dashed
217
+ green</b> line is the edge being looked for, and the <b>pale
218
+ lines</b> are the calipers themselves - one per band, drawn where
219
+ that band will measure. Turn them off with <i>Calipers</i> if they
220
+ get in the way.
221
+ </p>
222
+ <ul>
223
+ <li>mouse wheel zooms to the cursor; <i>Fit</i> and <i>100%</i>
224
+ jump; right-drag pans at any time, or switch <i>Mode</i> to Pan</li>
225
+ <li>drag on empty space to draw a new region - the drag direction
226
+ sets the scan direction, which is the one thing a static box
227
+ cannot tell you</li>
228
+ <li>corner handles resize from the opposite corner; dragging inside
229
+ moves the box; the arrow keys nudge it a pixel at a time, ten with
230
+ Shift</li>
231
+ <li>the amber grip rotates the region about its centre, to a tenth
232
+ of a degree - hold Shift to snap to five</li>
233
+ <li><i>Scan</i> cycles the direction without redrawing</li>
234
+ <li><i>Apply</i> writes the region into the fields; <i>Cancel</i> or
235
+ Escape leaves them alone</li>
236
+ </ul>
237
+ <p>
238
+ A region may hang off the frame, but the footer says so: a caliper
239
+ band that is not wholly inside the image is skipped rather than
240
+ averaged over a moving pixel count, so a box half off the edge
241
+ quietly loses its calipers.
242
+ </p>
243
+
244
+ <h3>Input</h3>
245
+ <p>
246
+ <code>msg.payload</code> — an encoded image Buffer or a raw
247
+ <code>{ data, width, height, channels }</code> object. The payload is
248
+ <b>not</b> modified; this is a measuring tool.
249
+ </p>
250
+ <p>
251
+ <code>msg.region</code> — optional per-message region
252
+ <code>{ x, y, width, height, angleDeg }</code>, so one node can be
253
+ re-aimed rather than copied. Every option below can also be
254
+ overridden by a same-named <code>msg</code> property.
255
+ </p>
256
+
257
+ <h3>Output</h3>
258
+ <p><code>msg.lineFinder</code>:</p>
259
+ <ul>
260
+ <li><code>found</code> / <code>reason</code> — <code>ok</code>, <code>no-edge</code>,
261
+ <code>too-few-calipers</code>, <code>angle-out-of-tolerance</code>,
262
+ <code>below-min-score</code></li>
263
+ <li><code>line</code> — <code>{ x, y, dx, dy, p0, p1 }</code>: a point on
264
+ the line, a unit direction along it, and the endpoints clipped to the
265
+ region</li>
266
+ <li><code>angleDeg</code> — the line's angle, folded to (-90, 90]</li>
267
+ <li><code>score</code> — coverage × fit tightness, 0..1</li>
268
+ <li><code>calipers</code> — <code>{ total, found, used }</code></li>
269
+ <li><code>residualPx</code> — RMS distance of the used points from the line</li>
270
+ <li><code>points</code> — every caliper hit, each flagged <code>used</code>
271
+ or not, for overlaying on a debug image</li>
272
+ </ul>
273
+ <p>
274
+ A miss is a normal result, not an error - <code>found</code> is false
275
+ and the flow decides what to do. Only an unusable payload is an error.
276
+ </p>
277
+
278
+ <h3>Preview</h3>
279
+ <p>
280
+ Switch <i>Preview</i> on and the node draws its result on the flow
281
+ canvas: the search region as you configured it (a rotated region shows
282
+ as the parallelogram it is), every caliper hit - <b>green</b> kept,
283
+ <b>red</b> dropped by the outlier trim - and the <b>yellow</b> fitted
284
+ line, with the angle, caliper count, score and residual above. Click
285
+ the preview to dismiss it.
286
+ </p>
287
+ <p>
288
+ A <b>miss</b> previews too, which is the point: it shows whether the
289
+ box is aimed at the wrong edge, clipped by the frame, or straddling
290
+ two steps - none of which a score alone can tell you. It re-encodes
291
+ the frame on every message, so it is a tuning aid, not something to
292
+ leave on in production.
293
+ </p>
294
+
295
+ <h3>Tuning</h3>
296
+ <p>
297
+ Start with <i>Contrast</i> at 2 and lower it until the edge is found;
298
+ if it finds the wrong edge instead, tighten the region rather than
299
+ raising the threshold. <i>Select</i> = <b>first</b> is the
300
+ deterministic choice when two steps of similar strength sit close
301
+ together - <b>strongest</b> can alternate between them from frame to
302
+ frame. <i>Edges to skip</i> steps past a known structure, such as a
303
+ frame vignette, without narrowing the box.
304
+ </p>
305
+
306
+ <h3>Four of these make a rectangle</h3>
307
+ <p>
308
+ Feed four line-finders (left, right, top, bottom) into
309
+ <code>label-crop</code>'s <i>calipers</i> boundary mode and it will
310
+ intersect them into the label's corners and crop to them - far more
311
+ stable than a blob search when the boundary is faint. See that node's
312
+ help.
313
+ </p>
314
+ <p>
315
+ No OpenCV engine is needed: the search is pure JS and only touches
316
+ the pixels inside the region, so its cost follows the box you drew,
317
+ not the frame size.
318
+ </p>
319
+ </script>
320
+
321
+ <script type="text/javascript">
322
+ RED.nodes.registerType("line-finder", {
323
+ category: "vision",
324
+ color: "#5DADE2",
325
+ defaults: {
326
+ name: { value: "" },
327
+ regionX: { value: 0, validate: RED.validators.number(true) },
328
+ regionY: { value: 0, validate: RED.validators.number(true) },
329
+ regionWidth: { value: 100, validate: RED.validators.number(true) },
330
+ regionHeight: { value: 100, validate: RED.validators.number(true) },
331
+ regionAngleDeg: { value: 0, validate: RED.validators.number(true) },
332
+ scanDirection: { value: "right" },
333
+ polarity: { value: "either" },
334
+ edgeSelect: { value: "best" },
335
+ ignoreCount: { value: 0, validate: RED.validators.number(true) },
336
+ calipers: { value: 16, validate: RED.validators.number(true) },
337
+ contrastThreshold: { value: 2, validate: RED.validators.number(true) },
338
+ filterHalfWidth: { value: 2, validate: RED.validators.number(true) },
339
+ outlierTolerancePx: { value: 2.5, validate: RED.validators.number(true) },
340
+ minCaliperFraction: { value: 0.5, validate: RED.validators.number(true) },
341
+ angleToleranceDeg: { value: 10, validate: RED.validators.number(true) },
342
+ minScore: { value: 0, validate: RED.validators.number(true) },
343
+ previewEnabled: { value: false },
344
+ previewWidth: { value: 260, validate: RED.validators.number(true) },
345
+ },
346
+ inputs: 1,
347
+ outputs: 1,
348
+ icon: "font-awesome/fa-minus",
349
+ label: function () {
350
+ return this.name || "line finder";
351
+ },
352
+ paletteLabel: function () {
353
+ return "line finder";
354
+ },
355
+ oneditprepare: function () {
356
+ // ---- region geometry ------------------------------------------
357
+ // A copy of regionFrame()/regionCorners() from lib/lineFinder.js: the
358
+ // region rotates about its own centre, and the scan direction decides
359
+ // which corner the calipers start from. The editor has to agree with
360
+ // the runtime exactly or the box drawn is not the box searched, so
361
+ // test/editorRegionGeometry.test.js lifts this very function out of
362
+ // the file and runs it against the runtime one.
363
+ // EDITOR_GEOMETRY_START
364
+ const regionFrameEditor = function (region, scanDirection) {
365
+ const a = (region.angleDeg || 0) * (Math.PI / 180);
366
+ const ca = Math.cos(a);
367
+ const sa = Math.sin(a);
368
+ const ex = { x: ca, y: sa };
369
+ const ey = { x: -sa, y: ca };
370
+ const hw = region.width / 2;
371
+ const hh = region.height / 2;
372
+ let scan;
373
+ let line;
374
+ let depth;
375
+ let length;
376
+ let start;
377
+ switch (scanDirection) {
378
+ case "left":
379
+ scan = { x: -ex.x, y: -ex.y };
380
+ line = ey;
381
+ depth = region.width;
382
+ length = region.height;
383
+ start = { u: hw, v: -hh };
384
+ break;
385
+ case "down":
386
+ scan = ey;
387
+ line = ex;
388
+ depth = region.height;
389
+ length = region.width;
390
+ start = { u: -hw, v: -hh };
391
+ break;
392
+ case "up":
393
+ scan = { x: -ey.x, y: -ey.y };
394
+ line = ex;
395
+ depth = region.height;
396
+ length = region.width;
397
+ start = { u: -hw, v: hh };
398
+ break;
399
+ default: // "right"
400
+ scan = ex;
401
+ line = ey;
402
+ depth = region.width;
403
+ length = region.height;
404
+ start = { u: -hw, v: -hh };
405
+ break;
406
+ }
407
+ const cx = region.x + region.width / 2;
408
+ const cy = region.y + region.height / 2;
409
+ const origin = {
410
+ x: cx + start.u * ex.x + start.v * ey.x,
411
+ y: cy + start.u * ex.y + start.v * ey.y,
412
+ };
413
+ const at = function (u, v) {
414
+ return {
415
+ x: origin.x + scan.x * u + line.x * v,
416
+ y: origin.y + scan.y * u + line.y * v,
417
+ };
418
+ };
419
+ return {
420
+ scan: scan,
421
+ line: line,
422
+ origin: origin,
423
+ depth: depth,
424
+ length: length,
425
+ at: at,
426
+ centre: { x: cx, y: cy },
427
+ ex: ex,
428
+ ey: ey,
429
+ corners: [at(0, 0), at(depth, 0), at(depth, length), at(0, length)],
430
+ };
431
+ };
432
+ // EDITOR_GEOMETRY_END
433
+
434
+ /**
435
+ * The four corners in the region's own order (top-left, top-right,
436
+ * bottom-right, bottom-left before rotation) rather than in scan
437
+ * order, so a resize handle keeps its identity when the scan
438
+ * direction changes.
439
+ */
440
+ const localCorners = function (region) {
441
+ const f = regionFrameEditor(region, "right");
442
+ const hw = region.width / 2;
443
+ const hh = region.height / 2;
444
+ const at = function (u, v) {
445
+ return {
446
+ x: f.centre.x + u * f.ex.x + v * f.ey.x,
447
+ y: f.centre.y + u * f.ex.y + v * f.ey.y,
448
+ };
449
+ };
450
+ return [at(-hw, -hh), at(hw, -hh), at(hw, hh), at(-hw, hh)];
451
+ };
452
+
453
+ /** Pointer position expressed in the region's own axes, from its centre. */
454
+ const toLocal = function (region, px, py) {
455
+ const a = (region.angleDeg || 0) * (Math.PI / 180);
456
+ const ca = Math.cos(a);
457
+ const sa = Math.sin(a);
458
+ const dx = px - (region.x + region.width / 2);
459
+ const dy = py - (region.y + region.height / 2);
460
+ return { u: dx * ca + dy * sa, v: -dx * sa + dy * ca };
461
+ };
462
+
463
+ // ---- fields ----------------------------------------------------
464
+ const canvas = document.getElementById("line-finder-canvas");
465
+ const ctx = canvas.getContext("2d");
466
+ const readout = document.getElementById("line-finder-readout");
467
+ const fileInput = document.getElementById("line-finder-file");
468
+
469
+ const SCAN_ORDER = ["right", "down", "left", "up"];
470
+ const SCAN_ARROW = { right: "→", left: "←", down: "↓", up: "↑" };
471
+ const MIN_SIDE = 4;
472
+
473
+ let image = null;
474
+ let imageW = 0;
475
+ let imageH = 0;
476
+
477
+ const field = function (id) {
478
+ return document.getElementById("node-input-" + id);
479
+ };
480
+ const num = function (id) {
481
+ const v = parseFloat(field(id).value);
482
+ return Number.isFinite(v) ? v : 0;
483
+ };
484
+ const clamp = function (v, lo, hi) {
485
+ return Math.min(hi, Math.max(lo, v));
486
+ };
487
+
488
+ const currentRegion = function () {
489
+ return {
490
+ x: num("regionX"),
491
+ y: num("regionY"),
492
+ width: Math.max(MIN_SIDE, num("regionWidth")),
493
+ height: Math.max(MIN_SIDE, num("regionHeight")),
494
+ angleDeg: num("regionAngleDeg"),
495
+ };
496
+ };
497
+ const currentScan = function () {
498
+ return field("scanDirection").value || "right";
499
+ };
500
+ const currentCalipers = function () {
501
+ const n = parseInt(field("calipers").value, 10);
502
+ return Number.isFinite(n) ? clamp(n, 1, 512) : 16;
503
+ };
504
+ const writeRegion = function (region, scanDirection) {
505
+ field("regionX").value = Math.round(region.x);
506
+ field("regionY").value = Math.round(region.y);
507
+ field("regionWidth").value = Math.max(1, Math.round(region.width));
508
+ field("regionHeight").value = Math.max(1, Math.round(region.height));
509
+ field("regionAngleDeg").value =
510
+ Math.round((region.angleDeg || 0) * 10) / 10;
511
+ field("scanDirection").value = scanDirection;
512
+ };
513
+
514
+ const hasRegion = function (r) {
515
+ return r.width > 0 && r.height > 0;
516
+ };
517
+ const outsideFrame = function (region, scanDirection) {
518
+ if (!imageW || !imageH) return false;
519
+ return regionFrameEditor(region, scanDirection).corners.some(function (p) {
520
+ return p.x < 0 || p.y < 0 || p.x > imageW || p.y > imageH;
521
+ });
522
+ };
523
+
524
+ const describeRegion = function (region, scanDirection) {
525
+ const bands = currentCalipers();
526
+ const f = regionFrameEditor(region, scanDirection);
527
+ return (
528
+ "x " + Math.round(region.x) +
529
+ " y " + Math.round(region.y) +
530
+ " " + Math.round(region.width) + "×" + Math.round(region.height) + " px" +
531
+ " " + (Math.round((region.angleDeg || 0) * 10) / 10) + "°" +
532
+ " scan " + SCAN_ARROW[scanDirection] +
533
+ " " + bands + " calipers of " + (f.length / bands).toFixed(1) + " px"
534
+ );
535
+ };
536
+
537
+ const updateReadout = function () {
538
+ const r = currentRegion();
539
+ if (!hasRegion(r)) {
540
+ readout.textContent = "no region";
541
+ return;
542
+ }
543
+ readout.textContent =
544
+ describeRegion(r, currentScan()) +
545
+ (imageW ? " (image " + imageW + "×" + imageH + ")" : "") +
546
+ (outsideFrame(r, currentScan()) ? " ⚠ past the frame" : "");
547
+ };
548
+
549
+ // ---- shared overlay drawing ------------------------------------
550
+ // Both the thumbnail and the viewer draw the region through this, in
551
+ // screen coordinates, so line weights stay one pixel at any zoom.
552
+ const drawArrow = function (c, x0, y0, x1, y1) {
553
+ c.beginPath();
554
+ c.moveTo(x0, y0);
555
+ c.lineTo(x1, y1);
556
+ c.stroke();
557
+ const a = Math.atan2(y1 - y0, x1 - x0);
558
+ c.beginPath();
559
+ c.moveTo(x1, y1);
560
+ c.lineTo(x1 - 9 * Math.cos(a - 0.4), y1 - 9 * Math.sin(a - 0.4));
561
+ c.lineTo(x1 - 9 * Math.cos(a + 0.4), y1 - 9 * Math.sin(a + 0.4));
562
+ c.closePath();
563
+ c.fill();
564
+ };
565
+
566
+ const drawRegion = function (c, region, scanDirection, toScreen, opts) {
567
+ if (!hasRegion(region)) return;
568
+ const o = opts || {};
569
+ const f = regionFrameEditor(region, scanDirection);
570
+ const S = function (u, v) {
571
+ const p = f.at(u, v);
572
+ return toScreen(p.x, p.y);
573
+ };
574
+
575
+ const box = f.corners.map(function (p) {
576
+ return toScreen(p.x, p.y);
577
+ });
578
+ c.beginPath();
579
+ c.moveTo(box[0].x, box[0].y);
580
+ for (let i = 1; i < box.length; i++) c.lineTo(box[i].x, box[i].y);
581
+ c.closePath();
582
+ c.fillStyle = "rgba(33,150,243,0.12)";
583
+ c.fill();
584
+ // a dark under-stroke so the box reads on pale and dark frames alike
585
+ c.lineJoin = "round";
586
+ c.strokeStyle = "rgba(0,0,0,0.6)";
587
+ c.lineWidth = 3;
588
+ c.stroke();
589
+ c.strokeStyle = "#2196f3";
590
+ c.lineWidth = 1.5;
591
+ c.stroke();
592
+
593
+ // one line per caliper, along the scan axis at the band's centre:
594
+ // this is literally where each measurement is taken
595
+ if (o.showCalipers) {
596
+ const bands = currentCalipers();
597
+ const bandLength = f.length / bands;
598
+ const a0 = S(0, 0);
599
+ const a1 = S(0, bandLength);
600
+ const spacing = Math.hypot(a1.x - a0.x, a1.y - a0.y);
601
+ if (spacing >= 3) {
602
+ c.strokeStyle = "rgba(255,255,255,0.45)";
603
+ c.lineWidth = 1;
604
+ for (let b = 0; b < bands; b++) {
605
+ const v = bandLength * (b + 0.5);
606
+ const a = S(0, v);
607
+ const z = S(f.depth, v);
608
+ c.beginPath();
609
+ c.moveTo(a.x, a.y);
610
+ c.lineTo(z.x, z.y);
611
+ c.stroke();
612
+ }
613
+ }
614
+ }
615
+
616
+ // the edge being looked for runs across the scan, through the middle
617
+ const e0 = S(f.depth / 2, 0);
618
+ const e1 = S(f.depth / 2, f.length);
619
+ c.strokeStyle = "rgba(76,175,80,0.9)";
620
+ c.lineWidth = 1;
621
+ c.setLineDash([5, 4]);
622
+ c.beginPath();
623
+ c.moveTo(e0.x, e0.y);
624
+ c.lineTo(e1.x, e1.y);
625
+ c.stroke();
626
+ c.setLineDash([]);
627
+
628
+ // and the scan direction, from the starting edge inward
629
+ const a0 = S(0, f.length / 2);
630
+ const a1 = S(f.depth, f.length / 2);
631
+ c.strokeStyle = "#e65100";
632
+ c.fillStyle = "#e65100";
633
+ c.lineWidth = 2;
634
+ drawArrow(c, a0.x, a0.y, a1.x, a1.y);
635
+ };
636
+
637
+ // ---- thumbnail -------------------------------------------------
638
+ const fit = function () {
639
+ if (!imageW || !imageH) return { s: 1, dx: 0, dy: 0 };
640
+ const s = Math.min(canvas.width / imageW, canvas.height / imageH);
641
+ return {
642
+ s: s,
643
+ dx: (canvas.width - imageW * s) / 2,
644
+ dy: (canvas.height - imageH * s) / 2,
645
+ };
646
+ };
647
+
648
+ const drawThumb = function () {
649
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
650
+ if (!image) {
651
+ ctx.fillStyle = "#999";
652
+ ctx.font = "12px sans-serif";
653
+ ctx.textAlign = "center";
654
+ ctx.fillText("Load a sample image below,", canvas.width / 2, 106);
655
+ ctx.fillText("then click here for the zoom viewer", canvas.width / 2, 124);
656
+ return;
657
+ }
658
+ const f = fit();
659
+ ctx.drawImage(image, f.dx, f.dy, imageW * f.s, imageH * f.s);
660
+ drawRegion(
661
+ ctx,
662
+ currentRegion(),
663
+ currentScan(),
664
+ function (x, y) {
665
+ return { x: f.dx + x * f.s, y: f.dy + y * f.s };
666
+ },
667
+ { showCalipers: false },
668
+ );
669
+ };
670
+
671
+ // ---- zoomable modal viewer -------------------------------------
672
+ let viewer = null;
673
+
674
+ const openViewer = function () {
675
+ if (!image) {
676
+ readout.textContent = "Load a sample image first.";
677
+ return;
678
+ }
679
+ if (viewer) return;
680
+
681
+ const dpr = window.devicePixelRatio || 1;
682
+ // the region being edited, committed to the fields only on Apply
683
+ let reg = currentRegion();
684
+ let scan = currentScan();
685
+ let showCalipers = true;
686
+ let mode = "draw";
687
+
688
+ const overlay = document.createElement("div");
689
+ overlay.style.cssText =
690
+ "position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,0.78);" +
691
+ "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
692
+ "font:12px/1.4 sans-serif;color:#ddd;";
693
+
694
+ const mkButton = function (text, title) {
695
+ const b = document.createElement("button");
696
+ b.type = "button";
697
+ b.textContent = text;
698
+ b.title = title || "";
699
+ b.style.cssText =
700
+ "background:#4a4d54;color:#eee;border:1px solid #666;border-radius:3px;" +
701
+ "padding:3px 9px;cursor:pointer;font:12px sans-serif;";
702
+ return b;
703
+ };
704
+
705
+ const header = document.createElement("div");
706
+ header.style.cssText =
707
+ "display:flex;align-items:center;gap:6px;width:92vw;max-width:1200px;" +
708
+ "padding:6px 8px;background:#33353a;border:1px solid #555;border-bottom:none;" +
709
+ "border-radius:6px 6px 0 0;box-sizing:border-box;flex:0 0 auto;";
710
+
711
+ const btnZoomOut = mkButton("−", "Zoom out (mouse wheel zooms to cursor)");
712
+ const btnZoomIn = mkButton("+", "Zoom in");
713
+ const btnFit = mkButton("Fit", "Fit the image into the window");
714
+ const btn100 = mkButton("100%", "Actual pixel size");
715
+ const btnMode = mkButton(
716
+ "Mode: Draw",
717
+ "Drag to draw the region; right-drag pans",
718
+ );
719
+ const btnScan = mkButton(
720
+ "Scan " + SCAN_ARROW[scan],
721
+ "Cycle the scan direction (drawing a new region sets it too)",
722
+ );
723
+ const btnCalipers = mkButton("Calipers", "Show or hide the caliper lines");
724
+ const imgInfo = document.createElement("span");
725
+ imgInfo.style.color = "#999";
726
+ const btnClose = mkButton("✕", "Close without applying");
727
+ btnClose.style.marginLeft = "auto";
728
+ header.append(
729
+ btnZoomOut, btnZoomIn, btnFit, btn100,
730
+ btnMode, btnScan, btnCalipers, imgInfo, btnClose,
731
+ );
732
+
733
+ const body = document.createElement("div");
734
+ body.style.cssText =
735
+ "width:92vw;max-width:1200px;height:76vh;background:#232325;" +
736
+ "border:1px solid #555;position:relative;overflow:hidden;box-sizing:border-box;";
737
+
738
+ const viewCanvas = document.createElement("canvas");
739
+ viewCanvas.style.cssText =
740
+ "position:absolute;inset:0;width:100%;height:100%;display:block;" +
741
+ "touch-action:none;cursor:crosshair;";
742
+ body.appendChild(viewCanvas);
743
+ const vctx = viewCanvas.getContext("2d");
744
+
745
+ const footer = document.createElement("div");
746
+ footer.style.cssText =
747
+ "width:92vw;max-width:1200px;padding:6px 8px;background:#33353a;border:1px solid #555;" +
748
+ "border-top:none;border-radius:0 0 6px 6px;box-sizing:border-box;flex:0 0 auto;" +
749
+ "display:flex;align-items:center;gap:10px;";
750
+ const footReadout = document.createElement("span");
751
+ footReadout.style.color = "#bbb";
752
+ footReadout.style.fontFamily = "monospace";
753
+ const btnApply = mkButton("Apply", "Write this region into the node's fields");
754
+ btnApply.style.background = "#3574f0";
755
+ btnApply.style.borderColor = "#3574f0";
756
+ btnApply.style.marginLeft = "auto";
757
+ const btnCancel = mkButton("Cancel", "Close without applying");
758
+ footer.append(footReadout, btnApply, btnCancel);
759
+
760
+ overlay.append(header, body, footer);
761
+ document.body.appendChild(overlay);
762
+ viewer = overlay;
763
+
764
+ // view.scale/ox/oy map image space -> canvas CSS px
765
+ const view = { scale: 1, ox: 0, oy: 0 };
766
+ const toImage = function (ev) {
767
+ const b = viewCanvas.getBoundingClientRect();
768
+ return {
769
+ x: (ev.clientX - b.left - view.ox) / view.scale,
770
+ y: (ev.clientY - b.top - view.oy) / view.scale,
771
+ };
772
+ };
773
+ const toScreen = function (x, y) {
774
+ return { x: view.ox + x * view.scale, y: view.oy + y * view.scale };
775
+ };
776
+ const fitView = function () {
777
+ const w = body.clientWidth;
778
+ const h = body.clientHeight;
779
+ view.scale = Math.min(w / imageW, h / imageH, 1) * 0.96;
780
+ view.ox = (w - imageW * view.scale) / 2;
781
+ view.oy = (h - imageH * view.scale) / 2;
782
+ };
783
+ const zoomAt = function (cx, cy, factor) {
784
+ const ix = (cx - view.ox) / view.scale;
785
+ const iy = (cy - view.oy) / view.scale;
786
+ view.scale = clamp(view.scale * factor, 0.02, 40);
787
+ view.ox = cx - ix * view.scale;
788
+ view.oy = cy - iy * view.scale;
789
+ };
790
+
791
+ // ---- hit testing ---------------------------------------------
792
+ const HANDLE = 5; // half-size in screen px
793
+ const TOL = 11;
794
+ const handlePoints = function () {
795
+ return localCorners(reg).map(function (p) {
796
+ return toScreen(p.x, p.y);
797
+ });
798
+ };
799
+ /** The rotation grip: 30 screen px beyond the +u edge, on the centre line. */
800
+ const rotatePoint = function () {
801
+ const f = regionFrameEditor(reg, "right");
802
+ const edge = {
803
+ x: f.centre.x + (reg.width / 2) * f.ex.x,
804
+ y: f.centre.y + (reg.width / 2) * f.ex.y,
805
+ };
806
+ const s = toScreen(edge.x, edge.y);
807
+ return { x: s.x + f.ex.x * 30, y: s.y + f.ex.y * 30 };
808
+ };
809
+ const hitHandle = function (sx, sy) {
810
+ const pts = handlePoints();
811
+ for (let i = 0; i < pts.length; i++) {
812
+ if (Math.abs(pts[i].x - sx) <= TOL && Math.abs(pts[i].y - sy) <= TOL) {
813
+ return i;
814
+ }
815
+ }
816
+ return -1;
817
+ };
818
+ const hitRotate = function (sx, sy) {
819
+ const r = rotatePoint();
820
+ return Math.hypot(r.x - sx, r.y - sy) <= TOL;
821
+ };
822
+ const inside = function (px, py) {
823
+ const l = toLocal(reg, px, py);
824
+ return (
825
+ Math.abs(l.u) <= reg.width / 2 && Math.abs(l.v) <= reg.height / 2
826
+ );
827
+ };
828
+
829
+ // ---- edits ---------------------------------------------------
830
+ const fromCorners = function (a, b, angleDeg) {
831
+ // a and b are opposite corners in the region's own axes, measured
832
+ // from the *old* centre; rebuild x/y/w/h from them
833
+ const w = Math.max(MIN_SIDE, Math.abs(b.u - a.u));
834
+ const h = Math.max(MIN_SIDE, Math.abs(b.v - a.v));
835
+ const mid = { u: (a.u + b.u) / 2, v: (a.v + b.v) / 2 };
836
+ const rad = (angleDeg || 0) * (Math.PI / 180);
837
+ const ca = Math.cos(rad);
838
+ const sa = Math.sin(rad);
839
+ const cx = reg.x + reg.width / 2 + mid.u * ca - mid.v * sa;
840
+ const cy = reg.y + reg.height / 2 + mid.u * sa + mid.v * ca;
841
+ return { x: cx - w / 2, y: cy - h / 2, width: w, height: h, angleDeg: angleDeg };
842
+ };
843
+ const resizeByHandle = function (handleIndex, px, py) {
844
+ const signs = [
845
+ { u: -1, v: -1 },
846
+ { u: 1, v: -1 },
847
+ { u: 1, v: 1 },
848
+ { u: -1, v: 1 },
849
+ ][handleIndex];
850
+ const fixed = {
851
+ u: -signs.u * (reg.width / 2),
852
+ v: -signs.v * (reg.height / 2),
853
+ };
854
+ const moved = toLocal(reg, px, py);
855
+ return fromCorners(fixed, moved, reg.angleDeg);
856
+ };
857
+ const newRegionFromDrag = function (from, to) {
858
+ const x0 = clamp(Math.min(from.x, to.x), 0, imageW);
859
+ const y0 = clamp(Math.min(from.y, to.y), 0, imageH);
860
+ const x1 = clamp(Math.max(from.x, to.x), 0, imageW);
861
+ const y1 = clamp(Math.max(from.y, to.y), 0, imageH);
862
+ const dx = to.x - from.x;
863
+ const dy = to.y - from.y;
864
+ // the drag's dominant axis and sign is the scan direction: which
865
+ // way the calipers travel is the one thing a static box cannot say
866
+ scan =
867
+ Math.abs(dx) >= Math.abs(dy)
868
+ ? dx >= 0 ? "right" : "left"
869
+ : dy >= 0 ? "down" : "up";
870
+ return {
871
+ x: x0,
872
+ y: y0,
873
+ width: Math.max(MIN_SIDE, x1 - x0),
874
+ height: Math.max(MIN_SIDE, y1 - y0),
875
+ angleDeg: 0,
876
+ };
877
+ };
878
+
879
+ // ---- painting ------------------------------------------------
880
+ let boardPattern = null;
881
+ const drawView = function () {
882
+ const w = body.clientWidth;
883
+ const h = body.clientHeight;
884
+ vctx.clearRect(0, 0, w, h);
885
+ if (!boardPattern) {
886
+ const p = document.createElement("canvas");
887
+ p.width = 28;
888
+ p.height = 28;
889
+ const pc = p.getContext("2d");
890
+ pc.fillStyle = "#26262a";
891
+ pc.fillRect(0, 0, 28, 28);
892
+ pc.fillStyle = "#2d2d32";
893
+ pc.fillRect(0, 0, 14, 14);
894
+ pc.fillRect(14, 14, 14, 14);
895
+ boardPattern = vctx.createPattern(p, "repeat");
896
+ }
897
+ vctx.fillStyle = boardPattern;
898
+ vctx.fillRect(0, 0, w, h);
899
+
900
+ vctx.save();
901
+ vctx.translate(view.ox, view.oy);
902
+ vctx.scale(view.scale, view.scale);
903
+ // past 4x the interesting question is "what does one pixel say",
904
+ // so stop blurring them together
905
+ vctx.imageSmoothingEnabled = view.scale < 4;
906
+ vctx.imageSmoothingQuality = "high";
907
+ vctx.drawImage(image, 0, 0, imageW, imageH);
908
+ vctx.restore();
909
+
910
+ // the frame's own border, so a region hanging off the edge is obvious
911
+ const tl = toScreen(0, 0);
912
+ const br = toScreen(imageW, imageH);
913
+ vctx.strokeStyle = "rgba(255,255,255,0.25)";
914
+ vctx.lineWidth = 1;
915
+ vctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y);
916
+
917
+ drawRegion(vctx, reg, scan, toScreen, { showCalipers: showCalipers });
918
+
919
+ // resize handles and the rotation grip
920
+ const pts = handlePoints();
921
+ for (const p of pts) {
922
+ vctx.fillStyle = "#ffffff";
923
+ vctx.fillRect(p.x - HANDLE, p.y - HANDLE, HANDLE * 2, HANDLE * 2);
924
+ vctx.strokeStyle = "#2196f3";
925
+ vctx.lineWidth = 1.5;
926
+ vctx.strokeRect(p.x - HANDLE, p.y - HANDLE, HANDLE * 2, HANDLE * 2);
927
+ }
928
+ const rp = rotatePoint();
929
+ const cs = toScreen(reg.x + reg.width / 2, reg.y + reg.height / 2);
930
+ vctx.strokeStyle = "rgba(255,255,255,0.5)";
931
+ vctx.lineWidth = 1;
932
+ vctx.setLineDash([3, 3]);
933
+ vctx.beginPath();
934
+ vctx.moveTo(cs.x, cs.y);
935
+ vctx.lineTo(rp.x, rp.y);
936
+ vctx.stroke();
937
+ vctx.setLineDash([]);
938
+ vctx.beginPath();
939
+ vctx.arc(rp.x, rp.y, 6, 0, Math.PI * 2);
940
+ vctx.fillStyle = "#ffb300";
941
+ vctx.fill();
942
+ vctx.strokeStyle = "#7a4f00";
943
+ vctx.stroke();
944
+ };
945
+
946
+ const updateFootReadout = function () {
947
+ imgInfo.textContent = imageW + " × " + imageH + " px";
948
+ let txt = "zoom " + Math.round(view.scale * 100) + "% · " +
949
+ describeRegion(reg, scan);
950
+ if (outsideFrame(reg, scan)) {
951
+ txt += " · ⚠ past the frame: bands outside it are skipped";
952
+ }
953
+ footReadout.textContent = txt;
954
+ };
955
+
956
+ const repaint = function () {
957
+ drawView();
958
+ updateFootReadout();
959
+ };
960
+
961
+ // ---- pointer -------------------------------------------------
962
+ let drag = null;
963
+ viewCanvas.addEventListener("pointerdown", function (ev) {
964
+ if (ev.button !== 0 && ev.button !== 2) return;
965
+ viewCanvas.setPointerCapture(ev.pointerId);
966
+ const b = viewCanvas.getBoundingClientRect();
967
+ const sx = ev.clientX - b.left;
968
+ const sy = ev.clientY - b.top;
969
+ const p = toImage(ev);
970
+ if (ev.button === 2 || mode === "pan") {
971
+ drag = { type: "pan", sx: sx, sy: sy, ox: view.ox, oy: view.oy };
972
+ } else if (hitRotate(sx, sy)) {
973
+ drag = { type: "rotate" };
974
+ } else {
975
+ const handleIndex = hitHandle(sx, sy);
976
+ if (handleIndex >= 0) {
977
+ drag = { type: "handle", handle: handleIndex };
978
+ } else if (inside(p.x, p.y)) {
979
+ drag = { type: "move", ix: p.x, iy: p.y, start: reg };
980
+ } else {
981
+ drag = { type: "new", from: p, prev: reg, prevScan: scan };
982
+ }
983
+ }
984
+ ev.preventDefault();
985
+ });
986
+ viewCanvas.addEventListener("pointermove", function (ev) {
987
+ if (!drag) {
988
+ return;
989
+ }
990
+ const b = viewCanvas.getBoundingClientRect();
991
+ const sx = ev.clientX - b.left;
992
+ const sy = ev.clientY - b.top;
993
+ const p = toImage(ev);
994
+ if (drag.type === "pan") {
995
+ view.ox = drag.ox + (sx - drag.sx);
996
+ view.oy = drag.oy + (sy - drag.sy);
997
+ } else if (drag.type === "new") {
998
+ reg = newRegionFromDrag(drag.from, p);
999
+ btnScan.textContent = "Scan " + SCAN_ARROW[scan];
1000
+ } else if (drag.type === "move") {
1001
+ reg = {
1002
+ x: drag.start.x + (p.x - drag.ix),
1003
+ y: drag.start.y + (p.y - drag.iy),
1004
+ width: drag.start.width,
1005
+ height: drag.start.height,
1006
+ angleDeg: drag.start.angleDeg,
1007
+ };
1008
+ } else if (drag.type === "handle") {
1009
+ reg = resizeByHandle(drag.handle, p.x, p.y);
1010
+ } else if (drag.type === "rotate") {
1011
+ const cx = reg.x + reg.width / 2;
1012
+ const cy = reg.y + reg.height / 2;
1013
+ let deg = (Math.atan2(p.y - cy, p.x - cx) * 180) / Math.PI;
1014
+ // shift snaps to 5 degrees; otherwise a tenth, which is the
1015
+ // finest the field carries
1016
+ deg = ev.shiftKey ? Math.round(deg / 5) * 5 : Math.round(deg * 10) / 10;
1017
+ reg = { x: reg.x, y: reg.y, width: reg.width, height: reg.height, angleDeg: deg };
1018
+ }
1019
+ repaint();
1020
+ });
1021
+ const endDrag = function () {
1022
+ if (!drag) return;
1023
+ if (drag.type === "new" && (reg.width <= MIN_SIDE || reg.height <= MIN_SIDE)) {
1024
+ // a click rather than a drag: keep what was there
1025
+ reg = drag.prev;
1026
+ scan = drag.prevScan;
1027
+ btnScan.textContent = "Scan " + SCAN_ARROW[scan];
1028
+ }
1029
+ drag = null;
1030
+ repaint();
1031
+ };
1032
+ viewCanvas.addEventListener("pointerup", endDrag);
1033
+ viewCanvas.addEventListener("pointercancel", endDrag);
1034
+ viewCanvas.addEventListener("contextmenu", function (ev) {
1035
+ ev.preventDefault();
1036
+ });
1037
+ viewCanvas.addEventListener(
1038
+ "wheel",
1039
+ function (ev) {
1040
+ ev.preventDefault();
1041
+ const b = viewCanvas.getBoundingClientRect();
1042
+ zoomAt(ev.clientX - b.left, ev.clientY - b.top, ev.deltaY < 0 ? 1.25 : 0.8);
1043
+ repaint();
1044
+ },
1045
+ { passive: false },
1046
+ );
1047
+
1048
+ // ---- window plumbing -----------------------------------------
1049
+ const setViewSize = function () {
1050
+ viewCanvas.width = Math.round(body.clientWidth * dpr);
1051
+ viewCanvas.height = Math.round(body.clientHeight * dpr);
1052
+ vctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1053
+ repaint();
1054
+ };
1055
+ const closeViewer = function () {
1056
+ if (!viewer) return;
1057
+ window.removeEventListener("keydown", onKeyDown, true);
1058
+ window.removeEventListener("resize", onResize);
1059
+ overlay.remove();
1060
+ viewer = null;
1061
+ };
1062
+ const nudge = function (dx, dy) {
1063
+ reg = {
1064
+ x: reg.x + dx,
1065
+ y: reg.y + dy,
1066
+ width: reg.width,
1067
+ height: reg.height,
1068
+ angleDeg: reg.angleDeg,
1069
+ };
1070
+ repaint();
1071
+ };
1072
+ const onKeyDown = function (ev) {
1073
+ if (ev.key === "Escape") {
1074
+ ev.preventDefault();
1075
+ ev.stopPropagation();
1076
+ closeViewer();
1077
+ return;
1078
+ }
1079
+ const step = ev.shiftKey ? 10 : 1;
1080
+ if (ev.key === "ArrowLeft") nudge(-step, 0);
1081
+ else if (ev.key === "ArrowRight") nudge(step, 0);
1082
+ else if (ev.key === "ArrowUp") nudge(0, -step);
1083
+ else if (ev.key === "ArrowDown") nudge(0, step);
1084
+ else if (
1085
+ ev.key === "Enter" &&
1086
+ !(ev.target && ev.target instanceof Element && overlay.contains(ev.target))
1087
+ ) {
1088
+ // keep Enter from saving the whole edit dialog behind the modal
1089
+ ev.preventDefault();
1090
+ ev.stopPropagation();
1091
+ return;
1092
+ } else {
1093
+ return;
1094
+ }
1095
+ ev.preventDefault();
1096
+ ev.stopPropagation();
1097
+ };
1098
+ const onResize = function () {
1099
+ setViewSize();
1100
+ };
1101
+
1102
+ btnZoomIn.addEventListener("click", function () {
1103
+ zoomAt(body.clientWidth / 2, body.clientHeight / 2, 1.5);
1104
+ repaint();
1105
+ });
1106
+ btnZoomOut.addEventListener("click", function () {
1107
+ zoomAt(body.clientWidth / 2, body.clientHeight / 2, 1 / 1.5);
1108
+ repaint();
1109
+ });
1110
+ btnFit.addEventListener("click", function () {
1111
+ fitView();
1112
+ repaint();
1113
+ });
1114
+ btn100.addEventListener("click", function () {
1115
+ view.scale = 1;
1116
+ view.ox = (body.clientWidth - imageW) / 2;
1117
+ view.oy = (body.clientHeight - imageH) / 2;
1118
+ repaint();
1119
+ });
1120
+ btnMode.addEventListener("click", function () {
1121
+ mode = mode === "draw" ? "pan" : "draw";
1122
+ btnMode.textContent = "Mode: " + (mode === "draw" ? "Draw" : "Pan");
1123
+ btnMode.title =
1124
+ mode === "draw"
1125
+ ? "Drag to draw the region; right-drag pans"
1126
+ : "Drag to pan the image; switch back to Draw to edit the region";
1127
+ viewCanvas.style.cursor = mode === "draw" ? "crosshair" : "grab";
1128
+ });
1129
+ btnScan.addEventListener("click", function () {
1130
+ scan = SCAN_ORDER[(SCAN_ORDER.indexOf(scan) + 1) % SCAN_ORDER.length];
1131
+ btnScan.textContent = "Scan " + SCAN_ARROW[scan];
1132
+ repaint();
1133
+ });
1134
+ btnCalipers.addEventListener("click", function () {
1135
+ showCalipers = !showCalipers;
1136
+ btnCalipers.style.opacity = showCalipers ? "1" : "0.55";
1137
+ repaint();
1138
+ });
1139
+ btnClose.addEventListener("click", closeViewer);
1140
+ btnCancel.addEventListener("click", closeViewer);
1141
+ btnApply.addEventListener("click", function () {
1142
+ writeRegion(reg, scan);
1143
+ updateReadout();
1144
+ drawThumb();
1145
+ closeViewer();
1146
+ });
1147
+
1148
+ window.addEventListener("keydown", onKeyDown, true);
1149
+ window.addEventListener("resize", onResize);
1150
+ overlay.addEventListener(
1151
+ "wheel",
1152
+ function (ev) {
1153
+ ev.preventDefault();
1154
+ },
1155
+ { passive: false },
1156
+ );
1157
+
1158
+ // open zoomed to the region when there is one worth looking at: the
1159
+ // whole point of the viewer is the pixels at the edge
1160
+ fitView();
1161
+ if (hasRegion(reg) && imageW) {
1162
+ const f = regionFrameEditor(reg, scan);
1163
+ const want = Math.min(
1164
+ body.clientWidth / Math.max(20, reg.width * 1.6),
1165
+ body.clientHeight / Math.max(20, reg.height * 1.6),
1166
+ );
1167
+ if (Number.isFinite(want) && want > view.scale) {
1168
+ view.scale = clamp(want, view.scale, 8);
1169
+ view.ox = body.clientWidth / 2 - f.centre.x * view.scale;
1170
+ view.oy = body.clientHeight / 2 - f.centre.y * view.scale;
1171
+ }
1172
+ }
1173
+ setViewSize();
1174
+ btnApply.focus();
1175
+ };
1176
+
1177
+ // ---- wiring ----------------------------------------------------
1178
+ canvas.addEventListener("click", openViewer);
1179
+ document
1180
+ .getElementById("line-finder-open-viewer")
1181
+ .addEventListener("click", openViewer);
1182
+
1183
+ fileInput.addEventListener("change", function () {
1184
+ const file = fileInput.files && fileInput.files[0];
1185
+ if (!file) return;
1186
+ const url = URL.createObjectURL(file);
1187
+ const img = new Image();
1188
+ img.onload = function () {
1189
+ image = img;
1190
+ imageW = img.naturalWidth;
1191
+ imageH = img.naturalHeight;
1192
+ URL.revokeObjectURL(url);
1193
+ updateReadout();
1194
+ drawThumb();
1195
+ openViewer();
1196
+ };
1197
+ img.onerror = function () {
1198
+ URL.revokeObjectURL(url);
1199
+ RED.notify("line-finder: could not read that image", "error");
1200
+ };
1201
+ img.src = url;
1202
+ });
1203
+
1204
+ document
1205
+ .getElementById("line-finder-clear")
1206
+ .addEventListener("click", function () {
1207
+ writeRegion(
1208
+ { x: 0, y: 0, width: 100, height: 100, angleDeg: 0 },
1209
+ currentScan(),
1210
+ );
1211
+ updateReadout();
1212
+ drawThumb();
1213
+ });
1214
+
1215
+ for (const id of [
1216
+ "regionX",
1217
+ "regionY",
1218
+ "regionWidth",
1219
+ "regionHeight",
1220
+ "regionAngleDeg",
1221
+ "scanDirection",
1222
+ "calipers",
1223
+ ]) {
1224
+ field(id).addEventListener("change", function () {
1225
+ updateReadout();
1226
+ drawThumb();
1227
+ });
1228
+ }
1229
+
1230
+ // preview width only means anything when the preview is on
1231
+ const previewCheckbox = document.getElementById("node-input-previewEnabled");
1232
+ const previewWidthRow = document.getElementById("line-finder-preview-width-row");
1233
+ const updatePreviewVisibility = function () {
1234
+ previewWidthRow.style.display = previewCheckbox.checked ? "" : "none";
1235
+ };
1236
+ previewCheckbox.addEventListener("change", updatePreviewVisibility);
1237
+ updatePreviewVisibility();
1238
+
1239
+ updateReadout();
1240
+ drawThumb();
1241
+ },
1242
+ });
1243
+
1244
+ // ---- flow-canvas preview -----------------------------------------
1245
+ // One subscription for every line-finder node, registered once. The
1246
+ // overlay is drawn from image-pixel coordinates scaled to the
1247
+ // thumbnail, so a rotated region shows as the parallelogram it is
1248
+ // rather than as a misleading upright box.
1249
+ if (!window.lineFinderPreviewRendererInitialized) {
1250
+ window.lineFinderPreviewRendererInitialized = true;
1251
+
1252
+ const removePreview = function (nodeId) {
1253
+ const existing = document.getElementById(
1254
+ "line-finder-preview-container-" + nodeId,
1255
+ );
1256
+ if (existing) existing.remove();
1257
+ };
1258
+
1259
+ RED.comms.subscribe("line-finder-preview", function (_event, data) {
1260
+ if (!data || !data.id) return;
1261
+ if (data.clear || !data.image) {
1262
+ removePreview(data.id);
1263
+ return;
1264
+ }
1265
+ const nodeElement = document.getElementById(data.id);
1266
+ if (!nodeElement) return;
1267
+ removePreview(data.id);
1268
+
1269
+ const width = Number(data.previewWidth) || 260;
1270
+ const scale = width / data.imageWidth;
1271
+ const height = Math.round(data.imageHeight * scale);
1272
+
1273
+ const container = document.createElementNS(
1274
+ "http://www.w3.org/2000/svg",
1275
+ "foreignObject",
1276
+ );
1277
+ container.id = "line-finder-preview-container-" + data.id;
1278
+ container.setAttribute("x", "70");
1279
+ container.setAttribute("y", "5");
1280
+ container.setAttribute("width", String(width + 20));
1281
+ container.setAttribute("height", String(height + 46));
1282
+ container.style.overflow = "visible";
1283
+
1284
+ const body = document.createElementNS("http://www.w3.org/1999/xhtml", "div");
1285
+ body.style.padding = "5px";
1286
+ body.style.background = "rgba(32, 32, 32, 0.9)";
1287
+ body.style.borderRadius = "4px";
1288
+ body.style.color = "white";
1289
+ body.style.font = "11px sans-serif";
1290
+ body.style.cursor = "pointer";
1291
+ body.title = "line-finder result (click to hide)";
1292
+ body.onclick = function () {
1293
+ removePreview(data.id);
1294
+ };
1295
+
1296
+ const caption = document.createElementNS("http://www.w3.org/1999/xhtml", "div");
1297
+ caption.style.paddingBottom = "3px";
1298
+ caption.style.textAlign = "center";
1299
+ caption.textContent = data.found
1300
+ ? data.angleDeg.toFixed(2) +
1301
+ "° · " + data.calipers.used + "/" + data.calipers.total +
1302
+ " · score " + data.score.toFixed(2) +
1303
+ " · resid " + data.residualPx.toFixed(2) + "px"
1304
+ : "not found (" + data.reason + ")";
1305
+ caption.style.color = data.found ? "#a5d6a7" : "#ffcc80";
1306
+
1307
+ const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
1308
+ canvas.width = width;
1309
+ canvas.height = height;
1310
+ canvas.style.display = "block";
1311
+ canvas.style.width = width + "px";
1312
+ canvas.style.height = height + "px";
1313
+
1314
+ const img = new Image();
1315
+ img.onload = function () {
1316
+ const ctx = canvas.getContext("2d");
1317
+ ctx.drawImage(img, 0, 0, width, height);
1318
+ const sx = function (v) { return v * scale; };
1319
+
1320
+ // the search region, as drawn
1321
+ if (Array.isArray(data.region) && data.region.length === 4) {
1322
+ ctx.strokeStyle = "#2196f3";
1323
+ ctx.lineWidth = 1;
1324
+ ctx.fillStyle = "rgba(33,150,243,0.15)";
1325
+ ctx.beginPath();
1326
+ ctx.moveTo(sx(data.region[0].x), sx(data.region[0].y));
1327
+ for (let i = 1; i < 4; i++) {
1328
+ ctx.lineTo(sx(data.region[i].x), sx(data.region[i].y));
1329
+ }
1330
+ ctx.closePath();
1331
+ ctx.fill();
1332
+ ctx.stroke();
1333
+ }
1334
+
1335
+ // every caliper hit: green kept, red dropped by the outlier trim
1336
+ for (const p of data.points || []) {
1337
+ ctx.fillStyle = p.used ? "#66bb6a" : "#ef5350";
1338
+ ctx.beginPath();
1339
+ ctx.arc(sx(p.x), sx(p.y), 1.8, 0, Math.PI * 2);
1340
+ ctx.fill();
1341
+ }
1342
+
1343
+ // the fitted line
1344
+ if (data.line) {
1345
+ ctx.strokeStyle = "#ffeb3b";
1346
+ ctx.lineWidth = 1.5;
1347
+ ctx.beginPath();
1348
+ ctx.moveTo(sx(data.line.p0.x), sx(data.line.p0.y));
1349
+ ctx.lineTo(sx(data.line.p1.x), sx(data.line.p1.y));
1350
+ ctx.stroke();
1351
+ }
1352
+ };
1353
+ img.src = "data:image/" + (data.mimeType || "jpeg") + ";base64," + data.image;
1354
+
1355
+ body.appendChild(caption);
1356
+ body.appendChild(canvas);
1357
+ container.appendChild(body);
1358
+ nodeElement.appendChild(container);
1359
+ });
1360
+ }
1361
+ </script>