Explorar o código

Merge branch 'ammod' into ammod-add-users-for-manual-annotations

Dimitri Korsch %!s(int64=3) %!d(string=hai) anos
pai
achega
bc26fe9489
Modificáronse 55 ficheiros con 2865 adicións e 172 borrados
  1. 5 2
      .gitignore
  2. 2 1
      .pylintrc
  3. 6 0
      app.py
  4. 964 0
      labels/LepiForum_PandasVersion/LepiForum_Species_edited_by_GBrehm.csv
  5. 133 0
      labels/LepiForum_PandasVersion/Provider.py
  6. 16 0
      labels/LepiForum_PandasVersion/configuration1.json
  7. 16 0
      labels/LepiForum_PandasVersion/configuration2.json
  8. 16 0
      labels/LepiForum_PandasVersion/configuration3.json
  9. 14 0
      labels/LepiForum_PandasVersion/configuration4.json
  10. 4 0
      notebooks/.gitignore
  11. 888 0
      notebooks/show_results.ipynb
  12. 12 4
      pycs/__init__.py
  13. 1 1
      pycs/database/Collection.py
  14. 40 16
      pycs/database/File.py
  15. 5 0
      pycs/database/Label.py
  16. 2 2
      pycs/database/LabelProvider.py
  17. 1 1
      pycs/database/Model.py
  18. 15 4
      pycs/database/Project.py
  19. 0 16
      pycs/database/util/JSONEncoder.py
  20. 13 6
      pycs/frontend/WebServer.py
  21. 14 4
      pycs/frontend/endpoints/data/GetPreviousAndNextFile.py
  22. 147 0
      pycs/frontend/endpoints/pipelines/EstimateBoundingBox.py
  23. 10 0
      pycs/frontend/endpoints/pipelines/PredictModel.py
  24. 14 2
      pycs/frontend/endpoints/projects/ListProjectFiles.py
  25. 3 1
      pycs/frontend/endpoints/results/ConfirmResult.py
  26. 51 0
      pycs/frontend/endpoints/results/CopyResults.py
  27. 1 2
      pycs/frontend/notifications/NotificationManager.py
  28. 0 31
      pycs/frontend/util/JSONEncoder.py
  29. 0 0
      pycs/frontend/util/__init__.py
  30. 3 1
      pycs/interfaces/MediaImageLabel.py
  31. 2 1
      pycs/interfaces/Pipeline.py
  32. 1 1
      pycs/jobs/JobRunner.py
  33. 0 17
      pycs/jobs/util/JSONEncoder.py
  34. 0 0
      pycs/jobs/util/__init__.py
  35. 7 0
      pycs/management/__init__.py
  36. 43 0
      pycs/management/project.py
  37. 60 0
      pycs/management/result.py
  38. 21 1
      pycs/util/FileOperations.py
  39. 22 0
      pycs/util/JSONEncoder.py
  40. 1 1
      pycs/util/PipelineUtil.py
  41. 2 1
      pycs/util/ProgressFileWriter.py
  42. 2 0
      requirements.txt
  43. 1 1
      settings.json
  44. 64 0
      webui/src/assets/icons/double-chevron-left.svg
  45. 64 0
      webui/src/assets/icons/double-chevron-right.svg
  46. 9 1
      webui/src/components/media/annotated-image.vue
  47. 8 0
      webui/src/components/media/annotation-overlay.vue
  48. 5 6
      webui/src/components/media/cropped-image.vue
  49. 5 5
      webui/src/components/media/label-selector.vue
  50. 46 9
      webui/src/components/media/media-control.vue
  51. 12 10
      webui/src/components/media/options-bar.vue
  52. 86 19
      webui/src/components/media/paginated-media.vue
  53. 0 2
      webui/src/components/other/LabelTreeView.vue
  54. 6 2
      webui/src/components/projects/project-data-view-window.vue
  55. 2 1
      webui/src/components/projects/project-open-window.vue

+ 5 - 2
.gitignore

@@ -37,8 +37,8 @@ htmlcov/
 projects
 projects
 db
 db
 external_data
 external_data
-/models/
-/labels/
+models*
+labels*
 dist/
 dist/
 
 
 .htpasswd
 .htpasswd
@@ -48,3 +48,6 @@ dist/
 *.sqlite-journal
 *.sqlite-journal
 *.sqlite3
 *.sqlite3
 *.sqlite3-journal
 *.sqlite3-journal
+
+output*.json
+settings.local.json

+ 2 - 1
.pylintrc

@@ -155,7 +155,8 @@ disable=print-statement,
         comprehension-escape,
         comprehension-escape,
         duplicate-code,
         duplicate-code,
         missing-module-docstring,
         missing-module-docstring,
-        too-many-instance-attributes
+        too-many-instance-attributes,
+        no-member
 
 
 # Enable the message, report, category or checker with the given id(s). You can
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
 # either give multiple identifier separated by comma (,) or put this option

+ 6 - 0
app.py

@@ -1,9 +1,15 @@
 #!/usr/bin/env python
 #!/usr/bin/env python
 
 
+import logging.config
+
 from pycs import app
 from pycs import app
 from pycs import htpasswd
 from pycs import htpasswd
 from pycs import settings
 from pycs import settings
 from pycs.frontend.WebServer import WebServer
 from pycs.frontend.WebServer import WebServer
+from pycs.management import setup_commands
+
+logging.config.dictConfig(settings.logging)
+setup_commands(app)
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
     server = WebServer(app, htpasswd, settings)
     server = WebServer(app, htpasswd, settings)

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 964 - 0
labels/LepiForum_PandasVersion/LepiForum_Species_edited_by_GBrehm.csv


+ 133 - 0
labels/LepiForum_PandasVersion/Provider.py

@@ -0,0 +1,133 @@
+import re
+import numpy as np
+import pandas as pd
+import typing as T
+
+from pathlib import Path
+from munch import munchify
+
+from pycs import app
+from pycs.interfaces.LabelProvider import LabelProvider
+
+class Provider(LabelProvider):
+
+    names = [
+        'is_local',
+        'rarity',
+        'super_family',
+        'family',
+        'sub_family',
+        'tribe',
+        'german',
+        'swiss',
+        'austrian',
+        'kr_nr',
+        'genus',
+        'species',
+        'authors',
+        'comment',
+        'remove_me',
+        'changed',
+        'version1_comment',
+        'misc', # 'D-CH-A / non-KR / Kaukasus',
+        'german_name',
+    ]
+
+    dtype = {
+        'is_local': pd.CategoricalDtype(['nur lokal', 'tagaktiv']),
+        'rarity': np.float32,
+        'super_family': "category",
+        'family': "category",
+        'sub_family': "category",
+        'tribe': "category",
+        'german': pd.CategoricalDtype(['D', 'e', '?']),
+        'swiss': pd.CategoricalDtype(['C', 'e', '?']),
+        'austrian': pd.CategoricalDtype(['A', 'e', '?']),
+        'kr_nr': "object",
+        'genus': "category",
+        'species': "category",
+        'authors': "object",
+        'comment': "object",
+        'remove_me': "category",
+        'changed': "object",
+        'version1_comment': "object",
+        'misc': "object",
+        'german_name': str,
+    }
+
+    KR_REGEX = re.compile(r"^[\d\-a-zA-Z]+")
+
+
+    def __init__(self, root_folder: str, configuration: T.Dict):
+        config = munchify(configuration)
+        self.root = Path(root_folder)
+
+        self.label_file = self.root / config.filename
+        self.min_rarity = config.minimumRarity
+        self.hierarchy_levels = config.hierarchyLevels
+        self.only_german = config.onlyGerman
+
+    def close(self):
+        pass
+
+    def get_labels(self) -> T.List[dict]:
+        result = []
+
+        lepi_list = pd.read_csv(self.label_file,
+                        names=self.names,
+                        dtype=self.dtype,
+                        sep="\t", header=0
+                       )
+        app.logger.info(f"Found {len(lepi_list)} labels in {self.label_file}")
+
+        if self.min_rarity is not None:
+            mask = lepi_list.rarity >= self.min_rarity
+            lepi_list = lepi_list[mask]
+            app.logger.info(f"Labels {len(lepi_list):,d} with {self.min_rarity=}")
+
+        if self.only_german:
+            mask = (
+                lepi_list.german.eq("D") |
+                lepi_list.austrian.eq("A") |
+                lepi_list.swiss.eq("C")
+                ) & \
+                lepi_list["remove_me"].isin([np.nan])
+
+            lepi_list = lepi_list[mask]
+            app.logger.info(f"Labels {len(lepi_list):,d} for german-speaking countries")
+
+
+        parents = set()
+        for i, entry in lepi_list.iterrows():
+            parent_reference = None
+
+            for level, level_name in self.hierarchy_levels:
+                level_entry = entry[level]
+                if level_entry is None:
+                    continue
+
+                reference, name = f'{level}_{level_entry.lower()}', level_entry
+
+                # parents should be added once
+                if reference not in parents:
+                    result.append(self.create_label(reference, name, parent_reference, level_name))
+                    parents.add(reference)
+
+                parent_reference = reference
+
+
+            # add label itself
+            if self.KR_REGEX.match(entry.kr_nr):
+                name = f'{entry.genus} {entry.species} ({entry.kr_nr})'
+                reference = entry.kr_nr
+
+            else:
+                name = f'{entry.genus} {entry.species}'
+                reference = f'_{name.lower()}'
+            result.append(self.create_label(reference, name, parent_reference))
+
+
+        app.logger.info(f"Finally, provided {len(result):,d} labels")
+        return result
+
+

+ 16 - 0
labels/LepiForum_PandasVersion/configuration1.json

@@ -0,0 +1,16 @@
+{
+  "name": "LepiForum (Alle Spezies)",
+  "description": "Stand: 01.12.2021, bearbeitet GBrehm",
+  "code": {
+    "module": "Provider",
+    "class": "Provider"
+  },
+
+  "filename": "LepiForum_Species_edited_by_GBrehm.csv",
+  "minimumRarity": null,
+  "onlyGerman": false,
+  "hierarchyLevels": [
+    ["family", "Familie"],
+    ["genus", "Gattung"]
+  ]
+}

