Browse Source

Merge branch 'ammod' into alchemy

Dimitri Korsch 3 years ago
parent
commit
89daca16e9
33 changed files with 1395 additions and 111 deletions
  1. 964 0
      labels/lepiforum_version_7/Lepiforums-Europaliste_Schmetterlinge_Version_7_Stand_2020_01_01_bearbeitet_GBrehm.csv
  2. 62 0
      labels/lepiforum_version_7/Provider.py
  3. 26 0
      labels/lepiforum_version_7/RowWrapper.py
  4. 11 0
      labels/lepiforum_version_7/configuration1.json
  5. 11 0
      labels/lepiforum_version_7/configuration2.json
  6. 11 0
      labels/lepiforum_version_7/configuration3.json
  7. 11 0
      labels/lepiforum_version_7/configuration4.json
  8. 7 6
      pycs/database/Collection.py
  9. 3 0
      pycs/database/Database.py
  10. 7 1
      pycs/database/File.py
  11. 0 1
      pycs/database/Label.py
  12. 71 11
      pycs/database/LabelProvider.py
  13. 34 9
      pycs/database/Project.py
  14. 5 0
      pycs/database/base.py
  15. 2 2
      pycs/frontend/endpoints/data/UploadFile.py
  16. 7 1
      pycs/frontend/endpoints/pipelines/FitModel.py
  17. 24 8
      pycs/frontend/endpoints/pipelines/PredictModel.py
  18. 1 2
      pycs/frontend/endpoints/projects/ExecuteLabelProvider.py
  19. 2 2
      pycs/frontend/endpoints/results/GetProjectResults.py
  20. 15 2
      pycs/frontend/notifications/NotificationList.py
  21. 2 1
      pycs/interfaces/LabelProvider.py
  22. 10 0
      pycs/interfaces/MediaBoundingBox.py
  23. 11 6
      pycs/interfaces/MediaFile.py
  24. 12 2
      pycs/interfaces/MediaFileList.py
  25. 9 0
      pycs/interfaces/MediaImageLabel.py
  26. 4 0
      pycs/interfaces/MediaLabel.py
  27. 4 4
      pycs/interfaces/MediaStorage.py
  28. 2 2
      pycs/jobs/JobRunner.py
  29. 0 28
      pycs/util/LabelProviderUtil.py
  30. 4 0
      pycs/util/PipelineCache.py
  31. 20 4
      webui/src/components/projects/project-creation-window.vue
  32. 28 17
      webui/src/components/projects/project-labels-window.vue
  33. 15 2
      webui/src/components/projects/settings/general-settings.vue

File diff suppressed because it is too large
+ 964 - 0
labels/lepiforum_version_7/Lepiforums-Europaliste_Schmetterlinge_Version_7_Stand_2020_01_01_bearbeitet_GBrehm.csv


+ 62 - 0
labels/lepiforum_version_7/Provider.py

@@ -0,0 +1,62 @@
+import csv
+import typing
+from os import path
+
+from pycs.interfaces.LabelProvider import LabelProvider
+from .RowWrapper import RowWrapper
+
+
+class Provider(LabelProvider):
+    def __init__(self, root_folder, configuration):
+        self.csv_path = path.join(root_folder, configuration['filename'])
+        self.csv_minimum_rarity = configuration['minimumRarity']
+        self.csv_all_hierarchy_levels = configuration['includeAllHierarchyLevels']
+
+    def close(self):
+        pass
+
+    def get_labels(self) -> typing.List[dict]:
+        result = []
+
+        with open(self.csv_path, mode='r', newline='', encoding='utf8') as csv_file:
+            # skip first line which contains column names
+            csv_file.readline()
+
+            # read csv line by line
+            reader = csv.reader(csv_file, delimiter='\t')
+            entries = list(map(RowWrapper, reader))
+
+        # filter
+        if self.csv_minimum_rarity is not None:
+            entries = filter(lambda row: row.rarity_is_larger_than(self.csv_minimum_rarity),
+                             entries)
+
+        # create result set
+        for entry in entries:
+            entry = entry.__dict__
+            parent_reference = None
+
+            # add hierarchy
+            if self.csv_all_hierarchy_levels:
+                hierarchy_levels = ('superfamily', 'family', 'subfamily', 'tribe', 'genus')
+            else:
+                hierarchy_levels = ('family', 'genus')
+
+            for tax in hierarchy_levels:
+                if entry[tax] is not None:
+                    reference, name = entry[tax].lower(), entry[tax]
+                    result.append(self.create_label(reference, name, parent_reference))
+
+                    parent_reference = reference
+
+            # add element
+            if entry['kr_number'].isnumeric():
+                name = f'{entry["genus"]} {entry["species"]} ({entry["kr_number"]})'
+                reference = entry['kr_number']
+            else:
+                name = f'{entry["genus"]} {entry["species"]}'
+                reference = name.lower()
+
+            result.append(self.create_label(reference, name, parent_reference))
+
+        return result

