Explorar o código

Resolve "media collections"

Eric Tröbs %!s(int64=4) %!d(string=hai) anos
pai
achega
502747d58e

+ 12 - 0
models/haarcascade_frontalface_default/Pipeline.py

@@ -27,6 +27,12 @@ class Pipeline(Interface):
     def close(self):
         print('hcffdv1 close')
 
+    def collections(self) -> List[dict]:
+        return [
+            self.create_collection('face', 'face detected', autoselect=True),
+            self.create_collection('none', 'no face detected')
+        ]
+
     def execute(self, file: MediaFile) -> List[dict]:
         print('hcffdv1 execute')
 
@@ -55,4 +61,10 @@ class Pipeline(Interface):
                 h / height
             ))
 
+        # set file collection
+        if len(result) > 0:
+            result.append(self.create_collection_result('face'))
+        else:
+            result.append(self.create_collection_result('none'))
+
         return result

+ 72 - 0
pycs/database/Collection.py

@@ -0,0 +1,72 @@
+from contextlib import closing
+from typing import List
+
+from pycs.database.File import File
+
+
+class Collection:
+    """
+    database class for collections
+    """
+
+    def __init__(self, database, row):
+        self.database = database
+
+        self.identifier = row[0]
+        self.project_id = row[1]
+        self.reference = row[2]
+        self.name = row[3]
+        self.description = row[4]
+        self.position = row[5]
+        self.autoselect = False if row[6] == 0 else True
+
+    def set_name(self, name: str):
+        """
+        set this collection's name
+
+        :param name: new name
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE collections SET name = ? WHERE id = ?', (name, self.identifier))
+            self.name = name
+
+    def remove(self) -> None:
+        """
+        remove this collection from the database
+
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('DELETE FROM collections WHERE id = ?', [self.identifier])
+
+    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 = ? AND collection = ?',
+                           (self.project_id, self.identifier))
+            return cursor.fetchone()[0]
+
+    def files(self, offset=0, limit=-1) -> List[File]:
+        """
+        get a list of files associated with this collection
+
+        :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 = ? AND collection = ?
+                ORDER BY id ASC LIMIT ? OFFSET ?
+                ''', (self.project_id, self.identifier, limit, offset))
+
+            return list(map(
+                lambda row: File(self.database, row),
+                cursor.fetchall()
+            ))

+ 48 - 14
pycs/database/Database.py

@@ -3,6 +3,7 @@ from contextlib import closing
 from time import time
 from typing import Optional, List
 
+from pycs.database.Collection import Collection
 from pycs.database.File import File
 from pycs.database.LabelProvider import LabelProvider
 from pycs.database.Model import Model
@@ -79,21 +80,38 @@ class Database:
                     UNIQUE(project, reference)
                 )
             ''')
+            cursor.execute('''
+                CREATE TABLE IF NOT EXISTS collections (
+                    id          INTEGER          PRIMARY KEY,
+                    project     INTEGER NOT NULL,
+                    reference   TEXT    NOT NULL,
+                    name        TEXT    NOT NULL,
+                    description TEXT,
+                    position    INTEGER NOT NULL,
+                    autoselect  INTEGER 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,
+                    id         INTEGER PRIMARY KEY,
+                    uuid       TEXT                NOT NULL,
+                    project    INTEGER             NOT NULL,
+                    collection INTEGER,
+                    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,
+                    FOREIGN KEY (collection) REFERENCES collections(id)
+                        ON UPDATE CASCADE ON DELETE SET NULL,
                     UNIQUE(project, path)
                 )
             ''')
@@ -109,9 +127,9 @@ class Database:
                         ON UPDATE CASCADE ON DELETE CASCADE
                 )
             ''')
-            cursor.execute('''
-                CREATE INDEX IF NOT EXISTS idx_results_label ON results(label)
-            ''')
+            # cursor.execute('''
+            #     CREATE INDEX IF NOT EXISTS idx_results_label ON results(label)
+            # ''')
 
         # run discovery modules
         if discovery:
@@ -252,6 +270,22 @@ class Database:
 
             return self.project(cursor.lastrowid)
 
+    def collection(self, identifier: int) -> Optional[Collection]:
+        """
+        get a collection using its unique identifier
+
+        :param identifier: unique identifier
+        :return: collection
+        """
+        with closing(self.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM collections WHERE id = ?', [identifier])
+            row = cursor.fetchone()
+
+            if row is not None:
+                return Collection(self, row)
+
+            return None
+
     def file(self, identifier) -> Optional[File]:
         """
         get a file using its unique identifier

