Browse Source

Resolve "backend storage system"

Eric Tröbs 4 years ago
parent
commit
c855f64876
100 changed files with 3710 additions and 1777 deletions
  1. 5 0
      .gitignore
  2. 24 68
      .gitlab-ci.yml
  3. 21 16
      app.py
  4. 4 1
      labels/fixed_label_provider/Provider.py
  5. 1 1
      labels/fixed_label_provider/configuration.json
  6. 16 12
      models/fixed_model/Pipeline.py
  7. 1 1
      models/fixed_model/configuration.json
  8. 58 0
      models/haarcascade_frontalface_default/Pipeline.py
  9. 9 0
      models/haarcascade_frontalface_default/configuration.json
  10. 0 33
      pycs/ApplicationStatus.py
  11. 281 0
      pycs/database/Database.py
  12. 129 0
      pycs/database/File.py
  13. 44 0
      pycs/database/Label.py
  14. 12 0
      pycs/database/LabelProvider.py
  15. 56 0
      pycs/database/Model.py
  16. 249 0
      pycs/database/Project.py
  17. 66 0
      pycs/database/Result.py
  18. 31 0
      pycs/database/discovery/LabelProviderDiscovery.py
  19. 32 0
      pycs/database/discovery/ModelDiscovery.py
  20. 14 0
      pycs/database/util/JSONEncoder.py
  21. 218 341
      pycs/frontend/WebServer.py
  22. 19 0
      pycs/frontend/endpoints/ListJobs.py
  23. 19 0
      pycs/frontend/endpoints/ListLabelProviders.py
  24. 19 0
      pycs/frontend/endpoints/ListModels.py
  25. 19 0
      pycs/frontend/endpoints/ListProjects.py
  26. 35 0
      pycs/frontend/endpoints/data/GetFile.py
  27. 32 0
      pycs/frontend/endpoints/data/GetPreviousAndNextFile.py
  28. 137 0
      pycs/frontend/endpoints/data/GetResizedFile.py
  29. 51 0
      pycs/frontend/endpoints/data/RemoveFile.py
  30. 94 0
      pycs/frontend/endpoints/data/UploadFile.py
  31. 29 0
      pycs/frontend/endpoints/jobs/RemoveJob.py
  32. 41 0
      pycs/frontend/endpoints/labels/CreateLabel.py
  33. 46 0
      pycs/frontend/endpoints/labels/EditLabelName.py
  34. 28 0
      pycs/frontend/endpoints/labels/ListLabels.py
  35. 46 0
      pycs/frontend/endpoints/labels/RemoveLabel.py
  36. 57 0
      pycs/frontend/endpoints/pipelines/FitModel.py
  37. 81 0
      pycs/frontend/endpoints/pipelines/PredictFile.py
  38. 96 0
      pycs/frontend/endpoints/pipelines/PredictModel.py
  39. 100 0
      pycs/frontend/endpoints/projects/CreateProject.py
  40. 38 0
      pycs/frontend/endpoints/projects/EditProjectDescription.py
  41. 38 0
      pycs/frontend/endpoints/projects/EditProjectName.py
  42. 117 0
      pycs/frontend/endpoints/projects/ExecuteExternalStorage.py
  43. 92 0
      pycs/frontend/endpoints/projects/ExecuteLabelProvider.py
  44. 28 0
      pycs/frontend/endpoints/projects/GetProjectModel.py
  45. 33 0
      pycs/frontend/endpoints/projects/ListFiles.py
  46. 50 0
      pycs/frontend/endpoints/projects/RemoveProject.py
  47. 37 0
      pycs/frontend/endpoints/results/ConfirmResult.py
  48. 62 0
      pycs/frontend/endpoints/results/CreateResult.py
  49. 38 0
      pycs/frontend/endpoints/results/EditResultData.py
  50. 42 0
      pycs/frontend/endpoints/results/EditResultLabel.py
  51. 29 0
      pycs/frontend/endpoints/results/GetProjectResults.py
  52. 28 0
      pycs/frontend/endpoints/results/GetResults.py
  53. 37 0
      pycs/frontend/endpoints/results/RemoveResult.py
  54. 43 0
      pycs/frontend/endpoints/results/ResetResults.py
  55. 183 0
      pycs/frontend/notifications/NotificationManager.py
  56. 22 0
      pycs/frontend/util/JSONEncoder.py
  57. 20 0
      pycs/interfaces/AnnotatedMediaFile.py
  58. 11 1
      pycs/interfaces/LabelProvider.py
  59. 21 0
      pycs/interfaces/MediaFile.py
  60. 89 0
      pycs/interfaces/Pipeline.py
  61. 22 0
      pycs/jobs/Job.py
  62. 4 0
      pycs/jobs/JobGroupBusyException.py
  63. 223 0
      pycs/jobs/JobRunner.py
  64. 17 0
      pycs/jobs/util/JSONEncoder.py
  65. 0 41
      pycs/observable/Observable.py
  66. 0 31
      pycs/observable/ObservableDict.py
  67. 0 42
      pycs/observable/ObservableList.py
  68. 0 3
      pycs/observable/__init__.py
  69. 0 25
      pycs/pipeline/Fit.py
  70. 0 13
      pycs/pipeline/Job.py
  71. 0 68
      pycs/pipeline/Pipeline.py
  72. 0 51
      pycs/pipeline/PipelineManager.py
  73. 0 40
      pycs/projects/ImageFile.py
  74. 0 20
      pycs/projects/LabelManager.py
  75. 0 70
      pycs/projects/MediaFile.py
  76. 0 20
      pycs/projects/ModelManager.py
  77. 0 222
      pycs/projects/Project.py
  78. 0 129
      pycs/projects/ProjectManager.py
  79. 0 100
      pycs/projects/UnmanagedImageFile.py
  80. 0 100
      pycs/projects/UnmanagedVideoFile.py
  81. 0 46
      pycs/projects/VideoFile.py
  82. 38 0
      pycs/util/FileParser.py
  83. 0 3
      pycs/util/GenericWrapper.py
  84. 28 0
      pycs/util/LabelProviderUtil.py
  85. 28 0
      pycs/util/PipelineUtil.py
  86. 4 0
      pycs/util/ProgressFileWriter.py
  87. 0 6
      pycs/util/RecursiveDictionary.py
  88. 2 5
      settings.json
  89. 0 32
      test/test_application_status.py
  90. 0 122
      test/test_observable.py
  91. 34 114
      webui/src/App.vue
  92. 4 0
      webui/src/assets/icons/check-circle.svg
  93. 4 0
      webui/src/assets/icons/chevron-left.svg
  94. 4 0
      webui/src/assets/icons/chevron-right.svg
  95. 3 0
      webui/src/assets/icons/four-directions.svg
  96. 4 0
      webui/src/assets/icons/location.svg
  97. 3 0
      webui/src/assets/icons/north-star.svg
  98. 3 0
      webui/src/assets/icons/open-circle.svg
  99. 4 0
      webui/src/assets/icons/package-dependents.svg
  100. 3 0
      webui/src/assets/icons/pause.svg

+ 5 - 0
.gitignore

@@ -37,3 +37,8 @@ __pycache__/
 /models/
 /labels/
 dist/
+
+*.sqlite
+*.sqlite-journal
+*.sqlite3
+*.sqlite3-journal

+ 24 - 68
.gitlab-ci.yml

@@ -21,11 +21,7 @@ webui:
       - webui/dist/
 
 
-tests_3.6:
-  stage: test
-  image: python:3.6
-  only:
-    - master
+.python_test_template: &python_test_definition
   variables:
     PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
   cache:
@@ -37,72 +33,46 @@ tests_3.6:
     - python -V
     - python -m venv env
     - source env/bin/activate
+    - pip install numpy opencv-python Pillow scipy
+    - pip install eventlet flask python-socketio
     - pip install coverage pylint
   script:
-    - coverage run --source=pycs/ -m unittest discover test/
-    - pylint pycs --fail-under=5
+    # - coverage run --source=pycs/ -m unittest discover test/
+    - "pylint --fail-under=9.5
+         --disable=duplicate-code
+         --disable=missing-module-docstring
+         --disable=too-many-instance-attributes
+         --extension-pkg-whitelist=cv2
+         --module-rgx='^[A-Za-z0-9]+$' --class-rgx='^[A-Za-z0-9]+$'
+         app.py pycs"
+
+tests_3.6:
+  stage: test
+  image: python:3.6
+  only:
+    - master
+  <<: *python_test_definition
 
 tests_3.7:
   stage: test
   image: python:3.7
   only:
     - master
-  variables:
-    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
-  cache:
-    key: "$CI_JOB_NAME"
-    paths:
-      - .cache/pip
-      - env/
-  before_script:
-    - python -V
-    - python -m venv env
-    - source env/bin/activate
-    - pip install coverage pylint
-  script:
-    - coverage run --source=pycs/ -m unittest discover test/
-    - pylint pycs --fail-under=5
+  <<: *python_test_definition
 
 tests_3.8:
   stage: test
   image: python:3.8
   only:
     - master
-  variables:
-    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
-  cache:
-    key: "$CI_JOB_NAME"
-    paths:
-      - .cache/pip
-      - env/
-  before_script:
-    - python -V
-    - python -m venv env
-    - source env/bin/activate
-    - pip install coverage pylint
-  script:
-    - coverage run --source=pycs/ -m unittest discover test/
-    - pylint pycs --fail-under=5
+  <<: *python_test_definition
 
 tests_3.9:
   stage: test
   image: python:3.9
-  variables:
-    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
-  cache:
-    key: "$CI_JOB_NAME"
-    paths:
-      - .cache/pip
-      - env/
-  before_script:
-    - python -V
-    - python -m venv env
-    - source env/bin/activate
-    - pip install coverage pylint
-  script:
-    - coverage run --source=pycs/ -m unittest discover test/
-    - coverage report -m
-    - pylint pycs --fail-under=5
+  <<: *python_test_definition
+  # after_script:
+  #   - coverage report -m
 
 tests_3.10:
   stage: test
@@ -110,21 +80,7 @@ tests_3.10:
   allow_failure: true
   only:
     - master
-  variables:
-    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
-  cache:
-    key: "$CI_JOB_NAME"
-    paths:
-      - .cache/pip
-      - env/
-  before_script:
-    - python -V
-    - python -m venv env
-    - source env/bin/activate
-    - pip install coverage pylint
-  script:
-    - coverage run --source=pycs/ -m unittest discover test/
-    - pylint pycs --fail-under=5
+  <<: *python_test_definition
 
 
 bundle:

+ 21 - 16
app.py

@@ -1,28 +1,33 @@
 #!/usr/bin/env python
 
-from pycs.ApplicationStatus import ApplicationStatus
+from json import load
+from os import mkdir, path
+
+from pycs.database.Database import Database
 from pycs.frontend.WebServer import WebServer
-from pycs.projects.LabelManager import LabelManager
-from pycs.projects.ModelManager import ModelManager
-from pycs.projects.ProjectManager import ProjectManager
+from pycs.jobs.JobRunner import JobRunner
 
 if __name__ == '__main__':
-    # load settings and initialize application status object
+    # load settings
     print('- load settings')
-    app_status = ApplicationStatus(path_to_settings_json='settings.json')
+    with open('settings.json', 'r') as file:
+        settings = load(file)
+
+    host = settings['host']
+    port = settings['port']
 
-    # load model manager
-    print('- load model manager')
-    model_manager = ModelManager(app_status)
+    # create projects folder
+    if not path.exists('projects/'):
+        mkdir('projects/')
 
-    # load label manager
-    print('- load label manager')
-    label_manager = LabelManager(app_status)
+    # initialize database
+    print('- load database')
+    database = Database('data.sqlite3')
 
-    # load project manager
-    print('- load project manager')
-    project_manager = ProjectManager(app_status)
+    # start job runner
+    print('- start job runner')
+    jobs = JobRunner()
 
     # start web server
     print('- start web server')
-    web_server = WebServer(app_status)
+    web_server = WebServer(host, port, database, jobs)

+ 4 - 1
labels/fixed_label_provider/Provider.py

@@ -1,6 +1,7 @@
 import typing
+from time import sleep
 
-from pycs.labels.LabelProvider import LabelProvider
+from pycs.interfaces.LabelProvider import LabelProvider
 
 
 class Provider(LabelProvider):
@@ -11,6 +12,8 @@ class Provider(LabelProvider):
         pass
 
     def get_labels(self) -> typing.List[dict]:
+        sleep(5)
+
         return list(map(
             lambda l: self.create_label(l.lower(), l),
             [

+ 1 - 1
labels/fixed_label_provider/configuration.json

@@ -1,6 +1,6 @@
 {
-  "id": "flpv1",
   "name": "Fixed Label Provider v1",
+  "description": "provides some pre-defined labels",
   "code": {
     "module": "Provider",
     "class": "Provider"

+ 16 - 12
models/fixed_model/Pipeline.py

@@ -1,10 +1,11 @@
 from json import dump, load
 from os import path
+from time import sleep
 from typing import List
 
-from pycs.pipeline.Fit import Fit
-from pycs.pipeline.Job import Job
-from pycs.pipeline.Pipeline import Pipeline as Interface
+from pycs.interfaces.AnnotatedMediaFile import AnnotatedMediaFile
+from pycs.interfaces.MediaFile import MediaFile
+from pycs.interfaces.Pipeline import Pipeline as Interface
 
 
 class Pipeline(Interface):
@@ -15,27 +16,30 @@ class Pipeline(Interface):
     def close(self):
         print('fmv1 close')
 
-    def execute(self, job: Job) -> List[dict]:
+    def execute(self, file: MediaFile) -> List[dict]:
         print('fmv1 execute')
+        sleep(0.01)
+
         data_file = path.join(self.root_folder, 'data.json')
 
         if path.exists(data_file):
-            with open(data_file, 'r') as file:
-                result = load(file)
+            with open(data_file, 'r') as f:
+                result = load(f)
         else:
             result = {}
 
-        if job.path in result:
-            return result[job.path]
+        if file.path in result:
+            return result[file.path]
         else:
             return []
 
-    def fit(self, fit: List[Fit]):
+    def fit(self, files: List[AnnotatedMediaFile]):
         print('fmv1 fit')
-        result = {}
+        sleep(5)
 
-        for f in fit:
-            result[f.path] = f.result
+        result = {}
+        for f in files:
+            result[f.path] = f.results
 
         data_file = path.join(self.root_folder, 'data.json')
         with open(data_file, 'w') as file:

+ 1 - 1
models/fixed_model/distribution.json → models/fixed_model/configuration.json

@@ -1,6 +1,6 @@
 {
-  "id": "fmv1",
   "name": "Fixed Base Model v1",
+  "description": "stores training data in a local json file and returns exact results based on the file path",
   "supports": [
     "labeled-bounding-boxes",
     "fit"

+ 58 - 0
models/haarcascade_frontalface_default/Pipeline.py

@@ -0,0 +1,58 @@
+from os import path
+from typing import List
+from urllib.request import urlretrieve
+
+import cv2
+
+from pycs.interfaces.MediaFile import MediaFile
+from pycs.interfaces.Pipeline import Pipeline as Interface
+
+
+class Pipeline(Interface):
+    URL = 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml'
+
+    def __init__(self, root_folder, distribution):
+        print('hcffdv1 init')
+
+        # get path to xml file
+        xml_file = path.join(root_folder, 'haarcascade_frontalface_default.xml')
+
+        # download
+        if not path.exists(xml_file):
+            urlretrieve(self.URL, xml_file)
+
+        # load
+        self.face_cascade = cv2.CascadeClassifier(xml_file)
+
+    def close(self):
+        print('hcffdv1 close')
+
+    def execute(self, file: MediaFile) -> List[dict]:
+        print('hcffdv1 execute')
+
+        # load image and convert to grayscale
+        image = cv2.imread(file.path)
+        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+
+        height, width = gray.shape
+        min_size = int(min(width, height) / 10)
+
+        # detect faces
+        faces = self.face_cascade.detectMultiScale(
+            gray,
+            scaleFactor=1.1,
+            minNeighbors=5,
+            minSize=(min_size, min_size)
+        )
+
+        # convert faces to result list
+        result = []
+        for x, y, w, h in faces:
+            result.append(self.create_bounding_box_result(
+                x / width,
+                y / height,
+                w / width,
+                h / height
+            ))
+
+        return result

+ 9 - 0
models/haarcascade_frontalface_default/configuration.json

@@ -0,0 +1,9 @@
+{
+  "name": "Stump-based 24x24 discrete(?) adaboost frontal face detector",
+  "description": "Created by Rainer Lienhart\n\nThis model downloads and uses a classifier from\nhttps://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml\n\nPlease follow the link and read the prepended license before using this model in any of your projects.",
+  "supports": [],
+  "code": {
+    "module": "Pipeline",
+    "class": "Pipeline"
+  }
+}

+ 0 - 33
pycs/ApplicationStatus.py

@@ -1,33 +0,0 @@
-from json import load, dump
-from os.path import exists
-
-from pycs.observable import ObservableDict
-
-
-class ApplicationStatus(ObservableDict):
-    def __init__(self, path_to_settings_json=None, settings=None):
-        # load settings if file exists
-        self.__path_to_settings_json = path_to_settings_json
-
-        if settings is not None:
-            settings = settings
-        elif path_to_settings_json is not None and exists(path_to_settings_json):
-            with open(path_to_settings_json, 'r') as settings_json:
-                settings = load(settings_json)
-        else:
-            settings = {}
-
-        # initialize data structure
-        super().__init__({
-            'settings': settings,
-            'models': {},
-            'labels': {},
-            'projects': {}
-        })
-
-        # subscribe to settings change to write it to file
-        self['settings'].subscribe(self.__write_settings_to_file)
-
-    def __write_settings_to_file(self, settings, keys):
-        with open(self.__path_to_settings_json, 'w') as settings_json:
-            dump(settings, settings_json, indent=4)

+ 281 - 0
pycs/database/Database.py

@@ -0,0 +1,281 @@
+import sqlite3
+from contextlib import closing
+from time import time
+from typing import Optional, List
+
+from pycs.database.File import File
+from pycs.database.LabelProvider import LabelProvider
+from pycs.database.Model import Model
+from pycs.database.Project import Project
+from pycs.database.Result import Result
+from pycs.database.discovery.LabelProviderDiscovery import discover as discover_label_providers
+from pycs.database.discovery.ModelDiscovery import discover as discover_models
+
+
+class Database:
+    """
+    opens an sqlite database and allows to access several objects
+    """
+
+    def __init__(self, path: str = ':memory:'):
+        """
+        opens or creates a given sqlite database and creates all required tables
+
+        :param path: path to sqlite database
+        """
+        # save properties
+        self.path = path
+
+        # initialize database connection
+        self.con = sqlite3.connect(path)
+        self.con.execute("PRAGMA foreign_keys = ON")
+
+        # create tables
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('''
+                CREATE TABLE IF NOT EXISTS models (
+                    id          INTEGER PRIMARY KEY,
+                    name        TEXT                NOT NULL,
+                    description TEXT,
+                    root_folder TEXT                NOT NULL UNIQUE,
+                    supports    TEXT                NOT NULL
+                )
+            ''')
+            cursor.execute('''
+                CREATE TABLE IF NOT EXISTS label_providers (
+                    id          INTEGER PRIMARY KEY,
+                    name        TEXT                NOT NULL,
+                    description TEXT,
+                    root_folder TEXT                NOT NULL UNIQUE
+                )
+            ''')
+
+            cursor.execute('''
+                CREATE TABLE IF NOT EXISTS projects (
+                    id             INTEGER PRIMARY KEY,
+                    name           TEXT                NOT NULL,
+                    description    TEXT,
+                    created        INTEGER             NOT NULL,
+                    model          INTEGER,
+                    label_provider INTEGER,
+                    root_folder    TEXT                NOT NULL UNIQUE,
+                    external_data  BOOL                NOT NULL,
+                    data_folder    TEXT                NOT NULL,
+                    FOREIGN KEY (model) REFERENCES models(id)
+                        ON UPDATE CASCADE ON DELETE SET NULL,
+                    FOREIGN KEY (label_provider) REFERENCES label_providers(id)
+                        ON UPDATE CASCADE ON DELETE SET NULL
+                )
+            ''')
+            cursor.execute('''
+                CREATE TABLE IF NOT EXISTS labels (
+                    id        INTEGER PRIMARY KEY,
+                    project   INTEGER             NOT NULL,
+                    created   INTEGER             NOT NULL,
+                    reference TEXT,
+                    name      TEXT                NOT NULL,
+                    FOREIGN KEY (project) REFERENCES projects(id)
+                        ON UPDATE CASCADE ON DELETE CASCADE,
+                    UNIQUE(project, reference)
+                )
+            ''')
+            cursor.execute('''
+                CREATE TABLE IF NOT EXISTS files (
+                    id        INTEGER PRIMARY KEY,
+                    uuid      TEXT                NOT NULL,
+                    project   INTEGER             NOT NULL,
+                    type      TEXT                NOT NULL,
+                    name      TEXT                NOT NULL,
+                    extension TEXT                NOT NULL,
+                    size      INTEGER             NOT NULL,
+                    created   INTEGER             NOT NULL,
+                    path      TEXT                NOT NULL,
+                    frames    INTEGER,
+                    fps       FLOAT,
+                    FOREIGN KEY (project) REFERENCES projects(id)
+                        ON UPDATE CASCADE ON DELETE CASCADE,
+                    UNIQUE(project, path)
+                )
+            ''')
+            cursor.execute('''
+                CREATE TABLE IF NOT EXISTS results (
+                    id     INTEGER PRIMARY KEY,
+                    file   INTEGER             NOT NULL,
+                    origin TEXT                NOT NULL,
+                    type   TEXT                NOT NULL,
+                    label  INTEGER,
+                    data   TEXT                NOT NULL,
+                    FOREIGN KEY (file) REFERENCES files(id)
+                        ON UPDATE CASCADE ON DELETE CASCADE
+                )
+            ''')
+            cursor.execute('''
+                CREATE INDEX IF NOT EXISTS idx_results_label ON results(label)
+            ''')
+
+        # run discovery modules
+        with self:
+            discover_models(self.con)
+            discover_label_providers(self.con)
+
+    def __enter__(self):
+        self.con.__enter__()
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.con.__exit__(exc_type, exc_val, exc_tb)
+
+    def models(self) -> List[Model]:
+        """
+        get a list of all available models
+
+        :return: list of all available models
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM models')
+            return list(map(
+                lambda row: Model(self, row),
+                cursor.fetchall()
+            ))
+
+    def model(self, identifier: int) -> Optional[Model]:
+        """
+        get a model using its unique identifier
+
+        :param identifier: unique identifier
+        :return: model
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM models WHERE id = ?', [identifier])
+            row = cursor.fetchone()
+
+            if row is not None:
+                return Model(self, row)
+
+            return None
+
+    def label_providers(self) -> List[LabelProvider]:
+        """
+        get a list of all available label providers
+
+        :return: list of all available label providers
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM label_providers')
+            return list(map(
+                lambda row: LabelProvider(self, row),
+                cursor.fetchall()
+            ))
+
+    def label_provider(self, identifier: int) -> Optional[LabelProvider]:
+        """
+        get a label provider using its unique identifier
+
+        :param identifier: unique identifier
+        :return: label provider
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM label_providers WHERE id = ?', [identifier])
+            row = cursor.fetchone()
+
+            if row is not None:
+                return LabelProvider(self, row)
+
+            return None
+
+    def projects(self) -> List[Project]:
+        """
+        get a list of all available projects
+
+        :return: list of all available projects
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM projects')
+            return list(map(
+                lambda row: Project(self, row),
+                cursor.fetchall()
+            ))
+
+    def project(self, identifier: int) -> Optional[Project]:
+        """
+        get a project using its unique identifier
+
+        :param identifier: unique identifier
+        :return: project
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM projects WHERE id = ?', [identifier])
+            row = cursor.fetchone()
+
+            if row is not None:
+                return Project(self, row)
+
+            return None
+
+    def create_project(self,
+                       name: str,
+                       description: str,
+                       model: Model,
+                       label_provider: Optional[LabelProvider],
+                       root_folder: str,
+                       external_data: bool,
+                       data_folder: str):
+        """
+        insert a project into the database
+
+        :param name: project name
+        :param description: project description
+        :param model: used model
+        :param label_provider: used label provider (optional)
+        :param root_folder: path to project folder
+        :param external_data: whether an external data directory is used
+        :param data_folder: path to data folder
+        :return: created project
+        """
+        # prepare some values
+        created = int(time())
+        label_provider_id = label_provider.identifier if label_provider is not None else None
+
+        # insert statement
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('''
+                INSERT INTO projects (
+                    name, description, created, model, label_provider, root_folder, external_data, 
+                    data_folder
+                )
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+            ''', (name, description, created, model.identifier, label_provider_id, root_folder,
+                  external_data, data_folder))
+
+            return self.project(cursor.lastrowid)
+
+    def file(self, identifier) -> Optional[File]:
+        """
+        get a file using its unique identifier
+
+        :param identifier: unique identifier
+        :return: file
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM files WHERE id = ?', [identifier])
+            row = cursor.fetchone()
+
+            if row is not None:
+                return File(self, row)
+
+            return None
+
+    def result(self, identifier) -> Optional[Result]:
+        """
+        get a result using its unique identifier
+
+        :param identifier: unique identifier
+        :return: result
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM results WHERE id = ?', [identifier])
+            row = cursor.fetchone()
+
+            if row is not None:
+                return Result(self, row)
+
+            return None

+ 129 - 0
pycs/database/File.py

@@ -0,0 +1,129 @@
+from contextlib import closing
+from json import dumps
+from typing import List, Optional
+
+from pycs.database.Result import Result
+
+
+class File:
+    """
+    database class for files
+    """
+
+    def __init__(self, database, row):
+        self.database = database
+
+        self.identifier = row[0]
+        self.uuid = row[1]
+        self.project_id = row[2]
+        self.type = row[3]
+        self.name = row[4]
+        self.extension = row[5]
+        self.size = row[6]
+        self.created = row[7]
+        self.path = row[8]
+        self.frames = row[9]
+        self.fps = row[10]
+
+    def project(self):
+        """
+        get the project associated with this file
+
+        :return: project
+        """
+        return self.database.project(self.project_id)
+
+    def remove(self) -> None:
+        """
+        remove this file from the database
+
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('DELETE FROM files WHERE id = ?', [self.identifier])
+
+    def previous(self):
+        """
+        get the predecessor of this file
+
+        :return: another file
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                SELECT * FROM files WHERE id < ? AND project = ? ORDER BY id DESC LIMIT 1
+            ''', (self.identifier, self.project_id))
+            row = cursor.fetchone()
+
+            if row is not None:
+                return File(self.database, row)
+
+            return None
+
+    def next(self):
+        """
+        get the successor of this file
+
+        :return: another file
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                    SELECT * FROM files WHERE id > ? AND project = ? ORDER BY id ASC LIMIT 1
+                ''', (self.identifier, self.project_id))
+            row = cursor.fetchone()
+
+            if row is not None:
+                return File(self.database, row)
+
+            return None
+
+    def results(self) -> List[Result]:
+        """
+        get a list of all results associated with this file
+
+        :return: list of results
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM results WHERE file = ?', [self.identifier])
+            return list(map(
+                lambda row: Result(self.database, row),
+                cursor.fetchall()
+            ))
+
+    def result(self, identifier) -> Optional[Result]:
+        """
+        get a specific result using its unique identifier
+
+        :param identifier: unique identifier
+        :return: result
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                SELECT * FROM results WHERE id = ? AND file = ?
+            ''', (identifier, self.identifier))
+            row = cursor.fetchone()
+
+            if row is not None:
+                return Result(self.database, row)
+
+            return None
+
+    def create_result(self, origin, result_type, label, data):
+        """
+        create a result
+
+        :param origin:
+        :param result_type:
+        :param label:
+        :param data:
+        :return:
+        """
+        if data is not None:
+            data = dumps(data)
+
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                INSERT INTO results (file, origin, type, label, data)
+                VALUES              (   ?,      ?,    ?,     ?,    ?)
+            ''', (self.identifier, origin, result_type, label, data))
+
+            return self.result(cursor.lastrowid)

+ 44 - 0
pycs/database/Label.py

@@ -0,0 +1,44 @@
+from contextlib import closing
+
+
+class Label:
+    """
+    database class for labels
+    """
+
+    def __init__(self, database, row):
+        self.database = database
+
+        self.identifier = row[0]
+        self.project_id = row[1]
+        self.created = row[2]
+        self.reference = row[3]
+        self.name = row[4]
+
+    def project(self):
+        """
+        get the project this label is associated with
+
+        :return: project
+        """
+        return self.database.project(self.project_id)
+
+    def set_name(self, name: str):
+        """
+        set this labels name
+
+        :param name: new name
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE labels SET name = ? WHERE id = ?', (name, self.identifier))
+            self.name = name
+
+    def remove(self):
+        """
+        remove this label from the database
+
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('DELETE FROM labels WHERE id = ?', [self.identifier])

+ 12 - 0
pycs/database/LabelProvider.py

@@ -0,0 +1,12 @@
+class LabelProvider:
+    """
+    database class for label providers
+    """
+
+    def __init__(self, database, row):
+        self.database = database
+
+        self.identifier = row[0]
+        self.name = row[1]
+        self.description = row[2]
+        self.root_folder = row[3]

+ 56 - 0
pycs/database/Model.py

@@ -0,0 +1,56 @@
+from contextlib import closing
+from json import loads, dumps
+
+
+class Model:
+    """
+    database class for label providers
+    """
+
+    def __init__(self, database, row):
+        self.database = database
+
+        self.identifier = row[0]
+        self.name = row[1]
+        self.description = row[2]
+        self.root_folder = row[3]
+        self.supports = loads(row[4])
+
+    def copy_to(self, name: str, root_folder: str):
+        """
+        copies the models database entry while changing name and root_folder
+
+        :param name: copy name
+        :param root_folder: copy root folder
+        :return: copy
+        """
+        supports = dumps(self.supports)
+
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                INSERT INTO models (name, description, root_folder, supports)
+                VALUES (?, ?, ?, ?)
+                ON CONFLICT (root_folder)
+                DO UPDATE SET name = ?, description = ?, supports = ?
+            ''', (name, self.description, root_folder, supports, name, self.description, supports))
+
+            # lastrowid is 0 if on conflict clause applies.
+            # If this is the case we do an extra query to receive the row id.
+            if cursor.lastrowid > 0:
+                row_id = cursor.lastrowid
+                insert = True
+            else:
+                cursor.execute('SELECT id FROM models WHERE root_folder = ?', [root_folder])
+                row_id = cursor.fetchone()[0]
+                insert = False
+
+        return self.database.model(row_id), insert
+
+    def remove(self):
+        """
+        remove this model from the database
+
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('DELETE FROM models WHERE id = ?', [self.identifier])

+ 249 - 0
pycs/database/Project.py

@@ -0,0 +1,249 @@
+from contextlib import closing
+from os.path import join
+from time import time
+from typing import List, Optional, Tuple
+
+from pycs.database.File import File
+from pycs.database.Label import Label
+from pycs.database.LabelProvider import LabelProvider
+from pycs.database.Model import Model
+
+
+class Project:
+    """
+    database class for projects
+    """
+
+    def __init__(self, database, row):
+        self.database = database
+
+        self.identifier = row[0]
+        self.name = row[1]
+        self.description = row[2]
+        self.created = row[3]
+        self.model_id = row[4]
+        self.label_provider_id = row[5]
+        self.root_folder = row[6]
+        self.external_data = bool(row[7])
+        self.data_folder = row[8]
+
+    def model(self) -> Model:
+        """
+        get the model this project is associated with
+
+        :return: model
+        """
+        return self.database.model(self.model_id)
+
+    def label_provider(self) -> Optional[LabelProvider]:
+        """
+        get the label provider this project is associated with
+
+        :return: label provider
+        """
+        if self.label_provider_id is not None:
+            return self.database.label_provider(self.label_provider_id)
+
+        return None
+
+    def labels(self) -> List[Label]:
+        """
+        get a list of labels associated with this project
+
+        :return: list of labels
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM labels WHERE project = ?', [self.identifier])
+            return list(map(
+                lambda row: Label(self.database, row),
+                cursor.fetchall()
+            ))
+
+    def label(self, identifier: int) -> Optional[Label]:
+        """
+        get a label using its unique identifier
+
+        :param identifier: unique identifier
+        :return: label
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM labels WHERE id = ? AND project = ?',
+                           (identifier, self.identifier))
+            row = cursor.fetchone()
+
+            if row is not None:
+                return Label(self.database, row)
+
+            return None
+
+    def create_label(self, name: str, reference: str = None) -> Tuple[Optional[Label], bool]:
+        """
+        create a label for this project. If there is already a label with the same reference
+        in the database its name is updated.
+
+        :param name: label name
+        :param reference: label reference
+        :return: created or edited label, insert
+        """
+        created = int(time())
+
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                INSERT INTO labels (project, created, reference, name)
+                VALUES (?, ?, ?, ?)
+                ON CONFLICT (project, reference) DO
+                UPDATE SET name = ?
+            ''', (self.identifier, created, reference, name, name))
+
+            # lastrowid is 0 if on conflict clause applies.
+            # If this is the case we do an extra query to receive the row id.
+            if cursor.lastrowid > 0:
+                row_id = cursor.lastrowid
+                insert = True
+            else:
+                cursor.execute('SELECT id FROM labels WHERE project = ? AND reference = ?',
+                               (self.identifier, reference))
+                row_id = cursor.fetchone()[0]
+                insert = False
+
+        return self.label(row_id), insert
+
+    def remove(self) -> None:
+        """
+        remove this project from the database
+
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('DELETE FROM projects WHERE id = ?', [self.identifier])
+
+    def set_name(self, name: str) -> None:
+        """
+        set this projects name
+
+        :param name: new name
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE projects SET name = ? WHERE id = ?', (name, self.identifier))
+            self.name = name
+
+    def set_description(self, description: str) -> None:
+        """
+        set this projects description
+
+        :param description: new description
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE projects SET description = ? WHERE id = ?',
+                           (description, self.identifier))
+            self.description = description
+
+    def count_files(self) -> int:
+        """
+        count files associated with this project
+
+        :return: count
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT COUNT(*) FROM files WHERE project = ?', [self.identifier])
+            return cursor.fetchone()[0]
+
+    def files(self, offset=0, limit=-1) -> List[File]:
+        """
+        get a list of files associated with this project
+
+        :param offset: file offset
+        :param limit: file limit
+        :return: list of files
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM files WHERE project = ? ORDER BY id ASC LIMIT ? OFFSET ?',
+                           (self.identifier, limit, offset))
+
+            return list(map(
+                lambda row: File(self.database, row),
+                cursor.fetchall()
+            ))
+
+    def files_without_results(self) -> List[File]:
+        """
+        get a list of files without associated results
+
+        :return: list of files
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                SELECT files.*
+                FROM files
+                LEFT JOIN results ON files.id = results.file
+                WHERE files.project = ? AND results.id IS NULL
+                ORDER BY id ASC
+            ''', [self.identifier])
+
+            return list(map(
+                lambda row: File(self.database, row),
+                cursor.fetchall()
+            ))
+
+    def file(self, identifier) -> Optional[File]:
+        """
+        get a file using its unique identifier
+
+        :param identifier: unique identifier
+        :return: file
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM files WHERE id = ? AND project = ?',
+                           (identifier, self.identifier))
+            row = cursor.fetchone()
+
+            if row is not None:
+                return File(self.database, row)
+
+            return None
+
+    def add_file(self, uuid: str, file_type: str, name: str, extension: str, size: int,
+                 filename: str, frames: int = None, fps: float = None) -> Tuple[File, bool]:
+        """
+        add a file to this project
+
+        :param uuid: unique identifier which is used for temporary files
+        :param file_type: file type (either image or video)
+        :param name: file name
+        :param extension: file extension
+        :param size: file size
+        :param filename: actual name in filesystem
+        :param frames: frame count
+        :param fps: frames per second
+        :return: file
+        """
+        created = int(time())
+        path = join(self.data_folder, filename + extension)
+
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                INSERT INTO files (
+                    uuid, project, type, name, extension, size, created, path, frames, fps
+                )
+                VALUES (  
+                       ?,       ?,    ?,    ?,         ?,    ?,       ?,    ?,      ?,   ?
+                )
+                ON CONFLICT (project, path) DO
+                UPDATE SET type = ?, name = ?, extension = ?, size = ?, frames = ?, fps = ?
+            ''', (uuid, self.identifier, file_type, name, extension, size, created, path, frames,
+                  fps, file_type, name, extension, size, frames, fps))
+
+            # lastrowid is 0 if on conflict clause applies.
+            # If this is the case we do an extra query to receive the row id.
+            if cursor.lastrowid > 0:
+                row_id = cursor.lastrowid
+                insert = True
+            else:
+                cursor.execute('SELECT id FROM files WHERE project = ? AND path = ?',
+                               (self.identifier, path))
+                row_id = cursor.fetchone()[0]
+                insert = False
+
+        return self.file(row_id), insert

+ 66 - 0
pycs/database/Result.py

@@ -0,0 +1,66 @@
+from contextlib import closing
+
+from json import dumps, loads
+
+
+class Result:
+    """
+    database class for results
+    """
+
+    def __init__(self, database, row):
+        self.database = database
+
+        self.identifier = row[0]
+        self.file_id = row[1]
+        self.origin = row[2]
+        self.type = row[3]
+        self.label = row[4]
+        self.data = loads(row[5])
+
+    def remove(self):
+        """
+        remove this result from the database
+
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('DELETE FROM results WHERE id = ?', [self.identifier])
+
+    def set_origin(self, origin: str):
+        """
+        set this results origin
+
+        :param origin: either 'user' or 'pipeline'
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE results SET origin = ? WHERE id = ?', (origin, self.identifier))
+            self.origin = origin
+
+    def set_label(self, label: int):
+        """
+        set this results label
+
+        :param label: label id
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE results SET label = ? WHERE id = ?', (label, self.identifier))
+            self.label = label
+
+    def set_data(self, data: dict):
+        """
+        set this results data object
+
+        :param data: data object
+        :return:
+        """
+        if data is None:
+            data_txt = None
+        else:
+            data_txt = dumps(data)
+
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE results SET data = ? WHERE id = ?', (data_txt, self.identifier))
+            self.data = data

+ 31 - 0
pycs/database/discovery/LabelProviderDiscovery.py

@@ -0,0 +1,31 @@
+from contextlib import closing
+from glob import glob
+from json import load
+from os import path
+
+
+def discover(database):
+    """
+    find label providers in the corresponding folder and add them to the database
+
+    :param database:
+    :return:
+    """
+    with closing(database.cursor()) as cursor:
+        # list folders in labels/
+        for folder in glob('labels/*'):
+            # load distribution.json
+            with open(path.join(folder, 'configuration.json'), 'r') as file:
+                label = load(file)
+
+            # extract data
+            name = label['name']
+            description = label['description'] if 'description' in label else None
+
+            # save to database
+            cursor.execute('''
+                INSERT INTO label_providers (name, description, root_folder)
+                VALUES (?, ?, ?)
+                ON CONFLICT (root_folder)
+                DO UPDATE SET name = ?, description = ?
+            ''', (name, description, folder, name, description))

+ 32 - 0
pycs/database/discovery/ModelDiscovery.py

@@ -0,0 +1,32 @@
+from contextlib import closing
+from glob import glob
+from json import load, dumps
+from os import path
+
+
+def discover(database):
+    """
+    find models in the corresponding folder and add them to the database
+
+    :param database:
+    :return:
+    """
+    with closing(database.cursor()) as cursor:
+        # list folders in models/
+        for folder in glob('models/*'):
+            # load distribution.json
+            with open(path.join(folder, 'configuration.json'), 'r') as file:
+                model = load(file)
+
+            # extract data
+            name = model['name']
+            description = model['description'] if 'description' in model else None
+            supports = dumps(model['supports'])
+
+            # save to database
+            cursor.execute('''
+                INSERT INTO models (name, description, root_folder, supports)
+                VALUES (?, ?, ?, ?)
+                ON CONFLICT (root_folder)
+                DO UPDATE SET name = ?, description = ?, supports = ?
+            ''', (name, description, folder, supports, name, description, supports))

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

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

+ 218 - 341
pycs/frontend/WebServer.py

@@ -1,379 +1,256 @@
 from glob import glob
-from json import dumps
 from os import path, getcwd
 from os.path import exists
-from time import time
 
 import eventlet
 import socketio
-from flask import Flask, make_response, send_from_directory, request
-from werkzeug import formparser
-
-from pycs.ApplicationStatus import ApplicationStatus
-from pycs.util.GenericWrapper import GenericWrapper
-from pycs.util.ProgressFileWriter import ProgressFileWriter
-from pycs.util.RecursiveDictionary import set_recursive
+from flask import Flask, send_from_directory
+
+from pycs.database.Database import Database
+from pycs.frontend.endpoints.ListJobs import ListJobs
+from pycs.frontend.endpoints.ListLabelProviders import ListLabelProviders
+from pycs.frontend.endpoints.ListModels import ListModels
+from pycs.frontend.endpoints.ListProjects import ListProjects
+from pycs.frontend.endpoints.data.GetFile import GetFile
+from pycs.frontend.endpoints.data.GetPreviousAndNextFile import GetPreviousAndNextFile
+from pycs.frontend.endpoints.data.GetResizedFile import GetResizedFile
+from pycs.frontend.endpoints.data.RemoveFile import RemoveFile
+from pycs.frontend.endpoints.data.UploadFile import UploadFile
+from pycs.frontend.endpoints.jobs.RemoveJob import RemoveJob
+from pycs.frontend.endpoints.labels.CreateLabel import CreateLabel
+from pycs.frontend.endpoints.labels.EditLabelName import EditLabelName
+from pycs.frontend.endpoints.labels.ListLabels import ListLabels
+from pycs.frontend.endpoints.labels.RemoveLabel import RemoveLabel
+from pycs.frontend.endpoints.pipelines.FitModel import FitModel
+from pycs.frontend.endpoints.pipelines.PredictFile import PredictFile
+from pycs.frontend.endpoints.pipelines.PredictModel import PredictModel
+from pycs.frontend.endpoints.projects.CreateProject import CreateProject
+from pycs.frontend.endpoints.projects.EditProjectDescription import EditProjectDescription
+from pycs.frontend.endpoints.projects.EditProjectName import EditProjectName
+from pycs.frontend.endpoints.projects.ExecuteExternalStorage import ExecuteExternalStorage
+from pycs.frontend.endpoints.projects.ExecuteLabelProvider import ExecuteLabelProvider
+from pycs.frontend.endpoints.projects.GetProjectModel import GetProjectModel
+from pycs.frontend.endpoints.projects.ListFiles import ListFiles
+from pycs.frontend.endpoints.projects.RemoveProject import RemoveProject
+from pycs.frontend.endpoints.results.ConfirmResult import ConfirmResult
+from pycs.frontend.endpoints.results.CreateResult import CreateResult
+from pycs.frontend.endpoints.results.EditResultData import EditResultData
+from pycs.frontend.endpoints.results.EditResultLabel import EditResultLabel
+from pycs.frontend.endpoints.results.GetProjectResults import GetProjectResults
+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
 
 
 class WebServer:
-    def __init__(self, app_status: ApplicationStatus):
+    """
+    wrapper class for flask and socket.io which initializes most networking
+    """
+
+    # pylint: disable=line-too-long
+    def __init__(self, host, port, database: Database, jobs: JobRunner):
         # initialize web server
         if exists('webui/index.html'):
             print('production build')
 
-            # find svg icons and add them as separate static files to
-            # set their correct mime type / content_type
+            # find static files and folders
             static_files = {}
 
             for file_path in glob('webui/*'):
                 file_path = file_path.replace('\\', '/')
                 static_files[file_path[5:]] = file_path
 
+            # separately add svg files and set their correct mime type
             for svg_path in glob('webui/img/*.svg'):
                 svg_path = svg_path.replace('\\', '/')
                 static_files[svg_path[5:]] = {'content_type': 'image/svg+xml', 'filename': svg_path}
 
-            self.__sio = socketio.Server()
+            # create service objects
+            self.__sio = socketio.Server(async_mode='eventlet')
             self.__flask = Flask(__name__)
             self.__app = socketio.WSGIApp(self.__sio, self.__flask, static_files=static_files)
 
-            def response(data=None):
-                if data is None:
-                    return make_response()
-                else:
-                    return make_response(data)
-
+            # overwrite root path to serve index.html
             @self.__flask.route('/', methods=['GET'])
             def index():
+                # pylint: disable=unused-variable
                 return send_from_directory(path.join(getcwd(), 'webui'), 'index.html')
+
         else:
             print('development build')
 
-            self.__sio = socketio.Server(cors_allowed_origins='*')
+            # create service objects
+            self.__sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet')
             self.__flask = Flask(__name__)
             self.__app = socketio.WSGIApp(self.__sio, self.__flask)
 
-            def response(data=None):
-                if data is None:
-                    rsp = make_response()
-                else:
-                    rsp = make_response(data)
-
-                rsp.headers['Access-Control-Allow-Origin'] = '*'
-                return rsp
-
-        # save every change in application status and send it to the client
-        app_status.subscribe(self.__update_application_status, immediate=True)
-
-        # define events
-        @self.__sio.event
-        def connect(id, msg):
-            self.__sio.emit('app_status', {
-                'keys': [],
-                'value': self.__status
-            }, to=id)
-
-        @self.__flask.route('/settings', methods=['POST'])
-        def edit_settings():
-            data = request.get_json(force=True)
-            set_recursive(data, app_status['settings'])
-
-            return response()
-
-        @self.__flask.route('/projects', methods=['POST'])
-        def create_project():
-            data = request.get_json(force=True)
-            app_status['projects'].create_project(data['name'], data['description'], data['model'], data['label'], data['unmanaged'])
-
-            return response()
-
-        @self.__flask.route('/projects/<identifier>', methods=['POST'])
-        def edit_project(identifier):
-            data = request.get_json(force=True)
-
-            if 'delete' in data.keys():
-                app_status['projects'].delete_project(identifier)
-            elif 'fit' in data.keys():
-                app_status['projects'].fit(identifier)
-            elif 'predictAll' in data.keys():
-                app_status['projects'].predict(identifier)
-            elif 'predictUnlabeled' in data.keys():
-                app_status['projects'].predict(identifier, unlabeled=True)
-            elif 'predict' in data.keys():
-                app_status['projects'].predict(identifier, data['predict'])
-            else:
-                app_status['projects'].update_project(identifier, data)
-
-            return response()
-
-        @self.__flask.route('/projects/<identifier>/data', methods=['POST'])
-        def upload_file(identifier):
-            # abort if project id is not valid
-            if identifier not in app_status['projects'].keys():
-                # TODO return 404
-                return make_response('project does not exist', 500)
-
-            # get project and upload path
-            project = app_status['projects'][identifier]
-            upload_path, file_uuid = project.new_media_file_path()
-
-            # prepare wrapper objects
-            job = GenericWrapper()
-            file_name = GenericWrapper()
-            file_extension = GenericWrapper()
-            file_size = GenericWrapper(0)
-
-            # save upload to file
-            def custom_stream_factory(total_content_length, filename, content_type, content_length=None):
-                file_name.value, file_extension.value = path.splitext(filename)
-                file_path = path.join(upload_path, f'{file_uuid}{file_extension.value}')
-
-                # add job to app status
-                project['jobs'][file_uuid] = {
-                    'id': file_uuid,
-                    'type': 'upload',
-                    'progress': 0,
-                    'filename': filename,
-                    'created': int(time()),
-                    'finished': None
-                }
-                job.value = project['jobs'][file_uuid]
-
-                # define progress callback
-                length = content_length if content_length is not None and content_length != 0 else total_content_length
-
-                def callback(progress):
-                    file_size.value += progress
-                    relative = progress / length
-
-                    if relative - job.value['progress'] > 0.02:
-                        job.value['progress'] = relative
-
-                # open file handler
-                return ProgressFileWriter(file_path, 'wb', callback)
-
-            stream, form, files = formparser.parse_form_data(request.environ, stream_factory=custom_stream_factory)
-
-            if 'file' not in files.keys():
-                return make_response('no file uploaded', 500)
-
-            # set progress to 1 after upload is done
-            job = job.value
-
-            job['progress'] = 1
-            job['finished'] = int(time())
-
-            # add to project files
-            project.add_media_file(file_uuid, file_name.value, file_extension.value, file_size.value, job['created'])
-
-            # return default success response
-            return response()
-
-        @self.__flask.route('/projects/<project_identifier>/unmanaged/<int:file_number>', methods=['GET'])
-        def get_unmanaged_file(project_identifier, file_number):
-            # abort if project id is not valid
-            if project_identifier not in app_status['projects'].keys():
-                return make_response('project does not exist', 500)
-
-            project = app_status['projects'][project_identifier]
-
-            # abort if file id is not valid
-            if file_number >= len(project.unmanaged_files_keys):
-                return make_response('file does not exist', 500)
-
-            file_identifier = project.unmanaged_files_keys[file_number]
-            if file_identifier not in project.unmanaged_files_keys:
-                return make_response('file does not exist', 500)
-
-            target_object = project.unmanaged_files[file_identifier]
-
-            # return element data
-            return response(target_object.get_data())
-
-        @self.__flask.route('/projects/<project_identifier>/data/<file_identifier>', defaults={'size': None}, methods=['GET'])
-        @self.__flask.route('/projects/<project_identifier>/data/<file_identifier>/<size>', methods=['GET'])
-        def get_file(project_identifier, file_identifier, size):
-            # abort if project id is not valid
-            if project_identifier not in app_status['projects'].keys():
-                return make_response('project does not exist', 500)
-
-            project = app_status['projects'][project_identifier]
-
-            # abort if file id is not valid
-            target_object = project.get_media_file(file_identifier)
-            if target_object is None:
-                return make_response('file does not exist', 500)
-
-            # resize image to requested size
-            if size is not None:
-                target_object = target_object.resize(size)
-
-            # construct directory and filename
-            file_directory = path.join(getcwd(), target_object.directory)
-            file_name = target_object.full_name
-
-            # return data
-            return send_from_directory(file_directory, file_name)
-
-        @self.__flask.route('/projects/<project_identifier>/data/<file_identifier>', methods=['POST'])
-        def add_result(project_identifier, file_identifier):
-            # abort if project id is not valid
-            if project_identifier not in app_status['projects'].keys():
-                return make_response('project does not exist', 500)
-
-            project = app_status['projects'][project_identifier]
-
-            # abort if file id is not valid
-            target_object = project.get_media_file(file_identifier)
-            if target_object is None:
-                return make_response('file does not exist', 500)
-
-            # add result
-            result = request.get_json(force=True)
-            if result:
-                if 'delete' in result:
-                    project.remove_media_file(file_identifier)
-                elif 'reset' in result:
-                    target_object.remove_results()
-                elif 'x' not in result:
-                    if result['label']:
-                        result['type'] = 'labeled-image'
-                        target_object.add_global_result(result)
-                    else:
-                        target_object.remove_global_result()
-                else:
-                    result['type'] = 'labeled-bounding-box' if 'label' in result else 'bounding-box'
-                    target_object.add_result(result)
-
-            # return default success response
-            return response()
-
-        @self.__flask.route('/projects/<project_identifier>/data/<file_identifier>/<result_identifier>', methods=['POST'])
-        def edit_result(project_identifier, file_identifier, result_identifier):
-            # abort if project id is not valid
-            if project_identifier not in app_status['projects'].keys():
-                return make_response('project does not exist', 500)
-
-            project = app_status['projects'][project_identifier]
-
-            # abort if file id is not valid
-            target_object = project.get_media_file(file_identifier)
-            if target_object is None:
-                return make_response('file does not exist', 500)
-
-            # parse post data
-            result = request.get_json(force=True)
-            if result:
-                # remove result
-                if 'delete' in result.keys():
-                    target_object.remove_result(result_identifier)
-
-                # update result
-                else:
-                    target_object.update_result(result_identifier, result)
-
-            # return default success response
-            return response()
-
-        @self.__flask.route('/projects/<project_identifier>/labels', methods=['POST'])
-        def create_label(project_identifier):
-            # abort if project id is not valid
-            if project_identifier not in app_status['projects'].keys():
-                return make_response('project does not exist', 500)
-
-            project = app_status['projects'][project_identifier]
-
-            # add result
-            result = request.get_json(force=True)
-            if result:
-                project.add_label(result['name'])
-
-            # return default success response
-            return response()
-
-        @self.__flask.route('/projects/<project_identifier>/labels/<label_identifier>', methods=['POST'])
-        def edit_label(project_identifier, label_identifier):
-            # abort if project id is not valid
-            if project_identifier not in app_status['projects'].keys():
-                return make_response('project does not exist', 500)
-
-            project = app_status['projects'][project_identifier]
-
-            # parse post data
-            result = request.get_json(force=True)
-            if result:
-                # remove label
-                if 'delete' in result.keys():
-                    project.remove_label(label_identifier)
-
-                # update label
-                else:
-                    project.update_label(label_identifier, result['name'])
-
-            # return default success response
-            return response()
-
-        @self.__flask.route('/projects/<project_identifier>/predictions', methods=['GET'])
-        def download_predictions(project_identifier):
-            # abort if project id is not valid
-            if project_identifier not in app_status['projects'].keys():
-                return make_response('project does not exist', 404)
-
-            project = app_status['projects'][project_identifier]
-
-            # create export
-            result = []
-
-            def mk_obj(type, name, extension, predictions):
-                data_res = {
-                    'type': type,
-                    'filename': name + extension,
-                    'predictions': []
-                }
-
-                for result_key in predictions:
-                    result_obj = predictions[result_key]
-                    data_res['predictions'].append(result_obj)
-
-                return data_res
-
-            for data_key in project['data']:
-                data_obj = project['data'][data_key]
-                result.append(mk_obj(
-                    data_obj['type'],
-                    data_obj['name'],
-                    data_obj['extension'],
-                    data_obj['predictionResults']
-                ))
-
-            for data_key in project.unmanaged_files:
-                data_obj = project.unmanaged_files[data_key].get_data()
-                result.append(mk_obj(
-                    data_obj['type'],
-                    data_obj['id'],
-                    data_obj['extension'],
-                    data_obj['predictionResults']
-                ))
-
-            # send to user
-            rsp = make_response(dumps(result))
-            rsp.headers['Content-Type'] = 'text/json;charset=UTF-8'
-            rsp.headers['Content-Disposition'] = 'attachment;filename=predictions.json'
-            return rsp
+            # set access control header to allow requests from Vue.js development server
+            @self.__flask.after_request
+            def after_request(response):
+                # pylint: disable=unused-variable
+                response.headers['Access-Control-Allow-Origin'] = '*'
+                return response
+
+        # set json encoder so database objects are serialized correctly
+        self.__flask.json_encoder = JSONEncoder
+
+        # create notification manager
+        notifications = NotificationManager(self.__sio)
+
+        jobs.on_create(notifications.create_job)
+        jobs.on_start(notifications.edit_job)
+        jobs.on_progress(notifications.edit_job)
+        jobs.on_finish(notifications.edit_job)
+        jobs.on_remove(notifications.remove_job)
+
+        # jobs
+        self.__flask.add_url_rule(
+            '/jobs',
+            view_func=ListJobs.as_view('list_jobs', jobs)
+        )
+        self.__flask.add_url_rule(
+            '/jobs/<identifier>/remove',
+            view_func=RemoveJob.as_view('remove_job', jobs)
+        )
+
+        # models
+        self.__flask.add_url_rule(
+            '/models',
+            view_func=ListModels.as_view('list_models', database)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/model',
+            view_func=GetProjectModel.as_view('get_project_model', database)
+        )
+
+        # labels
+        self.__flask.add_url_rule(
+            '/label_providers',
+            view_func=ListLabelProviders.as_view('label_providers', database)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/labels',
+            view_func=ListLabels.as_view('list_labels', database)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/labels',
+            view_func=CreateLabel.as_view('create_label', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/labels/<int:label_id>/remove',
+            view_func=RemoveLabel.as_view('remove_label', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/labels/<int:label_id>/name',
+            view_func=EditLabelName.as_view('edit_label_name', database, notifications)
+        )
+
+        # data
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/data',
+            view_func=UploadFile.as_view('upload_file', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/data/<int:start>/<int:length>',
+            view_func=ListFiles.as_view('list_files', database)
+        )
+        self.__flask.add_url_rule(
+            '/data/<int:identifier>/remove',
+            view_func=RemoveFile.as_view('remove_file', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/data/<int:file_id>',
+            view_func=GetFile.as_view('get_file', database)
+        )
+        self.__flask.add_url_rule(
+            '/data/<int:file_id>/<resolution>',
+            view_func=GetResizedFile.as_view('get_resized_file', database)
+        )
+        self.__flask.add_url_rule(
+            '/data/<int:file_id>/previous_next',
+            view_func=GetPreviousAndNextFile.as_view('get_previous_and_next_file', database)
+        )
+
+        # results
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/results',
+            view_func=GetProjectResults.as_view('get_project_results', database)
+        )
+        self.__flask.add_url_rule(
+            '/data/<int:file_id>/results',
+            view_func=GetResults.as_view('get_results', database)
+        )
+        self.__flask.add_url_rule(
+            '/data/<int:file_id>/results',
+            view_func=CreateResult.as_view('create_result', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/data/<int:file_id>/reset',
+            view_func=ResetResults.as_view('reset_results', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/results/<int:result_id>/remove',
+            view_func=RemoveResult.as_view('remove_result', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/results/<int:result_id>/confirm',
+            view_func=ConfirmResult.as_view('confirm_result', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/results/<int:result_id>/label',
+            view_func=EditResultLabel.as_view('edit_result_label', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/results/<int:result_id>/data',
+            view_func=EditResultData.as_view('edit_result_data', database, notifications)
+        )
+
+        # projects
+        self.__flask.add_url_rule(
+            '/projects',
+            view_func=ListProjects.as_view('list_projects', database)
+        )
+        self.__flask.add_url_rule(
+            '/projects',
+            view_func=CreateProject.as_view('create_project', database, notifications, jobs)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/label_provider',
+            view_func=ExecuteLabelProvider.as_view('execute_label_provider', database, notifications, jobs)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/external_storage',
+            view_func=ExecuteExternalStorage.as_view('execute_external_storage', database, notifications, jobs)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/remove',
+            view_func=RemoveProject.as_view('remove_project', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/name',
+            view_func=EditProjectName.as_view('edit_project_name', database, notifications)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:identifier>/description',
+            view_func=EditProjectDescription.as_view('edit_project_description', database, notifications)
+        )
+
+        # pipelines
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/pipelines/fit',
+            view_func=FitModel.as_view('fit_model', database, jobs)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/pipelines/predict',
+            view_func=PredictModel.as_view('predict_model', database, notifications, jobs)
+        )
+        self.__flask.add_url_rule(
+            '/data/<int:file_id>/predict',
+            view_func=PredictFile.as_view('predict_file', database, notifications, jobs)
+        )
 
         # finally start web server
-        host = app_status['settings']['frontend']['host']
-        port = app_status['settings']['frontend']['port']
-
         eventlet.wsgi.server(eventlet.listen((host, port)), self.__app)
-
-    def __update_application_status(self, status, keys):
-        value = status
-        for key in keys[:-1]:
-            value = value[key]
-
-        self.__status = status
-        self.__sio.emit('app_status', {
-            'keys': keys[:-1],
-            'value': value
-        })

+ 19 - 0
pycs/frontend/endpoints/ListJobs.py

@@ -0,0 +1,19 @@
+from flask import jsonify
+from flask.views import View
+
+from pycs.jobs.JobRunner import JobRunner
+
+
+class ListJobs(View):
+    """
+    return a list ob jobs
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, jobs: JobRunner):
+        # pylint: disable=invalid-name
+        self.jobs = jobs
+
+    def dispatch_request(self):
+        return jsonify(self.jobs.list())

+ 19 - 0
pycs/frontend/endpoints/ListLabelProviders.py

@@ -0,0 +1,19 @@
+from flask import jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class ListLabelProviders(View):
+    """
+    returns a list of all available label providers
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self):
+        return jsonify(self.db.label_providers())

+ 19 - 0
pycs/frontend/endpoints/ListModels.py

@@ -0,0 +1,19 @@
+from flask import jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class ListModels(View):
+    """
+    get a list of all available models
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self):
+        return jsonify(self.db.models())

+ 19 - 0
pycs/frontend/endpoints/ListProjects.py

@@ -0,0 +1,19 @@
+from flask import jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class ListProjects(View):
+    """
+    get a list of all available projects
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self):
+        return jsonify(self.db.projects())

+ 35 - 0
pycs/frontend/endpoints/data/GetFile.py

@@ -0,0 +1,35 @@
+from os import path, getcwd
+
+from flask import abort, send_from_directory
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class GetFile(View):
+    """
+    returns binary file data
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self, file_id: int):
+        # get file from database
+        file = self.db.file(file_id)
+
+        if file is None:
+            return abort(404)
+
+        # get absolute path
+        if path.isabs(file.path):
+            abs_file_path = file.path
+        else:
+            abs_file_path = path.join(getcwd(), file.path)
+
+        # return data
+        file_directory, file_name = path.split(abs_file_path)
+        return send_from_directory(file_directory, file_name)

