diff --git a/.gitignore b/.gitignore index 710bd9ab..cb480289 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ /.env /keys /logs +/components/openvino/*.tgz +/profiles # Ignore temporary files docker-compose.override.yml diff --git a/.vscode/launch.json b/.vscode/launch.json index e0ae80ab..ac399cc3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,6 +9,7 @@ "type": "python", "request": "launch", "stopOnEntry": false, + "debugStdLib": true, "pythonPath": "${config:python.pythonPath}", "program": "${workspaceRoot}/manage.py", "args": [ @@ -23,7 +24,6 @@ "DjangoDebugging" ], "cwd": "${workspaceFolder}", - "env": {}, "envFile": "${workspaceFolder}/.env", }, { @@ -44,6 +44,7 @@ "type": "python", "request": "launch", "stopOnEntry": false, + "debugStdLib": true, "pythonPath": "${config:python.pythonPath}", "program": "${workspaceRoot}/manage.py", "args": [ @@ -65,6 +66,7 @@ "name": "CVAT RQ - low", "type": "python", "request": "launch", + "debugStdLib": true, "stopOnEntry": false, "pythonPath": "${config:python.pythonPath}", "program": "${workspaceRoot}/manage.py", diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d5aacf8..3a4432c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2018-12-29 +### Added +- Ability to copy Object URL and Frame URL via object context menu and player context menu respectively. +- Ability to change opacity for selected shape with help "Selected Fill Opacity" slider. +- Ability to remove polyshapes points by double click. +- Ability to draw/change polyshapes (except for points) by slip method. Just press ENTER and moving a cursor. +- Ability to switch lock/hide properties via label UI element (in right menu) for all objects with same label. +- Shortcuts for outside/keyframe properties +- Support of Intel OpenVINO for accelerated model inference +- Tensorflow annotation now works without CUDA. It can use CPU only. OpenVINO and CUDA are supported optionally. +- Incremental saving of annotations. +- Tutorial for using polygons (screencast) +- Silk profiler to improve development process +- Admin panel can be used to edit labels and attributes for annotation tasks +- Analytics component to manage a data annotation team, monitor exceptions, collect client and server logs +- Changeable job and task statuses (annotation, validation, completed). A job status can be changed manually, a task status is computed automatically based on job statuses (#153) +- Backlink to a task from its job annotation view (#156) +- Buttons lock/hide for labels. They work for all objects with the same label on a current frame (#116) + +### Changed +- Polyshape editing method has been improved. You can redraw part of shape instead of points cloning. +- Unified shortcut (Esc) for close any mode instead of different shortcuts (Alt+N, Alt+G, Alt+M etc.). +- Dump file contains information about data source (e.g. video name, archive name, ...) +- Update requests library due to https://nvd.nist.gov/vuln/detail/CVE-2018-18074 +- Per task/job permissions to create/access/change/delete tasks and annotations +- Documentation was improved +- Timeout for creating tasks was increased (from 1h to 4h) (#136) +- Drawing has become more convenience. Now it is possible to draw outside an image. Shapes will be automatically truncated after drawing process (#202) + +### Fixed +- Performance bottleneck has been fixed during you create new objects (draw, copy, merge etc). +- Label UI elements aren't updated after changelabel. +- Attribute annotation mode can use invalid shape position after resize or move shapes. +- Labels order is preserved now (#242) +- Uploading large XML files (#123) +- Django vulnerability (#121) +- Grammatical cleanup of README.md (#107) +- Dashboard loading has been accelerated (#156) +- Text drawing outside of a frame in some cases (#202) + ## [0.2.0] - 2018-09-28 ### Added - New annotation shapes: polygons, polylines, points diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35615780..5a5086c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,196 @@ -# How to contribute to Computer Vision Annotation Tool (CVAT) +# Contributing to this project -When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the owners of this repository before making a change. +Please take a moment to review this document in order to make the contribution +process easy and effective for everyone involved. + +Following these guidelines helps to communicate that you respect the time of +the developers managing and developing this open source project. In return, +they should reciprocate that respect in addressing your issue or assessing +patches and features. + +## Development environment + +Next steps should work on clear Ubuntu 18.04. + +- Install necessary dependencies: + +```sh +$ sudo apt-get install -y curl redis-server python3-dev python3-pip python3-venv libldap2-dev libsasl2-dev +``` + +- Install [Visual Studio Code](https://code.visualstudio.com/docs/setup/linux#_debian-and-ubuntu-based-distributions) for development + +- Install CVAT on your local host: + +```sh +$ git clone https://github.com/opencv/cvat +$ cd cvat && mkdir logs keys +$ python3 -m venv .env +$ . .env/bin/activate +$ pip install -U pip wheel +$ pip install -r cvat/requirements/development.txt +$ python manage.py migrate +$ python manage.py collectstatic +``` + +- Create a super user for CVAT: + +```sh +$ python manage.py createsuperuser +Username (leave blank to use 'django'): *** +Email address: *** +Password: *** +Password (again): *** +``` + +- Run Visual Studio Code from the virtual environment + +``` +$ code . +``` + +- Inside Visual Studio Code install [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) and [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) extensions + +- Reload Visual Studio Code + +- Select `CVAT Debugging` configuration and start debugging (F5) + +You have done! Now it is possible to insert breakpoints and debug server and client of the tool. + +## Branching model + +The project uses [a successful Git branching model](https://nvie.com/posts/a-successful-git-branching-model). +Thus it has a couple of branches. Some of them are described below: + +- `origin/master` to be the main branch where the source code of +HEAD always reflects a production-ready state. +- `origin/develop` to be the main branch where the source code of +HEAD always reflects a state with the latest delivered development +changes for the next release. Some would call this the “integration branch”. + +## Using the issue tracker + +The issue tracker is the preferred channel for [bug reports](#bugs), +[features requests](#features) and [submitting pull +requests](#pull-requests), but please respect the following restrictions: + +* Please **do not** use the issue tracker for personal support requests (use + [Stack Overflow](http://stackoverflow.com)). + +* Please **do not** derail or troll issues. Keep the discussion on topic and + respect the opinions of others. + + +## Bug reports + +A bug is a _demonstrable problem_ that is caused by the code in the repository. +Good bug reports are extremely helpful - thank you! + +Guidelines for bug reports: + +1. **Use the GitHub issue search** — check if the issue has already been + reported. + +2. **Check if the issue has been fixed** — try to reproduce it using the + latest `develop` branch in the repository. + +3. **Isolate the problem** — ideally create a reduced test case. + +A good bug report shouldn't leave others needing to chase you up for more +information. Please try to be as detailed as possible in your report. What is +your environment? What steps will reproduce the issue? What browser(s) and OS +experience the problem? What would you expect to be the outcome? All these +details will help people to fix any potential bugs. + +Example: + +> Short and descriptive example bug report title +> +> A summary of the issue and the browser/OS environment in which it occurs. If +> suitable, include the steps required to reproduce the bug. +> +> 1. This is the first step +> 2. This is the second step +> 3. Further steps, etc. +> +> +> Any other information you want to share that is relevant to the issue being +> reported. This might include the lines of code that you have identified as +> causing the bug, and potential solutions (and your opinions on their +> merits). + + +## Feature requests + +Feature requests are welcome. But take a moment to find out whether your idea +fits with the scope and aims of the project. It's up to *you* to make a strong +case to convince the project's developers of the merits of this feature. Please +provide as much detail and context as possible. + + +## Pull requests + +Good pull requests - patches, improvements, new features - are a fantastic +help. They should remain focused in scope and avoid containing unrelated +commits. + +**Please ask first** before embarking on any significant pull request (e.g. +implementing features, refactoring code, porting to a different language), +otherwise you risk spending a lot of time working on something that the +project's developers might not want to merge into the project. + +Please adhere to the coding conventions used throughout a project (indentation, +accurate comments, etc.) and any other requirements (such as test coverage). + +Follow this process if you'd like your work considered for inclusion in the +project: + +1. [Fork](http://help.github.com/fork-a-repo/) the project, clone your fork, + and configure the remotes: + + ```bash + # Clone your fork of the repo into the current directory + git clone https://github.com// + # Navigate to the newly cloned directory + cd + # Assign the original repo to a remote called "upstream" + git remote add upstream https://github.com// + ``` + +2. If you cloned a while ago, get the latest changes from upstream: + + ```bash + git checkout + git pull upstream + ``` + +3. Create a new topic branch (off the main project development branch) to + contain your feature, change, or fix: + + ```bash + git checkout -b + ``` + +4. Commit your changes in logical chunks. Please adhere to these [git commit + message guidelines](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) + or your code is unlikely be merged into the main project. Use Git's + [interactive rebase](https://help.github.com/articles/interactive-rebase) + feature to tidy up your commits before making them public. + +5. Locally merge (or rebase) the upstream development branch into your topic branch: + + ```bash + git pull [--rebase] upstream + ``` + +6. Push your topic branch up to your fork: + + ```bash + git push origin + ``` + +7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) + with a clear title and description. + +**IMPORTANT**: By submitting a patch, you agree to allow the project owner to +license your work under the same license as that used by the project. diff --git a/Dockerfile b/Dockerfile index fe2952a8..0f5ab6fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,6 @@ ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' ARG USER -ARG TF_ANNOTATION -ENV TF_ANNOTATION=${TF_ANNOTATION} ARG DJANGO_CONFIGURATION ENV DJANGO_CONFIGURATION=${DJANGO_CONFIGURATION} @@ -42,6 +40,8 @@ RUN apt-get update && \ unrar \ p7zip-full \ vim && \ + add-apt-repository --remove ppa:mc3man/gstffmpeg-keep -y && \ + add-apt-repository --remove ppa:mc3man/xerus-media -y && \ rm -rf /var/lib/apt/lists/* # Add a non-root user @@ -50,13 +50,28 @@ ENV HOME /home/${USER} WORKDIR ${HOME} RUN adduser --shell /bin/bash --disabled-password --gecos "" ${USER} -# Install tf annotation if need -COPY cvat/apps/tf_annotation/docker_setup_tf_annotation.sh /tmp/tf_annotation/ -COPY cvat/apps/tf_annotation/requirements.txt /tmp/tf_annotation/ -ENV TF_ANNOTATION_MODEL_PATH=${HOME}/rcnn/frozen_inference_graph.pb +COPY components /tmp/components + +# OpenVINO toolkit support +ARG OPENVINO_TOOLKIT +ENV OPENVINO_TOOLKIT=${OPENVINO_TOOLKIT} +RUN if [ "$OPENVINO_TOOLKIT" = "yes" ]; then \ + /tmp/components/openvino/install.sh; \ + fi + +# CUDA support +ARG CUDA_SUPPORT +ENV CUDA_SUPPORT=${CUDA_SUPPORT} +RUN if [ "$CUDA_SUPPORT" = "yes" ]; then \ + /tmp/components/cuda/install.sh; \ + fi +# Tensorflow annotation support +ARG TF_ANNOTATION +ENV TF_ANNOTATION=${TF_ANNOTATION} +ENV TF_ANNOTATION_MODEL_PATH=${HOME}/rcnn/inference_graph RUN if [ "$TF_ANNOTATION" = "yes" ]; then \ - /tmp/tf_annotation/docker_setup_tf_annotation.sh; \ + bash -i /tmp/components/tf_annotation/install.sh; \ fi ARG WITH_TESTS diff --git a/README.md b/README.md index 18f0c954..513b6de9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Computer Vision Annotation Tool (CVAT) +[![Gitter chat](https://badges.gitter.im/opencv-cvat/gitter.png)](https://gitter.im/opencv-cvat) + CVAT is completely re-designed and re-implemented version of [Video Annotation Tool from Irvine, California](http://carlvondrick.com/vatic/) tool. It is free, online, interactive video and image annotation tool for computer vision. It is being used by our team to annotate million of objects with different properties. Many UI and UX decisions are based on feedbacks from professional data annotation team. ![CVAT screenshot](cvat/apps/documentation/static/documentation/images/cvat.jpg) @@ -8,6 +10,8 @@ CVAT is completely re-designed and re-implemented version of [Video Annotation T - [User's guide](cvat/apps/documentation/user_guide.md) - [XML annotation format](cvat/apps/documentation/xml_format.md) +- [AWS Deployment Guide](cvat/apps/documentation/AWS-Deployment-Guide.md) +- [Questions](#questions) ## Screencasts @@ -15,6 +19,7 @@ CVAT is completely re-designed and re-implemented version of [Video Annotation T - [Interpolation mode](https://youtu.be/U3MYDhESHo4) - [Attribute mode](https://youtu.be/UPNfWl8Egd8) - [Segmentation mode](https://youtu.be/6IJ0QN7PBKo) +- [Tutorial for polygons](https://www.youtube.com/watch?v=XTwfXDh4clI) ## LICENSE @@ -22,32 +27,12 @@ Code released under the [MIT License](https://opensource.org/licenses/MIT). ## INSTALLATION -These instructions below should work for Ubuntu 16.04. Probably it will work on other OSes as well with minor modifications. +The instructions below should work for `Ubuntu 16.04`. It will probably work on other Operating Systems such as `Windows` and `macOS`, but may require minor modifications. ### Install [Docker CE](https://www.docker.com/community-edition) or [Docker EE](https://www.docker.com/enterprise-edition) from official site Please read official manual [here](https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/). -### Install the latest driver for your graphics card - -The step is necessary only to run tf_annotation app. If you don't have a Nvidia GPU you can skip the step. - -```bash -sudo add-apt-repository ppa:graphics-drivers/ppa -sudo apt-get update -sudo apt-cache search nvidia-* # find latest nvidia driver -sudo apt-get install nvidia-* # install the nvidia driver -sudo apt-get install mesa-common-dev -sudo apt-get install freeglut3-dev -sudo apt-get install nvidia-modprobe -``` - -Reboot your PC and verify installation by `nvidia-smi` command. - -### Install [Nvidia-Docker](https://github.com/NVIDIA/nvidia-docker) - -The step is necessary only to run tf_annotation app. If you don't have a Nvidia GPU you can skip the step. See detailed installation instructions on repository page. - ### Install docker-compose (1.19.0 or newer) ```bash @@ -58,25 +43,28 @@ sudo pip install docker-compose To build all necessary docker images run `docker-compose build` command. By default, in production mode the tool uses PostgreSQL as database, Redis for caching. -### Run containers without tf_annotation app +### Run docker containers -To start all containers run `docker-compose up -d` command. Go to [localhost:8080](http://localhost:8080/). You should see a login page. +To start default container run `docker-compose up -d` command. Go to [localhost:8080](http://localhost:8080/). You should see a login page. -### Run containers with tf_annotation app +### You can include any additional components. Just add corresponding docker-compose file to build or run command: -If you would like to enable tf_annotation app first of all be sure that nvidia-driver, nvidia-docker and docker-compose>=1.19.0 are installed properly (see instructions above) and `docker info | grep 'Runtimes'` output contains `nvidia`. - -Run following command: ```bash -docker-compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d --build +# Build image with CUDA and OpenVINO support +docker-compose -f docker-compose.yml -f components/cuda/docker-compose.cuda.yml -f components/openvino/docker-compose.openvino.yml build + +# Run containers with CUDA and OpenVINO support +docker-compose -f docker-compose.yml -f components/cuda/docker-compose.cuda.yml -f components/openvino/docker-compose.openvino.yml up -d ``` +For details please see [components section](components/README.md). + ### Create superuser account You can [register a user](http://localhost:8080/auth/register) but by default it will not have rights even to view list of tasks. Thus you should create a superuser. The superuser can use admin panel to assign correct groups to the user. Please use the command below: ```bash -docker exec -it cvat sh -c '/usr/bin/python3 ~/manage.py createsuperuser' +docker exec -it cvat bash -ic '/usr/bin/python3 ~/manage.py createsuperuser' ``` Type your login/password for the superuser [on the login page](http://localhost:8080/auth/login) and press **Login** button. Now you should be able to create a new annotation task. Please read documentation for more details. @@ -106,19 +94,13 @@ services: ``` ### Annotation logs -It is possible to proxy annotation logs from client to another server over http. For examlpe you can use Logstash. -To do that set DJANGO_LOG_SERVER_URL environment variable in cvat section of docker-compose.yml -file (or add this variable to docker-compose.override.yml). +It is possible to proxy annotation logs from client to ELK. To do that run the following command below: -```yml -version: "2.3" - -services: -cvat: - environment: - DJANGO_LOG_SERVER_URL: https://annotation.example.com:5000 +```bash +docker-compose -f docker-compose.yml -f components/analytics/docker-compose.analytics.yml up -d --build ``` + ### Share path You can use a share storage for data uploading during you are creating a task. To do that you can mount it to CVAT docker container. Example of docker-compose.override.yml for this purpose: @@ -131,7 +113,7 @@ services: environment: CVAT_SHARE_URL: "Mounted from /mnt/share host directory" volumes: - cvat_share:/home/django/share:ro + - cvat_share:/home/django/share:ro volumes: cvat_share: @@ -141,3 +123,11 @@ volumes: o: bind ``` You can change the share device path to your actual share. For user convenience we have defined the enviroment variable $CVAT_SHARE_URL. This variable contains a text (url for example) which will be being shown in the client-share browser. + +## Questions + +CVAT usage related questions or unclear concepts can be posted in our [Gitter chat](https://gitter.im/opencv-cvat) for **quick replies** from contributors and other users. + +However, if you have a feature request or a bug report that can reproduced, feel free to open an issue (with steps to reproduce the bug if it's a bug report). + +If you are not sure or just want to browse other users common questions, [Gitter chat](https://gitter.im/opencv-cvat) is the way to go. diff --git a/components/README.md b/components/README.md new file mode 100644 index 00000000..f02bee3b --- /dev/null +++ b/components/README.md @@ -0,0 +1,6 @@ +### There are some additional components for CVAT + +* [NVIDIA CUDA](cuda/README.md) +* [OpenVINO](openvino/README.md) +* [Tensorflow Object Detector](tf_annotation/README.md) +* [Analytics](analytics/README.md) diff --git a/components/analytics/README.md b/components/analytics/README.md new file mode 100644 index 00000000..c7cb7fdc --- /dev/null +++ b/components/analytics/README.md @@ -0,0 +1,104 @@ +## Analytics for Computer Vision Annotation Tool (CVAT) + +It is possible to proxy annotation logs from client to ELK. To do that run the following command below: + +### Build docker image +```bash +# From project root directory +docker-compose -f docker-compose.yml -f components/analytics/docker-compose.analytics.yml build +``` + +### Run docker container +```bash +# From project root directory +docker-compose -f docker-compose.yml -f components/analytics/docker-compose.analytics.yml up -d +``` + +At the moment it is not possible to save advanced settings. Below values should be specified manually. + +## Time picker default +{ + "from": "now/d", + "to": "now/d", + "display": "Today", + "section": 0 +} + +## Time picker quick ranges + +```json +[ + { + "from": "now/d", + "to": "now/d", + "display": "Today", + "section": 0 + }, + { + "from": "now/w", + "to": "now/w", + "display": "This week", + "section": 0 + }, + { + "from": "now/M", + "to": "now/M", + "display": "This month", + "section": 0 + }, + { + "from": "now/y", + "to": "now/y", + "display": "This year", + "section": 0 + }, + { + "from": "now/d", + "to": "now", + "display": "Today so far", + "section": 2 + }, + { + "from": "now/w", + "to": "now", + "display": "Week to date", + "section": 2 + }, + { + "from": "now/M", + "to": "now", + "display": "Month to date", + "section": 2 + }, + { + "from": "now/y", + "to": "now", + "display": "Year to date", + "section": 2 + }, + { + "from": "now-1d/d", + "to": "now-1d/d", + "display": "Yesterday", + "section": 1 + }, + { + "from": "now-1w/w", + "to": "now-1w/w", + "display": "Previous week", + "section": 1 + }, + { + "from": "now-1m/m", + "to": "now-1m/m", + "display": "Previous month", + "section": 1 + }, + { + "from": "now-1y/y", + "to": "now-1y/y", + "display": "Previous year", + "section": 1 + } +] +``` diff --git a/components/analytics/docker-compose.analytics.yml b/components/analytics/docker-compose.analytics.yml new file mode 100644 index 00000000..fcb88272 --- /dev/null +++ b/components/analytics/docker-compose.analytics.yml @@ -0,0 +1,69 @@ +version: '2.3' +services: + cvat_elasticsearch: + container_name: cvat_elasticsearch + image: cvat_elasticsearch + networks: + default: + aliases: + - elasticsearch + build: + context: ./components/analytics/elasticsearch + args: + ELK_VERSION: 6.4.0 + volumes: + - cvat_events:/usr/share/elasticsearch/data + restart: always + + cvat_kibana: + container_name: cvat_kibana + image: cvat_kibana + networks: + default: + aliases: + - kibana + build: + context: ./components/analytics/kibana + args: + ELK_VERSION: 6.4.0 + depends_on: ['cvat_elasticsearch'] + restart: always + + cvat_kibana_setup: + container_name: cvat_kibana_setup + image: cvat + volumes: ['./components/analytics/kibana:/home/django/kibana:ro'] + depends_on: ['cvat'] + working_dir: '/home/django' + entrypoint: ['bash', 'wait-for-it.sh', 'elasticsearch:9200', '-t', '0', '--', + '/bin/bash', 'wait-for-it.sh', 'kibana:5601', '-t', '0', '--', + '/usr/bin/python3', 'kibana/setup.py', 'kibana/export.json'] + environment: + no_proxy: elasticsearch,kibana,${no_proxy} + + cvat_logstash: + container_name: cvat_logstash + image: cvat_logstash + networks: + default: + aliases: + - logstash + build: + context: ./components/analytics/logstash + args: + ELK_VERSION: 6.4.0 + http_proxy: ${http_proxy} + https_proxy: ${https_proxy} + depends_on: ['cvat_elasticsearch'] + restart: always + + cvat: + environment: + DJANGO_LOG_SERVER_HOST: logstash + DJANGO_LOG_SERVER_PORT: 5000 + DJANGO_LOG_VIEWER_HOST: kibana + DJANGO_LOG_VIEWER_PORT: 5601 + no_proxy: kibana,logstash,${no_proxy} + +volumes: + cvat_events: diff --git a/components/analytics/elasticsearch/Dockerfile b/components/analytics/elasticsearch/Dockerfile new file mode 100644 index 00000000..2c270426 --- /dev/null +++ b/components/analytics/elasticsearch/Dockerfile @@ -0,0 +1,4 @@ +ARG ELK_VERSION +FROM docker.elastic.co/elasticsearch/elasticsearch-oss:${ELK_VERSION} +COPY --chown=elasticsearch:elasticsearch elasticsearch.yml /usr/share/elasticsearch/config/ + diff --git a/components/analytics/elasticsearch/elasticsearch.yml b/components/analytics/elasticsearch/elasticsearch.yml new file mode 100644 index 00000000..50e4cf0b --- /dev/null +++ b/components/analytics/elasticsearch/elasticsearch.yml @@ -0,0 +1,3 @@ +http.host: 0.0.0.0 +script.painless.regex.enabled: true +path.repo: ["/usr/share/elasticsearch/data/backup"] diff --git a/components/analytics/kibana/Dockerfile b/components/analytics/kibana/Dockerfile new file mode 100644 index 00000000..f7982dff --- /dev/null +++ b/components/analytics/kibana/Dockerfile @@ -0,0 +1,5 @@ +ARG ELK_VERSION +FROM docker.elastic.co/kibana/kibana-oss:${ELK_VERSION} +COPY kibana.yml /usr/share/kibana/config/ + + diff --git a/components/analytics/kibana/export.json b/components/analytics/kibana/export.json new file mode 100644 index 00000000..477b1f4d --- /dev/null +++ b/components/analytics/kibana/export.json @@ -0,0 +1,198 @@ +[ + { + "_id": "7e8996e0-c23d-11e8-8e1b-758ef07f6de8", + "_type": "dashboard", + "_source": { + "panelsJSON": "[{\"embeddableConfig\":{},\"gridData\":{\"x\":0,\"y\":21,\"w\":48,\"h\":13,\"i\":\"1\"},\"id\":\"3ade53d0-c23e-11e8-8e1b-758ef07f6de8\",\"panelIndex\":\"1\",\"type\":\"visualization\",\"version\":\"6.4.0\"},{\"embeddableConfig\":{},\"gridData\":{\"x\":0,\"y\":34,\"w\":48,\"h\":27,\"i\":\"2\"},\"id\":\"9397f350-c23e-11e8-8e1b-758ef07f6de8\",\"panelIndex\":\"2\",\"type\":\"search\",\"version\":\"6.4.0\"},{\"embeddableConfig\":{},\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":21,\"i\":\"3\"},\"id\":\"1ec6a660-c244-11e8-8e1b-758ef07f6de8\",\"panelIndex\":\"3\",\"type\":\"visualization\",\"version\":\"6.4.0\"},{\"embeddableConfig\":{},\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":21,\"i\":\"4\"},\"id\":\"65918380-c244-11e8-8e1b-758ef07f6de8\",\"panelIndex\":\"4\",\"type\":\"visualization\",\"version\":\"6.4.0\"}]", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[]}" + }, + "timeRestore": false, + "description": "", + "title": "Monitoring", + "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", + "version": 1 + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "d92524b0-c25c-11e8-8e1b-758ef07f6de8", + "_type": "visualization", + "_source": { + "visState": "{\"title\":\"Activity of users\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"timeseries\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"#68BC00\",\"split_mode\":\"terms\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"count\"}],\"separate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"label\":\"User\",\"terms_field\":\"userid.keyword\",\"terms_size\":\"100\"}],\"time_field\":\"@timestamp\",\"index_pattern\":\"cvat*\",\"interval\":\"auto\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"axis_scale\":\"normal\",\"show_legend\":1,\"show_grid\":1},\"aggs\":[]}", + "uiStateJSON": "{}", + "description": "", + "title": "Activity of users", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + }, + "version": 1 + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "9397f350-c23e-11e8-8e1b-758ef07f6de8", + "_type": "search", + "_source": { + "hits": 0, + "sort": [ + "@timestamp", + "desc" + ], + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"ec510550-c238-11e8-8e1b-758ef07f6de8\",\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"event:\\\"Send exception\\\"\"},\"filter\":[]}" + }, + "columns": [ + "task", + "type", + "userid", + "stack" + ], + "description": "", + "title": "Table with exceptions", + "version": 1 + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "3ade53d0-c23e-11e8-8e1b-758ef07f6de8", + "_type": "visualization", + "_source": { + "title": "Timeline for exceptions", + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{\"customBucket\":{\"enabled\":true,\"id\":\"1-bucket\",\"params\":{\"filters\":[{\"input\":{\"query\":\"event:\\\"Send exception\\\"\"},\"label\":\"\"}]},\"schema\":{\"aggFilter\":[],\"deprecate\":false,\"editor\":false,\"group\":\"none\",\"max\":null,\"min\":0,\"name\":\"bucketAgg\",\"params\":[],\"title\":\"Bucket Agg\"},\"type\":\"filters\"},\"customLabel\":\"Exceptions\",\"customMetric\":{\"enabled\":true,\"id\":\"1-metric\",\"params\":{\"customLabel\":\"Exceptions\"},\"schema\":{\"aggFilter\":[\"!top_hits\",\"!percentiles\",\"!percentile_ranks\",\"!median\",\"!std_dev\",\"!sum_bucket\",\"!avg_bucket\",\"!min_bucket\",\"!max_bucket\",\"!derivative\",\"!moving_avg\",\"!serial_diff\",\"!cumulative_sum\"],\"deprecate\":false,\"editor\":false,\"group\":\"none\",\"max\":null,\"min\":0,\"name\":\"metricAgg\",\"params\":[],\"title\":\"Metric Agg\"},\"type\":\"count\"}},\"schema\":\"metric\",\"type\":\"sum_bucket\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"customInterval\":\"2h\",\"customLabel\":\"Time\",\"extended_bounds\":{},\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1},\"schema\":\"segment\",\"type\":\"date_histogram\"}],\"params\":{\"addLegend\":true,\"addTimeMarker\":true,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{},\"type\":\"category\"}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"legendPosition\":\"right\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Exceptions\"},\"drawLinesBetweenPoints\":true,\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"histogram\",\"valueAxis\":\"ValueAxis-1\"}],\"times\":[],\"type\":\"histogram\",\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"Exceptions\"},\"type\":\"value\"}]},\"title\":\"Timeline for exceptions\",\"type\":\"histogram\"}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"ec510550-c238-11e8-8e1b-758ef07f6de8\",\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "1ec6a660-c244-11e8-8e1b-758ef07f6de8", + "_type": "visualization", + "_source": { + "title": "Duration of events", + "visState": "{\"title\":\"Duration of events\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showMetricsAtAllLevels\":false,\"showPartialRows\":false,\"showTotal\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"event.keyword\",\"size\":1000,\"order\":\"desc\",\"orderBy\":\"_key\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"Action\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"avg\",\"schema\":\"metric\",\"params\":{\"field\":\"duration\",\"customLabel\":\"\"}},{\"id\":\"4\",\"enabled\":true,\"type\":\"min\",\"schema\":\"metric\",\"params\":{\"field\":\"duration\",\"customLabel\":\"\"}},{\"id\":\"5\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"duration\"}}]}", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"ec510550-c238-11e8-8e1b-758ef07f6de8\",\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[{\"$state\":{\"store\":\"appState\"},\"exists\":{\"field\":\"duration\"},\"meta\":{\"alias\":null,\"disabled\":false,\"index\":\"ec510550-c238-11e8-8e1b-758ef07f6de8\",\"key\":\"duration\",\"negate\":false,\"type\":\"exists\",\"value\":\"exists\"}}]}" + } + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "ec510550-c238-11e8-8e1b-758ef07f6de8", + "_type": "index-pattern", + "_source": { + "fields": "[{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@version\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@version.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"application\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"application.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"box count\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"duration\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"event\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"event.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"frame count\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"object count\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"points count\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"polygon count\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"polyline count\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"task\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"task.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"timestamp\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"track count\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"userid\",\"type\":\"string\",\"count\":2,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"userid.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"working time\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "title": "cvat*", + "timeFieldName": "@timestamp", + "fieldFormatMap": "{\"duration\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"milliseconds\",\"outputFormat\":\"asSeconds\"}},\"working time\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"milliseconds\",\"outputFormat\":\"asHours\"}}}" + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "65918380-c244-11e8-8e1b-758ef07f6de8", + "_type": "visualization", + "_source": { + "title": "Number of events", + "visState": "{\"title\":\"Number of events\",\"type\":\"table\",\"params\":{\"perPage\":20,\"showMetricsAtAllLevels\":false,\"showPartialRows\":false,\"showTotal\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"event.keyword\",\"size\":1000,\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"Action\"}}]}", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"ec510550-c238-11e8-8e1b-758ef07f6de8\",\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[]}" + } + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "543f6260-c25c-11e8-8e1b-758ef07f6de8", + "_type": "visualization", + "_source": { + "title": "Working day", + "visState": "{\"title\":\"Working day\",\"type\":\"table\",\"params\":{\"perPage\":20,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"min\",\"schema\":\"metric\",\"params\":{\"field\":\"@timestamp\",\"customLabel\":\"Start\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"userid.keyword\",\"size\":1000,\"order\":\"asc\",\"orderBy\":\"_key\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"User\"}},{\"id\":\"4\",\"enabled\":true,\"type\":\"max\",\"schema\":\"metric\",\"params\":{\"field\":\"@timestamp\",\"customLabel\":\"End\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"split\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"d\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{},\"customLabel\":\"day\",\"row\":true}}]}", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"ec510550-c238-11e8-8e1b-758ef07f6de8\",\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "31ac2d60-c25b-11e8-8e1b-758ef07f6de8", + "_type": "visualization", + "_source": { + "title": "List of users", + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"2\",\"params\":{\"customLabel\":\"User\",\"field\":\"userid.keyword\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"order\":\"asc\",\"orderBy\":\"_key\",\"otherBucket\":true,\"otherBucketLabel\":\"Other\",\"size\":1000},\"schema\":\"bucket\",\"type\":\"terms\"},{\"enabled\":true,\"id\":\"3\",\"params\":{\"customBucket\":{\"enabled\":true,\"id\":\"3-bucket\",\"params\":{\"customInterval\":\"2h\",\"extended_bounds\":{},\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1},\"schema\":{\"aggFilter\":[],\"deprecate\":false,\"editor\":false,\"group\":\"none\",\"max\":null,\"min\":0,\"name\":\"bucketAgg\",\"params\":[],\"title\":\"Bucket Agg\"},\"type\":\"date_histogram\"},\"customLabel\":\"Activity\",\"customMetric\":{\"enabled\":true,\"id\":\"3-metric\",\"params\":{},\"schema\":{\"aggFilter\":[\"!top_hits\",\"!percentiles\",\"!percentile_ranks\",\"!median\",\"!std_dev\",\"!sum_bucket\",\"!avg_bucket\",\"!min_bucket\",\"!max_bucket\",\"!derivative\",\"!moving_avg\",\"!serial_diff\",\"!cumulative_sum\"],\"deprecate\":false,\"editor\":false,\"group\":\"none\",\"max\":null,\"min\":0,\"name\":\"metricAgg\",\"params\":[],\"title\":\"Metric Agg\"},\"type\":\"count\"}},\"schema\":\"metric\",\"type\":\"sum_bucket\"},{\"enabled\":true,\"id\":\"1\",\"params\":{\"customLabel\":\"Working Time (h)\",\"field\":\"working time\"},\"schema\":\"metric\",\"type\":\"sum\"}],\"params\":{\"perPage\":20,\"showMetricsAtAllLevels\":false,\"showPartialRows\":false,\"showTotal\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"totalFunc\":\"sum\"},\"title\":\"List of users\",\"type\":\"table\"}", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"ec510550-c238-11e8-8e1b-758ef07f6de8\",\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[]}" + } + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "7f637200-d068-11e8-9320-a3c87be2b433", + "_type": "visualization", + "_source": { + "title": "List of tasks", + "visState": "{\"title\":\"List of tasks\",\"type\":\"table\",\"params\":{\"perPage\":20,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":2,\"direction\":\"desc\"},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"sum\",\"schema\":\"metric\",\"params\":{\"field\":\"working time\",\"customLabel\":\"Working time (h)\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"task.keyword\",\"size\":1000,\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"Task\"}},{\"id\":\"4\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"userid.keyword\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"_key\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"User\"}}]}", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":2,\"direction\":\"desc\"}}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"ec510550-c238-11e8-8e1b-758ef07f6de8\",\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "22250a40-c25d-11e8-8e1b-758ef07f6de8", + "_type": "dashboard", + "_source": { + "title": "Managment", + "hits": 0, + "description": "", + "panelsJSON": "[{\"embeddableConfig\":{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":1,\"direction\":\"desc\"}}}},\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":33,\"i\":\"1\"},\"id\":\"31ac2d60-c25b-11e8-8e1b-758ef07f6de8\",\"panelIndex\":\"1\",\"type\":\"visualization\",\"version\":\"6.4.0\"},{\"embeddableConfig\":{},\"gridData\":{\"x\":0,\"y\":33,\"w\":48,\"h\":33,\"i\":\"2\"},\"id\":\"543f6260-c25c-11e8-8e1b-758ef07f6de8\",\"panelIndex\":\"2\",\"type\":\"visualization\",\"version\":\"6.4.0\"},{\"embeddableConfig\":{},\"gridData\":{\"x\":0,\"y\":66,\"w\":48,\"h\":33,\"i\":\"3\"},\"id\":\"d92524b0-c25c-11e8-8e1b-758ef07f6de8\",\"panelIndex\":\"3\",\"type\":\"visualization\",\"version\":\"6.4.0\"},{\"embeddableConfig\":{},\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":33,\"i\":\"4\"},\"id\":\"7f637200-d068-11e8-9320-a3c87be2b433\",\"panelIndex\":\"4\",\"type\":\"visualization\",\"version\":\"6.4.0\"}]", + "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[]}" + } + }, + "_meta": { + "savedObjectVersion": 2 + } + } +] \ No newline at end of file diff --git a/components/analytics/kibana/kibana.yml b/components/analytics/kibana/kibana.yml new file mode 100644 index 00000000..457aec60 --- /dev/null +++ b/components/analytics/kibana/kibana.yml @@ -0,0 +1,5 @@ +server.host: 0.0.0.0 +elasticsearch.url: http://elasticsearch:9200 +elasticsearch.requestHeadersWhitelist: [ "cookie", "authorization", "x-forwarded-user" ] +kibana.defaultAppId: "discover" +server.basePath: /analytics diff --git a/components/analytics/kibana/setup.py b/components/analytics/kibana/setup.py new file mode 100644 index 00000000..f1877ffe --- /dev/null +++ b/components/analytics/kibana/setup.py @@ -0,0 +1,40 @@ +#/usr/bin/env python + +import os +import argparse +import requests +import json + +def import_resources(host, port, cfg_file): + with open(cfg_file, 'r') as f: + for saved_object in json.load(f): + _id = saved_object["_id"] + _type = saved_object["_type"] + _doc = saved_object["_source"] + import_saved_object(host, port, _type, _id, _doc) + +def import_saved_object(host, port, _type, _id, data): + saved_objects_api = "http://{}:{}/api/saved_objects/{}/{}".format( + host, port, _type, _id) + request = requests.get(saved_objects_api) + if request.status_code == 404: + print("Creating {} as {}".format(_type, _id)) + request = requests.post(saved_objects_api, json={"attributes": data}, + headers={'kbn-xsrf': 'true'}) + else: + print("Updating {} named {}".format(_type, _id)) + request = requests.put(saved_objects_api, json={"attributes": data}, + headers={'kbn-xsrf': 'true'}) + request.raise_for_status() + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='import Kibana 6.x resources', + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument('export_file', metavar='FILE', + help='JSON export file with resources') + parser.add_argument('-p', '--port', metavar='PORT', default=5601, type=int, + help='port of Kibana instance') + parser.add_argument('-H', '--host', metavar='HOST', default='kibana', + help='host of Kibana instance') + args = parser.parse_args() + import_resources(args.host, args.port, args.export_file) diff --git a/components/analytics/logstash/Dockerfile b/components/analytics/logstash/Dockerfile new file mode 100644 index 00000000..ad012ccf --- /dev/null +++ b/components/analytics/logstash/Dockerfile @@ -0,0 +1,7 @@ +ARG ELK_VERSION +FROM docker.elastic.co/logstash/logstash-oss:${ELK_VERSION} +RUN logstash-plugin install logstash-input-http logstash-filter-aggregate \ + logstash-filter-prune logstash-output-email + +COPY logstash.conf /usr/share/logstash/pipeline/ +EXPOSE 5000 diff --git a/components/analytics/logstash/logstash.conf b/components/analytics/logstash/logstash.conf new file mode 100644 index 00000000..34a9a0a3 --- /dev/null +++ b/components/analytics/logstash/logstash.conf @@ -0,0 +1,98 @@ +input { + tcp { + port => 5000 + codec => json + } +} + +filter { + if [logger_name] =~ /cvat.client/ { + # 1. Decode the event from json in 'message' field + # 2. Remove unnecessary field from it + # 3. Type it as client + json { + source => "message" + } + + date { + match => ["timestamp", "UNIX", "UNIX_MS"] + remove_field => "timestamp" + } + + if [event] == "Send exception" { + aggregate { + task_id => "%{userid}_%{application}_%{message}_%{filename}_%{line}" + code => " + require 'time' + + map['userid'] ||= event.get('userid'); + map['application'] ||= event.get('application'); + map['error'] ||= event.get('message'); + map['filename'] ||= event.get('filename'); + map['line'] ||= event.get('line'); + map['task'] ||= event.get('task'); + + map['error_count'] ||= 0; + map['error_count'] += 1; + + map['aggregated_stack'] ||= ''; + map['aggregated_stack'] += event.get('stack') + '\n\n\n';" + + timeout => 3600 + timeout_tags => ['aggregated_exception'] + push_map_as_event_on_timeout => true + } + } + + prune { + blacklist_names => ["level", "host", "logger_name", "message", "path", + "port", "stack_info"] + } + + mutate { + replace => { "type" => "client" } + } + } else if [logger_name] =~ /cvat.server/ { + # 1. Remove 'logger_name' field and create 'task' field + # 2. Remove unnecessary field from it + # 3. Type it as server + if [logger_name] =~ /cvat\.server\.task_[0-9]+/ { + mutate { + rename => { "logger_name" => "task" } + gsub => [ "task", "cvat.server.task_", "" ] + } + + # Need to split the mutate because otherwise the conversion + # doesn't work. + mutate { + convert => { "task" => "integer" } + } + } + + prune { + blacklist_names => ["host", "port", "stack_info"] + } + + mutate { + replace => { "type" => "server" } + } + } +} + +output { + stdout { + codec => rubydebug + } + + if [type] == "client" { + elasticsearch { + hosts => ["elasticsearch:9200"] + index => "cvat.client" + } + } else if [type] == "server" { + elasticsearch { + hosts => ["elasticsearch:9200"] + index => "cvat.server" + } + } +} diff --git a/components/cuda/README.md b/components/cuda/README.md new file mode 100644 index 00000000..8255615f --- /dev/null +++ b/components/cuda/README.md @@ -0,0 +1,41 @@ +## [NVIDIA CUDA Toolkit](https://developer.nvidia.com/cuda-toolkit) + +### Requirements + +* NVIDIA GPU with a compute capability [3.0 - 7.2] +* Latest GPU driver + +### Installation + +#### Install the latest driver for your graphics card + +```bash +sudo add-apt-repository ppa:graphics-drivers/ppa +sudo apt-get update +sudo apt-cache search nvidia-* # find latest nvidia driver +sudo apt-get install nvidia-* # install the nvidia driver +sudo apt-get install mesa-common-dev +sudo apt-get install freeglut3-dev +sudo apt-get install nvidia-modprobe +``` + +#### Reboot your PC and verify installation by `nvidia-smi` command. + +#### Install [Nvidia-Docker](https://github.com/NVIDIA/nvidia-docker) + +Please be sure that installation was successful. +```bash +docker info | grep 'Runtimes' # output should contains 'nvidia' +``` + +### Build docker image +```bash +# From project root directory +docker-compose -f docker-compose.yml -f components/cuda/docker-compose.cuda.yml build +``` + +### Run docker container +```bash +# From project root directory +docker-compose -f docker-compose.yml -f components/cuda/docker-compose.cuda.yml up -d +``` diff --git a/docker-compose.nvidia.yml b/components/cuda/docker-compose.cuda.yml similarity index 91% rename from docker-compose.nvidia.yml rename to components/cuda/docker-compose.cuda.yml index 5721c3ff..66445f12 100644 --- a/docker-compose.nvidia.yml +++ b/components/cuda/docker-compose.cuda.yml @@ -10,7 +10,7 @@ services: build: context: . args: - TF_ANNOTATION: "yes" + CUDA_SUPPORT: "yes" runtime: "nvidia" environment: NVIDIA_VISIBLE_DEVICES: all diff --git a/cvat/apps/tf_annotation/docker_setup_tf_annotation.sh b/components/cuda/install.sh similarity index 72% rename from cvat/apps/tf_annotation/docker_setup_tf_annotation.sh rename to components/cuda/install.sh index 42dccd54..dd689f77 100755 --- a/cvat/apps/tf_annotation/docker_setup_tf_annotation.sh +++ b/components/cuda/install.sh @@ -18,8 +18,8 @@ CUDA_VERSION=9.0.176 NCCL_VERSION=2.1.15 CUDNN_VERSION=7.0.5.15 CUDA_PKG_VERSION="9-0=${CUDA_VERSION}-1" -echo "export PATH=/usr/local/nvidia/bin:/usr/local/cuda/bin:${PATH}" >> ${HOME}/.bashrc -echo "export LD_LIBRARY_PATH=/usr/local/nvidia/lib:/usr/local/nvidia/lib64:${LD_LIBRARY_PATH}" >> ${HOME}/.bashrc +echo 'export PATH=/usr/local/nvidia/bin:/usr/local/cuda/bin:${PATH}' >> ${HOME}/.bashrc +echo 'export LD_LIBRARY_PATH=/usr/local/nvidia/lib:/usr/local/nvidia/lib64:${LD_LIBRARY_PATH}' >> ${HOME}/.bashrc apt-get update && apt-get install -y --no-install-recommends --allow-unauthenticated \ libprotobuf-dev \ @@ -32,11 +32,3 @@ apt-get update && apt-get install -y --no-install-recommends --allow-unauthentic ln -s cuda-9.0 /usr/local/cuda && \ rm -rf /var/lib/apt/lists/* \ /etc/apt/sources.list.d/nvidia-ml.list /etc/apt/sources.list.d/cuda.list - -pip3 install --no-cache-dir -r "$(cd `dirname $0` && pwd)/requirements.txt" - -cd ${HOME} -wget -O model.tar.gz http://download.tensorflow.org/models/object_detection/faster_rcnn_inception_resnet_v2_atrous_coco_11_06_2017.tar.gz -tar -xzf model.tar.gz -rm model.tar.gz -mv faster_rcnn_inception_resnet_v2_atrous_coco_11_06_2017 rcnn diff --git a/components/openvino/README.md b/components/openvino/README.md new file mode 100644 index 00000000..e763d26d --- /dev/null +++ b/components/openvino/README.md @@ -0,0 +1,23 @@ +## [Intel OpenVINO toolkit](https://software.intel.com/en-us/openvino-toolkit) + +### Requirements + +* Intel Core with 6th generation and higher or Intel Xeon CPUs. + +### Preparation + +* Download latest [OpenVINO toolkit](https://software.intel.com/en-us/openvino-toolkit) installer (offline or online) for Linux platform. It should be .tgz archive. Minimum required version is 2018 R3. +* Put downloaded file into ```components/openvino```. +* Accept EULA in the eula.cfg file. + +### Build docker image +```bash +# From project root directory +docker-compose -f docker-compose.yml -f components/openvino/docker-compose.openvino.yml build +``` + +### Run docker container +```bash +# From project root directory +docker-compose -f docker-compose.yml -f components/openvino/docker-compose.openvino.yml up -d +``` diff --git a/components/openvino/docker-compose.openvino.yml b/components/openvino/docker-compose.openvino.yml new file mode 100644 index 00000000..3805f1b8 --- /dev/null +++ b/components/openvino/docker-compose.openvino.yml @@ -0,0 +1,13 @@ +# +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT +# +version: "2.3" + +services: + cvat: + build: + context: . + args: + OPENVINO_TOOLKIT: "yes" diff --git a/components/openvino/eula.cfg b/components/openvino/eula.cfg new file mode 100644 index 00000000..7c34e8fe --- /dev/null +++ b/components/openvino/eula.cfg @@ -0,0 +1,3 @@ +# Accept actual EULA from openvino installation archive. Valid values are: {accept, decline} +ACCEPT_EULA=accept + diff --git a/components/openvino/install.sh b/components/openvino/install.sh new file mode 100755 index 00000000..54e87418 --- /dev/null +++ b/components/openvino/install.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT +# +set -e + +if [[ `lscpu | grep -o "GenuineIntel"` != "GenuineIntel" ]]; then + echo "OpenVINO supports only Intel CPUs" + exit 1 +fi + +if [[ `lscpu | grep -o "sse4" | head -1` != "sse4" ]] && [[ `lscpu | grep -o "avx2" | head -1` != "avx2" ]]; then + echo "You Intel CPU should support sse4 or avx2 instruction if you want use OpenVINO" + exit 1 +fi + + +cd /tmp/components/openvino + +tar -xzf `ls | grep "openvino_toolkit"` +cd `ls -d */ | grep "openvino_toolkit"` + +apt-get update && apt-get install -y sudo cpio && \ + ./install_cv_sdk_dependencies.sh && SUDO_FORCE_REMOVE=yes apt-get remove -y sudo + +cat ../eula.cfg >> silent.cfg +./install.sh -s silent.cfg + +cd /tmp/components && rm openvino -r + +echo "source /opt/intel/computer_vision_sdk/bin/setupvars.sh" >> ${HOME}/.bashrc +echo -e '\nexport IE_PLUGINS_PATH=${IE_PLUGINS_PATH}' >> /opt/intel/computer_vision_sdk/bin/setupvars.sh diff --git a/components/tf_annotation/README.md b/components/tf_annotation/README.md new file mode 100644 index 00000000..5a9a2c10 --- /dev/null +++ b/components/tf_annotation/README.md @@ -0,0 +1,41 @@ +## [Tensorflow Object Detector](https://github.com/tensorflow/models/tree/master/research/object_detection) + +### What is it? +* This application allows you automatically to annotate many various objects on images. +* It uses [Faster RCNN Inception Resnet v2 Atrous Coco Model](http://download.tensorflow.org/models/object_detection/faster_rcnn_inception_resnet_v2_atrous_coco_2018_01_28.tar.gz) from [tensorflow detection model zoo](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/detection_model_zoo.md) +* It can work on CPU (with Tensorflow or OpenVINO) or GPU (with Tensorflow GPU). +* It supports next classes (just specify them in "labels" row): +``` +'surfboard', 'car', 'skateboard', 'boat', 'clock', +'cat', 'cow', 'knife', 'apple', 'cup', 'tv', +'baseball_bat', 'book', 'suitcase', 'tennis_racket', +'stop_sign', 'couch', 'cell_phone', 'keyboard', +'cake', 'tie', 'frisbee', 'truck', 'fire_hydrant', +'snowboard', 'bed', 'vase', 'teddy_bear', +'toaster', 'wine_glass', 'traffic_light', +'broccoli', 'backpack', 'carrot', 'potted_plant', +'donut', 'umbrella', 'parking_meter', 'bottle', +'sandwich', 'motorcycle', 'bear', 'banana', +'person', 'scissors', 'elephant', 'dining_table', +'toothbrush', 'toilet', 'skis', 'bowl', 'sheep', +'refrigerator', 'oven', 'microwave', 'train', +'orange', 'mouse', 'laptop', 'bench', 'bicycle', +'fork', 'kite', 'zebra', 'baseball_glove', 'bus', +'spoon', 'horse', 'handbag', 'pizza', 'sports_ball', +'airplane', 'hair_drier', 'hot_dog', 'remote', +'sink', 'dog', 'bird', 'giraffe', 'chair'. +``` +* Component adds "Run TF Annotation" button into dashboard. + + +### Build docker image +```bash +# From project root directory +docker-compose -f docker-compose.yml -f components/tf_annotation/docker-compose.tf_annotation.yml build +``` + +### Run docker container +```bash +# From project root directory +docker-compose -f docker-compose.yml -f components/tf_annotation/docker-compose.tf_annotation.yml up -d +``` diff --git a/components/tf_annotation/docker-compose.tf_annotation.yml b/components/tf_annotation/docker-compose.tf_annotation.yml new file mode 100644 index 00000000..89fd7844 --- /dev/null +++ b/components/tf_annotation/docker-compose.tf_annotation.yml @@ -0,0 +1,13 @@ +# +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT +# +version: "2.3" + +services: + cvat: + build: + context: . + args: + TF_ANNOTATION: "yes" diff --git a/components/tf_annotation/install.sh b/components/tf_annotation/install.sh new file mode 100755 index 00000000..0bf3f8d3 --- /dev/null +++ b/components/tf_annotation/install.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT +# +set -e + +cd ${HOME} && \ +wget -O model.tar.gz http://download.tensorflow.org/models/object_detection/faster_rcnn_inception_resnet_v2_atrous_coco_2018_01_28.tar.gz && \ +tar -xzf model.tar.gz && rm model.tar.gz && \ +mv faster_rcnn_inception_resnet_v2_atrous_coco_2018_01_28 ${HOME}/rcnn && cd ${HOME} && \ +mv rcnn/frozen_inference_graph.pb rcnn/inference_graph.pb + +if [[ "$CUDA_SUPPORT" = "yes" ]] +then + pip3 install --no-cache-dir tensorflow-gpu==1.7.0 +else + if [[ "$OPENVINO_TOOLKIT" = "yes" ]] + then + pip3 install -r ${INTEL_CVSDK_DIR}/deployment_tools/model_optimizer/requirements.txt && \ + cd ${HOME}/rcnn/ && \ + ${INTEL_CVSDK_DIR}/deployment_tools/model_optimizer/mo.py --framework tf \ + --data_type FP32 --input_shape [1,600,600,3] \ + --input image_tensor --output detection_scores,detection_boxes,num_detections \ + --tensorflow_use_custom_operations_config ${INTEL_CVSDK_DIR}/deployment_tools/model_optimizer/extensions/front/tf/faster_rcnn_support.json \ + --tensorflow_object_detection_api_pipeline_config pipeline.config --input_model inference_graph.pb && \ + rm inference_graph.pb + else + pip3 install --no-cache-dir tensorflow==1.7.0 + fi +fi diff --git a/cvat/apps/authentication/__init__.py b/cvat/apps/authentication/__init__.py index a7b92720..4921b1df 100644 --- a/cvat/apps/authentication/__init__.py +++ b/cvat/apps/authentication/__init__.py @@ -5,3 +5,13 @@ default_app_config = 'cvat.apps.authentication.apps.AuthenticationConfig' +from enum import Enum + +class AUTH_ROLE(Enum): + ADMIN = 'admin' + USER = 'user' + ANNOTATOR = 'annotator' + OBSERVER = 'observer' + + def __str__(self): + return self.value diff --git a/cvat/apps/authentication/admin.py b/cvat/apps/authentication/admin.py index af8dfc47..3267ca68 100644 --- a/cvat/apps/authentication/admin.py +++ b/cvat/apps/authentication/admin.py @@ -4,6 +4,24 @@ # SPDX-License-Identifier: MIT from django.contrib import admin +from django.contrib.auth.models import Group, User +from django.contrib.auth.admin import GroupAdmin, UserAdmin +from django.utils.translation import ugettext_lazy as _ -# Register your models here. +class CustomUserAdmin(UserAdmin): + fieldsets = ( + (None, {'fields': ('username', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), + (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', + 'groups',)}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + ) +class CustomGroupAdmin(GroupAdmin): + fieldsets = ((None, {'fields': ('name',)}),) + + +admin.site.unregister(User) +admin.site.unregister(Group) +admin.site.register(User, CustomUserAdmin) +admin.site.register(Group, CustomGroupAdmin) \ No newline at end of file diff --git a/cvat/apps/authentication/apps.py b/cvat/apps/authentication/apps.py index c0e41e42..c6f9e549 100644 --- a/cvat/apps/authentication/apps.py +++ b/cvat/apps/authentication/apps.py @@ -4,20 +4,11 @@ # SPDX-License-Identifier: MIT from django.apps import AppConfig -from django.db.models.signals import post_migrate, post_save -from .settings.authentication import DJANGO_AUTH_TYPE class AuthenticationConfig(AppConfig): name = 'cvat.apps.authentication' def ready(self): - from . import signals - from django.contrib.auth.models import User + from .auth import register_signals - post_migrate.connect(signals.create_groups) - - if DJANGO_AUTH_TYPE == 'SIMPLE': - post_save.connect(signals.create_user, sender=User, dispatch_uid="create_user") - - import django_auth_ldap.backend - django_auth_ldap.backend.populate_user.connect(signals.update_ldap_groups) + register_signals() diff --git a/cvat/apps/authentication/auth.py b/cvat/apps/authentication/auth.py new file mode 100644 index 00000000..0da83216 --- /dev/null +++ b/cvat/apps/authentication/auth.py @@ -0,0 +1,80 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os +from django.conf import settings +import rules +from . import AUTH_ROLE + +def register_signals(): + from django.db.models.signals import post_migrate, post_save + from django.contrib.auth.models import User, Group + + def create_groups(sender, **kwargs): + for role in AUTH_ROLE: + db_group, _ = Group.objects.get_or_create(name=role) + db_group.save() + + post_migrate.connect(create_groups, weak=False) + + if settings.DJANGO_AUTH_TYPE == 'BASIC': + from .auth_basic import create_user + + post_save.connect(create_user, sender=User) + elif settings.DJANGO_AUTH_TYPE == 'LDAP': + import django_auth_ldap.backend + from .auth_ldap import create_user + + django_auth_ldap.backend.populate_user.connect(create_user) + +# AUTH PREDICATES +has_admin_role = rules.is_group_member(str(AUTH_ROLE.ADMIN)) +has_user_role = rules.is_group_member(str(AUTH_ROLE.USER)) +has_annotator_role = rules.is_group_member(str(AUTH_ROLE.ANNOTATOR)) +has_observer_role = rules.is_group_member(str(AUTH_ROLE.OBSERVER)) + +@rules.predicate +def is_task_owner(db_user, db_task): + # If owner is None (null) the task can be accessed/changed/deleted + # only by admin. At the moment each task has an owner. + return db_task.owner == db_user + +@rules.predicate +def is_task_assignee(db_user, db_task): + return db_task.assignee == db_user + +@rules.predicate +def is_task_annotator(db_user, db_task): + from functools import reduce + + db_segments = list(db_task.segment_set.prefetch_related('job_set__assignee').all()) + return any([is_job_annotator(db_user, db_job) + for db_segment in db_segments for db_job in db_segment.job_set.all()]) + +@rules.predicate +def is_job_owner(db_user, db_job): + return is_task_owner(db_user, db_job.segment.task) + +@rules.predicate +def is_job_annotator(db_user, db_job): + db_task = db_job.segment.task + # A job can be annotated by any user if the task's assignee is None. + has_rights = db_task.assignee is None or is_task_assignee(db_user, db_task) + if db_job.assignee is not None: + has_rights |= (db_user == db_job.assignee) + + return has_rights + +# AUTH PERMISSIONS RULES +rules.add_perm('engine.task.create', has_admin_role | has_user_role) +rules.add_perm('engine.task.access', has_admin_role | has_observer_role | + is_task_owner | is_task_annotator) +rules.add_perm('engine.task.change', has_admin_role | is_task_owner | + is_task_assignee) +rules.add_perm('engine.task.delete', has_admin_role | is_task_owner) + +rules.add_perm('engine.job.access', has_admin_role | has_observer_role | + is_job_owner | is_job_annotator) +rules.add_perm('engine.job.change', has_admin_role | is_job_owner | + is_job_annotator) diff --git a/cvat/apps/authentication/auth_basic.py b/cvat/apps/authentication/auth_basic.py new file mode 100644 index 00000000..936628e7 --- /dev/null +++ b/cvat/apps/authentication/auth_basic.py @@ -0,0 +1,12 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT +from . import AUTH_ROLE +from django.conf import settings + +def create_user(sender, instance, created, **kwargs): + from django.contrib.auth.models import Group + + if instance.is_superuser and instance.is_staff: + db_group = Group.objects.get(name=AUTH_ROLE.ADMIN) + instance.groups.add(db_group) diff --git a/cvat/apps/authentication/auth_ldap.py b/cvat/apps/authentication/auth_ldap.py new file mode 100644 index 00000000..1c1cee2b --- /dev/null +++ b/cvat/apps/authentication/auth_ldap.py @@ -0,0 +1,33 @@ + +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from django.conf import settings +from . import AUTH_ROLE + +AUTH_LDAP_GROUPS = { + AUTH_ROLE.ADMIN: settings.AUTH_LDAP_ADMIN_GROUPS, + AUTH_ROLE.ANNOTATOR: settings.AUTH_LDAP_ANNOTATOR_GROUPS, + AUTH_ROLE.USER: settings.AUTH_LDAP_USER_GROUPS, + AUTH_ROLE.OBSERVER: settings.AUTH_LDAP_OBSERVER_GROUPS +} + +def create_user(sender, user=None, ldap_user=None, **kwargs): + from django.contrib.auth.models import Group + user_groups = [] + for role in AUTH_ROLE: + db_group = Group.objects.get(name=role) + + for ldap_group in AUTH_LDAP_GROUPS[role]: + if ldap_group.lower() in ldap_user.group_dns: + user_groups.append(db_group) + if role == AUTH_ROLE.ADMIN: + user.is_staff = user.is_superuser = True + + # It is important to save the user before adding groups. Please read + # https://django-auth-ldap.readthedocs.io/en/latest/users.html#populating-users + # The user instance will be saved automatically after the signal handler + # is run. + user.save() + user.groups.set(user_groups) diff --git a/cvat/apps/authentication/decorators.py b/cvat/apps/authentication/decorators.py index 3ef9d263..dc0b107f 100644 --- a/cvat/apps/authentication/decorators.py +++ b/cvat/apps/authentication/decorators.py @@ -3,16 +3,16 @@ # # SPDX-License-Identifier: MIT +from functools import wraps +from urllib.parse import urlparse from django.contrib.auth import REDIRECT_FIELD_NAME from django.shortcuts import resolve_url, reverse from django.http import JsonResponse -from urllib.parse import urlparse from django.contrib.auth.views import redirect_to_login - -from functools import wraps from django.conf import settings -def login_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None, redirect_methods=['GET']): +def login_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, + login_url=None, redirect_methods=['GET']): def decorator(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): diff --git a/cvat/apps/authentication/settings/__init__.py b/cvat/apps/authentication/settings/__init__.py deleted file mode 100644 index d8e62e54..00000000 --- a/cvat/apps/authentication/settings/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - diff --git a/cvat/apps/authentication/settings/auth_ldap.py b/cvat/apps/authentication/settings/auth_ldap.py deleted file mode 100644 index 01021060..00000000 --- a/cvat/apps/authentication/settings/auth_ldap.py +++ /dev/null @@ -1,56 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.conf import settings -import ldap -from django_auth_ldap.config import LDAPSearch, NestedActiveDirectoryGroupType - -# Baseline configuration. -settings.AUTH_LDAP_SERVER_URI = "" - -# Credentials for LDAP server -settings.AUTH_LDAP_BIND_DN = "" -settings.AUTH_LDAP_BIND_PASSWORD = "" - -# Set up basic user search -settings.AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com", - ldap.SCOPE_SUBTREE, "(sAMAccountName=%(user)s)") - -# Set up the basic group parameters. -settings.AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", - ldap.SCOPE_SUBTREE, "(objectClass=group)") -settings.AUTH_LDAP_GROUP_TYPE = NestedActiveDirectoryGroupType() - -# # Simple group restrictions -settings.AUTH_LDAP_REQUIRE_GROUP = "cn=cvat,ou=Groups,dc=example,dc=com" - -# Populate the Django user from the LDAP directory. -settings.AUTH_LDAP_USER_ATTR_MAP = { - "first_name": "givenName", - "last_name": "sn", - "email": "mail", -} - -settings.AUTH_LDAP_ALWAYS_UPDATE_USER = True - -# Cache group memberships for an hour to minimize LDAP traffic -settings.AUTH_LDAP_CACHE_GROUPS = True -settings.AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 -settings.AUTH_LDAP_AUTHORIZE_ALL_USERS = True - -# Keep ModelBackend around for per-user permissions and maybe a local -# superuser. -settings.AUTHENTICATION_BACKENDS.append('django_auth_ldap.backend.LDAPBackend') - -AUTH_LDAP_ADMIN_GROUPS = [ - "cn=cvat_admins,ou=Groups,dc=example,dc=com" -] - -AUTH_LDAP_DATA_ANNOTATORS_GROUPS = [ -] - -AUTH_LDAP_DEVELOPER_GROUPS = [ - "cn=cvat_users,ou=Groups,dc=example,dc=com" -] diff --git a/cvat/apps/authentication/settings/auth_simple.py b/cvat/apps/authentication/settings/auth_simple.py deleted file mode 100644 index 41a18c98..00000000 --- a/cvat/apps/authentication/settings/auth_simple.py +++ /dev/null @@ -1,8 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# Specify groups that new users will have -AUTH_SIMPLE_DEFAULT_GROUPS = [] - diff --git a/cvat/apps/authentication/settings/authentication.py b/cvat/apps/authentication/settings/authentication.py deleted file mode 100644 index 3948d0ee..00000000 --- a/cvat/apps/authentication/settings/authentication.py +++ /dev/null @@ -1,58 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.conf import settings -import os - -settings.LOGIN_URL = 'login' -settings.LOGIN_REDIRECT_URL = '/' - -settings.AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', -] - -AUTH_LDAP_DEVELOPER_GROUPS = [] -AUTH_LDAP_DATA_ANNOTATORS_GROUPS = [] -AUTH_LDAP_ADMIN_GROUPS = [] - -DJANGO_AUTH_TYPE = 'LDAP' if os.environ.get('DJANGO_AUTH_TYPE', '') == 'LDAP' else 'SIMPLE' - -if DJANGO_AUTH_TYPE == 'LDAP': - from .auth_ldap import * -else: - from .auth_simple import * - -# Definition of CVAT groups with permissions for task and annotation objects -# Annotator - can modify annotation for task, but cannot add, change and delete tasks -# Developer - can create tasks and modify (delete) owned tasks and any actions with annotation -# Admin - can any actions for task and annotation, can login to admin area and manage groups and users -cvat_groups_definition = { - 'user': { - 'description': '', - 'permissions': { - 'task': ['view', 'add', 'change', 'delete'], - 'annotation': ['view', 'change'], - }, - 'ldap_groups': AUTH_LDAP_DEVELOPER_GROUPS, - }, - - 'annotator': { - 'description': '', - 'permissions': { - 'task': ['view'], - 'annotation': ['view', 'change'], - }, - 'ldap_groups': AUTH_LDAP_DATA_ANNOTATORS_GROUPS, - }, - - 'admin': { - 'description': '', - 'permissions': { - 'task': ['view', 'add', 'change', 'delete'], - 'annotation': ['view', 'change'], - }, - 'ldap_groups': AUTH_LDAP_ADMIN_GROUPS, - }, -} diff --git a/cvat/apps/authentication/signals.py b/cvat/apps/authentication/signals.py deleted file mode 100644 index 2dfc89dc..00000000 --- a/cvat/apps/authentication/signals.py +++ /dev/null @@ -1,62 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.db import models - -from django.conf import settings -from .settings import authentication -from django.contrib.auth.models import User, Group - -def setup_group_permissions(group): - from cvat.apps.engine.models import Task - from django.contrib.auth.models import Permission - from django.contrib.contenttypes.models import ContentType - - def append_permissions_for_model(model): - content_type = ContentType.objects.get_for_model(model) - for perm_target, actions in authentication.cvat_groups_definition[group.name]['permissions'].items(): - for action in actions: - codename = '{}_{}'.format(action, perm_target) - try: - perm = Permission.objects.get(codename=codename, content_type=content_type) - group_permissions.append(perm) - except: - pass - group_permissions = [] - append_permissions_for_model(Task) - - group.permissions.set(group_permissions) - group.save() - -def create_groups(sender, **kwargs): - for cvat_role, _ in authentication.cvat_groups_definition.items(): - Group.objects.get_or_create(name=cvat_role) - -def update_ldap_groups(sender, user=None, ldap_user=None, **kwargs): - user_groups = [] - for cvat_role, role_settings in authentication.cvat_groups_definition.items(): - group_instance, _ = Group.objects.get_or_create(name=cvat_role) - setup_group_permissions(group_instance) - - for ldap_group in role_settings['ldap_groups']: - if ldap_group.lower() in ldap_user.group_dns: - user_groups.append(group_instance) - - user.save() - user.groups.set(user_groups) - user.is_staff = user.is_superuser = user.groups.filter(name='admin').exists() - -def create_user(sender, instance, created, **kwargs): - if instance.is_superuser and instance.is_staff: - admin_group, _ = Group.objects.get_or_create(name='admin') - admin_group.user_set.add(instance) - - if created: - for cvat_role, _ in authentication.cvat_groups_definition.items(): - group_instance, _ = Group.objects.get_or_create(name=cvat_role) - setup_group_permissions(group_instance) - - if cvat_role in authentication.AUTH_SIMPLE_DEFAULT_GROUPS: - instance.groups.add(group_instance) diff --git a/cvat/apps/authentication/templates/login.html b/cvat/apps/authentication/templates/login.html index 128a8ccb..855031f7 100644 --- a/cvat/apps/authentication/templates/login.html +++ b/cvat/apps/authentication/templates/login.html @@ -23,5 +23,7 @@ {% endblock %} {% block note%} -

Have not registered yet? Register here.

+ {% autoescape off %} + {{ note }} + {% endautoescape %} {% endblock %} \ No newline at end of file diff --git a/cvat/apps/authentication/templates/login_ldap.html b/cvat/apps/authentication/templates/login_ldap.html deleted file mode 100644 index 8cba181d..00000000 --- a/cvat/apps/authentication/templates/login_ldap.html +++ /dev/null @@ -1,27 +0,0 @@ - -{% extends "auth_base.html" %} - -{% block title %}Login{% endblock %} - -{% block content %} -

Login

- {% if form.errors %} - Your username and password didn't match. Please try again. - {% endif %} -
- {% csrf_token %} - {% for field in form %} - {{ field }} - {% endfor %} - - -
-{% endblock %} - -{% block note %} - {% include "note.html" %} -{% endblock %} diff --git a/cvat/apps/authentication/templates/note.html b/cvat/apps/authentication/templates/note.html deleted file mode 100644 index ba2fea92..00000000 --- a/cvat/apps/authentication/templates/note.html +++ /dev/null @@ -1,7 +0,0 @@ - -

-

\ No newline at end of file diff --git a/cvat/apps/authentication/urls.py b/cvat/apps/authentication/urls.py index d1227171..e05d7340 100644 --- a/cvat/apps/authentication/urls.py +++ b/cvat/apps/authentication/urls.py @@ -4,17 +4,20 @@ # SPDX-License-Identifier: MIT from django.urls import path -import os - from django.contrib.auth import views as auth_views +from django.conf import settings + from . import forms from . import views -from .settings.authentication import DJANGO_AUTH_TYPE - -login_page = 'login{}.html'.format('_ldap' if DJANGO_AUTH_TYPE == 'LDAP' else '') urlpatterns = [ - path('login', auth_views.LoginView.as_view(form_class=forms.AuthForm, template_name=login_page), name='login'), + path('login', auth_views.LoginView.as_view(form_class=forms.AuthForm, + template_name='login.html', extra_context={'note': settings.AUTH_LOGIN_NOTE}), + name='login'), path('logout', auth_views.LogoutView.as_view(next_page='login'), name='logout'), - path('register', views.register_user, name='register'), ] + +if settings.DJANGO_AUTH_TYPE == 'BASIC': + urlpatterns += [ + path('register', views.register_user, name='register'), + ] diff --git a/cvat/apps/authentication/views.py b/cvat/apps/authentication/views.py index 2964cf4b..c8effb07 100644 --- a/cvat/apps/authentication/views.py +++ b/cvat/apps/authentication/views.py @@ -3,13 +3,12 @@ # # SPDX-License-Identifier: MIT -from django.shortcuts import render -from django.contrib.auth.views import LoginView -from django.http import HttpResponseRedirect +from django.shortcuts import render, redirect +from django.conf import settings +from django.contrib.auth import login, authenticate + from . import forms -from django.contrib.auth import login, authenticate -from django.shortcuts import render, redirect def register_user(request): if request.method == 'POST': @@ -20,7 +19,7 @@ def register_user(request): raw_password = form.cleaned_data.get('password1') user = authenticate(username=username, password=raw_password) login(request, user) - return redirect('/') + return redirect(settings.LOGIN_REDIRECT_URL) else: form = forms.NewUserForm() return render(request, 'register.html', {'form': form}) diff --git a/cvat/apps/dashboard/__init__.py b/cvat/apps/dashboard/__init__.py index d8e62e54..da270801 100644 --- a/cvat/apps/dashboard/__init__.py +++ b/cvat/apps/dashboard/__init__.py @@ -3,3 +3,6 @@ # # SPDX-License-Identifier: MIT +from cvat.settings.base import JS_3RDPARTY + +JS_3RDPARTY['engine'] = JS_3RDPARTY.get('engine', []) + ['dashboard/js/enginePlugin.js'] diff --git a/cvat/apps/dashboard/static/dashboard/js/dashboard.js b/cvat/apps/dashboard/static/dashboard/js/dashboard.js index 4a898666..d3815969 100644 --- a/cvat/apps/dashboard/static/dashboard/js/dashboard.js +++ b/cvat/apps/dashboard/static/dashboard/js/dashboard.js @@ -524,12 +524,16 @@ function uploadAnnotationRequest() { $.ajax({ url: '/get/task/' + window.cvat.dashboard.taskID, success: function(data) { - let annotationParser = new AnnotationParser({ - start: 0, - stop: data.size, - image_meta_data: data.image_meta_data, - flipped: data.flipped - }, new LabelsInfo(data.spec)); + let annotationParser = new AnnotationParser( + { + start: 0, + stop: data.size, + image_meta_data: data.image_meta_data, + flipped: data.flipped + }, + new LabelsInfo(data.spec), + new ConstIdGenerator(-1) + ); let asyncParse = function() { let parsed = null; @@ -538,28 +542,64 @@ function uploadAnnotationRequest() { } catch(error) { overlay.remove(); - showMessage("Parsing errors was occured. " + error); + showMessage("Parsing errors was occurred. " + error); return; } let asyncSave = function() { $.ajax({ - url: '/save/annotation/task/' + window.cvat.dashboard.taskID, - type: 'POST', - data: JSON.stringify(parsed), - contentType: 'application/json', + url: '/delete/annotation/task/' + window.cvat.dashboard.taskID, + type: 'DELETE', success: function() { - let message = 'Annotation successfully uploaded'; - showMessage(message); + asyncSaveChunk(0); }, error: function(response) { - let message = 'Annotation uploading errors was occured. ' + response.responseText; + let message = 'Previous annotations cannot be deleted: ' + + response.responseText; showMessage(message); + overlay.remove(); }, - complete: () => overlay.remove() }); }; + let asyncSaveChunk = function(start) { + const CHUNK_SIZE = 100000; + let end = start + CHUNK_SIZE; + let chunk = {}; + let next = false; + for (let prop in parsed) { + if (parsed.hasOwnProperty(prop)) { + chunk[prop] = parsed[prop].slice(start, end); + next |= chunk[prop].length > 0; + } + } + + if (next) { + let exportData = createExportContainer(); + exportData.create = chunk; + + $.ajax({ + url: '/save/annotation/task/' + window.cvat.dashboard.taskID, + type: 'POST', + data: JSON.stringify(exportData), + contentType: 'application/json', + success: function() { + asyncSaveChunk(end); + }, + error: function(response) { + let message = 'Annotations uploading errors were occurred: ' + + response.responseText; + showMessage(message); + overlay.remove(); + }, + }); + } else { + let message = 'Annotations were uploaded successfully'; + showMessage(message); + overlay.remove(); + } + }; + overlay.setMessage('Annotation is being saved..'); setTimeout(asyncSave); }; diff --git a/cvat/apps/dashboard/static/dashboard/js/enginePlugin.js b/cvat/apps/dashboard/static/dashboard/js/enginePlugin.js new file mode 100644 index 00000000..4c95fa66 --- /dev/null +++ b/cvat/apps/dashboard/static/dashboard/js/enginePlugin.js @@ -0,0 +1,15 @@ +/* + * Copyright (C) 2018 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +"use strict"; + +window.addEventListener('DOMContentLoaded', () => { + $(``).on('click', () => { + let win = window.open(`${window.location.origin }/dashboard/?jid=${window.cvat.job.id}`, '_blank'); + win.focus(); + }).prependTo('#engineMenuButtons'); +}); + diff --git a/cvat/apps/dashboard/templates/dashboard/task.html b/cvat/apps/dashboard/templates/dashboard/task.html index 46f10255..da017006 100644 --- a/cvat/apps/dashboard/templates/dashboard/task.html +++ b/cvat/apps/dashboard/templates/dashboard/task.html @@ -3,22 +3,22 @@ SPDX-License-Identifier: MIT --> -
+
-
+
- {%if item.has_bug_tracker %} + {%if item.bug_tracker %} - + {% endif %}
@@ -26,10 +26,12 @@ - {% for segment in item.segments %} - - - + {% for segm in item.segment_set.all %} + {% for job in segm.job_set.all %} + + + + {% endfor %} {% endfor %}
{{segment.url}}
{{base_url}}?id={{job.id}}
diff --git a/cvat/apps/dashboard/views.py b/cvat/apps/dashboard/views.py index 37e2d3d5..0341cc4d 100644 --- a/cvat/apps/dashboard/views.py +++ b/cvat/apps/dashboard/views.py @@ -7,10 +7,9 @@ from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest from django.shortcuts import redirect from django.shortcuts import render from django.conf import settings -from django.contrib.auth.decorators import permission_required from cvat.apps.authentication.decorators import login_required -from cvat.apps.engine.models import Task as TaskModel +from cvat.apps.engine.models import Task as TaskModel, Job as JobModel from cvat.settings.base import JS_3RDPARTY import os @@ -40,7 +39,6 @@ def ScanNode(directory): return result @login_required -@permission_required('engine.add_task', raise_exception=True) def JsTreeView(request): node_id = None if 'id' in request.GET: @@ -56,63 +54,27 @@ def JsTreeView(request): json_dumps_params=dict(ensure_ascii=False)) -def MainTaskInfo(task, dst_dict): - dst_dict["status"] = task.status - dst_dict["num_of_segments"] = task.segment_set.count() - dst_dict["mode"] = task.mode.capitalize() - dst_dict["name"] = task.name - dst_dict["task_id"] = task.id - dst_dict["created_date"] = task.created_date - dst_dict["updated_date"] = task.updated_date - dst_dict["bug_tracker_link"] = task.bug_tracker - dst_dict["has_bug_tracker"] = len(task.bug_tracker) > 0 - dst_dict["owner"] = 'undefined' - dst_dict["id"] = task.id - dst_dict["segments"] = [] - -def DetailTaskInfo(request, task, dst_dict): - scheme = request.scheme - host = request.get_host() - dst_dict['segments'] = [] - - for segment in task.segment_set.all(): - for job in segment.job_set.all(): - segment_url = "{0}://{1}/?id={2}".format(scheme, host, job.id) - dst_dict["segments"].append({ - 'id': job.id, - 'start': segment.start_frame, - 'stop': segment.stop_frame, - 'url': segment_url - }) - - db_labels = task.label_set.prefetch_related('attributespec_set').all() - attributes = {} - for db_label in db_labels: - attributes[db_label.id] = {} - for db_attrspec in db_label.attributespec_set.all(): - attributes[db_label.id][db_attrspec.id] = db_attrspec.text - - dst_dict['labels'] = attributes - @login_required -@permission_required('engine.view_task', raise_exception=True) def DashboardView(request): - filter_name = request.GET['search'] if 'search' in request.GET else None - tasks_query_set = list(TaskModel.objects.prefetch_related('segment_set').order_by('-created_date').all()) - if filter_name is not None: - tasks_query_set = list(filter(lambda x: filter_name.lower() in x.name.lower(), tasks_query_set)) - - data = [] - for task in tasks_query_set: - task_info = {} - MainTaskInfo(task, task_info) - DetailTaskInfo(request, task, task_info) - data.append(task_info) + query_name = request.GET['search'] if 'search' in request.GET else None + query_job = int(request.GET['jid']) if 'jid' in request.GET and request.GET['jid'].isdigit() else None + task_list = None + + if query_job is not None and JobModel.objects.filter(pk = query_job).exists(): + task_list = [JobModel.objects.select_related('segment__task').get(pk = query_job).segment.task] + else: + task_list = list(TaskModel.objects.prefetch_related('segment_set__job_set').order_by('-created_date').all()) + if query_name is not None: + task_list = list(filter(lambda x: query_name.lower() in x.name.lower(), task_list)) + + task_list = list(filter(lambda task: request.user.has_perm( + 'engine.task.access', task), task_list)) return render(request, 'dashboard/dashboard.html', { - 'data': data, + 'data': task_list, 'max_upload_size': settings.LOCAL_LOAD_MAX_FILES_SIZE, 'max_upload_count': settings.LOCAL_LOAD_MAX_FILES_COUNT, + 'base_url': "{0}://{1}/".format(request.scheme, request.get_host()), 'share_path': os.getenv('CVAT_SHARE_URL', default=r'${cvat_root}/share'), - 'js_3rdparty': JS_3RDPARTY.get('dashboard', []) + 'js_3rdparty': JS_3RDPARTY.get('dashboard', []), }) diff --git a/cvat/apps/documentation/AWS-Deployment-Guide.md b/cvat/apps/documentation/AWS-Deployment-Guide.md new file mode 100644 index 00000000..5b40ee39 --- /dev/null +++ b/cvat/apps/documentation/AWS-Deployment-Guide.md @@ -0,0 +1,9 @@ +### AWS-Deployment Guide + +There are two ways of deploying the CVAT. +1. **On Nvidia GPU Machine:** Tensorflow annotation feature is dependent on GPU hardware. One of the easy ways to launch CVAT with the tf-annotation app is to use AWS P3 instances, which provides the NVIDIA GPU. Read more about [P3 instances here.](https://aws.amazon.com/about-aws/whats-new/2017/10/introducing-amazon-ec2-p3-instances/) +Overall setup instruction is explained in [main readme file](https://github.com/opencv/cvat/), except Installing Nvidia drivers. So we need to download the drivers and install it. For Amazon P3 instances, download the Nvidia Drivers from Nvidia website. For more check [Installing the NVIDIA Driver on Linux Instances](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/install-nvidia-driver.html) link. + +2. **On Any other AWS Machine:** We can follow the same instruction guide mentioned in the [Readme file](https://github.com/opencv/cvat/). The additional step is to add a [security group and rule to allow incoming connections](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-network-security.html). + +For any of above, don't forget to add exposed AWS public IP address to `docker-compose.override.com`. diff --git a/cvat/apps/documentation/static/documentation/images/gif004.gif b/cvat/apps/documentation/static/documentation/images/gif004.gif index 13afacfe..c601b65e 100644 Binary files a/cvat/apps/documentation/static/documentation/images/gif004.gif and b/cvat/apps/documentation/static/documentation/images/gif004.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/gif006.gif b/cvat/apps/documentation/static/documentation/images/gif006.gif new file mode 100644 index 00000000..c973c81f Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/gif006.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/gif007.gif b/cvat/apps/documentation/static/documentation/images/gif007.gif new file mode 100644 index 00000000..ed02475e Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/gif007.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/image006.jpg b/cvat/apps/documentation/static/documentation/images/image006.jpg index ce6e9dd2..2fed246a 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image006.jpg and b/cvat/apps/documentation/static/documentation/images/image006.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image007.jpg b/cvat/apps/documentation/static/documentation/images/image007.jpg index 0a4be80a..225f716c 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image007.jpg and b/cvat/apps/documentation/static/documentation/images/image007.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image011.jpg b/cvat/apps/documentation/static/documentation/images/image011.jpg index f7e32107..e3f3d4cd 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image011.jpg and b/cvat/apps/documentation/static/documentation/images/image011.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image012.jpg b/cvat/apps/documentation/static/documentation/images/image012.jpg index 8f1ed7eb..36b3e713 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image012.jpg and b/cvat/apps/documentation/static/documentation/images/image012.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image013.jpg b/cvat/apps/documentation/static/documentation/images/image013.jpg index 121eaf30..2d1c0b78 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image013.jpg and b/cvat/apps/documentation/static/documentation/images/image013.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image028.jpg b/cvat/apps/documentation/static/documentation/images/image028.jpg index 7b83355e..0b7ae1df 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image028.jpg and b/cvat/apps/documentation/static/documentation/images/image028.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image029.jpg b/cvat/apps/documentation/static/documentation/images/image029.jpg index 6a0c8398..44ce3ed7 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image029.jpg and b/cvat/apps/documentation/static/documentation/images/image029.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image034.jpg b/cvat/apps/documentation/static/documentation/images/image034.jpg index 51881406..a526abe9 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image034.jpg and b/cvat/apps/documentation/static/documentation/images/image034.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image035.jpg b/cvat/apps/documentation/static/documentation/images/image035.jpg index b206f7cd..f531fc0f 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image035.jpg and b/cvat/apps/documentation/static/documentation/images/image035.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image043.jpg b/cvat/apps/documentation/static/documentation/images/image043.jpg index 03648098..7aa8ea9a 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image043.jpg and b/cvat/apps/documentation/static/documentation/images/image043.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image051.jpg b/cvat/apps/documentation/static/documentation/images/image051.jpg index 44768fa7..8f132922 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image051.jpg and b/cvat/apps/documentation/static/documentation/images/image051.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image054.jpg b/cvat/apps/documentation/static/documentation/images/image054.jpg index 2b8694f8..c061692a 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image054.jpg and b/cvat/apps/documentation/static/documentation/images/image054.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image062.jpg b/cvat/apps/documentation/static/documentation/images/image062.jpg index 226c1160..f3a5b9b7 100644 Binary files a/cvat/apps/documentation/static/documentation/images/image062.jpg and b/cvat/apps/documentation/static/documentation/images/image062.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image086.jpg b/cvat/apps/documentation/static/documentation/images/image086.jpg new file mode 100644 index 00000000..00e91a19 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image086.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image087.jpg b/cvat/apps/documentation/static/documentation/images/image087.jpg new file mode 100644 index 00000000..2bf45a39 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image087.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image088.jpg b/cvat/apps/documentation/static/documentation/images/image088.jpg new file mode 100644 index 00000000..9d4d8943 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image088.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image089.jpg b/cvat/apps/documentation/static/documentation/images/image089.jpg new file mode 100644 index 00000000..1ed42a77 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image089.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image090.jpg b/cvat/apps/documentation/static/documentation/images/image090.jpg new file mode 100644 index 00000000..feb5a07e Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image090.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image091.jpg b/cvat/apps/documentation/static/documentation/images/image091.jpg new file mode 100644 index 00000000..2d04019a Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image091.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image092.jpg b/cvat/apps/documentation/static/documentation/images/image092.jpg new file mode 100644 index 00000000..c31e5f02 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image092.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image093.jpg b/cvat/apps/documentation/static/documentation/images/image093.jpg new file mode 100644 index 00000000..62811485 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image093.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image094.jpg b/cvat/apps/documentation/static/documentation/images/image094.jpg new file mode 100644 index 00000000..68692722 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image094.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image095.jpg b/cvat/apps/documentation/static/documentation/images/image095.jpg new file mode 100644 index 00000000..f607b4df Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image095.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image096.jpg b/cvat/apps/documentation/static/documentation/images/image096.jpg new file mode 100644 index 00000000..1e63d72f Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image096.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image097.jpg b/cvat/apps/documentation/static/documentation/images/image097.jpg new file mode 100644 index 00000000..199baa7d Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image097.jpg differ diff --git a/cvat/apps/documentation/static/documentation/images/image098.jpg b/cvat/apps/documentation/static/documentation/images/image098.jpg new file mode 100644 index 00000000..b66b4ef2 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/image098.jpg differ diff --git a/cvat/apps/documentation/static/documentation/js/shortcuts.js b/cvat/apps/documentation/static/documentation/js/shortcuts.js index ff28a64f..9b45a8f1 100644 --- a/cvat/apps/documentation/static/documentation/js/shortcuts.js +++ b/cvat/apps/documentation/static/documentation/js/shortcuts.js @@ -5,7 +5,7 @@ */ Mousetrap.bind(window.cvat.config.shortkeys["open_help"].value, function() { - window.location.href = "/documentation/user_guide.html"; + window.open("/documentation/user_guide.html"); return false; }); \ No newline at end of file diff --git a/cvat/apps/documentation/user_guide.md b/cvat/apps/documentation/user_guide.md index 3fe1defd..d6d60442 100644 --- a/cvat/apps/documentation/user_guide.md +++ b/cvat/apps/documentation/user_guide.md @@ -32,7 +32,7 @@ There you can: ![](static/documentation/images/image005.jpg) - __Labels__. Use the following schema to create labels: ``label_name input_type=attribute_name:attribute_value1,attribute_value2``. You can specify multiple labels and multiple attributes separated by space. Attributes belong to previous label. + __Labels__. Use the following scheme to create labels: ``label_name input_type=attribute_name:attribute_value1,attribute_value2``. You can specify multiple labels and multiple attributes separated by space. Attributes belong to previous label. Example: - ``vehicle @select=type:__undefined__,car,truck,bus,train ~radio=quality:good,bad ~checkbox=parked:false`` - @@ -43,8 +43,8 @@ There you can: ``label_name``: for example *vehicle, person, face etc.* ````: - - Use ``@`` for unique attributes which cannot be changed from frame to frame *(e.g. age, gender, color, etc)* - - Use ``~`` for temporary attributes which can be changed on any frame *(e.g. quality, pose, truncated, etc)* + - Use ``@`` for unique attributes which cannot be changed from frame to frame *(e.g. age, gender, color, etc.)* + - Use ``~`` for temporary attributes which can be changed on any frame *(e.g. quality, pose, truncated, etc.)* ``input_type``: the following input types are available ``select``, ``checkbox``, ``radio``, ``number``, ``text``. @@ -63,15 +63,15 @@ There you can: __Flip images__. All selected files will be turned around 180. - __Z-Order__. Defines the order on drawn polygons. Check the box for enable layered dislaying. + __Z-Order__. Defines the order on drawn polygons. Check the box for enable layered displaying. __Overlap Size__. Use this option to make overlapped segments. The option makes tracks continuous from one segment into another. Use it for interpolation mode. There are several use cases for the parameter: - For an interpolation task (video sequence) if an object exists on overlapped segments it will be automatically merged into one track if overlap is greater than zero and annotation is good enough on adjacent segments. If overlap equals to zero or annotation is poor on adjacent segments inside a dumped annotation file you will have several tracks, one for each segment, which correspond to the object). - For an annotation task (independent images) if an object exists on overlapped segments bounding boxes will be automatically merged into one if overlap is greater than zero and annotation is good enough on adjacent segments. If overlap equals to zero or annotation is poor on adjacent segments inside a dumped annotation file you will have several bounding boxes for the same object. - Thus you annotate an object on first segment. You annotate the same object on second segment and if you do it right you will have one track inside your annotation file. If annotations on different segments (on overlapped frames) are very different or overlap is zero you will have two tracks for the same object. The functionality only works for bounding boxes. Polygon, polyline, points don't support automatic merge on overlapped segments even the overlap parameter isn't zero and match between corresponding shapes on adjacent segments is perfect. + Thus you annotate an object on the first segment. You annotate the same object on second segment and if you do it right you will have one track inside your annotation file. If annotations on different segments (on overlapped frames) are very different or overlap is zero you will have two tracks for the same object. This functionality works only for bounding boxes. Polygon, polyline, points don't support automatic merge on overlapped segments even the overlap parameter isn't zero and match between corresponding shapes on adjacent segments is perfect. - __Segment size__. Use this option to divide huge dataset by a few less size segments. + __Segment size__. Use this option to divide a huge dataset into a few smaller segments. __Image Quality__. Use this option to specify quality of uploaded images. The option makes it faster to load high-quality datasets. Use the value from ``1`` (completely compressed images) to ``95`` (almost not compressed images). @@ -85,13 +85,13 @@ There you can: ### Basic navigation -1. Use arrows below to move on next/previous frame. Mostly every button is covered by a shortcut. To get a hint about the shortcut just put your mouse pointer over an UI element. +1. Use arrows below to move on next/previous frame. Almost every button is covered by a shortcut. To get a hint about the shortcut just put your mouse pointer over an UI element. ![](static/documentation/images/image008.jpg) 2. An image can be zoom in/out using mouse's wheel. The image will be zoomed relatively your current cursor position. Thus if you point on an object it will be under your mouse during zooming process. -3. An image can be moved/shifted by holding left mouse button inside some area without annotated objects. If ``Shift`` key is pressed then all annotated objects are ignored otherwise a highlighted bounding box will be moved instead of the image itself. Usually the functionality is used together with zoom to precisely locate an object of interest. +3. An image can be moved/shifted by holding left mouse button inside some area without annotated objects. If ``Mouse Wheel`` is pressed then all annotated objects are ignored otherwise a highlighted bounding box will be moved instead of the image itself. ### Types of Shapes (basic) There are four shapes you can annotate your images with: @@ -100,7 +100,7 @@ There are four shapes you can annotate your images with: - ``polyline`` - ``points`` -And here how they all look like: +And there is how they all look like: ![](static/documentation/images/image038.jpg) ![](static/documentation/images/image033.jpg) @@ -111,13 +111,13 @@ Usage examples: - Create new annotations for a set of images. - Add/modify/delete objects for existing annotations. -1. Before start need to check that ``Annotation`` is selected: +1. Before starting need to check that ``Annotation`` is selected: ![](static/documentation/images/image082.jpg) ![](static/documentation/images/image081.jpg) 2. Create a new annotation: - - - Choose right ``Shape`` (e.g. box) and ``Label`` (was specified by you while creating the task) beforehand: + + - Choose right ``Shape`` (box etc.) and ``Label`` (was specified by you while creating the task) beforehand: ![](static/documentation/images/image080.jpg) ![](static/documentation/images/image083.jpg) @@ -125,9 +125,9 @@ Usage examples: ![](static/documentation/images/image011.jpg) - - It is possible to adjust boundaries and location of the bounding box using mouse. In the top right corner size of the box is shown. You can also undo/redo your actions by using ``Ctrl+Z`` / ``Shift+Ctrl+Z Ctrl+Y``. + - It is possible to adjust boundaries and location of the bounding box using mouse. In the top right corner boxes' size is shown, you can check it by clicking one of the boxes' points. You can also undo your actions by using ``Ctrl+Z`` and redo them with ``Shift+Ctrl+Z`` or ``Ctrl+Y``. -3. In the list of objects you can see the labeled car. In the side panel you can perform basic operations under the object. +3. In the list of objects you can see the labeled car. In the side panel you can perform basic operations under the object — choose attributes, change label or delete box. ![](static/documentation/images/image012.jpg) @@ -141,19 +141,18 @@ Usage examples: - Add/modify/delete objects for existing annotations. - Edit tracks, merge many bounding boxes into one track. -1. Before start need to be sure that ``Interpolation`` is selected. +1. Before starting need to be sure that ``Interpolation`` is selected. ![](static/documentation/images/image014.jpg) 2. Create a track for an object (look at the selected car as an example): - - Annotate a bounding box on first frame for the object. - + - Annotate a bounding box on the first frame for the object. - In ``Interpolation`` mode the bounding box will be interpolated on next frames automatically. ![](static/documentation/images/image015.jpg) 3. If the object starts to change its position you need to modify bounding boxes where it happens. Changing of bounding boxes on each frame isn't necessary. It is enough to update several key frames and frames between them will be interpolated automatically. See an example below: - - The car starts moving on frame #70. Let's mark the frame as a key frame. + - The car starts moving on frame #70. Let's mark the frame as a key frame. You can press ``K`` for that. ![](static/documentation/images/image016.jpg) @@ -165,7 +164,7 @@ Usage examples: ![](static/documentation/images/image018.jpg) -4. When the annotated object disappears or becomes too small, you need to finish the track. To do that you need to choose ``Outside Property``. +4. When the annotated object disappears or becomes too small, you need to finish the track. To do that you need to choose ``Outside Property``, shortcut ``O``. ![](static/documentation/images/image019.jpg) @@ -181,7 +180,7 @@ Usage examples: ![](static/documentation/images/gif002.gif) - - Press ``Merge Tracks`` button and click on any bounding box of first track and on any bounding box of second track. + - Press ``Merge Tracks`` button and click on any bounding box of the first track and on any bounding box of the second track. ![](static/documentation/images/image021.jpg) @@ -193,17 +192,17 @@ Usage examples: ![](static/documentation/images/gif003.gif) -### Attribute Annotation mode (basics) +### Attribute annotation mode (basics) -- In this Mode you can edit attributes with fast navigation between objects and frames using keyboard. Press ``Shift+Enter`` shortcut to enter AAMode. After that it is possible to change attributes using keyboard. +- In this mode you can edit attributes with fast navigation between objects and frames using keyboard. Press ``Shift+Enter`` shortcut to enter AAMode. After that it is possible to change attributes using keyboard. ![](static/documentation/images/image023.jpg) -- The active attribute will be red. In this case it is ``Gender``. Look at the bottom side panel to see all possible shortcuts to change the attribute. Press ``2`` key on your keyboard to assign ``female`` value for the attribute. +- The active attribute will be red. In this case it is ``gender``. Look at the bottom side panel to see all possible shortcuts to change the attribute. Press ``2`` key on your keyboard to assign ``female`` value for the attribute. ![](static/documentation/images/image024.jpg) ![](static/documentation/images/image025.jpg) -- Press ``Up Arrow``/``Down Arrow`` keys on your keyboard to go to next attribute. In this case after pressing ``Down Arrow`` you will be able to edit ``Age`` attribute. +- Press ``Up Arrow``/``Down Arrow`` on your keyboard to go to next/previous attribute. In this case after pressing ``Down Arrow`` you will be able to edit ``Age`` attribute. ![](static/documentation/images/image026.jpg) ![](static/documentation/images/image027.jpg) @@ -211,21 +210,21 @@ Usage examples: ### Downloading annotations -1. To download latest annotations save all changes first. Press ``Open Menu`` and then ``Save Work`` button. There is ``Ctrl+s`` shortcut to save annotations quickly. +1. To download latest annotations save all changes first. Press ``Open Menu`` and then ``Save Work`` button. There is ``Ctrl+S`` shortcut to save annotations quickly. 2. After that press ``Open Menu`` and then ``Dump Annotation`` button. ![](static/documentation/images/image028.jpg) -3. The annotation will be written into **.xml** file. To find the annotation file go to the directory where your browser saves downloaded files by default. For more information visit [.xml format page](/documentation/xml_format.html). +3. The annotation will be written into **.xml** file. To find the annotation file go to the directory where your browser saves downloaded files by default. For more information visit [.xml format page](./documentation/xml_format.html). ![](static/documentation/images/image029.jpg) ### Vocabulary -**Bounding box** is an area which defines boundaries of an object. To specify it you need to define top left and bottom right points. +**Bounding box** is an area which defines boundaries of an object. To specify it you need to define two opposite corners. -**Tight bounding box** is a bounding box where margin between the object inside and boundaries of the box is absent. By default the type of bounding box is used in most tasks but precision completely depends on an annotation task. +**Tight bounding box** is a bounding box where margin between the object inside and boundaries of the box is absent. This type of bounding box is used in most tasks by default but precision completely depends on an annotation task. | Bounding box |Tight bounding box| | ------------ |:----------------:| @@ -240,7 +239,7 @@ Usage examples: **Attribute** is a property of an annotated object (e.g. color, model, quality, etc.). There are two types of attributes: -- __Unique__: immutable and isn't changed from frame to frame (e.g age, gender, color, etc.) +- __Unique__: immutable and can't be changed from frame to frame (e.g. age, gender, color, etc.) ![](static/documentation/images/image073.jpg) @@ -249,7 +248,7 @@ Usage examples: ![](static/documentation/images/image072.jpg) --- -**Track** is a set of shapes on different frames which corresponds to one object. Tracks are created in ``Interpolation mode`` mode. +**Track** is a set of shapes on different frames which corresponds to one object. Tracks are created in ``Interpolation mode`` ![](static/documentation/images/gif004.gif) @@ -264,18 +263,38 @@ Usage examples: The tool is composed of: - ``Workspace`` — where images are shown; - ``Bottom panel`` (under workspace) — for navigation, filtering annotation and accessing tools' menu; -- ``Side Panel`` — contain two lists: of Objects (on the frame) and Labels (of Objects on the frame); -- ``Bottom Side Panel`` — for choosing types of/creating/merging/grouping annotation; +- ``Side panel`` — contains two lists: of objects (on the frame) and labels (of objects on the frame); +- ``Bottom side panel`` — for choosing types of/creating/merging/grouping annotation; ![](static/documentation/images/image034.jpg) -There are also: -- ``Settings`` (F2) — contains different parameters which can be adjusted by the user needs +There is also: +- ``Settings`` (F2) — pop-up in the Bottom panel, contains different parameters which can be adjusted by the user's needs + +- ``Context menu`` — available on right mouse button. + +--- +### Workspace — Context menu + +Context menu opens by right mouse click. +By clicking inside bounding box the next is available: +- ``Copy Object URL`` — copying in buffer address of an object on the frame in the task +- ``Change Color`` +- ``Remove Shape`` +- ``Switch Occluded`` +- ``Switch Lock`` +- ``Enable Dragging`` — (only for polygons) allows to adjust polygons position -- ``Context menu`` — click right mouse button inside of a shape or at a point (only in poly-shapes) +![](static/documentation/images/image089.jpg) ![](static/documentation/images/image090.jpg) -![](static/documentation/images/image070.jpg) ![](static/documentation/images/image071.jpg) +By clicking on the points of poly-shapes ``Remove`` option is available. + +![](static/documentation/images/image092.jpg) + +By clicking outside any shapes you can either copy ``Frame URL`` (link to present frame) or ``Job URL`` (link from address bar) + +![](static/documentation/images/image091.jpg) --- ### Settings @@ -284,7 +303,7 @@ Click ``F2`` to access settings menu. ![](static/documentation/images/image067.jpg) -There is ``Player Settings`` which adjusting ``Workspace`` and ``Other Settings``. +There is ``Player Settings`` which adjusts ``Workspace`` and ``Other Settings``. In ``Player Settings`` you can: - Control step of ``C`` and ``V`` shortcuts @@ -295,7 +314,7 @@ In ``Player Settings`` you can: ![](static/documentation/images/image068.jpg) - - Adjust ``Brightness``/``Contrast``/``Saturation`` of too expose or too dark images using ``F2`` — color settings (changes displaying and not the image itself). + - Adjust ``Brightness``/``Contrast``/``Saturation`` of too exposing or too dark images using ``F2`` — color settings (changes displaying and not the image itself). Shortcuts: - ``Shift+B``/``Alt+B`` for brightness - ``Shift+C``/``Alt+C`` for contrast @@ -304,12 +323,12 @@ Shortcuts: ![](static/documentation/images/image069.jpg) -``Other Settings`` contain: - - ``Show All Interpolation Tracks`` checkbox — shows hidden object on side panel for every interpolated object (turned off by default) +``Other Settings`` contains: + - ``Show All Interpolation Tracks`` checkbox — shows hidden object on the side panel for every interpolated object (turned off by default) - ``AAM Zoom Margin`` slider — defines margins for shape in attribute annotation mode - ``Enable AutoSaving`` checkbox — turned off by default - - ``AutoSaving Interval (Min)`` input box — 15 minutes by default - - ``Propagate Frames`` input box — allow to choose on how many frames selected object will be copied in by ``Ctrl+B`` (50 by default) + - ``AutoSaving Interval (min)`` input box — 15 minutes by default + - ``Propagate Frames`` input box — allows to choose on how many frames of selected object will be copied in by ``Ctrl+B`` (50 by default) --- ### Bottom Panel @@ -344,40 +363,85 @@ Go to specified frame. Press ``~`` to highlight element. --- __Open Menu__ button -It is the main menu for the annotation tool. It can be used to download, upload and remove annotations. As well it shows statistics about the current annotation task. +It is the main menu for the annotation tool. It can be used to download, upload and remove annotations. ![](static/documentation/images/image051.jpg) +As well it shows statistics about the current task, such as: +- task name +- type of performance on the task: ``annotation``, ``validation`` or ``completed task`` +- technical information about task +- number of created bounding boxes, sorted by labels (e.g. vehicle, person) and type of annotation (polygons, boxes, etc.) + --- __Filter__ input box -How to use filters is described in the Advanced guide (below). +The way how to use filters is described in the advanced guide (below). ![](static/documentation/images/image059.jpg) --- -__History / Undo-Redo panel__ +__History / Undo-redo panel__ -Use shortcuts for undo/redo actions ``Ctrl+Z`` __/__ ``Ctrl+Shift+Z``/``Ctrl+Y`` +Use ``Ctrl+Z`` for undo actions and ``Ctrl+Shift+Z`` or ``Ctrl+Y`` to redo them. ![](static/documentation/images/image061.jpg) +--- +__Fill Opacity slider__ + +Change opacity of every bounding box in the annotation. + +![](static/documentation/images/image086.jpg) + +Opacity can be chaged from 0% to 100% and by random colors or white. If any white option is chosen, ``Color By`` scheme won't work. + +__Selected Fill Opacity slider__ + +Change opacity of bounding box under mouse pointer. + +![](static/documentation/images/image087.jpg) + +Opacity can be changed from 0% till 100%. + +__Black Stroke checkbox__ + +Change bounding box border from white/colored to black. + +![](static/documentation/images/image088.jpg) + +__Color By options__ + +Change color scheme of annotation: +- ``Instance`` — every bounding box has random color +![](static/documentation/images/image095.jpg) + +- ``Group`` — every group of boxes has its own random color, ungrouped boxes are white + +![](static/documentation/images/image094.jpg) + +- ``Label`` — every label (e.g. vehicle, pedestrian, roadmark) has its own random color + +![](static/documentation/images/image093.jpg) + +You can change any random color by pointing on needed box on a frame or on a side panel and pressing ``Enter``. + +--- ### Side panel #### Objects -In the Side Panel you can see the list of available objects on the current frame. An example how the list can look like below: +In the side panel you can see the list of available objects on the current frame. An example how the list can look like is below: |Annotation mode|Interpolation mode| |--|--| |![](static/documentation/images/image044.jpg)|![](static/documentation/images/image045.jpg)| #### Labels -You also can see all labels that used on this frame and highlight them by clicking needed label. +You also can see all the labels that used on this frame and highlight them by clicking needed label. ![](static/documentation/images/image062.jpg) - --- __Objects' card__ @@ -398,17 +462,17 @@ A shape can be **Occluded**. Shortcut: ``q``. Such shapes have dashed boundaries ![](static/documentation/images/image049.jpg) --- -You can copy and paste this object on this or other frame. ``Ctrl+C``/``Ctrl+V`` shortcuts works under mouse point. +You can copy and paste this object on this or another frame. ``Ctrl+C``/``Ctrl+V`` shortcuts works under mouse point. ![](static/documentation/images/image052.jpg) --- -You can propagate this object on next X frames. ``Ctrl+B`` shortcut works under mouse point. ``F2`` for change on how many frames to propagate this object. +You can propagate this object on next X frames. ``Ctrl+B`` shortcut works under mouse point. ``F2`` for change on how many frames in nesessary to propagate this object. ![](static/documentation/images/image053.jpg) --- -You can change how this objects' annotation is displayed on this frame. It could be Hide, Shows Only Box, Shows Box and Title. ``H`` is for this object, ``T+H`` for all objects on this frame. +You can change the way this objects' annotation is displayed on this frame. It could be hide, shows only box, shows box and title. ``H`` is for this object, ``T+H`` for all objects on this frame. ![](static/documentation/images/image055.jpg) @@ -421,13 +485,13 @@ To change a type of a highlighted shape using keyboard you need to press ``Shift ### Bottom side panel -- ``Create Shape`` (``N``) — start/stop draw new shape mode -- ``Merge Shapes`` (``M``) — start/stop merge boxes mode +- ``Create Shape`` (``N``) — start/stop drawing new shape mode +- ``Merge Shapes`` (``M``) — start/stop merging boxes mode - ``Group Shapes`` (``G``) — start/stop grouping boxes mode -- ``Label Type`` — (e.g. Face, Person, Vehicle) -- ``Working Mode`` — Annotation or Interpolation modes. You can't interpolate Polygons/Polylines/Points, but you can propagate them using ``Ctrl+B`` or merge into a track -- ``Shape type`` — (e.g. Box, Polygon, Polyline, Points) -- ``Poly Shape Size`` — (optional) hard number of dots for creating Polygon/Polyline shapes +- ``Label Type`` — (e.g. face, person, vehicle) +- ``Working Mode`` — Annotation or Interpolation modes. You can't interpolate polygons/polylines/points, but you can propagate them using ``Ctrl+B`` or merge into a track +- ``Shape Type`` — (e.g. box, polygon, polyline, points) +- ``Poly Shape Size`` — (optional) hard number of dots for creating polygon/polyline shapes ![](static/documentation/images/image082.jpg) @@ -442,7 +506,7 @@ That is how it looks like. ## Annotation mode (advanced) -Basic operations in the mode was described above. +Basic operations in the mode were described above. __occluded__ attribute is used if an object is occluded by another object or it isn't fully visible on the frame. Use ``Q`` shortcut to set the property quickly. @@ -452,13 +516,13 @@ Example: both cars on the figure below should be labeled as __occluded__. ![](static/documentation/images/image054.jpg) -If a frame contains too many objects and it is difficult to annotate them due to many shapes are placed mostly in the same place when it makes sense to lock them. Shapes for locked objects are transparent and it is easy to annotate new objects. Also it will not be possible to change previously annotated objects by an accident. Shortcut: ``L``. +If a frame contains too many objects and it is difficult to annotate them due to many shapes which are placed mostly in the same place then it makes sense to lock them. Shapes for locked objects are transparent and it is easy to annotate new objects. Also it will not be possible to change previously annotated objects by an accident. Shortcut: ``L``. ![](static/documentation/images/image066.jpg) ## Interpolation mode (advanced) -Basic operations in the mode was described above. +Basic operations in the mode were described above. Bounding boxes created in the mode have extra navigation buttons. - These buttons help to jump to previous/next key frame. @@ -470,7 +534,7 @@ Bounding boxes created in the mode have extra navigation buttons. ![](static/documentation/images/image057.jpg) -## Attribute Annotation mode (advanced) +## Attribute annotation mode (advanced) Basic operations in the mode was described above. @@ -478,9 +542,7 @@ It is possible to handle many objects on the same frame in the mode. ![](static/documentation/images/image058.jpg) -It is more convenient to annotate objects of the same type. For the purpose -it is possible to specify a corresponding filter. For example, the following -filter will hide all objects except pedestrians: ``pedestrian``. +It is more convenient to annotate objects of the same type. For the purpose it is possible to specify a corresponding filter. For example, the following filter will hide all objects except pedestrians: ``pedestrian``. To navigate between objects (pedestrians in the case) use the following shortcuts: - ``Tab`` — go to the next object @@ -493,45 +555,46 @@ By default in the mode objects are zoomed in to full screen. Check It is used for semantic / instance segmentation. -Be sure ``Z-Order`` flag in ``Create task`` dialog is enabled if you want annotate polygons. Z-Order flag defines order of drawing. It is necessary to get right annotation mask without extra work (additional drawing of borders). Z-order can be changed by `+`/`-` which set maximum/minimum z-order respectively. +Be sure ``Z-Order`` flag in ``Create task`` dialog is enabled if you want to annotate polygons. Z-Order flag defines order of drawing. It is necessary to get right annotation mask without extra work (additional drawing of borders). Z-Order can be changed by `+`/`-` which set maximum/minimum z-order respectively. ![](static/documentation/images/image074.jpg) -Before start need to be sure that ``Polygon`` is selected. +Before starting need to be sure that ``Polygon`` is selected. ![](static/documentation/images/image084.jpg) -Click ``N`` for entering drawing mode. Now you can start your polygon. -You can zoom in/out (on mouse wheel scroll) and move (on mouse wheel press -and mouse move) while drawing. Click ``N`` again for completing the shape. -Also you can set fixed number of points in the field "Poly Shape Size", then -drawing will be stopped automatically. You can drag object after it was drawn -and fix a position of an individual points after finishing the object. You -can add/delete points after finishing. +Click ``N`` for entering drawing mode. There are two ways to draw a polygon — you either create points by clicking or by dragging mouse on the screen, holding ``Shift``. + +|Clicking points|Holding Shift+Dragging| +|--|--| +|![](static/documentation/images/gif005.gif)|![](static/documentation/images/gif006.gif)| + + +When ``Shift`` isn't pressed, you can zoom in/out (on mouse wheel scroll) and move (on mouse wheel press and mouse move), you can delete previous point by clicking right mouse button. Click ``N`` again for completing the shape. You can move points or delete them by double-clicking. Double-click with pressed ``Shift`` will open a polygon editor. In it you can create new points (by clicking or dragging) or delete part of a polygon by closing the red line on other point. Press ``Esc`` to cancel editing. + +![](static/documentation/images/gif007.gif) + +Also you can set fixed number of points in the field "poly shape size", then drawing will be stopped automatically. +To enable dragging, right-click inside polygon and choose ``Enable Dragging``. -![](static/documentation/images/gif005.gif) Below you can see results with opacity and black stroke: ![](static/documentation/images/image064.jpg) -Also if you need annotate small objects, increase ``Image Quality`` to ``95`` in ``Create task`` dialog for annotators convenience. +Also if you need to annotate small objects, increase ``Image Quality`` to ``95`` in ``Create task`` dialog for annotators convenience. ## Annotation with polylines It is used for road markup annotation etc. -Before start need to be sure that ``Polyline`` is selected. +Before starting you have to be sure that ``Polyline`` is selected. ![](static/documentation/images/image085.jpg) -Click ``N`` for entering drawing mode. Now you can start your polyline. -You can zoom in/out (on mouse wheel scroll) and move (on mouse wheel press and -mouse move) while drawing. Click ``N`` again for completing the shape. Also -you can set fixed number of points in the field "Poly Shape Size", then drawing - will be stopped automatically. You can drag object after it was drawn and fix - a position of an individual points after finishing the object. You can - add/delete points after finishing. +Click ``N`` for entering drawing mode. There are two ways to draw a polyline — you either create points by clicking or by dragging mouse on the screen, holding ``Shift``. +When ``Shift`` isn't pressed, you can zoom in/out (on mouse wheel scroll) and move (on mouse wheel press and mouse move), you can delete previous point by clicking right mouse button. Click ``N`` again for completing the shape. You can delete points by double-clicking them. Double-click with pressed ``Shift`` will open a polyline editor. In it you can create new points (by clicking or dragging) or delete part of a polyline by closing the red line on other point. Press ``Esc`` to cancel editing. Also you can set fixed number of points in the field "poly shape size", then drawing will be stopped automatically. +You can adjust polyline after it was drawn. ![](static/documentation/images/image039.jpg) @@ -539,18 +602,12 @@ you can set fixed number of points in the field "Poly Shape Size", then drawing It is used for face landmarks annotation etc. -Before start need to be sure that ``Points`` is selected. +Before starting you have to be sure that ``Points`` is selected. ![](static/documentation/images/image042.jpg) Click ``N`` for entering drawing mode. Now you can start marking a needed area. -Click ``N`` again for finishing marking an area. Also you can set fixed number -of points in the field "Poly Shape Size", then drawing will be stopped -automatically. Points are automatically grouped — between individual start - and finish all points will be considered linked. You can zoom in/out (on mouse - wheel scroll) and move (on mouse wheel press and mouse move) while drawing. -You can drag object after it was drawn and fix a position of an individual -points after finishing the object. You can add/delete points after finishing. +Click ``N`` again for finishing marking an area. You can delete points by double-clicking them. Double-click with pressed ``Shift`` will open a points shape editor. In it you can create new points into existing shape. Also you can set fixed number of points in the field "poly shape size", then drawing will be stopped automatically. Points are automatically grouped — between individual start and finish all points will be considered linked. You can zoom in/out (on mouse wheel scroll) and move (on mouse wheel press and mouse move) while drawing. You can drag object after it was drawn and fix a position of individual points after finishing the object. You can add/delete points after finishing. ![](static/documentation/images/image063.jpg) @@ -563,11 +620,11 @@ You may use ``Group Shapes`` button or shortcuts: - ``Alt+G`` — close group mode - ``Shift+G`` — reset group for selected shapes -You may select shapes by click or by area selection. +You may select shapes by clicking or by area selection. Grouped shapes will have ``group_id`` filed in dumped annotation. -Also you may switch color distribution from by instance (default) to by group. For it need switch ``Color By Group`` checkbox. +Also you may switch color distribution from by instance (default) to by group. To do this you have to switch ``Color By Group`` checkbox. Shapes which haven't ``group_id`` will be highlighted with white color. @@ -579,9 +636,9 @@ Shapes which haven't ``group_id`` will be highlighted with white color. ![](static/documentation/images/image076.jpg) -There are several reasons to use the feature: +There are several reasons for using the feature: -1. When use a filter objects which don't correspond to the filter will be hidden. +1. When using a filter objects which don't correspond to the filter will be hidden. 2. Fast navigation between frames which have an object of interest. Use ``Left Arrow/Right Arrow`` keys for the purpose. If the filter is empty the mentioned arrows will go to previous/next frames which contain any objects. To use the functionality it is enough to specify a value inside ``Filter`` text box and defocus the text box (for example, click on the image). After that the filter will be applied. @@ -589,7 +646,7 @@ To use the functionality it is enough to specify a value inside ``Filter`` text --- In a trivial case a correct filter should correspond to the template: ``label[prop operator "value"]`` -``label`` is a type of an object (e.g _person, car, face_, etc.). If the type isn't important you can use ``*``. +``label`` is a type of an object (e.g. _person, car, face_, etc.). If the type isn't important you can use ``*``. ``prop`` is a property which should be filtered. The following items are available: @@ -627,10 +684,22 @@ Example | Description ``face[attr/glass="sunglass" or attr/glass="no"]`` | faces with sunglasses or without glasses at all. ```person[attr/race="asian"] | car[attr/model="bmw" or attr/model="mazda"]``` | asian persons or bmw or mazda cars. +## Analytics + +If your CVAT instance is built with [analytics](/components/analytics) support you can press F3 in dashboard, a new tab with analytics and logs will be opened. + +It allows to see how much working time every user spend on each task and how much they did, over any time range. + +![](static/documentation/images/image097.jpg) + +It also has activity graph, which can be modified with number of users shown, and timeframe. + +![](static/documentation/images/image096.jpg) + ## Shortcuts -Many UI elements have shortcut hints. Put your pointer to an interesting element to see it. +Many UI elements have shortcut hints. Put your pointer to a required element to see it. ![](static/documentation/images/image075.jpg) @@ -643,7 +712,7 @@ Many UI elements have shortcut hints. Put your pointer to an interesting element ``L+T`` | lock/unlock all shapes on the current frame ``Q`` or ``Num/`` | set occluded property for an active shape ``N`` | start/stop draw mode -``Alt+N`` | close draw mode without create +``Esc`` | close draw mode without create ``Ctrl+`` | change type of an active shape ``Shift+`` | change type of new shape by default ``Enter`` | change color of active shape @@ -666,22 +735,24 @@ Many UI elements have shortcut hints. Put your pointer to an interesting element ``Shift+S``/``Alt+S`` | increase/decrease saturation on an image ``Ctrl+S`` | save job ``Ctrl+B`` | propagate active shape -``+``/``-`` | change relative order of highlighted polygon +``+``/``-`` | change relative order of highlighted box (if Z-Order is enabled) | | __Interpolation__ | ``M`` | enter/apply merge mode -``Alt+M`` | close merge mode without apply the merge +``Esc`` | close merge mode without apply the merge ``R`` | go to the next key frame of an active shape ``E`` | go to the previous key frame of an active shape +``O`` | change attribute of an active shape to "Outside the frame" +``K`` | mark current frame as key frame on an active shape | | __Attribute annotation mode__ | ``Shift+Enter`` | enter/leave Attribute Annotation mode ``Up Arrow`` | go to the next attribute (up) -``Down Arrown`` | go to the next attribute (down) +``Down Arrow`` | go to the next attribute (down) ``Tab`` | go to the next annotated object ``Shift+Tab`` | go to the previous annotated object ```` | assign a corresponding value to the current attribute | | __Grouping__ | ``G`` | switch group mode -``Alt+G`` | close group mode +``Esc`` | close group mode ``Shift+G`` | reset group for selected shapes | | __Filter__ | ``Left Arrow`` | go to the previous frame which corresponds to the specified filter value diff --git a/cvat/apps/engine/admin.py b/cvat/apps/engine/admin.py index af8dfc47..9cc6599c 100644 --- a/cvat/apps/engine/admin.py +++ b/cvat/apps/engine/admin.py @@ -4,6 +4,74 @@ # SPDX-License-Identifier: MIT from django.contrib import admin +from .models import Task, Segment, Job, Label, AttributeSpec -# Register your models here. +class JobInline(admin.TabularInline): + model = Job + can_delete = False + # Don't show extra lines to add an object + def has_add_permission(self, request, object=None): + return False + +class SegmentInline(admin.TabularInline): + model = Segment + show_change_link = True + readonly_fields = ('start_frame', 'stop_frame') + can_delete = False + + # Don't show extra lines to add an object + def has_add_permission(self, request, object=None): + return False + + +class AttributeSpecInline(admin.TabularInline): + model = AttributeSpec + extra = 0 + max_num = None + +class LabelInline(admin.TabularInline): + model = Label + show_change_link = True + extra = 0 + max_num = None + +class LabelAdmin(admin.ModelAdmin): + # Don't show on admin index page + def has_module_permission(self, request): + return False + + inlines = [ + AttributeSpecInline + ] + +class SegmentAdmin(admin.ModelAdmin): + # Don't show on admin index page + def has_module_permission(self, request): + return False + + inlines = [ + JobInline + ] + +class TaskAdmin(admin.ModelAdmin): + date_hierarchy = 'updated_date' + readonly_fields = ('size', 'path', 'created_date', 'updated_date', + 'overlap', 'flipped') + list_display = ('name', 'mode', 'owner', 'assignee', 'created_date', 'updated_date') + search_fields = ('name', 'mode', 'owner__username', 'owner__first_name', + 'owner__last_name', 'owner__email', 'assignee__username', 'assignee__first_name', + 'assignee__last_name') + inlines = [ + SegmentInline, + LabelInline + ] + + # Don't allow to add a task because it isn't trivial operation + def has_add_permission(self, request): + return False + + +admin.site.register(Task, TaskAdmin) +admin.site.register(Segment, SegmentAdmin) +admin.site.register(Label, LabelAdmin) diff --git a/cvat/apps/engine/annotation.py b/cvat/apps/engine/annotation.py index 35265cb0..79f4f126 100644 --- a/cvat/apps/engine/annotation.py +++ b/cvat/apps/engine/annotation.py @@ -11,7 +11,6 @@ import numpy as np from scipy.optimize import linear_sum_assignment from collections import OrderedDict from distutils.util import strtobool -from xml.dom import minidom from xml.sax.saxutils import XMLGenerator from abc import ABCMeta, abstractmethod from PIL import Image @@ -20,9 +19,10 @@ import django_rq from django.conf import settings from django.db import transaction +from cvat.apps.profiler import silk_profile from . import models from .task import get_frame_path, get_image_meta_cache -from .logging import task_logger, job_logger +from .log import slogger ############################# Low Level server API @@ -39,7 +39,7 @@ def dump(tid, data_format, scheme, host): def check(tid): """ - Check that potentialy long operation 'dump' is completed. + Check that potentially long operation 'dump' is completed. Return the status as json/dictionary object. """ queue = django_rq.get_queue('default') @@ -71,23 +71,57 @@ def get(jid): return annotation.to_client() +@silk_profile(name="Save job") @transaction.atomic def save_job(jid, data): """ Save new annotations for the job. """ - db_job = models.Job.objects.select_for_update().get(id=jid) + slogger.job[jid].info("Enter save_job API: jid = {}".format(jid)) + db_job = models.Job.objects.select_related('segment__task') \ + .select_for_update().get(id=jid) + + annotation = _AnnotationForJob(db_job) + annotation.force_set_client_id(data['create']) + client_ids = annotation.validate_data_from_client(data) + + annotation.delete_from_db(data['delete']) + annotation.save_to_db(data['create']) + annotation.update_in_db(data['update']) + + db_job.segment.task.updated_date = timezone.now() + db_job.segment.task.save() + + db_job.max_shape_id = max(db_job.max_shape_id, max(client_ids['create']) if client_ids['create'] else -1) + db_job.save() + + slogger.job[jid].info("Leave save_job API: jid = {}".format(jid)) + +@silk_profile(name="Clear job") +@transaction.atomic +def clear_job(jid): + """ + Clear annotations for the job. + """ + slogger.job[jid].info("Enter clear_job API: jid = {}".format(jid)) + db_job = models.Job.objects.select_related('segment__task') \ + .select_for_update().get(id=jid) + annotation = _AnnotationForJob(db_job) - annotation.init_from_client(data) - annotation.save_to_db() + annotation.delete_all_shapes_from_db() + annotation.delete_all_paths_from_db() + db_job.segment.task.updated_date = timezone.now() db_job.segment.task.save() + slogger.job[jid].info("Leave clear_job API: jid = {}".format(jid)) # pylint: disable=unused-argument +@silk_profile(name="Save task") def save_task(tid, data): """ Save new annotations for the task. """ + slogger.task[tid].info("Enter save_task API: tid = {}".format(tid)) db_task = models.Task.objects.get(id=tid) db_segments = list(db_task.segment_set.prefetch_related('job_set').all()) @@ -97,24 +131,54 @@ def save_task(tid, data): jid = segment.job_set.first().id start = segment.start_frame stop = segment.stop_frame - splitted_data[jid] = { - "boxes": list(filter(lambda x: start <= int(x['frame']) <= stop, data['boxes'])), - "polygons": list(filter(lambda x: start <= int(x['frame']) <= stop, data['polygons'])), - "polylines": list(filter(lambda x: start <= int(x['frame']) <= stop, data['polylines'])), - "points": list(filter(lambda x: start <= int(x['frame']) <= stop, data['points'])), - "box_paths": list(filter(lambda x: len(list(filter(lambda y: (start <= int(y['frame']) <= stop) and (not y['outside']), x['shapes']))), data['box_paths'])), - "polygon_paths": list(filter(lambda x: len(list(filter(lambda y: (start <= int(y['frame']) <= stop) and (not y['outside']), x['shapes']))), data['polygon_paths'])), - "polyline_paths": list(filter(lambda x: len(list(filter(lambda y: (start <= int(y['frame']) <= stop) and (not y['outside']), x['shapes']))), data['polyline_paths'])), - "points_paths": list(filter(lambda x: len(list(filter(lambda y: (start <= int(y['frame']) <= stop) and (not y['outside']), x['shapes']))), data['points_paths'])), - } + splitted_data[jid] = {} + for action in ['create', 'update', 'delete']: + splitted_data[jid][action] = { + "boxes": list(filter(lambda x: start <= int(x['frame']) <= stop, data[action]['boxes'])), + "polygons": list(filter(lambda x: start <= int(x['frame']) <= stop, data[action]['polygons'])), + "polylines": list(filter(lambda x: start <= int(x['frame']) <= stop, data[action]['polylines'])), + "points": list(filter(lambda x: start <= int(x['frame']) <= stop, data[action]['points'])), + "box_paths": list(filter(lambda x: len(list(filter(lambda y: (start <= int(y['frame']) <= stop) and (not y['outside']), x['shapes']))), data[action]['box_paths'])), + "polygon_paths": list(filter(lambda x: len(list(filter(lambda y: (start <= int(y['frame']) <= stop) and (not y['outside']), x['shapes']))), data[action]['polygon_paths'])), + "polyline_paths": list(filter(lambda x: len(list(filter(lambda y: (start <= int(y['frame']) <= stop) and (not y['outside']), x['shapes']))), data[action]['polyline_paths'])), + "points_paths": list(filter(lambda x: len(list(filter(lambda y: (start <= int(y['frame']) <= stop) and (not y['outside']), x['shapes']))), data[action]['points_paths'])), + } for jid, _data in splitted_data.items(): - save_job(jid, _data) + # if an item inside _data isn't empty need to call save_job + isNonEmpty = False + for action in ['create', 'update', 'delete']: + for objects in _data[action].values(): + if objects: + isNonEmpty = True + break + + if isNonEmpty: + save_job(jid, _data) + + slogger.task[tid].info("Leave save_task API: tid = {}".format(tid)) + + +# pylint: disable=unused-argument +@silk_profile(name="Clear task") +def clear_task(tid): + """ + Clear annotations for the task. + """ + slogger.task[tid].info("Enter clear_task API: tid = {}".format(tid)) + db_task = models.Task.objects.get(id=tid) + db_segments = list(db_task.segment_set.prefetch_related('job_set').all()) + + for db_segment in db_segments: + for db_job in list(db_segment.job_set.all()): + clear_job(db_job.id) + + slogger.task[tid].info("Leave clear_task API: tid = {}".format(tid)) # pylint: disable=unused-argument def rq_handler(job, exc_type, exc_value, traceback): tid = job.id.split('/')[1] - task_logger[tid].error("dump annotation error was occured", exc_info=True) + slogger.task[tid].error("dump annotation error was occured", exc_info=True) ################################################## @@ -133,13 +197,14 @@ class _Attribute: self.value = str(value) class _BoundingBox: - def __init__(self, x0, y0, x1, y1, frame, occluded, z_order, attributes=None): + def __init__(self, x0, y0, x1, y1, frame, occluded, z_order, client_id=None, attributes=None): self.xtl = x0 self.ytl = y0 self.xbr = x1 self.ybr = y1 self.occluded = occluded self.z_order = z_order + self.client_id = client_id self.frame = frame self.attributes = attributes if attributes else [] @@ -156,14 +221,14 @@ class _BoundingBox: self.attributes.append(attr) class _LabeledBox(_BoundingBox): - def __init__(self, label, x0, y0, x1, y1, frame, group_id, occluded, z_order, attributes=None): - super().__init__(x0, y0, x1, y1, frame, occluded, z_order, attributes) + def __init__(self, label, x0, y0, x1, y1, frame, group_id, occluded, z_order, client_id=None, attributes=None): + super().__init__(x0, y0, x1, y1, frame, occluded, z_order, client_id, attributes) self.label = label self.group_id = group_id class _TrackedBox(_BoundingBox): def __init__(self, x0, y0, x1, y1, frame, occluded, z_order, outside, attributes=None): - super().__init__(x0, y0, x1, y1, frame, occluded, z_order, attributes) + super().__init__(x0, y0, x1, y1, frame, occluded, z_order, None, attributes) self.outside = outside class _InterpolatedBox(_TrackedBox): @@ -172,25 +237,26 @@ class _InterpolatedBox(_TrackedBox): self.keyframe = keyframe class _PolyShape: - def __init__(self, points, frame, occluded, z_order, attributes=None): + def __init__(self, points, frame, occluded, z_order, client_id=None, attributes=None): self.points = points + self.frame = frame self.occluded = occluded self.z_order = z_order - self.frame = frame + self.client_id=client_id self.attributes = attributes if attributes else [] def add_attribute(self, attr): self.attributes.append(attr) class _LabeledPolyShape(_PolyShape): - def __init__(self, label, points, frame, group_id, occluded, z_order, attributes=None): - super().__init__(points, frame, occluded, z_order, attributes) + def __init__(self, label, points, frame, group_id, occluded, z_order, client_id=None, attributes=None): + super().__init__(points, frame, occluded, z_order, client_id, attributes) self.label = label self.group_id = group_id class _TrackedPolyShape(_PolyShape): def __init__(self, points, frame, occluded, z_order, outside, attributes=None): - super().__init__(points, frame, occluded, z_order, attributes) + super().__init__(points, frame, occluded, z_order, None, attributes) self.outside = outside class _InterpolatedPolyShape(_TrackedPolyShape): @@ -199,12 +265,13 @@ class _InterpolatedPolyShape(_TrackedPolyShape): self.keyframe = keyframe class _BoxPath: - def __init__(self, label, start_frame, stop_frame, group_id, boxes=None, attributes=None): + def __init__(self, label, start_frame, stop_frame, group_id, boxes=None, client_id=None, attributes=None): self.label = label self.frame = start_frame - self.group_id = group_id self.stop_frame = stop_frame + self.group_id = group_id self.boxes = boxes if boxes else [] + self.client_id = client_id self.attributes = attributes if attributes else [] self._interpolated_boxes = [] assert not self.boxes or self.boxes[-1].frame <= self.stop_frame @@ -273,12 +340,13 @@ class _BoxPath: self.attributes.append(attr) class _PolyPath: - def __init__(self, label, start_frame, stop_frame, group_id, shapes=None, attributes=None): + def __init__(self, label, start_frame, stop_frame, group_id, shapes=None, client_id=None, attributes=None): self.label = label self.frame = start_frame - self.group_id = group_id self.stop_frame = stop_frame + self.group_id = group_id self.shapes = shapes if shapes else [] + self.client_id = client_id self.attributes = attributes if attributes else [] self._interpolated_shapes = [] # ??? @@ -333,14 +401,29 @@ class _Annotation: self.points = [] self.points_paths = [] + def has_data(self): + non_empty = False + for attr in ['boxes', 'box_paths', 'polygons', 'polygon_paths', + 'polylines', 'polyline_paths', 'points', 'points_paths']: + non_empty |= bool(getattr(self, attr)) + + return non_empty + # Functions below used by dump functionality def to_boxes(self): boxes = [] for path in self.box_paths: for box in path.get_interpolated_boxes(): if not box.outside: - box = _LabeledBox(path.label, box.xtl, box.ytl, box.xbr, box.ybr, - box.frame, path.group_id, box.occluded, box.z_order, box.attributes + path.attributes) + box = _LabeledBox( + label=path.label, + x0=box.xtl, y0=box.ytl, x1=box.xbr, y1=box.ybr, + frame=box.frame, + group_id=path.group_id, + occluded=box.occluded, + z_order=box.z_order, + attributes=box.attributes + path.attributes, + ) boxes.append(box) return self.boxes + boxes @@ -350,19 +433,29 @@ class _Annotation: for path in getattr(self, iter_attr_name): for shape in path.get_interpolated_shapes(): if not shape.outside: - shape = _LabeledPolyShape(path.label, shape.points, shape.frame, path.group_id, - shape.occluded, shape.z_order, shape.attributes + path.attributes) + shape = _LabeledPolyShape( + label=path.label, + points=shape.points, + frame=shape.frame, + group_id=path.group_id, + occluded=shape.occluded, + z_order=shape.z_order, + attributes=shape.attributes + path.attributes, + ) shapes.append(shape) return shapes def to_polygons(self): - return self._to_poly_shapes('polygon_paths') + self.polygons + polygons = self._to_poly_shapes('polygon_paths') + return polygons + self.polygons def to_polylines(self): - return self._to_poly_shapes('polyline_paths') + self.polylines + polylines = self._to_poly_shapes('polyline_paths') + return polylines + self.polylines def to_points(self): - return self._to_poly_shapes('points_paths') + self.points + points = self._to_poly_shapes('points_paths') + return points + self.points def to_box_paths(self): paths = [] @@ -372,7 +465,15 @@ class _Annotation: box1 = copy.copy(box0) box1.outside = True box1.frame += 1 - path = _BoxPath(box.label, box.frame, box.frame + 1, box.group_id, [box0, box1], box.attributes) + path = _BoxPath( + label=box.label, + start_frame=box.frame, + stop_frame=box.frame + 1, + group_id=box.group_id, + boxes=[box0, box1], + attributes=box.attributes, + client_id=box.client_id, + ) paths.append(path) return self.box_paths + paths @@ -385,7 +486,15 @@ class _Annotation: shape1 = copy.copy(shape0) shape1.outside = True shape1.frame += 1 - path = _PolyPath(shape.label, shape.frame, shape.frame + 1, shape.group_id, [shape0, shape1], shape.attributes) + path = _PolyPath( + label=shape.label, + start_frame=shape.frame, + stop_frame=shape.frame + 1, + group_id=shape.group_id, + shapes=[shape0, shape1], + client_id=shape.client_id, + attributes=shape.attributes, + ) paths.append(path) return paths @@ -399,6 +508,18 @@ class _Annotation: def to_points_paths(self): return self._to_poly_paths('points') + self.points_paths +def bulk_create(db_model, objects, flt_param = {}): + if objects: + if flt_param: + if 'postgresql' in settings.DATABASES["default"]["ENGINE"]: + return db_model.objects.bulk_create(objects) + else: + ids = list(db_model.objects.filter(**flt_param).values_list('id', flat=True)) + db_model.objects.bulk_create(objects) + + return list(db_model.objects.exclude(id__in=ids).filter(**flt_param)) + else: + return db_model.objects.bulk_create(objects) class _AnnotationForJob(_Annotation): def __init__(self, db_job): @@ -407,13 +528,26 @@ class _AnnotationForJob(_Annotation): # pylint: disable=bad-continuation self.db_job = db_job - self.logger = job_logger[db_job.id] + self.logger = slogger.job[db_job.id] self.db_labels = {db_label.id:db_label for db_label in db_job.segment.task.label_set.all()} self.db_attributes = {db_attr.id:db_attr for db_attr in models.AttributeSpec.objects.filter( label__task__id=db_job.segment.task.id)} + def _get_client_ids_from_db(self): + client_ids = set() + + ids = list(self.db_job.objectpath_set.values_list('client_id', flat=True)) + client_ids.update(ids) + + for shape_type in ['polygons', 'polylines', 'points', 'boxes']: + ids = list(self._get_shape_class(shape_type).objects.filter( + job_id=self.db_job.id).values_list('client_id', flat=True)) + client_ids.update(ids) + + return client_ids + def _merge_table_rows(self, rows, keys_for_merge, field_id): """dot.notation access to dictionary attributes""" class dotdict(OrderedDict): @@ -450,11 +584,16 @@ class _AnnotationForJob(_Annotation): return list(merged_rows.values()) + @staticmethod + def _clamp(value, min_value, max_value): + return max(min(value, max_value), min_value) + def _clamp_box(self, xtl, ytl, xbr, ybr, im_size): - xtl = max(min(xtl, im_size['width']), 0) - ytl = max(min(ytl, im_size['height']), 0) - xbr = max(min(xbr, im_size['width']), 0) - ybr = max(min(ybr, im_size['height']), 0) + xtl = self._clamp(xtl, 0, im_size['width']) + xbr = self._clamp(xbr, 0, im_size['width']) + ytl = self._clamp(ytl, 0, im_size['height']) + ybr = self._clamp(ybr, 0, im_size['height']) + return xtl, ytl, xbr, ybr def _clamp_poly(self, points, im_size): @@ -463,16 +602,17 @@ class _AnnotationForJob(_Annotation): for p in points: p = p.split(',') verified.append('{},{}'.format( - max(min(float(p[0]), im_size['width']), 0), - max(min(float(p[1]), im_size['height']), 0) + self._clamp(float(p[0]), 0, im_size['width']), + self._clamp(float(p[1]), 0, im_size['height']) )) + return ' '.join(verified) def init_from_db(self): def get_values(shape_type): if shape_type == 'polygons': return [ - ('id', 'frame', 'points', 'label_id', 'group_id', 'occluded', 'z_order', + ('id', 'frame', 'points', 'label_id', 'group_id', 'occluded', 'z_order', 'client_id', 'labeledpolygonattributeval__value', 'labeledpolygonattributeval__spec_id', 'labeledpolygonattributeval__id'), { 'attributes': [ @@ -484,7 +624,7 @@ class _AnnotationForJob(_Annotation): ] elif shape_type == 'polylines': return [ - ('id', 'frame', 'points', 'label_id', 'group_id', 'occluded', 'z_order', + ('id', 'frame', 'points', 'label_id', 'group_id', 'occluded', 'z_order', 'client_id', 'labeledpolylineattributeval__value', 'labeledpolylineattributeval__spec_id', 'labeledpolylineattributeval__id'), { 'attributes': [ @@ -496,7 +636,7 @@ class _AnnotationForJob(_Annotation): ] elif shape_type == 'boxes': return [ - ('id', 'frame', 'xtl', 'ytl', 'xbr', 'ybr', 'label_id', 'group_id', 'occluded', 'z_order', + ('id', 'frame', 'xtl', 'ytl', 'xbr', 'ybr', 'label_id', 'group_id', 'occluded', 'z_order', 'client_id', 'labeledboxattributeval__value', 'labeledboxattributeval__spec_id', 'labeledboxattributeval__id'), { 'attributes': [ @@ -508,7 +648,7 @@ class _AnnotationForJob(_Annotation): ] elif shape_type == 'points': return [ - ('id', 'frame', 'points', 'label_id', 'group_id', 'occluded', 'z_order', + ('id', 'frame', 'points', 'label_id', 'group_id', 'occluded', 'z_order', 'client_id', 'labeledpointsattributeval__value', 'labeledpointsattributeval__spec_id', 'labeledpointsattributeval__id'), { 'attributes': [ @@ -528,11 +668,24 @@ class _AnnotationForJob(_Annotation): for db_shape in db_shapes: label = _Label(self.db_labels[db_shape.label_id]) if shape_type == 'boxes': - shape = _LabeledBox(label, db_shape.xtl, db_shape.ytl, db_shape.xbr, db_shape.ybr, - db_shape.frame, db_shape.group_id, db_shape.occluded, db_shape.z_order) + shape = _LabeledBox(label=label, + x0=db_shape.xtl, y0=db_shape.ytl, x1=db_shape.xbr, y1=db_shape.ybr, + frame=db_shape.frame, + group_id=db_shape.group_id, + occluded=db_shape.occluded, + z_order=db_shape.z_order, + client_id=db_shape.client_id, + ) else: - shape = _LabeledPolyShape(label, db_shape.points, db_shape.frame, - db_shape.group_id, db_shape.occluded, db_shape.z_order) + shape = _LabeledPolyShape( + label=label, + points=db_shape.points, + frame=db_shape.frame, + group_id=db_shape.group_id, + occluded=db_shape.occluded, + z_order=db_shape.z_order, + client_id=db_shape.client_id, + ) for db_attr in db_shape.attributes: if db_attr.id != None: spec = self.db_attributes[db_attr.spec_id] @@ -540,8 +693,6 @@ class _AnnotationForJob(_Annotation): shape.add_attribute(attr) getattr(self, shape_type).append(shape) - - db_paths = self.db_job.objectpath_set for shape in ['trackedpoints_set', 'trackedbox_set', 'trackedpolyline_set', 'trackedpolygon_set']: db_paths.prefetch_related(shape) @@ -549,7 +700,7 @@ class _AnnotationForJob(_Annotation): 'trackedpolygon_set__trackedpolygonattributeval_set', 'trackedpolyline_set__trackedpolylineattributeval_set']: db_paths.prefetch_related(shape_attr) db_paths.prefetch_related('objectpathattributeval_set') - db_paths = list (db_paths.values('id', 'frame', 'group_id', 'shapes', 'objectpathattributeval__spec_id', + db_paths = list (db_paths.values('id', 'frame', 'group_id', 'shapes', 'client_id', 'objectpathattributeval__spec_id', 'objectpathattributeval__id', 'objectpathattributeval__value', 'trackedbox', 'trackedpolygon', 'trackedpolyline', 'trackedpoints', 'trackedbox__id', 'label_id', 'trackedbox__xtl', 'trackedbox__ytl', @@ -667,7 +818,13 @@ class _AnnotationForJob(_Annotation): for db_shape in db_path.shapes: db_shape.attributes = list(set(db_shape.attributes)) label = _Label(self.db_labels[db_path.label_id]) - path = _BoxPath(label, db_path.frame, self.stop_frame, db_path.group_id) + path = _BoxPath( + label=label, + start_frame=db_path.frame, + stop_frame=self.stop_frame, + group_id=db_path.group_id, + client_id=db_path.client_id, + ) for db_attr in db_path.attributes: spec = self.db_attributes[db_attr.spec_id] attr = _Attribute(spec, db_attr.value) @@ -675,8 +832,13 @@ class _AnnotationForJob(_Annotation): frame = -1 for db_shape in db_path.shapes: - box = _TrackedBox(db_shape.xtl, db_shape.ytl, db_shape.xbr, db_shape.ybr, - db_shape.frame, db_shape.occluded, db_shape.z_order, db_shape.outside) + box = _TrackedBox( + x0=db_shape.xtl, y0=db_shape.ytl, x1=db_shape.xbr, y1=db_shape.ybr, + frame=db_shape.frame, + occluded=db_shape.occluded, + z_order=db_shape.z_order, + outside=db_shape.outside, + ) assert box.frame > frame frame = box.frame @@ -695,7 +857,13 @@ class _AnnotationForJob(_Annotation): for db_shape in db_path.shapes: db_shape.attributes = list(set(db_shape.attributes)) label = _Label(self.db_labels[db_path.label_id]) - path = _PolyPath(label, db_path.frame, self.stop_frame, db_path.group_id) + path = _PolyPath( + label=label, + start_frame=db_path.frame, + stop_frame= self.stop_frame, + group_id=db_path.group_id, + client_id=db_path.client_id, + ) for db_attr in db_path.attributes: spec = self.db_attributes[db_attr.spec_id] attr = _Attribute(spec, db_attr.value) @@ -703,7 +871,13 @@ class _AnnotationForJob(_Annotation): frame = -1 for db_shape in db_path.shapes: - shape = _TrackedPolyShape(db_shape.points, db_shape.frame, db_shape.occluded, db_shape.z_order, db_shape.outside) + shape = _TrackedPolyShape( + points=db_shape.points, + frame=db_shape.frame, + occluded=db_shape.occluded, + z_order=db_shape.z_order, + outside=db_shape.outside, + ) assert shape.frame > frame frame = shape.frame @@ -731,8 +905,16 @@ class _AnnotationForJob(_Annotation): xtl, ytl, xbr, ybr = self._clamp_box(float(box['xtl']), float(box['ytl']), float(box['xbr']), float(box['ybr']), image_meta['original_size'][frame_idx]) - labeled_box = _LabeledBox(label, xtl, ytl, xbr, ybr, int(box['frame']), - int(box['group_id']), strtobool(str(box['occluded'])), int(box['z_order'])) + + labeled_box = _LabeledBox( + label=label, + x0=xtl, y0=ytl, x1=xbr, y1=ybr, + frame=int(box['frame']), + group_id=int(box['group_id']), + occluded=strtobool(str(box['occluded'])), + z_order=int(box['z_order']), + client_id=int(box['id']), + ) for attr in box['attributes']: spec = self.db_attributes[int(attr['id'])] @@ -747,8 +929,15 @@ class _AnnotationForJob(_Annotation): frame_idx = int(poly_shape['frame']) if db_task.mode == 'annotation' else 0 points = self._clamp_poly(poly_shape['points'], image_meta['original_size'][frame_idx]) - labeled_poly_shape = _LabeledPolyShape(label, points, int(poly_shape['frame']), - int(poly_shape['group_id']), poly_shape['occluded'], int(poly_shape['z_order'])) + labeled_poly_shape = _LabeledPolyShape( + label=label, + points=points, + frame=int(poly_shape['frame']), + group_id=int(poly_shape['group_id']), + occluded=poly_shape['occluded'], + z_order=int(poly_shape['z_order']), + client_id=int(poly_shape['id']), + ) for attr in poly_shape['attributes']: spec = self.db_attributes[int(attr['id'])] @@ -781,9 +970,14 @@ class _AnnotationForJob(_Annotation): frame_idx = int(box['frame']) if db_task.mode == 'annotation' else 0 xtl, ytl, xbr, ybr = self._clamp_box(float(box['xtl']), float(box['ytl']), float(box['xbr']), float(box['ybr']), image_meta['original_size'][frame_idx]) - tracked_box = _TrackedBox(xtl, ytl, xbr, ybr, int(box['frame']), strtobool(str(box['occluded'])), - int(box['z_order']), strtobool(str(box['outside']))) - assert tracked_box.frame > frame + tracked_box = _TrackedBox( + x0=xtl, y0=ytl, x1=xbr, y1=ybr, + frame=int(box['frame']), + occluded=strtobool(str(box['occluded'])), + z_order=int(box['z_order']), + outside=strtobool(str(box['outside'])), + ) + assert tracked_box.frame > frame frame = tracked_box.frame for attr in box['attributes']: @@ -805,8 +999,14 @@ class _AnnotationForJob(_Annotation): attributes.append(attr) assert frame <= self.stop_frame - box_path = _BoxPath(label, min(list(map(lambda box: box.frame, boxes))), self.stop_frame, - int(path['group_id']), boxes, attributes) + box_path = _BoxPath(label=label, + start_frame=min(list(map(lambda box: box.frame, boxes))), + stop_frame=self.stop_frame, + group_id=int(path['group_id']), + boxes=boxes, + client_id=int(path['id']), + attributes=attributes, + ) self.box_paths.append(box_path) for poly_path_type in ['points_paths', 'polygon_paths', 'polyline_paths']: @@ -833,8 +1033,13 @@ class _AnnotationForJob(_Annotation): if int(poly_shape['frame']) <= self.stop_frame and int(poly_shape['frame']) >= self.start_frame: frame_idx = int(poly_shape['frame']) if db_task.mode == 'annotation' else 0 points = self._clamp_poly(poly_shape['points'], image_meta['original_size'][frame_idx]) - tracked_poly_shape = _TrackedPolyShape(points, int(poly_shape['frame']), strtobool(str(poly_shape['occluded'])), - int(poly_shape['z_order']), strtobool(str(poly_shape['outside']))) + tracked_poly_shape = _TrackedPolyShape( + points=points, + frame=int(poly_shape['frame']), + occluded=strtobool(str(poly_shape['occluded'])), + z_order=int(poly_shape['z_order']), + outside=strtobool(str(poly_shape['outside'])), + ) assert tracked_poly_shape.frame > frame frame = tracked_poly_shape.frame @@ -856,11 +1061,20 @@ class _AnnotationForJob(_Annotation): attr = _Attribute(spec, str(attr['value'])) attributes.append(attr) - poly_path = _PolyPath(label, min(list(map(lambda shape: shape.frame, poly_shapes))), self.stop_frame + 1, - int(path['group_id']), poly_shapes, attributes) + poly_path = _PolyPath( + label=label, + start_frame=min(list(map(lambda shape: shape.frame, poly_shapes))), + stop_frame=self.stop_frame + 1, + group_id=int(path['group_id']), + shapes=poly_shapes, + client_id=int(path['id']), + attributes=attributes, + ) getattr(self, poly_path_type).append(poly_path) + return self.has_data() + def _get_shape_class(self, shape_type): if shape_type == 'polygons': return models.LabeledPolygon @@ -898,20 +1112,20 @@ class _AnnotationForJob(_Annotation): return models.TrackedPointsAttributeVal def _save_paths_to_db(self): - self.db_job.objectpath_set.all().delete() - for shape_type in ['polygon_paths', 'polyline_paths', 'points_paths', 'box_paths']: db_paths = [] db_path_attrvals = [] db_shapes = [] db_shape_attrvals = [] - for path in getattr(self, shape_type): + shapes = getattr(self, shape_type) + for path in shapes: db_path = models.ObjectPath() db_path.job = self.db_job db_path.label = self.db_labels[path.label.id] db_path.frame = path.frame db_path.group_id = path.group_id + db_path.client_id = path.client_id if shape_type == 'polygon_paths': db_path.shapes = 'polygons' elif shape_type == 'polyline_paths': @@ -929,8 +1143,8 @@ class _AnnotationForJob(_Annotation): db_attrval.value = attr.value db_path_attrvals.append(db_attrval) - shapes = path.boxes if hasattr(path, 'boxes') else path.shapes - for shape in shapes: + path_shapes = path.boxes if hasattr(path, 'boxes') else path.shapes + for shape in path_shapes: db_shape = self._get_shape_class(shape_type)() db_shape.track_id = len(db_paths) if shape_type == 'box_paths': @@ -962,37 +1176,19 @@ class _AnnotationForJob(_Annotation): db_shapes.append(db_shape) db_paths.append(db_path) - db_paths = models.ObjectPath.objects.bulk_create(db_paths) - if db_paths and db_paths[0].id == None: - # Try to get primary keys. Probably the code will work for sqlite - # but it definetely doesn't work for Postgres. Need to say that - # for Postgres bulk_create will return objects with ids even ids - # are auto incremented. Thus we will not be inside the 'if'. - if shape_type == 'polygon_paths': - db_paths = list(self.db_job.objectpath_set.filter(shapes="polygons")) - elif shape_type == 'polyline_paths': - db_paths = list(self.db_job.objectpath_set.filter(shapes="polylines")) - elif shape_type == 'box_paths': - db_paths = list(self.db_job.objectpath_set.filter(shapes="boxes")) - elif shape_type == 'points_paths': - db_paths = list(self.db_job.objectpath_set.filter(shapes="points")) + db_paths = bulk_create(models.ObjectPath, db_paths, + {"job_id": self.db_job.id}) for db_attrval in db_path_attrvals: db_attrval.track_id = db_paths[db_attrval.track_id].id - models.ObjectPathAttributeVal.objects.bulk_create(db_path_attrvals) + bulk_create(models.ObjectPathAttributeVal, db_path_attrvals) for db_shape in db_shapes: db_shape.track_id = db_paths[db_shape.track_id].id - db_shapes = self._get_shape_class(shape_type).objects.bulk_create(db_shapes) - - if db_shapes and db_shapes[0].id == None: - # Try to get primary keys. Probably the code will work for sqlite - # but it definetely doesn't work for Postgres. Need to say that - # for Postgres bulk_create will return objects with ids even ids - # are auto incremented. Thus we will not be inside the 'if'. - db_shapes = list(self._get_shape_class(shape_type).objects.filter(track__job_id=self.db_job.id)) + db_shapes = bulk_create(self._get_shape_class(shape_type), db_shapes, + {"track__job_id": self.db_job.id}) for db_attrval in db_shape_attrvals: if shape_type == 'polygon_paths': @@ -1004,7 +1200,7 @@ class _AnnotationForJob(_Annotation): elif shape_type == 'points_paths': db_attrval.points_id = db_shapes[db_attrval.points_id].id - self._get_shape_attr_class(shape_type).objects.bulk_create(db_shape_attrvals) + bulk_create(self._get_shape_attr_class(shape_type), db_shape_attrvals) def _get_shape_set(self, shape_type): if shape_type == 'polygons': @@ -1017,19 +1213,17 @@ class _AnnotationForJob(_Annotation): return self.db_job.labeledpoints_set def _save_shapes_to_db(self): - db_shapes = [] - db_attrvals = [] - for shape_type in ['polygons', 'polylines', 'points', 'boxes']: - self._get_shape_set(shape_type).all().delete() db_shapes = [] db_attrvals = [] - for shape in getattr(self, shape_type): + shapes = getattr(self, shape_type) + for shape in shapes: db_shape = self._get_shape_class(shape_type)() db_shape.job = self.db_job db_shape.label = self.db_labels[shape.label.id] db_shape.group_id = shape.group_id + db_shape.client_id = shape.client_id if shape_type == 'boxes': db_shape.xtl = shape.xtl db_shape.ytl = shape.ytl @@ -1058,14 +1252,8 @@ class _AnnotationForJob(_Annotation): db_shapes.append(db_shape) - db_shapes = self._get_shape_class(shape_type).objects.bulk_create(db_shapes) - - if db_shapes and db_shapes[0].id == None: - # Try to get primary keys. Probably the code will work for sqlite - # but it definetely doesn't work for Postgres. Need to say that - # for Postgres bulk_create will return objects with ids even ids - # are auto incremented. Thus we will not be inside the 'if'. - db_shapes = list(self._get_shape_set(shape_type).all()) + db_shapes = bulk_create(self._get_shape_class(shape_type), db_shapes, + {"job_id": self.db_job.id}) for db_attrval in db_attrvals: if shape_type == 'polygons': @@ -1077,12 +1265,61 @@ class _AnnotationForJob(_Annotation): else: db_attrval.points_id = db_shapes[db_attrval.points_id].id - self._get_shape_attr_class(shape_type).objects.bulk_create(db_attrvals) + bulk_create(self._get_shape_attr_class(shape_type), db_attrvals) - def save_to_db(self): + def _update_shapes_in_db(self): + client_ids_to_delete = {} + for shape_type in ['polygons', 'polylines', 'points', 'boxes']: + client_ids_to_delete[shape_type] = list(shape.client_id for shape in getattr(self, shape_type)) + self._delete_shapes_from_db(client_ids_to_delete) self._save_shapes_to_db() + + def _update_paths_in_db(self): + client_ids_to_delete = {} + for shape_type in ['polygon_paths', 'polyline_paths', 'points_paths', 'box_paths']: + client_ids_to_delete[shape_type] = list(shape.client_id for shape in getattr(self, shape_type)) + self._delete_paths_from_db(client_ids_to_delete) self._save_paths_to_db() + def _delete_shapes_from_db(self, data): + for shape_type in ['polygons', 'polylines', 'points', 'boxes']: + client_ids_to_delete = data[shape_type] + deleted = self._get_shape_set(shape_type).filter(client_id__in=client_ids_to_delete).delete() + class_name = 'engine.{}'.format(self._get_shape_class(shape_type).__name__) + if not (deleted[0] == 0 and len(client_ids_to_delete) == 0) and (class_name in deleted[1] and deleted[1][class_name] != len(client_ids_to_delete)): + raise Exception('Number of deleted object doesn\'t match with requested number') + + def _delete_paths_from_db(self, data): + client_ids_to_delete = [] + for shape_type in ['polygon_paths', 'polyline_paths', 'points_paths', 'box_paths']: + client_ids_to_delete.extend(data[shape_type]) + deleted = self.db_job.objectpath_set.filter(client_id__in=client_ids_to_delete).delete() + class_name = 'engine.ObjectPath' + if not (deleted[0] == 0 and len(client_ids_to_delete) == 0) and \ + (class_name in deleted[1] and deleted[1][class_name] != len(client_ids_to_delete)): + raise Exception('Number of deleted object doesn\'t match with requested number') + + def delete_all_shapes_from_db(self): + for shape_type in ['polygons', 'polylines', 'points', 'boxes']: + self._get_shape_set(shape_type).all().delete() + + def delete_all_paths_from_db(self): + self.db_job.objectpath_set.all().delete() + + def delete_from_db(self, data): + self._delete_shapes_from_db(data) + self._delete_paths_from_db(data) + + def update_in_db(self, data): + if self.init_from_client(data): + self._update_shapes_in_db() + self._update_paths_in_db() + + def save_to_db(self, data): + if self.init_from_client(data): + self._save_shapes_to_db() + self._save_paths_to_db() + def to_client(self): data = { "boxes": [], @@ -1092,11 +1329,12 @@ class _AnnotationForJob(_Annotation): "polylines": [], "polyline_paths": [], "points": [], - "points_paths": [] + "points_paths": [], } for box in self.boxes: data["boxes"].append({ + "id": box.client_id, "label_id": box.label.id, "group_id": box.group_id, "xtl": box.xtl, @@ -1112,6 +1350,7 @@ class _AnnotationForJob(_Annotation): for poly_type in ['polygons', 'polylines', 'points']: for poly in getattr(self, poly_type): data[poly_type].append({ + "id": poly.client_id, "label_id": poly.label.id, "group_id": poly.group_id, "points": poly.points, @@ -1123,6 +1362,7 @@ class _AnnotationForJob(_Annotation): for box_path in self.box_paths: data["box_paths"].append({ + "id": box_path.client_id, "label_id": box_path.label.id, "group_id": box_path.group_id, "frame": box_path.frame, @@ -1145,6 +1385,7 @@ class _AnnotationForJob(_Annotation): for poly_path_type in ['polygon_paths', 'polyline_paths', 'points_paths']: for poly_path in getattr(self, poly_path_type): data[poly_path_type].append({ + "id": poly_path.client_id, "label_id": poly_path.label.id, "group_id": poly_path.group_id, "frame": poly_path.frame, @@ -1163,6 +1404,75 @@ class _AnnotationForJob(_Annotation): return data + def validate_data_from_client(self, data): + client_ids = { + 'saved': self._get_client_ids_from_db(), + 'create': set(), + 'update': set(), + 'delete': set(), + } + + def extract_clinet_id(shape, action): + if action != 'delete': + if 'id' not in shape: + raise Exception('No id field in received data') + client_id = shape['id'] + else: + # client send only shape.id, not shape object + client_id = shape + client_ids[action].add(client_id) + + shape_types = ['boxes', 'points', 'polygons', 'polylines', 'box_paths', + 'points_paths', 'polygon_paths', 'polyline_paths'] + + for action in ['create', 'update', 'delete']: + for shape_type in shape_types: + for shape in data[action][shape_type]: + extract_clinet_id(shape, action) + + # In case of delete action potentially it is possible to intersect set of IDs + # that should delete and set of IDs that should create(i.e. save uploaded anno). + # There is no need to check that + tmp_res = (client_ids['create'] & client_ids['update']) | (client_ids['update'] & client_ids['delete']) + if tmp_res: + raise Exception('More than one action for shape(s) with id={}'.format(tmp_res)) + + tmp_res = (client_ids['saved'] - client_ids['delete']) & client_ids['create'] + if tmp_res: + raise Exception('Trying to create new shape(s) with existing client id {}'.format(tmp_res)) + + tmp_res = client_ids['delete'] - client_ids['saved'] + if tmp_res: + raise Exception('Trying to delete shape(s) with nonexistent client id {}'.format(tmp_res)) + + tmp_res = client_ids['update'] - (client_ids['saved'] - client_ids['delete']) + if tmp_res: + raise Exception('Trying to update shape(s) with nonexistent client id {}'.format(tmp_res)) + + max_id = self.db_job.max_shape_id + if any(new_client_id <= max_id for new_client_id in client_ids['create']): + raise Exception('Trying to create shape(s) with client id {} less than allowed value {}'.format(client_ids['create'], max_id)) + + return client_ids + + def force_set_client_id(self, data): + shape_types = ['boxes', 'points', 'polygons', 'polylines', 'box_paths', + 'points_paths', 'polygon_paths', 'polyline_paths'] + + max_id = self.db_job.max_shape_id + for shape_type in shape_types: + if not data[shape_type]: + continue + for shape in data[shape_type]: + if 'id' in shape: + max_id = max(max_id, shape['id']) + + max_id += 1 + for shape_type in shape_types: + for shape in data[shape_type]: + if 'id' not in shape or shape['id'] == -1: + shape['id'] = max_id + max_id += 1 class _AnnotationForSegment(_Annotation): def __init__(self, db_segment): @@ -1192,7 +1502,7 @@ def _dump(tid, data_format, scheme, host): db_task = models.Task.objects.select_for_update().get(id=tid) annotation = _AnnotationForTask(db_task) annotation.init_from_db() - annotation.dump(data_format, db_task, scheme, host) + annotation.dump(data_format, scheme, host) def _calc_box_area(box): return (box.xbr - box.xtl) * (box.ybr - box.ytl) @@ -1571,7 +1881,7 @@ class _AnnotationForTask(_Annotation): # We don't have old boxes on the frame. Let's add all new ones. self.boxes.extend(int_boxes_by_frame[frame]) - def dump(self, data_format, db_task, scheme, host): + def dump(self, data_format, scheme, host): def _flip_box(box, im_w, im_h): box.xbr, box.xtl = im_w - box.xtl, im_w - box.xbr box.ybr, box.ytl = im_h - box.ytl, im_h - box.ybr @@ -1591,6 +1901,7 @@ class _AnnotationForTask(_Annotation): shape.points = ' '.join(['{},{}'.format(point['x'], point['y']) for point in points]) + db_task = self.db_task db_segments = db_task.segment_set.all().prefetch_related('job_set') db_labels = db_task.label_set.all().prefetch_related('attributespec_set') im_meta_data = get_image_meta_cache(db_task) @@ -1606,6 +1917,7 @@ class _AnnotationForTask(_Annotation): ("flipped", str(db_task.flipped)), ("created", str(timezone.localtime(db_task.created_date))), ("updated", str(timezone.localtime(db_task.updated_date))), + ("source", db_task.source), ("labels", [ ("label", OrderedDict([ @@ -1633,41 +1945,44 @@ class _AnnotationForTask(_Annotation): ("dumped", str(timezone.localtime(timezone.now()))) ]) - if self.db_task.mode == "interpolation": + if db_task.mode == "interpolation": meta["task"]["original_size"] = OrderedDict([ ("width", str(im_meta_data["original_size"][0]["width"])), ("height", str(im_meta_data["original_size"][0]["height"])) ]) - dump_path = self.db_task.get_dump_path() + dump_path = db_task.get_dump_path() with open(dump_path, "w") as dump_file: dumper = _XmlAnnotationWriter(dump_file) dumper.open_root() dumper.add_meta(meta) - if self.db_task.mode == "annotation": + if db_task.mode == "annotation": shapes = {} shapes["boxes"] = {} shapes["polygons"] = {} shapes["polylines"] = {} shapes["points"] = {} - - for box in self.to_boxes(): + boxes = self.to_boxes() + for box in boxes: if box.frame not in shapes["boxes"]: shapes["boxes"][box.frame] = [] shapes["boxes"][box.frame].append(box) - for polygon in self.to_polygons(): + polygons = self.to_polygons() + for polygon in polygons: if polygon.frame not in shapes["polygons"]: shapes["polygons"][polygon.frame] = [] shapes["polygons"][polygon.frame].append(polygon) - for polyline in self.to_polylines(): + polylines = self.to_polylines() + for polyline in polylines: if polyline.frame not in shapes["polylines"]: shapes["polylines"][polyline.frame] = [] shapes["polylines"][polyline.frame].append(polyline) - for points in self.to_points(): + points = self.to_points() + for points in points: if points.frame not in shapes["points"]: shapes["points"][points.frame] = [] shapes["points"][points.frame].append(points) @@ -1677,7 +1992,7 @@ class _AnnotationForTask(_Annotation): list(shapes["polylines"].keys()) + list(shapes["points"].keys()))): - link = get_frame_path(self.db_task.id, frame) + link = get_frame_path(db_task.id, frame) path = os.readlink(link) rpath = path.split(os.path.sep) @@ -1707,7 +2022,7 @@ class _AnnotationForTask(_Annotation): ("ytl", "{:.2f}".format(shape.ytl)), ("xbr", "{:.2f}".format(shape.xbr)), ("ybr", "{:.2f}".format(shape.ybr)), - ("occluded", str(int(shape.occluded))) + ("occluded", str(int(shape.occluded))), ]) if db_task.z_order: dump_dict['z_order'] = str(shape.z_order) @@ -1726,7 +2041,7 @@ class _AnnotationForTask(_Annotation): "{:.2f}".format(float(p.split(',')[1])) )) for p in shape.points.split(' ')) )), - ("occluded", str(int(shape.occluded))) + ("occluded", str(int(shape.occluded))), ]) if db_task.z_order: @@ -1767,13 +2082,15 @@ class _AnnotationForTask(_Annotation): im_w = im_meta_data['original_size'][0]['width'] im_h = im_meta_data['original_size'][0]['height'] - path_idx = 0 + counter = 0 for shape_type in ["boxes", "polygons", "polylines", "points"]: path_list = paths[shape_type] for path in path_list: + path_id = path.client_id if path.client_id != -1 else counter + counter += 1 dump_dict = OrderedDict([ - ("id", str(path_idx)), - ("label", path.label.name) + ("id", str(path_id)), + ("label", path.label.name), ]) if path.group_id: dump_dict['group_id'] = str(path.group_id) @@ -1842,6 +2159,5 @@ class _AnnotationForTask(_Annotation): dumper.close_polyline() else: dumper.close_points() - path_idx += 1 dumper.close_track() dumper.close_root() diff --git a/cvat/apps/engine/log.py b/cvat/apps/engine/log.py new file mode 100644 index 00000000..4b065bfc --- /dev/null +++ b/cvat/apps/engine/log.py @@ -0,0 +1,100 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os +import logging +from cvat.settings.base import LOGGING +from .models import Job, Task + +def _get_task(tid): + try: + return Task.objects.get(pk=tid) + except Exception: + raise Exception('{} key must be a task identifier'.format(tid)) + +def _get_job(jid): + try: + return Job.objects.select_related("segment__task").get(id=jid) + except Exception: + raise Exception('{} key must be a job identifier'.format(jid)) + +class TaskLoggerStorage: + def __init__(self): + self._storage = dict() + + def __getitem__(self, tid): + if tid not in self._storage: + self._storage[tid] = self._create_task_logger(tid) + return self._storage[tid] + + def _create_task_logger(self, tid): + task = _get_task(tid) + + logger = logging.getLogger('cvat.server.task_{}'.format(tid)) + server_file = logging.FileHandler(filename=task.get_log_path()) + formatter = logging.Formatter(LOGGING['formatters']['standard']['format']) + server_file.setFormatter(formatter) + logger.addHandler(server_file) + + return logger + +class JobLoggerStorage: + def __init__(self): + self._storage = dict() + + def __getitem__(self, jid): + if jid not in self._storage: + self._storage[jid] = self._get_task_logger(jid) + return self._storage[jid] + + def _get_task_logger(self, jid): + job = _get_job(jid) + return slogger.task[job.segment.task.id] + +class TaskClientLoggerStorage: + def __init__(self): + self._storage = dict() + + def __getitem__(self, tid): + if tid not in self._storage: + self._storage[tid] = self._create_client_logger(tid) + return self._storage[tid] + + def _create_client_logger(self, tid): + task = _get_task(tid) + logger = logging.getLogger('cvat.client.task_{}'.format(tid)) + client_file = logging.FileHandler(filename=task.get_client_log_path()) + logger.addHandler(client_file) + + return logger + +class JobClientLoggerStorage: + def __init__(self): + self._storage = dict() + + def __getitem__(self, jid): + if jid not in self._storage: + self._storage[jid] = self._get_task_logger(jid) + return self._storage[jid] + + def _get_task_logger(self, jid): + job = _get_job(jid) + return clogger.task[job.segment.task.id] + +class dotdict(dict): + """dot.notation access to dictionary attributes""" + __getattr__ = dict.get + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + +clogger = dotdict({ + 'task': TaskClientLoggerStorage(), + 'job': JobClientLoggerStorage() +}) + +slogger = dotdict({ + 'task': TaskLoggerStorage(), + 'job': JobLoggerStorage(), + 'glob': logging.getLogger('cvat.server'), +}) diff --git a/cvat/apps/engine/logging.py b/cvat/apps/engine/logging.py deleted file mode 100644 index 8948821f..00000000 --- a/cvat/apps/engine/logging.py +++ /dev/null @@ -1,75 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import os -import inspect -import logging -from . import models -from cvat.settings.base import LOGGING - - -class TaskLoggerStorage: - def __init__(self): - self._storage = dict() - self._formatter = logging.getLogger('task') - - def __getitem__(self, tid): - if tid not in self._storage: - self._storage[tid] = self._create_task_logger(tid) - return self._storage[tid] - - def _create_task_logger(self, tid): - task = self._get_task(tid) - if task is not None: - configuration = LOGGING.copy() - handler_configuration = configuration['handlers']['file'] - handler_configuration['filename'] = task.get_log_path() - configuration['handlers'] = { - 'file_{}'.format(tid): handler_configuration - } - configuration['loggers'] = { - 'task_{}'.format(tid): { - 'handlers': ['file_{}'.format(tid)], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), - } - } - - logging.config.dictConfig(configuration) - logger = logging.getLogger('task_{}'.format(tid)) - return logger - else: - raise Exception('Key must be task indentificator') - - def _get_task(self, tid): - try: - return models.Task.objects.get(pk=tid) - except Exception: - return None - - -class JobLoggerStorage: - def __init__(self): - self._storage = dict() - - def __getitem__(self, jid): - if jid not in self._storage: - self._storage[jid] = self._get_task_logger(jid) - return self._storage[jid] - - def _get_task_logger(self, jid): - job = self._get_job(jid) - if job is not None: - return task_logger[job.segment.task.id] - else: - raise Exception('Key must be job identificator') - - def _get_job(self, jid): - try: - return models.Job.objects.select_related("segment__task").get(id=jid) - except Exception: - return None - -task_logger = TaskLoggerStorage() -job_logger = JobLoggerStorage() diff --git a/cvat/apps/engine/migrations/0010_auto_20181011_1517.py b/cvat/apps/engine/migrations/0010_auto_20181011_1517.py new file mode 100644 index 00000000..c33d1319 --- /dev/null +++ b/cvat/apps/engine/migrations/0010_auto_20181011_1517.py @@ -0,0 +1,38 @@ +# Generated by Django 2.0.9 on 2018-10-11 12:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0009_auto_20180917_1424'), + ] + + operations = [ + migrations.AddField( + model_name='labeledbox', + name='client_id', + field=models.BigIntegerField(default=-1), + ), + migrations.AddField( + model_name='labeledpoints', + name='client_id', + field=models.BigIntegerField(default=-1), + ), + migrations.AddField( + model_name='labeledpolygon', + name='client_id', + field=models.BigIntegerField(default=-1), + ), + migrations.AddField( + model_name='labeledpolyline', + name='client_id', + field=models.BigIntegerField(default=-1), + ), + migrations.AddField( + model_name='objectpath', + name='client_id', + field=models.BigIntegerField(default=-1), + ), + ] diff --git a/cvat/apps/engine/migrations/0011_add_task_source_and_safecharfield.py b/cvat/apps/engine/migrations/0011_add_task_source_and_safecharfield.py new file mode 100644 index 00000000..bb96c1b5 --- /dev/null +++ b/cvat/apps/engine/migrations/0011_add_task_source_and_safecharfield.py @@ -0,0 +1,74 @@ +# Generated by Django 2.0.9 on 2018-10-24 10:50 + +import cvat.apps.engine.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0010_auto_20181011_1517'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='source', + field=cvat.apps.engine.models.SafeCharField(default='unknown', max_length=256), + ), + migrations.AlterField( + model_name='label', + name='name', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='labeledboxattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='labeledpointsattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='labeledpolygonattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='labeledpolylineattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='objectpathattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='task', + name='name', + field=cvat.apps.engine.models.SafeCharField(max_length=256), + ), + migrations.AlterField( + model_name='trackedboxattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='trackedpointsattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='trackedpolygonattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + migrations.AlterField( + model_name='trackedpolylineattributeval', + name='value', + field=cvat.apps.engine.models.SafeCharField(max_length=64), + ), + ] diff --git a/cvat/apps/engine/migrations/0012_auto_20181025_1618.py b/cvat/apps/engine/migrations/0012_auto_20181025_1618.py new file mode 100644 index 00000000..ae0664f0 --- /dev/null +++ b/cvat/apps/engine/migrations/0012_auto_20181025_1618.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.9 on 2018-10-25 13:18 + +import cvat.apps.engine.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0011_add_task_source_and_safecharfield'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='status', + field=models.CharField(default=cvat.apps.engine.models.StatusChoice('annotation'), max_length=32), + ), + migrations.AlterField( + model_name='task', + name='status', + field=models.CharField(default=cvat.apps.engine.models.StatusChoice('annotation'), max_length=32), + ), + ] diff --git a/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py b/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py new file mode 100644 index 00000000..bc735269 --- /dev/null +++ b/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py @@ -0,0 +1,118 @@ +# Generated by Django 2.0.9 on 2018-11-07 12:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0012_auto_20181025_1618'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='attributespec', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='job', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='label', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='labeledboxattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='labeledpointsattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='labeledpolygonattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='labeledpolylineattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='objectpathattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='segment', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='task', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedbox', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedboxattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpoints', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpointsattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpolygon', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpolygonattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpolyline', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpolylineattributeval', + options={'default_permissions': ()}, + ), + migrations.RenameField( + model_name='job', + old_name='annotator', + new_name='assignee', + ), + migrations.AddField( + model_name='task', + name='assignee', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assignees', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='task', + name='owner', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owners', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='job', + name='assignee', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='task', + name='bug_tracker', + field=models.CharField(blank=True, default='', max_length=2000), + ), + migrations.AlterField( + model_name='task', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owners', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/cvat/apps/engine/migrations/0014_job_max_shape_id.py b/cvat/apps/engine/migrations/0014_job_max_shape_id.py new file mode 100644 index 00000000..bf3421a4 --- /dev/null +++ b/cvat/apps/engine/migrations/0014_job_max_shape_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-23 10:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0013_auth_no_default_permissions'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='max_shape_id', + field=models.BigIntegerField(default=-1), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 6883018b..1d2729d4 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -1,4 +1,3 @@ - # Copyright (C) 2018 Intel Corporation # # SPDX-License-Identifier: MIT @@ -8,34 +7,54 @@ from django.conf import settings from django.contrib.auth.models import User +from io import StringIO +from enum import Enum + import shlex import csv -from io import StringIO import re import os +class StatusChoice(Enum): + ANNOTATION = 'annotation' + VALIDATION = 'validation' + COMPLETED = 'completed' + + @classmethod + def choices(self): + return tuple((x.name, x.value) for x in self) + + def __str__(self): + return self.value + +class SafeCharField(models.CharField): + def get_prep_value(self, value): + value = super().get_prep_value(value) + if value: + return value[:self.max_length] + return value class Task(models.Model): - name = models.CharField(max_length=256) + name = SafeCharField(max_length=256) size = models.PositiveIntegerField() path = models.CharField(max_length=256) mode = models.CharField(max_length=32) - owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) - bug_tracker = models.CharField(max_length=2000, default="") + owner = models.ForeignKey(User, null=True, blank=True, + on_delete=models.SET_NULL, related_name="owners") + assignee = models.ForeignKey(User, null=True, blank=True, + on_delete=models.SET_NULL, related_name="assignees") + bug_tracker = models.CharField(max_length=2000, blank=True, default="") created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now_add=True) - status = models.CharField(max_length=32, default="annotate") overlap = models.PositiveIntegerField(default=0) z_order = models.BooleanField(default=False) flipped = models.BooleanField(default=False) + source = SafeCharField(max_length=256, default="unknown") + status = models.CharField(max_length=32, default=StatusChoice.ANNOTATION) # Extend default permission model class Meta: - permissions = ( - ("view_task", "Can see available tasks"), - ("view_annotation", "Can see annotation for the task"), - ("change_annotation", "Can modify annotation for the task"), - ) + default_permissions = () def get_upload_dirname(self): return os.path.join(self.path, ".upload") @@ -71,18 +90,29 @@ class Segment(models.Model): start_frame = models.IntegerField() stop_frame = models.IntegerField() + class Meta: + default_permissions = () + class Job(models.Model): segment = models.ForeignKey(Segment, on_delete=models.CASCADE) - annotator = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) - # TODO: add sub-issue number for the task + assignee = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) + status = models.CharField(max_length=32, default=StatusChoice.ANNOTATION) + max_shape_id = models.BigIntegerField(default=-1) + + class Meta: + default_permissions = () class Label(models.Model): task = models.ForeignKey(Task, on_delete=models.CASCADE) - name = models.CharField(max_length=64) + name = SafeCharField(max_length=64) def __str__(self): return self.name + class Meta: + default_permissions = () + + def parse_attribute(text): match = re.match(r'^([~@])(\w+)=(\w+):(.+)?$', text) prefix = match.group(1) @@ -99,6 +129,9 @@ class AttributeSpec(models.Model): label = models.ForeignKey(Label, on_delete=models.CASCADE) text = models.CharField(max_length=1024) + class Meta: + default_permissions = () + def get_attribute(self): return parse_attribute(self.text) @@ -122,31 +155,38 @@ class AttributeSpec(models.Model): attr = self.get_attribute() return attr['values'] - def __str__(self): return self.get_attribute()['name'] + class AttributeVal(models.Model): # TODO: add a validator here to be sure that it corresponds to self.label id = models.BigAutoField(primary_key=True) spec = models.ForeignKey(AttributeSpec, on_delete=models.CASCADE) - value = models.CharField(max_length=64) + value = SafeCharField(max_length=64) + class Meta: abstract = True + default_permissions = () + class Annotation(models.Model): job = models.ForeignKey(Job, on_delete=models.CASCADE) label = models.ForeignKey(Label, on_delete=models.CASCADE) frame = models.PositiveIntegerField() group_id = models.PositiveIntegerField(default=0) + client_id = models.BigIntegerField(default=-1) + class Meta: abstract = True class Shape(models.Model): occluded = models.BooleanField(default=False) z_order = models.IntegerField(default=0) + class Meta: abstract = True + default_permissions = () class BoundingBox(Shape): id = models.BigAutoField(primary_key=True) @@ -154,14 +194,18 @@ class BoundingBox(Shape): ytl = models.FloatField() xbr = models.FloatField() ybr = models.FloatField() + class Meta: abstract = True + default_permissions = () class PolyShape(Shape): id = models.BigAutoField(primary_key=True) points = models.TextField() + class Meta: abstract = True + default_permissions = () class LabeledBox(Annotation, BoundingBox): pass @@ -200,6 +244,7 @@ class TrackedObject(models.Model): outside = models.BooleanField(default=False) class Meta: abstract = True + default_permissions = () class TrackedBox(TrackedObject, BoundingBox): pass diff --git a/cvat/apps/engine/static/engine/js/3rdparty.patch b/cvat/apps/engine/static/engine/js/3rdparty.patch index 38f26000..c12855f4 100644 --- a/cvat/apps/engine/static/engine/js/3rdparty.patch +++ b/cvat/apps/engine/static/engine/js/3rdparty.patch @@ -1,14 +1,14 @@ -From 5eeb1092c64865c555671ed585da18f974c9c10c Mon Sep 17 00:00:00 2001 +From d44089dfc96b56d427d5631442d6587f876f43b6 Mon Sep 17 00:00:00 2001 From: Boris Sekachev -Date: Tue, 18 Sep 2018 15:58:20 +0300 +Date: Mon, 19 Nov 2018 12:09:48 +0300 Subject: [PATCH] tmp --- .../engine/static/engine/js/3rdparty/svg.draggable.js | 1 + cvat/apps/engine/static/engine/js/3rdparty/svg.draw.js | 17 +++++++++++++++-- - .../apps/engine/static/engine/js/3rdparty/svg.resize.js | 5 +++-- + .../apps/engine/static/engine/js/3rdparty/svg.resize.js | 6 ++++-- .../apps/engine/static/engine/js/3rdparty/svg.select.js | 5 ++++- - 4 files changed, 23 insertions(+), 5 deletions(-) + 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/cvat/apps/engine/static/engine/js/3rdparty/svg.draggable.js b/cvat/apps/engine/static/engine/js/3rdparty/svg.draggable.js index d88abf5..aba474c 100644 @@ -78,7 +78,7 @@ index 68dbf2a..20a6917 100644 } diff --git a/cvat/apps/engine/static/engine/js/3rdparty/svg.resize.js b/cvat/apps/engine/static/engine/js/3rdparty/svg.resize.js -index 0c3b63d..fb5dc26 100644 +index 0c3b63d..dceede5 100644 --- a/cvat/apps/engine/static/engine/js/3rdparty/svg.resize.js +++ b/cvat/apps/engine/static/engine/js/3rdparty/svg.resize.js @@ -34,8 +34,8 @@ @@ -92,7 +92,15 @@ index 0c3b63d..fb5dc26 100644 }; }; -@@ -343,6 +343,7 @@ +@@ -98,6 +98,7 @@ + }; + + ResizeHandler.prototype.resize = function (event) { ++ if (event.detail.event.button) return; // only left mouse button + + var _this = this; + +@@ -343,6 +344,7 @@ } return; } diff --git a/cvat/apps/engine/static/engine/js/3rdparty/md5.js b/cvat/apps/engine/static/engine/js/3rdparty/md5.js deleted file mode 100644 index 762de3c5..00000000 --- a/cvat/apps/engine/static/engine/js/3rdparty/md5.js +++ /dev/null @@ -1,280 +0,0 @@ -/* - * JavaScript MD5 - * https://github.com/blueimp/JavaScript-MD5 - * - * Copyright 2011, Sebastian Tschan - * https://blueimp.net - * - * Licensed under the MIT license: - * https://opensource.org/licenses/MIT - * - * Based on - * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message - * Digest Algorithm, as defined in RFC 1321. - * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009 - * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet - * Distributed under the BSD License - * See http://pajhome.org.uk/crypt/md5 for more info. - */ - -/* global define */ - -;(function ($) { - 'use strict' - - /* - * Add integers, wrapping at 2^32. This uses 16-bit operations internally - * to work around bugs in some JS interpreters. - */ - function safeAdd (x, y) { - var lsw = (x & 0xffff) + (y & 0xffff) - var msw = (x >> 16) + (y >> 16) + (lsw >> 16) - return (msw << 16) | (lsw & 0xffff) - } - - /* - * Bitwise rotate a 32-bit number to the left. - */ - function bitRotateLeft (num, cnt) { - return (num << cnt) | (num >>> (32 - cnt)) - } - - /* - * These functions implement the four basic operations the algorithm uses. - */ - function md5cmn (q, a, b, x, s, t) { - return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b) - } - function md5ff (a, b, c, d, x, s, t) { - return md5cmn((b & c) | (~b & d), a, b, x, s, t) - } - function md5gg (a, b, c, d, x, s, t) { - return md5cmn((b & d) | (c & ~d), a, b, x, s, t) - } - function md5hh (a, b, c, d, x, s, t) { - return md5cmn(b ^ c ^ d, a, b, x, s, t) - } - function md5ii (a, b, c, d, x, s, t) { - return md5cmn(c ^ (b | ~d), a, b, x, s, t) - } - - /* - * Calculate the MD5 of an array of little-endian words, and a bit length. - */ - function binlMD5 (x, len) { - /* append padding */ - x[len >> 5] |= 0x80 << (len % 32) - x[((len + 64) >>> 9 << 4) + 14] = len - - var i - var olda - var oldb - var oldc - var oldd - var a = 1732584193 - var b = -271733879 - var c = -1732584194 - var d = 271733878 - - for (i = 0; i < x.length; i += 16) { - olda = a - oldb = b - oldc = c - oldd = d - - a = md5ff(a, b, c, d, x[i], 7, -680876936) - d = md5ff(d, a, b, c, x[i + 1], 12, -389564586) - c = md5ff(c, d, a, b, x[i + 2], 17, 606105819) - b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330) - a = md5ff(a, b, c, d, x[i + 4], 7, -176418897) - d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426) - c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341) - b = md5ff(b, c, d, a, x[i + 7], 22, -45705983) - a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416) - d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417) - c = md5ff(c, d, a, b, x[i + 10], 17, -42063) - b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162) - a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682) - d = md5ff(d, a, b, c, x[i + 13], 12, -40341101) - c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290) - b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329) - - a = md5gg(a, b, c, d, x[i + 1], 5, -165796510) - d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632) - c = md5gg(c, d, a, b, x[i + 11], 14, 643717713) - b = md5gg(b, c, d, a, x[i], 20, -373897302) - a = md5gg(a, b, c, d, x[i + 5], 5, -701558691) - d = md5gg(d, a, b, c, x[i + 10], 9, 38016083) - c = md5gg(c, d, a, b, x[i + 15], 14, -660478335) - b = md5gg(b, c, d, a, x[i + 4], 20, -405537848) - a = md5gg(a, b, c, d, x[i + 9], 5, 568446438) - d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690) - c = md5gg(c, d, a, b, x[i + 3], 14, -187363961) - b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501) - a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467) - d = md5gg(d, a, b, c, x[i + 2], 9, -51403784) - c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473) - b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734) - - a = md5hh(a, b, c, d, x[i + 5], 4, -378558) - d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463) - c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562) - b = md5hh(b, c, d, a, x[i + 14], 23, -35309556) - a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060) - d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353) - c = md5hh(c, d, a, b, x[i + 7], 16, -155497632) - b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640) - a = md5hh(a, b, c, d, x[i + 13], 4, 681279174) - d = md5hh(d, a, b, c, x[i], 11, -358537222) - c = md5hh(c, d, a, b, x[i + 3], 16, -722521979) - b = md5hh(b, c, d, a, x[i + 6], 23, 76029189) - a = md5hh(a, b, c, d, x[i + 9], 4, -640364487) - d = md5hh(d, a, b, c, x[i + 12], 11, -421815835) - c = md5hh(c, d, a, b, x[i + 15], 16, 530742520) - b = md5hh(b, c, d, a, x[i + 2], 23, -995338651) - - a = md5ii(a, b, c, d, x[i], 6, -198630844) - d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415) - c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905) - b = md5ii(b, c, d, a, x[i + 5], 21, -57434055) - a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571) - d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606) - c = md5ii(c, d, a, b, x[i + 10], 15, -1051523) - b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799) - a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359) - d = md5ii(d, a, b, c, x[i + 15], 10, -30611744) - c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380) - b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649) - a = md5ii(a, b, c, d, x[i + 4], 6, -145523070) - d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379) - c = md5ii(c, d, a, b, x[i + 2], 15, 718787259) - b = md5ii(b, c, d, a, x[i + 9], 21, -343485551) - - a = safeAdd(a, olda) - b = safeAdd(b, oldb) - c = safeAdd(c, oldc) - d = safeAdd(d, oldd) - } - return [a, b, c, d] - } - - /* - * Convert an array of little-endian words to a string - */ - function binl2rstr (input) { - var i - var output = '' - var length32 = input.length * 32 - for (i = 0; i < length32; i += 8) { - output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff) - } - return output - } - - /* - * Convert a raw string to an array of little-endian words - * Characters >255 have their high-byte silently ignored. - */ - function rstr2binl (input) { - var i - var output = [] - output[(input.length >> 2) - 1] = undefined - for (i = 0; i < output.length; i += 1) { - output[i] = 0 - } - var length8 = input.length * 8 - for (i = 0; i < length8; i += 8) { - output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (i % 32) - } - return output - } - - /* - * Calculate the MD5 of a raw string - */ - function rstrMD5 (s) { - return binl2rstr(binlMD5(rstr2binl(s), s.length * 8)) - } - - /* - * Calculate the HMAC-MD5, of a key and some data (raw strings) - */ - function rstrHMACMD5 (key, data) { - var i - var bkey = rstr2binl(key) - var ipad = [] - var opad = [] - var hash - ipad[15] = opad[15] = undefined - if (bkey.length > 16) { - bkey = binlMD5(bkey, key.length * 8) - } - for (i = 0; i < 16; i += 1) { - ipad[i] = bkey[i] ^ 0x36363636 - opad[i] = bkey[i] ^ 0x5c5c5c5c - } - hash = binlMD5(ipad.concat(rstr2binl(data)), 512 + data.length * 8) - return binl2rstr(binlMD5(opad.concat(hash), 512 + 128)) - } - - /* - * Convert a raw string to a hex string - */ - function rstr2hex (input) { - var hexTab = '0123456789abcdef' - var output = '' - var x - var i - for (i = 0; i < input.length; i += 1) { - x = input.charCodeAt(i) - output += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f) - } - return output - } - - /* - * Encode a string as utf-8 - */ - function str2rstrUTF8 (input) { - return unescape(encodeURIComponent(input)) - } - - /* - * Take string arguments and return either raw or hex encoded strings - */ - function rawMD5 (s) { - return rstrMD5(str2rstrUTF8(s)) - } - function hexMD5 (s) { - return rstr2hex(rawMD5(s)) - } - function rawHMACMD5 (k, d) { - return rstrHMACMD5(str2rstrUTF8(k), str2rstrUTF8(d)) - } - function hexHMACMD5 (k, d) { - return rstr2hex(rawHMACMD5(k, d)) - } - - function md5 (string, key, raw) { - if (!key) { - if (!raw) { - return hexMD5(string) - } - return rawMD5(string) - } - if (!raw) { - return hexHMACMD5(key, string) - } - return rawHMACMD5(key, string) - } - - if (typeof define === 'function' && define.amd) { - define(function () { - return md5 - }) - } else if (typeof module === 'object' && module.exports) { - module.exports = md5 - } else { - $.md5 = md5 - } -})(this) diff --git a/cvat/apps/engine/static/engine/js/annotationParser.js b/cvat/apps/engine/static/engine/js/annotationParser.js index 92536516..e34a780d 100644 --- a/cvat/apps/engine/static/engine/js/annotationParser.js +++ b/cvat/apps/engine/static/engine/js/annotationParser.js @@ -8,13 +8,14 @@ "use strict"; class AnnotationParser { - constructor(job, labelsInfo) { + constructor(job, labelsInfo, idGenerator) { this._parser = new DOMParser(); this._startFrame = job.start; this._stopFrame = job.stop; this._flipped = job.flipped; this._im_meta = job.image_meta_data; this._labelsInfo = labelsInfo; + this._idGen = idGenerator; } _xmlParseError(parsedXML) { @@ -131,7 +132,7 @@ class AnnotationParser { let result = []; for (let track of tracks) { let label = track.getAttribute('label'); - let group_id = track.getAttribute('group_id') || "0"; + let group_id = track.getAttribute('group_id') || '0'; let labelId = this._labelsInfo.labelIdOf(label); if (labelId === null) { throw Error(`An unknown label found in the annotation file: ${label}`); @@ -224,6 +225,7 @@ class AnnotationParser { ybr: ybr, z_order: z_order, attributes: attributeList, + id: this._idGen.next(), }); } else { @@ -236,6 +238,7 @@ class AnnotationParser { occluded: occluded, z_order: z_order, attributes: attributeList, + id: this._idGen.next(), }); } } @@ -255,7 +258,7 @@ class AnnotationParser { let tracks = xml.getElementsByTagName('track'); for (let track of tracks) { let labelId = this._labelsInfo.labelIdOf(track.getAttribute('label')); - let groupId = track.getAttribute('group_id') || "0"; + let groupId = track.getAttribute('group_id') || '0'; if (labelId === null) { throw Error('An unknown label found in the annotation file: ' + name); } @@ -307,7 +310,8 @@ class AnnotationParser { group_id: +groupId, frame: +parsed[type][0].getAttribute('frame'), attributes: [], - shapes: [] + shapes: [], + id: this._idGen.next(), }; for (let shape of parsed[type]) { diff --git a/cvat/apps/engine/static/engine/js/annotationUI.js b/cvat/apps/engine/static/engine/js/annotationUI.js index 3bd09d35..53a0edca 100644 --- a/cvat/apps/engine/static/engine/js/annotationUI.js +++ b/cvat/apps/engine/static/engine/js/annotationUI.js @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT */ -/* exported callAnnotationUI translateSVGPos blurAllElements drawBoxSize */ +/* exported callAnnotationUI blurAllElements drawBoxSize copyToClipboard */ "use strict"; function callAnnotationUI(jid) { @@ -40,6 +40,7 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { // Setup some API window.cvat = { labelsInfo: new LabelsInfo(job), + translate: new CoordinateTranslator(), player: { geometry: { scale: 1, @@ -53,24 +54,69 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { mode: null, job: { z_order: job.z_order, - id: job.jobid + id: job.jobid, + images: job.image_meta_data, }, + search: { + value: window.location.search, + + set: function(name, value) { + let searchParams = new URLSearchParams(this.value); + + if (typeof value === 'undefined' || value === null) { + if (searchParams.has(name)) { + searchParams.delete(name); + } + } + else searchParams.set(name, value); + this.value = `${searchParams.toString()}`; + }, + + get: function(name) { + try { + let decodedURI = decodeURIComponent(this.value); + let urlSearchParams = new URLSearchParams(decodedURI); + if (urlSearchParams.has(name)) { + return urlSearchParams.get(name); + } + else return null; + } + catch (error) { + showMessage('Bad URL has been found'); + this.value = window.location.href; + return null; + } + }, + + toString: function() { + return `${window.location.origin}/?${this.value}`; + } + } }; + // Remove external search parameters from url + window.history.replaceState(null, null, `${window.location.origin}/?id=${job.jobid}`); + window.cvat.config = new Config(); // Setup components - let annotationParser = new AnnotationParser(job, window.cvat.labelsInfo); + let idGenerator = new IncrementIdGenerator(job.max_shape_id + 1); + let annotationParser = new AnnotationParser(job, window.cvat.labelsInfo, idGenerator); - let shapeCollectionModel = new ShapeCollectionModel().import(shapeData).updateHash(); + let shapeCollectionModel = new ShapeCollectionModel(idGenerator).import(shapeData, true); let shapeCollectionController = new ShapeCollectionController(shapeCollectionModel); let shapeCollectionView = new ShapeCollectionView(shapeCollectionModel, shapeCollectionController); + // In case of old tasks that dont provide max saved shape id properly + if (job.max_shape_id === -1) { + idGenerator.reset(shapeCollectionModel.maxId + 1); + } + window.cvat.data = { - get: () => shapeCollectionModel.export(), + get: () => shapeCollectionModel.exportAll(), set: (data) => { shapeCollectionModel.empty(); - shapeCollectionModel.import(data); + shapeCollectionModel.import(data, false); shapeCollectionModel.update(); }, clear: () => shapeCollectionModel.empty(), @@ -85,6 +131,13 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { let shapeCreatorController = new ShapeCreatorController(shapeCreatorModel); let shapeCreatorView = new ShapeCreatorView(shapeCreatorModel, shapeCreatorController); + let polyshapeEditorModel = new PolyshapeEditorModel(); + let polyshapeEditorController = new PolyshapeEditorController(polyshapeEditorModel); + let polyshapeEditorView = new PolyshapeEditorView(polyshapeEditorModel, polyshapeEditorController); + + // Add static member for class. It will be used by all polyshapes. + PolyShapeView.editor = polyshapeEditorModel; + let shapeMergerModel = new ShapeMergerModel(shapeCollectionModel); let shapeMergerController = new ShapeMergerController(shapeMergerModel); new ShapeMergerView(shapeMergerModel, shapeMergerController); @@ -95,6 +148,8 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { let aamModel = new AAMModel(shapeCollectionModel, (xtl, xbr, ytl, ybr) => { playerModel.focus(xtl, xbr, ytl, ybr); + }, () => { + playerModel.fit(); }); let aamController = new AAMController(aamModel); new AAMView(aamModel, aamController); @@ -129,7 +184,8 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { playerModel.subscribe(shapeCreatorView); playerModel.subscribe(shapeBufferView); playerModel.subscribe(shapeGrouperView); - playerModel.shift(0); + playerModel.subscribe(polyshapeEditorView); + playerModel.shift(window.cvat.search.get('frame') || 0, true); let shortkeys = window.cvat.config.shortkeys; @@ -137,7 +193,14 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { setupSettingsWindow(); setupMenu(job, shapeCollectionModel, annotationParser, aamModel, playerModel, historyModel); setupFrameFilters(); - setupShortkeys(shortkeys); + setupShortkeys(shortkeys, { + aam: aamModel, + shapeCreator: shapeCreatorModel, + shapeMerger: shapeMergerModel, + shapeGrouper: shapeGrouperModel, + shapeBuffer: shapeBufferModel, + shapeEditor: polyshapeEditorModel + }); $(window).on('click', function(event) { Logger.updateUserActivityTimer(); @@ -177,6 +240,16 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { }); } + +function copyToClipboard(text) { + let tempInput = $(""); + $("body").append(tempInput); + tempInput.prop('value', text).select(); + document.execCommand("copy"); + tempInput.remove(); +} + + function setupFrameFilters() { let brightnessRange = $('#playerBrightnessRange'); let contrastRange = $('#playerContrastRange'); @@ -248,7 +321,7 @@ function setupFrameFilters() { } -function setupShortkeys(shortkeys) { +function setupShortkeys(shortkeys, models) { let annotationMenu = $('#annotationMenu'); let settingsWindow = $('#settingsWindow'); let helpWindow = $('#helpWindow'); @@ -291,9 +364,34 @@ function setupShortkeys(shortkeys) { return false; }); + let cancelModeHandler = Logger.shortkeyLogDecorator(function() { + switch (window.cvat.mode) { + case 'aam': + models.aam.switchAAMMode(); + break; + case 'creation': + models.shapeCreator.switchCreateMode(true); + break; + case 'merge': + models.shapeMerger.cancel(); + break; + case 'groupping': + models.shapeGrouper.cancel(); + break; + case 'paste': + models.shapeBuffer.switchPaste(); + break; + case 'poly_editing': + models.shapeEditor.finish(); + break; + } + return false; + }); + Mousetrap.bind(shortkeys["open_help"].value, openHelpHandler, 'keydown'); Mousetrap.bind(shortkeys["open_settings"].value, openSettingsHandler, 'keydown'); Mousetrap.bind(shortkeys["save_work"].value, saveHandler, 'keydown'); + Mousetrap.bind(shortkeys["cancel_mode"].value, cancelModeHandler, 'keydown'); } @@ -423,12 +521,23 @@ function setupMenu(job, shapeCollectionModel, annotationParser, aamModel, player })(); $('#statTaskName').text(job.slug); - $('#statTaskStatus').text(job.status); $('#statFrames').text(`[${job.start}-${job.stop}]`); $('#statOverlap').text(job.overlap); $('#statZOrder').text(job.z_order); $('#statFlipped').text(job.flipped); - + $('#statTaskStatus').prop("value", job.status).on('change', (e) => { + $.ajax({ + type: 'POST', + url: 'save/status/job/' + window.cvat.job.id, + data: JSON.stringify({ + status: e.target.value + }), + contentType: "application/json; charset=utf-8", + error: (data) => { + showMessage(`Can not change job status. Code: ${data.status}. Message: ${data.responeText || data.statusText}`); + } + }); + }); let shortkeys = window.cvat.config.shortkeys; $('#helpButton').on('click', () => { @@ -459,13 +568,15 @@ function setupMenu(job, shapeCollectionModel, annotationParser, aamModel, player }); $('#removeAnnotationButton').on('click', () => { - hide(); - confirm('Do you want to remove all annotations? The action cannot be undone!', - () => { - historyModel.empty(); - shapeCollectionModel.empty(); - } - ); + if (!window.cvat.mode) { + hide(); + confirm('Do you want to remove all annotations? The action cannot be undone!', + () => { + historyModel.empty(); + shapeCollectionModel.empty(); + } + ); + } }); $('#saveButton').on('click', () => { @@ -561,7 +672,7 @@ function uploadAnnotation(shapeCollectionModel, historyModel, annotationParser, try { historyModel.empty(); shapeCollectionModel.empty(); - shapeCollectionModel.import(data); + shapeCollectionModel.import(data, false); shapeCollectionModel.update(); } finally { @@ -599,11 +710,12 @@ function saveAnnotation(shapeCollectionModel, job) { 'points count': totalStat.points.annotation + totalStat.points.interpolation, }); - let exportedData = shapeCollectionModel.export(); - let annotationLogs = Logger.getLogs(); + const exportedData = shapeCollectionModel.export(); + shapeCollectionModel.updateExportedState(); + const annotationLogs = Logger.getLogs(); const data = { - annotation: exportedData, + annotation: JSON.stringify(exportedData), logs: JSON.stringify(annotationLogs.export()), }; @@ -612,7 +724,7 @@ function saveAnnotation(shapeCollectionModel, job) { saveJobRequest(job.jobid, data, () => { // success - shapeCollectionModel.updateHash(); + shapeCollectionModel.confirmExportedState(); saveButton.text('Success!'); setTimeout(() => { saveButton.prop('disabled', false); @@ -628,26 +740,7 @@ function saveAnnotation(shapeCollectionModel, job) { }); } -function translateSVGPos(svgCanvas, clientX, clientY) { - let pt = svgCanvas.createSVGPoint(); - pt.x = clientX; - pt.y = clientY; - pt = pt.matrixTransform(svgCanvas.getScreenCTM().inverse()); - - let pos = { - x: pt.x, - y: pt.y - }; - - if (platform.name.toLowerCase() == 'firefox') { - pos.x /= window.cvat.player.geometry.scale; - pos.y /= window.cvat.player.geometry.scale; - } - - return pos; -} - function blurAllElements() { document.activeElement.blur(); -} \ No newline at end of file +} diff --git a/cvat/apps/engine/static/engine/js/attributeAnnotationMode.js b/cvat/apps/engine/static/engine/js/attributeAnnotationMode.js index 701840f2..f0ae3cdb 100644 --- a/cvat/apps/engine/static/engine/js/attributeAnnotationMode.js +++ b/cvat/apps/engine/static/engine/js/attributeAnnotationMode.js @@ -10,10 +10,11 @@ const AAMUndefinedKeyword = '__undefined__'; class AAMModel extends Listener { - constructor(shapeCollection, focus) { + constructor(shapeCollection, focus, fit) { super('onAAMUpdate', () => this); this._shapeCollection = shapeCollection; this._focus = focus; + this._fit = fit; this._activeAAM = false; this._activeIdx = null; this._active = null; @@ -91,7 +92,10 @@ class AAMModel extends Listener { for (let shape of this._shapeCollection.currentShapes) { let labelAttributes = window.cvat.labelsInfo.labelAttributes(shape.model.label); if (Object.keys(labelAttributes).length && !shape.model.removed && !shape.interpolation.position.outside) { - this._currentShapes.push(shape); + this._currentShapes.push({ + model: shape.model, + interpolation: shape.model.interpolate(window.cvat.player.frames.current), + }); } } @@ -164,6 +168,7 @@ class AAMModel extends Listener { // Notify for remove aam UI this.notify(); + this._fit(); } } @@ -182,7 +187,7 @@ class AAMModel extends Listener { } this._deactivate(); - if (Math.sign(direction) > 0) { + if (Math.sign(direction) < 0) { // next this._activeIdx ++; if (this._activeIdx >= this._currentShapes.length) { diff --git a/cvat/apps/engine/static/engine/js/base.js b/cvat/apps/engine/static/engine/js/base.js index dc9fb1ef..35590355 100644 --- a/cvat/apps/engine/static/engine/js/base.js +++ b/cvat/apps/engine/static/engine/js/base.js @@ -4,7 +4,16 @@ * SPDX-License-Identifier: MIT */ -/* exported confirm showMessage showOverlay dumpAnnotationRequest */ +/* exported + ExportType + confirm + createExportContainer + dumpAnnotationRequest + getExportTargetContainer + showMessage + showOverlay +*/ + "use strict"; Math.clamp = function(x, min, max) { @@ -160,6 +169,79 @@ function dumpAnnotationRequest(dumpButton, taskID) { } } +const ExportType = Object.freeze({ + 'create': 0, + 'update': 1, + 'delete': 2, +}); + +function createExportContainer() { + const container = {}; + Object.keys(ExportType).forEach( action => { + container[action] = { + "boxes": [], + "box_paths": [], + "points": [], + "points_paths": [], + "polygons": [], + "polygon_paths": [], + "polylines": [], + "polyline_paths": [], + }; + }); + + return container; +} + +function getExportTargetContainer(export_type, shape_type, container) { + let shape_container_target = undefined; + let export_action_container = undefined; + + switch (export_type) { + case ExportType.create: + export_action_container = container.create; + break; + case ExportType.update: + export_action_container = container.update; + break; + case ExportType.delete: + export_action_container = container.delete; + break; + default: + throw Error('Unexpected export type'); + } + + switch (shape_type) { + case 'annotation_box': + shape_container_target = export_action_container.boxes; + break; + case 'interpolation_box': + shape_container_target = export_action_container.box_paths; + break; + case 'annotation_points': + shape_container_target = export_action_container.points; + break; + case 'interpolation_points': + shape_container_target = export_action_container.points_paths; + break; + case 'annotation_polygon': + shape_container_target = export_action_container.polygons; + break; + case 'interpolation_polygon': + shape_container_target = export_action_container.polygon_paths; + break; + case 'annotation_polyline': + shape_container_target = export_action_container.polylines; + break; + case 'interpolation_polyline': + shape_container_target = export_action_container.polyline_paths; + break; + default: + throw Error('Undefined shape type'); + } + + return shape_container_target; +} /* These HTTP methods do not require CSRF protection */ function csrfSafeMethod(method) { @@ -178,7 +260,7 @@ $.ajaxSetup({ $(document).ready(function(){ $('body').css({ - width: window.screen.width * 0.95 + 'px', + width: window.screen.width + 'px', height: window.screen.height * 0.95 + 'px' }); }); diff --git a/cvat/apps/engine/static/engine/js/coordinateTranslator.js b/cvat/apps/engine/static/engine/js/coordinateTranslator.js new file mode 100644 index 00000000..08797401 --- /dev/null +++ b/cvat/apps/engine/static/engine/js/coordinateTranslator.js @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2018 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/* exported CoordinateTranslator */ +"use strict"; + +class CoordinateTranslator { + constructor() { + this._boxTranslator = { + _playerOffset: 0, + _convert: function(box, sign) { + for (let prop of ["xtl", "ytl", "xbr", "ybr", "x", "y"]) { + if (prop in box) { + box[prop] += this._playerOffset * sign; + } + } + + return box; + }, + actualToCanvas: function(actualBox) { + let canvasBox = {}; + for (let key in actualBox) { + canvasBox[key] = actualBox[key]; + } + + return this._convert(canvasBox, 1); + }, + + canvasToActual: function(canvasBox) { + let actualBox = {}; + for (let key in canvasBox) { + actualBox[key] = canvasBox[key]; + } + + return this._convert(actualBox, -1); + }, + }; + + this._pointsTranslator = { + _playerOffset: 0, + _convert: function(points, sign) { + if (typeof(points) === 'string') { + return points.split(' ').map((coord) => coord.split(',') + .map((x) => +x + this._playerOffset * sign).join(',')).join(' '); + } + else if (typeof(points) === 'object') { + let result = []; + for (let point of points) { + result.push({ + x: point.x + this._playerOffset * sign, + y: point.y + this._playerOffset * sign, + }); + } + return result; + } + else { + throw Error('Unknown points type was found'); + } + }, + actualToCanvas: function(actualPoints) { + return this._convert(actualPoints, 1); + }, + + canvasToActual: function(canvasPoints) { + return this._convert(canvasPoints, -1); + } + }, + + this._pointTranslator = { + clientToCanvas: function(targetCanvas, clientX, clientY) { + let pt = targetCanvas.createSVGPoint(); + pt.x = clientX; + pt.y = clientY; + pt = pt.matrixTransform(targetCanvas.getScreenCTM().inverse()); + return pt; + }, + canvasToClient: function(sourceCanvas, canvasX, canvasY) { + let pt = sourceCanvas.createSVGPoint(); + pt.x = canvasX; + pt.y = canvasY; + pt = pt.matrixTransform(sourceCanvas.getScreenCTM()); + return pt; + } + }; + } + + get box() { + return this._boxTranslator; + } + + get points() { + return this._pointsTranslator; + } + + get point() { + return this._pointTranslator; + } + + set playerOffset(value) { + this._boxTranslator._playerOffset = value; + this._pointsTranslator._playerOffset = value; + } +} diff --git a/cvat/apps/engine/static/engine/js/idGenerator.js b/cvat/apps/engine/static/engine/js/idGenerator.js new file mode 100644 index 00000000..d9b4a3fa --- /dev/null +++ b/cvat/apps/engine/static/engine/js/idGenerator.js @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2018 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + + /* exported + IncrementIdGenerator + ConstIdGenerator +*/ + +"use strict"; + +class IncrementIdGenerator { + constructor(startId=0) { + this._startId = startId; + } + + next() { + return this._startId++; + } + + reset(startId=0) { + this._startId = startId; + } +} + +class ConstIdGenerator { + constructor(startId=-1) { + this._startId = startId; + } + + next() { + return this._startId; + } +} diff --git a/cvat/apps/engine/static/engine/js/logger.js b/cvat/apps/engine/static/engine/js/logger.js index 45bf95bf..e26aef9d 100644 --- a/cvat/apps/engine/static/engine/js/logger.js +++ b/cvat/apps/engine/static/engine/js/logger.js @@ -80,7 +80,7 @@ var LoggerHandler = function(applicationName, jobId) return new Promise( (resolve, reject) => { let xhr = new XMLHttpRequest(); - xhr.open('POST', '/logs/exception/' + this._jobId); + xhr.open('POST', '/save/exception/' + this._jobId); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader("X-CSRFToken", Cookies.get('csrftoken')); @@ -202,25 +202,31 @@ var LoggerHandler = function(applicationName, jobId) /* -Log message has simple json format - each message is set of "key" : "value" pairs inside curly braces - {"key1" : "string_value", "key2" : number_value, ...} -Value may be string or number (see json spec) -required fields for all event types: +Log message has simple json format - each message is set of "key" : "value" +pairs inside curly braces - {"key1" : "string_value", "key2" : number_value, +...} Value may be string or number (see json spec) required fields for all event +types: NAME TYPE DESCRIPTION "event" string see EventType enum description of possible values. -"timestamp" number timestamp in UNIX format - the number of seconds or milliseconds that have elapsed since 00:00:00 Thursday, 1 January 1970 +"timestamp" number timestamp in UNIX format - the number of seconds + or milliseconds that have elapsed since 00:00:00 + Thursday, 1 January 1970 "application" string application name "userid" string Unique userid "task" string Unique task id. (Is expected corresponding Jira task id) -"count" is requiered field for "Add object", "Delete object", "Copy track", "Propagate object", "Merge objecrs", "Undo action" and "Redo action" -events with number value. +"count" is requiered field for "Add object", "Delete object", "Copy track", +"Propagate object", "Merge objecrs", "Undo action" and "Redo action" events with +number value. -Example : { "event" : "Add object", "timestamp" : 1486040342867, "application" : "CVAT", "duration" : 4200, "userid" : "ESAZON1X-MOBL", "count" : 1, "type" : "bounding box" } +Example : { "event" : "Add object", "timestamp" : 1486040342867, "application" : +"CVAT", "duration" : 4200, "userid" : "ESAZON1X-MOBL", "count" : 1, "type" : +"bounding box" } -Types of supported events. -Minimum subset of events to generate simple report are Logger.EventType.addObject, Logger.EventType.deleteObject and Logger.EventType.sendTaskInfo. -Value of "count" property should be a number. +Types of supported events. Minimum subset of events to generate simple report +are Logger.EventType.addObject, Logger.EventType.deleteObject and +Logger.EventType.sendTaskInfo. Value of "count" property should be a number. */ var Logger = { @@ -276,50 +282,67 @@ var Logger = { EventType: { // dumped as "Paste object". There are no additional required fields. pasteObject: 0, - // dumped as "Change attribute". There are no additional required fields. + // dumped as "Change attribute". There are no additional required + // fields. changeAttribute: 1, // dumped as "Drag object". There are no additional required fields. dragObject: 2, - // dumped as "Delete object". "count" is required field, value of deleted objects should be positive number. + // dumped as "Delete object". "count" is required field, value of + // deleted objects should be positive number. deleteObject: 3, // dumped as "Press shortcut". There are no additional required fields. pressShortcut: 4, // dumped as "Resize object". There are no additional required fields. resizeObject: 5, - // dumped as "Send logs". It's expected that event has "duration" field, but it isn't necessary. + // dumped as "Send logs". It's expected that event has "duration" field, + // but it isn't necessary. sendLogs: 6, - // dumped as "Save job". It's expected that event has "duration" field, but it isn't necessary. + // dumped as "Save job". It's expected that event has "duration" field, + // but it isn't necessary. saveJob: 7, // dumped as "Jump frame". There are no additional required fields. jumpFrame: 8, - // dumped as "Draw object". It's expected that event has "duration" field, but it isn't necessary. + // dumped as "Draw object". It's expected that event has "duration" + // field, but it isn't necessary. drawObject: 9, // dumped as "Change label". changeLabel: 10, - // dumped as "Send task info". "track count", "frame count", "object count" are required fields. It's expected that event has "current_frame" field. + // dumped as "Send task info". "track count", "frame count", "object + // count" are required fields. It's expected that event has + // "current_frame" field. sendTaskInfo: 11, - // dumped as "Load job". "track count", "frame count", "object count" are required fields. It's expected that event has "duration" field, but it isn't necessary. + // dumped as "Load job". "track count", "frame count", "object count" + // are required fields. It's expected that event has "duration" field, + // but it isn't necessary. loadJob: 12, - // dumped as "Move image". It's expected that event has "duration" field, but it isn't necessary. + // dumped as "Move image". It's expected that event has "duration" + // field, but it isn't necessary. moveImage: 13, - // dumped as "Zoom image". It's expected that event has "duration" field, but it isn't necessary. + // dumped as "Zoom image". It's expected that event has "duration" + // field, but it isn't necessary. zoomImage: 14, // dumped as "Lock object". There are no additional required fields. lockObject: 15, - // dumped as "Merge objects". "count" is required field with positive or negative number value. + // dumped as "Merge objects". "count" is required field with positive or + // negative number value. mergeObjects: 16, // dumped as "Copy object". "count" is required field with number value. copyObject: 17, - // dumped as "Propagate object". "count" is required field with number value. + // dumped as "Propagate object". "count" is required field with number + // value. propagateObject: 18, - // dumped as "Undo action". "count" is required field with positive or negative number value. + // dumped as "Undo action". "count" is required field with positive or + // negative number value. undoAction: 19, - // dumped as "Redo action". "count" is required field with positive or negative number value. + // dumped as "Redo action". "count" is required field with positive or + // negative number value. redoAction: 20, - // dumped as "Send user activity". "working_time" is required field with positive number value. + // dumped as "Send user activity". "working_time" is required field with + // positive number value. sendUserActivity: 21, - // dumped as "Send exception". Use to send any exception events to the server. - // "message", "filename", "line" are mandatory fields. "stack" and "column" are optional. + // dumped as "Send exception". Use to send any exception events to the + // server. "message", "filename", "line" are mandatory fields. "stack" + // and "column" are optional. sendException: 22, // dumped as "Change frame". There are no additional required fields. changeFrame: 23, @@ -356,10 +379,12 @@ var Logger = { /** * Logger.addContinuedEvent Use to add log event with duration field. - * Duration will be calculated automatically when LogEvent.close() method of returned Object will be called. - * Note: in case of LogEvent.close() method will not be callsed event will not be sended to server + * Duration will be calculated automatically when LogEvent.close() method of + * returned Object will be called. Note: in case of LogEvent.close() method + * will not be callsed event will not be sent to server * @param {Logger.EventType} type Event Type - * @param {Object} values Any event values, for example {count: 1, label: 'vehicle'} + * @param {Object} values Any event values, for example {count: 1, label: + * 'vehicle'} * @return {LogEvent} instance of LogEvent * @static */ @@ -370,7 +395,8 @@ var Logger = { /** * Logger.shortkeyLogDecorator use for decorating the shortkey handlers. - * This decorator just create appropriate log event and close it when decored function will performed. + * This decorator just create appropriate log event and close it when + * decored function will performed. * @param {Function} decoredFunc is function for decorating * @return {Function} is decorated decoredFunc * @static @@ -387,7 +413,7 @@ var Logger = { }, /** - * Logger.sendLogs Try to send exception logs to the server immediatly. + * Logger.sendLogs Try to send exception logs to the server immediately. * @return {Promise} * @param {LogEvent} exceptionEvent * @static @@ -414,7 +440,8 @@ var Logger = { }, /** - * Logger.setUsername just set username property which will be added to all log messages + * Logger.setUsername just set username property which will be added to all + * log messages * @param {String} username * @static */ @@ -423,7 +450,8 @@ var Logger = { this._logger.setUsername(username); }, - /** Logger.updateUserActivityTimer method updates internal timer for working time calculation logic + /** Logger.updateUserActivityTimer method updates internal timer for working + * time calculation logic * @static */ updateUserActivityTimer: function() @@ -431,11 +459,12 @@ var Logger = { this._logger.updateTimer(); }, - /** Logger.setTimeThreshold set time threshold in ms for EventType. - * If time interval betwwen incoming log events less than threshold events will be collapsed. - * Note that result event will have timestamp of first event, - * In case of time threshold used for continued event duration will be difference between - * first and last event timestamps and other fields from last event. + /** Logger.setTimeThreshold set time threshold in ms for EventType. If time + * interval betwwen incoming log events less than threshold events will be + * collapsed. Note that result event will have timestamp of first event, In + * case of time threshold used for continued event duration will be + * difference between first and last event timestamps and other fields from + * last event. * @static * @param {Logger.EventType} eventType * @param {Number} threshold @@ -445,7 +474,8 @@ var Logger = { this._logger.setTimeThreshold(eventType, threshold); }, - /** Logger._eventTypeToString private method to transform Logger.EventType to string + /** Logger._eventTypeToString private method to transform Logger.EventType + * to string * @param {Logger.EventType} type Event Type * @return {String} string reppresentation of Logger.EventType * @static diff --git a/cvat/apps/engine/static/engine/js/player.js b/cvat/apps/engine/static/engine/js/player.js index 2cce44cb..2f445647 100644 --- a/cvat/apps/engine/static/engine/js/player.js +++ b/cvat/apps/engine/static/engine/js/player.js @@ -152,8 +152,15 @@ class PlayerModel extends Listener { top: 0, width: playerSize.width, height: playerSize.height, + frameOffset: 0, }; + this._geometry.frameOffset = Math.floor(Math.max( + (playerSize.height - MIN_PLAYER_SCALE) / MIN_PLAYER_SCALE, + (playerSize.width - MIN_PLAYER_SCALE) / MIN_PLAYER_SCALE + )); + window.cvat.translate.playerOffset = this._geometry.frameOffset; + this._frameProvider.subscribe(this); } @@ -167,11 +174,7 @@ class PlayerModel extends Listener { } get geometry() { - return { - scale: this._geometry.scale, - top: this._geometry.top, - left: this._geometry.left - }; + return Object.assign({}, this._geometry); } get playing() { @@ -498,7 +501,6 @@ class PlayerController { this._moving = true; this._lastClickX = e.clientX; this._lastClickY = e.clientY; - e.preventDefault(); } } @@ -520,6 +522,7 @@ class PlayerController { let leftOffset = e.clientX - this._lastClickX; this._lastClickX = e.clientX; this._lastClickY = e.clientY; + this._model.move(topOffset, leftOffset); } } @@ -647,11 +650,21 @@ class PlayerView { this._frameNumber = $('#frameNumber'); this._playerGridPattern = $('#playerGridPattern'); this._playerGridPath = $('#playerGridPath'); + this._contextMenuUI = $('#playerContextMenu'); + + $('*').on('mouseup.player', () => this._controller.frameMouseUp()); + this._playerContentUI.on('mousedown', (e) => { + let pos = window.cvat.translate.point.clientToCanvas(this._playerBackgroundUI[0], e.clientX, e.clientY); + let frameWidth = window.cvat.player.geometry.frameWidth; + let frameHeight = window.cvat.player.geometry.frameHeight; + if (pos.x >= 0 && pos.y >= 0 && pos.x <= frameWidth && pos.y <= frameHeight) { + this._controller.frameMouseDown(e); + } + e.preventDefault(); + }); - $('*').on('mouseup', () => this._controller.frameMouseUp()); this._playerUI.on('wheel', (e) => this._controller.zoom(e)); this._playerUI.on('dblclick', () => this._controller.fit()); - this._playerContentUI.on('mousedown', (e) => this._controller.frameMouseDown(e)); this._playerUI.on('mousemove', (e) => this._controller.frameMouseMove(e)); this._progressUI.on('mousedown', (e) => this._controller.progressMouseDown(e)); this._progressUI.on('mouseup', () => this._controller.progressMouseUp()); @@ -763,6 +776,45 @@ class PlayerView { this._multiplePrevButtonUI.find('polygon').append($(document.createElementNS('http://www.w3.org/2000/svg', 'title')) .html(`${shortkeys['backward_frame'].view_value} - ${shortkeys['backward_frame'].description}`)); + + this._contextMenuUI.click((e) => { + $('.custom-menu').hide(100); + switch($(e.target).attr("action")) { + case "job_url": { + window.cvat.search.set('frame', null); + window.cvat.search.set('filter', null); + copyToClipboard(window.cvat.search.toString()); + break; + } + case "frame_url": + window.cvat.search.set('frame', window.cvat.player.frames.current); + window.cvat.search.set('filter', null); + copyToClipboard(window.cvat.search.toString()); + window.cvat.search.set('frame', null); + break; + } + }); + + this._playerUI.on('contextmenu.playerContextMenu', (e) => { + if (!window.cvat.mode) { + $('.custom-menu').hide(100); + this._contextMenuUI.finish().show(100); + let x = Math.min(e.pageX, this._playerUI[0].offsetWidth - + this._contextMenuUI[0].scrollWidth); + let y = Math.min(e.pageY, this._playerUI[0].offsetHeight - + this._contextMenuUI[0].scrollHeight); + this._contextMenuUI.offset({ + left: x, + top: y, + }); + e.preventDefault(); + } + }); + + this._playerContentUI.on('mousedown.playerContextMenu', () => { + $('.custom-menu').hide(100); + }); + playerModel.subscribe(this); } @@ -778,7 +830,9 @@ class PlayerView { } this._loadingUI.addClass('hidden'); - this._playerBackgroundUI.css('background-image', 'url(' + '"' + image.src + '"' + ')'); + if (this._playerBackgroundUI.css('background-image').slice(5,-2) != image.src) { + this._playerBackgroundUI.css('background-image', 'url(' + '"' + image.src + '"' + ')'); + } if (model.playing) { this._playButtonUI.addClass('hidden'); @@ -815,7 +869,7 @@ class PlayerView { this._progressUI['0'].value = frames.current - frames.start; - for (let obj of [this._playerBackgroundUI, this._playerContentUI, this._playerGridUI]) { + for (let obj of [this._playerBackgroundUI, this._playerGridUI]) { obj.css('width', image.width); obj.css('height', image.height); obj.css('top', geometry.top); @@ -823,6 +877,12 @@ class PlayerView { obj.css('transform', 'scale(' + geometry.scale + ')'); } + this._playerContentUI.css('width', image.width + geometry.frameOffset * 2); + this._playerContentUI.css('height', image.height + geometry.frameOffset * 2); + this._playerContentUI.css('top', geometry.top - geometry.frameOffset * geometry.scale); + this._playerContentUI.css('left', geometry.left - geometry.frameOffset * geometry.scale); + this._playerContentUI.css('transform', 'scale(' + geometry.scale + ')'); + this._playerGridPath.attr('stroke-width', 2 / geometry.scale); this._frameNumber.prop('value', frames.current); } diff --git a/cvat/apps/engine/static/engine/js/polyshapeEditor.js b/cvat/apps/engine/static/engine/js/polyshapeEditor.js new file mode 100644 index 00000000..4ed27168 --- /dev/null +++ b/cvat/apps/engine/static/engine/js/polyshapeEditor.js @@ -0,0 +1,398 @@ +/* + * Copyright (C) 2018 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/* exported PolyshapeEditorModel PolyshapeEditorController PolyshapeEditorView */ + +"use strict"; + +class PolyshapeEditorModel extends Listener { + constructor() { + super("onPolyshapeEditorUpdate", () => this); + + this._modeName = 'poly_editing'; + this._active = false; + this._data = { + points: null, + color: null, + start: null, + oncomplete: null, + type: null, + event: null, + }; + } + + edit(type, points, color, start, event, oncomplete) { + if (!this._active && !window.cvat.mode) { + window.cvat.mode = this._modeName; + this._active = true; + this._data.points = points; + this._data.color = color; + this._data.start = start; + this._data.oncomplete = oncomplete; + this._data.type = type; + this._data.event = event; + this.notify(); + } + else if (this._active) { + throw Error('Polyshape has been being edited already'); + } + } + + finish(points) { + if (this._active && this._data.oncomplete) { + this._data.oncomplete(points); + } + + this.cancel(); + } + + cancel() { + if (this._active) { + this._active = false; + if (window.cvat.mode != this._modeName) { + throw Error(`Inconsistent behaviour has been detected. Edit mode is activated, but mode variable is '${window.cvat.mode}'`); + } + else { + window.cvat.mode = null; + } + + this._data.points = null; + this._data.color = null; + this._data.start = null; + this._data.oncomplete = null; + this._data.type = null; + this._data.event = null; + this.notify(); + } + } + + get active() { + return this._active; + } + + get data() { + return this._data; + } +} + + +class PolyshapeEditorController { + constructor(model) { + this._model = model; + } + + finish(points) { + this._model.finish(points); + } + + cancel() { + this._model.cancel(); + } +} + + +class PolyshapeEditorView { + constructor(model, controller) { + this._controller = controller; + this._data = null; + + this._frameContent = SVG.adopt($('#frameContent')[0]); + this._originalShapePointsGroup = null; + this._originalShapePoints = []; + this._originalShape = null; + this._correctLine = null; + + this._scale = window.cvat.player.geometry.scale; + this._frame = window.cvat.player.frames.current; + + model.subscribe(this); + } + + _rescaleDrawPoints() { + let scale = this._scale; + $('.svg_draw_point').each(function() { + this.instance.radius(POINT_RADIUS / (2 * scale)).attr('stroke-width', STROKE_WIDTH / (2 * scale)); + }); + } + + // After this method start element will be in begin of the array. + // Array will consist only range elements from start to stop + _resortPoints(points, start, stop) { + let sorted = []; + + if (points.indexOf(start) === -1 || points.indexOf(stop) === -1) { + throw Error('Point array must consist both start and stop elements'); + } + + let idx = points.indexOf(start) + 1; + let condition = true; // constant condition is eslint error + while (condition) { + if (idx >= points.length) idx = 0; + if (points[idx] === stop) condition = false; + else sorted.push(points[idx++]); + } + + return sorted; + } + + // Method represents array like circle list and find shortest way from source to target + // It returns integer number - distance from source to target. + // It can be negative if shortest way is anti clockwise + _findMinCircleDistance(array, source, target) { + let clockwise_distance = 0; + let anti_clockwise_distance = 0; + + let source_idx = array.indexOf(source); + let target_idx = array.indexOf(target); + + if (source_idx === -1 || target_idx == -1) { + throw Error('Array should consist both elements'); + } + + let idx = source_idx; + while (array[idx++] != target) { + clockwise_distance ++; + if (idx >= array.length) idx = 0; + } + + idx = source_idx; + while (array[idx--] != target) { + anti_clockwise_distance ++; + if (idx < 0) idx = array.length - 1; + } + + let offset = Math.min(clockwise_distance, anti_clockwise_distance); + if (anti_clockwise_distance < clockwise_distance) { + offset = -offset; + } + + return offset; + } + + _startEdit() { + this._frame = window.cvat.player.frames.current; + let strokeWidth = this._data.type === 'points' ? 0 : STROKE_WIDTH / this._scale; + + // Draw copy of original shape + if (this._data.type === 'polygon') { + this._originalShape = this._frameContent.polygon(this._data.points); + } + else { + this._originalShape = this._frameContent.polyline(this._data.points); + } + + this._originalShape.attr({ + 'stroke-width': strokeWidth, + 'stroke': 'white', + 'fill': 'none', + }); + + // Create the correct line + this._correctLine = this._frameContent.polyline().draw({snapToGrid: 0.1}).attr({ + 'stroke-width': strokeWidth / 2, + 'fill': 'none', + 'stroke': 'red', + }).on('mouseover', () => false); + + + // Add points to original shape + let pointRadius = POINT_RADIUS / this._scale; + this._originalShapePointsGroup = this._frameContent.group(); + for (let point of PolyShapeModel.convertStringToNumberArray(this._data.points)) { + let uiPoint = this._originalShapePointsGroup.circle(pointRadius * 2) + .move(point.x - pointRadius, point.y - pointRadius) + .attr({ + 'stroke-width': strokeWidth, + 'stroke': 'black', + 'fill': 'white', + 'z_order': Number.MAX_SAFE_INTEGER, + }); + this._originalShapePoints.push(uiPoint); + } + + + let prevPoint = { + x: this._data.event.clientX, + y: this._data.event.clientY + }; + + this._correctLine.draw('point', this._data.event); + this._rescaleDrawPoints(); + this._frameContent.on('mousemove.polyshapeEditor', (e) => { + if (e.shiftKey && this._data.type != 'points') { + let delta = Math.sqrt(Math.pow(e.clientX - prevPoint.x, 2) + Math.pow(e.clientY - prevPoint.y, 2)); + let deltaTreshold = 15; + if (delta > deltaTreshold) { + this._correctLine.draw('point', e); + prevPoint = { + x: e.clientX, + y: e.clientY + }; + } + } + }); + + this._frameContent.on('contextmenu.polyshapeEditor', (e) => { + if (PolyShapeModel.convertStringToNumberArray(this._correctLine.attr('points')).length > 2) { + this._correctLine.draw('undo'); + } + else { + // Finish without points argument is just cancel + this._controller.finish(); + } + e.preventDefault(); + e.stopPropagation(); + }); + + this._correctLine.on('drawpoint', (e) => { + prevPoint = { + x: e.detail.event.clientX, + y: e.detail.event.clientY + }; + this._rescaleDrawPoints(); + }); + + this._correctLine.on('drawstart', () => this._rescaleDrawPoints()); + + + for (let instance of this._originalShapePoints) { + instance.on('mouseover', () => { + instance.attr('stroke-width', STROKE_WIDTH * 2 / this._scale); + }).on('mouseout', () => { + instance.attr('stroke-width', STROKE_WIDTH / this._scale); + }).on('mousedown', (e) => { + if (e.which != 1) return; + let currentPoints = PolyShapeModel.convertStringToNumberArray(this._data.points); + let correctPoints = PolyShapeModel.convertStringToNumberArray(this._correctLine.attr('points')); + let resultPoints = []; + + if (this._data.type === 'polygon') { + let startPtIdx = this._data.start; + let stopPtIdx = $(instance.node).index(); + let offset = this._findMinCircleDistance(currentPoints, currentPoints[startPtIdx], currentPoints[stopPtIdx]); + + if (!offset) { + currentPoints = this._resortPoints(currentPoints, currentPoints[startPtIdx], currentPoints[stopPtIdx]); + resultPoints.push(...correctPoints.slice(0, -2)); + resultPoints.push(...currentPoints); + } + else { + resultPoints.push(...correctPoints); + if (offset < 0) { + resultPoints = resultPoints.reverse(); + currentPoints = this._resortPoints(currentPoints, currentPoints[startPtIdx], currentPoints[stopPtIdx]); + } + else { + currentPoints = this._resortPoints(currentPoints, currentPoints[stopPtIdx], currentPoints[startPtIdx]); + } + + resultPoints.push(...currentPoints); + } + } + else if (this._data.type === 'polyline') { + let startPtIdx = this._data.start; + let stopPtIdx = $(instance.node).index(); + + if (startPtIdx === stopPtIdx) { + resultPoints.push(...correctPoints.slice(1, -1).reverse()); + resultPoints.push(...currentPoints); + } + else { + if (startPtIdx > stopPtIdx) { + if (startPtIdx < currentPoints.length - 1) { + resultPoints.push(...currentPoints.slice(startPtIdx + 1).reverse()); + } + resultPoints.push(...correctPoints.slice(0, -1)); + if (stopPtIdx > 0) { + resultPoints.push(...currentPoints.slice(0, stopPtIdx).reverse()); + } + } + else { + if (startPtIdx > 0) { + resultPoints.push(...currentPoints.slice(0, startPtIdx)); + } + resultPoints.push(...correctPoints.slice(0, -1)); + if (stopPtIdx < currentPoints.length) { + resultPoints.push(...currentPoints.slice(stopPtIdx + 1)); + } + } + } + } + else { + resultPoints.push(...currentPoints); + resultPoints.push(...correctPoints.slice(1, -1).reverse()); + } + + this._correctLine.draw('cancel'); + this._controller.finish(PolyShapeModel.convertNumberArrayToString(resultPoints)); + }); + } + } + + _endEdit() { + for (let uiPoint of this._originalShapePoints) { + uiPoint.off(); + uiPoint.remove(); + } + + this._originalShapePoints = []; + this._originalShapePointsGroup.remove(); + this._originalShapePointsGroup = null; + this._originalShape.remove(); + this._originalShape = null; + this._correctLine.off('drawstart'); + this._correctLine.off('drawpoint'); + this._correctLine.draw('cancel'); + this._correctLine.remove(); + this._correctLine = null; + this._data = null; + + this._frameContent.off('mousemove.polyshapeEditor'); + this._frameContent.off('mousedown.polyshapeEditor'); + this._frameContent.off('contextmenu.polyshapeEditor'); + } + + + onPolyshapeEditorUpdate(model) { + if (model.active && !this._data) { + this._data = model.data; + this._startEdit(); + } + else if (!model.active) { + this._endEdit(); + } + } + + onPlayerUpdate(player) { + let scale = player.geometry.scale; + if (this._scale != scale) { + this._scale = scale; + + let strokeWidth = this._data && this._data.type === 'points' ? 0 : STROKE_WIDTH / this._scale; + let pointRadius = POINT_RADIUS / this._scale; + + if (this._originalShape) { + this._originalShape.attr('stroke-width', strokeWidth); + } + + if (this._correctLine) { + this._correctLine.attr('stroke-width', strokeWidth / 2); + } + + for (let uiPoint of this._originalShapePoints) { + uiPoint.attr('stroke-width', strokeWidth); + uiPoint.radius(pointRadius); + } + + this._rescaleDrawPoints(); + } + + // Abort if frame have been changed + if (player.frames.current != this._frame && this._data) { + this._controller.cancel(); + } + } +} diff --git a/cvat/apps/engine/static/engine/js/qunitTests.js b/cvat/apps/engine/static/engine/js/qunitTests.js index 78216e74..4b7bf2bb 100644 --- a/cvat/apps/engine/static/engine/js/qunitTests.js +++ b/cvat/apps/engine/static/engine/js/qunitTests.js @@ -6,136 +6,129 @@ "use strict"; -let qunit_tests = []; +let qUnitTests = []; +window.cvat = { + translate: {} +}; -// RUN ALL TESTS +// Run all tests window.addEventListener('DOMContentLoaded', function() { - for (let qunit_test of qunit_tests) { - qunit_test(); + for (let qUnitTest of qUnitTests) { + qUnitTest(); } }); +qUnitTests.push(function() { + let labelsInfo = null; -// labels info unit tests -qunit_tests.push(function() { - let labels_info = null; - - QUnit.module('labels_info_class', { + QUnit.module('LabelsInfo_class', { before: function() { - labels_info = make_labels_info(); + labelsInfo = makeLabelsInfo(); } }); QUnit.test('labelIdOf', function(assert) { - assert.equal(labels_info.labelIdOf('person'), 1, 'Id of "car" must be 1'); - assert.equal(labels_info.labelIdOf('face'), 2, 'Id of "bicycle" must be 2'); - assert.equal(labels_info.labelIdOf('car'), 3, 'Id of "person" must be 3'); - assert.equal(labels_info.labelIdOf('bicycle'), 4, 'Id of "motorcycle" must be 4'); - assert.equal(labels_info.labelIdOf('motorcycle'), 5, 'Id of "unknown" must be 5'); - assert.equal(labels_info.labelIdOf('road'), 6, 'Id of "unknown" must be 6'); - assert.equal(labels_info.labelIdOf('unknown'), null, 'Id of "unknown" must be null'); + assert.equal(labelsInfo.labelIdOf('person'), 13, 'Id of "person" must be 13'); + assert.equal(labelsInfo.labelIdOf('face'), 14, 'Id of "face" must be 14'); + assert.equal(labelsInfo.labelIdOf('car'), 15, 'Id of "car" must be 15'); + assert.equal(labelsInfo.labelIdOf('bicycle'), 16, 'Id of "bicycle" must be 16'); + assert.equal(labelsInfo.labelIdOf('motorcycle'), 17, 'Id of "motorcycle" must be 17'); + assert.equal(labelsInfo.labelIdOf('road'), 18, 'Id of "road" must be 18'); + assert.equal(labelsInfo.labelIdOf('unknown'), null, 'Id of "unknown" must be null'); }); - QUnit.test('attrIdOf', function(assert) { - assert.equal(labels_info.attrIdOf('2','beard'), 8, 'Attribute id must be equal 8'); - assert.equal(labels_info.attrIdOf('3','parked'), 12, 'Attribute id must be equal 12'); - assert.equal(labels_info.attrIdOf('unknown','driver'), null, 'Attribute id must be equal null'); - assert.equal(labels_info.attrIdOf('1','unknown'), null, 'Attribute id must be equal null'); + assert.equal(labelsInfo.attrIdOf('14','beard'), 38, 'Attribute id must be equal 38'); + assert.equal(labelsInfo.attrIdOf('15','parked'), 42, 'Attribute id must be equal 42'); + assert.equal(labelsInfo.attrIdOf('unknown','driver'), null, 'Attribute id must be equal null'); + assert.equal(labelsInfo.attrIdOf('15','unknown'), null, 'Attribute id must be equal null'); }); - QUnit.test('strToValues', function(assert) { - assert.deepEqual(labels_info.strToValues('checkbox', 'false'), [false]); - assert.deepEqual(labels_info.strToValues('checkbox', 'false,true'), [true]); - assert.deepEqual(labels_info.strToValues('checkbox', '0'), [false]); - assert.deepEqual(labels_info.strToValues('checkbox', false), [false]); - assert.deepEqual(labels_info.strToValues('checkbox', 'abrakadabra'), [true]); - assert.deepEqual(labels_info.strToValues('select', 'value1,value2,value3'), ['value1', 'value2', 'value3']); - assert.deepEqual(labels_info.strToValues('select', 'value1'), ['value1']); - assert.deepEqual(labels_info.strToValues('text', 'value1,together value2 and 3'), ['value1,together value2 and 3']); - assert.deepEqual(labels_info.strToValues('radio', 'value'), ['value']); - assert.deepEqual(labels_info.strToValues('number', '1,2,3'), ['1','2','3']); - assert.deepEqual(labels_info.strToValues('number', 1), ['1']); + assert.deepEqual(labelsInfo.strToValues('checkbox', 'false'), [false]); + assert.deepEqual(labelsInfo.strToValues('checkbox', 'false,true'), [true]); + assert.deepEqual(labelsInfo.strToValues('checkbox', '0'), [false]); + assert.deepEqual(labelsInfo.strToValues('checkbox', false), [false]); + assert.deepEqual(labelsInfo.strToValues('checkbox', 'abrakadabra'), [true]); + assert.deepEqual(labelsInfo.strToValues('select', 'value1,value2,value3'), ['value1', 'value2', 'value3']); + assert.deepEqual(labelsInfo.strToValues('select', 'value1'), ['value1']); + assert.deepEqual(labelsInfo.strToValues('text', 'value1,together value2 and 3'), ['value1,together value2 and 3']); + assert.deepEqual(labelsInfo.strToValues('radio', 'value'), ['value']); + assert.deepEqual(labelsInfo.strToValues('number', '1,2,3'), ['1','2','3']); + assert.deepEqual(labelsInfo.strToValues('number', 1), ['1']); }); - QUnit.test('labels', function(assert) { let expected = { - 1:"person", - 2:"face", - 3:"car", - 4:"bicycle", - 5:"motorcycle", - 6:"road" + 13:"person", + 14:"face", + 15:"car", + 16:"bicycle", + 17:"motorcycle", + 18:"road" }; - assert.deepEqual(labels_info.labels(), expected, 'Return value must be like expected'); - }); + assert.deepEqual(labelsInfo.labels(), expected, 'Return value must be like expected'); + }); QUnit.test('attributes', function(assert) { let expected = { - 1:"action", - 2:"age", - 3:"gender", - 4:"false_positive", - 5:"clother", - 6:"age", - 7:"glass", - 8:"beard", - 9:"race", - 10:"model", - 11:"driver", - 12:"parked", - 13:"driver", - 14:"sport", - 15:"model" + 35:"action", + 32:"age", + 33:"gender", + 31:"false_positive", + 34:"clother", + 37:"age", + 36:"glass", + 38:"beard", + 39:"race", + 40:"model", + 41:"driver", + 42:"parked", + 44:"driver", + 43:"sport", + 45:"model" }; - assert.deepEqual(labels_info.attributes(), expected, 'Return value must be like expected'); - }); + assert.deepEqual(labelsInfo.attributes(), expected, 'Return value must be like expected'); + }); QUnit.test('labelAttributes', function(assert) { - let expected_1 = { - 1:"action", - 2:"age", - 3:"gender", - 4:"false_positive", - 5:"clother" - }; - - let expected_2 = { - 6:"age", - 7:"glass", - 8:"beard", - 9:"race" - }; - - let expected_3 = { - 10:"model", - 11:"driver", - 12:"parked" - }; - - let expected_4 = { - 13:"driver", - 14:"sport" - }; - - assert.deepEqual(labels_info.labelAttributes(1), labels_info.labelAttributes("1"), 'Return values must be equal'); - assert.deepEqual(labels_info.labelAttributes(1), expected_1, 'Return value must be like expected'); - assert.deepEqual(labels_info.labelAttributes("2"), expected_2, 'Return value must be like expected'); - assert.deepEqual(labels_info.labelAttributes(3), expected_3, 'Return value must be like expected'); - assert.deepEqual(labels_info.labelAttributes(4), expected_4, 'Return value must be like expected'); - assert.deepEqual(labels_info.labelAttributes(45), {}, 'Return value must be empty object'); - assert.deepEqual(labels_info.labelAttributes(), {}, 'Return value must be empty object'); - assert.deepEqual(labels_info.labelAttributes(null), {}, 'Return value must be empty object'); - assert.deepEqual(labels_info.labelAttributes("road"), {}, 'Return value must be empty object'); + assert.deepEqual(labelsInfo.labelAttributes(13), { + 35:"action", + 32:"age", + 33:"gender", + 31:"false_positive", + 34:"clother" + }, 'Return value must be like expected'); + + assert.deepEqual(labelsInfo.labelAttributes("14"), { + 37:"age", + 36:"glass", + 38:"beard", + 39:"race" + }, 'Return value must be like expected'); + + assert.deepEqual(labelsInfo.labelAttributes(15), { + 40:"model", + 41:"driver", + 42:"parked" + }, 'Return value must be like expected'); + + assert.deepEqual(labelsInfo.labelAttributes(16), { + 44:"driver", + 43:"sport" + }, 'Return value must be like expected'); + + assert.deepEqual(labelsInfo.labelAttributes(13), labelsInfo.labelAttributes("13"), 'Return values must be equal'); + assert.deepEqual(labelsInfo.labelAttributes(100), {}, 'Return value must be empty object'); + assert.deepEqual(labelsInfo.labelAttributes(), {}, 'Return value must be empty object'); + assert.deepEqual(labelsInfo.labelAttributes(null), {}, 'Return value must be empty object'); + assert.deepEqual(labelsInfo.labelAttributes("road"), {}, 'Return value must be empty object'); }); - QUnit.test('attrInfo', function(assert) { - let expected_1 = { + assert.deepEqual(labelsInfo.attrInfo(35), { name:"action", type:"select", mutable:true, @@ -145,148 +138,225 @@ qunit_tests.push(function() { "raising_hand", "standing" ] - }; + }, 'Return value must be like expected'); - let expected_5 = { + assert.deepEqual(labelsInfo.attrInfo(34), { name:"clother", type:"text", mutable:true, values: [ "non-initialized" ] - }; + }, 'Return value must be like expected'); - let expected_13 = { + assert.deepEqual(labelsInfo.attrInfo(41), { name:"driver", - type:"radio", + type:"select", mutable:false, values: [ + "__undefined__", "man", "woman" ] - }; + }, 'Return value must be like expected'); - assert.deepEqual(labels_info.attrInfo(4), labels_info.attrInfo("4"), 'Return values must be equal'); - assert.deepEqual(labels_info.attrInfo(1), expected_1, 'Return value must be like expected'); - assert.deepEqual(labels_info.attrInfo(5), expected_5, 'Return value must be like expected'); - assert.deepEqual(labels_info.attrInfo(13), expected_13, 'Return value must be like expected'); - assert.deepEqual(labels_info.attrInfo(45), {}, 'Return value must be empty object'); - assert.deepEqual(labels_info.attrInfo(), {}, 'Return value must be empty object'); - assert.deepEqual(labels_info.attrInfo("clother"), {}, 'Return value must be empty object'); - assert.deepEqual(labels_info.attrInfo(null), {}, 'Return value must be empty object'); + assert.deepEqual(labelsInfo.attrInfo(37), labelsInfo.attrInfo("37"), 'Return values must be equal'); + assert.deepEqual(labelsInfo.attrInfo(100), {}, 'Return value must be empty object'); + assert.deepEqual(labelsInfo.attrInfo(), {}, 'Return value must be empty object'); + assert.deepEqual(labelsInfo.attrInfo("clother"), {}, 'Return value must be empty object'); + assert.deepEqual(labelsInfo.attrInfo(null), {}, 'Return value must be empty object'); }); }); // annotation parser unit tests -qunit_tests.push(function() { - let labels_info = null; - let annotation_parser = null; +qUnitTests.push(function() { + let annotationParser = null; - QUnit.module('annotation_parser_class', { + QUnit.module('AnnotatinParser_class', { before: function() { - labels_info = make_labels_info(); - annotation_parser = make_annotation_parser(labels_info); + annotationParser = makeAnnotationParser(); } }); - let correct_xml = + let metaBlock = + ` + + 5 + QUnitTests + 16 + annotation + 0 + + False + 2018-12-24 16:43:33.275376+03:00 + 2018-12-24 16:52:19.644934+03:00 + 16 images: 12642-1.jpg, 24443-ycfych.jpeg, ... + + + + + + + + + + + 3 + 0 + 15 + http://localhost:8081/?id=3 + + + + admin + + + + 2018-12-25 11:58:50.400406+03:00 + + `; + + let correctXml = ` - 1.0 - - - adult (20-45) + 1.1 + ${metaBlock} + + no + adult (20-45) no asian - - __undefined__ + no + __undefined__ no asian - + + false 25 female - false - standing non-initialized + standing - + + false 25 female - false - standing non-initialized + standing - + - + `; - let incorrect_xml = + let incorrectXml = ` - 1.0 - - - adult (20- + 1.1 + ${metaBlock} + + + no + adult (20- `; - let unknown_label = + let unknownLabel = ` - 1.0 - - - adult (20-45) + 1.1 + ${metaBlock} + + no + adult (20-45) no asian - - - - `; - let unknown_attribute = + let unknownAttribute = ` - 1.0 - - - adult (20-45) + 1.1 + ${metaBlock} + + no + adult (20-45) no asian `; - let bad_attr_values = + let badAttributeValues = ` - 1.0 - - - adult (20-45) + 1.1 + ${metaBlock} + + some bad value + adult (20-45) no asian `; - let empty_xml = + let emptyXml = ` - `; + + 1.1 + `; let empty = { "boxes": [], @@ -300,318 +370,377 @@ qunit_tests.push(function() { }; QUnit.test('parse', function(assert) { - assert.deepEqual(annotation_parser.parse(correct_xml), window.job_data, 'Return value must be like expected.'); - assert.deepEqual(annotation_parser.parse(empty_xml), empty, 'Return value must be like expected.'); - assert.throws(annotation_parser.parse.bind(annotation_parser, bad_attr_values), 'This function must throw exception. Bad attribute values into XML.'); - assert.throws(annotation_parser.parse.bind(annotation_parser, incorrect_xml),'This function must throw exception. Bad input xml.'); - assert.throws(annotation_parser.parse.bind(annotation_parser, unknown_label),'This function must throw exception. Unknown label in input xml.'); - assert.throws(annotation_parser.parse.bind(annotation_parser, unknown_attribute),'This function must throw exception. Unknown attribute in input xml.'); + assert.deepEqual(annotationParser.parse(correctXml), window.jobData, 'Return value must be like expected.'); + assert.deepEqual(annotationParser.parse(emptyXml), empty, 'Return value must be like expected.'); + assert.throws(annotationParser.parse.bind(annotationParser, badAttributeValues), 'This function must throw exception. Bad attribute values into XML.'); + assert.throws(annotationParser.parse.bind(annotationParser, incorrectXml),'This function must throw exception. Bad input xml.'); + assert.throws(annotationParser.parse.bind(annotationParser, unknownLabel),'This function must throw exception. Unknown label in input xml.'); + assert.throws(annotationParser.parse.bind(annotationParser, unknownAttribute),'This function must throw exception. Unknown attribute in input xml.'); }); }); + // listener interface -qunit_tests.push(function() { - QUnit.module('listener_interface'); +qUnitTests.push(function() { + QUnit.module('Listener_interface'); QUnit.test('subscribe', function(assert) { - let listener_interface = new Listener('onUpdate', () => {return {};}); + let listenerInterface = new Listener('onUpdate', () => {return {};}); - let fake_listener_1 = { + let dummyListener1 = { onUpdate: function() {} }; - let fake_listener_2 = { + let dummyListener2 = { onUpdate: 'someProp' }; - let fake_listener_3 = { + let dummyListener3 = { // no onUpdate property }; - let fake_listener_4 = { + let dummyListener4 = { onUpdate: function() {} }; - let fake_listener_5 = { + let dummyListener5 = { onUpdate: function() {} }; - listener_interface.subscribe(fake_listener_1); // no exceptions, listener added - assert.throws(listener_interface.subscribe.bind(listener_interface, fake_listener_2), 'Function must be throw exception. Fake listener does not have onUpdate function'); - assert.throws(listener_interface.subscribe.bind(listener_interface, fake_listener_3), 'Function must be throw exception. Fake listener does not have onUpdate function'); - assert.deepEqual(listener_interface._listeners, [fake_listener_1], 'One listener must be added'); // check internal state - listener_interface.subscribe(fake_listener_4); // no exceptions, listener added - listener_interface.subscribe(fake_listener_5); // no exceptions, listener added - assert.deepEqual(listener_interface._listeners, [fake_listener_1, fake_listener_4, fake_listener_5], 'Three listener must be added'); // check internal state + listenerInterface.subscribe(dummyListener1); // no exceptions, listener added + assert.throws(listenerInterface.subscribe.bind(listenerInterface, dummyListener2), 'Function must be throw exception. Fake listener does not have onUpdate function'); + assert.throws(listenerInterface.subscribe.bind(listenerInterface, dummyListener3), 'Function must be throw exception. Fake listener does not have onUpdate function'); + assert.deepEqual(listenerInterface._listeners, [dummyListener1], 'One listener must be added'); // check internal state + listenerInterface.subscribe(dummyListener4); // no exceptions, listener added + listenerInterface.subscribe(dummyListener5); // no exceptions, listener added + assert.deepEqual(listenerInterface._listeners, [dummyListener1, dummyListener4, dummyListener5], 'Three listener must be added'); // check internal state }); QUnit.test('unsubscribe', function(assert) { - let listener_interface = new Listener('onUpdate', () => {return {};}); + let listenerInterface = new Listener('onUpdate', () => {return {};}); - let fake_listener_1 = { + let dummyListener1 = { onUpdate: function() {} }; - let fake_listener_2 = { + let dummyListener2 = { onUpdate: function() {} }; - let fake_listener_3 = { + let dummyListener3 = { onUpdate: function() {} }; - let fake_listener_4 = { + let dummyListener4 = { onUpdate: function() {} }; - listener_interface.subscribe(fake_listener_1); - listener_interface.subscribe(fake_listener_2); - listener_interface.subscribe(fake_listener_3); - listener_interface.subscribe(fake_listener_4); + listenerInterface.subscribe(dummyListener1); + listenerInterface.subscribe(dummyListener2); + listenerInterface.subscribe(dummyListener3); + listenerInterface.subscribe(dummyListener4); - listener_interface.unsubscribe(fake_listener_2); - listener_interface.unsubscribe(fake_listener_4); + listenerInterface.unsubscribe(dummyListener2); + listenerInterface.unsubscribe(dummyListener4); - assert.throws(listener_interface.unsubscribe.bind(listener_interface, null), 'Function must throw exception. Listener is not an object.'); - assert.throws(listener_interface.unsubscribe.bind(listener_interface), 'Function must throw exception. Listener is not an object.'); - assert.deepEqual(listener_interface._listeners, [fake_listener_1, fake_listener_3], 'Two listeners must be added'); + assert.throws(listenerInterface.unsubscribe.bind(listenerInterface, null), 'Function must throw exception. Listener is not an object.'); + assert.throws(listenerInterface.unsubscribe.bind(listenerInterface), 'Function must throw exception. Listener is not an object.'); + assert.deepEqual(listenerInterface._listeners, [dummyListener1, dummyListener3], 'Two listeners must be added'); - listener_interface.unsubscribe(fake_listener_1); - listener_interface.unsubscribe(fake_listener_3); + listenerInterface.unsubscribe(dummyListener1); + listenerInterface.unsubscribe(dummyListener3); - assert.deepEqual(listener_interface._listeners, [], 'Listener state must be empty'); + assert.deepEqual(listenerInterface._listeners, [], 'Listener state must be empty'); }); QUnit.test('unsubscribeAll', function(assert) { - let listener_interface = new Listener('onUpdate', () => {return {};}); - let fake_listener_1 = { onUpdate: function() {} }; - let fake_listener_2 = { onUpdate: function() {} }; + let listenerInterface = new Listener('onUpdate', () => {return {};}); + let dummyListener1 = { onUpdate: function() {} }; + let dummyListener2 = { onUpdate: function() {} }; - listener_interface.subscribe(fake_listener_1); - listener_interface.subscribe(fake_listener_2); + listenerInterface.subscribe(dummyListener1); + listenerInterface.subscribe(dummyListener2); - listener_interface.unsubscribeAll(); - assert.deepEqual(listener_interface._listeners, [], 'Listener state must be empty'); + listenerInterface.unsubscribeAll(); + assert.deepEqual(listenerInterface._listeners, [], 'Listener state must be empty'); }); }); + // player model unit tests -qunit_tests.push(function() { - let player_model = null; - QUnit.module('player_model_class', { +qUnitTests.push(function() { + let playerModel = null; + QUnit.module('PlayerModel_class', { before: function() { - player_model = make_player_model(); + playerModel = makePlayerModel(); } }); QUnit.test('scale', function(assert) { // Scale when player is not ready assert.expect(0); - player_model.scale(20,20,1); + playerModel.scale(20,20,1); }); QUnit.test('fit', function(assert) { // Fit when player is not ready assert.expect(0); - player_model.fit(); + playerModel.fit(); }); }); -function make_labels_info() { +function makeLabelsInfo() { return new LabelsInfo(window.job); } -function make_annotation_parser() { - return new AnnotationParser(window.job, make_labels_info()); +function makeAnnotationParser() { + return new AnnotationParser(window.job, makeLabelsInfo(), makeIncrementIdGenerator()); } -function make_player_model() { - let fake_player_geometry = { +function makeIncrementIdGenerator() { + return new IncrementIdGenerator(window.job.max_shape_id + 1); +} + +function makePlayerModel() { + let dummyPlayerGeometry = { width: 800, height: 600, left: 10, top: 10 }; - return new PlayerModel(window.job, fake_player_geometry); + return new PlayerModel(window.job, dummyPlayerGeometry); } + // stub data window.job = { - "jobid":1, - "labels":{ - "1":"person", - "2":"face", - "3":"car", - "4":"bicycle", - "5":"motorcycle", - "6":"road" - }, - "taskid":1, - "stop":12, - "z_order":true, - "overlap":0, - "slug":"QUnitTests", - "status":"Annotate", - "attributes":{ - "1":{ - "1":"~select=action:__undefined__,sitting,raising_hand,standing", - "2":"@number=age:1,100,1", - "3":"@select=gender:male,female", - "4":"@checkbox=false_positive:false", - "5":"~text=clother:non-initialized" - }, - "2":{ - "8":"@select=beard:__undefined__,skip,no,yes", - "9":"@select=race:__undefined__,skip,asian,black,caucasian,other", - "6":"@select=age:__undefined__,skip,baby (0-5),child (6-12),adolescent (13-19),adult (20-45),middle-age (46-64),old (65-)", - "7":"@select=glass:__undefined__,skip,no,sunglass,transparent,other" - }, - "3":{ - "10":"@select=model:__undefined__,bmw,mazda,suzuki,kia", - "11":"@select=driver:__undefined__,man,woman", - "12":"~checkbox=parked:true" - }, - "4":{ - "13":"@radio=driver:man,woman", - "14":"~checkbox=sport:false" - }, - "5":{ - "15":"@text=model:unknown" - }, - "6":{ - - } - }, - "flipped": false, "image_meta_data": { "original_size": [{ - "width": 3240, - "height": 2000 - }] - }, - "mode":"annotation", - "start":0 -}; - -window.job_data = { - "boxes": [{ - "label_id": 2, - "frame": 0, - "group_id": 0, - "occluded": 0, - "xtl": 1045.98, - "ytl": 403.64, - "xbr": 1127.48, - "ybr": 498.08, - "z_order": 8, - "attributes": [{ - "id": "6", - "value": "adult (20-45)" + "height": 1280, + "width": 1920 }, { - "id": "7", - "value": "no" + "height": 1280, + "width": 1920 }, { - "id": "8", - "value": "no" + "height": 1280, + "width": 1920 }, { - "id": "9", - "value": "asian" - }] - }, { - "label_id": 2, - "frame": 0, - "group_id": 0, - "occluded": 0, - "xtl": 766.53, - "ytl": 426.93, - "xbr": 858.39, - "ybr": 534.31, - "z_order": 9, - "attributes": [{ - "id": "6", - "value": "__undefined__" + "height": 1280, + "width": 1920 }, { - "id": "7", - "value": "no" + "height": 1280, + "width": 1920 }, { - "id": "8", - "value": "no" + "height": 1024, + "width": 962 }, { - "id": "9", - "value": "asian" + "height": 1280, + "width": 1920 + }, { + "height": 200, + "width": 200 + }, { + "height": 256, + "width": 128 + }, { + "height": 1280, + "width": 1920 + }, { + "height": 1280, + "width": 1920 + }, { + "height": 1280, + "width": 1920 + }, { + "height": 1280, + "width": 1920 + }, { + "height": 1280, + "width": 1920 + }, { + "height": 1280, + "width": 1920 + }, { + "height": 1280, + "width": 1920 }] - }], + }, + "z_order": true, + "start": 0, + "slug": "QUnitTests", + "mode": "annotation", + "labels": { + "18": "road", + "17": "motorcycle", + "16": "bicycle", + "15": "car", + "14": "face", + "13": "person" + }, + "status": "annotation", + "flipped": false, + "taskid": 5, + "overlap": 0, + "max_shape_id": 6, + "stop": 15, + "jobid": 3, + "attributes": { + "16": { + "43": "~checkbox=sport:false", + "44": "@radio=driver:man,woman" + }, + "17": { + "45": "@text=model:unknown" + }, + "18": { + + }, + "13": { + "32": "@number=age:1,100,1", + "33": "@select=gender:male,female", + "34": "~text=clother:non-initialized", + "35": "~select=action:__undefined__,sitting,raising_hand,standing", + "31": "@checkbox=false_positive:false" + }, + "14": { + "36": "@select=glass:__undefined__,skip,no,sunglass,transparent,other", + "37": "@select=age:__undefined__,skip,baby (0-5),child (6-12),adolescent (13-19),adult (20-45),middle-age (46-64),old (65-)", + "38": "@select=beard:__undefined__,skip,no,yes", + "39": "@select=race:__undefined__,skip,asian,black,caucasian,other" + }, + "15": { + "40": "@select=model:__undefined__,bmw,mazda,suzuki,kia", + "41": "@select=driver:__undefined__,man,woman", + "42": "~checkbox=parked:true" + } + } +}; + +window.jobData = { + "polyline_paths": [], + "points_paths": [], + "box_paths": [], + "polygon_paths": [], "polygons": [{ - "label_id": 1, - "frame": 0, - "group_id": 0, - "points": "1014.31,1043.74 1024.71,1053.62 1041.87,1061.93 1052.78,1067.13 1060.58,1069.21 1076.18,1070.25 1079.3,1068.69 1077.74,1057.26 1077.22,1048.94 1076.7,1041.14 1078.26,1031.26 1081.9,1016.7 1091.78,995.91 1101.14,975.11 1108.42,950.67 1118.81,967.31 1130.77,992.27 1132.33,1004.22 1127.13,1009.94 1120.89,1017.74 1115.69,1025.54 1104.26,1030.74 1096.46,1039.58 1096.46,1046.34 1104.26,1047.38 1125.05,1048.94 1141.69,1045.82 1144.29,1040.1 1158.85,1036.46 1171.33,1030.22 1172.89,1026.58 1167.69,1012.02 1157.81,993.31 1152.09,986.03 1152.09,977.19 1148.45,967.31 1142.21,944.95 1138.05,930.92 1138.05,922.08 1134.41,911.68 1128.17,897.64 1122.45,883.6 1120.37,856.57 1137.01,812.37 1256.07,783.78 1245.67,737.51 1240.99,701.11 1230.6,654.84 1216.56,609.09 1204.6,610.64 1198.36,608.57 1197.32,603.89 1192.1,598.9 1187.44,597.65 1185.36,581.53 1182.76,571.65 1177.56,553.45 1167.17,535.78 1164.57,525.38 1157.29,519.66 1145.33,513.42 1145.33,509.26 1139.09,505.62 1144.29,475.99 1139.09,449.99 1126.09,437.51 1124.53,436.99 1121.93,422.43 1113.61,414.12 1099.58,410.48 1079.82,410.48 1065.78,416.2 1055.38,419.83 1054.34,433.87 1052.78,445.83 1053.3,453.63 1051.74,462.99 1053.82,472.87 1056.94,484.82 1059.02,495.22 1062.66,498.86 1054.86,508.74 1041.87,514.98 1033.55,519.14 1028.35,529.02 1022.63,550.33 1020.55,563.85 1014.83,585.17 1009.63,602.33 1005.99,618.44 1006.51,630.4 1015.35,641.32 1019.51,643.92 1020.55,659.52 1018.99,677.71 1014.31,694.35 1012.75,706.31 1012.75,719.31 1010.15,728.67 1012.23,737.51 1012.23,752.06 1010.15,769.74 1007.03,794.18 1007.55,809.25 1021.59,810.29 1023.67,785.34 1025.75,760.9 1034.59,759.86 1041.87,758.82 1046.55,781.7 1053.82,800.94 1062.14,820.69 1067.34,839.41 1073.06,853.45 1071.5,873.2 1073.06,884.12 1075.14,891.92 1074.1,921.04 1069.94,945.99 1066.82,968.87 1063.18,996.42 1060.06,1012.54 1055.38,1017.22 1046.55,1016.7 1039.79,1016.18 1036.67,1019.3 1038.75,1025.02 1032.51,1026.58 1024.19,1025.02 1014.83,1029.18", "occluded": 0, - "z_order": 6, + "group_id": 0, "attributes": [{ - "id": "1", - "value": "standing" + "id": "31", + "value": false }, { - "id": "2", + "id": "32", "value": "25" }, { - "id": "3", + "id": "33", "value": "female" }, { - "id": "4", - "value": false - }, { - "id": "5", + "id": "34", "value": "non-initialized" - }] + }, { + "id": "35", + "value": "standing" + }], + "label_id": 13, + "z_order": 10, + "id": 9, + "points": "328.74,1031.61 633.3,1219.68 1010.84,1106 641.72,929.16", + "frame": 0 }, { - "label_id": 1, - "frame": 0, - "group_id": 0, - "points": "860.26,1048.76 871.05,1049.52 885.84,1047.76 896.87,1043.75 901.38,1034.47 904.39,1023.19 908.15,1013.91 906.9,1008.4 904.14,1006.64 901.63,991.35 897.62,990.34 897.12,985.58 893.61,983.82 892.61,976.55 899.38,969.78 902.14,964.02 900.13,957.5 898.63,951.48 898.12,940.7 896.37,930.17 893.86,924.4 892.11,909.11 888.09,894.56 881.58,879.77 874.56,870.24 871.05,861.47 871.3,854.2 871.3,835.89 873.05,817.34 876.31,801.29 880.32,786.75 889.6,786.75 905.4,783.99 922.45,782.24 930.47,780.48 934.23,778.22 939.5,776.47 944.01,771.45 944.76,764.18 942.25,750.14 937.49,734.09 932.47,714.54 927.96,705.76 924.2,682.69 914.42,641.07 909.66,623.27 905.65,608.48 903.39,598.45 900.13,599.2 924.2,707.02 914.67,710.02 895.87,711.28 889.1,711.28 884.33,708.52 877.06,706.26 876.06,701.25 878.57,701.25 879.82,700.25 880.82,698.74 885.59,698.24 890.35,698.24 890.1,695.73 889.35,692.72 888.85,688.71 882.58,690.47 877.06,690.47 874.81,685.45 875.81,681.44 876.31,675.92 874.56,671.16 873.55,668.65 873.3,663.14 874.3,655.87 876.31,651.6 877.56,644.83 878.07,639.32 882.83,633.3 885.34,623.27 891.35,611.99 892.86,603.46 894.36,595.94 895.87,591.93 897.87,593.68 897.87,595.94 898.88,598.45 900.63,598.95 903.64,598.95 901.63,588.17 898.12,575.38 897.12,571.37 897.62,553.57 894.61,548.8 890.35,544.79 889.35,538.02 884.84,536.52 880.57,537.27 878.57,537.52 877.06,535.77 872.8,535.01 870.29,535.01 868.04,533.76 869.54,526.74 867.28,516.96 866.03,510.19 861.52,505.18 857.76,501.42 854,492.14 852.49,482.36 851.99,475.34 848.23,458.79 841.71,440.24 831.43,432.46 821.4,428.2 808.86,426.45 797.33,427.95 787.55,431.46 778.02,438.23 771,446.76 772.76,457.79 775.27,471.08 778.02,476.34 779.03,482.61 778.27,488.88 778.02,493.89 781.03,499.91 784.04,506.18 789.81,515.71 793.07,519.97 795.83,524.73 796.08,527.74 790.56,531.25 784.54,534.76 774.01,536.02 765.74,537.52 757.96,542.28 754.71,548.8 749.44,559.59 745.93,569.87 741.42,585.16 737.4,599.95 733.39,610.48 729.63,619.51 725.62,629.54 723.61,637.81 724.62,646.59 725.12,656.87 725.87,663.89 727.38,669.16 731.14,672.16 735.65,673.92 745.93,675.17 755.71,678.18 751.45,689.21 747.18,701 745.68,714.04 754.71,718.8 751.45,725.32 748.44,735.85 745.68,745.38 742.42,755.41 740.16,769.45 739.41,784.49 737.66,800.79 738.16,842.41 737.25,863.17 736.35,880.92 733.64,896.57 731.54,910.11 731.84,924.25 729.73,935.68 727.63,950.13 726.42,966.98 724.32,982.62 721.61,1004.59 718.3,1013.61 708.37,1022.04 702.05,1026.55 691.22,1025.95 680.69,1024.44 673.77,1028.96 673.47,1041.89 682.49,1051.82 698.14,1057.24 709.87,1063.56 721.91,1068.07 732.14,1071.38 738.76,1071.98 743.57,1067.77 743.57,1056.64 745.08,1046.11 747.78,1036.18 746.28,1031.06 745.98,1019.93 749.89,1006.69 751.7,1000.67 756.21,999.77 764.03,995.86 764.33,983.52 768.24,973.29 770.35,955.84 773.66,938.99 777.27,921.84 779.98,904.99 781.18,895.07 789.31,860.77 791.11,845.42 798.33,831.88 807.66,815.63 810.97,805.1 815.18,823.16 821.5,840 825.41,861.07 830.23,877.31 832.93,893.26 840.76,907.4 850.38,919.14 853.09,929.06 857,944.11 859.11,957.35 861.52,965.77 865.43,977.51 869.04,982.92 868.74,991.35 868.14,999.77 868.14,1008.2 868.14,1013.91 865.43,1022.64 862.12,1029.26 858.21,1037.08 857.61,1043.1", "occluded": 0, - "z_order": 7, + "group_id": 0, "attributes": [{ - "id": "1", - "value": "standing" + "id": "31", + "value": false }, { - "id": "2", + "id": "32", "value": "25" }, { - "id": "3", + "id": "33", "value": "female" }, { - "id": "4", - "value": false - }, { - "id": "5", + "id": "34", "value": "non-initialized" - }] + }, { + "id": "35", + "value": "standing" + }], + "label_id": 13, + "z_order": 11, + "id": 10, + "points": "1064.14,997.18 1368.7,1185.26 1746.24,1071.57 1377.12,894.73", + "frame": 0 }], "polylines": [{ - "label_id": 6, - "frame": 0, - "group_id": 0, - "points": "1917.9,1060.2 1813.7,1033.4 1696,1007.7 1570.48,980.03 1428.17,952.86 1316.91,932.16 1217.29,916.64 1134.5,903.7 1063.34,890.77 976.66,880.42 889.98,870.07 816.24,862.3 724.38,854.54 649.35,845.49 541.97,836.04 437.05,826.73 329.15,819.87 246.87,816.64 139.23,811.46 34.18,806.93", "occluded": 1, - "z_order": 5, - "attributes": [] + "group_id": 0, + "attributes": [], + "label_id": 18, + "z_order": 13, + "id": 11, + "points": "108.39,1021.79 275.4,329.86", + "frame": 0 }], "points": [{ - "label_id": 6, - "frame": 0, + "occluded": 0, + "group_id": 0, + "attributes": [], + "label_id": 18, + "z_order": 14, + "id": 12, + "points": "1304.18,345.3 1544.18,317.23 1643.82,196.53 1309.79,190.91", + "frame": 0 + }], + "boxes": [{ + "xtl": 438.21, + "group_id": 0, + "xbr": 1043.12, + "ytl": 291.96, + "label_id": 14, + "z_order": 8, + "id": 7, + "attributes": [{ + "id": "36", + "value": "no" + }, { + "id": "37", + "value": "adult (20-45)" + }, { + "id": "38", + "value": "no" + }, { + "id": "39", + "value": "asian" + }], + "ybr": 764.95, + "occluded": 0, + "frame": 0 + }, { + "xtl": 1077.47, "group_id": 0, - "points": "1334.48,1137.18 511.5,1134.4 515.55,706.37 1334.48,707.67", + "xbr": 1682.38, + "ytl": 489.11, + "label_id": 14, + "z_order": 9, + "id": 8, + "attributes": [{ + "id": "36", + "value": "no" + }, { + "id": "37", + "value": "__undefined__" + }, { + "id": "38", + "value": "no" + }, { + "id": "39", + "value": "asian" + }], + "ybr": 962.1, "occluded": 0, - "z_order": 10, - "attributes": [] + "frame": 0 }], - "box_paths": [], - "polygon_paths": [], - "polyline_paths": [], - "points_paths": [] -}; \ No newline at end of file +}; diff --git a/cvat/apps/engine/static/engine/js/shapeBuffer.js b/cvat/apps/engine/static/engine/js/shapeBuffer.js index 42ef6bf5..6c554b94 100644 --- a/cvat/apps/engine/static/engine/js/shapeBuffer.js +++ b/cvat/apps/engine/static/engine/js/shapeBuffer.js @@ -50,7 +50,7 @@ class ShapeBufferModel extends Listener { } } - _makeObject(bbRect, polyPoints, trackedObj) { + _makeObject(box, points, isTracked) { if (!this._shape.type) { return null; } @@ -71,18 +71,11 @@ class ShapeBufferModel extends Listener { object.attributes = attributes; if (this._shape.type === 'box') { - let box = {}; - - box.xtl = Math.max(bbRect.x, 0); - box.ytl = Math.max(bbRect.y, 0); - box.xbr = Math.min(bbRect.x + bbRect.width, window.cvat.player.geometry.frameWidth); - box.ybr = Math.min(bbRect.y + bbRect.height, window.cvat.player.geometry.frameHeight); box.occluded = this._shape.position.occluded; box.frame = window.cvat.player.frames.current; box.z_order = this._collection.zOrder(box.frame).max; - - if (trackedObj) { + if (isTracked) { object.shapes = []; object.shapes.push(Object.assign(box, { outside: false, @@ -95,8 +88,7 @@ class ShapeBufferModel extends Listener { } else { let position = {}; - - position.points = polyPoints; + position.points = points; position.occluded = this._shape.position.occluded; position.frame = window.cvat.player.frames.current; position.z_order = this._collection.zOrder(position.frame).max; @@ -135,78 +127,122 @@ class ShapeBufferModel extends Listener { return false; } - pasteToFrame(bbRect, polyPoints) { - if (!this._shape.type) { - return; - } + pasteToFrame(box, polyPoints) { + let object = this._makeObject(box, polyPoints, this._shape.mode === 'interpolation'); - Logger.addEvent(Logger.EventType.pasteObject); - let object = this._makeObject(bbRect, polyPoints, this._shape.mode === 'interpolation'); - if (this._shape.type === 'box') { - this._collection.add(object, `${this._shape.mode}_${this._shape.type}`); - } - else { - this._collection.add(object, `annotation_${this._shape.type}`); - } + if (object) { + Logger.addEvent(Logger.EventType.pasteObject); + if (this._shape.type === 'box') { + this._collection.add(object, `${this._shape.mode}_${this._shape.type}`); + } + else { + this._collection.add(object, `annotation_${this._shape.type}`); + } - // Undo/redo code - let model = this._collection.shapes.slice(-1)[0]; - window.cvat.addAction('Paste Object', () => { - model.removed = true; - model.unsubscribe(this._collection); - }, () => { - model.subscribe(this._collection); - model.removed = false; - }, window.cvat.player.frames.current); - // End of undo/redo code - - this._collection.update(); + // Undo/redo code + let model = this._collection.shapes.slice(-1)[0]; + window.cvat.addAction('Paste Object', () => { + model.removed = true; + model.unsubscribe(this._collection); + }, () => { + model.subscribe(this._collection); + model.removed = false; + }, window.cvat.player.frames.current); + // End of undo/redo code + + this._collection.update(); + } } propagateToFrames() { let numOfFrames = this._propagateFrames; if (this._shape.type && Number.isInteger(numOfFrames)) { - let bbRect = null; - let polyPoints = null; + let object = null; if (this._shape.type === 'box') { - bbRect = { - x: this._shape.position.xtl, - y: this._shape.position.ytl, - height: this._shape.position.ybr - this._shape.position.ytl, - width: this._shape.position.xbr - this._shape.position.xtl, + let box = { + xtl: this._shape.position.xtl, + ytl: this._shape.position.ytl, + xbr: this._shape.position.xbr, + ybr: this._shape.position.ybr, }; + object = this._makeObject(box, null, false); } else { - polyPoints = this._shape.position.points; + object = this._makeObject(null, this._shape.position.points, false); } - let object = this._makeObject(bbRect, polyPoints, false); - Logger.addEvent(Logger.EventType.propagateObject, { - count: numOfFrames, - }); + if (object) { + Logger.addEvent(Logger.EventType.propagateObject, { + count: numOfFrames, + }); - let addedObjects = []; - while (numOfFrames > 0 && (object.frame + 1 <= window.cvat.player.frames.stop)) { - object.frame ++; - object.z_order = this._collection.zOrder(object.frame).max; - this._collection.add(object, `annotation_${this._shape.type}`); - addedObjects.push(this._collection.shapes.slice(-1)[0]); - numOfFrames --; - } + let imageSizes = window.cvat.job.images.original_size; + let startFrame = window.cvat.player.frames.start; + let originalImageSize = imageSizes[object.frame - startFrame] || imageSizes[0]; + + // Getting normalized coordinates [0..1] + let normalized = {}; + if (this._shape.type === 'box') { + normalized.xtl = object.xtl / originalImageSize.width; + normalized.ytl = object.ytl / originalImageSize.height; + normalized.xbr = object.xbr / originalImageSize.width; + normalized.ybr = object.ybr / originalImageSize.height; + } + else { + normalized.points = []; + for (let point of PolyShapeModel.convertStringToNumberArray(object.points)) { + normalized.points.push({ + x: point.x / originalImageSize.width, + y: point.y / originalImageSize.height, + }); + } + } - // Undo/redo code - window.cvat.addAction('Propagate Object', () => { - for (let object of addedObjects) { - object.removed = true; - object.unsubscribe(this._collection); + let addedObjects = []; + while (numOfFrames > 0 && (object.frame + 1 <= window.cvat.player.frames.stop)) { + object.frame ++; + numOfFrames --; + + object.z_order = this._collection.zOrder(object.frame).max; + let imageSize = imageSizes[object.frame - startFrame] || imageSizes[0]; + let position = {}; + if (this._shape.type === 'box') { + position.xtl = normalized.xtl * imageSize.width; + position.ytl = normalized.ytl * imageSize.height; + position.xbr = normalized.xbr * imageSize.width; + position.ybr = normalized.ybr * imageSize.height; + } + else { + position.points = []; + for (let point of normalized.points) { + position.points.push({ + x: point.x * imageSize.width, + y: point.y * imageSize.height, + }); + } + position.points = PolyShapeModel.convertNumberArrayToString(position.points); + } + Object.assign(object, position); + this._collection.add(object, `annotation_${this._shape.type}`); + addedObjects.push(this._collection.shapes.slice(-1)[0]); } - }, () => { - for (let object of addedObjects) { - object.removed = false; - object.subscribe(this._collection); + + if (addedObjects.length) { + // Undo/redo code + window.cvat.addAction('Propagate Object', () => { + for (let object of addedObjects) { + object.removed = true; + object.unsubscribe(this._collection); + } + }, () => { + for (let object of addedObjects) { + object.removed = false; + object.subscribe(this._collection); + } + }, window.cvat.player.frames.current); + // End of undo/redo code } - }, window.cvat.player.frames.current); - // End of undo/redo code + } } } @@ -246,8 +282,24 @@ class ShapeBufferController { let propagateHandler = Logger.shortkeyLogDecorator(function() { if (!propagateDialogShowed) { if (this._model.copyToBuffer()) { + let curFrame = window.cvat.player.frames.current; + let startFrame = window.cvat.player.frames.start; + let endFrame = Math.min(window.cvat.player.frames.stop, curFrame + this._model.propagateFrames); + let imageSizes = window.cvat.job.images.original_size; + + let message = `Propagate up to ${endFrame} frame. `; + let refSize = imageSizes[curFrame - startFrame] || imageSizes[0]; + for (let _frame = curFrame + 1; _frame <= endFrame; _frame ++) { + let size = imageSizes[_frame - startFrame] || imageSizes[0]; + if ((size.width != refSize.width) || (size.height != refSize.height) ) { + message += 'Some covered frames have another resolution. Shapes in them can differ from reference. '; + break; + } + } + message += 'Are you sure?'; + propagateDialogShowed = true; - confirm(`Propagate to ${this._model.propagateFrames} frames. Are you sure?`, () => { + confirm(message, () => { this._model.propagateToFrames(); propagateDialogShowed = false; }, () => propagateDialogShowed = false); @@ -264,7 +316,10 @@ class ShapeBufferController { pasteToFrame(e, bbRect, polyPoints) { if (this._model.pasteMode) { - this._model.pasteToFrame(bbRect, polyPoints); + if (bbRect || polyPoints) { + this._model.pasteToFrame(bbRect, polyPoints); + } + if (!e.ctrlKey) { this._model.switchPaste(); } @@ -298,35 +353,37 @@ class ShapeBufferView { _drawShapeView() { let scale = window.cvat.player.geometry.scale; + let points = this._shape.position.points ? + window.cvat.translate.points.actualToCanvas(this._shape.position.points) : null; switch (this._shape.type) { case 'box': { let width = this._shape.position.xbr - this._shape.position.xtl; let height = this._shape.position.ybr - this._shape.position.ytl; + this._shape.position = window.cvat.translate.box.actualToCanvas(this._shape.position); this._shapeView = this._frameContent.rect(width, height) - .move(this._shape.position.xtl, this._shape.position.ytl) - .addClass('shapeCreation').attr({ + .move(this._shape.position.xtl, this._shape.position.ytl).addClass('shapeCreation').attr({ 'stroke-width': STROKE_WIDTH / scale, }); break; } case 'polygon': - this._shapeView = this._frameContent.polygon(this._shape.position.points).addClass('shapeCreation').attr({ + this._shapeView = this._frameContent.polygon(points).addClass('shapeCreation').attr({ 'stroke-width': STROKE_WIDTH / scale, }); break; case 'polyline': - this._shapeView = this._frameContent.polyline(this._shape.position.points).addClass('shapeCreation').attr({ + this._shapeView = this._frameContent.polyline(points).addClass('shapeCreation').attr({ 'stroke-width': STROKE_WIDTH / scale, }); break; case 'points': - this._shapeView = this._frameContent.polyline(this._shape.position.points).addClass('shapeCreation').attr({ + this._shapeView = this._frameContent.polyline(points).addClass('shapeCreation').attr({ 'stroke-width': 0, }); this._shapeViewGroup = this._frameContent.group(); - for (let point of PolyShapeModel.convertStringToNumberArray(this._shape.position.points)) { + for (let point of PolyShapeModel.convertStringToNumberArray(points)) { let radius = POINT_RADIUS * 2 / window.cvat.player.geometry.scale; let scaledStroke = STROKE_WIDTH / window.cvat.player.geometry.scale; this._shapeViewGroup.circle(radius).move(point.x - radius / 2, point.y - radius / 2) @@ -344,6 +401,7 @@ class ShapeBufferView { _moveShapeView(pos) { let rect = this._shapeView.node.getBBox(); + this._shapeView.move(pos.x - rect.width / 2, pos.y - rect.height / 2); if (this._shapeViewGroup) { let rect = this._shapeViewGroup.node.getBBox(); @@ -362,25 +420,58 @@ class ShapeBufferView { _enableEvents() { this._frameContent.on('mousemove.buffer', (e) => { - let pos = translateSVGPos(this._frameContent.node, e.clientX, e.clientY); + let pos = window.cvat.translate.point.clientToCanvas(this._frameContent.node, e.clientX, e.clientY); this._shapeView.style('visibility', ''); this._moveShapeView(pos); }); this._frameContent.on('mousedown.buffer', (e) => { if (e.which != 1) return; - let rect = this._shapeView.node.getBBox(); if (this._shape.type != 'box') { - let points = PolyShapeModel.convertStringToNumberArray(this._shapeView.attr('points')); - for (let point of points) { - point.x = Math.clamp(point.x, 0, window.cvat.player.geometry.frameWidth); - point.y = Math.clamp(point.y, 0, window.cvat.player.geometry.frameHeight); + let actualPoints = window.cvat.translate.points.canvasToActual(this._shapeView.attr('points')); + let frameWidth = window.cvat.player.geometry.frameWidth; + let frameHeight = window.cvat.player.geometry.frameHeight; + + actualPoints = PolyShapeModel.convertStringToNumberArray(actualPoints); + for (let point of actualPoints) { + point.x = Math.clamp(point.x, 0, frameWidth); + point.y = Math.clamp(point.y, 0, frameHeight); + } + actualPoints = PolyShapeModel.convertNumberArrayToString(actualPoints); + + // Set clamped points to a view in order to get an updated bounding box for a poly shape + this._shapeView.attr('points', window.cvat.translate.points.actualToCanvas(actualPoints)); + + // Get an updated bounding box for check it area + let polybox = this._shapeView.node.getBBox(); + let w = polybox.width; + let h = polybox.height; + let area = w * h; + let type = this._shape.type; + + if (area >= AREA_TRESHOLD || type === 'points' || type === 'polyline' && (w >= AREA_TRESHOLD || h >= AREA_TRESHOLD)) { + this._controller.pasteToFrame(e, null, actualPoints); + } + else { + this._controller.pasteToFrame(e, null, null); } - points = PolyShapeModel.convertNumberArrayToString(points); - this._controller.pasteToFrame(e, rect, points); } else { - this._controller.pasteToFrame(e, rect); + let frameWidth = window.cvat.player.geometry.frameWidth; + let frameHeight = window.cvat.player.geometry.frameHeight; + let rect = window.cvat.translate.box.canvasToActual(this._shapeView.node.getBBox()); + let box = {}; + box.xtl = Math.clamp(rect.x, 0, frameWidth); + box.ytl = Math.clamp(rect.y, 0, frameHeight); + box.xbr = Math.clamp(rect.x + rect.width, 0, frameWidth); + box.ybr = Math.clamp(rect.y + rect.height, 0, frameHeight); + + if ((box.xbr - box.xtl) * (box.ybr - box.ytl) >= AREA_TRESHOLD) { + this._controller.pasteToFrame(e, box, null); + } + else { + this._controller.pasteToFrame(e, null, null); + } } }); diff --git a/cvat/apps/engine/static/engine/js/shapeCollection.js b/cvat/apps/engine/static/engine/js/shapeCollection.js index 084b6da6..ab1fe14f 100644 --- a/cvat/apps/engine/static/engine/js/shapeCollection.js +++ b/cvat/apps/engine/static/engine/js/shapeCollection.js @@ -8,7 +8,7 @@ "use strict"; class ShapeCollectionModel extends Listener { - constructor() { + constructor(idGenereator) { super('onCollectionUpdate', () => this); this._annotationShapes = {}; this._groups = {}; @@ -51,10 +51,10 @@ class ShapeCollectionModel extends Listener { this._colorIdx = 0; this._filter = new FilterModel(() => this.update()); this._splitter = new ShapeSplitter(); - } - - _nextIdx() { - return this._idx++; + this._initialShapes = {}; + this._exportedShapes = {}; + this._shapesToDelete = createExportContainer(); + this._idGen = idGenereator; } _nextGroupIdx() { @@ -121,7 +121,7 @@ class ShapeCollectionModel extends Listener { } } - this._currentShapes = this._filter.filter(this._currentShapes).reverse(); + this._currentShapes = this._filter.filter(this._currentShapes); this.notify(); } @@ -146,6 +146,38 @@ class ShapeCollectionModel extends Listener { } } + // Common code for switchActiveOccluded(), switchActiveKeyframe(), switchActiveLock() and switchActiveOutside() + _selectActive() { + let shape = null; + if (this._activeAAMShape) { + shape = this._activeAAMShape; + } + else { + this.selectShape(this._lastPos, false); + if (this._activeShape) { + shape = this._activeShape; + } + } + + return shape; + } + + _importShape(shape, shapeType, udpateInitialState) { + let importedShape = this.add(shape, shapeType); + if (udpateInitialState) { + if (shape.id === -1) { + const toDelete = getExportTargetContainer(ExportType.delete, importedShape.type, this._shapesToDelete); + toDelete.push(shape.id); + } + else { + this._initialShapes[shape.id] = { + type: importedShape.type, + exportedString: importedShape.export(), + }; + } + } + } + colorsByGroup(groupId) { // If group id of shape is 0 (default value), then shape not contained in a group if (!groupId) { @@ -184,86 +216,87 @@ class ShapeCollectionModel extends Listener { } } - import(data) { + import(data, udpateInitialState=false) { for (let box of data.boxes) { - this.add(box, 'annotation_box'); + this._importShape(box, 'annotation_box', udpateInitialState); } - for (let box_path of data.box_paths) { - this.add(box_path, 'interpolation_box'); + for (let boxPath of data.box_paths) { + this._importShape(boxPath, 'interpolation_box', udpateInitialState); } for (let points of data.points) { - this.add(points, 'annotation_points'); + this._importShape(points, 'annotation_points', udpateInitialState); } - for (let points_path of data.points_paths) { - this.add(points_path, 'interpolation_points'); + for (let pointsPath of data.points_paths) { + this._importShape(pointsPath, 'interpolation_points', udpateInitialState); } for (let polygon of data.polygons) { - this.add(polygon, 'annotation_polygon'); + this._importShape(polygon, 'annotation_polygon', udpateInitialState); } - for (let polygon_path of data.polygon_paths) { - this.add(polygon_path, 'interpolation_polygon'); + for (let polygonPath of data.polygon_paths) { + this._importShape(polygonPath, 'interpolation_polygon', udpateInitialState); } for (let polyline of data.polylines) { - this.add(polyline, 'annotation_polyline'); + this._importShape(polyline, 'annotation_polyline', udpateInitialState); } - for (let polyline_path of data.polyline_paths) { - this.add(polyline_path, 'interpolation_polyline'); + for (let polylinePath of data.polyline_paths) { + this._importShape(polylinePath, 'interpolation_polyline', udpateInitialState); } this.notify(); return this; } + confirmExportedState() { + this._initialShapes = this._exportedShapes; + this._shapesToDelete = createExportContainer(); + } export() { - let response = { - "boxes": [], - "box_paths": [], - "points": [], - "points_paths": [], - "polygons": [], - "polygon_paths": [], - "polylines": [], - "polyline_paths": [], - }; - - for (let shape of this._shapes) { - if (shape.removed) continue; - switch (shape.type) { - case 'annotation_box': - response.boxes.push(shape.export()); - break; - case 'interpolation_box': - response.box_paths.push(shape.export()); - break; - case 'annotation_points': - response.points.push(shape.export()); - break; - case 'interpolation_points': - response.points_paths.push(shape.export()); - break; - case 'annotation_polygon': - response.polygons.push(shape.export()); - break; - case 'interpolation_polygon': - response.polygon_paths.push(shape.export()); - break; - case 'annotation_polyline': - response.polylines.push(shape.export()); - break; - case 'interpolation_polyline': - response.polyline_paths.push(shape.export()); + const response = createExportContainer(); + + for (const shape of this._shapes) { + let targetExportContainer = undefined; + if (!shape._removed) { + if (!(shape.id in this._initialShapes)) { + targetExportContainer = getExportTargetContainer(ExportType.create, shape.type, response); + } else if (JSON.stringify(this._initialShapes[shape.id].exportedString) !== JSON.stringify(shape.export())) { + targetExportContainer = getExportTargetContainer(ExportType.update, shape.type, response); + } else { + continue; + } + targetExportContainer.push(shape.export()); } + else if (shape.id in this._initialShapes) { + targetExportContainer = getExportTargetContainer(ExportType.delete, shape.type, response); + targetExportContainer.push(shape.id); + } + else { + continue; + } + } + for (const shapeType in this._shapesToDelete.delete) { + const shapes = this._shapesToDelete.delete[shapeType]; + response.delete[shapeType].push.apply(response.delete[shapeType], shapes); } - return JSON.stringify(response); + return response; + } + + exportAll() { + const response = createExportContainer(); + for (const shape of this._shapes) { + if (!shape._removed) { + getExportTargetContainer(ExportType.create, shape.type, response).push(shape.export()); + } + } + return response.create; } find(direction) { @@ -322,15 +355,39 @@ class ShapeCollectionModel extends Listener { } hasUnsavedChanges() { - return md5(this.export()) !== this._hash; + const exportData = this.export(); + for (const actionType in ExportType) { + for (const shapes of Object.values(exportData[actionType])) { + if (shapes.length) { + return true; + } + } + } + + return false; } - updateHash() { - this._hash = md5(this.export()); + updateExportedState() { + this._exportedShapes = {}; + + for (const shape of this._shapes) { + if (!shape.removed) { + this._exportedShapes[shape.id] = { + type: shape.type, + exportedString: shape.export(), + }; + } + } return this; } empty() { + for (const shapeId in this._initialShapes) { + const exportTarget = getExportTargetContainer(ExportType.delete, this._initialShapes[shapeId].type, this._shapesToDelete); + exportTarget.push(+shapeId); + } + + this._initialShapes = {}; this._annotationShapes = {}; this._interpolationShapes = []; this._shapes = []; @@ -340,7 +397,9 @@ class ShapeCollectionModel extends Listener { } add(data, type) { - let model = buildShapeModel(data, type, this._nextIdx(), this.nextColor()); + let id = 'id' in data && data.id !== -1 ? data.id : this._idGen.next(); + + let model = buildShapeModel(data, type, id, this.nextColor()); if (type.startsWith('interpolation')) { this._interpolationShapes.push(model); } @@ -358,6 +417,7 @@ class ShapeCollectionModel extends Listener { this._groups[groupIdx] = this._groups[groupIdx] || []; this._groups[groupIdx].push(model); } + return model; } selectShape(pos, noActivation) { @@ -445,6 +505,9 @@ class ShapeCollectionModel extends Listener { } this._frame = frame; this._interpolate(); + if (!window.cvat.mode) { + this.selectShape(this._lastPos, false); + } } else { this._clear(); @@ -594,16 +657,7 @@ class ShapeCollectionModel extends Listener { } switchActiveLock() { - let shape = null; - if (this._activeAAMShape) { - shape = this._activeAAMShape; - } - else { - this.selectShape(this._lastPos, false); - if (this._activeShape) { - shape = this._activeShape; - } - } + let shape = this._selectActive(); if (shape) { shape.switchLock(); @@ -614,10 +668,12 @@ class ShapeCollectionModel extends Listener { } } - switchAllLock() { + switchObjectsLock(labelId) { this.resetActive(); let value = true; - for (let shape of this._currentShapes) { + + let shapes = Number.isInteger(labelId) ? this._currentShapes.filter((el) => el.model.label === labelId) : this._currentShapes; + for (let shape of shapes) { if (shape.model.removed) continue; value = value && shape.model.lock; if (!value) break; @@ -628,7 +684,7 @@ class ShapeCollectionModel extends Listener { value: !value, }); - for (let shape of this._currentShapes) { + for (let shape of shapes) { if (shape.model.removed) continue; if (shape.model.lock === value) { shape.model.switchLock(); @@ -637,39 +693,40 @@ class ShapeCollectionModel extends Listener { } switchActiveOccluded() { - let shape = null; - if (this._activeAAMShape) { - shape = this._activeAAMShape; - } - else { - this.selectShape(this._lastPos, false); - if (this._activeShape && !this._activeShape.lock) { - shape = this._activeShape; - } + let shape = this._selectActive(); + if (shape && !shape.lock) { + shape.switchOccluded(window.cvat.player.frames.current); } + } - if (shape) { - shape.switchOccluded(window.cvat.player.frames.current); + switchActiveKeyframe() { + let shape = this._selectActive(); + if (shape && shape.type === 'interpolation_box' && !shape.lock) { + shape.switchKeyFrame(window.cvat.player.frames.current); } } - switchActiveHide() { - if (this._activeAAMShape) { - return; + switchActiveOutside() { + let shape = this._selectActive(); + if (shape && shape.type === 'interpolation_box' && !shape.lock) { + shape.switchOutside(window.cvat.player.frames.current); } + } - this.selectShape(this._lastPos, false); - if (this._activeShape) { - this._activeShape.switchHide(); + switchActiveHide() { + let shape = this._selectActive(); + if (shape) { + shape.switchHide(); } } - switchAllHide() { + switchObjectsHide(labelId) { this.resetActive(); let hiddenShape = true; let hiddenText = true; - for (let shape of this._shapes) { + let shapes = Number.isInteger(labelId) ? this._shapes.filter((el) => el.label === labelId) : this._shapes; + for (let shape of shapes) { if (shape.removed) continue; hiddenShape = hiddenShape && shape.hiddenShape; @@ -680,7 +737,7 @@ class ShapeCollectionModel extends Listener { if (!hiddenShape) { // any shape visible - for (let shape of this._shapes) { + for (let shape of shapes) { if (shape.removed) continue; hiddenText = hiddenText && shape.hiddenText; @@ -691,7 +748,7 @@ class ShapeCollectionModel extends Listener { if (!hiddenText) { // any shape text visible - for (let shape of this._shapes) { + for (let shape of shapes) { if (shape.removed) continue; while (shape.hiddenShape || !shape.hiddenText) { shape.switchHide(); @@ -700,7 +757,7 @@ class ShapeCollectionModel extends Listener { } else { // all shape text invisible - for (let shape of this._shapes) { + for (let shape of shapes) { if (shape.removed) continue; while (!shape.hiddenShape) { shape.switchHide(); @@ -710,7 +767,7 @@ class ShapeCollectionModel extends Listener { } else { // all shapes invisible - for (let shape of this._shapes) { + for (let shape of shapes) { if (shape.removed) continue; while (shape.hiddenShape || shape.hiddenText) { shape.switchHide(); @@ -719,19 +776,14 @@ class ShapeCollectionModel extends Listener { } } + + removePointFromActiveShape(idx) { if (this._activeShape && !this._activeShape.lock) { this._activeShape.removePoint(idx); } } - clonePointForActiveShape(idx, direction, insertPoint) { - if (this._activeShape && !this._activeShape.lock) { - return this._activeShape.clonePoint(idx, direction, insertPoint); - } - else return null; - } - split() { if (this._activeShape) { if (!this._activeShape.lock && this._activeShape.type.split('_')[0] === 'interpolation') { @@ -808,6 +860,10 @@ class ShapeCollectionModel extends Listener { get shapes() { return this._shapes; } + + get maxId() { + return Math.max(-1, ...this._shapes.map( shape => shape.id )); + } } class ShapeCollectionController { @@ -829,16 +885,20 @@ class ShapeCollectionController { this.switchActiveOccluded(); }.bind(this)); + let switchActiveKeyframeHandler = Logger.shortkeyLogDecorator(function() { + this.switchActiveKeyframe(); + }.bind(this)); + + let switchActiveOutsideHandler = Logger.shortkeyLogDecorator(function() { + this.switchActiveOutside(); + }.bind(this)); + let switchHideHandler = Logger.shortkeyLogDecorator(function() { - if (!window.cvat.mode || window.cvat.mode === 'aam') { - this._model.switchActiveHide(); - } + this.switchActiveHide(); }.bind(this)); let switchAllHideHandler = Logger.shortkeyLogDecorator(function() { - if (!window.cvat.mode || window.cvat.mode === 'aam') { - this._model.switchAllHide(); - } + this.switchAllHide(); }.bind(this)); let removeActiveHandler = Logger.shortkeyLogDecorator(function(e) { @@ -889,6 +949,8 @@ class ShapeCollectionController { Mousetrap.bind(shortkeys["switch_lock_property"].value, switchLockHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["switch_all_lock_property"].value, switchAllLockHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["switch_occluded_property"].value, switchOccludedHandler.bind(this), 'keydown'); + Mousetrap.bind(shortkeys["switch_active_keyframe"].value, switchActiveKeyframeHandler.bind(this), 'keydown'); + Mousetrap.bind(shortkeys["switch_active_outside"].value, switchActiveOutsideHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["switch_hide_mode"].value, switchHideHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["switch_all_hide_mode"].value, switchAllHideHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["change_default_label"].value, switchDefaultLabelHandler.bind(this), 'keydown'); @@ -909,9 +971,27 @@ class ShapeCollectionController { } } + switchActiveKeyframe() { + if (!window.cvat.mode) { + this._model.switchActiveKeyframe(); + } + } + + switchActiveOutside() { + if (!window.cvat.mode) { + this._model.switchActiveOutside(); + } + } + switchAllLock() { if (!window.cvat.mode || window.cvat.mode === 'aam') { - this._model.switchAllLock(); + this._model.switchObjectsLock(); + } + } + + switchLabelLock(labelId) { + if (!window.cvat.mode || window.cvat.mode === 'aam') { + this._model.switchObjectsLock(labelId); } } @@ -921,6 +1001,24 @@ class ShapeCollectionController { } } + switchAllHide() { + if (!window.cvat.mode || window.cvat.mode === 'aam') { + this._model.switchObjectsHide(); + } + } + + switchLabelHide(lableId) { + if (!window.cvat.mode || window.cvat.mode === 'aam') { + this._model.switchObjectsHide(lableId); + } + } + + switchActiveHide() { + if (!window.cvat.mode || window.cvat.mode === 'aam') { + this._model.switchActiveHide(); + } + } + switchActiveColor() { let colorByInstanceInput = $('#colorByInstanceRadio'); let colorByGroupInput = $('#colorByGroupRadio'); @@ -968,10 +1066,6 @@ class ShapeCollectionController { this._model.removePointFromActiveShape(idx); } - clonePointForActiveShape(idx, direction, insertPoint) { - return this._model.clonePointForActiveShape(idx, direction, insertPoint); - } - splitForActive() { this._model.split(); } @@ -999,17 +1093,23 @@ class ShapeCollectionController { get filterController() { return this._filterController; } + + get activeShape() { + return this._model.activeShape; + } } class ShapeCollectionView { constructor(collectionModel, collectionController) { collectionModel.subscribe(this); this._controller = collectionController; + this._frameBackground = $('#frameBackground'); this._frameContent = SVG.adopt($('#frameContent')[0]); this._UIContent = $('#uiContent'); this._labelsContent = $('#labelsContent'); this._showAllInterpolationBox = $('#showAllInterBox'); this._fillOpacityRange = $('#fillOpacityRange'); + this._selectedFillOpacityRange = $('#selectedFillOpacityRange'); this._blackStrokeCheckbox = $('#blackStrokeCheckbox'); this._colorByInstanceRadio = $('#colorByInstanceRadio'); this._colorByGroupRadio = $('#colorByGroupRadio'); @@ -1017,6 +1117,10 @@ class ShapeCollectionView { this._colorByGroupCheckbox = $('#colorByGroupCheckbox'); this._filterView = new FilterView(this._controller.filterController); this._currentViews = []; + + this._currentModels = []; + this._frameMarker = null; + this._activeShapeUI = null; this._scale = 1; this._colorSettings = { @@ -1031,7 +1135,7 @@ class ShapeCollectionView { let value = Math.clamp(+e.target.value, +e.target.min, +e.target.max); e.target.value = value; if (value >= 0) { - this._colorSettings["fill-opacity"] = value / 5; + this._colorSettings["fill-opacity"] = value; delete this._colorSettings['white-opacity']; for (let view of this._currentViews) { @@ -1040,7 +1144,7 @@ class ShapeCollectionView { } else { value *= -1; - this._colorSettings["white-opacity"] = value / 5; + this._colorSettings["white-opacity"] = value; for (let view of this._currentViews) { view.updateColorSettings(this._colorSettings); @@ -1048,6 +1152,16 @@ class ShapeCollectionView { } }); + this._selectedFillOpacityRange.on('input', (e) => { + let value = Math.clamp(+e.target.value, +e.target.min, +e.target.max); + e.target.value = value; + this._colorSettings["selected-fill-opacity"] = value; + + for (let view of this._currentViews) { + view.updateColorSettings(this._colorSettings); + } + }); + this._blackStrokeCheckbox.on('click', (e) => { this._colorSettings["black-stroke"] = e.target.checked; @@ -1103,19 +1217,33 @@ class ShapeCollectionView { return; } - let pos = translateSVGPos(this._frameContent.node, e.clientX, e.clientY); - if (!window.cvat.mode) { - this._controller.selectShape(pos, false); - } + let frameHeight = window.cvat.player.geometry.frameHeight; + let frameWidth = window.cvat.player.geometry.frameWidth; + let pos = window.cvat.translate.point.clientToCanvas(this._frameBackground[0], e.clientX, e.clientY); + if (pos.x >= 0 && pos.y >= 0 && pos.x <= frameWidth && pos.y <= frameHeight) { + if (!window.cvat.mode) { + this._controller.selectShape(pos, false); + } - this._controller.setLastPosition(pos); + this._controller.setLastPosition(pos); + } }.bind(this)); $('#shapeContextMenu li').click((e) => { - let menu = $('#shapeContextMenu'); - menu.hide(100); + $('.custom-menu').hide(100); switch($(e.target).attr("action")) { + case "object_url": { + let active = this._controller.activeShape; + if (active) { + window.cvat.search.set('frame', window.cvat.player.frames.current); + window.cvat.search.set('filter', `*[id="${active.id}"]`); + copyToClipboard(window.cvat.search.toString()); + window.cvat.search.set('frame', null); + window.cvat.search.set('filter', null); + } + break; + } case "change_color": this._controller.switchActiveColor(); break; @@ -1158,67 +1286,99 @@ class ShapeCollectionView { $('#pointContextMenu li').click((e) => { let menu = $('#pointContextMenu'); let idx = +menu.attr('point_idx'); - menu.hide(100); + $('.custom-menu').hide(100); switch($(e.target).attr("action")) { case "remove_point": this._controller.removePointFromActiveShape(idx); break; - case "clone_point_before": - this._controller.clonePointForActiveShape(idx, 'before', true); - break; - case "clone_point_after": - this._controller.clonePointForActiveShape(idx, 'after', true); - break; } }); - $('#pointContextMenu').mouseout(() => { - $(this._frameContent.node).find('.tmp_inserted_point').remove(); - }); + let labels = window.cvat.labelsInfo.labels(); + for (let labelId in labels) { + let lockButton = $(``) + .addClass('graphicButton lockButton') + .attr('title', 'Switch lock for all object with same label') + .on('click', () => { + this._controller.switchLabelLock(+labelId); + }); + + lockButton[0].updateState = function(button, labelId) { + let models = this._currentModels.filter((el) => el.label === labelId); + let locked = true; + for (let model of models) { + locked = locked && model.lock; + if (!locked) { + break; + } + } - $('#pointContextMenu li').mouseover((e) => { - $(this._frameContent.node).find('.tmp_inserted_point').remove(); - let menu = $('#pointContextMenu'); - let idx = +menu.attr('point_idx'); - let point = null; + if (!locked) { + button.removeClass('locked'); + } + else { + button.addClass('locked'); + } + }.bind(this, lockButton, +labelId); + + let hiddenButton = $(``) + .addClass('graphicButton hiddenButton') + .attr('title', 'Switch hide for all object with same label') + .on('click', () => { + this._controller.switchLabelHide(+labelId); + }); + + hiddenButton[0].updateState = function(button, labelId) { + let models = this._currentModels.filter((el) => el.label === labelId); + let hiddenShape = true; + let hiddenText = true; + for (let model of models) { + hiddenShape = hiddenShape && model.hiddenShape; + hiddenText = hiddenText && model.hiddenText; + if (!hiddenShape && !hiddenText) { + break; + } + } - switch($(e.target).attr("action")) { - case "clone_point_before": - point = this._controller.clonePointForActiveShape(idx, 'before', false); - if (point) { - this._frameContent.circle(POINT_RADIUS * 2 / this._scale).center(point.x, point.y) - .addClass('tmp_inserted_point tempMarker').fill('white').stroke('black').attr({ - 'stroke-width': STROKE_WIDTH / this._scale - }); + if (hiddenShape) { + button.removeClass('hiddenText'); + button.addClass('hiddenShape'); } - break; - case "clone_point_after": - point = this._controller.clonePointForActiveShape(idx, 'after', false); - if (point) { - this._frameContent.circle(POINT_RADIUS * 2 / this._scale).center(point.x, point.y) - .addClass('tmp_inserted_point tempMarker').fill('white').stroke('black').attr({ - 'stroke-width': STROKE_WIDTH / this._scale - }); + else if (hiddenText) { + button.addClass('hiddenText'); + button.removeClass('hiddenShape'); } - break; - } - }); + else { + button.removeClass('hiddenText hiddenShape'); + } + }.bind(this, hiddenButton, +labelId); + + let buttonBlock = $('
') + .append(lockButton).append(hiddenButton) + .addClass('buttonBlockOfLabelUI'); + + let title = $(``); + + let mainDiv = $('
').addClass('labelContentElement h2 regular hidden') + .css({ + 'background-color': collectionController.colorsByGroup(+window.cvat.labelsInfo.labelColorIdx(+labelId)), + }).attr({ + 'label_id': labelId, + }).on('mouseover mouseup', () => { + mainDiv.addClass('highlightedUI'); + collectionModel.selectAllWithLabel(+labelId); + }).on('mouseout mousedown', () => { + mainDiv.removeClass('highlightedUI'); + collectionModel.deselectAll(); + }).append(title).append(buttonBlock); + + mainDiv[0].updateState = function() { + lockButton[0].updateState(); + hiddenButton[0].updateState(); + }; - let labels = window.cvat.labelsInfo.labels(); - for (let labelId in labels) { - let div = $('
').addClass('labelContentElement h2 regular hidden').css({ - 'background-color': collectionController.colorsByGroup(+window.cvat.labelsInfo.labelColorIdx(+labelId)), - }).attr({ - 'label_id': labelId, - }).on('mouseover mouseup', () => { - div.addClass('highlightedUI'); - collectionModel.selectAllWithLabel(+labelId); - }).on('mouseout mousedown', () => { - div.removeClass('highlightedUI'); - collectionModel.deselectAll(); - }).append( $(``) ); - div.appendTo(this._labelsContent); + this._labelsContent.append(mainDiv); } let sidePanelObjectsButton = $('#sidePanelObjectsButton'); @@ -1239,14 +1399,22 @@ class ShapeCollectionView { }); } - onCollectionUpdate(collection) { + _updateLabelUIs() { this._labelsContent.find('.labelContentElement').addClass('hidden'); - for (let view of this._currentViews) { - view.unsubscribe(this); - view.controller().model().unsubscribe(view); - view.erase(); + let labels = new Set(this._currentModels.map((el) => el.label)); + for (let label of labels) { + this._labelsContent.find(`.labelContentElement[label_id="${label}"]`).removeClass('hidden'); } + this._updateLabelUIsState(); + } + + _updateLabelUIsState() { + for (let labelUI of this._labelsContent.find('.labelContentElement:not(.hidden)')) { + labelUI.updateState(); + } + } + onCollectionUpdate(collection) { // Save parents and detach elements from DOM // in order to increase performance in the buildShapeView function let parents = { @@ -1254,25 +1422,69 @@ class ShapeCollectionView { shapes: this._frameContent.node.parentNode }; - this._frameContent.node.parent = null; - this._UIContent.detach(); + let oldModels = this._currentModels; + let oldViews = this._currentViews; + let newShapes = collection.currentShapes; + let newModels = newShapes.map((el) => el.model); + + let frameChanged = this._frameMarker != window.cvat.player.frames.current; + + if (frameChanged) { + this._frameContent.node.parent = null; + this._UIContent.detach(); + } this._currentViews = []; - for (let shape of collection.currentShapes) { - let model = shape.model; + this._currentModels = []; + + // Check which old models are new models + for (let oldIdx = 0; oldIdx < oldModels.length; oldIdx ++) { + let newIdx = newModels.indexOf(oldModels[oldIdx]); + let significantUpdate = ['remove', 'keyframe', 'outside'].includes(oldModels[oldIdx].updateReason); + + // Changed frame means a changed position in common case. We need redraw it. + // If shape has been restored after removing, it view already removed. We need redraw it. + if (newIdx === -1 || significantUpdate || frameChanged) { + let view = oldViews[oldIdx]; + view.unsubscribe(this); + view.controller().model().unsubscribe(view); + view.erase(); + + if (newIdx != -1 && (frameChanged || significantUpdate)) { + drawView.call(this, newShapes[newIdx], newModels[newIdx]); + } + } + else { + this._currentViews.push(oldViews[oldIdx]); + this._currentModels.push(oldModels[oldIdx]); + } + } + + // Now we need draw new models which aren't on previous collection + for (let newIdx = 0; newIdx < newModels.length; newIdx ++) { + if (!this._currentModels.includes(newModels[newIdx])) { + drawView.call(this, newShapes[newIdx], newModels[newIdx]); + } + } + + if (frameChanged) { + parents.shapes.append(this._frameContent.node); + parents.uis.prepend(this._UIContent); + } + + ShapeCollectionView.sortByZOrder(); + this._frameMarker = window.cvat.player.frames.current; + this._updateLabelUIs(); + + function drawView(shape, model) { let view = buildShapeView(model, buildShapeController(model), this._frameContent, this._UIContent); view.draw(shape.interpolation); view.updateColorSettings(this._colorSettings); - this._currentViews.push(view); model.subscribe(view); view.subscribe(this); - this._labelsContent.find(`.labelContentElement[label_id="${model.label}"]`).removeClass('hidden'); + this._currentViews.push(view); + this._currentModels.push(model); } - - parents.shapes.append(this._frameContent.node); - parents.uis.prepend(this._UIContent); - - ShapeCollectionView.sortByZOrder(); } onPlayerUpdate(player) { @@ -1301,18 +1513,43 @@ class ShapeCollectionView { } onShapeViewUpdate(view) { - if (view.dragging) { - window.cvat.mode = 'drag'; - } - else if (window.cvat.mode === 'drag') { - window.cvat.mode = null; + switch (view.updateReason) { + case 'drag': + if (view.dragging) { + window.cvat.mode = 'drag'; + } + else if (window.cvat.mode === 'drag') { + window.cvat.mode = null; + } + break; + case 'resize': + if (view.resize) { + window.cvat.mode = 'resize'; + } + else if (window.cvat.mode === 'resize') { + window.cvat.mode = null; + } + break; + case 'remove': { + let idx = this._currentViews.indexOf(view); + view.unsubscribe(this); + view.controller().model().unsubscribe(view); + view.erase(); + this._currentViews.splice(idx, 1); + this._currentModels.splice(idx, 1); + this._updateLabelUIs(); + break; } - - if (view.resize) { - window.cvat.mode = 'resize'; + case 'changelabel': { + this._updateLabelUIs(); + break; } - else if (window.cvat.mode === 'resize') { - window.cvat.mode = null; + case 'lock': + this._updateLabelUIsState(); + break; + case 'hidden': + this._updateLabelUIsState(); + break; } } diff --git a/cvat/apps/engine/static/engine/js/shapeCreator.js b/cvat/apps/engine/static/engine/js/shapeCreator.js index dc797fc2..28079b66 100644 --- a/cvat/apps/engine/static/engine/js/shapeCreator.js +++ b/cvat/apps/engine/static/engine/js/shapeCreator.js @@ -136,15 +136,7 @@ class ShapeCreatorController { this.switchCreateMode(false); }.bind(this)); - let closeDrawHandler = Logger.shortkeyLogDecorator(function(e) { - e.preventDefault(); - if (this._model.createMode) { - this.switchCreateMode(true); - } - }.bind(this)); - Mousetrap.bind(shortkeys["switch_draw_mode"].value, switchDrawHandler.bind(this), 'keydown'); - Mousetrap.bind(shortkeys["cancel_draw_mode"].value, closeDrawHandler.bind(this), 'keydown'); } } @@ -195,8 +187,7 @@ class ShapeCreatorView { let shortkeys = window.cvat.config.shortkeys; this._createButton.attr('title', ` - ${shortkeys['switch_draw_mode'].view_value} - ${shortkeys['switch_draw_mode'].description}` + `\n` + - `${shortkeys['cancel_draw_mode'].view_value} - ${shortkeys['cancel_draw_mode'].description}`); + ${shortkeys['switch_draw_mode'].view_value} - ${shortkeys['switch_draw_mode'].description}`); this._labelSelector.attr('title', ` ${shortkeys['change_default_label'].view_value} - ${shortkeys['change_default_label'].description}`); @@ -243,28 +234,18 @@ class ShapeCreatorView { this._polyShapeSizeInput.on('keydown', function(e) { e.stopPropagation(); }); - - this._playerFrame.on('mousemove', function(e) { - // Save last coordinates in order to draw aim - this._aimCoord = translateSVGPos(this._frameContent.node, e.clientX, e.clientY); - if (this._aim) { - this._aim.x.attr({ - y1: this._aimCoord.y, - y2: this._aimCoord.y, - }); - - this._aim.y.attr({ - x1: this._aimCoord.x, - x2: this._aimCoord.x, - }); - } - }.bind(this)); } _createPolyEvents() { // If number of points for poly shape specified, use it. // Dicrement number on draw new point events. Drawstart trigger when create first point + let lastPoint = { + x: null, + y: null, + }; + + let numberOfPoints = 0; if (this._polyShapeSize) { let size = this._polyShapeSize; @@ -287,40 +268,90 @@ class ShapeCreatorView { // Callbacks for point scale this._drawInstance.on('drawstart', this._rescaleDrawPoints.bind(this)); this._drawInstance.on('drawpoint', this._rescaleDrawPoints.bind(this)); + + this._drawInstance.on('drawstart', (e) => { + lastPoint = { + x: e.detail.event.clientX, + y: e.detail.event.clientY, + }; + numberOfPoints ++; + }); + + this._drawInstance.on('drawpoint', (e) => { + lastPoint = { + x: e.detail.event.clientX, + y: e.detail.event.clientY, + }; + numberOfPoints ++; + }); + this._frameContent.on('mousedown.shapeCreator', (e) => { if (e.which === 3) { + let lenBefore = this._drawInstance.array().value.length; this._drawInstance.draw('undo'); + let lenAfter = this._drawInstance.array().value.length; + if (lenBefore != lenAfter) { + numberOfPoints --; + } + } + }); + + + this._frameContent.on('mousemove.shapeCreator', (e) => { + if (e.shiftKey && ['polygon', 'polyline'].includes(this._type)) { + if (lastPoint.x === null || lastPoint.y === null) { + this._drawInstance.draw('point', e); + } + else { + let delta = Math.sqrt(Math.pow(e.clientX - lastPoint.x, 2) + Math.pow(e.clientY - lastPoint.y, 2)); + let deltaTreshold = 15; + if (delta > deltaTreshold) { + this._drawInstance.draw('point', e); + lastPoint = { + x: e.clientX, + y: e.clientY + }; + } + } } }); this._drawInstance.on('drawstop', () => { this._frameContent.off('mousedown.shapeCreator'); + this._frameContent.off('mousemove.shapeCreator'); }); // Also we need callback on drawdone event for get points this._drawInstance.on('drawdone', function(e) { - let points = PolyShapeModel.convertStringToNumberArray(e.target.getAttribute('points')); - for (let point of points) { - point.x = Math.clamp(point.x, 0, window.cvat.player.geometry.frameWidth); - point.y = Math.clamp(point.y, 0, window.cvat.player.geometry.frameHeight); - } + let actualPoints = window.cvat.translate.points.canvasToActual(e.target.getAttribute('points')); + actualPoints = PolyShapeModel.convertStringToNumberArray(actualPoints); // Min 2 points for polyline and 3 points for polygon - if (points.length) { - if (this._type === 'polyline' && points.length < 2) { + if (actualPoints.length) { + if (this._type === 'polyline' && actualPoints.length < 2) { showMessage("Min 2 points must be for polyline drawing."); } - else if (this._type === 'polygon' && points.length < 3) { + else if (this._type === 'polygon' && actualPoints.length < 3) { showMessage("Min 3 points must be for polygon drawing."); } else { - points = PolyShapeModel.convertNumberArrayToString(points); - - // Update points in view in order to get updated box - e.target.setAttribute('points', points); - let box = e.target.getBBox(); - if (box.width * box.height >= AREA_TRESHOLD || this._type === 'points' || - this._type === 'polyline' && (box.width >= AREA_TRESHOLD || box.height >= AREA_TRESHOLD)) { - this._controller.finish({points: e.target.getAttribute('points')}, this._type); + let frameWidth = window.cvat.player.geometry.frameWidth; + let frameHeight = window.cvat.player.geometry.frameHeight; + for (let point of actualPoints) { + point.x = Math.clamp(point.x, 0, frameWidth); + point.y = Math.clamp(point.y, 0, frameHeight); + } + actualPoints = PolyShapeModel.convertNumberArrayToString(actualPoints); + + // Update points in a view in order to get an updated box + e.target.setAttribute('points', window.cvat.translate.points.actualToCanvas(actualPoints)); + let polybox = e.target.getBBox(); + let w = polybox.width; + let h = polybox.height; + let area = w * h; + let type = this._type; + + if (area >= AREA_TRESHOLD || type === 'points' && numberOfPoints || type === 'polyline' && (w >= AREA_TRESHOLD || h >= AREA_TRESHOLD)) { + this._controller.finish({points: actualPoints}, type); } } } @@ -342,19 +373,22 @@ class ShapeCreatorView { sizeUI.rm(); sizeUI = null; } - let result = { - xtl: Math.max(0, +e.target.getAttribute('x')), - ytl: Math.max(0, +e.target.getAttribute('y')), - xbr: Math.min(window.cvat.player.geometry.frameWidth, +e.target.getAttribute('x') + +e.target.getAttribute('width')), - ybr: Math.min(window.cvat.player.geometry.frameHeight, +e.target.getAttribute('y') + +e.target.getAttribute('height')), - }; + + let frameWidth = window.cvat.player.geometry.frameWidth; + let frameHeight = window.cvat.player.geometry.frameHeight; + let rect = window.cvat.translate.box.canvasToActual(e.target.getBBox()); + let box = {}; + box.xtl = Math.clamp(rect.x, 0, frameWidth); + box.ytl = Math.clamp(rect.y, 0, frameHeight); + box.xbr = Math.clamp(rect.x + rect.width, 0, frameWidth); + box.ybr = Math.clamp(rect.y + rect.height, 0, frameHeight); if (this._mode === 'interpolation') { - result.outside = false; + box.outside = false; } - if ((result.ybr - result.ytl) * (result.xbr - result.xtl) >= AREA_TRESHOLD) { - this._controller.finish(result, this._type); + if ((box.ybr - box.ytl) * (box.xbr - box.xtl) >= AREA_TRESHOLD) { + this._controller.finish(box, this._type); } this._controller.switchCreateMode(true); @@ -403,37 +437,6 @@ class ShapeCreatorView { throw Error(`Bad type found ${this._type}`); } - this._playerFrame.on('click.shapeCreation', (e) => { - if (e.target === this._playerFrame[0]) { - let original = e.originalEvent; - Object.defineProperty(original, 'clientX', { - value: original.clientX, - writable: true, - }); - - Object.defineProperty(original, 'clientY', { - value: original.clientY, - writable: true, - }); - - let svgNodePos = this._frameContent.node.getBoundingClientRect(); - - original.clientX = Math.clamp(original.clientX, svgNodePos.left, svgNodePos.right); - original.clientY = Math.clamp(original.clientY, svgNodePos.top, svgNodePos.bottom); - - if (this._type === 'box') { - this._drawInstance.draw(original); - } - else { - for (let point of this._drawInstance.array().value) { - point[0] = Math.clamp(point[0], 0, window.cvat.player.geometry.frameWidth); - point[1] = Math.clamp(point[1], 0, window.cvat.player.geometry.frameHeight); - } - this._drawInstance.draw('point', original); - } - } - }); - this._drawInstance.attr({ 'z_order': Number.MAX_SAFE_INTEGER, }); @@ -449,13 +452,13 @@ class ShapeCreatorView { _drawAim() { if (!(this._aim)) { this._aim = { - x: this._frameContent.line(0, this._aimCoord.y, window.cvat.player.geometry.frameWidth, this._aimCoord.y) + x: this._frameContent.line(0, this._aimCoord.y, this._frameContent.node.clientWidth, this._aimCoord.y) .attr({ 'stroke-width': STROKE_WIDTH / this._scale, 'stroke': 'red', 'z_order': Number.MAX_SAFE_INTEGER, }).addClass('aim'), - y: this._frameContent.line(this._aimCoord.x, 0, this._aimCoord.x, window.cvat.player.geometry.frameHeight) + y: this._frameContent.line(this._aimCoord.x, 0, this._aimCoord.x, this._frameContent.node.clientHeight) .attr({ 'stroke-width': STROKE_WIDTH / this._scale, 'stroke': 'red', @@ -481,6 +484,20 @@ class ShapeCreatorView { if (!['polygon', 'polyline', 'points'].includes(this._type)) { this._drawAim(); + this._playerFrame.on('mousemove.shapeCreatorAIM', (e) => { + this._aimCoord = window.cvat.translate.point.clientToCanvas(this._frameContent.node, e.clientX, e.clientY); + if (this._aim) { + this._aim.x.attr({ + y1: this._aimCoord.y, + y2: this._aimCoord.y, + }); + + this._aim.y.attr({ + x1: this._aimCoord.x, + x2: this._aimCoord.x, + }); + } + }); } this._createButton.text("Stop Creation"); @@ -488,18 +505,19 @@ class ShapeCreatorView { this._create(); } else { + this._playerFrame.off('mousemove.shapeCreatorAIM'); this._removeAim(); + this._aimCoord = { + x: 0, + y: 0 + }; this._cancel = true; this._createButton.text("Create Shape"); document.oncontextmenu = null; - this._playerFrame.off('click.shapeCreation'); if (this._drawInstance) { - // if need save current result for poly shape, do it. - // drawInstance and env will clean in the future when - // drawdone handler will call switchCreateMode with force argument - // also need draw min one point. Otherwise errors occur in SVG.draw.js on done event + // We save current result for poly shape if it's need + // drawInstance will be removed after save when drawdone handler calls switchCreateMode with force argument if (model.saveCurrent && this._type != 'box') { - // FIXME: Error occured in svg.draw.js if no points was drawed and done, cancel or stop action applied this._drawInstance.draw('done'); } else { diff --git a/cvat/apps/engine/static/engine/js/shapeFilter.js b/cvat/apps/engine/static/engine/js/shapeFilter.js index 9781812b..06310e8a 100644 --- a/cvat/apps/engine/static/engine/js/shapeFilter.js +++ b/cvat/apps/engine/static/engine/js/shapeFilter.js @@ -64,9 +64,11 @@ class FilterModel { } } - updateFilter(value) { + updateFilter(value, silent) { this._filter = value; - this._update(); + if (!silent) { + this._update(); + } } } @@ -75,22 +77,22 @@ class FilterController { this._model = filterModel; } - updateFilter(value) { + updateFilter(value, silent) { if (value.length) { + value = value.split('|').map(x => '/d:data/' + x).join('|').toLowerCase().replace(/-/g, "_"); try { document.evaluate(value, document, () => 'ns'); } catch (error) { return false; } - this._model.updateFilter(value); + this._model.updateFilter(value, silent); return true; } else { - this._model.updateFilter(value); + this._model.updateFilter('', silent); return true; } - } deactivate() { @@ -109,14 +111,12 @@ class FilterView { this._filterString.on('keypress keydown keyup', (e) => e.stopPropagation()); this._filterString.on('change', (e) => { let value = $.trim(e.target.value); - if (value.length) { - value = value.split('|').map(x => '/d:data/' + x).join('|').toLowerCase().replace(/-/g, "_"); - } - if (this._controller.updateFilter(value)) { + if (this._controller.updateFilter(value, false)) { this._filterString.css('color', 'green'); } else { this._filterString.css('color', 'red'); + this._controller.updateFilter('', false); } }); @@ -127,9 +127,19 @@ class FilterView { this._resetFilterButton.on('click', () => { this._filterString.prop('value', ''); - this._controller.updateFilter(''); + this._controller.updateFilter('', false); }); - } - + let initialFilter = window.cvat.search.get('filter'); + if (initialFilter) { + this._filterString.prop('value', initialFilter); + if (this._controller.updateFilter(initialFilter, true)) { + this._filterString.css('color', 'green'); + } + else { + this._filterString.prop('value', ''); + this._filterString.css('color', 'red'); + } + } + } } diff --git a/cvat/apps/engine/static/engine/js/shapeGrouper.js b/cvat/apps/engine/static/engine/js/shapeGrouper.js index deb264df..a7cbf9ee 100644 --- a/cvat/apps/engine/static/engine/js/shapeGrouper.js +++ b/cvat/apps/engine/static/engine/js/shapeGrouper.js @@ -109,12 +109,6 @@ class ShapeGrouperController { this.switch(); }.bind(this)); - let cancelGrouperHandler = Logger.shortkeyLogDecorator(function() { - if (this._model.active) { - this._model.cancel(); - } - }.bind(this)); - let resetGroupHandler = Logger.shortkeyLogDecorator(function() { if (this._model.active) { this._model.reset(); @@ -124,7 +118,6 @@ class ShapeGrouperController { }.bind(this)); Mousetrap.bind(shortkeys["switch_group_mode"].value, switchGrouperHandler.bind(this), 'keydown'); - Mousetrap.bind(shortkeys["cancel_group_mode"].value, cancelGrouperHandler.bind(this), 'keydown'); Mousetrap.bind(shortkeys["reset_group"].value, resetGroupHandler.bind(this), 'keydown'); } } @@ -160,7 +153,6 @@ class ShapeGrouperView { this._groupShapesButton.attr('title', ` ${shortkeys['switch_group_mode'].view_value} - ${shortkeys['switch_group_mode'].description}` + `\n` + - `${shortkeys['cancel_group_mode'].view_value} - ${shortkeys['cancel_group_mode'].description}` + `\n` + `${shortkeys['reset_group'].view_value} - ${shortkeys['reset_group'].description}`); grouperModel.subscribe(this); @@ -194,11 +186,12 @@ class ShapeGrouperView { _enableEvents() { this._frameContent.on('mousedown.grouper', (e) => { - this._initPoint = translateSVGPos(this._frameContent[0], e.clientX, e.clientY); + this._initPoint = window.cvat.translate.point.clientToCanvas(this._frameContent[0], e.clientX, e.clientY); }); this._frameContent.on('mousemove.grouper', (e) => { - let currentPoint = translateSVGPos(this._frameContent[0], e.clientX, e.clientY); + let currentPoint = window.cvat.translate.point.clientToCanvas(this._frameContent[0], e.clientX, e.clientY); + if (this._initPoint) { if (!this._rectSelector) { this._rectSelector = $(document.createElementNS('http://www.w3.org/2000/svg', 'rect')); @@ -270,4 +263,4 @@ class ShapeGrouperView { } } } -} \ No newline at end of file +} diff --git a/cvat/apps/engine/static/engine/js/shapeMerger.js b/cvat/apps/engine/static/engine/js/shapeMerger.js index 6386e477..8688c237 100644 --- a/cvat/apps/engine/static/engine/js/shapeMerger.js +++ b/cvat/apps/engine/static/engine/js/shapeMerger.js @@ -35,7 +35,6 @@ class ShapeMergerModel extends Listener { } } - start() { if (!window.cvat.mode) { window.cvat.mode = 'merge'; @@ -135,14 +134,19 @@ class ShapeMergerModel extends Listener { } ) ); - } - // if last is annotation box, push outside - if (shapeDict[sortedFrames[sortedFrames.length - 1]].shape.type === 'annotation_box') { - let copy = Object.assign({}, object.shapes[object.shapes.length - 1]); - copy.outside = true; - copy.frame += 1; - object.shapes.push(copy); + // push an outsided box after each annotation box if next frame is empty + let nextFrame = frame + 1; + let stopFrame = window.cvat.player.frames.stop; + let type = shapeDict[frame].shape.type; + if (type === 'annotation_box' && !(nextFrame in shapeDict) && nextFrame <= stopFrame) { + let copy = Object.assign({}, object.shapes[object.shapes.length - 1]); + copy.outside = true; + copy.frame += 1; + copy.z_order = this._collectionModel.zOrder(frame).max; + copy.attributes = []; + object.shapes.push(copy); + } } Logger.addEvent(Logger.EventType.mergeObjects, { @@ -225,14 +229,7 @@ class ShapeMergerController { this.switch(); }.bind(this)); - let cancelMergeHandler = Logger.shortkeyLogDecorator(function() { - if (this._model.mergeMode) { - this._model.cancel(); - } - }.bind(this)); - Mousetrap.bind(shortkeys["switch_merge_mode"].value, switchMergeHandler.bind(this), 'keydown'); - Mousetrap.bind(shortkeys["cancel_merge_mode"].value, cancelMergeHandler.bind(this), 'keydown'); } } @@ -259,8 +256,7 @@ class ShapeMergerView { let shortkeys = window.cvat.config.shortkeys; this._mergeButton.attr('title', ` - ${shortkeys['switch_merge_mode'].view_value} - ${shortkeys['switch_merge_mode'].description}` + `\n` + - `${shortkeys['cancel_merge_mode'].view_value} - ${shortkeys['cancel_merge_mode'].description}`); + ${shortkeys['switch_merge_mode'].view_value} - ${shortkeys['switch_merge_mode'].description}`); model.subscribe(this); } diff --git a/cvat/apps/engine/static/engine/js/shapeSplitter.js b/cvat/apps/engine/static/engine/js/shapeSplitter.js index 1a1bf9c5..ff6dbe5e 100644 --- a/cvat/apps/engine/static/engine/js/shapeSplitter.js +++ b/cvat/apps/engine/static/engine/js/shapeSplitter.js @@ -28,7 +28,7 @@ class ShapeSplitter { split(track, frame) { let keyFrames = track.keyframes.sort((a,b) => a - b); let exported = track.export(); - if (frame > keyFrames[0]) { + if (frame > +keyFrames[0]) { let curInterpolation = track.interpolate(frame); let prevInterpolation = track.interpolate(frame - 1); let curAttributes = this._convertMutableAttributes(curInterpolation.attributes); @@ -45,12 +45,19 @@ class ShapeSplitter { } } - if (!prevInterpolation.position.outside && track.type.split('_')[1] === 'box') { - prevPositionList.push(Object.assign(prevInterpolation.position, { - outside: true, - frame: frame, + if (track.type.split('_')[1] === 'box') { + prevPositionList.push(Object.assign({}, prevInterpolation.position, { + frame: frame - 1, attributes: prevAttrributes, })); + + if (!prevInterpolation.position.outside) { + prevPositionList.push(Object.assign({}, prevInterpolation.position, { + outside: true, + frame: frame, + attributes: [], + })); + } } curPositionList.push(Object.assign(curInterpolation.position, { @@ -58,6 +65,8 @@ class ShapeSplitter { attributes: curAttributes, })); + // don't clone id of splitted object + delete exported.id; let prevExported = Object.assign({}, exported); let curExported = Object.assign({}, exported); prevExported.shapes = prevPositionList; @@ -71,4 +80,4 @@ class ShapeSplitter { return [exported]; } } -} \ No newline at end of file +} diff --git a/cvat/apps/engine/static/engine/js/shapes.js b/cvat/apps/engine/static/engine/js/shapes.js index 88b4b2d3..000d75d6 100644 --- a/cvat/apps/engine/static/engine/js/shapes.js +++ b/cvat/apps/engine/static/engine/js/shapes.js @@ -14,6 +14,7 @@ const AREA_TRESHOLD = 9; const TEXT_MARGIN = 10; /******************************** SHAPE MODELS ********************************/ + class ShapeModel extends Listener { constructor(data, positions, type, id, color) { super('onShapeUpdate', () => this ); @@ -195,6 +196,17 @@ class ShapeModel extends Listener { return counter; } + notify(updateReason) { + let oldReason = this._updateReason; + this._updateReason = updateReason; + try { + Listener.prototype.notify.call(this); + } + finally { + this._updateReason = oldReason; + } + } + collectStatistic() { let collectObj = {}; collectObj.type = this._type.split('_')[1]; @@ -229,8 +241,7 @@ class ShapeModel extends Listener { window.cvat.addAction('Change Attribute', () => { if (typeof(oldAttr) === 'undefined') { delete this._attributes.mutable[frame][attrId]; - this._updateReason = 'attributes'; - this.notify(); + this.notify('attributes'); } else { this.updateAttribute(frame, attrId, oldAttr); @@ -249,8 +260,7 @@ class ShapeModel extends Listener { this._attributes.immutable[attrId] = labelsInfo.strToValues(attrInfo.type, value)[0]; } - this._updateReason = 'attributes'; - this.notify(); + this.notify('attributes'); } changeLabel(labelId) { @@ -263,8 +273,7 @@ class ShapeModel extends Listener { this._label = +labelId; this._importAttributes([], []); this._setupKeyFrames(); - this._updateReason = 'changelabel'; - this.notify(); + this.notify('changelabel'); } else { throw Error(`Unknown label id value found: ${labelId}`); @@ -273,8 +282,7 @@ class ShapeModel extends Listener { changeColor(color) { this._color = color; - this._updateReason = 'color'; - this.notify(); + this.notify('color'); } interpolate(frame) { @@ -297,14 +305,12 @@ class ShapeModel extends Listener { // End of undo/redo code this.updatePosition(frame, position, true); - this._updateReason = 'occluded'; - this.notify(); + this.notify('occluded'); } switchLock() { this._locked = !this._locked; - this._updateReason = 'lock'; - this.notify(); + this.notify('lock'); } switchHide() { @@ -321,8 +327,7 @@ class ShapeModel extends Listener { this._hiddenText = false; } - this._updateReason = 'hidden'; - this.notify(); + this.notify('hidden'); } switchOutside(frame) { @@ -346,8 +351,7 @@ class ShapeModel extends Listener { delete this._attributes.mutable[frame]; } - this._updateReason = 'outside'; - this.notify(); + this.notify('outside'); } else { this.switchOutside(frame); @@ -370,8 +374,7 @@ class ShapeModel extends Listener { this._frame = frame; } - this._updateReason = 'outside'; - this.notify(); + this.notify('outside'); } switchKeyFrame(frame) { @@ -381,8 +384,12 @@ class ShapeModel extends Listener { } // Undo/redo code + let oldPos = Object.assign({}, this._positions[frame]); window.cvat.addAction('Change Keyframe', () => { this.switchKeyFrame(frame); + if (Object.keys(oldPos).length && oldPos.outside) { + this.switchOutside(frame); + } }, () => { this.switchKeyFrame(frame); }, frame); @@ -411,13 +418,12 @@ class ShapeModel extends Listener { this._frame = frame; } } - this._updateReason = 'keyframe'; - this.notify(); + + this.notify('keyframe'); } click() { - this._updateReason = 'click'; - this.notify(); + this.notify('click'); } prevKeyFrame() { @@ -437,23 +443,20 @@ class ShapeModel extends Listener { } aamAttributeFocus() { - this._updateReason = 'attributeFocus'; - this.notify(); + this.notify('attributeFocus'); } select() { if (!this._selected) { this._selected = true; - this._updateReason = 'selection'; - this.notify(); + this.notify('selection'); } } deselect() { if (this._selected) { this._selected = false; - this._updateReason = 'selection'; - this.notify(); + this.notify('selection'); } } @@ -470,6 +473,7 @@ class ShapeModel extends Listener { this.removed = false; }, () => { this.removed = true; + }, window.cvat.player.frames.current); // End of undo/redo code } @@ -480,8 +484,7 @@ class ShapeModel extends Listener { let position = this._interpolatePosition(frame); position.z_order = value; this.updatePosition(frame, position, true); - this._updateReason = 'z_order'; - this.notify(); + this.notify('z_order'); } } @@ -489,9 +492,9 @@ class ShapeModel extends Listener { if (value) { this._active = false; } + this._removed = value; - this._updateReason = 'remove'; - this.notify(); + this.notify('remove'); } get removed() { @@ -513,8 +516,7 @@ class ShapeModel extends Listener { set active(value) { this._active = value; if (!this._removed) { - this._updateReason = 'activation'; - this.notify(); + this.notify('activation'); } } @@ -525,8 +527,7 @@ class ShapeModel extends Listener { set activeAAM(active) { this._activeAAM = active.shape; this._activeAAMAttributeId = active.attribute; - this._updateReason = 'activeAAM'; - this.notify(); + this.notify('activeAAM'); } get activeAAM() { @@ -538,8 +539,7 @@ class ShapeModel extends Listener { set merge(value) { this._merge = value; - this._updateReason = 'merge'; - this.notify(); + this.notify('merge'); } get merge() { @@ -548,8 +548,7 @@ class ShapeModel extends Listener { set groupping(value) { this._groupping = value; - this._updateReason = 'groupping'; - this.notify(); + this.notify('groupping'); } get groupping() { @@ -610,7 +609,7 @@ class BoxModel extends ShapeModel { return Object.assign({}, this._positions[this._frame], { - outside: false + outside: this._frame != frame } ); } @@ -683,8 +682,7 @@ class BoxModel extends ShapeModel { window.cvat.addAction('Change Position', () => { if (!Object.keys(oldPos).length) { delete this._positions[frame]; - this._updateReason = 'position'; - this.notify(); + this.notify('position'); } else { this.updatePosition(frame, oldPos, false); @@ -706,8 +704,7 @@ class BoxModel extends ShapeModel { } if (!silent) { - this._updateReason = 'position'; - this.notify(); + this.notify('position'); } } @@ -763,6 +760,7 @@ class BoxModel extends ShapeModel { } return Object.assign({}, this._positions[this._frame], { + id: this._id, attributes: immutableAttributes, label_id: this._label, group_id: this._groupId, @@ -771,6 +769,7 @@ class BoxModel extends ShapeModel { } else { let boxPath = { + id: this._id, label_id: this._label, group_id: this._groupId, frame: this._frame, @@ -804,10 +803,6 @@ class BoxModel extends ShapeModel { // nothing do } - clonePoint() { - // nothing do - } - static importPositions(positions) { let imported = {}; if (this._type === 'interpolation_box') { @@ -872,18 +867,43 @@ class PolyShapeModel extends ShapeModel { this._setupKeyFrames(); } - _interpolatePosition(frame) { + if (this._type.startsWith('annotation')) { + return Object.assign({}, + this._positions[this._frame], + { + outside: this._frame != frame + } + ); + } + + let [leftFrame, rightFrame] = this._neighboringFrames(frame); if (frame in this._positions) { - return Object.assign({}, this._positions[frame], { - outside: false - }); + leftFrame = frame; } - else { - return { - outside: true - }; + + let leftPos = null; + let rightPos = null; + + if (leftFrame != null) leftPos = this._positions[leftFrame]; + if (rightFrame != null) rightPos = this._positions[rightFrame]; + + if (!leftPos) { + if (rightPos) { + return Object.assign({}, rightPos, { + outside: true, + }); + } + else { + return { + outside: true + }; + } } + + return Object.assign({}, leftPos, { + outside: leftFrame != frame, + }); } updatePosition(frame, position, silent) { @@ -937,8 +957,7 @@ class PolyShapeModel extends ShapeModel { } if (!silent) { - this._updateReason = 'position'; - this.notify(); + this.notify('position'); } } @@ -962,6 +981,7 @@ class PolyShapeModel extends ShapeModel { } return Object.assign({}, this._positions[this._frame], { + id: this._id, attributes: immutableAttributes, label_id: this._label, group_id: this._groupId, @@ -970,6 +990,7 @@ class PolyShapeModel extends ShapeModel { } else { let polyPath = { + id: this._id, label_id: this._label, group_id: this._groupId, frame: this._frame, @@ -1011,40 +1032,6 @@ class PolyShapeModel extends ShapeModel { } } - clonePoint(idx, direction, inserPoint) { - let frame = window.cvat.player.frames.current; - let position = this._interpolatePosition(frame); - let points = PolyShapeModel.convertStringToNumberArray(position.points); - - let otherIdx = null; - if (direction === 'before') { - otherIdx = idx - 1 >= 0 ? idx - 1: points.length - 1; - } - else { - otherIdx = idx + 1 in points ? idx + 1: 0; - } - let curP = points[idx]; - let otherP = points[otherIdx]; - let newP = { - x: curP.x + (otherP.x - curP.x) / 2, - y: curP.y + (otherP.y - curP.y) / 2, - }; - - if (direction === 'before') { - points.splice(idx, 0, newP); - } - else { - points.splice(idx + 1, 0, newP); - } - - if (inserPoint) { - position.points = PolyShapeModel.convertNumberArrayToString(points); - this.updatePosition(frame, position); - } - - return newP; - } - static convertStringToNumberArray(serializedPoints) { let pointArray = []; for (let pair of serializedPoints.split(' ')) { @@ -1057,16 +1044,7 @@ class PolyShapeModel extends ShapeModel { } static convertNumberArrayToString(arrayPoints) { - let serializedPoints = ''; - for (let point of arrayPoints) { - serializedPoints += `${point.x},${point.y} `; - } - let len = serializedPoints.length; - if (len) { - serializedPoints = serializedPoints.substring(0, len - 1); - } - - return serializedPoints; + return arrayPoints.map(point => `${point.x},${point.y}`).join(' '); } static importPositions(positions) { @@ -1247,8 +1225,7 @@ class PolygonModel extends PolyShapeModel { set draggable(value) { this._draggable = value; - this._updateReason = 'draggable'; - this.notify(); + this.notify('draggable'); } get draggable() { @@ -1438,7 +1415,7 @@ class ShapeView extends Listener { this._appearance = { colors: shapeModel.color, fillOpacity: 0, - selectedFillOpacity: 0.1, + selectedFillOpacity: 0.2, }; this._flags = { @@ -1449,10 +1426,14 @@ class ShapeView extends Listener { }; this._controller = shapeController; + this._updateReason = null; this._shapeContextMenu = $('#shapeContextMenu'); this._pointContextMenu = $('#pointContextMenu'); + this._rightBorderFrame = $('#playerFrame')[0].offsetWidth; + this._bottomBorderFrame = $('#playerFrame')[0].offsetHeight; + shapeModel.subscribe(this); } @@ -1472,7 +1453,7 @@ class ShapeView extends Listener { this._flags.dragging = true; blurAllElements(); this._hideShapeText(); - this.notify(); + this.notify('drag'); }).on('dragend', (e) => { let p1 = e.detail.handler.startPoints.point; let p2 = e.detail.p; @@ -1484,7 +1465,7 @@ class ShapeView extends Listener { events.drag = null; this._flags.dragging = false; this._showShapeText(); - this.notify(); + this.notify('drag'); }); // Setup resize events @@ -1502,7 +1483,7 @@ class ShapeView extends Listener { events.resize = Logger.addContinuedEvent(Logger.EventType.resizeObject); blurAllElements(); this._hideShapeText(); - this.notify(); + this.notify('resize'); }).on('resizing', () => { objWasResized = true; }).on('resizedone', () => { @@ -1515,7 +1496,7 @@ class ShapeView extends Listener { events.resize = null; this._flags.resizing = false; this._showShapeText(); - this.notify(); + this.notify('resize'); }); @@ -1538,8 +1519,7 @@ class ShapeView extends Listener { // Setup context menu this._uis.shape.on('mousedown.contextMenu', (e) => { if (e.which === 1) { - this._shapeContextMenu.hide(100); - this._pointContextMenu.hide(100); + $('.custom-menu').hide(100); } if (e.which === 3) { e.stopPropagation(); @@ -1547,7 +1527,7 @@ class ShapeView extends Listener { }); this._uis.shape.on('contextmenu.contextMenu', (e) => { - this._pointContextMenu.hide(100); + $('.custom-menu').hide(100); let type = this._controller.type.split('_'); if (type[0] === 'interpolation') { this._shapeContextMenu.find('.interpolationItem').removeClass('hidden'); @@ -1571,9 +1551,12 @@ class ShapeView extends Listener { dragPolyItem.addClass('hidden'); } - this._shapeContextMenu.finish().show(100).offset({ - top: e.pageY - 10, - left: e.pageX - 10, + this._shapeContextMenu.finish().show(100); + let x = Math.min(e.pageX, this._rightBorderFrame - this._shapeContextMenu[0].scrollWidth); + let y = Math.min(e.pageY, this._bottomBorderFrame - this._shapeContextMenu[0].scrollHeight); + this._shapeContextMenu.offset({ + left: x, + top: y, }); e.preventDefault(); @@ -1589,6 +1572,16 @@ class ShapeView extends Listener { deepSelect: true, }).resize(false); + if (this._flags.resizing) { + this._flags.resizing = false; + this.notify('resize'); + } + + if (this._flags.dragging) { + this._flags.dragging = false; + this.notify('drag'); + } + this._uis.shape.off('dragstart') .off('dragend') .off('resizestart') @@ -1596,11 +1589,11 @@ class ShapeView extends Listener { .off('resizedone') .off('contextmenu.contextMenu') .off('mousedown.contextMenu'); + this._flags.editable = false; } - this._pointContextMenu.hide(100); - this._shapeContextMenu.hide(100); + $('.custom-menu').hide(100); } @@ -1828,7 +1821,7 @@ class ShapeView extends Listener { UI.style.backgroundColor = this._controller.color.ui; this._uis.menu = $(UI); - this._scenes.menus.append(this._uis.menu); + this._scenes.menus.prepend(this._uis.menu); function makeTitleBlock(id, label, type, shortkeys) { let title = document.createElement('div'); @@ -2401,6 +2394,18 @@ class ShapeView extends Listener { } + notify(newReason) { + let oldReason = this._updateReason; + this._updateReason = newReason; + try { + Listener.prototype.notify.call(this); + } + finally { + this._updateReason = oldReason; + } + } + + // Inteface methods draw(interpolation) { let outside = interpolation.position.outside; @@ -2442,24 +2447,18 @@ class ShapeView extends Listener { this._uis.shape.attr('stroke-width', STROKE_WIDTH / scale); } - if (this._uis.text && this._uis.text.node.parentElement) { let revscale = 1 / scale; let shapeBBox = this._uis.shape.node.getBBox(); let textBBox = this._uis.text.node.getBBox(); - let x = shapeBBox.x + shapeBBox.width + TEXT_MARGIN; + let x = shapeBBox.x + shapeBBox.width + TEXT_MARGIN * revscale; let y = shapeBBox.y; - if (x + textBBox.width * revscale > window.cvat.player.geometry.frameWidth) { - x = shapeBBox.x - TEXT_MARGIN - textBBox.width * revscale; - if (x < 0) { - x = shapeBBox.x + TEXT_MARGIN; - } - } - - if (y + textBBox.height * revscale > window.cvat.player.geometry.frameHeight) { - y = Math.max(0, window.cvat.player.geometry.frameHeight - textBBox.height * revscale); + let transl = window.cvat.translate.point; + let canvas = this._scenes.svg.node; + if (transl.canvasToClient(canvas, x + textBBox.width * revscale, 0).x > this._rightBorderFrame) { + x = shapeBBox.x + TEXT_MARGIN * revscale; } this._uis.text.move(x / revscale, y / revscale); @@ -2508,6 +2507,7 @@ class ShapeView extends Listener { this._setupLockedUI(locked); this._updateButtonsBlock(interpolation.position); + this.notify('lock'); break; } case 'occluded': @@ -2517,10 +2517,12 @@ class ShapeView extends Listener { case 'hidden': setupHidden.call(this, hiddenShape, hiddenText, activeAAM, model.active, interpolation); this._updateButtonsBlock(interpolation.position); + this.notify('hidden'); break; case 'remove': if (model.removed) { this.erase(); + this.notify('remove'); } break; case 'position': @@ -2542,6 +2544,7 @@ class ShapeView extends Listener { if (colorByLabel.prop('checked')) { colorByLabel.trigger('change'); } + this.notify('changelabel'); break; } case 'attributeFocus': { @@ -2683,6 +2686,10 @@ class ShapeView extends Listener { } } + if ('selected-fill-opacity' in settings) { + this._appearance.selectedFillOpacity = settings['selected-fill-opacity']; + } + if (settings['black-stroke']) { this._appearance['stroke'] = 'black'; } @@ -2707,6 +2714,10 @@ class ShapeView extends Listener { return this._flags.resizing; } + get updateReason() { + return this._updateReason; + } + // Used in shapeGrouper in order to get model via controller and set group id controller() { return this._controller; @@ -2731,29 +2742,43 @@ ShapeView.labels = function() { class BoxView extends ShapeView { constructor(boxModel, boxController, svgScene, menusScene) { super(boxModel, boxController, svgScene, menusScene); + + this._uis.boxSize = null; } _makeEditable() { if (this._uis.shape && this._uis.shape.node.parentElement && !this._flags.editable) { if (!this._controller.lock) { - let sizeObject = null; this._uis.shape.on('resizestart', (e) => { - sizeObject = drawBoxSize(this._scenes.svg, e.target); + if (this._uis.boxSize) { + this._uis.boxSize.rm(); + this._uis.boxSize = null; + } + + this._uis.boxSize = drawBoxSize(this._scenes.svg, e.target); }).on('resizing', (e) => { - sizeObject = drawBoxSize.call(sizeObject, this._scenes.svg, e.target); + this._uis.boxSize = drawBoxSize.call(this._uis.boxSize, this._scenes.svg, e.target); }).on('resizedone', () => { - sizeObject.rm(); + this._uis.boxSize.rm(); }); } ShapeView.prototype._makeEditable.call(this); } } + _makeNotEditable() { + if (this._uis.boxSize) { + this._uis.boxSize.rm(); + this._uis.boxSize = null; + } + ShapeView.prototype._makeNotEditable.call(this); + } + _buildPosition() { let shape = this._uis.shape.node; - return { + return window.cvat.translate.box.canvasToActual({ xtl: +shape.getAttribute('x'), ytl: +shape.getAttribute('y'), xbr: +shape.getAttribute('x') + +shape.getAttribute('width'), @@ -2761,13 +2786,12 @@ class BoxView extends ShapeView { occluded: this._uis.shape.hasClass('occludedShape'), outside: false, // if drag or resize possible, track is not outside z_order: +shape.getAttribute('z_order'), - }; + }); } _drawShapeUI(position) { - let xtl = position.xtl; - let ytl = position.ytl; + position = window.cvat.translate.box.actualToCanvas(position); let width = position.xbr - position.xtl; let height = position.ybr - position.ytl; @@ -2777,12 +2801,11 @@ class BoxView extends ShapeView { 'stroke-width': STROKE_WIDTH / window.cvat.player.geometry.scale, 'z_order': position.z_order, 'fill-opacity': this._appearance.fillOpacity - }).move(xtl, ytl).addClass('shape'); + }).move(position.xtl, position.ytl).addClass('shape'); ShapeView.prototype._drawShapeUI.call(this); } - _setupAAMView(active, pos) { let oldRect = $('#outsideRect'); let oldMask = $('#outsideMask'); @@ -2793,12 +2816,14 @@ class BoxView extends ShapeView { oldMask.remove(); } - let size = { + let size = window.cvat.translate.box.actualToCanvas({ x: 0, y: 0, width: window.cvat.player.geometry.frameWidth, height: window.cvat.player.geometry.frameHeight - }; + }); + + pos = window.cvat.translate.box.actualToCanvas(pos); let excludeField = this._scenes.svg.rect(size.width, size.height).move(size.x, size.y).fill('#666'); let includeField = this._scenes.svg.rect(pos.xbr - pos.xtl, pos.ybr - pos.ytl).move(pos.xtl, pos.ytl); @@ -2824,7 +2849,7 @@ class PolyShapeView extends ShapeView { _buildPosition() { return { - points: this._uis.shape.node.getAttribute('points'), + points: window.cvat.translate.points.canvasToActual(this._uis.shape.node.getAttribute('points')), occluded: this._uis.shape.hasClass('occludedShape'), outside: false, z_order: +this._uis.shape.node.getAttribute('z_order'), @@ -2842,15 +2867,17 @@ class PolyShapeView extends ShapeView { oldMask.remove(); } - let size = { + let size = window.cvat.translate.box.actualToCanvas({ x: 0, y: 0, width: window.cvat.player.geometry.frameWidth, height: window.cvat.player.geometry.frameHeight - }; + }); + + let points = window.cvat.translate.points.actualToCanvas(pos.points); let excludeField = this._scenes.svg.rect(size.width, size.height).move(size.x, size.y).fill('#666'); - let includeField = this._scenes.svg.polygon(pos.points); + let includeField = this._scenes.svg.polygon(points); this._scenes.svg.mask().add(excludeField).add(includeField).fill('black').attr('id', 'outsideMask'); this._scenes.svg.rect(size.width, size.height).move(size.x, size.y).attr({ mask: 'url(#outsideMask)', @@ -2869,18 +2896,62 @@ class PolyShapeView extends ShapeView { if (this._flags.editable) { for (let point of $('.svg_select_points')) { point = $(point); + point.on('contextmenu.contextMenu', (e) => { - this._shapeContextMenu.hide(100); + $('.custom-menu').hide(100); this._pointContextMenu.attr('point_idx', point.index()); - - this._pointContextMenu.finish().show(100).offset({ - top: e.pageY - 20, - left: e.pageX - 20, + this._pointContextMenu.attr('dom_point_id', point.attr('id')); + + this._pointContextMenu.finish().show(100); + let x = Math.min(e.pageX, this._rightBorderFrame - this._pointContextMenu[0].scrollWidth); + let y = Math.min(e.pageY, this._bottomBorderFrame - this._pointContextMenu[0].scrollHeight); + this._pointContextMenu.offset({ + left: x, + top: y, }); e.preventDefault(); e.stopPropagation(); }); + + point.on('dblclick.polyshapeEditor', (e) => { + if (e.shiftKey) { + if (!window.cvat.mode) { + // Get index before detach shape from DOM + let index = point.index(); + + // Make non active view and detach shape from DOM + this._makeNotEditable(); + this._deselect(); + if (this._controller.hiddenText) { + this._hideShapeText(); + } + this._uis.shape.addClass('hidden'); + if (this._uis.points) { + this._uis.points.addClass('hidden'); + } + + // Run edit mode + PolyShapeView.editor.edit(this._controller.type.split('_')[1], + this._uis.shape.attr('points'), this._color, index, e, + (points) => { + this._uis.shape.removeClass('hidden'); + if (this._uis.points) { + this._uis.points.removeClass('hidden'); + } + if (points) { + this._uis.shape.attr('points', points); + this._controller.updatePosition(window.cvat.player.frames.current, this._buildPosition()); + } + } + ); + } + } + else { + this._controller.model().removePoint(point.index()); + } + e.stopPropagation(); + }); } } } @@ -2889,6 +2960,7 @@ class PolyShapeView extends ShapeView { _makeNotEditable() { for (let point of $('.svg_select_points')) { $(point).off('contextmenu.contextMenu'); + $(point).off('dblclick.polyshapeEditor'); } ShapeView.prototype._makeNotEditable.call(this); } @@ -2901,7 +2973,8 @@ class PolygonView extends PolyShapeView { } _drawShapeUI(position) { - this._uis.shape = this._scenes.svg.polygon(position.points).fill(this._appearance.colors.shape).attr({ + let points = window.cvat.translate.points.actualToCanvas(position.points); + this._uis.shape = this._scenes.svg.polygon(points).fill(this._appearance.colors.shape).attr({ 'fill': this._appearance.fill || this._appearance.colors.shape, 'stroke': this._appearance.stroke || this._appearance.colors.shape, 'stroke-width': STROKE_WIDTH / window.cvat.player.geometry.scale, @@ -2941,7 +3014,8 @@ class PolylineView extends PolyShapeView { _drawShapeUI(position) { - this._uis.shape = this._scenes.svg.polyline(position.points).fill(this._appearance.colors.shape).attr({ + let points = window.cvat.translate.points.actualToCanvas(position.points); + this._uis.shape = this._scenes.svg.polyline(points).fill(this._appearance.colors.shape).attr({ 'stroke': this._appearance.stroke || this._appearance.colors.shape, 'stroke-width': STROKE_WIDTH / window.cvat.player.geometry.scale, 'z_order': position.z_order, @@ -3042,14 +3116,15 @@ class PointsView extends PolyShapeView { return; } - this._uis.points = this._scenes.svg.group().fill(this._appearance.fill || this._appearance.colors.shape) + this._uis.points = this._scenes.svg.group() + .fill(this._appearance.fill || this._appearance.colors.shape) .on('click', () => { this._positionateMenus(); this._controller.click(); - }).attr({ - 'z_order': position.z_order }).addClass('pointTempGroup'); + this._uis.points.node.setAttribute('z_order', position.z_order); + let points = PolyShapeModel.convertStringToNumberArray(position.points); for (let point of points) { let radius = POINT_RADIUS * 2 / window.cvat.player.geometry.scale; @@ -3083,17 +3158,19 @@ class PointsView extends PolyShapeView { if (!this._controller.hiddenShape) { let interpolation = this._controller.interpolate(window.cvat.player.frames.current); if (interpolation.position.points) { - this._drawPointMarkers(interpolation.position); + let points = window.cvat.translate.points.actualToCanvas(interpolation.position.points); + this._drawPointMarkers(Object.assign(interpolation.position, {points: points})); } } } _drawShapeUI(position) { - this._uis.shape = this._scenes.svg.polyline(position.points).addClass('shape points').attr({ + let points = window.cvat.translate.points.actualToCanvas(position.points); + this._uis.shape = this._scenes.svg.polyline(points).addClass('shape points').attr({ 'z_order': position.z_order, }); - this._drawPointMarkers(position); + this._drawPointMarkers(Object.assign(position, {points: points})); ShapeView.prototype._drawShapeUI.call(this); } @@ -3155,8 +3232,6 @@ class PointsView extends PolyShapeView { } } - - function buildShapeModel(data, type, idx, color) { switch (type) { case 'interpolation_box': diff --git a/cvat/apps/engine/static/engine/js/userConfig.js b/cvat/apps/engine/static/engine/js/userConfig.js index 9f1fd0b1..25feb124 100644 --- a/cvat/apps/engine/static/engine/js/userConfig.js +++ b/cvat/apps/engine/static/engine/js/userConfig.js @@ -36,36 +36,18 @@ class Config { description: "start draw / stop draw" }, - cancel_draw_mode: { - value: "alt+n", - view_value: "Alt + N", - description: "close draw mode without create" - }, - switch_merge_mode: { value: "m", view_value: "M", description: "start merge / apply changes" }, - cancel_merge_mode: { - value: "alt+m", - view_value: "Alt + M", - description: "close merge mode without apply the merge" - }, - switch_group_mode: { value: "g", view_value: "G", description: "start group / apply changes" }, - cancel_group_mode: { - value: "alt+g", - view_value: "Alt + G", - description: "close group mode without changes" - }, - reset_group: { value: "shift+g", view_value: "Shift + G", @@ -114,6 +96,18 @@ class Config { description: "switch hide mode for active shape" }, + switch_active_keyframe: { + value: "k", + view_value: "K", + description: "switch keyframe property for active shape" + }, + + switch_active_outside: { + value: "o", + view_value: "O", + description: "switch outside property for active shape" + }, + switch_all_hide_mode: { value: "t h", view_value: "T + H", @@ -198,6 +192,12 @@ class Config { description: "open settings window " }, + open_analytics: { + value: "f3", + view_value: "F3", + description: "open analytics window" + }, + save_work: { value: "ctrl+s", view_value: "Ctrl + S", @@ -281,6 +281,12 @@ class Config { view_value: "Ctrl + Shift + Z / Ctrl + Y", description: "redo" }, + + cancel_mode: { + value: 'esc', + view_value: "Esc", + description: "cancel active mode" + } }; if (window.cvat && window.cvat.job && window.cvat.job.z_order) { @@ -343,4 +349,4 @@ class Config { get settings() { return JSON.parse(JSON.stringify(this._settings)); } -} \ No newline at end of file +} diff --git a/cvat/apps/engine/static/engine/stylesheet.css b/cvat/apps/engine/static/engine/stylesheet.css index 4e53b72d..1a5484a8 100644 --- a/cvat/apps/engine/static/engine/stylesheet.css +++ b/cvat/apps/engine/static/engine/stylesheet.css @@ -263,6 +263,7 @@ fill: white; text-shadow: 0px 0px 3px black; cursor: default; + pointer-events: none; } .highlightedShape { @@ -340,6 +341,13 @@ padding: 0; } +.buttonBlockOfLabelUI { + margin: 5px; + padding: 5px; + border: 1px solid black; + border-radius: 2px; +} + /* Each of the items in the list */ .custom-menu li { padding: 8px 12px; @@ -453,6 +461,7 @@ #frameContent { position: absolute; z-index: 1; + outline: 10px solid black; -moz-transform-origin: top left; -webkit-transform-origin: top left; } diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index f6d755be..00b11e66 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -3,16 +3,12 @@ # # SPDX-License-Identifier: MIT -import csv import os -import re -import rq import sys +import rq import shlex -import logging import shutil import tempfile -from io import StringIO from PIL import Image from traceback import print_exception from ast import literal_eval @@ -22,17 +18,18 @@ _SCRIPT_DIR = os.path.realpath(os.path.dirname(__file__)) _MEDIA_MIMETYPES_FILE = os.path.join(_SCRIPT_DIR, "media.mimetypes") mimetypes.init(files=[_MEDIA_MIMETYPES_FILE]) +from cvat.apps.engine.models import StatusChoice + import django_rq from django.conf import settings from django.db import transaction from ffmpy import FFmpeg from pyunpack import Archive from distutils.dir_util import copy_tree +from collections import OrderedDict from . import models -from .logging import task_logger, job_logger - -global_logger = logging.getLogger(__name__) +from .log import slogger ############################# Low Level server API @@ -158,7 +155,7 @@ def get(tid): """Get the task as dictionary of attributes""" db_task = models.Task.objects.get(pk=tid) if db_task: - db_labels = db_task.label_set.prefetch_related('attributespec_set').all() + db_labels = db_task.label_set.prefetch_related('attributespec_set').order_by('-pk').all() im_meta_data = get_image_meta_cache(db_task) attributes = {} for db_label in db_labels: @@ -167,12 +164,18 @@ def get(tid): attributes[db_label.id][db_attrspec.id] = db_attrspec.text db_segments = list(db_task.segment_set.prefetch_related('job_set').all()) segment_length = max(db_segments[0].stop_frame - db_segments[0].start_frame + 1, 1) - job_indexes = [segment.job_set.first().id for segment in db_segments] + job_indexes = [] + for segment in db_segments: + db_job = segment.job_set.first() + job_indexes.append({ + "job_id": db_job.id, + "max_shape_id": db_job.max_shape_id, + }) response = { - "status": db_task.status.capitalize(), + "status": db_task.status, "spec": { - "labels": { db_label.id:db_label.name for db_label in db_labels }, + "labels": OrderedDict((db_label.id, db_label.name) for db_label in db_labels), "attributes": attributes }, "size": db_task.size, @@ -191,6 +194,29 @@ def get(tid): return response + +@transaction.atomic +def save_job_status(jid, status, user): + db_job = models.Job.objects.select_related("segment__task").select_for_update().get(pk = jid) + db_task = db_job.segment.task + status = StatusChoice(status) + + slogger.job[jid].info('changing job status from {} to {} by an user {}'.format(db_job.status, str(status), user)) + + db_job.status = status.value + db_job.save() + db_segments = list(db_task.segment_set.prefetch_related('job_set').all()) + db_jobs = [db_segment.job_set.first() for db_segment in db_segments] + + if len(list(filter(lambda x: StatusChoice(x.status) == StatusChoice.ANNOTATION, db_jobs))) > 0: + db_task.status = StatusChoice.ANNOTATION + elif len(list(filter(lambda x: StatusChoice(x.status) == StatusChoice.VALIDATION, db_jobs))) > 0: + db_task.status = StatusChoice.VALIDATION + else: + db_task.status = StatusChoice.COMPLETED + + db_task.save() + def get_job(jid): """Get the job as dictionary of attributes""" db_job = models.Job.objects.select_related("segment__task").get(id=jid) @@ -203,7 +229,7 @@ def get_job(jid): if db_task.mode == 'annotation': im_meta_data['original_size'] = im_meta_data['original_size'][db_segment.start_frame:db_segment.stop_frame + 1] - db_labels = db_task.label_set.prefetch_related('attributespec_set').all() + db_labels = db_task.label_set.prefetch_related('attributespec_set').order_by('-pk').all() attributes = {} for db_label in db_labels: attributes[db_label.id] = {} @@ -211,8 +237,8 @@ def get_job(jid): attributes[db_label.id][db_attrspec.id] = db_attrspec.text response = { - "status": db_task.status.capitalize(), - "labels": { db_label.id:db_label.name for db_label in db_labels }, + "status": db_job.status, + "labels": OrderedDict((db_label.id, db_label.name) for db_label in db_labels), "stop": db_segment.stop_frame, "taskid": db_task.id, "slug": db_task.name, @@ -223,20 +249,14 @@ def get_job(jid): "attributes": attributes, "z_order": db_task.z_order, "flipped": db_task.flipped, - "image_meta_data": im_meta_data + "image_meta_data": im_meta_data, + "max_shape_id": db_job.max_shape_id, } else: raise Exception("Cannot find the job: {}".format(jid)) return response -def is_task_owner(user, tid): - try: - return user == models.Task.objects.get(pk=tid).owner or \ - user.groups.filter(name='admin').exists() - except: - return False - @transaction.atomic def rq_handler(job, exc_type, exc_value, traceback): tid = job.id.split('/')[1] @@ -356,7 +376,7 @@ def _get_frame_path(frame, base_dir): return path def _parse_labels(labels): - parsed_labels = {} + parsed_labels = OrderedDict() last_label = "" for token in shlex.split(labels): @@ -381,10 +401,12 @@ def _parse_labels(labels): raise ValueError("labels string is not corect. " + "`{}` attribute has incorrect value.".format(attr['name'])) elif attr['type'] == 'number': # number=name:min,max,step - if not (len(values) == 3 and values[0].isdigit() and \ - values[1].isdigit() and values[2].isdigit() and \ - int(values[0]) < int(values[1])): - raise ValueError("labels string is not corect. " + + try: + if len(values) != 3 or float(values[2]) <= 0 or \ + float(values[0]) >= float(values[1]): + raise ValueError + except ValueError: + raise ValueError("labels string is not correct. " + "`{}` attribute has incorrect format.".format(attr['name'])) if attr['name'] in parsed_labels[last_label]: @@ -504,6 +526,8 @@ def _find_and_unpack_archive(upload_dir): else: raise Exception('Type defined as archive, but archives were not found.') + return archive + ''' Search a video in upload dir and split it by frames. Copy frames to target dirs @@ -531,6 +555,8 @@ def _find_and_extract_video(upload_dir, output_dir, db_task, compress_quality, f else: raise Exception("Video files were not found") + return video + ''' Recursive search for all images in upload dir and compress it to RGB jpg with specified quality. Create symlinks for them. @@ -571,17 +597,21 @@ def _find_and_compress_images(upload_dir, output_dir, db_task, compress_quality, else: raise Exception("Image files were not found") + return filenames + def _save_task_to_db(db_task, task_params): db_task.overlap = min(db_task.size, task_params['overlap']) db_task.mode = task_params['mode'] db_task.z_order = task_params['z_order'] db_task.flipped = task_params['flip'] + db_task.source = task_params['data'] segment_step = task_params['segment'] - db_task.overlap for x in range(0, db_task.size, segment_step): start_frame = x stop_frame = min(x + task_params['segment'] - 1, db_task.size - 1) - global_logger.info("New segment for task #{}: start_frame = {}, stop_frame = {}".format(db_task.id, start_frame, stop_frame)) + slogger.glob.info("New segment for task #{}: start_frame = {}, \ + stop_frame = {}".format(db_task.id, start_frame, stop_frame)) db_segment = models.Segment() db_segment.task = db_task @@ -615,7 +645,7 @@ def _create_thread(tid, params): raise Exception('Only one archive, one video or many images can be dowloaded simultaneously. \ {} image(s), {} dir(s), {} video(s), {} archive(s) found'.format(images, dirs, videos, archives)) - global_logger.info("create task #{}".format(tid)) + slogger.glob.info("create task #{}".format(tid)) job = rq.get_current_job() db_task = models.Task.objects.select_for_update().get(pk=tid) @@ -643,10 +673,11 @@ def _create_thread(tid, params): job.save_meta() _copy_data_from_share(share_files_mapping, share_dirs_mapping) + archive = None if counters['archive']: job.meta['status'] = 'Archive is being unpacked..' job.save_meta() - _find_and_unpack_archive(upload_dir) + archive = _find_and_unpack_archive(upload_dir) # Define task mode and other parameters task_params = { @@ -659,13 +690,22 @@ def _create_thread(tid, params): } task_params['overlap'] = int(params.get('overlap_size', 5 if task_params['mode'] == 'interpolation' else 0)) task_params['overlap'] = min(task_params['overlap'], task_params['segment'] - 1) - global_logger.info("Task #{} parameters: {}".format(tid, task_params)) + slogger.glob.info("Task #{} parameters: {}".format(tid, task_params)) if task_params['mode'] == 'interpolation': - _find_and_extract_video(upload_dir, output_dir, db_task, task_params['compress'], task_params['flip'], job) + video = _find_and_extract_video(upload_dir, output_dir, db_task, + task_params['compress'], task_params['flip'], job) + task_params['data'] = os.path.relpath(video, upload_dir) else: - _find_and_compress_images(upload_dir, output_dir, db_task, task_params['compress'], task_params['flip'], job) - global_logger.info("Founded frames {} for task #{}".format(db_task.size, tid)) + files =_find_and_compress_images(upload_dir, output_dir, db_task, + task_params['compress'], task_params['flip'], job) + if archive: + task_params['data'] = os.path.relpath(archive, upload_dir) + else: + task_params['data'] = '{} images: {}, ...'.format(len(files), + ", ".join([os.path.relpath(x, upload_dir) for x in files[0:2]])) + + slogger.glob.info("Founded frames {} for task #{}".format(db_task.size, tid)) job.meta['status'] = 'Task is being saved in database' job.save_meta() diff --git a/cvat/apps/engine/templates/engine/annotation.html b/cvat/apps/engine/templates/engine/annotation.html index 2b12d634..b5d612ff 100644 --- a/cvat/apps/engine/templates/engine/annotation.html +++ b/cvat/apps/engine/templates/engine/annotation.html @@ -19,7 +19,6 @@ - @@ -36,11 +35,13 @@ + + @@ -76,7 +77,9 @@ +
    +
  • Copy Object URL
  • Change Color
  • Remove Shape
  • Switch Occluded
  • @@ -85,10 +88,13 @@
  • Enable Dragging
+
    +
  • Copy Job URL
  • +
  • Copy Frame URL
  • +
+
  • Remove
  • -
  • Clone Before
  • -
  • Clone After
@@ -143,16 +149,17 @@
-
+
-
+
-
-
+
+
- +
+
@@ -300,7 +307,9 @@
-
+
+ +
@@ -319,7 +328,13 @@

- +
+ +
diff --git a/cvat/apps/engine/templates/engine/base.html b/cvat/apps/engine/templates/engine/base.html index 6df91bf6..beb37783 100644 --- a/cvat/apps/engine/templates/engine/base.html +++ b/cvat/apps/engine/templates/engine/base.html @@ -37,6 +37,7 @@ {% compress js file cvat %} {% block head_js_cvat %} + {% endblock %} {% endcompress %} diff --git a/cvat/apps/engine/urls.py b/cvat/apps/engine/urls.py index cc1e3496..2977f647 100644 --- a/cvat/apps/engine/urls.py +++ b/cvat/apps/engine/urls.py @@ -9,7 +9,7 @@ from . import views urlpatterns = [ path('', views.dispatch_request), path('create/task', views.create_task), - path('get/task//frame/', views.get_frame), + path('get/task//frame/', views.get_frame), path('check/task/', views.check_task), path('delete/task/', views.delete_task), path('update/task/', views.update_task), @@ -20,6 +20,9 @@ urlpatterns = [ path('download/annotation/task/', views.download_annotation), path('save/annotation/job/', views.save_annotation_for_job), path('save/annotation/task/', views.save_annotation_for_task), + path('delete/annotation/task/', views.delete_annotation_for_task), path('get/annotation/job/', views.get_annotation), path('get/username', views.get_username), + path('save/exception/', views.catch_client_exception), + path('save/status/job/', views.save_job_status), ] diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index f1edac08..a0da8102 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -5,45 +5,54 @@ import os import json -import logging import traceback from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import redirect, render from django.conf import settings -from django.contrib.auth.decorators import permission_required +from rules.contrib.views import permission_required, objectgetter from django.views.decorators.gzip import gzip_page from sendfile import sendfile from . import annotation, task, models from cvat.settings.base import JS_3RDPARTY from cvat.apps.authentication.decorators import login_required -from cvat.apps.log_proxy.proxy_logger import client_log_proxy from requests.exceptions import RequestException -from .logging import task_logger, job_logger - -global_logger = logging.getLogger(__name__) +import logging +from .log import slogger, clogger +from cvat.apps.engine.models import StatusChoice ############################# High Level server API +@login_required +@permission_required(perm=['engine.job.access'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) +def catch_client_exception(request, jid): + data = json.loads(request.body.decode('utf-8')) + for event in data['exceptions']: + clogger.job[jid].error(json.dumps(event)) + + return HttpResponse() + @login_required def dispatch_request(request): """An entry point to dispatch legacy requests""" if request.method == 'GET' and 'id' in request.GET: return render(request, 'engine/annotation.html', { - 'js_3rdparty': JS_3RDPARTY.get('engine', []) + 'js_3rdparty': JS_3RDPARTY.get('engine', []), + 'status_list': [str(i) for i in StatusChoice] }) else: return redirect('/dashboard/') @login_required -@permission_required('engine.add_task', raise_exception=True) +@permission_required(perm=['engine.task.create'], raise_exception=True) def create_task(request): """Create a new annotation task""" db_task = None params = request.POST.dict() params['owner'] = request.user - global_logger.info("create task with params = {}".format(params)) + slogger.glob.info("create task with params = {}".format(params)) try: db_task = task.create_empty(params) target_paths = [] @@ -88,28 +97,29 @@ def create_task(request): return JsonResponse({'tid': db_task.id}) except Exception as exc: - global_logger.error("cannot create task {}".format(params['task_name']), exc_info=True) + slogger.glob.error("cannot create task {}".format(params['task_name']), exc_info=True) db_task.delete() return HttpResponseBadRequest(str(exc)) return JsonResponse({'tid': db_task.id}) @login_required -@permission_required('engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def check_task(request, tid): """Check the status of a task""" - try: - global_logger.info("check task #{}".format(tid)) + slogger.glob.info("check task #{}".format(tid)) response = task.check(tid) except Exception as e: - global_logger.error("cannot check task #{}".format(tid), exc_info=True) + slogger.glob.error("cannot check task #{}".format(tid), exc_info=True) return HttpResponseBadRequest(str(e)) return JsonResponse(response) @login_required -@permission_required('engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def get_frame(request, tid, frame): """Stream corresponding from for the task""" @@ -119,87 +129,87 @@ def get_frame(request, tid, frame): path = os.path.realpath(task.get_frame_path(tid, frame)) return sendfile(request, path) except Exception as e: - task_logger[tid].error("cannot get frame #{}".format(frame), exc_info=True) + slogger.task[tid].error("cannot get frame #{}".format(frame), exc_info=True) return HttpResponseBadRequest(str(e)) @login_required -@permission_required('engine.delete_task', raise_exception=True) +@permission_required(perm=['engine.task.delete'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def delete_task(request, tid): """Delete the task""" try: - global_logger.info("delete task #{}".format(tid)) - if not task.is_task_owner(request.user, tid): - return HttpResponseBadRequest("You don't have permissions to delete the task.") - + slogger.glob.info("delete task #{}".format(tid)) task.delete(tid) except Exception as e: - global_logger.error("cannot delete task #{}".format(tid), exc_info=True) + slogger.glob.error("cannot delete task #{}".format(tid), exc_info=True) return HttpResponseBadRequest(str(e)) return HttpResponse() @login_required -@permission_required('engine.change_task', raise_exception=True) +@permission_required(perm=['engine.task.change'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def update_task(request, tid): """Update labels for the task""" try: - task_logger[tid].info("update task request") - if not task.is_task_owner(request.user, tid): - return HttpResponseBadRequest("You don't have permissions to change the task.") - + slogger.task[tid].info("update task request") labels = request.POST['labels'] task.update(tid, labels) except Exception as e: - task_logger[tid].error("cannot update task", exc_info=True) + slogger.task[tid].error("cannot update task", exc_info=True) return HttpResponseBadRequest(str(e)) return HttpResponse() @login_required -@permission_required(perm='engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def get_task(request, tid): try: - task_logger[tid].info("get task request") + slogger.task[tid].info("get task request") response = task.get(tid) except Exception as e: - task_logger[tid].error("cannot get task", exc_info=True) + slogger.task[tid].error("cannot get task", exc_info=True) return HttpResponseBadRequest(str(e)) return JsonResponse(response, safe=False) @login_required -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.job.access'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) def get_job(request, jid): try: - job_logger[jid].info("get job #{} request".format(jid)) + slogger.job[jid].info("get job #{} request".format(jid)) response = task.get_job(jid) except Exception as e: - job_logger[jid].error("cannot get job #{}".format(jid), exc_info=True) + slogger.job[jid].error("cannot get job #{}".format(jid), exc_info=True) return HttpResponseBadRequest(str(e)) return JsonResponse(response, safe=False) @login_required -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def dump_annotation(request, tid): try: - task_logger[tid].info("dump annotation request") + slogger.task[tid].info("dump annotation request") annotation.dump(tid, annotation.FORMAT_XML, request.scheme, request.get_host()) except Exception as e: - task_logger[tid].error("cannot dump annotation", exc_info=True) + slogger.task[tid].error("cannot dump annotation", exc_info=True) return HttpResponseBadRequest(str(e)) return HttpResponse() @login_required @gzip_page -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def check_annotation(request, tid): try: - task_logger[tid].info("check annotation") + slogger.task[tid].info("check annotation") response = annotation.check(tid) except Exception as e: - task_logger[tid].error("cannot check annotation", exc_info=True) + slogger.task[tid].error("cannot check annotation", exc_info=True) return HttpResponseBadRequest(str(e)) return JsonResponse(response) @@ -207,15 +217,16 @@ def check_annotation(request, tid): @login_required @gzip_page -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def download_annotation(request, tid): try: - task_logger[tid].info("get dumped annotation") + slogger.task[tid].info("get dumped annotation") db_task = models.Task.objects.get(pk=tid) response = sendfile(request, db_task.get_dump_path(), attachment=True, attachment_filename='{}_{}.xml'.format(db_task.id, db_task.name)) except Exception as e: - task_logger[tid].error("cannot get dumped annotation", exc_info=True) + slogger.task[tid].error("cannot get dumped annotation", exc_info=True) return HttpResponseBadRequest(str(e)) return response @@ -223,50 +234,85 @@ def download_annotation(request, tid): @login_required @gzip_page -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.job.access'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) def get_annotation(request, jid): try: - job_logger[jid].info("get annotation for {} job".format(jid)) + slogger.job[jid].info("get annotation for {} job".format(jid)) response = annotation.get(jid) except Exception as e: - job_logger[jid].error("cannot get annotation for job {}".format(jid), exc_info=True) + slogger.job[jid].error("cannot get annotation for job {}".format(jid), exc_info=True) return HttpResponseBadRequest(str(e)) return JsonResponse(response, safe=False) @login_required -@permission_required(perm=['engine.view_task', 'engine.change_annotation'], raise_exception=True) +@permission_required(perm=['engine.job.change'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) def save_annotation_for_job(request, jid): try: - job_logger[jid].info("save annotation for {} job".format(jid)) + slogger.job[jid].info("save annotation for {} job".format(jid)) data = json.loads(request.body.decode('utf-8')) if 'annotation' in data: annotation.save_job(jid, json.loads(data['annotation'])) if 'logs' in data: - client_log_proxy.push_logs(jid, json.loads(data['logs'])) + for event in json.loads(data['logs']): + clogger.job[jid].info(json.dumps(event)) + slogger.job[jid].info("annotation have been saved for the {} job".format(jid)) except RequestException as e: - job_logger[jid].error("cannot send annotation logs for job {}".format(jid), exc_info=True) + slogger.job[jid].error("cannot send annotation logs for job {}".format(jid), exc_info=True) return HttpResponseBadRequest(str(e)) except Exception as e: - job_logger[jid].error("cannot save annotation for job {}".format(jid), exc_info=True) + slogger.job[jid].error("cannot save annotation for job {}".format(jid), exc_info=True) return HttpResponseBadRequest(str(e)) return HttpResponse() - @login_required -@permission_required(perm=['engine.view_task', 'engine.change_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.change'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def save_annotation_for_task(request, tid): try: - task_logger[tid].info("save annotation request") + slogger.task[tid].info("save annotation request") data = json.loads(request.body.decode('utf-8')) annotation.save_task(tid, data) except Exception as e: - task_logger[tid].error("cannot save annotation", exc_info=True) + slogger.task[tid].error("cannot save annotation", exc_info=True) return HttpResponseBadRequest(str(e)) return HttpResponse() +@login_required +@permission_required(perm=['engine.task.change'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) +def delete_annotation_for_task(request, tid): + try: + slogger.task[tid].info("delete annotation request") + annotation.clear_task(tid) + except Exception as e: + slogger.task[tid].error("cannot delete annotation", exc_info=True) + return HttpResponseBadRequest(str(e)) + + return HttpResponse() + + +@login_required +@permission_required(perm=['engine.job.change'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) +def save_job_status(request, jid): + try: + data = json.loads(request.body.decode('utf-8')) + status = data['status'] + slogger.job[jid].info("changing job status request") + task.save_job_status(jid, status, request.user.username) + except Exception as e: + if jid: + slogger.job[jid].error("cannot change status", exc_info=True) + else: + slogger.glob.error("cannot change status", exc_info=True) + return HttpResponseBadRequest(str(e)) + return HttpResponse() + @login_required def get_username(request): response = {'username': request.user.username} diff --git a/cvat/apps/log_proxy/__init__.py b/cvat/apps/log_proxy/__init__.py deleted file mode 100644 index d8e62e54..00000000 --- a/cvat/apps/log_proxy/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - diff --git a/cvat/apps/log_proxy/admin.py b/cvat/apps/log_proxy/admin.py deleted file mode 100644 index af8dfc47..00000000 --- a/cvat/apps/log_proxy/admin.py +++ /dev/null @@ -1,9 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.contrib import admin - -# Register your models here. - diff --git a/cvat/apps/log_proxy/apps.py b/cvat/apps/log_proxy/apps.py deleted file mode 100644 index 6b456281..00000000 --- a/cvat/apps/log_proxy/apps.py +++ /dev/null @@ -1,11 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.apps import AppConfig - - -class LogProxyConfig(AppConfig): - name = 'log_proxy' - diff --git a/cvat/apps/log_proxy/migrations/__init__.py b/cvat/apps/log_proxy/migrations/__init__.py deleted file mode 100644 index d8e62e54..00000000 --- a/cvat/apps/log_proxy/migrations/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - diff --git a/cvat/apps/log_proxy/models.py b/cvat/apps/log_proxy/models.py deleted file mode 100644 index cdf3b082..00000000 --- a/cvat/apps/log_proxy/models.py +++ /dev/null @@ -1,9 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.db import models - -# Create your models here. - diff --git a/cvat/apps/log_proxy/proxy_logger.py b/cvat/apps/log_proxy/proxy_logger.py deleted file mode 100644 index d10398c5..00000000 --- a/cvat/apps/log_proxy/proxy_logger.py +++ /dev/null @@ -1,91 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.conf import settings -import os -import logging -import requests -import json -from urllib.parse import urlparse -from enum import Enum -from cvat.apps.engine.models import Job, Task - -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -class ClientLoggerStorage: - def __init__(self): - self._storage = dict() - self._formatter = logging.Formatter('%(message)s') - - def __getitem__(self, tid): - if tid not in self._storage: - self._storage[tid] = self._create_client_logger(tid) - return self._storage[tid] - - def _create_client_logger(self, tid): - task = self._get_task(tid) - logger = logging.getLogger(name='client_annotation_logger_{}'.format(tid)) - logger.setLevel(logging.INFO) - handler = logging.FileHandler(filename=task.get_client_log_path()) - handler.setFormatter(self._formatter) - logger.addHandler(handler) - return logger - - def _get_task(self, tid): - try: - return Task.objects.get(pk=tid) - except Exception: - raise Exception('Key must be task indentificator') - -class ClientLogProxy(): - class _HandlerType(Enum): - FILE = 1 - HTTP = 2 - - def __init__(self): - self._client_logger = ClientLoggerStorage() - def file_log_handler(tid, messages): - for event in messages: - self._client_logger[tid].info(json.dumps(event)) - - self._handlers = {self._HandlerType.FILE: file_log_handler} - - log_server_url = os.environ.get('DJANGO_LOG_SERVER_URL') - - def create_retry_session(retries=3, session=None, backoff_factor=0.3): - session = session or requests.Session() - retry = Retry(total=retries, backoff_factor=backoff_factor) - adapter = HTTPAdapter(max_retries=retry) - session.mount('http://', adapter) - session.mount('https://', adapter) - return session - - if log_server_url: - parse_result = urlparse(log_server_url) - - if parse_result.scheme and 'http' not in parse_result.scheme: - raise Exception('unsuported annotation log destination') - - def http_log_handler(taskID, messages): - r = create_retry_session().post(url=log_server_url, json=messages, verify=False) - r.raise_for_status() - - self._handlers[self._HandlerType.HTTP] = http_log_handler - - def push_logs(self, jid, logs): - taskID = self._get_task_id(jid) - - for handler in self._handlers.values(): - handler(taskID, logs) - - def _get_task_id(self, jid): - try: - job = Job.objects.select_related("segment__task").get(id=jid) - return job.segment.task.id - except: - raise Exception('Key must be job indentificator') - -client_log_proxy = ClientLogProxy() diff --git a/cvat/apps/log_proxy/tests.py b/cvat/apps/log_proxy/tests.py deleted file mode 100644 index 53bc3b7a..00000000 --- a/cvat/apps/log_proxy/tests.py +++ /dev/null @@ -1,9 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.test import TestCase - -# Create your tests here. - diff --git a/cvat/apps/log_proxy/views.py b/cvat/apps/log_proxy/views.py deleted file mode 100644 index 645c2036..00000000 --- a/cvat/apps/log_proxy/views.py +++ /dev/null @@ -1,25 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.http import HttpResponse, HttpResponseBadRequest -from django.contrib.auth.decorators import permission_required -from .proxy_logger import client_log_proxy -from cvat.apps.authentication.decorators import login_required - - -import json - -# Create your views here. -@login_required() -@permission_required('engine.view_task', raise_exception=True) -def exception_receiver(request, jid): - data = json.loads(request.body.decode('utf-8')) - try: - if 'exceptions' in data: - client_log_proxy.push_logs(jid, data['exceptions']) - except Exception as e: - return HttpResponseBadRequest(str(e)) - - return HttpResponse() diff --git a/cvat/apps/log_viewer/__init__.py b/cvat/apps/log_viewer/__init__.py new file mode 100644 index 00000000..3c7cca70 --- /dev/null +++ b/cvat/apps/log_viewer/__init__.py @@ -0,0 +1,7 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from cvat.settings.base import JS_3RDPARTY + +JS_3RDPARTY['dashboard'] = JS_3RDPARTY.get('dashboard', []) + ['log_viewer/js/shortcuts.js'] diff --git a/cvat/apps/log_viewer/admin.py b/cvat/apps/log_viewer/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/cvat/apps/log_viewer/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/cvat/apps/log_viewer/apps.py b/cvat/apps/log_viewer/apps.py new file mode 100644 index 00000000..8f4bd9cc --- /dev/null +++ b/cvat/apps/log_viewer/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class LogViewerConfig(AppConfig): + name = 'log_viewer' diff --git a/cvat/apps/log_viewer/migrations/__init__.py b/cvat/apps/log_viewer/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cvat/apps/log_viewer/models.py b/cvat/apps/log_viewer/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/cvat/apps/log_viewer/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/cvat/apps/log_viewer/static/log_viewer/js/shortcuts.js b/cvat/apps/log_viewer/static/log_viewer/js/shortcuts.js new file mode 100644 index 00000000..3dffb583 --- /dev/null +++ b/cvat/apps/log_viewer/static/log_viewer/js/shortcuts.js @@ -0,0 +1,11 @@ +/* + * Copyright (C) 2018 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +Mousetrap.bind(window.cvat.config.shortkeys["open_analytics"].value, function() { + window.open("/analytics/app/kibana"); + + return false; +}); \ No newline at end of file diff --git a/cvat/apps/log_viewer/tests.py b/cvat/apps/log_viewer/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/cvat/apps/log_viewer/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/cvat/apps/log_proxy/urls.py b/cvat/apps/log_viewer/urls.py similarity index 70% rename from cvat/apps/log_proxy/urls.py rename to cvat/apps/log_viewer/urls.py index c483e7fd..d8996c68 100644 --- a/cvat/apps/log_proxy/urls.py +++ b/cvat/apps/log_viewer/urls.py @@ -7,6 +7,6 @@ from django.urls import path from . import views urlpatterns = [ - path('exception/', views.exception_receiver), + path('', views.LogViewerProxy.as_view()) ] diff --git a/cvat/apps/log_viewer/views.py b/cvat/apps/log_viewer/views.py new file mode 100644 index 00000000..63d27fb4 --- /dev/null +++ b/cvat/apps/log_viewer/views.py @@ -0,0 +1,16 @@ +import os +from revproxy.views import ProxyView +from cvat.apps.authentication.decorators import login_required +from django.utils.decorators import method_decorator + +@method_decorator(login_required, name='dispatch') +class LogViewerProxy(ProxyView): + upstream = 'http://{}:{}'.format(os.getenv('DJANGO_LOG_VIEWER_HOST'), + os.getenv('DJANGO_LOG_VIEWER_PORT')) + add_remote_user = True + + def get_request_headers(self): + headers = super().get_request_headers() + headers['X-Forwarded-User'] = headers['REMOTE_USER'] + + return headers diff --git a/cvat/apps/profiler.py b/cvat/apps/profiler.py new file mode 100644 index 00000000..ac9e18c0 --- /dev/null +++ b/cvat/apps/profiler.py @@ -0,0 +1,13 @@ +from django.apps import apps + +if apps.is_installed('silk'): + from silk.profiling.profiler import silk_profile +else: + from functools import wraps + def silk_profile(name=None): + def profile(f): + @wraps(f) + def wrapped(*args, **kwargs): + return f(*args, **kwargs) + return wrapped + return profile \ No newline at end of file diff --git a/cvat/apps/tf_annotation/README.md b/cvat/apps/tf_annotation/README.md deleted file mode 100644 index 5e7cf7ba..00000000 --- a/cvat/apps/tf_annotation/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Tensorflow annotation cvat django-app - -#### What is it? -This application allows you automatically to annotate many various objects on images. [Tensorflow object detector](https://github.com/tensorflow/models/tree/master/research/object_detection) work in backend. It needs NVIDIA GPU for convenience using, but you may run it on CPU (just remove tensorflow-gpu python package and install the CPU tensorflow package version). - -#### Enable instructions -1. Download the root dir with this app to cvat/apps if need. -2. Add urls for tf annotation in ```urls.py```: -``` -urlpatterns += [path('tf_annotation/', include('cvat.apps.tf_annotation.urls'))] -``` -3. Enable this application in ```settings/base.py``` -``` -INSTALLED_APPS += ['cvat.apps.tf_annotation'] -``` - -1. If you want to run CVAT in container: - -* Set TF_ANNOTATION argument to "yes" in ```docker-compose.yml``` -* Add ```runtime: nvidia``` (if you have nvidia-gpu) to cvat block ([nvidia-docker2](https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) must be installed) - -5. Else you must download [model](http://download.tensorflow.org/models/object_detection/faster_rcnn_inception_resnet_v2_atrous_coco_11_06_2017.tar.gz), unpack it and set TF_ANNOTATION_MODEL_PATH environment variable to unpacked file ```frozen_inference_graph.pb```. -This variable must be available from cvat runtime environment. diff --git a/cvat/apps/tf_annotation/requirements.txt b/cvat/apps/tf_annotation/requirements.txt deleted file mode 100644 index 5fda99fb..00000000 --- a/cvat/apps/tf_annotation/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -tensorflow==1.7.0 -tensorflow-gpu==1.7.0 \ No newline at end of file diff --git a/cvat/apps/tf_annotation/static/tf_annotation/js/tf_annotation.js b/cvat/apps/tf_annotation/static/tf_annotation/js/tf_annotation.js index 22b425c0..f7e0f3f0 100644 --- a/cvat/apps/tf_annotation/static/tf_annotation/js/tf_annotation.js +++ b/cvat/apps/tf_annotation/static/tf_annotation/js/tf_annotation.js @@ -4,12 +4,14 @@ * SPDX-License-Identifier: MIT */ +"use strict"; + function CheckTFAnnotationRequest(taskId, tfAnnotationButton) { let frequence = 5000; let errorCount = 0; let interval = setInterval(function() { $.ajax ({ - url: '/tf_annotation/check/task/' + taskId, + url: '/tensorflow/annotation/check/task/' + taskId, success: function(jsonData) { let status = jsonData["status"]; if (status == "started" || status == "queued") { @@ -40,7 +42,7 @@ function RunTFAnnotationRequest() { let tfAnnotationButton = this; let taskID = window.cvat.dashboard.taskID; $.ajax ({ - url: '/tf_annotation/create/task/' + taskID, + url: '/tensorflow/annotation/create/task/' + taskID, success: function() { showMessage('Process started.'); tfAnnotationButton.text(`Cancel TF Annotation (0%)`); @@ -57,7 +59,7 @@ function RunTFAnnotationRequest() { function CancelTFAnnotationRequest() { let tfAnnotationButton = this; $.ajax ({ - url: '/tf_annotation/cancel/task/' + window.cvat.dashboard.taskID, + url: '/tensorflow/annotation/cancel/task/' + window.cvat.dashboard.taskID, success: function() { tfAnnotationButton.prop("disabled", true); }, @@ -90,20 +92,38 @@ window.cvat.dashboard = window.cvat.dashboard || {}; window.cvat.dashboard.uiCallbacks = window.cvat.dashboard.uiCallbacks || []; window.cvat.dashboard.uiCallbacks.push(function(newElements) { - newElements.each(function(idx) { - let elem = $(newElements[idx]); - let taskId = +elem.attr('id').split('_')[1]; - let status = $.trim($(elem.find('label.dashboardStatusLabel')[0]).text()); - let buttonsUI = elem.find('div.dashboardButtonsUI')[0]; - let tfAnnotationButton = $(''); - tfAnnotationButton.on('click', onTFAnnotationClick.bind(tfAnnotationButton)); - tfAnnotationButton.addClass('dashboardTFAnnotationButton semiBold dashboardButtonUI'); - tfAnnotationButton.appendTo(buttonsUI); + let tids = []; + for (let el of newElements) { + tids.push(el.id.split('_')[1]); + } - if (status == "TF Annotation") { - tfAnnotationButton.text("Cancel TF Annotation"); - tfAnnotationButton.addClass("tfAnnotationProcess"); - CheckTFAnnotationRequest(taskId, tfAnnotationButton); + $.ajax({ + type: 'POST', + url: '/tensorflow/annotation/meta/get', + data: JSON.stringify(tids), + contentType: "application/json; charset=utf-8", + success: (data) => { + newElements.each(function(idx) { + let elem = $(newElements[idx]); + let tid = +elem.attr('id').split('_')[1]; + let buttonsUI = elem.find('div.dashboardButtonsUI')[0]; + let tfAnnotationButton = $(''); + + tfAnnotationButton.on('click', onTFAnnotationClick.bind(tfAnnotationButton)); + tfAnnotationButton.addClass('dashboardTFAnnotationButton semiBold dashboardButtonUI'); + tfAnnotationButton.appendTo(buttonsUI); + + if ((tid in data) && (data[tid].active)) { + tfAnnotationButton.text("Cancel TF Annotation"); + tfAnnotationButton.addClass("tfAnnotationProcess"); + CheckTFAnnotationRequest(tid, tfAnnotationButton); + } + }); + }, + error: (data) => { + let message = `Can not get tf annotation meta info. Code: ${data.status}. Message: ${data.responseText || data.statusText}`; + showMessage(message); + throw Error(message); } }); }); diff --git a/cvat/apps/tf_annotation/urls.py b/cvat/apps/tf_annotation/urls.py index 99d6dee1..f84019be 100644 --- a/cvat/apps/tf_annotation/urls.py +++ b/cvat/apps/tf_annotation/urls.py @@ -10,4 +10,5 @@ urlpatterns = [ path('create/task/', views.create), path('check/task/', views.check), path('cancel/task/', views.cancel), + path('meta/get', views.get_meta_info), ] diff --git a/cvat/apps/tf_annotation/views.py b/cvat/apps/tf_annotation/views.py index 287814f7..dbad68a7 100644 --- a/cvat/apps/tf_annotation/views.py +++ b/cvat/apps/tf_annotation/views.py @@ -6,12 +6,13 @@ from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, QueryDict from django.core.exceptions import ObjectDoesNotExist from django.shortcuts import render -from django.contrib.auth.decorators import permission_required +from rules.contrib.views import permission_required, objectgetter from cvat.apps.authentication.decorators import login_required from cvat.apps.engine.models import Task as TaskModel from cvat.apps.engine import annotation, task import django_rq +import subprocess import fnmatch import logging import json @@ -20,34 +21,111 @@ import rq import tensorflow as tf import numpy as np + from PIL import Image +from cvat.apps.engine.log import slogger -_logger = logging.getLogger(__name__) +if os.environ.get('OPENVINO_TOOLKIT') == 'yes': + from openvino.inference_engine import IENetwork, IEPlugin def load_image_into_numpy(image): (im_width, im_height) = image.size return np.array(image.getdata()).reshape((im_height, im_width, 3)).astype(np.uint8) -def normalize_box(box, w, h): - xmin = int(box[1] * w) - ymin = int(box[0] * h) - xmax = int(box[3] * w) - ymax = int(box[2] * h) - return xmin, ymin, xmax, ymax +def run_inference_engine_annotation(image_list, labels_mapping, treshold): + def _check_instruction(instruction): + return instruction == str.strip( + subprocess.check_output( + 'lscpu | grep -o "{}" | head -1'.format(instruction), shell=True + ).decode('utf-8') + ) + + def _normalize_box(box, w, h, dw, dh): + xmin = min(int(box[0] * dw * w), w) + ymin = min(int(box[1] * dh * h), h) + xmax = min(int(box[2] * dw * w), w) + ymax = min(int(box[3] * dh * h), h) + return xmin, ymin, xmax, ymax + + result = {} + MODEL_PATH = os.environ.get('TF_ANNOTATION_MODEL_PATH') + if MODEL_PATH is None: + raise OSError('Model path env not found in the system.') + + IE_PLUGINS_PATH = os.getenv('IE_PLUGINS_PATH') + if IE_PLUGINS_PATH is None: + raise OSError('Inference engine plugin path env not found in the system.') + + plugin = IEPlugin(device='CPU', plugin_dirs=[IE_PLUGINS_PATH]) + if (_check_instruction('avx2')): + plugin.add_cpu_extension(os.path.join(IE_PLUGINS_PATH, 'libcpu_extension_avx2.so')) + elif (_check_instruction('sse4')): + plugin.add_cpu_extension(os.path.join(IE_PLUGINS_PATH, 'libcpu_extension_sse4.so')) + else: + raise Exception('Inference engine requires a support of avx2 or sse4.') + + network = IENetwork.from_ir(model = MODEL_PATH + '.xml', weights = MODEL_PATH + '.bin') + input_blob_name = next(iter(network.inputs)) + output_blob_name = next(iter(network.outputs)) + executable_network = plugin.load(network=network) + job = rq.get_current_job() + + del network + + try: + for image_num, im_name in enumerate(image_list): + + job.refresh() + if 'cancel' in job.meta: + del job.meta['cancel'] + job.save() + return None + job.meta['progress'] = image_num * 100 / len(image_list) + job.save_meta() + + image = Image.open(im_name) + width, height = image.size + image.thumbnail((600, 600), Image.ANTIALIAS) + dwidth, dheight = 600 / image.size[0], 600 / image.size[1] + image = image.crop((0, 0, 600, 600)) + image_np = load_image_into_numpy(image) + image_np = np.transpose(image_np, (2, 0, 1)) + prediction = executable_network.infer(inputs={input_blob_name: image_np[np.newaxis, ...]})[output_blob_name][0][0] + for obj in prediction: + obj_class = int(obj[1]) + obj_value = obj[2] + if obj_class and obj_class in labels_mapping and obj_value >= treshold: + label = labels_mapping[obj_class] + if label not in result: + result[label] = [] + xmin, ymin, xmax, ymax = _normalize_box(obj[3:7], width, height, dwidth, dheight) + result[label].append([image_num, xmin, ymin, xmax, ymax]) + finally: + del executable_network + del plugin + + return result + +def run_tensorflow_annotation(image_list, labels_mapping, treshold): + def _normalize_box(box, w, h): + xmin = int(box[1] * w) + ymin = int(box[0] * h) + xmax = int(box[3] * w) + ymax = int(box[2] * h) + return xmin, ymin, xmax, ymax -def run_annotation(image_list, labels_mapping, treshold): result = {} model_path = os.environ.get('TF_ANNOTATION_MODEL_PATH') if model_path is None: - raise OSError('Model path env not found in the system. Please check the installation manual.') + raise OSError('Model path env not found in the system.') job = rq.get_current_job() detection_graph = tf.Graph() with detection_graph.as_default(): od_graph_def = tf.GraphDef() - with tf.gfile.GFile(model_path, 'rb') as fid: + with tf.gfile.GFile(model_path + '.pb', 'rb') as fid: serialized_graph = fid.read() od_graph_def.ParseFromString(serialized_graph) tf.import_graph_def(od_graph_def, name='') @@ -55,8 +133,9 @@ def run_annotation(image_list, labels_mapping, treshold): try: config = tf.ConfigProto() config.gpu_options.allow_growth=True - sess = tf.Session(graph=detection_graph,config=config) + sess = tf.Session(graph=detection_graph, config=config) for image_num, image_path in enumerate(image_list): + job.refresh() if 'cancel' in job.meta: del job.meta['cancel'] @@ -64,6 +143,7 @@ def run_annotation(image_list, labels_mapping, treshold): return None job.meta['progress'] = image_num * 100 / len(image_list) job.save_meta() + image = Image.open(image_path) width, height = image.size if width > 1920 or height > 1080: @@ -81,7 +161,7 @@ def run_annotation(image_list, labels_mapping, treshold): for i in range(len(classes[0])): if classes[0][i] in labels_mapping.keys(): if scores[0][i] >= treshold: - xmin, ymin, xmax, ymax = normalize_box(boxes[0][i], width, height) + xmin, ymin, xmax, ymax = _normalize_box(boxes[0][i], width, height) label = labels_mapping[classes[0][i]] if label not in result: result[label] = [] @@ -106,20 +186,28 @@ def make_image_list(path_to_data): def convert_to_cvat_format(data): + def create_anno_container(): + return { + "boxes": [], + "polygons": [], + "polylines": [], + "points": [], + "box_paths": [], + "polygon_paths": [], + "polyline_paths": [], + "points_paths": [], + } + result = { - "boxes": [], - "polygons": [], - "polylines": [], - "points": [], - "box_paths": [], - "polygon_paths": [], - "polyline_paths": [], - "points_paths": [], + 'create': create_anno_container(), + 'update': create_anno_container(), + 'delete': create_anno_container(), } + for label in data: boxes = data[label] for box in boxes: - result['boxes'].append({ + result['create']['boxes'].append({ "label_id": label, "frame": box[0], "xtl": box[1], @@ -129,13 +217,13 @@ def convert_to_cvat_format(data): "z_order": 0, "group_id": 0, "occluded": False, - "attributes": [] + "attributes": [], + "id": -1, }) return result - -def create_thread(id, labels_mapping): +def create_thread(tid, labels_mapping): try: TRESHOLD = 0.5 # Init rq job @@ -143,101 +231,123 @@ def create_thread(id, labels_mapping): job.meta['progress'] = 0 job.save_meta() # Get job indexes and segment length - db_task = TaskModel.objects.get(pk=id) - db_segments = list(db_task.segment_set.prefetch_related('job_set').all()) - segment_length = max(db_segments[0].stop_frame - db_segments[0].start_frame + 1, 1) - job_indexes = [segment.job_set.first().id for segment in db_segments] + db_task = TaskModel.objects.get(pk=tid) # Get image list image_list = make_image_list(db_task.get_data_dirname()) # Run auto annotation by tf - result = run_annotation(image_list, labels_mapping, TRESHOLD) + result = None + if os.environ.get('CUDA_SUPPORT') == 'yes' or os.environ.get('OPENVINO_TOOLKIT') != 'yes': + slogger.glob.info("tf annotation with tensorflow framework for task {}".format(tid)) + result = run_tensorflow_annotation(image_list, labels_mapping, TRESHOLD) + else: + slogger.glob.info('tf annotation with openvino toolkit for task {}'.format(tid)) + result = run_inference_engine_annotation(image_list, labels_mapping, TRESHOLD) + if result is None: - _logger.info('tf annotation for task {} canceled by user'.format(id)) + slogger.glob.info('tf annotation for task {} canceled by user'.format(tid)) return # Modify data format and save result = convert_to_cvat_format(result) - annotation.save_task(id, result) - db_task.status = "Annotation" - db_task.save() - _logger.info('tf annotation for task {} done'.format(id)) - except Exception: - _logger.exception('exception was occured during tf annotation of the task {}'.format(id)) - db_task.status = "TF Annotation Fault" - db_task.save() + annotation.clear_task(tid) + annotation.save_task(tid, result) + slogger.glob.info('tf annotation for task {} done'.format(tid)) + except: + try: + slogger.task[tid].exception('exception was occured during tf annotation of the task', exc_info=True) + except: + slogger.glob.exception('exception was occured during tf annotation of the task {}'.format(tid), exc_into=True) @login_required -@permission_required(perm=['engine.view_task', 'engine.change_annotation'], raise_exception=True) -def create(request, tid): - _logger.info('tf annotation create request for task {}'.format(tid)) +def get_meta_info(request): try: - db_task = TaskModel.objects.get(pk=tid) - except ObjectDoesNotExist: - _logger.exception('task with id {} not found'.format(tid)) - return HttpResponseBadRequest("A task with this ID was not found") - - if not task.is_task_owner(request.user, tid): - _logger.error('not enought of permissions for tf annotation of the task {}'.format(tid)) - return HttpResponseBadRequest("You don't have permissions to tf annotation of the task.") - - queue = django_rq.get_queue('low') - job = queue.fetch_job('tf_annotation.create/{}'.format(tid)) - if job is not None and (job.is_started or job.is_queued): - _logger.error('tf annotation for task {} already running'.format(tid)) - return HttpResponseBadRequest("The process is already running") - db_labels = db_task.label_set.prefetch_related('attributespec_set').all() - db_labels = {db_label.id:db_label.name for db_label in db_labels} - - tf_annotation_labels = { - "person": 1, "bicycle": 2, "car": 3, "motorcycle": 4, "airplane": 5, - "bus": 6, "train": 7, "truck": 8, "boat": 9, "traffic_light": 10, - "fire_hydrant": 11, "stop_sign": 13, "parking_meter": 14, "bench": 15, - "bird": 16, "cat": 17, "dog": 18, "horse": 19, "sheep": 20, "cow": 21, - "elephant": 22, "bear": 23, "zebra": 24, "giraffe": 25, "backpack": 27, - "umbrella": 28, "handbag": 31, "tie": 32, "suitcase": 33, "frisbee": 34, - "skis": 35, "snowboard": 36, "sports_ball": 37, "kite": 38, "baseball_bat": 39, - "baseball_glove": 40, "skateboard": 41, "surfboard": 42, "tennis_racket": 43, - "bottle": 44, "wine_glass": 46, "cup": 47, "fork": 48, "knife": 49, "spoon": 50, - "bowl": 51, "banana": 52, "apple": 53, "sandwich": 54, "orange": 55, "broccoli": 56, - "carrot": 57, "hot_dog": 58, "pizza": 59, "donut": 60, "cake": 61, "chair": 62, - "couch": 63, "potted_plant": 64, "bed": 65, "dining_table": 67, "toilet": 70, - "tv": 72, "laptop": 73, "mouse": 74, "remote": 75, "keyboard": 76, "cell_phone": 77, - "microwave": 78, "oven": 79, "toaster": 80, "sink": 81, "refrigerator": 83, - "book": 84, "clock": 85, "vase": 86, "scissors": 87, "teddy_bear": 88, "hair_drier": 89, - "toothbrush": 90 - } - - labels_mapping = {} - for key, labels in db_labels.items(): - if labels in tf_annotation_labels.keys(): - labels_mapping[tf_annotation_labels[labels]] = key + queue = django_rq.get_queue('low') + tids = json.loads(request.body.decode('utf-8')) + result = {} + for tid in tids: + job = queue.fetch_job('tf_annotation.create/{}'.format(tid)) + if job is not None: + result[tid] = { + "active": job.is_queued or job.is_started, + "success": not job.is_failed + } + + return JsonResponse(result) + except Exception as ex: + slogger.glob.exception('exception was occured during tf meta request', exc_into=True) + return HttpResponseBadRequest(str(ex)) - if not len(labels_mapping.values()): - _logger.error('no labels found for task {} tf annotation'.format(tid)) - return HttpResponseBadRequest("No labels found for tf annotation") - db_task.status = "TF Annotation" - db_task.save() +@login_required +@permission_required(perm=['engine.task.change'], + fn=objectgetter(TaskModel, 'tid'), raise_exception=True) +def create(request, tid): + slogger.glob.info('tf annotation create request for task {}'.format(tid)) + try: + db_task = TaskModel.objects.get(pk=tid) + queue = django_rq.get_queue('low') + job = queue.fetch_job('tf_annotation.create/{}'.format(tid)) + if job is not None and (job.is_started or job.is_queued): + raise Exception("The process is already running") + + db_labels = db_task.label_set.prefetch_related('attributespec_set').all() + db_labels = {db_label.id:db_label.name for db_label in db_labels} + + tf_annotation_labels = { + "person": 1, "bicycle": 2, "car": 3, "motorcycle": 4, "airplane": 5, + "bus": 6, "train": 7, "truck": 8, "boat": 9, "traffic_light": 10, + "fire_hydrant": 11, "stop_sign": 13, "parking_meter": 14, "bench": 15, + "bird": 16, "cat": 17, "dog": 18, "horse": 19, "sheep": 20, "cow": 21, + "elephant": 22, "bear": 23, "zebra": 24, "giraffe": 25, "backpack": 27, + "umbrella": 28, "handbag": 31, "tie": 32, "suitcase": 33, "frisbee": 34, + "skis": 35, "snowboard": 36, "sports_ball": 37, "kite": 38, "baseball_bat": 39, + "baseball_glove": 40, "skateboard": 41, "surfboard": 42, "tennis_racket": 43, + "bottle": 44, "wine_glass": 46, "cup": 47, "fork": 48, "knife": 49, "spoon": 50, + "bowl": 51, "banana": 52, "apple": 53, "sandwich": 54, "orange": 55, "broccoli": 56, + "carrot": 57, "hot_dog": 58, "pizza": 59, "donut": 60, "cake": 61, "chair": 62, + "couch": 63, "potted_plant": 64, "bed": 65, "dining_table": 67, "toilet": 70, + "tv": 72, "laptop": 73, "mouse": 74, "remote": 75, "keyboard": 76, "cell_phone": 77, + "microwave": 78, "oven": 79, "toaster": 80, "sink": 81, "refrigerator": 83, + "book": 84, "clock": 85, "vase": 86, "scissors": 87, "teddy_bear": 88, "hair_drier": 89, + "toothbrush": 90 + } + + labels_mapping = {} + for key, labels in db_labels.items(): + if labels in tf_annotation_labels.keys(): + labels_mapping[tf_annotation_labels[labels]] = key + + if not len(labels_mapping.values()): + raise Exception('No labels found for tf annotation') + + # Run tf annotation job + queue.enqueue_call(func=create_thread, + args=(tid, labels_mapping), + job_id='tf_annotation.create/{}'.format(tid), + timeout=604800) # 7 days + + slogger.task[tid].info('tensorflow annotation job enqueued with labels {}'.format(labels_mapping)) - # Run tf annotation job - queue.enqueue_call(func=create_thread, - args=(tid, labels_mapping), - job_id='tf_annotation.create/{}'.format(tid), - timeout=604800) # 7 days - _logger.info('tf annotation job enqueued for task {} with labels {}'.format(tid, labels_mapping)) + except Exception as ex: + try: + slogger.task[tid].exception("exception was occured during tensorflow annotation request", exc_info=True) + except: + pass + return HttpResponseBadRequest(str(ex)) return HttpResponse() @login_required -@permission_required(perm='engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(TaskModel, 'tid'), raise_exception=True) def check(request, tid): - queue = django_rq.get_queue('low') - job = queue.fetch_job('tf_annotation.create/{}'.format(tid)) - if job is not None and 'cancel' in job.meta: - return JsonResponse({'status': 'finished'}) - data = {} try: + queue = django_rq.get_queue('low') + job = queue.fetch_job('tf_annotation.create/{}'.format(tid)) + 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: @@ -251,6 +361,7 @@ def check(request, tid): else: data['status'] = 'failed' job.delete() + except Exception: data['status'] = 'unknown' @@ -258,20 +369,23 @@ def check(request, tid): @login_required -@permission_required(perm='engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.change'], + fn=objectgetter(TaskModel, 'tid'), raise_exception=True) def cancel(request, tid): try: queue = django_rq.get_queue('low') job = queue.fetch_job('tf_annotation.create/{}'.format(tid)) if job is None or job.is_finished or job.is_failed: - raise Exception('Task is not in tf annotation process') + raise Exception('Task is not being annotated currently') elif 'cancel' not in job.meta: job.meta['cancel'] = True job.save() - db_task = TaskModel.objects.get(pk=tid) - db_task.status = "Annotation" - db_task.save() except Exception as ex: - return HttpResponseBadRequest("TF annotation cancel error: {}".format(str(ex))) + try: + slogger.task[tid].exception("cannot cancel tensorflow annotation for task #{}".format(tid), exc_info=True) + except: + pass + return HttpResponseBadRequest(str(ex)) + return HttpResponse() diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index 8f609236..bf4e6c1e 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -1,5 +1,5 @@ click==6.7 -Django==2.0.3 +Django==2.1.3 django-appconf==1.0.2 django-auth-ldap==1.4.0 django-cacheops==4.0.6 @@ -15,10 +15,13 @@ pytz==2018.3 pyunpack==0.1.2 rcssmin==1.0.6 redis==2.10.6 -requests==2.18.4 +requests==2.20.0 rjsmin==1.0.12 rq==0.10.0 scipy==1.0.1 sqlparse==0.2.4 django-sendfile==0.3.11 -dj-pagination==2.3.2 +dj-pagination==2.4.0 +python-logstash==0.4.6 +django-revproxy==0.9.15 +rules==2.0 diff --git a/cvat/requirements/development.txt b/cvat/requirements/development.txt index 108a8ab9..fea2fb3b 100644 --- a/cvat/requirements/development.txt +++ b/cvat/requirements/development.txt @@ -11,4 +11,4 @@ six==1.11.0 wrapt==1.10.11 django-extensions==2.0.6 Werkzeug==0.14.1 -snakeviz==0.4.2 \ No newline at end of file +snakeviz==0.4.2 diff --git a/cvat/requirements/staging.txt b/cvat/requirements/staging.txt index d7f1e2f9..6fefa087 100644 --- a/cvat/requirements/staging.txt +++ b/cvat/requirements/staging.txt @@ -1 +1,2 @@ -r production.txt +django-silk==3.0.1 \ No newline at end of file diff --git a/cvat/settings/__init__.py b/cvat/settings/__init__.py index d8e62e54..a59acdef 100644 --- a/cvat/settings/__init__.py +++ b/cvat/settings/__init__.py @@ -2,4 +2,3 @@ # Copyright (C) 2018 Intel Corporation # # SPDX-License-Identifier: MIT - diff --git a/cvat/settings/base.py b/cvat/settings/base.py index cb937f47..bcb4f1cb 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -1,4 +1,3 @@ - # Copyright (C) 2018 Intel Corporation # # SPDX-License-Identifier: MIT @@ -54,11 +53,16 @@ INSTALLED_APPS = [ 'cacheops', 'sendfile', 'dj_pagination', + 'revproxy', + 'rules', ] if 'yes' == os.environ.get('TF_ANNOTATION', 'no'): INSTALLED_APPS += ['cvat.apps.tf_annotation'] +if os.getenv('DJANGO_LOG_VIEWER_HOST'): + INSTALLED_APPS += ['cvat.apps.log_viewer'] + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -96,6 +100,18 @@ TEMPLATES = [ WSGI_APPLICATION = 'cvat.wsgi.application' +# Django Auth +DJANGO_AUTH_TYPE = 'BASIC' +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = '/' +AUTH_LOGIN_NOTE = '

Have not registered yet? Register here.

' + +AUTHENTICATION_BACKENDS = [ + 'rules.permissions.ObjectPermissionBackend', + 'django.contrib.auth.backends.ModelBackend' +] + + # Django-RQ # https://github.com/rq/django-rq @@ -104,7 +120,7 @@ RQ_QUEUES = { 'HOST': 'localhost', 'PORT': 6379, 'DB': 0, - 'DEFAULT_TIMEOUT': '1h' + 'DEFAULT_TIMEOUT': '4h' }, 'low': { 'HOST': 'localhost', @@ -157,6 +173,10 @@ CACHEOPS = { # Automatically cache any Task.objects.get() calls for 15 minutes # This also includes .first() and .last() calls. 'engine.task': {'ops': 'get', 'timeout': 60*15}, + + # Automatically cache any Job.objects.get() calls for 15 minutes + # This also includes .first() and .last() calls. + 'engine.job': {'ops': 'get', 'timeout': 60*15}, } CACHEOPS_DEGRADE_ON_FAILURE = True @@ -185,25 +205,53 @@ LOGGING = { 'handlers': { 'console': { 'class': 'logging.StreamHandler', + 'filters': [], 'formatter': 'standard', }, - 'file': { + 'server_file': { 'class': 'logging.handlers.RotatingFileHandler', - 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), - 'filename': os.path.join(BASE_DIR, 'logs', 'cvat.log'), + 'level': 'DEBUG', + 'filename': os.path.join(BASE_DIR, 'logs', 'cvat_server.log'), 'formatter': 'standard', 'maxBytes': 1024*1024*50, # 50 MB 'backupCount': 5, + }, + 'logstash': { + 'level': 'INFO', + 'class': 'logstash.TCPLogstashHandler', + 'host': os.getenv('DJANGO_LOG_SERVER_HOST', 'localhost'), + 'port': os.getenv('DJANGO_LOG_SERVER_PORT', 5000), + 'version': 1, + 'message_type': 'django', } }, 'loggers': { - 'cvat': { - 'handlers': ['console', 'file'], + 'cvat.server': { + 'handlers': ['console', 'server_file'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), + }, + + 'cvat.client': { + 'handlers': [], 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), + }, + + 'revproxy': { + 'handlers': ['console', 'server_file'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG') + }, + 'django': { + 'handlers': ['console', 'server_file'], + 'level': 'INFO', + 'propagate': True } }, } +if os.getenv('DJANGO_LOG_SERVER_HOST'): + LOGGING['loggers']['cvat.server']['handlers'] += ['logstash'] + LOGGING['loggers']['cvat.client']['handlers'] += ['logstash'] + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.0/howto/static-files/ diff --git a/cvat/settings/development.py b/cvat/settings/development.py index d3758049..8c1b8ee8 100644 --- a/cvat/settings/development.py +++ b/cvat/settings/development.py @@ -1,4 +1,3 @@ - # Copyright (C) 2018 Intel Corporation # # SPDX-License-Identifier: MIT @@ -8,9 +7,6 @@ from .base import * # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ - INSTALLED_APPS += [ 'django_extensions', ] diff --git a/cvat/settings/production.py b/cvat/settings/production.py index 46f6ac6e..4f919518 100644 --- a/cvat/settings/production.py +++ b/cvat/settings/production.py @@ -1,4 +1,3 @@ - # Copyright (C) 2018 Intel Corporation # # SPDX-License-Identifier: MIT diff --git a/cvat/settings/staging.py b/cvat/settings/staging.py index e3dee778..46ddeb8c 100644 --- a/cvat/settings/staging.py +++ b/cvat/settings/staging.py @@ -5,4 +5,34 @@ from .production import * +# SECURITY WARNING: don't run with debug turned on in production! DEBUG = True + +INSTALLED_APPS += [ + 'silk' +] + +MIDDLEWARE += [ + 'silk.middleware.SilkyMiddleware', +] + +# Django profiler +# https://github.com/jazzband/django-silk +SILKY_PYTHON_PROFILER = True +SILKY_PYTHON_PROFILER_BINARY = True +SILKY_PYTHON_PROFILER_RESULT_PATH = os.path.join(BASE_DIR, 'profiles/') +os.makedirs(SILKY_PYTHON_PROFILER_RESULT_PATH, exist_ok=True) +SILKY_AUTHENTICATION = True +SILKY_AUTHORISATION = True +SILKY_MAX_REQUEST_BODY_SIZE = 1024 +SILKY_MAX_RESPONSE_BODY_SIZE = 1024 +SILKY_IGNORE_PATHS = ['/admin', '/documentation', '/django-rq', '/auth'] +SILKY_MAX_RECORDED_REQUESTS = 10**4 +def SILKY_INTERCEPT_FUNC(request): + # Ignore all requests which try to get a frame (too many of them) + if request.method == 'GET' and '/frame/' in request.path: + return False + + return True + +SILKY_INTERCEPT_FUNC = SILKY_INTERCEPT_FUNC diff --git a/cvat/urls.py b/cvat/urls.py index 657af2f4..4b6eecf8 100644 --- a/cvat/urls.py +++ b/cvat/urls.py @@ -22,6 +22,7 @@ from django.contrib import admin from django.urls import path, include from django.conf import settings from django.conf.urls.static import static +from django.apps import apps import os urlpatterns = [ @@ -31,8 +32,13 @@ urlpatterns = [ path('django-rq/', include('django_rq.urls')), path('auth/', include('cvat.apps.authentication.urls')), path('documentation/', include('cvat.apps.documentation.urls')), - path('logs/', include('cvat.apps.log_proxy.urls')) ] -if 'yes' == os.environ.get('TF_ANNOTATION', 'no'): - urlpatterns += [path('tf_annotation/', include('cvat.apps.tf_annotation.urls'))] +if apps.is_installed('cvat.apps.tf_annotation'): + urlpatterns.append(path('tensorflow/annotation/', include('cvat.apps.tf_annotation.urls'))) + +if apps.is_installed('cvat.apps.log_viewer'): + urlpatterns.append(path('analytics/', include('cvat.apps.log_viewer.urls'))) + +if apps.is_installed('silk'): + urlpatterns.append(path('profiler/', include('silk.urls'))) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 490684f0..0f8bda78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,10 @@ services: cvat_db: container_name: cvat_db image: postgres:10.3-alpine + networks: + default: + aliases: + - db restart: always environment: POSTGRES_USER: root @@ -19,6 +23,10 @@ services: cvat_redis: container_name: cvat_redis image: redis:4.0.5-alpine + networks: + default: + aliases: + - redis restart: always cvat: @@ -42,7 +50,6 @@ services: WITH_TESTS: "no" environment: DJANGO_MODWSGI_EXTRA_ARGS: "" - DJANGO_LOG_SERVER_URL: "" volumes: - cvat_data:/home/django/data - cvat_keys:/home/django/keys diff --git a/supervisord.conf b/supervisord.conf index 70c3cf8d..2aa7f92f 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -18,16 +18,25 @@ pidfile=/tmp/supervisord/supervisord.pid ; pidfile location childlogdir=%(ENV_HOME)s/logs/ ; where child log files will live [program:rqworker_default] -command=%(ENV_HOME)s/wait-for-it.sh cvat_redis:6379 -t 0 -- bash -ic "/usr/bin/python3 %(ENV_HOME)s/manage.py rqworker -v 3 default" +command=%(ENV_HOME)s/wait-for-it.sh cvat_redis:6379 -t 0 -- bash -ic \ + "exec /usr/bin/python3 %(ENV_HOME)s/manage.py rqworker -v 3 default" numprocs=2 process_name=rqworker_default_%(process_num)s [program:rqworker_low] -command=%(ENV_HOME)s/wait-for-it.sh cvat_redis:6379 -t 0 -- bash -ic "/usr/bin/python3 %(ENV_HOME)s/manage.py rqworker -v 3 low" +command=%(ENV_HOME)s/wait-for-it.sh redis:6379 -t 0 -- bash -ic \ + "exec /usr/bin/python3 %(ENV_HOME)s/manage.py rqworker -v 3 low" numprocs=1 [program:runserver] -command=%(ENV_HOME)s/wait-for-it.sh cvat_db:5432 -t 0 -- bash -ic "/usr/bin/python3 ~/manage.py migrate && \ - exec /usr/bin/python3 $HOME/manage.py runmodwsgi --log-to-terminal --port 8080 \ +; Here need to run a couple of commands to initialize DB and copy static files. +; We cannot initialize DB on build because the DB should be online. Also some +; apps are dynamically loaded by an environment variable. It can lead to issues +; with docker cache. Thus it is necessary to run collectstatic here for such +; apps. +command=%(ENV_HOME)s/wait-for-it.sh db:5432 -t 0 -- bash -ic \ + "/usr/bin/python3 ~/manage.py migrate && \ + /usr/bin/python3 ~/manage.py collectstatic --no-input && \ + exec /usr/bin/python3 $HOME/manage.py runmodwsgi --log-to-terminal --port 8080 \ --limit-request-body 1073741824 --log-level INFO --include-file ~/mod_wsgi.conf \ %(ENV_DJANGO_MODWSGI_EXTRA_ARGS)s --locale %(ENV_LC_ALL)s" diff --git a/tests/eslintrc.conf.js b/tests/eslintrc.conf.js index e54e0548..cd04f937 100644 --- a/tests/eslintrc.conf.js +++ b/tests/eslintrc.conf.js @@ -41,14 +41,20 @@ module.exports = { 'AnnotationParser': true, // from annotationUI.js 'callAnnotationUI': true, - 'translateSVGPos': true, 'blurAllElements': true, 'drawBoxSize': true, + 'copyToClipboard': true, // from base.js 'showMessage': true, 'showOverlay': true, 'confirm': true, 'dumpAnnotationRequest': true, + 'createExportContainer': true, + 'ExportType': true, + 'getExportTargetContainer': true, + // from idGenerator.js + 'IncrementIdGenerator': true, + 'ConstIdGenerator': true, // from shapeCollection.js 'ShapeCollectionModel': true, 'ShapeCollectionController': true, @@ -73,6 +79,7 @@ module.exports = { 'ShapeMergerView': true, // from shapes.js 'PolyShapeModel': true, + 'PolyShapeView': true, 'buildShapeModel': true, 'buildShapeController': true, 'buildShapeView': true, @@ -82,8 +89,6 @@ module.exports = { 'SELECT_POINT_STROKE_WIDTH': true, // from mousetrap.js 'Mousetrap': true, - // from md5.js - 'md5': true, // from platform.js 'platform': true, // from player.js @@ -116,5 +121,11 @@ module.exports = { 'HistoryModel': true, 'HistoryController': true, 'HistoryView': true, + // from polyshapeEditor.js + 'PolyshapeEditorModel': true, + 'PolyshapeEditorController': true, + 'PolyshapeEditorView': true, + // from coordinateTranslator + 'CoordinateTranslator': true, }, }; diff --git a/tests/karma.conf.js b/tests/karma.conf.js index cf681a58..c123c087 100644 --- a/tests/karma.conf.js +++ b/tests/karma.conf.js @@ -10,6 +10,7 @@ module.exports = function(config) { basePath: path.join(process.env.HOME, 'cvat/apps/'), frameworks: ['qunit'], files: [ + 'engine/static/engine/js/idGenerator.js', 'engine/static/engine/js/labelsInfo.js', 'engine/static/engine/js/annotationParser.js', 'engine/static/engine/js/listener.js',