Przeglądaj źródła

Resolve "individual manager"

Eric Tröbs 4 lat temu
rodzic
commit
e256a2790b

+ 38 - 0
pycs/frontend/WebServer.py

@@ -229,6 +229,44 @@ class WebServer:
             # return default success response
             return response()
 
+        @self.__flask.route('/projects/<project_identifier>/labels', methods=['POST'])
+        def create_label(project_identifier):
+            # abort if project id is not valid
+            if project_identifier not in app_status['projects'].keys():
+                return make_response('project does not exist', 500)
+
+            project = app_status['projects'][project_identifier]
+
+            # add result
+            result = request.get_json(force=True)
+            if result:
+                project.add_label(result['name'])
+
+            # return default success response
+            return response()
+
+        @self.__flask.route('/projects/<project_identifier>/labels/<label_identifier>', methods=['POST'])
+        def edit_label(project_identifier, label_identifier):
+            # abort if project id is not valid
+            if project_identifier not in app_status['projects'].keys():
+                return make_response('project does not exist', 500)
+
+            project = app_status['projects'][project_identifier]
+
+            # parse post data
+            result = request.get_json(force=True)
+            if result:
+                # remove label
+                if 'delete' in result.keys():
+                    project.remove_label(label_identifier)
+
+                # update label
+                else:
+                    project.update_label(label_identifier, result['name'])
+
+            # return default success response
+            return response()
+
         # finally start web server
         host = app_status['settings']['frontend']['host']
         port = app_status['settings']['frontend']['port']

+ 21 - 1
pycs/projects/Project.py

@@ -1,13 +1,18 @@
+from os import path
 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):
+        # ensure all required object keys are available
+        for key in ['data', 'labels', 'jobs']:
+            if key not in obj.keys():
+                obj[key] = {}
+
         # save data as MediaFile objects
         for key in obj['data'].keys():
             obj['data'][key] = MediaFile(obj['data'][key], self)
@@ -24,3 +29,18 @@ class Project(ObservableDict):
     def add_media_file(self, file):
         file = MediaFile(file, self)
         self['data'][file['id']] = file
+
+    def add_label(self, name):
+        label_uuid = str(uuid1())
+        self['labels'][label_uuid] = {
+            'id': label_uuid,
+            'name': name
+        }
+
+    def update_label(self, identifier, name):
+        if identifier in self['labels']:
+            self['labels'][identifier]['name'] = name
+
+    def remove_label(self, identifier):
+        if identifier in self['labels']:
+            del self['labels'][identifier]

+ 1 - 0
pycs/projects/ProjectManager.py

@@ -48,6 +48,7 @@ class ProjectManager(ObservableDict):
                 'model-distribution': model
             },
             'data': {},
+            'labels': {},
             'jobs': {}
         }, self)
 

+ 6 - 0
webui/src/App.vue

@@ -36,6 +36,10 @@
                                  :current-project="currentProject"
                                  :socket="socket"/>
 
+        <project-labels-window v-if="window.content === 'labels'"
+                               :current-project="currentProject"
+                               :socket="socket"/>
+
         <project-data-view-window v-if="window.content === 'view_data'"
                                   :current-project="currentProject"
                                   :status="status"
@@ -57,10 +61,12 @@ import AboutWindow from "@/components/other/about-window";
 import ProjectDataAddWindow from "@/components/projects/project-data-add-window";
 import ProjectDataViewWindow from "@/components/projects/project-data-view-window";
 import LoadingOverlay from "@/components/other/loading-overlay";
