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 180async def focus_shape(shape): 181global selected_shape 182 183if shape_type != "select": 184return 185if selected_shape is not None: 186selected_shape.classList.remove("selected") 187 188selected_shape = shape 189 190selected_shape.classList.add("selected") 191 192objects = await get_all_objects() 193 194delete_button.style.display = "block" 195next_button.style.display = "block" 196previous_button.style.display = "block" 197document.addEventListener("keydown", delete_shape_key_proxy) 198document.addEventListener("keydown", next_shape_key_proxy) 199document.addEventListener("keydown", previous_shape_key_proxy) 200 201object_list.innerHTML = "" 202 203new_radio = document.createElement("input") 204new_radio.setAttribute("type", "radio") 205new_radio.setAttribute("name", "object-type") 206new_radio.setAttribute("value", "") 207new_label = document.createElement("label") 208new_label.appendChild(new_radio) 209new_label.append("Undefined") 210object_list.appendChild(new_label) 211new_radio.addEventListener("change", change_object_type_proxy) 212 213selected_object = selected_shape.getAttribute("data-object-type") 214if not selected_object: 215new_radio.setAttribute("checked", "") 216 217for object, description in objects.items(): 218new_radio = document.createElement("input") 219new_radio.setAttribute("type", "radio") 220new_radio.setAttribute("name", "object-type") 221new_radio.setAttribute("value", object) 222if selected_object == object: 223new_radio.setAttribute("checked", "") 224new_label = document.createElement("label") 225new_label.appendChild(new_radio) 226new_label.append(object) 227object_list.appendChild(new_label) 228new_radio.addEventListener("change", change_object_type_proxy) 229 230 231async def select_shape(event): 232await focus_shape(event.target) 233 234 235async def next_shape(event): 236global selected_shape 237if selected_shape is None: 238return 239 240selected_svg = selected_shape.parentNode 241 242while selected_svg is not None: 243next_sibling = selected_svg.nextElementSibling 244if next_sibling and next_sibling.classList.contains("shape-container"): 245selected_svg = next_sibling 246break 247elif next_sibling is None: 248# If no more siblings, loop back to the first child 249selected_svg = selected_svg.parentNode.firstElementChild 250while selected_svg is not None and not selected_svg.classList.contains( 251"shape-container"): 252selected_svg = selected_svg.nextElementSibling 253break 254else: 255selected_svg = next_sibling 256 257if selected_svg: 258shape = selected_svg.firstElementChild 259await focus_shape(shape) 260 261 262async def previous_shape(event): 263global selected_shape 264if selected_shape is None: 265return 266 267selected_svg = selected_shape.parentNode 268 269while selected_svg is not None: 270next_sibling = selected_svg.previousElementSibling 271if next_sibling and next_sibling.classList.contains("shape-container"): 272selected_svg = next_sibling 273break 274elif next_sibling is None: 275# If no more siblings, loop back to the last child 276selected_svg = selected_svg.parentNode.lastElementChild 277while selected_svg is not None and not selected_svg.classList.contains( 278"shape-container"): 279selected_svg = selected_svg.previousElementSibling 280break 281else: 282selected_svg = next_sibling 283 284if selected_svg: 285shape = selected_svg.firstElementChild 286await focus_shape(shape) 287 288 289def unselect_shape(event): 290global selected_shape 291 292if selected_shape is not None: 293selected_shape.classList.remove("selected") 294selected_shape = None 295 296object_list.innerHTML = "" 297delete_button.style.display = "none" 298next_button.style.display = "none" 299previous_button.style.display = "none" 300document.removeEventListener("keydown", delete_shape_key_proxy) 301document.removeEventListener("keydown", next_shape_key_proxy) 302document.removeEventListener("keydown", previous_shape_key_proxy) 303 304 305def delete_shape(event): 306global selected_shape 307if selected_shape is None: 308return 309# Shape is SVG shape inside SVG so we need to remove the parent SVG 310selected_shape.parentNode.remove() 311selected_shape = None 312object_list.innerHTML = "" 313delete_button.style.display = "none" 314next_button.style.display = "none" 315previous_button.style.display = "none" 316document.removeEventListener("keydown", delete_shape_key_proxy) 317document.removeEventListener("keydown", next_shape_key_proxy) 318document.removeEventListener("keydown", previous_shape_key_proxy) 319 320 321select_shape_proxy = create_proxy(select_shape) 322unselect_shape_proxy = create_proxy(unselect_shape) 323delete_shape_proxy = create_proxy(delete_shape) 324next_shape_proxy = create_proxy(next_shape) 325previous_shape_proxy = create_proxy(previous_shape) 326 327delete_button.addEventListener("click", delete_shape_proxy) 328next_button.addEventListener("click", next_shape_proxy) 329previous_button.addEventListener("click", previous_shape_proxy) 330 331# These are functions usable in JS 332cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event)) 333make_bbox_proxy = create_proxy(lambda event: make_bbox(event)) 334make_polygon_proxy = create_proxy(lambda event: make_polygon(event)) 335follow_cursor_proxy = create_proxy(follow_cursor) 336 337 338def switch_shape(event): 339global shape_type 340object_list.innerHTML = "" 341unselect_shape(None) 342shape = event.currentTarget.id 343shape_type = shape 344if shape_type == "select": 345# Add event listeners to existing shapes 346print(len(list(document.getElementsByClassName("shape"))), "shapes found") 347for shape in document.getElementsByClassName("shape"): 348print("Adding event listener to shape:", shape) 349shape.addEventListener("click", select_shape_proxy) 350image.addEventListener("click", unselect_shape_proxy) 351helper_message.innerText = "Click on a shape to select" 352# Cancel the current shape creation 353if shape_type == "shape-bbox": 354cancel_bbox(None) 355elif shape_type == "shape-polygon": 356cancel_polygon(None) 357elif shape_type == "shape-polyline": 358cancel_polygon(None) 359else: 360# Remove event listeners for selection 361for shape in document.getElementsByClassName("shape"): 362print("Removing event listener from shape:", shape) 363shape.removeEventListener("click", select_shape_proxy) 364image.removeEventListener("click", unselect_shape_proxy) 365helper_message.innerText = "Select a shape type then click on the image to begin defining it" 366print("Shape is now of type:", shape) 367 368 369vertical_ruler = document.getElementById("annotation-ruler-vertical") 370horizontal_ruler = document.getElementById("annotation-ruler-horizontal") 371vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary") 372horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary") 373helper_message = document.getElementById("annotation-helper-message") 374 375helper_message.innerText = "Select a shape type then click on the image to begin defining it" 376 377 378def cancel_bbox(event): 379global bbox_pos, new_shape 380 381# Key must be ESCAPE 382if event is not None and hasattr(event, "key") and event.key != "Escape": 383return 384 385if new_shape is not None and event is not None: 386# Require event so the shape is kept when it ends normally 387new_shape.remove() 388zone.removeEventListener("click", make_bbox_proxy) 389document.removeEventListener("keydown", cancel_bbox_proxy) 390cancel_button.removeEventListener("click", cancel_bbox_proxy) 391 392bbox_pos = None 393vertical_ruler.style.display = "none" 394horizontal_ruler.style.display = "none" 395vertical_ruler_2.style.display = "none" 396horizontal_ruler_2.style.display = "none" 397zone.style.cursor = "auto" 398cancel_button.style.display = "none" 399helper_message.innerText = "Select a shape type then click on the image to begin defining it" 400new_shape = None 401 402 403def make_bbox(event): 404global new_shape, bbox_pos 405zone_rect = zone.getBoundingClientRect() 406 407if bbox_pos is None: 408helper_message.innerText = "Now define the second point" 409 410bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width, 411(event.clientY - zone_rect.top) / zone_rect.height] 412vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%" 413horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%" 414vertical_ruler_2.style.display = "block" 415horizontal_ruler_2.style.display = "block" 416 417else: 418x0, y0 = bbox_pos.copy() 419x1 = (event.clientX - zone_rect.left) / zone_rect.width 420y1 = (event.clientY - zone_rect.top) / zone_rect.height 421 422rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 423 424new_shape = make_shape_container() 425zone_rect = zone.getBoundingClientRect() 426 427new_shape.appendChild(rectangle) 428zone.appendChild(new_shape) 429 430minx = min(x0, x1) 431miny = min(y0, y1) 432maxx = max(x0, x1) 433maxy = max(y0, y1) 434 435rectangle.setAttribute("x", str(minx * image.naturalWidth)) 436rectangle.setAttribute("y", str(miny * image.naturalHeight)) 437rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth)) 438rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight)) 439rectangle.setAttribute("fill", "none") 440rectangle.setAttribute("data-object-type", "") 441rectangle.classList.add("shape-bbox") 442rectangle.classList.add("shape") 443 444# Add event listeners to the new shape 445rectangle.addEventListener("click", select_shape_proxy) 446 447cancel_bbox(None) 448 449 450polygon_points = [] 451 452 453def make_polygon(event): 454global new_shape, polygon_points 455 456polygon = new_shape.children[0] 457 458zone_rect = zone.getBoundingClientRect() 459 460polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width, 461(event.clientY - zone_rect.top) / zone_rect.height)) 462 463# Update the polygon 464polygon.setAttribute("points", " ".join( 465[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 466polygon_points])) 467 468 469def reset_polygon(): 470global new_shape, polygon_points 471 472zone.removeEventListener("click", make_polygon_proxy) 473document.removeEventListener("keydown", close_polygon_proxy) 474document.removeEventListener("keydown", cancel_polygon_proxy) 475document.removeEventListener("keydown", backspace_polygon_proxy) 476confirm_button.style.display = "none" 477cancel_button.style.display = "none" 478backspace_button.style.display = "none" 479confirm_button.removeEventListener("click", close_polygon_proxy) 480cancel_button.removeEventListener("click", cancel_polygon_proxy) 481backspace_button.removeEventListener("click", backspace_polygon_proxy) 482polygon_points.clear() 483 484zone.style.cursor = "auto" 485new_shape = None 486 487 488def close_polygon(event): 489if event is not None and hasattr(event, "key") and event.key != "Enter": 490return 491# Polygon is already there, but we need to remove the events 492reset_polygon() 493 494 495def cancel_polygon(event): 496if event is not None and hasattr(event, "key") and event.key != "Escape": 497return 498# Delete the polygon 499new_shape.remove() 500reset_polygon() 501 502 503def backspace_polygon(event): 504if event is not None and hasattr(event, "key") and event.key != "Backspace": 505return 506if not polygon_points: 507return 508polygon_points.pop() 509polygon = new_shape.children[0] 510polygon.setAttribute("points", " ".join( 511[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 512polygon_points])) 513 514 515close_polygon_proxy = create_proxy(close_polygon) 516cancel_polygon_proxy = create_proxy(cancel_polygon) 517backspace_polygon_proxy = create_proxy(backspace_polygon) 518 519 520def open_shape(event): 521global new_shape, bbox_pos 522if bbox_pos or shape_type == "select": 523return 524print("Creating a new shape of type:", shape_type) 525 526if shape_type == "shape-bbox": 527helper_message.innerText = ("Define the first point at the intersection of the lines " 528"by clicking on the image, or click the cross to cancel") 529 530cancel_button.addEventListener("click", cancel_bbox_proxy) 531document.addEventListener("keydown", cancel_bbox_proxy) 532cancel_button.style.display = "block" 533bbox_pos = None 534zone.addEventListener("click", make_bbox_proxy) 535vertical_ruler.style.display = "block" 536horizontal_ruler.style.display = "block" 537zone.style.cursor = "crosshair" 538elif shape_type == "shape-polygon" or shape_type == "shape-polyline": 539if shape_type == "shape-polygon": 540helper_message.innerText = ("Click on the image to define the points of the polygon, " 541"press escape to cancel, enter to close, or backspace to " 542"remove the last point") 543elif shape_type == "shape-polyline": 544helper_message.innerText = ("Click on the image to define the points of the polyline, " 545"press escape to cancel, enter to finish, or backspace to " 546"remove the last point") 547 548if not polygon_points and not new_shape: 549new_shape = make_shape_container() 550zone_rect = zone.getBoundingClientRect() 551 552if not polygon_points and int(new_shape.children.length) == 0: 553zone.addEventListener("click", make_polygon_proxy) 554document.addEventListener("keydown", close_polygon_proxy) 555document.addEventListener("keydown", cancel_polygon_proxy) 556document.addEventListener("keydown", backspace_polygon_proxy) 557cancel_button.addEventListener("click", cancel_polygon_proxy) 558cancel_button.style.display = "block" 559confirm_button.addEventListener("click", close_polygon_proxy) 560confirm_button.style.display = "block" 561backspace_button.addEventListener("click", backspace_polygon_proxy) 562backspace_button.style.display = "block" 563if shape_type == "shape-polygon": 564polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon") 565polygon.classList.add("shape-polygon") 566elif shape_type == "shape-polyline": 567polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline") 568polygon.classList.add("shape-polyline") 569polygon.setAttribute("fill", "none") 570polygon.setAttribute("data-object-type", "") 571polygon.classList.add("shape") 572new_shape.appendChild(polygon) 573zone.appendChild(new_shape) 574zone.style.cursor = "crosshair" 575elif shape_type == "shape-point": 576point = document.createElementNS("http://www.w3.org/2000/svg", "circle") 577zone_rect = zone.getBoundingClientRect() 578point.setAttribute("cx", str((event.clientX - zone_rect.left) / zone_rect.width * image.naturalWidth)) 579point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight)) 580point.setAttribute("r", "0") 581point.classList.add("shape-point") 582point.classList.add("shape") 583point.setAttribute("data-object-type", "") 584 585new_shape = make_shape_container() 586zone_rect = zone.getBoundingClientRect() 587 588new_shape.appendChild(point) 589zone.appendChild(new_shape) 590 591new_shape = None 592 593 594 595for button in list(document.getElementById("shape-selector").children): 596button.addEventListener("click", create_proxy(switch_shape)) 597print("Shape", button.id, "is available") 598 599zone.addEventListener("mousemove", follow_cursor_proxy) 600zone.addEventListener("click", create_proxy(open_shape)) 601 602# Load existing annotations, if any 603put_shapes(await load_shapes()) 604print("Ready!") 605