From 16431df78673cb221fd5f3bc1d43367ded55b229 Mon Sep 17 00:00:00 2001 From: PastaTime Date: Tue, 28 Jul 2020 13:47:32 +1200 Subject: [PATCH] Release v1.1 (#76) * Fixes logging errors while creating new projects and adding new images. --- .gitignore | 8 + README.md | 66 ++ client/__init__.py | 0 client/annotation_client.py | 114 ++ client/build/windows/client.spec | 34 + client/client_config.py | 27 + client/controller/__init__.py | 0 .../instance_annotator_controller.py | 323 +++++ client/data/common.kv | 87 ++ client/data/image_view_screen.kv | 34 + client/data/instance_annotator_screen.kv | 347 ++++++ client/data/project_select_screen.kv | 144 +++ client/data/project_tool_screen.kv | 32 + client/model/__init__.py | 0 client/model/instance_annotator_model.py | 251 ++++ client/screens/__init__.py | 0 client/screens/common.py | 149 +++ client/screens/image_view_screen.py | 66 ++ client/screens/instance_annotator_screen.py | 1043 +++++++++++++++++ client/screens/project_select_screen.py | 186 +++ client/screens/project_tool_screen.py | 58 + client/simple_client.py | 28 + client/utils.py | 260 ++++ client_requirements.txt | 25 + database/DATA/README | 3 + database/create_database.sql | 58 + database/create_test_data.sql | 2 + database/database.py | 52 + definitions.py | 3 + docker-compose.yml | 17 + server/apis/__init__.py | 14 + server/apis/image.py | 495 ++++++++ server/apis/project.py | 371 ++++++ server/app.py | 10 + server/core/__init__.py | 0 server/core/common_dtos.py | 42 + server/core/dto_store.py | 12 + server/server_config.py | 43 + server/template.xml | 28 + server/utils.py | 99 ++ server_requirements.txt | 21 + 41 files changed, 4552 insertions(+) create mode 100644 client/__init__.py create mode 100644 client/annotation_client.py create mode 100644 client/build/windows/client.spec create mode 100644 client/client_config.py create mode 100644 client/controller/__init__.py create mode 100644 client/controller/instance_annotator_controller.py create mode 100644 client/data/common.kv create mode 100644 client/data/image_view_screen.kv create mode 100644 client/data/instance_annotator_screen.kv create mode 100644 client/data/project_select_screen.kv create mode 100644 client/data/project_tool_screen.kv create mode 100644 client/model/__init__.py create mode 100644 client/model/instance_annotator_model.py create mode 100644 client/screens/__init__.py create mode 100644 client/screens/common.py create mode 100644 client/screens/image_view_screen.py create mode 100644 client/screens/instance_annotator_screen.py create mode 100644 client/screens/project_select_screen.py create mode 100644 client/screens/project_tool_screen.py create mode 100644 client/simple_client.py create mode 100644 client/utils.py create mode 100644 client_requirements.txt create mode 100644 database/DATA/README create mode 100644 database/create_database.sql create mode 100644 database/create_test_data.sql create mode 100644 database/database.py create mode 100644 definitions.py create mode 100644 docker-compose.yml create mode 100644 server/apis/__init__.py create mode 100644 server/apis/image.py create mode 100644 server/apis/project.py create mode 100644 server/app.py create mode 100644 server/core/__init__.py create mode 100644 server/core/common_dtos.py create mode 100644 server/core/dto_store.py create mode 100644 server/server_config.py create mode 100644 server/template.xml create mode 100644 server/utils.py create mode 100644 server_requirements.txt diff --git a/.gitignore b/.gitignore index b6e4761..a3bb5a1 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,11 @@ dmypy.json # Pyre type checker .pyre/ + +# PyCharm IDE Configuration +.idea/ + +# Data directory for Database +database/DATA + + diff --git a/README.md b/README.md index b7c8a60..888623b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,68 @@ # FastAnnotation A tool which aims to streamline the annotation process for computer vision projects dealing with Object Detection, Instance Segmentation and Semantic Segmentation. + + +## Installation +### Requirements +Python 3.6 or greater + +### Client +Note: For the following Installation steps please ensure pip is run with administrative privileges. + +1. Kivy installation + 1. Windows: + ``` + python -m pip install --upgrade pip wheel setuptools + python -m pip install docutils pygments pypiwin32 kivy.deps.sdl2 kivy.deps.glew + python -m pip install kivy.deps.gstreamer + python -m pip install kivy.deps.angle + python -m pip install kivy + ``` + 2. Ubuntu: + ``` + python -m pip install -r client_requirements.txt + ``` + +### Server +1. Flask installation + + 1.Windows installation + ``` + python -m pip install flask + python -m pip install flask-restplus + python -m pip install flask_negotiate + python -m pip install python-dateutil + python -m pip install mysql-connector-python + ``` + 2. Ubuntu + + ``` + python -m pip install -r server_requirements.txt + ``` + +2. MySql Installation + 1. Windows: + ``` + TODO + ``` + 2. Ubuntu: + 1. Install mysql + ``` + sudo apt-get install mysql-workbench + ``` + 1. Create a connection to the database and run the `database/create_database.sql` script + 1. (Optional) run the 'database/create_test_data.sql' script to populate tables with test data + 3. Alternatively, Docker can be used to run a MySQL database for development. + 1. Download and install Docker and Docker Compose + 2. Start the container with + ``` + >> docker-compose up + ``` + 3. Stop the container with + ``` + >> docker-compose down + ``` + 4. To stop the container and delete the volume (deleting all data in the DB) + ``` + >> docker-compose down -v + ``` \ No newline at end of file diff --git a/client/__init__.py b/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/annotation_client.py b/client/annotation_client.py new file mode 100644 index 0000000..d1073f0 --- /dev/null +++ b/client/annotation_client.py @@ -0,0 +1,114 @@ +import os +import sys +import traceback +from concurrent.futures import ThreadPoolExecutor + +from kivy.app import App +from kivy.config import Config +from kivy.properties import StringProperty, NumericProperty +from kivy.resources import resource_add_path +from kivy.uix.screenmanager import ScreenManager + +import client.utils as utils +from client.client_config import ClientConfig +from client.screens.common import Alert +from client.screens.image_view_screen import ImageViewScreen +from client.screens.instance_annotator_screen import InstanceAnnotatorScreen +from client.screens.project_select_screen import ProjectSelectScreen +from client.screens.project_tool_screen import ProjectToolScreen +from client.utils import ApiException + +Config.set('input', 'mouse', 'mouse,disable_multitouch') + + +class MyScreenManager(ScreenManager): + """ + Defines the screens which should be included in the ScreenManager at launch. + Use the ScreenManager to handle transitions between Screens. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_widget(ProjectSelectScreen(name="ProjectSelect")) + self.add_widget(ProjectToolScreen(name="ProjectTool")) + self.add_widget(ImageViewScreen(name="ImageView")) + self.add_widget(InstanceAnnotatorScreen(name="InstanceAnnotator")) + + +class AnnotationClientApp(App): + """ + The launch point for the application itself. + """ + + current_project_name = StringProperty("") + current_project_id = NumericProperty(0) + sm = None + thread_pool = ThreadPoolExecutor( + max_workers=ClientConfig.CLIENT_POOL_LIMIT) + + def build(self): + print("Building") + self.sm = MyScreenManager() + return self.sm + + def show_project_tools(self, name, id): + self.current_project_name = name + self.current_project_id = id + self.sm.current = "ProjectTool" + + def show_home(self): + self.sm.current = "ProjectSelect" + + def show_image_viewer(self): + self.sm.current = "ImageView" + + def show_instance_annotator(self): + self.sm.current = "InstanceAnnotator" + + def alert_user(self, future): + if not future.exception(): + return + + exception = future.exception() + try: + raise exception + except Exception: + tb = traceback.format_exc() + print(tb) + + pop_up = Alert() + if isinstance(exception, ApiException): + pop_up.title = "Server Error: %d" % exception.code + else: + pop_up.title = "Unknown Error: %s" % type(exception).__name__ + pop_up.alert_message = str(exception) + pop_up.open() + + +def resourcePath(): + """Returns path containing content - either locally or in pyinstaller tmp file""" + if hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS) + + return os.path.join(os.path.abspath(".")) + + +if __name__ == "__main__": + resource_add_path(resourcePath()) # add this line + app = AnnotationClientApp() + try: + app.run() + except Exception as e: + print(str(e)) + tb = traceback.format_exc() + print(tb) + finally: + app.thread_pool.shutdown(wait=True) + open_images = app.sm.get_screen("InstanceAnnotator").model.active.copy() + print(open_images) + for iid in open_images: + response = utils.update_image_meta_by_id(iid, lock=False) + if response.status_code == 200: + print("Unlocked %d" % iid) + else: + print("Failed to unlock %d" % iid) diff --git a/client/build/windows/client.spec b/client/build/windows/client.spec new file mode 100644 index 0000000..b95dcb7 --- /dev/null +++ b/client/build/windows/client.spec @@ -0,0 +1,34 @@ +# -*- mode: python -*- + +from kivy_deps import sdl2, glew + +block_cipher = None + + +a = Analysis(['..\\..\\annotation_client.py'], + pathex=['..\\..'], + binaries=None, + datas=None, + hiddenimports=None, + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + + +exe = EXE(pyz, Tree('..\\..\\data','client\\data'), + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + *[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins)], + name='fastannotation', + debug=False, + strip=False, + upx=True, + console=True) \ No newline at end of file diff --git a/client/client_config.py b/client/client_config.py new file mode 100644 index 0000000..9ae7d2c --- /dev/null +++ b/client/client_config.py @@ -0,0 +1,27 @@ +import os + +from definitions import ROOT_DIR + + +class ClientConfig: + """ A collection of constants utilized by the client """ + + SERVER_URL = "http://localhost:5001/" + # Switch to production URL before building executable + # SERVER_URL = "http://130.216.239.117:5000/" + + SECONDS_PER_MINUTE = 60 + SECONDS_PER_HOUR = 3600 + SECONDS_PER_DAY = SECONDS_PER_HOUR * 24 + SECONDS_PER_MONTH = SECONDS_PER_DAY * 30 + SECONDS_PER_YEAR = SECONDS_PER_DAY * 365 + + CLIENT_HIGHLIGHT_1 = "#3AD6E7" + CLIENT_DARK_3 = "#0C273B" + + BBOX_SELECT = "#ff0000" + BBOX_UNSELECT = "#ebcf1a" + + DATA_DIR = os.path.join(ROOT_DIR, 'client', 'data') + + CLIENT_POOL_LIMIT = 50 diff --git a/client/controller/__init__.py b/client/controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/controller/instance_annotator_controller.py b/client/controller/instance_annotator_controller.py new file mode 100644 index 0000000..246012f --- /dev/null +++ b/client/controller/instance_annotator_controller.py @@ -0,0 +1,323 @@ +import numpy as np + +import client.utils as utils +from client.model.instance_annotator_model import ImageState, AnnotationState, LabelState +from client.utils import ApiException + + +class InstanceAnnotatorController: + def __init__(self, model): + self.model = model + + def fetch_image_metas(self, project_id, filter_details): + """ + Fetch the meta information for all un-opened images in this project + :param project_id: The ID for this project + :param filter_details: A dict of filter params used to order the images + :return: + """ + resp = utils.get_project_images(project_id, filter_details) + + if resp.status_code != 200: + raise ApiException( + "Failed to retrieve project image ids.", + resp.status_code) + + result = resp.json() + + resp = utils.get_image_metas_by_ids(result["ids"]) + if resp.status_code != 200: + raise ApiException( + "Failed to retrieve image meta information.", + resp.status_code) + + result = resp.json() + for row in result["images"]: + if self.model.images.is_open(row["id"]): + continue + + state = ImageState(id=row["id"], + name=row["name"], + is_locked=row["is_locked"], + is_open=False) + self.model.images.add(row["id"], state) + + def fetch_image(self, image_id): + """ + Fetch the image and annotation data for this image. + :param image_id: The ID for this image. + :return: + """ + + image_model = self.model.images.get(image_id) + if not image_model: + image_model = ImageState() + + resp = utils.update_image_meta_by_id(image_id, lock=True) + if resp.status_code != 200: + raise ApiException( + "Failed to lock image with id %d" % + image_id, resp.status_code) + + resp = utils.get_image_by_id(image_id) + if resp.status_code == 404: + raise ApiException( + "Image does not exist with id %d." % + image_id, resp.status_code) + elif resp.status_code != 200: + raise ApiException( + "Failed to retrieve image with id %d." % + image_id, resp.status_code) + + result = resp.json() + img_bytes = utils.decode_image(result["image_data"]) + image_model.id = result["id"] + image_model.name = result["name"] + image_model.is_locked = result["is_locked"] + image_model.image = utils.bytes2mat(img_bytes) + image_model.shape = image_model.image.shape + + resp = utils.get_image_annotation(image_id) + if resp.status_code != 200: + raise ApiException( + "Failed to retrieve annotations for the image with id %d." % + image_id, resp.status_code) + + result = resp.json() + annotations = {} + i = 0 + for row in result["annotations"]: + # TODO: Add actual annotation names to database + annotation_name = row["name"] + class_name = row["class_name"] + mask = utils.decode_mask(row["mask_data"], row["shape"][:2]) + + bbox = row["bbox"] + print("CLIENT: incoming bbox") + print("\t%s" % str(bbox)) + + annotations[annotation_name] = AnnotationState( + annotation_name=annotation_name, + class_name=class_name, + mat=utils.mask2mat(mask), + bbox=bbox) + + i += 1 + + image_model.annotations = annotations + self.model.images.add(image_id, image_model) + + def fetch_class_labels(self, project_id): + """ + Fetch the class labels for this project. + :param project_id: The ID for this project. + :return: + """ + print("Fetching Class Labels") + + # TODO: These arent stored in the database yet + + data = [ + LabelState("Trunk", [120, 80, 0, 255]), + LabelState("Cane", [150, 250, 0, 255]), + LabelState("Shoot", [0, 130, 200, 255]), + LabelState("Node", [255, 100, 190, 255]), + LabelState("Wire", [255, 128, 0, 255]), + LabelState("Post", [128, 128, 0, 255]) + ] + + for label in data: + self.model.labels.add(label.name, label) + + def open_image(self, image_id): + """ + Add a fully loaded image to the active open images. + :param image_id: The ID of a valid image + :return: + """ + + image_model = self.model.images.get(image_id) + + if image_model is None or image_model.image is None: + self.fetch_image(image_id) + + if not self.model.images.contains(image_id): + return + + if not self.model.active.contains(image_id): + self.model.active.append(image_id) + print(self.model.active._list) + + self.model.tool.set_current_image_id(image_id) + + def save_image(self, image_canvas): + """ + Save the changes from the image_canvas to the model and reflect these changes back to the server. + + NOTE: Ensure the ImageCanvas has run prepare_to_save() on the mainthread before running this operation + :param image_canvas: The ImageCanvas object + :return: + """ + + iid = image_canvas.image_id + image_model = self.model.images.get(iid) + + if image_model is None: + raise ValueError( + "Image Canvas points to image id %d, which is not valid." % + iid) + + # Build annotations + annotations = {} + i = 0 + for layer in image_canvas.layer_stack.get_all_layers(): + annotation_name = layer.layer_name + mask = layer.mask + + print("CLIENT: outgoing bbox") + print("\t%s" % str(layer.bbox_bounds)) + + annotation = AnnotationState(annotation_name=annotation_name, + class_name=layer.class_name, + mat=utils.mask2mat(mask), + bbox=layer.bbox_bounds) + annotations[annotation_name] = annotation + i += 1 + + image_model.annotations = annotations + + resp = utils.add_image_annotation(iid, image_model.annotations) + if resp.status_code == 200: + result = resp.json() + errors = [] + for row in result["results"]: + errors.append(row["error"]["message"]) + errors = '\n'.join(errors) + msg = "The following errors occurred while saving annotations to the image with id %d:\n" % iid + msg += errors + raise ApiException(message=msg, code=resp.status_code) + elif resp.status_code != 201: + msg = "Failed to save annotations to the image with id %d." % iid + raise ApiException(message=msg, code=resp.status_code) + + resp = utils.update_image_meta_by_id(iid, lock=False) + if resp.status_code != 200: + msg = "Failed to unlock the image with id %d." % iid + raise ApiException(message=msg, code=resp.status_code) + + image_model.is_locked = False + image_model.unsaved = False + + self.model.images.add(iid, image_model) + + def update_tool_state(self, + pen_size=None, + alpha=None, + eraser=None, + current_iid=None, + current_label=None, + current_layer=None): + if pen_size is not None: + self.model.tool.set_pen_size(pen_size) + if alpha is not None: + self.model.tool.set_alpha(alpha) + if eraser is not None: + self.model.tool.set_eraser(eraser) + if current_iid is not None: + self.model.tool.set_current_image_id(current_iid) + if current_layer is not None: + self.model.tool.set_current_layer_name(current_layer) + # If current layer changes update current_label aswell + iid = self.model.tool.get_current_image_id() + img = self.model.images.get(iid) + if img is not None: + annotation = img.annotations.get(current_layer, None) + if annotation is not None: + self.model.tool.set_current_label_name(annotation.class_name) + if current_label is not None: + self.model.tool.set_current_label_name(current_label) + + def update_annotation( + self, + iid=None, + layer_name=None, + bbox=None, + texture=None, + label_name=None, + mask_enabled=None, + bbox_enabled=None): + # Populate iid and layer_name with current values if None + if iid is None: + iid = self.model.tool.get_current_image_id() + if layer_name is None: + layer_name = self.model.tool.get_current_layer_name() + + image = self.model.images.get(iid) + if image is None or image.annotations is None: + return + + annotation = image.annotations.get(layer_name, None) + if annotation is None: + return + + if bbox is not None: + annotation.bbox = bbox + + if texture is not None: + annotation.mat = utils.texture2mat(texture) + + if label_name is not None: + annotation.class_name = label_name + + if mask_enabled is not None: + annotation.mask_enabled = bool(mask_enabled) + + if bbox_enabled is not None: + annotation.bbox_enabled = bool(bbox_enabled) + + image.annotations[layer_name] = annotation + self.model.images.add(iid, image) + + def update_image_meta( + self, + iid, + is_locked=None, + is_open=None, + unsaved=None): + image = self.model.images.get(iid) + if image is None: + return + + if is_locked is not None: + image.is_locked = is_locked + + if is_open is not None: + image.is_open = is_open + + if unsaved is not None: + print("Controller: Marking as unsaved") + image.unsaved = unsaved + + self.model.images.add(iid, image) + + def add_blank_layer(self, iid): + img = self.model.images.get(iid) + layer_name = img.get_unique_annotation_name() + class_name = self.model.tool.get_current_label_name() + mask = np.zeros(shape=img.shape, dtype=np.uint8) + bbox = (0, 0, 0, 0) + annotation = AnnotationState(layer_name, class_name, mask, bbox) + img.annotations[layer_name] = annotation + img.unsaved = True + self.model.images.add(iid, img) + self.model.tool.set_current_layer_name(layer_name) + print("Controller: Adding blank layer (%s)" % layer_name) + + def delete_layer(self, iid, layer_name): + img = self.model.images.get(iid) + img.annotations.pop(layer_name, None) + img.unsaved = True + self.model.images.add(iid, img) + if self.model.tool.get_current_layer_name() is layer_name: + self.model.tool.set_current_layer_name(None) + print("Controller: Deleting layer (%s)" % layer_name) diff --git a/client/data/common.kv b/client/data/common.kv new file mode 100644 index 0000000..2de5296 --- /dev/null +++ b/client/data/common.kv @@ -0,0 +1,87 @@ +#:import utils kivy.utils +#:import ClientConfig client.client_config.ClientConfig + +: + size_hint: None, None + size: app.root.width/3, app.root.height/3 + auto_dismiss: True + alert_message: '' + BoxLayout: + orientation: 'vertical' + ScrollView: + do_scroll_x: False + do_scroll_y: True + Label: + text: root.alert_message + size_hint_y: None + height: self.texture_size[1] + text_size: self.width, None + AnchorLayout: + anchor_x: 'center' + anchor_y: 'bottom' + size_hint: (1, 0.2) + Button: + size_hint_x: 0.3 + text: 'Ok' + on_release: root.dismiss() + + +: + orientation: 'horizontal' + default_text: 'default' + label_text: 'default label' + text_field: text_field + Label: + text: root.label_text + TextInput: + id: text_field + text: root.default_text + +: + tile_width: 0 + tile_height: 0 + size_hint_y: None + height: self.minimum_height + col_default_width: root.tile_width + col_force_default: True + row_default_height: root.tile_height + row_force_default: True + spacing: 16 + padding: 16 + cols: int((self.width - self.padding[0]) / (self.col_default_width + self.spacing[0])) + +: + decimal_places: 2 + text_input: text_input + TextInput: + id: text_input + size_hint_x: None + width: root.width - 20 + text: str(root.value) + multiline: False + halign: 'center' + valign: 'center' + on_text_validate: root.validate_user_input() + BoxLayout: + size_hint_x: None + width: 20 + orientation: 'vertical' + padding: 1 + Button: + text: '+' + disabled: root.value >= root.max + on_release: root.increment(1) + Button: + text: '-' + disabled: root.value <= root.min + on_release: root.increment(-1) + + + mask_layer: mask_layer + fbo: self.fbo + EffectWidget: + size: root.size + effects: [common.TransparentBlackEffect()] + Widget: + size: root.size + id: mask_layer \ No newline at end of file diff --git a/client/data/image_view_screen.kv b/client/data/image_view_screen.kv new file mode 100644 index 0000000..9c65c2a --- /dev/null +++ b/client/data/image_view_screen.kv @@ -0,0 +1,34 @@ +: + tile_view: tile_view + BoxLayout: + orientation: "vertical" + ActionBar: + pos_hint: {'top':1} + ActionView: + use_separator: True + ActionPrevious: + title: app.current_project_name + with_previous: True + on_release: app.show_project_tools(app.current_project_name, app.current_project_id) + ScrollView: + TileView: + id: tile_view + tile_width: 150 + tile_height: 150 + padding: 32 + spacing: 32 + +: + cust_texture: self.cust_texture + orientation: "vertical" + canvas: + Color: + rgba: 0.8, 0.8, 0.8, 0.8 + Rectangle: + pos: self.pos + size: self.size + Image: + texture: root.cust_texture + keep_ratio: True + pos: self.pos + size: self.size \ No newline at end of file diff --git a/client/data/instance_annotator_screen.kv b/client/data/instance_annotator_screen.kv new file mode 100644 index 0000000..ecad3f8 --- /dev/null +++ b/client/data/instance_annotator_screen.kv @@ -0,0 +1,347 @@ +#:import utils kivy.utils +#:import ClientConfig client.client_config.ClientConfig +#:import common client.screens.common + +: + left_control: left_control + right_control: right_control + tab_panel: tab_panel + BoxLayout: + orientation: "vertical" + ActionBar: + pos_hint: {'top':1} + ActionView: + use_separator: True + ActionPrevious: + title: app.current_project_name + with_previous: True + on_release: app.show_project_tools(app.current_project_name, app.current_project_id) + BoxLayout: + orientation: "horizontal" + LeftControlColumn: + id: left_control + size_hint_x: 350 / root.width + ImageCanvasTabPanel: + id: tab_panel + + RightControlColumn: + id: right_control + size_hint_x: 350 / root.width + +: + do_default_tab: False + +: + tab_name: self.tab_name + unsaved: self.unsaved + image_canvas: image_canvas + text: self.tab_name + "*" * self.unsaved + halign: 'left' + valign: 'center' + text_size: self.size + padding: (10,0) + shorten: True + ImageCanvas: + id: image_canvas + +: + tool_select: tool_select + class_picker: class_picker + layer_view: layer_view + orientation: "vertical" + canvas: + Color: + rgba: utils.get_color_from_hex(ClientConfig.CLIENT_DARK_3) + Rectangle: + pos: self.pos + size: self.size + ToolSelect: + id: tool_select + size_hint_y: 300 / root.height + + ClassPicker: + id: class_picker + size_hint_y: 500 / root.height + LayerView: + id: layer_view + +: + pen_size: pen_size + alpha: alpha + cols: 1 + Label: + text: "Tool Options" + size_hint_y: None + height: 50 + BoxLayout: + orientation: "horizontal" + size_hint_y: None + height: 30 + Label: + text: "pen size" + NumericInput: + id: pen_size + min: 1 + max: 100 + value: 10 + step: 5 + on_value: root.set_pencil_size(self.value) + BoxLayout: + orientation: "horizontal" + size_hint_y: None + height: 30 + Label: + text: "alpha" + NumericInput: + id: alpha + min: 0.0 + max: 1.0 + value: 1.0 + step: 0.1 + on_value: root.set_alpha(self.value) + +: + current_class: self.current_class + grid: grid + cols: 1 + Label: + text: "Class Picker" + size_hint_y: None + height: 50 + GridLayout: + id: grid + cols: 2 + + + class_color: self.class_color + class_name: self.class_name + class_id: self.class_id + release_cb: self.release_cb + + background_color: root.class_color + background_down: '' + size_hint_y: None + height: 50 + text: self.class_name + +: + layer_item_layout: layer_item_layout + cols: 1 + Button: + size_hint_y: None + height: 50 + text: "Add Layer" + on_release: app.root.current_screen.add_layer() + ScrollView: + GridLayout: + cols: 1 + size_hint_y: None + height: self.minimum_height + id: layer_item_layout + +: + layer_name: self.layer_name + + mask_enabled: self.mask_enabled + bbox_enabled: self.bbox_enabled + layer_selected: self.layer_selected + + layer_select_cb: self.layer_select_cb + layer_delete_cb: self.layer_delete_cb + + button_up_color: self.button_up_color + button_down_color : self.button_down_color + + btn_base: btn_base + btn_mask: btn_mask + btn_bbox: btn_bbox + + height: 50 + size_hint_y: None + Button: + id: btn_base + size_hint: (1,1) + background_color: root.button_up_color + on_release: root.layer_select_cb() + BoxLayout: + size_hint: (1, 1) + pos_hint: {'center_x': .5, 'center_y': .5} + padding: 8 + spacing: 8 + Button: + id: btn_mask + text: "M" + size_hint_x: None + width: self.height + on_release: root.mask_enabled = not root.mask_enabled + background_color: root.button_down_color + Button: + id: btn_bbox + text: "B" + size_hint_x: None + width: self.height + on_release: root.bbox_enabled = not root.bbox_enabled + background_color: root.button_down_color + Label: + text: root.layer_name + halign: 'left' + valign: 'center' + text_size: self.size + shorten: True + BoxLayout: + pos_hint: {'right': 1, 'top': 1} + size_hint_y: 0.5 + size_hint_x: None + width: self.height + spacing: 8 + padding: 8 + Button: + text: "X" + on_release: root.layer_delete_cb() + +: + image_id: self.image_id + pen_size: self.pen_size + alpha: self.alpha + current_class: self.current_class + current_layer: self.current_layer + + scatter: scatter + image: image + layer_stack: layer_stack + draw_tool: draw_tool + scroll_view: scroll_view + canvas: + Color: + rgba: 1.0, 0.0, 0.1, 0.8 + Rectangle: + pos: self.pos + size: self.size + Scatter: + id: scatter + pos: (0,0) + ScrollView: + scroll_type: ['bars', 'content'] + scroll_timeout: 5 + bar_width: '10dp' + id: scroll_view + width: root.width / root.scatter.scale + height: root.height / root.scatter.scale + on_size: print("ScrollView Size: %s" % str(self.size)) + on_pos: print("ScrollView Pos: %s" % str(self.pos)) + RelativeLayout: + pos: (0,0) + size: root.image.size + size_hint:(None, None) + Image: + id: image + keep_ratio: True + pos: (0,0) + DrawTool: + id: draw_tool + pos: (0,0) + LayerStack: + id: layer_stack + pos: (0,0) + + +: + layer_list: self.layer_list + current_layer: self.current_layer + + + layer: self.layer + pen_size: self.pen_size + layer_color: self.layer_color + +: + paint_window: paint_window + mask_color: self.mask_color + + bbox_layer: bbox_layer + bbox_color: self.bbox_color + bbox_thickness: self.bbox_thickness + bbox_bounds: self.bbox_bounds + + PaintWindow: + id: paint_window + size: root.size + Widget: + id: bbox_layer + canvas: + Color: + rgba: root.bbox_color + Line: + width: root.bbox_thickness + rectangle: root.bbox_bounds + + +: + image_queue: image_queue + image_queue_control: image_queue_control + orientation: "vertical" + canvas: + Color: + rgba: utils.get_color_from_hex(ClientConfig.CLIENT_DARK_3) + Rectangle: + pos: self.pos + size: self.size + ImageQueueControl: + id: image_queue_control + size_hint_y: 400 / root.height + size_hint_x: 1 + ImageQueue: + id: image_queue + +: + btn_save: btn_save + cols: 1 + padding: 16 + spacing: 16 + Button: + text: "Next" + size_hint_y: None + height: 50 + on_release: app.root.current_screen.load_next() + Button: + id: btn_save + text: "Save" + size_hint_y: None + height: 50 + disabled: True + on_release: app.root.current_screen.save_image() + Button: + text: "Refresh" + size_hint_y: None + height: 50 + on_release: app.root.current_screen.fetch_image_metas() + +: + queue: queue + cols: 1 + Label: + text: "Image Queue" + size_hint_y: None + height: 50 + ScrollView: + GridLayout: + id: queue + cols: 1 + size_hint_y: None + height: self.minimum_height + + +: + image_name: self.image_name + image_id: self.image_id + image_open: self.image_open + image_locked: self.image_locked + button_color: self.button_color + + size_hint_y: None + height: 50 + Button: + text: root.image_name + background_color: root.button_color + on_release: app.root.current_screen.load_image(root.image_id) + disabled: root.image_locked \ No newline at end of file diff --git a/client/data/project_select_screen.kv b/client/data/project_select_screen.kv new file mode 100644 index 0000000..34df075 --- /dev/null +++ b/client/data/project_select_screen.kv @@ -0,0 +1,144 @@ +: + size_hint: None, None + size: app.root.width/2, app.root.height/2 + auto_dismiss: True + title: "Delete Project" + message: "" + confirmation_callback: None + BoxLayout: + padding: 8 + spacing: 8 + orientation: 'vertical' + Label: + text: root.message + AnchorLayout: + anchor_x: 'center' + anchor_y: 'bottom' + BoxLayout: + orientation: 'horizontal' + padding: 8 + spacing: 8 + Button: + size_hint: (0.3, 0.2) + text: 'Delete' + on_release: root.confirmation_callback(); root.dismiss() + Button: + size_hint: (0.3, 0.2) + text: 'Cancel' + on_release: root.dismiss() + +: + size_hint: None, None + size: app.root.width/2, app.root.height/2 + auto_dismiss: False + title: "Add New Project" + BoxLayout: + padding: 8 + spacing: 8 + orientation: 'vertical' + LabelInput: + id: project_name + size_hint_y: 0.2 + label_text: "Project Name" + AnchorLayout: + anchor_x: 'center' + anchor_y: 'bottom' + BoxLayout: + orientation: 'horizontal' + padding: 8 + spacing: 8 + Button: + size_hint: (0.3, 0.2) + text: 'Add' + on_release: root.add_project(project_name=project_name.text_field.text) + Button: + size_hint: (0.3, 0.2) + text: 'Cancel' + on_release: root.dismiss() + +: + orientation: "horizontal" + size_hint_y: None + width: root.width + height: 64 + padding: (16, 16, 16, 8) + spacing: 16 + Button: + size_hint_x: None + width: 150 + text: "Add" + on_release: root.open_add_project_popup() + Button: + id: btn_refresh_project + size_hint_x: None + width: 150 + text: "Refresh" + on_release: root.trigger_project_refresh() + +: + project_name: self.project_name + project_id: self.project_id + image: self.image + total_images: self.total_images + labeled_images: self.labeled_images + last_update_time: self.last_update_time + last_update_label: last_update_label + Button: + on_release: app.show_project_tools(root.project_name, root.project_id) + FloatLayout: + size: root.size + pos: root.pos + BoxLayout: + pos_hint:{"x": 0.00, "y": 0.00} + orientation: "vertical" + canvas: + Color: + rgba: 1, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + BoxLayout: + Label: + text: root.image + BoxLayout: + size_hint: (1, 0.3) + Label: + text: root.project_name + ProgressBar: + max: root.total_images + value: root.labeled_images + size_hint: (1, 0.15) + Label: + id: last_update_label + size_hint: (1, 0.2) + markup: True + font_size: "13dp" + text: "Updated [b][color=3AD6E7]5 minutes[/color][/b] ago" + Button: + pos_hint:{"x": 0.9, "y": 0.9} + size_hint: (0.1, 0.1) + text: "X" + on_release: root.confirm_delete_project() + +: + tile_width: 150 + tile_height: 200 + + +: + control_bar: control_bar + project_view_window: project_view_window + BoxLayout: + orientation: "vertical" + ActionBar: + pos_hint: {'top':1} + ActionView: + use_separator: True + ActionPrevious: + title: 'Select a Project' + with_previous: False + ControlBar: + id: control_bar + ScrollView: + ProjectViewWindow: + id: project_view_window \ No newline at end of file diff --git a/client/data/project_tool_screen.kv b/client/data/project_tool_screen.kv new file mode 100644 index 0000000..12b9a2c --- /dev/null +++ b/client/data/project_tool_screen.kv @@ -0,0 +1,32 @@ +: + BoxLayout: + orientation: "vertical" + ActionBar: + pos_hint: {'top':1} + ActionView: + use_separator: True + ActionPrevious: + title: app.current_project_name + with_previous: True + on_release: app.show_home() + ActionButton: + text: "Edit" + on_release: print("Edit Project") + ActionButton: + text: "Delete" + on_release: print("Delete Project") + ScrollView: + TileView: + tile_width: 150 + tile_height: 150 + padding: 32 + spacing: 32 + Button: + text: "Add Images" + on_release: root.upload_images() + Button: + text: "View Images" + on_release: app.show_image_viewer() + Button: + text: "Instance Annotator" + on_release: app.show_instance_annotator() \ No newline at end of file diff --git a/client/model/__init__.py b/client/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/model/instance_annotator_model.py b/client/model/instance_annotator_model.py new file mode 100644 index 0000000..5652f78 --- /dev/null +++ b/client/model/instance_annotator_model.py @@ -0,0 +1,251 @@ +import uuid +from copy import deepcopy +from threading import Lock + +import numpy as np + + +class BlockingList: + _lock = Lock() + + def __init__(self): + self._list = [] + + def copy(self): + with self._lock: + return deepcopy(self._list) + + def contains(self, value): + with self._lock: + return value in self._list + + def append(self, value): + with self._lock: + self._list.append(value) + + def get(self, index): + with self._lock: + return deepcopy(self._list[index]) + + def remove(self, value): + with self._lock: + self._list.remove(value) + + def clear(self): + with self._lock: + self._list = [] + + +class BlockingCache: + _lock = Lock() + + def __init__(self): + self._cache = {} + + def keys(self): + with self._lock: + return list(self._cache.keys()) + + def contains(self, key): + with self._lock: + return key in self._cache + + def add(self, key, obj): + with self._lock: + self._cache[key] = obj + + def get(self, key): + with self._lock: + return deepcopy(self._cache.get(key, None)) + + def delete(self, key): + with self._lock: + self._cache.pop(key, None) + + def clear(self): + with self._lock: + self._cache = {} + + +class InstanceAnnotatorModel: + """ + A model representation of the data used in the Instance Annotator Screen + """ + + def __init__(self): + self.images = ImageCache() + self.labels = LabelCache() + self.active = ActiveImages() + self.tool = ToolState() + + +class ToolState: + """ + A thread-safe object containing state related to the Instance Annotator Tool + """ + _lock = Lock() + + def __init__( + self, + pen_size=1, + alpha=0.0, + eraser=False, + current_image_id=-1, + current_label_name="", + current_layer_name=""): + self._pen_size = pen_size + self._alpha = alpha + self._eraser = eraser + self._current_image_id = current_image_id + self._current_label_name = current_label_name + self._current_layer_name = current_layer_name + + def get_pen_size(self): + with self._lock: + return self._pen_size + + def set_pen_size(self, value): + with self._lock: + self._pen_size = value + + def get_alpha(self): + with self._lock: + return self._alpha + + def set_alpha(self, value): + with self._lock: + self._alpha = value + + def get_eraser(self): + with self._lock: + return self._eraser + + def set_eraser(self, value): + with self._lock: + self._eraser = value + + def get_current_image_id(self): + with self._lock: + return self._current_image_id + + def set_current_image_id(self, value): + with self._lock: + self._current_image_id = value + + def get_current_label_name(self): + with self._lock: + return self._current_label_name + + def set_current_label_name(self, value): + with self._lock: + self._current_label_name = value + + def get_current_layer_name(self): + with self._lock: + return self._current_layer_name + + def set_current_layer_name(self, value): + with self._lock: + print("[Layer_Name]: %s" % value) + self._current_layer_name = value + + +class ActiveImages(BlockingList): + """ + A list of active ids associated to images currently in use by the Instance Annotator + """ + + +class ImageCache(BlockingCache): + """ + A thread-safe cache of images that can be annotated by the Instance Annotator + """ + + def is_open(self, image_id): + with self._lock: + if image_id in self._cache: + return self._cache[image_id].is_open + return False + + +class ImageState: + """ + State associated with an image + """ + + def __init__(self, + id=0, + name="", + is_open=False, + is_locked=False, + unsaved=False, + image=None, + annotations=None): + self.id = id + self.name = name + self.unsaved = unsaved + self.is_open = is_open + self.is_locked = is_locked + + # State for opened images, should be none if unopened + self.image = image + self.shape = (0, 0, 0) # (width, height, depth) + self.annotations = annotations + + def get_unique_annotation_name(self): + name = uuid.uuid1().hex + return str(name) + + def detect_collisions(self, pos): + collisions = [] + for annotation in self.annotations.values(): + if annotation.collision(pos): + collisions.append(annotation) + return collisions + + +class AnnotationState: + """ + State associated with an annotation + """ + + def __init__( + self, + annotation_name, + class_name, + mat, + bbox, + mask_enabled=True, + bbox_enabled=True): + self.annotation_name = annotation_name + self.class_name = class_name + self.mat = mat # A BGR mat with 1,1,1 at labeled locations and 0,0,0 otherwise + self.bbox = bbox # (x1, y1, w, h) + self.mask_enabled = mask_enabled + self.bbox_enabled = bbox_enabled + + def collision(self, pos): + """ + Detects whether a point collides with this annotations bounding box + :param pos: position in the form (x,y) + :return True if collision occurs, False otherwise: + """ + bl = np.array(self.bbox[:2]) + tr = bl + np.array(self.bbox[2:]) + return np.all(np.logical_and(bl < pos, pos < tr)) + + +class LabelCache(BlockingCache): + """ + A thread-safe cache of class labels used by the Instance Annotator + """ + + +class LabelState: + """ + State associated with a class label + """ + + def __init__(self, name, color): + self.name = name + self.color = (np.array(color) / 255).tolist() \ No newline at end of file diff --git a/client/screens/__init__.py b/client/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/screens/common.py b/client/screens/common.py new file mode 100644 index 0000000..e4bf1f3 --- /dev/null +++ b/client/screens/common.py @@ -0,0 +1,149 @@ +import math +import os + +import numpy as np +from kivy.graphics import Color +from kivy.graphics import Rectangle, Fbo +from kivy.lang import Builder +from kivy.properties import ObjectProperty, StringProperty, NumericProperty +from kivy.uix.actionbar import ActionItem +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.button import Button +from kivy.uix.effectwidget import EffectBase +from kivy.uix.floatlayout import FloatLayout +from kivy.uix.gridlayout import GridLayout +from kivy.uix.popup import Popup +from kivy.uix.stacklayout import StackLayout +from kivy.uix.widget import Widget + +from client.client_config import ClientConfig + +# Load corresponding kivy file +Builder.load_file(os.path.join(ClientConfig.DATA_DIR, 'common.kv')) + + +class Alert(Popup): + alert_message = StringProperty('') + + +class LabelInput(BoxLayout): + text_field = ObjectProperty(None) + + +class ActionCustomButton(Button, ActionItem): + pass + + +class TileView(GridLayout): + tile_width = NumericProperty(0) + tile_height = NumericProperty(0) + + +class MouseDrawnTool(FloatLayout): + # Override these in child classes + def on_touch_down_hook(self, touch): + pass + + def on_touch_move_hook(self, touch): + pass + + def on_touch_up_hook(self, touch): + pass + + # Do not override these in child classes + def on_touch_down(self, touch): + self.on_touch_down_hook(touch) + return super(MouseDrawnTool, self).on_touch_down(touch) + + def on_touch_move(self, touch): + self.on_touch_move_hook(touch) + return super(MouseDrawnTool, self).on_touch_move(touch) + + def on_touch_up(self, touch): + self.on_touch_up_hook(touch) + return super(MouseDrawnTool, self).on_touch_up(touch) + + +class NumericInput(StackLayout): + decimal_places = NumericProperty(0) + value = NumericProperty(0) + min = NumericProperty(-float('inf')) + max = NumericProperty(float('inf')) + step = NumericProperty(1) + text_input = ObjectProperty(None) + + def validate_user_input(self): + try: + user_input = float(self.text_input.text) + except ValueError: + pass + else: + self.value = type(self.value)( + np.clip( + user_input, + self.min, + self.max)) + finally: + self.text_input.text = str(self.value) + + def increment(self, n): + # To accommodate for floating point division + x = round(self.value / self.step, 10) + # Round down to the nearest 'step size' + x = math.floor(x) * self.step + # Add n 'step size' + x = x + (n * self.step) + # Round to desired display decimal places + x = round(x, self.decimal_places) + # Clip to bounds + x = np.clip(x, self.min, self.max) + # Maintain type of value (int or float) + self.value = type(self.value)(x) + + +class TransparentBlackEffect(EffectBase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.glsl = """ + vec4 effect(vec4 color, sampler2D texture, vec2 tex_coords, vec2 coords) + { + if (color.r < 0.01 && color.g < 0.01 && color.b < 0.01) { + return vec4(0.0, 0.0, 0.0, 0.0); + } + return color; + } + """ + + +class PaintWindow(Widget): + fbo = ObjectProperty(None) + mask_layer = ObjectProperty(None) + color = ObjectProperty(None) + + def refresh(self): + with self.mask_layer.canvas: + self.color = Color([1, 0, 1, 1]) + self.fbo = Fbo(size=self.size) + Rectangle(size=self.size, texture=self.fbo.texture) + + def set_visible(self, visible=True): + self.mask_layer.canvas.opacity = float(visible) + + def update_color(self, color): + if self.color is None: + return + self.color.rgba = color + + def add_instruction(self, instruction): + self.fbo.add(instruction) + + def remove_instruction(self, instruction): + new_children = [x for x in self.fbo.children if x != instruction] + self.fbo.clear() + self.fbo.bind() + self.fbo.clear_buffer() + self.fbo.release() + for c in new_children: + self.fbo.add(c) + + self.fbo.draw() diff --git a/client/screens/image_view_screen.py b/client/screens/image_view_screen.py new file mode 100644 index 0000000..54912eb --- /dev/null +++ b/client/screens/image_view_screen.py @@ -0,0 +1,66 @@ +from kivy.app import App +from kivy.uix.screenmanager import Screen + +import client.utils as utils +from client.screens.common import * +from client.utils import ApiException +from client.utils import background +from kivy.clock import mainthread + +# Load corresponding kivy file +Builder.load_file( + os.path.join( + ClientConfig.DATA_DIR, + 'image_view_screen.kv')) + + +class Thumbnail(BoxLayout): + cust_texture = ObjectProperty(None) + + +class ImageViewScreen(Screen): + tile_view = ObjectProperty(None) + + def __init__(self, **kw): + super().__init__(**kw) + self.app = App.get_running_app() + + def on_enter(self, *args): + # TODO: Optimize to cache previously retrieved data + self.tile_view.clear_widgets() + print("Loading Images") + filter_details = { + "order_by": { + "key": "name", + "ascending": True + } + } + self._load_images(self.app.current_project_id, filter_details) + + @background + def _load_images(self, pid, filter_details): + resp = utils.get_project_images(pid, filter_details=filter_details) + if resp.status_code != 200: + raise ApiException( + "Failed to load project images from server.", + resp.status_code) + + result = resp.json() + + resp = utils.get_images_by_ids(result["ids"]) + if resp.status_code != 200: + raise ApiException( + "Failed to load project images from server.", + resp.status_code) + result = resp.json() + + for row in result["images"]: + img = utils.decode_image(row["image_data"]) + self.add_thumbnail(img) + + @mainthread + def add_thumbnail(self, image): + img = utils.bytes2texture(image, "jpg") + thumbnail = Thumbnail() + thumbnail.cust_texture = img + self.tile_view.add_widget(thumbnail) diff --git a/client/screens/instance_annotator_screen.py b/client/screens/instance_annotator_screen.py new file mode 100644 index 0000000..5ee7aac --- /dev/null +++ b/client/screens/instance_annotator_screen.py @@ -0,0 +1,1043 @@ +from threading import Lock + +import cv2 +import kivy.utils +from kivy.app import App +from kivy.clock import Clock +from kivy.clock import mainthread +from kivy.core.window import Window +from kivy.graphics import Ellipse, InstructionGroup, Line +from kivy.properties import BooleanProperty +from kivy.uix.relativelayout import RelativeLayout +from kivy.uix.screenmanager import Screen +from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem + +import client.utils as utils +from client.controller.instance_annotator_controller import InstanceAnnotatorController +from client.model.instance_annotator_model import InstanceAnnotatorModel +from client.screens.common import * +from client.utils import background + +# Load corresponding kivy file +Builder.load_file( + os.path.join( + ClientConfig.DATA_DIR, + 'instance_annotator_screen.kv')) + + +class InstanceAnnotatorScreen(Screen): + left_control = ObjectProperty(None) + right_control = ObjectProperty(None) + tab_panel = ObjectProperty(None) + + _update_lock = Lock() + _update_flag = False + + def __init__(self, **kw): + super().__init__(**kw) + self.app = App.get_running_app() + self.model = InstanceAnnotatorModel() + self.controller = InstanceAnnotatorController(self.model) + + def get_current_image_canvas(self): + if not isinstance(self.tab_panel.current_tab, ImageCanvasTab): + return None + return self.tab_panel.current_tab.image_canvas + + def queue_update(self): + with self._update_lock: + if not self._update_flag: + self._update_flag = True + self._update() + + @mainthread + def _update(self): + # TODO: Implement a diff system which only updates changed sections of + # the model + + current_iid = self.model.tool.get_current_image_id() + current_label_name = self.model.tool.get_current_label_name() + current_label = self.model.labels.get(current_label_name) + current_layer = self.model.tool.get_current_layer_name() + image = self.model.images.get(current_iid) + + # Update ToolSelect + print("Updating Tool Select") + self.left_control.tool_select.alpha.value = self.model.tool.get_alpha() + self.left_control.tool_select.pen_size.value = self.model.tool.get_pen_size() + + # Update Class Picker + print("Updating Class Picker") + label_names = self.model.labels.keys() + self.left_control.class_picker.clear() + for name in label_names: + label = self.model.labels.get(name) + self.left_control.class_picker.add_label(label.name, label.color) + + print("\tSelecting Label: %s" % current_label_name) + self.left_control.class_picker.select(current_label_name) + + # Update Layer View + print("Updating Layer View") + if image is not None and image.annotations is not None: + self.left_control.layer_view.clear() + for annotation in image.annotations.values(): + self.left_control.layer_view.add_layer_item(annotation) + + self.left_control.layer_view.select( + self.model.tool.get_current_layer_name()) + + # Update ImageCanvas + print("Updating Image Canvas") + if current_iid > 0 and not self.tab_panel.has_tab(current_iid): + self.tab_panel.add_tab(current_iid) + tab = self.tab_panel.get_tab(current_iid) + self.tab_panel.switch_to(tab, do_scroll=True) + image_canvas = self.get_current_image_canvas() + print(image_canvas.image.size) + + image_canvas = self.get_current_image_canvas() + if image_canvas is not None: + image_canvas.load_pen_size(self.model.tool.get_pen_size()) + image_canvas.load_global_alpha(self.model.tool.get_alpha()) + image_canvas.load_eraser_state(self.model.tool.get_eraser()) + image_canvas.load_current_label(current_label) + image_canvas.load_annotations(image.annotations) + image_canvas.load_current_layer(current_layer) + + if image is not None: + self.tab_panel.current_tab.unsaved = image.unsaved + + # Update ImageQueue + print("Updating Image Queue") + self.right_control.load_image_queue() + + # Reset update flag + with self._update_lock: + self._update_flag = False + + print("Update Save Button") + if image is not None and image.unsaved: + self.right_control.image_queue_control.btn_save.disabled = False + else: + self.right_control.image_queue_control.btn_save.disabled = True + + def on_enter(self, *args): + self.fetch_image_metas() + self.fetch_class_labels() + + @background + def load_next(self): + image_ids = self.model.images.keys() + current_id = self.model.tool.get_current_image_id() + idx = 0 + if current_id > 0: + idx = image_ids.index(current_id) + idx += 1 + + while self.model.images.get(image_ids[idx]).is_locked: + idx += 1 + next_id = image_ids[idx] + self.controller.open_image(next_id) + self.queue_update() + + @background + def load_image(self, id): + self.controller.open_image(id) + self.queue_update() + + @mainthread + def save_image(self): + image_canvas = self.get_current_image_canvas() + if image_canvas is None: + return + image_canvas.prepare_to_save() + self._save_image() + + @background + def _save_image(self): + image_canvas = self.get_current_image_canvas() + if image_canvas is None: + return + self.controller.save_image(image_canvas) + self.queue_update() + + @background + def add_layer(self): + self.controller.add_blank_layer(self.model.tool.get_current_image_id()) + self.queue_update() + + @background + def fetch_image_metas(self): + filter_details = { + "order_by": { + "key": "name", + "ascending": True + } + } + self.controller.fetch_image_metas( + self.app.current_project_id, filter_details) + self.queue_update() + + @background + def fetch_class_labels(self): + self.controller.fetch_class_labels(self.app.current_project_id) + if self.model.tool.get_current_label_name() is "": + self.controller.update_tool_state( + current_label=self.model.labels.keys()[0]) + self.queue_update() + + +class LeftControlColumn(BoxLayout): + tool_select = ObjectProperty(None) + class_picker = ObjectProperty(None) + layer_view = ObjectProperty(None) + + +class ToolSelect(GridLayout): + pen_size = ObjectProperty(None) + alpha = ObjectProperty(None) + + def __init__(self, **kw): + super().__init__(**kw) + self.app = App.get_running_app() + + def set_alpha(self, alpha): + print("Alpha: %s" % str(alpha)) + self.app.root.current_screen.controller.update_tool_state(alpha=alpha) + self.app.root.current_screen.queue_update() + + def set_pencil_size(self, size): + print("Pen Size: %s" % str(size)) + self.app.root.current_screen.controller.update_tool_state( + pen_size=size) + self.app.root.current_screen.queue_update() + + +class ClassPicker(GridLayout): + eraser_enabled = BooleanProperty(False) + current_label = ObjectProperty(None, allownone=True) + grid = ObjectProperty(None) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.app = App.get_running_app() + self.label_dict = {} + + def on_eraser_enabled(self, instance, value): + print("Eraser: %s" % str(value)) + self.app.root.current_screen.controller.update_tool_state(eraser=value) + + def on_current_label(self, instance, value): + class_name = "" + if value is not None: + class_name = value.class_name + + print("Label: %s" % str(class_name)) + self.app.root.current_screen.controller.update_tool_state( + current_label=class_name) + + if class_name not in ("", "eraser"): + self.app.root.current_screen.controller.update_annotation( + label_name=class_name) + self.app.root.current_screen.queue_update() + + def clear(self): + self.grid.clear_widgets() + self.label_dict.clear() + self.add_eraser() + + def select(self, name): + label = self.label_dict.get(name, None) + if label is None: + return + self._change_label(label) + + def add_eraser(self): + def eraser_enable(): + self.eraser_enabled = True + item.enable() + + def eraser_disable(): + self.eraser_enabled = False + item.disable() + + name = "eraser" + item = self._make_label(name, [0.2, 0.2, 0.2, 1.0]) + item.enable_cb = eraser_enable + item.disable_cb = eraser_disable + self.grid.add_widget(item) + self.label_dict[name] = item + + def add_label(self, name, color): + item = self._make_label(name, color) + self.grid.add_widget(item) + self.label_dict[name] = item + + def _make_label(self, name, color): + item = ClassPickerItem() + item.class_name = name + item.class_color = color + item.enable_cb = item.enable + item.disable_cb = item.disable + item.bind(on_release=lambda *args: self._change_label(item)) + return item + + def _change_label(self, instance): + self.eraser_enabled = False + if self.current_label: + self.current_label.disable_cb() + self.current_label = instance + self.current_label.enable_cb() + self.app.root.current_screen.queue_update() + + +class ClassPickerItem(Button): + class_color = ObjectProperty((0, 0, 0, 1)) + class_name = StringProperty("") + class_id = NumericProperty(-1) + + enable_cb = ObjectProperty(None) + disable_cb = ObjectProperty(None) + + def enable(self): + self.state = 'down' + + def disable(self): + self.state = 'normal' + + +class LayerView(GridLayout): + layer_item_layout = ObjectProperty(None) + + current_selection = ObjectProperty(None, allownone=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.app = App.get_running_app() + self.layers = {} + + def on_current_selection(self, instance, value): + layer_name = "" + if value is None: + print("Setting LayerView Layer to None") + else: + layer_name = value.layer_name + print("Layer: %s" % str(value)) + self.app.root.current_screen.controller.update_tool_state( + current_layer=layer_name) + + def clear(self): + self.layer_item_layout.clear_widgets() + self.layers.clear() + + def select(self, layer_name): + item = self.layers.get(layer_name, None) + if item is None: + return + self._change_layer(item) + + def add_layer_item(self, annotation): + item = LayerViewItem(annotation.annotation_name) + item.layer_select_cb = lambda: self._change_layer(item) + item.layer_delete_cb = lambda: self._delete_layer(item) + item.mask_enabled = annotation.mask_enabled + item.bbox_enabled = annotation.bbox_enabled + self.layer_item_layout.add_widget(item) + self.layers[annotation.annotation_name] = item + + def _change_layer(self, instance): + if self.current_selection: + self.current_selection.deselect() + self.current_selection = instance + self.current_selection.select() + self.app.root.current_screen.queue_update() + + def _delete_layer(self, instance): + if instance is self.current_selection: + self.current_selection = None + self.layer_item_layout.remove_widget(instance) + iid = self.app.root.current_screen.model.tool.get_current_image_id() + self.app.root.current_screen.controller.delete_layer( + iid, instance.layer_name) + self.app.root.current_screen.queue_update() + + +class LayerViewItem(RelativeLayout): + layer_name = StringProperty('') + mask_enabled = BooleanProperty(True) + bbox_enabled = BooleanProperty(True) + + layer_select_cb = ObjectProperty(None) + layer_delete_cb = ObjectProperty(None) + + btn_base = ObjectProperty(None) + btn_mask = ObjectProperty(None) + btn_bbox = ObjectProperty(None) + + button_down_color = ObjectProperty( + kivy.utils.get_color_from_hex( + ClientConfig.CLIENT_HIGHLIGHT_1)) + button_up_color = ObjectProperty( + kivy.utils.get_color_from_hex( + ClientConfig.CLIENT_DARK_3)) + + def __init__(self, name, **kw): + super().__init__(**kw) + self.app = App.get_running_app() + self.layer_name = name + + def on_mask_enabled(self, instance, value): + self.btn_mask.background_color = self.button_down_color if value else self.button_up_color + self.btn_mask.state = 'down' if value else 'normal' + self.app.root.current_screen.controller.update_annotation( + layer_name=self.layer_name, mask_enabled=value) + self.app.root.current_screen.queue_update() + + def on_bbox_enabled(self, instance, value): + self.btn_bbox.background_color = self.button_down_color if value else self.button_up_color + self.btn_bbox.state = 'down' if value else 'normal' + self.app.root.current_screen.controller.update_annotation( + layer_name=self.layer_name, bbox_enabled=value) + self.app.root.current_screen.queue_update() + + def select(self): + self.btn_base.background_color = self.button_down_color + self.btn_base.state = 'down' + + def deselect(self): + self.btn_base.background_color = self.button_up_color + self.btn_base.state = 'normal' + + +class MaskInstruction(InstructionGroup): + def __init__(self, pos, pen_size, negate=False, **kwargs): + super().__init__(**kwargs) + color = (1, 1, 1, 1) + if negate: + print("NEGATE!") + color = (0, 0, 0, 1) + self.color = Color(*color) + self.add(self.color) + self.circle = Ellipse( + pos=(pos[0] - + pen_size / 2, + pos[1] - + pen_size / 2), + size=(pen_size, + pen_size)) + self.add(self.circle) + self.line = Line( + points=pos, + cap='round', + joint='round', + width=pen_size / 2) + self.add(self.line) + + +class DrawTool(MouseDrawnTool): + layer = ObjectProperty(None, allownone=True) + pen_size = NumericProperty(10) + + erase = BooleanProperty(False) + + class Action: + def __init__(self, layer, group): + self.layer = layer + self.group = group + + def __init__(self, **kwargs): + self.app = App.get_running_app() + self.keyboard_shortcuts = {} + self.keycode_buffer = {} + self._keyboard = Window.request_keyboard(lambda: None, self) + self._consecutive_selects = 0 + + self.mask_stack = [] + self.delete_stack = [] + + self.bind_shortcuts() + super().__init__(**kwargs) + + def bind_shortcuts(self): + self.keyboard_shortcuts[("lctrl", "z")] = self.undo + self.keyboard_shortcuts[("lctrl", "y")] = self.redo + self.keyboard_shortcuts[("spacebar", + )] = self.app.root.current_screen.add_layer + + def bind_keyboard(self): + print("Binding keyboard") + self._keyboard.bind(on_key_down=self.on_key_down) + self._keyboard.bind(on_key_up=self.on_key_up) + + def unbind_keyboard(self): + self._keyboard.unbind(on_key_down=self.on_key_down) + self._keyboard.unbind(on_key_up=self.on_key_up) + + def on_key_down(self, keyboard, keycode, text, modifiers): + if keycode[1] in self.keycode_buffer: + return + print("DOWN: %s" % (str(keycode[1]))) + self.keycode_buffer[keycode[1]] = keycode[0] + + def on_key_up(self, keyboard, keycode): + print("UP: %s" % (str(keycode[1]))) + + for shortcut in self.keyboard_shortcuts.keys(): + if keycode[1] in shortcut and set( + shortcut).issubset(self.keycode_buffer): + self.keyboard_shortcuts[shortcut]() + + self.keycode_buffer.pop(keycode[1]) + + def undo(self): + if not self.mask_stack: + return + action = self.mask_stack.pop() + if action.layer.parent is None: + self.undo() + self.delete_stack.append(action) + action.layer.remove_instruction(action.group) + self.fit_bbox(layer=action.layer) + + screen = self.app.root.current_screen + iid = screen.model.tool.get_current_image_id() + screen.controller.update_annotation(iid, + layer_name=action.layer.layer_name, + bbox=action.layer.bbox_bounds) + + def redo(self): + if not self.delete_stack: + return + action = self.delete_stack.pop() + if action.layer.parent is None: + self.redo() + self.mask_stack.append(action) + action.layer.add_instruction(action.group) + self.fit_bbox(layer=action.layer) + + screen = self.app.root.current_screen + iid = screen.model.tool.get_current_image_id() + screen.controller.update_annotation(iid, + layer_name=action.layer.layer_name, + bbox=action.layer.bbox_bounds) + + def add_action(self, instruction_group): + self.layer.add_instruction(instruction_group) + self.mask_stack.append(DrawTool.Action(self.layer, instruction_group)) + self.delete_stack.clear() + + screen = self.app.root.current_screen + iid = screen.model.tool.get_current_image_id() + image = screen.model.images.get(iid) + if not image.unsaved: + screen.controller.update_image_meta(iid, unsaved=True) + screen.queue_update() + + def set_layer(self, layer): + print("Setting DrawTool Layer: %s" % layer.layer_name) + if self.layer is not None: + self.layer.set_bbox_highlight(active=False) + self.layer = layer + self.layer.set_bbox_highlight(active=True) + + def on_touch_down_hook(self, touch): + if not self.layer: + return + if 'lctrl' in self.keycode_buffer: + image_id = self.app.root.current_screen.model.tool.get_current_image_id() + image = self.app.root.current_screen.model.images.get(image_id) + select_items = image.detect_collisions(touch.pos) + if not select_items: + return + item = select_items[self._consecutive_selects % len(select_items)] + + screen = self.app.root.current_screen + screen.controller.update_tool_state( + current_layer=item.annotation_name) + screen.queue_update() + self._consecutive_selects += 1 + return + + if 'shift' in self.keycode_buffer: + self.flood_fill(touch.pos) + return + + pos = np.round(touch.pos).astype(int) + + self._consecutive_selects = 0 + + mask = MaskInstruction( + pos=list(pos), + pen_size=self.pen_size, + negate=self.erase) + + self.add_action(mask) + + def on_touch_move_hook(self, touch): + if not self.layer: + return + + if not self.mask_stack: + return + + pos = np.round(touch.pos).astype(int) + + action = self.mask_stack[-1] + action.group.line.points += list(pos) + + def on_touch_up_hook(self, touch): + if not self.layer: + return + + self.fit_bbox() + image_id = self.app.root.current_screen.model.tool.get_current_image_id() + layer_name = self.layer.layer_name + self.app.root.current_screen.controller.update_annotation( + image_id, layer_name, bbox=self.layer.bbox_bounds) + + def fit_bbox(self, layer=None): + if layer is None: + layer = self.layer + + fbo = layer.get_fbo() + + if fbo is None: + return + + fbo.draw() + mat_gray = np.sum( + utils.texture2mat(fbo.texture), + axis=2) + + col_sum = np.sum(mat_gray, axis=0) + x1 = 0 + x2 = len(col_sum) + for x in col_sum: + if x > 0: + break + x1 += 1 + + for x in reversed(col_sum): + if x > 0: + break + x2 -= 1 + + row_sum = np.sum(mat_gray, axis=1) + y1 = 0 + y2 = len(row_sum) + for x in reversed(row_sum): + if x > 0: + break + y1 += 1 + + for x in row_sum: + if x > 0: + break + y2 -= 1 + + bounds = [x1, y1, x2 - x1, y2 - y1] + if bounds[2] <= 0 or bounds[3] <= 0: + bounds = [0, 0, 0, 0] + + layer.bbox_bounds = bounds + + def flood_fill(self, pos): + print("FLOOD") + + fbo = self.layer.get_fbo() + if fbo is None: + return + + if np.sum(fbo.get_pixel_color(*pos)[:3]) > 0: + return + + bounds = self.layer.bbox_bounds + valid = bounds[0] < pos[0] < bounds[0] + \ + bounds[2] and bounds[1] < pos[1] < bounds[1] + bounds[3] + if not valid: + return + + region = fbo.texture.get_region(*self.layer.bbox_bounds) + relative_pos = np.array(pos) - np.array(self.layer.bbox_bounds[:2]) + + cv2_pos = np.round(relative_pos).astype(int) + + (width, height) = region.size + + mat = utils.texture2mat(region) + mat_copy = mat.copy() + mask = np.zeros((height + 2, width + 2), dtype=np.uint8) + cv2.floodFill(mat_copy, mask, tuple(cv2_pos), (255, 255, 255)) + + mat = np.clip(mat_copy - mat, 0, 255) + mat = cv2.cvtColor(mat, cv2.COLOR_BGR2RGBA) + mask = cv2.inRange(mat, (0, 0, 0, 255), (0, 0, 0, 255)) + mat[mask == 255] = 0 + # TODO: Allow eraser fill + g = InstructionGroup() + g.add(Color(1, 1, 1, 1)) + g.add(Rectangle(size=(width, height), + pos=tuple(self.layer.bbox_bounds[:2]), + texture=utils.mat2texture(mat))) + self.add_action(g) + + +class LayerStack(FloatLayout): + layer_view = ObjectProperty(None) + layer_sizes = ObjectProperty(None) + + alpha = NumericProperty(0) + + def __init__(self, **kw): + super().__init__(**kw) + self.app = App.get_running_app() + self.layer_dict = {} + + def set_alpha(self, alpha): + if np.isclose(alpha, self.alpha): + return + self.alpha = alpha + for layer in self.layer_dict.values(): + print("Layer Color: %s" % str(layer.mask_color)) + new_color = layer.get_mask_color() + new_color[3] = float(alpha) + layer.set_mask_color(new_color) + + def add_layer(self, layer): + print("Adding Layer to Stack") + new_color = layer.get_mask_color() + new_color[3] = float(self.alpha) + layer.set_mask_color(new_color) + + self.add_widget(layer) + self.layer_dict[layer.layer_name] = layer + + def get_layer(self, name): + return self.layer_dict.get(name, None) + + def get_all_layers(self): + return self.layer_dict.values() + + def remove_layer(self, layer): + self.remove_widget(layer) + self.layer_dict.pop(layer.layer_name, None) + + def clear(self): + print("Clearing Stack") + self.layer_dict = {} + self.clear_widgets() + + +class DrawableLayer(FloatLayout): + layer_name = StringProperty("") + class_name = StringProperty("") + + """ A bounding rectangle represented in the form (x, y, width, height)""" + bbox_bounds = ObjectProperty([0, 0, 0, 0]) + + bbox_visible = BooleanProperty(True) + mask_visible = BooleanProperty(True) + + # fbo = ObjectProperty(None) + bbox_layer = ObjectProperty(None) + bbox_color = ObjectProperty( + kivy.utils.get_color_from_hex( + ClientConfig.BBOX_UNSELECT)) + bbox_thickness = NumericProperty(1) + + def __init__(self, + layer_name, + texture, + class_name="", + mask_color=(1, 1, 1, 1), + bbox=None, + **kwargs): + super().__init__(**kwargs) + self.layer_name = layer_name + self.texture = texture + self.size = self.texture.size + self.class_name = class_name + self._mask_color = list(mask_color) + self.mask = None + + if bbox is not None: + self.bbox_bounds = bbox + + Clock.schedule_once(lambda dt: self.late_init()) + + def late_init(self): + self.paint_window.refresh() + self.load_texture(self.texture) + self.set_mask_color(self._mask_color) + + def load_texture(self, texture): + if self.texture: + g = InstructionGroup() + g.add(Color(1, 1, 1, 1)) + g.add(Rectangle(size=self.get_fbo().size, texture=texture)) + self.paint_window.add_instruction(g) + + def prepare_matrix(self): + self.mask = utils.mat2mask(utils.texture2mat(self.get_fbo().texture)) + + def set_mask_color(self, color): + print("New Mask Color: %s" % color) + self._mask_color = color + self.paint_window.update_color(color) + + def get_mask_color(self): + return self._mask_color + + def update_label(self, label): + if label is None: + return + self.class_name = label.name + new_color = label.color[:3] + [self.get_mask_color()[3], ] + self.set_mask_color(new_color) + + def update_bbox(self, bbox): + self.bbox_bounds = bbox + + def set_bbox_highlight(self, active=True): + if active: + self.bbox_color = kivy.utils.get_color_from_hex( + ClientConfig.BBOX_SELECT) + else: + self.bbox_color = kivy.utils.get_color_from_hex( + ClientConfig.BBOX_UNSELECT) + + def get_fbo(self): + return self.paint_window.fbo + + def add_instruction(self, instruction): + self.paint_window.add_instruction(instruction) + + def remove_instruction(self, instruction): + self.paint_window.remove_instruction(instruction) + + def set_mask_visible(self, visible=True): + self.paint_window.set_visible(visible) + + def set_bbox_visible(self, visible=True): + self.bbox_layer.canvas.opacity = float(visible) + + +class ImageCanvasTabPanel(TabbedPanel): + def __init__(self, **kwargs): + self.app = App.get_running_app() + super().__init__(**kwargs) + + def get_tab(self, iid): + for tab in self.tab_list: + if not isinstance(tab, ImageCanvasTab): + continue + if tab.image_canvas.image_id == iid: + return tab + return None + + def has_tab(self, iid): + return self.get_tab(iid) is not None + + def add_tab(self, iid): + image = self.app.root.current_screen.model.images.get(iid) + # Add Tab + Load everything + tab = ImageCanvasTab(image.name) + tab.image_canvas.load_image(image) + tab.image_canvas.load_annotations(image.annotations, overwrite=True) + self.add_widget(tab) + + def switch_to(self, header, do_scroll=False): + if not isinstance(header, ImageCanvasTab): + return + if isinstance(self.current_tab, ImageCanvasTab): + self.current_tab.image_canvas.draw_tool.unbind_keyboard() + + header.image_canvas.draw_tool.bind_keyboard() + + screen = self.app.root.current_screen + screen.controller.update_tool_state( + current_iid=header.image_canvas.image_id) + screen.queue_update() + return super(ImageCanvasTabPanel, self).switch_to(header, do_scroll) + + +class ImageCanvasTab(TabbedPanelItem): + image_canvas = ObjectProperty(None) + tab_name = StringProperty("") + unsaved = BooleanProperty(False) + + def __init__(self, name, **kwargs): + self.tab_name = name + super().__init__(**kwargs) + + def get_iid(self): + return self.image_canvas.image_id + + +class ImageCanvas(BoxLayout): + scatter = ObjectProperty(None) + image = ObjectProperty(None) + image_id = NumericProperty(-1) + unsaved = BooleanProperty(False) + draw_tool = ObjectProperty(None) + layer_stack = ObjectProperty(None) + + max_scale = NumericProperty(10.0) + min_scale = NumericProperty(0.5) + step_scale = NumericProperty(0.1) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.app = App.get_running_app() + + def prepare_to_save(self): + # Note: This method must be run on the main thread + for layer in self.layer_stack.get_all_layers(): + layer.prepare_matrix() + + def load_save_status(self, unsaved): + self.unsaved = unsaved + + def load_pen_size(self, size): + self.draw_tool.pen_size = size + + def load_global_alpha(self, alpha): + self.layer_stack.set_alpha(alpha) + + def load_eraser_state(self, eraser): + self.draw_tool.erase = eraser + + def load_current_label(self, label): + if label is None or self.draw_tool.layer is None: + return + new_color = self.draw_tool.layer.get_mask_color() + new_color[:3] = label.color[:3] + self.draw_tool.layer.set_mask_color(new_color) + + def load_current_layer(self, layer_name): + print("Loading Current Layer: %s" % layer_name) + layer = self.layer_stack.get_layer(layer_name) + if layer is None: + return + self.draw_tool.set_layer(layer) + + def load_image(self, image_state): + if image_state is None: + return + print("Loading Image") + self.image_id = image_state.id + texture = utils.mat2texture(image_state.image) + self.image.texture = texture + self.image.size = image_state.shape[1::-1] + + def load_annotations(self, annotations, overwrite=False): + print("Loading Annotations") + + if annotations is None: + return + + if overwrite: + self.layer_stack.clear() + + active_layers = [x.layer_name for x in self.layer_stack.get_all_layers()] + active_annotations = [x.annotation_name for x in annotations.values()] + + for layer_name in active_layers: + if layer_name not in active_annotations: + self.layer_stack.remove_layer(self.layer_stack.get_layer(layer_name)) + + for annotation in annotations.values(): + layer = self.layer_stack.get_layer(annotation.annotation_name) + label = self.app.root.current_screen.model.labels.get( + annotation.class_name) + if overwrite or layer is None: + layer = DrawableLayer( + layer_name=annotation.annotation_name, + size=annotation.mat.shape[1::-1], + texture=utils.mat2texture(annotation.mat)) + self.layer_stack.add_layer(layer) + layer.update_label(label) + layer.update_bbox(annotation.bbox) + layer.set_mask_visible(annotation.mask_enabled) + layer.set_bbox_visible(annotation.bbox_enabled) + + def on_touch_down(self, touch): + if 'lctrl' in self.draw_tool.keycode_buffer and touch.is_mouse_scrolling: + if touch.button == 'scrolldown': + self.zoom(1.0 + self.step_scale) + elif touch.button == 'scrollup': + self.zoom(1.0 - self.step_scale) + super(ImageCanvas, self).on_touch_down(touch) + + def zoom(self, scale): + print("pos: %s size: %s" % (str(self.scatter.pos), + str(self.scatter.size))) + self.scatter.scale = np.clip(self.scatter.scale * scale, + self.min_scale, + self.max_scale) + self.scatter.pos = self.pos + + +class RightControlColumn(BoxLayout): + image_queue = ObjectProperty(None) + image_queue_control = ObjectProperty(None) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.app = App.get_running_app() + + def load_image_queue(self): + self.image_queue.clear() + image_ids = self.app.root.current_screen.model.images.keys() + for iid in image_ids: + image = self.app.root.current_screen.model.images.get(iid) + self.image_queue.add_item(image.name, + iid, + locked=image.is_locked, + opened=image.is_open) + + +class ImageQueueControl(GridLayout): + btn_save = ObjectProperty(None) + + +class ImageQueue(GridLayout): + queue = ObjectProperty(None) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.app = App.get_running_app() + self.queue_item_dict = {} + + def clear(self): + self.queue.clear_widgets() + self.queue_item_dict.clear() + + def add_item(self, name, image_id, locked=False, opened=False): + item = ImageQueueItem() + item.image_name = name + item.image_id = image_id + item.set_status(lock=locked, opened=opened) + self.queue.add_widget(item) + self.queue_item_dict[image_id] = item + + +class ImageQueueItem(BoxLayout): + image_name = StringProperty("") + image_id = NumericProperty(0) + button_color = ObjectProperty( + kivy.utils.get_color_from_hex( + ClientConfig.CLIENT_DARK_3)) + image_open = BooleanProperty(False) + image_locked = BooleanProperty(False) + + def set_status(self, opened=False, lock=False): + self.image_open = opened + self.image_locked = lock + if opened: + self.button_color = kivy.utils.get_color_from_hex( + ClientConfig.CLIENT_HIGHLIGHT_1) + else: + self.button_color = kivy.utils.get_color_from_hex( + ClientConfig.CLIENT_DARK_3) diff --git a/client/screens/project_select_screen.py b/client/screens/project_select_screen.py new file mode 100644 index 0000000..53f4605 --- /dev/null +++ b/client/screens/project_select_screen.py @@ -0,0 +1,186 @@ +from datetime import datetime + +import dateutil.parser +from kivy.app import App +from kivy.clock import mainthread +from kivy.uix.screenmanager import Screen + +import client.utils as utils +from client.screens.common import * +from client.utils import ApiException +from client.utils import background + +# Load corresponding kivy file +Builder.load_file( + os.path.join( + ClientConfig.DATA_DIR, + 'project_select_screen.kv')) + + +class DeleteProjectPopup(Popup): + title = StringProperty("") + message = StringProperty("") + confirmation_callback = ObjectProperty(None) + + +class AddProjectPopup(Popup): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.app = App.get_running_app() + + @background + def add_project(self, project_name): + resp = utils.add_projects(project_name) + + if resp.status_code == 200: + result = resp.json() + msg = [] + for row in result["results"]: + msg.append(row["error"]["message"]) + msg = '\n'.join(msg) + raise ApiException( + message="The following errors occurred while trying to add project '%s':\n %s" % + (project_name, msg), code=resp.status_code) + elif resp.status_code != 201: + raise ApiException("Failed to add project '%s'." % project_name) + + result = resp.json() + pvw = App.get_running_app().root.current_screen.project_view_window + pvw.refresh_projects() + self.dismiss() + + +class ProjectSelectScreen(Screen): + project_view_window = ObjectProperty(None) + control_bar = ObjectProperty(None) + + def __init__(self, **kw): + super().__init__(**kw) + self.app = App.get_running_app() + + def on_enter(self, *args): + self.project_view_window.refresh_projects() + + +class ControlBar(BoxLayout): + @mainthread + def open_add_project_popup(self): + pop_up = AddProjectPopup() + pop_up.open() + + def trigger_project_refresh(self): + self.parent.parent.ids.project_view_window.refresh_projects() + + +class ProjectViewWindow(TileView): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.app = App.get_running_app() + + @mainthread + def add_card( + self, + name, + id, + image, + total_images, + labeled_images, + last_update_time): + card = ProjectCard() + card.project_name = name + card.project_id = id + card.image = image + card.total_images = total_images + card.labeled_images = labeled_images + card.last_update_time = last_update_time + self.add_widget(card) + card.format_last_updated_label() + + @mainthread + def remove_card(self, card): + self.remove_widget(card) + + @background + def refresh_projects(self): + resp = utils.get_projects() + if resp.status_code != 200: + raise ApiException( + "Failed to refresh project list", + resp.status_code) + + result = resp.json() + self.clear_widgets() + for project in result["projects"]: + total = project['labeled_count'] + project['unlabeled_count'] + self.add_card( + project['name'], + project['id'], + "IMAGE", + total, + project['labeled_count'], + dateutil.parser.parse(project['last_uploaded'])) + + +class ProjectCard(BoxLayout): + project_name = StringProperty('') + project_id = NumericProperty(0) + image = StringProperty('') + total_images = NumericProperty(0) + labeled_images = NumericProperty(0) + last_update_time = ObjectProperty(None) + last_update_label = ObjectProperty(None) + + def confirm_delete_project(self): + pop_up = DeleteProjectPopup() + pop_up.title = "Delete '%s' Project" % self.project_name + pop_up.message = "Are you sure you want to Delete the '%s' project?" % self.project_name + pop_up.confirmation_callback = self.delete_card + pop_up.open() + + def format_last_updated_label(self): + delta = datetime.utcnow() - self.last_update_time + seconds = delta.total_seconds() + + if seconds < 0: + seconds = 0 + + time_dict = {} + time_dict['year'] = seconds // ClientConfig.SECONDS_PER_YEAR + time_dict['month'] = ( + seconds % + ClientConfig.SECONDS_PER_YEAR) // ClientConfig.SECONDS_PER_MONTH + time_dict['day'] = ( + seconds % + ClientConfig.SECONDS_PER_MONTH) // ClientConfig.SECONDS_PER_DAY + time_dict['hour'] = ( + seconds % + ClientConfig.SECONDS_PER_DAY) // ClientConfig.SECONDS_PER_HOUR + time_dict['minute'] = ( + seconds % + ClientConfig.SECONDS_PER_HOUR) // ClientConfig.SECONDS_PER_MINUTE + time_dict['second'] = seconds % ClientConfig.SECONDS_PER_MINUTE + + time = 0 + time_unit = "seconds" + for key in time_dict: + if time_dict[key] > 0: + time = time_dict[key] + time_unit = key + if time_dict[key] > 1: + time_unit += "s" + break + + self.last_update_label.text = 'Updated [b][color=%s]%d %s[/color][/b] ago' % ( + ClientConfig.CLIENT_HIGHLIGHT_1, time, time_unit) + + @background + def delete_card(self): + pid = self.project_id + resp = utils.delete_project(pid) + if resp.status_code != 200: + raise ApiException( + "Failed to delete project with id %d" % + pid, resp.status_code) + + pvw = App.get_running_app().root.current_screen.project_view_window + pvw.remove_card(self) diff --git a/client/screens/project_tool_screen.py b/client/screens/project_tool_screen.py new file mode 100644 index 0000000..c3176c8 --- /dev/null +++ b/client/screens/project_tool_screen.py @@ -0,0 +1,58 @@ +import tkinter as tk +from tkinter import filedialog + +from kivy.app import App +from kivy.uix.screenmanager import Screen + +import client.utils as utils +from client.screens.common import * +from client.utils import ApiException +from client.utils import background +from definitions import ROOT_DIR + +# Load corresponding kivy file +Builder.load_file( + os.path.join( + ClientConfig.DATA_DIR, + 'project_tool_screen.kv')) + + +class ProjectToolScreen(Screen): + def __init__(self, **kw): + super().__init__(**kw) + self.app = App.get_running_app() + + def upload_images(self, *args): + root = tk.Tk() + root.withdraw() + filepath = filedialog.askdirectory(initialdir=ROOT_DIR) + image_paths = [] + if isinstance(filepath, str): + for (root, _, filename) in os.walk(filepath): + for f in filename: + # tkinter does not return windows style filepath + image_paths.append(root + '/' + f) + self._upload_images(self.app.current_project_id, image_paths) + + @background + def _upload_images(self, pid, image_paths): + resp = utils.add_project_images(pid, image_paths) + if resp.status_code == 200: + result = resp.json() + msg = [] + for row in result["results"]: + if "error" in row: + msg.append(row["error"]["message"]) + msg = '\n'.join(msg) + raise ApiException( + message="The following errors occurred while trying to upload images:\n %s" % + (msg,), code=resp.status_code) + elif resp.status_code != 201: + raise ApiException( + "Failed to upload images to project.", + resp.status_code) + else: + pop_up = Alert() + pop_up.title = "Success!" + pop_up.alert_message = "Successfully added new images to the '%s' project" % self.app.current_project_name + pop_up.open() diff --git a/client/simple_client.py b/client/simple_client.py new file mode 100644 index 0000000..28e0a6e --- /dev/null +++ b/client/simple_client.py @@ -0,0 +1,28 @@ +from kivy.app import App +from kivy.uix.screenmanager import ScreenManager + +from client.screens.simple_screen import SimpleScreen + + +class MyScreenManager(ScreenManager): + """ + Defines the screens which should be included in the ScreenManager at launch. + Use the ScreenManager to handle transitions between Screens. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_widget(SimpleScreen()) + + +class SimpleClientApp(App): + """ + The launch point for the application itself. + """ + + def build(self): + return MyScreenManager() + + +if __name__ == "__main__": + SimpleClientApp().run() diff --git a/client/utils.py b/client/utils.py new file mode 100644 index 0000000..3c100f5 --- /dev/null +++ b/client/utils.py @@ -0,0 +1,260 @@ +""" +A utility file containing common methods +""" +import base64 +import io +import json +import os + +import cv2 +import numpy as np +import requests +from PIL import Image +from kivy.app import App +from kivy.graphics.texture import Texture +from kivy.uix.image import CoreImage + +from client.client_config import ClientConfig + + +class ApiException(Exception): + def __init__(self, message, code): + self.message = message + self.code = code + + def __str__(self): + return "ApiException, %s" % self.message + + +def background(f): + def aux(*xs, **kws): + app = App.get_running_app() + future = app.thread_pool.submit(f, *xs, **kws) + future.add_done_callback(app.alert_user) + return future + return aux + + +def get_project_by_id(id): + url = ClientConfig.SERVER_URL + "projects/" + str(id) + headers = {"Accept": "application/json"} + return requests.get(url, headers=headers) + + +def get_projects(): + url = ClientConfig.SERVER_URL + "projects" + headers = {"Accept": "application/json"} + return requests.get(url, headers=headers) + + +def add_projects(names): + if not isinstance(names, list): + names = [names] + + body = [] + for n in names: + body.append({'name': n}) + + payload = json.dumps({"projects": body}) + url = ClientConfig.SERVER_URL + "projects" + headers = {"Content-Type": "application/json"} + return requests.post(url, headers=headers, data=payload) + + +def delete_project(id): + url = ClientConfig.SERVER_URL + "projects/" + str(id) + return requests.delete(url) + + +def add_project_images(project_id, image_paths): + if not isinstance(image_paths, list): + image_paths = [image_paths] + + body = [] + for path in image_paths: + filename = os.path.basename(path) + + ext = "." + str(filename.split('.')[-1]) + filename = '.'.join(filename.split('.')[:-1]) + + body.append({'name': filename, 'ext': ext, + 'image_data': encode_image(path)}) + + payload = json.dumps({'images': body}) + url = ClientConfig.SERVER_URL + "projects/" + str(project_id) + "/images" + headers = {"Accept": "application/json", + "Content-Type": "application/json"} + return requests.post(url, headers=headers, data=payload) + + +def get_project_images(project_id, filter_details=None): + if not filter_details: + filter_details = {} + + payload = json.dumps(filter_details) + url = ClientConfig.SERVER_URL + "projects/" + \ + str(project_id) + "/images" + headers = {"Accept": "application/json", + "Content-Type": "application/json"} + + return requests.get(url, headers=headers, data=payload) + + +def update_image_meta_by_id(image_id, name=None, lock=None, labeled=None): + image_meta = {} + if name is not None: + image_meta["name"] = str(name) + if lock is not None: + image_meta["is_locked"] = bool(lock) + if labeled is not None: + image_meta["is_labeled"] = bool(labeled) + + payload = json.dumps(image_meta) + + url = ClientConfig.SERVER_URL + "images/" + str(image_id) + headers = {"Accept": "application/json", + "Content-Type": "application/json"} + + return requests.put(url, headers=headers, data=payload) + + +def get_image_by_id(image_id): + url = ClientConfig.SERVER_URL + "images/" + str(image_id) + headers = {"Accept": "application/json"} + + return requests.get(url, headers=headers) + + +def get_images_by_ids(image_ids): + url = ClientConfig.SERVER_URL + "images" + headers = {"Accept": "application/json", + "Content-Type": "application/json"} + body = {"ids": image_ids} + + payload = json.dumps(body) + + return requests.get(url, headers=headers, data=payload) + + +def get_image_metas_by_ids(image_ids): + url = ClientConfig.SERVER_URL + "images?image-data=False" + headers = {"Accept": "application/json", + "Content-Type": "application/json"} + body = {"ids": image_ids} + payload = json.dumps(body) + + return requests.get(url, headers=headers, data=payload) + + +def add_image_annotation(image_id, annotations): + url = ClientConfig.SERVER_URL + "images/" + str(image_id) + "/annotation" + headers = {"Accept": "application/json", + "Content-Type": "application/json"} + payload = {"image_id": image_id, "annotations": []} + for annotation in annotations.values(): + body = { + 'name': annotation.annotation_name, + 'mask_data': encode_mask(mat2mask(annotation.mat)), + 'bbox': np.array(annotation.bbox).tolist(), + 'class_name': annotation.class_name, + 'shape': annotation.mat.shape} + payload["annotations"].append(body) + + payload = json.dumps(payload) + + return requests.post(url, headers=headers, data=payload) + + +def delete_image_annotation(image_id, on_success=None, on_fail=None): + url = ClientConfig.SERVER_URL + "images/" + str(image_id) + "/annotation" + return requests.delete(url) + + +def get_image_annotation(image_id, on_success=None, on_fail=None): + url = ClientConfig.SERVER_URL + "images/" + str(image_id) + "/annotation" + headers = {"Accept": "application/json"} + return requests.get(url, headers=headers) + + +# ====================== +# === Helper methods === +# ====================== + +def encode_image(img_path): + with open(img_path, "rb") as img_file: + encoded_image = base64.b64encode(img_file.read()) + return encoded_image.decode('utf-8') + + +def decode_image(b64_str): + img_bytes_b64 = b64_str.encode('utf-8') + return base64.b64decode(img_bytes_b64) + + +# Takes Boolean mask -> bytes +def encode_mask(mask): + encoded_mask = base64.b64encode(mask.tobytes(order='C')) + return encoded_mask.decode('utf-8') + + +# Takes bytes -> Boolean Mask +def decode_mask(b64_str, shape): + mask_bytes = base64.b64decode(b64_str.encode("utf-8")) + flat = np.fromstring(mask_bytes, bool) + return np.reshape(flat, newshape=shape[:2], order='C') + + +def mask2mat(mask): + mat = mask.astype(np.uint8) * 255 + return cv2.cvtColor(mat, cv2.COLOR_GRAY2BGR) + + +def mat2mask(mat): + return np.sum(mat.astype(bool), axis=2, dtype=bool) + + +def bytes2mat(bytes): + nparr = np.fromstring(bytes, np.uint8) + return cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + +def mat2bytes(mat, ext): + buf = cv2.imencode(ext, mat) + return buf[1].tostring() + + +def bytes2texture(bytes, ext): + data = io.BytesIO(bytes) + return CoreImage(data, ext=ext).texture + + +def texture2bytes(texture): + pil_image = Image.frombytes( + mode='RGBA', + size=texture.size, + data=texture.pixels) + pil_image = pil_image.convert('RGB') + b = io.BytesIO() + pil_image.save(b, 'jpeg') + return b.getvalue() + + +def mat2texture(mat): + if mat.shape[-1] is not 4: + mat = cv2.flip(mat, 0) + mat = cv2.cvtColor(mat, cv2.COLOR_BGR2RGBA) + buf = mat.tostring() + tex = Texture.create(size=(mat.shape[1], mat.shape[0]), colorfmt='rgba') + tex.blit_buffer(buf, colorfmt='rgba', bufferfmt='ubyte') + return tex + + +def texture2mat(texture): + pil_image = Image.frombytes( + mode='RGBA', + size=texture.size, + data=texture.pixels) + + mat = np.array(pil_image) + mat = cv2.flip(mat, 0) + return cv2.cvtColor(mat, cv2.COLOR_RGBA2BGR) diff --git a/client_requirements.txt b/client_requirements.txt new file mode 100644 index 0000000..6a556ee --- /dev/null +++ b/client_requirements.txt @@ -0,0 +1,25 @@ +certifi==2020.6.20 +chardet==3.0.4 +cycler==0.10.0 +decorator==4.4.2 +docutils==0.16 +idna==2.10 +imageio==2.9.0 +Kivy==1.11.1 +Kivy-Garden==0.1.4 +kiwisolver==1.2.0 +matplotlib==3.2.2 +networkx==2.4 +numpy==1.19.0 +opencv-python==4.3.0.36 +Pillow==7.2.0 +Pygments==2.6.1 +pyparsing==2.4.7 +python-dateutil==2.8.1 +PyWavelets==1.1.1 +requests==2.24.0 +scikit-image==0.17.2 +scipy==1.5.1 +six==1.15.0 +tifffile==2020.7.4 +urllib3==1.25.9 diff --git a/database/DATA/README b/database/DATA/README new file mode 100644 index 0000000..51561dc --- /dev/null +++ b/database/DATA/README @@ -0,0 +1,3 @@ +This is a placeholder folder used by the server to store files, which are referenced by the SQL Database. + +Do not delete files from this folder without updating the appropriate database tables as it could result in a corrupt state. diff --git a/database/create_database.sql b/database/create_database.sql new file mode 100644 index 0000000..b6ad86c --- /dev/null +++ b/database/create_database.sql @@ -0,0 +1,58 @@ +CREATE DATABASE IF NOT EXISTS `fadb` +USE `fadb`; + +-- +-- Table structure for table `image` +-- + +DROP TABLE IF EXISTS `image`; +CREATE TABLE `image` ( + `image_id` int NOT NULL AUTO_INCREMENT, + `project_fid` int NOT NULL, + `image_path` varchar(260) NOT NULL, + `image_name` varchar(260) NOT NULL, + `image_ext` varchar(10) NOT NULL, + `is_locked` bit(1) NOT NULL DEFAULT b'0', + `is_labeled` bit(1) NOT NULL DEFAULT b'0', + PRIMARY KEY (`image_id`), + UNIQUE KEY `image_id_UNIQUE` (`image_id`), + UNIQUE KEY `image_path_UNIQUE` (`image_path`), + KEY `project_id_idx` (`project_fid`), + CONSTRAINT `project_id` FOREIGN KEY (`project_fid`) REFERENCES `project` (`project_id`) +) ENGINE=InnoDB AUTO_INCREMENT=428 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- +-- Table structure for table `instance_seg_meta` +-- + +DROP TABLE IF EXISTS `instance_seg_meta`; +CREATE TABLE `instance_seg_meta` ( + `annotation_id` int NOT NULL AUTO_INCREMENT, + `image_id` int NOT NULL, + `annotation_name` varchar(45) NOT NULL, + `mask_path` varchar(260) NOT NULL, + `info_path` varchar(260) NOT NULL, + `class_name` varchar(45) NOT NULL, + PRIMARY KEY (`annotation_id`), + UNIQUE KEY `mask_path_UNIQUE` (`mask_path`), + UNIQUE KEY `info_path_UNIQUE` (`info_path`), + UNIQUE KEY `annotation_id_UNIQUE` (`annotation_id`), + KEY `image_fid_idx` (`image_id`), + CONSTRAINT `image_fid` FOREIGN KEY (`image_id`) REFERENCES `image` (`image_id`) +) ENGINE=InnoDB AUTO_INCREMENT=137 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- +-- Table structure for table `project` +-- + +DROP TABLE IF EXISTS `project`; +CREATE TABLE `project` ( + `project_id` int NOT NULL AUTO_INCREMENT, + `project_name` varchar(80) NOT NULL, + `last_uploaded` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `unlabeled_count` int NOT NULL DEFAULT '0', + `labeled_count` int NOT NULL DEFAULT '0', + PRIMARY KEY (`project_id`), + UNIQUE KEY `project_id_UNIQUE` (`project_id`), + UNIQUE KEY `project_name_UNIQUE` (`project_name`) +) ENGINE=InnoDB AUTO_INCREMENT=119 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; \ No newline at end of file diff --git a/database/create_test_data.sql b/database/create_test_data.sql new file mode 100644 index 0000000..d7aca64 --- /dev/null +++ b/database/create_test_data.sql @@ -0,0 +1,2 @@ +-- Optional script to populate tables with test data. Run after create_database.sql +INSERT INTO `project` VALUES (116,'default','2020-04-14 04:31:49',32,0); diff --git a/database/database.py b/database/database.py new file mode 100644 index 0000000..6f3a178 --- /dev/null +++ b/database/database.py @@ -0,0 +1,52 @@ +import mysql.connector +from mysql.connector import pooling +from mysql.connector.errors import InterfaceError +from mysql.connector.errors import PoolError + +import time + + +class Database: + def __init__(self, config): + self.config = config + self.db_config = { + 'host': self.config.DATABASE_HOST, + 'user': self.config.DATABASE_USER, + 'database': self.config.DATABASE_NAME, + 'password': self.config.DATABASE_PASSWORD, + 'autocommit': True, + 'time_zone': self.config.DATABASE_TIMEZONE + } + + self.db_pool = mysql.connector.pooling.MySQLConnectionPool( + pool_name="db_pool", pool_size=self.config.DATABASE_POOL_SIZE, **self.db_config) + + def query(self, query_string, params=None, timeout=3): + t0 = time.time() + t1 = t0 + + connection = None + while (t1 - t0) < timeout: + t1 = time.time() + try: + connection = self.db_pool.get_connection() + except PoolError: + print("Retrying Connection..") + time.sleep(1) + continue + else: + break + print("Connection Made") + + cursor = connection.cursor(dictionary=True) + try: + cursor.execute(query_string, params) + try: + result = cursor.fetchall() + except InterfaceError as ex: + result = [] + id = cursor.lastrowid + finally: + cursor.close() + connection.close() + return result, id diff --git a/definitions.py b/definitions.py new file mode 100644 index 0000000..1267de0 --- /dev/null +++ b/definitions.py @@ -0,0 +1,3 @@ +import os + +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f28dca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.3' +services: + db: + image: mysql:8 + restart: always + environment: + MYSQL_DATABASE: 'fadb' + MYSQL_ROOT_PASSWORD: 'root' + ports: + - '3306:3306' + expose: + - '3306' + volumes: + - my-db:/var/lib/mysql +# Names our volume +volumes: + my-db: \ No newline at end of file diff --git a/server/apis/__init__.py b/server/apis/__init__.py new file mode 100644 index 0000000..ba3faed --- /dev/null +++ b/server/apis/__init__.py @@ -0,0 +1,14 @@ +from flask_restplus import Api + +from .project import api as project_api +from .image import api as image_api + +api = Api( + title='FastAnnotation API', + version='1.0', + description='The annotation handling API for the FastAnnotation tool.', +) + + +api.add_namespace(project_api) +api.add_namespace(image_api) \ No newline at end of file diff --git a/server/apis/image.py b/server/apis/image.py new file mode 100644 index 0000000..c808e25 --- /dev/null +++ b/server/apis/image.py @@ -0,0 +1,495 @@ +import base64 +import os + +from flask import request +from flask_restplus import Namespace, Resource, fields, marshal +from mysql.connector.errors import DatabaseError + +import server.utils as utils +from server.core.common_dtos import common_store +from server.server_config import DatabaseInstance +from server.server_config import ServerConfig + +api = Namespace('images', description='Image related operations') + +db = DatabaseInstance() + +api.models.update(common_store.get_dtos()) + +image = api.model( + 'image', + { + 'id': fields.Integer( + attribute='image_id', + required=False, + description='The image identifier'), + 'name': fields.String( + attribute='image_name', + required=False, + description='The image name', + example="image_123"), + 'ext': fields.String( + attribute='image_ext', + required=False, + description="The file extension of the image", + example=".jpg"), + 'is_locked': fields.Boolean( + required=False, + description="A flag indicating whether the image is locked"), + 'is_labeled': fields.Boolean( + required=False, + description="A flag indicating whether the image is labeled"), + 'image_data': fields.String( + required=False, + description="The encoded image data")}) + +bulk_images = api.model('bulk_images', { + 'images': fields.List(fields.Nested(image)) +}) + + +annotation = api.model('annotation', { + 'id': fields.Integer( + attribute="annotation_id", + required=False, + description="The annotation identifier"), + 'name': fields.String( + attribute="annotation_name", + required=True, + description="The name of the annotation", + example="Layer 1"), + 'mask_data': fields.String( + required=True, + description="The encoded mask data"), + 'bbox': fields.List( + fields.Integer, + required=True, + description="The bounding box coordinates", + min_items=4, + max_items=4, + example=[0, 0, 200, 200]), + 'shape': fields.List( + fields.Integer, + required=True, + description="The dimensions of the image in pixels", + min_items=3, + max_items=3, + example=[1920, 1080, 3]), + 'class_name': fields.String( + required=True, + description="The name of the class associated with this annotation", + example="class_1")}) + +bulk_annotations = api.model('bulk_annotations', { + 'image_id': fields.Integer( + required=True, + description="The identifier for the image associated with the attached annotations"), + 'annotations': fields.List(fields.Nested(annotation)) +}) + +bulk_image_request = api.model('bulk_image_request', {'ids': fields.List( + fields.Integer, required=True, description="The list of image ids to retrieve")}) + + +@api.route("") +class ImageList(Resource): + @api.response(200, "OK", bulk_images) + @api.response(400, "Invalid Payload") + @api.response(500, "Unexpected Failure", api.models["generic_response"]) + @api.expect(bulk_image_request) + @api.param( + 'image-data', + description='A flag indicating whether image data is required', + type='boolean') + def get(self): + """ + A bulk operation for retrieving images by id. + """ + content = request.json + + image_data_flag = request.args.get('image-data') + if image_data_flag is None or image_data_flag.lower() not in ("true", "false"): + image_data_flag = True + else: + image_data_flag = image_data_flag.lower() == "true" + + query = "SELECT image_id, image_path, image_name, image_ext, is_locked, is_labeled FROM image " + query += "WHERE image_id IN " + query += "(%s)" % ",".join(str(x) for x in content["ids"]) + + try: + result = db.query(query)[0] + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 400, + "message": e.msg + } + } + code = 400 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + images = [] + for row in result: + if not image_data_flag: + pass + elif row["image_ext"].lower() in (".jpg", ".jpeg", ".png"): + with open(row["image_path"], "rb") as img_file: + encoded_image = base64.b64encode(img_file.read()) + row["image_data"] = encoded_image.decode('utf-8') + images.append(row) + response = {"images": images} + code = 200 + + if code == 200: + return marshal(response, bulk_images), code + else: + return marshal(response, api.models["generic_response"]), code + + +@api.doc(params={"iid": "An id associated with an existing image."}) +@api.route("/") +class Image(Resource): + @api.response(200, "OK", image) + @api.response(404, "Resource Not Found", api.models["generic_response"]) + @api.response(500, "Unexpected Failure", api.models["generic_response"]) + def get(self, iid): + """ + Gets an image as referenced by its identifier. + """ + query = "SELECT image_id, image_path, image_name, image_ext, is_locked, is_labeled FROM image " + query += "WHERE image_id = %s" + + try: + result = db.query(query, (iid,))[0] + response = result[0] + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 500 + except IndexError: + response = { + "action": "failed", + "error": { + "code": 404, + "message": "Resource not found." + } + } + code = 404 + + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + if response["image_ext"] == ".jpg": + with open(response["image_path"], "rb") as img_file: + encoded_image = base64.b64encode(img_file.read()) + response["image_data"] = encoded_image.decode('utf-8') + code = 200 + + if code == 200: + return marshal(response, image, skip_none=True), code + else: + return marshal( + response, api.models["generic_response"], skip_none=True), code + + @api.response(200, "OK", api.models["generic_response"]) + @api.response(400, "Invalid Payload", api.models["generic_response"]) + @api.response(500, "Unexpected Failure", api.models["generic_response"]) + @api.marshal_with(api.models["generic_response"], skip_none=True) + @api.expect(image) + def put(self, iid): + """ + Update an images meta parameters. + """ + + #TODO: Update to raise 409 when lock is requested on already locked object + + content = request.json + + query = "UPDATE image SET" + params = [] + if "name" in content: + query += " image_name = %s" + params.append(content["name"]) + if "ext" in content: + query += " image_ext = %s" + params.append(content["ext"]) + if "is_locked" in content: + query += " is_locked = %s" + params.append(content["is_locked"]) + if "is_labeled" in content: + query += " is_labeled = %s" + params.append(content["is_labeled"]) + + if not params: + response = { + "action": "failed", + "error": { + "code": 400, + "message": "No valid parameters provided for update." + } + } + return response, 400 + + query += " WHERE image_id = %s" + params.append(iid) + try: + db.query(query, tuple(params)) + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 500 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + response = { + "action": "updated", + "id": iid + } + code = 200 + return response, code + + @api.response(200, "OK", api.models["generic_response"]) + @api.response(500, "Unexpected Failure", api.models["generic_response"]) + @api.marshal_with(api.models["generic_response"], skip_none=True) + def delete(self, iid): + """ + Delete an image as referenced by its identifier. + """ + + q_delete_annotations = "DELETE from instance_seg_meta WHERE image_id = %s" + query = "DELETE from image WHERE image_id = %s" + + try: + db.query(q_delete_annotations, (iid,)) + db.query(query, (iid,)) + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 500 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + response = { + "action": "deleted", + "id": iid + } + code = 200 + return response, code + + +@api.doc(params={"iid": "An id associated with an existing image"}) +@api.route("//annotation") +class ImageAnnotationList(Resource): + @api.response(200, "OK", bulk_annotations) + @api.response(500, "Unexpected Failure", api.models["generic_response"]) + def get(self, iid): + """ + Gets all the annotations associated with an image. + """ + + query = "SELECT annotation_id, annotation_name, mask_path, info_path, class_name FROM instance_seg_meta " + query += "WHERE image_id = %s" + try: + result = db.query(query, (iid,))[0] + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 500 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + response = [] + for row in result: + mask = utils.load_mask(row["mask_path"]) + info = utils.load_info(row["info_path"]) + row["mask_data"] = utils.encode_mask(mask) + + row["shape"] = info["source_shape"] + row["bbox"] = info["bbox"] + + print("SERVER: outgoing bbox") + print("\t%s" % str(row["bbox"])) + response.append(row) + + response = {"image_id": iid, "annotations": response} + code = 200 + if code == 200: + return marshal(response, bulk_annotations, skip_none=True), code + else: + return marshal( + response, api.models["generic_response"], skip_none=True), code + + @api.response(200, "Partial Success", api.models["bulk_response"]) + @api.response(201, "Success", api.models["bulk_response"]) + @api.marshal_with(api.models["bulk_response"], skip_none=True) + @api.expect(bulk_annotations) + def post(self, iid): + """ + A bulk operation for adding annotations to an image. + """ + + content = request.json + + query = "DELETE FROM instance_seg_meta WHERE image_id = %s" + try: + db.query(query, (iid,)) + except BaseException: + pass + + i = 0 + code = 201 + results = [] + for row in content["annotations"]: + try: + mask = utils.decode_mask(row['mask_data'], row['shape']) + + print("SERVER: incoming bbox") + print("\t%s" % str(row["bbox"])) + + mask_path = os.path.join( + ServerConfig.DATA_ROOT_DIR, + "annotation", + str(iid), + "trimaps", + "%s.png" % row["name"]) + info_path = os.path.join( + ServerConfig.DATA_ROOT_DIR, + "annotation", + str(iid), + "xmls", + "%s.xml" % row["name"]) + + query = "REPLACE INTO instance_seg_meta (annotation_name, image_id, mask_path, info_path, class_name)" + query += " VALUES (%s,%s,%s,%s,%s)" + _, aid = db.query( + query, (row['name'], iid, mask_path, info_path, row["class_name"])) + + utils.save_mask(mask, mask_path) + utils.save_info( + shape=row["shape"], + bbox=row["bbox"], + class_name=row["class_name"], + filepath=info_path) + i += 1 + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 400, + "message": e.msg + } + } + results.append(response) + code = 200 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + results.append(response) + code = 200 + else: + response = { + "action": "created", + "id": aid + } + results.append(response) + + return {"results": results}, code + + @api.response(200, "OK", api.models["generic_response"]) + @api.response(500, "Unexpected Failure", api.models["generic_response"]) + @api.marshal_with(api.models["generic_response"], skip_none=True) + def delete(self, iid): + """ + Deletes all annotations from an image. + """ + query = "DELETE FROM instance_seg_meta WHERE image_id = %s" + try: + db.query(query, (iid,)) + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 500 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + response = { + "action": "deleted", + "id": iid + } + code = 200 + return response, code diff --git a/server/apis/project.py b/server/apis/project.py new file mode 100644 index 0000000..d18beff --- /dev/null +++ b/server/apis/project.py @@ -0,0 +1,371 @@ +import base64 +import os +from pathlib import Path + +import cv2 +import numpy as np +from flask import request +from flask_restplus import Namespace, Resource, fields +from mysql.connector.errors import DatabaseError + +from server.core.common_dtos import common_store +from server.server_config import DatabaseInstance +from server.server_config import ServerConfig + +api = Namespace('projects', description='Project related operations') + +db = DatabaseInstance() + +api.models.update(common_store.get_dtos()) + +project = api.model('project', { + 'id': fields.Integer(attribute='project_id', required=False, description='The project identifier'), + 'name': fields.String(attribute='project_name', required=True, description='The project name'), + 'labeled_count': fields.Integer(required=False, description="The number of labeled images in the project"), + 'unlabeled_count': fields.Integer(required=False, description="The number of unlabeled images in the project"), + 'last_uploaded': fields.DateTime(required=False, description="The datetime when this project was last uploaded") +}) + +project_bulk = api.model('project_bulk', { + 'projects': fields.List(fields.Nested(project)) +}) + +order_by = api.model('order_by', { + 'key': fields.String( + required=False, + default="id", + enum=["id", "name"], + description="The key on which to order"), + 'ascending': fields.Boolean( + required=False, + default=True, + description="Indicates whether ascending or descending ordering should be used.")}) + +image_filter = api.model( + 'image_filter', { + 'locked': fields.Boolean( + required=False, + description="An optional flag for filtering locked images"), + 'labeled': fields.Boolean( + required=False, + description="An optional flag for filtering labeled images"), + 'order_by': fields.Nested( + order_by, + required=True, + default={}, + description="An optional flag for ordering images")}) + +image_upload = api.model('image_upload', { + 'name': fields.String(required=True, description='The image name'), + 'ext': fields.String(required=True, description="The file extension of the image"), + 'image_data': fields.String(required=True, description="The encoded image data") +}) + +bulk_image_upload = api.model('bulk_image_upload', { + 'images': fields.List(fields.Nested(image_upload), required=True) +}) + + +@api.route("") +class ProjectList(Resource): + @api.response(200, "OK", project_bulk) + @api.marshal_with(project_bulk, skip_none=True) + def get(self): + """ + Get a list of all available projects + """ + results = db.query( + "SELECT project_id, project_name, labeled_count, unlabeled_count, last_uploaded FROM project")[0] + return {"projects": results}, 200 + + @api.response(200, "Partial Success", api.models['bulk_response']) + @api.response(201, "Success", api.models['bulk_response']) + @api.marshal_with(api.models['bulk_response'], skip_none=True) + @api.expect(project_bulk) + def post(self): + """ + A bulk operation for creating projects + """ + content = request.json["projects"] + + code = 201 + bulk_response = [] + + for row in content: + query = "INSERT INTO project (project_name) " + query += "VALUES (%s);" + + try: + _, id = db.query(query, (row["name"],)) + except DatabaseError as e: + result = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + bulk_response.append(result) + code = 200 + except BaseException as e: + result = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + bulk_response.append(result) + code = 200 + else: + result = { + "action": "created", + "id": id + } + bulk_response.append(result) + + return {"results": bulk_response}, code + + +@api.doc(params={"pid": "An id associated with an existing project."}) +@api.route("/") +class Project(Resource): + @api.response(200, "OK", project) + @api.marshal_with(project, skip_none=True) + def get(self, pid): + """ + Get a project by its identifier. + """ + query = "SELECT project_id, project_name, labeled_count, unlabeled_count, last_uploaded " + query += "from project " + query += "WHERE project_id = %s" + results = db.query(query, (pid,))[0] + return results, 200 + + @api.response(200, "OK", api.models["generic_response"]) + @api.response(500, "Unexpected Failure", api.models["generic_response"]) + @api.marshal_with(api.models["generic_response"], skip_none=True) + def delete(self, pid): + """ + Delete a project by its identifier. All associated images and annotations will also be deleted. + """ + q_delete_annotation = "DELETE FROM instance_seg_meta WHERE image_id IN" + q_delete_annotation += " (SELECT image_id from image where project_fid = %s)" + q_delete_images = "DELETE FROM image WHERE project_fid = %s" + + query = "DELETE FROM project WHERE project_id = %s" + code = 200 + try: + db.query(q_delete_annotation, (pid,)) + db.query(q_delete_images, (pid,)) + db.query(query, (pid,)) + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 500 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + response = { + "action": "deleted", + "id": pid + } + return response, code + + +@api.doc(params={"pid": "An id associated with a project."}) +@api.route("//images") +class ProjectImageList(Resource): + @api.response(200, "OK", api.models['generic_response']) + @api.response(500, "Unexpected Failure", api.models['generic_response']) + @api.marshal_with(api.models['generic_response'], skip_none=True) + @api.expect(image_filter) + def get(self, pid): + """ + Get the images associated with the a project as referenced by its identifier. + """ + content = request.json + + query = "SELECT image_id FROM fadb.image " + query += "WHERE project_fid = %s" + + if "locked" in content: + query += " and is_locked = " + str(content["locked"]) + + if "labeled" in content: + query += " and is_labeled = " + str(content["labeled"]) + + valid_order_by = True + if content["order_by"]["key"] == "id": + query += " ORDER BY image_id" + elif content["order_by"]["key"] == "name": + query += " ORDER BY image_name" + else: + valid_order_by = False + + if valid_order_by: + query += " asc" if content["order_by"]["ascending"] else " desc" + + code = 200 + try: + results, _ = db.query(query, (pid,)) + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 500 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + response = { + "action": "read", + "ids": [row['image_id'] for row in results] + } + return response, code + + @api.response(200, "Partial Success", api.models['bulk_response']) + @api.response(201, "Success", api.models['bulk_response']) + @api.expect(bulk_image_upload) + @api.marshal_with(api.models['bulk_response'], skip_none=True) + def post(self, pid): + """ + A bulk operation for adding images to a project as referenced by its identifier. + """ + content = request.json["images"] + + code = 201 + + success_count = 0 + bulk_response = [] + for row in content: + array = np.fromstring( + base64.b64decode( + row["image_data"]), + np.uint8) + img = cv2.imdecode(array, cv2.IMREAD_COLOR) + img_dir = os.path.join( + ServerConfig.DATA_ROOT_DIR, + "images", + str(pid)) + Path(img_dir).mkdir(parents=True, exist_ok=True) + img_path = os.path.join( + img_dir, + row["name"] + + ServerConfig.DEFAULT_IMAGE_EXT) + + query = "INSERT INTO image (project_fid, image_path, image_name, image_ext) " + query += "VALUES (%s, %s, %s, %s);" + try: + _, id = db.query( + query, (pid, img_path, row["name"], ServerConfig.DEFAULT_IMAGE_EXT)) + cv2.imwrite(img_path, img) + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 200 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 200 + else: + response = { + "action": "created", + "id": id + } + success_count += 1 + bulk_response.append(response) + + # Increment unlabeled image count + if success_count > 0: + query = "UPDATE project SET unlabeled_count = unlabeled_count + %s WHERE project_id = %s" + try: + db.query(query, (success_count, pid)) + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + bulk_response.append(response) + code = 200 + return {"results": bulk_response}, code + + @api.response(200, "OK", api.models['generic_response']) + @api.response(500, "Unexpected Failure", api.models['generic_response']) + @api.marshal_with(api.models['generic_response'], skip_none=True) + def delete(self, pid): + """ + Deletes all images associated with this project as referenced by its identifier. + """ + + q_get_image_ids = "SELECT image_id FROM image WHERE project_fid = %s" + q_delete_annotations = "DELETE from instance_seg_meta WHERE image_id IN (" + q_delete_annotations += q_get_image_ids + q_delete_annotations += ")" + query = "DELETE FROM image WHERE project_fid = %s" + + try: + results, _ = db.query(q_get_image_ids, (pid,)) + db.query(q_delete_annotations, (pid,)) + db.query(query, (pid,)) + except DatabaseError as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": e.msg + } + } + code = 500 + except BaseException as e: + response = { + "action": "failed", + "error": { + "code": 500, + "message": str(e) + } + } + code = 500 + else: + response = { + "action": "deleted", + "ids": [x[0] for x in results] + } + code = 200 + + return response, code diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..3860c4f --- /dev/null +++ b/server/app.py @@ -0,0 +1,10 @@ +from flask import Flask + +from server.apis import api + +app = Flask(__name__) +app.config['RESTPLUS_VALIDATE'] = True +app.config['RESTPLUS_MASK_SWAGGER'] = False + +api.init_app(app) +app.run(debug=True, port="5001") diff --git a/server/core/__init__.py b/server/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/core/common_dtos.py b/server/core/common_dtos.py new file mode 100644 index 0000000..1453d24 --- /dev/null +++ b/server/core/common_dtos.py @@ -0,0 +1,42 @@ +from flask_restplus import fields, Model +from server.core.dto_store import DtoStore + +common_store = DtoStore() + +error_model = Model('error_response', { + 'code': fields.Integer( + required=False, + description="An optional error code associated with the failure", + example="404"), + 'message': fields.String( + required=False, + description="A message describing the nature of the failure", + example="Resource not found." + )}) +common_store.add_dto(error_model) + +generic_model = Model('generic_response', { + 'action': fields.String( + required=True, + description="The action performed on the bulk item", + enum=["created", "read", "updated", "deleted", "failed"], + example="failed"), + 'error': fields.Nested( + error_model, + required=False, + skip_none=True, + description="An optional error message"), + 'id': fields.Integer( + required=False, + description="An optional identifier"), + 'ids': fields.List( + fields.Integer, + required=False, + description="An optional list of identifiers") +}) + +common_store.add_dto(generic_model) + +common_store.add_dto(Model('bulk_response', { + 'results': fields.List(fields.Nested(generic_model, skip_none=True)) +})) diff --git a/server/core/dto_store.py b/server/core/dto_store.py new file mode 100644 index 0000000..8a33b03 --- /dev/null +++ b/server/core/dto_store.py @@ -0,0 +1,12 @@ +class DtoStore: + def __init__(self): + self.dto_dict = {} + + def add_dto(self, model): + self.dto_dict[model.name] = model + + def get_dtos(self): + return self.dto_dict + + def get_dto(self, name): + return self.dto_dict[name] \ No newline at end of file diff --git a/server/server_config.py b/server/server_config.py new file mode 100644 index 0000000..1ef04de --- /dev/null +++ b/server/server_config.py @@ -0,0 +1,43 @@ +import os + +from database.database import Database +from definitions import ROOT_DIR + + +class ServerConfig: + DATABASE_HOST = "127.0.0.1" + DATABASE_USER = "root" + DATABASE_PASSWORD = "root" + DATABASE_NAME = "fadb" + DATABASE_POOL_SIZE = 3 + DATABASE_TIMEZONE = '+00:00' + + DATA_ROOT_DIR = os.path.join(ROOT_DIR, "database", "DATA") + XML_TEMPLATE_PATH = os.path.join(ROOT_DIR, "server", "template.xml") + DEFAULT_IMAGE_EXT = ".jpg" + + # Used to white list filter combinations for Project Images + IMAGE_FILTER_MAP = { + "locked": { + True: "is_locked = 1", + False: "is_locked = 0" + }, + "labelled": { + True: "is_labeled = 1", + False: "is_labeled = 0" + } + } + + IMAGE_ORDER_BY_MAP = { + "name": "image_name", + "id": "image_id" + } + + +class DatabaseInstance: + __instance = None + + def __new__(cls): + if DatabaseInstance.__instance is None: + DatabaseInstance.__instance = Database(ServerConfig()) + return DatabaseInstance.__instance diff --git a/server/template.xml b/server/template.xml new file mode 100644 index 0000000..ae5e7ad --- /dev/null +++ b/server/template.xml @@ -0,0 +1,28 @@ + + + jpgs + + OXIIIT Custom + OXIIIT Bespoke + Self + + + 0 + 0 + 0 + + + + name + Frontal + 0 + 0 + + 0 + 0 + 0 + 0 + + 0 + + diff --git a/server/utils.py b/server/utils.py new file mode 100644 index 0000000..e6c3475 --- /dev/null +++ b/server/utils.py @@ -0,0 +1,99 @@ +import base64 +import os +import xml.etree.ElementTree as ET +from xml.dom import minidom +from pathlib import Path + +import cv2 +import numpy as np +from server.server_config import ServerConfig + + +def encode_mask(mask): + encoded_mask = base64.b64encode(mask.tobytes(order='C')) + return encoded_mask.decode('utf-8') + + +def decode_mask(b64_str, shape): + mask_bytes = base64.b64decode(b64_str.encode("utf-8")) + flat = np.fromstring(mask_bytes, bool) + return np.reshape(flat, newshape=shape[:2], order='C') + + +def mask2mat(mask): + mat = mask.astype(np.uint8) * 255 + return cv2.cvtColor(mat, cv2.COLOR_GRAY2BGR) + + +def mat2mask(mat): + return np.sum(mat.astype(bool), axis=2, dtype=bool) + + +def save_mask(mask, filepath): + folder = os.path.dirname(filepath) + Path(folder).mkdir(parents=True, exist_ok=True) + mask = mask.astype(np.uint8) * 255 + cv2.imwrite(filepath, mask) + + +def load_mask(filepath): + mask = cv2.imread(filepath) + mask = np.all(mask.astype(bool), axis=2) + return mask + + +def save_info(shape, bbox, class_name, filepath): + create_info_file(filepath) + + root = ET.parse(filepath).getroot() + + obj = root.find('size') + obj.find('width').text = str(shape[0]) + obj.find('height').text = str(shape[1]) + obj.find('depth').text = str(shape[2]) + + obj = root.find('object') + obj.find("name").text = class_name + + obj.find("bndbox/xmin").text = str(bbox[0]) + obj.find("bndbox/ymin").text = str(bbox[1]) + obj.find("bndbox/xmax").text = str(bbox[2]) + obj.find("bndbox/ymax").text = str(bbox[3]) + + + xmlstr = minidom.parseString(ET.tostring(root)).toprettyxml(indent=" ") + xmlstr = os.linesep.join([s for s in xmlstr.splitlines() if s.strip()]) + with open(filepath, 'w') as f: + f.write(xmlstr) + + +def load_info(filepath): + root = ET.parse(filepath).getroot() + obj = root.find('size') + width = int(obj.find("width").text) + height = int(obj.find("height").text) + depth = int(obj.find("depth").text) + + obj = root.find('object') + class_name = obj.find("name").text + + xmin = int(obj.find("bndbox/xmin").text) + ymin = int(obj.find("bndbox/ymin").text) + xmax = int(obj.find("bndbox/xmax").text) + ymax = int(obj.find("bndbox/ymax").text) + + info = { + "bbox": (xmin, ymin, xmax, ymax), + "source_shape": (width, height, depth), + "class_name": class_name + } + return info + + +def create_info_file(filepath): + folder = os.path.dirname(filepath) + Path(folder).mkdir(parents=True, exist_ok=True) + with open(ServerConfig.XML_TEMPLATE_PATH) as f_in: + with open(filepath, "w") as f_out: + for line in f_in: + f_out.write(line) \ No newline at end of file diff --git a/server_requirements.txt b/server_requirements.txt new file mode 100644 index 0000000..e592474 --- /dev/null +++ b/server_requirements.txt @@ -0,0 +1,21 @@ +aniso8601==8.0.0 +attrs==19.3.0 +click==7.1.2 +Flask==1.1.2 +Flask-Negotiate==0.1.0 +flask-restplus==0.13.0 +importlib-metadata==1.7.0 +itsdangerous==1.1.0 +Jinja2==2.11.2 +jsonschema==3.2.0 +MarkupSafe==1.1.1 +mysql-connector-python==8.0.21 +numpy==1.19.0 +opencv-python==4.3.0.36 +protobuf==3.12.2 +pyrsistent==0.16.0 +python-dateutil==2.8.1 +pytz==2020.1 +six==1.15.0 +Werkzeug==0.16.1 +zipp==3.1.0