Browse Source

Merge branch '40-show-images-with-predictions' into 'master'

Resolve "show images with predictions"

Closes #40

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

+ 20 - 7
webui/src/App.vue

@@ -29,13 +29,17 @@
                                  :socket="socket"
                                  v-on:close="closeProject"/>
 
-        <project-data-window v-if="window.content === 'add_data'"
-                             :current-project="currentProject"
-                             :status="status"
-                             :socket="socket"/>
+        <project-data-add-window v-if="window.content === 'add_data'"
+                                 :current-project="currentProject"
+                                 :status="status"
+                                 :socket="socket"/>
+
+        <project-data-view-window v-if="window.content === 'view_data'"
+                                  :current-project="currentProject"
+                                  :status="status"
+                                  :socket="socket"/>
 
         <about-window v-if="window.content === 'about'"/>
-        <!-- </template> -->
       </div>
     </div>
   </div>
@@ -48,12 +52,14 @@ import TopNavigationBar from "@/components/window/top-navigation-bar";
 import SideNavigationBar from "@/components/window/side-navigation-bar";
 import ProjectSettingsWindow from "@/components/projects/project-settings-window";
 import AboutWindow from "@/components/other/about-window";
-import ProjectDataWindow from "@/components/projects/project-data-window";
+import ProjectDataAddWindow from "@/components/projects/project-data-add-window";
+import ProjectDataViewWindow from "@/components/projects/project-data-view-window";
 
 export default {
   name: 'App',
   components: {
-    ProjectDataWindow,
+    ProjectDataAddWindow,
+    ProjectDataViewWindow,
     AboutWindow,
     ProjectSettingsWindow,
     SideNavigationBar,
@@ -81,6 +87,9 @@ export default {
             method: 'POST',
             body: form
           });
+        },
+        media: function(projectId, fileId) {
+          return this.baseurl + '/projects/' + projectId + '/data/' + fileId;
         }
       },
       status: null,
@@ -115,6 +124,10 @@ export default {
       this.window.project = project.id;
       this.show('settings');
     },