+ 26 - 0
labels/lepiforum_version_7/RowWrapper.py

@@ -0,0 +1,26 @@
+class RowWrapper:
+    def __init__(self, row: list):
+        self.local_occurrence = self.__empty_to_none(row[0])
+        self.rarity = self.__empty_to_none(row[1])
+        self.superfamily = self.__empty_to_none(row[2])
+        self.family = self.__empty_to_default(row[3], self.superfamily)
+        self.subfamily = self.__empty_to_default(row[4], self.family)
+        self.tribe = self.__empty_to_default(row[5], self.subfamily)
+        self.kr_number = self.__empty_to_none(row[9])
+        self.genus = self.__empty_to_default(row[10], self.tribe)
+        self.species = self.__empty_to_none(row[11])
+
+    def rarity_is_larger_than(self, limit: int):
+        return self.rarity is not None and self.rarity.isnumeric() and limit < int(self.rarity)
+
+    @staticmethod
+    def __empty_to_none(val: str):
+        return val if val.strip() else None
+
+    @staticmethod
+    def __empty_to_default(val: str, default: str):
+        val = RowWrapper.__empty_to_none(val)
+        if val is not None:
+            return val
+
+        return default

+ 11 - 0
labels/lepiforum_version_7/configuration1.json

@@ -0,0 +1,11 @@
+{
+  "name": "Lepiforum Version 7 Hierarchic / most common, reduced hierarchy depth",
+  "description": "Stand: 01.01.2020, bearbeitet GBrehm",
+  "code": {
+    "module": "Provider",
+    "class": "Provider"
+  },
+  "filename": "Lepiforums-Europaliste_Schmetterlinge_Version_7_Stand_2020_01_01_bearbeitet_GBrehm.csv",
+  "minimumRarity": 0,
+  "includeAllHierarchyLevels": false
+}

+ 11 - 0
labels/lepiforum_version_7/configuration2.json

@@ -0,0 +1,11 @@
+{
+  "name": "Lepiforum Version 7 Hierarchic / reduced hierarchy depth",
+  "description": "Stand: 01.01.2020, bearbeitet GBrehm",
+  "code": {
+    "module": "Provider",
+    "class": "Provider"
+  },
+  "filename": "Lepiforums-Europaliste_Schmetterlinge_Version_7_Stand_2020_01_01_bearbeitet_GBrehm.csv",
+  "minimumRarity": null,
+  "includeAllHierarchyLevels": false
+}

+ 11 - 0
labels/lepiforum_version_7/configuration3.json

@@ -0,0 +1,11 @@
+{
+  "name": "Lepiforum Version 7 Hierarchic / most common",
+  "description": "Stand: 01.01.2020, bearbeitet GBrehm",
+  "code": {
+    "module": "Provider",
+    "class": "Provider"
+  },
+  "filename": "Lepiforums-Europaliste_Schmetterlinge_Version_7_Stand_2020_01_01_bearbeitet_GBrehm.csv",
+  "minimumRarity": 0,
+  "includeAllHierarchyLevels": true
+}

+ 11 - 0
labels/lepiforum_version_7/configuration4.json