+ 16 - 0
labels/LepiForum_PandasVersion/configuration2.json

@@ -0,0 +1,16 @@
+{
+  "name": "LepiForum (Alle Spezies aus D/A/CH)",
+  "description": "Stand: 01.12.2021, bearbeitet GBrehm",
+  "code": {
+    "module": "Provider",
+    "class": "Provider"
+  },
+
+  "filename": "LepiForum_Species_edited_by_GBrehm.csv",
+  "minimumRarity": null,
+  "onlyGerman": true,
+  "hierarchyLevels": [
+    ["family", "Familie"],
+    ["genus", "Gattung"]
+  ]
+}

+ 16 - 0
labels/LepiForum_PandasVersion/configuration3.json

@@ -0,0 +1,16 @@
+{
+  "name": "LepiForum (Nur häufige Spezies aus D/A/CH)",
+  "description": "Stand: 01.12.2021, bearbeitet GBrehm",
+  "code": {
+    "module": "Provider",
+    "class": "Provider"
+  },
+
+  "filename": "LepiForum_Species_edited_by_GBrehm.csv",
+  "minimumRarity": 0,
+  "onlyGerman": true,
+  "hierarchyLevels": [
+    ["family", "Familie"],
+    ["genus", "Gattung"]
+  ]
+}

+ 14 - 0
labels/LepiForum_PandasVersion/configuration4.json

@@ -0,0 +1,14 @@
+{
+  "name": "LepiForum (Alle Spezies aus D/A/CH, ohne Hierarchie)",
+  "description": "Stand: 01.12.2021, bearbeitet GBrehm",
+  "code": {
+    "module": "Provider",
+    "class": "Provider"
+  },
+
+  "filename": "LepiForum_Species_edited_by_GBrehm.csv",
+  "minimumRarity": null,
+  "onlyGerman": true,
+  "hierarchyLevels": [
+  ]
+}

+ 4 - 0
notebooks/.gitignore

@@ -0,0 +1,4 @@
+export*
+.ipynb_checkpoints
+*.zip
+*.tar*

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 888 - 0
notebooks/show_results.ipynb


+ 12 - 4
pycs/__init__.py

@@ -9,24 +9,29 @@ from munch import munchify
 from pathlib import Path
 from pathlib import Path
 
 
 from flask import Flask
 from flask import Flask
+from flask_htpasswd import HtPasswdAuth
 from flask_migrate import Migrate
 from flask_migrate import Migrate
 from flask_sqlalchemy import SQLAlchemy
 from flask_sqlalchemy import SQLAlchemy
 from sqlalchemy import event
 from sqlalchemy import event
 from sqlalchemy import pool
 from sqlalchemy import pool
 from sqlalchemy.engine import Engine
 from sqlalchemy.engine import Engine
 
 
-from flask_htpasswd import HtPasswdAuth
+from pycs.util.JSONEncoder import JSONEncoder
+
 
 
-print('=== Loading settings ===')
-with open('settings.json') as file:
+settings_file = os.environ.get("PYCS_SETTINGS", "settings.json")
+
+print(f'=== Loading settings from "{settings_file}" ===')
+with open(settings_file, encoding='utf8') as file:
     settings = munchify(json.load(file))
     settings = munchify(json.load(file))
 
 
 # create projects folder
 # create projects folder
 if not os.path.exists(settings.projects_folder):
 if not os.path.exists(settings.projects_folder):
-    os.mkdir(settings.projects_folder)
+    os.mkdir(settings.projects_folder) # pragma: no-cover
 
 
 DB_FILE = Path.cwd() / settings.database
 DB_FILE = Path.cwd() / settings.database
 
 
+
 app = Flask(__name__)
 app = Flask(__name__)
 app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_FILE}"
 app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_FILE}"
 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
@@ -38,6 +43,9 @@ if not os.path.isfile(app.config['FLASK_HTPASSWD_PATH']):
 app.config['FLASK_SECRET'] = 'Hey Hey Kids, secure me!'
 app.config['FLASK_SECRET'] = 'Hey Hey Kids, secure me!'
 htpasswd = HtPasswdAuth(app)
 htpasswd = HtPasswdAuth(app)
 
 
+# set json encoder so database objects are serialized correctly
+app.json_encoder = JSONEncoder
+
 # pylint: disable=unused-argument
 # pylint: disable=unused-argument
 @event.listens_for(Engine, "connect")
 @event.listens_for(Engine, "connect")
 def set_sqlite_pragma(dbapi_connection, connection_record):
 def set_sqlite_pragma(dbapi_connection, connection_record):

+ 1 - 1
pycs/database/Collection.py

@@ -58,7 +58,7 @@ class Collection(NamedBaseModel):
 
 
         # pylint: disable=import-outside-toplevel, cyclic-import
         # pylint: disable=import-outside-toplevel, cyclic-import
         from pycs.database.File import File
         from pycs.database.File import File
-        return self.files.filter(*filters).order_by(File.id).offset(offset).limit(limit)
+        return self.files.filter(*filters).order_by(File.path).offset(offset).limit(limit)
 
 
     # pylint: disable=too-many-arguments
     # pylint: disable=too-many-arguments
     @commit_on_return
     @commit_on_return

+ 40 - 16
pycs/database/File.py

@@ -66,6 +66,7 @@ class File(NamedBaseModel):
         "created",
         "created",
         "path",
         "path",
         "frames",
         "frames",
+        "has_annotations",
         "fps",
         "fps",
         "project_id",
         "project_id",
         "collection_id",
         "collection_id",
@@ -76,6 +77,11 @@ class File(NamedBaseModel):
         """ filename consisting of a name and an extension """
         """ filename consisting of a name and an extension """
         return f"{self.name}{self.extension}"
         return f"{self.name}{self.extension}"
 
 
+    @property
+    def has_annotations(self):
+        """ check if there are any referenced results """
+        return self.results.count() != 0
+
     @property
     @property
     def absolute_path(self) -> str:
     def absolute_path(self) -> str:
         """ returns an absolute of the file """
         """ returns an absolute of the file """
@@ -128,26 +134,37 @@ class File(NamedBaseModel):
         collection = Collection.query.filter_by(reference=collection_reference).one()
         collection = Collection.query.filter_by(reference=collection_reference).one()
         self.collection_id = collection.id
         self.collection_id = collection.id
 
 
-    def _get_another_file(self, *query) -> T.Optional[File]:
+    def _get_another_file(self, *query, with_annotations=None) -> T.Optional[File]:
         """
         """
         get the first file matching the query ordered by descending id
         get the first file matching the query ordered by descending id
 
 
         :return: another file or None
         :return: another file or None
         """
         """
-        return File.query.filter(File.project_id == self.project_id, *query)
+        result = File.query.filter(File.project_id == self.project_id, *query)
+
+        if with_annotations is None:
+            return result
+
+        annot_query = File.results.any()
 
 
-    def next(self) -> T.Optional[File]:
+        if with_annotations == False:
+            annot_query = ~annot_query
+
+        return result.filter(annot_query)
+
+    def next(self, **kwargs) -> T.Optional[File]:
         """
         """
         get the successor of this file
         get the successor of this file
 
 
         :return: another file or None
         :return: another file or None
         """
         """
 
 
-        return self._get_another_file(File.id > self.id)\
-            .order_by(File.id).first()
+        res = self._get_another_file(File.path > self.path, **kwargs)\
+            .order_by(File.path)
+        return res.first()
 
 
 
 
-    def previous(self) -> T.Optional[File]:
+    def previous(self, **kwargs) -> T.Optional[File]:
         """
         """
         get the predecessor of this file
         get the predecessor of this file
 
 
@@ -155,22 +172,23 @@ class File(NamedBaseModel):
         """
         """
 
 
         # pylint: disable=no-member
         # pylint: disable=no-member
-        return self._get_another_file(File.id < self.id)\
-            .order_by(File.id.desc()).first()
+        res = self._get_another_file(File.path < self.path, **kwargs)\
+            .order_by(File.path.desc())
+        return res.first()
 
 
 
 
-    def next_in_collection(self) -> T.Optional[File]:
+    def next_in_collection(self, **kwargs) -> T.Optional[File]:
         """
         """
         get the predecessor of this file
         get the predecessor of this file
 
 
         :return: another file or None
         :return: another file or None
         """
         """
         return self._get_another_file(
         return self._get_another_file(
-            File.id > self.id, File.collection_id == self.collection_id)\
-            .order_by(File.id).first()
+            File.path > self.path, File.collection_id == self.collection_id, **kwargs)\
+            .order_by(File.path).first()
 
 
 
 
-    def previous_in_collection(self) -> T.Optional[File]:
+    def previous_in_collection(self, **kwargs) -> T.Optional[File]:
         """
         """
         get the predecessor of this file
         get the predecessor of this file
 
 
@@ -179,8 +197,8 @@ class File(NamedBaseModel):
 
 
         # pylint: disable=no-member
         # pylint: disable=no-member
         return self._get_another_file(
         return self._get_another_file(
-            File.id < self.id, File.collection_id == self.collection_id)\
-            .order_by(File.id.desc()).first()
+            File.path < self.path, File.collection_id == self.collection_id, **kwargs)\
+            .order_by(File.path.desc()).first()
 
 
 
 
     def result(self, identifier: int) -> T.Optional[Result]:
     def result(self, identifier: int) -> T.Optional[Result]:
@@ -197,7 +215,7 @@ class File(NamedBaseModel):
                       origin: str,
                       origin: str,
                       result_type: str,
                       result_type: str,
                       origin_user: str = None,
                       origin_user: str = None,
