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