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" 168zone.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 reset_polygon(): 234zone.removeEventListener("click", make_polygon_proxy) 235document.removeEventListener("keydown", close_polygon_proxy) 236document.removeEventListener("keydown", cancel_polygon_proxy) 237document.removeEventListener("keydown", backspace_polygon_proxy) 238confirm_button.style.display = "none" 239cancel_button.style.display = "none" 240backspace_button.style.display = "none" 241confirm_button.removeEventListener("click", close_polygon_proxy) 242cancel_button.removeEventListener("click", cancel_polygon_proxy) 243backspace_button.removeEventListener("click", backspace_polygon_proxy) 244polygon_points.clear() 245 246zone.style.cursor = "auto" 247 248 249def close_polygon(event): 250if event is not None and hasattr(event, "key") and event.key != "Enter": 251return 252# Polygon is already there, but we need to remove the events 253reset_polygon() 254 255 256def cancel_polygon(event): 257if event is not None and hasattr(event, "key") and event.key != "Escape": 258return 259# Delete the polygon 260new_shape.remove() 261reset_polygon() 262 263 264def backspace_polygon(event): 265if event is not None and hasattr(event, "key") and event.key != "Backspace": 266return 267if not polygon_points: 268return 269polygon_points.pop() 270polygon = new_shape.children[0] 271polygon.setAttribute("points", " ".join([f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in polygon_points])) 272 273 274close_polygon_proxy = create_proxy(close_polygon) 275cancel_polygon_proxy = create_proxy(cancel_polygon) 276backspace_polygon_proxy = create_proxy(backspace_polygon) 277 278 279def open_shape(event): 280global new_shape, bbox_pos 281if bbox_pos or shape_type == "select": 282return 283print("Creating a new shape of type:", shape_type) 284if not polygon_points: 285new_shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 286new_shape.setAttribute("width", "100%") 287new_shape.setAttribute("height", "100%") 288zone_rect = zone.getBoundingClientRect() 289new_shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 290new_shape.classList.add("shape-container") 291 292if shape_type == "shape-bbox": 293helper_message.innerText = ("Define the first point at the intersection of the lines " 294"by clicking on the image, or click the cross to cancel") 295 296cancel_button.addEventListener("click", cancel_bbox_proxy) 297document.addEventListener("keydown", cancel_bbox_proxy) 298cancel_button.style.display = "block" 299bbox_pos = None 300zone.addEventListener("click", make_bbox_proxy) 301vertical_ruler.style.display = "block" 302horizontal_ruler.style.display = "block" 303zone.style.cursor = "crosshair" 304elif shape_type == "shape-polygon": 305helper_message.innerText = ("Click on the image to define the points of the polygon, " 306"press escape to cancel, enter to close, or backspace to " 307"remove the last point") 308 309if not polygon_points: 310zone.addEventListener("click", make_polygon_proxy) 311document.addEventListener("keydown", close_polygon_proxy) 312document.addEventListener("keydown", cancel_polygon_proxy) 313document.addEventListener("keydown", backspace_polygon_proxy) 314cancel_button.addEventListener("click", cancel_polygon_proxy) 315cancel_button.style.display = "block" 316confirm_button.addEventListener("click", close_polygon_proxy) 317confirm_button.style.display = "block" 318backspace_button.addEventListener("click", backspace_polygon_proxy) 319backspace_button.style.display = "block" 320polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon") 321polygon.setAttribute("fill", "none") 322polygon.setAttribute("data-object-type", "") 323polygon.classList.add("shape-polygon") 324polygon.classList.add("shape") 325new_shape.appendChild(polygon) 326zone.appendChild(new_shape) 327zone.style.cursor = "crosshair" 328 329 330for button in list(document.getElementById("shape-selector").children): 331button.addEventListener("click", create_proxy(switch_shape)) 332print("Shape", button.id, "is available") 333 334 335zone.addEventListener("mousemove", follow_cursor_proxy) 336zone.addEventListener("click", create_proxy(open_shape)) 337