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