diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
index f3ec1d48262d..a86f236f49d7 100644
--- a/.github/workflows/black.yml
+++ b/.github/workflows/black.yml
@@ -5,38 +5,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - id: files
- uses: tj-actions/changed-files@v41.0.0
- with:
- files: |
- cvat-sdk/**/*.py
- cvat-cli/**/*.py
- tests/python/**/*.py
- cvat/apps/quality_control/**/*.py
- cvat/apps/analytics_report/**/*.py
- dir_names: true
- name: Run checks
- env:
- PR_FILES_AM: ${{ steps.files.outputs.added_modified }}
- PR_FILES_RENAMED: ${{ steps.files.outputs.renamed }}
run: |
- # If different modules use different Black configs,
- # we need to run Black for each python component group separately.
- # Otherwise, they all will use the same config.
+ pipx install $(grep "^black" ./dev/requirements.txt)
- UPDATED_DIRS="${{steps.files.outputs.all_changed_files}}"
+ echo "Black version: $(black --version)"
- if [[ ! -z $UPDATED_DIRS ]]; then
- pipx install $(egrep "black.*" ./cvat-cli/requirements/development.txt)
-
- echo "Black version: "$(black --version)
- echo "The dirs will be checked: $UPDATED_DIRS"
- EXIT_CODE=0
- for DIR in $UPDATED_DIRS; do
- black --check --diff $DIR || EXIT_CODE=$(($? | $EXIT_CODE)) || true
- done
- exit $EXIT_CODE
- else
- echo "No files with the \"py\" extension found"
- fi
+ black --check --diff .
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/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml
index 2cb6035769ae..8f19cb1e9e60 100644
--- a/.github/workflows/finalize-release.yml
+++ b/.github/workflows/finalize-release.yml
@@ -65,7 +65,7 @@ jobs:
- name: Bump version
run:
- ./dev/update_version.py --minor
+ ./dev/update_version.py --patch
- name: Commit post-release changes
run: |
diff --git a/.github/workflows/full.yml b/.github/workflows/full.yml
index c6369340b5c3..e42380de5ead 100644
--- a/.github/workflows/full.yml
+++ b/.github/workflows/full.yml
@@ -55,7 +55,7 @@ jobs:
cache-from: type=local,src=/tmp/cvat_cache_server
context: .
file: Dockerfile
- tags: cvat/server
+ tags: cvat/server:${{ env.CVAT_VERSION }}
outputs: type=docker,dest=/tmp/cvat_server/image.tar
- name: CVAT UI. Build and push
@@ -64,7 +64,7 @@ jobs:
cache-from: type=local,src=/tmp/cvat_cache_ui
context: .
file: Dockerfile.ui
- tags: cvat/ui
+ tags: cvat/ui:${{ env.CVAT_VERSION }}
outputs: type=docker,dest=/tmp/cvat_ui/image.tar
- name: CVAT SDK. Build
@@ -102,7 +102,7 @@ jobs:
- uses: actions/setup-python@v5
with:
- python-version: '3.8'
+ python-version: '3.9'
- name: Download CVAT server image
uses: actions/download-artifact@v4
@@ -126,8 +126,6 @@ jobs:
run: |
docker load --input /tmp/cvat_server/image.tar
docker load --input /tmp/cvat_ui/image.tar
- docker tag cvat/server:latest cvat/server:${CVAT_VERSION}
- docker tag cvat/ui:latest cvat/ui:${CVAT_VERSION}
docker image ls -a
- name: Verify API schema
@@ -158,7 +156,7 @@ jobs:
- name: Install SDK
run: |
pip3 install -r ./tests/python/requirements.txt \
- -e './cvat-sdk[pytorch]' -e ./cvat-cli \
+ -e './cvat-sdk[masks,pytorch]' -e ./cvat-cli \
--extra-index-url https://download.pytorch.org/whl/cpu
- name: Running REST API and SDK tests
@@ -203,7 +201,6 @@ jobs:
- name: Load Docker server image
run: |
docker load --input /tmp/cvat_server/image.tar
- docker tag cvat/server:latest cvat/server:${CVAT_VERSION}
docker image ls -a
- name: Running OPA tests
@@ -280,8 +277,6 @@ jobs:
run: |
docker load --input /tmp/cvat_server/image.tar
docker load --input /tmp/cvat_ui/image.tar
- docker tag cvat/server:latest cvat/server:${CVAT_VERSION}
- docker tag cvat/ui:latest cvat/ui:${CVAT_VERSION}
docker image ls -a
- name: Run CVAT instance
diff --git a/.github/workflows/isort.yml b/.github/workflows/isort.yml
index 19332d917030..bf90604cbb2f 100644
--- a/.github/workflows/isort.yml
+++ b/.github/workflows/isort.yml
@@ -25,7 +25,7 @@ jobs:
UPDATED_DIRS="${{steps.files.outputs.all_changed_files}}"
if [[ ! -z $UPDATED_DIRS ]]; then
- pipx install $(egrep "isort.*" ./cvat-cli/requirements/development.txt)
+ pipx install $(grep "^isort" ./dev/requirements.txt)
echo "isort version: $(isort --version-number)"
echo "The dirs will be checked: $UPDATED_DIRS"
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 0c9211b0c4a5..becca0218f94 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -67,7 +67,7 @@ jobs:
cache-from: type=local,src=/tmp/cvat_cache_server
context: .
file: Dockerfile
- tags: cvat/server
+ tags: cvat/server:${{ env.CVAT_VERSION }}
outputs: type=docker,dest=/tmp/cvat_server/image.tar
- name: Instrumentation of the code then rebuilding the CVAT UI
@@ -81,7 +81,7 @@ jobs:
cache-from: type=local,src=/tmp/cvat_cache_ui
context: .
file: Dockerfile.ui
- tags: cvat/ui
+ tags: cvat/ui:${{ env.CVAT_VERSION }}
outputs: type=docker,dest=/tmp/cvat_ui/image.tar
- name: CVAT SDK. Build
@@ -95,7 +95,7 @@ jobs:
id: verify_schema
run: |
docker load --input /tmp/cvat_server/image.tar
- docker run --rm cvat/server bash \
+ docker run --rm "cvat/server:${CVAT_VERSION}" bash \
-c 'python manage.py spectacular' > cvat/schema-expected.yml
if ! git diff --no-index cvat/schema.yml cvat/schema-expected.yml; then
@@ -109,7 +109,7 @@ jobs:
- name: Verify migrations
run: |
- docker run --rm cvat/server bash \
+ docker run --rm "cvat/server:${CVAT_VERSION}" bash \
-c 'python manage.py makemigrations --check'
- name: Upload CVAT server artifact
@@ -138,7 +138,7 @@ jobs:
- uses: actions/setup-python@v5
with:
- python-version: '3.8'
+ python-version: '3.9'
- name: Download CVAT server image
uses: actions/download-artifact@v4
@@ -156,8 +156,6 @@ jobs:
run: |
docker load --input /tmp/cvat_server/image.tar
docker load --input /tmp/cvat_ui/image.tar
- docker tag cvat/server:latest cvat/server:${CVAT_VERSION}
- docker tag cvat/ui:latest cvat/ui:${CVAT_VERSION}
docker image ls -a
- name: Generate SDK
@@ -168,7 +166,7 @@ jobs:
- name: Install SDK
run: |
pip3 install -r ./tests/python/requirements.txt \
- -e './cvat-sdk[pytorch]' -e ./cvat-cli \
+ -e './cvat-sdk[masks,pytorch]' -e ./cvat-cli \
--extra-index-url https://download.pytorch.org/whl/cpu
- name: Run REST API and SDK tests
@@ -221,7 +219,6 @@ jobs:
- name: Load Docker server image
run: |
docker load --input /tmp/cvat_server/image.tar
- docker tag cvat/server:latest cvat/server:${CVAT_VERSION}
docker image ls -a
- name: Running OPA tests
@@ -304,8 +301,6 @@ jobs:
run: |
docker load --input /tmp/cvat_server/image.tar
docker load --input /tmp/cvat_ui/image.tar
- docker tag cvat/server:latest cvat/server:${CVAT_VERSION}
- docker tag cvat/ui:latest cvat/ui:${CVAT_VERSION}
docker image ls -a
- name: Run CVAT instance
@@ -426,10 +421,10 @@ jobs:
SERVER_IMAGE_REPO: ${{ secrets.DOCKERHUB_WORKSPACE }}/server
UI_IMAGE_REPO: ${{ secrets.DOCKERHUB_WORKSPACE }}/ui
run: |
- docker tag cvat/server:latest "${SERVER_IMAGE_REPO}:dev"
+ docker tag "cvat/server:${CVAT_VERSION}" "${SERVER_IMAGE_REPO}:dev"
docker push "${SERVER_IMAGE_REPO}:dev"
- docker tag cvat/ui:latest "${UI_IMAGE_REPO}:dev"
+ docker tag "cvat/ui:${CVAT_VERSION}" "${UI_IMAGE_REPO}:dev"
docker push "${UI_IMAGE_REPO}:dev"
codecov:
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
index d808a823771f..05237f441988 100644
--- a/.github/workflows/pylint.yml
+++ b/.github/workflows/pylint.yml
@@ -19,11 +19,11 @@ jobs:
CHANGED_FILES="${{steps.files.outputs.all_changed_files}}"
if [[ ! -z $CHANGED_FILES ]]; then
- pipx install $(egrep "^pylint==" ./cvat/requirements/development.txt)
+ pipx install $(grep "^pylint==" ./dev/requirements.txt)
pipx inject pylint \
- $(egrep "^pylint-.+==" ./cvat/requirements/development.txt) \
- $(egrep "^django==" ./cvat/requirements/base.txt)
+ $(grep "^pylint-.\+==" ./dev/requirements.txt) \
+ $(grep "^django==" ./cvat/requirements/base.txt)
echo "Pylint version: "$(pylint --version | head -1)
echo "The files will be checked: "$(echo $CHANGED_FILES)
diff --git a/.github/workflows/schedule.yml b/.github/workflows/schedule.yml
index c2071cd85d13..bf74b30df047 100644
--- a/.github/workflows/schedule.yml
+++ b/.github/workflows/schedule.yml
@@ -5,9 +5,8 @@ on:
workflow_dispatch:
env:
- SERVER_IMAGE_TEST_REPO: cvat_server
- UI_IMAGE_TEST_REPO: instrumentation_cvat_ui
CYPRESS_VERIFY_TIMEOUT: 180000 # https://docs.cypress.io/guides/guides/command-line#cypress-verify
+ CVAT_VERSION: "local"
jobs:
check_updates:
@@ -48,12 +47,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_CI_USERNAME }}
- password: ${{ secrets.DOCKERHUB_CI_TOKEN }}
-
- name: CVAT server. Getting cache from the default branch
uses: actions/cache@v4
with:
@@ -66,34 +59,23 @@ jobs:
path: /tmp/cvat_cache_ui
key: ${{ runner.os }}-build-ui-${{ needs.search_cache.outputs.sha }}
- - name: CVAT server. Extract metadata (tags, labels) for Docker
- id: meta-server
- uses: docker/metadata-action@master
- with:
- images: ${{ secrets.DOCKERHUB_CI_WORKSPACE }}/${{ env.SERVER_IMAGE_TEST_REPO }}
- tags:
- type=raw,value=nightly
-
- - name: CVAT UI. Extract metadata (tags, labels) for Docker
- id: meta-ui
- uses: docker/metadata-action@master
- with:
- images: ${{ secrets.DOCKERHUB_CI_WORKSPACE }}/${{ env.UI_IMAGE_TEST_REPO }}
- tags:
- type=raw,value=nightly
-
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+ - name: Create artifact directories
+ run: |
+ mkdir /tmp/cvat_server
+ mkdir /tmp/cvat_ui
+ mkdir /tmp/cvat_sdk
+
- name: CVAT server. Build and push
uses: docker/build-push-action@v6
with:
cache-from: type=local,src=/tmp/cvat_cache_server
context: .
file: Dockerfile
- push: true
- tags: ${{ steps.meta-server.outputs.tags }}
- labels: ${{ steps.meta-server.outputs.labels }}
+ tags: cvat/server:${{ env.CVAT_VERSION }}
+ outputs: type=docker,dest=/tmp/cvat_server/image.tar
- name: CVAT UI. Build and push
uses: docker/build-push-action@v6
@@ -101,9 +83,20 @@ jobs:
cache-from: type=local,src=/tmp/cvat_cache_ui
context: .
file: Dockerfile.ui
- push: true
- tags: ${{ steps.meta-ui.outputs.tags }}
- labels: ${{ steps.meta-ui.outputs.labels }}
+ tags: cvat/ui:${{ env.CVAT_VERSION }}
+ outputs: type=docker,dest=/tmp/cvat_ui/image.tar
+
+ - name: Upload CVAT server artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: cvat_server
+ path: /tmp/cvat_server/image.tar
+
+ - name: Upload CVAT UI artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: cvat_ui
+ path: /tmp/cvat_ui/image.tar
unit_testing:
needs: build
@@ -113,43 +106,25 @@ jobs:
- uses: actions/setup-python@v5
with:
- python-version: '3.8'
+ python-version: '3.9'
- - name: Getting CVAT UI cache from the default branch
- uses: actions/cache@v4
+ - name: Download CVAT server image
+ uses: actions/download-artifact@v4
with:
- path: /tmp/cvat_cache_ui
- key: ${{ runner.os }}-build-ui-${{ needs.search_cache.outputs.sha }}
+ name: cvat_server
+ path: /tmp/cvat_server/
- - name: Building CVAT UI image
- uses: docker/build-push-action@v6
+ - name: Download CVAT UI images
+ uses: actions/download-artifact@v4
with:
- context: .
- file: ./Dockerfile.ui
- cache-from: type=local,src=/tmp/cvat_cache_ui
- tags: cvat/ui:latest
- load: true
+ name: cvat_ui
+ path: /tmp/cvat_ui/
- - name: CVAT server. Extract metadata (tags, labels) for Docker
- id: meta-server
- uses: docker/metadata-action@master
- with:
- images: ${{ secrets.DOCKERHUB_CI_WORKSPACE }}/${{ env.SERVER_IMAGE_TEST_REPO }}
- tags:
- type=raw,value=nightly
-
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_CI_USERNAME }}
- password: ${{ secrets.DOCKERHUB_CI_TOKEN }}
-
- - name: Pull CVAT server image
+ - name: Load Docker images
run: |
- docker pull ${{ steps.meta-server.outputs.tags }}
- docker tag ${{ steps.meta-server.outputs.tags }} cvat/server:local
- docker tag ${{ steps.meta-server.outputs.tags }} cvat/server:latest
- docker tag cvat/ui:latest cvat/ui:local
+ docker load --input /tmp/cvat_server/image.tar
+ docker load --input /tmp/cvat_ui/image.tar
+ docker image ls -a
- name: OPA tests
run: |
@@ -210,35 +185,23 @@ jobs:
with:
node-version: '16.x'
- - name: Login to Docker Hub
- uses: docker/login-action@v3
+ - name: Download CVAT server image
+ uses: actions/download-artifact@v4
with:
- username: ${{ secrets.DOCKERHUB_CI_USERNAME }}
- password: ${{ secrets.DOCKERHUB_CI_TOKEN }}
+ name: cvat_server
+ path: /tmp/cvat_server/
- - name: CVAT server. Extract metadata (tags, labels) for Docker
- id: meta-server
- uses: docker/metadata-action@master
+ - name: Download CVAT UI image
+ uses: actions/download-artifact@v4
with:
- images: ${{ secrets.DOCKERHUB_CI_WORKSPACE }}/${{ env.SERVER_IMAGE_TEST_REPO }}
- tags:
- type=raw,value=nightly
+ name: cvat_ui
+ path: /tmp/cvat_ui/
- - name: CVAT UI. Extract metadata (tags, labels) for Docker
- id: meta-ui
- uses: docker/metadata-action@master
- with:
- images: ${{ secrets.DOCKERHUB_CI_USERNAME }}/${{ env.UI_IMAGE_TEST_REPO }}
- tags:
- type=raw,value=nightly
-
- - name: Pull CVAT UI image
+ - name: Load Docker images
run: |
- docker pull ${{ steps.meta-server.outputs.tags }}
- docker tag ${{ steps.meta-server.outputs.tags }} cvat/server:dev
-
- docker pull ${{ steps.meta-ui.outputs.tags }}
- docker tag ${{ steps.meta-ui.outputs.tags }} cvat/ui:dev
+ docker load --input /tmp/cvat_server/image.tar
+ docker load --input /tmp/cvat_ui/image.tar
+ docker image ls -a
- name: Run CVAT instance
run: |
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 5ed666059a9d..cb4b0f9dcf0f 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -4,6 +4,7 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
+
{
"name": "REST API tests: Attach to server",
"type": "debugpy",
@@ -168,7 +169,7 @@
"CVAT_SERVERLESS": "1",
"ALLOWED_HOSTS": "*",
"DJANGO_LOG_SERVER_HOST": "localhost",
- "DJANGO_LOG_SERVER_PORT": "8282"
+ "DJANGO_LOG_SERVER_PORT": "8282",
},
"args": [
"runserver",
@@ -178,7 +179,7 @@
],
"django": true,
"cwd": "${workspaceFolder}",
- "console": "internalConsole"
+ "console": "internalConsole",
},
{
"name": "server: chrome",
@@ -360,6 +361,28 @@
},
"console": "internalConsole"
},
+ {
+ "name": "server: RQ - chunks",
+ "type": "debugpy",
+ "request": "launch",
+ "stopOnEntry": false,
+ "justMyCode": false,
+ "python": "${command:python.interpreterPath}",
+ "program": "${workspaceFolder}/manage.py",
+ "args": [
+ "rqworker",
+ "chunks",
+ "--worker-class",
+ "cvat.rqworker.SimpleWorker"
+ ],
+ "django": true,
+ "cwd": "${workspaceFolder}",
+ "env": {
+ "DJANGO_LOG_SERVER_HOST": "localhost",
+ "DJANGO_LOG_SERVER_PORT": "8282"
+ },
+ "console": "internalConsole"
+ },
{
"name": "server: migrate",
"type": "debugpy",
@@ -376,6 +399,22 @@
"env": {},
"console": "internalConsole"
},
+ {
+ "name": "server: sync periodic jobs",
+ "type": "debugpy",
+ "request": "launch",
+ "justMyCode": false,
+ "stopOnEntry": false,
+ "python": "${command:python.interpreterPath}",
+ "program": "${workspaceFolder}/manage.py",
+ "args": [
+ "syncperiodicjobs"
+ ],
+ "django": true,
+ "cwd": "${workspaceFolder}",
+ "env": {},
+ "console": "internalConsole"
+ },
{
"name": "server: tests",
"type": "debugpy",
@@ -405,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}",
@@ -537,7 +578,8 @@
"server: RQ - scheduler",
"server: RQ - quality reports",
"server: RQ - analytics reports",
- "server: RQ - cleaning"
+ "server: RQ - cleaning",
+ "server: RQ - chunks",
]
}
]
diff --git a/.vscode/settings.json b/.vscode/settings.json
index a0caaf036765..baf7dc5b3879 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -29,6 +29,15 @@
"database": "${workspaceFolder:cvat}/db.sqlite3"
}
],
+ "python.analysis.exclude": [
+ // VS Code defaults
+ "**/node_modules",
+ "**/__pycache__",
+ ".git",
+
+ "cvat-cli/build",
+ "cvat-sdk/build",
+ ],
"python.defaultInterpreterPath": "${workspaceFolder}/.env/",
"python.testing.pytestArgs": [
"--rootdir","${workspaceFolder}/tests/"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2cd17ae9868..31a4aae3db7f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,360 @@ 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
+
+### Changed
+
+- \[CLI\] Log messages are now printed on stderr rather than stdout
+ ()
+
+### Fixed
+
+- Optimized memory consumption and reduced the number of database queries
+ when importing annotations to a task with a lot of jobs and images
+ ()
+
+- Incorrect display of validation frames on the task quality management page
+ ()
+
+- Player may navigate to removed frames when playing
+ ()
+
+- User may navigate forward with a keyboard when a modal opened
+ ()
+
+- fit:canvas event is not generated if to fit it from the controls sidebar
+ ()
+
+- Color of 'Create object URL' button for a not saved on the server object
+ ()
+
+- Failed request for a chunk inside a job after it was recently modified by updating `validation_layout`
+ ()
+
+- Memory consumption during preparation of image chunks
+ ()
+
+- Possible endless lock acquisition for chunk preparation job
+ ()
+
+- Fixed issue: Cannot read properties of undefined (reading 'getUpdated')
+ ()
+
+
+## \[2.23.0\] - 2024-11-29
+
+### Added
+
+- Support for direct .json file import in Datumaro format
+ ()
+
+- \[SDK, CLI\] Added a `conf_threshold` parameter to
+ `cvat_sdk.auto_annotation.annotate_task`, which is passed as-is to the AA
+ function object via the context. The CLI equivalent is `auto-annotate
+ --conf-threshold`. This makes it easier to write and use AA functions that
+ support object filtering based on confidence levels
+ ()
+
+- \[SDK\] Built-in auto-annotation functions now support object filtering by
+ confidence level
+ ()
+
+- New events (create|update|delete):(membership|webhook) and (create|delete):invitation
+ ()
+
+- \[SDK\] Added new auto-annotation helpers (`mask`, `polygon`, `encode_mask`)
+ to support AA functions that return masks or polygons
+ ()
+
+- \[SDK\] Added a new built-in auto-annotation function,
+ `torchvision_instance_segmentation`
+ ()
+
+- \[SDK, CLI\] Added a new auto-annotation parameter, `conv_mask_to_poly`
+ (`--conv-mask-to-poly` in the CLI)
+ ()
+
+- A user may undo or redo changes, made by an annotations actions using general approach (e.g. Ctrl+Z, Ctrl+Y)
+ ()
+
+- Basically, annotations actions now support any kinds of objects (shapes, tracks, tags)
+ ()
+
+- A user may run annotations actions on a certain object (added corresponding object menu item)
+ ()
+
+- A shortcut to open annotations actions modal for a currently selected object
+ ()
+
+- A default role if IAM_TYPE='LDAP' and if the user is not a member of any group in 'DJANGO_AUTH_LDAP_GROUPS' ()
+
+- The `POST /api/lambda/requests` endpoint now has a `conv_mask_to_poly`
+ parameter with the same semantics as the old `convMaskToPoly` parameter
+ ()
+
+- \[SDK\] Model instances can now be pickled
+ ()
+
+### Changed
+
+- Chunks are now prepared in a separate worker process
+ ()
+
+- \[Helm\] Traefik sticky sessions for the backend service are disabled
+ ()
+
+- Payload for events (create|update|delete):(shapes|tags|tracks) does not include frame and attributes anymore
+ ()
+
+### Deprecated
+
+- The `convMaskToPoly` parameter of the `POST /api/lambda/requests` endpoint
+ is deprecated; use `conv_mask_to_poly` instead
+ ()
+
+### Removed
+
+- It it no longer possible to run lambda functions on compressed images;
+ original images will always be used
+ ()
+
+### Fixed
+
+- Export without images in Datumaro format should include image info
+ ()
+
+- Inconsistent zOrder behavior on job open
+ ()
+
+- Ground truth annotations can be shown in standard mode
+ ()
+
+- Keybinds in UI allow drawing disabled shape types
+ ()
+
+- Style issues on the Quality page when browser zoom is applied
+ ()
+- Flickering of masks in review mode, even when no conflicts are highlighted
+ ()
+
+- Fixed security header duplication in HTTP responses from the backend
+ ()
+
+- The error occurs when trying to copy/paste a mask on a video after opening the job
+ ()
+
+- Attributes do not get copied when copy/paste a mask
+ ()
+
+
+## \[2.22.0\] - 2024-11-11
+
+### Added
+
+- Feature to hide a mask during editing ()
+
+- A quality setting to compare point groups without using bbox
+ ()
+
+- A quality check option to consider empty frames matching
+ ()
+
+### Changed
+
+- Reduced memory usage of the utils container
+ ()
+
+### Removed
+
+- Removed unused business group
+ ()
+
+### Fixed
+
+- Propagation creates copies on non-existing frames in a ground truth job
+ ()
+
+- Exporting projects with tasks containing honeypots. Honeypots are no longer exported.
+ ()
+
+- Error after creating GT job on Create job page with frame selection method `random_per_job`
+ ()
+
+- Fixed issue 'Cannot read properties of undefined (reading 'push')'
+ ()
+
+- Re-newed import/export request failed immediately if the previous failed
+ ()
+
+- Fixed automatic zooming in attribute annotation mode for masks
+ ()
+
+- Export dataset in CVAT format misses frames in tasks with non-default frame step
+ ()
+
+- Incorrect progress representation on `Requests` page
+ ()
+
+
+## \[2.21.3\] - 2024-10-31
+
+### Changed
+
+- CLI no longer prints the stack trace in case of HTTP errors
+ ()
+
+### Removed
+
+- Dropped support for Python 3.8 since its EOL was on 2024-10-07
+ ()
+
+### Fixed
+
+- Requests page crush with `Cannot read property 'target' of undefined` error
+ ()
+
+- Tags in ground truth job were displayed as `tag (GT)`
+ ()
+
+- Tags in ground truth job couldn't be deleted via `x` button
+ ()
+
+- Exception 'Canvas is busy' when change frame during drag/resize a track
+ ()
+
+- A shape gets shifted if auto save triggered during dragging
+ ()
+
+
+## \[2.21.2\] - 2024-10-24
+
+### Added
+
+- Access to /analytics can now be granted
+ ()
+
+### Fixed
+
+- Expired sessions are now cleared from the database daily
+ ()
+
+- Fixed export/import errors for tracks with duplicated shapes.
+ Fixed a bug which caused shape duplication on track import.
+ ()
+
+- Fix Grafana container restart policy
+ ()
+
+- Fixed some interface tooltips having 'undefined' shortcuts
+ ()
+
+- Memory consumption during preparation of image chunks
+ ()
+
+- Fixed a bug where an export RQ job being retried may break scheduling
+ of new jobs
+ ()
+
+- UI now allows the user to start automatic annotation again
+ if the previous request fails
+ ()
+
+
+## \[2.21.1\] - 2024-10-18
+
+### Added
+
+- Keyboard shortcuts for **brush**, **eraser**, **polygon** and **polygon remove** tools on masks drawing toolbox
+ ()
+
+### Fixed
+
+- Ground truth tracks are displayed not only on GT frames in review mode
+ ()
+
+- Incorrect navigation by keyframes when annotation job ends earlier than track in a ground truth job
+ ()
+- Tracks from a ground truth job displayed on wrong frames in review mode when frame step is not equal to 1
+ ()
+
+- Task creation with cloud storage data and GT_POOL validation mode
+ ()
+
+- Incorrect quality reports and immediate feedback with non default start frame or frame step
+ ()
+
+- av context closing issue when using AUTO thread_type
+ ()
+
## \[2.21.0\] - 2024-10-10
@@ -237,7 +591,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Deprecated
- Client events `upload:annotations`, `lock:object`, `change:attribute`, `change:label`
- ()
+ ()
### Removed
@@ -264,7 +618,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
()
- Sometimes it is not possible to switch workspace because active control broken after
-trying to create a tag with a shortcut ()
+ trying to create a tag with a shortcut
+ ()
## \[2.16.3\] - 2024-08-13
@@ -305,13 +660,14 @@ trying to create a tag with a shortcut ()
+ **Asset is already related to another guide**
+ ()
- Undo can't be done when a shape is rotated
()
- Exporting a skeleton track in a format defined for shapes raises error
-`operands could not be broadcast together with shapes (X, ) (Y, )`
+ `operands could not be broadcast together with shapes (X, ) (Y, )`
()
- Delete label modal window does not have cancellation button
@@ -330,10 +686,11 @@ trying to create a tag with a shortcut ()
- API call to run automatic annotations fails on a model with attributes
- when mapping not provided in the request ()
+ when mapping not provided in the request
+ ()
- Fixed a label collision issue where labels with similar prefixes
-and numeric suffixes could conflict, causing error on export.
+ and numeric suffixes could conflict, causing error on export.
()
@@ -370,9 +727,9 @@ and numeric suffixes could conflict, causing error on export.
### Added
- Set of features to track background activities: importing/exporting datasets, annotations or backups, creating tasks.
-Now you may find these processes on Requests page, it allows a user to understand current status of these activities
-and enhances user experience, not losing progress when the browser tab is closed
-()
+ Now you may find these processes on Requests page, it allows a user to understand current status of these activities
+ and enhances user experience, not losing progress when the browser tab is closed
+ ()
- User now may update a job state from the corresponding task page
()
@@ -383,7 +740,8 @@ and enhances user experience, not losing progress when the browser tab is closed
### Changed
- "Finish the job" button on annotation view now only sets state to 'completed'.
- The job stage keeps unchanged ()
+ The job stage keeps unchanged
+ ()
- Log files for individual backend processes are now stored in ephemeral
storage of each backend container rather than in the `cvat_logs` volume
@@ -395,7 +753,7 @@ and enhances user experience, not losing progress when the browser tab is closed
### Removed
- Renew the job button in annotation menu was removed
- ()
+ ()
### Fixed
@@ -443,10 +801,12 @@ and enhances user experience, not losing progress when the browser tab is closed
()
- Exception 'this.el.node.getScreenCTM() is null' occuring in Firefox when
-a user resizes window during skeleton dragging/resizing ()
+ a user resizes window during skeleton dragging/resizing
+ ()
- Exception 'Edge's nodeFrom M or nodeTo N do not to refer to any node'
-occuring when a user resizes window during skeleton dragging/resizing ()
+ occuring when a user resizes window during skeleton dragging/resizing
+ ()
- Slightly broken layout when running attributed face detection model
()
@@ -504,7 +864,8 @@ occuring when a user resizes window during skeleton dragging/resizing ()
- When use route `/auth/login-with-token/` without `next` query parameter
-the page reloads infinitely ()
+ the page reloads infinitely
+ ()
- Fixed kvrocks port naming for istio
()
@@ -675,7 +1036,7 @@ the page reloads infinitely ()
- Opening update CS page sends infinite requests when CS id does not exist
()
-Uploading files with TUS immediately failed when one of the requests failed
+- Uploading files with TUS immediately failed when one of the requests failed
()
- Longer analytics report calculation because of inefficient requests to analytics db
@@ -845,7 +1206,7 @@ Uploading files with TUS immediately failed when one of the requests failed
()
- 90 deg-rotated video was added with "Prefer Zip Chunks" disabled
-was warped, fixed using the static cropImage function.
+ was warped, fixed using the static cropImage function.
()
@@ -883,7 +1244,7 @@ was warped, fixed using the static cropImage function.
### Added
- Single shape annotation mode allowing to easily annotate scenarious where a user
-only needs to draw one object on one image ()
+ only needs to draw one object on one image ()
### Fixed
@@ -1011,7 +1372,7 @@ only needs to draw one object on one image ()
- \[Compose, Helm\] Updated Clickhouse to version 23.11.*
@@ -1060,11 +1421,11 @@ longer accepted automatically. Instead, the invitee can now review the invitatio
()
- Error message `Edge's nodeFrom ${dataNodeFrom} or nodeTo ${dataNodeTo} do not to refer to any node`
- when upload a file with some abscent skeleton nodes ()
+ when upload a file with some abscent skeleton nodes ()
- Wrong context menu position in skeleton configurator (Firefox only)
- ()
+ ()
- Fixed console error `(Error: attribute width: A negative value is not valid`
- appearing when skeleton with all outside elements is created ()
+ appearing when skeleton with all outside elements is created ()
- Updating cloud storage attached to CVAT using Azure connection string
()
@@ -1075,7 +1436,7 @@ longer accepted automatically. Instead, the invitee can now review the invitatio
### Added
- Introduced CVAT actions. Actions allow performing different
- predefined scenarios on annotations automatically (e.g. shape converters)
+ predefined scenarios on annotations automatically (e.g. shape converters)
()
- The UI will now retry requests that were rejected due to rate limiting
diff --git a/Dockerfile b/Dockerfile
index 8a10a34b771b..00dea1de30d0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -134,6 +134,7 @@ RUN apt-get update && \
supervisor \
tzdata \
unrar \
+ wait-for-it \
&& ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && \
dpkg-reconfigure -f noninteractive tzdata && \
rm -rf /var/lib/apt/lists/* && \
@@ -192,7 +193,7 @@ RUN python -m pip uninstall -y pip
COPY cvat/nginx.conf /etc/nginx/nginx.conf
COPY --chown=${USER} components /tmp/components
COPY --chown=${USER} supervisord/ ${HOME}/supervisord
-COPY --chown=${USER} wait-for-it.sh manage.py backend_entrypoint.sh wait_for_deps.sh ${HOME}/
+COPY --chown=${USER} manage.py backend_entrypoint.sh wait_for_deps.sh ${HOME}/
COPY --chown=${USER} utils/ ${HOME}/utils
COPY --chown=${USER} cvat/ ${HOME}/cvat
COPY --chown=${USER} rqscheduler.py ${HOME}
diff --git a/Dockerfile.ui b/Dockerfile.ui
index 170ee1a76633..f134f5d62883 100644
--- a/Dockerfile.ui
+++ b/Dockerfile.ui
@@ -1,11 +1,5 @@
FROM node:lts-slim AS cvat-ui
-ARG WA_PAGE_VIEW_HIT
-ARG UI_APP_CONFIG
-ARG CLIENT_PLUGINS
-ARG DISABLE_SOURCE_MAPS
-ARG SOURCE_MAPS_TOKEN
-
ENV TERM=xterm \
LANG='C.UTF-8' \
LC_ALL='C.UTF-8'
@@ -29,6 +23,12 @@ COPY cvat-core/ /tmp/cvat-core/
COPY cvat-canvas3d/ /tmp/cvat-canvas3d/
COPY cvat-canvas/ /tmp/cvat-canvas/
COPY cvat-ui/ /tmp/cvat-ui/
+
+ARG UI_APP_CONFIG
+ARG CLIENT_PLUGINS
+ARG DISABLE_SOURCE_MAPS
+ARG SOURCE_MAPS_TOKEN
+
RUN CLIENT_PLUGINS="${CLIENT_PLUGINS}" \
DISABLE_SOURCE_MAPS="${DISABLE_SOURCE_MAPS}" \
UI_APP_CONFIG="${UI_APP_CONFIG}" \
diff --git a/backend_entrypoint.sh b/backend_entrypoint.sh
index c8b681eabb4d..bac37c76e5be 100755
--- a/backend_entrypoint.sh
+++ b/backend_entrypoint.sh
@@ -8,7 +8,7 @@ fail() {
}
wait_for_db() {
- ~/wait-for-it.sh "${CVAT_POSTGRES_HOST}:${CVAT_POSTGRES_PORT:-5432}" -t 0
+ wait-for-it "${CVAT_POSTGRES_HOST}:${CVAT_POSTGRES_PORT:-5432}" -t 0
}
cmd_bash() {
@@ -18,6 +18,9 @@ cmd_bash() {
cmd_init() {
wait_for_db
~/manage.py migrate
+
+ wait-for-it "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0
+ ~/manage.py syncperiodicjobs
}
cmd_run() {
diff --git a/changelog.d/20241009_101726_klakhov_brush_shortcuts.md b/changelog.d/20241009_101726_klakhov_brush_shortcuts.md
deleted file mode 100644
index 8d70aac199be..000000000000
--- a/changelog.d/20241009_101726_klakhov_brush_shortcuts.md
+++ /dev/null
@@ -1,4 +0,0 @@
-### Added
-
-- Keyboard shortcuts for **brush**, **eraser**, **polygon** and **polygon remove** tools on masks drawing toolbox
- ()
diff --git a/changelog.d/20241011_132931_sekachev.bs_fixed_state_destructurization.md b/changelog.d/20241011_132931_sekachev.bs_fixed_state_destructurization.md
deleted file mode 100644
index 2693c7fb1327..000000000000
--- a/changelog.d/20241011_132931_sekachev.bs_fixed_state_destructurization.md
+++ /dev/null
@@ -1,4 +0,0 @@
-### Fixed
-
-- Ground truth tracks are displayed not only on GT frames in review mode
- ()
diff --git a/changelog.d/20241011_142625_sekachev.bs_fixed_navigation.md b/changelog.d/20241011_142625_sekachev.bs_fixed_navigation.md
deleted file mode 100644
index 60aededd34b1..000000000000
--- a/changelog.d/20241011_142625_sekachev.bs_fixed_navigation.md
+++ /dev/null
@@ -1,6 +0,0 @@
-### Fixed
-
-- Incorrect navigation by keyframes when annotation job ends earlier than track in a ground truth job
- ()
-- Tracks from a ground truth job displayed on wrong frames in review mode when frame step is not equal to 1
- ()
diff --git a/changelog.d/20241016_142620_maria_fix_task_creation_with_gt_pool_and_cs_data.md b/changelog.d/20241016_142620_maria_fix_task_creation_with_gt_pool_and_cs_data.md
deleted file mode 100644
index 8772b7f6713e..000000000000
--- a/changelog.d/20241016_142620_maria_fix_task_creation_with_gt_pool_and_cs_data.md
+++ /dev/null
@@ -1,4 +0,0 @@
-### Fixed
-
-- Task creation with cloud storage data and GT_POOL validation mode
- ()
diff --git a/changelog.d/20241016_180804_sekachev.bs.md b/changelog.d/20241016_180804_sekachev.bs.md
deleted file mode 100644
index a16bb1a62f55..000000000000
--- a/changelog.d/20241016_180804_sekachev.bs.md
+++ /dev/null
@@ -1,4 +0,0 @@
-### Fixed
-
-- Incorrect quality reports and immediate feedback with non default start frame or frame step
- ()
diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json
index 2b24ff47e347..c89e7506854c 100644
--- a/cvat-canvas/package.json
+++ b/cvat-canvas/package.json
@@ -1,6 +1,6 @@
{
"name": "cvat-canvas",
- "version": "2.20.9",
+ "version": "2.20.10",
"type": "module",
"description": "Part of Computer Vision Annotation Tool which presents its canvas library",
"main": "src/canvas.ts",
diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts
index 2c7a1f08d203..0225c738683b 100644
--- a/cvat-canvas/src/typescript/canvasModel.ts
+++ b/cvat-canvas/src/typescript/canvasModel.ts
@@ -96,6 +96,7 @@ export interface Configuration {
controlPointsSize?: number;
outlinedBorders?: string | false;
resetZoom?: boolean;
+ hideEditedObject?: boolean;
}
export interface BrushTool {
@@ -416,6 +417,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
textPosition: consts.DEFAULT_SHAPE_TEXT_POSITION,
textContent: consts.DEFAULT_SHAPE_TEXT_CONTENT,
undefinedAttrValue: consts.DEFAULT_UNDEFINED_ATTR_VALUE,
+ hideEditedObject: false,
},
imageBitmap: false,
image: null,
@@ -685,28 +687,34 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
public fit(): void {
const { angle } = this.data;
+ let updatedScale = this.data.scale;
if ((angle / 90) % 2) {
// 90, 270, ..
- this.data.scale = Math.min(
+ updatedScale = Math.min(
this.data.canvasSize.width / this.data.imageSize.height,
this.data.canvasSize.height / this.data.imageSize.width,
);
} else {
- this.data.scale = Math.min(
+ updatedScale = Math.min(
this.data.canvasSize.width / this.data.imageSize.width,
this.data.canvasSize.height / this.data.imageSize.height,
);
}
- this.data.scale = Math.min(Math.max(this.data.scale, FrameZoom.MIN), FrameZoom.MAX);
- this.data.top = this.data.canvasSize.height / 2 - this.data.imageSize.height / 2;
- this.data.left = this.data.canvasSize.width / 2 - this.data.imageSize.width / 2;
+ updatedScale = Math.min(Math.max(updatedScale, FrameZoom.MIN), FrameZoom.MAX);
+ const updatedTop = this.data.canvasSize.height / 2 - this.data.imageSize.height / 2;
+ const updatedLeft = this.data.canvasSize.width / 2 - this.data.imageSize.width / 2;
- // scale is changed during zooming or translating
- // so, remember fitted scale to compute fit-relative scaling
- this.data.fittedScale = this.data.scale;
+ if (updatedScale !== this.data.scale || updatedTop !== this.data.top || updatedLeft !== this.data.left) {
+ this.data.scale = updatedScale;
+ this.data.top = updatedTop;
+ this.data.left = updatedLeft;
- this.notify(UpdateReasons.IMAGE_FITTED);
+ // scale is changed during zooming or translating
+ // so, remember fitted scale to compute fit-relative scaling
+ this.data.fittedScale = this.data.scale;
+ this.notify(UpdateReasons.IMAGE_FITTED);
+ }
}
public grid(stepX: number, stepY: number): void {
@@ -981,6 +989,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.data.configuration.CSSImageFilter = configuration.CSSImageFilter;
}
+ if (typeof configuration.hideEditedObject === 'boolean') {
+ this.data.configuration.hideEditedObject = configuration.hideEditedObject;
+ }
+
this.notify(UpdateReasons.CONFIG_UPDATED);
}
diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts
index 480a5d3aea52..f21255ab4213 100644
--- a/cvat-canvas/src/typescript/canvasView.ts
+++ b/cvat-canvas/src/typescript/canvasView.ts
@@ -245,6 +245,53 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.canvas.dispatchEvent(event);
}
+ private resetViewPosition(clientID: number): void {
+ const drawnState = this.drawnStates[clientID];
+ const drawnShape = this.svgShapes[clientID];
+
+ if (drawnState && drawnShape) {
+ const { shapeType, points } = drawnState;
+ const translatedPoints: number[] = this.translateToCanvas(points);
+ const stringified = stringifyPoints(translatedPoints);
+ if (shapeType === 'cuboid') {
+ drawnShape.attr('points', stringified);
+ } else if (['polygon', 'polyline', 'points'].includes(shapeType)) {
+ (drawnShape as SVG.PolyLine | SVG.Polygon).plot(stringified);
+ if (shapeType === 'points') {
+ this.selectize(false, drawnShape);
+ this.setupPoints(drawnShape as SVG.PolyLine, drawnState);
+ }
+ } else if (shapeType === 'rectangle') {
+ const [xtl, ytl, xbr, ybr] = translatedPoints;
+ drawnShape.rotate(0);
+ drawnShape.size(xbr - xtl, ybr - ytl).move(xtl, ytl);
+ drawnShape.rotate(drawnState.rotation);
+ } else if (shapeType === 'ellipse') {
+ const [cx, cy, rightX, topY] = translatedPoints;
+ const [rx, ry] = [rightX - cx, cy - topY];
+ drawnShape.rotate(0);
+ drawnShape.size(rx * 2, ry * 2).center(cx, cy);
+ drawnShape.rotate(drawnState.rotation);
+ } else if (shapeType === 'skeleton') {
+ drawnShape.rotate(0);
+ for (const child of (drawnShape as SVG.G).children()) {
+ if (child.type === 'circle') {
+ const childClientID = child.attr('data-client-id');
+ const element = drawnState.elements.find((el: any) => el.clientID === childClientID);
+ const [x, y] = this.translateToCanvas(element.points);
+ child.center(x, y);
+ }
+ }
+ drawnShape.rotate(drawnState.rotation);
+ } else if (shapeType === 'mask') {
+ const [left, top] = points.slice(-4);
+ drawnShape.move(this.geometry.offset + left, this.geometry.offset + top);
+ } else {
+ throw new Error('Not implemented');
+ }
+ }
+ }
+
private onInteraction = (
shapes: InteractionResult[] | null,
shapesUpdated = true,
@@ -1114,6 +1161,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
}).on('dragend', (e: CustomEvent): void => {
if (aborted) {
+ this.resetViewPosition(state.clientID);
return;
}
@@ -1172,6 +1220,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.draggableShape = null;
aborted = true;
// disable internal drag events of SVG.js
+ // call chain is (mouseup -> SVG.handler.end -> SVG.handler.drag -> dragend)
window.dispatchEvent(new MouseEvent('mouseup'));
});
} else {
@@ -1303,6 +1352,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
})
.on('resizedone', (): void => {
if (aborted) {
+ this.resetViewPosition(state.clientID);
return;
}
@@ -1359,7 +1409,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
onResizeEnd();
aborted = true;
this.resizableShape = null;
- // disable internal drag events of SVG.js
+ // disable internal resize events of SVG.js
+ // call chain is (mouseup -> SVG.handler.end -> SVG.handler.resize-> resizeend)
window.dispatchEvent(new MouseEvent('mouseup'));
});
} else {
@@ -1600,12 +1651,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
// Setup event handlers
this.canvas.addEventListener('dblclick', (e: MouseEvent): void => {
this.controller.fit();
- this.canvas.dispatchEvent(
- new CustomEvent('canvas.fit', {
- bubbles: false,
- cancelable: true,
- }),
- );
e.preventDefault();
});
@@ -1845,6 +1890,15 @@ export class CanvasViewImpl implements CanvasView, Listener {
}),
);
} else if ([UpdateReasons.IMAGE_ZOOMED, UpdateReasons.IMAGE_FITTED].includes(reason)) {
+ if (reason === UpdateReasons.IMAGE_FITTED) {
+ this.canvas.dispatchEvent(
+ new CustomEvent('canvas.fit', {
+ bubbles: false,
+ cancelable: true,
+ }),
+ );
+ }
+
this.moveCanvas();
this.transformCanvas();
} else if (reason === UpdateReasons.IMAGE_ROTATED) {
@@ -1867,15 +1921,26 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.gridPattern.setAttribute('height', `${size.height}`);
} else if (reason === UpdateReasons.SHAPE_FOCUSED) {
const { padding, clientID } = this.controller.focusData;
+ const drawnState = this.drawnStates[clientID];
const object = this.svgShapes[clientID];
- if (object) {
- const bbox: SVG.BBox = object.bbox();
- this.onFocusRegion(
- bbox.x - padding,
- bbox.y - padding,
- bbox.width + padding * 2,
- bbox.height + padding * 2,
- );
+ if (drawnState && object) {
+ const { offset } = this.geometry;
+ let [x, y, width, height] = [0, 0, 0, 0];
+
+ if (drawnState.shapeType === 'mask') {
+ const [xtl, ytl, xbr, ybr] = drawnState.points.slice(-4);
+ x = xtl + offset;
+ y = ytl + offset;
+ width = xbr - xtl + 1;
+ height = ybr - ytl + 1;
+ } else {
+ const bbox: SVG.BBox = object.bbox();
+ ({
+ x, y, width, height,
+ } = bbox);
+ }
+
+ this.onFocusRegion(x - padding, y - padding, width + padding * 2, height + padding * 2);
}
} else if (reason === UpdateReasons.SHAPE_ACTIVATED) {
this.activate(this.controller.activeElement);
@@ -2815,6 +2880,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
const shapeView = window.document.getElementById(`cvat_canvas_shape_${clientID}`);
if (shapeView) shapeView.classList.remove(this.getHighlightClassname());
});
+ const redrawMasks = (highlightedElements.elementsIDs.length !== 0 ||
+ this.highlightedElements.elementsIDs.length !== 0);
+
if (highlightedElements.elementsIDs.length) {
this.highlightedElements = { ...highlightedElements };
this.canvas.classList.add('cvat-canvas-highlight-enabled');
@@ -2829,9 +2897,11 @@ export class CanvasViewImpl implements CanvasView, Listener {
};
this.canvas.classList.remove('cvat-canvas-highlight-enabled');
}
- const masks = Object.values(this.drawnStates).filter((state) => state.shapeType === 'mask');
- this.deleteObjects(masks);
- this.addObjects(masks);
+ if (redrawMasks) {
+ const masks = Object.values(this.drawnStates).filter((state) => state.shapeType === 'mask');
+ this.deleteObjects(masks);
+ this.addObjects(masks);
+ }
if (this.highlightedElements.elementsIDs.length) {
this.deactivate();
const clientID = this.highlightedElements.elementsIDs[0];
@@ -3401,7 +3471,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
return skeleton;
}
- private setupPoints(basicPolyline: SVG.PolyLine, state: any): any {
+ private setupPoints(basicPolyline: SVG.PolyLine, state: any | DrawnState): any {
this.selectize(true, basicPolyline);
const group: SVG.G = basicPolyline
diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts
index b7e9cbb90130..77b674dec05e 100644
--- a/cvat-canvas/src/typescript/drawHandler.ts
+++ b/cvat-canvas/src/typescript/drawHandler.ts
@@ -5,7 +5,7 @@
import * as SVG from 'svg.js';
import 'svg.draw.js';
-import './svg.patch';
+import { CIRCLE_STROKE } from './svg.patch';
import { AutoborderHandler } from './autoborderHandler';
import {
@@ -104,6 +104,7 @@ export class DrawHandlerImpl implements DrawHandler {
private controlPointsSize: number;
private selectedShapeOpacity: number;
private outlinedBorders: string;
+ private isHidden: boolean;
// we should use any instead of SVG.Shape because svg plugins cannot change declared interface
// so, methods like draw() just undefined for SVG.Shape, but nevertheless they exist
@@ -1276,6 +1277,7 @@ export class DrawHandlerImpl implements DrawHandler {
this.selectedShapeOpacity = configuration.selectedShapeOpacity;
this.outlinedBorders = configuration.outlinedBorders || 'black';
this.autobordersEnabled = false;
+ this.isHidden = false;
this.startTimestamp = Date.now();
this.onDrawDoneDefault = onDrawDone;
this.canvas = canvas;
@@ -1301,10 +1303,28 @@ export class DrawHandlerImpl implements DrawHandler {
});
}
+ private strokePoint(point: SVG.Element): void {
+ point.attr('stroke', this.isHidden ? 'none' : CIRCLE_STROKE);
+ point.fill({ opacity: this.isHidden ? 0 : 1 });
+ }
+
+ private updateHidden(value: boolean) {
+ this.isHidden = value;
+
+ if (value) {
+ this.canvas.attr('pointer-events', 'none');
+ } else {
+ this.canvas.attr('pointer-events', 'all');
+ }
+ }
+
public configurate(configuration: Configuration): void {
this.controlPointsSize = configuration.controlPointsSize;
this.selectedShapeOpacity = configuration.selectedShapeOpacity;
this.outlinedBorders = configuration.outlinedBorders || 'black';
+ if (this.isHidden !== configuration.hideEditedObject) {
+ this.updateHidden(configuration.hideEditedObject);
+ }
const isFillableRect = this.drawData &&
this.drawData.shapeType === 'rectangle' &&
@@ -1315,15 +1335,26 @@ export class DrawHandlerImpl implements DrawHandler {
const isFilalblePolygon = this.drawData && this.drawData.shapeType === 'polygon';
if (this.drawInstance && (isFillableRect || isFillableCuboid || isFilalblePolygon)) {
- this.drawInstance.fill({ opacity: configuration.selectedShapeOpacity });
+ this.drawInstance.fill({
+ opacity: configuration.hideEditedObject ? 0 : configuration.selectedShapeOpacity,
+ });
+ }
+
+ if (this.drawInstance && (isFilalblePolygon)) {
+ const paintHandler = this.drawInstance.remember('_paintHandler');
+ if (paintHandler) {
+ for (const point of (paintHandler as any).set.members) {
+ this.strokePoint(point);
+ }
+ }
}
if (this.drawInstance && this.drawInstance.attr('stroke')) {
- this.drawInstance.attr('stroke', this.outlinedBorders);
+ this.drawInstance.attr('stroke', configuration.hideEditedObject ? 'none' : this.outlinedBorders);
}
if (this.pointsGroup && this.pointsGroup.attr('stroke')) {
- this.pointsGroup.attr('stroke', this.outlinedBorders);
+ this.pointsGroup.attr('stroke', configuration.hideEditedObject ? 'none' : this.outlinedBorders);
}
this.autobordersEnabled = configuration.autoborders;
@@ -1369,6 +1400,7 @@ export class DrawHandlerImpl implements DrawHandler {
const paintHandler = this.drawInstance.remember('_paintHandler');
for (const point of (paintHandler as any).set.members) {
+ this.strokePoint(point);
point.attr('stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`);
point.attr('r', `${this.controlPointsSize / geometry.scale}`);
}
diff --git a/cvat-canvas/src/typescript/editHandler.ts b/cvat-canvas/src/typescript/editHandler.ts
index 567eea29c7de..84ecb1684ad4 100644
--- a/cvat-canvas/src/typescript/editHandler.ts
+++ b/cvat-canvas/src/typescript/editHandler.ts
@@ -472,7 +472,7 @@ export class EditHandlerImpl implements EditHandler {
const paintHandler = this.editLine.remember('_paintHandler');
- for (const point of (paintHandler as any).set.members) {
+ for (const point of paintHandler.set.members) {
point.attr('stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`);
point.attr('r', `${this.controlPointsSize / geometry.scale}`);
}
diff --git a/cvat-canvas/src/typescript/masksHandler.ts b/cvat-canvas/src/typescript/masksHandler.ts
index cdaa4d86d2fa..7f6a4e313fb3 100644
--- a/cvat-canvas/src/typescript/masksHandler.ts
+++ b/cvat-canvas/src/typescript/masksHandler.ts
@@ -6,7 +6,7 @@ import { fabric } from 'fabric';
import debounce from 'lodash/debounce';
import {
- DrawData, MasksEditData, Geometry, Configuration, BrushTool, ColorBy,
+ DrawData, MasksEditData, Geometry, Configuration, BrushTool, ColorBy, Position,
} from './canvasModel';
import consts from './consts';
import { DrawHandler } from './drawHandler';
@@ -61,10 +61,11 @@ export class MasksHandlerImpl implements MasksHandler {
private editData: MasksEditData | null;
private colorBy: ColorBy;
- private latestMousePos: { x: number; y: number; };
+ private latestMousePos: Position;
private startTimestamp: number;
private geometry: Geometry;
private drawingOpacity: number;
+ private isHidden: boolean;
private keepDrawnPolygon(): void {
const canvasWrapper = this.canvas.getElement().parentElement;
@@ -217,12 +218,29 @@ export class MasksHandlerImpl implements MasksHandler {
private imageDataFromCanvas(wrappingBBox: WrappingBBox): Uint8ClampedArray {
const imageData = this.canvas.toCanvasElement()
.getContext('2d').getImageData(
- wrappingBBox.left, wrappingBBox.top,
- wrappingBBox.right - wrappingBBox.left + 1, wrappingBBox.bottom - wrappingBBox.top + 1,
+ wrappingBBox.left,
+ wrappingBBox.top,
+ wrappingBBox.right - wrappingBBox.left + 1,
+ wrappingBBox.bottom - wrappingBBox.top + 1,
).data;
return imageData;
}
+ private updateHidden(value: boolean) {
+ this.isHidden = value;
+
+ // Need to update style of upper canvas explicitly because update of default cursor is not applied immediately
+ // https://github.com/fabricjs/fabric.js/issues/1456
+ const newOpacity = value ? '0' : '';
+ const newCursor = value ? 'inherit' : 'none';
+ this.canvas.getElement().parentElement.style.opacity = newOpacity;
+ const upperCanvas = this.canvas.getElement().parentElement.querySelector('.upper-canvas') as HTMLElement;
+ if (upperCanvas) {
+ upperCanvas.style.cursor = newCursor;
+ }
+ this.canvas.defaultCursor = newCursor;
+ }
+
private updateBrushTools(brushTool?: BrushTool, opts: Partial = {}): void {
if (this.isPolygonDrawing) {
// tool was switched from polygon to brush for example
@@ -350,6 +368,7 @@ export class MasksHandlerImpl implements MasksHandler {
this.editData = null;
this.drawingOpacity = 0.5;
this.brushMarker = null;
+ this.isHidden = false;
this.colorBy = ColorBy.LABEL;
this.onDrawDone = onDrawDone;
this.onDrawRepeat = onDrawRepeat;
@@ -385,6 +404,10 @@ export class MasksHandlerImpl implements MasksHandler {
rle.push(wrappingBbox.left, wrappingBbox.top, wrappingBbox.right, wrappingBbox.bottom);
this.onDrawDone({
+ occluded: this.drawData.initialState.occluded,
+ attributes: { ...this.drawData.initialState.attributes },
+ color: this.drawData.initialState.color,
+ objectType: this.drawData.initialState.objectType,
shapeType: this.drawData.shapeType,
points: rle,
label: this.drawData.initialState.label,
@@ -452,7 +475,7 @@ export class MasksHandlerImpl implements MasksHandler {
this.canvas.renderAll();
}
- if (isMouseDown && !isBrushSizeChanging && ['brush', 'eraser'].includes(tool?.type)) {
+ if (isMouseDown && !this.isHidden && !isBrushSizeChanging && ['brush', 'eraser'].includes(tool?.type)) {
const color = fabric.Color.fromHex(tool.color);
color.setAlpha(tool.type === 'eraser' ? 1 : 0.5);
@@ -530,6 +553,10 @@ export class MasksHandlerImpl implements MasksHandler {
public configurate(configuration: Configuration): void {
this.colorBy = configuration.colorBy;
+
+ if (this.isHidden !== configuration.hideEditedObject) {
+ this.updateHidden(configuration.hideEditedObject);
+ }
}
public transform(geometry: Geometry): void {
@@ -563,7 +590,10 @@ export class MasksHandlerImpl implements MasksHandler {
const color = fabric.Color.fromHex(this.getStateColor(drawData.initialState)).getSource();
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(color[0], color[1], color[2], points);
- imageDataToDataURL(imageBitmap, right - left + 1, bottom - top + 1,
+ imageDataToDataURL(
+ imageBitmap,
+ right - left + 1,
+ bottom - top + 1,
(dataURL: string) => new Promise((resolve) => {
fabric.Image.fromURL(dataURL, (image: fabric.Image) => {
try {
@@ -654,7 +684,10 @@ export class MasksHandlerImpl implements MasksHandler {
const color = fabric.Color.fromHex(this.getStateColor(editData.state)).getSource();
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(color[0], color[1], color[2], points);
- imageDataToDataURL(imageBitmap, right - left + 1, bottom - top + 1,
+ imageDataToDataURL(
+ imageBitmap,
+ right - left + 1,
+ bottom - top + 1,
(dataURL: string) => new Promise((resolve) => {
fabric.Image.fromURL(dataURL, (image: fabric.Image) => {
try {
diff --git a/cvat-canvas/src/typescript/svg.patch.ts b/cvat-canvas/src/typescript/svg.patch.ts
index 40af155a956f..7b728b274335 100644
--- a/cvat-canvas/src/typescript/svg.patch.ts
+++ b/cvat-canvas/src/typescript/svg.patch.ts
@@ -86,6 +86,7 @@ SVG.Element.prototype.draw.extend(
}),
);
+export const CIRCLE_STROKE = '#000';
// Fix method drawCircles
function drawCircles(): void {
const array = this.el.array().valueOf();
@@ -109,6 +110,7 @@ function drawCircles(): void {
.circle(5)
.stroke({
width: 1,
+ color: CIRCLE_STROKE,
})
.fill('#ccc')
.center(p.x, p.y),
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 e9be53974d91..94b064e0ace5 100644
--- a/cvat-cli/requirements/base.txt
+++ b/cvat-cli/requirements/base.txt
@@ -1,3 +1,3 @@
-cvat-sdk~=2.22.0
+cvat-sdk~=2.24.1
Pillow>=10.3.0
setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability
diff --git a/cvat-cli/requirements/development.txt b/cvat-cli/requirements/development.txt
deleted file mode 100644
index 42a144087213..000000000000
--- a/cvat-cli/requirements/development.txt
+++ /dev/null
@@ -1,5 +0,0 @@
--r base.txt
-
-black>=24.1
-isort>=5.10.1
-pylint>=2.7.0
\ No newline at end of file
diff --git a/cvat-cli/setup.py b/cvat-cli/setup.py
index 454ce2f00956..05b20a9165e1 100644
--- a/cvat-cli/setup.py
+++ b/cvat-cli/setup.py
@@ -56,7 +56,7 @@ def parse_requirements(filename=BASE_REQUIREMENTS_FILE):
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
- python_requires=">=3.8",
+ python_requires=">=3.9",
install_requires=BASE_REQUIREMENTS,
entry_points={
"console_scripts": [
diff --git a/cvat-cli/src/cvat_cli/__main__.py b/cvat-cli/src/cvat_cli/__main__.py
index 2448587245f9..c93569182c08 100755
--- a/cvat-cli/src/cvat_cli/__main__.py
+++ b/cvat-cli/src/cvat_cli/__main__.py
@@ -1,78 +1,37 @@
# Copyright (C) 2020-2022 Intel Corporation
-# Copyright (C) 2022 CVAT.ai Corporation
+# Copyright (C) 2022-2024 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
+import argparse
import logging
import sys
-from http.client import HTTPConnection
-from types import SimpleNamespace
-from typing import List
+import urllib3.exceptions
from cvat_sdk import exceptions
-from cvat_sdk.core.client import Client, Config
-from cvat_cli.cli import CLI
-from cvat_cli.parser import get_action_args, make_cmdline_parser
+from ._internal.commands_all import COMMANDS
+from ._internal.common import build_client, configure_common_arguments, configure_logger
+from ._internal.utils import popattr
logger = logging.getLogger(__name__)
-def configure_logger(level):
- formatter = logging.Formatter(
- "[%(asctime)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", style="%"
- )
- handler = logging.StreamHandler(sys.stdout)
- handler.setFormatter(formatter)
- logger.addHandler(handler)
- logger.setLevel(level)
- if level <= logging.DEBUG:
- HTTPConnection.debuglevel = 1
+def main(args: list[str] = None):
+ parser = argparse.ArgumentParser(description=COMMANDS.description)
+ configure_common_arguments(parser)
+ COMMANDS.configure_parser(parser)
-
-def build_client(parsed_args: SimpleNamespace, logger: logging.Logger) -> Client:
- config = Config(verify_ssl=not parsed_args.insecure)
-
- url = parsed_args.server_host
- if parsed_args.server_port:
- url += f":{parsed_args.server_port}"
-
- client = Client(
- url=url,
- logger=logger,
- config=config,
- check_server_version=False, # version is checked after auth to support versions < 2.3
- )
-
- client.organization_slug = parsed_args.organization
-
- return client
-
-
-def main(args: List[str] = None):
- actions = {
- "create": CLI.tasks_create,
- "delete": CLI.tasks_delete,
- "ls": CLI.tasks_list,
- "frames": CLI.tasks_frames,
- "dump": CLI.tasks_dump,
- "upload": CLI.tasks_upload,
- "export": CLI.tasks_export,
- "import": CLI.tasks_import,
- "auto-annotate": CLI.tasks_auto_annotate,
- }
- parser = make_cmdline_parser()
parsed_args = parser.parse_args(args)
- configure_logger(parsed_args.loglevel)
- with build_client(parsed_args, logger=logger) as client:
- action_args = get_action_args(parser, parsed_args)
- try:
- cli = CLI(client=client, credentials=parsed_args.auth)
- actions[parsed_args.action](cli, **vars(action_args))
- except exceptions.ApiException as e:
- logger.critical(e)
- return 1
+ configure_logger(logger, parsed_args)
+
+ try:
+ with build_client(parsed_args, logger=logger) as client:
+ popattr(parsed_args, "_executor")(client, **vars(parsed_args))
+ except (exceptions.ApiException, urllib3.exceptions.HTTPError) as e:
+ logger.critical(e)
+ return 1
return 0
diff --git a/cvat-cli/src/cvat_cli/_internal/__init__.py b/cvat-cli/src/cvat_cli/_internal/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/cvat-cli/src/cvat_cli/_internal/command_base.py b/cvat-cli/src/cvat_cli/_internal/command_base.py
new file mode 100644
index 000000000000..94e13f3f16e9
--- /dev/null
+++ b/cvat-cli/src/cvat_cli/_internal/command_base.py
@@ -0,0 +1,126 @@
+# Copyright (C) 2024 CVAT.ai Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import argparse
+import json
+import textwrap
+import types
+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
+ def description(self) -> str: ...
+
+ def configure_parser(self, parser: argparse.ArgumentParser) -> None: ...
+
+ # The exact parameters accepted by `execute` vary between commands,
+ # so we're forced to declare it like this instead of as a method.
+ @property
+ def execute(self) -> Callable[..., None]: ...
+
+
+class CommandGroup:
+ def __init__(self, *, description: str) -> None:
+ self._commands: dict[str, Command] = {}
+ self.description = description
+
+ def command_class(self, name: str):
+ def decorator(cls: type):
+ self._commands[name] = cls()
+ return cls
+
+ return decorator
+
+ def add_command(self, name: str, command: Command) -> None:
+ self._commands[name] = command
+
+ @property
+ def commands(self) -> Mapping[str, Command]:
+ return types.MappingProxyType(self._commands)
+
+ def configure_parser(self, parser: argparse.ArgumentParser) -> None:
+ subparsers = parser.add_subparsers(required=True)
+
+ for name, command in self._commands.items():
+ subparser = subparsers.add_parser(name, description=command.description)
+ subparser.set_defaults(_executor=command.execute)
+ command.configure_parser(subparser)
+
+ 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_tasks.py b/cvat-cli/src/cvat_cli/_internal/commands_tasks.py
new file mode 100644
index 000000000000..8c6782887d97
--- /dev/null
+++ b/cvat-cli/src/cvat_cli/_internal/commands_tasks.py
@@ -0,0 +1,507 @@
+# Copyright (C) 2022-2024 CVAT.ai Corporation
+#
+# SPDX-License-Identifier: MIT
+
+from __future__ import annotations
+
+import argparse
+import importlib
+import importlib.util
+import textwrap
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any, Optional
+
+import cvat_sdk.auto_annotation as cvataa
+from attr.converters import to_bool
+from cvat_sdk import Client, models
+from cvat_sdk.core.helpers import DeferredTqdmProgressReporter
+from cvat_sdk.core.proxies.tasks import ResourceType
+
+from .command_base import CommandGroup, GenericCommand, GenericDeleteCommand, GenericListCommand
+from .parsers import (
+ BuildDictAction,
+ parse_function_parameter,
+ parse_label_arg,
+ parse_resource_type,
+ parse_threshold,
+)
+
+COMMANDS = CommandGroup(description="Perform operations on CVAT tasks.")
+
+
+class GenericTaskCommand(GenericCommand):
+ resource_type_str = "task"
+
+ def repo(self, client: Client):
+ return client.tasks
+
+
+@COMMANDS.command_class("ls")
+class TaskList(GenericListCommand, GenericTaskCommand):
+ pass
+
+
+@COMMANDS.command_class("create")
+class TaskCreate:
+ description = textwrap.dedent(
+ """\
+ Create a new CVAT task. To create a task, you need
+ to specify labels using the --labels argument or
+ attach the task to an existing project using the
+ --project_id argument.
+ """
+ )
+
+ def configure_parser(self, parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("name", type=str, help="name of the task")
+ parser.add_argument(
+ "resource_type",
+ default="local",
+ choices=list(ResourceType),
+ type=parse_resource_type,
+ help="type of files specified",
+ )
+ parser.add_argument("resources", type=str, help="list of paths or URLs", nargs="+")
+ parser.add_argument(
+ "--annotation_path", default="", type=str, help="path to annotation file"
+ )
+ parser.add_argument(
+ "--annotation_format",
+ default="CVAT 1.1",
+ type=str,
+ help="format of the annotation file being uploaded, e.g. CVAT 1.1",
+ )
+ parser.add_argument(
+ "--bug_tracker", "--bug", default=argparse.SUPPRESS, type=str, help="bug tracker URL"
+ )
+ parser.add_argument(
+ "--chunk_size",
+ default=argparse.SUPPRESS,
+ type=int,
+ help="the number of frames per chunk",
+ )
+ parser.add_argument(
+ "--completion_verification_period",
+ dest="status_check_period",
+ default=2,
+ type=float,
+ help=textwrap.dedent(
+ """\
+ number of seconds to wait until checking
+ if data compression finished (necessary before uploading annotations)
+ """
+ ),
+ )
+ parser.add_argument(
+ "--copy_data",
+ default=False,
+ action="store_true",
+ help=textwrap.dedent(
+ """\
+ set the option to copy the data, only used when resource type is
+ share (default: %(default)s)
+ """
+ ),
+ )
+ parser.add_argument(
+ "--frame_step",
+ default=argparse.SUPPRESS,
+ type=int,
+ help=textwrap.dedent(
+ """\
+ set the frame step option in the advanced configuration
+ when uploading image series or videos
+ """
+ ),
+ )
+ parser.add_argument(
+ "--image_quality",
+ default=70,
+ type=int,
+ help=textwrap.dedent(
+ """\
+ set the image quality option in the advanced configuration
+ when creating tasks.(default: %(default)s)
+ """
+ ),
+ )
+ parser.add_argument(
+ "--labels",
+ default="[]",
+ type=parse_label_arg,
+ help="string or file containing JSON labels specification",
+ )
+ parser.add_argument(
+ "--project_id", default=argparse.SUPPRESS, type=int, help="project ID if project exists"
+ )
+ parser.add_argument(
+ "--overlap",
+ default=argparse.SUPPRESS,
+ type=int,
+ help="the number of intersected frames between different segments",
+ )
+ parser.add_argument(
+ "--segment_size",
+ default=argparse.SUPPRESS,
+ type=int,
+ help="the number of frames in a segment",
+ )
+ parser.add_argument(
+ "--sorting-method",
+ default="lexicographical",
+ choices=["lexicographical", "natural", "predefined", "random"],
+ help="""data soring method (default: %(default)s)""",
+ )
+ parser.add_argument(
+ "--start_frame",
+ default=argparse.SUPPRESS,
+ type=int,
+ help="the start frame of the video",
+ )
+ parser.add_argument(
+ "--stop_frame", default=argparse.SUPPRESS, type=int, help="the stop frame of the video"
+ )
+ parser.add_argument(
+ "--use_cache",
+ action="store_true",
+ help="""use cache""", # automatically sets default=False
+ )
+ parser.add_argument(
+ "--use_zip_chunks",
+ action="store_true", # automatically sets default=False
+ help="""zip chunks before sending them to the server""",
+ )
+ parser.add_argument(
+ "--cloud_storage_id",
+ default=argparse.SUPPRESS,
+ type=int,
+ help="cloud storage ID if you would like to use data from cloud storage",
+ )
+ parser.add_argument(
+ "--filename_pattern",
+ default=argparse.SUPPRESS,
+ type=str,
+ help=textwrap.dedent(
+ """\
+ pattern for filtering data from the manifest file for the upload.
+ Only shell-style wildcards are supported:
+ * - matches everything;
+ ? - matches any single character;
+ [seq] - matches any character in 'seq';
+ [!seq] - matches any character not in seq
+ """
+ ),
+ )
+
+ def execute(
+ self,
+ client,
+ *,
+ name: str,
+ labels: list[dict[str, str]],
+ resources: Sequence[str],
+ resource_type: ResourceType,
+ annotation_path: str,
+ annotation_format: str,
+ status_check_period: int,
+ **kwargs,
+ ) -> None:
+ task_params = {}
+ data_params = {}
+
+ for k, v in kwargs.items():
+ if k in models.DataRequest.attribute_map or k == "frame_step":
+ data_params[k] = v
+ else:
+ task_params[k] = v
+
+ task = client.tasks.create_from_data(
+ spec=models.TaskWriteRequest(name=name, labels=labels, **task_params),
+ resource_type=resource_type,
+ resources=resources,
+ data_params=data_params,
+ annotation_path=annotation_path,
+ annotation_format=annotation_format,
+ status_check_period=status_check_period,
+ pbar=DeferredTqdmProgressReporter(),
+ )
+ print(task.id)
+
+
+@COMMANDS.command_class("delete")
+class TaskDelete(GenericDeleteCommand, GenericTaskCommand):
+ pass
+
+
+@COMMANDS.command_class("frames")
+class TaskFrames:
+ description = textwrap.dedent(
+ """\
+ Download the requested frame numbers for a task and save images as
+ task__frame_.jpg.
+ """
+ )
+
+ def configure_parser(self, parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("task_id", type=int, help="task ID")
+ parser.add_argument("frame_ids", type=int, help="list of frame IDs to download", nargs="+")
+ parser.add_argument(
+ "--outdir", type=str, default="", help="directory to save images (default: CWD)"
+ )
+ parser.add_argument(
+ "--quality",
+ type=str,
+ choices=("original", "compressed"),
+ default="original",
+ help="choose quality of images (default: %(default)s)",
+ )
+
+ def execute(
+ self,
+ client: Client,
+ *,
+ task_id: int,
+ frame_ids: Sequence[int],
+ outdir: str,
+ quality: str,
+ ) -> None:
+ client.tasks.retrieve(obj_id=task_id).download_frames(
+ frame_ids=frame_ids,
+ outdir=outdir,
+ quality=quality,
+ filename_pattern=f"task_{task_id}" + "_frame_{frame_id:06d}{frame_ext}",
+ )
+
+
+@COMMANDS.command_class("export-dataset")
+class TaskExportDataset:
+ description = textwrap.dedent(
+ """\
+ Export a task as a dataset in the specified format (e.g. 'YOLO 1.1').
+ """
+ )
+
+ def configure_parser(self, parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("task_id", type=int, help="task ID")
+ parser.add_argument("filename", type=str, help="output file")
+ parser.add_argument(
+ "--format",
+ dest="fileformat",
+ type=str,
+ default="CVAT for images 1.1",
+ help="annotation format (default: %(default)s)",
+ )
+ parser.add_argument(
+ "--completion_verification_period",
+ dest="status_check_period",
+ default=2,
+ type=float,
+ help="number of seconds to wait until checking if dataset building finished",
+ )
+ parser.add_argument(
+ "--with-images",
+ type=to_bool,
+ default=False,
+ dest="include_images",
+ help="Whether to include images or not (default: %(default)s)",
+ )
+
+ def execute(
+ self,
+ client: Client,
+ *,
+ task_id: int,
+ fileformat: str,
+ filename: str,
+ status_check_period: int,
+ include_images: bool,
+ ) -> None:
+ client.tasks.retrieve(obj_id=task_id).export_dataset(
+ format_name=fileformat,
+ filename=filename,
+ pbar=DeferredTqdmProgressReporter(),
+ status_check_period=status_check_period,
+ include_images=include_images,
+ )
+
+
+@COMMANDS.command_class("import-dataset")
+class TaskImportDataset:
+ description = textwrap.dedent(
+ """\
+ Import annotations into a task from a dataset in the specified format
+ (e.g. 'YOLO 1.1').
+ """
+ )
+
+ def configure_parser(self, parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("task_id", type=int, help="task ID")
+ parser.add_argument("filename", type=str, help="upload file")
+ parser.add_argument(
+ "--format",
+ dest="fileformat",
+ type=str,
+ default="CVAT 1.1",
+ help="annotation format (default: %(default)s)",
+ )
+
+ def execute(
+ self,
+ client: Client,
+ *,
+ task_id: int,
+ fileformat: str,
+ filename: str,
+ ) -> None:
+ client.tasks.retrieve(obj_id=task_id).import_annotations(
+ format_name=fileformat,
+ filename=filename,
+ pbar=DeferredTqdmProgressReporter(),
+ )
+
+
+@COMMANDS.command_class("backup")
+class TaskBackup:
+ description = """Download a task backup."""
+
+ def configure_parser(self, parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("task_id", type=int, help="task ID")
+ parser.add_argument("filename", type=str, help="output file")
+ parser.add_argument(
+ "--completion_verification_period",
+ dest="status_check_period",
+ default=2,
+ type=float,
+ help="time interval between checks if archive building has been finished, in seconds",
+ )
+
+ def execute(
+ self, client: Client, *, task_id: int, filename: str, status_check_period: int
+ ) -> None:
+ client.tasks.retrieve(obj_id=task_id).download_backup(
+ filename=filename,
+ status_check_period=status_check_period,
+ pbar=DeferredTqdmProgressReporter(),
+ )
+
+
+@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")
+ parser.add_argument(
+ "--completion_verification_period",
+ dest="status_check_period",
+ default=2,
+ type=float,
+ help="time interval between checks if archive processing was finished, in seconds",
+ )
+
+ def execute(self, client: Client, *, filename: str, status_check_period: int) -> None:
+ task = client.tasks.create_from_backup(
+ filename=filename,
+ status_check_period=status_check_period,
+ pbar=DeferredTqdmProgressReporter(),
+ )
+ print(task.id)
+
+
+@COMMANDS.command_class("auto-annotate")
+class TaskAutoAnnotate:
+ description = "Automatically annotate a CVAT task by running a function on the local machine."
+
+ def configure_parser(self, parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("task_id", type=int, help="task ID")
+
+ function_group = parser.add_mutually_exclusive_group(required=True)
+
+ function_group.add_argument(
+ "--function-module",
+ metavar="MODULE",
+ help="qualified name of a module to use as the function",
+ )
+
+ function_group.add_argument(
+ "--function-file",
+ metavar="PATH",
+ type=Path,
+ help="path to a Python source file to use as the function",
+ )
+
+ parser.add_argument(
+ "--function-parameter",
+ "-p",
+ metavar="NAME=TYPE:VALUE",
+ type=parse_function_parameter,
+ action=BuildDictAction,
+ dest="function_parameters",
+ help="parameter for the function",
+ )
+
+ parser.add_argument(
+ "--clear-existing",
+ action="store_true",
+ help="Remove existing annotations from the task",
+ )
+
+ parser.add_argument(
+ "--allow-unmatched-labels",
+ action="store_true",
+ help="Allow the function to declare labels not configured in the task",
+ )
+
+ parser.add_argument(
+ "--conf-threshold",
+ type=parse_threshold,
+ help="Confidence threshold for filtering detections",
+ default=None,
+ )
+
+ parser.add_argument(
+ "--conv-mask-to-poly",
+ action="store_true",
+ help="Convert mask shapes to polygon shapes",
+ )
+
+ def execute(
+ self,
+ client: Client,
+ *,
+ task_id: int,
+ function_module: Optional[str] = None,
+ function_file: Optional[Path] = None,
+ function_parameters: dict[str, Any],
+ clear_existing: bool = False,
+ allow_unmatched_labels: bool = False,
+ conf_threshold: Optional[float],
+ conv_mask_to_poly: bool,
+ ) -> None:
+ if function_module is not None:
+ function = importlib.import_module(function_module)
+ elif function_file is not None:
+ module_spec = importlib.util.spec_from_file_location("__cvat_function__", function_file)
+ function = importlib.util.module_from_spec(module_spec)
+ module_spec.loader.exec_module(function)
+ else:
+ assert False, "function identification arguments missing"
+
+ if hasattr(function, "create"):
+ # this is actually a function factory
+ function = function.create(**function_parameters)
+ else:
+ if function_parameters:
+ raise TypeError("function takes no parameters")
+
+ cvataa.annotate_task(
+ client,
+ task_id,
+ function,
+ pbar=DeferredTqdmProgressReporter(),
+ clear_existing=clear_existing,
+ allow_unmatched_labels=allow_unmatched_labels,
+ conf_threshold=conf_threshold,
+ conv_mask_to_poly=conv_mask_to_poly,
+ )
diff --git a/cvat-cli/src/cvat_cli/_internal/common.py b/cvat-cli/src/cvat_cli/_internal/common.py
new file mode 100644
index 000000000000..6f37e3d74eaa
--- /dev/null
+++ b/cvat-cli/src/cvat_cli/_internal/common.py
@@ -0,0 +1,104 @@
+# Copyright (C) 2021-2022 Intel Corporation
+# Copyright (C) 2022-2024 CVAT.ai Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import argparse
+import getpass
+import logging
+import os
+import sys
+from http.client import HTTPConnection
+
+from cvat_sdk.core.client import Client, Config
+
+from ..version import VERSION
+from .utils import popattr
+
+
+def get_auth(s):
+ """Parse USER[:PASS] strings and prompt for password if none was
+ supplied."""
+ user, _, password = s.partition(":")
+ password = password or os.environ.get("PASS") or getpass.getpass()
+ return user, password
+
+
+def configure_common_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("--version", action="version", version=VERSION)
+ parser.add_argument(
+ "--insecure",
+ action="store_true",
+ help="Allows to disable SSL certificate check",
+ )
+
+ parser.add_argument(
+ "--auth",
+ type=get_auth,
+ metavar="USER:[PASS]",
+ default=getpass.getuser(),
+ help="""defaults to the current user and supports the PASS
+ environment variable or password prompt
+ (default user: %(default)s).""",
+ )
+ parser.add_argument(
+ "--server-host", type=str, default="localhost", help="host (default: %(default)s)"
+ )
+ parser.add_argument(
+ "--server-port",
+ type=int,
+ default=None,
+ help="port (default: 80 for http and 443 for https connections)",
+ )
+ parser.add_argument(
+ "--organization",
+ "--org",
+ metavar="SLUG",
+ help="""short name (slug) of the organization
+ to use when listing or creating resources;
+ set to blank string to use the personal workspace
+ (default: list all accessible objects, create in personal workspace)""",
+ )
+ parser.add_argument(
+ "--debug",
+ action="store_const",
+ dest="loglevel",
+ const=logging.DEBUG,
+ default=logging.INFO,
+ help="show debug output",
+ )
+
+
+def configure_logger(logger: logging.Logger, parsed_args: argparse.Namespace) -> None:
+ level = popattr(parsed_args, "loglevel")
+ formatter = logging.Formatter(
+ "[%(asctime)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", style="%"
+ )
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setFormatter(formatter)
+ logger.addHandler(handler)
+ logger.setLevel(level)
+ if level <= logging.DEBUG:
+ HTTPConnection.debuglevel = 1
+
+
+def build_client(parsed_args: argparse.Namespace, logger: logging.Logger) -> Client:
+ config = Config(verify_ssl=not popattr(parsed_args, "insecure"))
+
+ url = popattr(parsed_args, "server_host")
+ if server_port := popattr(parsed_args, "server_port"):
+ url += f":{server_port}"
+
+ client = Client(
+ url=url,
+ logger=logger,
+ config=config,
+ check_server_version=False, # version is checked after auth to support versions < 2.3
+ )
+
+ client.login(popattr(parsed_args, "auth"))
+ client.check_server_version(fail_if_unsupported=False)
+
+ client.organization_slug = popattr(parsed_args, "organization")
+
+ return client
diff --git a/cvat-cli/src/cvat_cli/_internal/parsers.py b/cvat-cli/src/cvat_cli/_internal/parsers.py
new file mode 100644
index 000000000000..97dcb5b2668a
--- /dev/null
+++ b/cvat-cli/src/cvat_cli/_internal/parsers.py
@@ -0,0 +1,73 @@
+# Copyright (C) 2021-2022 Intel Corporation
+# Copyright (C) 2022-2024 CVAT.ai Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import argparse
+import json
+import os.path
+from typing import Any
+
+from attr.converters import to_bool
+from cvat_sdk.core.proxies.tasks import ResourceType
+
+
+def parse_resource_type(s: str) -> ResourceType:
+ try:
+ return ResourceType[s.upper()]
+ except KeyError:
+ return s
+
+
+def parse_label_arg(s):
+ """If s is a file load it as JSON, otherwise parse s as JSON."""
+ if os.path.exists(s):
+ with open(s, "r") as fp:
+ return json.load(fp)
+ else:
+ return json.loads(s)
+
+
+def parse_function_parameter(s: str) -> tuple[str, Any]:
+ key, sep, type_and_value = s.partition("=")
+
+ if not sep:
+ raise argparse.ArgumentTypeError("parameter value not specified")
+
+ type_, sep, value = type_and_value.partition(":")
+
+ if not sep:
+ raise argparse.ArgumentTypeError("parameter type not specified")
+
+ if type_ == "int":
+ value = int(value)
+ elif type_ == "float":
+ value = float(value)
+ elif type_ == "str":
+ pass
+ elif type_ == "bool":
+ value = to_bool(value)
+ else:
+ raise argparse.ArgumentTypeError(f"unsupported parameter type {type_!r}")
+
+ return (key, value)
+
+
+def parse_threshold(s: str) -> float:
+ try:
+ value = float(s)
+ except ValueError as e:
+ raise argparse.ArgumentTypeError("must be a number") from e
+
+ if not 0 <= value <= 1:
+ raise argparse.ArgumentTypeError("must be between 0 and 1")
+ return value
+
+
+class BuildDictAction(argparse.Action):
+ def __init__(self, option_strings, dest, default=None, **kwargs):
+ super().__init__(option_strings, dest, default=default or {}, **kwargs)
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ key, value = values
+ getattr(namespace, self.dest)[key] = value
diff --git a/cvat-cli/src/cvat_cli/_internal/utils.py b/cvat-cli/src/cvat_cli/_internal/utils.py
new file mode 100644
index 000000000000..b541534790c4
--- /dev/null
+++ b/cvat-cli/src/cvat_cli/_internal/utils.py
@@ -0,0 +1,9 @@
+# Copyright (C) 2024 CVAT.ai Corporation
+#
+# SPDX-License-Identifier: MIT
+
+
+def popattr(obj, name):
+ value = getattr(obj, name)
+ delattr(obj, name)
+ return value
diff --git a/cvat-cli/src/cvat_cli/cli.py b/cvat-cli/src/cvat_cli/cli.py
deleted file mode 100644
index e7945b18bb2e..000000000000
--- a/cvat-cli/src/cvat_cli/cli.py
+++ /dev/null
@@ -1,177 +0,0 @@
-# Copyright (C) 2022 CVAT.ai Corporation
-#
-# SPDX-License-Identifier: MIT
-
-from __future__ import annotations
-
-import importlib
-import importlib.util
-import json
-from pathlib import Path
-from typing import Any, Dict, List, Optional, Sequence, Tuple
-
-import cvat_sdk.auto_annotation as cvataa
-from cvat_sdk import Client, models
-from cvat_sdk.core.helpers import DeferredTqdmProgressReporter
-from cvat_sdk.core.proxies.tasks import ResourceType
-
-
-class CLI:
- def __init__(self, client: Client, credentials: Tuple[str, str]):
- self.client = client
-
- self.client.login(credentials)
-
- self.client.check_server_version(fail_if_unsupported=False)
-
- def tasks_list(self, *, use_json_output: bool = False, **kwargs):
- """List all tasks in either basic or JSON format."""
- results = self.client.tasks.list(return_json=use_json_output, **kwargs)
- if use_json_output:
- print(json.dumps(json.loads(results), indent=2))
- else:
- for r in results:
- print(r.id)
-
- def tasks_create(
- self,
- name: str,
- labels: List[Dict[str, str]],
- resources: Sequence[str],
- *,
- resource_type: ResourceType = ResourceType.LOCAL,
- annotation_path: str = "",
- annotation_format: str = "CVAT XML 1.1",
- status_check_period: int = 2,
- **kwargs,
- ) -> None:
- """
- Create a new task with the given name and labels JSON and add the files to it.
- """
-
- task_params = {}
- data_params = {}
-
- for k, v in kwargs.items():
- if k in models.DataRequest.attribute_map or k == "frame_step":
- data_params[k] = v
- else:
- task_params[k] = v
-
- task = self.client.tasks.create_from_data(
- spec=models.TaskWriteRequest(name=name, labels=labels, **task_params),
- resource_type=resource_type,
- resources=resources,
- data_params=data_params,
- annotation_path=annotation_path,
- annotation_format=annotation_format,
- status_check_period=status_check_period,
- pbar=DeferredTqdmProgressReporter(),
- )
- print("Created task id", task.id)
-
- def tasks_delete(self, task_ids: Sequence[int]) -> None:
- """Delete a list of tasks, ignoring those which don't exist."""
- self.client.tasks.remove_by_ids(task_ids=task_ids)
-
- def tasks_frames(
- self,
- task_id: int,
- frame_ids: Sequence[int],
- *,
- outdir: str = "",
- quality: str = "original",
- ) -> None:
- """
- Download the requested frame numbers for a task and save images as
- task__frame_.jpg.
- """
- self.client.tasks.retrieve(obj_id=task_id).download_frames(
- frame_ids=frame_ids,
- outdir=outdir,
- quality=quality,
- filename_pattern=f"task_{task_id}" + "_frame_{frame_id:06d}{frame_ext}",
- )
-
- def tasks_dump(
- self,
- task_id: int,
- fileformat: str,
- filename: str,
- *,
- status_check_period: int = 2,
- include_images: bool = False,
- ) -> None:
- """
- Download annotations for a task in the specified format (e.g. 'YOLO ZIP 1.0').
- """
- self.client.tasks.retrieve(obj_id=task_id).export_dataset(
- format_name=fileformat,
- filename=filename,
- pbar=DeferredTqdmProgressReporter(),
- status_check_period=status_check_period,
- include_images=include_images,
- )
-
- def tasks_upload(
- self, task_id: str, fileformat: str, filename: str, *, status_check_period: int = 2
- ) -> None:
- """Upload annotations for a task in the specified format
- (e.g. 'YOLO ZIP 1.0')."""
- self.client.tasks.retrieve(obj_id=task_id).import_annotations(
- format_name=fileformat,
- filename=filename,
- status_check_period=status_check_period,
- pbar=DeferredTqdmProgressReporter(),
- )
-
- def tasks_export(self, task_id: str, filename: str, *, status_check_period: int = 2) -> None:
- """Download a task backup"""
- self.client.tasks.retrieve(obj_id=task_id).download_backup(
- filename=filename,
- status_check_period=status_check_period,
- pbar=DeferredTqdmProgressReporter(),
- )
-
- def tasks_import(self, filename: str, *, status_check_period: int = 2) -> None:
- """Import a task from a backup file"""
- self.client.tasks.create_from_backup(
- filename=filename,
- status_check_period=status_check_period,
- pbar=DeferredTqdmProgressReporter(),
- )
-
- def tasks_auto_annotate(
- self,
- task_id: int,
- *,
- function_module: Optional[str] = None,
- function_file: Optional[Path] = None,
- function_parameters: Dict[str, Any],
- clear_existing: bool = False,
- allow_unmatched_labels: bool = False,
- ) -> None:
- if function_module is not None:
- function = importlib.import_module(function_module)
- elif function_file is not None:
- module_spec = importlib.util.spec_from_file_location("__cvat_function__", function_file)
- function = importlib.util.module_from_spec(module_spec)
- module_spec.loader.exec_module(function)
- else:
- assert False, "function identification arguments missing"
-
- if hasattr(function, "create"):
- # this is actually a function factory
- function = function.create(**function_parameters)
- else:
- if function_parameters:
- raise TypeError("function takes no parameters")
-
- cvataa.annotate_task(
- self.client,
- task_id,
- function,
- pbar=DeferredTqdmProgressReporter(),
- clear_existing=clear_existing,
- allow_unmatched_labels=allow_unmatched_labels,
- )
diff --git a/cvat-cli/src/cvat_cli/parser.py b/cvat-cli/src/cvat_cli/parser.py
deleted file mode 100644
index d456b087cd65..000000000000
--- a/cvat-cli/src/cvat_cli/parser.py
+++ /dev/null
@@ -1,452 +0,0 @@
-# Copyright (C) 2021-2022 Intel Corporation
-# Copyright (C) 2022 CVAT.ai Corporation
-#
-# SPDX-License-Identifier: MIT
-
-import argparse
-import getpass
-import json
-import logging
-import os
-import textwrap
-from pathlib import Path
-from typing import Any, Tuple
-
-from attr.converters import to_bool
-from cvat_sdk.core.proxies.tasks import ResourceType
-
-from .version import VERSION
-
-
-def get_auth(s):
- """Parse USER[:PASS] strings and prompt for password if none was
- supplied."""
- user, _, password = s.partition(":")
- password = password or os.environ.get("PASS") or getpass.getpass()
- return user, password
-
-
-def parse_label_arg(s):
- """If s is a file load it as JSON, otherwise parse s as JSON."""
- if os.path.exists(s):
- with open(s, "r") as fp:
- return json.load(fp)
- else:
- return json.loads(s)
-
-
-def parse_resource_type(s: str) -> ResourceType:
- try:
- return ResourceType[s.upper()]
- except KeyError:
- return s
-
-
-def parse_function_parameter(s: str) -> Tuple[str, Any]:
- key, sep, type_and_value = s.partition("=")
-
- if not sep:
- raise argparse.ArgumentTypeError("parameter value not specified")
-
- type_, sep, value = type_and_value.partition(":")
-
- if not sep:
- raise argparse.ArgumentTypeError("parameter type not specified")
-
- if type_ == "int":
- value = int(value)
- elif type_ == "float":
- value = float(value)
- elif type_ == "str":
- pass
- elif type_ == "bool":
- value = to_bool(value)
- else:
- raise argparse.ArgumentTypeError(f"unsupported parameter type {type_!r}")
-
- return (key, value)
-
-
-class BuildDictAction(argparse.Action):
- def __init__(self, option_strings, dest, default=None, **kwargs):
- super().__init__(option_strings, dest, default=default or {}, **kwargs)
-
- def __call__(self, parser, namespace, values, option_string=None):
- key, value = values
- getattr(namespace, self.dest)[key] = value
-
-
-def make_cmdline_parser() -> argparse.ArgumentParser:
- #######################################################################
- # Command line interface definition
- #######################################################################
- parser = argparse.ArgumentParser(
- description="Perform common operations related to CVAT tasks.\n\n"
- )
- parser.add_argument("--version", action="version", version=VERSION)
- parser.add_argument(
- "--insecure",
- action="store_true",
- help="Allows to disable SSL certificate check",
- )
-
- task_subparser = parser.add_subparsers(dest="action")
-
- #######################################################################
- # Positional arguments
- #######################################################################
- parser.add_argument(
- "--auth",
- type=get_auth,
- metavar="USER:[PASS]",
- default=getpass.getuser(),
- help="""defaults to the current user and supports the PASS
- environment variable or password prompt
- (default user: %(default)s).""",
- )
- parser.add_argument(
- "--server-host", type=str, default="localhost", help="host (default: %(default)s)"
- )
- parser.add_argument(
- "--server-port",
- type=int,
- default=None,
- help="port (default: 80 for http and 443 for https connections)",
- )
- parser.add_argument(
- "--organization",
- "--org",
- metavar="SLUG",
- help="""short name (slug) of the organization
- to use when listing or creating resources;
- set to blank string to use the personal workspace
- (default: list all accessible objects, create in personal workspace)""",
- )
- parser.add_argument(
- "--debug",
- action="store_const",
- dest="loglevel",
- const=logging.DEBUG,
- default=logging.INFO,
- help="show debug output",
- )
-
- #######################################################################
- # Create
- #######################################################################
- task_create_parser = task_subparser.add_parser(
- "create",
- description=textwrap.dedent(
- """\
- Create a new CVAT task. To create a task, you need
- to specify labels using the --labels argument or
- attach the task to an existing project using the
- --project_id argument.
- """
- ),
- formatter_class=argparse.RawTextHelpFormatter,
- )
- task_create_parser.add_argument("name", type=str, help="name of the task")
- task_create_parser.add_argument(
- "resource_type",
- default="local",
- choices=list(ResourceType),
- type=parse_resource_type,
- help="type of files specified",
- )
- task_create_parser.add_argument("resources", type=str, help="list of paths or URLs", nargs="+")
- task_create_parser.add_argument(
- "--annotation_path", default="", type=str, help="path to annotation file"
- )
- task_create_parser.add_argument(
- "--annotation_format",
- default="CVAT 1.1",
- type=str,
- help="format of the annotation file being uploaded, e.g. CVAT 1.1",
- )
- task_create_parser.add_argument(
- "--bug_tracker", "--bug", default=None, type=str, help="bug tracker URL"
- )
- task_create_parser.add_argument(
- "--chunk_size", default=None, type=int, help="the number of frames per chunk"
- )
- task_create_parser.add_argument(
- "--completion_verification_period",
- dest="status_check_period",
- default=2,
- type=float,
- help=textwrap.dedent(
- """\
- number of seconds to wait until checking
- if data compression finished (necessary before uploading annotations)
- """
- ),
- )
- task_create_parser.add_argument(
- "--copy_data",
- default=False,
- action="store_true",
- help=textwrap.dedent(
- """\
- set the option to copy the data, only used when resource type is
- share (default: %(default)s)
- """
- ),
- )
- task_create_parser.add_argument(
- "--frame_step",
- default=None,
- type=int,
- help=textwrap.dedent(
- """\
- set the frame step option in the advanced configuration
- when uploading image series or videos (default: %(default)s)
- """
- ),
- )
- task_create_parser.add_argument(
- "--image_quality",
- default=70,
- type=int,
- help=textwrap.dedent(
- """\
- set the image quality option in the advanced configuration
- when creating tasks.(default: %(default)s)
- """
- ),
- )
- task_create_parser.add_argument(
- "--labels",
- default="[]",
- type=parse_label_arg,
- help="string or file containing JSON labels specification",
- )
- task_create_parser.add_argument(
- "--project_id", default=None, type=int, help="project ID if project exists"
- )
- task_create_parser.add_argument(
- "--overlap",
- default=None,
- type=int,
- help="the number of intersected frames between different segments",
- )
- task_create_parser.add_argument(
- "--segment_size", default=None, type=int, help="the number of frames in a segment"
- )
- task_create_parser.add_argument(
- "--sorting-method",
- default="lexicographical",
- choices=["lexicographical", "natural", "predefined", "random"],
- help="""data soring method (default: %(default)s)""",
- )
- task_create_parser.add_argument(
- "--start_frame", default=None, type=int, help="the start frame of the video"
- )
- task_create_parser.add_argument(
- "--stop_frame", default=None, type=int, help="the stop frame of the video"
- )
- task_create_parser.add_argument(
- "--use_cache", action="store_true", help="""use cache""" # automatically sets default=False
- )
- task_create_parser.add_argument(
- "--use_zip_chunks",
- action="store_true", # automatically sets default=False
- help="""zip chunks before sending them to the server""",
- )
- task_create_parser.add_argument(
- "--cloud_storage_id",
- default=None,
- type=int,
- help="cloud storage ID if you would like to use data from cloud storage",
- )
- task_create_parser.add_argument(
- "--filename_pattern",
- type=str,
- help=textwrap.dedent(
- """\
- pattern for filtering data from the manifest file for the upload.
- Only shell-style wildcards are supported:
- * - matches everything
- ? - matches any single character
- [seq] - matches any character in 'seq'
- [!seq] - matches any character not in seq
- """
- ),
- )
-
- #######################################################################
- # Delete
- #######################################################################
- delete_parser = task_subparser.add_parser("delete", description="Delete a CVAT task.")
- delete_parser.add_argument("task_ids", type=int, help="list of task IDs", nargs="+")
-
- #######################################################################
- # List
- #######################################################################
- ls_parser = task_subparser.add_parser(
- "ls", description="List all CVAT tasks in simple or JSON format."
- )
- ls_parser.add_argument(
- "--json",
- dest="use_json_output",
- default=False,
- action="store_true",
- help="output JSON data",
- )
-
- #######################################################################
- # Frames
- #######################################################################
- frames_parser = task_subparser.add_parser(
- "frames", description="Download all frame images for a CVAT task."
- )
- frames_parser.add_argument("task_id", type=int, help="task ID")
- frames_parser.add_argument(
- "frame_ids", type=int, help="list of frame IDs to download", nargs="+"
- )
- frames_parser.add_argument(
- "--outdir", type=str, default="", help="directory to save images (default: CWD)"
- )
- frames_parser.add_argument(
- "--quality",
- type=str,
- choices=("original", "compressed"),
- default="original",
- help="choose quality of images (default: %(default)s)",
- )
-
- #######################################################################
- # Dump
- #######################################################################
- dump_parser = task_subparser.add_parser(
- "dump", description="Download annotations for a CVAT task."
- )
- dump_parser.add_argument("task_id", type=int, help="task ID")
- dump_parser.add_argument("filename", type=str, help="output file")
- dump_parser.add_argument(
- "--format",
- dest="fileformat",
- type=str,
- default="CVAT for images 1.1",
- help="annotation format (default: %(default)s)",
- )
- dump_parser.add_argument(
- "--completion_verification_period",
- dest="status_check_period",
- default=2,
- type=float,
- help="number of seconds to wait until checking if dataset building finished",
- )
- dump_parser.add_argument(
- "--with-images",
- type=to_bool,
- default=False,
- dest="include_images",
- help="Whether to include images or not (default: %(default)s)",
- )
-
- #######################################################################
- # Upload Annotations
- #######################################################################
- upload_parser = task_subparser.add_parser(
- "upload", description="Upload annotations for a CVAT task."
- )
- upload_parser.add_argument("task_id", type=int, help="task ID")
- upload_parser.add_argument("filename", type=str, help="upload file")
- upload_parser.add_argument(
- "--format",
- dest="fileformat",
- type=str,
- default="CVAT 1.1",
- help="annotation format (default: %(default)s)",
- )
-
- #######################################################################
- # Export task
- #######################################################################
- export_task_parser = task_subparser.add_parser("export", description="Export a CVAT task.")
- export_task_parser.add_argument("task_id", type=int, help="task ID")
- export_task_parser.add_argument("filename", type=str, help="output file")
- export_task_parser.add_argument(
- "--completion_verification_period",
- dest="status_check_period",
- default=2,
- type=float,
- help="time interval between checks if archive building has been finished, in seconds",
- )
-
- #######################################################################
- # Import task
- #######################################################################
- import_task_parser = task_subparser.add_parser("import", description="Import a CVAT task.")
- import_task_parser.add_argument("filename", type=str, help="upload file")
- import_task_parser.add_argument(
- "--completion_verification_period",
- dest="status_check_period",
- default=2,
- type=float,
- help="time interval between checks if archive processing was finished, in seconds",
- )
-
- #######################################################################
- # Auto-annotate
- #######################################################################
- auto_annotate_task_parser = task_subparser.add_parser(
- "auto-annotate",
- description="Automatically annotate a CVAT task by running a function on the local machine.",
- )
- auto_annotate_task_parser.add_argument("task_id", type=int, help="task ID")
-
- function_group = auto_annotate_task_parser.add_mutually_exclusive_group(required=True)
-
- function_group.add_argument(
- "--function-module",
- metavar="MODULE",
- help="qualified name of a module to use as the function",
- )
-
- function_group.add_argument(
- "--function-file",
- metavar="PATH",
- type=Path,
- help="path to a Python source file to use as the function",
- )
-
- auto_annotate_task_parser.add_argument(
- "--function-parameter",
- "-p",
- metavar="NAME=TYPE:VALUE",
- type=parse_function_parameter,
- action=BuildDictAction,
- dest="function_parameters",
- help="parameter for the function",
- )
-
- auto_annotate_task_parser.add_argument(
- "--clear-existing", action="store_true", help="Remove existing annotations from the task"
- )
-
- auto_annotate_task_parser.add_argument(
- "--allow-unmatched-labels",
- action="store_true",
- help="Allow the function to declare labels not configured in the task",
- )
-
- return parser
-
-
-def get_action_args(
- parser: argparse.ArgumentParser, parsed_args: argparse.Namespace
-) -> argparse.Namespace:
- # FIXME: a hacky way to remove unnecessary args
- action_args = dict(vars(parsed_args))
-
- for action in parser._actions:
- action_args.pop(action.dest, None)
-
- # remove default args
- for k, v in dict(action_args).items():
- if v is None:
- action_args.pop(k, None)
-
- return argparse.Namespace(**action_args)
diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py
index b2829a54b105..c176a6b233ec 100644
--- a/cvat-cli/src/cvat_cli/version.py
+++ b/cvat-cli/src/cvat_cli/version.py
@@ -1 +1 @@
-VERSION = "2.22.0"
+VERSION = "2.24.1"
diff --git a/cvat-core/package.json b/cvat-core/package.json
index 782d74c15b65..8e27f80f0b98 100644
--- a/cvat-core/package.json
+++ b/cvat-core/package.json
@@ -1,6 +1,6 @@
{
"name": "cvat-core",
- "version": "15.2.0",
+ "version": "15.3.1",
"type": "module",
"description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "src/api.ts",
diff --git a/cvat-core/src/annotations-actions.ts b/cvat-core/src/annotations-actions.ts
deleted file mode 100644
index 9e956421ae08..000000000000
--- a/cvat-core/src/annotations-actions.ts
+++ /dev/null
@@ -1,318 +0,0 @@
-// Copyright (C) 2023-2024 CVAT.ai Corporation
-//
-// SPDX-License-Identifier: MIT
-
-import { omit, throttle } from 'lodash';
-import { ArgumentError } from './exceptions';
-import { SerializedCollection, SerializedShape } from './server-response-types';
-import { Job, Task } from './session';
-import { EventScope, ObjectType } from './enums';
-import ObjectState from './object-state';
-import { getAnnotations, getCollection } from './annotations';
-import { propagateShapes } from './object-utils';
-
-export interface SingleFrameActionInput {
- collection: Omit;
- frameData: {
- width: number;
- height: number;
- number: number;
- };
-}
-
-export interface SingleFrameActionOutput {
- collection: Omit;
-}
-
-export enum ActionParameterType {
- SELECT = 'select',
- NUMBER = 'number',
-}
-
-// For SELECT values should be a list of possible options
-// For NUMBER values should be a list with [min, max, step],
-// or a callback ({ instance }: { instance: Job | Task }) => [min, max, step]
-type ActionParameters = Record string[]);
- defaultValue: string | (({ instance }: { instance: Job | Task }) => string);
-}>;
-
-export enum FrameSelectionType {
- SEGMENT = 'segment',
- CURRENT_FRAME = 'current_frame',
-}
-
-export default class BaseSingleFrameAction {
- /* eslint-disable @typescript-eslint/no-unused-vars */
- public async init(
- sessionInstance: Job | Task,
- parameters: Record,
- ): Promise {
- throw new Error('Method not implemented');
- }
-
- public async destroy(): Promise {
- throw new Error('Method not implemented');
- }
-
- public async run(sessionInstance: Job | Task, input: SingleFrameActionInput): Promise {
- throw new Error('Method not implemented');
- }
-
- public get name(): string {
- throw new Error('Method not implemented');
- }
-
- public get parameters(): ActionParameters | null {
- throw new Error('Method not implemented');
- }
-
- public get frameSelection(): FrameSelectionType {
- return FrameSelectionType.SEGMENT;
- }
-}
-
-class RemoveFilteredShapes extends BaseSingleFrameAction {
- public async init(): Promise {
- // nothing to init
- }
-
- public async destroy(): Promise {
- // nothing to destroy
- }
-
- public async run(): Promise {
- return { collection: { shapes: [] } };
- }
-
- public get name(): string {
- return 'Remove filtered shapes';
- }
-
- public get parameters(): ActionParameters | null {
- return null;
- }
-}
-
-class PropagateShapes extends BaseSingleFrameAction {
- #targetFrame: number;
-
- public async init(instance, parameters): Promise {
- this.#targetFrame = parameters['Target frame'];
- }
-
- public async destroy(): Promise {
- // nothing to destroy
- }
-
- public async run(
- instance,
- { collection: { shapes }, frameData: { number } },
- ): Promise {
- if (number === this.#targetFrame) {
- return { collection: { shapes } };
- }
- const propagatedShapes = propagateShapes(shapes, number, this.#targetFrame);
- return { collection: { shapes: [...shapes, ...propagatedShapes] } };
- }
-
- public get name(): string {
- return 'Propagate shapes';
- }
-
- public get parameters(): ActionParameters | null {
- return {
- 'Target frame': {
- type: ActionParameterType.NUMBER,
- values: ({ instance }) => {
- if (instance instanceof Job) {
- return [instance.startFrame, instance.stopFrame, 1].map((val) => val.toString());
- }
- return [0, instance.size - 1, 1].map((val) => val.toString());
- },
- defaultValue: ({ instance }) => {
- if (instance instanceof Job) {
- return instance.stopFrame.toString();
- }
- return (instance.size - 1).toString();
- },
- },
- };
- }
-
- public get frameSelection(): FrameSelectionType {
- return FrameSelectionType.CURRENT_FRAME;
- }
-}
-
-const registeredActions: BaseSingleFrameAction[] = [];
-
-export async function listActions(): Promise {
- return [...registeredActions];
-}
-
-export async function registerAction(action: BaseSingleFrameAction): Promise {
- if (!(action instanceof BaseSingleFrameAction)) {
- throw new ArgumentError('Provided action is not instance of BaseSingleFrameAction');
- }
-
- const { name } = action;
- if (registeredActions.map((_action) => _action.name).includes(name)) {
- throw new ArgumentError(`Action name must be unique. Name "${name}" is already exists`);
- }
-
- registeredActions.push(action);
-}
-
-registerAction(new RemoveFilteredShapes());
-registerAction(new PropagateShapes());
-
-async function runSingleFrameChain(
- instance: Job | Task,
- actionsChain: BaseSingleFrameAction[],
- actionParameters: Record[],
- frameFrom: number,
- frameTo: number,
- filters: string[],
- onProgress: (message: string, progress: number) => void,
- cancelled: () => boolean,
-): Promise {
- type IDsToHandle = { shapes: number[] };
- const event = await instance.logger.log(EventScope.annotationsAction, {
- from: frameFrom,
- to: frameTo,
- chain: actionsChain.map((action) => action.name).join(' => '),
- }, true);
-
- // if called too fast, it will freeze UI, so, add throttling here
- const wrappedOnProgress = throttle(onProgress, 100, { leading: true, trailing: true });
- const showMessageWithPause = async (message: string, progress: number, duration: number): Promise => {
- // wrapper that gives a chance to abort action
- wrappedOnProgress(message, progress);
- await new Promise((resolve) => setTimeout(resolve, duration));
- };
-
- try {
- await showMessageWithPause('Actions initialization', 0, 500);
- if (cancelled()) {
- return;
- }
-
- await Promise.all(actionsChain.map((action, idx) => {
- const declaredParameters = action.parameters;
- if (!declaredParameters) {
- return action.init(instance, {});
- }
-
- const setupValues = actionParameters[idx];
- const parameters = Object.entries(declaredParameters).reduce((acc, [name, { type, defaultValue }]) => {
- if (type === ActionParameterType.NUMBER) {
- acc[name] = +(Object.hasOwn(setupValues, name) ? setupValues[name] : defaultValue);
- } else {
- acc[name] = (Object.hasOwn(setupValues, name) ? setupValues[name] : defaultValue);
- }
- return acc;
- }, {} as Record);
-
- return action.init(instance, parameters);
- }));
-
- const exportedCollection = getCollection(instance).export();
- const handledCollection: SingleFrameActionInput['collection'] = { shapes: [] };
- const modifiedCollectionIDs: IDsToHandle = { shapes: [] };
-
- // Iterate over frames
- const totalFrames = frameTo - frameFrom + 1;
- for (let frame = frameFrom; frame <= frameTo; frame++) {
- const frameData = await Object.getPrototypeOf(instance).frames
- .get.implementation.call(instance, frame);
-
- // Ignore deleted frames
- if (!frameData.deleted) {
- // Get annotations according to filter
- const states: ObjectState[] = await getAnnotations(instance, frame, false, filters);
- const frameCollectionIDs = states.reduce((acc, val) => {
- if (val.objectType === ObjectType.SHAPE) {
- acc.shapes.push(val.clientID as number);
- }
- return acc;
- }, { shapes: [] });
-
- // Pick frame collection according to filtered IDs
- let frameCollection = {
- shapes: exportedCollection.shapes.filter((shape) => frameCollectionIDs
- .shapes.includes(shape.clientID as number)),
- };
-
- // Iterate over actions on each not deleted frame
- for await (const action of actionsChain) {
- ({ collection: frameCollection } = await action.run(instance, {
- collection: frameCollection,
- frameData: {
- width: frameData.width,
- height: frameData.height,
- number: frameData.number,
- },
- }));
- }
-
- const progress = Math.ceil(+(((frame - frameFrom) / totalFrames) * 100));
- wrappedOnProgress('Actions are running', progress);
- if (cancelled()) {
- return;
- }
-
- handledCollection.shapes.push(...frameCollection.shapes.map((shape) => omit(shape, 'id')));
- modifiedCollectionIDs.shapes.push(...frameCollectionIDs.shapes);
- }
- }
-
- await showMessageWithPause('Commiting handled objects', 100, 1500);
- if (cancelled()) {
- return;
- }
-
- exportedCollection.shapes.forEach((shape) => {
- if (Number.isInteger(shape.clientID) && !modifiedCollectionIDs.shapes.includes(shape.clientID as number)) {
- handledCollection.shapes.push(shape);
- }
- });
-
- await instance.annotations.clear();
- await instance.actions.clear();
- await instance.annotations.import({
- ...handledCollection,
- tracks: exportedCollection.tracks,
- tags: exportedCollection.tags,
- });
-
- event.close();
- } finally {
- wrappedOnProgress('Finalizing', 100);
- await Promise.all(actionsChain.map((action) => action.destroy()));
- }
-}
-
-export async function runActions(
- instance: Job | Task,
- actionsChain: BaseSingleFrameAction[],
- actionParameters: Record[],
- frameFrom: number,
- frameTo: number,
- filters: string[],
- onProgress: (message: string, progress: number) => void,
- cancelled: () => boolean,
-): Promise {
- // there will be another function for MultiFrameChains (actions handling tracks)
- return runSingleFrameChain(
- instance,
- actionsChain,
- actionParameters,
- frameFrom,
- frameTo,
- filters,
- onProgress,
- cancelled,
- );
-}
diff --git a/cvat-core/src/annotations-actions/annotations-actions.ts b/cvat-core/src/annotations-actions/annotations-actions.ts
new file mode 100644
index 000000000000..172b8cd88e3d
--- /dev/null
+++ b/cvat-core/src/annotations-actions/annotations-actions.ts
@@ -0,0 +1,113 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import ObjectState from '../object-state';
+import { ArgumentError } from '../exceptions';
+import { Job, Task } from '../session';
+import { BaseAction } from './base-action';
+import {
+ BaseShapesAction, run as runShapesAction, call as callShapesAction,
+} from './base-shapes-action';
+import {
+ BaseCollectionAction, run as runCollectionAction, call as callCollectionAction,
+} from './base-collection-action';
+
+import { RemoveFilteredShapes } from './remove-filtered-shapes';
+import { PropagateShapes } from './propagate-shapes';
+
+const registeredActions: BaseAction[] = [];
+
+export async function listActions(): Promise {
+ return [...registeredActions];
+}
+
+export async function registerAction(action: BaseAction): Promise {
+ if (!(action instanceof BaseAction)) {
+ throw new ArgumentError('Provided action must inherit one of base classes');
+ }
+
+ const { name } = action;
+ if (registeredActions.map((_action) => _action.name).includes(name)) {
+ throw new ArgumentError(`Action name must be unique. Name "${name}" is already exists`);
+ }
+
+ registeredActions.push(action);
+}
+
+registerAction(new RemoveFilteredShapes());
+registerAction(new PropagateShapes());
+
+export async function runAction(
+ instance: Job | Task,
+ action: BaseAction,
+ actionParameters: Record,
+ frameFrom: number,
+ frameTo: number,
+ filters: object[],
+ onProgress: (message: string, progress: number) => void,
+ cancelled: () => boolean,
+): Promise {
+ if (action instanceof BaseShapesAction) {
+ return runShapesAction(
+ instance,
+ action,
+ actionParameters,
+ frameFrom,
+ frameTo,
+ filters,
+ onProgress,
+ cancelled,
+ );
+ }
+
+ if (action instanceof BaseCollectionAction) {
+ return runCollectionAction(
+ instance,
+ action,
+ actionParameters,
+ frameFrom,
+ filters,
+ onProgress,
+ cancelled,
+ );
+ }
+
+ return Promise.resolve();
+}
+
+export async function callAction(
+ instance: Job | Task,
+ action: BaseAction,
+ actionParameters: Record,
+ frame: number,
+ states: ObjectState[],
+ onProgress: (message: string, progress: number) => void,
+ cancelled: () => boolean,
+): Promise {
+ if (action instanceof BaseShapesAction) {
+ return callShapesAction(
+ instance,
+ action,
+ actionParameters,
+ frame,
+ states,
+ onProgress,
+ cancelled,
+ );
+ }
+
+ if (action instanceof BaseCollectionAction) {
+ return callCollectionAction(
+ instance,
+ action,
+ actionParameters,
+ frame,
+ states,
+ onProgress,
+ cancelled,
+ );
+ }
+
+ return Promise.resolve();
+}
diff --git a/cvat-core/src/annotations-actions/base-action.ts b/cvat-core/src/annotations-actions/base-action.ts
new file mode 100644
index 000000000000..8a0abba4b32d
--- /dev/null
+++ b/cvat-core/src/annotations-actions/base-action.ts
@@ -0,0 +1,61 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import { SerializedCollection } from 'server-response-types';
+import ObjectState from '../object-state';
+import { Job, Task } from '../session';
+
+export enum ActionParameterType {
+ SELECT = 'select',
+ NUMBER = 'number',
+ CHECKBOX = 'checkbox',
+}
+
+// For SELECT values should be a list of possible options
+// For NUMBER values should be a list with [min, max, step],
+// or a callback ({ instance }: { instance: Job | Task }) => [min, max, step]
+export type ActionParameters = Record string[]);
+ defaultValue: string | (({ instance }: { instance: Job | Task }) => string);
+}>;
+
+export abstract class BaseAction {
+ public abstract init(sessionInstance: Job | Task, parameters: Record): Promise;
+ public abstract destroy(): Promise;
+ public abstract run(input: unknown): Promise;
+ public abstract applyFilter(input: unknown): unknown;
+ public abstract isApplicableForObject(objectState: ObjectState): boolean;
+
+ public abstract get name(): string;
+ public abstract get parameters(): ActionParameters | null;
+}
+
+export function prepareActionParameters(declared: ActionParameters, defined: object): Record {
+ if (!declared) {
+ return {};
+ }
+
+ return Object.entries(declared).reduce((acc, [name, { type, defaultValue }]) => {
+ if (type === ActionParameterType.NUMBER) {
+ acc[name] = +(Object.hasOwn(defined, name) ? defined[name] : defaultValue);
+ } else {
+ acc[name] = (Object.hasOwn(defined, name) ? defined[name] : defaultValue);
+ }
+ return acc;
+ }, {} as Record);
+}
+
+export function validateClientIDs(collection: Partial) {
+ [].concat(
+ collection.shapes ?? [],
+ collection.tracks ?? [],
+ collection.tags ?? [],
+ ).forEach((object) => {
+ // clientID is required to correct collection filtering and committing in annotations actions logic
+ if (typeof object.clientID !== 'number') {
+ throw new Error('ClientID is undefined when running annotations action, but required');
+ }
+ });
+}
diff --git a/cvat-core/src/annotations-actions/base-collection-action.ts b/cvat-core/src/annotations-actions/base-collection-action.ts
new file mode 100644
index 000000000000..f2676bbdc375
--- /dev/null
+++ b/cvat-core/src/annotations-actions/base-collection-action.ts
@@ -0,0 +1,177 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import { throttle } from 'lodash';
+
+import ObjectState from '../object-state';
+import AnnotationsFilter from '../annotations-filter';
+import { Job, Task } from '../session';
+import {
+ SerializedCollection, SerializedShape,
+ SerializedTag, SerializedTrack,
+} from '../server-response-types';
+import { EventScope, ObjectType } from '../enums';
+import { getCollection } from '../annotations';
+import { BaseAction, prepareActionParameters, validateClientIDs } from './base-action';
+
+export interface CollectionActionInput {
+ onProgress(message: string, percent: number): void;
+ cancelled(): boolean;
+ collection: Pick;
+ frameData: {
+ width: number;
+ height: number;
+ number: number;
+ };
+}
+
+export interface CollectionActionOutput {
+ created: CollectionActionInput['collection'];
+ deleted: CollectionActionInput['collection'];
+}
+
+export abstract class BaseCollectionAction extends BaseAction {
+ public abstract run(input: CollectionActionInput): Promise;
+ public abstract applyFilter(
+ input: Pick,
+ ): CollectionActionInput['collection'];
+}
+
+export async function run(
+ instance: Job | Task,
+ action: BaseCollectionAction,
+ actionParameters: Record,
+ frame: number,
+ filters: object[],
+ onProgress: (message: string, progress: number) => void,
+ cancelled: () => boolean,
+): Promise {
+ const event = await instance.logger.log(EventScope.annotationsAction, {
+ from: frame,
+ to: frame,
+ name: action.name,
+ }, true);
+
+ const wrappedOnProgress = throttle(onProgress, 100, { leading: true, trailing: true });
+ const showMessageWithPause = async (message: string, progress: number, duration: number): Promise => {
+ // wrapper that gives a chance to abort action
+ wrappedOnProgress(message, progress);
+ await new Promise((resolve) => setTimeout(resolve, duration));
+ };
+
+ try {
+ await showMessageWithPause('Action initialization', 0, 500);
+ if (cancelled()) {
+ return;
+ }
+
+ await action.init(instance, prepareActionParameters(action.parameters, actionParameters));
+
+ const frameData = await Object.getPrototypeOf(instance).frames
+ .get.implementation.call(instance, frame);
+ const exportedCollection = getCollection(instance).export();
+
+ // Apply action filter first
+ const filteredByAction = action.applyFilter({ collection: exportedCollection, frameData });
+ validateClientIDs(filteredByAction);
+
+ let mapID2Obj = [].concat(filteredByAction.shapes, filteredByAction.tags, filteredByAction.tracks)
+ .reduce((acc, object) => {
+ acc[object.clientID as number] = object;
+ return acc;
+ }, {});
+
+ // Then apply user filter
+ const annotationsFilter = new AnnotationsFilter();
+ const filteredCollectionIDs = annotationsFilter
+ .filterSerializedCollection(filteredByAction, instance.labels, filters);
+ const filteredByUser = {
+ shapes: filteredCollectionIDs.shapes.map((clientID) => mapID2Obj[clientID]),
+ tags: filteredCollectionIDs.tags.map((clientID) => mapID2Obj[clientID]),
+ tracks: filteredCollectionIDs.tracks.map((clientID) => mapID2Obj[clientID]),
+ };
+ mapID2Obj = [].concat(filteredByUser.shapes, filteredByUser.tags, filteredByUser.tracks)
+ .reduce((acc, object) => {
+ acc[object.clientID as number] = object;
+ return acc;
+ }, {});
+
+ const { created, deleted } = await action.run({
+ collection: filteredByUser,
+ frameData: {
+ width: frameData.width,
+ height: frameData.height,
+ number: frameData.number,
+ },
+ onProgress: wrappedOnProgress,
+ cancelled,
+ });
+
+ await instance.annotations.commit(created, deleted, frame);
+ event.close();
+ } finally {
+ await action.destroy();
+ }
+}
+
+export async function call(
+ instance: Job | Task,
+ action: BaseCollectionAction,
+ actionParameters: Record,
+ frame: number,
+ states: ObjectState[],
+ onProgress: (message: string, progress: number) => void,
+ cancelled: () => boolean,
+): Promise {
+ const event = await instance.logger.log(EventScope.annotationsAction, {
+ from: frame,
+ to: frame,
+ name: action.name,
+ }, true);
+
+ const throttledOnProgress = throttle(onProgress, 100, { leading: true, trailing: true });
+ try {
+ await action.init(instance, prepareActionParameters(action.parameters, actionParameters));
+ const exportedStates = await Promise.all(states.map((state) => state.export()));
+ const exportedCollection = exportedStates.reduce((acc, value, idx) => {
+ if (states[idx].objectType === ObjectType.SHAPE) {
+ acc.shapes.push(value as SerializedShape);
+ }
+
+ if (states[idx].objectType === ObjectType.TAG) {
+ acc.tags.push(value as SerializedTag);
+ }
+
+ if (states[idx].objectType === ObjectType.TRACK) {
+ acc.tracks.push(value as SerializedTrack);
+ }
+
+ return acc;
+ }, { shapes: [], tags: [], tracks: [] });
+
+ const frameData = await Object.getPrototypeOf(instance).frames.get.implementation.call(instance, frame);
+ const filteredByAction = action.applyFilter({ collection: exportedCollection, frameData });
+ validateClientIDs(filteredByAction);
+
+ const processedCollection = await action.run({
+ onProgress: throttledOnProgress,
+ cancelled,
+ collection: filteredByAction,
+ frameData: {
+ width: frameData.width,
+ height: frameData.height,
+ number: frameData.number,
+ },
+ });
+
+ await instance.annotations.commit(
+ processedCollection.created,
+ processedCollection.deleted,
+ frame,
+ );
+ event.close();
+ } finally {
+ 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
new file mode 100644
index 000000000000..e5223f085d2d
--- /dev/null
+++ b/cvat-core/src/annotations-actions/base-shapes-action.ts
@@ -0,0 +1,195 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import { throttle } from 'lodash';
+
+import ObjectState from '../object-state';
+import AnnotationsFilter from '../annotations-filter';
+import { Job, Task } from '../session';
+import { SerializedCollection, SerializedShape } from '../server-response-types';
+import { EventScope, ObjectType } from '../enums';
+import { getCollection } from '../annotations';
+import { BaseAction, prepareActionParameters, validateClientIDs } from './base-action';
+
+export interface ShapesActionInput {
+ onProgress(message: string, percent: number): void;
+ cancelled(): boolean;
+ collection: Pick;
+ frameData: {
+ width: number;
+ height: number;
+ number: number;
+ };
+}
+
+export interface ShapesActionOutput {
+ created: ShapesActionInput['collection'];
+ deleted: ShapesActionInput['collection'];
+}
+
+export abstract class BaseShapesAction extends BaseAction {
+ public abstract run(input: ShapesActionInput): Promise;
+ public abstract applyFilter(
+ input: Pick
+ ): ShapesActionInput['collection'];
+}
+
+export async function run(
+ instance: Job | Task,
+ action: BaseShapesAction,
+ actionParameters: Record,
+ frameFrom: number,
+ frameTo: number,
+ filters: object[],
+ onProgress: (message: string, progress: number) => void,
+ cancelled: () => boolean,
+): Promise {
+ const event = await instance.logger.log(EventScope.annotationsAction, {
+ from: frameFrom,
+ to: frameTo,
+ name: action.name,
+ }, true);
+
+ const throttledOnProgress = throttle(onProgress, 100, { leading: true, trailing: true });
+ const showMessageWithPause = async (message: string, progress: number, duration: number): Promise => {
+ // wrapper that gives a chance to abort action
+ throttledOnProgress(message, progress);
+ await new Promise((resolve) => setTimeout(resolve, duration));
+ };
+
+ try {
+ await showMessageWithPause('Actions initialization', 0, 500);
+ if (cancelled()) {
+ return;
+ }
+
+ await action.init(instance, prepareActionParameters(action.parameters, actionParameters));
+
+ const exportedCollection = getCollection(instance).export();
+ validateClientIDs(exportedCollection);
+
+ const annotationsFilter = new AnnotationsFilter();
+ const filteredShapeIDs = annotationsFilter.filterSerializedCollection({
+ shapes: exportedCollection.shapes,
+ tags: [],
+ tracks: [],
+ }, instance.labels, filters).shapes;
+
+ const filteredShapesByFrame = exportedCollection.shapes.reduce((acc, shape) => {
+ if (shape.frame >= frameFrom && shape.frame <= frameTo && filteredShapeIDs.includes(shape.clientID)) {
+ acc[shape.frame] = acc[shape.frame] ?? [];
+ acc[shape.frame].push(shape);
+ }
+ return acc;
+ }, {} as Record);
+
+ const totalUpdates = { created: { shapes: [] }, deleted: { shapes: [] } };
+ // Iterate over frames
+ const totalFrames = frameTo - frameFrom + 1;
+ for (let frame = frameFrom; frame <= frameTo; frame++) {
+ const frameData = await Object.getPrototypeOf(instance).frames
+ .get.implementation.call(instance, frame);
+
+ // Ignore deleted frames
+ if (!frameData.deleted) {
+ const frameShapes = filteredShapesByFrame[frame] ?? [];
+ if (!frameShapes.length) {
+ continue;
+ }
+
+ // finally apply the own filter of the action
+ const filteredByAction = action.applyFilter({
+ collection: {
+ shapes: frameShapes,
+ },
+ frameData,
+ });
+ validateClientIDs(filteredByAction);
+
+ const { created, deleted } = await action.run({
+ onProgress: throttledOnProgress,
+ cancelled,
+ collection: { shapes: filteredByAction.shapes },
+ frameData: {
+ width: frameData.width,
+ height: frameData.height,
+ number: frameData.number,
+ },
+ });
+
+ Array.prototype.push.apply(totalUpdates.created.shapes, created.shapes);
+ Array.prototype.push.apply(totalUpdates.deleted.shapes, deleted.shapes);
+
+ const progress = Math.ceil(+(((frame - frameFrom) / totalFrames) * 100));
+ throttledOnProgress('Actions are running', progress);
+ if (cancelled()) {
+ return;
+ }
+ }
+ }
+
+ await showMessageWithPause('Committing handled objects', 100, 1500);
+ if (cancelled()) {
+ return;
+ }
+
+ await instance.annotations.commit(
+ { shapes: totalUpdates.created.shapes, tags: [], tracks: [] },
+ { shapes: totalUpdates.deleted.shapes, tags: [], tracks: [] },
+ frameFrom,
+ );
+
+ event.close();
+ } finally {
+ await action.destroy();
+ }
+}
+
+export async function call(
+ instance: Job | Task,
+ action: BaseShapesAction,
+ actionParameters: Record,
+ frame: number,
+ states: ObjectState[],
+ onProgress: (message: string, progress: number) => void,
+ cancelled: () => boolean,
+): Promise {
+ const event = await instance.logger.log(EventScope.annotationsAction, {
+ from: frame,
+ to: frame,
+ name: action.name,
+ }, true);
+
+ const throttledOnProgress = throttle(onProgress, 100, { leading: true, trailing: true });
+ try {
+ await action.init(instance, prepareActionParameters(action.parameters, actionParameters));
+
+ const exported = await Promise.all(states.filter((state) => state.objectType === ObjectType.SHAPE)
+ .map((state) => state.export())) as SerializedShape[];
+ const frameData = await Object.getPrototypeOf(instance).frames.get.implementation.call(instance, frame);
+ const filteredByAction = action.applyFilter({ collection: { shapes: exported }, frameData });
+ validateClientIDs(filteredByAction);
+
+ const processedCollection = await action.run({
+ onProgress: throttledOnProgress,
+ cancelled,
+ collection: { shapes: filteredByAction.shapes },
+ frameData: {
+ width: frameData.width,
+ height: frameData.height,
+ number: frameData.number,
+ },
+ });
+
+ await instance.annotations.commit(
+ { shapes: processedCollection.created.shapes, tags: [], tracks: [] },
+ { shapes: processedCollection.deleted.shapes, tags: [], tracks: [] },
+ frame,
+ );
+
+ event.close();
+ } finally {
+ await action.destroy();
+ }
+}
diff --git a/cvat-core/src/annotations-actions/propagate-shapes.ts b/cvat-core/src/annotations-actions/propagate-shapes.ts
new file mode 100644
index 000000000000..ee68b9600f4f
--- /dev/null
+++ b/cvat-core/src/annotations-actions/propagate-shapes.ts
@@ -0,0 +1,85 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import { range } from 'lodash';
+
+import ObjectState from '../object-state';
+import { Job, Task } from '../session';
+import { SerializedShape } from '../server-response-types';
+import { propagateShapes } from '../object-utils';
+import { ObjectType } from '../enums';
+
+import { ActionParameterType, ActionParameters } from './base-action';
+import { BaseCollectionAction, CollectionActionInput, CollectionActionOutput } from './base-collection-action';
+
+export class PropagateShapes extends BaseCollectionAction {
+ #instance: Task | Job;
+ #targetFrame: number;
+
+ public async init(instance: Job | Task, parameters): Promise {
+ this.#instance = instance;
+ this.#targetFrame = parameters['Target frame'];
+ }
+
+ public async destroy(): Promise {
+ // nothing to destroy
+ }
+
+ public async run(input: CollectionActionInput): Promise {
+ const { collection, frameData: { number } } = input;
+ if (number === this.#targetFrame) {
+ return {
+ created: { shapes: [], tags: [], tracks: [] },
+ deleted: { shapes: [], tags: [], tracks: [] },
+ };
+ }
+
+ const frameNumbers = this.#instance instanceof Job ?
+ await this.#instance.frames.frameNumbers() : range(0, this.#instance.size);
+ const propagatedShapes = propagateShapes(
+ collection.shapes, number, this.#targetFrame, frameNumbers,
+ );
+
+ return {
+ created: { shapes: propagatedShapes, tags: [], tracks: [] },
+ deleted: { shapes: [], tags: [], tracks: [] },
+ };
+ }
+
+ public applyFilter(input: CollectionActionInput): CollectionActionInput['collection'] {
+ return {
+ shapes: input.collection.shapes.filter((shape) => shape.frame === input.frameData.number),
+ tags: [],
+ tracks: [],
+ };
+ }
+
+ public isApplicableForObject(objectState: ObjectState): boolean {
+ return objectState.objectType === ObjectType.SHAPE;
+ }
+
+ public get name(): string {
+ return 'Propagate shapes';
+ }
+
+ public get parameters(): ActionParameters | null {
+ return {
+ 'Target frame': {
+ type: ActionParameterType.NUMBER,
+ values: ({ instance }) => {
+ if (instance instanceof Job) {
+ return [instance.startFrame, instance.stopFrame, 1].map((val) => val.toString());
+ }
+ return [0, instance.size - 1, 1].map((val) => val.toString());
+ },
+ defaultValue: ({ instance }) => {
+ if (instance instanceof Job) {
+ return instance.stopFrame.toString();
+ }
+ return (instance.size - 1).toString();
+ },
+ },
+ };
+ }
+}
diff --git a/cvat-core/src/annotations-actions/remove-filtered-shapes.ts b/cvat-core/src/annotations-actions/remove-filtered-shapes.ts
new file mode 100644
index 000000000000..ab2a30964fad
--- /dev/null
+++ b/cvat-core/src/annotations-actions/remove-filtered-shapes.ts
@@ -0,0 +1,41 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import { BaseShapesAction, ShapesActionInput, ShapesActionOutput } from './base-shapes-action';
+import { ActionParameters } from './base-action';
+
+export class RemoveFilteredShapes extends BaseShapesAction {
+ public async init(): Promise {
+ // nothing to init
+ }
+
+ public async destroy(): Promise {
+ // nothing to destroy
+ }
+
+ public async run(input: ShapesActionInput): Promise {
+ return {
+ created: { shapes: [] },
+ deleted: input.collection,
+ };
+ }
+
+ public applyFilter(input: ShapesActionInput): ShapesActionInput['collection'] {
+ const { collection } = input;
+ return collection;
+ }
+
+ public isApplicableForObject(): boolean {
+ // remove action does not make sense when running on one object
+ return false;
+ }
+
+ public get name(): string {
+ return 'Remove filtered shapes';
+ }
+
+ public get parameters(): ActionParameters | null {
+ return null;
+ }
+}
diff --git a/cvat-core/src/annotations-collection.ts b/cvat-core/src/annotations-collection.ts
index 291fcc6c3e97..25496dfe69a7 100644
--- a/cvat-core/src/annotations-collection.ts
+++ b/cvat-core/src/annotations-collection.ts
@@ -157,9 +157,68 @@ export default class Collection {
return result;
}
- public export(): Omit {
+ public commit(
+ appended: Omit,
+ removed: Omit,
+ frame: number,
+ ): { tags: Tag[]; shapes: Shape[]; tracks: Track[]; } {
+ const isCollectionConsistent = [].concat(removed.shapes, removed.tags, removed.tracks)
+ .every((object) => typeof object.clientID === 'number' &&
+ Object.prototype.hasOwnProperty.call(this.objects, object.clientID));
+
+ if (!isCollectionConsistent) {
+ throw new ArgumentError('Objects required to be deleted were not found in the collection');
+ }
+
+ const removedCollection: (Shape | Tag | Track)[] = [].concat(removed.shapes, removed.tags, removed.tracks)
+ .map((object) => this.objects[object.clientID as number]);
+
+ const imported = this.import(appended);
+ const appendedCollection = ([] as (Shape | Tag | Track)[])
+ .concat(imported.shapes, imported.tags, imported.tracks);
+ if (!(appendedCollection.length > 0 || removedCollection.length > 0)) {
+ // nothing to commit
+ return;
+ }
+
+ let prevRemoved = [];
+ removedCollection.forEach((collectionObject) => {
+ prevRemoved.push(collectionObject.removed);
+ collectionObject.removed = true;
+ });
+
+ this.history.do(
+ HistoryActions.COMMIT_ANNOTATIONS,
+ () => {
+ removedCollection.forEach((collectionObject, idx) => {
+ collectionObject.removed = prevRemoved[idx];
+ });
+ prevRemoved = [];
+ appendedCollection.forEach((collectionObject) => {
+ collectionObject.removed = true;
+ });
+ },
+ () => {
+ removedCollection.forEach((collectionObject) => {
+ prevRemoved.push(collectionObject.removed);
+ collectionObject.removed = true;
+ });
+ appendedCollection.forEach((collectionObject) => {
+ collectionObject.removed = false;
+ });
+ },
+ [].concat(
+ removedCollection.map((object) => object.clientID),
+ appendedCollection.map((object) => object.clientID),
+ ),
+ frame,
+ );
+ }
+
+ public export(): Pick {
const data = {
- tracks: this.tracks.filter((track) => !track.removed).map((track) => track.toJSON() as SerializedTrack),
+ tracks: this.tracks.filter((track) => !track.removed)
+ .map((track) => track.toJSON() as SerializedTrack),
shapes: Object.values(this.shapes)
.reduce((accumulator, frameShapes) => {
accumulator.push(...frameShapes);
@@ -201,7 +260,7 @@ export default class Collection {
}
const objectStates = [];
- const filtered = this.annotationsFilter.filter(visible, filters);
+ const filtered = this.annotationsFilter.filterSerializedObjectStates(visible, filters);
visible.forEach((stateData) => {
if (!filters.length || filtered.includes(stateData.clientID)) {
@@ -1295,7 +1354,7 @@ export default class Collection {
const predicate = sign > 0 ? (frame) => frame <= frameTo : (frame) => frame >= frameTo;
const update = sign > 0 ? (frame) => frame + 1 : (frame) => frame - 1;
- // if not looking for an emty frame nor frame with annotations, return the next frame
+ // if not looking for an empty frame nor frame with annotations, return the next frame
// check if deleted frames are allowed additionally
if (!annotationsFilters) {
let frame = frameFrom;
@@ -1338,7 +1397,7 @@ export default class Collection {
statesData.push(...tracks.map((track) => track.get(frame)).filter((state) => !state.outside));
// Filtering
- const filtered = this.annotationsFilter.filter(statesData, annotationsFilters);
+ const filtered = this.annotationsFilter.filterSerializedObjectStates(statesData, annotationsFilters);
if (filtered.length) {
return frame;
}
diff --git a/cvat-core/src/annotations-filter.ts b/cvat-core/src/annotations-filter.ts
index 58c9e82a63e5..fa7b8e739f5a 100644
--- a/cvat-core/src/annotations-filter.ts
+++ b/cvat-core/src/annotations-filter.ts
@@ -6,15 +6,74 @@
import jsonLogic from 'json-logic-js';
import { SerializedData } from './object-state';
import { AttributeType, ObjectType, ShapeType } from './enums';
+import { SerializedCollection } from './server-response-types';
+import { Attribute, Label } from './labels';
function adjustName(name): string {
return name.replace(/\./g, '\u2219');
}
+function getDimensions(points: number[], shapeType: ShapeType): {
+ width: number | null;
+ height: number | null;
+} {
+ let [width, height]: (number | null)[] = [null, null];
+ if (shapeType === ShapeType.MASK) {
+ const [xtl, ytl, xbr, ybr] = points.slice(-4);
+ [width, height] = [xbr - xtl + 1, ybr - ytl + 1];
+ } else if (shapeType === ShapeType.ELLIPSE) {
+ const [cx, cy, rightX, topY] = points;
+ width = Math.abs(rightX - cx) * 2;
+ height = Math.abs(cy - topY) * 2;
+ } else {
+ let xtl = Number.MAX_SAFE_INTEGER;
+ let xbr = Number.MIN_SAFE_INTEGER;
+ let ytl = Number.MAX_SAFE_INTEGER;
+ let ybr = Number.MIN_SAFE_INTEGER;
+
+ points.forEach((coord, idx) => {
+ if (idx % 2) {
+ // y
+ ytl = Math.min(ytl, coord);
+ ybr = Math.max(ybr, coord);
+ } else {
+ // x
+ xtl = Math.min(xtl, coord);
+ xbr = Math.max(xbr, coord);
+ }
+ });
+ [width, height] = [xbr - xtl, ybr - ytl];
+ }
+
+ return {
+ width,
+ height,
+ };
+}
+
+function convertAttribute(id: number, value: string, attributesSpec: Record): [
+ string,
+ number | boolean | string,
+] {
+ const spec = attributesSpec[id];
+ const name = adjustName(spec.name);
+ if (spec.inputType === AttributeType.NUMBER) {
+ return [name, +value];
+ }
+
+ if (spec.inputType === AttributeType.CHECKBOX) {
+ return [name, value === 'true'];
+ }
+
+ return [name, value];
+}
+
+type ConvertedAttributes = Record;
+
interface ConvertedObjectData {
width: number | null;
height: number | null;
- attr: Record>;
+ attr: Record;
label: string;
serverID: number;
objectID: number;
@@ -24,7 +83,7 @@ interface ConvertedObjectData {
}
export default class AnnotationsFilter {
- _convertObjects(statesData: SerializedData[]): ConvertedObjectData[] {
+ private _convertSerializedObjectStates(statesData: SerializedData[]): ConvertedObjectData[] {
const objects = statesData.map((state) => {
const labelAttributes = state.label.attributes.reduce((acc, attr) => {
acc[attr.id] = attr;
@@ -33,50 +92,26 @@ export default class AnnotationsFilter {
let [width, height]: (number | null)[] = [null, null];
if (state.objectType !== ObjectType.TAG) {
- if (state.shapeType === ShapeType.MASK) {
- const [xtl, ytl, xbr, ybr] = state.points.slice(-4);
- [width, height] = [xbr - xtl + 1, ybr - ytl + 1];
- } else {
- let xtl = Number.MAX_SAFE_INTEGER;
- let xbr = Number.MIN_SAFE_INTEGER;
- let ytl = Number.MAX_SAFE_INTEGER;
- let ybr = Number.MIN_SAFE_INTEGER;
-
- const points = state.points || state.elements.reduce((acc, val) => {
- acc.push(val.points);
- return acc;
- }, []).flat();
- points.forEach((coord, idx) => {
- if (idx % 2) {
- // y
- ytl = Math.min(ytl, coord);
- ybr = Math.max(ybr, coord);
- } else {
- // x
- xtl = Math.min(xtl, coord);
- xbr = Math.max(xbr, coord);
- }
- });
- [width, height] = [xbr - xtl, ybr - ytl];
- }
+ const points = state.shapeType === ShapeType.SKELETON ? state.elements.reduce((acc, val) => {
+ acc.push(val.points);
+ return acc;
+ }, []).flat() : state.points;
+
+ ({ width, height } = getDimensions(points, state.shapeType as ShapeType));
}
- const attributes = Object.keys(state.attributes).reduce>((acc, key) => {
- const attr = labelAttributes[key];
- let value = state.attributes[key];
- if (attr.inputType === AttributeType.NUMBER) {
- value = +value;
- } else if (attr.inputType === AttributeType.CHECKBOX) {
- value = value === 'true';
- }
- acc[adjustName(attr.name)] = value;
+ const attributes = Object.keys(state.attributes).reduce((acc, key) => {
+ const [name, value] = convertAttribute(+key, state.attributes[key], labelAttributes);
+ acc[name] = value;
return acc;
- }, {});
+ }, {} as Record);
return {
width,
height,
- attr: Object.fromEntries([[adjustName(state.label.name), attributes]]),
+ attr: {
+ [adjustName(state.label.name)]: attributes,
+ },
label: state.label.name,
serverID: state.serverID,
objectID: state.clientID,
@@ -89,11 +124,119 @@ export default class AnnotationsFilter {
return objects;
}
- filter(statesData: SerializedData[], filters: object[]): number[] {
- if (!filters.length) return statesData.map((stateData): number => stateData.clientID);
- const converted = this._convertObjects(statesData);
+ private _convertSerializedCollection(
+ collection: Omit,
+ labelsSpec: Label[],
+ ): { shapes: ConvertedObjectData[]; tags: ConvertedObjectData[]; tracks: ConvertedObjectData[]; } {
+ const labelByID = labelsSpec.reduce>((acc, label) => ({
+ [label.id]: label,
+ ...acc,
+ }), {});
+
+ const attributeById = labelsSpec.map((label) => label.attributes).flat().reduce((acc, attribute) => ({
+ ...acc,
+ [attribute.id]: attribute,
+ }), {} as Record);
+
+ const convertAttributes = (
+ attributes: SerializedCollection['shapes'][0]['attributes'],
+ ): ConvertedAttributes => attributes.reduce((acc, { spec_id, value }) => {
+ const [name, adjustedValue] = convertAttribute(spec_id, value, attributeById);
+ acc[name] = adjustedValue;
+ return acc;
+ }, {} as Record);
+
+ return {
+ shapes: collection.shapes.map((shape) => {
+ const label = labelByID[shape.label_id];
+ const points = shape.type === ShapeType.SKELETON ?
+ shape.elements.map((el) => el.points).flat() : shape.points;
+ let [width, height]: (number | null)[] = [null, null];
+ ({ width, height } = getDimensions(points, shape.type));
+
+ return {
+ width,
+ height,
+ attr: {
+ [adjustName(label.name)]: convertAttributes(shape.attributes),
+ },
+ label: label.name,
+ serverID: shape.id ?? null,
+ type: ObjectType.SHAPE,
+ shape: shape.type,
+ occluded: shape.occluded,
+ objectID: shape.clientID ?? null,
+ };
+ }),
+ tags: collection.tags.map((tag) => {
+ const label = labelByID[tag.label_id];
+
+ return {
+ width: null,
+ height: null,
+ attr: {
+ [adjustName(label.name)]: convertAttributes(tag.attributes),
+ },
+ label: labelByID[tag.label_id]?.name ?? null,
+ serverID: tag.id ?? null,
+ type: ObjectType.SHAPE,
+ shape: null,
+ occluded: false,
+ objectID: tag.clientID ?? null,
+ };
+ }),
+ tracks: collection.tracks.map((track) => {
+ const label = labelByID[track.label_id];
+
+ return {
+ width: null,
+ height: null,
+ attr: {
+ [adjustName(label.name)]: convertAttributes(track.attributes),
+ },
+ label: labelByID[track.label_id]?.name ?? null,
+ serverID: track.id,
+ type: ObjectType.TRACK,
+ shape: track.shapes[0]?.type ?? null,
+ occluded: null,
+ objectID: track.clientID ?? null,
+ };
+ }),
+ };
+ }
+
+ public filterSerializedObjectStates(statesData: SerializedData[], filters: object[]): number[] {
+ if (!filters.length) {
+ return statesData.map((stateData): number => stateData.clientID);
+ }
+
+ const converted = this._convertSerializedObjectStates(statesData);
return converted
.map((state) => state.objectID)
.filter((_, index) => jsonLogic.apply(filters[0], converted[index]));
}
+
+ public filterSerializedCollection(
+ collection: Omit,
+ labelsSpec: Label[],
+ filters: object[],
+ ): { shapes: number[]; tags: number[]; tracks: number[]; } {
+ if (!filters.length) {
+ return {
+ shapes: collection.shapes.map((shape) => shape.clientID),
+ tags: collection.tags.map((tag) => tag.clientID),
+ tracks: collection.tracks.map((track) => track.clientID),
+ };
+ }
+
+ const converted = this._convertSerializedCollection(collection, labelsSpec);
+ return {
+ shapes: converted.shapes.map((shape) => shape.objectID)
+ .filter((_, index) => jsonLogic.apply(filters[0], converted.shapes[index])),
+ tags: converted.tags.map((shape) => shape.objectID)
+ .filter((_, index) => jsonLogic.apply(filters[0], converted.tags[index])),
+ tracks: converted.tracks.map((shape) => shape.objectID)
+ .filter((_, index) => jsonLogic.apply(filters[0], converted.tracks[index])),
+ };
+ }
}
diff --git a/cvat-core/src/annotations-history.ts b/cvat-core/src/annotations-history.ts
index 748d55bcf93d..2e59db96ea1f 100644
--- a/cvat-core/src/annotations-history.ts
+++ b/cvat-core/src/annotations-history.ts
@@ -5,7 +5,7 @@
import { HistoryActions } from './enums';
-const MAX_HISTORY_LENGTH = 128;
+const MAX_HISTORY_LENGTH = 32;
interface ActionItem {
action: HistoryActions;
diff --git a/cvat-core/src/annotations-objects.ts b/cvat-core/src/annotations-objects.ts
index defcf7dbbada..ab7e32de9784 100644
--- a/cvat-core/src/annotations-objects.ts
+++ b/cvat-core/src/annotations-objects.ts
@@ -150,17 +150,12 @@ class Annotation {
injection.groups.max = Math.max(injection.groups.max, this.group);
}
- protected withContext(frame: number): {
- __internal: {
- save: (data: ObjectState) => ObjectState;
- delete: Annotation['delete'];
- };
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ protected withContext(_: number): {
+ delete: Annotation['delete'];
} {
return {
- __internal: {
- save: (this as any).save.bind(this, frame),
- delete: this.delete.bind(this),
- },
+ delete: this.delete.bind(this),
};
}
@@ -530,6 +525,17 @@ export class Shape extends Drawn {
this.zOrder = data.z_order;
}
+ protected withContext(frame: number): ReturnType & {
+ save: (data: ObjectState) => ObjectState;
+ export: () => SerializedShape;
+ } {
+ return {
+ ...super.withContext(frame),
+ save: this.save.bind(this, frame),
+ export: this.toJSON.bind(this) as () => SerializedShape,
+ };
+ }
+
// Method is used to export data to the server
public toJSON(): SerializedShape | SerializedShape['elements'][0] {
const result: SerializedShape = {
@@ -592,7 +598,7 @@ export class Shape extends Drawn {
pinned: this.pinned,
frame,
source: this.source,
- ...this.withContext(frame),
+ __internal: this.withContext(frame),
};
if (typeof this.outside !== 'undefined') {
@@ -838,6 +844,17 @@ export class Track extends Drawn {
}, {});
}
+ protected withContext(frame: number): ReturnType & {
+ save: (data: ObjectState) => ObjectState;
+ export: () => SerializedTrack;
+ } {
+ return {
+ ...super.withContext(frame),
+ save: this.save.bind(this, frame),
+ export: this.toJSON.bind(this) as () => SerializedTrack,
+ };
+ }
+
// Method is used to export data to the server
public toJSON(): SerializedTrack | SerializedTrack['elements'][0] {
const labelAttributes = attrsAsAnObject(this.label.attributes);
@@ -931,7 +948,7 @@ export class Track extends Drawn {
},
frame,
source: this.source,
- ...this.withContext(frame),
+ __internal: this.withContext(frame),
};
}
@@ -1405,6 +1422,17 @@ export class Track extends Drawn {
}
export class Tag extends Annotation {
+ protected withContext(frame: number): ReturnType & {
+ save: (data: ObjectState) => ObjectState;
+ export: () => SerializedTag;
+ } {
+ return {
+ ...super.withContext(frame),
+ save: this.save.bind(this, frame),
+ export: this.toJSON.bind(this) as () => SerializedTag,
+ };
+ }
+
// Method is used to export data to the server
public toJSON(): SerializedTag {
const result: SerializedTag = {
@@ -1451,7 +1479,7 @@ export class Tag extends Annotation {
updated: this.updated,
frame,
source: this.source,
- ...this.withContext(frame),
+ __internal: this.withContext(frame),
};
}
@@ -2022,7 +2050,7 @@ export class SkeletonShape extends Shape {
hidden: elements.every((el) => el.hidden),
frame,
source: this.source,
- ...this.withContext(frame),
+ __internal: this.withContext(frame),
};
}
@@ -3064,7 +3092,7 @@ export class SkeletonTrack extends Track {
occluded: elements.every((el) => el.occluded),
lock: elements.every((el) => el.lock),
hidden: elements.every((el) => el.hidden),
- ...this.withContext(frame),
+ __internal: this.withContext(frame),
};
}
diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts
index 0e9f400ad499..c9e53a2e1e0d 100644
--- a/cvat-core/src/api-implementation.ts
+++ b/cvat-core/src/api-implementation.ts
@@ -39,7 +39,9 @@ import QualityConflict, { ConflictSeverity } from './quality-conflict';
import QualitySettings from './quality-settings';
import { getFramesMeta } from './frames';
import AnalyticsReport from './analytics-report';
-import { listActions, registerAction, runActions } from './annotations-actions';
+import {
+ callAction, listActions, registerAction, runAction,
+} from './annotations-actions/annotations-actions';
import { convertDescriptions, getServerAPISchema } from './server-schema';
import { JobType } from './enums';
import { PaginatedResource } from './core-types';
@@ -54,7 +56,8 @@ export default function implementAPI(cvat: CVATCore): CVATCore {
implementationMixin(cvat.plugins.register, PluginRegistry.register.bind(cvat));
implementationMixin(cvat.actions.list, listActions);
implementationMixin(cvat.actions.register, registerAction);
- implementationMixin(cvat.actions.run, runActions);
+ implementationMixin(cvat.actions.run, runAction);
+ implementationMixin(cvat.actions.call, callAction);
implementationMixin(cvat.lambda.list, lambdaManager.list.bind(lambdaManager));
implementationMixin(cvat.lambda.run, lambdaManager.run.bind(lambdaManager));
diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts
index 5f624ad0e8ae..60de43fd4b18 100644
--- a/cvat-core/src/api.ts
+++ b/cvat-core/src/api.ts
@@ -21,12 +21,14 @@ import CloudStorage from './cloud-storage';
import Organization from './organization';
import Webhook from './webhook';
import AnnotationGuide from './guide';
-import BaseSingleFrameAction from './annotations-actions';
+import { BaseAction } from './annotations-actions/base-action';
+import { BaseCollectionAction } from './annotations-actions/base-collection-action';
+import { BaseShapesAction } from './annotations-actions/base-shapes-action';
import QualityReport from './quality-report';
import QualityConflict from './quality-conflict';
import QualitySettings from './quality-settings';
import AnalyticsReport from './analytics-report';
-import ValidationLayout from './validation-layout';
+import { JobValidationLayout, TaskValidationLayout } from './validation-layout';
import { Request } from './request';
import * as enums from './enums';
@@ -191,14 +193,14 @@ function build(): CVATCore {
const result = await PluginRegistry.apiWrapper(cvat.actions.list);
return result;
},
- async register(action: BaseSingleFrameAction) {
+ async register(action: BaseAction) {
const result = await PluginRegistry.apiWrapper(cvat.actions.register, action);
return result;
},
async run(
instance: Job | Task,
- actionsChain: BaseSingleFrameAction[],
- actionsParameters: Record[],
+ actions: BaseAction,
+ actionsParameters: Record,
frameFrom: number,
frameTo: number,
filters: string[],
@@ -211,7 +213,7 @@ function build(): CVATCore {
const result = await PluginRegistry.apiWrapper(
cvat.actions.run,
instance,
- actionsChain,
+ actions,
actionsParameters,
frameFrom,
frameTo,
@@ -221,6 +223,30 @@ function build(): CVATCore {
);
return result;
},
+ async call(
+ instance: Job | Task,
+ actions: BaseAction,
+ actionsParameters: Record,
+ frame: number,
+ states: ObjectState[],
+ onProgress: (
+ message: string,
+ progress: number,
+ ) => void,
+ cancelled: () => boolean,
+ ) {
+ const result = await PluginRegistry.apiWrapper(
+ cvat.actions.call,
+ instance,
+ actions,
+ actionsParameters,
+ frame,
+ states,
+ onProgress,
+ cancelled,
+ );
+ return result;
+ },
},
lambda: {
async list() {
@@ -294,6 +320,12 @@ function build(): CVATCore {
set requestsStatusDelay(value) {
config.requestsStatusDelay = value;
},
+ get jobMetaDataReloadPeriod() {
+ return config.jobMetaDataReloadPeriod;
+ },
+ set jobMetaDataReloadPeriod(value) {
+ config.jobMetaDataReloadPeriod = value;
+ },
},
client: {
version: `${pjson.version}`,
@@ -420,14 +452,16 @@ function build(): CVATCore {
Organization,
Webhook,
AnnotationGuide,
- BaseSingleFrameAction,
+ BaseShapesAction,
+ BaseCollectionAction,
QualitySettings,
AnalyticsReport,
QualityConflict,
QualityReport,
Request,
FramesMetaData,
- ValidationLayout,
+ JobValidationLayout,
+ TaskValidationLayout,
},
utils: {
mask2Rle,
diff --git a/cvat-core/src/cloud-storage.ts b/cvat-core/src/cloud-storage.ts
index e4e4fb0e5d23..1e7cdeb8d7f7 100644
--- a/cvat-core/src/cloud-storage.ts
+++ b/cvat-core/src/cloud-storage.ts
@@ -290,7 +290,7 @@ Object.defineProperties(CloudStorage.prototype.save, {
}
// update
if (typeof this.id !== 'undefined') {
- // provider_type and recource should not change;
+ // provider_type and resource should not change;
// send to the server only the values that have changed
const initialData: SerializedCloudStorage = {};
if (this.displayName) {
diff --git a/cvat-core/src/config.ts b/cvat-core/src/config.ts
index 99d76a723655..eefb535814bb 100644
--- a/cvat-core/src/config.ts
+++ b/cvat-core/src/config.ts
@@ -19,6 +19,8 @@ const config = {
globalObjectsCounter: 0,
requestsStatusDelay: null,
+
+ jobMetaDataReloadPeriod: 1 * 60 * 60 * 1000, // 1 hour
};
export default config;
diff --git a/cvat-core/src/core-types.ts b/cvat-core/src/core-types.ts
index e44a354cb5bd..c05b7b6ba4a5 100644
--- a/cvat-core/src/core-types.ts
+++ b/cvat-core/src/core-types.ts
@@ -2,7 +2,9 @@
//
// SPDX-License-Identifier: MIT
-import { ModelKind, ModelReturnType, ShapeType } from './enums';
+import {
+ ModelKind, ModelReturnType, RQStatus, ShapeType,
+} from './enums';
export interface ModelAttribute {
name: string;
@@ -54,4 +56,10 @@ export interface SerializedModel {
updated_date?: string;
}
+export interface UpdateStatusData {
+ status: RQStatus;
+ progress: number;
+ message: string;
+}
+
export type PaginatedResource = T[] & { count: number };
diff --git a/cvat-core/src/enums.ts b/cvat-core/src/enums.ts
index 1b291662d213..25fdf815fa20 100644
--- a/cvat-core/src/enums.ts
+++ b/cvat-core/src/enums.ts
@@ -148,6 +148,7 @@ export enum HistoryActions {
REMOVED_OBJECT = 'Removed object',
REMOVED_FRAME = 'Removed frame',
RESTORED_FRAME = 'Restored frame',
+ COMMIT_ANNOTATIONS = 'Commit annotations',
}
export enum ModelKind {
diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts
index 1192058c11b3..3305edfc5aab 100644
--- a/cvat-core/src/frames.ts
+++ b/cvat-core/src/frames.ts
@@ -12,6 +12,7 @@ import serverProxy from './server-proxy';
import { SerializedFramesMetaData } from './server-response-types';
import { Exception, ArgumentError, DataError } from './exceptions';
import { FieldUpdateTrigger } from './common';
+import config from './config';
// frame storage by job id
const frameDataCache: Record this.getJobRelativeFrameNumber(frame) + jobStartFrame);
+ }
+
getDataFrameNumbers(): number[] {
if (this.includedFrames) {
return [...this.includedFrames];
@@ -348,9 +354,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', {
const requestId = +_.uniqueId();
const requestedDataFrameNumber = meta.getDataFrameNumber(this.number - jobStartFrame);
const chunkIndex = meta.getFrameChunkIndex(requestedDataFrameNumber);
- const segmentFrameNumbers = meta.getDataFrameNumbers().map((dataFrameNumber: number) => (
- meta.getJobRelativeFrameNumber(dataFrameNumber) + jobStartFrame
- ));
+ const segmentFrameNumbers = meta.getSegmentFrameNumbers(jobStartFrame);
const frame = provider.frame(this.number);
function findTheNextNotDecodedChunk(currentFrameIndex: number): number | null {
@@ -532,41 +536,55 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', {
writable: false,
});
-export async function getFramesMeta(type: 'job' | 'task', id: number, forceReload = false): Promise {
+export function getFramesMeta(type: 'job' | 'task', id: number, forceReload = false): Promise {
if (type === 'task') {
// we do not cache task meta currently. So, each new call will results to the server request
- const result = await serverProxy.frames.getMeta('task', id);
- return new FramesMetaData({
- ...result,
- deleted_frames: Object.fromEntries(result.deleted_frames.map((_frame) => [_frame, true])),
- });
+ return serverProxy.frames.getMeta('task', id).then((serialized) => (
+ new FramesMetaData({
+ ...serialized,
+ deleted_frames: Object.fromEntries(serialized.deleted_frames.map((_frame) => [_frame, true])),
+ })
+ ));
}
+
if (!(id in frameMetaCache) || forceReload) {
- frameMetaCache[id] = serverProxy.frames.getMeta('job', id)
- .then((serverMeta) => new FramesMetaData({
- ...serverMeta,
- deleted_frames: Object.fromEntries(serverMeta.deleted_frames.map((_frame) => [_frame, true])),
- }))
- .catch((error) => {
+ const previousCache = frameMetaCache[id];
+ frameMetaCache[id] = new Promise((resolve, reject) => {
+ serverProxy.frames.getMeta('job', id).then((serialized) => {
+ const framesMetaData = new FramesMetaData({
+ ...serialized,
+ deleted_frames: Object.fromEntries(serialized.deleted_frames.map((_frame) => [_frame, true])),
+ });
+ resolve(framesMetaData);
+ }).catch((error: unknown) => {
delete frameMetaCache[id];
- throw error;
+ if (previousCache instanceof Promise) {
+ frameMetaCache[id] = previousCache;
+ }
+ reject(error);
});
+ });
}
+
return frameMetaCache[id];
}
-async function saveJobMeta(meta: FramesMetaData, jobID: number): Promise {
- frameMetaCache[jobID] = serverProxy.frames.saveMeta('job', jobID, {
- deleted_frames: Object.keys(meta.deletedFrames).map((frame) => +frame),
- })
- .then((serverMeta) => new FramesMetaData({
- ...serverMeta,
- deleted_frames: Object.fromEntries(serverMeta.deleted_frames.map((_frame) => [_frame, true])),
- }))
- .catch((error) => {
- delete frameMetaCache[jobID];
- throw error;
+function saveJobMeta(meta: FramesMetaData, jobID: number): Promise {
+ frameMetaCache[jobID] = new Promise((resolve, reject) => {
+ serverProxy.frames.saveMeta('job', jobID, {
+ deleted_frames: Object.keys(meta.deletedFrames).map((frame) => +frame),
+ }).then((serverMeta) => {
+ const updatedMetaData = new FramesMetaData({
+ ...serverMeta,
+ deleted_frames: Object.fromEntries(serverMeta.deleted_frames.map((_frame) => [_frame, true])),
+ });
+ resolve(updatedMetaData);
+ }).catch((error) => {
+ frameMetaCache[jobID] = Promise.resolve(meta);
+ reject(error);
});
+ });
+
return frameMetaCache[jobID];
}
@@ -594,8 +612,7 @@ async function refreshJobCacheIfOutdated(jobID: number): Promise {
throw new Error('Frame data cache is abscent');
}
- const META_DATA_RELOAD_PERIOD = 1 * 60 * 60 * 1000; // 1 hour
- const isOutdated = (Date.now() - cached.metaFetchedTimestamp) > META_DATA_RELOAD_PERIOD;
+ const isOutdated = (Date.now() - cached.metaFetchedTimestamp) > config.jobMetaDataReloadPeriod;
if (isOutdated) {
// get metadata again if outdated
@@ -683,7 +700,7 @@ export function getContextImage(jobID: number, frame: number): Promise