+import ProjectLabelsWindow from "@/components/projects/project-labels-window";
 
 export default {
   name: 'App',
   components: {
+    ProjectLabelsWindow,
     LoadingOverlay,
     ProjectDataAddWindow,
     ProjectDataViewWindow,

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

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path fill-rule="evenodd"
+          d="M2.5 7.775V2.75a.25.25 0 01.25-.25h5.025a.25.25 0 01.177.073l6.25 6.25a.25.25 0 010 .354l-5.025 5.025a.25.25 0 01-.354 0l-6.25-6.25a.25.25 0 01-.073-.177zm-1.5 0V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 010 2.474l-5.026 5.026a1.75 1.75 0 01-2.474 0l-6.25-6.25A1.75 1.75 0 011 7.775zM6 5a1 1 0 100 2 1 1 0 000-2z"></path>
+</svg>

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

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path fill-rule="evenodd"
+          d="M3.404 12.596a6.5 6.5 0 119.192-9.192 6.5 6.5 0 01-9.192 9.192zM2.344 2.343a8 8 0 1011.313 11.314A8 8 0 002.343 2.343zM6.03 4.97a.75.75 0 00-1.06 1.06L6.94 8 4.97 9.97a.75.75 0 101.06 1.06L8 9.06l1.97 1.97a.75.75 0 101.06-1.06L9.06 8l1.97-1.97a.75.75 0 10-1.06-1.06L8 6.94 6.03 4.97z"></path>
+</svg>

+ 19 - 3
webui/src/components/base/text-input.vue

@@ -6,7 +6,9 @@
              :placeholder="placeholder"
              :class="{error: error}"
              v-bind:value="value"
-             v-on:input="input">
+             v-on:input="input"
+             v-on:change="change"
+             v-on:keypress="keypress">
     </label>
   </div>
 </template>
@@ -16,9 +18,23 @@ export default {
   name: "text-input",
   props: ['value', 'placeholder', 'error'],
   methods: {
-    input: function(event) {
+    clear: function () {
       this.$emit('clearError', null);
-      this.$emit('input', event.target.value)
+    },
+    input: function (event) {
+      this.clear();
+      this.$emit('input', event.target.value);
+    },
+    change: function (event) {
+      this.clear();
+      this.$emit('change', event.target.value);
+    },
+    keypress: function (event) {
+      if (event.keyCode !== 13 && event.keyCode !== 10)
+        return;
+
+      this.clear();
+      this.$emit('enter', event.target.value)
     }
   }
 }

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

@@ -17,6 +17,7 @@
                     :position="result"
                     :socket="socket"
                     :box-url="mediaUrl + '/' + result.id"
+                    :labels="project.labels"
                     @move="move"
                     @resize="resize"/>
   </div>

+ 74 - 4
webui/src/components/media/annotation-box.vue

@@ -7,11 +7,35 @@
 
     <div class="buttons">
       <template v-if="!immutable">
-        <img v-if="confirmationButton" alt="confirm" src="@/assets/icons/check.svg" @click="confirmSelf">
-        <img alt="delete" src="@/assets/icons/trash.svg" @click="deleteSelf">
+        <div v-if="position.label" class="label-text">{{ currentLabel }}</div>
+
+        <img v-if="confirmationButton"
+             alt="confirm" src="@/assets/icons/check.svg"
+             @click.stop.prevent="confirmSelf"
+             @touchstart.stop.prevent="confirmSelf">
+        <img v-if="labelButton"
+             alt="label" src="@/assets/icons/tag.svg"
+             @click.stop.prevent="showLabelSelection = true"
+             @touchstart.stop.prevent="showLabelSelection = true">
+        <img alt="delete" src="@/assets/icons/trash.svg"
+             @click.stop.prevent="deleteSelf"
+             @touchstart.stop.prevent="deleteSelf">
       </template>
     </div>
 
+    <select v-if="!immutable && showLabelSelection"
+            @touchstart.stop @mousedown.stop
+            @change="labelSelf"
+            @focusout="showLabelSelection = false">
+      <option value="">None</option>
+
+      <option v-for="label in labelList"
+              :key="label.id"
+              :value="label.id">
+        {{ label.name }}
+      </option>
+    </select>
+
     <div class="ee" @mousedown.stop.prevent="resizeSelf('ee')" @touchstart.stop.prevent="resizeSelf('ee')"></div>
     <div class="sw" @mousedown.stop.prevent="resizeSelf('sw')" @touchstart.stop.prevent="resizeSelf('sw')"></div>
     <div class="ss" @mousedown.stop.prevent="resizeSelf('ss')" @touchstart.stop.prevent="resizeSelf('ss')"></div>
@@ -22,7 +46,12 @@
 <script>
 export default {
   name: "annotation-box",
-  props: ['type', 'image', 'position', 'socket', 'boxUrl'],
+  props: ['type', 'image', 'position', 'socket', 'boxUrl', 'labels'],
+  data: function () {
+    return {
+      showLabelSelection: false
+    }
+  },
   computed: {
     immutable: function () {
       return !this.type;
@@ -40,6 +69,15 @@ export default {
     confirmationButton: function () {
       return this.type === 'pipeline';
     },
+    labelButton: function () {
+      return this.type !== 'pipeline';
+    },
+    labelList: function () {
+      return Object.keys(this.labels).map(key => this.labels[key]);
+    },
+    currentLabel: function () {
+      return this.labels[this.position.label].name;
+    },
     style: function () {
       const left = this.image.left;
       const top = this.image.top;
@@ -59,8 +97,21 @@ export default {
     confirmSelf: function () {
       this.updateSelf(this.position);
     },
+    labelSelf: function (event) {
+      const options = event.target.options;
+      const option = options[options.selectedIndex];
+      const value = option.value;
+
+      if (value)
+        this.position.label = value;
+      else
+        delete this.position.label;
+
+      this.updateSelf(this.position);
+      this.showLabelSelection = false;
+    },
     deleteSelf: function () {
-      this.socket.post(this.boxUrl, {delete: true, id: this.id});
+      this.socket.post(this.boxUrl, {delete: true});
     },
     moveSelf: function (event) {
       this.$emit('move', event, this.position, this.updateSelf);
@@ -69,6 +120,9 @@ export default {
       this.$emit('resize', this.position, mode, this.updateSelf);
     },
     updateSelf: function (value) {
+      if ('label' in this.position && !('label' in value))
+        value.label = this.position.label;
+
       this.socket.post(this.boxUrl, value);
     }
   }
@@ -88,6 +142,7 @@ export default {
   display: flex;
   justify-content: center;
   align-items: center;
+  position: relative;
 }
 
 img {
@@ -111,4 +166,19 @@ img {
 .nw, .se {
   cursor: nwse-resize;
 }
+
+select {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.label-text {
+  position: absolute;
+  bottom: 0;
+  color: whitesmoke;
+  font-size: 80%;
+}
 </style>

+ 117 - 0
webui/src/components/projects/project-labels-window.vue

@@ -0,0 +1,117 @@
+<template>
+  <div class="project-labels-window">
+    <div class="label" v-for="label in labels" :key="label.id">
+      <h2>
+        <img alt="delete" src="@/assets/icons/x-circle.svg" @click="removeLabel(label.id)">
+        {{ label.name }}
+      </h2>
+
+      <media-selector v-if="labeledImages[label.id].length > 0"
+                      :project-id="currentProject.id"
+                      :media="labeledImages[label.id]"
+                      current="-1"
+                      :socket="socket"></media-selector>
+      <div v-else
+           class="no-elements">
+        No labeled images found...
+      </div>
+    </div>
+
+    <div class="label">
+      <text-input placeholder="New Label"
+                  v-model="createLabelValue"
+                  @change="createLabel"
+                  @enter="createLabel"/>
+    </div>
+  </div>
+</template>
+
+<script>
+import TextInput from "@/components/base/text-input";
+import MediaSelector from "@/components/media/media-selector";
+
+export default {
+  name: "project-labels-window",
+  components: {MediaSelector, TextInput},
+  props: ['currentProject', 'socket'],
+  data: function () {
+    return {
+      createLabelValue: ''
+    }
+  },
+  computed: {
+    labels: function () {
+      let labels = Object.keys(this.currentProject.labels)
+          .map(e => this.currentProject.labels[e]);
+      labels.sort((a, b) => a.name < b.name ? -1 : +1);
+
+      return labels;
+    },
+    labeledImages: function () {
+      const result = {};
+
+      for (let label of this.labels)
+        result[label.id] = [];
+
+      for (let image of Object.values(this.currentProject.data)) {
+        for (let prediction of Object.values(image['predictionResults'])) {
+          if ('label' in prediction && !result[prediction.label].includes(image)) {
+            result[prediction.label].push(image);
+          }
+        }
+      }
+
+      return result;
+    }
+  },
+  methods: {
+    createLabel: function (value) {
+      if (!this.createLabelValue)
+        return;
+
+      this.socket.post('/projects/' + this.currentProject.id + '/labels', {name: value});
+      this.createLabelValue = '';
+    },
+    removeLabel: function (id) {
+      this.socket.post('/projects/' + this.currentProject.id + '/labels/' + id, {delete: true});
+    }
+  }
+}
+</script>
+
+<style scoped>
+.project-labels-window {
+  padding: 1rem;
+}
+
+.label {
+  margin-bottom: 1rem;
+}
+
+h2 {
+  display: flex;
+  align-items: center;
+  margin: 0;
+}
+
+img {
+  width: 1.5rem;
+  margin-right: 0.75rem;
+}
+
+/deep/ .element {
+  border: none;
+}
+
+/deep/ .element:not(:first-child) {
+  margin-left: 0.1rem;
+}
+
+/deep/ .element:not(:last-child) {
+  margin-right: 0.1rem;
+}
+
+.no-elements {
+  margin-top: 0.5rem;
+}
+</style>

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

@@ -127,7 +127,7 @@ h2 {
   cursor: pointer;
 }
 
-.description {
+h2, .description {
   text-overflow: ellipsis;
   white-space: nowrap;
   overflow: hidden;

+ 7 - 0
webui/src/components/window/side-navigation-bar.vue

@@ -28,6 +28,13 @@
         <span>Add Data</span>
       </div>
 
+      <div class="item"
+           :class="{active: window.content === 'labels', inactive: !currentProject}"
+           @click="ifProjectIsOpened(show, 'labels')">
+        <img src="@/assets/icons/tag.svg">
+        <span>Labels</span>
+      </div>
+
       <div class="item"
            :class="{active: window.content === 'view_data', inactive: !currentProject || !mediaAvailable}"
            @click="ifProjectIsOpened(show, 'view_data')">