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