-                      label: T.Optional[T.Union[Label, int]] = None,
+                      label: T.Optional[T.Union[Label, int, str]] = None,
                       data: T.Optional[dict] = None) -> Result:
                       data: T.Optional[dict] = None) -> Result:
         """
         """
         Creates a result and returns the created object
         Creates a result and returns the created object
@@ -217,7 +235,13 @@ class File(NamedBaseModel):
         result.data = data
         result.data = data
 
 
         if label is not None:
         if label is not None:
-            assert isinstance(label, (int, Label)), f"Wrong label type: {type(label)}"
+            assert isinstance(label, (int, Label, str)), \
+                f"Label \"{label}\" has invalid type: {type(label)}"
+
+            if isinstance(label, str):
+                label = Label.query.filter(
+                    Label.project_id == self.project_id,
+                    Label.reference == label).one_or_none()
 
 
             if isinstance(label, Label):
             if isinstance(label, Label):
                 label = label.id
                 label = label.id

+ 5 - 0
pycs/database/Label.py

@@ -66,11 +66,16 @@ class Label(NamedBaseModel):
     serialize_only = NamedBaseModel.serialize_only + (
     serialize_only = NamedBaseModel.serialize_only + (
         "project_id",
         "project_id",
         "parent_id",
         "parent_id",
+        "parent_reference",
         "reference",
         "reference",
         "hierarchy_level",
         "hierarchy_level",
         # "children",
         # "children",
     )
     )
 
 
+    @property
+    def parent_reference(self):
+        return None if self.parent is None else self.parent.reference
+
     @commit_on_return
     @commit_on_return
     def set_parent(self, parent: T.Optional[T.Union[int, str, Label]] = None) -> None:
     def set_parent(self, parent: T.Optional[T.Union[int, str, Label]] = None) -> None:
 
 

+ 2 - 2
pycs/database/LabelProvider.py

@@ -40,7 +40,7 @@ class LabelProvider(NamedBaseModel):
         """
         """
 
 
         for folder, conf_path in _find_files(root):
         for folder, conf_path in _find_files(root):
-            with open(conf_path) as conf_file:
+            with open(conf_path, encoding='utf8') as conf_file:
                 config = json.load(conf_file)
                 config = json.load(conf_file)
 
 
             provider, _ = cls.get_or_create(
             provider, _ = cls.get_or_create(
@@ -73,7 +73,7 @@ class LabelProvider(NamedBaseModel):
         :return: LabelProvider instance
         :return: LabelProvider instance
         """
         """
         # load configuration.json
         # load configuration.json
-        with open(self.configuration_file_path) as configuration_file:
+        with open(self.configuration_file_path, encoding='utf8') as configuration_file:
             configuration = json.load(configuration_file)
             configuration = json.load(configuration_file)
 
 
         # load code
         # load code

+ 1 - 1
pycs/database/Model.py

@@ -37,7 +37,7 @@ class Model(NamedBaseModel):
             and stores them in the database
             and stores them in the database
         """
         """
         for folder in Path(root).glob("*"):
         for folder in Path(root).glob("*"):
-            with open(folder / config_name) as config_file:
+            with open(folder / config_name, encoding='utf8') as config_file:
                 config = json.load(config_file)
                 config = json.load(config_file)
 
 
             # extract data
             # extract data

+ 15 - 4
pycs/database/Project.py

@@ -107,7 +107,7 @@ class Project(NamedBaseModel):
         """
         """
         return self.labels.filter(Label.reference == reference).one_or_none()
         return self.labels.filter(Label.reference == reference).one_or_none()
 
 
-    def file(self, identifier: int) -> T.Optional[Label]:
+    def file(self, identifier: int) -> T.Optional[File]:
         """
         """
         get a file using its unique identifier
         get a file using its unique identifier
 
 
@@ -174,6 +174,9 @@ class Project(NamedBaseModel):
                 - AssertionError if project_id and reference are not unique
                 - AssertionError if project_id and reference are not unique
                 - ValueError if a cycle in the hierarchy is found
                 - ValueError if a cycle in the hierarchy is found
         """
         """
+        if len(labels) == 0:
+            return labels
+
         if clean_old_labels:
         if clean_old_labels:
             self.labels.delete()
             self.labels.delete()
 
 
@@ -205,7 +208,7 @@ class Project(NamedBaseModel):
     def __check_labels(self, labels):
     def __check_labels(self, labels):
         """ check labels for unique keys and cycles """
         """ check labels for unique keys and cycles """
 
 
-        unique_keys = dict()
+        unique_keys = {}
 
 
         for label in labels:
         for label in labels:
             key = (label["project_id"], label["reference"])
             key = (label["project_id"], label["reference"])
@@ -289,7 +292,8 @@ class Project(NamedBaseModel):
 
 
         return file, is_new
         return file, is_new
 
 
-    def get_files(self, *filters, offset: int = 0, limit: int = -1) -> T.List[File]:
+    def get_files(self, *filters, offset: int = 0, limit: int = -1,
+                  with_annotations: T.Optional[bool] = None) -> T.List[File]:
         """
         """
         get an iterator of files associated with this project
         get an iterator of files associated with this project
 
 
@@ -297,8 +301,15 @@ class Project(NamedBaseModel):
         :param limit: file limit
         :param limit: file limit
         :return: iterator of files
         :return: iterator of files
         """
         """
+        if with_annotations is not None:
+            annot_query = File.results.any()
+
+            if with_annotations == False:
+                annot_query = ~annot_query
+
+            filters = filters + (annot_query,)
 
 
-        return self.files.filter(*filters).order_by(File.id).offset(offset).limit(limit)
+        return self.files.filter(*filters).order_by(File.path).offset(offset).limit(limit)
 
 
     def _files_without_results(self):
     def _files_without_results(self):
         """
         """

+ 0 - 16
pycs/database/util/JSONEncoder.py

@@ -1,16 +0,0 @@
-from typing import Any
-
-from flask.json import JSONEncoder as Base
-
-from pycs.database.base import BaseModel
-
-class JSONEncoder(Base):
-    """
-    prepares database objects to be json encoded
-    """
-
-    def default(self, o: Any) -> Any:
-        if isinstance(o, BaseModel):
-            return o.serialize()
-
-        return o.__dict__.copy()

+ 13 - 6
pycs/frontend/WebServer.py

@@ -1,4 +1,3 @@
-import logging.config
 import typing as T
 import typing as T
 
 
 from glob import glob
 from glob import glob
@@ -10,8 +9,8 @@ import socketio
 
 
 from flask import send_from_directory
 from flask import send_from_directory
 
 
-from pycs.database.Model import Model
 from pycs.database.LabelProvider import LabelProvider
 from pycs.database.LabelProvider import LabelProvider
+from pycs.database.Model import Model
 from pycs.frontend.endpoints.ListJobs import ListJobs
 from pycs.frontend.endpoints.ListJobs import ListJobs
 from pycs.frontend.endpoints.ListLabelProviders import ListLabelProviders
 from pycs.frontend.endpoints.ListLabelProviders import ListLabelProviders
 from pycs.frontend.endpoints.ListModels import ListModels
 from pycs.frontend.endpoints.ListModels import ListModels
@@ -31,6 +30,7 @@ from pycs.frontend.endpoints.labels.EditLabelParent import EditLabelParent
 from pycs.frontend.endpoints.labels.ListLabelTree import ListLabelTree
 from pycs.frontend.endpoints.labels.ListLabelTree import ListLabelTree
 from pycs.frontend.endpoints.labels.ListLabels import ListLabels
 from pycs.frontend.endpoints.labels.ListLabels import ListLabels
 from pycs.frontend.endpoints.labels.RemoveLabel import RemoveLabel
 from pycs.frontend.endpoints.labels.RemoveLabel import RemoveLabel
+from pycs.frontend.endpoints.pipelines.EstimateBoundingBox import EstimateBoundingBox
 from pycs.frontend.endpoints.pipelines.FitModel import FitModel
 from pycs.frontend.endpoints.pipelines.FitModel import FitModel
 from pycs.frontend.endpoints.pipelines.PredictBoundingBox import PredictBoundingBox
 from pycs.frontend.endpoints.pipelines.PredictBoundingBox import PredictBoundingBox
 from pycs.frontend.endpoints.pipelines.PredictFile import PredictFile
 from pycs.frontend.endpoints.pipelines.PredictFile import PredictFile
@@ -45,6 +45,7 @@ from pycs.frontend.endpoints.projects.ListProjectCollections import ListProjectC
 from pycs.frontend.endpoints.projects.ListProjectFiles import ListProjectFiles
 from pycs.frontend.endpoints.projects.ListProjectFiles import ListProjectFiles
 from pycs.frontend.endpoints.projects.RemoveProject import RemoveProject
 from pycs.frontend.endpoints.projects.RemoveProject import RemoveProject
 from pycs.frontend.endpoints.results.ConfirmResult import ConfirmResult
 from pycs.frontend.endpoints.results.ConfirmResult import ConfirmResult
+from pycs.frontend.endpoints.results.CopyResults import CopyResults
 from pycs.frontend.endpoints.results.CreateResult import CreateResult
 from pycs.frontend.endpoints.results.CreateResult import CreateResult
 from pycs.frontend.endpoints.results.EditResultData import EditResultData
 from pycs.frontend.endpoints.results.EditResultData import EditResultData
 from pycs.frontend.endpoints.results.EditResultLabel import EditResultLabel
 from pycs.frontend.endpoints.results.EditResultLabel import EditResultLabel
@@ -53,7 +54,6 @@ from pycs.frontend.endpoints.results.GetResults import GetResults
 from pycs.frontend.endpoints.results.RemoveResult import RemoveResult
 from pycs.frontend.endpoints.results.RemoveResult import RemoveResult
 from pycs.frontend.endpoints.results.ResetResults import ResetResults
 from pycs.frontend.endpoints.results.ResetResults import ResetResults
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 from pycs.frontend.notifications.NotificationManager import NotificationManager
-from pycs.frontend.util.JSONEncoder import JSONEncoder
 from pycs.jobs.JobRunner import JobRunner
 from pycs.jobs.JobRunner import JobRunner
 from pycs.util.PipelineCache import PipelineCache
 from pycs.util.PipelineCache import PipelineCache
 
 
@@ -67,11 +67,8 @@ class WebServer:
 
 
     def __init__(self, app, htpasswd, settings: munch.Munch, discovery: bool = True):
     def __init__(self, app, htpasswd, settings: munch.Munch, discovery: bool = True):
 
 
-        logging.config.dictConfig(settings.logging)
         self.app = app
         self.app = app
         self.htpasswd = htpasswd
         self.htpasswd = htpasswd
-        # set json encoder so database objects are serialized correctly
-        self.app.json_encoder = JSONEncoder
 
 
         # initialize web server
         # initialize web server
         if self.is_production:
         if self.is_production:
@@ -290,6 +287,10 @@ class WebServer:
             view_func=self.htpasswd.required( CreateResult.as_view('create_result',
             view_func=self.htpasswd.required( CreateResult.as_view('create_result',
                 self.notifications) )
                 self.notifications) )
         )
         )
