Browse Source

Crop view

Dimitri Korsch 3 years ago
parent
commit
5c41f5e131

+ 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

+ 5 - 0
pycs/frontend/WebServer.py

@@ -12,6 +12,7 @@ from pycs.frontend.endpoints.ListLabelProviders import ListLabelProviders
 from pycs.frontend.endpoints.ListModels import ListModels
 from pycs.frontend.endpoints.ListProjects import ListProjects
 from pycs.frontend.endpoints.additional.FolderInformation import FolderInformation
+from pycs.frontend.endpoints.data.GetCroppedFile import GetCroppedFile
 from pycs.frontend.endpoints.data.GetFile import GetFile
 from pycs.frontend.endpoints.data.GetPreviousAndNextFile import GetPreviousAndNextFile
 from pycs.frontend.endpoints.data.GetResizedFile import GetResizedFile
@@ -204,6 +205,10 @@ class WebServer:
             '/data/<int:file_id>/<resolution>',
             view_func=GetResizedFile.as_view('get_resized_file', database)
         )
+        self.__flask.add_url_rule(
+            '/data/<int:file_id>/<resolution>/<crop_box>',
+            view_func=GetCroppedFile.as_view('crop_result', database)
+        )
         self.__flask.add_url_rule(
             '/data/<int:file_id>/previous_next',
             view_func=GetPreviousAndNextFile.as_view('get_previous_and_next_file', database)

+ 52 - 0
pycs/frontend/endpoints/data/GetCroppedFile.py

@@ -0,0 +1,52 @@
+import os
+import re
+
+from eventlet import tpool
+from flask import abort
+from flask import send_from_directory
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.util.FileOperations import crop_file
+
+
+class GetCroppedFile(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, file_id: int, resolution: str, crop_box: str):
+        # get file from database
+        file = self.db.file(file_id)
+        if file is None:
+            return abort(404)
+
+        project = file.project()
+
+        if not os.path.exists(file.absolute_path):
+            abort(404, "File not found!")
+
+        # extract desired crop
+        resolution = re.split(r'[^0-9]', resolution)
+        max_width = int(resolution[0])
+        max_height = int(resolution[1]) if len(resolution) > 1 else 2 ** 24
+
+        crop_box = re.split(r'[^0-9.]', crop_box)
+        crop_x = float(crop_box[0])
+        crop_y = float(crop_box[1]) if len(crop_box) > 1 else 0
+        crop_w = float(crop_box[2]) if len(crop_box) > 2 else 1 - crop_x
+        crop_h = float(crop_box[3]) if len(crop_box) > 3 else 1 - crop_y
+
+        # crop file
+        file_directory, file_name = tpool.execute(crop_file, file, project.root_folder,
+                                                  crop_x, crop_y, crop_w, crop_h,
+                                                  max_width, max_height)
+
+        # send to client
+        return send_from_directory(file_directory, file_name)

+ 12 - 104
pycs/frontend/endpoints/data/GetResizedFile.py

@@ -1,13 +1,13 @@
+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.FileOperations import resize_file
 
 
 class GetResizedFile(View):
@@ -29,109 +29,17 @@ 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)
-        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
+        # resize file
+        file_directory, file_name = tpool.execute(resize_file, file, project.root_folder,
+                                                  max_width, max_height)
 
-        # 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()
+        # send to client
+        return send_from_directory(file_directory, file_name)

+ 1 - 1
pycs/frontend/endpoints/data/UploadFile.py

@@ -8,7 +8,7 @@ from werkzeug import formparser
 
 from pycs.database.Database import Database
 from pycs.frontend.notifications.NotificationManager import NotificationManager
-from pycs.util.FileParser import file_info
+from pycs.util.FileOperations import file_info
 
 
 class UploadFile(View):

+ 1 - 1
pycs/frontend/endpoints/projects/ExecuteExternalStorage.py

@@ -11,7 +11,7 @@ from pycs.database.Project import Project
 from pycs.frontend.notifications.NotificationManager import NotificationManager
 from pycs.jobs.JobGroupBusyException import JobGroupBusyException
 from pycs.jobs.JobRunner import JobRunner
-from pycs.util.FileParser import file_info
+from pycs.util.FileOperations import file_info
 
 
 class ExecuteExternalStorage(View):

+ 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]):
         """

+ 233 - 0
pycs/util/FileOperations.py

@@ -0,0 +1,233 @@
+import os
+from typing import Tuple
+
+import cv2
+from PIL import Image
+
+from pycs.database.File import File
+
+DEFAULT_JPEG_QUALITY = 80
+
+
+def file_info(data_folder: str, file_name: str, file_ext: str):
+    """
+    Receive file type, frame count and frames per second.
+    The last two are always None for images.
+
+    :param data_folder: path to data folder
+    :param file_name: file name
+    :param file_ext: file extension
+    :return: file type, frame count, frames per second
+    """
+    # determine file type
+    if file_ext.lower() in ['.jpg', '.png']:
+        file_type = 'image'
+
+    elif file_ext.lower() in ['.mp4']:
+        file_type = 'video'
+
+    else:
+        raise ValueError(f"Unsupported file extension: {file_ext}!")
+
+    # determine frames and fps for video files
+    if file_type == 'image':
+        frames = None
+        fps = None
+    else:
+        file_path = os.path.join(data_folder, file_name + file_ext)
+        video = cv2.VideoCapture(file_path)
+
+        frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
+        fps = video.get(cv2.CAP_PROP_FPS)
+
+        video.release()
+
+    # return values
+    return file_type, frames, fps
+
+
+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 project_root: project root folder path
+    :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 crop_file(file: File, project_root: str,
+              x: float, y: float, w: float, h: float,
+              max_width: int, max_height: int) -> Tuple[str, 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 project_root: project root folder path
+    :param max_width: maximum image or thumbnail width
+    :param max_height: maximum image or thumbnail height
+    :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
+    """
+    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
+
+    # crop image file
+    abs_target_path = os.path.join(os.getcwd(),
+                                   project_root,
+                                   'temp',
+                                   f'{file.uuid}_{x}_{y}_{w}_{h}.jpg')
+    result = crop_image(abs_file_path, abs_target_path, x, y, w, h)
+
+    if result:
+        abs_file_path = abs_target_path
+
+    # resize image
+    abs_target_path = os.path.join(os.getcwd(),
+                                   project_root,
+                                   'temp',
+                                   f'{file.uuid}_{max_width}_{max_height}_{x}_{y}_{w}_{h}.jpg')
+    result = resize_image(abs_file_path, abs_target_path, max_width, max_height)
+
+    if result:
+        abs_file_path = abs_target_path
+
+    # return image
+    return os.path.split(abs_file_path)
+
+
+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()
+
+
+def resize_image(file_path: str, target_path: str, max_width: int, max_height: int) -> bool:
+    """
+    Resize an image so width < `max_width` and height < `max_height` applies.
+    If the image is already smaller than the given dimensions no new file is stored.
+
+    :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: `True` if a resize operation was performed or the target file already exists
+    """
+    # 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 False
+
+    # 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=DEFAULT_JPEG_QUALITY)
+    return True
+
+
+def crop_image(file_path: str, target_path: str, x: float, y: float, w: float, h: float) -> bool:
+    """
+    Crop an image with the given coordinates, width and height.
+    If however no crop is applied no new file is stored.
+
+    :param file_path: path to source file
+    :param target_path: path to target file
+    :param x: crop x position (normalized)
+    :param y: crop y position (normalized)
+    :param w: crop width (normalized)
+    :param h: crop height (normalized)
+    :return: `True` if a crop operation was performed or the target file already exists
+    """
+    # return if file exists
+    if os.path.exists(target_path):
+        return True
+
+    # abort if no crop would be applied
+    if x <= 0 and y <= 0 and w >= 1 and h >= 1:
+        return False
+
+    # load full size image
+    image = Image.open(file_path)
+    img_width, img_height = image.size
+
+    # calculate absolute crop position
+    crop_x1 = int(img_width * x)
+    crop_y1 = int(img_height * y)
+    crop_x2 = min(int(img_width * w) + crop_x1, img_width)
+    crop_y2 = min(int(img_height * h) + crop_y1, img_height)
+
+    # crop image
+    print(crop_x1, crop_y1, crop_x2, crop_y2)
+    cropped_image = image.crop((crop_x1, crop_y1, crop_x2, crop_y2))
+
+    # save to file
+    cropped_image.save(target_path, quality=DEFAULT_JPEG_QUALITY)
+    return True

+ 0 - 40
pycs/util/FileParser.py

@@ -1,40 +0,0 @@
-from os import path
-
-import cv2
-
-
-def file_info(data_folder: str, file_name: str, file_ext: str):
-    """
-    Receive file type, frame count and frames per second.
-    The last two are always None for images.
-
-    :param data_folder: path to data folder
-    :param file_name: file name
-    :param file_ext: file extension
-    :return: file type, frame count, frames per second
-    """
-    # determine file type
-    if file_ext.lower() in ['.jpg', '.png']:
-        ftype = 'image'
-
-    elif file_ext.lower() in ['.mp4']:
-        ftype = 'video'
-
-    else:
-        raise ValueError(f"Unsupported file extension: {file_ext}!")
-
-    # determine frames and fps for video files
-    if ftype == 'image':
-        frames = None
-        fps = None
-    else:
-        file_path = path.join(data_folder, file_name + file_ext)
-        video = cv2.VideoCapture(file_path)
-
-        frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
-        fps = video.get(cv2.CAP_PROP_FPS)
-
-        video.release()
-
-    # return values
-    return ftype, frames, fps

