diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b52deddc3f58..c93361d55975 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,7 +5,7 @@ on: - 'master' - 'develop' pull_request: - types: [ready_for_review, opened, synchronize, reopened] + types: [opened, synchronize, reopened] concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.gitignore b/.gitignore index 9736baa80a3f..c375c7df4e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ yarn-error.log* # Ignore all the installed packages node_modules +venv/ +.venv/ # Ignore all js dists cvat-data/dist diff --git a/.vscode/launch.json b/.vscode/launch.json index 78f24c96ca83..cb4b0f9dcf0f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -444,6 +444,8 @@ "python": "${command:python.interpreterPath}", "module": "pytest", "args": [ + "--verbose", + "--no-cov", // vscode debugger might not work otherwise "tests/python/rest_api/" ], "cwd": "${workspaceFolder}", diff --git a/CHANGELOG.md b/CHANGELOG.md index f798c1fa2766..31a4aae3db7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +## \[2.24.0\] - 2024-12-20 + +### Added + +- \[CLI\] Added new commands: `project create`, `project delete`, `project ls` + () + +- \[SDK\] You can now use `client.projects.remove_by_ids` to remove multiple + projects + () + +- Support for boolean parameters in annotations actions + () + +### Changed + +- Improved uniformity of validation frames distribution in honeypot tasks and + random honeypot rerolls + () + +- \[CLI\] Switched to a new subcommand hierarchy; now CLI subcommands + have the form `cvat-cli ` + () + +- \[CLI\] The output of the `task create`, `task create-from-backup` and + `project create` commands is now just the created resource ID, + making it machine-readable + () + +- /api/events can now be used to receive events from several sources + () + +### Deprecated + +- \[CLI\] All existing CLI commands of the form `cvat-cli ` + are now deprecated. Use `cvat-cli task ` instead + () + +### Removed + +- Automatic calculation of quality reports in tasks + () + +### Fixed + +- Uploading a skeleton template in configurator does not work + () + +- Installation of YOLOv7 on GPU + () + +- \[Server API\] Significantly improved preformance of honeypot changes in tasks + () +- \[Server API\] `PATCH tasks/id/validation_layout` responses now include correct + `disabled_frames` and handle simultaneous updates of + `disabled_frames` and honeypot frames correctly + () + +- Fixed handling of tracks keyframes from deleted frames on export + () + +- Exporting datasets could start significantly later than expected, both for 1 + and several users in the same project/task/job () +- Scheduled RQ jobs could not be restarted due to incorrect RQ job status + updating and handling () + ## \[2.23.1\] - 2024-12-09 diff --git a/cvat-cli/README.md b/cvat-cli/README.md index 71c19b79d908..bbd98c0980c9 100644 --- a/cvat-cli/README.md +++ b/cvat-cli/README.md @@ -1,19 +1,26 @@ # Command-line client for CVAT -A simple command line interface for working with CVAT tasks. At the moment it +A simple command line interface for working with CVAT. At the moment it implements a basic feature set but may serve as the starting point for a more comprehensive CVAT administration tool in the future. -Overview of functionality: +The following subcommands are supported: -- Create a new task (supports name, bug tracker, project, labels JSON, local/share/remote files) -- Delete tasks (supports deleting a list of task IDs) -- List all tasks (supports basic CSV or JSON output) -- Download JPEG frames (supports a list of frame IDs) -- Dump annotations (supports all formats via format string) -- Upload annotations for a task in the specified format (e.g. 'YOLO ZIP 1.0') -- Export and download a whole task -- Import a task +- Projects: + - `create` - create a new project + - `delete` - delete projects + - `ls` - list all projects + +- Tasks: + - `create` - create a new task + - `create-from-backup` - create a task from a backup file + - `delete` - delete tasks + - `ls` - list all tasks + - `frames` - download frames from a task + - `export-dataset` - export a task as a dataset + - `import-dataset` - import annotations into a task from a dataset + - `backup` - back up a task + - `auto-annotate` - automatically annotate a task using a local function ## Installation @@ -21,29 +28,25 @@ Overview of functionality: ## Usage -```bash -$ cvat-cli --help - -usage: cvat-cli [-h] [--version] [--auth USER:[PASS]] - [--server-host SERVER_HOST] [--server-port SERVER_PORT] [--debug] - {create,delete,ls,frames,dump,upload,export,import} ... - -Perform common operations related to CVAT tasks. - -positional arguments: - {create,delete,ls,frames,dump,upload,export,import} - -optional arguments: - -h, --help show this help message and exit - --version show program's version number and exit - --auth USER:[PASS] defaults to the current user and supports the PASS - environment variable or password prompt - (default: current user) - --server-host SERVER_HOST - host (default: localhost) - --server-port SERVER_PORT - port (default: 8080) - --debug show debug output +The general form of a CLI command is: + +```console +$ cvat-cli +``` + +where: + +- `` are options shared between all subcommands; +- `` is a CVAT resource, such as `task`; +- `` is the action to do with the resource, such as `create`; +- `` is any options specific to a particular resource and action. + +You can list available subcommands and options using the `--help` option: + +``` +$ cvat-cli --help # get help on available common options and resources +$ cvat-cli --help # get help on actions for the given resource +$ cvat-cli --help # get help on action-specific options ``` ## Examples @@ -51,7 +54,7 @@ optional arguments: Create a task with local images: ```bash -cvat-cli --auth user create +cvat-cli --auth user task create --labels '[{"name": "car"}, {"name": "person"}]' "test_task" "local" @@ -63,5 +66,5 @@ List tasks on a custom server with auth: ```bash cvat-cli --auth admin:password \ --server-host cvat.my.server.com --server-port 30123 \ - ls + task ls ``` diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index 8793644b6339..664017edebe3 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,3 +1,3 @@ -cvat-sdk~=2.23.1 +cvat-sdk~=2.24.0 Pillow>=10.3.0 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/__main__.py b/cvat-cli/src/cvat_cli/__main__.py index 3d89935fc2d0..c93569182c08 100755 --- a/cvat-cli/src/cvat_cli/__main__.py +++ b/cvat-cli/src/cvat_cli/__main__.py @@ -10,7 +10,7 @@ import urllib3.exceptions from cvat_sdk import exceptions -from ._internal.commands import COMMANDS +from ._internal.commands_all import COMMANDS from ._internal.common import build_client, configure_common_arguments, configure_logger from ._internal.utils import popattr diff --git a/cvat-cli/src/cvat_cli/_internal/command_base.py b/cvat-cli/src/cvat_cli/_internal/command_base.py index ec6ccbbcd47f..94e13f3f16e9 100644 --- a/cvat-cli/src/cvat_cli/_internal/command_base.py +++ b/cvat-cli/src/cvat_cli/_internal/command_base.py @@ -3,10 +3,15 @@ # SPDX-License-Identifier: MIT import argparse +import json +import textwrap import types -from collections.abc import Mapping +from abc import ABCMeta, abstractmethod +from collections.abc import Mapping, Sequence from typing import Callable, Protocol +from cvat_sdk import Client + class Command(Protocol): @property @@ -51,3 +56,71 @@ def execute(self) -> None: # It should be impossible for a command group to be executed, # because configure_parser requires that a subcommand is specified. assert False, "unreachable code" + + +class DeprecatedAlias: + def __init__(self, command: Command, replacement: str) -> None: + self._command = command + self._replacement = replacement + + @property + def description(self) -> str: + return textwrap.dedent( + f"""\ + {self._command.description} + (Deprecated; use "{self._replacement}" instead.) + """ + ) + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + self._command.configure_parser(parser) + + def execute(self, client: Client, **kwargs) -> None: + client.logger.warning('This command is deprecated. Use "%s" instead.', self._replacement) + self._command.execute(client, **kwargs) + + +class GenericCommand(metaclass=ABCMeta): + @abstractmethod + def repo(self, client: Client): ... + + @property + @abstractmethod + def resource_type_str(self) -> str: ... + + +class GenericListCommand(GenericCommand): + @property + def description(self) -> str: + return f"List all CVAT {self.resource_type_str}s in either basic or JSON format." + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--json", + dest="use_json_output", + default=False, + action="store_true", + help="output JSON data", + ) + + def execute(self, client: Client, *, use_json_output: bool = False): + results = self.repo(client).list(return_json=use_json_output) + if use_json_output: + print(json.dumps(json.loads(results), indent=2)) + else: + for r in results: + print(r.id) + + +class GenericDeleteCommand(GenericCommand): + @property + def description(self): + return f"Delete a list of {self.resource_type_str}s, ignoring those which don't exist." + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "ids", type=int, help=f"list of {self.resource_type_str} IDs", nargs="+" + ) + + def execute(self, client: Client, *, ids: Sequence[int]) -> None: + self.repo(client).remove_by_ids(ids) diff --git a/cvat-cli/src/cvat_cli/_internal/commands_all.py b/cvat-cli/src/cvat_cli/_internal/commands_all.py new file mode 100644 index 000000000000..758d6b1d05e8 --- /dev/null +++ b/cvat-cli/src/cvat_cli/_internal/commands_all.py @@ -0,0 +1,27 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from .command_base import CommandGroup, DeprecatedAlias +from .commands_projects import COMMANDS as COMMANDS_PROJECTS +from .commands_tasks import COMMANDS as COMMANDS_TASKS + +COMMANDS = CommandGroup(description="Perform operations on CVAT resources.") + +COMMANDS.add_command("project", COMMANDS_PROJECTS) +COMMANDS.add_command("task", COMMANDS_TASKS) + +_legacy_mapping = { + "create": "create", + "ls": "ls", + "delete": "delete", + "frames": "frames", + "dump": "export-dataset", + "upload": "import-dataset", + "export": "backup", + "import": "create-from-backup", + "auto-annotate": "auto-annotate", +} + +for _legacy, _new in _legacy_mapping.items(): + COMMANDS.add_command(_legacy, DeprecatedAlias(COMMANDS_TASKS.commands[_new], f"task {_new}")) diff --git a/cvat-cli/src/cvat_cli/_internal/commands_projects.py b/cvat-cli/src/cvat_cli/_internal/commands_projects.py new file mode 100644 index 000000000000..b6c39eeef434 --- /dev/null +++ b/cvat-cli/src/cvat_cli/_internal/commands_projects.py @@ -0,0 +1,91 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import argparse +import textwrap + +from cvat_sdk import Client, models + +from .command_base import CommandGroup, GenericCommand, GenericDeleteCommand, GenericListCommand +from .parsers import parse_label_arg + +COMMANDS = CommandGroup(description="Perform operations on CVAT projects.") + + +class GenericProjectCommand(GenericCommand): + resource_type_str = "project" + + def repo(self, client: Client): + return client.projects + + +@COMMANDS.command_class("ls") +class ProjectList(GenericListCommand, GenericProjectCommand): + pass + + +@COMMANDS.command_class("create") +class ProjectCreate: + description = textwrap.dedent( + """\ + Create a new CVAT project, optionally importing a dataset. + """ + ) + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument("name", type=str, help="name of the project") + parser.add_argument( + "--bug_tracker", "--bug", default=argparse.SUPPRESS, type=str, help="bug tracker URL" + ) + parser.add_argument( + "--labels", + default=[], + type=parse_label_arg, + help="string or file containing JSON labels specification (default: %(default)s)", + ) + parser.add_argument( + "--dataset_path", + default="", + type=str, + help="path to the dataset file to import", + ) + parser.add_argument( + "--dataset_format", + default="CVAT 1.1", + type=str, + help="format of the dataset file being uploaded" + " (only applies when --dataset_path is specified; default: %(default)s)", + ) + parser.add_argument( + "--completion_verification_period", + dest="status_check_period", + default=2, + type=float, + help="period between status checks" + " (only applies when --dataset_path is specified; default: %(default)s)", + ) + + def execute( + self, + client: Client, + *, + name: str, + labels: dict, + dataset_path: str, + dataset_format: str, + status_check_period: int, + **kwargs, + ) -> None: + project = client.projects.create_from_dataset( + spec=models.ProjectWriteRequest(name=name, labels=labels, **kwargs), + dataset_path=dataset_path, + dataset_format=dataset_format, + status_check_period=status_check_period, + ) + print(project.id) + + +@COMMANDS.command_class("delete") +class ProjectDelete(GenericDeleteCommand, GenericProjectCommand): + pass diff --git a/cvat-cli/src/cvat_cli/_internal/commands.py b/cvat-cli/src/cvat_cli/_internal/commands_tasks.py similarity index 90% rename from cvat-cli/src/cvat_cli/_internal/commands.py rename to cvat-cli/src/cvat_cli/_internal/commands_tasks.py index efda05c58454..8c6782887d97 100644 --- a/cvat-cli/src/cvat_cli/_internal/commands.py +++ b/cvat-cli/src/cvat_cli/_internal/commands_tasks.py @@ -7,7 +7,6 @@ import argparse import importlib import importlib.util -import json import textwrap from collections.abc import Sequence from pathlib import Path @@ -19,7 +18,7 @@ from cvat_sdk.core.helpers import DeferredTqdmProgressReporter from cvat_sdk.core.proxies.tasks import ResourceType -from .command_base import CommandGroup +from .command_base import CommandGroup, GenericCommand, GenericDeleteCommand, GenericListCommand from .parsers import ( BuildDictAction, parse_function_parameter, @@ -28,29 +27,19 @@ parse_threshold, ) -COMMANDS = CommandGroup(description="Perform common operations related to CVAT tasks.") +COMMANDS = CommandGroup(description="Perform operations on CVAT tasks.") -@COMMANDS.command_class("ls") -class TaskList: - description = "List all CVAT tasks in either basic or JSON format." +class GenericTaskCommand(GenericCommand): + resource_type_str = "task" - def configure_parser(self, parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--json", - dest="use_json_output", - default=False, - action="store_true", - help="output JSON data", - ) + def repo(self, client: Client): + return client.tasks - def execute(self, client: Client, *, use_json_output: bool = False): - results = client.tasks.list(return_json=use_json_output) - if use_json_output: - print(json.dumps(json.loads(results), indent=2)) - else: - for r in results: - print(r.id) + +@COMMANDS.command_class("ls") +class TaskList(GenericListCommand, GenericTaskCommand): + pass @COMMANDS.command_class("create") @@ -237,18 +226,12 @@ def execute( status_check_period=status_check_period, pbar=DeferredTqdmProgressReporter(), ) - print("Created task id", task.id) + print(task.id) @COMMANDS.command_class("delete") -class TaskDelete: - description = "Delete a list of tasks, ignoring those which don't exist." - - def configure_parser(self, parser: argparse.ArgumentParser) -> None: - parser.add_argument("task_ids", type=int, help="list of task IDs", nargs="+") - - def execute(self, client: Client, *, task_ids: Sequence[int]) -> None: - client.tasks.remove_by_ids(task_ids=task_ids) +class TaskDelete(GenericDeleteCommand, GenericTaskCommand): + pass @COMMANDS.command_class("frames") @@ -291,11 +274,11 @@ def execute( ) -@COMMANDS.command_class("dump") -class TaskDump: +@COMMANDS.command_class("export-dataset") +class TaskExportDataset: description = textwrap.dedent( """\ - Download annotations for a task in the specified format (e.g. 'YOLO ZIP 1.0'). + Export a task as a dataset in the specified format (e.g. 'YOLO 1.1'). """ ) @@ -343,12 +326,12 @@ def execute( ) -@COMMANDS.command_class("upload") -class TaskUpload: +@COMMANDS.command_class("import-dataset") +class TaskImportDataset: description = textwrap.dedent( """\ - Upload annotations for a task in the specified format - (e.g. 'YOLO ZIP 1.0'). + Import annotations into a task from a dataset in the specified format + (e.g. 'YOLO 1.1'). """ ) @@ -378,8 +361,8 @@ def execute( ) -@COMMANDS.command_class("export") -class TaskExport: +@COMMANDS.command_class("backup") +class TaskBackup: description = """Download a task backup.""" def configure_parser(self, parser: argparse.ArgumentParser) -> None: @@ -403,9 +386,9 @@ def execute( ) -@COMMANDS.command_class("import") -class TaskImport: - description = """Import a task from a backup file.""" +@COMMANDS.command_class("create-from-backup") +class TaskCreateFromBackup: + description = """Create a task from a backup file.""" def configure_parser(self, parser: argparse.ArgumentParser) -> None: parser.add_argument("filename", type=str, help="upload file") @@ -423,7 +406,7 @@ def execute(self, client: Client, *, filename: str, status_check_period: int) -> status_check_period=status_check_period, pbar=DeferredTqdmProgressReporter(), ) - print(f"Created task ID", task.id) + print(task.id) @COMMANDS.command_class("auto-annotate") diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index c642e25a75ea..203e6c4bc9b2 100644 --- a/cvat-cli/src/cvat_cli/version.py +++ b/cvat-cli/src/cvat_cli/version.py @@ -1 +1 @@ -VERSION = "2.23.1" +VERSION = "2.24.0" diff --git a/cvat-core/src/annotations-actions/base-action.ts b/cvat-core/src/annotations-actions/base-action.ts index 3246261d2c9a..2ec2148b24c7 100644 --- a/cvat-core/src/annotations-actions/base-action.ts +++ b/cvat-core/src/annotations-actions/base-action.ts @@ -9,6 +9,7 @@ import { Job, Task } from '../session'; export enum ActionParameterType { SELECT = 'select', NUMBER = 'number', + CHECKBOX = 'checkbox', } // For SELECT values should be a list of possible options diff --git a/cvat-core/src/annotations-actions/base-collection-action.ts b/cvat-core/src/annotations-actions/base-collection-action.ts index c48135694566..f2676bbdc375 100644 --- a/cvat-core/src/annotations-actions/base-collection-action.ts +++ b/cvat-core/src/annotations-actions/base-collection-action.ts @@ -111,7 +111,6 @@ export async function run( await instance.annotations.commit(created, deleted, frame); event.close(); } finally { - wrappedOnProgress('Finalizing', 100); await action.destroy(); } } diff --git a/cvat-core/src/annotations-actions/base-shapes-action.ts b/cvat-core/src/annotations-actions/base-shapes-action.ts index 80d2b4fee78b..9eb65f052ee4 100644 --- a/cvat-core/src/annotations-actions/base-shapes-action.ts +++ b/cvat-core/src/annotations-actions/base-shapes-action.ts @@ -142,7 +142,6 @@ export async function run( event.close(); } finally { - throttledOnProgress('Finalizing', 100); await action.destroy(); } } diff --git a/cvat-sdk/cvat_sdk/core/proxies/jobs.py b/cvat-sdk/cvat_sdk/core/proxies/jobs.py index 5eb3e6767477..4dde676179b7 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/jobs.py +++ b/cvat-sdk/cvat_sdk/core/proxies/jobs.py @@ -53,7 +53,7 @@ def import_annotations( pbar: Optional[ProgressReporter] = None, ): """ - Upload annotations for a job in the specified format (e.g. 'YOLO ZIP 1.0'). + Upload annotations for a job in the specified format (e.g. 'YOLO 1.1'). """ filename = Path(filename) diff --git a/cvat-sdk/cvat_sdk/core/proxies/model_proxy.py b/cvat-sdk/cvat_sdk/core/proxies/model_proxy.py index 1557a61861ae..124d7c2beb3d 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/model_proxy.py +++ b/cvat-sdk/cvat_sdk/core/proxies/model_proxy.py @@ -6,6 +6,7 @@ import json from abc import ABC +from collections.abc import Sequence from copy import deepcopy from pathlib import Path from typing import ( @@ -162,6 +163,27 @@ def list(self: Repo, *, return_json: bool = False) -> list[Union[_EntityT, Any]] return [self._entity_type(self._client, model) for model in results] +class ModelBatchDeleteMixin(Repo): + def remove_by_ids(self, ids: Sequence[int], /) -> None: + """ + Delete a list of objects from the server, ignoring those which don't exist. + """ + type_name = self._entity_type.__name__ + + for object_id in ids: + (_, response) = self.api.destroy(object_id, _check_status=False) + + if 200 <= response.status <= 299: + self._client.logger.info(f"{type_name} #{object_id} deleted") + elif response.status == 404: + self._client.logger.info(f"{type_name} #{object_id} not found") + else: + self._client.logger.error( + f"Failed to delete {type_name} #{object_id}: " + f"{response.msg} (status {response.status})" + ) + + #### Entity mixins @@ -300,7 +322,7 @@ def export_dataset( cloud_storage_id: Optional[int] = None, ) -> None: """ - Export a dataset in the specified format (e.g. 'YOLO ZIP 1.0'). + Export a dataset in the specified format (e.g. 'YOLO 1.1'). By default, a result file will be downloaded based on the default configuration. To force file downloading, pass `location=Location.LOCAL`. To save a file to a specific cloud storage, use the `location` and `cloud_storage_id` arguments. diff --git a/cvat-sdk/cvat_sdk/core/proxies/projects.py b/cvat-sdk/cvat_sdk/core/proxies/projects.py index 3e0eddc00e36..0ea7063eaf72 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/projects.py +++ b/cvat-sdk/cvat_sdk/core/proxies/projects.py @@ -15,6 +15,7 @@ from cvat_sdk.core.proxies.model_proxy import ( DownloadBackupMixin, ExportDatasetMixin, + ModelBatchDeleteMixin, ModelCreateMixin, ModelDeleteMixin, ModelListMixin, @@ -52,7 +53,7 @@ def import_dataset( pbar: Optional[ProgressReporter] = None, ): """ - Import dataset for a project in the specified format (e.g. 'YOLO ZIP 1.0'). + Import dataset for a project in the specified format (e.g. 'YOLO 1.1'). """ filename = Path(filename) @@ -97,6 +98,7 @@ class ProjectsRepo( ModelCreateMixin[Project, models.IProjectWriteRequest], ModelListMixin[Project], ModelRetrieveMixin[Project], + ModelBatchDeleteMixin, ): _entity_type = Project diff --git a/cvat-sdk/cvat_sdk/core/proxies/tasks.py b/cvat-sdk/cvat_sdk/core/proxies/tasks.py index 9654399bc8ce..23d8f1c84962 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/tasks.py +++ b/cvat-sdk/cvat_sdk/core/proxies/tasks.py @@ -24,6 +24,7 @@ from cvat_sdk.core.proxies.model_proxy import ( DownloadBackupMixin, ExportDatasetMixin, + ModelBatchDeleteMixin, ModelCreateMixin, ModelDeleteMixin, ModelListMixin, @@ -168,7 +169,7 @@ def import_annotations( pbar: Optional[ProgressReporter] = None, ): """ - Upload annotations for a task in the specified format (e.g. 'YOLO ZIP 1.0'). + Upload annotations for a task in the specified format (e.g. 'YOLO 1.1'). """ filename = Path(filename) @@ -286,6 +287,7 @@ class TasksRepo( ModelCreateMixin[Task, models.ITaskWriteRequest], ModelRetrieveMixin[Task], ModelListMixin[Task], + ModelBatchDeleteMixin, ): _entity_type = Task @@ -333,23 +335,16 @@ def create_from_data( return task + # This is a backwards compatibility wrapper to support calls which pass + # the task_ids parameter by keyword (the base class implementation is generic, + # so it doesn't support this). + # pylint: disable-next=arguments-differ def remove_by_ids(self, task_ids: Sequence[int]) -> None: """ Delete a list of tasks, ignoring those which don't exist. """ - for task_id in task_ids: - (_, response) = self.api.destroy(task_id, _check_status=False) - - if 200 <= response.status <= 299: - self._client.logger.info(f"Task ID {task_id} deleted") - elif response.status == 404: - self._client.logger.info(f"Task ID {task_id} not found") - else: - self._client.logger.warning( - f"Failed to delete task ID {task_id}: " - f"{response.msg} (status {response.status})" - ) + super().remove_by_ids(task_ids) def create_from_backup( self, diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index de41d2f680cb..f4d78e868601 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.23.1" +VERSION="2.24.0" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 703718121cd1..ce374b2e2be6 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -21,7 +21,7 @@ "license": "MIT", "dependencies": { "@ant-design/compatible": "^5.1.2", - "@ant-design/icons": "^4.6.3", + "@ant-design/icons": "^5.5.2", "@react-awesome-query-builder/antd": "^6.5.2", "@types/json-logic-js": "^2.0.2", "@types/lru-cache": "^7.10.10", diff --git a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx index f33dd9bf231a..509dd42b9b35 100644 --- a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx +++ b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx @@ -4,9 +4,11 @@ import './styles.scss'; -import React, { - useEffect, useReducer, useRef, useState, -} from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { createStore } from 'redux'; +import { + Provider, shallowEqual, useDispatch, useSelector, +} from 'react-redux'; import { createRoot } from 'react-dom/client'; import Button from 'antd/lib/button'; import { Col, Row } from 'antd/lib/grid'; @@ -17,14 +19,14 @@ import Text from 'antd/lib/typography/Text'; import Modal from 'antd/lib/modal'; import Alert from 'antd/lib/alert'; import InputNumber from 'antd/lib/input-number'; +import Switch from 'antd/lib/switch'; import config from 'config'; -import { useIsMounted } from 'utils/hooks'; import { createAction, ActionUnion } from 'utils/redux'; import { getCVATStore } from 'cvat-store'; import { BaseCollectionAction, BaseAction, Job, getCore, - ObjectState, + ObjectState, ActionParameterType, } from 'cvat-core-wrapper'; import { Canvas } from 'cvat-canvas-wrapper'; import { fetchAnnotationsAsync } from 'actions/annotation-actions'; @@ -35,18 +37,20 @@ const core = getCore(); interface State { actions: BaseAction[]; activeAction: BaseAction | null; + initialized: boolean; fetching: boolean; progress: number | null; progressMessage: string | null; cancelled: boolean; frameFrom: number; frameTo: number; - actionParameters: Record; + actionParameters: Record>; modalVisible: boolean; targetObjectState?: ObjectState | null; } enum ReducerActionType { + SET_INITIALIZED = 'SET_INITIALIZED', SET_ANNOTATIONS_ACTIONS = 'SET_ANNOTATIONS_ACTIONS', SET_ACTIVE_ANNOTATIONS_ACTION = 'SET_ACTIVE_ANNOTATIONS_ACTION', UPDATE_PROGRESS = 'UPDATE_PROGRESS', @@ -55,11 +59,15 @@ enum ReducerActionType { CANCEL_ACTION = 'CANCEL_ACTION', UPDATE_FRAME_FROM = 'UPDATE_FRAME_FROM', UPDATE_FRAME_TO = 'UPDATE_FRAME_TO', + UPDATE_TARGET_OBJECT_STATE = 'UPDATE_TARGET_OBJECT_STATE', UPDATE_ACTION_PARAMETER = 'UPDATE_ACTION_PARAMETER', SET_VISIBLE = 'SET_VISIBLE', } export const reducerActions = { + setInitialized: (initialized: boolean) => ( + createAction(ReducerActionType.SET_INITIALIZED, { initialized }) + ), setAnnotationsActions: (actions: BaseAction[]) => ( createAction(ReducerActionType.SET_ANNOTATIONS_ACTIONS, { actions }) ), @@ -84,6 +92,9 @@ export const reducerActions = { updateFrameTo: (frameTo: number) => ( createAction(ReducerActionType.UPDATE_FRAME_TO, { frameTo }) ), + updateTargetObjectState: (targetObjectState: ObjectState | null) => ( + createAction(ReducerActionType.UPDATE_TARGET_OBJECT_STATE, { targetObjectState }) + ), updateActionParameter: (name: string, value: string) => ( createAction(ReducerActionType.UPDATE_ACTION_PARAMETER, { name, value }) ), @@ -92,70 +103,54 @@ export const reducerActions = { ), }; -const KEEP_LATEST = 5; -let lastSelectedActions: [string, Record][] = []; -function updateLatestActions(name: string, parameters: Record = {}): void { - const idx = lastSelectedActions.findIndex((el) => el[0] === name); - if (idx === -1) { - lastSelectedActions = [[name, parameters], ...lastSelectedActions]; - } else { - lastSelectedActions = [ - [name, parameters], - ...lastSelectedActions.slice(0, idx), - ...lastSelectedActions.slice(idx + 1), - ]; - } +const defaultState = { + actions: [], + initialized: false, + fetching: false, + activeAction: null, + progress: null, + progressMessage: null, + cancelled: false, + frameFrom: 0, + frameTo: 0, + actionParameters: {}, + modalVisible: true, + targetObjectState: null, +}; - lastSelectedActions = lastSelectedActions.slice(-KEEP_LATEST); -} +const reducer = (state: State = { ...defaultState }, action: ActionUnion): State => { + if (action.type === ReducerActionType.SET_INITIALIZED) { + return { + ...state, + initialized: action.payload.initialized, + }; + } -const reducer = (state: State, action: ActionUnion): State => { if (action.type === ReducerActionType.SET_ANNOTATIONS_ACTIONS) { const { actions } = action.payload; - const list = state.targetObjectState ? actions - .filter((_action) => _action.isApplicableForObject(state.targetObjectState as ObjectState)) : actions; - - let activeAction = null; - let activeActionParameters = {}; - for (const item of lastSelectedActions) { - const [actionName, actionParameters] = item; - const candidate = list.find((el) => el.name === actionName); - if (candidate) { - activeAction = candidate; - activeActionParameters = actionParameters; - break; - } - } + const { targetObjectState } = state; + const filteredActions = targetObjectState ? actions + .filter((_action) => _action.isApplicableForObject(targetObjectState)) : actions; return { ...state, - actions: list, - activeAction: activeAction ?? list[0] ?? null, - actionParameters: activeActionParameters, + actions, + activeAction: filteredActions[0] ?? null, }; } if (action.type === ReducerActionType.SET_ACTIVE_ANNOTATIONS_ACTION) { const { activeAction } = action.payload; - updateLatestActions(activeAction.name, {}); + const { targetObjectState } = state; - if (action.payload.activeAction instanceof BaseCollectionAction) { - const storage = getCVATStore(); - const currentFrame = storage.getState().annotation.player.frame.number; + if (!targetObjectState || activeAction.isApplicableForObject(targetObjectState)) { return { ...state, - frameFrom: currentFrame, - frameTo: currentFrame, - activeAction: action.payload.activeAction, - actionParameters: {}, + activeAction, }; } - return { - ...state, - activeAction: action.payload.activeAction, - actionParameters: {}, - }; + return state; } if (action.type === ReducerActionType.UPDATE_PROGRESS) { @@ -208,16 +203,16 @@ const reducer = (state: State, action: ActionUnion): Stat } if (action.type === ReducerActionType.UPDATE_ACTION_PARAMETER) { - const updatedActionParameters = { - ...state.actionParameters, - [action.payload.name]: action.payload.value, - }; - - updateLatestActions((state.activeAction as BaseAction).name, updatedActionParameters); - + const currentActionName = (state.activeAction as BaseAction).name; return { ...state, - actionParameters: updatedActionParameters, + actionParameters: { + ...state.actionParameters, + [currentActionName]: { + ...state.actionParameters[currentActionName] ?? {}, + [action.payload.name]: action.payload.value, + }, + }, }; } @@ -228,11 +223,42 @@ const reducer = (state: State, action: ActionUnion): Stat }; } + if (action.type === ReducerActionType.UPDATE_TARGET_OBJECT_STATE) { + const { targetObjectState } = action.payload; + let { activeAction } = state; + + if (activeAction && targetObjectState && !activeAction.isApplicableForObject(targetObjectState)) { + const filtered = state.actions.filter((_action) => _action.isApplicableForObject(targetObjectState)); + activeAction = filtered[0] ?? null; + } + + return { + ...state, + activeAction, + targetObjectState: action.payload.targetObjectState, + }; + } + return state; }; type ActionParameterProps = NonNullable[keyof BaseAction['parameters']]; +const componentStorage = createStore(reducer, { + actions: [], + initialized: false, + fetching: false, + activeAction: null, + progress: null, + progressMessage: null, + cancelled: false, + frameFrom: 0, + frameTo: 0, + actionParameters: {}, + modalVisible: true, + targetObjectState: null, +}); + function ActionParameterComponent(props: ActionParameterProps & { onChange: (value: string) => void }): JSX.Element { const { defaultValue, type, values, onChange, @@ -248,7 +274,7 @@ function ActionParameterComponent(props: ActionParameterProps & { onChange: (val const computedValues = typeof values === 'function' ? values({ instance: job }) : values; - if (type === 'select') { + if (type === ActionParameterType.SELECT) { return (