+        self.app.add_url_rule(
+            '/data/<int:file_id>/copy_results',
+            view_func=CopyResults.as_view('copy_results', self.notifications)
+        )
         self.app.add_url_rule(
         self.app.add_url_rule(
             '/data/<int:file_id>/reset',
             '/data/<int:file_id>/reset',
             view_func=self.htpasswd.required( ResetResults.as_view('reset_results',
             view_func=self.htpasswd.required( ResetResults.as_view('reset_results',
@@ -377,6 +378,12 @@ class WebServer:
                 self.notifications, self.jobs, self.pipelines) )
                 self.notifications, self.jobs, self.pipelines) )
         )
         )
 
 
+        self.app.add_url_rule(
+            '/data/<int:file_id>/estimate',
+            view_func=EstimateBoundingBox.as_view('estimate_result', self.notifications,
+                                          self.jobs)
+        )
+
     def run(self):
     def run(self):
         """ start web server """
         """ start web server """
         self.pipelines.start()
         self.pipelines.start()

+ 14 - 4
pycs/frontend/endpoints/data/GetPreviousAndNextFile.py

@@ -1,4 +1,5 @@
 from flask import jsonify
 from flask import jsonify
+from flask import request
 from flask.views import View
 from flask.views import View
 
 
 from pycs.database.File import File
 from pycs.database.File import File
@@ -16,12 +17,21 @@ class GetPreviousAndNextFile(View):
         # get file from database
         # get file from database
         file = File.get_or_404(file_id)
         file = File.get_or_404(file_id)
 
 
+
+        with_annotations = request.args.get("only_with_annotations")
+
+        kwargs = dict(with_annotations=None)
+
+        if with_annotations is not None:
+            kwargs["with_annotations"] = with_annotations == "1"
+
         # get previous and next
         # get previous and next
         result = {
         result = {
-            'previous': file.previous(),
-            'next': file.next(),
-            'previousInCollection': file.previous_in_collection(),
-            'nextInCollection': file.next_in_collection()
+            'current': file,
+            'previous': file.previous(**kwargs),
+            'next': file.next(**kwargs),
+            'previousInCollection': file.previous_in_collection(**kwargs),
+            'nextInCollection': file.next_in_collection(**kwargs)
         }
         }
 
 
         # return data
         # return data

+ 147 - 0
pycs/frontend/endpoints/pipelines/EstimateBoundingBox.py

@@ -0,0 +1,147 @@
+import cv2
+import uuid
+import numpy as np
+import typing as T
+
+from flask import abort
+from flask import make_response
+from flask import request
+from flask.views import View
+
+from pycs import db
+from pycs.database.File import File
+from pycs.database.Result import Result
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+from pycs.jobs.JobGroupBusyException import JobGroupBusyException
+from pycs.jobs.JobRunner import JobRunner
+
+class EstimateBoundingBox(View):
+    """
+    create a result for a file
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, nm: NotificationManager, jobs: JobRunner,):
+        # pylint: disable=invalid-name
+        self.nm = nm
+        self.jobs = jobs
+
+    def dispatch_request(self, file_id: int):
+
+        file = File.get_or_404(file_id)
+        request_data = request.get_json(force=True)
+        if 'x' not in request_data or 'y' not in request_data:
+            abort(400, "coordinates for the estimation are missing")
+
+        x,y = map(request_data.get, "xy")
+
+        # get project
+        project = file.project
+        try:
+            rnd = str(uuid.uuid4())[:10]
+            self.jobs.run(project,
+                          "Estimation",
+                          f'{project.name} (create predictions)',
+                          f"{project.id}/estimation/{rnd}",
+                          estimate,
+                          file.id, x, y,
+                          result=self.nm.create_result
+                          )
+
+        except JobGroupBusyException:
+            abort(400, "Job is already running!")
+
+        return make_response()
+
+
+def estimate(file_id: int, x: float, y: float) -> Result:
+    file = File.query.get(file_id)
+
+    im = cv2.imread(file.absolute_path, cv2.IMREAD_GRAYSCALE)
+
+    h, w = im.shape
+    pos = int(x * w), int(y * h)
+    x0, y0, x1, y1 = detect(im, pos,
+                            window_size=1000,
+                            pixel_delta=50,
+                            enlarge=1e-2,
+                           )
+
+    data = dict(
+       x=x0 / w,
+       y=y0 / h,
+       w=(x1-x0) / w,
+       h=(y1-y0) / h
+    )
+
+    return file.create_result('pipeline', 'bounding-box', label=None, data=data)
+
+def detect(im: np.ndarray,
+           pos: T.Tuple[int, int],
+           window_size: int = 1000,
+           pixel_delta: int = 0,
+           enlarge: float = -1) -> T.Tuple[int, int, int, int]:
+    # im = blur(im, 3)
+    x, y = pos
+    pixel = im[y, x]
+
+    min_pix, max_pix = pixel - pixel_delta, pixel + pixel_delta
+
+    mask = np.logical_and(min_pix < im, im < max_pix).astype(np.float32)
+    # mask = open_close(mask)
+    # mask = blur(mask)
+
+    pad = window_size // 2
+    mask = np.pad(mask, pad, mode="constant")
+    window = mask[y: y + window_size, x: x + window_size]
+
+    sum_x, sum_y = window.sum(axis=0), window.sum(axis=1)
+
+    enlarge = int(enlarge * max(im.shape))
+    (x0, x1), (y0, y1) = get_borders(sum_x, enlarge), get_borders(sum_y, enlarge)
+
+    x0 = max(x + x0 - pad, 0)
+    y0 = max(y + y0 - pad, 0)
+
+    x1 = min(x + x1 - pad, im.shape[1])
+    y1 = min(y + y1 - pad, im.shape[0])
+
+    return x0, y0, x1, y1
+
+def get_borders(arr, enlarge: int, eps=5e-1):
+    mid = len(arr) // 2
+
+    arr0, arr1 = arr[:mid], arr[mid:]
+
+    thresh = arr[mid] * eps
+
+    lowers = np.where(arr0 < thresh)[0]
+    lower = 0 if len(lowers) == 0 else lowers[-1]
+
+    uppers = np.where(arr1 < thresh)[0]
+    upper = arr1.argmin() if len(uppers) == 0 else uppers[0]
+
+    # since the second half starts after the first
+    upper = len(arr0) + upper
+
+    if enlarge > 0:
+        lower = max(lower - enlarge, 0)
+        upper = min(upper + enlarge, len(arr)-1)
+
+    return int(lower), int(upper)
+
+
+"""
+def blur(im, sigma=5):
+    from skimage import filters
+    return filters.gaussian(im, sigma=sigma, preserve_range=True)
+
+def open_close(im, kernel_size=3):
+
+    kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
+
+    im = cv2.morphologyEx(im, cv2.MORPH_OPEN, kernel)
+    im = cv2.morphologyEx(im, cv2.MORPH_CLOSE, kernel)
+    return im
+"""

+ 10 - 0
pycs/frontend/endpoints/pipelines/PredictModel.py

@@ -12,6 +12,7 @@ from pycs.database.Result import Result
 from pycs.frontend.notifications.NotificationList import NotificationList
 from pycs.frontend.notifications.NotificationList import NotificationList
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 from pycs.interfaces.MediaFile import MediaFile
 from pycs.interfaces.MediaFile import MediaFile
+from pycs.interfaces.MediaLabel import MediaLabel
 from pycs.interfaces.MediaBoundingBox import MediaBoundingBox
 from pycs.interfaces.MediaBoundingBox import MediaBoundingBox
 from pycs.interfaces.MediaStorage import MediaStorage
 from pycs.interfaces.MediaStorage import MediaStorage
 from pycs.jobs.JobGroupBusyException import JobGroupBusyException
 from pycs.jobs.JobGroupBusyException import JobGroupBusyException
@@ -168,10 +169,19 @@ class PredictModel(View):
                 bbox_labels = pipeline.pure_inference(storage, file, bounding_boxes)
                 bbox_labels = pipeline.pure_inference(storage, file, bounding_boxes)
 
 
                 # Add the labels determined in the inference process.
                 # Add the labels determined in the inference process.
+
+                # for i, result in enumerate(result_filter[file_id]):
+                #     bbox_label = bbox_labels[i]
+                #     if isinstance(bbox_label, MediaLabel):
+                #         result.label_id = bbox_label.identifier
+
+                #     result.set_origin('user', commit=True)
+
                 for i, bbox_id in enumerate(bbox_id_filter[file_id]):
                 for i, bbox_id in enumerate(bbox_id_filter[file_id]):
                     result = Result.get_or_404(bbox_id)
                     result = Result.get_or_404(bbox_id)
                     result.set_label(bbox_labels[i].identifier, commit=True)
                     result.set_label(bbox_labels[i].identifier, commit=True)
                     result.set_origin('user', origin_user=user, commit=True)
                     result.set_origin('user', origin_user=user, commit=True)
+
                     notifications.add(notification_manager.edit_result, result)
                     notifications.add(notification_manager.edit_result, result)
 
 
                 # commit changes and yield progress
                 # commit changes and yield progress

+ 14 - 2
pycs/frontend/endpoints/projects/ListProjectFiles.py

@@ -1,5 +1,6 @@
 from flask import abort
 from flask import abort
 from flask import jsonify
 from flask import jsonify
+from flask import request
 from flask.views import View
 from flask.views import View
 
 
 from pycs.database.Project import Project
 from pycs.database.Project import Project
@@ -39,8 +40,19 @@ class ListProjectFiles(View):
                 files = collection.get_files(offset=start, limit=length).all()
                 files = collection.get_files(offset=start, limit=length).all()
 
 
         else:
         else:
-            count = project.files.count()
-            files = project.get_files(offset=start, limit=length).all()
+
+            with_annotations = request.args.get("only_with_annotations")
+            kwargs = dict(with_annotations=None)
+
+            if with_annotations is not None:
+                kwargs["with_annotations"] = with_annotations == "1"
+
+            # first get all files without specific limit
+            files = project.get_files(**kwargs)
+            # get the count of those
+            count = files.count()
+            # finally, limit to the desired offset and number of files
+            files = files.offset(start).limit(length).all()
 
 
         # return files
         # return files
         return jsonify({
         return jsonify({

+ 3 - 1
pycs/frontend/endpoints/results/ConfirmResult.py

@@ -1,4 +1,6 @@
-from flask import make_response, request, abort
+from flask import abort
+from flask import make_response
+from flask import request
 from flask.views import View
 from flask.views import View
 
 
 from pycs.database.Result import Result
 from pycs.database.Result import Result

+ 51 - 0
pycs/frontend/endpoints/results/CopyResults.py

@@ -0,0 +1,51 @@
+from flask import abort
+from flask import jsonify
+from flask import make_response
+from flask import request
+from flask.views import View
+
+from pycs import db
+from pycs.database.File import File
+from pycs.database.File import Result
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class CopyResults(View):
+    """
+    copy all results for one file to another
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.nm = nm
+
+    def dispatch_request(self, file_id: int):
+
+        file = File.get_or_404(file_id)
+        request_data = request.get_json(force=True)
+
+        if 'copy_from' not in request_data:
+            abort(400, "copy_from argument is missing")
+
+        other_file = File.get_or_404(request_data.get('copy_from'))
+
+        new = []
+        # start transaction
+        with db.session.begin_nested():
+
+            for result in other_file.results.all():
+                new_result = file.create_result(
+                    origin='pipeline',
+                    result_type=result.type,
+                    label=result.label,
+                    data=result.data,
+                    commit=False)
+                new.append(new_result)
+
+
+        for new_result in new:
+            self.nm.create_result(new_result)
+
+        return make_response()

+ 1 - 2
pycs/frontend/notifications/NotificationManager.py

@@ -6,7 +6,6 @@ from pycs.database.Label import Label
 from pycs.database.Model import Model
 from pycs.database.Model import Model
 from pycs.database.Project import Project
 from pycs.database.Project import Project
 from pycs.database.Result import Result
 from pycs.database.Result import Result
-from pycs.frontend.util.JSONEncoder import JSONEncoder
 from pycs.jobs.Job import Job
 from pycs.jobs.Job import Job
 
 
 
 
@@ -17,7 +16,7 @@ class NotificationManager:
 
 
     def __init__(self, sio: Server):
     def __init__(self, sio: Server):
         self.sio = sio
         self.sio = sio
-        self.json = JSONEncoder()
+        self.json = app.json_encoder()
 
 
     def __emit(self, name, obj):
     def __emit(self, name, obj):
         enc = self.json.default(obj)
         enc = self.json.default(obj)

+ 0 - 31
pycs/frontend/util/JSONEncoder.py

@@ -1,31 +0,0 @@
-import datetime
-
-from typing import Any
-
-from flask.json import JSONEncoder as Base
-
-from pycs.database.util.JSONEncoder import JSONEncoder as DatabaseEncoder
-from pycs.jobs.util.JSONEncoder import JSONEncoder as JobsEncoder
-
-
-class JSONEncoder(Base):
-    """
-    prepares job and DB objects to be json encoded
-    """
-
-    def default(self, o: Any) -> Any:
-        module = o.__class__.__module__
-
-        if module.startswith('pycs.database'):
-            return DatabaseEncoder().default(o)
-
-        if module.startswith('pycs.jobs'):
-            return JobsEncoder().default(o)
-
-        if isinstance(o, datetime.datetime):
-            return str(o)
-
-        if isinstance(o, dict):
-            return o
-
-        return o.__dict__

+ 0 - 0
pycs/frontend/util/__init__.py


+ 3 - 1
pycs/interfaces/MediaImageLabel.py

@@ -9,7 +9,9 @@ class MediaImageLabel:
 
 
     def __init__(self, result: Result):
     def __init__(self, result: Result):
         self.label = result.label
         self.label = result.label
-        self.frame = result.data['frame'] if 'frame' in result.data else None
+        self.frame = None
+        if result.data is not None and 'frame' in result.data:
+            self.frame = result.data['frame']
 
 
     def serialize(self) -> dict:
     def serialize(self) -> dict:
         """
         """

+ 2 - 1
pycs/interfaces/Pipeline.py

@@ -70,7 +70,8 @@ class Pipeline:
         """
         """
         raise NotImplementedError
         raise NotImplementedError
 
 
-    def pure_inference(self, storage: MediaStorage, file: MediaFile, bounding_boxes: List[MediaBoundingBox]):
+    def pure_inference(self, storage: MediaStorage, file: MediaFile,
+                       bounding_boxes: List[MediaBoundingBox]):
         """
         """
         receive a file and a list of bounding boxes and only create a
         receive a file and a list of bounding boxes and only create a
         classification for the given bounding boxes.
         classification for the given bounding boxes.

+ 1 - 1
pycs/jobs/JobRunner.py

@@ -100,7 +100,7 @@ class JobRunner:
         :param identifier: job identifier
         :param identifier: job identifier
         :return:
         :return:
         """
         """
-        for i in range(len(self.__jobs)):
+        for i, job in enumerate(self.__jobs):
             if self.__jobs[i].identifier == identifier:
             if self.__jobs[i].identifier == identifier:
                 if self.__jobs[i].finished is not None:
                 if self.__jobs[i].finished is not None:
                     job = self.__jobs[i]
                     job = self.__jobs[i]

+ 0 - 17
pycs/jobs/util/JSONEncoder.py

@@ -1,17 +0,0 @@
-from typing import Any
-
-from flask.json import JSONEncoder as Base
-
-
-class JSONEncoder(Base):
-    """
-    prepares job objects to be json encoded
-    """
-
-    def default(self, o: Any) -> Any:
-        # copy = o.__dict__.copy()
-        # del copy['runner']
-        # del copy['group']
-        # return copy
-
-        return o.__dict__.copy()

+ 0 - 0
pycs/jobs/util/__init__.py


+ 7 - 0
pycs/management/__init__.py

@@ -0,0 +1,7 @@
+from pycs.management.project import project_cli
+from pycs.management.result import result_cli
+
+def setup_commands(app):
+    """ adds commands to app's CLI """
+    app.cli.add_command(project_cli)
+    app.cli.add_command(result_cli)

+ 43 - 0
pycs/management/project.py

@@ -0,0 +1,43 @@
+import click
+from tabulate import tabulate
+
+from flask.cli import AppGroup
+
+from pycs import app
+from pycs.database.Project import Project
+from pycs.util import FileOperations
+
+
+project_cli = AppGroup("project", short_help="Project operations")
+
+@project_cli.command()
+@click.argument("project_id")
+def generate_thumbnails(project_id):
+    """ Generates thumbnails for a specific project or all project """
+
+    if project_id == "all":
+        projects = Project.query.all()
+        app.logger.info(f"Generating thumbnails for all projects ({len(projects)})!")
+    else:
+        project = Project.query.get(project_id)
+        if project is None:
+            app.logger.error(f"Could not find project with ID {project_id}!")
+            return
+        app.logger.info(f"Generating thumbnails for project {project}!")
+        projects = [project]
+
+    for project in projects:
+        FileOperations.generate_thumbnails(project)
+
+@project_cli.command("list")
+def list_projects():
+    """ List information about existing projects """
+    projects = Project.query.all()
+
+    print(f"Got {len(projects)} projects")
+    rows = [(p.id, p.name, p.description) for p in projects]
+
+    print(tabulate(rows,
+        headers=["id", "name", "description"],
+        tablefmt="fancy_grid"
+    ))

+ 60 - 0
pycs/management/result.py

@@ -0,0 +1,60 @@
+import click
+import flask
+
+from flask.cli import AppGroup
+
+from pycs import app
+from pycs.database.Project import Project
+
+result_cli = AppGroup("result", short_help="Result operations")
+
+
+
+@result_cli.command("export")
+@click.argument("project_id")
+@click.argument("indent", required=False)
+@click.argument("output", required=False)
+def export(project_id, output, indent):
+    """ Export results for a specific project or for all projects """
+    if project_id == "all":
+        projects = Project.query.all()
+        app.logger.info(f"Exporting results for all projects ({len(projects)})!")
+        if output is None:
+            output = "output.json"
+
+    else:
+        project = Project.query.get(project_id)
+        if project is None:
+            app.logger.error(f"Could not find project with ID {project_id}!")
+            return
+        app.logger.info(f"Exporting results for project {project}!")
+        projects = [project]
+        if output is None:
+            output = f"output_project_{int(project_id):04d}.json"
+
+    app.logger.info(f"Exporting to {output}")
+
+    results = []
+
+    for project in projects:
+        project_files = [
+            dict(**f.serialize(),
+                results=[
+                    dict(**r.serialize(), label=r.label.serialize() if r.label is not None else None)
+                        for r in f.results.all()
+                ])
+                for f in project.files.all() if f.results.count() != 0
+            ]
+
+        results.append(dict(
+            project_id=project.id,
+            files=project_files,
+            labels=[lab.serialize() for lab in project.labels.all()],
+        ))
+
+
+    if indent is not None:
+        indent = int(indent)
+
+    with open(output, "w", encoding="utf-8") as out_f:
+        flask.json.dump(results, out_f, app=app, indent=indent)

