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 = authorself.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 jsondocument.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 %}