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