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