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