Web platform for sharing free data for ML and research

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