Web platform for sharing free image data for ML and research

Homepage: https://datasets.roundabout-host.com

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