roundabout,
created on Wednesday, 4 September 2024, 16:44:59 (1725468299),
received on Thursday, 5 September 2024, 07:25:39 (1725521139)
Author identity: vlad <vlad.muntoiu@gmail.com>
424bf305a78ccf73dd8f754c948ca462df417940
.idea/markdown.xml
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="customStylesheetText" value="pre, code { font-family: monospace; }" />
<option name="useCustomStylesheetText" value="true" />
</component>
</project>
app.py
@@ -40,6 +40,7 @@ with app.app_context():
username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True)
password_hashed = db.Column(db.String(60), nullable=False)
admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false")
pictures = db.relationship("PictureResource", back_populates="author")
def __init__(self, username, password):
self.username = username
@@ -140,7 +141,7 @@ with app.app_context():
json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False)
resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False)
object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=False)
object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True)
resource = db.relationship("PictureResource", backref="regions")
object = db.relationship("PictureObject", backref="regions")
@@ -158,6 +159,8 @@ with app.app_context():
width = db.Column(db.Integer, nullable=False)
height = db.Column(db.Integer, nullable=False)
nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True)
author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False)
author = db.relationship("User", back_populates="pictures")
nature = db.relationship("PictureNature", back_populates="resources")
@@ -172,9 +175,10 @@ with app.app_context():
licences = db.relationship("PictureLicence", back_populates="resource")
def __init__(self, title, description, origin_url, licence_ids, mime, nature=None,
def __init__(self, title, author, description, origin_url, licence_ids, mime, nature=None,
replaces=None):
self.title = title
self.author = author
self.description = description
self.origin_url = origin_url
self.file_format = mime
@@ -287,6 +291,7 @@ def upload_post():
title = flask.request.form["title"]
description = flask.request.form["description"]
origin_url = flask.request.form["origin_url"]
author = db.session.get(User, flask.session.get("username"))
file = flask.request.files["file"]
@@ -294,7 +299,7 @@ def upload_post():
flask.flash("No selected file")
return flask.redirect(flask.request.url)
resource = PictureResource(title, description, origin_url, ["CC0-1.0"], file.mimetype)
resource = PictureResource(title, author, description, origin_url, ["CC0-1.0"], file.mimetype)
db.session.add(resource)
db.session.commit()
file.save(path.join(config.DATA_PATH, "pictures", str(resource.id)))
@@ -316,6 +321,10 @@ def picture(id):
@app.route("/picture/<int:id>/annotate")
def annotate_picture(id):
resource = db.session.get(PictureResource, id)
current_user = db.session.get(User, flask.session.get("username"))
if resource.author != current_user and not current_user.admin:
return flask.abort(403)
if resource is None:
return flask.abort(404)
@@ -323,6 +332,60 @@ def annotate_picture(id):
file_extension=mimetypes.guess_extension(resource.file_format))
@app.route("/picture/<int:id>/save-annotations", methods=["POST"])
def save_annotations(id):
resource = db.session.get(PictureResource, id)
if resource is None:
return flask.abort(404)
current_user = db.session.get(User, flask.session.get("username"))
if resource.author != current_user and not current_user.admin:
return flask.abort(403)
# Delete all previous annotations
db.session.query(PictureRegion).filter_by(resource_id=id).delete()
json = flask.request.json
for region in json:
object_id = region["object"]
picture_object = db.session.get(PictureObject, object_id)
region_data = {
"type": region["type"],
"shape": region["shape"],
}
region_row = PictureRegion(region_data, resource, picture_object)
db.session.add(region_row)
db.session.commit()
response = flask.make_response()
response.status_code = 204
return response
@app.route("/picture/<int:id>/get-annotations")
def get_annotations(id):
resource = db.session.get(PictureResource, id)
if resource is None:
return flask.abort(404)
regions = db.session.query(PictureRegion).filter_by(resource_id=id).all()
regions_json = []
for region in regions:
regions_json.append({
"object": region.object_id,
"type": region.json["type"],
"shape": region.json["shape"],
})
return flask.jsonify(regions_json)
@app.route("/raw/picture/<int:id>")
def raw_picture(id):
resource = db.session.get(PictureResource, id)
formats.md
@@ -0,0 +1,110 @@
Data formats
============
This document describes the various data formats that are used in the system.
Raw annotation data (client → server)
-------------------------------------
The client sends raw data for image annotations in a JSON format which is a list
of shapes. Each shape is a dictionary with the following keys:
* `type`: The type of the shape which can be:
* `bbox` (bounding box, rectangle)
* `polygon`
* `polyline`
* `point`
* `shape`: The shape data. Its format depends on the shape `type`:
* For `bbox` it is a dictionary with keys x, y, w, h:
~~~json
{"x": x, "y": y, "w": w, "h": h}
~~~
* For `polygon` and `polyline` it is a list of points; each point is a
dictionary with keys x and y:
~~~json
[{"x": x1, "y": y1}, {"x": x2, "y": y2}, ...]
~~~
The only difference between `polygon` and `polyline` is that the former is
supposed to be closed so the last point is connected to the first one.
* For `point` it is a dictionary with keys x and y:
~~~json
{"x": x, "y": y}
~~~
* All coordinates are floating-point numbers in the range [0, 1] and relative
to the image size, with the origin in the top-left corner.
* `object`: The ID of the type of object (label) depicted in the shape. This ID
is a human-readable string that must be registered in the system before
being used on shapes.
### Example
~~~json
[
{
"type": "bbox",
"shape": {"x": 0.1, "y": 0.1, "w": 0.5, "h": 0.5},
"object": "Cat (Felis catus)"
},
{
"type": "polygon",
"shape": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 0, "y": 1}],
"object": "Slice of pizza margherita"
},
{
"type": "point",
"shape": {"x": 0.5, "y": 0.5},
"object": "Cat (Felis catus) - left eye"
}
]
~~~
Query format
------------
The query format is based on YAML and used to query for pictures in the system.
### Example
~~~yaml
# Restrictions for queried images
- want:
# This means that the image must contain both rules, so both a cat and a dog
- has_object: ["Cat (Felis catus)"]
- has_object: ["Dog (Canis lupus familiaris)"]
# Or we can put them in a list to mean that the image can contain any of the
# objects in the list
- has_object: ["Grass", "Flower"]
# So the image must contain a cat and a dog, as well as either grass or
# a flower
# The following rule restricts the images to those with a certain source,
# like a camera or a drawing; omitting this rule means that the images can
# be of any type
- nature: ["photo", "drawing"]
# The following rule restricts the images to those with a certain licence
- licence: ["CC-BY-1.0", "CC-BY-2.0", "CC-BY-3.0", "CC-BY-4.0", "CC0-1.0",
"Unlicense", "WTFPL", "MIT", "BSD-2-Clause", "BSD-3-Clause",
"Apache-2.0", "Informal-attribution", "Informal-do-anything",
"Public-domain-old", "Public-domain"]
# Prohibitions for queried images
- exclude:
# This means that the image must not contain any of the objects in the list
- has_object: ["Human"]
# This excludes images taken before the given date
- before_date: 2019-01-01
# This requires images to have a minimum resolution
- below_width: 800
- below_height: 600
# Pagination
- limit: 32
- offset: 0
# Sorting
- sort_by: "date-uploaded-recent"
# Format
- format: "jpg"
- max_resolution: [800, 800] # resizes
# In summary, we want the 32 most recent images that contain both a cat and
# a dog, either a grass or a flower, but not a human, taken after 2019-01-01,
# must be a photo or a drawing, must carry one of certain permissive licences
# and have a resolution of at least 800x600 pixels.
~~~
static/picture-annotation.py
@@ -2,6 +2,7 @@ from pyscript import document
from pyodide.ffi import create_proxy
from pyodide.http import pyfetch
import asyncio
import json
document.getElementById("shape-options").style.display = "flex"
@@ -13,6 +14,7 @@ backspace_button = document.getElementById("annotation-backspace")
delete_button = document.getElementById("annotation-delete")
previous_button = document.getElementById("annotation-previous")
next_button = document.getElementById("annotation-next")
save_button = document.getElementById("annotation-save")
object_list = document.getElementById("object-types")
@@ -52,6 +54,122 @@ def change_object_type(event):
change_object_type_proxy = create_proxy(change_object_type)
def list_shapes():
shapes = list(zone.getElementsByClassName("shape"))
json_shapes = []
for shape in shapes:
shape_dict = {}
match shape.tagName:
case "rect":
shape_dict["type"] = "bbox"
shape_dict["shape"] = {
"x": float(shape.getAttribute("x")) / image.naturalWidth,
"y": float(shape.getAttribute("y")) / image.naturalHeight,
"w": float(shape.getAttribute("width")) / image.naturalWidth,
"h": float(shape.getAttribute("height")) / image.naturalHeight
}
case "polygon" | "polyline":
if shape.tagName == "polygon":
shape_dict["type"] = "polygon"
elif shape.tagName == "polyline":
shape_dict["type"] = "polyline"
points = shape.getAttribute("points").split(" ")
json_points = []
for point in points:
x, y = point.split(",")
x, y = float(x), float(y)
json_points.append({
"x": x / image.naturalWidth,
"y": y / image.naturalHeight
})
shape_dict["shape"] = json_points
case "circle" if shape.classList.contains("shape-point"):
shape_dict["type"] = "point"
shape_dict["shape"] = {
"x": float(shape.getAttribute("cx")) / image.naturalWidth,
"y": float(shape.getAttribute("cy")) / image.naturalHeight
}
case _:
continue
shape_dict["object"] = shape.getAttribute("data-object-type")
json_shapes.append(shape_dict)
return json_shapes
def put_shapes(json_shapes):
for shape in json_shapes:
new_shape = document.createElementNS("http://www.w3.org/2000/svg", "svg")
new_shape.setAttribute("width", "100%")
new_shape.setAttribute("height", "100%")
zone_rect = zone.getBoundingClientRect()
new_shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}")
new_shape.classList.add("shape-container")
if shape["type"] == "bbox":
rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect")
rectangle.setAttribute("x", str(shape["shape"]["x"] * image.naturalWidth))
rectangle.setAttribute("y", str(shape["shape"]["y"] * image.naturalHeight))
rectangle.setAttribute("width", str(shape["shape"]["w"] * image.naturalWidth))
rectangle.setAttribute("height", str(shape["shape"]["h"] * image.naturalHeight))
rectangle.setAttribute("fill", "none")
rectangle.setAttribute("data-object-type", shape["object"])
rectangle.classList.add("shape-bbox")
rectangle.classList.add("shape")
new_shape.appendChild(rectangle)
elif shape["type"] == "polygon" or shape["type"] == "polyline":
polygon = document.createElementNS("http://www.w3.org/2000/svg", shape["type"])
points = " ".join(
[f"{point['x'] * image.naturalWidth},{point['y'] * image.naturalHeight}" for point in shape["shape"]])
polygon.setAttribute("points", points)
polygon.setAttribute("fill", "none")
polygon.setAttribute("data-object-type", shape["object"])
polygon.classList.add(f"shape-{shape['type']}")
polygon.classList.add("shape")
new_shape.appendChild(polygon)
elif shape["type"] == "point":
point = document.createElementNS("http://www.w3.org/2000/svg", "circle")
point.setAttribute("cx", str(shape["shape"]["x"] * image.naturalWidth))
point.setAttribute("cy", str(shape["shape"]["y"] * image.naturalHeight))
point.setAttribute("r", "0")
point.classList.add("shape-point")
point.classList.add("shape")
point.setAttribute("data-object-type", shape["object"])
new_shape.appendChild(point)
zone.appendChild(new_shape)
async def load_shapes():
resource_id = document.getElementById("resource-id").value
response = await pyfetch(f"/picture/{resource_id}/get-annotations")
if response.ok:
shapes = await response.json()
return shapes
async def save_shapes(event):
shapes = list_shapes()
resource_id = document.getElementById("resource-id").value
print("Saving shapes:", shapes)
response = await pyfetch(f"/picture/{resource_id}/save-annotations",
method="POST",
headers={
"Content-Type": "application/json"
},
body=json.dumps(shapes)
)
if response.ok:
return await response
save_shapes_proxy = create_proxy(save_shapes)
save_button.addEventListener("click", save_shapes_proxy)
async def focus_shape(shape):
global selected_shape
@@ -480,3 +598,9 @@ for button in list(document.getElementById("shape-selector").children):
zone.addEventListener("mousemove", follow_cursor_proxy)
zone.addEventListener("click", create_proxy(open_shape))
# Load existing annotations, if any
async def load_existing():
put_shapes(await load_shapes())
load_existing()
templates/picture-annotation.html
@@ -57,9 +57,14 @@
<button class="button-flat" title="select next shape" id="annotation-next">
<iconify-icon icon="mdi:chevron-right"></iconify-icon>
</button>
<div class="flexible-space"></div>
<button id="annotation-save">
Save
</button>
</x-buttonbox>
<x-vbox id="object-types" style="--gap-box: 0.25rem; --padding-box: 1rem;">
</x-vbox>
</x-frame>
<input type="hidden" id="resource-id" name="resource-id" value="{{ resource.id }}">
<script type="py" src="/static/picture-annotation.py"></script>
{% endblock %}