Web platform for sharing free data for ML and research

By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 picture-annotation.py

View raw Download
text/x-script.python • 33.02 kiB
Python script, ASCII text executable
        
            
1
import pyscript
2
import js
3
from pyscript import document, fetch as pyfetch
4
from pyscript.ffi import create_proxy
5
import asyncio
6
import json
7
8
document.getElementById("shape-options").style.display = "flex"
9
10
image = document.getElementById("annotation-image")
11
zone = document.getElementById("annotation-zone")
12
confirm_button = document.getElementById("annotation-confirm")
13
cancel_button = document.getElementById("annotation-cancel")
14
backspace_button = document.getElementById("annotation-backspace")
15
delete_button = document.getElementById("annotation-delete")
16
previous_button = document.getElementById("annotation-previous")
17
next_button = document.getElementById("annotation-next")
18
save_button = document.getElementById("annotation-save")
19
zoom_slider = document.getElementById("zoom-slider")
20
zoom_in_button = document.getElementById("zoom-in")
21
zoom_out_button = document.getElementById("zoom-out")
22
zoom_indicator = document.getElementById("zoom-indicator")
23
24
object_list = document.getElementById("object-types")
25
object_list_content = document.getElementById("object-types-content")
26
object_list_filter = document.getElementById("filter-object-types")
27
28
confirm_button.style.display = "none"
29
cancel_button.style.display = "none"
30
backspace_button.style.display = "none"
31
delete_button.style.display = "none"
32
previous_button.style.display = "none"
33
next_button.style.display = "none"
34
shape_type = ""
35
bbox_pos = None
36
new_shape = None
37
selected_shape = None
38
39
40
def make_shape_container():
41
shape = document.createElementNS("http://www.w3.org/2000/svg", "svg")
42
shape.setAttribute("width", "100%")
43
shape.setAttribute("height", "100%")
44
shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}")
45
shape.classList.add("shape-container")
46
47
return shape
48
49
50
async def get_all_objects():
51
response = await pyfetch("/api/object-types")
52
if response.ok:
53
return await response.json()
54
55
56
def follow_cursor(event):
57
rect = zone.getBoundingClientRect()
58
x = (event.clientX - rect.left) / scale
59
y = (event.clientY - rect.top) / scale
60
vertical_ruler.style.left = str(x) + "px"
61
horizontal_ruler.style.top = str(y) + "px"
62
63
64
def change_object_type(event):
65
global selected_shape
66
if selected_shape is None:
67
return
68
selected_shape.setAttribute("data-object-type", event.currentTarget.value)
69
70
71
change_object_type_proxy = create_proxy(change_object_type)
72
73
74
def list_shapes():
75
shapes = list(zone.getElementsByClassName("shape"))
76
json_shapes = []
77
for shape in shapes:
78
shape_dict = {}
79
if shape.tagName == "rect":
80
shape_dict["type"] = "bbox"
81
shape_dict["shape"] = {
82
"x": float(shape.getAttribute("x")) / image.naturalWidth,
83
"y": float(shape.getAttribute("y")) / image.naturalHeight,
84
"w": float(shape.getAttribute("width")) / image.naturalWidth,
85
"h": float(shape.getAttribute("height")) / image.naturalHeight
86
}
87
elif shape.tagName == "polygon" or shape.tagName == "polyline":
88
if shape.tagName == "polygon":
89
shape_dict["type"] = "polygon"
90
elif shape.tagName == "polyline":
91
shape_dict["type"] = "polyline"
92
93
points = shape.getAttribute("points").split(" ")
94
json_points = []
95
for point in points:
96
x, y = point.split(",")
97
x, y = float(x), float(y)
98
json_points.append({
99
"x": x / image.naturalWidth,
100
"y": y / image.naturalHeight
101
})
102
103
shape_dict["shape"] = json_points
104
elif shape.tagName == "circle" and shape.classList.contains("shape-point"):
105
shape_dict["type"] = "point"
106
shape_dict["shape"] = {
107
"x": float(shape.getAttribute("cx")) / image.naturalWidth,
108
"y": float(shape.getAttribute("cy")) / image.naturalHeight
109
}
110
else:
111
continue
112
113
shape_dict["object"] = shape.getAttribute("data-object-type")
114
json_shapes.append(shape_dict)
115
116
return json_shapes
117
118
119
def put_shapes(json_shapes):
120
for shape in json_shapes:
121
new_shape = make_shape_container()
122
zone_rect = zone.getBoundingClientRect()
123
124
if shape["type"] == "bbox":
125
rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect")
126
rectangle.setAttribute("x", str(shape["shape"]["x"] * image.naturalWidth))
127
rectangle.setAttribute("y", str(shape["shape"]["y"] * image.naturalHeight))
128
rectangle.setAttribute("width", str(shape["shape"]["w"] * image.naturalWidth))
129
rectangle.setAttribute("height", str(shape["shape"]["h"] * image.naturalHeight))
130
rectangle.setAttribute("fill", "none")
131
rectangle.setAttribute("data-object-type", shape["object"] or "")
132
rectangle.classList.add("shape-bbox")
133
rectangle.classList.add("shape")
134
new_shape.appendChild(rectangle)
135
elif shape["type"] == "polygon" or shape["type"] == "polyline":
136
polygon = document.createElementNS("http://www.w3.org/2000/svg", shape["type"])
137
points = " ".join(
138
[f"{point['x'] * image.naturalWidth},{point['y'] * image.naturalHeight}" for point in shape["shape"]])
139
polygon.setAttribute("points", points)
140
polygon.setAttribute("fill", "none")
141
polygon.setAttribute("data-object-type", shape["object"] or "")
142
polygon.classList.add(f"shape-{shape['type']}")
143
polygon.classList.add("shape")
144
new_shape.appendChild(polygon)
145
elif shape["type"] == "point":
146
point = document.createElementNS("http://www.w3.org/2000/svg", "circle")
147
point.setAttribute("cx", str(shape["shape"]["x"] * image.naturalWidth))
148
point.setAttribute("cy", str(shape["shape"]["y"] * image.naturalHeight))
149
point.setAttribute("r", "0")
150
point.classList.add("shape-point")
151
point.classList.add("shape")
152
point.setAttribute("data-object-type", shape["object"] or "")
153
new_shape.appendChild(point)
154
155
zone.appendChild(new_shape)
156
157
158
async def load_shapes():
159
resource_id = document.getElementById("resource-id").value
160
response = await pyfetch(f"/picture/{resource_id}/get-annotations")
161
if response.ok:
162
shapes = await response.json()
163
return shapes
164
165
166
async def save_shapes(event):
167
shapes = list_shapes()
168
resource_id = document.getElementById("resource-id").value
169
print("Saving shapes:", shapes)
170
response = await pyfetch(f"/picture/{resource_id}/save-annotations",
171
method="POST",
172
headers={
173
"Content-Type": "application/json"
174
},
175
body=json.dumps(shapes)
176
)
177
if response.ok:
178
return await response
179
180
181
save_shapes_proxy = create_proxy(save_shapes)
182
save_button.addEventListener("click", save_shapes_proxy)
183
delete_shape_key_proxy = create_proxy(lambda event: event.key == "Delete" and delete_shape_proxy(None))
184
next_shape_key_proxy = create_proxy(lambda event: event.key == "ArrowRight" and next_shape_proxy(None))
185
previous_shape_key_proxy = create_proxy(lambda event: event.key == "ArrowLeft" and previous_shape_proxy(None))
186
187
188
def get_centre(shape):
189
if shape.tagName == "rect":
190
x = float(shape.getAttribute("x")) + float(shape.getAttribute("width")) / 2
191
y = float(shape.getAttribute("y")) + float(shape.getAttribute("height")) / 2
192
elif shape.tagName == "polygon":
193
points = shape.getAttribute("points").split(" ")
194
# Average of the extreme points
195
sorted_x = sorted([float(point.split(",")[0]) for point in points])
196
sorted_y = sorted([float(point.split(",")[1]) for point in points])
197
top = sorted_y[0]
198
bottom = sorted_y[-1]
199
left = sorted_x[0]
200
right = sorted_x[-1]
201
202
x = (left + right) / 2
203
y = (top + bottom) / 2
204
elif shape.tagName == "polyline":
205
points = shape.getAttribute("points").split(" ")
206
# Median point
207
x = float(points[len(points) // 2].split(",")[0])
208
y = float(points[len(points) // 2].split(",")[1])
209
elif shape.tagName == "circle" and shape.classList.contains("shape-point"):
210
x = float(shape.getAttribute("cx"))
211
y = float(shape.getAttribute("cy"))
212
else:
213
return None
214
215
return x, y
216
217
218
async def update_object_list_filter(event):
219
filter_text = event.currentTarget.value
220
for label in object_list_content.children:
221
if label.innerText.lower().find(filter_text.lower()) == -1:
222
label.style.display = "none"
223
else:
224
label.style.display = "flex"
225
226
227
async def focus_shape(shape):
228
global selected_shape
229
230
if shape_type != "select":
231
return
232
if selected_shape is not None:
233
selected_shape.classList.remove("selected")
234
235
selected_shape = shape
236
237
selected_shape.classList.add("selected")
238
239
objects = await get_all_objects()
240
241
delete_button.style.display = "flex"
242
next_button.style.display = "flex"
243
previous_button.style.display = "flex"
244
document.addEventListener("keydown", delete_shape_key_proxy)
245
document.addEventListener("keydown", next_shape_key_proxy)
246
document.addEventListener("keydown", previous_shape_key_proxy)
247
248
object_list_content.innerHTML = ""
249
object_list_filter.value = ""
250
new_radio = document.createElement("input")
251
new_radio.setAttribute("type", "radio")
252
new_radio.setAttribute("name", "object-type")
253
new_radio.setAttribute("tabindex", "1") # Focus first
254
new_radio.setAttribute("value", "")
255
new_label = document.createElement("label")
256
new_label.appendChild(new_radio)
257
new_label.append("Undefined")
258
object_list_content.appendChild(new_label)
259
new_radio.addEventListener("change", change_object_type_proxy)
260
261
selected_object = selected_shape.getAttribute("data-object-type")
262
if not selected_object:
263
new_radio.setAttribute("checked", "")
264
265
for object, description in objects.items():
266
new_radio = document.createElement("input")
267
new_radio.setAttribute("type", "radio")
268
new_radio.setAttribute("name", "object-type")
269
new_radio.setAttribute("value", object)
270
if selected_object == object:
271
new_radio.setAttribute("checked", "")
272
new_label = document.createElement("label")
273
new_label.appendChild(new_radio)
274
new_label.append(object)
275
object_list_content.appendChild(new_label)
276
new_radio.addEventListener("change", change_object_type_proxy)
277
278
object_list.style.display = "flex"
279
object_list_filter.focus()
280
object_list_filter.addEventListener("input", update_object_list_filter)
281
object_list.style.left = str(get_centre(shape)[0] / image.naturalWidth * 100) + "%"
282
object_list.style.top = str(get_centre(shape)[1] / image.naturalHeight * 100) + "%"
283
object_list.style.right = "auto"
284
object_list.style.bottom = "auto"
285
object_list.style.maxHeight = str(image.height - get_centre(shape)[1] / image.naturalHeight * image.height) + "px"
286
object_list.style.maxWidth = str(image.width - get_centre(shape)[0] / image.naturalWidth * image.width) + "px"
287
transform_origin = ["top", "left"]
288
289
if get_centre(shape)[0] / image.naturalWidth * image.width + object_list.offsetWidth > image.width:
290
object_list.style.left = "auto"
291
object_list.style.right = str((image.naturalWidth - get_centre(shape)[0]) / image.naturalWidth * 100) + "%"
292
object_list.style.maxWidth = str(get_centre(shape)[0] / image.naturalWidth * image.width) + "px"
293
transform_origin[1] = "right"
294
295
if get_centre(shape)[1] / image.naturalHeight * image.height + object_list.offsetHeight > image.height:
296
object_list.style.top = "auto"
297
object_list.style.bottom = str((image.naturalHeight - get_centre(shape)[1]) / image.naturalHeight * 100) + "%"
298
object_list.style.maxHeight = str(get_centre(shape)[1] / image.naturalHeight * image.height) + "px"
299
transform_origin[0] = "bottom"
300
301
object_list.style.transformOrigin = " ".join(transform_origin)
302
303
304
async def select_shape(event):
305
await focus_shape(event.target)
306
307
308
async def next_shape(event):
309
global selected_shape
310
if selected_shape is None:
311
return
312
313
selected_svg = selected_shape.parentNode
314
315
while selected_svg is not None:
316
next_sibling = selected_svg.nextElementSibling
317
if next_sibling and next_sibling.classList.contains("shape-container"):
318
selected_svg = next_sibling
319
break
320
elif next_sibling is None:
321
# If no more siblings, loop back to the first child
322
selected_svg = selected_svg.parentNode.firstElementChild
323
while selected_svg is not None and not selected_svg.classList.contains(
324
"shape-container"):
325
selected_svg = selected_svg.nextElementSibling
326
break
327
else:
328
selected_svg = next_sibling
329
330
if selected_svg:
331
shape = selected_svg.firstElementChild
332
await focus_shape(shape)
333
334
event.preventDefault()
335
336
337
async def previous_shape(event):
338
global selected_shape
339
if selected_shape is None:
340
return
341
342
selected_svg = selected_shape.parentNode
343
344
while selected_svg is not None:
345
next_sibling = selected_svg.previousElementSibling
346
if next_sibling and next_sibling.classList.contains("shape-container"):
347
selected_svg = next_sibling
348
break
349
elif next_sibling is None:
350
# If no more siblings, loop back to the last child
351
selected_svg = selected_svg.parentNode.lastElementChild
352
while selected_svg is not None and not selected_svg.classList.contains(
353
"shape-container"):
354
selected_svg = selected_svg.previousElementSibling
355
break
356
else:
357
selected_svg = next_sibling
358
359
if selected_svg:
360
shape = selected_svg.firstElementChild
361
await focus_shape(shape)
362
363
event.preventDefault()
364
365
366
def unselect_shape(event):
367
global selected_shape
368
369
if selected_shape is not None:
370
selected_shape.classList.remove("selected")
371
selected_shape = None
372
373
object_list_content.innerHTML = ""
374
object_list.style.display = "none"
375
delete_button.style.display = "none"
376
next_button.style.display = "none"
377
previous_button.style.display = "none"
378
document.removeEventListener("keydown", delete_shape_key_proxy)
379
document.removeEventListener("keydown", next_shape_key_proxy)
380
document.removeEventListener("keydown", previous_shape_key_proxy)
381
382
383
def delete_shape(event):
384
global selected_shape
385
if selected_shape is None:
386
return
387
# Shape is SVG shape inside SVG so we need to remove the parent SVG
388
selected_shape.parentNode.remove()
389
selected_shape = None
390
object_list_content.innerHTML = ""
391
object_list.style.display = "none"
392
delete_button.style.display = "none"
393
next_button.style.display = "none"
394
previous_button.style.display = "none"
395
document.removeEventListener("keydown", delete_shape_key_proxy)
396
document.removeEventListener("keydown", next_shape_key_proxy)
397
document.removeEventListener("keydown", previous_shape_key_proxy)
398
399
400
select_shape_proxy = create_proxy(select_shape)
401
unselect_shape_proxy = create_proxy(unselect_shape)
402
delete_shape_proxy = create_proxy(delete_shape)
403
next_shape_proxy = create_proxy(next_shape)
404
previous_shape_proxy = create_proxy(previous_shape)
405
406
delete_button.addEventListener("click", delete_shape_proxy)
407
next_button.addEventListener("click", next_shape_proxy)
408
previous_button.addEventListener("click", previous_shape_proxy)
409
410
# These are functions usable in JS
411
cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event))
412
make_bbox_proxy = create_proxy(lambda event: make_bbox(event))
413
make_polygon_proxy = create_proxy(lambda event: make_polygon(event))
414
follow_cursor_proxy = create_proxy(follow_cursor)
415
416
417
def switch_shape(event):
418
global shape_type
419
object_list_content.innerHTML = ""
420
unselect_shape(None)
421
shape = event.currentTarget.id
422
shape_type = shape
423
for button in list(document.getElementById("shape-selector").children):
424
if not button.classList.contains("button-flat"):
425
button.classList.add("button-flat")
426
event.currentTarget.classList.remove("button-flat")
427
if shape_type == "select":
428
# Add event listeners to existing shapes
429
print(len(list(document.getElementsByClassName("shape"))), "shapes found")
430
for shape in document.getElementsByClassName("shape"):
431
print("Adding event listener to shape:", shape)
432
shape.addEventListener("click", select_shape_proxy)
433
image.addEventListener("click", unselect_shape_proxy)
434
helper_message.innerText = "Click on a shape to select"
435
# Cancel the current shape creation
436
if shape_type == "shape-bbox":
437
cancel_bbox(None)
438
elif shape_type == "shape-polygon":
439
cancel_polygon(None)
440
elif shape_type == "shape-polyline":
441
cancel_polygon(None)
442
else:
443
# Remove event listeners for selection
444
for shape in document.getElementsByClassName("shape"):
445
print("Removing event listener from shape:", shape)
446
shape.removeEventListener("click", select_shape_proxy)
447
image.removeEventListener("click", unselect_shape_proxy)
448
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
449
print("Shape is now of type:", shape)
450
451
452
def select_mode():
453
global shape_type
454
document.getElementById("select").click()
455
456
457
vertical_ruler = document.getElementById("annotation-ruler-vertical")
458
horizontal_ruler = document.getElementById("annotation-ruler-horizontal")
459
vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary")
460
horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary")
461
helper_message = document.getElementById("annotation-helper-message")
462
463
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
464
465
466
def cancel_bbox(event):
467
global bbox_pos, new_shape
468
469
# Key must be ESCAPE
470
if event is not None and hasattr(event, "key") and event.key != "Escape":
471
return
472
473
if new_shape is not None and event is not None:
474
# Require event so the shape is kept when it ends normally
475
new_shape.remove()
476
zone.removeEventListener("click", make_bbox_proxy)
477
document.removeEventListener("keydown", cancel_bbox_proxy)
478
cancel_button.removeEventListener("click", cancel_bbox_proxy)
479
480
bbox_pos = None
481
vertical_ruler.style.display = "none"
482
horizontal_ruler.style.display = "none"
483
vertical_ruler_2.style.display = "none"
484
horizontal_ruler_2.style.display = "none"
485
zone.style.cursor = "auto"
486
cancel_button.style.display = "none"
487
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
488
new_shape = None
489
update_transform()
490
491
492
def make_bbox(event):
493
global new_shape, bbox_pos
494
zone_rect = zone.getBoundingClientRect()
495
496
if bbox_pos is None:
497
helper_message.innerText = "Now define the second point"
498
499
bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width,
500
(event.clientY - zone_rect.top) / zone_rect.height]
501
vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%"
502
horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%"
503
vertical_ruler_2.style.display = "block"
504
horizontal_ruler_2.style.display = "block"
505
506
else:
507
x0, y0 = bbox_pos.copy()
508
x1 = (event.clientX - zone_rect.left) / zone_rect.width
509
y1 = (event.clientY - zone_rect.top) / zone_rect.height
510
511
rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect")
512
513
new_shape = make_shape_container()
514
zone_rect = zone.getBoundingClientRect()
515
516
new_shape.appendChild(rectangle)
517
zone.appendChild(new_shape)
518
519
minx = min(x0, x1)
520
miny = min(y0, y1)
521
maxx = max(x0, x1)
522
maxy = max(y0, y1)
523
524
rectangle.setAttribute("x", str(minx * image.naturalWidth))
525
rectangle.setAttribute("y", str(miny * image.naturalHeight))
526
rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth))
527
rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight))
528
rectangle.setAttribute("fill", "none")
529
rectangle.setAttribute("data-object-type", "")
530
rectangle.classList.add("shape-bbox")
531
rectangle.classList.add("shape")
532
533
# Add event listeners to the new shape
534
rectangle.addEventListener("click", select_shape_proxy)
535
536
cancel_bbox(None)
537
538
539
polygon_points = []
540
541
542
def make_polygon(event):
543
global new_shape, polygon_points
544
545
polygon = new_shape.children[0]
546
547
zone_rect = zone.getBoundingClientRect()
548
549
polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width,
550
(event.clientY - zone_rect.top) / zone_rect.height))
551
552
# Update the polygon
553
polygon.setAttribute("points", " ".join(
554
[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in
555
polygon_points]))
556
557
558
def reset_polygon():
559
global new_shape, polygon_points
560
561
zone.removeEventListener("click", make_polygon_proxy)
562
document.removeEventListener("keydown", close_polygon_proxy)
563
document.removeEventListener("keydown", cancel_polygon_proxy)
564
document.removeEventListener("keydown", backspace_polygon_proxy)
565
confirm_button.style.display = "none"
566
cancel_button.style.display = "none"
567
backspace_button.style.display = "none"
568
confirm_button.removeEventListener("click", close_polygon_proxy)
569
cancel_button.removeEventListener("click", cancel_polygon_proxy)
570
backspace_button.removeEventListener("click", backspace_polygon_proxy)
571
polygon_points.clear()
572
573
zone.style.cursor = "auto"
574
new_shape = None
575
576
update_transform()
577
578
579
def close_polygon(event):
580
if event is not None and hasattr(event, "key") and event.key != "Enter":
581
return
582
# Polygon is already there, but we need to remove the events
583
reset_polygon()
584
585
586
def cancel_polygon(event):
587
if event is not None and hasattr(event, "key") and event.key != "Escape":
588
return
589
# Delete the polygon
590
new_shape.remove()
591
reset_polygon()
592
593
594
def backspace_polygon(event):
595
if event is not None and hasattr(event, "key") and event.key != "Backspace":
596
return
597
if not polygon_points:
598
return
599
polygon_points.pop()
600
polygon = new_shape.children[0]
601
polygon.setAttribute("points", " ".join(
602
[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in
603
polygon_points]))
604
605
606
close_polygon_proxy = create_proxy(close_polygon)
607
cancel_polygon_proxy = create_proxy(cancel_polygon)
608
backspace_polygon_proxy = create_proxy(backspace_polygon)
609
610
611
def open_shape(event):
612
global new_shape, bbox_pos
613
if bbox_pos or shape_type == "select":
614
return
615
print("Creating a new shape of type:", shape_type)
616
617
if shape_type == "shape-bbox":
618
helper_message.innerText = ("Define the first point at the intersection of the lines "
619
"by clicking on the image, or click the cross to cancel")
620
621
cancel_button.addEventListener("click", cancel_bbox_proxy)
622
document.addEventListener("keydown", cancel_bbox_proxy)
623
cancel_button.style.display = "flex"
624
bbox_pos = None
625
zone.addEventListener("click", make_bbox_proxy)
626
vertical_ruler.style.display = "block"
627
horizontal_ruler.style.display = "block"
628
zone.style.cursor = "crosshair"
629
elif shape_type == "shape-polygon" or shape_type == "shape-polyline":
630
if shape_type == "shape-polygon":
631
helper_message.innerText = ("Click on the image to define the points of the polygon, "
632
"press escape to cancel, enter to close, or backspace to "
633
"remove the last point")
634
elif shape_type == "shape-polyline":
635
helper_message.innerText = ("Click on the image to define the points of the polyline, "
636
"press escape to cancel, enter to finish, or backspace to "
637
"remove the last point")
638
639
if not polygon_points and not new_shape:
640
new_shape = make_shape_container()
641
zone_rect = zone.getBoundingClientRect()
642
643
if not polygon_points and int(new_shape.children.length) == 0:
644
zone.addEventListener("click", make_polygon_proxy)
645
document.addEventListener("keydown", close_polygon_proxy)
646
document.addEventListener("keydown", cancel_polygon_proxy)
647
document.addEventListener("keydown", backspace_polygon_proxy)
648
cancel_button.addEventListener("click", cancel_polygon_proxy)
649
cancel_button.style.display = "flex"
650
confirm_button.addEventListener("click", close_polygon_proxy)
651
confirm_button.style.display = "flex"
652
backspace_button.addEventListener("click", backspace_polygon_proxy)
653
backspace_button.style.display = "flex"
654
if shape_type == "shape-polygon":
655
polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon")
656
polygon.classList.add("shape-polygon")
657
elif shape_type == "shape-polyline":
658
polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline")
659
polygon.classList.add("shape-polyline")
660
polygon.setAttribute("fill", "none")
661
polygon.setAttribute("data-object-type", "")
662
polygon.classList.add("shape")
663
new_shape.appendChild(polygon)
664
zone.appendChild(new_shape)
665
zone.style.cursor = "crosshair"
666
elif shape_type == "shape-point":
667
point = document.createElementNS("http://www.w3.org/2000/svg", "circle")
668
zone_rect = zone.getBoundingClientRect()
669
point.setAttribute("cx", str((event.clientX - zone_rect.left) / zone_rect.width * image.naturalWidth))
670
point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight))
671
point.setAttribute("r", "0")
672
point.classList.add("shape-point")
673
point.classList.add("shape")
674
point.setAttribute("data-object-type", "")
675
676
new_shape = make_shape_container()
677
zone_rect = zone.getBoundingClientRect()
678
679
new_shape.appendChild(point)
680
zone.appendChild(new_shape)
681
682
new_shape = None
683
684
update_transform()
685
686
687
688
for button in list(document.getElementById("shape-selector").children):
689
button.addEventListener("click", create_proxy(switch_shape))
690
print("Shape", button.id, "is available")
691
692
zone.addEventListener("mousemove", follow_cursor_proxy)
693
zone.addEventListener("click", create_proxy(open_shape))
694
695
# Zoom
696
zoom_container = document.getElementById("annotation-zoom-container")
697
scale = 1
698
translate_x = 0
699
translate_y = 0
700
701
scale_exponent = 0
702
703
def update_transform():
704
zone.style.transform = f"translate({translate_x}px, {translate_y}px) scale({scale}) "
705
object_list.style.transform = f"scale({1/scale})" # neutralise the zoom
706
vertical_ruler.style.transform = f"scale({1/scale})"
707
horizontal_ruler.style.transform = f"scale({1/scale})"
708
vertical_ruler_2.style.transform = f"scale({1/scale})"
709
horizontal_ruler_2.style.transform = f"scale({1/scale})"
710
711
for svg in zone.getElementsByClassName("shape-container"):
712
# Because vector-effect="non-scaling-stroke" doesn't work when an HTML ancestor is
713
# scaled, we need to scale the shapes down then scale them back up
714
svg.style.transform = f"scale({1/scale})"
715
svg.style.transformOrigin = "0 0"
716
for shape in svg.children:
717
shape.setAttribute("transform", f"scale({scale})")
718
shape.setAttribute("transform-origin", "0 0")
719
720
zoom_indicator.innerText = f"{scale:.3f}x"
721
722
723
def compute_zoom_translation_around_point(element, x, y, sc, nsc):
724
rect = element.getBoundingClientRect()
725
# The difference in size between the new and old scales.
726
size_difference_x = (nsc - sc) * image.width
727
size_difference_y = (nsc - sc) * image.height
728
width = rect.width / sc
729
height = rect.height / sc
730
731
# (size difference) * ((cursor position) / (image size) - (transform origin))
732
tx = size_difference_x * (x / width - 0.5)
733
ty = size_difference_y * (y / height - 0.5)
734
735
return tx, ty
736
737
738
def on_wheel(event):
739
global scale, scale_exponent, translate_x, translate_y
740
if object_list.matches(":hover"):
741
return
742
743
event.preventDefault()
744
745
rect = zone.getBoundingClientRect()
746
747
# Position of the cursor in the unscaled image.
748
mouse_x = (event.clientX - rect.left) / scale
749
mouse_y = (event.clientY - rect.top) / scale
750
751
# Adjust scale exponent and compute new scale.
752
scale_exponent += (-1 if event.deltaY > 0 else 1) / 4
753
new_scale = 2 ** scale_exponent
754
755
# Limit the scale to a reasonable range.
756
new_scale = max(0.0625, min(64, new_scale))
757
758
# Compute the new translation.
759
offset_x, offset_y = compute_zoom_translation_around_point(zone, mouse_x, mouse_y, scale, new_scale)
760
translate_x -= offset_x
761
translate_y -= offset_y
762
763
scale = new_scale
764
zoom_slider.value = scale_exponent
765
766
update_transform()
767
768
769
is_dragging = False
770
start_x = 0
771
start_y = 0
772
773
def on_mouse_down(event):
774
global is_dragging, start_x, start_y
775
if object_list.matches(":hover"):
776
return
777
778
if event.button == 0:
779
is_dragging = True
780
start_x = event.clientX
781
start_y = event.clientY
782
783
def on_mouse_move(event):
784
global translate_x, translate_y
785
if object_list.matches(":hover"):
786
return
787
788
if is_dragging:
789
translate_x += event.movementX
790
translate_y += event.movementY
791
update_transform()
792
793
def on_mouse_up(event):
794
global is_dragging
795
if object_list.matches(":hover"):
796
return
797
is_dragging = False
798
799
last_touch_positions = None
800
last_touch_distance = None
801
802
def calculate_distance(touches):
803
dx = touches[1].clientX - touches[0].clientX
804
dy = touches[1].clientY - touches[0].clientY
805
return (dx**2 + dy**2) ** 0.5
806
807
def on_touch_start(event):
808
global last_touch_positions, last_touch_distance
809
810
if event.touches.length == 2:
811
event.preventDefault()
812
last_touch_positions = [
813
(event.touches[0].clientX, event.touches[0].clientY),
814
(event.touches[1].clientX, event.touches[1].clientY)
815
]
816
last_touch_distance = calculate_distance(event.touches)
817
818
def on_touch_move(event):
819
global translate_x, translate_y, scale, last_touch_positions, last_touch_distance
820
821
if event.touches.length == 2:
822
event.preventDefault()
823
824
# Finger positions.
825
current_positions = [
826
(event.touches[0].clientX, event.touches[0].clientY),
827
(event.touches[1].clientX, event.touches[1].clientY)
828
]
829
830
current_distance = calculate_distance(event.touches)
831
# Ignore small movements.
832
if abs(current_distance - last_touch_distance) > 16:
833
zoom_factor = current_distance / last_touch_distance
834
new_scale = max(0.0625, min(64, scale * zoom_factor))
835
836
# Finger midpoint.
837
midpoint_x = (current_positions[0][0] + current_positions[1][0]) / 2
838
midpoint_y = (current_positions[0][1] + current_positions[1][1]) / 2
839
840
# Compute translation for zooming around the midpoint.
841
tx, ty = compute_zoom_translation_around_point(zone, midpoint_x, midpoint_y, scale, new_scale)
842
843
# Compute translation for panning.
844
delta_x = sum(p[0] - lp[0] for p, lp in zip(current_positions, last_touch_positions)) / 2
845
delta_y = sum(p[1] - lp[1] for p, lp in zip(current_positions, last_touch_positions)) / 2
846
847
translate_x += delta_x - tx
848
translate_y += delta_y - ty
849
850
# Update state.
851
scale = new_scale
852
last_touch_positions = current_positions
853
last_touch_distance = current_distance
854
855
zoom_slider.value = scale_exponent
856
update_transform()
857
858
859
def on_touch_end(event):
860
global last_touch_positions, last_touch_distance
861
if event.touches.length < 2:
862
last_touch_positions = None
863
last_touch_distance = None
864
865
866
def zoom_slider_change(event):
867
global scale, scale_exponent
868
scale_exponent = float(event.currentTarget.value)
869
scale = 2 ** scale_exponent
870
871
update_transform()
872
873
874
def zoom_in(event):
875
zoom_slider.stepUp()
876
zoom_slider.dispatchEvent(js.Event.new("input"))
877
878
879
def zoom_out(event):
880
zoom_slider.stepDown()
881
zoom_slider.dispatchEvent(js.Event.new("input"))
882
883
884
zoom_container.addEventListener("wheel", on_wheel)
885
zoom_container.addEventListener("mousedown", on_mouse_down)
886
document.addEventListener("mousemove", on_mouse_move)
887
document.addEventListener("mouseup", on_mouse_up)
888
zone.addEventListener("touchstart", on_touch_start)
889
zone.addEventListener("touchmove", on_touch_move)
890
zone.addEventListener("touchend", on_touch_end)
891
zoom_slider.addEventListener("input", zoom_slider_change)
892
zoom_in_button.addEventListener("click", zoom_in)
893
zoom_out_button.addEventListener("click", zoom_out)
894
895
# Load existing annotations, if any
896
put_shapes(await load_shapes())
897
update_transform()
898
print("Ready!")
899