From 99a6cf9e5e1d1763cfb70fdd43f79c4c53c1d1c8 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Fri, 14 Jul 2023 12:16:51 +0300 Subject: [PATCH 1/9] Bump Hadolint version (#6478) --- .github/workflows/hadolint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index aa81751ab536..9529f69becba 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -14,7 +14,7 @@ jobs: - name: Run checks env: HADOLINT: "${{ github.workspace }}/hadolint" - HADOLINT_VER: "2.1.0" + HADOLINT_VER: "2.12.0" VERIFICATION_LEVEL: "error" run: | CHANGED_FILES="${{steps.files.outputs.all_changed_files}}" From 1531f862c480f3349a87b8f328518a1d2802c4cc Mon Sep 17 00:00:00 2001 From: Kirill Sizov Date: Mon, 17 Jul 2023 14:29:46 +0300 Subject: [PATCH 2/9] Update codeowners (#6489) ### Motivation and context ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have added a description of my changes into the [CHANGELOG](https://github.com/opencv/cvat/blob/develop/CHANGELOG.md) file - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/opencv/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/opencv/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/opencv/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/opencv/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/opencv/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7d95099ce985..d5a9291e707b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -38,10 +38,10 @@ /components/ @azhavoro # Component: Tests -/tests/ @yasakova-anastasia +/tests/ @kirill-sizov # Component: Serverless functions -/serverless/ @yasakova-anastasia +/serverless/ @kirill-sizov # Infrastructure Dockerfile* @azhavoro From bf3e31d743bfacc74e1a480a697f85146292eaa2 Mon Sep 17 00:00:00 2001 From: Kirill Sizov Date: Tue, 18 Jul 2023 11:19:49 +0300 Subject: [PATCH 3/9] Add error message (#6500) ### Motivation and context When users try to backup a task with cloud storage data they get such error: ![Screenshot from 2023-07-18 09-21-18](https://github.com/opencv/cvat/assets/43179655/a6e4ed09-8fb5-4ebb-a5e3-72b95d9aa195) And after that, they have no idea what's wrong with this task and why they cannot backup it. ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have added a description of my changes into the [CHANGELOG](https://github.com/opencv/cvat/blob/develop/CHANGELOG.md) file - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/opencv/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/opencv/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/opencv/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/opencv/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/opencv/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. --- cvat/apps/engine/backup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index ee994bc81907..799b217c2d95 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -364,7 +364,7 @@ def _write_data(self, zip_object, target_dir=None): target_dir=target_data_dir, ) else: - raise NotImplementedError() + raise NotImplementedError("We don't currently support backing up tasks with data from cloud storage") def _write_task(self, zip_object, target_dir=None): task_dir = self._db_task.get_dirname() From 60b925a7d2e2088aa61a05672003cc056f1dbe4b Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Tue, 18 Jul 2023 15:28:36 +0300 Subject: [PATCH 4/9] Fixed: 3d job can not be opened in validation mode (#6507) ### Motivation and context Resolved #6383 ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have added a description of my changes into the [CHANGELOG](https://github.com/opencv/cvat/blob/develop/CHANGELOG.md) file - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [x] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/opencv/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/opencv/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/opencv/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/opencv/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/opencv/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. --- CHANGELOG.md | 1 + cvat-ui/src/actions/annotation-actions.ts | 7 +------ cvat-ui/src/reducers/annotation-reducer.ts | 3 ++- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a08bcdf77f02..64ca6d2021dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - \[SDK\] Ability to create attributes with blank default values () - \[SDK\] SDK should not change input data in models () +- 3D job can not be opened in validation mode () ### Security - TDB diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index ebfe3a123a29..33ad6d48b9df 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -13,7 +13,7 @@ import { RectDrawingMethod, CuboidDrawingMethod, Canvas, CanvasMode as Canvas2DMode, } from 'cvat-canvas-wrapper'; import { - getCore, MLModel, DimensionType, JobType, Job, QualityConflict, + getCore, MLModel, JobType, Job, QualityConflict, } from 'cvat-core-wrapper'; import logger, { LogType } from 'cvat-logger'; import { getCVATStore } from 'cvat-store'; @@ -993,11 +993,6 @@ export function getJobAsync( }, }); - if (job.dimension === DimensionType.DIMENSION_3D) { - const workspace = Workspace.STANDARD3D; - dispatch(changeWorkspace(workspace)); - } - dispatch(changeFrameAsync(frameNumber, false)); } catch (error) { dispatch({ diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index a31c6740160e..71524879f8bd 100644 --- a/cvat-ui/src/reducers/annotation-reducer.ts +++ b/cvat-ui/src/reducers/annotation-reducer.ts @@ -221,7 +221,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { instance: job.dimension === DimensionType.DIMENSION_2D ? new Canvas() : new Canvas3d(), }, colors, - workspace: isReview ? Workspace.REVIEW_WORKSPACE : workspaceSelected, + workspace: isReview && job.dimension === DimensionType.DIMENSION_2D ? + Workspace.REVIEW_WORKSPACE : workspaceSelected, }; } case AnnotationActionTypes.GET_JOB_FAILED: { From 9fc6b00e27b1a75dad657d5fce17f7e931b157ca Mon Sep 17 00:00:00 2001 From: Kirill Sizov Date: Tue, 18 Jul 2023 16:02:13 +0300 Subject: [PATCH 5/9] No helm tests on edit (#6495) Duplicate https://github.com/opencv/cvat/pull/6373 for Helm tests --- .github/workflows/helm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index d90c33fb6087..0fae48884c28 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -5,7 +5,7 @@ on: - 'master' - 'develop' pull_request: - types: [edited, ready_for_review, opened, synchronize, reopened] + types: [ready_for_review, opened, synchronize, reopened] paths-ignore: - 'site/**' - '**/*.md' From bc5036fd2744dddd34be51f66b0368acfaf3a684 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 18 Jul 2023 17:58:00 +0300 Subject: [PATCH 6/9] Create cvat_sdk.datasets, a framework-agnostic version of cvat_sdk.pytorch (#6428) The new `TaskDataset` class provides conveniences like per-frame annotations, bulk data downloading, and caching without forcing a dependency on PyTorch (and somewhat awkwardly conforming to the PyTorch dataset interface). It also provides a few extra niceties, like easy access to labels and original frame numbers. Note that it's called `TaskDataset` rather than `TaskVisionDataset`, as my plan is to keep it domain-agnostic. The `MediaElement` class is extensible, and we can add, for example, support for point clouds, by adding another `load_*` method. There is currently no `ProjectDataset` equivalent, although one could (and probably should) be added later. If we add one, we should probably also add a `task_id` field to `Sample`. --- CHANGELOG.md | 2 + cvat-sdk/cvat_sdk/datasets/__init__.py | 7 + .../cvat_sdk/{pytorch => datasets}/caching.py | 0 cvat-sdk/cvat_sdk/datasets/common.py | 57 +++++ cvat-sdk/cvat_sdk/datasets/task_dataset.py | 164 ++++++++++++++ cvat-sdk/cvat_sdk/pytorch/__init__.py | 8 +- cvat-sdk/cvat_sdk/pytorch/common.py | 21 +- cvat-sdk/cvat_sdk/pytorch/project_dataset.py | 2 +- cvat-sdk/cvat_sdk/pytorch/task_dataset.py | 98 +-------- cvat-sdk/cvat_sdk/pytorch/transforms.py | 3 +- tests/python/sdk/test_datasets.py | 207 ++++++++++++++++++ tests/python/sdk/test_pytorch.py | 26 +-- tests/python/sdk/util.py | 19 +- 13 files changed, 484 insertions(+), 130 deletions(-) create mode 100644 cvat-sdk/cvat_sdk/datasets/__init__.py rename cvat-sdk/cvat_sdk/{pytorch => datasets}/caching.py (100%) create mode 100644 cvat-sdk/cvat_sdk/datasets/common.py create mode 100644 cvat-sdk/cvat_sdk/datasets/task_dataset.py create mode 100644 tests/python/sdk/test_datasets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ca6d2021dd..7a39e413eaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## \[Unreleased] ### Added - Multi-line text attributes supported () +- \{SDK\] `cvat_sdk.datasets`, a framework-agnostic equivalent of `cvat_sdk.pytorch` + () ### Changed - TDB diff --git a/cvat-sdk/cvat_sdk/datasets/__init__.py b/cvat-sdk/cvat_sdk/datasets/__init__.py new file mode 100644 index 000000000000..08dd89165eac --- /dev/null +++ b/cvat-sdk/cvat_sdk/datasets/__init__.py @@ -0,0 +1,7 @@ +# Copyright (C) 2023 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from .caching import UpdatePolicy +from .common import FrameAnnotations, MediaElement, Sample, UnsupportedDatasetError +from .task_dataset import TaskDataset diff --git a/cvat-sdk/cvat_sdk/pytorch/caching.py b/cvat-sdk/cvat_sdk/datasets/caching.py similarity index 100% rename from cvat-sdk/cvat_sdk/pytorch/caching.py rename to cvat-sdk/cvat_sdk/datasets/caching.py diff --git a/cvat-sdk/cvat_sdk/datasets/common.py b/cvat-sdk/cvat_sdk/datasets/common.py new file mode 100644 index 000000000000..2b8269dbd567 --- /dev/null +++ b/cvat-sdk/cvat_sdk/datasets/common.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022-2023 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import abc +from typing import List + +import attrs +import attrs.validators +import PIL.Image + +import cvat_sdk.core +import cvat_sdk.core.exceptions +import cvat_sdk.models as models + + +class UnsupportedDatasetError(cvat_sdk.core.exceptions.CvatSdkException): + pass + + +@attrs.frozen +class FrameAnnotations: + """ + Contains annotations that pertain to a single frame. + """ + + tags: List[models.LabeledImage] = attrs.Factory(list) + shapes: List[models.LabeledShape] = attrs.Factory(list) + + +class MediaElement(metaclass=abc.ABCMeta): + """ + The media part of a dataset sample. + """ + + @abc.abstractmethod + def load_image(self) -> PIL.Image.Image: + """ + Loads the media data and returns it as a PIL Image object. + """ + ... + + +@attrs.frozen +class Sample: + """ + Represents an element of a dataset. + """ + + frame_index: int + """Index of the corresponding frame in its task.""" + + annotations: FrameAnnotations + """Annotations belonging to the frame.""" + + media: MediaElement + """Media data of the frame.""" diff --git a/cvat-sdk/cvat_sdk/datasets/task_dataset.py b/cvat-sdk/cvat_sdk/datasets/task_dataset.py new file mode 100644 index 000000000000..586070457934 --- /dev/null +++ b/cvat-sdk/cvat_sdk/datasets/task_dataset.py @@ -0,0 +1,164 @@ +# Copyright (C) 2022-2023 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import zipfile +from concurrent.futures import ThreadPoolExecutor +from typing import Sequence + +import PIL.Image + +import cvat_sdk.core +import cvat_sdk.core.exceptions +import cvat_sdk.models as models +from cvat_sdk.datasets.caching import UpdatePolicy, make_cache_manager +from cvat_sdk.datasets.common import FrameAnnotations, MediaElement, Sample, UnsupportedDatasetError + +_NUM_DOWNLOAD_THREADS = 4 + + +class TaskDataset: + """ + Represents a task on a CVAT server as a collection of samples. + + Each sample corresponds to one frame in the task, and provides access to + the corresponding annotations and media data. Deleted frames are omitted. + + This class caches all data and annotations for the task on the local file system + during construction. + + Limitations: + + * Only tasks with image (not video) data are supported at the moment. + * Track annotations are currently not accessible. + """ + + class _TaskMediaElement(MediaElement): + def __init__(self, dataset: TaskDataset, frame_index: int) -> None: + self._dataset = dataset + self._frame_index = frame_index + + def load_image(self) -> PIL.Image.Image: + return self._dataset._load_frame_image(self._frame_index) + + def __init__( + self, + client: cvat_sdk.core.Client, + task_id: int, + *, + update_policy: UpdatePolicy = UpdatePolicy.IF_MISSING_OR_STALE, + ) -> None: + """ + Creates a dataset corresponding to the task with ID `task_id` on the + server that `client` is connected to. + + `update_policy` determines when and if the local cache will be updated. + """ + + self._logger = client.logger + + cache_manager = make_cache_manager(client, update_policy) + self._task = cache_manager.retrieve_task(task_id) + + if not self._task.size or not self._task.data_chunk_size: + raise UnsupportedDatasetError("The task has no data") + + if self._task.data_original_chunk_type != "imageset": + raise UnsupportedDatasetError( + f"{self.__class__.__name__} only supports tasks with image chunks;" + f" current chunk type is {self._task.data_original_chunk_type!r}" + ) + + self._logger.info("Fetching labels...") + self._labels = tuple(self._task.get_labels()) + + data_meta = cache_manager.ensure_task_model( + self._task.id, + "data_meta.json", + models.DataMetaRead, + self._task.get_meta, + "data metadata", + ) + + active_frame_indexes = set(range(self._task.size)) - set(data_meta.deleted_frames) + + self._logger.info("Downloading chunks...") + + self._chunk_dir = cache_manager.chunk_dir(task_id) + self._chunk_dir.mkdir(exist_ok=True, parents=True) + + needed_chunks = {index // self._task.data_chunk_size for index in active_frame_indexes} + + with ThreadPoolExecutor(_NUM_DOWNLOAD_THREADS) as pool: + + def ensure_chunk(chunk_index): + cache_manager.ensure_chunk(self._task, chunk_index) + + for _ in pool.map(ensure_chunk, sorted(needed_chunks)): + # just need to loop through all results so that any exceptions are propagated + pass + + self._logger.info("All chunks downloaded") + + annotations = cache_manager.ensure_task_model( + self._task.id, + "annotations.json", + models.LabeledData, + self._task.get_annotations, + "annotations", + ) + + self._frame_annotations = { + frame_index: FrameAnnotations() for frame_index in sorted(active_frame_indexes) + } + + for tag in annotations.tags: + # Some annotations may belong to deleted frames; skip those. + if tag.frame in self._frame_annotations: + self._frame_annotations[tag.frame].tags.append(tag) + + for shape in annotations.shapes: + if shape.frame in self._frame_annotations: + self._frame_annotations[shape.frame].shapes.append(shape) + + # TODO: tracks? + + self._samples = [ + Sample(frame_index=k, annotations=v, media=self._TaskMediaElement(self, k)) + for k, v in self._frame_annotations.items() + ] + + @property + def labels(self) -> Sequence[models.ILabel]: + """ + Returns the labels configured in the task. + + Clients must not modify the object returned by this property or its components. + """ + return self._labels + + @property + def samples(self) -> Sequence[Sample]: + """ + Returns a sequence of all samples, in order of their frame indices. + + Note that the frame indices may not be contiguous, as deleted frames will not be included. + + Clients must not modify the object returned by this property or its components. + """ + return self._samples + + def _load_frame_image(self, frame_index: int) -> PIL.Image: + assert frame_index in self._frame_annotations + + chunk_index = frame_index // self._task.data_chunk_size + member_index = frame_index % self._task.data_chunk_size + + with zipfile.ZipFile(self._chunk_dir / f"{chunk_index}.zip", "r") as chunk_zip: + with chunk_zip.open(chunk_zip.infolist()[member_index]) as chunk_member: + image = PIL.Image.open(chunk_member) + image.load() + + return image diff --git a/cvat-sdk/cvat_sdk/pytorch/__init__.py b/cvat-sdk/cvat_sdk/pytorch/__init__.py index ba6609b268a4..3fa537ff99c0 100644 --- a/cvat-sdk/cvat_sdk/pytorch/__init__.py +++ b/cvat-sdk/cvat_sdk/pytorch/__init__.py @@ -2,8 +2,12 @@ # # SPDX-License-Identifier: MIT -from .caching import UpdatePolicy -from .common import FrameAnnotations, Target, UnsupportedDatasetError +from .common import Target from .project_dataset import ProjectVisionDataset from .task_dataset import TaskVisionDataset from .transforms import ExtractBoundingBoxes, ExtractSingleLabelIndex, LabeledBoxes + +# isort: split +# Compatibility imports +from ..datasets.caching import UpdatePolicy +from ..datasets.common import FrameAnnotations, UnsupportedDatasetError diff --git a/cvat-sdk/cvat_sdk/pytorch/common.py b/cvat-sdk/cvat_sdk/pytorch/common.py index ac5d8fb7ad96..97ef38bc33a8 100644 --- a/cvat-sdk/cvat_sdk/pytorch/common.py +++ b/cvat-sdk/cvat_sdk/pytorch/common.py @@ -2,28 +2,11 @@ # # SPDX-License-Identifier: MIT -from typing import List, Mapping +from typing import Mapping import attrs -import attrs.validators -import cvat_sdk.core -import cvat_sdk.core.exceptions -import cvat_sdk.models as models - - -class UnsupportedDatasetError(cvat_sdk.core.exceptions.CvatSdkException): - pass - - -@attrs.frozen -class FrameAnnotations: - """ - Contains annotations that pertain to a single frame. - """ - - tags: List[models.LabeledImage] = attrs.Factory(list) - shapes: List[models.LabeledShape] = attrs.Factory(list) +from cvat_sdk.datasets.common import FrameAnnotations @attrs.frozen diff --git a/cvat-sdk/cvat_sdk/pytorch/project_dataset.py b/cvat-sdk/cvat_sdk/pytorch/project_dataset.py index be834b1cedd9..ada554ee1210 100644 --- a/cvat-sdk/cvat_sdk/pytorch/project_dataset.py +++ b/cvat-sdk/cvat_sdk/pytorch/project_dataset.py @@ -12,7 +12,7 @@ import cvat_sdk.core import cvat_sdk.core.exceptions import cvat_sdk.models as models -from cvat_sdk.pytorch.caching import UpdatePolicy, make_cache_manager +from cvat_sdk.datasets.caching import UpdatePolicy, make_cache_manager from cvat_sdk.pytorch.task_dataset import TaskVisionDataset diff --git a/cvat-sdk/cvat_sdk/pytorch/task_dataset.py b/cvat-sdk/cvat_sdk/pytorch/task_dataset.py index 6edd3ec24aa2..8964d2db47db 100644 --- a/cvat-sdk/cvat_sdk/pytorch/task_dataset.py +++ b/cvat-sdk/cvat_sdk/pytorch/task_dataset.py @@ -2,21 +2,17 @@ # # SPDX-License-Identifier: MIT -import collections import os import types -import zipfile -from concurrent.futures import ThreadPoolExecutor -from typing import Callable, Dict, Mapping, Optional +from typing import Callable, Mapping, Optional -import PIL.Image import torchvision.datasets import cvat_sdk.core import cvat_sdk.core.exceptions -import cvat_sdk.models as models -from cvat_sdk.pytorch.caching import UpdatePolicy, make_cache_manager -from cvat_sdk.pytorch.common import FrameAnnotations, Target, UnsupportedDatasetError +from cvat_sdk.datasets.caching import UpdatePolicy, make_cache_manager +from cvat_sdk.datasets.task_dataset import TaskDataset +from cvat_sdk.pytorch.common import Target _NUM_DOWNLOAD_THREADS = 4 @@ -75,92 +71,31 @@ def __init__( `update_policy` determines when and if the local cache will be updated. """ - self._logger = client.logger + self._underlying = TaskDataset(client, task_id, update_policy=update_policy) cache_manager = make_cache_manager(client, update_policy) - self._task = cache_manager.retrieve_task(task_id) - - if not self._task.size or not self._task.data_chunk_size: - raise UnsupportedDatasetError("The task has no data") - - if self._task.data_original_chunk_type != "imageset": - raise UnsupportedDatasetError( - f"{self.__class__.__name__} only supports tasks with image chunks;" - f" current chunk type is {self._task.data_original_chunk_type!r}" - ) super().__init__( - os.fspath(cache_manager.task_dir(self._task.id)), + os.fspath(cache_manager.task_dir(task_id)), transforms=transforms, transform=transform, target_transform=target_transform, ) - data_meta = cache_manager.ensure_task_model( - self._task.id, - "data_meta.json", - models.DataMetaRead, - self._task.get_meta, - "data metadata", - ) - self._active_frame_indexes = sorted( - set(range(self._task.size)) - set(data_meta.deleted_frames) - ) - - self._logger.info("Downloading chunks...") - - self._chunk_dir = cache_manager.chunk_dir(task_id) - self._chunk_dir.mkdir(exist_ok=True, parents=True) - - needed_chunks = { - index // self._task.data_chunk_size for index in self._active_frame_indexes - } - - with ThreadPoolExecutor(_NUM_DOWNLOAD_THREADS) as pool: - - def ensure_chunk(chunk_index): - cache_manager.ensure_chunk(self._task, chunk_index) - - for _ in pool.map(ensure_chunk, sorted(needed_chunks)): - # just need to loop through all results so that any exceptions are propagated - pass - - self._logger.info("All chunks downloaded") - if label_name_to_index is None: self._label_id_to_index = types.MappingProxyType( { label.id: label_index for label_index, label in enumerate( - sorted(self._task.get_labels(), key=lambda l: l.id) + sorted(self._underlying.labels, key=lambda l: l.id) ) } ) else: self._label_id_to_index = types.MappingProxyType( - {label.id: label_name_to_index[label.name] for label in self._task.get_labels()} + {label.id: label_name_to_index[label.name] for label in self._underlying.labels} ) - annotations = cache_manager.ensure_task_model( - self._task.id, - "annotations.json", - models.LabeledData, - self._task.get_annotations, - "annotations", - ) - - self._frame_annotations: Dict[int, FrameAnnotations] = collections.defaultdict( - FrameAnnotations - ) - - for tag in annotations.tags: - self._frame_annotations[tag.frame].tags.append(tag) - - for shape in annotations.shapes: - self._frame_annotations[shape.frame].shapes.append(shape) - - # TODO: tracks? - def __getitem__(self, sample_index: int): """ Returns the sample with index `sample_index`. @@ -168,19 +103,10 @@ def __getitem__(self, sample_index: int): `sample_index` must satisfy the condition `0 <= sample_index < len(self)`. """ - frame_index = self._active_frame_indexes[sample_index] - chunk_index = frame_index // self._task.data_chunk_size - member_index = frame_index % self._task.data_chunk_size + sample = self._underlying.samples[sample_index] - with zipfile.ZipFile(self._chunk_dir / f"{chunk_index}.zip", "r") as chunk_zip: - with chunk_zip.open(chunk_zip.infolist()[member_index]) as chunk_member: - sample_image = PIL.Image.open(chunk_member) - sample_image.load() - - sample_target = Target( - annotations=self._frame_annotations[frame_index], - label_id_to_index=self._label_id_to_index, - ) + sample_image = sample.media.load_image() + sample_target = Target(sample.annotations, self._label_id_to_index) if self.transforms: sample_image, sample_target = self.transforms(sample_image, sample_target) @@ -188,4 +114,4 @@ def __getitem__(self, sample_index: int): def __len__(self) -> int: """Returns the number of samples in the dataset.""" - return len(self._active_frame_indexes) + return len(self._underlying.samples) diff --git a/cvat-sdk/cvat_sdk/pytorch/transforms.py b/cvat-sdk/cvat_sdk/pytorch/transforms.py index 259ebc045375..d63fdba65f68 100644 --- a/cvat-sdk/cvat_sdk/pytorch/transforms.py +++ b/cvat-sdk/cvat_sdk/pytorch/transforms.py @@ -10,7 +10,8 @@ import torch.utils.data from typing_extensions import TypedDict -from cvat_sdk.pytorch.common import Target, UnsupportedDatasetError +from cvat_sdk.datasets.common import UnsupportedDatasetError +from cvat_sdk.pytorch.common import Target @attrs.frozen diff --git a/tests/python/sdk/test_datasets.py b/tests/python/sdk/test_datasets.py new file mode 100644 index 000000000000..67204e4c26c9 --- /dev/null +++ b/tests/python/sdk/test_datasets.py @@ -0,0 +1,207 @@ +# Copyright (C) 2023 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import io +from logging import Logger +from pathlib import Path +from typing import Tuple + +import cvat_sdk.datasets as cvatds +import PIL.Image +import pytest +from cvat_sdk import Client, models +from cvat_sdk.core.proxies.tasks import ResourceType + +from shared.utils.helpers import generate_image_files + +from .util import restrict_api_requests + + +@pytest.fixture(autouse=True) +def _common_setup( + tmp_path: Path, + fxt_login: Tuple[Client, str], + fxt_logger: Tuple[Logger, io.StringIO], +): + logger = fxt_logger[0] + client = fxt_login[0] + client.logger = logger + client.config.cache_dir = tmp_path / "cache" + + api_client = client.api_client + for k in api_client.configuration.logger: + api_client.configuration.logger[k] = logger + + +class TestTaskDataset: + @pytest.fixture(autouse=True) + def setup( + self, + tmp_path: Path, + fxt_login: Tuple[Client, str], + ): + self.client = fxt_login[0] + self.images = generate_image_files(10) + + image_dir = tmp_path / "images" + image_dir.mkdir() + + image_paths = [] + for image in self.images: + image_path = image_dir / image.name + image_path.write_bytes(image.getbuffer()) + image_paths.append(image_path) + + self.task = self.client.tasks.create_from_data( + models.TaskWriteRequest( + "Dataset layer test task", + labels=[ + models.PatchedLabelRequest(name="person"), + models.PatchedLabelRequest(name="car"), + ], + ), + resource_type=ResourceType.LOCAL, + resources=image_paths, + data_params={"chunk_size": 3}, + ) + + self.expected_labels = sorted(self.task.get_labels(), key=lambda l: l.id) + + self.task.update_annotations( + models.PatchedLabeledDataRequest( + tags=[ + models.LabeledImageRequest(frame=8, label_id=self.expected_labels[0].id), + models.LabeledImageRequest(frame=8, label_id=self.expected_labels[1].id), + ], + shapes=[ + models.LabeledShapeRequest( + frame=6, + label_id=self.expected_labels[1].id, + type=models.ShapeType("rectangle"), + points=[1.0, 2.0, 3.0, 4.0], + ), + ], + ) + ) + + def test_basic(self): + dataset = cvatds.TaskDataset(self.client, self.task.id) + + # verify that the cache is not empty + assert list(self.client.config.cache_dir.iterdir()) + + for expected_label, actual_label in zip( + self.expected_labels, sorted(dataset.labels, key=lambda l: l.id) + ): + assert expected_label.id == actual_label.id + assert expected_label.name == actual_label.name + + assert len(dataset.samples) == self.task.size + + for index, sample in enumerate(dataset.samples): + assert sample.frame_index == index + + actual_image = sample.media.load_image() + expected_image = PIL.Image.open(self.images[index]) + + assert actual_image == expected_image + + assert not dataset.samples[0].annotations.tags + assert not dataset.samples[1].annotations.shapes + + assert {tag.label_id for tag in dataset.samples[8].annotations.tags} == { + label.id for label in self.expected_labels + } + assert not dataset.samples[8].annotations.shapes + + assert not dataset.samples[6].annotations.tags + assert len(dataset.samples[6].annotations.shapes) == 1 + assert dataset.samples[6].annotations.shapes[0].type.value == "rectangle" + assert dataset.samples[6].annotations.shapes[0].points == [1.0, 2.0, 3.0, 4.0] + + def test_deleted_frame(self): + self.task.remove_frames_by_ids([1]) + + dataset = cvatds.TaskDataset(self.client, self.task.id) + + assert len(dataset.samples) == self.task.size - 1 + + # sample #0 is still frame #0 + assert dataset.samples[0].frame_index == 0 + assert dataset.samples[0].media.load_image() == PIL.Image.open(self.images[0]) + + # sample #1 is now frame #2 + assert dataset.samples[1].frame_index == 2 + assert dataset.samples[1].media.load_image() == PIL.Image.open(self.images[2]) + + # sample #5 is now frame #6 + assert dataset.samples[5].frame_index == 6 + assert dataset.samples[5].media.load_image() == PIL.Image.open(self.images[6]) + assert len(dataset.samples[5].annotations.shapes) == 1 + + def test_offline(self, monkeypatch: pytest.MonkeyPatch): + dataset = cvatds.TaskDataset( + self.client, + self.task.id, + update_policy=cvatds.UpdatePolicy.IF_MISSING_OR_STALE, + ) + + fresh_samples = list(dataset.samples) + + restrict_api_requests(monkeypatch) + + dataset = cvatds.TaskDataset( + self.client, + self.task.id, + update_policy=cvatds.UpdatePolicy.NEVER, + ) + + cached_samples = list(dataset.samples) + + for fresh_sample, cached_sample in zip(fresh_samples, cached_samples): + assert fresh_sample.frame_index == cached_sample.frame_index + assert fresh_sample.annotations == cached_sample.annotations + assert fresh_sample.media.load_image() == cached_sample.media.load_image() + + def test_update(self, monkeypatch: pytest.MonkeyPatch): + dataset = cvatds.TaskDataset( + self.client, + self.task.id, + ) + + # Recreating the dataset should only result in minimal requests. + restrict_api_requests( + monkeypatch, allow_paths={f"/api/tasks/{self.task.id}", "/api/labels"} + ) + + dataset = cvatds.TaskDataset( + self.client, + self.task.id, + ) + + assert dataset.samples[6].annotations.shapes[0].label_id == self.expected_labels[1].id + + # After an update, the annotations should be redownloaded. + monkeypatch.undo() + + self.task.update_annotations( + models.PatchedLabeledDataRequest( + shapes=[ + models.LabeledShapeRequest( + id=dataset.samples[6].annotations.shapes[0].id, + frame=6, + label_id=self.expected_labels[0].id, + type=models.ShapeType("rectangle"), + points=[1.0, 2.0, 3.0, 4.0], + ), + ] + ) + ) + + dataset = cvatds.TaskDataset( + self.client, + self.task.id, + ) + + assert dataset.samples[6].annotations.shapes[0].label_id == self.expected_labels[0].id diff --git a/tests/python/sdk/test_pytorch.py b/tests/python/sdk/test_pytorch.py index bcfedf7b8b3b..722cb37ab003 100644 --- a/tests/python/sdk/test_pytorch.py +++ b/tests/python/sdk/test_pytorch.py @@ -7,12 +7,10 @@ import os from logging import Logger from pathlib import Path -from typing import Container, Tuple -from urllib.parse import urlparse +from typing import Tuple import pytest from cvat_sdk import Client, models -from cvat_sdk.api_client.rest import RESTClientObject from cvat_sdk.core.proxies.tasks import ResourceType try: @@ -30,6 +28,8 @@ from shared.utils.helpers import generate_image_files +from .util import restrict_api_requests + @pytest.fixture(autouse=True) def _common_setup( @@ -47,20 +47,6 @@ def _common_setup( api_client.configuration.logger[k] = logger -def _restrict_api_requests( - monkeypatch: pytest.MonkeyPatch, allow_paths: Container[str] = () -) -> None: - original_request = RESTClientObject.request - - def restricted_request(self, method, url, *args, **kwargs): - parsed_url = urlparse(url) - if parsed_url.path in allow_paths: - return original_request(self, method, url, *args, **kwargs) - raise RuntimeError("Disallowed!") - - monkeypatch.setattr(RESTClientObject, "request", restricted_request) - - @pytest.mark.skipif(cvatpt is None, reason="PyTorch dependencies are not installed") class TestTaskVisionDataset: @pytest.fixture(autouse=True) @@ -254,7 +240,7 @@ def test_offline(self, monkeypatch: pytest.MonkeyPatch): fresh_samples = list(dataset) - _restrict_api_requests(monkeypatch) + restrict_api_requests(monkeypatch) dataset = cvatpt.TaskVisionDataset( self.client, @@ -273,7 +259,7 @@ def test_update(self, monkeypatch: pytest.MonkeyPatch): ) # Recreating the dataset should only result in minimal requests. - _restrict_api_requests( + restrict_api_requests( monkeypatch, allow_paths={f"/api/tasks/{self.task.id}", "/api/labels"} ) @@ -447,7 +433,7 @@ def test_offline(self, monkeypatch: pytest.MonkeyPatch): fresh_samples = list(dataset) - _restrict_api_requests(monkeypatch) + restrict_api_requests(monkeypatch) dataset = cvatpt.ProjectVisionDataset( self.client, diff --git a/tests/python/sdk/util.py b/tests/python/sdk/util.py index 83e6b10e2908..5861c658111a 100644 --- a/tests/python/sdk/util.py +++ b/tests/python/sdk/util.py @@ -4,8 +4,11 @@ import textwrap from pathlib import Path -from typing import Tuple +from typing import Container, Tuple +from urllib.parse import urlparse +import pytest +from cvat_sdk.api_client.rest import RESTClientObject from cvat_sdk.core.helpers import TqdmProgressReporter from tqdm import tqdm @@ -82,3 +85,17 @@ def generate_coco_anno(image_path: str, image_width: int, image_height: int) -> "image_width": image_width, } ) + + +def restrict_api_requests( + monkeypatch: pytest.MonkeyPatch, allow_paths: Container[str] = () +) -> None: + original_request = RESTClientObject.request + + def restricted_request(self, method, url, *args, **kwargs): + parsed_url = urlparse(url) + if parsed_url.path in allow_paths: + return original_request(self, method, url, *args, **kwargs) + raise RuntimeError("Disallowed!") + + monkeypatch.setattr(RESTClientObject, "request", restricted_request) From 783c414cfd08b7d0bc1510936bca955fa0c5a733 Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Wed, 19 Jul 2023 07:06:40 +0300 Subject: [PATCH 7/9] Close av contaner after use (#6501) --- CHANGELOG.md | 1 + cvat/apps/engine/media_extractors.py | 126 +++++++++++++-------------- 2 files changed, 62 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a39e413eaae..604f34deb964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 () - \[SDK\] SDK should not change input data in models () - 3D job can not be opened in validation mode () +- Memory leak related to unclosed av container () ### Security - TDB diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index c79bd84a8ea0..65ab80ae4745 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -429,32 +429,27 @@ def _has_frame(self, i): return False - def _decode(self, container): - frame_num = 0 - for packet in container.demux(): - if packet.stream.type == 'video': + def __iter__(self): + with self._get_av_container() as container: + stream = container.streams.video[0] + stream.thread_type = 'AUTO' + frame_num = 0 + for packet in container.demux(stream): for image in packet.decode(): frame_num += 1 if self._has_frame(frame_num - 1): if packet.stream.metadata.get('rotate'): - old_image = image + pts = image.pts image = av.VideoFrame().from_ndarray( rotate_image( image.to_ndarray(format='bgr24'), - 360 - int(container.streams.video[0].metadata.get('rotate')) + 360 - int(stream.metadata.get('rotate')) ), format ='bgr24' ) - image.pts = old_image.pts + image.pts = pts yield (image, self._source_path[0], image.pts) - def __iter__(self): - container = self._get_av_container() - source_video_stream = container.streams.video[0] - source_video_stream.thread_type = 'AUTO' - - return self._decode(container) - def get_progress(self, pos): duration = self._get_duration() return pos / duration if duration else None @@ -465,38 +460,38 @@ def _get_av_container(self): return av.open(self._source_path[0]) def _get_duration(self): - container = self._get_av_container() - stream = container.streams.video[0] - duration = None - if stream.duration: - duration = stream.duration - else: - # may have a DURATION in format like "01:16:45.935000000" - duration_str = stream.metadata.get("DURATION", None) - tb_denominator = stream.time_base.denominator - if duration_str and tb_denominator: - _hour, _min, _sec = duration_str.split(':') - duration_sec = 60*60*float(_hour) + 60*float(_min) + float(_sec) - duration = duration_sec * tb_denominator - return duration + with self._get_av_container() as container: + stream = container.streams.video[0] + duration = None + if stream.duration: + duration = stream.duration + else: + # may have a DURATION in format like "01:16:45.935000000" + duration_str = stream.metadata.get("DURATION", None) + tb_denominator = stream.time_base.denominator + if duration_str and tb_denominator: + _hour, _min, _sec = duration_str.split(':') + duration_sec = 60*60*float(_hour) + 60*float(_min) + float(_sec) + duration = duration_sec * tb_denominator + return duration def get_preview(self, frame): - container = self._get_av_container() - stream = container.streams.video[0] - tb_denominator = stream.time_base.denominator - needed_time = int((frame / stream.guessed_rate) * tb_denominator) - container.seek(offset=needed_time, stream=stream) - for packet in container.demux(stream): - for frame in packet.decode(): - return self._get_preview(frame.to_image() if not stream.metadata.get('rotate') \ - else av.VideoFrame().from_ndarray( - rotate_image( - frame.to_ndarray(format='bgr24'), - 360 - int(container.streams.video[0].metadata.get('rotate')) - ), - format ='bgr24' - ).to_image() - ) + with self._get_av_container() as container: + stream = container.streams.video[0] + tb_denominator = stream.time_base.denominator + needed_time = int((frame / stream.guessed_rate) * tb_denominator) + container.seek(offset=needed_time, stream=stream) + for packet in container.demux(stream): + for frame in packet.decode(): + return self._get_preview(frame.to_image() if not stream.metadata.get('rotate') \ + else av.VideoFrame().from_ndarray( + rotate_image( + frame.to_ndarray(format='bgr24'), + 360 - int(container.streams.video[0].metadata.get('rotate')) + ), + format ='bgr24' + ).to_image() + ) def get_image_size(self, i): image = (next(iter(self)))[0] @@ -700,6 +695,8 @@ def save_as_chunk( return image_sizes class Mpeg4ChunkWriter(IChunkWriter): + FORMAT = 'mp4' + def __init__(self, quality=67): # translate inversed range [1:100] to [0:51] quality = round(51 * (100 - quality) / 99) @@ -722,21 +719,20 @@ def __init__(self, quality=67): "preset": "ultrafast", } - def _create_av_container(self, path, w, h, rate, options, f='mp4'): + def _add_video_stream(self, container, w, h, rate, options): # x264 requires width and height must be divisible by 2 for yuv420p if h % 2: h += 1 if w % 2: w += 1 - container = av.open(path, 'w',format=f) video_stream = container.add_stream(self._codec_name, rate=rate) video_stream.pix_fmt = "yuv420p" video_stream.width = w video_stream.height = h video_stream.options = options - return container, video_stream + return video_stream def save_as_chunk(self, images, chunk_path): if not images: @@ -745,16 +741,16 @@ def save_as_chunk(self, images, chunk_path): input_w = images[0][0].width input_h = images[0][0].height - output_container, output_v_stream = self._create_av_container( - path=chunk_path, - w=input_w, - h=input_h, - rate=self._output_fps, - options=self._codec_opts, - ) + with av.open(chunk_path, 'w', format=self.FORMAT) as output_container: + output_v_stream = self._add_video_stream( + container=output_container, + w=input_w, + h=input_h, + rate=self._output_fps, + options=self._codec_opts, + ) - self._encode_images(images, output_container, output_v_stream) - output_container.close() + self._encode_images(images, output_container, output_v_stream) return [(input_w, input_h)] @staticmethod @@ -797,16 +793,16 @@ def save_as_chunk(self, images, chunk_path): output_h = input_h // downscale_factor output_w = input_w // downscale_factor - output_container, output_v_stream = self._create_av_container( - path=chunk_path, - w=output_w, - h=output_h, - rate=self._output_fps, - options=self._codec_opts, - ) + with av.open(chunk_path, 'w', format=self.FORMAT) as output_container: + output_v_stream = self._add_video_stream( + container=output_container, + w=output_w, + h=output_h, + rate=self._output_fps, + options=self._codec_opts, + ) - self._encode_images(images, output_container, output_v_stream) - output_container.close() + self._encode_images(images, output_container, output_v_stream) return [(input_w, input_h)] def _is_archive(path): From 19fefc5e3bc3f8cc4a94d269e2125dda950eedc5 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Wed, 19 Jul 2023 15:22:21 +0300 Subject: [PATCH 8/9] Fixed calculating statistics in case with removed frames (#6493) ### Motivation and context ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [ ] I have added a description of my changes into the [CHANGELOG](https://github.com/opencv/cvat/blob/develop/CHANGELOG.md) file - [ ] I have updated the documentation accordingly - [x] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [x] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/opencv/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/opencv/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/opencv/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/opencv/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/opencv/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. --- CHANGELOG.md | 2 ++ cvat-core/package.json | 2 +- cvat-core/src/annotations-collection.ts | 24 +++++++++++++++++++----- cvat-core/tests/api/annotations.js | 22 ++++++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 604f34deb964..759993515dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - TDB ### Fixed +- Calculating number of objects on annotation view when frames are deleted + () - \[SDK\] Ability to create attributes with blank default values () - \[SDK\] SDK should not change input data in models () diff --git a/cvat-core/package.json b/cvat-core/package.json index b35b9122a9f0..5aca1d1ab763 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "9.2.0", + "version": "9.2.1", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "src/api.ts", "scripts": { diff --git a/cvat-core/src/annotations-collection.ts b/cvat-core/src/annotations-collection.ts index 8755867c4e79..cf6be26c2da3 100644 --- a/cvat-core/src/annotations-collection.ts +++ b/cvat-core/src/annotations-collection.ts @@ -659,18 +659,32 @@ export default class Collection { fillBody(Object.values(this.labels).filter((label) => !label.hasParent)); const scanTrack = (track, prefix = ''): void => { + const countInterpolatedFrames = (start: number, stop: number, lastIsKeyframe: boolean): number => { + let count = stop - start; + if (lastIsKeyframe) { + count -= 1; + } + for (let i = start + 1; lastIsKeyframe ? i < stop : i <= stop; i++) { + if (this.frameMeta.deleted_frames[i]) { + count--; + } + } + return count; + }; + const pref = prefix ? `${prefix}${sep}` : ''; const label = `${pref}${track.label.name}`; labels[label][track.shapeType].track++; const keyframes = Object.keys(track.shapes) .sort((a, b) => +a - +b) - .map((el) => +el); + .map((el) => +el) + .filter((frame) => !this.frameMeta.deleted_frames[frame]); let prevKeyframe = keyframes[0]; let visible = false; for (const keyframe of keyframes) { if (visible) { - const interpolated = keyframe - prevKeyframe - 1; + const interpolated = countInterpolatedFrames(prevKeyframe, keyframe, true); labels[label].interpolated += interpolated; labels[label].total += interpolated; } @@ -692,7 +706,7 @@ export default class Collection { } if (lastKey !== this.stopFrame && !track.get(lastKey).outside) { - const interpolated = this.stopFrame - lastKey; + const interpolated = countInterpolatedFrames(lastKey, this.stopFrame, false); labels[label].interpolated += interpolated; labels[label].total += interpolated; } @@ -719,13 +733,13 @@ export default class Collection { } const { name: label } = object.label; - if (objectType === 'tag') { + if (objectType === 'tag' && !this.frameMeta.deleted_frames[object.frame]) { labels[label].tag++; labels[label].manually++; labels[label].total++; } else if (objectType === 'track') { scanTrack(object); - } else { + } else if (!this.frameMeta.deleted_frames[object.frame]) { const { shapeType } = object as Shape; labels[label][shapeType].shape++; labels[label].manually++; diff --git a/cvat-core/tests/api/annotations.js b/cvat-core/tests/api/annotations.js index 3fd6727b78a9..a8bb9ba57154 100644 --- a/cvat-core/tests/api/annotations.js +++ b/cvat-core/tests/api/annotations.js @@ -872,7 +872,29 @@ describe('Feature: get statistics', () => { expect(statistics.label[labelName].manually).toBe(2); expect(statistics.label[labelName].interpolated).toBe(3); expect(statistics.label[labelName].total).toBe(5); + }); + test('get statistics from a job with skeletons', async () => { + const job = (await window.cvat.jobs.get({ jobID: 102 }))[0]; + await job.annotations.clear(true); + let statistics = await job.annotations.statistics(); + expect(statistics.total.manually).toBe(5); + expect(statistics.total.interpolated).toBe(443); + expect(statistics.total.tag).toBe(1); + expect(statistics.total.rectangle.shape).toBe(1); + expect(statistics.total.rectangle.track).toBe(1); + await job.frames.delete(500); // track frame + await job.frames.delete(510); // rectangle shape frame + await job.frames.delete(550); // the first keyframe of a track + statistics = await job.annotations.statistics(); + expect(statistics.total.manually).toBe(2); + expect(statistics.total.tag).toBe(0); + expect(statistics.total.rectangle.shape).toBe(0); + expect(statistics.total.interpolated).toBe(394); + await job.frames.delete(650); // intermediate frame in a track + statistics = await job.annotations.statistics(); + expect(statistics.total.interpolated).toBe(393); + await job.close(); }); }); From 8de7722e09b3df7e38078fda163d05e950ef1a4e Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Wed, 19 Jul 2023 15:23:21 +0300 Subject: [PATCH 9/9] Fixed using initial frame from query parameter (#6506) ### Motivation and context Resolved #6505 ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have added a description of my changes into the [CHANGELOG](https://github.com/opencv/cvat/blob/develop/CHANGELOG.md) file - [ ] I have updated the documentation accordingly - [x] I have added tests to cover my changes - [x] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [x] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/opencv/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/opencv/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/opencv/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/opencv/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/opencv/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. --- CHANGELOG.md | 2 ++ cvat-ui/package.json | 2 +- cvat-ui/src/actions/annotation-actions.ts | 9 +++++---- ...ontinue_frame_n.js => navigate_specific_frame.js} | 12 +++++++++++- 4 files changed, 19 insertions(+), 6 deletions(-) rename tests/cypress/e2e/actions_tasks/{continue_frame_n.js => navigate_specific_frame.js} (80%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 759993515dd3..57dd773edb26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - \[SDK\] SDK should not change input data in models () - 3D job can not be opened in validation mode () - Memory leak related to unclosed av container () +- Using initial frame from query parameter to open specific frame in a job + () ### Security - TDB diff --git a/cvat-ui/package.json b/cvat-ui/package.json index f506b0e81a1b..ef964a392d1c 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.53.1", + "version": "1.53.3", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 33ad6d48b9df..8b7a899b9ec3 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -942,10 +942,11 @@ export function getJobAsync( if (report) conflicts = await cvat.analytics.quality.conflicts({ reportId: report.id }); } - // navigate to correct first frame according to setup - const frameNumber = (await job.frames.search( - { notDeleted: !showDeletedFrames }, job.startFrame, job.stopFrame, - )) || job.startFrame; + // frame query parameter does not work for GT job + const frameNumber = Number.isInteger(initialFrame) && groundTruthJobId !== job.id ? + initialFrame : (await job.frames.search( + { notDeleted: !showDeletedFrames }, job.startFrame, job.stopFrame, + )) || job.startFrame; const frameData = await job.frames.get(frameNumber); // call first getting of frame data before rendering interface diff --git a/tests/cypress/e2e/actions_tasks/continue_frame_n.js b/tests/cypress/e2e/actions_tasks/navigate_specific_frame.js similarity index 80% rename from tests/cypress/e2e/actions_tasks/continue_frame_n.js rename to tests/cypress/e2e/actions_tasks/navigate_specific_frame.js index a369ad969ef0..ea34133eebd4 100644 --- a/tests/cypress/e2e/actions_tasks/continue_frame_n.js +++ b/tests/cypress/e2e/actions_tasks/navigate_specific_frame.js @@ -6,7 +6,7 @@ context('Paste labels from one task to another.', { browser: '!firefox' }, () => { const task = { - name: 'Test "Continue frame N"', + name: 'Test "Continue/open frame N"', label: 'Test label', attrName: 'Test attribute', attrValue: 'Test attribute value', @@ -54,5 +54,15 @@ context('Paste labels from one task to another.', { browser: '!firefox' }, () => cy.get('.cvat-notification-continue-job-button').click(); cy.checkFrameNum(2); }); + + it('Trying to open a frame using query parameter', () => { + cy.url().then(($url) => { + cy.visit('/projects'); + cy.get('.cvat-projects-page').should('exist'); + cy.visit($url, { qs: { frame: 2 } }); + cy.get('.cvat-canvas-container').should('exist').and('be.visible'); + cy.checkFrameNum(2); + }); + }); }); });