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