+ 32 - 0
pycs/frontend/endpoints/data/GetPreviousAndNextFile.py

@@ -0,0 +1,32 @@
+from flask import abort, jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class GetPreviousAndNextFile(View):
+    """
+    return the previous and the next file
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self, file_id: int):
+        # get file from database
+        file = self.db.file(file_id)
+
+        if file is None:
+            return abort(404)
+
+        # get previous and next
+        result = {
+            'previous': file.previous(),
+            'next': file.next()
+        }
+
+        # return data
+        return jsonify(result)

+ 137 - 0
pycs/frontend/endpoints/data/GetResizedFile.py

@@ -0,0 +1,137 @@
+import re
+from os import path, getcwd
+
+import cv2
+from PIL import Image
+from eventlet import tpool
+from flask import abort, send_from_directory
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class GetResizedFile(View):
+    """
+    returns binary file after resizing
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self, file_id: int, resolution: str):
+        # get file from database
+        file = self.db.file(file_id)
+        if file is None:
+            return abort(404)
+
+        project = file.project()
+
+        # extract desired resolution
+        resolution = re.split(r'[^0-9]', resolution)
+        max_width = int(resolution[0])
+        max_height = int(resolution[1]) if len(resolution) > 1 else 2 ** 24
+
+        # send data
+        file_directory, file_name = tpool.execute(self.resize_file,
+                                                  project, file, max_width, max_height)
+        return send_from_directory(file_directory, file_name)
+
+    @staticmethod
+    def resize_file(project, file, max_width, max_height):
+        """
+        If file type equals video this function extracts a thumbnail first. It calls resize_image
+        to resize and returns the resized files directory and name.
+
+        :param project: associated project
+        :param file: file object
+        :param max_width: maximum image or thumbnail width
+        :param max_height: maximum image or thumbnail height
+        :return: resized file directory, resized file name
+        """
+        # get absolute path
+        if path.isabs(file.path):
+            abs_file_path = file.path
+        else:
+            abs_file_path = path.join(getcwd(), file.path)
+
+        # extract video thumbnail
+        if file.type == 'video':
+            abs_target_path = path.join(getcwd(), project.root_folder, 'temp', f'{file.uuid}.jpg')
+            GetResizedFile.create_thumbnail(abs_file_path, abs_target_path)
+
+            abs_file_path = abs_target_path
+
+        # resize image file
+        abs_target_path = path.join(getcwd(), project.root_folder,
+                                    'temp', f'{file.uuid}_{max_width}_{max_height}.jpg')
+        result = GetResizedFile.resize_image(abs_file_path, abs_target_path, max_width, max_height)
+
+        # return path
+        if result is not None:
+            return path.split(abs_target_path)
+
+        return path.split(abs_file_path)
+
+    @staticmethod
+    def resize_image(file_path, target_path, max_width, max_height):
+        """
+        resize an image so width < max_width and height < max_height
+
+        :param file_path: path to source file
+        :param target_path: path to target file
+        :param max_width: maximum image width
+        :param max_height: maximum image height
+        :return:
+        """
+        # return if file exists
+        if path.exists(target_path):
+            return True
+
+        # load full size image
+        image = Image.open(file_path)
+        img_width, img_height = image.size
+
+        # abort if file is smaller than desired
+        if img_width < max_width and img_height < max_height:
+            return None
+
+        # calculate target size
+        target_width = int(max_width)
+        target_height = int(max_width * img_height / img_width)
+
+        if target_height > max_height:
+            target_height = int(max_height)
+            target_width = int(max_height * img_width / img_height)
+
+        # resize image
+        resized_image = image.resize((target_width, target_height))
+
+        # save to file
+        resized_image.save(target_path, quality=80)
+        return True
+
+    @staticmethod
+    def create_thumbnail(file_path, target_path):
+        """
+        extract a thumbnail from a video
+
+        :param file_path: path to source file
+        :param target_path: path to target file
+        :return:
+        """
+        # return if file exists
+        if path.exists(target_path):
+            return
+
+        # load video
+        video = cv2.VideoCapture(file_path)
+
+        # create thumbnail
+        _, image = video.read()
+        cv2.imwrite(target_path, image)
+
+        # close video file
+        video.release()

