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