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") 13 14object_list = document.getElementById("object-types") 15 16confirm_button.style.display = "none" 17cancel_button.style.display = "none" 18backspace_button.style.display = "none" 19shape_type = "" 20bbox_pos = None 21new_shape = None 22selected_shape = None 23 24 25async def get_all_objects(): 26response = await pyfetch("/api/object-types") 27if response.ok: 28return await response.json() 29 30 31def follow_cursor(event): 32rect = zone.getBoundingClientRect() 33x = event.clientX - rect.left 34y = event.clientY - rect.top 35vertical_ruler.style.left = str(x) + "px" 36horizontal_ruler.style.top = str(y) + "px" 37 38 39def change_object_type(event): 40global selected_shape 41if selected_shape is None: 42return 43selected_shape.setAttribute("data-object-type", event.currentTarget.value) 44 45 46change_object_type_proxy = create_proxy(change_object_type) 47 48 49async def select_shape(event): 50global selected_shape 51if shape_type != "select": 52return 53if selected_shape is not None: 54selected_shape.classList.remove("selected") 55 56print("Selected shape:", event.target) 57 58selected_shape = event.target 59selected_shape.classList.add("selected") 60 61objects = await get_all_objects() 62 63object_list.innerHTML = "" 64 65new_radio = document.createElement("input") 66new_radio.setAttribute("type", "radio") 67new_radio.setAttribute("name", "object-type") 68new_radio.setAttribute("value", "") 69new_label = document.createElement("label") 70new_label.appendChild(new_radio) 71new_label.append("Undefined") 72object_list.appendChild(new_label) 73new_radio.addEventListener("change", change_object_type_proxy) 74 75selected_object = selected_shape.getAttribute("data-object-type") 76if not selected_object: 77new_radio.setAttribute("checked", "") 78 79for object, description in objects.items(): 80new_radio = document.createElement("input") 81new_radio.setAttribute("type", "radio") 82new_radio.setAttribute("name", "object-type") 83new_radio.setAttribute("value", object) 84if selected_object == object: 85new_radio.setAttribute("checked", "") 86new_label = document.createElement("label") 87new_label.appendChild(new_radio) 88new_label.append(object) 89object_list.appendChild(new_label) 90new_radio.addEventListener("change", change_object_type_proxy) 91 92print(objects) 93 94 95def unselect_shape(event): 96global selected_shape 97 98if selected_shape is not None: 99selected_shape.classList.remove("selected") 100selected_shape = None 101 102object_list.innerHTML = "" 103 104 105select_shape_proxy = create_proxy(select_shape) 106unselect_shape_proxy = create_proxy(unselect_shape) 107 108 109# These are functions usable in JS 110cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event)) 111make_bbox_proxy = create_proxy(lambda event: make_bbox(event)) 112make_polygon_proxy = create_proxy(lambda event: make_polygon(event)) 113follow_cursor_proxy = create_proxy(follow_cursor) 114 115 116def switch_shape(event): 117global shape_type 118object_list.innerHTML = "" 119unselect_shape(None) 120shape = event.currentTarget.id 121shape_type = shape 122if shape_type == "select": 123# Add event listeners to existing shapes 124print(len(list(document.getElementsByClassName("shape"))), "shapes found") 125for shape in document.getElementsByClassName("shape"): 126print("Adding event listener to shape:", shape) 127shape.addEventListener("click", select_shape_proxy) 128image.addEventListener("click", unselect_shape_proxy) 129helper_message.innerText = "Click on a shape to select" 130else: 131# Remove event listeners for selection 132for shape in document.getElementsByClassName("shape"): 133print("Removing event listener from shape:", shape) 134shape.removeEventListener("click", select_shape_proxy) 135image.removeEventListener("click", unselect_shape_proxy) 136helper_message.innerText = "Select a shape type then click on the image to begin defining it" 137print("Shape is now of type:", shape) 138 139vertical_ruler = document.getElementById("annotation-ruler-vertical") 140horizontal_ruler = document.getElementById("annotation-ruler-horizontal") 141vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary") 142horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary") 143helper_message = document.getElementById("annotation-helper-message") 144 145helper_message.innerText = "Select a shape type then click on the image to begin defining it" 146 147 148def cancel_bbox(event): 149global bbox_pos, new_shape 150 151# Key must be ESCAPE 152if event is not None and hasattr(event, "key") and event.key != "Escape": 153return 154 155if new_shape is not None and event is not None: 156# Require event so the shape is kept when it ends normally 157new_shape.remove() 158new_shape = None 159zone.removeEventListener("click", make_bbox_proxy) 160document.removeEventListener("keydown", cancel_bbox_proxy) 161cancel_button.removeEventListener("click", cancel_bbox_proxy) 162 163bbox_pos = None 164vertical_ruler.style.display = "none" 165horizontal_ruler.style.display = "none" 166vertical_ruler_2.style.display = "none" 167horizontal_ruler_2.style.display = "none" 168document.body.style.cursor = "auto" 169cancel_button.style.display = "none" 170helper_message.innerText = "Select a shape type then click on the image to begin defining it" 171 172def make_bbox(event): 173global new_shape, bbox_pos 174zone_rect = zone.getBoundingClientRect() 175 176if bbox_pos is None: 177helper_message.innerText = "Now define the second point" 178 179bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width, 180(event.clientY - zone_rect.top) / zone_rect.height] 181vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%" 182horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%" 183vertical_ruler_2.style.display = "block" 184horizontal_ruler_2.style.display = "block" 185 186else: 187x0, y0 = bbox_pos.copy() 188x1 = (event.clientX - zone_rect.left) / zone_rect.width 189y1 = (event.clientY - zone_rect.top) / zone_rect.height 190 191rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 192 193new_shape.appendChild(rectangle) 194zone.appendChild(new_shape) 195 196minx = min(x0, x1) 197miny = min(y0, y1) 198maxx = max(x0, x1) 199maxy = max(y0, y1) 200 201rectangle.setAttribute("x", str(minx * image.naturalWidth)) 202rectangle.setAttribute("y", str(miny * image.naturalHeight)) 203rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth)) 204rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight)) 205rectangle.setAttribute("fill", "none") 206rectangle.setAttribute("data-object-type", "") 207rectangle.classList.add("shape-bbox") 208rectangle.classList.add("shape") 209 210# Add event listeners to the new shape 211rectangle.addEventListener("click", select_shape_proxy) 212 213cancel_bbox(None) 214 215 216polygon_points = [] 217 218def make_polygon(event): 219global new_shape, polygon_points 220 221print(new_shape.outerHTML) 222polygon = new_shape.children[0] 223 224zone_rect = zone.getBoundingClientRect() 225 226polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width, 227(event.clientY - zone_rect.top) / zone_rect.height)) 228 229# Update the polygon 230polygon.setAttribute("points", " ".join([f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in polygon_points])) 231 232 233def close_polygon(event): 234if event is not None and hasattr(event, "key") and event.key != "Enter": 235return 236# Polygon is already there, but we need to remove the events 237zone.removeEventListener("click", make_polygon_proxy) 238document.removeEventListener("keydown", close_polygon_proxy) 239document.removeEventListener("keydown", cancel_polygon_proxy) 240confirm_button.style.display = "none" 241cancel_button.style.display = "none" 242confirm_button.removeEventListener("click", close_polygon_proxy) 243cancel_button.removeEventListener("click", cancel_polygon_proxy) 244polygon_points.clear() 245 246document.body.style.cursor = "auto" 247 248 249def cancel_polygon(event): 250if event is not None and hasattr(event, "key") and event.key != "Escape": 251return 252# Delete the polygon 253new_shape.remove() 254zone.removeEventListener("click", make_polygon_proxy) 255document.removeEventListener("keydown", close_polygon_proxy) 256document.removeEventListener("keydown", cancel_polygon_proxy) 257confirm_button.style.display = "none" 258cancel_button.style.display = "none" 259confirm_button.removeEventListener("click", close_polygon_proxy) 260cancel_button.removeEventListener("click", cancel_polygon_proxy) 261 262polygon_points.clear() 263 264document.body.style.cursor = "auto" 265 266 267def backspace_polygon(event): 268if event is not None and hasattr(event, "key") and event.key != "Backspace": 269return 270if not polygon_points: 271return 272polygon_points.pop() 273polygon = new_shape.children[0] 274polygon.setAttribute("points", " ".join([f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in polygon_points])) 275 276 277close_polygon_proxy = create_proxy(close_polygon) 278cancel_polygon_proxy = create_proxy(cancel_polygon) 279backspace_polygon_proxy = create_proxy(backspace_polygon) 280 281 282def open_shape(event): 283global new_shape, bbox_pos 284if bbox_pos or shape_type == "select": 285return 286print("Creating a new shape of type:", shape_type) 287if not polygon_points: 288new_shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 289new_shape.setAttribute("width", "100%") 290new_shape.setAttribute("height", "100%") 291zone_rect = zone.getBoundingClientRect() 292new_shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 293new_shape.classList.add("shape-container") 294 295if shape_type == "shape-bbox": 296helper_message.innerText = ("Define the first point at the intersection of the lines " 297"by clicking on the image, or click the cross to cancel") 298 299cancel_button.addEventListener("click", cancel_bbox_proxy) 300document.addEventListener("keydown", cancel_bbox_proxy) 301cancel_button.style.display = "block" 302bbox_pos = None 303zone.addEventListener("click", make_bbox_proxy) 304vertical_ruler.style.display = "block" 305horizontal_ruler.style.display = "block" 306document.body.style.cursor = "crosshair" 307elif shape_type == "shape-polygon": 308helper_message.innerText = ("Click on the image to define the points of the polygon, " 309"press escape to cancel, enter to close, or backspace to " 310"remove the last point") 311 312if not polygon_points: 313zone.addEventListener("click", make_polygon_proxy) 314document.addEventListener("keydown", close_polygon_proxy) 315document.addEventListener("keydown", cancel_polygon_proxy) 316document.addEventListener("keydown", backspace_polygon_proxy) 317cancel_button.addEventListener("click", cancel_polygon_proxy) 318cancel_button.style.display = "block" 319confirm_button.addEventListener("click", close_polygon_proxy) 320confirm_button.style.display = "block" 321backspace_button.addEventListener("click", backspace_polygon_proxy) 322backspace_button.style.display = "block" 323polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon") 324polygon.setAttribute("fill", "none") 325polygon.setAttribute("data-object-type", "") 326polygon.classList.add("shape-polygon") 327polygon.classList.add("shape") 328new_shape.appendChild(polygon) 329zone.appendChild(new_shape) 330document.body.style.cursor = "crosshair" 331 332 333for button in list(document.getElementById("shape-selector").children): 334button.addEventListener("click", create_proxy(switch_shape)) 335print("Shape", button.id, "is available") 336 337 338zone.addEventListener("mousemove", follow_cursor_proxy) 339zone.addEventListener("click", create_proxy(open_shape)) 340