picture-annotation.py
Python script, ASCII text executable
1from pyscript import document, fetch as pyfetch 2from pyscript.ffi import create_proxy 3import asyncio 4import json 5 6document.getElementById("shape-options").style.display = "flex" 7 8image = document.getElementById("annotation-image") 9zone = document.getElementById("annotation-zone") 10confirm_button = document.getElementById("annotation-confirm") 11cancel_button = document.getElementById("annotation-cancel") 12backspace_button = document.getElementById("annotation-backspace") 13delete_button = document.getElementById("annotation-delete") 14previous_button = document.getElementById("annotation-previous") 15next_button = document.getElementById("annotation-next") 16save_button = document.getElementById("annotation-save") 17 18object_list = document.getElementById("object-types") 19 20confirm_button.style.display = "none" 21cancel_button.style.display = "none" 22backspace_button.style.display = "none" 23delete_button.style.display = "none" 24previous_button.style.display = "none" 25next_button.style.display = "none" 26shape_type = "" 27bbox_pos = None 28new_shape = None 29selected_shape = None 30 31 32def make_shape_container(): 33shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 34shape.setAttribute("width", "100%") 35shape.setAttribute("height", "100%") 36shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 37shape.classList.add("shape-container") 38 39return shape 40 41 42async def get_all_objects(): 43response = await pyfetch("/api/object-types") 44if response.ok: 45return await response.json() 46 47 48def follow_cursor(event): 49rect = zone.getBoundingClientRect() 50x = event.clientX - rect.left 51y = event.clientY - rect.top 52vertical_ruler.style.left = str(x) + "px" 53horizontal_ruler.style.top = str(y) + "px" 54 55 56def change_object_type(event): 57global selected_shape 58if selected_shape is None: 59return 60selected_shape.setAttribute("data-object-type", event.currentTarget.value) 61 62 63change_object_type_proxy = create_proxy(change_object_type) 64 65 66def list_shapes(): 67shapes = list(zone.getElementsByClassName("shape")) 68json_shapes = [] 69for shape in shapes: 70shape_dict = {} 71if shape.tagName == "rect": 72shape_dict["type"] = "bbox" 73shape_dict["shape"] = { 74"x": float(shape.getAttribute("x")) / image.naturalWidth, 75"y": float(shape.getAttribute("y")) / image.naturalHeight, 76"w": float(shape.getAttribute("width")) / image.naturalWidth, 77"h": float(shape.getAttribute("height")) / image.naturalHeight 78} 79elif shape.tagName == "polygon" or shape.tagName == "polyline": 80if shape.tagName == "polygon": 81shape_dict["type"] = "polygon" 82elif shape.tagName == "polyline": 83shape_dict["type"] = "polyline" 84 85points = shape.getAttribute("points").split(" ") 86json_points = [] 87for point in points: 88x, y = point.split(",") 89x, y = float(x), float(y) 90json_points.append({ 91"x": x / image.naturalWidth, 92"y": y / image.naturalHeight 93}) 94 95shape_dict["shape"] = json_points 96elif shape.tagName == "circle" and shape.classList.contains("shape-point"): 97shape_dict["type"] = "point" 98shape_dict["shape"] = { 99"x": float(shape.getAttribute("cx")) / image.naturalWidth, 100"y": float(shape.getAttribute("cy")) / image.naturalHeight 101} 102else: 103continue 104 105shape_dict["object"] = shape.getAttribute("data-object-type") 106json_shapes.append(shape_dict) 107 108return json_shapes 109 110 111def put_shapes(json_shapes): 112for shape in json_shapes: 113new_shape = make_shape_container() 114zone_rect = zone.getBoundingClientRect() 115 116if shape["type"] == "bbox": 117rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 118rectangle.setAttribute("x", str(shape["shape"]["x"] * image.naturalWidth)) 119rectangle.setAttribute("y", str(shape["shape"]["y"] * image.naturalHeight)) 120rectangle.setAttribute("width", str(shape["shape"]["w"] * image.naturalWidth)) 121rectangle.setAttribute("height", str(shape["shape"]["h"] * image.naturalHeight)) 122rectangle.setAttribute("fill", "none") 123rectangle.setAttribute("data-object-type", shape["object"] or "") 124rectangle.classList.add("shape-bbox") 125rectangle.classList.add("shape") 126new_shape.appendChild(rectangle) 127elif shape["type"] == "polygon" or shape["type"] == "polyline": 128polygon = document.createElementNS("http://www.w3.org/2000/svg", shape["type"]) 129points = " ".join( 130[f"{point['x'] * image.naturalWidth},{point['y'] * image.naturalHeight}" for point in shape["shape"]]) 131polygon.setAttribute("points", points) 132polygon.setAttribute("fill", "none") 133polygon.setAttribute("data-object-type", shape["object"] or "") 134polygon.classList.add(f"shape-{shape['type']}") 135polygon.classList.add("shape") 136new_shape.appendChild(polygon) 137elif shape["type"] == "point": 138point = document.createElementNS("http://www.w3.org/2000/svg", "circle") 139point.setAttribute("cx", str(shape["shape"]["x"] * image.naturalWidth)) 140point.setAttribute("cy", str(shape["shape"]["y"] * image.naturalHeight)) 141point.setAttribute("r", "0") 142point.classList.add("shape-point") 143point.classList.add("shape") 144point.setAttribute("data-object-type", shape["object"] or "") 145new_shape.appendChild(point) 146 147zone.appendChild(new_shape) 148 149 150async def load_shapes(): 151resource_id = document.getElementById("resource-id").value 152response = await pyfetch(f"/picture/{resource_id}/get-annotations") 153if response.ok: 154shapes = await response.json() 155return shapes 156 157 158async def save_shapes(event): 159shapes = list_shapes() 160resource_id = document.getElementById("resource-id").value 161print("Saving shapes:", shapes) 162response = await pyfetch(f"/picture/{resource_id}/save-annotations", 163method="POST", 164headers={ 165"Content-Type": "application/json" 166}, 167body=json.dumps(shapes) 168) 169if response.ok: 170return await response 171 172 173save_shapes_proxy = create_proxy(save_shapes) 174save_button.addEventListener("click", save_shapes_proxy) 175delete_shape_key_proxy = create_proxy(lambda event: event.key == "Delete" and delete_shape_proxy(None)) 176next_shape_key_proxy = create_proxy(lambda event: event.key == "ArrowRight" and next_shape_proxy(None)) 177previous_shape_key_proxy = create_proxy(lambda event: event.key == "ArrowLeft" and previous_shape_proxy(None)) 178 179 180def get_centre(shape): 181if shape.tagName == "rect": 182x = float(shape.getAttribute("x")) + float(shape.getAttribute("width")) / 2 183y = float(shape.getAttribute("y")) + float(shape.getAttribute("height")) / 2 184elif shape.tagName == "polygon": 185points = shape.getAttribute("points").split(" ") 186# Average of the extreme points 187sorted_x = sorted([float(point.split(",")[0]) for point in points]) 188sorted_y = sorted([float(point.split(",")[1]) for point in points]) 189top = sorted_y[0] 190bottom = sorted_y[-1] 191left = sorted_x[0] 192right = sorted_x[-1] 193 194x = (left + right) / 2 195y = (top + bottom) / 2 196elif shape.tagName == "polyline": 197points = shape.getAttribute("points").split(" ") 198# Median point 199x = float(points[len(points) // 2].split(",")[0]) 200y = float(points[len(points) // 2].split(",")[1]) 201elif shape.tagName == "circle" and shape.classList.contains("shape-point"): 202x = float(shape.getAttribute("cx")) 203y = float(shape.getAttribute("cy")) 204else: 205return None 206 207return x, y 208 209 210async def focus_shape(shape): 211global selected_shape 212 213if shape_type != "select": 214return 215if selected_shape is not None: 216selected_shape.classList.remove("selected") 217 218selected_shape = shape 219 220selected_shape.classList.add("selected") 221 222objects = await get_all_objects() 223 224delete_button.style.display = "flex" 225next_button.style.display = "flex" 226previous_button.style.display = "flex" 227document.addEventListener("keydown", delete_shape_key_proxy) 228document.addEventListener("keydown", next_shape_key_proxy) 229document.addEventListener("keydown", previous_shape_key_proxy) 230 231object_list.innerHTML = "" 232 233new_radio = document.createElement("input") 234new_radio.setAttribute("type", "radio") 235new_radio.setAttribute("name", "object-type") 236new_radio.setAttribute("tabindex", "1") # Focus first 237new_radio.setAttribute("value", "") 238new_label = document.createElement("label") 239new_label.appendChild(new_radio) 240new_label.append("Undefined") 241object_list.appendChild(new_label) 242new_radio.addEventListener("change", change_object_type_proxy) 243 244selected_object = selected_shape.getAttribute("data-object-type") 245if not selected_object: 246new_radio.setAttribute("checked", "") 247 248for object, description in objects.items(): 249new_radio = document.createElement("input") 250new_radio.setAttribute("type", "radio") 251new_radio.setAttribute("name", "object-type") 252new_radio.setAttribute("value", object) 253if selected_object == object: 254new_radio.setAttribute("checked", "") 255new_label = document.createElement("label") 256new_label.appendChild(new_radio) 257new_label.append(object) 258object_list.appendChild(new_label) 259new_radio.addEventListener("change", change_object_type_proxy) 260 261object_list.style.display = "flex" 262object_list.style.left = str(get_centre(shape)[0] / image.naturalWidth * 100) + "%" 263object_list.style.top = str(get_centre(shape)[1] / image.naturalHeight * 100) + "%" 264object_list.style.right = "auto" 265object_list.style.bottom = "auto" 266object_list.style.maxHeight = str(image.height - get_centre(shape)[1] / image.naturalHeight * image.height) + "px" 267object_list.style.maxWidth = str(image.width - get_centre(shape)[0] / image.naturalWidth * image.width) + "px" 268 269if get_centre(shape)[0] / image.naturalWidth * image.width + object_list.offsetWidth > image.width: 270object_list.style.left = "auto" 271object_list.style.right = str((image.naturalWidth - get_centre(shape)[0]) / image.naturalWidth * 100) + "%" 272object_list.style.maxWidth = str(get_centre(shape)[0] / image.naturalWidth * image.width) + "px" 273 274if get_centre(shape)[1] / image.naturalHeight * image.height + object_list.offsetHeight > image.height: 275object_list.style.top = "auto" 276object_list.style.bottom = str((image.naturalHeight - get_centre(shape)[1]) / image.naturalHeight * 100) + "%" 277object_list.style.maxHeight = str(get_centre(shape)[1] / image.naturalHeight * image.height) + "px" 278 279 280async def select_shape(event): 281await focus_shape(event.target) 282 283 284async def next_shape(event): 285global selected_shape 286if selected_shape is None: 287return 288 289selected_svg = selected_shape.parentNode 290 291while selected_svg is not None: 292next_sibling = selected_svg.nextElementSibling 293if next_sibling and next_sibling.classList.contains("shape-container"): 294selected_svg = next_sibling 295break 296elif next_sibling is None: 297# If no more siblings, loop back to the first child 298selected_svg = selected_svg.parentNode.firstElementChild 299while selected_svg is not None and not selected_svg.classList.contains( 300"shape-container"): 301selected_svg = selected_svg.nextElementSibling 302break 303else: 304selected_svg = next_sibling 305 306if selected_svg: 307shape = selected_svg.firstElementChild 308await focus_shape(shape) 309 310 311async def previous_shape(event): 312global selected_shape 313if selected_shape is None: 314return 315 316selected_svg = selected_shape.parentNode 317 318while selected_svg is not None: 319next_sibling = selected_svg.previousElementSibling 320if next_sibling and next_sibling.classList.contains("shape-container"): 321selected_svg = next_sibling 322break 323elif next_sibling is None: 324# If no more siblings, loop back to the last child 325selected_svg = selected_svg.parentNode.lastElementChild 326while selected_svg is not None and not selected_svg.classList.contains( 327"shape-container"): 328selected_svg = selected_svg.previousElementSibling 329break 330else: 331selected_svg = next_sibling 332 333if selected_svg: 334shape = selected_svg.firstElementChild 335await focus_shape(shape) 336 337 338def unselect_shape(event): 339global selected_shape 340 341if selected_shape is not None: 342selected_shape.classList.remove("selected") 343selected_shape = None 344 345object_list.innerHTML = "" 346object_list.style.display = "none" 347delete_button.style.display = "none" 348next_button.style.display = "none" 349previous_button.style.display = "none" 350document.removeEventListener("keydown", delete_shape_key_proxy) 351document.removeEventListener("keydown", next_shape_key_proxy) 352document.removeEventListener("keydown", previous_shape_key_proxy) 353 354 355def delete_shape(event): 356global selected_shape 357if selected_shape is None: 358return 359# Shape is SVG shape inside SVG so we need to remove the parent SVG 360selected_shape.parentNode.remove() 361selected_shape = None 362object_list.innerHTML = "" 363object_list.style.display = "none" 364delete_button.style.display = "none" 365next_button.style.display = "none" 366previous_button.style.display = "none" 367document.removeEventListener("keydown", delete_shape_key_proxy) 368document.removeEventListener("keydown", next_shape_key_proxy) 369document.removeEventListener("keydown", previous_shape_key_proxy) 370 371 372select_shape_proxy = create_proxy(select_shape) 373unselect_shape_proxy = create_proxy(unselect_shape) 374delete_shape_proxy = create_proxy(delete_shape) 375next_shape_proxy = create_proxy(next_shape) 376previous_shape_proxy = create_proxy(previous_shape) 377 378delete_button.addEventListener("click", delete_shape_proxy) 379next_button.addEventListener("click", next_shape_proxy) 380previous_button.addEventListener("click", previous_shape_proxy) 381 382# These are functions usable in JS 383cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event)) 384make_bbox_proxy = create_proxy(lambda event: make_bbox(event)) 385make_polygon_proxy = create_proxy(lambda event: make_polygon(event)) 386follow_cursor_proxy = create_proxy(follow_cursor) 387 388 389def switch_shape(event): 390global shape_type 391object_list.innerHTML = "" 392unselect_shape(None) 393shape = event.currentTarget.id 394shape_type = shape 395for button in list(document.getElementById("shape-selector").children): 396if not button.classList.contains("button-flat"): 397button.classList.add("button-flat") 398event.currentTarget.classList.remove("button-flat") 399if shape_type == "select": 400# Add event listeners to existing shapes 401print(len(list(document.getElementsByClassName("shape"))), "shapes found") 402for shape in document.getElementsByClassName("shape"): 403print("Adding event listener to shape:", shape) 404shape.addEventListener("click", select_shape_proxy) 405image.addEventListener("click", unselect_shape_proxy) 406helper_message.innerText = "Click on a shape to select" 407# Cancel the current shape creation 408if shape_type == "shape-bbox": 409cancel_bbox(None) 410elif shape_type == "shape-polygon": 411cancel_polygon(None) 412elif shape_type == "shape-polyline": 413cancel_polygon(None) 414else: 415# Remove event listeners for selection 416for shape in document.getElementsByClassName("shape"): 417print("Removing event listener from shape:", shape) 418shape.removeEventListener("click", select_shape_proxy) 419image.removeEventListener("click", unselect_shape_proxy) 420helper_message.innerText = "Select a shape type then click on the image to begin defining it" 421print("Shape is now of type:", shape) 422 423 424def select_mode(): 425global shape_type 426document.getElementById("select").click() 427 428 429vertical_ruler = document.getElementById("annotation-ruler-vertical") 430horizontal_ruler = document.getElementById("annotation-ruler-horizontal") 431vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary") 432horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary") 433helper_message = document.getElementById("annotation-helper-message") 434 435helper_message.innerText = "Select a shape type then click on the image to begin defining it" 436 437 438def cancel_bbox(event): 439global bbox_pos, new_shape 440 441# Key must be ESCAPE 442if event is not None and hasattr(event, "key") and event.key != "Escape": 443return 444 445if new_shape is not None and event is not None: 446# Require event so the shape is kept when it ends normally 447new_shape.remove() 448zone.removeEventListener("click", make_bbox_proxy) 449document.removeEventListener("keydown", cancel_bbox_proxy) 450cancel_button.removeEventListener("click", cancel_bbox_proxy) 451 452bbox_pos = None 453vertical_ruler.style.display = "none" 454horizontal_ruler.style.display = "none" 455vertical_ruler_2.style.display = "none" 456horizontal_ruler_2.style.display = "none" 457zone.style.cursor = "auto" 458cancel_button.style.display = "none" 459helper_message.innerText = "Select a shape type then click on the image to begin defining it" 460new_shape = None 461 462 463def make_bbox(event): 464global new_shape, bbox_pos 465zone_rect = zone.getBoundingClientRect() 466 467if bbox_pos is None: 468helper_message.innerText = "Now define the second point" 469 470bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width, 471(event.clientY - zone_rect.top) / zone_rect.height] 472vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%" 473horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%" 474vertical_ruler_2.style.display = "block" 475horizontal_ruler_2.style.display = "block" 476 477else: 478x0, y0 = bbox_pos.copy() 479x1 = (event.clientX - zone_rect.left) / zone_rect.width 480y1 = (event.clientY - zone_rect.top) / zone_rect.height 481 482rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 483 484new_shape = make_shape_container() 485zone_rect = zone.getBoundingClientRect() 486 487new_shape.appendChild(rectangle) 488zone.appendChild(new_shape) 489 490minx = min(x0, x1) 491miny = min(y0, y1) 492maxx = max(x0, x1) 493maxy = max(y0, y1) 494 495rectangle.setAttribute("x", str(minx * image.naturalWidth)) 496rectangle.setAttribute("y", str(miny * image.naturalHeight)) 497rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth)) 498rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight)) 499rectangle.setAttribute("fill", "none") 500rectangle.setAttribute("data-object-type", "") 501rectangle.classList.add("shape-bbox") 502rectangle.classList.add("shape") 503 504# Add event listeners to the new shape 505rectangle.addEventListener("click", select_shape_proxy) 506 507cancel_bbox(None) 508 509 510polygon_points = [] 511 512 513def make_polygon(event): 514global new_shape, polygon_points 515 516polygon = new_shape.children[0] 517 518zone_rect = zone.getBoundingClientRect() 519 520polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width, 521(event.clientY - zone_rect.top) / zone_rect.height)) 522 523# Update the polygon 524polygon.setAttribute("points", " ".join( 525[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 526polygon_points])) 527 528 529def reset_polygon(): 530global new_shape, polygon_points 531 532zone.removeEventListener("click", make_polygon_proxy) 533document.removeEventListener("keydown", close_polygon_proxy) 534document.removeEventListener("keydown", cancel_polygon_proxy) 535document.removeEventListener("keydown", backspace_polygon_proxy) 536confirm_button.style.display = "none" 537cancel_button.style.display = "none" 538backspace_button.style.display = "none" 539confirm_button.removeEventListener("click", close_polygon_proxy) 540cancel_button.removeEventListener("click", cancel_polygon_proxy) 541backspace_button.removeEventListener("click", backspace_polygon_proxy) 542polygon_points.clear() 543 544zone.style.cursor = "auto" 545new_shape = None 546 547 548def close_polygon(event): 549if event is not None and hasattr(event, "key") and event.key != "Enter": 550return 551# Polygon is already there, but we need to remove the events 552reset_polygon() 553 554 555def cancel_polygon(event): 556if event is not None and hasattr(event, "key") and event.key != "Escape": 557return 558# Delete the polygon 559new_shape.remove() 560reset_polygon() 561 562 563def backspace_polygon(event): 564if event is not None and hasattr(event, "key") and event.key != "Backspace": 565return 566if not polygon_points: 567return 568polygon_points.pop() 569polygon = new_shape.children[0] 570polygon.setAttribute("points", " ".join( 571[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 572polygon_points])) 573 574 575close_polygon_proxy = create_proxy(close_polygon) 576cancel_polygon_proxy = create_proxy(cancel_polygon) 577backspace_polygon_proxy = create_proxy(backspace_polygon) 578 579 580def open_shape(event): 581global new_shape, bbox_pos 582if bbox_pos or shape_type == "select": 583return 584print("Creating a new shape of type:", shape_type) 585 586if shape_type == "shape-bbox": 587helper_message.innerText = ("Define the first point at the intersection of the lines " 588"by clicking on the image, or click the cross to cancel") 589 590cancel_button.addEventListener("click", cancel_bbox_proxy) 591document.addEventListener("keydown", cancel_bbox_proxy) 592cancel_button.style.display = "flex" 593bbox_pos = None 594zone.addEventListener("click", make_bbox_proxy) 595vertical_ruler.style.display = "block" 596horizontal_ruler.style.display = "block" 597zone.style.cursor = "crosshair" 598elif shape_type == "shape-polygon" or shape_type == "shape-polyline": 599if shape_type == "shape-polygon": 600helper_message.innerText = ("Click on the image to define the points of the polygon, " 601"press escape to cancel, enter to close, or backspace to " 602"remove the last point") 603elif shape_type == "shape-polyline": 604helper_message.innerText = ("Click on the image to define the points of the polyline, " 605"press escape to cancel, enter to finish, or backspace to " 606"remove the last point") 607 608if not polygon_points and not new_shape: 609new_shape = make_shape_container() 610zone_rect = zone.getBoundingClientRect() 611 612if not polygon_points and int(new_shape.children.length) == 0: 613zone.addEventListener("click", make_polygon_proxy) 614document.addEventListener("keydown", close_polygon_proxy) 615document.addEventListener("keydown", cancel_polygon_proxy) 616document.addEventListener("keydown", backspace_polygon_proxy) 617cancel_button.addEventListener("click", cancel_polygon_proxy) 618cancel_button.style.display = "flex" 619confirm_button.addEventListener("click", close_polygon_proxy) 620confirm_button.style.display = "flex" 621backspace_button.addEventListener("click", backspace_polygon_proxy) 622backspace_button.style.display = "flex" 623if shape_type == "shape-polygon": 624polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon") 625polygon.classList.add("shape-polygon") 626elif shape_type == "shape-polyline": 627polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline") 628polygon.classList.add("shape-polyline") 629polygon.setAttribute("fill", "none") 630polygon.setAttribute("data-object-type", "") 631polygon.classList.add("shape") 632new_shape.appendChild(polygon) 633zone.appendChild(new_shape) 634zone.style.cursor = "crosshair" 635elif shape_type == "shape-point": 636point = document.createElementNS("http://www.w3.org/2000/svg", "circle") 637zone_rect = zone.getBoundingClientRect() 638point.setAttribute("cx", str((event.clientX - zone_rect.left) / zone_rect.width * image.naturalWidth)) 639point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight)) 640point.setAttribute("r", "0") 641point.classList.add("shape-point") 642point.classList.add("shape") 643point.setAttribute("data-object-type", "") 644 645new_shape = make_shape_container() 646zone_rect = zone.getBoundingClientRect() 647 648new_shape.appendChild(point) 649zone.appendChild(new_shape) 650 651new_shape = None 652 653 654 655for button in list(document.getElementById("shape-selector").children): 656button.addEventListener("click", create_proxy(switch_shape)) 657print("Shape", button.id, "is available") 658 659zone.addEventListener("mousemove", follow_cursor_proxy) 660zone.addEventListener("click", create_proxy(open_shape)) 661 662# Load existing annotations, if any 663put_shapes(await load_shapes()) 664print("Ready!") 665