@@ -0,0 +1,11 @@
+{
+  "name": "Lepiforum Version 7 Hierarchic",
+  "description": "Stand: 01.01.2020, bearbeitet GBrehm",
+  "code": {
+    "module": "Provider",
+    "class": "Provider"
+  },
+  "filename": "Lepiforums-Europaliste_Schmetterlinge_Version_7_Stand_2020_01_01_bearbeitet_GBrehm.csv",
+  "minimumRarity": null,
+  "includeAllHierarchyLevels": true
+}

+ 7 - 6
pycs/database/Collection.py

@@ -32,17 +32,18 @@ class Collection(NamedBaseModel):
 
     # relationships to other models
     files = db.relationship("File", backref="collection", lazy="dynamic")
-    serialize_rules = ('-files',)
 
+    serialize_only = NamedBaseModel.serialize_only + (
+        "project_id",
+        "reference",
+        "description",
+        "position",
+        "autoselect",
+    )
 
     def count_files(self) -> int:
         return self.files.count()
 
-    # def files_it(self, offset: int = 0, limit: int = -1) -> Iterator[File]:
-    #     # self.files.filter
-    #     files = File.query.filter_by(project_id=self.project_id, collection_id=self.id)
-    #     raise NotImplementedError
-
     def get_files(self, offset: int = 0, limit: int = -1):
         """
         get an iterator of files associated with this project

+ 3 - 0
pycs/database/Database.py

@@ -47,6 +47,9 @@ class Database:
         app.logger.warning("Database.close(): REMOVE ME!")
 
     def commit(self):
+        """
+        commit changes
+        """
         db.session.commit()
 
     def copy(self):

+ 7 - 1
pycs/database/File.py

@@ -158,7 +158,13 @@ class File(NamedBaseModel):
         self.commit()
         return result
 
-    def remove_results(self, origin='pipeline'):
+    def remove_results(self, origin='pipeline') -> List[Result]:
+        """
+        remove all results with the specified origin
+
+        :param origin: either 'pipeline' or 'user'
+        :return: list of removed results
+        """
 
         results = Result.query.filter(Result.file == self, Result.origin == origin)
 

+ 0 - 1
pycs/database/Label.py

@@ -22,7 +22,6 @@ def compare_children(start_label: Label, id: int):
 
 class Label(NamedBaseModel):
 
-    id = db.Column(db.Integer, primary_key=True)
     project_id = db.Column(
         db.Integer,
         db.ForeignKey("project.id", ondelete="CASCADE"),

+ 71 - 11
pycs/database/LabelProvider.py

@@ -1,9 +1,31 @@
+import glob
 import json
+import os
+import re
 
 from pathlib import Path
 
 from pycs import db
 from pycs.database.base import NamedBaseModel
+from pycs.interfaces.LabelProvider import LabelProvider as LabelProviderInterface
+
+
+def __find_files(root: str, config_regex=re.compile(r'^configuration(\d+)?\.json$')):
+    # list folders in labels/
+    for folder in Path(root).glob('*'):
+        # list files
+        for file_path in folder.iterdir():
+
+            # filter configuration files
+            if not file_path.isfile():
+                continue
+
+            if config_regex.match(file_path.name) is None:
+                continue
+
+            # yield element
+            yield folder, file_path
+
 
 class LabelProvider(NamedBaseModel):
     """
