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