+ 21 - 1
pycs/util/FileOperations.py

@@ -7,13 +7,16 @@ from pathlib import Path
 import cv2
 import cv2
 
 
 from PIL import Image
 from PIL import Image
+from tqdm import tqdm
 
 
+from pycs import app
 from pycs.database.File import File
 from pycs.database.File import File
 
 
 DEFAULT_JPEG_QUALITY = 80
 DEFAULT_JPEG_QUALITY = 80
 
 
 
 
 BoundingBox = namedtuple("BoundingBox", "x y w h")
 BoundingBox = namedtuple("BoundingBox", "x y w h")
+Size = namedtuple("Size", "max_width max_height")
 
 
 
 
 def file_info(data_folder: str, file_name: str, file_ext: str):
 def file_info(data_folder: str, file_name: str, file_ext: str):
@@ -248,7 +251,7 @@ def find_images(folder,
     """ walk recursively the folder and find images """
     """ walk recursively the folder and find images """
 
 
     suffixes = suffixes if suffixes is not None else [".jpg", ".jpeg", ".png"]
     suffixes = suffixes if suffixes is not None else [".jpg", ".jpeg", ".png"]
-    images: T.List[Path] = list()
+    images: T.List[Path] = []
     for root, _, files in os.walk(folder):
     for root, _, files in os.walk(folder):
         for file in files:
         for file in files:
             fpath = Path(root, file)
             fpath = Path(root, file)
@@ -258,3 +261,20 @@ def find_images(folder,
             images.append(fpath)
             images.append(fpath)
 
 
     return images
     return images
+
+
+def generate_thumbnails(project: "Project", sizes = None):
+    """ generates thumbnails for all image files in the given  """
+
+    if sizes is None:
+        sizes = [Size(200, 200), Size(2000, 1200)]
+
+    app.logger.info(f"Generating thumbnails for project \"{project.name}\"")
+
+    files = list(project.files)
+    for file in tqdm(files):
+        for size in sizes:
+            resize_file(file,
+                project.root_folder,
+                size.max_width,
+                size.max_height)

+ 22 - 0
pycs/util/JSONEncoder.py

@@ -0,0 +1,22 @@
+import datetime
+import typing as T
+
+from flask import json
+
+class JSONEncoder(json.JSONEncoder):
+    """
+    prepares job and DB objects to be json encoded
+    """
+
+    def default(self, o: T.Any) -> T.Any:
+
+        if hasattr(o, "serialize") and callable(o.serialize):
+            return o.serialize()
+
+        if isinstance(o, datetime.datetime):
+            return str(o)
+
+        if isinstance(o, dict):
+            return o
+
+        return o.__dict__.copy()

+ 1 - 1
pycs/util/PipelineUtil.py

@@ -13,7 +13,7 @@ def load_from_root_folder(root_folder: str) -> Pipeline:
     """
     """
     # load configuration.json
     # load configuration.json
     configuration_path = path.join(root_folder, 'configuration.json')
     configuration_path = path.join(root_folder, 'configuration.json')
-    with open(configuration_path, 'r') as configuration_file:
+    with open(configuration_path, 'r', encoding='utf8') as configuration_file:
         configuration = load(configuration_file)
         configuration = load(configuration_file)
 
 
     # load code
     # load code

+ 2 - 1
pycs/util/ProgressFileWriter.py

@@ -7,7 +7,8 @@ class ProgressFileWriter(BufferedWriter):
     """
     """
 
 
     def __init__(self, path, mode, callback=None):
     def __init__(self, path, mode, callback=None):
-        self.file_handler = open(path, mode)
+        # pylint: disable=consider-using-with
+        self.file_handler = open(path, mode, encoding='utf8')
 
 
         self.progress = 0
         self.progress = 0
         self.callback = callback
         self.callback = callback

+ 2 - 0
requirements.txt

@@ -11,6 +11,8 @@ flask-migrate
 python-socketio
 python-socketio
 munch
 munch
 scikit-image
 scikit-image
+pandas
+tqdm
 
 
 chainer~=7.8
 chainer~=7.8
 chainer-addons~=0.10
 chainer-addons~=0.10

+ 1 - 1
settings.json

@@ -1,7 +1,7 @@
 {
 {
   "host": "",
   "host": "",
   "port": 5000,
   "port": 5000,
-  "allowedOrigins": ["https://ammod.inf-cv.uni-jena.de", "https://deimos.inf-cv.uni-jena.de"],
+  "allowedOrigins": ["https://ammod.inf-cv.uni-jena.de", "https://deimos.inf-cv.uni-jena.de", "http://localhost:5000"],
   "projects_folder": "projects",
   "projects_folder": "projects",
   "database": "db/data.sqlite3",
   "database": "db/data.sqlite3",
   "pipeline_cache_time": 120,
   "pipeline_cache_time": 120,

+ 64 - 0
webui/src/assets/icons/double-chevron-left.svg

@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   viewBox="0 0 16 16"
+   width="16"
+   height="16"
+   version="1.1"
+   id="svg4"
+   sodipodi:docname="double-chevron-left.svg"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata10">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs8" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1920"
+     inkscape:window-height="1135"
+     id="namedview6"
+     showgrid="false"
+     inkscape:zoom="14.75"
+     inkscape:cx="-6.6779661"
+     inkscape:cy="8"
+     inkscape:window-x="1200"
+     inkscape:window-y="536"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4" />
+  <g
+     id="g834"
+     transform="matrix(-1,0,0,1,18.776483,0.00902426)">
+    <path
+       id="path2"
+       d="m 6.22,3.22 a 0.75,0.75 0 0 1 1.06,0 l 4.25,4.25 a 0.75,0.75 0 0 1 0,1.06 L 7.28,12.78 A 0.75,0.75 0 0 1 6.22,11.72 L 9.94,8 6.22,4.28 a 0.75,0.75 0 0 1 0,-1.06 z"
+       inkscape:connector-curvature="0"
+       style="fill-rule:evenodd" />
+    <path
+       id="path2-3"
+       d="m 10.022966,3.2199998 a 0.75,0.75 0 0 1 1.06,0 l 4.25,4.25 a 0.75,0.75 0 0 1 0,1.06 l -4.25,4.2500002 a 0.75,0.75 0 0 1 -1.06,-1.06 l 3.72,-3.7200002 -3.72,-3.72 a 0.75,0.75 0 0 1 0,-1.06 z"
+       style="fill-rule:evenodd"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>

+ 64 - 0
webui/src/assets/icons/double-chevron-right.svg

@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   viewBox="0 0 16 16"
+   width="16"
+   height="16"
+   version="1.1"
+   id="svg4"
+   sodipodi:docname="double-chevron-right.svg"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata10">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs8" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1920"
+     inkscape:window-height="1135"
+     id="namedview6"
+     showgrid="false"
+     inkscape:zoom="14.75"
+     inkscape:cx="8"
+     inkscape:cy="8"
+     inkscape:window-x="1200"
+     inkscape:window-y="536"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4" />
+  <g
+     id="g834"
+     transform="translate(-2.776483,0.00902426)">
+    <path
+       id="path2"
+       d="m 6.22,3.22 a 0.75,0.75 0 0 1 1.06,0 l 4.25,4.25 a 0.75,0.75 0 0 1 0,1.06 L 7.28,12.78 A 0.75,0.75 0 0 1 6.22,11.72 L 9.94,8 6.22,4.28 a 0.75,0.75 0 0 1 0,-1.06 z"
+       inkscape:connector-curvature="0"
+       style="fill-rule:evenodd" />
+    <path
+       id="path2-3"
+       d="m 10.022966,3.2199998 a 0.75,0.75 0 0 1 1.06,0 l 4.25,4.25 a 0.75,0.75 0 0 1 0,1.06 l -4.25,4.2500002 a 0.75,0.75 0 0 1 -1.06,-1.06 l 3.72,-3.7200002 -3.72,-3.72 a 0.75,0.75 0 0 1 0,-1.06 z"
+       style="fill-rule:evenodd"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>

+ 9 - 1
webui/src/components/media/annotated-image.vue

@@ -17,6 +17,8 @@
                    @nextzoom="$refs.overlay.nextZoom()"/>
                    @nextzoom="$refs.overlay.nextZoom()"/>
 
 
       <div class="media">
       <div class="media">
+        <h3>{{current.path}}</h3>
+
         <!-- image -->
         <!-- image -->
         <img v-if="current.type === 'image'"
         <img v-if="current.type === 'image'"
              ref="media" :src="src" alt="media"
              ref="media" :src="src" alt="media"
@@ -363,7 +365,13 @@ export default {
 
 
 img, video {
 img, video {
   max-width: 100%;
   max-width: 100%;
-  max-height: 100%;
+  max-height: 95%;
   transition: transform 0.01s;
   transition: transform 0.01s;
 }
 }
+
+h3 {
+  max-width: 100%;
+  max-height: 5%;
+  margin: 0.5em;
+}
 </style>
 </style>

+ 8 - 0
webui/src/components/media/annotation-overlay.vue

@@ -199,6 +199,14 @@ export default {
       if (this.interaction === 'extreme-clicking')
       if (this.interaction === 'extreme-clicking')
         return;
         return;
 
 
+
+      if (this.interaction === 'estimate-box'){
+        const coordinates = this.getEventCoordinates(event);
+        this.$root.socket.post(`/data/${this.file.identifier}/estimate`, coordinates);
+        return;
+      }
+
+
       if (this.current) {
       if (this.current) {
         if (this.callback)
         if (this.callback)
           this.callback(this.current);
           this.callback(this.current);

+ 5 - 6
webui/src/components/media/cropped-image.vue

@@ -9,12 +9,11 @@
     <div class="label-container">
     <div class="label-container">
       <h3>{{ label }}</h3>
       <h3>{{ label }}</h3>
 
 
-      <div  v-if="this.box.origin === 'user'"
-            ref="create_predictions"
-            class="create-predictions-icon"
-            title="create prediction for this image"
-            :class="{active: isPredictionRunning}"
-            @click="predict_cropped_image">
+      <div ref="create_predictions"
+           class="create-predictions-icon"
+           title="create prediction for this image"
+           :class="{active: isPredictionRunning}"
+           @click="predict_cropped_image">
         <img alt="create prediction" src="@/assets/icons/rocket.svg">
         <img alt="create prediction" src="@/assets/icons/rocket.svg">
       </div>
       </div>
     </div>
     </div>

+ 5 - 5
webui/src/components/media/label-selector.vue

@@ -54,13 +54,13 @@ export default {
       return [...this.labels].sort((a, b) => {
       return [...this.labels].sort((a, b) => {
         if (a.hierarchy_level !== b.hierarchy_level) {
         if (a.hierarchy_level !== b.hierarchy_level) {
           if (a.hierarchy_level === null)
           if (a.hierarchy_level === null)
-            return -1;
-          if (b.hierarchy_level === null)
             return +1;
             return +1;
-          if (a.hierarchy_level < b.hierarchy_level)
+          if (b.hierarchy_level === null)
             return -1;
             return -1;
-          else
+          if (a.hierarchy_level < b.hierarchy_level)
             return +1;
             return +1;
+          else
+            return -1;
         }
         }
 
 
         if (a.name < b.name)
         if (a.name < b.name)
@@ -158,4 +158,4 @@ input {
   font-size: 77%;
   font-size: 77%;
   opacity: 0.8;
   opacity: 0.8;
 }
 }
-</style>
+</style>

+ 46 - 9
webui/src/components/media/media-control.vue

@@ -2,11 +2,11 @@
   <div class="media-control">
   <div class="media-control">
     <button-input ref="previousPage"
     <button-input ref="previousPage"
                   type="transparent"
                   type="transparent"
-                  title="previous page (W)"
+                  title="previous page (Y)"
                   style="color: var(--on_error)"
                   style="color: var(--on_error)"
                   :class="{disabled: !hasPreviousPage}"
                   :class="{disabled: !hasPreviousPage}"
                   @click="$emit('previousPage', true)">
                   @click="$emit('previousPage', true)">
-      &lt;&lt;
+      <img alt="next" :class="{disabled: !hasPreviousPage}" src="@/assets/icons/double-chevron-left.svg">
     </button-input>
     </button-input>
 
 
     <button-input ref="previousElement"
     <button-input ref="previousElement"
@@ -15,7 +15,8 @@
                   style="color: var(--on_error)"
                   style="color: var(--on_error)"
                   :class="{disabled: !hasPreviousElement}"
                   :class="{disabled: !hasPreviousElement}"
                   @click="$emit('previousElement', true)">
                   @click="$emit('previousElement', true)">
-      &lt;
+
+      <img alt="next" :class="{disabled: !hasPreviousElement}" src="@/assets/icons/chevron-left.svg">
     </button-input>
     </button-input>
 
 
     <select v-if="collections.length > 0"
     <select v-if="collections.length > 0"
@@ -30,22 +31,29 @@
       </option>
       </option>
     </select>
     </select>
 
 
+    <select v-else @change="only_annotations">
+      <option>all images</option>
+      <option>images with annotations</option>
+      <option>images without annotations</option>
+    </select>
+
     <button-input ref="nextElement"
     <button-input ref="nextElement"
                   type="transparent"
                   type="transparent"
                   title="next element (D)"
                   title="next element (D)"
                   style="color: var(--on_error)"
                   style="color: var(--on_error)"
                   :class="{disabled: !hasNextElement}"
                   :class="{disabled: !hasNextElement}"
                   @click="$emit('nextElement', true)">
                   @click="$emit('nextElement', true)">
-      &gt;
+      <img alt="next" :class="{disabled: !hasNextElement}" src="@/assets/icons/chevron-right.svg">
     </button-input>
     </button-input>
 
 
     <button-input ref="nextPage"
     <button-input ref="nextPage"
                   type="transparent"
                   type="transparent"
-                  title="next page (S)"
+                  title="next page (C)"
                   style="color: var(--on_error)"
                   style="color: var(--on_error)"
                   :class="{disabled: !hasNextPage}"
                   :class="{disabled: !hasNextPage}"
                   @click="$emit('nextPage', true)">
                   @click="$emit('nextPage', true)">
-      &gt;&gt;
+      <img alt="next" :class="{disabled: !hasNextPage}" src="@/assets/icons/double-chevron-right.svg">
+
     </button-input>
     </button-input>
   </div>
   </div>
 </template>
 </template>
@@ -82,7 +90,7 @@ export default {
   methods: {
   methods: {
     keypressEvent: function (event) {
     keypressEvent: function (event) {
       switch (event.key) {
       switch (event.key) {
-        case 'w':
+        case 'y':
           this.$refs.previousPage.click();
           this.$refs.previousPage.click();
           break;
           break;
         case 'a':
         case 'a':
@@ -91,7 +99,7 @@ export default {
         case 'd':
         case 'd':
           this.$refs.nextElement.click();
           this.$refs.nextElement.click();
           break;
           break;
-        case 's':
+        case 'c':
           this.$refs.nextPage.click();
           this.$refs.nextPage.click();
           break;
           break;
       }
       }
@@ -114,6 +122,27 @@ export default {
           this.$emit('filter', select.options[select.selectedIndex].value);
           this.$emit('filter', select.options[select.selectedIndex].value);
           break;
           break;
       }
       }
+    },
+
+    only_annotations: function(e) {
+      const select = e.target;
+
+      switch (select.selectedIndex) {
+          // no filter
+        case 0:
+          this.$emit('only_annotations', null);
+          break;
+
+          // only with annotations
+        case 1:
+          this.$emit('only_annotations', true);
+          break;
+
+          // only without annotations
+        default:
+          this.$emit('only_annotations', false);
+          break;
+      }
     }
     }
   }
   }
 }
 }
@@ -139,9 +168,17 @@ export default {
   opacity: 0.4;
   opacity: 0.4;
 }
 }
 
 
+img {
+  filter: invert(1);
+}
+img.disabled {
+  filter: invert(1);
+  background-color: transparent;
+}
+
 select {
 select {
   flex-grow: 1;
   flex-grow: 1;
   max-width: 15rem;
   max-width: 15rem;
   margin: 0 1rem;
   margin: 0 1rem;
 }
 }
-</style>
+</style>

+ 12 - 10
webui/src/components/media/options-bar.vue

@@ -8,6 +8,15 @@
       <img alt="draw bounding box" src="@/assets/icons/screen-full.svg">
       <img alt="draw bounding box" src="@/assets/icons/screen-full.svg">
     </div>
     </div>
 
 
+
+    <div ref="estimate_box"
+         class="image"
+         title="estimate bounding box (W)"
+         :class="{active: interaction === 'estimate-box'}"
+         @click="$emit('interaction', 'estimate-box')">
+      <img alt="estimate bounding box" src="@/assets/icons/paper-airplane.svg">
+    </div>
+
     <div ref="extreme_clicking"
     <div ref="extreme_clicking"
          class="image"
          class="image"
          title="extreme clicking (E)"
          title="extreme clicking (E)"
@@ -18,7 +27,6 @@
       <img v-else
       <img v-else
            alt="extreme clicking" src="@/assets/icons/flame.svg">
            alt="extreme clicking" src="@/assets/icons/flame.svg">
     </div>
     </div>
-
     <div class="spacer"/>
     <div class="spacer"/>
 
 
     <div ref="move_box"
     <div ref="move_box"
@@ -200,6 +208,9 @@ export default {
         case 'q':
         case 'q':
           this.$refs.draw_box.click();
           this.$refs.draw_box.click();
           break;
           break;
+        case 'w':
+          this.$refs.estimate_box.click();
+          break;
         case 'e':
         case 'e':
           this.$refs.extreme_clicking.click();
           this.$refs.extreme_clicking.click();
           break;
           break;
@@ -216,15 +227,6 @@ export default {
         case 'b':
         case 'b':
           this.$refs.create_predictions.click();
           this.$refs.create_predictions.click();
           break;
           break;
-        case 'x':
-          this.$refs.zoom_annotation.click();
-          break;
-        case 'c':
-          this.$refs.zoom_prev_annotation.click();
-          break;
-        case 'v':
-          this.$refs.zoom_next_annotation.click();
-          break;
         case 'i':
         case 'i':
           this.$refs.crop_info.click();
           this.$refs.crop_info.click();
           break;
           break;

+ 86 - 19
webui/src/components/media/paginated-media.vue

@@ -2,30 +2,39 @@
   <div class="paginated-media">
   <div class="paginated-media">
     <div class="media" ref="media">
     <div class="media" ref="media">
       <div v-for="image in images"
       <div v-for="image in images"
-           v-bind:key="image.identifier"
+           v-bind:key="image.path"
            class="image"
            class="image"
            @click="$emit('click', image)">
            @click="$emit('click', image)">
         <img :alt="image.name" :src="image.src">
         <img :alt="image.name" :src="image.src">
 
 
-        <div v-if="current && current.identifier === image.identifier"
+        <div v-if="current && current.path === image.path"
              class="active"/>
              class="active"/>
 
 
-        <div v-if="deletable"
-             class="delete"
-             @click="deleteElement(image)">
+        <div v-if="deletable" class="media-control delete" @click="deleteElement(image)">
           <img alt="remove" src="@/assets/icons/x-circle.svg">
           <img alt="remove" src="@/assets/icons/x-circle.svg">
         </div>
         </div>
+
+        <div title="Annotated file" v-if="image.has_annotations" class="annotated">
+          <img alt="annotated" src="@/assets/icons/tag.svg">
+        </div>
+
+        <div title="Copy results from previous file"
+             v-if="inline && elements.previous && elements.previous.has_annotations && current && current.identifier == image.identifier" class="media-control copy"
+             @click="copyFromPrev2Current">
+          <img alt="copy" src="@/assets/icons/paper-airplane.svg">
+        </div>
+
       </div>
       </div>
     </div>
     </div>
 
 
-    <div v-if="!inline" class="pagination">
-      <div class="button" :class="{clickable: page > 1}" @click="prevPage">
+    <div class="pagination">
+      <div v-if="!inline" class="button" :class="{clickable: page > 1}" @click="prevPage">
         &lt;
         &lt;
       </div>
       </div>
       <div class="text">
       <div class="text">
-        {{ page }} / {{ pageCount }}
+        Page {{ page }} / {{ pageCount }}
       </div>
       </div>
-      <div class="button" :class="{clickable: page < pageCount}" @click="nextPage">
+      <div v-if="!inline" class="button" :class="{clickable: page < pageCount}" @click="nextPage">
         &gt;
         &gt;
       </div>
       </div>
     </div>
     </div>
@@ -35,7 +44,15 @@
 <script>
 <script>
 export default {
 export default {
   name: "paginated-media",
   name: "paginated-media",
-  props: ['rows', 'width', 'inline', 'deletable', 'current', 'filter'],
+  props: [
+    'rows',
+    'width',
+    'inline',
+    'deletable',
+    'current',
+    'filter',
+    'only_annotations'
+  ],
   mounted: function () {
   mounted: function () {
     window.addEventListener('resize', this.resize);
     window.addEventListener('resize', this.resize);
     window.addEventListener('wheel', this.scroll);
     window.addEventListener('wheel', this.scroll);
@@ -98,10 +115,10 @@ export default {
       // edited file is in the current image list
       // edited file is in the current image list
       if (this.filter !== false) {
       if (this.filter !== false) {
         for (let image of this.images) {
         for (let image of this.images) {
-          if (image.identifier === file.identifier) {
+          if (image.path === file.path) {
             this.get(() => {
             this.get(() => {
               // click the first image if the current shown was removed
               // click the first image if the current shown was removed
-              if (this.current.identifier === file.identifier) {
+              if (this.current.path === file.path) {
                 this.$emit('click', this.images[0]);
                 this.$emit('click', this.images[0]);
               }
               }
             });
             });
@@ -113,6 +130,14 @@ export default {
     deleteElement: function (element) {
     deleteElement: function (element) {
       this.$root.socket.post(`/data/${element.identifier}/remove`, {remove: true});
       this.$root.socket.post(`/data/${element.identifier}/remove`, {remove: true});
     },
     },
+    copyFromPrev2Current: function () {
+      if (!this.elements.previous  || !this.current)
+        return
+
+      let copy_from = this.elements.previous.identifier;
+      let copy_to = this.current.identifier;
+      this.$root.socket.post(`/data/${copy_to}/copy_results`, {copy_from});
+    },
     prevPage: function (callback) {
     prevPage: function (callback) {
       if (this.page > 1)
       if (this.page > 1)
         this.page -= 1;
         this.page -= 1;
@@ -166,6 +191,14 @@ export default {
       else
       else
         url = `/projects/${this.$root.project.identifier}/data/${this.filter}/${offset}/${limit}`;
         url = `/projects/${this.$root.project.identifier}/data/${this.filter}/${offset}/${limit}`;
 
 
+      if (this.only_annotations === true)
+        url = `${url}?only_with_annotations=1`
+      else if (this.only_annotations === false)
+        url = `${url}?only_with_annotations=0`
+
+      // for null or undefined, do not change the URL
+
+
       // call endpoint
       // call endpoint
       this.$root.socket.get(url)
       this.$root.socket.get(url)
           .then(response => response.json())
           .then(response => response.json())
@@ -201,9 +234,9 @@ export default {
       if (this.images.length === 0)
       if (this.images.length === 0)
         return;
         return;
 
 
-      if (this.current.identifier < this.images[0].identifier)
+      if (this.current.path < this.images[0].path)
         this.prevPage(this.findCurrent);
         this.prevPage(this.findCurrent);
-      else if (this.current.identifier > this.images[this.images.length - 1].identifier)
+      else if (this.current.path > this.images[this.images.length - 1].path)
         this.nextPage(this.findCurrent);
         this.nextPage(this.findCurrent);
     }
     }
   },
   },
@@ -220,8 +253,15 @@ export default {
       // find current in list
       // find current in list
       this.findCurrent();
       this.findCurrent();
 
 
+      let url = `/data/${this.current.identifier}/previous_next`;
+
+      if (this.only_annotations === true)
+        url = `${url}?only_with_annotations=1`
+      else if (this.only_annotations === false)
+        url = `${url}?only_with_annotations=0`
+
       // receive previous and next element
       // receive previous and next element
-      this.$root.socket.get(`/data/${this.current.identifier}/previous_next`)
+      this.$root.socket.get(url)
           .then(response => response.json())
           .then(response => response.json())
           .then(data => {
           .then(data => {
             if (this.filter === undefined || this.filter === false) {
             if (this.filter === undefined || this.filter === false) {
@@ -243,6 +283,14 @@ export default {
         else
         else
           this.$emit('click', this.images[0]);
           this.$emit('click', this.images[0]);
       });
       });
+    },
+    only_annotations: function() {
+      this.get(() => {
+        if (this.images.length === 0)
+          this.$emit('click', false);
+        else
+          this.$emit('click', this.images[0]);
+      });
     }
     }
   }
   }
 }
 }
@@ -288,15 +336,34 @@ export default {
   box-shadow: 0 0 20px -5px var(--primary) inset;
   box-shadow: 0 0 20px -5px var(--primary) inset;
 }
 }
 
 
+.media .media-control {
+  background-color: rgba(255, 255, 255, 0.4);
+  border-radius: 2rem;
+  filter: invert(1);
+  padding: 0.3rem 0.3rem 0.1rem;
+}
+
+.media .media-control:hover {
+  background-color: rgba(255, 255, 255, 0.8);
+}
+
 .media .delete {
 .media .delete {
   position: absolute;
   position: absolute;
   top: 0.15rem;
   top: 0.15rem;
   right: 0.15rem;
   right: 0.15rem;
+}
 
 
-  background-color: rgba(255, 255, 255, 0.4);
-  border-radius: 2rem;
-  padding: 0.3rem 0.3rem 0.1rem;
-  filter: invert(1);
+.media .annotated {
+  position: absolute;
+  top: 0.15rem;
+  left: 0.15rem;
+
+  padding: 0.3rem 0.3rem 0.3rem;
+}
+.media .copy {
+  position: absolute;
+  bottom: 0.10rem;
+  left: 0.10rem;
 }
 }
 
 
 .media .delete img {
 .media .delete img {

+ 0 - 2
webui/src/components/other/LabelTreeView.vue

@@ -43,7 +43,6 @@ export default {
   components: {EditableHeadline},
   components: {EditableHeadline},
   props: ['label', 'indent', 'targetable'],
   props: ['label', 'indent', 'targetable'],
   data: function () {
   data: function () {
-    console.log(this.label)
     return {
     return {
       untouched: true,
       untouched: true,
       target: false,
       target: false,
@@ -71,7 +70,6 @@ export default {
     },
     },
     removeLabel: function () {
     removeLabel: function () {
       // TODO then / error
       // TODO then / error
-      console.log(this.label)
       let id = this.label.identifier;
       let id = this.label.identifier;
       this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels/${id}/remove`, {remove: true});
       this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels/${id}/remove`, {remove: true});
     },
     },

+ 6 - 2
webui/src/components/projects/project-data-view-window.vue

@@ -14,12 +14,15 @@
                    @nextPage="$refs.media.nextPage()"
                    @nextPage="$refs.media.nextPage()"
                    @previousElement="$refs.media.prevElement()"
                    @previousElement="$refs.media.prevElement()"
                    @nextElement="$refs.media.nextElement()"
                    @nextElement="$refs.media.nextElement()"
-                   @filter="filter=$event"/>
+                   @filter="filter=$event"
+                   @only_annotations="only_annotations=$event"
+                   />
 
 
     <paginated-media ref="media"
     <paginated-media ref="media"
                      rows="1" width="100" :inline="true"
                      rows="1" width="100" :inline="true"
                      :current="current"
                      :current="current"
                      :filter="filter"
                      :filter="filter"
+                     :only_annotations="only_annotations"
                      @click="current=$event"
                      @click="current=$event"
                      @hasPreviousPage="hasPreviousPage=$event"
                      @hasPreviousPage="hasPreviousPage=$event"
                      @hasNextPage="hasNextPage=$event"
                      @hasNextPage="hasNextPage=$event"
@@ -43,7 +46,8 @@ export default {
       hasNextPage: false,
       hasNextPage: false,
       hasPreviousElement: false,
       hasPreviousElement: false,
       hasNextElement: false,
       hasNextElement: false,
-      filter: false
+      filter: false,
+      only_annotations: null,
     }
     }
   }
   }
 }
 }

+ 2 - 1
webui/src/components/projects/project-open-window.vue

@@ -25,7 +25,8 @@
         </div>
         </div>
 
 
         <div v-if="sortedProjects.length === 0"
         <div v-if="sortedProjects.length === 0"
-             class="project">
+          @click="create = true"
+          class="project">
           <h2>There are no projects available.</h2>
           <h2>There are no projects available.</h2>
           <div class="description">Please use the button at the bottom of the page to create a new one.</div>
           <div class="description">Please use the button at the bottom of the page to create a new one.</div>
         </div>
         </div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio