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): 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 selected_shape is not None: 370selected_shape.classList.remove("selected") 371selected_shape = None 372 373object_list_content.innerHTML = "" 374object_list.style.display = "none" 375delete_button.style.display = "none" 376next_button.style.display = "none" 377previous_button.style.display = "none" 378document.removeEventListener("keydown", delete_shape_key_proxy) 379document.removeEventListener("keydown", next_shape_key_proxy) 380document.removeEventListener("keydown", previous_shape_key_proxy) 381 382 383def delete_shape(event): 384global selected_shape 385if selected_shape is None: 386return 387# Shape is SVG shape inside SVG so we need to remove the parent SVG 388selected_shape.parentNode.remove() 389selected_shape = None 390object_list_content.innerHTML = "" 391object_list.style.display = "none" 392delete_button.style.display = "none" 393next_button.style.display = "none" 394previous_button.style.display = "none" 395document.removeEventListener("keydown", delete_shape_key_proxy) 396document.removeEventListener("keydown", next_shape_key_proxy) 397document.removeEventListener("keydown", previous_shape_key_proxy) 398 399 400select_shape_proxy = create_proxy(select_shape) 401unselect_shape_proxy = create_proxy(unselect_shape) 402delete_shape_proxy = create_proxy(delete_shape) 403next_shape_proxy = create_proxy(next_shape) 404previous_shape_proxy = create_proxy(previous_shape) 405 406delete_button.addEventListener("click", delete_shape_proxy) 407next_button.addEventListener("click", next_shape_proxy) 408previous_button.addEventListener("click", previous_shape_proxy) 409 410# These are functions usable in JS 411cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event)) 412make_bbox_proxy = create_proxy(lambda event: make_bbox(event)) 413make_polygon_proxy = create_proxy(lambda event: make_polygon(event)) 414follow_cursor_proxy = create_proxy(follow_cursor) 415 416 417def switch_shape(event): 418global shape_type 419object_list_content.innerHTML = "" 420unselect_shape(None) 421shape = event.currentTarget.id 422shape_type = shape 423for button in list(document.getElementById("shape-selector").children): 424if not button.classList.contains("button-flat"): 425button.classList.add("button-flat") 426event.currentTarget.classList.remove("button-flat") 427if shape_type == "select": 428# Add event listeners to existing shapes 429print(len(list(document.getElementsByClassName("shape"))), "shapes found") 430for shape in document.getElementsByClassName("shape"): 431print("Adding event listener to shape:", shape) 432shape.addEventListener("click", select_shape_proxy) 433image.addEventListener("click", unselect_shape_proxy) 434helper_message.innerText = "Click on a shape to select" 435# Cancel the current shape creation 436if shape_type == "shape-bbox": 437cancel_bbox(None) 438elif shape_type == "shape-polygon": 439cancel_polygon(None) 440elif shape_type == "shape-polyline": 441cancel_polygon(None) 442else: 443# Remove event listeners for selection 444for shape in document.getElementsByClassName("shape"): 445print("Removing event listener from shape:", shape) 446shape.removeEventListener("click", select_shape_proxy) 447image.removeEventListener("click", unselect_shape_proxy) 448helper_message.innerText = "Select a shape type then click on the image to begin defining it" 449print("Shape is now of type:", shape) 450 451 452def select_mode(): 453global shape_type 454document.getElementById("select").click() 455 456 457vertical_ruler = document.getElementById("annotation-ruler-vertical") 458horizontal_ruler = document.getElementById("annotation-ruler-horizontal") 459vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary") 460horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary") 461helper_message = document.getElementById("annotation-helper-message") 462 463helper_message.innerText = "Select a shape type then click on the image to begin defining it" 464 465 466def cancel_bbox(event): 467global bbox_pos, new_shape 468 469# Key must be ESCAPE 470if event is not None and hasattr(event, "key") and event.key != "Escape": 471return 472 473if new_shape is not None and event is not None: 474# Require event so the shape is kept when it ends normally 475new_shape.remove() 476zone.removeEventListener("click", make_bbox_proxy) 477document.removeEventListener("keydown", cancel_bbox_proxy) 478cancel_button.removeEventListener("click", cancel_bbox_proxy) 479 480bbox_pos = None 481vertical_ruler.style.display = "none" 482horizontal_ruler.style.display = "none" 483vertical_ruler_2.style.display = "none" 484horizontal_ruler_2.style.display = "none" 485zone.style.cursor = "auto" 486cancel_button.style.display = "none" 487helper_message.innerText = "Select a shape type then click on the image to begin defining it" 488new_shape = None 489update_transform() 490 491 492def make_bbox(event): 493global new_shape, bbox_pos 494zone_rect = zone.getBoundingClientRect() 495 496if bbox_pos is None: 497helper_message.innerText = "Now define the second point" 498 499bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width, 500(event.clientY - zone_rect.top) / zone_rect.height] 501vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%" 502horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%" 503vertical_ruler_2.style.display = "block" 504horizontal_ruler_2.style.display = "block" 505 506else: 507x0, y0 = bbox_pos.copy() 508x1 = (event.clientX - zone_rect.left) / zone_rect.width 509y1 = (event.clientY - zone_rect.top) / zone_rect.height 510 511rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 512 513new_shape = make_shape_container() 514zone_rect = zone.getBoundingClientRect() 515 516new_shape.appendChild(rectangle) 517zone.appendChild(new_shape) 518 519minx = min(x0, x1) 520miny = min(y0, y1) 521maxx = max(x0, x1) 522maxy = max(y0, y1) 523 524rectangle.setAttribute("x", str(minx * image.naturalWidth)) 525rectangle.setAttribute("y", str(miny * image.naturalHeight)) 526rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth)) 527rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight)) 528rectangle.setAttribute("fill", "none") 529rectangle.setAttribute("data-object-type", "") 530rectangle.classList.add("shape-bbox") 531rectangle.classList.add("shape") 532 533# Add event listeners to the new shape 534rectangle.addEventListener("click", select_shape_proxy) 535 536cancel_bbox(None) 537 538 539polygon_points = [] 540 541 542def make_polygon(event): 543global new_shape, polygon_points 544 545polygon = new_shape.children[0] 546 547zone_rect = zone.getBoundingClientRect() 548 549polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width, 550(event.clientY - zone_rect.top) / zone_rect.height)) 551 552# Update the polygon 553polygon.setAttribute("points", " ".join( 554[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 555polygon_points])) 556 557 558def reset_polygon(): 559global new_shape, polygon_points 560 561zone.removeEventListener("click", make_polygon_proxy) 562document.removeEventListener("keydown", close_polygon_proxy) 563document.removeEventListener("keydown", cancel_polygon_proxy) 564document.removeEventListener("keydown", backspace_polygon_proxy) 565confirm_button.style.display = "none" 566cancel_button.style.display = "none" 567backspace_button.style.display = "none" 568confirm_button.removeEventListener("click", close_polygon_proxy) 569cancel_button.removeEventListener("click", cancel_polygon_proxy) 570backspace_button.removeEventListener("click", backspace_polygon_proxy) 571polygon_points.clear() 572 573zone.style.cursor = "auto" 574new_shape = None 575 576update_transform() 577 578 579def close_polygon(event): 580if event is not None and hasattr(event, "key") and event.key != "Enter": 581return 582# Polygon is already there, but we need to remove the events 583reset_polygon() 584 585 586def cancel_polygon(event): 587if event is not None and hasattr(event, "key") and event.key != "Escape": 588return 589# Delete the polygon 590new_shape.remove() 591reset_polygon() 592 593 594def backspace_polygon(event): 595if event is not None and hasattr(event, "key") and event.key != "Backspace": 596return 597if not polygon_points: 598return 599polygon_points.pop() 600polygon = new_shape.children[0] 601polygon.setAttribute("points", " ".join( 602[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 603polygon_points])) 604 605 606close_polygon_proxy = create_proxy(close_polygon) 607cancel_polygon_proxy = create_proxy(cancel_polygon) 608backspace_polygon_proxy = create_proxy(backspace_polygon) 609 610 611def open_shape(event): 612global new_shape, bbox_pos 613if bbox_pos or shape_type == "select": 614return 615print("Creating a new shape of type:", shape_type) 616 617if shape_type == "shape-bbox": 618helper_message.innerText = ("Define the first point at the intersection of the lines " 619"by clicking on the image, or click the cross to cancel") 620 621cancel_button.addEventListener("click", cancel_bbox_proxy) 622document.addEventListener("keydown", cancel_bbox_proxy) 623cancel_button.style.display = "flex" 624bbox_pos = None 625zone.addEventListener("click", make_bbox_proxy) 626vertical_ruler.style.display = "block" 627horizontal_ruler.style.display = "block" 628zone.style.cursor = "crosshair" 629elif shape_type == "shape-polygon" or shape_type == "shape-polyline": 630if shape_type == "shape-polygon": 631helper_message.innerText = ("Click on the image to define the points of the polygon, " 632"press escape to cancel, enter to close, or backspace to " 633"remove the last point") 634elif shape_type == "shape-polyline": 635helper_message.innerText = ("Click on the image to define the points of the polyline, " 636"press escape to cancel, enter to finish, or backspace to " 637"remove the last point") 638 639if not polygon_points and not new_shape: 640new_shape = make_shape_container() 641zone_rect = zone.getBoundingClientRect() 642 643if not polygon_points and int(new_shape.children.length) == 0: 644zone.addEventListener("click", make_polygon_proxy) 645document.addEventListener("keydown", close_polygon_proxy) 646document.addEventListener("keydown", cancel_polygon_proxy) 647document.addEventListener("keydown", backspace_polygon_proxy) 648cancel_button.addEventListener("click", cancel_polygon_proxy) 649cancel_button.style.display = "flex" 650confirm_button.addEventListener("click", close_polygon_proxy) 651confirm_button.style.display = "flex" 652backspace_button.addEventListener("click", backspace_polygon_proxy) 653backspace_button.style.display = "flex" 654if shape_type == "shape-polygon": 655polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon") 656polygon.classList.add("shape-polygon") 657elif shape_type == "shape-polyline": 658polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline") 659polygon.classList.add("shape-polyline") 660polygon.setAttribute("fill", "none") 661polygon.setAttribute("data-object-type", "") 662polygon.classList.add("shape") 663new_shape.appendChild(polygon) 664zone.appendChild(new_shape) 665zone.style.cursor = "crosshair" 666elif shape_type == "shape-point": 667point = document.createElementNS("http://www.w3.org/2000/svg", "circle") 668zone_rect = zone.getBoundingClientRect() 669point.setAttribute("cx", str((event.clientX - zone_rect.left) / zone_rect.width * image.naturalWidth)) 670point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight)) 671point.setAttribute("r", "0") 672point.classList.add("shape-point") 673point.classList.add("shape") 674point.setAttribute("data-object-type", "") 675 676new_shape = make_shape_container() 677zone_rect = zone.getBoundingClientRect() 678 679new_shape.appendChild(point) 680zone.appendChild(new_shape) 681 682new_shape = None 683 684update_transform() 685 686 687 688for button in list(document.getElementById("shape-selector").children): 689button.addEventListener("click", create_proxy(switch_shape)) 690print("Shape", button.id, "is available") 691 692zone.addEventListener("mousemove", follow_cursor_proxy) 693zone.addEventListener("click", create_proxy(open_shape)) 694 695# Zoom 696zoom_container = document.getElementById("annotation-zoom-container") 697scale = 1 698translate_x = 0 699translate_y = 0 700 701scale_exponent = 0 702 703def update_transform(): 704zone.style.transform = f"translate({translate_x}px, {translate_y}px) scale({scale}) " 705object_list.style.transform = f"scale({1/scale})" # neutralise the zoom 706vertical_ruler.style.transform = f"scale({1/scale})" 707horizontal_ruler.style.transform = f"scale({1/scale})" 708vertical_ruler_2.style.transform = f"scale({1/scale})" 709horizontal_ruler_2.style.transform = f"scale({1/scale})" 710 711for svg in zone.getElementsByClassName("shape-container"): 712# Because vector-effect="non-scaling-stroke" doesn't work when an HTML ancestor is 713# scaled, we need to scale the shapes down then scale them back up 714svg.style.transform = f"scale({1/scale})" 715svg.style.transformOrigin = "0 0" 716for shape in svg.children: 717shape.setAttribute("transform", f"scale({scale})") 718shape.setAttribute("transform-origin", "0 0") 719 720zoom_indicator.innerText = f"{scale:.3f}x" 721 722 723def compute_zoom_translation_around_point(element, x, y, sc, nsc): 724rect = element.getBoundingClientRect() 725# The difference in size between the new and old scales. 726size_difference_x = (nsc - sc) * image.width 727size_difference_y = (nsc - sc) * image.height 728width = rect.width / sc 729height = rect.height / sc 730 731# (size difference) * ((cursor position) / (image size) - (transform origin)) 732tx = size_difference_x * (x / width - 0.5) 733ty = size_difference_y * (y / height - 0.5) 734 735return tx, ty 736 737 738def on_wheel(event): 739global scale, scale_exponent, translate_x, translate_y 740if object_list.matches(":hover"): 741return 742 743event.preventDefault() 744 745rect = zone.getBoundingClientRect() 746 747# Position of the cursor in the unscaled image. 748mouse_x = (event.clientX - rect.left) / scale 749mouse_y = (event.clientY - rect.top) / scale 750 751# Adjust scale exponent and compute new scale. 752scale_exponent += (-1 if event.deltaY > 0 else 1) / 4 753new_scale = 2 ** scale_exponent 754 755# Limit the scale to a reasonable range. 756new_scale = max(0.0625, min(64, new_scale)) 757 758# Compute the new translation. 759offset_x, offset_y = compute_zoom_translation_around_point(zone, mouse_x, mouse_y, scale, new_scale) 760translate_x -= offset_x 761translate_y -= offset_y 762 763scale = new_scale 764zoom_slider.value = scale_exponent 765 766update_transform() 767 768 769is_dragging = False 770start_x = 0 771start_y = 0 772 773def on_mouse_down(event): 774global is_dragging, start_x, start_y 775if object_list.matches(":hover"): 776return 777 778if event.button == 0: 779is_dragging = True 780start_x = event.clientX 781start_y = event.clientY 782 783def on_mouse_move(event): 784global translate_x, translate_y 785if object_list.matches(":hover"): 786return 787 788if is_dragging: 789translate_x += event.movementX 790translate_y += event.movementY 791update_transform() 792 793def on_mouse_up(event): 794global is_dragging 795if object_list.matches(":hover"): 796return 797is_dragging = False 798 799last_touch_positions = None 800last_touch_distance = None 801 802def calculate_distance(touches): 803dx = touches[1].clientX - touches[0].clientX 804dy = touches[1].clientY - touches[0].clientY 805return (dx**2 + dy**2) ** 0.5 806 807def on_touch_start(event): 808global last_touch_positions, last_touch_distance 809 810if event.touches.length == 2: 811event.preventDefault() 812last_touch_positions = [ 813(event.touches[0].clientX, event.touches[0].clientY), 814(event.touches[1].clientX, event.touches[1].clientY) 815] 816last_touch_distance = calculate_distance(event.touches) 817 818def on_touch_move(event): 819global translate_x, translate_y, scale, last_touch_positions, last_touch_distance 820 821if event.touches.length == 2: 822event.preventDefault() 823 824# Finger positions. 825current_positions = [ 826(event.touches[0].clientX, event.touches[0].clientY), 827(event.touches[1].clientX, event.touches[1].clientY) 828] 829 830current_distance = calculate_distance(event.touches) 831# Ignore small movements. 832if abs(current_distance - last_touch_distance) > 16: 833zoom_factor = current_distance / last_touch_distance 834new_scale = max(0.0625, min(64, scale * zoom_factor)) 835 836# Finger midpoint. 837midpoint_x = (current_positions[0][0] + current_positions[1][0]) / 2 838midpoint_y = (current_positions[0][1] + current_positions[1][1]) / 2 839 840# Compute translation for zooming around the midpoint. 841tx, ty = compute_zoom_translation_around_point(zone, midpoint_x, midpoint_y, scale, new_scale) 842 843# Compute translation for panning. 844delta_x = sum(p[0] - lp[0] for p, lp in zip(current_positions, last_touch_positions)) / 2 845delta_y = sum(p[1] - lp[1] for p, lp in zip(current_positions, last_touch_positions)) / 2 846 847translate_x += delta_x - tx 848translate_y += delta_y - ty 849 850# Update state. 851scale = new_scale 852last_touch_positions = current_positions 853last_touch_distance = current_distance 854 855zoom_slider.value = scale_exponent 856update_transform() 857 858 859def on_touch_end(event): 860global last_touch_positions, last_touch_distance 861if event.touches.length < 2: 862last_touch_positions = None 863last_touch_distance = None 864 865 866def zoom_slider_change(event): 867global scale, scale_exponent 868scale_exponent = float(event.currentTarget.value) 869scale = 2 ** scale_exponent 870 871update_transform() 872 873 874def zoom_in(event): 875zoom_slider.stepUp() 876zoom_slider.dispatchEvent(js.Event.new("input")) 877 878 879def zoom_out(event): 880zoom_slider.stepDown() 881zoom_slider.dispatchEvent(js.Event.new("input")) 882 883 884zoom_container.addEventListener("wheel", on_wheel) 885zoom_container.addEventListener("mousedown", on_mouse_down) 886document.addEventListener("mousemove", on_mouse_move) 887document.addEventListener("mouseup", on_mouse_up) 888zone.addEventListener("touchstart", on_touch_start) 889zone.addEventListener("touchmove", on_touch_move) 890zone.addEventListener("touchend", on_touch_end) 891zoom_slider.addEventListener("input", zoom_slider_change) 892zoom_in_button.addEventListener("click", zoom_in) 893zoom_out_button.addEventListener("click", zoom_out) 894 895# Load existing annotations, if any 896put_shapes(await load_shapes()) 897update_transform() 898print("Ready!") 899