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