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.49 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]),
85
"y": float(shape.getAttribute("y")[:-1]),
86
"w": float(shape.getAttribute("width")[:-1]),
87
"h": float(shape.getAttribute("height")[:-1])
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
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] * 100) + "%"
282
object_list.style.top = str(get_centre(shape)[1] * 100) + "%"
283
object_list.style.right = "auto"
284
object_list.style.bottom = "auto"
285
object_list.style.maxHeight = str(image.height * (1 - get_centre(shape)[1])) + "px"
286
object_list.style.maxWidth = str(image.width * (1 - get_centre(shape)[0])) + "px"
287
transform_origin = ["top", "left"]
288
289
if get_centre(shape)[0] * image.width + object_list.offsetWidth > image.width:
290
object_list.style.left = "auto"
291
object_list.style.right = str((image.width - get_centre(shape)[0]) * 100) + "%"
292
object_list.style.maxWidth = str(get_centre(shape)[0] * image.width) + "px"
293
transform_origin[1] = "right"
294
295
if get_centre(shape)[1] / image.height * image.height + object_list.offsetHeight > image.height:
296
object_list.style.top = "auto"
297
object_list.style.bottom = str((image.height - get_centre(shape)[1]) * 100) + "%"
298
object_list.style.maxHeight = str(get_centre(shape)[1] * 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
if drag_distance == 0:
306
await focus_shape(event.target)
307
308
309
async def next_shape(event):
310
global selected_shape
311
if selected_shape is None:
312
return
313
314
selected_svg = selected_shape.parentNode
315
316
while selected_svg is not None:
317
next_sibling = selected_svg.nextElementSibling
318
if next_sibling and next_sibling.classList.contains("shape-container"):
319
selected_svg = next_sibling
320
break
321
elif next_sibling is None:
322
# If no more siblings, loop back to the first child
323
selected_svg = selected_svg.parentNode.firstElementChild
324
while selected_svg is not None and not selected_svg.classList.contains(
325
"shape-container"):
326
selected_svg = selected_svg.nextElementSibling
327
break
328
else:
329
selected_svg = next_sibling
330
331
if selected_svg:
332
shape = selected_svg.firstElementChild
333
await focus_shape(shape)
334
335
event.preventDefault()
336
337
338
async def previous_shape(event):
339
global selected_shape
340
if selected_shape is None:
341
return
342
343
selected_svg = selected_shape.parentNode
344
345
while selected_svg is not None:
346
next_sibling = selected_svg.previousElementSibling
347
if next_sibling and next_sibling.classList.contains("shape-container"):
348
selected_svg = next_sibling
349
break
350
elif next_sibling is None:
351
# If no more siblings, loop back to the last child
352
selected_svg = selected_svg.parentNode.lastElementChild
353
while selected_svg is not None and not selected_svg.classList.contains(
354
"shape-container"):
355
selected_svg = selected_svg.previousElementSibling
356
break
357
else:
358
selected_svg = next_sibling
359
360
if selected_svg:
361
shape = selected_svg.firstElementChild
362
await focus_shape(shape)
363
364
event.preventDefault()
365
366
367
def unselect_shape(event):
368
global selected_shape
369
370
if drag_distance == 0:
371
if selected_shape is not None:
372
selected_shape.classList.remove("selected")
373
selected_shape = None
374
375
object_list_content.innerHTML = ""
376
object_list.style.display = "none"
377
delete_button.style.display = "none"
378
next_button.style.display = "none"
379
previous_button.style.display = "none"
380
document.removeEventListener("keydown", delete_shape_key_proxy)
381
document.removeEventListener("keydown", next_shape_key_proxy)
382
document.removeEventListener("keydown", previous_shape_key_proxy)
383
384
385
def delete_shape(event):
386
global selected_shape
387
if selected_shape is None:
388
return
389
# Shape is SVG shape inside SVG so we need to remove the parent SVG
390
selected_shape.parentNode.remove()
391
selected_shape = None
392
object_list_content.innerHTML = ""
393
object_list.style.display = "none"
394
delete_button.style.display = "none"
395
next_button.style.display = "none"
396
previous_button.style.display = "none"
397
document.removeEventListener("keydown", delete_shape_key_proxy)
398
document.removeEventListener("keydown", next_shape_key_proxy)
399
document.removeEventListener("keydown", previous_shape_key_proxy)
400
401
402
select_shape_proxy = create_proxy(select_shape)
403
unselect_shape_proxy = create_proxy(unselect_shape)
404
delete_shape_proxy = create_proxy(delete_shape)
405
next_shape_proxy = create_proxy(next_shape)
406
previous_shape_proxy = create_proxy(previous_shape)
407
408
delete_button.addEventListener("click", delete_shape_proxy)
409
next_button.addEventListener("click", next_shape_proxy)
410
previous_button.addEventListener("click", previous_shape_proxy)
411
412
# These are functions usable in JS
413
cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event))
414
make_bbox_proxy = create_proxy(lambda event: make_bbox(event))
415
make_polygon_proxy = create_proxy(lambda event: make_polygon(event))
416
follow_cursor_proxy = create_proxy(follow_cursor)
417
418
419
def switch_shape(event):
420
global shape_type
421
object_list_content.innerHTML = ""
422
unselect_shape(None)
423
shape = event.currentTarget.id
424
shape_type = shape
425
for button in list(document.getElementById("shape-selector").children):
426
if not button.classList.contains("button-flat"):
427
button.classList.add("button-flat")
428
event.currentTarget.classList.remove("button-flat")
429
if shape_type == "select":
430
# Add event listeners to existing shapes
431
print(len(list(document.getElementsByClassName("shape"))), "shapes found")
432
for shape in document.getElementsByClassName("shape"):
433
print("Adding event listener to shape:", shape)
434
shape.addEventListener("click", select_shape_proxy)
435
image.addEventListener("click", unselect_shape_proxy)
436
helper_message.innerText = "Click on a shape to select"
437
zone.style.cursor = "inherit"
438
# Cancel the current shape creation
439
if shape_type == "shape-bbox":
440
cancel_bbox(None)
441
elif shape_type == "shape-polygon":
442
cancel_polygon(None)
443
elif shape_type == "shape-polyline":
444
cancel_polygon(None)
445
else:
446
zone.style.cursor = "pointer"
447
if shape_type == "shape-point":
448
zone.style.cursor = "crosshair"
449
# Remove event listeners for selection
450
for shape in document.getElementsByClassName("shape"):
451
print("Removing event listener from shape:", shape)
452
shape.removeEventListener("click", select_shape_proxy)
453
image.removeEventListener("click", unselect_shape_proxy)
454
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
455
print("Shape is now of type:", shape)
456
457
458
def select_mode():
459
global shape_type
460
document.getElementById("select").click()
461
462
463
vertical_ruler = document.getElementById("annotation-ruler-vertical")
464
horizontal_ruler = document.getElementById("annotation-ruler-horizontal")
465
vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary")
466
horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary")
467
helper_message = document.getElementById("annotation-helper-message")
468
469
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
470
471
472
def cancel_bbox(event):
473
global bbox_pos, new_shape
474
475
# Key must be ESCAPE
476
if event is not None and hasattr(event, "key") and event.key != "Escape":
477
return
478
479
if new_shape is not None and event is not None:
480
# Require event so the shape is kept when it ends normally
481
new_shape.remove()
482
zone.removeEventListener("click", make_bbox_proxy)
483
document.removeEventListener("keydown", cancel_bbox_proxy)
484
cancel_button.removeEventListener("click", cancel_bbox_proxy)
485
486
bbox_pos = None
487
vertical_ruler.style.display = "none"
488
horizontal_ruler.style.display = "none"
489
vertical_ruler_2.style.display = "none"
490
horizontal_ruler_2.style.display = "none"
491
zone.style.cursor = "auto"
492
cancel_button.style.display = "none"
493
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
494
new_shape = None
495
update_transform()
496
497
498
def make_bbox(event):
499
global new_shape, bbox_pos
500
zone_rect = zone.getBoundingClientRect()
501
502
if bbox_pos is None:
503
helper_message.innerText = "Now define the second point"
504
505
bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width,
506
(event.clientY - zone_rect.top) / zone_rect.height]
507
vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%"
508
horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%"
509
vertical_ruler_2.style.display = "block"
510
horizontal_ruler_2.style.display = "block"
511
512
else:
513
x0, y0 = bbox_pos.copy()
514
x1 = (event.clientX - zone_rect.left) / zone_rect.width
515
y1 = (event.clientY - zone_rect.top) / zone_rect.height
516
517
rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect")
518
519
new_shape = make_shape_container()
520
zone_rect = zone.getBoundingClientRect()
521
522
new_shape.appendChild(rectangle)
523
zone.appendChild(new_shape)
524
525
minx = min(x0, x1)
526
miny = min(y0, y1)
527
maxx = max(x0, x1)
528
maxy = max(y0, y1)
529
530
rectangle.setAttribute("x", str(minx))
531
rectangle.setAttribute("y", str(miny))
532
rectangle.setAttribute("width", str(maxx - minx))
533
rectangle.setAttribute("height", str(maxy - miny))
534
rectangle.setAttribute("fill", "none")
535
rectangle.setAttribute("data-object-type", "")
536
rectangle.classList.add("shape-bbox")
537
rectangle.classList.add("shape")
538
539
# Add event listeners to the new shape
540
rectangle.addEventListener("click", select_shape_proxy)
541
542
cancel_bbox(None)
543
544
545
polygon_points = []
546
547
548
def make_polygon(event):
549
global new_shape, polygon_points
550
551
polygon = new_shape.children[0]
552
553
zone_rect = zone.getBoundingClientRect()
554
555
polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width,
556
(event.clientY - zone_rect.top) / zone_rect.height))
557
558
# Update the polygon
559
polygon.setAttribute("points", " ".join(
560
[f"{point[0]},{point[1]}" for point in
561
polygon_points]))
562
563
564
def reset_polygon():
565
global new_shape, polygon_points
566
567
zone.removeEventListener("click", make_polygon_proxy)
568
document.removeEventListener("keydown", close_polygon_proxy)
569
document.removeEventListener("keydown", cancel_polygon_proxy)
570
document.removeEventListener("keydown", backspace_polygon_proxy)
571
confirm_button.style.display = "none"
572
cancel_button.style.display = "none"
573
backspace_button.style.display = "none"
574
confirm_button.removeEventListener("click", close_polygon_proxy)
575
cancel_button.removeEventListener("click", cancel_polygon_proxy)
576
backspace_button.removeEventListener("click", backspace_polygon_proxy)
577
polygon_points.clear()
578
579
zone.style.cursor = "auto"
580
new_shape = None
581
582
update_transform()
583
584
585
def close_polygon(event):
586
if event is not None and hasattr(event, "key") and event.key != "Enter":
587
return
588
# Polygon is already there, but we need to remove the events
589
reset_polygon()
590
591
592
def cancel_polygon(event):
593
if event is not None and hasattr(event, "key") and event.key != "Escape":
594
return
595
# Delete the polygon
596
new_shape.remove()
597
reset_polygon()
598
599
600
def backspace_polygon(event):
601
if event is not None and hasattr(event, "key") and event.key != "Backspace":
602
return
603
if not polygon_points:
604
return
605
polygon_points.pop()
606
polygon = new_shape.children[0]
607
polygon.setAttribute("points", " ".join(
608
[f"{point[0]},{point[1]}" for point in
609
polygon_points]))
610
611
612
close_polygon_proxy = create_proxy(close_polygon)
613
cancel_polygon_proxy = create_proxy(cancel_polygon)
614
backspace_polygon_proxy = create_proxy(backspace_polygon)
615
616
617
def open_shape(event):
618
global new_shape, bbox_pos
619
if bbox_pos or shape_type == "select":
620
return
621
print("Creating a new shape of type:", shape_type)
622
623
if shape_type == "shape-bbox":
624
helper_message.innerText = ("Define the first point at the intersection of the lines "
625
"by clicking on the image, or click the cross to cancel")
626
627
cancel_button.addEventListener("click", cancel_bbox_proxy)
628
document.addEventListener("keydown", cancel_bbox_proxy)
629
cancel_button.style.display = "flex"
630
bbox_pos = None
631
zone.addEventListener("click", make_bbox_proxy)
632
vertical_ruler.style.display = "block"
633
horizontal_ruler.style.display = "block"
634
zone.style.cursor = "crosshair"
635
elif shape_type == "shape-polygon" or shape_type == "shape-polyline":
636
if shape_type == "shape-polygon":
637
helper_message.innerText = ("Click on the image to define the points of the polygon, "
638
"press escape to cancel, enter to close, or backspace to "
639
"remove the last point")
640
elif shape_type == "shape-polyline":
641
helper_message.innerText = ("Click on the image to define the points of the polyline, "
642
"press escape to cancel, enter to finish, or backspace to "
643
"remove the last point")
644
645
if not polygon_points and not new_shape:
646
new_shape = make_shape_container()
647
zone_rect = zone.getBoundingClientRect()
648
649
if not polygon_points and int(new_shape.children.length) == 0:
650
zone.addEventListener("click", make_polygon_proxy)
651
document.addEventListener("keydown", close_polygon_proxy)
652
document.addEventListener("keydown", cancel_polygon_proxy)
653
document.addEventListener("keydown", backspace_polygon_proxy)
654
cancel_button.addEventListener("click", cancel_polygon_proxy)
655
cancel_button.style.display = "flex"
656
confirm_button.addEventListener("click", close_polygon_proxy)
657
confirm_button.style.display = "flex"
658
backspace_button.addEventListener("click", backspace_polygon_proxy)
659
backspace_button.style.display = "flex"
660
if shape_type == "shape-polygon":
661
polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon")
662
polygon.classList.add("shape-polygon")
663
elif shape_type == "shape-polyline":
664
polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline")
665
polygon.classList.add("shape-polyline")
666
polygon.setAttribute("fill", "none")
667
polygon.setAttribute("data-object-type", "")
668
polygon.classList.add("shape")
669
new_shape.appendChild(polygon)
670
zone.appendChild(new_shape)
671
zone.style.cursor = "crosshair"
672
elif shape_type == "shape-point":
673
point = document.createElementNS("http://www.w3.org/2000/svg", "circle")
674
zone_rect = zone.getBoundingClientRect()
675
point.setAttribute("cx", str((event.clientX - zone_rect.left) / zone_rect.width))
676
point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height))
677
point.setAttribute("r", "0")
678
point.classList.add("shape-point")
679
point.classList.add("shape")
680
point.setAttribute("data-object-type", "")
681
682
new_shape = make_shape_container()
683
zone_rect = zone.getBoundingClientRect()
684
685
new_shape.appendChild(point)
686
zone.appendChild(new_shape)
687
688
new_shape = None
689
690
update_transform()
691
692
693
694
for button in list(document.getElementById("shape-selector").children):
695
button.addEventListener("click", create_proxy(switch_shape))
696
print("Shape", button.id, "is available")
697
698
zone.addEventListener("mousemove", follow_cursor_proxy)
699
zone.addEventListener("click", create_proxy(open_shape))
700
701
# Zoom
702
zoom_container = document.getElementById("annotation-zoom-container")
703
scale = 1
704
translate_x = 0
705
translate_y = 0
706
707
scale_exponent = 0
708
709
def update_transform():
710
zone.style.transform = f"translate({translate_x}px, {translate_y}px) scale({scale}) "
711
object_list.style.transform = f"scale({1/scale})" # neutralise the zoom
712
vertical_ruler.style.transform = f"scale({1/scale})"
713
horizontal_ruler.style.transform = f"scale({1/scale})"
714
vertical_ruler_2.style.transform = f"scale({1/scale})"
715
horizontal_ruler_2.style.transform = f"scale({1/scale})"
716
717
for svg in zone.getElementsByClassName("shape-container"):
718
# Because vector-effect="non-scaling-stroke" doesn't work when an HTML ancestor is
719
# scaled, we need to scale the shapes down then scale them back up
720
svg.style.transform = f"scale({1/scale})"
721
svg.style.transformOrigin = "0 0"
722
svg.setAttribute("width", f"{100 * scale}%")
723
svg.setAttribute("height", f"{100 * scale}%")
724
725
zoom_indicator.innerText = f"{scale:.3f}x"
726
727
728
def compute_zoom_translation_around_point(element, x, y, sc, nsc):
729
rect = element.getBoundingClientRect()
730
# The difference in size between the new and old scales.
731
size_difference_x = (nsc - sc) * image.width
732
size_difference_y = (nsc - sc) * image.height
733
width = rect.width / sc
734
height = rect.height / sc
735
736
# (size difference) * ((cursor position) / (image size) - (transform origin))
737
tx = size_difference_x * (x / width - 0.5)
738
ty = size_difference_y * (y / height - 0.5)
739
740
return tx, ty
741
742
743
def on_wheel(event):
744
global scale, scale_exponent, translate_x, translate_y
745
if object_list.matches(":hover"):
746
return
747
748
event.preventDefault()
749
750
rect = zone.getBoundingClientRect()
751
752
# Position of the cursor in the unscaled image.
753
mouse_x = (event.clientX - rect.left) / scale
754
mouse_y = (event.clientY - rect.top) / scale
755
756
# Adjust scale exponent and compute new scale.
757
scale_exponent += (-1 if event.deltaY > 0 else 1) / 4
758
new_scale = 2 ** scale_exponent
759
760
# Limit the scale to a reasonable range.
761
new_scale = max(0.0625, min(64, new_scale))
762
763
# Compute the new translation.
764
offset_x, offset_y = compute_zoom_translation_around_point(zone, mouse_x, mouse_y, scale, new_scale)
765
translate_x -= offset_x
766
translate_y -= offset_y
767
768
scale = new_scale
769
zoom_slider.value = scale_exponent
770
771
update_transform()
772
773
774
is_dragging = False
775
drag_distance = 0
776
start_x = 0
777
start_y = 0
778
779
def on_mouse_down(event):
780
global is_dragging, start_x, start_y
781
if object_list.matches(":hover"):
782
return
783
784
if event.button == 0:
785
is_dragging = True
786
start_x = event.clientX
787
start_y = event.clientY
788
zoom_container.style.cursor = "grabbing"
789
790
def on_mouse_move(event):
791
global translate_x, translate_y, drag_distance
792
if object_list.matches(":hover"):
793
return
794
795
if is_dragging and min(event.movementX, event.movementY) != 0:
796
drag_distance += abs(event.movementX) + abs(event.movementY)
797
translate_x += event.movementX
798
translate_y += event.movementY
799
update_transform()
800
801
def on_mouse_up(event):
802
global is_dragging, drag_distance
803
if drag_distance:
804
# Don't click if the image was panned
805
event.preventDefault()
806
event.stopPropagation()
807
if object_list.matches(":hover"):
808
return
809
is_dragging = False
810
drag_distance = 0
811
zoom_container.style.cursor = "grab"
812
813
last_touch_positions = None
814
last_touch_distance = None
815
816
def calculate_distance(touches):
817
dx = touches[1].clientX - touches[0].clientX
818
dy = touches[1].clientY - touches[0].clientY
819
return (dx**2 + dy**2) ** 0.5
820
821
def on_touch_start(event):
822
global last_touch_positions, last_touch_distance
823
824
if event.touches.length == 2:
825
event.preventDefault()
826
last_touch_positions = [
827
((event.touches[0].clientX - zone.getBoundingClientRect().left) / scale, (event.touches[0].clientY - zone.getBoundingClientRect().top) / scale),
828
((event.touches[1].clientX - zone.getBoundingClientRect().left) / scale, (event.touches[1].clientY - zone.getBoundingClientRect().top) / scale)
829
]
830
last_touch_distance = calculate_distance(event.touches)
831
832
def on_touch_move(event):
833
global translate_x, translate_y, scale, last_touch_positions, last_touch_distance, scale_exponent
834
835
if event.touches.length == 2:
836
event.preventDefault()
837
838
# Finger positions.
839
current_positions = [
840
((event.touches[0].clientX - zone.getBoundingClientRect().left) / scale, (event.touches[0].clientY - zone.getBoundingClientRect().top) / scale),
841
((event.touches[1].clientX - zone.getBoundingClientRect().left) / scale, (event.touches[1].clientY - zone.getBoundingClientRect().top) / scale)
842
]
843
844
current_distance = calculate_distance(event.touches)
845
# Ignore small movements.
846
if last_touch_distance and abs(current_distance - last_touch_distance) > min(image.width, image.height) / 64:
847
zoom_factor = current_distance / last_touch_distance
848
new_scale = max(0.0625, min(64, scale * zoom_factor))
849
scale_exponent = math.log2(new_scale)
850
zoom_slider.value = scale_exponent
851
else:
852
new_scale = scale
853
854
# Finger midpoint.
855
midpoint_x = (current_positions[0][0] + current_positions[1][0]) / 2
856
midpoint_y = (current_positions[0][1] + current_positions[1][1]) / 2
857
858
# Compute translation for zooming around the midpoint.
859
tx, ty = compute_zoom_translation_around_point(zone, midpoint_x, midpoint_y, scale, new_scale)
860
861
# Compute translation for panning.
862
if last_touch_positions:
863
delta_x = sum(p[0] - lp[0] for p, lp in zip(current_positions, last_touch_positions)) / 2
864
delta_y = sum(p[1] - lp[1] for p, lp in zip(current_positions, last_touch_positions)) / 2
865
else:
866
delta_x = delta_y = 0
867
868
translate_x += delta_x - tx
869
translate_y += delta_y - ty
870
871
# Update state.
872
scale = new_scale
873
last_touch_positions = current_positions
874
last_touch_distance = current_distance
875
876
zoom_slider.value = scale_exponent
877
update_transform()
878
879
880
def on_touch_end(event):
881
global last_touch_positions, last_touch_distance
882
if event.touches.length < 2:
883
last_touch_positions = None
884
last_touch_distance = None
885
886
887
def zoom_slider_change(event):
888
global scale, scale_exponent
889
scale_exponent = float(event.currentTarget.value)
890
scale = 2 ** scale_exponent
891
892
update_transform()
893
894
895
def zoom_in(event):
896
zoom_slider.stepUp()
897
zoom_slider.dispatchEvent(js.Event.new("input"))
898
899
900
def zoom_out(event):
901
zoom_slider.stepDown()
902
zoom_slider.dispatchEvent(js.Event.new("input"))
903
904
905
zoom_container.addEventListener("wheel", on_wheel)
906
zoom_container.addEventListener("mousedown", on_mouse_down)
907
document.addEventListener("mousemove", on_mouse_move)
908
document.addEventListener("click", on_mouse_up)
909
zone.addEventListener("touchstart", on_touch_start)
910
zone.addEventListener("touchmove", on_touch_move)
911
zone.addEventListener("touchend", on_touch_end)
912
zoom_slider.addEventListener("input", zoom_slider_change)
913
zoom_in_button.addEventListener("click", zoom_in)
914
zoom_out_button.addEventListener("click", zoom_out)
915
916
# Load existing annotations, if any
917
put_shapes(await load_shapes())
918
update_transform()
919
print("Ready!")