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) 224case "shape-polyline": 225cancel_polygon(None) 226else: 227# Remove event listeners for selection 228for shape in document.getElementsByClassName("shape"): 229print("Removing event listener from shape:", shape) 230shape.removeEventListener("click", select_shape_proxy) 231image.removeEventListener("click", unselect_shape_proxy) 232helper_message.innerText = "Select a shape type then click on the image to begin defining it" 233print("Shape is now of type:", shape) 234 235 236vertical_ruler = document.getElementById("annotation-ruler-vertical") 237horizontal_ruler = document.getElementById("annotation-ruler-horizontal") 238vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary") 239horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary") 240helper_message = document.getElementById("annotation-helper-message") 241 242helper_message.innerText = "Select a shape type then click on the image to begin defining it" 243 244 245def cancel_bbox(event): 246global bbox_pos, new_shape 247 248# Key must be ESCAPE 249if event is not None and hasattr(event, "key") and event.key != "Escape": 250return 251 252if new_shape is not None and event is not None: 253# Require event so the shape is kept when it ends normally 254new_shape.remove() 255zone.removeEventListener("click", make_bbox_proxy) 256document.removeEventListener("keydown", cancel_bbox_proxy) 257cancel_button.removeEventListener("click", cancel_bbox_proxy) 258 259bbox_pos = None 260vertical_ruler.style.display = "none" 261horizontal_ruler.style.display = "none" 262vertical_ruler_2.style.display = "none" 263horizontal_ruler_2.style.display = "none" 264zone.style.cursor = "auto" 265cancel_button.style.display = "none" 266helper_message.innerText = "Select a shape type then click on the image to begin defining it" 267new_shape = None 268 269 270def make_bbox(event): 271global new_shape, bbox_pos 272zone_rect = zone.getBoundingClientRect() 273 274if bbox_pos is None: 275helper_message.innerText = "Now define the second point" 276 277bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width, 278(event.clientY - zone_rect.top) / zone_rect.height] 279vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%" 280horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%" 281vertical_ruler_2.style.display = "block" 282horizontal_ruler_2.style.display = "block" 283 284else: 285x0, y0 = bbox_pos.copy() 286x1 = (event.clientX - zone_rect.left) / zone_rect.width 287y1 = (event.clientY - zone_rect.top) / zone_rect.height 288 289rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 290 291new_shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 292new_shape.setAttribute("width", "100%") 293new_shape.setAttribute("height", "100%") 294zone_rect = zone.getBoundingClientRect() 295new_shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 296new_shape.classList.add("shape-container") 297 298new_shape.appendChild(rectangle) 299zone.appendChild(new_shape) 300 301minx = min(x0, x1) 302miny = min(y0, y1) 303maxx = max(x0, x1) 304maxy = max(y0, y1) 305 306rectangle.setAttribute("x", str(minx * image.naturalWidth)) 307rectangle.setAttribute("y", str(miny * image.naturalHeight)) 308rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth)) 309rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight)) 310rectangle.setAttribute("fill", "none") 311rectangle.setAttribute("data-object-type", "") 312rectangle.classList.add("shape-bbox") 313rectangle.classList.add("shape") 314 315# Add event listeners to the new shape 316rectangle.addEventListener("click", select_shape_proxy) 317 318cancel_bbox(None) 319 320 321polygon_points = [] 322 323 324def make_polygon(event): 325global new_shape, polygon_points 326 327polygon = new_shape.children[0] 328 329zone_rect = zone.getBoundingClientRect() 330 331polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width, 332(event.clientY - zone_rect.top) / zone_rect.height)) 333 334# Update the polygon 335polygon.setAttribute("points", " ".join( 336[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 337polygon_points])) 338 339 340def reset_polygon(): 341global new_shape, polygon_points 342 343zone.removeEventListener("click", make_polygon_proxy) 344document.removeEventListener("keydown", close_polygon_proxy) 345document.removeEventListener("keydown", cancel_polygon_proxy) 346document.removeEventListener("keydown", backspace_polygon_proxy) 347confirm_button.style.display = "none" 348cancel_button.style.display = "none" 349backspace_button.style.display = "none" 350confirm_button.removeEventListener("click", close_polygon_proxy) 351cancel_button.removeEventListener("click", cancel_polygon_proxy) 352backspace_button.removeEventListener("click", backspace_polygon_proxy) 353polygon_points.clear() 354 355zone.style.cursor = "auto" 356new_shape = None 357 358 359def close_polygon(event): 360if event is not None and hasattr(event, "key") and event.key != "Enter": 361return 362# Polygon is already there, but we need to remove the events 363reset_polygon() 364 365 366def cancel_polygon(event): 367if event is not None and hasattr(event, "key") and event.key != "Escape": 368return 369# Delete the polygon 370new_shape.remove() 371reset_polygon() 372 373 374def backspace_polygon(event): 375if event is not None and hasattr(event, "key") and event.key != "Backspace": 376return 377if not polygon_points: 378return 379polygon_points.pop() 380polygon = new_shape.children[0] 381polygon.setAttribute("points", " ".join( 382[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 383polygon_points])) 384 385 386close_polygon_proxy = create_proxy(close_polygon) 387cancel_polygon_proxy = create_proxy(cancel_polygon) 388backspace_polygon_proxy = create_proxy(backspace_polygon) 389 390 391def open_shape(event): 392global new_shape, bbox_pos 393if bbox_pos or shape_type == "select": 394return 395print("Creating a new shape of type:", shape_type) 396 397match shape_type: 398case "shape-bbox": 399helper_message.innerText = ("Define the first point at the intersection of the lines " 400"by clicking on the image, or click the cross to cancel") 401 402cancel_button.addEventListener("click", cancel_bbox_proxy) 403document.addEventListener("keydown", cancel_bbox_proxy) 404cancel_button.style.display = "block" 405bbox_pos = None 406zone.addEventListener("click", make_bbox_proxy) 407vertical_ruler.style.display = "block" 408horizontal_ruler.style.display = "block" 409zone.style.cursor = "crosshair" 410case "shape-polygon" | "shape-polyline": 411match shape_type: 412case "shape-polygon": 413helper_message.innerText = ("Click on the image to define the points of the polygon, " 414"press escape to cancel, enter to close, or backspace to " 415"remove the last point") 416case "shape-polyline": 417helper_message.innerText = ("Click on the image to define the points of the polyline, " 418"press escape to cancel, enter to finish, or backspace to " 419"remove the last point") 420 421if not polygon_points and not new_shape: 422new_shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 423new_shape.setAttribute("width", "100%") 424new_shape.setAttribute("height", "100%") 425zone_rect = zone.getBoundingClientRect() 426new_shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 427new_shape.classList.add("shape-container") 428 429if not polygon_points and int(len(new_shape.children)) == 0: 430zone.addEventListener("click", make_polygon_proxy) 431document.addEventListener("keydown", close_polygon_proxy) 432document.addEventListener("keydown", cancel_polygon_proxy) 433document.addEventListener("keydown", backspace_polygon_proxy) 434cancel_button.addEventListener("click", cancel_polygon_proxy) 435cancel_button.style.display = "block" 436confirm_button.addEventListener("click", close_polygon_proxy) 437confirm_button.style.display = "block" 438backspace_button.addEventListener("click", backspace_polygon_proxy) 439backspace_button.style.display = "block" 440match shape_type: 441case "shape-polygon": 442polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon") 443polygon.classList.add("shape-polygon") 444case "shape-polyline": 445polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline") 446polygon.classList.add("shape-polyline") 447polygon.setAttribute("fill", "none") 448polygon.setAttribute("data-object-type", "") 449polygon.classList.add("shape") 450new_shape.appendChild(polygon) 451zone.appendChild(new_shape) 452zone.style.cursor = "crosshair" 453case "shape-point": 454point = document.createElementNS("http://www.w3.org/2000/svg", "circle") 455zone_rect = zone.getBoundingClientRect() 456point.setAttribute("cx", str((event.clientX - zone_rect.left) / zone_rect.width * image.naturalWidth)) 457point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight)) 458point.setAttribute("r", "0") 459point.classList.add("shape-point") 460point.classList.add("shape") 461point.setAttribute("data-object-type", "") 462 463new_shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 464new_shape.setAttribute("width", "100%") 465new_shape.setAttribute("height", "100%") 466zone_rect = zone.getBoundingClientRect() 467new_shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 468new_shape.classList.add("shape-container") 469 470new_shape.appendChild(point) 471zone.appendChild(new_shape) 472 473new_shape = None 474 475 476 477for button in list(document.getElementById("shape-selector").children): 478button.addEventListener("click", create_proxy(switch_shape)) 479print("Shape", button.id, "is available") 480 481zone.addEventListener("mousemove", follow_cursor_proxy) 482zone.addEventListener("click", create_proxy(open_shape)) 483