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
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]
@ -549,10 +553,13 @@ class AnnotationIndex:
def annotations(self) -> dict[str, Annotation]:
return self.shelve["_annotations"]
def get_annotations(self, tag) -> list[Annotation]:
if tag not in self.tags:
def has_tag(self, tag):
return tag 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 [
@ -567,6 +574,14 @@ class AnnotationIndex:
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"<svganim.strokes.Tag {self.id}>"
my0 = Tag('root')
my1 = Tag('my1', 1, 0, parent=my0)
my2 = Tag('my2', 0, 2, parent=my0)
def get_color(self):
if self.color is None and self.parent is not None:
return self.parent.get_color()
return self.color
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 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
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
def annotation_hash(handler, input):
def annotation_hash(handler=None, input =""):
return md5(input.encode()).hexdigest()
# def nmbr(handler, lst) -> int:

View file

@ -10,16 +10,64 @@
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;
;
}
#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;
@ -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;
}
</style>
<script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script>
<script src="playlist.js"></script>
<script src="annotations.js"></script>
</head>
<body>
{% for tag in index.tags %}
<details>
<summary>
<h2>{{tag}} ({{len(index.tags[tag])}})</h2>
</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> -->
<div id="annotation_manager">
<ul id="tags"></ul>
<div id="annotations"></div>
</div>
<hr>
<a href="?refresh=1">Reload index</a>
</body>
<script>
let images = document.querySelectorAll('[data-audio]');
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;
});
}
const am = new AnnotationManager(document.getElementById('annotation_manager'), 'tags.json');
</script>
</html>

View file

@ -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"""

View file

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

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