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