@@ -11,25 +33,63 @@ class LabelProvider(NamedBaseModel):
     """
 
     description = db.Column(db.String)
-    root_folder = db.Column(db.String, nullable=False, unique=True)
+    root_folder = db.Column(db.String, nullable=False)
+    configuration_file = db.Column(db.String, nullable=False)
 
     # relationships to other models
     projects = db.relationship("Project", backref="label_provider", lazy="dynamic")
-    serialize_rules = ('-projects',)
+
+    # contraints
+    __table_args__ = (
+        db.UniqueConstraint('root_folder', 'configuration_file'),
+    )
+
+    serialize_only = NamedBaseModel.serialize_only + (
+        "description",
+        "root_folder",
+        "configuration_file",
+    )
 
     @classmethod
-    def discover(cls, root: Path, config_name: str = "configuration.json"):
+    def discover(cls, root: Path):
 
-        for folder in Path(root).glob("*"):
-            with open(folder / config_name) as f:
+        for folder, conf_path in __find_files(root):
+            with open(conf_path) as f:
                 config = json.load(f)
 
-            # extract data
-            name = config['name']
-            description = config.get('description', None)
+            provider, _ = cls.get_or_create(
+                root_folder=str(folder),
+                configuration_file=conf_path.name
+            )
 
-            provider, _ = cls.get_or_create(root_folder=str(folder))
-            provider.name = name
-            provider.description = description
+            provider.name = config['name']
+
+            # returns None if not present
+            provider.description = config.get('description')
 
             db.session.commit()
+
+    @property
+    def configuration_path(self) -> Path:
+        return Path(self.root_folder, self.configuration_file)
+
+    def load(self) -> LabelProviderInterface:
+        """
+        load configuration.json and create an instance from the included code object
+
+        :return: LabelProvider instance
+        """
+        # load configuration.json
+        with open(self.configuration_path, 'r') as configuration_file:
+            configuration = json.load(configuration_file)
+
+        # load code
+        code_path = Path(self.root_folder, configuration['code']['module']).resolve()
+        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(self.root_folder, configuration)

+ 34 - 9
pycs/database/Project.py

@@ -50,9 +50,7 @@ class Project(NamedBaseModel):
         lazy="dynamic",
         passive_deletes=True)
 
-    serialize_only = (
-        "id",
-        "name",
+    serialize_only = NamedBaseModel.serialize_only + (
         "created",
         "description",
         "model_id",
@@ -66,16 +64,26 @@ class Project(NamedBaseModel):
         """
         get a label using its unique identifier
 
-        :param identifier: unique identifier
+        :param id: unique identifier
         :return: label
         """
+
         return self.labels.filter_by(id=id).one_or_none()
 
+    def label_by_reference(self, reference: str) -> T.Optional[Label]:
+        """
+        get a label using its reference string
+
+        :param reference: reference string
+        :return: label
+        """
+        return self.labels.filter_by(reference=reference).one_or_none()
+
     def file(self, id: int) -> T.Optional[Label]:
         """
         get a file using its unique identifier
 
-        :param identifier: unique identifier
+        :param id: unique identifier
         :return: file
         """
         return self.files.filter_by(id=id).one_or_none()
@@ -99,7 +107,7 @@ class Project(NamedBaseModel):
         return self.collections.filter_by(reference=reference).one_or_none()
 
     def create_label(self, name: str, reference: str = None,
-                     parent_id: int = None, commit: bool = True) -> T.Tuple[T.Optional[Label], bool]:
+                     parent: T.Union[Label, int, str] = None, commit: bool = True) -> T.Tuple[T.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.
@@ -110,10 +118,16 @@ class Project(NamedBaseModel):
         :return: created or edited label, insert
         """
 
+        if isinstance(parent, str):
+            parent = self.label_by_reference(parent)
+
+        if isinstance(parent, Label):
+            parent = parent.id
+
         label, is_new = Label.get_or_create(project=self, reference=reference)
 
         label.name = name
-        label.set_parent(parent_id, commit=False)
+        label.set_parent(parent, commit=False)
 
         if commit:
             self.commit()
@@ -126,9 +140,20 @@ class Project(NamedBaseModel):
                           description: str,
                           position: int,
                           autoselect: bool,
-                          commit: bool = True):
+                          commit: bool = True) -> T.Tuple[Collection, bool]:
+        """
+        create a new collection associated with this project
+
+        :param reference: collection reference string
+        :param name: collection name
+        :param description: collection description
+        :param position: position in menus
+        :param autoselect: automatically select this collection on session load
+
+        :return: collection object, insert
+        """
 
-        collection, is_new = Collection.get_or_create(project=self, reference=reference)
+        collection, is_new = Collection.get_or_create(project_id=self.id, reference=reference)
         collection.name = name
         collection.description = description
         collection.position = position

+ 5 - 0
pycs/database/base.py

@@ -16,6 +16,8 @@ class BaseModel(db.Model, ModelSerializer):
 
     id = db.Column(db.Integer, primary_key=True)
 
+    serialize_only = ("id",)
+
     @property
     def identifier(self) -> int:
         app.logger.warning("BaseModel.identifier: REMOVE ME!")
@@ -70,8 +72,11 @@ class BaseModel(db.Model, ModelSerializer):
 class NamedBaseModel(BaseModel):
     __abstract__ = True
 
+
     name = db.Column(db.String, nullable=False)
 
+    serialize_only = BaseModel.serialize_only + ("name", )
+
     def set_name(self, name: str):
         self.name = name
         self.commit()

+ 2 - 2
pycs/frontend/endpoints/data/UploadFile.py

@@ -55,8 +55,8 @@ class UploadFile(View):
         try:
             ftype, frames, fps = tpool.execute(file_info,
                                                self.data_folder, self.file_id, self.file_extension)
-        except ValueError as e:
-            return abort(400, str(e))
+        except ValueError as exception:
+            return abort(400, str(exception))
 
         file, _ = project.add_file(self.file_id, ftype, self.file_name, self.file_extension,
                                    self.file_size, self.file_id, frames, fps)

+ 7 - 1
pycs/frontend/endpoints/pipelines/FitModel.py

@@ -47,10 +47,16 @@ class FitModel(View):
 
     @staticmethod
     def load_and_fit(pipelines: PipelineCache, project_id: int):
+        """
+        load the pipeline and call the fit function
+
+        :param pipelines: pipeline cache
+        :param project_id: project id
+        """
         pipeline = None
 
         project = Project.query.get(project_id)
-        model = project.model()
+        model = project.model
         storage = MediaStorage(project_id)
 
         # load pipeline

+ 24 - 8
pycs/frontend/endpoints/pipelines/PredictModel.py

@@ -1,4 +1,5 @@
-from typing import Any
+from typing import List
+from typing import Union
 
 from flask import abort
 from flask import make_response
@@ -7,7 +8,7 @@ from flask.views import View
 
 from pycs import app
 from pycs import db
-from pycs.database.Database import Database
+from pycs.database.File import File
 from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationList import NotificationList
 from pycs.frontend.notifications.NotificationManager import NotificationManager
@@ -61,11 +62,19 @@ class PredictModel(View):
         return make_response()
 
     @staticmethod
-    def load_and_predict(pipelines: PipelineCache,
-                         notifications: NotificationList, project_id: int, file_filter: Any):
+    def load_and_predict(pipelines: PipelineCache, notifications: NotificationList,
+                         project_id: int, file_filter: Union[str, List[File]]):
+        """
+        load the pipeline and call the execute function
+
+        :param pipelines: pipeline cache
+        :param notifications: notification object
+        :param project_id: project id
+        :param file_filter: list of files or 'new' / 'all'
+        :return:
+        """
         pipeline = None
 
-        # create new database instance
         try:
             project = Project.query.get(project_id)
             model = project.model
@@ -104,10 +113,11 @@ class PredictModel(View):
                     yield index / length, notifications
 
                     index += 1
+
             except Exception as e:
                 import traceback
                 traceback.print_exc()
-                app.logger.warning(f"Pipeline Error #2: {e}")
+                app.logger.error(f"Pipeline Error #2: {e}")
 
             finally:
                 if pipeline is not None:
@@ -116,10 +126,16 @@ class PredictModel(View):
         except Exception as e:
             import traceback
             traceback.print_exc()
-            app.logger.warning(f"Pipeline Error #1: {e}")
-
+            app.logger.error(f"Pipeline Error #1: {e}")
 
     @staticmethod
     def progress(progress: float, notifications: NotificationList):
+        """
+        fire notifications from the correct thread
+
+        :param progress: [0, 1]
+        :param notifications: Notificationlist
+        :return: progress
+        """
         notifications.fire()
         return progress

+ 1 - 2
pycs/frontend/endpoints/projects/ExecuteLabelProvider.py

@@ -11,7 +11,6 @@ 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):
@@ -68,7 +67,7 @@ class ExecuteLabelProvider(View):
         # 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:
