Browse Source

Merge branch '43-add-button-to-perform-predictions' into 'master'

Resolve "add button to perform predictions"

Closes #38 and #43

See merge request troebs/pycs!34
Eric Tröbs 4 years ago
parent
commit
d7da4dedbb

+ 1 - 1
pycs/ApplicationStatus.py

@@ -21,7 +21,7 @@ class ApplicationStatus(ObservableDict):
         super().__init__({
             'settings': settings,
             'models': {},
-            'projects': [],
+            'projects': {},
             'jobs': []
         })
 

+ 30 - 63
pycs/frontend/WebServer.py

@@ -17,14 +17,11 @@ from pycs.util.RecursiveDictionary import set_recursive
 
 
 class WebServer:
-    def __init__(self, app_status: ApplicationStatus, pipeline_manager: PipelineManager):
+    def __init__(self, app_status: ApplicationStatus):
         # initialize web server
         if exists('webui/index.html'):
             print('production build')
 
-            # TODO update file upload
-            # files = FileProvider(app_status)
-
             # find svg icons and add them as separate static files to
             # set their correct mime type / content_type
             static_files = {'/': 'webui/'}
@@ -42,9 +39,6 @@ class WebServer:
         else:
             print('development build')
 
-            # TODO update file upload
-            # files = FileProvider(app_status, cors=True)
-
             self.__sio = socketio.Server(cors_allowed_origins='*')
             self.__flask = Flask(__name__)
             self.__app = socketio.WSGIApp(self.__sio, self.__flask)
@@ -60,7 +54,6 @@ class WebServer:
         # define events
         @self.__sio.event
         def connect(id, msg):
-            print('connect')
             self.__sio.emit('app_status', self.__status, to=id)
 
         @self.__flask.route('/settings', methods=['POST'])
@@ -68,56 +61,40 @@ class WebServer:
             data = request.get_json(force=True)
             set_recursive(data, app_status['settings'])
 
-            response = make_response()
-            response.headers['Access-Control-Allow-Origin'] = '*'
-            return response
+            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'])
 
-            # TODO move to project manager
-            app_status['projects'].append({
-                'status': 'create',
-                'name': data['name'],
-                'description': data['description'],
-                'model': data['model']
-            })
-
-            response = make_response()
-            response.headers['Access-Control-Allow-Origin'] = '*'
-            return response
+            return response()
 
         @self.__flask.route('/projects/<identifier>', methods=['POST'])
         def edit_project(identifier):
             data = request.get_json(force=True)
 
-            # TODO move to project manager
-            for project in app_status['projects']:
-                if project['id'] == identifier:
-                    # delete
-                    if 'delete' in data.keys():
-                        project['action'] = 'delete'
-                    # update
-                    else:
-                        set_recursive(data, project)
-                        project['action'] = 'update'
+            if 'delete' in data.keys():
+                app_status['projects'].delete_project(identifier)
+            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):
-            # TODO move to project manager
-            file_uuid = str(uuid1())
+            # 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 current project path
-            opened_projects = list(filter(lambda x: x['id'] == identifier, app_status['projects']))
-            if len(opened_projects) == 0:
-                return make_response('no open project available', 500)
-
-            current_project = opened_projects[0]
-            upload_path = path.join('projects', current_project['id'], 'data')
+            # 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()
@@ -167,10 +144,7 @@ class WebServer:
             job['finished'] = int(time())
 
             # add to project files
