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