浏览代码

implemented first version of the crop view. Moved image operations to a separate utils module

Dimitri Korsch 3 年之前
父节点
当前提交
05fab88a78

+ 16 - 0
.editorconfig

@@ -0,0 +1,16 @@
+# editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.{py,sh}]
+indent_style = space
+indent_size = 4
+
+[*.{js,vue}]
+indent_style = space
+indent_size = 2

+ 11 - 1
pycs/database/File.py

@@ -1,6 +1,8 @@
 from contextlib import closing
+import os
 from json import dumps
-from typing import List, Optional
+from typing import List
+from typing import Optional
 
 from pycs.database.Result import Result
 
@@ -26,6 +28,14 @@ class File:
         self.frames = row[10]
         self.fps = row[11]
 
+
+    @property
+    def absolute_path(self):
+        if os.path.isabs(self.path):
+            return self.path
+
+        return os.path.join(os.getcwd(), self.path)
+
     def project(self):
         """
         get the project associated with this file

+ 9 - 0
pycs/database/Result.py

@@ -18,6 +18,15 @@ class Result:
         self.label = row[4]
         self.data = loads(row[5]) if row[5] is not None else None
 
+    def file(self):
+        """
+        getter for the according file
+
+        :return: file object
+        """
+
+        return self.database.file(self.file_id)
+
     def remove(self):
         """
         remove this result from the database

+ 13 - 0
pycs/frontend/WebServer.py

@@ -38,6 +38,7 @@ from pycs.frontend.endpoints.projects.ListFiles import ListFiles
 from pycs.frontend.endpoints.projects.RemoveProject import RemoveProject
 from pycs.frontend.endpoints.results.ConfirmResult import ConfirmResult
 from pycs.frontend.endpoints.results.CreateResult import CreateResult
+from pycs.frontend.endpoints.results.ResultAsCrop import ResultAsCrop
 from pycs.frontend.endpoints.results.EditResultData import EditResultData
 from pycs.frontend.endpoints.results.EditResultLabel import EditResultLabel
 from pycs.frontend.endpoints.results.GetProjectResults import GetProjectResults
@@ -234,6 +235,18 @@ class WebServer:
             '/results/<int:result_id>/confirm',
             view_func=ConfirmResult.as_view('confirm_result', database, notifications)
         )
