Start with tag management. Incl. fancier index

This commit is contained in:
Ruben van de Ven 2022-06-08 13:28:36 +02:00
parent 232903c0ff
commit ab9c66794c
6 changed files with 467 additions and 112 deletions

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import copy import copy
from ctypes.wintypes import tagMSG
import json import json
from os import X_OK, PathLike from os import X_OK, PathLike
import os import os
@ -12,7 +13,7 @@ import svgwrite
import tempfile import tempfile
import io import io
import logging import logging
from anytree import NodeMixin, RenderTree from anytree import NodeMixin, RenderTree, iterators
from anytree.exporter import JsonExporter from anytree.exporter import JsonExporter
from anytree.importer import JsonImporter, DictImporter from anytree.importer import JsonImporter, DictImporter
@ -22,11 +23,12 @@ Milliseconds = float
Seconds = float Seconds = float
class Annotation: 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.tag = tag
self.t_in = t_in self.t_in = t_in
self.t_out = t_out self.t_out = t_out
self.drawing = drawing self.drawing = drawing
self.comment = comment
@property @property
def id(self) -> str: def id(self) -> str:
@ -502,6 +504,8 @@ class AnnotationIndex:
self.drawing_dir = drawing_dir self.drawing_dir = drawing_dir
self.metadata_dir = metadata_dir self.metadata_dir = metadata_dir
self.root_tag = getRootTag()
# disable disk cache because of glitches shelve.open(filename, writeback=True) # disable disk cache because of glitches shelve.open(filename, writeback=True)
self.shelve = {} self.shelve = {}
@ -518,7 +522,7 @@ class AnnotationIndex:
Drawing(fn, self.metadata_dir, self.drawing_dir) for fn in self.get_drawing_filenames() 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'] = {} self.shelve['_annotations'] = {}
drawing: Drawing drawing: Drawing
@ -528,7 +532,7 @@ class AnnotationIndex:
continue continue
for ann in meta['annotations']: for ann in meta['annotations']:
annotation = Annotation( 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 self.shelve['_annotations'][annotation.id] = annotation
if annotation.tag not in self.shelve['_tags']: if annotation.tag not in self.shelve['_tags']:
self.shelve['_tags'][annotation.tag] = [annotation] self.shelve['_tags'][annotation.tag] = [annotation]
@ -549,10 +553,13 @@ class AnnotationIndex:
def annotations(self) -> dict[str, Annotation]: def annotations(self) -> dict[str, Annotation]:
return self.shelve["_annotations"] return self.shelve["_annotations"]
def get_annotations(self, tag) -> list[Annotation]: def has_tag(self, tag):
if tag not in self.tags: return tag in self.tags
def get_annotations_for_tag(self, tag_id) -> list[Annotation]:
if tag_id not in self.tags:
return [] return []
return self.tags[tag] return self.tags[tag_id]
def get_drawing_names(self) -> list[str]: def get_drawing_names(self) -> list[str]:
return [ return [
@ -567,6 +574,14 @@ class AnnotationIndex:
for name in self.get_drawing_names() 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): def __del__(self):
self.shelve.close() self.shelve.close()
@ -698,7 +713,7 @@ def strokes2D(strokes):
return d return d
class Tag(NodeMixin): 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.id = id
self.name = self.id if name is None else name self.name = self.id if name is None else name
self.color = color self.color = color
@ -707,20 +722,31 @@ class Tag(NodeMixin):
if children: if children:
self.children = 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 __repr__(self):
def __init__(self, parent=None, children=None): return f"<svganim.strokes.Tag {self.id}>"
self.parent = parent
if children:
self.children = children
my0 = Tag('root') def get_color(self):
my1 = Tag('my1', 1, 0, parent=my0) if self.color is None and self.parent is not None:
my2 = Tag('my2', 0, 2, parent=my0) return self.parent.get_color()
return self.color
print(RenderTree(my0)) def descendants_incl_self(self):
tree = JsonExporter(indent=2).export(my0) return tuple(iterators.PreOrderIter(self))
print(tree)
print(RenderTree(JsonImporter(DictImporter(Tag)).import_(tree))) def find_by_id(self, tag_id) -> Optional[Tag]:
with open('www/tags.json', 'r') as fp: for t in self.descendants:
print(RenderTree(JsonImporter(DictImporter(Tag)).read(fp))) if t.id == tag_id:
return t
return None
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))

View file

@ -1,7 +1,7 @@
from hashlib import md5 from hashlib import md5
def annotation_hash(handler, input): def annotation_hash(handler=None, input =""):
return md5(input.encode()).hexdigest() return md5(input.encode()).hexdigest()
# def nmbr(handler, lst) -> int: # def nmbr(handler, lst) -> int:

View file

@ -10,16 +10,64 @@
color: white 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; margin: 0;
padding: 0; padding: 0;
} }
li { #tags li>div,
display: inline-block; #tags li.add-tag {
cursor: pointer
}
#tags .tag-id-root>div {
display: none;
}
#tags li:hover>ul>li.add-tag {
visibility: visible;
; ;
} }
#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 { summary h2 {
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;
@ -37,101 +85,87 @@
display: block;; 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; */ /* width: 400px; */
background: white; background: white;
width: 300px; width: 300px;
height: 200px; height: 200px;
cursor: pointer; cursor: pointer;
padding: 20px; padding: 10px;
} }
.svganim_player { #annotations .svganim_player {
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 300px; width: 300px;
height: 200px; height: 200px;
overflow: hidden; overflow: hidden;
padding: 20px; padding: 10px;
background: white; background: white;
} }
.svganim_player svg { #annotations .svganim_player svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.svganim_player.play:not(.loading) .controls { #annotations .svganim_player.play:not(.loading) .controls {
visibility: hidden; visibility: hidden;
} }
.svganim_player:hover .controls { #annotations .svganim_player:hover .controls {
visibility: visible !important; visibility: visible !important;
} }
</style> </style>
<script src="assets/nouislider-15.5.0.js"></script> <script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script> <script src="assets/wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script> <script src="annotate.js"></script>
<script src="playlist.js"></script> <script src="annotations.js"></script>
</head> </head>
<body> <body>
{% for tag in index.tags %} <div id="annotation_manager">
<details> <ul id="tags"></ul>
<summary> <div id="annotations"></div>
<h2>{{tag}} ({{len(index.tags[tag])}})</h2> </div>
</summary>
<ul>
{% for annotation in index.tags[tag] %}
<li>
<img src="/annotation/{{ annotation.id }}.svg" loading="lazy" id="img-{{ annotation_hash(annotation.id) }}">
<div class="play" id="annotation-{{ annotation_hash(annotation.id) }}"></div>
<script type='text/javascript'>
(function () {
let imgEl = document.getElementById('img-{{ annotation_hash(annotation.id) }}');
imgEl.addEventListener('click', () => {
imgEl.style.display = 'none';
new Annotator(
document.getElementById("annotation-{{ annotation_hash(annotation.id) }}"),
"tags.json",
"{{ annotation.getJsonUrl() }}",
{ is_player: true, crop_to_fit: true, autoplay: true }
);
})
})();
</script>
</li>
<!-- <li><img src="/annotation/{{ annotation.id }}.svg" data-audio="/annotation/{{ annotation.id }}.mp3"></li> -->
{% end %}
</ul>
</details>
{% end %}
<!-- <ul>
{% for annotation in index.annotations %}
<li>{{ annotation }}</li>
{% end %}
</ul> -->
<hr> <hr>
<a href="?refresh=1">Reload index</a> <a href="?refresh=1">Reload index</a>
</body> </body>
<script> <script>
let images = document.querySelectorAll('[data-audio]'); const am = new AnnotationManager(document.getElementById('annotation_manager'), 'tags.json');
for (const image of images) {
const audio = new Audio(image.dataset.audio);
console.log(image, audio);
image.addEventListener('mouseover', (e) => {
audio.play();
});
image.addEventListener('mouseout', (e) => {
audio.pause();
audio.currentTime = 0;
});
}
</script> </script>
</html> </html>

View file

