From ab9c66794c334d0281067b0a9089721f648baae1 Mon Sep 17 00:00:00 2001 From: Ruben van de Ven Date: Wed, 8 Jun 2022 13:28:36 +0200 Subject: [PATCH] Start with tag management. Incl. fancier index --- app/svganim/strokes.py | 70 +++++++--- app/svganim/uimethods.py | 2 +- app/templates/index.html | 160 +++++++++++++--------- app/webserver.py | 22 ++- app/www/annotate.js | 46 +++---- app/www/annotations.js | 279 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 467 insertions(+), 112 deletions(-) create mode 100644 app/www/annotations.js diff --git a/app/svganim/strokes.py b/app/svganim/strokes.py index a18f53d..c01ca1b 100644 --- a/app/svganim/strokes.py +++ b/app/svganim/strokes.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio import copy +from ctypes.wintypes import tagMSG import json from os import X_OK, PathLike import os @@ -12,7 +13,7 @@ import svgwrite import tempfile import io import logging -from anytree import NodeMixin, RenderTree +from anytree import NodeMixin, RenderTree, iterators from anytree.exporter import JsonExporter from anytree.importer import JsonImporter, DictImporter @@ -22,11 +23,12 @@ Milliseconds = float Seconds = float class Annotation: - def __init__(self, tag: str, drawing: Drawing, t_in: Milliseconds, t_out: Milliseconds) -> None: + def __init__(self, tag: str, drawing: Drawing, t_in: Milliseconds, t_out: Milliseconds, comment: str = None) -> None: self.tag = tag self.t_in = t_in self.t_out = t_out self.drawing = drawing + self.comment = comment @property def id(self) -> str: @@ -502,6 +504,8 @@ class AnnotationIndex: self.drawing_dir = drawing_dir self.metadata_dir = metadata_dir + self.root_tag = getRootTag() + # disable disk cache because of glitches shelve.open(filename, writeback=True) self.shelve = {} @@ -518,7 +522,7 @@ class AnnotationIndex: Drawing(fn, self.metadata_dir, self.drawing_dir) for fn in self.get_drawing_filenames() ] } - self.shelve['_tags'] = {} + self.shelve['_tags'] = {tag.id: [] for tag in self.root_tag.descendants} self.shelve['_annotations'] = {} drawing: Drawing @@ -528,7 +532,7 @@ class AnnotationIndex: continue for ann in meta['annotations']: annotation = Annotation( - ann['tag'], drawing, ann['t_in'], ann['t_out']) + ann['tag'], drawing, ann['t_in'], ann['t_out'], ann['comment'] if 'comment' in ann else "") self.shelve['_annotations'][annotation.id] = annotation if annotation.tag not in self.shelve['_tags']: self.shelve['_tags'][annotation.tag] = [annotation] @@ -548,11 +552,14 @@ class AnnotationIndex: @property def annotations(self) -> dict[str, Annotation]: return self.shelve["_annotations"] + + def has_tag(self, tag): + return tag in self.tags - def get_annotations(self, tag) -> list[Annotation]: - if tag not in self.tags: + def get_annotations_for_tag(self, tag_id) -> list[Annotation]: + if tag_id not in self.tags: return [] - return self.tags[tag] + return self.tags[tag_id] def get_drawing_names(self) -> list[str]: return [ @@ -566,6 +573,14 @@ class AnnotationIndex: os.path.join(self.drawing_dir, f"{name}.json_appendable") for name in self.get_drawing_names() ] + + def get_nested_annotations_for_tag(self, tag_id) -> list[Annotation]: + tag = self.root_tag.find_by_id(tag_id) + annotations = [] + for tag in tag.descendants_incl_self(): + annotations.extend(self.get_annotations_for_tag(tag.id)) + return annotations + def __del__(self): self.shelve.close() @@ -698,7 +713,7 @@ def strokes2D(strokes): return d class Tag(NodeMixin): - def __init__(self, id, name = None, description = "", color = "black", parent=None, children=None): + def __init__(self, id, name = None, description = "", color = None, parent=None, children=None): self.id = id self.name = self.id if name is None else name self.color = color @@ -707,20 +722,31 @@ class Tag(NodeMixin): if children: self.children = children + if self.id == 'root' and not self.is_root: + logger.error("Root node shouldn't have a parent assigned") -class TagTree(NodeMixin): - def __init__(self, parent=None, children=None): - self.parent = parent - if children: - self.children = children + def __repr__(self): + return f"" + + def get_color(self): + if self.color is None and self.parent is not None: + return self.parent.get_color() + return self.color -my0 = Tag('root') -my1 = Tag('my1', 1, 0, parent=my0) -my2 = Tag('my2', 0, 2, parent=my0) + def descendants_incl_self(self): + return tuple(iterators.PreOrderIter(self)) + + def find_by_id(self, tag_id) -> Optional[Tag]: + for t in self.descendants: + if t.id == tag_id: + return t + return None -print(RenderTree(my0)) -tree = JsonExporter(indent=2).export(my0) -print(tree) -print(RenderTree(JsonImporter(DictImporter(Tag)).import_(tree))) -with open('www/tags.json', 'r') as fp: - print(RenderTree(JsonImporter(DictImporter(Tag)).read(fp))) +def getRootTag(file = 'www/tags.json') -> Tag: + with open(file, 'r') as fp: + tree: Tag = JsonImporter(DictImporter(Tag)).read(fp) + + # print(tree.descendants) + return tree + + # print(RenderTree(tree)) diff --git a/app/svganim/uimethods.py b/app/svganim/uimethods.py index 38acd95..10b520d 100644 --- a/app/svganim/uimethods.py +++ b/app/svganim/uimethods.py @@ -1,7 +1,7 @@ from hashlib import md5 -def annotation_hash(handler, input): +def annotation_hash(handler=None, input =""): return md5(input.encode()).hexdigest() # def nmbr(handler, lst) -> int: diff --git a/app/templates/index.html b/app/templates/index.html index 1d447bd..18f8c7a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -10,22 +10,70 @@ color: white } - ul { + #tags { + font-size: 80%; + padding: 0; + margin: 0; + } + + #tags li { + + list-style: none; + } + + #tags .tag-id-root { + list-style: none; + ; + } + + #tags .tag-id-root>ul { margin: 0; padding: 0; } - li { - display: inline-block; + #tags li>div, + #tags li.add-tag { + cursor: pointer + } + + #tags .tag-id-root>div { + display: none; + } + + #tags li:hover>ul>li.add-tag { + visibility: visible; ; } - summary h2{ + #tags .add-tag { + visibility: hidden; + ; + color: lightgray; + font-size: 80%; + } + + #tags input[type="color"] { + cursor: pointer; + width: 15px; + height: 15px; + padding: 0; + border: solid 1px black; + border-radius: 2px; + vertical-align: bottom; + margin-right: 10px; + } + + #tags .selected>div { + background: lightblue + } + + + summary h2 { display: inline-block; cursor: pointer; } - details[open] summary{ + details[open] summary { color: rgb(224, 196, 196); } @@ -37,101 +85,87 @@ display: block;; } */ - img { + + + #annotation_manager { + display: grid; + gap: 20px; + grid-template-columns: 200px auto; + } + + #tags { + grid-column: 1; + } + + #annotations { + grid-column: 2; + } + + #annotations ul { + grid-template-columns: repeat(auto-fill, 320px); + grid-gap: 20px; + display: grid; + list-style: none; + margin: 0; + padding: 0; + } + + + #annotations li { + + } + + #annotations img { /* width: 400px; */ background: white; width: 300px; height: 200px; cursor: pointer; - padding: 20px; + padding: 10px; } - .svganim_player { + #annotations .svganim_player { display: inline-block; position: relative; width: 300px; height: 200px; overflow: hidden; - padding: 20px; + padding: 10px; background: white; } - .svganim_player svg { + #annotations .svganim_player svg { width: 100%; height: 100%; } - .svganim_player.play:not(.loading) .controls { + #annotations .svganim_player.play:not(.loading) .controls { visibility: hidden; } - .svganim_player:hover .controls { + #annotations .svganim_player:hover .controls { visibility: visible !important; } + - + - {% for tag in index.tags %} -
- -

