@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/line-finder.html
ADDED
|
@@ -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>
|