+ 51 - 0
pycs/frontend/endpoints/data/RemoveFile.py

@@ -0,0 +1,51 @@
+from os import remove
+
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class RemoveFile(View):
+    """
+    remove a given file
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, identifier):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'remove' not in data or data['remove'] is not True:
+            return abort(400)
+
+        # start transaction
+        with self.db:
+            # find file
+            file = self.db.file(identifier)
+            if file is None:
+                return abort(400)
+
+            # check if project uses an external data directory
+            project = file.project()
+            if project.external_data:
+                return abort(400)
+
+            # remove file from database
+            file.remove()
+
+            # remove file from folder
+            remove(file.path)
+
+            # TODO remove temp files
+
+        # send notification
+        self.nm.remove_file(file)
+        return make_response()

+ 94 - 0
pycs/frontend/endpoints/data/UploadFile.py

@@ -0,0 +1,94 @@
+from os import path
+from uuid import uuid1
+
+from eventlet import tpool
+from flask import make_response, request, abort
+from flask.views import View
+from werkzeug import formparser
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+from pycs.util.FileParser import file_info
+
+
+class UploadFile(View):
+    """
+    save file uploads
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+        self.data_folder = None
+        self.file_id = None
+        self.file_name = None
+        self.file_extension = None
+        self.file_size = None
+
+    def dispatch_request(self, identifier):
+        # find project
+        project = self.db.project(identifier)
+
+        if project is None:
+            return abort(404)
+
+        # abort if external storage is used
+        if project.external_data:
+            return abort(400)
+
+        # get upload path and id
+        self.data_folder = project.data_folder
+        self.file_id = str(uuid1())
+
+        # parse upload data
+        _, _, files = tpool.execute(formparser.parse_form_data,
+                                    request.environ, stream_factory=self.custom_stream_factory)
+
+        # abort if there is no file entry in uploaded data
+        if 'file' not in files.keys():
+            return abort(400)
+
+        # detect file type
+        try:
+            ftype, frames, fps = file_info(self.data_folder, self.file_id, self.file_extension)
+        except ValueError:
+            return abort(400)
+
+        # add to project files
+        with self.db:
+            file, _ = project.add_file(self.file_id, ftype, self.file_name, self.file_extension,
+                                       self.file_size, self.file_id, frames, fps)
+
+        # send update
+        self.nm.create_file(file)
+
+        # return default success response
+        return make_response()
+
+    def custom_stream_factory(self, total_content_length, filename, content_type,
+                              content_length=None):
+        """
+        save some useful information and open a file handler to save the uploaded file to
+
+        :param total_content_length:
+        :param filename:
+        :param content_type:
+        :param content_length:
+        :return:
+        """
+        # pylint: disable=unused-argument
+        # set relevant properties
+        self.file_name, self.file_extension = path.splitext(filename)
+
+        if content_length is not None and content_length > 0:
+            self.file_size = content_length
+        else:
+            self.file_size = total_content_length
+
+        # open file handler
+        file_path = path.join(self.data_folder, f'{self.file_id}{self.file_extension}')
+        return open(file_path, 'wb')

+ 29 - 0
pycs/frontend/endpoints/jobs/RemoveJob.py

@@ -0,0 +1,29 @@
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.jobs.JobRunner import JobRunner
+
+
+class RemoveJob(View):
+    """
+    remove a job from the job runners list
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, jobs: JobRunner):
+        # pylint: disable=invalid-name
+        self.jobs = jobs
+
+    def dispatch_request(self, identifier):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'remove' not in data or data['remove'] is not True:
+            abort(400)
+
+        # remove job
+        self.jobs.remove(identifier)
+
+        # return success response
+        return make_response()

+ 41 - 0
pycs/frontend/endpoints/labels/CreateLabel.py

@@ -0,0 +1,41 @@
+from flask import request, abort, make_response
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class CreateLabel(View):
+    """
+    create a new label
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, identifier):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'name' not in data:
+            abort(400)
+
+        # find project
+        project = self.db.project(identifier)
+        if project is None:
+            abort(404)
+
+        # start transaction
+        with self.db:
+            # insert label
+            label, _ = project.create_label(data['name'])
+
+        # send notification
+        self.nm.create_label(label)
+
+        # return success response
+        return make_response()

+ 46 - 0
pycs/frontend/endpoints/labels/EditLabelName.py

@@ -0,0 +1,46 @@
+from flask import request, abort, make_response
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class EditLabelName(View):
+    """
+    edit a labels name
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, project_id: int, label_id: int):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'name' not in data:
+            abort(400)
+
+        # find project
+        project = self.db.project(project_id)
+        if project is None:
+            abort(404)
+
+        # find label
+        label = project.label(label_id)
+        if label is None:
+            abort(404)
+
+        # start transaction
+        with self.db:
+            # insert label
+            label.set_name(data['name'])
+
+        # send notification
+        self.nm.edit_label(label)
+
+        # return success response
+        return make_response()

+ 28 - 0
pycs/frontend/endpoints/labels/ListLabels.py

@@ -0,0 +1,28 @@
+from flask import abort, jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class ListLabels(View):
+    """
+    return a list of labels for a given project
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self, identifier):
+        # find project
+        project = self.db.project(identifier)
+        if project is None:
+            abort(404)
+
+        # get labels
+        labels = project.labels()
+
+        # return labels
+        return jsonify(labels)

+ 46 - 0
pycs/frontend/endpoints/labels/RemoveLabel.py

@@ -0,0 +1,46 @@
+from flask import request, abort, make_response
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class RemoveLabel(View):
+    """
+    remove a label from database
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, project_id: int, label_id: int):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'remove' not in data or data['remove'] is not True:
+            abort(400)
+
+        # find project
+        project = self.db.project(project_id)
+        if project is None:
+            abort(404)
+
+        # find label
+        label = project.label(label_id)
+        if label is None:
+            abort(404)
+
+        # start transaction
+        with self.db:
+            # remove label
+            label.remove()
+
+        # send notification
+        self.nm.remove_label(label)
+
+        # return success response
+        return make_response()

+ 57 - 0
pycs/frontend/endpoints/pipelines/FitModel.py

@@ -0,0 +1,57 @@
+from contextlib import closing
+
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.interfaces.AnnotatedMediaFile import AnnotatedMediaFile
+from pycs.jobs.JobGroupBusyException import JobGroupBusyException
+from pycs.jobs.JobRunner import JobRunner
+from pycs.util.PipelineUtil import load_from_root_folder as load_pipeline
+
+
+class FitModel(View):
+    """
+    use annotated data to fit a model
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, jobs: JobRunner):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.jobs = jobs
+
+    def dispatch_request(self, project_id):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'fit' not in data or data['fit'] is not True:
+            return abort(400)
+
+        # find project
+        project = self.db.project(project_id)
+        if project is None:
+            return abort(404)
+
+        # get model
+        model = project.model()
+
+        # get data and results
+        files = list(map(AnnotatedMediaFile, project.files()))
+
+        # create job
+        try:
+            self.jobs.run(project,
+                          'Model Interaction',
+                          f'{project.name} (fit model with new data)',
+                          f'{project.name}/model-interaction',
+                          self.load_and_fit, model, files)
+        except JobGroupBusyException:
+            return abort(400)
+
+        return make_response()
+
+    def load_and_fit(self, model, files):
+        with closing(load_pipeline(model.root_folder)) as pipeline:
+            pipeline.fit(files)

+ 81 - 0
pycs/frontend/endpoints/pipelines/PredictFile.py

@@ -0,0 +1,81 @@
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.endpoints.pipelines.PredictModel import PredictModel
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+from pycs.interfaces.MediaFile import MediaFile
+from pycs.jobs.JobGroupBusyException import JobGroupBusyException
+from pycs.jobs.JobRunner import JobRunner
+
+
+class PredictFile(View):
+    """
+    load a model and create predictions or a given file
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager, jobs: JobRunner):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+        self.jobs = jobs
+
+    def dispatch_request(self, file_id):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'predict' not in data or data['predict'] is not True:
+            return abort(400)
+
+        # find file
+        file = self.db.file(file_id)
+        if file is None:
+            return abort(404)
+
+        media_file = MediaFile(file)
+
+        # get project and model
+        project = file.project()
+        model = project.model()
+
+        # create job
+        def store(index, length, result):
+            with self.db:
+                for remove in file.results():
+                    if remove.origin == 'pipeline':
+                        remove.remove()
+                        self.nm.remove_result(remove)
+
+                for entry in result:
+                    file_type = entry['type']
+                    del entry['type']
+
+                    if 'label' in entry:
+                        label = entry['label']
+                        del entry['label']
+                    else:
+                        label = None
+
+                    if file_type == 'labeled-image':
+                        for remove in file.results():
+                            remove.remove()
+                            self.nm.remove_result(remove)
+
+                    created = file.create_result('pipeline', file_type, label, entry)
+                    self.nm.create_result(created)
+
+            return (index + 1) / length
+
+        try:
+            self.jobs.run(project,
+                          'Model Interaction',
+                          f'{project.name} (create predictions)',
+                          f'{project.name}/model-interaction',
+                          PredictModel.load_and_predict, model, [media_file],
+                          progress=store)
+        except JobGroupBusyException:
+            return abort(400)
+
+        return make_response()

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

@@ -0,0 +1,96 @@
+from contextlib import closing
+
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+from pycs.interfaces.MediaFile import MediaFile
+from pycs.jobs.JobGroupBusyException import JobGroupBusyException
+from pycs.jobs.JobRunner import JobRunner
+from pycs.util.PipelineUtil import load_from_root_folder as load_pipeline
+
+
+class PredictModel(View):
+    """
+    load a model and create predictions
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager, jobs: JobRunner):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+        self.jobs = jobs
+
+    def dispatch_request(self, project_id):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'predict' not in data or data['predict'] not in ['all', 'new']:
+            return abort(400)
+
+        # find project
+        project = self.db.project(project_id)
+        if project is None:
+            return abort(404)
+
+        # get model
+        model = project.model()
+
+        # get data and results
+        if data['predict'] == 'new':
+            files = project.files_without_results()
+        else:
+            files = project.files()
+
+        objects = list(map(MediaFile, files))
+
+        # create job
+        def store(index, length, result):
+            with self.db:
+                for remove in files[index].results():
+                    if remove.origin == 'pipeline':
+                        remove.remove()
+                        self.nm.remove_result(remove)
+
+                for entry in result:
+                    file_type = entry['type']
+                    del entry['type']
+
+                    if 'label' in entry:
+                        label = entry['label']
+                        del entry['label']
+                    else:
+                        label = None
+
+                    if file_type == 'labeled-image':
+                        for remove in files[index].results():
+                            remove.remove()
+                            self.nm.remove_result(remove)
+
+                    created = files[index].create_result('pipeline', file_type, label, entry)
+                    self.nm.create_result(created)
+
+            return (index + 1) / length
+
+        try:
+            self.jobs.run(project,
+                          'Model Interaction',
+                          f'{project.name} (create predictions)',
+                          f'{project.name}/model-interaction',
+                          self.load_and_predict, model, objects,
+                          progress=store)
+        except JobGroupBusyException:
+            return abort(400)
+
+        return make_response()
+
+    @staticmethod
+    def load_and_predict(model, files):
+        with closing(load_pipeline(model.root_folder)) as pipeline:
+            length = len(files)
+            for index in range(length):
+                result = pipeline.execute(files[index])
+                yield index, length, result

+ 100 - 0
pycs/frontend/endpoints/projects/CreateProject.py

@@ -0,0 +1,100 @@
+from os import mkdir
+from os import path
+from shutil import copytree
+from uuid import uuid1
+
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.endpoints.projects.ExecuteExternalStorage import ExecuteExternalStorage
+from pycs.frontend.endpoints.projects.ExecuteLabelProvider import ExecuteLabelProvider
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+from pycs.jobs.JobRunner import JobRunner
+
+
+class CreateProject(View):
+    """
+    create a project, insert data into database and create directories
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager, jobs: JobRunner):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+        self.jobs = jobs
+
+    def dispatch_request(self):
+        # extract request data
+        data = request.get_json(force=True)
+
+        name = data['name']
+        description = data['description']
+
+        # start transaction
+        with self.db:
+            # find model
+            model_id = int(data['model'])
+            model = self.db.model(model_id)
+
+            if model is None:
+                abort(404)
+
+            # find label provider
+            if data['label'] is None:
+                label_provider = None
+            else:
+                label_provider_id = int(data['label'])
+                label_provider = self.db.label_provider(label_provider_id)
+
+                if label_provider is None:
+                    abort(404)
+
+            # create project folder
+            project_folder = path.join('projects', str(uuid1()))
+            mkdir(project_folder)
+
+            temp_folder = path.join(project_folder, 'temp')
+            mkdir(temp_folder)
+
+            # check project data directory
+            if data['external'] is None:
+                external_data = False
+                data_folder = path.join(project_folder, 'data')
+
+                mkdir(data_folder)
+            else:
+                external_data = True
+                data_folder = data['external']
+
+                # check if exists
+                if not path.exists(data_folder):
+                    return abort(400)
+
+            # copy model to project folder
+            model_folder = path.join(project_folder, 'model')
+            copytree(model.root_folder, model_folder)
+
+            model, _ = model.copy_to(f'{model.name} ({name})', model_folder)
+
+            # create entry in database
+            created = self.db.create_project(name, description, model, label_provider,
+                                             project_folder, external_data, data_folder)
+
+        # execute label provider and add labels to project
+        if label_provider is not None:
+            ExecuteLabelProvider.execute_label_provider(self.db, self.nm, self.jobs, created,
+                                                        label_provider)
+
+        # find media files
+        if external_data:
+            ExecuteExternalStorage.find_media_files(self.db, self.nm, self.jobs, created)
+
+        # fire event
+        self.nm.create_model(model)
+        self.nm.create_project(created)
+
+        # return success response
+        return make_response()

+ 38 - 0
pycs/frontend/endpoints/projects/EditProjectDescription.py

@@ -0,0 +1,38 @@
+from flask import make_response, abort
+from flask.views import View, request
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class EditProjectDescription(View):
+    """
+    edit a projects description
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, identifier):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'description' not in data or not data['description']:
+            return abort(400)
+
+        # start transaction
+        with self.db:
+            # find project
+            project = self.db.project(identifier)
+            if project is None:
+                return abort(404)
+
+            # set description
+            project.set_description(data['description'])
+            self.nm.edit_project(project)
+
+        return make_response()