+        self.__flask.add_url_rule(
+            '/results/<int:result_id>/crop',
+            view_func=ResultAsCrop.as_view('crop_result', database)
+        )
+        self.__flask.add_url_rule(
+            '/results/<int:result_id>/crop/<int:max_width>',
+            view_func=ResultAsCrop.as_view('crop_result_resized_by_width', database)
+        )
+        self.__flask.add_url_rule(
+            '/results/<int:result_id>/crop/<int:max_width>x<int:max_height>',
+            view_func=ResultAsCrop.as_view('crop_result_resized', database)
+        )
         self.__flask.add_url_rule(
             '/results/<int:result_id>/label',
             view_func=EditResultLabel.as_view('edit_result_label', database, notifications)

+ 10 - 102
pycs/frontend/endpoints/data/GetResizedFile.py

@@ -1,13 +1,15 @@
+import cv2
+import os
 import re
-from os import path, getcwd
 
-import cv2
 from PIL import Image
 from eventlet import tpool
-from flask import abort, send_from_directory
+from flask import abort
+from flask import send_from_directory
 from flask.views import View
 
 from pycs.database.Database import Database
+from pycs.util import file_ops
 
 
 class GetResizedFile(View):
@@ -29,109 +31,15 @@ class GetResizedFile(View):
 
         project = file.project()
 
+        if not os.path.exists(file.absolute_path):
+            abort(404, "File not found!")
+
         # extract desired resolution
         resolution = re.split(r'[^0-9]', resolution)
         max_width = int(resolution[0])
         max_height = int(resolution[1]) if len(resolution) > 1 else 2 ** 24
 
         # send data
-        file_directory, file_name = tpool.execute(self.resize_file,
-                                                  project, file, max_width, max_height)
+        file_directory, file_name = tpool.execute(file_ops.resize_file,
+                                                  file, project.root_folder, max_width, max_height)
         return send_from_directory(file_directory, file_name)
-
-    @staticmethod
-    def resize_file(project, file, max_width, max_height):
-        """
-        If file type equals video this function extracts a thumbnail first. It calls resize_image
-        to resize and returns the resized files directory and name.
-
-        :param project: associated project
-        :param file: file object
-        :param max_width: maximum image or thumbnail width
-        :param max_height: maximum image or thumbnail height
-        :return: resized file directory, resized file name
-        """
-        # get absolute path
-        if path.isabs(file.path):
-            abs_file_path = file.path
-        else:
-            abs_file_path = path.join(getcwd(), file.path)
-
-        # extract video thumbnail
-        if file.type == 'video':
-            abs_target_path = path.join(getcwd(), project.root_folder, 'temp', f'{file.uuid}.jpg')
-            GetResizedFile.create_thumbnail(abs_file_path, abs_target_path)
-
-            abs_file_path = abs_target_path
-
-        # resize image file
-        abs_target_path = path.join(getcwd(), project.root_folder,
-                                    'temp', f'{file.uuid}_{max_width}_{max_height}.jpg')
-        result = GetResizedFile.resize_image(abs_file_path, abs_target_path, max_width, max_height)
-
-        # return path
-        if result is not None:
-            return path.split(abs_target_path)
-
-        return path.split(abs_file_path)
-
-    @staticmethod
-    def resize_image(file_path, target_path, max_width, max_height):
-        """
-        resize an image so width < max_width and height < max_height
-
-        :param file_path: path to source file
-        :param target_path: path to target file
-        :param max_width: maximum image width
-        :param max_height: maximum image height
-        :return:
-        """
-        # return if file exists
-        if path.exists(target_path):
-            return True
-
-        # load full size image
-        image = Image.open(file_path)
-        img_width, img_height = image.size
-
-        # abort if file is smaller than desired
-        if img_width < max_width and img_height < max_height:
-            return None
-
-        # calculate target size
-        target_width = int(max_width)
-        target_height = int(max_width * img_height / img_width)
-
-        if target_height > max_height:
-            target_height = int(max_height)
-            target_width = int(max_height * img_width / img_height)
-
-        # resize image
-        resized_image = image.resize((target_width, target_height))
-
-        # save to file
-        resized_image.save(target_path, quality=80)
-        return True
-
-    @staticmethod
-    def create_thumbnail(file_path, target_path):
-        """
-        extract a thumbnail from a video
-
-        :param file_path: path to source file
-        :param target_path: path to target file
-        :return:
-        """
-        # return if file exists
-        if path.exists(target_path):
-            return
-
-        # load video
-        video = cv2.VideoCapture(file_path)
-
-        # create thumbnail
-        _, image = video.read()
-        cv2.imwrite(target_path, image)
-
-        # close video file
-        video.release()

+ 69 - 0
pycs/frontend/endpoints/results/ResultAsCrop.py

@@ -0,0 +1,69 @@
+import os
+
+from flask import abort
+from flask import send_from_directory
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.util import file_ops
+
+
+class ResultAsCrop(View):
+    """
+    return the image crop defined by the result.
+    """
+    # pylint: disable=arguments-differ
+    methods = ['GET']
+
+    def __init__(self, db: Database):
+        # pylint: disable=invalid-name
+        self.db = db
+
+
+    def dispatch_request(self, result_id: int, max_width: int = 2**24, max_height: int = 2**24):
+
+        # find result
+        result = self.db.result(result_id)
+
+        if result is None:
+            abort(404)
+
+        if result.type != "bounding-box":
+            abort(400, f"The type of the queried result was not \"bounding-box\"! It was {result.type}")
+
+        file = result.file()
+
+        if file.type != "image":
+            abort(400, f"Currently only supporting images!")
+
+
+        data = result.data
+
+        if data is None:
+            abort(400, "The data of the result was None!")
+
+        xywh = [data.get(attr, -1) for attr in "xywh"]
+        if -1 in xywh:
+            abort(400, f"The data of the result is not correct: {data}!")
+
+        x, y, w, h = xywh
+
+        project = file.project()
+
+        crop_path, crop_fname = file_ops.crop_file(file, project.root_folder, x, y, w, h)
+
+        parts = os.path.splitext(crop_fname)
+
+        crop_new_fname = f"{parts[0]}_{max_width}_{max_height}.{parts[1]}"
+
+        resized = file_ops.resize_image(
+            os.path.join(crop_path, crop_fname),
+            os.path.join(crop_path, crop_new_fname),
+            max_width,
+            max_height
+        )
+
+        if resized:
+            crop_fname = crop_new_fname
+
+        return send_from_directory(crop_path, crop_fname)

+ 4 - 6
pycs/interfaces/MediaFile.py

@@ -1,5 +1,6 @@
-from os import path, getcwd
-from typing import Optional, List, Union
+from typing import List
+from typing import Optional
+from typing import Union
 
 from pycs.database.File import File
 from pycs.database.Result import Result
@@ -23,10 +24,7 @@ class MediaFile:
         self.frames = file.frames
         self.fps = file.fps
 
-        if path.isabs(file.path):
-            self.path = file.path
-        else:
-            self.path = path.join(getcwd(), file.path)
+        self.path = file.absolute_path
 
     def set_collection(self, reference: Optional[str]):
         """

+ 137 - 0
pycs/util/file_ops.py

@@ -0,0 +1,137 @@
+import cv2
+import os
+
+from PIL import Image
+from typing import Tuple
+from typing import Optional
+
+from pycs.database.File import File
+
+def crop_file(file: File, project_root: str, x: float, y: float, w: float, h: float) -> str:
+    """
+    gets a file for the given file_id, crops the according image to the
+    bounding box and saves the crops in the temp folder of the project.
+
+    :param file: file object
+    :param x: relative x-coordinate of the top left corner
+    :param y: relative y-coordinate of the top left corner
+    :param w: relative width of the bounding box
+    :param h: relative height of the bounding box
+
+    :return: directory and file name of the cropped patch
+    """
+
+    image = Image.open(file.absolute_path)
+    width, height = image.size
+
+    crop_width = int(width * w)
+    crop_height = int(height * h)
+
+    x0 = int(width * x)
+    y0 = int(height * y)
+    x1 = x0 + crop_width
+    y1 = y0 + crop_height
+
+    target_path = os.path.join(
+        os.getcwd(),
+        project_root,
+        'temp',
+        f'{file.uuid}_{x0}-{y0}_{x1}-{y1}.jpg',
+    )
+
+    if not os.path.exists(target_path):
+        crop = image.crop((x0, y0, x0+crop_width, y0+crop_height))
+        crop.save(target_path, quality=80)
+
+    return os.path.split(target_path)
+
+
+def resize_file(file: File, project_root: str, max_width: int, max_height: int) -> Tuple[str, str]:
+    """
+    If file type equals video this function extracts a thumbnail first. It calls resize_image
+    to resize and returns the resized files directory and name.
+
+    :param file: file object
+    :param max_width: maximum image or thumbnail width
+    :param max_height: maximum image or thumbnail height
+    :return: resized file directory, resized file name
+    """
+
+    abs_file_path = file.absolute_path
+
+    # extract video thumbnail
+    if file.type == 'video':
+        abs_target_path = os.path.join(os.getcwd(), project_root, 'temp', f'{file.uuid}.jpg')
+        create_thumbnail(abs_file_path, abs_target_path)
+
+        abs_file_path = abs_target_path
+
+    # resize image file
+    abs_target_path = os.path.join(os.getcwd(), project_root,
+                                'temp', f'{file.uuid}_{max_width}_{max_height}.jpg')
+    result = resize_image(abs_file_path, abs_target_path, max_width, max_height)
+
+    # return path
+    if result is not None:
+        return os.path.split(abs_target_path)
+
+    return os.path.split(abs_file_path)
+
+def resize_image(file_path: str, target_path: str, max_width: int, max_height: int) -> Optional[bool]:
+    """
+    resize an image so width < max_width and height < max_height
+
+    :param file_path: path to source file
+    :param target_path: path to target file
+    :param max_width: maximum image width
+    :param max_height: maximum image height
+    :return:
+    """
+    # return if file exists
+    if os.path.exists(target_path):
+        return True
+
+    # load full size image
+    image = Image.open(file_path)
+    img_width, img_height = image.size
+
+    # abort if file is smaller than desired
+    if img_width < max_width and img_height < max_height:
+        return None
+
+    # calculate target size
+    target_width = int(max_width)
+    target_height = int(max_width * img_height / img_width)
+
+    if target_height > max_height:
+        target_height = int(max_height)
+        target_width = int(max_height * img_width / img_height)
+
+    # resize image
+    resized_image = image.resize((target_width, target_height))
+
+    # save to file
+    resized_image.save(target_path, quality=80)
+    return True
+
+def create_thumbnail(file_path: str, target_path: str) -> None:
+    """
+    extract a thumbnail from a video
+
+    :param file_path: path to source file
+    :param target_path: path to target file
+    :return:
+    """
+    # return if file exists
+    if os.path.exists(target_path):
+        return
+
+    # load video
+    video = cv2.VideoCapture(file_path)
+
+    # create thumbnail
+    _, image = video.read()
+    cv2.imwrite(target_path, image)
+
+    # close video file
+    video.release()

+ 8 - 0
webui/src/App.vue

@@ -148,3 +148,11 @@ export default {
   border-bottom: 1px solid rgba(0, 0, 0, 0.2);
 }
 </style>
+
+<style>
+
+.btn {
+  cursor: pointer;
+}
+
+</style>

+ 9 - 0
webui/src/assets/icons/cross.svg

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns="http://www.w3.org/2000/svg"
+   viewBox="0 0 16 16"
+   width="16"
+   height="16">
+  <path
+     d="M 13.675781 1.0898438 C 13.486976 1.0809964 13.287965 1.1509288 13.111328 1.3339844 L 7.7519531 6.6914062 L 2.390625 1.3300781 C 1.6712043 0.61050136 0.59785585 1.684079 1.3300781 2.390625 L 6.6914062 7.7519531 L 1.3320312 13.111328 C 0.60047528 13.818564 1.6860321 14.904097 2.3925781 14.171875 L 7.7519531 8.8125 L 13.109375 14.169922 C 13.816611 14.901478 14.902144 13.815921 14.169922 13.109375 L 8.8125 7.7519531 L 14.171875 2.3945312 C 14.711557 1.8549657 14.242196 1.1163858 13.675781 1.0898438 z "/>
+</svg>

+ 2 - 0
webui/src/assets/style/main.css

@@ -1,5 +1,7 @@
 * {
     box-sizing: border-box;
+    margin: 0;
+    padding: 0;
 }
 
 html, body {

+ 38 - 2
webui/src/components/media/annotated-image.vue

@@ -9,6 +9,7 @@
                    :label="label"
                    @label="label = $event"
                    :labels="labels"
+                   @infoBox="toggleInfoBox()"
                    :zoomBox="zoomBox"
                    @unzoom="zoomBox=false; interaction=false"
                    @prevzoom="$refs.overlay.prevZoom()"
@@ -47,9 +48,17 @@
                             :video="video"
                             :results="results"
                             :labels="labels"
+                            @crop="selectCrop($event)"
                             :zoom="zoomBox"
                             @zoom="zoomBox = $event"/>
       </div>
+
+      <cropped-image ref="info"
+                     :width="300"
+                     :margin="10"
+                     :labels="labels"
+                     @closed="infoBoxClosed"
+                     />
     </template>
   </div>
 </template>
@@ -58,15 +67,16 @@
 import AnnotationOverlay from "@/components/media/annotation-overlay";
 import VideoControl from "@/components/media/video-control";
 import OptionsBar from "@/components/media/options-bar";
+import CroppedImage from "@/components/media/cropped-image";
 
 export default {
   name: "annotated-image",
-  components: {OptionsBar, VideoControl, AnnotationOverlay},
+  components: {OptionsBar, VideoControl, AnnotationOverlay, CroppedImage},
   props: ['current'],
   mounted: function () {
     // add resize listener
     window.addEventListener('resize', this.resize);
-    this.interval = setInterval(this.resize, 1000);
+    this.interval = setInterval(this.resize, 100);
     this.resize();
 
     // add result listener
@@ -123,6 +133,7 @@ export default {
         frame: 0,
         play: false
       },
+      selectedCrop: null,
       zoomBox: false,
       supported: {
         labeledImages: false,
@@ -173,6 +184,27 @@ export default {
     }
   },
   methods: {
+
+    toggleInfoBox: function() {
+      let infoBox = this.$refs.info;
+      infoBox.toggle();
+      if (infoBox.visible)
+        this.interaction = "info-box";
+      else {
+        this.interaction = false;
+        this.$refs.overlay.deselectAllBoxes()
+      }
+    },
+
+    selectCrop: function(event) {
+        this.$refs.info.show(event);
+    },
+
+    infoBoxClosed: function() {
+      this.interaction = false;
+      this.$refs.overlay.deselectAllBoxes()
+    },
+
     resize: function () {
       const rect = this.$refs.root.getBoundingClientRect();
 
@@ -296,6 +328,10 @@ export default {
             .then(results => {
               this.results = results;
             })
+        let infoBox = this.$refs.info;
+        if (infoBox == undefined)
+          return;
+        infoBox.box = null;
       }
     }
   }

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="annotation-box"
+  <div class="annotation-box btn"
        :style="style"
        :class="{draggable: draggable, shine: shine}"
        @mousedown.prevent="click" @touchstart.prevent="click">
@@ -23,7 +23,23 @@
 <script>
 export default {
   name: "annotation-box",
-  props: ['box', 'position', 'draggable', 'deletable', 'taggable', 'confirmable', 'zoomable', 'labels', 'shine'],
+  props: [
+    'box',
+    'position',
+    'draggable',
+    'deletable',
+    'taggable',
+    'confirmable',
+    'zoomable',
+    'croppable',
+    'labels',
+    'shine'
+  ],
+  data: function() {
+    return {
+      selected: false
+    }
+  },
   computed: {
     labelName: function () {
       if (!this.box || !this.box.label)
@@ -50,9 +66,16 @@ export default {
         }
       }
 
+      let bgAlpha = 0.6;
+      let borderAlpha = 0.8;
+      if (!this.selected) {
+        bgAlpha *= 0.5;
+        borderAlpha *= 0.5;
+      }
+
       return {
-        backgroundColor: `rgba(${color}, 0.3)`,
-        borderColor: `rgba(${color}, 0.4)`,
+        backgroundColor: `rgba(${color}, ${bgAlpha})`,
+        borderColor: `rgba(${color}, ${borderAlpha})`,
         top: this.position.y * 100 + '%',
         left: this.position.x * 100 + '%',
         width: this.position.w * 100 + '%',
@@ -90,6 +113,11 @@ export default {
         this.$emit('zoom', this.position);
         event.stopPropagation();
       }
+      if (this.croppable) {
+        this.$emit('crop', this);
+        this.selected = true;
+        event.stopPropagation();
+      }
     },
     resize: function (mode) {
       this.$emit('resize', mode, this.position, this.update);

+ 29 - 1
webui/src/components/media/annotation-overlay.vue

@@ -6,6 +6,7 @@
 
     <annotation-box v-for="(box, index) in boundingBoxes"
                     v-bind:key="box.identifier"
+                    ref="box"
                     :box="box"
                     :position="box.data"
                     :draggable="interaction === 'move-box'"
@@ -13,10 +14,12 @@
                     :taggable="interaction === 'label-box' ? label : false"
                     :confirmable="interaction === 'confirm-box'"
                     :zoomable="interaction === 'zoom-box'"
+                    :croppable="interaction === 'info-box'"
                     :labels="labels"
                     :shine="zoom"
                     @move="move"
                     @resize="resize"
+                    @crop="crop($event)"
                     @zoom="zoomBox(index, $event)"/>
 
     <annotation-box v-if="current"
@@ -39,7 +42,18 @@ import AnnotationBox from "@/components/media/annotation-box";
 export default {
   name: "annotation-overlay",
   components: {AnnotationBox},
-  props: ['file', 'position', 'size', 'interaction', 'filter', 'label', 'video', 'results', 'labels', 'zoom'],
+  props: [
+    'file',
+    'position',
+    'size',
+    'interaction',
+    'filter',
+    'label',
+    'video',
+    'results',
+    'labels',
+    'zoom'
+  ],
   data: function () {
     return {
       callback: false,
@@ -255,6 +269,20 @@ export default {
           break;
       }
     },
+    crop: function (box) {
+      this.deselectAllBoxes();
+      if (box == undefined)
+        return;
+
+      this.$emit('crop', box);
+    },
+
+    deselectAllBoxes: function(){
+      this.$refs.box.forEach( child => {
+        child.selected = false;
+      })
+    },
+
     zoomBox: function (index) {
       if (this.boundingBoxes.length === 0) {
         this.zoomed = false;

+ 125 - 0
webui/src/components/media/cropped-image.vue

@@ -0,0 +1,125 @@
+<template>
+    <div v-if="visible" class="cropped-image">
+      <div id="close_btn" class="btn"
+        @mousedown.prevent="close"
+        @touchstart.prevent="close">
+        <img alt="close button" src="@/assets/icons/cross.svg">
+      </div>
+      <div class="image-container" :style="style">
+        <div v-if="box">
+          <h3>{{labelName}}</h3>
+          <img :src="src" ref="crop" alt="crop" />
+        </div>
+        <div v-else>Select a crop to show</div>
+      </div>
+    </div>
+
+
+</template>
+
+<script>
+    export default {
+        name: "cropped-image",
+        props: ["width", "margin", "labels"],
+
+
+        // mounted: function() {}
+        // destroyed: function() {}
+
+        data: function() {
+            return {
+                visible: false,
+                box: null,
+            }
+        },
+
+        computed: {
+          src: function () {
+            if (!this.box)
+              return ''
+            var width = this.width - 2 * this.margin /*padding*/;
+            return this.$root.socket.url(`/results/${this.box.identifier}/crop/${width}x${width}`)
+          },
+
+          style: function() {
+
+            return {
+              minWidth: `${this.width}px`,
+              margin: `${this.margin}px`,
+            }
+
+          },
+          labelName: function() {
+            if (!this.box)
+              return ''
+
+            if (this.box.label == null)
+              return 'Unknown'
+
+            let name = 'Not Found';
+            for (let label of this.labels){
+              if (label.identifier != this.box.label)
+                continue;
+
+              name = label.name;
+              break;
+            }
+            return name
+          },
+
+        },
+
+        methods: {
+            toggle: function () {
+              this.set_visible(!this.visible);
+            },
+
+            set_visible: function (visible) {
+              this.visible = visible;
+
+              if (!visible)
+                this.box = null;
+
+              this.$emit("visibility-change", visible);
+            },
+
+            show: function (box_element) {
+              this.box = box_element.box;
+            },
+
+            close: function() {
+              this.set_visible(false);
+              this.$emit("closed");
+            }
+        },
+    }
+</script>
+
+<style scoped>
+
+.cropped-image {
+  height: 100%;
+
+  background-color: rgba(133, 133, 133, 0.7);
+}
+
+.image-container {
+  /*align-items: center;*/
+  justify-content: center;
+  text-align: center;
+  display: flex;
+}
+
+.image-container img {
+  border-width: 2px;
+  border-style: solid;
+}
+
+#close_btn {
+  justify-content: right;
+  text-align: right;
+
+  margin:  5px;
+}
+
+</style>

+ 8 - 0
webui/src/components/media/options-bar.vue

@@ -61,6 +61,14 @@
 
     <div class="spacer"/>
 
+    <div ref="crop_info"
+         class="image"
+         title="Show info of a bounding box (I)"
+         :class="{active: interaction === 'info-box'}"
+         @click="$emit('infoBox')">
+      <img alt="zoom bounding box" src="@/assets/icons/info.svg">
+    </div>
+
     <div ref="zoom_annotation"
          class="image"
          title="zoom bounding box (X)"