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