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