6
0
Эх сурвалжийг харах

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

Dimitri Korsch 3 жил өмнө
parent
commit
bc26fe9489
55 өөрчлөгдсөн 2865 нэмэгдсэн , 172 устгасан
  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
 db
 external_data
-/models/
-/labels/
+models*
+labels*
 dist/
 
 .htpasswd
@@ -48,3 +48,6 @@ dist/
 *.sqlite-journal
 *.sqlite3
 *.sqlite3-journal
+
+output*.json
+settings.local.json

+ 2 - 1
.pylintrc

@@ -155,7 +155,8 @@ disable=print-statement,
         comprehension-escape,
         duplicate-code,
         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
 # either give multiple identifier separated by comma (,) or put this option

+ 6 - 0
app.py

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

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 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*

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 888 - 0
notebooks/show_results.ipynb


+ 12 - 4
pycs/__init__.py

@@ -9,24 +9,29 @@ from munch import munchify
 from pathlib import Path
 
 from flask import Flask
+from flask_htpasswd import HtPasswdAuth
 from flask_migrate import Migrate
 from flask_sqlalchemy import SQLAlchemy
 from sqlalchemy import event
 from sqlalchemy import pool
 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))
 
 # create 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
 
+
 app = Flask(__name__)
 app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_FILE}"
 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!'
 htpasswd = HtPasswdAuth(app)
 
+# set json encoder so database objects are serialized correctly
+app.json_encoder = JSONEncoder
+
 # pylint: disable=unused-argument
 @event.listens_for(Engine, "connect")
 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
         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
     @commit_on_return

+ 40 - 16
pycs/database/File.py

@@ -66,6 +66,7 @@ class File(NamedBaseModel):
         "created",
         "path",
         "frames",
+        "has_annotations",
         "fps",
         "project_id",
         "collection_id",
@@ -76,6 +77,11 @@ class File(NamedBaseModel):
         """ filename consisting of a name and an 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
     def absolute_path(self) -> str:
         """ returns an absolute of the file """
@@ -128,26 +134,37 @@ class File(NamedBaseModel):
         collection = Collection.query.filter_by(reference=collection_reference).one()
         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
 
         :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
 
         :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
 
@@ -155,22 +172,23 @@ class File(NamedBaseModel):
         """
 
         # 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
 
         :return: another file or None
         """
         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
 
@@ -179,8 +197,8 @@ class File(NamedBaseModel):
 
         # pylint: disable=no-member
         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]:
@@ -197,7 +215,7 @@ class File(NamedBaseModel):
                       origin: str,
                       result_type: str,
                       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:
         """
         Creates a result and returns the created object
@@ -217,7 +235,13 @@ class File(NamedBaseModel):
         result.data = data
 
         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):
                 label = label.id

+ 5 - 0
pycs/database/Label.py

@@ -66,11 +66,16 @@ class Label(NamedBaseModel):
     serialize_only = NamedBaseModel.serialize_only + (
         "project_id",
         "parent_id",
+        "parent_reference",
         "reference",
         "hierarchy_level",
         # "children",
     )
 
+    @property
+    def parent_reference(self):
+        return None if self.parent is None else self.parent.reference
+
     @commit_on_return
     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):
-            with open(conf_path) as conf_file:
+            with open(conf_path, encoding='utf8') as conf_file:
                 config = json.load(conf_file)
 
             provider, _ = cls.get_or_create(
@@ -73,7 +73,7 @@ class LabelProvider(NamedBaseModel):
         :return: LabelProvider instance
         """
         # 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)
 
         # load code

+ 1 - 1
pycs/database/Model.py

@@ -37,7 +37,7 @@ class Model(NamedBaseModel):
             and stores them in the database
         """
         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)
 
             # 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()
 
-    def file(self, identifier: int) -> T.Optional[Label]:
+    def file(self, identifier: int) -> T.Optional[File]:
         """
         get a file using its unique identifier
 
@@ -174,6 +174,9 @@ class Project(NamedBaseModel):
                 - AssertionError if project_id and reference are not unique
                 - ValueError if a cycle in the hierarchy is found
         """
+        if len(labels) == 0:
+            return labels
+
         if clean_old_labels:
             self.labels.delete()
 