+ 38 - 0
pycs/frontend/endpoints/projects/EditProjectName.py

@@ -0,0 +1,38 @@
+from flask import make_response, abort
+from flask.views import View, request
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class EditProjectName(View):
+    """
+    edit a projects name
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, identifier):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'name' not in data or not data['name']:
+            return abort(400)
+
+        # start transaction
+        with self.db:
+            # find project
+            project = self.db.project(identifier)
+            if project is None:
+                return abort(404)
+
+            # set name
+            project.set_name(data['name'])
+            self.nm.edit_project(project)
+
+            return make_response()

+ 117 - 0
pycs/frontend/endpoints/projects/ExecuteExternalStorage.py

@@ -0,0 +1,117 @@
+from os import listdir
+from os import path
+from os.path import isfile
+from uuid import uuid1
+
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.database.Project import Project
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+from pycs.jobs.JobGroupBusyException import JobGroupBusyException
+from pycs.jobs.JobRunner import JobRunner
+from pycs.util.FileParser import file_info
+
+
+class ExecuteExternalStorage(View):
+    """
+    find media files stored in a projects data_folder
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager, jobs: JobRunner):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+        self.jobs = jobs
+
+    def dispatch_request(self, identifier):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'execute' not in data or data['execute'] is not True:
+            return abort(400)
+
+        # find project
+        project = self.db.project(identifier)
+        if project is None:
+            return abort(404)
+
+        if not project.external_data:
+            return abort(400)
+
+        # execute label provider and add labels to project
+        try:
+            self.find_media_files(self.db, self.nm, self.jobs, project)
+        except JobGroupBusyException:
+            return abort(400)
+
+        return make_response()
+
+    @staticmethod
+    def find_media_files(db: Database, nm: NotificationManager, jobs: JobRunner, project: Project):
+        """
+        start a job that finds media files in the projects data_folder and adds them to the
+        database afterwards
+
+        :param db: database object
+        :param nm: notification manager object
+        :param jobs: job runner object
+        :param project: project
+        :return:
+        """
+
+        # pylint: disable=invalid-name
+        # find lists the given data folder and prepares item dictionaries
+        def find():
+            files = listdir(project.data_folder)
+            length = len(files)
+
+            elements = []
+            current = 0
+
+            for file_name in files:
+                file_path = path.join(project.data_folder, file_name)
+                if not isfile(file_path):
+                    continue
+
+                file_name, file_extension = path.splitext(file_name)
+                file_size = path.getsize(file_path)
+
+                try:
+                    ftype, frames, fps = file_info(project.data_folder, file_name, file_extension)
+                except ValueError:
+                    continue
+
+                elements.append((ftype, file_name, file_extension, file_size, frames, fps))
+                current += 1
+
+                if len(elements) >= 200:
+                    yield elements, current, length
+                    elements = []
+
+            if len(elements) > 0:
+                yield elements, current, length
+
+        # progress inserts elements into the database and fires events
+        def progress(elements, current, length):
+            with db:
+                for ftype, file_name, file_extension, file_size, frames, fps in elements:
+                    uuid = str(uuid1())
+                    file, insert = project.add_file(uuid, ftype, file_name, file_extension,
+                                                    file_size, file_name, frames, fps)
+
+                    if insert:
+                        nm.create_file(file)
+
+            return current / length
+
+        # run job with given functions
+        jobs.run(project,
+                 'Find Media Files',
+                 project.name,
+                 f'{project.identifier}/find-files',
+                 find,
+                 progress=progress)

+ 92 - 0
pycs/frontend/endpoints/projects/ExecuteLabelProvider.py

@@ -0,0 +1,92 @@
+from contextlib import closing
+
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.database.LabelProvider import LabelProvider
+from pycs.database.Project import Project
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+from pycs.jobs.JobGroupBusyException import JobGroupBusyException
+from pycs.jobs.JobRunner import JobRunner
+from pycs.util.LabelProviderUtil import load_from_root_folder as load_label_provider
+
+
+class ExecuteLabelProvider(View):
+    """
+    execute the label provider associated with a passed project identifier
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager, jobs: JobRunner):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+        self.jobs = jobs
+
+    def dispatch_request(self, identifier):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'execute' not in data or data['execute'] is not True:
+            return abort(400)
+
+        # find project
+        project = self.db.project(identifier)
+        if project is None:
+            return abort(404)
+
+        # get label provider
+        label_provider = project.label_provider()
+        if label_provider is None:
+            return abort(400)
+
+        # execute label provider and add labels to project
+        try:
+            self.execute_label_provider(self.db, self.nm, self.jobs, project, label_provider)
+        except JobGroupBusyException:
+            return abort(400)
+
+        return make_response()
+
+    @staticmethod
+    def execute_label_provider(db: Database, nm: NotificationManager, jobs: JobRunner,
+                               project: Project, label_provider: LabelProvider):
+        """
+        start a job that loads and executes a label provider and saves its results to the
+        database afterwards
+
+        :param db: database object
+        :param nm: notification manager object
+        :param jobs: job runner object
+        :param project: project
+        :param label_provider: label provider
+        :return:
+        """
+
+        # pylint: disable=invalid-name
+        # receive loads and executes the given label provider
+        def receive():
+            with closing(load_label_provider(label_provider.root_folder)) as label_provider_impl:
+                provided_labels = label_provider_impl.get_labels()
+                return provided_labels
+
+        # result adds the received labels to the database and fires events
+        def result(provided_labels):
+            with db:
+                for label in provided_labels:
+                    created_label, insert = project.create_label(label['name'], label['id'])
+
+                    if insert:
+                        nm.create_label(created_label)
+                    else:
+                        nm.edit_label(created_label)
+
+        # run job with given functions
+        jobs.run(project,
+                 'Label Provider',
+                 f'{project.name} ({label_provider.name})',
+                 f'{project.identifier}/label-provider',
+                 receive,
+                 result=result)

+ 28 - 0
pycs/frontend/endpoints/projects/GetProjectModel.py

@@ -0,0 +1,28 @@
+from flask import abort, jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class GetProjectModel(View):
+    """
+    return the model used by a given project
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self, identifier):
+        # find project
+        project = self.db.project(identifier)
+        if project is None:
+            abort(404)
+
+        # get model
+        model = project.model()
+
+        # return model
+        return jsonify(model)

+ 33 - 0
pycs/frontend/endpoints/projects/ListFiles.py

@@ -0,0 +1,33 @@
+from flask import abort, jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class ListFiles(View):
+    """
+    return a list of files for a given project
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self, project_id: int, start: int, length: int):
+        # find project
+        project = self.db.project(project_id)
+
+        if project is None:
+            return abort(404)
+
+        # get file list
+        count = project.count_files()
+        files = project.files(start, length)
+
+        # return files
+        return jsonify({
+            'count': count,
+            'files': files
+        })

+ 50 - 0
pycs/frontend/endpoints/projects/RemoveProject.py

@@ -0,0 +1,50 @@
+from shutil import rmtree
+
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class RemoveProject(View):
+    """
+    remove a project from database and remove its directory
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, identifier):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'remove' not in data or data['remove'] is not True:
+            abort(400)
+
+        # start transaction
+        with self.db:
+            # find project
+            project = self.db.project(identifier)
+            if project is None:
+                abort(404)
+
+            # remove from database
+            project.remove()
+
+            # remove model from database
+            model = project.model()
+            model.remove()
+
+            # remove from file system
+            rmtree(project.root_folder)
+
+            # send update
+            self.nm.remove_model(model)
+            self.nm.remove_project(project)
+
+            return make_response()

+ 37 - 0
pycs/frontend/endpoints/results/ConfirmResult.py

@@ -0,0 +1,37 @@
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class ConfirmResult(View):
+    """
+    confirm a result (change its origin to user)
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, result_id: int):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'confirm' not in data or data['confirm'] is not True:
+            return abort(400)
+
+        # find result
+        result = self.db.result(result_id)
+        if result is None:
+            return abort(404)
+
+        # start transaction
+        with self.db:
+            result.set_origin('user')
+
+        self.nm.edit_result(result)
+        return make_response()

+ 62 - 0
pycs/frontend/endpoints/results/CreateResult.py

@@ -0,0 +1,62 @@
+from flask import request, abort, jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class CreateResult(View):
+    """
+    create a result for a file
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, file_id: int):
+        # extract request data
+        request_data = request.get_json(force=True)
+
+        if 'type' not in request_data:
+            return abort(400)
+        if request_data['type'] not in ['labeled-image', 'bounding-box']:
+            return abort(400)
+
+        rtype = request_data['type']
+
+        if 'label' in request_data and request_data['label']:
+            label = request_data['label']
+        elif request_data['type'] == 'labeled-image':
+            return abort(400)
+        else:
+            label = None
+
+        if 'data' in request_data and request_data['data']:
+            data = request_data['data']
+        elif request_data['type'] == 'bounding-box':
+            return abort(400)
+        else:
+            data = {}
+
+        # find file
+        file = self.db.file(file_id)
+        if file is None:
+            return abort(404)
+
+        # start transaction
+        with self.db:
+            # find full-image labels and remove them
+            for result in file.results():
+                if result.type == 'labeled-image':
+                    result.remove()
+                    self.nm.remove_result(result)
+
+            # insert into database
+            result = file.create_result('user', rtype, label, data)
+            self.nm.create_result(result)
+
+        return jsonify(result)

+ 38 - 0
pycs/frontend/endpoints/results/EditResultData.py

@@ -0,0 +1,38 @@
+from flask import make_response, abort
+from flask.views import View, request
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class EditResultData(View):
+    """
+    edit a result and set its data object
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, result_id: int):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'data' not in data:
+            return abort(400)
+
+        # find result
+        result = self.db.result(result_id)
+        if result is None:
+            return abort(404)
+
+        # start transaction and set label
+        with self.db:
+            result.set_data(data['data'])
+            result.set_origin('user')
+
+        self.nm.edit_result(result)
+        return make_response()

+ 42 - 0
pycs/frontend/endpoints/results/EditResultLabel.py

@@ -0,0 +1,42 @@
+from flask import make_response, abort
+from flask.views import View, request
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class EditResultLabel(View):
+    """
+    edit a result and set its label
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, result_id: int):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'label' not in data:
+            return abort(400)
+
+        # find result
+        result = self.db.result(result_id)
+        if result is None:
+            return abort(404)
+
+        # abort if label is empty for labeled-images
+        if result.type == 'labeled-image' and not data['label']:
+            return abort(400)
+
+        # start transaction and set label
+        with self.db:
+            result.set_label(data['label'])
+            result.set_origin('user')
+
+        self.nm.edit_result(result)
+        return make_response()

+ 29 - 0
pycs/frontend/endpoints/results/GetProjectResults.py

@@ -0,0 +1,29 @@
+from flask import abort, jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.interfaces.AnnotatedMediaFile import AnnotatedMediaFile
+
+
+class GetProjectResults(View):
+    """
+    get a list of all files and annotations for a project
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self, project_id: int):
+        # get project from database
+        project = self.db.project(project_id)
+        if project is None:
+            return abort(404)
+
+        # get results
+        files = list(map(AnnotatedMediaFile, project.files()))
+
+        # return result
+        return jsonify(files)

+ 28 - 0
pycs/frontend/endpoints/results/GetResults.py

@@ -0,0 +1,28 @@
+from flask import abort, jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class GetResults(View):
+    """
+    returns a list of a files results
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+    def dispatch_request(self, file_id: int):
+        # get file from database
+        file = self.db.file(file_id)
+        if file is None:
+            return abort(404)
+
+        # get results
+        results = file.results()
+
+        # return result
+        return jsonify(results)

+ 37 - 0
pycs/frontend/endpoints/results/RemoveResult.py

@@ -0,0 +1,37 @@
+from flask import make_response, request, abort
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class RemoveResult(View):
+    """
+    removes a result from the database
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, result_id: int):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'remove' not in data or data['remove'] is not True:
+            abort(400)
+
+        # find result
+        result = self.db.result(result_id)
+        if result is None:
+            return abort(404)
+
+        # start transaction
+        with self.db:
+            result.remove()
+
+        self.nm.remove_result(result)
+        return make_response()

+ 43 - 0
pycs/frontend/endpoints/results/ResetResults.py

@@ -0,0 +1,43 @@
+from flask import make_response, abort
+from flask.views import View, request
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class ResetResults(View):
+    """
+    removes all results from a given file
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, file_id: int):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'reset' not in data or data['reset'] is not True:
+            abort(400)
+
+        # find file
+        file = self.db.file(file_id)
+        if file is None:
+            return abort(404)
+
+        # get results
+        results = file.results()
+
+        # start transaction
+        with self.db:
+            for result in results:
+                result.remove()
+
+        for result in results:
+            self.nm.remove_result(result)
+
+        return make_response()

+ 183 - 0
pycs/frontend/notifications/NotificationManager.py

@@ -0,0 +1,183 @@
+from socketio import Server
+
+from pycs.database.File import File
+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
+
+
+class NotificationManager:
+    """
+    send events via a socket.io connection
+    """
+
+    def __init__(self, sio: Server):
+        self.sio = sio
+        self.json = JSONEncoder()
+
+    def __emit(self, name, obj):
+        enc = self.json.default(obj)
+        self.sio.emit(name, enc)
+
+    def create_job(self, created_job: Job):
+        """
+        fire create-job event
+
+        :param created_job:
+        :return:
+        """
+        print('create_job', created_job)
+        self.__emit('create-job', created_job)
+
+    def edit_job(self, edited_job: Job):
+        """
+        fire edit-job event
+
+        :param edited_job:
+        :return:
+        """
+        print('edit_job', edited_job)
+        self.__emit('edit-job', edited_job)
+
+    def remove_job(self, removed_job: Job):
+        """
+        fire remove-job event
+
+        :param removed_job:
+        :return:
+        """
+        print('remove_job', removed_job)
+        self.__emit('remove-job', removed_job)
+
+    def create_model(self, created_model: Model):
+        """
+        fire create-model event
+
+        :param created_model:
+        :return:
+        """
+        print('create_model', created_model)
+        self.__emit('create-model', created_model)
+
+    def remove_model(self, removed_model: Model):
+        """
+        fire remove-model event
+
+        :param removed_model:
+        :return:
+        """
+        print('remove_model', removed_model)
+        self.__emit('remove-model', removed_model)
+
+    def create_project(self, created_project: Project):
+        """
+        fire create-project event
+
+        :param created_project:
+        :return:
+        """
+        print('create_project', created_project)
+        self.__emit('create-project', created_project)
+
+    def remove_project(self, removed_project: Project):
+        """
+        fire remove-project event
+
+        :param removed_project:
+        :return:
+        """
+        print('remove_project', removed_project)
+        self.__emit('remove-project', removed_project)
+
+    def edit_project(self, edited_project: Project):
+        """
+        fire edit-project event
+
+        :param edited_project:
+        :return:
+        """
+        print('edit_project', edited_project)
+        self.__emit('edit-project', edited_project)
+
+    def create_label(self, created_label: Label):
+        """
+        fire create-label event
+
+        :param created_label:
+        :return:
+        """
+        print('create_label', created_label)
+        self.__emit('create-label', created_label)
+
+    def edit_label(self, edited_label: Label):
+        """
+        fire edit-label event
+
+        :param edited_label:
+        :return:
+        """
+        print('edit_label', edited_label)
+        self.__emit('edit-label', edited_label)
+
+    def remove_label(self, removed_label: Label):
+        """
+        fire remove-label event
+
+        :param removed_label:
+        :return:
+        """
+        print('remove_label', removed_label)
+        self.__emit('remove-label', removed_label)
+
+    def create_file(self, created_file: File):
+        """
+        fire create-file event
+
+        :param created_file:
+        :return:
+        """
+        print('create_file', created_file)
+        self.__emit('create-file', created_file)
+
+    def remove_file(self, removed_file: File):
+        """
+        fire remove-file event
+
+        :param removed_file:
+        :return:
+        """
+        print('remove_file', removed_file)
+        self.__emit('remove-file', removed_file)
+
+    def create_result(self, created_result: Result):
+        """
+        fire create-result event
+
+        :param created_result:
+        :return:
+        """
+        print('create_result', created_result)
+        self.__emit('create-result', created_result)
+
+    def edit_result(self, edited_result: Result):
+        """
+        fire edit-result event
+
+        :param edited_result:
+        :return:
+        """
+        print('edit_result', edited_result)
+        self.__emit('edit-result', edited_result)
+
+    def remove_result(self, removed_result: Result):
+        """
+        fire remove-result event
+
+        :param removed_result:
+        :return:
+        """
+        print('remove_result', removed_result)
+        self.__emit('remove-result', removed_result)

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

@@ -0,0 +1,22 @@
+from typing import Any
+
+from flask.json import JSONEncoder as Base
+
+from pycs.database.util.JSONEncoder import JSONEncoder as Database
+from pycs.jobs.util.JSONEncoder import JSONEncoder as Jobs
+
+
+class JSONEncoder(Base):
+    """
+    prepares job objects to be json encoded
+    """
+
+    def default(self, o: Any) -> Any:
+        module = o.__class__.__module__
+
+        if module.startswith('pycs.database'):
+            return Database().default(o)
+        if module.startswith('pycs.jobs'):
+            return Jobs().default(o)
+
+        return o.__dict__

+ 20 - 0
pycs/interfaces/AnnotatedMediaFile.py

@@ -0,0 +1,20 @@
+from pycs.database.File import File
+from pycs.interfaces.MediaFile import MediaFile
+
+
+class AnnotatedMediaFile(MediaFile):
+    # pylint: disable=too-few-public-methods
+    """
+    contains various attributes of a saved media file including annotations
+    """
+
+    def __init__(self, file: File):
+        super().__init__(file)
+
+        self.results = []
+        for result in file.results():
+            if result.origin == 'user':
+                self.results.append({**{
+                    'type': result.type,
+                    'label': result.label
+                }, **result.data})

+ 11 - 1
pycs/labels/LabelProvider.py → pycs/interfaces/LabelProvider.py

@@ -2,6 +2,10 @@ import typing
 
 
 class LabelProvider:
+    """
+    label provider interface that should be implemented by label provider developers
+    """
+
     def __init__(self, root_folder, configuration):
         """
         prepare everything needed to provide labels
@@ -27,9 +31,15 @@ class LabelProvider:
         """
         raise NotImplementedError
 
