From e225c380920dc4b31f876934bc4b041f07bf60c3 Mon Sep 17 00:00:00 2001 From: Boris Sekachev <40690378+bsekachev@users.noreply.github.com> Date: Fri, 8 Feb 2019 15:59:10 +0300 Subject: [PATCH 1/4] Re-Identification application (#299) --- .remarkrc.js | 16 ++ CHANGELOG.md | 1 + Dockerfile | 15 +- .../engine/static/engine/js/annotationUI.js | 9 +- cvat/apps/reid/README.md | 22 ++ cvat/apps/reid/__init__.py | 9 + cvat/apps/reid/apps.py | 8 + cvat/apps/reid/reid.py | 226 ++++++++++++++++++ cvat/apps/reid/static/reid/js/enginePlugin.js | 170 +++++++++++++ cvat/apps/reid/urls.py | 12 + cvat/apps/reid/views.py | 96 ++++++++ cvat/settings/base.py | 3 + cvat/urls.py | 3 + 13 files changed, 584 insertions(+), 6 deletions(-) create mode 100644 .remarkrc.js create mode 100644 cvat/apps/reid/README.md create mode 100644 cvat/apps/reid/__init__.py create mode 100644 cvat/apps/reid/apps.py create mode 100644 cvat/apps/reid/reid.py create mode 100644 cvat/apps/reid/static/reid/js/enginePlugin.js create mode 100644 cvat/apps/reid/urls.py create mode 100644 cvat/apps/reid/views.py diff --git a/.remarkrc.js b/.remarkrc.js new file mode 100644 index 000000000000..23862ca44441 --- /dev/null +++ b/.remarkrc.js @@ -0,0 +1,16 @@ +exports.settings = {bullet: '*', paddedTable: false} + +exports.plugins = [ + require('remark-preset-lint-recommended'), + require('remark-preset-lint-consistent'), + require('remark-validate-links'), + [require("remark-lint-no-dead-urls"), { skipOffline: true }], + [require("remark-lint-maximum-line-length"), 120], + [require("remark-lint-maximum-heading-length"), 120], + [require("remark-lint-list-item-indent"), "tab-size"], + [require("remark-lint-list-item-spacing"), false], + [require("remark-lint-strong-marker"), "*"], + [require("remark-lint-emphasis-marker"), "_"], + [require("remark-lint-unordered-list-marker-style"), "-"], + [require("remark-lint-ordered-list-marker-style"), "."], +] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6097e3648355..d2bf4c9ba669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - OpenVINO auto annotation: it is possible to upload a custom model and annotate images automatically. - Ability to rotate images/video in the client part (Ctrl+R, Shift+Ctrl+R shortcuts) (#305) +- The ReID application for automatic bounding box merging has been added (#299) ### Changed - Propagation setup has been moved from settings to bottom player panel diff --git a/Dockerfile b/Dockerfile index 97f84c3f0f43..48dbe177843d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -103,14 +103,11 @@ RUN if [ "$WITH_TESTS" = "yes" ]; then \ COPY cvat/requirements/ /tmp/requirements/ COPY supervisord.conf mod_wsgi.conf wait-for-it.sh manage.py ${HOME}/ RUN pip3 install --no-cache-dir -r /tmp/requirements/${DJANGO_CONFIGURATION}.txt -COPY cvat/ ${HOME}/cvat - -COPY ssh ${HOME}/.ssh # Install git application dependencies RUN apt-get update && \ apt-get install -y ssh netcat-openbsd git curl zip && \ - curl -s https://packagecloud.io/install/repositories/github/git-lfs/script.deb.sh | bash && \ + wget -qO /dev/stdout https://packagecloud.io/install/repositories/github/git-lfs/script.deb.sh | bash && \ apt-get install -y git-lfs && \ git lfs install && \ rm -rf /var/lib/apt/lists/* && \ @@ -120,6 +117,16 @@ RUN apt-get update && \ echo export "GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o ConnectTimeout=30 -o ProxyCommand='nc -X 5 -x ${socks_proxy} %h %p'\"" >> ${HOME}/.bashrc; \ fi +# Download model for re-identification app +ENV REID_MODEL_DIR=${HOME}/reid +RUN if [ "$OPENVINO_TOOLKIT" = "yes" ]; then \ + mkdir ${HOME}/reid && \ + wget https://download.01.org/openvinotoolkit/2018_R5/open_model_zoo/person-reidentification-retail-0079/FP32/person-reidentification-retail-0079.xml -O reid/reid.xml && \ + wget https://download.01.org/openvinotoolkit/2018_R5/open_model_zoo/person-reidentification-retail-0079/FP32/person-reidentification-retail-0079.bin -O reid/reid.bin; \ + fi + +COPY ssh ${HOME}/.ssh +COPY cvat/ ${HOME}/cvat COPY tests ${HOME}/tests RUN patch -p1 < ${HOME}/cvat/apps/engine/static/engine/js/3rdparty.patch RUN chown -R ${USER}:${USER} . diff --git a/cvat/apps/engine/static/engine/js/annotationUI.js b/cvat/apps/engine/static/engine/js/annotationUI.js index c351f4c5e614..58b306c1e2d4 100644 --- a/cvat/apps/engine/static/engine/js/annotationUI.js +++ b/cvat/apps/engine/static/engine/js/annotationUI.js @@ -159,7 +159,12 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { window.cvat.data = { get: () => shapeCollectionModel.exportAll(), set: (data) => { - shapeCollectionModel.empty(); + for (let type in data) { + for (let shape of data[type]) { + shape.id = idGenerator.next(); + } + } + shapeCollectionModel.import(data, false); shapeCollectionModel.update(); }, @@ -248,7 +253,7 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { $(window).on('click', function(event) { Logger.updateUserActivityTimer(); - if (event.target.classList.contains('modal')) { + if (event.target.classList.contains('modal') && !event.target.classList.contains('force-modal')) { event.target.classList.add('hidden'); } }); diff --git a/cvat/apps/reid/README.md b/cvat/apps/reid/README.md new file mode 100644 index 000000000000..8cf29c503781 --- /dev/null +++ b/cvat/apps/reid/README.md @@ -0,0 +1,22 @@ +# Re-Identification Application + +## About the application + +The ReID application uses deep learning model to perform an automatic bbox merging between neighbor frames. +You can use "Merge" and "Split" functionality to edit automatically generated annotation. + +## Installation + +This application will be installed automatically with the [OpenVINO](https://github.com/opencv/cvat/blob/develop/components/openvino/README.md) component. + +## Running + +For starting the ReID merge process: + +- Open an annotation job +- Open the menu +- Click the "Run ReID Merge" button +- Click the "Submit" button. Also here you can experiment with values of model threshold or maximum distance. + - Model threshold is maximum cosine distance between objects embeddings. + - Maximum distance defines a maximum radius that an object can diverge between neightbor frames. +- The process will be run. You can cancel it in the menu. diff --git a/cvat/apps/reid/__init__.py b/cvat/apps/reid/__init__.py new file mode 100644 index 000000000000..4a9759cc68f5 --- /dev/null +++ b/cvat/apps/reid/__init__.py @@ -0,0 +1,9 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from cvat.settings.base import JS_3RDPARTY + +default_app_config = 'cvat.apps.reid.apps.ReidConfig' + +JS_3RDPARTY['engine'] = JS_3RDPARTY.get('engine', []) + ['reid/js/enginePlugin.js'] diff --git a/cvat/apps/reid/apps.py b/cvat/apps/reid/apps.py new file mode 100644 index 000000000000..f6aa66e1a5f9 --- /dev/null +++ b/cvat/apps/reid/apps.py @@ -0,0 +1,8 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from django.apps import AppConfig + +class ReidConfig(AppConfig): + name = 'cvat.apps.reid' diff --git a/cvat/apps/reid/reid.py b/cvat/apps/reid/reid.py new file mode 100644 index 000000000000..915359b3e11a --- /dev/null +++ b/cvat/apps/reid/reid.py @@ -0,0 +1,226 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os +import rq +import cv2 +import math +import numpy +import fnmatch + +from openvino.inference_engine import IENetwork, IEPlugin +from scipy.optimize import linear_sum_assignment +from scipy.spatial.distance import euclidean, cosine + +from cvat.apps.engine.models import Job + + +class ReID: + __threshold = None + __max_distance = None + __frame_urls = None + __frame_boxes = None + __stop_frame = None + __plugin = None + __executable_network = None + __input_blob_name = None + __output_blob_name = None + __input_height = None + __input_width = None + + + def __init__(self, jid, data): + self.__threshold = data["threshold"] + self.__max_distance = data["maxDistance"] + self.__frame_urls = {} + self.__frame_boxes = {} + + db_job = Job.objects.select_related('segment__task').get(pk = jid) + db_segment = db_job.segment + db_task = db_segment.task + + self.__stop_frame = db_segment.stop_frame + + for root, _, filenames in os.walk(db_task.get_data_dirname()): + for filename in fnmatch.filter(filenames, '*.jpg'): + frame = int(os.path.splitext(filename)[0]) + if frame >= db_segment.start_frame and frame <= db_segment.stop_frame: + self.__frame_urls[frame] = os.path.join(root, filename) + + for frame in self.__frame_urls: + self.__frame_boxes[frame] = [box for box in data["boxes"] if box["frame"] == frame] + + IE_PLUGINS_PATH = os.getenv('IE_PLUGINS_PATH', None) + REID_MODEL_DIR = os.getenv('REID_MODEL_DIR', None) + + if not IE_PLUGINS_PATH: + raise Exception("Environment variable 'IE_PLUGINS_PATH' isn't defined") + if not REID_MODEL_DIR: + raise Exception("Environment variable 'REID_MODEL_DIR' isn't defined") + + REID_XML = os.path.join(REID_MODEL_DIR, "reid.xml") + REID_BIN = os.path.join(REID_MODEL_DIR, "reid.bin") + + self.__plugin = IEPlugin(device="CPU", plugin_dirs=[IE_PLUGINS_PATH]) + network = IENetwork.from_ir(model=REID_XML, weights=REID_BIN) + self.__input_blob_name = next(iter(network.inputs)) + self.__output_blob_name = next(iter(network.outputs)) + self.__input_height, self.__input_width = network.inputs[self.__input_blob_name].shape[-2:] + self.__executable_network = self.__plugin.load(network=network) + del network + + + def __del__(self): + if self.__executable_network: + del self.__executable_network + self.__executable_network = None + + if self.__plugin: + del self.__plugin + self.__plugin = None + + + def __boxes_are_compatible(self, cur_box, next_box): + cur_c_x = (cur_box["xtl"] + cur_box["xbr"]) / 2 + cur_c_y = (cur_box["ytl"] + cur_box["ybr"]) / 2 + next_c_x = (next_box["xtl"] + next_box["xbr"]) / 2 + next_c_y = (next_box["ytl"] + next_box["ybr"]) / 2 + compatible_distance = euclidean([cur_c_x, cur_c_y], [next_c_x, next_c_y]) <= self.__max_distance + compatible_label = cur_box["label_id"] == next_box["label_id"] + return compatible_distance and compatible_label and "path_id" not in next_box + + + def __compute_difference(self, image_1, image_2): + image_1 = cv2.resize(image_1, (self.__input_width, self.__input_height)).transpose((2,0,1)) + image_2 = cv2.resize(image_2, (self.__input_width, self.__input_height)).transpose((2,0,1)) + + input_1 = { + self.__input_blob_name: image_1[numpy.newaxis, ...] + } + + input_2 = { + self.__input_blob_name: image_2[numpy.newaxis, ...] + } + + embedding_1 = self.__executable_network.infer(inputs = input_1)[self.__output_blob_name] + embedding_2 = self.__executable_network.infer(inputs = input_2)[self.__output_blob_name] + + embedding_1 = embedding_1.reshape(embedding_1.size) + embedding_2 = embedding_2.reshape(embedding_2.size) + + return cosine(embedding_1, embedding_2) + + + def __compute_difference_matrix(self, cur_boxes, next_boxes, cur_image, next_image): + def _int(number, upper): + return math.floor(numpy.clip(number, 0, upper - 1)) + + default_mat_value = 1000.0 + + matrix = numpy.full([len(cur_boxes), len(next_boxes)], default_mat_value, dtype=float) + for row, cur_box in enumerate(cur_boxes): + cur_width = cur_image.shape[1] + cur_height = cur_image.shape[0] + cur_xtl, cur_xbr, cur_ytl, cur_ybr = ( + _int(cur_box["xtl"], cur_width), _int(cur_box["xbr"], cur_width), + _int(cur_box["ytl"], cur_height), _int(cur_box["ybr"], cur_height) + ) + + for col, next_box in enumerate(next_boxes): + next_box = next_boxes[col] + next_width = next_image.shape[1] + next_height = next_image.shape[0] + next_xtl, next_xbr, next_ytl, next_ybr = ( + _int(next_box["xtl"], next_width), _int(next_box["xbr"], next_width), + _int(next_box["ytl"], next_height), _int(next_box["ybr"], next_height) + ) + + if not self.__boxes_are_compatible(cur_box, next_box): + continue + + crop_1 = cur_image[cur_ytl:cur_ybr, cur_xtl:cur_xbr] + crop_2 = next_image[next_ytl:next_ybr, next_xtl:next_xbr] + matrix[row][col] = self.__compute_difference(crop_1, crop_2) + + return matrix + + + def __apply_matching(self): + frames = sorted(list(self.__frame_boxes.keys())) + job = rq.get_current_job() + box_paths = {} + + for idx, (cur_frame, next_frame) in enumerate(list(zip(frames[:-1], frames[1:]))): + job.refresh() + if "cancel" in job.meta: + return None + + job.meta["progress"] = idx * 100.0 / len(frames) + job.save_meta() + + cur_boxes = self.__frame_boxes[cur_frame] + next_boxes = self.__frame_boxes[next_frame] + + for box in cur_boxes: + if "path_id" not in box: + path_id = len(box_paths) + box_paths[path_id] = [box] + box["path_id"] = path_id + + if not (len(cur_boxes) and len(next_boxes)): + continue + + cur_image = cv2.imread(self.__frame_urls[cur_frame], cv2.IMREAD_COLOR) + next_image = cv2.imread(self.__frame_urls[next_frame], cv2.IMREAD_COLOR) + difference_matrix = self.__compute_difference_matrix(cur_boxes, next_boxes, cur_image, next_image) + cur_idxs, next_idxs = linear_sum_assignment(difference_matrix) + for idx, cur_idx in enumerate(cur_idxs): + if (difference_matrix[cur_idx][next_idxs[idx]]) <= self.__threshold: + cur_box = cur_boxes[cur_idx] + next_box = next_boxes[next_idxs[idx]] + next_box["path_id"] = cur_box["path_id"] + box_paths[cur_box["path_id"]].append(next_box) + + for box in self.__frame_boxes[frames[-1]]: + if "path_id" not in box: + path_id = len(box_paths) + box["path_id"] = path_id + box_paths[path_id] = [box] + + return box_paths + + + def run(self): + box_paths = self.__apply_matching() + output = [] + + # ReID process has been canceled + if box_paths is None: + return + + for path_id in box_paths: + output.append({ + "label_id": box_paths[path_id][0]["label_id"], + "group_id": 0, + "attributes": [], + "frame": box_paths[path_id][0]["frame"], + "shapes": box_paths[path_id] + }) + + for box in output[-1]["shapes"]: + del box["id"] + del box["path_id"] + del box["group_id"] + del box["label_id"] + box["outside"] = False + box["attributes"] = [] + + for path in output: + if path["shapes"][-1]["frame"] != self.__stop_frame: + copy = path["shapes"][-1].copy() + copy["outside"] = True + copy["frame"] += 1 + path["shapes"].append(copy) + + return output diff --git a/cvat/apps/reid/static/reid/js/enginePlugin.js b/cvat/apps/reid/static/reid/js/enginePlugin.js new file mode 100644 index 000000000000..6bf14377cf5a --- /dev/null +++ b/cvat/apps/reid/static/reid/js/enginePlugin.js @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2018 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/* global showMessage userConfirm */ + + +document.addEventListener('DOMContentLoaded', () => { + function run(overlay, cancelButton, thresholdInput, distanceInput) { + const collection = window.cvat.data.get(); + const data = { + threshold: +thresholdInput.prop('value'), + maxDistance: +distanceInput.prop('value'), + boxes: collection.boxes, + }; + + overlay.removeClass('hidden'); + cancelButton.prop('disabled', true); + $.ajax({ + url: `reid/start/job/${window.cvat.job.id}`, + type: 'POST', + data: JSON.stringify(data), + contentType: 'application/json', + success: () => { + function checkCallback() { + $.ajax({ + url: `/reid/check/${window.cvat.job.id}`, + type: 'GET', + success: (jobData) => { + if (jobData.progress) { + cancelButton.text(`Cancel ReID Merge (${jobData.progress.toString().slice(0, 4)}%)`); + } + + if (['queued', 'started'].includes(jobData.status)) { + setTimeout(checkCallback, 1000); + } else { + overlay.addClass('hidden'); + + if (jobData.status === 'finished') { + if (jobData.result) { + collection.boxes = []; + collection.box_paths = collection.box_paths + .concat(JSON.parse(jobData.result)); + window.cvat.data.clear(); + window.cvat.data.set(collection); + showMessage('ReID merge has done.'); + } else { + showMessage('ReID merge been canceled.'); + } + } else if (jobData.status === 'failed') { + const message = `ReID merge has fallen. Error: '${jobData.stderr}'`; + showMessage(message); + } else { + let message = `Check request returned "${jobData.status}" status.`; + if (jobData.stderr) { + message += ` Error: ${jobData.stderr}`; + } + showMessage(message); + } + } + }, + error: (errorData) => { + overlay.addClass('hidden'); + const message = `Can not check ReID merge. Code: ${errorData.status}. Message: ${errorData.responseText || errorData.statusText}`; + showMessage(message); + }, + }); + } + + setTimeout(checkCallback, 1000); + }, + error: (errorData) => { + overlay.addClass('hidden'); + const message = `Can not start ReID merge. Code: ${errorData.status}. Message: ${errorData.responseText || errorData.statusText}`; + showMessage(message); + }, + complete: () => { + cancelButton.prop('disabled', false); + }, + }); + } + + function cancel(overlay, cancelButton) { + cancelButton.prop('disabled', true); + $.ajax({ + url: `/reid/cancel/${window.cvat.job.id}`, + type: 'GET', + success: () => { + overlay.addClass('hidden'); + cancelButton.text('Cancel ReID Merge (0%)'); + }, + error: (errorData) => { + const message = `Can not cancel ReID process. Code: ${errorData.status}. Message: ${errorData.responseText || errorData.statusText}`; + showMessage(message); + }, + complete: () => { + cancelButton.prop('disabled', false); + } + }); + } + + const buttonsUI = $('#engineMenuButtons'); + const reidWindowId = 'reidSubmitWindow'; + const reidThresholdValueId = 'reidThresholdValue'; + const reidDistanceValueId = 'reidDistanceValue'; + const reidCancelMergeId = 'reidCancelMerge'; + const reidSubmitMergeId = 'reidSubmitMerge'; + const reidCancelButtonId = 'reidCancelReID'; + const reidOverlay = 'reidOverlay'; + + $('').on('click', () => { + $('#annotationMenu').addClass('hidden'); + $(`#${reidWindowId}`).removeClass('hidden'); + }).addClass('menuButton semiBold h2').prependTo(buttonsUI); + + $(` + + `).appendTo('body'); + + $(` + + `).appendTo('body'); + + $(`#${reidCancelMergeId}`).on('click', () => { + $(`#${reidWindowId}`).addClass('hidden'); + }); + + $(`#${reidCancelButtonId}`).on('click', () => { + userConfirm('ReID process will be canceld. Are you sure?', () => { + cancel($(`#${reidOverlay}`), $(`#${reidCancelButtonId}`)); + }); + }); + + $(`#${reidSubmitMergeId}`).on('click', () => { + $(`#${reidWindowId}`).addClass('hidden'); + run($(`#${reidOverlay}`), $(`#${reidCancelButtonId}`), + $(`#${reidThresholdValueId}`), $(`#${reidDistanceValueId}`)); + }); +}); diff --git a/cvat/apps/reid/urls.py b/cvat/apps/reid/urls.py new file mode 100644 index 000000000000..4decc5cf7467 --- /dev/null +++ b/cvat/apps/reid/urls.py @@ -0,0 +1,12 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from django.urls import path +from . import views + +urlpatterns = [ + path('start/job/', views.start), + path('cancel/', views.cancel), + path('check/', views.check), +] diff --git a/cvat/apps/reid/views.py b/cvat/apps/reid/views.py new file mode 100644 index 000000000000..d0753e46d664 --- /dev/null +++ b/cvat/apps/reid/views.py @@ -0,0 +1,96 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse +from cvat.apps.authentication.decorators import login_required +from rules.contrib.views import permission_required, objectgetter + +from cvat.apps.engine.models import Job +from cvat.apps.reid.reid import ReID + +import django_rq +import json +import rq + + +def _create_thread(jid, data): + job = rq.get_current_job() + reid_obj = ReID(jid, data) + job.meta["result"] = json.dumps(reid_obj.run()) + job.save_meta() + + +@login_required +@permission_required(perm=["engine.job.change"], + fn=objectgetter(Job, 'jid'), raise_exception=True) +def start(request, jid): + try: + data = json.loads(request.body.decode('utf-8')) + queue = django_rq.get_queue("low") + job_id = "reid.create.{}".format(jid) + job = queue.fetch_job(job_id) + if job is not None and (job.is_started or job.is_queued): + raise Exception('ReID process has been already started') + queue.enqueue_call(func=_create_thread, args=(jid, data), job_id=job_id, timeout=7200) + job = queue.fetch_job(job_id) + job.meta = {} + job.save_meta() + except Exception as e: + return HttpResponseBadRequest(str(e)) + + return HttpResponse() + + +@login_required +@permission_required(perm=["engine.job.change"], + fn=objectgetter(Job, 'jid'), raise_exception=True) +def check(request, jid): + try: + queue = django_rq.get_queue("low") + rq_id = "reid.create.{}".format(jid) + job = queue.fetch_job(rq_id) + if job is not None and "cancel" in job.meta: + return JsonResponse({"status": "finished"}) + data = {} + if job is None: + data["status"] = "unknown" + elif job.is_queued: + data["status"] = "queued" + elif job.is_started: + data["status"] = "started" + if "progress" in job.meta: + data["progress"] = job.meta["progress"] + elif job.is_finished: + data["status"] = "finished" + data["result"] = job.meta["result"] + job.delete() + else: + data["status"] = "failed" + data["stderr"] = job.exc_info + job.delete() + + except Exception as ex: + data["stderr"] = str(ex) + data["status"] = "unknown" + + return JsonResponse(data) + + +@login_required +@permission_required(perm=["engine.job.change"], + fn=objectgetter(Job, 'jid'), raise_exception=True) +def cancel(request, jid): + try: + queue = django_rq.get_queue("low") + rq_id = "reid.create.{}".format(jid) + job = queue.fetch_job(rq_id) + if job is None or job.is_finished or job.is_failed: + raise Exception("Task is not being annotated currently") + elif "cancel" not in job.meta: + job.meta["cancel"] = True + job.save_meta() + except Exception as e: + return HttpResponseBadRequest(str(e)) + + return HttpResponse() diff --git a/cvat/settings/base.py b/cvat/settings/base.py index aba1d56e3a41..5c70bb710665 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -107,6 +107,9 @@ def generate_ssh_keys(): if 'yes' == os.environ.get('OPENVINO_TOOLKIT', 'no'): INSTALLED_APPS += ['cvat.apps.auto_annotation'] +if 'yes' == os.environ.get('OPENVINO_TOOLKIT', 'no'): + INSTALLED_APPS += ['cvat.apps.reid'] + if os.getenv('DJANGO_LOG_VIEWER_HOST'): INSTALLED_APPS += ['cvat.apps.log_viewer'] diff --git a/cvat/urls.py b/cvat/urls.py index 3680a012ff8c..a0e2769cbf93 100644 --- a/cvat/urls.py +++ b/cvat/urls.py @@ -40,6 +40,9 @@ if apps.is_installed('cvat.apps.git'): urlpatterns.append(path('git/repository/', include('cvat.apps.git.urls'))) +if apps.is_installed('cvat.apps.reid'): + urlpatterns.append(path('reid/', include('cvat.apps.reid.urls'))) + if apps.is_installed('cvat.apps.auto_annotation'): urlpatterns.append(path('auto_annotation/', include('cvat.apps.auto_annotation.urls'))) From cdc34f6993c7250895bd418ef2512d7c82feb82b Mon Sep 17 00:00:00 2001 From: Boris Sekachev <40690378+bsekachev@users.noreply.github.com> Date: Fri, 8 Feb 2019 16:01:57 +0300 Subject: [PATCH 2/4] Fixed 400 coda after undo/redo (#315) --- cvat/apps/engine/static/engine/js/annotationUI.js | 2 +- cvat/apps/engine/static/engine/js/history.js | 11 ++++++++--- cvat/apps/engine/static/engine/js/shapeBuffer.js | 6 ++++-- cvat/apps/engine/static/engine/js/shapeCollection.js | 6 ++++-- cvat/apps/engine/static/engine/js/shapeCreator.js | 3 ++- cvat/apps/engine/static/engine/js/shapeMerger.js | 6 ++++-- cvat/apps/engine/static/engine/js/shapes.js | 8 ++++++-- 7 files changed, 29 insertions(+), 13 deletions(-) diff --git a/cvat/apps/engine/static/engine/js/annotationUI.js b/cvat/apps/engine/static/engine/js/annotationUI.js index 58b306c1e2d4..1c1471f225c0 100644 --- a/cvat/apps/engine/static/engine/js/annotationUI.js +++ b/cvat/apps/engine/static/engine/js/annotationUI.js @@ -224,7 +224,7 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { }), job); new PlayerView(playerModel, playerController, job); - let historyModel = new HistoryModel(playerModel); + let historyModel = new HistoryModel(playerModel, idGenerator); let historyController = new HistoryController(historyModel); new HistoryView(historyController, historyModel); diff --git a/cvat/apps/engine/static/engine/js/history.js b/cvat/apps/engine/static/engine/js/history.js index cca15672e1a4..3a05b6826bfd 100644 --- a/cvat/apps/engine/static/engine/js/history.js +++ b/cvat/apps/engine/static/engine/js/history.js @@ -14,7 +14,7 @@ "use strict"; class HistoryModel extends Listener { - constructor(playerModel) { + constructor(playerModel, idGenerator) { super('onHistoryUpdate', () => this ); this._deep = 128; @@ -23,10 +23,15 @@ class HistoryModel extends Listener { this._redo_stack = []; this._locked = false; this._player = playerModel; + this._idGenerator = idGenerator; window.cvat.addAction = (name, undo, redo, frame) => this.addAction(name, undo, redo, frame); } + generateId() { + return this._idGenerator.next(); + } + undo() { let frame = window.cvat.player.frames.current; let undo = this._undo_stack.pop(); @@ -42,7 +47,7 @@ class HistoryModel extends Listener { this._player.shift(undo.frame, true); } this._locked = true; - undo.undo(); + undo.undo(this); } catch(err) { this.notify(); @@ -73,7 +78,7 @@ class HistoryModel extends Listener { this._player.shift(redo.frame, true); } this._locked = true; - redo.redo(); + redo.redo(this); } catch(err) { this.notify(); diff --git a/cvat/apps/engine/static/engine/js/shapeBuffer.js b/cvat/apps/engine/static/engine/js/shapeBuffer.js index 15ab15dae905..bd793dcc8876 100644 --- a/cvat/apps/engine/static/engine/js/shapeBuffer.js +++ b/cvat/apps/engine/static/engine/js/shapeBuffer.js @@ -157,8 +157,9 @@ class ShapeBufferModel extends Listener { window.cvat.addAction('Paste Object', () => { model.removed = true; model.unsubscribe(this._collection); - }, () => { + }, (self) => { model.subscribe(this._collection); + model.id = self.generateId(); model.removed = false; }, window.cvat.player.frames.current); // End of undo/redo code @@ -247,8 +248,9 @@ class ShapeBufferModel extends Listener { object.removed = true; object.unsubscribe(this._collection); } - }, () => { + }, (self) => { for (let object of addedObjects) { + object.id = self.generateId(); object.removed = false; object.subscribe(this._collection); } diff --git a/cvat/apps/engine/static/engine/js/shapeCollection.js b/cvat/apps/engine/static/engine/js/shapeCollection.js index 7acde4bb94f5..df0cc61a7c75 100644 --- a/cvat/apps/engine/static/engine/js/shapeCollection.js +++ b/cvat/apps/engine/static/engine/js/shapeCollection.js @@ -814,14 +814,16 @@ class ShapeCollectionModel extends Listener { // Undo/redo code let newShapes = this._shapes.slice(-list.length); let originalShape = this._activeShape; - window.cvat.addAction('Split Object', () => { + window.cvat.addAction('Split Object', (self) => { for (let shape of newShapes) { shape.removed = true; shape.unsubscribe(this); } + originalShape.id = self.generateId(); originalShape.removed = false; - }, () => { + }, (self) => { for (let shape of newShapes) { + shape.id = self.generateId(); shape.removed = false; shape.subscribe(this); } diff --git a/cvat/apps/engine/static/engine/js/shapeCreator.js b/cvat/apps/engine/static/engine/js/shapeCreator.js index a93dcc77e40a..bebb7bb17f88 100644 --- a/cvat/apps/engine/static/engine/js/shapeCreator.js +++ b/cvat/apps/engine/static/engine/js/shapeCreator.js @@ -70,8 +70,9 @@ class ShapeCreatorModel extends Listener { window.cvat.addAction('Draw Object', () => { model.removed = true; model.unsubscribe(this._shapeCollection); - }, () => { + }, (self) => { model.subscribe(this._shapeCollection); + model.id = self.generateId(); model.removed = false; }, window.cvat.player.frames.current); // End of undo/redo code diff --git a/cvat/apps/engine/static/engine/js/shapeMerger.js b/cvat/apps/engine/static/engine/js/shapeMerger.js index 8223be05eaee..cd11936652ca 100644 --- a/cvat/apps/engine/static/engine/js/shapeMerger.js +++ b/cvat/apps/engine/static/engine/js/shapeMerger.js @@ -167,20 +167,22 @@ class ShapeMergerModel extends Listener { let shapes = this._shapesForMerge; // Undo/redo code - window.cvat.addAction('Merge Objects', () => { + window.cvat.addAction('Merge Objects', (self) => { model.unsubscribe(this._collectionModel); model.removed = true; for (let shape of shapes) { + shape.id = self.generateId(); shape.removed = false; shape.subscribe(this._collectionModel); } this._collectionModel.update(); - }, () => { + }, (self) => { for (let shape of shapes) { shape.removed = true; shape.unsubscribe(this._collectionModel); } model.subscribe(this._collectionModel); + model.id = self.generateId(); model.removed = false; }, window.cvat.player.frames.current); // End of undo/redo code diff --git a/cvat/apps/engine/static/engine/js/shapes.js b/cvat/apps/engine/static/engine/js/shapes.js index c273640be331..85101f557776 100644 --- a/cvat/apps/engine/static/engine/js/shapes.js +++ b/cvat/apps/engine/static/engine/js/shapes.js @@ -476,11 +476,11 @@ class ShapeModel extends Listener { this.removed = true; // Undo/redo code - window.cvat.addAction('Remove Object', () => { + window.cvat.addAction('Remove Object', (self) => { + this.id = self.generateId(); this.removed = false; }, () => { this.removed = true; - }, window.cvat.player.frames.current); // End of undo/redo code } @@ -574,6 +574,10 @@ class ShapeModel extends Listener { return this._id; } + set id(value) { + this._id = value; + } + get frame() { return this._frame; } From 19573c81b70b73b1394a55707bcc221d2b0d1025 Mon Sep 17 00:00:00 2001 From: Boris Sekachev <40690378+bsekachev@users.noreply.github.com> Date: Fri, 8 Feb 2019 16:13:06 +0300 Subject: [PATCH 3/4] Keyboard shortcut for change shape type (Alt + 1,2,3,4) (#316) --- CHANGELOG.md | 2 ++ .../static/engine/js/shapeCollection.js | 27 +++++++++++++++++++ .../engine/static/engine/js/userConfig.js | 12 +++++++++ 3 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2bf4c9ba669..d577d5ab6364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OpenVINO auto annotation: it is possible to upload a custom model and annotate images automatically. - Ability to rotate images/video in the client part (Ctrl+R, Shift+Ctrl+R shortcuts) (#305) - The ReID application for automatic bounding box merging has been added (#299) +- Keyboard shortcuts to switch next/previous default shape type (box, polygon etc) [Alt + <, Alt + >] (#316) + ### Changed - Propagation setup has been moved from settings to bottom player panel diff --git a/cvat/apps/engine/static/engine/js/shapeCollection.js b/cvat/apps/engine/static/engine/js/shapeCollection.js index df0cc61a7c75..14a2027bc955 100644 --- a/cvat/apps/engine/static/engine/js/shapeCollection.js +++ b/cvat/apps/engine/static/engine/js/shapeCollection.js @@ -965,6 +965,30 @@ class ShapeCollectionController { } }.bind(this)); + let nextShapeType = Logger.shortkeyLogDecorator(function(e) { + if (window.cvat.mode === null) { + let next = $('#shapeTypeSelector option:selected').next(); + if (!next.length) { + next = $('#shapeTypeSelector option').first(); + } + + next.prop('selected', true); + next.trigger('change'); + } + }.bind(this)); + + let prevShapeType = Logger.shortkeyLogDecorator(function(e) { + if (window.cvat.mode === null) { + let prev = $('#shapeTypeSelector option:selected').prev(); + if (!prev.length) { + prev = $('#shapeTypeSelector option').last(); + } + + prev.prop('selected', true); + prev.trigger('change'); + } + }.bind(this)); + let shortkeys = window.cvat.config.shortkeys; Mousetrap.bind(shortkeys["switch_lock_property"].value, switchLockHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["switch_all_lock_property"].value, switchAllLockHandler.bind(this), 'keydown'); @@ -977,6 +1001,9 @@ class ShapeCollectionController { Mousetrap.bind(shortkeys["change_shape_label"].value, switchLabelHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["delete_shape"].value, removeActiveHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["change_shape_color"].value, changeShapeColorHandler.bind(this), 'keydown'); + Mousetrap.bind(shortkeys['next_shape_type'].value, nextShapeType.bind(this), 'keydown'); + Mousetrap.bind(shortkeys['prev_shape_type'].value, prevShapeType.bind(this), 'keydown'); + if (window.cvat.job.z_order) { Mousetrap.bind(shortkeys["inc_z"].value, incZHandler.bind(this), 'keydown'); diff --git a/cvat/apps/engine/static/engine/js/userConfig.js b/cvat/apps/engine/static/engine/js/userConfig.js index 3e0329bd22a0..a7dec874fe6c 100644 --- a/cvat/apps/engine/static/engine/js/userConfig.js +++ b/cvat/apps/engine/static/engine/js/userConfig.js @@ -299,6 +299,18 @@ class Config { view_value: 'Ctrl + Shift + R', description: 'counter clockwise image rotation' }, + + next_shape_type: { + value: ['alt+.'], + view_value: 'Alt + >', + description: 'switch next default shape type' + }, + + prev_shape_type: { + value: ['alt+,'], + view_value: 'Alt + <', + description: 'switch previous default shape type' + }, }; if (window.cvat && window.cvat.job && window.cvat.job.z_order) { From df10fe19e26d5311100473a1ea633b8a44ddadda Mon Sep 17 00:00:00 2001 From: Boris Sekachev <40690378+bsekachev@users.noreply.github.com> Date: Fri, 8 Feb 2019 16:29:24 +0300 Subject: [PATCH 4/4] Optional LFS. LFS name mask for an entire repository (#314) --- CHANGELOG.md | 4 ++- cvat/apps/git/git.py | 32 +++++++++++++++---- cvat/apps/git/migrations/0003_gitdata_lfs.py | 18 +++++++++++ cvat/apps/git/models.py | 1 + .../apps/git/static/git/js/dashboardPlugin.js | 6 ++++ 5 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 cvat/apps/git/migrations/0003_gitdata_lfs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d577d5ab6364..549d87e2ca22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,15 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Propagation setup has been moved from settings to bottom player panel - Additional events like "Debug Info" or "Fit Image" have been added for analitics +- Optional using LFS for git annotation storages (#314) ### Deprecated -- +- "Flip images" flag in the create task dialog will be removed. Rotation functionality in client part have been added instead. ### Removed - ### Fixed - Django 2.1.5 (security fix, https://nvd.nist.gov/vuln/detail/CVE-2019-3498) +- Several scenarious which cause code 400 after undo/redo/save have been fixed (#315) ### Security - diff --git a/cvat/apps/git/git.py b/cvat/apps/git/git.py index ab2f8d0a0410..4085041e2374 100644 --- a/cvat/apps/git/git.py +++ b/cvat/apps/git/git.py @@ -50,6 +50,7 @@ class Git: __diffs_dir = None __annotation_file = None __sync_date = None + __lfs = None def __init__(self, db_git, tid, user): self.__db_git = db_git @@ -66,6 +67,7 @@ def __init__(self, db_git, tid, user): self.__branch_name = 'cvat_{}_{}'.format(tid, self.__task_name) self.__annotation_file = os.path.join(self.__cwd, self.__path) self.__sync_date = db_git.sync_date + self.__lfs = db_git.lfs # Method parses an got URL. @@ -256,6 +258,28 @@ def _accumulate(source, target, target_key): if os.path.exists(self.__annotation_file): os.remove(self.__annotation_file) + # Initialize LFS if need + if self.__lfs: + updated = False + lfs_settings = ["*.xml\tfilter=lfs diff=lfs merge=lfs -text\n", "*.zip\tfilter=lfs diff=lfs merge=lfs -text\n"] + if not os.path.isfile(os.path.join(self.__cwd, ".gitattributes")): + with open(os.path.join(self.__cwd, ".gitattributes"), "w") as gitattributes: + gitattributes.writelines(lfs_settings) + updated = True + else: + with open(os.path.join(self.__cwd, ".gitattributes"), "r+") as gitattributes: + lines = gitattributes.readlines() + for setting in lfs_settings: + if setting not in lines: + updated = True + lines.append(setting) + gitattributes.seek(0) + gitattributes.writelines(lines) + gitattributes.truncate() + + if updated: + self.__rep.git.add(['.gitattributes']) + # Dump an annotation dump(self.__tid, format, scheme, host, OrderedDict()) dump_name = Task.objects.get(pk = self.__tid).get_dump_path() @@ -268,8 +292,6 @@ def _accumulate(source, target, target_key): else: raise Exception("Got unknown annotation file type") - # Setup LFS for *.zip files - self.__rep.git.lfs("track", self.__path) self.__rep.git.add(self.__annotation_file) # Merge diffs @@ -279,12 +301,7 @@ def _accumulate(source, target, target_key): diff = json.loads(f.read()) _accumulate(diff, summary_diff, None) - # Commit and push - self.__rep.index.add([ - '.gitattributes', - ]) self.__rep.index.commit("CVAT Annotation updated by {}. Summary: {}".format(self.__user["name"], str(summary_diff))) - self.__rep.git.push("origin", self.__branch_name, "--force") shutil.rmtree(self.__diffs_dir, True) @@ -344,6 +361,7 @@ def _initial_create(tid, params): db_git.url = git_path db_git.path = path db_git.task = db_task + db_git.lfs = params["use_lfs"].lower() == "true" try: _git = Git(db_git, tid, user) diff --git a/cvat/apps/git/migrations/0003_gitdata_lfs.py b/cvat/apps/git/migrations/0003_gitdata_lfs.py new file mode 100644 index 000000000000..84da1c7ba721 --- /dev/null +++ b/cvat/apps/git/migrations/0003_gitdata_lfs.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2019-02-05 17:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('git', '0002_auto_20190123_1305'), + ] + + operations = [ + migrations.AddField( + model_name='gitdata', + name='lfs', + field=models.BooleanField(default=True), + ), + ] diff --git a/cvat/apps/git/models.py b/cvat/apps/git/models.py index d72c921c057e..07a6ece6d50d 100644 --- a/cvat/apps/git/models.py +++ b/cvat/apps/git/models.py @@ -22,3 +22,4 @@ class GitData(models.Model): path = models.CharField(max_length=256) sync_date = models.DateTimeField(auto_now_add=True) status = models.CharField(max_length=20, default=GitStatusChoice.NON_SYNCED) + lfs = models.BooleanField(default=True) diff --git a/cvat/apps/git/static/git/js/dashboardPlugin.js b/cvat/apps/git/static/git/js/dashboardPlugin.js index eff27e10a213..9380ce7396e8 100644 --- a/cvat/apps/git/static/git/js/dashboardPlugin.js +++ b/cvat/apps/git/static/git/js/dashboardPlugin.js @@ -60,6 +60,7 @@ window.cvat.git = { labelStatusId: "gitReposLabelStatus", labelMessageId: "gitReposLabelMessage", createURLInputTextId: "gitCreateURLInputText", + lfsCheckboxId: "gitLFSCheckbox", updateState: () => { let gitWindow = $(`#${window.cvat.git.reposWindowId}`); @@ -136,6 +137,10 @@ document.addEventListener("DOMContentLoaded", () => { `style="width: 90%", placeholder="github.com/user/repos [annotation/.zip]" ` + `title = "Field for a repository URL and a relative path inside the repository. Default repository path is 'annotation/.zip'. There are .zip or .xml extenstions are supported."/>` + ` + + + + ` ).insertAfter($("#dashboardBugTrackerInput").parent().parent()); @@ -145,6 +150,7 @@ document.addEventListener("DOMContentLoaded", () => { let gitPath = $(`#${window.cvat.git.createURLInputTextId}`).prop("value").replace(/\s/g, ""); if (gitPath.length) { oData.append("git_path", gitPath); + oData.append("use_lfs", $(`#${window.cvat.git.lfsCheckboxId}`).prop("checked")); } originalCreateTaskRequest(oData, onSuccessRequest, onSuccessCreate, onError, onComplete, onUpdateStatus); };