@@ -205,7 +208,7 @@ class Project(NamedBaseModel):
     def __check_labels(self, labels):
         """ check labels for unique keys and cycles """
 
-        unique_keys = dict()
+        unique_keys = {}
 
         for label in labels:
             key = (label["project_id"], label["reference"])
@@ -289,7 +292,8 @@ class Project(NamedBaseModel):
 
         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
 
@@ -297,8 +301,15 @@ class Project(NamedBaseModel):
         :param limit: file limit
         :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):
         """

+ 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
 
 from glob import glob
@@ -10,8 +9,8 @@ import socketio
 
 from flask import send_from_directory
 
-from pycs.database.Model import Model
 from pycs.database.LabelProvider import LabelProvider
+from pycs.database.Model import Model
 from pycs.frontend.endpoints.ListJobs import ListJobs
 from pycs.frontend.endpoints.ListLabelProviders import ListLabelProviders
 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.ListLabels import ListLabels
 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.PredictBoundingBox import PredictBoundingBox
 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.RemoveProject import RemoveProject
 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.EditResultData import EditResultData
 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.ResetResults import ResetResults
 from pycs.frontend.notifications.NotificationManager import NotificationManager
-from pycs.frontend.util.JSONEncoder import JSONEncoder
 from pycs.jobs.JobRunner import JobRunner
 from pycs.util.PipelineCache import PipelineCache
 
@@ -67,11 +67,8 @@ class WebServer:
 
     def __init__(self, app, htpasswd, settings: munch.Munch, discovery: bool = True):
 
-        logging.config.dictConfig(settings.logging)
         self.app = app
         self.htpasswd = htpasswd
-        # set json encoder so database objects are serialized correctly
-        self.app.json_encoder = JSONEncoder
 
         # initialize web server
         if self.is_production:
@@ -290,6 +287,10 @@ class WebServer:
             view_func=self.htpasswd.required( CreateResult.as_view('create_result',
                 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(
             '/data/<int:file_id>/reset',
             view_func=self.htpasswd.required( ResetResults.as_view('reset_results',
@@ -377,6 +378,12 @@ class WebServer:
                 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):
         """ start web server """
         self.pipelines.start()

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

@@ -1,4 +1,5 @@
 from flask import jsonify
+from flask import request
 from flask.views import View
 
 from pycs.database.File import File
@@ -16,12 +17,21 @@ class GetPreviousAndNextFile(View):
         # get file from database
         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
         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

+ 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.NotificationManager import NotificationManager
 from pycs.interfaces.MediaFile import MediaFile
+from pycs.interfaces.MediaLabel import MediaLabel
 from pycs.interfaces.MediaBoundingBox import MediaBoundingBox
 from pycs.interfaces.MediaStorage import MediaStorage
 from pycs.jobs.JobGroupBusyException import JobGroupBusyException
@@ -168,10 +169,19 @@ class PredictModel(View):
                 bbox_labels = pipeline.pure_inference(storage, file, bounding_boxes)
 
                 # 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]):
                     result = Result.get_or_404(bbox_id)
                     result.set_label(bbox_labels[i].identifier, commit=True)
                     result.set_origin('user', origin_user=user, commit=True)
+
                     notifications.add(notification_manager.edit_result, result)
 
                 # commit changes and yield progress

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

@@ -1,5 +1,6 @@
 from flask import abort
 from flask import jsonify
+from flask import request
 from flask.views import View
 
 from pycs.database.Project import Project
@@ -39,8 +40,19 @@ class ListProjectFiles(View):
                 files = collection.get_files(offset=start, limit=length).all()
 
         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 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 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.Project import Project
 from pycs.database.Result import Result
-from pycs.frontend.util.JSONEncoder import JSONEncoder
 from pycs.jobs.Job import Job
 
 
@@ -17,7 +16,7 @@ class NotificationManager:
 
     def __init__(self, sio: Server):
         self.sio = sio
-        self.json = JSONEncoder()
+        self.json = app.json_encoder()
 
     def __emit(self, name, 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):
         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:
         """

+ 2 - 1
pycs/interfaces/Pipeline.py