+            with closing(label_provider.load()) as label_provider_impl:
                 provided_labels = label_provider_impl.get_labels()
                 return provided_labels
 

+ 2 - 2
pycs/frontend/endpoints/results/GetProjectResults.py

@@ -19,8 +19,8 @@ class GetProjectResults(View):
             return abort(404)
 
         # map media files to a dict
-        ms = MediaStorage(project.id)
-        files = list(map(lambda f: f.serialize(), ms.files().iter()))
+        storage = MediaStorage(project.id)
+        files = list(map(lambda f: f.serialize(), storage.files().iter()))
 
         # return result
         return jsonify(files)

+ 15 - 2
pycs/frontend/notifications/NotificationList.py

@@ -2,14 +2,27 @@ from pycs.frontend.notifications.NotificationManager import NotificationManager
 
 
 class NotificationList:
-    def __init__(self, nm: NotificationManager):
+    """
+    stores notifications to fire them later in the correct thread
+    """
+
+    def __init__(self, notifications: NotificationManager):
         self.__list = []
-        self.nm = nm
+        self.notifications = notifications
 
     def add(self, fun: callable, *params):
+        """
+        add a function and parameters to be executed later
+
+        :param fun: function
+        :param params: parameters
+        """
         self.__list.append((fun, *params))
 
     def fire(self):
+        """
+        fire all stored events and reset the list
+        """
         for fun, *params in self.__list:
             fun(*params)
 

+ 2 - 1
pycs/interfaces/LabelProvider.py

@@ -1,4 +1,5 @@
 from typing import List
+from typing import Optional
 
 
 class LabelProvider:
@@ -32,7 +33,7 @@ class LabelProvider:
         raise NotImplementedError
 
     @staticmethod
-    def create_label(reference, name, parent_id=None):
+    def create_label(reference: str, name: str, parent_id: Optional[int] = None):
         """
         create a label result
 

+ 10 - 0
pycs/interfaces/MediaBoundingBox.py

@@ -2,6 +2,11 @@ from pycs.database.Result import Result
 
 
 class MediaBoundingBox:
+    """
+    A bounding box defined by it's upper left corner coordinates plus width and height. All those
+    values are normalized. A label and a frame (for videos) are optional.
+    """
+
     def __init__(self, result: Result):
         self.x = result.data['x']
         self.y = result.data['y']
@@ -11,4 +16,9 @@ class MediaBoundingBox:
         self.frame = result.data['frame'] if 'frame' in result.data else None
 
     def serialize(self) -> dict:
+        """
+        serialize all object properties to a dict
+
+        :return: dict
+        """
         return dict({'type': 'bounding-box'}, **self.__dict__)

+ 11 - 6
pycs/interfaces/MediaFile.py

@@ -35,7 +35,7 @@ class MediaFile:
         :param reference: use None to remove this file's collection
         """
         self.__file.set_collection_by_reference(reference)
-        self.__notifications.add(self.__notifications.nm.edit_file, self.__file.id)
+        self.__notifications.add(self.__notifications.notifications.edit_file, self.__file.id)
 
     def set_image_label(self, label: Union[int, MediaLabel], frame: int = None):
         """
@@ -53,7 +53,7 @@ class MediaFile:
             data = None
 
         created = self.__file.create_result('pipeline', 'labeled-image', label, data)
-        self.__notifications.add(self.__notifications.nm.create_result, created.id)
+        self.__notifications.add(self.__notifications.notifications.create_result, created.id)
 
     def add_bounding_box(self, x: float, y: float, w: float, h: float,
                          label: Union[int, MediaLabel] = None, frame: int = None):
@@ -80,7 +80,7 @@ class MediaFile:
             label = label.id
 
         created = self.__file.create_result('pipeline', 'bounding-box', label, result)
-        self.__notifications.add(self.__notifications.nm.create_result, created.id)
+        self.__notifications.add(self.__notifications.notifications.create_result, created.id)
 
     def remove_predictions(self):
         """
