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 7 8document.getElementById("shape-options").style.display = "flex" 9 10image = document.getElementById("annotation-image") 11zone = document.getElementById("annotation-zone") 12confirm_button = document.getElementById("annotation-confirm") 13cancel_button = document.getElementById("annotation-cancel") 14backspace_button = document.getElementById("annotation-backspace") 15delete_button = document.getElementById("annotation-delete") 16previous_button = document.getElementById("annotation-previous") 17next_button = document.getElementById("annotation-next") 18save_button = document.getElementById("annotation-save") 19zoom_slider = document.getElementById("zoom-slider") 20zoom_in_button = document.getElementById("zoom-in") 21zoom_out_button = document.getElementById("zoom-out") 22zoom_indicator = document.getElementById("zoom-indicator") 23 24object_list = document.getElementById("object-types") 25object_list_content = document.getElementById("object-types-content") 26object_list_filter = document.getElementById("filter-object-types") 27 28confirm_button.style.display = "none" 29cancel_button.style.display = "none" 30backspace_button.style.display = "none" 31delete_button.style.display = "none" 32previous_button.style.display = "none" 33next_button.style.display = "none" 34shape_type = "" 35bbox_pos = None 36new_shape = None 37selected_shape = None 38 39 40def make_shape_container(): 41shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 42shape.setAttribute("width", "100%") 43shape.setAttribute("height", "100%") 44shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 45shape.classList.add("shape-container") 46 47return shape 48 49 50async def get_all_objects(): 51response = await pyfetch("/api/object-types") 52if response.ok: 53return await response.json() 54 55 56def follow_cursor(event): 57rect = zone.getBoundingClientRect() 58x = (event.clientX - rect.left) / scale 59y = (event.clientY - rect.top) / scale 60vertical_ruler.style.left = str(x) + "px" 61horizontal_ruler.style.top = str(y) + "px" 62 63 64def change_object_type(event): 65global selected_shape 66if selected_shape is None: 67return 68selected_shape.setAttribute("data-object-type", event.currentTarget.value) 69 70 71change_object_type_proxy = create_proxy(change_object_type) 72 73 74def list_shapes(): 75shapes = list(zone.getElementsByClassName("shape")) 76json_shapes = [] 77for shape in shapes: 78shape_dict = {} 79if shape.tagName == "rect": 80shape_dict["type"] = "bbox" 81shape_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} 87elif shape.tagName == "polygon" or shape.tagName == "polyline": 88if shape.tagName == "polygon": 89shape_dict["type"] = "polygon" 90elif shape.tagName == "polyline": 91shape_dict["type"] = "polyline" 92 93points = shape.getAttribute("points").split(" ") 94json_points = [] 95for point in points: 96x, y = point.split(",") 97x, y = float(x), float(y) 98json_points.append({ 99"x": x / image.naturalWidth, 100"y": y / image.naturalHeight 101}) 102 103shape_dict["shape"] = json_points 104elif shape.tagName == "circle" and shape.classList.contains("shape-point"): 105shape_dict["type"] = "point" 106shape_dict["shape"] = { 107"x": float(shape.getAttribute("cx")) / image.naturalWidth, 108"y": float(shape.getAttribute("cy")) / image.naturalHeight 109} 110else: 111continue 112 113shape_dict["object"] = shape.getAttribute("data-object-type") 114json_shapes.append(shape_dict) 115 116return json_shapes 117 118 119def put_shapes(json_shapes): 120for shape in json_shapes: 121new_shape = make_shape_container() 122zone_rect = zone.getBoundingClientRect() 123 124if shape["type"] == "bbox": 125rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 126rectangle.setAttribute("x", str(shape["shape"]["x"] * image.naturalWidth)) 127rectangle.setAttribute("y", str(shape["shape"]["y"] * image.naturalHeight)) 128rectangle.setAttribute("width", str(shape["shape"]["w"] * image.naturalWidth)) 129rectangle.setAttribute("height", str(shape["shape"]["h"] * image.naturalHeight)) 130rectangle.setAttribute("fill", "none") 131rectangle.setAttribute("data-object-type", shape["object"] or "") 132rectangle.classList.add("shape-bbox") 133rectangle.classList.add("shape") 134new_shape.appendChild(rectangle) 135elif shape["type"] == "polygon" or shape["type"] == "polyline": 136polygon = document.createElementNS("http://www.w3.org/2000/svg", shape["type"]) 137points = " ".join( 138[f"{point['x'] * image.naturalWidth},{point['y'] * image.naturalHeight}" for point in shape["shape"]]) 139polygon.setAttribute("points", points) 140polygon.setAttribute("fill", "none") 141polygon.setAttribute("data-object-type", shape["object"] or "") 142polygon.classList.add(f"shape-{shape['type']}") 143polygon.classList.add("shape") 144new_shape.appendChild(polygon) 145elif shape["type"] == "point": 146point = document.createElementNS("http://www.w3.org/2000/svg", "circle") 147point.setAttribute("cx", str(shape["shape"]["x"] * image.naturalWidth)) 148point.setAttribute("cy", str(shape["shape"]["y"] * image.naturalHeight)) 149point.setAttribute("r", "0") 150point.classList.add("shape-point") 151point.classList.add("shape") 152point.setAttribute("data-object-type", shape["object"] or "") 153new_shape.appendChild(point) 154 155zone.appendChild(new_shape) 156 157 158async def load_shapes(): 159resource_id = document.getElementById("resource-id").value 160response = await pyfetch(f"/picture/{resource_id}/get-annotations") 161if response.ok: 162shapes = await response.json() 163return shapes 164 165 166async def save_shapes(event): 167shapes = list_shapes() 168resource_id = document.getElementById("resource-id").value 169print("Saving shapes:", shapes) 170response = await pyfetch(f"/picture/{resource_id}/save-annotations", 171method="POST", 172headers={ 173"Content-Type": "application/json" 174}, 175body=json.dumps(shapes) 176) 177if response.ok: 178return await response 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] / image.naturalWidth * 100) + "%" 282object_list.style.top = str(get_centre(shape)[1] / image.naturalHeight * 100) + "%" 283object_list.style.right = "auto" 284object_list.style.bottom = "auto" 285object_list.style.maxHeight = str(image.height - get_centre(shape)[1] / image.naturalHeight * image.height) + "px" 286object_list.style.maxWidth = str(image.width - get_centre(shape)[0] / image.naturalWidth * image.width) + "px" 287transform_origin = ["top", "left"] 288 289if get_centre(shape)[0] / image.naturalWidth * image.width + object_list.offsetWidth > image.width: 290object_list.style.left = "auto" 291object_list.style.right = str((image.naturalWidth - get_centre(shape)[0]) / image.naturalWidth * 100) + "%" 292object_list.style.maxWidth = str(get_centre(shape)[0] / image.naturalWidth * image.width) + "px" 293transform_origin[1] = "right" 294 295if get_centre(shape)[1] / image.naturalHeight * image.height + object_list.offsetHeight > image.height: 296object_list.style.top = "auto" 297object_list.style.bottom = str((image.naturalHeight - get_centre(shape)[1]) / image.naturalHeight * 100) + "%" 298object_list.style.maxHeight = str(get_centre(shape)[1] / image.naturalHeight * 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 * image.naturalWidth)) 531rectangle.setAttribute("y", str(miny * image.naturalHeight)) 532rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth)) 533rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight)) 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] * image.naturalWidth},{point[1] * image.naturalHeight}" 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] * image.naturalWidth},{point[1] * image.naturalHeight}" 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 * image.naturalWidth)) 676point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight)) 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" 722for shape in svg.children: 723shape.setAttribute("transform", f"scale({scale})") 724shape.setAttribute("transform-origin", "0 0") 725 726zoom_indicator.innerText = f"{scale:.3f}x" 727 728 729def compute_zoom_translation_around_point(element, x, y, sc, nsc): 730rect = element.getBoundingClientRect() 731# The difference in size between the new and old scales. 732size_difference_x = (nsc - sc) * image.width 733size_difference_y = (nsc - sc) * image.height 734width = rect.width / sc 735height = rect.height / sc 736 737# (size difference) * ((cursor position) / (image size) - (transform origin)) 738tx = size_difference_x * (x / width - 0.5) 739ty = size_difference_y * (y / height - 0.5) 740 741return tx, ty 742 743 744def on_wheel(event): 745global scale, scale_exponent, translate_x, translate_y 746if object_list.matches(":hover"): 747return 748 749event.preventDefault() 750 751rect = zone.getBoundingClientRect() 752 753# Position of the cursor in the unscaled image. 754mouse_x = (event.clientX - rect.left) / scale 755mouse_y = (event.clientY - rect.top) / scale 756 757# Adjust scale exponent and compute new scale. 758scale_exponent += (-1 if event.deltaY > 0 else 1) / 4 759new_scale = 2 ** scale_exponent 760 761# Limit the scale to a reasonable range. 762new_scale = max(0.0625, min(64, new_scale)) 763 764# Compute the new translation. 765offset_x, offset_y = compute_zoom_translation_around_point(zone, mouse_x, mouse_y, scale, new_scale) 766translate_x -= offset_x 767translate_y -= offset_y 768 769scale = new_scale 770zoom_slider.value = scale_exponent 771 772update_transform() 773 774 775is_dragging = False 776drag_distance = 0 777start_x = 0 778start_y = 0 779 780def on_mouse_down(event): 781global is_dragging, start_x, start_y 782if object_list.matches(":hover"): 783return 784 785if event.button == 0: 786is_dragging = True 787start_x = event.clientX 788start_y = event.clientY 789zoom_container.style.cursor = "grabbing" 790 791def on_mouse_move(event): 792global translate_x, translate_y, drag_distance 793if object_list.matches(":hover"): 794return 795 796if is_dragging and min(event.movementX, event.movementY) != 0: 797drag_distance += abs(event.movementX) + abs(event.movementY) 798translate_x += event.movementX 799translate_y += event.movementY 800update_transform() 801 802def on_mouse_up(event): 803global is_dragging, drag_distance 804if drag_distance: 805# Don't click if the image was panned 806event.preventDefault() 807event.stopPropagation() 808if object_list.matches(":hover"): 809return 810is_dragging = False 811drag_distance = 0 812zoom_container.style.cursor = "grab" 813 814last_touch_positions = None 815last_touch_distance = None 816 817def calculate_distance(touches): 818dx = touches[1].clientX - touches[0].clientX 819dy = touches[1].clientY - touches[0].clientY 820return (dx**2 + dy**2) ** 0.5 821 822def on_touch_start(event): 823global last_touch_positions, last_touch_distance 824 825if event.touches.length == 2: 826event.preventDefault() 827last_touch_positions = [ 828(event.touches[0].clientX, event.touches[0].clientY), 829(event.touches[1].clientX, event.touches[1].clientY) 830] 831last_touch_distance = calculate_distance(event.touches) 832 833def on_touch_move(event): 834global translate_x, translate_y, scale, last_touch_positions, last_touch_distance 835 836if event.touches.length == 2: 837event.preventDefault() 838 839# Finger positions. 840current_positions = [ 841(event.touches[0].clientX, event.touches[0].clientY), 842(event.touches[1].clientX, event.touches[1].clientY) 843] 844 845current_distance = calculate_distance(event.touches) 846# Ignore small movements. 847if abs(current_distance - last_touch_distance) > 16: 848zoom_factor = current_distance / last_touch_distance 849new_scale = max(0.0625, min(64, scale * zoom_factor)) 850 851# Finger midpoint. 852midpoint_x = (current_positions[0][0] + current_positions[1][0]) / 2 853midpoint_y = (current_positions[0][1] + current_positions[1][1]) / 2 854 855# Compute translation for zooming around the midpoint. 856tx, ty = compute_zoom_translation_around_point(zone, midpoint_x, midpoint_y, scale, new_scale) 857 858# Compute translation for panning. 859delta_x = sum(p[0] - lp[0] for p, lp in zip(current_positions, last_touch_positions)) / 2 860delta_y = sum(p[1] - lp[1] for p, lp in zip(current_positions, last_touch_positions)) / 2 861 862translate_x += delta_x - tx 863translate_y += delta_y - ty 864 865# Update state. 866scale = new_scale 867last_touch_positions = current_positions 868last_touch_distance = current_distance 869 870zoom_slider.value = scale_exponent 871update_transform() 872 873 874def on_touch_end(event): 875global last_touch_positions, last_touch_distance 876if event.touches.length < 2: 877last_touch_positions = None 878last_touch_distance = None 879 880 881def zoom_slider_change(event): 882global scale, scale_exponent 883scale_exponent = float(event.currentTarget.value) 884scale = 2 ** scale_exponent 885 886update_transform() 887 888 889def zoom_in(event): 890zoom_slider.stepUp() 891zoom_slider.dispatchEvent(js.Event.new("input")) 892 893 894def zoom_out(event): 895zoom_slider.stepDown() 896zoom_slider.dispatchEvent(js.Event.new("input")) 897 898 899zoom_container.addEventListener("wheel", on_wheel) 900zoom_container.addEventListener("mousedown", on_mouse_down) 901document.addEventListener("mousemove", on_mouse_move) 902document.addEventListener("click", on_mouse_up) 903zone.addEventListener("touchstart", on_touch_start) 904zone.addEventListener("touchmove", on_touch_move) 905zone.addEventListener("touchend", on_touch_end) 906zoom_slider.addEventListener("input", zoom_slider_change) 907zoom_in_button.addEventListener("click", zoom_in) 908zoom_out_button.addEventListener("click", zoom_out) 909 910# Load existing annotations, if any 911put_shapes(await load_shapes()) 912update_transform() 913print("Ready!") 914