+ 105 - 8
pycs/database/File.py

@@ -16,14 +16,15 @@ class File:
         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]
+        self.collection_id = row[3]
+        self.type = row[4]
+        self.name = row[5]
+        self.extension = row[6]
+        self.size = row[7]
+        self.created = row[8]
+        self.path = row[9]
+        self.frames = row[10]
+        self.fps = row[11]
 
     def project(self):
         """
@@ -33,6 +34,46 @@ class File:
         """
         return self.database.project(self.project_id)
 
+    def collection(self):
+        """
+        get the collection associated with this file
+
+        :return: collection
+        """
+        if self.collection_id is None:
+            return None
+
+        return self.database.collection(self.collection_id)
+
+    def set_collection(self, collection_id: Optional[int]):
+        """
+        set this file's collection
+
+        :param collection_id: new collection
+        :return:
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE files SET collection = ? WHERE id = ?',
+                           (collection_id, self.identifier))
+            self.collection_id = collection_id
+
+    def set_collection_by_reference(self, collection_reference: str):
+        """
+        set this file's collection
+
+        :param collection_reference: collection reference
+        :return:
+        """
+        if collection_reference is None:
+            self.set_collection(None)
+            return
+
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT id FROM collections WHERE reference = ?', [collection_reference])
+            row = cursor.fetchone()
+
+        self.set_collection(row[0] if row is not None else None)
+
     def remove(self) -> None:
         """
         remove this file from the database
@@ -76,6 +117,62 @@ class File:
 
             return None
 