-    # TODO documentation
     @staticmethod
     def create_label(identifier, name):
+        """
+        create a label result
+
+        :param identifier: label identifier
+        :param name: label name
+        :return:
+        """
         return {
             'id': identifier,
             'name': name

+ 21 - 0
pycs/interfaces/MediaFile.py

@@ -0,0 +1,21 @@
+from os import path, getcwd
+
+from pycs.database.File import File
+
+
+class MediaFile:
+    # pylint: disable=too-few-public-methods
+    """
+    contains various attributes of a saved media file
+    """
+
+    def __init__(self, file: File):
+        self.type = file.type
+        self.size = file.size
+        self.frames = file.frames
+        self.fps = file.fps
+
+        if path.isabs(file.path):
+            self.path = file.path
+        else:
+            self.path = path.join(getcwd(), file.path)

+ 89 - 0
pycs/interfaces/Pipeline.py

@@ -0,0 +1,89 @@
+from typing import List
+
+from pycs.interfaces.AnnotatedMediaFile import AnnotatedMediaFile
+from pycs.interfaces.MediaFile import MediaFile
+
+
+class Pipeline:
+    """
+    pipeline interface that should be implemented by model developers
+    """
+
+    def __init__(self, root_folder: str, distribution: dict):
+        """
+        prepare everything needed to run jobs later
+
+        :param root_folder: relative path to model folder
+        :param distribution: dict parsed from distribution.json
+        """
+        raise NotImplementedError
+
+    def close(self):
+        """
+        is called everytime a pipeline is not needed anymore and should be used
+        to free native resources
+
+        :return:
+        """
+        raise NotImplementedError
+
+    def execute(self, file: MediaFile) -> List[dict]:
+        """
+        receive a job, execute it and return the predicted result
+
+        :param file: which should be analyzed
+        :return:
+        """
+        raise NotImplementedError
+
+    def fit(self, files: List[AnnotatedMediaFile]):
+        """
+        receive a list of annotated media files and adapt the underlying model
+
+        :param files: list of annotated media files
+        :return:
+        """
+        raise NotImplementedError
+
+    @staticmethod
+    def create_labeled_image_result(label: int):
+        """
+        create a labeled-image result dictionary
+
+        :param label: label identifier
+        :return: dict
+        """
+        return {
+            'type': 'labeled-image',
+            'label': label
+        }
+
+    @staticmethod
+    def create_bounding_box_result(x: float, y: float, w: float, h: float, label=None, frame=None):
+        # pylint: disable=too-many-arguments
+        # pylint: disable=invalid-name
+        """
+        create a bounding-box result dictionary
+
+        :param x: relative x coordinate [0, 1]
+        :param y: relative y coordinate [0, 1]
+        :param w: relative width [0, 1]
+        :param h: relative height [0, 1]
+        :param label: label identifier
+        :param frame: frame index
+        :return: dict
+        """
+        result = {
+            'type': 'bounding-box',
+            'x': x,
+            'y': y,
+            'w': w,
+            'h': h
+        }
+
+        if label is not None:
+            result['label'] = label
+        if frame is not None:
+            result['frame'] = frame
+
+        return result

+ 22 - 0
pycs/jobs/Job.py

@@ -0,0 +1,22 @@
+from time import time
+from uuid import uuid1
+
+from pycs.database.Project import Project
+
+
+class Job:
+    """
+    wrapper class to track job data and progress
+    """
+
+    # pylint: disable=too-few-public-methods
+    def __init__(self, project: Project, job_type: str, name: str):
+        self.identifier = str(uuid1())
+        self.project_id = project.identifier
+        self.type = job_type
+        self.name = name
+        self.progress = 0
+        self.created = int(time())
+        self.updated = int(time())
+        self.started = None
+        self.finished = None

+ 4 - 0
pycs/jobs/JobGroupBusyException.py

@@ -0,0 +1,4 @@
+class JobGroupBusyException(Exception):
+    """
+    is raised if there is already a job running with the same group identifier
+    """

+ 223 - 0
pycs/jobs/JobRunner.py

@@ -0,0 +1,223 @@
+from time import time
+from types import GeneratorType
+from typing import Callable, List, Generator, Optional, Any
+
+from eventlet import tpool, spawn_n
+from eventlet.event import Event
+from eventlet.queue import Queue
+
+from pycs.database.Project import Project
+from pycs.jobs.Job import Job
+from pycs.jobs.JobGroupBusyException import JobGroupBusyException
+
+
+class JobRunner:
+    """
+    run jobs in a thread pool, but track progress and process results in eventlet queue
+    """
+
+    # pylint: disable=too-many-arguments
+    def __init__(self):
+        self.__jobs = []
+        self.__groups = {}
+        self.__queue = Queue()
+
+        self.__create_listeners = []
+        self.__start_listeners = []
+        self.__progress_listeners = []
+        self.__finish_listeners = []
+        self.__remove_listeners = []
+
+        spawn_n(self.__run)
+
+    def list(self) -> List[Job]:
+        """
+        get a list of all jobs including finished ones
+
+        :return: list of job objects
+        """
+        return self.__jobs
+
+    def on_create(self, callback: Callable[[Job], None]) -> None:
+        """
+        register a callback that is executed each time a job is created
+
+        :param callback: callback function
+        :return:
+        """
+        self.__create_listeners.append(callback)
+
+    def on_start(self, callback: Callable[[Job], None]) -> None:
+        """
+        register a callback that is executed each time a job is started
+
+        :param callback: callback function
+        :return:
+        """
+        self.__start_listeners.append(callback)
+
+    def on_progress(self, callback: Callable[[Job], None]) -> None:
+        """
+        register a callback that is executed each time a job changes it's progress
+
+        :param callback: callback function
+        :return:
+        """
+        self.__progress_listeners.append(callback)
+
+    def on_finish(self, callback: Callable[[Job], None]) -> None:
+        """
+        register a callback that is executed each time a job is finished
+
+        :param callback: callback function
+        :return:
+        """
+        self.__finish_listeners.append(callback)
+
+    def on_remove(self, callback: Callable[[Job], None]) -> None:
+        """
+        register a callback that is executed each time a job is removed
+
+        :param callback: callback function
+        :return:
+        """
+        self.__remove_listeners.append(callback)
+
+    def remove(self, identifier):
+        """
+        remove a job using its unique identifier
+
+        :param identifier: job identifier
+        :return:
+        """
+        for i in range(len(self.__jobs)):
+            if self.__jobs[i].identifier == identifier:
+                if self.__jobs[i].finished is not None:
+                    job = self.__jobs[i]
+                    del self.__jobs[i]
+
+                    for callback in self.__remove_listeners:
+                        callback(job)
+
+                return
+
+    def run(self,
+            project: Project,
+            job_type: str,
+            name: str,
+            group: str,
+            executable: Callable[[Any], Optional[Generator[float, None, None]]],
+            *args,
+            progress: Callable[[Any], float] = None,
+            result: Callable[[Any], None] = None,
+            result_event: Event = None,
+            **kwargs) -> Job:
+        """
+        add a job to run it in a thread pool
+
+        :param project: project the job is associated with
+        :param job_type: job type
+        :param name: job name
+        :param group: job group (raises JobGroupBusyException if there is already a job running
+                      with the same group identifier)
+        :param executable: function to execute
+        :param args: arguments for executable
+        :param progress: is called everytime executable yields a value
+        :param result: is called as soon as executable returns a value
+        :param result_event: eventlet event to be called as soon as executable returns a value
+        :param kwargs: named arguments for executable
+        :return: job object
+        """
+        # create job object
+        job = Job(project, job_type, name)
+
+        # abort if group is busy
+        # otherwise add to groups dict
+        if group is not None:
+            if group in self.__groups:
+                raise JobGroupBusyException
+
+            self.__groups[group] = job
+
+        # add to job list
+        self.__jobs.append(job)
+
+        # execute create listeners
+        for callback in self.__create_listeners:
+            callback(job)
+
+        # add to execution queue
+        self.__queue.put((group, executable, job, progress, result, result_event, args, kwargs))
+
+        # return job object
+        return job
+
+    def __run(self):
+        while True:
+            # get execution function and job from queue
+            group, executable, job, progress_fun, result_fun, result_event, args, kwargs \
+                = self.__queue.get(block=True)
+
+            # execute start listeners
+            job.started = int(time())
+            job.updated = int(time())
+
+            for callback in self.__start_listeners:
+                callback(job)
+
+            # run function and track progress
+            generator = tpool.execute(executable, *args, **kwargs)
+            result = generator
+
+            if isinstance(generator, GeneratorType):
+                iterator = iter(generator)
+
+                try:
+                    while True:
+                        # run until next progress event
+                        progress = tpool.execute(next, iterator)
+
+                        # execute progress function
+                        if progress_fun is not None:
+                            if isinstance(progress, tuple):
+                                progress = progress_fun(*progress)
+                            else:
+                                progress = progress_fun(progress)
+
+                        # execute progress listeners
+                        job.progress = progress
+                        job.updated = int(time())
+
+                        for callback in self.__progress_listeners:
+                            callback(job)
+                except StopIteration as stop_iteration_exception:
+                    result = stop_iteration_exception.value
+
+            # update progress
+            job.progress = 1
+            job.updated = int(time())
+
+            for callback in self.__progress_listeners:
+                callback(job)
+
+            # execute result function
+            if result_fun is not None:
+                if isinstance(result, tuple):
+                    result_fun(*result)
+                else:
+                    result_fun(result)
+
+            # execute event
+            if result_event is not None:
+                result_event.send(result)
+
+            # remove from group dict
+            if group is not None:
+                del self.__groups[group]
+
+            # finish job
+            job.finished = int(time())
+            job.updated = int(time())
+
+            for callback in self.__finish_listeners:
+                callback(job)

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

@@ -0,0 +1,17 @@
+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 - 41
pycs/observable/Observable.py

@@ -1,41 +0,0 @@
-class Observable:
-    @staticmethod
-    def create(source, parent=None, key=None):
-        from . import ObservableDict
-        from . import ObservableList
-
-        if isinstance(source, ObservableDict) or isinstance(source, ObservableList):
-            source.parent = parent
-            source.key = key
-            return source
-        if isinstance(source, dict):
-            return ObservableDict(source, parent, key)
-        if isinstance(source, list):
-            return ObservableList(source, parent, key)
-        else:
-            return source
-
-    def __init__(self, parent, key):
-        self.parent = parent
-        self.key = key
-        self.subscriptions = []
-
-    def subscribe(self, handler, immediate=False):
-        self.subscriptions.append(handler)
-
-        if immediate:
-            handler(self, [])
-
-    def notify(self, keys=None):
-        if keys is None:
-            keys = []
-
-        for s in self.subscriptions:
-            s(self, keys)
-
-        if self.parent is not None:
-            if self.key is None:
-                raise ValueError
-
-            keys.insert(0, self.key)
-            self.parent.notify(keys)

+ 0 - 31
pycs/observable/ObservableDict.py

@@ -1,31 +0,0 @@
-from . import Observable
-
-
-class ObservableDict(dict, Observable):
-    def __init__(self, obj: dict, parent: Observable = None, key: str = None):
-        dict.__init__(self)
-        Observable.__init__(self, parent, key)
-
-        for key in obj.keys():
-            dict.__setitem__(self, key, Observable.create(obj[key], self, key))
-
-    def __setitem__(self, key, value):
-        dict.__setitem__(self, key, Observable.create(value, self, key))
-        Observable.notify(self, [key])
-
-    def __delitem__(self, key):
-        super().__delitem__(key)
-        Observable.notify(self, [key])
-
-    def copy(self):
-        result = {}
-        for key in self.keys():
-            if type(self[key]) == ObservableDict or type(self[key]) == ObservableList:
-                result[key] = self[key].copy()
-            else:
-                result[key] = self[key]
-
-        return result
-
-
-from . import ObservableList

+ 0 - 42
pycs/observable/ObservableList.py

@@ -1,42 +0,0 @@
-from . import Observable
-
-
-class ObservableList(list, Observable):
-    def __init__(self, lst: list, parent: Observable = None, key: int = None):
-        list.__init__(self)
-        Observable.__init__(self, parent, key)
-
-        for element in lst:
-            list.append(self, Observable.create(element, self, key))
-
-    def __getitem__(self, value):
-        return super().__getitem__(int(value))
-
-    def __setitem__(self, key, value):
-        super().__setitem__(key, Observable.create(value, self, key))
-        Observable.notify(self, [key])
-
-    def __delitem__(self, key):
-        super().__delitem__(key)
-        Observable.notify(self, [key])
-
-    def append(self, value):
-        key = len(self)
-        obs = Observable.create(value, self, key)
-
-        super().append(obs)
-        Observable.notify(self, [key])
-
-        return obs
-
-    def __copy__(self):
-        def c(e):
-            if isinstance(e, (ObservableDict, ObservableList)):
-                return e.copy()
-            else:
-                return e
-
-        return list(map(c, self))
-
-
-from . import ObservableDict

+ 0 - 3
pycs/observable/__init__.py

@@ -1,3 +0,0 @@
-from .Observable import Observable
-from .ObservableDict import ObservableDict
-from .ObservableList import ObservableList

+ 0 - 25
pycs/pipeline/Fit.py

@@ -1,25 +0,0 @@
-from pycs.pipeline.Job import Job
-
-
-class Fit(Job):
-    def __init__(self, project, data: dict):
-        super().__init__(project, data)
-
-        self.result = []
-        for identifier in data['predictionResults']:
-            if data['predictionResults'][identifier]['origin'] != 'user':
-                continue
-
-            copy = data['predictionResults'][identifier].copy()
-            del copy['id']
-            del copy['origin']
-
-            # TODO remove and add in webui endpoint
-            if 'x' not in copy:
-                copy['type'] = 'labeled-image'
-            elif 'label' in copy:
-                copy['type'] = 'labeled-bounding-box'
-            else:
-                copy['type'] = 'bounding-box'
-
-            self.result.append(copy)

+ 0 - 13
pycs/pipeline/Job.py

@@ -1,13 +0,0 @@
-from os import path
-from uuid import uuid1
-
-
-class Job:
-    def __init__(self, project, data: dict):
-        self.id = uuid1()
-        self.type = data['type']
-
-        if project['unmanaged'] is None:
-            self.path = path.join('projects', project['id'], 'data', data['id'] + data['extension'])
-        else:
-            self.path = path.join(project['unmanaged'], data['id'] + data['extension'])

+ 0 - 68
pycs/pipeline/Pipeline.py

@@ -1,68 +0,0 @@
-from typing import List
-
-from pycs.pipeline.Fit import Fit
-from pycs.pipeline.Job import Job
-
-
-class Pipeline:
-    def __init__(self, root_folder, distribution):
-        """
-        prepare everything needed to run jobs later
-
-        :param root_folder: relative path to model folder
-        :param distribution: object parsed from distribution.json
-        """
-        raise NotImplementedError
-
-    def close(self):
-        """
-        is called everytime a pipeline is not needed anymore and should be used
-        to free native resources
-
-        :return:
-        """
-        raise NotImplementedError
-
-    def execute(self, job: Job) -> List[dict]:
-        """
-        receive a job, execute it and return the predicted result
-
-        :param job: that should be executed
-        :return:
-        """
-        raise NotImplementedError
-
-    # TODO documentation
-    def fit(self, fit: List[Fit]):
-        raise NotImplementedError
-
-    # TODO documentation
-    @staticmethod
-    def create_labeled_image_result(label):
-        return {
-            'type': 'labeled-image',
-            'label': label
-        }
-
-    # TODO documentation
-    @staticmethod
-    def create_bounding_box_result(x, y, w, h):
-        return {
-            'type': 'bounding-box',
-            'x': x,
-            'y': y,
-            'w': w,
-            'h': h
-        }
-
-    # TODO documentation
-    @staticmethod
-    def create_labeled_bounding_box_result(x, y, w, h, label):
-        return {
-            'type': 'labeled-bounding-box',
-            'x': x,
-            'y': y,
-            'w': w,
-            'h': h,
-            'label': label
-        }

+ 0 - 51
pycs/pipeline/PipelineManager.py

@@ -1,51 +0,0 @@
-from os import path
-
-from eventlet import tpool
-
-from pycs.pipeline.Fit import Fit
-from pycs.pipeline.Job import Job
-
-
-class PipelineManager:
-    def __init__(self, project):
-        code_path = path.join(project['model']['path'], project['model']['code']['module'])
-        module_name = code_path.replace('/', '.').replace('\\', '.')
-        class_name = project['model']['code']['class']
-
-        mod = __import__(module_name, fromlist=[class_name])
-        cl = getattr(mod, class_name)
-
-        self.project = project
-        self.pipeline = cl(project['model']['path'], project['model'])
-
-    def close(self):
-        print('PipelineManager', 'close')
-        self.pipeline.close()
-
-    def run(self, media_file):
-        # create job list
-        # TODO update job progress
-        job = Job(self.project, media_file)
-        result = tpool.execute(lambda p, j: p.execute(j), self.pipeline, job)
-
-        # remove existing pipeline predictions from media_fle
-        media_file.remove_pipeline_results()
-
-        # add new predictions
-        for prediction in result:
-            media_file.add_result(prediction, origin='pipeline')
-
-    def fit(self):
-        print('PipelineManager', 'fit')
-        data = []
-
-        for identifier in self.project['data']:
-            fit = Fit(self.project, self.project['data'][identifier])
-            data.append(fit)
-
-        for key in self.project.unmanaged_files:
-            obj = self.project.unmanaged_files[key].get_data()
-            fit = Fit(self.project, obj)
-            data.append(fit)
-
-        self.pipeline.fit(data)

+ 0 - 40
pycs/projects/ImageFile.py

@@ -1,40 +0,0 @@
-from os import path
-
-from PIL import Image
-
-from pycs.projects.MediaFile import MediaFile
-
-
-class ImageFile(MediaFile):
-    def __init__(self, obj, project, type='data'):
-        obj['type'] = 'image'
-        super().__init__(obj, project, type)
-
-    def resize(self, maximum_width):
-        # check if resized file already exists
-        resized = ImageFile(self.copy(), self.project, 'temp')
-        resized['id'] = self['id'] + '-' + maximum_width
-
-        target_path = resized.path
-        if path.exists(target_path):
-            return resized
-
-        # load full size image
-        current_directory, current_name = self.directory, self.full_name
-        image = Image.open(path.join(current_directory, current_name))
-        image_width, image_height = image.size
-
-        # calculate target height
-        maximum_width = int(maximum_width)
-        maximum_height = int(maximum_width * image_height / image_width)
-
-        # return self if requested size is larger than the image
-        if image_width < maximum_width:
-            return self
-
-        # resize image
-        resized_image = image.resize((maximum_width, maximum_height))
-
-        # save to file
-        resized_image.save(target_path, quality=80)
-        return resized

+ 0 - 20
pycs/projects/LabelManager.py

@@ -1,20 +0,0 @@
-from glob import glob
-from json import load
-from os import path
-
-from pycs import ApplicationStatus
-
-
-class LabelManager:
-    def __init__(self, app_status: ApplicationStatus):
-        # TODO create models folder if it does not exist
-
-        # find labels
-        for folder in glob('labels/*'):
-            # load configuration.json
-            with open(path.join(folder, 'configuration.json'), 'r') as file:
-                label = load(file)
-                label['path'] = folder
-
-                label_id = label['id']
-                app_status['labels'][label_id] = label

+ 0 - 70
pycs/projects/MediaFile.py

@@ -1,70 +0,0 @@
-from os.path import join
-from uuid import uuid1
-
-from pycs.observable import ObservableDict
-
-
-class MediaFile(ObservableDict):
-    def __init__(self, obj, project, type):
-        if 'predictionResults' not in obj.keys():
-            obj['predictionResults'] = {}
-
-        self.project = project
-        self.type = type
-
-        super().__init__(obj)
-
-    @property
-    def directory(self):
-        if self.project['unmanaged'] is not None and self.type == 'data':
-            return self.project['unmanaged']
-        else:
-            return join('projects', self.project['id'], self.type)
-
-    @property
-    def full_name(self):
-        return self['id'] + self['extension']
-
-    @property
-    def path(self):
-        return join(self.directory, self.full_name)
-
-    def add_global_result(self, result, origin='user'):
-        self.remove_global_result()
-        self.add_result(result, origin)
-
-    def remove_global_result(self):
-        delete = []
-        for result_id in self['predictionResults']:
-            if 'x' not in self['predictionResults'][result_id]:
-                delete.append(result_id)
-
-        for result_id in delete:
-            del self['predictionResults'][result_id]
-
-    def add_result(self, result, origin='user'):
-        result['id'] = str(uuid1())
-        result['origin'] = origin
-
-        self['predictionResults'][result['id']] = result
-
-    def remove_result(self, identifier):
-        del self['predictionResults'][identifier]
-
-    def remove_pipeline_results(self):
-        remove = list(filter(lambda k: self['predictionResults'][k]['origin'] == 'pipeline', self['predictionResults'].keys()))
-
-        for key in remove:
-            del self['predictionResults'][key]
-
-    def remove_results(self):
-        self['predictionResults'] = {}
-
-    def update_result(self, identifier, result, origin='user'):
-        result['id'] = identifier
-        result['origin'] = origin
-
-        self['predictionResults'][identifier] = result
-
-    def resize(self, maximum_width):
-        raise NotImplementedError

+ 0 - 20
pycs/projects/ModelManager.py

@@ -1,20 +0,0 @@
-from glob import glob
-from json import load
-from os import path
-
-from pycs import ApplicationStatus
-
-
-class ModelManager:
-    def __init__(self, app_status: ApplicationStatus):
-        # TODO create models folder if it does not exist
-
-        # find models
-        for folder in glob('models/*'):
-            # load distribution.json
-            with open(path.join(folder, 'distribution.json'), 'r') as file:
-                model = load(file)
-                model['path'] = folder
-
-                model_id = model['id']
-                app_status['models'][model_id] = model

+ 0 - 222
pycs/projects/Project.py

@@ -1,222 +0,0 @@
-from json import load
-from os import path, mkdir, listdir
-from os.path import splitext
-from uuid import uuid1
-
-from eventlet import spawn_after
-
-from pycs.observable import ObservableDict
-from pycs.pipeline.PipelineManager import PipelineManager
-from pycs.projects.ImageFile import ImageFile
-from pycs.projects.UnmanagedImageFile import UnmanagedImageFile
-from pycs.projects.UnmanagedVideoFile import UnmanagedVideoFile
-from pycs.projects.VideoFile import VideoFile
-from pycs.util.RecursiveDictionary import set_recursive
-
-
-class Project(ObservableDict):
-    DEFAULT_PIPELINE_TIMEOUT = 120
-
-    def __init__(self, obj: dict, parent):
-        self.pipeline_manager = None
-        self.quit_pipeline_thread = None
-
-        self.unmanaged_files_keys = []
-        self.unmanaged_files = {}
-
-        # ensure all required object keys are available
-        for key in ['data', 'labels', 'jobs']:
-            if key not in obj.keys():
-                obj[key] = {}
-
-        # load model data
-        folder = path.join('projects', obj['id'], 'model')
-        with open(path.join(folder, 'distribution.json'), 'r') as file:
-            model = load(file)
-            model['path'] = folder
-
-            obj['model'] = model
-
-        # save data as MediaFile objects
-        if obj['unmanaged'] is None:
-            for key in obj['data'].keys():
-                obj['data'][key] = self.create_media_file(obj['data'][key], project=obj)
-
-        # handle unmanaged files
-        else:
-            prev = None
-            for file in listdir(obj['unmanaged']):
-                uuid, ext = splitext(file)
-
-                next = {
-                    'id': uuid,
-                    'extension': ext
-                }
-                next = self.create_media_file(next, project=obj)
-
-                if prev is not None:
-                    next.prev(prev)
-                    prev.next(next)
-
-                prev = next
-
-                self.unmanaged_files_keys.append(uuid)
-                self.unmanaged_files[uuid] = next
-
-            length = len(self.unmanaged_files_keys)
-            for key in self.unmanaged_files:
-                self.unmanaged_files[key].length(length)
-
-        # initialize super
-        super().__init__(obj, parent)
-
-        # create data and temp
-        data_path = path.join('projects', self['id'], 'data')
-        if not path.exists(data_path):
-            mkdir(data_path)
-
-        temp_path = path.join('projects', self['id'], 'temp')
-        if not path.exists(temp_path):
-            mkdir(temp_path)
-
-        # subscribe to changes to write to disk afterwards
-        self.subscribe(lambda d, k: self.parent.write_project(self['id']))
-
-    def update_properties(self, update):
-        set_recursive(update, self)
-
-    def get_media_file(self, identifier):
-        if self['unmanaged']:
-            if identifier not in self.unmanaged_files_keys:
-                return None
-
-            return self.unmanaged_files[identifier]
-        else:
-            if identifier not in self['data'].keys():
-                return None
-
-            return self['data'][identifier]
-
-    def new_media_file_path(self):
-        return path.join('projects', self['id'], 'data'), str(uuid1())
-
-    def create_media_file(self, file, project=None):
-        if project is None:
-            project = self
-
-        if file['extension'] in ['.jpg', '.png']:
-            if project['unmanaged']:
-                return UnmanagedImageFile(file, project)
-            else:
-                return ImageFile(file, project)
-        if file['extension'] in ['.mp4']:
-            if project['unmanaged']:
-                return UnmanagedVideoFile(file, project)
-            else:
-                return VideoFile(file, project)
-
-        raise NotImplementedError
-
-    def add_media_file(self, uuid, name, extension, size, created):
-        file = {
-            'id': uuid,
-            'name': name,
-            'extension': extension,
-            'size': size,
-            'created': created
-        }
-        self['data'][file['id']] = self.create_media_file(file)
-
-    def remove_media_file(self, file_id):
-        del self['data'][file_id]
-
-    def add_label(self, name, identifier=None):
-        if identifier is None:
-            identifier = str(uuid1())
-
-        self['labels'][identifier] = {
-            'id': identifier,
-            'name': name
-        }
-
-    def update_label(self, identifier, name):
-        if identifier in self['labels']:
-            self['labels'][identifier]['name'] = name
-
-    def remove_label(self, identifier):
-        # abort if identifier is unknown
-        if identifier not in self['labels']:
-            return
-
-        # remove label from data elements
-        remove = list()
-
-        for data in self['data']:
-            for pred in self['data'][data]['predictionResults']:
-                if 'label' in self['data'][data]['predictionResults'][pred]:
-                    if self['data'][data]['predictionResults'][pred]['label'] == identifier:
-                        remove.append((data, pred))
-
-        for t in remove:
-            del self['data'][t[0]]['predictionResults'][t[1]]
-
-        # remove label from list
-        del self['labels'][identifier]
-
-    def predict(self, identifiers, unlabeled=False):
-        # create pipeline
-        pipeline = self.__create_pipeline()
-
-        # run jobs
-        if self['unmanaged'] is None:
-            for file_id in identifiers:
-                if file_id in self['data'].keys():
-                    if not unlabeled or len(self['data'][file_id]['predictionResults'].keys()) == 0:
-                        pipeline.run(self['data'][file_id])
-
-        else:
-            for file_id in identifiers:
-                if file_id in self.unmanaged_files:
-                    if not unlabeled or len(self.unmanaged_files[file_id].get_data()['predictionResults']) == 0:
-                        pipeline.run(self.unmanaged_files[file_id])
-
-        # schedule timeout thread
-        self.quit_pipeline_thread = spawn_after(self.DEFAULT_PIPELINE_TIMEOUT, self.__quit_pipeline)
-
-    def fit(self):
-        # create pipeline
-        pipeline = self.__create_pipeline()
-
-        # run fit
-        pipeline.fit()
-
-        # schedule timeout thread
-        self.quit_pipeline_thread = spawn_after(self.DEFAULT_PIPELINE_TIMEOUT, self.__quit_pipeline)
-
-    def __create_pipeline(self):
-        # abort pipeline termination
-        self.__quit_pipeline_thread()
-
-        # create pipeline if it does not exist already
-        if self.pipeline_manager is None:
-            self.pipeline_manager = PipelineManager(self)
-
-        return self.pipeline_manager
-
-    def __quit_pipeline(self):
-        if self.pipeline_manager is not None:
-            self.pipeline_manager.close()
-            self.pipeline_manager = None
-            self.quit_pipeline_thread = None
-
-    def __create_quit_pipeline_thread(self):
-        # abort pipeline termination
-        self.__quit_pipeline_thread()
-
-        # create new thread
-        self.quit_pipeline_thread = spawn_after(self.DEFAULT_PIPELINE_TIMEOUT, self.__quit_pipeline)
-
-    def __quit_pipeline_thread(self):
-        if self.quit_pipeline_thread is not None:
-            self.quit_pipeline_thread.cancel()
-            self.quit_pipeline_thread = None

+ 0 - 129
pycs/projects/ProjectManager.py

@@ -1,129 +0,0 @@
-from glob import glob
-from json import load, dump
-from os import path, mkdir
-from os.path import exists
-from shutil import rmtree, copytree
-from time import time
-from uuid import uuid1
-
-from pycs import ApplicationStatus
-from pycs.observable import ObservableDict
-from pycs.projects.Project import Project
-
-
-class ProjectManager(ObservableDict):
-    def __init__(self, app_status: ApplicationStatus):
-        self.app_status = app_status
-
-        # create projects folder if it does not exist
-        if not exists('projects/'):
-            mkdir('projects/')
-
-        # initialize observable dict with no keys and
-        # app_status object as parent
-        super().__init__({}, app_status)
-        app_status['projects'] = self
-
-        # find projects
-        for folder in glob('projects/*'):
-            # load project.json
-            with open(path.join(folder, 'project.json'), 'r') as file:
-                project = Project(load(file), self)
-                self[project['id']] = project
-
-    def write_project(self, uuid):
-        copy = self[uuid].copy()
-        del copy['jobs']
-        del copy['model']
-
-        with open(path.join('projects', uuid, 'project.json'), 'w') as file:
-            dump(copy, file, indent=4)
-
-    def create_project(self, name, description, model, label, unmanaged=None):
-        # create dict representation
-        uuid = str(uuid1())
-
-        # create project directory
-        folder = path.join('projects', uuid)
-        mkdir(folder)
-
-        # copy model to project directory
-        copytree(self.parent['models'][model]['path'], path.join(folder, 'model'))
-
-        # create project object
-        self[uuid] = Project({
-            'id': uuid,
-            'name': name,
-            'description': description,
-            'unmanaged': unmanaged,
-            'created': int(time()),
-            'data': {},
-            'labels': {},
-            'jobs': {}
-        }, self)
-
-        # add labels if id is valid
-        if label in self.parent['labels']:
-            code_path = path.join(self.parent['labels'][label]['path'], self.parent['labels'][label]['code']['module'])
-            module_name = code_path.replace('/', '.').replace('\\', '.')
-            class_name = self.parent['labels'][label]['code']['class']
-
-            mod = __import__(module_name, fromlist=[class_name])
-            cl = getattr(mod, class_name)
-
-            provider = cl(self.parent['labels'][label]['path'], self.parent['labels'][label])
-            for label in provider.get_labels():
-                self[uuid].add_label(label['name'], identifier=label['id'])
-
-            provider.close()
-
-        # create project.json
-        self.write_project(uuid)
-
-    def update_project(self, uuid, update):
-        # abort if uuid is no valid key
-        if uuid not in self.keys():
-            return
-
-        # set values and write to disk
-        self[uuid].update_properties(update)
-        self.write_project(uuid)
-
-    def delete_project(self, uuid):
-        # abort if uuid is no valid key
-        if uuid not in self.keys():
-            return
-
-        # delete project folder
-        folder = path.join('projects', uuid)
-        rmtree(folder)
-
-        # delete project data
-        del self[uuid]
-
-    def predict(self, uuid, identifiers=None, unlabeled=False):
-        # abort if uuid is no valid key
-        if uuid not in self.keys():
-            return
-
-        project = self[uuid]
-
-        # get identifiers
-        if identifiers is None:
-            if project['unmanaged'] is None:
-                identifiers = list(project['data'].keys())
-            else:
-                identifiers = project.unmanaged_files_keys
-
-        # run prediction
-        project.predict(identifiers, unlabeled=unlabeled)
-
-    def fit(self, uuid):
-        # abort if uuid is no valid key
-        if uuid not in self.keys():
-            return
-
-        project = self[uuid]
-
-        # run fit
-        project.fit()

+ 0 - 100
pycs/projects/UnmanagedImageFile.py

@@ -1,100 +0,0 @@
-from json import load, dump
-from os.path import join, exists
-from uuid import uuid1
-
-from pycs.projects.ImageFile import ImageFile
-
-
-class UnmanagedImageFile(ImageFile):
-    def __init__(self, obj, project):
-        obj['prev'] = None
-        obj['next'] = None
-
-        super().__init__(obj, project, 'data')
-        del self['predictionResults']
-
-    def prev(self, value):
-        self['prev'] = value['id']
-
-    def next(self, value):
-        self['next'] = value['id']
-
-    def length(self, value):
-        self['length'] = value
-
-    @property
-    def data_path(self):
-        return join('projects', self.project['id'], 'data', self['id'] + '.json')
-
-    def __load(self):
-        if not exists(self.data_path):
-            return {}
-
-        with open(self.data_path, 'r') as f:
-            return load(f)
-
-    def __write(self, data):
-        with open(self.data_path, 'w') as f:
-            dump(data, f, indent=4)
-
-    def get_data(self):
-        copy = self.copy()
-        copy['predictionResults'] = self.__load()
-        return copy
-
-    def add_global_result(self, result, origin='user'):
-        self.remove_global_result()
-        self.add_result(result, origin)
-
-    def remove_global_result(self):
-        data = self.__load()
-
-        delete = []
-        for result_id in data:
-            if 'x' not in data[result_id]:
-                delete.append(result_id)
-
-        for result_id in delete:
-            del data[result_id]
-
-        self.__write(data)
-
-    def add_result(self, result, origin='user'):
-        data = self.__load()
-
-        result['id'] = str(uuid1())
-        result['origin'] = origin
-
-        data[result['id']] = result
-
-        self.__write(data)
-
-    def remove_result(self, identifier):
-        data = self.__load()
-
-        del data[identifier]
-
-        self.__write(data)
-
-    def remove_pipeline_results(self):
-        data = self.__load()
-
-        remove = list(filter(lambda k: data[k]['origin'] == 'pipeline', data.keys()))
-
-        for key in remove:
-            del data[key]
-
-        self.__write(data)
-
-    def remove_results(self):
-        self.__write({})
-
-    def update_result(self, identifier, result, origin='user'):
-        data = self.__load()
-
-        result['id'] = identifier
-        result['origin'] = origin
-
-        data[identifier] = result
-
-        self.__write(data)

+ 0 - 100
pycs/projects/UnmanagedVideoFile.py

@@ -1,100 +0,0 @@
-from json import load, dump
-from os.path import join, exists
-from uuid import uuid1
-
-from pycs.projects.VideoFile import VideoFile
-
-
-class UnmanagedVideoFile(VideoFile):
-    def __init__(self, obj, project):
-        obj['prev'] = None
-        obj['next'] = None
-
-        super().__init__(obj, project, 'data')
-        del self['predictionResults']
-
-    def prev(self, value):
-        self['prev'] = value['id']
-
-    def next(self, value):
-        self['next'] = value['id']
-
-    def length(self, value):
-        self['length'] = value
-
-    @property
-    def data_path(self):
-        return join('projects', self.project['id'], 'data', self['id'] + '.json')
-
-    def __load(self):
-        if not exists(self.data_path):
-            return {}
-
-        with open(self.data_path, 'r') as f:
-            return load(f)
-
-    def __write(self, data):
-        with open(self.data_path, 'w') as f:
-            dump(data, f, indent=4)
-
-    def get_data(self):
-        copy = self.copy()
-        copy['predictionResults'] = self.__load()
-        return copy
-
-    def add_global_result(self, result, origin='user'):
-        self.remove_global_result()
-        self.add_result(result, origin)
-
-    def remove_global_result(self):
-        data = self.__load()
-
-        delete = []
-        for result_id in data:
-            if 'x' not in data[result_id]:
-                delete.append(result_id)
-
-        for result_id in delete:
-            del data[result_id]
-
-        self.__write(data)
-
-    def add_result(self, result, origin='user'):
-        data = self.__load()
-
-        result['id'] = str(uuid1())
-        result['origin'] = origin
-
-        data[result['id']] = result
-
-        self.__write(data)
-
-    def remove_result(self, identifier):
-        data = self.__load()
-
-        del data[identifier]
-
-        self.__write(data)
-
-    def remove_pipeline_results(self):
-        data = self.__load()
-
-        remove = list(filter(lambda k: data[k]['origin'] == 'pipeline', data.keys()))
-
-        for key in remove:
-            del data[key]
-
-        self.__write(data)
-
-    def remove_results(self):
-        self.__write({})
-
-    def update_result(self, identifier, result, origin='user'):
-        data = self.__load()
-
-        result['id'] = identifier
-        result['origin'] = origin
-
-        data[identifier] = result
-
-        self.__write(data)

+ 0 - 46
pycs/projects/VideoFile.py

@@ -1,46 +0,0 @@
-from os.path import exists
-
-import cv2
-
-from pycs.projects.ImageFile import ImageFile
-from pycs.projects.MediaFile import MediaFile
-
-
-class VideoFile(MediaFile):
-    def __init__(self, obj, project, type='data'):
-        # add type to object
-        obj['type'] = 'video'
-
-        # call parent constructor
-        super().__init__(obj, project, type)
-
-        # generate thumbnail
-        self.__thumbnail = self.__read_video()
-
-    def __read_video(self):
-        # create image file from properties
-        copy = self.copy()
-        copy['extension'] = '.jpg'
-
-        resized = ImageFile(copy, self.project, 'temp')
-
-        # open video file
-        video = cv2.VideoCapture(self.path)
-
-        # read fps value
-        self['fps'] = video.get(cv2.CAP_PROP_FPS)
-        self['frameCount'] = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
-
-        # create thumbnail
-        if not exists(resized.path):
-            _, image = video.read()
-            cv2.imwrite(resized.path, image)
-
-        # close video file
-        video.release()
-
-        # return
-        return resized
-
-    def resize(self, maximum_width):
-        return self.__thumbnail.resize(maximum_width)

+ 38 - 0
pycs/util/FileParser.py

@@ -0,0 +1,38 @@
+from os import path
+
+import cv2
+
+
+def file_info(data_folder: str, file_name: str, file_ext: str):
+    """
+    Receive file type, frame count and frames per second.
+    The last two are always None for images.
+
+    :param data_folder: path to data folder
+    :param file_name: file name
+    :param file_ext: file extension
+    :return: file type, frame count, frames per second
+    """
+    # determine file type
+    if file_ext in ['.jpg', '.png']:
+        ftype = 'image'
+    elif file_ext in ['.mp4']:
+        ftype = 'video'
+    else:
+        raise ValueError
+
+    # determine frames and fps for video files
+    if ftype == 'image':
+        frames = None
+        fps = None
+    else:
+        file_path = path.join(data_folder, file_name + file_ext)
+        video = cv2.VideoCapture(file_path)
+
+        frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
+        fps = video.get(cv2.CAP_PROP_FPS)
+
+        video.release()
+
+    # return values
+    return ftype, frames, fps

+ 0 - 3
pycs/util/GenericWrapper.py

@@ -1,3 +0,0 @@
-class GenericWrapper:
-    def __init__(self, value=None):
-        self.value = value

+ 28 - 0
pycs/util/LabelProviderUtil.py

@@ -0,0 +1,28 @@
+from json import load
+from os import path
+
+from pycs.interfaces.LabelProvider import LabelProvider
+
+
+def load_from_root_folder(root_folder: str) -> LabelProvider:
+    """
+    load configuration.json and create an instance from the included code object
+
+    :param root_folder: path to label provider root folder
+    :return: LabelProvider instance
+    """
+    # load configuration.json
+    configuration_path = path.join(root_folder, 'configuration.json')
+    with open(configuration_path, 'r') as configuration_file:
+        configuration = load(configuration_file)
+
+    # load code
+    code_path = path.join(root_folder, configuration['code']['module'])
+    module_name = code_path.replace('/', '.').replace('\\', '.')
+    class_name = configuration['code']['class']
+
+    imported_module = __import__(module_name, fromlist=[class_name])
+    class_attr = getattr(imported_module, class_name)
+
+    # return instance
+    return class_attr(root_folder, configuration)

+ 28 - 0
pycs/util/PipelineUtil.py

@@ -0,0 +1,28 @@
+from json import load
+from os import path
+
+from pycs.interfaces.Pipeline import Pipeline
+
+
+def load_from_root_folder(root_folder: str) -> Pipeline:
+    """
+    load configuration.json and create an instance from the included code object
+
+    :param root_folder: path to model root folder
+    :return: Pipeline instance
+    """
+    # load configuration.json
+    configuration_path = path.join(root_folder, 'configuration.json')
+    with open(configuration_path, 'r') as configuration_file:
+        configuration = load(configuration_file)
+
+    # load code
+    code_path = path.join(root_folder, configuration['code']['module'])
+    module_name = code_path.replace('/', '.').replace('\\', '.')
+    class_name = configuration['code']['class']
+
+    imported_module = __import__(module_name, fromlist=[class_name])
+    class_attr = getattr(imported_module, class_name)
+
+    # return instance
+    return class_attr(root_folder, configuration)

+ 4 - 0
pycs/util/ProgressFileWriter.py

@@ -2,6 +2,10 @@ from io import BufferedWriter
 
 
 class ProgressFileWriter(BufferedWriter):
+    """
+    opens a file and calls a given callback method on every write
+    """
+
     def __init__(self, path, mode, callback=None):
         self.file_handler = open(path, mode)
 

+ 0 - 6
pycs/util/RecursiveDictionary.py

@@ -1,6 +0,0 @@
-def set_recursive(source_object, target_object):
-    for key in source_object.keys():
-        if isinstance(source_object[key], dict):
-            set_recursive(source_object[key], target_object[key])
-        else:
-            target_object[key] = source_object[key]

+ 2 - 5
settings.json

@@ -1,7 +1,4 @@
 {
-  "frontend": {
-    "host": "",
-    "port": 5000,
-    "collapse": false
-  }
+  "host": "",
+  "port": 5000
 }

+ 0 - 32
test/test_application_status.py

@@ -1,32 +0,0 @@
-import unittest
-
-from pycs.ApplicationStatus import ApplicationStatus
-
-
-class TestApplicationStatus(unittest.TestCase):
-    def test_load_default(self):
-        aso = ApplicationStatus()
-        self.assertEqual({
-            'settings': {},
-            'models': {},
-            'labels': {},
-            'projects': {}
-        }, aso)
-
-    def test_load_from_object(self):
-        settings = {
-            'a': 1,
-            'b': 'c'
-        }
-
-        aso = ApplicationStatus(settings=settings)
-        self.assertEqual({
-            'settings': settings,
-            'models': {},
-            'labels': {},
-            'projects': {}
-        }, aso)
-
-
-if __name__ == '__main__':
-    unittest.main()

+ 0 - 122
test/test_observable.py

@@ -1,122 +0,0 @@
-import unittest
-
-from pycs.observable import Observable
-
-
-class Wrapper:
-    def __init__(self, init=0):
-        self.value = init
-
-    def inc(self):
-        self.value += 1
-
-    def set(self, value):
-        self.value = value
-
-
-class TestObservable(unittest.TestCase):
-    settings = {
-        'dir1': {
-            'file11': 11,
-            'file12': ['one', 'two'],
-            'subdir1': {
-                'file111': 'oneoneone',
-                'file112': 112,
-                'subdir11': {
-                    'file1111': 1111
-                }
-            }
-        },
-        'dir2': {
-            'file21': 21,
-            'file22': 22
-        },
-        'dir3': {
-            'subdir1': {
-                'file311': 311
-            }
-        },
-        'file1': 1
-    }
-
-    def test_load(self):
-        obs = Observable.create(source=self.settings)
-
-        # root
-        self.assertEqual(obs['file1'], self.settings['file1'])
-
-        # dict
-        self.assertEqual(obs['dir1']['file11'], self.settings['dir1']['file11'])
-
-        # list
-        self.assertEqual(obs['dir1']['file12'][0], self.settings['dir1']['file12'][0])
-
-    def test_update(self):
-        obs = Observable.create(source=self.settings)
-
-        # root
-        obs['file1'] = 2
-        self.assertEqual(2, obs['file1'])
-
-        del obs['file1']
-        self.assertFalse('file1' in obs)
-
-        # dict
-        obs['dir1']['file11'] = 12
-        self.assertEqual(12, obs['dir1']['file11'])
-
-        del obs['dir1']['file11']
-        self.assertFalse('file11' in obs['dir1'])
-
-        # list
-        obs['dir1']['file12'][0] = 'three'
-        self.assertEqual('three', obs['dir1']['file12'][0])
-
-        del obs['dir1']['file12'][0]
-        self.assertEqual(1, len(obs['dir1']['file12']))
-        self.assertEqual('two', obs['dir1']['file12'][0])
-
-    def test_subscribe(self):
-        obs = Observable.create(source=self.settings)
-
-        # root
-        counter1 = Wrapper()
-        obs['dir1'].subscribe(lambda v, k: counter1.inc())
-
-        obs['dir1']['file11'] = 12
-        self.assertEqual(1, counter1.value)
-
-        del obs['dir1']['file11']
-        self.assertEqual(2, counter1.value)
-
-        # dict
-        counter2 = Wrapper()
-        obs['dir1'].subscribe(lambda v, k: counter2.inc())
-
-        obs['dir1']['file11'] = 12
-        self.assertEqual(1, counter2.value)
-
-        del obs['dir1']['file11']
-        self.assertEqual(2, counter2.value)
-
-        # list
-        counter3 = Wrapper()
-        obs['dir1']['file12'].subscribe(lambda v, k: counter3.inc())
-
-        obs['dir1']['file12'][0] = 'three'
-        self.assertEqual(1, counter3.value)
-
-        obs['dir1']['file12'].append(4)
-        self.assertEqual(2, counter3.value)
-
-        del obs['dir1']['file12'][1]
-        self.assertEqual(3, counter3.value)
-
-    # TODO test subscription value
-    # TODO test complex append
-    # TODO test subscription after adding
-    # TODO test multiple subscription calls when a child is added
-
-
-if __name__ == '__main__':
-    unittest.main()

+ 34 - 114
webui/src/App.vue

@@ -1,62 +1,40 @@
 <template>
   <div id="app">
     <!-- loading screen -->
-    <loading-overlay v-if="!connected"/>
+    <loading-overlay v-if="!$root.connected"/>
 
     <!-- top navigation bar -->
     <top-navigation-bar :window="window"
-                        :current-project="currentProject"
-                        v-on:side="window.menu = !window.menu"></top-navigation-bar>
+                        v-on:side="window.menu = !window.menu"
+                        @close="closeProject"></top-navigation-bar>
 
     <!-- bottom content -->
     <div class="bottom">
       <!-- side navigation bar -->
       <side-navigation-bar :window="window"
-                           :socket="socket"
-                           :status="status"
-                           :current-project="currentProject"
                            v-on:close="window.menu = false"/>
 
       <!-- actual content -->
       <div class="content">
-        <project-open-window v-if="window.content === 'projects'"
-                             :projects="projects"
-                             :status="status"
-                             :socket="socket"
-                             :window="window"
+        <project-open-window v-if="currentPage === 'projects'"
                              v-on:open="openProject"/>
 
-        <project-settings-window v-if="window.content === 'settings'"
-                                 :current-project="currentProject"
-                                 :status="status"
-                                 :socket="socket"
+        <project-settings-window v-if="currentPage === 'settings'"
                                  v-on:close="closeProject"/>
 
-        <project-data-add-window v-if="window.content === 'add_data'"
-                                 :current-project="currentProject"
-                                 :socket="socket"/>
+        <project-data-add-window v-if="currentPage === 'add_data'"/>
 
-        <project-labels-window v-if="window.content === 'labels'"
-                               :current-project="currentProject"
-                               :socket="socket"/>
+        <project-labels-window v-if="currentPage === 'labels'"/>
 
-        <project-data-view-window v-if="window.content === 'view_data'"
-                                  :current-project="currentProject"
-                                  :status="status"
-                                  :socket="socket"/>
+        <project-data-view-window v-if="currentPage === 'view_data'"/>
 
-        <project-model-interaction-window v-if="window.content === 'model_interaction'"
-                                          :current-project="currentProject"
-                                          :socket="socket"/>
-
-        <about-window v-if="window.content === 'about'"/>
+        <about-window v-if="currentPage === 'about'"/>
       </div>
     </div>
   </div>
 </template>
 
 <script>
-import io from "socket.io-client";
 import ProjectOpenWindow from "@/components/projects/project-open-window";
 import TopNavigationBar from "@/components/window/top-navigation-bar";
 import SideNavigationBar from "@/components/window/side-navigation-bar";
@@ -66,12 +44,10 @@ import ProjectDataAddWindow from "@/components/projects/project-data-add-window"
 import ProjectDataViewWindow from "@/components/projects/project-data-view-window";
 import LoadingOverlay from "@/components/other/loading-overlay";
 import ProjectLabelsWindow from "@/components/projects/project-labels-window";
-import ProjectModelInteractionWindow from "@/components/projects/project-model-interaction-window";
 
 export default {
   name: 'App',
   components: {
-    ProjectModelInteractionWindow,
     ProjectLabelsWindow,
     LoadingOverlay,
     ProjectDataAddWindow,
@@ -84,64 +60,29 @@ export default {
   },
   data: function () {
     return {
-      connected: false,
-      socket: {
-        baseurl: window.location.protocol + '//' + window.location.hostname + ':5000',
-        // initialize socket.io connection
-        io: io(window.location.protocol + '//' + window.location.hostname + ':5000'),
-        // http methods
-        get: function (name) {
-          if (!name.startsWith('http'))
-            name = this.baseurl + name;
-
-          return fetch(name, {
-            method: 'GET'
-          });
-        },
-        post: function (name, value) {
-          if (!name.startsWith('http'))
-            name = this.baseurl + name;
-
-          return fetch(name, {
-            method: 'POST',
-            body: JSON.stringify(value)
-          });
-        },
-        upload: function (name, file) {
-          const form = new FormData();
-          form.append('file', file);
-
-          return fetch(this.baseurl + name, {
-            method: 'POST',
-            body: form
-          });
-        },
-        media: function (projectId, fileId) {
-          return this.baseurl + '/projects/' + projectId + '/data/' + fileId;
-        }
-      },
-      status: null,
       window: {
         wide: true,
         menu: false,
-        content: 'projects',
-        project: null
+        content: 'settings'
       }
     }
   },
+  created: function () {
+    window.addEventListener('resize', this.resize);
+    this.resize();
+  },
+  destroyed: function () {
+    window.removeEventListener('resize', this.resize);
+  },
   computed: {
-    projects: function () {
-      if (this.status == null)
-        return [];
-
-      return Object.keys(this.status.projects).map(key => this.status.projects[key]);
-    },
-    currentProject: function () {
-      for (let i = 0; i < this.projects.length; i++)
-        if (this.projects[i].id === this.window.project)
-          return this.projects[i];
-
-      return false;
+    currentPage: function () {
+      if (!this.$root.project) {
+        if (this.window.content === 'about')
+          return this.window.content;
+        else
+          return 'projects';
+      } else
+        return this.window.content;
     }
   },
   methods: {
@@ -151,40 +92,19 @@ export default {
     show: function (value) {
       this.window.content = value;
     },
-    openProject: function (project) {
-      this.window.project = project.id;
+    openProject: function () {
       this.show('settings');
     },
     closeProject: function () {
-      this.window.project = null;
-      this.show('projects');
+      this.$root.project = null;
+      this.show('settings');
     }
   },
-  created: function () {
-    this.socket.io.on('app_status', data => {
-      if (data.keys.length === 0) {
-        this.status = data.value;
-        this.connected = true;
-        return;
-      }
-
-      const last = data.keys.pop();
-      let target = this.status;
-
-      for (let key of data.keys) {
-        target = target[key];
-      }
-
-      target[last] = data.value;
-    });
-
-    setInterval(() => this.connected = this.socket.io.connected, 2000);
-
-    window.addEventListener('resize', this.resize);
-    this.resize();
-  },
-  destroyed: function () {
-    window.removeEventListener('resize', this.resize);
+  watch: {
+    currentPage: function (newVal) {
+      if (newVal === 'projects')
+        this.window.content = 'settings';
+    }
   }
 }
 </script>
@@ -218,7 +138,7 @@ export default {
 
 /deep/ .headline {
   width: 100%;
-  font-family: "Roboto Condensed";
+  font-family: "Roboto Condensed", sans-serif;
   text-overflow: ellipsis;
   white-space: nowrap;
   overflow: hidden;

+ 4 - 0
webui/src/assets/icons/check-circle.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path fill-rule="evenodd"
+          d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM0 8a8 8 0 1116 0A8 8 0 010 8zm11.78-1.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"></path>
+</svg>

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

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path fill-rule="evenodd"
+          d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"></path>
+</svg>

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

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path fill-rule="evenodd"
+          d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"></path>
+</svg>

+ 3 - 0
webui/src/assets/icons/four-directions.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="485.213" height="485.212">
+    <path d="M363.909 333.584V272.93h-90.976v90.976h60.653l-90.979 121.307-90.978-121.307h60.654V272.93h-90.978v65.391L.001 247.346l121.304-90.98v55.916h90.978v-90.978h-60.654L242.607 0l90.979 121.304h-60.653v90.978h90.976v-60.653l121.303 90.978-121.303 90.977z"/>
+</svg>

+ 4 - 0
webui/src/assets/icons/location.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path fill-rule="evenodd"
+          d="M11.536 3.464a5 5 0 010 7.072L8 14.07l-3.536-3.535a5 5 0 117.072-7.072v.001zm1.06 8.132a6.5 6.5 0 10-9.192 0l3.535 3.536a1.5 1.5 0 002.122 0l3.535-3.536zM8 9a2 2 0 100-4 2 2 0 000 4z"></path>
+</svg>

+ 3 - 0
webui/src/assets/icons/north-star.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path d="M8.5.75a.75.75 0 00-1.5 0v5.19L4.391 3.33a.75.75 0 10-1.06 1.061L5.939 7H.75a.75.75 0 000 1.5h5.19l-2.61 2.609a.75.75 0 101.061 1.06L7 9.561v5.189a.75.75 0 001.5 0V9.56l2.609 2.61a.75.75 0 101.06-1.061L9.561 8.5h5.189a.75.75 0 000-1.5H9.56l2.61-2.609a.75.75 0 00-1.061-1.06L8.5 5.939V.75z"></path>
+</svg>

+ 3 - 0
webui/src/assets/icons/open-circle.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
+    <path d="M10 40v-1.1-1.3-.8c0-.3.1-.6.1-.9.1-.6.1-1.4.2-2.1l.5-2.5c.2-.9.6-1.8.8-2.8.3-1 .8-1.9 1.2-3l1.7-3.1 2.2-3.1c1.6-2.1 3.7-3.9 6-5.6s5-3 7.9-4.1l2.2-.7c.7-.3 1.5-.3 2.3-.5s1.5-.3 2.3-.4l1.2-.1.6-.1h.3.1.1 0c.1 0-.1 0 .1 0 1.5 0 2.9-.1 4.5.2.8.1 1.6.1 2.4.3l2.3.5c3 .8 5.9 2 8.5 3.6s4.9 3.4 6.8 5.4c1 1 1.8 2.1 2.7 3.1l2.1 3.2 1.6 3.1 1.2 3 .8 2.7.5 2.4c.1.4.1.7.2 1 0 .3.1.6.1.9.1.6.1 1 .1 1.4.4 1 .4 1.4.4 1.4.2 2.2-1.5 4.1-3.7 4.3s-4.1-1.5-4.3-3.7v-.3-.4-.9-1.1-.7c0-.2-.1-.5-.1-.8-.1-.6-.1-1.2-.2-1.9l-.4-2.2-.7-2.4-1.1-2.6-1.5-2.7-1.9-2.7c-1.4-1.8-3.2-3.4-5.2-4.9s-4.4-2.7-6.9-3.6l-1.9-.6c-.7-.2-1.3-.3-1.9-.4-1.2-.3-2.8-.4-4.2-.5h-2l-2.1.1c-.7.1-1.4.1-2 .3-.7.1-1.3.3-2 .4-2.6.7-5.2 1.7-7.5 3.1-2.2 1.4-4.3 2.9-6 4.7-.9.8-1.6 1.8-2.4 2.7-.7.9-1.3 1.9-1.9 2.8l-1.4 2.8c-.4.9-.8 1.8-1 2.6l-.7 2.4c-.2.7-.3 1.4-.4 2.1-.1.3-.1.6-.2.9 0 .3-.1.6-.1.8 0 .5-.1.9-.1 1.3-.2.7-.2 1.1-.2 1.1z"/>
+</svg>

+ 4 - 0
webui/src/assets/icons/package-dependents.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path fill-rule="evenodd"
+          d="M6.122.392a1.75 1.75 0 011.756 0l5.25 3.045c.54.313.872.89.872 1.514V7.25a.75.75 0 01-1.5 0V5.677L7.75 8.432v6.384a1 1 0 01-1.502.865L.872 12.563A1.75 1.75 0 010 11.049V4.951c0-.624.332-1.2.872-1.514L6.122.392zM7.125 1.69l4.63 2.685L7 7.133 2.245 4.375l4.63-2.685a.25.25 0 01.25 0zM1.5 11.049V5.677l4.75 2.755v5.516l-4.625-2.683a.25.25 0 01-.125-.216zm10.828 3.684a.75.75 0 101.087 1.034l2.378-2.5a.75.75 0 000-1.034l-2.378-2.5a.75.75 0 00-1.087 1.034L13.501 12H10.25a.75.75 0 000 1.5h3.251l-1.173 1.233z"></path>
+</svg>

+ 3 - 0
webui/src/assets/icons/pause.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="519.479" height="519.479">
+    <path d="M193.441 0h-75.484c-16.897 0-30.6 13.703-30.6 30.6v458.277c0 16.898 13.703 30.602 30.6 30.602h75.484c16.897 0 30.6-13.703 30.6-30.602V30.6c.001-16.897-13.702-30.6-30.6-30.6zm208.08 0h-75.484c-16.896 0-30.6 13.703-30.6 30.6v458.277c0 16.898 13.703 30.602 30.6 30.602h75.484c16.896 0 30.6-13.703 30.6-30.602V30.6c0-16.897-13.703-30.6-30.6-30.6z"/>
+</svg>

Some files were not shown because too many files changed in this diff