@ -345,12 +345,21 @@ class TagAnnotationsHandler(tornado.web.RequestHandler):
self.metadir = os.path.join(self.config.storage, "metadata") self.metadir = os.path.join(self.config.storage, "metadata")
def get(self, tag): def get(self, tag):
if tag not in self.index.tags: if not self.index.has_tag(tag):
raise tornado.web.HTTPError(404) raise tornado.web.HTTPError(404)
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
annotations = self.index.tags[tag] # annotations = self.index.tags[tag]
self.write(json.dumps(list([a.id for a in annotations]))) # 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): class AnnotationHandler(tornado.web.RequestHandler):
@ -504,6 +513,13 @@ class AnnotationsHandler(tornado.web.RequestHandler):
with open(meta_file, "w") as fp: with open(meta_file, "w") as fp:
json.dump(self.json_args, 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): class IndexHandler(tornado.web.RequestHandler):
"""Get annotation as svg""" """Get annotation as svg"""

View file

@ -230,30 +230,30 @@ class Annotator extends EventTarget {
this.tagsEl = document.createElement('ul'); this.tagsEl = document.createElement('ul');
this.tagsEl.classList.add('tags'); this.tagsEl.classList.add('tags');
const addTags = (tags, tagsEl) => { const addTags = (tags, tagsEl) => {
Object.entries(tags).forEach(([tag, tagData]) => { tags.forEach((tag) => {
let tagLiEl = document.createElement('li'); let tagLiEl = document.createElement('li');
let tagEl = document.createElement('div'); let tagEl = document.createElement('div');
tagEl.classList.add('tag'); tagEl.classList.add('tag');
tagEl.dataset.tag = tag; tagEl.dataset.tag = tag.id;
tagEl.innerText = tagData.hasOwnProperty('fullname') ? tagData.fullname : tag; tagEl.innerText = tag.hasOwnProperty('name') ? tag.name : tag.id;
tagEl.addEventListener('click', (e) => { 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'); let signEl = document.createElement('span');
signEl.classList.add('annotation-' + tag); signEl.classList.add('annotation-' + tag.id);
signEl.style.backgroundColor = this.getColorForTag(tag); signEl.style.backgroundColor = this.getColorForTag(tag.id);
tagEl.prepend(signEl); tagEl.prepend(signEl);
tagLiEl.appendChild(tagEl); tagLiEl.appendChild(tagEl);
if (tagData.hasOwnProperty('sub')) { if (tag.hasOwnProperty('children')) {
const subEl = document.createElement('ul'); const subEl = document.createElement('ul');
subEl.classList.add('subtags'); subEl.classList.add('subtags');
addTags(tagData.sub, subEl); addTags(tag.children, subEl);
tagLiEl.appendChild(subEl); tagLiEl.appendChild(subEl);
} }
@ -304,14 +304,14 @@ class Annotator extends EventTarget {
} }
} }
getColorForTag(tag) { getColorForTag(tag_id) {
const tagData = this.tagMap[tag]; const tag = this.tagMap[tag_id];
console.log(tag, tagData); console.log(tag_id, tag);
if (tagData && tagData.hasOwnProperty('color')) { if (tag && tag.hasOwnProperty('color')) {
return tagData.color; return tag.color;
} }
if (tagData && tagData.hasOwnProperty('parent')) { if (tag && tag.hasOwnProperty('parent')) {
return this.getColorForTag(tagData['parent']); return this.getColorForTag(tag['parent'].id);
} }
return 'black'; return 'black';
} }
@ -794,15 +794,15 @@ class Annotator extends EventTarget {
const request = new Request(tagFile); const request = new Request(tagFile);
return fetch(request) return fetch(request)
.then(response => response.json()) .then(response => response.json())
.then(tags => { .then(rootTag => {
this.tags = tags; this.tags = rootTag.children;
this.tagMap = {}; this.tagMap = {};
const addTagsToMap = (tags, parent) => { const addTagsToMap = (tags, parent) => {
Object.entries(tags).forEach(([tag, tagData]) => { tags.forEach((tag) => {
tagData['parent'] = typeof parent != "undefined" ? parent : null; tag['parent'] = typeof parent != "undefined" ? parent : null;
this.tagMap[tag] = tagData; this.tagMap[tag.id] = tag;
if (tagData.hasOwnProperty("sub")) { if (tag.hasOwnProperty("children")) {
addTagsToMap(tagData.sub, tag); addTagsToMap(tag.children, tag);
} }
}); });
}; };

279
app/www/annotations.js Normal file
View file

@ -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)
}
}