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 • 34.35 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.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
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 * image.naturalWidth))
531
rectangle.setAttribute("y", str(miny * image.naturalHeight))
532
rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth))
533
rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight))
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] * image.naturalWidth},{point[1] * image.naturalHeight}" 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] * image.naturalWidth},{point[1] * image.naturalHeight}" 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 * image.naturalWidth))
676
point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight))
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
for shape in svg.children:
725
shape.setAttribute("transform", f"scale({scale})")
726
shape.setAttribute("transform-origin", "0 0")
727
728
zoom_indicator.innerText = f"{scale:.3f}x"
729
730
731
def compute_zoom_translation_around_point(element, x, y, sc, nsc):
732
rect = element.getBoundingClientRect()
733
# The difference in size between the new and old scales.
734
size_difference_x = (nsc - sc) * image.width
735
size_difference_y = (nsc - sc) * image.height
736
width = rect.width / sc
737
height = rect.height / sc
738
739
# (size difference) * ((cursor position) / (image size) - (transform origin))
740
tx = size_difference_x * (x / width - 0.5)
741
ty = size_difference_y * (y / height - 0.5)
742
743
return tx, ty
744
745
746
def on_wheel(event):
747
global scale, scale_exponent, translate_x, translate_y
748
if object_list.matches(":hover"):
749
return
750
751
event.preventDefault()
752
753
rect = zone.getBoundingClientRect()
754
755
# Position of the cursor in the unscaled image.
756
mouse_x = (event.clientX - rect.left) / scale
757
mouse_y = (event.clientY - rect.top) / scale
758
759
# Adjust scale exponent and compute new scale.
760
scale_exponent += (-1 if event.deltaY > 0 else 1) / 4
761
new_scale = 2 ** scale_exponent
762
763
# Limit the scale to a reasonable range.
764
new_scale = max(0.0625, min(64, new_scale))
765
766
# Compute the new translation.
767
offset_x, offset_y = compute_zoom_translation_around_point(zone, mouse_x, mouse_y, scale, new_scale)
768
translate_x -= offset_x
769
translate_y -= offset_y
770
771
scale = new_scale
772
zoom_slider.value = scale_exponent
773
774
update_transform()
775
776
777
is_dragging = False
778
drag_distance = 0
779
start_x = 0
780
start_y = 0
781
782
def on_mouse_down(event):
783
global is_dragging, start_x, start_y
784
if object_list.matches(":hover"):
785
return
786
787
if event.button == 0:
788
is_dragging = True
789
start_x = event.clientX
790
start_y = event.clientY
791
zoom_container.style.cursor = "grabbing"
792
793
def on_mouse_move(event):
794
global translate_x, translate_y, drag_distance
795
if object_list.matches(":hover"):
796
return
797
798
if is_dragging and min(event.movementX, event.movementY) != 0:
799
drag_distance += abs(event.movementX) + abs(event.movementY)
800
translate_x += event.movementX
801
translate_y += event.movementY
802
update_transform()
803
804
def on_mouse_up(event):
805
global is_dragging, drag_distance
806
if drag_distance:
807
# Don't click if the image was panned
808
event.preventDefault()
809
event.stopPropagation()
810
if object_list.matches(":hover"):
811
return
812
is_dragging = False
813
drag_distance = 0
814
zoom_container.style.cursor = "grab"
815
816
last_touch_positions = None
817
last_touch_distance = None
818
819
def calculate_distance(touches):
820
dx = touches[1].clientX - touches[0].clientX
821
dy = touches[1].clientY - touches[0].clientY
822
return (dx**2 + dy**2) ** 0.5
823
824
def on_touch_start(event):
825
global last_touch_positions, last_touch_distance
826
827
if event.touches.length == 2:
828
event.preventDefault()
829
last_touch_positions = [
830
((event.touches[0].clientX - zone.getBoundingClientRect().left) / scale, (event.touches[0].clientY - zone.getBoundingClientRect().top) / scale),
831
((event.touches[1].clientX - zone.getBoundingClientRect().left) / scale, (event.touches[1].clientY - zone.getBoundingClientRect().top) / scale)
832
]
833
last_touch_distance = calculate_distance(event.touches)
834
835
def on_touch_move(event):
836
global translate_x, translate_y, scale, last_touch_positions, last_touch_distance, scale_exponent
837
838
if event.touches.length == 2:
839
event.preventDefault()
840
841
# Finger positions.
842
current_positions = [
843
((event.touches[0].clientX - zone.getBoundingClientRect().left) / scale, (event.touches[0].clientY - zone.getBoundingClientRect().top) / scale),
844
((event.touches[1].clientX - zone.getBoundingClientRect().left) / scale, (event.touches[1].clientY - zone.getBoundingClientRect().top) / scale)
845
]
846
847
current_distance = calculate_distance(event.touches)
848
# Ignore small movements.
849
if last_touch_distance and abs(current_distance - last_touch_distance) > min(image.width, image.height) / 64:
850
zoom_factor = current_distance / last_touch_distance
851
new_scale = max(0.0625, min(64, scale * zoom_factor))
852
scale_exponent = math.log2(new_scale)
853
zoom_slider.value = scale_exponent
854
else:
855
new_scale = scale
856
857
# Finger midpoint.
858
midpoint_x = (current_positions[0][0] + current_positions[1][0]) / 2
859
midpoint_y = (current_positions[0][1] + current_positions[1][1]) / 2
860
861
# Compute translation for zooming around the midpoint.
862
tx, ty = compute_zoom_translation_around_point(zone, midpoint_x, midpoint_y, scale, new_scale)
863
864
# Compute translation for panning.
865
if last_touch_positions:
866
delta_x = sum(p[0] - lp[0] for p, lp in zip(current_positions, last_touch_positions)) / 2
867
delta_y = sum(p[1] - lp[1] for p, lp in zip(current_positions, last_touch_positions)) / 2
868
else:
869
delta_x = delta_y = 0
870
871
translate_x += delta_x - tx
872
translate_y += delta_y - ty
873
874
# Update state.
875
scale = new_scale
876
last_touch_positions = current_positions
877
last_touch_distance = current_distance
878
879
zoom_slider.value = scale_exponent
880
update_transform()
881
882
883
def on_touch_end(event):
884
global last_touch_positions, last_touch_distance
885
if event.touches.length < 2:
886
last_touch_positions = None
887
last_touch_distance = None
888
889
890
def zoom_slider_change(event):
891
global scale, scale_exponent
892
scale_exponent = float(event.currentTarget.value)
893
scale = 2 ** scale_exponent
894
895
update_transform()
896
897
898
def zoom_in(event):
899
zoom_slider.stepUp()
900
zoom_slider.dispatchEvent(js.Event.new("input"))
901
902
903
def zoom_out(event):
904
zoom_slider.stepDown()
905
zoom_slider.dispatchEvent(js.Event.new("input"))
906
907
908
zoom_container.addEventListener("wheel", on_wheel)
909
zoom_container.addEventListener("mousedown", on_mouse_down)
910
document.addEventListener("mousemove", on_mouse_move)
911
document.addEventListener("click", on_mouse_up)
912
zone.addEventListener("touchstart", on_touch_start)
913
zone.addEventListener("touchmove", on_touch_move)
914
zone.addEventListener("touchend", on_touch_end)
915
zoom_slider.addEventListener("input", zoom_slider_change)
916
zoom_in_button.addEventListener("click", zoom_in)
917
zoom_out_button.addEventListener("click", zoom_out)
918
919
# Load existing annotations, if any
920
put_shapes(await load_shapes())
921
update_transform()
922
print("Ready!")
923