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