@@ -88,14 +88,14 @@ class MediaFile:
         """
         removed = self.__file.remove_results(origin='pipeline')
         for r in removed:
-            self.__notifications.add(self.__notifications.nm.remove_result, r.serialize())
+            self.__notifications.add(self.__notifications.notifications.remove_result, r.serialize())
 
     def __get_results(self, origin: str) -> List[Union[MediaImageLabel, MediaBoundingBox]]:
         def map_r(result: Result) -> Union[MediaImageLabel, MediaBoundingBox]:
             if result.type == 'labeled-image':
                 return MediaImageLabel(result)
-            else:
-                return MediaBoundingBox(result)
+
+            return MediaBoundingBox(result)
 
         return list(map(map_r, self.__file.results.filter_by(origin=origin)))
 
@@ -116,6 +116,11 @@ class MediaFile:
         return self.__get_results('pipeline')
 
     def serialize(self) -> dict:
+        """
+        serialize all object properties to a dict
+
+        :return: dict
+        """
         return {
             'type': self.type,
             'size': self.size,

+ 12 - 2
pycs/interfaces/MediaFileList.py

@@ -17,13 +17,23 @@ class MediaFileList:
         self.__collection = None
         self.__label = None
 
-    # TODO pydoc
     def filter_collection(self, collection_reference: str):
+        """
+        only include results matching the collection
+
+        :param collection_reference: reference string
+        :return: MediaFileList
+        """
         self.__collection = collection_reference
         return self
 
-    # TODO pydoc
     def filter_label(self, label: MediaLabel):
+        """
+        only include results matching the label
+
+        :param label: label
+        :return: MediaFileList
+        """
         self.__label = label
         return self
 

+ 9 - 0
pycs/interfaces/MediaImageLabel.py

@@ -2,9 +2,18 @@ from pycs.database.Result import Result
 
 
 class MediaImageLabel:
+    """
+    An image label with an optional frame index for videos.
+    """
+
     def __init__(self, result: Result):
         self.label = result.label
         self.frame = result.data['frame'] if 'frame' in result.data else None
 
     def serialize(self) -> dict:
+        """
+        serialize all object properties to a dict
+
+        :return: dict
+        """
         return dict({'type': 'image-label'}, **self.__dict__)

+ 4 - 0
pycs/interfaces/MediaLabel.py

@@ -2,6 +2,10 @@ from pycs.database.Label import Label
 
 
 class MediaLabel:
+    """
+    a label
+    """
+
     def __init__(self, label: Label):
         self.id = label.id
         self.parent = None

+ 4 - 4
pycs/interfaces/MediaStorage.py

@@ -29,13 +29,13 @@ class MediaStorage:
         result = []
 
         for label in label_list:
-            ml = label_dict[label.id]
+            medial_label = label_dict[label.id]
 
             if label.parent_id is not None:
-                ml.parent = label_dict[label.parent_id]
-                ml.parent.children.append(ml)
+                medial_label.parent = label_dict[label.parent_id]
+                medial_label.parent.children.append(medial_label)
 
-            result.append(ml)
+            result.append(medial_label)
 
         return result
 

+ 2 - 2
pycs/jobs/JobRunner.py

@@ -300,10 +300,10 @@ class JobRunner(GreenWorker):
                     result_event.send(result)
 
             # save exceptions to show in ui
-            except Exception as e:
+            except Exception as exception:
                 import traceback
                 traceback.print_exc()
-                job.exception = f'{type(e).__name__} ({str(e)})'
+                job.exception = f'{type(exception).__name__} ({str(exception)})'
 
             # remove from group dict
             if group is not None:

+ 0 - 28
pycs/util/LabelProviderUtil.py

@@ -1,28 +0,0 @@
-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)

+ 4 - 0
pycs/util/PipelineCache.py

@@ -38,6 +38,10 @@ class PipelineEntry(object):
         return f"<Pipeline '{self.pipeline_name}' for project #{self.project_id} (last_used: {self.last_used})>"
 
 class PipelineCache(GreenWorker):
+    """
+    Store initialized pipelines and call `close` after `CLOSE_TIMER` if they are not requested
+    another time.
+    """
     CLOSE_TIMER = dt.timedelta(seconds=120)
 
     def __init__(self, jobs: JobRunner):

+ 20 - 4
webui/src/components/projects/project-creation-window.vue

@@ -191,11 +191,9 @@ export default {
       return false;
     },
     availableLabels: function () {
-      let result = [{
-        name: 'None',
-        value: null
-      }];
+      let result = [];
 
+      // add label providers
       for (let label of this.labels) {
         result.push({
           name: label.name,
@@ -203,6 +201,24 @@ export default {
         });
       }
 
+      // sort
+      result.sort((a, b) => {
+        if (a.name.includes(b.name))
+          return +1;
+        if (b.name.includes(a.name))
+          return -1;
+        if (a.name > b.name)
+          return +1;
+        else
+          return -1;
+      });
+
+      // add `None` option
+      result.unshift({
+        name: 'None',
+        value: null
+      });
+
       return result;
     }
   },

+ 28 - 17
webui/src/components/projects/project-labels-window.vue

@@ -6,21 +6,26 @@
       Labels
     </h1>
 
-    <label-tree-view v-for="label in labelTree" :key="label.id"
-                     :label="label"
-                     :targetable="true"
-                     indent="2rem"/>
-
-    <div class="label">
-      <input-group>
-        <text-input placeholder="New Label"
-                    v-model="createLabelValue"
-                    @enter="createLabel"/>
-        <button-input type="primary" @click="createLabel">
-          create
-        </button-input>
-      </input-group>
-    </div>
+    <template v-if="labels !== false">
+      <label-tree-view v-for="label in labelTree" :key="label.id"
+                       :label="label"
+                       :targetable="true"
+                       indent="2rem"/>
+
+      <div class="label">
+        <input-group>
+          <text-input placeholder="New Label"
+                      v-model="createLabelValue"
+                      @enter="createLabel"/>
+          <button-input type="primary" @click="createLabel">
+            create
+          </button-input>
+        </input-group>
+      </div>
+    </template>
+    <template v-else>
+      <loading-icon/>
+    </template>
   </div>
 </template>
 
@@ -29,10 +34,11 @@ import TextInput from "@/components/base/text-input";
 import ButtonInput from "@/components/base/button-input";
 import InputGroup from "@/components/base/input-group";
 import LabelTreeView from "@/components/other/LabelTreeView";
+import LoadingIcon from "@/components/base/loading-icon";
 
 export default {
   name: "project-labels-window",
-  components: {LabelTreeView, InputGroup, ButtonInput, TextInput},
+  components: {LoadingIcon, LabelTreeView, InputGroup, ButtonInput, TextInput},
   created: function () {
     // get labels
     this.getLabels();
@@ -51,7 +57,7 @@ export default {
   },
   data: function () {
     return {
-      labels: [],
+      labels: false,
       createLabelValue: '',
       target: false
     }
@@ -171,6 +177,11 @@ h1.target {
   text-decoration: underline;
 }
 
+.loading-icon {
+  width: 4rem;
+  height: 4rem;
+}
+
 /*
 /deep/ .element {
   border: none;

+ 15 - 2
webui/src/components/projects/settings/general-settings.vue

@@ -1,11 +1,23 @@
 <template>
   <div class="general-settings">
     <text-input placeholder="Project ID"
-                :value="projectId"
+                :value="$root.project.id"
                 readonly="readonly">
       Project ID
     </text-input>
 
+    <text-input placeholder="Project Root"
+                :value="$root.project.root_folder"
+                readonly="readonly">
+      Project Root
+    </text-input>
+
+    <text-input placeholder="Data Root"
+                :value="$root.project.data_folder"
+                readonly="readonly">
+      Data Root
+    </text-input>
+
     <text-input placeholder="Name"
                 :value="name"
                 @change="sendName"
@@ -52,10 +64,11 @@ export default {
     this.projectId = this.$root.project.id;
     this.name = this.$root.project.name;
     this.description = this.$root.project.description;
+
+    console.log(this.$root.project);
   },
   data: function () {
     return {
-      projectId: '',
       name: '',
       nameError: false,
       description: '',

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