picture-annotation.py
Python script, ASCII text executable
1
from pyscript import document, fetch as pyfetch
2
from pyscript.ffi import create_proxy
3
import asyncio
4
import json
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
backspace_button = document.getElementById("annotation-backspace")
13
delete_button = document.getElementById("annotation-delete")
14
previous_button = document.getElementById("annotation-previous")
15
next_button = document.getElementById("annotation-next")
16
save_button = document.getElementById("annotation-save")
17
18
object_list = document.getElementById("object-types")
19
20
confirm_button.style.display = "none"
21
cancel_button.style.display = "none"
22
backspace_button.style.display = "none"
23
delete_button.style.display = "none"
24
previous_button.style.display = "none"
25
next_button.style.display = "none"
26
shape_type = ""
27
bbox_pos = None
28
new_shape = None
29
selected_shape = None
30
31
32
def make_shape_container():
33
shape = document.createElementNS("http://www.w3.org/2000/svg", "svg")
34
shape.setAttribute("width", "100%")
35
shape.setAttribute("height", "100%")
36
shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}")
37
shape.classList.add("shape-container")
38
39
return shape
40
41
42
async def get_all_objects():
43
response = await pyfetch("/api/object-types")
44
if response.ok:
45
return await response.json()
46
47
48
def follow_cursor(event):
49
rect = zone.getBoundingClientRect()
50
x = event.clientX - rect.left
51
y = event.clientY - rect.top
52
vertical_ruler.style.left = str(x) + "px"
53
horizontal_ruler.style.top = str(y) + "px"
54
55
56
def change_object_type(event):
57
global selected_shape
58
if selected_shape is None:
59
return
60
selected_shape.setAttribute("data-object-type", event.currentTarget.value)
61
62
63
change_object_type_proxy = create_proxy(change_object_type)
64
65
66
def list_shapes():
67
shapes = list(zone.getElementsByClassName("shape"))
68
json_shapes = []
69
for shape in shapes:
70
shape_dict = {}
71
if shape.tagName == "rect":
72
shape_dict["type"] = "bbox"
73
shape_dict["shape"] = {
74
"x": float(shape.getAttribute("x")) / image.naturalWidth,
75
"y": float(shape.getAttribute("y")) / image.naturalHeight,
76
"w": float(shape.getAttribute("width")) / image.naturalWidth,
77
"h": float(shape.getAttribute("height")) / image.naturalHeight
78
}
79
elif shape.tagName == "polygon" or shape.tagName == "polyline":
80
if shape.tagName == "polygon":
81
shape_dict["type"] = "polygon"
82
elif shape.tagName == "polyline":
83
shape_dict["type"] = "polyline"
84
85
points = shape.getAttribute("points").split(" ")
86
json_points = []
87
for point in points:
88
x, y = point.split(",")
89
x, y = float(x), float(y)
90
json_points.append({
91
"x": x / image.naturalWidth,
92
"y": y / image.naturalHeight
93
})
94
95
shape_dict["shape"] = json_points
96
elif shape.tagName == "circle" and shape.classList.contains("shape-point"):
97
shape_dict["type"] = "point"
98
shape_dict["shape"] = {
99
"x": float(shape.getAttribute("cx")) / image.naturalWidth,
100
"y": float(shape.getAttribute("cy")) / image.naturalHeight
101
}
102
else:
103
continue
104
105
shape_dict["object"] = shape.getAttribute("data-object-type")
106
json_shapes.append(shape_dict)
107
108
return json_shapes
109
110
111
def put_shapes(json_shapes):
112
for shape in json_shapes:
113
new_shape = make_shape_container()
114
zone_rect = zone.getBoundingClientRect()
115
116
if shape["type"] == "bbox":
117
rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect")
118
rectangle.setAttribute("x", str(shape["shape"]["x"] * image.naturalWidth))
119
rectangle.setAttribute("y", str(shape["shape"]["y"] * image.naturalHeight))
120
rectangle.setAttribute("width", str(shape["shape"]["w"] * image.naturalWidth))
121
rectangle.setAttribute("height", str(shape["shape"]["h"] * image.naturalHeight))
122
rectangle.setAttribute("fill", "none")
123
rectangle.setAttribute("data-object-type", shape["object"] or "")
124
rectangle.classList.add("shape-bbox")
125
rectangle.classList.add("shape")
126
new_shape.appendChild(rectangle)
127
elif shape["type"] == "polygon" or shape["type"] == "polyline":
128
polygon = document.createElementNS("http://www.w3.org/2000/svg", shape["type"])
129
points = " ".join(
130
[f"{point['x'] * image.naturalWidth},{point['y'] * image.naturalHeight}" for point in shape["shape"]])
131
polygon.setAttribute("points", points)
132
polygon.setAttribute("fill", "none")
133
polygon.setAttribute("data-object-type", shape["object"] or "")
134
polygon.classList.add(f"shape-{shape['type']}")
135
polygon.classList.add("shape")
136
new_shape.appendChild(polygon)
137
elif shape["type"] == "point":
138
point = document.createElementNS("http://www.w3.org/2000/svg", "circle")
139
point.setAttribute("cx", str(shape["shape"]["x"] * image.naturalWidth))
140
point.setAttribute("cy", str(shape["shape"]["y"] * image.naturalHeight))
141
point.setAttribute("r", "0")
142
point.classList.add("shape-point")
143
point.classList.add("shape")
144
point.setAttribute("data-object-type", shape["object"] or "")
145
new_shape.appendChild(point)
146
147
zone.appendChild(new_shape)
148
149
150
async def load_shapes():
151
resource_id = document.getElementById("resource-id").value
152
response = await pyfetch(f"/picture/{resource_id}/get-annotations")
153
if response.ok:
154
shapes = await response.json()
155
return shapes
156
157
158
async def save_shapes(event):
159
shapes = list_shapes()
160
resource_id = document.getElementById("resource-id").value
161
print("Saving shapes:", shapes)
162
response = await pyfetch(f"/picture/{resource_id}/save-annotations",
163
method="POST",
164
headers={
165
"Content-Type": "application/json"
166
},
167
body=json.dumps(shapes)
168
)
169
if response.ok:
170
return await response
171
172
173
save_shapes_proxy = create_proxy(save_shapes)
174
save_button.addEventListener("click", save_shapes_proxy)
175
176
177
async def focus_shape(shape):
178
global selected_shape
179
180
if shape_type != "select":
181
return
182
if selected_shape is not None:
183
selected_shape.classList.remove("selected")
184
185
selected_shape = shape
186
187
selected_shape.classList.add("selected")
188
189
objects = await get_all_objects()
190
191
delete_button.style.display = "block"
192
next_button.style.display = "block"
193
previous_button.style.display = "block"
194
195
object_list.innerHTML = ""
196
197
new_radio = document.createElement("input")
198
new_radio.setAttribute("type", "radio")
199
new_radio.setAttribute("name", "object-type")
200
new_radio.setAttribute("value", "")
201
new_label = document.createElement("label")
202
new_label.appendChild(new_radio)
203
new_label.append("Undefined")
204
object_list.appendChild(new_label)
205
new_radio.addEventListener("change", change_object_type_proxy)
206
207
selected_object = selected_shape.getAttribute("data-object-type")
208
if not selected_object:
209
new_radio.setAttribute("checked", "")
210
211
for object, description in objects.items():
212
new_radio = document.createElement("input")
213
new_radio.setAttribute("type", "radio")
214
new_radio.setAttribute("name", "object-type")
215
new_radio.setAttribute("value", object)
216
if selected_object == object:
217
new_radio.setAttribute("checked", "")
218
new_label = document.createElement("label")
219
new_label.appendChild(new_radio)
220
new_label.append(object)
221
object_list.appendChild(new_label)
222
new_radio.addEventListener("change", change_object_type_proxy)
223
224
225
async def select_shape(event):
226
await focus_shape(event.target)
227
228
229
async def next_shape(event):
230
global selected_shape
231
if selected_shape is None:
232
return
233
234
selected_svg = selected_shape.parentNode
235
236
while selected_svg is not None:
237
next_sibling = selected_svg.nextElementSibling
238
if next_sibling and next_sibling.classList.contains("shape-container"):
239
selected_svg = next_sibling
240
break
241
elif next_sibling is None:
242
# If no more siblings, loop back to the first child
243
selected_svg = selected_svg.parentNode.firstElementChild
244
while selected_svg is not None and not selected_svg.classList.contains(
245
"shape-container"):
246
selected_svg = selected_svg.nextElementSibling
247
break
248
else:
249
selected_svg = next_sibling
250
251
if selected_svg:
252
shape = selected_svg.firstElementChild
253
await focus_shape(shape)
254
255
256
async def previous_shape(event):
257
global selected_shape
258
if selected_shape is None:
259
return
260
261
selected_svg = selected_shape.parentNode
262
263
while selected_svg is not None:
264
next_sibling = selected_svg.previousElementSibling
265
if next_sibling and next_sibling.classList.contains("shape-container"):
266
selected_svg = next_sibling
267
break
268
elif next_sibling is None:
269
# If no more siblings, loop back to the last child
270
selected_svg = selected_svg.parentNode.lastElementChild
271
while selected_svg is not None and not selected_svg.classList.contains(
272
"shape-container"):
273
selected_svg = selected_svg.previousElementSibling
274
break
275
else:
276
selected_svg = next_sibling
277
278
if selected_svg:
279
shape = selected_svg.firstElementChild
280
await focus_shape(shape)
281
282
283
def unselect_shape(event):
284
global selected_shape
285
286
if selected_shape is not None:
287
selected_shape.classList.remove("selected")
288
selected_shape = None
289
290
object_list.innerHTML = ""
291
delete_button.style.display = "none"
292
next_button.style.display = "none"
293
previous_button.style.display = "none"
294
295
296
def delete_shape(event):
297
global selected_shape
298
if selected_shape is None:
299
return
300
# Shape is SVG shape inside SVG so we need to remove the parent SVG
301
selected_shape.parentNode.remove()
302
selected_shape = None
303
object_list.innerHTML = ""
304
delete_button.style.display = "none"
305
next_button.style.display = "none"
306
previous_button.style.display = "none"
307
308
309
select_shape_proxy = create_proxy(select_shape)
310
unselect_shape_proxy = create_proxy(unselect_shape)
311
delete_shape_proxy = create_proxy(delete_shape)
312
next_shape_proxy = create_proxy(next_shape)
313
previous_shape_proxy = create_proxy(previous_shape)
314
315
delete_button.addEventListener("click", delete_shape_proxy)
316
next_button.addEventListener("click", next_shape_proxy)
317
previous_button.addEventListener("click", previous_shape_proxy)
318
319
# These are functions usable in JS
320
cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event))
321
make_bbox_proxy = create_proxy(lambda event: make_bbox(event))
322
make_polygon_proxy = create_proxy(lambda event: make_polygon(event))
323
follow_cursor_proxy = create_proxy(follow_cursor)
324
325
326
def switch_shape(event):
327
global shape_type
328
object_list.innerHTML = ""
329
unselect_shape(None)
330
shape = event.currentTarget.id
331
shape_type = shape
332
if shape_type == "select":
333
# Add event listeners to existing shapes
334
print(len(list(document.getElementsByClassName("shape"))), "shapes found")
335
for shape in document.getElementsByClassName("shape"):
336
print("Adding event listener to shape:", shape)
337
shape.addEventListener("click", select_shape_proxy)
338
image.addEventListener("click", unselect_shape_proxy)
339
helper_message.innerText = "Click on a shape to select"
340
# Cancel the current shape creation
341
if shape_type == "shape-bbox":
342
cancel_bbox(None)
343
elif shape_type == "shape-polygon":
344
cancel_polygon(None)
345
elif shape_type == "shape-polyline":
346
cancel_polygon(None)
347
else:
348
# Remove event listeners for selection
349
for shape in document.getElementsByClassName("shape"):
350
print("Removing event listener from shape:", shape)
351
shape.removeEventListener("click", select_shape_proxy)
352
image.removeEventListener("click", unselect_shape_proxy)
353
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
354
print("Shape is now of type:", shape)
355
356
357
vertical_ruler = document.getElementById("annotation-ruler-vertical")
358
horizontal_ruler = document.getElementById("annotation-ruler-horizontal")
359
vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary")
360
horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary")
361
helper_message = document.getElementById("annotation-helper-message")
362
363
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
364
365
366
def cancel_bbox(event):
367
global bbox_pos, new_shape
368
369
# Key must be ESCAPE
370
if event is not None and hasattr(event, "key") and event.key != "Escape":
371
return
372
373
if new_shape is not None and event is not None:
374
# Require event so the shape is kept when it ends normally
375
new_shape.remove()
376
zone.removeEventListener("click", make_bbox_proxy)
377
document.removeEventListener("keydown", cancel_bbox_proxy)
378
cancel_button.removeEventListener("click", cancel_bbox_proxy)
379
380
bbox_pos = None
381
vertical_ruler.style.display = "none"
382
horizontal_ruler.style.display = "none"
383
vertical_ruler_2.style.display = "none"
384
horizontal_ruler_2.style.display = "none"
385
zone.style.cursor = "auto"
386
cancel_button.style.display = "none"
387
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
388
new_shape = None
389
390
391
def make_bbox(event):
392
global new_shape, bbox_pos
393
zone_rect = zone.getBoundingClientRect()
394
395
if bbox_pos is None:
396
helper_message.innerText = "Now define the second point"
397
398
bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width,
399
(event.clientY - zone_rect.top) / zone_rect.height]
400
vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%"
401
horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%"
402
vertical_ruler_2.style.display = "block"
403
horizontal_ruler_2.style.display = "block"
404
405
else:
406
x0, y0 = bbox_pos.copy()
407
x1 = (event.clientX - zone_rect.left) / zone_rect.width
408
y1 = (event.clientY - zone_rect.top) / zone_rect.height
409
410
rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect")
411
412
new_shape = make_shape_container()
413
zone_rect = zone.getBoundingClientRect()
414
415
new_shape.appendChild(rectangle)
416
zone.appendChild(new_shape)
417
418
minx = min(x0, x1)
419
miny = min(y0, y1)
420
maxx = max(x0, x1)
421
maxy = max(y0, y1)
422
423
rectangle.setAttribute("x", str(minx * image.naturalWidth))
424
rectangle.setAttribute("y", str(miny * image.naturalHeight))
425
rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth))
426
rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight))
427
rectangle.setAttribute("fill", "none")
428
rectangle.setAttribute("data-object-type", "")
429
rectangle.classList.add("shape-bbox")
430
rectangle.classList.add("shape")
431
432
# Add event listeners to the new shape
433
rectangle.addEventListener("click", select_shape_proxy)
434
435
cancel_bbox(None)
436
437
438
polygon_points = []
439
440
441
def make_polygon(event):
442
global new_shape, polygon_points
443
444
polygon = new_shape.children[0]
445
446
zone_rect = zone.getBoundingClientRect()
447
448
polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width,
449
(event.clientY - zone_rect.top) / zone_rect.height))
450
451
# Update the polygon
452
polygon.setAttribute("points", " ".join(
453
[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in
454
polygon_points]))
455
456
457
def reset_polygon():
458
global new_shape, polygon_points
459
460
zone.removeEventListener("click", make_polygon_proxy)
461
document.removeEventListener("keydown", close_polygon_proxy)
462
document.removeEventListener("keydown", cancel_polygon_proxy)
463
document.removeEventListener("keydown", backspace_polygon_proxy)
464
confirm_button.style.display = "none"
465
cancel_button.style.display = "none"
466
backspace_button.style.display = "none"
467
confirm_button.removeEventListener("click", close_polygon_proxy)
468
cancel_button.removeEventListener("click", cancel_polygon_proxy)
469
backspace_button.removeEventListener("click", backspace_polygon_proxy)
470
polygon_points.clear()
471
472
zone.style.cursor = "auto"
473
new_shape = None
474
475
476
def close_polygon(event):
477
if event is not None and hasattr(event, "key") and event.key != "Enter":
478
return
479
# Polygon is already there, but we need to remove the events
480
reset_polygon()
481
482
483
def cancel_polygon(event):
484
if event is not None and hasattr(event, "key") and event.key != "Escape":
485
return
486
# Delete the polygon
487
new_shape.remove()
488
reset_polygon()
489
490
491
def backspace_polygon(event):
492
if event is not None and hasattr(event, "key") and event.key != "Backspace":
493
return
494
if not polygon_points:
495
return
496
polygon_points.pop()
497
polygon = new_shape.children[0]
498
polygon.setAttribute("points", " ".join(
499
[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in
500
polygon_points]))
501
502
503
close_polygon_proxy = create_proxy(close_polygon)
504
cancel_polygon_proxy = create_proxy(cancel_polygon)
505
backspace_polygon_proxy = create_proxy(backspace_polygon)
506
507
508
def open_shape(event):
509
global new_shape, bbox_pos
510
if bbox_pos or shape_type == "select":
511
return
512
print("Creating a new shape of type:", shape_type)
513
514
if shape_type == "shape-bbox":
515
helper_message.innerText = ("Define the first point at the intersection of the lines "
516
"by clicking on the image, or click the cross to cancel")
517
518
cancel_button.addEventListener("click", cancel_bbox_proxy)
519
document.addEventListener("keydown", cancel_bbox_proxy)
520
cancel_button.style.display = "block"
521
bbox_pos = None
522
zone.addEventListener("click", make_bbox_proxy)
523
vertical_ruler.style.display = "block"
524
horizontal_ruler.style.display = "block"
525
zone.style.cursor = "crosshair"
526
elif shape_type == "shape-polygon" or shape_type == "shape-polyline":
527
if shape_type == "shape-polygon":
528
helper_message.innerText = ("Click on the image to define the points of the polygon, "
529
"press escape to cancel, enter to close, or backspace to "
530
"remove the last point")
531
elif shape_type == "shape-polyline":
532
helper_message.innerText = ("Click on the image to define the points of the polyline, "
533
"press escape to cancel, enter to finish, or backspace to "
534
"remove the last point")
535
536
if not polygon_points and not new_shape:
537
new_shape = make_shape_container()
538
zone_rect = zone.getBoundingClientRect()
539
540
if not polygon_points and int(new_shape.children.length) == 0:
541
zone.addEventListener("click", make_polygon_proxy)
542
document.addEventListener("keydown", close_polygon_proxy)
543
document.addEventListener("keydown", cancel_polygon_proxy)
544
document.addEventListener("keydown", backspace_polygon_proxy)
545
cancel_button.addEventListener("click", cancel_polygon_proxy)
546
cancel_button.style.display = "block"
547
confirm_button.addEventListener("click", close_polygon_proxy)
548
confirm_button.style.display = "block"
549
backspace_button.addEventListener("click", backspace_polygon_proxy)
550
backspace_button.style.display = "block"
551
if shape_type == "shape-polygon":
552
polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon")
553
polygon.classList.add("shape-polygon")
554
elif shape_type == "shape-polyline":
555
polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline")
556
polygon.classList.add("shape-polyline")
557
polygon.setAttribute("fill", "none")
558
polygon.setAttribute("data-object-type", "")
559
polygon.classList.add("shape")
560
new_shape.appendChild(polygon)
561
zone.appendChild(new_shape)
562
zone.style.cursor = "crosshair"
563
elif shape_type == "shape-point":
564
point = document.createElementNS("http://www.w3.org/2000/svg", "circle")
565
zone_rect = zone.getBoundingClientRect()
566
point.setAttribute("cx", str((event.clientX - zone_rect.left) / zone_rect.width * image.naturalWidth))
567
point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight))
568
point.setAttribute("r", "0")
569
point.classList.add("shape-point")
570
point.classList.add("shape")
571
point.setAttribute("data-object-type", "")
572
573
new_shape = make_shape_container()
574
zone_rect = zone.getBoundingClientRect()
575
576
new_shape.appendChild(point)
577
zone.appendChild(new_shape)
578
579
new_shape = None
580
581
582
583
for button in list(document.getElementById("shape-selector").children):
584
button.addEventListener("click", create_proxy(switch_shape))
585
print("Shape", button.id, "is available")
586
587
zone.addEventListener("mousemove", follow_cursor_proxy)
588
zone.addEventListener("click", create_proxy(open_shape))
589
590
# Load existing annotations, if any
591
put_shapes(await load_shapes())
592
print("Ready!")
593