diff --git a/atlas_server/atlaslib b/atlas_server/atlaslib index 4b4ff334c7b03f7ec8727a70ac79a4503ec15b9a..8baf242b517cbffb2bf5916b21059bc5bc8af534 160000 --- a/atlas_server/atlaslib +++ b/atlas_server/atlaslib @@ -1 +1 @@ -Subproject commit 4b4ff334c7b03f7ec8727a70ac79a4503ec15b9a +Subproject commit 8baf242b517cbffb2bf5916b21059bc5bc8af534 diff --git a/atlas_server/src/app.py b/atlas_server/src/app.py index acbd640a2eb90b56e8d0e7bf98408a8d8d3871a2..5d2de6699ac4743eca30056bbe7d0c3df815e658 100644 --- a/atlas_server/src/app.py +++ b/atlas_server/src/app.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -from flask import Flask +from flask import Flask, request from flask_restx import Api, Resource, fields, abort from . import models @@ -19,6 +19,7 @@ api = Api(app, # ----------------------------------- project_namespace = api.namespace("projects", description="Operations to deal with projects") +annotation_namespace = api.namespace("annotations", description="Operations to deal with annotations") # ----------------------------------- # Model definitions @@ -420,6 +421,102 @@ class TaskTileServerConfig(Resource): return base_file_content +@annotation_namespace.route("/<host_base64>/<user>/<path_base64>") +class AnnotationImportExport(Resource): + @annotation_namespace.doc("Retrieve annotations for given host, user and path") + def get(self, host_base64, user, path_base64): + import base64 + from .microdraw_db import MicrodrawDb + + # Test data: + # Host: aW1lZHYwMi5pbWUua2ZhLWp1ZWxpY2guZGU= + # Path: ZGF0YS9icmFpbm1hcC9CMjAuanNvbg== + + # Decode host and path. They are encoded, as the may + # contain invalid signs. + try: + host = base64.b64decode(host_base64).decode("utf-8") + path = base64.b64decode(path_base64).decode("utf-8") + except Exception as ex: + abort(500, f"Error while decoding parameters: {ex}") + return + + # Initiate connection to microdraw database + try: + db = MicrodrawDb(host=host) + except Exception as ex: + abort(500, f"Error while connecting to database at {host}: {ex}") + return + + try: + annotations = db.export_annotations(json_path=path, + user_name=user) + except Exception as ex: + abort(500, f"Error while retrieving annotations: {ex}") + return + + return { + "host": host, + "user": user, + "path": path, + "annotations": annotations, + } + + @annotation_namespace.doc("Import annotations to a given host, user and path") + @annotation_namespace.expect(api.model("Dummy", {"dummy": fields.String})) + def post(self, host_base64, user, path_base64): + import base64 + from atlaslib.annotations import Annotations, parse_microdraw_json + from .microdraw_db import MicrodrawDb + + # Test data: + # Host: aW1lZHYwMi5pbWUua2ZhLWp1ZWxpY2guZGU= + # Localhost: YXRsYXN1aV9taWNyb2RyYXctbWFyaWFkYl8x + # Path: ZGF0YS9icmFpbm1hcC9CMjAuanNvbg== + + # Decode host and path. They are encoded, as the may + # contain invalid signs. + try: + host = base64.b64decode(host_base64).decode("utf-8") + path = base64.b64decode(path_base64).decode("utf-8") + except Exception as ex: + abort(500, f"Error while decoding parameters: {ex}") + return + + try: + annotations = api.payload + except Exception as ex: + abort(500, f"Error while decoding annotations: {ex}") + return + + # Initiate connection to microdraw database + try: + db = MicrodrawDb(host=host) + except Exception as ex: + abort(500, f"Error while connecting to database at {host}: {ex}") + return + + # Import annotations + try: + overwrite = request.args.get("overwrite", False) + for section, structures in annotations.items(): + structures = parse_microdraw_json(structures) + annotations_for_section = Annotations(structures=structures, section=int(section)) + db.import_annotations(json_path=path, + user_name=user, + annotations=annotations_for_section, + overwrite=overwrite) + except Exception as ex: + abort(500, f"Error importing annotations into database: {ex}") + return + + return { + "host": host, + "user": user, + "overwrite": overwrite, + "status": "ok", + } + # ----------------------------------- # Main entry point # ----------------------------------- diff --git a/atlas_server/src/microdraw_db.py b/atlas_server/src/microdraw_db.py new file mode 100644 index 0000000000000000000000000000000000000000..bb22aff231b6191bed8f6b6866e2197dbeafd945 --- /dev/null +++ b/atlas_server/src/microdraw_db.py @@ -0,0 +1,188 @@ +""" +Based on annotation_db/microdraw.py +""" + +try: + import MySQLdb as MySQLdb +except ImportError: + import pymysql as MySQLdb + +import json +import sys + +assert sys.version_info[0] == 3, "WARNING: Python 3 is required to handle umlaute correctly." + + +class MicrodrawDb(object): + def __init__(self, host): + self.host = host + self.db = MySQLdb.connect(host=self.host, + user="microdraw", + passwd="microdraw", + db="Interact") + self.cursor = self.db.cursor() + + def export_annotations(self, json_path, user_name, sections=None): + available_sections = self.get_available_slices(json_path=json_path, + user_name=user_name) + if sections is None: + sections = available_sections + else: + available_sections = set(available_sections) + sections = [s for s in sections if s in available_sections] + + annotation_per_section = {} + # Query data for each section individually + for section in sections: + origin = json.dumps({"appName": "microdraw", + "source": json_path, + # Important: Slice number must be string! + "slice": f"{section:04d}", + "user": user_name}, sort_keys=True, separators=(",", ":")) + + annotation_per_section[int(section)] = self.get_latest_regions(origin) + return annotation_per_section + + def import_annotations(self, json_path, user_name, annotations, overwrite=False): + import random + from datetime import datetime + from atlaslib.annotations import serialize_structures + + section_str = f"{annotations.section:04d}" + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + origin = {"appName": "microdraw", + "source": json_path, + "slice": section_str, + "user": user_name} + origin = json.dumps(origin, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False) + # Serialize annotations to microdraw compatible format + annotation_data = serialize_structures(structures=annotations.structures, + colors=annotations.colors) + # Annotation data has to provide a hash, otherwise saving in microdraw does not work + dummy_hash = f"dummy_hash_{random.randint(1, 999999999):09d}" + annotation_data["Hash"] = dummy_hash + + sql_query = None + if not overwrite: + # Check if annotations already exist for this key + unique_id, existing_annotations = self.get_latest_annotations(origin, return_id=True) + if existing_annotations: + # Update the existing annotations with the new annotations + def _update_field(field_name): + if field_name in existing_annotations and field_name in annotation_data: + existing_annotations[field_name].extend(annotation_data[field_name]) + elif field_name in annotation_data: + existing_annotations[field_name] = annotation_data[field_name] + + _update_field("Regions") + _update_field("Points") + _update_field("Text") + + # Update hash, so saving works properly + existing_annotations["Hash"] = dummy_hash + + # Prepare update SQL statement + existing_annotations = json.dumps(existing_annotations, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False) + sql_query = f"UPDATE KeyValue SET myValue = '{existing_annotations}' WHERE UniqueID = {unique_id}" + # Prevent insertion + insert_new = False + else: + # No existing annotations, insert new data + insert_new = True + else: + # Just insert as new data, ignoring existing data + insert_new = True + + if insert_new: + # Just insert into the database, overwriting existing annotations + value = json.dumps(annotation_data, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + sql_query = f"INSERT INTO KeyValue (myTimestamp, myOrigin, myKey, myValue) values ('{timestamp}', '{origin}', 'regionPaths', '{value}')" + + # Perform insert operation + self.cursor.execute(sql_query) + self.db.commit() + + def get_available_slices(self, json_path, user_name): + # Wildcard to find all annotations for given JSON path and username + origin = "%" + json_path + "%" + user_name + "%" + sql_query = f"SELECT DISTINCT myOrigin FROM Interact.KeyValue WHERE myOrigin like '{origin}' AND myKey = 'regionPaths'" + self.cursor.execute(sql_query) + res = self.cursor.fetchall() + sources = [] + + for r in res: + parsed = json.loads(r[0]) + # is this in the new format? + if "slice" in parsed: + sources.append([parsed["source"], parsed["slice"]]) + else: + sources.append([parsed["source"].split("@")[0], parsed["source"].split("@")[1]]) + # We only need the slice number + slice_numbers = [int(s[1]) for s in sources] + return sorted(slice_numbers) + + def get_latest_annotations(self, origin, return_id=False): + """ + Gets the latest annotations for user and image specified by origin. + """ + sql_query = f"SELECT myValue, UniqueID FROM Interact.KeyValue WHERE myOrigin = '{origin}' AND myKey = 'regionPaths' ORDER BY myTimestamp DESC LIMIT 1" + self.cursor.execute(sql_query) + res = self.cursor.fetchone() + + if res: + if return_id: + # Return ID and value + return res[1], json.loads(res[0]) + else: + return json.loads(res[0]) + else: + if return_id: + return None, None + else: + return None + + # noinspection PyTypeChecker + def get_latest_regions(self, origin): + """ + Get most recent annotations for user and image specified by origin. + """ + regions = self.get_latest_annotations(origin) + + if not regions: + return None + + # Convert all segment of paths into proper python lists + for i in range(len(regions["Regions"])): + if "segments" in regions["Regions"][i]["path"][1]: + regions["Regions"][i]["path"][1]["segments"] = path_dict_to_list(regions["Regions"][i]["path"][1]["segments"]) + return regions + + def commit(self): + self.db.commit() + + +def path_dict_to_list(path): + """ + Converts given points which are represented as dictionary + into default python lists. + """ + res_path = [] + for pt in path: + if isinstance(pt, dict): + res_path.append([pt["x"], pt["y"]]) + else: + res_pt = [] + for ele in pt: + if isinstance(ele, dict): + res_pt.append([ele["x"], ele["y"]]) + else: + res_pt.append(ele) + res_path.append(res_pt) + return res_path diff --git a/atlas_server/uwsgi/Dockerfile b/atlas_server/uwsgi/Dockerfile index ef8eb39e8311a81be557f3dfcefd5929c738d3c8..a0d462d6a5bdb34d93c9b3faeacad0b3e5d38497 100644 --- a/atlas_server/uwsgi/Dockerfile +++ b/atlas_server/uwsgi/Dockerfile @@ -6,6 +6,8 @@ RUN pip3 install requests RUN pip3 install flask flask-restx marshmallow RUN pip3 install pymongo RUN pip3 install uwsgi +RUN pip3 install pymysql +RUN pip3 install numpy # Port for uWSGI EXPOSE 5000 diff --git a/microdraw/src/atlas_ui.js b/microdraw/src/atlas_ui.js index 490d47f9ec2213ce3ab447ef69cb8905baa01acc..e44d202be85e6555d45bc135b6c32440b3762f73 100644 --- a/microdraw/src/atlas_ui.js +++ b/microdraw/src/atlas_ui.js @@ -3,7 +3,8 @@ if (debug) { } var atlasRegions = []; -var atlasServerApiUrl = "http://medpc076.ime.kfa-juelich.de:5000/api" + var atlasServerApiUrl = "http://medpc076.ime.kfa-juelich.de:5000/api" +//var atlasServerApiUrl = "http://localhost:5000/api" // var serverHost = "medpc076.ime.kfa-juelich.de" // var serverPort = 80 // var serverEndpoint = "atlas_server/" @@ -40,6 +41,24 @@ function Task() { this.jobs = []; } +function AnnotationImport() { + this.src_host = "imedv02.ime.kfa-juelich.de"; + // This is fixed for now. Always import into running instance. + this.dst_host = "atlasui_microdraw-mariadb_1"; + this.src_user = null; + this.dst_user = null; + this.src_path = null; + this.dst_path = null; + this.annotation_items = []; + this.loading_state = false; +} + +function AnnotationImportItem(section, annotations) { + this.section = section; + this.annotations = annotations; + this.loading_state = false; +} + function compareRegions(region0, region1) { if (region0 === region1) { return true; @@ -89,6 +108,7 @@ var app = new Vue({ selected_task: null, selected_job: null, enable_auto_rename: true, + annotation_import: new AnnotationImport(), }, methods: { getProjects: function () { @@ -544,6 +564,121 @@ var app = new Vue({ let target_link = `${protocol}//${host}${path}?overlay=${overlay}&opacity=0.4&%sliceNumber=${section}&source=${endpoint}` window.location.href = target_link; + }, + initializeAnnotationImport: function() { + if (typeof(myOrigin.user) == "object") { + this.annotation_import.src_user = null; + this.annotation_import.dst_user = null; + } else { + this.annotation_import.src_user = myOrigin.user; + this.annotation_import.dst_user = myOrigin.user; + } + this.annotation_import.src_path = myOrigin.source; + this.annotation_import.dst_path = myOrigin.source; + }, + getAnnotations: function() { + console.log("get annotations"); + + let user = this.annotation_import.src_user; + + if (user === null) { + alert("Please enter a source user before retrieving annotations."); + return; + } + + let host = encodeURIComponent(btoa(this.annotation_import.src_host)); + let path = encodeURIComponent(btoa(this.annotation_import.src_path)); + this.annotation_import.annotation_items = []; + + let endpoint = `${atlasServerApiUrl}/annotations/${host}/${user}/${path}`; + let _this = this; + this.annotation_import.loading_state = true; + $.ajax({ + url: endpoint, + type: "GET", + crossDomain: true, + success: function (data) { + // Update job state + post_message(`Retrieved annotations`); + let annotations = data["annotations"]; + _this.annotation_items = []; + console.log(annotations); + for (let key in annotations) { + let value = annotations[key]; + _this.annotation_import.annotation_items.push(new AnnotationImportItem(key, value)); + } + }, + error: function (jqXHR, textStatus, errorThrown) { + post_message(`Error getting annotations status: ${textStatus} ${errorThrown}`); + }, + complete: function(data) { + _this.annotation_import.loading_state = false; + } + }); + }, + importAll: function(overwrite) { + if (!this.checkUser()) { + return; + } + for (let annotation in this.annotation_import.annotation_items) { + this.importAnnotation(this.annotation_import.annotation_items[annotation], overwrite); + } + }, + importAnnotation: function(annotation, overwrite) { + if (!this.checkUser()) { + return; + } + console.log(annotation); + + let user = this.annotation_import.dst_user; + console.log(`Importing annotation to user ${this.annotation_import.dst_user} at ${this.annotation_import.dst_host}`); + let host = encodeURIComponent(btoa(this.annotation_import.dst_host)); + let path = encodeURIComponent(btoa(this.annotation_import.dst_path)); + + let endpoint = `${atlasServerApiUrl}/annotations/${host}/${user}/${path}`; + if (overwrite) { + endpoint = `${endpoint}?overwrite=True`; + } + console.log(endpoint); + let _this = this; + let data = {}; + data[annotation.section] = annotation.annotations; + + // Disable buttons while importing + annotation.loading_state = true; + $.ajax({ + url: endpoint, + type: "POST", + dataType: "json", + contentType: "application/json", + data: JSON.stringify(data), + crossDomain: true, + success: function (data) { + // Remove annotation from list + _this.removeImportAnnotation(annotation); + }, + error: function (jqXHR, textStatus, errorThrown) { + post_message(`Error importing annotation: ${textStatus} ${errorThrown}`); + }, + complete: function(data) { + annotation.loading_state = false; + } + }); + }, + checkUser: function() { + let user_ok = this.annotation_import.dst_user !== null && this.annotation_import.dst_user !== "" + if (!user_ok) { + alert("Please enter a target user before importing annotations."); + return false; + } + return true; + }, + removeImportAnnotation: function(annotation) { + for (let idx in this.annotation_import.annotation_items) { + if (this.annotation_import.annotation_items[idx] === annotation) { + this.annotation_import.annotation_items.splice(idx, 1); + } + } } }, created: function () { diff --git a/microdraw/src/microdraw.html b/microdraw/src/microdraw.html index 4e0a779fa39b96e6bfe5a31adf15b7bf09bface8..f76c08dc90cb6a41dd3b4485aefadde0f9ec3205 100755 --- a/microdraw/src/microdraw.html +++ b/microdraw/src/microdraw.html @@ -12,7 +12,10 @@ </head> <body> - + <!-- Load javascript --> + <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js"></script> + <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js"></script> <div id="atlas_app"> <div id="menuButton"> <img class="button" id="openMenu" title="Menu" src="img/openMenu.svg" /> @@ -96,6 +99,10 @@ data-target="#atlas_ui_projects_modal"> ATLaS UI </button> + <button type="button" class="btn btn-secondary" data-toggle="modal" + v-on:click="initializeAnnotationImport" data-target="#atlas_ui_imports_modal"> + Import + </button> </div> </div> @@ -334,7 +341,7 @@ <!-- List of annotations --> <label for="atlas_annotation_list">Annotations</label> - <ol id="atlas_task_list" + <ol id="atlas_annotation_list_selected" class="list-group atlas_annotation_list border border-dark rounded bg-light" v-if="selected_task !== null"> <li v-for="(annotation, index) in selected_task.annotations" @@ -361,7 +368,7 @@ </ol> <label for="atlas_annotation_list">Available annotations</label> - <ol id="atlas_task_list" + <ol id="atlas_annotation_list_available" class="list-group atlas_annotation_list border border-dark rounded bg-light" v-if="selected_task !== null"> <li v-for="(annotation, index) in available_annotations" @@ -500,18 +507,116 @@ </div> </div> + <div class="modal fade" id="atlas_ui_imports_modal" tabindex="-1" role="dialog" + aria-labelledby="exampleModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-lg" role="document"> + <div class="modal-content"> + <!-- Header of dialogue --> + <div class="modal-header"> + <h5 class="modal-title" id="exampleModalLabel">Import annotations</h5> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + + <!-- Body of dialogue --> + <div class="modal-body"> + <div class="form"> + <div class="form-row"> + <label class="col-form-label">Source host</label> + <div class="col-4"> + <input v-model="annotation_import.src_host" class="form-control" type="text" + data-toggle="tooltip" data-placement="top" title="Host of the machine to retrieve annotations from. If you do not know what this is, leave the default value untouched."> + </div> + </div> + <div class="form-row"> + <label class="col-form-label">Source user</label> + <div class="col-4"> + <input v-model="annotation_import.src_user" type="text" class="form-control" + data-toggle="tooltip" data-placement="top" title="Microdraw user on source host to retrieve annotations from."> + </div> + <label class="col-form-label">Destination user</label> + <div class="col-4"> + <input v-model="annotation_import.dst_user" type="text" class="form-control" + data-toggle="tooltip" data-placement="top" title="Microdraw user to import annotations to."> + </div> + </div> + <div class="form-row"> + <label class="col-form-label">Source path</label> + <div class="col-4"> + <input v-model="annotation_import.src_path" type="text" class="form-control" + data-toggle="tooltip" data-placement="top" title="Microdraw path to import annotations from."> + </div> + <label class="col-form-label">Destination path</label> + <div class="col-4"> + <input v-model="annotation_import.dst_path" type="text" class="form-control" + data-toggle="tooltip" data-placement="top" title="Microdraw path to import annotations to."> + </div> + <div class="col-1"> + </div> + </div> + <br> + <div class="form-row"> + <div class="btn-group float-right" role="group"> + <button type="button" class="btn btn-primary" v-on:click="getAnnotations" :disabled="annotation_import.loading_state" + data-toggle="tooltip" data-placement="top" title="Retrieve annotations from source host. Enter source host, user and path first."> + Retrieve source annotations + </button> + </div> + </div> + <br> + + <!-- List of annotations --> + <ol id="atlas_import_list" + class="list-group atlas_task_list border border-dark rounded bg-light"> + <li v-for="ann_item in annotation_import.annotation_items" + class="list-group-item list-group-item-action"> + <div class="form-row"> + <label class="col-1 col-form-label text-right">Section</label> + <div class="col-2"> + <input v-model="ann_item.section" class="form-control" type="text" readonly /> + </div> + <label class="col-2 col-form-label text-right">Annotations</label> + <div class="col-1"> + <input v-model="ann_item.annotations.Regions.length" class="form-control" type="text" readonly /> + </div> + <div class="col-3"> + </div> + <div class="col-3"> + <div class="btn-group" role="group"> + <button type="button" class="btn btn-primary" v-on:click="importAnnotation(ann_item, false)" :disabled="ann_item.loading_state" + data-toggle="tooltip" data-placement="top" title="Add annotations without overwriting existing annotations.">Add</button> + <button type="button" class="btn btn-danger" v-on:click="importAnnotation(ann_item, true)" :disabled="ann_item.loading_state" + data-toggle="tooltip" data-placement="top" title="Overwrite existing annotations for this section.">Overwrite</button> + </div> + </div> + </div> + </li> + </ol> + </div> + <br> + + <!-- Button group --> + <div class="btn-group" role="group"> + <button v-on:click="importAll(false)" type="button" class="btn btn-primary" + data-toggle="tooltip" data-placement="top" title="Add all annotations without overwriting existing annotations."> + Add all + </button> + <button v-on:click="importAll(true)" type="button" class="btn btn-danger" + data-toggle="tooltip" data-placement="top" title="Overwrite all existing annotations."> + Overwrite all + </button> + </div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> + </div> + </div> + </div> + </div> + <!-- Discard changes dialogue --> </div> - <!-- Load javascript --> - <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" - integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" - crossorigin="anonymous"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" - integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" - crossorigin="anonymous"></script> - <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" - integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" - crossorigin="anonymous"></script> <!-- development version, includes helpful console warnings --> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <!-- <script src="https://unpkg.com/vue-contextmenu"></script> -->