roundabout,
created on Sunday, 5 January 2025, 21:24:26 (1736112266),
received on Sunday, 5 January 2025, 21:24:29 (1736112269)
Author identity: vlad <vlad.muntoiu@gmail.com>
87febd36d95cac58a7cde0cf4d71f4fd74d61d19
static/picture-annotation.py
@@ -49,8 +49,8 @@ async def get_all_objects():
def follow_cursor(event):
rect = zone.getBoundingClientRect()
x = event.clientX - rect.left
y = event.clientY - rect.top
x = (event.clientX - rect.left) / scale
y = (event.clientY - rect.top) / scale
vertical_ruler.style.left = str(x) + "px"
horizontal_ruler.style.top = str(y) + "px"
@@ -278,16 +278,21 @@ async def focus_shape(shape):
object_list.style.bottom = "auto"
object_list.style.maxHeight = str(image.height - get_centre(shape)[1] / image.naturalHeight * image.height) + "px"
object_list.style.maxWidth = str(image.width - get_centre(shape)[0] / image.naturalWidth * image.width) + "px"
transform_origin = ["top", "left"]
if get_centre(shape)[0] / image.naturalWidth * image.width + object_list.offsetWidth > image.width:
object_list.style.left = "auto"
object_list.style.right = str((image.naturalWidth - get_centre(shape)[0]) / image.naturalWidth * 100) + "%"
object_list.style.maxWidth = str(get_centre(shape)[0] / image.naturalWidth * image.width) + "px"
transform_origin[1] = "right"
if get_centre(shape)[1] / image.naturalHeight * image.height + object_list.offsetHeight > image.height:
object_list.style.top = "auto"
object_list.style.bottom = str((image.naturalHeight - get_centre(shape)[1]) / image.naturalHeight * 100) + "%"
object_list.style.maxHeight = str(get_centre(shape)[1] / image.naturalHeight * image.height) + "px"
transform_origin[0] = "bottom"
object_list.style.transformOrigin = " ".join(transform_origin)
async def select_shape(event):
@@ -475,6 +480,7 @@ def cancel_bbox(event):
cancel_button.style.display = "none"
helper_message.innerText = "Select a shape type then click on the image to begin defining it"
new_shape = None
update_transform()
def make_bbox(event):
@@ -561,6 +567,8 @@ def reset_polygon():
zone.style.cursor = "auto"
new_shape = None
update_transform()
def close_polygon(event):
if event is not None and hasattr(event, "key") and event.key != "Enter":
@@ -667,6 +675,8 @@ def open_shape(event):
new_shape = None
update_transform()
for button in list(document.getElementById("shape-selector").children):
@@ -676,6 +686,80 @@ for button in list(document.getElementById("shape-selector").children):
zone.addEventListener("mousemove", follow_cursor_proxy)
zone.addEventListener("click", create_proxy(open_shape))
# Zoom
zoom_container = document.getElementById("annotation-zoom-container")
scale = 1
translate_x = 0
translate_y = 0
def update_transform():
zone.style.transform = f"scale({scale}) translate({translate_x}px, {translate_y}px)"
object_list.style.transform = f"scale({1/scale})" # neutralise the zoom
vertical_ruler.style.transform = f"scale({1/scale})"
horizontal_ruler.style.transform = f"scale({1/scale})"
vertical_ruler_2.style.transform = f"scale({1/scale})"
horizontal_ruler_2.style.transform = f"scale({1/scale})"
for svg in zone.getElementsByClassName("shape-container"):
# Because vector-effect="non-scaling-stroke" doesn't work when an HTML ancestor is
# scaled, we need to scale the shapes down then scale them back up
svg.style.transform = f"scale({1/scale})"
svg.style.transformOrigin = "0 0"
for shape in svg.children:
shape.setAttribute("transform", f"scale({scale})")
shape.setAttribute("transform-origin", "0 0")
scale_exponent = 0
zoom_offset = (0, 0)
def on_wheel(event):
global scale, scale_exponent, translate_x, translate_y
event.preventDefault()
scale_exponent += (-1 if event.deltaY > 0 else 1) / 16
new_scale = 2 ** scale_exponent
rect = zoom_container.getBoundingClientRect()
pointer_x = event.clientX - rect.left
pointer_y = event.clientY - rect.top
translate_x = pointer_x - (pointer_x - translate_x) * (new_scale / scale)
translate_y = pointer_y - (pointer_y - translate_y) * (new_scale / scale)
scale = new_scale
update_transform()
is_dragging = False
start_x = 0
start_y = 0
def on_mouse_down(event):
global is_dragging, start_x, start_y
is_dragging = True
start_x = event.clientX
start_y = event.clientY
def on_mouse_move(event):
global translate_x, translate_y
if is_dragging:
translate_x += event.movementX / scale
translate_y += event.movementY / scale
update_transform()
def on_mouse_up(event):
global is_dragging
is_dragging = False
zoom_container.addEventListener("wheel", on_wheel)
zoom_container.addEventListener("mousedown", on_mouse_down)
document.addEventListener("mousemove", on_mouse_move)
document.addEventListener("mouseup", on_mouse_up)
# Load existing annotations, if any
put_shapes(await load_shapes())
print("Ready!")
static/style.css
@@ -66,14 +66,12 @@ iconify-icon {
#annotation-zone, .annotation-zone {
position: relative;
user-select: none;
}
.annotation-zone {
overflow: hidden;
transform-origin: 50% 50%;
}
#annotation-zone {
overflow: hidden;
#annotation-zoom-container > #annotation-zone {
overflow: visible;
}
#annotation-image, .annotation-image {
@@ -115,6 +113,7 @@ iconify-icon {
fill: var(--color-accent);
fill-opacity: 0.1;
stroke-width: 2px;
--shape-stroke-width: 2px;
vector-effect: non-scaling-stroke;
pointer-events: auto;
transition: filter 0.25s cubic-bezier(0.61, 1, 0.88, 1),
@@ -131,6 +130,7 @@ iconify-icon {
.shape-point {
stroke: var(--color-accent);
stroke-width: 2px;
--shape-radius: 2px;
fill-opacity: 1;
r: 2;
transition: r 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
@@ -141,12 +141,14 @@ iconify-icon {
.shape.selected {
fill-opacity: 0.2;
stroke-width: 4px;
--shape-stroke-width: 4px;
filter: drop-shadow(0 0 2px var(--text-soft));
}
.shape-point.selected {
fill-opacity: 1;
r: 6;
--shape-radius: 6px;
stroke-width: 6px;
}
@@ -154,10 +156,6 @@ iconify-icon {
pointer-events: none;
}
#annotation-helper-message {
min-height: 2lh;
}
.shape-container-viewonly > .shape, .thumbnail-list > li .shape {
fill: var(--color-info);
stroke: var(--color-info);
@@ -812,6 +810,44 @@ nav .button-flat .ripple-pad {
}
#annotation-helper-message {
padding: 1rem;
padding: 0.75rem;
text-align: center;
flex: 1 0 auto;
}
#annotation-zoom-container {
overflow: hidden;
}
#annotation-zoom-container {
flex: 0 1 auto;
overflow: scroll;
}
#annotation-zoom-container > #annotation-zone {
transform: scale(1);
transition: transform 0.125s ease;
max-width: 100%;
max-height: 100%;
}
#annotation-main-area {
display: flex;
flex-direction: column;
flex: 1 1 auto;
overflow: auto;
}
body.fixed-content-area {
max-width: 100vw;
max-height: 100vh;
display: flex;
flex-direction: column;
}
body.fixed-content-area > main {
flex: 0 1 auto;
overflow: auto;
display: flex;
flex-direction: column;
}
templates/default.html
@@ -14,7 +14,7 @@
}
}
</style>
<body>
<body {% if fixed_content_area %}class="fixed-content-area"{% endif %}>
<dialog id="hamburger" class="sheet-left">
<nav>
<ul>
templates/picture-annotation.html
@@ -3,6 +3,7 @@
{% block nav_title %}{{ resource.title }}{% endblock %}
{% block title %}Annotating picture {{ resource.title }} | {{ site_name }}{% endblock %}
{% set back_url = "/picture/" + resource.id|string %}
{% set fixed_content_area = true %}
{% block content %}
<style>
@@ -13,7 +14,7 @@
</style>
<script type="module" src="https://pyscript.net/releases/2024.8.2/core.js"></script>
<x-frame id="main-area">
<x-frame id="annotation-main-area">
<x-hbox id="annotation-controls">
<x-buttonbox id="shape-selector">
<button class="button-flat" title="selection" id="select">
@@ -57,16 +58,18 @@
</button>
</x-buttonbox>
</x-hbox>
<div id="annotation-zone">
<x-vbox id="object-types" style="--gap-box: 0.25rem; --padding-box: 1rem;">
<input type="text" id="filter-object-types" placeholder="Search" aria-label="Search object types">
<x-vbox id="object-types-content"></x-vbox>
</x-vbox>
<img id="annotation-image" src="/raw/picture/{{ resource.id }}" alt="">
<div id="annotation-ruler-vertical" class="annotation-ruler"></div>
<div id="annotation-ruler-horizontal" class="annotation-ruler"></div>
<div id="annotation-ruler-vertical-secondary" class="annotation-ruler"></div>
<div id="annotation-ruler-horizontal-secondary" class="annotation-ruler"></div>
<div id="annotation-zoom-container">
<div id="annotation-zone">
<x-vbox id="object-types" style="--gap-box: 0.25rem; --padding-box: 1rem;">
<input type="text" id="filter-object-types" placeholder="Search" aria-label="Search object types">
<x-vbox id="object-types-content"></x-vbox>
</x-vbox>
<img id="annotation-image" src="/raw/picture/{{ resource.id }}" alt="">
<div id="annotation-ruler-vertical" class="annotation-ruler"></div>
<div id="annotation-ruler-horizontal" class="annotation-ruler"></div>
<div id="annotation-ruler-vertical-secondary" class="annotation-ruler"></div>
<div id="annotation-ruler-horizontal-secondary" class="annotation-ruler"></div>
</div>
</div>
<p id="annotation-helper-message">Please wait for Python to load...</p>
</x-frame>