{{tag}} ({{len(index.tags[tag])}})

-
-
    - {% for annotation in index.tags[tag] %} -
  • - -
    - - -
  • - - {% end %} -
-
- {% end %} - +
+
    +
    +

    Reload index \ No newline at end of file diff --git a/app/webserver.py b/app/webserver.py index c9da100..0d5c4b3 100644 --- a/app/webserver.py +++ b/app/webserver.py @@ -345,12 +345,21 @@ class TagAnnotationsHandler(tornado.web.RequestHandler): self.metadir = os.path.join(self.config.storage, "metadata") def get(self, tag): - if tag not in self.index.tags: + if not self.index.has_tag(tag): raise tornado.web.HTTPError(404) self.set_header("Content-Type", "application/json") - annotations = self.index.tags[tag] - self.write(json.dumps(list([a.id for a in annotations]))) + # annotations = self.index.tags[tag] + # self.write(json.dumps(list([a.id for a in annotations]))) + annotations = self.index.get_nested_annotations_for_tag(tag) + self.write(json.dumps([{ + "id": annotation.id, + "tag": annotation.tag, + "id_hash": svganim.uimethods.annotation_hash(input=annotation.id), + "url": annotation.getJsonUrl(), + "comment": annotation.comment, + "drawing": annotation.drawing.get_url() + } for annotation in annotations])) class AnnotationHandler(tornado.web.RequestHandler): @@ -504,6 +513,13 @@ class AnnotationsHandler(tornado.web.RequestHandler): with open(meta_file, "w") as fp: json.dump(self.json_args, fp) +class TagsHandler(tornado.web.RequestHandler): + def initialize(self, config, index: svganim.strokes.AnnotationIndex) -> None: + self.config = config + self.index = index + + def get(self): + raise Exception('todo') class IndexHandler(tornado.web.RequestHandler): """Get annotation as svg""" diff --git a/app/www/annotate.js b/app/www/annotate.js index 2f64321..07b924e 100644 --- a/app/www/annotate.js +++ b/app/www/annotate.js @@ -230,30 +230,30 @@ class Annotator extends EventTarget { this.tagsEl = document.createElement('ul'); this.tagsEl.classList.add('tags'); const addTags = (tags, tagsEl) => { - Object.entries(tags).forEach(([tag, tagData]) => { + tags.forEach((tag) => { let tagLiEl = document.createElement('li'); let tagEl = document.createElement('div'); tagEl.classList.add('tag'); - tagEl.dataset.tag = tag; - tagEl.innerText = tagData.hasOwnProperty('fullname') ? tagData.fullname : tag; + tagEl.dataset.tag = tag.id; + tagEl.innerText = tag.hasOwnProperty('name') ? tag.name : tag.id; tagEl.addEventListener('click', (e) => { - this.addTag(tag, this.inPointPosition, this.outPointPosition); + this.addTag(tag.id, this.inPointPosition, this.outPointPosition); }); - tagEl.title = tagData.hasOwnProperty('description') ? tagData.description : ""; + tagEl.title = tag.hasOwnProperty('description') ? tag.description : ""; let signEl = document.createElement('span'); - signEl.classList.add('annotation-' + tag); - signEl.style.backgroundColor = this.getColorForTag(tag); + signEl.classList.add('annotation-' + tag.id); + signEl.style.backgroundColor = this.getColorForTag(tag.id); tagEl.prepend(signEl); tagLiEl.appendChild(tagEl); - if (tagData.hasOwnProperty('sub')) { + if (tag.hasOwnProperty('children')) { const subEl = document.createElement('ul'); subEl.classList.add('subtags'); - addTags(tagData.sub, subEl); + addTags(tag.children, subEl); tagLiEl.appendChild(subEl); } @@ -304,14 +304,14 @@ class Annotator extends EventTarget { } } - getColorForTag(tag) { - const tagData = this.tagMap[tag]; - console.log(tag, tagData); - if (tagData && tagData.hasOwnProperty('color')) { - return tagData.color; + getColorForTag(tag_id) { + const tag = this.tagMap[tag_id]; + console.log(tag_id, tag); + if (tag && tag.hasOwnProperty('color')) { + return tag.color; } - if (tagData && tagData.hasOwnProperty('parent')) { - return this.getColorForTag(tagData['parent']); + if (tag && tag.hasOwnProperty('parent')) { + return this.getColorForTag(tag['parent'].id); } return 'black'; } @@ -794,15 +794,15 @@ class Annotator extends EventTarget { const request = new Request(tagFile); return fetch(request) .then(response => response.json()) - .then(tags => { - this.tags = tags; + .then(rootTag => { + this.tags = rootTag.children; this.tagMap = {}; const addTagsToMap = (tags, parent) => { - Object.entries(tags).forEach(([tag, tagData]) => { - tagData['parent'] = typeof parent != "undefined" ? parent : null; - this.tagMap[tag] = tagData; - if (tagData.hasOwnProperty("sub")) { - addTagsToMap(tagData.sub, tag); + tags.forEach((tag) => { + tag['parent'] = typeof parent != "undefined" ? parent : null; + this.tagMap[tag.id] = tag; + if (tag.hasOwnProperty("children")) { + addTagsToMap(tag.children, tag); } }); }; diff --git a/app/www/annotations.js b/app/www/annotations.js new file mode 100644 index 0000000..da7b172 --- /dev/null +++ b/app/www/annotations.js @@ -0,0 +1,279 @@ +class AnnotationManager { + constructor(rootEl, tagsUrl) { + this.rootEl = rootEl; + + + this.tagsEl = this.rootEl.querySelector('#tags'); + this.annotationsEl = this.rootEl.querySelector('#annotations'); + + this.loadTags(tagsUrl); + } + + loadTags(tagFile) { + // tags config + const request = new Request(tagFile); + return fetch(request) + .then(response => response.json()) + .then(rootTag => { + this.rootTag = Tag.from(rootTag); + this.buildTagList() + }); + } + + buildTagList() { + // build, and rebuild + this.tagsEl.innerHTML = ""; + + const addTag = (tag, parentEl) => { + const tagLiEl = document.createElement('li'); + const tagSubEl = document.createElement('ul'); + const tagEl = document.createElement('div'); + + tagEl.innerText = tag.get_name(); + tagEl.addEventListener('click', (ev) => { + this.selectTag(tag); + }) + + const colorEl = document.createElement('input'); + colorEl.type = 'color'; + colorEl.value = tag.get_color(); + colorEl.addEventListener('change', (ev) => { + this.setTagColor(tag, ev.target.value); + }); + tagEl.prepend(colorEl) + + tagLiEl.classList.add('tag-id-' + tag.id); + tagLiEl.appendChild(tagEl); + + tag.menuLiEl = tagLiEl; + + tagLiEl.appendChild(tagSubEl); + tag.children.forEach((tag) => addTag(tag, tagSubEl)); + const tagAddSubEl = document.createElement('li'); + tagAddSubEl.classList.add('add-tag') + tagAddSubEl.innerText = 'add tag'; + tagAddSubEl.addEventListener('click', (ev) => { + const name = prompt(`Add a tag under '${tag.get_name()}':`); + if (name === null || name.length < 1) return //cancel + this.addTag(name, tag); + }); + tagSubEl.appendChild(tagAddSubEl); + + + parentEl.appendChild(tagLiEl); + + }; + + addTag(this.rootTag, this.tagsEl); + } + + + selectTag(tag) { + this.clearSelectedTag(); + + tag.menuLiEl.classList.add('selected'); + this.loadAnnotationsForTag(tag) + } + + clearSelectedTag() { + const selected = this.tagsEl.querySelectorAll('.selected'); + selected.forEach((s) => s.classList.remove('selected')); + //TODO empty annotationEl + this.annotationsEl.innerHTML = ""; + } + + selectAnnotation() { + throw new Error("Not implemented"); + } + + addTag(name, parentTag) { + const tag = new Tag(); + tag.id = crypto.randomUUID(); + tag.name = name; + tag.parent = parentTag; + parentTag.children.push(tag); + + this.buildTagList(); + this.saveTags(); + } + + loadAnnotationsForTag(tag) { + this.annotationsEl.innerHTML = ""; + + const request = new Request("/tags/" + tag.id); + return fetch(request) + .then(response => response.json()) + .then(annotations => { + this.annotations = annotations; + this.buildAnnotationList() + }); + } + + buildAnnotationList() { + this.annotationsEl.innerHTML = ""; + + const ulEl = document.createElement('ul'); + this.annotations.forEach((annotation, idx) => { + const liEl = document.createElement('li'); + const imgEl = document.createElement('img'); + const playerEl = document.createElement('div'); + const infoEl = document.createElement('div'); + infoEl.classList.add('annotation-info'); + + const tag = this.rootTag.find_by_id(annotation.tag); + console.log(tag) + + infoEl.innerText = `[${tag.get_name()}] ${annotation.comment}`; + + imgEl.src = `/annotation/${annotation.id}.svg`; + imgEl.addEventListener('click', () => { + imgEl.style.display = 'none'; + new Annotator( + playerEl, + "tags.json", + annotation.url, + { is_player: true, crop_to_fit: true, autoplay: true } + ); + }) + + playerEl.classList.add('play'); + + liEl.appendChild(imgEl); + liEl.appendChild(playerEl); + liEl.appendChild(infoEl); + ulEl.appendChild(liEl); + + }); + + this.annotationsEl.appendChild(ulEl); + + } + + renameTag(tag) { + // TODO: add button for this + const name = prompt(`Rename tag '${tag.get_name()}':`); + if (name === null || name.length < 1) return //cancel + tag.name = name; + + this.saveTags(); + } + + removeTag(tag) { + // TODO: add button for this + const request = new Request("/tags/" + tag.id); + return fetch(request) + .then(response => response.json()) + .then(annotations => { + if(annotations.length) { + alert(`Cannot remove '${tag.get_name()}', as it is used for ${annotations.length} annotations.`) + } else { + // TODO: remove tag + tag.parent.children.splice(tag.parent.children.indexOf(tag), 1); + tag.parent = null; + + this.saveTags(); + } + }); + } + + setTagColor(tag, color) { + tag.color = color; + this.buildTagList(); + + this.saveTags(); + } + + moveSelectedAnnotations(newTag) { + // TODO: add button for this + throw new Error("Not implemented"); + } + + saveTags() { + const json = this.rootTag.export(); + // TODO: save to remote + console.log('save', json) + } +} + +class Tag { + parent = null; + children = []; + + static from(json_obj) { + if (json_obj.hasOwnProperty('children')) { + json_obj.children = json_obj.children.map(Tag.from) + } else { + json_obj.children = []; + } + json_obj.parent = null; + const tag = Object.assign(new Tag(), json_obj); + tag.children.map((child) => child.parent = tag); + return tag; + } + + is_root() { + return this.parent === null; + } + + descendants(inc_self = false) { + let tags = this.children.flatMap((t) => t.descendants(true)) + if (inc_self) + tags.unshift(this) + return tags; + } + + find_by_id(tag_id) { + const desc = this.descendants().filter((tag) => tag.id == tag_id); + if (desc.length) return desc[0]; + return null; + } + + depth() { + if (this.parent === null) { + return 0; + } + return this.parent.depth() + 1; + } + + root() { + if (this.parent !== null) { + return this.parent.root(); + } + return this; + } + + get_name() { + if (this.hasOwnProperty('name')) { + return this.name; + } + return this.id; + } + + get_color() { + if (this.hasOwnProperty('color')) { + return this.color; + } + if (this.parent !== null) { + return this.parent.get_color() + } + return 'black'; + } + + _json_replacer(key, value) { + if (key === 'children' && !value.length) { + return undefined; + } + if (key === 'parent' || key === 'menuLiEl') { + return undefined; + } + if (key === 'color' && this.parent !== null && this.parent.get_color() === value) { + return undefined; + } + return value; + } + + export() { + return JSON.stringify(this, this._json_replacer) + } + +} \ No newline at end of file