@@ -70,7 +70,8 @@ class Pipeline:
         """
         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
         classification for the given bounding boxes.

+ 1 - 1
pycs/jobs/JobRunner.py

@@ -100,7 +100,7 @@ class JobRunner:
         :param identifier: job identifier
         :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].finished is not None:
                     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
 
 from PIL import Image
+from tqdm import tqdm
 
+from pycs import app
 from pycs.database.File import File
 
 DEFAULT_JPEG_QUALITY = 80
 
 
 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):
@@ -248,7 +251,7 @@ def find_images(folder,
     """ walk recursively the folder and find images """
 
     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 file in files:
             fpath = Path(root, file)
@@ -258,3 +261,20 @@ def find_images(folder,
             images.append(fpath)
 
     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
     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)
 
     # load code

+ 2 - 1
pycs/util/ProgressFileWriter.py

@@ -7,7 +7,8 @@ class ProgressFileWriter(BufferedWriter):
     """
 
     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.callback = callback

+ 2 - 0
requirements.txt

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

+ 1 - 1
settings.json

@@ -1,7 +1,7 @@
 {
   "host": "",
   "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",
   "database": "db/data.sqlite3",
   "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()"/>
 
       <div class="media">
+        <h3>{{current.path}}</h3>
+
         <!-- image -->
         <img v-if="current.type === 'image'"
              ref="media" :src="src" alt="media"
@@ -363,7 +365,13 @@ export default {
 
 img, video {
   max-width: 100%;
-  max-height: 100%;
+  max-height: 95%;
   transition: transform 0.01s;
 }
+
+h3 {
+  max-width: 100%;
+  max-height: 5%;
+  margin: 0.5em;
+}
 </style>

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

@@ -199,6 +199,14 @@ export default {
       if (this.interaction === 'extreme-clicking')
         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.callback)
           this.callback(this.current);

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

@@ -9,12 +9,11 @@
     <div class="label-container">
       <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">
       </div>
     </div>

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

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

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

@@ -2,11 +2,11 @@
   <div class="media-control">
     <button-input ref="previousPage"
                   type="transparent"
-                  title="previous page (W)"
+                  title="previous page (Y)"
                   style="color: var(--on_error)"
                   :class="{disabled: !hasPreviousPage}"
                   @click="$emit('previousPage', true)">
-      &lt;&lt;
+      <img alt="next" :class="{disabled: !hasPreviousPage}" src="@/assets/icons/double-chevron-left.svg">
     </button-input>
 
     <button-input ref="previousElement"
@@ -15,7 +15,8 @@
                   style="color: var(--on_error)"
                   :class="{disabled: !hasPreviousElement}"
                   @click="$emit('previousElement', true)">
-      &lt;
+
+      <img alt="next" :class="{disabled: !hasPreviousElement}" src="@/assets/icons/chevron-left.svg">
     </button-input>
 
     <select v-if="collections.length > 0"
@@ -30,22 +31,29 @@
       </option>
     </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"
                   type="transparent"
                   title="next element (D)"
                   style="color: var(--on_error)"
                   :class="{disabled: !hasNextElement}"
                   @click="$emit('nextElement', true)">
-      &gt;
+      <img alt="next" :class="{disabled: !hasNextElement}" src="@/assets/icons/chevron-right.svg">
     </button-input>
 
     <button-input ref="nextPage"
                   type="transparent"
-                  title="next page (S)"
+                  title="next page (C)"
                   style="color: var(--on_error)"
                   :class="{disabled: !hasNextPage}"
                   @click="$emit('nextPage', true)">
-      &gt;&gt;
+      <img alt="next" :class="{disabled: !hasNextPage}" src="@/assets/icons/double-chevron-right.svg">
+
     </button-input>
   </div>
 </template>
@@ -82,7 +90,7 @@ export default {
   methods: {
     keypressEvent: function (event) {
       switch (event.key) {
-        case 'w':
+        case 'y':
           this.$refs.previousPage.click();
           break;
         case 'a':
@@ -91,7 +99,7 @@ export default {
         case 'd':
           this.$refs.nextElement.click();
           break;
-        case 's':
+        case 'c':
           this.$refs.nextPage.click();
           break;
       }
@@ -114,6 +122,27 @@ export default {
           this.$emit('filter', select.options[select.selectedIndex].value);
           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;
 }
 
+img {
+  filter: invert(1);
+}
+img.disabled {
+  filter: invert(1);
+  background-color: transparent;
+}
+
 select {
   flex-grow: 1;
   max-width: 15rem;
   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">
     </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"
          class="image"
          title="extreme clicking (E)"
@@ -18,7 +27,6 @@
       <img v-else
            alt="extreme clicking" src="@/assets/icons/flame.svg">
     </div>
-
     <div class="spacer"/>
 
     <div ref="move_box"
@@ -200,6 +208,9 @@ export default {
         case 'q':
           this.$refs.draw_box.click();
           break;
+        case 'w':
+          this.$refs.estimate_box.click();
+          break;
         case 'e':
           this.$refs.extreme_clicking.click();
           break;
@@ -216,15 +227,6 @@ export default {
         case 'b':
           this.$refs.create_predictions.click();
           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':
           this.$refs.crop_info.click();
           break;

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

@@ -2,30 +2,39 @@
   <div class="paginated-media">
     <div class="media" ref="media">
       <div v-for="image in images"
-           v-bind:key="image.identifier"
+           v-bind:key="image.path"
            class="image"
            @click="$emit('click', image)">
         <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"/>
 
-        <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">
         </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 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;
       </div>
       <div class="text">
-        {{ page }} / {{ pageCount }}
+        Page {{ page }} / {{ pageCount }}
       </div>
-      <div class="button" :class="{clickable: page < pageCount}" @click="nextPage">
+      <div v-if="!inline" class="button" :class="{clickable: page < pageCount}" @click="nextPage">
         &gt;
       </div>
     </div>
@@ -35,7 +44,15 @@
 <script>
 export default {
   name: "paginated-media",
-  props: ['rows', 'width', 'inline', 'deletable', 'current', 'filter'],
+  props: [
+    'rows',
+    'width',
+    'inline',
+    'deletable',
+    'current',
+    'filter',
+    'only_annotations'
+  ],
   mounted: function () {
     window.addEventListener('resize', this.resize);
     window.addEventListener('wheel', this.scroll);
@@ -98,10 +115,10 @@ export default {
       // edited file is in the current image list
       if (this.filter !== false) {
         for (let image of this.images) {
-          if (image.identifier === file.identifier) {
+          if (image.path === file.path) {
             this.get(() => {
               // 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]);
               }
             });
@@ -113,6 +130,14 @@ export default {
     deleteElement: function (element) {
       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) {
       if (this.page > 1)
         this.page -= 1;
@@ -166,6 +191,14 @@ export default {
       else
         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
       this.$root.socket.get(url)
           .then(response => response.json())
@@ -201,9 +234,9 @@ export default {
       if (this.images.length === 0)
         return;
 
-      if (this.current.identifier < this.images[0].identifier)
+      if (this.current.path < this.images[0].path)
         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);
     }
   },
@@ -220,8 +253,15 @@ export default {
       // find current in list
       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
-      this.$root.socket.get(`/data/${this.current.identifier}/previous_next`)
+      this.$root.socket.get(url)
           .then(response => response.json())
           .then(data => {
             if (this.filter === undefined || this.filter === false) {
@@ -243,6 +283,14 @@ export default {
         else
           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;
 }
 
+.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 {
   position: absolute;
   top: 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 {

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

@@ -43,7 +43,6 @@ export default {
   components: {EditableHeadline},
   props: ['label', 'indent', 'targetable'],
   data: function () {
-    console.log(this.label)
     return {
       untouched: true,
       target: false,
@@ -71,7 +70,6 @@ export default {
     },
     removeLabel: function () {
       // TODO then / error
-      console.log(this.label)
       let id = this.label.identifier;
       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()"
                    @previousElement="$refs.media.prevElement()"
                    @nextElement="$refs.media.nextElement()"
-                   @filter="filter=$event"/>
+                   @filter="filter=$event"
+                   @only_annotations="only_annotations=$event"
+                   />
 
     <paginated-media ref="media"
                      rows="1" width="100" :inline="true"
                      :current="current"
                      :filter="filter"
+                     :only_annotations="only_annotations"
                      @click="current=$event"
                      @hasPreviousPage="hasPreviousPage=$event"
                      @hasNextPage="hasNextPage=$event"
@@ -43,7 +46,8 @@ export default {
       hasNextPage: false,
       hasPreviousElement: 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 v-if="sortedProjects.length === 0"
-             class="project">
+          @click="create = true"
+          class="project">
           <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>

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно