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 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") 16 17object_list = document.getElementById("object-types") 18 19confirm_button.style.display = "none" 20cancel_button.style.display = "none" 21backspace_button.style.display = "none" 22delete_button.style.display = "none" 23previous_button.style.display = "none" 24next_button.style.display = "none" 25shape_type = "" 26bbox_pos = None 27new_shape = None 28selected_shape = None 29 30 31async def get_all_objects(): 32response = await pyfetch("/api/object-types") 33if response.ok: 34return await response.json() 35 36 37def follow_cursor(event): 38rect = zone.getBoundingClientRect() 39x = event.clientX - rect.left 40y = event.clientY - rect.top 41vertical_ruler.style.left = str(x) + "px" 42horizontal_ruler.style.top = str(y) + "px" 43 44 45def change_object_type(event): 46global selected_shape 47if selected_shape is None: 48return 49selected_shape.setAttribute("data-object-type", event.currentTarget.value) 50 51 52change_object_type_proxy = create_proxy(change_object_type) 53 54 55async def focus_shape(shape): 56global selected_shape 57 58if shape_type != "select": 59return 60if selected_shape is not None: 61selected_shape.classList.remove("selected") 62 63selected_shape = shape 64 65selected_shape.classList.add("selected") 66 67objects = await get_all_objects() 68 69delete_button.style.display = "block" 70next_button.style.display = "block" 71previous_button.style.display = "block" 72 73object_list.innerHTML = "" 74 75new_radio = document.createElement("input") 76new_radio.setAttribute("type", "radio") 77new_radio.setAttribute("name", "object-type") 78new_radio.setAttribute("value", "") 79new_label = document.createElement("label") 80new_label.appendChild(new_radio) 81new_label.append("Undefined") 82object_list.appendChild(new_label) 83new_radio.addEventListener("change", change_object_type_proxy) 84 85selected_object = selected_shape.getAttribute("data-object-type") 86if not selected_object: 87new_radio.setAttribute("checked", "") 88 89for object, description in objects.items(): 90new_radio = document.createElement("input") 91new_radio.setAttribute("type", "radio") 92new_radio.setAttribute("name", "object-type") 93new_radio.setAttribute("value", object) 94if selected_object == object: 95new_radio.setAttribute("checked", "") 96new_label = document.createElement("label") 97new_label.appendChild(new_radio) 98new_label.append(object) 99object_list.appendChild(new_label) 100new_radio.addEventListener("change", change_object_type_proxy) 101 102 103async def select_shape(event): 104await focus_shape(event.target) 105 106 107async def next_shape(event): 108global selected_shape 109if selected_shape is None: 110return 111 112selected_svg = selected_shape.parentNode 113 114while selected_svg is not None: 115next_sibling = selected_svg.nextElementSibling 116if next_sibling and next_sibling.classList.contains("shape-container"): 117selected_svg = next_sibling 118break 119elif next_sibling is None: 120# If no more siblings, loop back to the first child 121selected_svg = selected_svg.parentNode.firstElementChild 122while selected_svg is not None and not selected_svg.classList.contains( 123"shape-container"): 124selected_svg = selected_svg.nextElementSibling 125break 126else: 127selected_svg = next_sibling 128 129if selected_svg: 130shape = selected_svg.firstElementChild 131await focus_shape(shape) 132 133 134async def previous_shape(event): 135global selected_shape 136if selected_shape is None: 137return 138 139selected_svg = selected_shape.parentNode 140 141while selected_svg is not None: 142next_sibling = selected_svg.previousElementSibling 143if next_sibling and next_sibling.classList.contains("shape-container"): 144selected_svg = next_sibling 145break 146elif next_sibling is None: 147# If no more siblings, loop back to the last child 148selected_svg = selected_svg.parentNode.lastElementChild 149while selected_svg is not None and not selected_svg.classList.contains( 150"shape-container"): 151selected_svg = selected_svg.previousElementSibling 152break 153else: 154selected_svg = next_sibling 155 156if selected_svg: 157shape = selected_svg.firstElementChild 158await focus_shape(shape) 159 160 161def unselect_shape(event): 162global selected_shape 163 164if selected_shape is not None: 165selected_shape.classList.remove("selected") 166selected_shape = None 167 168object_list.innerHTML = "" 169delete_button.style.display = "none" 170next_button.style.display = "none" 171previous_button.style.display = "none" 172 173 174def delete_shape(event): 175global selected_shape 176if selected_shape is None: 177return 178# Shape is SVG shape inside SVG so we need to remove the parent SVG 179selected_shape.parentNode.remove() 180selected_shape = None 181object_list.innerHTML = "" 182delete_button.style.display = "none" 183next_button.style.display = "none" 184previous_button.style.display = "none" 185 186 187select_shape_proxy = create_proxy(select_shape) 188unselect_shape_proxy = create_proxy(unselect_shape) 189delete_shape_proxy = create_proxy(delete_shape) 190next_shape_proxy = create_proxy(next_shape) 191previous_shape_proxy = create_proxy(previous_shape) 192 193delete_button.addEventListener("click", delete_shape_proxy) 194next_button.addEventListener("click", next_shape_proxy) 195previous_button.addEventListener("click", previous_shape_proxy) 196 197# These are functions usable in JS 198cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event)) 199make_bbox_proxy = create_proxy(lambda event: make_bbox(event)) 200make_polygon_proxy = create_proxy(lambda event: make_polygon(event)) 201follow_cursor_proxy = create_proxy(follow_cursor) 202 203 204def switch_shape(event): 205global shape_type 206object_list.innerHTML = "" 207unselect_shape(None) 208shape = event.currentTarget.id 209shape_type = shape 210if shape_type == "select": 211# Add event listeners to existing shapes 212print(len(list(document.getElementsByClassName("shape"))), "shapes found") 213for shape in document.getElementsByClassName("shape"): 214print("Adding event listener to shape:", shape) 215shape.addEventListener("click", select_shape_proxy) 216image.addEventListener("click", unselect_shape_proxy) 217helper_message.innerText = "Click on a shape to select" 218# Cancel the current shape creation 219match shape_type: 220case "shape-bbox": 221cancel_bbox(None) 222case "shape-polygon": 223cancel_polygon(None) 224else: 225# Remove event listeners for selection 226for shape in document.getElementsByClassName("shape"): 227print("Removing event listener from shape:", shape) 228shape.removeEventListener("click", select_shape_proxy) 229image.removeEventListener("click", unselect_shape_proxy) 230helper_message.innerText = "Select a shape type then click on the image to begin defining it" 231print("Shape is now of type:", shape) 232 233 234vertical_ruler = document.getElementById("annotation-ruler-vertical") 235horizontal_ruler = document.getElementById("annotation-ruler-horizontal") 236vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary") 237horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary") 238helper_message = document.getElementById("annotation-helper-message") 239 240helper_message.innerText = "Select a shape type then click on the image to begin defining it" 241 242 243def cancel_bbox(event): 244global bbox_pos, new_shape 245 246# Key must be ESCAPE 247if event is not None and hasattr(event, "key") and event.key != "Escape": 248return 249 250if new_shape is not None and event is not None: 251# Require event so the shape is kept when it ends normally 252new_shape.remove() 253zone.removeEventListener("click", make_bbox_proxy) 254document.removeEventListener("keydown", cancel_bbox_proxy) 255cancel_button.removeEventListener("click", cancel_bbox_proxy) 256 257bbox_pos = None 258vertical_ruler.style.display = "none" 259horizontal_ruler.style.display = "none" 260vertical_ruler_2.style.display = "none" 261horizontal_ruler_2.style.display = "none" 262zone.style.cursor = "auto" 263cancel_button.style.display = "none" 264helper_message.innerText = "Select a shape type then click on the image to begin defining it" 265new_shape = None 266 267 268def make_bbox(event): 269global new_shape, bbox_pos 270zone_rect = zone.getBoundingClientRect() 271 272if bbox_pos is None: 273helper_message.innerText = "Now define the second point" 274 275bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width, 276(event.clientY - zone_rect.top) / zone_rect.height] 277vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%" 278horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%" 279vertical_ruler_2.style.display = "block" 280horizontal_ruler_2.style.display = "block" 281 282else: 283x0, y0 = bbox_pos.copy() 284x1 = (event.clientX - zone_rect.left) / zone_rect.width 285y1 = (event.clientY - zone_rect.top) / zone_rect.height 286 287rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 288 289new_shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 290new_shape.setAttribute("width", "100%") 291new_shape.setAttribute("height", "100%") 292zone_rect = zone.getBoundingClientRect() 293new_shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 294new_shape.classList.add("shape-container") 295 296new_shape.appendChild(rectangle) 297zone.appendChild(new_shape) 298 299minx = min(x0, x1) 300miny = min(y0, y1) 301maxx = max(x0, x1) 302maxy = max(y0, y1) 303 304rectangle.setAttribute("x", str(minx * image.naturalWidth)) 305rectangle.setAttribute("y", str(miny * image.naturalHeight)) 306rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth)) 307rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight)) 308rectangle.setAttribute("fill", "none") 309rectangle.setAttribute("data-object-type", "") 310rectangle.classList.add("shape-bbox") 311rectangle.classList.add("shape") 312 313# Add event listeners to the new shape 314rectangle.addEventListener("click", select_shape_proxy) 315 316cancel_bbox(None) 317 318 319polygon_points = [] 320 321 322def make_polygon(event): 323global new_shape, polygon_points 324 325polygon = new_shape.children[0] 326 327zone_rect = zone.getBoundingClientRect() 328 329polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width, 330(event.clientY - zone_rect.top) / zone_rect.height)) 331 332# Update the polygon 333polygon.setAttribute("points", " ".join( 334[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 335polygon_points])) 336 337 338def reset_polygon(): 339global new_shape, polygon_points 340 341zone.removeEventListener("click", make_polygon_proxy) 342document.removeEventListener("keydown", close_polygon_proxy) 343document.removeEventListener("keydown", cancel_polygon_proxy) 344document.removeEventListener("keydown", backspace_polygon_proxy) 345confirm_button.style.display = "none" 346cancel_button.style.display = "none" 347backspace_button.style.display = "none" 348confirm_button.removeEventListener("click", close_polygon_proxy) 349cancel_button.removeEventListener("click", cancel_polygon_proxy) 350backspace_button.removeEventListener("click", backspace_polygon_proxy) 351polygon_points.clear() 352 353zone.style.cursor = "auto" 354new_shape = None 355 356 357def close_polygon(event): 358if event is not None and hasattr(event, "key") and event.key != "Enter": 359return 360# Polygon is already there, but we need to remove the events 361reset_polygon() 362 363 364def cancel_polygon(event): 365if event is not None and hasattr(event, "key") and event.key != "Escape": 366return 367# Delete the polygon 368new_shape.remove() 369reset_polygon() 370 371 372def backspace_polygon(event): 373if event is not None and hasattr(event, "key") and event.key != "Backspace": 374return 375if not polygon_points: 376return 377polygon_points.pop() 378polygon = new_shape.children[0] 379polygon.setAttribute("points", " ".join( 380[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 381polygon_points])) 382 383 384close_polygon_proxy = create_proxy(close_polygon) 385cancel_polygon_proxy = create_proxy(cancel_polygon) 386backspace_polygon_proxy = create_proxy(backspace_polygon) 387 388 389def open_shape(event): 390global new_shape, bbox_pos 391if bbox_pos or shape_type == "select": 392return 393print("Creating a new shape of type:", shape_type) 394 395if shape_type == "shape-bbox": 396helper_message.innerText = ("Define the first point at the intersection of the lines " 397"by clicking on the image, or click the cross to cancel") 398 399cancel_button.addEventListener("click", cancel_bbox_proxy) 400document.addEventListener("keydown", cancel_bbox_proxy) 401cancel_button.style.display = "block" 402bbox_pos = None 403zone.addEventListener("click", make_bbox_proxy) 404vertical_ruler.style.display = "block" 405horizontal_ruler.style.display = "block" 406zone.style.cursor = "crosshair" 407elif shape_type == "shape-polygon" or shape_type == "shape-polyline": 408match shape_type: 409case "shape-polygon": 410helper_message.innerText = ("Click on the image to define the points of the polygon, " 411"press escape to cancel, enter to close, or backspace to " 412"remove the last point") 413case "shape-polyline": 414helper_message.innerText = ("Click on the image to define the points of the polyline, " 415"press escape to cancel, enter to finish, or backspace to " 416"remove the last point") 417 418if not polygon_points and not new_shape: 419new_shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 420new_shape.setAttribute("width", "100%") 421new_shape.setAttribute("height", "100%") 422zone_rect = zone.getBoundingClientRect() 423new_shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 424new_shape.classList.add("shape-container") 425 426if not polygon_points and int(len(new_shape.children)) == 0: 427zone.addEventListener("click", make_polygon_proxy) 428document.addEventListener("keydown", close_polygon_proxy) 429document.addEventListener("keydown", cancel_polygon_proxy) 430document.addEventListener("keydown", backspace_polygon_proxy) 431cancel_button.addEventListener("click", cancel_polygon_proxy) 432cancel_button.style.display = "block" 433confirm_button.addEventListener("click", close_polygon_proxy) 434confirm_button.style.display = "block" 435backspace_button.addEventListener("click", backspace_polygon_proxy) 436backspace_button.style.display = "block" 437match shape_type: 438case "shape-polygon": 439polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon") 440polygon.classList.add("shape-polygon") 441case "shape-polyline": 442polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline") 443polygon.classList.add("shape-polyline") 444polygon.setAttribute("fill", "none") 445polygon.setAttribute("data-object-type", "") 446polygon.classList.add("shape") 447new_shape.appendChild(polygon) 448zone.appendChild(new_shape) 449zone.style.cursor = "crosshair" 450 451 452for button in list(document.getElementById("shape-selector").children): 453button.addEventListener("click", create_proxy(switch_shape)) 454print("Shape", button.id, "is available") 455 456zone.addEventListener("mousemove", follow_cursor_proxy) 457zone.addEventListener("click", create_proxy(open_shape)) 458