+    def previous_in_collection(self):
+        """
+        get the predecessor of this file
+
+        :return: another file
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            if self.collection_id is None:
+                cursor.execute('''
+                    SELECT * FROM files
+                    WHERE id < ? AND project = ? AND collection IS NULL
+                    ORDER BY id DESC
+                    LIMIT 1
+                ''', (self.identifier, self.project_id))
+            else:
+                cursor.execute('''
+                    SELECT * FROM files
+                    WHERE id < ? AND project = ? AND collection = ?
+                    ORDER BY id DESC
+                    LIMIT 1
+                ''', (self.identifier, self.project_id, self.collection_id))
+
+            row = cursor.fetchone()
+            if row is not None:
+                return File(self.database, row)
+
+            return None
+
+    def next_in_collection(self):
+        """
+        get the successor of this file
+
+        :return: another file
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            if self.collection_id is None:
+                cursor.execute('''
+                    SELECT * FROM files
+                    WHERE id > ? AND project = ? AND collection IS NULL
+                    ORDER BY id ASC
+                    LIMIT 1
+                ''', (self.identifier, self.project_id))
+            else:
+                cursor.execute(''' 
+                    SELECT * FROM files
+                    WHERE id > ? AND project = ? AND collection = ?
+                    ORDER BY id ASC
+                    LIMIT 1
+                ''', (self.identifier, self.project_id, self.collection_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

+ 94 - 0
pycs/database/Project.py

@@ -3,6 +3,7 @@ from os.path import join
 from time import time
 from typing import List, Optional, Tuple
 
+from pycs.database.Collection import Collection
 from pycs.database.File import File
 from pycs.database.Label import Label
 from pycs.database.LabelProvider import LabelProvider
@@ -108,6 +109,69 @@ class Project:
 
         return self.label(row_id), insert
 
+    def collections(self) -> List[Collection]:
+        """
+        get a list of collections associated with this project
+
+        :return: list of collections
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM collections WHERE project = ? ORDER BY position ASC',
+                           [self.identifier])
+
+            return list(map(
+                lambda row: Collection(self.database, row),
+                cursor.fetchall()
+            ))
+
+    def collection(self, identifier: int) -> Optional[Collection]:
+        """
+        get a collection using its unique identifier
+
+        :param identifier: unique identifier
+        :return: collection
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM collections WHERE id = ? AND project = ?',
+                           (identifier, self.identifier))
+            row = cursor.fetchone()
+
+            if row is not None:
+                return Collection(self.database, row)
+
+            return None
+
+    def create_collection(self,
+                          reference: str,
+                          name: str,
+                          description: str,
+                          position: int,
+                          autoselect: bool):
+        autoselect = 1 if autoselect else 0
+
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                INSERT INTO collections
+                    (project, reference, name, description, position, autoselect)
+                VALUES (?, ?, ?, ?, ?, ?)
+                ON CONFLICT (project, reference) DO
+                UPDATE SET name = ?, description = ?, position = ?, autoselect = ?
+            ''', (self.identifier, reference, name, description, position, autoselect,
+                  name, description, position, autoselect))
+
+            # 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 collections WHERE project = ? AND reference = ?',
+                               (self.identifier, reference))
+                row_id = cursor.fetchone()[0]
+                insert = False
+
+        return self.collection(row_id), insert
+
     def remove(self) -> None:
         """
         remove this project from the database
@@ -187,6 +251,36 @@ class Project:
                 cursor.fetchall()
             ))
 
+    def count_files_without_collection(self) -> int:
+        """
+        count files associated with this project but with no collection
+
+        :return: count
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT COUNT(*) FROM files WHERE project = ? AND collection IS NULL',
+                           [self.identifier])
+            return cursor.fetchone()[0]
+
+    def files_without_collection(self, offset=0, limit=-1) -> List[File]:
+        """
+        get a list of files without not associated with any collection
+
+        :return: list of files
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('''
+                SELECT * FROM files
+                WHERE files.project = ? AND files.collection IS NULL
+                ORDER BY id ASC
+                LIMIT ? OFFSET ?
+            ''', (self.identifier, limit, offset))
+
+            return list(map(
+                lambda row: File(self.database, row),
+                cursor.fetchall()
+            ))
+
     def file(self, identifier) -> Optional[File]:
         """
         get a file using its unique identifier

+ 11 - 0
pycs/frontend/WebServer.py

@@ -30,6 +30,7 @@ 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.ListCollections import ListCollections
 from pycs.frontend.endpoints.projects.ListFiles import ListFiles
 from pycs.frontend.endpoints.projects.RemoveProject import RemoveProject
 from pycs.frontend.endpoints.results.ConfirmResult import ConfirmResult
@@ -148,6 +149,16 @@ class WebServer:
             view_func=EditLabelName.as_view('edit_label_name', database, notifications)
         )
 
+        # collections
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/collections',
+            view_func=ListCollections.as_view('list_collections', database)
+        )
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/data/<int:collection_id>/<int:start>/<int:length>',
+            view_func=ListFiles.as_view('list_collection_files', database)
+        )
+
         # data
         self.__flask.add_url_rule(
             '/projects/<int:identifier>/data',

+ 3 - 1
pycs/frontend/endpoints/data/GetPreviousAndNextFile.py

@@ -25,7 +25,9 @@ class GetPreviousAndNextFile(View):
         # get previous and next
         result = {
             'previous': file.previous(),
-            'next': file.next()
+            'next': file.next(),
+            'previousInCollection': file.previous_in_collection(),
+            'nextInCollection': file.next_in_collection()
         }
 
         # return data

+ 22 - 7
pycs/frontend/endpoints/pipelines/PredictModel.py

@@ -49,28 +49,43 @@ class PredictModel(View):
 
         # create job
         def store(index, length, result):
+            # get file from list
+            file = files[index]
+
+            # start transaction
             with self.db:
-                for remove in files[index].results():
+                # remove current results from file
+                for remove in file.results():
                     if remove.origin == 'pipeline':
                         remove.remove()
                         self.nm.remove_result(remove)
 
+                # iterate over result entries
                 for entry in result:
-                    file_type = entry['type']
+                    # extract entry type
+                    entry_type = entry['type']
                     del entry['type']
 
+                    # update file collection
+                    if entry_type == 'collection':
+                        file.set_collection_by_reference(entry['reference'])
+                        self.nm.edit_file(file)
+                        continue
+
+                    # extract label from entry
                     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)
+                    # if entry_type == 'labeled-image':
+                    #     for remove in file.results():
+                    #         remove.remove()
+                    #         self.nm.remove_result(remove)
 
-                    created = files[index].create_result('pipeline', file_type, label, entry)
+                    # add result
+                    created = files[index].create_result('pipeline', entry_type, label, entry)
                     self.nm.create_result(created)
 
             return (index + 1) / length

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

@@ -1,3 +1,4 @@
+from contextlib import closing
 from os import mkdir
 from os import path
 from shutil import copytree
@@ -11,6 +12,7 @@ from pycs.frontend.endpoints.projects.ExecuteExternalStorage import ExecuteExter
 from pycs.frontend.endpoints.projects.ExecuteLabelProvider import ExecuteLabelProvider
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 from pycs.jobs.JobRunner import JobRunner
+from pycs.util.PipelineUtil import load_from_root_folder as load_pipeline
 
 
 class CreateProject(View):
@@ -88,6 +90,27 @@ class CreateProject(View):
             ExecuteLabelProvider.execute_label_provider(self.db, self.nm, self.jobs, created,
                                                         label_provider)
 
+        # load model and add collections to the project
+        def load_model_and_get_collections():
+            with closing(load_pipeline(model.root_folder)) as pipeline:
+                return pipeline.collections()
+
+        def add_collections_to_project(provided_collections):
+            with self.db:
+                for position, collection in enumerate(provided_collections):
+                    created.create_collection(collection['reference'],
+                                              collection['name'],
+                                              collection['description'],
+                                              position + 1,
+                                              collection['autoselect'])
+
+        self.jobs.run(created,
+                      'Media Collections',
+                      f'{created.name}',
+                      f'{created.identifier}/media-collections',
+                      executable=load_model_and_get_collections,
+                      result=add_collections_to_project)
+
         # find media files
         if external_data:
             ExecuteExternalStorage.find_media_files(self.db, self.nm, self.jobs, created)

+ 40 - 0
pycs/frontend/endpoints/projects/ListCollections.py

@@ -0,0 +1,40 @@
+from flask import abort, jsonify
+from flask.views import View
+
+from pycs.database.Database import Database
+
+
+class ListCollections(View):
+    """
+    return a list of collections 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):
+        # find project
+        project = self.db.project(project_id)
+
+        if project is None:
+            return abort(404)
+
+        # get collection list
+        collections = project.collections()
+
+        # disable autoselect if there are no elements in the collection
+        found = False
+
+        for collection in collections:
+            if collection.autoselect:
+                if found:
+                    collection.autoselect = False
+                elif collection.count_files() == 0:
+                    collection.autoselect = False
+                    found = True
+
+        # return files
+        return jsonify(collections)

+ 16 - 5
pycs/frontend/endpoints/projects/ListFiles.py

@@ -15,16 +15,27 @@ class ListFiles(View):
         # pylint: disable=invalid-name
         self.db = db
 
-    def dispatch_request(self, project_id: int, start: int, length: int):
+    def dispatch_request(self, project_id: int, start: int, length: int, collection_id: int = None):
         # 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)
+        # get count and files
+        if collection_id is not None:
+            if collection_id == 0:
+                count = project.count_files_without_collection()
+                files = project.files_without_collection(start, length)
+            else:
+                collection = project.collection(collection_id)
+                if collection is None:
+                    return abort(404)
+
+                count = collection.count_files()
+                files = collection.files(start, length)
+        else:
+            count = project.count_files()
+            files = project.files(start, length)
 
         # return files
         return jsonify({

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

@@ -142,6 +142,16 @@ class NotificationManager:
         print('create_file', created_file)
         self.__emit('create-file', created_file)
 
+    def edit_file(self, edited_file: File):
+        """
+        fire edit-file event
+
+        :param edited_file:
+        :return:
+        """
+        print('edit_file', edited_file)
+        self.__emit('edit-file', edited_file)
+
     def remove_file(self, removed_file: File):
         """
         fire remove-file event

+ 53 - 9
pycs/interfaces/Pipeline.py

@@ -16,7 +16,7 @@ class Pipeline:
         :param root_folder: relative path to model folder
         :param distribution: dict parsed from distribution.json
         """
-        raise NotImplementedError
+        pass
 
     def close(self):
         """
@@ -25,7 +25,37 @@ class Pipeline:
 
         :return:
         """
-        raise NotImplementedError
+        pass
+
+    def collections(self) -> List[dict]:
+        """
+        is called while initializing a pipeline to receive available
+        collections
+
+        :return: list of collections or None
+        """
+        return []
+
+    @staticmethod
+    def create_collection(reference: str,
+                          name: str,
+                          description: str = None,
+                          autoselect: bool = False) -> dict:
+        """
+        create a collection dict
+
+        :param reference: unique reference
+        :param name: collection name
+        :param description: collection description
+        :param autoselect: show this collection by default if it contains elements
+        :return: collection dict
+        """
+        return {
+            'reference': reference,
+            'name': name,
+            'description': description,
+            'autoselect': autoselect
+        }
 
     def execute(self, file: MediaFile) -> List[dict]:
         """
@@ -36,17 +66,21 @@ class Pipeline:
         """
         raise NotImplementedError
 
-    def fit(self, files: List[AnnotatedMediaFile]):
+    @staticmethod
+    def create_collection_result(reference: str) -> dict:
         """
-        receive a list of annotated media files and adapt the underlying model
+        create a collection result dictionary
 
-        :param files: list of annotated media files
-        :return:
+        :param reference: use None to remove this file's collection
+        :return: dict
         """
-        raise NotImplementedError
+        return {
+            'type': 'collection',
+            'reference': reference
+        }
 
     @staticmethod
-    def create_labeled_image_result(label: int):
+    def create_labeled_image_result(label: int) -> dict:
         """
         create a labeled-image result dictionary
 
@@ -59,7 +93,8 @@ class Pipeline:
         }
 
     @staticmethod
-    def create_bounding_box_result(x: float, y: float, w: float, h: float, label=None, frame=None):
+    def create_bounding_box_result(x: float, y: float, w: float, h: float,
+                                   label=None, frame=None) -> dict:
         # pylint: disable=too-many-arguments
         # pylint: disable=invalid-name
         """
@@ -87,3 +122,12 @@ class Pipeline:
             result['frame'] = frame
 
         return result
+
+    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

+ 54 - 31
webui/src/components/media/media-control.vue

@@ -13,17 +13,29 @@
                   type="transparent"
                   title="previous element (A)"
                   style="color: var(--on_error)"
-                  :class="{disabled: !previousElement}"
-                  @click="clickPreviousElement">
+                  :class="{disabled: !hasPreviousElement}"
+                  @click="$emit('previousElement', true)">
       &lt;
     </button-input>
 
+    <select v-if="collections.length > 0"
+            @change="filter">
+      <option>no filter</option>
+      <option>without collection</option>
+
+      <option v-for="collection in collections" :key="collection.identifier"
+              :value="collection.identifier"
+              :selected="collection.autoselect">
+        {{ collection.name }}
+      </option>
+    </select>
+
     <button-input ref="nextElement"
                   type="transparent"
                   title="next element (D)"
                   style="color: var(--on_error)"
-                  :class="{disabled: !nextElement}"
-                  @click="clickNextElement">
+                  :class="{disabled: !hasNextElement}"
+                  @click="$emit('nextElement', true)">
       &gt;
     </button-input>
 
@@ -44,17 +56,27 @@ import ButtonInput from "@/components/base/button-input";
 export default {
   name: "media-control",
   components: {ButtonInput},
-  props: ['current', 'hasPreviousPage', 'hasNextPage'],
+  props: ['current', 'hasPreviousPage', 'hasNextPage', 'hasPreviousElement', 'hasNextElement'],
   created: function () {
     window.addEventListener('keypress', this.keypressEvent);
+
+    // receive collections
+    this.$root.socket.get(`/projects/${this.$root.project.identifier}/collections`)
+        .then(response => response.json())
+        .then(collections => {
+          this.collections = collections;
+
+          for (let collection of collections)
+            if (collection.autoselect)
+              this.$emit('filter', collection.identifier);
+        });
   },
   destroyed: function () {
     window.removeEventListener('keypress', this.keypressEvent);
   },
   data: function () {
     return {
-      previousElement: false,
-      nextElement: false
+      collections: []
     }
   },
   methods: {
@@ -64,38 +86,33 @@ export default {
           this.$refs.previousPage.click();
           break;
         case 'a':
-          this.clickPreviousElement();
+          this.$refs.previousElement.click();
           break;
         case 'd':
-          this.clickNextElement();
+          this.$refs.nextElement.click();
           break;
         case 's':
           this.$refs.nextPage.click();
           break;
       }
     },
-    clickPreviousElement: function () {
-      if (this.previousElement)
-        this.$emit('click', this.previousElement);
-    },
-    clickNextElement: function () {
-      if (this.nextElement)
-        this.$emit('click', this.nextElement);
-    }
-  },
-  watch: {
-    current: {
-      immediate: true,
-      handler: function (newVal) {
-        if (!newVal)
-          return;
-
-        this.$root.socket.get(`/data/${this.current.identifier}/previous_next`)
-            .then(response => response.json())
-            .then(data => {
-              this.previousElement = data.previous;
-              this.nextElement = data.next;
-            });
+    filter: function (e) {
+      const select = e.target;
+      switch (select.selectedIndex) {
+          // no filter
+        case 0:
+          this.$emit('filter', false);
+          break;
+
+          // without collection
+        case 1:
+          this.$emit('filter', null);
+          break;
+
+          // other
+        default:
+          this.$emit('filter', select.options[select.selectedIndex].value);
+          break;
       }
     }
   }
@@ -121,4 +138,10 @@ export default {
 .disabled {
   opacity: 0.4;
 }
+
+select {
+  flex-grow: 1;
+  max-width: 15rem;
+  margin: 0 1rem;
+}
 </style>

+ 61 - 7
webui/src/components/media/paginated-media.vue

@@ -35,7 +35,7 @@
 <script>
 export default {
   name: "paginated-media",
-  props: ['rows', 'width', 'inline', 'deletable', 'current'],
+  props: ['rows', 'width', 'inline', 'deletable', 'current', 'filter'],
   mounted: function () {
     window.addEventListener('resize', this.resize);
     window.addEventListener('wheel', this.scroll);
@@ -57,7 +57,11 @@ export default {
       resizeEvent: false,
       page: 1,
       pageCount: 1,
-      images: []
+      images: [],
+      elements: {
+        previous: null,
+        next: null
+      }
     }
   },
   methods: {
@@ -78,7 +82,7 @@ export default {
       }, 500);
     },
     change: function (file) {
-      if (file['project_id'] === this.$root.project.identifier)
+      if (file.project_id === this.$root.project.identifier)
         this.get();
     },
     deleteElement: function (element) {
@@ -96,6 +100,14 @@ export default {
 
       this.get(callback);
     },
+    prevElement: function () {
+      if (this.elements.previous)
+        this.$emit('click', this.elements.previous);
+    },
+    nextElement: function () {
+      if (this.elements.next)
+        this.$emit('click', this.elements.next);
+    },
     get: function (callback) {
       // get container size
       const width = this.$refs.media.offsetWidth;
@@ -112,13 +124,25 @@ export default {
       this.$refs.media.style.gridTemplateRows = `repeat(${rows}, ${elementHeight}px)`;
 
       // receive elements
+      if (this.page === 0)
+        this.page = 1;
+
       const limit = rows * cols;
-      const offset = (this.page === 0 ? 0 : this.page - 1) * limit;
+      const offset = (this.page - 1) * limit;
 
       const requestWidth = Math.ceil(elementWidth / 200) * 200;
       const requestHeight = Math.ceil(elementHeight / 200) * 200;
 
-      this.$root.socket.get(`/projects/${this.$root.project.identifier}/data/${offset}/${limit}`)
+      let url;
+      if (this.filter === undefined || this.filter === false)
+        url = `/projects/${this.$root.project.identifier}/data/${offset}/${limit}`;
+      else if (this.filter === null)
+        url = `/projects/${this.$root.project.identifier}/data/0/${offset}/${limit}`;
+      else
+        url = `/projects/${this.$root.project.identifier}/data/${this.filter}/${offset}/${limit}`;
+
+      // call endpoint
+      this.$root.socket.get(url)
           .then(response => response.json())
           .then(elements => {
             this.images = elements.files;
@@ -128,7 +152,7 @@ export default {
               image.src = this.$root.socket.media(image, requestWidth, requestHeight);
             }
 
-            if (this.page > this.pageCount) {
+            if (this.pageCount > 0 && this.page > this.pageCount) {
               this.page = this.pageCount;
               return this.get(callback);
             }
@@ -160,10 +184,40 @@ export default {
   },
   watch: {
     current: function (newVal) {
-      if (!newVal)
+      if (!newVal) {
+        this.elements.previous = false;
+        this.elements.next = false;
+        this.$emit('hasPreviousElement', false);
+        this.$emit('hasNextElement', false);
         return;
+      }
 
+      // find current in list
       this.findCurrent();
+
+      // receive previous and next element
+      this.$root.socket.get(`/data/${this.current.identifier}/previous_next`)
+          .then(response => response.json())
+          .then(data => {
+            if (this.filter === undefined || this.filter === false) {
+              this.elements.previous = data.previous;
+              this.elements.next = data.next;
+            } else {
+              this.elements.previous = data.previousInCollection;
+              this.elements.next = data.nextInCollection;
+            }
+
+            this.$emit('hasPreviousElement', this.elements.previous);
+            this.$emit('hasNextElement', this.elements.next);
+          });
+    },
+    filter: function () {
+      this.get(() => {
+        if (this.images.length === 0)
+          this.$emit('click', false);
+        else
+          this.$emit('click', this.images[0]);
+      });
     }
   }
 }

+ 23 - 35
webui/src/components/projects/project-data-view-window.vue

@@ -1,20 +1,30 @@
 <template>
   <div class="project-data-view-window">
     <annotated-image v-if="current" :current="current"/>
+    <div v-else class="empty">
+      There are no items in this collection.
+    </div>
 
     <media-control :current="current"
                    :hasPreviousPage="hasPreviousPage"
                    :hasNextPage="hasNextPage"
+                   :hasPreviousElement="hasPreviousElement"
+                   :hasNextElement="hasNextElement"
                    @previousPage="$refs.media.prevPage()"
                    @nextPage="$refs.media.nextPage()"
-                   @click="current=$event"/>
+                   @previousElement="$refs.media.prevElement()"
+                   @nextElement="$refs.media.nextElement()"
+                   @filter="filter=$event"/>
 
     <paginated-media ref="media"
                      rows="1" width="100" :inline="true"
                      :current="current"
+                     :filter="filter"
                      @click="current=$event"
                      @hasPreviousPage="hasPreviousPage=$event"
-                     @hasNextPage="hasNextPage=$event"/>
+                     @hasNextPage="hasNextPage=$event"
+                     @hasPreviousElement="hasPreviousElement=$event"
+                     @hasNextElement="hasNextElement=$event"/>
   </div>
 </template>
 
@@ -30,7 +40,10 @@ export default {
     return {
       current: false,
       hasPreviousPage: false,
-      hasNextPage: false
+      hasNextPage: false,
+      hasPreviousElement: false,
+      hasNextElement: false,
+      filter: false
     }
   }
 }
@@ -52,6 +65,13 @@ export default {
   position: relative;
 }
 
+.empty {
+  flex-grow: 1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
 /deep/ .paginated-media {
   background-color: rgba(0, 0, 0, 0.5);
   color: whitesmoke;
@@ -63,36 +83,4 @@ export default {
   cursor: pointer;
   border: 1px solid rgba(0, 0, 0, 0.5);
 }
-
-/*
-.media {
-  flex-grow: 1;
-  position: relative;
-}
-
-.annotated-image {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.control {
-  background-color: rgba(0, 0, 0, 0.8);
-  padding-top: 0.5rem;
-  padding-bottom: 0.5rem;
-}
-
-/*
-.selector {
-  overflow-x: auto;
-  white-space: nowrap;
-  background-color: rgba(0, 0, 0, 0.5);
-}
-*/
 </style>