From 8dd68f4f398a2f256cd0dc06e9d70495988d93ec Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Fri, 2 Apr 2021 09:00:38 +0300 Subject: [PATCH 01/19] initial version of task export/import feature --- cvat-core/src/server-proxy.js | 59 +++ cvat-core/src/session.js | 40 ++ cvat-ui/src/actions/tasks-actions.ts | 106 ++++++ .../components/actions-menu/actions-menu.tsx | 8 + .../src/components/tasks-page/tasks-page.tsx | 11 +- cvat-ui/src/components/tasks-page/top-bar.tsx | 29 +- .../containers/actions-menu/actions-menu.tsx | 14 +- .../src/containers/tasks-page/tasks-page.tsx | 8 +- cvat-ui/src/reducers/interfaces.ts | 4 + cvat-ui/src/reducers/tasks-reducer.ts | 48 +++ cvat/apps/dataset_manager/views.py | 42 ++- cvat/apps/engine/backup.py | 354 ++++++++++++++++++ cvat/apps/engine/serializers.py | 6 +- cvat/apps/engine/task.py | 56 +-- cvat/apps/engine/views.py | 175 +++++++-- 15 files changed, 898 insertions(+), 62 deletions(-) create mode 100644 cvat/apps/engine/backup.py diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index 1e7018c53bc..90cfee08696 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -465,6 +465,63 @@ }); } + async function backupTask(id) { + const { backendAPI } = config; + let url = `${backendAPI}/tasks/${id}`; + + return new Promise((resolve, reject) => { + async function request() { + try { + const response = await Axios.get(`${url}?action=export`, { + proxy: config.proxy, + }); + if (response.status === 202) { + setTimeout(request, 3000); + } else { + resolve(`${url}?action=download`); + } + } catch (errorData) { + reject(generateError(errorData)); + } + } + + setTimeout(request); + }); + } + + async function importTask(file) { + const { backendAPI } = config; + + let annotationData = new FormData(); + annotationData.append('task_file', file); + + return new Promise((resolve, reject) => { + async function request() { + try { + const response = await Axios.post( + `${backendAPI}/tasks?action=import`, + annotationData, + { + proxy: config.proxy, + }, + ); + if (response.status === 202) { + annotationData = new FormData(); + annotationData.append('rq_id', response.data.rq_id); + setTimeout(request, 3000); + } else { + const importedTask = await getTasks(`?id=${response.data.id}`); + resolve(importedTask[0]); + } + } catch (errorData) { + reject(generateError(errorData)); + } + } + + setTimeout(request); + }); + } + async function createTask(taskSpec, taskDataSpec, onUpdate) { const { backendAPI } = config; @@ -1046,6 +1103,8 @@ createTask, deleteTask, exportDataset, + backupTask, + importTask, }), writable: false, }, diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index 105dfc9b5b8..458c49a955b 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -1604,6 +1604,36 @@ const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.delete); return result; } + + /** + * Method makes a backup of a task + * @method backup + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async backup() { + const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.backup); + return result; + } + + /** + * Method imports a task from a backup + * @method import + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async import(file) { + const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.import, file); + return result; + } } module.exports = { @@ -2005,6 +2035,16 @@ return result; }; + Task.prototype.backup.implementation = async function () { + const result = await serverProxy.tasks.backupTask(this.id); + return result; + }; + + Task.prototype.import.implementation = async function (file) { + const result = await serverProxy.tasks.importTask(file); + return result; + }; + Task.prototype.frames.get.implementation = async function (frame, isPlaying, step) { if (!Number.isInteger(frame) || frame < 0) { throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 3dcf75bcfbc..1d2dee09250 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -35,6 +35,12 @@ export enum TasksActionTypes { UPDATE_TASK_SUCCESS = 'UPDATE_TASK_SUCCESS', UPDATE_TASK_FAILED = 'UPDATE_TASK_FAILED', HIDE_EMPTY_TASKS = 'HIDE_EMPTY_TASKS', + BACKUP_TASK = 'BACKUP_TASK', + BACKUP_TASK_SUCCESS = 'BACKUP_TASK_SUCCESS', + BACKUP_TASK_FAILED = 'BACKUP_TASK_FAILED', + IMPORT_TASK = 'IMPORT_TASK', + IMPORT_TASK_SUCCESS = 'IMPORT_TASK_SUCCESS', + IMPORT_TASK_FAILED = 'IMPORT_TASK_FAILED', } function getTasks(): AnyAction { @@ -213,6 +219,55 @@ export function loadAnnotationsAsync( }; } +function importTask(): AnyAction { + const action = { + type: TasksActionTypes.IMPORT_TASK, + payload: {}, + importing: true, + }; + + return action; +} + +function importTaskSuccess(taskId: number): AnyAction { + const action = { + type: TasksActionTypes.IMPORT_TASK_SUCCESS, + payload: { + taskId, + importing: false, + }, + }; + + return action; +} + +function importTaskFailed(error: any): AnyAction { + const action = { + type: TasksActionTypes.IMPORT_TASK_FAILED, + payload: { + error, + importing: false, + }, + }; + + return action; +} + +export function importTaskAsync( + file: File, +): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + dispatch(importTask()); + let taskInstance = new cvat.classes.Task({}); + taskInstance = await taskInstance.import(file); + dispatch(importTaskSuccess(taskInstance)); + } catch (error) { + dispatch(importTaskFailed(error)); + } + }; +} + function exportDataset(task: any, exporter: any): AnyAction { const action = { type: TasksActionTypes.EXPORT_DATASET, @@ -267,6 +322,57 @@ export function exportDatasetAsync(task: any, exporter: any): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + dispatch(backupTask(taskInstance.id)); + + try { + const url = await taskInstance.backup(); + const downloadAnchor = window.document.getElementById('downloadAnchor') as HTMLAnchorElement; + downloadAnchor.href = url; + downloadAnchor.click(); + } catch (error) { + dispatch(backupTaskFailed(taskInstance.id, error)); + } + + dispatch(backupTaskSuccess(taskInstance.id)); + }; +} + function deleteTask(taskID: number): AnyAction { const action = { type: TasksActionTypes.DELETE_TASK, diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index 52e0f05bcb1..c56fd84e3dd 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -6,6 +6,7 @@ import './styles.scss'; import React from 'react'; import Menu from 'antd/lib/menu'; import Modal from 'antd/lib/modal'; +import { LoadingOutlined } from '@ant-design/icons'; // eslint-disable-next-line import/no-extraneous-dependencies import { MenuInfo } from 'rc-menu/lib/interface'; import DumpSubmenu from './dump-submenu'; @@ -25,6 +26,7 @@ interface Props { inferenceIsActive: boolean; taskDimension: DimensionType; onClickMenu: (params: MenuInfo, file?: File) => void; + backupIsActive: boolean; } export enum Actions { @@ -34,6 +36,7 @@ export enum Actions { DELETE_TASK = 'delete_task', RUN_AUTO_ANNOTATION = 'run_auto_annotation', OPEN_BUG_TRACKER = 'open_bug_tracker', + BACKUP_TASK = 'backup_task', } export default function ActionsMenuComponent(props: Props): JSX.Element { @@ -49,6 +52,7 @@ export default function ActionsMenuComponent(props: Props): JSX.Element { exportActivities, loadActivity, taskDimension, + backupIsActive, } = props; let latestParams: MenuInfo | null = null; @@ -127,6 +131,10 @@ export default function ActionsMenuComponent(props: Props): JSX.Element { Automatic annotation + + {backupIsActive && } + Backup Task +
Delete diff --git a/cvat-ui/src/components/tasks-page/tasks-page.tsx b/cvat-ui/src/components/tasks-page/tasks-page.tsx index daafb44d976..fd1bd3df092 100644 --- a/cvat-ui/src/components/tasks-page/tasks-page.tsx +++ b/cvat-ui/src/components/tasks-page/tasks-page.tsx @@ -25,6 +25,8 @@ interface TasksPageProps { numberOfHiddenTasks: number; onGetTasks: (gettingQuery: TasksQuery) => void; hideEmptyTasks: (hideEmpty: boolean) => void; + onImportTask: (file: File) => void; + taskImporting: boolean; } function getSearchField(gettingQuery: TasksQuery): string { @@ -186,7 +188,7 @@ class TasksPageComponent extends React.PureComponent; @@ -194,7 +196,12 @@ class TasksPageComponent extends React.PureComponent - + {numberOfVisibleTasks ? ( ) : ( diff --git a/cvat-ui/src/components/tasks-page/top-bar.tsx b/cvat-ui/src/components/tasks-page/top-bar.tsx index b3c64d3209e..58611805856 100644 --- a/cvat-ui/src/components/tasks-page/top-bar.tsx +++ b/cvat-ui/src/components/tasks-page/top-bar.tsx @@ -9,14 +9,18 @@ import { PlusOutlined } from '@ant-design/icons'; import Button from 'antd/lib/button'; import Input from 'antd/lib/input'; import Text from 'antd/lib/typography/Text'; +import Upload from 'antd/lib/upload'; +import { UploadOutlined, LoadingOutlined } from '@ant-design/icons'; interface VisibleTopBarProps { onSearch: (value: string) => void; + onFileUpload(file: File): void; searchValue: string; + taskImporting: boolean; } export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element { - const { searchValue, onSearch } = props; + const { searchValue, onSearch, onFileUpload, taskImporting } = props; const history = useHistory(); @@ -33,6 +37,29 @@ export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element placeholder='Search' /> + + { + onFileUpload(file); + return false; + }} + > + + + - + - - - - - - - + + + + + Tasks + + + + + + + + { + onFileUpload(file); + return false; + }} + > + + + + + + + + + + + ); } From 78eef6647ba2acc6e1e0c15ab962473cdef566c3 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Fri, 4 Jun 2021 10:01:42 +0300 Subject: [PATCH 18/19] Fixed span position --- cvat-ui/src/components/tasks-page/styles.scss | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/cvat-ui/src/components/tasks-page/styles.scss b/cvat-ui/src/components/tasks-page/styles.scss index 0516d4753a4..2c6dce8ce44 100644 --- a/cvat-ui/src/components/tasks-page/styles.scss +++ b/cvat-ui/src/components/tasks-page/styles.scss @@ -15,6 +15,15 @@ > div:nth-child(1) { > div:nth-child(1) { width: 100%; + + > div:nth-child(1) { + display: flex; + + > span:nth-child(2) { + width: 200px; + margin-left: 10px; + } + } } } } @@ -27,22 +36,6 @@ > div:nth-child(3) { padding-top: 10px; } - - > div:nth-child(1) { - > div:nth-child(1) { - display: flex; - - > span:nth-child(2) { - width: 200px; - margin-left: 10px; - } - } - - > div:nth-child(2) { - display: flex; - justify-content: flex-end; - } - } } /* empty-tasks icon */ From 32aacc4655c2a9f58107f93a90c667a3ebb46000 Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Mon, 7 Jun 2021 10:26:16 +0300 Subject: [PATCH 19/19] fixed comments --- cvat/apps/engine/backup.py | 139 ++++++++++++++++++++++++++++++------- cvat/apps/engine/task.py | 5 +- cvat/apps/engine/views.py | 5 +- 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 69a958e7206..da42cab6b30 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -9,6 +9,7 @@ from zipfile import ZipFile from django.conf import settings +from django.db import transaction from rest_framework.parsers import JSONParser from rest_framework.renderers import JSONRenderer @@ -16,7 +17,8 @@ from cvat.apps.engine import models from cvat.apps.engine.log import slogger from cvat.apps.engine.serializers import (AttributeSerializer, DataSerializer, - LabeledDataSerializer, SegmentSerializer, SimpleJobSerializer, TaskSerializer) + LabeledDataSerializer, SegmentSerializer, SimpleJobSerializer, TaskSerializer, + ReviewSerializer, IssueSerializer, CommentSerializer) from cvat.apps.engine.utils import av_scan_paths from cvat.apps.engine.models import StorageChoice, StorageMethodChoice, DataChoice from cvat.apps.engine.task import _create_thread @@ -71,13 +73,6 @@ def _prepare_data_meta(self, data): return data - def _prepare_segment_meta(self, segments): - allowed_fields = { - 'start_frame', - 'stop_frame', - } - return self._prepare_meta(allowed_fields, segments) - def _prepare_job_meta(self, job): allowed_fields = { 'status', @@ -159,10 +154,43 @@ def _update_label(shape): return annotations + def _prepare_review_meta(self, review): + allowed_fields = { + 'estimated_quality', + 'status', + 'issues', + } + return self._prepare_meta(allowed_fields, review) + + def _prepare_issue_meta(self, issue): + allowed_fields = { + 'frame', + 'position', + 'created_date', + 'resolved_date', + 'comments', + } + return self._prepare_meta(allowed_fields, issue) + + def _prepare_comment_meta(self, comment): + allowed_fields = { + 'message', + 'created_date', + 'updated_date', + } + return self._prepare_meta(allowed_fields, comment) + + def _get_db_jobs(self): + if self._db_task: + db_segments = list(self._db_task.segment_set.all().prefetch_related('job_set')) + db_segments.sort(key=lambda i: i.job_set.first().id) + db_jobs = (s.job_set.first() for s in db_segments) + return db_jobs + return () + class TaskExporter(_TaskBackupBase): def __init__(self, pk, version=Version.V1): self._db_task = models.Task.objects.prefetch_related('data__images').select_related('data__video').get(pk=pk) - self._db_task.segment_set.all().prefetch_related('job_set') self._db_data = self._db_task.data self._version = version @@ -258,6 +286,32 @@ def serialize_task(): return task + def serialize_comment(db_comment): + comment_serializer = CommentSerializer(db_comment) + comment_serializer.fields.pop('author') + + return self._prepare_comment_meta(comment_serializer.data) + + def serialize_issue(db_issue): + issue_serializer = IssueSerializer(db_issue) + issue_serializer.fields.pop('owner') + issue_serializer.fields.pop('resolver') + + issue = issue_serializer.data + issue['comments'] = (serialize_comment(c) for c in db_issue.comment_set.order_by('id')) + + return self._prepare_issue_meta(issue) + + def serialize_review(db_review): + review_serializer = ReviewSerializer(db_review) + review_serializer.fields.pop('reviewer') + review_serializer.fields.pop('assignee') + + review = review_serializer.data + review['issues'] = (serialize_issue(i) for i in db_review.issue_set.order_by('id')) + + return self._prepare_review_meta(review) + def serialize_segment(db_segment): db_job = db_segment.job_set.first() job_serializer = SimpleJobSerializer(db_job) @@ -271,6 +325,9 @@ def serialize_segment(db_segment): segment = segment_serailizer.data segment.update(job_data) + db_reviews = db_job.review_set.order_by('id') + segment['reviews'] = (serialize_review(r) for r in db_reviews) + return segment def serialize_jobs(): @@ -294,9 +351,8 @@ def serialize_data(): def _write_annotations(self, zip_object): def serialize_annotations(): job_annotations = [] - db_segments = list(self._db_task.segment_set.all()) - db_segments.sort(key=lambda i: i.job_set.first().id) - db_job_ids = (s.job_set.first().id for s in db_segments) + db_jobs = self._get_db_jobs() + db_job_ids = (j.id for j in db_jobs) for db_job_id in db_job_ids: annotations = dm.task.get_job_data(db_job_id) annotations_serializer = LabeledDataSerializer(data=annotations) @@ -325,6 +381,7 @@ def __init__(self, filename, user_id): self._manifest, self._annotations = self._read_meta() self._version = self._read_version() self._labels_mapping = {} + self._db_task = None def _read_meta(self): with ZipFile(self._filename, 'r') as input_file: @@ -374,12 +431,6 @@ def _create_annotations(self, db_job, annotations): serializer.is_valid(raise_exception=True) dm.task.put_job_data(db_job.id, serializer.data) - def _create_data(self, data): - data_serializer = DataSerializer(data=data) - data_serializer.is_valid(raise_exception=True) - db_data = data_serializer.save() - return db_data - @staticmethod def _calculate_segment_size(jobs): segment_size = jobs[0]['stop_frame'] - jobs[0]['start_frame'] + 1 @@ -388,12 +439,47 @@ def _calculate_segment_size(jobs): return segment_size, overlap def _import_task(self): + + def _create_comment(comment, db_issue): + comment['issue'] = db_issue.id + comment_serializer = CommentSerializer(data=comment) + comment_serializer.is_valid(raise_exception=True) + db_comment = comment_serializer.save() + return db_comment + + def _create_issue(issue, db_review, db_job): + issue['review'] = db_review.id + issue['job'] = db_job.id + comments = issue.pop('comments') + + issue_serializer = IssueSerializer(data=issue) + issue_serializer.is_valid( raise_exception=True) + db_issue = issue_serializer.save() + + for comment in comments: + _create_comment(comment, db_issue) + + return db_issue + + def _create_review(review, db_job): + review['job'] = db_job.id + issues = review.pop('issues') + + review_serializer = ReviewSerializer(data=review) + review_serializer.is_valid(raise_exception=True) + db_review = review_serializer.save() + + for issue in issues: + _create_issue(issue, db_review, db_job) + + return db_review + data = self._manifest.pop('data') labels = self._manifest.pop('labels') - self._jobs = self._manifest.pop('jobs') + jobs = self._manifest.pop('jobs') self._prepare_task_meta(self._manifest) - self._manifest['segment_size'], self._manifest['overlap'] = self._calculate_segment_size(self._jobs) + self._manifest['segment_size'], self._manifest['overlap'] = self._calculate_segment_size(jobs) self._manifest["owner_id"] = self._user_id self._db_task = models.Task.objects.create(**self._manifest) @@ -439,11 +525,15 @@ def _import_task(self): db_data.storage = StorageChoice.LOCAL db_data.save(update_fields=['start_frame', 'stop_frame', 'frame_filter', 'storage']) - def _import_annotations(self): - db_segments = list(self._db_task.segment_set.all()) - db_segments.sort(key=lambda i: i.job_set.first().id) - db_jobs = (s.job_set.first() for s in db_segments) + for db_job, job in zip(self._get_db_jobs(), jobs): + db_job.status = job['status'] + db_job.save() + for review in job['reviews']: + _create_review(review, db_job) + + def _import_annotations(self): + db_jobs = self._get_db_jobs() for db_job, annotations in zip(db_jobs, self._annotations): self._create_annotations(db_job, annotations) @@ -452,6 +542,7 @@ def import_task(self): self._import_annotations() return self._db_task +@transaction.atomic def import_task(filename, user): av_scan_paths(filename) task_importer = TaskImporter(filename, user) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index ad0c385ce83..a026c3b9e93 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -39,12 +39,13 @@ def create(tid, data): @transaction.atomic def rq_handler(job, exc_type, exc_value, traceback): splitted = job.id.split('/') - tid = int(splitted[splitted.index('tasks') + 1]) + tid = splitted[splitted.index('tasks') + 1] try: + tid = int(tid) db_task = models.Task.objects.select_for_update().get(pk=tid) with open(db_task.get_log_path(), "wt") as log_file: print_exception(exc_type, exc_value, traceback, file=log_file) - except models.Task.DoesNotExist: + except (models.Task.DoesNotExist, ValueError): pass # skip exceptions in the code return False diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index e0f8907f055..2085a37c66a 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -379,7 +379,7 @@ def create(self, request): if 'rq_id' in request.data: rq_id = request.data['rq_id'] else: - rq_id = "{}@/api/v1/tasks/{}?action_import".format(request.user, uuid.uuid4()) + rq_id = "{}@/api/v1/tasks/{}/import".format(request.user, uuid.uuid4()) queue = django_rq.get_queue("default") rq_job = queue.fetch_job(rq_id) @@ -438,7 +438,7 @@ def retrieve(self, request, pk=None): return super().retrieve(request, pk) elif action in ('export', 'download'): queue = django_rq.get_queue("default") - rq_id = "/api/v1/tasks/{}?action_export".format(pk) + rq_id = "/api/v1/tasks/{}/export".format(pk) rq_job = queue.fetch_job(rq_id) if rq_job: @@ -484,7 +484,6 @@ def retrieve(self, request, pk=None): raise serializers.ValidationError( "Unexpected action specified for the request") - def perform_create(self, serializer): owner = self.request.data.get('owner', None) if owner: