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