+ 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>

+ 35 - 17
webui/src/components/media/annotated-image.vue

@@ -10,6 +10,8 @@
                    @label="label = $event"
                    :labels="labels"
                    :zoomBox="zoomBox"
+                   :infoBox="infoBox !== false"
+                   @infoBox="infoBox = $event; interaction=false"
                    @unzoom="zoomBox=false; interaction=false"
                    @prevzoom="$refs.overlay.prevZoom()"
                    @nextzoom="$refs.overlay.nextZoom()"/>
@@ -47,9 +49,17 @@
                             :video="video"
                             :results="results"
                             :labels="labels"
+                            :crop="infoBox"
+                            @crop="infoBox = $event"
                             :zoom="zoomBox"
                             @zoom="zoomBox = $event"/>
       </div>
+
+      <cropped-image v-if="infoBox !== false"
+                     :labels="labels"
+                     :file="current"
+                     :box="infoBox"
+                     @close="infoBox=false"/>
     </template>
   </div>
 </template>
@@ -58,10 +68,11 @@
 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
@@ -83,13 +94,13 @@ export default {
 
     // get model
     this.$root.socket.get(`/projects/${this.$root.project.identifier}/model`)
-        .then(response => response.json())
-        .then(model => {
-          this.supported.labeledImages = model.supports.includes('labeled-images');
-          this.supported.labeledBoundingBoxes = model.supports.includes('labeled-bounding-boxes');
-          this.supported.boundingBoxes = this.supported.labeledBoundingBoxes
-              || model.supports.includes('bounding-boxes');
-        });
+      .then(response => response.json())
+      .then(model => {
+        this.supported.labeledImages = model.supports.includes('labeled-images');
+        this.supported.labeledBoundingBoxes = model.supports.includes('labeled-bounding-boxes');
+        this.supported.boundingBoxes = this.supported.labeledBoundingBoxes
+          || model.supports.includes('bounding-boxes');
+      });
   },
   destroyed: function () {
     window.removeEventListener('resize', this.resize);
@@ -123,6 +134,7 @@ export default {
         frame: 0,
         play: false
       },
+      infoBox: false,
       zoomBox: false,
       supported: {
         labeledImages: false,
@@ -240,6 +252,9 @@ export default {
       }
     },
     editResult: function (result) {
+      if (this.infoBox && result.identifier === this.infoBox.identifier)
+        this.infoBox = result;
+
       for (let i = 0; i < this.results.length; i++) {
         if (this.results[i].identifier === result.identifier) {
           this.$set(this.results, i, result);
@@ -249,11 +264,11 @@ export default {
     },
     getLabels: function () {
       this.$root.socket.get(`/projects/${this.$root.project.identifier}/labels`)
-          .then(response => response.json())
-          .then(labels => {
-            this.labels = [];
-            labels.forEach(this.addLabelToList);
-          });
+        .then(response => response.json())
+        .then(labels => {
+          this.labels = [];
+          labels.forEach(this.addLabelToList);
+        });
     },
     addLabelToList: function (label) {
       if (label['project_id'] !== this.$root.project.identifier)
@@ -292,11 +307,14 @@ export default {
         this.zoomBox = false;
 
         this.$root.socket.get(`/data/${newVal.identifier}/results`)
-            .then(response => response.json())
-            .then(results => {
-              this.results = results;
-            })
+          .then(response => response.json())
+          .then(results => {
+            this.results = results;
+          });
       }
+    },
+    infoBox: function () {
+      setTimeout(this.resize, 1);
     }
   }
 }

+ 19 - 9
webui/src/components/media/annotation-box.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="annotation-box"
        :style="style"
-       :class="{draggable: draggable, shine: shine}"
+       :class="{draggable: draggable}"
        @mousedown.prevent="click" @touchstart.prevent="click">
     <template v-if="draggable">
       <div class="nw" @mousedown.prevent.stop="resize('nw')" @touchstart.prevent.stop="resize('nw')"/>
@@ -23,7 +23,18 @@
 <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'
+  ],
   computed: {
     labelName: function () {
       if (!this.box || !this.box.label)
@@ -51,8 +62,8 @@ export default {
       }
 
       return {
-        backgroundColor: `rgba(${color}, 0.3)`,
-        borderColor: `rgba(${color}, 0.4)`,
+        backgroundColor: `rgba(${color}, ${this.shine ? 0.6 : 0.3})`,
+        borderColor: `rgba(${color}, ${this.shine ? 0.8 : 0.4})`,
         top: this.position.y * 100 + '%',
         left: this.position.x * 100 + '%',
         width: this.position.w * 100 + '%',
@@ -90,6 +101,10 @@ export default {
         this.$emit('zoom', this.position);
         event.stopPropagation();
       }
+      if (this.croppable) {
+        this.$emit('crop', this.box);
+        event.stopPropagation();
+      }
     },
     resize: function (mode) {
       this.$emit('resize', mode, this.position, this.update);
@@ -109,11 +124,6 @@ export default {
   border: 1px solid transparent;
 }
 
-.annotation-box.shine {
-  background: none !important;
-  border-width: 5px;
-}
-
 .label {
   position: absolute;
   bottom: 0.25rem;

+ 17 - 2
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"
+                    :shine="crop && box.identifier === crop.identifier"
                     @move="move"
                     @resize="resize"
+                    @crop="$emit('crop', $event)"
                     @zoom="zoomBox(index, $event)"/>
 
     <annotation-box v-if="current"
@@ -39,7 +42,19 @@ 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',
+    'crop',
+    'zoom'
+  ],
   data: function () {
     return {
       callback: false,

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

@@ -0,0 +1,81 @@
+<template>
+  <div class="cropped-image">
+    <div class="close-button"
+         @mousedown.prevent="$emit('close', true)"
+         @touchstart.prevent="$emit('close', true)">
+      <img alt="close button" src="@/assets/icons/cross.svg">
+    </div>
+
+    <div v-if="src" class="image-container">
+      <h3>{{ label }}</h3>
+
+      <img alt="crop" :src="src"/>
+    </div>
+    <div v-else>
+      click a bounding box
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "cropped-image",
+  props: ['labels', 'file', 'box'],
+  computed: {
+    src: function () {
+      if (!this.box)
+        return false;
+
+      const file = this.file.identifier;
+      const uuid = this.file.uuid;
+      const x = this.box.data.x;
+      const y = this.box.data.y;
+      const w = this.box.data.w;
+      const h = this.box.data.h;
+
+      return this.$root.socket.url(`/data/${file}/1024/${x}x${y}x${w}x${h}?uuid=${uuid}`)
+    },
+    label: function () {
+      if (!this.box)
+        return false;
+      if (this.box.label == null)
+        return 'Unknown'
+
+      for (let label of this.labels)
+        if (label.identifier === this.box.label)
+          return label.name;
+
+      return 'Not found';
+    }
+  }
+}
+</script>
+
+<style scoped>
+.cropped-image {
+  width: 40%;
+  height: 100%;
+  background-color: #858585;
+  position: relative;
+  padding: 2rem 1rem;
+  text-align: center;
+}
+
+.close-button {
+  position: absolute;
+  top: 0;
+  right: 0;
+  padding: 0.5rem 0.67rem;
+  cursor: pointer;
+  filter: invert(1);
+}
+
+.close-button img {
+  width: 1.5rem;
+}
+
+.image-container img {
+  border: 2px solid;
+  max-width: 100%;
+}
+</style>

+ 16 - 3
webui/src/components/media/options-bar.vue

@@ -61,6 +61,15 @@
 
     <div class="spacer"/>
 
+    <div ref="crop_info"
+         class="image"
+         title="Show info of a bounding box (I)"
+         :class="{active: interaction === 'info-box'}"
+         @click="$emit('interaction', 'info-box')">
+      <img alt="zoom bounding box" src="@/assets/icons/info.svg">
+    </div>
+
+    <!--
     <div ref="zoom_annotation"
          class="image"
          title="zoom bounding box (X)"
@@ -82,6 +91,7 @@
          @click="$emit('nextzoom', true)">
       <img alt="zoom next bounding box" src="@/assets/icons/chevron-right.svg">
     </div>
+    -->
 
     <div class="spacer"/>
 
@@ -119,14 +129,14 @@ import LabelSelector from "@/components/media/label-selector";
 export default {
   name: "options-bar",
   components: {LabelSelector},
-  props: ['file', 'interaction', 'filter', 'label', 'labels', 'zoomBox'],
+  props: ['file', 'interaction', 'filter', 'label', 'labels', 'infoBox', 'zoomBox'],
   created: function () {
     // get data
     this.getJobs();
 
     this.$root.socket.get(`/projects/${this.$root.project.identifier}/model`)
-        .then(response => response.json())
-        .then(model => this.model = model);
+      .then(response => response.json())
+      .then(model => this.model = model);
 
     // subscribe to changes
     this.$root.socket.on('connect', this.getJobs);
@@ -215,6 +225,9 @@ export default {
         case 'v':
           this.$refs.zoom_next_annotation.click();
           break;
+        case 'i':
+          this.$refs.crop_info.click();
+          break;
       }
     },
     getJobs: function () {