-            if 'data' not in current_project:
-                current_project['data'] = []
-
-            current_project['data'].append({
+            project.add_media_file({
                 'id': file_uuid,
                 'name': file_name.value,
                 'extension': file_extension.value,
@@ -183,32 +157,25 @@ class WebServer:
 
         @self.__flask.route('/projects/<project_identifier>/data/<file_identifier>', methods=['GET'])
         def get_file(project_identifier, file_identifier):
-            # get current project
-            opened_projects = list(filter(lambda x: x['id'] == project_identifier, app_status['projects']))
-            if len(opened_projects) == 0:
-                return make_response('no open project available', 500)
+            # abort if project id is not valid
+            if project_identifier not in app_status['projects'].keys():
+                return make_response('project does not exist', 500)
 
-            current_project = opened_projects[0]
-            file_directory = path.join(getcwd(), 'projects', current_project['id'], 'data')
+            project = app_status['projects'][project_identifier]
 
-            print(current_project)
+            # abort if file id is not valid
+            if file_identifier not in project['data'].keys():
+                return make_response('file does not exist', 500)
 
-            # get object
-            data_objects = list(filter(lambda x: x['id'] == file_identifier, current_project['data']))
-            if len(data_objects) == 0:
-                return make_response('data object not avilable', 500)
+            target_object = project['data'][file_identifier]
 
-            target_object = data_objects[0]
+            # construct directory and filename
+            file_directory = path.join(getcwd(), 'projects', project['id'], 'data')
+            file_name = target_object['id'] + target_object['extension']
 
             # return data
-            file_name = target_object['id'] + target_object['extension']
             return send_from_directory(file_directory, file_name)
 
-        @self.__flask.route('/projects/<project_identifier>/data/<file_identifier>', methods=['POST'])
-        def run_prediction(project_identifier, file_identifier):
-            pipeline_manager.run(project_identifier, file_identifier)
-            return response()
-
         # finally start web server
         host = app_status['settings']['frontend']['host']
         port = app_status['settings']['frontend']['port']

+ 10 - 6
pycs/observable/Observable.py

@@ -4,6 +4,10 @@ class Observable:
         from . import ObservableDict
         from . import ObservableList
 
+        if isinstance(source, ObservableDict):
+            return source
+        if isinstance(source, ObservableList):
+            return source
         if isinstance(source, dict):
             return ObservableDict(source, parent)
         if isinstance(source, list):
@@ -12,18 +16,18 @@ class Observable:
             return source
 
     def __init__(self, parent):
-        self.__parent = parent
-        self.__subscriptions = []
+        self.parent = parent
+        self.subscriptions = []
 
     def subscribe(self, handler, immediate=False):
-        self.__subscriptions.append(handler)
+        self.subscriptions.append(handler)
 
         if immediate:
             handler(self)
 
     def _notify(self):
-        for s in self.__subscriptions:
+        for s in self.subscriptions:
             s(self)
 
-        if self.__parent is not None:
-            self.__parent._notify()
+        if self.parent is not None:
+            self.parent._notify()

+ 46 - 36
pycs/pipeline/PipelineManager.py

@@ -6,47 +6,43 @@ from eventlet import tpool
 from pycs.ApplicationStatus import ApplicationStatus
 from pycs.pipeline.Job import Job
 from pycs.pipeline.tf1.pipeline import Pipeline as TF1Pipeline
+from pycs.projects.Project import Project
 
 
 class PipelineManager:
-    def __init__(self, app_status: ApplicationStatus):
-        self.app_status = app_status
-        # app_status['projects'].subscribe(self.__update)
+    def __init__(self, project: Project):
+        self.project = project
+        self.pipeline = tpool.execute(self.__load_pipeline, project['pipeline']['model-distribution'])
 
-    def run(self, project_identifier, file_identifier):
-        # find project
-        opened_projects = list(filter(lambda x: x['id'] == project_identifier, self.app_status['projects']))
-        if len(opened_projects) == 0:
-            return
+    def __enter__(self):
+        return self
 
-        current_project = opened_projects[0]
+    def __exit__(self, type, value, traceback):
+        self.pipeline.close()
 
-        # find data object
-        data_objects = list(filter(lambda x: x['id'] == file_identifier, current_project['data']))
-        if len(data_objects) == 0:
-            return
+    def run(self, media_file):
+        print('>>>', media_file)
 
-        target_objects = list(filter(lambda o: 'predictionResults' not in o.keys(), current_project['data']))
-        if len(target_objects) == 0:
-            return
+        # create job list
+        # TODO update job progress
+        job = Job('detect-faces', self.project['id'], media_file)
+        result = tpool.execute(lambda p, j: p.execute(j), self.pipeline, job)
+        media_file['predictionResults'] = result.predictions
 
-        # load pipeline
-        pipeline = tpool.execute(self.__load_pipeline, current_project['pipeline']['model-distribution'])
+        print('<<<', media_file)
 
-        for target_object in target_objects:
-            print('>>>', target_object)
+    def __load_pipeline(self, pipeline_identifier):
+        model_distribution = self.project.parent.parent['models'][pipeline_identifier]
 
-            # create job list
-            # TODO update job progress
-            job = Job('detect-faces', current_project['id'], target_object)
-            result = tpool.execute(lambda p, j: p.execute(j), pipeline, job)
-            target_object['predictionResults'] = result.predictions
+        if model_distribution['mode'] == 'tf1':
+            model_root = path.join(getcwd(), 'models', model_distribution['name'])
 
-            print('<<<', target_object)
+            pipeline = TF1Pipeline()
+            pipeline.load(model_root, model_distribution['pipeline'])
 
-        # close pipeline
-        pipeline.close()
+            return pipeline
 
+    '''
     def __update(self, data):
         # get current project path
         opened_projects = list(filter(lambda x: x['status'] == 'open', data))
@@ -73,14 +69,28 @@ class PipelineManager:
 
         # close pipeline
         pipeline.close()
+    '''
 
-    def __load_pipeline(self, pipeline_identifier):
-        model_distribution = self.app_status['models'][pipeline_identifier]
-
-        if model_distribution['mode'] == 'tf1':
-            model_root = path.join(getcwd(), 'models', model_distribution['name'])
+    '''
+    def __update(self, data):
+        for current_project in data:
+            print('>>>>>>>>>>')
+            # find images to predict
+            if 'data' not in current_project.keys() or len(current_project['data']) == 0:
+                return
 
-            pipeline = TF1Pipeline()
-            pipeline.load(model_root, model_distribution['pipeline'])
+            # load pipeline
+            pipeline = tpool.execute(self.__load_pipeline, current_project['pipeline']['model-distribution'])
 
-            return pipeline
+            # create job list
+            for d in current_project['data']:
+                print('keys:', d.keys())
+                if 'result' not in d.keys():
+                    # TODO update job progress
+                    job = Job('detect-faces', current_project['id'], d)
+                    result = tpool.execute(lambda p, j: p.execute(j), pipeline, job)
+                    d['result'] = result.predictions
+
+            # close pipeline
+            pipeline.close()
+    '''

+ 6 - 0
pycs/projects/MediaFile.py

@@ -0,0 +1,6 @@
+from pycs.observable import ObservableDict
+
+
+class MediaFile(ObservableDict):
+    def __init__(self, obj, parent):
+        super().__init__(obj, parent)

+ 26 - 0
pycs/projects/Project.py

@@ -0,0 +1,26 @@
+from uuid import uuid1
+
+from pycs.observable import ObservableDict
+from pycs.projects.MediaFile import MediaFile
+from pycs.util.RecursiveDictionary import set_recursive
+from os import path
+
+
+class Project(ObservableDict):
+    def __init__(self, obj: dict, parent):
+        # save data as MediaFile objects
+        for key in obj['data'].keys():
+            obj['data'][key] = MediaFile(obj['data'][key], self)
+
+        # initialize super
+        super().__init__(obj, parent)
+
+    def update_properties(self, update):
+        set_recursive(update, self)
+
+    def new_media_file_path(self):
+        return path.join('projects', self['id'], 'data'), str(uuid1())
+
+    def add_media_file(self, file):
+        file = MediaFile(file, self)
+        self['data'][file['id']] = file

+ 73 - 63
pycs/projects/ProjectManager.py

@@ -6,75 +6,85 @@ from time import time
 from uuid import uuid1
 
 from pycs import ApplicationStatus
+from pycs.observable import ObservableDict
+from pycs.pipeline.PipelineManager import PipelineManager
+from pycs.projects.Project import Project
 
 
-class ProjectManager:
+class ProjectManager(ObservableDict):
     def __init__(self, app_status: ApplicationStatus):
         # TODO create projects folder if it does not exist
+        self.app_status = app_status
+
+        # 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 = load(file)
-                project['status'] = 'close'
-
-                app_status['projects'].append(project)
-
-        # subscribe to changes
-        app_status['projects'].subscribe(self.update)
-
-    def update(self, data):
-        # detect project to create
-        for i in range(len(data)):
-            if data[i]['status'] == 'create':
-                # create dict representation
-                uuid = str(uuid1())
-
-                data[i] = {
-                    'id': uuid,
-                    'status': 'close',
-                    'name': data[i]['name'],
-                    'description': data[i]['description'],
-                    'created': int(time()),
-                    'access': 0,
-                    'pipeline': {
-                        'model-distribution': data[i]['model']
-                    }
-                }
-
-                # create project directory
-                folder = path.join('projects', uuid)
-                mkdir(folder)
-
-                # create project.json
-                with open(path.join(folder, 'project.json'), 'w') as file:
-                    dump(data[i], file, indent=4)
-
-        # detect project to load
-        to_load = list(filter(lambda x: x['status'] == 'load', data))
-        for project in to_load:
-            # TODO actually load pipeline
-            project['status'] = 'open'
-            project['access'] = int(time())
-
-        # detect project to update
-        for i in range(len(data)):
-            if 'action' in data[i] and data[i]['action'] == 'update':
-                del data[i]['action']
-
-                prj = data[i].copy()
-                del prj['status']
-
-                with open(path.join('projects', data[i]['id'], 'project.json'), 'w') as file:
-                    dump(prj, file, indent=4)
-
-        # detect project to delete
-        for i in range(len(data)):
-            if 'action' in data[i] and data[i]['action'] == 'delete':
-                folder = path.join('projects', data[i]['id'])
-
-                del data[i]
-                rmtree(folder)
-
-                break
+                project = Project(load(file), self)
+                self[project['id']] = project
+
+    def __write_project(self, uuid):
+        with open(path.join('projects', uuid, 'project.json'), 'w') as file:
+            dump(self[uuid], file, indent=4)
+
+    def create_project(self, name, description, model):
+        # create dict representation
+        uuid = str(uuid1())
+        self[uuid] = Project({
+            'id': uuid,
+            'name': name,
+            'description': description,
+            'created': int(time()),
+            'pipeline': {
+                'model-distribution': model
+            },
+            'data': {}
+        }, self)
+
+        # create project directory
+        folder = path.join('projects', uuid)
+        mkdir(folder)
+
+        # 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):
+        # abort if uuid is no valid key
+        if uuid not in self.keys():
+            return
+
+        project = self[uuid]
+
+        # load pipeline
+        with PipelineManager(project) as pm:
+            # TODO add jobs to list
+            # run predictions
+            for file_id in identifiers:
+                if file_id in project['data'].keys():
+                    pm.run(project['data'][file_id])

+ 2 - 2
test/test_application_status.py

@@ -9,7 +9,7 @@ class TestApplicationStatus(unittest.TestCase):
         self.assertEqual({
             'settings': {},
             'models': {},
-            'projects': [],
+            'projects': {},
             'jobs': []
         }, aso)
 
@@ -23,7 +23,7 @@ class TestApplicationStatus(unittest.TestCase):
         self.assertEqual({
             'settings': settings,
             'models': {},
-            'projects': [],
+            'projects': {},
             'jobs': []
         }, aso)
 

+ 4 - 1
webui/src/App.vue

@@ -103,7 +103,10 @@ export default {
   },
   computed: {
     projects: function() {
-      return this.status == null ? [] : this.status.projects;
+      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++)

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

@@ -8,7 +8,7 @@
                     :image="image"
                     :position="current"/>
 
-    <annotation-box v-for="result in data.result"
+    <annotation-box v-for="result in data.predictionResults"
                     v-bind:key="result"
                     :image="image"
                     :position="result"/>

+ 16 - 6
webui/src/components/media/annotated-media-view.vue

@@ -6,12 +6,14 @@
 
     <div class="control">
       <media-control :hasPrevious="hasPrevious" :hasNext="hasNext"
-                     @previous="previous" @next="next"/>
+                     @previous="previous" @next="next"
+                     @predict="predict"/>
     </div>
 
     <div class="selector">
-      <media-selector :project="currentProject"
-                      :current="currentMedia"
+      <media-selector :projectId="currentProject.id"
+                      :media="media"
+                      :current="current"
                       :socket="socket"
                       @click="current = $event"/>
     </div>
@@ -37,11 +39,14 @@ export default {
       return this.current > 0;
     },
     hasNext: function() {
-      return this.current < this.currentProject.data.length - 1;
+      return this.current < this.media.length - 1;
+    },
+    media: function() {
+      return Object.keys(this.currentProject.data).map(key => this.currentProject.data[key]);
     },
     currentMedia: function() {
-      if (this.current < this.currentProject.data.length)
-        return this.currentProject.data[this.current];
+      if (this.current < this.media.length)
+        return this.media[this.current];
       else
         return false;
     }
@@ -54,6 +59,11 @@ export default {
     next: function() {
       if (this.hasNext)
         this.current += 1;
+    },
+    predict: function() {
+      this.socket.post('/projects/' + this.currentProject.id, {
+        'predict': [this.currentMedia.id]
+      })
     }
   }
 }

+ 17 - 7
webui/src/components/media/media-control.vue

@@ -1,5 +1,5 @@
 <template>
-  <button-row class="media-control">
+  <div class="media-control">
     <button-input type="transparent"
                   style="color: var(--on_error)"
                   :class="{disabled: !hasPrevious}"
@@ -7,10 +7,17 @@
       &lt;
     </button-input>
 
-    <button-input type="transparent"
-                  style="color: var(--on_error)">
-      Reset
-    </button-input>
+    <button-row class="media-control">
+      <button-input type="primary"
+                    style="color: var(--on_error)">
+        Reset
+      </button-input>
+      <button-input type="primary"
+                    style="color: var(--on_error)"
+                    @click="$emit('predict', 'current')">
+        Predict
+      </button-input>
+    </button-row>
 
     <button-input type="transparent"
                   style="color: var(--on_error)"
@@ -18,7 +25,8 @@
                   @click="next">
       &gt;
     </button-input>
-  </button-row>
+  </div>
+
 </template>
 
 <script>
@@ -44,7 +52,9 @@ export default {
 
 <style scoped>
 .media-control {
-  text-align: center;
+  display: flex;
+  justify-content: center;
+  align-items: center;
 }
 
 .disabled {

+ 11 - 24
webui/src/components/media/media-selector.vue

@@ -4,7 +4,8 @@
          v-for="(m, index) in media"
          v-bind:key="m.id"
          @click="$emit('click', index)">
-      <img alt="media" :src="socket.media(project.id, m.id)">
+      <img alt="media" :src="socket.media(projectId, m.id)">
+      <div class="active" v-if="index === current"/>
     </div>
   </div>
 </template>
@@ -12,21 +13,7 @@
 <script>
 export default {
   name: "media-selector",
-  props: ['project', 'current', 'socket'],
-  computed: {
-    media: function() {
-      return this.project.data;
-    },
-    currentIndex: function() {
-      for (let i = 0; i < this.media.length; i++) {
-        if (this.current.id === this.project.data[i].id) {
-          return i;
-        }
-      }
-
-      return false;
-    }
-  }
+  props: ['projectId', 'media', 'current', 'socket']
 }
 </script>
 
@@ -40,6 +27,7 @@ export default {
 }
 
 .element {
+  position: relative;
   width: 5rem;
   height: 5rem;
   display: flex;
@@ -55,15 +43,14 @@ export default {
   border-right: none;
 }
 
-/*
-.element:first-child {
-  border-left: 2px solid transparent;
-}
-
-.element:last-child {
-  border-right: 2px solid transparent;
+.active {
+  position: absolute;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+  box-shadow: 0 0 20px -5px var(--primary) inset;
 }
-*/
 
 img {
   max-width: 100%;

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

@@ -23,7 +23,7 @@
       </select-input>
     </div>
 
-    <div class="footer">
+    <button-row class="footer">
       <button-input @click="create" type="primary">
         Create Project
       </button-input>
@@ -31,7 +31,7 @@
       <button-input @click="$emit('cancel', null)">
         Cancel
       </button-input>
-    </div>
+    </button-row>
   </div>
 </template>
 
@@ -40,10 +40,11 @@ import TextInput from "@/components/base/text-input";
 import TextareaInput from "@/components/base/textarea-input";
 import SelectInput from "@/components/base/select-input";
 import ButtonInput from "@/components/base/button-input";
+import ButtonRow from "@/components/base/button-row";
 
 export default {
   name: "project-creation-window",
-  components: {ButtonInput, SelectInput, TextareaInput, TextInput},
+  components: {ButtonRow, ButtonInput, SelectInput, TextareaInput, TextInput},
   props: ['status', 'socket'],
   data: function() {
     return {
@@ -126,12 +127,4 @@ export default {
   flex-direction: row;
   justify-content: flex-end;
 }
-
-.footer > * {
-  margin-left: 0.5rem;
-}
-
-.cancel {
-  cursor: pointer;
-}
 </style>

+ 3 - 14
webui/src/components/projects/project-open-window.vue

@@ -19,11 +19,8 @@
 
           <div class="description">{{ project.description }}</div>
 
-          <div v-if="project.access === 0">
-            created on {{ datetime(project.created) }}
-          </div>
-          <div v-else>
-            {{ datetime(project.access) }}
+          <div>
+            {{ datetime(project.created) }}
           </div>
         </div>
       </div>
@@ -55,15 +52,7 @@ export default {
       return [...this.projects].map((e, i) => {
         e.index = i;
         return e;
-      }).sort((a, b) => {
-        const x = a.access === 0 ? a.created : a.access;
-        const y = b.access === 0 ? b.created : b.access;
-
-        if (x < y)
-          return 1;
-        else
-          return -1;
-      });
+      }).sort((a, b) => a.name < b.name ? -1 : 1);
     }
   },
   methods: {

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

@@ -65,13 +65,13 @@ export default {
     nameF: function(value) {
       // TODO then / error
       this.socket.post(this.path, {
-        'name': value
+        name: value
       });
     },
     descriptionF: function(value) {
       // TODO then / error
       this.socket.post(this.path, {
-        'description': value
+        description: value
       });
     },
     deleteProject: function() {

+ 4 - 1
webui/src/components/window/side-navigation-bar.vue

@@ -29,7 +29,7 @@
       </div>
 
       <div class="item"
-           :class="{active: window.content === 'view_data', inactive: !currentProject}"
+           :class="{active: window.content === 'view_data', inactive: !currentProject || !mediaAvailable}"
            @click="ifProjectIsOpened(show, 'view_data')">
         <img src="@/assets/icons/file-media.svg">
         <span>View Data</span>
@@ -68,6 +68,9 @@ export default {
         return false;
 
       return this.status.settings.frontend.collapse;
+    },
+    mediaAvailable: function() {
+      return this.currentProject && 'data' in this.currentProject && Object.keys(this.currentProject.data).length > 0;
     }
   },
   methods: {