+    closeProject: function() {
+      this.window.project = null;
+      this.show('projects');
+    }
   },
   created: function() {
     this.socket.io.on('app_status', status => {

+ 4 - 0
webui/src/assets/icons/file-media.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
+  <path fill-rule="evenodd"
+        d="M2.25 4a.25.25 0 00-.25.25v15.5c0 .138.112.25.25.25h3.178L14 10.977a1.75 1.75 0 012.506-.032L22 16.44V4.25a.25.25 0 00-.25-.25H2.25zm3.496 17.5H21.75a1.75 1.75 0 001.75-1.75V4.25a1.75 1.75 0 00-1.75-1.75H2.25A1.75 1.75 0 00.5 4.25v15.5c0 .966.784 1.75 1.75 1.75h3.496zM22 19.75v-1.19l-6.555-6.554a.25.25 0 00-.358.004L7.497 20H21.75a.25.25 0 00.25-.25zM9 9.25a1.75 1.75 0 11-3.5 0 1.75 1.75 0 013.5 0zm1.5 0a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0z"></path>
+</svg>

+ 3 - 0
webui/src/assets/icons/triangle-left.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
+  <path d="M8.854 11.646l5.792-5.792a.5.5 0 01.854.353v11.586a.5.5 0 01-.854.353l-5.792-5.792a.5.5 0 010-.708z"></path>
+</svg>

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

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
+  <path
+    d="M15.146 12.354l-5.792 5.792a.5.5 0 01-.854-.353V6.207a.5.5 0 01.854-.353l5.792 5.792a.5.5 0 010 .708z"></path>
+</svg>

+ 7 - 1
webui/src/components/base/button-input.vue

@@ -3,7 +3,8 @@
           :class="{
             error: type === 'error',
             primary: type === 'primary',
-            secondary: type === 'secondary'
+            secondary: type === 'secondary',
+            transparent: type === 'transparent'
           }">
     <slot/>
   </button>
@@ -37,4 +38,9 @@ button {
 .secondary {
   background-color: var(--secondary);
 }
+
+.transparent {
+  background-color: transparent;
+  border: none;
+}
 </style>

+ 24 - 0
webui/src/components/base/button-row.vue

@@ -0,0 +1,24 @@
+<template>
+  <div class="button-row">
+    <slot/>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "button-row"
+}
+</script>
+
+<style scoped>
+.button-row >>> :not(:first-child) {
+  border-top-left-radius: 0 !important;
+  border-bottom-left-radius: 0 !important;
+}
+
+.button-row >>> :not(:last-child) {
+  border-top-right-radius: 0 !important;
+  border-bottom-right-radius: 0 !important;
+  border-right: none;
+}
+</style>

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

@@ -0,0 +1,137 @@
+<template>
+  <div class="annotated-image" v-on="events">
+    <img alt="media" :src="mediaUrl" ref="image">
+
+    <div style="position: absolute; top: 0.5rem; left: 0.5rem">{{ data }}</div>
+
+    <annotation-box v-if="current"
+                    :image="image"
+                    :position="current"/>
+
+    <annotation-box v-for="result in data.result"
+                    v-bind:key="result"
+                    :image="image"
+                    :position="result"/>
+  </div>
+</template>
+
+<script>
+import AnnotationBox from "@/components/media/annotation-box";
+
+export default {
+  name: "annotated-image",
+  components: {AnnotationBox},
+  props: ['project', 'data', 'socket'],
+  mounted: function() {
+    window.addEventListener("resize", this.resizeEvent);
+
+    if (this.$refs.image.complete)
+      this.resizeEvent();
+    else
+      this.$refs.image.addEventListener('load', this.resizeEvent);
+  },
+  destroyed() {
+    window.removeEventListener("resize", this.resizeEvent);
+  },
+  watch: {
+    data: function() {
+      this.current = false;
+      this.resizeEvent();
+    }
+  },
+  data: function() {
+    return {
+      image: {
+        left: 0,
+        top: 0,
+        width: 0,
+        height: 0
+      },
+      start: false,
+      current: false
+    }
+  },
+  computed: {
+    mediaUrl: function() {
+      return this.socket.media(this.project.id, this.data.id);
+    },
+    events: function() {
+      return {
+        'touchstart': this.press,
+        'touchmove': this.track,
+        'touchend': this.release,
+        'mousedown': this.press,
+        'drag': this.track,
+        'dragend': this.release,
+      }
+    }
+  },
+  methods: {
+    resizeEvent: function() {
+      const element = this.$refs.image.getBoundingClientRect();
+      const parent = this.$refs.image.parentElement.getBoundingClientRect();
+
+      this.image.left = element.x - parent.x;
+      this.image.top = element.y - parent.y;
+      this.image.width = element.width;
+      this.image.height = element.height;
+    },
+    get: function(event) {
+      if ('clientX' in event)
+        return event;
+      else if ('touches' in event && event.touches.length > 0)
+        return event.touches[0];
+      else if ('changedTouches' in event && event.changedTouches.length > 0)
+        return event.changedTouches[0];
+    },
+    getX: function(event) {
+      const bcr = event.target.getBoundingClientRect();
+      return (this.get(event).clientX - bcr.left) / bcr.width;
+    },
+    getY: function(event) {
+      const bcr = event.target.getBoundingClientRect();
+      return (this.get(event).clientY - bcr.top) / bcr.height;
+    },
+    buildRectangle: function(x1, y1, x2, y2) {
+      const lx = Math.max(Math.min(x1, x2), 0);
+      const hx = Math.min(Math.max(x1, x2), 1);
+      const ly = Math.max(Math.min(y1, y2), 0);
+      const hy = Math.min(Math.max(y1, y2), 1);
+
+      return {
+        x: lx,
+        y: ly,
+        w: hx - lx,
+        h: hy - ly
+      }
+    },
+    press: function(event) {
+      this.start = {
+        x: this.getX(event),
+        y: this.getY(event)
+      };
+    },
+    track: function(event) {
+      const x = this.getX(event);
+      const y = this.getY(event);
+
+      // Chrome 88 fires a drag event with negative coordinates on mouseup.
+      if (x >= 0 && y >= 0)
+        this.current = this.buildRectangle(this.start.x, this.start.y, x, y);
+    },
+    release: function(event) {
+      console.log('release', event);
+      // TODO send to server
+      this.start = false;
+      // this.current = this.buildRectangle(this.start.x, this.start.y, this.getX(event), this.getY(event));
+    }
+  }
+}
+</script>
+
+<style scoped>
+img {
+  max-width: 100%;
+  max-height: 100%;
+}
+</style>

+ 99 - 0
webui/src/components/media/annotated-media-view.vue

@@ -0,0 +1,99 @@
+<template>
+  <div class="annotated-media-view">
+    <div class="media">
+      <annotated-image :data="currentMedia" :project="currentProject" :socket="socket"/>
+    </div>
+
+    <div class="control">
+      <media-control :hasPrevious="hasPrevious" :hasNext="hasNext"
+                     @previous="previous" @next="next"/>
+    </div>
+
+    <div class="selector">
+      <media-selector :project="currentProject"
+                      :current="currentMedia"
+                      :socket="socket"
+                      @click="current = $event"/>
+    </div>
+  </div>
+</template>
+
+<script>
+import MediaSelector from "@/components/media/media-selector";
+import AnnotatedImage from "@/components/media/annotated-image";
+import MediaControl from "@/components/media/media-control";
+
+export default {
+  name: "annotated-media-view",
+  components: {MediaControl, AnnotatedImage, MediaSelector},
+  props: ['currentProject', 'socket'],
+  data: function() {
+    return {
+      current: 0
+    };
+  },
+  computed: {
+    hasPrevious: function() {
+      return this.current > 0;
+    },
+    hasNext: function() {
+      return this.current < this.currentProject.data.length - 1;
+    },
+    currentMedia: function() {
+      if (this.current < this.currentProject.data.length)
+        return this.currentProject.data[this.current];
+      else
+        return false;
+    }
+  },
+  methods: {
+    previous: function() {
+      if (this.hasPrevious)
+        this.current -= 1;
+    },
+    next: function() {
+      if (this.hasNext)
+        this.current += 1;
+    }
+  }
+}
+</script>
+
+<style scoped>
+.annotated-media-view {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.3);
+}
+
+.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>

+ 35 - 0
webui/src/components/media/annotation-box.vue

@@ -0,0 +1,35 @@
+<template>
+  <div class="annotation-box" :style="style">
+    .annotation-box
+  </div>
+</template>
+
+<script>
+export default {
+  name: "annotation-box",
+  props: ['image', 'position'],
+  computed: {
+    style: function() {
+      const left = this.image.left;
+      const top = this.image.top;
+      const width = this.image.width;
+      const height = this.image.height;
+
+      return {
+        left: (left + this.position.x * width) + 'px',
+        top: (top + this.position.y * height) + 'px',
+        width: (this.position.w * width) + 'px',
+        height: (this.position.h * height) + 'px',
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.annotation-box {
+  position: absolute;
+  background-color: rgba(255, 255, 255, 0.3);
+
+}
+</style>

+ 53 - 0
webui/src/components/media/media-control.vue

@@ -0,0 +1,53 @@
+<template>
+  <button-row class="media-control">
+    <button-input type="transparent"
+                  style="color: var(--on_error)"
+                  :class="{disabled: !hasPrevious}"
+                  @click="previous">
+      &lt;
+    </button-input>
+
+    <button-input type="transparent"
+                  style="color: var(--on_error)">
+      Reset
+    </button-input>
+
+    <button-input type="transparent"
+                  style="color: var(--on_error)"
+                  :class="{disabled: !hasNext}"
+                  @click="next">
+      &gt;
+    </button-input>
+  </button-row>
+</template>
+
+<script>
+import ButtonInput from "@/components/base/button-input";
+import ButtonRow from "@/components/base/button-row";
+
+export default {
+  name: "media-control",
+  components: {ButtonRow, ButtonInput},
+  props: ['hasPrevious', 'hasNext'],
+  methods: {
+    previous: function() {
+      if (this.hasPrevious)
+        this.$emit('previous', null);
+    },
+    next: function() {
+      if (this.hasNext)
+        this.$emit('next', null);
+    }
+  }
+}
+</script>
+
+<style scoped>
+.media-control {
+  text-align: center;
+}
+
+.disabled {
+  opacity: 0.4;
+}
+</style>

+ 72 - 0
webui/src/components/media/media-selector.vue

@@ -0,0 +1,72 @@
+<template>
+  <div class="media-selector">
+    <div class="element"
+         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)">
+    </div>
+  </div>
+</template>
+
+<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;
+    }
+  }
+}
+</script>
+
+<style scoped>
+.media-selector {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+  width: fit-content;
+}
+
+.element {
+  width: 5rem;
+  height: 5rem;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+
+  border-left: 2px solid rgba(0, 0, 0, 0.5);
+  border-right: 2px solid rgba(0, 0, 0, 0.5);
+}
+
+.element:not(:last-child) {
+  border-right: none;
+}
+
+/*
+.element:first-child {
+  border-left: 2px solid transparent;
+}
+
+.element:last-child {
+  border-right: 2px solid transparent;
+}
+*/
+
+img {
+  max-width: 100%;
+  max-height: 100%;
+}
+</style>

+ 4 - 3
webui/src/components/projects/project-data-window.vue → webui/src/components/projects/project-data-add-window.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="project-data-window">
+  <div class="project-data-add-window">
     <!-- TODO use valid url -->
     <file-input :socket="socket" :name="'/projects/' + currentProject.id + '/data'"></file-input>
 
@@ -21,11 +21,12 @@ import FileInput from "@/components/base/file-input";
 import ProgressBar from "@/components/base/progress-bar";
 
 export default {
-  name: "project-data-window",
+  name: "project-data-add-window",
   components: {ProgressBar, FileInput},
   props: ['status', 'socket', 'currentProject'],
   computed: {
     uploads: function() {
+      // TODO filter current project
       const filtered = this.status.jobs.filter(x => x.type === 'upload');
       filtered.sort(this.sortUploads);
       return filtered;
@@ -43,7 +44,7 @@ export default {
 </script>
 
 <style scoped>
-.project-data-window {
+.project-data-add-window {
   padding: 1rem;
 }
 

+ 19 - 0
webui/src/components/projects/project-data-view-window.vue

@@ -0,0 +1,19 @@
+<template>
+  <div class="project-data-view-window">
+    <annotated-media-view :currentProject="currentProject" :socket="socket"/>
+  </div>
+</template>
+
+<script>
+import AnnotatedMediaView from "@/components/media/annotated-media-view";
+
+export default {
+  name: "project-data-view-window",
+  components: {AnnotatedMediaView},
+  props: ['status', 'socket', 'currentProject']
+}
+</script>
+
+<style scoped>
+
+</style>

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

@@ -67,18 +67,12 @@ export default {
       this.socket.post(this.path, {
         'name': value
       });
-      this.update();
     },
     descriptionF: function(value) {
       // TODO then / error
       this.socket.post(this.path, {
         'description': value
       });
-      this.update();
-    },
-    update: function() {
-      // TODO is this function actually needed?
-      // this.socket.set(/* this.currentProjectPath + */ '/action', 'update');
     },
     deleteProject: function() {
       // TODO then / error

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

@@ -29,17 +29,17 @@
       </div>
 
       <div class="item"
-           :class="{active: window.content === 'about'}"
-           @click="show('about')">
-        <img src="@/assets/icons/info.svg">
-        <span>About PyCS</span>
+           :class="{active: window.content === 'view_data', inactive: !currentProject}"
+           @click="ifProjectIsOpened(show, 'view_data')">
+        <img src="@/assets/icons/file-media.svg">
+        <span>View Data</span>
       </div>
 
       <div class="item"
-           :class="{inactive: !currentProject}"
-           @click="socket.post('/projects/' + currentProject.id + '/data/' + currentProject.data[0].id, {})">
+           :class="{active: window.content === 'about'}"
+           @click="show('about')">
         <img src="@/assets/icons/info.svg">
-        <span>Run Prediction</span>
+        <span>About PyCS</span>
       </div>
 
       <div v-if="window.wide"
@@ -162,7 +162,7 @@ export default {
 
 .item.inactive img {
   opacity: 0.5;
- }
+}
 
 .item span {
   margin: 1rem 4rem 1rem 0.75rem